Backtracking Algorithm Explained: Master Recursion for Coding Interviews

Complete guide to backtracking with choose-explore-unchoose pattern. Solve N-Queens, Sudoku, permutations, and combinations. Learn constraint pruning techniques with Python examples for technical interviews

📅 Published: June 12, 2025 ✏️ Updated: July 8, 2025 By Ojaswi Athghara
#backtracking #recursion #permutations #n-queens #constraints #leetcode

Backtracking Algorithm Explained: Master Recursion for Coding Interviews

The Moment Backtracking Finally Made Sense

I'll be honest—backtracking was one of those algorithmic concepts that felt like magic when I first encountered it. My interviewer asked me to generate all permutations of a string, and while I knew it involved recursion, I had no idea how to systematically explore and "undo" choices to avoid duplicates and find all solutions.

Then someone explained the three-word mantra that changed everything: "Choose, Explore, Unchoose." Suddenly, backtracking wasn't mysterious anymore. It was just a systematic way to explore all possibilities by trying each option, recursing deeper, and then undoing that choice to try the next option.

In this guide, I'll demystify backtracking the same way it was demystified for me. No confusing abstractions—just clear patterns, practical examples, and the mental models you need to solve any backtracking problem confidently.

What Is Backtracking, Really?

Backtracking is a systematic way to explore all possible solutions to a problem by making choices, exploring their consequences, and "backing up" (undoing choices) when they lead to dead ends.

Think of it like exploring a maze:

  • You try a path (make a choice)
  • You walk down that path (explore)
  • If you hit a dead end, you walk back to the last intersection (backtrack/unchoose)
  • You try the next unexplored path
  • Repeat until you've tried all paths

The universal backtracking template:

def backtrack(state, choices):
    """
    Universal backtracking template.
    State: current partial solution
    Choices: available options at this point
    """
    # Base case: found complete solution
    if is_solution(state):
        output(state)
        return

    # Try each available choice
    for choice in choices:
        # 1. Choose: add choice to state
        make_choice(state, choice)

        # 2. Explore: recurse with updated state
        backtrack(state, get_new_choices(state))

        # 3. Unchoose: remove choice from state (backtrack)
        undo_choice(state, choice)

That's it. Every backtracking problem follows this pattern. The only things that change are:

  1. What constitutes a "solution"
  2. What "choices" are available
  3. How you "make" and "undo" choices

When to Recognize a Backtracking Problem

Your brain should immediately think "backtracking" when you see:

Clear indicators:

  • "Generate ALL possible..." (permutations, combinations, subsets)
  • "Find ALL solutions..." (N-Queens, Sudoku)
  • "Count the number of ways to..." (when you need to explore all paths)
  • Making a series of choices where future choices depend on past ones
  • Problems with constraints that can rule out partial solutions early

Keywords that scream backtracking:

  • Permutations, combinations, subsets
  • N-Queens, Sudoku solver
  • Word search in grid
  • Path finding with constraints
  • Partition problems
  • Generate parentheses

If you can solve it by trying every possibility and some possibilities can be eliminated early (before exploring fully), it's probably backtracking.

The Choose-Explore-Unchoose Pattern

Let me break down the three steps with a concrete example: generating all subsets of [1, 2, 3].

Step 1: Choose

Make a decision. Add an element to your current subset.

current_subset.append(nums[i])  # Choose to include nums[i]

Step 2: Explore

Recursively explore what happens after making that choice.

backtrack(i + 1, current_subset)  # Explore with this choice

Step 3: Unchoose

Undo your choice so you can try the next option.

current_subset.pop()  # Unchoose - remove nums[i] to try next

Complete example:

def generate_subsets(nums):
    """
    Generate all subsets using backtracking.
    Classic choose-explore-unchoose pattern.
    """
    result = []

    def backtrack(start, current):
        # Base case: we've made a choice for all positions
        # Add current subset to results
        result.append(current[:])  # Copy current state

        # Try adding each remaining element
        for i in range(start, len(nums)):
            # Choose: include nums[i]
            current.append(nums[i])

            # Explore: recurse with nums[i] included
            backtrack(i + 1, current)

            # Unchoose: remove nums[i] to try next option
            current.pop()

    backtrack(0, [])
    return result

Test: generate_subsets([1, 2, 3])Output: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

