1看一下灵神的思路
2方法一:选或不选(输入的视角)
3对于输入的 nums,考虑每个 nums[i] 是选还是不选,由此组合出 2的n个不同的子集。
4
5dfs 中的 i 表示当前考虑到 nums[i] 选或不选。
6
7答疑
8问:为什么要恢复现场?
9
10答:我们来做个实验。去掉代码中的恢复现场那行代码,然后测试 nums=[1,2] 这个数据。你会发现答案居然包含 [2,1,2],这是为什么呢?
11
12看视频中的图。如果不恢复现场,当我们从 [2] 递归返回后,path 中还残留有 2,对于后面的递归来说,这个 2 是多余的。继续递归「选 1」的右子树时,会把 1 加到 path 中,导致 path=[2,1];继续递归到「选 2」的右子树时,path=[2,1,2],显然这是错的。
13
14class Solution:
15 def subsets(self, nums: List[int]) -> List[List[int]]:
16 n = len(nums)
17 ans = []
18 path = []
19
20 # 选或不选:讨论 nums[i] 是否加入 path
21 def dfs(i: int) -> None:
22 if i == n: # 子集构造完毕
23 ans.append(path.copy()) # 复制 path,也可以写 path[:]
24 return
25
26 # 不选 nums[i]
27 dfs(i + 1) # 考虑下一个数 nums[i+1] 选或不选
28
29 # 选 nums[i]
30 path.append(nums[i])
31 dfs(i + 1) # 考虑下一个数 nums[i+1] 选或不选
32 path.pop() # 恢复现场,撤销 path.append(nums[i])
33
34 dfs(0)
35 return ans
36复杂度分析
37时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。有 2的n
38 个子集,所以搜索树有 2的n
39 个叶子,每个叶子复制 path 需要 O(n) 的时间,一共需要 O(n乘以2的n) 时间。
40空间复杂度:O(n)。返回值的空间不计。
41方法二:枚举选哪个(答案的视角)
42枚举子集(答案)的第一个数选谁,第二个数选谁,第三个数选谁,依此类推。
43
44dfs 中的 i 表示现在要枚举选 nums[i] 到 nums[n−1] 中的一个数,添加到 path 末尾。
45
46如果选 nums[j] 添加到 path 末尾,那么下一个要添加到 path 末尾的数,就要在 nums[j+1] 到 nums[n−1] 中枚举了。
47
48注意:不需要在回溯中判断 i=n 的边界情况,因为此时不会进入循环,if i == n: return 这句话写不写都一样。
49
50class Solution:
51 def subsets(self, nums: List[int]) -> List[List[int]]:
52 n = len(nums)
53 ans = []
54 path = []
55
56 # 枚举选哪个:在下标 i 到 n-1 中选一个数,加到 path 末尾
57 def dfs(i: int) -> None:
58 ans.append(path.copy()) # 不选,把当前子集加入答案
59 for j in range(i, n): # 选,枚举选择的数字
60 path.append(nums[j])
61 dfs(j + 1) # 选 nums[j] 意味着 i 到 j-1 都跳过不选,下一个数从 j+1 开始选
62 path.pop() # 恢复现场
63
64 dfs(0)
65 return ans
66复杂度分析
67时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。答案的长度为子集的个数,即2的n
68 ,同时每次递归都把一个数组放入答案,因此会递归 2的n
69 次,再算上加入答案时复制 path 需要 O(n) 的时间,所以时间复杂度为 O(n乘以2的n)。
70空间复杂度:O(n)。返回值的空间不计。
71方法三:二进制枚举
72根据 从集合论到位运算,常见位运算技巧分类总结 中的「枚举子集」的技巧,可以只用简单的循环枚举所有子集。
73
74class Solution:
75 def subsets(self, nums: List[int]) -> List[List[int]]:
76 ans = []
77 for i in range(1 << len(nums)): # 枚举全集 U 的所有子集 i
78 subset = [x for j, x in enumerate(nums) if i >> j & 1]
79 ans.append(subset)
80 return ans
81复杂度分析
82时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。
83空间复杂度:O(1)。返回值的空间不计。
84
85子集型回溯笔记
861. 前提条件
87
88子集型问题问的是:
89
90给你一组数,每个数可以选,也可以不选。
91求所有可能的集合。
92
93典型题:
94
9578. 子集
9690. 子集 II
97131. 分割回文串
9817. 电话号码的字母组合
99
100最基础例子:
101
102nums = [1,2,3]
103
104答案:
105
106[]
107[1]
108[2]
109[3]
110[1,2]
111[1,3]
112[2,3]
113[1,2,3]
1142. 核心概念
115
116子集型回溯本质是:
117
118每个元素都有两个选择:
119
120选
121不选
122
123比如 [1,2,3]:
124
1251:选 / 不选
1262:选 / 不选
1273:选 / 不选
128
129所以总共有:
130
1312^n 个子集
1323. 回溯三件套
133
134子集型回溯一定会有:
135
136path:当前已经选了哪些数
137
138start / index:当前从哪里开始选
139
140ans:保存所有答案
141
142其中:
143
144path 是临时路径
145ans 是最终答案
1464. 写法一:选 / 不选
147
148这是最接近“每个数选不选”的写法。
149
150核心模板
151void dfs(int index)
152{
153 if(index == numsSize)
154 {
155 保存 path;
156 return;
157 }
158
159 // 不选 nums[index]
160 dfs(index + 1);
161
162 // 选 nums[index]
163 path[pathSize++] = nums[index];
164 dfs(index + 1);
165 pathSize--;
166}
167怎么理解
168
169比如:
170
171nums = [1,2]
172
173递归树:
174
175 []
176 / \
177 不选1 选1
178 [] [1]
179 / \ / \
180 不选2 选2 不选2 选2
181 [] [2] [1] [1,2]
182
183最后得到:
184
185[]
186[2]
187[1]
188[1,2]
189
190顺序不重要,内容对就行。
191
192为什么要 pathSize--
193path[pathSize++] = nums[index];
194dfs(index + 1);
195pathSize--;
196
197意思是:
198
199先把 nums[index] 放进 path
200递归搜索后面的选择
201回来以后撤销刚刚这个选择
202
203如果不撤销,后面分支会带着错误的 path。
204
2055. 写法二:枚举下一个选谁
206
207这是灵神常用写法,也更适合后面组合题。
208
209核心模板
210void dfs(int start)
211{
212 保存 path;
213
214 for(int i = start; i < numsSize; i++)
215 {
216 path[pathSize++] = nums[i];
217
218 dfs(i + 1);
219
220 pathSize--;
221 }
222}
223
224这版的意思是:
225
226当前 path 本身就是一个子集,先保存。
227
228然后从 start 开始,枚举下一个要加入 path 的数。
229用 [1,2,3] 走一遍
230
231开始:
232
233path = []
234start = 0
235
236先保存:
237
238[]
239
240然后循环:
241
242选 1:
243
244path = [1]
245
246保存:
247
248[1]
249
250继续从 2 开始选:
251
252[1,2]
253[1,2,3]
254[1,3]
255
256回到最外层,选 2:
257
258[2]
259[2,3]
260
261再选 3:
262
263[3]
264
265最终:
266
267[]
268[1]
269[1,2]
270[1,2,3]
271[1,3]
272[2]
273[2,3]
274[3]
2756. 为什么递归是 dfs(i + 1)
276
277因为子集里:
278
279每个数只能用一次
280并且不能回头选
281
282例如已经选了 2,后面只能考虑 3,不能再回头选 1。
283
284所以:
285
286dfs(i + 1);
287
288表示:
289
290下一层只能从 i 后面的元素开始选
291
292这样可以避免重复:
293
294[1,2]
295[2,1]
2967. 子集型核心代码 C 版
297
298先写一个用于理解的打印版。
299
300#include <stdio.h>
301
302#define MAXN 20
303
304int path[MAXN];
305int pathSize = 0;
306
307void printPath()
308{
309 printf("[");
310 for(int i = 0; i < pathSize; i++)
311 {
312 if(i > 0)
313 {
314 printf(",");
315 }
316 printf("%d", path[i]);
317 }
318 printf("]\n");
319}
320
321void dfs(int* nums, int numsSize, int start)
322{
323 printPath();
324
325 for(int i = start; i < numsSize; i++)
326 {
327 path[pathSize++] = nums[i];
328
329 dfs(nums, numsSize, i + 1);
330
331 pathSize--;
332 }
333}
334
335int main()
336{
337 int nums[] = {1, 2, 3};
338 int numsSize = 3;
339
340 dfs(nums, numsSize, 0);
341
342 return 0;
343}
3448. LeetCode 78 核心写法
345
346LeetCode C 返回二维数组比较麻烦,先记核心逻辑:
347
348void dfs(int* nums, int numsSize, int start)
349{
350 savePath();
351
352 for(int i = start; i < numsSize; i++)
353 {
354 path[pathSize++] = nums[i];
355
356 dfs(nums, numsSize, i + 1);
357
358 pathSize--;
359 }
360}
361
362真正完整返回二维数组时,需要:
363
364returnSize
365returnColumnSizes
366ans
367
368但核心不变。
369
3709. 子集型和普通 DFS 的区别
371
372普通 DFS:
373
374访问节点
375
376子集型回溯:
377
378枚举选择方案
379
380普通 DFS:
381
382visited[u] = 1;
383dfs(v);
384
385子集回溯:
386
387path[pathSize++] = nums[i];
388dfs(i + 1);
389pathSize--;
390
391关键多了:
392
393撤销选择
39410. 子集型常见变形
39578 子集
396无重复元素
397直接 dfs(start)
39890 子集 II
399有重复元素
400先排序
401同一层跳过重复
402
403核心剪枝:
404
405if(i > start && nums[i] == nums[i - 1])
406{
407 continue;
408}
409131 分割回文串
410也是子集型
411只不过每次选的不是单个元素
412而是一段字符串
41311. 易错点
4141. 保存答案的位置
415
416子集型一般一进入 dfs 就保存 path。
417
4182. dfs(i + 1)
419
420不是 dfs(start + 1)。
421
422因为当前选的是 i,下层要从 i 后面开始。
423
4243. pathSize--
425
426递归回来必须撤销选择。
427
4284. 子集顺序不重要
429
430输出顺序不同通常也算对。
431
4325. 有重复元素时要排序 + 同层去重。
43312. 最终总结
434子集型回溯
435
436问题:
437 每个元素选或不选
438
439核心:
440 path 保存当前选择
441 start 表示下一个从哪里开始选
442
443模板:
444 保存 path
445 for i from start to numsSize-1
446 选择 nums[i]
447 dfs(i+1)
448 撤销 nums[i]
449
450关键词:
451 path
452 start
453 dfs(i+1)
454 pathSize--
455
456一句话:
457
458子集型回溯 = 当前 path 先保存,然后枚举后面还能选谁。
看一下灵神的思路
方法一:选或不选(输入的视角)
对于输入的 nums,考虑每个 nums[i] 是选还是不选,由此组合出 2的n个不同的子集。
dfs 中的 i 表示当前考虑到 nums[i] 选或不选。
答疑
问:为什么要恢复现场?
答:我们来做个实验。去掉代码中的恢复现场那行代码,然后测试 nums=[1,2] 这个数据。你会发现答案居然包含 [2,1,2],这是为什么呢?
看视频中的图。如果不恢复现场,当我们从 [2] 递归返回后,path 中还残留有 2,对于后面的递归来说,这个 2 是多余的。继续递归「选 1」的右子树时,会把 1 加到 path 中,导致 path=[2,1];继续递归到「选 2」的右子树时,path=[2,1,2],显然这是错的。
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
ans = []
path = []
# 选或不选:讨论 nums[i] 是否加入 path
def dfs(i: int) -> None:
if i == n: # 子集构造完毕
ans.append(path.copy()) # 复制 path,也可以写 path[:]
return
# 不选 nums[i]
dfs(i + 1) # 考虑下一个数 nums[i+1] 选或不选
# 选 nums[i]
path.append(nums[i])
dfs(i + 1) # 考虑下一个数 nums[i+1] 选或不选
path.pop() # 恢复现场,撤销 path.append(nums[i])
dfs(0)
return ans
复杂度分析
时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。有 2的n
个子集,所以搜索树有 2的n
个叶子,每个叶子复制 path 需要 O(n) 的时间,一共需要 O(n乘以2的n) 时间。
空间复杂度:O(n)。返回值的空间不计。
方法二:枚举选哪个(答案的视角)
枚举子集(答案)的第一个数选谁,第二个数选谁,第三个数选谁,依此类推。
dfs 中的 i 表示现在要枚举选 nums[i] 到 nums[n−1] 中的一个数,添加到 path 末尾。
如果选 nums[j] 添加到 path 末尾,那么下一个要添加到 path 末尾的数,就要在 nums[j+1] 到 nums[n−1] 中枚举了。
注意:不需要在回溯中判断 i=n 的边界情况,因为此时不会进入循环,if i == n: return 这句话写不写都一样。
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
ans = []
path = []
# 枚举选哪个:在下标 i 到 n-1 中选一个数,加到 path 末尾
def dfs(i: int) -> None:
ans.append(path.copy()) # 不选,把当前子集加入答案
for j in range(i, n): # 选,枚举选择的数字
path.append(nums[j])
dfs(j + 1) # 选 nums[j] 意味着 i 到 j-1 都跳过不选,下一个数从 j+1 开始选
path.pop() # 恢复现场
dfs(0)
return ans
复杂度分析
时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。答案的长度为子集的个数,即2的n
,同时每次递归都把一个数组放入答案,因此会递归 2的n
次,再算上加入答案时复制 path 需要 O(n) 的时间,所以时间复杂度为 O(n乘以2的n)。
空间复杂度:O(n)。返回值的空间不计。
方法三:二进制枚举
根据 从集合论到位运算,常见位运算技巧分类总结 中的「枚举子集」的技巧,可以只用简单的循环枚举所有子集。
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
ans = []
for i in range(1 << len(nums)): # 枚举全集 U 的所有子集 i
subset = [x for j, x in enumerate(nums) if i >> j & 1]
ans.append(subset)
return ans
复杂度分析
时间复杂度:O(n乘以2的n),其中 n 为 nums 的长度。
空间复杂度:O(1)。返回值的空间不计。
子集型回溯笔记
- 前提条件
子集型问题问的是:
给你一组数,每个数可以选,也可以不选。
求所有可能的集合。
典型题:
- 子集
- 子集 II
- 分割回文串
- 电话号码的字母组合
最基础例子:
nums = [1,2,3]
答案:
[]
[1]
[2]
[3]
[1,2]
[1,3]
[2,3]
[1,2,3]
2. 核心概念
子集型回溯本质是:
每个元素都有两个选择:
选
不选
比如 [1,2,3]:
1:选 / 不选
2:选 / 不选
3:选 / 不选
所以总共有:
2^n 个子集
3. 回溯三件套
子集型回溯一定会有:
path:当前已经选了哪些数
start / index:当前从哪里开始选
ans:保存所有答案
其中:
path 是临时路径
ans 是最终答案
4. 写法一:选 / 不选
这是最接近“每个数选不选”的写法。
核心模板
void dfs(int index)
{
if(index == numsSize)
{
保存 path;
return;
}
// 不选 nums[index]
dfs(index + 1);
// 选 nums[index]
path[pathSize++] = nums[index];
dfs(index + 1);
pathSize--;
}
怎么理解
比如:
nums = [1,2]
递归树:
[]
/ \
不选1 选1
[] [1]
/ \ / \
不选2 选2 不选2 选2
[] [2] [1] [1,2]
最后得到:
[]
[2]
[1]
[1,2]
顺序不重要,内容对就行。
为什么要 pathSize--
path[pathSize++] = nums[index];
dfs(index + 1);
pathSize--;
意思是:
先把 nums[index] 放进 path
递归搜索后面的选择
回来以后撤销刚刚这个选择
如果不撤销,后面分支会带着错误的 path。
- 写法二:枚举下一个选谁
这是灵神常用写法,也更适合后面组合题。
核心模板
void dfs(int start)
{
保存 path;
for(int i = start; i < numsSize; i++)
{
path[pathSize++] = nums[i];
dfs(i + 1);
pathSize--;
}
}
这版的意思是:
当前 path 本身就是一个子集,先保存。
然后从 start 开始,枚举下一个要加入 path 的数。
用 [1,2,3] 走一遍
开始:
path = []
start = 0
先保存:
[]
然后循环:
选 1:
path = [1]
保存:
[1]
继续从 2 开始选:
[1,2]
[1,2,3]
[1,3]
回到最外层,选 2:
[2]
[2,3]
再选 3:
[3]
最终:
[]
[1]
[1,2]
[1,2,3]
[1,3]
[2]
[2,3]
[3]
6. 为什么递归是 dfs(i + 1)
因为子集里:
每个数只能用一次
并且不能回头选
例如已经选了 2,后面只能考虑 3,不能再回头选 1。
所以:
dfs(i + 1);
表示:
下一层只能从 i 后面的元素开始选
这样可以避免重复:
[1,2]
[2,1]
7. 子集型核心代码 C 版
先写一个用于理解的打印版。
#include <stdio.h>
#define MAXN 20
int path[MAXN];
int pathSize = 0;
void printPath()
{
printf("[");
for(int i = 0; i < pathSize; i++)
{
if(i > 0)
{
printf(",");
}
printf("%d", path[i]);
}
printf("]\n");
}
void dfs(int* nums, int numsSize, int start)
{
printPath();
for(int i = start; i < numsSize; i++)
{
path[pathSize++] = nums[i];
dfs(nums, numsSize, i + 1);
pathSize--;
}
}
int main()
{
int nums[] = {1, 2, 3};
int numsSize = 3;
dfs(nums, numsSize, 0);
return 0;
}
8. LeetCode 78 核心写法
LeetCode C 返回二维数组比较麻烦,先记核心逻辑:
void dfs(int* nums, int numsSize, int start)
{
savePath();
for(int i = start; i < numsSize; i++)
{
path[pathSize++] = nums[i];
dfs(nums, numsSize, i + 1);
pathSize--;
}
}
真正完整返回二维数组时,需要:
returnSize
returnColumnSizes
ans
但核心不变。
- 子集型和普通 DFS 的区别
普通 DFS:
访问节点
子集型回溯:
枚举选择方案
普通 DFS:
visited[u] = 1;
dfs(v);
子集回溯:
path[pathSize++] = nums[i];
dfs(i + 1);
pathSize--;
关键多了:
撤销选择
10. 子集型常见变形
78 子集
无重复元素
直接 dfs(start)
90 子集 II
有重复元素
先排序
同一层跳过重复
核心剪枝:
if(i > start && nums[i] == nums[i - 1])
{
continue;
}
131 分割回文串
也是子集型
只不过每次选的不是单个元素
而是一段字符串
11. 易错点
- 保存答案的位置
子集型一般一进入 dfs 就保存 path。
- dfs(i + 1)
不是 dfs(start + 1)。
因为当前选的是 i,下层要从 i 后面开始。
- pathSize--
递归回来必须撤销选择。
- 子集顺序不重要
输出顺序不同通常也算对。
- 有重复元素时要排序 + 同层去重。
- 最终总结
子集型回溯
问题:
每个元素选或不选
核心:
path 保存当前选择
start 表示下一个从哪里开始选
模板:
保存 path
for i from start to numsSize-1
选择 nums[i]
dfs(i+1)
撤销 nums[i]
关键词:
path
start
dfs(i+1)
pathSize--
一句话:
子集型回溯 = 当前 path 先保存,然后枚举后面还能选谁。