JavaScript 递归入门:从 1 到 n 求和,再到数组扁平化

递归是很多人学算法时的第一个坎。

循环很好理解,for 从 1 跑到 n,代码一步一步执行。但递归不一样,它会出现"函数调用自己"的情况,看起来像绕了一圈。

这篇文章用两个小例子把递归讲清楚:

  • 1 + 2 + 3 + ... + n
  • 手写数组扁平化 flatten

这两个例子都不复杂,但很适合理解递归的核心:把一个大问题拆成更小的同类问题。

一、先用循环求和

如果要求:

复制代码
1 + 2 + 3 + ... + n

最容易想到的是循环。

ini 复制代码
function sum(n) {
  let total = 0;

  for (let i = 1; i <= n; i++) {
    total += i;
  }

  return total;
}

console.log(sum(5)); // 15

这段代码很好理解。

n = 5 时:

makefile 复制代码
total = 0
total += 1
total += 2
total += 3
total += 4
total += 5

最后得到:

复制代码
15

循环是从小到大累加。

递归的思路不一样。

二、递归怎么想

还是求:

复制代码
1 + 2 + 3 + ... + n

我们可以换一种写法:

scss 复制代码
sum(n) = n + sum(n - 1)

比如:

scss 复制代码
sum(5) = 5 + sum(4)
sum(4) = 4 + sum(3)
sum(3) = 3 + sum(2)
sum(2) = 2 + sum(1)
sum(1) = 1

这就是递归的味道。

一个大问题:

scss 复制代码
sum(5)

被拆成了一个更小的同类问题:

scss 复制代码
sum(4)

继续拆:

scss 复制代码
sum(3)
sum(2)
sum(1)

直到遇到最小问题:

scss 复制代码
sum(1) = 1

三、递归求和代码

scss 复制代码
function sum(n) {
  if (n === 1) {
    return 1;
  }

  return n + sum(n - 1);
}

console.log(sum(5)); // 15

这段代码有两个重点。

第一个是递归出口:

ini 复制代码
if (n === 1) {
  return 1;
}

第二个是递归公式:

bash 复制代码
return n + sum(n - 1);

递归一定要有出口。

如果没有出口,函数会一直调用自己,最后栈溢出。

四、递归执行过程

看这行代码:

scss 复制代码
sum(5)

它会展开成:

scss 复制代码
sum(5)
= 5 + sum(4)
= 5 + 4 + sum(3)
= 5 + 4 + 3 + sum(2)
= 5 + 4 + 3 + 2 + sum(1)
= 5 + 4 + 3 + 2 + 1
= 15

如果从函数调用角度看,是这样的:

scss 复制代码
sum(5)
  sum(4)
    sum(3)
      sum(2)
        sum(1)

sum(1) 时,递归停止。

然后结果再一层一层返回:

scss 复制代码
sum(1) 返回 1
sum(2) 返回 2 + 1
sum(3) 返回 3 + 3
sum(4) 返回 4 + 6
sum(5) 返回 5 + 10

最后得到 15

这就是递归里常说的:

复制代码
先递,再归

递,是不断往下拆。

归,是结果一层一层返回。

五、递归三要素

写递归时,我会先问自己三个问题。

1. 这个问题能不能拆成同类小问题

比如:

scss 复制代码
sum(n) = n + sum(n - 1)

sum(n)sum(n - 1) 是同一类问题,只是规模变小了。

2. 递归出口是什么

求和问题里,最小情况是:

scss 复制代码
sum(1) = 1

所以出口是:

ini 复制代码
if (n === 1) return 1;

3. 每次递归有没有让问题变小

scss 复制代码
sum(n - 1)

每次都让 n 减 1。

这样最终一定能走到 n === 1

如果问题规模没有变小,递归就会停不下来。

六、数组扁平化是什么

接着看第二个例子:数组扁平化。

比如有一个嵌套数组:

ini 复制代码
const arr = [1, [2, [3, 4, 5, [5, 6]]]];

我们希望把它变成:

csharp 复制代码
[1, 2, 3, 4, 5, 5, 6]

这种操作就叫数组扁平化。

JavaScript 原生提供了 flat 方法:

ini 复制代码
const arr = [1, [2, [3, 4, 5, [5, 6]]]];

console.log(arr.flat(Infinity));

输出:

csharp 复制代码
[1, 2, 3, 4, 5, 5, 6]

Infinity 表示不管嵌套多少层,都全部展开。

但如果想练递归,手写一版 flatten 更有价值。

七、手写 flatten

ini 复制代码
const flatten = (arr) => {
  let result = [];

  arr.forEach((item) => {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  });

  return result;
};

const arr = [1, [2, [3, 4, 5, [5, 6]]]];

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

这段代码的核心判断是:

javascript 复制代码
Array.isArray(item)

如果当前元素还是数组,就继续递归展开。

如果当前元素不是数组,就直接放进结果数组。

八、flatten 的递归思路

数组:

csharp 复制代码
[1, [2, [3, 4, 5, [5, 6]]]]

从左到右遍历。

第一个元素是:

复制代码
1

不是数组,直接放进结果:

ini 复制代码
result = [1]

第二个元素是:

csharp 复制代码
[2, [3, 4, 5, [5, 6]]]

它还是数组,所以不能直接 push。

