☰
二叉搜索树从入门到精通:插入删除与自平衡原理
2026/10/10 7:47:59 网站建设 项目流程

二叉搜索树(Binary Search Tree,BST)这个数据结构的名字,几乎每个学过算法的人都会背,但只要写过删节点的人,大概都经历过“看着没错、一跑就崩”的绝望。我最早接触BST的时候也有个误区:以为它只是某种面试专用树,后来真去处理工程里的有序数据、区间查询、动态排序需求,才意识到BST是理解一半高级数据结构的基石。这篇东西我尽量讲透,从定义到实现,从删除的三种情况到为什么会退化,再到AVL和红黑树的衔接思路,争取让一个刚学完C++基础的人也能一步步写出来。

1. 二叉搜索树到底在解决什么问题

1.1 数组、链表与BST的取舍

先看一组很朴素的操作:维护一堆整数,要支持“插入一个数”“删除一个数”“查找某个数是否存在”。数组能做到插入和删除都是O(n),因为要搬动后续元素;查找是O(1),但只限于按下标访问,如果要“判断数字7在不在”,最差也是O(n)。链表插入和删除很快,只要找到位置,但查找还是O(n)。这里面的核心矛盾是:光有物理连续或物理离散的线性结构,都不足以同时平衡“有序性”和“动态修改”。

BST给了一个很聪明的解法:让每个节点都携带一个“左小右大”的约束条件。假设根节点是50,所有比50小的都挂在左边,比50大的都挂在右边,那查找一个数时,每走一步就能丢掉一半的搜索空间。这个“减半”的行为在平衡状态下,就是O(log n)的来源。

我印象很深的是一道模拟题:需要动态维护学生成绩排名,要求按分数查人、按人改分数、随时给出前几名。用数组每次插入都挪动O(n),后来改成BST,每个操作都稳定在数十万量级下可接受。那一刻我才真正理解,BST不是拿来炫技的,它解决的是“要一直保持有序,但又不能每次重新排序”的工程痛点。

1.2 二叉搜索树的核心不变量

BST的定义可以精确描述为:对于任意节点,其左子树中所有节点的值都小于该节点的值,右子树中所有节点的值都大于该节点的值。注意,这里说的是“所有”,不只是左孩子和右孩子。因此当你把一棵BST做中序遍历时,得到的结果一定是严格递增序列。

这个性质非常重要,因为它是后续所有操作正确性的基础。如果你实现的插入逻辑破坏了“任意节点左子树全部小于它”这个条件,那这棵树就不再是BST,查找的正确性直接失效。很多人在写递归插入时只比较了当前节点和插入值的大小,但在回溯过程中忘记更新高度、或者在删除后没有重新维护父子关系,导致之后某一个遍历看起来正常,但搜索路径已经错了。

关于数值相等的情况,不同实现策略不同。最简单的是定义为“左子树小于或等于当前节点,右子树大于当前节点”,相当于允许重复值放在左侧;也可以完全禁止重复。我建议初学阶段先禁止重复,因为工程里如果涉及频次统计,一般会额外在节点里维护一个count字段,而不是把相同值挂成多个节点。多值版本对删除逻辑复杂度的提升,远大于它带来的那一点点便利。

2. 动手实现:从结构体定义到插入节点的完整过程

2.1 节点结构怎么设计

用C++实现BST,节点结构通常是这样的:

struct TreeNode { int val; TreeNode* left; TreeNode* right; explicit TreeNode(int v) : val(v), left(nullptr), right(nullptr) {} };

一个节点只存三样东西:值、左孩子指针、右孩子指针。这里的指针是裸指针而不是智能指针,是因为树结构的递归归属关系如果用shared_ptr会有循环引用风险,用unique_ptr又需要频繁move,写起来反而绕。对于初学数据结构的场景,裸指针最直观,只要在析构函数里统一delete即可。

C++11以后也有一种做法是把树整体包成一个类,外部只暴露接口,内部递归函数设为private:

class BinarySearchTree { public: BinarySearchTree() : root_(nullptr) {} ~BinarySearchTree() { destroy(root_); } void insert(int val) { root_ = insert(root_, val); } bool find(int val) const { return find(root_, val); } private: TreeNode* root_; void destroy(TreeNode* node) { if (!node) return; destroy(node->left); destroy(node->right); delete node; } TreeNode* insert(TreeNode* node, int val) { if (!node) return new TreeNode(val); if (val < node->val) node->left = insert(node->left, val); else if (val > node->val) node->right = insert(node->right, val); else return node; return node; } };

