Davide Spataro

Number of Islands

medium · Graphs · 28 min read
graph dfs bfs union-find flood-fill grid connected-components

Introduction

A 2D grid of '1's (land) and '0's (water) arrives, and you have to report how many separate islands it contains — where an island is any maximal blob of 4-connected land cells. The interview version of the problem looks like a puzzle; the actual content is graph theory in costume.

The move: read the grid as an implicit graph. Every cell is a vertex. Every pair of 4-adjacent land cells is an edge. An island is a connected component of the land-cell subgraph. The question collapses to “count connected components,” and you’ve met that problem a hundred times — flood fill, union-find, BFS from each unvisited seed, take your pick.

The grid is not the problem. It’s the graph in disguise. Every flood-fill problem becomes tractable the moment you see it that way.

If you’re early in graph prep, walk through the DFS approach and the viz first. If you already know DFS cold, the two sections worth your time are the BFS “mark on enqueue, not dequeue” subtlety and the union-find treatment — both are where candidates stumble under pressure.

What the interviewer is actually testing

Four signals:

  1. Graph recognition. Do you name the grid as an implicit graph in the first thirty seconds? Candidates who don’t spend the rest of the interview reinventing BFS.
  2. Component-counting structure. Do you articulate the outer-loop-plus-inner-visit pattern? The outer loop triggers one visit per component; the inner visit is the mechanics.
  3. In-place vs auxiliary bookkeeping. Do you notice that the grid itself can double as the visited matrix? This is the micro-optimization that cuts auxiliary space from O(nm)O(nm) to O(1)O(1) (plus recursion/queue).
  4. Idiomatic traversal. Does your DFS stop cleanly at boundaries and water cells? Do you know the BFS enqueue-vs-dequeue marking trap? Can you reach for union-find when it genuinely helps (streaming, incremental merging)?

Problem statement

Problem

You’re given a 2D grid of characters '1' (land) and '0' (water), with nn rows and mm columns. Return the number of islands, where an island is a maximal group of '1' cells connected horizontally or vertically. Diagonal adjacency does not count.

Figure · canonical 7×7 gridFour islands: a 7-cell L-shape in the top-left, a 2×2 block at columns 4–5, a 5-cell snake along the lower-left, and a single-cell islet at (5, 5). Same coordinates as Example 2; colors are for reading only.
11111111111111111
Example 1
Input grid = [ ['1','1','0'], ['0','1','0'], ['1','0','1'] ]
Output 3
The top-left blob ((0,0), (0,1), (1,1)) is one island. (2,0) is its own island. (2,2) is its own island. Total 3.
Example 2
Input grid = [ ['1','1','1','0','0','0','0'], ['0','1','0','0','0','0','0'], ['0','1','1','0','1','1','0'], ['0','0','1','0','1','1','0'], ['0','1','0','0','0','0','0'], ['0','1','1','0','0','1','0'], ['0','0','1','1','0','0','0'] ]
Output 4
A 7-cell island around the top-left, a 2×2 island at columns 4–5, a 5-cell snake in the lower left, and a single-cell islet at (5,5).
Example 3
Input grid = [ ['1','0','1'], ['0','1','0'], ['1','0','1'] ]
Output 5
Diagonals do NOT count as adjacent. Each land cell is isolated from every other: five islands.

Clarifying questions — and why each one matters

Clarification questions

Q Is diagonal adjacency considered?
A The single most load-bearing question. The standard problem is 4-connectivity (up, down, left, right). 8-connectivity (include diagonals) changes the answer on every grid with diagonal contact. Always ask; never assume.
Q Can the input grid be modified?
A If yes, you can mark visited cells in place by flipping them to '0', saving O(nm) auxiliary space. If not, allocate a separate visited matrix. Most interviewers allow mutation.
Q What's the cell type — char, bool, int?
A LeetCode uses vector<vector<char>> with '1' and '0'. Other sources use bool or int. Ask and match; the algorithm doesn't change but the equality tests do.
Q Can the grid be empty or jagged?
A Empty (n = 0 or m = 0) → return 0. Jagged (rows of different lengths) is unusual — typically rectangular is guaranteed. Confirm.
Q How large can n × m be?
A Up to ~10⁶ cells is typical. That's fine for O(nm) solutions but calls the DFS recursion depth into question — a long snake island can blow the call stack. Have the iterative BFS version in your pocket.
Q Is the input streaming — cells turn from water to land over time?
A Different problem (LeetCode 305, Number of Islands II). Union-find is the right tool: each new land cell is a singleton, then union with up to four neighbors in O(α(nm)). For the static problem, DFS or BFS is simpler and just as fast.

