子集遍历
问题
使用二进制表示一个集合,10100
表示集合包含2
个元素,有时我们需要遍历该集合的非空子集,10100
对应的非空子集有10100
、10000
、00100
。
模板
// 遍历 u 的非空子集
for (int s = u; s; s = (s - 1) & u) {
// s 是 u 的一个非空子集
}
分析
每次将s
更新为(s - 1) & u
,每次减1
是将s
中位最低的1
去除掉,按位与是保证s
中1
只出现在u
中对应位为1
的位置,遍历10100
的过程如下
1: 10100
2: (10100 - 1) & 10100 = 10011 & 10100 = 10000
3:(10000 - 1) & 10100 = 01111 & 10100 = 00100
4: (00100 - 1) & 10100 = 00011 & 10100 = 00000 // 结束
时间复杂度:设二进制u
中 1
的个数为m
,用这种方法可以在 2 ^ m
的时间复杂度内遍历u
的子集(因为u
一共有2 ^ m
个子集)。进而可以在 3 ^ n
的时间复杂度内遍历大小为n
的集合的每个子集的子集。(复杂度为3 ^ n
是因为每个元素都有不在大子集中/只在大子集中/同时在大小子集中 三种状态。
SOS DP
问题
用一个二进制数表示一个集合,每个集合对应一个值,a[i]
就表示集合i
对应的值。
需求:统计一个集合的所有子集对应的值(例如统计该集合所有子集对应值之和)。
朴素思路:利用上面讲到的知识,遍历该集合对应的所有子集,在遍历的过程中统计答案。对于大量查询往往会超时。
SOS DP:子集和 DP,f[i]
表示集合i
对应的所有子集的值的统计结果。
模板
SOS DP (Sum of Subset),子集和 DP。a[i]
表示集合i
对应的值,f[i]
表示集合i
的所有子集对应值之和,下面代码就是用于求解f[]
// 遍历所有的集合,并初始化
for (int i = 0; i < (1 << n); i++) f[i] = a[i];
for (int k = 0; k < n; k++)
for (int i = 0; i < (1 << n); i++) { // 遍历所有集合
if(i & (1 << k))
f[i] += f[i ^ (1 << k)];
}
分析
以上代码是降维后的写法,为了方便理解,我们先看看二维代码该如何写。
a[i]
表示集合i
对应的值,f[i]
表示集合i
的所有子集对应值之和,dp[i][k]
表示集合i
,仅改变最低k
位(最靠右的k
位,k
= 0
表示最低位)形成的子集对应值之和,求f[i]
,设集合共n
位,则 f[i] = dp[i][n - 1]
,其中dp[i][n - 1]
表示仅修改下标在[0, n - 1]
的二进制位形成的子集对应值之和,此时就是能修改所有位,因此想求f[]
就需要求dp[][n - 1]
。接下来我们看看如何求解dp[][]
。
// 此处 -1 仅仅用于示意,表示初始化
for (int i = 0; i < (1 << n); i++) dp[i][-1] = a[i];
for (int i = 0; i < (1 << n); i++) {
for (int k = 0; k < n; k++) {
if (i & (1 << k)) // 集合 i 的第 k 位为 1
dp[i][k] = dp[i][k - 1] + dp[i ^ (1 << k)][k - 1];
else // 集合 i 的第 k 位为 0
dp[i][k] = dp[i][k - 1];
}
f[i] = dp[i][n - 1];
}
上面代码首先遍历所有集合,用a[i]
初始化dp[i][-1]
,-1
仅仅用于示意,表示不修改任何二进制位形成的子集,也就是它自身,因此直接初始化为a[i]
。
然后是两层循环,外层循环按二进制值从小到大遍历所有的集合,内层循环从小到大遍历k
。
已知 dp[][k - 1]
如何求 dp[][k]
呢 ?注意到,一个集合的子集是通过将原集合某些为1
的位改为0
形成的,因此也只能通过修改为1
的位才能形成新子集。我们根据集合i
的第k
位的值分类讨论:
情况一:集合i
第k
位为0
即i & (1 << k)
值为0
。此时不能通过修改第k
位,形成新子集,只能通过修改下标在[0, k - 1]
范围内的1
形成子集,这不就是dp[i][k - 1]
对应的值吗?因此此时是dp[i][k] = dp[i][k - 1]
情况二:集合i
第k
位为1
即i & (1 << k)
值为1
。此时有两种选择,不修改当前位、将当前位修改为0
。
- 不修改当前位,也就是仅修改
[0, k - 1]
范围内的位,其值为dp[i][k - 1]
。 - 修改当前位,从
1
变为0
,此时对应的二进制值变为i ^ (1 << k)
,此时还可以通过修改[0, k - 1]
范围内的位形成新子集,那么对应的值便为dp[i ^ (1 << k)][k - 1]
因此这种情况下dp[i][k] = dp[i][k - 1] + dp[i ^ (1 << k)][k - 1]
。
知道二维代码的写法,现在思考一下,dp[][]
数组是否可以省略掉,直接更新f[]
呢?
// 遍历所有的集合,并初始化
for (int i = 0; i < (1 << n); i++) f[i] = a[i];
for (int k = 0; k < n; k++)
for (int i = 0; i < (1 << n); i++) { // 遍历所有集合
if(i & (1 << k))
f[i] += f[i ^ (1 << k)];
}
现在我们就来分析省略dp[][]
的写法。代码首先遍历所有集合,f[i] = a[i]
表示不修改集合i
的任何位形成的子集对应的值之和(此时即为集合自身)。
然后是两层循环,外层循环从小到大遍历k
,此时f[i]
表示修改集合i
位于[0, k]
的位形成的子集对应的值之和,当外层循环结束,f[i]
对应的值就是我们需要求的。内层循环,从小到大遍历所有集合,如果集合i
的第k
位为1
则f[i] += f[i ^ (1 << k)]
。
设当前的遍历位置为外层循环k
,内层循环i
,此时正在求f[i]
的值,我们分析一下此时此刻,f[]
对应的值是哪个阶段的。f[0] ~ f[i - 1]
已经更新过了,因此保存的是k
阶段的值,f[i] ~ f[(1 << n) - 1]
正在等待更新,因此保存的是k - 1
阶段的值。
当集合i
的第k
位为0
时,保持f[i]
的值不变,即对应上面代码中的dp[i][k] = dp[i][k - 1]
。
当集合i
的第k
位为1
时,f[i] += f[i ^ (1 << k)]
,因为i ^ (1 << k)
小于i
(去掉了1
),因此f[i ^ (1 << k)]
在当前轮已经更新过了,它保存的是k
阶段的值,f[i]
还没更新,保存的是k - 1
阶段的值,那么这条更新语句对应的代码为dp[i][k] = dp[i][k - 1] + dp[i ^ (1 << k)][k]
。等等,怎么感觉不对?后面部分不应该是+ dp[i ^ (1 << k)][k - 1]
吗,仔细一想,i ^ (1 << k)
的第k
位为0
,那么dp[i ^ (1 << k)][k] = dp[i ^ (1 << k)][k - 1]
,因此上面的更新是 OK 的。
至此,分析完毕,可以快乐地做题了。
等等。还有一件事!!!
f[i]
表示的是i
的所有子集对应的值之和,如果题目让你求以i
为子集的所有集合之和该怎么办?其实很简单,将更新语句代码顺序换一下即可。
// 遍历所有的集合,并初始化
for (int i = 0; i < (1 << n); i++) f[i] = a[i];
for (int k = 0; k < n; k++)
for (int i = 0; i < (1 << n); i++) { // 遍历所有集合
if(i & (1 << k))
f[i ^ (1 << k)] += f[i]; // i ^ (1 << k) 是 i 的子集,因此将 f[i] 加到前者
}