Lecture 4. 树
二叉搜索树 Binary Search Tree
搜索树是一种支持 search get-min get-max insert delete predecessor successor 等动态集合操作的数据结构。
搜索树可以当作字典,也可以当作优先队列。
二叉树三种递归遍历方式
由于二叉树每个节点最多拥有两个子节点,所以,二叉树有以下三种 递归 的遍历方式(还有非递归的层次遍历)
- 中序遍历:左根右(inorder)。BST 的中序遍历产生的序列是单调的!伪代码如下。
- 先序遍历:根左右(preorder)
- 后序遍历:左右根(postorder)

三种遍历方式都是 的。
二叉树可以用链表实现。每个节点除了 key,还包含左右儿子和父节点(如果不存在则 NIL)。只有根节点的父节点是 NIL。
BST 性质
对于二叉搜索树,各种操作的效率与树高有关。对于完全二叉树,效率最坏 。但是最坏情况下树退化成一条链,复杂度变成最坏 。
二叉搜索树当中,对于每个节点 ,设 ,都满足:。也就是说,左子树当中所有节点都小于等于根,右子树当中所有节点都大于等于根。
此外,CLRS 当中认为根节点的深度是 0。 如下图,CLRS 认为这棵 BST 的高度是 。

然而,根节点如果选取不当,则可能导致不平衡,树高增长到 :

BST 的各项查找操作
查找指定元素
Search(x, k) 表示在根是 的子树当中查找键值 的元素的指针。效率 。
递归的版本比较好写,也可以写非递归的版本,常数小一些。以下是两种伪代码。

查找最大最小值
最大值就一路向右,最小值一路向左,效率 。
代码就一个 while:

查找前驱和后继
这里的前驱和后继,都是指 中序遍历 产生的序列上的前后相邻的节点。
中序遍历是「左根右」产生的,因此它 单调。如果一棵 BST 当中所有的键值互不相同,那么一个节点的
- 前驱(predecessor)就是比他小的最大节点
- 后继(successor)就是比他大的最小节点
最简单的办法就是,执行中序遍历,把序列搞出来,然后再从这个序列里面线性或者二分的去找当且节点,然后得到前驱后继。但是这个效率太低了。
分析(以查找后继为例)
情况一:有右子树
如果这个节点具有右子树,那么它的后继结点,就是右子树当中的最小值。
直接调用 get-min(x.r) 即可获得后继。

情况一:无右子树
如果这个节点不具有右子树,它仍有可能具有后继结点:
- 它如果是父节点的左儿子,那么它的父节点就会比他大。此时它的父节点就是后继。
- 如果它是父节点的右儿子,虽然它的父节点没它大,但是它的父节点可能属于它的太太太太太爷爷的左子树,于是它的父节点大不过它的太太太太太爷爷,从而有可能它的太太太太太爷爷比它大,是它的后继。
再结合中序遍历左根右的特性,这种情况下, 的后继是:位置最深的,左儿子是( 自己或 的祖先)的 的祖先。
如下图,从 自底向上(寻找满足要求的深度最深的),它的所有祖先节点 当中,第一个是 的左儿子的 是 。于是, 的后继就是 。

伪代码
效率是 的。

YYF 感觉这个 else 用了个 y,反而画蛇添足不太好理解。不如改成:
TREE_SUCCESSOR(x)
1 if x.right != NIL
2 return TREE_MINIMUM(x.right)
3 else
4 while x.fa != NIL and x.fa.left != x
5 x = x.fa
6 return x.faBST 的插入与删除操作
插入和删除会影响原先 BST 的结构。需要保证插入或删除之后,树依然满足 BST 的性质。
插入操作比较简单(插到合适的叶子上,结构肯定没有影响),但是删除操作需要调整结构。
插入
插入的元素,一定会成为新的叶子。
示例

带有颜色的路径表示插入过程走过的路径。深橙色表示要插入的元素最终能够确定的位置。
插入的过程可以用递归的方式描述:
- 首先与根节点比较。,因此现在移动到右子树。
- 再跟当前的根节点比较,,移动到左子树。
- 再跟当前的根节点比较,,还是移动到左子树。
- 再跟当前的根…欸,不对,现在这棵树是空的。那就插在这儿了!
效率 。
伪代码:将 插入到 BST 中
然而,CLRS 当中给出的伪代码是非递归的,常数小一些:
- 以下代码用 表示当前走到的节点(从根开始),不断更新
- 直到 表示走到空树(递归边界),开始处理
- 一直用于备份 。因此,当 后, 就是
- 由于 要插入到当前空树的位置,所以
- 由于 是叶子,所以 (这在伪代码中没有体现,因为我们假设插入前的 的各个指针都是 NIL)
- 最后用条件语句更新一下 也就是 (判断 是 的左儿子还是右儿子)。对于特殊情况(树空, 不存在),把 当作树根。

