- 原文地址:stackfull.dev/tree-data-s…
- 原文作者:Anish Kumar
- 译文出自:掘金翻译计划
树是一种非常有趣的数据结构,在很多领域里有着广泛的应用。比如:
- 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
下面用一个稍微复杂点的树画了示意图,更容易理解:
实现这种遍历可以使用一个队列(先进先出)。算法如下:
- 初始化一个包含根节点的队列
- 从队列中取出第一项
- 把取出项的左、右子节点送入队列
- 重复第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
}
深度优先搜索/遍历
深度优先遍历,就是拿到一个节点,逐级遍历子节点,直至到头。有如下几种方式:
根节点 -> 左节点 -> 右节点 // 前序遍历
左节点 -> 根节点 -> 右节点 // 中序遍历
左节点 -> 右节点 -> 根节点 // 后序遍历
这几种方式都既可以用递归实现,也可以用迭代实现。下面来看实现细节。
前序遍历
前序遍历树的顺序如下:
根节点 -> 左节点 -> 右节点
窍门:
可以用这个窍门手工前序遍历任何树进行:从根节点开始,一直往左。
实现:
下面我们就实现这种遍历。递归方式非常直观。
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)
}
}
中序遍历
下面是对树进行中序遍历的过程:
左节点 -> 根节点 -> 右节点
窍门:
可以用这个简单的窍门手工对任何树进行中序遍历:拿一面镜子,在树底从左向右移动,取得照到的所有节点。
实现:
递归:
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
}
}
后序遍历
后序遍历看起来是这样的:
左节点 -> 右节点 -> 根节点
窍门:
要快速手工后序遍历任何树,可以:一个接一个扯掉最左边的叶节点。
实现:
来看一下这种遍历的实际实现。
递归:
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)
}
}