Foundations to FrontierPart IV: Algorithms and systemsBezhig

W02: From invariants to algorithm design

5 exercises, each with a complete solution. 1 code listing.

On this page
  1. Prerequisites and observable goals
  2. One pattern underneath several algorithms
  3. Deriving a boundary search
  4. A dynamic program is a recurrence plus an evaluation order
  5. Worked counterexample: when method selection matters
  6. Representation affects the implemented work
  7. Amortized cost is a bound on a sequence
  8. Independent practice
  9. Complete solutions: W02
  10. Retention and readiness

Prerequisites and observable goals#

Before

C04–C08: induction, arrays, graphs, shortest paths, and asymptotic analysis.

After

Derive a dynamic program from a dependency order; prove a search boundary; separate a valid algorithm from a tempting method whose assumptions fail.

One pattern underneath several algorithms#

An algorithm is not a list of code lines to memorize. It is a way to move from a state where little is known to a state where the requested property is established. An invariant records the knowledge preserved between steps. A dependency order tells us when a subanswer is ready to use. A decreasing measure or acyclic order explains why the process ends. These ideas connect loops, recursive functions, dynamic programming, and graph algorithms.

Choose the mathematical specification first. “Return the first index whose value is at least the target” is more precise than “do binary search.” It handles duplicates and a target larger than every element. A half-open array interval [ℓ,r)[\ell,r) includes ℓ\ell but excludes rr; its length is r−ℓr-\ell. That representation lets an empty search range satisfy ℓ=r\ell=r without inventing a valid element at that index.

For a sorted array aa of length nn and target tt, maintain

0≤ℓ≤r≤n,i<ℓ⇒ai<t,i≥r⇒ai≥t.0\le\ell\le r\le n,\quad i<\ell\Rightarrow a_i<t,\quad i\ge r\Rightarrow a_i\ge t.

Only valid array indices are quantified in the last two statements. Initially ℓ=0,r=n\ell=0,r=n; the two known regions are empty. With ℓ<r\ell<r, choose m=ℓ+⌊(r−ℓ)/2⌋m=\ell+\lfloor(r-\ell)/2\rfloor. If am<ta_m<t, sortedness proves all indices through mm too small, so set ℓ=m+1\ell=m+1. Otherwise all indices from mm onward are large enough, so set r=mr=m. Each branch discards a region justified by sortedness and keeps the invariant true.

At termination ℓ=r\ell=r. Every earlier valid index is too small, and every later valid index is large enough. Thus ℓ\ell is exactly the requested boundary, possibly nn. The nonnegative measure r−ℓr-\ell decreases on every iteration. This establishes termination separately from the boundary property. Removing sortedness destroys the implication that permits discarding half the array; extensive testing on unsorted arrays cannot repair that missing proof assumption.

Trace a=[1,3,3,8]a=[1,3,3,8], t=3t=3. Start [0,4)[0,4), midpoint 2 has 3, so r=2r=2. Midpoint 1 has 3, so r=1r=1. Midpoint 0 has 1, so ℓ=1\ell=1. The final interval is empty at 1, the first position with value at least 3. Returning an arbitrary matching midpoint would answer a different specification.

A dynamic program is a recurrence plus an evaluation order#

Consider a directed acyclic graph (DAG) with edge weight w(u,v)w(u,v) and source ss. The shortest distance is

d(s)=0,d(v)=min⁡(u,v)∈E{d(u)+w(u,v)}d(s)=0,\qquad d(v)=\min_{(u,v)\in E}\{d(u)+w(u,v)\}

for reachable nonsource vertices, with unreachable predecessors ignored. In a DAG no path returns to the source, so initialization is consistent even if the source has incoming edges from unreachable vertices. The empty path gives distance zero at ss. A vertex with no reachable predecessor has no finite distance.

The recurrence is not yet an algorithm: using an unfinished predecessor could propagate a wrong answer. A topological order places the start of every edge before its end. Process vertices in that order, relaxing outgoing edges. When processing uu, all paths reaching uu end in predecessors already processed. An inductive argument therefore establishes that its current distance is final. Negative edges are allowed because the proof uses acyclicity, not nonnegativity. Cycles invalidate this evaluation order, even if a particular cyclic graph has well-defined shortest paths.

Worked counterexample: when method selection matters#

Take vertices s,a,b,ts,a,b,t and edges

s→a:2,s→b:5,b→a:−4,a→t:2.s\to a:2,\quad s\to b:5,\quad b\to a:-4,\quad a\to t:2.

A valid topological order is s,b,a,ts,b,a,t. Process ss to obtain tentative d(a)=2,d(b)=5d(a)=2,d(b)=5. Process bb to improve d(a)d(a) to 5−4=15-4=1. Process aa to set d(t)=3d(t)=3. The path is s,b,a,ts,b,a,t.

A usual Dijkstra implementation that finalizes the smallest tentative vertex might finalize aa at 2 and tt at 4 before processing bb at 5. The negative edge later reveals a better route to a supposedly final vertex. This is not proof that Dijkstra is poor software; it is a proof that its nonnegative-edge hypothesis matters. Bellman–Ford offers a different method for general weighted directed graphs, with a different complexity and negative-cycle reasoning. Selecting a DAG method is both simpler and more efficient when the acyclicity premise is available.

Representation affects the implemented work#

An adjacency list visits each vertex and edge a bounded number of times in topological sorting and relaxation, giving O(V+E)O(V+E) graph-operation work. Constructing and checking input also costs work. If weights and distances are unbounded integers, arithmetic cost grows with bit length; constant-time addition is an explicit machine-word model, not a mathematical truth. Sorting the ready queue after every step would add unnecessary cost. A queue suffices for any valid topological order; a heap buys a specific tie-breaking order at extra cost.

A returned path adds a second specification. Store the predecessor that achieved a strict improvement, then walk backward from the target. An unreachable target should return no path rather than a fabricated empty route that looks like success. The path’s edges, summed weight, and endpoints supply an independent certificate of a feasible route. Optimality still depends on the invariant or a valid comparison algorithm; a path of weight 3 alone does not prove no path weighs 2.

Amortized cost is a bound on a sequence#

A dynamic array that doubles capacity may copy 1,2,4,…,2k1,2,4,\ldots,2^k elements across many appends. Their sum is 2k+1−12^{k+1}-1, not nn copies on every append. Including ordinary writes, nn appends cost O(n)O(n) total under fixed-size elements and a conventional allocator model. That gives constant amortized cost per append, while an individual resizing append can still cost Θ(n)\Theta(n). A real-time frame deadline cares about the expensive individual event, not just the amortized average. Reserving capacity or choosing another structure changes latency at the cost of memory or complexity.

Independent practice#

F foundation C standard course work T transfer S optional stretch L runnable lab or project. Attempt the complete set before opening its solutions.

W02.01CSolution

Trace the derived boundary search on [2,2,5,9][2,2,5,9] for targets 2 and 10. Give the invariant and explain why returning nn is valid in one case but dereferencing ana_n is not.

W02.02TSolution

For the four-vertex graph above, give a topological order, every distance after each processed vertex, and a shortest-path certificate. Explain exactly which Dijkstra proof step is unavailable.

W02.03CSolution

A table is filled by D(i,j)=D(i−1,j)+D(i,j−1)D(i,j)=D(i-1,j)+D(i,j-1) with first row and column equal to 1. Interpret D(i,j)D(i,j) as a counting problem, prove the recurrence, and give a valid fill order and the value D(2,2)D(2,2).

W02.04TSolution

A growing array starts at capacity 1 and doubles when full. Bound the total number of old-element copies in the first 17 appends. Explain whether this bound guarantees that every append meets a 1 ms deadline.

W02.05LSolution

Implement shortest_paths and path from the DAG lab. Reject invalid vertices, duplicate edges, noninteger weights, and cycles. Support negative weights and disconnected graphs. Compare small generated acyclic graphs with a simple repeated-relaxation oracle; verify returned path weights independently.

Optional small hints

For .01 the answer is a boundary in {0,…,n}\{0,\ldots,n\}, not always an element. For .03 classify paths by their last step. For .04 include the resize triggered by append 17. For the lab, finish topological validation before treating a distance as final.

Complete solutions: W02#

Compare the first step where your work differs. A corrected answer is not yet an independent success.

W02.01Show solution

The empty interval names the answer

For target 2: [0,4)[0,4) gives m=2m=2, value 5, so r=2r=2; then m=1m=1, value 2, so r=1r=1; then m=0m=0, value 2, so r=0r=0. Return 0. For target 10: m=2m=2 gives 5, so ℓ=3\ell=3; m=3m=3 gives 9, so ℓ=4\ell=4 and return 4. The invariant says all earlier positions are too small and all positions at or beyond the upper boundary are large enough. At 4 the second condition is vacuous because there are no valid array indices at least 4. A boundary may equal the length; an element access may not. The loop measure decreases as in the derivation.

Back to W02.01
W02.02Show solution

Use finalized dependencies, not a nonnegative-edge assumption

Use s,b,a,ts,b,a,t. Initially distances are (0,∞,∞,∞)(0,\infty,\infty,\infty) in order (s,a,b,t)(s,a,b,t). After ss: (0,2,5,∞)(0,2,5,\infty). After bb: (0,1,5,∞)(0,1,5,\infty). After aa: (0,1,5,3)(0,1,5,3). After tt: unchanged. The path s,b,a,ts,b,a,t has weight 5−4+2=35-4+2=3. All paths to each vertex pass through already processed predecessors, proving finality by induction. Dijkstra cannot rely on the assertion that a route through a later, larger tentative distance cannot improve a finalized distance: the edge of weight −4-4 does exactly that.