删除
删除操作有点麻烦,因为需要维护树的结构,保持 BST 的性质。
分析
删除节点有三种情况:
- 要删除的节点是叶子——那就直接删掉他,然后父亲丧子!
- 要删除的节点有一个孩子——那就把他删掉,孙子变成了爷爷的儿子!
- 要删除的节点有两个孩子——哎呀太麻烦了。不想学了。
每个要删除的节点 ,总共有五种可能的情况(00D、01D、10D、111、110):
- 没左儿子(0XD):
- 没右儿子(00D)
- 有右儿子(01D)
- 有左儿子(1XD):
- 没右儿子(10D)
- 有右儿子(11X)
- 右儿子是后继节点(111)
- 右儿子不是后继节点(110)
方案:没左儿子、没右儿子(00D)
它是叶子。直接删掉。更新父亲的指针。
方案:没左儿子、有右儿子(01D)
用右儿子替换掉 ,更新父亲的指针。

方案:有左儿子、没右儿子(10D)
用左儿子替换掉 ,更新父亲的指针。

方案:有左儿子、有右儿子(11X)
考虑 的后继节点 。由于两个儿子都存在,所以 一定在右子树当中。由中序遍历的性质可知,这个后继节点 一定不会拥有左儿子,可能拥有右儿子。
- 如果后继 不是 的直接右儿子,就先 ,然后 。
- 如果后继 是 的直接右儿子,就直接 。
总之,后继 要变成新的根。
如果 是 的直接右儿子(111)
把 的左儿子变成 的左儿子(原本不存在),然后用 替换 。记得修改 的父节点指针。

如果 不是 的直接右儿子(110)
最麻烦的一种情况。概括起来说,就是 的右子树替换掉了 ,然后再用原先的节点 替换节点 ;详细来说,就是(课件这个示意图太麻烦了,不要管他):
- 从 的右子树当中,删掉后继结点 (它没左儿子,可能有右儿子,很好删)
- 从某个地方新建一个 节点
- 把 的右子树(以 为根)抽出来,作为 的右儿子(图中)
- 把原先 的左子树(以 为根)抽出来,作为 的左儿子
- 此时 已经是个叶子了。把以 为根的子树替换掉节点 (图右),结束。

伪代码:从树 中删除节点
由于删除操作涉及到「子树移动」操作,所以需要一个 Transplant(T, u, v),把以 为根的子树,替换成以 为根的子树。注意,这里的 可以是空树。
移植操作的效率是 。
注意这里的指针修改只涉及 或 ,并不包含 与 ,因为 的子树是一起移动过来的

然后是删除操作的伪代码,效率 :

注意, 本身是不可能有左儿子的。而 替换掉 之后就有左儿子了,所以执行 Transparent(T,z,y) 之后要更新一下 的信息。类似地, 替换掉 之后,新的 也要做出相应的修改。
前两种情况只需要移植不需要手动修改其他指针。后两种(俩儿子)需要手动修改指针。
红黑树
BST 的各项操作都是 的,但最坏情况下一棵 BST 可能退化成链,那么每次操作就都是 的。
红黑树也是一种 BST,但可以避免退化成链。红黑树确保没有一条路径会比其他路径长出两倍,因而是近似于 平衡 的。因此红黑树的各项操作,时间复杂度都是 。
红黑树的每个节点,除了 , , , ,还新增了一个 。红黑树当中还有一个特殊的黑色哨兵(sentinel)节点 ,表示根节点的父节点以及所有叶子节点,除了 ,它的其他四项属性可以等于任意值。这个哨兵节点对于红黑树的边界处理很方便。在红黑树的代码实现当中,所有的 NIL 都用 T.nil 替换。

