在编写排列组合相关的算法时,我们经常会遇到需要存储多个结果的情况。特别是在使用深度优先搜索(DFS)生成全排列时,如何正确地将中间结果添加到结果集合中,是一个容易被忽视却至关重要的细节。今天,我们将通过一个具体的代码示例,深入探讨 list.add(ans) 和 list.add(new ArrayList<>(ans)) 的区别,以及为什么在某些场景下选择后者是更安全的做法。
示例代码
先来看一个典型的生成数组全排列的代码:
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> list = new ArrayList<>();
List<Integer> ans = Arrays.asList(new Integer[nums.length]);
boolean[] flag = new boolean[nums.length];
dfs(0, list, ans, flag, nums);
return list;
}
public void dfs(int i, List<List<Integer>> list, List<Integer> ans, boolean[] flag, int[] nums) {
if (i == nums.length) {
list.add(ans); // 这里是关键点
return;
}
for (int j = 0; j < nums.length; j++) {
if (!flag[j]) {
flag[j] = true;
ans.set(i, nums[j]);
dfs(i + 1, list, ans, flag, nums);
flag[j] = false;
}
}
}
}
这是一个基于DFS的经典全排列算法,输入一个整数数组 nums,输出它的所有可能排列。代码中,list 是一个存储所有排列的列表,而 ans 是一个临时列表,用于在递归过程中构建单个排列。
但在这段代码中,list.add(ans) 的写法是有问题的。我们将通过分析它的行为,以及与 list.add(new ArrayList<>(ans)) 的对比,来揭示问题的根源。
list.add(ans) 的问题
在Java中,List.add() 方法会将传入的对象引用添加到列表中。也就是说,list.add(ans) 并不是将 ans 的当前内容复制一份存储到 list 中,而是直接将 ans 这个对象的引用添加到了 list 中。
在上面的代码中,ans 是一个单一的 List<Integer> 对象,它在整个DFS过程中被反复使用和修改。具体来说:
- 在递归的每一层,
ans.set(i, nums[j])会修改ans的内容。 - 当递归到底(
i == nums.length)时,list.add(ans)将ans的引用加入到list中。 - 但由于
ans是同一个对象,后续的递归回溯会继续修改它,导致之前添加到list中的排列也会被覆盖。
举个例子
假设输入 nums = [1, 2],我们期望输出 [[1, 2], [2, 1]]。但使用 list.add(ans) 的代码执行过程如下:
- 第一条路径:
ans = [1, 2],list.add(ans),此时list = [[1, 2]]。 - 回溯后,
ans被修改为[2, 1],list.add(ans),但由于list中存储的是ans的引用,list变成了[[2, 1], [2, 1]]。
最终结果全是最后一个排列 [2, 1],之前的 [1, 2] 被覆盖了。这显然不是我们想要的。
list.add(new ArrayList<>(ans)) 的解决方案
为了修复这个问题,我们可以使用 list.add(new ArrayList<>(ans))。它的核心区别在于:
new ArrayList<>(ans)会创建一个新的ArrayList,并将ans的当前内容复制到这个新对象中。list.add()添加的是这个新对象的引用,而不是原始的ans。
这样,每次递归到底时,添加到 list 中的是一个独立的副本,后续对 ans 的修改不会影响到之前添加的结果。
再次举例
还是输入 nums = [1, 2]:
- 第一条路径:
ans = [1, 2],list.add(new ArrayList<>(ans)),此时list = [[1, 2]]。 - 回溯后,
ans修改为[2, 1],list.add(new ArrayList<>(ans)),list = [[1, 2], [2, 1]]。
结果正确地保留了所有排列。
代码中的另一个问题
值得一提的是,原代码中还有一个潜在问题:List<Integer> ans = Arrays.asList(new Integer[nums.length]) 创建的列表是不可变的(Arrays.asList() 返回的列表不支持 set() 操作)。这会导致运行时抛出 UnsupportedOperationException。正确的初始化应该是:
List<Integer> ans = new ArrayList<>(Collections.nCopies(nums.length, 0));
这里用 Collections.nCopies 创建一个初始值为0的列表,或者直接用 new ArrayList<>() 并在需要时动态添加元素。
修正后的完整代码
以下是修复后的代码:
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> list = new ArrayList<>();
List<Integer> ans = new ArrayList<>(Collections.nCopies(nums.length, 0));
boolean[] flag = new boolean[nums.length];
dfs(0, list, ans, flag, nums);
return list;
}
public void dfs(int i, List<List<Integer>> list, List<Integer> ans, boolean[] flag, int[] nums) {
if (i == nums.length) {
list.add(new ArrayList<>(ans)); // 创建副本
return;
}
for (int j = 0; j < nums.length; j++) {
if (!flag[j]) {
flag[j] = true;
ans.set(i, nums[j]);
dfs(i + 1, list, ans, flag, nums);
flag[j] = false;
}
}
}
}
总结
list.add(ans):直接添加对象的引用,后续修改会影响所有已添加的结果,通常会导致错误。list.add(new ArrayList<>(ans)):添加一个独立的副本,确保每次添加的结果不会被后续修改覆盖。
这个区别的核心在于Java的对象引用机制。在需要保存中间状态的递归算法中,时刻注意是否需要创建副本,是避免bug的关键。希望这篇博客能帮你更好地理解这个问题,并在未来的编码中避开类似的陷阱!