Foundations to FrontierPart IV: Algorithms and systemsBezhig

C08: Trees, heaps, graphs, and shortest-path reasoning

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

On this page
  1. A. Prerequisites and observable goals
  2. B. The central question: how should relationships be represented and explored?
  3. C. A tree is not necessarily a balanced search structure
  4. D. BFS and DFS answer different questions
  5. E. Dijkstra’s algorithm needs nonnegative weights
  6. F. Spanning trees solve a different optimization problem
  7. G. Independent practice
  8. H. Complete solutions: C08
  9. I. Retention and readiness

A. Prerequisites and observable goals#

Before

C05 graph definitions and induction; C06 structures; C07 correctness and complexity.

After

Trace BFS, DFS, and Dijkstra; distinguish a heap from a search tree; justify a spanning-tree choice; and select graph representations under explicit constraints.

Level: Degree core.

B. The central question: how should relationships be represented and explored?#

A graph G=(V,E)G=(V,E) can be directed or undirected, weighted or unweighted. These are not cosmetic labels. A route allowed from uu to vv need not be allowed in reverse. An edge weight may represent distance, latency, or cost; summing it along a path requires compatible units and an appropriate objective. A disconnected graph has vertices that cannot be reached from a chosen source. An algorithm should represent that state explicitly instead of assigning a fabricated finite distance.

An adjacency list stores the outgoing neighbors of each vertex and uses O(∣V∣+∣E∣)O(|V|+|E|) space in the standard representation. An adjacency matrix uses O(∣V∣2)O(|V|^2) space but gives direct edge-presence lookup. Sparse graphs often favor lists; dense or matrix-oriented computations can favor matrices. Multiple edges, self-loops, and vertex identifiers need explicit policies. The lab accepts a declared vertex mapping with finite nonnegative weighted edges and rejects references to missing vertices.

A binary search tree stores keys so that keys in a left subtree precede the node’s key and keys in the right subtree follow it, under a declared duplicate policy. Search follows a root-to-leaf path, costing O(h)O(h) for height hh. Inserting sorted keys into a naive tree can produce height n−1n-1, so “tree lookup is logarithmic” is false without a balance guarantee or suitable distribution assumption.

Balanced-tree families maintain additional invariants. An AVL tree bounds the height difference of child subtrees and uses local rotations after updates. A rotation changes parent-child links while preserving in-order key order. Red-black trees maintain coloring and black-height constraints that bound height logarithmically. This module introduces the balancing idea, not a complete verified implementation of either family. Implementing rotations, deletion, and iterator invalidation safely is a substantial continuation exercise, not a one-line extension.

A binary min-heap instead requires every parent key to be no greater than its children. It does not order all left-subtree keys before all right-subtree keys. In a zero-based array, children of index ii are 2i+12i+1 and 2i+22i+2 when present; the parent of i>0i>0 is ⌊(i−1)/2⌋\lfloor(i-1)/2\rfloor. Insertion appends then repairs upward; extracting the minimum moves the last element to the root then repairs downward. Both repairs follow one path of logarithmic height. Bottom-up heap construction is O(n)O(n) because most nodes are close to leaves; summing work by height gives a convergent bound proportional to n∑h≥0h/2hn\sum_{h\ge0}h/2^h.

D. BFS and DFS answer different questions#

Breadth-first search uses a FIFO queue. Mark a vertex discovered when enqueuing it, set its distance one more than the current vertex’s, and record its predecessor. The queue processes vertices in nondecreasing edge-count distance, so first discovery gives a shortest path in an unweighted or uniformly weighted graph. It does not minimize arbitrary unequal weights. With adjacency lists, every vertex and edge is processed a bounded number of times, giving O(∣V∣+∣E∣)O(|V|+|E|) work.

Depth-first search uses a stack, explicit or recursive, to pursue one branch before returning. It supports reachability, connected components in undirected graphs, cycle analysis, and topological ordering for directed acyclic graphs. In a directed graph, distinguishing undiscovered, active, and finished vertices detects a back edge to the active path. A topological order exists only for a DAG. Reversing a valid DFS finishing order gives one such order after cycle checks; doing so blindly on a cyclic graph does not remove the cycle.

