On this page

Dynamic Programming I

The question

A courier's bag holds 10 kg. Five parcels are waiting, and each pays a fee on delivery:

parcel 0 1 2 3 4
weight (kg) 4 3 5 2 6
pay 10 7 12 3 13

Which parcels should go in the bag to earn the most? This is 0/1 knapsack: items 0..n−1 with integer weights wi≥1 and values vi≥0, a capacity W, and a subset of total weight at most W whose value is as large as possible. Each item is taken once or not at all.

This module is about a method more than a problem. Knapsack, independent sets on trees and longest increasing subsequences look unrelated, and each becomes a table once seven decisions are made. Prerequisites: Python, big-O notation and proof by induction; the doubling array from Amortized Analysis appears once, in the recap.

Trying everything, and trying greedily

With five parcels you can try all 25=32 subsets. The best is parcels 0 and 4: weight 10, pay 23, and no other subset reaches it. The obvious shortcut, taking parcels in order of pay per kilogram while they fit, takes parcel 0 (2.5 per kg) and parcel 2 (2.4 per kg), then nothing else fits: 22.

from itertools import combinations
from functools import lru_cache

WTS, PAY, CAP = [4, 3, 5, 2, 6], [10, 7, 12, 3, 13], 10

def brute_force(wts, pay, cap):
    best = (0, ())
    for r in range(len(wts) + 1):
        for s in combinations(range(len(wts)), r):
            if sum(wts[i] for i in s) <= cap and sum(pay[i] for i in s) > best[0]:
                best = (sum(pay[i] for i in s), s)
    return best

def greedy_by_ratio(wts, pay, cap):
    load = total = 0
    for i in sorted(range(len(wts)), key=lambda i: -pay[i] / wts[i]):
        if load + wts[i] <= cap:
            load, total = load + wts[i], total + pay[i]
    return total

assert brute_force(WTS, PAY, CAP) == (23, (0, 4)) and greedy_by_ratio(WTS, PAY, CAP) == 22

Greedy is wrong because a good ratio can leave an awkward gap: after parcels 0 and 2 the bag has 1 kg left, and 1 kg earns nothing. Brute force is right but takes 2n subsets, about 1018 at n=60.

Where does brute force waste its work? Write it as a recursion: the best pay from parcels 0..i−1 with c kg of room either skips parcel i−1 or takes it. On twenty parcels and a 50 kg bag, that recursion makes 710,746 calls, but it only ever asks 777 different questions. It answers the same question, "best from the first i parcels with c kg left", hundreds of times.

import random

rng = random.Random(9)
w20 = [rng.randint(1, 10) for _ in range(20)]
v20 = [rng.randint(1, 30) for _ in range(20)]
calls, asked = 0, set()

def plain(i, c):
    global calls
    calls += 1
    asked.add((i, c))
    if i == 0:
        return 0
    best = plain(i - 1, c)
    if w20[i - 1] <= c:
        best = max(best, plain(i - 1, c - w20[i - 1]) + v20[i - 1])
    return best

assert plain(20, 50) == 232 and calls == 710_746 and len(asked) == 777

Dynamic programming is the decision to answer each question once and write the answer down.

Seven decisions before any code

Every dynamic program in this course is designed by making the same seven decisions, in words, before writing a line:

Decision The question it answers
State What does one table entry mean, in one sentence, with its indices?
Recurrence How is an entry computed from smaller entries, and why does its case split cover every solution?
Base case Which entries are known without the recurrence?
Evaluation order In what order can the table be filled so that every entry's inputs are ready?
Answer location Which entry, or which max over entries, is the answer?
Reconstruction How do you recover the object (the subset, the nodes, the subsequence), not just its value?
Space Which entries can be forgotten, and what does forgetting them cost?

The state is the decision that everything else hangs on. If you cannot say what one cell means, you cannot check the recurrence, and "what does this cell mean?" is the first question to ask of any DP you debug.

Invariant (every table in this module)

When an entry is written, it equals the optimum of exactly the subproblem its state names.