The recursion tree visualization:

                          []
                /         |         \
           [1]           [2]         [3]
          /    \          |
      [1,2]   [1,3]     [2,3]
       |
    [1,2,3]

At each level, we choose to include an element, explore all possibilities with that choice, then unchoose and try the next element.

Pattern 1: Generating Combinations and Permutations

These are the "bread and butter" of backtracking. Master these, and you'll recognize the pattern everywhere.

Permutations: Order Matters

Problem: Generate all arrangements of elements where order matters.

def permutations(nums):
    """
    Generate all permutations where order matters.
    Uses used[] array to track which elements are in current permutation.
    """
    result = []
    used = [False] * len(nums)

    def backtrack(current):
        # Base case: built complete permutation
        if len(current) == len(nums):
            result.append(current[:])
            return

        # Try each unused number
        for i in range(len(nums)):
            if used[i]:
                continue  # Skip if already in current permutation

            # Choose: mark as used, add to current
            used[i] = True
            current.append(nums[i])

            # Explore: continue building permutation
            backtrack(current)

            # Unchoose: mark as unused, remove from current
            current.pop()
            used[i] = False

    backtrack([])
    return result

Test: permutations([1, 2, 3])Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

Key insight: We need a used array because for permutations, each element can appear anywhere, so we need to track what's already in our current permutation.

Time complexity: O(n! × n) - n! permutations, O(n) to copy each Space complexity: O(n) for recursion depth and current permutation

Combinations: Order Doesn't Matter

Problem: Generate all selections of k elements where order doesn't matter.

def combinations(n, k):
    """
    Generate all combinations of k numbers from 1 to n.
    Use start index to avoid duplicates.
    """
    result = []

    def backtrack(start, current):
        # Base case: selected k elements
        if len(current) == k:
            result.append(current[:])
            return

        # Try each number from start to n
        for i in range(start, n + 1):
            # Choose: add number i
            current.append(i)

            # Explore: continue from i+1 (avoid duplicates)
            backtrack(i + 1, current)

            # Unchoose: remove number i
            current.pop()

    backtrack(1, [])
    return result

Test: combinations(4, 2)Output: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]

Key insight: We use a start index and only explore elements after the current one. This naturally avoids duplicates since 1,2 and 2,1 are the same combination.

Pattern 2: Constraint Satisfaction Problems

These problems have explicit constraints that let you prune the search space early—the hallmark of efficient backtracking.

N-Queens Problem

Problem: Place N queens on an N×N chessboard so no two queens attack each other.

This is THE classic backtracking problem. It demonstrates constraint checking and pruning beautifully.

def solve_n_queens(n):
    """
    Solve N-Queens using backtracking with constraint checking.
    Place queens row by row, checking column and diagonal conflicts.
    """
    result = []
    board = [['.'] * n for _ in range(n)]

    # Track occupied columns and diagonals
    cols = set()
    diag1 = set()  # row - col is constant for each diagonal /
    diag2 = set()  # row + col is constant for each diagonal \

    def backtrack(row):
        # Base case: placed queens in all rows
        if row == n:
            result.append([''.join(row) for row in board])
            return

        # Try placing queen in each column of this row
        for col in range(n):
            # Check constraints: is this position safe?
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue  # Conflict detected, skip this position

            # Choose: place queen
            board[row][col] = 'Q'
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)

            # Explore: move to next row
            backtrack(row + 1)

            # Unchoose: remove queen
            board[row][col] = '.'
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)

    backtrack(0)
    return result

Test: solve_n_queens(4)Output: Two solutions for 4-Queens

Key insight: We place queens row by row (each row must have exactly one queen). For each row, we try each column, but skip positions that would create conflicts. The sets track which columns and diagonals are occupied, allowing O(1) conflict checking.

The power of pruning: Without constraint checking, we'd explore all n^n possible placements. With constraints, we prune entire branches early, making it feasible even for larger n.

Sudoku Solver

Problem: Fill a 9×9 grid so each row, column, and 3×3 box contains digits 1-9.

