AI EngineeringZero to ProductionHome·About·Contact
Appendix · Data Structures & Algorithms · Part 13

Backtracking & branch-and-bound

Backtracking is systematic brute force with a brain: build a solution incrementally, and the instant a partial choice cannot possibly lead to a valid answer, undo it and try the next option. It is how you generate subsets, permutations and combinations, solve N-queens and Sudoku, and search a grid for words. Branch-and-bound adds cost bounds to prune even harder on optimization problems. This lesson gives you the reusable template and the pruning mindset.

⏱️ ~2.5 hours🎯 Advanced → Expert🌳 search & pruningrunnable

Learning objectives

  • Write the universal backtracking template: choose → explore → un-choose.
  • Generate subsets, permutations and combinations correctly (and dedupe).
  • Solve N-queens and Sudoku with constraint-based pruning.
  • Search a grid for a word (DFS with a visited mark you restore).
  • Explain how pruning changes practical (not worst-case) complexity.
  • Distinguish plain backtracking from branch-and-bound on optimization problems.

1 · The backtracking template advanced

Every backtracking algorithm is the same skeleton over a decision tree. At each node you choose an option, recurse to explore the consequences, then un-choose (undo the choice) so you can try the next option. A base case records a complete solution; a pruning check abandons hopeless branches early.

Choose make a move Explore (recurse) recurse deeper Un-choose (undo) backtrack & try next
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the template, instantiated as subsets
pythondef subsets(nums):
    result, path = [], []
    def backtrack(start):
        result.append(path[:])              # record: every node is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])            # CHOOSE
            backtrack(i + 1)                # EXPLORE the rest
            path.pop()                      # UN-CHOOSE (backtrack)
    backtrack(0)
    return result

print(subsets([1, 2, 3]))
print("count:", len(subsets([1, 2, 3])), "= 2^3")
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
count: 8 = 2^3
The one bug everyone hitsAppend a copy (path[:]) when recording a solution, never the live list. The path keeps mutating as you backtrack, so storing the reference gives you a list of identical (empty) lists at the end. And always pop() exactly what you append()ed — choose and un-choose must be mirror images.

2 · Permutations & combinations intermediate

Same template, different branching. Combinations pick k of n ignoring order — pass a start index so you never look back. Permutations care about order — track which elements are already used. Counts: C(n,k) combinations, n! permutations, so these are exponential/factorial by nature.

Try it — combinations and permutations
pythondef combinations(n, k):
    result, path = [], []
    def backtrack(start):
        if len(path) == k:
            result.append(path[:]); return
        for i in range(start, n + 1):
            path.append(i)
            backtrack(i + 1)                # i+1: never reuse -> no order duplicates
            path.pop()
    backtrack(1)
    return result

def permutations(nums):
    result, path, used = [], [], [False] * len(nums)
    def backtrack():
        if len(path) == len(nums):
            result.append(path[:]); return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True; path.append(nums[i])
            backtrack()
            path.pop(); used[i] = False
    backtrack()
    return result

