CSP-S 提高级课程中心 / 算法进阶 / 第41讲
CSP-S 提高级 · 算法进阶 · s5-19

状态压缩动态规划 与 动态规划的常用优化

当一个「集合」成为状态的一部分时,用一个整数就能表示它——这就是状态压缩;而当转移式里出现「区间取最值」时,单调队列能把它从 O(nk) 压到 O(n)。

n ≤ 20 状压DP 2ⁿ 集合状态 O(n) 单调队列优化 3 个经典案例 12 道洛谷原题
本讲 · 第 41/52 讲状态压缩动态规划与动态规划的常用优化
模块 01

情景导航

用「校庆筹备组的三道难题」,把状态压缩与 DP 优化讲成一件很自然的事。

故事校庆筹备组的三道难题

百年校庆临近,你被拉进了筹备组,负责「数据与排班」这一块。组长一口气扔来三份需求,每一份看起来都不难,可仔细一想都不好惹。

难题一(纪律委员的座位表):校庆当天要在 n × n 的观礼方阵里安排 K 名纪律委员。为了互相照应,任意两名委员不能坐在相邻的格子里——注意是「八邻接」,也就是同一行相邻、同一列相邻、以及斜对角相邻都算相邻。请问一共有多少种安排方案?n ≤ 9,K ≤ n²。

难题二(幸运编号的筛选):校庆要发一批纪念徽章,编号从 A 到 B。组委会想把「相邻两位数字之差至少为 2」的编号挑出来做纪念款(比如 130 就不行,因为 |3−0| = 3 其实可以,但 12 不行,因为 |2−1| = 1)。A 和 B 可能高达 2×10⁹,请问一共有多少个纪念款编号?

难题三(实时监控窗口):大屏上要滚动展示各学院的捐款数据,屏幕一次只能显示连续 k 个学院的数据。为了让大屏更有冲击力,每滑动一格,都要立即报出当前窗口内的最大捐款额。数据点有 10⁶ 个,每一次都重新扫一遍窗口太慢了。

三道难题,正好对应本讲的两大主题。

• 难题一的难点在于「当前这一行怎么排」这件事的信息量太大——但如果把「某一行的排布」编码成一个二进制整数(1 表示有人、0 表示空着),状态立刻就能装进数组下标了。这就是状态压缩动态规划;
• 难题二的难点在于「数字太大,没法一个一个枚举」——但如果一位一位地填,每一位只关心「上一位填了什么」,状态就只有十几个,这就是数位 DP;
• 难题三的难点在于「窗口内有大量无用信息」——每次滑动时,那些「又小又靠左」的元素永远不可能成为答案,可以直接丢掉。用一个单调队列维护候选集合,就把 O(nk) 压缩到了 O(n)。这就是单调队列优化。

把集合压成整数、把大数字按位拆开、把无用的候选提前淘汰——这是提高组 DP 的三大「降维」利器。

💡 故事背后的算法思想

共同点:三道题都在面对同一个敌人——状态空间太大。状压的基本思路是「把集合编码成整数,让状态空间从「组合数」变成「2ⁿ」」;数位 DP 的基本思路是「按位拆分,把 10⁹ 规模的枚举变成 10 位 × 10 种选择的 DP」;单调队列优化的基本思路是「把一个 O(k) 的扫描,用数据结构均摊成 O(1)」。

它们的关系:状压与数位 DP 属于「重新设计状态,让状态数变少」;单调队列优化属于「状态不变,但把转移的代价降低」。这两条路线合起来,就是竞赛中说的「DP 优化」。

工程意义:提高组的很多题,朴素 DP 想得到,但过不了——卡的就是「状态数」和「转移代价」这两样。本讲的核心训练目标就是:看到「n ≤ 20」想到状压,看到「求 [L,R] 满足条件个数」想到数位 DP,看到「f[i] 由某个区间的最值转移」想到单调队列。

①生活类比
💡

一排开关 = 一个集合

8 个开关的开关状态,一目了然地记成 0~255 的一个数。集合的「有哪些元素」就变成了整数。

→ 状态压缩
🧮

数位屏 = 一位一位填

电子钟的每一位可以独立调节,调当前位时只需要看一眼前一位是否会产生「跳变」。

→ 数位 DP 按位转移
🚪

排队办事 = 单调队列

队伍里「年纪更小又排在后面」的人,永远轮不到他代表队伍去答题,直接请他出队。

→ 淘汰无用候选
📈

认领长跑配速 = 斜率优化

一堆配速方案里,只有「拐点」上的方案可能最优,中间的都可以直接排除。

→ 凸包 / 决策单调
②核心定义

状态压缩(State Compression):把一个「集合 / 排布方案」用一个整数的二进制位来表示。第 i 位为 1 表示「元素 i 在集合里」,为 0 表示不在。这样「集合」就变成了数组下标,可以直接作为 DP 状态。

状压 DP:状态中含有一个「压缩后的集合」的 DP。典型状态形如 f[S] 或 f[S][i],其中 S 是一个 2ⁿ 范围内的整数。

适用判据:当「需要在状态里记住『哪些元素已经用过 / 哪些位置已经填过』」并且 n ≤ 20 左右(2²⁰ ≈ 10⁶)时,就应该考虑状压。

棋盘式状压:DP 按行推进,每一行的填法用一个整数的二进制表示(n 列 → n 位)。状态形如 f[i][S]:第 i 行的填法是 S。转移时检查相邻两行的 S 是否冲突。

轮廓线 DP:不按行推进,而是逐个格子推进。状态是「已处理的格子的边界(轮廓线)」上那 m 个格子的填法,共 2^m 种。它比按行状压更通用(例如铺砖 / 插头问题)。

数位 DP:求「区间 [L, R] 中满足某种『与数字各位有关』的条件的数的个数」。核心是按位从高到低填数,状态里记住「当前位、上一位的值、是否贴着上界」,并把区间答案用 f(R) − f(L−1) 求出。

单调队列优化:当转移式形如 f[i] = min/max{ f[j] + w(j) } (j ∈ [i−k, i−1]),即「从一个固定长度的滑动区间里取最值」时,用双端队列维护「值单调」的候选集合,把每次转移从 O(k) 降到均摊 O(1)。

📌 本讲核心知识地图

板块核心内容对应大纲
位运算取第 i 位、置位、清位、取最低位、枚举子集状态压缩动态规划【8】
集合型状压f[S] / f[S][i];TSP、哈密顿路径、集合覆盖状态压缩动态规划【8】
棋盘式状压f[i][S] 按行推进;行内合法 + 相邻行冲突检查状态压缩动态规划【8】
轮廓线 DP逐格推进;状态为轮廓线上 m 格的填法状态压缩动态规划【8】
数位 DP按位填;状态 (pos, 上一位, limit, lead);前缀差分动态规划优化【8】
单调队列优化滑动区间取最值;O(nk) → O(n)动态规划优化【8】
其它优化滚动数组、前缀和优化、斜率优化、矩阵快速幂动态规划优化【8】
③核心口诀
🔑 状压诀

n ≤ 20 就上状压,集合编码成整数;第 i 位是 1 就是「选了 i」,是 0 就是「没选」。
转移时用位运算做交集、并集和冲突判断,速度极快。

🔑 数位诀

区间答案拆两半,f(R) 减 f(L−1);从高位往低位填,记住「上一位」和「有没有顶到上界」。
顶到上界的路径不能记忆化(因为它是唯一的),不顶界的才能缓存复用。

🔑 优化诀

区间取最值 → 单调队列;区间求和 → 前缀和;只依赖上一行 → 滚动数组;固定长度递推 → 矩阵快速幂;决策有单调性 → 斜率优化 / 决策单调性。
先写对朴素 DP,再按「转移长什么样」挑优化。

模块 02

知识探究

先把「集合 → 整数」这套编码学会,再掌握棋盘式与轮廓线两种状压形态,最后补上数位 DP 与四大优化工具。

1️⃣位运算与状态表示

为什么需要「压缩」?假设有 8 个格子,每个格子「放 / 不放」共 2⁸ = 256 种排布。如果想把这个排布当作 DP 的下标,最直觉的做法是开一个 8 维数组 f[2][2][2][2][2][2][2][2]——显然荒唐。而把每一位当成一个「开关」,256 种排布就对应 0~255 这 256 个整数,用一维数组就装下了。

编码约定:整数 S 的第 i 位(从 0 开始编号,即 (S >> i) & 1)表示「第 i 个元素是否被选中」。

操作写法说明
取第 i 位(S >> i) & 1判断元素 i 是否在集合里
置第 i 位为 1S | (1 << i)把元素 i 加入集合
清第 i 位为 0S & ~(1 << i)把元素 i 移出集合
翻转第 i 位S ^ (1 << i)在 / 不在 互相切换
取最低位的 1S & (-S)常用于「逐位提取」
去掉最低位的 1S & (S - 1)常用于「统计 1 的个数」
集合大小__builtin_popcount(S)GCC 内置,编译为单条指令
全集(1 << n) - 1n 个元素全都在集合里
子集判定(A & S) == AA 是 S 的子集当且仅当 A&S == A
不相交判定(S & T) == 0两个集合没有公共元素

⚠️ 运算符优先级陷阱

位运算符的优先级低于比较运算符,高于赋值运算符。所以:
· S & 1 == 1 会被解释成 S & (1 == 1),即 S & 1——本例恰好不出错,但含义已经变了;
· S & 1 << 2 会被解释成 S & (1 << 2) 而不是 (S & 1) << 2;
· T = T - 1 & S 会出错,正确写法是 T = (T - 1) & S。
结论:写位运算时,凡是心里有点不踏实的括号,全都补上。多加一对括号永远不会错,少加一对可能调试一小时。

2️⃣枚举子集与合法性判断

基本枚举:把所有 2ⁿ 个集合都遍历一遍,是最朴素的写法:

for (int S = 0; S < (1 << n); S++) {
    // 处理集合 S
}

枚举「S 的所有子集」:这是一个非常常用且巧妙的写法,复杂度正好是 O(3ⁿ)(因为每个元素有「在 S 里 / 在子集里 / 都不在」三种状态):

for (int T = S; T; T = (T - 1) & S) {
    // T 依次取遍 S 的所有非空子集(从大到小)
}
// 别忘了 T = 0(空集)

为什么 (T - 1) & S 能枚举出所有子集?因为 T - 1 会把 T 里最低的 1 变成 0、并把它右边的 0 全变成 1;再与 S 求交,就恰好得到「S 中小于 T 的最大子集」。这个操作不重不漏地遍历所有子集,是状压 DP 的「标准武器」。

S = 0101
T = 0101
T = 0100
T = 0001
T = 0000

以 S = 0101(十进制 5)为例,枚举子集依次得到 5 → 4 → 1 → 0,共 4 个子集,正好是 2² 个(因为 S 里有 2 个 1)。

常见的「行内合法性」判断(棋盘式状压必备):

