AVL树:从平衡因子到四种旋转的完整实现

AVL树:从平衡因子到四种旋转的完整实现

文章目录

  • AVL树:从平衡因子到四种旋转的完整实现
    • [1 AVL树的基本概念](#1 AVL树的基本概念)
    • [2 为什么要求高度差不超过1](#2 为什么要求高度差不超过1)
  • [3 平衡因子 Balance Factor](#3 平衡因子 Balance Factor)
  • [4 AVL树的结点结构](#4 AVL树的结点结构)
  • [5 AVL树的整体结构](#5 AVL树的整体结构)
  • [6 AVL树的插入](#6 AVL树的插入)
  • [7 AVL树插入的基本过程](#7 AVL树插入的基本过程)
  • [8 平衡因子的更新规则](#8 平衡因子的更新规则)
  • [9 更新平衡因子的三种情况](#9 更新平衡因子的三种情况)
    • [9.1 更新后变成0](#9.1 更新后变成0)
    • [9.2 更新后变成1或-1](#9.2 更新后变成1或-1)
    • [9.3 更新后变成2或-2](#9.3 更新后变成2或-2)
  • [10 AVL插入代码](#10 AVL插入代码)
  • [11 AVL树的旋转](#11 AVL树的旋转)
  • [12 右单旋](#12 右单旋)
  • [13 右单旋的核心](#13 右单旋的核心)
  • [14 右单旋代码](#14 右单旋代码)
  • [15 右旋过程中为什么需要处理subLR](#15 右旋过程中为什么需要处理subLR)
  • [16 左单旋](#16 左单旋)
  • [17 左单旋代码](#17 左单旋代码)
  • [18 左右双旋](#18 左右双旋)
  • [19 左右双旋的结构变化](#19 左右双旋的结构变化)
  • [20 左右双旋为什么需要特殊处理平衡因子](#20 左右双旋为什么需要特殊处理平衡因子)
  • [21 左右双旋代码](#21 左右双旋代码)
  • [22 右左双旋](#22 右左双旋)
  • [23 右左双旋代码](#23 右左双旋代码)
  • [24 四种旋转如何判断](#24 四种旋转如何判断)
  • [25 AVL树查找](#25 AVL树查找)
  • [26 AVL树的平衡检测](#26 AVL树的平衡检测)
  • [27 计算树的高度](#27 计算树的高度)
  • [28 判断是否为AVL树](#28 判断是否为AVL树)
  • [29 AVL树平衡检测代码](#29 AVL树平衡检测代码)
  • [30 AVL树的测试](#30 AVL树的测试)
  • [31 大规模数据测试](#31 大规模数据测试)
  • [32 AVL树的核心代码逻辑](#32 AVL树的核心代码逻辑)

1 AVL树的基本概念

AVL树是一种自平衡二叉搜索树

普通二叉搜索树在插入数据后,如果数据本身具有某种顺序,例如依次插入 1 2 3 4 5,很容易退化成类似链表的结构

此时搜索效率会从理想情况下的 O(logN) 退化到 O(N)

AVL树通过控制树的高度,使二叉搜索树始终保持较好的平衡状态,从而保证查找等操作的效率

AVL树的定义

一棵树满足下面几个条件,就可以认为它是一棵 AVL 树

1 这棵树是一棵空树,或者是一棵二叉搜索树

2 左右子树本身也必须是 AVL 树

3 任意结点的左右子树高度差的绝对值不能超过 1

也就是

cpp 复制代码
abs(左子树高度 - 右子树高度) <= 1

因此 AVL 树本质上是一棵高度平衡的二叉搜索树


2 为什么要求高度差不超过1

很多人第一次接触 AVL 树时会产生一个问题

既然是平衡树,为什么不直接要求左右子树高度完全相等呢

也就是为什么不要求

cpp 复制代码
左子树高度 == 右子树高度

原因是这种要求过于严格,有些结点数量下根本无法做到

例如一棵树只有两个结点

text 复制代码
    1
     \
      2

此时左右子树高度必然存在差异

因此 AVL 树采用的是更加合理的要求

text 复制代码
左右子树高度差 <= 1

这样既可以保证树不会严重倾斜,又不会对树的结构要求过于苛刻


3 平衡因子 Balance Factor

为了方便判断 AVL 树是否平衡,给每一个结点增加一个平衡因子 _bf

平衡因子的定义是

cpp 复制代码
平衡因子 = 右子树高度 - 左子树高度

因此

text 复制代码
_bf = rightHeight - leftHeight

对于正常的 AVL 树来说,每个结点的平衡因子只能是

text 复制代码
-1
0
1

如果出现

text 复制代码
2

说明右边太高

如果出现

text 复制代码
-2

说明左边太高

例如

text 复制代码
      10
     /
    5

左边高度比右边高 1

所以

text 复制代码
_bf = 0 - 1 = -1

再例如

text 复制代码
    10
      \
       15

右边高度比左边高 1

所以

text 复制代码
_bf = 1 - 0 = 1

如果变成

text 复制代码
      10
     /
    5
   /
  3

那么

text 复制代码
左子树高度 = 2
右子树高度 = 0

_bf = 0 - 2 = -2

此时就出现了不平衡,需要进行旋转


4 AVL树的结点结构

AVL树的结点不仅需要保存左右孩子,还需要保存父亲结点以及平衡因子

cpp 复制代码
template<class K, class V>
struct AVLTreeNode
{
    pair<K, V> _kv;

    AVLTreeNode<K, V>* _left;
    AVLTreeNode<K, V>* _right;
    AVLTreeNode<K, V>* _parent;

    int _bf;

    AVLTreeNode(const pair<K, V>& kv)
        : _kv(kv)
        , _left(nullptr)
        , _right(nullptr)
        , _parent(nullptr)
        , _bf(0)
    {}
};

各成员的作用

成员 作用
_kv 保存键值对
_left 指向左孩子
_right 指向右孩子
_parent 指向父亲结点
_bf 保存平衡因子

这里的 _parent 非常重要

因为插入一个新结点之后,需要从新结点的父亲开始一路向上更新平衡因子

如果没有 _parent,就无法直接从当前结点找到上一层结点


5 AVL树的整体结构

cpp 复制代码
template<class K, class V>
class AVLTree
{
    typedef AVLTreeNode<K, V> Node;

private:
    Node* _root = nullptr;
};

_root 保存整棵 AVL 树的根结点


6 AVL树的插入

AVL树插入一个结点的过程,可以分成几个阶段

text 复制代码
按照二叉搜索树规则插入
        ↓
更新祖先结点的平衡因子
        ↓
判断是否出现不平衡
        ↓
如果平衡,继续向上更新
        ↓
如果不平衡,进行旋转
        ↓
旋转完成后结束

最关键的一点是

AVL树的插入首先仍然遵循二叉搜索树的插入规则

AVL树并没有改变二叉搜索树的基本性质


7 AVL树插入的基本过程

假设现在插入一个新结点

首先按照普通二叉搜索树的方法寻找插入位置

text 复制代码
如果 key < 当前结点
    向左走

如果 key > 当前结点
    向右走

如果 key == 当前结点
    插入失败

新结点插入以后,需要开始向上更新平衡因子

更新路径为

text 复制代码
新结点
  ↑
parent
  ↑
parent
  ↑
...
  ↑
root

最坏情况下需要一直更新到根结点

但并不是每次都需要更新到根

有些情况下更新到中间位置就可以停止


8 平衡因子的更新规则

AVL树中平衡因子的定义是

cpp 复制代码
_bf = 右子树高度 - 左子树高度

插入一个新结点后,新结点所在的子树高度可能增加

因此父亲结点的平衡因子可能发生变化

插入到左边

如果新结点插入到 parent 的左子树

cpp 复制代码
parent->_bf--;

因为左子树高度增加了


插入到右边

如果新结点插入到 parent 的右子树

cpp 复制代码
parent->_bf++;

因为右子树高度增加了


9 更新平衡因子的三种情况

这是 AVL 插入中最重要的部分之一

更新父亲结点的平衡因子以后,主要有三种情况

text 复制代码
0
1 或 -1
2 或 -2

9.1 更新后变成0

例如

text 复制代码
-1 → 0

或者

text 复制代码
1 → 0

说明原来两边高度不一样

新结点插入到了原来较矮的一边

插入以后两边重新变得一样高

关键点在于

当前子树的高度没有增加

因此不会继续影响父亲结点

所以可以直接结束更新

cpp 复制代码
if (parent->_bf == 0)
{
    break;
}

9.2 更新后变成1或-1

例如

text 复制代码
0 → 1

或者

text 复制代码
0 → -1

说明原来左右子树高度相同

插入以后其中一边变高了

当前结点仍然满足 AVL 的平衡要求

但是

当前子树的高度增加了1

因此还可能影响父亲结点

所以需要继续向上更新

cpp 复制代码
cur = parent;
parent = parent->_parent;

9.3 更新后变成2或-2

例如

text 复制代码
1 → 2

或者

text 复制代码
-1 → -2

此时说明当前结点已经失去平衡

必须通过旋转恢复平衡

cpp 复制代码
else if (parent->_bf == 2 || parent->_bf == -2)
{
    // 不平衡了,旋转处理
    break;
}

旋转的两个目标

text 复制代码
1 恢复平衡
2 降低当前子树的高度

当旋转完成后,当前子树的高度恢复到插入之前的状态,因此不会继续影响上一层

所以插入操作可以结束


10 AVL插入代码

核心代码结构如下

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    if (_root == nullptr)
    {
        _root = new Node(kv);
        return true;
    }

    Node* parent = nullptr;
    Node* cur = _root;

    while (cur)
    {
        if (cur->_kv.first < kv.first)
        {
            parent = cur;
            cur = cur->_right;
        }
        else if (cur->_kv.first > kv.first)
        {
            parent = cur;
            cur = cur->_left;
        }
        else
        {
            return false;
        }
    }

    cur = new Node(kv);

    if (parent->_kv.first < kv.first)
    {
        parent->_right = cur;
    }
    else
    {
        parent->_left = cur;
    }

    cur->_parent = parent;

    while (parent)
    {
        if (cur == parent->_left)
            parent->_bf--;
        else
            parent->_bf++;

        if (parent->_bf == 0)
        {
            break;
        }
        else if (parent->_bf == 1 || parent->_bf == -1)
        {
            cur = parent;
            parent = parent->_parent;
        }
        else if (parent->_bf == 2 || parent->_bf == -2)
        {
            // 不平衡
            break;
        }
        else
        {
            assert(false);
        }
    }

    return true;
}

这里需要特别理解一个问题

为什么更新平衡因子时要同时维护

cpp 复制代码
cur
parent

因为当前需要判断

cpp 复制代码
cur

到底是

text 复制代码
parent的左孩子

还是

text 复制代码
parent的右孩子

然后决定

cpp 复制代码
parent->_bf--;

还是

cpp 复制代码
parent->_bf++;

更新完成以后再把当前结点整体向上移动

cpp 复制代码
cur = parent;
parent = parent->_parent;

这样就可以继续处理上一层


11 AVL树的旋转

当某个结点的平衡因子变成

text 复制代码
2

或者

text 复制代码
-2

说明树已经不平衡

此时需要旋转

AVL树一共有四种旋转情况

text 复制代码
右单旋
左单旋
左右双旋
右左双旋

旋转必须满足两个原则

原则一

旋转以后仍然必须满足二叉搜索树的大小关系

原则二

旋转以后恢复平衡,并尽可能将树的高度降低到插入之前的高度


12 右单旋

右单旋主要解决

text 复制代码
左边过高

并且新增结点位于左子树的左侧

典型结构

text 复制代码
        parent
        /
      subL
      /
    ...

例如

text 复制代码
        10
       /
      5
     /
    3

此时

text 复制代码
10的平衡因子 = -2

需要进行右旋

旋转之后

text 复制代码
      5
     / \
    3   10

原来的 5 成为新的根

原来的 10 成为 5 的右孩子


13 右单旋的核心

假设结构为

text 复制代码
        parent
        /
      subL
      /   \
     a     b

其中

text 复制代码
a < subL < b < parent

右旋之后

text 复制代码
        subL
       /    \
      a     parent
            /
           b

为什么 b 可以成为 parent 的左子树

因为满足

text 复制代码
subL < b < parent

所以不会破坏二叉搜索树的性质


14 右单旋代码

cpp 复制代码
void RotateR(Node* parent)
{
    Node* subL = parent->_left;
    Node* subLR = subL->_right;

    parent->_left = subLR;

    if (subLR)
        subLR->_parent = parent;

    Node* parentParent = parent->_parent;

    subL->_right = parent;
    parent->_parent = subL;

    if (parentParent == nullptr)
    {
        _root = subL;
        subL->_parent = nullptr;
    }
    else
    {
        if (parent == parentParent->_left)
        {
            parentParent->_left = subL;
        }
        else
        {
            parentParent->_right = subL;
        }

        subL->_parent = parentParent;
    }

    parent->_bf = subL->_bf = 0;
}

右旋最容易出错的地方并不是旋转方向

而是指针关系的修改

需要同时处理

text 复制代码
孩子指针
父亲指针
_root
上一层结点的孩子指针

15 右旋过程中为什么需要处理subLR

假设

text 复制代码
        parent
        /
      subL
        \
        subLR

右旋后

text 复制代码
      subL
        \
        parent
        /
      subLR

因此原来的

cpp 复制代码
subL->_right

必须变成

cpp 复制代码
parent

而原来的

cpp 复制代码
subLR

必须移动到

cpp 复制代码
parent->_left

所以代码中有

cpp 复制代码
Node* subLR = subL->_right;

parent->_left = subLR;

if (subLR)
    subLR->_parent = parent;

这一步非常关键


16 左单旋

左单旋与右单旋完全对称

它主要解决

text 复制代码
右边过高

例如

text 复制代码
10
  \
   15
     \
      20

此时

text 复制代码
10的平衡因子 = 2

需要左旋

旋转以后

text 复制代码
      15
     /  \
   10    20

17 左单旋代码

cpp 复制代码
void RotateL(Node* parent)
{
    Node* subR = parent->_right;
    Node* subRL = subR->_left;

    parent->_right = subRL;

    if (subRL)
        subRL->_parent = parent;

    Node* parentParent = parent->_parent;

    subR->_left = parent;
    parent->_parent = subR;

    if (parentParent == nullptr)
    {
        _root = subR;
        subR->_parent = nullptr;
    }
    else
    {
        if (parent == parentParent->_left)
        {
            parentParent->_left = subR;
        }
        else
        {
            parentParent->_right = subR;
        }

        subR->_parent = parentParent;
    }

    parent->_bf = subR->_bf = 0;
}

左旋和右旋实际上就是镜像关系

右旋

text 复制代码
左孩子上升
原根下降到右边

左旋

text 复制代码
右孩子上升
原根下降到左边

18 左右双旋

有些情况下单旋无法解决问题

例如

text 复制代码
        10
       /
      5
       \
        8

此时

text 复制代码
10左边高

但是新增结点并不是位于 5 的左边,而是位于 5 的右边

因此直接对 10 进行右旋无法彻底解决问题

这就是左右双旋

处理过程

text 复制代码
先以5为旋转点进行左旋
        ↓
再以10为旋转点进行右旋

也就是

cpp 复制代码
RotateL(parent->_left);
RotateR(parent);

19 左右双旋的结构变化

初始

text 复制代码
        10
       /
      5
       \
        8

第一次左旋

text 复制代码
        10
       /
      8
     /
    5

第二次右旋

text 复制代码
       8
      / \
     5  10

这样就重新恢复平衡


20 左右双旋为什么需要特殊处理平衡因子

左右双旋与单旋不同

旋转之前,中间结点的平衡因子可能不同

因此旋转以后三个关键结点的平衡因子并不一定全部为 0

代码需要提前保存中间结点的平衡因子

cpp 复制代码
int bf = subLR->_bf;

然后进行两次旋转

cpp 复制代码
RotateL(parent->_left);
RotateR(parent);

最后根据旋转之前保存的 bf 设置三个结点的平衡因子


21 左右双旋代码

cpp 复制代码
void RotateLR(Node* parent)
{
    Node* subL = parent->_left;
    Node* subLR = subL->_right;

    int bf = subLR->_bf;

    RotateL(parent->_left);
    RotateR(parent);

    if (bf == 0)
    {
        subL->_bf = 0;
        subLR->_bf = 0;
        parent->_bf = 0;
    }
    else if (bf == -1)
    {
        subL->_bf = 0;
        subLR->_bf = 0;
        parent->_bf = 1;
    }
    else if (bf == 1)
    {
        subL->_bf = -1;
        subLR->_bf = 0;
        parent->_bf = 0;
    }
    else
    {
        assert(false);
    }
}

这里最重要的是理解

cpp 复制代码
int bf = subLR->_bf;

必须在旋转之前保存

因为旋转之后结点之间的关系已经发生变化

如果之后再判断原来的平衡因子,就无法得到原始信息


22 右左双旋

右左双旋与左右双旋完全对称

典型结构

text 复制代码
    10
      \
       15
      /
     12

此时

text 复制代码
10的右边过高

但是新增结点位于 15 的左边

所以不能直接左旋

需要

text 复制代码
先对15进行右旋
再对10进行左旋

也就是

cpp 复制代码
RotateR(parent->_right);
RotateL(parent);

23 右左双旋代码

cpp 复制代码
void RotateRL(Node* parent)
{
    Node* subR = parent->_right;
    Node* subRL = subR->_left;

    int bf = subRL->_bf;

    RotateR(parent->_right);
    RotateL(parent);

    if (bf == 0)
    {
        subR->_bf = 0;
        subRL->_bf = 0;
        parent->_bf = 0;
    }
    else if (bf == 1)
    {
        subR->_bf = 0;
        subRL->_bf = 0;
        parent->_bf = -1;
    }
    else if (bf == -1)
    {
        subR->_bf = 1;
        subRL->_bf = 0;
        parent->_bf = 0;
    }
    else
    {
        assert(false);
    }
}

24 四种旋转如何判断

判断 AVL 树旋转类型时,可以根据失衡结点的平衡因子 以及较高子树根结点的平衡因子来判断

可以记成下面的关系

情况 结构 旋转
LL 左左 右单旋
RR 右右 左单旋
LR 左右 左右双旋
RL 右左 右左双旋

其中

text 复制代码
LL

表示失衡结点的左子树的左边更高

text 复制代码
RR

表示失衡结点的右子树的右边更高

text 复制代码
LR

表示失衡结点的左子树的右边更高

text 复制代码
RL

表示失衡结点的右子树的左边更高

最重要的不是死记旋转名称,而是观察

text 复制代码
失衡发生在哪一侧
新增结点又位于这一侧的哪一边

25 AVL树查找

AVL树本质上还是二叉搜索树

所以查找逻辑与普通二叉搜索树基本一致

cpp 复制代码
Node* Find(const K& key)
{
    Node* cur = _root;

    while (cur)
    {
        if (cur->_kv.first < key)
        {
            cur = cur->_right;
        }
        else if (cur->_kv.first > key)
        {
            cur = cur->_left;
        }
        else
        {
            return cur;
        }
    }

    return nullptr;
}

查找过程

text 复制代码
key > 当前结点
    ↓
向右走

key < 当前结点
    ↓
向左走

key == 当前结点
    ↓
找到

AVL树通过控制树的高度,使查找效率保持在

text 复制代码
O(logN)

26 AVL树的平衡检测

实现 AVL 树之后,不能只依赖插入代码判断自己是否正确

还可以编写一个检测函数

通过重新计算每个结点左右子树的高度

然后检查

text 复制代码
实际平衡因子

和

text 复制代码
结点保存的_bf

是否一致


27 计算树的高度

cpp 复制代码
int _Height(Node* root)
{
    if (root == nullptr)
        return 0;

    int leftHeight = _Height(root->_left);
    int rightHeight = _Height(root->_right);

    return leftHeight > rightHeight
        ? leftHeight + 1
        : rightHeight + 1;
}

递归计算高度的基本思想

text 复制代码
空树高度 = 0

非空树高度 =
max(左子树高度, 右子树高度) + 1

28 判断是否为AVL树

首先计算当前结点左右子树高度

cpp 复制代码
int leftHeight = _Height(root->_left);
int rightHeight = _Height(root->_right);

int diff = rightHeight - leftHeight;

这里的 diff 就是根据真实高度重新计算出来的平衡因子

然后检查

cpp 复制代码
abs(diff) <= 1

同时检查

cpp 复制代码
root->_bf == diff

也就是说

不仅要检查树是否平衡,还要检查代码维护的 _bf 是否正确


29 AVL树平衡检测代码

cpp 复制代码
bool _IsBalanceTree(Node* root)
{
    if (root == nullptr)
        return true;

    int leftHeight = _Height(root->_left);
    int rightHeight = _Height(root->_right);

    int diff = rightHeight - leftHeight;

    if (abs(diff) >= 2)
    {
        cout << root->_kv.first
             << "高度差异常"
             << endl;

        return false;
    }

    if (root->_bf != diff)
    {
        cout << root->_kv.first
             << "平衡因子异常"
             << endl;

        return false;
    }

    return _IsBalanceTree(root->_left)
        && _IsBalanceTree(root->_right);
}

这个检测函数实际上检查了两件事情

text 复制代码
第一
左右子树高度差是否超过1

第二
代码维护的_bf是否等于真实计算出来的平衡因子

只要其中一个条件不满足,就说明 AVL 树实现存在问题


30 AVL树的测试

测试 AVL 树时,可以准备一些容易触发双旋的特殊数据

例如

cpp 复制代码
int a[] = {
    4, 2, 6, 1, 3,
    5, 15, 7, 16, 14
};

插入完成后进行中序遍历

cpp 复制代码
t.InOrder();

再检查

cpp 复制代码
t.IsBalanceTree();

这样可以同时验证

text 复制代码
插入逻辑
搜索树性质
旋转逻辑
平衡因子

31 大规模数据测试

除了特殊数据,还可以进行大量随机数据测试

例如

cpp 复制代码
const int N = 100000;

生成大量数据之后插入 AVL 树

然后统计

text 复制代码
插入耗时
查找耗时
树高
结点数量

例如

cpp 复制代码
cout << "Insert:" << end2 - begin2 << endl;
cout << t.IsBalanceTree() << endl;
cout << "Height:" << t.Height() << endl;
cout << "Size:" << t.Size() << endl;

这种测试可以验证 AVL 树在大量数据下是否仍然保持较好的树高和运行效率


32 AVL树的核心代码逻辑

整个 AVL 插入过程可以浓缩成下面这条逻辑链

text 复制代码
按照BST规则插入
        ↓
新结点连接parent
        ↓
从parent开始向上更新_bf
        ↓
_bf == 0
        ↓
停止更新

_bf == 1 或 -1
        ↓
继续向上更新

_bf == 2 或 -2
        ↓
判断旋转类型
        ↓
LL → 右单旋
RR → 左单旋
LR → 左右双旋
RL → 右左双旋
        ↓
恢复平衡
        ↓
插入结束

真正实现 AVL 树时,最需要关注的并不是某一行代码,而是这几个核心关系

text 复制代码
二叉搜索树规则
        +
parent指针
        +
平衡因子
        +
四种旋转
        +
旋转后的父子指针维护

其中旋转代码最容易出问题的地方主要是

text 复制代码
1 修改孩子指针

2 修改孩子的parent

3 修改原parent的parent

4 修改上一层结点指向

5 必要时修改_root

6 更新旋转后结点的_bf

AVL树的删除在该实现中没有展开讲解

相关推荐
进化中的刀盾兽2 小时前
从零实现 C++ AI 大模型接入 SDK(九):LLMManager 统一模型管理与请求路由
c++
会周易的程序员2 小时前
STVM OSAL 层架构设计文档
c++·物联网·嵌入式·虚拟机·iot·软plc·iec61131
.YM.Z2 小时前
C++——【红黑树】原理详解:定义、性质、插入变色与旋转实现
开发语言·c++
沐欣工作室_lvyiyi2 小时前
水文信息检测系统的设计与实现(论文+源码)
c语言·单片机设计·柳州工学院·河南工学院·怀化学院·集美大学·辽宁科技学院
jidaowansui2 小时前
B3643 图的存储———>数组模拟邻接矩阵
算法
y1su2 小时前
Leetcode 二分模板
java·数据结构·算法·leetcode·排序算法
bksczm2 小时前
数据结构的大纲概括
数据结构
youshi66682 小时前
平衡车——PID算法优化
stm32·单片机·嵌入式硬件·算法·pid·平衡车
无名猿3 小时前
constexpr 能力扩张:从 C++11 到 C++20 的编译期计算
c++·性能优化·现代c++·编译期