一、题目
给定一个整数数组:
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 对象本身。