../

Graphs

Graphs in Python 3.14. There is no graph type, so this sheet is about building one from list, dict, set, deque and heapq: representations, BFS and DFS templates that survive the recursion limit, Dijkstra with heapq, topological order with graphlib, union-find, costs, and the idioms graph problems use. The algorithms themselves, with TypeScript and JavaScript beside Python, are in Graph algorithms and Trees & graphs.

Representations

RepresentationPythonBest forWatch out
Adjacency list, nodes 0..n-1adj = [[] for _ in range(n)]most problems: about twice as fast as a dict (see Costs)[[]] * n shares one list between every node
Adjacency list, any hashable nodesadj = defaultdict(list)string ids, sparse or unknown node setsreading a missing node inserts it; isolated nodes are absent
Adjacency setsdefaultdict(set)O(1) edge tests (v in adj[u]), dropping duplicate edgesa set's iteration order is arbitrary
Weighted listadj[u].append((v, w))Dijkstra, Prim
Weighted dict of dictsg[u][v] = wO(1) weight lookups and updatesa second edge between u and v overwrites the first
Adjacency matrix[[0] * n for _ in range(n)], or math.inf for weightsdense graphs, Floyd–WarshallO(n²) memory: 10⁴ nodes is 10⁸ cells
Edge listedges = [(u, v, w), ...]Kruskal, Bellman–Fordno neighbor lookups
Implicitdef neighbors(state): ...grids, word ladders, puzzle statesnodes appear on demand: track them in a seen set

An undirected edge is stored twice, once in each direction. A directed edge u → v goes only in adj[u].

from collections import defaultdict
 
n = 4
edges = [(0, 1), (0, 2), (1, 2), (2, 3)]
 
adj: list[list[int]] = [[] for _ in range(n)]
for u, v in edges:  # undirected: both directions
    adj[u].append(v)
    adj[v].append(u)
print(adj)  # [[1, 2], [0, 2], [0, 1, 3], [2]]
 
nbrs: defaultdict[int, set[int]] = defaultdict(set)
for u, v in edges:
    nbrs[u].add(v)
    nbrs[v].add(u)
1 in nbrs[0]  # True: an O(1) edge test
 
matrix = [[0] * n for _ in range(n)]
for u, v in edges:
    matrix[u][v] = matrix[v][u] = 1
matrix[2]  # [1, 1, 0, 1]
 
roads = {"a": {"b": 4, "c": 1}, "c": {"b": 2}}
roads["a"]["c"]  # 1: the weight of a -> c
0 1 2 3 adj list of lists 0: [1, 2] 1: [0, 2] 2: [0, 1, 3] 3: [2] nbrs dict of sets 0: {1, 2} 1: {0, 2} 2: {0, 1, 3} 3: {2} matrix list of rows 0 0 1 1 2 2 3 3 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 edges = [(0, 1), (0, 2), (1, 2), (2, 3)] edge list: one tuple per edge
The graph built by the code above, in each representation

Building graphs

InputBuild
n and 0-indexed edgesadj = [[] for _ in range(n)], then append
nodes 1..n[[] for _ in range(n + 1)] and leave index 0 empty, or subtract 1 while reading
string idsdefaultdict(list), or map them to ints: ids = {name: i for i, name in enumerate(names)}
prerequisite pairs [a, b] ("b before a")the edge is b → a: check each problem's direction
a grid of charactersimplicit: neighbors computed from (r, c)
a list of wordsimplicit: one-letter changes, or buckets keyed by a pattern like h*t
from collections import defaultdict
from collections.abc import Iterator
 
raw = "4 3\n1 2\n2 3\n3 1"  # n m, then m edges, 1-indexed
lines = raw.splitlines()
n, m = map(int, lines[0].split())
adj: list[list[int]] = [[] for _ in range(n + 1)]
for line in lines[1 : m + 1]:
    u, v = map(int, line.split())
    adj[u].append(v)
    adj[v].append(u)
print(adj[1:])
# [[2, 3], [1, 3], [2, 1], []]: node 4 has no edges
 
