☰
递归与数组:五大核心模式、性能优化与实战避坑指南
2026/10/16 7:35:53 网站建设 项目流程

递归和数组,这两个词放在一起,几乎是算法学习里绕不开的“地基组合”。灰度图像处理里我天天用递归遍历二维数组,业务逻辑里用递归处理树形菜单和嵌套评论,经典算法里二分查找、归并排序、全排列、岛屿数量,本质全都是在“递归操作数组”。

我自己见过太多人把递归学成玄学,一见到递归就画递归树、背模板,但换个题目还是不会。问题不在于递归本身难,而在于没有把递归和数组的下标关系想明白。这篇内容我打算换个思路,不堆概念,直接讲清楚:数组为什么会天然适配递归,数组上最常见的递归模式有哪几类,代码怎么写,坑在哪里,以及递归深度、性能、栈溢出这些实际问题怎么处理。适合刚接触递归想系统地搞懂它的人,也适合写了几年代码但遇到递归还是发怵的开发者。

1. 递归与数组的底层关系:为什么数组天然适合递归

1.1 把数组看成“头和尾”,递归思路就打开了

很多人学不懂递归,我觉得卡住的核心原因是:他们始终把数组看成一个整体,认为数组就是一个装了很多元素的盒子。这种视角当然没错,但它不利于拆分问题。

数组真正的隐藏属性是:可以通过下标把一个长数组看成“第一个元素 + 剩下的子数组”。例如[1, 2, 3, 4, 5]可以看成1 + [2, 3, 4, 5],而[2, 3, 4, 5]又可以看成2 + [3, 4, 5]。这种拆分方式叫“递归结构”。只要有“当前元素 + 剩余部分”的视角,数组求和、找最大值、逆序输出这些操作就都可以自然地写成递归形式。

还有一种拆分方式叫“区间分治”,把数组从中间劈开,看成一个左半区间和一个右半区间。二分查找、归并排序全靠这个视角。不管是“头和尾”还是“左和右”,本质都是同一个思想:把大数组降解成小数组,把大问题降解成小问题,直到小问题简单到可以直接返回结果。

我在实际代码里感受到的一点是:一旦你习惯了用下标而不是整体值去思考数组,递归代码写起来就顺了。因为你不再关心数组里具体是什么数据,你只关心“当前下标任务是什么,下一层要处理哪个下标区间”。这个思路一旦建立,递归就不再是背诵模板,而是一种非常自然的思维工具。

1.2 递归三要素落到数组上,对应关系一目了然

任何递归函数都需要三个东西:基线条件、递归调用、结果合并。这三个东西放在数组场景里,其实有非常明确的对应关系。

基线条件对应的是“数组不能再拆的情况”。最常见的三种:数组为空、数组只有一个元素、区间左右下标相遇或者交错(left > right)、二维网格的行列坐标越界。很多递归栈溢出的问题,都是因为这个条件写错了或者没写。

递归调用对应的是“把当前任务交给剩余部分”。对于线性扫描,就是f(index) = 处理 arr[index] + f(index+1);对于二分,就是f(left, right) = f(left, mid-1) + f(mid+1, right)的某种組合。递归调用的参数里几乎必然带着下标,这个下标就是递归的“状态”。

结果合并对应的是“把子问题的答案拼成当前问题的答案”。求和就是当前元素加上子数组的和;找最大值就取当前元素和子数组最大值的较大者;全排列就是把当前选择追加到路径数组后面,再收集子问题的结果;归并排序就是把两个有序子数组合并后写回原数组。

我写递归函数时有个习惯:先问自己三个问题,数组什么时候不能再拆?下一层递归要传什么区间?返回值怎么合并到上一层?这三个问题答完了,代码基本就出来了。后面所有数组相关的递归,万变不离其宗。

2. 数组场景里最常见的五种递归模式,代码直接抄

2.1 线性扫描:从数组头扫到数组尾

