拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

华为OD机考C卷:Java求解开心消消乐连通块算法题

华为OD机考C卷:Java求解开心消消乐连通块算法题

第一次坐进“华为OD机考双机位C卷”的考场时,我花了不少时间调整手机支架。正面摄像头对着人脸,侧后方摄像头盯着电脑屏幕,两个画面同时开着,桌面上的题名叫“开心消消乐”——我很确定这不是让我放松的休闲游戏,而是一道需要用 Java 完成的算法题。

这道题的题面很朴素:一个 m 行 n 列的棋盘,格子里只有 0 和 1,1 表示有图标,0 表示空位。点击一个 1,这个格子以及上下左右相连的所有 1 会一起消除,问最少点几次能把整个棋盘清扫干净。懂点算法的人一眼就能看出来,这题本质是连通块计数,跟 LeetCode 的岛屿问题同源。但在双机位监考、网页编辑器、没有 IDE 补全的环境里,从读数据到提交全绿,中间还是有不少暗坑。这篇文章就把我的完整解法、踩坑过程,以及 Java 机考现场的一些实操经验都写出来,给同样在备考的 Java 方向朋友做个参考。

1. 双机位监考怎么架、C卷题目怎么抽:进入考场前的真实环境

1.1 双机位的典型摆法

双机位不是打开两个页面就算数,它是两路实时画面同时监控。大多数场次的要求是这样的:主机位用电脑自带摄像头,正对着你的面部,画面里要能看到脸、双手、桌面大部分区域;第二机位用手机或者另一台摄像头,放在你侧后方大概 45 度角的位置,完整的照到电脑屏幕和手部动作。

我当时的布置是主摄像头放在显示器正中上方,稍微俯视,确保键盘和胸前位置可见。手机用一个落地支架架在左后方,画面稍微拉远一点,既能拍到正面屏幕,也能拍到我的侧身动作。这里有几个容易忽略的细节:

  • 手机最好设置成飞行模式并连接 Wi-Fi,避免考试中途来电打断画面。
  • 桌面要清理干净,水杯、手机、纸质资料这类东西提前拿开,监考端如果看到可疑物品会要求你展示桌面。
  • 光线不要背光,正对窗户会让人脸发黑,监考端一旦看不清面部,可能要求你换位置。

这些不是官方文档里写得特别细的内容,但实际开考前十分钟如果调试不好,非常影响心态。我当时光调整第二机位角度就花了五分钟,等真正开始做题的时候,第一题已经在屏幕上了。

1.2 C卷的构成与成绩结构

华为OD机考常见的是 A/B/C 卷随机抽题,C卷不是比 A/B 卷更难,而是不同的题目集合。每场考试的卷子从题库里随机抽取,本质是防泄题和防作弊。常见的题量是三题,总分 400 分,有 100 + 100 + 200 的结构,也有 100 + 200 + 200 的结构,具体要看当时邮件通知。

时间通常是 120 到 180 分钟之间,不同批次可能不一样,一定以考试邮件为准。网页端编辑器只提供基础的代码高亮和运行功能,没有自动补全,也没有本地 IDE 那么顺手。Java 的话主类名一般固定为 Main,不要写 package 声明,提交代码时只提交 .java 文件内容。

很多人关注“华为OD好进吗”,我的体感是:机考这一关反而是最可控的。题目难度分布通常是一道简单、一道中等、一道偏难,总分过线就能进入下一轮。真正拉开差距的往往是第二题第三题的时间分配,而不是第一题会不会。“开心消消乐”这道题在 C 卷里定位就是送分题,但也正因为是送分题,读题不仔细、输入没处理对,照样会拿不到满分。

1.3 看到“开心消消乐”这个题名的第一反应

说实话,第一眼看到“开心消消乐”我愣了一下,以为是要实现一个消除游戏的主流程,担心要处理下落、合并、随机生成这些复杂逻辑。把题面完整读完之后才放心:它说的就是点击消除,消除后棋盘不会发生塌陷或下落,问最少点击次数。

这时候我对这道题的判断就很明确了:

  • 输入:m 行 n 列,格子值 0 或 1。
  • 操作:点击任意一个 1,这个 1 与上下左右相连的所有 1 同时变成 0。
  • 目标:把棋盘上所有 1 清空,求最小点击次数。

这就是标准的二维矩阵连通块数量统计。一个连通块里,点任意一个格子就能消掉整个块;不同连通块之间互相独立,所以最少点击次数正好等于 1 的连通块总数。思路清楚之后,剩下的就是 Java 代码怎么写更稳。

2. “开心消消乐”题面还原:点一下消一片,本质就是连通块计数

2.1 我遇到的这版题面还原

为了让后面代码部分更好理解,我根据自己的记忆还原一下题面,不同批次的措辞可能略有差异,但算法模型基本一致:

有一个 m 行 n 列的棋盘,格子里可能有图标,也可能为空。图标用 1 表示,空位用 0 表示。每次操作时,玩家点击一个还存在的图标,这个图标以及与其上下左右相邻的图标会被一起消除,被消除的位置变成空位。请问最少需要点击多少次,才能把棋盘上所有图标全部消除?

示例输入:

3 3 1 0 1 1 1 0 0 0 1

棋盘里三个 1 的连通块分别是:左上角与中间左侧连成一块,右上角单独一块,右下角单独一块,所以输出是 3。

这类题偶尔会改一点包装,比如有些版本里棋盘元素是字符“A”“B”而不是 0/1,但做法完全一样。也有一些变体真的会加入重力下落或者自动连锁消除的机制,那种题的算法就完全不同了。所以读题阶段最重要的一件事,就是确认“点击一次到底消除哪些格子,消除后棋盘动不动”。判断错了,后面全盘皆输。

2.2 把“游戏规则”翻译成算法模型

“最小点击次数”之所以等于连通块数量,逻辑上很好推:在一个连通块内部,任意两个 1 之间都存在一条由相邻 1 组成的路径。你点击块内任意一个格子,整条路径上的 1 全都被蔓延消除,最终整个块变 0。想要再消别的块,必须点块外的格子。所以每个连通块至少需要点一次,并且每点一次整块就没了。既然每个块只需要一次,那最少次数就是块的总数。

有了这个结论,问题退化成:

统计二维矩阵中,所有值为 1 的格子组成的连通块数量,其中相邻关系只包含上下左右四个方向。

遍历顺序很简单:从上到下、从左到右扫一遍。碰到一个 1,就把计数器加一,然后以这个点为起点,把所有和它相连的 1 都改成 0,避免后面重复计数。等整个矩阵扫完,计数器里就是答案。

2.3 用一个手工算例验证思路

拿一个 4 × 4 的例子说明:

1 1 0 0 0 1 0 1 1 0 0 1 0 1 1 1

按连通块划分:

  • 左上角一片:包含 (0,0)、(0,1)、(1,1),共 3 格。
  • 右上角单独一个:(1,3)。
  • 左下角一片:(2,0) 与 (3,1) 并不是上下左右相邻,仔细看,(2,0) 是单独的,(3,1)、(3,2)、(3,3) 是一块,(2,3) 也在这一块里,因为它和 (3,3) 上下相邻。
  • 第 (1,3) 和 (2,3) 是上下相邻的,所以它们属于同一块,要和右下大块合并。

整理后一共是 3 个连通块,输出应该是 3。如果扫描时不把已访问格子改成 0,右下这一大片会在外层循环里被重复计数很多次,这也是新手最常犯的错误。

3. Java 两个解法:递归 DFS 与 BFS 队列,完整代码可以直接抄

3.1 机考答题的 Java 主结构

机考环境里 Java 通常只能用标准库,主类名固定是 Main,不能带 package。我习惯用一个干净的类结构:

import java.util.*; public class Main { public static void main(String[] args) { // 读入处理 // 遍历棋盘并统计连通块 // 输出结果 } }

读入方式有两种选择:Scanner 简单直观,适合快速写第一版;但数据量大的时候,Scanner 逐行解析慢,而且在处理混合输入时容易因为 nextInt 和 nextLine 混用而出 bug。我更推荐 BufferedReader 配合 StringTokenizer,后面第 4 章会专门讲这个问题。

3.2 递归 DFS 写法

DFS 的思路是收到一个起点后,立刻把这个点改成 0,再向四个方向递归。这样既起到了访问标记的作用,也完成了消除操作。代码非常简短:

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 clicks = 0; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 1) { clicks++; dfs(grid, i, j, m, n); } } } System.out.println(clicks); } static void dfs(int[][] grid, int x, int y, int m, int n) { if (x < 0 || x >= m || y < 0 || y >= n || grid[x][y] == 0) { return; } grid[x][y] = 0; for (int[] d : dirs) { dfs(grid, x + d[0], y + d[1], m, n); } } }

这段代码的优点是容易写、不容易漏方向。缺点是当矩阵很大,比如 500 × 500 或者 1000 × 1000 且所有格子都是 1 时,递归深度可能达到上万层,Java 默认的线程栈可能会溢出。机考题如果明确说明 m、n 不超过 100 或者 200,用递归没问题;如果没给上限,我个人更建议用 BFS 或者迭代 DFS。

3.3 BFS 队列写法

BFS 用 ArrayDeque 保存待扩展的格子,每碰到一个新的 1,就启动一轮队列扩散。代码量和 DFS 差不多,但没有递归深度风险,是机考环境下更稳妥的选择:

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 clicks = 0; ArrayDeque<int[]> queue = new ArrayDeque<>(); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 1) { clicks++; grid[i][j] = 0; queue.offer(new int[]{i, j}); 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 && grid[nx][ny] == 1) { grid[nx][ny] = 0; queue.offer(new int[]{nx, ny}); } } } } } } System.out.println(clicks); } }

