华为OD机试“水库溃坝填补”题解:图论建模与BFS/DFS实战
1. 项目概述与核心需求解析
最近在准备华为OD机试的朋友,特别是瞄准2025年双机位A卷C++方向的,应该都注意到了“水库溃坝填补”这道题。乍一看标题,可能会觉得这像是一道复杂的物理模拟或工程计算题,心里先咯噔一下。但根据我对历年华为OD机试题目的拆解经验,这类题目往往“标题唬人,内核清晰”,它本质上是一个 图论或动态规划 的经典问题,披上了一层现实场景的外衣。这道题的核心,绝对不是让你去写一个流体力学仿真,而是考察你在有限时间内,如何将实际问题抽象为计算机可解的模型,并用高效的算法实现。
简单来说,“水库溃坝填补”描述的场景是:有一个由网格表示的水库大坝(或者堤坝区域),某些网格点因为“溃坝”出现了缺口(可以理解为值为0或一个特殊标记),而你有有限的“填补材料”(可能是土石方,在题目里通常体现为一个数值或一种资源)。你的任务是,用最少的步骤、最优的策略,或者在不违反某些规则的前提下(比如填补必须连续、填补后不能产生新的薄弱点等),将这些缺口全部填补上,使得整个大坝恢复“完整”或达到某种安全状态。题目真正的考点,就藏在对“填补”规则的定义、对“最优”策略的衡量以及对边界条件的处理之中。它适合所有正在备战华为OD,尤其是对C++算法和数据结构有基本掌握,但需要提升实际问题建模能力的同学。接下来,我会彻底拆解这道题可能涉及的几种主流思路、C++实现的关键细节,以及如何在双机位监考环境下高效、稳定地完成编码。
2. 核心思路与算法选型分析
面对“水库溃坝填补”,我们首先要做的是 问题抽象 。大坝网格最自然的表示就是一个二维矩阵。缺口是矩阵中一些特定的点。填补操作,根据题目描述的不同,可能对应以下几种经典模型:
2.1 可能的模型一:连通区域填充(BFS/DFS) 这是最直观的想法。把每个缺口点看作需要被“覆盖”的点。填补材料可能以“单位方块”的形式,一次操作可以填补一个点。但如果题目要求“填补操作必须从边缘开始,或填补后的区域必须保持连通”,这就变成了一个 寻找最小覆盖或最优填充顺序 的问题。例如,缺口可能形成多个分散的“坑洞”,你需要决定先填哪个坑、从坑的哪个位置开始填,才能用最少的材料或步骤连通所有需要填补的区域。此时,算法核心是 图的遍历(BFS/DFS) ,用于搜索缺口形成的连通分量,并计算每个分量的“填补成本”(如周长、面积、到边缘的距离等)。解题关键在于如何定义状态和设计BFS/DFS的扩展规则。
2.2 可能的模型二:最短路径覆盖(Dijkstra 或 BFS for Multi-source) 如果“填补”被定义为一种从某个“材料仓库”(源点)出发,向缺口点“运输”材料的过程,并且每次移动/填补有代价(比如距离越远代价越高),那么问题就变成了 多源点最短路径 问题。我们可以将所有缺口点视为目标点,将材料源点(可能一个或多个)作为起点,使用Dijkstra算法(如果移动代价不同)或BFS(如果每步代价相同)计算每个缺口点被覆盖的最短距离。最终答案可能是所有缺口点被覆盖的最大距离(最小化最远距离),或者是所有距离之和(最小化总成本)。这要求考生熟练实现堆优化的Dijkstra算法。
2.3 可能的模型三:动态规划(DP) 如果大坝是“一维”的(比如一道长堤),或者填补操作具有强烈的后效性(例如,填补当前位置的花费取决于前一个位置是否被填补),那么动态规划就可能派上用场。我们可以定义 dp[i][state] ,表示处理到第 i 个位置、且当前状态为 state (例如,前一个位置是否已填补、当前连续填补了多少单位等)时的最小代价。状态转移方程需要根据具体的填补规则来设计。这种思路对状态设计和转移方程的推导能力要求较高。
2.4 模型选择与结合 在实际的OD机试中,题目往往会简化。结合“水库”、“填补”这些关键词和常见的考察点,我推测 模型一(连通区域处理) 和 模型二(最短路径类) 的可能性最大。双机位A卷通常不会在最难的动态规划上设置过于复杂的障碍,但一定会考察对二维网格的处理、对搜索算法的灵活运用以及对边界条件的周全考虑。
注意: 在真正的考场上,拿到题目后,前5-10分钟必须用于仔细阅读输入输出格式、数据范围以及问题描述中的每一个约束条件。数据范围(如网格大小N、M,缺口数量K)直接决定了算法复杂度的上限,是选择BFS还是DFS,是否需要剪枝的关键依据。
3. 基于BFS/DFS的连通区域填补实现详解
我们假设一个最可能出现的题目变体:给定一个N x M的网格,0代表完好,1代表缺口。你每次操作可以选择一个缺口点进行填补,将其从1变为0。但是,填补操作必须保证操作后,该点所在的 连通缺口区域 (上下左右四个方向相连的1构成一个区域)不能有任意一点与网格边界相连(即,所有缺口必须被“包围”在完好区域内才能被填补)。你需要计算至少需要多少次操作,才能填补所有 可以被填补 的缺口。
3.1 问题分析与思路 这道题的关键在于理解“可以被填补”的条件:一个缺口连通分量,如果其中任何一个点接触到了网格的边界,那么整个分量都无法被填补(因为从边界“溃坝”了,无法从内部修复?或者材料无法送达)。只有那些完全被0(完好区域)包围的缺口区域,才能被逐一填补。 因此,解题步骤清晰了:
- 遍历整个网格。
- 当遇到一个值为1(缺口)且未被访问过的点时,以其为起点进行BFS或DFS,探索整个连通分量。
- 在探索过程中,检查是否有节点到达了网格的边界(即
i==0或i==N-1或j==0或j==M-1)。 - 如果整个连通分量没有任何一个点在边界上,那么这个分量就是“可填补的”,其包含的1的数量就是需要填补的操作次数。
- 累加所有“可填补”连通分量的大小,即为答案。
3.2 C++代码实现与逐行解析 下面给出基于BFS的C++实现。DFS递归实现代码更简洁,但在网格很大时有栈溢出风险,BFS更稳妥。
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
// 方向数组,表示上、下、左、右四个移动方向
const vector<pair<int, int>> directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int main() {
int N, M;
cin >> N >> M; // 假设第一行输入网格尺寸
vector<vector<int>> grid(N, vector<int>(M));
// 读入网格数据
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
cin >> grid[i][j];
}
}
vector<vector<bool>> visited(N, vector<bool>(M, false));
int totalFillOperations = 0;
// 遍历网格中的每一个点
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
// 如果当前点是缺口(1)且未被访问过,开始探索一个新的连通区域
if (grid[i][j] == 1 && !visited[i][j]) {
queue<pair<int, int>> q;
q.push({i, j});
visited[i][j] = true;
bool isSurrounded = true; // 假设该区域最初是被包围的
int componentSize = 0; // 当前连通区域的大小
// BFS遍历开始
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
componentSize++;
// 关键检查:如果当前点在任何边界上,则整个区域不是被包围的
if (x == 0 || x == N-1 || y == 0 || y == M-1) {
isSurrounded = false;
// 注意:这里不能直接break,因为我们需要标记完整个区域的所有点为visited,避免后续重复访问。
// 但我们已经知道这个区域无效了。
}
// 向四个方向探索邻居
for (auto& dir : directions) {
int nx = x + dir.first;
int ny = y + dir.second;
// 检查邻居是否在网格内、是否是缺口(1)、且未被访问
if (nx >= 0 && nx < N && ny >= 0 && ny < M &&
grid[nx][ny] == 1 && !visited[nx][ny]) {
visited[nx][ny] = true;
q.push({nx, ny});
}
}
} // BFS结束
// 如果这个缺口区域完全被包围(不接触边界),则其大小就是需要填补的操作次数
if (isSurrounded) {
totalFillOperations += componentSize;
}
}
}
}
cout << totalFillOperations << endl;
return 0;
}
代码关键点解析:
-
visited数组 :这是图遍历算法的标配,用于防止重复访问和陷入死循环。在网格问题中,它是一个与grid等大的二维布尔数组。 - 方向数组
directions:用数组统一管理移动方向,比写四个if语句更清晰、不易出错。这是处理网格类问题的经典技巧。 -
isSurrounded标志 :这个布尔变量是本题逻辑的核心。它在BFS开始时设为true,一旦发现区域中有点在边界上,就置为false。它代表的是 整个连通区域 的属性。 - BFS内部的边界检查 :在弹出队列节点
(x, y)后立即检查是否在边界。即使发现了边界点,BFS仍需继续完成遍历,以确保该区域所有点都被标记为visited,否则主循环可能会再次进入这个区域的一部分,导致逻辑错误和重复计算。 - 邻居探索条件 :
if (nx >= 0 && nx < N && ny >= 0 && ny < M && ...)这是网格DFS/BFS的 防越界检查 ,必须写在最前面,利用逻辑运算符&&的短路特性,避免访问非法内存。
3.3 复杂度分析与优化点
- 时间复杂度 :O(N * M)。每个网格点最多被访问一次(通过
visited数组保证)。 - 空间复杂度 :O(N * M),主要用于
visited数组和BFS队列。在最坏情况下(整个网格都是一个连通区域),队列可能存储O(N*M)个点。 - 优化点 :对于非常大的网格,可以考虑“标记法”,直接修改原
grid数组,将访问过的缺口点从1改为另一个值(如2),从而省去visited数组的空间。但要注意,如果原数据后续还有用,或者题目不允许修改输入,则不能使用此方法。
4. 基于多源BFS的最短路径填补模型
现在考虑另一种可能:假设有多个“材料投放点”(源点),位于网格的某些特定位置(可能是边界,也可能是内部完好区域)。填补一个缺口点的代价等于从最近的源点到该点的最短路径长度(每一步移动代价为1)。你需要计算,为了覆盖所有缺口点,所需要的 最小最大距离 (即最小化距离最远的那个缺口点的距离)。这实际上是 多源BFS 求每个点到最近源点的距离,然后取所有缺口点距离的最大值。
4.1 算法思路
- 初始化一个距离矩阵
dist,大小同网格,所有值设为-1(表示未到达)。 - 将所有“材料投放点”(源点)加入BFS队列,并将其
dist值设为0。 - 执行标准的BFS。每次从队列取出一个点,检查其四个邻居。如果邻居是合法的网格点且
dist为-1(未访问过),则将其dist更新为当前点距离+1,并加入队列。 - BFS结束后,
dist矩阵就存储了每个网格点到最近源点的最短距离。 - 遍历所有缺口点(值为1的点),找出它们
dist值中的最大值。这个最大值就是答案。如果存在缺口点的dist为-1(表示无法从任何源点到达),则根据题意可能需要返回-1或进行特殊处理。
4.2 C++代码实现
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const vector<pair<int, int>> dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int main() {
int N, M;
cin >> N >> M;
vector<vector<int>> grid(N, vector<int>(M));
vector<vector<int>> dist(N, vector<int>(M, -1));
queue<pair<int, int>> q;
// 读入网格,并初始化源点
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
cin >> grid[i][j];
// 假设源点用2表示,或者题目另有说明。这里假设输入中源点为2。
if (grid[i][j] == 2) {
q.push({i, j});
dist[i][j] = 0; // 源点距离为0
}
}
}
// 多源BFS
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
for (auto& dir : dirs) {
int nx = x + dir.first;
int ny = y + dir.second;
if (nx >= 0 && nx < N && ny >= 0 && ny < M && dist[nx][ny] == -1) {
// 这里可以加上对地形障碍的判断,例如 grid[nx][ny] == 0 代表不能通过
// 假设所有点都可以通行
dist[nx][ny] = dist[x][y] + 1;
q.push({nx, ny});
}
}
}
// 计算所有缺口点(值为1)的最大距离
int maxDistForGaps = 0;
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
if (grid[i][j] == 1) {
if (dist[i][j] == -1) {
// 存在无法到达的缺口,根据题目要求处理,这里假设返回-1
cout << -1 << endl;
return 0;
}
maxDistForGaps = max(maxDistForGaps, dist[i][j]);
}
}
}
cout << maxDistForGaps << endl;
return 0;
}
4.3 模型变体与思考
- 变体一:总代价最小化 :如果不求最小化最大距离,而是求所有缺口点距离之和的最小值,那么算法主体不变,最后累加所有缺口点的
dist值即可。但需要注意,如果源点位置是固定的,这个总和也是固定的。只有当你可以选择投放点位置时,问题才变成更复杂的设施选址问题。 - 变体二:有障碍物 :如果网格中存在障碍物(比如值-1表示无法通过),那么在BFS扩展邻居时,需要增加条件
grid[nx][ny] != -1。 - 与“水库溃坝”场景的结合 :在这个模型下,“源点”可以理解为完好的、坚固的坝体部分或材料仓库。“距离”可以理解为材料运输或加固效应传播的时间。题目可能要求在所有缺口被“加固”(覆盖)之前,溃坝风险不能扩大,因此需要最小化最远缺口被处理的时间。
实操心得: 多源BFS的初始化是关键。一定要把所有源点 同时 放入队列,并且它们的初始距离都设为0。这样BFS第一次扩展时,相当于所有源点同时向外“扩散”,从而自然计算出每个点到最近源点的距离。如果错误地逐个对源点做单源BFS,再取最小值,会严重超时。
5. 输入输出处理与边界条件实战
机试题目不仅考算法,更考 工程实现细节 。输入输出处理不当、边界条件考虑不周,是大量丢分的主要原因。
5.1 输入格式的多样性处理 题目不会总是 cin >> N >> M 然后接矩阵那么简单。常见的“坑点”包括:
- 不规则输入 :每行数据可能用空格分隔,也可能用逗号分隔。一定要根据题目描述使用
cin配合char过滤逗号,或者用getline和stringstream进行分割。// 假设输入是"1,0,1,0" string line; getline(cin, line); // 读取一行 stringstream ss(line); vector<int> row; int num; char comma; while (ss >> num) { row.push_back(num); if (ss >> comma) { // 尝试读取逗号,如果失败说明读到行尾 // 继续 } } - 大矩阵输入 :如果N和M很大(比如
10^3量级),使用cout << endl频繁换行输出可能会导致超时。可以考虑使用'\n'代替endl,因为endl会刷新输出缓冲区,更耗时。对于最终结果,一次性构建好输出字符串或直接输出即可。 - 多组测试数据 :题目可能包含T组测试。务必使用
while (T--)或while (cin >> N)这样的循环结构完整处理每一组数据,并且 每组数据开始前,要清空或重新初始化全局变量、容器 !这是新手极易忽略的点。int T; cin >> T; while (T--) { int N, M; cin >> N >> M; vector<vector<int>> grid(N, vector<int>(M)); // 局部变量,每次循环新建 // ... 处理逻辑 // ... 输出结果 }
5.2 边界条件与特殊场景
- 空网格或零缺口 :如果N或M为0,或者网格中根本没有缺口(全0),你的程序是否能正确处理?对于连通区域模型,答案应该是0;对于最短路径模型,如果缺口列表为空,最大距离可能是0或需要特殊输出。
- 所有缺口都在边界 :在连通区域模型中,如果所有缺口区域都接触边界,那么可填补的操作数应为0。
- 源点就是缺口点 :在多源BFS模型中,如果某个源点本身也是一个需要被覆盖的缺口(即该点既是源点,grid值又是1),那么它的距离应该是0。我们的代码逻辑通常能正确处理,因为我们在初始化时已经将源点的
dist设为0。 - 无法到达的情况 :在最短路径模型中,必须考虑是否存在缺口点无法从任何源点到达。这是常见的输出
-1的场景。
5.3 双机位环境下的编程习惯 双机位意味着严格的监考环境,任何试图查阅外部资料或复制粘贴的行为都会被系统记录。因此:
- 提前准备好模板 :在本地IDE中准备好常用的代码模板,包括快速输入输出、方向数组、常用STL容器声明等。考试时,在允许的编辑器中快速敲入或使用系统可能提供的代码片段功能。
// 我的常用开头模板 #include <bits/stdc++.h> // 有些在线判题系统支持,但华为OD环境不一定。最稳妥是引入具体头文件。 using namespace std; #define rep(i, a, b) for(int i = a; i < (b); ++i) // 谨慎使用宏,确保自己完全理解 typedef long long ll; const int INF = 0x3f3f3f3f; - 模块化与注释 :将BFS/DFS封装成函数。即使时间紧张,也要写清关键步骤的注释。这不仅能帮助自己理清思路,万一调试时出现问题,也更容易定位。
int bfs(int sx, int sy, vector<vector<int>>& grid, vector<vector<bool>>& visited) { // 函数功能:从(sx, sy)开始BFS,返回连通分量大小,并通过引用参数返回是否接触边界 // ... } - 先写伪代码,再填充 :如果题目较复杂,不要急于动手。在草稿纸或代码注释区先写下核心步骤的伪代码,确保逻辑无误后再转化为C++代码,能有效减少反复修改调试的时间。
6. 调试技巧与常见“坑点”实录
即便思路正确,实现时也难免踩坑。以下是我在解决这类网格搜索问题时总结的常见错误和调试方法。
6.1 访问标记与状态重置
- 坑点 :在有多组测试数据时,忘记重置
visited、dist等全局或静态数组,导致上一组数据污染下一组。 - 排查 :输出每组数据计算前的
visited数组或dist数组的初始状态,看是否为预期值(全false或全-1)。 - 解决 : 最佳实践是将这些数组声明在每组测试的循环内部 ,作为局部变量。如果必须用全局变量,则在每组数据开始处使用
memset或fill进行O(N*M)的重置。
6.2 方向数组与越界检查
- 坑点 :方向数组写错,漏掉某个方向;越界检查
nx < N错写成nx <= N。 - 排查 :当程序输出结果明显偏小或发生运行时错误(如数组越界)时,首先怀疑BFS/DFS的扩展部分。可以添加调试输出,打印每次探索的
(nx, ny)坐标。 - 解决 :使用标准的四方向/八方向数组。越界检查必须是
>=0和< N,这是固定模式,形成肌肉记忆。
6.3 队列操作与状态更新顺序
- 坑点 :在将新节点
(nx, ny)加入队列 之前 ,忘记更新其visited或dist状态。这可能导致同一个节点被多次加入队列,造成逻辑错误、性能下降甚至死循环。 - 排查 :在
q.push之前打印(nx, ny)及其新状态,确认状态已更新。 - 解决 :严格遵守“发现新节点 -> 更新其状态 -> 将其入队”的顺序。这是一个必须养成的习惯。
6.4 数据类型与溢出
- 坑点 :网格很大时,连通分量的大小或距离之和可能超过
int范围(约21亿)。例如,N=M=1000,全网格都是可填补缺口,操作次数是10^6,仍在int内。但如果涉及累加多次BFS的结果,或者距离值很大,就需要使用long long。 - 排查 :计算数据范围。如果
N*M在10^5量级以上,且可能全为有效值,考虑使用long long。 - 解决 :在定义存储答案的变量(如
totalFillOperations,maxDist)时,如果不确定,直接用long long。空间换安全。
6.5 题目理解偏差 这是最致命的“坑”。例如,题目可能规定“填补操作只能从当前已填补的区域向相邻缺口扩展”,这就变成了一个类似“感染”或“洪水填充”的过程,需要模拟每一步的扩散,而不是简单地计算连通分量大小。或者,“填补材料有限”,要求在材料约束下最大化填补面积,这就变成了背包或贪心问题。
- 排查 :重新逐字阅读题目描述,特别是输入输出样例。手动模拟样例数据,看自己的算法输出是否与样例一致。如果不一致,仔细对比中间每一步的结果。
- 解决 : 务必手动模拟样例! 这是调试和理解题意的黄金法则。在代码中插入打印,输出关键步骤的中间变量,与你的手动模拟过程进行比对。
7. 性能优化与代码简洁之道
对于机试,在保证正确性的前提下,追求代码的清晰、简洁和一定的鲁棒性,比追求极致的性能优化更重要。但了解一些优化技巧,能让你在面对大数据时更有底气。
7.1 输入输出加速 在C++中,默认的 cin/cout 与 scanf/printf 同步,且 cout 默认绑定到 cin ,这会导致一些性能开销。在需要处理大量输入(如 10^5 以上)时,可以加入以下代码加速:
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
注意,一旦使用了这段代码,就 不能 再混用 cin/cout 和 scanf/printf ,否则可能导致输入输出顺序错乱。
7.2 使用数组代替vector(谨慎) 对于固定大小的网格,使用原生二维数组 int grid[1005][1005] 在栈上分配,其访问速度通常略快于 vector<vector<int>> 在堆上的分配。但需要注意栈空间限制(通常约1-8MB)。如果 N*M 超过 10^6 ,使用大数组可能导致栈溢出。 vector 更灵活安全。机试中,除非明确知道网格很小,否则用 vector 是更稳妥的选择。
7.3 BFS/DFS的细微选择
- BFS :使用队列,适合求最短路径、层次遍历。空间复杂度取决于队列最大长度。
- DFS :递归实现代码短,但存在栈溢出风险;迭代实现(用栈)稍复杂。适合遍历所有路径、判断连通性。
- 在单纯的连通分量计数或标记场景,两者时间复杂度相同。根据个人习惯和题目对递归深度的潜在要求选择。
7.4 代码简洁性技巧
- 使用
auto和范围for循环 :for (auto& row : grid)和for (auto& dir : directions)能让代码更干净。 - 使用
pair和结构化绑定(C++17) :auto [x, y] = q.front();比int x = q.front().first; int y = q.front().second;简洁得多。确保在线环境支持C++17。 - 将通用操作封装为函数 :比如判断坐标是否在网格内的函数
inline bool inGrid(int x, int y),能减少重复代码和错误。
最后,关于“水库溃坝填补”这道题,它更像一个载体,考察的是你面对陌生问题描述时的抽象能力、对基础图论算法的掌握程度,以及编写健壮代码的工程能力。在备考时,与其死记硬背特定题解,不如把 网格上的DFS/BFS、多源BFS、 Flood Fill 这几个经典模板练到纯熟,并养成仔细审题、手动模拟样例、严谨处理输入输出的好习惯。在双机位的紧张环境下,稳定的发挥就来自于这些扎实的基本功和清晰的解题套路。
更多推荐


所有评论(0)