标签: 数据结构与算法分析

平衡二叉树(AVL 树):概念、实现原理和算法代码

引入

上一篇中已经介绍了二叉搜索树(BST),在二叉搜索树的复杂度分析中,我们提到,二叉搜索树的算法复杂度与其拓扑结构(具体来说是其树的深度)有关。当 BST 最为「平衡」时,查找、删除、插入的算法为 O(\log{n}),而最不平衡情况下,算法复杂度为 O(n)

当节点数 n 增长时,O(\log{n}) 的算法相对于 O(n) 的算法在运算次数上增长较慢,性能更优。

因此,我们希望按照更加「平衡」的方式构建二叉树。那么,能否让二叉树一层一层地「铺满」,构建出一个完全二叉树?可以的,但是这样的插入算法较为复杂。我们下面介绍一种比较容易实现的解决平衡二叉树的算法——AVL 树,它是由两位俄罗斯数学家 G. M. Adelson-Velsky 和 Evgenii Landis 在 1962 年发明的。

定义

怎样的一棵树可以被称为高度平衡的呢?

一棵 BST 若要成为平衡二叉树,应满足:

  • 其左子树和右子树深度之差绝对值不超过 1
  • 其左子树和右子树也是平衡二叉树

这是一个具有递归特征的定义。值得注意的是,从定义上说,空树也属于一种特殊的平衡二叉树。

引入平衡因子 (Balance Factor, BF) 的概念,平衡因子是指二叉树上一个节点的左子树与右子树的深度之差。一棵平衡二叉树,其任意节点上的平衡应为 – 1、0 或 1。

根据平衡二叉树的定义,下面给出一些例子,节点左上角标注的是当前节点的平衡因子:

图一是一个平衡二叉树,它满足平衡二叉树的定义。图二不是平衡二叉树,其原因并不是不满足平衡因子的条件,而是因为它不满足 BST 的构成条件,这提醒我们平衡二叉树首先要是一棵 BST。图三满足平衡二叉树的构成条件。图 4 中的节点 (8) 平衡因子为 3,不满足平衡二叉树的要求。

实现原理

概述

AVL 树的实现原理是,在构建 BST 时,时刻对 BST 进行「监督」,即每插入一个节点,便检查插入操作是否破坏了 BST 的平衡性。

如果当前的插入操作破坏了平衡性,那么要找出最小不平衡子树,对其执行某种「旋转」操作来调整其中节点的连接关系,使之重新平衡。

这里所说的最小不平衡子树,指的是以离插入节点最近,且平衡因子绝对值大于 1 的节点为根的子树。(注意:是离插入节点最近,而不是离根节点最近)

那么问题的关键就是,如何进行这种「旋转」调整,使得树始终保持平衡?下面将进行叙述。

根据插入操作发生位置的不同,可以将需要进行调平的情况分成下面四类(记最小不平衡子树的根节点为 N):

  • 情况①:对 N 的左儿子的左子树进行插入之后(左 – 左插入)
  • 情况②:对 N 的左儿子的右子树进行插入之后(左 – 右插入)
  • 情况③:对 N 的右儿子的左子树进行插入之后(右 – 左插入)
  • 情况④:对 N 的右儿子的右子树进行插入之后(右 – 右插入)

下面是四种情况的图示:

可以看出情况①和情况④是对称的,情况②和情况③是对称的,所以在理解上实际只需要理解两种情况,但是在编程时要区分四种情况。

我们先统领性地讲一下针对情况①④和情况②③采用的调平方法:

  • 情况①④(即左 – 左和右 – 右),通过单旋转进行调平。
  • 情况②③(即左 – 右和右 – 左),通过双旋转进行调平。

下面具体介绍两种旋转方法。

单旋转

单旋转可以用于处理情形①和情形④,其中情形①需要右旋(顺时针旋转)调平,情形④需要左旋(逆时针旋转)调平。下面通过两个实例来理解这里提到的右旋和左旋。

