☰
华为OD机考数值同化题详解:BFS连通块搜索与五种语言实战
2026/10/10 9:05:58 网站建设 项目流程

华为OD机考C卷里,有一道题被很多考过的人挂在嘴边:返回矩阵中非1的元素个数,也叫数值同化。我第一次看到这个题目名字的时候有点懵,直到在双机位监控下敲完代码才意识到,这道题表面问的是“数数”,实际考的是你脑子里有没有一套完整的连通块搜索模型。考过的朋友都知道,双机位考试意味着全程不能查资料、不能开小差,代码编辑器也没有智能提示,这时候能不能在半小时内把BFS/DFS的架子默写出来,直接决定你这道题是拿满分还是零分。

这篇文章我不打算只贴一份题解,那没什么意思。我会把题目从本质拆开讲透,然后给出Java、Python、JS、C++、C这五种语言的完整实现,再把我实际调试过程中踩过的坑、总结的排查方法一起倒出来。无论你是刚开始刷华为OD机试的新手,还是已经会写一点算法但总在细节上翻车的老手,这篇都能让你少走几天弯路。下面直接进入正题。

1. 题目本质与考点拆解

1.1 “非1元素个数”到底在问什么

只看题目名称,很多人会以为这是一道白给题:遍历矩阵,数一下不等于1的格子有几个,两层循环就出来了。如果你真的这么交,大概率会掉进陷阱。因为这道题的完整场景里,关键不在“数数”,而在“哪些格子最终会变为非1状态”。

我按最常见的考试版本给你还原一下场景:有一个m行n列的矩阵,格子里的值只有0和1,0表示可同化区域,1表示障碍或者已固化区域。现在从某个初始点出发,执行“数值同化”——起点变成非1状态,然后向上下左右四个方向扩散,把相邻的、值为0的格子也同化为非1状态,直到这个连通区域扩散不动为止。最后要求返回矩阵中非1元素的个数,也就是被同化区域的格子总数。

换句话说,如果你只统计原始矩阵里非1的格子,那结果和同化过程毫无关系;真正的考点是“从起点能扩散到多少个格子”。这个扩散过程,在算法领域有个很经典的名字叫Flood Fill(洪水填充),你在力扣上见过的岛屿数量、岛屿最大面积、被围绕的区域,全都是它的近亲。理解了这一层,题目就从一个“统计题”变成了“搜索题”,你的解题思路才会落在正确的方向上。

1.2 数值同化的本质是连通块计数

把“同化”两个字翻译成算法语言,其实就是找四连通块。所谓四连通,就是每个格子只能影响上下左右四个邻居,对角线方向不算。你从起点出发,沿着值为0的格子一路扩展,走过的所有格子组成一个连通块,答案就是这个连通块的面积。

需要注意的是,题目里的1充当的是“墙”的角色。墙不能被同化,也不能穿越。这就好比你在一个房间里用拖把拖地,0是地板,1是家具,你只能拖家具围出来的那片区域,家具底下永远拖不到。这个类比虽然朴素,但能把题目的约束条件解释得很清楚。

这类题的核心考法就三个:第一,能不能正确建模为图上搜索;第二,能不能处理好去重(一个格子不能被同化两次);第三,在边界条件和输入格式上会不会翻车。华为OD的C卷在不同批次里出现过多种变体,比如有的版本从左上角(0,0)开始扩散,有的版本会额外给起点坐标,还有的版本要求统计所有从边界能扩散到的非1格子。不管怎么变,底层都是同一个模型,你只要把基础版本吃透,变体无非是换起点、换方向集合、换返回值形式。

2. 核心算法思路与方案选型

2.1 BFS和DFS怎么选:三种情况一句话判断

连通块计数有两种经典实现:广度优先搜索(BFS)和深度优先搜索(DFS)。这两个方案都能求出正确答案,但在机考场景下,我的建议非常明确:优先写BFS,除非你明确知道矩阵规模很小并且DFS递归深度不会爆栈。

为什么这么建议?因为DFS本质上是递归,递归深度等于连通块的大小。如果一个矩阵是1000×1000而且全为0,从左上角开始扩散,DFS的递归深度可能接近100万层。C++和Java的默认栈空间根本扛不住,Python更是需要手动调高递归上限,而考试环境里你大概率没有权限改这些设置。BFS用队列实现,没有递归深度问题,每一个格子最多入队一次,内存占用是可控的,对机考来说稳得多。

