个人主页:@Weraphael
✍作者简介:目前学习C++和算法
✈️专栏:C++航路
希望大家多多支持,咱一起进步!
如果文章对你有帮助的话
欢迎 评论 点赞 收藏 加关注✨
在搜索二叉树章节,我们知道二叉搜索树可能会失去平衡(退化成单支树),造成搜索效率低落的情况,时间复杂度会退化成O(N)
(效率没有保障)。当然为了避免这种情况,可以使用平衡二叉树,例如AVL
树或红黑树等。
为了解决二叉搜索树的缺陷,两位俄罗斯的数学家G.M.Adelson-Velskii
和E.M.Landis
发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1
(超过1
则需要对结点进行旋转),即可降低树的高度,确保整颗树的深度为O(logN)
。
因此,如果二叉搜索树中节点具备以下性质:
AVL
树右子树的高度 - 左子树的高度
(简称平衡因子)的绝对值不超过1
)那么这颗搜索二叉树就是一颗AVL
树。
AVL
树在原二叉搜索树的基础上添加了平衡因子(Balance factor
),简写: bf
,以及用于快速向上调整的父亲指针parent
(为什么定义指针变量parent
,在插入部分会介绍到),所以AVL
树是一个三叉链结构。
// 以Key/Value模型为例
#include
#include
using namespace std;
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>& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
,_parent(nullptr)
, _bf(0)
{}
};
至于AVLTree
类中,成员变量只需要创建一个 根节点_root
即可
template<class K, class V>
class AVLTree
{
typedef AVLTreeNode<K, V> Node;
public:
// 默认构造
// 初始化_root
AVLTree()
:_root(nullptr)
{}
private:
Node* _root;
};
注意: 这篇博客规定平衡因子差值为右 - 左
。当然左 - 右
也是可以的,根据自己的个人习惯。
通过上图,我们可以发现一个结论: 左边多一个结点,其祖先的路径上的平衡因子_bf--
。
同理的,右边多一个结点,其祖先的路径上的平衡因子_bf++
。
【总结】
- 左边多一个结点,其祖先的路径上的平衡因子
_bf--
。- 右边多一个结点,其祖先的路径上的平衡因子
_bf++
。
将结点插入以后,我们需要做的就是控制平衡。
因此,就有以下3
种情况
parent
的平衡因子为0
时,说明插入的结点已经把矮的那边给补上了,那么就不需要沿着祖先向上更新。parent
的平衡因子为1
或者-1
,就要沿着祖先的路径向上检查是否要更新。(祖先可能会不平衡)parent
的平衡因子为2
或者-2
,说明不平衡。解决方法:对parent
所在的子树进行旋转。(具体后面再谈)根据以上分析,我们就可以写出大致的AVL
插入的框架
bool insert(const pair<K, V>& key)
{
// 如果一开始根结点为空,直接插入即可
if (_root == NULL)
{
_root = new Node(key); // new会自动调用自定义类型的构造函数
return true;
}
// 如果一开始根结点不为空,就要找到合适的位置插入
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key.first < key.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key.first > key.first)
{
parent = cur;
cur = cur->_left;
}
else // 出现数据冗余,插入失败
{
return false;
}
}
// 当cur走到空,说明已经找到了合适的位置
cur = new Node(key);
if (parent->_key.first < key.first)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
// 以上的代码基本和搜索二叉树一样
// 更新成员变量_parent
cur->_parent = parent;
// 控制平衡并更新平衡因子_bf(重要部分)
// parent向上更新至多要到根结点root的_parent
while (parent)
{
// 更新平衡因子
// 左边多一个结点,父亲的平衡因子--
if (cur == parent->_left)
{
parent->_bf--;
}
// 右边多一个结点,父亲的平衡因子++
else // cur == parent->_right
{
parent->_bf++;
}
// 控制平衡
// parent的平衡因子为0就不需要向上控制平衡了
if (parent->_bf == 0)
{
break;
}
// parent的平衡因子为1或者-1
// 就要沿着祖先的路径向上检查是否有不平衡的情况
else if (parent->_bf == 1 || parent->_bf == -1)
{
// 迭代
cur = parent;
parent = parent->_parent;
}
// parent的平衡因子为2或者-2,说明不平衡。
// 解决方法:对parent所在的子树进行旋转
else if (parent->_bf == 2 || parent->_bf == -2)
{
// 旋转部分
// ....
// 旋转完后一定平衡,直接退出即可
break;
}
// 如果断言错误出现在这一行,说明在一开始插入前,平衡因子就出现了错误
else
{
assert(false);
}
}
return true;
}
因此,定义parent
指针变量就是为了快速找到某结点的父亲,从而快速更新平衡因子以及快速判断是否需要进行旋转操作,减低了遍历子树来找到父结点的时间。
旋转需要注意的问题
还是需要保持它是一颗具有搜索树的性质(左子树比根小,右子树比根大)
让它变成平衡树,且减低这个子树的高度
左单旋是为了处理当某个结点的右子树过深而导致失衡的情况(parent->_bf == 2 && cur->_bf == 1
)。具体步骤如下:
cur
的左结点作为parent
的右结点。parent
结点作为cur
的左结点。当然还要考虑很多细节问题,比如:需要更新cur
和parent
的父亲以及树可能是作为子树存在等,具体看看代码实现。
【代码实现】
else if (parent->_bf == 2 || parent->_bf == -2)
{
// 1. 左旋转(右边高)
if (parent->_bf == 2 && cur->_bf == 1)
{
RotateLeft(parent);
}
break;
}
// 左旋转函数实现
void RotateLeft(Node* parent)
{
// 记录cur和cur的左孩子
Node* cur = parent->_right;
Node* curleft = cur->_left;
// ======== 旋转 ==========
// 1. 将cur的左结点,也就是curleft作为parent的右结点
parent->_right = curleft;
// 2. 再将parent结点作为cur的左结点
cur->_left = parent;
// ======= 更新父亲关系 =======
// 修改curleft的父亲,但可能存在可能不存在
if (curleft)
{
curleft->_parent = parent;
}
Node* ppnode = parent->_parent;
// 修改parent的父亲
parent->_parent = cur;
// 考虑是否为子树的情况
// 为根的情况
if (ppnode == nullptr)
{
_root = cur;
cur->_parent = nullptr; // 根节点的父亲本身就为空
}
// 是子树的情况
else
{
if (ppnode->_left == parent)
{
ppnode->_left = cur;
}
else
{
ppnode->_right = cur;
}
// 更新父亲
cur->_parent = ppnode;
}
// 最后更新(修改)平衡因子
parent->_bf = cur->_bf = 0;
}
右单旋本质上和左单旋一样,有种对称的感觉~
右单旋是为了处理当某个节点的左子树过深而导致失衡的情况(parent->_bf == -2 && cur->_bf == -1
)。具体步骤如下:
cur
的右结点作为parent
的左结点。parent
结点作为cur
的右结点。当然还要考虑很多细节问题,比如:需要更新cur
和parent
的父亲以及树可能是作为子树存在等,具体看看代码实现。
【代码实现】
else if (parent->_bf == 2 || parent->_bf == -2)
{
// 右单旋
else if (parent->_bf == -2 && cur->_bf == -1)
{
RotateRight(parent);
}
break;
}
// 右旋转函数实现
void RotateLeft(Node* parent)
{
// 记录cur和cur的右孩子curRight
Node* cur = parent->_left;
Node* curRight = cur->_right;
// ======== 旋转 ==========
// 将cur的右孩子curRight接到parent的left
parent->_left = curRight;
// 接着让parent替代cur右的位置
cur->_right = parent;
// ======= 更新父亲关系 =======
// 修改curRight的父亲,但可能存在可能不存在
if (curRight)
{
curRight->_parent = parent;
}
Node* ppnode = parent->_parent;
// 修改parent的父亲
parent->_parent = cur;
// 考虑是否为子树的情况
// 为根的情况
if (ppnode == nullptr)
{
_root = cur;
cur->_parent = nullptr; // 根节点的父亲为空
}
// 是子树的情况
else
{
if (ppnode->_left == parent)
{
ppnode->_left = cur;
}
else
{
ppnode->_right = cur;
}
// 更新父亲
cur->_parent = ppnode;
}
// 最后更新(修改)平衡因子
parent->_bf = cur->_bf = 0;
}
从上图A
样例发现:parent
的左子树高,因此很容易可以想到右单旋来控制平衡。但是,通过图B
发现,右单旋还是解决不了问题。
那么,如果是以上这种 折线型
不平衡的情况,我们可以考虑使用双旋来解决。
左右双旋是为了处理parent
的左子树cur
的右子树过深而导致失衡的情况(parent->_bf == -2 && cur->_bf == 1
)。具体步骤如下:
cur
结点进行左旋操作(因为cur
右子树过深)parent
结点进行右旋操作我们可以根据以上步骤来验证
通过以上分析,有的人可能会旋转代码复用,几行代码就搞定了。
如果这样写就错了,左单旋接口和右单旋接口会默认把cur
和parent
的平衡因子置为0
,但双旋后的cur
和parent
的平衡因子不一定为0
,因此 需要考虑旋转后平衡因子的情况。
当curRight
的平衡因子为-1
时(有左孩子),左右双旋后parent
、cur
、curRight
平衡因子分别为1
、0
、0
【代码实现】
else if (parent->_bf == 2 || parent->_bf == -2)
{
// 左右双旋
else if (parent->_bf == -2 && cur->_bf == 1)
{
RotateLR(parent);
}
break;
}
void RotateLR(Node* parent)
{
Node* cur = parent->_left;
Node* curRight = cur->_right;
int curRight_of_bf = curRight->_bf;
RotateLeft(cur);
RotateRight(parent);
if (curRight_of_bf == 0)
{
parent->_bf = 0;
cur->_bf = 0;
curRight->_bf = 0;
}
else if (curRight_of_bf == -1)
{
parent->_bf = 1;
cur->_bf = 0;
curRight->_bf = 0;
}
else if (curRight_of_bf == 1)
{
parent->_bf = 0;
cur->_bf = -1;
curRight->_bf = 0;
}
else
{
assert(false);
}
}
右左双旋是为了处理parent
结点的右结点cur
的左子树过深而导致失衡的情况(parent->_bf == 2 && cur->_bf == -1
)。具体步骤如下:
cur
结点进行右旋操作(cur
左子树过深)parent
结点进行左旋操作右左旋和左右旋类似,手撕代码之前同样需要考虑旋转后平衡因子的情况:
curLeft
的平衡因子为0
时(没有孩子),右左双旋后parent
、cur
、curLeft
平衡因子都为0
curLeft
的平衡因子为-1
时(有左孩子),右左双旋后parent
、cur
、curLeft
平衡因子分别为都为0
、1
、0
curLeft
的平衡因子为1
时(有右孩子),右左双旋后parent
、cur
、curLeft
平衡因子分别为都为-1
、0
、0
【代码实现】
else if (parent->_bf == 2 || parent->_bf == -2)
{
// 右左双旋
else if (parent->_bf == 2 && cur->_bf == -1)
{
RotateRL(parent);
}
break;
}
void RotateRL(Node* parent)
{
Node* cur = parent->_right;
Node* curLeft = cur->_left;
int curLeft_of_bf = curLeft->_bf;
RotateRight(cur);
RotateLeft(parent);
if (curLeft_of_bf == 0)
{
cur->_bf = 0;
curLeft->_bf = 0;
parent->_bf = 0;
}
else if (curLeft_of_bf == 1)
{
cur->_bf = 0;
curLeft->_bf = 0;
parent->_bf = -1;
}
else if (curLeft_of_bf == -1)
{
cur->_bf = 1;
curLeft->_bf = 0;
parent->_bf = 0;
}
else
{
assert(false);
}
}
通过平衡因子检查检查即可。平衡因子反映的是左右子树高度之差(本篇博客是:右子树 - 左子树
)。通过计算出左右子树高度之差并与当前节点的平衡因子进行比对,如果发现不同,则说明 AVL
树非法。
注意:如果当前节点的 平衡因子 取值范围不在[-1, 1]
内,也可以判断非法
// 验证AVL树
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;
}
bool IsBalance()
{
return IsBalance(_root);
}
bool IsBalance(Node* root)
{
if (root == nullptr)
{
return true;
}
int leftHeight = Height(root->_left);
int rightHeight = Height(root->_right);
if (rightHeight - leftHeight != root->_bf || root->_bf < -1 || root->_bf > 1)
{
cout << "平衡因子异常:" << root->_key.first << "->" << root->_bf << endl;
return false;
}
return abs(rightHeight - leftHeight) < 2
&& IsBalance(root->_left)
&& IsBalance(root->_right);
}
通过一段简单的代码,随机插入1w
个节点,判断是否合法
#include "AVL.h"
#include
int main()
{
const int N = 10000;
vector<int> v(N);
srand((size_t)time(NULL));
for (int i = 0; i < N; i++)
{
v.push_back(rand());
}
AVLTree<int, int> t;
for (auto x : v)
{
t.insert(make_pair(x, x));
cout << "Insert:" << x << "->" << t.IsBalance() << endl;
}
return 0;
}
当打印出来的结果全为1
(表示真),那么它就是一个AVL
树
AVL
树是一棵 绝对平衡 的二叉树,对高度的控制极为苛刻,稍微有点退化的趋势,都要被旋转调整,这样做的好处是 严格控制了查询的时间,查询速度极快,时间复杂度为 logN
。而对于删除,大家不用担心,因为在面试的时候只会考察AVL
树的插入操作hh
这是本篇博客的相关代码:代码仓库。