持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
在之前的文章《JS 数据结构 —— 二叉搜索树(上篇)》《JS 数据结构 —— 二叉搜索树(中篇)》中,我们使用 js 自行封装了 BinarySearchTree
类,用来实现二叉搜索树这种数据结构。并为这个类添加了增加节点和查询节点(包括对极值的搜索)的方法。本篇作为完结篇,继续实现删除节点的方法。
删 remove()
删除节点相对之前篇章的增查操作而言会显得比较复杂,我们先根据传入的 key
找到该节点,再根据几种不同情况进行处理。
查找要删除的节点
通常情况下,当我们要删除某个节点时,只要把该节点的父节点,指向要删除节点的引用,重新指向 null
即可。所以我们定义两个变量作为指针,一个是 current
,指向当前节点,初始赋值为根节点;一个是 parent
,指向当前节点父节点,初始赋值为 null
。另外,我们还需要定义一个变量 isLeft
用于判断要删除的节点到底是父节点的左子节点还是右子节点,初始变量为 false
。这部分代码如下:
// 删
remove(key) {
// 查找 key 所在的节点
let parent = null,
current = this.root,
isLeft = false
while (key !== current.key) {
parent = current
if (key < current.key) {
current = current.left
isLeft = true
} else {
current = current.right
isLeft = false
}
// 如果找到最后也没找到,说明不存在该 key。直接返回 false
if (current === null) return false
}
// 如果找到了,则分几种情况处理
// ...
}
删除节点为叶节点(没有子节点)
如果要删除的节点为叶节点,也就是没有子节点的情况。
我们只需将其父节点的左子节点或右子节点赋值为 null
即可,至于到底是左还是右,要根据 isLeft
判断。另外,我们还需要考虑该节点为根节点这种特殊情况,如果是,则直接让根节点为空。
remove(key) {
// ...
// 1.要删除的节点为叶节点(没有子节点)
if (current.left === null && current.right === null) {
if (current === this.root) {
this.root = null
} else {
isLeft ? (parent.left = null) : (parent.right = null)
}
}
}
删除的节点只有 1 个子节点
接着我们考虑被删除的节点只有 1 个子节点的情况,我们需要将被删除节点的父节点指向被删除节点的子节点。此时,还可以细分出几种不同条件,这里为了避免被一堆左右子节点弄晕,我们每种情况都画个示意图:
- 被删除节点只有左子节点(current.right 为 null)
- 该节点是父节点的左子节点(
isLeft
为true
)
- 该节点是父节点的右子节点(
isLeft
为false
)
- 被删除节点只有右子节点(current.left 为 null)
- 该节点是父节点的左子节点
- 该节点是父节点的右子节点
注意,还有一种特殊情况,即要删除的节点为根节点,此时parent
为null
,如果根节点只有左子节点,则如下图,只需要将节点 4 作为根节点即可。
如果根节点只有右子节点则是将根节点重新赋值为current.right
。 被删除节点只有 1 个子节点的整体代码如下:
remove(key) {
// ...
else if (current.right === null) {
// 2.要删除的节点只有一个左子节点
if (current === this.root) {
this.root = current.left
} else {
isLeft ? (parent.left = current.left) : (parent.right = current.left)
}
} else if (current.left === null) {
// 2.要删除的节点只有一个右子节点
if (current === this.root) {
this.root = current.right
} else {
isLeft ? (parent.left = current.right) : (parent.right = current.right)
}
}
}
删除的节点有 2 个子节点
被删除的节点如果有 2 个子节点,这种情况就比较复杂了,直接说结论:就是在被删除节点的所有子节点中,找到 key 值最接近该节点的节点,用以替换被删除节点。可以在左子树中找,也可以在右子树中找。如果是在左子树中找,因为左子树中所有节点的 key 值都小于被删节点的,那么最接近的就应该是左子树中最大的那个节点,称为被删节点的前驱(中序遍历的序列中,被删节点的前一个节点);如果是在右子树中找,那么就应该找右子树中最小的那个节点,称为被删节点的后继(中序遍历的序列中,被删节点的后一个节点)。
如上图这样的二叉搜索树,如果我想删除节点 11,那么我就可以从该节点的左子树中深度最大的右子节点,也就是前驱节点 10;或是从右子树中找到深度最大的左子节点,也就是后继节点 13。将它们其中 1 个放到节点 11 的位置。我们以查找后继节点为例,首先定义寻找后继节点的方法 getSuccessorNode
:
// 寻找后继节点
getSuccessorNode(removeNode) {
let successor = removeNode,
cur = removeNode.right,
sucParent = removeNode
while (cur) {
sucParent = successor
successor = cur
cur = cur.left
}
if (removeNode.right !== successor) {
sucParent.left = successor.right
successor.right = removeNode.right
}
return successor
}
定义变量 successor
,用于保存后继节点,初始时让它等于需要被删除的节点(作为参数传入);定义变量 cur
,用于保存 while 循环当前遍历到的节点,初始值为需要被删除的节点的右子节点。只要 cur
还有值,就让 successor
指向 cur
,cur
则指向其左子节点。直至 cur
为 null 时,说明此时的 successor
就是我们要找的后继节点。至于变量 sucParent
,则用于保存后继节点的父节点,当被删除节点的右节点不恰好等于后继节点时,我们需要让 sucParent
的左子节点直接指向后继节点的右子节点(对应上图的情况就是让节点 15 直接指向节点 14),这样才相当于把后继节点(13)从原来的位置删除了。
找到了替代被删节点的后继节点后,接下去做的事就比较简单了,无非就是让被删除节点的父节点指向后继节点,让后继节点的左子节点指向原本被删除节点的左子节点。
remove(key) {
// ...
// 3.要删除的节点有 2 个子节点
else {
// 先找到该节点,这里我们寻找该节点的后继节点
let successor = this.getSuccessorNode(current)
// 先判断要删除的节点是否为根节点
if (current === this.root) {
this.root = successor
} else {
isLeft ? (parent.left = successor) : (parent.right = successor)
}
successor.left = current.left
// 下面是错误代码
// if (current.right !== successor) {
// successor.right = current.right
// }
}
}
注意:上面第 14 行至 16 行注释部分,是错误代码,我原本在 getSuccessorNode()
方法中没有对后继节点的右子节点进行处理,直接通过 successor.right = current.right
将被删除节点的右子节点指向后继节点。这样做的问题在于没有将后继节点从原来的位置移开,拿上面的图为例也就是节点 15 依旧指向的是节点 13。