首先看上图的上半部分。向原 BST 中插入节点 (6) 时破坏了 BST 的平衡,标为红色节点 (8) 处为最小不平衡子树的根节点(显然,节点 (6) 是对节点 (8) 左儿子的左子树进行插入,符合情形①)。将以节点 (8) 为根节点的最小不平衡子树「取出」,我们发现,如果对这棵子树进行一次右旋(顺时针旋转),让节点 (7) 成为根节点,节点 (6) 和节点 (8) 成为节点 (7) 的左右子节点,将其放回原 BST,即可保持 BST 的平衡。

同样的,对于情形④,对最小不平衡子树进行一次左旋(顺时针旋转),便可保持 BST 平衡。

下面给出一个更加形式化的操作说明:

如上图,对于情况①,标为红色的 n_2 为其最小不平衡子树的根节点,为了使 BST 重新平衡,将其进行一次右旋,使得 X 抬高一层,Z 下降一层,同时 Y 作为 n_1 的右子树被转移给 n_2 作为左子树。情况④是对称的,不再赘述。

通过单次左旋或右旋,即可调整情况①④导致的不平衡。

双旋转

单次旋转已经可以处理一部分的不平衡,但是针对情况②③,却是无效的,这里用形式化的表示来说明这种无效的情况:

可见,对于情况②,一次旋转并不能消除不平衡。情况③与情况②对称,可想而知结果相同。

对此,需要借助左 – 右双旋转或右 – 左双旋转,下面是一个实例。

节点 (3) 的插入破坏了节点 (4) 处的平衡,节点 (4) 为最小不平衡子树的根节点。如图所示,先执行左旋,再执行右旋,即可通过两次旋转纠正不平衡。

下面给出形式化的操作图示:

代码实现

搞清楚了如何进行通过「旋转」进行调平,便可以写出 AVL 树的代码了。

单旋转

/* 向左单旋转 */
static Position SingleRotateWithLeft(Position K2)
{
    Position K1;

    K1 = K2->Left;
    K2->Left = K1->Right;
    K1->Right = K2;

    K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
    K1->Height = Max(Height(K1->Left), K2->Height) + 1;

    return K1;
}

/* 向右单旋转 */
static Position SingleRotateWithRight(Position K1)
{
    Position K2;

    K2 = K1->Right;
    K1->Right = K2->Left;
    K2->Left = K1;

    K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;
    K2->Height = Max(Height(K2->Right), K1->Height) + 1;

    return K2;
}

双旋转

/* 左 - 右双旋转 */
static Position DoubleRotateWithLeft(Position K3)
{
    K3->Left = SingleRotateWithRight(K3->Left);
    return SingleRotateWithLeft(K3);
}

/* 右 - 左双旋转 */
static Position DoubleRotateWithRight(Position K1)
{
    K1->Right = SingleRotateWithLeft(K1->Right);
    return SingleRotateWithRight(K1);
}

插入操作

/* 向 AVL 中插入节点 */
AvlTree Insert(ElementType X, AvlTree T)
{
    if (T == NULL)
    {
        T = malloc(sizeof(struct AvlNode));
        if (T == NULL)
            FatalError("Out of space!!!");
        else
        {
            T->Element = X;
            T->Height = 0;
            T->Left = T->Right = NULL;
        }
    }
    else if (X < T->Element)
    {
        T->Left = Insert(X, T->Left);
        if (Height(T->Left) - Height(T->Right) == 2)
            if (X < T->Left->Element)
                T = SingleRotateWithLeft(T);
            else
                T = DoubleRotateWithLeft(T);
    }
    else if (X > T->Element)
    {
        T->Right = Insert(X, T->Right);
        if (Height(T->Right) - Height(T->Left) == 2)
            if (X > T->Right->Element)
                T = SingleRotateWithRight(T);
            else
                T = DoubleRotateWithRight(T);
    }

    T->Height = Max(Height(T->Left), Height(T->Right)) + 1;
    return T;
}

参考资料

[1] 程杰. 大话数据结构 [M]. 清华大学出版社: 北京, 2011:328-341.
[2] (美)Mark Allen Weiss. 数据结构与算法分析: C 语言描述 [M]. 机械工业出版社: 北京, 2017:80-89.
[3] Vamei(张腾飞). 纸上谈兵: AVL 树 [EB/OL].https://www.cnblogs.com/vamei/archive/2013/03/21/2964092.html,2013-3-21.

二叉查找树(BST):概念、基本操作和性能分析

