codex/depth-first-search-stack.py

depth-first-search-stack.py
"""Compute recursive DFS and its call stack; write the PNG to the current directory.

Tested with Python 3.14, numpy 2.3.5 and matplotlib 3.10.7.
Run from the repository with make _media/depth-first-search-stack.png.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np


VERTICES = "ABCDEFG"
EDGES = ("AB", "AC", "BD", "BE", "CE", "CF", "EG", "FG")
POSITIONS = dict(zip(VERTICES, np.array([
    [0, 3], [-1, 2], [1, 2], [-1.8, 1], [-0.2, 1], [1.8, 1], [0.8, 0]
])))
DARK, BLUE, PURPLE = "#253746", "#24688f", "#704599"


def compute_dfs():
    adjacency = {v: sorted(b if a == v else a for a, b in EDGES if v in (a, b))
                 for v in VERTICES}
    seen, order, tree, stack, events = set(), [], [], [], []
    scans = 0

    def visit(u):
        nonlocal scans
        seen.add(u)
        order.append(u)
        stack.append(u)
        events.append(("Enter " + u, tuple(stack)))
        for v in adjacency[u]:
            scans += 1
            if v not in seen:
                tree.append(u + v)
                visit(v)
        assert stack.pop() == u
        events.append(("Return " + u, tuple(stack)))

    visit("A")
    # Simulate suspended adjacency-list iterators to check recursive equivalence.
    frames, marked, iterative, iterative_tree = [("A", iter(adjacency["A"]))], {"A"}, ["A"], []
    while frames:
        u, neighbors = frames[-1]
        v = next(neighbors, None)
        if v is None:
            frames.pop()
        elif v not in marked:
            marked.add(v)
            iterative.append(v)
            iterative_tree.append(u + v)
            frames.append((v, iter(adjacency[v])))
    assert order == iterative == list("ABDECFG")
    assert tree == iterative_tree == ["AB", "BD", "BE", "EC", "CF", "FG"]
    assert seen == set(VERTICES) and len(order) == len(seen) == 7
    assert scans == 16 and len(events) == 14
    assert max(len(state) for _, state in events) == 6
    assert dict(events)["Return D"] == ("A", "B")
    assert dict(events)["Enter G"] == tuple("ABECFG")
    assert [action for action, _ in events[8:]] == ["Return " + v for v in "GFCEBA"]

    # A stack that marks all reverse-ordered neighbors on push differs.
    pending, marked, eager = ["A"], {"A"}, []
    while pending:
        u = pending.pop()
        eager.append(u)
        for v in reversed(adjacency[u]):
            if v not in marked:
                marked.add(v)
                pending.append(v)
    assert eager == list("ABDEGFC") and eager != order
    return order, tree, events, scans


def draw_graph(ax, order, tree):
    highlighted = {frozenset(e) for e in tree}
    for a, b in EDGES:
        xy = np.array([POSITIONS[a], POSITIONS[b]])
        is_tree = frozenset((a, b)) in highlighted
        ax.plot(xy[:, 0], xy[:, 1], color=BLUE if is_tree else "#929ca6",
                lw=2.7 if is_tree else 1.7, ls="-" if is_tree else "--", zorder=1)
    for v, (x, y) in POSITIONS.items():
        ax.scatter(x, y, s=1100, facecolors="white", edgecolors=BLUE,
                   linewidths=1.8, zorder=3)
        ax.text(x, y, f"{v}\n{order.index(v) + 1}", ha="center", va="center",
                fontsize=12, linespacing=1.15, color=DARK, zorder=4)
    ax.set(xlim=(-2.3, 2.3), ylim=(-0.5, 3.5), aspect="equal")
    ax.axis("off")
    ax.set_title("Same graph; numbers show discovery order", fontsize=12, pad=12)


def main():
    order, tree, events, scans = compute_dfs()
    fig, axes = plt.subplots(1, 2, figsize=(10.4, 5.7), dpi=120,
                             facecolor="white", gridspec_kw={"width_ratios": [1, 1.18]})
    draw_graph(axes[0], order, tree)
    ax = axes[1]
    ax.set(xlim=(0, 1), ylim=(-0.6, 9.6))
    ax.axis("off")
    ax.set_title("Call stack: oldest → current (top)", fontsize=12, pad=12)
    ax.text(0.02, 9.1, "Action", color=DARK, fontsize=11, fontweight="bold")
    ax.text(0.33, 9.1, "Active calls", color=DARK, fontsize=11, fontweight="bold")
    rows = events[:8] + [("Return rest", ())]
    for i, (action, state) in enumerate(rows):
        y = 8.35 - i * 0.91
        color = PURPLE if action.startswith("Enter") else DARK
        ax.text(0.02, y, action, fontsize=11, color=color, va="center")
        ax.text(0.33, y, " → ".join(state) if state else "empty", fontsize=11,
                color=color, va="center")
        ax.axhline(y - 0.37, xmin=0.02, xmax=0.96, color="#e6e9ed", lw=0.8)
    ax.text(0.02, -0.3, "Final returns: " + " → ".join(a.split()[1] for a, _ in events[8:]),
            fontsize=10, color=DARK)
    fig.suptitle("Depth-first search: finish a new branch before resuming its caller", fontsize=14)
    fig.text(0.5, 0.095, "Solid: discovery tree     Dashed: other graph edges     "
             "Scan neighbors alphabetically", ha="center", fontsize=10, color=DARK)
    fig.text(0.5, 0.05, "First discovery: " + " → ".join(order) + "     "
             "D finishes before E starts; discovery marks persist after return",
             ha="center", fontsize=10, color=DARK)
    fig.subplots_adjust(left=0.025, right=0.985, top=0.84, bottom=0.19, wspace=0.1)
    fig.savefig(Path.cwd() / "depth-first-search-stack.png", facecolor="white",
                transparent=False)
    plt.close(fig)
    print(f"DFS: order={''.join(order)}; tree={tree}; scans={scans}; "
          f"stack_peak=6; entries=7; returns=7; eager_stack_order=ABDEGFC")


if __name__ == "__main__":
    main()