The proof is always the same induction in evaluation order. The recurrence reads only entries written earlier, which are correct by induction. Its cases are exhaustive: every feasible solution of the subproblem falls into one case. And within a case, the best solution is that case's choice plus an optimal solution of a smaller state. If the rest were not optimal, swapping in a better rest would improve the whole (an exchange argument). That shows the optimum is at most what the recurrence computes. Conversely, every candidate the recurrence compares is a real solution (a case's choice added to a feasible solution of a smaller state), so the recurrence never exceeds the optimum. The two bounds meet.

Knapsack, decided

  • State: T[i][c] is the best pay using only parcels 0..i−1 with room c, for 0≤i≤n and 0≤c≤W.
  • Recurrence: T[i][c]=max(T[i−1][c],T[i−1][c−wi−1]+vi−1), the second option only when wi−1≤c. An optimal subset for (i,c) either leaves parcel i−1 out, and then the rest is optimal for (i−1,c), or contains it, and then the rest is optimal for (i−1,c−wi−1). Those two cases cover every subset. Conversely, both options are achieved by real subsets: T[i−1][c] by one that fits in c, and the second option by adding parcel i−1 to one that fits in c−wi−1.
  • Base case: T[0][c]=0 for every c: no parcels, no pay.
  • Evaluation order: i increasing. Row i reads only row i−1, so the order within a row does not matter.
  • Answer location: T[n][W].
  • Reconstruction: walk back from (n,W). If T[i][c]≠T[i−1][c], parcel i−1 was needed: record it and subtract its weight from c. Then move to row i−1.
  • Space: one row of W+1 cells is enough for the value (next section), but this walk back needs every row.

The seven decisions translate almost word for word into a recursion that remembers its answers. This version asks only the questions it needs; the lab fills the whole table bottom-up instead, in the evaluation order above.

@lru_cache(maxsize=None)
def best(i, c):
    """T[i][c]: the best pay from parcels 0..i-1 with room c."""
    if i == 0:
        return 0
    skip = best(i - 1, c)
    if WTS[i - 1] > c:
        return skip
    return max(skip, best(i - 1, c - WTS[i - 1]) + PAY[i - 1])

table = [[best(i, c) for c in range(CAP + 1)] for i in range(len(WTS) + 1)]
assert table[3] == [0, 0, 0, 7, 10, 12, 12, 17, 19, 22, 22]
assert table[5] == [0, 0, 3, 7, 10, 12, 13, 17, 19, 22, 23]

The table, filled and walked back

Here is the whole table for the courier. Row i has considered parcels 0..i−1, and column c is the room left.

T[i][c] = max(T[i-1][c], T[i-1][c - w] + v) 0 1 2 3 4 5 6 7 8 9 10 i=0 i=1 i=2 i=3 i=4 i=5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 10 10 10 10 10 10 10 0 0 0 7 10 10 10 17 17 17 17 0 0 0 7 10 12 12 17 19 22 22 0 0 3 7 10 12 13 17 19 22 22 0 0 3 7 10 12 13 17 19 22 23
Yellow: the answer T[5][10] = 23. Blue: the two cells it read, T[4][10] = 22 (skip parcel 4) and T[4][4] = 10 (take it: 10 + 13). The walk back continues from T[4][4] through the green cells.

The answer 23 sits in the bottom-right corner. It came from T[4][4]+13=23, which beats T[4][10]=22, so parcel 4 was taken and the walk moves to room 10−6=4. In rows 4, 3 and 2 the value at room 4 does not change (10, 10, 10), so parcels 3, 2 and 1 were skipped. At row 1 the value drops from 10 to 0, so parcel 0 was taken. The subset is {0,4}.

assert table[4][4] + PAY[4] == 23 > table[4][10] == 22 and CAP - WTS[4] == 4
assert [table[i][4] for i in (4, 3, 2, 1, 0)] == [10, 10, 10, 10, 0]
assert brute_force(WTS, PAY, CAP)[1] == (0, 4)
Predict: why not read the chosen parcels off the last row?

The last row holds values only: 23 at room 10 says nothing about which parcels made it. The walk back compares each row with the one above to learn which choice produced the value, so this walk back needs every row.