线性扫描是最简单、最直观的递归模式。它处理的是“需要从数组的第一个元素依次走到最后一个元素”的问题。典型场景:数组求和、求最大值、判断数组中是否存在某元素、逆序输出数组元素。

以数组求和为例:

function sumArray(arr, index = 0) { if (index >= arr.length) { return 0; } return arr[index] + sumArray(arr, index + 1); }

这里的基线条件是index >= arr.length,表示已经越界,没有元素可加了。递归关系是“当前元素 + 后面所有元素的和”。下一层调用时index + 1,状态就是当前扫描到的位置。

逆序输出数组也是同样的结构,只是把输出语句放到递归调用之后:

function printReverse(arr, index = 0) { if (index >= arr.length) { return; } printReverse(arr, index + 1); console.log(arr[index]); }

第一次调用从下标0开始,一直递归到末尾,触底后开始返回,再逐层执行打印语句。因为打印是在递归返回之后执行的,所以先输出的反而是数组末尾的元素。这个例子能帮你理解“递去和归来”两个阶段,递归调用前后代码的执行顺序不一样,是很多初学者容易忽略的细节。

2.2 区间分治:把数组切两半,分别处理

线性扫描是从头到尾逐个处理,而区间分治是把数组从中间切成两半,先处理左半边,再处理右半边,最后合并结果。这个模式有个天然优势:递归深度从数组长度变成了数组长度的对数级别。一万个元素的数组,线性递归可能叠一万层,二分递归只需要十几层。

最经典的例子是二分查找。它的核心思想是:对于有序数组,每次看中间元素,比目标值大就搜左半区间,比目标值小就搜右半区间:

function binarySearch(arr, target, left, right) { if (left > right) { return -1; } const mid = left + Math.floor((right - left) / 2); if (arr[mid] === target) { return mid; } if (arr[mid] > target) { return binarySearch(arr, target, left, mid - 1); } return binarySearch(arr, target, mid + 1, right); }

注意这里计算mid时用的是left + (right - left) / 2,而不是(left + right) / 2。原因很简单:当 left 和 right 都接近大数时,两者相加可能超出语言允许的数值范围,导致溢出。用减法先算区间长度再除以二,可以规避这个问题。这个细节在很多教科书里没讲,但实战场上非常重要。

归并排序是另一个典型。它的递归过程是:先把数组从中间拆成两个子数组,分别对左右两半排序,然后执行合并操作,把两个有序子数组合并成一个有序数组。拆分的过程十分直观:

function mergeSort(arr) { if (arr.length <= 1) { return arr; } const mid = Math.floor(arr.length / 2); const left = mergeSort(arr.slice(0, mid)); const right = mergeSort(arr.slice(mid)); return merge(left, right); }

这个模式最棒的地方在于,递归负责拆和排序,真正干活的反而是最后的 merge 合并函数。它把一个难以理解的整体排序问题,变成了“两个有序数组合并”这个相对简单的小问题。

2.3 二维网格:数组升级成地图,递归变成DFS

数组一维的时候,递归只需要管理一个下标。数组变成二维网格后,递归管理的就变成两个坐标:row 和 col。这种模式本质是深度优先搜索(DFS)。

最经典的例题是“岛屿数量”:给定一个仅包含'1'和'0'的二维网格,'1'表示陆地,'0'表示水,统计网格中岛屿的数量。常规解法是遍历每个格子,遇到陆地就把计数器加一,然后把这块陆地“染成水”,避免后续重复统计:

function numIslands(grid) { let count = 0; for (let i = 0; i < grid.length; i++) { for (let j = 0; j < grid[0].length; j++) { if (grid[i][j] === '1') { count++; dfs(grid, i, j); } } } return count; } function dfs(grid, row, col) { if ( row < 0 || row >= grid.length || col < 0 || col >= grid[0].length || grid[row][col] !== '1' ) { return; } grid[row][col] = '0'; dfs(grid, row - 1, col); dfs(grid, row + 1, col); dfs(grid, row, col - 1); dfs(grid, row, col + 1); }