g: defaultdict[str, list[str]] = defaultdict(list)
g["a"].append("b")
"b" in g  # False: b has no entry of its own
g.get("b", [])  # []: reads without inserting
nodes = ["a", "b", "c"]
full = {u: g.get(u, []) for u in nodes}  # every node present
sorted(full)  # ['a', 'b', 'c']
 
 
def grid_neighbors(
    grid: list[str], r: int, c: int
) -> Iterator[tuple[int, int]]:
    for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
        nr, nc = r + dr, c + dc
        if 0 <= nr < len(grid) and 0 <= nc < len(grid[0]):
            if grid[nr][nc] != "#":
                yield nr, nc
 
 
list(grid_neighbors(["..#", ".#."], 0, 1))  # [(0, 0)]

A defaultdict built only from edges misses isolated nodes, so a loop over its keys skips them; loop over range(n) or the node list instead. Iterate sorted(adj[u]) when the problem wants the lexicographically smallest traversal or a deterministic answer.

BFS

from collections import deque
 
type Adj = list[list[int]]
 
 
def bfs(adj: Adj, src: int) -> list[int]:
    dist = [-1] * len(adj)  # -1: not reached yet
    dist[src] = 0
    q = deque([src])
    while q:
        u = q.popleft()
        for v in adj[u]:
            if dist[v] == -1:  # mark when enqueued
                dist[v] = dist[u] + 1
                q.append(v)
    return dist
 
 
def levels(adj: Adj, src: int) -> list[list[int]]:
    seen = {src}
    q = deque([src])
    out: list[list[int]] = []
    while q:
        level: list[int] = []
        for _ in range(len(q)):  # exactly this level
            u = q.popleft()
            level.append(u)
            for v in adj[u]:
                if v not in seen:
                    seen.add(v)
                    q.append(v)
        out.append(level)
    return out
 
 
adj: Adj = [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], []]
print(bfs(adj, 0))  # [0, 1, 1, 2, 3, -1]
print(levels(adj, 0))  # [[0], [1, 2], [3], [4]]
no edges 0 1 2 3 4 5 q = 0 1 2 popped 0 2 3 popped 1 3 popped 2 4 popped 3 deque([]) (empty) popped 4 deque([]) (empty) dist = [0] [1] [2] [3] [4] [5] 0 -1 -1 -1 -1 -1 0 1 1 -1 -1 -1 0 1 1 2 -1 -1 0 1 1 2 -1 -1 0 1 1 2 3 -1 0 1 1 2 3 -1 0 1 1 2 3 -1 current queued done

1/7

Start: dist[0] = 0 and the queue holds the source. Every other node is -1, not reached yet.

VariantChange
several sources at oncestart with deque(sources), every source at distance 0
stop at a targetreturn when the target is enqueued: with unit weights, its distance is already final
the path itselfset parent[v] = u when enqueuing v, then walk back from the target
weights 0 or 10-1 BFS: weight-0 neighbors go to the front with appendleft (below)
a queue that is a listq.pop(0) is O(n) per pop; deque.popleft() is O(1)
import math
from collections import deque
 
type WAdj = list[list[tuple[int, int]]]  # w is 0 or 1
 
 
def zero_one_bfs(adj: WAdj, src: int) -> list[float]:
    dist: list[float] = [math.inf] * len(adj)
    dist[src] = 0
    q = deque([src])
    while q:
        u = q.popleft()
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:  # can improve later
                dist[v] = dist[u] + w
                if w == 0:
                    q.appendleft(v)  # same distance: front
                else:
                    q.append(v)
    return dist
 
 
wadj: WAdj = [[(1, 1), (2, 0)], [(3, 1)], [(1, 0)], []]
print(zero_one_bfs(wadj, 0))  # [0, 0, 0, 1]

Marking on enqueue keeps each node in the queue at most once; marking on dequeue lets one node be queued many times. The theory is in BFS.

DFS