def solve_sudoku(board):
    """
    Solve Sudoku using backtracking.
    Fill empty cells while checking row/column/box constraints.
    """
    def is_valid(board, row, col, num):
        """Check if placing num at (row, col) is valid."""
        # Check row
        if str(num) in board[row]:
            return False

        # Check column
        if str(num) in [board[i][col] for i in range(9)]:
            return False

        # Check 3x3 box
        box_row, box_col = 3 * (row // 3), 3 * (col // 3)
        for i in range(box_row, box_row + 3):
            for j in range(box_col, box_col + 3):
                if board[i][j] == str(num):
                    return False

        return True

    def backtrack():
        # Find next empty cell
        for row in range(9):
            for col in range(9):
                if board[row][col] == '.':
                    # Try each digit
                    for num in range(1, 10):
                        if is_valid(board, row, col, num):
                            # Choose: place digit
                            board[row][col] = str(num)

                            # Explore: continue solving
                            if backtrack():
                                return True

                            # Unchoose: backtrack
                            board[row][col] = '.'

                    return False  # No valid digit for this cell

        return True  # All cells filled successfully

    backtrack()
    return board

Key insight: We fill cells one by one, trying each digit 1-9. If a digit doesn't violate constraints, we place it and recurse. If we later find no solution with that choice, we backtrack and try the next digit.

Pattern 3: Grid and Path Exploration

When you need to explore all paths in a grid with certain properties.

Word Search in Grid

Problem: Given a 2D board and a word, find if the word exists in the grid (can move up/down/left/right, can't reuse cells).

def word_search(board, word):
    """
    Search for word in grid using backtracking.
    Explore all 4 directions, mark visited cells.
    """
    rows, cols = len(board), len(board[0])

    def backtrack(row, col, index):
        # Base case: found entire word
        if index == len(word):
            return True

        # Check boundaries and if current cell matches
        if (row < 0 or row >= rows or col < 0 or col >= cols or
            board[row][col] != word[index]):
            return False

        # Choose: mark cell as visited
        temp = board[row][col]
        board[row][col] = '#'  # Mark as visited

        # Explore: try all 4 directions
        found = (backtrack(row + 1, col, index + 1) or
                 backtrack(row - 1, col, index + 1) or
                 backtrack(row, col + 1, index + 1) or
                 backtrack(row, col - 1, index + 1))

        # Unchoose: restore cell
        board[row][col] = temp

        return found

    # Try starting from each cell
    for i in range(rows):
        for j in range(cols):
            if backtrack(i, j, 0):
                return True

    return False

Test:

board = [
    ['A','B','C','E'],
    ['S','F','C','S'],
    ['A','D','E','E']
]
word_search(board, "ABCCED")  # True
word_search(board, "SEE")     # True
word_search(board, "ABCB")    # False

Key insight: We mark cells as visited by temporarily changing their value, then restore after exploring. This prevents us from using the same cell twice in a path.

Pattern 4: Partition and Split Problems

Problems where you need to try all ways to partition or split something.

Palindrome Partitioning

Problem: Partition a string so every substring is a palindrome. Return all possible partitionings.

def palindrome_partition(s):
    """
    Partition string into palindromes using backtracking.
    Try all split points, check if substring is palindrome.
    """
    result = []

    def is_palindrome(sub):
        """Check if substring is palindrome."""
        return sub == sub[::-1]

    def backtrack(start, current):
        # Base case: partitioned entire string
        if start == len(s):
            result.append(current[:])
            return

        # Try all possible end points from current start
        for end in range(start + 1, len(s) + 1):
            substring = s[start:end]

            # Only continue if current substring is palindrome
            if is_palindrome(substring):
                # Choose: add this palindrome substring
                current.append(substring)

                # Explore: partition rest of string
                backtrack(end, current)

                # Unchoose: remove substring to try next split
                current.pop()

    backtrack(0, [])
    return result

Test: palindrome_partition("aab")Output: [["a","a","b"], ["aa","b"]]

Key insight: At each position, we try all possible substrings starting from that position. We only recurse if the substring is a palindrome—this is constraint pruning!

Common Backtracking Optimizations

Optimization 1: Early Pruning

Check constraints BEFORE recursing, not after. This avoids exploring entire branches that will fail.

# Bad: explore then check
backtrack(new_state)
if not is_valid(new_state):
    return

# Good: check then explore
if not is_valid(new_state):
    return
backtrack(new_state)

Optimization 2: Sort Input

For some problems, sorting allows you to break early when values get too large.

# For combination sum: if nums is sorted and current num > target
if nums[i] > target:
    break  # All remaining numbers are also too large

Optimization 3: Skip Duplicates

When input has duplicates and you want unique solutions:

# Skip duplicate elements at same recursion level
for i in range(start, len(nums)):
    if i > start and nums[i] == nums[i-1]:
        continue  # Skip duplicate
    backtrack(i + 1, current)

The Backtracking Decision Tree

When you encounter a backtracking problem, ask yourself:

1. What is a complete solution?

  • Define your base case clearly
  • When do you add to results?

2. What choices do I have at each step?

  • What can I add to current state?
  • What are the options to explore?

3. What constraints must be satisfied?

  • When should I prune (not explore further)?
  • What makes a choice invalid?

4. How do I represent state?

  • List, set, grid markers?
  • What do I need to track?

5. How do I make/unmake choices?

  • Append/pop from list?
  • Mark/unmark cells?
  • Add/remove from set?

Time Complexity: Why Backtracking Can Be Slow

Backtracking explores exponential search spaces:

  • Subsets: O(2^n) - each element is in or out
  • Permutations: O(n!) - n choices for first, n-1 for second...
  • N-Queens: O(n!) with pruning
  • Sudoku: O(9^m) where m is number of empty cells

This is why backtracking is often only feasible for small inputs (n ≤ 20 typically). But with good pruning, it becomes practical for many problems.

My Practice Strategy

Here's how I went from confused to confident with backtracking:

Week 1: Master the Template

  • Implement subsets, permutations, combinations 5 times each
  • Focus on the choose-explore-unchoose rhythm
  • Draw recursion trees on paper

Week 2: Add Constraints

  • Solve N-Queens for n=4, 5, 6, 8
  • Implement Sudoku solver
  • Focus on pruning techniques

Week 3: Grid Problems

  • Word search and variations
  • Path finding problems
  • Practice modifying state (marking visited)

Week 4: Mixed Practice

  • 20-30 backtracking problems of varying difficulty
  • Practice recognizing the pattern from problem description
  • Time yourself: recognition in 1 minute, solution in 20 minutes

Real Interview Experience

I was asked: "Generate all valid combinations of n pairs of parentheses."

My approach:

  1. Recognized backtracking: "generate all" + "valid" (constraint)
  2. State: current string, count of open/close parentheses used
  3. Choices: add '(' or add ')'
  4. Constraints: can't add ')' if it would exceed '(', can't exceed n opens

I coded it in 12 minutes:

def generate_parentheses(n):
    result = []

    def backtrack(current, open_count, close_count):
        # Base case: used all n pairs
        if len(current) == 2 * n:
            result.append(current)
            return

        # Can add '(' if haven't used all n
        if open_count < n:
            backtrack(current + '(', open_count + 1, close_count)

        # Can add ')' if it wouldn't exceed '('
        if close_count < open_count:
            backtrack(current + ')', open_count, close_count + 1)

    backtrack('', 0, 0)
    return result

The interviewer was impressed by how I verbalized the pattern recognition and constraint checking.

Conclusion: Your Backtracking Mastery Roadmap

Backtracking isn't magic—it's systematic exploration with the discipline to undo choices and try alternatives. Here's what matters:

  1. The mantra: Choose, Explore, Unchoose
  2. Recognize the pattern: "all possible," "generate," "find all"
  3. Prune early: Check constraints before recursing
  4. Practice the template: Until it's muscle memory

Start with simple problems like subsets and permutations. Once the pattern clicks, move to constraint problems like N-Queens. Finally, tackle grid and partition problems.

Within 3-4 weeks of focused practice, you'll recognize backtracking problems instantly and code solutions confidently.

Remember: every expert was once a beginner drawing recursion trees on paper. The difference is they kept practicing until the choose-explore-unchoose rhythm became second nature.

Happy coding, and may your backtracking always terminate!


If you found this guide helpful, share it with others learning algorithms. Backtracking is one of those topics that seems hard until the pattern clicks—then it becomes a powerful problem-solving tool. If this guide helped you master backtracking or understand the generate-all-solutions pattern, I'd love to hear about it! Connect with me on Twitter or LinkedIn.

Support My Work

If this guide helped you finally understand backtracking, gave you confidence to solve "generate all" problems, or helped you ace a coding interview, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for students like you.

☕ Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by Filip Mroz on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Š ojaswiat.com 2025-2027