二叉查找树 (Binary Search Tree, BST) 是二叉树在查找中的一种重要应用形式。

在这篇文章接下来的叙述中,做出以下假设:

  • 尽管二叉树节点中存储的数据类型是任意的,但为了大小比较和理解的方便,本文中将数据类型指定为整数。
  • 各个节点中的存储的数据是没有重复的。

定义

若一棵二叉树为二叉查找树,那么它必须满足:

  • 对于树中的任意节点 X,若其左子树不为空,则左子树中每一个节点的值都小于其根节点的值
  • 对于树中的任意节点 X,若其右子树不为空,则右子树中每一个节点的值都大于或等于其根节点的值
  • 对于树中的任意节点 X,其左子树、右子树也为二叉查找树

这是一个明显带有递归特征的定义。根据这个定义,下面给出一个正确的 BST 示例和两个错误的 BST 实例。

声明

我们给出 BST 的类型定义并列出 BST 的一系列基本操作,如下即为 BST 的声明程序。

typedef int ElementType;

struct TreeNode;
typedef struct TreeNode *Position;
typedef struct TreeNode *SearchTree;

/*BST 的基本操作定义 */
SearchTree MakeEmpty(SearchTree T);
Position Find(ElementType X, SearchTree T);
Position FindMin(SearchTree T);
Position FindMax(SearchTree T);
SearchTree Insert(ElementType X, SearchTree T);
SearchTree Delete(ElementType X, SearchTree T);
ElementType Retrieve(Position P);

/* 单个树节点的结构体定义 */
struct TreeNode
{
    ElementType Element;
    SearchTree Left;
    SearchTree Right;
};

接下来我们将对 BST 的基本操作进行逐一实现。

基本操作

初始化 MakeEmpty

该操作用于初始化树 T

事实上,我们可以把 BST 的第一个元素初始化为一个单节点树,但是下面给出的初始化代码是一种更加遵循递归原则的形式(如 BST 的定义一样)。

/* 初始化一棵树 T*/
SearchTree MakeEmpty(SearchTree T)
{
    if (T != NULL)
    {
        MakeEmpty(T->Left);
        MakeEmpty(T->Right);
        free(T);
    }
    return NULL;
}

查找任意值 Find

该操作用于返回给定的树 T 中值为 X 的节点的指针。若不存在则返回 NULL。

/* 对树 T 查找值 X 的位置 */
Position Find(ElementType X, SearchTree T)
{
    if (T == NULL)
        return NULL;
    if (X < T->Element)
        return Find(X, T->Left);
    else if (X> T->Element)
        return Find(X, T->Right);
    else
        return T;
}

如果树 T 本身为 NULL,则直接返回 NULL。否则根据所寻值 X 与当前节点值的大小关系对左子树或右子树进行递归调用,若找到相等值即返回。

查找最小/最大值 FindMin/FindMax

与查找任意值 X 的 Find 操作类似,查找最小 / 最大值的程序可以很容易地利用递归的思想写出来。

下面的例程中阐释了 FindMin 操作的递归实现:从根节点出发,不断地向左寻找左子树(这是由于 BST 的性质),最终找到的就是最小的元素。FindMax 操作仅在寻找的方向上有所不同。

/*FindMin 操作的递归实现 */
Position FindMin(SearchTree T)
{
    if(T == NULL)
        return NULL;
    else
    if(T->Left == NULL)
        return T;
    else
        return FindMin(T->Left);
}

事实上,查找最小/最大值的操作使用非递归的思想实现也是非常简单的,下面是 FindMin 操作的非递归实现。

/*FindMin 操作的非递归实现 */
Position FindMin(SearchTree T)
{
    if(T != NULL)
        while(T->Left != NULL)
            T = T->Left;

    return T;
}

插入 Insert

在查找操作的基础上,我们很容易实现 BST 的插入操作,其本质上就是在树 T 中寻找适合待插值 X 的位置:如果在树 T 中找到了 X,那么不执行操作(或者执行特定的更新操作);如果树 T 中不存在 X,那么,Find 操作最终找到的位置就是适合 X 的位置。

