JavaScript中的树数据结构

2,596 阅读5分钟

树是一种非常有趣的数据结构,在很多领域里有着广泛的应用。比如:

  • DOM是树形数据结构
  • 操作系统中的目录和文件也可以用树形结构表示
  • 家谱也可以表示为树形结构

树有很多变体(比如堆、BST),可以解决很多问题,比如排期、图像处理、数据库等相关的问题。 很多复杂问题乍一看好像跟树并没有关系,但实际上可以用树来表示。本系列文章将介绍一些这样的问题(在后面几篇),展示一下怎么将复杂问题用树来表示,从而简化问题的理解和解决。

大家别忘了订阅我的简报,可以收到新文章的通知。

简介

实现一个二叉树的Node 很简单。

function Node(value){
  this.value = value
  this.left = null
  this.right = null
}
// 用法
const root = new Node(2)
root.left = new Node(1)
root.right = new Node(3)

这几行代码就创建了下面这样的二叉树:

           2  
        /      \
       /         \
     1            3
   /   \        /    \
null  null   null   null

酷!确实简单。接下来,看看怎么应用。

遍历

我们先看一下怎么遍历这些连接的节点(或树)。如果可以像遍历数组一样“遍历”树节点就好了。可是,树不是数组那样的线性数据结构,因此遍历树不止一种方式。可以宽泛地这样来分类遍历方式:

  • 广度优先遍历
  • 深度优先遍历

广度优先搜索/遍历

这种方式是一层一层遍历。从根节点开始,然后遍历它的所有子节点,再遍历所有第二级子节点,依此类推。比如,对上面的树来说,广度优先遍历会得到这个结果:

2, 1, 3

下面用一个稍微复杂点的树画了示意图,更容易理解:

BFS.png

实现这种遍历可以使用一个队列(先进先出)。算法如下:

  1. 初始化一个包含根节点的队列
  2. 从队列中取出第一项
  3. 把取出项的左、右子节点送入队列
  4. 重复第2、3步,直到队列为空

下面是这个算法的实现:

function walkBFS(root){
  if(root === null) return

  const queue = [root]
  while(queue.length){
      const item = queue.shift()
      // 做些什么
      console.log(item)

      if(item.left) queue.push(item.left)
      if(item.right) queue.push(item.right)
   }
}

可以修改上面的算法,返回一个数组的数组。其中,每个内部数组都包含同一层级的节点:

function walkBFS(root){
  if(root === null) return

  const queue = [root], ans = []

  while(queue.length){
      const len = queue.length, level = []
      for(let i = 0; i < len; i++){
          const item = queue.shift()
          level.push(item)
          if(item.left) queue.push(item.left)
          if(item.right) queue.push(item.right)
       }
       ans.push(level)
   }
  return ans
}

深度优先搜索/遍历

深度优先遍历,就是拿到一个节点,逐级遍历子节点,直至到头。有如下几种方式:

 根节点 -> 左节点 -> 右节点 // 前序遍历
 左节点 -> 根节点 -> 右节点 // 中序遍历
 左节点 -> 右节点 -> 根节点 // 后序遍历

这几种方式都既可以用递归实现,也可以用迭代实现。下面来看实现细节。

前序遍历

前序遍历树的顺序如下:

 根节点 -> 左节点 -> 右节点

PreOrder visual.png

窍门:

可以用这个窍门手工前序遍历任何树进行:从根节点开始,一直往左。

preOrderTrick.png

实现:

下面我们就实现这种遍历。递归方式非常直观。

function walkPreOrder(root){
  if(root === null) return

  // 做些什么
  console.log(root.val)

  // 递归遍历子节点
  if(root.left) walkPreOrder(root.left)
  if(root.right) walkPreOrder(root.right)
}

前序遍历的迭代方式非常类似广度优先遍历,区别在于这里使用的是栈stack,而非队列queue,另外是先把右子节点推入栈:

