DSA sheet · Graphs · Grid traversal · start from the border
Surrounded Regions
This problem looks like "find every closed group of O's and paint it X". The teacher shows that checking each group from the inside is the hard way. The easy way is to turn the question around: start from the border and walk inwards. Any O that touches the border can never be captured, and neither can any O joined to it. Mark those as "safe" with one BFS per border O, then flip everything else. She first keeps a separate visited grid, then removes it by writing a temporary # into the board itself. She codes it with BFS and asks us to also write the DFS version, which we do in Part C. The same "border first" trick comes back in the very next problem, Pacific Atlantic Water Flow.
Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember
- Part 0 · Graph and grid basics this page needs
- Part A · Border BFS with a separate "safe" grid (the first idea)
- Part B · Border BFS, marking in place with # (the teacher's code)
- Part C · The same thing with DFS
- Part D · Revision page
Part 0 · Graph and grid basics this page needs
Nodes and edges
A graph is a set of points joined by lines. The points are nodes (also called vertices). The lines are edges. Two nodes joined by an edge are called neighbours.
(0)—(1)—(2) (3)—(4) nodes: 0 1 2 3 4 edges: 0-1, 1-2, 3-4
Directed, undirected, weighted
| kind | meaning | drawn as |
|---|---|---|
| undirected | The edge works both ways: 0 can reach 1 and 1 can reach 0. A grid is undirected: if you can step from a cell to the cell on its right, you can step back. | (0)—(1) |
| directed | A one-way edge: from 0 to 1 only. | (0)→(1) |
| weighted | Each edge carries a number (a cost or a distance). This problem has no weights. | (0)—5—(1) |
Three ways a graph can be given, and building an adjacency list
| form | looks like (for the picture above) | neighbours of node u |
|---|---|---|
| edge list | [[0,1],[1,2],[3,4]] | scan all edges, so convert it first |
| adjacency matrix | n × n grid of 0/1, M[u][v] = 1 if joined | every v with M[u][v] == 1 |
| adjacency list | [[1],[0,2],[1],[4],[3]] | just read adj[u] |
n = 5
edges = [[0, 1], [1, 2], [3, 4]]
adj = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u) # undirected: add both ways
print(adj) # [[1], [0, 2], [1], [4], [3]]In this problem we never build an adjacency list: the grid itself tells us the neighbours, as shown below.
Visited, and connected components
In an undirected graph, 0 lists 1 as a neighbour and 1 lists 0 back. A walk that does not remember where it has been goes 0 → 1 → 0 → 1… forever. So every traversal keeps a visited mark: mark a node the moment you reach it, and never enter a marked node again. A connected component is a group of nodes that can all reach each other, with nothing outside the group reachable. In this problem, a region is exactly a connected component of O cells.
BFS and DFS
| BFS: Breadth First Search | DFS: Depth First Search | |
|---|---|---|
| idea | Spread out level by level, like a ripple: first the start, then everything 1 step away, then 2 steps away… | Go deep along one neighbour until stuck, then come back and try the next one. |
| tool | a queue, collections.deque (append at the back, popleft from the front) | recursion (Python's call stack) or your own list used as a stack |
| visits | Both reach exactly the same set of nodes from a start node. Only the order differs. When the job is just "mark everything reachable", either one works. | |
from collections import deque
def bfs(start, adj, visited):
q = deque([start])
visited[start] = True # mark when you ADD to the queue
while q:
u = q.popleft()
for v in adj[u]:
if not visited[v]:
visited[v] = True
q.append(v)
def dfs(u, adj, visited):
visited[u] = True
for v in adj[u]:
if not visited[v]:
dfs(v, adj, visited)
adj = [[1], [0, 2], [1], [4], [3]]
seen = [False] * 5
bfs(0, adj, seen)
print(seen) # [True, True, True, False, False]
dfs(3, adj, seen)
print(seen) # [True, True, True, True, True]A grid is a graph
Each cell (r, c) is a node. Its neighbours are the 4 cells up, down, left and right (no diagonals in this problem). We keep the 4 moves in a small list of (dr, dc) pairs: how much the row and the column change.
(r-1, c)
↑ (-1, 0)
(r, c-1) ← (r, c) → (r, c+1)
(0,-1) ↓ (0, +1)
(r+1, c)
(+1, 0)
Before touching a neighbour (nr, nc), do the bounds check 0 <= nr < rows and 0 <= nc < cols, or a border cell will look outside the board (in Python, a −1 index silently wraps to the last row, which is an even sneakier bug than a crash). And instead of a separate visited grid we can often mark visited in place, by overwriting the cell with a value that no longer passes the "should I go here?" test. That is exactly the trick of Part B.
grid = ["XOX",
"OOX"]
rows, cols = len(grid), len(grid[0])
r, c = 1, 1
for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): # up, down, left, right
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols:
print((nr, nc), grid[nr][nc])
# prints (0, 1) O, then (1, 0) O, then (1, 2) X (down is outside)Part A · Border BFS with a separate "safe" grid
LeetCode 130 · Surrounded Regions
1The question in simple words
You get a board of 'X' and 'O' letters. O cells that touch each other up/down/left/right form a region. A region is surrounded when none of its cells sits on the edge of the board, so it is fully closed in by X's. Capture every surrounded region by turning all its O's into X's. Change the board in place; return nothing.
c0 c1 c2 c3 r0 X X X X r1 X O O X r2 X X O X r3 X O X X
c0 c1 c2 c3 r0 X X X X r1 X X X X r2 X X X X r3 X O X X ← stays O
The three O's at (1,1), (1,2), (2,2) are one region, and it is closed in on every side, so they all become X. The O at (3,1) is on the bottom edge, so it stays.
2What the constraints tell us
1 ≤ m, n ≤ 200→ the board is never empty, soboard[0]is always safe to read. At most 200 × 200 = 40,000 cells.- An O(m·n) solution is about 40,000 steps, which is instant. But "for every cell, run a full search" is about (m·n)² = 1.6 × 10⁹ steps, which is far beyond the rough 10⁸ limit and would get TLE. So we want each cell handled a constant number of times.
- Every cell is
'X'or'O'. That means any third character, like'#', is free for us to use as a temporary mark (Part B).
(The teacher doesn't read the constraints aloud in this video; the reasoning above is ours, using her usual checklist: base case? fast enough?)
3Intuition: think about who can NOT be captured
Picture the board as a lake. X is land, O is water. Water on the edge of the board leaks out into the sea. Any water connected to that leaking water also drains away. Only water in a closed pond stays put, and a closed pond is exactly a surrounded region.
So instead of asking, for every region, "are you closed?", ask the easy opposite question: "which O's are connected to the border?" Those are safe. Every other O is captured.
4Building the logic from the teacher's examples
Step 1: what does "surrounded" really need?
Look at the O at (1,1) in Example 1. Above it is X, left of it is X, below it is X, but to its right is another O, not an X. Is it still surrounded? Yes. The teacher's point: one cell doesn't need X on all four of its own sides. If a side has an O, that O belongs to the same region, and the region as a whole just has to be fenced. The O at (1,2) supplies the missing X on the right, the O at (2,2) supplies the X further down, and so on. Every path out of the region eventually hits an X.
X X X X
X [O][O] X [ ] = the region
X X [O] X every step outward from a [ ] cell
X O X X lands on X or on another [ ] cell
Step 2: what breaks it? Reaching the edge
Now she changes the picture: suppose there is no row of X under the region, so the bottom O of the region sits on the last row. When you try to step down from it, you fall off the board instead of hitting an X. There is no fence on that side, so the region is not surrounded. It doesn't matter that every other side is fenced.
X X O X ← edge O
X O O X
X X O X
X O X XX X O X X O O X all 4 O's are joined X X O X to the edge O, so X O X X all stay O
One O on the border spoils its whole region: every O joined to it, directly or through other O's, can step its way to that border cell and out. So the rule is about the border:
Step 3: the special case that makes it click
The teacher asks: what if every border cell is X? Then no O anywhere can reach the edge, so every O inside is captured. Two loops that flip all O's to X would finish the job. The border is the only thing that can save an O, which is why we only need to start our searches from the border.
→ You can, but it's clumsier: you must find each region, collect all its cells, check them, and only then decide whether to flip them. If you do it carelessly (one fresh search per O cell) it becomes about (m·n)² work. Starting from the border is simpler: one pass marks every safe O, a second pass flips the rest. No region bookkeeping at all.
Step 4: remembering the safe cells
Walk around the 4 borders. Whenever a border cell is O, it's safe, and so is every O connected to it. Finding "every O connected to this cell" is a plain BFS (or DFS) on the grid, only stepping onto cells that are O and inside the board. Her first idea is to remember the safe cells in a separate grid safe of the same size, all False at the start, set to True for each safe O.
Then a final pass over every cell: O and not safe → turn into X. O and safe → leave it.
→ No. If it's already safe, an earlier BFS reached it and therefore also reached everything connected to it. Starting again would repeat the same work. So the start test is "O and not yet safe".
→ It works, but the
safe grid costs m × n extra space. She keeps it only as a stepping stone and removes it in Part B.5Approach steps
- Make
safe, an m × n grid of False. - Walk the first and last column, and the first and last row.
- For every border cell that is O and not safe: BFS from it, marking every reachable O as safe.
- Walk every cell: if it's O and not safe, change it to X.
6Code (Python)
from collections import deque
class Solution:
def solve(self, board):
n, m = len(board), len(board[0])
safe = [[False] * m for _ in range(n)]
def bfs(r, c):
q = deque([(r, c)])
safe[r][c] = True
while q:
r, c = q.popleft()
for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
nr, nc = r + dr, c + dc
if (0 <= nr < n and 0 <= nc < m
and board[nr][nc] == 'O' and not safe[nr][nc]):
safe[nr][nc] = True
q.append((nr, nc))
for i in range(n): # first and last column
for j in (0, m - 1):
if board[i][j] == 'O' and not safe[i][j]:
bfs(i, j)
for j in range(m): # first and last row
for i in (0, n - 1):
if board[i][j] == 'O' and not safe[i][j]:
bfs(i, j)
for i in range(n):
for j in range(m):
if board[i][j] == 'O' and not safe[i][j]:
board[i][j] = 'X' # trapped -> captured7Code line by line
| line | what it means |
|---|---|
| safe = [[False] * m for _ in range(n)] | One flag per cell. True = "this O touches the border through other O's, never flip it". (Writing [[False]*m]*n would make all rows the same list, a classic Python bug.) |
| q = deque([(r, c)]) safe[r][c] = True | The BFS starts at one border O, which is safe by definition. |
| r, c = q.popleft() | Take the oldest cell in the queue and look around it. |
| for dr, dc in (...) | The 4 moves: up, down, left, right. |
| 0 <= nr < n and 0 <= nc < m | Bounds check first, so the next checks never read outside the board. |
| board[nr][nc] == 'O' | Only water spreads. An X blocks the walk. |
| not safe[nr][nc] | The visited check: don't add a cell twice. |
| safe[nr][nc] = True q.append((nr, nc)) | Mark it the moment we add it, then let the queue explore its neighbours later. |
| for i in range(n): for j in (0, m-1) | Column 0 and column m−1 of every row. (If m is 1, both are the same column; the "not safe" check stops a second BFS.) |
| for j in range(m): for i in (0, n-1) | Row 0 and row n−1 of every column. The 4 corners get checked twice, which is harmless. |
| if board[i][j] == 'O' and not safe[i][j]: board[i][j] = 'X' | Any O that no border search could reach is trapped. Capture it. |
8Dry run
The dry run is identical to Part B's, with "safe = True" in place of "write #". We do it once, in Part B, where it matters most.
9Complexity & remember
- Time O(m·n): border loops are O(2m + 2n); each O becomes safe once, so all BFS calls together touch each cell a constant number of times; the final pass is m·n.
- Space O(m·n): the
safegrid, plus the queue.
Part B · Border BFS, marking in place with #
1The question
Same question, same idea. The only goal now is to drop the extra safe grid.
2What the constraints tell us
Cells are only ever 'X' or 'O', so a third symbol is free to carry extra information. That is the whole trick.
3Intuition: write the "safe" flag into the board itself
The teacher's line of thought: in the final pass we flip every cell that reads as O. We don't want the safe O's to be flipped, so let's make them temporarily not read as O. Paint them with a third symbol, say # (not X, not O). The final pass then has three cases:
| cell in the final pass | meaning | becomes |
|---|---|---|
O | no border search reached it → trapped | X |
# | joined to the border → was safe | O (restore it) |
X | land | X (untouched) |
4Building the logic: why # also acts as "visited"
The BFS only steps onto cells that are O. The moment we paint a cell #, it stops being O, so no later step can walk into it again. The paint does double duty: it is the safe flag and the visited mark. That's why this version needs no visited grid at all.
Her walk-through around one border O: from it, look up → X, don't go. Look left → X, don't go. Look down → off the board, don't go. Look at the cell we came from → it's already #, not O, so don't go. Only real O neighbours are painted and queued.
→ If we waited until popping, a cell could be pushed twice: two of its neighbours both see it as O before either one is popped. Painting at push time closes the door immediately. (The teacher paints right before appending, exactly like this.)
→ Use
if … elif …, as below, and it doesn't. But if you write two separate ifs with "# → O" first, then a cell that was just restored to O is seen by the second if as O and flipped to X. Every safe cell would be wrongly captured. With "O → X" first and "# → O" second, two plain ifs happen to work; elif makes it safe either way.→ No. The start test is
board[i][j] == 'O', and those cells are now #. This is what keeps the total work at m·n (see the complexity below).5Approach steps
- For each cell in the first column, last column, first row and last row: if it's O, BFS from it.
- The BFS paints the start cell
#, then repeatedly pops a cell and paints + queues every in-bounds O neighbour. - Walk the whole board: O → X (captured), # → O (restored).
6Code (Python)
from collections import deque
class Solution:
def solve(self, board):
n, m = len(board), len(board[0])
def bfs(r, c):
q = deque([(r, c)])
board[r][c] = '#' # safe, and visited
while q:
r, c = q.popleft()
for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < m and board[nr][nc] == 'O':
board[nr][nc] = '#'
q.append((nr, nc))
for i in range(n):
if board[i][0] == 'O': # first column
bfs(i, 0)
if board[i][m - 1] == 'O': # last column
bfs(i, m - 1)
for j in range(m):
if board[0][j] == 'O': # first row
bfs(0, j)
if board[n - 1][j] == 'O': # last row
bfs(n - 1, j)
for i in range(n):
for j in range(m):
if board[i][j] == 'O':
board[i][j] = 'X' # trapped: capture
elif board[i][j] == '#':
board[i][j] = 'O' # was safe: restore7Code line by line
| line | what it means |
|---|---|
| n, m = len(board), len(board[0]) | Rows and columns, as the teacher names them. |
| board[r][c] = '#' | The start cell is a border O: safe. Painting it also means no one walks into it again. |
| if 0 <= nr < n and 0 <= nc < m and board[nr][nc] == 'O': | Inside the board and still an unpainted O. X, # and off-board are all skipped by this one test. |
| board[nr][nc] = '#' q.append((nr, nc)) | Paint it now, explore its neighbours later. |
| if board[i][0] == 'O': bfs(i, 0) | A border O that no earlier search painted. Start a new search. (If it's already #, skip.) |
| if board[i][j] == 'O': board[i][j] = 'X' | After all border searches, any O left was unreachable from the border → surrounded → capture. |
| elif board[i][j] == '#': board[i][j] = 'O' | Undo the temporary paint so the board only has X and O again. |
8Dry run
A slightly bigger board, so we see two separate border searches and one captured region. Loop order is the code's: column checks for each row i, then row checks for each column j.
c0 c1 c2 c3 c4
r0 X X X X X
r1 X O O X X inner region {(1,1),(1,2),(2,2)}
r2 X X O X O ← (2,4) is a border O
r3 X O X O O ← (3,4) border O, (3,3) joined to it
r4 X O X X X ← (4,1) border O, (3,1) joined to it
| step | cell popped / visited | neighbours checked (up, down, left, right) | queue after | painted # so far |
|---|---|---|---|---|
| 1 | border loop, rows 0–1: all X | - | - | none |
| 2 | row 2: (2,4) is O → bfs(2,4), paint | - | [(2,4)] | (2,4) |
| 3 | pop (2,4) | (1,4) X ✗ · (3,4) O ✓ · (2,3) X ✗ · right off-board ✗ | [(3,4)] | (2,4) (3,4) |
| 4 | pop (3,4) | (2,4) # ✗ · (4,4) X ✗ · (3,3) O ✓ · right off-board ✗ | [(3,3)] | + (3,3) |
| 5 | pop (3,3) | (2,3) X · (4,3) X · (3,2) X · (3,4) # → nothing | [ ] | (2,4) (3,4) (3,3) |
| 6 | row 3: (3,0) X; (3,4) is already # → no new BFS. Row 4: (4,0), (4,4) X | - | - | same |
| 7 | column loop: row 0 all X; (4,1) is O → bfs(4,1), paint | - | [(4,1)] | + (4,1) |
| 8 | pop (4,1) | (3,1) O ✓ · down off-board · (4,0) X · (4,2) X | [(3,1)] | + (3,1) |
| 9 | pop (3,1) | (2,1) X · (4,1) # · (3,0) X · (3,2) X | [ ] | 5 cells |
| 10 | rest of the row loop: no more O on the border | - | - | 5 cells |
X X X X X X O O X X X X O X # X # X # # X # X X X
X X X X X X X X X X X X X X O X O X O O X O X X X
The inner region was never painted, because no border search could reach it, so the final pass captured it ✓. The two border-connected groups came back as O ✓.
9Complexity & remember
- Time O(m·n). The teacher's argument: the border loops are O(2n + 2m), which is small. One BFS can, in the worst case, run over the whole board (imagine all O's). But then every O is now #, so all the other border checks find # and start nothing. Each cell is painted at most once across all searches, so together they cost m·n, not m·n per border cell. Add the final m·n pass: still O(m·n).
- Space O(m·n) in the worst case, for the queue (an all-O board can put many cells in it). No visited grid any more.
#. Then O → X, # → O. The paint is both the safe flag and the visited mark.Part C · The same thing with DFS
The teacher points out that this problem is only a traversal: we just need to reach every O connected to a border O. BFS and DFS reach exactly the same cells, so DFS works equally well. She asks us to submit the DFS version ourselves. Steps ①–③ are the same as Part B.
4What changes
- No queue. The function paints its cell and calls itself on each O neighbour. The call stack remembers where to come back to.
- The check moves to the start of the function: "if this cell is off the board, or not O, return". Then the caller can blindly call on all 4 neighbours.
- The border loops and the final pass are unchanged.
sys.setrecursionlimit?→ Python stops recursion at about 1000 nested calls by default. On a 200 × 200 board full of O's, a DFS can go 40,000 calls deep (a long snake through every cell). Raising the limit avoids a
RecursionError. The BFS version never has this problem, which is one practical reason to prefer BFS on big grids in Python.5Approach steps
dfs(r, c): if out of bounds or not O → return.- Paint
#, then call dfs on up, down, left, right. - Call dfs on every border cell; then do the same final pass as Part B.
6Code (Python)
import sys
class Solution:
def solve(self, board):
sys.setrecursionlimit(100000)
n, m = len(board), len(board[0])
def dfs(r, c):
if r < 0 or r >= n or c < 0 or c >= m or board[r][c] != 'O':
return
board[r][c] = '#'
dfs(r - 1, c)
dfs(r + 1, c)
dfs(r, c - 1)
dfs(r, c + 1)
for i in range(n):
dfs(i, 0)
dfs(i, m - 1)
for j in range(m):
dfs(0, j)
dfs(n - 1, j)
for i in range(n):
for j in range(m):
if board[i][j] == 'O':
board[i][j] = 'X'
elif board[i][j] == '#':
board[i][j] = 'O'7Code line by line
| line | what it means |
|---|---|
| if r < 0 or r >= n or ... or board[r][c] != 'O': return | The base case. Off the board, X, or already # → nothing to do. Bounds come first so we never index outside. |
| board[r][c] = '#' | Paint before going deeper, so the neighbours' calls can't come back into this cell. |
| dfs(r - 1, c) ... dfs(r, c + 1) | Try all 4 directions. Each one returns at once if that cell isn't an O. |
| dfs(i, 0); dfs(i, m - 1) | No "if O" needed out here, because the function checks it itself. |
8Dry run (call stack)
Same board as Part B. The first border O is (2,4):
- dfs(2,4): O → paint #. Try up (1,4): X → returns at once. Try down:
- dfs(3,4): O → paint #. Up (2,4) is # → returns. Down (4,4) X → returns. Left:
- dfs(3,3): O → paint #. All 4 neighbours are X or # → each returns. dfs(3,3) is done.
- Back in dfs(3,4): right is off the board → returns. dfs(3,4) done. Back in dfs(2,4): left (2,3) X, right off-board. Done.
- Later, dfs(4,1) paints (4,1), goes up to dfs(3,1), which paints (3,1) and finds nothing more.
- Final pass: same result as Part B ✓.
9Complexity & remember
- Time O(m·n): same reason, each O is painted once; calls on X/#/off-board cells return in O(1).
- Space O(m·n): the recursion stack in the worst case.
Part D · Revision page
| A · safe grid | B · # in place (BFS) | C · # in place (DFS) | |
|---|---|---|---|
| start points | every O on the 4 borders | ||
| visited mark | separate safe grid | the board itself: O → # | |
| final pass | O and not safe → X | O → X, # → O | |
| time | O(m·n) | ||
| extra space | O(m·n) grid + queue | O(m·n) queue worst case | O(m·n) call stack worst case |
2. So start from the border O's, not from the inside.
3. BFS/DFS from each border O, painting every connected O as
#.4. Final pass: O → X (trapped), # → O (safe).
5. Each cell is painted once overall → O(m·n).
✗ forgetting one of the 4 borders (first/last row and first/last column)
✗ final pass "# → O" before "O → X" with two separate ifs (everything becomes X)
✗ painting when popping instead of when pushing (cells queued twice)
✗
[[False]*m]*n for the safe grid (all rows are one list)✗ no bounds check, or relying on Python's negative indexes
b1 = [list("XXXX"), list("XOOX"), list("XXOX"), list("XOXX")]
Solution().solve(b1)
print(["".join(r) for r in b1]) # ['XXXX', 'XXXX', 'XXXX', 'XOXX']
b2 = [list("XXOX"), list("XOOX"), list("XXOX"), list("XOXX")]
Solution().solve(b2)
print(["".join(r) for r in b2]) # ['XXOX', 'XOOX', 'XXOX', 'XOXX']
b3 = [list("XXXXX"), list("XOOXX"), list("XXOXO"), list("XOXOO"), list("XOXXX")]
Solution().solve(b3)
print(["".join(r) for r in b3]) # ['XXXXX', 'XXXXX', 'XXXXO', 'XOXOO', 'XOXXX']
b4 = [["O"]]
Solution().solve(b4)
print(b4) # [['O']]Based on this video: Surrounded Regions | Graph | Pattern-wise DSA sheet