LeetCode 78:子集|回溯、选与不选、递归和path快照

一、题目

给定一个整数数组:

复制代码
nums

数组中的元素互不相同。

要求返回:

所有可能的子集。

例如:

复制代码
nums = [1,2]

所有子集:

复制代码
[]
[1]
[2]
[1,2]

二、这道题考什么?

这题主要考:

复制代码
回溯
+
DFS
+
递归

最核心的思想:

对于每一个元素,都有两种选择:选,或者不选。

比如:

复制代码
nums = [1,2]

对于 1

复制代码
选1
不选1

对于 2

复制代码
选2
不选2

所以所有可能:

复制代码
不选1,不选2 → []

不选1,选2   → [2]

选1,不选2   → [1]

选1,选2     → [1,2]

三、最终代码

java 复制代码
class Solution {

    public List<List<Integer>> subsets(int[] nums) {

        List<List<Integer>> ans = new ArrayList<>();
        List<Integer> path = new ArrayList<>();

        dfs(0, nums, ans, path);

        return ans;
    }

    private void dfs(
        int i,
        int[] nums,
        List<List<Integer>> ans,
        List<Integer> path
    ) {

        if (i == nums.length) {
            ans.add(new ArrayList<>(path));
            return;
        }

        // 不选 nums[i]
        dfs(i + 1, nums, ans, path);

        // 选 nums[i]
        path.add(nums[i]);

        // 带着当前选择继续往下
        dfs(i + 1, nums, ans, path);

        // 撤销选择
        path.remove(path.size() - 1);
    }
}

四、dfs(i) 到底表示什么?

这是理解整题最重要的一点。

复制代码
dfs(i, ...)

表示:

现在轮到决定 nums[i] 选还是不选。

例如:

复制代码
nums = [1,2,3]

那么:

复制代码
dfs(0)

表示:

复制代码
决定 1 选不选

dfs(1)

表示:

复制代码
决定 2 选不选

dfs(2)

表示:

复制代码
决定 3 选不选

当:

复制代码
i == nums.length

说明:

所有数字都已经决定完了。

此时的 path 就是一个完整子集。


五、为什么"选"和"不选"代码挨着写?

代码:

复制代码
// 不选
dfs(i + 1, nums, ans, path);

// 选
path.add(nums[i]);
dfs(i + 1, nums, ans, path);

path.remove(path.size() - 1);

一开始看很容易误解成:

复制代码
先不选
然后马上又选

其实不是。

真正执行过程是:

复制代码
先把"不选"这整个分支全部走完
↓
return 回来
↓
再走"选"这整个分支

所以:

复制代码
dfs(i + 1);

不是执行一下就结束。

它可能会继续递归很多层。

直到整个"不选"分支全部搜索完成以后,才回到当前函数,继续执行:

复制代码
path.add(nums[i]);

六、递归最重要的规则:调用后会回来

例如:

复制代码
A();

B();

假设执行:

复制代码
A();

当前函数会暂时停下来。

A()

复制代码
执行完
return

以后,会回来继续执行:

复制代码
B();

回溯题也是一样。

例如:

复制代码
dfs(i + 1);

path.add(nums[i]);

意思不是:

复制代码
dfs以后就没了

而是:

复制代码
dfs(i+1)
完整执行完
↓
返回当前这一层
↓
继续 path.add(nums[i])

七、用 [1,2] 完整走一遍

我们只看:

复制代码
nums = [1,2]

开始:

复制代码
dfs(0)

此时:

复制代码
i = 0
path = []

当前决定:

复制代码
1 选不选

第一条路:不选 1

代码:

复制代码
dfs(i + 1);

也就是:

复制代码
dfs(1);

因为还没有:

复制代码
path.add(1);

所以:

复制代码
path = []

表示:

1 没选。

现在进入:

复制代码
dfs(1)

开始决定:

复制代码
2 选不选

不选 2

继续:

复制代码
dfs(2);

此时:

复制代码
path = []

而:

复制代码
i == nums.length

所以保存:

复制代码
[]

得到:

复制代码
ans = [[]]

然后:

复制代码
return;

八、return 回到哪里?

刚才是谁调用了:

复制代码
dfs(2)

就是:

复制代码
dfs(1)

所以返回以后,继续执行 dfs(1) 后面的代码:

复制代码
path.add(nums[1]);

即:

复制代码
path.add(2);

所以:

复制代码
path = [2]

九、选 2

然后继续:

复制代码
dfs(2);

此时:

复制代码
path = [2]

又到达终点。

于是保存:

复制代码
[2]

现在:

复制代码
ans =
[
  [],
  [2]
]

然后返回。


十、为什么要 remove?

返回以后:

复制代码
path.remove(path.size() - 1);

把:

复制代码
2

删除。

于是:

复制代码
path:
[2]
↓
[]

为什么?

因为刚才的:

复制代码
2

只是:

"选2"这条分支临时加入的。

现在这条分支全部走完了。

要恢复到进入 dfs(1) 时的状态:

复制代码
path = []

这就是:

回溯 / 恢复现场。


十一、然后回到 dfs(0)

dfs(1) 全部结束。

于是回到:

复制代码
dfs(0)

刚才 dfs(0) 停在:

复制代码
dfs(1);

这一行。

现在子递归回来了,所以继续:

复制代码
path.add(nums[0]);

也就是:

复制代码
path.add(1);

于是:

复制代码
path = [1]

这就表示:

现在开始走"选1"这条分支。


十二、第二次进入 dfs(1)

现在:

复制代码
dfs(1);

但这一次:

复制代码
path = [1]

所以虽然还是:

复制代码
dfs(1)

状态却和第一次不一样。

第一次:

复制代码
path = []

表示:

复制代码
没选1

第二次:

复制代码
path = [1]

表示:

复制代码
已经选1

然后再决定 2。


不选2

保存:

复制代码
[1]

选2

先:

复制代码
path.add(2);

得到:

复制代码
[1,2]

保存:

复制代码
[1,2]

最终:

复制代码
ans =
[
  [],
  [2],
  [1],
  [1,2]
]

十三、整棵递归树

复制代码
                 决定1
               /      \
            不选1      选1
             /          \
          决定2         决定2
         /    \         /    \
      不选2   选2    不选2    选2
       |       |       |       |
      []      [2]     [1]    [1,2]

所以:

复制代码
每个 dfs(i)

其实都在生成两个分支:

复制代码
不选 nums[i]

选 nums[i]

十四、为什么"选"后必须再 dfs?

一开始容易写成:

复制代码
path.add(nums[i]);

path.remove(path.size() - 1);

这是错的。

因为这样相当于:

复制代码
选进去
马上删掉

根本没有带着当前选择继续往下搜索。

正确必须:

复制代码
path.add(nums[i]);

dfs(i + 1, nums, ans, path);

path.remove(path.size() - 1);

固定模板:

复制代码
做选择
↓
递归深入
↓
撤销选择

也就是:

复制代码
add
dfs
remove

这是回溯最重要的模板。


十五、为什么"不选"不用 remove?

不选分支:

复制代码
dfs(i + 1, nums, ans, path);

我们没有修改:

复制代码
path

所以不需要恢复。

而选择分支:

复制代码
path.add(nums[i]);

修改了 path

所以递归回来以后必须:

复制代码
path.remove(path.size() - 1);

恢复。

可以记:

谁修改了共享状态,谁负责撤销。


十六、递归终止条件

复制代码
if (i == nums.length) {
    ans.add(new ArrayList<>(path));
    return;
}

什么意思?

例如:

复制代码
nums = [1,2]

下标只有:

复制代码
0
1

当:

复制代码
i = 2

说明:

复制代码
1 已经决定完
2 也已经决定完

所有元素都处理完。

此时:

复制代码
path

就是一个完整子集。

所以加入答案。


十七、ans.add(new ArrayList<>(path)) 到底是什么意思?

这句很重要:

复制代码
ans.add(new ArrayList<>(path));

拆成两部分。

先:

复制代码
new ArrayList<>(path)

表示:

创建一个新的 List,把当前 path 的内容复制进去。

例如:

复制代码
path = [1,2]

执行:

复制代码
new ArrayList<>(path)

会创建一个新的:

复制代码
[1,2]

然后:

复制代码
ans.add(...)

就是:

把这个新的子集加入答案。


十八、为什么不能直接 ans.add(path)

因为:

复制代码
path

整个回溯过程中一直是同一个对象。

后面会不断:

复制代码
path.add(...)
path.remove(...)

如果:

复制代码
ans.add(path);

那么 ans 保存的是:

path 对象本身的引用。

