Graph Algorithms
Once edges carry weights or direction with dependencies, plain BFS stops being enough. This is the toolbox: build the adjacency list, then reach for Dijkstra, Bellman-Ford, topological sort, or an MST.
First build the graph, then pick the algorithm
Almost every graph problem hands you an edge list — [[0,1],[1,2],[0,3]] —
and almost every algorithm wants an adjacency list: for each node, who can I reach
and at what cost. Getting from one to the other is three lines, and it is the single most
common place people lose the problem before it starts.
def build_undirected_graph(num_nodes, edges):
"""edges is a list of [u, v] pairs."""
neighbors = {node: [] for node in range(num_nodes)}
for u, v in edges:
neighbors[u].append(v)
neighbors[v].append(u) # both directions - THIS is the forgotten line
return neighbors
def build_directed_graph(num_nodes, edges):
"""edges is a list of [before, after] pairs: 'before' points at 'after'."""
neighbors = {node: [] for node in range(num_nodes)}
for before, after in edges:
neighbors[before].append(after) # one direction only
return neighbors
def build_weighted_graph(num_nodes, edges):
"""edges is a list of [u, v, weight]. Store the weight alongside the neighbor."""
neighbors = {node: [] for node in range(num_nodes)}
for u, v, weight in edges:
neighbors[u].append((v, weight))
neighbors[v].append((u, weight))
return neighbors
defaultdict(list) is fine, but it only creates keys for nodes that appear in an
edge. A node with no edges at all silently vanishes, and every “count the components”
answer comes out one too low. Seeding {node: [] for node in range(num_nodes)} keeps
isolated nodes alive. If the problem numbers nodes 1..n (LC 743 does), use
range(1, num_nodes + 1).
Why plain BFS stops working the moment edges have weights
BFS finds the path with the fewest edges. When every edge costs 1, fewest edges and cheapest path are the same thing, so BFS is a correct shortest-path algorithm. The instant edges have different costs, those two stop being the same:
Dijkstra fixes this by replacing the FIFO queue with a min-heap keyed on distance. Instead of “whoever I found first,” it always expands whoever is currently cheapest. Because no edge has a negative weight, the cheapest unexpanded node can never get any cheaper later — so the moment you pop it, its distance is final.
heapq has no decrease-key. So when a node's distance improves we just
push a second entry and let the old one rot. On pop, one guard
(if dist_to_node > distance[node]: continue) throws away anything out of date.
Two of the nine steps above are exactly that.
Dijkstra, Bellman-Ford, topological sort, MST
T1Dijkstra — cheapest path, non-negative weights
One source, every destination. The heap holds (distance_so_far, node) so Python's
tuple ordering pops the cheapest node next.
import heapq
def dijkstra(num_nodes, neighbors, source):
"""neighbors[node] is a list of (other_node, weight) pairs."""
INFINITY = float('inf')
distance = [INFINITY] * num_nodes
distance[source] = 0
frontier = [(0, source)] # min-heap of (distance_so_far, node)
while frontier:
dist_to_node, node = heapq.heappop(frontier)
# A node gets pushed again every time we find a cheaper route to it,
# so old entries linger. If this one is worse than what we already
# know, it is stale - drop it.
if dist_to_node > distance[node]:
continue
# Popping the heap minimum with no negative weights means
# distance[node] can never improve again. It is final now.
for other_node, weight in neighbors[node]:
candidate = dist_to_node + weight
if candidate < distance[other_node]:
distance[other_node] = candidate
heapq.heappush(frontier, (candidate, other_node))
return distance
O((V + E) log V) time, O(V + E) space. If the problem asks for one specific target, you may
return dist_to_node the moment you pop it — it is already final.
T2Bellman-Ford — negative weights, or a hop limit
Dijkstra's whole argument collapses if an edge can be negative: a node you already settled might get cheaper later. Bellman-Ford gives up on the greedy pop and instead relaxes every edge, V−1 times. Slower — O(V · E) — but it does not care about signs, and it detects negative cycles.
The other reason to reach for it: after round i, every distance reachable in i edges or fewer is correct. That round counter is exactly the “at most k stops” constraint in LC 787, which is why Dijkstra is the wrong tool there.
def bellman_ford(num_nodes, edges, source):
"""edges is a list of (u, v, weight); weights may be negative.
Returns None if a negative cycle is reachable from the source."""
INFINITY = float('inf')
distance = [INFINITY] * num_nodes
distance[source] = 0
# A shortest path visits at most num_nodes - 1 edges, and each round
# extends every correct answer by one more edge.
for _ in range(num_nodes - 1):
something_improved = False
for u, v, weight in edges:
if distance[u] == INFINITY:
continue
if distance[u] + weight < distance[v]:
distance[v] = distance[u] + weight
something_improved = True
if not something_improved:
break # already stable, no point continuing
# One extra round: if anything STILL improves, a negative cycle exists.
for u, v, weight in edges:
if distance[u] != INFINITY and distance[u] + weight < distance[v]:
return None
return distance
def find_cheapest_price(num_cities, flights, source, target, k):
INFINITY = float('inf')
cost = [INFINITY] * num_cities
cost[source] = 0
# "At most k stops" means at most k + 1 flights, so k + 1 rounds.
for _ in range(k + 1):
cost_before_this_round = cost[:] # freeze - see callout above
for start, end, price in flights:
if cost_before_this_round[start] == INFINITY:
continue
candidate = cost_before_this_round[start] + price
if candidate < cost[end]:
cost[end] = candidate
return cost[target] if cost[target] != INFINITY else -1
T3Topological sort, way 1 — Kahn's (BFS on in-degrees)
Given a directed acyclic graph, produce an order where every node comes after everything that points at it. Courses after prerequisites, tasks after blockers, letters after the letters they outrank. A topological order exists if and only if there is no cycle, which is why “can you finish all courses?” and “is this graph acyclic?” are the same question.
Count how many arrows point at each node. Repeatedly take a node with zero incoming arrows, emit it, and delete its outgoing arrows by decrementing its successors.
from collections import deque
def topological_order(num_nodes, edges):
"""edges is a list of [before, after] pairs."""
neighbors = {node: [] for node in range(num_nodes)}
in_degree = [0] * num_nodes
for before, after in edges:
neighbors[before].append(after)
in_degree[after] += 1
# Anything with no unmet prerequisites can legally go first.
queue = deque()
for node in range(num_nodes):
if in_degree[node] == 0:
queue.append(node)
order = []
while queue:
node = queue.popleft()
order.append(node)
for other_node in neighbors[node]:
in_degree[other_node] -= 1 # one prerequisite satisfied
# Only push at exactly zero, so each node is enqueued once.
if in_degree[other_node] == 0:
queue.append(other_node)
# Nodes trapped in a cycle never reach in-degree 0, so they never emit.
if len(order) != num_nodes:
return [] # cycle - no valid ordering exists
return order
T4Topological sort, way 2 — DFS post-order
Recurse first, append after. When visit(node) returns, everything reachable from
node is already in the list, so node belongs before all of them —
reverse at the end. Cycle detection needs three states, not two: a node currently on
the recursion stack is different from a node we finished long ago.
def topological_order_dfs(num_nodes, neighbors):
UNVISITED, IN_PROGRESS, DONE = 0, 1, 2
state = [UNVISITED] * num_nodes
reverse_order = []
def visit(node):
if state[node] == IN_PROGRESS:
return False # looped back onto the current path -> cycle
if state[node] == DONE:
return True # already placed, nothing to do
state[node] = IN_PROGRESS
for other_node in neighbors[node]:
if not visit(other_node):
return False
state[node] = DONE
# Everything reachable from here is already in the list, so putting
# node on the end now puts it in FRONT of them after we reverse.
reverse_order.append(node)
return True
for node in range(num_nodes):
if not visit(node):
return []
reverse_order.reverse()
return reverse_order
| Kahn's (in-degree BFS) | DFS post-order | |
|---|---|---|
| Cycle detection | len(order) != num_nodes | hit an IN_PROGRESS node |
| Stack risk | None — iterative | Recursion depth O(V) |
| Lexicographic order | Easy — swap deque for a heap | Awkward |
| Partial progress on a cycle | Tells you how many nodes were fine | Bails out |
| Default pick | Yes — harder to get wrong | When you already have a DFS |
T5Minimum spanning tree — connect everything, cheaply
Different question from shortest path: connect all V nodes using V−1 edges of minimum total weight. An MST does not give you shortest paths between pairs — it only minimises the total wiring.
Prim's grows one tree outward with a heap. It is Dijkstra with a one-line change:
push the edge weight instead of distance + weight, because we care about the
cost to attach a node, not the cost to reach it. Prim's is the right pick on dense or
implicit graphs like LC 1584, where all n² edges exist but nobody handed you a list.
import heapq
def min_cost_connect_points(points):
"""Every pair of points is connected, at Manhattan-distance cost."""
num_points = len(points)
in_tree = [False] * num_points
frontier = [(0, 0)] # (cost to attach, point index); node 0 is free
total_cost = 0
attached = 0
while attached < num_points:
cost, node = heapq.heappop(frontier)
# Same stale-entry guard as Dijkstra: we may have queued this point
# several times before it got attached.
if in_tree[node]:
continue
in_tree[node] = True
total_cost += cost
attached += 1
for other_node in range(num_points):
if in_tree[other_node]:
continue
x1, y1 = points[node]
x2, y2 = points[other_node]
manhattan = abs(x1 - x2) + abs(y1 - y2)
# Push the EDGE weight, not cost + manhattan. That single
# difference is what separates Prim's from Dijkstra.
heapq.heappush(frontier, (manhattan, other_node))
return total_cost
Kruskal's attacks it from the other side: sort every edge cheapest-first and take an edge whenever its endpoints are in different components. That membership test is exactly union-find (pattern 17) — Kruskal's is basically “sort the edges, then let union tell you no.”
def kruskal_mst_cost(num_nodes, weighted_edges):
"""weighted_edges is a list of (weight, u, v).
UnionFind is the class from pattern 17."""
weighted_edges.sort() # cheapest edge first
union_find = UnionFind(num_nodes)
total_cost = 0
edges_taken = 0
for weight, u, v in weighted_edges:
# union() returns False when u and v already share a root - taking
# this edge would close a cycle, so skip it.
if union_find.union(u, v):
total_cost += weight
edges_taken += 1
if edges_taken == num_nodes - 1:
break # a spanning tree is complete
return total_cost
Prim's is O(E log V) and prefers dense graphs; Kruskal's is O(E log E) dominated by the sort and prefers sparse edge lists. Either is accepted on LC 1584.
Reading the problem statement
Signals in the wording
- The word cost, time, distance, price attached to an edge → weighted → not plain BFS.
- “before”, “prerequisite”, “depends on”, “order” → topological sort.
- “at most k” attached to hops → Bellman-Ford rounds, or Dijkstra with
(cost, stops, node)state. - “connect all”, “minimum total wire/cable/cost” → MST.
- “minimise the maximum” along a path (LC 1631, 778) → Dijkstra where the
path cost is
max(so_far, edge)instead of a sum. Nothing else changes.
Four templates worth memorising
P1Dijkstra on a grid — LC 1631 / 778
Grids are graphs where the neighbours are computed, not stored. LC 1631 asks for the path whose
largest single step is smallest, so candidate uses max rather
than +.
import heapq
def minimum_effort_path(heights):
rows, cols = len(heights), len(heights[0])
INFINITY = float('inf')
best_effort = [[INFINITY] * cols for _ in range(rows)]
best_effort[0][0] = 0
frontier = [(0, 0, 0)] # (effort so far, row, col)
while frontier:
effort, row, col = heapq.heappop(frontier)
if row == rows - 1 and col == cols - 1:
return effort # popped the target: this value is final
if effort > best_effort[row][col]:
continue # stale entry
for d_row, d_col in ((1, 0), (-1, 0), (0, 1), (0, -1)):
next_row, next_col = row + d_row, col + d_col
if not (0 <= next_row < rows and 0 <= next_col < cols):
continue
step = abs(heights[next_row][next_col] - heights[row][col])
# The cost of a path is its WORST step, not the sum of its steps.
candidate = max(effort, step)
if candidate < best_effort[next_row][next_col]:
best_effort[next_row][next_col] = candidate
heapq.heappush(frontier, (candidate, next_row, next_col))
return 0
P2Course Schedule II — LC 210
LC gives prerequisites[i] = [course, needs_first], so the edge runs
needs_first → course. Reading that backwards produces a reversed order that passes
LC 207 (it only checks for a cycle) and fails LC 210 — a classic silent bug.
from collections import deque
def find_order(num_courses, prerequisites):
neighbors = {course: [] for course in range(num_courses)}
in_degree = [0] * num_courses
for course, needs_first in prerequisites:
neighbors[needs_first].append(course) # needs_first unlocks course
in_degree[course] += 1
queue = deque()
for course in range(num_courses):
if in_degree[course] == 0:
queue.append(course)
order = []
while queue:
course = queue.popleft()
order.append(course)
for unlocked in neighbors[course]:
in_degree[unlocked] -= 1
if in_degree[unlocked] == 0:
queue.append(unlocked)
return order if len(order) == num_courses else []
P3Alien Dictionary — LC 269
The graph is not given; you derive it. Compare adjacent words character by character — the first position where they differ yields exactly one edge, and you must stop there.
from collections import deque
def alien_order(words):
neighbors = {}
in_degree = {}
for word in words:
for char in word:
neighbors.setdefault(char, [])
in_degree.setdefault(char, 0)
for first, second in zip(words, words[1:]):
# "abc" before "ab" is impossible in any alphabet: a prefix must
# come first. This case yields no edge, it yields an error.
if len(first) > len(second) and first.startswith(second):
return ""
for char_a, char_b in zip(first, second):
if char_a != char_b:
neighbors[char_a].append(char_b)
in_degree[char_b] += 1
break # only the FIRST difference tells us anything
queue = deque()
for char in in_degree:
if in_degree[char] == 0:
queue.append(char)
order = []
while queue:
char = queue.popleft()
order.append(char)
for next_char in neighbors[char]:
in_degree[next_char] -= 1
if in_degree[next_char] == 0:
queue.append(next_char)
if len(order) != len(in_degree):
return "" # cycle: the ordering contradicts itself
return "".join(order)
P4Peeling leaves — LC 310 Minimum Height Trees
Kahn's shape on an undirected tree: instead of in-degree 0, strip every node of degree 1, layer by layer. Whatever survives the last peel is the centre — at most two nodes, always.
from collections import deque
def find_min_height_trees(num_nodes, edges):
if num_nodes == 1:
return [0]
neighbors = {node: set() for node in range(num_nodes)}
for u, v in edges:
neighbors[u].add(v)
neighbors[v].add(u)
leaves = deque()
for node in range(num_nodes):
if len(neighbors[node]) == 1:
leaves.append(node)
remaining = num_nodes
while remaining > 2:
# Peel exactly one full layer of leaves per iteration.
layer_size = len(leaves)
remaining -= layer_size
for _ in range(layer_size):
leaf = leaves.popleft()
parent = neighbors[leaf].pop()
neighbors[parent].discard(leaf)
if len(neighbors[parent]) == 1:
leaves.append(parent)
return list(leaves)
Where these go wrong
neighbors[u].append(v) without neighbors[v].append(u). The code runs, the
small test cases often pass, and paths that happen to go “uphill” silently disappear. Decide
whether the graph is directed before you write the build loop, and say it out loud.
if dist_to_node > distance[node]: continue.
cost array you are reading lets one round chain two
or more flights, so you accept routes that exceed k stops. Snapshot the array at the top of
every round and relax from the snapshot.
len(order) != num_nodes is not optional — it is the entire
answer to LC 207. On the DFS side, a plain visited set cannot tell
“this node is on my current path” (a real cycle) from “I finished this node
down another branch” (a harmless diamond), so it reports cycles that do not exist. Three
states, always: UNVISITED, IN_PROGRESS, DONE.
Practice ladder
In this order — easiest first. Tick each one as you solve it, and re-solve anything that took more than 25 minutes two days later.