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

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

kindmeaningdrawn as
undirectedThe 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)
directedA one-way edge: from 0 to 1 only.(0)→(1)
weightedEach 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

formlooks 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 matrixn × n grid of 0/1, M[u][v] = 1 if joinedevery v with M[u][v] == 1
adjacency list[[1],[0,2],[1],[4],[3]]just read adj[u]
building an adjacency list from an edge list
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 SearchDFS: Depth First Search
ideaSpread 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.
toola queue, collections.deque (append at the back, popleft from the front)recursion (Python's call stack) or your own list used as a stack
visitsBoth 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.
BFS and DFS templates on an adjacency list
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.

walking the 4 neighbours of a cell
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.

Example 1, before
     c0 c1 c2 c3
r0   X  X  X  X
r1   X  O  O  X
r2   X  X  O  X
r3   X  O  X  X
after
     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

(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.

region reaches the top edge
X  X  O  X     ← edge O
X  O  O  X
X  X  O  X
X  O  X  X
result: nothing changes
X  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:

Key ruleAn O can be captured only if it is not connected (through other O's) to any O on 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.

Doubt 1: why not just check each region from the inside, "does any of your cells touch the edge?"
→ 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.

Doubt 2: do I need to start a BFS from a border O that is already marked safe?
→ 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".
Doubt 3: the teacher's comment on this version?
→ 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

  1. Make safe, an m × n grid of False.
  2. Walk the first and last column, and the first and last row.
  3. For every border cell that is O and not safe: BFS from it, marking every reachable O as safe.
  4. Walk every cell: if it's O and not safe, change it to X.

6Code (Python)

Part A · border BFS with a safe grid
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 -> captured

7Code line by line

linewhat 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] = TrueThe 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 < mBounds 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

Remember Part ADon't hunt for closed regions. Hunt for the open ones, from the border, and mark them safe. Everything else is captured.

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 passmeaningbecomes
Ono border search reached it → trappedX
#joined to the border → was safeO (restore it)
XlandX (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.

Doubt 1: why paint the cell when we push it, not when we pop it?
→ 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.)
Doubt 2: in the final pass, does the order of the two checks matter?
→ 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.
Doubt 3: once a border BFS has painted a big area, are later border cells in that area searched again?
→ 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

  1. For each cell in the first column, last column, first row and last row: if it's O, BFS from it.
  2. The BFS paints the start cell #, then repeatedly pops a cell and paints + queues every in-bounds O neighbour.
  3. Walk the whole board: O → X (captured), # → O (restored).

6Code (Python)

Part B · border BFS, mark in place (the teacher's approach)
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: restore

7Code line by line

linewhat 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
stepcell popped / visitedneighbours checked (up, down, left, right)queue afterpainted # so far
1border loop, rows 0–1: all X--none
2row 2: (2,4) is O → bfs(2,4), paint-[(2,4)](2,4)
3pop (2,4)(1,4) X ✗ · (3,4) O ✓ · (2,3) X ✗ · right off-board ✗[(3,4)](2,4) (3,4)
4pop (3,4)(2,4) # ✗ · (4,4) X ✗ · (3,3) O ✓ · right off-board ✗[(3,3)]+ (3,3)
5pop (3,3)(2,3) X · (4,3) X · (3,2) X · (3,4) # → nothing[ ](2,4) (3,4) (3,3)
6row 3: (3,0) X; (3,4) is already # → no new BFS. Row 4: (4,0), (4,4) X--same
7column loop: row 0 all X; (4,1) is O → bfs(4,1), paint-[(4,1)]+ (4,1)
8pop (4,1)(3,1) O ✓ · down off-board · (4,0) X · (4,2) X[(3,1)]+ (3,1)
9pop (3,1)(2,1) X · (4,1) # · (3,0) X · (3,2) X[ ]5 cells
10rest of the row loop: no more O on the border--5 cells
after the border searches
X  X  X  X  X
X  O  O  X  X
X  X  O  X  #
X  #  X  #  #
X  #  X  X  X
final pass: O→X, #→O
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

Remember Part BPaint border-connected O's as #. 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

Doubt: why 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

  1. dfs(r, c): if out of bounds or not O → return.
  2. Paint #, then call dfs on up, down, left, right.
  3. Call dfs on every border cell; then do the same final pass as Part B.

6Code (Python)

Part C · border DFS, mark in place
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

linewhat it means
if r < 0 or r >= n or ... or board[r][c] != 'O': returnThe 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):

  1. dfs(2,4): O → paint #. Try up (1,4): X → returns at once. Try down:
  2. dfs(3,4): O → paint #. Up (2,4) is # → returns. Down (4,4) X → returns. Left:
  3. dfs(3,3): O → paint #. All 4 neighbours are X or # → each returns. dfs(3,3) is done.
  4. 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.
  5. Later, dfs(4,1) paints (4,1), goes up to dfs(3,1), which paints (3,1) and finds nothing more.
  6. Final pass: same result as Part B ✓.
deepest point of the first search
dfs(2,4)dfs(3,4)dfs(3,3)
second search
dfs(4,1)dfs(3,1)

9Complexity & remember

Remember Part CWhenever the task is just "mark everything reachable", BFS and DFS are interchangeable. Practise both until either one comes out without thinking. In Python, deep grids favour BFS.

Part D · Revision page

A · safe gridB · # in place (BFS)C · # in place (DFS)
start pointsevery O on the 4 borders
visited markseparate safe gridthe board itself: O → #
final passO and not safe → XO → X, # → O
timeO(m·n)
extra spaceO(m·n) grid + queueO(m·n) queue worst caseO(m·n) call stack worst case
If you remember only 5 lines 1. A region is safe if any of its O's is on the border.
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).
Mistakes to avoid ✗ capturing an O just because its own 4 sides aren't all X (it's the region that counts)
✗ 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
test it yourself (paste under any of the solutions above)
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