LeetCode 2943 这道题,说实话刚拿到手的时候我愣了一下。又是网格图、又是正方形空洞、又是最大化面积,乍一看还以为是道计算几何或者搜索题。但把题目读完、在草稿纸上画了两笔之后才发现,这题的本质其实是个很朴素的数组最长连续子段问题,考的是你能不能从几何图形里把关键信息抽出来。今天就把我从第一眼懵逼到顺利 AC 的完整过程写下来,包括这题的核心思路、代码模板、以及那些我在提交过程中踩过和预判出来的坑。
这道题适合正在刷 LeetCode 中等题准备面试的朋友,也适合那些一看到“网格图”三个字就条件反射地想写 DFS/BFS 的同学。看完之后你会发现,很多所谓的网格题,真正卡人的不是搜索,而是抽象能力。LeetCode 2943 的通过率不高,不是因为代码难写,而是因为题意理解这一步就筛掉了一批人。
1. 题目理解与破题思路
1.1 题目到底在说什么
题目描述里给了一个 n 行 m 列的网格图。注意这里的网格图可以理解成由 (n+1) 条水平线和 (m+1) 条竖直线交织出来的图形,每两条相邻水平线和两条相邻竖直线之间围成的格子就是一个小方格。
然后题目给了两个数组:hBars 和 vBars,分别表示被移除的水平线段编号和被移除的竖直线段编号。就拿水平线来说,从顶部到底部,编号依次是 0, 1, 2, ..., n。hBars 里的数字就是这些水平线中哪些被拆掉了。竖直线同理,从左到右编号 0, 1, ..., m,vBars 里的数字就是哪些竖直线被拆掉了。
这些线段被移除之后,原本相邻的两行或者两列之间就没有了隔断,它们就会合并成一个更大的空白区域。题目要求我们找出能在这片空白区域中放下的最大正方形的面积。
注意一个关键点:这个正方形必须是完整的,它的四边必须落在剩下的网格线上,内部不能有任何残留的线段横穿过去。换句话说,这个正方形对应的横向范围内,所有水平方向的隔断线都得被拆掉;纵向范围内,所有竖直方向的隔断线也得被拆掉。这样才能保证正方形内部是干干净净的一片空洞。
1.2 正方形空洞和“连续区段”的关系
我第一次读完题,脑子里冒出来的想法是:是不是要沿着被移除的线段做连通性分析?甚至想用并查集把相邻的被移除线段合并,然后看每个连通块能撑起多大的正方形。这个思路不能说错,但完全没必要,而且很容易把自己绕进去。
正确的打开方式是把问题拆成两个独立的方向来看。先说横向,也就是水平方向。网格原本有 n+1 条水平线,它们把网格分成 n 行。如果只有一条水平线被移除,比如第 1 条水平线没了,那它上下两行就会并成一个大行,这个合并区域的垂直跨度是 2 个格子。
如果连续两条水平线都被移除,比如第 1 条和第 2 条都没了,那原本被它们分隔的三行会全部合并在一起,垂直跨度是 3 个格子。
推广一下:如果有一串连续的水平线都被移除,数量是 k,那么这 k 条线原本分隔的 k+1 行区域会合并成一个完整的大区域,垂直跨度就是 k+1 个格子。
看到这里,问题就已经现出原形了。我们要找的,其实就是 hBars 数组里最长的连续数字区段长度。连续数字区段长度是 k,水平方向能提供的最大跨度就是 k+1。竖直方向完全同理,从 vBars 里找最长连续区段长度,然后加一得到竖直方向的最大跨度。
正方形要求长和宽相等,所以最终能形成的最大正方形边长就是这两个方向最大跨度的较小值。面积就是这个小值的平方。整个题目到这里就从一个几何问题变成了一个数组处理问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法:排序 + 最长连续段扫描
2.1 为什么要排序
题目给的 hBars 和 vBars 并不是有序的。就比如 hBars = [3, 1, 2],其实 1, 2, 3 是连续的三条线被移除,但它们在数组里是乱序的。如果你不对数组排序就直接从头到尾扫一遍,你会发现扫到 3 和后面某个元素根本不连续,从而漏掉正确的连续区段。
所以第一步必须排序。排完序之后,连续被移除的线在数组里的位置也是紧挨着的,这样我们只需要线性扫描一遍就能数出最长连续段。这是整个算法最基础、也最不能省略的一步。
排序的时间复杂度是 O(H log H),其中 H 是 hBars 的长度。vBars 同理。对于 LeetCode 的常规数据范围来说,这个排序代价非常小。
2.2 最长连续段扫描的写法
最长连续段的扫描写起来其实很简单,核心就是两个变量:一个记录当前连续段的长度,一个记录到目前为止看到的最长连续段长度。
cpp复制int getMaxContinuousLen(vector<int>& bars) {
if (bars.empty()) return 1;
sort(bars.begin(), bars.end());
int curLen = 1;
int maxLen = 1;
for (int i = 1; i < bars.size(); ++i) {
if (bars[i] == bars[i - 1] + 1) {
++curLen;
maxLen = max(maxLen, curLen);
} else {
curLen = 1;
}
}
return maxLen + 1;
}
这段代码的逻辑再直白不过:从第二个元素开始,每次检查当前元素和前一个元素是不是差 1。如果是,说明这两个编号的线是相邻的,当前连续段长度加一,并更新最大值。如果差不是 1,说明连续中断了,当前连续段长度重置为 1,然后继续往后扫。
注意我把 bars.empty() 的情况单拎出来返回了 1。这是因为如果没有线被移除,那网格里的一行格子仍然存在,跨度至少是 1。这个边界情况很重要,后面会专门说。
2.3 +1 这个关键细节是怎么来的
很多第一次写这道题的人,在 return maxLen + 1; 这一步会犹豫,甚至写错成 return maxLen;。这个 +1 恰恰是整个解题过程最容易出问题的地方。
我说一下它的来源。假设水平线编号为 0, 1, 2, 3, 4,对应 4 行格子。如果被移除的水平线是 1 和 2,那它们构成了一个长度为 2 的连续段。把这两条线抽掉之后,原本的格子行分布是这样的:
- 线
0和线1之间是第1行。 - 线
1和线2之间是第2行。 - 线
2和线3之间是第3行。 - 线
3和线4之间是第4行。
移除线 1 后,第 1 行和第 2 行合并成一块高度为 2 的区域。移除线 2 后,这块区域又和第 3 行合并,变成高度为 3 的区域。
也就是说,长度为 k 的连续被移除线段,最终能合并出 k+1 行的高度。这就是 +1 的几何来源。不要把这里的跨度误以为是 k,k 只是被拆掉的线数,真正能利用的格子行数是线数加一。
如果你还是容易搞混,就把它当成一个最简单的情况来记:只移除一条线 hBars = [1],它能合并出两行,所以跨度为 2。只要这个 case 你想明白了,+1 就不会错。
3. 代码实现与复杂度分析
3.1 C++ 实现
下面是完整的 C++ 解法,代码风格偏简洁,在 LeetCode 上可以直接跑通。
cpp复制class Solution {
public:
int maximizeSquareHoleArea(int n, int m, vector<int>& hBars, vector<int>& vBars) {
auto getMaxSpan = [](vector<int>& bars) -> int {
if (bars.empty()) return 1;
sort(bars.begin(), bars.end());
int curLen = 1;
int maxLen = 1;
for (int i = 1; i < bars.size(); ++i) {
if (bars[i] == bars[i - 1] + 1) {
++curLen;
maxLen = max(maxLen, curLen);
} else {
curLen = 1;
}
}
return maxLen + 1;
};
int hSpan = getMaxSpan(hBars);
int vSpan = getMaxSpan(vBars);
int side = min(hSpan, vSpan);
return side * side;
}
};
这里我把逻辑封装成了一个 lambda 表达式 getMaxSpan,这样 hBars 和 vBars 的处理逻辑完全复用。注意传入的是 vector<int>& 引用,因为在函数内部会对数组进行排序,排序会修改原数组。如果不想改动原数组,可以改成按值传递或者传 const 引用后在内部拷贝一份,但那样会增加不必要的开销。在 LeetCode 的评测环境下,直接排序原数组没有任何问题。
3.2 Python 实现
如果你平时用 Python 刷题,参考这个版本:
python复制class Solution:
def maximizeSquareHoleArea(self, n: int, m: int, hBars: List[int], vBars: List[int]) -> int:
def max_span(bars: List[int]) -> int:
if not bars:
return 1
bars.sort()
cur_len = 1
max_len = 1
for i in range(1, len(bars)):
if bars[i] == bars[i - 1] + 1:
cur_len += 1
max_len = max(max_len, cur_len)
else:
cur_len = 1
return max_len + 1
side = min(max_span(hBars), max_span(vBars))
return side * side
Python 的写法更简短,但核心逻辑完全一致。bars.sort() 是原地排序,会直接修改传入的列表。在 LeetCode 的判题流程中,函数参数本来就是每次调用新建的列表,所以不用担心影响其他测试用例。
3.3 时间与空间复杂度分析
时间复杂度由两部分组成:排序和线性扫描。
- 排序:
hBars长度记为H,vBars长度记为V,排序耗时分别是O(H log H)和O(V log V)。 - 扫描:对每个数组各扫一遍,耗时
O(H + V)。 - 总时间复杂度:
O(H log H + V log V)。
空间复杂度是 O(1),因为我们只用了几个整数变量,没有额外申请和输入规模相关的存储空间。
有一点很有意思:题目参数里的 n 和 m 在整个解法里根本没有用到。原因在于,hBars 和 vBars 已经直接告诉我们哪些线被移除了,而网格本身的初始行列数只影响线的编号范围,并不影响“最长连续段加一”的计算。反正被移除的线一定在合法编号范围内,我们只要处理题目给的数组就够了。
这个现象在 LeetCode 里不算罕见。经常有题目给了很多参数,实际解题时有一两个是冗余的。做题的时候留个心眼,如果发现某个参数没用上,不要慌,先确认思路没有遗漏,大概率是题目故意把信息给全了。
4. 踩坑记录与边界情况
4.1 空数组和单元素数组的处理
空数组是我最开始写的时候栽过的地方。如果 hBars 为空,说明没有任何水平线被移除,那水平方向的最大跨度就是 1。这个 1 指的是原始的一行格子。
为什么不是 0?因为即使一条线都没拆,网格里依然存在最小的 1 x 1 个格子。如果我们把最大的正方形空洞定义成“内部没有保留线段的正方形”,那任何一个原始单元格都满足条件,面积至少是 1。当然,如果你把“空洞”严格理解为必须由至少一条被移除的线产生的新区域,那空数组时答案或许是 0。但从这道题的实际通过标准来看,空数组返回 1 是安全的。
单元素数组,比如 hBars = [5],说明只有一条水平线被移除。它会把相邻两行合并,最大跨度是 2。按照 maxLen + 1 = 1 + 1 = 2 的公式,结果正好符合。所以单元素的 case 是验证你公式是否正确的好帮手。
4.2 重复元素和数据结构选择的误区
我见过有人用 unordered_set 来存 hBars,然后再遍历找连续区间。用哈希表本身不是不行,但如果你的连续段判断逻辑依赖“数组中相邻两个数字差为 1”,那哈希表就没法直接帮你判断,因为你还是得先找到所有被移除的线,然后尝试从某条线开始向两边扩展。这个做法的复杂度可能退化成 O(N^2),而且代码写起来更绕。
如果题目里允许重复元素,那就会有点麻烦。比如 hBars = [1, 1],排序后是 [1, 1]。扫描时第一个 1 和前一个 1 差是 0,不是 1,所以 curLen 会重置为 1,不会把重复元素误算成连续线段。这样处理其实是合理的,因为两条编号相同的线本质上是一样的线段,重复出现在数组里不产生额外效果。
不过更稳妥的做法是在处理前先做一次去重。但去重操作本身又引入额外开销,所以如果你能确认题目不会出现重复元素,直接排序扫描是最简洁的。
4.3 为什么答案是 min 的平方,而不是 max 的平方
这个点看起来显然,但我在评论区看到好几个人问过。横向能提供的跨度是 3,纵向能提供的跨度是 5,很多人第一反应是不是能形成一个 3 x 5 的矩形,然后取个 3 x 3 的正方形,面积 9?
问题是,如果横向跨度是 3,纵向跨度是 5,那能形成的最大正方形边长应该是 min(3, 5) = 3,面积是 9。但如果你错误地用最大值 5 的平方,得到 25,你就会试图构造一个 5 x 5 的正方形,可横向根本没有那么大的空洞,正方形会压在尚存留的线段上,非法。
这个道理就像木桶效应,正方形的一边被短的那块木板限制住了。所以最终一定是:
code复制side = min(hSpan, vSpan)
answer = side * side
在写代码的时候,先算 side,再平方,比直接写 min(hSpan, vSpan) * min(hSpan, vSpan) 更清晰,也避免你手滑写错。
5. 同类问题与扩展思考
5.1 如果要求最大矩形空洞呢
这道题只要求正方形,所以我们必须让横向和纵向跨度相等,取较小的值。那如果题目改成“最大矩形空洞的面积”,解法会变成什么样?
矩形不需要长宽相等,所以横向最大跨度 hSpan 和纵向最大跨度 vSpan 可以分别取到最大,面积就是 hSpan * vSpan。代码只需要改返回值:
cpp复制return hSpan * vSpan;
不需要取 min。
这个对比能帮助你更好地理解题目本质:正方形本质上是矩形的约束版本,额外加了一条长宽相等的硬性条件。
5.2 为什么不用 DFS 或并查集
我前面提到,我第一次看到这个题差点写并查集。这里认真说一下为什么不推荐。
用并查集的思路是:把相邻的被移除线段看成连通块,然后找连通块覆盖的范围。这个思路如果要实现,需要维护水平线和竖直线的二维关系,并且最终还是要计算每个连通块在横纵两个方向上的跨度。实现复杂度远高于简单的排序扫描,而且很容易在处理二维映射时出错。
DFS 的思路就更绕了。你会想从某个被移除的线段出发,向四个方向扩展,看能扩展到多大的正方形。但网格本身是 n 行 m 列,而真正决定空洞大小的只是被移除线段的编号连续性。DFS 在这种问题里属于“过度建模”。
所以这道题最合适的思维模型就是:把每条被移除的水平线想象成把原本的若干行“解锁”了,连续的解锁行数决定了跨度。不要被“网格图”三个字带偏。
5.3 从这道题联想到其他网格题
网格题在 LeetCode 里是个大类别,但它们的核心考察点其实差别很大。
LeetCode 200 岛屿数量:考察的是图的遍历,DFS/BFS 是标配。LeetCode 695 岛屿的最大面积:同样考察遍历,加上面积统计。LeetCode 85 最大矩形:考察单调栈和前缀和,把二维矩阵按行转化成一维柱状图问题。LeetCode 221 最大正方形:考察动态规划,dp[i][j]表示以某个点为右下角的最大正方形边长。
LeetCode 2943 和它们都不一样,它考察的是“连续区段”这一概念如何从几何图形中抽象出来。这提醒我们,看到网格题不要形成条件反射式地套用搜索算法,先想想题目到底在优化什么变量。很多网格题表面上长得一模一样,背后的数学模型可能完全不同。
5.4 如果移除的线段不连续,但中间隔了障碍物呢
这是一个很自然的扩展思考。假设网格里有一些格子本来就是不可用的障碍物,那即使水平线连续被移除,只要中间有障碍物,空洞也无法贯通。这种情况下,问题就回归到了经典的二维最大正方形问题,需要用动态规划来解决。
动态规划的状态转移方程是:
code复制dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
其中 dp[i][j] 表示以当前位置为右下角的最大正方形边长。这个场景和 LeetCode 2943 是两种完全不同的思路,但放在一起对比看会很有意思:同样是找最大正方形,一个是纯粹在线的移除关系上做文章,一个是在格子的占用关系上做文章。
我在实际复盘这道题的时候,最大的收获不是学会了这段连续区间的扫描代码,而是意识到读题阶段多花两分钟做几何推导,能省下后面半小时的弯路。如果你在面试中遇到这道题,哪怕一时想不出最优解,也可以先说出“排序后找最长连续段”这个关键观察,这已经能向面试官证明你读题读到位了。
最后再分享一个小技巧:遇到这种涉及网格、线段、格子的题目,先别急着敲代码,拿张纸把最小规模的例子画出来。比如画一个 3 x 3 的网格,手动移除几条线,看看合并效果,再回头理解题目里的“空洞”到底长什么样。很多时候,思路就是在画图的过程中自己冒出来的。
