搞懂二叉树递归遍历,我居然是从爬楼梯开始的

我一直没搞明白为什么树的遍历非要用递归,直到昨天我把爬楼梯和二叉树放在一起 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)); // 直接爆栈

所以对于深度很大的树,还是得用迭代的方式来实现遍历。

最后说两句

今天捣鼓了一天递归和二叉树,最大的三个收获:

第一,递归的本质就是函数调用栈,它的执行过程本身就是一棵树。理解了这一点,你就不会觉得递归很神秘了。

第二,二叉树的递归遍历之所以简单,是因为二叉树的定义本身就是递归的。三个遍历的区别只是处理根节点的时机不同,代码几乎一模一样。

第三,递归不是万能的。它写起来简洁,但可读性差,而且有栈溢出的风险。对于深度很大的树,或者对性能要求很高的场景,还是得用迭代。

其实我现在还有点懵的是,怎么把递归的遍历改成迭代的。等我下次搞懂了,再写一篇分享给你们。

搞懂了记得回来留个言,我也想看看你的理解。

相关推荐
fei_sun11 分钟前
黑洞路由(Null Route/空接口路由)
服务器·前端·javascript
大爱一家盟23 分钟前
告别卡点BGM同质化 2026原创卡点音乐素材下载网站 TOP5 推荐
大数据·前端·人工智能
彦为君26 分钟前
算法思维与经典智力题
java·前端·redis·算法
aa小小1 小时前
localhost 访问异常排查笔记
前端
格子软件1 小时前
2026年GEO优化系统源码的分布式状态机深度拆解
java·前端·vue.js·vue·geo
陈随易1 小时前
Rust、Golang、MoonBit 编译成 WASM,体积和速度差距有多大?
前端·后端·程序员
IT_陈寒1 小时前
Python多线程的坑,我居然现在才踩到
前端·人工智能·后端
摇滚侠2 小时前
方法 A 等方法 B 执行完再执行 叫同步调用还是异步调用 JS 默认是同步调用还是异步调用
开发语言·javascript·ecmascript
触底反弹2 小时前
🔥 字符串算法面试三连击:反转、回文、回文变种,搞懂这三题稳了!
前端·javascript·算法
触底反弹2 小时前
AI Tool Use 深度解析:大模型是如何"突破物理限制"调用外部工具的?
javascript·人工智能·后端