function walkPreOrder(root){
  if(root === null) return

  const stack = [root]
  while(stack.length){
      const item = stack.pop()

      // 做些什么
      console.log(item)

      // 左子节点在右子节点后面入栈,因为
      // 要先打印左子节点,所以它要在上面
      if(item.right) stack.push(item.right)
      if(item.left) stack.push(item.left)
   }
}

中序遍历

下面是对树进行中序遍历的过程:

左节点 -> 根节点 -> 右节点

InOrder visual.png

窍门:

可以用这个简单的窍门手工对任何树进行中序遍历:拿一面镜子,在树底从左向右移动,取得照到的所有节点。

Inorder trick.png

实现:

递归:

function walkInOrder(root){
  if(root === null) return

  if(root.left) walkInOrder(root.left)

  // 做些什么
  console.log(root.val)

  if(root.right) walkInOrder(root.right)
}

迭代: 这个算法乍一看不好理解。但实际上非常直观。可以这样理解:中序遍历时,最左边的子节点会先打印出来,然后打印根节点,之后才打印右子节点。因此,开始我们可能会这样来实现:

const curr = root

while(curr){
  while(curr.left){
    curr = curr.left // 到达最左侧子节点
  }

  console.log(curr) // 打印

  curr = curr.right // 移向右子节点
}

但以上面的方式遍历无法回溯,也就是无法回到最后那个子节点的父节点。因此需要一个栈记录这些节点。修改后的实现如下:

const stack = []
const curr = root

while(stack.length || curr){
  while(curr){
    stack.push(curr) // 记录经过的节点,以便回溯
    curr = curr.left // 到达最左侧子节点
  }
  const leftMost = stack.pop()
  console.log(leftMost) // 打印

  curr = leftMost.right // 移向右子节点
}

使用上面的方式可以实现最终的迭代算法:

function walkInOrder(root){
  if(root === null) return

  const stack = []
  let current = root

  while(stack.length || current){
      while(current){
         stack.push(current)
         current = current.left
      }
      const last = stack.pop()

      // 做些什么
      console.log(last)

      current = last.right
   }
}

后序遍历

后序遍历看起来是这样的:

 左节点 -> 右节点 -> 根节点

PostOrder visual.png

窍门:

要快速手工后序遍历任何树,可以:一个接一个扯掉最左边的叶节点。

PostOrder trick.png

实现:

来看一下这种遍历的实际实现。

递归:

function walkPostOrder(root){
  if(root === null) return

  if(root.left) walkPostOrder(root.left)
  if(root.right) walkPostOrder(root.right)

  // 做些什么
  console.log(root.val)

}

迭代: 我们已经有了前序遍历的算法实现,可以直接使用它吗?因为后序遍历看起来跟前序遍历刚好相反。来看看:

// 前序:->->// 前序的反序:->->// 但后序是:->->

哈哈!还是有一点不同的。不过,只要稍微改一下我们的前序算法,然后再反转,应该就能得到后序结果。整个算法如下所示:

// 这样记录结果->->// 再对结果反序->->
  • 使用与上面迭代前序算法类似的方式,使用一个临时的栈stack

    • 唯一例外是按根 -> 右 -> 左而不是根 -> 左 -> 右来记录
  • 将遍历的节点保存在数组result

  • 反转result得到后序遍历结果

function walkPostOrder(root){
  if(root === null) return []
    
  const tempStack = [root], result = []
    
  while(tempStack.length){
      const last = tempStack.pop()
    
      result.push(last)
    
      if(last.left) tempStack.push(last.left)
      if(last.right) tempStack.push(last.right)
  }
    
  return result.reverse()
}

惊喜:JavaScript小贴士

如果可以像下面这样遍历树多好啊:

 for(let node of walkPreOrder(tree) ){
   console.log(node)
 }

看起来很舒服,而且也简单,对不?只要那些walk函数返回迭代器就行了。

比如,可以修改上面的walkPreOrder函数,修改之后就可以那么简单地使用了:


function* walkPreOrder(root){
   if(root === null) return

  const stack = [root]
  while(stack.length){
      const item = stack.pop()
      yield item
      if(item.right) stack.push(item.right)
      if(item.left) stack.push(item.left)
   }
}