我一直没搞明白为什么树的遍历非要用递归,直到昨天我把爬楼梯和二叉树放在一起 debug 了一下,突然就通了。
说实话,之前学递归的时候,老师总拿爬楼梯举例子,说什么 f (n) = f (n-1) + f (n-2)。我当时就记住了公式,考试也能写对,但根本不知道这玩意儿和树有啥关系。更别说为什么树的遍历非得用递归写,我总觉得用循环也能搞定啊,为啥要搞这么反人类的东西。
直到我画了一下爬楼梯的递归调用过程,才发现原来递归的执行过程本身就是一棵树!
plaintext
f(10)
f(9) f(8)
f(8) f(7) f(7) f(6)
...
f(1)-1 f(2)-2
你看,f (10) 下面分 f (9) 和 f (8),f (9) 下面又分 f (8) 和 f (7),这不就是一棵二叉树吗?每一个节点都是一个函数调用,叶子节点就是递归的退出条件 f (1)=1 和 f (2)=2。
哦!原来递归和树天生就是一对啊。那反过来,树的结构天生就适合用递归来遍历,这不就顺理成章了吗?
先搞懂二叉树到底是什么
说出来不怕你们笑话,我之前对二叉树的理解就是 "每个节点最多有两个孩子的树"。直到我看了正式的定义,才发现我错了。
二叉树的定义是递归的:它要么是空树,要么由一个根节点加上左子树和右子树组成,而左子树和右子树又都是二叉树。
重点来了:左右子树的位置是严格约定的,不能交换!也就是说,即使一个节点只有一个孩子,你也得说清楚它是左孩子还是右孩子。这和普通的树不一样,普通的树孩子之间没有顺序。
在 JS 里,我们用对象来表示一个二叉树节点,每个节点就三个东西:
- 数据域 val:存节点的值
- left:指向左子节点的引用
- right:指向右子节点的引用
javascript
function TreeNode(val) {
this.val = val;
this.left = this.right = null;
}
我创建了一个最简单的满二叉树来做测试,就是这个样子:
javascript
const tree = {
val: 'A',
left: {
val: 'B',
left: { val: 'D', left: null, right: null },
right: { val: 'E', left: null, right: null }
},
right: {
val: 'C',
left: { val: 'F', left: null, right: null },
right: { val: 'G', left: null, right: null }
}
}
递归遍历:原来只是 console.log 的位置不一样
递归遍历有三种:先序、中序、后序。我之前总是记混它们的顺序,后来发现其实特别简单。
所有的递归遍历,一定是先左后右,唯一的区别就是什么时候处理根节点。
- 先序:先处理根,再处理左,再处理右(根左右)
- 中序:先处理左,再处理根,再处理右(左根右)
- 后序:先处理左,再处理右,再处理根(左右根)
你看,三个遍历的代码几乎一模一样,唯一的区别就是 console.log 的位置:
javascript
// 先序遍历:根左右
function preOrder(root) {
if(!root) return; // 退出条件:空节点直接返回
console.log(`当前遍历节点是:${root.val}`); // 先处理根
preOrder(root.left); // 再处理左子树
preOrder(root.right); // 最后处理右子树
}
// 中序遍历:左根右
function inOrder(root) {
if(!root) return;
inOrder(root.left); // 先处理左子树
console.log(`当前遍历节点是:${root.val}`); // 再处理根
inOrder(root.right); // 最后处理右子树
}
// 后序遍历:左右根
function postOrder(root) {
if(!root) return;
postOrder(root.left); // 先处理左子树
postOrder(root.right); // 再处理右子树
console.log(`当前遍历节点是:${root.val}`); // 最后处理根
}
我把这三个函数跑了一下,输出结果如下:
plaintext
先序遍历:
当前遍历节点是:A
当前遍历节点是:B
当前遍历节点是:D
当前遍历节点是:E
当前遍历节点是:C
当前遍历节点是:F
当前遍历节点是:G
-----------------
中序遍历:
当前遍历节点是:D
当前遍历节点是:B
当前遍历节点是:E
当前遍历节点是:A
当前遍历节点是:F
当前遍历节点是:C
当前遍历节点是:G
-----------------
后序遍历:
当前遍历节点是:D
当前遍历节点是:E
当前遍历节点是:B
当前遍历节点是:F
当前遍历节点是:G
当前遍历节点是:C
当前遍历节点是:A
-----------------
说实话,我第一次跑通的时候,心里是懵的。因为我根本不知道它是怎么一步步从 A 走到 D,再走回 B,再走到 E 的。
后来我在每一行都加了 console.log,一步步看执行过程,才明白原来这都是函数调用栈在搞鬼。
当我调用 preOrder (A) 的时候,这个函数会被压入栈顶。首先打印 A,然后调用 preOrder (A.left) 也就是 preOrder (B),这个函数又被压入栈顶。接着打印 B,调用 preOrder (B.left) 也就是 preOrder (D),压入栈顶。
打印 D 之后,调用 preOrder (D.left),这时候 D.left 是 null,触发退出条件,函数返回,从栈顶弹出。接着调用 preOrder (D.right),也是 null,返回,弹出。这时候栈顶又回到了 preOrder (B),接下来调用 preOrder (B.right) 也就是 preOrder (E),以此类推。
哦!原来递归就是这么一回事啊。函数调用栈帮我们记住了接下来要执行的代码,所以我们不用自己写循环来维护遍历的顺序。
层序遍历:这个得用队列
层序遍历就不一样了,它是按层次从上到下、从左到右遍历的。这个不能用递归,得用队列。
因为队列是先进先出的,正好符合一层一层处理的逻辑。我们先把根节点入队,然后每次从队头取出一个节点处理,再把它的左孩子和右孩子依次入队。这样就能保证先处理上层的节点,再处理下层的节点。
javascript
function levelOrder(root) {
const queue = [];
const ans = [];
if(!root) return ans;
queue.push(root); // 根节点先入队
while(queue.length) {
const node = queue.shift(); // 从队头取出节点
ans.push(node.val);
if(node.left) queue.push(node.left); // 左孩子入队
if(node.right) queue.push(node.right); // 右孩子入队
}
return ans;
}
跑一下这个函数,输出结果是:
plaintext
层序遍历:
[ 'A', 'B', 'C', 'D', 'E', 'F', 'G' ]
完美符合预期。
我踩过的那些坑
说几个我今天掉进去的坑,你们别再踩了。
第一个坑:忘记写退出条件。
我第一次写递归的时候,就忘了写if(!root) return;这一行。结果浏览器直接卡死了,控制台报了个Maximum call stack size exceeded。
截图:当时控制台输出的爆栈错误就长这样
递归一定要有退出条件!不然就会无限递归,导致栈溢出。
第二个坑:左右子树搞反了。
还有一次,我把preOrder(root.right)写在了preOrder(root.left)前面,结果输出的顺序完全反过来了。我当时还以为是我遍历方式记错了,查了半天资料才发现是左右写反了。
第三个坑:递归深度太大导致爆栈。
递归虽然写起来简单,但它有个致命的缺点:栈溢出。JS 的函数调用栈深度是有限的,大概是 1 万多一点。如果你的树深度超过了这个值,就会爆栈。
比如我写了个爬楼梯的函数,传入 n=10000,直接就爆栈了。
javascript
function climbStairs(n) {
if(n === 1) return 1;
if(n === 2) return 2;
return climbStairs(n - 1) + climbStairs(n - 2);
}
console.log(climbStairs(10000)); // 直接爆栈
所以对于深度很大的树,还是得用迭代的方式来实现遍历。
最后说两句
今天捣鼓了一天递归和二叉树,最大的三个收获:
第一,递归的本质就是函数调用栈,它的执行过程本身就是一棵树。理解了这一点,你就不会觉得递归很神秘了。
第二,二叉树的递归遍历之所以简单,是因为二叉树的定义本身就是递归的。三个遍历的区别只是处理根节点的时机不同,代码几乎一模一样。
第三,递归不是万能的。它写起来简洁,但可读性差,而且有栈溢出的风险。对于深度很大的树,或者对性能要求很高的场景,还是得用迭代。
其实我现在还有点懵的是,怎么把递归的遍历改成迭代的。等我下次搞懂了,再写一篇分享给你们。
搞懂了记得回来留个言,我也想看看你的理解。