E. Dijkstra’s algorithm needs nonnegative weights#

Initialize the source distance to zero and others to infinity. Repeatedly remove the smallest tentative distance from a priority queue. Relax an outgoing edge (u,v)(u,v) by comparing the current best d(v)d(v) with d(u)+w(u,v)d(u)+w(u,v). A smaller candidate replaces the distance and predecessor and enters the heap. The lab uses lazy deletion: old heap entries remain but are ignored when their stored distance no longer equals the current best.

The correctness argument is a frontier argument. Suppose the smallest unsettled tentative vertex uu had a truly shorter path. Follow that path until its first unsettled vertex yy, whose predecessor was already settled. Relaxation from that predecessor would have made d(y)d(y) no greater than the corresponding path prefix. With nonnegative remaining edges, that prefix cannot exceed the supposedly shorter path to uu, contradicting uu being selected with the smallest tentative distance. Negative edges break this comparison. Bellman–Ford is a continuation for negative edges and detection of reachable negative cycles.

Directed graph with abstract nonnegative cost units. The best S-to-C path costs 2+1+1=42+1+1=4, not 7.

For this graph, S first proposes A at 2 and B at 5. A improves B to 3 and proposes C at 7. B improves C to 4. Stale entries B at 5 and C at 7 do not invalidate the result; they must be skipped when removed. Predecessors reconstruct S–A–B–C.

For the lazy-heap implementation, at most one successful decrease is generated per examined edge. A safe bound is O(∣V∣+∣E∣+(∣E∣+1)log⁡(∣E∣+2))O(|V|+|E|+(|E|+1)\log(|E|+2)) work and O(∣V∣+∣E∣)O(|V|+|E|) storage. For simple graphs this is commonly simplified to the familiar logarithm-in-vertices bound. State the implementation and graph model instead of silently assuming a decrease-key heap. Floating-point weights also introduce rounding in nearly tied path sums; finite-value checks do not establish exact real-arithmetic ranking at arbitrary precision.

F. Spanning trees solve a different optimization problem#

A shortest-path tree minimizes source-to-vertex path distances. A minimum spanning tree minimizes the sum of selected edge weights needed to connect all vertices of a connected undirected graph. These objectives differ. Kruskal’s algorithm sorts edges by weight and accepts an edge when it connects two previously disconnected components. The cut property justifies accepting a lightest edge across an appropriate component cut: an optimal spanning tree can exchange another crossing edge for it without increasing total weight.

A disjoint-set union structure tracks components. Each element points toward a representative; union joins roots, and path compression shortens future searches. Union by rank or size avoids long naive chains. The well-known near-constant amortized bound with both techniques is not a worst-case constant guarantee for each operation. With disconnected input, Kruskal produces a minimum spanning forest. Negative edge weights are allowed in this problem; Dijkstra’s nonnegativity restriction must not be transferred blindly to every graph algorithm.

G. 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.

C08.01CSolution

Insert keys 1,2,3,4,51,2,3,4,5 into an initially empty unbalanced binary search tree. Give its height and search cost for 5. Explain what balancing would change and why a min-heap is not a substitute for ordered range search.

C08.02CSolution

Trace Dijkstra on the four-vertex diagram, including the stale heap entries. Reconstruct the shortest S-to-C path and state its total cost.

C08.03CSolution

Give a three-vertex example where BFS’s fewest-edge path is not the minimum-weight path. Then give a negative-edge example explaining why Dijkstra’s settled-vertex proof fails.

C08.04TSolution

For an undirected triangle with edge weights AB=1AB=1, BC=2BC=2, AC=2AC=2, identify an MST and a shortest-path tree rooted at A. Show that the optimization objectives can select different edges even when some totals tie.

C08.05LabSolution

Implement shortest_paths in graph_lab.py using a heap and stale-entry skipping. Validate all edges and nonnegative finite weights, preserve infinity for unreachable vertices, record predecessors, and reject path-cost overflow. Test against a simple repeated-relaxation reference on small nonnegative graphs.

Optional small hints

C08.03: compare one expensive direct edge with two cheap edges. C08.04: a path through B costs 3 from A to C, while the direct edge costs 2.

H. Complete solutions: C08#

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

