1. 什么是树?
树是一种有上下层级的数据结构,就像电脑里的文件夹:
text
资料
├── 工作
│ ├── 合同
│ └── 报表
└── 生活
├── 照片
└── 账单
每一个位置叫作节点 ,节点之间的连接叫作边。
| 名称 | 含义 | 上图中的例子 |
|---|---|---|
| 根节点 | 最顶层的节点 | 资料 |
| 父节点 | 某个节点的直接上级 | 工作是合同的父节点 |
| 子节点 | 某个节点的直接下级 | 合同是工作的子节点 |
| 兄弟节点 | 拥有相同父节点的节点 | 合同和报表 |
| 叶子节点 | 没有子节点的节点 | 合同、照片 |
| 子树 | 某个节点及其下面的所有节点 | 工作、合同、报表组成一棵子树 |
普通的树,一个节点可以有很多个孩子。
2. 什么是二叉树?
每个节点最多有两个孩子的树,叫作二叉树。
两个位置分别叫左孩子、右孩子:
text
A
/ \
B C
/ \
D E
"最多两个"意味着:
- 可以没有孩子。
- 可以只有左孩子或右孩子。
- 可以同时有左右两个孩子。
二叉树只限制孩子的数量,没有规定数据的大小顺序。
3. 什么是二叉搜索树?
为了方便查找,可以给二叉树增加一个排列规则。
先假设数字不重复:
对于每个节点,左边整棵子树里的数字都比它小,右边整棵子树里的数字都比它大。
例如:
text
8
/ \
4 12
/ \ / \
2 6 10 14
它不仅要求 4 比 8 小,还要求 4 下面的 2、6 也都比 8 小。
这样的树叫作二叉搜索树。
找数字 10 时:
text
10 比 8 大 → 往右走
10 比 12 小 → 往左走
找到 10
每次比较,都能排除另一边的整棵子树。
这就是它能加快查找的原因。
4. 数据怎么插进去?
插入时,同样遵守"左小右大"。
例如在刚才的树中插入 5:
text
5 比 8 小 → 往左
5 比 4 大 → 往右
5 比 6 小 → 往左
这里是空位置 → 放进去
结果:
text
8
/ \
4 12
/ \ / \
2 6 10 14
/
5
普通二叉搜索树的插入,就是这样一路比较,找到空位置。
但这会带来一个问题:树的形状受插入顺序影响。
5. 二叉搜索树为什么会长歪?
从空树开始,依次插入:
text
1、2、3、4、5
每个新数字都比之前的大,所以一直往右放:
text
1
\
2
\
3
\
4
\
5
它仍然符合"左小右大",但已经很像链表了。
找 5,需要经过:
text
1 → 2 → 3 → 4 → 5
如果有一百万个节点,最坏就可能逐个比较一百万次。
而同样的数据,可以组织成:
text
3
/ \
2 4
/ \
1 5
这样查找走的路就短了。
所以,只保证大小顺序还不够,还需要控制树的高度。
6. 高度是什么?平衡又是什么?
高度可以理解为:从这个位置往下,最长能经过多少层节点。
这里约定:
text
空树高度为 0
只有一个节点,高度为 1
例如:
text
8
/ \
4 12
/
2
对于节点 8:
text
左子树:4 → 2,高度为 2
右子树:12,高度为 1
整棵树高度为 3
平衡的目的,就是避免一侧长得特别深,让整棵树的高度保持较小。
平衡不等于左右完全对称,也不要求每一层都填满。
7. 什么是平衡二叉搜索树?
平衡二叉搜索树会在插入、删除之后,主动调整结构,控制高度。
不同的树,对"允许歪多少"有不同规定。
一种严格的标准是:
每个节点的左右子树,高度差都不能超过 1。
按这个标准维护的二叉搜索树,叫作 AVL 树。中文教材里常把它称为"平衡二叉树"。
例如:
text
8
/ \
4 12
/
2
它符合 AVL 的要求。
但下面这棵就不符合:
text
10
\
20
\
30
对于 10:
text
左子树高度 = 0
右子树高度 = 2
高度差 = 2
注意,每个节点都要检查,不能只看根节点。
8. 失衡之后,怎么调整?
主要通过旋转。
先看最简单的左旋:
text
调整前: 调整后:
10 20
\ / \
20 10 30
\
30
具体做法是:
text
把右孩子 20 提上来;
让原来的 10 成为 20 的左孩子;
30 继续留在 20 的右边。
为什么这样调整是合法的?
因为大小关系始终是:
text
10 < 20 < 30
调整后仍然符合"左小右大",但树变矮了。
旋转的作用,就是改变树的形状,同时保留二叉搜索树的大小顺序。
向左长歪时,可以反过来右旋:
text
调整前: 调整后:
30 20
/ / \
20 10 30
/
10
复杂一点时,被提上来的节点可能还带着其他孩子,这些孩子也需要安排到正确的位置。
例如左旋:
text
调整前: 调整后:
10 20
\ / \
20 10 30
/ \ \
15 30 15
15 比 10 大、比 20 小,所以调整后放在 10 的右边。
有些形状需要组合两次旋转,但原则一样。
9. 什么叫"自动平衡"?
不是等树全部建完,才统一整理,而是在数据变化后执行:
text
按大小关系插入或删除
↓
检查受影响的节点
↓
必要时旋转
↓
继续检查上层节点
这里有两个不同的角色:
| 内容 | 解决什么问题 |
|---|---|
| 平衡规则 | 判断什么时候需要调整 |
| 旋转操作 | 真正改变树的形状 |
AVL 用高度差判断是否需要调整。
红黑树则使用另一套判断规则。
10. 有了 AVL,为什么还需要红黑树?
因为维持平衡本身也有成本。
AVL 的要求比较严格:每个节点左右子树高度差都不能超过 1。
红黑树允许更宽松的形态:不必时刻保持那么整齐,只要保证树不会长得过高。
| 对比 | AVL 树 | 红黑树 |
|---|---|---|
| 都是什么 | 自平衡二叉搜索树 | 自平衡二叉搜索树 |
| 主要判断依据 | 左右子树高度差 | 节点颜色及相关规则 |
| 平衡要求 | 比较严格 | 比较宽松 |
| 调整手段 | 旋转、更新高度等 | 旋转、变色 |
宽松一些,给维护操作留下了更多余地,同时仍然能够保证对数级别的树高。
具体使用哪个更快,还取决于读写比例、实现方式等,不能只凭树的名字判断。
红黑树也属于自平衡二叉搜索树,但不遵守 AVL 的"高度差最多为 1"规则。
11. 红黑树里的颜色是做什么的?
红黑树给每个节点增加了一个颜色属性:
text
10黑
/ \
5红 15红
颜色可以用一个布尔值记录。
它与数字大小、奇偶、业务优先级都没有关系,也不是固定按层交替。
数字大小决定节点放在哪里,颜色帮助程序检查和维护平衡。
先记住两个核心要求:
- 红色节点不能直接连接红色孩子。
- 从任意节点出发,到它下面各个空位置的路径,黑色节点数量必须相同。
接下来通过插入过程理解它们。
12. 第一个节点是什么颜色?新节点又是什么颜色?
从空树开始,插入 10:
text
10黑
它是根节点,所以设为黑色。
再插入 5:
text
10黑
/
5红
通常,新插入的节点先设为红色;如果它是根节点,就设为黑色。
但这只是初始颜色,后续调整时可能改变。
为什么优先设为红色?需要结合"黑色节点数量相同"来理解。
13. 为什么新节点先设红色?
节点没有孩子的位置,在红黑树的规则中,会被看作黑色空节点,称为 NIL。
例如:
text
10黑
/ \
NIL黑 NIL黑
这些 NIL 用于统一描述规则,实际实现不一定需要为每个空位置创建对象。
插入一个红色的 5:
text
10黑
/ \
5红 NIL黑
/ \
NIL黑 NIL黑
从 10 出发:
text
往左:10黑 → 5红 → NIL黑 共 2 个黑节点
往右:10黑 → NIL黑 共 2 个黑节点
各条路径的黑色节点数量依然相同。
如果把新加入的 5 直接设为黑色:
text
往左:10黑 → 5黑 → NIL黑 共 3 个黑节点
往右:10黑 → NIL黑 共 2 个黑节点
就破坏了这个要求。
所以,新节点先设红,可以保持原有路径上的黑色节点数量,减少影响。
接下来只需要检查:它有没有和红色父节点直接相连。
14. 父节点是黑色时,可以直接保留红色
例如:
text
10黑
/
5红
父黑、子红,没有问题。
再插入 15:
text
10黑
/ \
5红 15红
15 的父节点也是黑色,同样不需要调整。
这里 5 和 15 可以同时为红色,因为它们是兄弟。
不允许的是直接相连的父子都为红色。
15. 父节点也是红色时,需要看"叔叔"
继续插入 1,先设为红色:
text
10黑
/ \
5红 15红
/
1红
5 和 1 连续为红,违反规则。
此时:
text
新节点:1
父节点:5
爷爷节点:10
叔叔节点:15,也就是父节点的兄弟
如果父节点和叔叔都是红色,通常先通过变色处理:
text
父节点 5:红 → 黑
叔叔 15:红 → 黑
爷爷 10:黑 → 红
为什么爷爷要变红?
因为父节点和叔叔都变黑了,两边的路径各多出一个黑节点;把爷爷从黑改成红,可以抵消这个增加,保持这片子树原有的黑色节点数量。
不过,这里的爷爷恰好是根节点。根必须是黑色,所以最终再将它设回黑色:
text
10黑
/ \
5黑 15黑
/
1红
这会让整棵树所有根到 NIL 的路径统一多一个黑节点,因此仍然符合要求。
如果爷爷上面还有节点,就可能出现新的红红冲突,需要继续向上检查。
颜色不是节点的永久身份,会随着维护过程改变。
16. 叔叔是黑色时,为什么会用到旋转?
换一个例子,依次插入:
text
10、20、30
插入 10:
text
10黑
插入 20,先设红色:
text
10黑
\
20红
父节点是黑色,不需要处理。
插入 30,先设红色:
text
10黑
\
20红
\
30红
20 和 30 连续为红。
这次,30 的叔叔是 10 的左孩子位置,也就是黑色 NIL。
对于这个向右排列的例子,可以通过左旋配合变色处理:
text
20黑
/ \
10红 30红
这就是前面已经讲过的旋转:
text
把 20 提上来;
10 放左边;
30 放右边。
然后配合颜色调整,使规则重新成立。
如果父子节点拐了一个弯,例如:
text
10黑
\
30红
/
20红
通常需要先旋转一次,把这种"折线"变成前面的"直线",再进一步旋转、变色。
具体分支可以后面再学。现在要理解的是:
周围节点的颜色和位置,共同决定该变色、旋转一次,还是旋转两次。
17. 现在再看红黑树的完整规则
| 规则 | 含义 |
|---|---|
| 每个节点要么红,要么黑 | 节点具有颜色标记 |
| 根节点是黑色 | 调整结束时必须保证 |
| 所有 NIL 空节点是黑色 | 统一处理路径终点 |
| 红色节点的孩子必须是黑色 | 不允许连续两个红节点 |
| 从任意节点到下面各个 NIL 的路径,黑节点数相同 | 维护各条路径的黑色节点数量 |
这些规则描述的是维护完成后必须满足的状态。
而"新节点先设红、检查父节点、检查叔叔、变色、旋转",是恢复这些规则的操作过程。
不要把"规则"和"操作步骤"混成一件事。
18. 为什么这些规则能保证树不会长歪?
把黑色节点想成路径上的"检查站"。
两条核心要求意味着:
text
各条路径上的黑色检查站数量相同;
两个黑色检查站之间,最多夹一个红色节点。
于是:
- 不能在一条路径上多塞很多黑节点。
- 也不能靠连续塞红节点,把一条路径无限拉长。
最短的路径可以主要由黑节点组成,最长的路径可以红黑交替,但长度不会无限分化。
这些约束能保证:按根到 NIL 的路径长度计算,最长路径不超过最短路径的两倍。
因此,红黑树允许一定程度的歪斜,但树高始终保持在 O(log n),查找、插入、删除也能保持 O(log n)。
它不是每次都把树整理得完全对称,而是始终把高度控制在可接受的范围内。
19. 删除节点时也要维护吗?
要,而且通常比插入更复杂。
插入时,我们优先放入红节点,尽量不改变黑色节点数量。
删除时,如果实际移除的是黑色节点,就可能让某些路径少一个黑节点:
text
原来各条路径黑节点数相同
↓
某一条路径少了一个黑节点
↓
需要通过变色、旋转等操作修复
这里还会涉及兄弟节点及其孩子的颜色。
入门时先掌握插入即可。理解了"为什么要维持黑色节点数量",以后学习删除就能知道每一步在修复什么,而不是只能背步骤。