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

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

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

最后说两句

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

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

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

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

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

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

相关推荐
何何____1 小时前
svg基本图形绘制介绍
前端·css
weedsfly1 小时前
Sass 运算 vs CSS calc():你的计算该放在哪一层?
前端
用户7229134504521 小时前
数字故障美学:用 Canvas 实现 RGB 偏移、像素排序与扫描线
javascript
小森林之主1 小时前
深入正则表达式:核心语法与实战剖析
javascript·python·正则表达式·编程技巧·字符串处理
在水一缸2 小时前
重塑前端开发认知:当 AI 遇见 HTML 的“不合理有效性”
前端·人工智能·html·ai编程·claude·前端开发
果丁智能2 小时前
智慧校园一卡通深度融合方案:基于超级SIM卡的手机碰一碰智能开锁技术落地实践
数据结构·人工智能·python·科技·算法·智能家居·信息与通信
SwJieJie2 小时前
Webpack vs Vite 构建工程化实战(Vue 项目深度解析)
前端·vue.js·webpack·node.js
Irissgwe2 小时前
顺序表和链表
数据结构·c++·链表·c·顺序表·线性表
swg3213212 小时前
Redis实现主从选举
java·前端·redis