前言:
本文介绍了AVL的插入和删除, 首先让我们想一下很简单的二分查找,其效率为O(lgn),这是很好的,而大多数时候在数据的处理涉及到插入和删除,但是,线型表仅仅使用于查找,所以—依据二分查找性质的二叉排序树出现了,但是却出现了由于树的形状而影响查找效率的情况,导致可能会出现O(n)的情况,一般二叉树的查找复杂度是和深度有关,所以如果能够控制二叉排序树的形状,那么就能够控制查找的效率,自然而然就想到让它靠近完全树,于是便有了AVL。
AVL树是最先发明的自平衡二叉查找树,也是一种严格的平衡树,它有着十分简单的定义:在AVL树中,其任何一个结点的左子树和右子树的高度差不大于1,其左子树和右子树也是平衡树。很明显的递归定义,但是也就是这个定义,在接下来的编程中得到了许多运用,简洁但是十分的重要。平衡因子(Balancefactory)在这里规定为结点的左子树的深度减去结点右子树的深度,也就是说,在AVL中,任何结点的bf的绝对值不大于1,换句话说如果某个结点的bf为2,-2那么这个结点就需要执行平衡操作。
性能:
AVL树以及根据其原理的派生树,排序虽然平均复杂度为O(nlgn),但是由于有额外的树调整操作和相关的cache,所以在性能上是不如qs排序,heap排序和merge排 序,但是一个“动态”就足够抵消掉这些性能上的损失了。而查找和删除,均为O(lgn),而在实际的运用中,AVL使用的并不是很多,而其派生的树—红黑树却得到极大的运用,根本原因就在于AVL树是严格的平衡树,使得它的所有操作几乎会靠近lgn,而且由于严格,在进行树调整的时候也会相对与非严格平衡树有更多的时间消耗。
插入
如何平衡:
AVL树采取的操作是旋转,这个词其实是相当的抽象,落实到实际的编程其实就是调整指针(或者说是调整不平衡结点与其父结点和孩子结点的关系),而左旋和右旋操作其实是逆时针和顺时针旋转结点,“旋转”一共分为四中情况:
1.LL(左左)---进行一次右旋操作。而左左的意思是由于在不平衡结点的左孩子的左子树上插入了结点而导致了树的不平衡,这里的“树的不平衡”是对于整颗树而言,对于局部也就是说以那个不平衡结点为根结点的树不平衡,根据定义,也就是说整颗树是不平衡的。右旋操作图如下:
其中,在B1中插入了一个结点而导致了A的不平衡,B1插入后的高度为h,B2的高度为h-1,A2的高度为h-1,所以导致了A的bf为2,所以需要平衡,进行右旋后,得到的A和B的bf均为0。由于在插入前B是平衡树,而A也是平衡树,在LL中插入导致了不平衡,那么可以推得B原来的bf一定为0,而A在插入后bf为2,可以推得A的右子树比A的左子树的高度少1,所以可以用上图的h-1和h来表示LL这种不平衡的情况,进行一次右旋,便得到了平衡的树,观察可以知道,平衡前的深度和平衡后的深度是相同的,所以不会影响A的父结点的平衡,而又由于A的左子树和A的右子树均为平衡树,所以B1,B2,A2的移动对于自身的平衡是没有任何影响的,所以,一次右旋操作可以解决LL失衡的情况。
2.RR对于RR,解释和LL相同,一次左旋操作可以解决平衡问题,就不再赘述,左旋的操作示意图如下:
3.LR,也就是对于失衡的结点,是由于在其左子树的右子树上面插入结点而引起的失衡,如果还是采取LL的操作是不能平衡树的,读者可以自己证明,LR需要进行两次旋转,一次是失衡结点的左子树的左旋,二次是在一次的基础上进行失衡结点的右旋。操作图如下:
从图中可以看出,如果不看A结点,假设以B结点为失衡结点(第一次旋转)进行左旋,根据上面的RR描述,C是不能达到平衡的,但是B是平衡的,所以将B看成一个整体---C的左子树,此时由于C的左子树深度为h,而右子树为h-2,所以可以认为是在C的左子树上插入了结点而导致了A的不平衡,So此时的情况刚好又是LL的情况,所以以A为失衡结点进行一次右旋操作将平衡整棵树。
4.RL 自然而然也就可以如LR一样推出结论,也就不再赘述,操作示意图如下:
所以从上面的四种旋转可以看出:
1.在插入中旋转操作只有两种—左旋和右旋
2.以上四种不平衡的情况均可以在进行一次或者两次旋转后平衡。
插入中,只有可能是在叶子结点,插入后也只有影响和不影响两种情况,而受影响的结点只有可能是父结点,和其兄弟结点是没有关系。
总结插入结点算法思路为:
向AVL插入可以通过如同向二叉排序树的插入一样,插入相应的结点后,向上回溯并检查每一个经过了的结点的bf,修改相应的需要修改的值,如果不平衡,那么进行需要的旋转操作。还是性能:由于查找的时间是O(lgn),而旋转操作是常数,所以插入的效率为O(lgn)。
具体的代码实现:
数据结构:
#define TRUE 1
#define FALSE 0
#define ERROR -1
#define OK 1
#define KeyType int
//以下是bf
#define LH 1
#define EH 0
#define RH -1
typedef int Status;
typedef int Boolean;
Boolean isTall = FALSE;
typedef struct Node{
struct Node *lchild;
struct Node *rchild;
int bf;
KeyType key;
}bstNode,*bstTree;
旋转操作:
void R_Rotate(bstTree *p) //其中p是失去平衡的最小子树的根节点
{
//对p为根节点的二叉排序树作右旋处理
bstTree lc = (*p)->lchild;
(*p)->lchild = lc->rchild;
lc->rchild = (*p);
(*p) = lc;
}
void L_Rotate(bstTree *p)
{
//对p为根节点的二叉排序树做左旋处理
bstTree rc = (*p)->rchild;
(*p)->rchild = rc->lchild;
rc->lchild = (*p);
(*p) = rc;
}
特别注意其中的双重指针,也就是这个p使得使用C语言写的插入相当的简洁,p是不是指向特定的结点,而是指向结点的指针的指针,这样,在旋转的时候,由于是需要修改被影响结点和父结点的关系,所以如果不使用双重指针,那么就必须知道父结点的指针,然后才能修改,而且在修改的时候还必须判断失衡的结点到底是父结点的左孩子还是父结点的右孩子,所以这个双重指针在这里用得十分的漂亮(我是这么觉得的,呵呵),有了双重指针后,便可以直接修改失衡结点的父结点的指针域,而不需要父结点的指针来进行操作。
然后就是针对四种情况的平衡—组合调用上面两种旋转,当然,这就需要左平衡和右平衡两种,两个术语的意思是如果失衡结点是LL和LR,那么进行左平衡,反之为右平衡,为什么会有这两中平衡?原因在于LL和RR进行的旋转分别为右旋和左旋,而LR和RL也是相反的,所以需要有两种平衡来平衡所有情况。
在进行平衡操作的时候,旋转是控制了结点之间的关系,也就是树的形状,左右平衡控制的便是结点的bf,由于结点的bf是否平衡的标志,所以显得十分十分的重要,对于不同的情况旋转后bf的变化是需要特别的慎重和仔细的,一错全错。左平衡的时候,分为是LL还是LR,右平衡同理为RR和RL,如何判断?比如左平衡,当然是左子树高就在左边--LL,而右子树高就在右边--LR。具体分析上面已经说过。代码中特别需要注意的还是那个双重指针。
具体代码如下:
void LeftBalance(bstTree *p)
{
//rc是被影响的节点的子节点
bstTree lc = (*p)->lchild;
bstTree rd;
switch(lc->bf)
{
case LH:
(*p)->lchild->bf = lc->bf = EH;
R_Rotate(p);
break;
case RH:
rd = lc->rchild;
switch(rd->bf)
{
case LH:
(*p)->bf = RH;lc->bf = EH;
break;
case EH:
(*p)->bf = lc->bf = EH;
break;
case RH:
(*p)->bf = EH;lc->bf = LH;
break;
}
rd->bf = EH;
L_Rotate(&((*p)->lchild));
R_Rotate(p);
break;
}
return;
}
void RightBalance(bstTree *p)
{
//rc是被影响的节点的子节点
bstTree rc = (*p)->rchild;
bstTree rd;
switch(rc->bf)
{
case RH:
(*p)->bf = rc->bf = EH;
L_Rotate(p);
break;
case LH:
rd = rc->lchild;
switch(rd->bf)
{
case LH:
rc->bf = RH;(*p)->bf = EH;
break;
case EH:
(*p)->bf = rc->bf = EH;
break;
case RH:
rc->bf = EH;(*p)->bf = LH;
break;
}
rd->bf = EH;
R_Rotate(&((*p)->rchild));
L_Rotate(p);
break;
}
return ;
}
对于插入操作,具体算法中最需要注意的是那个isTall标志位,由于在进行一次的平衡后整个树就都平衡了,或者插入后发现父结点的左子树和右子树深度一样了,就不需要进行继续的平衡,但是如果发现是左子树高了1,后面就会受到影响,isTall便是标志了是否还需要进行可能存在的平衡操作。还是需要提醒那个双重指针。
具体代码如下:
Status InsertAVL(bstTree *T,KeyType key)
{
//其中T是指向当前节点,key是需要插入的关键字的值
if(!(*T))
{
*T = (bstTree)malloc(sizeof(bstNode));
(*T)->key = key;
(*T)->lchild = (*T)->rchild = NULL;
(*T)->bf = EH;
isTall = TRUE;
}
else
{
if((*T)->key == key)
{
isTall = FALSE;
return 0;
}
if((*T)->key > key) //在左子树中查找
{
if(!InsertAVL(&((*T)->lchild),key))return 0; //继续在左子树中搜索
if(isTall)
{
switch((*T)->bf)
{
case LH:
LeftBalance(T);isTall = FALSE;
break;
case EH:
(*T)->bf = LH;isTall = TRUE;
break;
case RH:
(*T)->bf = EH;isTall = FALSE;
break;
}
}
}
else //在右子树中查找
{
if(!InsertAVL(&((*T)->rchild),key))return 0;
if(isTall)
{
switch((*T)->bf)
{
case LH:
(*T)->bf = EH;isTall = FALSE;
break;
case EH:
(*T)->bf = RH;isTall = TRUE;
break;
case RH:
RightBalance(T);isTall = FALSE;
break;
}
}
}
}
return OK;
}
以上便是插入的思路和算法,对接下来的删除有很大的启发,删除相对与插入要复杂一些,整体的思路可以理解为---将需要删除的结点旋转为叶子结点,然后删除叶子结点,进行回溯,判断是否需要进行平衡操作。
删除
对于删除,整体的思路如上面那段文字所说,受到插入算法的启发很大。删除算法的具体思路为:
0.如果结点为null,那么返回没有结点的信息。
1.如果为叶子结点,直接删除然后回溯。
2.如果该结点有左子树,那么将该节点的值和它的左子树最右边孩子(左子树的最大值)的值互换,继续查找。
3.如果该结点有有孩子,那么将该节点的值和它的右子树的最左孩子(右子树的最小值)的值互换,继续查找。
其中,3是在2不成立的基础上进行的,2或者3都会回到1或者0,这里和二叉排序树的删除思路类似,但是最大的区别是,二叉排序树在删除有孩子的结点的是否分为,只有一左孩子或者右孩子和都有,前者采取的是连接的操作,后者采取的是替换操作,但是最关键的一点AVL删除后需要回溯,判断是否有结点需要采取平衡操作。
所以总结删除的操作,其有别于插入的特点在于
1.实际被删除的结点总是叶子结点。
2.进行一次平衡操作后,实际上会减少失衡节点的父结点的深度,所以还是需要继续向上回溯,判断是否需要进行平衡-----这是区别于查找最大的不同,正是由于这个原因,所以需要写新的平衡函数(在插入描述中,isTall是由插入函数控制,而在删除中,由于2这个原因,需要由删除函数和平衡函数一起控制),但是旋转都是一样的。
3.由于删除算法的思路是值互换,而且还需要回溯,所以插入中的判断大小还需要增加一个相等的判断,进行回溯。
删除算法使用Java编写,这样一是为了和对比,二是这样对细节的把握更加充分。
数据结构:
public class AVLNode<E> {
private E data;
private AVLNode<E> lchild,rchild,parent; //由于是成员变量,所以自动初始化为null
private int bf;
}
//其中的get和set没有列出。
private final int RH = -1;
private final int EH = 0;
private final int LH = 1;
private boolean tall; // 用于表示孩子结点是否增长(主要用于判断是否需要修改结点的bf)
private boolean low; //用来表示孩子结点是否减少(主要用于判断是否会影响该结点bf的值)
private AVLNode<E> root;
旋转函数:
其中root是成员变量,根结点的引用。
private void L_Rotate(AVLNode<E> node) {
AVLNode<E> rc = node.getRchild();
node.setRchild(rc.getLchild());
if(node.getRchild() != null)
node.getRchild().setParent(node);
if(node.getParent() == null){
root = rc;
root.setParent(null);
}
else{
if(node.getParent().getLchild() == node)
node.getParent().setLchild(rc);
else
node.getParent().setRchild(rc);
rc.setParent(node.getParent());
}
rc.setLchild(node);
node.setParent(rc);
}
private void R_Rotate(AVLNode<E> node) {
AVLNode<E> lc = node.getLchild();
node.setLchild(lc.getRchild()); // 将被影响的结点的左孩子设置为其左孩子的右孩子
if(node.getLchild() != null)
node.getLchild().setParent(node);
if (node.getParent() == null) {
root = lc;
root.setParent(null);
}else{
if(node.getParent().getLchild() == node)
node.getParent().setLchild(lc);
else
node.getParent().setRchild(lc);
lc.setParent(node.getParent());
}
lc.setRchild(node);
node.setParent(lc);
}
其中的关系有可能会让你迷糊,但是C语言的简洁就在这里得到了体现。但是,Java这里描述了所有的相关结点变化关系,十分清楚明了。
然后是右平衡和左平衡,最大的不同在于其中不仅仅添加了对low(是否降低)的标志位的控制,还有一个EH的情况,读者可以自己列举揣摩其bf的变化。
private void D_RightBalance(AVLNode<E> node) {
AVLNode<E> rc = node.getRchild();
switch(rc.getBf()){
case LH:
AVLNode<E> rd = rc.getLchild();
switch (rd.getBf()) {
case LH:
node.setBf(EH);rc.setBf(RH);
break;
case EH:
node.setBf(EH);rc.setBf(EH);
break;
case RH:
rc.setBf(EH);node.setBf(LH);
break;
}
rd.setBf(EH);
R_Rotate(node.getRchild());
L_Rotate(node);
//由于是先右旋,这样就成了RH的情况,所以会减少根的左子树的长度,虽然平衡了,但是由于左子树的长度
//减少,所以还是要对根结点进行判定
low = true;
break;
case EH:
node.setBf(RH);
rc.setBf(LH);
L_Rotate(node);
low = false;
break;
case RH:
//虽然进行了右旋,使得node这个树平衡,但是由于右旋后使得
//node这个树结点的深度减少,所以必须判断是否对上面产生了影响
//这里是需要特别注意的地方,和插入最大的不同也就是在这个地方
//插入是增加深度,旋转后减少深度,由于二叉树的定义使得一次平衡后
//对其根结点没有影响,所以不需要再继续进行判断
node.setBf(EH);rc.setBf(EH);
L_Rotate(node);
low = true;
break;
}
}
private void D_LeftBalance(AVLNode<E> node) {
AVLNode<E> lc = node.getLchild();
switch(lc.getBf()){
case LH:
node.setBf(EH);lc.setBf(EH);
R_Rotate(node);
low = true;
break;
case EH:
node.setBf(LH);
lc.setBf(RH);
R_Rotate(node);
low = false;
break;
case RH:
AVLNode<E> rd = lc.getRchild();
switch(rd.getBf()){
case LH:
node.setBf(RH);
lc.setBf(EH);
break;
case EH:
node.setBf(EH);lc.setBf(EH);
break;
case RH:
node.setBf(EH);
lc.setBf(LH);
break;
}
rd.setBf(EH);
L_Rotate(node.getLchild());
R_Rotate(node);
low = true;
break;
}
}
下面是删除算法的具体描述:
private int nodeToDelete(AVLNode<E> node,E element){
@SuppressWarnings("unchecked")
Comparable<? super E> e = (Comparable<? super E>) element;
if(node == null){
//没有相应的值可以删除
low = false;
return 0;
}else{
/**
* 如果和某个结点的值相等,那么就使用下面的删除方法
*/
if(e.compareTo(node.getData()) == 0){
//如果该结点没有左子树或者没有右子树,就执行删除
if(node.getLchild() == null && node.getRchild() == null){
//实际上所有的删除操作都是这个if在执行,也就是说,实际上的删除只有
//在叶子结点进行
// if(node.getParent() == null){
// System.out.println("最后一个结点!");
// root = null;
// }
if(node.getParent().getLchild() == node)
node.getParent().setLchild(null);
else
node.getParent().setRchild(null);
//如果成功删除,那么就设置low为true
low = true;
return 1;
}
//如果左子树存在,那么就将node的值和左子树最右边的孩子交换
else if(node.getLchild() != null){
AVLNode<E> poir = node.getLchild();
while(poir.getRchild() != null){
poir = poir.getRchild();
}
E temp = poir.getData();
poir.setData(node.getData());
node.setData(temp);
if(nodeToDelete(node.getLchild(), element) == 0)return 0;
//由于和递归插入的判断条件不同,所以要在这里检查
//由于是存在左子树,对于有两个结点的孩子,会用到下面的
//判断递归,但是如果仅仅只有左孩子,那么就需要使用下面这个
//if来进行判断(由于结点的值是相等的,而不是有大小关系)
if(low){
switch(node.getBf()){
case LH:
node.setBf(EH);low = true;
break;
case EH:
node.setBf(RH);low = false;
break;
case RH:
D_RightBalance(node);
break;
}
}
}
//如果左子树不存在,而且右子树存在,那么就将node的值和右子树最左边的孩子交换
else if(node.getRchild() != null){
AVLNode<E> poir = node.getRchild();
while(poir.getLchild() != null){
poir = poir.getLchild();
}
E temp = poir.getData();
poir.setData(node.getData());
node.setData(temp);
if(nodeToDelete(node.getRchild(), element) == 0)return 0;
if(low){
switch(node.getBf()){
case LH:
D_LeftBalance(node);
break;
case EH:
node.setBf(LH);low = false;
break;
case RH:
node.setBf(EH);low = true;
break;
}
}
}
return 1;
}else if(e.compareTo(node.getData()) < 0){
if(nodeToDelete(node.getLchild(), element) == 0)
return 0;
if(low){
switch(node.getBf()){
case LH:
node.setBf(EH);low = true;
break;
case EH:
node.setBf(RH);low = false;
break;
case RH:
D_RightBalance(node);
break;
}
}
return 1;
}else{
if(nodeToDelete(node.getRchild(), element) == 0)return 0;
if(low){
switch(node.getBf()){
case LH:
D_LeftBalance(node);
break;
case EH:
node.setBf(LH);low = false;
break;
case RH:
node.setBf(EH);low = true;
break;
}
}
return 1;
}
}
}
删除操作并没有详细的描述,因为如果理解了插入,对于删除也就是上面说的那个算法,很好理解。
性能测试:
联想Y480-ISE 语言Java ubuntu12.04 4G 2.3GHz 4核
插入:
1到1千万的整数:
treeSet 4.5秒左右
AVL:4.8秒左右
也不知道是Java的原因或者是其他什么原因,一千五百万的时候就 java.lang.OutOfMemoryError: Java heap space ,我对Java和系统了解还不多,但是为什么会这样?计算的值也应该是2亿以上啊,我测试是接连测试的,从500万到1千五百万。大概测试了10次。如果Java的垃圾回收是有效的,应该是有理论计算值那样的啊,不知道为什么。。
treeSet和AVL都是这样的,但是C语言就至少可以跑1亿,时间大约为21秒左右,当我测试2.5亿的时候,我的机器。。。。我忘记我没有写free,这样理论估计有至少500M内存泄漏了,另外这个2.5亿测试的时候跑到大约30秒的时候,我的屏幕彻底卡了,结果我不得不从新启动。
当然,这个测试仅仅是娱乐性质的测试,但是还是有一点代表性,至少在8这个级数,AVL和红黑树并没有太大的差距。
删除也是在8这个级数获得了同样的数据,AVL和红黑树差距不大。
另外,我为了验证正确性,写了一个Swing演示,虽然很简单,但是对于AVL树插入和删除的理解还是有一定的帮助,如果需要可以留下邮箱。
由于知识水平有限,写得不好的地方还请指出。其中,图是google网上的图片,如有版权,请原谅指出,我声明。