高维前缀和 SOS DP
一、热身运动:从一维到二维
我们先回顾一下熟悉的前缀和。
1. 一维前缀和
给你一个数组 a[1...n],求它的前缀和 s[i] = a[1] + ... + a[i]。
这太简单了,一个 for 循环搞定:
s[i] = s[i-1] + a[i]
它的本质是什么?
s[i] 存储了“所有下标小于等于 i 的元素的和”。
2. 二维前缀和
给你一个二维数组(矩阵)a[i][j],求它的前缀和 s[i][j],表示以 (1,1) 为左上角,(i,j) 为右下角的矩形内所有元素的和。
我们也熟悉它的递推公式:
s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]
这背后隐藏着什么思想?
s[i][j] 存储了“所有第一维下标 x ≤ i 且 第二维下标 y ≤ j 的元素的和”。
观察一下:
- 一维是解决
x ≤ i的问题。 - 二维是解决
x ≤ i且y ≤ j的问题。
那么,三维前缀和呢?就是解决 x ≤ i 且 y ≤ j 且 z ≤ k 的问题。
四维、五维... N维呢?都是一个道理。
二、升维打击:另一种计算二维前缀和的思路
刚才那个二维前缀和的公式,是容斥原理得来的。现在我们换一种思路,一种更符合“高维”思想的思路。
目标: 求 s[i][j],即 sum(a[x][y]) 其中 x ≤ i, y ≤ j。
我们可以分两步走:
第1步:只考虑第一维
我们先不管第二维,对矩阵的每一行都做一次一维前缀和。
for i from 1 to R:
for j from 1 to C:
a[i][j] = a[i][j] + a[i][j-1]
执行完这一步后,a[i][j] 的新含义变成了:第 i 行,从第 1 列到第 j 列所有原始元素的和。
即 a[i][j] (新) = ∑ a[i][k] (原),其中 1 ≤ k ≤ j。
第2步:再考虑第二维
现在,在已经更新过的矩阵上,我们对每一列再做一次一维前缀和。
for j from 1 to C:
for i from 1 to R:
a[i][j] = a[i][j] + a[i-1][j]
执行完这一步后,a[i][j] 的最终含义是什么呢?
它等于 (新的 a[i-1][j]) + (新的 a[i][j])
= (第 i-1 行,前 j 列的和) + (第 i 行,前 j 列的和)
= (从第 1 行到第 i-1 行,每一行的前 j 列的和) + (第 i 行,前 j 列的和)
= 从第 1 行到第 i 行,每一行的前 j 列的和
这不就是我们想要的二维前缀和 s[i][j] 吗!
总结一下这个新方法:
我们没有用复杂的容斥,而是按维度,依次进行一维前缀和。
- 先固定行,对所有列做前缀和。
- 再固定列,对所有行做前缀和。
这个思路可以轻松推广到任意维度!
三、主角登场:高维前缀和
现在,假设我们有一个 D 维的“数组” a[i₁][i₂]...[i_D]。
我们要计算它的前缀和 s[i₁][i₂]...[i_D],它表示的是所有满足 x₁ ≤ i₁ 且 x₂ ≤ i₂ 且 ... 且 x_D ≤ i_D 的元素的和。
高维前缀和的算法就是:
对每一个维度
d从1到D,依次进行一次“一维前缀和”操作。
伪代码:
// D 是维度数
// N 是每个维度的最大长度
for d from 1 to D: // 枚举是哪个维度
for i_1 from 1 to N_1: // 枚举第一个坐标
for i_2 from 1 to N_2: // 枚举第二个坐标
...
for i_D from 1 to N_D: // 枚举第 D 个坐标
// 关键一步:只在第 d 维上做前缀和
if i_d > 1:
a[i_1]...[i_d]...[i_D] += a[i_1]...[i_d - 1]...[i_D]
这个伪代码看起来很吓人,有很多层 for 循环。但它的核心思想就是我们刚才讲的二维的例子。
时间复杂度:
每一维的计算,都需要遍历整个 N₁*N₂*...*N_D 的数据。我们总共有 D 个维度。
所以总时间复杂度是 O(D * N₁ * N₂ * ... * N_D)。
四、从“前缀和”到“子集和”:一个重要的变体
在 OI 比赛中,高维前缀和最常见的应用场景并不是处理几何上的多维空间,而是处理位运算中的“子集”问题。
问题:
有一个数组 a[0...2^N-1],下标是 0到 2^N-1。
我们想计算一个新的数组 s,其中 s[mask] 等于所有 submask 是 mask 的子集(submask | mask == mask)的 a[submask] 的和。
即 s[mask] = ∑ a[submask],其中 submask 是 mask 的子集。
这怎么就和高维前缀和扯上关系了?
我们可以把一个 N 位的二进制数,看成是一个 N 维空间中的点!
一个数 mask,它的二进制表示是 (b_{N-1} b_{N-2} ... b_0)。
我们可以把它看成是一个 N 维空间中的点 (b_{N-1}, b_{N-2}, ..., b_0),这个空间的特殊之处在于,每个坐标只能是 0 或 1。
那么,“submask 是 mask 的子集”这个条件,用坐标来描述是什么意思?
submask | mask == mask 意味着,如果 mask 的第 i 位是 0,那么 submask 的第 i 位也必须是 0。如果 mask 的第 i 位是 1,submask 的第 i 位可以是 0 也可以是 1。
这正好对应了我们前缀和的定义!
s[mask] = s[b_{N-1}...b_0] = ∑ a[x_{N-1}...x_0],其中对于所有 i,x_i ≤ b_i。
所以,求“子集和”的问题,完全等价于在一个 N 维,每维大小为 2(坐标只能是0或1)的空间里做高维前缀和!
代码实现(子集和/SOS DP):
这个版本的代码更常用,也更简洁。SOS DP (Sum over Subsets) 是它的另一个名字。
// N 是位数
// a[0...2^N-1] 是初始数组
// dp[mask] 最终存储 s[mask]
// 初始化 dp = a
for (int mask = 0; mask < (1 << N); ++mask) {
dp[mask] = a[mask];
}
// 按维度进行前缀和
for (int i = 0; i < N; ++i) { // 枚举维度 i (第 i 位)
for (int mask = 0; mask < (1 << N); ++mask) {
// 只对第 i 位是 1 的 mask 进行更新
if (mask & (1 << i)) {
// dp[mask] 的值,是从它第 i 位为 0 的那个状态转移过来的
// 也就是 dp[mask] = dp[mask] + dp[mask 异或 (1<<i)]
dp[mask] += dp[mask ^ (1 << i)];
}
}
}
时间复杂度: O(N * 2^N)。
五、总结(划重点)
-
高维前缀和是干嘛的?
计算
D维空间中,s[i₁]...[i_D]= ∑a[x₁]...[x_D](其中x_k ≤ i_kfor all k)。它是普通前缀和向高维的自然推广。 -
核心思想是什么?
降维打击。不要一次性考虑所有维度,而是按维度逐个击破。对每一个维度,都做一次一维前缀和的计算。
-
在OI中,最常见的应用是什么?
子集和问题 (Sum over Subsets, SOS DP)。把一个
N位的二进制数mask看成N维空间中的一个点(b_{N-1}, ..., b_0)。求mask的所有子集的和,就等价于求这个点的高维前缀和。 -
子集和的代码怎么写?
两层循环。外层循环
i从0到N-1(枚举维度),内层循环mask从0到2^N-1(枚举所有状态)。
if (mask & (1 << i)),则dp[mask] += dp[mask ^ (1 << i)]。
高维前缀和/SOS DP 是一个非常强大的工具,能解决很多和位运算、子集、超集相关的计数问题,是进阶选手必须掌握的技巧之一。
浙公网安备 33010602011771号