Part 4 · Algorithms · Module 33

Greedy Algorithms & Backtracking

Two opposite strategies for problems built from choices. A greedy algorithm commits to the best-looking choice and never looks back: fast, simple, and correct only when you can prove it. Backtracking tries every choice but abandons a branch the moment it cannot succeed: slow in the worst case, yet the only practical tool for puzzles, scheduling, and constraint problems.

⏱ ~2.5 hoursPrerequisites: M32 Dynamic Programming, M28 Recursion

The greedy idea and how to prove it

A greedy algorithm builds a solution one piece at a time, always taking the locally best piece according to some rule, and never revisiting a decision. Dijkstra (always settle the closest vertex) and Prim and Kruskal (always add the cheapest safe edge) from M31 are greedy.

Greedy works when the problem has two properties:

The standard proof technique is the exchange argument: take any optimal solution O that does not start with the greedy choice g. Show you can swap g into O in place of some element without making it worse. Then there is an optimal solution that starts with g, and induction finishes the proof.

Remember: greedy = one pass, no regrets. It is correct only with a proof; DP (M32) is the safe fallback when choices interact.

Interval scheduling and interval merging

Activity selection: given meetings as (start, end) intervals and one room, schedule as many as possible. Candidate greedy rules: shortest first? earliest start first? fewest conflicts first? All three fail on some input. The rule that works is earliest end time first.

Exchange argument. Let g be the meeting that ends first. Take any optimal schedule O, and let o be its first meeting. Since g ends no later than o, replacing o with g conflicts with nothing else in O, and the count is unchanged. So an optimal schedule starts with g; remove everything overlapping g and repeat. Sorting dominates: O(n log n).

Explain the result

Shortest-first picks the short middle meeting, which blocks both long ones: 1 meeting instead of 2.

Merging intervals: sort by start; walk through, extending the current block while the next interval starts before the block ends. Also O(n log n), and the basis of calendar "busy" views.

A third classic: the minimum number of rooms for all meetings equals the maximum number of meetings in progress at once. Sort starts, keep a min-heap of end times (M25), and pop rooms that are free. You will build this in the mini-project.

When greedy fails: coin change

"Take the largest coin that fits" makes change optimally for US coins (1, 5, 10, 25). Many people assume it always works. It does not.

At 6, greedy takes 4 + 1 + 1 (three coins) while 3 + 3 uses two. The exchange argument breaks: you cannot swap the 4 into the optimal solution {3, 3} without making it worse. Coin systems where greedy is always optimal are called canonical; deciding canonicity is itself a nontrivial algorithm. When in doubt, use DP.

Huffman coding

Store text with fewer bits by giving frequent characters short codes and rare ones long codes. For decoding to be unambiguous, no code may be a prefix of another (a prefix code), which is the same as saying the codes are leaves of a binary tree: 0 = left, 1 = right.

Huffman's greedy rule: repeatedly merge the two least frequent trees into one whose weight is their sum. A min-heap makes each step O(log k), so the whole build is O(k log k) for k distinct symbols.

Why it is optimal (sketch). In some optimal tree the two rarest symbols are sibling leaves at the deepest level: if a rarer symbol were shallower than a more frequent one, swapping them would not increase the total cost (exchange argument). Merging those two into one pseudo-symbol leaves a smaller instance of the same problem, so induction applies.

Fractional knapsack: greedy wins where 0/1 needs DP

If you may take fractions of items (gold dust rather than gold bars), sort by value per unit weight and take as much of the densest item as fits, then the next. Exchange argument: any solution that holds some weight of a less dense item while a denser one is not fully taken can be improved by swapping that weight. O(n log n).

Remember: a tiny change in the rules (fractions allowed or not) can switch a problem between greedy and DP. Always re-check the exchange argument.

Backtracking: depth-first search over choices

