LeetCode 803. 打砖块
题目描述
✅ 803. 打砖块
题意分析
给定一个 $m \times n$ 的二值网格,1 表示砖块、0 表示空。一块砖是稳定的,当且仅当它位于第一行,或者它与某块稳定的砖四向相邻。现在按顺序执行一串敲击
hits,每次敲掉指定位置的砖块;敲击之后,所有变得不稳定的砖块会立即消失。返回每次敲击导致掉落的砖块数量(不含被敲掉的那一块本身)。「稳定」这个定义要翻译成图论语言:把第一行看成一个虚拟的「天花板」节点,那么一块砖稳定 $\iff$ 它与天花板连通。于是「敲掉一块砖后有多少砖掉落」就是「删除一个节点后,与天花板连通的节点数减少了多少」。
「不含被敲掉的那一块」这句话必须记牢。被敲的砖是主动移除的,不算掉落;但它在计数时又确实曾属于(或不属于)天花板连通块,所以最后要精确地把它扣掉一次。
还有一个容易忽略的情形:
hits里的位置可能本来就是空的(网格该处为 0)。这时候敲了个寂寞,答案是 0,且不能对结构做任何改动。题目保证hits中的位置互不相同,所以不必担心同一块砖被敲两次。
约束是 $1 \le m, n \le 200$、$1 \le hits \le 4 \times 10^4$。格子最多四万,敲击也最多四万。这个规模允许 $O(mn \cdot \alpha)$ 级别的做法,但不允许每次敲击后重跑一遍全图搜索——那是 $4 \times 10^4 \times 4 \times 10^4 = 1.6 \times 10^9$,必然超时。所以需要一个能增量维护连通性的结构。 边界方面:敲掉第一行的砖时,它自己不算掉落;敲掉一块孤立的、本来就不与天花板相连的砖时,掉落数为 0(它以及它带的那些砖其实在更早的时刻就已经掉了)。
解法:逆向并查集 + 虚拟顶部
核心思路
最直接的模拟是:每次敲掉一块砖,然后从第一行做一遍广度优先搜索,标记所有仍然稳定的砖,把没被标记的清除并计数。正确但太慢——$4 \times 10^4$ 次敲击乘以每次 $4 \times 10^4$ 个格子的搜索,量级到了 $10^9$。
瓶颈在于「删除」这个操作。并查集是维护连通性的利器,但它只支持合并、不支持分裂:一旦两个集合合并,就无法高效地再拆开。而敲砖块恰恰是在不断地拆散连通块。
突破口是时间倒流。把敲击序列反过来看:从「所有敲击都已完成」的最终状态出发,逆序地把砖一块块加回去。加回砖块只会让连通块合并,绝不会分裂——这正是并查集擅长的方向。这个「正向删除难、逆向添加易」的转换,是本题的全部精髓。
具体做法分三步。
第一步,构造终局。 复制一份网格,把
hits中所有位置全部置 0,得到「所有敲击执行完毕」时的砖块分布。注意这里要用副本,原网格还要用来判断某次敲击的位置本来是否有砖。第二步,在终局上建并查集,并引入虚拟顶部节点。 给每个格子编号 $r \times n + c$,再额外开一个编号为 $m \times n$ 的虚拟节点
top。把终局中所有第一行的砖与top合并,把所有四向相邻的砖两两合并。此后,「稳定的砖数」就等于top所在集合的大小减一(减掉top自身)。引入虚拟节点的好处是:把「与第一行任意一块砖连通」这个存在性条件,转化成了「与某个固定节点连通」,一次查询就能回答。第三步,逆序加回砖块并作差。 对 $k$ 从最后一次敲击往前遍历:
- 若原网格该处本来就是 0,这次敲击没敲到任何东西,答案为 0,且不做任何结构改动,直接跳过。
- 否则,先记录当前
top集合的大小 $prev$;把砖加回副本;若它在第一行就与top合并;再与四周所有存在的砖合并;最后记录新的大小 $cur$。那么 $answer[k] = \max(0,\ cur - prev - 1)$。
这个式子要逐项解释。$cur - prev$ 是加回这块砖之后,与天花板连通的节点净增量。这些新增的节点包括:加回的那块砖本身,以及因为它的加入而重新接上天花板的所有砖。而后者,恰恰就是正向时间线上「敲掉这块砖时会掉落的那些砖」——两者是同一批砖,只是一个在加回时接上、一个在敲掉时脱落。所以减去 1(那块砖自己不算掉落)就是答案。
$\max(0, \cdot)$ 处理的是「加回的砖并没有连上天花板」的情形:此时 $cur - prev = 0$,式子给出 $-1$,需要修正为 0。物理含义是这块砖本来就悬空,敲掉它不会连累任何砖。
这里维持的不变量是:处理完第 $k$ 次逆序步骤后,副本网格与并查集所描述的,正是「第 $k$ 次敲击发生之前」的稳定状态。 正因为如此,第 $k$ 步的差值才准确对应第 $k$ 次敲击的后果。
并查集本身要按大小合并并做路径压缩,同时维护每个根节点的集合大小——后者是作差的数据来源,必须在
union时同步更新。
解题步骤
- 第一步,复制网格并把
hits中所有位置置 0,得到终局副本。 为什么必须是副本:原网格要保留用于判断「这次敲击处本来有没有砖」。若在原网格上就地置 0,这个信息就丢了,所有敲空的情形都会被误当成有效敲击。- 第二步,建立大小为 $mn + 1$ 的并查集,末位下标 $mn$ 作为虚拟顶部。 为什么要虚拟顶部:稳定的判据是「与第一行某块砖连通」,这是一个对多个源点的存在性查询;接上虚拟节点后,它变成对单个节点的连通性查询,一次
find即可,且集合大小天然就是稳定砖数(含虚拟节点)。- 第三步,扫描终局副本,对每块砖:若在第一行就与顶部合并,再与上方、左方存在的砖合并。 为什么只看上和左:全表按行优先扫描时,右邻和下邻还没被访问到,等轮到它们时会反过来与当前格合并,所有相邻关系恰好被覆盖一次,不重不漏。
- 第四步,从最后一次敲击开始逆序遍历。 为什么必须逆序:并查集只能合并不能分裂,正序删除无法维护;逆序等价于把删除变成添加。
- 第五步,若原网格该处为 0,答案记 0 并跳过。 为什么不能只跳过而不判:如果照常执行「加回」,会凭空造出一块原本不存在的砖,副本网格从此与真实历史不符,后续所有差值全部作废。
- 第六步,记录 $prev = $ 顶部集合大小,把砖写回副本,视情况与顶部合并,再与四个方向上存在的砖合并,记录 $cur$。 为什么这里要查四个方向而不是像建图时只查两个:建图时是全表扫描,靠遍历顺序保证覆盖;这里是单点插入,必须主动检查全部四个邻居。为什么必须先写回副本再合并:若顺序反了,检查邻居时当前格还是 0,虽然不影响与邻居的合并(合并只依赖邻居的状态),但会让「当前格是否存在」的语义在中途不一致,后续步骤读到错误的副本状态。
- 第七步,令 $answer[k] = \max(0,\ cur - prev - 1)$。 为什么减 1:净增量里包含了加回的这块砖本身,而它不计入掉落数。为什么与 0 取最大:加回的砖若没连上天花板,净增量为 0,式子会得到 $-1$。
- 第八步,遍历结束返回答案数组。
以
grid = [[1,0,0,0],[1,1,1,0]]、hits = [[1,0]]走一遍($m = 2$、$n = 4$、虚拟顶部编号 8)。构造终局:副本把 $(1,0)$ 置 0,得到
[[1,0,0,0],[0,1,1,0]]。建图:扫描副本。$(0,0)$ 是砖且在第一行,与顶部合并,顶部集合变成 ${top, (0,0)}$,大小 2。$(1,1)$ 是砖,上邻 $(0,1)$ 为 0、左邻 $(1,0)$ 为 0,自成一个大小 1 的集合。$(1,2)$ 是砖,上邻 $(0,2)$ 为 0,左邻 $(1,1)$ 是砖,两者合并成大小 2 的集合。此时与天花板连通的只有 $(0,0)$ 一块砖——因为敲掉 $(1,0)$ 后,$(1,1)$ 和 $(1,2)$ 已经悬空。
逆序处理 $k = 0$:敲击位置是 $(1,0)$。原网格该处为 1,是一次有效敲击,继续。
记录 $prev = $ 顶部集合大小 $= 2$。
把 $(1,0)$ 写回副本,副本恢复成原始网格[[1,0,0,0],[1,1,1,0]]。它不在第一行,所以不直接与顶部合并。
检查四个邻居:右邻 $(1,1)$ 是砖,合并——顶部集合此刻还没变,合并的是 $(1,0)$ 与 ${(1,1),(1,2)}$,得到大小 3 的集合;上邻 $(0,0)$ 是砖,合并——这一步把刚才那个大小 3 的集合接到了 ${top,(0,0)}$ 上,顶部集合变成 ${top,(0,0),(1,0),(1,1),(1,2)}$,大小 5;左邻列号为 $-1$ 越界,下邻行号为 2 越界,都跳过。
记录 $cur = 5$。
计算 $answer[0] = \max(0,\ 5 - 2 - 1) = 2$。返回
[2]。核对物理含义:敲掉 $(1,0)$ 后,$(1,1)$ 和 $(1,2)$ 失去了唯一的支撑路径而掉落,共 2 块,而被敲掉的 $(1,0)$ 自身不计。净增量 3 里,1 块是加回的 $(1,0)$,另外 2 块正是掉落的那两块——这就是为什么减 1 之后恰好是答案。再看一个体现 $\max(0, \cdot)$ 的例子:
grid = [[1,0,0,0],[1,1,0,0]]、hits = [[1,1],[1,0]],答案是[0, 0]。终局副本是[[1,0,0,0],[0,0,0,0]],顶部集合为 ${top,(0,0)}$ 大小 2。逆序先处理 $k = 1$ 即 $(1,0)$:$prev = 2$,加回后与上邻 $(0,0)$ 合并,$cur = 3$,$answer[1] = \max(0, 3-2-1) = 0$——敲掉 $(1,0)$ 时它上方的 $(1,1)$ 已经被更早的敲击移走了,无砖可掉。再处理 $k = 0$ 即 $(1,1)$:$prev = 3$,加回后与左邻 $(1,0)$ 合并,$cur = 4$,$answer[0] = \max(0, 4-3-1) = 0$——敲掉 $(1,1)$ 时它是连通块的末端,不带走任何砖。
代码实现
class Solution {
private int[] parent;
private int[] size;
public int[] hitBricks(int[][] grid, int[][] hits) {
int m = grid.length;
int n = grid[0].length;
int top = m * n;
int[][] copy = new int[m][n];
for (int i = 0; i < m; i++) {
copy[i] = grid[i].clone();
}
for (int[] h : hits) {
copy[h[0]][h[1]] = 0;
}
parent = new int[top + 1];
size = new int[top + 1];
for (int i = 0; i <= top; i++) {
parent[i] = i;
size[i] = 1;
}
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (copy[i][j] != 1) {
continue;
}
int id = idx(i, j, n);
if (i == 0) {
union(id, top);
}
if (i > 0 && copy[i - 1][j] == 1) {
union(id, idx(i - 1, j, n));
}
if (j > 0 && copy[i][j - 1] == 1) {
union(id, idx(i, j - 1, n));
}
}
}
int[] answer = new int[hits.length];
int[][] dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
for (int k = hits.length - 1; k >= 0; k--) {
int r = hits[k][0];
int c = hits[k][1];
if (grid[r][c] == 0) {
continue;
}
int prev = getSize(top);
copy[r][c] = 1;
int id = idx(r, c, n);
if (r == 0) {
union(id, top);
}
for (int[] d : dirs) {
int nr = r + d[0];
int nc = c + d[1];
if (nr < 0 || nr >= m || nc < 0 || nc >= n) {
continue;
}
if (copy[nr][nc] == 1) {
union(id, idx(nr, nc, n));
}
}
int cur = getSize(top);
answer[k] = Math.max(0, cur - prev - 1);
}
return answer;
}
private int idx(int r, int c, int n) {
return r * n + c;
}
private int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
private void union(int x, int y) {
int px = find(x);
int py = find(y);
if (px == py) {
return;
}
if (size[px] < size[py]) {
int tmp = px;
px = py;
py = tmp;
}
parent[py] = px;
size[px] += size[py];
}
private int getSize(int x) {
return size[find(x)];
}
}
type UnionFind struct {
parent []int
size []int
}
func newUnionFind(n int) *UnionFind {
parent := make([]int, n)
size := make([]int, n)
for i := range parent {
parent[i] = i
size[i] = 1
}
return &UnionFind{parent, size}
}
func (uf *UnionFind) find(x int) int {
if uf.parent[x] != x {
uf.parent[x] = uf.find(uf.parent[x])
}
return uf.parent[x]
}
func (uf *UnionFind) union(x, y int) {
px, py := uf.find(x), uf.find(y)
if px == py {
return
}
if uf.size[px] < uf.size[py] {
px, py = py, px
}
uf.parent[py] = px
uf.size[px] += uf.size[py]
}
func (uf *UnionFind) getSize(x int) int {
return uf.size[uf.find(x)]
}
func hitBricks(grid [][]int, hits [][]int) []int {
m, n := len(grid), len(grid[0])
top := m * n
idx := func(r, c int) int { return r*n + c }
copy2 := make([][]int, m)
for i := range copy2 {
copy2[i] = make([]int, n)
copy(copy2[i], grid[i])
}
for _, hit := range hits {
copy2[hit[0]][hit[1]] = 0
}
uf := newUnionFind(m*n + 1)
for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if copy2[i][j] == 1 {
if i == 0 {
uf.union(top, idx(i, j))
}
if i > 0 && copy2[i-1][j] == 1 {
uf.union(idx(i, j), idx(i-1, j))
}
if j > 0 && copy2[i][j-1] == 1 {
uf.union(idx(i, j), idx(i, j-1))
}
}
}
}
dirs := [][2]int{{0, 1}, {0, -1}, {1, 0}, {-1, 0}}
result := make([]int, len(hits))
for k := len(hits) - 1; k >= 0; k-- {
r, c := hits[k][0], hits[k][1]
if grid[r][c] == 0 {
continue
}
prevTop := uf.getSize(top)
copy2[r][c] = 1
if r == 0 {
uf.union(idx(r, c), top)
}
for _, d := range dirs {
nr, nc := r+d[0], c+d[1]
if nr >= 0 && nr < m && nc >= 0 && nc < n && copy2[nr][nc] == 1 {
uf.union(idx(r, c), idx(nr, nc))
}
}
currTop := uf.getSize(top)
if currTop-prevTop-1 > 0 {
result[k] = currTop - prevTop - 1
}
}
return result
}
复杂度分析
时间复杂度:$O\big((mn + hits ) \cdot \alpha(mn)\big)$。构造终局与初始建图各扫一遍全表,是 $O(mn)$ 次合并;逆序处理每次敲击最多做 5 次合并(四个方向加一个顶部),是 $O( hits )$ 次。并查集配合路径压缩与按大小合并,单次操作的均摊代价是反阿克曼函数 $\alpha$,实际可视为常数。$mn \le 4 \times 10^4$、$ hits \le 4 \times 10^4$,总量在十万级。相比「每次敲击重跑一遍广搜」的 $O(mn \cdot hits ) \approx 1.6 \times 10^9$,快了四个数量级。 - 空间复杂度:$O(mn)$。网格副本、并查集的父指针数组与大小数组各占 $O(mn)$。此外递归版的
find在极端情况下有 $O(mn)$ 的栈深,但路径压缩会迅速把树压平,实际深度很小;若担心可以改写成迭代版。
关键点总结
- 并查集只能合并不能分裂,所以遇到删除就把时间倒过来。这是本题最值得带走的思想:正向的「删除节点、连通块分裂」无法增量维护,逆向的「添加节点、连通块合并」正好是并查集的原生操作。凡是看到「按顺序删除并查询连通性」,先想能不能离线倒序处理。
- 虚拟节点把「与一组源点连通」压成「与一个节点连通」。第一行有很多砖,逐个判断很麻烦;接上一个虚拟顶部之后,稳定性判定和稳定砖计数都变成对单个节点的查询。这个技巧在被围绕的区域、边界连通类题目里同样通用。
- 答案由集合大小的差值给出,而不是显式搜索。$cur - prev - 1$ 一步到位,既算出了掉落数,又避开了任何遍历。要用好它,前提是并查集在
union时同步维护了每个根的集合大小——这个「顺带维护聚合量」的习惯,能让并查集回答远超连通性本身的问题。- 保留原始输入用于区分「有效操作」与「无效操作」。
hits可能敲在空处,必须靠未被修改的原网格来判断。凡是要「先把某些位置清空再逆推」的题,都要留一份原始数据做参照。- 减一与取最大值这两个修正各有明确的物理含义,不是凑答案:减一扣掉加回的砖自身,取最大值处理「这块砖压根没连上天花板」的情形。写出这类公式后要能逐项解释,否则边界一定会出错。
- 面试视角:这题的分水岭是能否说出「逆序把砖加回去」。开口先讲清楚「并查集不支持删除,所以我把敲击序列倒过来处理」,再讲虚拟顶部,最后给出差值公式并解释减一的来源——三步讲完,面试官基本就认可了。常见追问有三个:一是「为什么不能正向做」,答并查集无法分裂;二是「$-1$ 和 $\max(0,\cdot)$ 分别在处理什么」,要能各举一个用例;三是「如果
hits里有重复位置怎么办」,答需要先去重或标记已处理,否则同一块砖会被加回两次导致计数错乱(本题的约束保证了位置互不相同)。
易错点总结
错误写法:正向处理敲击,每次敲完用并查集重新建图。以 $mn = 4 \times 10^4$、$ hits = 4 \times 10^4$ 的输入为例,每次重建都是 $O(mn)$,总量 $1.6 \times 10^9$,必然超时。并查集不支持删除,正向做只能靠重建,代价无法接受。 - 错误写法:忘记先把
hits中的位置在副本里全部置 0 就开始建图。以grid = [[1,0,0,0],[1,1,1,0]]、hits = [[1,0]]为例,初始图里 $(1,0)$ 仍在,顶部集合大小是 5;逆序处理时 $prev$ 和 $cur$ 都是 5,$answer[0] = \max(0, -1) = 0$,而正确答案是 2。必须从「所有敲击都已完成」的终局出发。- 错误写法:在原网格上就地置 0,不另存副本。以
hits中含有一个本来就是空格的位置为例,原网格被改写后无法再判断「这次敲击是否有效」,空敲会被当成有效敲击执行「加回」,凭空造出一块不存在的砖,此后所有答案全部错位。- 错误写法:漏掉
grid[r][c] == 0的跳过判断。以grid = [[1,0],[1,0]]、hits = [[0,1]]为例,$(0,1)$ 本来就是空的,正确答案是 0;漏判会把它当砖加回并与顶部合并,$cur - prev = 1$,答案算成 $\max(0, 0) = 0$ 侥幸相同——但换成hits = [[0,1],[1,1]]这类连续空敲,两块虚构的砖会互相合并并连上顶部,答案变成非零,彻底错误。- 错误写法:答案忘记减 1。以
grid = [[1,0,0,0],[1,1,1,0]]、hits = [[1,0]]为例,$cur - prev = 3$,直接返回 3,而正确答案是 2——多算的那一块正是被敲掉的 $(1,0)$ 自己,它不计入掉落。- 错误写法:不做 $\max(0, \cdot)$ 修正。以
grid = [[0,0],[1,1]]、hits = [[1,0]]为例,这块砖本来就悬空(第一行全空),加回后与顶部无关,$cur - prev = 0$,式子给出 $-1$,返回负数。悬空砖被敲不会连累任何砖,答案应是 0。- 错误写法:
union时不维护集合大小,或只更新被合并的一方。以任意输入为例,$getSize(top)$ 返回的值失真,差值计算完全失去意义。大小必须在每次真实合并(两个根不同)时累加到新根上,且路径压缩后要通过find拿根再读大小,不能直接读size[top]。- 错误写法:
getSize直接返回size[top]而不是size[find(top)]。以顶部节点在某次合并中被挂到别人下面的情形为例(按大小合并时完全可能发生),size[top]停留在旧值 1,所有差值都算成负数。查询集合大小必须先找根。- 错误写法:逆序加回时只检查上、左两个方向。以
grid = [[1,1],[0,1]]、hits = [[0,1]]为例,加回 $(0,1)$ 时需要与下邻 $(1,1)$ 合并才能把它拉进顶部集合;只查上左会漏掉这次合并,答案偏小。建图时靠扫描顺序覆盖了所有相邻关系,但单点插入必须主动查全部四个方向。- 错误写法:先做合并再把砖写回副本。以两次敲击落在相邻格子的输入为例,后处理的那一格在检查邻居时,先处理的那一格若尚未写回副本,就会被当成空格而漏掉合并,两块砖没接上,顶部集合偏小。副本状态必须在合并之前更新到位。
- 错误写法:虚拟顶部的编号与某个真实格子冲突。以 $m = 2$、$n = 4$ 为例,格子编号是 0 到 7,虚拟顶部必须取 8;若误取 $m \times n - 1 = 7$,它会与格子 $(1,3)$ 混为一体,连通性判定彻底错乱。并查集的规模要开 $mn + 1$。
- 错误写法:认为顶部集合大小就是稳定砖数,忘记虚拟节点自身也占 1。以只有 $(0,0)$ 一块砖的网格为例,顶部集合大小是 2 而稳定砖只有 1 块。不过本题只用到大小的差值,虚拟节点的这个常数偏移会自动抵消——但如果你在别处直接用集合大小作为砖数输出,就会整体多一。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 305. 岛屿数量 II | 困难 | 正向逐个加陆地并动态维护岛屿数,是本题「加回」操作的正序版本 |
| 130. 被围绕的区域 | 中等 | 同样用虚拟节点代表「边界」,把多源连通性压成单点查询 |
| 200. 岛屿数量 | 中等 | 静态一次性统计连通块,可用并查集也可用搜索,是网格连通性的入门题 |
| 827. 最大人工岛 | 困难 | 先给每个岛编号并记大小,再枚举每个空格试填,考察的是「预处理集合大小再查询」 |
| 547. 省份数量 | 中等 | 邻接矩阵直接给边的连通分量计数,可用来熟悉并查集模板 |
| 684. 冗余连接 | 中等 | 加边过程中检测首次成环,训练「合并前先判连通」的写法 |
| 1319. 连通网络的操作次数 | 中等 | 答案是连通分量数减一,同时要先判断边是否足够,是并查集计数的直接应用 |
| 1489. 找到最小生成树里的关键边和伪关键边 | 困难 | 反复用并查集跑带约束的最小生成树,训练「删边 / 强制加边」的离线处理思路 |