第 21~24 节只教了一件东西:把一个大问题拆成「第一步 + 剩下的同样问题」,拆到不能再拆(出口),再把答案一层层抛回来。四节课是同一副骨架换了四种状态:递归算数、递推打表、枚举组合排列、在网格上走路。这一节不讲新算法,只练四个动作——出口写对、状态写全、现场恢复、不该走的枝不走了。
| 节 | 内容 | 一句话判据 |
|---|---|---|
| 21 | 递归:出口、递推与栈 | 函数体第一句是 if (到出口) { 记答案; return; },之后才是「拆小 + 合并」 |
| 22 | 递推与记忆化搜索 | 同一个子问题被算第二次,就该把它存进表里 |
| 23 | DFS 枚举:子集、组合与排列 | 题目问「有哪些选法 / 怎么排」,就画一棵每个结点开 n 个儿子的树 |
| 24 | DFS 与回溯:网格搜索与剪枝 | 走一步改状态,退一步还原状态;注定不行的路当场 continue |
在 n × n 的棋盘上放 n 个皇后,要求每行、每列、每条对角线(两条主对角线方向上的所有平行线)上至多有一个皇后。
一个解用序列 a1 a2 … an 表示,ai 是第 i 行皇后所在的列号。按字典序输出前 3 个解,每行 n 个数;最后一行输出解的总数。
输入格式:一行一个整数 n(6 ≤ n ≤ 13)。
输出格式:前三行为前三个解,同一行的数字用一个空格隔开;第四行只有一个整数,表示解的总数。
第 24 节用这题讲过回溯,这一节不换题——闭卷把它重写一遍,看能不能一次写对。它把四节的东西一次用全:递归(出口 + 自己调自己)、回溯(放完要还原)、剪枝(三条线被占就不走)、枚举(列 1~n 依次试)。而且它同时要两样输出——前三个解和总数——正好逼你把「出口」和「计数」两件事分开写清楚。
| 组成 | 含义 / 格式要求 |
|---|---|
返回类型 | 只做枚举和计数时用 void,答案写进全局变量;只求「能不能到」时用 bool。 |
参数 | 只放「随分支变化」的量:第几行、第几步、当前和。不随分支变的(n、棋盘大小)一律放全局。 |
出口 | 函数体第一句永远是 if (到出口) { 记录/输出; return; }。没有出口就是无限递归,结果 RE(爆栈)。 |
分支 | for 枚举这一步的所有选择,每个选择先「改状态」,再递归,再「还原状态」。 |
回溯 | 递归返回后把状态改回去。漏了这一步,后面的分支会看到被污染的状态,答案偏少且难查。 |
深度上限 | 默认栈约 8MB,一帧 48~64 字节,递归到 10⁵ 层就可能爆栈。更深的搜索要改手写栈或 BFS。 |
int n, ans = 0;
void dfs(int step) {
if (step > n) { ++ans; return; } // 出口:n 步都走完了,得到一种方案
for (int i = 1; i <= n; ++i) { // 枚举这一步的所有选择
// 改状态(本题没有需要改的数组)
dfs(step + 1); // 递归到下一步
// 还原状态
}
}出口里的 return 不能省,否则走完一步还会接着往下枚举,同一方案被重复计数。
递归函数的返回值只用来「传答案」或「报告成败」,不要既返回值又改全局量,那样必然乱。
| 组成 | 含义 / 格式要求 |
|---|---|
为什么放全局 | 全局变量在静态区:自动清零(false),而且不占栈空间。在 main 里开 10⁶ 个 int(4MB)就可能爆栈。 |
两种写法 | vis[x] = true 记录访问过;网格题里也可以直接把地图 g[x][y] 改成 '.',省一个数组。 |
成套出现 | mark(x); dfs(...); unmark(x); 三句一定连在一起,中间不要插别的逻辑。 |
数组大小 | 按最大可能下标开,不是按元素个数:列号最大 n,就开 n+2;对角线下标最大 2n,就开 2n+2。 |
下标要能当索引 | 负数不能做下标。row - col 最小是 1-n,加 n 平移成 1 ~ 2n-1 就能用了。 |
int n;
bool col[30], d1[30], d2[30]; // 列、↘ 方向、↙ 方向
// 试图把第 row 行的皇后放在第 c 列
if (col[c] || d1[row + c] || d2[row - c + n]) continue; // 三条线有任一条被占,就不能放
col[c] = d1[row + c] = d2[row - c + n] = true; // 占住三条线
dfs(row + 1);
col[c] = d1[row + c] = d2[row - c + n] = false; // 回溯:三处一起还原连续赋值 a = b = c = true 是右结合,三处都会被赋 true,可以放心连写。
还原时漏掉一条线,那个位置后面就永远被占着,解会少一大截——这是八皇后最典型的错法。
| 组成 | 含义 / 格式要求 |
|---|---|
按字节填 | memset 把内存按字节填成同一个值:填 0 得到全 0,填 -1 得到全 -1(每字节 0xFF),填 1 得到 16843009。 |
只有 0 / -1 能用 | 想把 int 数组全设成 5、把 double 设成 0.3,memset 都做不到,只能用 fill 或循环。 |
sizeof | 对数组名写 sizeof f。写成 sizeof(int) 只清 4 个字节;数组传进函数后已退化成指针,此时 sizeof 只有 8。 |
替代品 | fill(f, f + N, 值) 可以填任意值,速度略慢,但语义清楚。 |
什么时候需要 | 记忆化搜索里「没算过」要用 -1 标记时;多组数据之间清空状态数组时。 |
long long f[40][40]; // 全局:本来就全是 0,不用清
int dp[1005];
memset(dp, -1, sizeof dp); // 全部变成 -1,用作「还没算过」的标记
// memset(dp, 1, sizeof dp); // 错误:得到 16843009,不是 1用 0 当「没算过」的标记时,必须保证真实答案是 0 的情况不存在;否则要换成 -1。
memset 只能处理 POD 类型(int、char、double、结构体),vector、string 不要 memset。
| 组成 | 含义 / 格式要求 |
|---|---|
两个下标数组 | dx[k]、dy[k] 是第 k 个方向的横纵增量,配成一对才表示一个方向。 |
4 连通 | 上下左右:(-1,0) (1,0) (0,-1) (0,1)。顺序只影响输出顺序,不影响答案。 |
8 连通 | 再加四条斜线 (-1,-1) (-1,1) (1,-1) (1,1),一共 8 组,两个数组要一一对应。 |
越界判断在前 | nx < 1 || nx > n || ny < 1 || ny > m 必须写在访问 g[nx][ny] 之前。 |
const | 方向表是常量,加 const 让编译器直接内联,也防手滑改掉。 |
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
for (int k = 0; k < 8; ++k) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue; // 先判界,再访问
if (g[nx][ny] == 'W') dfs(nx, ny);
}8 个方向手写 8 个 if 极易把 dy 抄错;写成表以后,改方向只改表,循环体不动。
方向表用不用哨兵边界(四周留一圈 '.')是风格问题;留了就不用判界,但数组要开大两圈。
① 状态:dfs(row) 表示「第 1 ~ row-1 行的皇后都已放好,现在给第 row 行找位置」。棋盘大小 n 全程不变,放全局,不进参数。
② 出口:row > n 时 n 行都放好了,计数 +1,前 3 个解顺便输出。
③ 分支:第 row 行可以放在 1 ~ n 任意一列,所以 for (int c = 1; c <= n; ++c)。
④ 剪枝:这一列、这条 ↘ 线、这条 ↙ 线上已经有皇后,就 continue,不进递归。三个标记数组就是剪枝的全部实现。
为什么按行枚举就够:一行只放一个皇后,「行」是天生的层数,所以不必再判行冲突。把二维问题压成一维,是所有棋盘类 DFS 的第一步。
为什么结果天然是字典序:外层行号递增、内层列号递增,先到达出口的解一定比后到达的字典序小。所以「前三个解」就是「前三次到达出口」,不需要额外排序。
为什么这里不需要记忆化:每条从根到出口的路径都是一个不同的解,子问题之间没有重复计算,存表只会添乱。第 22 节的记忆化只在「同一个局面被不同路径反复走到」时才划算,八皇后不是这种情况。
规模感受:n = 8 有 92 个解,n = 12 有 14200 个,n = 13 有 73712 个。剪枝后搜索树只剩这些分支及其附近的死路,n ≤ 13 一秒内跑完;把剪枝去掉是 13¹³ ≈ 3×10¹⁴ 条路,永远跑不完。
#include <bits/stdc++.h>
using namespace std;
int n;
int total = 0; // 解的总数:全局,dfs 的每个分支都要改它
int pos[15]; // pos[r] = 第 r 行皇后所在的列,输出解时按行读
bool col[30]; // col[c]:第 c 列是否被占
bool d1[30], d2[30]; // 两条对角线方向:row+col 相同 / row-col 相同
// 前 row-1 行已放好,现在给第 row 行找一个列
void dfs(int row) {
if (row > n) { // 出口:n 行都放好了
++total;
if (total <= 3) { // 只要字典序前三个解
for (int r = 1; r <= n; ++r)
cout << pos[r] << (r == n ? '\n' : ' ');
}
return; // 必须 return,否则还会接着找位置
}
for (int c = 1; c <= n; ++c) { // 列从小到大:字典序由此而来
if (col[c] || d1[row + c] || d2[row - c + n])
continue; // 剪枝:三条线任一被占就不放
col[c] = d1[row + c] = d2[row - c + n] = true; // 占住三条线
pos[row] = c;
dfs(row + 1); // 交给下一行
col[c] = d1[row + c] = d2[row - c + n] = false; // 回溯:三处一起还原
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
dfs(1); // 从第 1 行开始放
cout << total << "\n"; // 最后一行:解的总数
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
#include <bits/stdc++.h> | 竞赛万能头,一次包含 iostream、algorithm 等全部标准头。 |
int total = 0; | 解的总数。全局变量在静态区自动清零,dfs 的任意一层都能直接改它。 |
int pos[15]; | 记录每一行的列号。dfs 内部必须保留,否则出口处拼不出这个解。n ≤ 13,开 15 够用。 |
bool col[30]; | 列标记。列下标只到 13,开 30 是留余量——数组开小了是 RE,不是 WA。 |
bool d1[30], d2[30]; | 两条对角线方向。row+col 相同的格在同一条 ↘ 线上,编号 2 ~ 2n;row-col 相同的在同一条 ↙ 线上,编号 1-n ~ n-1。 |
void dfs(int row) | 状态只有一个 row。层数本身就是状态,不必额外传「已经放了几行」。 |
if (row > n) | 出口。第 n 行放完后 row 变成 n+1,写成 row == n 就永远漏掉最后一行。 |
++total; | 先加再判断,所以前三次到达出口都会被输出。 |
if (total <= 3) | 写成 total < 3 只输出两个解,这是本题最常见的 WA。 |
for (int c = 1; c <= n; ++c) | 列从小到大枚举。改成 c = n; c >= 1; --c 输出的就是字典序最大的三个解。 |
if (col[c] || d1[row + c] || d2[row - c + n]) | 三条线任一被占就跳过。短路求值,前面的判断为真时后面不执行,也顺带避免了无效下标。 |
col[c] = d1[...] = d2[...] = true; | 连等号右结合,三处一起标记,等价于三句赋值。 |
dfs(row + 1); | 放下这一行,交给下一行。这里不需要「成功/失败」的返回值,因为要穷举全部解。 |
col[c] = d1[...] = d2[...] = false; | 回溯:把三处一起还原。漏一处,后面的分支就以为那条线还被占着,解会变少。 |
cout << total << "\n"; | 总数在主函数里输出一次,不要在出口里输出——出口会被执行 total 次。 |
出口写成 row == n:最后一行永远放不上。症状是答案偏小甚至为 0,样例一跑就不对。
出口忘了 return:计数完还继续给第 n+1 行找位置。症状是答案大出几倍,n 稍大直接 TLE。
对角线下标不偏移:row - col 出现负数就当成下标用。症状是本地样例正常、评测 RE 或随机 WA,因为负下标属于越界访问,行为不确定。
标记数组按「大小」而不是「最大下标」开:n = 13 时 row + col 能到 26,开 bool d1[15] 就是越界。症状是小数据全对、n 一变大立刻 RE。
回溯漏还原:只写了 col[c] = false,忘了 d1、d2。症状是答案偏少,n = 8 时总数从 92 掉到几十。
输出格式:把总数写在每个解后面,或行末多打一个空格。症状是样例看着对、提交 WA。
关掉页面,把上面 P1219 的代码手打一遍,输入 6,核对是不是三行解加一行 4。
改一改①:把 if (total <= 3) 改成 if (total < 3),看是不是只剩两个解。
改一改②:把三个标记数组都开成 [15],输入 13——这是越界的现场。
改一改③:删掉回溯里还原 d2 的那一句,输入 8,看 92 变成多少。
改一改④:把列循环改成从 n 到 1 倒着枚举,再输入 6,观察输出的三个解——字典序原来只由枚举顺序决定。
21 递归:先写出口,再写递归调用。函数里第一句永远是出口判断。
22 记忆化:同一个状态算第二次就是浪费。递归 + 一张 f 表,「算过就取、算完就存」。
23 枚举:子集是「选 / 不选」,组合是「从 start 往后选」,排列是「vis 标记 + 全枚举」。三种树不同,出口相同。
24 网格:方向数组 + 越界判断 + 标记 + 回溯,四件缺一件就是 RE 或死循环。
下面 5 道题按「递归 → 网格选数 → 网格路径 → 剪枝 → 回溯」排:前两题把模板写熟,第三题多带一个状态,第四题专门练剪枝,最后一题回到字符串。每题都先自己想 20 分钟,卡住了再看提示。
| 题 | 编号 | 主要考什么 | 最容易错在哪 |
|---|---|---|---|
| A | P1928 | 递归下降:读一段,读到配对括号为止 | 读到 ] 忘了 return;重复次数读的位置不对 |
| B | P1123 | 网格 DFS:选 / 不选 + 8 邻域检查 | 只查 4 邻域;多组数据忘了清空 used 与 best |
| C | P7995 | 网格 DFS + 状态:走格子并数转向次数 | 第一步也算转向;dir 的编码两处不一致 |
| D | P10483 | 搜索 + 剪枝:分组使组数最少 | 不排序导致剪枝失效 TLE;回溯漏还原载重 |
| E | P1019 | 回溯 + 全局最优:每一步都更新答案 | 重叠长度取了最大而不是最小 |
0~8 分钟:只读题,按「能立刻写 → 有思路 → 没思路」排序。这五题代码都不长,难在想清楚状态。
8~22 分钟:A(P1928)。先想清楚递归函数返回什么、在哪里停,再动手写。
22~38 分钟:B(P1123)。骨架就是第 23 节的「选 / 不选」,多一步邻域检查。
38~55 分钟:C(P7995)。注意「转向次数」是状态的一部分,不能只传坐标。
55~72 分钟:D(P10483)。先写出不剪枝的版本对拍小数据,再加两条剪枝。
72~90 分钟:E(P1019)。重点在重叠长度的预处理;DFS 本身只是带回溯的模板。
每个 dfs 的第一行是不是出口?出口里有没有 return?
每一处「改状态」,返回后有没有配一句「还原状态」?数量对上没有?
标记数组的实际最大下标是多少?开的大小是不是比它大?
最坏递归深度是多少?超过 10⁵ 就要考虑爆栈,或改用 BFS。
有没有同一个状态被反复计算?有就加记忆化,或者用剪枝不再往下搜。
多组数据的题:答案、标记数组、累加量,每组开始前都重置了吗?
限时 100 分钟。每题先自己写 20~25 分钟;写不出来先看提示,再给自己 10 分钟;还不行才点开思路。每题都独立限时,不要一题耗死。
外星人把连续的若干个相同子串 X 压缩成 [DX] 的形式,其中 D 是 1 到 99 的整数。例如 CBCBCBCB 可以压成 [4CB],也可以压成 [2[2CB]];后者叫二重压缩,方括号还可以继续嵌套。
例如 AC[3FUN] 展开是 ACFUNFUNFUN,[2[2A]] 展开是 AAAA。
给定一个压缩串,输出解压后的字符串。
输入格式:一行一个字符串,只含数字、大写字母和方括号,保证格式合法;解压后的长度不超过 20000,压缩最多十重。
输出格式:一行,解压后的字符串。
这个串的结构天生是递归的:一个方括号里可以再套方括号,结构完全一样。
把「从当前位置一直读到配对的方括号为止」写成一个函数,让它自己调用自己。
读到左括号时,先读出重复次数,再递归读出里面那一整段,最后把那段重复拼接上去。
递归函数 readStr() 的语义是:从当前读位置开始,读一段内容,读到配对的 ] 或输入末尾为止,返回这段内容。
循环读字符,三种情况:普通字母 → 直接接在结果后面;[ → 读整数 D,递归读出子串 t,把 t 重复 D 次接上;] → 本层读完,return。
出口有两个:读到 ](这一层结束)和读到输入末尾(最外层结束)。
为什么这样一定对:左括号和右括号严格配对,递归进入一层就消耗掉一个左括号,返回时刚好消耗掉配对的右括号,流的位置自然停在括号后面,外层接着往下读。
复杂度:每个字符只被读一次,拼接总量等于输出长度,总时间 O(输出长度)。
#include <bits/stdc++.h>
using namespace std;
// 从当前读位置读一段,读到配对的 ']' 或输入结束为止
string readStr() {
string s;
char c;
while (cin >> c) { // 一次读一个字符
if (c == '[') { // 遇到左括号:后面紧跟重复次数
int k = 0;
cin >> k; // 读整数,正好停在内容第一个字符上
string t = readStr(); // 递归读括号里的内容
while (k--) s += t; // 重复 k 次(k-- 是先用后减)
} else if (c == ']') { // 出口:这一层结束
return s;
} else {
s += c; // 普通字母:直接接上
}
}
return s; // 最外层读到输入结束也要返回值
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout << readStr() << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
while (cin >> c) | 一次读一个字符;cin 在读不到东西时转成 false,循环自动结束。 |
if (c == '[') | 左括号表示「这里要重复」,它后面紧跟的是次数。 |
cin >> k | 读整数会自动跳过空白,并读到第一个非数字字符为止,正好停在内容的第一个字符上。 |
string t = readStr(); | 递归读内容:这一层不知道里面有多长,交给同名函数读到配对的右括号。 |
while (k--) s += t; | k-- 先取值再减,循环体恰好执行 k 次;k 是局部变量,不影响别的层。 |
} else if (c == ']') { return s; } | 出口。把本层攒到的内容交回上一层,同时右括号已经被消耗掉。 |
return s; | 最外层读到输入结束后,循环退出,同样要返回结果。 |
] 分支忘记 return,函数会继续往下读,把外层的内容也吞进来,输出整体错乱。cin >> k 再判断 c == '[',遇到字母时 cin 会失败,之后所有读入失效,输出空串。getline 读整行再手动递归,忘了前面 cin >> n 残留的换行。endl 逐行刷新,输出很长时明显变慢;本题只需拼好一次输出。给定一个 N × M 的由非负整数构成的矩阵,从中取出若干个数,要求取出的任意两个数不相邻(一个格子在另一个格子的 8 个相邻格中,就认为这两个数相邻),求取出的数之和的最大值。
输入格式:第一行一个正整数 T(T ≤ 20),表示数据组数。每组数据的第一行两个正整数 N、M(1 ≤ N, M ≤ 6);接下来 N 行,每行 M 个不超过 10⁵ 的非负整数。
输出格式:共 T 行,每行一个非负整数,表示该组数据的最大和。
每个格子只有两种命运:取或不取。按行序一个个决定,就得到一棵二叉搜索树。
决定「取」之前要检查它和已经决定过的格子是否相邻。按行序处理时,只需要看左、左上、上、右上四个方向。
所有格子都决定完才能更新答案;走不下去时,用「剩下的格子全取也超不过当前最优」把这一支砍掉。
状态:dfs(x, y, sum) 表示正在决定第 x 行第 y 列,已取数字之和是 sum。
分支:不取 → 直接走到下一个格子;取 → 先检查 4 个已处理方向的邻格,没冲突才标记并累加。
出口:走完最后一行时用 sum 更新 best。
剪枝:预处理 suf[x][y] = 从 (x,y) 按行序到末尾的所有数字之和。若 sum + suf[x][y] <= best,后面的格子全取也不可能更好,直接 return。
为什么只查 4 个方向:行序遍历时,右、左下、下、右下都还没决定,不可能与当前格冲突。
规模:6×6 网格的独立集约 5.6×10⁶ 个,比 2³⁶ = 6.9×10¹⁰ 小得多;再加后缀和剪枝,T ≤ 20 也很快。
#include <bits/stdc++.h>
using namespace std;
int T, n, m;
int a[8][8];
bool used[8][8];
int suf[8][8]; // suf[x][y]:从 (x,y) 按行序到末尾的所有数字之和
int best;
// 按行序处理时,只需要检查已经决定过的 4 个方向
const int dx[4] = {0, -1, -1, -1};
const int dy[4] = {-1, -1, 0, 1};
bool canTake(int x, int y) {
for (int k = 0; k < 4; ++k) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (used[nx][ny]) return false; // 和已选的格子相邻,不能取
}
return true;
}
void dfs(int x, int y, int sum) {
if (x > n) { // 出口:所有格子都决定完了
best = max(best, sum);
return;
}
if (sum + suf[x][y] <= best) return; // 剪枝:后面全取也超不过当前最优
int nx = x, ny = y + 1; // 下一个格子(到行末就换行)
if (ny > m) { nx = x + 1; ny = 1; }
dfs(nx, ny, sum); // 分支一:不取当前格
if (canTake(x, y)) { // 分支二:取当前格
used[x][y] = true;
dfs(nx, ny, sum + a[x][y]);
used[x][y] = false; // 回溯:还回去
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T;
while (T--) {
cin >> n >> m;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j) {
cin >> a[i][j];
used[i][j] = false; // 多组数据:每组都要清空
}
memset(suf, 0, sizeof suf); // 边界 suf[n+1][1] 必须是 0,多组数据要还原
for (int i = n; i >= 1; --i) // 从右下往左上递推后缀和
for (int j = m; j >= 1; --j) {
int nxt = (j == m) ? suf[i + 1][1] : suf[i][j + 1];
suf[i][j] = a[i][j] + nxt;
}
best = 0;
dfs(1, 1, 0);
cout << best << "\n";
}
return 0;
}| 片段 | 作用 |
|---|---|
int suf[8][8]; | 后缀和表:把「剩下所有格子的数字之和」预先算好,剪枝时 O(1) 查。 |
for (int i = n; i >= 1; --i) | 从右下往左上递推:每个位置 = 自己的值 + 下一个位置的后缀和。i = n、j = m 时会读到边界 suf[n+1][1],所以上面先 memset 成 0。 |
if (sum + suf[x][y] <= best) return; | 所有数字非负,所以「后面全取」是这个分支能达到的上界;上界都不如已知最优就砍掉。 |
int nx = x, ny = y + 1; if (ny > m) { nx = x + 1; ny = 1; } | 把二维格子压成一条顺序,递归时只剩一个「下一个」。 |
dfs(nx, ny, sum); | 分支一:不取,什么都不改,直接往后走。 |
if (canTake(x, y)) | 分支二:只有和已决定的 4 个邻格都不冲突才取。 |
used[x][y] = false; | 回溯:离开这一支前必须还回去,否则后面所有分支都以为这格被占了。 |
used[i][j] = false; | 多组数据:上一组的标记必须在这里清掉,否则第二组开始全错。 |
used[x][y] = false → 后面的分支全被挡住,答案偏小。used 或 best → 从第二组开始答案全错。suf[x][y] 写成 suf[x][y + 1],把当前格也算进「已取」→ 漏掉最优解。农场是一个 N × N 的方阵(2 ≤ N ≤ 50),草地在左上角 (1,1),牛棚在右下角 (N,N)。
Bessie 只向下或向右走,有干草捆的格子(H)不能通过。她希望改变行走方向至多 K 次(1 ≤ K ≤ 3)。
求不同的路线数量。两条路线只要经过的格子集合不同就算不同。
输入格式:第一行一个整数 T(1 ≤ T ≤ 50),表示子测试用例数。每个用例的第一行是 N、K;接下来 N 行,每行 N 个字符,. 表示空格子,H 表示干草捆。保证起点和终点不是干草捆。
输出格式:共 T 行,第 i 行是第 i 个用例的路线数。
只往右下走,所以一条路线最多 2N-2 步,可以完整枚举。
递归时多带两个信息:上一步的方向(下 / 右),以及已经转了几次方向。
换方向才 +1,沿原方向继续走不 +1;转向预算超了就不要走这一格。
状态:dfs(x, y, turns, dir) 表示站在 (x,y),已经转了 turns 次,最后一步的方向是 dir(0 下、1 右)。
分支:向下或向右,各判一次「这一格能不能走」和「换方向后 turns 是否超 K」。
出口:走到 (N,N) 就 ++ans,然后立刻 return。
起点的第一步不算转向,所以先单独迈出第一步(向下或向右),再进入递归。
为什么不会重复计数:只往右下走时,一条路线的方向序列唯一确定这条路线,反过来也唯一,所以枚举方向序列等于枚举路线。
复杂度:K = 3 时合法路线只有 O(N³) 级别,N ≤ 50 时不过十几万条,T ≤ 50 也跑得完。
#include <bits/stdc++.h>
using namespace std;
int T, n, K;
char g[55][55];
long long ans;
// x,y:当前位置;turns:已经转向几次;dir:0 表示上一步向下,1 表示向右
void dfs(int x, int y, int turns, int dir) {
if (x == n && y == n) { ++ans; return; } // 出口:到牛棚
if (x + 1 <= n && g[x + 1][y] == '.') { // 向下走一格
int t = (dir == 1) ? 1 : 0; // 原来向右,现在向下 = 转一次
if (turns + t <= K) dfs(x + 1, y, turns + t, 0);
}
if (y + 1 <= n && g[x][y + 1] == '.') { // 向右走一格
int t = (dir == 0) ? 1 : 0;
if (turns + t <= K) dfs(x, y + 1, turns + t, 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> T;
while (T--) {
cin >> n >> K;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j) cin >> g[i][j];
ans = 0;
if (g[2][1] == '.') dfs(2, 1, 0, 0); // 第一步向下:前面没有方向,不算转向
if (g[1][2] == '.') dfs(1, 2, 0, 1); // 第一步向右:同样不算转向
cout << ans << "\n";
}
return 0;
}| 片段 | 作用 |
|---|---|
void dfs(int x, int y, int turns, int dir) | 四个参数都是随分支变化的状态:位置、已转次数、上一步方向。 |
if (x == n && y == n) { ++ans; return; } | 出口。走到牛棚就完成一条路线,必须立刻 return,否则还会继续往下试探。 |
int t = (dir == 1) ? 1 : 0; | 方向变了才算一次转向;沿原方向继续走 t = 0。 |
if (turns + t <= K) | 转向预算超了就不走这一格,等于整棵子树被剪掉。 |
if (g[x + 1][y] == '.') | 干草捆不能通过;x + 1 <= n 顺便把越界挡掉。 |
dfs(2, 1, 0, 0) | 起点单独迈第一步:第一步前面没有方向,所以不计转向,这正是本题最容易错的地方。 |
ans = 0; | 多组数据,每组开始前必须清零。 |
dir == 0、另一处传了 2)→ 转向次数算错。return,或者把「走到最后一行 / 最后一列」当成到达终点 → 重复计数。ans 置 0 → 答案一路累加,越往后越大。N 只小猫要坐索道下山(N ≤ 18)。第 i 只小猫重 Ci,每辆缆车的最大承重是 W,一辆车上小猫的重量之和不能超过 W。保证每只猫的重量都不超过 W。
每租一辆缆车要付 1 美元,求把所有小猫都运下山最少要付多少美元(也就是最少几辆车)。
输入格式:第一行两个用空格隔开的整数 N、W(1 ≤ Ci, W ≤ 10⁸);接下来 N 行,每行一个整数 Ci。
输出格式:一个整数,表示最少缆车数。
把猫一只只往车上放:放进某一辆已经开过的车,或者新开一辆,这就是一棵搜索树。
直接枚举到 3¹⁸ 级别会超时,必须剪枝:当前车数已经不优于已知答案,就没必要再往下搜。
先放大猫。大猫可选的车少,越早把「车不够用」的分支砍掉,剪枝越有效。
状态:dfs(i, cnt) 表示正在安排第 i 只猫,目前已经租了 cnt 辆车;car[j] 记第 j 辆车的当前载重。
分支:放进某辆还装得下的车;或者新开一辆车。
出口:i > N 时用 cnt 更新答案。
剪枝一(最优性):cnt >= ans 时直接 return——已经租的车不比已知答案少,后面再完美也不可能更优。
剪枝二(对称性):如果这只猫放进去之前那辆车是空的,说明前面几辆都装不下它;此时再试其它空车完全是重复枚举,直接 break。
排序:把 C 从大到小排。这是本题能过的关键,不是可有可无的优化。
复杂度:3^18 ≈ 3.9×10⁸ 太大,但排序 + 两条剪枝之后实际搜索量很小,N = 18 稳定通过。
#include <bits/stdc++.h>
using namespace std;
int n, W;
int c[25];
int car[25]; // car[j]:第 j 辆车当前的载重
int ans;
void dfs(int i, int cnt) { // 安排第 i 只猫,已用 cnt 辆车
if (cnt >= ans) return; // 剪枝一:车数已不优于答案,放弃这一支
if (i > n) { // 出口:全部安排完
ans = cnt; // 能到这里的 cnt 一定比 ans 小
return;
}
for (int j = 1; j <= cnt; ++j) {
if (car[j] + c[i] <= W) {
car[j] += c[i];
dfs(i + 1, cnt);
car[j] -= c[i]; // 回溯:把猫从车上拿下来
if (car[j] == 0) break; // 剪枝二:空车彼此等价,不再重复试
}
}
car[cnt + 1] = c[i]; // 新开一辆车
dfs(i + 1, cnt + 1);
car[cnt + 1] = 0; // 回溯
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> W;
for (int i = 1; i <= n; ++i) cin >> c[i];
sort(c + 1, c + n + 1, greater<int>()); // 大猫先放,剪枝才狠
ans = n; // 上界:一只猫一辆车
dfs(1, 0);
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
int car[25]; | 记录每辆车当前的载重。搜索时只增不减,回溯时还原。 |
if (cnt >= ans) return; | 剪枝一。已经租了 cnt 辆,车数不可能再变小,这一支直接放弃。 |
if (i > n) { ans = cnt; return; } | 出口。能走到这里说明 cnt < ans,所以直接赋值就是更新最优。 |
if (car[j] + c[i] <= W) | 塞得进第 j 辆车才塞;塞不下就换一辆,都不行就新开。 |
car[j] -= c[i]; | 回溯:把猫拿下来,否则下一支会看到被撑满的车,答案偏大。 |
if (car[j] == 0) break; | 剪枝二。放进去之前是空车,说明前面几辆都装不下它;再试别的空车是重复枚举。 |
sort(c + 1, c + n + 1, greater<int>()); | 大猫先放。大猫可选的车少,尽早触发剪枝一。 |
ans = n; | 上界:一只猫一辆车一定有解,所以答案不会超过 n。 |
car[j] -= c[i] 或 car[cnt + 1] = 0 少一句 → 载重越算越大,答案偏大。break 写成 continue → 后面几辆空车被重复枚举,最坏情况退化成指数级。给定一组单词和一个开头字母,要求接出以该字母开头的最长的「龙」。每个单词最多在龙中出现两次。
两个单词相连时,重合部分合并成一部分(例如 beast 和 astonish 可以接成 beastonish);相邻两部分不能存在包含关系,例如 at 与 atide 之间不能相连。
输入格式:第一行一个整数 n(n ≤ 20)表示单词数;接下来 n 行每行一个单词;最后一行为一个单字符,表示龙开头的字母。保证以该字母开头的龙一定存在。
输出格式:一行一个整数,表示以此字母开头的最长的龙的长度。
两个单词能不能接、接上以后长度增加多少,只跟这两个词有关,可以先把所有配对算好存起来。
重叠部分越短,龙就越长——同一个配对有多种合法重叠时,要取最短的那个。
「每个单词最多用两次」是全局状态,每用一个要记下来,退出这一支时要还回去。
预处理:ov[i][j] = 单词 i 的后缀与单词 j 的前缀的最短非空重叠长度,0 表示接不上。对每对单词从小到大枚举 k(1 ≤ k < min(leni, lenj)),第一个满足「i 的最后 k 个字符 == j 的前 k 个字符」的 k 就是答案。
为什么要最小的 k:接上以后长度增加 len_j - k,k 越小增加越多。
状态:dfs(last, len) 表示当前龙以第 last 个单词结尾,总长度是 len。
每进入一个状态先 ans = max(ans, len)——龙可以随时停下,最长值不一定出现在最后一步。
分支:枚举下一个单词 j,跳过「已用两次」和「接不上」的。
回溯:used[j]++ 后递归,返回后 used[j]--。
起点:枚举所有首字母等于给定字母的单词,把它记为用了一次,从它开始搜。
本题是上古 NOIP 题,数据很弱,朴素回溯就能过;它的难度评级只对应「能通过原始数据」的做法。
#include <bits/stdc++.h>
using namespace std;
int n, ans = 0;
string w[25];
int used[25]; // 每个单词已经用了几次
int ov[25][25]; // ov[i][j]:i 后面接 j 的最短重叠长度,0 表示接不上
// a 的后缀与 b 的前缀的最短非空重叠长度
int overlap(const string &a, const string &b) {
int la = a.size(), lb = b.size();
for (int k = 1; k < la && k < lb; ++k) { // 重叠必须小于任一整词
bool ok = true;
for (int t = 0; t < k; ++t)
if (a[la - k + t] != b[t]) { ok = false; break; }
if (ok) return k; // 第一个合法的 k 就是最短的
}
return 0;
}
void dfs(int last, int len) {
ans = max(ans, len); // 每一步都可能是最长
for (int j = 0; j < n; ++j) {
if (used[j] >= 2 || ov[last][j] == 0) continue; // 用满两次 / 接不上
used[j]++;
dfs(j, len + (int)w[j].size() - ov[last][j]); // 重叠部分只算一次
used[j]--; // 回溯:还回去
}
}
int main() {
cin >> n;
for (int i = 0; i < n; ++i) cin >> w[i];
char st;
cin >> st;
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
ov[i][j] = overlap(w[i], w[j]);
for (int i = 0; i < n; ++i)
if (w[i][0] == st) { // 只能从这个字母开头的词起头
used[i]++; // 开头词本身也算用了一次
dfs(i, (int)w[i].size());
used[i]--;
}
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
int ov[25][25]; | 预处理表:把「两词怎么接」从每次递归里挪到外面,只算一遍。 |
for (int k = 1; k < la && k < lb; ++k) | 重叠长度从 1 试到 min(la, lb)-1:等于整词长度是不允许的(那就是包含关系)。 |
if (a[la - k + t] != b[t]) | 比较 a 的后 k 个字符与 b 的前 k 个字符。la - k 是这段后缀的起点。 |
if (ok) return k; | k 从小到大枚举,第一个合法的就是最短重叠,也就是能加得最多的接法。 |
ans = max(ans, len); | 放在函数开头:这条链随时可以停,最长值可能出现在中间某一步。 |
if (used[j] >= 2 || ov[last][j] == 0) continue; | 两个跳过条件:次数用满,或者根本接不上。 |
dfs(j, len + (int)w[j].size() - ov[last][j]); | 增加的长度是新词长度减去重叠部分,重叠只算一次。 |
used[j]--; | 回溯。漏了这一句,每种词用满两次之后搜索树就断了,答案偏小。 |
if (w[i][0] == st) | 起点必须是给定字母开头的单词;这个字母本身不计入长度。 |
used[j]-- 漏写 → 搜索提前被掐断,答案偏小。used[i]++,第一个词被多用了第三次。ans:最长的链如果还能继续接,这个长度就取不到。col、d1、d2 三个下标分别表示什么。