A mental framework — the grid is an implicit graph

When a problem hands you a grid, stop looking at the grid. Look at the graph:

  • Vertices: every cell.
  • Edges: connect 4-adjacent cells that share a property (both are land).
  • Question: usually a property of the graph — connected components, shortest path, spanning tree — dressed in grid language.

Once you see it that way, the move is mechanical. To count connected components in any graph, you do this:

  1. Maintain a visited set, initially empty.
  2. For every vertex v, if v is not yet visited and satisfies the component condition, increment the counter and run a traversal from v that marks every vertex reachable from v as visited.
  3. Return the counter.

For our grid, the “vertices” are cells, “visited” is either a separate matrix or the grid itself (flipping land to water), and “traversal” is DFS or BFS restricted to land cells. Everything else is detail.

Discussion

Approach 01

DFS recursive, in-place marking Recommended

Scan the grid; at every unvisited land cell, fire a recursive DFS that sinks the entire connected component. Increment the counter once per trigger.

Time O(n · m) Space O(n · m) recommendedgraphin-place

The interview narration

Say this out loud:

“The grid is an implicit graph — every cell a vertex, every pair of 4-adjacent land cells an edge. Counting islands is counting connected components. I’ll scan the grid; on each unvisited land cell I’ll run a DFS that sinks the whole component — flipping every visited '1' to '0'. The number of DFS triggers is the answer.”

How it works, step by step

Two loops: the outer scan runs over every cell of the grid. For each cell it checks: is this a '1'? If so, the cell is either (a) a newly discovered island’s seed, or (b) a cell already sunk by a prior DFS. The distinction is handled by the '1' check alone — a DFS that has already processed this cell left it as '0', so the scan passes over it.

When the scan finds a '1', the algorithm:

  1. Increments the counter. We’ve discovered a new island.
  2. Fires the DFS from that cell. The DFS is simple: check bounds and land; if valid, flip to '0'; recurse into all four neighbors. It returns when there’s nothing left to explore.

After the DFS completes, every cell of that island has been rewritten to '0'. The outer scan continues from where it left off and — crucially — will never again see this island’s cells as '1'. Each island is counted exactly once, no matter which of its cells the scan hits first.

The in-place marking is the idiomatic trick. An extra visited matrix would work but costs O(nm)O(nm) auxiliary space. Flipping '1' to '0' costs nothing and makes the “skip already-visited” check the same as “skip water” — one equality test handles both.

The code

dfs-recursive.cpp
#include <vector>
class Solution {
public:
int numIslands(std::vector<std::vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
const int n = static_cast<int>(grid.size());
const int m = static_cast<int>(grid[0].size());
int islands = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] == '1') {
++islands;
sink(grid, i, j, n, m);
}
}
}
return islands;
}
private:
void sink(std::vector<std::vector<char>>& grid, int i, int j, int n, int m) {
if (i < 0 || i >= n || j < 0 || j >= m || grid[i][j] != '1') return;
grid[i][j] = '0';
sink(grid, i + 1, j, n, m);
sink(grid, i - 1, j, n, m);
sink(grid, i, j + 1, n, m);
sink(grid, i, j - 1, n, m);
}
};

Four-line DFS, outer double loop, in-place marking. No visited matrix, no stack — the call stack is the DFS stack.

The recursion has a single base case: out of bounds or not land. That single return handles every case — off the edge of the grid, a water cell, or a cell another branch already sank. No layering of conditions, no special-casing the starting cell versus recursive cells.

Why the complexity is O(nm)O(n \cdot m)

Each cell is visited a bounded number of times:

  • The outer scan looks at every cell exactly once: nmnm checks.
  • The DFS visits each land cell exactly once — we flip it to '0' on the first visit, so subsequent recursive entries bounce off the “not land” base case.
  • Each DFS call makes at most 4 recursive calls (one per neighbor), and each cell is the subject of exactly one non-trivial call. Total DFS work: O(nm)O(nm).

Sum: O(nm)O(nm) time. The space cost is the recursion depth — worst case the entire grid is one serpentine island of length nmnm, and the call stack can reach nmnm deep. That’s where the iterative BFS version earns its keep on pathological inputs.

Edge cases the mechanics handle for free

  • Empty grid (n=0n = 0 or m=0m = 0): the outer loop doesn’t execute; return 0. One up-front guard handles it cleanly.
  • All water: no cell is ever a '1'; the outer loop never fires DFS. Count stays 0.
  • All land: one trigger; the DFS sinks everything. Count is 1.
  • Single cell: either 1 or 0 depending on the cell’s value. No boundary traps because the out-of-bounds check is the first line of DFS.
  • 1×N or N×1 strip: one-dimensional chains. DFS recurses along the single axis; same mechanics apply.
