数据结构:平衡二叉树

引言

对于一棵二叉排序树,为了提高效率,我们希望我们可以不断地进行判断,然后不停地分流,过滤掉很多没有用的数据,这样子可以让时间复杂度变成稳定的O(logn)。但是我们不管是插入还是删除,如果不作额外的调整,要保证这一颗树又矮又胖是一件很难的事情。所以我们引入了平衡二叉树

这颗树的作用就是无论你是插入还是删除,都保证了这颗树的平衡系数(一个结点的左右子树的高度差)小于等于1,这样我们可以提高很大的效率在查找方面。

不过这篇文章是建立在二叉排序树的基础上的,如果大家有不理解的地方,不妨回头看看呢~~

数据结构:二叉排序树(递归与非递归函数的全部实现)-CSDN博客

平衡二叉树的结构

平衡系数是判断一颗树是不是平衡二叉树的关键,我们为了计算平衡二叉树,我们就必须要知道每一个结点的高度,所以我们的结构体里面有一个记录结点高度的变量

cpp 复制代码
struct AVLNode{
    int data;
    AVLNode* left;
    AVLNode* right;
    int height;
};

然后我们还是选择中序遍历的思想来完成这颗树的构建

整体思路

首先,我们每次插入的结点一定是叶子结点,这和二叉排序树是一样的,但是不一样的是插入完成之后我们需要判断是不是应该调整整个树的结构,所以我们需要重新计算每个结点的平衡系数。计算平衡系数需要知道结点的高度,而高度的计算是至下向上。说到这里,有没有感觉了~我们的递归不是自顶向下嘛,如果我们使用递归的操作,那不正好插入完之后自动回溯,同时进行判断。

如果发现有结点不平衡了,那么就立马更改,因为一颗子树的不平衡很可能会引起上面树的不平衡,所以我们需要就地解决问题。所以我们如果发现有很多结点都出现了不平衡,就改动最小那颗树,让其平衡,然后继续往上回溯。

代码

整体思路我们有了,但是这很抽象。我知道,所以我想的是根据代码展现思想。

首先是我们得到一个结点的高度函数,这里要注意的是如果这个结点是空,那么返回的是0,因为我们每一次插入叶子节点的时候,肯定最后遍历到的是空结点,而空结点我们默认高度为0,加1之后就是叶子节点的高度1,这样可以统一我们的计算公式。

cpp 复制代码
int Geth(AVLNode* node) {
    if(node == nullptr){
        return 0;
    } else {
        return node->height;
    }
}

创建叶子结点

cpp 复制代码
AVLNode* createNode(int value) {
    AVLNode* node = new AVLNode();
    node->data = value;
    node->left = nullptr;
    node->right = nullptr;
    node->height = 1; // 这里其实赋值为1就可以,因为插入的一定是叶子节点
    return node;
}

四种旋转改变结构

这是这一篇文章的重点,我会重点讲其中一个例子,剩下的道理基本一样

不过在开始之前,我还是想要多说一句,出现不平衡最少经过了3层,最多无数层,但是这不影响大家理解,我们可以把那一整坨没啥用的看成一个结点,当成整体,相当于是3层的一个模型

RR

假设当我们加入c的之后,我们形成了这么一个结构,那么我们的操作是把整个结构左旋,把b变成root结点,然后a,c变成b的左右孩子,同时a的右孩子继承b的左孩子。

首先我们改变结构的前提是还是要保证顺序性,所以我们把b放到中间。但是我们实际中的情况并不是那么简单

这个是一个相对复杂的情况,a出现了失衡,所以我们要调整a的树,那么我们一左旋,然后就会发现a其实根本没有右节点,大家可以稍微想象一下,所以a的右节点继承的是b的左节点

然后我们要更新高度

cpp 复制代码
AVLNode* RRrotation(AVLNode* x) {
    AVLNode* y = x->right;
    x->right = y->left;
    y->left = x;
    // x和y的高度都发生了变化
    x->height = std::max(Geth(x->left), Geth(x->right)) + 1;
    y->height = std::max(Geth(y->left), Geth(y->right)) + 1;
    return y;
}

LL

cpp 复制代码
// 因为在节点x的左孩子的左子树插入结点,导致x失衡,因此以x为根节点的树变成最小失衡子树,对x进行一次右旋(LL)
AVLNode* LLrotation(AVLNode* x) {
    // 右旋
    // y是x的左孩子
    AVLNode* y = x->left;
    x->left = y->right;
    y->right = x;
    // x和y的高度都发生了变化
    // 这里是结构改变了之后,我们才来计算更新后的高度,但是实际上变化的高度只有x和y的,所以我们完全可以根据我们现有所有结点的高度计算x和y的高度
    // 至于为什么y的高度不会影响到x的高度,因为插入的地方,我们插入的那一边肯定是大于等于没插入的那一边,但是为了逻辑的正确,我们依然先计算x的高度,因为x在下面
    x->height = std::max(Geth(x->left), Geth(x->right)) + 1; // 这个是一个固定的公式,知道了孩子的高度,然后把孩子的高度加1就是自己的高度
    y->height = std::max(Geth(y->left), Geth(y->right)) + 1;
    return y;
}

