实现JavaScript基本数据结构系列---树

219 阅读5分钟

这是我参与11月更文挑战的第6天,活动详情查看:2021最后一次更文挑战

树是一种分层数据的抽象模型。最常见的就是家谱、组织架构等。

基本概念

根结点:位于顶部的节点,他没有父节点。

节点:树中每个元素都是节点,节点又分为内部节点和外部节点,至少有一个子节点叫内部节点,没有子元素的叫外部节点或者叶节点

子树:子树由节点和他的子代构成

二叉树:树的节点最多只能有两个子节点:一个是左节点,一个是右节点。

二叉搜索树:他是二叉树的一种,只允许你在左侧节点存储比父节点小的值,右侧节点比父节点的值

实现二叉搜索树

有概念可知,我们二叉搜索树每个节点都要存储自身值、对左侧节点、右侧节点的引用,那么我们创建的结构类型如下:

class Node {
    constructor(key) {
        this.key = key;
        this.left = null;
        this.right = null;
    }
}

和之前链表一样,我们也会声明一个变量用来存储此数据结构的第一个节点(根结点root),我们也需要一个对比函数来比较节点值。

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

定义好我们所需要的数据结构后,接下来我们就要实现我们所需要的方法了。

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

insert

insert函数作用是像树中插入一个新的键

像树中插入一个键,我们可以区分为两种情况,第一种是首次插入,根结点为空,此时直接赋值;第二种是根结点不为空,那么我们就需要对比值,判断新插入的键应在左边还是右边

insert(key) {
    if (this.root == null) {
        this.root = key;
    } else {
        this.insertNode(this.root, key)
    }
}

当新节点插入根节点其他位置时,对比当前节点值和新节点的值,新节点小,则插入当前节点左侧,当前节点的左节点为空,就赋值,否则递归左节点;右侧也是。

insertNode(node, key) {
    // 值比node的值小,插入左节点,繁殖右节点
    if (this.compareFn(key, node.key) === -1) {
        // 插入左子树
        // 1. 左节点为null,直接赋值,否则递归
        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)
        }
    }
}

inOrderTraverse

inOrderTraverse函数作用是通过中序遍历所有节点。

中序遍历是先访问左节点、当前节点、右节点,也就是从小到大的顺序输出所有节点。中序遍历的一种应用就是对树进行排序操作。inOrderTraverse接受一个callback回调函数作为参数,用来定义我们对遍历到的节点所进行的操作。

inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback)
}

inOrderTraverseNode(node, callback) {
    if (node !== null) {
        // 先左侧节点
        this.inOrderTraverseNode(node.left, callback)
        // 在当前节点
        callback(node.key)
        // 最后右侧节点
        this.inOrderTraverseNode(node.right, callback)
    }
}

preOrderTraverse

preOrderTraverse函数作用是通过先序遍历所有节点。

先序遍历是先访问当前节点、左节点、右节点。先序遍历的一个应用是打印一个结构化的文档

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

postOrderTraverse

postOrderTraverse函数作用是通过后序遍历所有节点。

后序遍历是先访问左节点、右节点、当前节点。后序遍历的一个应用是计算一个目录及其子目录所有文件所占空间大小

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

min()

min函数返回树中最小的值。

由上分析可知,最小值永远在左节点,只需要递归到最左侧节点就好了

min() {
    return this.minNode(this.root)
}

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

max()

max函数返回树中最大的值。

由上分析可知,最大值永远在右节点,只需要递归到最右侧节点就好了

max() {
    return this.maxNode(this.root)
}

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

search

search函数在树中查找一个键,节点存在返回true,否则返回false。

分析一下怎么实现

  1. 如果根结点不存在,返回false,结束
  2. 查找的键小于当前节点,那么递归左子树,等于返回true,大于递归右子树
search(key) {
    return this.searchNode(this.root, key)
}

searchNode(node, key) {
    // 如果根结点不存在,返回false,结束
    if (node == null) return false

    // 两者相等,找到了,结束
    if (node.key === key) return true;

    // 在左边
    if (this.compareFn(key, node.key) === -1) {
        return this.searchNode(node.left, key)
    } else {
        // 再右边
        return this.searchNode(node.right, key)
    } 
}

remove

remove函数用来从树中移除某个的键。

分析一下怎么实现

  1. 移除叶节点,直接删除(节点值变为null)
  2. 移除有子节点的节点
    1. 只有一个子节点,将父节点指向子节点就好了
    2. 左右子节点均存在,找右侧最小的节点,用来替换掉当前节点,然后删除那个节点
remove(key) {
    this.root = this.removeNode(this.root, key)
}

removeNode(node, key) {
    if (node == null) return null;

    if (node.key === key) {
        // 找到那个节点了,需要移除

        // 1. 左右子节点均不存在,直接将当前节点置为 null
        if (node.left == null && node.right == null) {
            node = null;
            return node; 
        }

        // 2. 左右子节点只存在一个
        if (node.left == null) {
            node = node.right;
            return node; 
        } else if (node.right == null) {
            node = node.left;
            return node; 
        }

        // 3. 左右子节点都存在
        const aux = this.minNode(node.right); // 找右侧最小的用来替换当前node节点
        node.key = aux.key;
        node.right = this.removeNode(node.right, aux.key) //删除那个叶节点
        return node;
    }

    // 在左边
    if (this.compareFn(key, node.key) === -1) {
        node.left = this.removeNode(node.left, key);
        return node
    } else {
        // 再右边
        node.right = this.removeNode(node.right, key)
        return node
    } 
}

到此我们实现了一个树结构应该需要的基本方法,其他可自行扩展。我们也对树结构有了清晰的认识,也明白了什么是二叉搜索树。除此之外我们还有AVL树和红黑树等概念,这里只做介绍,详细的大家可以自行查阅。

AVL树:AVL树是一种自平衡树,我们在添加或移除节点时,AVL树会尝试保持自平衡,任意一个节点(不论深度)的左子树和右子树高度最多相差1