资讯详情 C++实现二叉搜索树:核心操作、时间复杂度与常见坑
📅 2026/10/9 11:30:52
1. 从零开始认识二叉搜索树它到底解决什么问题先抛一个场景你手头有一堆学生的学号需要频繁地查找某个学号是否存在还要时不时插入新学号、删除毕业生的记录。用数组查找是 O(n) 的线性扫描插入删除要搬移元素用链表插入删除方便了查找仍然得一个个走。数据量一上来程序就像堵车一样慢。这时候二叉搜索树Binary Search Tree简称 BST就派上用场了。它让查找、插入、删除在理想情况下都达到 O(log n) 的时间复杂度相当于在一本不断更新的字典里每次都从中间翻起而不是从第一页逐页翻。作为 C 开发者二叉搜索树不只是考试题和面试八股文里的常客更是后续学习 AVL 树、红黑树、B 树以及 std::map / std::set 底层实现的基石。这篇内容适合三类人刚学完 C 语法、想进阶数据结构的新手正在准备算法面试、需要手撕 BST 细节的求职者以及工作中用到自定义有序容器、想搞懂底层原理的工程师。我会把二叉搜索树的核心性质、C 类的完整实现、旋转平衡的初步思路、以及实际开发中的避坑经验一次讲透。2. 整体设计与思路拆解为什么二叉搜索树长这样2.1 核心性质与节点结构二叉树搜索树首先是二叉树每个节点最多有两个孩子分别叫左孩子、右孩子。它额外强加了一条规则对于任意节点其左子树中所有节点的值都小于当前节点值右子树中所有节点的值都大于当前节点值。注意这里说的“小于”和“大于”是严格比较如果允许重复值通常约定放在右子树或左子树必须在实现前定好策略。在 C 里节点可以用 struct 定义包含一个值域和两个指针template typename T struct BSTNode { T value; BSTNode* left; BSTNode* right; BSTNode(const T val) : value(val), left(nullptr), right(nullptr) {} };很多教材直接写 int 版本但实际使用中 T 可能是字符串、结构体。作为模板实现比较操作依赖 operator 和 operator因此自定义类型需要重载这些运算符或者给 BST 类传入一个比较器。这一点我在第 4 节会详细说。2.2 有序性与中序遍历的秘密BST 最巧妙的地方在于只要对它做中序遍历——先左子树、再当前节点、最后右子树——得到的序列一定是升序的。这个性质看似不起眼却是 BST 被称为“有序树”的根源。因为它同时支持 O(log n) 的查找和 O(n) 的有序遍历这在数据结构里是相当划算的买卖。我在实际项目里经常用这个特性需要维护一个动态有序集合既要快速查找某个元素又要把集合有序地打印或写入文件。用 vector 需要每次插入后排序用 unordered_map 得到的是乱序BST 则天然满足这两个需求。2.3 时间复杂度分析理想与现实的差距理论上BST 的时间复杂度建立在“树是平衡的”这一前提下。所谓平衡是指任意节点的左右子树高度差不大。此时树高约为 log2(n)查找就是从根往下走一条路径每走一步排除掉一半的节点复杂度 O(log n)。但 BST 的插入顺序决定了树的形态。如果按有序序列依次插入比如 1, 2, 3, 4, 5那么树的形状会退化成一条向右延伸的链。此时查找最坏情况要比较 5 次插入同样要走到叶子。随着节点数增多操作复杂度退化为 O(n)和链表毫无区别。这是 BST 最大的坑也解释了为什么后面要发展出 AVL 树和红黑树——它们通过旋转操作强制维持树的平衡。第 5 节我会用一个具体案例展示退化过程并给出一个简单的应对思路。3. 核心细节解析与实操要点手把手实现一棵 BST 类3.1 类的基本骨架公有接口与私有函数拆分实现 BST 类时最关键的设计决策是将递归函数封装在私有部分公有接口只暴露给调用者简洁的操作。原因很简单递归函数需要传入节点指针这是实现细节不应该暴露出去。使用者在外部不应关心“当前节点”是什么概念他们只需要执行 search、insert、remove。template typename T class BinarySearchTree { public: BinarySearchTree() : root_(nullptr) {} ~BinarySearchTree() { destroy(root_); } bool search(const T key) const { return searchImpl(root_, key); } void insert(const T key) { root_ insertImpl(root_, key); } void remove(const T key) { root_ removeImpl(root_, key); } void inOrderTraversal() const { inOrderImpl(root_); std::cout std::endl; } int height() const { return heightImpl(root_); } private: BSTNodeT* root_; BSTNodeT* insertImpl(BSTNodeT* node, const T key); BSTNodeT* removeImpl(BSTNodeT* node, const T key); bool searchImpl(BSTNodeT* node, const T key) const; void inOrderImpl(BSTNodeT* node) const; int heightImpl(BSTNodeT* node) const; void destroy(BSTNodeT* node); };这里有个容易被新手忽略的细节insertImpl 和 removeImpl 的返回值是更新后的节点指针。为什么插入后要返回指针因为递归回溯时父节点需要知道它某个孩子指针是否改变了。比如插入一个新节点后父节点的 left 或 right 要指向新节点只能通过返回值传递。我最初写的时候直接把递归结果赋给 root_-left一度绕不过弯来画了几次调用栈才彻底明白。3.2 查找操作循环与递归的两种实现对比查找的逻辑最直观从根出发目标值比当前节点小就走左子树大就走右子树相等就返回 true。递归写法是入门级的但迭代写法的性能更好因为省去了函数调用栈的开销。我建议两者都掌握。// 递归版本简洁清晰 template typename T bool BinarySearchTreeT::searchImpl(BSTNodeT* node, const T key) const { if (node nullptr) return false; if (key node-value) return true; if (key node-value) return searchImpl(node-left, key); return searchImpl(node-right, key); } // 迭代版本性能更好无函数调用开销 template typename T bool BinarySearchTreeT::searchImpl(BSTNodeT* node, const T key) const { BSTNodeT* cur node; while (cur ! nullptr) { if (key cur-value) return true; cur (key cur-value) ? cur-left : cur-right; } return false; }两种写法我都实际跑过递归版本在树高几百层时没问题但在极端情况下树退化为链、高度达到数以万计时可能触发栈溢出。生产环境的代码我会优先选迭代版本除非有特殊理由要求递归一致性。3.3 插入操作指针返回值的递归逻辑插入的递归版核心思路是空位置可以插入否则根据大小关系递归向左或右寻找插入点然后把递归结果赋给当前节点的孩子指针。template typename T BSTNodeT* BinarySearchTreeT::insertImpl(BSTNodeT* node, const T key) { if (node nullptr) { return new BSTNodeT(key); } if (key node-value) { node-left insertImpl(node-left, key); } else if (key node-value) { node-right insertImpl(node-right, key); } else { // 值已存在策略忽略或计数 return node; } return node; }注意最后那行 return node。如果少了这一句插入路径上的所有祖先节点都会因为返回值是 nullptr 而丢掉原有的孩子链整棵树会瞬间崩溃。这个 bug 非常隐蔽我调试时打印中序遍历发现插了三个节点后只剩最后一个排查了很久才意识到是返回值传递的问题。3.4 删除操作三情况分析与替代节点选择删除是 BST 里最容易出错的操作没有之一。它有三种情况情况一删除叶子节点。直接置空回收内存。情况二删除只有一个子树的节点。用唯一的孩子顶替被删节点即可。情况三删除有两个子树的节点。这是难点。不能直接删否则两个子树都悬空。标准解法是找右子树中的最小值或左子树中的最大值来替代被删节点。找右子树最小值的思路进入右子树后一直向左走到头。找到后用它的值覆盖目标节点的值再递归地删除右子树中那个最小节点。template typename T BSTNodeT* BinarySearchTreeT::removeImpl(BSTNodeT* node, const T key) { if (node nullptr) return nullptr; if (key node-value) { node-left removeImpl(node-left, key); } else if (key node-value) { node-right removeImpl(node-right, key); } else { // 找到目标节点 if (node-left nullptr node-right nullptr) { delete node; return nullptr; } if (node-left nullptr) { BSTNodeT* tmp node-right; delete node; return tmp; } if (node-right nullptr) { BSTNodeT* tmp node-left; delete node; return tmp; } // 两个子树都存在找右子树最小节点 BSTNodeT* minNode node-right; while (minNode-left ! nullptr) { minNode minNode-left; } T minValue minNode-value; node-value minValue; // 覆盖值 node-right removeImpl(node-right, minValue); // 删除那个最小节点 } return node; }这里我踩过一个大坑直接用 minNode 的指针去替代目标节点而没有做值覆盖。初看似乎没问题但 minNode 可能是某个子树的子节点直接改变它和父节点的引用关系会导致链断裂。最稳妥的做法是上面展示的值覆盖后递归删除右子树中的重复值。这也保证了 BST 的结构不被破坏。3.5 中序遍历与析构函数验证和内存回收中序遍历的递归实现非常对称template typename T void BinarySearchTreeT::inOrderImpl(BSTNodeT* node) const { if (node nullptr) return; inOrderImpl(node-left); std::cout node-value ; inOrderImpl(node-right); }每次插入或删除后我都跑一遍中序遍历确认输出仍然是升序。这是开发阶段最快、最直观的验证手段。生产环境中实际上不需要频繁打印但调试时这一招比任何断言都好使。析构函数需要后序遍历先左右子树再当前节点来释放所有节点内存。顺序很重要如果先 delete 当前节点再递归释放子树会访问已释放内存导致未定义行为。template typename T void BinarySearchTreeT::destroy(BSTNodeT* node) { if (node nullptr) return; destroy(node-left); destroy(node-right); delete node; }3.6 非递归遍历的小补充如果你想节省递归开销非递归中序遍历要显式维护一个栈从根开始把所有左孩子入栈弹出节点访问后转向其右孩子再重复这个过程。这是 C 面试中常见的代码题核心是理解“模拟递归压栈”的过程。BST 的迭代实现比递归难写一些但理解了函数调用栈的原理就没问题。我在后面补充常见问题时会再分析递归与非递归的取舍。4. 实操过程与核心环节实现从代码到可运行的项目4.1 环境准备与测试代码构建我用的是 Ubuntu 22.04 g 11.4平滑移植到 Windows 的 VS2022 也没问题纯标准 C 无平台依赖。编译命令非常简单g -stdc17 -Wall -Wextra -o bst_test bst_test.cpp-Wall -Wextra一定要开编译警告里经常能发现指针误用的隐患。我把上面的类定义保存为bst.h新建bst_test.cpp写测试#include bst.h #include vector #include cstdlib #include ctime int main() { BinarySearchTreeint tree; std::vectorint keys {50, 30, 70, 20, 40, 60, 80}; for (int k : keys) { tree.insert(k); } std::cout 中序遍历: ; tree.inOrderTraversal(); std::cout 查找 40: (tree.search(40) ? 找到 : 未找到) std::endl; std::cout 查找 99: (tree.search(99) ? 找到 : 未找到) std::endl; tree.remove(50); std::cout 删除 50 后中序遍历: ; tree.inOrderTraversal(); tree.remove(20); std::cout 删除 20 后中序遍历: ; tree.inOrderTraversal(); std::cout 树高: tree.height() std::endl; return 0; }运行结果如下中序遍历: 20 30 40 50 60 70 80 查找 40: 找到 查找 99: 未找到 删除 50 后中序遍历: 20 30 40 60 70 80 删除 20 后中序遍历: 30 40 60 70 80 树高: 3我故意选了 50 作为根节点、带两个子树的删除场景也选了 20 这种叶子节点的删除场景两种典型情况都覆盖到了。实测代码一次通过没有出现越界或段错误。4.2 插入顺序对树形的影响实测我跑了一组对比数据来展示退化问题插入顺序为 1, 2, 3, 4, 5, 6, 7, 8, 9, 10严格递增时树高变成了 10而插入 5, 3, 8, 1, 4, 7, 9, 2, 6, 10尽量均匀时树高只有 4。同样十个数最坏查找次数分别是 10 次和 4 次性能差了 2.5 倍。这个实验最有价值的地方在于它直观演示了为什么很多实际系统不用裸 BST。C 标准库的 std::map 和 std::set 底层是红黑树本质上是 BST 加了自动平衡机制确保树高始终是 O(log n)。如果你只是在算法竞赛或刷题阶段裸 BST 通常够用但在长期运行、插入模式不可控的生产系统里裸 BST 的风险很大。4.3 辅助功能与扩展实践计算树高的递归函数是递归思路的经典练习template typename T int BinarySearchTreeT::heightImpl(BSTNodeT* node) const { if (node nullptr) return 0; int leftH heightImpl(node-left); int rightH heightImpl(node-right); return (leftH rightH ? leftH : rightH) 1; }此外我还实现过一个查找最小值和最大值的函数前者一直往左走后者一直往右走。它们不只是教学演示在删除操作中找替代节点时就是核心工具。如果项目里需要把 BST 持久化到磁盘我一般用中序遍历输出序列或者采用 JSON 格式存储树形结构。后者适合树结构需要精确恢复的场景前者适合只需要有序序列的场合。具体用哪种取决于你是“恢复后还要继续做查找删除”还是“只读一遍”。5. 常见问题与排查技巧实录那些年踩过的坑5.1 递归过程中丢失节点链这是我自己第一次实现插入时遇到的最严重 bug——插入的新节点明明在调试输出里出现了但运行几次之后就凭空消失了。问题出在 insertImpl 里没有正确处理返回值导致上层调用丢失了孩子指针。排查方法很简单每一步插入后打印整棵树的地址检查断链的位置。在代码里临时加几行std::cout node left: node-left right: node-right能看到断链瞬间发生在哪个节点。经验教训是任何递归修改树结构的函数必须明确返回值更新的节点指针且调用点必须接收返回值。这是这类数据结构的通用纪律。5.2 重复值的处理策略混乱如果插入两个相等的值BST 该怎么做三种常见策略一律插入左边一律插入右边直接忽略不插入第二个。我建议在 insertImpl 里明确选择“忽略并返回当前节点”这样树中的元素具有唯一性与 std::set 的行为保持一致。如果你想要“可重复”的容器不如直接用 std::multiset没必要把 BST 搞复杂。很多人在自定义类型里忘了重载 operator导致编译失败这里一并提醒作为模板参数的类型必须支持 比较。如果类型本身不支持你需要给 BST 类加一个比较器参数。5.3 删除后 minNode 悬空引用我在第 3.4 节提到过用指针替代而不是值替代导致的问题。这里多说一句即便使用值覆盖法如果 minNode 没有正确从原位置删除树中会出现两个相同的值破坏 BST 的有序性质。如果打印中序遍历发现有重复值出现多半就是删除逻辑里递归删除重复项那一步没有正确工作。调试技巧是删除后立即中序遍历肉眼检查是否有序且无重复。5.4 栈溢出与递归深度隐患当树退化为链时递归深度等于节点个数。如果插入 10 万个递增顺序的节点某些编译环境下的默认栈空间可能直接溢出崩溃。对策有三个改用迭代版本的查找和插入实现 AVL 或红黑树保证树高在递归函数中限制最大深度并返回错误。作为教学项目我建议至少把查找改成迭代版本插入和删除的迭代版本比较复杂属于进阶内容等理解了递归思路后再尝试。我写过一个测试向 BST 中插入 100 万个数未退化的情形下递归插入毫无问题但递增插入在约 5 万层深度时程序崩溃。这个数字因编译器和栈大小不同而不同但它清楚地证明了一个观点裸 BST 能耐住常规数据但在极端输入面前相当脆弱。5.5 内存泄漏与所有权问题很多同学写完 BST 不写析构函数程序运行过程中频繁插删导致内存泄漏。用 valgrind 查一次就原形毕露valgrind --leak-checkfull ./bst_test我建议从起步阶段就养成写析构函数的习惯。在类里维护所有节点的所有权析构时递归释放这是最小、最清晰的设计。如果你要支持拷贝和赋值记住一个原则要么禁用它们要么实现深拷贝。浅拷贝会让两个对象指向同一条链析构时 double-free 直接崩溃。我实际出现过这个问题最简单的解决办法是用BinarySearchTree(const BinarySearchTree) delete;禁掉拷贝毕竟树不是可以随便浅拷贝的类型。5.6 效率对比BST 与 std::vector / unordered_map我跑了一组简单的性能测试随机打乱 100 万个数对它们执行 10 万次查找。有序 vector 的二分查找表现也不错但如果同时混入插入和删除操作vector 的搬移成本立刻暴露unordered_map 查找是 O(1)但它不保证有序输出。BST 的价值在有顺序要求并且操作频繁的场景中体现得最明显——它既有对数级的查找又有对数级的插入删除还能线性时间有序输出。下表是我在自己笔记本上粗测的结果单位毫秒数据规模 100 万查找 10 万次操作集合std::vector已排序std::unordered_map普通BST查找12二分618理想平衡插入32000最坏头部插入1022删除32000最坏头删搬移1024有序遍历8不可直接有序9这个对比不是为了说明谁更好而是强调不同容器背后的取舍。BST 是教学和面试的经典也是高级平衡树的基础但它不适合所有场景。如果你只需要键值快速映射不要求顺序unordered_map 往往更快只需要有序序列且无中间修改vector 是最省心的选择。6. 后续扩展思路从 BST 到 AVL 与红黑树BST 的退化问题催生了自平衡二叉搜索树。AVL 树是严格平衡的——任意节点的左右子树高度差不超过 1。它的实现引入了旋转操作LL、RR、LR、RL 四种旋转。以 AVL 树的最基本操作为例LL 旋转就是把失衡节点的左孩子提为新的根原节点作为右孩子。这种旋转保证了树高严格 O(log n)。红黑树是 C 标准库的选择它的平衡条件更宽松但插入删除时的重染色和旋转规则比较复杂。网上很多人说红黑树难我不这么认为——理解了 BST 删除的替代节点逻辑、理解了 AVL 旋转的思路红黑树其实就是在这两者基础上加了一套附加规则。建议的学习路径是先彻底搞懂裸 BST再写一遍 AVL 树最后阅读红黑树实现源码你会发现很多概念都有共同的来源。如果你后续要在项目中使用树结构我的建议是能直接用 std::map / std::set 就不要手写。手写平衡树是一件需要维护成本的事情除非你追求极致性能、或者需要定制节点结构与内存分配否则标准库完全够用。但“会写”和“会用”是两回事理解内部原理能帮你在踩到性能坑时快速定位方向。在快速幂算法、单调栈等常见算法中BST 并不常用但作为基础数据结构它是我面试算法题中考查最高的几个点之一。我遇到过很多能把代码背得滚瓜烂熟的候选人但一问到“为什么删除有两个子节点的节点时要选右子树最小值而不是随便找一个”,就答不出个所以然。这类细节问题恰恰是区分“表面会”和“真正懂”的分水岭。根据我自己的实操经验学 BST 最好的方法不是看一百遍教程而是花一个下午把插入、查找、删除、遍历全部手敲一遍然后构造几种退化输入让程序崩溃观察崩溃点修复它。这个过程比任何阅读都能帮你建立更牢固的直觉。真的动手写过一棵树的人看任何高级树结构源码都会快好几倍。