平衡树
平衡树是一种特殊的二叉搜索树,其目的是通过一些特殊的技巧来维护树的高度平衡,从而保证树的插入、搜索、删除等操作的时间复杂度都较低(趋近O(logn))。
那么为什么二叉搜索树需要平衡呢?因为当一棵二叉搜索树插入或删除数据的顺序高度一致时,二叉搜索树就会退化成接近链表的状态(比如一棵数据从小到大插入的二叉搜索树)。这时树的插入、删除、搜索的时间复杂度都会变成O(n)。因此,当我们需要高效的处理大量数据时,搜索树的平衡就变得至关重要。
常见的平衡二叉搜索树
- AVL树:一种最早的平衡二叉搜索树
- 红黑树:一种比较流行的平衡二叉搜索树,由R.Bayer在1972年发明
- Splay树:一种动态平衡二叉搜索树
- Treap:一种随机化的平衡二叉搜索树,是二叉搜索树和堆的结合
- B-树:一种适用于磁盘或其他外存存储设备的多路平衡查找树
AVL树和红黑树是应用最广泛的两种平衡二叉搜索树,由于红黑树过于复杂,所以我们本章节主要讲解AVL树。
AVL树
AVL树是一种自平衡的二叉搜索树,它通过旋转操作保证树的平衡。那么AVL树是如何判断一棵树到底平不平衡呢?
在AVL树中,每个节点都有一个平衡因子,该值代表了以该节点为根节点的子树的高度差。该高度差的绝对值不能大于1,所以当节点平衡因子的绝对值超过1时,我们就要通过旋转来重新平衡树。下面是一幅标记各个节点平衡因子AVL树:
对于AVL树的封装,为了方便读者理解,将分为以下几步:
- AVL树节点封装
- AVL旋转操作(左旋、右旋)
- 不同情况调用不同旋转方法
- 插入操作
- 删除操作
AVL树节点封装
初始化属性和一些基础的方法(由于比较简单直接给出):
class AVLTreeNode<T> {
value: T;
left: AVLTreeNode<T> | null = null;
right: AVLTreeNode<T> | null = null;
parent: AVLTreeNode<T> | null = null;
constructor(value: T) {
this.value = value;
}
// 高度
get height(): number {
return Math.max(this.left?.height || 0, this.right?.height || 0) + 1;
}
// 平衡因子 = 左子树高度 - 右子树高度
get balanceFactor(): number {
return (this.left?.height || 0) - (this.right?.height || 0);
}
// 是否是平衡节点
get isBalanced(): boolean {
return Math.abs(this.balanceFactor) <= 1;
}
// 获取子节点中高度较高的节点 该节点就是要旋转的轴心节点
get higherChild(): AVLTreeNode<T> | null {
const leftHeight = this.left?.height || 0;
const rightHeight = this.right?.height || 0;
// 如果左右子树高度相等且当前节点是父节点的左子节点,返回左子节点,否则返回右子节点
if (leftHeight === rightHeight) return this.isLeftChild ? this.left : this.right;
// 如果左子树高度大于右子树高度,返回左子节点,否则返回右子节点
return leftHeight > rightHeight ? this.left : this.right;
}
// 是否是父节点的左子节点
get isLeftChild() {
return this.parent !== null && this.parent.left === this;
}
// 是否是父节点的右子节点
get isRightChild() {
return this.parent !== null && this.parent.right === this;
}
}
这里需要解释的是为什么higherChild获取的节点是轴心节点,试想如下情况,依次插入5、3、2节点:
此时会发现这棵二叉搜索树已经不平衡了,不平衡的节点就是5,此时AVL树要做的旋转操作就是以3为轴心进行右旋转,此时higherChild的值正是3节点。
右旋转(左左情况)
对于上述情况,由于是最后插入的2造成5节点的不平衡,而2是5左子树的左节点,我们称这种情况为左左不平衡(left left),这种情况我们要进行的操作就是右旋转。那么我们要怎么旋转呢?我们参考更为复杂一点的情况来看:
- 确定轴心:为不平衡节点的左子节点(pivot:3节点)
- 处理轴心的右子节点(图中的B):pivot的右子节点要挂载到root节点的left,同时要指定B的父节点为root
- 处理pivot节点:要把root挂载到pivot的right,如果root有父节点,那么判断root是左子节点还是右子节点,将pivot挂载到root父节点的left或right,然后pivot的父节点赋值为this的父节点。
- 将root的父节点赋值为pivot,判断如果pivot没有父节点,返回pivot,将pivot作为整棵树的根节点。
做完上述四步,树就会变成这样:
那么具体转换成代码实现:
// 右旋转
rotateRight() {
// 1、获取轴心节点
const pivot = this.higherChild!;
// 2、处理pivot的右子节点
this.left = pivot.right;
if (pivot.right) {
pivot.right.parent = this;
}
// 3、处理pivot节点
pivot.right = this;
pivot.parent = this.parent;
if (this.parent) {
if (this.isLeftChild) {
this.parent.left = pivot;
} else {
this.parent.right = pivot;
}
}
// 4、处理root节点(this)
this.parent = pivot;
// 如果pivot是根节点,返回pivot
if (!pivot.parent) return pivot;
// 默认返回null 意味着不需要更新根节点
return null;
}
左旋转(右右情况)
理解了左左情况的右旋转,那么右右情况的左旋转也是类似的。右右情况:
左旋转的步骤和右旋转基本相同,这里直接给出实现:
// 左旋转
rotateLeft() {
// 1、获取轴心节点
const pivot = this.higherChild!;
// 2、处理pivot的左子节点
this.right = pivot.left;
if (pivot.left) {
pivot.left.parent = this;
}
// 3、处理pivot节点
pivot.left = this;
pivot.parent = this.parent;
if (this.parent) {
if (this.isLeftChild) {
this.parent.left = pivot;
} else {
this.parent.right = pivot;
}
}
// 4、处理root节点(this)
this.parent = pivot;
// 如果pivot是根节点,返回pivot
if (!pivot.parent) return pivot;
// 默认返回null 意味着不需要更新根节点
return null;
}
旋转方法合并
可以看到两种旋转的实现高度相似,所以可以进行合并:
// 左右旋转封装 传入旋转方向
rotateByDir(direction: 'left' | 'right') {
// 1、获取轴心节点
const pivot = this.higherChild!;
// 2、处理pivot的子节点
this[direction === 'left' ? 'right' : 'left'] = pivot[direction];
if (pivot[direction]) {
pivot[direction]!.parent = this;
}
// 3、处理pivot节点
pivot[direction] = this;
pivot.parent = this.parent;
if (this.parent) {
if (this.isLeftChild) {
this.parent.left = pivot;
} else {
this.parent.right = pivot;
}
}
// 4、处理root节点(this)
this.parent = pivot;
// 如果pivot是根节点,返回pivot
if (!pivot.parent) return pivot;
// 默认返回null 意味着不需要更新根节点
return null;
}
以上就是AVL树节点方法的封装,重点是旋转的思路要理清楚,代码实现其实并不复杂。由于剩余的内容还有很多,所以不同情况调用不同的旋转方法、插入删除操作将在下一篇文章讲解,欢迎订阅本系列。