如果你的DFS功底确实好,或者你判断题目矩阵很小(比如50×50以内),用DFS也能过。但我不建议在考场上赌这个。我自己第一次写这道题用的就是DFS,样例全过,结果换到大矩阵测试用例直接栈溢出,那个教训至今难忘。所以后面所有版本的代码我都统一用BFS,逻辑简单、无栈风险、方便调试。这道题的时间复杂度是O(m×n),因为每个格子最多被访问一次;空间复杂度也是O(m×n),最坏情况下队列里可能同时存下大量待扩散的格子。

2.2 去重策略:原地标记比visited数组更省事

连通块搜索最大的坑就是重复访问。没有去重的话,A格子扩散到B,B又扩散回A,两个格子互相入队,死循环直接拖垮程序。常见的去重方案有两种:一是单独开一个visited二维数组记录是否访问过;二是直接修改原矩阵,把已访问的0改成2或者其他非0非1的值。

两种方案都能用,但我强烈推荐第二种,也就是原地标记。原因很直接:少维护一个数组,代码量更少,出错概率更低。你把已经同化的格子值改成2,后续访问时只要判断当前格子是不是0,不是0就跳过。这样一来,原矩阵本身既是数据源又是访问标记,不需要额外空间,也不容易漏判。

这里有一个非常关键的细节:**标记动作应该发生在入队的时候,而不是出队的时候。**如果你在出队时才标记,那么同一个格子可能被多个邻居同时判断为“未访问”并重复入队,计数就会偏大。正确的顺序是:判断邻居值为0 -> 立刻改成2 -> 计数器加1 -> 入队。这一个顺序问题,是我见过最多的错误来源,后面排查章节我会再展开讲。

3. 五种语言的完整实现与逐段解析

3.1 Java版本:ArrayDeque的性能优势与常见写法

Java版本是很多备考者的首选,因为华为OD机考支持Java,而且Java在工程场景里最常用。直接看代码:

import java.util.ArrayDeque; import java.util.Scanner; public class Main { static int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; public static void main(String[] args) { Scanner sc = new Scanner(System.in); int m = sc.nextInt(); int n = sc.nextInt(); int[][] grid = new int[m][n]; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { grid[i][j] = sc.nextInt(); } } int sx = sc.nextInt(); int sy = sc.nextInt(); System.out.println(floodFillCount(grid, sx, sy)); } static int floodFillCount(int[][] grid, int sx, int sy) { int m = grid.length; int n = grid[0].length; if (grid[sx][sy] != 0) { return 0; } ArrayDeque<int[]> queue = new ArrayDeque<>(); queue.offer(new int[]{sx, sy}); grid[sx][sy] = 2; int count = 1; while (!queue.isEmpty()) { int[] cur = queue.poll(); for (int[] d : dirs) { int nx = cur[0] + d[0]; int ny = cur[1] + d[1]; if (nx < 0 || nx >= m || ny < 0 || ny >= n) { continue; } if (grid[nx][ny] != 0) { continue; } grid[nx][ny] = 2; count++; queue.offer(new int[]{nx, ny}); } } return count; } }

Java版有三个要点。第一,队列用ArrayDeque而不是LinkedList。LinkedList虽然也能当队列,但内部是链表结构,节点频繁创建销毁的性能比不过ArrayDeque,在大矩阵下差距很明显。第二,坐标系是行和列,很多人习惯性写成(x, y)然后和(n, m)搞混,这里建议统一用sx、sy代表行和列,越界判断也按行优先来写。第三,起点本身如果是1,说明起点就是障碍物,无法扩散,直接返回0,这个特判不能省。

3.2 Python版本:用deque避开性能陷阱

Python写起来是最短的,但有几个地方容易踩坑。很多人图省事用list的pop(0)模拟队列,这在数据量大的时候是灾难,因为pop(0)需要把后面所有元素往前挪,时间复杂度是O(n),一个测试用例可能直接超时。正确做法是使用collections.deque,它的popleft是O(1)的。

import sys from collections import deque def flood_fill_count(grid, sx, sy): m, n = len(grid), len(grid[0]) if grid[sx][sy] != 0: return 0 q = deque() q.append((sx, sy)) grid[sx][sy] = 2 dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)] count = 1 while q: x, y = q.popleft() for dx, dy in dirs: nx, ny = x + dx, y + dy if 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == 0: grid[nx][ny] = 2 count += 1 q.append((nx, ny)) return count def main(): data = sys.stdin.read().strip().split() if not data: return idx = 0 m, n = int(data[idx]), int(data[idx + 1]) idx += 2 grid = [] for _ in range(m): row = [] for _ in range(n): row.append(int(data[idx])) idx += 1 grid.append(row) sx, sy = int(data[idx]), int(data[idx + 1]) print(flood_fill_count(grid, sx, sy)) if __name__ == "__main__": main()

