JavaScript数据结构与算法 - 树

1. 树数据结构

  • 树是一种分层数据的抽象模型
  • 树是一种非顺序的数据结构
  • 树对于存储需要快速查找的数据非常有用

常见例子:如公司的组织架构图

树结构:
在这里插入图片描述


2. 二叉树和二叉搜索树

二叉树: 只能有左右两个子节点

二叉搜索树: 二叉树的一种,但是只允许在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大的值(如上面的图)


2.1 创建BinarySearchTree类

创建Node类来表示二叉搜索树中的每个节点:

class Node {
    constructor(key) {
        // 节点值,就是下图的键
        this.key = key;
        // 左侧子节点
        this.left = null;
        // 右侧子节点
        this.right = null;
    }
    toString() {
	   	return `${this.key}`;
	}
}

组织方式:
在这里插入图片描述


声明BinarySearchTree类的基本结构:


  • 这里需要先引入Compare和defaultCompare方法;
const Compare = {
  LESS_THAN: -1,
  BIGGER_THAN: 1,
  EQUALS: 0
};
function defaultCompare(a, b) {
  if (a === b) {
    return Compare.EQUALS;
  }
  return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
  • 声明BinarySearchTree类的基本结构:
class BinarySearchTree {
    constructor(compareFn = defaultCompare) {
        // 用来比较节点值
        this.compareFn = compareFn;
        // Node类型的根节点
        this.root = null;
    }
}

常用方法:

  • insert(key):向树中插入一个新的键
  • search(key):在树中查找一个键。如果节点存在,则返回true;如果不存在,则返回false
  • inOrderTraverse():通过中序遍历方式遍历所有节点
  • preOrderTraverse():通过先序遍历方式遍历所有节点
  • postOrderTraverse():通过后序遍历方式遍历所有节点
  • min():返回树中最小的值/键
  • max():返回树中最大的值/键
  • remove(key):从树中移除某个键

3.2 向二叉搜索树中插入一个键

  1. 向树插入一个新键的算法的第一部分
insert(key) {
    // 验证是否为特殊情况,即要插入的节点是不是第一个节点
    if (this.root === null) {
        // 创建一个Node类的实例并将它赋值给root属性来讲root指向新节点
        this.root = new Node(key);
    }
    // 将节点添加到根节点以外的位置,需要借助辅助方法
    else {
        this.insertNode(this.root, key);
    }
}
  1. 在节点添加到根节点以外的位置的辅助方法
insertNode(node, key) {
    // 如果新节点的键小于当前节点的键,需要检查当前节点的左侧子节点
    if (this.compareFn(key, node.key) === Compare.LESS_THAN) {
        // 如果没有左侧子节点,就插入新节点
        if (node.left == null) {
            node.left = new Node(key);
        }
        // 如果有左侧子节点,就递归调用insertNode方法,继续找到树的下一层
        else {
            this.insertNode(node.left, key);
        }
    }
    // 如果新节点的键大于当前节点的键
    else {
        // 如果没有右侧子节点,就插入新节点
        if (node.right == null) {
            node.right = new Node(key);
        }
        // 如果有右侧子节点,就递归调用insertNode方法,继续找到树的下一层
        else {
            this.insertNode(node.right, key);
        }
    }
}

执行下面代码:

const tree = new BinarySearchTree();
tree.insert(11);
// 插入元素
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);

在这里插入图片描述
如果插入一个新元素6

tree.insert(6);

在这里插入图片描述


3. 树的遍历

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

3.1 中序遍历

中序遍历是一种以上行顺序访问BST所有节点的遍历方式,也就是以从最小到最大的顺序访问所有节点。

inOrderTraverse(callback) {
    // 接收一个节点和对应的回调函数作为参数
    this.inOrderTraverseNode(this.root, callback);
}

inOrderTraverseNode(node, callback) {
    // 先检查以参数形式传入的节点是否为null,这是停止递归继续执行的判断条件
    if (node != null) {
        // 调用相同的函数来访问左侧子节点
        this.inOrderTraverseNode(node.left, callback);
        // 对根节点进行操作
        callback(node.key);
        // 再访问右侧子节点
        this.inOrderTraverseNode(node.right, callback);
    }
}

执行方法:

const printNode = (value) => console.log(value);
tree.inOrderTraverse(printNode);

输出结果:
在这里插入图片描述
访问路径图:
在这里插入图片描述


3.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);
    }
}

输出结果:
在这里插入图片描述
在这里插入图片描述


3.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);
    }
}

输出结果:
在这里插入图片描述
在这里插入图片描述


4. 搜索树中的值

4.1 搜索最小值和最大值

在这里插入图片描述

  • 搜索最小值:
min() {
    // 在调用minNode方法的时候传入树的根节点
    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;
}