应该继续调用:

scss 复制代码
flatten([2, [3, 4, 5, [5, 6]]])

这就是递归。

因为里面还是同一个问题:

复制代码
把一个数组拍平

只是数组规模更小了。

九、为什么用 concat

代码里有一行:

ini 复制代码
result = result.concat(flatten(item));

这里不能简单写成:

ini 复制代码
result.push(flatten(item));

因为 flatten(item) 返回的是一个数组。

如果用 push,会把整个数组作为一个元素塞进去。

比如:

ini 复制代码
const result = [1];
result.push([2, 3]);

console.log(result); // [1, [2, 3]]

这不是扁平化。

concat 会把两个数组连接起来:

ini 复制代码
const result = [1];
const next = result.concat([2, 3]);

console.log(next); // [1, 2, 3]

所以递归展开数组时,用 concat 更合适。

十、递归版 flatten 的执行过程

以这个数组为例:

csharp 复制代码
[1, [2, [3, 4, 5, [5, 6]]]]

执行过程大概是:

scss 复制代码
flatten([1, [2, [3, 4, 5, [5, 6]]]])

遇到 1,放入 result
遇到 [2, [3, 4, 5, [5, 6]]],继续 flatten

flatten([2, [3, 4, 5, [5, 6]]])

遇到 2,放入 result
遇到 [3, 4, 5, [5, 6]],继续 flatten

flatten([3, 4, 5, [5, 6]])

遇到 3,放入 result
遇到 4,放入 result
遇到 5,放入 result
遇到 [5, 6],继续 flatten

flatten([5, 6])

遇到 5,放入 result
遇到 6,放入 result
返回 [5, 6]

最后所有结果一层层合并,得到:

csharp 复制代码
[1, 2, 3, 4, 5, 5, 6]

十一、递归和树结构

递归很适合处理树状结构。

嵌套数组其实就像一棵树:

csharp 复制代码
[1, [2, [3, 4]]]

可以想成:

markdown 复制代码
数组
├── 1
└── 数组
    ├── 2
    └── 数组
        ├── 3
        └── 4

只要数据结构里存在"里面还有同类结构"的情况,就很容易用递归。

比如:

复制代码
数组里面还有数组
目录里面还有目录
评论下面还有回复
树节点下面还有子节点

这些都适合用递归思考。

十二、递归和循环怎么选

不是所有问题都必须用递归。

1 + 2 + ... + n,循环其实更简单,也更省性能。

但递归的价值在于,它能更自然地表达某些结构。

比如数组扁平化、树遍历、文件目录扫描,这类问题用递归会更顺。

简单判断:

复制代码
线性重复:优先循环
嵌套结构:优先考虑递归

当然,递归也有成本。

每一次函数调用都会进入调用栈。

如果递归层级太深,可能会栈溢出。

十三、容易写错的地方

1. 忘记递归出口

错误写法:

bash 复制代码
function sum(n) {
  return n + sum(n - 1);
}

这会一直调用下去。

正确写法:

bash 复制代码
function sum(n) {
  if (n === 1) return 1;
  return n + sum(n - 1);
}

2. 递归时问题规模没有变小

错误写法:

bash 复制代码
function sum(n) {
  if (n === 1) return 1;
  return n + sum(n);
}

这里一直调用 sum(n)n 没有变化。

正确写法:

bash 复制代码
return n + sum(n - 1);

3. flatten 里把数组 push 进去

错误写法:

ini 复制代码
result.push(flatten(item));

这样会得到嵌套数组。

正确写法:

ini 复制代码
result = result.concat(flatten(item));

4. 不判断是不是数组

如果没有:

javascript 复制代码
Array.isArray(item)

就无法区分当前元素应该继续展开,还是直接放入结果。

十四、总结

递归不是魔法。

它的本质就是:

复制代码
把一个问题,拆成更小的同类问题

写递归时,先想三件事:

复制代码
递归公式是什么
递归出口是什么
问题规模有没有变小

求和问题里:

scss 复制代码
sum(n) = n + sum(n - 1)
sum(1) = 1

数组扁平化里:

sql 复制代码
如果元素是数组,就继续 flatten
如果元素不是数组,就放进 result
相关推荐
蝎子莱莱爱打怪2 小时前
XZLL-IM干货系列 04|Netty 长连接实战:Pipeline 怎么排、心跳怎么跳、连接怎么管
后端·微服务·面试
还有多久拿退休金3 小时前
一个 var 让整个团队加班到凌晨——JS 闭包的那些暗坑
前端·javascript
weedsfly3 小时前
用了 React/Vue 之后,这些 DOM 操作的坑你踩过几个?
前端·javascript
Asize3 小时前
Ajax 入门:从 JSON 序列化到 XMLHttpRequest
前端·javascript·前端框架
铁皮饭盒3 小时前
@kognitivedev/rag, 用js做AI Agent开发
javascript·后端
kyriewen16 小时前
别再 console.log 了:5 个 Chrome DevTools 调试技巧,用过就回不去了
前端·javascript·面试
To_OC17 小时前
LC 1 两数之和:面试第一道必考题,暴力解法直接被面试官 pass
javascript·算法·leetcode
GuWenyue19 小时前
排序效率低?5分钟吃透快速排序,性能飙升至O(nlogn)
前端·javascript·面试