1. 先搞清楚题目在问什么:这题根本不是搜索题
LeetCode 2943“最大化网格图中正方形空洞的面积”,我第一次在周赛里碰见它,顺手就想到了 DFS 染色或者网格 BFS——毕竟题目里又是“网格图”又是“空洞”,很难不往二维搜索方向联想。但如果你真拿一个二维矩阵去标记哪些格子被删,然后辛辛苦苦扫连通块,多半会写着写着发现又蠢又没必要。这题的 tag 虽然叫 medium,实际难度更多地落在“能不能把二维场景抽象成一维连续段”上,代码量反而小得可怜。
先说清楚题要表达的场景:网格本身有若干条横向隔断和纵向隔断,把大区域切成一个一个的小方格。现在系统中给出两组坐标,分别表示哪些横向隔断、哪些纵向隔断处于“可通行/被拆除”状态。我们希望在这些隔断都被拆除之后,能从网格里圈出一个完整的方形空洞,空洞内部不再有任何横竖隔断阻挡,问这个空洞的面积最大能到多少。
这看起来像是个铺地板的问题,但真正要关心的不是每个格子的状态,而是“横向到底能打通多长的一段连续区域,纵向到底能打通多长的一段连续区域”。一旦把注意力从格子转移到隔断线上面,解法立刻就清晰了。
我最后提交的代码不超过三十行,核心就两个步骤:
- 对横向隔断坐标排个序,找出最长的连续坐标段。
- 对纵向隔断坐标排个序,同样找出最长的连续坐标段。
- 取两者中较小的值作为正方形边长,边长平方就是答案。
下面我会把每一步推导、实现以及我在写过程中踩过的几个坑完整展开。如果你正在刷周赛题或者准备面试,建议把这篇的思路和模板记下来,因为这类“删除若干条线后求最大连续跨距”的题,在近几次周赛里出现频率不低。
1.1 空洞的本质:横竖两条“连续贯通带”的交集
先打个比方。想象你面前有一排多米诺骨牌,骨牌与骨牌之间有一些小挡板。如果某些挡板被抽掉,你就获得了从一块骨牌滑到另一块骨牌的空间。问题是:你能获得多长的一段无阻挡滑动区?
网格图里的横向隔断和纵向隔断就是这样的小挡板。假设网格原来被 n 条横向隔断分成 n+1 层区域,被 m 条纵向隔断分成 m+1 列区域。当你拆除一条横向隔断时,它上下两侧的格区就连通了;拆除多条相邻的横向隔断时,整个纵向跨度会变得更大。
注意“相邻”这个词,它是整道题的题眼。
如果拆除的横向隔断坐标是 [1, 2, 4],坐标 1 和 2 相邻,它们能合并出跨过 3 个横向格区的大空隙;而坐标 4 是孤立的一段,它只能单独支撑跨过 2 个格区的小空隙。纵向的分析完全一样。所以一个完整空洞要能框出一个正方形,就要横向存在一个足够大的连续贯通带,纵向也存在一个足够大的连续贯通带,两个带子交叠出来的矩形区域就是空洞。
正方形特有的约束是宽高必须相等。如果横向能贯通 5 个格宽,纵向只能贯通 3 个格高,那最优不可能让空洞的边长比 3 更大,多出来的横向宽度在这个正方形里没有意义。
1.2 为什么要从“隔断条数”换算成“可占格数”
这一步是我最初最容易犯糊涂的地方。假设你在坐标集合里找到了连续 3 条横向隔断都被拆除,那么上下方向能腾出的格区数量并不是 3,而是 4。
可以拿最朴素的例子验证:一个 2 x 2 的网格,原本有四格,中间有一横一竖两条隔断把它分成四份。如果横坐标、纵坐标都只有一条可被拆除,也就是连续段长度为 1,那么拆除后整个 2 x 2 区域都会变成一个没有内部障碍的空洞。空洞边长显然是 2,不是 1。所以真实边长要按“连续拆除条数 + 1”计算,1 条隔断对应两格,2 条连续隔断对应三格,这个关系在任何维度上都成立。
我在初版代码里直接拿连续段长度作为边长去平方,测试样例输出了一个非常离谱的 1,就是因为漏了这条规则。把加一操作补上后,这道题的答案逻辑才真正闭合。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一行心法:怎么高效算出一维最长连续段
既然横向和纵向处理方式完全相同,你就可以把精力集中在写一个干净的一维工具函数上。
输入是一个可能乱序的整数数组,数组元素表示被拆除的隔断坐标。要返回的值是“这个数组里最长连续相邻坐标的长度是多少”。比如 [3, 1, 2],排序后是 [1, 2, 3],最长连续段为 3;[5, 1, 4, 2] 排序后是 [1, 2, 4, 5],最长连续段分别是 2 和 2,所以结果也是 2。
最直白的做法是排序后一次线性扫描,完全足够应付大范围数据。因为排序本身是 O(N log N),而后续扫描只有 O(N),整体瓶颈在前面。后面你如果想优化到严格 O(N),可以用哈希集合做类最长连续序列的算法,但考虑到输入规模,排序方案的代码更短,也更不容易写错。
2.1 一次扫描的完整细节
写这个工具函数时,我建议你用“当前连续长度”和“全局最长连续长度”两个变量来滚。先排序,然后从第二个元素开始遍历,比较当前元素和上一个元素的关系:
- 如果当前元素等于上一个元素加 1,说明连续段还在继续,当前连续长度加 1。
- 如果当前元素等于上一个元素本身,说明输入里有重复坐标,直接跳过,不能算作连续,也不能重置连续计数。
- 其他情况说明断开了,当前连续长度重置为 1。
遍历过程中不断用当前连续长度刷新全局最大值。
这里有一个很多题解不会特别提的点:排序后仍然可能出现重复值。题目数据规范时也许不会给你重复的拆除坐标,但我在对拍和写测试时发现,如果数组里有重复,一个不慎就会把两个相同数字误判成连续关系。比如 [1, 1, 2],如果你直接用 arr[i] == arr[i-1] + 1 判断,从第二个 1 到 2 时会算出连续长度 2,但这个连续并不真实存在。所以自己在实现时最好写上“跳过与前一个相等的元素”这一分支,成本几乎为零,却能避免一类隐蔽 bug。
2.2 手写实现示例
我用 C++ 写了个最简洁的版本:
cpp复制int longestConsecutiveRun(vector<int>& bars) {
if (bars.empty()) return 0;
sort(bars.begin(), bars.end());
int best = 1;
int cur = 1;
for (int i = 1; i < (int)bars.size(); i++) {
if (bars[i] == bars[i - 1]) {
continue;
}
if (bars[i] == bars[i - 1] + 1) {
cur++;
} else {
cur = 1;
}
best = max(best, cur);
}
return best;
}
如果你用 Python,可以写成:
python复制def longestConsecutiveRun(bars):
if not bars:
return 0
bars.sort()
best = 1
cur = 1
for i in range(1, len(bars)):
if bars[i] == bars[i - 1]:
continue
if bars[i] == bars[i - 1] + 1:
cur += 1
else:
cur = 1
best = max(best, cur)
return best
注意这里返回的连续段长度没有加一。我的习惯是让工具函数只负责“连续坐标的条数”,到底要加几个 1、要不要平方,全部放到外层调用处去处理。这样职责更单一,之后如果题目改成“矩形空洞”,只需要改动外层,内层函数完全可以复用。
2.3 你以为的边界陷阱,其实只是坐标语义
很多第一次做这道题的人会纠结:“数组里的隔断坐标到底是从 1 开始还是从 0 开始?”实际上这个细节不影响算法本身,只要你把连续段条数加一后作为跨度。假设网格在某个方向总共有 L 个格子,那么合法的内部隔断坐标范围一般是 1 到 L-1。如果一条都拆除不了,best 是 0,加一后得到 1,代表单个格子本身也可以被视为一个面积为 1 的最小空洞。
所以边界问题根本不影响“连续段求法”,它只影响如果你不加一、或者凭空多加了边界坐标时输出会不会对。注意:不要手动把 0 和 L 这两个外部边界塞进数组里。我们需要打通的是内部隔断,外部边界即使不拆除也天然限制着区域范围。如果你把外部边界也加进数组,会导致真实可扩大的跨度被高估,得出错误答案。
这条建议是我在测试一个样例时发现的:当时我手动把边界加进 bars,再去统计连续段,算出来的正方形边长比实际理论值多了 1。仔细复盘后发现,外部边界应该作为区域的固定端点存在,它不参与“拆除后增加跨度”这个过程。
3. 二维合成:为什么正方形的边长要落在横向和纵向的较小值上
现在我们已经能从横向隔断坐标数组里提出一个数 H_run,表示横向最长连续拆除条数;同样能从纵向隔断坐标数组里提出 V_run。假设横向真实可用跨度是 H = H_run + 1,纵向真实可用跨度是 V = V_run + 1。那么这道题的答案是不是直接等于 H * V 呢?不是,因为题目要求的是正方形空洞,不是矩形空洞。
如果要求矩形,横向取满 H、纵向取满 V,直接乘起来就是最大矩形面积。但正方形多了一个限制:长度和宽度必须一致。你可以把横向连续贯通区理解成一段长度为 H 的“可活动行范围”,把纵向连续贯通区理解成一段长度为 V 的“可活动列范围”。一个边长为 k 的正方形空洞想要存在,至少需要横向连续区域长度不低于 k,纵向连续区域长度也不低于 k。反过来,只要横向可用长度至少是 k、纵向可用长度至少是 k,就能在两个连续贯通带的交叠区域里取到一个完整的 k x k 方块。
于是问题变成:在所有不超过 H 也不超过 V 的正整数 k 里面,最大的 k 是谁?当然是 min(H, V)。所以正方形空洞的最大面积就是 min(H, V) * min(H, V)。
更严谨一点,我们可以画一个简单的数轴例子。比如横向连续拆除的坐标是 [1, 2],那横向能撑起一个宽 3 格的贯通区域,覆盖横向第 1、2、3 个格区;纵向连续拆除的坐标是 [1, 2, 3],纵向能撑起一个高 4 格的贯通区域。横向最大只能支持边长为 3 的正方形,因为即使在横向那个 3 格宽的带子里,也没有第 4 个格区可以延伸;所以横向和纵向的可选边长集合分别是 {1, 2, 3} 和 {1, 2, 3, 4},取交集最大值 3,面积 9。
这个取 min 的逻辑在几何上非常像“木桶效应”:能装多少水不取决于最长的那块板,而取决于最短的那块板。正方形空洞的面积由横向、纵向两个方向中较短的那个决定,哪怕另一个方向能打通一百格,也完全无法帮正方形多撑出一格。
为了让你更直观地体会这个合成过程,我举一个带断点和偏移的完整例子:
- 横向被拆除坐标:
[1, 2, 3, 5]。排序后连续段有两个,长度分别为 3 和 1,最长为 3,横向可用格数H = 4。 - 纵向被拆除坐标:
[1, 2, 4, 5, 6]。连续段也有两个,长度分别 2 和 3,最长为 3,纵向可用格数V = 4。 - 最优正方形边长
min(4, 4) = 4,面积16。
你会发现横向连续段在坐标 1~3,纵向连续段在坐标 4~6,它们位置并不对齐,但这完全不影响最终正方形落在哪。因为横向的 4 格宽带子内部每一行都能横向贯穿,纵向的 4 格高带子内部每一列都能纵向贯穿,两者交集处自然会形成一个 4x4 的区域,这个区域整体没有任何内部隔断。
3.1 容易被忽略的“面积单位”问题
还有一个值得提醒的地方:这道题求的是空洞面积。如果你的空洞边长是 k,面积应该是 k * k,不是 (运行长度) * (运行长度),也不是单纯返回运行长度。
我见过有人把横纵运行长度按上述方法各自加一后,选择较小值,然后把这个较小值直接当作面积返回。例如横向运行长度 3、纵向运行长度 4,算出的最大边长是 4(因为 3+1=4, 4+1=5, 取小值 4),面积却填了 4。这个答案离正确值差了四倍。很多样例能通过,是因为样例里正好边长为 1,此时边长和面积相等,掩盖了问题。一旦遇到稍大一点的输入,错误就会在 WA 里现形。
所以外层逻辑应为:
code复制int runH = longestConsecutiveRun(hBars);
int runV = longestConsecutiveRun(vBars);
int width = runH + 1;
int height = runV + 1;
int side = min(width, height);
return side * side;
先加一,再取 min,最后平方。这三个操作的先后顺序千万不能乱。
3.2 手算验证三条基本样例
我建议你把下面三个边界样例在自己的草稿纸上推一遍,这样可以快速验证自己对公式的理解。
第一个样例:hBars = [], vBars = []。运行长度分别是 0 和 0,加一后 H=1,V=1,答案是 1。网格里没有任何内部隔断被拆除,仍然能找到一个单独的 1x1 格子当作空洞吗?这个问题取决于题目对“空洞”语义的定义。如果题目要求空洞面积必须从完整的空区域中诞生,单个格子也算一种退化情况;如果出题人的隐藏定义要求至少两格,则这里会返回 0。我在 LeetCode 本题中验证提交时,空数组返回 1 是通过的,说明 1x1 是合法答案。
第二个样例:hBars = [1], vBars = [1],假设网格在横竖方向至少能容纳两格。横向运行长度 1,纵向运行长度 1,加一后 H=2,V=2,答案 4。四格并成一个 2x2 大空洞,很直观。
第三个样例:hBars = [1, 2, 3], vBars = [1]。横向运行长度 3,加一后 H=4;纵向运行长度 1,加一后 V=2;最优侧边 min(4,2)=2,面积是 4。此时即使横向能大到跨 4 格,纵向只有 2 格,也不可能出现边长为 3 的正方形。你会得到与第二个样例相同的结果,但原因完全不同。第二个样例是横竖都到 2,第三个样例是纵向卡脖子。
4. 完整提交代码与两个隐蔽的坑
把前面的讨论组装成可直接提交的代码并不复杂。下面是我在本地上跑通过的完整版本,已针对 LeetCode 风格做了一点适配,变量命名也尽量贴近题目语义。
cpp复制class Solution {
public:
int maximizeSquareHoleArea(int n, int m, vector<int>& hBars, vector<int>& vBars) {
int runH = longestRun(hBars);
int runV = longestRun(vBars);
int side = min(runH + 1, runV + 1);
return side * side;
}
private:
int longestRun(vector<int>& bars) {
if (bars.empty()) return 0;
sort(bars.begin(), bars.end());
int best = 1;
int cur = 1;
for (int i = 1; i < (int)bars.size(); ++i) {
if (bars[i] == bars[i - 1]) {
continue;
}
if (bars[i] == bars[i - 1] + 1) {
++cur;
} else {
cur = 1;
}
best = max(best, cur);
}
return best;
}
};
Python 版本可以写得更紧凑:
python复制class Solution:
def maximizeSquareHoleArea(self, n: int, m: int, hBars: List[int], vBars: List[int]) -> int:
h_run = self.longest_run(hBars)
v_run = self.longest_run(vBars)
side = min(h_run + 1, v_run + 1)
return side * side
def longest_run(self, bars: List[int]) -> int:
if not bars:
return 0
bars.sort()
best = 1
cur = 1
for i in range(1, len(bars)):
if bars[i] == bars[i - 1]:
continue
if bars[i] == bars[i - 1] + 1:
cur += 1
else:
cur = 1
best = max(best, cur)
return best
如果是在周赛紧张环境中,我甚至建议把 longest_run 提取为外部函数,或者直接在 maximizeSquareHoleArea 里面写两遍循环。虽然重复代码不优雅,但可以少跳一次函数调用,减少思考负担。因为真正的逻辑已经很短,怎么组织都不会对可读性造成太大影响。
4.1 坑一:把横向运行长度和纵向运行长度直接乘起来
这是最容易踩的坑。如果你网上搜这道题,可能会看到一些讨论帖把答案写成“max size of hole”等于 h * w。注意,那是“矩形空洞”的求法,而本题是“正方形空洞”。矩形空洞的情况下,横向最长跨度和纵向最长跨度互相独立,可以全部利用;正方形却不允许某一方向比另一方向更长。出现这种错误时,常见场景是横向跨度大于纵向或者反过来,用一个具体样例就能暴露问题:hBars=[1,2,3], vBars=[1] 时,矩形答案是 4*2=8,正方形答案却是 4。题目明明叫 Square,千万别顺手按矩形处理。
4.2 坑二:坐标连续性判断时被重复元素干扰
前文提过一次,但这里值得作为独立坑点再说一遍。有些 LeetCode 题目给出的数组保证元素互异,但你在本地自测、造极端数据或者某些变体题里,重复坐标完全可能出现。
举个例子:bars = [1, 1, 2, 3]。只排序一次,然后从前往后扫描,如果不跳过重复值,程序会怎样处理?第一个元素 1 初始化为 cur=1;第二个元素仍是 1,既不等于上一个元素加 1,也不是完全无关的断裂,如果代码只写了 else 分支,会把 cur 重置为 1;第三个元素 2 等于上一个元素 1 加 1,cur 变成 2;第四个元素 3 又变成 3。这样结果仍然是 3,碰巧正确。但你换一组数据 bars = [1, 1, 2],不加跳过分支代码会把最终 cur 算成 2,而真实最长连续段是 [1,2],长度 2,其实也碰巧正确。最危险的是 bars = [1, 2, 2, 3]:不加跳过分支时,你会得到 cur=3,真实最长连续段却是 [1,2] 和 [2,3],最大长度都应该为 2。不要小看这个差异,它会造成最终面积偏大一个量级。解决办法很简单:遇到与前一个相等的元素直接 continue,既不要自增也不要重置。
4.3 时间复杂度与空间复杂度的快速评估
排序数组的复杂度是 O(A log A + B log B),其中 A 是 hBars.size(),B 是 vBars.size()。扫描阶段是线性的,总复杂度由排序主导。由于 n 和 m 指网格行列数而不是隔断总长度区间,实际空间复杂度只取决于输入数组长度,全程没有使用额外容器,是 O(1) 辅助空间(不计输入存储)。这个复杂度在 LeetCode 的 medium 题里相当友好,数据规模哪怕到十万级别也能轻松通过。
如果你想把复杂度优化到严格线性,可以用 unordered_set 先把所有坐标存入哈希集合,再从集合中任意一个未访问元素向左右扩展,统计连续段。这是在“最长连续序列”题里常用的套路。不过对于本题,我不推荐在周赛里玩这种花活。排序方案已经足够快,而且天然规避了哈希碰撞和最坏复杂度的问题。刷题重要的是在有限时间内拿到 AC,不是秀复杂度下界。
5. 从这题能顺手牵出的扩展:矩形、非对齐、二维搜索误区
每次刷完一类有价值的题目,我会顺带想一想它还有哪些变体能套同一套模板。LeetCode 2943 最直接的扩展就是把“正方形空洞”改成“矩形空洞”。改动只有一个字符:答案从 min(H, V) * min(H, V) 变成 H * V。横向连续贯通带和纵向连续贯通带可以各自取最长,两者相乘就是最大矩形面积。从几何上解释,矩形不需要横纵相等,所以两个方向的极限可以完全叠加。
另一个值得思考的扩展是“空洞不一定要求内部所有小方格都被删除,而是只看隔断是否被打通”。在某些类似题里,给定的是被删除的单元格坐标,而不是隔断坐标,那反而需要重新建模。因为一个单元格被删除只影响它四周的一小段隔断,如果想让两个相邻单元格连通,必须同时满足它们之间的隔断被移除,这种场景更接近二维并查集。很多选手在 LeetCode 2943 上翻车,就是因为把题目读成了“给定一堆被挖掉的格子,求最大空格”,然后开始做 DFS 连通块,方向完全跑偏。
如果你在别的平台遇到这类“打通隔断”模型,建议记住一个判别口诀:输入给的是“cell 坐标”还是“bar 坐标”。如果给的是 bar 坐标,说明每次拆除会对该方向一整行或一整列产生影响,适合排序扫描;如果给的是 cell 坐标,则要考虑相邻格子的连通性,可能需要 BFS/并查集。2943 属于前者,所以代码能简单到这种程度。
稍微展开一点:为什么 2943 不能用纯粹的“最大空格子”视角去看?因为题目里的网格并不是普通的障碍物网格,而是一组隔断线组成的网格。当某个横向隔断处于拆除状态时,它并不是只影响一个格子,而是影响了该横线所经过的所有格子之间的连通。横向坐标连续段的长度一旦确定,就表示沿横向一整段都不存在内部隔断。这种全局性的“拆一条线、通一整行”效果,决定了统计单位应该是一条连续的隔断区间,而不是离散的 cell。认清这一点后,你会发现很多看似是二维搜索的问题,底子里其实藏着很轻量的一维规律。
6. 最后的实测心得
我在本地手动构造过一个稍微复杂的用例,用来确认取 min 后求平方的逻辑不会漏掉边界。构造方式是这样:让横向隔断数组包含一个很长的连续段,比如 [1,2,3,4,5],让纵向隔断数组分成两段,比如 [1,2, 8,9],模拟隔断拆除后有两个分散区域。运行结果里横向跨度是 6,纵向最长跨度是 3(因为一段连续坐标长度 2,加一为 3,另一段也如此),最终边长 3,面积 9。这个用例能验证两件事:一是纵向分成两段后最长段只取其中较大的一段,不会把两段加起来;二是横向长度即使远超纵向,也不能让正方形边长超过纵向跨度。如果代码结果不是 9,大概率在“连续段内断裂处理”或“取 min 逻辑”这两处出了问题。
另外一个小建议:在周赛里写这类题时,优先写 longestRun 这个工具函数,再用两个测试断言确认它行为正常。因为这个函数是整道题唯一的算法核心,把它单独验证通过,后面主函数几乎不会出错。你可以先跑一个 [1, 2, 4, 5, 6],期望返回 3;再跑一个 [3],期望返回 1;再跑一个空数组,期望返回 0。三个断言过了,整道题的信心就有了。
我在实际提交前曾经把 side * side 写成 side * 2,原因是我一直想着“边长是长度,面积是边长的平方”,写代码时手一滑写成了二倍。这种低级错误在紧张状态很容易发生。建议提交前用样例手算一下返回数字是否等于边长乘边长,而不是边长的两倍。如果一个小样例边长为 3,答案应该是 9,如果函数返回 6,那就是乘法写错了。
这题让我印象最深的一点是,LeetCode 很多时候考察的不是你会不会复杂算法,而是你能不能把一个二维直觉问题正确降维。横竖坐标分别找最长连续段,然后取交集边长的平方——整个过程没有高深的数据结构,没有复杂的动态规划转移,只要你愿意先在草稿纸上画几根线,而不是直接脑子冲进网格搜索的迷宫,答案就会自己浮出来。以后遇到类似的“删线求洞”类题目,我都会先问自己一句:能不能把二维的连通性拆成两段独立的一维问题?多数情况下,这句提问比算法模板本身更值钱。
