数据结构:AVL 树
目录
二叉搜索树的缺点 (Drawbacks of BST)
第一性原理回顾:BST 的性能承诺是什么?
核心缺点:性能承诺的脆弱性——树的退化 (Degeneracy)
如何定义和衡量“平衡”?
AVL 树的诞生
如何把这个概念落实到代码结构上?
总结
二叉搜索树的缺点 (Drawbacks of BST)
到目前为止,我们一直在赞美 BST。但它并非完美,存在一些致命的缺陷,理解这些缺陷是通往更高级数据结构(如 AVL 树、红黑树)的必经之路。
第一性原理回顾:BST 的性能承诺是什么?
我们当初为什么选择 BST?因为我们期望它的查找、插入、删除操作都能达到 O(logn) 的时间复杂度。
这个期望是建立在一个隐含的假设之上的:
这棵树是“平衡”的,或者说是“枝繁叶茂”的。
“平衡”意味着树的高度 h 大约是 logn。因为每次操作最多走过树的高度,所以性能就是 O(h)approxO(logn)。
核心缺点:性能承诺的脆弱性——树的退化 (Degeneracy)
BST 的最大缺点在于,它无法保证自己永远处于“平衡”状态。树的最终形态完全取决于数据的插入顺序。
最坏情况推导: 如果我们插入一个已经排好序的数组,比如 [10, 20, 30, 40, 50]
,会发生什么?
-
插入
10
,成为根。 -
插入
20
,20 > 10
,成为10
的右孩子。 -
插入
30
,30 > 10
,30 > 20
,成为20
的右孩子。 -
...以此类推。
最终形成的“树”是这样的:
10\20\30\40\50
这棵所谓的“树”已经退化 (degenerate) 成了一个链表!
在这种退化的树上执行操作:
-
查找
50
:需要从10
开始,一路向下,访问 5 个节点。 -
插入
60
:需要从10
开始,一路向下,访问 5 个节点,最后挂在50
后面。
此时,树的高度 h 等于节点数量 n。所有操作的时间复杂度都从期望的 O(logn) 劣化到了 O(n)。我们费尽心机构建的 BST,其性能又回到了普通数组的水平,优势荡然无存。这就是 BST 最致命的缺点。
普通的搜索二叉树(BST)的性能严重依赖于其“形态”或“高度”。一棵“倾斜”的、不平衡的 BST 会使其性能优势丧失殆尽。
其他缺点
-
没有自平衡机制:这是导致核心缺点的原因。标准的 BST 只有一套“左小右大”的构建规则,但没有任何规则来监控和修正树的形态。它对坏的输入顺序(如有序数据)毫无抵抗力。
-
删除操作复杂:相比插入和查找,删除一个拥有两个子节点的节点的逻辑要复杂得多,需要“寻找后继”和“值替换”等操作,实现起来更容易出错。
-
递归深度问题:对于一棵退化的、深度为 n 的树,如果我们使用递归进行操作,递归调用的深度也会是 n。当 n 非常大时,可能会导致栈溢出 (stack overflow)。
总结与展望
特性 | 理想情况 (平衡树) | 最坏情况 (退化树) |
---|---|---|
高度 | O(logn) | O(n) |
查找 | O(logn) | O(n) |
插入 | O(logn) | O(n) |
删除 | O(logn) | O(n) |
BST 的缺点告诉我们,仅仅依赖“左小右大”的规则是不够的。我们需要一种更强大的数据结构,它能在保持 BST 性质的基础上,增加自平衡 (self-balancing) 的能力。无论我们按什么顺序插入或删除节点,它都能通过一些调整(如旋转操作)来确保树的高度始终维持在 O(logn) 级别。
这就是AVL 树和红黑树等高级数据结构被发明出来的原因。它们是 BST 的“加强版”,解决了性能不稳定的核心痛点。
如何定义和衡量“平衡”?
既然问题出在“不平衡”上,那我们的核心目标就是维持树的平衡。
要维持平衡,首先要能衡量平衡。我们怎么用一个具体的、可量化的指标来描述一个节点,甚至一整棵树,是否平衡呢?
想法 A:比较左右子树的节点数量?
这看起来很直观。如果一个节点的左子树有 50 个节点,右子树有 50 个节点,那它应该很平衡吧?
不一定。看这个例子:
(50)/ \(25) (75)/ \
(10) (80)
/ \
(5) (90)
在根节点 (50) 看来,左子树有 3 个节点,右子树也有 3 个节点。数量上是完美的。但左子树和右子树本身都是严重“倾斜”的。这棵树的整体性能依然不好。
所以,仅仅比较节点数量是不够的,因为它无法真实反映树的高度。
想法 B:比较左右子树的高度?
这个想法更加靠谱。因为我们所有性能分析都和“高度”直接挂钩。如果我们能确保在任何一个节点上,它的左子树和右子树的高度差都不会太大,那么整棵树的高度一定不会太离谱。
一个衡量树平衡的有效指标,是其任意节点的左右子树高度之差。
基于这个结论,我们来定义一个概念:
平衡因子 (Balance Factor, BF)
对于任意一个节点 node
,我们定义它的平衡因子为:
BF(node) = height(node->left) - height(node->right)
其中,height(subtree)
代表该子树的高度。我们做一个约定:
-
如果子树是
NULL
(空),那么它的高度为 -1。 -
如果子树只有一个节点(叶子节点),那么它的高度为 0。
根据这个定义:
-
当
BF = 0
时,该节点的左右子树高度完全相等。 -
当
BF = 1
时,该节点的左子树比右子树高 1。 -
当
BF = -1
时,该节点的右子树比左子树高 1。
AVL 树的诞生
有了平衡因子这个强大的工具,我们就可以给“平衡的搜索二叉树”下一个非常精确的定义了。
这个定义由两位苏联数学家 G. M. Adelson-Velsky 和 E. M. Landis 在 1962 年提出,所以这种树被称为 AVL 树。
AVL 树的定义:
一棵 AVL 树是满足以下两个条件的搜索二叉树:
-
它首先是一棵搜索二叉树 (BST)。
-
对于树中的每一个节点,其平衡因子的绝对值必须小于等于 1。
用公式表达就是,对于任意节点 node
: | BF(node) | <= 1
这等价于 BF(node)
的取值只能是 -1
, 0
, 1
中的一个。
一旦某个节点的 BF
变成了 2
或 -2
,我们就称这个节点“失衡”(unbalanced),这棵树就不再是 AVL 树了,需要进行调整(我们下次再讲如何调整)。
如何把这个概念落实到代码结构上?
现在我们知道了 AVL 树的定义,那么在 C/C++ 的代码里,我们应该如何表示一个 AVL 树的节点呢?
我们先回顾一下普通 BST 的节点结构:
// 普通搜索二叉树的节点
struct Node {int data; // 节点存储的数据Node* left; // 指向左子节点的指针Node* right; // 指向右子节点的指针
};
这个结构只包含了数据和左右孩子的链接,没有包含任何与“平衡”有关的信息。
根据我们上面的推导,为了判断一个节点是否平衡,我们需要计算它的平衡因子 BF
。而要计算 BF
,我们又需要知道它左右子树的高度。
那么,最直接的方式,就是在节点结构中,额外增加一个字段来存储以该节点为根的子树的高度。
让我们来改进这个结构:
// AVL 树的节点结构
struct AVLTreeNode {int data; // 节点存储的数据AVLTreeNode* left; // 指向左子节点的指针AVLTreeNode* right; // 指向右子节点的指针int height; // 关键!我们增加一个字段来存储该节点的高度
};
为什么是存储 height
而不是直接存储 BF
?
-
height
是更基础的信息。知道了左右子节点的height
,我们可以非常容易地计算出当前节点的height
和BF
。-
height(currentNode) = 1 + max(height(currentNode->left), height(currentNode->right))
-
BF(currentNode) = height(currentNode->left) - height(currentNode->right)
-
-
如果我们只存
BF
,当树的结构发生变化时,BF
的传递和更新会非常复杂。而height
的更新则相对简单和直观。
所以,我们选择在节点中存储 height
字段。每当创建一个新节点(比如插入时),它作为一个叶子节点,它的 height
就被初始化为 0。
总结
到目前为止,我们已经完成了对 AVL 树的介绍,全程没有涉及任何插入、删除、旋转等操作。让我们回顾一下我们的推导路径:
-
发现问题:普通 BST 在极端情况下会退化成链表,性能急剧下降。
-
分析根源:问题的根源在于树的“不平衡”,即高度过大。
-
寻找指标:为了解决问题,我们需要一个量化指标。我们排除了“节点数量”,选择了“左右子树高度差”作为核心指标,并定义了平衡因子 (BF)。
-
提出方案:基于平衡因子,我们给出了 AVL 树的严格定义——它是一棵任意节点的
|BF| <= 1
的 BST。 -
落实结构:为了在代码中实现这个定义,我们在原有的 BST 节点结构中,增加了一个
height
字段,用于存储每个节点的高度,为后续计算 BF 和维持平衡提供基础。
现在,我们对 AVL 树“是什么”以及“为什么是这样”有了清晰的、基于第一性原理的理解。