Recursive DFS is the shortest to write, but CPython stops at 1,000 frames by default (RecursionError: maximum recursion depth exceeded), so a path-shaped graph of a few thousand nodes breaks it. sys.setrecursionlimit(10**6) lets plain Python-to-Python recursion go deep (100,000 levels ran on 3.14), but recursion through C code, such as a @functools.cache wrapper, still overflows the C stack: the same depth with @cache raised RecursionError: Stack overflow. Details are in Call stack and depth limits. For deep or untrusted inputs, use an explicit stack.

type Adj = list[list[int]]
 
 
def count_reachable(adj: Adj, src: int) -> int:
    seen = [False] * len(adj)
 
    def dfs(u: int) -> None:  # recursive: fine below ~1,000
        seen[u] = True
        for v in adj[u]:
            if not seen[v]:
                dfs(v)
 
    dfs(src)
    return sum(seen)
 
 
def preorder(adj: Adj, src: int) -> list[int]:
    seen: set[int] = set()
    order: list[int] = []
    stack = [src]
    while stack:
        u = stack.pop()
        if u in seen:
            continue  # pushed twice; already visited
        seen.add(u)  # mark on pop: same order as recursion
        order.append(u)
        stack.extend(reversed(adj[u]))  # first child on top
    return order
 
 
def postorder(adj: Adj, src: int) -> list[int]:
    seen = {src}
    out: list[int] = []
    stack = [(src, iter(adj[src]))]
    while stack:
        u, it = stack[-1]
        for v in it:  # resumes where u left off
            if v not in seen:
                seen.add(v)
                stack.append((v, iter(adj[v])))
                break
        else:  # no unvisited neighbor left: u is finished
            stack.pop()
            out.append(u)
    return out
 
 
dag: Adj = [[1, 2], [3], [3], []]
print(count_reachable(dag, 0))  # 4
print(preorder(dag, 0))  # [0, 1, 3, 2]
print(postorder(dag, 0))  # [3, 1, 2, 0]
print(postorder(dag, 0)[::-1])  # [0, 2, 1, 3]: a topo order
0 1 2 3 stack top 0 1 2 3 2 2 3 [] stack = [0] pop 0: visit it push 2, then 1: 1 is on top pop 1: visit it push 3 pop 3: visit it no neighbors pop 2: visit it push 3 again pop 3: already seen, skip the stack is empty order = [] order = [0] order = [0, 1] order = [0, 1, 3] order = [0, 1, 3, 2] order = [0, 1, 3, 2] popped visited skipped

1/6

Start: the stack holds only the source, 0. Nothing is visited yet.

NeedTemplate
reachability, components, flood filliterative preorder (or BFS)
finishing order: topological sort, strongly connected componentsthe iterator stack in postorder
a directed cyclethree colors (white, gray, black): an edge to a gray node is a cycle (recipe)
an undirected cycleremember each node's parent, or use union-find
depth known to stay under ~1,000recursion is fine and clearest

The iterator stack keeps one iter(adj[u]) per frame, so each node resumes its neighbor scan exactly where it paused, just as a recursive call would after returning.

Shortest paths

import heapq
import math
 
type WAdj = list[list[tuple[int, int]]]  # (v, weight)
 
 
def dijkstra(
    adj: WAdj, src: int
) -> tuple[list[float], list[int]]:
    dist: list[float] = [math.inf] * len(adj)
    parent = [-1] * len(adj)
    dist[src] = 0
    h = [(0, src)]  # (distance so far, node)
    while h:
        d, u = heapq.heappop(h)
        if d > dist[u]:
            continue  # stale: u improved after this push
        for v, w in adj[u]:
            if d + w < dist[v]:
                dist[v] = d + w
                parent[v] = u
                heapq.heappush(h, (d + w, v))
    return dist, parent
 
 
def path_to(parent: list[int], t: int) -> list[int]:
    out: list[int] = []
    while t != -1:
        out.append(t)
        t = parent[t]
    return out[::-1]
 
 
