从一次栈溢出报错说起,我把递归彻底扒明白了

先从一个爆栈的报错说起

昨天在写一个数组工具函数,需要把深层嵌套的数组拍平。我顺手写了个递归版本,自测用个三层嵌套的数组没问题,就丢进去测了个十几层的大数组。结果控制台直接红了一片,报 Maximum call stack size exceeded。

说实话当时我有点懵。写递归也不是一天两天了,平时写个深拷贝、树遍历都顺得很,怎么今天说炸就炸。我对着代码盯了十分钟,改来改去还是不对,才反应过来:我好像一直只会套递归的模板,根本没真的搞懂它到底是怎么跑的。

索性当天下午啥也没干,从最基础的例子开始,一点点把递归拆了个明白。今天就顺着我当时的思路,跟大家唠唠。

从最简单的求和,看懂递归到底在干嘛

我没一上来就啃扁平化,先退回到最基础的问题:求 1+2+3+...+n 的结果。这种题正常人第一反应都是写循环,对吧?我也一样,几行代码的事,跑起来稳得很。

javascript 复制代码
// 迭代写法,怎么跑都不会炸
function sum(n) {
    let res = 0;
    for (let i = 1; i <= n; i++) {
        res += i;
    }
    return res;
}

那如果用递归写呢?当时我脑子里的第一反应是:要求 n 个数的和,不就是 n 加上前面 n-1 个数的和吗?比如 sum(5) = 5 + sum(4),sum(4) = 4 + sum(3),这样一层层拆下去,拆到什么时候是个头?到 1 的时候,sum(1) 就等于 1,不用再拆了。

想着想着我就敲出了第一版代码,运行,直接卡页面,过两秒就爆栈了。你们猜为啥?我光顾着写递归逻辑,把终止条件给忘了。

javascript 复制代码
    // 错误示范:没有终止条件,直接无限递归
    function sum(n) {
        // 注意这里,没写停止条件,会一直调用下去,直到栈溢出
        return n + sum(n - 1);
    }

后来加上了终止条件,才算跑通。

javascript 复制代码
    // 正确的递归求和
    function sum(n) {
        if(n === 1){
            return 1; // 到最小的子问题就返回,不能再拆了
        }
        return n + sum(n - 1);
    }

就这么个简单的例子,我来回折腾了两版,才静下心来想:递归这玩意儿,到底是个啥逻辑?其实掰碎了看,核心就三件事,跟俄罗斯套娃一模一样。你想打开一套套娃,就得一个个往外拿,每个娃娃结构都一样,只是大小不同。拿到最小的那个,拿不动了,就开始往回装,装完一个得到一个结果,直到装完最大的那个,整件事就做完了。

对应到递归里:

  • 每一层的问题结构完全一样,只是规模变小了(每个套娃长得一样,只是尺寸不同)
  • 有一个明确的终止点,到这就不能再拆了(最小的套娃,里面没东西了)
  • 每一层都等着下一层返回结果,再拼成自己的结果往回传(把小娃娃装回大娃娃里)

换个角度看,这也是个自顶向下的树状结构。最顶上的是原来的大问题,往下拆出一层一层的子问题,最底下的叶子节点就是不能再拆的终止条件。

我当时特意对着调用栈捋了一遍 sum(5) 的执行过程,才真的明白 "栈溢出" 到底是啥意思。

调用 sum(5) 的时候,浏览器会把这个函数压到调用栈里;然后 sum(5) 调用 sum(4),再压一层;一直压到 sum(1),这时候到了终止条件,开始返回结果,返回一个就从栈里弹出一层,直到 sum(5) 拿到结果弹出,整个过程结束。为啥会爆栈?因为浏览器给调用栈留的空间是有限的,你嵌套个几千层,栈装不下了,自然就报错了。

再进阶点:手写数组扁平化

搞懂了基础的求和,我就回头去改那个数组扁平化的代码。说起来挺丢人的,平时写业务我从来都是一把梭 ES6 的 flat 方法,根本没细想过它是怎么实现的。真到要手写的时候,脑子一片空白。

javascript 复制代码
    const arr = [1, [2, [3, 4, 5]]];
    // 业务里我都这么写,方便是真方便
    console.log(arr.flat(Infinity));

日常业务开发里,如果只是普通的数组扁平化,优先用原生的 flat 方法。JS 引擎对原生方法做过深度优化,性能比我们自己手写的递归版本好得多。

趁这个机会我也好好翻了下 flat 的用法,人家设计得其实挺细的。默认只扁平化一层,你传个数字 2 就拆两层,传 Infinity 就把所有嵌套都拆完。我之前不管啥场景都写 Infinity,现在想想挺浪费的,知道嵌套深度的话,传具体的数字性能会好不少,也能避免一些意料之外的问题。

那自己用递归实现的话,该怎么写?思路其实跟求和差不多:遍历数组的每一项,如果这一项还是数组,那就继续拆;如果不是,就放进结果里。我第一版写得特别顺,写完一跑,结果不对。

javascript 复制代码
    // 错误示范:我当时脑子抽了,直接push递归结果
    const flatten = (arr) => {
        let result = [];
        arr.forEach((item) => {
            if(Array.isArray(item)) {
                result.push(flatten(item)); // 坑就在这!flatten返回的是数组,直接push就嵌套了
            }else {
                result.push(item);
            }
        })
        return result;
    }

