从全排列问题看深浅拷贝:Java中List.add的陷阱——浅谈list.add(ans)与list.add(new ArrayList<>(ans))的区别

147 阅读4分钟

在编写排列组合相关的算法时,我们经常会遇到需要存储多个结果的情况。特别是在使用深度优先搜索(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) 的代码执行过程如下:

  1. 第一条路径:ans = [1, 2]list.add(ans),此时 list = [[1, 2]]
  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]

  1. 第一条路径:ans = [1, 2]list.add(new ArrayList<>(ans)),此时 list = [[1, 2]]
  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的关键。希望这篇博客能帮你更好地理解这个问题,并在未来的编码中避开类似的陷阱!