数据结构与算法-二叉查找树平衡(DSW)

1,929 阅读15分钟

上一节探讨了二叉查找树的基本操作,二叉查找树的查找效率在理想状态下是O(lgn),使用该树进行查找总是比链表快得多。但是,该论点并不总是正确,因为查找效率和二叉树的形状息息相关。就像这样:


图1-1给出了3颗二叉查找树,它们存储着相同的数据,但很明显,图1-1(A)的树是最好的。在最坏的情况下,图A定位一个对象需要3次测试,图C需要6次。原因在于,图C的数据不是平均分布的,该树实际上已经退化成一个链表,已经失去了二叉查找树的优越性。

这里需要引入一个新的概念,叫做平衡。如果树中任一节点的两个子树的高度差为0或者1,该二叉树就是高度平衡的或者简称平衡的。例如,图B中的节点20,其子树的高度差是1,这是可以接受的。 但是对于节点10,其子树的高度差是3,这意味着整棵树是不平衡的。另外,如果树是平衡的,并且该树所有叶节点都出现在一个或者两个层次上,那么该树是完全平衡的。

那么问题来了,如何得到一颗平衡的二叉查找树?

许多技术都可以适当的平衡二叉树。一些技术对数据重新排序从而创建一颗平衡的二叉树,另一些技术在由于插入或者删除元素而导致树不平衡时,会重新平衡树。我们首先来探讨如何创建一颗平衡二叉查找树,然后介绍如何重新平衡已有的二叉查找树。

想要创建一颗平衡二叉查找树,首先要观察这种树的特性,根据观察到的规律总结出来的数学逻辑,其实就是算法。就像下面:


发现了吗?如果将一颗完美的平衡的二叉查找树压平,将数据线性列出,你会发现它是有序的,并且根节点30处于数组A中间的位置。不止如此,根节点30的左子树的根节点20处于数组B的中间位置,根节点30的右子树的根节点47处于数组C的中间位置。以此类推,所有子树的根节点总是处于某个数组的中间位置。这很类似二分查找的逻辑,并且你是否发觉,数组A其实是该二叉树中序遍历的结果。

这里有个定论:二叉查找树的中序遍历可以得到有序的数据流

证明也很容易,这里使用自然语言简单描述下:

假如要对一颗二叉查找树进行中序遍历,首先将其分解成根节点,左子树,右子树。因为中序遍历的逻辑是首先遍历左子树,然后是根节点,最后是右子树。我们可以将它们放到一个栈中,大概长得是这个样子:


根据二叉查找树的定义,左子树所有节点小于根节点,右子树所有节点大于根节点。因此,栈此时的状态是有序的。最后,我们将栈中的子树全部分解成新的根节点、左子树、右子树,并且按照上图所示的顺序放入栈中,直到栈中不再存在子树,全部分解成了根节点。最后是这个样子的:


栈中保存的数据流就是中序遍历的结果。由于从分解开始是有序的,并且随后的每一次分解都是有序的,最终形成的数据流也是有序的。

这就证明了上面的定论: 二叉查找树的中序遍历可以得到有序的数据流

好吧,说了这么多,其实是在总结我们观察平衡二叉查找树找到的规律:

将平衡二叉查找树进行中序遍历,可以得到一个有序的数据流,并且根节点处于数据流的中间位置。以此类推,右子树的根节点处于右子树数据流的中间位置,左子树的根节点处于左子树数据流的中间位置。

根据以上规律,我们可以得到一个创建平衡二叉查找树的算法,首先用自然语言进行描述:

假设我们有一个有序的数组,数组中元素个数为n。我们可以将数组中间元素指定为根,这个数组现在包含两个子数组:一个包含从数组的开始到刚刚选为根的元素之间的所有元素,另一个包含刚刚选为根的元素到数组的末尾之间的所有元素。根的左子节点指定为第一个子数组的中间元素,根的右子节点指定为第二个子数组的中间元素。以此类推,数组中的每个元素都可以放到二叉树中,最终形成的二叉树就是一颗平衡二叉查找树。

代码如下:

void balance(int data[], int first, int last) { 
        if (first <= last) {
                int middle = (first + last)/2;         
                insert(data[middle]);
                balance(data, first, middle - 1);
                balance(data, middle + 1, last);   
        }
}

代码中使用了递归,递归可以使程序逻辑变得简单,但是会加大运行时栈的负担,慎用。我们这里只是探讨算法,因此,使用递归实现是可以的。

