Pattern 22 · Graphs

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.

time O((V+E) log V)space O(V + E)tools dijkstra · topo · mstpractice 15 problems
01 core concept

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.

build_graph.pypython
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
Why the dict comprehension and not defaultdict
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:

1 1 A -----> B -----> C \ ^ \_______5_________/ BFS from A, counting hops: level 0: A level 1: B, C <-- C is 1 hop away, BFS commits to cost 5 Answer BFS gives: A -> C = 5 Cheapest path: A -> B -> C = 1 + 1 = 2 (two hops, but half the cost)

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.

1 / 9walkthrough — dijkstra from A (each cell holds dist[node])
0A
∞B
∞C
∞D
∞E
heap [(0,A)]
settled { }
Weighted graph: A-B 4, A-C 1, C-B 2, B-D 5, C-D 8, B-E 10, D-E 3. Goal: cheapest cost from A to every node. Each cell is that node's best known distance so far — everything starts at ∞ except the source.
The stale-entry trick
Python's 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.
02 the four tools

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.

dijkstra.pypython
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.

bellman_ford.pypython
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
The k-stops variant needs a frozen copy
In LC 787 each round must represent one more flight. If you relax against the array you are writing into, a single round can chain two flights together and you will under-count stops. Copy the array at the top of each round and read from the copy.
cheapest_k_stops.py — LC 787python
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.

1 / 10walkthrough — kahn's topological sort (each cell holds in-degree)
0
0
1
2
2
1
queue —
order —
Six tasks, edges 0→2, 0→3, 1→3, 2→4, 3→4, 4→5 (arrow means must come first). Cell i holds node i's in-degree — how many prerequisites it is still waiting on. Goal: an order where every node appears after all of its prerequisites.
topo_kahn.pypython
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.

topo_dfs.pypython
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 detectionlen(order) != num_nodeshit an IN_PROGRESS node
Stack riskNone — iterativeRecursion depth O(V)
Lexicographic orderEasy — swap deque for a heapAwkward
Partial progress on a cycleTells you how many nodes were fineBails out
Default pickYes — harder to get wrongWhen 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.

prim_mst.py — LC 1584python
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.”

kruskal_mst.pypython
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.

03 when to use it

Reading the problem statement

+---------------------------------------------------------------------------+ | Edges all cost the same / "fewest moves" --> plain BFS (pattern 04) | | Edges have positive weights, "min cost/time" --> Dijkstra | | Negative weights, or "at most k edges/stops" --> Bellman-Ford | | "Order the tasks" / "prerequisites" / "can I --> topological sort | | finish?" / cycle in a DIRECTED graph | | "Connect all nodes for minimum total cost" --> MST (Prim / Kruskal) | | "Are these two connected?" incremental edges --> union-find (17) | | All-pairs, tiny V (<= ~400) --> Floyd-Warshall | +---------------------------------------------------------------------------+

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.
When NOT to use these
Do not reach for Dijkstra when every edge costs 1 — BFS is simpler, faster, and impossible to get wrong. Do not use topological sort on an undirected graph; in-degree is meaningless there, and undirected cycle detection is union-find's job. And an MST is not a shortest-path tree: the cheapest way to wire up every node routinely gives you a terrible path between two particular nodes.
04 templates

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

minimum_effort_path.py — LC 1631python
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.

course_schedule_ii.py — LC 210python
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.

alien_dictionary.py — LC 269python
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.

min_height_trees.py — LC 310python
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)
05 common mistakes

Where these go wrong

Mistake 1 — adding only one direction of an undirected edge
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.
Mistake 2 — running Dijkstra with negative weights
Dijkstra's correctness rests entirely on “the popped minimum can never improve.” One negative edge breaks that and you get a confidently wrong answer, not a crash. Negative weights mean Bellman-Ford. Note that LC 743's travel times and LC 1631's efforts are always non-negative, which is why Dijkstra is legal there.
Mistake 3 — a visited set instead of the stale check
Marking nodes visited when you push them (rather than when you pop) freezes in the first distance you happened to find, which is usually not the best one. Push freely; filter on pop with if dist_to_node > distance[node]: continue.
Mistake 4 — relaxing in place in the k-stops problem
In LC 787, writing into the same 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.
Mistake 5 — getting the cycle check wrong
Kahn's always returns something: with a cycle it returns a short, partial order that looks perfectly plausible, so 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.
Mistake 6 — confusing an MST with a shortest-path tree
An MST minimises total edge weight across the whole graph; Dijkstra minimises the distance from one source to each node. They are usually different trees. “Connect all the points” is MST; “get from here to there” is Dijkstra.
10 practice

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.

15 problems
easyLC 997
In-degree n-1 and out-degree 0 — degree counting, no traversal.
easyLC 1971
Build the undirected adjacency list, then one BFS. The warm-up for the build step.
mediumLC 207
Kahn's: emit count != numCourses means a cycle.
mediumLC 210
Same as 207 but return the emitted order (or [] on a cycle).
mediumLC 261 ★
Exactly n-1 edges plus connected. Union-find (pattern 17) or one BFS.
LeetCode Premium required
mediumLC 802
Kahn's on the reversed graph starting from out-degree-0 terminals.
mediumLC 743
Textbook Dijkstra; answer is max(dist), or -1 if any node is unreachable.
mediumLC 1514
Dijkstra with a max-heap: negate probabilities and multiply instead of add.
mediumLC 1584
Prim's MST on an implicit complete graph of Manhattan distances.
mediumLC 787
Bellman-Ford, k+1 rounds, relaxing against a frozen copy of last round.
mediumLC 1631
Dijkstra where path cost is max(edge) instead of sum(edge).
mediumLC 310
Peel degree-1 leaves layer by layer — Kahn’s shape on an undirected tree.
hardLC 332
Hierholzer's Eulerian path: post-order append, then reverse.
hardLC 778
Dijkstra on a grid minimising the maximum cell value along the path.
hardLC 269 ★
Derive edges from adjacent word pairs, then topo-sort; watch the prefix trap.
LeetCode Premium required