adj: WAdj = [
    [(1, 4), (2, 1)],
    [(3, 1)],
    [(1, 2), (3, 5)],
    [],
]
dist, parent = dijkstra(adj, 0)
print(dist)  # [0, 3, 1, 4]
print(path_to(parent, 3))  # [0, 2, 1, 3]
4 1 2 1 5 0 1 2 3 heap entries (d, node) smallest first (0, 0) (1, 2) (4, 1) (3, 1) (4, 1) stale (6, 3) (4, 1) stale (4, 3) (6, 3) stale (4, 3) (6, 3) stale (6, 3) stale [] (empty) pop (0, 0) pop (1, 2) pop (3, 1) stale: skip (4, 1) pop (4, 3) stale: skip (6, 3) start: push (0, 0) dist = [0] [1] [2] [3] 0 inf inf inf 0 4 1 inf 0 3 1 6 0 3 1 4 0 3 1 4 0 3 1 4 0 3 1 4 path to 3: [0, 2, 1, 3] current stale final

1/7

Start: dist[0] = 0, every other distance is inf, and the heap holds the entry (0, 0).

Python detailWhy
(distance, node) heap entriesthe heap orders by distance; ties compare nodes, fine for ints and strings, not for objects (tie-breaks)
if d > dist[u]: continueheapq has no decrease-key: push the better distance and skip the stale entry when it surfaces (updating)
math.inf for unreachedcompares with ints; int(math.inf) raises, so map unreached nodes to -1 before returning ints
stop earlythe first time the target is popped, its distance is final
negative weightsDijkstra gives wrong answers; use Bellman–Ford
import math
 
INF = math.inf
type WEdges = list[tuple[int, int, int]]  # (u, v, w)
 
 
def bellman_ford(
    n: int, edges: WEdges, src: int, rounds: int
) -> list[float]:
    dist: list[float] = [INF] * n
    dist[src] = 0
    for _ in range(rounds):  # n - 1 rounds: all paths
        nxt = dist[:]  # read last round's values only
        for u, v, w in edges:
            if dist[u] + w < nxt[v]:
                nxt[v] = dist[u] + w
        dist = nxt
    return dist
 
 
flights: WEdges = [(0, 1, 100), (1, 2, 100), (0, 2, 500)]
bellman_ford(3, flights, 0, 3 - 1)[2]  # 200
bellman_ford(3, flights, 0, 1)[2]  # 500: one edge at most
 
n = 3
d: list[list[float]] = [[INF] * n for _ in range(n)]
for i in range(n):
    d[i][i] = 0
for u, v, w in [(0, 1, 4), (1, 2, -2), (0, 2, 5)]:
    d[u][v] = min(d[u][v], w)
for k in range(n):  # Floyd–Warshall: k is the outer loop
    for i in range(n):
        for j in range(n):
            if d[i][k] + d[k][j] < d[i][j]:
                d[i][j] = d[i][k] + d[k][j]
print(d[0])  # [0, 4, 2]

Copying dist each round limits a path to rounds edges, which answers "cheapest flight with at most k stops" with rounds = k + 1; updating dist in place would let one round chain several edges. When to pick which algorithm is in Other shortest paths and Dijkstra.

Topological sort & graphlib

graphlib.TopologicalSorter (3.9+) takes a mapping from each node to its predecessors, the nodes that must come before it. That is the reverse of an adjacency list of u → v edges.

CallDoes
TopologicalSorter(deps)deps[node] is an iterable of the nodes that must come first
ts.add(node, *before)add a node and its predecessors; repeated calls merge
ts.static_order()an iterator over one valid order; raises CycleError on a cycle
ts.prepare(), ts.get_ready(), ts.done(*nodes), ts.is_active()hand out ready nodes in batches (a tuple each time), for parallel work
CycleErrora ValueError; e.args[1] lists one cycle with its first node repeated, such as ['a', 'b', 'a']
from graphlib import CycleError, TopologicalSorter
 
deps = {"deploy": {"test", "build"}, "test": {"build"}}
print(list(TopologicalSorter(deps).static_order()))
# ['build', 'test', 'deploy']
 
cyclic = TopologicalSorter({"a": {"b"}, "b": {"a"}})
try:
    list(cyclic.static_order())
except CycleError as e:
    print(e.args[1])  # ['a', 'b', 'a']
 
