这一篇在干嘛?
第 3 章的表只能线性找(),要”查找快 + 插入快”两全,就得把元素按规则挂到树上。本章从树的定义讲起,重点拆解二叉搜索树(BST)的全部操作,再用 AVL 树解决”输入有序时 BST 退化成链表”的致命缺陷,最后带出伸展树和数据库底层的 B 树。
4.1 树的定义与实现
递归定义:树是节点的集合,要么为空,要么有一个根(root)加 0 个或多个非空子树 ,每棵子树的根由一条边连向大根。除了根,每个节点都有一个父节点。


基本词汇:叶(无子节点)、深度(到根的路径长,根为 0)、高(最深叶子的深度)。 个节点的树恰有 条边。
实现:每个节点存”第一个孩子 + 下一个兄弟”两个指针(first-child / next-sibling)——任意叉的树都能用两个指针搞定:
struct TreeNode {
Object element;
TreeNode *firstChild; // 第一个孩子
TreeNode *nextSibling; // 下一个兄弟
};4.2 树的遍历:UNIX 目录是最好的例子

先序遍历(先处理节点再进子树):目录列表按名字打印——先打印本目录名,再递归打印每个子目录。
后序遍历(先走完子树再处理节点):计算目录总大小——必须先把所有子目录的大小加完,才能加上本目录自身文件的大小:

白话:先序 = “见面先报名字”,后序 = “事情办完再汇报”。哪个顺序对,取决于”本节点的信息是否依赖子节点的结果”。
4.3 二叉树与表达式树
二叉树:每个节点至多两个孩子的树。满的 节点二叉树每个节点都有 0 或 2 个孩子;性质:叶节点数 = 度为 2 的节点数 + 1。

表达式树:叶子是操作数,内部节点是运算符。(a+b)c + (de+f)*g 的表达式树:

白话:第 3 章的后缀表达式,其实就是表达式树的后序遍历;中缀(带足括号)是中序遍历。对表达式树做一次后序遍历(子树结果先算好,再到根做运算)就是求值算法本身。
4.4 二叉搜索树(BST):查找的排序二叉化
性质:对树中每个节点 X,左子树所有节点值 < X,右子树所有节点值 > X。

contains:顺着性质走
bool contains(const Comparable &x, BinaryNode *t) const {
if (t == nullptr)
return false; // 走到空:不存在
else if (x < t->element)
return contains(x, t->left); // 小了往左
else if (t->element < x)
return contains(x, t->right); // 大了往右
else
return true; // 相等:找到
}白话:每次比较排除一整棵子树。查找代价 = 根到目标路径的长度,最坏 (退化),平均 。
findMin / findMax:无脑走到底
最小值永远在最左(一路 left),最大值永远在最右(一路 right)——用循环而不是递归写, 平均。
insert:按 contains 的路走,空位即家
void insert(const Comparable &x, BinaryNode *&t) { // 注意是指针的引用!
if (t == nullptr)
t = new BinaryNode{x, nullptr, nullptr}; // 空位:挂上
else if (x < t->element)
insert(x, t->left);
else if (t->element < x)
insert(x, t->right);
else
; // 重复元素:本版选择忽略(也可挂计数器)
}白话:
BinaryNode *&t是”指针的引用”,递归深入时修改t->left就等于修改父节点里存的那个指针——新节点天然挂在正确的父节点下,不用回传父指针。这是 C++ 引用参数的神来之笔。

remove:分三种情形
- 叶节点:直接删,父指针置空。
- 只有一个孩子:父亲”过继”——把父节点指向自己的指针改指唯一的孩子。

- 有两个孩子:不能简单摘除。用右子树的最小值(or 左子树最大值)顶替本节点,再在右子树里删掉那个最小值(它必然是叶或只有一个右孩子,退化为情形 1/2)。

白话:两个孩子的节点删除像”公司换 CEO”——找一个既比左派服众、又比右派好说话的人(右子树最小者)接位,然后原位那个人就可以放心离开。
平均情形分析:为什么期望 O(log N)
坏消息:把 顺序插入,BST 退化成一条链(深度 ),操作全 :

好消息:随机插入顺序下,树的平均深度约为 (平均深度 ≈ )。

白话:BST 的命运完全交给输入顺序。数据库没法赌输入随机,所以需要”自我修理”的树——AVL。
4.5 AVL 树:带平衡条款的 BST
AVL 条款:每个节点的左右子树高度差至多 1。可以证明高度至多 ,查找仍是 保证。

插入破坏平衡时,找到最深的不平衡节点,按四种情形旋转修复。关键观察:只需处理”插入路径上第一个失衡点”,其上的部分自动恢复平衡。
单旋转(情形 1/4:外侧插入)
左-左 或 右-右 插入。以右-右为例:把失衡点 k₁ 的左孩子 k₂ 转上来当根:


白话:单旋转像”提携”——中间那棵子树 X 换爹:原本属于 k₁,现在归 k₂。它同时满足两边的排序性质(X 的值介于两者之间)。
双旋转(情形 2/3:内侧插入)
左-右 或 右-左 插入时单旋转救不了:



白话:孙子上位分两步——先让孙子把自己爹(内侧那棵)顶掉,再顶掉爷爷。也可以理解为”两次单旋转”。判断用哪种:看插入发生在失衡点的”外侧”还是”内侧”,等价于看失衡点、其较高孩子、插入方向是否三点共线。
高度信息怎么存
每个节点存高度(一个字节足够)。插入后沿回溯路径更新高度、发现失衡即旋转;一次插入至多一次(双)旋转,。
4.6 伸展树:不存高度的自适应树
伸展树(splay tree)换了个思路:不维护平衡条款,而是每次访问(查找/插入/删除)后把目标节点旋转到根(splaying)。常用”之字形(zig-zag)双旋 + 一字形(zig-zig)先旋父再旋祖”的组合:


白话:伸展树押注”热点数据会被反复访问”——被访问过的元素被搬到根附近,下次访问更快。单次操作最坏 ,但摊还意义下每次 (第 11 章的势能法会严格证明)。优点是不用存高度、代码少;缺点是”别碰我的指针”——访问过程会改树结构,不能并发。
4.7 B 树:为磁盘而生的多叉平衡树
内存访问快、磁盘 I/O 慢——数据库索引的瓶颈不是比较次数,是读盘次数。B 树让每个节点存很多关键字(M 阶 B 树:每节点至多 M 棵子树、至少 ⌈M/2⌉ 棵),树高骤降:


插入导致节点满时分裂上推(中间关键字升到父亲),所有叶子永远同层,所以也是平衡的。

白话:一个节点对应磁盘的一个块(比如 4KB),一次 I/O 整块读入。4 阶 B 树装 100 万关键字也就约 10 层 ≈ 10 次读盘;换成二叉树要 20 层。层数减半,I/O 减半——这是所有数据库索引的底层逻辑。
4.8 STL 的 set 与 map
set:数学集合,insert、erase、find(返回迭代器)、contains,底层是平衡 BST(红黑树,见第 12 章),各操作 。map:键值对(关联数组),m[key]直接索引——key 不存在时会自动插入默认值(这个坑见下)。遍历时得到pair<const Key, Value>。
map<string, double> salaries;
salaries["Pat"] = 75000.00; // 插入/更新
cout << salaries["Pat"]; // 查询
auto itr = salaries.find("Jan");
if (itr != salaries.end())
cout << itr->second;常见坑
①
map[key]在 key 不存在时顺手插入默认值——只读判断请用find或count,别用[],否则容器越查越大。② BST 的 remove 双孩子情形:选右子树最小值或左子树最大值顶替,选别的节点会破坏排序性质。③ AVL 旋转判断错”外侧/内侧”:外侧(一条直线)单旋转、内侧(一个折角)双旋转——画图确认三点是否共线。
通关标准
学完本篇你应该能做到:① 手写 BST 的 contains/insert/remove(含双孩子删除);② 讲清单旋转与双旋转分别修复哪两种失衡、如何判断;③ 解释为什么顺序输入会让 BST 退化、AVL/B 树如何避免;④ 说清 B 树为什么是数据库索引发明;⑤ 正确使用 STL 的 set/map 并避开
[]的隐式插入坑。
BST 删除有两个孩子的节点时,为什么用右子树最小值顶替?
右子树最小值 > 左子树全部、≤ 右子树全部,恰好同时满足”大于左、小于右”的排序性质;且它必是叶或只有一个右孩子,删除它退化为简单情形。
AVL 单旋转和双旋转各修复什么情形?
外侧插入(左-左、右-右)用单旋转;内侧插入(左-右、右-左)单旋转无效,需要双旋转(两次单旋转)。
伸展树单次操作最坏 O(N),凭什么被认为"和 AVL 一样好"?
它的保证是摊还的:任意连续 M 次操作总代价 O(M log N),势能法可证。热点访问还会被加速,适合访问模式局部性强的负载;但单次延迟无上界保证,不适合实时场景。
B 树为什么不让树更"瘦高"而是"矮胖"?
B 树优化的目标不是比较次数而是磁盘 I/O 次数。节点大 = 树矮 = 读盘次数少;一个 4KB 磁盘块反正要整块读入,多存几个关键字是免费的。
map[key]和map.find(key)的本质区别是什么?
[]:key 不存在时插入默认值并返回其引用(可写);find:不存在时返回 end() 迭代器,绝不修改容器。只读查询一律用 find/count。