print("C(4,2):", combinations(4, 2))
print("perms of [1,2,3]:", permutations([1, 2, 3]))
print("counts:", len(combinations(4, 2)), len(permutations([1, 2, 3])))
C(4,2): [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
perms of [1,2,3]: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
counts: 6 6
Deduping with duplicate inputsWhen the input has repeats (e.g. [1,1,2]), sort first and skip a value at the same tree level if it equals the previous one (if i>start and nums[i]==nums[i-1]: continue). That skip is what keeps [1,1,2] from producing the same subset/permutation twice.

3 · N-queens — pruning by constraint advanced

Place N queens on an N×N board so none attack another. Backtracking places one queen per row; before placing in a column it checks the column and both diagonals are free. The pruning is brutal: a placement that conflicts kills that entire subtree immediately, so we explore vastly fewer than the N^N naive placements.

Try it — count N-queens solutions with O(1) conflict checks
pythondef count_n_queens(n):
    cols = set(); diag = set(); anti = set()     # occupied columns / diagonals
    count = 0
    def backtrack(row):
        nonlocal count
        if row == n:
            count += 1; return
        for col in range(n):
            if col in cols or (row - col) in diag or (row + col) in anti:
                continue                    # PRUNE: conflict, skip this branch
            cols.add(col); diag.add(row - col); anti.add(row + col)   # CHOOSE
            backtrack(row + 1)                                        # EXPLORE
            cols.discard(col); diag.discard(row - col); anti.discard(row + col)  # UNDO
    backtrack(0)
    return count

for n in range(1, 9):
    print(f"N={n}: {count_n_queens(n)} solutions")
N=1: 1 solutions
N=2: 0 solutions
N=3: 0 solutions
N=4: 2 solutions
N=5: 10 solutions
N=6: 4 solutions
N=7: 40 solutions
N=8: 92 solutions
Diagonals as arithmetic invariantsCells on the same ↘ diagonal share row − col; cells on the same ↙ anti-diagonal share row + col. Storing those two sums in sets turns each attack check into an O(1) set lookup instead of scanning the board — the difference between fast and unusable at N=12+.

4 · Sudoku — constraint propagation + backtracking expert

Sudoku is backtracking on the empty cells: try 1–9 in the first empty cell, keep only digits that violate no row/column/box constraint, recurse, and undo on failure. Choosing the most-constrained cell first (fewest candidates) prunes far more — a taste of branch-and-bound heuristics.

Try it — solve a Sudoku by backtracking
pythondef solve_sudoku(board):
    def valid(r, c, v):
        for i in range(9):
            if board[r][i] == v or board[i][c] == v:
                return False
        br, bc = 3 * (r // 3), 3 * (c // 3)
        for i in range(br, br + 3):
            for j in range(bc, bc + 3):
                if board[i][j] == v:
                    return False
        return True

    def backtrack():
        for r in range(9):
            for c in range(9):
                if board[r][c] == 0:
                    for v in range(1, 10):
                        if valid(r, c, v):
                            board[r][c] = v         # CHOOSE
                            if backtrack():         # EXPLORE
                                return True
                            board[r][c] = 0         # UNDO
                    return False                    # no digit works -> dead end
        return True                                 # no empty cell -> solved
    backtrack()
    return board

puzzle = [
    [5,3,0, 0,7,0, 0,0,0], [6,0,0, 1,9,5, 0,0,0], [0,9,8, 0,0,0, 0,6,0],
    [8,0,0, 0,6,0, 0,0,3], [4,0,0, 8,0,3, 0,0,1], [7,0,0, 0,2,0, 0,0,6],
    [0,6,0, 0,0,0, 2,8,0], [0,0,0, 4,1,9, 0,0,5], [0,0,0, 0,8,0, 0,7,9]]
solve_sudoku(puzzle)
print(puzzle[0])
print("row sums all 45:", all(sum(row) == 45 for row in puzzle))
[5, 3, 4, 6, 7, 8, 9, 1, 2]
row sums all 45: True

5 · Word search — DFS with a restorable mark advanced

Find whether a word exists in a grid of letters via adjacent (up/down/left/right) cells, no cell reused. This is DFS from every start cell, marking a cell 'in use' during the path and restoring it on backtrack. The restore is the backtracking step — forget it and you falsely block reachable cells.

Try it — word search in a grid
pythondef exist(board, word):
    rows, cols = len(board), len(board[0])
    def dfs(r, c, k):
        if k == len(word):
            return True
        if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[k]:
            return False
        board[r][c] = "#"                   # MARK in-use (mutate in place)
        found = (dfs(r + 1, c, k + 1) or dfs(r - 1, c, k + 1) or
                 dfs(r, c + 1, k + 1) or dfs(r, c - 1, k + 1))
        board[r][c] = word[k]               # RESTORE (backtrack)
        return found
    return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))

