โ† Back to DevBytes

Solving Surrounded Regions in Python: Step-by-Step Guide

Solving Surrounded Regions in Python: Step-by-Step Guide

The "Surrounded Regions" problem is a classic graph traversal challenge that frequently appears in coding interviews and competitive programming. It tests your understanding of matrix traversal, depth-first search (DFS), breadth-first search (BFS), and the union-find data structure. In this tutorial, we will break down the problem, explore multiple solution strategies, and implement an efficient Python solution step by step.

What Is the Surrounded Regions Problem?

Given an m x n matrix board containing characters 'X' and 'O', the goal is to capture all regions that are fully surrounded by 'X' in all four directions (up, down, left, right). A region is considered "surrounded" if none of its 'O' cells lies on the border of the matrix. When a region is captured, all of its 'O' cells are flipped to 'X'.

Here is a visual example:

Input:
X X X X
X O O X
X X O X
X O X X

Output:
X X X X
X X X X
X X X X
X O X X

Notice that the 'O' at the bottom row remains unchanged because it touches the border, and therefore it is not surrounded. All other 'O' cells that are connected to it are also preserved.

Why This Problem Matters

This problem is more than an interview exercise. It models real-world scenarios such as:

Mastering this problem strengthens your ability to reason about grid connectivity, boundary conditions, and traversal algorithms โ€” skills that transfer directly to many production systems.

Understanding the Core Insight

The naive approach would be to iterate over every 'O' in the matrix and check whether it is surrounded. However, this leads to redundant work and complex bookkeeping. A much cleaner insight is to flip the problem on its head:

Instead of finding surrounded regions, find the regions that are not surrounded. Any 'O' that is connected โ€” directly or transitively โ€” to a border 'O' cannot be captured. Once we mark all such "safe" cells, every remaining 'O' must be surrounded and can be flipped.

This transforms the problem into a graph traversal starting from the borders of the matrix.

Step-by-Step Algorithm

Here is the high-level plan:

Implementing the DFS Solution

Let us implement this approach using recursive DFS. We will define a helper function that marks border-connected cells, then apply the transformation logic.

from typing import List

def solve(board: List[List[str]]) -> None:
    """
    Do not return anything, modify board in-place instead.
    Captures all 'O' regions that are fully surrounded by 'X'.
    """
    if not board or not board[0]:
        return

    rows, cols = len(board), len(board[0])

    def dfs(r: int, c: int) -> None:
        # Base case: out of bounds or not an 'O'
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        if board[r][c] != 'O':
            return

        # Mark this cell as temporarily safe
        board[r][c] = 'T'

        # Explore all four directions
        dfs(r + 1, c)
        dfs(r - 1, c)
        dfs(r, c + 1)
        dfs(r, c - 1)

    # Step 1: Mark all border-connected 'O' cells
    for r in range(rows):
        for c in range(cols):
            # Only start DFS from border cells
            if (r == 0 or r == rows - 1 or c == 0 or c == cols - 1) and board[r][c] == 'O':
                dfs(r, c)

    # Step 2: Flip remaining 'O' to 'X', and restore 'T' to 'O'
    for r in range(rows):
        for c in range(cols):
            if board[r][c] == 'O':
                board[r][c] = 'X'
            elif board[r][c] == 'T':
                board[r][c] = 'O'

This solution modifies the board in place and runs in O(m * n) time, where m is the number of rows and n is the number of columns. Each cell is visited at most a constant number of times. The space complexity is O(m * n) in the worst case due to the recursion stack, which could grow as large as the number of cells if the entire board is filled with 'O'.

Testing the Implementation

Let us verify the solution with a concrete test case:

if __name__ == "__main__":
    board = [
        ['X', 'X', 'X', 'X'],
        ['X', 'O', 'O', 'X'],
        ['X', 'X', 'O', 'X'],
        ['X', 'O', 'X', 'X'],
    ]

    solve(board)

    for row in board:
        print(' '.join(row))

