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, i) = \begin{cases} f(x, i - 1) + f(x \oplus 2^i, i - 1), & \text{若 } x_i = 1 \\ f(x, i - 1), & \text{若 } x_i = 0 \end{cases} \]

最后我们要求的就是 \(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 就介绍完了,可以用下面几题练练手。

posted @ 2025-04-10 13:30  Young_Cloud  阅读(293)  评论(0)    收藏  举报