C08.01Show solution

Shape determines the path length

The tree is a chain of five nodes with height four when height counts edges. Searching for 5 examines five keys, so this family has linear worst-case search. Balancing maintains logarithmic height through additional invariants and local restructuring. A min-heap only promises the minimum at its root and parent–child order; it does not support the same ordered-subtree pruning used by search trees for arbitrary lookup or range queries. Choosing between them follows the required operations, not the fact that both can be drawn as trees.

Back to C08.01
C08.02Show solution

Stale proposals are not current facts

After S, tentative values are A=2, B=5, C=∞\infty. Removing A gives B=3 and C=7. Removing B at 3 improves C to 4. Removing C at 4 establishes its shortest distance. Later heap entries for B at 5 and C at 7 are stale because they disagree with stored best distances. The predecessor chain is C←\leftarrowB←\leftarrowA←\leftarrowS, giving S–A–B–C with cost 4. The direct alternative S–A–C costs 7 and S–B–C costs 6.

Back to C08.02
C08.03Show solution

The cost model changes the theorem

Use edges S–T with weight 100, S–U with weight 1, and U–T with weight 1. BFS chooses the one-edge path of cost 100; the two-edge path costs 2. For negative weights, use directed S→\toA of cost 2, S→\toB of cost 5, and B→\toA of cost −10-10. Settling A at 2 would be premature because the path through B costs −5-5. The proof’s assertion that the remaining path cannot decrease cost is false. The lab rejects negative weights instead of pretending its nonnegative-weight argument still applies.

Back to C08.03
C08.04Show solution

Connection cost and source distance are distinct

An MST can use AB and BC for total 3, or AB and AC for total 3. A shortest-path tree rooted at A must use AB for distance 1 to B and AC for distance 2 to C; reaching C via B costs 3 and is not shortest. Thus one valid MST differs from the shortest-path tree, although another MST happens to coincide. Ties do not erase the difference in objectives. A general MST need not preserve every source’s shortest paths.

Back to C08.04
C08.05Show solution

Reference shortest-path implementation

code/solutions/graph_lab.pyPython
"""C08.05: Dijkstra; vertices are strings and weights finite/nonnegative."""
import heapq
import math

def shortest_paths(graph: dict[str, list[tuple[str, float]]],
                   start: str) -> tuple[dict[str, float], dict[str, str]]:
    if start not in graph:
        raise ValueError("missing start vertex")
    if any(not isinstance(u, str) for u in graph):
        raise TypeError("string vertices required")
    for edges in graph.values():
        for v, w in edges:
            if v not in graph or not math.isfinite(w) or w < 0:
                raise ValueError("invalid endpoint or weight")
    dist = {u: math.inf for u in graph}
    prev: dict[str, str] = {}
    dist[start] = 0.0
    heap = [(0.0, start)]
    while heap:
        d, u = heapq.heappop(heap)
        if d != dist[u]:
            continue
        for v, w in graph[u]:
            new = d + w
            if not math.isfinite(new):
                raise OverflowError("path cost overflow")
            if new < dist[v]:
                dist[v], prev[v] = new, u
                heapq.heappush(heap, (new, v))
    return dist, prev

if __name__ == "__main__":
    g = {"a": [("b", 4), ("c", 1)], "b": [], "c": [("b", 2)]}
    print(shortest_paths(g, "a")[0])

Validation rejects missing vertices and invalid weight domains before the search. Relaxation stores a predecessor only on strict improvement, and lazy skipping prevents obsolete proposals from being expanded as current distances. The source remains distance zero and disconnected vertices remain infinite. Generated comparisons with a repeated-relaxation baseline check small graphs independently of heap ordering. The nonnegative-weight frontier proof establishes the ideal arithmetic algorithm; floating-point accumulation and overflow handling are explicit implementation limits. J02 adds A* for a unit-cost grid, where a consistent Manhattan heuristic supplies another justified selection rule.

Back to C08.05

I. Retention and readiness#

Retrieve queue versus stack behavior, heap invariants, and the difference between a path and a spanning tree. Advance when you can trace a new graph without running it, state each algorithm’s hypotheses, and explain why a tempting alternative optimizes the wrong quantity.