约束位运算判据含义
行内不能有相邻的 1(S & (S << 1)) == 0任何一位与它左边一位不同时为 1
行内 1 之间至少隔 1 个 0(S & (S << 2)) == 0炮兵阵地中「炮不能互相打到」
不能放在指定的禁用格(S & ban[i]) == 0ban[i] 的第 j 位为 1 表示第 i 行第 j 列不能放
相邻两行不能同列冲突(S & P) == 0上下相邻同列都放了
相邻两行不能斜角冲突(S & (P << 1)) == 0 且 (S & (P >> 1)) == 0左上 / 右上对角冲突
🎬 交互演示:状压 DP 的「加入一个元素」转移过程
1初始集合 S = 0000(一个元素都没选),此时「已走过的点集合」是空集,站在起点 0 号点。
2选择一个还没在 S 里的元素 1:S' = S | (1 << 1) = 0010。判断条件是 (S >> 1) & 1 == 0,即元素 1 当前不在集合里。
3再从 S' 出发,选元素 2:S'' = S' | (1 << 2) = 0110。注意:每一步只能「加入」元素,不能「删除」,所以 S 的数值只会越来越大。
4这一点非常重要:「只能加入」保证了状态之间构成一个有向无环图。因为 S 单调递增,所以只要按 S 从小到大枚举,就天然满足了 DP 的拓扑序,不会出现循环依赖。
5继续加入元素 0:S''' = 0110 | 0001 = 0111;再加入元素 3:= 1111。此时集合满了,到达了「所有元素都选过」的终点状态。
6综合起来,转移式就是 f[S | (1<<i)][i] = min(f[S | (1<<i)][i], f[S][j] + w(j, i)),其中要求 ((S >> i) & 1) == 0 且 ((S >> j) & 1) == 1。
提示:状压 DP 的两个枚举顺序——外层按 S 从小到大,内层枚举「刚加入的那个元素 i」和「上一个元素 j」。这是 f[S][i] 形态的标准写法。

🔑 为什么按 S 从小到大枚举就够?

因为转移总是「S → S | (1<<i)」,而后者的数值严格大于前者(因为把一个 0 位变成了 1)。
所以当我们在计算 f[S] 时,所有能转移到 S 的更小状态都已经算完了——数值大小天然就是拓扑序。
这就是状压 DP 不需要额外拓扑排序的原因。一个技巧:任何时候发现状态转移有「环」,先想想能不能改成「单调增」的形式。

3️⃣棋盘式状压:按行推进的标准套路

问题的形状:n 行 m 列的棋盘(通常 m ≤ 10 左右),每个格子「放 / 不放」,有若干「互不攻击」的约束,求方案数或最优值。

为什么按行压、不按列压?因为约束通常是「相邻行之间」的,而一行的列数 m 很小(≤ 10),所以「一行的填法」只有 2^m 种,把它压成一个整数当作状态刚刚好。取行数少的那个方向做压缩维度。

四步套路:

1
🔍
预处理合法行状态

把一行内部就非法的状态(如相邻两个 1)全部筛掉,只留下 ok[] 列表。这样后续枚举量能大幅减少。

2
🔢
预处理每状态的代价

用 __builtin_popcount(S) 算出这一行放了多少个,作为「消耗」或「收益」。

3
➡️
按行递推

f[i][S][k]:第 i 行填 S、前 i 行共用 k 个的方案数。枚举上一行 P ∈ ok[] 并检查冲突。

4
🎯
汇总答案

最后把第 n 行所有合法状态的答案加起来(或按题目要求取 min/max)。

111 ✕冲突 111 上一行 S = 101 下一行 P = 101 与 P 的左上/右上冲突 与 S 不冲突,合法

左图(❌ 冲突):上一行 S = 101(第 0、2 列有棋子),下一行 P = 101。虽然 S & P = 101 ≠ 0 已经能判出「同列冲突」,但即使避开同列,只要上下两行的 1 在相邻列,也会形成斜角冲突。

右图(✅ 合法):P 的 1 落在与 S 完全「错开」的位置。

三个必备判据:
· 同列:S & P == 0
· 左上斜:S & (P << 1) == 0
· 右上斜:S & (P >> 1) == 0

为什么 P << 1 对应「左上」?因为把 P 整体左移一位后,第 j 位就变成了原来第 j−1 位(左邻列)的值。与 S 求交,正好检查「S 的第 j 列」与「P 的第 j−1 列」是否同时为 1。

🔑 棋盘状压诀

一行的填法压成整数;先筛行内合法,再查相邻冲突;同列与两斜一共三查。
n 行 m 列时,取 m 很小的那一维做压缩,状态数 2^m。

4️⃣轮廓线 DP:逐格推进的更通用形态

按行状压的局限:如果棋盘不是规则的矩形(比如有障碍、有拐弯),或者约束不是「相邻行之间」而是「每个格子与它的上下左右」有关,按行压就会很别扭。

轮廓线 DP 的想法:不按行推进,而是一个格子一个格子地推进(从左到右、从上到下)。当我们正在决定格子 (i, j) 的填法时,真正会影响它的只有「已经处理过、且与它相邻」的那些格子——具体来说就是它的上邻格 (i−1, j) 和左邻格 (i, j−1),再加上同一行左边几个还没来得及被「上邻」覆盖的格子。

轮廓线是什么:想象一把「梳子」扫过棋盘。梳齿上方是已处理的格子,下方是未处理的。如果把 (i, j) 左上方这条边界(共 m 个格子)的填法记下来,就能唯一确定「接下来每个格子的可选项」。这条边界就叫轮廓线,它上面的 m 个格子恰好可以用 m 位二进制表示,状态数是 2^m——与按行状压的规模完全相同,但适用范围广得多。

按行状压

推进单位:一整行
状态:本行 + 上一行的填法(2 个 m 位整数)
优点:代码直观、常数小、易于预处理合法状态
适用:规则的矩形棋盘 + 行间约束

轮廓线 DP

推进单位:单个格子
状态:轮廓线上 m 个格子的填法(1 个 m 位整数)
优点:能处理不规则形状、格子间任意邻接约束
适用:铺砖、插头、骨牌覆盖、复杂棋盘

一个直观的例子:多米诺骨牌铺砖。用 1×2 的骨牌铺满 n×m 的棋盘,求方案数。轮廓线 DP 的状态含义是「轮廓线上每个位置:若为 1,表示该格已经被一块横放的骨牌占据(伸过来了);若为 0,表示该格还空着等待处理」。每到一个新格子,只有两种决策:横放一块(占两格)或竖放一块(占上下两格),据此修改轮廓线的对应位并转移到下一个格子。状态数 2^m,总复杂度 O(n·m·2^m)。

💡 学习建议:先掌握按行,再理解轮廓线

提高组考场上,90% 的状压题都是「按行状压」(互不侵犯、玉米田、炮兵阵地、国王、牧场的安排……)。轮廓线 DP 属于更进阶的技巧,主要出现在省选及以上。
但理解轮廓线 DP 的价值在于它揭示了状压的本质:所谓「压缩」,压的其实不是「一行」,而是「所有决策都会用到的那条边界」。抓住这一点,状压就从「背模板」变成了「有方法论」。

5️⃣数位 DP:把 10⁹ 的枚举拆成十几次选择

问题的形状:求区间 [L, R] 中满足某种「与数字各位有关」的条件的数的个数。例如「相邻两位数字之差不小于 2」「数位中不含 4」「各位数字之和是 3 的倍数」……L、R 可达 10⁹ 甚至 10¹⁸。

两个核心思想:

思想一:区间差分。answer(L, R) = f(R) − f(L−1),其中 f(x) 表示「[0, x] 中满足条件的数的个数」。这样就把「区间问题」变成「前缀问题」,只需要写一个函数。这是数位 DP 的第一步,也是必写的一步。

思想二:按位填数 + 贴界标记。把 x 的每一位拆出来(比如 12345 拆成 1,2,3,4,5),然后从高位到低位一位一位地填。填第 pos 位时,需要知道两件事:
① 上一位填了什么(因为条件通常涉及相邻位);
② 前面的位是否和 x 完全相同(如果完全相同,那么当前位就不能超过 x 的这一位,否则后面的位可以随便填 0~9)。这个标记通常叫 limit(或「贴界」「压上界」)。

STEP 1拆分 x 的各位
STEP 2设计状态
STEP 3确定枚举上界
STEP 4逐位枚举并剪枝
STEP 5记忆化缓存

为什么 limit 为真时不能记忆化?这是数位 DP 最容易讲不清、也最容易写错的一点:

✅ limit = false 时可以缓存

此时「后面的位可以自由填 0~9」。无论前面填的是什么(只要上一位的值相同),剩余位的方案数都是固定的——与「从哪个数走过来的」无关。所以同一个 (pos, last) 可以被成千上万条路径复用。

❌ limit = true 时不能缓存

「贴着上界」的路径全世界只有一条(就是 x 自己那条)。为一棵只走一次的子树建缓存毫无意义,而且如果把它塞进缓存,后面不贴界的状态读到了它,就会得到错误的答案。所以记忆化必须加 if (!limit) 的条件。

还有两个细节:
· 前导零(lead):数字 0~999 中的「234」实际是 000234。前导的 0 不应该参与「相邻两位之差」的判断。所以要额外用一个 lead 标记表示「目前填的全是前导零」,或者用 last = 11 这样的特殊值表示「还没有上一位」。
· 答案要减 1:如果题目求的是正整数个数,而 f(x) 统计了 0,记得把那个「全是前导零」的 0 减去。建议写完后用 x = 10 这种小数据手工验证一遍。

🔑 数位诀

区间拆两半,按位从高填;记住上一位,盯紧贴界位;贴界不缓存,前导零别算。

6️⃣DP 优化总览:先写对,再写快

核心前提:任何优化都不能改变 DP 的正确性。正确顺序永远是「① 写出朴素 DP → ② 对拍验证 → ③ 再上优化」。跳过第① 步直接套优化模板,是提高组失分的重要原因。

优化方法触发信号(转移长什么样)效果
滚动数组f[i] 只依赖 f[i−1](或前几行)空间 O(nm) → O(m)
前缀和优化f[i] = Σj∈区间 g[j],区间端点单调时间 O(nk) → O(n)
单调队列优化f[i] = min/max{ f[j] + w(j) },j ∈ [i−k, i−1]时间 O(nk) → O(n)
斜率优化转移式中 f[j] 与 j 以「乘积」形式纠缠在一起时间 O(n²) → O(n)
决策单调性 / 分治最优决策点 opt[i] 随 i 单调不减时间 O(n²) → O(n log n)
矩阵快速幂固定长度的线性递推,且 n 极大(10¹⁸)时间 O(n·k) → O(k³ log n)
二进制拆分多重背包中每种物品有 c 个时间 O(n·V·c) → O(n·V·log c)
四边形不等式区间 DP 中代价满足凸性时间 O(n³) → O(n²)

重点讲解:单调队列优化(本讲的核心优化工具)

它的适用信号非常明确:转移要「从一个长度固定的滑动窗口里取最值」。典型形态是

f[i] = max/min{ f[j] + w(j) } ,其中 j ∈ [i − k, i − 1]

朴素做法对每个 i 都扫一遍窗口,总复杂度 O(nk)。而用单调队列,每个元素最多进队一次、出队一次,总复杂度降到 O(n)。

单调队列维护的是什么

队列里存的是下标,且这些下标对应的值保持单调(求最大值时从队首到队尾单调递减)。
· 求最大值:队首就是窗口内的最大值的下标;
· 入队时:把队尾那些「值比新元素还小」的全部弹出,因为新元素比它们更靠右、更大,它们在未来的任何窗口里都不可能胜出;
· 出队时:队首下标若已经滑出窗口范围,就弹出。

核心直觉:「更靠右且更大」者必胜

对一个候选 j 来说,它的「竞争力」取决于两件事:值够不够优、还能在窗口里待多久。
如果后来的 j′ 满足「值不比 j 差」而且「位置更靠右(待得更久)」,那么 j 就永远没有出头之日了——任何包含 j 的未来窗口都包含 j′,而在那个窗口里 j′ 至少不差。
这就是单调队列能「淘汰」元素的唯一理由,也是它从 O(k) 变 O(1) 的全部秘密。

其它优化的简要说明:
· 前缀和优化:把「区间求和」预处理成前缀和,O(1) 查询,是最简单也最常用的优化;
· 斜率优化:当转移式能整理成 f[i] = min{ k(i)·x(j) + y(j) } + b(i) 的形式时,可以把每个 j 看作平面上的点,用凸包 + 二分/单调栈加速;
· 矩阵快速幂:若 f[i] = a₁f[i−1] + a₂f[i−2] + … + a_k f[i−k],则把 k 维状态向量写成列向量,用 k×k 的转移矩阵表示,然后 M^(n) 用快速幂求,复杂度 O(k³ log n);
· 二进制拆分:多重背包中把「c 个同种物品」拆成 1、2、4、8… 的若干堆,每堆当作一个 0/1 物品,把 O(c) 降到 O(log c)。

案例 1互不侵犯(洛谷 P1896 / SCOI2005)
经典棋盘式状压 DP

题目描述

在 n × n 的棋盘里面放 k 个国王,使他们互不攻击。国王能攻击到它上下左右以及左上、右上、左下、右下八个方向(共八邻接)的格子。求共有多少种摆放方案。n ≤ 9,k ≤ n²。

输入格式:一行两个整数 n, k。
输出格式:一行一个整数,表示方案数。

输入样例:3 3 输出样例:8

💡 题目分析

为什么用状压:n ≤ 9,一行的填法只有 2⁹ = 512 种,把「这一行哪里放了国王」压成整数刚好合适。

状态:f[i][S][t] = 前 i 行已经摆好、第 i 行的摆法是 S、前 i 行一共放了 t 个国王的方案数。

转移:枚举上一行的摆法 P,若 (S, P) 不冲突,则 f[i][S][t] = Σ f[i−1][P][t − popcount(S)]。

三重冲突检查(本题的核心):
· 行内:S 的两个 1 不能相邻 → S & (S << 1) == 0(预处理时筛掉);
· 同列:S & P == 0;
· 左上斜:S & (P << 1) == 0;
· 右上斜:S & (P >> 1) == 0。

答案:ΣS ∈ ok f[n][S][k]。

复杂度:状态数 O(n · 2ⁿ · k),转移枚举 O(2ⁿ),总共 O(n · 4ⁿ · k)。n = 9 时约 9 × 262144 × 81 ≈ 1.9×10⁸ —— 偏大但可过;如果先筛掉行内非法的状态,实际合法状态只有 89 个(n=9 时),复杂度立刻降到 9 × 89² × 81 ≈ 5.8×10⁶,轻松通过。

这就是「预处理合法状态」的威力:把 512 种状态筛到 89 种,快了 33 倍。

C++P1896 互不侵犯 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, K;
long long f[12][1 << 10][90];  // f[i][S][t]: 前 i 行、第 i 行状态 S、共 t 个国王的方案数
int cnt[1 << 10];             // cnt[S] = S 中 1 的个数
vector<int> ok;                  // 行内合法的状态列表

int main() {
    cin >> n >> K;
    int FULL = 1 << n;

    // ---------- 第一步:筛出行内合法的状态 ----------
    for (int S = 0; S < FULL; S++) {
        if (S & (S << 1)) continue;      // 行内有相邻的 1,非法
        ok.push_back(S);
        cnt[S] = __builtin_popcount(S);
    }

    // ---------- 第二步:初始化第 1 行 ----------
    for (int S : ok)
        if (cnt[S] <= K) f[1][S][cnt[S]] = 1;

    // ---------- 第三步:按行递推 ----------
    for (int i = 2; i <= n; i++) {
        for (int S : ok) {                  // 本行状态
            for (int P : ok) {              // 上一行状态
                if (S & P) continue;                        // 同列冲突
                if (S & (P << 1)) continue;              // 左上斜冲突
                if (S & (P >> 1)) continue;              // 右上斜冲突
                for (int t = cnt[S]; t <= K; t++)
                    f[i][S][t] += f[i - 1][P][t - cnt[S]];
            }
        }
    }

    // ---------- 第四步:汇总第 n 行的答案 ----------
    long long ans = 0;
    for (int S : ok) ans += f[n][S][K];
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 3 3
输出: 8

说明: 3×3 的棋盘放 3 个互不攻击的国王,共有 8 种方案
      例如:第一行放第 1、3 列 + 第三行放第 1 列,就是一种;
      四个角任选 3 个有 C(4,3)=4 种,含边中点的还有 4 种。

输入: 4 4
输出: 79

⚠️ 三个必须注意的细节

① 三处冲突检查一个都不能少。很多同学只写了 S & P(同列)就交卷,结果斜角冲突漏判,答案偏大。正确的检查顺序是:先 S & P,再 S & (P<<1),最后 S & (P>>1)。

② 状态数组的第三维要开够。k 最大为 n² = 81,所以第三维要开到 82 以上。本代码写 [90],留有余量。

③ 答案用 long long。方案数增长很快(n=9、k=41 时答案可达 10¹⁷ 量级),int 必然溢出。凡是「方案数」类的问题,第一反应就是用 long long。

④ 一个常见的写法优化:可以预先算出「每个状态 S 的兼容状态列表」,把三重循环变成两重。当 ok[] 的长度是 89 时,预处理的收益非常明显。

案例 2windy 数(洛谷 P2657 / SCOI2009)
进阶数位 DP

题目描述

不含前导零且相邻两个数位之差至少为 2 的正整数被称为 windy 数。求在 A 和 B 之间(含 A、B)有多少个 windy 数。1 ≤ A ≤ B ≤ 2×10⁹。

输入格式:一行两个整数 A, B。
输出格式:一行一个整数。

输入样例:1 10 输出样例:9

💡 题目分析

第一步:区间差分。答案 = f(B) − f(A−1),其中 f(x) 表示 [0, x] 中 windy 数的个数。

第二步:按位填数。把 x 的各位拆成 a[len..1](高位在前),从最高位开始填。

状态设计(记忆化搜索的四个参数):
· pos:当前要填第几位(从 len 递减到 0,pos == 0 表示填完了);
· last:上一位填的数字(用 11 表示「还没有上一位」,即前面全是前导零);
· limit:前面的位是否与 x 完全相同(是则当前位最多填 a[pos],否则可填 0~9)。

转移:枚举当前位填 d(0 ≤ d ≤ 上界):
· 若 last != 11(已经有上一位)且 |d − last| < 2,直接跳过;
· 否则递归到 dfs(pos−1, 新的 last, 新的 limit)。
其中「新的 last」= 若 last == 11 && d == 0 则仍为 11(仍是前导零),否则为 d。

记忆化:只在 !limit 时把结果存进 f[pos][last]。limit 为真的路径不能缓存,原因见上文的讲解。

答案修正:dfs(...) − 1,因为上面的统计把「数字 0」(全前导零)也算成了合法的 windy 数,而题目求的是正整数。

复杂度:状态数 len × 12(last 的取值)× 10(枚举 d),约 10 × 12 × 10 = 1200 次,几乎瞬间完成。从 2×10⁹ 到 10³,这就是数位 DP 的威力。

C++P2657 windy 数 —— 完整代码(记忆化搜索版)
#include <bits/stdc++.h>
using namespace std;

int a[15];              // a[1] 是最低位,a[len] 是最高位
int f[15][13];           // f[pos][last],last ∈ [0,9] ∪ {11}

// last = 11 表示「前面全是前导零,还没有真正的上一位」
int dfs(int pos, int last, bool limit) {
    if (pos == 0) return 1;                  // 填完了,这一条路径合法
    if (!limit && f[pos][last] != -1) return f[pos][last];

    int up = limit ? a[pos] : 9;          // 当前位能填的最大值
    int res = 0;
    for (int d = 0; d <= up; d++) {
        if (last != 11 && abs(d - last) < 2) continue;   // 与上一位冲突
        int nlast = (last == 11 && d == 0) ? 11 : d;      // 处理前导零
        res += dfs(pos - 1, nlast, limit && (d == up));
    }
    if (!limit) f[pos][last] = res;      // 只有不贴界才缓存
    return res;
}

int solve(int x) {
    if (x <= 0) return 0;
    int len = 0;
    while (x) { a[++len] = x % 10; x /= 10; }
    memset(f, -1, sizeof(f));
    return dfs(len, 11, true) - 1;        // 减去数字 0 自己
}

int main() {
    int A, B;
    cin >> A >> B;
    cout << solve(B) - solve(A - 1) << "\n";
    return 0;
}
运行结果
输入: 1 10
输出: 9

说明: 1~9 都是 windy 数(个位数没有相邻数位,天然满足)
      10 不是,因为 |1 − 0| = 1 < 2
      所以共 9 个

输入: 25 50
输出: 20

逐段列举(共 20 个):
  25~29(|2-5|=3 起,全部合法)→ 5 个
  30、31(|3-0|=3、|3-1|=2)→ 2 个;32~34 不合法
  35~39(|3-5|=2 起)→ 5 个
  40、41、42(|4-0|=4、3、2)→ 3 个;43~45 不合法
  46~49(|4-6|=2 起)→ 4 个
  50(|5-0|=5)→ 1 个
  合计 5+2+5+3+4+1 = 20 ✅

⚠️ 数位 DP 的四个高频陷阱

① 忘记 if (!limit) 就记忆化。会把「贴界那条唯一路径」的结果污染到所有状态上,答案偏小或偏大,而且很难 debug。判断方法:写完后用手算的小数据(如 A=1, B=10)先验证。

② 前导零处理不好。如果不区分前导零,那么 0~99 中的「7」被当成 007,判断 |0 − 0| = 0 < 2 就直接把 7 判成非 windy,答案大错。用 last = 11 这样的哨兵值是最省事的写法。

③ 多组数据忘记清空 memo 数组。每次调用 solve() 都要 memset(f, -1, sizeof(f));否则上一组的缓存会污染这一组。注意:只有当「条件的判断与具体上界无关」时才能复用缓存,本题的 windy 条件与上界无关,所以每组清一次就够了。

④ 边界返回值的含义要一致。pos == 0 时返回 1,表示「这条填法构成一个合法数」。如果你在中间层就把不合法的路径 continue 掉了,那么到达 pos == 0 的路径一定是合法的,返回 1 才对。若把判断放在 pos == 0 处,就要返回「是否合格」的 0/1。

通用调试方法:写一个暴力函数枚举 0~x,与数位 DP 的结果对拍,在小数据(x ≤ 10000)上验证正确性。数位 DP 的错误往往只在大数据上才暴露,对拍是最可靠的验证手段。

案例 3单调队列优化(洛谷 P1886 滑动窗口)
核心技巧DP 优化 · 单调队列

题目描述

有一个长度为 n 的数列,和一个大小为 k 的滑动窗口。窗口从最左端滑到最右端,每次滑动一格。请求出每个时刻窗口中的最大值与最小值。n ≤ 10⁶。

输入格式:第一行两个整数 n, k;第二行 n 个整数。
输出格式:两行,第一行是每个位置的最小值,第二行是每个位置的最大值。

输入样例:8 3
1 3 -1 -3 5 3 6 7
输出样例:
-1 -3 -3 -3 3 3
3 3 5 5 6 7

💡 题目分析

朴素做法:对每个窗口扫一遍求最值 O(nk) 超时
n = 10⁶、k = 10⁵ 时是 10¹¹ 次运算,必然超时。

单调队列做法 O(n) 推荐

核心直觉:假设我们在求「窗口最大值」。当前窗口里有两个位置 j₁ < j₂,且 a[j₁] ≤ a[j₂]。那么从今往后的任何一个包含 j₁ 的窗口,都一定也包含 j₂(因为 j₂ 更靠右,滑出去得更晚),而在那个窗口里 a[j₂] ≥ a[j₁]。
所以 j₁ 永远不可能成为答案——可以立刻把它丢掉。不断丢掉这种「又被压制、又活得更短」的元素,剩下的候选就自然形成了一个单调递减的队列。

三步操作(求最大值):
① 出队首:若队首下标已经滑出窗口(q.front() <= i − k),弹出;
② 挤队尾:把队尾所有 a[q.back()] ≤ a[i] 的元素弹出(它们被 i 完全压制);
③ 入队:把 i 压入队尾。此时 a[q.front()] 就是当前窗口的最大值。

复杂度:每个元素最多进队一次、出队一次,总操作次数 O(n),均摊 O(1) 每次。

与 DP 优化的关系:凡是形如 f[i] = max{ f[j] + w(j) }(j ∈ [i−k, i−1]) 的转移,都可以用这套方法把「枚举 j」这一步从 O(k) 降到 O(1)。滑动窗口最大值是这套技术最纯粹的形态,学好它,DP 优化就成功了一半。

C++P1886 滑动窗口 —— 完整代码(deque 版,同时求最大与最小)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000006;
int n, k, a[MAXN];
int mn[MAXN], mx[MAXN];       // 答案数组

int main() {
    scanf("%d %d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    // ---------- 求最小值:队列内下标对应的值单调递增 ----------
    deque<int> q;
    for (int i = 1; i <= n; i++) {
        while (!q.empty() && q.front() <= i - k) q.pop_front();   // ① 滑出窗口
        while (!q.empty() && a[q.back()] >= a[i]) q.pop_back();   // ② 队尾更大者被压制
        q.push_back(i);                                            // ③ 入队
        if (i >= k) mn[i - k + 1] = a[q.front()];
    }

    // ---------- 求最大值:队列内下标对应的值单调递减 ----------
    q.clear();
    for (int i = 1; i <= n; i++) {
        while (!q.empty() && q.front() <= i - k) q.pop_front();
        while (!q.empty() && a[q.back()] <= a[i]) q.pop_back();   // 注意这里是 <=
        q.push_back(i);
        if (i >= k) mx[i - k + 1] = a[q.front()];
    }

    for (int i = 1; i <= n - k + 1; i++) printf("%d%c", mn[i], " \n"[i == n - k + 1]);
    for (int i = 1; i <= n - k + 1; i++) printf("%d%c", mx[i], " \n"[i == n - k + 1]);
    return 0;
}
运行结果
输入: 8 3
      1 3 -1 -3 5 3 6 7
输出: -1 -3 -3 -3 3 3
      3 3 5 5 6 7

验证最大值:
  窗口[1,3,-1] → 3
  窗口[3,-1,-3] → 3
  窗口[-1,-3,5] → 5
  窗口[-3,5,3] → 5
  窗口[5,3,6] → 6
  窗口[3,6,7] → 7
完全吻合

⚠️ 单调队列的四个易错点

① 队列里存的是「下标」,不是「值」。因为需要判断「是否滑出窗口」(q.front() <= i − k),必须知道位置。只存值就没法判断过期。

② 出队的比较符决定了「单调方向」。求最小值时要弹出队尾所有 a[q.back()] >= a[i] 的(保留更小的);求最大值时要弹出 a[q.back()] <= a[i] 的。写成 > 还是 >=,对于「有重复元素」的题目会直接影响正确性(一般用 >=/<= 更保险,因为把相等的旧元素丢掉更有利于「新元素活得更久」)。

③ 出队首和挤队尾的顺序。必须先处理「过期」(队首),再处理「压制」(队尾),最后入队。顺序反了可能把刚入队的元素又当成过期元素弹掉。

④ 输出时机。i >= k 时才形成第一个完整窗口;窗口总数是 n − k + 1 个。不要输出 n 个答案。

推广到 DP 优化:当转移式是 f[i] = min{ f[j] } + w(i)(j ∈ [i−k, i−1]) 时,把上面代码里的 a[j] 换成 f[j],就得到了单调队列优化的 DP。区别只在于:入队时机要调整——必须保证求 f[i] 时 f[i−1] 已经入队,通常是「先取队首算 f[i],再把 i 入队」。

模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1集合型状压 DP

最短哈密顿路径

有 n 个点(n ≤ 18),给出两两之间的距离 w[i][j]。从 0 号点出发,恰好访问每个点一次,求走完全程所需的最短总路程。
输入格式:第一行 n;接下来 n 行,每行 n 个整数,表示距离矩阵。
输出格式:一行一个整数。

输入样例:
4
0 3 8 10
3 0 5 7
8 5 0 2
10 7 2 0
输出样例:10

💡 题目分析

为什么用状压:n ≤ 18 是一个强烈的信号——20 以内的规模,2ⁿ 可以接受,而且「哪些点已经访问过」正是最需要用状态记住的信息。

状态:f[S][i] = 已经访问过的点集合为 S(S 的第 j 位为 1 表示点 j 访问过),且当前停在点 i 时的最短总路程。要求 i ∈ S。

转移:枚举「下一个要去的点 j」,要求 j ∉ S:
f[S | (1<<j)][j] = min(f[S | (1<<j)][j], f[S][i] + w[i][j])。

初始化:f[1][0] = 0(只访问了点 0,停在 0 号点,路程为 0),其余全部设为 INF。

答案:mini f[(1<<n) − 1][i](所有点都访问过,最后停在哪都行)。

两类状压形态的对照:

集合型:f[S] 或 f[S][i]

S 直接表示「选了哪些元素」,与「行」无关。典型:TSP、哈密顿路径、集合覆盖、子集划分。转移是「往集合里加一个元素」。

棋盘型:f[i][S]

第一维是行号,S 表示「这一行的填法」。转移是「从上一行的某个状态走过来」。两者都把「集合」压成整数,但一维是「行的序号」还是「元素的集合」完全不同。

复杂度:O(2ⁿ · n²)。n = 18 时约 2.6×10⁵ × 324 ≈ 8.5×10⁷,需要注意常数(用一维数组 + 尽量小的循环范围)。

C++最短哈密顿路径 —— 完整代码(f[S][i] 形态)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 20, INF = 0x3f3f3f3f;
int n, w[MAXN][MAXN];
int f[1 << 18][MAXN];

int main() {
    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) cin >> w[i][j];

    memset(f, 0x3f, sizeof(f));
    f[1][0] = 0;                        // 初始:只访问了点 0

    int FULL = (1 << n) - 1;
    for (int S = 1; S <= FULL; S++)          // S 从小到大 = 天然拓扑序
        for (int i = 0; i < n; i++) {
            if (!((S >> i) & 1)) continue;    // 当前必须停在 S 里的点上
            if (f[S][i] == INF) continue;     // 不可达,跳过
            for (int j = 0; j < n; j++) {
                if ((S >> j) & 1) continue;       // j 已经访问过,不能再走
                int T = S | (1 << j);
                f[T][j] = min(f[T][j], f[S][i] + w[i][j]);
            }
        }

    int ans = INF;
    for (int i = 0; i < n; i++) ans = min(ans, f[FULL][i]);
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 4
      0 3 8 10
      3 0 5 7
      8 5 0 2
      10 7 2 0
输出: 10

说明: 这 4 个点在一维直线上,位置分别为 0、3、8、10
      最短路径:0 → 1 → 2 → 3
      路程 = |0-3| + |3-8| + |8-10| = 3 + 5 + 2 = 10
      其他顺序(如 0→2→1→3)都会更长,例如 8 + 5 + 7 = 20

⚠️ 三个必须记住的细节

① INF 用 0x3f3f3f3f 而不是 INT_MAX。因为 f[S][i] + w[i][j] 可能溢出,用 INT_MAX 会变成负数,污染答案。0x3f3f3f3f ≈ 10⁹,两倍都还在 int 范围内。这是一个非常实用的工程习惯。

② 必须跳过不可达状态。if (f[S][i] == INF) continue; 这一句不能少,否则会用 INF 去更新别的状态,虽然 min 后不会变小,但白白浪费时间。

③ 空间可能吃紧。f[1<<18][20] = 262144 × 20 × 4 B ≈ 21 MB。若内存限制严(如 32 MB),需要把 f[S][i] 改成「滚动」或者用 short(若距离范围允许)。n 更大时(如 20 → 2²⁰ × 20 × 4 B ≈ 84 MB)就必须换写法了。

④ 与「旅行商问题(TSP)」的关系:本题是「哈密顿路径」(不要求回到起点);如果要求最后回到起点,就是 TSP,答案改成 min(f[FULL][i] + w[i][0])。两者只差最后一步。

题2棋盘式状压 · 三行约束

洛谷 P2704 炮兵阵地(NOI2001)

有一个 n × m 的网格地图(n ≤ 100,m ≤ 10),每个格子可能是平原 'P'(可放置炮兵)或山地 'H'(不可放置)。单个炮兵的攻击范围是上下左右各 2 格(共十字形 10 格,含自身)。求最多能放置多少个炮兵,使它们互不攻击。

输入格式:第一行 n, m;接下来 n 行,每行 m 个字符。
输出格式:一行一个整数。

输入样例:
5 4
PHPP
PPHH
PPPP
PHPP
PHHP
输出样例:6

💡 题目分析

与「互不侵犯」的两个关键差别:
① 攻击范围是 2 格(不是 1 格),所以「行内」要求 S & (S<<1) == 0 且 S & (S<<2) == 0;
② 影响跨 两行,所以状态里必须同时记住「上一行」和「上两行」的摆法。

状态:f[i][S][P] = 第 i 行的摆法是 S,第 i−1 行的摆法是 P 时,前 i 行能放的最多炮兵数。

转移:枚举第 i−2 行的摆法 Q,
f[i][S][P] = max(f[i][S][P], f[i−1][P][Q] + popcount(S))
要求:S & P == 0(相邻行同列冲突)、S & Q == 0(隔一行仍然同列冲突)。

注意:不需要检查斜角!因为炮兵的攻击是「十字形」,只能沿行列方向,没有对角线方向。这是本题与「互不侵犯」最大的区别,也是最容易照抄错的地方。

不能放在山地上:预处理 ban[i],第 j 位为 1 表示第 i 行第 j 列是山地,则要求 S & ban[i] == 0。

空间优化:f[100][1024][1024] 需要 400 MB,超内存。但因为 f[i] 只依赖 f[i−1],可以用滚动数组,只开 3 层(i%3),内存降到约 12 MB。

C++P2704 炮兵阵地 —— 完整代码(滚动数组 3 层)
#include <bits/stdc++.h>
using namespace std;

int n, m;
int ban[105];                  // ban[i] 的第 j 位为 1 表示第 i 行第 j 列是山地
int pc[1 << 10];
int f[3][1 << 10][1 << 10];
vector<int> ok;

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        string s; cin >> s;
        for (int j = 0; j < m; j++)
            if (s[j] == 'H') ban[i] |= (1 << j);
    }

    // ---------- 筛出行内合法状态:2 格范围内不能有第二个炮兵 ----------
    for (int S = 0; S < (1 << m); S++) {
        if (S & (S << 1)) continue;      // 相邻列冲突
        if (S & (S << 2)) continue;      // 隔一列冲突
        ok.push_back(S);
        pc[S] = __builtin_popcount(S);
    }

    int ans = 0;
    for (int i = 1; i <= n; i++) {
        int cur = i % 3, p1 = (i - 1) % 3, p2 = (i - 2 + 3) % 3;
        memset(f[cur], 0, sizeof(f[cur]));      // 每行先清空

        for (int S : ok) {                       // 本行状态
            if (S & ban[i]) continue;              // 不能放在山地上
            for (int P : ok) {                   // 上一行状态
                if (S & P) continue;
                for (int Q : ok) {               // 上两行状态
                    if (S & Q) continue;
                    f[cur][S][P] = max(f[cur][S][P], f[p1][P][Q] + pc[S]);
                }
                ans = max(ans, f[cur][S][P]);   // 随时更新全局答案
            }
        }
    }
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 5 4
      PHPP
      PPHH
      PPPP
      PHPP
      PHHP
输出: 6

说明: 5×4 的棋盘上最多放 6 个互不攻击的炮兵
      由于攻击范围是 2 格,同一列上每隔 3 行才能放一个
      5 行最多能在同一列放 2 个(第 1、4 行),配合列方向的错开

⚠️ 本题的五个坑

① 斜角检查多写了。照抄「互不侵犯」的代码会多加 S & (P<<1) 等判据,导致大量合法方案被错误排除,答案偏小。炮兵只有十字攻击,没有斜角。

② 忘了「隔一行」的冲突。只检查了 S & P,漏掉 S & Q。这是最典型的错误——因为攻击范围是 2,第 i 行和第 i−2 行同列也会互相攻击。

③ 行内只检查了相邻列。必须同时检查 S & (S<<1) 和 S & (S<<2),因为攻击范围是 2 格。

④ 滚动数组的下标写错。p2 = (i-2) % 3 在 i = 1 时会得到 -1 % 3 = -1,数组越界!必须写成 (i - 2 + 3) % 3。这是 C++ 负数取模的经典陷阱。

⑤ 每行忘记清空。滚动数组复用了同一块内存,若不清空,上一轮(i−3 行)的旧值会残留,导致答案偏大。写 memset(f[cur], 0, sizeof(f[cur])) 最保险。

复杂度:ok[] 的长度在 m = 10 时约为 60(比 2¹⁰ = 1024 小很多),所以实际是 100 × 60³ ≈ 2.2×10⁷,非常快。「预处理合法状态」在这里同样是决定性的优化。

题3单调队列优化 DP

洛谷 P1725 琪露诺

有一排 n+1 个格子(编号 0 到 n),第 i 个格子有冰冻指数 a[i]。一开始在 0 号格子,每次可以往后跳 [L, R] 格。当跳到编号 ≥ n 的格子时游戏结束。求能获得的最大冰冻指数总和。n ≤ 2×10⁵。

输入格式:第一行三个整数 n, L, R;第二行 n+1 个整数 a[0]..a[n]。
输出格式:一行一个整数。

输入样例:5 2 3
0 12 3 11 7 -2
输出样例:11

💡 题目分析

朴素 DP:f[i] = 到达格子 i 时的最大冰冻指数和。转移:
f[i] = max{ f[j] } + a[i],其中 j ∈ [i − R, i − L]。

直接枚举 j 的复杂度是 O(nR),n = 2×10⁵、R 可能很大时必然超时。

优化观察:转移式是「从一个长度固定(R − L + 1)的滑动窗口里取最大值」——这正是单调队列的标准适用信号!把「枚举 j 求 max」换成「单调队列 O(1) 取队首」,总复杂度从 O(nR) 降到 O(n)。

答案在哪:当她在某个格子 i 上,且 i + R > n(能一步跳出界)或者 i == n 时,游戏可以结束。所以答案 = max{ f[i] : i ∈ [n − R + 1, n] }(注意 i 可能 < 0,要取 max(0, ...))。

与「滑动窗口最大值」的区别:题目从「求每个窗口的最大值」变成了「用窗口的最大值来推进 DP」。核心代码几乎完全一样,只是把 a[j] 换成了 f[j],并调整了入队时机。

C++P1725 琪露诺 —— 完整代码(单调队列优化 DP)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005, NEG = -1e9;
int n, L, R, a[MAXN], f[MAXN];

int main() {
    scanf("%d %d %d", &n, &L, &R);
    for (int i = 0; i <= n; i++) scanf("%d", &a[i]);

    for (int i = 0; i <= n; i++) f[i] = NEG;
    f[0] = a[0];                    // 起点

    deque<int> q;                  // 存下标,对应的 f 值单调递减
    for (int i = L; i <= n; i++) {
        // ① 把「新进入窗口」的 i-L 加入队列
        int cand = i - L;
        if (f[cand] > NEG / 2) {
            while (!q.empty() && f[q.back()] <= f[cand]) q.pop_back();
            q.push_back(cand);
        }
        // ② 把「已经滑出窗口」的下标移出(小于 i-R)
        while (!q.empty() && q.front() < i - R) q.pop_front();
        // ③ 队首就是窗口内的最大 f 值
        if (!q.empty()) f[i] = f[q.front()] + a[i];
    }

    int ans = NEG;
    for (int i = max(0, n - R + 1); i <= n; i++) ans = max(ans, f[i]);
    printf("%d\n", ans);
    return 0;
}
运行结果
输入: 5 2 3
      0 12 3 11 7 -2
输出: 11

逐步验证:
  f[0] = 0
  f[2] = f[0] + a[2] = 0 + 3  = 3     (0 号格子跳 2 步到 2 号)
  f[3] = f[0] + a[3] = 0 + 11 = 11    (0 号格子跳 3 步到 3 号)
  f[4] = f[2] + a[4] = 3 + 7  = 10    (窗口 [1,2] 中最大是 f[2]=3)
  f[5] = f[3] + a[5] = 11 - 2 = 9     (窗口 [2,3] 中最大是 f[3]=11)

答案 = max(f[3], f[4], f[5]) = max(11, 10, 9) = 11
      (i=3 时 3+3=6 > 5,可以直接跳出界,游戏结束)

路径:0 → 3 →(跳出),获得 0 + 11 = 11

💭 单调队列优化 DP 的「入队时机」为什么容易错?

在「滑动窗口最大值」里,元素 i 一进来就立刻入队,因为我们要统计的是 i 自己所在的窗口。
但在 DP 优化里,转移式是 f[i] = max{f[j]} + a[i](j ∈ [i−R, i−L])——窗口里根本不包括 i 自己(因为 j ≤ i − L < i)。所以入队的东西是 f[i−L],而不是 f[i]。
记法:先「补进新候选」(i−L),再「踢掉过期的」(< i−R),最后「取队首」。三步顺序不能乱。

另一个易错点:不可达状态。在本题中,格子 1 是不可达的(因为从 0 出发最少跳 2 步)。如果直接把 f[1] = NEG 放进单调队列,它可能被当成极大值(如果 NEG 取错符号)或者污染后续计算。所以入队前必须判断 f[cand] > NEG/2(表示「可达」)。单调队列里只放「有效候选」是一个重要习惯。

推广到其它题型:
· 多重背包:f[j] = max(f[j − k·w] + k·v),按「模 w 的余数」分组后每组是一个滑动窗口,用单调队列优化;
· 最大子段和的变体:f[i] = a[i] + max(0, f[i−1]) 的推广形式;
· 带长度限制的 LIS / 分组问题:只要转移是「固定长度区间取最值」,就可以套用。
识别信号永远是同一句话:转移的候选集合是一个「长度固定的滑动窗口」。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
状压适用信号需要在状态里记住「一个集合 / 一行排布」,且 n ≤ 20
编码约定第 i 位为 1 表示「元素 i 在集合里」;(S >> i) & 1 判断
加元素 / 删元素S | (1 << i) / S & ~(1 << i)
枚举子集for (T = S; T; T = (T − 1) & S),总复杂度 O(3ⁿ)
集合大小__builtin_popcount(S)
集合型状态f[S] 或 f[S][i];按 S 从小到大枚举即拓扑序
棋盘型状态f[i][S];先筛行内合法,再查相邻行冲突
行内相邻S & (S << 1) == 0;隔一列再查 S & (S << 2) == 0
行间同列S & P == 0
行间斜角S & (P << 1) == 0 且 S & (P >> 1) == 0
跨两行约束状态要开到 f[i][S][P],转移再枚举上两行 Q
数位 DP 框架答案 = f(R) − f(L−1);按位从高到低填;状态 (pos, last, limit, lead)
数位 DP 记忆化只在 !limit 时缓存;前导零用哨兵值(如 last = 11)处理
单调队列优化f[i] = max/min{f[j] + w(j)},j ∈ [i−k, i−1] → O(nk) 降到 O(n)
单调队列入队时机先补 i−L 进队,再踢 < i−R,最后取队首
C++万能模板:棋盘式状压 DP + 单调队列优化 DP
// ==================== 模板一:棋盘式状压 DP ====================
int n, m, ban[MAXN], pc[1 << 12];
int f[MAXN][1 << 12];
vector<int> ok;

void init() {
    for (int S = 0; S < (1 << m); S++) {
        if (S & (S << 1)) continue;        // 行内相邻冲突
        ok.push_back(S);
        pc[S] = __builtin_popcount(S);
    }
}

void solve() {
    init();
    for (int S : ok)
        if (!(S & ban[1])) f[1][S] = pc[S];    // 第一行初始化

    for (int i = 2; i <= n; i++) {
        for (int S : ok) {
            if (S & ban[i]) continue;             // 不能用禁用的格子
            for (int P : ok) {
                if (S & P) continue;                 // 同列
                if (S & (P << 1)) continue;           // 左上斜(若题目有斜角约束)
                if (S & (P >> 1)) continue;           // 右上斜
                f[i][S] = max(f[i][S], f[i - 1][P] + pc[S]);
            }
        }
    }
}

// ==================== 模板二:单调队列优化 DP ====================
// f[i] = max{ f[j] } + w(i),  j ∈ [i-R, i-L]
void monoDP() {
    deque<int> q;                        // 存下标,f 值单调递减
    for (int i = L; i <= n; i++) {
        int cand = i - L;
        // ① 补进新候选(跳过不可达状态)
        if (f[cand] > NEG / 2) {
            while (!q.empty() && f[q.back()] <= f[cand]) q.pop_back();
            q.push_back(cand);
        }
        // ② 踢掉过期候选
        while (!q.empty() && q.front() < i - R) q.pop_front();
        // ③ 取队首转移
        if (!q.empty()) f[i] = f[q.front()] + w(i);
    }
}

// ==================== 模板三:数位 DP 记忆化搜索 ====================
int a[20], memo[20][13];
int dfs(int pos, int last, bool limit) {
    if (pos == 0) return 1;
    if (!limit && memo[pos][last] != -1) return memo[pos][last];
    int up = limit ? a[pos] : 9, res = 0;
    for (int d = 0; d <= up; d++) {
        if (last != 11 && abs(d - last) < 2) continue;   // 本题的合法性判断
        int nl = (last == 11 && d == 0) ? 11 : d;
        res += dfs(pos - 1, nl, limit && (d == up));
    }
    if (!limit) memo[pos][last] = res;   // 只有不贴界才缓存
    return res;
}
🔑 一句话总纲

集合压成整数、按行压成整数,两处都要先「筛掉非法」再转移;数位 DP 拆区间、盯贴界、管前导零;区间取最值就交给单调队列。

②拓展案例
拓展1轮廓线 DP

用 1×2 的多米诺骨牌铺满 n × m 的棋盘,求方案数。n, m ≤ 12。

查看参考答案

为什么按行状压在这里很别扭?因为骨牌是「横着占两格」或「竖着占两格」,相邻两行之间的约束不是简单的「哪些列被占用」,而是「哪些列被上一行的竖牌伸下来占用了」。可以按行做,但状态的物理意义要仔细定义。

轮廓线 DP 的做法:按「从上到下、从左到右」逐格推进,用一个 m 位整数 S 表示轮廓线。约定:S 的第 j 位为 1 表示「第 j 列在当前轮廓线上已经被占据」(即上一行的竖牌伸下来了,或者本行左边的横牌还没走完)。

处理格子 (i, j) 时只有两种决策:
· 若该格已被占据(S 的第 j 位为 1):什么都不能放,把这一位清 0(表示「占据已在此结束」),转移到下一格;
· 若该格空闲(S 的第 j 位为 0):
  ① 竖放一块:占据 (i,j) 和 (i+1,j),所以把 S 的第 j 位置 1(表示「伸到下一行去了」);
  ② 横放一块:占据 (i,j) 和 (i,j+1),所以把 S 的第 j+1 位置 1(表示「本行右边那格已被左邻占据」),且要求 j+1 < m。

状态转移的关键:推进到下一格时,如果到了行尾(j == m),就跳到下一行的第一格,并把 S 的第 m 位(越界的位)清零。同时要注意「S 的第 j 位」在移动过程中会「移位」——因为它代表的是「当前轮廓线上第 j 列的状态」,而轮廓线在滑动。

复杂度:状态数 2^m,格子数 n·m,所以 O(n·m·2^m)。n = m = 12 时约 12 × 12 × 4096 ≈ 5.9×10⁵,非常快。

与按行状压的统一视角:按行状压的状态是「一整行的填法」,轮廓线 DP 的状态是「轮廓线上 m 个格子的填法」。两者都是 2^m 个状态,区别只在于「推进到什么位置时保存一次状态」。按行状压相当于「每行保存一次」,轮廓线 DP 相当于「每格保存一次,但只有 m 位」。

更进阶:插头 DP(Plug DP)。当问题涉及「回路」「连通性」(如「求棋盘上哈密顿回路个数」「围墙方案数」)时,轮廓线上还需要记录「连通性信息」,此时用「括号序列」或「最小表示法」给状态编号,这就是插头 DP(也叫「基于连通性的状态压缩 DP」)。它是状压 DP 家族里最难的一支,属于省选 / NOI 级内容。

拓展2数位 DP 进阶

洛谷 P2602 数字计数(ZJOI2010):统计 [a, b] 中每个数字 0~9 各出现了多少次。

查看参考答案

思路:拆成 10 个数位 DP。对每个数字 d ∈ {0, 1, …, 9},分别求「[0, x] 中数字 d 出现的总次数」,10 次调用同一个函数,最后对每个 d 做 g(b) − g(a−1)。

状态设计:dfs(pos, last, limit, lead) 需要返回的不再是一个数,而是「从当前位置往后,数字 d 还会出现多少次」。因为我们要统计的是「出现次数」这个可加的量。

转移中的关键加法:枚举当前位填 c 时
res += dfs(pos − 1, ...) + (c == d ? 「后面所有方案数」 : 0)
那「后面所有方案数」怎么算?不能简单地写 1,因为从当前位置往后可能有很多种填法。正确的做法是:让 dfs 同时返回「方案数」和「d 的出现次数」两个值(用一个结构体或 pair),或者把「当前位贡献」拆成「d 在当前位出现了几次」= 后面位的方案数,而后面位的方案数就是 dfs(pos−1, ...).count。

struct Node { long long cnt, sum; };   // cnt: 方案数; sum: 数字 d 出现的总次数

Node dfs(int pos, int d, bool limit, bool lead) {
    if (pos == 0) return {1, 0};
    if (!limit && !lead && vis[pos]) return memo[pos];
    int up = limit ? a[pos] : 9;
    Node res = {0, 0};
    for (int c = 0; c <= up; c++) {
        if (lead && c == 0) {
            Node t = dfs(pos - 1, d, limit && (c == up), true);
            res.cnt += t.cnt; res.sum += t.sum;      // 前导零不算出现
        } else {
            Node t = dfs(pos - 1, d, limit && (c == up), false);
            res.cnt += t.cnt;
            res.sum += t.sum + (c == d ? t.cnt : 0);
            // 当前位若是 d,则它出现 t.cnt 次(后面每种填法都算一次)
        }
    }
    if (!limit && !lead) { memo[pos] = res; vis[pos] = true; }
    return res;
}

核心技巧:把「计数」和「方案数」一起返回。这是数位 DP 处理「统计类」问题(而不是「计数类」问题)的标准套路。因为「数字 d 出现了几次」是一个可加的统计量,而它依赖于「有多少种方案」,两者必须一起传递。

易错:① 前导零必须跳过(否则 007 里的两个 0 会被算作出现);② res.sum += t.sum + (c == d ? t.cnt : 0) 里用的是 t.cnt 而不是 1;③ 统计 0 的时候尤其要小心前导零,因为 0 本来就是个数字(本题就是因为前导零处理不当而成为经典易错题)。

启发:数位 DP 的框架是固定的,变的是「状态里记什么」和「dfs 返回什么」。记上一位就是为了判断相邻关系;返回 pair 就是为了同时统计可加量。掌握了「状态可扩展、返回值可扩展」这两点,数位 DP 就真正会了。

拓展3矩阵快速幂优化

洛谷 P1939 矩阵加速(数列):f[1] = f[2] = f[3] = 1,f[n] = f[n−1] + f[n−3](n ≥ 4)。求 f[n] mod 10⁹+7,n ≤ 2×10⁹。

查看参考答案

为什么需要矩阵快速幂:n ≤ 2×10⁹,直接递推 O(n) 需要 20 亿次运算,必然超时。但递推式是固定长度的线性递推(只依赖前 3 项),这是一个强烈信号。

核心思想:把「状态的推进」写成一个矩阵乘法。令状态向量为 Vₙ = (f[n], f[n−1], f[n−2])ᵀ,则有:

Vₙ = M · Vₙ₋₁,其中 M = [[1,0,1],[1,0,0],[0,1,0]]

验证一下:
· 第一行:1·f[n−1] + 0·f[n−2] + 1·f[n−3] = f[n−1] + f[n−3] ✅ 正好是递推式;
· 第二行:1·f[n−1] = f[n−1] ✅(把旧值「平移」下来);
· 第三行:1·f[n−2] = f[n−2] ✅。

于是:Vₙ = M^(n−3) · V₃,其中 V₃ = (1, 1, 1)ᵀ。用快速幂求 M^(n−3),复杂度 O(k³ log n)(k = 3 是矩阵阶数),log(2×10⁹) ≈ 31 次矩阵乘法,瞬间完成。

const long long MOD = 1000000007;
struct Mat {
    long long a[3][3];
    Mat() { memset(a, 0, sizeof(a)); }
};

Mat mul(const Mat& x, const Mat& y) {
    Mat r;
    for (int i = 0; i < 3; i++)
        for (int k = 0; k < 3; k++) {
            if (!x.a[i][k]) continue;              // 稀疏性剪枝
            for (int j = 0; j < 3; j++)
                r.a[i][j] = (r.a[i][j] + x.a[i][k] * y.a[k][j]) % MOD;
        }
    return r;
}

Mat qpow(Mat base, long long e) {
    Mat r;
    for (int i = 0; i < 3; i++) r.a[i][i] = 1;  // 单位矩阵
    while (e) {
        if (e & 1) r = mul(r, base);
        base = mul(base, base);
        e >>= 1;
    }
    return r;
}

// 转移矩阵
Mat M;
M.a[0][0] = 1; M.a[0][2] = 1;
M.a[1][0] = 1;
M.a[2][1] = 1;
// 答案 = (M^(n-3) · (1,1,1)^T)[0],即第一行与 (1,1,1) 的点积
Mat P = qpow(M, n - 3);
cout << (P.a[0][0] + P.a[0][1] + P.a[0][2]) % MOD << "\n";

适用范围(重要):矩阵快速幂只适用于「固定长度的线性递推」,也就是 f[n] = a₁f[n−1] + a₂f[n−2] + … + a_k f[n−k],其中系数与 n 无关。
如果系数中含 n(如 f[n] = n·f[n−1]),或者递推式里有 max/min,矩阵快速幂就不适用了。

常见搭配场景:斐波那契数列、路径计数(图的邻接矩阵的 n 次幂)、带常数项的线性递推(把常数作为状态的一维)、带前缀和的线性递推(把前缀和作为状态的一维)。
技巧:任何「能用矩阵表示的线性关系」都可以加一维常数 1 变成齐次式。

易错:① 矩阵乘法不满足交换律,mul(r, base) 与 mul(base, r) 结果不同,写错顺序答案全错;② 矩阵阶数 k 要按递推式的最长依赖长度来确定;③ 每条乘法后都要取模,否则 long long 也会溢出。

③思考与启发

💭 思考1:状压 DP 的状态数是 2ⁿ,n 最大能到多少?

题目里看到「n ≤ 20」,为什么就敢上状压?这个界限是怎么估出来的?

查看参考答案

先算状态数:2²⁰ = 1,048,576 ≈ 10⁶。这是几乎所有 OJ 都能承受的状态规模(一维数组 10⁶ 个 int 只有 4 MB)。

再看转移代价:
· 集合型(f[S]):若转移是「枚举一个元素加入」→ O(2ⁿ · n) = 2×10⁷,轻松通过;若转移是「枚举 S 的所有子集」→ O(3ⁿ) = 3²⁰ ≈ 3.5×10⁹,直接爆炸;
· 带终点的(f[S][i]):O(2ⁿ · n²) = 4×10⁸,偏大,需要卡常数(如 n ≤ 18 更稳);
· 棋盘型(f[i][S]):如果 m ≤ 10,2^m = 1024,再乘 n = 100 也只有 10⁵ 级别,非常安全。

粗略的容量表(以 10⁸ 次基本运算为上限):

转移复杂度n 的安全上限典型问题
O(2ⁿ · n)n ≤ 24简单集合 DP
O(2ⁿ · n²)n ≤ 18TSP、哈密顿路径
O(3ⁿ)n ≤ 15子集划分、集合覆盖
O(n · 2^m · 2^m)m ≤ 10 ~ 12棋盘式状压

三条压缩状态数的实用技巧:
① 筛掉非法状态:n = 9 时行内合法的状态只有 89 个(而不是 512 个),直接快 33 倍;
② 状态只保留「有用的位」:若某些位永远为 0,就不要把它计入状态维度;
③ 滚动数组:若 f[i] 只依赖 f[i−1],就用 i % 2 或 i % 3 的滚动,空间大幅下降。

启发:「n ≤ 20」这个数字本身就是命题人给的最大提示。读题时先看数据范围,往往就能反推出该用什么算法——这是竞赛中极重要的「元技能」。

💭 思考2:数位 DP 里,为什么「贴着上界」的路径不能记忆化?

这个问题几乎每个初学数位 DP 的人都困惑过,我们从两个角度讲清楚。

查看参考答案

角度一:从「状态含义」看。记忆化的前提是「同一个状态在不同路径下,后续答案相同」。
当 limit = true 时,状态的真实含义是「前面已经和上界 x 完全相同」,此时后面能填什么完全取决于 x 剩下的位。
而 limit = false 时,含义是「前面已经比 x 小了」,后面可以随便填。
两者的「剩余自由度」完全不同,所以是两套不同的状态,必须分开处理。

角度二:从「复用价值」看。在整棵搜索树里,limit = true 的路径全世界只有一条(就是一路贴着 x 走下来的那条)。为它建立缓存,第一,只能被用一次(毫无收益);第二,如果把它和 limit = false 的状态混用同一块缓存,就会把「贴着上界」这种特殊情形的结果当成「可以自由填」的通解,答案必然错。

一个反向的验证:如果允许 limit = true 时缓存,那么在不同 x 下(比如 solve(500) 和 solve(600))缓存会互相污染,而实际上 memo 是在每次 solve 里重新 memset 的——即使这样也不行,因为在同一次 solve 内部,贴着上界的那条路径的结果就是「专属」的。

延伸:类似「不能记忆化」的情形还有哪些?
· 前导零(lead):有两种处理方式。① 把 lead 也放进状态一起记忆化(状态数翻倍,但正确);② 在递归到「第一个非零位」之后才开始统计(本题采用哨兵 last = 11 的做法)。两种都可以,但不能不处理。
· 「是否已经出现过某个标记」:如「数字中是否出现过 4」,这类布尔标记可以放进状态一起记忆化。
· 「当前数字和」:若数字和的范围不大,也可以放进状态。

启发:记忆化搜索的黄金法则是——「状态必须包含所有影响后续答案的信息」。这条法则适用于所有 DP(不只是数位 DP)。如果你发现「同样的状态,后续答案不一样」,那一定是状态定义漏了信息。

💭 思考3:DP 优化方法这么多,考场上该怎么选?

滚动数组、前缀和、单调队列、斜率优化、决策单调性、矩阵快速幂、二进制拆分……面对一道题,如何快速定位该用哪一种?

查看参考答案

决策流程图(照这个顺序问自己):

第一问:超时还是超内存?
· 超内存 → 滚动数组(前提:f[i] 只依赖前面有限几行);
· 超时间 → 进入第二问。

第二问:暴力的转移长什么样?
· 「求和」形式:f[i] = Σ f[j](j ∈ 区间) → 前缀和优化,O(n) 预处理即可;
· 「取最值 + 固定长度区间」:f[i] = min/max{f[j] + w(j)}(j ∈ [i−k, i−1]) → 单调队列优化;
· 「取最值 + 区间端点单调」:j 的范围随 i 单调移动,且没有固定长度 → 若决策点也单调,可用单调栈 / 二分或决策单调性(分治);
· 「f[j] 与 j 以乘积形式纠缠」:能整理成 f[i] = min{ k(i)·x(j) + y(j) } + b(i) → 斜率优化(凸包 + 二分/单调队列);
· 「固定长度的线性递推 + n 极大」:f[n] = Σ aᵢf[n−i] 且 n ≥ 10⁹ → 矩阵快速幂;
· 「多重背包(每种物品有 c 个)」 → 二进制拆分(O(log c))或单调队列(O(n·V),更快)。

第三问:优化后复杂度够吗?把优化后的复杂度代入数据范围算一遍。若还不够,说明优化方向错了,应该回头重新设计状态(比如加维度换更小的转移范围),而不是硬堆优化。

重要提醒:优化的前提是「朴素 DP 已经写对」。正确的工作流是:
① 写出朴素 DP,通过小数据验证(或暴力对拍);
② 分析朴素 DP 的复杂度瓶颈在哪一维;
③ 针对那一维套优化;
④ 再对拍一次,确认优化没有改变答案。
跳过第①步直接套优化模板,是提高组失分最惨烈的方式之一。

最后一句总结:所有的 DP 优化,本质都是「利用转移的结构性」——要么是「区间结构」(前缀和、单调队列),要么是「凸性结构」(斜率优化、决策单调性),要么是「线性结构」(矩阵快速幂)。看清结构,优化就是顺理成章的事。

模块 05

学习检测

五个最容易翻车的地方,考前务必扫一眼。

易错1

位运算优先级:忘记加括号

❌ 错误
if (S & 1 == 1) —— 实际被解释成 S & (1 == 1);
if (S & (P << 1) == 0) —— 实际是 S & ((P << 1) == 0),结果变成一个 0/1 的值,完全不是你想判断的东西;
T = T - 1 & S; —— 实际是 T - (1 & S)。
✅ 正确
所有位运算表达式都用括号包起来:
if (((S >> i) & 1) == 1)
if ((S & (P << 1)) == 0)
T = (T - 1) & S;
口诀:位运算优先级低,每一层都加括号!
易错2

攻击范围是 2 格时,漏掉「隔一行」的冲突

❌ 错误
从「互不侵犯」照抄代码,只写了 S & P == 0(相邻行同列),甚至多加了对角线判据。
结果:第 i 行与第 i−2 行同列互相攻击的情况没被排除,答案偏大。
✅ 正确
先看清攻击范围再定判据:
· 范围 1 格(八邻接)→ 查同列 + 两个斜角;
· 范围 2 格(十字)→ 状态开成 f[i][S][P],额外要求 S & Q == 0(Q 是上两行),且行内要查 S & (S<<1) 和 S & (S<<2)。
口诀:范围是几格,就查几行;别照抄题目,先读攻击方式!
易错3

滚动数组:负数取模越界 + 忘记清空

❌ 错误
int p2 = (i - 2) % 3; —— 当 i = 1 时得到 -1,数组下标为负,越界读写(可能访问到 f 数组之外的内存,也可能访问到 f 的正确位置却毫无逻辑);
或者每轮循环忘记 memset,上一轮的旧值残留导致答案偏大。
✅ 正确
负数取模要「加回模数」:int p2 = (i - 2 + 3) % 3;
并且每轮开始都清空当前层:memset(f[cur], 0, sizeof(f[cur]));
口诀:负数取模先加模数,滚动数组每轮清空!
易错4

数位 DP:limit 为真时也去记忆化

❌ 错误
if (memo[pos][last] != -1) return memo[pos][last]; 没加 !limit 条件,或者写成了 if (limit) memo[...] = ...;。
「贴着上界」那条唯一路径的结果被当成通解缓存,后续不贴界的路径读到了它,答案全错。
✅ 正确
读取和写入都加条件:
if (!limit && memo[pos][last] != -1) return memo[pos][last];
if (!limit) memo[pos][last] = res;
同时处理前导零(用哨兵 last = 11 或独立的 lead 标记)。
口诀:贴界不缓存,前导零要区分!
易错5

单调队列优化 DP:入队时机与「不可达状态」

❌ 错误
① 照抄「滑动窗口最大值」的写法,把 i 自己(而不是 i−L)入队。但 DP 的窗口是 [i−R, i−L],不包含 i 自己,于是转移用到了不该用的 f[i];
② 把 f = −∞ 的不可达状态也塞进队列,队首取到一个 −∞ 却当成合法值。
✅ 正确
① 严格按三步走:先补候选 i−L → 再踢过期 < i−R → 最后取队首;
② 入队前判断可达性:if (f[cand] > NEG/2) { ... push ... },保证队列里只有有效候选。
口诀:补 i−L、踢 < i−R、取队首;不可达的不进队!
📋状压 DP 与 DP 优化速查表
任务 / 场景关键写法复杂度
判断第 i 位(S >> i) & 1O(1)
加入 / 移出元素S | (1 << i) / S & ~(1 << i)O(1)
集合大小__builtin_popcount(S)O(1)
枚举子集for (T=S; T; T=(T−1)&S)O(3ⁿ)
行内相邻冲突(S & (S << 1)) == 0O(1)
行内隔一列冲突(S & (S << 2)) == 0O(1)
行间同列冲突(S & P) == 0O(1)
行间斜角冲突(S & (P << 1)) == 0 且 (S & (P >> 1)) == 0O(1)
棋盘状压总复杂度O(n × |ok|² × k)约 10⁶~10⁸
集合型状压f[S][i],按 S 从小到大枚举O(2ⁿ·n²)
数位 DPf(R) − f(L−1);dfs(pos, last, limit)O(len × 10 × 状态数)
前缀和优化f[i] = pre[r] − pre[l−1]O(nk) → O(n)
单调队列优化deque 存下标,值单调O(nk) → O(n)
滚动数组f[i % 2][...] / f[i % 3][...]空间降 k 倍
矩阵快速幂Vₙ = M^(n−k) · V_kO(k³ log n)
二进制拆分把 c 个物品拆成 1,2,4,8,…O(c) → O(log c)
📌笔记卡
📖 必记概念
  • 状态压缩:集合 → 整数,第 i 位表示元素 i
  • n ≤ 20 是状压的强烈信号
  • 棋盘式状压:一行一个整数,按行递推
  • 轮廓线 DP:逐格推进,状态是边界上的 m 格
  • 数位 DP:区间差分 + 按位填 + 贴界标记
  • 单调队列:淘汰「更靠左且更差」的候选
🧠 必背模板
  • if ((S >> i) & 1) ...
  • for (T=S; T; T=(T-1)&S)
  • (S & (P<<1)) == 0
  • if (!limit && memo[pos][last]!=-1) return ...;
  • while (!q.empty() && f[q.back()] <= f[cand]) q.pop_back();
  • while (!q.empty() && q.front() < i - R) q.pop_front();
⚠️ 考场提醒
  • 位运算全加括号
  • 先筛合法状态再转移(快几十倍)
  • 方案数用 long long
  • 滚动数组:负数取模 + 每轮清空
  • 数位 DP 小数据手算验证
  • 先写对朴素 DP,再上优化
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1位运算手算

设 S = 二进制 10110(十进制 22)。请回答:① S 的第 2 位是多少?② 把元素 0 加入 S 后是多少?③ 把元素 1 从 S 中移除后是多少?④ popcount(S) 是多少?⑤ 用「枚举子集」的循环能依次得到哪些数?

查看参考答案

① 第 2 位的值:(S >> 2) & 1 = (10110₂ >> 2) & 1 = 101₂ & 1 = 1。所以第 2 位是 1(元素 2 在集合里)。

② 加入元素 0:S | (1 << 0) = 10110₂ | 00001₂ = 10111₂ = 23。

③ 移除元素 1:S & ~(1 << 1) = 10110₂ & 11101₂ = 10100₂ = 20。
注意:不能写成 S - (1<<1)!因为它等价于 10110₂ − 00010₂ = 10100₂ = 20——本例恰好正确(因为第 1 位本来就是 1),但如果第 1 位是 0,减法会「借位」,把高位也搞乱。例如 10100₂ − 00010₂ = 10010₂ = 18,而我们想要的是 10100 本身(第 1 位本来就是 0)。移除元素必须用 & ~(1<<i)。

④ popcount(S):10110₂ 中有 3 个 1(第 1、2、4 位),所以是 3。

⑤ 枚举子集:循环 for (T = S; T; T = (T-1) & S) 依次得到:

步骤计算T 的值
1初始10110₂ = 22
2(22−1) & 22 = 21 & 2210100₂ = 20
3(20−1) & 22 = 19 & 2210010₂ = 18
4(18−1) & 22 = 17 & 2210000₂ = 16
5(16−1) & 22 = 15 & 2200110₂ = 6
6(6−1) & 22 = 5 & 2200100₂ = 4
7(3−1) & 22 = 1 & 2200010₂ = 2

共得到 7 个非空子集,再加上被循环跳过的空集 0,总计 8 = 2³ 个(因为 S 里有 3 个 1)。验证通过。

易错:① 忘记循环会跳过 T = 0(空集),需要单独处理;② 以为循环是从小到大——实际上是从大到小;③ 把 (T-1) & S 写成 T - 1 & S(运算符优先级陷阱!)。

A2状压 DP 手算

3 个点,距离矩阵为 [[0,1,5],[1,0,2],[5,2,0]]。从 0 号点出发,恰好访问每个点一次,求最短总路程。

查看参考答案

答案:3(路径 0 → 1 → 2)。

枚举所有哈密顿路径:
· 0 → 1 → 2:w(0,1) + w(1,2) = 1 + 2 = 3 ✅
· 0 → 2 → 1:w(0,2) + w(2,1) = 5 + 2 = 7

用状压 DP 推出同样的结果:状态 f[S][i],初始化 f[001₂][0] = 0,其余为 INF。

S(二进制)含义更新过程
001只访问了点 0,停在 0f = 0
011访问了 {0,1}f[011][1] = f[001][0] + w(0,1) = 0 + 1 = 1
101访问了 {0,2}f[101][2] = f[001][0] + w(0,2) = 0 + 5 = 5
111访问了 {0,1,2}、停在 1f[111][1] = f[101][2] + w(2,1) = 5 + 2 = 7
111访问了 {0,1,2}、停在 2f[111][2] = f[011][1] + w(1,2) = 1 + 2 = 3

答案:min(f[111][1], f[111][2]) = min(7, 3) = 3 ✅

注意枚举顺序:必须先算完 S = 011 和 101,才能算 S = 111。而 S 的数值天然递增(001 → 011 → 101 → 111),所以只要按 S 从小到大循环就自动满足拓扑序——这就是状压 DP 不需要额外排序的原因。

易错:① 忘记初始化 f[1][0] = 0,全表都是 INF;② 转移时不检查 j ∉ S,导致重复访问同一个点;③ 把所有 S 都枚举了,但很多状态不可达(如 f[010][1]),需要用 if (f[S][i] == INF) continue; 跳过。

A3数位 DP 手算

求区间 [1, 20] 中有多少个 windy 数(不含前导零,相邻两位数字之差的绝对值至少为 2)。

查看参考答案

答案:17 个。

第一部分:1~9(共 9 个)。个位数没有「相邻的数位」,天然满足条件,全部计入。

第二部分:10~20,逐个检查:

数|十位 − 个位|是否 windy
10|1−0| = 1❌
11|1−1| = 0❌
12|1−2| = 1❌
13|1−3| = 2✅
143✅
154✅
165✅
176✅
187✅
198✅
20|2−0| = 2✅

合计:9 + 8 = 17 个。

验证思路(用数位 DP 的框架手推):f(20) 统计 [0, 20] 的 windy 数个数,结果是 18(含数字 0),减 1 得 17;f(0) = 0,所以 f(20) − f(0) = 17。✅

易错:① 把 20 判成非 windy(|2−0| = 2 ≥ 2,是合法的);② 把个位数漏掉;③ 额外把 0 计入(题目要求正整数)。

A4单调队列手算

数组 a = [2, 1, 4, 3],窗口大小 k = 2。求每个窗口的最大值与最小值。

查看参考答案

最大值序列:2 4 4  最小值序列:1 1 3

逐窗口验证:
· 窗口 [2, 1]:max = 2,min = 1
· 窗口 [1, 4]:max = 4,min = 1
· 窗口 [4, 3]:max = 4,min = 3

用单调队列(求最大值)模拟一遍:

ia[i]① 出队首(≤ i−k)② 挤队尾(a[back] ≤ a[i])③ 队列(存下标)队首值
12——[1]—
21无(1 > 0)a[1]=2 > 1,不弹[1, 2]2
34无(1 > 1 不成立)a[2]=1 ≤ 4 弹,a[1]=2 ≤ 4 弹[3]4
43无(3 > 2)a[3]=4 > 3,不弹[3, 4]4

结果与逐窗口验证一致。注意第 3 步:4 一进来就把 1 和 2 全部「挤」出去了——因为它们比 4 小、又比 4 老,永远不可能再成为答案。

易错:① 队列里存的是值而不是下标(无法判断过期);② 出队首的判据写成 q.front() < i − k(少了等号,会让过期一个的元素多留一轮);③ 求最小值时的比较符方向搞错(应该是「队尾 ≥ 当前值就弹出」)。

🔵 B组 · 提高(4题)

B1棋盘式状压

洛谷 P1879 [USACO06NOV] Corn Fields G:n × m 的玉米田(1 表示可种植,0 表示贫瘠不可种),相邻(上下左右)的两块地不能同时种植。求方案数 mod 10⁸。

查看参考答案

样例:输入 2 3 / 1 1 1 / 0 1 0,输出 9。

状态:f[i][S] = 前 i 行、第 i 行种植状态为 S 的方案数。

三个约束与判据:
· 行内相邻不能同时种 → S & (S << 1) == 0;
· 不能种在贫瘠地 → 预处理 ban[i](第 j 位为 1 表示第 i 行第 j 列贫瘠),要求 S & ban[i] == 0;
· 相邻行同列不能同时种 → S & P == 0。
注意:玉米田只有上下左右相邻,没有斜角约束,所以不需要检查 S & (P<<1)。

const int MOD = 100000000;
int n, m, ban[15], f[15][1 << 12];
vector<int> ok;

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        for (int j = 0; j < m; j++) {
            int x; cin >> x;
            if (x == 0) ban[i] |= (1 << j);
        }
    for (int S = 0; S < (1 << m); S++)
        if (!(S & (S << 1))) ok.push_back(S);

    f[0][0] = 1;                       // 第 0 行视为「什么都不种」
    for (int i = 1; i <= n; i++)
        for (int S : ok) {
            if (S & ban[i]) continue;
            for (int P : ok) {
                if (S & P) continue;
                f[i][S] = (f[i][S] + f[i - 1][P]) % MOD;
            }
        }
    int ans = 0;
    for (int S : ok) ans = (ans + f[n][S]) % MOD;
    cout << ans << "\n";
}

一个小技巧:把 f[0][0] = 1 当作初始状态(「第 0 行什么都不种」是一种合法方案),这样第 1 行就不需要单独初始化了,代码更简洁。这是棋盘状压的常见写法。

易错:① 把 0 当成「可种植」(题目中 1 才是可种);② 多写了斜角判据;③ MOD = 100000000(10⁸,不是常见的 10⁹+7),抄错模数会全 WA。

B2集合型状压 · TSP

洛谷 P1433 吃奶酪:平面上有 n 个奶酪(n ≤ 15),从原点出发,求走遍所有奶酪的最短路径长度。

查看参考答案

样例:输入 4 / 1 1 / 1 -1 / -1 1 / -1 -1,输出 7.41。

与「最短哈密顿路径」的唯一区别:起点固定为原点 (0, 0)(不在奶酪集合里),所以初始化时要先把「从原点到每个奶酪」作为第一段。其余完全一样。

int n;
double x[20], y[20];
double f[1 << 15][20];

double dist(int i, int j) {
    return sqrt((x[i]-x[j])*(x[i]-x[j]) + (y[i]-y[j])*(y[i]-y[j]));
}

int main() {
    cin >> n;
    x[0] = y[0] = 0;              // 0 号点是原点
    for (int i = 1; i <= n; i++) cin >> x[i] >> y[i];

    for (int S = 0; S < (1 << n); S++)
        for (int i = 1; i <= n; i++) f[S][i] = 1e18;

    for (int i = 1; i <= n; i++)          // 第一段:原点 → 奶酪 i
        f[1 << (i - 1)][i] = dist(0, i);

    for (int S = 1; S < (1 << n); S++)
        for (int i = 1; i <= n; i++) {
            if (!((S >> (i - 1)) & 1)) continue;
            if (f[S][i] > 1e17) continue;
            for (int j = 1; j <= n; j++) {
                if ((S >> (j - 1)) & 1) continue;
                int T = S | (1 << (j - 1));
                f[T][j] = min(f[T][j], f[S][i] + dist(i, j));
            }
        }

    double ans = 1e18;
    for (int i = 1; i <= n; i++)
        ans = min(ans, f[(1 << n) - 1][i]);
    printf("%.2f\n", ans);
}

运行验证(样例):4 个奶酪在 (±1, ±1)。最优路径:原点 → (1,1) → (1,−1) → (−1,−1) → (−1,1),长度 = √2 + 2 + 2 + 2 ≈ 7.414,输出 7.41 ✅

易错:① 坐标和距离用 int 存(必须 double);② 第 0 号点(原点)忘记设置坐标;③ 距离比较用 < 而不是 <=,在浮点数下可能导致「无解」;④ 初始代价值用 1e9 而不是 1e18——因为路径长度可能超过 10⁹(n 个点每段上百),用大一点的 INF 更安全。

B3数位 DP 建模

洛谷 P4124 [CQOI2016] 手机号码:11 位手机号中,必须同时出现至少 3 个连续相同的数字,且不能同时出现 4 和 8。求给定区间内合法号码个数。

查看参考答案

本题的关键是「状态里要记什么」。把条件拆开看,需要记住的信息有三类:

要记住的信息状态变量取值数
填到第几位pos11
上一位是什么(为了判断连续相同)last11(0~9 + 前导零哨兵)
已经连续重复了几次rep3(1 次 / 2 次 / ≥3 次)
是否出现过 4 / 是否出现过 8has4, has8各 2
是否已经满足「三个连续」ok32

状态规模:11 × 11 × 3 × 2 × 2 × 2 ≈ 2904 种,再乘上每次枚举 10 个数字,总计算量约 3 万次——对 10¹¹ 的范围来说,这是极其惊人的压缩。

一个重要的规模观察:rep 之所以只需要 3 种取值,是因为一旦达到 3 就已经满足条件了,再多也一样。「把已经满足条件的情形归并成一类」是数位 DP 常见的状态压缩技巧。

转移写法(伪代码):

// dfs(pos, last, rep, has4, has8, ok3, limit)
if (pos == 0) return ok3 && !(has4 && has8) ? 1 : 0;

for (int d = 0; d <= up; d++) {
    int nrep = (d == last) ? min(rep + 1, 3) : 1;
    int nok3 = ok3 || (nrep >= 3);
    res += dfs(pos-1, d, nrep, has4 || (d == 4), has8 || (d == 8), nok3,
               limit && (d == up));
}
if (!limit) memo[...] = res;      // 注意 memo 要开到 6 维!

易错:① 手机号是 11 位,首位不能为 0——所以要额外处理「首位从 1 开始」(或把「尚未填首位」也作为一个状态);② 记忆化数组维数很多,建议用 memo[pos][last][rep][has4][has8][ok3] 的扁平化编码,或者干脆不记忆化(本题数据量小,3 万次运算不记忆化也能过);③ 区间 [L, R] 可能包含「不足 11 位」的数,需要按要求处理(本题保证都是 11 位,但如果要兼容需要额外判断)。

方法论:数位 DP 的难点从来不是代码,而是「列出所有会影响后续决策的信息」。养成一个习惯:每遇到一个条件,就问「这个条件需要记住什么」,把所有需要的量都加进状态里——状态多一点没关系,只要是常数级就不会超时。

B4单调队列 + 前缀和

洛谷 P1714 切蛋糕:给定长度为 n 的序列,求「长度不超过 m 的连续子段」的最大和。n ≤ 5×10⁵。

查看参考答案

样例:输入 6 3 / 1 -2 3 -4 5 -6,输出 5。

第一步:转成前缀和。设 pre[i] = a[1] + … + a[i],则子段 [j+1, i] 的和是 pre[i] − pre[j]。
要求「长度 ≤ m」,即 i − j ≤ m,也就是 j ≥ i − m。

第二步:写成 DP 形式。
f[i] = pre[i] − min{ pre[j] },其中 j ∈ [i − m, i − 1]。

第三步:识别信号。这是「从一个长度固定(m)的滑动窗口里取最小值」——单调队列的标准形态!

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;
long long pre[MAXN];
int n, m;

int main() {
    scanf("%d %d", &n, &m);
    for (int i = 1; i <= n; i++) {
        long long x; scanf("%lld", &x);
        pre[i] = pre[i - 1] + x;
    }

    deque<int> q;
    q.push_back(0);                     // pre[0] = 0 是初始候选
    long long ans = LLONG_MIN;
    for (int i = 1; i <= n; i++) {
        // ① 踢掉过期:j 必须 ≥ i - m
        while (!q.empty() && q.front() < i - m) q.pop_front();
        // ② 用队首(窗口内 pre 最小)更新答案
        if (!q.empty()) ans = max(ans, pre[i] - pre[q.front()]);
        // ③ 把 i 作为新的候选加入(挤掉队尾更大的)
        while (!q.empty() && pre[q.back()] >= pre[i]) q.pop_back();
        q.push_back(i);
    }
    printf("%lld\n", ans);
    return 0;
}

运行验证(样例):a = [1,−2,3,−4,5,−6],pre = [0,1,−1,2,−2,3,−3]。
· i=1:窗口 {0},pre[0]=0,答案 1−0 = 1;
· i=2:窗口 {0,1},队首 0,答案 −1−0 = −1;
· i=3:窗口 {0,1,2},pre 最小是 1(j=2,pre=−1),答案 2−(−1) = 3;
· i=4:窗口 {1,2,3},pre 最小 −1(j=2),答案 −2−(−1) = −1;
· i=5:窗口 {2,3,4},pre 最小 −2(j=4),答案 3−(−2) = 5 ✅;
· i=6:窗口 {3,4,5},pre 最小 −2(j=4),答案 −3−(−2) = −1。
最大值 5,对应子段 [5](长度 1)。✅

易错:① 忘记把 j = 0(pre[0] = 0)放进初始队列——否则「以第 1 个元素开头的子段」全都算不出来;② 出队首的判据写成 q.front() < i − m 少一个边界(正确写法见上面代码,允许 j = i − m);③ 答案是「子段和」而不是「pre 的最小值」,别忘了用 pre[i] − pre[q.front()]。

为什么这题「长度不超过 m」也能用单调队列?因为「长度不超过 m」等价于「j ≥ i − m」,这仍然是一个「下界随 i 单调递增」的窗口——只要窗口的一端随 i 单调移动,单调队列就适用。固定长度只是最简单的一种情形。

🔴 C组 · 挑战(4题)

C1轮廓线 DP

用 1×2 的多米诺骨牌铺满 2 × n 的棋盘,求方案数(n ≤ 10⁶)。请分别用「按行状压」和「递推(斐波那契)」两种方法解决,并说明为什么后者的复杂度更低。

查看参考答案

方法一:按行状压 O(n · 2² · 2²)

每一行的状态 S 表示「哪些列被上方伸下来的竖牌占据」。一行有两格,S 有 4 种取值 {00, 01, 10, 11}。转移时枚举下一行的 T,要求「S 中为 1 的位置在 T 中可以为 0 或 1(竖牌继续往下),S 中为 0 的位置必须由 T 覆盖」……这套转移写出来有 4 种状态、16 种转移组合,可以手推出转移矩阵:

f[n][00] = f[n−1][11] + f[n−1][00] (两格都竖放 or 都横放)

方法二:递推(发现规律) O(n) 最优

设 F[n] 为铺满 2×n 棋盘的方案数。看最左边一列怎么处理:
· 竖放一块:占满最左列,剩下 2×(n−1) → F[n−1] 种;
· 两块横放(上下各一块):占满最左两列,剩下 2×(n−2) → F[n−2] 种。
再没有第三种可能(因为左边一列必须被完整填满)。所以:

F[n] = F[n−1] + F[n−2],F[1] = 1,F[2] = 2

这正是斐波那契数列!(F[1]=1, F[2]=2, F[3]=3, F[4]=5, F[5]=8 …)

为什么递推更快?
· 按行状压是 O(n)(4 个状态、每行常数次转移),但常数较大,且需要转移矩阵;
· 递推是 O(n),但只有一次加法,常数极小;
· 如果 n 达到 10¹⁸,递推可以进一步用矩阵快速幂优化到 O(log n)。

核心启发:状压 DP 是「通用武器」,但通用武器往往不是最快的。当我们发现问题的结构足够简单(例如 2×n 的棋盘只有两种列填法)时,用组合计数直接推导递推式,复杂度往往能从 O(n·2^m·2^m) 降到 O(n) 甚至 O(log n)。
考场上的优先级:找规律 → 组合推导 → 递推 → 状压。状压是「实在推不出规律时的兜底方案」。

一个自检方法:先用状压 DP 写一个 n ≤ 20 的版本,打印出结果序列,观察是否呈规律(1, 2, 3, 5, 8, 13…)——「打表找规律」是竞赛中极其高效的技巧,很多状压题都能被简化成简单递推。

C2状压 + 枚举子集

给定 n ≤ 16 个元素,每个元素有重量 w[i]。要把它们分成若干组,要求每组的总重量不超过 V,求最少的组数。

查看参考答案

第一步:预处理「哪些子集可以单独成一组」。
对每个子集 S,算出它的重量和 sum[S](用 sum[S] = sum[S & (S-1)] + w[lowbit] 递推,O(2ⁿ))。若 sum[S] ≤ V,则 S 可以单独成一组。

第二步:状压 DP。f[S] = 把集合 S 中的元素分完所需的最少组数。

转移:枚举 S 的一个「可以作为最后一组」的子集 T:

f[S] = min{ f[S \ T] + 1 },其中 T ⊆ S 且 sum[T] ≤ V

int sum[1 << 16], f[1 << 16];

// ① 预处理所有子集的重量和 O(2^n)
for (int S = 1; S < (1 << n); S++) {
    int low = S & (-S);              // 最低位的 1
    int idx = __builtin_ctz(low);     // 它的下标
    sum[S] = sum[S ^ low] + w[idx];
}

// ② 状压 DP
f[0] = 0;
for (int S = 1; S < (1 << n); S++) {
    f[S] = INF;
    for (int T = S; T; T = (T - 1) & S) {   // 枚举 S 的所有非空子集
        if (sum[T] > V) continue;            // T 不能单独成组
        f[S] = min(f[S], f[S ^ T] + 1);
    }
}
cout << f[(1 << n) - 1] << "\n";

复杂度:预处理 O(2ⁿ),DP 是「枚举所有子集的子集」共 O(3ⁿ)。n = 16 时 3¹⁶ ≈ 4.3×10⁷,可以接受;n = 18 时 3¹⁸ ≈ 3.9×10⁸,偏紧。

优化技巧:可以只枚举「包含 S 中最低位元素」的子集 T。因为「最低位的那个元素总得被分到某一组里」,把它固定在一组中,可以避免大量重复枚举,常数能降下来一大截。

易错:① S ^ T 与 S & ~T 等价(因为 T ⊆ S),但写成 S - T 在进位时会出错;② 忘记跳过 sum[T] > V 的子集;③ f 数组没有把「不可达」区分开(f[S] 的初值必须是一个足够大的 INF)。

补充:本题为什么不能贪心?一个常见想法是「每次尽量装到最满」(首次适应递减 FFD)。它是很好的近似算法,但不是最优的。经典反例:V = 10,物品为 7, 7, 6, 6, 5, 5, 4, 4, 4, 4。FFD 先放 7,再各配 3 个「凑不满」的组合,最终要用 6 组;而最优方案用 5 组就能装完。
事实上,箱装问题是 NP-hard 的,在 n 很小(≤ 20)时只能用状压 DP 求精确解;n 很大时只能上近似算法或启发式搜索。这也是为什么「数据范围」是判断该用哪种算法的第一依据。

C3数位 DP 进阶

洛谷 P2602 [ZJOI2010] 数字计数:求 [a, b] 中每个数字 0~9 各出现了多少次。a, b ≤ 10¹²。

查看参考答案

核心区别:本题返回的不是「方案数」,而是「统计量」。所以记忆化搜索的返回值必须是一个结构体,同时携带「方案数」和「出现次数」。

struct Node {
    long long cnt;      // 合法填法的方案数
    long long sum;      // 数字 d 在这些填法中出现的总次数
};

Node memo[15];       // 本题的状态只有 pos(因为条件与前面的位无关)
bool vis[15];
int a[15], d;         // d 是当前统计的数字

Node dfs(int pos, bool limit, bool lead) {
    if (pos == 0) return {1, 0};
    if (!limit && !lead && vis[pos]) return memo[pos];

    int up = limit ? a[pos] : 9;
    Node res = {0, 0};
    for (int c = 0; c <= up; c++) {
        bool nlead = lead && (c == 0);
        Node t = dfs(pos - 1, limit && (c == up), nlead);
        res.cnt += t.cnt;
        // 关键一行:如果当前位就是 d,则它出现了 t.cnt 次
        if (!nlead && c == d) res.sum += t.cnt;
        res.sum += t.sum;
    }
    if (!limit && !lead) { memo[pos] = res; vis[pos] = true; }
    return res;
}

Node solve(long long x, int digit) {   // 统计 [0, x] 中 digit 出现次数
    if (x < 0) return {0, 0};
    int len = 0;
    while (x) { a[++len] = x % 10; x /= 10; }
    memset(vis, 0, sizeof(vis));
    d = digit;
    return dfs(len, true, true);
}

// 答案:solve(b, d).sum - solve(a-1, d).sum

为什么「当前位是 d 时贡献 t.cnt」?因为当前位固定填 d 之后,后面的位有 t.cnt 种不同的填法。每种填法都会让「数字 d 在当前位置出现 1 次」,所以总共贡献 1 × t.cnt = t.cnt 次。这是「计数类」数位 DP 转向「统计类」数位 DP 的关键一步。

为什么本题的 memo 只与 pos 有关?因为题目统计的是「所有数字各出现多少次」,与「上一位是什么」无关,所以状态里不需要 last。数位 DP 的状态是要「按需设计」的——题目关心什么,就往状态里放什么;题目不关心的,就不要浪费一维。

易错:① 前导零必须处理,否则像 007 里的两个 0 会被错误统计(统计数字 0 时这个问题尤其致命);② res.sum += t.cnt 写成了 res.sum += 1;③ 因为要统计 10 个数字,每次调用 solve 都要清空 vis,否则上一次的缓存会污染(本题的状态只含 pos,与 d 无关,所以必须按 d 分别清空或把 d 也放进状态)。

⚠️ 一个隐藏的坑:上面代码里 memo 只按 pos 索引,但 d 是全局变量——所以每次调用 solve 都必须清空 vis。改进写法是把 d 也放进状态(memo[pos][d]),这样一次清空即可反复使用。这体现了「状态必须包含所有影响后续答案的信息」这条铁律。

C4单调队列优化多重背包

洛谷 P1776 宝物筛选:n 种物品(n ≤ 100),每种有数量 c[i]、体积 v[i]、价值 w[i],背包容量 V ≤ 4×10⁴。求最大总价值。

查看参考答案

先看两种朴素做法:
· 拆成 0/1 背包:把 c[i] 件物品当成 c[i] 件独立物品 → O(V · Σc[i]),当 c[i] 很大时(如 10⁵)直接爆炸;
· 二进制拆分:把 c[i] 拆成 1,2,4,8,… → O(V · Σlog c[i]),能过本题,但还有更快的。

单调队列优化 O(n·V) 最优

第一步:把转移写清楚。对第 i 种物品:
f[j] = max{ f[j − k·v] + k·w },其中 0 ≤ k ≤ c,且 j − k·v ≥ 0。

第二步:按「余数」分组。把容量 j 按 j mod v 分成 v 组。在同一组内,所有容量的形式都是 r + t·v(r 是余数,t 是序号)。
设 g[t] = f[r + t·v],则转移式变成:

g[t] = max{ g[t − k] − (t−k)·w + t·w },k ∈ [0, c]

第三步:识别出滑动窗口。把 g[t−k] + (t−k)·w 看成候选值 H[t−k],则 g[t] = max{ H[t−k] } + t·w,其中 t−k ∈ [t−c, t]——这正是「长度固定为 c+1 的滑动窗口取最大值」!用单调队列即可 O(1) 完成。

for (int i = 1; i <= n; i++) {
    for (int r = 0; r < v[i] && r <= V; r++) {     // 枚举余数 r
        deque<int> q;                          // 存 t 值
        for (int t = 0; r + t * v[i] <= V; t++) {
            int j = r + t * v[i];
            int H = f[j] - t * w[i];              // 候选值(去掉 t*w 的部分)
            while (!q.empty() && f[r + q.back() * v[i]] - q.back() * w[i] <= H)
                q.pop_back();
            q.push_back(t);
            while (!q.empty() && q.front() < t - c[i]) q.pop_front();
            f[j] = max(f[j], f[r + q.front() * v[i]] - q.front() * w[i] + t * w[i]);
        }
    }
}

复杂度:外层 n 种物品,内层对每个余数扫一遍所有容量,总计 O(n·V)。n = 100、V = 4×10⁴ 时约 4×10⁶ 次运算,非常快。相比二进制拆分的 O(V·Σlog c),当 c 很大时优势极其明显。

易错:① 队列里存的是 t(序号)而不是容量 j,取值时要换算成 r + t·v;② 候选值比较时要统一去掉 t·w(因为队列里的不同 t 对应的 t·w 不同,不能直接比较 f 值);③ 出队首的判据是 q.front() < t − c(窗口内最多用 c 件物品,所以合法的 t−k ≥ t−c);④ 本题的 w[i] 在 P1776 中对应「价值」,v[i] 对应「体积」,不要搞混。

总结:单调队列优化的三种典型战场
① 滑动窗口最值(P1886)——最纯粹的形态;
② 区间 DP 转移(P1725、P1714)——「窗口」是 j 的取值范围;
③ 多重背包(P1776)——按余数分组后,「窗口」隐藏在同余类里。
识别信号永远是「从一个随 i 单调移动的区间里取最值」。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖状态压缩、数位 DP、单调队列优化与矩阵快速幂四条主线,按难度从普及+到省选-排列。

1
P1433
吃奶酪(状压 TSP 入门)普及+
2
P1879
[USACO06NOV] Corn Fields G普及+
3
P1886
滑动窗口(单调队列模板)普及+
4
P1714
切蛋糕(前缀和 + 单调队列)普及+
5
P1776
宝物筛选(多重背包单调队列)提高
6
P1896
[SCOI2005] 互不侵犯提高
7
P2657
[SCOI2009] windy 数提高
8
P2602
[ZJOI2010] 数字计数提高
9
P4124
[CQOI2016] 手机号码提高
10
P1725
琪露诺(单调队列优化 DP)提高
11
P2704
[NOI2001] 炮兵阵地(三行状压)提高+
12
P1939
【模板】矩阵加速(矩阵快速幂)提高
刷题建议:第 1~4 题是「状压 + 单调队列」的基本功,务必亲手写完并理解每一步;第 5~9 题是本讲的核心战场——多重背包的单调队列优化、四种棋盘状压、三种数位 DP,建议每个模型都自己推导一遍转移式再对代码;第 10~12 题是「优化 + 状压」的进阶,P2704 一定要注意「攻击范围 2 格 → 状态开两维」和「负数取模越界」这两个坑。做题时先问自己「数据范围给了什么提示」,再动手。