该算法存在严重的缺陷:在创建树之前,所有的数据都必须放在数组中。当必须使用树,但是准备保存到树中的数据仍然在输入的时候,该算法就不太合适了。我们可以使用折中的方法,如果数据在持续输入,我们可以按照创建二叉查找树的方法,将数据保存到二叉树中。数据输入完毕之后,只需要对该树进行中序遍历,就可以得到有序的数据流,然后使用上述的算法,就可以得到一颗平衡的二叉查找树。

上诉讨论的算法效率有点低,因为在创建完全平衡的树之前,需要使用一个额外的有序数组。为了避免排序,这一算法需要破坏树并用中序遍历把元素放在数组中,然后重建该树,这样做效率并不高,除非树很小。然而,存在几乎不需要存储中间变量也不需要排序过程的算法。这就是DSW算法。该算法可以对已经存在的二叉查找树进行平衡,并且不需要中间变量。

老规矩,在不劳而获的获取DSW算法之前,我们先自己分析一下如何将一颗二叉查找树进行平衡。先思考一个问题:


如何将上图中的二叉查找树重新构建成一颗平衡的二叉查找树,为了方便找到规律,这里给出平衡之后的二叉查找树:


是不是发现很相似?如果将平衡之后的二叉查找树从右上角到左下角压平,可以得到和原始二叉查找树相似的结构。那么,通过哪些操作可以将原始二叉树转变成平衡二叉树呢?我们首先观察左上角的4个节点:5、10、20、15。可以屏蔽掉其他节点,把它们当做不存在。如果你了解二叉树节点的左旋操作,马上就能明白,只要将20节点围绕其父节点10左旋转,马上就可以得到平衡之后的二叉树。什么是左旋?左旋有什么作用?我们首先来探讨左旋的作用,然后探讨左旋的原理。左旋可以提升根节点左子树的高度,降低根节点右子树的高度,并且左旋之后依然是二叉查找树。比如,5、10、20、15。10作为根节点,左子树高度为1,右子树高度为2。经过左旋之后,20成为新的根节点,左子树高度提升为2,右子树高度降低为0。可以发现,左旋之后的二叉树依然是二叉查找树。那么左旋到底是什么呢?左旋其实与二叉树节点的合并删除算法非常相似,并且原理是一致的,在数据结构与算法-二叉查找树这篇文章中,详细讲解了合并删除的原理。这里简单描述下左旋的操作以及原理。依然以5、10、20、15节点为例,左旋是针对右子树的根节点来说的,对称的右旋是针对左子树的根节点来说的。在这里,20节点作为10节点的右子节点,可以围绕10节点进行左旋操作。首先,将根节点10以及左子树作为A组,将20节点所在的右子树作为B组,左旋就是将A组合并到B组上。将A组设置为20节点的左子树,将20节点原有的左子树设置为A组的右子树。就像这样:


将A组合并到B组,原则上来讲,只要20节点到10节点的路径中不存在右指针即可,因为,一旦出现右指针,就意味着,B组中存在节点小于A组节点,但是我们都知道,A组是二叉查找树的根节点以及左子树组成,所有节点都小于B组节点(右子树)。在左旋操作中,将A组设置为20节点的左子树,只有一个左指针,因此,该操作是合法的。20节点原有的左子树需要合并到A组上,原则上来讲,只要10节点到15节点的路径中不存在左指针即可,原理和上诉类似。因为10的右子树为空,所以这里就将15节点直接设置为10节点的右子树。到此为止,左旋操作完毕,因为左旋操作本质是二叉查找树中合法的子树合并操作,所以最后的二叉树也是合法的二叉查找树,但是左旋提高了左子树的高度,降低了右子树的高度,左旋和右旋是对称的,有兴趣的可以自行了解。将目光放到平衡二叉查找树上,比较平衡之前的二叉树以及平衡之后的二叉树。可以发现,只要进行两步操作就能实现转变。第一步,分别对20节点、30节点、49节点进行左旋操作。第二步,继续对30节点进行左旋操作。搞定收工,下面总结通用算法。

如果忽略最后一层的叶子节点,剩余的二叉树是一个完美二叉树的线性排列。那么,该完美二叉树的元素个数是多少呢?我们假设原二叉查找树元素个数为n,完美二叉树的高度为h,那么可以得到不等式2^h - 1 <= n,即h <= lg(n+1),只要不等式向下取整,就可以获取到完美二叉树的高度。那么,完美二叉树元素个数m为2^h - 1。一眼就能看出m是个奇数,并且在第一次左旋时,是从上到下第二个节点开始的,做多少次左旋呢?其实是m/2次。带入到上述二叉查找树中,完美二叉树高度h为lg(12 + 1),向下取整为3,完美二叉树元素个数m为2^h - 1,即7,第一次左旋次数为m/2,即3。上述平衡二叉查找树过程中,一共有两步,第一步做了3次左旋,第二步做了1次左旋。可以发现,根据不同高度(h)的完美二叉树,需要做不同的步数(p),它们的关系是p = h - 1。当然,终止条件也可以是另一种。可以发现,假设完美二叉树元素个数为m,那么第一步左旋次数为m/2,记为m1,第二步左旋次数为m1/2,记为m2,以此类推,如果m(n)小于1,证明已经平衡完毕。