grid = [["A","B","C","E"], ["S","F","C","S"], ["A","D","E","E"]]
print(exist([row[:] for row in grid], "ABCCED"))   # True
print(exist([row[:] for row in grid], "SEE"))      # True
print(exist([row[:] for row in grid], "ABCB"))     # False
True
True
False

6 · Branch-and-bound & complexity expert

Plain backtracking prunes infeasible branches (a constraint is violated). Branch-and-bound also prunes sub-optimal branches on optimization problems: keep the best solution found so far, and for each partial solution compute an optimistic bound on the best it could become — if that bound cannot beat the incumbent, prune the whole subtree. It never changes the worst-case (still exponential) but slashes practical runtime.

Try it — 0/1 knapsack by branch-and-bound
pythondef knapsack_bnb(items, W):
    """items: list of (value, weight). Maximize value within capacity W."""
    items = sorted(items, key=lambda it: it[0] / it[1], reverse=True)  # densest first
    n = len(items)
    best = 0
    def bound(i, cap, val):
        """Optimistic value: fill remaining capacity fractionally (an upper bound)."""
        b, w = val, cap
        for value, weight in items[i:]:
            if weight <= w:
                b += value; w -= weight
            else:
                b += value * (w / weight)   # fractional fill -> can't do better
                break
        return b
    def rec(i, cap, val):
        nonlocal best
        best = max(best, val)
        if i == n:
            return
        if bound(i, cap, val) <= best:      # PRUNE: even the optimistic bound loses
            return
        value, weight = items[i]
        if weight <= cap:
            rec(i + 1, cap - weight, val + value)   # take
        rec(i + 1, cap, val)                        # skip
    rec(0, W, 0)
    return best

items = [(60, 10), (100, 20), (120, 30)]
print("bnb knapsack, W=50:", knapsack_bnb(items, 50))    # 220
print("bnb knapsack, W=15:", knapsack_bnb(items, 15))
bnb knapsack, W=50: 220
bnb knapsack, W=15: 60
TechniquePrunesApplies toWorst case
backtrackinginfeasible partialsconstraint satisfactionexponential
branch-and-boundinfeasible + provably worseoptimizationexponential
DPnothing — memoizes overlapoverlapping subproblemspolynomial*
Backtracking complexity is honest exponentialSubsets are Θ(2ⁿ), permutations Θ(n!), N-queens roughly O(n!). Pruning improves the average/practical case dramatically but does not change the worst-case class. If a problem has overlapping subproblems, DP (polynomial) beats backtracking; backtracking is for search spaces with no such overlap. (*DP is polynomial only when the state space is; 0/1 knapsack is pseudo-polynomial.)

Checkpoint expert

✓ Checkpoint — you can move on when you can…

  • Write the choose → explore → un-choose template from memory.
  • Generate subsets, combinations and permutations and dedupe with repeated inputs.
  • Explain the O(1) conflict check that makes N-queens tractable.
  • Restore the grid mark after a word-search DFS branch (and say why).
  • State how branch-and-bound differs from plain backtracking.
✓ Knowledge check

In the subsets/permutation code, why must you append path[:] instead of path?

Show answer
path is a single list mutated throughout the search — every append/pop changes it in place. Storing the reference path means every entry in result points at the same list, which is empty again once recursion unwinds. path[:] takes a snapshot copy of the current state, so each recorded solution is independent.
✓ Knowledge check

For 0/1 knapsack, why is 'fill the remaining capacity fractionally' a valid upper bound for branch-and-bound pruning?

Show answer
The fractional (divisible-item) knapsack always achieves at least as much value as the 0/1 version, because 0/1 is a restriction of it (you may not take fractions). So the fractional value of any partial state is an optimistic estimate — the true 0/1 best can never exceed it. If even that optimistic bound is ≤ the incumbent best, the subtree cannot improve the answer and is safely pruned.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Generate balanced parenthesesBeginner