Space. The value alone needs one row, updated in place. Scan the room c downwards, from W to wi−1, so that c−wi−1 still holds the previous row's value when it is read. An upward scan reads a cell that this parcel has already updated, so the parcel can be taken twice. With one parcel of weight 2 and pay 3 and a 4 kg bag, the upward scan answers 6 and the right answer is 3. The plain one-row version gives up the walk back above, because the rows it compares have been overwritten. Reconstruction does not need every value, though. Store one take-or-skip bit per cell, n(W+1) bits instead of n(W+1) words, and walk back along the bits. Or split the parcels in half, run one row forwards over the first half and one row backwards over the second, pick the room split that maximises the sum, and recurse on each half: O(n+W) words of space for about twice the time.

Why O(nW) is not polynomial

The table has (n+1)(W+1) cells and each costs a constant number of word operations, so the time is Θ(nW) in the worst case on a word-RAM. That looks polynomial, but it isn't polynomial in the right thing. The input writes W in about log2W bits, and W itself is exponential in that. Multiply every weight and the capacity by 10: the answer is still 23, each number grows by about 3.3 bits, and the table grows tenfold.

def input_bits(scale):
    return sum((w * scale).bit_length() for w in WTS) + (CAP * scale).bit_length() \
        + sum(v.bit_length() for v in PAY)

@lru_cache(maxsize=None)
def scaled(i, c, s):
    if i == 0:
        return 0
    skip = scaled(i - 1, c, s)
    return skip if WTS[i - 1] * s > c else max(skip, scaled(i - 1, c - WTS[i - 1] * s, s) + PAY[i - 1])

rows = [(s, input_bits(s), 5 * (CAP * s + 1), scaled(5, CAP * s, s)) for s in (1, 10, 100, 1000)]
assert rows == [(1, 34, 55, 23), (10, 52, 505, 23), (100, 72, 5005, 23), (1000, 92, 50005, 23)]
scale input bits cells filled (rows 1–5) answer
1 34 55 23
10 52 505 23
100 72 5,005 23
1000 92 50,005 23

The input grows by about 20 bits per step, and the work grows by a factor of 10 per step: exponential in the input's length. An algorithm that is polynomial in the numeric values but not in their bit length is called pseudo-polynomial. It is excellent when W is a few thousand kilograms and useless when W is a 64-bit byte count. Nobody expects to do much better: 0/1 knapsack is NP-hard, so a truly polynomial algorithm would give one for every problem in NP.

Two states per node: independent sets on a tree

A charity runs its volunteers as a tree: node 0 is the coordinator, and every other volunteer has exactly one supervisor. It wants a team with no volunteer next to their own supervisor, and each volunteer has a weight (hours they can give). This is maximum-weight independent set on a tree: pick nodes, no node together with its parent, with total weight as large as possible.

Try one number per node, "the best total in u's subtree". The parent can't use it, because it doesn't say whether u itself was picked, and that is exactly what the parent's rule needs to know. On a coordinator of weight 5 with one volunteer of weight 4, adding "best below" to the coordinator's weight gives 5+4=9, a team that breaks the rule. So the state carries that bit of information:

  • State: two numbers per node. inc(u) is the best weight in u's subtree with u picked, and exc(u) is the best with u not picked.
  • Recurrence: inc(u)=w(u)+∑cexc(c) and exc(u)=∑cmax(inc(c),exc(c)) over u's children c. Picking u rules out its children and nothing else. Different children's subtrees share no edge, so once u's status is fixed they are independent.
  • Base case: a leaf has inc=w(u) and exc=0 (empty sums).
  • Evaluation order: children before parents (post-order).
  • Answer location: max(inc(r),exc(r)) at the root r.
  • Reconstruction: top-down, carrying "may this node be picked?". Pick u if it may be and inc(u)>exc(u). The children of a picked node may not be picked.
  • Space: two numbers per node, O(n) time and space. The value alone can fold each child's pair into its parent's running sums as soon as the child is done, so only the O(height) sums along the current path stay live; reconstruction reads every node's pair, so it keeps all n.