ts: TopologicalSorter[str] = TopologicalSorter()
ts.add("c", "a", "b")  # c needs a and b
ts.add("d", "c")
ts.prepare()  # raises CycleError here if there is one
batches: list[list[str]] = []
while ts.is_active():
    ready = ts.get_ready()  # everything runnable now
    batches.append(sorted(ready))
    ts.done(*ready)
print(batches)  # [['a', 'b'], ['c'], ['d']]

static_order() promises a valid order, not a particular one: when several nodes are ready at once, their relative order depends on insertion. Kahn's algorithm gives the same O(V + E) result with full control:

from collections import deque
 
type Edges = list[tuple[int, int]]  # (u, v): u before v
 
 
def kahn(n: int, edges: Edges) -> list[int] | None:
    adj: list[list[int]] = [[] for _ in range(n)]
    indeg = [0] * n
    for u, v in edges:
        adj[u].append(v)
        indeg[v] += 1
    q = deque(u for u in range(n) if indeg[u] == 0)
    order: list[int] = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    if len(order) < n:
        return None  # some nodes never freed: a cycle
    return order
 
 
diamond = [(0, 1), (0, 2), (1, 3), (2, 3)]
print(kahn(4, diamond))  # [0, 1, 2, 3]
print(kahn(2, [(0, 1), (1, 0)]))  # None
NeedUse
an order, or "is there a cycle?"TopologicalSorter(deps).static_order() and catch CycleError
the smallest order lexicographicallyKahn with a heap instead of the queue (alien dictionary)
rounds of parallel work (semesters, build waves)get_ready() batches, or Kahn one level at a time
int nodes and raw speedKahn with lists
the cycle itselfCycleError.args[1]

Union-find

class DSU:
    def __init__(self, n: int) -> None:
        self.parent = list(range(n))
        self.size = [1] * n
        self.count = n  # number of components
 
    def find(self, x: int) -> int:
        p = self.parent
        while p[x] != x:
            p[x] = p[p[x]]  # path halving
            x = p[x]
        return x
 
    def union(self, a: int, b: int) -> bool:
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False  # same group: this edge is a cycle
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra  # hang the smaller tree
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.count -= 1
        return True
 
 
dsu = DSU(5)
dsu.union(0, 1), dsu.union(1, 2), dsu.union(0, 2)
# (True, True, False): the third edge closes a cycle
dsu.count  # 3: {0, 1, 2}, {3}, {4}
 
parent: dict[str, str] = {}  # string or sparse nodes
 
 
def find(x: str) -> str:
    parent.setdefault(x, x)  # first sight: its own root
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x
 
 
parent[find("ann")] = find("bo")  # union
find("ann") == find("bo"), find("cy") == find("bo")
# (True, False)

The iterative find never recurses, so deep chains can't hit the recursion limit. Theory and uses are in Union-find.

Costs

OperationCostNotes
BFS, DFSO(V + E)every node once, every edge once (twice when undirected)
Dijkstra with heapqO((V + E) log V)stale entries mean up to E heap items
0-1 BFSO(V + E)
Bellman–FordO(V · E)
Floyd–WarshallO(V³)n = 200 took 0.31 s here; cubic growth makes n = 500 about 15 times longer
Kahn, graphlibO(V + E)
union-findO(α(n)) amortized per operationeffectively constant with path halving and union by size
adjacency matrixO(V²) memory

Measured on CPython 3.14 with one BFS over a random graph of 200,000 nodes and 1,000,000 edges:

RepresentationTime
list[list[int]], int ids0.44 s
defaultdict(list), int keys0.88 s
defaultdict(list), string keys0.89 s

The list wins because indexing needs no hashing. The queue matters more: a BFS over a star with 100,000 leaves took 0.009 s with deque.popleft() and 0.80 s with list.pop(0), which shifts the whole queue on every pop.

DS&A idioms

The graph moves problem solutions are built from, and the trap in each. The patterns are in the DS&A sheets (linked in the last column).

