DEV Community

Timevolt
Timevolt

Posted on

Backtracking: Mastering the Force Like a Jedi

The Quest Begins (The "Why")

I still remember the first time I stared at a Sudoku puzzle in a coffee shop, pencil poised, and felt my brain short‑circuit. I could fill in the obvious numbers, but as soon as I hit a dead end I’d erase everything and start over—again and again. It was maddening, and I kept thinking there had to be a smarter way than brute‑force guessing.

Around the same time, a friend threw the classic N‑Queens problem at me during an interview prep session: “Place n queens on an n×n board so none attack each other.” My first attempt was a nested‑loop nightmare that checked every permutation—O(n!)—and it choked on n = 8. I felt like I was trying to defeat a boss with a wooden sword.

That frustration sparked a question: What if we could explore possibilities intelligently, backing up the moment we realize we’re on a dead‑end path? That’s exactly what backtracking gives us—a systematic yet flexible way to walk through a search tree, pruning branches that can’t possibly lead to a solution.

The Revelation (The Insight)

At its heart, backtracking is depth‑first search with a guard rail. Imagine you’re navigating a maze. You pick a direction, walk forward, and if you hit a wall you immediately turn around and try the next path. You never waste time wandering down a corridor that you already know leads nowhere.

In algorithmic terms:

  1. Choose – make a decision (place a number, put a queen).
  2. Constrain – check if the partial solution still satisfies all rules.
  3. Recurse – if it’s valid, go deeper; otherwise, undo the last choice and try the next alternative.

The “undo” step is the magic. By reverting state instead of starting from scratch, we reuse work already done. The search space is still exponential in the worst case, but pruning cuts off huge swaths of it early, making puzzles that would take hours solvable in milliseconds.

Why does this work? Because any solution must be a leaf in the decision tree where every constraint holds. If a node violates a constraint, none of its descendants can be valid—so we can safely prune the entire subtree. That logical guarantee is why backtracking is both correct and, when constraints are tight, surprisingly fast.

Wielding the Power (Code & Examples)

Let’s see the pattern in code. I’ll write a generic backtracking skeleton first, then specialize it for Sudoku and N‑Queens.

def backtrack(state, is_valid, choices, goal_test):
    """
    state   – current partial solution (list)
    is_valid – fn(state) -> bool, checks constraints so far
    choices – fn(state) -> iterable of next candidates
    goal_test – fn(state) -> bool, true when we have a complete solution
    """
    if goal_test(state):
        return state[:]               # found a solution

    for cand in choices(state):
        state.append(cand)            # choose
        if is_valid(state):
            result = backtrack(state, is_valid, choices, goal_test)
            if result:                # propagate success upward
                return result
        state.pop()                   # undo (backtrack)
    return None                       # dead end
Enter fullscreen mode Exit fullscreen mode

Sudoku Solver

A Sudoku board is a 9×9 grid where each row, column, and 3×3 box must contain digits 1‑9 exactly once.

def solve_sudoku(board):
    EMPTY = 0

    def is_valid(b):
        # check rows, cols, boxes for duplicates (ignore zeros)
        for i in range(9):
            row = [b[i][j] for j in range(9) if b[i][j] != EMPTY]
            if len(row) != len(set(row)):
                return False
            col = [b[j][i] for j in range(9) if b[j][i] != EMPTY]
            if len(col) != len(set(col)):
                return False
        for bi in range(0, 9, 3):
            for bj in range(0, 9, 3):
                block = [
                    b[i][j]
                    for i in range(bi, bi+3)
                    for j in range(bj, bj+3)
                    if b[i][j] != EMPTY
                ]
                if len(block) != len(set(block)):
                    return False
        return True

    def next_empty(b):
        for i in range(9):
            for j in range(9):
                if b[i][j] == EMPTY:
                    return i, j
        return None

    def choices(b):
        pos = next_empty(b)
        if not pos:
            return []                     # board full
        i, j = pos
        used = set()
        used.update(b[i])                 # row
        used.update(b[x][j] for x in range(9))  # column
        bi, bj = (i//3)*3, (j//3)*3
        used.update(b[x][y] for x in range(bi, bi+3) for y in range(bj, bj+3))
        return [d for d in range(1,10) if d not in used]

    def goal(b):
        return next_empty(b) is None

    # start recursion
    backtrack(board, is_valid, choices, goal)
    return board   # solved in‑place
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • is_valid guarantees we never continue down a path that already breaks a rule.
  • choices only proposes numbers that aren’t already present in the row/col/box, drastically shrinking the branching factor.
  • The recursion depth is at most 81 (the number of cells), so space is O(n²) for the board plus O(n²) call stack—effectively O(n²).

N‑Queens

Place n queens on an n×n board so no two share a row, column, or diagonal.

def solve_n_queens(n):
    def is_valid(board):
        # board[col] = row where queen is placed; -1 means未放置
        for c1 in range(n):
            r1 = board[c1]
            if r1 == -1: continue
            for c2 in range(c1+1, n):
                r2 = board[c2]
                if r2 == -1: continue
                if r1 == r2 or abs(r1-r2) == abs(c1-c2):
                    return False
        return True

    def choices(board):
        # find first column without a queen
        try:
            col = board.index(-1)
        except ValueError:
            return []   # all placed
        # try every row in this column
        return [row for row in range(n) if row not in board]

    def goal(board):
        return -1 not in board

    board = [-1] * n
    backtrack(board, is_valid, choices, goal)
    return board   # list of rows per column
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The choices function guarantees we never put two queens in the same column (by construction) and we skip rows already used.
  • The diagonal check in is_valid kills any branch where a new queen would attack an existing one.
  • Depth is n, so auxiliary space is O(n).
  • In the worst case we still explore O(n!) leaves, but strong pruning reduces the explored nodes dramatically—n = 14 solves in a fraction of a second on a laptop.

Why This New Power Matters

Armed with backtracking, you can tackle a whole class of constraint‑satisfaction puzzles that appear in interviews: word searches, cross‑word filling, graph coloring, even scheduling problems. The pattern is universal:

  1. Encode the problem as a sequence of decisions.
  2. Define a fast “partial‑solution‑check”.
  3. Let the recursion do the heavy lifting, undoing when needed.

Because the algorithm is conceptual rather than tied to a specific data structure, you can adapt it to languages ranging from Python to C++ to JavaScript in minutes. And the best part? You’ll walk out of an interview feeling like you’ve just defeated the final boss with a lightsaber—elegant, precise, and unstoppable.


Your Turn

Pick a constraint problem you’ve struggled with (maybe a Kakuro puzzle or a Hamiltonian path). Write the is_valid, choices, and goal_test functions, plug them into the skeleton above, and watch the solution emerge. Share your code or a snippet in the comments—I’d love to see what you conquer next! 🚀

Top comments (0)