4: 1 5: 1 1: 3 6: 2 2: 8 7: 6 8: 5 3: 7 0: 1
The volunteers, each labelled node: weight; nodes 0 to 8 are numbered level by level. Green: the best team, nodes 1, 2, 7 and 8, weight 22.
inc(u) = w(u) + sum exc(c); exc(u) = sum max(inc(c), exc(c)) 0 1 2 3 4 5 6 7 8 w inc exc 1 3 8 7 1 1 2 6 5 16 3 8 7 1 1 2 6 5 22 2 2 11 0 0 0 0 0
One column per node, filled leaves first. Node 3: inc = 7 + 0 + 0 = 7 and exc = 6 + 5 = 11, so the root is better off leaving 3 out and picking its children.

The root's pair is (16,22), so the answer is 22. Reconstruction starts at the root: 16<22, so the coordinator is not picked. Node 1 has (3,2) and is picked. Node 2 has (8,2) and is picked. Node 3 has (7,11) and is not, so its children 7 and 8 may be, and both are. The team {1,2,7,8} weighs 22. Picking every other level gives 16 or 18. Picking the heaviest volunteer who still fits, over and over, takes 2 (weight 8), then 3 (weight 7), then 1, and gets 18.

The numbers above are checked here by brute force over every team inside each subtree, a deliberate stand-in: writing the linear-time version is the lab's job.

KIDS = {0: [1, 2, 3], 1: [4, 5], 2: [6], 3: [7, 8]}
HOURS = [1, 3, 8, 7, 1, 1, 2, 6, 5]
PARENT = {c: p for p, cs in KIDS.items() for c in cs}

def subtree(u):
    return [u] + [x for c in KIDS.get(u, []) for x in subtree(c)]

def best_team(nodes, must=None, never=None):
    top = 0
    for r in range(len(nodes) + 1):
        for s in combinations(nodes, r):
            if any(PARENT.get(x) in s for x in s) or (must is not None and must not in s) \
                    or (never is not None and never in s):
                continue
            top = max(top, sum(HOURS[x] for x in s))
    return top

pairs = [(best_team(subtree(u), must=u), best_team(subtree(u), never=u)) for u in range(9)]
assert pairs == [(16, 22), (3, 2), (8, 2), (7, 11), (1, 0), (1, 0), (2, 0), (6, 0), (5, 0)]
assert sum(HOURS[x] for x in (1, 2, 7, 8)) == 22
assert sum(HOURS[x] for x in (0, 4, 5, 6, 7, 8)) == 16 and sum(HOURS[x] for x in (1, 2, 3)) == 18

def heaviest_first(nodes):
    team = []
    for u in sorted(nodes, key=lambda u: -HOURS[u]):
        if PARENT.get(u) not in team and not any(PARENT.get(x) == u for x in team):
            team.append(u)
    return team, sum(HOURS[x] for x in team)

assert heaviest_first(range(9)) == ([2, 3, 1], 18)
assert heaviest_first(range(8, -1, -1))[1] == 18      # the same whichever way ties are broken
Predict: should reconstruction pick a node when inc equals exc?

Either choice is optimal: both lead to a team of the same weight. The rule above breaks ties by not picking, which leaves the children free. What matters is that the top-down pass uses the same numbers the bottom-up pass computed, so every step stays optimal.

Longest increasing subsequence in O(n2)

A sequence of twelve readings:

42 0 17 1 58 2 23 3 61 4 35 5 19 6 70 7 44 8 52 9 27 10 66 11
The array a. Green: its longest increasing subsequence 17, 23, 35, 44, 52, 66, of length 6.

A subsequence keeps the order of the positions and may skip any of them. It is not a contiguous block: 17,23,35 skips 58 and 61. We want the longest one that is strictly increasing.

  • State: L[i] is the length of the longest increasing subsequence ending at index i.
  • Recurrence: L[i]=1+max{L[j]:j<i, aj<ai}. A subsequence ending at ai is either ai alone or some subsequence ending at an earlier, smaller aj with ai appended.
  • Base case: when no earlier aj is smaller, the max is empty and counts as 0, so L[i]=1: ai alone.
  • Evaluation order: i increasing.
  • Answer location: maxiL[i], not L[n−1]. On [3,4,1], L[2]=1 but the answer is 2.
  • Reconstruction: store the j that gave the max, and follow those links back.
  • Space: O(n), and none of it can be dropped: a later i may read any earlier L[j], and the answer is a max over all of L.
