C++模板实现通用二叉搜索树:集合与映射二合一
最近整理手头几个内部组件发现凡是做索引、做去重、做有序集合最后都绕不开同一棵树。本来想直接调库但又需要一份不依赖外部框架的跨平台版本于是把二叉搜索树从裸模板到支持 key-value 重新写了一遍。你要是有过类似经历肯定能get到这类需求的尴尬面试题里的二叉搜索树只存一个 int但实际用的时候要么需要判断某个 key 在不在集合里要么需要根据 key 拿出对应的 value甚至要同时支持这两种形态。这篇文章就围绕这个核心矛盾展开。我会用一套 C 模板把 BST 设计成既能当“有序集合”用只存 key也能当“有序映射”用存 key-value 对重点讲清楚存储层怎么抽象、比较键怎么统一提取、插入删除有哪些坑。内容面向有 C 基础但没系统写过数据结构的读者同时也会给出一份可以直接编译运行的代码骨架。1. 先拆需求这套树到底要给谁用1.1 三种真实使用场景第一类场景是白名单或者去重。最常见的就是维护一批用户 ID、设备序列号、字符串黑名单只关心“某个 key 是否存在”value 完全用不上。这时候树的节点里只存一个 key相当于标准库里std::set做的事。第二类场景是配置项映射、词频统计、缓存表这一类。除了需要判断 key 是否存在还要从 key 找到对应的 value比如从端口号找到进程句柄、从指令名字找到处理函数。这是std::map的职责。第三类场景是有序区间查询。比如按时间戳取出 2024-03-01 到 2024-03-07 之间的所有记录或者按分数范围筛出一批候选人。二叉搜索树的中序遍历天然有序做这种范围扫描比哈希表顺滑得多。这三类需求并不冲突可以收敛到同一棵树上。问题是怎么用一套模板类把“只存 key”和“存 key-value 对”两种模式统一起来而不是复制粘贴两个差不多的类。1.2 存储层设计的三个候选方案方案一是节点直接存std::pairKey, Valuevalue 用不上的时候传一个空类型占位。优点是实现最简单所有节点的结构都一样缺点是空 value 也占了内存接口层面还得暴露一个毫无意义的“value 字段”相当别扭。方案二是给BSTKey, Value做模板特化单独写一版BSTKey, void。这样内存和接口都干净但问题也很现实两个特化版本需要各自维护插入、删除、查找、遍历一套代码改一个小逻辑要同步改两处漏改一处就会行为不一致。方案三是我最后采用的思路用std::conditional在编译期根据Value是不是void决定节点存储类型然后用 C17 的if constexpr在函数里区分不同逻辑。一套类两套行为存储和接口都干净。方案存储开销接口清晰度代码复用标准要求节点存pairKey, Valuekey-only 时浪费差最好C98模板特化两个版本无浪费好差C98std::conditionalif constexpr无浪费好好C171.3 最终选型与版本说明我选择方案三原因只有一条这一类树结构算法骨架占大头真正不同的只是“节点里存什么”。把这个差异点收口到一个类型别名和几个辅助函数里后面的插入、查找、删除几乎可以原样复用。所以本文代码默认 C17。如果你还在用 C11最接近的替代做法是做两个特化类或者写一个宏把公共部分摊开但那就偏离今天的主题了。2. 模板结构设计一套代码兼容两种存储2.1 用std::conditional决定存储类型核心就一句话让编译器当你的搬运营。模板签名写成template typename Key, typename Value void默认情况下它就是一棵只存 key 的树需要 key-value 时传入具体的 value 类型。内部先定义编译期常量static constexpr bool kIsVoidValue std::is_voidValue::value;然后定义存储类型using Storage typename std::conditional kIsVoidValue, Key, std::pairKey, Value ::type;std::conditional相当于一个编译期三元表达式条件为真就用第一个类型为假就用第二个类型。当Value是void时Storage 就是Key节点只存键当Value是std::string之类时Storage 就变成std::pairint, std::string节点同时持有键和值。这就解决了“支持 key 或 key-value”的第一层问题存储结构本身随着模板参数自动切换。2.2 节点定义与统一键提取有了 Storage节点可以这样定义struct Node { Storage data; Node* left nullptr; Node* right nullptr; explicit Node(const Storage d) : data(d) {} };光有节点还不够。插入、删除、查找、遍历四处都要用到“从存储内容里提取 key 做比较”。如果每次都在代码里写if constexpr判断当前模式逻辑会重复且容易出错。所以我把取 key 这件事收口成一个静态函数static const Key key_of(const Storage item) { if constexpr (kIsVoidValue) { return item; } else { return item.first; } }这个函数是整棵树的“比较接口”。key-only 模式它原样返回元素本身key-value 模式它取 pair 的 first 字段。后续所有比较逻辑都只跟key_of打交道不再关心当前是哪种存储模式。这也是我给所有写树类的人的第一条建议先定好“键怎么从节点数据里取出来”再写算法。这个接口一旦稳定算法部分就不会被模板参数干扰。2.3 比较器怎么设计比较器我直接暴露成模板参数默认用std::lessKeytemplate typename Key, typename Value void, typename Compare std::lessKey class BST { ... };为什么不用std::pair自带的比较因为std::pair的operator是先比 first 再比 second如果我们让节点直接存 pair 并天然用 pair 做比较会出现两个问题。第一同一个Key对应不同Value时字典序比较可能误判比如key相同而value不同的两个节点会被当成“大于”或“小于”而不是“相等”。这会导致重复键无法正确合并。第二key-only 模式下根本没 value 可比较。所以把模板参数设计成 Key 和 Value 分离而不是让用户传入一个整体对象再从中提取比较键正是为了避免这种误用。如果某个Key类型本身没有operator也没关系调用者可以传入自定义仿函数struct CaseInsensitiveCompare { bool operator()(const std::string a, const std::string b) const { return strcasecmp(a.c_str(), b.c_str()) 0; } }; BSTstd::string, void, CaseInsensitiveCompare words;这样一来“如何比较”和“存储什么”完全解耦树的排序规则由调用方决定内部逻辑完全不用动。2.4 完整类骨架预览把上面的决策串起来类的大体框架就是这样#include iostream #include functional #include type_traits #include utility template typename Key, typename Value void, typename Compare std::lessKey class BST { static constexpr bool kIsVoidValue std::is_voidValue::value; using Storage typename std::conditional kIsVoidValue, Key, std::pairKey, Value ::type; struct Node { Storage data; Node* left nullptr; Node* right nullptr; explicit Node(const Storage d) : data(d) {} }; Node* root_ nullptr; Compare cmp_; static const Key key_of(const Storage item) { if constexpr (kIsVoidValue) { return item; } else { return item.first; } } public: ~BST() { // 后续会给出非递归析构 } void insert(const Key key) { ... } void insert(const Key key, const Value value) { ... } bool contains(const Key key) const { ... } Value* findValue(const Key key) { ... } void erase(const Key key) { ... } void inOrder() const { ... } };到这里模板设计的核心已经完成了。接下来的插入、查找、删除就是在这个存储抽象之上填肉。3. 核心操作实现插入、查找、删除3.1 插入接口与重复键策略插入分成两个对外接口。key-only 模式调insert(const Key)key-value 模式调insert(const Key, const Value)。void insert(const Key key) { static_assert(kIsVoidValue, 仅 key-only 模式可调用单参数 insert); Storage data key; root_ insertRecursive(root_, data); } void insert(const Key key, const Value value) { static_assert(!kIsVoidValue, 仅 key-value 模式可调用双参数 insert); Storage data std::make_pair(key, value); root_ insertRecursive(root_, data); }static_assert的作用是在编译期拦住错误调用。如果你拿一棵纯 key 树去当 map 用编译器会直接报错而不是等运行时崩。真正的递归插入逻辑长这样Node* insertRecursive(Node* node, const Storage data) { if (!node) { return new Node(data); } const Key targetKey key_of(data); const Key nodeKey key_of(node-data); if (cmp_(targetKey, nodeKey)) { node-left insertRecursive(node-left, data); } else if (cmp_(nodeKey, targetKey)) { node-right insertRecursive(node-right, data); } else { if constexpr (!kIsVoidValue) { node-data.second data.second; } } return node; }这段代码最有价值的地方是“父节点回填法”递归函数返回的是“处理完后的子树根”调用方把它接到自己的 left 或 right 上。你不需要在节点里保存 parent 指针也不用在迭代循环里勤勤恳恳记录父节点整个插入过程非常干净。重复键的处理策略我默认是“新值覆盖旧值”。这更贴合 map 的语义。如果你想做成 multiset 那样的可重复 key要么在节点里加一个count字段要么把比较规则改为“相等时往右走”这两条路都简单看需求取用即可。3.2 查找接口一个查找两种返回查找我们拆成两个函数contains返回 bool用来回答“key 在不在”findValue返回指针用来回答“key 对应的 value 是什么”。bool contains(const Key key) const { Node* cur root_; while (cur) { const Key curKey key_of(cur-data); if (cmp_(key, curKey)) { cur cur-left; } else if (cmp_(curKey, key)) { cur cur-right; } else { return true; } } return false; }contains 实现为迭代不涉及递归走一趟从根到目标节点的路径就行。这里注意比较规则先试key curKey再试curKey key两个都不成立才说明相等。这正是有序容器比较的通用写法能正确处理任意满足严格弱序的比较器。取 value 的版本Value* findValue(const Key key) { static_assert(!kIsVoidValue, key-only 模式没有 value 可查); Node* cur root_; while (cur) { const Key curKey key_of(cur-data); if (cmp_(key, curKey)) { cur cur-left; } else if (cmp_(curKey, key)) { cur cur-right; } else { return (cur-data.second); } } return nullptr; }返回裸指针而不是返回值好处是既能判空又能让调用方直接修改红内的 value。如果你不想暴露修改能力可以再加一个 const 版本返回const Value*。实际项目里我还会专门提供一个bool find(const Key key, Value* out)版本把“是否存在”和“带出结果”合并成一个调用避免调用方写两遍查找。3.3 删除操作三情况与后继替换删除是二叉搜索树里最容易写错的部分没有之一。递归删除函数的套路是先按 key 找到目标节点找不到就原样返回 nullptr找到之后分三种情况处理。情况一目标节点没有孩子直接 delete 并返回 nullptr父节点接到空指针节点自然脱离树。情况二目标节点只有一个孩子delete 当前节点把孩子接上来。比如目标只有右孩子就保存右孩子指针释放当前节点返回右孩子。情况三目标节点有两个孩子这是核心难点。经典解法有两种用左子树最大节点覆盖或用右子树最小节点覆盖。我习惯取右子树最小节点也就是后继节点。Node* eraseRecursive(Node* node, const Key key) { if (!node) return nullptr; const Key nodeKey key_of(node-data); if (cmp_(key, nodeKey)) { node-left eraseRecursive(node-left, key); } else if (cmp_(nodeKey, key)) { node-right eraseRecursive(node-right, key); } else { if (!node-left !node-right) { delete node; return nullptr; } else if (!node-left) { Node* right node-right; delete node; return right; } else if (!node-right) { Node* left node-left; delete node; return left; } else { Node* successor node-right; while (successor-left) { successor successor-left; } node-data successor-data; node-right eraseRecursive(node-right, key_of(successor-data)); } } return node; }双孩子情况里有个隐蔽细节值得单独说。我并没有真的把 successor 节点从树上拆下来再挂到 node 位置而是先让 node 保存 successor 的数据再去右子树里递归删除 successor。这样整体的“树指针结构”只动了局部的孩子连接不需要处理 parent 链。另一个细节是右子树的最小节点必然没有左孩子或者只有右孩子所以递归删除它的时候一定会命中“单孩子或叶子”的分支不会无限循环。这种“数据覆盖”方案在面试里最容易被追问因为它有个副作用如果外部持有指向这个节点的指针删除后指针本身还在但 data 已经被替换成另一个节点的数据。涉及外部引用时要慎用。3.4 中序遍历与调试辅助二叉搜索树的中序遍历是有序的所以调试树的时候我几乎每次都把它打出来看能直接验证插入顺序、重复键策略、删除结果是否合理。void inOrder() const { inOrderRecursive(root_); std::cout std::endl; } void inOrderRecursive(Node* node) const { if (!node) return; inOrderRecursive(node-left); if constexpr (kIsVoidValue) { std::cout node-data ; } else { std::cout node-data.first : node-data.second ; } inOrderRecursive(node-right); }这里用if constexpr区分输出格式key-only 打印一个值key-value 打印key:value。调试透明树的结构一眼能看穿。4. 实战避坑退化、递归深度与模板报错4.1 最经典的退化问题我见过最多的 BST 翻车现场就是把已经有序的数据一股脑插入树里。比如你按1, 2, 3, 4, 5的顺序插入树会变成一条只有右孩子的链表。此时查找第 5 个元素本来以为是二叉树 O(log N)实际变成链表 O(N)。有一回我在性能测试里跑 10 万条有序数据查找最后一个 key时间直接飙到几十毫秒而用随机顺序插入只需要几毫秒。这个差距会随数据量继续放大。解决办法有三个层面。数据源可控时先随机打乱再插入数据频繁变动时改用 AVL 或红黑树只做静态查询时可以把有序数组直接构建成完全平衡树。后两种方案我会在第 5 章展开。4.2 析构函数的递归深度陷阱节点是用裸指针串起来的类析构必须负责释放整棵树。很多人第一反应是写递归析构~Node() { delete left; delete right; }代码挺简洁但隐患非常明显。如果树退化成链状析构递归深度等于节点数10 万层的树直接爆栈。我在自己项目里就吃过这个亏上线后偶发崩溃查了半天才发现是析构时递归太深。工程安全的写法是后序遍历的迭代版本用一个栈手动模拟递归~BST() { if (!root_) return; std::vectorNode* stack; stack.push_back(root_); Node* prev nullptr; while (!stack.empty()) { Node* cur stack.back(); if (cur-left cur-left ! prev cur-right ! prev) { stack.push_back(cur-left); } else if (cur-right cur-right ! prev) { stack.push_back(cur-right); } else { stack.pop_back(); delete cur; prev cur; } } }核心逻辑是先把左子树入栈处理完左子树再处理右子树最后释放当前节点。用prev记录上一次释放的节点防止左右孩子被重复入栈。这段代码比递归多了几行但换来了深树下的稳定性我认为非常值得。4.3 模板报错的排查思路泛型代码的编译报错一向是噩梦BST 的模板报错更是重灾区。我列几个高频的no match for operator是最常见的。这是 Key 类型不支持operator。解决办法是给模板传入一个自定义 Compare或者给这个类型重载。static assertion failed说明你调错了接口。在 key-only 树上调了insert(k, v)或者反过来编译器已经把错误信息写得很直白了顺着提示找调用点即可。std::conditional相关的报错多半是 Value 类型不完整。模板实例化时std::conditional的两个分支类型都必须是完整类型不能是不完整声明的自定义结构体。排查模板报错我的习惯是先写一个最小用例比如只用BSTint编译通过后再加上Value参数。别指望编译器一次给全信息分步缩小范围最快。4.4 常见问题速查表现象可能原因处理办法有序插入后查找极慢树退化成链表改用 AVL/红黑树或先随机化输入深度很大时析构崩溃析构递归过深爆栈用迭代栈做后序遍历析构同样 key 插入后 value 不更新重复键策略写错分支检查两条比较分支后是否有 else 覆盖删除双孩子节点后序遍历错乱后继替换逻辑没递归清理覆盖数据后递归删除右子树中的后继节点key-only 模式调了双参数 insert调错接口看 static_assert 提示或检查 Value 类型是否没写 void自定义类型不支持Key 类型缺失严格弱序比较给 Key 重载operator或传入自定义 Compare5. 测试验证与可扩展方向5.1 两个最小复现测试写树类不测试等于没写。先测 key-only 模式BSTint ids; ids.insert(5); ids.insert(3); ids.insert(8); ids.insert(3); // 重复什么都不改 std::cout ids.contains(3) std::endl; // 1 std::cout ids.contains(7) std::endl; // 0 ids.inOrder(); // 3 5 8输出结果是1 0 3 5 8有序、去重、存在性判断都正确。再测 key-value 模式BSTint, std::string dict; dict.insert(1, one); dict.insert(3, three); dict.insert(2, two); dict.insert(2, TWO); // 覆盖 value dict.inOrder(); // 1:one 2:TWO 3:three std::string* v dict.findValue(2); if (v) { *v two-plus; // 通过返回值修改 } dict.inOrder(); // 1:one 2:two-plus 3:three dict.erase(2); dict.inOrder(); // 1:one 3:threeinOrder的输出顺序严格按 key 排列覆盖 value 和删除都生效。再补充一个删除双孩子节点的独立用例树{5,3,8,1,4,7,9}删根节点 5验证中序仍是1 3 4 7 8 9。这一步最容易写错值得单独跑。5.2 从 BST 到平衡树的扩展路线很多读者下一步诉求必然是能不能让它自动保持平衡能而且起点就是当前的树结构。AVL 树的思路是在节点里加一个高度字段每次插入和删除后从递归返回时检查平衡因子。平衡因子绝对值超过 1就做 LL、RR、LR、RL 四种旋转之一。旋转的本质是局部调整指针并不影响中序遍历的顺序。在当前递归插入返回节点的地方把return node改成return balance(node)就已经完成了一半。红黑树的实现复杂度更高需要处理颜色翻转和叔叔节点的关系但思路相同也是在插入删除后做结构调整。如果只是想快速得到一个不退化、好维护的版本我建议直接做 Treap给每个节点加一个随机优先级用旋转维持堆序优先性代码量小且不容易越调越乱。我自己做项目时会先把这一版 BST 当作基线跑通业务逻辑后再把模板的Compare和key_of接口原样迁移到 AVL 版本里。算法的数据结构变了接口契约不变业务层完全无感。5.3 什么时候不该手写二叉搜索树这里必须浇一盆冷水手写 BST 不是万能的。如果场景是线上高并发查询、数据量过百万、需要持久化落盘、或者要跨线程访问我第一反应是直接用成熟容器或数据库索引而不是自己造轮子。手写树类更适合这些场景教学与面试准备嵌入式环境里标准库容器不可用对慢查询和内存有极致控制的场景或者你已经确认这棵树只服务单线程并且数据规模可控。如果是写个人项目造轮子可以帮你彻底搞懂原理。落地到团队项目除非有明确的性能或定制需求否则引入成熟方案更安全。这两种选择我都做过后者的教训是想当深刻。最后再分享一个调试小技巧树结构最容易看不见摸不着出问题时靠肉眼盯代码很难盯出来。我平时调试 BST会用“中序输出 缩进打印”的组合。中序输出确认顺序缩进打印确认树的真实形态。缩进打印的方法是写一个额外函数printTree(Node* node, int depth, char side)里面用深度决定缩进空格数再标出当前节点是左孩子还是右孩子这样树的结构能直接呈现出来。比如插入5, 3, 8, 1, 4, 7, 9后打印出来的效果就是这样R:8 R:9 R:5 L:3 L:1 R:4 R:7一眼就能看出左右子树形态对不对比在纸上手动画快得多。这也是我个人写树结构时最后一步必定要做的事先把结构可视化再谈优化算法。