不是当时内容的快照。


十九、举例理解引用问题

假设:

复制代码
List<Integer> path = new ArrayList<>();
List<List<Integer>> ans = new ArrayList<>();

先:

复制代码
path.add(1);

此时:

复制代码
path = [1]

然后:

复制代码
ans.add(path);

看起来:

复制代码
ans = [[1]]

但接着:

复制代码
path.add(2);

因为:

复制代码
ans里面的那个List
和
path

其实是同一个对象。

所以 ans 也会变成:

复制代码
[[1,2]]

这不是我们想要的。


二十、所以必须复制 path

正确:

复制代码
ans.add(new ArrayList<>(path));

可以理解成:

给当前 path 拍一张照片。

例如现在:

复制代码
path = [1]

拍照:

复制代码
snapshot = [1]

加入:

复制代码
ans

后面原来的:

复制代码
path

就算变成:

复制代码
[1,2]
[]
[2]

已经保存的:

复制代码
[1]

也不会变化。

所以回溯里固定记:

复制代码
ans.add(new ArrayList<>(path));

不要:

复制代码
ans.add(path);

二十一、path 和 ans 分别干什么?

path

复制代码
List<Integer> path = new ArrayList<>();

表示:

当前正在走的这条选择路径。

例如:

复制代码
选1
选3

那么:

复制代码
path = [1,3]

ans

复制代码
List<List<Integer>> ans = new ArrayList<>();

表示:

保存所有最终结果。

例如:

复制代码
[
 [],
 [2],
 [1],
 [1,2]
]

所以:

复制代码
path

是:

复制代码
临时状态

而:

复制代码
ans

是:

复制代码
最终结果集合

二十二、Java 泛型顺便复习

复制代码
List<Integer> path

表示:

一个整数列表。

例如:

复制代码
[1,2,3]

而:

复制代码
List<List<Integer>> ans

表示:

一个"列表的列表"。

因为每一个子集本身就是:

复制代码
List<Integer>

而所有子集组合在一起就是:

复制代码
List<List<Integer>>

比如:

复制代码
[
  [],
  [1],
  [2],
  [1,2]
]

二十三、path.remove(path.size()-1) 为什么删最后一个?

我们每次选择:

复制代码
path.add(nums[i]);

都是加在列表末尾。

例如:

复制代码
path = [1]

加入:

复制代码
2

得到:

复制代码
[1,2]

回溯时,刚才加入的是最后一个:

复制代码
2

所以删除:

复制代码
path.remove(path.size() - 1);

例如:

复制代码
path.size() = 2

最后一个下标:

复制代码
2 - 1 = 1

删除:

复制代码
path[1]

也就是:

复制代码
2

恢复:

复制代码
[1]

二十四、这道题真正的回溯模板

你可以把它压缩成:

java 复制代码
private void dfs(int i) {

    if (i == nums.length) {
        保存答案;
        return;
    }

    // 不选
    dfs(i + 1);

    // 选
    path.add(nums[i]);

    dfs(i + 1);

    // 撤销
    path.remove(path.size() - 1);
}

更进一步压成:

复制代码
不选:
dfs

选:
add
dfs
remove

二十五、为什么这叫 DFS?

因为程序会:

一条路一直走到底,然后再回来走另一条路。

例如:

复制代码
先:
不选1
↓
不选2
↓
到终点 []

然后回来:

复制代码
选2
↓
到终点 [2]

然后再回来:

复制代码
选1
...

这就是:

复制代码
Depth First Search
深度优先搜索

即:

先往深处走到底,再回头。


二十六、为什么这叫回溯?

因为搜索一条分支以后:

复制代码
path.add(nums[i]);

会改变当前状态。

分支搜索结束:

复制代码
dfs(...)

回来以后:

复制代码
path.remove(...)

恢复原来的状态。

这个:

复制代码
往下走
↓
做选择
↓
搜索
↓
撤销选择
↓
回到原状态

就是:

回溯。


二十七、复杂度

数组长度:

复制代码
n

每个元素:

复制代码
选
或
不选

所以总共有:

复制代码
2^n

个子集。

而每次保存结果:

复制代码
new ArrayList<>(path)

还需要复制最多 n 个元素。

所以时间复杂度通常写:

复制代码
O(n × 2^n)

答案本身就有:

复制代码
2^n

个子集。

递归深度:

复制代码
O(n)

二十八、最容易写错的地方

1. 选了以后没有 dfs

错误:

复制代码
path.add(nums[i]);
path.remove(path.size() - 1);

正确:

复制代码
path.add(nums[i]);

dfs(i + 1, nums, ans, path);

path.remove(path.size() - 1);

固定记:

复制代码
add
dfs
remove

2. 忘记 remove

如果:

复制代码
path.add(nums[i]);

以后不撤销,后面的分支会带着不属于自己的元素。

所以必须恢复。


3. ans.add(path)

错误:

复制代码
ans.add(path);

因为存的是同一个对象引用。

正确:

复制代码
ans.add(new ArrayList<>(path));

保存当前快照。


4. 不理解两个 dfs 为什么一样

代码:

复制代码
dfs(i + 1);

出现两次。

它们虽然参数 i+1 相同,但:

复制代码
path 状态不同

第一次:

复制代码
没选 nums[i]

第二次:

复制代码
已经选 nums[i]

所以代表完全不同的分支。


二十九、面试怎么讲?

可以这样说:

这题可以用回溯枚举每个元素选或不选。

我定义 dfs(i) 表示当前正在决定 nums[i] 是否加入子集。

每个位置有两个分支:第一条不选择当前元素,直接递归 dfs(i+1);第二条先把当前元素加入 path,再递归处理下一个位置,递归结束后删除刚加入的元素恢复现场。

i == nums.length 时,说明所有元素都已经决定完,此时需要将 path 复制一份加入答案,因为 path 后续还会继续被修改。

一共有 2^n 个子集,保存每个子集需要复制,时间复杂度为 O(n·2^n)


三十、30 秒速记

看到:

复制代码
子集

想到:

复制代码
每个元素:
选 / 不选

定义:

复制代码
dfs(i)
=
决定 nums[i] 选不选

模板:

java 复制代码
if (i == nums.length) {
    ans.add(new ArrayList<>(path));
    return;
}

// 不选
dfs(i + 1, nums, ans, path);

// 选
path.add(nums[i]);

dfs(i + 1, nums, ans, path);

// 撤销
path.remove(path.size() - 1);

记忆口诀:

复制代码
不选:dfs

选:
add
dfs
remove

保存答案:

复制代码
new ArrayList<>(path)

一句话:

子集回溯 = 每个数都有选和不选两条路;选的时候遵循 add → dfs → remove,保存答案时复制 path 快照。


三十一、最终总结

这道题真正要理解的是:

复制代码
dfs(i)

不是在做一个固定动作。

它是在:

枚举第 i 个数字的两种命运。

所以:

复制代码
不选
→ 递归搜索后面的所有可能

选
→ add
→ 带着当前选择继续递归
→ remove恢复现场

而递归最核心的规律:

子函数执行完以后,会回到父函数暂停的位置继续往下执行。

因此:

复制代码
dfs(i + 1);

path.add(nums[i]);

真正含义是:

先把"不选"的整棵搜索树走完,再回来开始"选"的整棵搜索树。

最后:

复制代码
ans.add(new ArrayList<>(path));

一定要理解成:

保存当前 path 的一份独立快照,而不是保存这个会继续变化的 path 对象本身。

相关推荐
YSL0701241 小时前
顺序表小补充
数据结构
hansang_IR1 小时前
【代数与组合数学 | 那忘算 5】生成函数 & 例题 & 卷积
c++·算法·多项式·生成函数·母函数
Zane19941 小时前
快速排序凭什么叫"快"排序?平均O(nlogn)背后,藏着一个能让它退化成O(n²)的选择
算法
金玉满堂@bj1 小时前
多环境部署方案(开发、测试、生产,搭配Tomcat\+WAR包)
java·tomcat·maven
6Hzlia1 小时前
【Classic 150 刷题计划】 LeetCode 242. 有效的字母异位词 | C++ 哈希计数与严密防线
c++·算法·leetcode
wabs6661 小时前
关于二叉树【力扣101.对称二叉树的思考】
数据结构·c++·算法·leetcode·二叉树
白远山1 小时前
无人自助健身平台搭建:从架构设计到设备联动的完整实战
java·开发语言·架构·需求分析
古法安卓2 小时前
Android-Fork 机制详解
android·java·android studio
曹牧2 小时前
Spring:HttpMessageConverter
java