JavaScript 数据结构(7)- 树

110 阅读15分钟

1669725719225.jpg

学习代码 git 仓库地址:gitee.com/zhangning18…

十、树

之前已经学过一些顺序结构,第一个非顺序数据结构是散列表。这里学习另一种非顺序数据结构--树,它对于存储需要快速查找的数据非常有用。

10.1 树数据结构

树是一种分层数据的抽象模型。现实生活中最常见的树的例子是家谱,或是公司的组织架构图等

10.2 树的相关术语

一个树结构包含一系列存在父子关系的节点。每个节点都有一个父节点(除了顶部的第一个节点)以及零个或多个子节点:

树顶部的节点叫做根节点。他没有父节点。树中的每一个元素都叫做节点,节点分为内部节点外部节点。至少有一个子节点的节点称为内部节点。没有子元素的节点称为外部节点或叶节点

有关树的另一个术语是子树。子树由节点和他的后代构成。

节点的一个属性是深度,结点的深度取决于他的祖先节点的数量。比如,节点 3 有 3 个祖先节点,他的深度为 3。

树的高度取决于所有节点深度的最大值。

10.3 二叉树和二叉搜索树

二叉树 中的节点最多只能有两个子节点:一个是左侧子节点,一个是右侧子节点。这个定义有助于写出高效的在树中插入、查找和删除节点的算法。二叉树在计算机科学中的应用非常广泛。

二叉搜索树(BST)是二叉树的一种,但是只允许你在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大的值。上图就是一个二叉搜索树

下面用代码实现一个二叉搜索树

10.3.1 创建 二叉搜索树 BinarySearchTree 类

先创建节点类

export class Node {
  constructor(key) {
    this.key = key;// 节点值
    this.left = null;// 左侧子节点的引用
    this.right = null;// 右侧子节点的引用
  }
}

和链表一样,将通过指针(引用)来表示节点之间的关系(树相关的术语称其为边)。在双向链表中,每个节点包含两个指针,一个指向下一个一个指向上一个。这里树是同样的方式,但是一个指向左侧子节点,一个指向右侧子节点。

有个小细节就是节点本身称之为节点或项,这里称之为键。键是树相关的术语中树节点的称呼。

// 二叉树类
export default class BinarySearchTree {
  constructor(compareFn = defaultCompare) {
    this.compareFn = compareFn; // 用来比较结点的值
    this.root = null; // Node 类型的根节点
  }

  // 二叉树中插入一个键
  insert(key) {
    if (this.root == null) {
      this.root = new Node(key);
    } else {
      this.insertNode(this.root, key);
    }
  }

  // 插入节点中
  insertNode(node, key) {
    // 判断要插入左边还有右边
    if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
      if (node.left == null) {
        node.left = new Node(key);
      } else {
        this.insertNode(node.left, key);
      }
    } else {
      if (node.right == null) {
        node.right = new Node(key);
      } else {
        this.insertNode(node.right, key);
      }
    }
  }
}

const tree = new BinarySearchTree();
tree.insert(11);
tree.insert(1);

console.log(tree);

10.4 树的遍历

遍历树是指访问树的每个节点并对它们进行某种操作的过程。但是我们应该怎么去做?应该从树的顶端还是底端开始?从左还是从右开始?

访问树的所有节点有三种方式:中序、先序和后序。

10.4.1 中序遍历

中序遍历是一种从上行顺序访问 BST 所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。中序遍历的一种应用就是对树进行排序操作。

  // 中序遍历
  inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback);
  }

  // 查找 节点
  inOrderTraverseNode(node, callback) {
    // 如果 node 为 null,这就是停止递归继续执行的判断条件,即递归算法的基线条件
    if (node != null) {
      this.inOrderTraverseNode(node.left, callback);
      callback(node.key);
      this.inOrderTraverseNode(node.right, callback);
    }
  }

10.4.2 先序遍历

先序遍历是以优先于后代节点的顺序访问每个节点的。先序遍历的一种应用是打印一个结构化的文档。

  // 先序遍历
  preOrderTraverse(callback) {
    this.preOrderTraverseNode(this.root, callback);
  }

  // 先序遍历查找结点
  preOrderTraverseNode(node, callback) {
    if (node != null) {
      callback(node.key);
      this.preOrderTraverseNode(node.left, callback);
      this.preOrderTraverseNode(node.right, callback);
    }
  }

