0Pricing
DSA Interview Prep · Lesson

Backtracking Template: Choose, Explore, Unchoose

Implement the three-step backtracking skeleton, trace it on a small example, and identify where pruning conditions slot in.

Backtracking Template: Choose, Explore, Unchoose is a free DSA Interview Prep lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DSA Interview Prep learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What is Backtracking?

Backtracking is a systematic method for finding all (or some) solutions by exploring every candidate incrementally and abandoning (pruning) a branch as soon as it is determined that the branch cannot yield a valid solution. It is the algorithm behind solving Sudoku, generating permutations, and finding all valid combinations. Think of it as a depth-first search on a decision tree.

# Mental model: backtracking explores a decision tree
# At each node you make a choice, go deeper, then undo it
#
# Tree for generating subsets of [1,2,3]:
#        []
#      /    \
#    [1]   []
#   / \    / \
# [1,2][1][2] []
# ...

# Every leaf is a potential solution
# Pruning cuts branches early based on constraints
print('Backtracking = DFS on decision tree with pruning')

The Three-Step Template

Every backtracking function follows three steps: Choose — pick the next candidate from available options. Explore — recurse with that choice, moving one level deeper in the decision tree. Unchoose — undo the choice after returning from recursion to restore state for the next candidate. This pattern is also called add/recurse/remove or mark/recurse/unmark in different contexts.

def backtrack(current_state, choices, results):
    # Base case: is current_state a complete solution?
    if is_complete(current_state):
        results.append(list(current_state))  # record solution
        return
    
    for choice in choices:
        if is_valid(choice, current_state):    # pruning condition
            # 1. CHOOSE
            current_state.append(choice)
            # 2. EXPLORE
            backtrack(current_state, choices, results)
            # 3. UNCHOOSE (backtrack)
            current_state.pop()

# Placeholder functions — filled per problem
def is_complete(state): return True
def is_valid(choice, state): return True

Simplest Example: All Subsets

Generate all subsets of [1, 2, 3]. At each index, we choose to include or exclude the element. The start index advances after each call so we don't revisit previous elements. No constraint check is needed — every partial state is valid. This produces 2ⁿ subsets. The unchoose step is path.pop() after the recursive call.

def subsets(nums):
    result = []
    def backtrack(start, path):
        result.append(list(path))  # every state is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])    # CHOOSE
            backtrack(i + 1, path)  # EXPLORE
            path.pop()              # UNCHOOSE
    backtrack(0, [])
    return result