JobPythonWatch outMore
Adjacency from edgesadj = [[] for _ in range(n)], then append both ways if undirected[[]] * n shares one listSetup
Visiteddist = [-1] * n or seen = {src}, marked when enqueuedmarking on dequeue queues a node many timesBFS
Grid as a graphneighbors of (r, c) with a bounds check, or a set of open cellsgrid[-1] reads the last row instead of raising: check bounds firstGrids as graphs
ComponentsBFS or DFS from every unseen node, or DSU.countloop over range(n): isolated nodes are components tooComponents and cycles
Undirected cycleDSU.union returns False, or DFS that skips the parentthe edge back to the parent is not a cycleUnion-find
Directed cyclethree colors, Kahn returning too few nodes, or CycleErrora plain seen set flags harmless diamondsRecipe
Bipartitecolor = {src: 0}, neighbors get 1 - color[u]start from every uncolored node: the graph may be disconnectedIs it bipartite?
Topological orderTopologicalSorter(deps).static_order()deps maps a node to its predecessorsTopological sort
Shortest path, unweightedBFS with diststop when the target is enqueuedBFS
Shortest path, weights ≥ 0Dijkstra: (d, u) entries, skip d > dist[u]no decrease-key in heapqDijkstra
Weights 0 or 10-1 BFS with appendlefta node may improve after it is queuedOther shortest paths
At most k edgesBellman–Ford with k + 1 rounds, copying dist each roundin-place updates chain several edges in one roundShortest paths
Minimum spanning treeKruskal: sort (w, u, v), keep edges DSU.union accepts; Prim: a heap of (w, v)Prim must skip nodes already in the tree when poppedMinimum spanning tree
String node idsdefaultdict(list), or map names to intsreading a missing key of a defaultdict inserts itDictionaries
Heap entries, state keys(dist, node), (r, c, mask) tuplesadd a counter when nodes can't be comparedTuples, Heaps
Clone a grapha dict from old node to new, filled on first sightcreate the copy before visiting its neighbors, or cycles never endRecipe
Memoized DFS on a DAG@cache on f(u)only on DAGs: a cycle recurses forever; @cache frames overflow sooner than plain onesTop-down DP
Deep graphsan explicit stack1,000 frames by defaultDepth limits
from functools import cache
 
type Adj = list[list[int]]
 
 
def all_paths(adj: Adj, target: int) -> list[list[int]]:
    out: list[list[int]] = []
    path = [0]
 
    def go(u: int) -> None:
        if u == target:
            out.append(path[:])  # copy: path keeps changing
            return
        for v in adj[u]:
            path.append(v)
            go(v)
            path.pop()
 
    go(0)
    return out
 
 
def count_paths(adj: Adj, target: int) -> int:
    @cache
    def ways(u: int) -> int:  # a DAG, so no cycles
        if u == target:
            return 1
        return sum(ways(v) for v in adj[u])
 
    return ways(0)
 
 
dag: Adj = [[1, 2], [3], [3], []]
print(all_paths(dag, 3))  # [[0, 1, 3], [0, 2, 3]]
print(count_paths(dag, 3))  # 2

Typing

AnnotationMeans
list[list[int]]adjacency list over nodes 0..n-1
dict[str, list[str]]adjacency list over string ids
list[list[tuple[int, int]]]weighted: (neighbor, weight) pairs
dict[str, dict[str, float]]weighted dict of dicts
Mapping[T, Iterable[T]]any read-only graph a generic function accepts
TopologicalSorter[str]graphlib is generic in the node type
from collections import deque
from collections.abc import Hashable, Iterable, Mapping
 
type Graph = dict[str, list[str]]
type Adj = list[list[int]]
 
 
def bfs_order[T: Hashable](
    g: Mapping[T, Iterable[T]], start: T
) -> list[T]:
    seen = {start}
    order: list[T] = []
    q = deque([start])
    while q:
        u = q.popleft()
        order.append(u)
        for v in g.get(u, ()):
            if v not in seen:
                seen.add(v)
                q.append(v)
    return order
 
 
g: Graph = {"a": ["b", "c"], "b": ["d"]}
print(bfs_order(g, "a"))  # ['a', 'b', 'c', 'd']
adj: Adj = [[1], [0, 2], []]
print(bfs_order(dict(enumerate(adj)), 0))  # [0, 1, 2]