跑出来的结果是啥样的?[1, [2, [3, [4, [5]]]]],等于没拆平。我盯着这行代码看了快二十分钟,才反应过来:flatten 函数返回的是一个一维数组,你把一个数组 push 进另一个数组,可不就又嵌套了吗。

改的方法也简单,不用 push,用 concat 就行,或者用展开运算符把结果拆开再 push。

javascript 复制代码
    // 正确的递归扁平化
    const flatten = (arr) => {
        let result = [];
        arr.forEach((item) => {
            if(Array.isArray(item)) {
                // 别问我为什么知道要用concat,试了三次才改对
                result = result.concat(flatten(item));
            }else {
                result.push(item);
            }
        })
        return result;
    }

    console.log(flatten([1, [2, [3, [4, [5]]]]])); // [1,2,3,4,5]

改完跑通的那一刻,我还挺有成就感的。不过转头测了个二十层嵌套的数组,又爆栈了。得,又回到最开始的问题了。

我踩过的三个递归的坑,别再往里跳了

这一下午写代码改 bug,踩了好几个实打实的坑,说出来大家避避雷,反正我是每个都掉进去过。

坑一:先写递归逻辑,后补终止条件

这是我最常犯的毛病。脑子里先想清楚了怎么拆问题,上来就写递归调用,写着写着就把终止条件给忘了。结果就是一运行直接死循环,轻的页面卡几秒,重的直接浏览器无响应。

写递归一定要先写终止条件,再写递归逻辑。这不是什么代码规范,是我无数次爆栈攒出来的经验。

现在我写递归的习惯改了:第一行先写 if 判断,把终止条件写上,再去写后面的递归逻辑。别嫌麻烦,这一步能帮你避开 80% 的递归 bug。

坑二:类型判断写错,边界兜不住

写扁平化的时候,我最开始没用 Array.isArray,图省事写了 typeof item === 'object'。结果呢?数组是对象没错,可 null 也是对象啊,普通对象也是对象啊。万一数组里有个 null,代码直接就往递归里走,然后就报错了。

后来我就学乖了,判断数组老老实实⽤ Array.isArray,别整那些花里胡哨的简写。不止是递归,平时写代码也一样,类型判断越严谨,后面出 bug 的概率越低。

坑三:为了递归而递归,徒增复杂度

不知道有没有人跟我一样,刚学会递归的时候,啥都想用递归写。明明一个 for 循环就能搞定的事,非要整个递归,觉得显得高级。结果就是,代码写得绕,出了 bug 半天查不出来,性能还不如循环。

比如刚才的求和,递归写得再漂亮,n 一大就爆栈,还不如 for 循环稳,更别说直接用高斯公式 n*(n+1)/2 了,一步出结果。

最后说句实在的:递归不是万能的

以前我总觉得递归是个很高级的写法,写出来代码短,看着优雅。真的踩了几次坑才明白,它就是个普通的解题思路,有适合的场景,也有很多不适合的地方。

什么时候用递归合适?比如树结构遍历、深拷贝、多层嵌套数据处理这种,天然就是嵌套结构,用递归写逻辑清晰,代码也好读。什么时候别硬上递归?第一是深度特别大的场景,比如几千几万层的嵌套,很容易爆栈;第二是对性能要求特别高的地方,每次函数调用都有开销,递归层数多了,性能不如循环;第三是边界条件特别复杂的,拆来拆去容易乱,不如老老实实写循环。

回头捋这一下午的折腾,其实最大的收获就三点。第一,递归的本质从来不是 "函数自己调用自己",而是把一个大问题,拆成若干个结构完全相同的小问题。调用自己只是实现形式,拆解问题才是核心。第二,终止条件是递归的命根子。写不对,轻则结果出错,重则直接干崩页面。写递归先写边界,这是血的教训。第三,优雅不能当饭吃。不要为了写递归而写递归,哪种写法简单、好维护、性能好,就用哪种。

我之前学东西总喜欢背模板,背完就觉得自己会了。真遇到问题才发现,模板背得再熟,不懂原理,一踩坑就懵。反而像这样,从一个报错入手,一步步拆,一步步踩坑,最后搞懂的东西,记得才最牢。

你写递归的时候踩过最蠢的坑是什么?评论区说出来让我找找平衡。要是看完觉得对你有点启发,也可以留个言,我看看有多少人跟我一样,是踩坑踩明白的。

相关推荐
GoAI17 小时前
DeepSeek Harness Memory (一)主流记忆插件总结
后端·算法·面试
Pniubi17 小时前
力扣54.螺旋矩阵
算法·leetcode·矩阵
陈随易18 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding18 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
kyriewen19 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易19 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
choumou_M20 小时前
算法_1:由浅入深学习快速排序QuickSort
学习·算法·排序算法
程序员大阳20 小时前
HTML+CSS+JS对话式实战(9)-项目总结与问题分析
javascript·css·html·实战·项目总结
Coder_Shenshen20 小时前
罗克韦尔ACD工程归档离线变量树解析
算法·c#