LeetCode 2943 是一道很有迷惑性的中等题,题面在网格图上求“最大正方形空洞面积”,听起来像搜索、DP、连通块,实际上只要把模型抽象到位,解法就是排序加一次遍历。我第一次拿到这题时差点往 BFS 方向走,后来冷静分析 hBars 和 vBars 的含义,才发现它考的是“一维最长连续段”的组合,核心代码不到 20 行。这篇文章我把整个思考链路完整写出来:题意怎么理解、连续段模型怎么建立、那个常见的“+1”坑在哪里、多语言实现怎么写、还有提交过程中的典型报错现场。适合刚刷题不久、想搞懂这类“网格图 + 稀疏边”题型的读者,也适合面试前突击中等题思路的人。
1. 题目速览与思路定位
1.1 题目到底想让我们算什么
题目给出的网格不是那种二维数组格子,而是由横线和竖线交织成的网格图。假设网格有 m 行 n 列小方格,那么横向会有 m+1 条线,纵向会有 n+1 条线,这些线的编号是连续的。hBars 数组里面存的是可以删除的横向线的编号,vBars 数组里存的是可以删除的纵向线的编号。删除这些线之后,某些原本被隔开的小格子会连成更大的空腔,题目要求的是“能形成的最大正方形空腔面积”。
关键点是:数组里给定的线是“全部删除”,不是让你挑一部分删。所以问题变成了:在给定可删集合的前提下,网格里能形成的最大正方形有多大。举个例子,一个 2x2 的网格,如果把中间横线和中间竖线都删掉,四个小格子就连成了一个 2x2 的大正方形,面积是 4。如果只删中间横线,上下两行虽然打通了,但横向宽度只有 1,最大正方形只能取 1x1,面积是 1。
从这个例子能看出,正方形空洞要求“高度”和“宽度”同时达标。高度由横向可删边的连续情况决定,宽度由纵向可删边的连续情况决定,最后两边取较小值,才是正方形边长。
1.2 这道题真正考的是什么
这道题不考搜索,不考动态规划,也不考并查集。它考的是你能不能把一个二维网格问题拆成两个一维问题。
行方向上,连续删除的横向线数量决定你能“纵向打通”多少行;列方向上,连续删除的纵向线数量决定你能“横向打通”多少列。正方形要求两边相等,所以答案就是两个维度各自能提供的最大连续长度的较小值,再平方。
这种“表面是网格,内核是数组”的中等题在 LeetCode 里其实不少。做题时我习惯先反问自己:决定答案的关键信息是什么?对这题来说,决定答案的不是每个格子的坐标,而是“哪些相邻行被打通了”和“哪些相邻列被打通了”。一旦意识到这一点,题目就从二维降到了一维,后续只需要处理两个数组。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心模型:为什么答案只跟“连续可删边”有关
2.1 行方向拆解:连续横边决定高度
想象一排教室,每两个相邻教室之间有一堵墙。墙如果拆掉,两个教室就合并成一个更大的房间。网格图也是同样的道理:横向线就是分隔上下两行的“墙”,如果连续拆掉 k 条横向线,就能把 k+1 行格子纵向打通。
举个例子,一个 3 行的网格,横线编号为 0、1、2、3,其中编号 1 和 2 是内部横线。如果 hBars 里包含 [1,2],说明两条内部横线都能删,三行全部打通,高度就变成了 3。如果 hBars 里只有 [1],那只能打通第 1 行和第 2 行,高度是 2,第三行依然被编号 2 的横线挡住。
这里有个容易搞混的点:题目给的是“线的编号”,不是“格子编号”。连续删掉 1 条线,打通的是 2 行;连续删掉 2 条线,打通的是 3 行。所以行方向能提供的最大高度,等于“最长连续可删横边数 + 1”。
2.2 列方向拆解:连续竖边决定宽度
列方向完全对称。纵向线是分隔左右两列的“墙”,连续删除的纵向线越多,能横向打通的列数就越多。最大宽度同样等于“最长连续可删竖边数 + 1”。
比如一个 4 列的网格,竖线编号 0 到 4,内部可删竖线编号 1、2、3。如果 vBars = [2,3],那么第 2、3、4 列可以横向打通,宽度是 3,也就是连续删了 2 条竖边,得到了 3 列宽。
正方形空洞的边长,在数值上等于:行方向最大高度和列方向最大宽度中较小的那个。所以两个维度的信息是独立统计的,最后再合并。
2.3 最容易踩的坑:那个“+1”到底从哪来
很多题解直接写答案等于 (最长连续段长度 + 1)^2,但如果不理解为什么加 1,代码很容易抄错。
原因回到模型本身:连续删掉 k 条边,会打通 k+1 行或 k+1 列。比如一条横线把上下两行分开,删掉它之后,两行合并成高度为 2 的区域。所以“连续删 1 条边”对应的边长是 2,“连续删 2 条边”对应的边长是 3。
统计代码里有两种常见习惯:一种是在统计连续段时,cur 初始化为 1,每遇到一个连续可删边就加 1,最后得到的 cur 代表“连续可删边的条数”,然后整体再加 1;另一种是 cur 初始化为 1,每遇到一个连续可删边就加 1,最后 cur 直接就代表“能打通的格数”,不需要额外加 1。两种写法都能 AC,但混着用就会差 1。
我建议代码里统一用一个辅助函数,返回“最长连续可删边的条数”,主逻辑中再加 1。这样语义最清楚:连续边数是一条、两条、三条,而实际边长是边数加一。
3. 算法设计:排序 + 最长连续段
3.1 整体流程
整个算法分成四步:
- 对 hBars 排序。
- 遍历 hBars,统计其中最长连续编号段的长度 maxH。
- 对 vBars 排序。
- 遍历 vBars,统计最长连续编号段的长度 maxV。
最后,正方形边长 = min(maxH + 1, maxV + 1),答案 = 边长的平方。
为什么要排序?因为统计“最长连续编号段”需要相邻比较,只有数组有序,才能在一次遍历中判断当前编号和上一个编号是否连续。排序的复杂度是 O(H log H + V log V),其中 H 和 V 分别是两个数组的长度。
这个流程的核心是:两个维度分别处理,最后用一个 min 合并。不要试图把横边和竖边混在一起处理,那样反而会把简单问题复杂化。
3.2 连续段统计的几种写法
统计最长连续段的方法很固定,但初始值细节容易出问题。我给一个经过多次验证的辅助函数,返回连续边的最大长度:
cpp复制int getMaxConsecutive(vector<int>& bars) {
if (bars.empty()) return 0;
sort(bars.begin(), bars.end());
int cur = 1, best = 1;
for (int i = 1; i < (int)bars.size(); ++i) {
if (bars[i] == bars[i - 1] + 1) {
++cur;
best = max(best, cur);
} else {
cur = 1;
}
}
return best;
}
这里的 cur 表示“当前连续段中可删边的条数”,best 记录历史最大值。注意 cur 和 best 的初始值都是 1,因为只要数组非空,至少有一条可删边,最长连续段长度至少为 1。空数组单独返回 0。
如果你更喜欢“直接用边长”的写法,可以把函数改成这样:
cpp复制int getMaxSpan(vector<int>& bars) {
if (bars.empty()) return 1;
sort(bars.begin(), bars.end());
int cur = 1, best = 1;
for (int i = 1; i < (int)bars.size(); ++i) {
if (bars[i] == bars[i - 1] + 1) {
++cur;
best = max(best, cur);
} else {
cur = 1;
}
}
return best + 1;
}
这个函数返回的是“能打通的格子数量”,也就是已经包含 +1 后的边长。不过我个人还是推荐第一种写法,因为“连续边数”和“边长”分开算,逻辑上更不容易错。
3.3 复杂度分析
排序是主要开销,统计过程是线性遍历。所以总时间复杂度是 O(H log H + V log V),空间复杂度是 O(1),不考虑排序本身使用的栈空间。
这里有一个值得注意的点:题目的 m 和 n 可能很大,比如 1e5 甚至更大,但我们完全不需要遍历整个网格,只需要处理 hBars 和 vBars 两个数组。这是因为可删边的数量有限,真正影响答案的是“边是否连续”,而不是网格的完整尺寸。这种“稀疏边驱动”的题型,核心就是抓信息量小的维度。
如果面试官追问能不能优化到 O(H + V),答案是:如果输入数组本身有序,统计过程就是线性的,不需要排序,复杂度直接降到线性。
4. 正确性说明与反例思维
4.1 为什么可以直接取最大值
假设行方向能提供的最大连续高度是 Hmax,列方向能提供的最大连续宽度是 Vmax。任意一个可行正方形,它的高度一定由某段连续横边决定,长度不可能超过 Hmax;它的宽度一定由某段连续竖边决定,长度不可能超过 Vmax。所以任意可行正方形的边长都不可能超过 min(Hmax, Vmax)。
反过来,取行方向最长连续段和列方向最长连续段,这两个段可以在网格中同时使用,因为它们一个影响纵向,一个影响横向,互不干扰。取完这两个段之后,能形成的正方形边长正好是 min(Hmax, Vmax),这个正方形一定存在。所以最大值恰好等于 min(Hmax, Vmax)。
这个逻辑其实是一种简单的贪心:目标函数是“取较小值”,那么两个维度的值都应该尽量大。最大值之间的组合一定不差于非最大值的组合。
4.2 如果不用贪心会怎样
如果不取最大值,而是枚举所有行连续段和列连续段的组合,那么答案也不会变。因为对于任意两个可能值 A 和 B,min(A, B) 的最大值一定出现在 A 取最大、B 取最大时。这个性质在数学上可以写成:
max over all segments (min(rowSpan, colSpan)) = min(max rowSpan, max colSpan)
这个等价关系是本题最核心的洞见。枚举组合的复杂度会达到 O(H^2 + V^2),完全没有必要。如果题目改成“最大矩形空洞”,这个性质虽然还成立,但就不需要取 min,直接行最大乘列最大;进一步的变体如果限制长宽比例,枚举就不可避免。这也是面试官可能追问的扩展点。
5. 多语言代码实现
5.1 Python 实现与说明
Python 写这道题非常简洁,核心就一个辅助函数加一次调用:
python复制from typing import List
class Solution:
def maximizeSquareHoleArea(self, m: int, n: int, hBars: List[int], vBars: List[int]) -> int:
def max_consecutive(bars: List[int]) -> int:
if not bars:
return 0
bars.sort()
cur = 1 # 当前连续段的边数
best = 1
for i in range(1, len(bars)):
if bars[i] == bars[i - 1] + 1:
cur += 1
best = max(best, cur)
else:
cur = 1
return best
high = max_consecutive(hBars) + 1 # 纵向能打通的行数
width = max_consecutive(vBars) + 1 # 横向能打通的列数
side = min(high, width)
return side * side
这里 max_consecutive 返回的是“连续边数”,比如 [3,4,5] 返回 3。主逻辑中加 1,得到 4,代表 4 行或 4 列被打通。如果两个数组都为空,max_consecutive 返回 0,side 变成 1,答案 1,对应网格里最小的 1x1 单元格,这是合理的下界。
5.2 C++ / Java 实现
C++ 版本我习惯写成 lambda 辅助函数,避免额外写一个全局函数:
cpp复制class Solution {
public:
int maximizeSquareHoleArea(int m, int n, vector<int>& hBars, vector<int>& vBars) {
auto max_consecutive = [](vector<int>& bars) -> int {
if (bars.empty()) return 0;
sort(bars.begin(), bars.end());
int cur = 1, best = 1;
for (int i = 1; i < (int)bars.size(); ++i) {
if (bars[i] == bars[i - 1] + 1) {
++cur;
best = max(best, cur);
} else {
cur = 1;
}
}
return best;
};
int high = max_consecutive(hBars) + 1;
int width = max_consecutive(vBars) + 1;
long long side = min(high, width);
return (int)(side * side);
}
};
Java 版本思路一样,只是语法不同:
java复制class Solution {
public int maximizeSquareHoleArea(int m, int n, int[] hBars, int[] vBars) {
int h = maxConsecutive(hBars) + 1;
int v = maxConsecutive(vBars) + 1;
long side = Math.min(h, v);
return (int)(side * side);
}
private int maxConsecutive(int[] bars) {
if (bars.length == 0) return 0;
Arrays.sort(bars);
int cur = 1, best = 1;
for (int i = 1; i < bars.length; ++i) {
if (bars[i] == bars[i - 1] + 1) {
++cur;
best = Math.max(best, cur);
} else {
cur = 1;
}
}
return best;
}
}
C++ 和 Java 里我特意用 long long 存储 side 再乘,因为当数组长度达到 1e5 时,边长也可能是 1e5 级别,平方后是 1e10,超过了 int 的上限。实际题目返回值如果声明为 int,强转回去即可;如果不强转,有些编译器会有警告。面试时主动提这个点会显得你考虑问题更全面。
5.3 处理空数组和边界值的细节
空数组是这题最容易被忽略的边界。如果 hBars 为空,说明没有任何横向线可删,那么纵向能打通的高度就是 1;vBars 为空同理。此时如果另一个维度有连续段,正方形边长会被 1 限制住,答案最多是 1。我在第一次写的时候,辅助函数对空数组直接返回 0,主逻辑加 1,看起来没问题,但有的人会在辅助函数里返回 1,然后主逻辑再加 1,结果空数组会得到 2,整个答案全错。
另一个细节是数组重复。题目如果保证 hBars 和 vBars 内部元素互不相同,那排序后直接判断相邻即可;如果不保证重复,排序后 [1,1,2] 会被误判成连续段长度 3,实际上是 1 到 2 的连续,重复的 1 不产生额外收益。稳妥起见,可以在统计前先用 set 去重,或者先把数组转成有序去重后的 list。LeetCode 原题一般保证无重复,但工程实现时这个细节值得注意。
还有一个问题是排序会修改原数组。如果你不想让外部传入的数组被改变,可以在辅助函数里对数组副本排序,或者说明这一点。C++ 中 sort 接收引用,会直接修改原数组;Python 中 bars.sort() 也一样。我在实际代码里通常可以接受这种修改,因为刷题场景下后续不再使用原数组。
6. 常见错误与调试实录
6.1 排序遗漏导致连续段统计错乱
这个错误最基础,但确实容易发生。如果 hBars 是 [2,1,3],不排序直接遍历,判断 nums[i] == nums[i-1] + 1 会得到完全错误的结果。排序后变成 [1,2,3],连续段长度一眼就能看出是 3。
我建议把排序放进辅助函数内部,而不是在外层排序。这样不管谁调用辅助函数,都不会因为忘记排序而出错。
6.2 初始计数值设错,结果差 1
我见过很多代码,统计连续段长度时 cur 初始化为 0,然后遇到一个元素就加 1。这样第一段连续长度会被少算 1。举个例子,[3,4,5] 这条连续段,正确边长是 4;如果 cur 从 0 开始,容易得到 3。
解决办法:cur 和 best 都初始化为 1,从下标 1 开始遍历。数组为空时单独返回 0。这个写法我已经验证过很多次,最稳。
6.3 把“线编号”当成“格子编号”
题目中的 hBars 和 vBars 是线的编号,不是格子的编号。一个 m 行网格有 m+1 条横线,内部可删横线编号通常是 1 到 m-1。连续删 2 条线会打通 3 行,不是 2 行。如果读者把这个搞混,答案会系统性偏小。
在线编号和格子编号的差别,本质上是“间隔数”和“点数”的关系。排成一排的 n 个点之间有 n-1 个间隔,删除一个间隔相当于把两侧的点连成同一个连通块;如果删掉了 k 个连续间隔,那么被连起来的点数是 k+1。
6.4 返回值类型溢出
当 m、n 能到 1e5 时,边长最多约 1e5,面积约 1e10。int 类型最大值约 2.1e9,直接返回会溢出。LeetCode 官方题解中,这个题目的返回值类型是 int,大部分测试用例的答案不会超过 int 范围,但为了安全,先转 long long 再乘是更好的习惯。
调试时我在本地跑了一个 m=n=100000 的测试用例,发现直接 return side * side 会返回一个负数,查了十分钟才意识到是溢出问题。改成 long long 之后问题消失。
下面把典型问题整理成一个速查表:
| 错误类型 | 典型现象 | 解决方案 |
|---|---|---|
| 未排序 | 连续段统计错乱 | 排序放在辅助函数内部 |
| cur 初始值错误 | 答案差 1 | cur 和 best 初始为 1,空数组特判 |
| 混淆线与格子 | 高度/宽度系统性少 1 | 记住连续删 k 条边打通 k+1 个单位 |
| int 溢出 | 大用例返回负数 | 用 long long 保存边长和面积 |
| 数组重复 | 连续段误判偏大 | set 去重或确认题目保证无重复 |
7. 扩展与面试追问
7.1 如果要求最大矩形空洞呢
如果题目变成“最大矩形空洞面积”,解法反而更简单:因为矩形长宽不需要相等,行方向最长连续段和列方向最长连续段可以直接组合,面积就是 Hmax * Vmax。正方形题目之所以要取 min,是因为长宽受限必须相等。这个对比在面试中很能体现思维灵活性。
但注意,如果矩形还有其他限制,比如必须包含某个指定点,或者长宽比例固定,那问题就复杂了,可能需要枚举一个维度再用二分、前缀和之类的技巧。这就是面试官层层加码的套路。
7.2 相关题型与迁移
这类“连续段”统计的题在 LeetCode 里很常见。比如 LeetCode 84“柱状图中最大的矩形”和 LeetCode 85“最大矩形”,虽然用的是单调栈,但同样依赖“连续”的概念。如果你想巩固这个知识点,可以把这三道题放一起刷。
另一个迁移方向是“稀疏图”思维。当网格尺寸很大但有效数据很少时,我们的算法复杂度应该由有效数据量决定,而不是网格面积。这题里的 hBars 和 vBars 就是典型有效数据,它们的长度决定了算法效率。
7.3 面试官可能追问的点
面试中如果遇到这题,对方大概率会问几个问题:
- 为什么不需要遍历整个网格?因为答案只由“边是否连续”决定,网格尺寸只是限制边长的上界。
- 如果输入数组已经有序,复杂度是多少?统计是线性的,总体复杂度 O(H + V)。
- 如果有重复元素怎么办?先去重再统计。
- 如果 hBars/vBars 里有边界编号(比如 0 或 m)怎么处理?根据题意,边界删除不会形成内部空洞,最好过滤掉边界编号,只考虑内部线。
这些追问其实都是在考察边界意识和复杂度分析能力。能把这些点讲清楚,比单纯背解法更能体现水平。
最后分享一个我自己的做题习惯:遇到网格图题目,先问自己这是“像素级问题”还是“线级问题”。像素级问题往往需要搜索、DP、最短路;线级问题往往可以抽象成区间、连续段、扫描线。这题显然属于后者。如果读者在面试或周赛中碰到这题,建议从“删掉一条边能带来多大空间”开始推演,多画几个例子,很快就能发现连续段的规律,然后代码就是水到渠成的事。