10.4.3 后序遍历

后序遍历是先访问节点的后代节点,再访问节点本身。

  // 后序遍历
  postOrderTraverse(callback) {
    this.postOrderTraverseNode(this.root, callback);
  }

  // 后序遍历查找结点
  postOrderTraverseNode(node, callback) {
    if (node != null) {
      this.postOrderTraverseNode(node.left, callback);
      this.postOrderTraverseNode(node.right, callback);
      callback(node.key);
    }
  }

10.5 搜索树中的值

搜索最小值

搜索最大值

搜索特定的值

10.5.1 搜索最小的值和最大值

  // 搜索最小的值
  min() {
    return this.minNode(this.root);
  }

  minNode(node) {
    let current = node;
    while (current != null && current.left != null) {
      current = current.left;
    }
    return current;
  }

  // 最大值
  max() {
    return this.maxNode(this.root);
  }

  maxNode(node) {
    let current = node;
    while (current != null && current.right != null) {
      current = current.right;
    }
    return current;
  }

10.5.2 搜索一个特定的值

  // 搜索特定值
  search(key) {
    return this.searchNode(this.root, key);
  }

  searchNode(node, key) {
    if (node == null) {
      return false;
    }
    if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
      return this.searchNode(node.left, key);
    } else if (
      this.compareFn(key, node.key) === Compare.BIGGER_THAN
    ) {
      return this.searchNode(node.right, key);
    } else {
      return true;
    }
  }

10.5.3 移除一个节点

该方法比较复杂,可以说是在学习数据结构里面最复杂的一个方法。复杂之处在于我们要处理不同的运行场景,同样是通过递归实现的。

// 移除方法
  remove(key) {
    // 判断是否存在节点
    if (!this.search(key)) {
      return '不存在该节点';
    }
    this.root = this.removeNode(this.root, key);
  }

  removeNode(node, key) {
    if (node == null) {
      return null;
    }
    if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
      node.left = this.removeNode(node.left, key);
      return node;
    } else if (
      this.compareFn(key, node.key) === Compare.BIGGER_THAN
    ) {
      node.right = this.removeNode(node.right, key);
      return node;
    } else {
      // 当前节点就是最后一个节点,没有左节点也没有右节点
      if (node.left == null && node.right == null) {
        node = null;
        return node;
      }
      // 没有左节点, 有右节点
      if (node.left == null) {
        node = node.right;
        return node;
        // 没有右节点,有左节点
      } else if (node.right == null) {
        node = node.left;
        return node;
      }
      // 有左节点也有右节点
      const aux = this.minNode(node.right);
      // 则把右侧节点中的最小节点放在当前位置
      node.key = aux.key;
      // 然后移除右侧节点的那个最小节点
      node.right = this.removeNode(node.right, aux.key);
      return node;
    }
  }

10.6 平衡树

BST 存在一个问题:取决于你添加的节点数,树的一条边可能会非常深;也就是说,树的一条分支会有很多层,而其它的分支却只有几层

这会在需要某条边上添加、移除、搜索某个节点时引起一些性能问题。为了解决这个问题,有一种树叫做 Adelson-Velskii-Landi 树(AVL树)。AVL 树是一种自平衡二叉搜索树,意思是任何一个节点左右两侧子树的高度之差最多为 1.

10.6.1 Adelson-Velskii-Landi 树(AVL 树)

AVL 树是一种自平衡树。添加或移除节点时,AVL 树会尝试保持自平衡。任意一个节点(不论深度)的左子树和右子树高度最多相差1.添加或移除节点时,AVL 树会尽可能尝试转换为完全树。

创建 AVLTree 类

class AVLTree extends BinarySearchTree {
  constructor(compareFn = defaultCompare) {
    super(compareFn);
    this.compareFn = compareFn;
    this.root = null;
  }
}

AVL树是一个 BST,我们可以扩展写的 BST 类,只需要覆盖用来维持 AVL 树平衡的方法,也就是 insert、insertNode 和 removeNode 方法。所有的 BST 方法都会被 AVLTree 类继承。