Approach 02

BFS iterative with a queue

Same outer loop; replace the recursive DFS with a BFS that pushes neighbors onto a queue. Avoids stack-depth issues and makes the frontier explicit.

Time O(n · m) Space O(min(n, m)) graphqueueiterative

The insight

The outer loop is identical. Only the inner visit changes: instead of recursing, we push cells onto a queue and pop them one at a time, enqueuing their unvisited land neighbors.

BFS and DFS give the same answer here — we’re counting components, not computing distances — but BFS has two practical advantages:

  • Bounded space. The queue holds the frontier of the search, which for a 2D grid is at most O(min(n,m))O(\min(n, m)) cells wide. Versus DFS recursion depth of up to nmnm in the worst case.
  • No recursion overhead. No function-call prolog per cell; in tight inner loops on large grids, this is a measurable speedup.

How it works, step by step

On finding a '1' at (si, sj):

  1. Initialize. Push (si, sj) onto the queue. Mark it '0' immediately — before any neighbor is considered.
  2. Process loop. While the queue is non-empty:
    • Pop the front cell (i, j).
    • For each of the four neighbors (ni, nj): if it’s in bounds and grid[ni][nj] == '1', mark it '0' and push it onto the queue.
  3. Finish. Queue empty means the component is fully explored.

The critical subtlety — mark on enqueue

Novice BFS implementations mark a cell “visited” when it’s popped from the queue, not when it’s pushed. That works for tree BFS, where each node has exactly one parent and is pushed at most once. It breaks on graphs with multiple paths: the same cell can be pushed by two different neighbors before it’s popped, and the queue ends up with duplicates. Worst case: quadratic blow-up.

The fix is one line: mark on enqueue. The instant you decide a neighbor is valid and you’re pushing it, flip its cell to '0'. The next time another neighbor considers it, the == '1' check rejects it immediately.

The code

bfs-iterative.cpp
#include <array>
#include <queue>
#include <utility>
#include <vector>
class Solution {
public:
int numIslands(std::vector<std::vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
const int n = static_cast<int>(grid.size());
const int m = static_cast<int>(grid[0].size());
int islands = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] == '1') {
++islands;
bfs(grid, i, j, n, m);
}
}
}
return islands;
}
private:
void bfs(std::vector<std::vector<char>>& grid, int si, int sj, int n, int m) {
static constexpr std::array<std::pair<int, int>, 4> dirs{{
{-1, 0}, {1, 0}, {0, -1}, {0, 1},
}};
std::queue<std::pair<int, int>> q;
q.emplace(si, sj);
grid[si][sj] = '0';
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
for (const auto& [di, dj] : dirs) {
const int ni = i + di;
const int nj = j + dj;
if (ni >= 0 && ni < n && nj >= 0 && nj < m && grid[ni][nj] == '1') {
grid[ni][nj] = '0';
q.emplace(ni, nj);
}
}
}
}
};

BFS flood fill. The static direction array and the mark-on-enqueue invariant are the two details worth naming out loud.

Why the space is O(min(n,m))O(\min(n, m))

At any moment during BFS on a 2D grid, the queue holds cells from at most a handful of “frontier layers” — the cells currently at the boundary of the explored region. That frontier, for a simply-connected region in an n×mn \times m grid, is bounded by the grid’s perimeter — specifically by O(min(n,m))O(\min(n, m)) in the worst case (a diagonal frontier through a square grid).

Compare with DFS recursion depth O(nm)O(nm). On a 1000×10001000 \times 1000 single snake island, DFS stack is 10⁶ frames deep; BFS queue is under 2000. The asymptotic complexity matches, but the constant and the resource pressure differ by three orders of magnitude.

When BFS wins

  • Large grids where recursion depth is a concern.
  • Stack-constrained environments (embedded, WASM, some interview sandboxes).
  • You need shortest distance from the source — a variant of the problem (Walls and Gates, Rotting Oranges) where BFS distances come free.

When DFS wins

  • Smaller grids, deeper code clarity. The four-line recursion is the most readable solution.
  • You need to compute per-island aggregates (area, perimeter) — DFS returns a value, BFS requires a counter by reference.
Approach 03

Union-find over grid cells

Allocate a disjoint-set with one node per cell. Scan left-to-right, top-to-bottom; for each land cell, union with its already-processed land neighbors (left and top). The component count is the answer.

Time O(n · m · α(n · m)) Space O(n · m) dsustreamingcomponents

The insight

