数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

今天我会带你深度思考子集,介绍掌握 5 种通用解法。

不知道你对子集问题是否还有印象,我们曾在 “12 | 回溯:我把回溯总结成一个公式,回溯题一出就用它”,花了大量篇幅深入讨论过子集相关的问题。但当时的思路是已经知道了解题方法,然后再对题目实施 “精确制导的定向爆破”。

但是,子集问题的解法就只有回溯吗?我们是否还可以继续深挖题目给予的信息?在这个 “一题多解” 的模块里面,我们需要重新审视子集问题,比如看一看:

  • 能否挖掘出更多的信息?
  • 能不能匹配到更多的算法和数据结构?
  • 能不能找到更多有趣的解法。

下面请你带着以上三个问题,开启今天的探索旅程。

题目

首先,子集问题实际上包含两类问题。下面我们通过两个题目详细介绍一下。

题目 1】给你一个整数数组 nums ,数组中的元素互不相同。返回该数组所有可能的子集。

解集不能包含重复的子集。你可以按任意顺序返回解集。

示例 1

输入:nums = [1,2,3]

输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

题目 2】给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集。解集不能包含重复的子集。返回的解集中,子集可以按任意顺序排列。

示例 1

输入:nums = [1,2,2]

输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

注:在后文中,我们将分别用题目 1、题目 2 来引用这两个题目。

声明:对于这个题目,在最差情况下,时间复杂度都是 O(N2N)。不算返回值的情况下,空间复杂度为 O(N)(BFS 的空间复杂度会到 O(2N))。因此,后面的代码不会再对时间复杂度和空间复杂度做特别的声明。

通用方法 1:回溯

首先,因为题目中已经说明了,数组中的每个元素都是不相同的,因此题目 1 不涉及去重。我们在 “第 12 讲”中利用 “回溯法:一个核心,三个条件” 写过题目 1 的回溯代码。关于这种方法,不再做过多的叙述,你可以参考如下代码(解析在注释里):

  1. void append(List<Integer> box, List<List<Integer>> all) {
  2. all.add(new ArrayList<>());
  3. for (Integer x : box) {
  4. all.get(all.size() - 1).add(x);
  5. }
  6. }
  7. void backTrace(int[] A,
  8. int start,
  9. List<Integer> box,
  10. List<List<Integer>> all) {
  11. final int N = A == null ? 0 : A.length;
  12. append(box, all);
  13. if (start >= N) {
  14. return;
  15. }
  16. for (int j = start; j < N; j++) {
  17. box.add(A[j]);
  18. backTrace(A, j + 1, box, all);
  19. box.remove(box.size() - 1);
  20. }
  21. }
  22. public List<List<Integer>> subsets(int[] A) {
  23. final int N = A == null ? 0 : A.length;
  24. List<Integer> box = new ArrayList<>();
  25. List<List<Integer>> ans = new ArrayList<>();
  26. backTrace(A, 0, box, ans);
  27. return ans;

代码:Java/C++/Python

可以看到,题目 2 与题目 1 有一点不同:

题目 2 给定的数组可能存在重复元素,因此,还需要对子集进行去重。

接下来我们讨论题目 2 中涉及的去重问题。

当时,我们采用 “排序 + 判断元素是否出现过” 的方法进行去重。经过优化后的代码如下(解析在注释里):

  1. class Solution {
  2. private void append(List<Integer> box,
  3. List<List<Integer>> ans) {
  4. ans.add(new ArrayList<>());
  5. for (Integer x : box) {
  6. ans.get(ans.size() - 1).add(x);
  7. }
  8. }
  9. private void backtrace(int[] A,
  10. int start,
  11. List<Integer> box,
  12. List<List<Integer>> ans)
  13. {
  14. final int N = A == null ? 0 : A.length;
  15. append(box, ans);
  16. if (start >= N) {
  17. return;
  18. }
  19. for (int j = start; j < N; j++) {
  20. if (j > start && A[j] == A[j-1]) continue;
  21. box.add(A[j]);
  22. backtrace(A, j + 1, box, ans);
  23. box.remove(box.size()-1);
  24. }
  25. }
  26. public List<List<Integer>> subsetsWithDup(int[] A) {
  27. final int N = A == null ? 0 : A.length;
  28. List<Integer> box = new ArrayList<>();
  29. List<List<Integer>> ans = new ArrayList<>();
  30. if (N <= 0) {
  31. return ans;
  32. }
  33. Arrays.sort(A);
  34. backtrace(A, 0, box, ans);
  35. return ans;
  36. }
  37. }

代码:Java/C++/Python

其实,这是一道非常经典的题目,我们可以从不同角度思考这个题目,进而得到不同的解法。

通用方法 2:BFS

既然可以利用回溯(可以认为回溯是某种形式的 DFS),那么应该也可以尝试往 BFS 方向思考。因为大部分时候,DFS 的代码都可以改写为 BFS 的代码,

题目 1 不需要考虑去重的问题。我们可以假设数组为 A[] = {1, 2, 3},那么可以进行如下的操作(解析在注释里):

  1. cur = {{}};
  2. for x in A:
  3. tmp = {};
  4. for subset in cur:
  5. subset.add(x)
  6. tmp.add(subset)
  7. for subset in tmp:
  8. cur.add(subset)
  9. return cur

不过,我们也要注意到,这里在进行 BFS 的时候,与常规的 BFS 不太一样。

BFS 的方法有两种,一种是使用 Queue,另一种是使用 “两段击”。这里我们指的是两段击。
如果你忘了什么是 “两段击”,那么快去看一下 “02 | 队列:FIFO 队列与单调队列的深挖与扩展”。

常规的 BFS 流程如下(我们聚焦于 BFS 的一轮迭代):

17 | 深度思考子集:如何掌握 5 种通用解法? - 图1

我们发现,进行 BFS 的时候,在生成 next 之后,并不是直接赋值给 cur,而是采用了 cur + next。这里我建议你花点时间完成下面两道常规 BFS 的练习题,巩固一下 BFS 的知识。

练习题 1:从上到下按层打印二叉树,同一层结点按照从左到右的顺序打印,每一层打印到一行。

代码:Java/C++/Python

练习题 2:有 n 个城市通过 m 个航班连接。每个航班都从城市 u 开始,以价格 w 抵达 v。现在给定所有的城市和航班,以及出发城市 src 和目的地 dst,你的任务是找到从 src 到 dst 最多经过 k 站中转的最便宜的价格。 如果没有这样的路线,则输出 -1。

输入:n = 3, edges = [[0,1,100],[1,2,100],[0,2,500]] src = 0, dst = 2, k = 1

输出:200

城市航班图如下:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图2

从城市 0 到城市 2 在 1 站中转的最便宜价格是 200,如上图中红色所示:

代码:Java/C++/Python

既然我们已经知道子集的 BFS 的程序框架了,针对题目 1,可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public List<List<Integer>> subsets(int[] nums) {
  3. List<List<Integer>> cur = new ArrayList<>();
  4. cur.add(new ArrayList<>());
  5. List<List<Integer>> next = new ArrayList<>();
  6. for (int x: nums) {
  7. next.clear();
  8. for (List<Integer> subset: cur) {
  9. List<Integer> newSubset = new ArrayList<>(subset);
  10. newSubset.add(x);
  11. next.add(newSubset);
  12. }
  13. for (List<Integer> newSubset: next) {
  14. cur.add(newSubset);
  15. }
  16. }
  17. return cur;
  18. }
  19. }

代码:Java/C++/Python

题目 1解决之后,我们再来看一下能不能用同样的 BFS 方法解决题目 2。假设 A[] = {1,2,2}。看看是否会出现什么问题,步骤如下:

Step 1. 一开始 cur = [{}];

Step 2. 当加入元素 1,生成 next = [{1}];

Step 3. cur = cur + next = [{}, {1}];

Step 4. 再加入元素 2,生成 next = [{2}, {1,2}];

Step 5. cur = cur + next = [{}, {1}, {2}, {1,2}];

Step 6. 再加入元素 2,生成 next = [{2}, {1,2}, {2,2}, {1,2,2}];

Step 7. 最后执行 cur = cur + next = [{}, {1}, {2}, {1,2},{2}, {1,2}, {2,2}, {1,2,2}]。

我们发现,如果还是沿用题目 1 的 BFS 方法,会在最终解中产生重复的子集。那么,有没有办法去除这些重复的子集?

下面我们需要追踪一下这些重复元素是如何生成的,可以画出下图:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图3

重复元素 [{2}, {1,2}] 是由 [{}, {1}] 加入元素 2 后生成的。重复的元素分别是在 Step 4 和 Step 6 生成的,如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图4

因此,在更新的时候,应该要注意,如果一些元素,比如 [{}, {1}] 已经被元素 2 更新过了。那么后面就不应该再去更新了。

此时,我们应该可以写出伪代码了:

  1. cur = [{}]
  2. for x in A:
  3. next = []
  4. for subset in cur:
  5. if updated(subset, x) == False:
  6. next.add(subset.clone().add(x))
  7. for newSubset in next:
  8. cur.add(newSubset)
  9. return cur

现在问题的重点在于:如何完成 updated(subset, x) 的检查。我们发现可以按照下图这样的方式操作:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图5

通过上述分析, 我们可以总结出如下结论:

  • 如果加入的元素与前面加入的元素一样,那么只需要更新 “新增的部分”;
  • 为了同时达到,就需要与前面一轮的元素进行比较,并且记住前面新增的部分。

也就是说,我们需要做两个事情:

  • 对数组排序,排序之后,我们总是可以很容易得出与前面一轮的元素是否相等;
  • 记住前面新增的部分,我们只需要每次更新之前,记录一下 cur 的 size 就可以了。

基于这两点,可以写出题目 2 的代码如下(解析在注释里):

  1. class Solution {
  2. public List<List<Integer>> subsetsWithDup(int[] nums) {
  3. Arrays.sort(nums);
  4. List<List<Integer>> cur = new ArrayList<>();
  5. cur.add(new ArrayList<>());
  6. List<List<Integer>> next = new ArrayList<>();
  7. int pre = Integer.MAX_VALUE;
  8. int beforePreUpdateSize = 0;
  9. for (int curValue: nums) {
  10. next.clear();
  11. int startUpdatePos = 0;
  12. if (curValue == pre) {
  13. startUpdatePos = beforePreUpdateSize;
  14. }
  15. for (int i = startUpdatePos; i < cur.size(); i++) {
  16. List<Integer> newSubset = new ArrayList<>(cur.get(i));
  17. newSubset.add(curValue);
  18. next.add(newSubset);
  19. }
  20. int beforeCurValueUpdateSize = cur.size();
  21. for (List<Integer> subset: next) {
  22. cur.add(subset);
  23. }
  24. beforePreUpdateSize = beforeCurValueUpdateSize;
  25. pre = curValue;
  26. }
  27. return cur;
  28. }
  29. }

代码:Java/C++/Python

小结】我们不妨将用到的 BFS 进行比较,通过比较它们之间的异同,有助于梳理知识点里面的考点(红色部分是变更之后的考点)。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图6

我们发现,题目 1 与题目 2 无非就是在原始的 BFS 上不停地更改 BFS 的条件,然后就出现了新的题型与考点。

这里我再给你留一个练习题,通过这些练习,可以和我们以前学习过的知识产生联动,让你对题目、知识点的理解更加深刻。

练习题 3:在学习 “第 12 讲” 回溯中例 4 的时候,也讲到了题目 2。在那里,我们同样用到了去重的技巧,并且也用到了排序。那么请问,“第 12 讲” 中用到的排序和这里 BFS 的排序的目的有什么异同点?

练习题 4:题目 2 在进行 BFS 的时候,采用了使用部分 cur 里面的元素来进行更新,并且生成 next 的办法。我们在讲解 “11 | 贪心:这种思想,没有模板,如何才能掌握它?”贪心算法例 3“青蛙跳”的时候,曾说过可以采用类似 BFS 的方法进行思考。那么题目 2 和 “青蛙跳” 有什么异同呢?

通用方法 3:选与不选

现在我们换一种思路来看这个题目:生成一个子集的时候,对于一个元素而言,就只有选与不选两种选择。如果用二进制 bit,0 表示选中,1 表示没有选中。

那么对于一个有 n 个元素的数组,可以用一个二进制串表示一个子集。比如空集就是所有的二进制 bit 都是 0。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图7

比如,我们还可以用 b0110 表示子集 {A[1], A[2]}。那么现在你应该明白,针对四个元素的子集,可以按照 [0, ~ b1111] 顺序遍历,然后依次遍历二进制串的每一位,通过 bit 0/1 决定是否需要把相应的元素放到集合中。

通过这种思路,我们可以写出题目 1的代码如下(解析在注释里):

  1. class Solution {
  2. public List<List<Integer>> subsets(int[] nums) {
  3. final int N = nums == null ? 0 : nums.length;
  4. List<List<Integer>> ans = new ArrayList<>();
  5. for (int i = 0; i < (1<<N); i++) {
  6. List<Integer> subset = new ArrayList<>();
  7. for (int j = 0; j < N; j++) {
  8. final int mask = 1 << j;
  9. if ((i & mask) != 0) {
  10. subset.add(nums[j]);
  11. }
  12. }
  13. ans.add(subset);
  14. }
  15. return ans;
  16. }
  17. }