在 AVL 树中插入或移除节点和 BST 完全相同。然而,AVL 树的不同之处在于我们需要检验它的平衡因子,如果有需要,会将其逻辑应用于树的自平衡。

我们将会学习怎样创建 remove 和 insert 方法,但是首先需要学习 AVL 树的术语和他的旋转操作。

  1. 节点的高度和平衡因子

节点的高度是从节点到其任意子节点的边的最大值。

计算一个节点的高度

  // 计算一个节点的高度
  getNodeHeight(node) {
    if (node == null) {
      return -1;
    }
    return Math.max(
      this.getNodeHeight(node.left), this.getNodeHeight(node.right)
    ) + 1;
  }

在 AVL 树中,需要对每个节点计算右子树高度(HR)和左子树(HL)高度之间的差值,该值(HR - HL)应为0、1 或 -1.如果结果不是这三个值之一,则需要平衡该 AVL 树。这就是平衡因子的概念。

const BalanceFactor = {
  UNBALANCED_RIGHT: 1,
  SLIGHTLY_UNBALANCED_RIGHT: 2,
  BALANCED: 3,
  SLIGHTLY_UNBALANCED_LEFT: 4,
  UNBALANCED_LEFT: 5
};  

  // 计算一个节点的平衡因子并返回其值
  getBalanceFactor(node) {
    const heightDifference = this.getNodeHeight(node.left) -
      this.getNodeHeight(node.right);
    switch (heightDifference) {
      case -2:
        return BalanceFactor.UNBALANCED_RIGHT;
      case -1:
        return BalanceFactor.SLIGHTLY_UNBALANCED_RIGHT;
      case 1:
        return BalanceFactor.SLIGHTLY_UNBALANCED_LEFT;
      case 2:
        return BalanceFactor.UNBALANCED_LEFT;
      default:
        return BalanceFactor.BALANCED;
    }
  }
  1. 平衡操作 -- AVL旋转

在 AVL 树添加或移除节点后,要计算节点的高度并验证树是否需要进行平衡。向 AVL 树插入节点时,可以执行单旋转或双旋转两种平衡操作,分别对应四种场景。

  • 左-左(LL):向右的单旋转
  • 右-右(RR):向左的单旋转
  • 左-右(LR):向右的双旋转(先 LL 旋转,再 RR 旋转)
  • 右-左(RL):向左的单旋转(先 RR 旋转,再 LL 旋转)

左-左(LL):向右的单旋转

这种情况出现于节点的左侧子节点的高度大于右侧子节点的高度,并且左侧子节点也是平衡或左侧较重的

看一个比较实际的例子

假设向 AVL 树插入节点 5,这会造成树失衡(节点 50-Y 高度为 +2)(就是左边高度和右边高度差),需要恢复树的平衡。

执行下面操作:

  • 与平衡操作相关的节点有三个(XYX),将节点 X 置于 节点 Y (平衡因子为 +2)所在的位置
  • 节点 X 的左子树保持不变
  • 将节点 Y 的左子节点置为节点 X 的右子节点 Z
  • 将节点 X 的右子节点置为节点 Y

代码表示

  rotationLL(node) {
    const tmp = node.left;
    node.left = tmp.right;
    tmp.right = node;
    return tmp;
  }

右-右(RR):向左的单旋转

这种情况出现于节点的左侧子节点的高度大于右侧子节点的高度,并且左侧子节点也是平衡或左侧较重的

看一个比较实际的例子

假设向 AVL 树插入节点 90,这会造成树失衡(节点 50-Y 高度为 -2)(就是左边高度和右边高度差),需要恢复树的平衡。

执行下面操作:

  • 与平衡操作相关的节点有三个(XYX),将节点 X 置于 节点 Y (平衡因子为 -2)所在的位置
  • 节点 X 的右子树保持不变
  • 将节点 Y 的右子节点置为节点 X 的左子节点 Z
  • 将节点 X 的左子节点置为节点 Y

代码表示

  // 右-右(RR):向左的单旋转
  rotationRR(node) {
    const tmp = node.right;
    node.right = tmp.left;
    tmp.left = node;
    return tmp;
  }

