用递归生成各种集合是前端和Node开发中很常见的需求,比如求数组的所有子集、全排列,或者把树形结构拍平。很多人在实现时发现,明明每一层递归都往结果里塞了不同的数组,最后打印出来却全都不一样,甚至全部变成了同一份数据。这个问题的核心不在于递归写错,而在于对JavaScript里数组是引用类型理解不够深。

一、引用类型在递归中的真实表现
JavaScript的数组、对象都是引用类型。当你把一个数组赋值给另一个变量,或者当作参数传进函数,两个变量指向的是堆内存中的同一个对象。在递归场景里,如果父函数把自己维护的数组直接推进结果集,子函数又继续修改这个数组,那么结果集里存的所有引用都会同步变化。
看一段有问题的代码。下面这个函数想生成输入数组的所有子集,但结果全部错乱:
function subsetsWrong(nums) {
var result = [];
var current = [];
function dfs(start) {
// 直接把current推进去,存的是引用
result.push(current);
for (var i = start; i < nums.length; i++) {
current.push(nums[i]);
dfs(i + 1);
current.pop();
}
}
dfs(0);
return result;
}
console.log(subsetsWrong([1, 2]));
// 输出类似 [[], [], [], []] 或全是空,因为current最后被pop光了
上面代码中,result.push(current) 放进去的是同一个数组对象的引用。递归结束之后,current 经过一系列 pop 变回空数组,于是 result 里每一项都指向这个空数组。这就是典型的数组引用陷阱。
要避免这个问题,就必须在推入结果时,给每一层递归一个独立的数组副本。这就引出了深浅拷贝的概念:浅拷贝只复制第一层,深拷贝会递归复制所有嵌套结构。
二、浅拷贝在单层集合生成中的用法
如果生成的集合里只有基本类型元素,或者只需要独立的第一层数组,浅拷贝就足够了。JavaScript里常用的浅拷贝方式有 slice()、展开运算符 [...arr],以及 Array.from()。
把前面的错误代码改成浅拷贝版本:
function subsetsRight(nums) {
var result = [];
var current = [];
function dfs(start) {
// 推入当前数组的浅拷贝副本
result.push(current.slice());
for (var i = start; i < nums.length; i++) {
current.push(nums[i]);
dfs(i + 1);
current.pop();
}
}
dfs(0);
return result;
}
console.log(subsetsRight([1, 2]));
// 输出 [[], [1], [1, 2], [2]] 符合预期
这里 current.slice() 创建了一个新数组,内容和 current 当时一样,但地址不同。后续对 current 的 push 和 pop 不会影响已经推入 result 的副本。这种方式在子集、组合类题目中非常高效,因为只需要复制一层。
不过浅拷贝有局限。如果数组里嵌套了对象或其他数组,浅拷贝只复制了外层,内层的引用仍然共享。在生成嵌套集合时,修改其中一个子对象会影响其他副本。
三、深拷贝应对嵌套结构的集合生成
当递归生成的集合包含多层对象,比如克隆树节点路径、生成带有子项的配置组合,就必须用深拷贝。最简单直观的做法是 JSON.parse(JSON.stringify(obj)),但它会丢失函数、undefined、循环引用。
更稳妥的方式是使用现代运行环境中的 structuredClone,或者自己写递归拷贝函数。下面用 structuredClone 演示一个嵌套数组的排列生成:
function generateNestedGroups(items) {
var result = [];
var path = [];
function dfs(index) {
// 深拷贝当前路径,避免嵌套引用问题
result.push(structuredClone(path));
for (var i = index; i < items.length; i++) {
path.push({ id: items[i], children: [] });
dfs(i + 1);
path.pop();
}
}
dfs(0);
return result;
}
console.log(generateNestedGroups([1, 2]));
在上面的代码里,path 的每一项都是对象,如果用浅拷贝,result 里所有路径中的对象都会指向同一批内存。structuredClone 会连同对象内部一起复制,保证每个结果独立。
需要注意,深拷贝性能开销比浅拷贝大。在元素数量很大、嵌套很深的场景,要权衡是否真的需要深拷贝,或者能否通过重新构造对象来规避引用共享。
四、根据场景选择拷贝策略
实际开发中可以按下面原则判断:
- 集合元素全是数字、字符串等原始类型:用 slice 或展开运算做浅拷贝即可。
- 集合包含一层对象但对象内部不再变:浅拷贝通常够用,前提是不要去改对象属性。
- 集合里的对象在递归中被修改或嵌套多层:使用 structuredClone 或手写深拷贝。
- 对性能极度敏感且数据规模大:考虑不在递归中存副本,而是用回溯结束时构造新数组。
还有一个常见写法是直接在递归参数里传新数组,这样连拷贝函数都不用显式调用:
function subsetsParam(nums) {
var result = [];
function dfs(start, current) {
result.push(current);
for (var i = start; i < nums.length; i++) {
// 每次递归传一个新数组,天然隔离
dfs(i + 1, current.concat(nums[i]));
}
}
dfs(0, []);
return result;
}
这种方式利用 concat 返回新数组的特性,把状态通过参数传递,父层完全不持有可变数组,从根源上避免了引用陷阱。代价是会产生更多中间数组,但在多数业务规模下可以接受。
理解引用机制和深浅拷贝差异,是写好递归集合生成逻辑的基础。只要记住:结果集要存副本,不要在多层之间共享同一个可变数组,就能绕开绝大多数坑。
JavaScript递归算法深拷贝修改时间:2026-08-07 17:36:20