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
| Representation | Python | Best for | Watch out |
|---|---|---|---|
Adjacency list, nodes 0..n-1 | adj = [[] 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 nodes | adj = defaultdict(list) | string ids, sparse or unknown node sets | reading a missing node inserts it; isolated nodes are absent |
| Adjacency sets | defaultdict(set) | O(1) edge tests (v in adj[u]), dropping duplicate edges | a set's iteration order is arbitrary |
| Weighted list | adj[u].append((v, w)) | Dijkstra, Prim | |
| Weighted dict of dicts | g[u][v] = w | O(1) weight lookups and updates | a second edge between u and v overwrites the first |
| Adjacency matrix | [[0] * n for _ in range(n)], or math.inf for weights | dense graphs, Floyd–Warshall | O(n²) memory: 10⁴ nodes is 10⁸ cells |
| Edge list | edges = [(u, v, w), ...] | Kruskal, Bellman–Ford | no neighbor lookups |
| Implicit | def neighbors(state): ... | grids, word ladders, puzzle states | nodes 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 -> cBuilding graphs
| Input | Build |
|---|---|
n and 0-indexed edges | adj = [[] 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 ids | defaultdict(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 characters | implicit: neighbors computed from (r, c) |
| a list of words | implicit: 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]]1/7
Start: dist[0] = 0 and the queue holds the source. Every other node is -1, not reached yet.
| Variant | Change |
|---|---|
| several sources at once | start with deque(sources), every source at distance 0 |
| stop at a target | return when the target is enqueued: with unit weights, its distance is already final |
| the path itself | set parent[v] = u when enqueuing v, then walk back from the target |
| weights 0 or 1 | 0-1 BFS: weight-0 neighbors go to the front with appendleft (below) |
| a queue that is a list | q.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 order1/6
Start: the stack holds only the source, 0. Nothing is visited yet.
| Need | Template |
|---|---|
| reachability, components, flood fill | iterative preorder (or BFS) |
| finishing order: topological sort, strongly connected components | the iterator stack in postorder |
| a directed cycle | three colors (white, gray, black): an edge to a gray node is a cycle (recipe) |
| an undirected cycle | remember each node's parent, or use union-find |
| depth known to stay under ~1,000 | recursion 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]1/7
Start: dist[0] = 0, every other distance is inf, and the heap holds the entry (0, 0).
| Python detail | Why |
|---|---|
(distance, node) heap entries | the heap orders by distance; ties compare nodes, fine for ints and strings, not for objects (tie-breaks) |
if d > dist[u]: continue | heapq has no decrease-key: push the better distance and skip the stale entry when it surfaces (updating) |
math.inf for unreached | compares with ints; int(math.inf) raises, so map unreached nodes to -1 before returning ints |
| stop early | the first time the target is popped, its distance is final |
| negative weights | Dijkstra 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.
| Call | Does |
|---|---|
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 |
CycleError | a 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| Need | Use |
|---|---|
| an order, or "is there a cycle?" | TopologicalSorter(deps).static_order() and catch CycleError |
| the smallest order lexicographically | Kahn 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 speed | Kahn with lists |
| the cycle itself | CycleError.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
| Operation | Cost | Notes |
|---|---|---|
| BFS, DFS | O(V + E) | every node once, every edge once (twice when undirected) |
Dijkstra with heapq | O((V + E) log V) | stale entries mean up to E heap items |
| 0-1 BFS | O(V + E) | |
| Bellman–Ford | O(V · E) | |
| Floyd–Warshall | O(V³) | n = 200 took 0.31 s here; cubic growth makes n = 500 about 15 times longer |
Kahn, graphlib | O(V + E) | |
| union-find | O(α(n)) amortized per operation | effectively constant with path halving and union by size |
| adjacency matrix | O(V²) memory |
Measured on CPython 3.14 with one BFS over a random graph of 200,000 nodes and 1,000,000 edges:
| Representation | Time |
|---|---|
list[list[int]], int ids | 0.44 s |
defaultdict(list), int keys | 0.88 s |
defaultdict(list), string keys | 0.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).
| Job | Python | Watch out | More |
|---|---|---|---|
| Adjacency from edges | adj = [[] for _ in range(n)], then append both ways if undirected | [[]] * n shares one list | Setup |
| Visited | dist = [-1] * n or seen = {src}, marked when enqueued | marking on dequeue queues a node many times | BFS |
| Grid as a graph | neighbors of (r, c) with a bounds check, or a set of open cells | grid[-1] reads the last row instead of raising: check bounds first | Grids as graphs |
| Components | BFS or DFS from every unseen node, or DSU.count | loop over range(n): isolated nodes are components too | Components and cycles |
| Undirected cycle | DSU.union returns False, or DFS that skips the parent | the edge back to the parent is not a cycle | Union-find |
| Directed cycle | three colors, Kahn returning too few nodes, or CycleError | a plain seen set flags harmless diamonds | Recipe |
| Bipartite | color = {src: 0}, neighbors get 1 - color[u] | start from every uncolored node: the graph may be disconnected | Is it bipartite? |
| Topological order | TopologicalSorter(deps).static_order() | deps maps a node to its predecessors | Topological sort |
| Shortest path, unweighted | BFS with dist | stop when the target is enqueued | BFS |
| Shortest path, weights ≥ 0 | Dijkstra: (d, u) entries, skip d > dist[u] | no decrease-key in heapq | Dijkstra |
| Weights 0 or 1 | 0-1 BFS with appendleft | a node may improve after it is queued | Other shortest paths |
| At most k edges | Bellman–Ford with k + 1 rounds, copying dist each round | in-place updates chain several edges in one round | Shortest paths |
| Minimum spanning tree | Kruskal: sort (w, u, v), keep edges DSU.union accepts; Prim: a heap of (w, v) | Prim must skip nodes already in the tree when popped | Minimum spanning tree |
| String node ids | defaultdict(list), or map names to ints | reading a missing key of a defaultdict inserts it | Dictionaries |
| Heap entries, state keys | (dist, node), (r, c, mask) tuples | add a counter when nodes can't be compared | Tuples, Heaps |
| Clone a graph | a dict from old node to new, filled on first sight | create the copy before visiting its neighbors, or cycles never end | Recipe |
| Memoized DFS on a DAG | @cache on f(u) | only on DAGs: a cycle recurses forever; @cache frames overflow sooner than plain ones | Top-down DP |
| Deep graphs | an explicit stack | 1,000 frames by default | Depth 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)) # 2Typing
| Annotation | Means |
|---|---|
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.
| JavaScript | Python |
|---|---|
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 visited | set() |
Infinity | math.inf |
a hand-written MinHeap or a library | heapq is built in |
object keys turn node ids into strings: adj[1] and adj["1"] are the same node | dict keys keep their type: 1 and "1" are different nodes |
| about 10,000 frames before a V8 stack overflow | 1,000 frames by default |
| no graph library in the standard library | graphlib (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 shapePlain 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 recursionA 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 cycleSwapping 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
- Python docs: graphlib (opens in a new tab):
TopologicalSorter,CycleError, parallel batches - Python docs: collections.deque (opens in a new tab): O(1) appends and pops at both ends
- Python docs: heapq (opens in a new tab): the priority queue behind Dijkstra and Prim
- Python docs: sys.setrecursionlimit (opens in a new tab): the recursion limit and its risks
- Python docs: functools.cache (opens in a new tab): memoized DFS on DAGs
- cp-algorithms: Dijkstra (opens in a new tab), 0-1 BFS (opens in a new tab) and topological sort (opens in a new tab)
- cp-algorithms: Disjoint set union (opens in a new tab): path compression, union by size
- NetworkX documentation (opens in a new tab): the third-party graph library, for work outside interviews