因此,对于寻找最小值,总是沿着树的左边;而对于寻找最大值,总是沿着树的右边。


4.2 搜索一个特定的值

search(key) {
    return this.searchNode(this.root, key);
};
// 用来寻找一棵树或其任意子树中的一个特定的值
searchNode(node, key) {
    // 检验传入的node是否合法
    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;
    }
}

测试方法:

console.log(tree.search(1) ? 'found' : 'not found'); // not found
console.log(tree.search(8) ? 'found' : 'not found'); // found

4.3 移除一个元素

remove(key) {
    return this.removeNode(this.root, key);
}
removeNode(node, key) {
    // 如果正在检测的节点为null,说明键不存在于树中
    if (node == null) {
        return false;
    }
    // 如果要找的键比当前节点的值小,就沿树的左边找下一个节点
    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;
    }
}

第一种情况:
在这里插入图片描述
第二种情况:
在这里插入图片描述
第三种情况:
在这里插入图片描述


5. 自平衡树

问题:BST存在一个问题,取决于你添加的节点数,树的一条边可能会非常深;也就是说,树的一条分支会有很多层,而其他的分支却只有几层。
在这里插入图片描述
解决:尽可能尝试转换为完全树(Adelson-Velskii-Landi)是一种自平衡二叉搜索树,意思是任何一个节点左右两侧子树的高度之差最多为1。


5.1 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类继承。


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

节点的高度是从节点到其任意子节点的边的最大值
在这里插入图片描述
计算节点高度:

getNodeHeight(node) {
    if (node == null) {
        return -1;
    }
    // 对每个节点计算右子树高度和左子树高度之间的差值,如果结果不是0,1或-1这三个值之一,则需要AVL树
    return Math.max(
        this.getNodeHeight(node.left), this.getNodeHeight(node.right)
    ) + 1;
}

在这里插入图片描述
遵循计算一个节点的平衡因子并返回其值:

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;
    }
}

为了避免直接在代码中处理平衡因子的数值,创建一个用来作为计数器的JavaScript常量:

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

2 平衡操作 - AVL旋转

在对AVL树添加或移除节点后,需要计算节点的高度并验证树是否需要进行平衡。

向AVL树插入节点时,可以执行单旋转或双旋转两种平衡操作。

有以下四种场景:

  • 左 - 左(LL): 向右的单旋转
    这种情况出现于节点的左侧子节点的高度大于右侧子节点的高度时,并且左侧子节点也是平衡或左侧较重的。
    在这里插入图片描述
rotationLL(node) {
    // 与平衡操作相关节点有X,Y和Z,将节点X置于节点Y(平衡因子为+2)所在的位置
    // 节点X的左子树保持不变
    const tmp = node.left;
    // 将节点Y的左子节点置为节点X的右子节点Z
    node.left = tmp.right;
    // 将节点X的右子节点置为节点Y
    tmp.right = node;
    return tmp;
}

在这里插入图片描述


  • 右 - 右(RR): 向左的单旋转
    右-右的情况和左-左的情况相反。它出现于右侧子节点的高度大于左侧子节点的高度,并且右侧子节点也是平衡或右侧较重的。
    在这里插入图片描述
rotationRR(node) {
    // 与平衡操作相关的节点有三个(X、Y、Z),将节点X置于节点Y(平衡因子为-2)所在的位置
    // 节点X的右子树保持不变
    const tmp = node.right;
    // 将节点Y的右子节点置为节点X的左子节点Z
    node.right = tmp.left;
    // 将节点X的左子节点置为节点Y
    tmp.left = node;
    return tmp;
}

在这里插入图片描述


  • 左 - 右(LR): 向右的双旋转(先LL旋转,再RR旋转)
    这种情况出现于左侧子节点的高度大于右侧子节点的高度,并且左侧子节点右侧较重。
    在这里插入图片描述
rotationLR(node) {
    node.left = this.rotationRR(node.left);
    return this.rotationLL(node);
}

在这里插入图片描述


  • 右 - 左(RL): 向左的双旋转(先RR旋转,再LL旋转)
    右-左的情况和左-右的情况相反。这种情况出现于右侧子节点的高度大于左侧子节点的高度,并且右侧子节点左侧较重。

在这里插入图片描述

rotationRL(node) {
    node.right = this.rotationLL(node.right);
    return this.rotationRR(node);
}

在这里插入图片描述


3 向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) {
            // 如果是,进行LL旋转
            node = this.rotationLL(node);
        } else {
            // 否则,进行LR旋转
            return this.rotationLR(node);
        }
    }
    // 如果在右侧子树插入节点后树不平衡
    if (balanceFactor === BalanceFactor.UNBALANCED_RIGHT) {
        // 比较是否插入的键小于右侧子节点的键
        if (this.compareFn(key, node.right.key) === Compare.BIGGER_THAN) {
            // 如果是,进行RR旋转
            node = this.rotationRR(node);
        } else {
            // 否则,进行RL旋转
            return this.rotationRL(node);
        }
    }
    return node;
}

