用字符“画”搜索树,包括二叉树、红黑树等。
此篇文章,以树的“搜索”为主线来讲解,因为无论是插入还是删除,均离不开搜索;而反过来,为了保证树的搜索性能,插入和删除均需要做一定处理。
Binary Tree
首先说二叉树(Binary Tree),毕竟,后面所讲的,都要基于二叉树。
从离散数学的角度来讲,树是由通过图结构来定义的,这里就直接讲二叉树了(当然,直接讲就不怎么严谨)。
基本概念
- 二叉树节点
D
/ \
L R
D : 当前节点,称D为L和R的父节点;
L : D的左孩子节点;
R : D的右孩子节点;
二叉树节点C式代码的实现:
struct Node{
Data data;
struct Node* parent;
struct Node* left;
struct Node* right;
int height; // 节点高度
int color; // 节点颜色,红黑树需要用到
};
- 简单的二叉树
----- r -----
Depth(v) / \
----- v u
/ \ / \ Height(r)
Height(v) a b x y
/ \ / \
----- al ar bl br -----
r : 根节点;
Height(r) : 树根节点r的高度,也称为树的高度;
Depth(v) : 子树根节点v的深度,根节点r的深度为0;
Height(v) : 子树根节点v的高度,空树的高度取为-1;
叶子节点 : 没有子节点(子节点为NULL)的节点,其高度为0;
节点数为n,高度为h;
二叉树 : h < n < 2^(h+1) 根根等比数列可求解;
满二叉树 : n = 2^(h+1) 树中所有节点均含有2个孩子节点,或没有孩子节点;
单链二叉树 : n = h + 1 树中所有节点均只有1个孩子节点,或没有孩子节点;
遍历算法
二叉树的遍历主要包括深度优先遍历(先序、中序、后序)和广度优先遍历(层次遍历)。
深序优先遍历递归式的算法就不说了,太简单了,这里主要说下迭代式的算法。
(1) 基本顺序
D
/ \
L R
先序遍历: D --> L --> R
(2) 实现
d
/ \
l1 r1 /
/ \ / => 对任何以d为根节点的子树,执行以下过程(称之为DLR过程):
l2 r2 / 因为先访问D(即d,l1,l2),所以在访问D的过程中,将R(r1,r2)按斜线方向入栈;
之后将出栈的节点R,当成新的子树d节点,执行DLR过程;
先序遍历 : d - l1 - l2 - r2 - r1
d - l1 - l2 : 从d开始,沿着left一直visit下去(注意将right入栈);
r2 - r1 : 看成一个Stack,r2最后入栈,但最先访问;
访问r2时,同样从r2开始,沿着left一直visit下去(注意将right入栈);
(1) 基本顺序
D
/ \
L R
先序遍历: L --> D --> R
(2) 通过迭代实现:
/ d
/ / \
/ l1 r1
/ / \
/ l2 r2
/ => 对任何以d为根节点的子树,执行以下过程(称之为LDR过程):
因为需要访问完L后才能访问D,故先将L(d,l1,l2)按斜线方向入栈(即沿left入栈);
然后每个出栈的节点,先访问,再转向R(r2,r1),再将R当成新子树的根节点d,执行LDR过程;
中序遍历 : l2 - l1 - r2 - d - r1
Stack示例 : 将L一直入栈,直至栈为 [d - l1 - l2>,然后弹出l2,访问l2,转向l2->right;
l2->right为nullptr,故不需要将L入栈,继续弹出l1,访问l1,转向l1->right;
l1->right不为nullptr,故需要将L入栈,然后......;
一直重复上述过程即可;
(1) 基本顺序
D
/ \
L R
先序遍历: L --> R --> D
(2) 通过迭代实现:
/ d
/ / \
/ l1 r1
/ / \
/ l2 r2
/ => 对任何以d为根节点的子树,执行以下过程(称之为LRD过程):
因为需要访问完L后才能访问R,访问完R后才能访问D,故先将L(d,l1,l2)按斜线方向入栈(即沿left入栈);
对每个出栈的节点:
<1>如果:没有右子树,或者右子树已经访问完毕,则访问之,然后继续弹出节点,继续<1>过程;
<2>否则:转向R(r2,r1),再将R当成新子树的根节点d,执行LRD过程;
后序遍历 : l2 - r2 - l1 - r1 - d
层次遍历即是“一行接一行”地遍历;
d
/ \
l1 r1
/ \
l2 r2
层次遍历 : d - l1 - r1 - l2 - r2
将遍历的顺序,看成一个队列:d最先入队,故最先访问;
r2最后入队,故最后访问;
二叉树接口
二叉树的基本操作,可以沿用至接下来要讲的Binary Search Tree等,这里列个表总结下。
内容 | 接口 |
---|---|
二叉树节点 | Binary Node |
节点高度 | Binary Node Height() |
树节节点 | Binary Tree Root() |
先序遍历 | Binary Tree PreOrder() |
中序遍历 | Binary Tree InOrder() |
后序遍历 | Binary Tree PostOrder() |
层次遍历 | Binary Tree LayerOrder() |
Binary Search Tree
二叉搜索树(BST),着重于查找,而且是快速的查找。
查找节点
- 查找的优势
为了快速查找,做如下约定:
D
/ \
L R
对于二叉树的任何一个节点,有大小关系: L <= D <= R,即BST的中序遍历是有序的;
这样,二叉搜索树的查找,每经过一次比较,就能减小一半的查找范围(和二分查找法类似了)。
简单C式的代码实现:
Node* Search (Node* x, Data ele)
{
if (!x || x->data == ele)
return node;
return Search(x->data < ele ? x->left : x->right);
}
- 查找的不足
对于单链二叉树,二叉搜索树的查找优势就没了,如:
r
\
r1
\
r2
...
二叉搜索树就成了线性查找了。
基于二叉搜索树的优势与不足,我们需要做如下思考:
为了保证二叉搜索树的优势,对于树中的每一个节点,需要使得
(1). 左子树与右子树尽可能接尽满二叉树;
(2). 左子树与右子树的高度尽可能接尽;
接下来的AVL Tree、Splay Tree、RebBlack Tree均是为尽可能达到上述两点,一般统称为平衡二叉树(Balanced Binary Search Tree, BBST)。
插入节点
r
/ \
a b
/ \ / \
w x y z
/
*(NULL)
根据二叉搜索树的Search算法,最终查找得到的是一个节点,或是已存在的节点,或是NULL节点。
如:查找一个位于(a,x)区间的数据,最终返回的就是x的left节点,
即 '*' 所表示的NULL节点,将新数据插入到x->left处即可。
删除节点
- 直接后继节点
因为要保证二叉树的有序性,删除节点比插入节点稍麻烦点,需要先明白什么是节点的直接后继。
节点r在中序遍历下,其后面的节点d即为r的直接后继,如下所示:
根据有无右子树,可做如下判定,
(1)有右子树:r为当前节点,d为r的直接后继,即r右子树中最靠左(最小)的节点;
r
/ \
L a
/ \
d c
\
b
(2)无右子树:r为当前节点,d为r的直接后继,即“将r包含于其左子树中的最低祖先节点”;
e
/ \
d R
/ \
b c
/ \
a r
/
L
- 删除节点
删除节点麻烦的原因就是,删除节点后,需要找到顶替原来位置的新节点,并且能够保证中序遍历的有序性。所以,直接后继就是一个理想的顶替节点,如下所示:
待删除的节点为d,根据d的孩子节点,可以如下判定,
(1) 没有孩子节点 : 直接删除d即可
(2) 只有左孩子节点 或 只有右孩子节点
a a
/ \ / \
T0 b T0 b
/ \ => / \
T1 d T1 c (d)
/
c
将d->right和d->left顶替在d的位置;
(3) 同时有左右孩子节点
d c
/ \ / \
T0 b T0 b
/ \ => / \
c T1 d T1
\ / \ \ / \
a T2 T3 a T2 T3
节点c为节点d直接后继,交换c和d,然后按情况(1)或(2)删除d;
通过以上图解,可以看到,直接后继节点的作用是将有两个孩子的节点,转化成最多只有一个孩子的节点。其实接替节点也可以使用如下节点:待删除节点左子树中最大的节点,或右子树中最小的节点。
BST接口
BST的查找等基本操作,各种二叉搜索树均可以沿用,这里列个表总结下。
特点 | 内容 |
---|---|
查找节点 | BST Search() |
插入节点 | BST Insert() |
删除节点 | BST Remove() |
旋转调整(后面会讲) | BST Rotate() |
AVL Tree
AVL Tree即也称为AVL平衡树。这里所谓的平衡,就是直接从上一节(Binary Search Tree)中“查找节点”的(1)(2)出发,使得二叉搜索树的“形状”尽可能的接尽“等腰三角形”。
AVL平衡定义
对任一节点v,平衡的相关定义如下
理想平衡 : height(v->left) = height(v->right) 即左子树树高与右子树树高相等
平衡因子 : height(v->left) - height(v->right) 即左子树树高与右子树树高的差
AVL平衡 : -2 < 平衡因子 < 2 即平衡因子的范围为[-1,1]
AVL平衡树,即是树中所有节点均达到AVL平衡,如以下等形式:
r r r
/ \ / \ / \
a b a b a b
/ / \
c c d
失衡与复衡
- 插入节点引起AVL失衡
(1)插入节点,引起树失衡的基本情况共4种:
(1) (2) (3) (4)
g g g g
/ \ / \ / \ / \
p t t p p t t p
/ \ \ /
v v v v
/ / / /
i i i i
g,p,v,t四个节点原本可以达到AVL平衡,但插入节点i做为v的子节点后,就会引起节点g失衡;
(因为插入节点i,使得g的高度变化,从而引起g的平衡因子超范围,即引起g失衡)。
这里估且设定 g->parent 节点为 x;
(2)恢复平衡的方法,即将g,p,v三个基本节点调整到以下形状:
(1) (2) (3) (4)
p p v v
/ \ / \ / \ / \
v g g v p g g p
/ \ / / \ \ / \
i t t i i t t i
需要注意的是:
恢复平衡后,g所在位置(即现在的p,v节点处)节点的高度也会同样恢复;
所以从 x 节点以上的所有祖先节点高度均不会变化,即全树恢复平衡。
- 删除节点引起AVL失衡
(1)插入节点,引起树失衡的基本情况共4种:
(1) (2) (3) (4)
g g g g
/ \ / \ / \ / \
p t t p p t t p
/ \ \ /
v v v v
g,p,v,t四个节点原本可以达到AVL平衡,但删除节点t后,就会引起节点g失衡;
(因为删除节点t,使得g的高度变化,从而引起g的平衡因子超范围,即引起g失衡)。
这里估且设定 g->parent 节点为 x;
(2)恢复平衡的方法,即将g,p,v三个基本节点调整到以下形状:
(1) (2) (3) (4)
p p v v
/ \ / \ / \ / \
v g g v p g g p
需要注意的是:
恢复平衡后,g所在位置(即现在的p,v节点处)节点的高度可能再次发生变化,可能引起g->parent失衡;
所以,需要自 x 节点开始,沿着 parent 方向,检测所有祖先节点是否发生失衡,直至树根节点Root;
旋转调整
旋转调整(BST Rotate)是BST的基本过程,不仅可以使得AVL树恢复平衡,还可以保证AVL树中序遍历的有序性,之后的RedBlack Tree也会用到。
示意图如下(包括对称情况):
(1)单旋:
zag(旋转gp) | zig(旋转gp)
g p | g p
/ \ / \ | / \ / \
T0 p => g v | p T3 => v g
/ \ / \ / \ | / \ / \ / \
T1 v T0 T1 T2 T3 | v T2 T0 T1 T2 T3
/ \ | / \
T2 T3 | T0 T1
(2)双旋:
zig-zag(先zig旋转pv,再zag旋转gv) | zag-zig(先zag旋转pv,再zig旋转gv)
g v | g v
/ \ / \ | / \ / \
T0 p g p | p T3 p g
/ \ / \ / \ | / \ / \ / \
v T3 => T0 T1 T2 T3 | T0 v => T0 T1 T2 T3
/ \ | / \
T1 T2 | T1 T2
(3) 代码实现思路
先根据g,p,v之间的父子节点关系,分清是哪种类型,然后代入a,b,c的位置;
同样,子节点T0~T3则代入w,x,y,z的位置。
b
/ \
a c
/ \ / \
w y x z
时间复杂度
AVL平衡树的查找、插入、删除操作,最坏情况下的时间复杂度均为O(logn),这里的n为节点数。但AVL平衡的维持,需要额外借助高度或者说平衡因子,以及旋转操作。特别地,删除/插入节点后的旋转操作,增加了算法时间消耗。
操作 | 时间复杂度 |
---|---|
查找 | O(logn) |
插入 | O(logn) |
删除 | O(logn) |
Splay Tree
伸展树(Spaly Tree)是通过间接的方式,使得二叉树接尽平衡。
伸展过程
伸展树的核心即是伸展,伸展过程,是基于一个这样的考量,如下图所示:
将经常访问的节点
,移到树根节点Root处;这样,随着时间的推移,在Root附近将聚集越来越多的经常访问的节点
,可以加快了搜索速度。同时,伸展过程最好能控制好树的高度。
* -----
/ \ Root附近聚集了经常访问的节点,可以加快搜索速度
/ \ -----
/ \
/__ \
\__ \ 访问底层的节点仍需要较长时间,最长的时间和树的高度有关,所以需要控制好树的高度
\___\ -----
- 伸展原理
伸展的目的是通基本伸展过程,使目标节点v向上移,直至成为根节点Root;
原则是优先双层伸展,直至最后只剩下一层,才使用单层伸展。
(1)双层伸展
zag-zag(先zag旋转gp,再zag旋转pv) | zig-zig(先zig旋转gp,再zig旋转pv)
g v | g v
/ \ / \ | / \ / \
T0 p => p T3 | p T3 => T0 p
/ \ / \ | / \ / \
T1 v g T2 | v T2 T1 g
/ \ / \ | / \ / \
T2 T3 T0 T1 | T0 T1 T2 T3
|
(与AVL旋转相同) | (与AVL旋转相同)
zig-zag(先zig旋转pv,再zag旋转gv) | zag-zig(先zag旋转pv,再zig旋转gv)
双旋: | 双旋:
g v | g v
/ \ / \ | / \ / \
T0 p g p | p T3 p g
/ \ / \ / \ | / \ / \ / \
v T3 => T0 T1 T2 T3 | T0 v => T0 T1 T2 T3
/ \ | / \
T1 T2 | T1 T2
(2)单层伸展
zig: | zag:
p v | p v
/ \ / \ | / \ / \
v T2 => T0 p | T0 v => p T2
/ \ / \ | / \ / \
T0 T1 T1 T2 | T1 T2 T0 T1
- 伸展效果
伸展过程既可以使目标节点聚在Root附尽,也控制好树的高度。
查找'1':
伸过数次伸展,'1'成为了根节点,而树的高度也减小了一半;
8 8 8 8 1
/ / / / => \
7 7 7 1 (伸展1,8) 8
/ / / \ /
6 6 6 6 6
/ / / => / \ / \
5 5 1 (伸展1,6,7) 4 7 4 7
/ / \ / \ / \
4 4 => 4 2 5 2 5
/ / (伸展1,4,5) / \ \ \
3 1 2 5 3 3
/ => \ \
2 (伸展1,2,3) 2 3
/ \
1 3
查找'3':
伸过数次伸展,'3'成为了根节点,'1'仍在根节点附近,树的高度再度减小一半;
1 1 1 3
\ \ \ => / \
8 8 3 (伸展1,3) 1 6
/ / / \ \ / \
6 6 => 2 6 2 4 8
/ \ / \ (伸展3,6,8) / \ \ /
4 7 3 7 4 8 5 7
/ \ => / \ \ /
2 5 (伸展3,2,4) 2 4 5 7
\ \
3 5
查找、插入、删除
查找过程中刚查找的节点
,插入过程中新插入的节点
,删除过程中待删除的节点
,均可认为是经常访问的节点
。值得注意的是,伸展树的查找、插入、删除,依靠查找操作均会改变树的拓扑结构(因为都会进行伸展过程)。
- 插入与删除节点
a r
/ \ \
b c a
/ \ => / \
d e Splay d c
/ \
r b
\
e
若插入的节点x(x<r):先查找x,最终会到达r节点,之后将r伸展到根节点,然后将x作为r的左孩子插入树中。
若要删除节点r: 同样先查找r,最终会到达r节点,之后将r伸展到根节点,然后删除r,重新合并r的左右子树。
相对于AVL平衡树,伸展树不需要平衡因子,更易编程实现,而伸展树查找、插入、删除的时间复杂度同样为O(logn),与AVL平衡树相当。
操作 | 时间复杂度 |
---|---|
查找 | O(logn) |
插入 | O(logn) |
删除 | O(logn) |
RebBlack Tree
红黑树,顾名思义,就是节点有颜色的二叉搜索树。
基本性质
红黑树约定:
- 1.树根:必为黑;
- 2.外部节点(NIL节点):均为黑(外部节点,或说NIL节点,即是一个为NULL的黑色叶子节点);
- 3.其余节点:若为红,则子节点只能为黑;
- 4.从任一节点到其每个(子孙)外部节点:途中路径经过的黑节点数相同;
红黑树的查找沿用BST的Search即可;不过,对于红黑树的每次插入/删除节点,均要保证上述4点仍然成立。
AVL树为了保证平衡因子在[-1,1]之间,失衡时需要进行较多的旋转操作。红黑树相对于AVL树来说,牺牲了部分平衡性,以换取减少插入/删除时旋转操作,整体性能优于AVL树。
插入节点
插入节点需要先用BST Search得到新节点插入的位置。新插入的节点均初始化为红色(如果设为黑色,就会导致根节点到NIL节点的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过颜色调换和树旋转来调整)。根据临近节点的颜色,进行相应的调整。
插入节点示意图:
设新插入节点为x,x的父节点为p,p的父节点为g,x的叔父节点为u(也即p的兄弟节点);
带有R的节点为红,未做特殊说明则为黑;
x为红,p为黑,则节点插入完成;
x为红,p也为红,即为双红冲突问题。
(1) (2) (3) (4)
g g g g
/ \ / \ / \ / \
pR u pR u u pR u pR
/ \ / \
xR xR xR xR
双红问题普遍情况有以上4种,且均是两两对称形式,调整方法是类似的;
还有特殊情况就是,新插入的节点x就是根节点。
针对不同的情况,进行相应的调整(以第(1)(2)种情况为例,(3)(4)情况对称,调整类似):
- (RR1)x是Root节点
xR => xB
将x染成黑色即可。
- (RR2)p为红(则g必为黑),u为黑(子树T3的根节点为u)
p,x均为红,违返了约定3;直接将x染成黑,则违反了约定4。
先用BST Rotate的单旋zig,再重染色 | 先用BST Rotate的双旋zig-zag,再重染色
g | g
/ \ p | / \ x
pR T3[u] / \ | pR T3[u] / \
/ \ => xR gR | / \ => pR gR
xR T2 / \ / \ | T0 xR / \ / \
/ \ T0 T1 T2 T3(u) | / \ T0 T1 T2 T3(u)
T0 T1 | T1 T2
调整前,T0~T3向上的第一个黑节点为子树根节点g;
变换后,T0~T3向上的第一个黑节点为仍为子树根节(p或x),同时满足约定3和4;
故调整前后,子树根节点到(子孙)外部节点所经过的黑节点数不变。
- (RR3)p为红(则g必为黑),u为红(子树T3的根节点为u)
重染色,递归检测g的双红问题
g gR | g gR
/ \ / \ | / \ / \
pR T3[uR] p T3[u] | pR T3[uR] p T3[u]
/ \ => / \ | / \ => / \
xR T2 xR T2 | T0 xR T0 xR
/ \ / \ | / \ / \
T0 T1 T0 T1 | T1 T2 T1 T2
调整前后,pu变黑,但g变红,故从g到其每个(子孙)外部节点上的黑节点数量仍不变;
但因为g的父节点可能为红,故需要继续对g进行双红检测。
删除节点
删除节点同样需要先用BST Search得到待删除节点的位置,然后用BST Remove删除节点。之后再根据删除节点和接替节点的颜色,进行相应的调整。
删除节点示意图:
设x为待删除的节点,r为接替x所在位置的节点(且r为其所在子树的根节点),p为x父节点,s为x兄弟节点;
带有R的节点为红,未做特别说明则为黑;
根据BST Remove算法,在删除x时,x必定最多只有一个子树分支。
x红,r黑,p黑 | x黑,r红,p红或黑
p p | p p
/ \ / \ | / \ / \
s xR => s T[r] | s x => s T[r]
\ | \
T[r] | T[rR]
若x和r只有一个为红,则将r染黑即可;
若x和r均为黑,删除节点x后,节点p-r所在路径的黑节点数少了1,此即为双黑问题。
双黑调整原理:通过旋转和染色,使得节点p-r所在路径黑节点数加1,
或使得节点p-s所在路径黑节点数减1,再递归调整。
针对不同的双黑情况,进行相应的调整:
- (BB1)s为黑,且s至少有一个红子节点,p可黑可红
先BST Rotate,再染色:
s继承p的颜色,tp变黑 | t继承p的颜色,sp变黑
p | p
/ \ s | / \ t
s T3[r] / \ | s T3[r] / \
/ \ => t p | / \ => s p
tR T2 / \ / \ | T0 tR / \ / \
/ \ T0 T1 T2 T3[r] | / \ T0 T1 T2 T3[r]
T0 T1 | T1 T2
调整前后,T0~T3到子树根节点经过的黑节点如下所示:
T0 - s - p T0 - t - s | T0 - s - p T0 - s - t
T1 - s - p => T1 - t - s | T1 - s - p => T1 - s - t
T2 - s - p T2 - p - s | T2 - s - p T2 - p - t
T3 - x - p T3 - p - s | T3 - x - p T3 - p - t
可以看到,在调整后,节点p-r所在路径黑节点数加了1(即节点s或t)。
且不改变T0~T2所在路径黑节点的数量。
- (BB2.1)s为黑,且s两个子节点均为黑,p为红
重染色:s变红,p变黑
pR p
/ \ / \
s T2[r] => sR T2[r]
/ \ / \
T0 T1 T0 T1
s变红,p-s所在路径黑节点数减1;
p变黑,p-s和p-r所在路径黑节点数均加1;
最终,p-s和p-r所在路径黑节点数不变;
- (BB2.2)s为黑,且s两个子节点均为黑,p为黑
重染色(s变红),再递归对p进行双黑检测;
p p
/ \ / \
s T2[r] => sR T2[r]
/ \ / \
T0 T1 T0 T1
s变红,p-s所在路径黑节点数减1;
p颜色不变,所以p-s和p-r所在路径的黑节点数均减1,故需要对p继续进行双黑检测。
- (BB3)s为红,则p必为黑,s子节点必为黑
先旋转:t选取与s同侧的(s为p的左子节点,则t也选s的左子节点),这样只需要一次旋转;
再染色:s变黑,p变红;
再对r进行双黑检测:因为p为红,故以pR-T2-T3形成的子树必为(BB1)或(BB2.1)情况。
p
/ \ s
sR T3[r] => / \
/ \ t pR
t T2 / \ / \
/ \ T0 T1 T2 T3[r]
T0 T1
p变红,p-r所在路径黑节点数再次减少了1,共减少了2;
s变成p的父节点,p-r所在路径黑节点数加1,共减少了1,故需要对r继续进行双黑检测。
附:代码实现
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 [ yehuohan@gmail.com ]