SoS dp(子集dp/高维前缀和)
本文从集合和高维前缀和两种角度分别介绍 SoS(Sum over Subsets) dp
问题引入
我们从一到经典的例题来引入学习 SoS dp:
对于两个二进制数 \(x\) 和 \(y\),我们定义 \(y \subseteq x\) 当且仅当 \(x \& y = x\),既 \(x\) 中为 0 的位在 \(y\) 一定为 0,\(y\) 中为 1 的位在 \(x\) 一定为 1。(此时若将每个位看成一个物品,每个二进制数看成一个集合,则上述定义表述的就是 \(y\) 是 \(x\) 的一个子集)
初始时我们有 \(A[i]\) 代表每个二进制数的数量(\(i < 2^N\)),对于所有的 \(x < 2^N\) 求 \(\sum_{y \subseteq x}A[y]\),也即 \(x\) 的子集的数量。
下面为了简便,将每个二进制数都等价为一个集合。
集合 + 动态规划思路
首先考虑,怎样可以划分一个集合的所有子集,划分了子集后我们就可以先计算子集,然后再由子集转移到待求集合。
我们令 \(S(x)\) 为 \(x\) 的子集的集合,则一种合法的划分方案是 \(S(x, i) = {y|x_i = 1 \&\&y \subseteq x \&\& x \oplus y < 2^{i + 1}}\)(\(x_i\) 表示 \(x\) 的第 i 位),既 \(S(x, i)\) 中的数,满足 \(i\) 位以上的位(不包括第 \(i\) 位)与 \(x\) 相同,而低位(包括第 \(i\) 位,且最低位是第 0 位)满足是 \(x\) 的子集。为什么要有 \(x_i = 1\) 这个条件?因为 \(S(1011, 2) = \{ 1000, 1001, 1010, 1011 \} = S(1011, 1)\),没有这个条件就会造成重复。
这样划分还有一个很好的性质:\(S(x, i) = S(x, i - 1) \cup S(x\oplus 2^i, i - 1)\)。这个性质的正确性是显然的。
有了划分方法,我们就可以用动态规划转移了,令 \(f(x, i)\) 为 \(|S(x, i)|\),则
最后我们要求的就是 \(f(x, N - 1)\)。
于是我们可以得出以下:
for (int i = 0; i < (1 << N); i++) {
f[i][-1] = A[i]; // 此时集合中就一个元素,大小就等于这个元素的数量
for (int bit = 0; bit < N; bit++) {
if (i >> bit & 1) {
f[i][bit] = f[i][bit - 1] + f[i ^ (1 << bit)][bit - 1];
}
else {
f[i][bit] = f[i][bit - 1];
}
}
}
可以简化成下面这个样子:
for (int i = 0; i < (1 << N); i++) {
f[i] = A[i];
}
for (int bit = 0; bit < N; bit++) {
for (int i = 0; i < (1 << N); i++) {
if (i >> bit & 1) {
f[i] += f[i ^ (1 << bit)];
}
}
}
// 最后 f[x] 就是我们要求的
注意两份代码的两层循环是不一样的,前者是先遍历数再遍历位(先遍历谁不影响),而后者是先遍历位再遍历数(这是保证正确性的要求)。
而且对于后者,遍历位的顺序可以是乱序的,也就是说以下代码也是正确的:
std::vector idx(N, 0);
std::iota(idx.begin(), idx.end(), 0);
std::random_device rd;
std::mt19937 rng(rd());
std::shuffle(idx.begin(), idx.end(), rng);
for (int i = 0; i < (1 << N); i++) {
f[i] = A[i];
}
for (int bit = 0; bit < N; bit++) {
for (int i = 0; i < (1 << N); i++) {
if (i >> idx[bit] & 1) {
f[i] += f[i ^ (1 << idx[bit])];
}
}
}
对于这几点(必须先遍历位和可以以任何顺序遍历位)从高维前缀和的角度出发会比较好理解。
前缀和 + 状态压缩思路
我们要求的是子集的总数,那这个东西跟高维前缀和有什么关系呢。
我们定义 \(y \subseteq x\) 当且仅当 \(x \& y = x\),这个条件又可以等价于 \(\forall i, y_i \leq x_i\)。此时若把第 \(i\) 位上的数看成一个点第 \(i\) 维的坐标(维度也从 0 开始编号),那么我们要求的子集的总数,不就相当于一个 \(N\) 前缀和了(放在二维三维空间好想一点)。
于是接下来我们只用解决如何简便高效地求高维前缀和就好了。
传统的,求前缀和我们是用容斥,但是这样太麻烦了,非传统的做法是遍历每一维做单维前缀和。比如对于二维前缀和,我们可以这样求:
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
a[i][j] += a[i][j - 1];
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
a[i][j] += a[i - 1][j];
}
}
用图来表现就是先横扫一遍再竖扫一遍:
对于高维前缀和保证每一维都被扫过一遍就好了(证明见此篇文章)。
但是对于更高维度的我们真的要写如此多循环么?其实我们可以通过状态压缩,把任意维度(若不考虑压缩后状态所占的空间)的高维前缀和简化成两个循环。
对于一个 \(n\) 维的坐标 \(A(x_0, x_1, \dots , x_{n - 1})\)(其中 \(x_i < m\)),我们可以把它压缩成 \(S_A = \sum_{i = 0}^{n - 1}m^{i}x_i\),则 \(A\) 第 \(i\) 维的坐标就是 \(\lfloor {S_A \over m^i}\rfloor \% m\)。于是对于 \(n\) 维前缀和的循环我们就可以这样写:
for (int d = 0; d < n; d++) {
for (int i = 0; i < pow(m, n); i++) {
// 当该维坐标不为 0 时才计算
if (i / pow(m, d) % m) {
a[i] += a[i - pow(m, d)];
}
}
}
当且 \(m = 2\) 时,以上循环就变成了:
for (int d = 0; d < n; d++) {
for (int i = 0; i < (1 << n); i++) {
// 当该维坐标不为 0 时才计算
if (i >> d & 1) {
a[i] += a[i ^ (1 << d)];
}
}
}
可以发现这段代码和从集合角度写出来的式子一模一样就是 SoS dp。而且从高维前缀和的角度出发,也可以比较直观的理解外层循环遍历维度的顺序并不重要,因为每个维度的地位都是相同的。
习题
到这里从两种角度出发的 SoS dp 就介绍完了,可以用下面几题练练手。
浙公网安备 33010602011771号