G状压 DP

综合技巧

枚举子集·计数变形

本课摘要

综合技巧课程回答“子集枚举与计数变形有哪些可复用的位运算技巧”。内容以枚举子集·计数变形为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。

  • 判断综合技巧的适用条件与状态边界
  • 围绕“枚举子集·计数变形”推导转移与计算顺序
  • 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节

正在整理目录…

当转移要「把集合劈成两半」

前三类里,转移都是往集合里加一个元素或一批元素。但有一类问题,转移需要把当前集合 SS 拆成两部分:一部分交给这一步处理、另一部分留给子问题。比如「把 nn 个任务分给若干天、每天做一个子集」,就要枚举「今天做哪个子集」,剩下的递归。

朴素地想:对每个 SS 枚举它的所有子集,再对子集枚举它的子集……听上去是 4n4^n 甚至更糟。但有一个漂亮的事实:「枚举所有集合的所有子集」总共只有 3n3^n 对。因为每个元素对一个 (S,T)(S,T) 对只有三种归属——在 TT 里、在 S∖TS\setminus T 里、或不在 SS 里,共 3n3^n。

母集 S = 1011(元素 0、1、3)S101111011210013101041000500116000170010T = (T−1) & S 只在 S 的 1 位上取值,7 个非空子集全枚举,O(3ⁿ)
母集 S=1011 的全部非空子集,由 T=(T−1)&S 依次生成——只在 S 的 1 位上取值,自动跳过 S 之外的元素。

怎么不重不漏地枚举 SS 的所有非空子集?这就是本类的招牌代码——一行 for:

for(int T=S; T; T=(T-1)&S)\texttt{for(int T=S; T; T=(T-1)\&S)}

它从 T=ST=S 开始,每次令 T←(T−1) & ST\leftarrow(T-1)\ \&\ S。T−1T-1 把最低的 11 位借位变 00、其下方全变 11,再 & S\&\,S 只保留 SS 里有的位——于是 TT 严格递减、且始终是 SS 的子集,直到 00 停止。恰好把 SS 的每个非空子集访问一次。

读懂 (T−1)&S:为什么不重不漏

盯住 S=1011S=1011 看一轮。子集要在 SS 的三个 11 位(第 0、1、3 位)里取值,第 2 位恒为 00:

1101
母集 S=1011:可自由取值的是第 0、1、3 位(描边);第 2 位不在 S 里,任何子集该位都是 0。
1
从 T=S 起步。 T=1011T=1011 是最大的子集(即 SS 自己)。
2
一步 (T−1)&S。 1011−1=10101011-1=1010,1010 & 1011=10101010\ \&\ 1011=1010。跳过了 10101010 与 10111011 之间那些「含第 2 位」的值,直接落到下一个合法子集。
3
继续。 依次得到 1001,1000,0011,0010,00011001,1000,0011,0010,0001,到 00 停。SS 有 3 个 11,非空子集恰 23−1=72^3-1=7 个,全部命中,无一重复。
下面的演示让你自己拼母集 SS,再单步跑 T=(T−1)&S——看它每一步落在哪个子集、如何绕开 SS 之外的位。

亲手跑一遍子集枚举

母集 S(点方块把元素放入 / 移出)
S = 1011 = {0,1,3},共有 7 个非空子集。
12^012^102^212^3
第 1 个子集:T = 1011 = {0,1,3}。 枚举从 T = S 开始。
已暂停,第 1 步,共 7 步,1 倍速

另一副面孔:位掩码 + 附加维做计数

状压的第二类「综合技巧」,是给位掩码再挂一维附加状态,把「求最优」变成「求方案数」。最典型的是排列计数:逐位决定「这一位放哪个数字」,用 mask 记「哪些数字已用」,同时挂一维记录某种附加量——比如「当前拼出的数  mod d\bmod d 的余数」。

主维:已用数字集合 mask0101mask = 0101附加维:当前数 mod d余数 rdp[mask][r] = 方案数
状态 dp[mask][r]:主维 mask 记已用数字集合,附加维 r 记当前数模 d 的余数——位掩码承载「用了谁」,附加维承载「算到哪」。

以「排列」(P4163)为例:给一串数字,求它的全排列中能被 dd 整除的有多少个(数字可能重复)。状态 dp[mask][r]dp[mask][r] = 已用数字集合为 maskmask、当前拼出的数  mod d=r\bmod d=r 的方案数。转移是在末尾追加一个未用的数字 digitidigit_i:

dp[ mask ∣ (1<<i) ][(r⋅10+digiti) mod d]+=dp[mask][r]dp[\,mask\,|\,(1{<}{<}i)\,]\big[(r\cdot 10+digit_i)\bmod d\big]\mathrel{+}=dp[mask][r]

答案是 dp[(1<<n)−1][0]dp[(1{<}{<}n)-1][0]——所有位都用上、且余数为 00(整除)。这里的转移是「加一位」而非「枚举子集」,但同样属于状压综合技巧:mask 之外挂一维,把最优 DP 改写成计数 DP。

本质

「枚举子集」(O(3n))\big(O(3^n)\big) 与「位掩码 + 附加维计数」是状压的两把通用扳手:前者应对「把集合劈成两块」的划分型转移;后者把 dp[mask]dp[mask] 升成 dp[mask][k]dp[mask][k](kk 为附加量,如余数),让状压能数方案、能带取模、能挂任意可累加的辅助信息。它们不是新模型,而是嫁接在前几类骨架上的技巧。