Many problems ask you to construct something: a subset, an ordering, a placement. Backtracking explores the tree of partial solutions depth-first. At each node it chooses an option, explores recursively, then un-chooses it so the next option starts from a clean state. If a partial solution already violates a constraint, the whole subtree is skipped: that is pruning.

def backtrack(partial):
    if is_complete(partial):
        record(partial)
        return
    for option in options(partial):
        if is_consistent(partial, option):   # prune
            partial.append(option)          # choose
            backtrack(partial)              # explore
            partial.pop()                   # un-choose

N-queens and sudoku

N-queens: place n queens on an n × n board so none attack each other. Place one queen per row; for each row try each column, skipping columns and diagonals already attacked. Tracking attacked columns and diagonals in sets makes each check O(1): cells on one "\" diagonal share row - col, cells on one "/" diagonal share row + col.

Sudoku: find an empty cell, try each digit that does not clash with its row, column, or box, recurse, and undo on failure. Plain backtracking already solves most newspaper puzzles in milliseconds; the next section makes it robust.

Pruning, constraint propagation, and branch and bound

Backtracking is exponential in the worst case, so its speed is entirely about how much of the tree you avoid.

Branch and bound

For optimization searches (find the best, not just any), keep the best complete solution found so far. At each node compute an optimistic bound on any solution in its subtree; if the bound cannot beat the incumbent, prune. For 0/1 knapsack the fractional-knapsack value of the remaining items is a valid upper bound (you can never do better than the relaxation). Good bounds plus good ordering (try the most promising branch first so the incumbent improves early) let branch and bound solve instances far beyond brute force. It is the core of integer-programming solvers.

Practice

Balloons are closed intervals [start, end] on a line. An arrow shot at x bursts every balloon with start <= x <= end. Write min_arrows(balloons) returning the minimum number of arrows that burst them all.

Hint

Sort by end. Shoot at the first balloon's end; skip every balloon whose start is at most that point; repeat. The exchange argument is the same as activity selection.

Show solution
def min_arrows(balloons: list[tuple[int, int]]) -> int:
    arrows = 0
    last = None
    for start, end in sorted(balloons, key=lambda b: b[1]):
        if last is None or start > last:
            arrows += 1
            last = end
    return arrows

Write first_greedy_failure(coins, limit) returning the smallest amount in 1..limit where "largest coin first" uses more coins than the optimum, or None if greedy is optimal for every amount up to limit. Assume 1 is always a coin.

Hint

Compute the optimum for every amount with the min-coins DP from M32, and compute the greedy count separately; return the first amount where they differ.

Show solution
def first_greedy_failure(coins: list[int], limit: int) -> int | None:
    best = [0] * (limit + 1)
    for a in range(1, limit + 1):
        best[a] = 1 + min(best[a - c] for c in coins if c <= a)
    desc = sorted(coins, reverse=True)
    for amount in range(1, limit + 1):
        left, used = amount, 0
        for c in desc:
            used += left // c
            left %= c
        if used > best[amount]:
            return amount
    return None

Write combination_sum(candidates, target) returning every combination of the distinct positive candidates (each may be used any number of times) that sums to target. Each combination is a list in non-decreasing order; return them sorted. Prune branches that overshoot.

Hint

Sort the candidates. Recurse with a start index so you never pick a smaller candidate after a larger one (that is what prevents duplicates). Break the loop as soon as a candidate exceeds the remaining target.

Show solution
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
    nums = sorted(candidates)
    out: list[list[int]] = []
    cur: list[int] = []

    def go(start: int, remaining: int) -> None:
        if remaining == 0:
            out.append(cur.copy())
            return
        for i in range(start, len(nums)):
            if nums[i] > remaining:
                break                      # sorted, so every later candidate overshoots too
            cur.append(nums[i])
            go(i, remaining - nums[i])     # i, not i + 1: reuse allowed
            cur.pop()

    go(0, target)
    return out

Write queens(n) returning every solution as a tuple of column indexes, one per row (so (1, 3, 0, 2) means row 0 has its queen in column 1). Return the solutions sorted.

Hint

