A clear explanation of Number of Islands II using Union-Find to dynamically merge connected land cells.
Problem Restatement
We are given an empty m x n grid filled with water.
Each operation turns one cell into land.
After every operation, return the current number of islands.
An island is a group of connected land cells connected horizontally or vertically.
The official problem defines:
| Value | Meaning |
|---|---|
0 | Water |
1 | Land |
The grid starts entirely with water. For each position in positions, we add land at that location and report the island count after the addition. (github.com)
Input and Output
| Item | Meaning |
|---|---|
| Input | Integers m, n, and a list positions |
| Operation | Convert one water cell into land |
| Output | Island count after each operation |
| Connectivity | Horizontal and vertical only |
Function shape:
def numIslands2(
m: int,
n: int,
positions: list[list[int]],
) -> list[int]:
...Examples
Example:
m = 3
n = 3
positions = [
[0, 0],
[0, 1],
[1, 2],
[2, 1],
]Initially:
0 0 0
0 0 0
0 0 0Add (0, 0):
1 0 0
0 0 0
0 0 0There is one island.
Output so far:
[1]Add (0, 1):
1 1 0
0 0 0
0 0 0The new land touches the existing island, so the island count stays 1.
Output:
[1, 1]Add (1, 2):
1 1 0
0 0 1
0 0 0This creates a second separate island.
Output:
[1, 1, 2]Add (2, 1):
1 1 0
0 0 1
0 1 0Still disconnected.
Final output:
[1, 1, 2, 3]First Thought: DFS After Every Operation
One idea is:
- Add the new land.
- Scan the entire grid.
- Run DFS or BFS to count islands.
This works.
But after every operation, we may scan the whole grid again.
If there are many operations, this becomes very slow.
Suppose:
| Symbol | Meaning |
|---|---|
k | Number of operations |
m * n | Grid size |
The complexity becomes roughly:
O(k * m * n)We need something incremental.
Key Insight
When adding one land cell, only nearby cells matter.
The new land can only connect to its four neighbors:
| Direction | Offset |
|---|---|
| Up | (-1, 0) |
| Down | (1, 0) |
| Left | (0, -1) |
| Right | (0, 1) |
This is a dynamic connectivity problem.
Union-Find, also called Disjoint Set Union (DSU), is designed for this.
Each island becomes one connected component.
When two neighboring lands touch, we merge their components.
Union-Find Structure
We maintain:
| Structure | Meaning |
|---|---|
parent[i] | Parent of node i |
rank[i] | Approximate tree height |
count | Current number of islands |
Each cell maps to one integer id:
For example, in a 3 x 3 grid:
| Cell | ID |
|---|---|
(0, 0) | 0 |
(0, 1) | 1 |
(1, 0) | 3 |
(2, 2) | 8 |
Union-Find Operations
We need two core operations.
Find
find(x) returns the root of the connected component containing x.
We also use path compression.
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]Path compression makes future operations faster.
Union
union(a, b) merges two components.
If the roots are already equal, the cells already belong to the same island.
Otherwise:
- Merge the two trees.
- Decrease island count by one.
if root_a != root_b:
parent[root_b] = root_a
count -= 1Algorithm
Initially:
- All cells are water.
- Island count is
0.
For each operation (r, c):
- If the cell is already land, append the current count.
- Otherwise:
- Convert the cell to land.
- Increase island count by one.
- Check all four neighbors.
- If a neighbor is land:
- Union the two cells.
- Append the island count.
Correctness
Initially, the grid contains no land, so the island count is correctly 0.
When a new land cell is added, it initially forms a new island by itself. Therefore, increasing the count by one is correct.
The only way this new land can affect existing islands is through its four neighboring cells. If a neighboring cell is water, no connection exists. If a neighboring cell is land, then the two cells belong to the same island and their connected components should be merged.
Union-Find maintains exactly one connected component for each island. When two neighboring land cells belong to different components, merging them correctly reduces the island count by one. If they already belong to the same component, no merge occurs and the count stays unchanged.
Since every possible connection caused by the new land is processed, the Union-Find structure always represents the exact island partition of the grid.
Therefore, after every operation, the stored island count is correct.
Complexity
| Operation | Complexity | Why |
|---|---|---|
| Add land | Nearly O(1) | At most four unions |
find / union | Amortized almost constant | Path compression + union by rank |
| Total | O(k α(mn)) | α is inverse Ackermann function |
α(n) grows extremely slowly and is effectively constant in practice.
Implementation
class UnionFind:
def __init__(self, size: int):
self.parent = list(range(size))
self.rank = [0] * size
self.count = 0
def find(self, x: int) -> int:
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a: int, b: int) -> bool:
root_a = self.find(a)
root_b = self.find(b)
if root_a == root_b:
return False
if self.rank[root_a] < self.rank[root_b]:
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a
if self.rank[root_a] == self.rank[root_b]:
self.rank[root_a] += 1
self.count -= 1
return True
class Solution:
def numIslands2(
self,
m: int,
n: int,
positions: list[list[int]],
) -> list[int]:
uf = UnionFind(m * n)
land = set()
ans = []
directions = [
(-1, 0),
(1, 0),
(0, -1),
(0, 1),
]
for r, c in positions:
if (r, c) in land:
ans.append(uf.count)
continue
land.add((r, c))
uf.count += 1
node_id = r * n + c
for dr, dc in directions:
nr = r + dr
nc = c + dc
if not (0 <= nr < m and 0 <= nc < n):
continue
if (nr, nc) not in land:
continue
neighbor_id = nr * n + nc
uf.union(node_id, neighbor_id)
ans.append(uf.count)
return ansCode Explanation
The Union-Find constructor initializes:
self.parent = list(range(size))Each node starts as its own parent.
Initially, every node forms its own component.
rank helps keep trees shallow.
self.rank = [0] * sizeThe find operation follows parent pointers until reaching the root.
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])This line performs path compression.
After compression, future searches become faster.
The union method first finds both roots.
root_a = self.find(a)
root_b = self.find(b)If the roots match, both nodes already belong to the same island.
if root_a == root_b:
return FalseOtherwise, merge the smaller-rank tree under the larger-rank tree.
if self.rank[root_a] < self.rank[root_b]:
root_a, root_b = root_b, root_aThen connect:
self.parent[root_b] = root_aSince two islands became one, reduce the count.
self.count -= 1Now look at the main solution.
We store land cells in:
land = set()Water cells are simply absent from the set.
For each new position:
if (r, c) in land:Duplicate additions do nothing.
Otherwise:
land.add((r, c))
uf.count += 1The new land starts as a new island.
Convert coordinates to node ids:
node_id = r * n + cThen inspect all four neighbors.
for dr, dc in directions:If a neighbor exists and is land:
uf.union(node_id, neighbor_id)The union operation automatically merges islands if needed.
Finally append the island count.
Testing
def run_tests():
s = Solution()
assert s.numIslands2(
3,
3,
[[0, 0], [0, 1], [1, 2], [2, 1]],
) == [1, 1, 2, 3]
assert s.numIslands2(
1,
1,
[[0, 0]],
) == [1]
assert s.numIslands2(
2,
2,
[[0, 0], [1, 1]],
) == [1, 2]
assert s.numIslands2(
2,
2,
[[0, 0], [0, 1], [1, 1]],
) == [1, 1, 1]
assert s.numIslands2(
3,
3,
[[0, 0], [0, 0]],
) == [1, 1]
assert s.numIslands2(
3,
3,
[[0, 0], [1, 0], [2, 0]],
) == [1, 1, 1]
print("all tests passed")
run_tests()Test meaning:
| Test | Why |
|---|---|
| Official-style example | Standard island merging |
| Single cell | Smallest valid grid |
| Separate lands | Multiple disconnected islands |
| Connecting neighbors | Merging into one island |
| Duplicate additions | Adding same land twice |
| Vertical chain | Repeated unions through neighbors |