序言
在 Part one 部分我们了解到了AVL树的定义,AVL在进行结点的调整(如插入,删除)时,会自适应的去调节树的高度,使左右高度的最大差值的绝对值不超过1。同时我们也介绍了调节的四个方法 — 左旋,右旋,左右旋,右左旋,他们具体的使用场景以及代码实现。这篇文章,我们将详细的讲解AVL树是如何进行删除操作的,但在这之前,大家还记得搜索二叉树是如何进行删除操作的吗❓
1. BSTree的删除操作 😉
我们在 基于二叉搜索树(BSTree) 实现 Map👈 这篇文章中详细地讲解了删除操作的过程,在这里我们就总结一下三种情况:
1.1 叶子结点的删除
最简单的的删除操作,但是需要注意该叶子节点是否是根节点
- 删除前先判断是否为根节点
- 不是根节点,直接删除;是根节点记得 root 置空,避免悬空指针
1.2 只有一个子节点
只需要将父节点连接该节点的子节点,也需要判断是否是根节点
- 删除前先判断是否为根节点
- 是根节点,将根节点指向该节点的子节点,删除该节点
- 不是根节点,将父节点指向该节点的子节点,删除该节点
1.3 有两个子节点
直接删除该节点是比较困难的,我们使用的方法是将该节点的值和该节点的子节点的值交换,之后删除子节点,但是交换也是有讲究的,替换后是需要该树依旧满足搜索二叉搜索树的性质;我们一般替换的方法有两种,一是左树的最大值,二是右树的最小值,并且删除
- 将该结点的值和子节点的值交换
- 删除子节点,该子节点一定是前两个情况的其中一种
这就是二叉搜索树的删除操作,一定要分清楚是哪一种情况,选择合适的方式很重要。
2. AVL树的删除操作
2.1 平衡因子的更新方式
AVL树和搜索二叉树唯一的不同就是,前者时时刻刻需要关注平衡因子的值。当我们删除结点时,平衡因子肯定会受到影响,所以我们需要对其进行更新。
在这里我们不需要分三种情况来更新,我们只需要向上遍历更新即可,所以我们必须要时刻弄明白,变化的子树是父节点的左子树还是右子树,我们对平衡因子的定义是 最高的右子树与最高的左子树高度之差,所以更新的方式为 删除的位置在右子树则减一,反之加一。
2.2 旋转方式的判别
在插入操作时,我们的旋转方式的判别可以总结为:
- 当 cur 的平衡因子为 1,parent 的平衡因子为 2,进行左旋操作
- 当 cur 的平衡因子为 -1,parent 的平衡因子为 -2,进行右旋操作
- 当 cur 的平衡因子为 -1,parent 的平衡因子为 2,进行先右旋再左旋操作
- 当 cur 的平衡因子为 1,parent 的平衡因子为 -2,进行先左旋再右旋操作
删除操作稍显不同,他仍然需要关注 parent 的平衡因子,但是他 不再关注 cur 的平衡因子,而是关注 cur 兄弟的平衡因子。 就比如:
在这里如果我想要删除 8 这个值,删除之后可以发现 cur 的平衡因子变成了 0,parent 结点的平衡因子变成了 -2,不符合我们上述的任意一个条件。但是你看 brother 这个结点的平衡因子变成了 -1,根据条件推断是需要执行右旋操作,符合该情况。
该如何理解,删除时旋转方式的判断要依赖于兄弟节点呢🌀? 你可以这样理解,彼消此长 — 我自己这棵树变矮了,相对而言,我兄弟那棵树就变高了嘛。 变高了,肯定是需要判断是否需要旋转呀。
2.3 更新的结束条件
在进行插入操作时,我们的平衡因子跟新结束条件为 直到我们进行了一次旋转操作,父节点的平衡因子更新为 0。 但是对于删除操作来说,进行一次旋转操作可能还是不够的,有可能的需要多次旋转操作,就比如:
这里就需要两次旋转操作才能达到平衡。
那删除操作后,更新的结束条件是什么呢❓ 可以总结为:
- 当更新的父节点的原始平衡因子为 0 时,这个需要和插入的结束条件区分开,前者为更新前为 0,后者是更新后为 0
- 一直更新,直到根节点的平衡因子完成更新
- 当旋转操作时,兄弟节点的平衡因子为 0
我们在这里着重解释第一点和第三点:
第一点:
就比如在这里需要删除 2 / 4,当对父结点 3 完成平衡因子的更新后,就完成了。
在这里解释一下,结点 3 的初始平衡因子为 0。根据平衡因子的定义,我们可以理解为 左子树的最大高度是等于右子树的最大高度。 现在左子树的高度或者是右子树的高度变矮了,但是对于上层结点来说,最大高度其实还是不变的呀!!!因为影响平衡因子的只能时最大高度的改变,现在虽然其中一棵树的高度变了,但是另一棵依旧没变!
对于图上来说,现在删除 2 结点,左子树原来的最大高度为 3,现在还是 3,该树为 534。
第二点:
这个也不难理解,我们一直都说,尝试去画图理解树,只要图能画出来,就一定可理解:
同一个图,只是删除的节点不同,在这里我们需要删除 6,父节点的平衡因子变为 -2,兄弟节点的平衡因子是 0,该怎么办呢?
答案是还是右旋,只是说这个右旋过后,平衡因子的改变和传统的不一样:
在传统的右旋操作后,parent,cur 的平衡因子都会变成 0 。但是在这里分别变成 -1, 1。 (左旋类似)
这里的原因和上面的差不多,普通的右旋操作会让整棵树的最大高度减一,但是该右旋最大高度不变。
3. 代码实现
光说不实现肯定是不可以的,现在我们尝试实现:
在这里记录并更新平衡因子
// 记录并更新平衡因子
int old_bf = parent->_bf;
if (parent->_left == child) { parent->_bf++; }
else { parent->_bf--; }
如果初始的平衡因子是 0,则直接退出:
// 该节点原来的的平衡因子为 0,直接退出
if (old_bf == 0) { break; }
之后如果不是 0,则判断是否旋转,以及旋转的方式(别忘了特殊的旋转,兄弟节点平衡因子为 0)
// 判断是否需要旋转,以及旋转方式:
if (parent->_bf == 2 || parent->_bf == -2) {
// 如何旋转取决于,另一边孩子
// 左旋
if (parent->_left == child) {
if (parent->_bf == 2 && parent->_right->_bf == 1) {
Node* subR = parent->_right;
RotateL(parent);
// 更新结点
parent = subR->_parent;
child = subR;
continue;
}
// 先右旋再左旋
else if(parent->_bf == 2 && parent->_right->_bf == -1){
Node* subRL = parent->_right->_left;
RotateRL(parent);
// 更新结点
parent = subRL->_parent;
child = subRL;
continue;
}
// 特殊情况为 0,这种情况可以直接旋转后退出
else if (parent->_bf == 2 && parent->_right->_bf == 0) {
Node* subR = parent->_right;
RotateL(parent);
// 更新平衡因子
subR->_bf = -1;
subR->_left->_bf = 1;
break;
}
}
else {
// 右旋
if (parent->_bf == -2 && parent->_left->_bf == -1) {
Node* subL = parent->_left;
RotateR(parent);
// 更新结点
parent = subL->_parent;
child = subL;
continue;
}
// 左旋再右旋
else if (parent->_bf == -2 && parent->_left->_bf == 1) {
Node* subLR = parent->_left->_right;
RotateLR(parent);
// 更新结点
parent = subLR->_parent;
child = subLR;
continue;
}
// 特殊情况为 0,这种情况可以直接旋转后退出
else if (parent->_bf == -2 && parent->_left->_bf == 0) {
Node* subL = parent->_left;
RotateR(parent);
// 更新平衡因子
subL->_bf = 1;
subL->_right->_bf = -1;
break;
}
}
}
如果不满足上述两种情况,则更新结点,向上循环
// 更新节点
child = parent;
parent = parent->_parent;
4. 总结
AVL树的内容就结束了。可以看到通过旋转操作,AVL树可以将查询效率始终控制在 O(log n)。但在 C++ STL 中却并没有使用该树来构造 map,set,而是使用红黑树。这位更是重量级,他的维护成本更低,删除效率更高,那他背后又是什么样的逻辑呢?👀