左-右(LR):左-右(LR):向右的双旋转(先 LL 旋转,再 RR 旋转)

这种情况出现于节点的左侧子节点的高度大于右侧子节点的高度,并且左侧子节点右侧较重.在这种情况下,我们可以对左侧子节点进行左旋转来修复,这样会形成左-左的情况,然后再对不平衡的节点进行一个右旋转来修复

看一个比较实际的例子

假设向 AVL 树插入节点 75,这会造成树失衡(节点 70-Y 高度为 -2),需要恢复树的平衡。

执行下面操作:

  • 将节点 X 置于节点 Y(平衡因子为 -2) 所在的位置
  • 将节点 Z 的左子节点置为节点 X 的右子节点
  • 将节点 Y 的右子节点置为节点 X 的左子节点
  • 将节点 X 的右子节点置为节点 Y
  • 将节点 X 的左子节点置为节点 Z

基本上,就是先做一次 LL 旋转,再做一次 RR 旋转

代码表示

  // 左-右(LR):左-右(LR):向右的双旋转(先 LL 旋转,再 RR 旋转)
  rotationLR(node) {
    node.left = this.rotationRR(node.left);
    return this.rotationLL(node);
  }

右-左(RL):向左的双旋转(先 RR 旋转,再 LL 旋转)

右左的情况和左右的情况相反.这种情况出现于右侧子节点的高度大于左侧子节点的高度,并且右侧子节点左侧较重.在这种情况下,我们可以对右侧子节点进行右旋转来修复,这样会形成右-右的情况,然后再对不平衡的节点进行一个左旋转来修复

看一个比较实际的例子

假设向 AVL 树插入节点 35,这会造成树失衡(节点 50-Y 高度为 +2),需要恢复树的平衡。

执行下面操作:

  • 将节点 X 置于节点 Y(平衡因子为 +2) 所在的位置
  • 将节点 Y 的左子节点置为节点 X 的右子节点
  • 将节点 Z 的右子节点置为节点 X 的左子节点
  • 将节点 X 的左子节点置为节点 Y
  • 将节点 X 的右子节点置为节点 Z

基本上,就是先做一次 RR 旋转,再做一次 LL 旋转

代码表示

  // 右-左(RL):向左的双旋转(先 RR 旋转,再 LL 旋转)
  rotationRL(node) {
    node.right = this.rotationLL(node.right);
    return this.rotationRR(node);
  }
  1. 向 AVL 树插入节点

向 AVL 树插入节点和在 BST 中是一样的.处理插入节点外,还要验证插入后树是否还是平衡的,如果不是就要进行必要的旋转操作.

  // 插入节点
  insert(key) {
    this.root = this.insertNode(this.root, key);
  }

  insertNode(node, key) {
    // 和 BST 树中插入节点一样
    if (node == null) {
      return new Node(key);
    } else if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
      node.left = this.insertNode(node.left, key);
    } else if (this.compareFn(key, node.key) === Compare.BIGGER_THAN) {
      node.right = this.insertNode(node.right, key);
    } else {
      return node; //重复的键
    }
    // 如果需要将树进行平衡操作
    const balanceFactor = this.getBalanceFactor(node);
    if (balanceFactor === BalanceFactor.UNBALANCED_LEFT) {
      if (this.compareFn(key, node.left.key) === Compare.LESS_THAN) {
        node = this.rotationLL(node);
      } else {
        return this.rotationLR(node);
      }
    }
    if (balanceFactor === BalanceFactor.UNBALANCED_RIGHT) {
      if (
        this.compareFn(key, node.right.key) === Compare.BIGGER_THAN
      ) {
        node = this.rotationRR(node);
      } else {
        return this.rotationRL(node);
      }
      return node;
    }
  }

在向 AVL 树插入节点后,我们需要检查树是否需要进行平衡,因此要使用递归计算以每个插入树的节点为根节点的平衡因子,然后对每种情况应用正确的旋转

  1. 从 AVL 树中移除节点

