C#与数据结构--树论--平衡二叉树(AVL Tree)
介绍
我们知道在二叉查找树中,如果插入元素的顺序接近有序,那么二叉查找树将退化为链表,从而导致二叉查找树的查找效率大为降低。如何使得二叉查找树无论在什么样情况下都能使它的形态最大限度地接近满二叉树以保证它的查找效率呢?
前苏联科学家 G.M. Adelson-Velskii 和 E.M. Landis 给出了答案。他们在 1962 年发表的一篇名为 《 An algorithm for the organization of information 》的文章中提出了一种自平衡二叉查找树( self-balancing binary search tree )。这种二叉查找树在插入和删除操作中,可以通过一系列的旋转操作来保持平衡,从而保证了二叉查找树的查找效率。最终这种二叉查找树以他们的名字命名为“ AVL-Tree ”,它也被称为平衡二叉树( Balanced Binary Tree )。这里所说的平衡使我们想到了中庸之道,但有句话说得好,“中不偏,庸不易”。学会这种平衡术是一个相当痛苦的过程。
什么是平衡
为了保证平衡, AVL 树中的每个结点都有一个平衡因子( balance factor ,以下用 BF 表示),它表示这个结点的左、右子树的高度差,也就是左子树的高度减去右子树的高度的结果值。 AVL 树上所有结点的 BF 值 只能是 -1 、 0 、 1 。反之,只要二叉树上一个结点的 BF 的绝对值大于 1 ,则该二叉树就不是平衡二叉树。图 1 演示了平衡二叉树和非平衡二叉树。
AVL 树的构造
如何构造一棵平衡二叉树呢?动态地调整二叉 查找树平衡的方法为:每插入一个结点后,首先检查是否破坏了树的平衡性,如果因插入结点而破坏了二叉查找树的平衡,则找出离插入点最近的不平衡结点,然后 将该不平衡结点为根的子树进行旋转操作,我们称该不平衡结点为旋转根,以该旋转根为根的子树称为最小不平衡子树。失衡状态可归纳为 4 种,它们对应着 4 种旋转类型。下面使用了 Flash 动画演示了这四种旋转类型,请确保你的电脑安装了 Flash8.0 以上版本的播放器,并且浏览器允许使用 Flash 。做这几个动画纯属好玩,希望有一天可以使用 Silverlight 做这些的动画。不过好象现在还没什么博客支持。
l LL 型旋转
如以上动画所示,插入结点 5 后,结点 50 的 BF 值由 1 变为 2 ,此时结点 50 为旋转根。这种插入结点 50 的左孩子的左子树而导致失衡的情况需要进行 LL 旋转( LL 意为左左)。可以观察到,虽然结点 50 的 BF 值由 1 变为了 0 ,但最小不平衡子树在插入结点 5 前和旋转后的高度不变。
l RR 型旋转
如以上动画所示,插入结点 90 后,结点 25 的 BF 值由 -1 变为 -2 ,此时结点 25 为旋转根。这种插入结点 25 的右孩子的右子树而导致失衡的情况需要进行 RR 旋转。最小不平衡子树在插入结点 90 前和旋转后的高度不变。
l LR 型旋转
插入旋转根的左孩子的右子树而导致失衡的情况需要进行 LR 旋转。这里演示了 LR(L) 和 LR(R) 两种情况。插入结点前和旋转后的最小不平衡子树高度不变。
l RL 型旋转
插入旋转根的右孩子的左子树而导致失衡的情况需要进行 RL 旋转。这里演示了 RL(L) 和 RL(R) 两种情况。插入结点前和旋转后的最小不平衡子树高度不变。
以上动画只演示了几种旋转类型的较复杂的情况,并没有全部演示,比如旋转根的左子树或右子树为空的情况,具体算法请参见稍后的代码。
AVL 树上结点的插入
AVL 算法的思想理解起来还是不太困难的,但如果真要使用代码实现就没那么简单了,它拥有超高的算法实现复杂度。我查了很多资料,大部分只给出主要算法代码,对于如何回溯修改 BF 值,如何处理不需要旋转的情况绝口不提,甚至对删除算法直接忽略。上网找资料,中文的,英文的全找了,大部分写代码不加注释,狂汗....,实在看不下去。大部分代码使用递归算法, C# 实现更是少得可怜,在国外网站找到一个,但使用了三叉链表实现,多加了一个 parent 指针,总之无法找到让人满意的代码。最后一咬牙一跺脚,自己实现。最让人头痛的莫过于如何处理插入和删除后的回溯和修改 BF 值,庆幸的是最终还是按照我最初的想法比较漂亮地实现了 AVL 树。优点是:无递归;无 parent 指针;插入和删除操作使用同一旋转方法,使代码更为简化。缺点是:为了兼顾效率,有些地方的处理比较特殊,代码很难完全读懂。
下面对本算法做原理上的介绍:
1、 如何回溯修改祖先结点的平衡因子
我们知道,在 AVL 树上插入一个新结点后,有可能导致其他结点 BF 值的改变,哪些结点的 BF 值会被改变?如何计算新的 BF 值呢?要解决这些问题,我们必须理解以下几个要点:
l 只有根结点到插入结(橙色结点)点路径(称为插入路径)上的结点的 BF 值会被改变。如图 2 所示,只有插入路径上结点(灰色结点)的 BF 值被改变,其他非插入路径上结点的 BF 值不变。
l 当一个结点插入到某个结点的左子树时,该结点的 BF 值加 1 (如图 2 的结点 50 、 43 );当一个结点插入到某个结点的右子树时,该结点的 BF 值减 1 (如图 2 的结点 25 、 30 )。如何在程序中判断一个结点是插入到左子树还是右子树呢?很简单,根据二叉查找树的特性可以得出结论:如果插入结点小于某个结点,则必定是插入到这个结点的左子树中;如果如果插入结点大于某个结点,则必定插入到这个结点的右子树中。
l 修改 BF 值的操作需从插入点开始向上回溯至根结点依次进行,当路径上某个结点 BF 值修改后变为 0 ,则修改停止。如图 3 所示,插入结点 30 后,首先由于 30<43 ,将结点 43 的 BF 值加 1 ,使得结点 43 的 BF 值由 0 变为 1 ;接下来由于 30>25 ,结点 25 的 BF 值由 1 改为 0 ;此时结点 25 的 BF 值为 0 ,停止回溯,不需要再修改插入路径上结点 50 的平衡因子。道理很简单:当结点的 BF 值由 1 或 -1 变为 0 ,表明高度小的子树添加了新结点,树的高度没有增加,所以不必修改祖先结点的平衡因子;当结点的 BF 值由 0 变为 1 或 -1 时,表明原本等高左右子树由于一边变高而导致失衡,整棵子树的高度变高,所以必须向上修改祖先结点的 BF 值。
2、 何时进行旋转操作?如何判断作什么类型的旋转?
在回溯修改祖先结点的平衡因子时,如果碰到某个结点的平衡因子变为 2 或 -2 ,表明 AVL 树失衡,这时需要以该结点为旋转根,对最小不平衡子树进行旋转操作。由于是从插入点开始回溯,所以最先碰到的 BF 值变为 2 或 -2 的结点必定为最小不平衡子树的根结点。如图 4 所示,插入 39 后, 43 和 50 两个结点的 BF 值都会变为 2 ,而必定先访问到结点 43 ,所以 43 是最小不平衡子树的根。根据以上 Flash 动画演示所示,旋转操作完成后,最小不平衡子树插入结点前和旋转完成后的高度不变,所以可以得出结论:旋转操作完成后,无需再回溯修改祖先的 BF 值。这样,图 4 中的结点 25 和 50 的平衡因子实际上在插入结点操作完成后的 BF 值不变(对比图 2 )。
可以通过旋转根及其孩子的 BF 值来决定作什么类型的旋转操作:
l 当旋转根的 BF 值为 2 时:
如果旋转根的左孩子的 BF 值为 1 ,则进行 LL 型旋转;
如果旋转根的左孩子的 BF 值为 -1 ,则进行 LR 型旋转。
l 当旋转根的 BF 值为 -2 时:
如果旋转根的右孩子的 BF 值为 1 ,则进行 RL 型旋转;
如果旋转根的右孩子的 BF 值为 -1 ,则进行 RR 型旋转。
可通过观察之前的 Flash 动画检验以上结论。
3、 如何保存插入路径?
可以使用栈来保存插入路径上的各个结点,但由于栈是由数组抽象而来,为了进一步加快 AVL 树的运行速度,我直接使用数组存放插入路径,这样可以减少方法的调用,尽量避免一些不必要的操作。
如果实现 AVL 树实现索引器,而在索引器中使用 int32 ,那么 AVL 树元素的长度不会超过一个 32 位整数的最大值。一个深度为 32 的满二叉树可以存放结点数为: 2^32-1=4294967295 ,这个值已经远远超出 32 位的整数范围,所以我将数组的长度定为 32 。这样就不必如 ArrayList 那样进行扩容操作了。另外本程序还使用了一个成员变量 p 用于指示当前访问结点,由于 p 指针的存在可以不必在每次进行插入和删除操作后清空数组中的元素,进一步增加了 AVL 树的运行速度。
使用数组的另一个好处是可以随时访问旋转根的双亲结点,以方便进行旋转操作时修改根结点。
AVL 树上结点的删除
AVL 树的删除操作与插入操作有许多相似之处,它的大体步骤如下:
⑴用二叉查找树的删除算法找到并删除结点(这里简称为删除点);
⑵沿删除点向上回溯,必要时,修改祖先结点的 BF 值;
⑶回溯途中,一旦发现某个祖先的 BF 值失衡,如插入操作那样旋转不平衡子树使之变为平衡,跟插入操作不同的是,旋转完成后,回溯不能停止,也就是说在 AVL 树上删除一个结点有可能引起多次旋转。
AVL 树上的删除和插入操作虽然大体相似,但还是有一些不同之处,大家需要注意以下几点:
1、 回溯方式的不同
在删除结点的回溯过程中,当某个结点的 BF 值变为 1 或 -1 时,则停止回溯。这一点同插入操作正好相反,因为 BF 值由 0 变为 1 或 -1 ,表明原本平衡的子树由于某个结点的删除导致了不平衡,子树的总体高度不变,所以不再需要向上回溯。
2、 旋转方式的不同
如图 5 所示:删除 AVL 树中的结点 25 导致结点 50 的 BF 值由原来的 -1 变为 -2 ,但旋转根 50 的右孩子的 BF 值为 0 ,这种情况在前面所讲的旋转操作中并不存在,那么是需要对它进行 RR 旋转还是 RL 旋转呢?正确方法是使用 RR 旋转,所不同之处是旋转后的 BF 值不同,需要单独处理。需要注意,这种情况在插入操作时不可能发生, LL 旋转也存在类型的情况。另外旋转完成后树的整体高度没有改变,所以大部分情况下旋转操作完成后,子树的高度降低,需要继续向上回溯修改祖先的 BF 值,而只有这种情况由于子树的高度未改变,所以停止回溯。
3、 删除点的选择特例
在二叉查找树中,我们知道当删除点 p 既有左子树,又有右子树,此时可以令 p 的中序遍历直接前驱结点代替 p ,然后再从二叉查找树中删除它的直接前驱。如图 7.13 所示,结点 5 既有左子树,又有右子树,它的直接前驱结点为 4 。在删除结点 5 时,首先用结点 4 代替结点 5 ,然后再删除结点 4 完成删除操作。这里需要注意的是此时必须将删除前的结点 4 作为删除点来进行向上回溯操作,而不是结点 5 。
AVL 树的代码实现
这里没有给出 AVL 树的泛型实现,它只存放整数。因为如果使用泛型实现并按照微软惯例,使用键 / 值对实现,那么代码真的就很难读懂了。以这个代码为基础,改为泛型实现是很容易的事。另外 C# 中没 AVL 树的实现,而实现了红黑树,说明红黑树更有效率,所以也不必将 AVL 泛型化,代码忽略了部分出错可能。红黑树将在后面讲解。
public class BinarySearchTree : IBinaryTree // 实现画树接口
{ // 成员变量
private Node _head; // 头指针
private Node[] path = new Node[ 32 ]; // 记录访问路径上的结点
private int p; // 表示当前访问到的结点在_path上的索引
INode IBinaryTree.Head // 显式接口实现
{
get { return (INode)_head; }
}
public bool Add( int value) // 添加一个元素
{ // 如果是空树,则新结点成为二叉排序树的根
if (_head == null )
{
_head = new Node(value);
_head.BF = 0 ;
return true ;
}
p = 0 ;
// prev为上一次访问的结点,current为当前访问结点
Node prev = null , current = _head;
while (current != null )
{
path[p ++ ] = current; // 将路径上的结点插入数组
// 如果插入值已存在,则插入失败
if (current.Data == value)
{
return false ;
}
prev = current;
// 当插入值小于当前结点,则继续访问左子树,否则访问右子树
current = (value < prev.Data) ? prev.Left : prev.Right;
}
current = new Node(value); // 创建新结点
current.BF = 0 ;
if (value < prev.Data) // 如果插入值小于双亲结点的值
{
prev.Left = current; // 成为左孩子
}
else // 如果插入值大于双亲结点的值
{
prev.Right = current; // 成为右孩子
}
path[p] = current; // 将新元素插入数组path的最后
// 修改插入点至根结点路径上各结点的平衡因子
int bf = 0 ;
while (p > 0 )
{ // bf表示平衡因子的改变量,当新结点插入左子树,则平衡因子+1
// 当新结点插入右子树,则平衡因子-1
bf = (value < path[p - 1 ].Data) ? 1 : - 1 ;
path[ -- p].BF += bf; // 改变当父结点的平衡因子
bf = path[p].BF; // 获取当前结点的平衡因子
// 判断当前结点平衡因子,如果为0表示该子树已平衡,不需再回溯
// 而改变祖先结点平衡因子,此时添加成功,直接返回
if (bf == 0 )
{
return true ;
}
else if (bf == 2 || bf == - 2 ) // 需要旋转的情况
{
RotateSubTree(bf);
return true ;
}
}
return true ;
}
// 删除指定值
public bool Remove( int value)
{
p = - 1 ;
// parent表示双亲结点,node表示当前结点
Node node = _head;
// 寻找指定值所在的结点
while (node != null )
{
path[ ++ p] = node;
// 如果找到,则调用RemoveNode方法删除结点
if (value == node.Data)
{
RemoveNode(node); // 现在p指向被删除结点
return true ; // 返回true表示删除成功
}
if (value < node.Data)
{ // 如果删除值小于当前结点,则向左子树继续寻找
node = node.Left;
}
else
{ // 如果删除值大于当前结点,则向右子树继续寻找
node = node.Right;
}
}
return false ; // 返回false表示删除失败
}
// 删除指定结点
private void RemoveNode(Node node)
{
Node tmp = null ;
// 当被删除结点存在左右子树时
if (node.Left != null && node.Right != null )
{
tmp = node.Left; // 获取左子树
path[ ++ p] = tmp;
while (tmp.Right != null ) // 获取node的中序遍历前驱结点,并存放于tmp中
{ // 找到左子树中的最右下结点
tmp = tmp.Right;
path[ ++ p] = tmp;
}
// 用中序遍历前驱结点的值代替被删除结点的值
node.Data = tmp.Data;
if (path[p - 1 ] == node)
{
path[p - 1 ].Left = tmp.Left;
}
else
{
path[p - 1 ].Right = tmp.Left;
}
}
else // 当只有左子树或右子树或为叶子结点时
{ // 首先找到惟一的孩子结点
tmp = node.Left;
if (tmp == null ) // 如果只有右孩子或没孩子
{
tmp = node.Right;
}
if (p > 0 )
{
if (path[p - 1 ].Left == node)
{ // 如果被删结点是左孩子
path[p - 1 ].Left = tmp;
}
else
{ // 如果被删结点是右孩子
path[p - 1 ].Right = tmp;
}
}
else // 当删除的是根结点时
{
_head = tmp;
}
}
// 删除完后进行旋转,现在p指向实际被删除的结点
int data = node.Data;
while (p > 0 )
{ // bf表示平衡因子的改变量,当删除的是左子树中的结点时,平衡因子-1
// 当删除的是右子树的孩子时,平衡因子+1
int bf = (data <= path[p - 1 ].Data) ? - 1 : 1 ;
path[ -- p].BF += bf; // 改变当父结点的平衡因子
bf = path[p].BF; // 获取当前结点的平衡因子
if (bf != 0 ) // 如果bf==0,表明高度降低,继续后上回溯
{
// 如果bf为1或-1则说明高度未变,停止回溯,如果为2或-2,则进行旋转
// 当旋转后高度不变,则停止回溯
if (bf == 1 || bf == - 1 || ! RotateSubTree(bf))
{
break ;
}
}
}
}
// 旋转以root为根的子树,当高度改变,则返回true;高度未变则返回false
private bool RotateSubTree( int bf)
{
bool tallChange = true ;
Node root = path[p], newRoot = null ;
if (bf == 2 ) // 当平衡因子为2时需要进行旋转操作
{
int leftBF = root.Left.BF;
if (leftBF == - 1 ) // LR型旋转
{
newRoot = LR(root);
}
else if (leftBF == 1 )
{
newRoot = LL(root); // LL型旋转
}
else // 当旋转根左孩子的bf为0时,只有删除时才会出现
{
newRoot = LL(root);
tallChange = false ;
}
}
if (bf == - 2 ) // 当平衡因子为-2时需要进行旋转操作
{
int rightBF = root.Right.BF; // 获取旋转根右孩子的平衡因子
if (rightBF == 1 )
{
newRoot = RL(root); // RL型旋转
}
else if (rightBF == - 1 )
{
newRoot = RR(root); // RR型旋转
}
else // 当旋转根左孩子的bf为0时,只有删除时才会出现
{
newRoot = RR(root);
tallChange = false ;
}
}
// 更改新的子树根
if (p > 0 )
{
if (root.Data < path[p - 1 ].Data)
{
path[p - 1 ].Left = newRoot;
}
else
{
path[p - 1 ].Right = newRoot;
}
}
else
{
_head = newRoot; // 如果旋转根为AVL树的根,则指定新AVL树根结点
}
return tallChange;
}
// root为旋转根,rootPrev为旋转根双亲结点
private Node LL(Node root) // LL型旋转,返回旋转后的新子树根
{
Node rootNext = root.Left;
root.Left = rootNext.Right;
rootNext.Right = root;
if (rootNext.BF == 1 )
{
root.BF = 0 ;
rootNext.BF = 0 ;
}
else // rootNext.BF==0的情况,删除时用
{
root.BF = 1 ;
rootNext.BF = - 1 ;
}
return rootNext; // rootNext为新子树的根
}
private Node LR(Node root) // LR型旋转,返回旋转后的新子树根
{
Node rootNext = root.Left;
Node newRoot = rootNext.Right;
root.Left = newRoot.Right;
rootNext.Right = newRoot.Left;
newRoot.Left = rootNext;
newRoot.Right = root;
switch (newRoot.BF) // 改变平衡因子
{
case 0 :
root.BF = 0 ;
rootNext.BF = 0 ;
break ;
case 1 :
root.BF = - 1 ;
rootNext.BF = 0 ;
break ;
case - 1 :
root.BF = 0 ;
rootNext.BF = 1 ;
break ;
}
newRoot.BF = 0 ;
return newRoot; // newRoot为新子树的根
}
private Node RR(Node root) // RR型旋转,返回旋转后的新子树根
{
Node rootNext = root.Right;
root.Right = rootNext.Left;
rootNext.Left = root;
if (rootNext.BF == - 1 )
{
root.BF = 0 ;
rootNext.BF = 0 ;
}
else // rootNext.BF==0的情况,删除时用
{
root.BF = - 1 ;
rootNext.BF = 1 ;
}
return rootNext; // rootNext为新子树的根
}
private Node RL(Node root) // RL型旋转,返回旋转后的新子树根
{
Node rootNext = root.Right;
Node newRoot = rootNext.Left;
root.Right = newRoot.Left;
rootNext.Left = newRoot.Right;
newRoot.Right = rootNext;
newRoot.Left = root;
switch (newRoot.BF) // 改变平衡因子
{
case 0 :
root.BF = 0 ;
rootNext.BF = 0 ;
break ;
case 1 :
root.BF = 0 ;
rootNext.BF = - 1 ;
break ;
case - 1 :
root.BF = 1 ;
rootNext.BF = 0 ;
break ;
}
newRoot.BF = 0 ;
return newRoot; // newRoot为新子树的根
}
}
运行效果如图 7 所示: