状压DP-子集枚举技巧
状压DP进阶-子集枚举技巧
- 一、子集枚举的核心意义与问题
- 1.1 为什么需要子集枚举?
- 1.2 暴力枚举的局限性
- 二、子集枚举的经典技巧
- 2.1 技巧1:标准子集枚举(迭代法)
- 2.2 技巧2:非空子集枚举
- 2.3 技巧3:真子集枚举(排除自身)
- 2.4 技巧4:按元素数量枚举子集
- 三、经典案例:子集枚举在状压DP中的应用
- 3.1 集合划分的最小代价
- 问题描述
- 状压DP设计
- 核心部分代码实现
- 3.2 子集贡献累加(SOS DP)
- 问题描述
- 高效解法:SOS DP(Sum Over Subsets)
- 四、子集枚举优化与注意事项
- 4.1 优化技巧
- 4.2 注意事项
- 4.3 总结
在状压DP中“子集枚举”是处理集合相关问题的核心操作,许多场景需要遍历某个状态的所有子集(如“从已选元素中拆分出一个子集进行转移”“枚举子集计算贡献”),直接暴力枚举会导致时间复杂度爆炸。
一、子集枚举的核心意义与问题
1.1 为什么需要子集枚举?
状压DP中,状态通常用二进制mask
表示元素的选择情况。当问题涉及“将集合拆分为两个子集”“从集合中选择一个子集满足特定条件”时,需要枚举mask
的所有子集。例如:
- 集合划分问题:将
mask
表示的集合拆分为两个不相交子集a
和b
(a | b = mask
且a & b = 0
),计算最优划分方案。 - 贡献累加问题:对
mask
的所有子集sub
,累加sub
对应的价值到mask
的状态中。
1.2 暴力枚举的局限性
若mask
有k
个1,则其子集数量为2^k
。当k=20
时,子集数量超过百万,暴力枚举(遍历所有可能的sub
并检查sub
是否为mask
的子集)会导致时间复杂度达到O(2^n * 2^n) = O(4^n)
,对于n=20
就是1e12
级操作,完全不可行。
因此,必须掌握高效的子集枚举技巧,利用位运算特性减少无效遍历。
二、子集枚举的经典技巧
2.1 技巧1:标准子集枚举(迭代法)
核心思想:利用位运算submask = (submask - 1) & mask
,从mask
开始,每次生成比当前submask
小的最大子集,直到submask
为0。
原理:
submask - 1
会将submask
的最后一个1变为0,并将其后的0变为1;- 与
mask
进行&
运算,确保submask
始终是mask
的子集(仅保留mask
中为1的位)。
代码示例:
// 枚举mask的所有子集(包括空集和mask本身)
void enumerateSubsets(int mask) {for (int sub = mask; ; sub = (sub - 1) & mask) {// 处理子集subSystem.out.println(sub);if (sub == 0) break; // 终止条件}
}
示例:mask = 6
(二进制110
)
- 第一次循环:
sub = 110
(6) - 第二次:
sub = (110-1) & 110 = 101 & 110 = 100
(4) - 第三次:
sub = (100-1) & 110 = 011 & 110 = 010
(2) - 第四次:
sub = (010-1) & 110 = 001 & 110 = 000
(0),循环结束。
时间复杂度:O(2^k)
,其中k
是mask
中1的个数(仅遍历有效子集)。
2.2 技巧2:非空子集枚举
场景:需排除空集的情况(如“至少选择一个元素的子集”)。
实现:在标准枚举基础上,当sub == 0
时提前终止(不处理空集):
// 枚举mask的所有非空子集
void enumerateNonEmptySubsets(int mask) {for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {// 处理非空子集subSystem.out.println(sub);}
}
示例:mask=6
时,输出6,4,2
(跳过空集)。
2.3 技巧3:真子集枚举(排除自身)
场景:需排除mask
本身的子集(如“拆分集合为两个非空子集”)。
实现:在标准枚举中,当sub == mask
时跳过:
// 枚举mask的所有真子集(不包括mask本身)
void enumerateProperSubsets(int mask) {for (int sub = (mask - 1) & mask; ; sub = (sub - 1) & mask) {// 处理真子集subSystem.out.println(sub);if (sub == 0) break;}
}
原理:初始sub
设为(mask-1) & mask
,直接跳过mask
本身。
2.4 技巧4:按元素数量枚举子集
场景:需按子集大小(1的个数)分组处理(如“先处理大小为k的子集,再处理k+1的”)。
实现:预处理每个mask
中1的数量(bitCount
),按数量分组存储:
List<Integer>[] subsetsBySize = new List[n+1]; // subsetsBySize[k]存储所有含k个1的mask
static {for (int i = 0; i <= n; i++) {subsetsBySize[i] = new ArrayList<>();}for (int mask = 0; mask < (1 << n); mask++) {int cnt = Integer.bitCount(mask);subsetsBySize[cnt].add(mask);}
}// 按元素数量从小到大枚举子集
for (int k = 1; k <= n; k++) {for (int mask : subsetsBySize[k]) {// 处理大小为k的mask// 再枚举其大小为t的子集(t < k)for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {if (Integer.bitCount(sub) == t) {// 处理特定大小的子集}}}
}
三、经典案例:子集枚举在状压DP中的应用
3.1 集合划分的最小代价
问题描述
给定n
个元素,每个子集S
有代价cost[S]
。将全集U
(mask = (1<<n)-1
)划分为k
个不相交非空子集S_1, S_2, ..., S_k
,总代价为各子集代价之和,求最小总代价。
状压DP设计
- 状态定义:
dp[mask][t]
表示将mask
划分为t
个子集的最小代价。 - 状态转移:
- 初始:
dp[sub][1] = cost[sub]
(单个子集的代价)。 - 对
t > 1
,枚举mask
的非空真子集sub
,则:
dp[mask][t] = min(dp[mask][t], dp[sub][t-1] + dp[mask ^ sub][1])
(将mask
拆分为sub
和mask^sub
,前者划分为t-1
个,后者为1个)。
- 初始:
- 结果:
dp[full_mask][k]
。
核心部分代码实现
int n = 5;
int k = 2;
int fullMask = (1 << n) - 1;
int[] cost = new int[1 << n]; // 预处理每个子集的代价
int[][] dp = new int[1 << n][k + 1];
for (int[] row : dp) Arrays.fill(row, INF);// 初始化:t=1的情况
for (int sub = 1; sub <= fullMask; sub++) {dp[sub][1] = cost[sub];
}// 填充dp[mask][t]
for (int t = 2; t <= k; t++) {for (int mask = 1; mask <= fullMask; mask++) {// 枚举mask的非空真子集subfor (int sub = (mask - 1) & mask; sub > 0; sub = (sub - 1) & mask) {int rest = mask ^ sub; // 剩余部分if (dp[sub][t-1] != INF && dp[rest][1] != INF) {dp[mask][t] = Math.min(dp[mask][t], dp[sub][t-1] + dp[rest][1]);}}}
}System.out.println(dp[fullMask][k]);
关键:用sub = (mask-1) & mask
枚举真子集,避免重复计算sub
和rest
(因sub < rest
时可跳过,减少一半运算)。
3.2 子集贡献累加(SOS DP)
问题描述
给定数组a[mask]
,对每个mask
,计算其所有子集sub
的a[sub]
之和,即sum[mask] = Σa[sub]
(sub
是mask
的子集)。
高效解法:SOS DP(Sum Over Subsets)
传统方法:对每个mask
枚举所有子集sub
累加,时间O(4^n)
。
优化方法:利用二进制位的递进关系,按位更新sum[mask]
:
- 状态定义:
sum[mask]
为mask
所有子集的a[sub]
之和。 - 转移:对每个位
i
,若mask
的第i
位为1,则sum[mask] = sum[mask] + sum[mask ^ (1<<i)]
(包含第i
位和不包含第i
位的子集之和)。
代码实现:
int n = 5;
int[] a = new int[1 << n]; // 原始数组
int[] sum = new int[1 << n];
// 初始化sum为a的副本
System.arraycopy(a, 0, sum, 0, 1 << n);// SOS DP:按位更新
for (int i = 0; i < n; i++) {for (int mask = 0; mask < (1 << n); mask++) {if ((mask & (1 << i)) != 0) { // 若mask包含第i位sum[mask] += sum[mask ^ (1 << i)];}}
}
原理:通过按位迭代,每个mask
的sum
值由其不包含第i
位的子集mask ^ (1<<i)
的sum
累加而来,时间复杂度降至O(n * 2^n)
。
四、子集枚举优化与注意事项
4.1 优化技巧
-
剪枝无效子集:
- 若子集
sub
不满足问题条件(如代价超过阈值),直接跳过。 - 利用对称性:若
sub
和mask^sub
对称(如代价相同),可只处理一次。
- 若子集
-
预处理加速:
- 预计算每个
mask
的子集列表,避免重复枚举(适用于多次查询的场景)。 - 预计算
mask
中1的位置,快速定位可拆分的元素。
- 预计算每个
-
位运算加速:
- 用
Integer.bitCount(sub)
快速获取子集大小,避免重复计算。 - 用
(sub & -sub)
获取子集的最低位1,用于特定场景的拆分(如逐个元素移除)。
- 用
4.2 注意事项
- 时间复杂度上限:即使优化后,子集枚举的时间复杂度仍与
2^k
相关,因此仅适用于n≤20
的问题(2^20≈1e6
,可接受)。 - 空集与全集的处理:根据问题需求明确是否包含空集或全集,避免边界错误。
- 位运算优先级:位运算优先级低于算术运算,需注意加括号(如
(sub - 1) & mask
而非sub - 1 & mask
)。
4.3 总结
子集枚举是状压DP中处理集合拆分、贡献累加等问题的核心技巧,其效率直接决定算法可行性。本文介绍的四大枚举方法各有适用场景:
- 标准迭代法(
sub = (sub-1) & mask
)是通用基础,适用于大多数子集枚举场景; - 非空/真子集枚举通过调整循环条件,减少无效状态处理;
- 按大小枚举结合预处理,适合分阶段处理的问题;
- SOS DP则是子集求和的专用优化方法,将复杂度从
O(4^n)
降至O(n*2^n)
。
掌握这些技巧的关键是:
- 理解位运算对二进制状态的操控逻辑;
- 根据问题特性选择合适的枚举方式;
- 结合预处理和剪枝进一步优化性能。
That’s all, thanks for reading~~
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~