第一次正式学 DP —— 先把「状态定义 → 转移方程 → 边界 → 答案」这四步练成肌肉记忆,再上手四道题
| 0 - 30 min | 搞懂 DP 四步法(第 1 节)—— 今天最重要的地基 |
| 30 - 60 min | 线性 DP 的三大模型(第 2 节)+ 手推数字三角形(第 3 节) |
| 60 - 95 min | 路径计数 DP(第 4 节)与 LIS / 合唱队形(第 5 节) |
| 95 - 120 min | 分类讨论递推:拿 P1990 覆盖墙壁当例子(第 6 节) |
| 120 - 240 min | 做 4 道题,每题限时 30 分钟 |
| 240 - 270 min | 对照本文题解把没做出来的补掉 + 做自测题 |
其实你早就写过 DP 了,只是没这么叫它:
· Day 16 的 P1028 数的计算、Day 17 的 P1044 栈,本质都是「把答案存在数组里、用更小的答案推出更大的答案」;
· Day 17 的 记忆化搜索(P1464)就是「递归 + 把算过的结果存起来」,它和 DP 是同一件事的两种写法;
· Day 24 的 P1115 最大子段和 更是标准的线性 DP,只是当时我们用「递推」的说法糊过去了。
今天要做的是:把这套东西命名、公式化,变成一套每道题都能照着走的流程。
有一段楼梯,共 n 级台阶。你每次可以跨 1 级或 2 级,问走到第 n 级有多少种走法?
小学生都会的推法:想到达第 n 级,最后一步只有两种可能——
· 最后一步跨了 1 级,那之前站在第 n-1 级;
· 最后一步跨了 2 级,那之前站在第 n-2 级。
所以 走法数(n) = 走法数(n-1) + 走法数(n-2)。这就是斐波那契。而你刚才做的事,就是动态规划。
把一个大问题拆成一堆「小一号的同类问题」,先把小问题的答案算好存起来,再用它们拼出大问题的答案。
「存起来」这一步是关键 —— 它保证每个小问题只算一次,这就是 DP 比暴力搜索快的原因。
| 做法 | 怎么算 fib(5) | 复杂度 |
|---|---|---|
| 朴素递归 | fib(5) 要 fib(4) 和 fib(3),fib(4) 又要 fib(3) 和 fib(2)……fib(3) 被重复算了两次,越往下重复越多 | O(2ⁿ) 爆炸 |
| 记忆化搜索 | 递归写,但算完 f[x] 就存进数组,下次直接取 | O(n) |
| 动态规划 | 干脆不递归:从 f[1]、f[2] 开始,一行一行往前推,推的时候用到的都已经算好了 | O(n) |
记忆化和 DP 是同一件事:记忆化是「自顶向下 + 查表」,DP 是「自底向上 + 填表」。本文只讲后者,因为它写起来更短、更快,也不会有递归爆栈的风险。
下面这张表是今天的核心。以后拿到任何一道 DP 题,先在草稿纸上把这四个空填出来,代码自然就出来了。
| 步骤 | 要回答的问题 | 爬楼梯这一题填什么 |
|---|---|---|
| ① 状态定义 |
dp[...] 表示什么?下标是「位置」「容量」还是「已经考虑了几个」? |
dp[i] = 走到第 i 级台阶的走法数 |
| ② 转移方程 |
这个状态能从哪些更小的状态来?把它们怎么组合起来? | dp[i] = dp[i-1] + dp[i-2] |
| ③ 边界(初始化) |
最小号的状态是多少?手算出来直接写死。 | dp[0] = 1(站着不动算 1 种)dp[1] = 1 |
| ④ 答案 |
最后要输出哪个状态?注意不一定是 dp[n]! | dp[n] |
// 爬楼梯:四步法直接翻译成代码 int dp[1005]; dp[0] = 1; dp[1] = 1; // ③ 边界:手算出来写死 for(int i = 2; i <= n; i++) dp[i] = dp[i-1] + dp[i-2]; // ② 转移方程 cout << dp[n] << endl; // ④ 答案
很多新手写完转移方程就 cout << dp[n],但答案不一定是 dp[n]。反例就是 Day 24 的 P1115 最大子段和:答案要在所有 dp[i] 里取 max。
每次写完代码都要回头问自己一句:「题目问的那个东西,对应的是哪个状态?」
| 前提 | 说人话 |
|---|---|
| 最优子结构 | 大问题的最优解,一定是由「某个小问题的最优解」接上一步得到的。 所以你可以放心地只用小问题的最优值,不用管它内部长什么样。 |
| 无后效性 | 状态算出来之后,它不受「我是怎么走到这里的」影响。 数字三角形里,只要你走到了格子 (i,j),后面能拿多少只跟 (i,j) 有关,跟前面走的路线无关。 |
问自己:「只给我这个状态的值,我能不能决定下一步怎么走?」
能 → 这个状态定义是无后效的,可以用;不能 → 说明状态里缺信息(比如缺了「上一个是谁」),要么加维度,要么换定义。
「线性 DP」指的是状态沿着一条线(数组下标、格子坐标)往前推的 DP。今天四道题全是这个套路,而且主要是三种形态。
状态长这样:dp[i] = 「必须选第 i 个元素」时能拿到的最大/最优值。
转移就是:枚举上一个选了谁,从 dp[j](j < i)接过来。
// 模板:以 i 结尾的线性 DP for(int i = 1; i <= n; i++){ dp[i] = 只选 a[i] 时的值; // ★ 先假设"从 i 重新开始" for(int j = 1; j < i; j++) if(可以接在 j 后面) dp[i] = max(dp[i], dp[j] + 代价); // 从 j 接过来 }
这个模板最典型的两个例子,一个是已学过的最大子段和(dp[i] = max(a[i], dp[i-1] + a[i])),一个是今天的 P1091 合唱队形。
为什么要「强制以 i 结尾」?因为如果不强制,状态就变成「前 i 个里随便挑」——那你没法知道最后一个是谁,也就没法判断新的元素能不能接上(无后效性被破坏了)。
强制结尾 = 把「上一个是谁」这个信息塞进状态里。这是 DP 里最常用的一个技巧。
状态长这样:dp[i][j] = 「处理到第 i 阶段、处在第 j 个位置」时的答案。转移只看上一阶段的邻居。
// 模板:多阶段推进(数字三角形、过河卒都是这个形状) for(int i = 2; i <= n; i++) for(int j = 1; j <= m; j++) dp[i][j] = 上一行相邻位置的组合; // 例如 max(...) 或加法;
| 模型 | 状态含义 | 转移来源 | 今天的题 |
|---|---|---|---|
| A · 以 i 结尾 | dp[i]:必须选 i | 所有更小的 dp[j](j < i) | P1091 合唱队形 |
| B · 多阶段推进 | dp[i][j]:走到第 i 行第 j 列 | 上一行的邻居 | P1216 数字三角形 P1002 过河卒 |
| C · 分类讨论递推 | f[n]:长度为 n 的整块问题的方案数 | 按「第一步怎么开始」分成几类,各类对应更短的前缀 | P1990 覆盖墙壁 |
第 3 行的 C 和前两行不太一样:它不枚举「上一个位置」,而是枚举「第一步有哪几种不同的走法」—— 竖着放、横着放、还是用 L 形起头。每种走法都会把问题缩小成一个更短的同类问题,把各类的方案数加起来就是答案。 「分类讨论」是 DP 里最常用的一招,本文第 6 节会用 P1990 把它讲透。
| 步骤 | 填什么 |
|---|---|
| ① 状态定义 | dp[i][j] = 从第 i 行第 j 列这个格子出发、一路走到最底部能拿到的最大数字和 |
| ② 转移方程 | 从 (i,j) 下一步只能去左下的 (i+1,j) 或右下的 (i+1,j+1),取大的那个:dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j] |
| ③ 边界 | 最后一行没有下一步了:dp[r][j] = a[r][j] |
| ④ 答案 | 起点就是塔尖:dp[1][1] |
同一道题,两种方向都能做,但工作量差很多:
| 自顶向下(从塔尖往下) | 自底向上(从底部往上) | |
|---|---|---|
| 状态含义 | dp[i][j] = 从塔尖走到 (i,j) 的最大和 | dp[i][j] = 从 (i,j) 走到塔底的最大和 |
| 转移 | dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + a[i][j] | dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j] |
| 麻烦点 | 左边界和右边界要特判:第 1 列上面没有格子、每行最后一列上面也没有格子,不特判就会读到下标 0 或越界 | 不用特判:每个格子下面都正好有 2 个格子(最后一行除外,而它本来就是边界) |
| 答案 | max(dp[r][1..r]),还要扫一遍最后一行 | dp[1][1],直接输出 |
自顶向下要处理两条斜边的边界,自底向上一个边界都不用管,而且答案天然就在 dp[1][1]。
以后遇到「塔形 / 三角 / 逐层推进」的题,先想想能不能倒着推。
输入是:
5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
自底向上填表,最后一行原样抄下来,然后从下往上逐格填:
| 行 | 填的过程 | 填完的结果 |
|---|---|---|
| 第 5 行 (边界) | 没有下一步,原样写 | 4 5 2 6 5 |
| 第 4 行 | 2+max(4,5)=7 7+max(5,2)=12 4+max(2,6)=10 4+max(6,5)=10 | 7 12 10 10 |
| 第 3 行 | 8+max(7,12)=20 1+max(12,10)=13 0+max(10,10)=10 | 20 13 10 |
| 第 2 行 | 3+max(20,13)=23 8+max(13,10)=21 | 23 21 |
| 第 1 行 | 7+max(23,21)=30 | 30 |
答案 30,和官方样例输出一致。这条路径就是 7 → 3 → 8 → 7 → 5,也正是题面里说的那条。
DP 题最怕的是「式子写对了但边界错」,手推一遍能让边界错误当场暴露(比如上面第 5 行如果忘了原样写,后面全错)。
DP 题写完务必手算一遍样例,逐格对照——这是学习计划原文里的原话,也是今天最重要的一条纪律。
r ≤ 1000,每个数在 [0,100] 之间。
最大和 = 1000 × 100 = 100000,稳稳在 int 里,不用 long long。
数组要开到 [1005][1005]:下标从 1 开始用,所以至少 1001;同时转移时要访问第 i+1 行,多留几格余量最保险。全局数组会自带清零,别写成局部数组。
卒只能往下或往右走。那么走到格子 (i,j) 的最后一步只有两种:
· 从上面 (i-1,j) 下来;
· 从左边 (i,j-1) 过来。
所以「到 (i,j) 的路径条数」= 「到 (i-1,j) 的条数」+ 「到 (i,j-1) 的条数」,又是一次加法型 DP。
| 步骤 | 填什么 |
|---|---|
| ① 状态定义 | dp[i][j] = 从起点 (0,0) 走到 (i,j) 的路径条数 |
| ② 转移方程 | dp[i][j] = dp[i-1][j] + dp[i][j-1](加法!因为是数方案不是求最大) |
| ③ 边界 | dp[0][0] = 1(起点自己算 1 条路) |
| ④ 答案 | dp[n][m] |
马的位置 (hx,hy) 本身、加上它一步能跳到的 8 个点,一共 9 个点都是控制点,卒不能进入。
处理办法很暴力也很好用:把这 9 个格子的 dp 值直接锁死为 0。因为条数是 0,后面谁从它转移过来也自动是 0,等于「此路不通」。
// 马自己 + 8 个跳跃点,一共 9 个控制点 int dx[9] = { 0, -2, -1, 1, 2, 2, 1, -1, -2}; int dy[9] = { 0, 1, 2, 2, 1, -1, -2, -2, -1}; for(int k = 0; k < 9; k++){ int x = hx + dx[k], y = hy + dy[k]; if(x >= 0 && y >= 0) blocked[x][y] = true; // 在棋盘内才标记 }
上面两个数组的第一个元素是 (0,0),对应的就是马自己所在的位置。很多人写 8 个偏移量写下习惯,顺手就漏掉了马自己——这样马脚下的格子还能走,答案直接错。
数一下:马的控制点一共 9 个,不是 8 个。
题目的坐标是 (0,0) 到 (n,m),从 0 开始。但转移时要读 dp[i-1][j],当 i = 0 时就是 dp[-1][j] —— 越界,轻则 WA 重则 RE(还可能读到别的变量,出玄学错误)。
标准解法:把所有坐标整体平移 +2,起点变成 (2,2),那么 dp[1][*]、dp[*][1] 这些"棋盘外面"的格子天然是 0,正好符合"从外面走不进来"的物理意义,一行特判都不用写。
这题的路径条数是组合数级别的大。20×20 的棋盘,从 (0,0) 走到 (20,20) 一共要走 40 步,其中 20 步向下、20 步向右,条数就是 C(40,20) = 137846528820,约 1.38×10¹¹。
而 int 的上限只有 2147483647(约 2.1×10⁹)——差了将近 64 倍,用 int 必爆。必须用 long long。
实测:n=m=20、马在 (0,1) 时答案 8597496600,也已经超过 int 上限了。
看到下面任何一种情况,先把 long long 写上再说:
· 题目在数方案数 / 路径条数 / 子集个数(组合数增长极快);
· 题目里出现阶乘、乘方(比如 Day 16 的 n! 那类);
· 数据范围里的 n 有二三十,而答案是计数型(C(40,20) 这个量级);
· 题目没给答案范围,你又算不出上界 —— 那就用 long long,代价只是多几 MB 内存。
从一个序列里挑出若干个元素,不改变它们在原序列里的先后顺序,让它们严格递增,问最多能挑几个。
比如 3 7 1 4 9,可以挑 3 7 9(3 个),也可以挑 1 4 9(3 个),最长就是 3。
up[i] = 以第 i 个数结尾的最长上升子序列长度。
想让它以 i 结尾,那前一个数可以是任意一个 j < i 且 a[j] < a[i]:
up[i] = max(up[i], up[j] + 1)
这跟第 2 节的「模型 A」一模一样。O(n²) 在 n ≤ 100 时完全够用,今天不学 O(n log n) 的贪心+二分写法。
合唱队形的要求是:存在一个「最高点」i,使得最高点左边严格递增、右边严格递减。整队人从左边走到最高点是一直变高的,过了最高点就一直变矮。
那么对于固定的最高点 i:
· 左边最多留 up[i] 个人(以 i 结尾的最长上升子序列);
· 右边最多留 down[i] 个人(以 i 开头的最长下降子序列);
· 但 i 自己两边都数了一遍,被重复计算了,要减 1。
留下的最多人数 = maxi=1..n ( up[i] + down[i] − 1 )
需要出列的人数 = n − 留下的最多人数
题目问「最少需要几位同学出列」,也就是「让留下的人尽量多」,所以是 n - best,不是 best。这一步别看反了。
down[i] 的定义是「以 i 开头、往右走的最长下降子序列」。求法有两种,效果一样:
| 写法 | 怎么想 |
|---|---|
| 倒着扫一遍(本文代码用这个) | 从 n 往 1 扫,找 j > i 且 a[j] < a[i]:down[i] = max(down[i], down[j] + 1) |
| 把序列反转,求 LIS | 反转之后原来的"下降"就变成"上升"了,直接套 LIS 模板,再按原下标对应回去 |
两种写法本质相同。倒着扫一遍少一次数组反转,本文用这种。
题目要求身高满足 严格递增、严格递减(不能有相等的人站在一起变高或变矮)。
所以判断条件写 a[j] < a[i],不能写成 a[j] <= a[i]。
实测边界:两个人身高都是 130 时,答案必须是 1(得请出去一个),因为两个一样高的人没法构成严格递增或严格递减。
n ≤ 100,两遍 O(n²) 各约 100×100/2 = 5000 次比较,枚举最高点再 100 次 —— 总共一万多次操作,随便怎么写都不会超时。
有一面 2 行 N 列 的墙(题面写作「长为 N 宽为 2」)。手里有两种砖:
| 砖 | 形状 | 怎么用 |
|---|---|---|
| 2×1 骨牌 | 竖放占一列,旋转 90° 就占同一行的相邻两列 | 竖放时一次铺满一列;横放时一次铺满一行的两格 |
| L 型砖 | 占 2×2 方块里的 3 格(挖掉一个角) | 可以旋转,一共 4 种朝向 |
// L 型砖的四种朝向(■ 表示被覆盖)
■■ ■· ·■ ■■
■· ■■ ■■ ·■
两种砖都可以无限提供。问把整面墙不重不漏地铺满,一共有多少种铺法(只输出末 4 位)。
| 步骤 | 填什么 |
|---|---|
| ① 状态定义 | f[n] = 把 2×n 的墙完整铺满的方案数 |
| ② 转移方程 | f[n] = f[n−1] + f[n−2] + 2·S[n−3]其中 S[m] = f[0] + f[1] + … + f[m](前缀和) |
| ③ 边界 | f[0] = 1(空墙只有「什么都不铺」1 种)、f[1] = 1、f[2] = 2 |
| ④ 答案 | f[N] % 10000(题目只要末 4 位) |
这是本题的全部难点,也是分类讨论型 DP 的标准推导方式:盯着整面墙最左上角的那一格,枚举「盖住它的砖」有哪几种可能。
| 情况 | 左上角被谁盖住 | 接下来会发生什么 | 这一类有多少种 |
|---|---|---|---|
| A | 一块竖放的骨牌 | 第 1 列两格一起被铺满,右边剩下一面干干净净的 2×(n−1) 墙 | f[n−1] |
| B | 一块横放的骨牌 | 左上角被横砖占掉之后,左下角那一格就没得选了:竖砖放不下(上面那格已被占)、L 砖也放不下(要覆盖的三格凑不齐),只能再横放一块。于是前 2 列一起铺满,右边剩 2×(n−2) | f[n−2] |
| C | 一块 L 型砖 | L 有 2 种能盖住左上角的朝向(互为镜像)。盖完之后第 1 列满了,但第 2 列会留下一个缺口 —— 下面详细说 | 2·S[n−3] |
L 起头之后,第 2 列会剩下一格空着(缺口)。这一格能怎么盖?
· 往右推:用一块横放的骨牌盖住它,于是缺口被推到第 3 列(而且上下翻转了),第 2 列随之铺满;
· 就地收尾:用另一块 L 盖住这个缺口,同时把下一列的两格一起补满,于是从这里往右又是一面干净的墙。
关键在于:每一步都只有一种走法 —— 缺口在第几列、哪一行是确定的,能盖住它的砖也就唯一确定了。所以整段「阶梯」的形状是被完全决定的,唯一的选择只剩它在哪里收尾。
于是:
· 阶梯可以在第 j 列收尾(j ≥ 3),对每一个收尾位置 j,都恰好有 2 种铺法(对应 L 的两种镜像朝向);
· 收尾之后,右边剩下 2×(n−j) 的干净墙,有 f[n−j] 种铺法。
把 j 从 3 到 n 全加起来:2·f[n−3] + 2·f[n−4] + … + 2·f[0] = 2·(f[0]+f[1]+…+f[n−3]) = 2·S[n−3]。
f[n] = f[n−1] + f[n−2] + 2·S[n−3]
而这三类之所以能直接相加,是因为它们互不重叠、又盖住了全部铺法:任何一个铺法里,左上角那一格总得被某一块砖盖住,而能盖住它的砖只有竖骨牌、横骨牌、L 型这三种。
写完分类讨论的转移方程,一定要回头检查这一句「不重不漏」。这是分类讨论题最容易翻车的地方。
如果老老实实每次现算那个和,算 f[n] 就要往回扫 n 项,总复杂度 O(N²)。N 最大 10⁶ 时是 10¹² 次操作 —— 必超时。
但那个和是连续前缀(f[0] 一直加到 f[n−3]),所以可以开个数组 S[m] = f[0]+…+f[m] 把它记下来:
S[0] = f[0];
for(int i = 1; i <= N; i++)
S[i] = (S[i - 1] + f[i]) % MOD; // 前缀和递推,O(1) 一项
这样每一项转移都是 O(1),总复杂度 O(N)。「看到连续区间的求和,就用前缀和」——Day 15 学过的套路,今天在 DP 里又见面了。
f 每次都被 % 10000 压住,但 S 是累加量:如果不取模,它会一路涨到 10⁶ × 10⁴ = 10¹⁰ 的量级 —— 用 int 会溢出成负数,用 long long 也是白涨。
凡是累加型的数组(前缀和、方案数),累加那一刻就要取模。
| n | 计算过程 | f[n] | S[n] |
|---|---|---|---|
| 0 | 边界:空墙,什么都不铺 | 1 | 1 |
| 1 | 边界:只能竖放一块 | 1 | 2 |
| 2 | 边界:两块竖放 或 两块横放 | 2 | 4 |
| 3 | f[2] + f[1] + 2×S[0] = 2 + 1 + 2×1 | 5 | 9 |
| 4 | f[3] + f[2] + 2×S[1] = 5 + 2 + 2×2 | 11 | 20 |
| 5 | f[4] + f[3] + 2×S[2] = 11 + 5 + 2×4 | 24 | 44 |
| 6 | f[5] + f[4] + 2×S[3] = 24 + 11 + 2×9 | 53 | 97 |
n = 3 得到 5,正好对上题面里那句「一个 2×3 的墙可以有 5 种覆盖方法」;继续往下推到 n = 13 会得到 13465,取末 4 位就是官方样例的 3465。
计划原文的提醒是「分情况讨论最后一块砖怎么放(竖放 / 两块横放 / L 形)」——按最右边分类和按最左边分类是一回事,因为整面墙左右对称,得到的方程完全一样。
本文选「看左上角」,只是因为这样剩下的部分永远是一个规规矩矩的前缀,可以直接写成 f[更小的下标],不用再操心「右边那块 L 是不是缺了个角」。两种写法你都该会,考场上挑顺手的用。
下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning),
并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。
题意:给一个数字金字塔,从塔尖出发往下走,每一步可以到左下方或右下方的格子,一直走到最底部(在哪一格停下都行)。问走过的数字之和最大是多少。
行数 1 ≤ r ≤ 1000,金字塔里的数字都在 [0, 100] 范围内。
所以最大和 = 1000 × 100 = 100000,int 完全够用。
但要读入的数字个数最多有 1+2+…+1000 = 500500 个,建议关掉 cin/cout 与 stdio 的同步以加快读入。
把「从塔尖走到 (i,j) 的最大和」翻过来想,改成「从 (i,j) 走到最底部的最大和」,然后自底向上递推:
dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
最后一行没有下一步,直接等于自己的数字(边界);答案就是塔尖 dp[1][1]。
为什么不用自顶向下?因为自顶向下时每一行的左右两条斜边都只有一个"父亲",得特判;自底向上时每个格子下面都正好有两个格子,一个特判都不用写(详见本文第 3 节的对比表)。
5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
30
#include <iostream> #include <algorithm> using namespace std; // r <= 1000,全局数组自动清零 int dp[1005][1005]; int main(){ ios::sync_with_stdio(false); // 最多要读 50 万个数,关掉同步更快 cin.tie(NULL); int r; cin >> r; // 直接读进 dp 里,省一个数组 for(int i = 1; i <= r; i++) for(int j = 1; j <= i; j++) cin >> dp[i][j]; // ★ 自底向上:先把最后一行当成「从该点走到最底部的最大和」 for(int i = r - 1; i >= 1; i--) for(int j = 1; j <= i; j++) dp[i][j] += max(dp[i + 1][j], dp[i + 1][j + 1]); cout << dp[1][1] << endl; // ④ 答案就是塔尖 return 0; }
官方样例 5 / 7 / 3 8 / 8 1 0 / 2 7 4 4 / 4 5 2 6 5 → 输出 30 完全一致
最大数据 r = 1000,每一格都填 100 → 输出 100000,正确(100 × 1000 级台阶)
耗时约 166 ms(含进程启动与读入 50 万个数)
编译:g++ -std=c++14 -O2 -Wall → 0 warning
dp[1005][1005] 是安全值。下标从 1 开始用,而且转移要读 i+1 行,别只开 1001。= {0}。题意:棋盘上卒在 (0,0),要走到 (n,m),只能向下或向右走一步。棋盘上有一匹不动的马,马自己所在的点以及它一步能跳到的 8 个点都是禁区,卒不能经过。问从起点到终点有多少条合法路径。
1 ≤ n, m ≤ 20,马的坐标也在 0 ~ 20 之间。保证起点不是马的控制点。
棋盘很小,但答案很大:无障碍时条数是 C(40,20) = 137846528820 ≈ 1.38×10¹¹,远超 int 上限 2147483647。
必须用 long long,dp 数组也必须是 long long。
四步法:
① dp[i][j] = 从起点走到 (i,j) 的路径条数;
② dp[i][j] = dp[i-1][j] + dp[i][j-1](因为是数方案,所以用加法);
③ 起点 dp[起点] = 1,马的控制点全部置 0;
④ 答案 dp[n][m]。
两个实现要点:
(1) 坐标整体平移 +2。所有坐标加上 2(起点变成 (2,2)),这样 dp[i-1][j] 在 i=2 时读到的是 dp[1][j] = 0,天然表示"棋盘外面走不进来",不用写任何边界特判。
(2) 9 个控制点。马自己的位置也算一个,漏了就错。
6 6 3 3
6
#include <iostream> using namespace std; // ★ 路径条数会超过 int(20x20 棋盘最大约 1.4e11),必须 long long long long dp[30][30]; bool blocked[30][30]; int main(){ int n, m, hx, hy; cin >> n >> m >> hx >> hy; // 整体平移 2 格,这样 dp[i-1][j]、dp[i][j-1] 不会用到负下标 n += 2; m += 2; hx += 2; hy += 2; // 马自己 + 8 个跳跃点,一共 9 个控制点(第一个 (0,0) 就是马自己!) int dx[9] = { 0, -2, -1, 1, 2, 2, 1, -1, -2}; int dy[9] = { 0, 1, 2, 2, 1, -1, -2, -2, -1}; for(int k = 0; k < 9; k++){ int x = hx + dx[k], y = hy + dy[k]; if(x >= 0 && y >= 0) blocked[x][y] = true; } dp[2][2] = 1; // 起点(平移后是 (2,2)) for(int i = 2; i <= n; i++){ for(int j = 2; j <= m; j++){ if(blocked[i][j]){ dp[i][j] = 0; continue; } // 控制点走不到 if(i == 2 && j == 2) continue; // 起点不参与转移 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } cout << dp[n][m] << endl; return 0; }
官方样例 6 6 3 3 → 输出 6 完全一致
最大数据 20 20 0 1(马在 (0,1))→ 输出 8597496600
这个数已经超过 int 上限 2147483647 三倍多 —— 用 int 存必爆,这就是本题必须 long long 的实证。
边界 1 1 0 1(马挡住 (0,1))→ 输出 1 正确
从 (0,0) 到 (1,1) 本来有两条路,走上面那条会被马挡住,只能先向下再向右。
对拍验证:n、m 取 1~5,马的坐标穷举 0~5,共 825 组,本解法与暴力 DFS 枚举所有路径的结果全部一致。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
dp[-1][j],越界或玄学错误。dp[2][2] = 1 之后,循环里要 continue 跳过它,否则会被 dp[1][2] + dp[2][1] = 0 覆盖成 0。x >= 0 && y >= 0,防止写到数组外面去。题意:有一面 2 行 N 列的墙,手里有 2×1 的骨牌和 L 型(2×2 挖掉一角的 3 格)砖,两种砖都能随便旋转、无限量供应。问把整面墙不重不漏铺满一共有多少种铺法;因为答案很大,只输出末 4 位(不足 4 位就直接输出,不加前导 0)。
1 ≤ N ≤ 10⁶。这个范围是本题最重要的信息:它意味着必须做到 O(N)。任何 O(N²) 的写法在 N = 10⁶ 时都是 10¹² 次操作,稳稳超时。
只要输出末 4 位,所以每一步都 % 10000。注意 N = 3 时答案是 5,要输出 5 而不是 0005 —— 用整数取模再输出天然满足这一点,别手动补零。
模数只有 10000,int 就够;但前缀和 S 是累加量,累加时也要取模,否则量级会到 10¹⁰,溢出。
四步法:
① f[n] = 把 2×n 的墙铺满的方案数;
② f[n] = f[n−1] + f[n−2] + 2·S[n−3](S[m] = f[0]+…+f[m] 是前缀和);
③ 边界 f[0] = 1(空墙 = 什么都不铺,也算 1 种)、f[1] = 1、f[2] = 2;
④ 答案 f[N] % 10000。
转移方程的三个来源,就是「左上角那一格被哪块砖盖住」的三种情况(详细推导见本文第 6 节):
| 左上角被谁盖住 | 接着发生什么 | 贡献 |
|---|---|---|
| 竖放的骨牌 | 第 1 列整列铺满,右边是干净的 2×(n−1) | f[n−1] |
| 横放的骨牌 | 左下那格只剩「再横放一块」这一条路,于是前 2 列一起铺满 | f[n−2] |
| L 型砖 | 2 种镜像朝向;之后缺口只能一列列往右推(每步无分支),直到用另一块 L 收尾。收尾位置 j 从 3 到 n 各 1 种,右边剩 f[n−j] 种 | 2·S[n−3] |
其中 L 那一类展开就是 Σj=3..n 2·f[n−j] = 2·(f[0]+f[1]+…+f[n−3])。用前缀和把它压成 O(1),整个算法就是一遍 O(N) 递推。
13
3465
题面明确说了 2×13 的铺法数是 13465,取末 4 位才是 3465。也就是说,只有真的算出 13465 再取模才会得到 3465 —— 这个样例顺便能验证我们的取模位置没写错。
#include <iostream> using namespace std; const int MOD = 10000; // f[n]:2 x n 墙壁的覆盖方案数 // S[n]:f[0] + f[1] + ... + f[n],用前缀和把转移压成 O(1) int f[1000005]; int S[1000005]; int main(){ int N; cin >> N; // 边界:空墙 1 种;2x1 只能竖放一块 1 种;2x2 可以两块竖放、也可以两块横放,2 种 f[0] = 1; f[1] = 1; if(N >= 2) f[2] = 2; S[0] = f[0] % MOD; if(N >= 1) S[1] = (S[0] + f[1]) % MOD; if(N >= 2) S[2] = (S[1] + f[2]) % MOD; // f[n] = f[n-1] + f[n-2] + 2 * S[n-3] for(int i = 3; i <= N; i++){ f[i] = (f[i - 1] + f[i - 2] + 2 * S[i - 3]) % MOD; S[i] = (S[i - 1] + f[i]) % MOD; // S 会越滚越大,必须每步取模 } cout << f[N] << endl; return 0; }
官方样例 13 → 输出 3465 完全一致
小程序实测 N = 1 … 13 → 1 2 5 11 24 53 117 258 569 1255 2768 6105 3465
其中 N = 3 的 5 与题面里「2×3 的墙有 5 种覆盖方法」一致。
最大数据 N = 1000000 → 输出 3427,耗时约 32 ms(时限 1 秒)
对拍验证:写了一个暴力程序,枚举 2×N 墙的所有精确覆盖(竖骨牌 / 横骨牌 / 4 种 L 朝向),N = 1…10 的结果是 1 2 5 11 24 53 117 258 569 1255,与递推全部一致;
另外单独验证了「左上角被 L 盖住」这一类铺法数恰好等于 2·S[n−3](N = 1…10 全部吻合),也就是第 6 节里那个分类推导是对的。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
f[0] = 1:它对应「L 阶梯正好铺满整面墙、右边一列不剩」这种情况。少了它 N = 3 会算成 3 而不是 5。5,不是 0005。用整数取模输出即可。long long 的多维数组。题意:n 个同学站成一排,每人有身高。请走最少的人,使剩下的人排成「合唱队形」:存在一个最高点,最高点左边严格递增、右边严格递减。问最少请出去几个人。
2 ≤ n ≤ 100,身高 130 ≤ ti ≤ 230。
n 只有 100,所以 O(n²) 的 LIS 完全够用,不需要写 O(n log n) 的优化版。
注意题目要求是严格递增 / 严格递减,身高相等的人不能构成队形。
枚举「最高点」是哪个同学(设为 i),那么:
· 左边最多留 up[i] 个人 —— 以 i 结尾的最长严格上升子序列;
· 右边最多留 down[i] 个人 —— 以 i 开头的最长严格下降子序列。
于是以 i 为最高点能留下 up[i] + down[i] - 1 个人(i 被两边各数了一次,要减 1)。
把所有 i 试一遍取最大,答案 = n − 最多能留下的人数。
求 up[] 是正着扫一遍 LIS;求 down[] 是倒着扫一遍,找 j > i 且 t[j] < t[i] 的转移。
8 186 186 150 200 160 130 197 220
4
样例最优方案:留下 150 200 160 130(先升到 200,再一路降到 130),共 4 人,所以请出去 8 − 4 = 4 人。
#include <iostream> #include <algorithm> using namespace std; int t[105]; int up[105]; // up[i]:以 i 结尾的最长「严格上升」子序列长度 int down[105]; // down[i]:以 i 开头的最长「严格下降」子序列长度 int main(){ int n; cin >> n; for(int i = 1; i <= n; i++) cin >> t[i]; // 正着扫一遍:上升的 LIS for(int i = 1; i <= n; i++){ up[i] = 1; for(int j = 1; j < i; j++) if(t[j] < t[i]) up[i] = max(up[i], up[j] + 1); // 严格上升 } // 倒着扫一遍:等价于「以 i 为最高点的下降部分」 for(int i = n; i >= 1; i--){ down[i] = 1; for(int j = i + 1; j <= n; j++) if(t[j] < t[i]) down[i] = max(down[i], down[j] + 1); } int best = 0; for(int i = 1; i <= n; i++) best = max(best, up[i] + down[i] - 1); // i 这个最高点被数了两次,减 1 cout << n - best << endl; // 出列人数 = 总人数 - 留下的 return 0; }
官方样例 → 输出 4 完全一致
边界 2 / 130 130(两人一样高)→ 输出 1 正确
身高相等不满足「严格」递增,必须请出去一个。如果把 < 写成 <=,这里会错答成 0。
边界 2 / 130 230 → 0;2 / 230 130 → 0 均正确(两人本身就构成合法队形,最高点可以在端点)
对拍验证:随机生成 3000 组数据(n = 2~10,身高故意限制在很小的范围以制造大量相等身高),与「暴力枚举所有子集、逐一检查是否先严格递增后严格递减」的结果全部一致。
编译:g++ -std=c++14 -O2 -Wall → 0 warning
best 而不是 n - best:题目问「最少请出去几个人」,不是「最多留几个人」。看清问的是哪个。up[i] + down[i] 把最高点 i 数了两次,正确是 up[i] + down[i] - 1。<=:题目要严格递增/递减,必须写 t[j] < t[i]。down[i] 是「以 i 开头往右走」,所以倒着扫、从 j = i+1 开始,条件也是 t[j] < t[i](比 i 矮的才能接在后面)。| 错误写法 | 后果 | 正确写法 |
|---|---|---|
| 先写代码,不先用四步法填表 | 式子想到一半就卡住,或者状态定义有后效性,改到崩溃 | 先在草稿上写清状态 / 转移 / 边界 / 答案四行,再动手敲 |
DP 写完直接输出 dp[n] |
答案根本不一定是它(最大子段和要取 max,合唱队形要取 n−best) | 回头问一遍:题目问的对应哪个状态 |
| P1216 自顶向下写,左右边界不特判 | 读到下标 0 或越界,样例就过不去 | 自底向上,一个边界都不用管 |
P1002 用 int dp[30][30] |
答案最大约 1.38×10¹¹,直接溢出成负数,WA | long long dp[30][30] |
| P1002 只标 8 个马的控制点 | 马脚下的格子还能走,方案数偏多 | 共 9 个控制点,偏移量数组第一个是 (0,0) |
| P1002 坐标不平移,直接用 (0,0) 起推 | dp[-1][j] 越界,RE 或玄学 WA |
所有坐标 +2,起点变 (2,2) |
P1091 比较写 <= |
身高相等的人也被算进队形,答案偏小 | 严格递增/递减用 t[j] < t[i] |
P1091 忘了 up[i] + down[i] - 1 里的 −1 |
最高点被数两次,留下人数偏多,出列人数偏少 | 减 1,再取 max |
| P1990 转移里现场扫一遍前缀求和 | 算 f[n] 要往回扫 n 项 → O(N²),N = 10⁶ 时 10¹² 次操作,超时 | 用前缀和 S[n] = (S[n-1] + f[n]) % MOD;,转移变 O(1) |
| P1990 的 S 数组忘记取模 | S 是累加量,涨得比 f 快,会溢出成负数 | 累加那一刻就取模:S[i] = (S[i-1] + f[i]) % MOD; |
P1990 漏了 f[0] = 1 |
「L 阶梯正好铺满整面墙」那一类跟着少算,N = 3 会得到 3 而不是 5 | 空墙也算 1 种铺法(什么都不铺) |
输出用 printf("%lld", ...) |
本机 MinGW(GCC 6.3.0)的 printf 不认 %lld,编译时就报 warning: unknown conversion type character 'l' in format,输出行为不可靠 |
一律用 cout(本题用 int,本来也不需要 long long) |
局部数组忘记 = {0} |
初值是随机垃圾,越算越错 | 开全局数组,或写 int dp[1005] = {0}; |
答:① 状态定义(dp[...] 表示什么)→ ② 转移方程(从哪些更小的状态来、怎么组合)→ ③ 边界 / 初始化(最小号状态的值,手算写死)→ ④ 答案(输出哪个状态,不一定是 dp[n])。
再加一句判断标准:状态必须满足无后效性——只给这个状态的值,就能决定下一步怎么走。
答:因为自顶向下时,每一行的最左边和最右边只有一个"父亲",转移时会读到不存在的格子,必须特判两条斜边;而自底向上时每个格子下面都正好有两个格子(最后一行除外,它本身就是边界),一次特判都不用写。而且答案天然就是 dp[1][1],不用再扫最后一行取 max。
答:因为这题在数方案数,增长极快。20×20 的棋盘从 (0,0) 到 (20,20) 要走 40 步,其中 20 步向右、20 步向下,条数是 C(40,20) = 137846528820,约 1.38×10¹¹;而 int 上限只有 2147483647(约 2.1×10⁹),装不下。
实测也印证了这一点:n=m=20、马在 (0,1) 时输出 8597496600,已经超过 int 上限。
down[i] 是什么意思?怎么求?答:down[i] = 以第 i 个同学为最高点、往右走的最长严格下降子序列长度(也就是"右边最多能留几个人,且 i 自己算在内")。
求法:倒着扫,i 从 n 到 1,枚举 j = i+1 .. n,若 t[j] < t[i] 则 down[i] = max(down[i], down[j] + 1)。
最后 答案 = n − max(up[i] + down[i] − 1)。
f[n] = f[n−1] + f[n−2] + 2·S[n−3],三项分别是什么情况?答:按「左上角那一格被哪块砖盖住」分类:
· f[n−1]:竖放一块骨牌,第 1 列整列铺满,右边剩 2×(n−1);
· f[n−2]:横放一块骨牌盖住左上角,此时左下那格只剩「也横放一块」这一条路,前 2 列一起铺满,右边剩 2×(n−2);
· 2·S[n−3]:用 L 型砖起头(2 种镜像朝向)。之后第 2 列留一个缺口,缺口只能一列列往右推(每一步都没有第二种选择),最后用另一块 L 收尾;收尾位置 j 可以从 3 到 n,每个 j 恰好 2 种铺法,右边剩 f[n−j] 种,全部加起来就是 2·(f[0]+…+f[n−3]) = 2·S[n−3]。
三项互不重叠、又覆盖了全部铺法,所以可以直接相加。
再补一句:那个 Σ 是连续前缀,所以要用 S 数组把它压成 O(1);否则每项都要往回扫,总复杂度 O(N²),N = 10⁶ 时必超时。
if(i == 2 && j == 2) continue; 删掉会怎样?答:起点会被转移式覆盖成 dp[1][2] + dp[2][1] = 0 + 0 = 0,于是从起点出发的路径条数变成 0,整个答案变成 0(除非起点就是终点)。
这就是为什么必须在设完 dp[2][2] = 1 之后,把起点从循环里跳过。
【DP 四步法】 ① 状态定义 dp[...] 表示什么 ② 转移方程 从哪些更小的状态来、怎么组合 ③ 边界 最小号状态的值(手算写死) ④ 答案 输出哪个状态(不一定是 dp[n]!) 【线性 DP 三大模型】 A 以 i 结尾 dp[i] = max(dp[i], dp[j] + 代价),j < i B 多阶段推进 dp[i][j] = f(上一行的邻居) C 分类讨论递推 f[n] = Σ(枚举「第一步」的各种情况) 【数字三角形(自底向上)】 dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j] 边界:最后一行 = 自己;答案:dp[1][1] 【过河卒(路径计数)】 坐标全部 +2(起点变 (2,2)) 马的控制点 9 个:马自己 + 8 个跳跃点,全部置 0 dp[i][j] = dp[i-1][j] + dp[i][j-1]; dp[起点] = 1 答案会超 int → 必须 long long 【LIS / 合唱队形】 up[i] = 以 i 结尾的最长严格上升子序列 down[i] = 以 i 开头的最长严格下降子序列(倒着扫) 留下最多 = max(up[i] + down[i] - 1) 答案 = n - 留下最多 // 问的是"出列几人" 【P1990 覆盖墙壁(分类讨论递推)】 看【左上角那一格】被谁盖住 -> 竖放 / 横放 / L 起头 f[0] = 1, f[1] = 1, f[2] = 2 f[n] = f[n-1] + f[n-2] + 2 * S[n-3] S[n] = (S[n-1] + f[n]) % 10000 // 前缀和把 Σ 压成 O(1) 答案 = f[N] % 10000 O(N);N 到 1e6,写成 O(N^2) 必超时 【判断该不该用 long long】 数方案 / 数路径 / 阶乘乘方 / 题目没给答案上界 → 用 long long
0/1 背包
今天你已经练熟了两种最常用的 DP 思路:「多阶段推进」(数字三角形、过河卒)和「分类讨论、枚举第一步」(P1990 覆盖墙壁)。 明天的 0/1 背包要把这两种思路合起来用:一件一件地处理物品(多阶段),每件物品做一个「选 / 不选」的决策(分类讨论)。
明天会重点讲清一件事:为什么一维写法里容量必须倒序枚举。这是背包最核心、最容易考的一点,也是区分"背过模板"和"真懂背包"的分水岭。
今天 P1990 的「看左上角那一格被谁盖住」、以及 P1216 / P1002 的逐格推进,已经把「转移方程 = 枚举最后一步的所有可能」这件事演示清楚了。明天你会看到又一个例子:最后一步是「放第 i 件物品」还是「不放」。