想象一下你正在整理一个巨大的电话簿,里面有几百万个联系人。如果这些名字是随意排列的,每次查找一个联系人可能需要翻遍整本书,效率低得让人抓狂。这就是普通二叉搜索树可能遇到的问题——当数据有序或接近有序插入时,树会退化成类似链表的结构,查找时间复杂度从O(logN)恶化到O(N)。
1962年,两位俄罗斯数学家G.M. Adelson-Velskii和E.M. Landis提出了一个天才的解决方案:让树在每次插入或删除后自动调整,保持左右子树高度差不超过1。这种自平衡的二叉搜索树后来以他们名字的首字母命名,就是我们现在要深入探讨的AVL树。
我曾在开发一个实时交易系统时使用AVL树来存储股票价格数据。实测下来,在频繁插入和查询的场景下,AVL树的性能比普通二叉搜索树稳定得多,查询时间始终保持在O(logN)级别。这种稳定性对于金融系统来说至关重要。
AVL树之所以能保持平衡,关键在于它引入了一个叫"平衡因子"的概念。简单来说,平衡因子就是某个结点右子树高度减去左子树高度的值。用公式表示就是:
code复制平衡因子 = 右子树高度 - 左子树高度
根据AVL树的定义,每个结点的平衡因子只能是-1、0或1。如果某个结点的平衡因子绝对值超过1,就说明这个子树不平衡了,需要通过旋转操作来调整。
在实际编码中,我们通常会把平衡因子作为结点的一个属性存储起来。比如在C++中可以这样定义AVL树的结点结构:
cpp复制struct AVLTreeNode {
AVLTreeNode* left; // 左孩子
AVLTreeNode* right; // 右孩子
AVLTreeNode* parent; // 父结点(三叉链结构)
pair<K, V> kv; // 存储的键值对
int bf; // 平衡因子
};
每次插入或删除结点后,都需要沿着插入/删除路径向上更新祖先结点的平衡因子。更新规则很简单:
更新后需要检查平衡因子的值:
我在第一次实现AVL树时,就因为没有正确处理平衡因子更新的终止条件,导致程序陷入了无限循环。后来通过打印每个结点的平衡因子变化,才找到问题所在。
左单旋适用于"右右"情况:即某个结点的平衡因子为+2,且它的右孩子的平衡因子为+1。
具体步骤可以用一个生活中的例子来理解:想象你手里拿着一根挂满衣物的晾衣杆,右边太重导致杆子向右倾斜。为了平衡,你需要把右边的部分向左旋转。
代码实现如下:
cpp复制void RotateL(Node* parent) {
Node* subR = parent->right; // parent的右孩子
Node* subRL = subR->left; // subR的左孩子
// 1. 建立subR和parent的关系
parent->right = subRL;
if(subRL) subRL->parent = parent;
// 2. 建立parent和subR的关系
subR->left = parent;
Node* ppNode = parent->parent;
parent->parent = subR;
// 3. 处理subR与原parent父结点的关系
if(ppNode == nullptr) {
_root = subR;
subR->parent = nullptr;
} else {
if(ppNode->left == parent) {
ppNode->left = subR;
} else {
ppNode->right = subR;
}
subR->parent = ppNode;
}
// 4. 更新平衡因子
parent->bf = subR->bf = 0;
}
右单旋处理"左左"情况:某结点平衡因子为-2,且左孩子的平衡因子为-1。
继续用晾衣杆的比喻:现在左边太重导致杆子向左倾斜,你需要把左边的部分向右旋转。
代码实现:
cpp复制void RotateR(Node* parent) {
Node* subL = parent->left;
Node* subLR = subL->right;
// 1. 建立subL和parent的关系
parent->left = subLR;
if(subLR) subLR->parent = parent;
// 2. 建立parent和subL的关系
subL->right = parent;
Node* ppNode = parent->parent;
parent->parent = subL;
// 3. 处理subL与原parent父结点的关系
if(ppNode == nullptr) {
_root = subL;
subL->parent = nullptr;
} else {
if(ppNode->left == parent) {
ppNode->left = subL;
} else {
ppNode->right = subL;
}
subL->parent = ppNode;
}
// 4. 更新平衡因子
parent->bf = subL->bf = 0;
}
左右双旋处理"左右"情况:某结点平衡因子为-2,左孩子的平衡因子为+1。
这种情况需要先对左孩子进行左旋,再对原结点进行右旋。就像先调整晾衣杆中间的挂钩,再调整整个杆子。
代码实现:
cpp复制void RotateLR(Node* parent) {
Node* subL = parent->left;
Node* subLR = subL->right;
int bf = subLR->bf; // 保存原始平衡因子
// 先左旋subL
RotateL(subL);
// 再右旋parent
RotateR(parent);
// 根据subLR原始平衡因子更新平衡因子
if(bf == 1) {
parent->bf = 0;
subL->bf = -1;
subLR->bf = 0;
} else if(bf == -1) {
parent->bf = 1;
subL->bf = 0;
subLR->bf = 0;
} else if(bf == 0) {
parent->bf = 0;
subL->bf = 0;
subLR->bf = 0;
} else {
assert(false);
}
}
右左双旋处理"右左"情况:某结点平衡因子为+2,右孩子的平衡因子为-1。
需要先对右孩子进行右旋,再对原结点进行左旋。就像先调整晾衣杆右侧的支撑点,再整体调整。
代码实现:
cpp复制void RotateRL(Node* parent) {
Node* subR = parent->right;
Node* subRL = subR->left;
int bf = subRL->bf;
// 先右旋subR
RotateR(subR);
// 再左旋parent
RotateL(parent);
// 更新平衡因子
if(bf == 1) {
parent->bf = -1;
subR->bf = 0;
subRL->bf = 0;
} else if(bf == -1) {
parent->bf = 0;
subR->bf = 1;
subRL->bf = 0;
} else if(bf == 0) {
parent->bf = 0;
subR->bf = 0;
subRL->bf = 0;
} else {
assert(false);
}
}
AVL树的插入分为三个主要步骤:
这里有个容易踩坑的地方:更新平衡因子时,当某个结点的bf变为0就可以停止了,因为这说明树的高度没有变化,不会影响更上层的平衡因子。
删除操作比插入更复杂,因为可能需要在多个位置进行旋转。关键点包括:
我在实现删除功能时,曾因为旋转后错误地终止了平衡因子更新,导致树在某些情况下仍然不平衡。后来通过大量测试用例才发现了这个问题。
验证AVL树需要检查两点:
可以采用后序遍历递归检查:
cpp复制bool IsAVLTree(Node* root) {
if(root == nullptr) return true;
// 检查左右子树
int leftHeight = Height(root->left);
int rightHeight = Height(root->right);
// 检查平衡因子
if(rightHeight - leftHeight != root->bf) {
cout << "平衡因子错误:" << root->kv.first << endl;
return false;
}
return abs(leftHeight - rightHeight) < 2
&& IsAVLTree(root->left)
&& IsAVLTree(root->right);
}
AVL树的优势在于查询效率,始终保持在O(logN)。但维护平衡需要额外开销:
因此,AVL树适合查询多、修改少的场景。对于频繁插入删除的应用,红黑树可能是更好的选择。