题单 · 习题解析

洛谷 P1541 [NOIP 2010 提高组] 乌龟棋

★★ 位置不用记(它是四个计数算出来的);★★★ 而「保证刚好用光 M 张卡片」我草稿当成命门,实测打回来了 —— 它挡的是越界,不是答案

原题:洛谷 P1541出自 第 25 章 二维费用与分组背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1541,日期见页头。两边不一致时信原站。

题目背景

NOIP2010 提高组 T2

题目描述

小明过生日的时候,爸爸送给他一副乌龟棋当作礼物。

乌龟棋的棋盘是一行 N 个格子,每个格子上一个分数(非负整数)。 棋盘第 1 格是唯一的起点,第 N 格是终点,游戏要求玩家控制一个乌龟棋子从起点出发走到终点。

乌龟棋中 M 张爬行卡片,分成 4 种不同的类型(M 张卡片中不一定包含所有 4 种类型的卡片,见样例), 每种类型的卡片上分别标有 1, 2, 3, 4 四个数字之一,表示使用这种卡片后,乌龟棋子将向前爬行相应的格子数。 游戏中,玩家每次需要从所有的爬行卡片中选择一张之前没有使用过的爬行卡片, 控制乌龟棋子前进相应的格子数,每张卡片只能使用一次。

游戏中,乌龟棋子自动获得起点格子的分数,并且在后续的爬行中每到达一个格子,就得到该格子相应的分数。 玩家最终游戏得分就是乌龟棋子从起点到终点过程中到过的所有格子的分数总和。

很明显,用不同的爬行卡片使用顺序会使得最终游戏的得分不同,小明想要找到一种卡片使用顺序使得最终游戏得分最多。

现在,告诉你棋盘上每个格子的分数和所有的爬行卡片,你能告诉小明,他最多能得到多少分吗?

输入格式

每行中两个数之间用一个空格隔开。

第 1 行 2 个正整数 N, M,分别表示棋盘格子数和爬行卡片数。

第 2 行 N 个非负整数,a₁, a₂, …, a_N,其中 aᵢ 表示棋盘第 i 个格子上的分数。

第 3 行 M 个整数,b₁, b₂, …, b_M,表示 M 张爬行卡片上的数字。

输入数据保证到达终点时刚好用光 M 张爬行卡片。

输出格式

一个整数,表示小明最多能得到的分数。

说明/提示

每个测试点 1s。

小明使用爬行卡片顺序为 1, 1, 3, 1, 2,得到的分数为 6+10+14+8+18+17 = 73。 注意,由于起点是 1,所以自动获得第 1 格的分数 6。

对于 30% 的数据有 1 ≤ N ≤ 301 ≤ M ≤ 12

对于 50% 的数据有 1 ≤ N ≤ 1201 ≤ M ≤ 50,且 4 种爬行卡片,每种卡片的张数不会超过 20。

对于 100% 的数据有 1 ≤ N ≤ 3501 ≤ M ≤ 120,且 4 种爬行卡片,每种卡片的张数不会超过 40; 0 ≤ aᵢ ≤ 100 (1 ≤ i ≤ N)1 ≤ bᵢ ≤ 4 (1 ≤ i ≤ M)

输入输出样例

输入

9 5
6 10 14 2 8 8 18 5 17
1 3 1 2 1

输出

73

9 格棋盘、5 张卡(三张 1、一张 2、一张 3)。按 1, 1, 3, 1, 2 出牌, 走过的格子是 1 → 2 → 3 → 6 → 7 → 9,分数 6 + 10 + 14 + 8 + 18 + 17 = 73

★ 这一组样例挡住了「忘了起点分数」(打 67,正好少了 a₁ = 6)。

1第一版:枚举出牌顺序

「不同的出牌顺序得分不同」—— 那就把顺序全试一遍。

p1541Brute.cpp第一版:DFS 枚举下一张出哪种卡
// P1541 的参照物:DFS 枚举「下一张出哪种卡」。
//
// 它不记忆化、也不用「位置能算出来」这件事 —— 就是把所有出牌顺序走一遍。
// 分支 4 层深 M ⇒ 卡片一多就跑不完,所以生成器把 M 压到 10 以内。
// ⇒ 它和正解一个字都不共享。
#include <bits/stdc++.h>
using namespace std;
int n, a[355], cnt[5], best;
void dfs(int pos, int sum) {
best = max(best, sum);
for (int d = 1; d <= 4; d++)
if (cnt[d] && pos + d <= n) {
cnt[d]--;
dfs(pos + d, sum + a[pos + d]);
cnt[d]++;
}
}
int main() {
int m;
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 1; i <= n; i++) if (scanf("%d", &a[i]) != 1) return 0;
for (int i = 0; i < m; i++) {
int x;
if (scanf("%d", &x) != 1) return 0;
cnt[x]++;
}
best = 0;
dfs(1, a[1]);
printf("%d\n", best);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它为什么不行 —— 一句乘法就够

