AI EngineeringZero to ProductionHome·About·What’s new·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

Picture yourself lost in a hedge maze. You reach a fork, pick the left path, walk until you hit a dead end — and what do you do? You don't teleport home and start over. You walk back to the last fork, remember that "left" was a bust, and try "right" instead. If that branches into more forks, you repeat: go deep, hit a wall, retreat one step, try the next door. That instinct — explore a choice fully, and if it fails, quietly undo it and try the next — is backtracking. The whole family of algorithms in this chapter is just that maze-walking discipline written down carefully.

Here's why it deserves its own chapter rather than being "just recursion." A huge class of problems have the same shape: build a solution one decision at a time, where each decision opens up more decisions, and most partial choices lead nowhere. Placing queens on a chessboard, filling a Sudoku grid, choosing which items to pack — they're all "make a sequence of choices, and when one turns out impossible, back up." If you tried to write bespoke logic for each, you'd drown. Backtracking gives you one skeleton that solves them all, and once you internalise it your job on any new problem shrinks to answering three small questions: what's a choice here, when am I done, and when is a branch hopeless?

The mental model is a decision tree. The root is "nothing decided yet." Every edge is one choice you could make; every path from the root downward is a partial solution being built. Backtracking is a depth-first walk of that tree — dive down one path as far as it goes, and when you can't continue, climb back up to the nearest unexplored branch. The tree is almost never stored in memory; it exists only implicitly, in the shape of your recursive calls. That's the beautiful part: a few lines of recursion traces out an enormous tree without ever building it.

Concretely, at each node you do three things in strict order. You choose an option (add it to your running path). You recurse to explore every consequence of that choice — the whole subtree beneath it. Then, crucially, you un-choose: you undo the exact change you just made, restoring the state to what it was before, so the next option starts from a clean slate. A base case notices when the path is a complete solution and records it; a pruning check notices when a partial path can't possibly succeed and abandons that entire subtree without exploring it.

The mistake that bites every beginner is forgetting to un-choose. It's seductive to write "choose, recurse" and move on — the code even runs — but without the matching undo, your leftover choices leak into sibling branches. Suddenly you're placing a queen on a square that's still marked "occupied" from a path you supposedly abandoned, and your answers are silently wrong. The rule that saves you: choose and un-choose must be exact mirror images. Whatever you did on the way down (append an element, mark a cell, add to a set), undo precisely that on the way back up — same element, same cell, same set. If you can't point at the line that reverses each choice, the code is broken even when it looks fine.

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
🗺️ How to read this diagram

Read the three boxes left-to-right, following the arrows — they trace one full step of backtracking at a single node.

  • The left green box is Choose — "make a move": you commit to one option (append an element, mark a cell, add to a set).
  • The middle blue box is Explore (recurse) — the arrow into it means you dive deeper to see what that choice leads to, letting the recursion play out the rest of the decision.
  • The right box is Un-choose (undo) — "backtrack & try next": once the branch is exhausted you reverse the exact move you made, so the next sibling option starts from a clean slate.

In short: choose → recurse → un-choose is the whole backtracking skeleton, and the un-choose must be an exact mirror of the choose.

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

Think about the difference between a lock combination and a lottery draw. A "combination lock" is misnamed — 42-17-9 opens it and 9-17-42 does not, so order matters; that's really a permutation. A lottery draw is the opposite: pick 6 numbers and it makes no difference whether you wrote them down as {3, 14, 22} or {22, 3, 14} — the same six balls win either way, so order is irrelevant. That single question — does the order of my choices matter? — is what separates the two most common backtracking outputs, and getting it wrong is the difference between generating six results and generating twenty-four.