Context: A first 'constrained generation' backtracking problem: emit all valid combinations of n pairs of parentheses. Teaches pruning by a running invariant.

Your task: Given n, return all strings of n well-formed pairs of parentheses.

Requirements:

  • Track counts of open and close parens used.
  • Prune: never add ')' unless it would still be balanced (close < open).
  • Only record when the string reaches length 2n.

💡 Hint: Add '(' while open < n; add ')' while close < open. Those two conditions are the whole pruning.

Show solution
Solution
pythondef generate_parentheses(n):
    result = []
    def backtrack(s, open_ct, close_ct):
        if len(s) == 2 * n:
            result.append(s); return
        if open_ct < n:
            backtrack(s + "(", open_ct + 1, close_ct)
        if close_ct < open_ct:              # prune: keep it balanceable
            backtrack(s + ")", open_ct, close_ct + 1)
    backtrack("", 0, 0)
    return result

print(generate_parentheses(2))
print("count for n=3:", len(generate_parentheses(3)))   # 5 (Catalan)
['(())', '()()']
count for n=3: 5
Exercise 2 · Combination sum (reuse allowed)Intermediate

Context: A combinations variant where numbers may be reused any number of times — the candidate-reuse pattern common in coin/target problems.

Your task: Given distinct positive candidates and a target, return all unique combinations summing to target (each candidate may be used unlimited times).

Requirements:

  • Pass the current index (not index+1) to allow reuse of the same candidate.
  • Prune branches whose running sum exceeds the target.
  • Return combinations in a stable order.

💡 Hint: Recurse with the same start index to reuse a number; advance start to move past it.

Show solution
Solution
pythondef combination_sum(candidates, target):
    candidates.sort()
    result, path = [], []
    def backtrack(start, remaining):
        if remaining == 0:
            result.append(path[:]); return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:   # prune (sorted -> rest are bigger too)
                break
            path.append(candidates[i])
            backtrack(i, remaining - candidates[i])   # i, not i+1 -> reuse allowed
            path.pop()
    backtrack(0, target)
    return result

print(combination_sum([2, 3, 6, 7], 7))
print(combination_sum([2, 3, 5], 8))
[[2, 2, 3], [7]]
[[2, 2, 2, 2], [2, 3, 3], [3, 5]]
Exercise 3 · Palindrome partitioningAdvanced

Context: Partition a string so every piece is a palindrome — a backtracking-over-cut-points problem that appears in text segmentation.

Your task: Return all ways to split a string so that each substring is a palindrome.

Requirements:

  • At each position, try every prefix; recurse only if that prefix is a palindrome.
  • Record a partition when you reach the end of the string.
  • Verify on a short string.

💡 Hint: Loop an end index; if s[start:end] is a palindrome, choose it and recurse from end.

Show solution
Solution
pythondef partition_palindromes(s):
    result, path = [], []
    def is_pal(x):
        return x == x[::-1]
    def backtrack(start):
        if start == len(s):
            result.append(path[:]); return
        for end in range(start + 1, len(s) + 1):
            piece = s[start:end]
            if is_pal(piece):               # prune: only palindromic prefixes
                path.append(piece)
                backtrack(end)
                path.pop()
    backtrack(0)
    return result

print(partition_palindromes("aab"))
print("count for 'aaa':", len(partition_palindromes("aaa")))
[['a', 'a', 'b'], ['aa', 'b']]
count for 'aaa': 4
Exercise 4 · Subsets II (with duplicates)Expert

Context: The dedup pattern in its purest form: generate all unique subsets of a multiset. The 'skip equal siblings' trick is the crux.

Your task: Given a collection that may contain duplicates, return all possible unique subsets.

Requirements:

  • Sort the input so duplicates are adjacent.
  • At each tree level, skip a candidate equal to the previous one (i > start and nums[i]==nums[i-1]).
  • Verify no duplicate subset appears.

💡 Hint: The condition i > start (not i > 0) allows the duplicate to be used deeper in the tree, just not as a repeated sibling.