出牌顺序的数量是多重排列数M = 12、四种各 3 张时就有 12! / (3!)⁴ = 369 600 种;而顶格 M = 120、四种各 30 张,这个数是天文数字。

★ 但同一批卡片,「四种各用了几张」只有 256 种状态4⁴)。 ⇒ 369 600 : 256。这个比就是这道题全部的门道。

2★★ 关键的一步:位置不用记 —— 它是算出来的

★ 四个计数把一切都定死了
    状态 = (用了几张 1,几张 2,几张 3,几张 4) = (i, j, k, l)
    位置 = 1 + 1×i + 2×j + 3×k + 4×l          ← 不需要第五维!
    f[i][j][k][l] = max(四个前驱) + a[位置]

这就是第 25 章上半场那件事推到四维:多一维费用,就多一层循环 —— 只不过这里的「费用」是四种卡片各自的张数。

⚠ 而「位置也塞进状态」是最常见的第一版:f[pos][i][j][k][l]。 它不是慢,是根本开不出来(见下面那张表)。

p1541.cpp★ 这一版就能 AC
// P1541 [NOIP 2010 提高组] 乌龟棋 —— ★ 这一版就能 AC。
//
// ★★ 关键的一步:**状态里不需要「现在站在第几格」这一维。**
// 四种卡片各用了几张(`i, j, k, l`),位置就被**算死**了:
// pos = 1 + 1×i + 2×j + 3×k + 4×l
// ⇒ 这就是第 25 章上半场那件事推到四维:**多一维费用就多一层循环**,
// 而这道题的「费用」是四种卡片各自的张数。
//
// 状态数 = (c1+1)(c2+1)(c3+1)(c4+1) ≤ 41⁴ ≈ 282 万,每个状态四个转移 ⇒ 一千万出头。
//
// ⚠ 两处最容易漏:
// ① **起点那一格的分数是白送的**(`f[0][0][0][0] = a[1]`,不是 0);
// ② 题面那句「保证到达终点时刚好用光 M 张卡片」⇒ 答案就是**全用完**那个状态,
// 而且 `pos` 不会跑出棋盘 —— 下面这句 `a[pos]` **正是靠它才敢直接读**。
// ⚠ 页面第 ④ 步量过:那句保证挡住的是**越界**,不是**答案** ——
// 把读法改成「跑出棋盘算 0」之后,故意违反它的 300 轮里答案一个字都没变
// (其中 98 轮真的跑出了棋盘)。
#include <bits/stdc++.h>
using namespace std;
int a[355];
int f[41][41][41][41];
int cnt[5];
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 1; i <= n; i++) if (scanf("%d", &a[i]) != 1) return 0;
for (int i = 0; i < m; i++) {
int x;
if (scanf("%d", &x) != 1) return 0;
cnt[x]++;
}
for (int i = 0; i <= cnt[1]; i++)
for (int j = 0; j <= cnt[2]; j++)
for (int k = 0; k <= cnt[3]; k++)
for (int l = 0; l <= cnt[4]; l++) {
if (i + j + k + l == 0) { f[0][0][0][0] = a[1]; continue; }
int pos = 1 + i + 2 * j + 3 * k + 4 * l;
int best = -1;
if (i) best = max(best, f[i - 1][j][k][l]);
if (j) best = max(best, f[i][j - 1][k][l]);
if (k) best = max(best, f[i][j][k - 1][l]);
if (l) best = max(best, f[i][j][k][l - 1]);
f[i][j][k][l] = best + a[pos];
}
printf("%d\n", f[cnt[1]][cnt[2]][cnt[3]][cnt[4]]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 规模:一道三十秒的算术题
状态数(每种卡片至多 40 张) 41⁴ = 2 825 761
转移次数(每个状态四个前驱) 11 303 044
f41⁴int 10 MB(限制 128 MB)
⚠ 如果把位置也当一维(× 350 3772 MB —— 想都不用想

⇒ 「位置能不能省」这个问题,乘一遍就有答案:省下它是 10 MB,留着它是 3.7 GB。

3★ 「忘了起点分数」——它算了什么,一句话说死

题面专门提醒过「乌龟棋子自动获得起点格子的分数」, 而 DP 初值写成 f[0][0][0][0] = 0 是最自然的动作。

p1541NoStart.cpp✗ 初值写成了 0
// ✗ P1541 的第一个坑:忘了起点那一格的分数。
//
// 题面专门提醒过:「乌龟棋子**自动获得起点格子的分数**」——
// 而 DP 的初值写成 `f[0][0][0][0] = 0` 是最自然的动作。
//
// ★ 它「算了什么」能一句话说死:**答案恒好少 a[1]**,一分不多一分不少。
// ⇒ 300 轮里「它 + a[1] = 正解」逐组成立;而它被抓的轮数 ≡ `a[1] > 0` 的轮数。
// ([第 22 章 P1091](/sol/p1091/) 那个「恒好少 1」的同款。)
#include <bits/stdc++.h>
using namespace std;
int a[355];
int f[41][41][41][41];
int cnt[5];
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 1; i <= n; i++) if (scanf("%d", &a[i]) != 1) return 0;
for (int i = 0; i < m; i++) {
int x;
if (scanf("%d", &x) != 1) return 0;
cnt[x]++;
}
for (int i = 0; i <= cnt[1]; i++)
for (int j = 0; j <= cnt[2]; j++)
for (int k = 0; k <= cnt[3]; k++)
for (int l = 0; l <= cnt[4]; l++) {
if (i + j + k + l == 0) { f[0][0][0][0] = 0; continue; } // ✗ 少了 a[1]
int pos = 1 + i + 2 * j + 3 * k + 4 * l;
int best = -1;
if (i) best = max(best, f[i - 1][j][k][l]);
if (j) best = max(best, f[i][j - 1][k][l]);
if (k) best = max(best, f[i][j][k - 1][l]);
if (l) best = max(best, f[i][j][k][l - 1]);
f[i][j][k][l] = best + a[pos];
}
printf("%d\n", f[cnt[1]][cnt[2]][cnt[3]][cnt[4]]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 300 轮:它恒好少 a₁,而被抓的轮数 ≡ a₁ > 0 的轮数
「它 + a₁ = 正解」 300 / 300 逐组相等
被抓 297 / 300
a₁ > 0 的轮数 297 / 300

⇒ 说清楚它「算了什么」之后,抓获率是白送的推论: 它只在 a₁ = 0 时碰巧对 —— 而分数是 0 ~ 100 随机,正好 3 轮撞上 0。 ★ 和第 22 章 P1091 那个「恒好少 1」是同一种形状。

4★★★ 题面那句「刚好用光 M 张卡片」——我的草稿把它当成了命门,实测打回来了

正解最后一行直接读 f[c1][c2][c3][c4](全用完那个状态), 靠的就是「保证到达终点时刚好用光」。草稿里写的是:违反它,这个写法就塌了。

★★ 量一遍(各 300 轮,参照物是 DFS 暴力)
生成器 正解 vs「不吃保证」的写法 正解 vs DFS 暴力 ⚠ 而「全用完时位置真的跑出棋盘」
守着那句保证(N = 1 + Σbᵢ 0 0 0 轮
故意违反它(N 自己随便取) 0 0 98 / 300 轮

★★★ 两档都是 0,而第二档里有 98 轮真的跑出了棋盘 —— 所以这个 0 不是「测了个空壳」(P2240 / P1094 那条规矩:报 0 之前先证明代码是活的)。

结论要改口:那句保证挡住的不是答案,是越界。 只要把读法改成「跑出棋盘就算 0」(一行),答案一个字都不会变 —— 因为多出来的那些卡片只能白打,max 自然会绕开它们。

⚠ 而没改的话,a[pos] 就是一次实打实的数组越界读 —— 那种 bug 打出来的数换台机器就变。 ⇒ 所以这句保证是「情报」不是「命门」:它省掉的是一行防守,不是一整个算法。

p1541Any.cpp(不吃那句保证的写法)对照:所有没跑出棋盘的状态一起取最大

5★ 题面给的那两档「部分分」,就是出题人写好的对拍档

数据范围里那三行不是背景:

拿它干什么
30%:N ≤ 30M ≤ 12 出牌顺序至多 12!/(3!)⁴ = 369 600 枚举顺序的暴力跑得动 ⇒ 现成的参照物
50%:N ≤ 120M ≤ 50,每种 ≤ 20 状态数 21⁴ = 194 481 中间档,验状态法对不对
100%:N ≤ 350M ≤ 120,每种 ≤ 40 状态数 41⁴ = 2 825 761 定死数组和内存

⇒ 这是第 24 章 P1776 那条的又一次:部分分那几行是出题人替你写好的对拍档。

6度量程序和生成器

p1541Count.cpp度量程序(本页所有数字都出自它)
p1541Gen.cpp数据生成器

7一页纸

★★ 关键的一步 位置不用记 —— pos = 1 + i + 2j + 3k + 4l,状态只要四个计数
它值多少 M = 12 时出牌顺序 369 600 种,而状态只有 256
规模 状态 41⁴ = 282 万、转移 1130 万次、f10 MB;⚠ 把位置也塞进状态是 3772 MB
「忘了起点分数」 恒好少 a₁(300/300);被抓 297a₁ > 0297
★★★ 「刚好用光」那句保证 ⚠ 草稿当成命门,实测打回来:两档答案都是 0 不一致(而 98 轮真的越过棋盘)—— 它挡的是越界,不是答案
★ 部分分那三行 30% 档 M ≤ 12 ⇒ 枚举出牌顺序的暴力跑得动,出题人替你写好了对拍档