从 AVL 树移除节点和在 BST 中是一样的。除了移除节点外,还要验证移除后树是否还是平衡的,如果不是就要进行必要的旋转操作。

  // 移除一个节点
  removeNode(node, key) {
    node = super.removeNode(node, key);
    if (node == null) {
      return node; // 不需要进行平衡
    }
    // 检查树是否平衡
    const balanceFactor = this.getBalanceFactor(node);
    if (balanceFactor === BalanceFactor.UNBALANCED_LEFT) {
      const balanceFactorLeft = this.getBalanceFactor(node.left);
      if (
        balanceFactorLeft === BalanceFactor.BALANCED ||
        balanceFactorLeft === BalanceFactor.SLIGHTLY_UNBALANCED_LEFT
      ) {
        return this.rotationLL(node);
      }
      if (
        balanceFactorLeft === BalanceFactor.SLIGHTLY_UNBALANCED_RIGHT
      ) {
        return this.rotationLR(node.left);
      }
    }
    if (balanceFactor === BalanceFactor.UNBALANCED_RIGHT) {
      const balanceFactorRight = this.getBalanceFactor(node.right);
      if (
        balanceFactorRight === BalanceFactor.BALANCED ||
        balanceFactorRight === BalanceFactor.SLIGHTLY_UNBALANCED_RIGHT
      ) {
        return this.rotationRR(node);
      }
      if (
        balanceFactorRight === BalanceFactor.SLIGHTLY_UNBALANCED_LEFT
      ) {
        return this.rotationRL(node.right);
      }
      return node;
    }
  }

AVLTree 类是 BinarySearchTree 类的子类,我们也可以使用 BST 的 removeNode 方法来从 AVL 树中移除节点。从 AVL 树中移除节点后,需要检查树是否需要进行平衡,所以使用递归计算以每个移除节点为根的节点的平衡因子,然后需要对每种情况应用正确的旋转

10.6.2 红黑树

和 AVL 树一样,红黑树也是一个自平衡二叉搜索树。我们学习了对 AVL 树插入和移除节点可能会造成旋转,所以需要一个包含多次插入和删除的自平衡树,红黑树是比较好的。如果插入和删除频率较低,那么 AVL 树比红黑树更好。

在红黑树中,每个节点都遵循以下规则:

  1. 顾名思义,每个节点不是红的就是黑的
  2. 树的根节点是黑的
  3. 所有页节点都是黑的(用 NULL 引用表示的节点)
  4. 如果一个节点是红的,那么他的两个子节点都是黑的
  5. 不能有两个相邻的红节点,一个红节点不能有红的父节点或子节点
  6. 从给定的节点到他的后代节点(NULL 叶节点)的所有路径包含相同数量的黑色节点
  1. 创建 RedBlackTree 红黑树类
/*
* @author: zhangning
* @date: 2022/5/5 23:23
* @Description: 红黑树节点数据
**/
import {Node} from './Node.js';
import {Colors} from './util.js';

export class RedBlackNode extends Node {
  constructor(key) {
    super(key);
    this.key = key;
    this.color = Colors.RED;
    this.parent = null;
  }

  isRed() {
    return this.color === Colors.RED;
  }
}

红黑树类

// 红黑树类
class RedBlackTree extends BinarySearchTree {
  constructor(compareFn = defaultCompare) {
    super(compareFn);
    this.compareFn = compareFn;
    this.root = null;
  }
}

红黑树也是二叉搜索树,可以扩展我们创建的二叉搜索树类并重写红黑树属性所需要的那些方法。

  1. 向红黑树中插入节点

向红黑树中插入节点和在二叉搜索树中是一样的。除了插入的代码,还要在插入后给节点应用一种颜色,并且验证树是否满足红黑树的条件以及是否还是自平衡的。

  // 插入节点
  insert(key) {
    if (this.root == null) {
      this.root = new RedBlackNode(key);
      this.root.color = Colors.BLACK;
    } else {
      const newNode = this.insertNode(this.root, key);
      // 验证红黑树是否得到满足
      this.fixTreeProperties(newNode);
    }
  }

  // 重写插入方法
  insertNode(node, key) {
    if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
      if (node.left == null) {
        node.left = new RedBlackNode(key);
        node.left.parent = node;
        return node.left;
      } else {
        return this.insertNode(node.left, key);
      }
    } else if (node.right == null) {
      node.right = new RedBlackNode(key);
      node.right.parent = node;
      return node.right;
    } else {
      return this.insertNode(node.right, key);
    }
  }
  1. 在插入节点后验证红黑树属性