Show solution
Solution
pythondef subsets_with_dup(nums):
    nums.sort()
    result, path = [], []
    def backtrack(start):
        result.append(path[:])
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue                    # skip duplicate sibling
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()
    backtrack(0)
    return result

subs = subsets_with_dup([1, 2, 2])
print(subs)
print("unique count:", len(subs))
[[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
unique count: 6
Exercise 5 · Graph coloring (m-coloring feasibility)Professional

Context: The m-coloring problem (can a graph be colored with m colors so no adjacent nodes share a color?) is NP-complete and models register allocation, scheduling and map coloring. Backtracking with pruning is the standard exact solver.

Your task: Given an adjacency matrix and m colors, decide whether a proper m-coloring exists (and return one).

Requirements:

  • Assign colors vertex by vertex.
  • Prune: a color is legal for v only if no already-colored neighbor uses it.
  • Return a valid coloring list or None.

💡 Hint: For each vertex try colors 1..m; recurse only into colors that don't clash with colored neighbors.

Show solution
Solution
pythondef graph_coloring(adj, m):
    n = len(adj)
    colors = [0] * n
    def legal(v, c):
        return all(not (adj[v][u] and colors[u] == c) for u in range(n))
    def backtrack(v):
        if v == n:
            return True
        for c in range(1, m + 1):
            if legal(v, c):
                colors[v] = c               # CHOOSE
                if backtrack(v + 1):
                    return True
                colors[v] = 0               # UNDO
        return False
    return colors[:] if backtrack(0) else None

# a square cycle 0-1-2-3-0 needs 2 colors; a triangle needs 3
square = [[0,1,0,1],[1,0,1,0],[0,1,0,1],[1,0,1,0]]
triangle = [[0,1,1],[1,0,1],[1,1,0]]
print("square with 2:", graph_coloring(square, 2))
print("triangle with 2:", graph_coloring(triangle, 2))
print("triangle with 3:", graph_coloring(triangle, 3))
square with 2: [1, 2, 1, 2]
triangle with 2: None
triangle with 3: [1, 2, 3]
Exercise 6 · Traveling salesman by branch-and-boundIndustry scenario

Context: An exact TSP solver via branch-and-bound: prune any partial tour whose cost already exceeds the best complete tour found. This is how small routing/logistics instances are solved optimally when heuristics are not trusted.

Your task: Given a distance matrix, find the minimum-cost tour visiting every city once and returning to the start.

Requirements:

  • Build tours incrementally from a fixed start city.
  • Maintain the best full-tour cost found; prune any partial path whose cost already >= best.
  • Return the optimal cost; verify against brute-force permutations.

💡 Hint: Depth-first extend the path; the incumbent 'best' tightens as you find complete tours, and the cost-so-far bound prunes doomed prefixes.

Show solution
Solution
pythonfrom itertools import permutations

def tsp_bnb(dist):
    n = len(dist)
    best = float("inf")
    visited = [False] * n
    visited[0] = True
    def rec(city, count, cost):
        nonlocal best
        if cost >= best:                    # PRUNE: prefix already too expensive
            return
        if count == n:
            best = min(best, cost + dist[city][0])   # close the loop
            return
        for nxt in range(n):
            if not visited[nxt]:
                visited[nxt] = True
                rec(nxt, count + 1, cost + dist[city][nxt])
                visited[nxt] = False
    rec(0, 1, 0)
    return best

def brute(dist):
    n = len(dist)
    return min(sum(dist[p[k]][p[k+1]] for k in range(len(p)-1))
               for perm in permutations(range(1, n))
               for p in [[0] + list(perm) + [0]])

dist = [[0, 10, 15, 20], [10, 0, 35, 25], [15, 35, 0, 30], [20, 25, 30, 0]]
print("branch-and-bound:", tsp_bnb(dist), " brute:", brute(dist))
branch-and-bound: 80  brute: 80
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in