这个写法里有三个关键点。

第一,四个方向的递归调用连续执行,顺序是上、下、左、右,但其实顺序无所谓,重要的是覆盖全部方向。

第二,每次把当前格子从'1'改成'0',这一步叫“标记已访问”。如果没有这一行,递归会在上下左右四个方向来回打转,两个相邻的陆地会在彼此之间无限递归,最终栈溢出。二维数组的DFS里,visited标记或者原地修改是必须的,这是保命代码。

第三,边界判断必须写在递归函数的第一行。因为四个方向的递归调用很容易越界,比如row - 1可能变成 -1,此时必须直接返回。很多人在分支里判断边界,导致代码重复且容易漏掉某个方向。不如在函数入口统一做一次,后面写递归调用就干净得多。

2.4 回溯生成:递归过程中不断改数组、再还原

回溯模式是用来生成结果的,典型场景是全排列、组合、子集、括号生成。和前面几种模式不同,回溯递归往往需要维护一个“路径数组”,每深入一层就往路径数组里追加一个元素,返回上一层时再把这个元素弹出。所以它不只是“读数组”,而是在递归过程中“改数组”。

以全排列为例:

function permute(nums) { const result = []; const path = []; const used = new Array(nums.length).fill(false); backtrack(nums, used, path, result); return result; } function backtrack(nums, used, path, result) { if (path.length === nums.length) { result.push([...path]); return; } for (let i = 0; i < nums.length; i++) { if (used[i]) { continue; } used[i] = true; path.push(nums[i]); backtrack(nums, used, path, result); path.pop(); used[i] = false; } }

全排列的递归树是所有排列组合问题的基础。每一层递归负责选择一个当前可用的数字,然后把选择标记为 used,继续下一层。当路径长度和数组长度相等时,就收集到一个排列。

这个模式有两个非常隐蔽的坑。

第一个坑是result.push(path)而不是result.push([...path])。如果直接把 path 塞进 result,后续 pop 操作会修改同一个数组,最终 result 里存的全是同一个数组的引用,打印出来会发现所有排列都一样。这个坑我见过太多人踩了,代码逻辑看起来对,但是结果就是不对。解决方法是拷贝一份:[...path]。

第二个坑是“选择”和“撤销”必须成对出现。选了used[i] = true; path.push(nums[i]),回溯回来就必须path.pop(); used[i] = false;。漏掉任何一行,要么结果错误,要么 generated 的结果重复。这个对称性是回溯算法的核心:进入递归前修改状态,离开递归后还原状态。

2.5 记忆化递归:数组当缓存表,把重复计算干掉

递归的一大痛点是重复计算。拿斐波那契数列来说,fib(5)会调用fib(4)和fib(3),而fib(4)又会调用fib(3)和fib(2),其中fib(3)被重复计算了两次。这个重复计算随着 n 变大呈指数级增长,复杂度直接炸掉。

解决办法很简单:用一个数组当备忘录,把已经算过的结果存进去。以后每次递归之前先查表,表里有就直接返回,没有才真正计算:

function fib(n) { const memo = new Array(n + 1).fill(-1); return dfsMemo(n, memo); } function dfsMemo(n, memo) { if (n <= 1) { return n; } if (memo[n] !== -1) { return memo[n]; } memo[n] = dfsMemo(n - 1, memo) + dfsMemo(n - 2, memo); return memo[n]; }

这里的备忘录数组memo,下标就是问题规模 n,存的值就是fib(n)的答案。这个模式把递归的时间从 O(2^n) 降到 O(n),空间 O(n)。动态规划的“状态 + 转移”,本质就是记忆化递归的反向写法。所以数组在这种场景里,其实扮演了“缓存表”的角色。

我实际写递归的时候,只要发现递归函数里同一个参数反复出现,第一反应就是开一个数组做备忘录。很多超时问题,往往不是算法思路错了,而是没有加这一层缓存。

3. 递归数组代码的性能真相与优化方向

3.1 函数调用栈:看不见的隐形开销

递归每次函数调用,系统都要把当前函数的参数、局部变量、返回地址压入调用栈。递归深度越大,栈占用越大。这不仅仅是内存问题,也是性能问题。函数调用本身有开销,调用一层、两层无所谓,但如果数组长度十万,线性递归深度十万,程序大概率直接栈溢出。

不同语言对递归深度的限制不一样。Python 默认递归深度限制是 1000 左右;JavaScript 不同引擎有差异,Chrome 下栈溢出点受内存和调用栈帧大小影响。在我实际写的业务代码里,数组长度可能轻松上千甚至上万,如果还硬要用线性递归扫描整个数组,风险非常大。所以在写递归之前,先估算一下递归深度到底是多少,是一个非常值得养成的习惯。

递归深度和数组长度的关系需要分类来看。线性扫描型递归,深度等于数组长度;区间分治型递归,深度是 log2(n);二维网格DFS递归,最坏情况深度等于网格中所有可访问格子的总数;回溯生成型递归,深度等于路径数组的最大长度。预估深度是选型的重要依据。

3.2 尾递归优化,别抱太大希望

有一种说法是:递归调用放在函数末尾,编译器会自动把它优化成循环,不会增加栈深度。这叫尾递归优化。听起来很美好,但现实情况很复杂。

JavaScript 规范里确实有尾调用优化的概念,但需要在严格模式(strict mode)下,而且实际引擎支持情况不一。问题在于,普通数组递归想写成真正的尾递归并不容易,因为很多操作的返回值还需要做合并处理。比如数组求和,return arr[index] + sumArray(arr, index + 1)并不是尾递归,因为当前元素和递归结果还需要相加,这要求当前函数的栈帧在递归返回后继续存活。

真正的尾递归求和需要额外传一个累计参数:

function sumTail(arr, index, acc) { if (index >= arr.length) { return acc; } return sumTail(arr, index + 1, acc + arr[index]); }

但即便如此,Python 依然不会把尾递归优化成循环。所以我的建议是:不要把性能优化押在尾递归上。当递归深度有风险时,直接转迭代,不要抱侥幸心理。

3.3 备忘录数组如何改变复杂度

备忘录数组是递归性能优化里性价比最高的手段之一。它的本质是以空间换时间,把重复计算的中间结果保存下来,让每个子问题只计算一次。

以斐波那契为例,没有备忘录的递归调用展开后是一棵二叉树,节点数量约等于 2^n,复杂度接近 O(2^n)。加了备忘录之后,每个 n 只计算一次,复杂度变成 O(n)。这个差距在 n = 40 时就已经很明显:无备忘录的递归程序可能跑几秒,有备忘录的几乎是瞬间完成。

理解备忘录数组要注意一个细节:memo 的下标就是递归函数的关键参数。当递归状态是一个整数参数时,备忘录就是普通数组;当递归状态变成左边界和右边界两个参数时,备忘录就需要二维数组;如果是二维网格上的DFS,状态是 (row, col),备忘录同样用二维数组。数组形态随状态维度变化,这是动态规划里“状态定义”的雏形。

3.4 递归转迭代的三板斧

递归虽然可读性好,但工程上不是什么时候都适合用。深度大、性能要求高的场景,通常要转成迭代。我自己常用的方法有三种。

最简单的是“线性递归转循环”。比如数组求和,用 for 循环遍历一遍,直接消掉递归,不需要维护任何栈结构。

第二种是“递归转显式栈”。DFS 本来依赖系统调用栈,如果不想递归,就自己创建一个栈数组,用 while 循环往栈里压入待访问的格子或区间。显式栈的好处是栈深度自己可控,坏处是代码会复杂一些。

第三种是“区间递归转双指针”。二分查找递归转成 while 循环就是标准写法:

function binarySearchIterative(arr, target) { let left = 0; let right = arr.length - 1; while (left <= right) { const mid = left + Math.floor((right - left) / 2); if (arr[mid] === target) { return mid; } if (arr[mid] > target) { right = mid - 1; } else { left = mid + 1; } } return -1; }

递归转迭代的核心思路永远只有一个:模拟递归过程中系统栈在干的事。只要把当前状态(数组区间、访问标记、当前路径)显式地维护起来,递归就可以被迭代完全替代。

4. 递归还是迭代?我的一份实用选型清单

4.1 选型前先问自己的四件事

我写数组相关代码时,遇到一个任务不会立刻决定用递归还是迭代,而是先问自己四个问题。

第一问:递归深度可控吗?二叉树遍历这类深度通常只有 log n 级别,用递归很安全。线性扫描数组求最大值,递归深度等于数组长度,一万个元素的数组可能就危险了。

第二问:代码可读性重要吗?业务代码里,后续维护者可能不止我一个人。递归对某些开发者来说理解成本高,如果团队普遍不熟递归,迭代可能更适合。

第三问:性能要求有多高?递归有函数调用开销,即使能跑,在高频调用的场景下也未必合适。写算法判断“能不能过”和写工业代码判“稳不稳定”,标准不一样。

第四问:有没有现成的迭代方案?很多数组问题其实用双指针、滑动窗口、for 循环就能解决,根本不需要递归。硬套递归反而增加复杂度。

这四个问题问完,选择基本就清晰了。

4.2 直接对照表格,节省决策时间

我把常见的数组场景和推荐方案整理成一张表,方便直接照做:

场景递归深度推荐方式原因
数组求和、求最大最小值等于数组长度迭代(for/while)深度不可控,递归收益低
二分查找(有序数组)log n递归或迭代均可深度小,递归代码更直观
归并排序log n递归分治结构天然适合递归
二叉树的前中后序遍历树高递归代码极简,可读性极高
全排列、组合、子集路径长度递归回溯迭代模拟回溯非常复杂
二维网格DFS(岛屿、迷宫)格子总数递归优先方向递归直观,注意深度
大数组线性扫描数组长度迭代栈溢出风险,性能更优

表格里的规律其实就一句话:如果递归深度是数组长度的线性规模,优先迭代;如果递归深度是 log 级或结构上天然分叉,递归会更好写。

4.3 一个折中策略:先递归,超限再迭代

尽管我上面给了建议,但实际开发中经常会遇到“某个方法用递归写起来最直观,但不确定数据规模会不会爆”的情况。我的习惯是分两步走。

第一步,先用递归实现,保证逻辑正确。递归版本通常短,核心思路清晰,便于 review 和测试。第二步,评估数据规模。如果最大长度可控,比如最多几十个元素,递归上线没问题;如果长度可能上万或者无法预估,就写一个迭代版本替换。

这样做的原因是,递归版本更容易作为“参考实现”去对齐测试结果。等迭代版本写出来,两边的结果可以互相验证。我在做线段树、树形数据遍历这些场景时,经常最早写递归版本验证算法正确性,再决定是否改成迭代或维护显式栈。这种先对后优的做法,能省下不少调试时间。

5. 常见报错与排查技巧实录

5.1 栈溢出:先查是不是“无限递归”

递归和数组结合时最常见的报错就是栈溢出(Stack Overflow)。我排查时第一个想法永远是:循环没走出来,即基线条件永远不满足。

比如下面的代码,想逆序打印数组,但条件写反了:

function printReverse(arr, index = 0) { if (index < arr.length) { return; } printReverse(arr, index + 1); console.log(arr[index]); }

index < arr.length在 index 为 0 时直接返回,根本不会递归,当然也不会栈溢出。真正容易踩的是另一种:基线条件判断的边界不对,导致永远无法触底。

排查栈溢出时,最快的手段是打印递归参数。在函数一开始输出console.log(index, left, right, row, col),看到底停在哪一层、参数有没有变化。如果参数一直在原地打转,说明递归关系写错了;如果参数持续增长但始终达不到条件,说明基线条件漏了。

5.2 数组越界:习惯在函数入口放边界判断

数组下标越界也是递归数组代码里高频的报错。常见原因有两个:一个是区间参数 left、right 处理不当,另一个是二维网格的行列坐标没有做边界检查。

区间递归里的经典错误是二分查找中mid - 1和mid + 1导致 left 和 right 交错。如果基线条件写的是if (left === right)而不是if (left > right),当 left 被改成mid + 1且 mid 等于 right 时,就会出现 left 大于 right,但递归调用的参数已经不是合法区间。

二维网格的越界更隐蔽。某个方向的递归导致 row 变成 -1,或者 col 超过列数,代码在访问grid[row][col]时才崩。我的习惯是:在 dfs 函数第一行写边界判断,把越界情况统一拦截掉。这样四个方向的递归调用可以放心写,不用每个方向都单独判断一次。

5.3 结果不对或超时:按这个顺序排查

遇到“代码跑得通,但结果不对”的问题,我的排查顺序基本是固定的。

先看基线条件返回的值是否正确。二分查找找不到元素返回 -1,但如果你返回了 0,结果看起来会“差不多”,实际差得很远。

再看递归返回值处理是否正确。回溯类型的问题尤其要注意 result 里收集的是数组引用,还是数组拷贝。全排列问题里十个有九个错误是出在result.push(path)上。

接着看状态有没有还原。回溯递归里如果只做了path.push没有做path.pop,路径会越加越长,生成的结果乱七八糟。

最后看有没有重复计算。如果程序跑起来越来越慢,大概率是递归重复处理了同一个子问题。开一个备忘录数组优化一下,往往立刻就不超时了。

5.4 递归调试三板斧:缩进打印、小用例、画递归树

调试递归是很多人的噩梦,因为函数调用链太深,普通断点调试根本追不过来。我自己的做法是“三板斧”。

第一板斧是缩进打印。写一个辅助函数,根据递归深度打印带缩进的日志,比如console.log(' '.repeat(depth), '进入:', index)。这样能直观看到递归一层的执行顺序和返回值。亲测对理解递归过程极有帮助。

第二板斧是构造最小用例。不要一上来就测一个大数组,先用长度 0、1、2、3 的小数组测试,验证基线条件和边界是否正确。小用例跑通了,再逐步加大输入。不少递归问题在小规模下就能暴露大部分错误。

第三板斧是画递归树。拿全排列来说,n = 3 时的递归树只有三层。在纸上把每层选择都画出来,和代码的实际输出对照,错在哪一步很快就清楚了。画递归树不是为了背诵,而是为了核对递归的分支逻辑是否符合预期。

6. 一点个人体会与练习建议

写完这些内容,我自己最大的感受是:递归和数组的配合,不只是算法题的考点,更是很多工程场景里的基础能力。树形数据转数组、嵌套列表扁平化、多维数组遍历、分页树结构搜索,底层都是同一个东西。

我练这个主题的推荐路径很明确:先做线性递归,把求和、逆序输出这种题写熟;再做二分和归并,体会区间分治;接着上回溯生成,把全排列吃透;最后做二维网格题,巩固 DFS 和边界处理。每一步都动手写,不要只看题解。

最后再分享一个我自己的小技巧:写递归函数时,永远先写基线条件,再写递归调用,再写返回值处理。这个顺序能强制自己在动笔前想清楚“什么时候停”和“怎么停”,而这两个问题恰恰是绝大多数递归 Bug 的根源。写多了之后你会发现,递归真不是什么玄学,它就是“把大问题切成结构相同的小问题”这么简单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询