我强烈建议把根节点作为成员变量封装在类里,而不是把外部指针到处传。因为一旦暴露裸的根指针,用户可能不小心把root置空、或者在遍历后错误地修改结构,问题排查很痛苦。

2.2 递归插入的每一步发生了什么

递归插入其实就一句话:沿着树往下找“该待的位置”,直到发现一个空节点,就把新节点放进去。比如插入数字35,当前根是50,35小于50,所以进入左子树;左子树的根是30,35大于30,进入右子树;右子树的根是40,35小于40,继续往左;此时左孩子为空,就在这个位置new出一个值为35的节点。

关键点在于递归返回值:底层返回了新的子树给上一层,上一层通过node->left = insert(node->left, val)把更新后的子树接回来。如果不接这个返回值,哪怕你在底层把新节点挂上了,上面一层的指针还是指向原来的老子树,新节点就丢了。我见过太多第一次写BST的人把insert写成void返回类型,然后在里面改指针,又因为传值拷贝导致树完全没变化,或者传指针又因为忘记修改根节点而丢掉整棵树。

这里还要提一个体验问题:递归插入在大规模随机数据下没问题,但如果树已经退化成了一条链,递归深度可能达到几万甚至几十万层,存在爆栈风险。生产环境中很多高性能场景会用迭代式插入,也就是一个while循环,用parent指针记录前驱节点,最后判断新节点应该挂左边还是右边。初学阶段递归更好理解,但心里要清楚它有一个深度上限。

2.3 一个测试插入结果的小技巧

插入动作做完,怎么证明树搭对了?我常用两个方法:

  1. 中序遍历,输出序列必须是升序。
  2. 打印树结构,比如用缩进或括号表达式(50(30(,40(35,)),70(60,80)))手工检查每个节点的左右关系。

事实上,第1种方法已经是BST正确性的一个必要条件。如果中序遍历出现乱序,说明某个节点的左右子树关系摆放有问题,或者递归返回值的接续逻辑有bug。我建议每写完一种操作,都把中序遍历作为一个自检函数保留在类里,调试时非常香。

3. 查找与中序性质:你真正需要背的两种遍历

3.1 查找的递归与迭代写法

查找是BST最顺理成章的操作,逻辑只有三步:等于当前节点就返回true,小于当前节点就往左走,大于当前节点就往右走。写成递归很容易:

bool find(TreeNode* node, int val) const { if (!node) return false; if (node->val == val) return true; if (val < node->val) return find(node->left, val); return find(node->right, val); }

但我实际更喜欢迭代版,原因并不是它性能更好(其实差不多),而是它更容易验证没有bug:

bool find(int val) const { TreeNode* cur = root_; while (cur) { if (cur->val == val) return true; cur = (val < cur->val) ? cur->left : cur->right; } return false; }

这段代码直白到几乎不可能出错。面试和笔试中,查找也往往不会单独考,它经常作为删除节点的辅助手段出现,比如“把某个节点删掉后,需要找到它的后继节点”。

3.2 中序遍历为什么一定是升序

这里值得把“为什么”讲透。对任意节点来说,中序遍历的顺序是:先左子树、再当前节点、再右子树。由于左子树所有节点都小于当前节点,右子树所有节点都大于当前节点,所以这种遍历顺序天然符合“小→中→大”的排序逻辑。

这个性质的实用价值非常大。你可以把一棵BST当作一个“永远有序”的集合,想要有序输出去遍历一遍子树即可,不需要额外sort。这也是为什么早期某些用来做词典排序的程序会直接用BST做底层结构。

同时,中序遍历还可以提供“第k小元素”的能力:用一个计数器记录已经访问的节点个数,当计数器等于k时,当前访问到的节点就是答案。这种操作在普通数组里需要先排序,在BST中却能随时查询,复杂度能压到O(h)。它是很多进阶题目的原型。

3.3 前驱与后继:比想象中更常用的工具

前驱节点是指小于某个节点的所有节点中最大的那个,后继节点是大于某个节点的所有节点中最小的那个。找后继的规则是:

  • 如果该节点有右子树,后继就是右子树中的最左节点。
  • 如果该节点没有右子树,需要向上回溯,找到第一个“作为左孩子”的祖先节点,这个祖先就是后继。

一个很直接的应用场景是删除节点时,如果被删节点有两个孩子,我们需要用它右子树的最小节点来顶替。这个最小节点就是它的后继。另一个场景是求“比某个数大的最小数”,比如给定分数列表,查找刚好及格(大于等于60)的成绩,BST后驱查找能在一趟log级别操作里得到结果。