到此为止,我们已经总结出了平衡二叉查找树的关键逻辑。问题从平衡二叉查找树转变成了如何获取类似下图的二叉树?


可以发现,二叉查找树总的元素个数n为12,从右上到左下,最外层的节点数m是7。发现了吗?其实7就是该二叉查找树包含的完美二叉树元素个数。通过lg(n + 1)向下取整可以得到完美二叉树的高度h,通过2^h - 1可以得到完美二叉树元素个数m。将12带入公式,可以得到h为3,m为7。内层有5个节点5、15、23、28、40,如果分别对它们进行右旋操作,可以得到以下图形:


是不是很熟悉?这不就是二叉查找树最差的链表形式嘛!真是造化弄人,我们在平衡二叉查找树的过程中,竟然还需要借助二叉树的链表形式。从链表形式的第二个节点开始,每次隔一个节点进行n - m次左旋,即5次左旋,就能得到我们想要的二叉树形式。现在问题又转变成了,如何从一个普通的二叉查找树获取到最差的链表形式?答案是从根节点开始,沿着右子树,不停的右旋(提高右子树高度,降低左子树高度),直至所有左指针为空。就像下面动图:


到目前为止,我们已经探讨出从普通二叉查找树到平衡二叉查找树的过程。其实这就是DSW算法。总结如下:

  • 创建主链

创建主链就是从普通二叉查找树转变到只有右指针的链式结构,伪代码如下:

createMainChain(root) {
        tmp = root;     
        while(tmp != 0) {
                if tmp有左子节点
                        围绕tmp右旋左子节点;             //这样左子节点将成为tmp的父节点
                        tmp设置为刚刚成为父节点的子节点;
                else  将tmp设置为它的右子节点; 
        }
}
可以发现,创建主链的过程就是不停的右旋,直至二叉树中不存在左指针。
  • 主链转换成平衡树

主链转换成平衡树其实有两步,第一步就是从主链转变成类似下面的图形:


二叉树一共有n(12)个节点,外层节点数n(7)是该二叉树包含的完美二叉树个数,计算方式在上面已经探讨过,就是lg(n + 1)向下取整获取完美二叉树高度h(3),然后由2^h - 1获取到完美二叉树个数m(7),那么内层叶子节点个数就是n - m,即5个。我们只要从主链的第二个节点开始,每隔一个节点进行一次左旋,一共进行n - m次(5次)即可。

第二步就是将以上图形转变成平衡二叉查找树,算法逻辑已经探讨过了。其实就是将外层节点从第二个开始,每隔一个节点进行一次左旋,一共进行m/2次,记为m1。这时,最外层节点个数成为m1,继续从第二个开始,每隔一个节点进行一次左旋,一共进行m1/2次,记为m2。以此类推,终止条件是m(n)小于1,或者是循环h - 1次。

算法中的逻辑,也就是为什么这么做?全部已经在上面探讨清楚,可以反复阅读加深理解。

第二步的逻辑总结一下就是不停的左旋(提高左子树高度,降低右子树高度),直至二叉查找树平衡。

伪代码如下:

createBalanceTree() {     
        n = 节点数;     
        m = 2^h - 1;    
        从主链的顶部第二个节点开始,每隔一个节点进行左旋,一共进行n - m次; 
        while(m > 1) {
                m = m/2;
                从主链的顶部第二个节点开始,每隔一个节点进行左旋,一共进行m次;       
        }
}

DSW算法已经探讨完毕。DSW算法是对已有的二叉查找树进行全局平衡的算法,二叉树中的每个节点都有可能改变位置。它的算法复杂度是O(n),这一时间随着n线性增长,而且只需要很小且固定的存储空间。总之,DSW是一个非常优秀的对二叉树进行全局平衡的算法。

到目前为止,我们探讨了从数组中如何创建一颗平衡的二叉查找树以及如何对已有的普通二叉查找树进行全局平衡。但是,还有一个问题没有解决,二叉查找树之所以不平衡,通常是由于插入或者删除操作造成的。这种不平衡通常是局部不平衡,这种时候不需要使用DSW算法对全局进行平衡。重新平衡只需要在局部进行即可,这就是大名鼎鼎的AVL树,这是我们下节需要探讨的内容。

数据结构与算法-二叉查找树平衡(AVL)