Python版我最想强调两件事。第一,输入解析用sys.stdin.read().strip().split()一次性读完全部内容,再按索引取数,比逐行调用input()要快很多,在数据量大的时候可以省下不少时间。第二,Python的元组解包在循环里很方便,但你如果追求极致性能,可以只存一个整数表示位置(比如x * n + y),需要坐标时再做除法取出行列,不过机考一般用不上这个优化。新手容易犯的错是把二维数组的行列索引搞反,记住grid[x][y]里第一个下标是行,对应上下方向,第二个下标是列,对应左右方向。

3.3 JavaScript版本:用索引指针替代shift模拟队列

JS在华为OD机考里也是可选的,但很多前端同学写算法题时会遇到一个非常尴尬的问题:数组的shift方法虽然能实现先进先出,但它的底层也是O(n)的移动操作,大矩阵测试用例下性能惨不忍睹。解决办法是用“头指针”模拟队列:数组只管push,另外用一个head变量记录当前读取位置。

const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin }); const lines = []; rl.on('line', (line) => lines.push(line)); rl.on('close', () => { let idx = 0; const [m, n] = lines[idx++].trim().split(/\s+/).map(Number); const grid = []; for (let i = 0; i < m; i++) { grid.push(lines[idx++].trim().split(/\s+/).map(Number)); } const [sx, sy] = lines[idx++].trim().split(/\s+/).map(Number); console.log(floodFillCount(grid, sx, sy)); }); function floodFillCount(grid, sx, sy) { const m = grid.length; const n = grid[0].length; if (grid[sx][sy] !== 0) return 0; const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; const q = []; let head = 0; q.push([sx, sy]); grid[sx][sy] = 2; let count = 1; while (head < q.length) { const [x, y] = q[head++]; for (const [dx, dy] of dirs) { const nx = x + dx; const ny = y + dy; if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] === 0) { grid[nx][ny] = 2; count++; q.push([nx, ny]); } } } return count; }

JS版的核心是head指针的用法:q[head++]取出元素后,head向后移动,但数组里被跳过的元素依然占着位置,所以while的终止条件是head < q.length而不是q.length不为0。这个写法省去了shift的开销,性能上和Java的ArrayDeque接近。如果你实在不习惯索引指针,也可以用两个队列来回倒,但没必要,索引指针是更标准的解法。另外输入解析那里,readline逐行收集,最后统一处理,比在每行触发时立刻解析要稳,因为有些测试数据的格式可能让你无法确定哪一行是最后一行。

3.4 C++版本:vector嵌套与pair让代码干净利落

C++版本是五种语言里我自己写起来最顺手的,因为STL的queue和pair组合起来非常简洁。需要注意的是,C++的vector<vector >可以动态处理矩阵大小,不需要像C语言那样预先设定MAXN,这对不确定矩阵边界的题目很友好。

#include <bits/stdc++.h> using namespace std; int main() { int m, n; cin >> m >> n; vector<vector<int>> grid(m, vector<int>(n)); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { cin >> grid[i][j]; } } int sx, sy; cin >> sx >> sy; if (grid[sx][sy] != 0) { cout << 0 << endl; return 0; } queue<pair<int, int>> q; q.push({sx, sy}); grid[sx][sy] = 2; int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; int count = 1; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (auto& d : dirs) { int nx = x + d[0]; int ny = y + d[1]; if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue; if (grid[nx][ny] != 0) continue; grid[nx][ny] = 2; count++; q.push({nx, ny}); } } cout << count << endl; return 0; }

C++里有一个大家常忽略的细节:dirs数组虽然是个二维数组,但用auto& d来遍历时,d的类型是int[2],可以直接用d[0]和d[1]访问。如果你不小心写成for (auto d : dirs),数组会退化成指针,有时候会引发编译警告,虽然机考环境下不影响运行,但养成用引用的习惯更专业。另外,如果你用#include <bits/stdc++.h>,在GCC环境下没问题,但有些老版本OJ只认标准头文件,稳妥起见可以改成#include 、#include 、#include ,反正考试时复制题目提供的编译指令就行。

3.5 C语言版本:手写循环队列才是C的灵魂

C语言没有STL,队列需要自己实现。这里我推荐数组模拟循环队列,初始容量给到m*n+5就足够,因为每个格子最多入队一次,队列里同时存在的元素不可能超过格子总数。

#include <stdio.h> #define MAXN 1005 int grid[MAXN][MAXN]; int qx[MAXN * MAXN]; int qy[MAXN * MAXN]; int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; int main() { int m, n; scanf("%d%d", &m, &n); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { scanf("%d", &grid[i][j]); } } int sx, sy; scanf("%d%d", &sx, &sy); if (grid[sx][sy] != 0) { printf("0\n"); return 0; } int head = 0, tail = 0; qx[tail] = sx; qy[tail++] = sy; grid[sx][sy] = 2; int count = 1; while (head < tail) { int x = qx[head]; int y = qy[head++]; for (int k = 0; k < 4; k++) { int nx = x + dirs[k][0]; int ny = y + dirs[k][1]; if (nx < 0 || nx >= m || ny < 0 || ny >= n) { continue; } if (grid[nx][ny] != 0) { continue; } grid[nx][ny] = 2; count++; qx[tail] = nx; qy[tail++] = ny; } } printf("%d\n", count); return 0; }

两个数组分别存行和列,队列大小用m*n+5,严格来说这个写法在MAXN=1005时,数组长度超过100万,栈上声明可能会爆,建议在函数外声明成全局数组(我就是这么写的)。C语言的坑主要在于:第一,scanf的格式串里不要加多余的空格和换行,直接"%d%d"最稳;第二,矩阵大小如果超过MAXN需要调大常量,所以读题时要留意数据范围;第三,如果题目给的矩阵行是字符串形式(比如“0101”这种没有空格分隔的数字),scanf("%d")是读不了的,得用字符读取再转换成整数,这个我在下一节会细说。

3.6 五种语言的差异对照与选型建议

代码贴完了,我把五种语言的差异点和踩坑点整理成一张表,方便你对照自己熟悉的语言重点记忆:

语言队列实现最常踩的坑推荐场景
JavaArrayDeque用LinkedList性能差;数组用int[]包装大多数考生首选,代码规范
Pythoncollections.dequelist.pop(0)超时;输入解析慢追求代码简洁,快速开发
JS数组+头指针shift()超时;忘记还原坐标前端转算法岗的同学
C++queuebits头文件兼容性;auto&引用追求运行速度和代码通用性
C手写数组队列全局数组越界;字符串输入处理院校要求或练习底层思维

选型建议很简单:如果你在五种语言里没有明显的偏好,根据岗位要求来。华为OD机考一般按你申请的岗位语言来定,前端岗选JS,后端岗选Java或C++,算法岗选Python,都无所谓对错。关键是选一个你最熟的语言,把模板写到肌肉记忆里。

4. 常见问题与排查技巧实录

4.1 死循环与重复入队的经典原因

这个问题我放在了排查章节的第一位,因为它的出现频率实在太高。典型症状是程序在小样例上输出正确,但在大样例上要么超时要么卡死。根因几乎都是同一个:去重标记的时机不对。

展开说:假设A格子出队,发现B格子是0,于是把B入队;同时C格子出队,也发现B格子是0,于是又把B入队了一次。如果B在入队前没有被标记,它就会被重复入队两次。更可怕的是,B出队时会把自己和邻居再处理一遍,而A和C处理的顺序又各不相同,最后整个队列可能呈指数级膨胀,这就是死循环的根源。

正确做法是入队前标记。每一步的逻辑写成:if (grid[nx][ny] == 0) { grid[nx][ny] = 2; count++; queue.offer(...); }。这样B一旦被第一个邻居发现,立刻变成2,其他邻居再看到B时就直接跳过,永远不会重复入队。我在给朋友review代码时,只要看到“出队后标记”或者“出队时才设置visited”,就知道十有八九会超时。这个错位,比任何语法错误都隐蔽。

4.2 矩阵输入格式变换带来的麻烦

华为OD机考的输入格式有时候很任性,同一个题在不同批次里可能给出两种格式:一种是数字之间有空格,比如“0 1 0”,另一种是数字连成一个字符串,比如“010”。如果题目里是后者,Java的nextInt、Python的input().split()、C的scanf("%d")、JS的split(" ")全都读不到你想要的数字。

我的处理方式是写一个统一的“读数字”辅助逻辑:先把每行当字符串读进来,去掉首尾空白,然后逐个字符判断是不是'0'或'1'。这个方法我在面试题的在线调试里反复验证过,对两种格式都通用。如果你在机考现场发现样例给的矩阵行没有空格,不要慌,不要改主算法,只改解析部分就行。这里特别提醒C语言的考生,字符读取时注意吃掉换行符,否则会因为残留的'\n'导致读错坐标。

4.3 边界条件自测清单

为了确保提交前不翻车,我养成了一个习惯:写完BFS模板之后,先跑一遍边界样例。下面这组测试用例你直接拿来用:

用例输入期望输出说明
基本连通3 3,全0矩阵,起点(0,0)9全部格子都被同化
有障碍3 3,中间一个1,起点(0,0)8障碍阻断扩散,但其余连通
起点是障碍2 2,[[1,0],[0,0]],起点(0,0)0起点为1,原地无扩散
单行矩阵1 5,0 0 0 0 0,起点(0,0)5单行边界最容易越界出错
隔离区域3 3,起点在左上角2×2全0区域,右下角被1隔开4不能穿越1扩散到另一块

我每次提交前都会把这几组样例跑一遍,全过才算安心。尤其单行和单列矩阵,很多人写方向数组时忽略了行或列为1的情况,导致越界判断出错,这属于低级但致命的失误。

4.4 调试技巧:打印扩散过程比断点快十倍

如果你写完之后输出不对,不要盯着代码发呆。我的经验是临时在BFS循环里加一行打印,把当前出队的坐标、新同化的坐标、当前计数都打出来,用一个小矩阵跑一遍,很快就能定位问题。比如3×3矩阵,打印出来的扩散顺序应该是(0,0) -> (0,1) -> (1,0) -> (0,2) -> (1,1) -> ...,如果顺序不对或者少了格子,看打印结果立马知道是哪一步判断错了。

调试完记得把打印删掉,否则提交时会因为多余输出被判错。这个低级错误我见过不止一次,很多人急着提交,最后挂在多余的System.out.println上,非常可惜。如果有条件,可以先用本地的IDE调试,把BFS的每一步可视化,远比你用眼睛干看代码高效得多。

5. 机考实战策略与备考建议

5.1 双机位考试环境下的代码模板准备

双机位机考最核心的挑战是:没有智能提示,不能查资料,英文单词拼写都要靠记忆。所以我的建议是——把这段BFS模板练到闭着眼都能默写出来。不要觉得这个建议太基础,我实际考场上看到不少人因为queue的import语句写不出来而卡住,这就是模板没有内化。

具体做法是:每天用你选定的语言,白纸手写一遍输入解析+BFS核心+输出。不用把整个main函数都写下来,但要能保证方向数组、队列定义、入队标记三个关键部分一笔不出错。等你能在10分钟内在空白编辑器里从零写完这个模板,这道题基本就稳了。另外,机考编辑器通常不支持自动补全,所以你平时在IDE里写代码时,可以自己习惯性地关掉补全练几次。

5.2 从这道题延伸出去的同族题目

做完这道题,你还可以顺便检验一下自己对Flood Fill家族的掌握程度:岛屿数量(统计连通块个数)、岛屿最大面积(统计最大连通块面积)、被围绕的区域(从边界扩散并标记)、墙与门(多源BFS求最短路径)。这几道题的核心都是同一个扩散模型,只是返回值、起点集合、扩散规则略有不同。把这道基础题吃透,等于打通了这四五道题的任督二脉。

备考时间有限的话,我建议按优先级来:会写BFS模板是第一优先级;能把矩阵的输入解析处理干净是第二优先级;会处理边界条件自测是第三优先级。这三件事做完,哪怕题目变体再多,你也有能力在考场上临场拆解,而不是看到新题就懵。

5.3 时间分配与交卷前的最后检查

机考的时间一般比较紧张,一道编程题从读题到提交控制在40分钟内是比较理想的状态。我的分配习惯是:读题和理解样例5分钟,写代码25分钟,自测边界用例5分钟,最后5分钟检查输入输出格式和多余打印。如果一道题写了30分钟还没写通,不要再死磕,先跳到下一题,防止时间耗尽导致全面崩盘。

交卷前过一遍这三个问题:起点坐标是不是读对了?矩阵行列有没有反过来?计数是不是从1开始(起点本身也算一个)?这三个问题几乎覆盖了这道题80%的失分点。我帮人review过很多次代码,发现大多数人丢分不是因为算法不会,而是因为小细节——坐标读反、计数忘记加起点、parseInt拼写错误。这些在编译时完全合法,但在逻辑上直接让答案错误,极其隐蔽。

最后说点个人的体会。我在刷这道题的时候,一开始总想找一个“数学公式”直接算出非1元素的个数,后来才明白这类题真正考的是你在面对“从一个点扩散到一片区域”这类问题时,能不能快速建立搜索模型。当你把五种语言的版本都跑通一遍之后,再看矩阵题眼光会完全不一样:看到一片0,第一反应就是起点在哪、墙在哪、队列怎么走。这种手感,比背一百道题都值钱,也是机考里最实在的底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询