代码:Java/C++/Python

学完这种解法之后,我们会发现和 “14 | DP:我是怎么治好‘DP 头痛症’的?” 里面的状态压缩有非常类似的地方:

  1. 都是用 0/1 bit 位来表示一个元素的选中和不选中;
  2. 都是用一个整数来表示一个集合的状态。

题目 1 解决之后,我们接下来看一下题目 2。如果仍然采用这种方法,可能会面临一个问题。对于给定数组 A[] = {1,2,2,2}。如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图8

虽然用了两个不同的二进制串,但是会导致它们映射到同样的子集。因此,也就存在重复的可能性。那么,有没有什么办法可以去重呢?

我们接着来看下面这个例子。对于数组 A[] = {1,2,2,2,2},可以发现有些二进制串实际上是等价的。比如只选中一个 1 的时候,如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图9

例 2:只选择 2 个 2 到子集里面的时候。如下图所示:
17 | 深度思考子集:如何掌握 5 种通用解法? - 图10

假设需要选择 x 个 2 出来,只需要数字 2 对应的位置 bit = 1 的总数有 x 个就可以了。比如,以选择 2 个 2 为例,只需 2 对应的位置有 2 个 bit=1 就可以了。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图11

那么,在这些二进制串中,需要选择一个我们想要的数字出来。所以,现在问题的重点,就是选谁

经过一系列挑选,我们精心按照一定规则,即相同的数的 bit = 1,挑选了如下数组的所有二进制串:

也就是说,本质上我们是在具有重复含义的二进制串中选出了 “代表”。比如下面这两个二进制串:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图12

两个二进制串都表示往集合中添加:

  • 1 个 1
  • 2 个 3
  • 2 个 4

你可以观察上图中红色勾选的二进制串,上述选择规则有 2 个条件。注意,这里的条件都是针对数组中值相同的元素:

  • 对于数组中的相同的元素,选中的时候,bit 为 1 时,都必须在一起;
  • 对于数组中的相同的元素,bit 为 1 时,都必须靠左边连续存放。

注意:这里的 “靠左存放” 不是靠整个二进制串的左边,而是靠着相同元素的左边界存放。如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图13

我们认为上图中相同元素 3 和元素 4 的 bit = 1 都是靠其左边连续存放的。

这里我给出一些示例,如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图14

例 1,满足条件 1,不满足条件 2。因为选中元素 2 的 bit = 1 没有连续靠左存放。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图15

例 2,不满足条件 1,也不满足条件 2,因为选中相同元素的 bit = 1 没有连续,也没有靠左存放。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图16

例 3,满足条件 1,也满足条件 2。针对相同元素 3 的 bit = 1 同时满足上面两个条件。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图17

例 4,针对元素 3 的选择,同时满足两个条件。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图18

例 5,针对相同元素 3 和 4,其中元素 3 的 bit = 1 满足两个条件,但是元素 4 的 bit 位不满足条件 2。因此,这个二进制串也不满足两个条件。

那么按照这个规则,在生成子集的同时,我们还要检查一下二进制串是否满足条件 1 和条件 2

注意:这里只是为了方便写代码,所以采用这个规则来挑选二进制串,挑选二进制串的方法并不是唯一的,你也可以选择其他简便的方法。

经过前面的分析,我们可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public List<List<Integer>> subsetsWithDup(int[] nums) {
  3. final int N = nums == null ? 0 : nums.length;
  4. Arrays.sort(nums);
  5. List<List<Integer>> ans = new ArrayList<>();
  6. for (int i = 0; i < (1<<N); i++) {
  7. List<Integer> subset = new ArrayList<>();
  8. boolean validSubset = true;
  9. for (int j = 0; j < N; j++) {
  10. if (j > 0 && nums[j] == nums[j-1]) {
  11. final int curBit = i & (1<<j);
  12. final int preBit = i & (1<<(j-1));
  13. if (curBit != 0 && preBit == 0) {
  14. validSubset = false;
  15. break;
  16. }
  17. }
  18. if ((i & (1<<j)) != 0) {
  19. subset.add(nums[j]);
  20. }
  21. }
  22. if (validSubset) {
  23. ans.add(subset);
  24. }
  25. }
  26. return ans;
  27. }
  28. }