Coming from JavaScript

The TypeScript version of this sheet is Graphs.

JavaScriptPython
new Map() plus if (!adj.has(u)) adj.set(u, [])adj = defaultdict(list)
Array.from({ length: n }, () => [])[[] for _ in range(n)]
Array(n).fill([]): one array shared by every slot[[]] * n: the same trap
queue.shift(), O(n)deque.popleft(), O(1)
new Set() for visitedset()
Infinitymath.inf
a hand-written MinHeap or a libraryheapq is built in
object keys turn node ids into strings: adj[1] and adj["1"] are the same nodedict keys keep their type: 1 and "1" are different nodes
about 10,000 frames before a V8 stack overflow1,000 frames by default
no graph library in the standard librarygraphlib (topological sort only); networkx on PyPI for the rest

Recipes

Course Schedule II with graphlib

When you need any valid order for tasks with prerequisites, or an empty answer if there is a cycle.

from graphlib import CycleError, TopologicalSorter
 
 
def find_order(
    n: int, prereqs: list[list[int]]
) -> list[int]:
    ts: TopologicalSorter[int] = TopologicalSorter()
    for course in range(n):
        ts.add(course)  # courses without prerequisites too
    for course, before in prereqs:
        ts.add(course, before)  # before comes first
    try:
        return list(ts.static_order())
    except CycleError:
        return []
 
 
reqs = [[1, 0], [2, 0], [3, 1], [3, 2]]
order = find_order(4, reqs)
pos = {c: i for i, c in enumerate(order)}
print(all(pos[b] < pos[a] for a, b in reqs))  # True
print(order[0], order[-1])  # 0 3
print(find_order(2, [[1, 0], [0, 1]]))  # []

Courses 1 and 2 may come out in either order. For a specific order among ready nodes, use Kahn's algorithm from Topological sort & graphlib.

Clone a graph

When you must deep-copy a graph of node objects, cycles included.

class Node:
    def __init__(self, val: int) -> None:
        self.val = val
        self.neighbors: list[Node] = []
 
 
def clone(start: Node | None) -> Node | None:
    if start is None:
        return None
    copies = {start: Node(start.val)}  # old -> new
    stack = [start]
    while stack:
        old = stack.pop()
        for nb in old.neighbors:
            if nb not in copies:  # create on first sight
                copies[nb] = Node(nb.val)
                stack.append(nb)
            copies[old].neighbors.append(copies[nb])
    return copies[start]
 
 
a, b, c = Node(1), Node(2), Node(3)
a.neighbors = [b, c]
b.neighbors = [a, c]
c.neighbors = [a, b]
a2 = clone(a)
assert a2 is not None
print(a2.val, [n.val for n in a2.neighbors])  # 1 [2, 3]
print(a2 is not a, a2.neighbors[0].neighbors[0] is a2)
# True True: a new graph with the same shape

Plain objects hash by identity, so the dict maps each original node to its copy even when two nodes hold the same value.

Accounts merge

When records that share any key (an email) belong together: union-find over strings.

from collections import defaultdict
 
 
def merge(accounts: list[list[str]]) -> list[list[str]]:
    parent: dict[str, str] = {}
 
    def find(x: str) -> str:
        parent.setdefault(x, x)
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
 
    owner: dict[str, str] = {}
    for name, first, *rest in accounts:
        owner[first] = name
        find(first)  # register one-email accounts too
        for email in rest:
            owner[email] = name
            parent[find(email)] = find(first)
    groups: defaultdict[str, list[str]] = defaultdict(list)
    for email in owner:
        groups[find(email)].append(email)
    return sorted(
        [owner[root], *sorted(emails)]
        for root, emails in groups.items()
    )
 
 
accounts = [
    ["ann", "a@x", "b@x"],
    ["ann", "a@x", "c@x"],
    ["bo", "d@x"],
    ["ann", "e@x"],
]
for row in merge(accounts):
    print(row)
# ['ann', 'a@x', 'b@x', 'c@x']
# ['ann', 'e@x']
# ['bo', 'd@x']

