前置知识
网格与编号
1)二维坐标 (r,c) 的含义
r 是行号(row),c 是列号(col)
范围:0 <= r < n 且 0 <= c < n
例如 n=5:合法坐标是 (0..4, 0..4)
越界判断(写条件必须熟):
if (r < 0 || r >= n || c < 0 || c >= n) // 越界
2)编号规则:id = r*n + c(行优先)
这代表每一行有 n 个格子,从左到右编号,下一行接着编号。
(r,c) -> id:id=r×n+c
id -> (r,c):r=id/n,c=id%n
对应 C++:
int id = r * n + c;
int r = id / n;
int c = id % n;
3)四方向移动(右、左、下、上)
常用写法是方向数组(在 DFS 里会一直用):
int dr[4] = {0, 0, 1, -1};
int dc[4] = {1, -1, 0, 0}; // 右 左 下 上
从 (r,c) 走到下一格 (nr,nc):
int nr = r + dr[k];
int nc = c + dc[k];
4)邻居枚举 + 越界过滤(要形成肌肉记忆)
伪代码类似这样:
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue; // 越界不要
// (nr, nc) 就是合法邻居
}
回溯/DFS 模板
1)递归函数在表达什么?
我们定义:dfs(r, c) = "我现在站在 (r,c),接下来继续往下走,尝试把路走完。"
递归不是"玄学",就是把"后续怎么走"交给下一层来做。
2)为什么要 vis(访问标记)?
vis[r][c] 表示这个格子是否已经走过。
题目要求每个格子最多进一次
所以每次准备走到 (nr,nc) 之前要判断:
if (vis[nr][nc]) continue;
3)path 为什么要 push / pop?
path 用来记录走过的路径(一般存 id,便于输出)
走进新格子:path.push_back(id)
回来撤销:path.pop_back()
这就是"现场还原"的一部分。
4)回溯的本质:撤销"本分支做过的一切"
你在一个分支里做的修改,只能对这个分支有效。
所以递归返回时必须撤销:
vis[nr][nc] = false
path.pop_back()
(后面会加:row/col 配额也要加回去)
否则会出现一个经典 bug:走过的痕迹污染别的分支,导致明明有解却搜不到。
题目约束建模
建模:走进一个格子 = 消耗该行与该列各 1 次
1)两个数组分别代表什么?
**row[i]:**第 i 行还需要走多少格(还剩多少"名额")
**col[j]:**第 j 列还需要走多少格
可以理解成:每进入一个格子 (r,c),就对 row[r] 和 col[c] 各扣一次。
2)进入新格子时,状态怎么更新?
从 (r,c) 走到 (nr,nc) 时:
row[nr]--;
col[nc]--;
vis[nr][nc] = 1;
path.push_back(nr*n + nc);
dfs(nr, nc);
path.pop_back();
vis[nr][nc] = 0;
row[nr]++; // 回溯时加回
col[nc]++; // 回溯时加回
注意:row/col 的撤销也必须做,它们和 vis/path 一样,都是"分支状态"。
3)什么时候算"合法到终点"?
到 (n-1,n-1) 还不够,还必须满足:
所有 row[i] == 0
所有 col[j] == 0
也就是该走的行/列次数刚好用完。
会写成:
bool ok = true;
for (int i=0;i<n;i++) if (row[i]!=0) ok=false;
for (int j=0;j<n;j++) if (col[j]!=0) ok=false;
if (ok) found = true;
(后面我们会优化:不每次循环扫一遍,而是用一个 rem 计数。)
4)"没配额了"必须直接剪掉(最基础剪枝)
准备走进 (nr,nc) 前,如果:
row[nr] <= 0 或 col[nc] <= 0
说明这一步会把配额用成负数/不允许 → 必死,不走:
if (row[nr] <= 0 || col[nc] <= 0) continue;
剪枝的基础逻辑
A. 不能走的直接排除(最基础)
这是"硬规则",不满足就别递归:
走 (nr,nc) 前检查:
-
越界
-
已访问 vis[nr][nc]
-
配额不足 row[nr] <= 0 || col[nc] <= 0
代码就是:
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
if (vis[nr][nc]) continue;
if (row[nr] <= 0 || col[nc] <= 0) continue;
B. 距离下界剪枝(很重要,提速最大)
核心:你不可能瞬移。从当前位置到终点至少要走 dist 步。
1)dist 怎么算?
终点是 (n-1, n-1),曼哈顿距离:
int dist = abs(r - (n-1)) + abs(c - (n-1));
这代表至少还要走 dist 步才可能到终点。
2)rem 是什么?
rem 表示"还剩多少步可以走"。
在这题里非常自然的做法是:
每走进一个格子,就消耗 1 次(同时 row/col 也各减 1)
所有格子总共要走的次数其实是固定的
所以我们维护一个:
rem:剩余还要走的"格子进入次数"(含终点/不含看你定义,但要一致)
通常写法:每走一步到新格子,rem--;回溯 rem++
3)剪枝条件:dist > rem 必死
如果你连"最低步数"都不够,那肯定到不了终点:
if (dist > rem) return; // 或 continue,看你放在哪
直觉:
你还剩 3 步,但离终点最少 5 步 → 怎么走都不可能到。
C. 奇偶性剪枝
这条很像"棋盘黑白格":
每走一步,颜色必然翻转一次
所以从当前位置走到终点,你走的步数奇偶必须跟 dist 同奇偶
等价写法(常见):
if ( (rem - dist) % 2 != 0 ) return;// 奇偶对不上必死
为什么是 rem - dist?
dist 是"最低必须走的步数"
rem 是"你实际还能走的步数"
多出来的步数只能靠"绕路"产生,而绕路必然是一进一出之类的 2 步、4 步... 偶数
所以 rem 必须和 dist 同奇偶,差值必须是偶数
为什么能输出"最小编号序"
1)"最小编号序"到底是什么意思?
路径用 id 序列表示(比如 0,1,6,11,...)
比较两条路径时,从前往后比:第一个不同的位置,谁的 id 更小,谁就更小(字典序)
2)为什么 DFS 先找到的就是最小?
前提:你每一步都先尝试更小的候选格子
做法:
枚举 4 个邻居
把合法邻居按 id = nr*n+nc 排序
按排序后的顺序递归
DFS 的特性是:一条路走到底,先找到的解会立刻返回(found=true)
而你每一层都把选择按 id 从小到大试,所以:
第 1 步选的尽可能小
如果第 1 步相同,就让第 2 步尽可能小
......
最终"第一个找到的完整解"就是字典序最小解
代码演变
先写一个能编译运行的空壳
目的:先把 main 搭好,不要一上来就写 dfs
cpp
#include <iostream>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
cout << "\n";
return 0;
}
把输入读完整 但先不求解
目的:先确认输入格式,能把数据正确读进来
cpp
#include <iostream>
#include <vector>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> col(n), row(n);
for (int i = 0; i < n; i++) cin >> col[i]; // 北边:列
for (int i = 0; i < n; i++) cin >> row[i]; // 西边:行
// 暂时不求解,先结束
cout << "\n";
return 0;
}
补状态变量 先把 dfs 壳子写出来
目的:知道 dfs 需要哪些全局状态。现在 dfs 还不干活
cpp
#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> col, row;
vector<vector<char>> vis;
vector<int> path;
bool found = false;
void dfs(int r, int c) {
// 先放一个空壳,后面逐步填
return;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
col.assign(n, 0);
row.assign(n, 0);
for (int i = 0; i < n; i++) cin >> col[i];
for (int i = 0; i < n; i++) cin >> row[i];
vis.assign(n, vector<char>(n, 0));
path.clear();
found = false;
cout << "\n";
return 0;
在 dfs 里加走格子的最基础逻辑
目的:先把 DFS 回溯模板写熟 做选择 递归 撤销
cpp
#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> col, row;
vector<vector<char>> vis;
vector<int> path;
bool found = false;
void dfs(int r, int c) {
if (found) return;
if (r == n - 1 && c == n - 1) { // 到终点就停
found = true;
return;
}
const int dr[4] = {0, 0, 1, -1};
const int dc[4] = {1, -1, 0, 0};
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
if (vis[nr][nc]) continue;
// 做选择
vis[nr][nc] = 1;
path.push_back(nr * n + nc);
dfs(nr, nc);
if (found) return;
// 撤销选择
path.pop_back();
vis[nr][nc] = 0;
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
col.assign(n, 0);
row.assign(n, 0);
for (int i = 0; i < n; i++) cin >> col[i];
for (int i = 0; i < n; i++) cin >> row[i];
vis.assign(n, vector<char>(n, 0));
path.clear();
found = false;
// 起点加入路径
vis[0][0] = 1;
path.push_back(0);
dfs(0, 0);
for (int i = 0; i < path.size(); i++) {
if (i) cout << ' ';
cout << path[i];
}
cout << "\n";
return 0;
}
加上题目的行列配额约束
规则:走到 (nr,nc) 就 row[nr]--、col[nc]--
目标:到终点时所有 row/col 都减到 0
cpp
#include <iostream>
#include <vector>
using namespace std;
int n;
vector<int> col, row;
vector<vector<char>> vis;
vector<int> path;
bool found = false;
void dfs(int r, int c) {
if (found) return;
if (r == n - 1 && c == n - 1) {
// 必须所有行列都刚好用完
for (int i = 0; i < n; i++) {
if (row[i] != 0 || col[i] != 0) return;
}
found = true;
return;
}
const int dr[4] = {0, 0, 1, -1};
const int dc[4] = {1, -1, 0, 0};
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
if (vis[nr][nc]) continue;
// 如果该行/列已经没配额了,不能走
if (row[nr] <= 0 || col[nc] <= 0) continue;
// 做选择:消耗配额
vis[nr][nc] = 1;
row[nr]--; col[nc]--;
path.push_back(nr * n + nc);
dfs(nr, nc);
if (found) return;
// 撤销选择:恢复配额
path.pop_back();
row[nr]++; col[nc]++;
vis[nr][nc] = 0;
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
col.assign(n, 0);
row.assign(n, 0);
for (int i = 0; i < n; i++) cin >> col[i];
for (int i = 0; i < n; i++) cin >> row[i];
// 起点也要消耗一次
if (row[0] <= 0 || col[0] <= 0) { cout << "\n"; return 0; }
row[0]--; col[0]--;
vis.assign(n, vector<char>(n, 0));
vis[0][0] = 1;
path.clear();
path.push_back(0);
found = false;
dfs(0, 0);
for (int i = 0; i < path.size(); i++) {
if (i) cout << ' ';
cout << path[i];
}
cout << "\n";
return 0;
}
加输出最小编号序的保证
原题常会要求:若多解,输出编号序列最小的那条
做法:把下一步候选点按 id 排序后依次尝试
这一步本质是暴力:
枚举所有不重复路径,只不过用行列配额做了最基本的合法性过滤
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>
using namespace std;
int n;
vector<int> col, row;
vector<vector<char>> vis;
vector<int> path;
bool found = false;
void dfs(int r, int c) {
if (found) return;
if (r == n - 1 && c == n - 1) {
for (int i = 0; i < n; i++) {
if (row[i] != 0 || col[i] != 0) return;
}
found = true;
return;
}
const int dr[4] = {0, 0, 1, -1};
const int dc[4] = {1, -1, 0, 0};
vector<pair<int, pair<int,int>>> nxt; // (id,(nr,nc))
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
if (vis[nr][nc]) continue;
if (row[nr] <= 0 || col[nc] <= 0) continue;
nxt.push_back({nr * n + nc, {nr, nc}});
}
sort(nxt.begin(), nxt.end());
for (auto &it : nxt) {
int id = it.first;
int nr = it.second.first, nc = it.second.second;
vis[nr][nc] = 1;
row[nr]--; col[nc]--;
path.push_back(id);
dfs(nr, nc);
if (found) return;
path.pop_back();
row[nr]++; col[nc]++;
vis[nr][nc] = 0;
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
col.assign(n, 0);
row.assign(n, 0);
for (int i = 0; i < n; i++) cin >> col[i];
for (int i = 0; i < n; i++) cin >> row[i];
if (row[0] <= 0 || col[0] <= 0) { cout << "\n"; return 0; }
row[0]--; col[0]--;
vis.assign(n, vector<char>(n, 0));
vis[0][0] = 1;
path.clear();
path.push_back(0);
found = false;
dfs(0, 0);
for (int i = 0; i < path.size(); i++) {
if (i) cout << ' ';
cout << path[i];
}
cout << "\n";
return 0;
}
加剪枝 奇偶 + 行列上界
这一步就是最终版,多了 rem/usedR/usedC 和剪枝判断
cpp
#include <bits/stdc++.h>
using namespace std;
int n, rem;
vector<int> col, row, usedR, usedC;
vector<vector<char>> vis;
vector<int> path;
bool found = false;
void dfs(int r, int c) {
if (found) return;
int dist = abs(r - (n - 1)) + abs(c - (n - 1));
if (dist > rem) return;
if (((rem - dist) & 1) != 0) return;
for (int i = 0; i < n; i++) {
if (row[i] < 0 || col[i] < 0) return;
if (row[i] > n - usedR[i]) return;
if (col[i] > n - usedC[i]) return;
}
if (r == n - 1 && c == n - 1) {
if (rem == 0) found = true;
return;
}
const int dr[4] = {0, 0, 1, -1};
const int dc[4] = {1, -1, 0, 0};
vector<pair<int, pair<int,int>>> nxt;
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
if (vis[nr][nc]) continue;
if (row[nr] <= 0 || col[nc] <= 0) continue;
nxt.push_back({nr * n + nc, {nr, nc}});
}
sort(nxt.begin(), nxt.end());
for (auto &it : nxt) {
int nr = it.second.first, nc = it.second.second, id = it.first;
vis[nr][nc] = 1;
row[nr]--; col[nc]--;
usedR[nr]++; usedC[nc]++;
path.push_back(id);
rem--;
dfs(nr, nc);
if (found) return;
rem++;
path.pop_back();
usedR[nr]--; usedC[nc]--;
row[nr]++; col[nc]++;
vis[nr][nc] = 0;
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
if (n <= 0) { cout << "\n"; return 0; }
col.assign(n, 0); row.assign(n, 0);
for (int i = 0; i < n; i++) cin >> col[i];
for (int i = 0; i < n; i++) cin >> row[i];
int sumC = 0, sumR = 0;
for (int x : col) sumC += x;
for (int x : row) sumR += x;
if (sumC != sumR || row[0] <= 0 || col[0] <= 0) { cout << "\n"; return 0; }
vis.assign(n, vector<char>(n, 0));
usedR.assign(n, 0); usedC.assign(n, 0);
path.clear();
vis[0][0] = 1;
row[0]--; col[0]--;
usedR[0] = usedC[0] = 1;
path.push_back(0);
rem = sumR - 1;
dfs(0, 0);
for (int i = 0; i < path.size(); i++) {
if (i) cout << ' ';
cout << path[i];
}
cout << "\n";
return 0;
}