Track used columns, row - col diagonals, and row + col anti-diagonals in three sets. Trying columns in increasing order produces solutions already sorted.

Show solution
def queens(n: int) -> list[tuple[int, ...]]:
    out: list[tuple[int, ...]] = []
    placed: list[int] = []
    cols: set[int] = set()
    diag: set[int] = set()
    anti: set[int] = set()

    def go(row: int) -> None:
        if row == n:
            out.append(tuple(placed))
            return
        for col in range(n):
            if col in cols or row - col in diag or row + col in anti:
                continue
            placed.append(col); cols.add(col); diag.add(row - col); anti.add(row + col)
            go(row + 1)
            placed.pop(); cols.remove(col); diag.remove(row - col); anti.remove(row + col)

    go(0)
    return out

Write solve_sudoku(puzzle) where puzzle is an 81-character string with . for blanks. Return the solved 81-character string, or None if unsolvable. Use the MRV heuristic (branch on the cell with the fewest candidates) so the hard test runs quickly.

Hint

Convert to a list of 81 ints. For each empty cell compute its candidates from its 20 peers. Pick the empty cell with the fewest; if it has none, return failure; otherwise try each, recurse, and undo.

Show solution
PEERS = []
for i in range(81):
    r, c = divmod(i, 9)
    br, bc = 3 * (r // 3), 3 * (c // 3)
    group = {9 * r + j for j in range(9)} | {9 * k + c for k in range(9)}
    group |= {9 * rr + cc for rr in range(br, br + 3) for cc in range(bc, bc + 3)}
    PEERS.append(group - {i})

def solve_sudoku(puzzle: str) -> str | None:
    cells = [0 if ch == "." else int(ch) for ch in puzzle]

    def candidates(i):
        return set(range(1, 10)) - {cells[p] for p in PEERS[i]}

    def go() -> bool:
        best, best_opts = None, None
        for i in range(81):
            if cells[i] == 0:
                opts = candidates(i)
                if best_opts is None or len(opts) < len(best_opts):
                    best, best_opts = i, opts
                    if not opts:
                        return False
        if best is None:
            return True
        for d in best_opts:
            cells[best] = d
            if go():
                return True
        cells[best] = 0
        return False

    for i, v in enumerate(cells):
        if v and v in {cells[p] for p in PEERS[i]}:
            return None
    return "".join(map(str, cells)) if go() else None

Mini-project

Mini-projectMeeting rooms and a timetable search

Build scheduler.py with two parts that contrast the two strategies of this module.

Part A: the greedy meeting-room allocator

assign_rooms(meetings: list[tuple[str, int, int]]) -> dict[str, int] maps each meeting name to a room number, using the fewest rooms. Sort by start time and keep a min-heap of (end_time, room); reuse the room that frees up earliest if it is free in time, otherwise open a new room. Prove (in a docstring comment) that the number of rooms equals the maximum overlap.

Part B: the backtracking timetable

timetable(courses, slots, conflicts) -> dict[str, str] | None assigns each course a time slot so no two conflicting courses (sharing a student or teacher) share a slot. This is graph coloring: use backtracking with MRV (the course with the fewest remaining legal slots goes next) and forward checking (after assigning, remove that slot from each conflicting course's options; fail early if any becomes empty).

pixi init scheduler && cd scheduler
pixi add python pytest
pixi run python scheduler.py meetings.csv
pixi run pytest -q
  • assign_rooms never puts overlapping meetings in the same room
  • The room count equals the maximum number of simultaneous meetings (checked by a sweep over sorted events)
  • timetable returns a valid assignment or None when impossible (for example, a triangle of conflicts with two slots)
  • A counter shows how many assignments MRV and forward checking save on a 20-course instance
  • At least six pytest tests run with pixi run pytest

Stretch goals: add soft constraints (teacher preferences) and switch Part B to branch and bound that minimizes violated preferences; load meetings from CSV with the csv module.

Quiz

Recap

Going further