print(subsets([1, 2, 3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Identifying the Pruning Condition

The power of backtracking over brute force lies in pruning: recognising early that a partial path cannot lead to a valid solution. For combination sum (target sum with a budget), once the running sum exceeds the target, any deeper branch will only grow larger — prune by returning immediately. For N-queens, if a queen attacks existing queens, skip that column. Pruning turns exponential trees into manageable searches.

def combination_sum(candidates, target):
    result = []
    candidates.sort()  # sort enables early termination
    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(list(path))
            return
        for i in range(start, len(candidates)):
            c = candidates[i]
            if c > remaining: break   # PRUNE: sorted, so rest are bigger too
            path.append(c)            # CHOOSE
            backtrack(i, path, remaining - c)   # EXPLORE (reuse allowed)
            path.pop()                # UNCHOOSE
    backtrack(0, [], target)
    return result

print(combination_sum([2, 3, 6, 7], 7))  # [[2,2,3],[7]]

State Restoration is Critical

A common bug in backtracking is failing to fully restore state before the next iteration. If you use a mutable data structure (list, set, grid), every modification made during Choose must be reversed in Unchoose. For example, when modifying a grid (like Sudoku or Word Search), set the cell to empty after the recursive call. Forgetting this leaves the state corrupted for sibling branches.

# Bug: forgetting to unmark in word search
# Correct pattern for grid backtracking:
def word_search(board, word):
    m, n = len(board), len(board[0])
    def dfs(r, c, k):
        if k == len(word): return True
        if not (0<=r<m and 0<=c<n): return False
        if board[r][c] != word[k]: return False
        temp, board[r][c] = board[r][c], '#'  # CHOOSE (mark visited)
        found = any(dfs(r+dr, c+dc, k+1)
                    for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)])
        board[r][c] = temp  # UNCHOOSE (restore cell)
        return found
    return any(dfs(r, c, 0) for r in range(m) for c in range(n))

board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']]
print(word_search([row[:] for row in board], 'ABCCED'))  # True

Tracing the Decision Tree

For combination sum with [2, 3, 6, 7] and target 7, trace the tree: at the root, try 2. From 2, try 2 again (remaining=3). From 2+2, try 2 again (remaining=1). 2>1 so prune. Try 3: 3>1 prune. Backtrack. From 2+2: try 3 (remaining=3). 3 matches remaining: record [2,2,3]. Backtrack and continue. This trace shows how pruning eliminates branches before they produce invalid results.

def combination_sum_trace(candidates, target):
    result = []
    candidates.sort()
    def backtrack(start, path, remaining, depth):
        indent = '  ' * depth
        print(f'{indent}explore({path}, remaining={remaining})')
        if remaining == 0:
            result.append(list(path))
            print(f'{indent}FOUND: {path}')
            return
        for i in range(start, len(candidates)):
            c = candidates[i]
            if c > remaining:
                print(f'{indent}PRUNE at {c}')
                break
            path.append(c)
            backtrack(i, path, remaining - c, depth + 1)
            path.pop()
    backtrack(0, [], target, 0)
    return result

combination_sum_trace([2, 3, 6, 7], 7)

Backtracking vs Brute Force

Brute force tries all possible complete solutions and then validates each. Backtracking prunes during construction, never completing invalid paths. For N-queens with N=8, brute force checks 8^8 = 16 million placements. Backtracking reduces this to around 2,057 recursive calls. The difference grows dramatically with N: N=12 brute force tries 8.9 billion placements while backtracking explores only a fraction of the tree.

# Compare call counts: brute force vs backtracking for permutations
import sys
calls_brute = [0]
calls_back = [0]

def brute_force_perms(nums):
    from itertools import permutations
    return list(permutations(nums))

def backtrack_perms(nums):
    result = []
    used = [False] * len(nums)
    def bt(path):
        calls_back[0] += 1
        if len(path) == len(nums):
            result.append(list(path))
            return
        for i, n in enumerate(nums):
            if not used[i]:
                used[i] = True
                path.append(n)
                bt(path)
                path.pop()
                used[i] = False
    bt([])
    return result

backtrack_perms([1,2,3,4])
print(f'Backtrack calls for 4 items: {calls_back[0]}')

Collecting vs Returning Early

Backtracking problems fall into two categories: enumerate all solutions (collect every complete path) or find any one solution (return True as soon as a path succeeds). For enumeration, always append to a results list. For find-any, return True immediately from the recursive call and propagate it up. Returning any(backtrack(...)) or if backtrack(...): return True implements the short-circuit behaviour.

# Enumerate all: collect in results list
def all_solutions(candidates):
    results = []
    def bt(path, remaining):
        if remaining == 0:
            results.append(list(path))
            return
        for c in candidates:
            if c <= remaining:
                path.append(c); bt(path, remaining - c); path.pop()
    bt([], 5)
    return results

# Find any one: return True on first success
def any_solution(candidates, target):
    def bt(path, remaining):
        if remaining == 0: return True
        for c in candidates:
            if c <= remaining:
                path.append(c)
                if bt(path, remaining - c): return True  # short-circuit
                path.pop()
        return False
    path = []
    return bt(path, target), path

Memoisation with Backtracking

Pure backtracking explores every path without caching, which is fine when all solutions are needed. However, some backtracking problems have overlapping sub-problems. For example, Word Break II can be solved with backtracking + memoisation: cache the list of sentences possible from each starting index. This converts the worst-case exponential backtracking into a polynomial-time algorithm. Recognise when sub-problems repeat to apply this hybrid.

from functools import lru_cache

def word_break_all(s, wordDict):
    words = set(wordDict)
    
    @lru_cache(maxsize=None)
    def bt(start):
        if start == len(s): return ['']  # empty suffix
        result = []
        for end in range(start + 1, len(s) + 1):
            word = s[start:end]
            if word in words:
                for rest in bt(end):
                    result.append(word if not rest else word + ' ' + rest)
        return result
    
    return bt(0)

print(word_break_all('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']

Time Complexity of Backtracking

Backtracking time complexity depends on the number of leaves in the decision tree times the work per node. For subsets: O(n × 2ⁿ). For permutations: O(n × n!). For combination sum: O(target/min_candidate ^ n) in the worst case. Pruning reduces the constant but not the asymptotic bound. When asked for complexity in an interview, give the worst-case tree size and mention that pruning typically makes it much faster in practice.

# Complexity quick reference:
# Subsets of n elements:     O(n * 2^n)  - 2^n subsets, each copied in O(n)
# Permutations of n:          O(n * n!)   - n! perms, each copied in O(n)
# Combination sum (target T): O(T^n / n!) worst case without pruning
# N-Queens:                   O(n!)       - prune reduces practical count

# For n=10 permutations: 10! = 3,628,800 paths
import math
n = 10
print(f'n={n}: n!={math.factorial(n):,} paths')
print(f'n={n}: 2^n={2**n:,} subsets')

Identifying Backtracking Problems

Signals that a problem needs backtracking: (1) Find all or generate all combinations, permutations, or subsets. (2) The problem involves placing items or people under constraints (N-queens, Sudoku). (3) The solution space is exponential but constraints eliminate most branches early. (4) You need to explore paths in a graph or grid that might revisit states. When you see these signals, reach for the choose-explore-unchoose template.

# Common backtracking problem types:
# 1. Subsets / Power set
# 2. Permutations (with/without duplicates)
# 3. Combinations (k from n, combination sum)
# 4. Grid path finding (word search, unique paths with visited tracking)
# 5. Constraint satisfaction (N-queens, Sudoku solver)
# 6. String partitioning (palindrome partition, word break all)

# Template reminder:
def backtrack(start, path):
    # base case: add to results or return True
    for choice in get_choices(start):
        if is_valid(choice, path):   # prune
            path.append(choice)      # choose
            backtrack(start+1, path) # explore
            path.pop()               # unchoose

def get_choices(start): return []
def is_valid(c, p): return True

Quick Check

Test your understanding of Data Structures & Algorithms — Coding Interview Prep concepts from this lesson.

Lesson Recap

In this lesson you learned: the backtracking template has three steps — choose, explore, unchoose — which correspond to adding a choice, recursing, and removing it, pruning conditions eliminate branches early and are what make backtracking practical vs brute force, and state must be fully restored after each recursive call to avoid corrupting sibling branches. Next up we apply the template to generate all Subsets and the Power Set.

Frequently asked questions

Is the “Backtracking Template: Choose, Explore, Unchoose” lesson free?

Yes — the full text of “Backtracking Template: Choose, Explore, Unchoose” is free to read here on the web, and the DSA Interview Prep course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DSA Interview Prep course, upgrade to CoddyKit PRO.

What will I learn in “Backtracking Template: Choose, Explore, Unchoose”?

Implement the three-step backtracking skeleton, trace it on a small example, and identify where pruning conditions slot in. You practise DSA Interview Prep with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start DSA Interview Prep?

No prior experience is required. DSA Interview Prep on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Backtracking Template: Choose, Explore, Unchoose” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this DSA Interview Prep lesson?

Yes. Every DSA Interview Prep lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Backtracking Template: Choose, Explore, Unchoose
  2. Subsets and Power Set
  3. Permutations and Combinations
  4. N-Queens and Constraint Propagation
← Back to DSA Interview Prep