4 从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;
}

5.2 红黑树

红黑树也是一种自平衡树。

  • 进行多次插入和删除,选择红黑树比较好
  • 插入和删除频率较低,选择AVL树比较好

红黑树的每个节点遵循以下规则:

  • 每个节点非红即黑
  • 树的根节点为黑
  • 所有叶节点(用NULL引用表示的节点)为黑
  • 如果一个节点是红的,那么它的两个子节点都是黑的
  • 不能有两个相邻的红节点,一个节点不能有红的父节点或子节点
  • 从给定节点到它的后代节点(NULL叶节点)的所有路径包含相同数量的黑节点

1. 创建RedBlackTree类

红黑树也是二叉搜索树,可以扩展二叉搜索树类并重写红黑树属性需要的方法。

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

2. 向红黑树中插入节点

向红黑树插入了新的节点:

insert(key: T) {
    // 如果树是空的
    if (this.root == null) {
        // 创建一个红黑树节点
        this.root = new RedBlackNode(key);
        // 将根节点颜色设为黑色
        this.root.color = Color.BLACK;
    } else {
        // 像二叉搜索树一样在正确的位置插入节点
        const newNode = this.insertNode(this.node, key);
        // insertNode方法需要返回新插入的节点,这样可以验证在插入后,红黑树的规则是否得到了满足
        this.fixTreeProperties(newNode);
    }
}

对红黑树来说,节点和之前比起来需要一些额外的属性:节点的颜色和指向父节点的引用:

class RedBlackNode extends Node {
    constructor(key) {
        super(key);
        this.key = key;
        // 节点的颜色
        this.color = Colors.RED;
        // 指向父节点的引用
        this.parent = null;
    }
    isRed() {
        return this.color === Colors.RED;
    }
}

重写insertNode方法:

insertNode(node, key) {
    if (this.compareFn(key, node.key) === Color.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);
    }
}

3. 插入节点后验证红黑树属性

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

在向树中插入节点后,新节点将会是红色。这不会影响黑色节点数量的规则,但需要改变父节点、祖父节点和叔节点。

在这里插入图片描述

fixTreeProperties(node) {
    while (node && node.parent && node.parent.color.isRed() && // 验证它的父节点是否是红色
        node.color !== Colors.BLACK // 验证这个节点是否为黑色
    ) {
        // 保存父节点的引用
        let parent = node.parent;
        // 保存祖父节点的引用
        const grandParent = parent.parent;
        // 情形1:父节点是左侧子节点
        if (grandParent && grandParent.left === parent) { // 要验证父节点是左侧子节点
            const uncle = grandParent.right;
            // 叔节点也是红色,只需要重新填色
            if (uncle && uncle.color === Colors.RED) {
                // 改变祖父节点、父节点和叔节点的颜色,并且将当前节点的引用指向祖父节点
                grandParent.color = Colors.RED;
                parent.color = Colors.BLACK;
                uncle.color = Colors.BLACK;
                node = grandParent;
            } else {
                // 节点是右侧子节点,左旋转
                if (node === parent.right) {
                    this.rotationRR(parent);
                    node = parent;
                    parent = node.parent;
                }
                // 节点是左侧子节点,右旋转
                this.rotationLL(grandParent);
                parent.color = Colors.BLACK;
                grandParent.color = Colors.RED;
                node = parent;
            }
        }
        // 父节点是右侧子节点
        else {
            const uncle = grandParent.left;
            // 叔节点是红色,需要重新填色
            if (uncle && uncle.color === Colors.RED) {
                grandParent.color = Colors.RED;
                parent.color = Colors.BLACK;
                uncle.color = Colors.BLACK;
                node = grandParent;
            } else {
                // 节点是左侧子节点,右旋转
                if (node === parent.left) {
                    this.rotationLL(parent);
                    node = parent;
                    parent = node.parent;
                }
                // 节点是右侧子节点,左旋转
                this.rotationRR(grandParent);
                parent.color = Colors.BLACK;
                grandParent.color = Colors.RED;
                node = parent;
            }
        }
    }
    // 为了保证根节点的颜色始终是黑色,在代码最后设置根节点的颜色
    this.root.color = Colors.BLACK;
}

四种旋转情况如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


4. 红黑树旋转

在插入算法中,我们只使用了右-右旋转和左-左旋转。逻辑和AVL树是一样,但由于保存了父节点的引用,需要将引用更新为旋转后的新父节点。

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 = tmp.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;
}
  • 21
    点赞
  • 76
    收藏
    觉得还不错? 一键收藏
  • 25
    评论
评论 25
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值