Back to W02.02
W02.03Show solution

A partition of possibilities gives the recurrence

Count grid paths from (0,0)(0,0) to (i,j)(i,j) using only steps increasing one coordinate by one. On the axes there is one possible path. Every path to an interior cell arrives from exactly one of (i−1,j)(i-1,j) or (i,j−1)(i,j-1); these disjoint cases exhaust the possibilities, so their counts add. Fill increasing row index and, within each row, increasing column index; both dependencies are ready. Values D(1,1)=2D(1,1)=2, D(1,2)=3D(1,2)=3, D(2,1)=3D(2,1)=3 give D(2,2)=6D(2,2)=6. The count also equals (42)=6\binom42=6, an independent check choosing the positions of the two horizontal steps. This is a mathematical-integer computation; large tables may exceed fixed-width storage.

Back to W02.03
W02.04Show solution

A sequence bound does not bound one pause

Appends 2, 3, 5, 9, and 17 copy respectively 1, 2, 4, 8, and 16 elements, totaling 31. Add 17 writes for 48 elementary writes/copies in the simplified model. This is linear in the number of appends; append 17 alone performs 16 copies plus its insertion. Element copy cost, allocation, page faults, and competing work are not bounded by this count in milliseconds. No 1 ms guarantee follows. To target a deadline, bound the workload and per-operation costs or remove the resize from the deadline-sensitive path, then validate on the actual target system.

Back to W02.04
W02.05Show solution

A complete bounded reference

The implementation validates the graph, constructs adjacency lists and indegrees, then computes a topological order. A remaining indegree cycle is rejected even when unreachable from the chosen source, matching the advertised DAG input contract. Distance None represents unreachable rather than a large finite sentinel that could be confused with a legitimate path weight. Strict improvements set predecessors; equal-cost alternatives do not require a unique path unless the contract says so.

integration_code/solutions/dag_paths.pyPython
"""W02: shortest paths on a DAG, allowing negative integer edge weights."""
from collections import deque


def shortest_paths(n, edges, source):
    if type(n) is not int or not 1<=n<=10000 or type(source) is not int or not 0<=source<n:
        raise ValueError("bounded valid vertices required")
    edges=list(edges)
    if len(edges)>100000:
        raise ValueError("too many edges")
    adjacent=[[] for _ in range(n)]
    incoming=[0]*n
    seen=set()
    for u,v,w in edges:
        if any(type(x) is not int for x in (u,v,w)) or not 0<=u<n or not 0<=v<n:
            raise ValueError("valid integer endpoints and weight required")
        if (u,v) in seen: raise ValueError("parallel edges excluded in this lab")
        seen.add((u,v));adjacent[u].append((v,w));incoming[v]+=1
    ready=deque(i for i in range(n) if incoming[i]==0)
    order=[]
    while ready:
        u=ready.popleft();order.append(u)
        for v,_ in adjacent[u]:
            incoming[v]-=1
            if incoming[v]==0:ready.append(v)
    if len(order)!=n:raise ValueError("graph is not acyclic")
    distance,previous=[None]*n,[None]*n
    distance[source]=0
    for u in order:
        if distance[u] is None:continue
        for v,w in adjacent[u]:
            candidate=distance[u]+w
            if distance[v] is None or candidate<distance[v]:
                distance[v]=candidate;previous[v]=u
    return distance,previous,order


def path(previous, source, target):
    if not 0<=source<len(previous) or not 0<=target<len(previous):
        raise ValueError("invalid endpoint")
    result=[];seen=set();current=target
    while current is not None:
        if current in seen:raise ValueError("invalid cyclic predecessor data")
        seen.add(current);result.append(current)
        if current==source:return list(reversed(result))
        current=previous[current]
    return None


if __name__=='__main__':
    d,p,order=shortest_paths(4,[(0,1,2),(0,2,5),(2,1,-4),(1,3,2)],0)
    print(d,order,path(p,0,3))

The test oracle repeatedly relaxes every edge, a deliberately different evaluation structure. Small DAGs have no negative cycles, and sufficiently many passes propagate every simple path. The tests compare distances and hand-checked reconstructed paths. The general correctness argument remains topological induction; generated samples are a check on its implementation. A graph with cycles belongs to a different contract and algorithm, not an optional silent fallback.

Back to W02.05

Retention and readiness#

Derive one invariant from a final specification without looking at code. Explain why DAG shortest paths permits negative edges while standard Dijkstra requires nonnegative weights. Distinguish total, worst-case individual, and measured operation costs in your own engine examples.