为JavaScript初学者设计的二进制搜索树算法

159 阅读5分钟

我最近有机会教高中生如何编码。关于用JavaScript编码的算法,并没有那么多适合初学者的教程,而JavaScript正是他们正在学习的语言。所以我决定做一个。

在这篇文章中,我将尽力解释一些你在编码面试前应该学习的核心算法。

如果你对二叉树的概念不熟悉,我鼓励你去看看维基百科的页面。如果你完全掌握了这些基本算法,你会更容易解决更复杂的问题。

什么是二进制搜索树(BST)?

BST在编码面试中很常见,它是一种树状的数据结构,在最顶端有一个单根。它们是存储数值的好方法,因为它们的有序性允许快速搜索和查找。

与普通的树相比,BST具有以下特性:

  • 每一个左边的孩子都比它的父辈有一个更小的值
  • 每个右边的子节点的数值都比其父节点的数值大
  • 每个节点可以包含0到2个孩子。

下面的图表应该能更清楚地说明问题。

二叉树节点的定义

一棵二进制搜索树

我们通常用Javascript中的以下函数来定义二叉树节点。

 function TreeNode(val, left, right) {
     this.val = val
     this.left = left
     this.right = right
 }

二叉树基本遍历(Inorder, Postorder, Preorder)

首先要知道的是如何循环浏览BST的每个节点。这使我们能够对我们的BST的所有节点执行一个函数。例如,如果我们想在我们的BST中找到一个值x ,我们就需要这些节点。

有三种主要的方法可以做到这一点。幸运的是,它们有共同的主题。

顺序遍历

递归算法是最容易开始使用二叉树顺序遍历的方法。其思路如下:

  • 如果节点是空的,什么都不做--否则,递归调用该节点左侧子节点上的函数。
  • 然后,在遍历了所有的左侧子节点后,对该节点做一些操作。我们当前的节点被保证是最左边的节点。
  • 最后,在node.right上调用该函数。

Inorder算法从左到中再到右遍历了树的节点:

/**
* @param {TreeNode} root
*/
const inorder = (root) => {
    const nodes = []
    if (root) {
        inorder(root.left)
        nodes.push(root.val)
        inorder(root.right)
    }
    return nodes
}
// for our example tree, this returns [1,2,3,4,5,6]

后序遍历

递归算法是启动后序遍历的最简单的方法:

  • 如果节点是空的,什么都不做--否则,递归调用该节点左边子节点上的函数。
  • 当没有更多的左侧子节点时,调用node.right上的函数。
  • 最后,对节点做一些操作。

后序遍历从左到右,再到中间访问树节点:

/**
* @param {TreeNode} root
*/
const postorder = (root) => {
    const nodes = []
    if (root) {
        postorder(root.left)
        postorder(root.right)
        nodes.push(root.val)
    }
    return nodes
}
// for our example tree, this returns [1,3,2,6,5,4]

前序遍历

递归算法是前序遍历的最简单的方法:

  • 如果节点为空,什么都不做--否则,对节点做一些操作。
  • 遍历到节点的左边的子节点并重复。
  • 遍历到节点的右侧子节点并重复。

后序遍历从中间到左边再到右边访问树节点:

/**
* @param {TreeNode} root
*/
const preorder = (root) => {
    const nodes = []
    if (root) {
        nodes.push(root.val)
        preorder(root.left)
        preorder(root.right)
    }
    return nodes
}
// for our example tree, this returns [4,2,1,3,5,6]

什么是有效的二进制搜索树?

一棵有效的二进制搜索树(BST)有所有左边子节点的值小于父节点,所有右边子节点的值大于父节点。

为了验证一棵树是否是有效的二进制搜索树:

  • 定义当前节点可以拥有的最小和最大值
  • 如果一个节点的值不在这些范围内,返回false
  • 递归验证该节点的左侧子节点,最大边界设置为该节点的值
  • 递归验证节点的右侧子节点,最小边界设置为节点的值
/**
* @param {TreeNode} root
*/
const isValidBST = (root) => {
    const helper = (node, min, max) => {
        if (!node) return true
        if (node.val <= min || node.val >= max) return false
        return helper(node.left, min, node.val) && helper(node.right, node.val, max)
    }
    return helper(root, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
}

如何找到二叉树的最大深度

这里,该算法试图找到我们的BST的高度/深度。换句话说,我们在看一个BST包含多少个 "层次":

  • 如果节点为空,我们返回0,因为它没有增加任何深度
  • 否则,我们在当前的深度上加+1(我们穿越了一个层次)。
  • 递归计算节点的子节点的深度,并返回node.left和node.right之间的最大和。
/**
* @param {TreeNode} root
*/
const maxDepth = function(root) {
    const calc = (node) => {
        if (!node) return 0
        return Math.max(1 + calc(node.left), 1 + calc(node.right))
    }
    return calc(root)
};

如何找到两个树节点之间的最低共同祖先

让我们提升一下难度。我们如何找到二叉树中两个树节点之间的共同祖先?让我们看看一些例子:

一棵二进制搜索树

在这棵树上,3和1的最低共同祖先是2,3和2的LCA是2,6和1及6的LCA是4。

看到这里的模式了吗?两个树节点之间的LCA要么是节点本身(3和2的情况),要么是一个父节点,其第一个孩子在其左子树的某个地方被发现,第二个孩子在其右子树的某个地方。

找到两个树节点p和q之间的最低共同祖先(LCA)的算法如下:

  • 验证p或q是否在左子树或右子树中被发现
  • 然后,验证当前节点是否为p或q
  • 如果在左子树或右子树中找到了p或q,并且p或q中的一个是节点本身,我们就找到了LCA。
  • 如果p和q都在左边或右边的子树上,我们就找到了LCA。
/**
* @param {TreeNode} root
* @param {TreeNode} p
* @param {TreeNode} q
*/
const lowestCommonAncestor = function(root, p, q) {
    let lca = null
    const isCommonPath = (node) => {
        if (!node) return false
        var isLeft = isCommonPath(node.left)
        var isRight = isCommonPath(node.right)
        var isMid = node == p || node == q
        if (isMid && isLeft || isMid && isRight || isLeft && isRight) {
            lca = node
        }
        return isLeft || isRight || isMid
    }
    isCommonPath(root)
    return lca
};

总结

综上所述,我们已经学会了如何遍历、验证和计算BST的深度。

这些算法经常在编码面试中被问及。在练习更高级的BST应用(如寻找两个节点的LCA)之前,了解它们很重要。