DFS and BFS are the clearest tools for static grids where the entire input is visible at once. Union-find shines when:

  • The grid arrives incrementally (cells turn from water to land over time — Number of Islands II).
  • You need to answer component-count queries after each of a stream of updates.
  • You’re computing a minimum spanning tree or a Kruskal-style merging, where union-find is the native tool.

The static problem can be solved with union-find too, and doing so exercises the DSU muscle in a setting where you can verify the answer.

How it works, step by step

Flatten the grid into nmnm cell ids (i * m + j). Create a DSU with that many components, each initially a singleton containing exactly one id. Then:

  1. Scan the grid left-to-right, top-to-bottom (the same outer loop as before).
  2. Land cell? Register it as an active component (increment the live-component counter inside the DSU).
  3. Union with processed neighbors. Look left (j > 0 and grid[i][j-1] == '1') and up (i > 0 and grid[i-1][j] == '1'). For each such neighbor, call unite(here, neighbor). Each successful union decrements the component count.

We never look right or down because those cells haven’t been scanned yet. This is what keeps each edge in the graph from being considered twice.

The DSU internals

Two standard tricks make union-find nearly O(1)O(1) amortized:

  • Path compression. During find, every node visited on the way up has its parent pointer updated to the root. Subsequent finds on any of those nodes become one hop.
  • Union by rank. When merging two trees, the shallower tree becomes a subtree of the deeper one. Keeps the tree balanced; find stays near-constant-time.

Together, any kk operations on an nn-element DSU run in O(kα(n))O(k \cdot \alpha(n)), where α\alpha is the inverse Ackermann function — effectively a small constant for any realistic nn. That’s the α(nm)\alpha(nm) factor in the total complexity.

The code

union-find.cpp
#include <numeric>
#include <utility>
#include <vector>
class DSU {
public:
explicit DSU(int n) : parent_(n), rank_(n, 0), components_(0) {
std::iota(parent_.begin(), parent_.end(), 0);
}
int find(int x) {
while (parent_[x] != x) {
parent_[x] = parent_[parent_[x]];
x = parent_[x];
}
return x;
}
void unite(int a, int b) {
int ra = find(a);
int rb = find(b);
if (ra == rb) return;
if (rank_[ra] < rank_[rb]) std::swap(ra, rb);
parent_[rb] = ra;
if (rank_[ra] == rank_[rb]) ++rank_[ra];
--components_;
}
void addSingleton() { ++components_; }
int components() const { return components_; }
private:
std::vector<int> parent_;
std::vector<int> rank_;
int components_;
};
class Solution {
public:
int numIslands(std::vector<std::vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
const int n = static_cast<int>(grid.size());
const int m = static_cast<int>(grid[0].size());
DSU dsu(n * m);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] != '1') continue;
dsu.addSingleton();
const int here = i * m + j;
if (i > 0 && grid[i - 1][j] == '1') dsu.unite(here, (i - 1) * m + j);
if (j > 0 && grid[i][j - 1] == '1') dsu.unite(here, i * m + (j - 1));
}
}
return dsu.components();
}
};

A small DSU class with path compression and union-by-rank, plus the grid driver that only unions with already-processed neighbors.

Why it’s overkill for the static problem

For a single grid, union-find does the same work as DFS or BFS — visit every cell, look at every adjacency — plus the overhead of maintaining parent pointers and rank. The asymptotic complexity gains the α\alpha factor, which is invisibly close to O(1)O(1) but still non-trivial code overhead. If an interviewer asks only the static problem, lead with DFS.

Union-find earns its code when:

  • The update sequence is long and each update needs a fresh component count.
  • You want to support merging two grids or two regions cheaply.
  • You’re composing with other DSU-native algorithms (MST, connectivity oracles).

Interactive viz — DFS flood fill in action

Live · DFS flood fillStep through the outer scan and the DFS that fires on every unvisited land cell. Cells colored as they join their island; the small crosshair marks the scan cursor. Next ▶ advances one step; Skip jumps to the next island-discovered event.
Grid:
Step 0 of 58startislands so far: 0
11111111111111111

Grid is 7×7. Scan left-to-right, top-to-bottom.

The same outer-scan + per-component-sink structure drives all three approaches. The viz shows it via the DFS narrative (cleanest), but BFS and union-find would paint the islands in the same order.

Approach comparison

ApproachTimeSpace (aux)When to reach for it
DFS recursiveO(nm)O(nm)O(nm)O(nm) stackDefault. Cleanest code; the four-line recursion.
BFS iterativeO(nm)O(nm)O(min(n,m))O(\min(n, m))Large grids where recursion depth threatens the stack.
Union-findO(nmα(nm))O(nm \cdot \alpha(nm))O(nm)O(nm)Streaming / incremental inputs; composing with other DSU algorithms.