BFS 的关键点在于入队之前就把格子置为 0。如果在出队的时候才置 0,同一个格子可能被多个邻居重复放入队列,虽然最终结果不受影响,但会白白增加很多无效遍历,极端情况下可能拖慢运行速度。

3.4 复杂度与机考可用性

这个算法的时间复杂度是 O(m × n),因为每个格子最多被访问一次;空间复杂度在 BFS 里最坏是 O(m × n),因为队列可能装下一整片连通块。对机考里绝大多数题目来说,这个复杂度都是足够的。就算棋盘到 1000 × 1000,也就是一百万级别,Java 跑完基本在一秒以内。

机考时我最后提交的是 BFS 版本。原因很简单:在不知道边界范围、网页编辑器又没有压力测试工具的情况下,选一个理论上限更稳的写法,比写一个更短的写法更让人安心。

4. 读入、越界、自测:三道最容易在考场上翻车的坎

4.1 输入解析:换行符、空格分隔、连续字符串

这题最阴险的坑其实不在算法,在输入。机考样题大多数给的是空格分隔的数字,像这样子:

3 3 1 0 1 1 1 0 0 0 1

但有些批次的输入格式是每一行直接给一个连续字符串:

3 3 101 110 001

还有更隐蔽的坑:用 Scanner 读完 m 和 n 之后,如果不处理那一行结尾的换行符,后面再用 nextLine 就会读到一个空字符串。很多 Java 考生在这里丢掉大量时间。

我建议直接写一个兼容两种格式的读入函数,一次搞定:

import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.StringTokenizer; public class Main { public static void main(String[] args) throws Exception { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringTokenizer st = new StringTokenizer(br.readLine()); int m = Integer.parseInt(st.nextToken()); int n = Integer.parseInt(st.nextToken()); int[][] grid = new int[m][n]; for (int i = 0; i < m; i++) { String line = br.readLine().trim(); if (line.contains(" ")) { StringTokenizer row = new StringTokenizer(line); for (int j = 0; j < n; j++) { grid[i][j] = Integer.parseInt(row.nextToken()); } } else { for (int j = 0; j < n; j++) { grid[i][j] = line.charAt(j) - '0'; } } } // 后面继续统计连通块逻辑 } }

用 BufferedReader 还有一个额外好处:当矩阵很大时,它的读入速度明显比 Scanner 快,不会在 IO 上成为超时点。这个技巧对机考的字符串处理题同样适用。

4.2 边界与越界:方向数组的四个判断别漏

写 DFS 或 BFS 的扩展逻辑时,最容易犯的错误是只检查了新坐标是否越界,却忘了检查目标格子的值。反过来也会出错:只检查值等于 1,却忘了数组下标可能已经越界,导致 ArrayIndexOutOfBoundsException。

我的固定写法是先判越界、再判值,两个条件用 && 连起来:

  • 新坐标 x 必须满足 0 <= x < m。
  • 新坐标 y 必须满足 0 <= y < n。
  • 目标格子的值必须是 1。

顺序看起来无关紧要,但我习惯把越界判断写在前面。因为数组索引一旦越界,后面的 grid[nx][ny] 根本访问不了,先判值反而可能触发异常。这种细节在紧张状态下特别容易写反。

4.3 “最小”两个字到底在问什么

有些同学会纠结:为什么不是模拟点击过程,而直接数连通块?其实这正是“最小”二字的含义。如果题目问“点击一次最多能消除多少个图标”,那才是另外一道题;问“最少点击几次清空”,那就等价于数块数量。

要验证自己的理解对不对,我提供一个自测模板。准备下面几组输入,分别跑一遍,看输出是否符合预期:

输入矩阵期望输出原因
3 3,全 00没有图标,不需要点击
1 5,全 11一整行都是同一块
5 1,交替 1 和 0点击次数等于 1 的个数上下不连通时每个 1 单独成块
5 5,棋盘全 11整个棋盘是一个连通块
5 5,只有中心一个 11单独一块

机考平台的示例测试用例往往比较简单,自己多构造几个边界用例,能提前暴露不少问题。我当时就是先用“全 1”的大矩阵跑了一遍,才确认 BFS 的入队逻辑没有重复计数。

4.4 是否需要用 visited 数组

这题其实不需要额外开 boolean[][] visited。因为点击消除后,格子会从 1 变成 0,而 0 本身就是“已经被处理过”的标记。外层循环碰到 0 会直接跳过。这样既省内存,又少写一套维护逻辑。

但要注意,这种做法会直接修改原始输入矩阵。在机考题里无所谓,反正输出结果跟原矩阵没有关系。如果你是在本地 IDE 里做调试,想保留原始数据,那可以加一个 visited 数组,思路完全一样。

5. 双机位下的 Java 编程纪律与 C 卷时间分配

5.1 双机位监考下的合规动作,别让监控误判

双机位监考最折磨人的不是题难,而是“被盯着”的压力。我总结了几条实际有用的动作规范:

  • 不要频繁切换浏览器页面。就算有正当理由,切屏次数一旦触发系统警告,处理起来非常麻烦。
  • 不要低头看手机,即使只是看时间。第二机位拍到低头动作,监考端很可能放大画面检查。
  • 避免长时间手托下巴或遮挡面部。有人习惯做题时托腮思考,这在人工复核时容易被误判。
  • 如果中途系统提示画面断开,先报备再处理,不要自己擅自关闭任何监考软件。

另外,机考网页编辑器一般不支持自动保存,写几分钟就手动保存一下。Java 代码通过编译后也要尽快运行自测用例,别攒到最后一起测,那样出错后很难定位。

5.2 Java 机考环境里值得默写下来的模板

网页编辑器没有 IDE 的自动补全,一些平时靠 IDE 帮忙的记忆突然就靠不住了。我最常默写的是三样东西:主类结构、方向数组、读入模板。方向数组这个写法在网格题里几乎通用:

static int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

另一个容易被忽略的是,机考 Java 版本可能不支持一些很新的语法。我建议不要写 Java 8 之后的流式 API,像 stream、var、List.of 这类特性,版本不匹配会导致编译错误。老老实实用数组、ArrayList、Scanner、BufferedReader,这些在任何版本都稳。

输出方面,System.out.println 足够。如果某些题目要求输出浮点数并保留两位小数,直接 System.out.printf("%.2f", result) 也行。不要搞花活,不要在输出里加多余空格和空行,很多机考判题是严格比对输出内容的。

5.3 C 卷时间预算:以三道题 150 分钟为例

我给自己定的时间预算是这样的:

  • 第一题:15 分钟。目标是全过测试用例。像“开心消消乐”这种连通块题,读完题顺手把 BFS 模板默写出来,提交自测后就不要过多纠结。
  • 第二题:45 分钟。中等难度题,通常是字符串处理、栈模拟、二分答案这类。先把暴力思路写出来,如果时间紧张,至少保证部分用例通过。
  • 第三题:75 分钟。偏难题,常见的是动态规划、图论、线段树。这一题允许拿不到满分,但最好用暴力法写出能过的部分分支。
  • 剩余 15 分钟:统一检查,跑一遍第一题和第二题自造用例,确认没有低级提交错误。

实际执行下来,我的“开心消消乐”大概用时 16 分钟,比预算稍微超了一点,主要浪费在摆弄手机支架和确认输入格式上。如果笔试前把读入模板背熟,这个时间能压到 10 分钟以内。

5.4 机考之后还有哪些环节

机考只是华为 OD 流程的一部分,通过之后通常还有性格测试、综合面试、技术面试等环节。硬要说“华为OD好进吗”,机考这一关完全可以通过系统刷题来掌握节奏,“开心消消乐”这类题本身就是典型的基础连通块题,刷过一遍就能有肌肉记忆。

但我也要提醒一句:机考通过不等于万事大吉,后续技术面试里 Java 基础、并发、Spring 相关的问题都会问到。热搜里那些“Java 八股文”“Java 判断字符串中是否不是字母和数字”“java排序”“java容器”的内容,建议机考之后的复习阶段也要覆盖。

最后复盘:这道题给我留下的几个记忆点

从“开心消消乐”这道题里,我最深的体会是:机考环境下,第一题往往不是考你多聪明,而是考你多稳。读入模板稳、主类结构稳、方向数组稳,这三点做到位,真正的算法思维只要十分钟就能完成。反而是那些双机位监考、输入格式不确定、网页编辑器没补全的附加压力,才是实际考试里最消耗精力的地方。

我后来再复盘这 16 分钟,真正敲代码只用了五六分钟,剩下的时间全在确认输入到底是空格分隔还是连续字符串。如果考前我把 BufferedReader 的通用读入函数写在本地备忘录里,并在脑海里过一遍,这个时间完全可以省掉。这就是我给各位备考 Java 方向朋友的最实际建议:不要只背算法题解,还要把工程模板背进手里。

最后再分享一个小技巧:不管题面包装成“开心消消乐”还是“打地鼠”还是“岛屿数量”,只要看到二维矩阵 + 上下左右 + 连通关系,第一反应就应该是连通块计数。代码写成 BFS,输入用兼容解析,自测用例多准备几个边界场景,这道送分题就稳稳拿下了。双机位确实有点让人紧张,但题目本身的“开心”程度,取决于你提前准备了多少。

返回列表