资讯动态

递归与数组:五大核心模式、性能优化与实战避坑指南

发布时间:2026/10/16 7:35:54 来源:尧图企业网站定制
递归和数组这两个词放在一起几乎是算法学习里绕不开的“地基组合”。灰度图像处理里我天天用递归遍历二维数组业务逻辑里用递归处理树形菜单和嵌套评论经典算法里二分查找、归并排序、全排列、岛屿数量本质全都是在“递归操作数组”。我自己见过太多人把递归学成玄学一见到递归就画递归树、背模板但换个题目还是不会。问题不在于递归本身难而在于没有把递归和数组的下标关系想明白。这篇内容我打算换个思路不堆概念直接讲清楚数组为什么会天然适配递归数组上最常见的递归模式有哪几类代码怎么写坑在哪里以及递归深度、性能、栈溢出这些实际问题怎么处理。适合刚接触递归想系统地搞懂它的人也适合写了几年代码但遇到递归还是发怵的开发者。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(index1)对于二分就是f(left, right) f(left, mid-1) f(mid1, 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 的根源。写多了之后你会发现递归真不是什么玄学它就是“把大问题切成结构相同的小问题”这么简单。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