A = [42, 17, 58, 23, 61, 35, 19, 70, 44, 52, 27, 66]

def lis_quadratic(a):
    L, link = [1] * len(a), [None] * len(a)
    for i in range(len(a)):
        for j in range(i):
            if a[j] < a[i] and L[j] + 1 > L[i]:
                L[i], link[i] = L[j] + 1, j
    i = max(range(len(a)), key=L.__getitem__) if a else None
    out = []
    while i is not None:
        out.append(a[i])
        i = link[i]
    return L, out[::-1]

L, sub = lis_quadratic(A)
assert L == [1, 1, 2, 2, 3, 3, 2, 4, 4, 5, 3, 6] and sub == [17, 23, 35, 44, 52, 66]
assert lis_quadratic([3, 4, 1])[0][-1] == 1

The inner loop runs j=0..i−1 whatever the data, so this makes exactly n(n−1)/2 comparisons aj<ai: 66 here, and about 5·109 for n=105.

Faster: the tails array

The quadratic DP asks, for each new element, "which earlier subsequences can I extend?" and looks at all of them. Most of that is wasted. Among subsequences of the same length, only the one with the smallest last value matters, because it is the easiest to extend. So keep one number per length:

Invariant (tails)

After reading a prefix of the array, tails[k] is the smallest last value of any increasing subsequence of length k+1 in that prefix, and tails is strictly increasing.

To read the next element x, find the first slot k with tails[k] ≥x by binary search (it is sorted), and put x there, or append x if there is no such slot. The answer is the length of tails.

Why the invariant survives. Suppose it holds before x. For slots j<k, tails[j] <x, so a subsequence ending in x can't lower them. At slot k, tails[k-1] <x, so x extends a subsequence of length k, and x≤ tails[k], so x is the new smallest. For j>k, ending at x with length j+1 would need a subsequence of length j ending below x, but every such subsequence ends at a value ≥ tails[j-1] ≥ tails[k] ≥x. And x lands between tails[k-1] and the old tails[k], so tails stays sorted.

tails on a, at four moments1a[5] = 35 replaces 612a[6] = 19 replaces 233a[10] = 27 replaces 354a[11] = 66 is appended
tails after each of four elements; yellow marks the slot that changed, and empty slots are lengths not reached yet. Slot k holds the smallest value that ends an increasing subsequence of length k + 1 so far. The final tails, 17 19 27 44 52 66, is not a subsequence of a: 27 comes after 44 in a.

The step not to skip: the final tails array is not an answer. It mixes the ends of different candidates, and here 27 (position 10) comes after 44 (position 8). To recover a real subsequence, remember a link whenever x lands in slot k: the position of the element in slot k−1 at that moment. Following links back from the element in the last slot gives a longest increasing subsequence.

The check below computes tails straight from the invariant's definition, using L from the quadratic DP: tails[k] is the smallest ai in the prefix with L[i]≥k+1, because a subsequence of length L[i] ending at ai contains one of every shorter length ending there. It is a stand-in that confirms the figure. The binary-search version is the lab's.

def tails_by_definition(a, upto):
    L = lis_quadratic(a[:upto])[0]
    return [min(a[i] for i in range(upto) if L[i] >= k + 1) for k in range(max(L))]

assert tails_by_definition(A, 6) == [17, 23, 35] and tails_by_definition(A, 7) == [17, 19, 35]
assert tails_by_definition(A, 11) == [17, 19, 27, 44, 52]
assert tails_by_definition(A, 12) == [17, 19, 27, 44, 52, 66]
assert A.index(27) > A.index(44)       # so the final tails is not a subsequence of A

Cost. With ℓ elements in tails, a binary search makes at most ⌈log2(ℓ+1)⌉ comparisons, so the total is at most n⌈log2(n+1)⌉: O(nlogn) in the worst case, in the comparison model. That matches a known lower bound: in the comparison model, the longest increasing subsequence needs Ω(nlogn) comparisons in the worst case (Fredman, 1975; stated here, not proved).

Measure the claim