RL

对于这一种新的结构,我们要转化成我们熟悉的结构,也就是先对b进行右旋,然后再对a进行整体的左旋

cpp 复制代码
AVLNode* RLrotation(AVLNode* x) {
    AVLNode* y = x->right;
    x->right = LLrotation(y);
    x = RRrotation(x);
    return x;
}

LR

cpp 复制代码
AVLNode* LRrotation(AVLNode* x) {
    AVLNode* y = x->left;
    x->left = RRrotation(y);
    x = LLrotation(x);
    return x;
}

插入操作

我们必须要使用递归的思想。

当插入结点了之后,不断地往上回溯,直到回溯到有问题的结点的时候,我们进行操作。大家可能有很多的疑惑,我们一一解释。

首先我想解释一下高度的事情:

高度的更新到底是在什么时候,我们看到倒数第二行,我们不断地递归到最后插入结点之后,我们就开始回溯,而回溯的过程之中,如果没有进入到if判断里面,那么就会更新这个结点的高度,也就是执行最后那个代码,然后一层一层的向上,直到遇到了有问题的结点。因为我们有问题的结点计算的依据都是根据它下面节点的高度来计算的,所以我们不需要太担心计算的问题。然后当进入我们的改变结构的操作里面,这个操作里面自然会更新结点的高度。这个点大家可能又比较疑惑,我结点到处旋转,高度不全乱了。不不不!希望大家还是记住那个最简单的模型,因为旋转的过程之中只有a和b的高度发生了变化,其他的结点没有任何变化,所以可以看成一坨,(我们这里拿RR来举例子)而变化之后b的孩子是a和c,a的高度由b之前的左孩子和a之前的左孩子决定,这两个孩子是没有任何变化的,所以a的高度完全可以由我们的公式得到,而b的高度是由a和c的高度得到,c没有任何变化,而a的高度已经被计算出来,所以b的高度也可以由固定公式计算得到。

等到这个左右旋的操作结束之后,会一次沿路过的所有结点回溯,更新每一个结点,所以大家不要担心高度的问题哦~~~

第二个问题就是我们怎么判断是哪个模型:

我们在第一次找数据的时候,我们就已经判断了第一个是L还是R,但是后面的那个字母,就需要我们做额外的判断,我们找到a结点的孩子b,用b和value判断大小,这样就可以知道插入的c在做还是右了。不过大家肯定还有问题,就是这个是一个简单的模型,那如果c距离b隔了100层,怎么办。其实根本没有区别,因为插入的位置在和b比较之后就已经定型了。

cpp 复制代码
AVLNode* Insert(AVLNode* root, int value) {
    if(root == nullptr) {
        return createNode(value);
    }
    if(value < root->data) {
        root->left = Insert(root->left, value);
        // 完成了在root的左子树中插入一个数据value,就可能导致左子树高度增加一,导致失衡
        if(Geth(root->left) - Geth(root->right) > 1) {
            AVLNode* left = root->left; // 这里是要用left来判断是LL还是LR
            if(value < left->data) { 
                // LL
                return LLrotation(root);
            } else {
                // LR
                return LRrotation(root);
            }
        }
    } else {
        root->right = Insert(root->right, value);
        if(Geth(root->right) - Geth(root->left) > 1) {
            AVLNode* right = root->right;
            if(value > right->data) { 
                // RR
                return RRrotation(root);
            } else {
                // RL
                return RLrotation(root);
            }
        }
    }
    // 改变这个结构之后才更新高度,别先更新高度再插入数据,到时候高度不匹配结构
    // 每一次结构的调整,在最小子树的那一块,高度的变化都只有x和y发生了变化,而在变化结构的时候已经同时更新了x和y的高度,所以之后路径上的所有结点高度更新都不需要担心
    root->height = std::max(Geth(root->left), Geth(root->right)) + 1; 
    return root;
}

删除操作

这又是一个老大难题

删除一个结点的操作完全和二叉排序树一样,所以这里就不作解释了。

但是删除完了结点之后,后事怎么处理。

我们可以把删除左边的结点看成往右边添加一个结点。反之同理。而添加一个结点我想大家已经会了吧。但是添加哪一个结点,这么多结点,到底应该添加哪一个,这个我们也需要思考。

这里我们依然用递归的操作,原理就是不断地把新的子树结点往上回溯传递。

所以对于这些问题,我们也一一解答:

第一个问题是怎么判断添加的结点:

先从简单的入手,这个结点失衡了,那么很有可能就是多了一个结点,那这个结点在哪里呢(这个会影响我们用的旋转操作),假设我们删除的是右边的结点,那么在左边添加了一个结点,但是我们肯定不是对root结点进行判断,因为对root结点判断我们完全没有办法知道是LR还是LL,所以是对root->left进行判断。如果运气很好,正好是相当于插入的一个结点,我们就按照正常的步骤来。