代码:Java/C++/Python

通用方法 4:以退为进

接下来,我们重点讨论一下题目 2。在题目 2 去重的时候。如果我们再深入思考一下挑选二进制串的本质。不难发现,当数组为 A[] = {1, 2, 2, 2, 2} 时,我们只是想往子集中加入 2 的时候,分别往子集中添加:

那么,我们有没有可能采用下面这种思路?

首先记录数组中每个数出现的次数 hash[number] = count;接着,将数组中的元素去重。最后,当我们需要往子集中加入某个数的时候,只需要在题目 1 的基础上做一点变动:

选中某个数的时候,需要加入 1 个, 2 个, …, hash[number] 个这样的数。

处理题目 2 的过程,实际上是先退回了题目 1(这里我们选中了 BFS 算法),然后再在加入元素的次数上做了调整。

通过这种思路,就可以写出题目 2 代码如下(解析在注释里):

  1. class Counter extends HashMap<Integer, Integer> {
  2. public int get(Integer k) {
  3. return containsKey(k) ? super.get(k) : 0;
  4. }
  5. public void add(int k, int v) {
  6. put(k, get(k) + v);
  7. if (get(k) <= 0) {
  8. remove(k);
  9. }
  10. }
  11. }
  12. class Solution {
  13. public List<List<Integer>> subsetsWithDup(int[] nums) {
  14. Counter cnt = new Counter();
  15. for (int x: nums) {
  16. cnt.add(x, 1);
  17. }
  18. List<List<Integer>> cur = new ArrayList<>();
  19. cur.add(new ArrayList<>());
  20. for (Map.Entry<Integer,Integer> en: cnt.entrySet()) {
  21. List<List<Integer>> next = new ArrayList<>();
  22. for (int addTimes = 1;
  23. addTimes <= en.getValue(); addTimes++) {
  24. for (List<Integer> subset: cur) {
  25. List<Integer> newSubset = new ArrayList<>(subset);
  26. for (int j = 0; j < addTimes; j++) {
  27. newSubset.add(en.getKey());
  28. }
  29. next.add(newSubset);
  30. }
  31. }
  32. for (List<Integer> subset: next) {
  33. cur.add(subset);
  34. }
  35. }
  36. return cur;
  37. }
  38. }

代码:Java/C++/Python

通用方法 5:分治

首先我们看一下题目 1,如果给定的数组 A[] = {1, 2}。实际上我们同然可以这样操作,得到所有的子集。

分治算法总是可以画成二叉树的样子,所以这里我们借助二叉树来展示分治的过程。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图19

基于这种思想,可以得到题目 1分治的解法,代码如下:

  1. class Solution {
  2. private List<Integer> merge(List<Integer> a, List<Integer> b) {
  3. List<Integer> tmp = new ArrayList<>(a);
  4. for (Integer x: b) {
  5. tmp.add(x);
  6. }
  7. return tmp;
  8. }
  9. private List<List<Integer>> dq(int[] nums, int b, int e) {
  10. List<List<Integer>> ans = new ArrayList<>();
  11. ans.add(new ArrayList<>());
  12. if (b >= e) {
  13. return ans;
  14. }
  15. List<Integer> tmp = new ArrayList<>();
  16. if (b + 1 == e) {
  17. tmp.add(nums[b]);
  18. ans.add(tmp);
  19. return ans;
  20. }
  21. final int mid = b + ((e-b)>>1);
  22. List<List<Integer>> l = dq(nums, b, mid);
  23. List<List<Integer>> r = dq(nums, mid, e);
  24. for (List<Integer> x: l) {
  25. for (List<Integer> y: r) {
  26. if (x.isEmpty() && y.isEmpty()) {
  27. continue;
  28. }
  29. ans.add(merge(x, y));
  30. }
  31. }
  32. return ans;
  33. }
  34. public List<List<Integer>> subsets(int[] nums) {
  35. final int N = nums == null ? 0 : nums.length;
  36. return dq(nums, 0, N);
  37. }
  38. }

代码:Java/C++/Python
我们需要格外注意空集的处理,合并的时候,如果两个集合都是空集,就不要再加入返回值了。因为它们之前已经加入空集(代码 13 行)了,重复加入会导致返回值中存在很多空集。

那么,我们应该如何处理题目 2 呢?如果仍然延续上述处理思路,在处理 A[] = {1, 2, 2, 2} 的时候,就会遇到问题。