Counting comparisons on seeded random arrays (the course's verification script, tools/verify/unit3.py, computes these):

n quadratic DP, n(n−1)/2 tails method tails /(nlog2n)
1,000 499,500 5,251 0.527
10,000 49,995,000 69,181 0.521
100,000 4,999,950,000 858,880 0.517

The ratio in the last column is flat, as an nlogn bound predicts, and well under the worst-case constant 1 because tails stays short on random input (its length grows like 2n). These are measurements on these inputs, not a theorem about all inputs. The quadratic column is exact for every input.

import math

assert [n * (n - 1) // 2 for n in (1000, 10_000, 100_000)] == [499_500, 49_995_000, 4_999_950_000]
assert [round(c / (n * math.log2(n)), 3)
        for n, c in ((1000, 5251), (10_000, 69_181), (100_000, 858_880))] == [0.527, 0.521, 0.517]

A problem that looks different

A post office sells stamps worth 1, 7 and 10 cents. What is the fewest stamps that make exactly 14 cents? Taking the largest stamp that fits gives 10+1+1+1+1, five stamps. Two are enough. What would one table entry mean, and where is the answer? The lab's last problem is a different one.

Practise

In the lab you fill a knapsack table cell by cell, with each cell's dependencies drawn as arrows and the recurrence checked in every frame, then walk it back. You predict and then watch the tails array on a new sequence, write the two-state tree DP with reconstruction for a new tree, measure the pseudo-polynomial table and the two subsequence methods on the lab's own seeds, and finish with one problem that does not say what it is.

Recap

  • You can now: name the seven DP decisions for a new problem; fill, walk back and space-optimize a knapsack table; solve independent set on a tree with two states per node; and find a longest increasing subsequence in O(nlogn), recovering the subsequence with links.
  • Invariant: each entry equals the optimum of exactly its state's subproblem, because the recurrence's case split is exhaustive and reads only entries written earlier. For tails: slot k holds the smallest end of an increasing subsequence of length k+1.
  • Complexity achieved: knapsack Θ(nW) time, pseudo-polynomial (against 2n subsets); the tree in O(n) (against 2n teams); the longest increasing subsequence in at most n⌈log2(n+1)⌉ comparisons (against n(n−1)/2), all worst case.
  • Failure mode: the one-row knapsack scanned upwards quietly lets every parcel be taken again and again; returning the final tails as the subsequence.
  • In real software: Python's bisect.bisect_left is the binary search the tails method uses. Git's --patience diff (xdiff/xpatience.c) matches lines that are unique in both files and keeps a longest increasing subsequence of the matches, found with a binary search over sorted pile ends.
  • Retrieval: (Amortized Analysis) tails only ever grows by appending. If it were an array that doubles its capacity when full, what would n appends cost in copying?

Check yourself

  1. Why is the knapsack recurrence correct, and why must the table be walked back instead of read off the last row?
  2. Each parcel may now be taken any number of times. What changes in the recurrence and in the one-row loop, and on which instance do the two answers differ?
  3. For n=105 random numbers, compare the quadratic DP with the tails method, and say whether either computes the longest increasing contiguous block.

Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.

Your turn

Now try it yourself

Write the code, press Run, and scrub through the frames your program draws. The checks tell you when it works.

Loading the lab…

In this lab

  1. Fill the knapsack table, one cell at a time

    Four items with weights [2, 3, 5, 6] and values [7, 9, 7, 11], and a capacity of 9. The seven decisions are the lesson's: T[i][c] is the best value using items 0..i-1 with capacity c, row 0 is all zeros, and the answer sits at T[4][9].

    Write knapsack(weights, values, cap). Fill the table one cell at a time, row by row, and after every cell call show(T, i, c, reads, weights, values), where reads lists the cells your recurrence read: always (i-1, c), and (i-1, c-w) too when the item fits. The helper draws the table so far with an arrow from each of those cells. Then walk back from (4, 9), collect the chosen items, and call show_walk(T, path, chosen) once with the cells the walk visited.

    Before you press Run: how many frames will it record? Which row will look exactly like the row above it, and why? And does taking items in order of value per unit of weight find the best answer here?

    The starter fills every cell by skipping the item, so its table is wrong from the first item that fits. The checks read your frames and test the recurrence in every filled cell of every frame.

  2. Trace the tails array, then search it in logarithmic time

    The lesson ran the tails method on its own twelve readings. Now predict it on

    B = [36, 14, 52, 21, 67, 28, 11, 45, 73, 33, 58, 24]

    and answer the questions before running. Work on paper: for each element, find the first slot whose value is at least the element, and write the element there, or append it.

    The program draws one frame per element: B with the element just read, and tails with the slot it went to. It is already correct, but first_at_least finds the slot by scanning from the left, which makes up to len(tails) comparisons per element. tails is strictly increasing, so replace the scan by a binary search that you write yourself (the check refuses the bisect module, which would be this exercise done for you). A check counts every comparison on 2,000 random values and allows at most ⌈log₂(len(tails) + 1)⌉ per element.

    After running, scrub the frames and find the moment the final tails stopped being a possible subsequence of B.

  3. Two states per node: the heaviest team in a tree

    Write tree_mwis(children, weight, root): the heaviest set of nodes with no node together with its parent, returned as (weight, sorted nodes). children maps a node to the list of its children (leaves may be missing from it).

    The starter keeps one number per node, the best total in its subtree, and adds the node's own weight on top. Run it: the answer is the total weight of the whole tree, which breaks the rule wherever a node and its parent are both counted. Give each node two numbers, inc[u] (u picked) and exc[u] (u not picked), fill them children first, and call show(...) after each node. Then reconstruct the set top-down, carrying whether each node may be picked, and call show(..., chosen=...) once more.

    Write the seven decisions as a comment above your code. post_order is given and never recurses; keep your reconstruction free of recursion too, because one check uses a tree 5,000 levels deep.

    Before you press Run: the demo tree weighs 46 in total. Taking every other level gives 20 or 26, and taking the heaviest node that still fits, again and again, gives 26 as well. Can you beat 26 on paper?

  4. Measure: a table that grows with the numbers, and two ways to find a subsequence

    Two claims from the lesson, measured on your own counts.

    Pseudo-polynomial. Write knapsack_value(weights, values, cap) with one row of cap + 1 cells, updated in place item by item, adding 1 to the global cells for every cell you compute. For each item only capacities from cap down to its weight can change. Which direction must that loop run, and why? The program then scales the four items from exercise 1 (every weight and the capacity times 1, 10, 100 and 1000) and records the input's size in bits, your cell count and the value.

    n² against n log n. The direct subsequence DP and the tails method are given, and every comparison between two values is counted. The program counts the direct DP at n = 200, 400 and 800. Add the same loop for lis_tails_length at n = 1,000, 4,000 and 16,000, with random_values(n, seed).

    Before you press Run: how many cells will the four items take at scale 1, and at scale 1000? The picture draws your cells on a log scale beside the input's bits, and your tails ratios.

  5. Mastery challenge: nesting the crates

    A returns depot stores empty wooden crates by putting crates inside crates. Crate x fits inside crate y only if both its width and its height are strictly smaller (crates never turn on their side), and a crate holds at most one crate directly inside it, which may hold another, and so on. The floor manager wants the longest nest: as many crates as possible, each inside the next.

    Every evening up to 200,000 crates come back, and the plan must be ready before the night shift loads the racks. longest_nest(crates) takes a list of (width, height) pairs and returns the nest itself, smallest crate first, as a list of those pairs. The loaders need the crates, not just a count. Any longest nest is accepted.

    The starter is correct but compares every pair of crates. A check counts every comparison of two widths or two heights, and on 4,000 crates it allows 20 · n · log₂ n of them, about 957,000. It runs twice: on crates of random sizes, and on crates that nest in long chains. The starter needs about 8 million and is stopped when it runs out. Widths and heights must stay the values you were given: int() and float() of them are still counted, and inside your functions, converting them to text, dividing them with / or reaching into their internals is refused (printing in the demo at the bottom is fine). Imports are limited to bisect, itertools, functools, collections and heapq.

    Answer the four questions, then write it. The demo draws the nest your code finds in twelve crates.