但是如果运气不好,因为删除一个结点,可能会直接导致一层的结点出现问题,那就不是只插入一个结点的事情了,可能相当于同时插入了好几个结点在同一层,简单来说就是左孩子右孩子都有插入的操作。这个时候是LR和LL都可以吗?

不是的!!!!!

这里只能是LL,因为如果LR的话,第一次的旋转会造成另外的不平衡,而第二次旋转会修复原来的不平衡。说人话就是挖一个坑填了另一个坑,到头来还是有坑,所以只能旋转一次,用LL

第二个就是这里的递归操作比较多,可能不是很好理解

我们的分成的大类是找数据的方向:找到、左边、右边

找到之后就是二叉排序树的删除方式

然后因为我们是递归的操作,所以结点都是要在回溯的时候不断往上传递的,所以每一次分流的时候都要递归调用,以便于连接结点与查找结点是否处于平衡。

cpp 复制代码
AVLNode* Delete(AVLNode* root, int value) {
    if(root == nullptr) {
        std::cout << "删除失败" << std::endl;
        return root;
    }
    if(root->data == value) {
        // 删除root
        if(root->left != nullptr && root->right != nullptr) {
            // 找前驱
            AVLNode* p = root->left;
            while(p->right != nullptr) {
                p = p->right;
            }
            root->data = p->data; // 这几步只是为了找到前驱的数据,后面是用递归删除
            root->left = Delete(root->left, p->data);
            // 在root的左子树中删除了一个结点,就可能导致左右子树高度差扩大到2,root可能失衡
            // 可能出现RR/RL失衡
            if(Geth(root->right) - Geth(root->left) > 1) {
                AVLNode* right = root->right;
                if(Geth(right->left) >= Geth(right->right)) { // 注意这里是等于号,原因看博客讲解
                    // RR
                    return RRrotation(root);
                } else {
                    // RL
                    return RLrotation(root);
                }
            }
        } else {
            AVLNode* temp = root;
            if(root->left != nullptr) {
                root = root->left;
            } else {
                root = root->right;
            }
            delete temp;
            temp = nullptr;
            return root;
        }
    } else if(value < root->data) {
        root->left = Delete(root->left, value);
        if(Geth(root->right) - Geth(root->left) > 1) {
            AVLNode* right = root->right;
            if(Geth(right->left) >= Geth(right->right)) { // 注意这里是等于号,原因看博客讲解
                // RR
                return RRrotation(root);
            } else {
                // RL
                return RLrotation(root);
            }
        }
    } else {
        root->right = Delete(root->right, value);
        if(Geth(root->left) - Geth(root->right) > 1) {
            AVLNode* left = root->left;
            if(Geth(left->left) >= Geth(left->right)) { // 注意这里是等于号,原因看博客讲解
                // LL
                return LLrotation(root);
            } else {
                // LR
                return LRrotation(root);
            }
        }
    }
    if(root != nullptr) { // 这里一定要额外小心,因为删除操作后,root可能变成nullptr
        root->height = std::max(Geth(root->left), Geth(root->right)) + 1;
    }
    return root;
}

总结

这个数据结构对于查找的效率十分高,但是增删的代码量大家也看到了,很复杂,所以对于频繁的增删操作,对这个数据结构并不友好。而面对这种情况,我们有更加强大的数据结构,红黑树,B树,B+树。对此我们也会一一到来。

本篇文章就到这里结束了,希望可以对大家的理解有所帮助。

相关推荐
花间相见1 天前
【LeetCode02】—— 两数之和:哈希表入门经典详解
数据结构·散列表
代码中介商1 天前
C++ 智能指针完全指南(三):weak_ptr 与循环引用
开发语言·c++
BestOrNothing_20151 天前
ROS2 C++ 小车控制完整实战(二):自定义 msg 消息发布与订阅保姆级教程
c++·ros2·subscriber·publisher·msg·topic通信·自定义接口
-森屿安年-1 天前
91. 解码方法
c++·动态规划
有点。1 天前
C++(二分答案)
c++
程序喵大人1 天前
【C++并发系列】第一章:多线程读写同一个变量为什么会出错
开发语言·c++·多线程·并发
zhengzhouliuhaha1 天前
智能医疗设备控费系统:以全院一体化管控,筑牢医疗资源“安全阀”
大数据·数据结构·人工智能·算法·安全·机器学习·软件需求
梓䈑1 天前
C++ 接入 SQLite 数据库:环境搭建、API 详解 与 两种执行方式对比
数据库·c++·sqlite
zh路西法1 天前
基于yaml-cpp的C++参数服务器设计2:多级参数配置
linux·服务器·c++
啦啦啦啦啦zzzz1 天前
算法总结(双指针)
c++·算法·双指针