17 | 深度思考子集:如何掌握 5 种通用解法? - 图20

那么,有没有办法解决冲突 1 和冲突 2 呢?

首先来看冲突 1,产生冲突 1 的本质是因为,当数组已经被切分成 [2, 2] 的时候,实际上可以直接通过计算 2 的个数来生成子集。这里有 2 个 2,所以可以直接生成如下子集:

  1. {}, 02
  2. {2}, 12
  3. {2,2} 22

也就是说,当数组里面的元素都是一样的时候,我们不能再进行切分。

那么再查看冲突 2。产生冲突 2 的根本原因在于:元素 2 分散在很多地方,每次合并的时候,都有可能生成重复的子集。在切分的时候,我们应该采用如下图所示的方法进行切分:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图21

那么,这里可以总结为两个原则。

  • 原则 1:当范围里面的数都是一样的时候,不能切分,而是采用计数的原则来生成子集。
  • 原则 2:相同的数,不能被切分开,而是要当成一个整体。

基于这两个原则,我们就可以写出题目 2 的分治的代码了,如下所示(解析在注释里):

  1. class Solution {
  2. private List<Integer> merge(List<Integer> a, List<Integer> b) {
  3. List<Integer> tmp = new ArrayList<>(a);
  4. for (Integer x: b) {
  5. tmp.add(x);
  6. }
  7. return tmp;
  8. }
  9. private boolean isSame(int[] nums, int b, int e) {
  10. boolean same = true;
  11. for (int i = b; i < e; i++) {
  12. if (nums[i] != nums[b]) {
  13. return false;
  14. }
  15. }
  16. return true;
  17. }
  18. private List<List<Integer>> dq(int[] nums, int b, int e) {
  19. List<List<Integer>> ans = new ArrayList<>();
  20. ans.add(new ArrayList<>());
  21. if (b >= e) {
  22. return ans;
  23. }
  24. boolean same =isSame(nums, b, e);
  25. if (same) {
  26. List<Integer> tmp = new ArrayList<>();
  27. for (int i = b; i < e; i++) {
  28. tmp.add(nums[i]);
  29. ans.add(new ArrayList<>(tmp));
  30. }
  31. return ans;
  32. }
  33. int mid = b + ((e-b)>>1);
  34. int l = mid - 1;
  35. while (l >= b && nums[l] == nums[mid]) {
  36. l--;
  37. }
  38. int r = mid;
  39. while (r < e && nums[r] == nums[mid]) {
  40. r++;
  41. }
  42. int cutPos = r;
  43. if (r >= e) {
  44. cutPos = l + 1;
  45. }
  46. List<List<Integer>> lans = dq(nums, b, cutPos);
  47. List<List<Integer>> rans = dq(nums, cutPos, e);
  48. for (List<Integer> x: lans) {
  49. for (List<Integer> y: rans) {
  50. if (x.isEmpty() && y.isEmpty()) {
  51. continue;
  52. }
  53. ans.add(merge(x, y));
  54. }
  55. }
  56. return ans;
  57. }
  58. public List<List<Integer>> subsetsWithDup(int[] nums) {
  59. if (nums == null || nums.length == 0) {
  60. return new ArrayList<>();
  61. }
  62. Arrays.sort(nums);
  63. return dq(nums, 0, nums.length);
  64. }
  65. }

代码:Java/C++/Python

总结

在本讲,我们通过分析题目的不同特点,展开了不同的解题思路,构造出了 5 种不同的解法。这里,我将 5 种解法进行了一个总结,如下图所示:

17 | 深度思考子集:如何掌握 5 种通用解法? - 图22

题目是做不完的,但是我们可以通过分析每一个题目来锻炼挖掘题目信息的能力,以及匹配到我们大脑中已经存储的数据结构与算法的能力。同时,也可以通过这种方式,将学会的知识变成我们解决问题的能力,只有这样 “学习” 才能变成“学会”。

思考题

最后,我再给你留一个思考题:在求解题目 2 的每一种方法中,我们都提前对数组进行了排序的处理,每次排序的原因和目的分别是什么

可以把你的分析写在留言区,我们一起讨论。如果你觉得今天的内容对你有所启发,欢迎分享给身边的朋友。

好了,子集问题我们就学习到这里,希望你还能找出更多有趣的解法。接下来让我们前进到 18 | 单词接龙:如何巧用深搜与广搜的变形?记得按时来探险。