Both come straight from the §1 template; only the branching rule changes. For combinations you want each group counted once, so you must never look backward: once you've considered element i, later picks may only come from i+1 onward. That's the whole trick of passing a start index — it forbids "going back," which is exactly what would produce {1,2} and {2,1} as separate answers. For permutations order does matter, so you're allowed to reach any element you haven't already placed; you just need a way to remember which ones are spoken for, which is what the used[] flags do. Same choose/recurse/un-choose bones — a different question asked at each node.

It helps to feel how fast these grow, because it explains why "just enumerate them all" stops working almost immediately. Combinations count C(n,k) and permutations count n!. That factorial is vicious: 10 items have 3.6 million orderings, 13 items have over 6 billion, and 20 items have more permutations than there are grains of sand on Earth. So these algorithms are inherently exponential-to-factorial — no cleverness makes generating all of them cheap, because the output itself is that large. The lesson isn't "optimise the generator"; it's "if n is more than a dozen or so, you almost certainly want to prune, sample, or reformulate rather than list everything."

The common mistake here is the double-count. Beginners write a permutation-style loop (reach any unused element) when they meant combinations, and get every group in every order — then "fix" it by dumping results into a set and hoping duplicates vanish. That works but wastes exponential effort generating garbage you throw away. The disciplined fix is structural: use the start index to make duplicates impossible to generate in the first place, rather than filtering them afterward. Prevention beats cleanup, especially when the thing you're cleaning up grows factorially.

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.

Combinations · order ignored pick(start): only i ≥ start 1 2 3 4 picked {1,2}; next only from index ≥ 3 {1,2} exists · {2,1} can never form Permutations · order matters pick: any i not yet used 1 2✓ 3 2 is used[]; 1 and 3 still reachable {1,2} AND {2,1} are both produced Same choose/recurse/un-choose bones — one question differs at each node: may I look back? Combinations say no (start); permutations say yes (used[]). One index vs a used[] flag. The start index forbids looking back, so each group is counted once — a combination. Tracking used[] lets order vary, so every ordering appears — a permutation.
🗺️ How to read this diagram

Read the two panels left and right: the same template, but the left forbids going backward and the right does not.

  • The left green banner is the combination rule — pick(start) may only take an element whose index is ≥ start. Below it, having picked {1,2}, the greyed 3 and 4 boxes show the only remaining candidates; index 1 can never be revisited.
  • The right amber banner is the permutation rule — any element whose used[] flag is False is reachable. With 2 marked used (✓), boxes 1 and 3 stay live, so you can still place them in any position.
  • The payoff lines at the bottom of each panel are the consequence: combinations produce {1,2} only, while permutations produce both {1,2} and {2,1} — that is exactly why permutations are n! and combinations C(n,k).

In short: A start index makes order-duplicates impossible (combinations); a used[] flag allows them on purpose (permutations).

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

Imagine trying to seat eight feuding relatives at a dinner where any two who can "see" each other along a row, column, or diagonal will start a fight. The brute-force approach — try every possible arrangement and check each — is hopeless: on an 8×8 board there are billions of ways to drop eight queens, and the vast majority are illegal the instant the second queen lands. N-queens is the classic teaching problem for the single most important idea in this chapter after the template itself: pruning. Instead of building doomed arrangements to completion and rejecting them at the end, you reject them the moment they become impossible — and that turns an intractable search into an instant one.

The key reframing is to bake the constraint into how you build. Two queens can never share a row, so don't even consider arrangements where they do: place exactly one queen per row, working top to bottom. That one decision collapses the search from "choose 8 squares out of 64" to "choose a column for row 0, then row 1, then row 2…" — a much smaller tree. Now at each row you only ask a narrow question: which columns are safe given the queens already placed above? A column is safe if no earlier queen sits in that same column or on either diagonal running through it.

Here is where pruning earns its keep, and it's worth seeing why it's so powerful. Suppose your first three queens are placed and they leave no safe square in row 3. A brute-force enumerator wouldn't notice — it would keep trying to fill rows 4 through 7 anyway, generating and discarding thousands of arrangements built on a foundation that was already broken. Backtracking sees the dead end at row 3 and immediately climbs back to reconsider row 2, never wasting a single step on the impossible subtree. One violated constraint prunes an entire exponential branch. That's the difference between exploring N^N placements and the tiny fraction that could actually work.