Two people with the same name stay separate unless they share an email; the name never joins groups.

Directed cycle with an explicit stack

When a directed graph may be deep enough to break recursion and you need to know whether it has a cycle.

WHITE, GRAY, BLACK = 0, 1, 2  # unseen, on the path, done
 
 
def has_cycle(adj: list[list[int]]) -> bool:
    color = [WHITE] * len(adj)
    for s in range(len(adj)):
        if color[s] != WHITE:
            continue
        color[s] = GRAY
        stack = [(s, iter(adj[s]))]
        while stack:
            u, it = stack[-1]
            for v in it:
                if color[v] == GRAY:
                    return True  # back edge to the path
                if color[v] == WHITE:
                    color[v] = GRAY
                    stack.append((v, iter(adj[v])))
                    break
            else:
                color[u] = BLACK  # all neighbors finished
                stack.pop()
    return False
 
 
print(has_cycle([[1], [2], [0]]))  # True
print(has_cycle([[1, 2], [3], [3], []]))  # False: a diamond
chain = [[i + 1] for i in range(99_999)] + [[]]
print(has_cycle(chain))  # False: 100,000 deep, no recursion

A black node was fully explored without finding a cycle, so reaching it again (the diamond's second path to node 3) is harmless; only gray means "still on the current path".

Evaluate division

When equations like a / b = 2 form a weighted graph and queries ask for products along a path.

from collections import defaultdict, deque
 
 
def calc(
    eqs: list[tuple[str, str]],
    vals: list[float],
    queries: list[tuple[str, str]],
) -> list[float]:
    g: defaultdict[str, dict[str, float]] = defaultdict(dict)
    for (a, b), k in zip(eqs, vals, strict=True):
        g[a][b] = k  # a / b = k
        g[b][a] = 1 / k
    out: list[float] = []
    for src, dst in queries:
        if src not in g or dst not in g:  # in: no insert
            out.append(-1.0)
            continue
        ratio = {src: 1.0}  # src / node, per reached node
        q = deque([src])
        while q:
            u = q.popleft()
            for v, k in g[u].items():
                if v not in ratio:
                    ratio[v] = ratio[u] * k
                    q.append(v)
        out.append(ratio.get(dst, -1.0))
    return out
 
 
eqs = [("a", "b"), ("b", "c")]
qs = [("a", "c"), ("b", "a"), ("a", "e"), ("x", "x")]
print(calc(eqs, [2.0, 3.0], qs))  # [6.0, 0.5, -1.0, -1.0]

The dict of dicts keeps each weight next to its edge; src not in g guards against the defaultdict inserting unknown variables.

Alien dictionary: smallest valid order

When letters must be ordered from a sorted word list and ties should go to the smallest letter.

import heapq
 
 
def alien_order(words: list[str]) -> str:
    chars = {c for w in words for c in w}
    adj: dict[str, set[str]] = {c: set() for c in chars}
    indeg = dict.fromkeys(chars, 0)
    for a, b in zip(words, words[1:]):
        for x, y in zip(a, b):
            if x != y:  # the first difference decides
                if y not in adj[x]:
                    adj[x].add(y)
                    indeg[y] += 1
                break
        else:
            if len(a) > len(b):
                return ""  # "abc" before "ab": invalid
    h = [c for c in chars if indeg[c] == 0]
    heapq.heapify(h)  # smallest ready letter first
    out: list[str] = []
    while h:
        c = heapq.heappop(h)
        out.append(c)
        for d in adj[c]:
            indeg[d] -= 1
            if indeg[d] == 0:
                heapq.heappush(h, d)
    return "".join(out) if len(out) == len(chars) else ""
 
 
words = ["wrt", "wrf", "er", "ett", "rftt"]
print(alien_order(words))  # wertf
print(alien_order(["ca", "cb"]))  # abc: c waits its turn
print(repr(alien_order(["z", "x", "z"])))  # '': a cycle

Swapping Kahn's queue for a heap is the whole change: among the letters that are free at each step, the smallest goes first. That's O((V + E) log V) instead of O(V + E).

References