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

先从一个爆栈的报错说起

昨天在写一个数组工具函数,需要把深层嵌套的数组拍平。我顺手写了个递归版本,自测用个三层嵌套的数组没问题,就丢进去测了个十几层的大数组。结果控制台直接红了一片,报 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 了,一步出结果。

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

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

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

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

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

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

相关推荐
kyriewen3 小时前
面试官问你:“AI 能写 80% 的代码了,公司为什么还需要你?”
前端·javascript·面试
Goodbye6 小时前
从 Token 到 Embedding:LLM 核心基础深度解析
javascript·人工智能
用户938515635076 小时前
工具调用背后:LLM 如何突破“缸中大脑”,操控真实世界?
javascript·人工智能
Goodbye6 小时前
从函数到智能:LLM Tool Use 深度解析
javascript·人工智能
半个落月6 小时前
大模型到底是怎么“调用工具”的?从一个 Node.js Demo 看懂 Tool Use
javascript·人工智能
烬羽6 小时前
中英文 token 数量差一倍?两段 JS 代码搞懂 LLM 底层是怎么"读"文字的
javascript·程序员·架构
千纸鹤安安6 小时前
千问Qwen-AgentWorld来了:一个语言模型搞定七大Agent场景,GPT-5.4都输了
算法
山河木马6 小时前
矩阵专题1-怎么创建模型矩阵(uModelMatrix)
javascript·webgl·计算机图形学