引言
蓝桥杯"水质检测"问题是一个看似简单实则巧妙的算法题。它要求我们在2行n列的网格中,用最少的检测器连接所有已有检测器。这个问题不仅考察了选手对图论算法的理解,更考察了从实际问题中抽象出数学模型并设计高效算法的能力。
本文将详细解析解决这个问题的完整思考过程,特别是0-1 BFS算法的设计思路、实现细节和正确性证明。

一、问题分析与转化
1.1 问题重述
给定一个2行n列的网格,包含:
-
#:已有检测器 -
.:空白位置
我们需要在空白位置添加检测器,使所有检测器连通(上下左右相邻即连通),并最小化添加的检测器数量。
1.2 问题的关键特点
-
网格结构特殊:只有2行,n列
-
数据规模大:n最大可达1,000,000
-
优化目标明确:最小化添加数量
-
连通性要求:所有检测器必须连通
二、算法思路的整体思考过程
2.1 从物理问题到数学模型
当我们面对这个问题时,首先需要思考如何将其转化为可计算的数学模型。最直接的想法是:在检测器之间添加检测器,形成连接路径。但这产生了几个关键问题:
-
如何选择添加位置?
-
如何保证添加数量最少?
-
如何高效计算最优方案?
2.2 关键突破:转化为路径搜索问题
经过深入思考,我意识到一个重要的转化:从一个检测器出发,找到连接其他所有检测器的最短路径。
为什么这样想?
-
要连接检测器A和B,需要在它们之间建立一条路径
-
路径上的空白位置需要添加检测器
-
路径的长度(空白位置数量)就是添加的检测器数量
-
因此,最小化添加数量等价于寻找最短路径
2.3 代价的量化
在网格中移动时:
-
移动到检测器
#:代价0(不需要添加检测器) -
移动到空白
.:代价1(需要添加一个检测器)
这自然地将网格转化为边权为0或1的无向图。
2.4 贪心策略的发现
观察代价定义,我们发现一个重要规律:检测器之间的移动是免费的,空白处的移动是昂贵的。这自然引出一个贪心策略:
优先走检测器,必要时才走空白。
为什么这是贪心的?
-
检测器是必须连接的,现在不走以后也要走
-
现在不走检测器,以后绕回来再走,只会增加总代价
-
优先走免费路径,能最大限度地减少总添加数
三、算法选择的深入分析
3.1 为什么不用暴力枚举?
n最大1,000,000,网格有2,000,000个格子。暴力枚举所有可能的添加方案是完全不可行的。
3.2 为什么不用普通BFS?
普通BFS适用于边权相同的图。但我们的图边权有0和1两种,普通BFS无法保证找到最小代价路径。
示例:
cpp
起点A(检测器),相邻B(检测器,代价0),C(空白,代价1)
如果用普通BFS,B和C以相同顺序处理,但实际上去B的路径更好。
3.3 为什么不用Dijkstra?
Dijkstra可以解决问题,但时间复杂度O(E log V)。我们的图有特殊性质:边权只有0和1,这让我们可以设计更高效的算法。
3.4 0-1 BFS的诞生
当边权只有0和1时,我们可以优化Dijkstra:
-
用双端队列代替优先队列
-
时间复杂度从O(E log V)降到O(V+E)
-
这就是0-1 BFS算法
四、0-1 BFS算法的核心思想
4.1 算法的三重融合
0-1 BFS巧妙融合了三种算法的优点:
-
贪心思想:优先处理代价为0的边
-
BFS框架:层次遍历,系统探索
-
Dijkstra核心:每次处理当前距离最小的节点
4.2 双端队列的妙用
算法的关键在于使用双端队列实现优先级:
-
权重0的边:节点插入队首
-
权重1的边:节点插入队尾
这个简单的策略保证了:队首的节点总是当前距离最小的节点。
4.3 距离更新机制
在算法执行中,一个节点可能被多次发现。比如节点X:
-
先被长路径发现,距离5
-
后被短路径发现,距离3
-
需要更新X的距离为3
cpp
if (dist[a][b] + w < dist[x][y]) {
dist[x][y] = dist[a][b] + w; // 更新为更短距离
// 重新入队...
}
4.4 出队时距离确定
当一个节点从队首出队时,它的距离就确定为最短距离。因为:
-
队列按距离排序
-
如果存在更短路径,相关节点会先出队并更新
-
所以该节点出队时,不可能有更短路径了
cpp
if (st[a][b]) continue; // 已访问,跳过
st[a][b] = true; // 标记为已访问,距离确定
五、算法的数学证明
5.1 队列有序性证明
定理:在0-1 BFS中,双端队列始终保持距离非递减顺序。
证明(归纳法):
-
初始时队列只有起点,距离0,有序
-
假设当前队列有序,队首距离为d
-
处理队首节点时:
-
扩展权重0的邻居:距离d,插入队首
-
扩展权重1的邻居:距离d+1,插入队尾
-
-
插入后队列仍有序
推论:队首节点是当前距离最小的节点。
5.2 出队时距离确定的证明
定理:节点u出队时,dist[u]是最短距离。
证明(反证法):
假设节点u以距离d出队,但存在更短距离d'<d。
-
设更短路径为:起点 → v₁ → v₂ → ... → vₖ → u
-
最后一个节点vₖ满足:dist[vₖ] + w = d'
-
由于d'<d,且w≥0,所以dist[vₖ] < d
-
根据队列有序性,vₖ在u之前出队
-
当vₖ出队时,会更新u的距离为d'
-
矛盾!所以u不可能以距离d出队
因此,节点出队时的距离就是最短距离。
5.3 答案正确性证明
定理:算法返回的ret值(从起点到最远检测器的距离)就是最少需要添加的检测器数量。
证明:
设起点为S,最远检测器为F,距离为D = dist[F]。
-
必要性(至少需要D个检测器):
-
从S到F的最短路径上有D个空白格子
-
要连接S和F,必须在每个空白格子处添加一个检测器
-
因此至少需要D个检测器
-
-
充分性(D个检测器足够):
-
在S到F的最短路径上的每个空白格子添加检测器
-
这样S和F就连通了
-
对于其他任意检测器X,设dist[X] = d ≤ D
-
从S到X的最短路径可以连接到S到F的路径上
-
不需要额外添加检测器
-
所以总共只需要D个检测器
-
六、算法实现细节
6.1 数据结构设计
cpp
const int N = 1e6 + 10; // 最大列数+10
int n; // 网格列数
char g[2][N]; // 存储网格
int dist[2][N]; // 最短距离数组
bool st[2][N]; // 访问标记数组
int dx[] = {0, 0, 1, -1}; // 方向数组
int dy[] = {1, -1, 0, 0};
6.2 核心BFS函数
cpp
int bfs(int i, int j) {
int ret = 0;
memset(dist, 0x3f, sizeof dist); // 初始化距离为无穷大
memset(st, false, sizeof st); // 初始化访问标记
deque<pair<int, int>> q; // 双端队列
q.push_back({i, j}); // 起点入队
dist[i][j] = 0; // 起点距离0
while (q.size()) {
auto t = q.front();
q.pop_front();
int a = t.first, b = t.second;
if (st[a][b]) continue; // 已访问,跳过
st[a][b] = true; // 标记为已访问
if (g[a][b] == '#') ret = max(ret, dist[a][b]); // 更新答案
for (int k = 0; k < 4; k++) {
int x = a + dx[k], y = b + dy[k];
if (x < 0 || x >= 2 || y < 0 || y >= n || st[x][y]) continue;
int w = g[x][y] == '#' ? 0 : 1; // 计算边权
if (dist[a][b] + w < dist[x][y]) {
dist[x][y] = dist[a][b] + w; // 更新距离
if (w) {
q.push_back({x, y}); // 权重1,入队尾
} else {
q.push_front({x, y}); // 权重0,入队首
}
}
}
}
return ret;
}
6.3 主函数
cpp
int main() {
cin >> g[0] >> g[1];
n = strlen(g[0]);
// 找到第一个检测器作为起点
for (int j = 0; j < n; j++) {
if (g[0][j] == '#') {
cout << bfs(0, j) << endl;
return 0;
}
if (g[1][j] == '#') {
cout << bfs(1, j) << endl;
return 0;
}
}
// 没有检测器
cout << 0 << endl;
return 0;
}
七、示例详细分析
7.1 示例网格
行0: # . #
行1: . # .
检测器位置:(0,0)、(0,2)、(1,1)
7.2 逐步执行过程
cpp
初始:队列[(0,0)], dist[0][0]=0
1. 处理(0,0):出队,标记已访问
- 是检测器,ret=max(0,0)=0
- 扩展(0,1):空白,w=1,距离1,入队尾
- 扩展(1,0):空白,w=1,距离1,入队尾
队列:[(0,1), (1,0)]
2. 处理(0,1):出队,标记已访问
- 空白,不更新ret
- 扩展(0,2):检测器,w=0,距离1,入队首
- 扩展(1,1):检测器,w=0,距离1,入队首
队列变为:[(1,1), (0,2), (1,0)]
3. 处理(1,1):出队,标记已访问
- 是检测器,ret=max(0,1)=1
- 扩展(1,2):空白,w=1,距离2,入队尾
队列:[(0,2), (1,0), (1,2)]
4. 处理(0,2):出队,标记已访问
- 是检测器,ret=max(1,1)=1
- 扩展邻居...
5. 处理(1,0):出队,标记已访问
- 空白,不更新ret
6. 处理(1,2):出队,标记已访问
- 空白,不更新ret
结束,返回ret=1
结果:最少需要添加1个检测器,在(0,1)位置添加即可。
八、算法复杂度分析
8.1 时间复杂度
-
每个节点最多入队几次
-
每个节点只出队一次
-
每次出队扩展4个邻居
-
总时间复杂度:O(4n) = O(n)
8.2 空间复杂度
-
网格存储:O(n)
-
距离数组:O(n)
-
访问标记:O(n)
-
队列:最坏情况O(n)
-
总空间复杂度:O(n)
九、完整代码实现
cpp
#include <iostream>
#include <deque> // 使用双端队列实现0-1 BFS
#include <cstring> // 使用memset初始化数组
#include <algorithm> // 使用max函数
using namespace std;
const int N = 1e6 + 10; // 最大列数+10,确保数组不越界
int n; // 网格的列数
char g[2][N]; // 存储网格,g[0]是第一行,g[1]是第二行
int dist[2][N]; // 记录从起点到每个格子的最短距离
bool st[2][N]; // 标记每个格子是否已访问(已确定最短距离)
// 方向数组:右、左、下、上
int dx[] = { 0, 0, 1, -1 };
int dy[] = { 1, -1, 0, 0 };
/**
* 0-1 BFS函数:从起点(i,j)开始,计算到所有检测器的最短距离
* 返回从起点到所有检测器中的最大最短距离
*
* @param i 起点的行坐标(0或1)
* @param j 起点的列坐标
* @return 最少需要添加的检测器数量
*/
int bfs(int i, int j) {
int ret = 0; // 记录最终答案:从起点到最远检测器的距离
// 初始化距离数组为无穷大(0x3f3f3f3f,约10亿)
memset(dist, 0x3f, sizeof dist);
memset(st, false, sizeof st);
// 双端队列,用于实现0-1 BFS
deque<pair<int, int>> q;
// 起点入队,距离设为0
q.push_back({ i, j });
dist[i][j] = 0;
// 开始BFS搜索
while (q.size()) {
// 从队首取出一个节点
auto t = q.front();
q.pop_front();
int a = t.first, b = t.second; // 当前节点的坐标(a,b)
// 如果当前节点已经访问过,跳过(避免重复处理)
if (st[a][b]) continue;
// 标记当前节点为已访问(已确定最短距离)
st[a][b] = true;
// 如果当前格子是检测器,更新答案ret
// 因为我们要的是到所有检测器的最远距离
if (g[a][b] == '#') ret = max(ret, dist[a][b]);
// 遍历当前节点的四个邻居(上下左右)
for (int k = 0; k < 4; k++) {
int x = a + dx[k], y = b + dy[k]; // 邻居坐标
// 边界检查:越界或已访问的邻居跳过
if (x < 0 || x >= 2 || y < 0 || y >= n || st[x][y]) continue;
// 计算移动到邻居的代价:
// 如果邻居是检测器,代价为0(不需要添加检测器)
// 如果邻居是空白,代价为1(需要添加一个检测器)
int w = g[x][y] == '#' ? 0 : 1;
// 更新邻居的最短距离
// 如果通过当前节点到达邻居的距离更短,则更新
if (dist[a][b] + w < dist[x][y]) {
dist[x][y] = dist[a][b] + w;
// 0-1 BFS的核心:根据权重决定插入队列的位置
// 权重为0(检测器)插入队首,优先处理(类似高优先级)
// 权重为1(空白)插入队尾,稍后处理(类似低优先级)
if (w) {
q.push_back({ x, y }); // 权重为1,放入队尾
}
else {
q.push_front({ x, y }); // 权重为0,放入队首
}
}
}
}
// 返回从起点到所有检测器的最大最短距离
return ret;
}
int main() {
// 读取两行网格
cin >> g[0] >> g[1];
// 计算网格列数(假设两行长度相同)
n = strlen(g[0]);
// 从左到右扫描,寻找第一个检测器作为BFS的起点
// 因为0-1 BFS的起点可以是任意一个检测器
for (int j = 0; j < n; j++) {
// 先检查第一行
if (g[0][j] == '#') {
cout << bfs(0, j) << endl;
return 0;
}
// 再检查第二行
if (g[1][j] == '#') {
cout << bfs(1, j) << endl;
return 0;
}
}
// 特殊情况:网格中没有检测器,不需要添加任何检测器
cout << 0 << endl;
return 0;
}
十、算法总结
0-1 BFS算法的精妙之处在于:
-
简单实现复杂功能:用双端队列实现了优先级队列
-
完美融合多种思想:贪心、BFS、Dijkstra
-
高效实用:时间复杂度O(n),空间复杂度O(n)
-
易于实现:代码简洁,逻辑清晰
十一、思考过程总结
解决这个问题的完整思考过程:
-
理解问题:明确要连接所有检测器,最小化添加数
-
抽象建模:将网格转化为图,定义边权
-
识别特性:发现边权只有0和1
-
贪心策略:优先走检测器,避免绕路
-
算法选择:0-1 BFS
-
数据结构:双端队列实现优先级
-
机制设计:距离更新和出队确定
-
正确性证明:数学证明算法正确
-
复杂度分析:确保算法效率
-
实现验证:代码实现,测试验证
结语
0-1 BFS算法展示了如何从实际问题出发,通过深入分析和巧妙设计,得到高效优雅的解决方案。这不仅是一个算法的学习,更是一种思维方法的训练。希望通过这篇文章,你能完全理解这个算法,并在未来的算法学习中运用这种系统性的思考方法。