红黑树可以有三种表示方式。图一比较原始,图二把所有的 替换成了 ,图三省略了所有的哨兵,关注核心部分。
红黑树基本性质
红黑树除了满足 BST 的基本性质,还满足以下五条红黑性质:
- 每个节点都具有一个颜色,要么红色,要么黑色
- 根节点是黑色的
- 每个叶子节点(T.nil)是黑色的
- 如果一个节点是红色的,那么它的两个子节点,必须是黑色的
- 对于每一个节点,从它到子树中每个叶子节点的简单路径上,黑色节点的数量都相等
定义 黑高(black height) 表示节点 出发(不含),到任意一个叶子节点的简单路径上,黑色节点的个数。红黑树的黑高等于根节点的黑高。
证明:红黑树的高度不超过
首先证明:任意节点 的子树中,至少包含 个非 nil 节点。当 是叶子(nil),高度为 ,显然成立;再考虑高度非零的情况,它的子节点的黑高要么是 要么是 。注意,由于红黑树的叶子 nil 是填满了所有位置的,所以每一个节点高度非零的节点都一定有且仅有两个子节点。所以每个 的子节点的子树当中,至少包含 个非 nil 节点,于是 的子树当中包含的非 nil 节点的个数就至少是 。
设整棵红黑树的高度(根节点的高度)是 。根据性质四,从根节点到任意叶节点的简单路径(不包含根本身)上,至少有一半是黑色节点,即 。而根节点的子树当中非 nil 节点的个数恰好是 ,也就是说,。
上面这个式子移项再取对数,可得 ,即 。
因此红黑树的各项查找操作(只读)的时间复杂度都是 。对于插入和删除其实也是 的,但是麻烦一点,后面再说。
旋转 rotation
研究红黑树的插入和删除之前,先研究一下,BST 的旋转操作。
旋转分为左旋右旋,且操作之后仍然保持 BST 性质,如图。

左旋就是把当前根节点 的右子节点(非 nil),旋到上面去。右旋就是把当前根节点 的左子节点 (非 nil),旋到上面去。
对于左旋
- 把 的右子树换成 的左子树,随后视情况更新
- 把 换成 ,随后视情况更新 或 或
- 更新为 ,同时 成为

对于右旋
- 把 的左子树替换成 的右子树,随后视情况更新
- 把 换成 ,随后视情况更新 或 或
- 更新为 ,同时 成为
代码跟左旋是对称的(呼唤所有的 x、y 以及 left、right),这里就不放了。
旋转操作的性能
由于只涉及到指针的修改,所以是 的。
插入
红黑树的插入操作 RB-INSERT(T, z 与 BST 的插入非常类似:
- 所有的
NIL改成了T.nil - 给新插入的节点新增了两个
T.nil作为叶子 - 把插入的节点的颜色弄成 红色
- 为了维持红黑树的性质,最后调用了一个
RB-INSERT-FIXUP
那么,主要研究的问题就是,红黑树的性质可能会因为什么而遭到破坏?

修复红黑性质
由于插入的是红色节点,黑高一定不会受到影响。那么唯一可能影响红黑性质的情况就是,插入的节点的父节点也是红色(代码当中体现为 while (z.p.color == RED) {})。

看图研究。
- 以开始刚把 插入进去,它和爹都是红的,不行。他爹是他爷爷的左子节点,他叔叔是他爷爷的右子节点。他叔叔竟然也是红的。这时候(爹跟叔叔都是红的)就应用代码的 case 1:爹跟叔叔都改成黑的,爷爷改成红的。然后 跳上去两格
- 现在的 跟他爹又都是红的,还得继续修!不过这时候他叔叔是黑的了。这时候(爹红叔叔黑,自己是爹的右孩子),应用代码的 case 2:先 跳一格,然后对 左旋,然后(这时候 已经变了)爹改成黑色,爷爷改成红色,再给爷爷执行右旋
- 现在的 跟他爹依然都是红的,又要继续修!现在(爹红叔叔黑,自己是爹的左孩子),只需要爹改黑,爷爷改红,然后爷爷右旋
- 过程中可能把根作为爷爷给改成红色了,那最后再赋回黑色就好了
修复的代码
代码前半段与后半段高度对称(所有 left、right 互换)

插入操作的性能
首先 RB-INSERT 的前半部分跟之前 BST 的插入没什么变化,所以是 的。那么主要分析修复过程的效率。
- 对于 case 1,由于每次循环后都执行 ,所以 case 1 执行循环次数最多是 级别的。
- 一旦执行了 case 2 或者 case 3 的代码,由于把爹改成黑的了,所以循环会就此终止。
- 因此修复过程也是 的。
因此,红黑树的插入操作是 的。
删除
不考。