P4667 BalticOI 2011 Switch the Lamp On (Day1)
题目描述
Casper 在一个 N×MN\times MN×M 的电路板上设计电路。有一种正方形的电路元件,在它的两组相对顶点中,有一组会用导线连接起来,另一组则不会。有 N×MN\times MN×M 个这样的元件,你想将其排列成 NNN 行,每行 MMM 个。 电源连接到板的左上角。灯连接到板的右下角。只有在电源和灯之间有一条电线连接的情况下,灯才会亮着。为了打开灯,任何数量的电路元件都可以转动 90°(两个方向)。


在上面的图片中,灯是关着的。如果从右往左数第二列的任何一个电路元件被旋转 90°,电源和灯都会连接,灯被打开。现在请你编写一个程序,求出最小需要旋转多少电路元件。
输入格式
输入的第一行包含两个整数 NNN 和 MMM,表示电路板的尺寸。 在以下 NNN 行中,每一行有 MMM 个符号 \ 或 /,表示连接对应电路元件对角线的导线的方向。
输出格式
如果可以打开灯,那么输出只包含一个整数,表示最少转动电路元件的数量。
如果不可能打开灯,输出 NO SOLUTION。
输入输出样例 #1
输入 #1
3 5
\\/\\
\\///
/\\\\
输出 #1
1
说明/提示
对于 40%40\%40% 的数据,1≤N≤41 \le N \le 41≤N≤4,1≤M≤51 \le M \le 51≤M≤5。
对于所有数据,1≤N,M≤5001 \le N,M \le 5001≤N,M≤500。
P4667 详细讲解(面向算法小白)
一、先理解题目在说什么
想象你有一块 N 行 M 列 的电路板,每个小方格里有 一根斜着的导线 ,可能是 \ 也可能是 /。
- 电源接在左上角
- 灯接在右下角
- 每个方格都可以单独旋转 90° (
\变/,/变\) - 问:最少旋转多少个方格,能让电从左上角一路通到右下角?
可以把它想象成"拼水管"游戏:每个格子是一根斜着的水管,你要让水从左上角流到右下角。不通就把某些管子转一下,求最少转几次。
二、关键洞察:把它变成"图"
1. 顶点在哪里?
每个小方格有 4 个角 。相邻方格的角是重合的。所以整块 N × M 的板子,其实有:
(N+1) 行 × (M+1) 列 个"角点"
我们把每个角点看成一个 图上的顶点。
用 (i, j) 表示第 i 行、第 j 列的角点(从 0 开始数):
(0,0) (0,1) (0,2) ... (0,M)
(1,0) (1,1) (1,2) ... (1,M)
...
(N,0) (N,1) (N,2) ... (N,M) ← 终点(右下角)
↑
起点(左上角)
2. 边在哪里?
每个小方格里有一根斜线,它把 两个对角 连起来------这条线就是图的一条边。
假设现在是第 i 行第 j 列的小方格(0 ≤ i < N, 0 ≤ j < M),它的四个角是:
- 左上角
(i, j) - 右上角
(i, j+1) - 左下角
(i+1, j) - 右下角
(i+1, j+1)
那么:
| 方格内容 | 导线连接的两个角 | 图上的边 |
|---|---|---|
\ |
左上 ↔ 右下 | (i,j) ------ (i+1,j+1) |
/ |
右上 ↔ 左下 | (i,j+1) ------ (i+1,j) |
3. 旋转代价是什么?
一个格子有两种可能状态:
- 状态 A:连接 左上-右下 (即
\) - 状态 B:连接 右上-左下 (即
/)
每种格子永远只有这两条对角线中的一条。 想让另一条出现,就必须旋转它(代价 1)。
所以对于每个格子,从图的角度看:
- 想要的那条对角线 出现 → 代价 0
- 另一条对角线 才出现 → 代价 1
4. 题目变成图论问题
从左上角顶点
(0,0)走到右下角顶点(N,M),每条边有一个代价 0 或 1,求最小总代价。
这就是一个 最短路问题,只不过边权只有 0 和 1。
三、建图的具体例子
假设一个 1 × 1 的板子,内容是 \:
(0,0) (0,1)
+--------+
| \ |
+--------+
(1,0) (1,1)
- 这个格子是
\,所以它 本来就有 连接(0,0)和(1,1)的边 → 代价 0 - 如果我想让
(0,1)和(1,0)相连,就需要旋转它 → 代价 1
所以图上:
(0,0) ───0─── (1,1)
(0,1) ───1─── (1,0)
从 (0,0) 到 (1,1) 的最短路是 0(不用旋转),灯就亮。
再假设内容是 /:
(0,0) (0,1)
+--------+
| / |
+--------+
(1,0) (1,1)
- 本来就有
(0,1)------(1,0)的边,代价 0 - 想要
(0,0)------(1,1)的边,要转一格,代价 1
从 (0,0) 到 (1,1) 最短距离是 1,转一次就亮。
思路通了。 接下来的任务就是在这个大图上跑最短路。
四、为什么用 0-1 BFS?
最短路算法挺多,为什么不用 Dijkstra?
因为 边权只有 0 和 1 。这种特殊情况下,有一个更快的算法叫 0-1 BFS:
- 用 双端队列 deque(两头都能进出的队列)
- 从队首取点,看它的邻居
- 如果走这条边代价是 0 → 新点 插入队首
- 如果代价是 1 → 新点 插入队尾
为什么这样对?
关键是不变式:队列里点的距离永远是"非递减"的(前面小,后面大)。这和 Dijkstra 里"优先队列按距离出队"是一个道理。
类比一下:
- Dijkstra :用优先队列,每次取最小距离的点。代价
O(E log V)。 - 0-1 BFS :因为距离只有两种增长方式(+0 或 +1),用双端队列就够了。代价
O(E),更快。
代码结构:
cpp
deque<int> dq;
dist[start] = 0;
dq.push_front(start);
while (!dq.empty()) {
int u = dq.front(); dq.pop_front();
if (visited[u]) continue;
visited[u] = true;
for (每条从 u 出发的边 e) {
if (dist[e.to] > dist[u] + e.w) {
dist[e.to] = dist[u] + e.w;
if (e.w == 0) dq.push_front(e.to); // 0 边 → 放前面
else dq.push_back(e.to); // 1 边 → 放后面
}
}
}
关键就是这个 push_front / push_back。
五、代码逐行讲解
1. 读入
cpp
int N, M;
cin >> N >> M;
vector<string> board(N);
for (int i = 0; i < N; i++) cin >> board[i];
board[i][j] 表示第 i 行第 j 列格子的字符(\ 或 /)。
⚠️ 注意转义 :C++ 里 '\' 表示"转义字符",如果你真的要表示一个反斜杠,要写 '\\'。输入进去后 board[i][j] 存的确实就是单个字符 \,代码里比较要用 '\\'。
2. 给顶点编号
每行有 M + 1 个角点,所以:
cpp
int C = M + 1; // 每行的角点数
auto id = [&](int x, int y) { return x * C + y; };
这样 id(i, j) 就把二维坐标 (i, j) 映射成一维编号(0 到 (N+1)*(M+1)-1)。
举例 N=3, M=5:
(0,0)→ 0(0,1)→ 1(0,6)→ 6 (下一行第一个)(3,5)→ 3*6 + 5 = 23(右下角)
3. 建图
cpp
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
int u1 = id(i, j); // 左上
int v1 = id(i + 1, j + 1); // 右下
int u2 = id(i, j + 1); // 右上
int v2 = id(i + 1, j); // 左下
if (board[i][j] == '\\') {
addEdge(u1, v1, 0); // \ 本来就连左上-右下
addEdge(u2, v2, 1); // 想要右上-左下?转一次
} else { // '/'
addEdge(u2, v2, 0); // / 本来就连右上-左下
addEdge(u1, v1, 1); // 想要左上-右下?转一次
}
}
}
addEdge(u, v, w) 加一条无向边 (两个方向都能走),代价 w:
cpp
auto addEdge = [&](int u, int v, int w) {
graph[u].push_back({v, w});
graph[v].push_back({u, w}); // 无向边
};
为什么要加两条边? 因为电可以在一条导线上"双向流动",从 u 走到 v 也行,从 v 走到 u 也行。
注意:每个格子只有一条实际的导线。但为了让 BFS 探索方便,我们把它写成两条(一条代表"无需旋转的直接通路",一条代表"旋转后打通的对角线"),代价分别是 0 和 1。这样才能表示"如果我选这条路就要额外旋转"。
4. 跑 0-1 BFS
cpp
int start = id(0, 0);
int target = id(N, M);
vector<int> dist(P, 1e9);
deque<int> dq;
dist[start] = 0;
dq.push_front(start);
while (!dq.empty()) {
int u = dq.front(); dq.pop_front();
// ... 更新邻居
}
BFS 结束后,dist[target] 就是从左上角到右下角的最少旋转数。
5. 判断无解
cpp
if (dist[target] == 1e9) cout << "NO SOLUTION\n";
else cout << dist[target] << '\n';
六、为什么可以提前判断无解(一个小技巧)
你想:从 (0,0) 出发,走的每条边都是对角线,也就是说每一次移动,i + j 的奇偶性都不变(+1 再 -1,或者 -1 再 +1,总和不变)。
- 起点
(0,0):i + j = 0(偶) - 终点
(N,M):i + j = N + M
如果 N + M 是奇数,就永远到不了! 直接输出 NO SOLUTION 就行。
不过不判断也没关系,BFS 自然会算出无法到达(距离还是无穷大)。
七、完整流程演示
用题目样例:
3 5
\\/\\
\\///
/\\\\
最终答案是 1,意味着只需要旋转 1 个元件。BFS 会找到一条所有边代价加起来为 1 的路径。
(具体哪条路径不方便手画,但原理一样。)
八、复杂度分析
设 V = (N+1)(M+1) 是点数,E 是边数。
- 每个格子最多贡献 2 条无向边 ,所以
E = O(NM) - 0-1 BFS 每个点最多出队一次,每条边最多被松弛几次 →
O(V + E) = O(NM) - 空间:
O(V + E) = O(NM)
题目 N, M ≤ 500,NM ≤ 250000,完全没问题。
如果用 Dijkstra + 优先队列 是 O(NM log NM),也能过,但 0-1 BFS 更快更简洁。
九、易错点提醒
- C++ 里
\要写成'\\',比较字符时要用board[i][j] == '\\'。 - 顶点是
(N+1) × (M+1)个 ,不是N × M(因为角点比格子多一行一列)。数组大小别开小了。 - 无向边要加两次,否则可能漏方向。
- 队首取点时要判断
visited,防止同一个点被处理多次。 - 0-1 BFS 中 边权为 0 放队首,边权为 1 放队尾,这个千万别搞反,搞反就成了普通 BFS 求错答案。
十、整体思路回顾
理解题意
↓
把每个角点当作图的顶点
每个格子当成连接对角两点的"边"
↓
边权:本来就连通的 = 0,旋转后连通的 = 1
↓
求从 (0,0) 到 (N,M) 的最短路
↓
边权只有 0/1 → 用 0-1 BFS 高效求解
↓
如果终点距离还是 INF → NO SOLUTION
核心就一句话:"最少旋转次数" = "一条路径上边权之和的最小值",而边权只有 0 和 1,所以用 0-1 BFS。
这就是这道题的灵魂。慢慢消化这个"把物理问题转成图"的思路,以后很多题都会用到。