标签: C/C++

平衡二叉树(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.

理解 C++ 中的引用 (Reference)

什么是引用

引用是一个别名,也就是某个已存在的变量的另一个名字。对某个对象的引用进行操作,就是直接对这个对象进行操作。

创建一个引用

创建一个引用的语句如下:

类型标识符 & 引用变量名 = 目标变量名;

例如:

// 原始变量
int    a;
double b;

// 声明引用变量
int&    ref_a = a;
double& ref_b = b;

我们尝试对原始变量赋值,并且打印原始变量和引用变量的值:

#include <iostream>

using namespace std;

int main(){
    // 原始变量
    int    a;
    double b;

    // 声明引用变量
    int&    ref_a = a;
    double& ref_b = b;

    a = 9;
    cout << "a:" << i << endl;
    cout << "ref_a:" << r << endl;

    b = 6.7;
    cout << "b:" << d << endl;
    cout << "ref_b:" << s << endl;

    return 0;
}

编译运行,输出结果是:

a: 9
ref_a: 9
b: 6.7
ref_b: 6.7

这个输出结果是符合预期的。

引用的特点

与指针相区别,引用有以下的特点:

首先,不存在空引用。引用 只能也必须 在创建时被初始化为一个已存在的变量(连接到一块合法内存)。而创建指针时可以不赋初值,可以在任何时候被初始化,也可以为空。

int a = 9;
int& ref_a = a; // 正确,引用变量 ref_a 被初始化为 a
int& ref_a; // 错误,未给 ref_a 初值,编译会出错

第二,引用不能更换目标。一旦引用被初始化为一个对象,就不能被指向另一个对象。而指针可以在任何时候重新指向另一个对象。

double a = 17.09;
double b = 6.7;
double& ref_a = a; // 至此正确
double& ref_a = b; // 出错,不能多次指向

引用的应用

引用作为参数:以实现一个交换函数为例

下面我们以实现一个交换函数为例,对比指针和引用的使用方法。

void swap(__,__){

}

int main (){
    double a = 17.09, b = 6.7;
    swap(__,__);
    return 0;
}

引用实现

void swap(double &x, double &y){
    double temp;
    temp = y;
    y = x;
    x = temp;
}

调用时使用:

swap(a,b);

指针实现

void swap(double *x, double *b){
    double temp;
    temp = *x;
    *x = *y;
    *y = temp;
}

调用时使用:

swap(&a,&b);

常引用

声明方式:const 类型标识符 & 引用变量名 = 目标变量名;

以 const 方式声明的引用,则不能通过引用对目标变量的值进行修改,从而使目标变量成为 const,比较安全。

int a;
const int &ref_a = a;
ref_a = 1; // 错误
a = 1; // 正确

引用作为返回值

声明方式:

类型标识符 & 函数名 (形参列表及类型说明){
    函数体
}

当函数返回一个引用时,实际上返回一个指向返回值的隐式指针。这使得函数就可以放在赋值语句的左边。例如:

#include <iostream>

using namespace std;

int vals[] = {6, 0, 7};

int& setValues(int i){
    return vals[i]; // 返回第 i 个元素的引用
}

int main(){
    setValues(1) = 1; // 改变第 2 个元素
    return 0;
}

更多细节

  • 存在指针数组,但不存在引用数组,也不能建立数组的引用。
    int* a[3] = {&x,&y,&z}; // 正确,定义了一个包含三个整型指针变量的指针数组
    int& a[3] = {x,y,z}; // 错误,不存在引用数组
    
    int b[2] = {6,7};
    int& ref_b = b; // 错误,不能对数组的建立引用
    
  • 引用本身不是一种数据类型,编译器不给引用分配存储单元。

  • 区分 & 用作引用变量和用作取地址符。

    int a = 9;
    int& ref_a = a; // & 用于引用变量的创建
    
    int b = 100;
    int *p_b;
    p_b = &N; // & 用作取地址符
    
  • &&r &*p 不被接受,而 *&p 可被接受。
    例如,以下的程序是不被接受的。

    int a;
    int &&ref = a; // 错误
    int &*p = a; // 错误
    

    但是如下程序是正确的:

    int *p;
    int *& q = p;
    

    (int *)(&q) = p;,相当于给指针 p 起了别名 q。

  • 引用在底层仍然是指针实现的,但引用更加符合面向对象和隐藏实现细节的原则,通过使用引用来替代指针,可以使 C++ 程序更容易阅读和维护,一定程度上避免了指针的几个缺点。例如指针可以为空,对指代对象不能为空的指针要做 null 检查,指针在赋值方面更加灵活,当然也更容易出错。

Windows 下 VSCode 搭建 C 开发环境

本文介绍在 Windows 下进行 VSCode C 运行环境搭建的方法。本文介绍的是使用 Code Runner 的配置方法,在对调试功能要求不高时,Code Runner 使用起来非常方便。针对调试功能的配置可以参考文末推荐的两篇文章。

一、安装 VSCode C/C++ 插件

C/C++ 插件由 Microsoft 官方提供,可以为 C/C++ 程序提供智能提示和调试功能。

二、安装 MinGW-w64

MinGW-w64 改进自原始 mingw.org 项目,是个精简的Windows平台的 C/C++、ADA 及 Fortran 编译器,相比 Cygwin 而言,体积更小,使用较为方便。MinGW编译的可执行文件能够独立在Windows上运行。

MinGW-w64下载地址:https://sourceforge.net/projects/mingw-w64/

安装的配置选项如下:
* Version:指定 GCC 版本,选择最高版本即可。
* Architecture:指定架构,64位系统选择 x86_64。
* Threads:指定 API 模式,使用默认选项即可。
* Exception:指定异常处理方式,seh 面向 64 位,sjlj 可兼容 32 位。
* Build revision:指定修订版本,使用默认选项即可。

下一步,指定安装位置,开始联网下载安装即可。

三、环境变量配置

按以下步骤打开系统环境变量配置:
1. 在「此电脑」上右击,选择「属性」

  1. 点击「高级系统设置」

  2. 点击「环境变量」

  3. 找到并打开 Path 变量

  4. 添加环境变量
    将 MinGW-w64 安装路径中的 bin 目录添加到环境变量。bin 目录的路径通常为%MinGW-w64安装路径%\mingw64\bin
    例如我的路径是:C:\Program Files\mingw-w64\x86_64-8.1.0-posix-seh-rt_v6-rev0\mingw64\bin

接下来测试环境变量是否配置正确:
在命令行输入gcc --version,如果看到正确返回版本信息,则配置成功。

四、Code Runner 插件安装与配置

Code Runner 插件可以极其方便地完成一键编译运行,免去设置launch.jsontask.json的麻烦,除非要进行极其复杂的dubug,简单的任务通过 Code Runner 都可以完成。

插件商店搜索 Code Runner 并安装。

让我们来准备一段简单的测试程序吧!

#include <stdio.h>  

int main(){  
    printf("hello world!\n");  
}  

可以看到,Code Runner 插件安装完毕之后右上角出现了一个三角形的运行按钮,点按按钮即可开始程序运行。

程序的执行结果显示在输出面板中,同时还会生成一个 .exe 的可执行文件。这相当于是在目录中执行了 gcc temp.c -o temp

但这是有一个问题,上面的执行方法没有办法处理有输入的情况,所以我们需要进行下面的设置:

第一步,打开扩展设置:

第二步,更改 Run in Terminal 设置:

勾选 Run in Terminal 前的复选框,程序将在VSCode的内嵌 Terminal 中运行,满足输入的需要。

以上我们通过 Code Runner 完成了 VSCode 的简单 C 运行环境搭建。如果需要进行调试,则依赖于在 VSCode 中进行 json 配置(可以参考推荐文章)。

推荐文章:
万字长文把 VSCode 打造成 C++ 开发利器
VS Code 搭建 C/C++ 编译运行环境的四种方案