前言
02-05 组合计数 里我们学过容斥原理:用”全部 - 重复”来计算精确值。容斥的本质其实是 Möbius 变换。这篇要把这个想法推广到子集上——对所有 个子集同时做”前缀和”或”差分”,时间只要 。
为什么这有用?因为很多计数问题涉及”子集的贡献”。比如:对每个集合 ,求 。朴素做法是对每个 枚举子集,。Zeta 变换把它加速到 。
问题的本质
Zeta 变换 = 子集前缀和
给定函数 ,Zeta 变换定义:
就像一维前缀和 一样,只是""换成了""。
Möbius 变换 = 子集差分(Zeta 的逆)
给定 ,求 使得 。由容斥原理:
为什么能加速?
Zeta 变换的朴素实现是 (枚举 和 )。但关键观察:我们可以逐位处理。
考虑第 个比特。如果 的第 位是 1,那 收到的贡献来自两种 :第 位是 0 或是 1。我们可以分步累加——先处理第 0 位,再处理第 1 位,依此类推。每步 ,共 步,总 。
追问:为什么逐位处理是正确的?因为 等价于”每一位 “。对每一位独立做前缀和(0→0 不变,1→0+1 的和),连做 次就得到了所有子集的和。这是标准的”SOS DP”(Sum Over Subsets)技巧。
理论 + 代码
高速 Zeta 变换(子集)
#include <vector>
using namespace std;
// 就地变换:f[T] 变为 sum_{S⊆T} f[S]
void subset_zeta(int n, vector<long long>& f) {
for (int i = 0; i < n; i++) // ① 逐位处理
for (int s = 0; s < (1 << n); s++)
if (s >> i & 1)
f[s] += f[s ^ (1 << i)]; // ② 加上翻转第 i 位后的值
}
逐行解析:
- ① 对每一位 (从 0 到 )做一轮扫描。
- ② 如果 的第 位是 1,则 是 去掉第 位后的子集。把这个子集的值累加到 上。经过 轮后, 就是所有 的原始值之和。
模拟表(,初始 ):
| 初始 | ||||
|---|---|---|---|---|
| 000 | 3 | 3 | 3 | 3 |
| 001 | 1 | 4 | 4 | 4 |
| 010 | 4 | 4 | 8 | 8 |
| 011 | 1 | 5 | 9 | 9 |
| 100 | 5 | 5 | 5 | 8 |
| 101 | 9 | 14 | 14 | 18 |
| 110 | 2 | 2 | 7 | 14 |
| 111 | 6 | 8 | 15 | 31 |
最终 (所有子集之和)。✓
高速 Möbius 变换(子集逆变换)
// 就地变换:f[T] 从 sum_{S⊆T} f[S] 还原为原始值
void subset_mobius(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (s >> i & 1)
f[s] -= f[s ^ (1 << i)]; // ① 把加号改减号
}
逐行解析:
- ① 和 Zeta 变换唯一的区别:
+=改为-=。这就是 Zeta 的逆操作。
上位集合 Zeta 变换
// g[T] = sum_{S⊇T} f[S]
void superset_zeta(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (!(s >> i & 1)) // ① 注意条件取反
f[s] += f[s | (1 << i)]; // ② 加上包含第 i 位的超集
}
OR 卷积
给定 和 (长度 ),求 。
vector<long long> or_convolution(int n, vector<long long> A, vector<long long> B) {
subset_zeta(n, A); // ① Zeta 变换
subset_zeta(n, B);
for (int s = 0; s < (1 << n); s++)
A[s] *= B[s]; // ② 逐点相乘
subset_mobius(n, A); // ③ Möbius 逆变换
return A;
}
逐行解析:
- ①②③ 类似 FFT:Zeta 变换(类比 DFT)→ 逐点相乘 → Möbius 逆变换(类比 IDFT)。总时间 。
例题
例题 1:子集和统计
场景: 种物品,第 种的价值为 。对每个子集 ,定义 。给定 个值 ,已知 。求所有 。
数据范围:
分析:Möbius 逆变换的直接应用。
代码:
#include <cstdio>
#include <vector>
using namespace std;
void subset_mobius(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (s >> i & 1) f[s] -= f[s ^ (1 << i)];
}
int main() {
int N;
scanf("%d", &N);
vector<long long> f(1 << N);
for (int s = 0; s < (1 << N); s++) scanf("%lld", &f[s]);
subset_mobius(N, f); // ① Möbius 逆变换
for (int s = 0; s < (1 << N); s++) {
if (s) printf(" ");
printf("%lld", f[s]);
}
printf("\n");
}
逐行解析:
- ① 对 做 Möbius 逆变换得到 。把 Zeta 的
+=改为-=即可。
例题 2:OR 卷积——集合合并计数
场景:两组 个数 和 。求 。
数据范围:
分析:OR 卷积标准模板。
代码:
#include <cstdio>
#include <vector>
using namespace std;
const long long MOD = 998244353;
void subset_zeta(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (s >> i & 1) f[s] = (f[s] + f[s ^ (1 << i)]) % MOD;
}
void subset_mobius(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (s >> i & 1) f[s] = (f[s] - f[s ^ (1 << i)] + MOD) % MOD;
}
int main() {
int N;
scanf("%d", &N);
vector<long long> A(1 << N), B(1 << N);
for (int s = 0; s < (1 << N); s++) scanf("%lld", &A[s]);
for (int s = 0; s < (1 << N); s++) scanf("%lld", &B[s]);
subset_zeta(N, A); // ① Zeta
subset_zeta(N, B);
for (int s = 0; s < (1 << N); s++)
A[s] = A[s] * B[s] % MOD; // ② 逐点相乘
subset_mobius(N, A); // ③ Möbius
for (int s = 0; s < (1 << N); s++) {
if (s) printf(" ");
printf("%lld", A[s]);
}
printf("\n");
}
逐行解析:
- ①②③ Zeta → 逐点乘 → Möbius,类比 FFT/NTT 的三步流程。
例题 3:上位集合贡献
场景: 个值 。对每个 ,求 。
数据范围:
分析:上位集合 Zeta 变换。
代码:
#include <cstdio>
#include <vector>
using namespace std;
void superset_zeta(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (!(s >> i & 1))
f[s] += f[s | (1 << i)]; // ① 第 i 位为 0 → 加上位为 1 的超集
}
int main() {
int N;
scanf("%d", &N);
vector<long long> f(1 << N);
for (int s = 0; s < (1 << N); s++) scanf("%lld", &f[s]);
superset_zeta(N, f);
for (int s = 0; s < (1 << N); s++) {
if (s) printf(" ");
printf("%lld", f[s]);
}
printf("\n");
}
逐行解析:
- ① 第 位为 0 的集合 ,加上第 位改为 1 的超集 的值。处理完所有位后, 就是所有 的原始值之和。
例题 4:位运算计数——AND 卷积
场景:两组 个数 和 。求 。
数据范围:
分析:AND 卷积用上位集合 Zeta/Möbius 变换。原理与 OR 卷积对称: 且 。
代码:
void superset_zeta(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (!(s >> i & 1)) f[s] = (f[s] + f[s | (1 << i)]) % MOD;
}
void superset_mobius(int n, vector<long long>& f) {
for (int i = 0; i < n; i++)
for (int s = 0; s < (1 << n); s++)
if (!(s >> i & 1)) f[s] = (f[s] - f[s | (1 << i)] + MOD) % MOD;
}
int main() {
// 读入 A, B ...
superset_zeta(N, A); // ① 上位集合 Zeta
superset_zeta(N, B);
for (int s = 0; s < (1 << N); s++)
A[s] = A[s] * B[s] % MOD;
superset_mobius(N, A); // ② 上位集合 Möbius
// 输出 A ...
}
逐行解析:
- ①② 和 OR 卷积完全对称,只是 Zeta/Möbius 换成了上位集合版本。
参考文献
理论讲义 — AtCoder Algorithm Lectures
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