Running this code produces the expected output:

X X X X
X X X X
X X X X
X O X X

The bottom-row 'O' survives because it touches the border, while the interior region is captured.

Implementing the BFS Alternative

Recursive DFS is elegant but can hit Python's recursion limit on very large boards. An iterative BFS approach avoids this issue entirely by using an explicit queue. Here is the equivalent implementation:

from collections import deque
from typing import List

def solve_bfs(board: List[List[str]]) -> None:
    if not board or not board[0]:
        return

    rows, cols = len(board), len(board[0])
    queue = deque()

    # Collect all border 'O' cells into the queue
    for r in range(rows):
        for c in range(cols):
            if (r == 0 or r == rows - 1 or c == 0 or c == cols - 1) and board[r][c] == 'O':
                board[r][c] = 'T'
                queue.append((r, c))

    # BFS to mark all connected 'O' cells as safe
    directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
    while queue:
        r, c = queue.popleft()
        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == 'O':
                board[nr][nc] = 'T'
                queue.append((nr, nc))

    # Final transformation pass
    for r in range(rows):
        for c in range(cols):
            if board[r][c] == 'O':
                board[r][c] = 'X'
            elif board[r][c] == 'T':
                board[r][c] = 'O'

The BFS version has the same time complexity of O(m * n) but avoids recursion depth issues. For production code or platforms with strict memory limits, this is often the safer choice.

Union-Find Approach

For those interested in an alternative data structure, the union-find (disjoint set) approach offers another perspective. The idea is to create a virtual node representing the "border" and union every border 'O' with this virtual node. Then, for every interior 'O', union it with adjacent 'O' cells. Finally, any 'O' that shares a root with the virtual node is safe.

from typing import List

class UnionFind:
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n

    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, x: int, y: int) -> None:
        px, py = self.find(x), self.find(y)
        if px == py:
            return
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1

def solve_union_find(board: List[List[str]]) -> None:
    if not board or not board[0]:
        return

    rows, cols = len(board), len(board[0])
    dummy = rows * cols  # virtual node for border
    uf = UnionFind(rows * cols + 1)

    def index(r: int, c: int) -> int:
        return r * cols + c

    for r in range(rows):
        for c in range(cols):
            if board[r][c] != 'O':
                continue
            # Connect border cells to the dummy node
            if r == 0 or r == rows - 1 or c == 0 or c == cols - 1:
                uf.union(index(r, c), dummy)
            # Connect to right and down neighbors
            if r + 1 < rows and board[r + 1][c] == 'O':
                uf.union(index(r, c), index(r + 1, c))
            if c + 1 < cols and board[r][c + 1] == 'O':
                uf.union(index(r, c), index(r, c + 1))

    for r in range(rows):
        for c in range(cols):
            if board[r][c] == 'O' and uf.find(index(r, c)) != uf.find(dummy):
                board[r][c] = 'X'

While union-find is slightly more complex to implement, it demonstrates how disjoint set structures can model connectivity problems elegantly. Its time complexity is approximately O(m * n * ฮฑ(m * n)), where ฮฑ is the inverse Ackermann function, which is effectively constant for all practical inputs.

Best Practices

When implementing this solution in real projects or interviews, keep the following best practices in mind:

Common Pitfalls to Avoid

Even experienced developers can stumble on this problem. Watch out for these mistakes:

Conclusion

The Surrounded Regions problem is a powerful exercise in graph traversal and boundary reasoning. By reframing the question from "find surrounded regions" to "find safe regions connected to the border," we unlock a clean and efficient solution that works with DFS, BFS, or union-find. The key takeaway is that recognizing the structural insight โ€” that border connectivity determines safety โ€” transforms a seemingly complex problem into a straightforward traversal task. Whether you are preparing for interviews or building grid-based applications, mastering this pattern will serve you well across a wide range of connected-component problems.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles