Understand
30-second explanation
Build the interview-ready mental model before comparing implementations.
Don't hunt for surrounded regions directly — find the ones that are NOT surrounded. Flood-fill from every 'O' on the border (DFS or BFS), marking those border-connected 'O's as safe (e.g. '#'). After the sweep, any 'O' still left is fully enclosed → flip it to 'X'; restore every '#' back to 'O'. One pass to mark, one pass to flip → O(m × n) time. Union-Find with a virtual 'border' node is the alternative.
Interview contextWhat is testedWhere candidates slipHow to stand outExplore interview contextHide interview context
What's being tested
Whether you spot that detecting 'fully surrounded' directly is awkward, and invert it to 'reachable from the border'. Also whether you mark safe cells with a temporary state and restore them cleanly.
Where candidates slip
Trying to detect enclosure with a flood fill that 'returns false if it hits the edge' — correct but fiddly and easy to get wrong. Or forgetting to restore the '#' markers back to 'O'.
How to stand out
Lead with the inversion ('mark what escapes, flip what's left'), give both DFS and BFS (BFS avoids stack overflow on huge boards), and mention the Union-Find-with-a-virtual-border-node formulation.
Choose
Compare the available approaches
Use constraints and trade-offs to select the route you can defend in an interview.
Interactive decision treeInteractiveChoose the right approachFirst decision previewCapture regions surrounded by XThink directly or invert the problem?Direct enclosure testInvertExplore the full treeCollapse the tree
Learn & trace
Build each solution step by step
Move from the core idea to a visual trace, implementation, proof, and edge cases.
Approach 1 of 3
Border DFS — Mark Escapees, Flip the Rest
1Hint 1Explore hintHide hint
Detecting 'this region is fully surrounded' from the inside is awkward — you'd have to track whether the fill ever touched an edge. Can you flip the question to make it easy?
2Hint 2Explore hintHide hint
An 'O' is safe exactly when it can reach the border. So flood-fill starting from every 'O' on the four edges and mark everything reachable as 'safe'.
3Hint 3Explore hintHide hint
After marking, sweep the whole board: any 'O' left was never reached from the border → it's enclosed, flip it to 'X'. Then turn your 'safe' markers back into 'O'.
A · Core idea
The breakthrough is refusing to solve the literal question. Asking 'is this region surrounded?' forces you to explore a region and somehow remember whether it ever brushed the boundary — a flood fill that must thread a boolean back up through recursion. It works, but it's error-prone.
Invert it. A region is captured unless it can escape to the edge. The complement — 'can this 'O' reach the border?' — is trivial to compute: start from the border and flood inward. So we seed a DFS from every 'O' sitting in row 0, the last row, column 0, or the last column. Anything those fills reach is, by definition, connected to the outside and therefore safe. We stamp each safe cell with a temporary marker '#'.
Why the temporary marker matters. After the border fills finish, the board holds three kinds of cells: 'X' (walls), '#' (safe 'O's that escaped), and 'O' (the ones the fills never reached). That last group is exactly the enclosed regions. The final sweep makes two replacements: every surviving 'O' becomes 'X' (captured), and every '#' is restored to 'O' (it was always safe). Skipping the restore step leaves the board littered with '#' — a wrong answer that's easy to overlook in testing.
Why DFS is enough. We're not measuring distances or sizes; we only need to reach every escapee. DFS does that with a four-way recursion and a single base case: stop when out of bounds or when the cell isn't an 'O'. The marker '#' doubles as the visited flag, so cells are never re-entered and the recursion terminates.
Why I'd flag the recursion depth. On a 200×200 board that's almost all 'O', the fill can recurse ~40,000 frames deep and overflow the call stack in languages with modest limits. That's the cue to offer the iterative BFS version, which moves the frontier into a heap-allocated queue.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
board = [[X,X,X,X],[X,O,O,X],[X,X,O,X],[X,O,X,X]]Interactive diagram
Why inverting the question is easier
scan bordersBorder-reachable 'O's are safe; the captured region is everything the border fill never touches.
[[X,X,X,X],[X,X,X,X],[X,X,X,X],[X,O,X,X]]C · Code with teaching comments
Reference implementation previewpublic void solve(char[][] board) { if (board == null || board.length == 0 || board[0].length == 0) { return; } int rows = board.length, cols = board[0].length;Explore the full codeHide the full code
D · Why it works and complexityO(m × n) timeO(m × n) worst-case recursion stack spaceOpen the invariant and proofExplore the proofHide the proof
The captured regions are the complement of the border-reachable region. Whenever a grid asks you to find cells that are 'trapped inside', the cheap move is almost always to flood-fill from the outside and keep what you didn't touch.
ComplexityO(m × n) time, O(m × n) worst-case recursion stack space — Each cell is visited a constant number of times: once by border seeding / a flood fill, and once by the final flip sweep → O(m × n). Recursion can descend to the size of one connected 'O' region, up to O(m × n) on an almost-all-'O' board.
E · Edge cases and pitfalls7board entirely 'O' — Every cell is border-connected → all marked '#', all restored to 'O'. Nothing captured.Explore edge casesHide edge cases
Edge cases handled
board entirely 'O'Every cell is border-connected → all marked '#', all restored to 'O'. Nothing captured.single row [[O,X,O]]Both 'O's are on the border → safe; board unchanged.no 'O' at allNo fills seed; the flip sweep changes nothing.
Pitfalls
- Trying to detect enclosure directly with a fill that returns a boolean — works but error-prone; invert instead.
- Forgetting the restore step, leaving '#' markers on the final board.
- Only seeding from one or two edges instead of all four.
- Deep recursion on a near-all-'O' 200×200 board can overflow the stack — prefer BFS there.
Approach 2 of 3
Border BFS — Iterative Frontier
1Hint 1Explore hintHide hint
Same inversion as DFS — mark border-reachable 'O's safe — but expand the frontier with an explicit queue instead of recursion.
2Hint 2Explore hintHide hint
First enqueue (and mark) every 'O' on the four borders. Then repeatedly dequeue a cell and enqueue its unmarked 'O' neighbours.
3Hint 3Explore hintHide hint
Mark cells at ENQUEUE time, not dequeue time, so the same cell is never queued twice.
A · Core idea
The logic is byte-for-byte the same as the DFS version — find what escapes to the border, capture the rest — but the traversal is iterative, which is what makes it safe at scale.
Why iterative matters here. A recursive border fill on a 200×200 board that is almost entirely 'O' can stack ~40,000 frames and overflow the call stack in stack-limited environments. BFS relocates that frontier into a heap-allocated queue, so the depth of exploration never touches the call stack.
Seed first, then expand. We make one initial pass over the four borders, and for each 'O' we find we mark it '#' and enqueue it. That primes the queue with every entry point to the outside. Then the main loop pops a cell, inspects its four orthogonal neighbours, and for each in-bounds 'O' it marks '#' and enqueues — extending the safe region outward layer by layer.
Mark on enqueue, never on dequeue. This is the one BFS detail that bites people. If you wait until a cell is dequeued to mark it, multiple neighbours can enqueue the same cell before it's processed, inflating the queue and re-doing work. Marking the instant you enqueue guarantees each cell is added exactly once.
Same final sweep. Once the queue drains, the board again has 'X' walls, '#' escapees, and trapped 'O's. The closing double-flip — 'O' → 'X', '#' → 'O' — finishes the job identically to the DFS approach.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
board = [[X,X,X,X],[X,O,O,X],[X,X,O,X],[X,O,X,X]]seed borders
only (3,1) is an 'O' on an edge → mark '#', enqueue
queue=[(3,1)][[X,X,X,X],[X,X,X,X],[X,X,X,X],[X,O,X,X]]C · Code with teaching comments
Reference implementation previewpublic void solve(char[][] board) { if (board == null || board.length == 0 || board[0].length == 0) { return; } int rows = board.length, cols = board[0].length;Explore the full codeHide the full code
D · Why it works and complexityO(m × n) timeO(m × n) worst-case queue spaceOpen the invariant and proofExplore the proofHide the proof
DFS and BFS are interchangeable for reachability — both just need to touch every border-connected 'O'. BFS is the safer default at scale because its memory lives in an explicit queue, not in recursion depth that can blow the stack.
ComplexityO(m × n) time, O(m × n) worst-case queue space — Each cell enters and leaves the queue at most once → O(m × n). The final flip sweep is another O(m × n). The queue can hold up to a large frontier, O(m × n) in the worst case.
E · Edge cases and pitfalls6near-all-'O' 200×200 board — BFS handles it with a queue and never risks a stack overflow; DFS might recurse ~40k deep.Explore edge casesHide edge cases
Edge cases handled
near-all-'O' 200×200 boardBFS handles it with a queue and never risks a stack overflow; DFS might recurse ~40k deep.board entirely 'X'Nothing is enqueued; the flip sweep changes nothing.single columnEvery cell is on the border → all 'O's safe; unchanged.
Pitfalls
- Marking visited at dequeue time — the same cell gets enqueued by multiple neighbours, inflating the queue.
- Using a list with pop(0) as the queue — O(n) per pop; use a deque/LinkedList.
- Forgetting bounds checks before reading board[nr][nc].
Approach 3 of 3 · Optimal
Union-Find with a Virtual Border Node
1Hint 1Explore hintHide hint
Model each cell as a node. Add one extra 'virtual' node that represents 'the outside / the border'.
2Hint 2Explore hintHide hint
Union every border 'O' with the virtual node, and union each 'O' with its 'O' neighbours below and to the right.
3Hint 3Explore hintHide hint
After all unions, an 'O' is safe iff it shares a root with the virtual node. Every other 'O' is captured.
A · Core idea
Union-Find reframes 'reachable from the border' as 'in the same set as a single virtual node that stands for the outside'.
The virtual node trick. Create one sentinel id (say rows * cols) that means 'connected to the border'. As we scan the grid, every 'O' that lies on an edge gets unioned with this sentinel. That collapses the question 'can this cell escape?' into 'does this cell's set contain the sentinel?' — a single find comparison at the end.
Building the rest of the connectivity. For each 'O', we union it with its 'O' neighbour directly below and directly to the right. Checking only down and right (rather than all four directions) is enough because every orthogonal adjacency is covered exactly once as we sweep the grid in row-major order — the up/left edges are handled when the other endpoint is processed. This halves the union calls without missing any connection.
The final classification. After the unions, we sweep once more. An 'O' is captured precisely when find(cell) != find(virtual) — i.e. it never joined the outside's set. Those flip to 'X'; everything else stays. Because we never used a temporary marker, there's no restore step.
Why path compression and the α bound. The find here flattens the tree as it walks (pointing each node at its grandparent), so repeated queries get cheaper. Combined over m × n cells, the total work is O(m × n × α(m × n)), where α is the inverse Ackermann function — at most 4 for any board you could ever allocate, so effectively linear. The flood-fill solutions are simpler to write under interview pressure; reach for Union-Find when the conversation is explicitly about disjoint-set structures or dynamic connectivity.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
board = [[X,X,X,X],[X,O,O,X],[X,X,O,X],[X,O,X,X]]Interactive diagram
Picking the right approach
scan (3,1)Border flood fill (DFS/BFS) is the default; Union-Find for a connectivity-flavored framing.
[[X,X,X,X],[X,X,X,X],[X,X,X,X],[X,O,X,X]]C · Code with teaching comments
D · Why it works and complexityO(m × n × α(m × n)) timeO(m × n) spaceOpen the invariant and proofExplore the proofHide the proof
A single virtual node that represents 'the boundary' turns a reachability question into a set-membership question. That sentinel pattern recurs whenever 'connected to a special place' is the property you care about.
ComplexityO(m × n × α(m × n)) time, O(m × n) space — Each cell does a constant number of unions/finds; with path compression and union the amortized cost per operation is α (inverse Ackermann), effectively constant → O(m × n × α).
E · Edge cases and pitfalls6board entirely 'O' — Every 'O' chains to a border 'O' and thus to the virtual node → nothing captured.Explore edge casesHide edge cases
Edge cases handled
board entirely 'O'Every 'O' chains to a border 'O' and thus to the virtual node → nothing captured.single enclosed 'O'It never unions with the virtual node → captured.no 'O'No unions happen; the classification sweep changes nothing.
Pitfalls
- Unioning in all four directions instead of just down+right — correct but does double the work.
- Forgetting to size the parent array to rows*cols + 1 for the virtual node — off-by-one crash.
- Comparing parent[a] == parent[b] instead of find(a) == find(b) — roots can be stale without a find.
Practice
Try the solution yourself
Edit the optimal implementation, test another input, and observe the result.
Editable code previewpublic void solve(char[][] board) { if (board == null || board.length == 0 || board[0].length == 0) { return; } int rows = board.length, cols = board[0].length;Explore the playgroundClose the playground
Avoid mistakes
Recognize the interview traps
Understand why common attempts fail before reviewing the corrected implementation.
1Forgetting to restore the safe markers
After the border fill, safe cells carry the temporary '#'. The final sweep must turn '#' back into 'O'; otherwise the output board is littered with stray markers and fails the judge.
Explore the code comparisonHide the code comparison
Forgetting to restore the safe markers
After the border fill, safe cells carry the temporary '#'. The final sweep must turn '#' back into 'O'; otherwise the output board is littered with stray markers and fails the judge.
Don't write this
for r in range(rows):
for c in range(cols):
if board[r][c] == 'O':
board[r][c] = 'X'
# missing: '#' -> 'O' restoreWrite this instead
for r in range(rows):
for c in range(cols):
if board[r][c] == 'O':
board[r][c] = 'X'
elif board[r][c] == '#':
board[r][c] = 'O'2Solving the problem directly instead of inverting
Detecting enclosure from the inside means a flood fill that must report whether it ever touched an edge and only flip if it didn't — extra state, easy to get wrong. Marking border-reachable cells safe is simpler and robust.
Explore the code comparisonHide the code comparison
Solving the problem directly instead of inverting
Detecting enclosure from the inside means a flood fill that must report whether it ever touched an edge and only flip if it didn't — extra state, easy to get wrong. Marking border-reachable cells safe is simpler and robust.
Don't write this
// fragile: fill returns false if it reaches the border
boolean escaped = floodAndReport(board, r, c);
if (!escaped) flipRegionToX(...); // must re-walk the regionWrite this instead
// invert: mark everything the border can reach, flip the rest
for (int r = 0; r < rows; r++) { dfs(board, r, 0); dfs(board, r, cols - 1); }
for (int c = 0; c < cols; c++) { dfs(board, 0, c); dfs(board, rows - 1, c); }
// then: leftover 'O' -> 'X', '#' -> 'O'3Marking BFS cells on dequeue instead of enqueue
If you only mark a cell when you pop it, several neighbours can enqueue the same cell first, so it is processed multiple times and the queue balloons.
Explore the code comparisonHide the code comparison
Marking BFS cells on dequeue instead of enqueue
If you only mark a cell when you pop it, several neighbours can enqueue the same cell first, so it is processed multiple times and the queue balloons.
Don't write this
while q:
r, c = q.popleft()
board[r][c] = '#' # too late: already enqueued by others
for dr, dc in dirs:
nr, nc = r + dr, c + dc
if in_bounds(nr, nc) and board[nr][nc] == 'O':
q.append((nr, nc))Write this instead
def enqueue_if_o(r, c):
if in_bounds(r, c) and board[r][c] == 'O':
board[r][c] = '#' # mark AT enqueue
q.append((r, c))Revise
Carry the pattern into the interview
Pattern to remember
Invert the problem: flood-fill the safe (border-connected) region, flip the rest
Code pattern to memorize
for each border cell that is 'O':
flood_fill → mark connected 'O's as '#' # safe, escapes
for each cell:
if 'O': -> 'X' # was enclosed
if '#': -> 'O' # restore safeRules to remember
- 1Solve the complement: an 'O' survives only if it can reach the border, so mark border-reachable 'O's first.
- 2Use a temporary marker ('#') for safe cells so the final pass can distinguish 'escaped' from 'enclosed'.
- 3Seed the flood fill from all four edges — top/bottom rows and left/right columns.
- 4Final pass does two flips: leftover
'O' → 'X'(captured) and'#' → 'O'(restore). Don't forget the restore. - 5This mutates the board in place and returns void — read the signature before coding.
Use this pattern when
- Capture / enclose problems where 'touching the border' means safe
- Grid flood fill where the answer is the cells you CAN'T reach from an edge
- Region-flipping on a matrix of two states
- Any 'connected to the outside' vs 'trapped inside' classification
Choose another pattern when
- You need the COUNT of regions, not a flip → plain connected-components (Number of Islands)
- Edges/connections arrive incrementally → Union-Find is the natural fit
- Movement has weights or you need shortest distance → BFS/Dijkstra, not a simple fill
Interview takeaway
Surrounded Regions teaches the single most useful grid trick after Number of Islands: when 'enclosed' is hard to test directly, flip the question and flood-fill from the border instead. The same border-seeding idea powers Pacific Atlantic Water Flow and Number of Closed Islands.
Continue
Transfer the pattern to another problem
Use a direct variation or pattern sibling to prove the idea is reusable.
Transferable patternInversion + border flood fill. When 'trapped inside' is hard to test, flood-fill from the outside and keep what you didn't reach. Same family as Number of Islands, Pacific Atlantic Water Flow, and Number of Closed Islands; Union-Find with a virtual node is the connectivity-flavored alternative.
Practice queueDirect variations and pattern siblings
Direct follow-ups
- Number of IslandsThe foundational grid flood fill — count connected components of land instead of inverting from the border.
- Pacific Atlantic Water FlowSame border-seeding idea, run twice (from each ocean), then intersect the reachable sets.
- Number of Closed IslandsComing soonFlood-fill border-touching land first to disqualify it, then count the remaining fully-enclosed islands.
- Max Area of IslandComing soonSame flood fill, but return the size of each region and track the maximum.