311 lines
14 KiB
YAML
311 lines
14 KiB
YAML
title: Bricks Falling When Hit
|
||
slug: bricks-falling-when-hit
|
||
difficulty: hard
|
||
leetcode_id: 803
|
||
leetcode_url: https://leetcode.com/problems/bricks-falling-when-hit/
|
||
categories:
|
||
- arrays
|
||
- graphs
|
||
patterns:
|
||
- union-find
|
||
- matrix-traversal
|
||
|
||
description: |
|
||
You are given an `m x n` binary `grid`, where each `1` represents a brick and `0` represents an empty space. A brick is **stable** if:
|
||
|
||
- It is directly connected to the top of the grid, or
|
||
- At least one other brick in its four adjacent cells is **stable**.
|
||
|
||
You are also given an array `hits`, which is a sequence of erasures we want to apply. Each time we want to erase the brick at the location `hits[i] = (row_i, col_i)`. The brick on that location (if it exists) will disappear. Some other bricks may no longer be stable because of that erasure and will **fall**. Once a brick falls, it is **immediately** erased from the `grid` (i.e., it does not land on other stable bricks).
|
||
|
||
Return *an array* `result`*, where each* `result[i]` *is the number of bricks that will **fall** after the* i<sup>th</sup> *erasure is applied.*
|
||
|
||
**Note** that an erasure may refer to a location with no brick, and if it does, no bricks drop.
|
||
|
||
constraints: |
|
||
- `m == grid.length`
|
||
- `n == grid[i].length`
|
||
- `1 <= m, n <= 200`
|
||
- `grid[i][j]` is `0` or `1`
|
||
- `1 <= hits.length <= 4 * 10^4`
|
||
- `hits[i].length == 2`
|
||
- `0 <= row_i <= m - 1`
|
||
- `0 <= col_i <= n - 1`
|
||
- All `(row_i, col_i)` are unique
|
||
|
||
examples:
|
||
- input: "grid = [[1,0,0,0],[1,1,1,0]], hits = [[1,0]]"
|
||
output: "[2]"
|
||
explanation: "Starting with the grid [[1,0,0,0],[1,1,1,0]], we erase the brick at (1,0). The two bricks at (1,1) and (1,2) are no longer connected to the top nor adjacent to another stable brick, so they fall. Hence the result is [2]."
|
||
- input: "grid = [[1,0,0,0],[1,1,0,0]], hits = [[1,1],[1,0]]"
|
||
output: "[0,0]"
|
||
explanation: "After erasing (1,1), the brick at (1,0) is still connected to the top via (0,0), so nothing falls. After erasing (1,0), (0,0) is directly on top so it remains stable. No bricks fall in either case."
|
||
|
||
explanation:
|
||
intuition: |
|
||
Imagine you have a wall of bricks where each brick must be connected to the ceiling (top row) to stay up. When you remove a brick, any bricks that lose their connection to the ceiling will fall like a cascade.
|
||
|
||
The **naive approach** would be to simulate each hit forward in time: remove a brick, then run a BFS/DFS to find which bricks are still stable, and count the fallen ones. However, with up to `4 * 10^4` hits on a `200 x 200` grid, this would be too slow.
|
||
|
||
The **key insight** is to **reverse the problem**: instead of removing bricks and watching them fall, we can **add bricks in reverse order** and count how many become newly connected to the ceiling. Think of it like rewinding a video of bricks falling — in reverse, they "fly up" and attach themselves.
|
||
|
||
Here's why this works: Union-Find is excellent at *adding* connections but terrible at *removing* them. By processing hits in reverse, we convert a "removal" problem into an "addition" problem. When we add a brick back:
|
||
- If it connects some floating bricks to the ceiling, those bricks "become stable"
|
||
- The count of newly stable bricks equals the count of bricks that *fell* when this brick was originally hit
|
||
|
||
We use a **virtual ceiling node** that connects to all bricks in the top row. A brick is stable if and only if it's in the same connected component as this ceiling node.
|
||
|
||
approach: |
|
||
We solve this using **Union-Find with Reverse Time Processing**:
|
||
|
||
**Step 1: Mark all hit locations**
|
||
|
||
- Create a copy of the grid
|
||
- For each hit location, if there's a brick (`grid[r][c] == 1`), mark it as removed by setting it to `0`
|
||
- This gives us the "final state" after all hits
|
||
|
||
|
||
|
||
**Step 2: Build initial Union-Find structure**
|
||
|
||
- Create a Union-Find with `m * n + 1` nodes (one extra for the virtual ceiling)
|
||
- The ceiling node is at index `m * n`
|
||
- For each remaining brick (`grid[r][c] == 1`):
|
||
- If it's in the top row, union it with the ceiling
|
||
- Union it with any adjacent bricks (right and down to avoid double-counting)
|
||
|
||
|
||
|
||
**Step 3: Process hits in reverse order**
|
||
|
||
- For each hit from last to first:
|
||
- If the original grid had no brick at this location, result is `0`
|
||
- Otherwise, count how many bricks are currently connected to the ceiling
|
||
- Add the brick back by setting `grid[r][c] = 1`
|
||
- Union this brick with the ceiling (if top row) and all adjacent bricks
|
||
- Count how many bricks are now connected to the ceiling
|
||
- The difference (minus 1 for the brick itself) is the number of bricks that fell
|
||
|
||
|
||
|
||
**Step 4: Reverse the results**
|
||
|
||
- Since we processed in reverse, reverse the result array to get the correct order
|
||
|
||
|
||
|
||
This approach works because Union-Find efficiently tracks connected components as we add connections, and the "reverse time" trick converts brick removal into brick addition.
|
||
|
||
common_pitfalls:
|
||
- title: Forward Simulation is Too Slow
|
||
description: |
|
||
The tempting approach is to simulate each hit forward:
|
||
1. Remove the brick
|
||
2. Run BFS/DFS from the top row to mark all stable bricks
|
||
3. Count unmarked bricks as fallen
|
||
|
||
With `4 * 10^4` hits and a `200 x 200` grid, each BFS could visit 40,000 cells. This gives O(hits × m × n) = O(1.6 × 10^9) operations — far too slow.
|
||
|
||
The reverse Union-Find approach processes each hit in near-constant time (amortised), giving O(hits × α(m×n)) ≈ O(hits) total.
|
||
wrong_approach: "Forward simulation with BFS after each hit"
|
||
correct_approach: "Reverse time processing with Union-Find"
|
||
|
||
- title: Forgetting the Virtual Ceiling Node
|
||
description: |
|
||
Without a virtual ceiling, you'd need to check if any brick in the top row is in the same component — requiring O(n) checks per query.
|
||
|
||
By creating a single ceiling node that unions with all top-row bricks, checking stability becomes a single O(1) find operation: `find(brick) == find(ceiling)`.
|
||
wrong_approach: "Checking connection to each top-row brick separately"
|
||
correct_approach: "Use a virtual ceiling node connected to all top-row bricks"
|
||
|
||
- title: Not Handling Empty Hit Locations
|
||
description: |
|
||
A hit can target a cell that was already empty (`grid[r][c] == 0`) or was emptied by a previous hit. In these cases, no bricks fall.
|
||
|
||
You must check the **original** grid to determine if a brick existed at the hit location, not the modified grid during reverse processing.
|
||
wrong_approach: "Assuming all hits target existing bricks"
|
||
correct_approach: "Check original grid values and return 0 for empty cells"
|
||
|
||
- title: Off-by-One in Fallen Count
|
||
description: |
|
||
When you add a brick back and count the newly stable bricks, you must subtract 1 because the brick you just added is counted in the new total but wasn't a "fallen" brick — it was the one that was hit.
|
||
|
||
`fallen = new_stable_count - old_stable_count - 1`
|
||
wrong_approach: "Counting the hit brick itself as a fallen brick"
|
||
correct_approach: "Subtract 1 from the difference to exclude the hit brick"
|
||
|
||
key_takeaways:
|
||
- "**Reverse time trick**: When Union-Find needs to handle deletions, reverse the problem to convert deletions into additions"
|
||
- "**Virtual node pattern**: A single virtual node connecting multiple boundary elements simplifies connectivity queries to O(1)"
|
||
- "**Union-Find with size tracking**: Storing component sizes enables efficient counting of connected elements"
|
||
- "**Amortised efficiency**: Union-Find operations are nearly O(1) with path compression and union by rank, making it ideal for incremental connectivity problems"
|
||
|
||
time_complexity: "O(h × α(m×n) + m×n) where h is the number of hits and α is the inverse Ackermann function. The α term is effectively constant (≤ 4 for any practical input), so this is essentially O(h + m×n)."
|
||
space_complexity: "O(m × n) for the Union-Find parent and size arrays, plus O(h) for storing results."
|
||
|
||
solutions:
|
||
- approach_name: Reverse Union-Find
|
||
is_optimal: true
|
||
code: |
|
||
class UnionFind:
|
||
def __init__(self, n: int):
|
||
self.parent = list(range(n))
|
||
self.size = [1] * n
|
||
|
||
def find(self, x: int) -> int:
|
||
# Path compression: point directly to root
|
||
if self.parent[x] != x:
|
||
self.parent[x] = self.find(self.parent[x])
|
||
return self.parent[x]
|
||
|
||
def union(self, x: int, y: int) -> None:
|
||
# Union by size: attach smaller tree to larger
|
||
px, py = self.find(x), self.find(y)
|
||
if px == py:
|
||
return
|
||
if self.size[px] < self.size[py]:
|
||
px, py = py, px
|
||
self.parent[py] = px
|
||
self.size[px] += self.size[py]
|
||
|
||
def get_size(self, x: int) -> int:
|
||
return self.size[self.find(x)]
|
||
|
||
|
||
def hit_bricks(grid: list[list[int]], hits: list[list[int]]) -> list[int]:
|
||
m, n = len(grid), len(grid[0])
|
||
CEILING = m * n # Virtual node representing the ceiling
|
||
|
||
# Step 1: Create a copy and remove all hit bricks
|
||
grid_copy = [row[:] for row in grid]
|
||
for r, c in hits:
|
||
grid_copy[r][c] = 0
|
||
|
||
# Step 2: Build Union-Find on the final state (after all hits)
|
||
uf = UnionFind(m * n + 1)
|
||
|
||
def index(r: int, c: int) -> int:
|
||
return r * n + c
|
||
|
||
# Connect remaining bricks
|
||
for r in range(m):
|
||
for c in range(n):
|
||
if grid_copy[r][c] == 1:
|
||
# Top row connects to ceiling
|
||
if r == 0:
|
||
uf.union(index(r, c), CEILING)
|
||
# Connect to adjacent bricks (only right and down)
|
||
if r > 0 and grid_copy[r - 1][c] == 1:
|
||
uf.union(index(r, c), index(r - 1, c))
|
||
if c > 0 and grid_copy[r][c - 1] == 1:
|
||
uf.union(index(r, c), index(r, c - 1))
|
||
|
||
# Step 3: Process hits in reverse, adding bricks back
|
||
result = []
|
||
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||
|
||
for r, c in reversed(hits):
|
||
# If original grid had no brick here, nothing falls
|
||
if grid[r][c] == 0:
|
||
result.append(0)
|
||
continue
|
||
|
||
# Count bricks connected to ceiling before adding this brick
|
||
prev_ceiling_size = uf.get_size(CEILING)
|
||
|
||
# Add the brick back
|
||
grid_copy[r][c] = 1
|
||
|
||
# Connect to ceiling if in top row
|
||
if r == 0:
|
||
uf.union(index(r, c), CEILING)
|
||
|
||
# Connect to all adjacent bricks
|
||
for dr, dc in directions:
|
||
nr, nc = r + dr, c + dc
|
||
if 0 <= nr < m and 0 <= nc < n and grid_copy[nr][nc] == 1:
|
||
uf.union(index(r, c), index(nr, nc))
|
||
|
||
# Count bricks connected to ceiling after adding
|
||
new_ceiling_size = uf.get_size(CEILING)
|
||
|
||
# Fallen bricks = newly connected bricks - 1 (the brick we added)
|
||
fallen = max(0, new_ceiling_size - prev_ceiling_size - 1)
|
||
result.append(fallen)
|
||
|
||
# Reverse to get correct order
|
||
return result[::-1]
|
||
explanation: |
|
||
**Time Complexity:** O(h × α(m×n) + m×n) — Building initial Union-Find takes O(m×n), and each of the h hits requires constant Union-Find operations (amortised).
|
||
|
||
**Space Complexity:** O(m × n) — For the Union-Find data structure storing parent and size arrays.
|
||
|
||
The algorithm reverses time to convert brick removal into brick addition, which Union-Find handles efficiently. A virtual ceiling node simplifies stability checks to a single find operation.
|
||
|
||
- approach_name: Forward BFS Simulation
|
||
is_optimal: false
|
||
code: |
|
||
from collections import deque
|
||
|
||
|
||
def hit_bricks_bfs(grid: list[list[int]], hits: list[list[int]]) -> list[int]:
|
||
m, n = len(grid), len(grid[0])
|
||
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
|
||
|
||
def count_stable() -> set[tuple[int, int]]:
|
||
"""BFS from top row to find all stable bricks."""
|
||
stable = set()
|
||
queue = deque()
|
||
|
||
# Start from all bricks in top row
|
||
for c in range(n):
|
||
if grid[0][c] == 1:
|
||
stable.add((0, c))
|
||
queue.append((0, c))
|
||
|
||
# BFS to find all connected bricks
|
||
while queue:
|
||
r, c = queue.popleft()
|
||
for dr, dc in directions:
|
||
nr, nc = r + dr, c + dc
|
||
if (0 <= nr < m and 0 <= nc < n and
|
||
grid[nr][nc] == 1 and (nr, nc) not in stable):
|
||
stable.add((nr, nc))
|
||
queue.append((nr, nc))
|
||
|
||
return stable
|
||
|
||
result = []
|
||
|
||
for r, c in hits:
|
||
# If no brick at hit location, nothing falls
|
||
if grid[r][c] == 0:
|
||
result.append(0)
|
||
continue
|
||
|
||
# Remove the brick
|
||
grid[r][c] = 0
|
||
|
||
# Find all stable bricks after removal
|
||
stable_before = sum(row.count(1) for row in grid)
|
||
stable_after = len(count_stable())
|
||
|
||
# Fallen = total bricks - stable bricks
|
||
fallen = stable_before - stable_after
|
||
result.append(fallen)
|
||
|
||
# Actually remove fallen bricks from grid
|
||
stable_set = count_stable()
|
||
for i in range(m):
|
||
for j in range(n):
|
||
if grid[i][j] == 1 and (i, j) not in stable_set:
|
||
grid[i][j] = 0
|
||
|
||
return result
|
||
explanation: |
|
||
**Time Complexity:** O(h × m × n) — Each hit requires a BFS that can visit all cells, and we have h hits.
|
||
|
||
**Space Complexity:** O(m × n) — For the BFS queue and stable set.
|
||
|
||
This approach simulates each hit forward: remove a brick, run BFS to find stable bricks, and count the fallen ones. While correct and intuitive, it's too slow for the given constraints and will result in TLE on LeetCode. Included to illustrate why the reverse Union-Find approach is necessary.
|