JS 数据结构 —— 二叉搜索树(下篇)

1,295 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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
  }
  // 如果找到了,则分几种情况处理
  // ...
}

删除节点为叶节点(没有子节点)

如果要删除的节点为叶节点,也就是没有子节点的情况。
2022-02-04_234255.png
我们只需将其父节点的左子节点或右子节点赋值为 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)
  1. 该节点是父节点的左子节点(isLefttrue
    2022-02-04_234541.png
  2. 该节点是父节点的右子节点(isLeftfalse
    2022-02-04_234633.png
  • 被删除节点只有右子节点(current.left 为 null)
  1. 该节点是父节点的左子节点
    2022-02-04_234753.png
  2. 该节点是父节点的右子节点
    2022-02-04_234815.png
    注意,还有一种特殊情况,即要删除的节点为根节点,此时 parentnull,如果根节点只有左子节点,则如下图,只需要将节点 4 作为根节点即可。
    2022-02-04_234928.png
    如果根节点只有右子节点则是将根节点重新赋值为 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 值都小于被删节点的,那么最接近的就应该是左子树中最大的那个节点,称为被删节点的前驱(中序遍历的序列中,被删节点的前一个节点);如果是在子树中找,那么就应该找右子树中最小的那个节点,称为被删节点的后继(中序遍历的序列中,被删节点的后一个节点)。
2022-02-04_235142.png
如上图这样的二叉搜索树,如果我想删除节点 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 指向 curcur 则指向其左子节点。直至 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。

感谢.gif 点赞.png