self-balancing binary search tree, height-balanced binary search tree
引入
二叉排序树的查找性能可用平均查找长度AVL度量,而AVL和二叉查找树的结构有关。即,同样一组数据,用不同的顺序建树,则得到的树的形状不同,则查找效率也不同。所以我们要想办法,尽量建出一个AVL更小的树,而平均查找长度和树的高度有关,之前学过,同样结点数目的树中,完全二叉树的高度最小,其高度是 ⌊ log 2 n ⌋ + 1 \lfloor \log _2 n \rfloor+1 ⌊log2n⌋+1。
树越平衡,其高度越小;越不平衡,高度越大
定义
名字由来
平衡因子 balance factor
B
F
(
T
)
=
h
L
−
h
R
BF(T)=h_L-h_R
BF(T)=hL−hR
h
L
h_L
hL是左子树的高度/深度,即层数。
∣
B
F
(
T
)
∣
≤
1
|BF(T)|\leq1
∣BF(T)∣≤1
比如
高度为h的平衡二叉树的最少结点数(竟然和斐波那契数列有关!)
世界真小,缘分真神奇,或者说,斐波那契数列本身很神奇。
构建:建树的每一步都保证树的平衡性
对于一个数组,或者查找表,按照数据记录的不同出场顺序,可以构建出多种截然不同的树,有的树的高度很高,有的却很平衡,高度很低。那么我们如何找到一种方法,保证自己构建的树一定是一棵平衡二叉树呢?答案是,在构建树的过程中一直监视树的平衡性,一旦不平衡则采取措施让其变为平衡树再继续插入下一个数据记录。
采取什么措施呢?左旋(逆时针旋转)和右旋(顺时针旋转)最小不平衡子树。这里涉及一个关键概念,最小不平衡子树。
关键概念:最小不平衡子树
以距离新插入结点最近且平衡因子绝对值大于1的结点为根结点的子树。
如
构建平衡二叉树的关键就是:每加入一个新结点,判断新树的所有结点的平衡因子的绝对值是否小于等于1,即新树是否是平衡树(即检查新结点的插入是否破坏了树的平衡性),如果不是,则需要找到最小不平衡子树,然后根据情况将它左旋或者右旋,以使得新树变为平衡树,才继续加入下一个结点。
左旋,右旋
- 左旋
树右高左低,所以最小不平衡子树的BF符号一致,且都为负,(某些结点的BF也可能为0),则将最小不平衡子树左旋,即把他的右孩子做为新的根结点。注意左旋后有些结点的父子关系会发生变化,以维持二叉排序树的特性。
- 右旋
树左高右低,所以最小不平衡子树的BF符号一致,且都为正,(某些结点的BF也可能为0),则将最小不平衡子树右旋,即把他的左孩子做为新的根结点。注意右旋后有些结点的父子关系会发生变化,以维持二叉排序树的特性。
示例:AVL树的构建过程
如果按照数组元素顺序直接构建,则得到图1的二叉排序树,很不平衡,深度为8,查找效率低。
但是如果用这里要说的方法构建,就可以得到图2的AVL树。
-
首先,插入3,2,两个节点的BF分别是1和0,是平衡的,继续下一步。
-
插入1,这时候3的BF成了2,不平衡了,所以要采取措施。
由于3是距离新结点1最近的,且BF绝对值大于1的点,所以以3为根结点的子树就是最小不平衡子树,在这里其实就是目前的整棵树。
由于3的bf为正,且最小不平衡子树的所有子节点的BF都和根结点的BF同号,所以我们把这个子树右旋,即当前根节点的左孩子成为根结点。得到了图2,它是平衡树,所以插入结点1这一步结束。
-
插入结点4,如上图3,仍然是AVL树,继续下一个点。
-
插入5,最小不平衡子树是3,4,5三个结点构成的,由于最小不平衡子树的根节点BF为负数,且所有子节点的BF和根节点同号,所以把他左旋,即当前根节点的右孩子成为新的根结点。
得到的树如图5,是平衡树,这一步结束。
-
插入6,最小不平衡子树在红框里,这时应该左旋,但是注意这次需要多做一点工作,即3本来是4的孩子,左旋后变为2的右孩子才能保证二叉排序树的特性。
所以,左旋和右旋还需要考虑新根结点的孩子的位置是否能够保证二叉排序树的特性。
- 插入7,左旋,新根节点的孩子位置不需要改变,得到的新树是平衡二叉树
- 插入10,还是平衡二叉树,继续
- 插入9,新现象!插入9后,得到的最小不平衡子树的所有结点的符号不一致,有正有负,即子节点有和根节点BF符号不一致的。这时候不能直接左旋或右旋,比如这里,由于最小不平衡子树根节点的BF是负的就直接左旋,得到图11虚线框中的部分,不满足二叉排序树的特性,这很不好调整。
所以我们要先把这个最小不平衡子树转换为一棵所有结点BF符号相同的最小不平衡子树。即先保证符号统一。
对9和10右旋,得到图12,就是一棵符号统一的最小不平衡子树。
再对这个新的最小不平衡子树左旋,得到AVL树
- 插入8,
最小不平衡子树的符号不一致,于是找第一个和根节点BF符号不一致的点进行旋转,6的右孩子和6不一致,所以首先把以9为根结点的子树进行右旋(不一致的符号是正)
结果得到的树仍然不是AVL树,但是最小不平衡子树的BF符号已经一致了。所以把最小不平衡子树左旋,得到AVL树
代码
二叉树的二叉链表结点的结构体
//比之前多了一个数据域存储平衡因子
typedef struct BiTNode
{
ElemType data;
int bf;
struct BiTNode * lchild, * rchild;
}BiTNode, *BiTree;
右旋操作:当前根结点的左孩子的右孩子,会变为当前根结点的左孩子
直观想象就能知道,要右旋才能恢复平衡,则树一定是左高右低了,那就说明新结点插入在了左子树上。
放个图在这里,便于理解
- 建立一个新结点(右旋后的新根结点)
- 让新结点指向当前根结点的左孩子,因为当前根结点的左孩子即将成为新的根结点
-保证右旋后还满足二叉排序树特性: 如果当前根结点的左孩子有右孩子,那么这个右孩子比当前根结点的左孩子大,且比当前根结点小,所以把他设置为当前根结点的左孩子 - 实现右旋:把当前根结点设置为新结点的右孩子
用下图形象表示,主要是处理
L
R
L_R
LR和
P
R
P_R
PR:先把L的右子树变为P的左子树;再把P改为L的右子树;再把L改为新的根结点。
传入的树不一定是最小不平衡子树,而是P和P的左子树构成了最小不平衡子树。
/*对以p为根结点的二叉排序树右旋,(P指向结点的BF绝对值大于1且为正),处理后P指向新的根结点*/
void R_Rotate(BiTree *p)
{
BiTree L;
L = (*p)->lchild;//让新结点指向当前根结点的左孩子(右旋后的新根结点)
(*p)->lchild = L->rchild;
L->rchild = *p;
*p = L;
}
可以看到上面代码对指针的修改都是环环相扣的,(*p)->lchild
先赋给了L,下一步才给(*p)->lchild
赋值,保证了值不会丢失。
注意,右旋完全不会涉及到当前根结点的右子树 P R P_R PR,它一直挂在当前根节点P上,直到P成为新根结点L的右孩子, P R P_R PR也还是乖乖挂在P的右边,做P的右子树。也就是说,右旋操作中,虽然传入的是P为根结点的整棵树,但是被处理的只有P和P的左子树构成的最小不平衡子树。
还有,由于建树时是一个结点一个结点地插入,所以遇到不平衡树时,树中结点的BF最大绝对值一定是2,不可能大于2。所以右旋前,根结点的BF一定是2,而右旋一次后,新根结点的BF一定是0。
左旋操作(代码和右旋操作对称):当前根结点的右孩子的左孩子,会变为当前根结点的右孩子
要左旋才能恢复平衡,则树一定是左低右高了,那就说明新结点插入在了右子树上。
/*对以p为根结点的二叉排序树左旋,处理后P指向新的根结点*/
void L_Rotate(BiTree *p)
{
BiTree R;
R = (*p)->rchild;//让新结点指向当前根结点的右孩子(左旋后的新根结点)
(*p)->rchild = R->lchild;
R->lchild = *p;
*p = R;
}
左旋前,根结点的BF一定是-2;左旋一次后,新根结点的BF一定是0。
左平衡操作:新结点插入到左子树,造成左子树比右子树高2,通过左平衡操作使树恢复平衡
左平衡操作的目的是解决树的左子树造成的不平衡,它也许需要左旋,也许需要右旋,甚至可能需要双旋。
(最小不平衡子树根结点BF为正,根结点的左孩子BF为负)
左平衡旋转函数的输入应该是一棵左高右低的不平衡二叉树,其根结点的BF为正,且绝对值大于1,如下图第二个图
函数的目的是调整平衡性,把它调整为一棵平衡二叉树
函数的输入是一棵不平衡的二叉排序树,根结点的BF为正。
左平衡旋转,即左子树需要通过某种旋转以恢复平衡,所以函数只需要找根结点的左子树,并通过根结点的左孩子的BF是1或者-1(不可能是0,否则左子树就平衡了,哪里还需要左平衡旋转),分为两种情况分别讨论和处理。
- 新结点插在了T的左孩子的左子树上,只做右旋
比如:
- 新结点插在了T的左孩子的右子树上,要做双旋,这时候还要对T的左孩子的右子树的平衡状况分类分析,即左高右低,平衡,右高左低三种情况
由于分类讨论的情况多,所以代码用了嵌套switch语句
/*T指向根结点的指针,对T指向的结点为根的二叉树进行左平衡旋转,函数结束后,T指向新的根结点*/
//定义三个常数,平衡二叉树的结点的BF只可能是这三个值
#define LH +1 //左子树比右子树高1
#define EH 0 //等高
#define RH -1 //左低右高
void LeftBalance(BiTree * T)
{
BiTree L, Lr;
//检查T的左子树的平衡度
L = (*T)->lchild;
switch(L->bf)
{
case LH://T的左子树左高右低,说明新结点插在了T的左孩子的左子树上
//则这棵树的最小不平衡子树的结点BF都是正,应该右旋
R_Rotate(T);
(*T)->bf = L->bf = EH;//右旋后,根结点的BF一定是0,根结点的左孩子的BF也一定为0
break;
case RH://T的左子树右高左低,新结点插在T的左孩子的右子树,则要双旋
Lr = L->rchild;
switch(Lr->bf)
{
case LH:
(*T)->bf = RH;
L->bf = EH;
break;
case EH:
(*T)->bf = L->bf = EH;
break;
case RH:
(*T)->bf = EH;
L->bf = LH;
break;
}
Lr->bf = EH;
L_Rotate(&((*T)->lchild));//T的左子树进行左旋
R_Rotate(T);//再右旋T
}
}
(*T)->bf = L->bf = EH;
可以用下图解释,下图第一个是平衡二叉树,第二个是在根结点个左孩子的左子树插入一个新结点,变为不平衡树,第三个是右旋后的结果,原根结点和新根结点(原根结点的左孩子)的BF一定都是0,原因也很好理解,比如说P,之前是2,右旋后,右子树还是
P
R
P_R
PR,左子树是
L
R
L_R
LR,这两者的高度都是1,而
L
L
,
L
R
,
P
R
L_L,L_R,P_R
LL,LR,PR三者的高度一定相等,才可以保证L的BF是1,P的BF是2
右平衡操作(和左平衡操作对称)
不知道内部switch的几个BF怎么改,暂时脑子不够用了,明天再仔细想想
//定义三个常数
#define LH +1 //左子树比右子树高1
#define EH 0 //等高
#define RH -1 //左低右高
void RighttBalance(BiTree * T)
{
BiTree R, Rl;
//检查T的右子树的平衡度
R = (*T)->rchild;
switch(R->bf)
{
case RH://T的右子树左低右高,说明新结点插在了T的右孩子的右子树上
//则这棵树的最小不平衡子树的结点BF都是负,应该左旋
L_Rotate(T);
(*T)->bf = L->bf = EH;//左旋后,根结点的BF一定是0,根结点的左孩子的BF也一定为0
break;
case LH://T的右子树右低左高,新结点插在T的右孩子的左子树,则要双旋
Rl = R->lchild;
switch(Rl->bf)
{
case LH:
(*T)->bf = RH;
R->bf = EH;
break;
case EH:
(*T)->bf = R->bf = EH;
break;
case RH:
(*T)->bf = EH;
R->bf = LH;
break;
}
Rl->bf = EH;
R_Rotate(&((*T)->rchild));//T的右子树进行右旋
L_Rotate(T);//再左旋T
}
}
AVL树的插入结点操作(逐个结点插入,构建一棵AVL树):通过递归,一边查找一边插入一边平衡
前面的函数都是基本准备,是主函数的构造部件,这个主函数真的太有料了,以一己之力做了所有事情
这里的布尔指针的用法启发了我,很不错,避免了全局变量,但又做到了全局变量的功能
/*如果记录e已经存在则返回false,否则插入e到树中*/
/*如果e的插入破坏了树的平衡性,则做平衡旋转处理*/
/*布尔变量taller表示树T长高与否,布尔指针在多次调用InsertAVL函数时都有用,相当于全局变量;*/
bool InsertAVL(BiTree * T, int e, bool * taller)
{
if (!*T)
{
*T = (BiTree)malloc(sizeof(BiTNode));
(*T)->data = e;
(*T)->bf = EH;
(*T)->lchild = (*T)->rchild = NULL;
*taller = true;
}
else//查找关键字e是否已经存在
{
if (e == (*T)->data)
{
*taller = false;
return false;
}
if (e < (*T)->data)
{
//在左子树中找到了关键字e,则不再插入
if (!InsertAVL(&((*T)->lchild),e,taller))
return false;
//左子树长高,则在左子树中没找到关键字e,且已经插入到左子树。这时应检查T的平衡性
if (taller)
{
switch((*T)->bf)
{
case LH://之前左子树就比右子树高1,现在左子树更高了,所以要做左平衡处理
LeftBalance(T);//左平衡操作会更新平衡因子
*taller = false;//平衡处理后树的高度恢复了,添加新结点并没有造成平衡树高度的增加!
break;
case EH://之前一样高,那现在左子树比右子树高1,仍然平衡,则树的高度增加1
*taller = true;
(*T)->bf = LH;//更新平衡因子
break;
case RH://之前左子树比右子树低1,则现在一样高
(*T)->bf = EH;
*taller = false;
break;
}
}
}
else if (e > (*T)->data)//在右子树搜索
{
if (!InsertAVL(&((*T)->rchild), e, taller))//没插入,即右子树中已经有了关键字e
return false;
if (*taller)//插入到了右子树,且右子树长高
{
//检查T的平衡性
switch ((*T)->bf)
{
case LH://之前左高1,则现在等高
(*T)->bf = EH;
*taller = false;
case EH://之前一样高
(*T)->bf = RH;
*taller = true;
break;
case RH://之前右边高,现在右边就比左边高2了,需要右平衡处理
RightBalance(T);//右平衡操作会更新平衡因子
*taller = false;
break;
}
}
}
}
return true;
}
主函数
int i;
int a[10] = {3,2,1,4,5,6,7,10,9,8};
BiTree T = NULL;
bool taller;
for (i = 0;i < 10; ++i)
InsertAVL(&T, a[i], &taller);
可以得到下图这样一棵平衡二叉排序树
AVL树的删除结点操作
时间复杂度: O ( log n ) O(\log n) O(logn)
二叉排序树本身的查找效率是不稳定的,根据二叉查找树的形状而异,最差是 O ( n ) O(n) O(n),最好是 O ( log n ) O(\log n) O(logn)。
但是平衡二叉树就不一样了,它达到了二叉查找树的最优时间复杂度,且非常稳定,并且插入和删除数据记录的时间复杂度也是 O ( log n ) O(\log n) O(logn),所以平衡二叉树是一种理想的动态查找表算法。
相关算法:红黑树(另一种二叉查找树的平衡算法)
把二叉排序树变为平衡的二叉排序树还有别的平衡算法,比如红黑树,red black tree,他和AVL树各有优势。