面试题这个东西,最怕的就是“背答案”。尤其是春招这种节奏快、考察覆盖面广的场景,刷题的意义不在于记住某道题的解法,而在于通过一道题把一类题的底层逻辑打通。“黑白纸片”这类题目,听起来像是拼图游戏或者剪纸质感的趣味题,实际上落到代码里,就是非常经典的连通块计数问题,跟LeetCode上的岛屿数量、图像渲染、省份数量是同一棵树上的果子。这次顺丰春招卷子里的第二题把它换了个包装,用纸片当壳,考的还是DFS、BFS和并查集那套基本功。我用了Java、C++和Python三种语言分别过了一遍,配合在线OJ测试,整理出了这份完整解析,从题意拆解到每种语言的实现细节,再到容易踩的坑,一次性说清楚。这道题适合三类人看:正在准备春招、需要把连通块问题吃透的应届生;想对比三种主流语言在同一道算法题上写法的差异、顺便复习STL和标准库用法的开发者;以及想把“会做题”升级成“会讲题”的面试准备者。
1. 题目理解与整体思路拆解
1.1 题目到底在问什么
先还原一下题目的原始描述。有一张由黑白格子组成的矩形纸片,被分成了M行N列的网格,每个格子上要么是黑色要么是白色。相邻的定义是两个格子有公共边,即上下左右四个方向,斜对角不算。现在需要统计这张纸片上一共有多少块“黑色纸片”——所谓一块黑色纸片,就是由若干个相邻的黑色格子组成的整体,白色格子会把它们隔开。
这就是标准的连通块计数。输入格式通常是第一行两个整数M和N,表示行数和列数,接下来M行每行给出N个整数,0代表白色,1代表黑色。输出是一个整数,代表黑色连通块的数量。有些版本会稍微改一改,比如用字符矩阵,或者把方向定义成八个方向、允许斜着相连,但核心逻辑不变。
这类题的精髓在于“连通”这个词。用一个生活化的类比:你往黑色格子的区域倒一桶水,水会沿着相邻的黑色格子流遍整个连通区域,但遇到白色格子就停下来。倒一桶水覆盖到的所有黑色格子就是一块纸片,倒了几桶水就说明有几块纸片。这个“倒水”的动作,翻译成算法语言就是搜索。
1.2 为什么面试官喜欢拿它当春招题
春招笔试不同于社招的深度考察,它更像是一张过滤网。时间有限、题量固定,每道题都要在十几分钟内考察出候选人的多项素质。黑白纸片这道题能出现在顺丰的春招卷里,恰恰是因为它在多个维度上都合格:
第一,模型转换的思维。题面用“纸片”包装,本质上考察的是能不能快速把实际问题抽象成图论模型。面试官想看的是你拿到一道生活化题目时,是直接懵掉,还是能马上意识到“这不就是矩阵上的连通性判断吗”。
第二,基础算法的熟练度。DFS、BFS、并查集是数据结构和算法里的基本功,任何一个岗位用到这些都不奇怪。三道题里如果有一道是纯考堆砌复杂数据结构的偏题,那对大多数人是无效的,连通块这种难度适中、解法多样的题反而能拉开区分度。
第三,代码实现的规范性。这道题的边界判断、数组越界、访问标记、递归深度控制,每一处细节都藏着扣分点。能在规定时间内把代码写干净、不犯低级错误,本身就是一种能力证明。
所以别小看这道“简单题”,它是一面照妖镜,基础扎不扎实,一照便知。
1.3 整体算法框架:遍历 + 搜索
解决连通块计数的通用框架非常统一,三步走:
- 从左上角开始,逐行逐列扫描整个矩阵。
- 遇到一个“未访问过的黑色格子”时,答案计数器加一,然后从这个格子出发,做一次完整的搜索,把和它连通的所有黑色格子全部标记为已访问。
- 扫描继续,直到遍历完整个矩阵,输出计数结果。
这里的核心在于第二步的“搜索”。可以用**深度优先搜索(DFS)递归地往四个方向钻进黑色区域;也可以用广度优先搜索(BFS)借助队列层层往外扩;还可以用并查集(Union-Find)**先把所有黑色格子的相邻关系全部合并一遍,最后数一数有多少个独立的集合。
三种思路在时间复杂度上都是O(M×N),因为每个格子最多被访问一次。空间复杂度上,DFS最坏情况下递归层级就是连通块的大小,极端情况(整个矩阵全黑)下递归深度可能达到M×N,所以Python里需要手动调大递归限制,这属于典型的“大坑”;BFS的空间取决于队列的最大长度,也就是一层里最宽的数量,一般不会超过矩阵的短边长度;并查集则需要额外开一个大小为M×N的父节点数组。具体场景下的选择逻辑,后面第二章展开细说。
2. 核心算法原理详解:三种主流方案怎么选
2.1 DFS:最直观的递归染色
DFS的思路和“倒水”这个比喻几乎一模一样。从起点格子出发,标记为已访问,然后依次探测上、下、左、右四个邻居,如果邻居是黑色且没被访问过,就递归进入这个邻居继续同样的动作。
用代码伪表达一遍主流程:
计数 = 0 对于矩阵中每一个位置(i, j): 如果 grid[i][j] == 黑色 且 未访问: 计数 += 1 DFS(i, j)DFS函数本身要做什么:
DFS(x, y): 标记 (x, y) 为已访问 对于每个方向 (dx, dy): 计算新坐标 nx = x + dx, ny = y + dy 如果 nx、ny 在矩阵范围内 且 grid[nx][ny] == 黑色 且 未访问: DFS(nx, ny)DFS最大的优势是写起来爽,代码量最小,逻辑直白,和人对“一块纸片”的直觉完全一致。它的代价是递归深度受限。面试现场如果矩阵规模没给明,或者明确说M和N可以到1000以上,就要考虑递归栈溢出的风险。这时候要么在开头主动调大递归限制,要么直接换成BFS或迭代式DFS(自己维护一个栈)。
2.2 BFS:没有递归栈风险的遍历方式
BFS把递归换成了队列,思路变成了“从起点出发,先看看四周有哪些邻居能走,把它们全部放进队列,然后一个一个处理”。
主流程几乎一样,只是搜索函数内部变成了循环结构:
BFS(startX, startY): 创建队列 把(startX, startY)入队并标记为已访问 当队列非空: 弹出队头元素 (x, y) 对于每个方向: 计算邻居坐标 如果邻居合法 且 是黑色 且 未访问: 标记邻居已访问 邻居入队值得注意的是,这里“标记已访问”的动作必须发生在入队的那一刻,而不是出队的时候。如果等到出队再标记,同一个格子可能被多个邻居重复入队,虽然最终结果不会错,但队列里会有大量冗余元素,最坏情况下空间和时间都会被白白浪费。这是我实际写代码时踩过的坑,后面第五章会再提。
BFS的优势是不用担心递归深度,适合矩阵特别大的情况。代价是手写的队列操作比递归稍微繁琐一点,在Java和C++里通常用ArrayDeque和queue<pair<int,int>>来解决,Python里则直接用collections.deque来保持双向队列的高效入队出队。
2.3 并查集:另一种视角的连通性统计
DFS和BFS是“从起点开始往外扩”,并查集则是一种完全不同的思路:它不主动“搜索”,而是把所有黑色格子之间相邻关系逐条“合并”。最后数一下有多少个集合,每个集合代表一块纸片。
并查集的核心操作有两个:
find(x):找到x所在集合的代表元素(根节点),同时做路径压缩。union(a, b):把a和b所在的两个集合合并成一个。
对于这道题,做法是:先把整个二维矩阵铺平成一维,也就是给每个格子编号idx = i * n + j,然后扫描每个黑色格子,看它的右方邻居和下方邻居是否也是黑色,如果是,就把这两个格子合并。为什么只需要看右和下?因为合并关系是双向的,看左上已经覆盖,看右和下就能覆盖所有相邻关系。
合并结束后,把所有黑色格子找一遍根,不同的根的数量就是答案。这里其实还能优化:直接在合并过程中用变量统计“当前独立的集合数量”,初始化时每个黑色格子自成一个集合,每次合并成功就减一,省去最后再遍历一遍。
并查集的代码量在三种方案里最大,但它有一个其他方案没有的优势:不用死记方向数组,对“八连通”甚至“任意不规则连通规则”的扩展特别自然。如果题目要求你计算黑色纸片里最大的一块面积,DFS和BFS都能做,但并查集要额外维护集合大小信息,也不复杂。
2.4 三种方案的对比与取舍
| 方案 | 核心数据结构 | 时间复杂度 | 空间复杂度 | 代码量 | 风险点 |
|---|---|---|---|---|---|
| DFS | 系统递归栈 | O(M×N) | O(最坏连通块大小) | 最少 | 递归过深导致栈溢出 |
| BFS | 显式队列 | O(M×N) | O(队列最大宽度) | 中等 | 重复入队导致冗余 |
| 并查集 | 父节点数组 | O(M×N×α) | O(M×N) | 较大 | 坐标压缩时容易下标搞错 |
这里的α是反阿克曼函数,在实践中可以认为是一个不超过5的极小常数,所以并查集的时间复杂度也维持在近乎线性的水平。
实际做题时怎么选?我个人的习惯是:矩阵规模不超过200×200时,首选DFS,写起来最快,不容易出逻辑错误;规模接近1000×1000甚至更大时,果断上BFS,稳字当头;如果题目还附带多个查询、动态合并之类的附加条件,直接考虑并查集,它天然支持动态连通性判断,这是DFS和BFS做不到的。
3. 三种语言的完整代码实现
3.1 Java版:面向对象风格与递归DFS
Java在春招笔试里最常见的写法就是递归DFS版本,配合Scanner处理标准输入。关键点有几个:方向数组要定义成静态二维数组,递归出口的判断要写在访问邻居之前,visited数组用boolean[][]默认值就是false,省事。
import java.util.*; public class Main { private static final 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(); } } boolean[][] visited = new boolean[m][n]; int count = 0; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 1 && !visited[i][j]) { count++; dfs(grid, visited, i, j); } } } System.out.println(count); } private static void dfs(int[][] grid, boolean[][] visited, int x, int y) { visited[x][y] = true; for (int[] d : DIRS) { int nx = x + d[0]; int ny = y + d[1]; if (nx >= 0 && nx < grid.length && ny >= 0 && ny < grid[0].length && grid[nx][ny] == 1 && !visited[nx][ny]) { dfs(grid, visited, nx, ny); } } } }这段代码里有一个细节值得展开:nx >= 0 && nx < grid.length和ny >= 0 && ny < grid[0].length这四个条件是先判断是否越界,再访问数组元素。Java和C++里都遵循从左到右的短路求值,如果越界条件在前且为真,后面的数组访问根本不会执行,所以安全。如果顺序反过来先读grid[nx][ny]再判断边界,一旦越界就会抛ArrayIndexOutOfBoundsException,这是新手最容易翻车的地方。
3.2 C++版:STL容器与BFS队列
C++在这个场景下最大的优势是STL容器足够好用。vector<vector<int>>存矩阵,vector<vector<bool>>存访问标记,queue<pair<int,int>>作为BFS队列。cin读取标准输入时,虽然比scanf稍微慢一点,但在笔试环境通常够用,除非明确了超大数据量,那可以考虑关闭同步锁加速,也就是在main里写一行ios::sync_with_stdio(false);。
#include <bits/stdc++.h> using namespace std; const int DIRS[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); 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]; } } vector<vector<bool>> visited(m, vector<bool>(n, false)); int count = 0; queue<pair<int, int>> q; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 1 && !visited[i][j]) { count++; q.push({i, j}); visited[i][j] = true; 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 && grid[nx][ny] == 1 && !visited[nx][ny]) { visited[nx][ny] = true; q.push({nx, ny}); } } } } } } cout << count << endl; return 0; }C++版本的BFS里,auto [x, y]是C++17的结构化绑定特性,能直接从pair里拆出两个变量,比用q.front().first和q.front().second直观得多。如果你的本地编译环境没开C++17,这套代码在在线OJ上也能跑,因为大多数在线OJ的默认标准已经是C++17。如果确实遇到老编译器,改成显式取first、second就行。
3.3 Python版:简洁实现与递归深度调整
Python的代码几乎和伪代码一样清晰,但有两个必须注意的坑:一是递归深度默认只有1000,如果矩阵很大且全是黑色,DFS会直接崩在RecursionError上,所以要么sys.setrecursionlimit()调大上限,要么直接用BFS。二是sys.stdin.read().split()一次性读入所有数据的写法,在笔试里会显著提升输入效率,比input().split()逐行读要稳,而且代码更短。
import sys from collections import deque DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)] def solve(): data = sys.stdin.buffer.read().split() if not data: return idx = 0 m = int(data[idx]) n = int(data[idx + 1]) idx += 2 grid = [] for _ in range(m): row = list(map(int, data[idx:idx + n])) idx += n grid.append(row) visited = [[False] * n for _ in range(m)] count = 0 for i in range(m): for j in range(n): if grid[i][j] == 1 and not visited[i][j]: count += 1 q = deque() q.append((i, j)) visited[i][j] = True 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] == 1 and not visited[nx][ny]: visited[nx][ny] = True q.append((nx, ny)) print(count) if __name__ == "__main__": solve()Python的deque.popleft()是O(1)的操作,如果用list.pop(0)来模拟队列,每次弹出都会把整个列表往前挪一位,总复杂度会退化成O(M×N×短边长度),在大矩阵上会慢到离谱。这在网上能搜到不少反面案例,都是用list硬扛BFS然后超时的。所以我不厌其烦地再次强调:Python里写BFS,队列一律用collections.deque。
4. 在线测试与样例验证
4.1 测试用例怎么设计
在线OJ测试遵循一个基本逻辑:样例过了不代表代码对了,样例没过那说明代码肯定有问题。真正的可靠性来自自己额外补充的边界用例。我来分享几个我实测时一定会加进去的用例。
第一个是最小规模:1 1,矩阵只有一个格子。如果它是黑色,答案应该是1;如果是白色,答案应该是0。这个用例能直接验证主循环是否正常执行,也能避免那种“运行起来连输入都读不对”的低级问题。
第二个是全黑矩阵:比如3 3全部是1。答案是1,整个矩阵就是一块纸片。这个用例能验证搜索是否能覆盖所有方向,也在极限上考验了DFS的递归深度。
第三个是全白矩阵:答案必须是0。有些人会在主循环里写if (grid[i][j] == 1)判断,全白矩阵没问题,但如果你没有判断颜色就进来搜索,就会把白色也算成一块,这是低级失误。
第四个是棋盘交错:黑白相间、类似国际象棋棋盘。这种矩阵中没有任何两个黑色格子是相邻的,所以黑色纸片数量等于黑色格子数量本身。这个用例能验证方向数组是否准确,尤其是会不会把斜对角错误地当成相邻。
下面用一个具体的样例走一遍:
输入: 4 5 1 1 0 0 0 1 0 0 1 1 0 0 1 1 0 1 0 0 0 1 输出: 4因为左上角两块连成一块,右上角两块连成一块,第二行第三列和第三行第三列再加第三行第四列又连成一块,最后角落里单独一个再加一个单独一个,总共4块。用这段样例跑三种语言版本,输出一致,可以初步确认代码逻辑。
4.2 不同OJ上的细节差异
在线测试时,不同平台在输入输出上会有一些微妙的差别。有的OJ是多组测试数据,读入到一个EOF才结束,代码就要包一层while(sc.hasNext())或while(cin >> m >> n);有的是单组测试,输入格式固定,直接读即可。标题里提到的在线测试,大部分情况下都是单组输入,但我建议在本地练习时把多组版本的写法也准备好,因为很多时候你放到题库里调试,后台数据就是多组的,少写一层循环就会少得很多分。
输出端的细节也值得提一句:换行符是标准答案的一部分,print(count)默认带换行,Java的System.out.println也带,C++的cout << count << endl也带。如果你习惯用System.out.print(count)或者cout << count,在某些严格比对输出结果的OJ上会被判成Presentation Error,这属于非技术原因丢分,非常可惜。
另外一个本地调试小技巧:如果你是用在线OJ提交,先在本地写好带样例的测试脚本,跑通之后再删掉测试代码只保留核心逻辑上传。比起直接在网页里盲改代码,这个流程能帮你节省大量时间。
5. 常见问题与实战避坑
5.1 递归深度造成的栈溢出
这是DFS方案最大的坑。Java里每个线程有一个固定的栈大小,递归层级太深会抛出StackOverflowError;Python里默认递归限制是1000层,超出后直接RecursionError。我印象最深刻的一次是拿一个2000×2000的全黑矩阵去测递归版DFS,Java和Python双双击穿,那一刻才真正理解BFS存在的意义。
针对这个问题的解法有三个层次:
- 如果矩阵规模已知较小,放心用递归DFS,调通即可。
- 如果矩阵规模可能较大,直接用BFS,根本不给栈溢出的机会。
- 如果想坚持DFS,可以手写一个显式栈来模拟递归,本质上是把系统栈换成堆内存里的栈,矩阵再大也不会栈溢出,代价是代码复杂一点。
顺带一提,在面试中如果你的解法能主动说出“我考虑过DFS的栈溢出风险,所以选择了BFS”,这是一个隐藏的加分项,因为它代表着你不仅会写代码,还懂得权衡工程风险。
5.2 边界判断顺序的经典失误
再强调一次,数组访问越界判断必须放在访问之前。C++、Java、Python这几种语言在这一点上的行为不同:Java越界直接抛异常,C++的越界是未定义行为(可能程序崩了也可能没崩,但结果是错的),Python会抛IndexError。不管是哪种语言,正确的写法永远是先把越界条件写在前面。
一个好的习惯是,把边界判断封装成一个独立的isValid(x, y, m, n)函数,或者用一个统一的if条件,让代码的可读性和安全性兼得。别小看这个细节,笔试环境下时间一紧张,这部分错误是最高发的一类。
5.3 多语言工程实现的性能差异
同样一道题,三种语言放在同一台机器上跑,速度差距是肉眼可见的。C++通常是最快的,Java次之,Python如果不加优化会慢不少。但这是语言特性决定的不公平竞赛,笔试环境不会要求你用Python跑上千万级的矩阵规模,所以不要有“Python太慢所以不配刷题”的错觉。
Python代码的性能优化优先级我总结一下:首选sys.stdin.buffer.read()读取数据,次选collections.deque做BFS队列,再次避免在循环内做列表推导式。这三点做到了,Python版本的黑白纸片在常规测试数据下都能跑进一两秒。C++优化的重点则是ios::sync_with_stdio(false)和cin.tie(nullptr)这两行,能极大降低读写开销。Java在笔试场景下的性能想再进一步,可以用BufferedReader替换Scanner,不过黑白纸片这题的数据量通常不大,Scanner足够。
5.4 答案统计的隐蔽逻辑错误
把“统计黑色纸片数量”误写成了“统计黑色格子的数量”,这是另一种常见错误。前者是在扫描主循环里遇到黑色格子才加一,而且加一的前提是它没有被任何一次搜索访问过;后者是只要看到1就加一。从字面上看两者很像,但结果差异巨大。要区分清楚,最好的办法就是在写完代码后手动跑一遍样例,对照标准输出,再额外加上“全黑矩阵”这个用例,一眼就能看出来逻辑是不是对的。
5.5 矩阵坐标压缩时的下标错误
用并查集方案时,二维坐标(i,j)到一维下标id的映射是id = i * n + j。这里最常犯的错误是把它写成i * m + j,这种错误在矩阵行列数不相等的时候表现特别隐蔽:程序不报错,也没有越界异常,就是答案不对。排查起来也困难,因为你只会在整个矩阵扫描完毕后发现返回值偏少或偏多,很难直接定位到是哪一行出了问题。怎么预防?把矩阵第0行的格子编号在草稿纸上演算一遍,确认0到n-1,第1行从n开始,所有行的编号能连续覆盖到m×n-1,这个映射就没问题了。
提示:在代码中用
id = i * n + j固定写法时,可以额外加一个if (id != i * n + j)的断言自检,虽然不影响最终答案,但在调试阶段能救命。
写在最后:这一题背后的面试观
黑白纸片这道题现在看来简单,但它背后藏着的面试逻辑是我最想分享的。春招笔试不是竞赛,它更看重的是“稳定发挥”,也就是在有限时间内,把一个中等难度的基础题做对、做稳、做干净。比如这道题,如果能在10分钟内写出DFS或BFS版本,并且考虑到了矩阵边界、访问标记、极端用例,那么这道题对你来说就是一道好题,它帮你建立起来的是“拿到任何连通性问题都可以套搜索框架”的底气。
我个人在实际操作中反复练过三种语言写同一个题,最大的收益不是多会了几种语法,而是能直观感觉到每种语言在设计上的取舍:Java的工程严谨、C++的底层自由、Python的表达简洁,各有各的侧重点。后续如果想把这个题目继续扩展,可以试试用这道题来练习“求最大黑色纸片面积”“求每个黑色纸片的周长”或者“判断两块纸片是否通过一个格子就能连接”,这些变体题目都能在黑白纸片的基础上无缝迁移,一通百通。