Default answer for the static problem: DFS recursive with in-place marking. State that out loud when you close.

Follow-ups to anticipate

The interviewer asks…What to do
”Return the size of the largest island.”Change the DFS to return the count of cells it sank. Track the running max in the outer loop. O(nm)O(nm), same traversal.
”Return the total perimeter of all islands.”Per land cell, count the number of its four neighbors that are water or off-grid. Sum across all land cells. A single pass, no DFS needed.
”8-connectivity (include diagonals).”Add four more directions to the neighbor list: (±1, ±1). The rest of the code is unchanged.
”Cells turn from water to land over time; report count after each update.”Number of Islands II. Use union-find: each new land is a singleton; union with up to four land neighbors. Each update is O(α(nm))O(\alpha(nm)).
”Find the shortest bridge between two islands.”LeetCode 934. First DFS to label one island; then multi-source BFS from its boundary, stopping at the first cell of another island.
”Count islands in a very large grid that doesn’t fit in memory.”Process row-by-row or chunk-by-chunk; track the components along the boundary and union across chunks. Union-find across boundaries is the usual tool.
”Stack depth is a concern — no recursion allowed.”BFS with a queue, or an explicit stack-simulated DFS. The algorithm is identical; the data structure switches.
”The grid is immutable.”Allocate a parallel visited matrix. Same O(nm)O(nm) time, extra O(nm)O(nm) space.

Pattern recognition — flood fill and connected components

The move this problem teaches — grid as implicit graph, outer scan triggers inner traversal per component — is one of the most reused patterns in interviews. Once you see it, half a dozen problems become applications of the same template:

  • Flood Fill (LeetCode 733). Paint a color starting from a seed cell. Literally the inner DFS of this problem.
  • Max Area of Island (LC 695). Same outer loop; DFS returns a size instead of mutating to count triggers.
  • Surrounded Regions (LC 130). Invert the question: flood-fill from the border to mark “safe” cells; flip everything else.
  • Walls and Gates (LC 286). Multi-source BFS from every gate; BFS distance replaces DFS reach.
  • Pacific Atlantic Water Flow (LC 417). Two independent floods from opposite borders; intersect the results.
  • Making A Large Island (LC 827). Label each island with an id and its size; for each water cell, sum the neighboring distinct-id sizes.

The reflex you want: when a problem hands you a 2D grid and asks for “how many blobs” or “reach from here to here” or “fill this color”, the answer is a variant of this template. Start with the graph view; the code falls out in ten lines.

What to say out loud — the delivery script

Ten beats. Rehearse until fluid.

  1. “Let me restate: count the number of maximal 4-connected land components in a 2D grid.”
  2. Ask: “diagonals not connected, right? Can I mutate the grid? How large does it get?”
  3. “I’ll read the grid as an implicit graph. Each cell is a vertex; 4-adjacent land cells share an edge. Counting islands = counting connected components.”
  4. “The standard structure is: outer loop over every cell; on each unvisited land cell, fire a DFS to sink the whole component; increment a counter once per trigger.”
  5. “I’ll mark cells visited in place by flipping '1' to '0'. That saves an extra visited matrix and collapses ‘already visited’ into ‘now water.’”
  6. Write the DFS — four lines. Base case: out of bounds or not land. Mark, then recurse on four neighbors.
  7. Write the outer loop — two nested for-loops, counter increments on trigger.
  8. “Complexity: O(nm)O(nm) time, each cell touched a bounded number of times. Space is O(nm)O(nm) worst-case recursion depth if the grid is one long snake island.”
  9. “Two alternatives worth naming: BFS with a queue is the same complexity with bounded queue space — safer on huge grids. Union-find is the right tool if the input is streaming or we need to merge regions.”
  10. “Edge cases: empty grid, all water, all land, single row — the recursion’s base case handles them without special cases. I’d ship the DFS version.”

Summary

Number of Islands is a connected-components problem wearing a 2D-grid costume. The algorithm is the textbook one: outer scan finds component heads, inner DFS (or BFS) sinks each component, count the triggers. The real interview signal isn’t the code — it’s articulating the grid-as-graph view, knowing when BFS beats DFS on stack pressure, and spotting when union-find is the right tool (streaming updates, region merging) versus when it’s just showing off.

The pattern generalizes to flood-fill, max-area-of-island, surrounded regions, and most grid problems an interviewer will hand you. Internalize the outer-scan-plus-inner-traversal template here; it will carry you through the rest of the grid-problem family without a single new idea.