4. 最棘手的部分:删除节点与三种分支情况

4.1 叶子节点的删除为何最安全

删除叶子节点没有孩子,直接把它从父节点上摘掉,然后delete即可。例如删掉数字35,它没有左右子树,那么其父节点40的左指针直接置空。这一种情况属于“最没心理负担”的,唯一要注意的是:不要把父节点的指针弄丢,得在递归回溯时更新。

另一种容易忽略的细节:如果删除的是根节点,而且整棵树只有一个根节点,那么要把root_置为nullptr。很多人会在递归里delete之后不知道该返回什么,最后返回了一个悬空指针造成崩溃。

4.2 只有一个孩子节点的删除策略

被删节点只有一个左孩子或只有一个右孩子,处理方式是让这个孩子直接提升,替代被删节点的位置。比如删除节点40,它只有左孩子35,那么让35替代40成为30的右孩子即可。

这一步在递归代码里非常优雅:

TreeNode* remove(TreeNode* node, int val) { if (!node) return nullptr; if (val < node->val) { node->left = remove(node->left, val); } else if (val > node->val) { node->right = remove(node->right, val); } else { if (!node->left) { TreeNode* rightChild = node->right; delete node; return rightChild; } if (!node->right) { TreeNode* leftChild = node->left; delete node; return leftChild; } // 有两个孩子的情况,后面讲 } return node; }

这里的核心是:把“被删节点的右孩子”或者“左孩子”作为新的子树返回给上层,上层接住并设置成自己的对应孩子。直接delete旧节点,释放内存。

很多教学里没有强调的一个点是:如果删的是根节点,且根节点只有一个孩子,那么这个孩子会成为新的根。递归函数返回根节点给外部调用方,外部接口需要写成root_ = remove(root_, val),而不是傻乎乎地remove(root_, val)。

4.3 有两个孩子:为什么不直接删,而是“换人”

如果被删节点同时存在左右子树,直接删除会让两个子树都变成孤儿。常规做法有两种:

  • 找到右子树的最小节点,用它的值覆盖被删节点,然后递归删除那个最小节点。
  • 找到左子树的最大节点,用它的值覆盖被删节点,然后递归删除那个最大节点。

第一种更常用,我解释一下为什么选“右子树最小”:右子树的所有节点都大于当前节点,右子树的最小节点是所有这些“大于当前节点”的值中最小的一个,把它放到当前节点位置后,依然保持“左子树全部小于它、右子树全部大于它”的BST性质。同时,这个最小节点肯定没有左孩子,所以递归删除它时,只会走进“没有左孩子”的分支,处理简单。

代码实现:

TreeNode* findMin(TreeNode* node) { while (node && node->left) node = node->left; return node; } TreeNode* remove(TreeNode* node, int val) { if (!node) return nullptr; if (val < node->val) { node->left = remove(node->left, val); } else if (val > node->val) { node->right = remove(node->right, val); } else { if (!node->left) { /* 只有一个右孩子 */ } else if (!node->right) { /* 只有一个左孩子 */ } else { TreeNode* successor = findMin(node->right); node->val = successor->val; node->right = remove(node->right, successor->val); } } return node; }

这里的“值覆盖”不是唯一的做法,也有人直接调整指针把后继节点挪上来。对初学者来说,值覆盖的思路更好理解,也不容易破坏引用关系。但要注意,如果节点结构里除了val还存了其他业务字段(比如卫星数据、count、附带信息),那么“值覆盖”也需把这些字段一并拷贝,否则数据会丢失。

我在做实际项目时遇到过一种bug:节点里存了id和score两个字段,删除时只覆盖了val,导致新节点的id是旧节点的id,score却是被删掉的score。正确的做法要么整体替换节点,要么明确告诉自己在删除时要维护所有业务字段的一致性。

4.4 删除操作的两个隐藏要点

第一个是“递归删除后继节点”后,原来右子树的结构会发生改变,但新的右子树依然是合法的BST,所以直接接回node->right没问题。第二个是要注意链式调整的顺序——千万不要在delete旧节点之前去读它的后继节点的指针,因为如果后继恰好是它的右孩子,那个指针已经被释放了。

我写这段代码时踩过最深的坑是:被删节点有两个孩子,我找到了后继,但后继的父节点不是被删节点,而是更深的某个节点。这时候如果你直接让后继的右孩子成为它父节点的左孩子,然后再替换,指针操作一多就会乱。用“值覆盖+递归删除后继”能彻底绕开这个复杂度,这也是我推荐这个写法的核心原因。

5. 高度才是命门:从平均log n到最坏n的退化链路

5.1 为什么复杂度取决于树高

BST查找、插入、删除的时间复杂度都是O(h),这里的h是树的高度。在一棵“左右均匀”的树里,h大约等于log₂n,所以每次操作都很高效。但树高这个值并不保证一定是log n,它只取决于当前树的形态。

把{1,2,3,4,5}按顺序插入空BST,会发生什么?每次新值都比当前根大,于是每次都走右子树,最终得到一棵只有右孩子、没有左子树的链表。此时树高h=n,查找最后一个元素要遍历n个节点,复杂度直接退化成O(n)。这就是BST最有名的问题:操作序列能决定树的形态。

产生退化的本质原因是插入操作没有“自我纠偏”能力,它永远把新节点放到从根开始一路比较到的那个空位上。一旦数据是近似有序的,就会出现“全是右子树”或“全是左子树”的畸形结构。

5.2 随机数据与有序数据的表现差异

如果数据是随机排列的,BST的树高期望值是O(log n),这来自随机二叉搜索树的高度期望分析。但如果数据来自实际业务,比如日志里的时间戳、用户ID的递增序列,几乎都是有序或部分有序的,直接插入BST就会退化。

我在测评一个内存索引模块时遇到过这种情况:测试数据是生成器随机排列,插入100万节点性能很好,树高只有二十几。结果换成真实日志数据,时间戳递增,处理到5万条时树高已经5万,查询一个尾部数据慢了几个数量级。这个案例让我彻底记住了“BST的高度由插入顺序决定”这句话的含义。

5.3 解决退化的两条路线

路线一:插入前先把数据打乱。这个在离线场景有效,但工程里数据是动态到达的,无法预知全局顺序,所以并不可靠。

路线二:把BST升级成自平衡树。插入和删除过程中检测高度差,超过一个阈值就通过“旋转”调整树形,强制把高度控制在O(log n)。这就是AVL树和红黑树的出发点。

要理解自平衡树,旋转这个概念无法回避。左旋(left rotation)和右旋(right rotation)是两种局部调整手段,它们能在不破坏“左小右大”性质的前提下,把不平衡的形态纠正过来。比如一个“右-右”型的失衡链,做一次左旋,把中间节点提为根,原根变左孩子,树高就降下来了。

AVL树把每个节点的左右子树高度差限制在-1、0、1之间,一旦失衡就旋转修复。它的查询性能非常稳定,但每次插入可能需要多次旋转,修改成本高。红黑树不追求绝对平衡,只要求“最长路径不超过最短路径的两倍”,插入删除时通过变色和旋转保持黑高一致,修改次数更少,所以C++标准库的std::map和std::set底层用的是红黑树,而不是AVL树。

这里有一个我经常被问到的点:既然红黑树听着这么复杂,初学阶段能不能先不碰,只学BST?其实完全可以。自平衡树的旋转都是在BST骨架上做文章,先把普通BST的插入删除写熟练,再去补旋转插入的四种情况和删除的三种情况,会顺很多。而且很多算法题里,你要实现的并不是红黑树,而是给一个“平衡版BST”的接口题,底层还是BST的思路。

6. 从BST到自平衡与工程实践:map、set背后的那棵树

6.1 std::map为什么不用哈希表

有人会问:C++的std::map底层用红黑树,那为什么不用哈希表?原因是map要求的操作不只是插入删除查找,还包括“找最小键”“找最大键”“找大于某个键的第一个元素”“按顺序遍历所有键”。哈希表在平均情况下查找更快,但无法保持键的有序性,也无法高效支持范围查询。红黑树天然有序,能在O(log n)时间完成这些操作。

实际工程里,如果一个容器只需要“按键查找数据”而完全不需要有序遍历,那就应该用std::unordered_map,它的平均查询更快,常数更小。但如果你需要“拿到比某个值大的最小key”“遍历时希望key自动升序”这类需求,std::map才是正确选择。理解了BST的“有序动态集合”定位,你就知道这类容器该怎么选型了。

6.2 红黑树和AVL树的取舍思路

简单总结一下两种树在工程中的取舍:

  • AVL树平衡条件更严格,查找更快,但插入和删除需要更多旋转来恢复平衡。
  • 红黑树平衡条件更宽松,插入和删除需要调整的次数少,所以修改频繁时性能更好。
  • 查找操作远多于修改操作时,AVL可能更有优势;修改频繁则红黑树更合适。

C++标准库选择红黑树,就是因为在通用的动态集合场景下,插入和删除的频繁度并不低,红黑树更适合作为通用容器。数据库的索引在某些场景下会选择B树而不是红黑树,是因为B树是多路平衡,适合磁盘的页式读取,这已经不是二叉树的范畴了。

这些内容在面试中经常被串起来问:先写一个BST的插入删除,然后追问如果插入有序数据会怎样,接着追问如何解决,然后是旋转,然后是AVL和红黑树的区别。所以深入学习时不要只停留在能写出代码,要把“为什么会有这些设计”的脉络想清楚。

6.3 面试题目中的BST变体

基于BST的进阶题目很多,我认为值得优先掌握的包括:

  • 判断一棵树是不是BST:通过中序遍历是否递增来判断,比单纯比较每个节点的左右孩子更可靠。
  • BST中第K小的元素:用中序遍历配合计数,时间复杂度O(k),也可以给节点增加size字段做到O(log n)。
  • 验证删除是否正确:删除后中序遍历仍是递增序列,而且树中不存在被删节点。
  • 将有序数组重建为BST:每次取中间元素做根,递归构造左右子树,得到的是平衡树。
  • 合并两棵BST:先把两棵树中序遍历得到两个有序数组,归并成一个有序数组,再重建平衡BST。

这些题目反复在考察同一个核心素养:是否能清晰理解BST中序遍历的有序性、删除时的结构维护、以及树高与复杂度的关系。

7. 高频进阶玩法与我自己调试BST的几条经验

7.1 给节点加上size字段之后能做什么

如果每个节点都记录以它为根的子树节点数量,就能实现“按排名查元素”的功能。比如要查“第5小的数”,从根开始,先看左子树的size:

  • 如果左子树size等于k-1,当前节点就是第k小。
  • 如果左子树size大于等于k,第k小在左子树里。
  • 否则第k小在右子树,且需要把k更新为k减去左子树size再减一。

有了这个size字段,插入和删除时也要同步更新路径上每个节点的size,代价仍然是O(log n)。这种“带子节点计数”的BST是进阶数据结构的基础,再往前走就是求区间第K大用的树状数组、线段树、平衡树套树等,理解难度会逐步攀升,但根子里的思想还是“利用树的形态快速缩小范围”。

7.2 调试BST操作时的打印技巧

我早期调试BST,满脑子都是断点,断点一多自己都乱了。后来学到一个很实用的笨办法:把树打印成括号表达式,快速目测结构。比如:

void printTree(TreeNode* node) { if (!node) { std::cout << "#"; return; } std::cout << node->val; std::cout << "("; printTree(node->left); std::cout << ","; printTree(node->right); std::cout << ")"; }

删除节点之后打印一棵树,你能很直观地看到新根是谁、左右子树是否还满足BST性质。要验证BST性质,也可以对每个节点递归检查它的值是否大于左子树最大值、小于右子树最小值,或者干脆做中序遍历看是否升序。

7.3 我建议的练习路径

如果让我给一个新手推荐学习路径,我会是:

  1. 先手工画一棵BST,比如按{50,30,70,40,60,80,35}插入,画出每一步的树形。
  2. 代码实现插入和查找,用中序遍历自检。
  3. 代码实现删除,覆盖叶子节点、单孩子节点、双孩子节点三种情况。
  4. 测试把有序序列插入BST,观察它退化成链表的效果,亲手验证复杂度从O(log n)变成O(n)。
  5. 学习AVL旋转,理解左旋右旋如何恢复平衡。
  6. 最后去读或者手撕红黑树删除的复杂逻辑。

这个过程不必求快,重点是把每一步的“为什么”弄明白。我见过太多人背了几遍红黑树删除的case,面完一个星期就全忘光了。根源就在于普通BST的删除场景没亲手写透,一旦加上平衡调整自然更加混乱。

就我自己而言,二叉搜索树最大的价值不是“我能在五秒内写出一棵删除代码”,而是它让我养成了一种思维习惯:任何动态有序数据,都要考虑“插入顺序是否会影响性能”。带着这个习惯再去看哈希表、跳表、B树,你看到的就不只是各种数据结构的API,而是一组在不同场景下做取舍的工程方案。这种视角,才是学数据的意义所在。

最后补一句实际的建议:如果你的项目里真的需要一个“有序动态集合”,先别急着手撸红黑树,优先用C++标准库里的std::set或std::map试试,它们经过大量优化,稳定可靠。等你发现某个特定操作成为瓶颈、需要定制数据结构时,再回来手写BST及自平衡版本也不迟。学习阶段手写,是理解;工程阶段复用,是效率,两者并不冲突。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询