The mistake to guard against is checking constraints too late or too slowly. If you place all N queens first and only then ask "is this legal?", you've thrown away every ounce of pruning — you're back to brute force with extra steps. Prune as you place, at every row. And watch the second trap: writing the safety check as a scan over the whole board (an O(N) or worse test) inside a search that runs millions of times. The lab below shows the professional move — encoding columns and both diagonal families as sets so each "is this square attacked?" question is answered by an O(1) lookup. Prune early and prune cheap.

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.

row 0 choose a column row 1 → col a safe so far row 1 → col b ✗ diagonal attack whole subtree pruned row 2, 3 … keep descending explore only live branches one violated constraint (col / row−col / row+col)removes an entire exponential subtree at once Prune as you place, not at the end. The instant a column choice conflicts on column or either diagonal, that branch and everything beneath it is abandoned — turning an N^N search into a tiny fraction of it.
🗺️ How to read this diagram

Read it top-down as a recursion tree: the root picks a column for row 0, and each level places the next row's queen.

  • The purple root is row 0 choosing a column; each arrow down is one candidate column for the next row.
  • The left blue node is a placement that is still safe, so the search keeps descending into row 2, row 3, and so on (green node).
  • The right red node is a placement that conflicts on a diagonal. The red X and the label whole subtree pruned show the payoff: backtracking never explores anything beneath a dead placement — one failed check erases an exponential region.

In short: Checking the column and both diagonals (row−col, row+col) before descending is what collapses the tree — prune early and the impossible branches never cost you a step.

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

Watch how you actually solve a Sudoku by hand and you'll find you're already backtracking. You scan for an empty cell, pencil in a digit that looks legal, and carry on — until somewhere down the line you reach a cell where nothing fits. At that point you erase your way back, change an earlier guess, and continue. Human solvers hate that "erase back" step because it feels like wasted work, but it's precisely the choose → explore → undo cycle from §1, applied to a 9×9 grid. Sudoku is the perfect bridge from "toy puzzle" to "real constraint-satisfaction problem," because the same machinery scales up to scheduling, seating, and resource allocation.

The structure is: the empty cells are your sequence of decisions, and the digits 1–9 are the options at each one. You choose a digit for an empty cell, recurse to solve the rest of the grid given that choice, and if the recursion reports failure you undo (clear the cell back to empty) and try the next digit. The base case is subtle and elegant: if you scan the whole grid and find no empty cell, the puzzle is solved — there's nothing left to decide. Failure is the mirror image: if some empty cell has no legal digit, this branch is a dead end and you return up the tree.

Pruning here comes from Sudoku's three rules — no repeat in a row, a column, or a 3×3 box. Every one of those is a chance to reject a digit before you commit to it, which is what keeps the search from exploding. This is constraint propagation in its simplest form: each placement immediately shrinks the legal options for its neighbours. The truly powerful move, hinted at in the code, is choosing which empty cell to fill next. Instead of always taking the top-left blank, pick the cell with the fewest legal candidates — if some cell can only hold a 7, decide it now. Filling the most-constrained cell first means you either make forced moves (no branching at all) or discover contradictions fast, and it's a direct taste of the branch-and-bound heuristics in §6.

The classic bug is, once again, an incomplete undo — but Sudoku adds its own twist. Because the grid is a shared mutable structure passed through every recursive call, a digit you fail to clear on the way back up doesn't just corrupt one branch; it poisons the validity checks for every sibling attempt, and you'll get "unsolvable" reports for puzzles that are perfectly solvable. Set the cell, recurse, and if that fails set it back to 0 before trying the next digit — no exceptions. The second trap is trusting a solution you never verified; the lab confirms the result by checking that every row sums to 45, the fingerprint of a valid grid.

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.

