Post

子集生成

概述

许多看似复杂的问题都能通过“穷举所有可能”来解决——即暴力枚举。虽然乍看笨拙,却常常是最直接、最可靠的手段。实际开发里,BFS 搜索最短路径、回溯找解、组合枚举等都离不开这种思想。

本文聚焦暴力枚举中的典型问题:给定集合生成全部子集(Power Set)。其他如排列、路径搜索将在后续再谈。

问题定义

给定一个不含重复元素的数组 nums,输出它的所有子集,即 LeetCode 78 - Subsets。答案规模为 2^n,任何算法都至少需要线性时间输出这些结果。

以下三种常用方法可以覆盖大多数子集生成需求:增量构造法递归回溯(位向量法)位运算法(枚举二进制)。如果原数组包含重复元素,可在生成过程中去重,或先排序再跳过相同值,下文会给出提示。

方法一:增量构造

从空集开始,每遍历一个元素就把当前结果集复制一份并追加该元素。最终得到的集合数正好翻倍。

[1,2,3] 为例:

  • 初始结果:[]
  • 处理 1:在现有集合的每个子集中追加 1,得到 [][1]
  • 处理 2:追加 2,得到 [][1][2][1,2]
  • 处理 3:同理可得 [][1][2][1,2][3][1,3][2,3][1,2,3]

时间复杂度 O(n·2^n),空间复杂度 O(2^n)

1
2
3
4
5
6
7
8
9
10
11
12
vector<vector<int>> subsets(vector<int> &S) {
    vector<vector<int>> res(1);
    sort(S.begin(), S.end());
    for (int i = 0; i < S.size(); i++) {
        int size = res.size();
        for (int j = 0; j < size; j++) {
            res.push_back(res[j]);
            res.back().push_back(S[i]);
        }
    }
    return res;
}

若输入存在重复元素,可在遍历时记录本轮新增子集的范围,仅对新子集追加相同元素,避免重复结果。 整个添加的顺序为:

1
2
3
4
5
6
7
8
[]
[1]
[2]
[1 2]
[3]
[1 3]
[2 3]
[1 2 3]

方法二:递归回溯(位向量法)

每个元素都有「选 / 不选」两种状态,可以模型化为一棵二叉决策树:左子树表示选择当前元素,右子树表示不选,叶子节点即为所有子集。状态树如下:

1
2
3
4
5
6
7
8
9
10
                        []        
                   /          \        
                  /            \     
                 /              \
              [1]                []
           /       \           /    \
          /         \         /      \        
       [1 2]       [1]       [2]     []
      /     \     /   \     /   \    / \
  [1 2 3] [1 2] [1 3] [1] [2 3] [2] [3] []    

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<vector<int>> subsets(vector<int> &S) {
    vector<vector<int>> res;
    vector<int> path;
    sort(S.begin(), S.end());
    genSubsets(S, 0, path, res);
    return res;
}
void genSubsets(vector<int> &S, int pos, vector<int> &path, vector<vector<int>> &res) {
    res.push_back(path);
    for (int i = pos; i < S.size(); i++) {
        if (i > pos && S[i] == S[i - 1]) continue; // 若有重复元素,跳过
        path.push_back(S[i]);
        genSubsets(S, i + 1, path, res);
        path.pop_back();
    }
}

整个添加的顺序为:

1
2
3
4
5
6
7
8
[]
[1]
[1 2]
[1 2 3]
[1 3]
[2]
[2 3]
[3]

方法三:位运算枚举

集合中每个元素只有「选 / 不选」两种状态,与二进制位一一对应。将长度为 n 的集合映射为 0 ~ (1<<n)-1 的二进制数,即可枚举所有子集。下表展示了 [1,2,3] 的映射关系:

 123Subset
0000[]
1001[3]
2010[2]
3011[2,3]
4100[1]
5101[1,3]
6110[1,2]
7111[1,2,3]

对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vector<vector<int>> subsets(vector<int> &S) {
    vector<vector<int>> res;
    sort(S.begin(), S.end());
    int total = 1 << S.size();
    for (int mask = 0; mask < total; mask++) {
        vector<int> subset;
        for (int i = 0; i < S.size(); i++) {
            if ((mask >> i) & 1) {
                subset.push_back(S[i]);
            }
        }
        res.push_back(move(subset));
    }
    return res;
}

小结

  • 三种方法时间复杂度均为 O(n · 2^n),差别主要在实现方式与常数因子;
  • 增量构造适合迭代实现,回溯法便于剪枝或附加条件,位运算适合底层性能优化;
  • 处理含重复元素的集合时,记得先排序并在生成过程中跳过重复值;
  • 可扩展到相关问题:求固定长度子集、求满足约束的子集(在回溯时提前剪枝)、统计数量等。

暴力枚举并不意味着粗糙,只要设计好状态与剪枝策略,就能在 2^n 的搜索空间里快速找到答案。

This post is licensed under CC BY 4.0 by the author.