回看「宝藏」:层内枚举子集扩展

上一类里「宝藏」(P3959)是从「集合覆盖」的角度看的;换到「枚举子集」的视角,它其实正是本类技巧的实战——转移时对已连通集合 SS 的补集枚举一个子集 subsub,作为「这一层新接入的点」。

代码里那句 for(int sub=rest; sub; sub=(sub-1)&rest) 就是子集枚举——restrest 是 SS 的补集,枚举它的每个非空子集当作新增的一层。这也解释了为什么状压 DP 常被说成「O(3n)O(3^n) 级别」:一旦转移需要枚举子集,复杂度就从 2n2^n 抬到 3n3^n。

常见陷阱:计数去重、子集别把空集也算进去

排列计数里数字可能重复,若不去重会把「相同数字换位」的等价排列重复计数。稳妥做法:同一层里,相同数字只允许在首次出现的那一位被选(见代码里 digiti=digiti−1digit_i=digit_{i-1} 且前一位未用则跳过)。另外 for(T=S;T;...) 只枚举非空子集——若你的转移需要「空子集」(这一层不接任何点),要另行单独处理,别指望这行循环覆盖它。

例题

P4163[SCOI2007] 排列SCOI2007普及+/提高
题意
给一个数字串和整数 dd,求这些数字的全排列中能被 dd 整除的个数(数字可重复,去重后计数),多组数据。
为什么选它
位掩码 + 取模计数的样板:dp[mask][r]dp[mask][r] 主维记已用数字、附加维记  mod d\bmod d 的余数,还必须处理重复数字去重。把「状压计数变形」的三个要点(掩码、附加维、去重)一次讲全。
状态 · 转移 · 复杂度
dp[mask∣(1<<i)][(r⋅10+di) mod d]+=dp[mask][r]dp[mask|(1{<}{<}i)][(r\cdot10+d_i)\bmod d]\mathrel{+}=dp[mask][r];答案 dp[full][0]dp[full][0];O(2n⋅d⋅n)O(2^n\cdot d\cdot n)。
参考代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

int T;
long long f[1 << 10][1010];      // f[mask][r]:用了数字集合 mask、当前数 mod d = r 的方案数

int main()
{
    cin >> T;
    while (T--)
    {
        char s[15];
        int d;
        cin >> s >> d;
        int n = strlen(s);
        int digit[15];
        for (int i = 0; i < n; i++) digit[i] = s[i] - '0';

        memset(f, 0, sizeof f);
        f[0][0] = 1;                        // 空排列,余数 0

        for (int mask = 0; mask < (1 << n); mask++)
            for (int r = 0; r < d; r++)
            {
                if (f[mask][r] == 0) continue;
                for (int i = 0; i < n; i++)
                {
                    if (mask >> i & 1) continue;        // 第 i 位已用
                    // ★去重:同一层里相同数字只在「首次出现的那位」用一次
                    if (i > 0 && digit[i] == digit[i - 1] && !(mask >> (i - 1) & 1))
                        continue;
                    int nr = (r * 10 + digit[i]) % d;   // 追加一位后的新余数
                    f[mask | (1 << i)][nr] += f[mask][r];
                }
            }

        cout << f[(1 << n) - 1][0] << endl; // 用完所有位、且整除 d
    }
    return 0;
}
P3959[NOIP2017 提高组] 宝藏NOIP2017提高+/省选-
题意
nn 个点、mm 条带权边(n≤12n\le 12),选根建生成树,边代价 = 边权 × 到根层数,求最小总代价。
换个视角
与上一类「覆盖」相比,这里换个角度看它的转移:对已连通集合 SS 的补集枚举子集 subsub 作为新一层。正是 sub=(sub-1)&rest 这行子集枚举,把复杂度抬到 O(3n)O(3^n)——本类技巧的实战范例。
参考代码
子集枚举骨架(配合上一类的宝藏完整代码)
// 枚举集合 S 的所有非空子集 T:经典写法,复杂度对单个 S 是 O(2^popcount(S))
for (int T = S; T; T = (T - 1) & S)
{
    // 这里 T 恰好取遍 S 的每个非空子集
    int rest = S ^ T;           // rest 是 T 在 S 内的补集(另一半)
    // ... 用 (T, rest) 做转移,例如把 S 劈成两块
}

// 对全部 S 求和:Σ 2^popcount(S) = 3^n —— 所以「枚举子集」整体是 O(3^n)

练习

P2831[NOIP2016 提高组] 愤怒的小鸟复用上一类的覆盖 mask:转移也可写成「对未打的猪集合枚举一条线覆盖」。试着把它和补集/子集枚举结合,体会覆盖与子集两种视角的统一。在洛谷打开
P2915[USACO08NOV] Mixed Up Cows G位掩码 + 附加维计数的另一例:f[S][i]=用完集合 S、末位是 i 的合法排列数,附加维就是「末位是谁」。转移追加一头与末位编号差 > K 的牛。在洛谷打开
P3959[NOIP2017 提高组] 宝藏亲手把「层内枚举子集扩展」写一遍:rest=full ^ S,for(sub=rest; sub; sub=(sub-1)&rest) 枚举新接入的一层,注意深度乘子与边权预处理。在洛谷打开

已进入 综合技巧 · 状压 DP · DP大师