1 · candidates before rules 1 2 3 4 5 6 7 8 9 2 · remove row / column / box repeats → · 5 · 9 only {5, 9} remain legal 3 · try one, recurse, undo set cell = 5 recurse fail → set 0 then try 9; no digit → dead end Constraint propagation shrinks the choices first. Before branching, the row/column/box rules cut a cell's 9 options down to a handful; picking the cell with the fewest candidates keeps the tree thin. Each try is choose → recurse → undo.
🗺️ How to read this diagram

Read the left column top-to-bottom (how a cell's options shrink), then the right column top-to-bottom (how one option is tried).

  • The top blue row is a raw empty cell: all nine digits are still conceivable.
  • The middle row applies Sudoku's three rules — anything already present in the same row, column, or 3×3 box is struck out (grey dots), leaving only the two green survivors {5, 9}. That is constraint propagation.
  • The right column is the backtracking cycle on the surviving choices: set cell = 5 (choose, green), recurse (explore, purple), and on failure set 0 (undo, red) before trying 9.

In short: Rules first prune the options; then choose → recurse → undo runs over only what is left. Filling the most-constrained cell (fewest survivors) first makes the search almost forced.

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

Think of the word-find puzzles in a newspaper — a grid of letters, and you drag your finger from cell to neighbouring cell trying to spell a target word, never crossing your own trail. When a path fizzles out, you lift your finger back to where it went wrong and try a different direction. That physical "trace a path, and if it dead-ends, retrace and branch off" is backtracking on a grid, and the finger you can't cross twice is the one detail that turns a simple search into a genuine backtracking problem.

Mechanically this is depth-first search launched from every starting cell (the word could begin anywhere), where each step tries the four neighbours — up, down, left, right — that match the next letter. The choice at each node is "which adjacent cell do I step to?"; the base case is "I've matched every letter, success"; the dead ends are "off the board," "letter doesn't match," or "already on my path." Because the word snakes through the grid one letter at a time, it's the template again — just with the decision tree laid over a 2-D board instead of a list.

The heart of this section is the "no cell reused" rule, and it forces a technique worth naming: the restorable mark. As you step onto a cell you mark it in use (here by overwriting it with a placeholder like #), so the recursion can't step back onto it and count the same letter twice. But — and this is the entire point — when that branch returns, you must restore the cell to its original letter. Marking is your "choose"; restoring is your "un-choose." They are the mirror-image pair from §1, expressed as a single mutable cell rather than a list you append to and pop from.

Forgetting the restore is the signature bug of this problem, and it's insidious because the code still runs and often gives the right answer on small inputs. If you mark a cell in use and never clear it, that cell stays blocked for every other path that would legitimately want to pass through it later — you've falsely walled off reachable squares, and words that genuinely exist get reported as missing. The failure is silent and data-dependent: it only shows up when two possible paths would have shared a cell. The fix is the discipline you now know by heart: mark on the way down, restore on the way back up, every single time. Note too that the lab passes a copy of the grid to each call so the mutation never leaks between test cases — a real-world habit worth copying.

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.

on the way down A B C step onto B A # C MARK: B → # recursion can't re-enter it on the way back up A # C branch returns A B C RESTORE: # → B other paths can use B again Mark is "choose", restore is "un-choose". Overwriting the cell with # stops the path from stepping on itself; putting the original letter back when the branch returns keeps that cell available to every other path. Skip the restore and you falsely wall off reachable squares.
🗺️ How to read this diagram

Read the left column first (descending into a cell), then the right column (returning from it) — the two are exact mirror images.

  • The left side shows the path stepping onto cell B (green), then immediately overwriting it with # (amber) — the mark. While it is #, the recursion cannot step back onto it and count the same letter twice.
  • The right side is what happens when that branch returns: the # is written back to its original letter B (green) — the restore. Now other paths are free to pass through B.
  • The arrows connect the two states of the cell; the amber-then-green pairing is the choose/un-choose mirror from §1 expressed on a single mutable cell.

In short: Mark # on the way down, write the letter back on the way up — every time. Forget the restore and words that genuinely exist get reported as missing.

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

Suppose you're shopping for the cheapest flight itinerary and you've already found one that costs $600. Now you start pricing out a new route, and after just the first two legs the running total is already $650. Do you bother pricing the remaining legs? Of course not — it's already more expensive than the best you've found, and adding more legs can only make it worse. You abandon it on the spot. That instinct — "this partial answer already can't win, so don't finish it" — is the leap from plain backtracking to branch-and-bound, and it's what lets you find a provably optimal answer without checking every possibility.

The distinction is worth stating precisely because it's a favourite interview question. Plain backtracking prunes branches that are infeasible — they break a hard rule, like a queen attacking another or a Sudoku digit repeating. Branch-and-bound handles a different flavour of problem: optimization, where every complete answer is legal but you want the best (cheapest, most valuable, shortest) one. So it prunes a second kind of branch on top of the infeasible ones — the branches that are still legal but provably worse than a solution you've already got in hand.

Two pieces make it work. First, the incumbent: the best complete solution found so far — your $600 flight. It starts pessimistic (infinity for a minimization, zero for a maximization) and tightens every time you complete a better solution. Second, and this is the clever part, the bound: for any partial solution you compute an optimistic estimate of the best it could possibly become if everything went perfectly from here. For a minimization that's a lower bound; for the knapsack in the lab it's an upper bound found by pretending you can pack items fractionally. If even that best-case estimate can't beat the incumbent, the entire subtree is hopeless and you prune it. The bound must be genuinely optimistic — never worse than reality — or you might prune the true optimum by mistake.

Now the honest part, because this is where hype meets math. Branch-and-bound does not change the worst-case complexity — it's still exponential, because a pathological instance can force you to explore nearly the whole tree. What it changes is the practical runtime, often by orders of magnitude, by skipping vast doomed regions. Keep this straight from a genuinely different technique: dynamic programming (D10–D11) attacks problems with overlapping subproblems by memoizing repeated work, and can drop you to polynomial time. Backtracking and branch-and-bound are for search spaces with no such overlap, where you must genuinely explore — you just explore as little of it as you can get away with. The comparison table and the closing warning make this trade-off explicit: prune all you like, but don't fool yourself that pruning turns exponential into polynomial.

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.

incumbent best = 600 partial A · bound 580 optimistic 580 beats 600? yes partial B · bound 650 best case 650 ≥ 600? no explore subtree prune whole subtree bound must be optimistic (never worse than reality) or you might prune the true optimum Bound vs incumbent decides the cut. For each partial solution you compute the best it could possibly reach. If even that optimistic bound can't beat the best complete answer found so far, the entire subtree is hopeless and skipped — still exponential worst-case, but vast regions never visited.
🗺️ How to read this diagram

Start at the green banner (the incumbent), then compare each partial solution beneath it left and right.

  • The top green banner is the incumbent — the best complete solution found so far, here a value of 600. It tightens as better complete solutions turn up.
  • The left purple node is partial A: its optimistic bound (580, for a minimization) could still beat the incumbent, so the search descends into it (green "explore subtree").
  • The right red node is partial B: its best-case bound (650) is already no better than the incumbent, so the whole subtree is pruned (red X) without ever being finished.
  • The footer is the safety rule: the bound must be genuinely optimistic — never worse than what the subtree could truly achieve — otherwise a good branch could be cut by mistake.

In short: Compute an optimistic bound for each partial answer; if it can't beat the incumbent, drop the subtree. That is the only difference from plain backtracking — and it prunes legal-but-worse branches, not just infeasible ones.

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