要验证红黑树是否还是平衡的以及满足它的所有要求,需要使用两个概念:重新填色和旋转

在向树中插入节点后,新节点将会是红色。这不会影响黑色节点数量的规则(规则6),但会影响规则5:两个后代红节点不能共存。如果插入节点的父节点是黑色,那没有问题。但是如果插入节点的父节点是红色,那么违反规则5. 解决这个问题,需要改变父节点、祖父节点和叔节点(因为我们同样改变了父节点的颜色)。

看代码实现

  // 插入节点后验证红黑树属性
  fixTreeProperties(node) {
    // 验证节点的父节点是否是红色、节点是否不是黑色
    while (node && node.parent && node.parent.color.isRed()
    && node.color !== Colors.BLACK) {
      // 为了代码可读性,保存父节点和祖父节点的引用
      let parent = node.parent;
      const grandParent = parent.parent;

      // 情形A:父节点是左侧子节点
      if (grandParent && grandParent.left === parent) {
        const uncle = grandParent.right;
        //情形1A:叔节点也是红色--只需要重新填色
        if (uncle && uncle.color === Colors.RED) {
          grandParent.color = Colors.RED;
          parent.color = Colors.BLACK;
          uncle.color = Colors.BLACK;
          node = grandParent;
        } else {
          // 在节点的叔节点颜色为黑时,也就是说仅仅重新填色是不够的,树是不平衡的,需要进行旋转操作
          // 情形2A:节点是右侧子节点--左旋转
          if (node === parent.right) {
            this.rotationRR(parent);
            node = parent;
            parent = node.parent;
          }
          // 情形3A:节点是左侧子节点--右旋转
          this.rotationLL(grandParent);
          parent.color = Colors.BLACK;
          grandParent.color = Colors.RED;
          node = parent;
        }
      } else { // 情形B:父节点是右侧子节点
        const uncle = grandParent.left;
        // 情形1B:叔节点是红色--只需要重新填色
        if (uncle && uncle.color === Colors.RED) {
          grandParent.color = Colors.RED;
          parent.color = Colors.BLACK;
          uncle.color = Colors.BLACK;
          node = grandParent;
        } else {
          // 情形2B:节点是左侧子节点--右旋转
          if (node === parent.left) {
            this.rotationLL(parent);
            node = parent;
            parent = node.parent;
          }
          // 情形3B:节点是右侧子节点--左旋转
          this.rotationRR(grandParent);
          parent.color = Colors.BLACK;
          grandParent.color = Colors.RED;
          node = parent;
        }
      }
    }
    // 保证根节点的颜色是黑色
    this.root.color = Colors.BLACK;
  }


  // 左-左旋转(右旋转)
  rotationLL(node) {
    const tmp = node.left;
    node.left = tmp.right;
    if (tmp.right && tmp.right.key) {
      tmp.right.parent = node;
    }
    tmp.parent = node.parent;
    if (!node.parent) {
      this.root = tmp;
    } else {
      if (node === node.parent.left) {
        node.parent.left = tmp;
      } else {
        node.parent.right = tmp;
      }
    }
    tmp.right = node;
    node.parent = tmp;
  }

  // 右-右旋转(左旋转)
  rotationRR(node) {
    const tmp = node.right;
    node.right = tmp.left;
    if (tmp.left && tmp.left.key) {
      tmp.left.parent = node;
    }
    tmp.parent = node.parent;
    if (!node.parent) {
      this.root = tmp;
    } else {
      if (node === node.parent.left) {
        node.parent.left = tmp;
      } else {
        node.parent.right = tmp;
      }
    }
    tmp.left = node;
    node.parent = tmp;
  }

上面的红黑树旋转逻辑和 AVL 树是一样的,但是由于保存了父节点的引用,需要将引用更新为旋转后的新父节点。

情形 2A:

情形3A

情形 2B:

情形3B