/* 将值 X 插入到二叉查找树 T 中 */
SearchTree Insert(ElementType X, SearchTree T)
{
    if (T == NULL)
    {
        T = malloc(sizeof(struct TreeNode));
        if (T == NULL)
            return NULL; /*Out of space*/
        else
        {
            T->Element = X;
            T->Left = T->Right = NULL;
        }
    }
    else if (X < T->Element)
        T->Left = Insert(X, T->Left);
    else if (X> T->Element)
        T->Right = Insert(X, T->Right);

    return T;
}

有了上面的 Insert 操作,我们就可以用一段简单的程序来构造一棵 BST 了,像下面这样:

int i;
int a[7] = {67, 73, 129, 25, 101, 310, 123};
SearchTree *T = NULL;
for (i = 0; i < 7; i++)
{
    Insert(a[i], T);
}

删除 Delete

与前面的操作相比,从一棵 BST 中删除一个节点是最困难的,因为我们需要保证删除节点后的新树依然满足 BST 的性质。因此我们需要将可能存在的情况进行分类考虑。

首先是最简单的情况:要删除的节点是一个叶子节点,这时可以直接删除该节点,下面是这种情况的示意图。

其次是待删除节点 N 有一个子节点的情况,这时需要将其父节点 N_{parent} 连接至待删除节点 N 的子节点 N_{child} 后,再对待删除节点执行删除,下面是这种情况的示意图。

最复杂的情况是待删除的节点 N 有两个子节点 N_{lchild}N_{rchild},这时,通常采用的删除方法是,找到以 N_{rchild} 为根节点的右子树中数据最小的节点 N_{rmin} 来替代 N,然后递归地删除 N_{rmin}
举个例子,在下左的 BST 中删除具有两个子节点的节点 (2),根据上述的删除方法,删除的结果就是,(2) 的右子树中的最小节点 (3) 替代了 (2),并且原 (3) 节点被删除。

根据上面的思路,可以得到如下的删除操作代码:

/* 从树 T 中删除值为 X 的节点 */
SearchTree Delete(ElementType X, SearchTree T)
{
    Position TmpCell;

    if (T == NULL)
        Error("Element not found");
    else if (X < T->Element)
        T->Left = Delete(X, T->Left);
    else if (X> T->Element)
        T->Right = Delete(X, T->Right);
    else
        if (T->Left && T->Right)
        {
            TmpCell = FindMin(T->Right);
            T->Element = TmpCell->Element;
            T->Right = Delete(T->Element, T->Right);
        }
    else
    {
        TmpCell = T;
        if (T->Left == NULL)
            T = T->Right;
        else if (T->Right == NULL)
            T = T->Left;
        free(TmpCell);
    }
    return T;
}

性能分析

考虑两个元素内容相同但顺序不同的序列:{4, 2, 1, 3, 6, 5, 7, 9, 8} 和 {1, 2, 3, 4, 5, 6, 7, 8, 9},下图展示了两种序列得到的 BST,前一种得到的是一个相对「平衡」的 BST,而后一种,其构造序列是严格从小到大排列的,于是得到的 BST 是一种极端的右斜树。

在这两个不同的树上考虑 BST 的查找、插入和删除操作,很容易得出结论:BST 各项操作的复杂度和树的拓扑结构有着密切的联系。以查找操作为例,在左树中查找节点 (7) 仅需两次查找,而右树中则需要 7 次。

因此在树的结构上,我们希望 BST 是比较「平衡」的,其中最优的情况是,由 n 个元素构成的 BST 的高度 h 与其构成的完全二叉树高度相等,即满足 h = \lfloor \log{n} \rfloor + 1,最差的情况类似上图右树所示,其高度满足 h=n

与之对应,查找、删除、插入算法的复杂度最优情况下为 O(\log{n}),最坏情况下为 O(n)

这里也引出了一个问题,即如何使二叉树更加平衡,这涉及到一种古老的平衡查找树:AVL 树,将在下一篇做介绍。

参考资料

[1] (美)Mark Allen Weiss. 数据结构与算法分析: C 语言描述 [M]. 机械工业出版社: 北京, 2017:73-80.
[2] 程杰. 大话数据结构 [M]. 清华大学出版社: 北京, 2011:313-328.
[3] 维基百科:二叉搜索树 [EB/OL].https://zh.wikipedia.org/wiki/二叉搜索树,2020-2-14.