Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Graph Algorithms

Conceptual Foundation & Intuitive Analogy

🧭 Conceptual Foundation & Pattern Intuition

Graph traversal models arbitrary systems of interconnected entities. A graph consists of vertices (states, grid cells, entities) and edges (dependencies, roads, cardinal adjacencies). Unlike trees, general graphs may contain cycles and disconnected components. Graph algorithms systematically explore reachable states using either Depth-First Search (DFS, diving deeply down one branch using a LIFO stack) or Breadth-First Search (BFS, expanding concentrically outward level by level using a FIFO queue). The cardinal rule of graph traversal is the visited set invariant: every discovered node must be recorded immediately upon discovery to prevent infinite loops on cyclic edges and redundant work across dense connections.

The abstract template above generalizes this to any "count the connected clusters" question: get_neighbours and is_interesting are the only holes a concrete problem needs to fill in. The next archetype, Number of Islands, fills those holes concretely and walks through the exact flood-fill analogy and code this shape produces.

Visual Blueprint & State Transition Model
Graph Algorithms Mental Model Diagram
Figure 1 • Graph Algorithms Core Invariant & State Lifecycle
Core Invariant Specification

Every node is processed exactly once. The visited marker is what turns a possibly-infinite walk into O(V + E).

Boundary Model: Graph Component Traversal & Visited State Machine

Maintains visited set or 3-color states (WHITE=unvisited, GREY=visiting/cycle, BLACK=processed) across adjacency lists.

Loop Invariant Termination

Loop outer vertices 0..V-1 to handle disconnected subgraphs; explore edges via BFS/DFS/PriorityQueue.

The 6 Mental Models of Graph Algorithms

Classify graph problems by connectivity, directionality, and edge weights.

1Flood Fill
2D grid / graph ➔ outer unvisited discovery ➔ consume full component

Connected Components (Number of Islands / Flood Fill)

Spot it when: You count or label separate groups: islands, provinces, regions, rooms you can reach.
Mode 1 · Flood Fill
Python
1from typing import Callable, Dict, Hashable, Iterable, List
2 
3 
4def flood_fill(cells: Iterable[Hashable], is_land: Callable[[Hashable], bool], neighbors: Callable[[Hashable], Iterable[Hashable]]) -> Dict[Hashable, int]:
5 """Flood Fill: label every land cell with the number of its group of connected land
6 cells.
7 Precondition: neighbors is symmetric. If b is a neighbour of a, then a is a
8 neighbour of b.
9 Groups are numbered 0, 1, 2, ... in scan order, so the number of groups is
10 max(group.values()) + 1.
11 V = cells. T = the cost of one is_land or neighbors call (neighbors yields at most T
12 cells).
13 Time: O(V * T) | Space: O(V)
14 """
15 # Maps every already-searched land cell to its group number
16 group: Dict[Hashable, int] = {}
17 count = 0
18 
19 for cell in cells:
20 # 1. Find An Unlabeled Land Cell:
21 # A cell with no group yet and is_land(cell) true has not been reached by any
22 # earlier search, so it starts a brand-new group.
23 if is_land(cell) and cell not in group:
24 # A new group: count grows once per search, not per cell
25 group[cell] = count
26 stack = [cell]
27 
28 # 2. Flood The Whole Group:
29 # The loop keeps running until stack is empty, so every cell reachable from
30 # this one is labeled before the outer loop is allowed to move on.
31 while stack: # Inner search uses up the whole group
32 curr = stack.pop()
33 for nxt in neighbors(curr):
34 if is_land(nxt) and nxt not in group:
35 group[nxt] = count # Label on push, not on pop
36 stack.append(nxt)
37 
38 # 3. Advance The Group Counter:
39 # count only changes once the whole group has been labeled, so the next
40 # brand-new cell starts the next number.
41 count += 1
42 
43 return group

Scan every cell, and when you meet a "1" that no search has reached, add one to islands and flood that cell's whole island with the inner stack search. The inner search turns every connected land cell to "0", so the outer scan never meets that island again and counts it exactly once. The trap is where the counter lives: islands += 1 belongs in the outer scan, once per search, never inside the inner loop, where it would count cells instead of islands.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Adding to islands inside the inner search loop

    Mistake
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
        grid[nr][nc] = "0"
        islands += 1
        stack.append((nr, nc))
    Fix
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
        grid[nr][nc] = "0"
        stack.append((nr, nc))

    What happens: The outer scan adds 1 to islands for the first cell of an island. The inner loop then adds 1 again for every other cell it reaches. So islands ends as the number of land cells, not the number of islands. islands += 1 belongs in the outer scan, once per new search.

    Example: Number of Islands (LC 200): grid = [["1", "1"], ["0", "1"]]
    Mistake: returns 3Fix: returns 1
  2. 2

    Gotcha 2: Marking a cell visited when it is popped, not pushed

    Mistake
    curr_r, curr_c = stack.pop()
    grid[curr_r][curr_c] = "0"
    for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
        nr, nc = curr_r + dr, curr_c + dc
        if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
            stack.append((nr, nc))
    Fix
    curr_r, curr_c = stack.pop()
    for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
        nr, nc = curr_r + dr, curr_c + dc
        if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
            grid[nr][nc] = "0"
            stack.append((nr, nc))

    What happens: A land cell stays "1" until it is popped. Before that, each of its land neighbours can push it again. So the same cell sits on stack several times. The count of islands is still right, but the search does extra work. That is why this mistake has no example.

  3. 3

    Gotcha 3: Reading grid[nr][nc] before checking the bounds

    Mistake
    if grid[nr][nc] == "1" and 0 <= nr < R and 0 <= nc < C:
    Fix
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":

    What happens: and checks its parts from left to right. Here grid[nr][nc] is read first. From a cell on the bottom row, the step down gives nr = R, and grid[R] does not exist. Python raises an IndexError. A negative nr causes a different problem. grid[-1] silently reads the last row.

    Example: grid = [["1"]]
    Mistake: raises IndexErrorFix: returns 1
  4. 4

    Gotcha 4: Searching an island with a recursive dfs

    Mistake
    def dfs(r, c):
        if 0 <= r < R and 0 <= c < C and grid[r][c] == "1":
            grid[r][c] = "0"
            for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                dfs(r + dr, c + dc)
    Fix
    stack = [(r, c)]
    grid[r][c] = "0"

    What happens: Each call of a recursive dfs waits for the calls it makes. Number of Islands (LC 200) allows grids of 300 x 300 cells. On a 40 x 40 island of land, the chain of nested calls is already deeper than 1000. Python's default recursion limit is 1000, so it raises a RecursionError. The blueprint's stack list has no such limit.

    Example: grid = 40 rows of 40 "1" cells (one island)
    Mistake: raises RecursionErrorFix: returns 1
Canonical LeetCode:Number of Islands (LC 200)Number of Provinces (LC 547)Flood Fill (LC 733)Surrounded Regions (LC 130)Pacific Atlantic Water Flow (LC 417)Number of Distinct Islands (LC 694)Keys and Rooms (LC 841)Max Area of Island (LC 695)Number of Connected Components in an Undirected Graph (LC 323)
Open the Flood Fill card
2Kruskal's MST
edge list sorted by weight ➔ union by rank/size ➔ connect components

Minimum Spanning Tree (Kruskal's, built on Union-Find)

Spot it when: You must connect all points, cities or computers at the lowest total edge cost, with every node reachable in the end.
Mode 2 · Kruskal's MST
Python
1from typing import List
2 
3def kruskal_mst(n: int, edges: List[List[int]]) -> int:
4 """Kruskal's MST (Connecting Cities With Minimum Cost): cheapest total cost to join
5 nodes 1..n, or -1.
6 Time: O(E log E) | Space: O(V)
7 """
8 parent = list(range(n + 1))
9 
10 def find(x: int) -> int:
11 while parent[x] != x:
12 parent[x] = parent[parent[x]] # Path halving: skip a level on every step
13 x = parent[x]
14 return x
15 
16 edges.sort(key=lambda e: e[2]) # Cheapest edges first
17 mst_cost = 0
18 edges_count = 0
19 
20 for u, v, w in edges:
21 root_u, root_v = find(u), find(v)
22 if root_u != root_v: # Different groups: this edge is safe to take
23 parent[root_u] = root_v # Join the roots, not the nodes
24 mst_cost += w
25 edges_count += 1
26 if edges_count == n - 1:
27 break # A spanning tree has exactly n - 1 edges
28 
29 return mst_cost if edges_count == n - 1 else -1 # Fewer edges: not connected

Sort edges by weight and take each edge whose ends have different roots, find(u) != find(v), joining the two groups. The cheapest edge between two separate groups is always safe to take: any full wiring must cross that gap somewhere, and swapping in the cheaper edge never costs more; an edge whose ends already share a root would only close a cycle, so skipping it loses nothing. The trap is the final check: fewer than n - 1 edges taken means the graph is not connected, so return -1 instead of the partial cost.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Returning mst_cost without checking edges_count

    Mistake
    return mst_cost
    Fix
    return mst_cost if edges_count == n - 1 else -1

    What happens: A tree that joins n nodes has exactly n - 1 edges. If the edges run out with fewer, some cities are still in separate groups. mst_cost is then the cost of a partial wiring. The problem asks for -1 in that case.

    Example: Connecting Cities With Minimum Cost (LC 1135): n = 4, connections = [[1, 2, 3], [3, 4, 4]]
    Mistake: returns 7Fix: returns -1
  2. 2

    Gotcha 2: Writing find without path halving

    Mistake
    while parent[x] != x:
        x = parent[x]
    Fix
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]

    What happens: Without parent[x] = parent[parent[x]], the parent links are never shortened. The joins can build one long chain. Each find then walks the whole chain, which is O(n) per call. The answer stays the same, so this mistake has no example. Union by size keeps the trees short too.

  3. 3

    Gotcha 3: Sorting the edges by e[0] instead of the weight e[2]

    Mistake
    edges.sort(key=lambda e: e[0])
    Fix
    edges.sort(key=lambda e: e[2])

    What happens: Each edge is [u, v, w], so e[0] is a city number. The greedy step is only safe when the cheapest edges come first. Sorted by city, an expensive edge can join two groups before a cheap one. The total cost is then too high.

    Example: n = 3, connections = [[1, 2, 5], [1, 3, 6], [2, 3, 1]]
    Mistake: returns 11Fix: returns 6
  4. 4

    Gotcha 4: Joining the nodes parent[u] = root_v instead of the roots

    Mistake
    parent[u] = root_v
    Fix
    parent[root_u] = root_v

    What happens: u may not be the root of its group. Setting parent[u] moves only u, and the part of the group below u. The rest of the group keeps its old root. Two groups that were joined can then look separate. So an edge inside one group is taken, and it closes a cycle. It still counts toward n - 1, so the loop can stop before every city is joined.

    Example: n = 4, connections = [[1, 2, 6], [2, 3, 1], [2, 4, 3], [3, 4, 4]]
    Mistake: returns 8Fix: returns 10
Canonical LeetCode:Min Cost to Connect All Points (LC 1584)Connecting Cities With Minimum Cost (LC 1135)Optimize Water Distribution in a Village (LC 1168)Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree (LC 1489)
Open the Kruskal's MST card
3Dijkstra
weighted directed graph ➔ min-heap (dist, node) ➔ greedy relaxation

Dijkstra's Algorithm

Spot it when: Edges have non-negative weights and you need the cheapest path, or the path whose largest step is smallest (effort, water level).
Mode 3 · Dijkstra
Python
1import heapq
2from typing import List, Dict
3 
4def dijkstra(n: int, edges: List[List[int]], source: int) -> Dict[int, float]:
5 """Dijkstra (the core of Network Delay Time): cheapest distance from source to nodes
6 1..n.
7 Time: O((V + E) log V) | Space: O(V + E)
8 """
9 adj = [[] for _ in range(n + 1)]
10 for u, v, w in edges:
11 adj[u].append((v, w))
12 
13 dist = {i: float('inf') for i in range(1, n + 1)}
14 dist[source] = 0
15 pq = [(0, source)] # (cost, node)
16 
17 while pq:
18 d, u = heapq.heappop(pq)
19 if d > dist[u]:
20 continue # Stale entry: u was already settled with a smaller d
21 
22 for v, weight in adj[u]:
23 if dist[u] + weight < dist[v]: # Relax the edge
24 dist[v] = dist[u] + weight
25 heapq.heappush(pq, (dist[v], v))
26 
27 return dist

Keep a min-heap pq of (distance, node) pairs. Popping the smallest d settles u, because with non-negative weights any other path to u leaves the settled area through a node that is at least as far away, so it can't be shorter. For each edge, if going through u is shorter, update dist[v] and push the new pair. The trap is the stale entry: u can sit in pq several times, so skip it when d > dist[u]; without that line the answer stays right, but old entries relax every edge again and the run can slow down badly.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Relaxing edges from a stale heap entry

    Mistake
    d, u = heapq.heappop(pq)
    Fix
    d, u = heapq.heappop(pq)
    if d > dist[u]:
        continue

    What happens: u is pushed again every time dist[u] improves. So pq can hold old pairs for u with a larger d. Without the check, an old pair relaxes every edge of u again. The answer stays right, because an old d never improves dist[v]. But the run can do much more work. That is why this mistake has no example.

  2. 2

    Gotcha 2: Relaxing with <= instead of <

    Mistake
    if dist[u] + weight <= dist[v]:
    Fix
    if dist[u] + weight < dist[v]:

    What happens: Network Delay Time (LC 743) allows a weight of 0. With <=, a path of equal length counts as an improvement. Two nodes joined by 0-weight edges then push each other again and again. pq never empties, so the loop never ends.

    Example: n = 2, edges = [[1, 2, 0], [2, 1, 0]], source = 1
    Mistake: the loop never endsFix: returns {1: 0, 2: 0}
  3. 3

    Gotcha 3: Pushing a neighbour even when dist[v] did not improve

    Mistake
    if dist[u] + weight < dist[v]:
        dist[v] = dist[u] + weight
    heapq.heappush(pq, (dist[v], v))
    Fix
    if dist[u] + weight < dist[v]:
        dist[v] = dist[u] + weight
        heapq.heappush(pq, (dist[v], v))

    What happens: A pair is useful only when it carries a new, smaller dist[v]. A push without an improvement adds a pair that the stale check will skip. The answer stays right, but pq grows with pairs that can never win. That is why this mistake has no example.

  4. 4

    Gotcha 4: Running Dijkstra on a graph with negative weights

    What happens: Dijkstra treats a popped node as settled. A negative edge can later give that node a smaller distance. This version pushes the node again, so the same node can be popped and relaxed many times. A cycle with a negative total makes the loop never end. For negative weights, use Bellman-Ford.

Canonical LeetCode:Network Delay Time (LC 743)Path With Minimum Effort (LC 1631)Swim in Rising Water (LC 778)Number of Ways to Arrive at Destination (LC 1976)The Maze II (LC 505)
Open the Dijkstra card
4Eulerian Path
edges ➔ walk and delete each used edge ➔ nodes appended when stuck, then reversed

Eulerian Path (Hierholzer's Algorithm)

Spot it when: Every edge (a ticket, a domino, a pair, a transition) must be used exactly once in one walk, or you need the shortest string that contains every combination as a substring.
Mode 4 · Eulerian Path
Python
1from collections import defaultdict
2from typing import Hashable, List
3 
4def eulerian_path(edges: List[List[Hashable]]) -> List[Hashable]:
5 """Eulerian Path (Hierholzer's algorithm, the core of Reconstruct Itinerary): the
6 smallest walk that uses every directed edge exactly once.
7 Time: O(E log E) | Space: O(E)
8 """
9 graph = defaultdict(list)
10 balance = defaultdict(int) # out-degree minus in-degree
11 # reverse order, so pop() takes the smallest neighbour
12 for u, v in sorted(edges, reverse=True):
13 graph[u].append(v)
14 balance[u] += 1
15 balance[v] -= 1
16 # one extra edge out starts the path; a circuit starts at the smallest node
17 start = next((x for x in graph if balance[x] == 1), min(graph))
18 path, stack = [], [start]
19 while stack:
20 node = stack[-1]
21 if graph[node]:
22 stack.append(graph[node].pop()) # follow an unused edge and delete it
23 else:
24 # no edge left: node is final in the rest of the walk
25 path.append(stack.pop())
26 return path[::-1] # nodes were finished last-first

Walk from start, deleting each edge as you follow it with stack.append(graph[node].pop()), and add a node to path only when it has no unused edge left, with path.append(stack.pop()); reversing path gives the walk. This is correct because a walk that always takes some unused edge can only get stuck at the node where the whole path must end, so the first node to run out of edges is the last node of the answer; any cycle the walk skipped hangs off a node that is still on stack, and it is walked, and placed, when the stack backs up to that node. The trap is writing a node into the answer when you first reach it: the walk can enter the final dead end before a cycle has been used (in Reconstruct Itinerary (LC 332), taking the smallest airport first can strand the tickets of a loop), so a node goes into path only once its edges are used up, and the list is reversed at the end.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Writing each node into the answer when first reached

    Mistake
    path.append(node)
    while graph[node]:
        node = graph[node].pop()
        path.append(node)
    Fix
    if graph[node]:
        stack.append(graph[node].pop())
    else:
        path.append(stack.pop())

    What happens: The walk can enter the final dead end before a cycle has been used. Written in the order reached, the answer stops at that dead end. The cycle's edges are never used. The blueprint adds a node to path only when its edges are used up, then reverses path.

    Example: Reconstruct Itinerary (LC 332): tickets = [["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"]]
    Mistake: returns ['JFK', 'KUL']Fix: returns ['JFK', 'NRT', 'JFK', 'KUL']
  2. 2

    Gotcha 2: Starting at the smallest node instead of the unbalanced one

    Mistake
    start = min(graph)
    Fix
    start = next((x for x in graph if balance[x] == 1), min(graph))

    What happens: A path that is not a circuit must start at the node with one more edge out than in, balance[x] == 1. That node has one extra edge out, so the walk must leave it first. Started anywhere else, the walk gets stuck before it uses every edge. Only when every node is balanced is the path a circuit. Then it starts at the smallest node, min(graph).

    Example: edges = [[1, 0], [0, 2]]
    Mistake: returns [0, 2]Fix: returns [1, 0, 2]
  3. 3

    Gotcha 3: Sorting the edges ascending, so pop() takes the largest

    Mistake
    for u, v in sorted(edges):
    Fix
    for u, v in sorted(edges, reverse=True):

    What happens: pop() takes the last item of a list in O(1). Sorted in reverse, the last item is the smallest neighbour. Sorted ascending, pop() takes the largest neighbour instead. The walk still uses every edge, but it is not the smallest walk. pop(0) would take the smallest, but it costs O(E) per step.

    Example: tickets = [["JFK", "SFO"], ["JFK", "ATL"], ["SFO", "ATL"], ["ATL", "JFK"], ["ATL", "SFO"]]
    Mistake: returns ['JFK', 'SFO', 'ATL', 'JFK', 'ATL', 'SFO']Fix: returns ['JFK', 'ATL', 'JFK', 'SFO', 'ATL', 'SFO']
  4. 4

    Gotcha 4: Returning path without reversing it

    Mistake
    return path
    Fix
    return path[::-1]

    What happens: A node goes into path when it has no edge left. The final node of the walk runs out first. So path holds the walk from its end back to its start. path[::-1] puts it in order.

    Example: tickets = [["MUC", "LHR"], ["JFK", "MUC"], ["SFO", "SJC"], ["LHR", "SFO"]]
    Mistake: returns ['SJC', 'SFO', 'LHR', 'MUC', 'JFK']Fix: returns ['JFK', 'MUC', 'LHR', 'SFO', 'SJC']
Canonical LeetCode:Reconstruct Itinerary (LC 332)Valid Arrangement of Pairs (LC 2097)Cracking the Safe (LC 753)
Open the Eulerian Path card
5Floyd-Warshall
distance table ➔ allow one more middle node per round ➔ all shortest distances

Floyd-Warshall (All-Pairs Shortest Path)

Spot it when: You need the shortest distance, or just reachability, between every pair of nodes (or answers to many pair queries), and the graph is small: a few hundred nodes at most.
Mode 5 · Floyd-Warshall
Python
1from typing import List
2 
3def floyd_warshall(n: int, edges: List[List[int]]) -> List[List[float]]:
4 """Floyd-Warshall (the core of Course Schedule IV): shortest distance between every
5 pair of nodes.
6 Time: O(V^3) | Space: O(V^2)
7 """
8 dist = [[float("inf")] * n for _ in range(n)]
9 for i in range(n):
10 dist[i][i] = 0 # a node reaches itself at distance 0
11 for u, v, w in edges:
12 # directed edge u -> v; keep the lighter of two parallel edges
13 dist[u][v] = min(dist[u][v], w)
14 for k in range(n): # k OUTERMOST: allow node k as a stop
15 for i in range(n):
16 for j in range(n):
17 if dist[i][k] + dist[k][j] < dist[i][j]:
18 dist[i][j] = dist[i][k] + dist[k][j] # go through k
19 return dist

Allow the nodes as stops one at a time: for each k, let every pair i, j go through k when dist[i][k] + dist[k][j] < dist[i][j]. This is correct because, once k is allowed, the shortest i -> j path with stops in 0..k either avoids k, and is already in dist[i][j], or passes through k once and splits into an i -> k part and a k -> j part whose stops lie in 0..k - 1, and both of those are already in the table. The trap is the loop order: for k in range(n) must be the outermost loop. With k inside the i and j loops, dist[i][k] is read before the paths through the smaller stops have been allowed, and some pairs keep a distance that is too long, or no path at all.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Putting for k in range(n) inside the i and j loops

    Mistake
    for i in range(n):
        for j in range(n):
            for k in range(n):
    Fix
    for k in range(n):
        for i in range(n):
            for j in range(n):

    What happens: With k innermost, the pair (i, j) is finished before the other pairs are updated. dist[i][k] may still miss a path through a smaller stop. So some pairs keep a distance that is too long, or no path at all.

    Example: n = 4, edges = [[0, 3, 1], [3, 2, 1], [2, 1, 1]], read dist[0][1]
    Mistake: returns infFix: returns 3
  2. 2

    Gotcha 2: Using INT_MAX as "no path" in Java or C++

    What happens: In Python, float("inf") + 1 is still inf, so the blueprint is safe. In Java or C++, INT_MAX + 1 overflows to a negative number. dist[i][k] + dist[k][j] then looks shorter than any real path. Use a value such as INT_MAX / 2, or skip pairs where either part is unreached.

  3. 3

    Gotcha 3: Storing edge [u, v] as dist[v][u]

    Mistake
    dist[v][u] = min(dist[v][u], w)
    Fix
    dist[u][v] = min(dist[u][v], w)

    What happens: In Course Schedule IV (LC 1462), the pair [ai, bi] means course ai comes before course bi. So the edge goes from u to v, and dist[u][v] gets its weight. Storing it as dist[v][u] reverses every edge. Every query is then answered backwards.

    Example: n = 2, edges = [[1, 0, 1]], read dist[1][0]
    Mistake: returns infFix: returns 1
  4. 4

    Gotcha 4: Writing dist[u][v] = w for parallel edges

    Mistake
    dist[u][v] = w
    Fix
    dist[u][v] = min(dist[u][v], w)

    What happens: Two edges can join the same pair of nodes. dist[u][v] = w keeps whichever edge comes last. If the lighter edge came first, it is lost.

    Example: n = 2, edges = [[0, 1, 2], [0, 1, 5]], read dist[0][1]
    Mistake: returns 5Fix: returns 2
Canonical LeetCode:Find the City With the Smallest Number of Neighbors at a Threshold Distance (LC 1334)Course Schedule IV (LC 1462)Minimum Cost to Convert String I (LC 2976)
Open the Floyd-Warshall card
6Tarjan's Bridges
DFS tree ➔ discovery time and low-link per node ➔ bridge when low[child] > disc[parent]

Tarjan's Bridges & Articulation Points

Spot it when: You must find the edges (or nodes) whose removal splits an undirected graph into more pieces: critical links, single points of failure, cells whose loss disconnects a region.
Mode 6 · Tarjan's Bridges
Python
1from typing import List
2 
3def find_bridges(n: int, edges: List[List[int]]) -> List[List[int]]:
4 """Tarjan's Bridges (the core of Critical Connections in a Network): every edge
5 whose removal disconnects the graph.
6 Time: O(V + E) | Space: O(V + E)
7 """
8 graph = [[] for _ in range(n)]
9 for i, (u, v) in enumerate(edges):
10 graph[u].append((v, i))
11 graph[v].append((u, i))
12 # discovery time; -1 = not reached yet
13 disc = [-1] * n
14 # earliest disc the subtree reaches with one back edge
15 low = [0] * n
16 bridges = []
17 time = 0
18 for root in range(n):
19 if disc[root] != -1:
20 continue
21 disc[root] = low[root] = time
22 time += 1
23 # (node, edge id we came in by, next neighbour index)
24 stack = [(root, -1, 0)]
25 while stack:
26 node, in_edge, idx = stack[-1]
27 if idx < len(graph[node]):
28 stack[-1] = (node, in_edge, idx + 1)
29 nxt, edge = graph[node][idx]
30 if edge == in_edge:
31 continue # not the tree edge back to the parent
32 if disc[nxt] == -1:
33 disc[nxt] = low[nxt] = time # tree edge: go deeper
34 time += 1
35 stack.append((nxt, edge, 0))
36 else:
37 # back edge: reaches an earlier node
38 low[node] = min(low[node], disc[nxt])
39 else:
40 stack.pop() # node is finished
41 if stack:
42 parent = stack[-1][0]
43 low[parent] = min(low[parent], low[node])
44 # nothing below node climbs to parent or above
45 if low[node] > disc[parent]:
46 bridges.append([parent, node])
47 return bridges

Number the nodes in DFS order with disc, and let low[node] be the earliest disc that node's subtree can reach through one edge other than the tree edge it came in by; when node finishes, the tree edge parent -> node is a bridge exactly when low[node] > disc[parent]. This is correct because any other route between the two sides of that edge has to leave node's subtree by some edge, and an undirected DFS has no edges across subtrees, so that edge climbs to parent or above and would make low[node] <= disc[parent]. The trap is the comparison: bridges need the strict low[node] > disc[parent]. With >=, which is the test for articulation points, an edge whose subtree climbs back exactly to parent (so it sits on a cycle) is wrongly reported.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Testing low[node] >= disc[parent] instead of >

    Mistake
    if low[node] >= disc[parent]:
    Fix
    if low[node] > disc[parent]:

    What happens: low[node] == disc[parent] means the subtree of node climbs back exactly to parent. So the edge parent -> node lies on a cycle, and removing it disconnects nothing. >= reports it anyway. >= is the test for articulation points, not for bridges.

    Example: Critical Connections in a Network (LC 1192): n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns [[1, 3], [0, 1]]Fix: returns [[1, 3]]
  2. 2

    Gotcha 2: Writing the search as a recursive dfs

    Mistake
    def dfs(node, in_edge):
        ...
        dfs(nxt, edge)
    Fix
    stack = [(root, -1, 0)]

    What happens: A recursive dfs makes one nested call per node on the current path. Critical Connections in a Network (LC 1192) allows 10^5 servers in one chain. Python's default recursion limit is 1000. A chain of 2000 servers already raises a RecursionError. The blueprint keeps its path in stack, which has no such limit.

    Example: n = 2000 servers in one chain: [[0, 1], [1, 2], ..., [1998, 1999]]
    Mistake: raises RecursionErrorFix: returns 1999
  3. 3

    Gotcha 3: Following the edge back to the parent

    Mistake
    nxt, edge = graph[node][idx]
    Fix
    nxt, edge = graph[node][idx]
    if edge == in_edge:
        continue

    What happens: The tree edge parent -> node is also in graph[node]. If it is followed back, node reaches parent through it. So low[node] <= disc[parent] always holds, and no bridge is ever found. The check skips that one edge by its id, in_edge. Two parallel edges have different ids, so the second parallel edge is still followed as a back edge.

    Example: n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns []Fix: returns [[1, 3]]
  4. 4

    Gotcha 4: Not passing low[node] up to the parent

    Mistake
    parent = stack[-1][0]
    Fix
    parent = stack[-1][0]
    low[parent] = min(low[parent], low[node])

    What happens: low[parent] must learn how high the subtree of node can climb. Without this line, low[parent] sees only the back edges of parent itself. An edge above a cycle then looks like a bridge.

    Example: n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns [[1, 3], [0, 1]]Fix: returns [[1, 3]]
Canonical LeetCode:Critical Connections in a Network (LC 1192)Minimum Number of Days to Disconnect Island (LC 1568)
Open the Tarjan's Bridges card
Built on the mental models above
  • Cycle DetectionCycle Detection in a Directed Graph (DFS Three-Color)

    A node has three states, not seen, on the current path and finished, and reaching a node on the current path proves a cycle.

  • Strongly Connected ComponentsStrongly Connected Components (Kosaraju / Tarjan)

    Record the finish order, then search the reversed graph in reverse finish order, and each search finds one group.

  • Bipartite CheckBipartite Graph (Two-Coloring)

    Each node gets one of two sides, a neighbour gets the other side, and an edge inside one side means the answer is no.

  • Undirected CycleCycle Detection in an Undirected Graph (Parent Check)

    The search remembers the node it came from, and reaching an already-seen node that is not that parent proves a cycle.

  • Graph PathsTraverse an Adjacency List and Carry a Result (All Paths From Source to Target)

    The graph is an adjacency list, and the walk carries a path that it extends, copies at the target and restores. No visited set: every path counts.

  • Bellman-FordBellman-Ford (K-Stop Relaxation)

    Edges are relaxed in rounds instead of popping the cheapest node, so a path is limited to a number of edges and negative weights are allowed.

  • A Star SearchA* Search (Cost So Far Plus a Guess)

    The heap orders states by the cost so far plus a guess of the cost still to go, and the guess must never be too large.

  • Tarjan SCCTarjan's Strongly Connected Components (One Pass with Low-Link)

    The same discovery time and low-link run on a directed graph with a stack, and a node whose low-link equals its own discovery time is the root of one group.

The Graphs Decision Rule

When facing an unseen graph problem, ask in this order:

Step 1.Does the problem ask for connected component count or flood filling on a grid?
➔ Flood Fill
Number of Islands (LC 200)Number of Provinces (LC 547)

Scan every cell or node; each unvisited one starts a new group, and a DFS or BFS marks its whole group visited.

Step 2.Must nodes be split into two groups so every edge runs between them, or must you detect an odd cycle in an undirected graph?
➔ Bipartite Check
Is Graph Bipartite? (LC 785)Possible Bipartition (LC 886)

Color each component by BFS or DFS, alternating colors; an edge between two nodes of the same color means it is not bipartite.

Step 3.Does the problem require ordering tasks with prerequisites?
➔ Topological Sort
Course Schedule II (LC 210)Course Schedule (LC 207)

Build graph and run Kahn's algorithm (in_degree == 0 queue).

Step 4.Must you detect a cycle in a directed graph, or find the nodes that can't reach one?
➔ Cycle Detection
Course Schedule (LC 207)Find Eventual Safe States (LC 802)

Color nodes WHITE, GREY while on the DFS path and BLACK when finished; reaching a GREY node means a cycle.

Step 5.Does the problem ask for the cheapest / shortest path with non-negative edge weights?
➔ Dijkstra
Network Delay Time (LC 743)Path With Minimum Effort (LC 1631)

Use min-heap with distance relaxation and stale-entry guard.

Step 6.Is there one start and one known goal, and can you compute for any state a guess of the cost still to go that is never too large?
➔ A Star Search
Shortest Path in Binary Matrix (LC 1091)Sliding Puzzle (LC 773)

Order the heap by cost + guess(state), skip a stale entry, and check the goal when it is popped, not when it is pushed.

Step 7.Is the number of edges on the path capped (at most k stops), or can some edge weights be negative?
➔ Bellman-Ford
Cheapest Flights Within K Stops (LC 787)Minimum Cost to Reach Destination in Time (LC 1928)

Relax every edge k + 1 times, each round reading only the previous round's distances.

Step 8.Do you need the distance between every pair of nodes of a small graph (a few hundred nodes)?
➔ Floyd-Warshall
Find the City With the Smallest Number of Neighbors at a Threshold Distance (LC 1334)Course Schedule IV (LC 1462)

dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]), with k as the outer loop.

Step 9.Must every node be connected at the lowest total edge cost?
➔ Kruskal's MST
Min Cost to Connect All Points (LC 1584)Connecting Cities With Minimum Cost (LC 1135)

Sort edges by weight and keep each edge whose ends Union-Find reports as not yet connected.

Step 10.Must you find the edges (or nodes) whose removal splits an undirected graph?
➔ Tarjan's Bridges
Critical Connections in a Network (LC 1192)Minimum Number of Days to Disconnect Island (LC 1568)

DFS with discovery times and low-links; edge (u, v) is a bridge when low[v] > disc[u].

Step 11.Must you find the groups of a directed graph where every node reaches every other?
➔ Strongly Connected Components
Planets and Kingdoms (CSES 1683)Flight Routes Check (CSES 1682)

Record DFS finish order, reverse every edge, then DFS in reverse finish order; each tree is one group.

Step 12.Must you group the nodes of a directed graph in one search over the original edges, with no reversed copy of the graph?
➔ Tarjan SCC
Flight Routes Check (CSES 1682)Planets and Kingdoms (CSES 1683)

Run one DFS with discovery times, low-links and a stack of waiting nodes; pop a group whenever a node's low-link equals its discovery time.

Step 13.Must every edge be used exactly once in one walk?
➔ Eulerian Path
Reconstruct Itinerary (LC 332)Valid Arrangement of Pairs (LC 2097)

Walk unused edges until stuck, add the stuck node to the route on the way back, then reverse the route.

Step 14.Must you tell whether an undirected graph has a cycle, or whether it is a tree (connected with no cycle)?
➔ Undirected Cycle
Graph Valid Tree (LC 261)Redundant Connection (LC 684)

Search from a node and remember the node it came from; an edge to a seen node that is not that parent closes a cycle, and a tree also needs every node reached.

Step 15.Must you list every path between two nodes of a graph with no cycle, or carry a value from the start node down every path?
➔ Graph Paths
All Paths From Source to Target (LC 797)Time Needed to Inform All Employees (LC 1376)

Walk the adjacency list with one shared path: append before the call, pop after it, save a copy at the target, and keep no visited set.

Pattern Architecture & Invariant Design Masterclass

A graph is the most general shape data comes in: a set of nodes, and a set of connections between them. That is all. There is no root, there is no "first" node, and — the part that changes everything — a path can lead you back to where you started.

Trees are graphs with the loops removed and a root chosen. Every technique in dfs.md and bfs.md still applies, plus one mandatory addition:

You must remember where you have already been. Without a visited set, a single cycle makes the traversal run forever.

Three sub-problems cover almost every graph question in an interview, and telling them apart is the actual skill:

  • "How many separate pieces are there?" → Traverse from every unvisited node; each traversal you start is one new piece.
  • "In what order can I do these tasks, given that some must come before others?" → Topological sort. Only possible when the graph has no cycles.
  • "What is the cheapest route, when steps cost different amounts?" → Dijkstra. BFS is wrong here, and knowing why is the point.

🔬 Variable Anatomy: The 7 Variables Every Graph Algorithm Uses

To connect abstract graph concepts with concrete Python syntax, here is the universal code anatomy showing what every variable does:

Code / Blueprint
from collections import defaultdict, deque
import heapq
 
def graphMasterTemplate(
num_nodes: int,
edges: list[list[int]],
source: int
) -> dict[int, int]:
# =========================================================================
# 1. THE TOPOLOGY & STATE STORAGE POOLS
# =========================================================================
adj = defaultdict(list) # 🕸️ Graph Adjacency List: node -> list of (neighbor, weight)
in_degree = [0] * num_nodes # 📥 Prerequisite Counter: tracks incoming DAG(Directed Acyclic Graph) edges
visited = set() # 🚫 Visited Deduplication Set: prevents infinite cycles
dist = {source: 0} # ⏱️ Shortest Distance Table: tracks minimal cost to each node
# =========================================================================
# 2. THE GRAPH BUILD PHASE
# =========================================================================
for u, v, w in edges:
adj[u].append((v, w))
in_degree[v] += 1 # In-degree populated for topological sorting
 
# =========================================================================
# 3. FRONTIER PROCESSING PIPELINE (DIJKSTRA / BFS / KAHN'S)
# =========================================================================
# Min-heap priority queue storing (current_distance, node_id)
min_heap = [(0, source)]
 
while min_heap:
d, u = heapq.heappop(min_heap)
 
# ⚠️ CRITICAL: Stale-entry guard for lazy Dijkstra heap relaxation
if d > dist.get(u, float('inf')):
continue
 
visited.add(u)
 
for v, weight in adj[u]:
new_dist = d + weight
if new_dist < dist.get(v, float('inf')):
dist[v] = new_dist
heapq.heappush(min_heap, (new_dist, v)) # ⚡ Edge relaxation
 
return dist
 
Variable Role Reference:
VariableRole in CodeQuestion It Answers❌ What Happens If Omitted or Mismanaged?
adjdefaultdict(list)"Who are my direct outgoing neighbors and edge weights?"❌ Without an adjacency map, querying neighbors requires scanning the entire edge list on every step (O(V⋅E)O(V \cdot E)O(V⋅E)).
visitedHash set / boolean table"Have I already scheduled or processed this vertex?"❌ Without visited, traversing a cyclic graph enters an infinite loop and crashes with call-stack/memory overflow.
in_degreeInteger array / map"How many unmet prerequisites block this task from executing?"❌ In Topological Sort (Kahn's), without tracking in-degree, dependent tasks execute prematurely out of topological order.
queue / stackdeque or list"Which node is next in line for BFS level-order or DFS depth traversal?"❌ Using list.pop(0) turns O(V+E)O(V + E)O(V+E) BFS into an unacceptable O(V2)O(V^2)O(V2) bottleneck.
distDistance lookup table"What is the absolute cheapest cost known to reach vertex vvv?"❌ Without maintaining a distance table, Dijkstra's algorithm cannot determine if an edge represents a valid relaxation.
min_heapPriority queue [(d, u)]"Which unfinalized node currently has the globally smallest provisional distance?"❌ Using a standard FIFO queue on weighted graphs produces incorrect non-shortest paths.
componentsInteger accumulator"How many isolated, disconnected clusters exist across the graph?"❌ Incrementing components inside the inner flood-fill traversal counts individual vertices instead of disjoint components.

🧠 The Universal Representation Contract:
In graph algorithms, graph structure is represented in two standard ways:

  • Explicit Adjacency List (adj[u] = [v1, v2]): Used for sparse node-and-edge graphs (e.g. Course Schedule, Network Delay). Built once in O(V+E)O(V + E)O(V+E) before traversal.
  • Implicit Coordinate Grids (dr, dc = [(-1,0), (1,0), (0,-1), (0,1)]): Used for 2D matrices (e.g. Number of Islands, Walls and Gates). Neighbors are dynamically computed on-the-fly without allocating adjacency lists.

🌟 The 3 Dimensions of Graph Algorithms & The Cycle-Breaker Principle

Every graph problem is governed by 3 Fundamental Dimensions:

Code / Blueprint
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE 3 DIMENSIONS OF GRAPH ALGORITHMS │
├───────────────────┬───────────────────────────────────┬─────────────────────┤
│ Dimension │ Mechanism in Code │ Question It Answers │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 1. FRONTIER ORDER │ Queue (BFS), Stack (DFS), │ "Which node gets │
│ (Discipline) │ or Min-Heap (Dijkstra) │ processed next?" │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 2. EDGE DYNAMICS │ Unweighted (step count), │ "How is transition │
│ (Weights/Dir) │ Directed DAG(Directed Acyclic Graph), or Weighted edges │ cost evaluated?" │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 3. CYCLE CONTROL │ `visited` set, in-degree counter, │ "How do we prevent │
│ (Loop Breaker) │ or 3-color state machine │ infinite loops?" │
└───────────────────┴───────────────────────────────────┴─────────────────────┘
 
How this plays out in actual problems:
🟢 Scenario A: Connected Components & Grid Flood Fill (Using Outer Scan + Sink)
  • In connected component problems (like Number of Islands), we want to count separate isolated clusters.
  • The Rule: The outer loop scans every cell. When it hits an unvisited land cell, increment components += 1 and launch a traversal to sink the entire island so the outer loop never sees it again.
  • The Code: if grid[r][c] == '1': count += 1; dfs(r, c)
🔵 Scenario B: Topological Sort on DAGs(Directed Acyclic Graphs) (Using In-Degree Reduction)
  • In dependency resolution (like Course Schedule II), tasks must be ordered such that all prerequisites precede dependent tasks.
  • The Rule: Only enqueue nodes whose in_degree == 0. When a node is popped, decrement in_degree[v] for all neighbors, and enqueue any neighbor whose in_degree reaches 0. If processed count <V< V<V, a cycle exists!
  • The Code: if in_degree[neighbor] == 0: queue.append(neighbor)
🟣 Scenario C: Single-Source Shortest Paths with Weights (Using Dijkstra's Heap Relaxation)
  • When edge weights vary, BFS fails because the earliest visited path is not necessarily the cheapest.
  • The Rule: Greedily pop the minimum provisional distance node from a min-heap. Relax outgoing edges with if d + weight < dist[v]: dist[v] = d + weight; heappush(...). Always guard stale entries with if d > dist[u]: continue.

💡 The Ultimate Takeaway Decision Rule:
When analyzing a new graph problem, ask: "Are edge weights present, and are there dependencies?"

  • Unweighted Grid / Components ➔ Outer scan + DFS/BFS in-place sink (O(V+E)O(V + E)O(V+E) or O(R⋅C)O(R \cdot C)O(R⋅C)).
  • Prerequisites / DAG(Directed Acyclic Graph) Order ➔ Kahn's Algorithm with in-degree 0 queue (O(V+E)O(V + E)O(V+E)).
  • Weighted Non-Negative Edges ➔ Dijkstra's Algorithm with Min-Heap (O((V+E)log⁡V)O((V + E) \log V)O((V+E)logV)).
  • Weighted 0 or 1 Edges ➔ 0-1 BFS with deque.appendleft / append (O(V+E)O(V + E)O(V+E)).

🎯 The 5 Essential Graph Paradigms & Archetypes
ArchetypeFrontier ManagementEdge / Neighbor RuleInvariant / Cycle Prevention
1. Connected Comps
(LC 200 / LC 733)
DFS / BFS stack or implicit recursion4-directional cardinal grid (r+dr, c+dc) in boundsSink in-place or mark visited at discovery; count in outer loop
2. Topological Sort
(LC 207 / LC 210)
FIFO deque of in_degree == 0In-degree array decrement in_degree[nxt] -= 1DAG(Directed Acyclic Graph) order valid iff dequeued elements count equals numNodes
3. Dijkstra Shortest
(LC 743 / LC 787)
Min-Heap (dist, u) priority queueGreedy edge relaxation d + w < dist[v]Stale-entry guard d > dist[u]; Non-negative edge weights only
4. 0-1 BFS Shortest
(LC 1368)
Double-ended deque front / back pushesCost 0 ➔ appendleft()
Cost 1 ➔ append()
Strict distance monotonicity without O(log⁡V)O(\log V)O(logV) heap overhead
5. Graph Bipartition
(LC 785)
BFS / DFS queue with color array2-Color alternating parity color[nxt] = 1 - color[curr]Graph is bipartite iff no adjacent nodes share same color
🏝️ 1. Connected Components & Grid Flood Fill (LC 200 & 733)
  • Mental Model: A grid is an implicit graph where each cell (r,c)(r, c)(r,c) connects to its 4 orthogonal neighbors.
  • Outer Counter vs Inner Drain: The counter must live in the outer grid scan (for r in range(R): for c in range(C)). The inner DFS/BFS traversal exists solely to consume ("sink") the entire connected cluster so it is never counted again.
  • Code: see the Flood Fill card.

📋 2. Topological Sort & DAG(Directed Acyclic Graph) Cycle Detection (LC 207 & 210 — Kahn's Algorithm)
  • Mental Model: Kahn's algorithm removes nodes with zero incoming prerequisites sequentially.
  • The Cycle Detection Guarantee: If all prerequisites can be satisfied, every node is enqueued and dequeued exactly once (len(order) == numCourses). If len(order) < numCourses, any remaining nodes are trapped in a cyclic dependency deadlock.
  • Code: see the Topological Sort card.

⚡ 3. Weighted Shortest Paths (LC 743 — Dijkstra's Algorithm)
  • Mental Model: Dijkstra uses a priority queue to greedily finalize shortest distances from origin in ascending order of total cost.
  • The Stale Entry Guard: Because Python's heapq does not support O(log⁡N)O(\log N)O(logN) decrease-key, we push new (new_dist, v) pairs whenever an edge relaxes. Outdated higher-cost entries remain in the heap and must be dropped with if d > dist[u]: continue.
  • Code: see the Dijkstra card.

🚀 4. 0-1 BFS with Double-Ended Queue (LC 1368)
  • Mental Model: When edges cost strictly 0 or 1, a collections.deque replaces the priority queue.
  • How It Works: Cost-0 transitions are pushed to the front (appendleft), while Cost-1 transitions are pushed to the back (append). This maintains strict distance monotonicity in O(V+E)O(V + E)O(V+E) linear time without O(log⁡V)O(\log V)O(logV) heap overhead!

🎨 5. Graph 2-Coloring & Bipartition Check (LC 785)
  • Mental Model: A graph is bipartite if its vertices can be partitioned into two sets such that no two adjacent vertices share the same color.
  • How It Works: Run BFS from every unvisited node. Assign color 0 to root. For every neighbor, if unvisited, assign 1 - color[curr]; if already visited and color[neighbor] == color[curr], return False immediately.

🌳 Evaluating Graph Topologies, State Management & Frontier Management

Building an accurate mental model of graph representation and frontier management prevents the most pervasive graph bugs:

1. Graph Representation Best Practices
  • Never build an adjacency list for a 2D matrix: On grids, neighbor offsets [(-1,0), (1,0), (0,-1), (0,1)] generate valid edges dynamically in O(1)O(1)O(1) without memory allocation.
  • Always verify edge direction in prerequisites: If taking course BBB is required before AAA ([A, B]), the directed edge points B→AB \rightarrow AB→A.
2. Frontier Discipline & Cycle Prevention
  • Mark on Enqueue / Discovery: In BFS, adding a node to visited at the moment it is placed in the queue prevents duplicate queue insertions across diamond graphs (A→B,C→DA \rightarrow B, C \rightarrow DA→B,C→D).
  • Iterative over Recursive for Large Grids: A 300×300300 \times 300300×300 grid contains 90,000 cells. Recursive DFS will exceed CPython's 1000-frame recursion limit; use an explicit stack or BFS queue.

💡 Staff SWE Architectural Invariant:
In graph traversal, the choice of frontier data structure completely defines the algorithm: a FIFO Queue produces unweighted shortest paths (BFS), a LIFO Stack produces deep component reachability (DFS), a Min-Heap produces weighted shortest paths (Dijkstra), and an In-Degree Array produces valid topological DAG(Directed Acyclic Graph) orderings (Kahn's).

Understanding Invariants: Visited State Tracking & In-Degree Reduction

Every node is processed exactly once. Marking seen immediately upon discovery guarantees O(V + E) or O(R · C) linear traversal and prevents infinite cycles.

Frontier management defines graph traversal: FIFO Queues yield BFS wavefronts, LIFO Stacks yield DFS reachability, Min-Heaps yield Dijkstra shortest paths, and In-Degree 0 Queues yield Topological orderings.

Cycle Prevention Invariant (Visited Discovery Guard)
Condition: If neighbor v in visited set or grid[r][c] == '0'
Action: skip neighbor exploration (continue / return)

Prevents infinite recursion loops across cyclic and bidirectional graph edges.

Topological DAG(Directed Acyclic Graph) In-Degree Reduction Invariant
Condition: If in_degree[neighbor] > 0 after decrementing incoming prerequisite edge
Action: do not enqueue neighbor until in_degree reaches strictly 0

Guarantees a dependent task executes only after all prerequisite tasks have fully completed.

Common Questions
  • "When doing BFS on a graph, why do we mark a node visited the moment we enqueue it, instead of when we actually pop and process it? Wouldn't checking at pop-time also stop the traversal from looping forever?"

    It would stop it from looping forever — but it wouldn't stop it from doing wasted, duplicate work, and in a diamond-shaped graph that duplication can multiply badly.

    Picture node A connected to both B and C, and B and C both connected to D. If you only mark visited at pop-time, D can get enqueued twice — once when B is processed, once when C is processed — because at the moment either of them looks at D, D hasn't been popped yet, so it doesn't look visited. Both copies of D sit in the queue. If the loop also skips a node that is already visited when it pops it, the second copy is thrown away and BFS stays correct, but the queue can hold one entry per edge (up to E) instead of one per node. If it does not skip, D's own neighbors are explored twice, and in a chain of such diamonds that duplication doubles at every diamond.

    Marking visited at the moment of discovery — right when you decide to enqueue a node — closes that window. By the time C looks at D, B has already claimed it, so C skips it. Every node is enqueued at most once, so the queue never holds more than V entries. The rule of thumb: in BFS, mark on enqueue; marking at pop-time is correct only together with a skip of already-visited nodes when they are popped, and it still lets the queue grow to E entries.

Coach discussion points
  1. 1.

    "Move your count += 1 inside the flood-fill function. What does your program now compute, and why?" — The number of land cells. Forces the student to articulate what the outer scan is for, which is the whole lesson of connected components.

  2. 2.

    "Your grid is 300 × 300 and entirely land. Walk me through what happens to your recursive solution." — Recursion depth. Then: what changes if you make it iterative, and does the space complexity actually improve? (It does not — O(R·C) either way — but the failure mode does.)

  3. 3.

    "You returned a topological order for a graph with a cycle in it. How would you have known?" — The len(order) == n check, discovered rather than recited. Follow up: "why is that check sufficient — could a cycle ever still produce a full-length order?"

  4. 4.

    "One of these edges now costs −3. Which of your three algorithms still work?" — Flood fill: unaffected (weights are irrelevant). Topological sort: unaffected. Dijkstra: broken, silently. Tests whether preconditions are held as real constraints or as decoration.

  5. 5.

    "You just solved Path With Minimum Effort. Now solve Swim in Rising Water without looking anything up -- what changes in your code, and what stays exactly the same?" — The min-heap, the visited/dist-table discipline, and the early-return-on-pop all stay identical; only the value being tracked moves from an edge quantity (abs(heights[nr][nc] - heights[r][c])) to a node quantity (grid[r][c]). Follow-up: "Can you also solve Swim in Rising Water with binary search plus BFS? Why does that alternate approach not work as naturally for Path With Minimum Effort?" (Because Path With Minimum Effort's bottleneck is an edge property, so "can I reach the destination using only edges with weight ≤ T" is a less natural reachability check than Swim in Rising Water's node-property version.)

Complexity analysis

Number of Islands — Time O(R · C), Space O(R · C) worst case. Every cell is examined by the outer scan once, and sunk by a flood at most once, so the total work is proportional to the number of cells. The space is the traversal frontier: an all-land grid means a single flood can hold R · C cells on the stack (or, recursively, R · C call frames — which on a 300 × 300 grid is 90,000 frames and will raise RecursionError in CPython). This is why the abstract template is iterative.

Topological sort — Time O(V + E), Space O(V + E). Building the adjacency list and in-degree array touches every edge once. Each node enters the queue at most once; each edge is relaxed once when its source is dequeued.

Dijkstra — Time O(E log V), Space O(V + E). Each edge can push one entry onto the heap, giving at most E heap operations at O(log V) each. (The lazy variant used above may hold up to E entries in the heap, hence log E — but log E ≤ 2 log V, so O(E log V) is the standard statement.) Say E log V, and be ready to explain that the log comes from the heap and nothing else.

The general statement for all three: O(V + E) is the floor for any traversal, because you must at minimum look at every node and every connection. Anything faster is not a traversal.

Production Systems Deployment & Motivation

A satellite photo of a coastline is stored as a grid of pixels, each labelled land or water. A geographer needs to count how many separate islands there are. Two land pixels belong to the same island when you can walk between them stepping only up, down, left, or right onto land. The count is not the number of land pixels — it is the number of connected clusters of them. The same computation counts connected components in a network outage map, distinct regions in a paint-bucket fill, and distinct clusters in a fraud graph.

Mastery Roadmap & Prerequisite Graph

💡 Don't worry if graph representations and connectivity rules feel complex right now! Most graph problems reduce to building an adjacency list and choosing the right traversal or disjoint-set primitive. As you progress through the Archetype Variants (from Number of Provinces and Kahn's Topological Sort to Dijkstra's Shortest Path and Accounts Merge), each hands-on challenge will cement this intuition until detecting cycles and managing visited states becomes second nature.

  • Counting land pixels instead of connected clusters: Incrementing the island count inside the recursive/queue expansion loop rather than exclusively in the outer grid scan. The counter must only increment when a new unvisited '1' is discovered.

  • Unbounded call stack overflow on large grids: Using recursion on a 300×300300 \times 300300×300 grid of solid land causes call stack depth of 90,000, exceeding Python's recursion limit. Iterative BFS or increasing recursion limit is required.

  • Assuming 8-directional diagonal connectivity: Treating diagonal neighbors as connected. The problem strictly specifies 4-directional horizontal and vertical adjacency.

Staff+ Engineering Perspective

Staff+ Systems Architecture & Hardware Considerations

ARCHITECTURAL SIGNALS & INTERVIEW TRIGGERS

Triggers: "relationships between entities", "shortest path in weighted network", "dependency resolution / cycle detection", "minimum spanning tree / network reliability". Discrete topological structures composed of vertices (VVV) and directed/undirected edges (EEE).

CONSTRAINTS & BOUNDS

Vertices V≤105V \le 10^5V≤105, edges E≤106E \le 10^6E≤106. Graph representation dictates memory: adjacency list requires O(V+E)O(V + E)O(V+E), while adjacency matrix requires O(V2)O(V^2)O(V2) memory and is strictly prohibited for sparse graphs.

FAANG PRODUCTION TRAPS & EDGE CASES

Confusing directed vs undirected edge insertions; Dijkstra's algorithm failing on graphs with negative edge weights (requires Bellman-Ford); unhandled disconnected components (must run search across an outer loop over all VVV); infinite cycles on cyclic networks.

Theory Context•Graph Algorithms

Conceptual Foundation & Intuitive Analogy

🧭 Conceptual Foundation & Pattern Intuition

Graph traversal models arbitrary systems of interconnected entities. A graph consists of vertices (states, grid cells, entities) and edges (dependencies, roads, cardinal adjacencies). Unlike trees, general graphs may contain cycles and disconnected components. Graph algorithms systematically explore reachable states using either Depth-First Search (DFS, diving deeply down one branch using a LIFO stack) or Breadth-First Search (BFS, expanding concentrically outward level by level using a FIFO queue). The cardinal rule of graph traversal is the visited set invariant: every discovered node must be recorded immediately upon discovery to prevent infinite loops on cyclic edges and redundant work across dense connections.

The abstract template above generalizes this to any "count the connected clusters" question: get_neighbours and is_interesting are the only holes a concrete problem needs to fill in. The next archetype, Number of Islands, fills those holes concretely and walks through the exact flood-fill analogy and code this shape produces.

Visual Blueprint & State Transition Model
Graph Algorithms Mental Model Diagram
Figure 1 • Graph Algorithms Core Invariant & State Lifecycle
Core Invariant Specification

Every node is processed exactly once. The visited marker is what turns a possibly-infinite walk into O(V + E).

Boundary Model: Graph Component Traversal & Visited State Machine

Maintains visited set or 3-color states (WHITE=unvisited, GREY=visiting/cycle, BLACK=processed) across adjacency lists.

Loop Invariant Termination

Loop outer vertices 0..V-1 to handle disconnected subgraphs; explore edges via BFS/DFS/PriorityQueue.

The 6 Mental Models of Graph Algorithms

Classify graph problems by connectivity, directionality, and edge weights.

1Flood Fill
2D grid / graph ➔ outer unvisited discovery ➔ consume full component

Connected Components (Number of Islands / Flood Fill)

Spot it when: You count or label separate groups: islands, provinces, regions, rooms you can reach.
Mode 1 · Flood Fill
Python
1from typing import Callable, Dict, Hashable, Iterable, List
2 
3 
4def flood_fill(cells: Iterable[Hashable], is_land: Callable[[Hashable], bool], neighbors: Callable[[Hashable], Iterable[Hashable]]) -> Dict[Hashable, int]:
5 """Flood Fill: label every land cell with the number of its group of connected land
6 cells.
7 Precondition: neighbors is symmetric. If b is a neighbour of a, then a is a
8 neighbour of b.
9 Groups are numbered 0, 1, 2, ... in scan order, so the number of groups is
10 max(group.values()) + 1.
11 V = cells. T = the cost of one is_land or neighbors call (neighbors yields at most T
12 cells).
13 Time: O(V * T) | Space: O(V)
14 """
15 # Maps every already-searched land cell to its group number
16 group: Dict[Hashable, int] = {}
17 count = 0
18 
19 for cell in cells:
20 # 1. Find An Unlabeled Land Cell:
21 # A cell with no group yet and is_land(cell) true has not been reached by any
22 # earlier search, so it starts a brand-new group.
23 if is_land(cell) and cell not in group:
24 # A new group: count grows once per search, not per cell
25 group[cell] = count
26 stack = [cell]
27 
28 # 2. Flood The Whole Group:
29 # The loop keeps running until stack is empty, so every cell reachable from
30 # this one is labeled before the outer loop is allowed to move on.
31 while stack: # Inner search uses up the whole group
32 curr = stack.pop()
33 for nxt in neighbors(curr):
34 if is_land(nxt) and nxt not in group:
35 group[nxt] = count # Label on push, not on pop
36 stack.append(nxt)
37 
38 # 3. Advance The Group Counter:
39 # count only changes once the whole group has been labeled, so the next
40 # brand-new cell starts the next number.
41 count += 1
42 
43 return group

Scan every cell, and when you meet a "1" that no search has reached, add one to islands and flood that cell's whole island with the inner stack search. The inner search turns every connected land cell to "0", so the outer scan never meets that island again and counts it exactly once. The trap is where the counter lives: islands += 1 belongs in the outer scan, once per search, never inside the inner loop, where it would count cells instead of islands.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Adding to islands inside the inner search loop

    Mistake
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
        grid[nr][nc] = "0"
        islands += 1
        stack.append((nr, nc))
    Fix
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
        grid[nr][nc] = "0"
        stack.append((nr, nc))

    What happens: The outer scan adds 1 to islands for the first cell of an island. The inner loop then adds 1 again for every other cell it reaches. So islands ends as the number of land cells, not the number of islands. islands += 1 belongs in the outer scan, once per new search.

    Example: Number of Islands (LC 200): grid = [["1", "1"], ["0", "1"]]
    Mistake: returns 3Fix: returns 1
  2. 2

    Gotcha 2: Marking a cell visited when it is popped, not pushed

    Mistake
    curr_r, curr_c = stack.pop()
    grid[curr_r][curr_c] = "0"
    for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
        nr, nc = curr_r + dr, curr_c + dc
        if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
            stack.append((nr, nc))
    Fix
    curr_r, curr_c = stack.pop()
    for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
        nr, nc = curr_r + dr, curr_c + dc
        if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":
            grid[nr][nc] = "0"
            stack.append((nr, nc))

    What happens: A land cell stays "1" until it is popped. Before that, each of its land neighbours can push it again. So the same cell sits on stack several times. The count of islands is still right, but the search does extra work. That is why this mistake has no example.

  3. 3

    Gotcha 3: Reading grid[nr][nc] before checking the bounds

    Mistake
    if grid[nr][nc] == "1" and 0 <= nr < R and 0 <= nc < C:
    Fix
    if 0 <= nr < R and 0 <= nc < C and grid[nr][nc] == "1":

    What happens: and checks its parts from left to right. Here grid[nr][nc] is read first. From a cell on the bottom row, the step down gives nr = R, and grid[R] does not exist. Python raises an IndexError. A negative nr causes a different problem. grid[-1] silently reads the last row.

    Example: grid = [["1"]]
    Mistake: raises IndexErrorFix: returns 1
  4. 4

    Gotcha 4: Searching an island with a recursive dfs

    Mistake
    def dfs(r, c):
        if 0 <= r < R and 0 <= c < C and grid[r][c] == "1":
            grid[r][c] = "0"
            for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                dfs(r + dr, c + dc)
    Fix
    stack = [(r, c)]
    grid[r][c] = "0"

    What happens: Each call of a recursive dfs waits for the calls it makes. Number of Islands (LC 200) allows grids of 300 x 300 cells. On a 40 x 40 island of land, the chain of nested calls is already deeper than 1000. Python's default recursion limit is 1000, so it raises a RecursionError. The blueprint's stack list has no such limit.

    Example: grid = 40 rows of 40 "1" cells (one island)
    Mistake: raises RecursionErrorFix: returns 1
Canonical LeetCode:Number of Islands (LC 200)Number of Provinces (LC 547)Flood Fill (LC 733)Surrounded Regions (LC 130)Pacific Atlantic Water Flow (LC 417)Number of Distinct Islands (LC 694)Keys and Rooms (LC 841)Max Area of Island (LC 695)Number of Connected Components in an Undirected Graph (LC 323)
Open the Flood Fill card
2Kruskal's MST
edge list sorted by weight ➔ union by rank/size ➔ connect components

Minimum Spanning Tree (Kruskal's, built on Union-Find)

Spot it when: You must connect all points, cities or computers at the lowest total edge cost, with every node reachable in the end.
Mode 2 · Kruskal's MST
Python
1from typing import List
2 
3def kruskal_mst(n: int, edges: List[List[int]]) -> int:
4 """Kruskal's MST (Connecting Cities With Minimum Cost): cheapest total cost to join
5 nodes 1..n, or -1.
6 Time: O(E log E) | Space: O(V)
7 """
8 parent = list(range(n + 1))
9 
10 def find(x: int) -> int:
11 while parent[x] != x:
12 parent[x] = parent[parent[x]] # Path halving: skip a level on every step
13 x = parent[x]
14 return x
15 
16 edges.sort(key=lambda e: e[2]) # Cheapest edges first
17 mst_cost = 0
18 edges_count = 0
19 
20 for u, v, w in edges:
21 root_u, root_v = find(u), find(v)
22 if root_u != root_v: # Different groups: this edge is safe to take
23 parent[root_u] = root_v # Join the roots, not the nodes
24 mst_cost += w
25 edges_count += 1
26 if edges_count == n - 1:
27 break # A spanning tree has exactly n - 1 edges
28 
29 return mst_cost if edges_count == n - 1 else -1 # Fewer edges: not connected

Sort edges by weight and take each edge whose ends have different roots, find(u) != find(v), joining the two groups. The cheapest edge between two separate groups is always safe to take: any full wiring must cross that gap somewhere, and swapping in the cheaper edge never costs more; an edge whose ends already share a root would only close a cycle, so skipping it loses nothing. The trap is the final check: fewer than n - 1 edges taken means the graph is not connected, so return -1 instead of the partial cost.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Returning mst_cost without checking edges_count

    Mistake
    return mst_cost
    Fix
    return mst_cost if edges_count == n - 1 else -1

    What happens: A tree that joins n nodes has exactly n - 1 edges. If the edges run out with fewer, some cities are still in separate groups. mst_cost is then the cost of a partial wiring. The problem asks for -1 in that case.

    Example: Connecting Cities With Minimum Cost (LC 1135): n = 4, connections = [[1, 2, 3], [3, 4, 4]]
    Mistake: returns 7Fix: returns -1
  2. 2

    Gotcha 2: Writing find without path halving

    Mistake
    while parent[x] != x:
        x = parent[x]
    Fix
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]

    What happens: Without parent[x] = parent[parent[x]], the parent links are never shortened. The joins can build one long chain. Each find then walks the whole chain, which is O(n) per call. The answer stays the same, so this mistake has no example. Union by size keeps the trees short too.

  3. 3

    Gotcha 3: Sorting the edges by e[0] instead of the weight e[2]

    Mistake
    edges.sort(key=lambda e: e[0])
    Fix
    edges.sort(key=lambda e: e[2])

    What happens: Each edge is [u, v, w], so e[0] is a city number. The greedy step is only safe when the cheapest edges come first. Sorted by city, an expensive edge can join two groups before a cheap one. The total cost is then too high.

    Example: n = 3, connections = [[1, 2, 5], [1, 3, 6], [2, 3, 1]]
    Mistake: returns 11Fix: returns 6
  4. 4

    Gotcha 4: Joining the nodes parent[u] = root_v instead of the roots

    Mistake
    parent[u] = root_v
    Fix
    parent[root_u] = root_v

    What happens: u may not be the root of its group. Setting parent[u] moves only u, and the part of the group below u. The rest of the group keeps its old root. Two groups that were joined can then look separate. So an edge inside one group is taken, and it closes a cycle. It still counts toward n - 1, so the loop can stop before every city is joined.

    Example: n = 4, connections = [[1, 2, 6], [2, 3, 1], [2, 4, 3], [3, 4, 4]]
    Mistake: returns 8Fix: returns 10
Canonical LeetCode:Min Cost to Connect All Points (LC 1584)Connecting Cities With Minimum Cost (LC 1135)Optimize Water Distribution in a Village (LC 1168)Find Critical and Pseudo-Critical Edges in Minimum Spanning Tree (LC 1489)
Open the Kruskal's MST card
3Dijkstra
weighted directed graph ➔ min-heap (dist, node) ➔ greedy relaxation

Dijkstra's Algorithm

Spot it when: Edges have non-negative weights and you need the cheapest path, or the path whose largest step is smallest (effort, water level).
Mode 3 · Dijkstra
Python
1import heapq
2from typing import List, Dict
3 
4def dijkstra(n: int, edges: List[List[int]], source: int) -> Dict[int, float]:
5 """Dijkstra (the core of Network Delay Time): cheapest distance from source to nodes
6 1..n.
7 Time: O((V + E) log V) | Space: O(V + E)
8 """
9 adj = [[] for _ in range(n + 1)]
10 for u, v, w in edges:
11 adj[u].append((v, w))
12 
13 dist = {i: float('inf') for i in range(1, n + 1)}
14 dist[source] = 0
15 pq = [(0, source)] # (cost, node)
16 
17 while pq:
18 d, u = heapq.heappop(pq)
19 if d > dist[u]:
20 continue # Stale entry: u was already settled with a smaller d
21 
22 for v, weight in adj[u]:
23 if dist[u] + weight < dist[v]: # Relax the edge
24 dist[v] = dist[u] + weight
25 heapq.heappush(pq, (dist[v], v))
26 
27 return dist

Keep a min-heap pq of (distance, node) pairs. Popping the smallest d settles u, because with non-negative weights any other path to u leaves the settled area through a node that is at least as far away, so it can't be shorter. For each edge, if going through u is shorter, update dist[v] and push the new pair. The trap is the stale entry: u can sit in pq several times, so skip it when d > dist[u]; without that line the answer stays right, but old entries relax every edge again and the run can slow down badly.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Relaxing edges from a stale heap entry

    Mistake
    d, u = heapq.heappop(pq)
    Fix
    d, u = heapq.heappop(pq)
    if d > dist[u]:
        continue

    What happens: u is pushed again every time dist[u] improves. So pq can hold old pairs for u with a larger d. Without the check, an old pair relaxes every edge of u again. The answer stays right, because an old d never improves dist[v]. But the run can do much more work. That is why this mistake has no example.

  2. 2

    Gotcha 2: Relaxing with <= instead of <

    Mistake
    if dist[u] + weight <= dist[v]:
    Fix
    if dist[u] + weight < dist[v]:

    What happens: Network Delay Time (LC 743) allows a weight of 0. With <=, a path of equal length counts as an improvement. Two nodes joined by 0-weight edges then push each other again and again. pq never empties, so the loop never ends.

    Example: n = 2, edges = [[1, 2, 0], [2, 1, 0]], source = 1
    Mistake: the loop never endsFix: returns {1: 0, 2: 0}
  3. 3

    Gotcha 3: Pushing a neighbour even when dist[v] did not improve

    Mistake
    if dist[u] + weight < dist[v]:
        dist[v] = dist[u] + weight
    heapq.heappush(pq, (dist[v], v))
    Fix
    if dist[u] + weight < dist[v]:
        dist[v] = dist[u] + weight
        heapq.heappush(pq, (dist[v], v))

    What happens: A pair is useful only when it carries a new, smaller dist[v]. A push without an improvement adds a pair that the stale check will skip. The answer stays right, but pq grows with pairs that can never win. That is why this mistake has no example.

  4. 4

    Gotcha 4: Running Dijkstra on a graph with negative weights

    What happens: Dijkstra treats a popped node as settled. A negative edge can later give that node a smaller distance. This version pushes the node again, so the same node can be popped and relaxed many times. A cycle with a negative total makes the loop never end. For negative weights, use Bellman-Ford.

Canonical LeetCode:Network Delay Time (LC 743)Path With Minimum Effort (LC 1631)Swim in Rising Water (LC 778)Number of Ways to Arrive at Destination (LC 1976)The Maze II (LC 505)
Open the Dijkstra card
4Eulerian Path
edges ➔ walk and delete each used edge ➔ nodes appended when stuck, then reversed

Eulerian Path (Hierholzer's Algorithm)

Spot it when: Every edge (a ticket, a domino, a pair, a transition) must be used exactly once in one walk, or you need the shortest string that contains every combination as a substring.
Mode 4 · Eulerian Path
Python
1from collections import defaultdict
2from typing import Hashable, List
3 
4def eulerian_path(edges: List[List[Hashable]]) -> List[Hashable]:
5 """Eulerian Path (Hierholzer's algorithm, the core of Reconstruct Itinerary): the
6 smallest walk that uses every directed edge exactly once.
7 Time: O(E log E) | Space: O(E)
8 """
9 graph = defaultdict(list)
10 balance = defaultdict(int) # out-degree minus in-degree
11 # reverse order, so pop() takes the smallest neighbour
12 for u, v in sorted(edges, reverse=True):
13 graph[u].append(v)
14 balance[u] += 1
15 balance[v] -= 1
16 # one extra edge out starts the path; a circuit starts at the smallest node
17 start = next((x for x in graph if balance[x] == 1), min(graph))
18 path, stack = [], [start]
19 while stack:
20 node = stack[-1]
21 if graph[node]:
22 stack.append(graph[node].pop()) # follow an unused edge and delete it
23 else:
24 # no edge left: node is final in the rest of the walk
25 path.append(stack.pop())
26 return path[::-1] # nodes were finished last-first

Walk from start, deleting each edge as you follow it with stack.append(graph[node].pop()), and add a node to path only when it has no unused edge left, with path.append(stack.pop()); reversing path gives the walk. This is correct because a walk that always takes some unused edge can only get stuck at the node where the whole path must end, so the first node to run out of edges is the last node of the answer; any cycle the walk skipped hangs off a node that is still on stack, and it is walked, and placed, when the stack backs up to that node. The trap is writing a node into the answer when you first reach it: the walk can enter the final dead end before a cycle has been used (in Reconstruct Itinerary (LC 332), taking the smallest airport first can strand the tickets of a loop), so a node goes into path only once its edges are used up, and the list is reversed at the end.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Writing each node into the answer when first reached

    Mistake
    path.append(node)
    while graph[node]:
        node = graph[node].pop()
        path.append(node)
    Fix
    if graph[node]:
        stack.append(graph[node].pop())
    else:
        path.append(stack.pop())

    What happens: The walk can enter the final dead end before a cycle has been used. Written in the order reached, the answer stops at that dead end. The cycle's edges are never used. The blueprint adds a node to path only when its edges are used up, then reverses path.

    Example: Reconstruct Itinerary (LC 332): tickets = [["JFK", "KUL"], ["JFK", "NRT"], ["NRT", "JFK"]]
    Mistake: returns ['JFK', 'KUL']Fix: returns ['JFK', 'NRT', 'JFK', 'KUL']
  2. 2

    Gotcha 2: Starting at the smallest node instead of the unbalanced one

    Mistake
    start = min(graph)
    Fix
    start = next((x for x in graph if balance[x] == 1), min(graph))

    What happens: A path that is not a circuit must start at the node with one more edge out than in, balance[x] == 1. That node has one extra edge out, so the walk must leave it first. Started anywhere else, the walk gets stuck before it uses every edge. Only when every node is balanced is the path a circuit. Then it starts at the smallest node, min(graph).

    Example: edges = [[1, 0], [0, 2]]
    Mistake: returns [0, 2]Fix: returns [1, 0, 2]
  3. 3

    Gotcha 3: Sorting the edges ascending, so pop() takes the largest

    Mistake
    for u, v in sorted(edges):
    Fix
    for u, v in sorted(edges, reverse=True):

    What happens: pop() takes the last item of a list in O(1). Sorted in reverse, the last item is the smallest neighbour. Sorted ascending, pop() takes the largest neighbour instead. The walk still uses every edge, but it is not the smallest walk. pop(0) would take the smallest, but it costs O(E) per step.

    Example: tickets = [["JFK", "SFO"], ["JFK", "ATL"], ["SFO", "ATL"], ["ATL", "JFK"], ["ATL", "SFO"]]
    Mistake: returns ['JFK', 'SFO', 'ATL', 'JFK', 'ATL', 'SFO']Fix: returns ['JFK', 'ATL', 'JFK', 'SFO', 'ATL', 'SFO']
  4. 4

    Gotcha 4: Returning path without reversing it

    Mistake
    return path
    Fix
    return path[::-1]

    What happens: A node goes into path when it has no edge left. The final node of the walk runs out first. So path holds the walk from its end back to its start. path[::-1] puts it in order.

    Example: tickets = [["MUC", "LHR"], ["JFK", "MUC"], ["SFO", "SJC"], ["LHR", "SFO"]]
    Mistake: returns ['SJC', 'SFO', 'LHR', 'MUC', 'JFK']Fix: returns ['JFK', 'MUC', 'LHR', 'SFO', 'SJC']
Canonical LeetCode:Reconstruct Itinerary (LC 332)Valid Arrangement of Pairs (LC 2097)Cracking the Safe (LC 753)
Open the Eulerian Path card
5Floyd-Warshall
distance table ➔ allow one more middle node per round ➔ all shortest distances

Floyd-Warshall (All-Pairs Shortest Path)

Spot it when: You need the shortest distance, or just reachability, between every pair of nodes (or answers to many pair queries), and the graph is small: a few hundred nodes at most.
Mode 5 · Floyd-Warshall
Python
1from typing import List
2 
3def floyd_warshall(n: int, edges: List[List[int]]) -> List[List[float]]:
4 """Floyd-Warshall (the core of Course Schedule IV): shortest distance between every
5 pair of nodes.
6 Time: O(V^3) | Space: O(V^2)
7 """
8 dist = [[float("inf")] * n for _ in range(n)]
9 for i in range(n):
10 dist[i][i] = 0 # a node reaches itself at distance 0
11 for u, v, w in edges:
12 # directed edge u -> v; keep the lighter of two parallel edges
13 dist[u][v] = min(dist[u][v], w)
14 for k in range(n): # k OUTERMOST: allow node k as a stop
15 for i in range(n):
16 for j in range(n):
17 if dist[i][k] + dist[k][j] < dist[i][j]:
18 dist[i][j] = dist[i][k] + dist[k][j] # go through k
19 return dist

Allow the nodes as stops one at a time: for each k, let every pair i, j go through k when dist[i][k] + dist[k][j] < dist[i][j]. This is correct because, once k is allowed, the shortest i -> j path with stops in 0..k either avoids k, and is already in dist[i][j], or passes through k once and splits into an i -> k part and a k -> j part whose stops lie in 0..k - 1, and both of those are already in the table. The trap is the loop order: for k in range(n) must be the outermost loop. With k inside the i and j loops, dist[i][k] is read before the paths through the smaller stops have been allowed, and some pairs keep a distance that is too long, or no path at all.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Putting for k in range(n) inside the i and j loops

    Mistake
    for i in range(n):
        for j in range(n):
            for k in range(n):
    Fix
    for k in range(n):
        for i in range(n):
            for j in range(n):

    What happens: With k innermost, the pair (i, j) is finished before the other pairs are updated. dist[i][k] may still miss a path through a smaller stop. So some pairs keep a distance that is too long, or no path at all.

    Example: n = 4, edges = [[0, 3, 1], [3, 2, 1], [2, 1, 1]], read dist[0][1]
    Mistake: returns infFix: returns 3
  2. 2

    Gotcha 2: Using INT_MAX as "no path" in Java or C++

    What happens: In Python, float("inf") + 1 is still inf, so the blueprint is safe. In Java or C++, INT_MAX + 1 overflows to a negative number. dist[i][k] + dist[k][j] then looks shorter than any real path. Use a value such as INT_MAX / 2, or skip pairs where either part is unreached.

  3. 3

    Gotcha 3: Storing edge [u, v] as dist[v][u]

    Mistake
    dist[v][u] = min(dist[v][u], w)
    Fix
    dist[u][v] = min(dist[u][v], w)

    What happens: In Course Schedule IV (LC 1462), the pair [ai, bi] means course ai comes before course bi. So the edge goes from u to v, and dist[u][v] gets its weight. Storing it as dist[v][u] reverses every edge. Every query is then answered backwards.

    Example: n = 2, edges = [[1, 0, 1]], read dist[1][0]
    Mistake: returns infFix: returns 1
  4. 4

    Gotcha 4: Writing dist[u][v] = w for parallel edges

    Mistake
    dist[u][v] = w
    Fix
    dist[u][v] = min(dist[u][v], w)

    What happens: Two edges can join the same pair of nodes. dist[u][v] = w keeps whichever edge comes last. If the lighter edge came first, it is lost.

    Example: n = 2, edges = [[0, 1, 2], [0, 1, 5]], read dist[0][1]
    Mistake: returns 5Fix: returns 2
Canonical LeetCode:Find the City With the Smallest Number of Neighbors at a Threshold Distance (LC 1334)Course Schedule IV (LC 1462)Minimum Cost to Convert String I (LC 2976)
Open the Floyd-Warshall card
6Tarjan's Bridges
DFS tree ➔ discovery time and low-link per node ➔ bridge when low[child] > disc[parent]

Tarjan's Bridges & Articulation Points

Spot it when: You must find the edges (or nodes) whose removal splits an undirected graph into more pieces: critical links, single points of failure, cells whose loss disconnects a region.
Mode 6 · Tarjan's Bridges
Python
1from typing import List
2 
3def find_bridges(n: int, edges: List[List[int]]) -> List[List[int]]:
4 """Tarjan's Bridges (the core of Critical Connections in a Network): every edge
5 whose removal disconnects the graph.
6 Time: O(V + E) | Space: O(V + E)
7 """
8 graph = [[] for _ in range(n)]
9 for i, (u, v) in enumerate(edges):
10 graph[u].append((v, i))
11 graph[v].append((u, i))
12 # discovery time; -1 = not reached yet
13 disc = [-1] * n
14 # earliest disc the subtree reaches with one back edge
15 low = [0] * n
16 bridges = []
17 time = 0
18 for root in range(n):
19 if disc[root] != -1:
20 continue
21 disc[root] = low[root] = time
22 time += 1
23 # (node, edge id we came in by, next neighbour index)
24 stack = [(root, -1, 0)]
25 while stack:
26 node, in_edge, idx = stack[-1]
27 if idx < len(graph[node]):
28 stack[-1] = (node, in_edge, idx + 1)
29 nxt, edge = graph[node][idx]
30 if edge == in_edge:
31 continue # not the tree edge back to the parent
32 if disc[nxt] == -1:
33 disc[nxt] = low[nxt] = time # tree edge: go deeper
34 time += 1
35 stack.append((nxt, edge, 0))
36 else:
37 # back edge: reaches an earlier node
38 low[node] = min(low[node], disc[nxt])
39 else:
40 stack.pop() # node is finished
41 if stack:
42 parent = stack[-1][0]
43 low[parent] = min(low[parent], low[node])
44 # nothing below node climbs to parent or above
45 if low[node] > disc[parent]:
46 bridges.append([parent, node])
47 return bridges

Number the nodes in DFS order with disc, and let low[node] be the earliest disc that node's subtree can reach through one edge other than the tree edge it came in by; when node finishes, the tree edge parent -> node is a bridge exactly when low[node] > disc[parent]. This is correct because any other route between the two sides of that edge has to leave node's subtree by some edge, and an undirected DFS has no edges across subtrees, so that edge climbs to parent or above and would make low[node] <= disc[parent]. The trap is the comparison: bridges need the strict low[node] > disc[parent]. With >=, which is the test for articulation points, an edge whose subtree climbs back exactly to parent (so it sits on a cycle) is wrongly reported.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Testing low[node] >= disc[parent] instead of >

    Mistake
    if low[node] >= disc[parent]:
    Fix
    if low[node] > disc[parent]:

    What happens: low[node] == disc[parent] means the subtree of node climbs back exactly to parent. So the edge parent -> node lies on a cycle, and removing it disconnects nothing. >= reports it anyway. >= is the test for articulation points, not for bridges.

    Example: Critical Connections in a Network (LC 1192): n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns [[1, 3], [0, 1]]Fix: returns [[1, 3]]
  2. 2

    Gotcha 2: Writing the search as a recursive dfs

    Mistake
    def dfs(node, in_edge):
        ...
        dfs(nxt, edge)
    Fix
    stack = [(root, -1, 0)]

    What happens: A recursive dfs makes one nested call per node on the current path. Critical Connections in a Network (LC 1192) allows 10^5 servers in one chain. Python's default recursion limit is 1000. A chain of 2000 servers already raises a RecursionError. The blueprint keeps its path in stack, which has no such limit.

    Example: n = 2000 servers in one chain: [[0, 1], [1, 2], ..., [1998, 1999]]
    Mistake: raises RecursionErrorFix: returns 1999
  3. 3

    Gotcha 3: Following the edge back to the parent

    Mistake
    nxt, edge = graph[node][idx]
    Fix
    nxt, edge = graph[node][idx]
    if edge == in_edge:
        continue

    What happens: The tree edge parent -> node is also in graph[node]. If it is followed back, node reaches parent through it. So low[node] <= disc[parent] always holds, and no bridge is ever found. The check skips that one edge by its id, in_edge. Two parallel edges have different ids, so the second parallel edge is still followed as a back edge.

    Example: n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns []Fix: returns [[1, 3]]
  4. 4

    Gotcha 4: Not passing low[node] up to the parent

    Mistake
    parent = stack[-1][0]
    Fix
    parent = stack[-1][0]
    low[parent] = min(low[parent], low[node])

    What happens: low[parent] must learn how high the subtree of node can climb. Without this line, low[parent] sees only the back edges of parent itself. An edge above a cycle then looks like a bridge.

    Example: n = 4, connections = [[0, 1], [1, 2], [2, 0], [1, 3]]
    Mistake: returns [[1, 3], [0, 1]]Fix: returns [[1, 3]]
Canonical LeetCode:Critical Connections in a Network (LC 1192)Minimum Number of Days to Disconnect Island (LC 1568)
Open the Tarjan's Bridges card
Built on the mental models above
  • Cycle DetectionCycle Detection in a Directed Graph (DFS Three-Color)

    A node has three states, not seen, on the current path and finished, and reaching a node on the current path proves a cycle.

  • Strongly Connected ComponentsStrongly Connected Components (Kosaraju / Tarjan)

    Record the finish order, then search the reversed graph in reverse finish order, and each search finds one group.

  • Bipartite CheckBipartite Graph (Two-Coloring)

    Each node gets one of two sides, a neighbour gets the other side, and an edge inside one side means the answer is no.

  • Undirected CycleCycle Detection in an Undirected Graph (Parent Check)

    The search remembers the node it came from, and reaching an already-seen node that is not that parent proves a cycle.

  • Graph PathsTraverse an Adjacency List and Carry a Result (All Paths From Source to Target)

    The graph is an adjacency list, and the walk carries a path that it extends, copies at the target and restores. No visited set: every path counts.

  • Bellman-FordBellman-Ford (K-Stop Relaxation)

    Edges are relaxed in rounds instead of popping the cheapest node, so a path is limited to a number of edges and negative weights are allowed.

  • A Star SearchA* Search (Cost So Far Plus a Guess)

    The heap orders states by the cost so far plus a guess of the cost still to go, and the guess must never be too large.

  • Tarjan SCCTarjan's Strongly Connected Components (One Pass with Low-Link)

    The same discovery time and low-link run on a directed graph with a stack, and a node whose low-link equals its own discovery time is the root of one group.

The Graphs Decision Rule

When facing an unseen graph problem, ask in this order:

Step 1.Does the problem ask for connected component count or flood filling on a grid?
➔ Flood Fill
Number of Islands (LC 200)Number of Provinces (LC 547)

Scan every cell or node; each unvisited one starts a new group, and a DFS or BFS marks its whole group visited.

Step 2.Must nodes be split into two groups so every edge runs between them, or must you detect an odd cycle in an undirected graph?
➔ Bipartite Check
Is Graph Bipartite? (LC 785)Possible Bipartition (LC 886)

Color each component by BFS or DFS, alternating colors; an edge between two nodes of the same color means it is not bipartite.

Step 3.Does the problem require ordering tasks with prerequisites?
➔ Topological Sort
Course Schedule II (LC 210)Course Schedule (LC 207)

Build graph and run Kahn's algorithm (in_degree == 0 queue).

Step 4.Must you detect a cycle in a directed graph, or find the nodes that can't reach one?
➔ Cycle Detection
Course Schedule (LC 207)Find Eventual Safe States (LC 802)

Color nodes WHITE, GREY while on the DFS path and BLACK when finished; reaching a GREY node means a cycle.

Step 5.Does the problem ask for the cheapest / shortest path with non-negative edge weights?
➔ Dijkstra
Network Delay Time (LC 743)Path With Minimum Effort (LC 1631)

Use min-heap with distance relaxation and stale-entry guard.

Step 6.Is there one start and one known goal, and can you compute for any state a guess of the cost still to go that is never too large?
➔ A Star Search
Shortest Path in Binary Matrix (LC 1091)Sliding Puzzle (LC 773)

Order the heap by cost + guess(state), skip a stale entry, and check the goal when it is popped, not when it is pushed.

Step 7.Is the number of edges on the path capped (at most k stops), or can some edge weights be negative?
➔ Bellman-Ford
Cheapest Flights Within K Stops (LC 787)Minimum Cost to Reach Destination in Time (LC 1928)

Relax every edge k + 1 times, each round reading only the previous round's distances.

Step 8.Do you need the distance between every pair of nodes of a small graph (a few hundred nodes)?
➔ Floyd-Warshall
Find the City With the Smallest Number of Neighbors at a Threshold Distance (LC 1334)Course Schedule IV (LC 1462)

dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]), with k as the outer loop.

Step 9.Must every node be connected at the lowest total edge cost?
➔ Kruskal's MST
Min Cost to Connect All Points (LC 1584)Connecting Cities With Minimum Cost (LC 1135)

Sort edges by weight and keep each edge whose ends Union-Find reports as not yet connected.

Step 10.Must you find the edges (or nodes) whose removal splits an undirected graph?
➔ Tarjan's Bridges
Critical Connections in a Network (LC 1192)Minimum Number of Days to Disconnect Island (LC 1568)

DFS with discovery times and low-links; edge (u, v) is a bridge when low[v] > disc[u].

Step 11.Must you find the groups of a directed graph where every node reaches every other?
➔ Strongly Connected Components
Planets and Kingdoms (CSES 1683)Flight Routes Check (CSES 1682)

Record DFS finish order, reverse every edge, then DFS in reverse finish order; each tree is one group.

Step 12.Must you group the nodes of a directed graph in one search over the original edges, with no reversed copy of the graph?
➔ Tarjan SCC
Flight Routes Check (CSES 1682)Planets and Kingdoms (CSES 1683)

Run one DFS with discovery times, low-links and a stack of waiting nodes; pop a group whenever a node's low-link equals its discovery time.

Step 13.Must every edge be used exactly once in one walk?
➔ Eulerian Path
Reconstruct Itinerary (LC 332)Valid Arrangement of Pairs (LC 2097)

Walk unused edges until stuck, add the stuck node to the route on the way back, then reverse the route.

Step 14.Must you tell whether an undirected graph has a cycle, or whether it is a tree (connected with no cycle)?
➔ Undirected Cycle
Graph Valid Tree (LC 261)Redundant Connection (LC 684)

Search from a node and remember the node it came from; an edge to a seen node that is not that parent closes a cycle, and a tree also needs every node reached.

Step 15.Must you list every path between two nodes of a graph with no cycle, or carry a value from the start node down every path?
➔ Graph Paths
All Paths From Source to Target (LC 797)Time Needed to Inform All Employees (LC 1376)

Walk the adjacency list with one shared path: append before the call, pop after it, save a copy at the target, and keep no visited set.

Pattern Architecture & Invariant Design Masterclass

A graph is the most general shape data comes in: a set of nodes, and a set of connections between them. That is all. There is no root, there is no "first" node, and — the part that changes everything — a path can lead you back to where you started.

Trees are graphs with the loops removed and a root chosen. Every technique in dfs.md and bfs.md still applies, plus one mandatory addition:

You must remember where you have already been. Without a visited set, a single cycle makes the traversal run forever.

Three sub-problems cover almost every graph question in an interview, and telling them apart is the actual skill:

  • "How many separate pieces are there?" → Traverse from every unvisited node; each traversal you start is one new piece.
  • "In what order can I do these tasks, given that some must come before others?" → Topological sort. Only possible when the graph has no cycles.
  • "What is the cheapest route, when steps cost different amounts?" → Dijkstra. BFS is wrong here, and knowing why is the point.

🔬 Variable Anatomy: The 7 Variables Every Graph Algorithm Uses

To connect abstract graph concepts with concrete Python syntax, here is the universal code anatomy showing what every variable does:

Code / Blueprint
from collections import defaultdict, deque
import heapq
 
def graphMasterTemplate(
num_nodes: int,
edges: list[list[int]],
source: int
) -> dict[int, int]:
# =========================================================================
# 1. THE TOPOLOGY & STATE STORAGE POOLS
# =========================================================================
adj = defaultdict(list) # 🕸️ Graph Adjacency List: node -> list of (neighbor, weight)
in_degree = [0] * num_nodes # 📥 Prerequisite Counter: tracks incoming DAG(Directed Acyclic Graph) edges
visited = set() # 🚫 Visited Deduplication Set: prevents infinite cycles
dist = {source: 0} # ⏱️ Shortest Distance Table: tracks minimal cost to each node
# =========================================================================
# 2. THE GRAPH BUILD PHASE
# =========================================================================
for u, v, w in edges:
adj[u].append((v, w))
in_degree[v] += 1 # In-degree populated for topological sorting
 
# =========================================================================
# 3. FRONTIER PROCESSING PIPELINE (DIJKSTRA / BFS / KAHN'S)
# =========================================================================
# Min-heap priority queue storing (current_distance, node_id)
min_heap = [(0, source)]
 
while min_heap:
d, u = heapq.heappop(min_heap)
 
# ⚠️ CRITICAL: Stale-entry guard for lazy Dijkstra heap relaxation
if d > dist.get(u, float('inf')):
continue
 
visited.add(u)
 
for v, weight in adj[u]:
new_dist = d + weight
if new_dist < dist.get(v, float('inf')):
dist[v] = new_dist
heapq.heappush(min_heap, (new_dist, v)) # ⚡ Edge relaxation
 
return dist
 
Variable Role Reference:
VariableRole in CodeQuestion It Answers❌ What Happens If Omitted or Mismanaged?
adjdefaultdict(list)"Who are my direct outgoing neighbors and edge weights?"❌ Without an adjacency map, querying neighbors requires scanning the entire edge list on every step (O(V⋅E)O(V \cdot E)O(V⋅E)).
visitedHash set / boolean table"Have I already scheduled or processed this vertex?"❌ Without visited, traversing a cyclic graph enters an infinite loop and crashes with call-stack/memory overflow.
in_degreeInteger array / map"How many unmet prerequisites block this task from executing?"❌ In Topological Sort (Kahn's), without tracking in-degree, dependent tasks execute prematurely out of topological order.
queue / stackdeque or list"Which node is next in line for BFS level-order or DFS depth traversal?"❌ Using list.pop(0) turns O(V+E)O(V + E)O(V+E) BFS into an unacceptable O(V2)O(V^2)O(V2) bottleneck.
distDistance lookup table"What is the absolute cheapest cost known to reach vertex vvv?"❌ Without maintaining a distance table, Dijkstra's algorithm cannot determine if an edge represents a valid relaxation.
min_heapPriority queue [(d, u)]"Which unfinalized node currently has the globally smallest provisional distance?"❌ Using a standard FIFO queue on weighted graphs produces incorrect non-shortest paths.
componentsInteger accumulator"How many isolated, disconnected clusters exist across the graph?"❌ Incrementing components inside the inner flood-fill traversal counts individual vertices instead of disjoint components.

🧠 The Universal Representation Contract:
In graph algorithms, graph structure is represented in two standard ways:

  • Explicit Adjacency List (adj[u] = [v1, v2]): Used for sparse node-and-edge graphs (e.g. Course Schedule, Network Delay). Built once in O(V+E)O(V + E)O(V+E) before traversal.
  • Implicit Coordinate Grids (dr, dc = [(-1,0), (1,0), (0,-1), (0,1)]): Used for 2D matrices (e.g. Number of Islands, Walls and Gates). Neighbors are dynamically computed on-the-fly without allocating adjacency lists.

🌟 The 3 Dimensions of Graph Algorithms & The Cycle-Breaker Principle

Every graph problem is governed by 3 Fundamental Dimensions:

Code / Blueprint
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE 3 DIMENSIONS OF GRAPH ALGORITHMS │
├───────────────────┬───────────────────────────────────┬─────────────────────┤
│ Dimension │ Mechanism in Code │ Question It Answers │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 1. FRONTIER ORDER │ Queue (BFS), Stack (DFS), │ "Which node gets │
│ (Discipline) │ or Min-Heap (Dijkstra) │ processed next?" │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 2. EDGE DYNAMICS │ Unweighted (step count), │ "How is transition │
│ (Weights/Dir) │ Directed DAG(Directed Acyclic Graph), or Weighted edges │ cost evaluated?" │
├───────────────────┼───────────────────────────────────┼─────────────────────┤
│ 3. CYCLE CONTROL │ `visited` set, in-degree counter, │ "How do we prevent │
│ (Loop Breaker) │ or 3-color state machine │ infinite loops?" │
└───────────────────┴───────────────────────────────────┴─────────────────────┘
 
How this plays out in actual problems:
🟢 Scenario A: Connected Components & Grid Flood Fill (Using Outer Scan + Sink)
  • In connected component problems (like Number of Islands), we want to count separate isolated clusters.
  • The Rule: The outer loop scans every cell. When it hits an unvisited land cell, increment components += 1 and launch a traversal to sink the entire island so the outer loop never sees it again.
  • The Code: if grid[r][c] == '1': count += 1; dfs(r, c)
🔵 Scenario B: Topological Sort on DAGs(Directed Acyclic Graphs) (Using In-Degree Reduction)
  • In dependency resolution (like Course Schedule II), tasks must be ordered such that all prerequisites precede dependent tasks.
  • The Rule: Only enqueue nodes whose in_degree == 0. When a node is popped, decrement in_degree[v] for all neighbors, and enqueue any neighbor whose in_degree reaches 0. If processed count <V< V<V, a cycle exists!
  • The Code: if in_degree[neighbor] == 0: queue.append(neighbor)
🟣 Scenario C: Single-Source Shortest Paths with Weights (Using Dijkstra's Heap Relaxation)
  • When edge weights vary, BFS fails because the earliest visited path is not necessarily the cheapest.
  • The Rule: Greedily pop the minimum provisional distance node from a min-heap. Relax outgoing edges with if d + weight < dist[v]: dist[v] = d + weight; heappush(...). Always guard stale entries with if d > dist[u]: continue.

💡 The Ultimate Takeaway Decision Rule:
When analyzing a new graph problem, ask: "Are edge weights present, and are there dependencies?"

  • Unweighted Grid / Components ➔ Outer scan + DFS/BFS in-place sink (O(V+E)O(V + E)O(V+E) or O(R⋅C)O(R \cdot C)O(R⋅C)).
  • Prerequisites / DAG(Directed Acyclic Graph) Order ➔ Kahn's Algorithm with in-degree 0 queue (O(V+E)O(V + E)O(V+E)).
  • Weighted Non-Negative Edges ➔ Dijkstra's Algorithm with Min-Heap (O((V+E)log⁡V)O((V + E) \log V)O((V+E)logV)).
  • Weighted 0 or 1 Edges ➔ 0-1 BFS with deque.appendleft / append (O(V+E)O(V + E)O(V+E)).

🎯 The 5 Essential Graph Paradigms & Archetypes
ArchetypeFrontier ManagementEdge / Neighbor RuleInvariant / Cycle Prevention
1. Connected Comps
(LC 200 / LC 733)
DFS / BFS stack or implicit recursion4-directional cardinal grid (r+dr, c+dc) in boundsSink in-place or mark visited at discovery; count in outer loop
2. Topological Sort
(LC 207 / LC 210)
FIFO deque of in_degree == 0In-degree array decrement in_degree[nxt] -= 1DAG(Directed Acyclic Graph) order valid iff dequeued elements count equals numNodes
3. Dijkstra Shortest
(LC 743 / LC 787)
Min-Heap (dist, u) priority queueGreedy edge relaxation d + w < dist[v]Stale-entry guard d > dist[u]; Non-negative edge weights only
4. 0-1 BFS Shortest
(LC 1368)
Double-ended deque front / back pushesCost 0 ➔ appendleft()
Cost 1 ➔ append()
Strict distance monotonicity without O(log⁡V)O(\log V)O(logV) heap overhead
5. Graph Bipartition
(LC 785)
BFS / DFS queue with color array2-Color alternating parity color[nxt] = 1 - color[curr]Graph is bipartite iff no adjacent nodes share same color
🏝️ 1. Connected Components & Grid Flood Fill (LC 200 & 733)
  • Mental Model: A grid is an implicit graph where each cell (r,c)(r, c)(r,c) connects to its 4 orthogonal neighbors.
  • Outer Counter vs Inner Drain: The counter must live in the outer grid scan (for r in range(R): for c in range(C)). The inner DFS/BFS traversal exists solely to consume ("sink") the entire connected cluster so it is never counted again.
  • Code: see the Flood Fill card.

📋 2. Topological Sort & DAG(Directed Acyclic Graph) Cycle Detection (LC 207 & 210 — Kahn's Algorithm)
  • Mental Model: Kahn's algorithm removes nodes with zero incoming prerequisites sequentially.
  • The Cycle Detection Guarantee: If all prerequisites can be satisfied, every node is enqueued and dequeued exactly once (len(order) == numCourses). If len(order) < numCourses, any remaining nodes are trapped in a cyclic dependency deadlock.
  • Code: see the Topological Sort card.

⚡ 3. Weighted Shortest Paths (LC 743 — Dijkstra's Algorithm)
  • Mental Model: Dijkstra uses a priority queue to greedily finalize shortest distances from origin in ascending order of total cost.
  • The Stale Entry Guard: Because Python's heapq does not support O(log⁡N)O(\log N)O(logN) decrease-key, we push new (new_dist, v) pairs whenever an edge relaxes. Outdated higher-cost entries remain in the heap and must be dropped with if d > dist[u]: continue.
  • Code: see the Dijkstra card.

🚀 4. 0-1 BFS with Double-Ended Queue (LC 1368)
  • Mental Model: When edges cost strictly 0 or 1, a collections.deque replaces the priority queue.
  • How It Works: Cost-0 transitions are pushed to the front (appendleft), while Cost-1 transitions are pushed to the back (append). This maintains strict distance monotonicity in O(V+E)O(V + E)O(V+E) linear time without O(log⁡V)O(\log V)O(logV) heap overhead!

🎨 5. Graph 2-Coloring & Bipartition Check (LC 785)
  • Mental Model: A graph is bipartite if its vertices can be partitioned into two sets such that no two adjacent vertices share the same color.
  • How It Works: Run BFS from every unvisited node. Assign color 0 to root. For every neighbor, if unvisited, assign 1 - color[curr]; if already visited and color[neighbor] == color[curr], return False immediately.

🌳 Evaluating Graph Topologies, State Management & Frontier Management

Building an accurate mental model of graph representation and frontier management prevents the most pervasive graph bugs:

1. Graph Representation Best Practices
  • Never build an adjacency list for a 2D matrix: On grids, neighbor offsets [(-1,0), (1,0), (0,-1), (0,1)] generate valid edges dynamically in O(1)O(1)O(1) without memory allocation.
  • Always verify edge direction in prerequisites: If taking course BBB is required before AAA ([A, B]), the directed edge points B→AB \rightarrow AB→A.
2. Frontier Discipline & Cycle Prevention
  • Mark on Enqueue / Discovery: In BFS, adding a node to visited at the moment it is placed in the queue prevents duplicate queue insertions across diamond graphs (A→B,C→DA \rightarrow B, C \rightarrow DA→B,C→D).
  • Iterative over Recursive for Large Grids: A 300×300300 \times 300300×300 grid contains 90,000 cells. Recursive DFS will exceed CPython's 1000-frame recursion limit; use an explicit stack or BFS queue.

💡 Staff SWE Architectural Invariant:
In graph traversal, the choice of frontier data structure completely defines the algorithm: a FIFO Queue produces unweighted shortest paths (BFS), a LIFO Stack produces deep component reachability (DFS), a Min-Heap produces weighted shortest paths (Dijkstra), and an In-Degree Array produces valid topological DAG(Directed Acyclic Graph) orderings (Kahn's).

Understanding Invariants: Visited State Tracking & In-Degree Reduction

Every node is processed exactly once. Marking seen immediately upon discovery guarantees O(V + E) or O(R · C) linear traversal and prevents infinite cycles.

Frontier management defines graph traversal: FIFO Queues yield BFS wavefronts, LIFO Stacks yield DFS reachability, Min-Heaps yield Dijkstra shortest paths, and In-Degree 0 Queues yield Topological orderings.

Cycle Prevention Invariant (Visited Discovery Guard)
Condition: If neighbor v in visited set or grid[r][c] == '0'
Action: skip neighbor exploration (continue / return)

Prevents infinite recursion loops across cyclic and bidirectional graph edges.

Topological DAG(Directed Acyclic Graph) In-Degree Reduction Invariant
Condition: If in_degree[neighbor] > 0 after decrementing incoming prerequisite edge
Action: do not enqueue neighbor until in_degree reaches strictly 0

Guarantees a dependent task executes only after all prerequisite tasks have fully completed.

Common Questions
  • "When doing BFS on a graph, why do we mark a node visited the moment we enqueue it, instead of when we actually pop and process it? Wouldn't checking at pop-time also stop the traversal from looping forever?"

    It would stop it from looping forever — but it wouldn't stop it from doing wasted, duplicate work, and in a diamond-shaped graph that duplication can multiply badly.

    Picture node A connected to both B and C, and B and C both connected to D. If you only mark visited at pop-time, D can get enqueued twice — once when B is processed, once when C is processed — because at the moment either of them looks at D, D hasn't been popped yet, so it doesn't look visited. Both copies of D sit in the queue. If the loop also skips a node that is already visited when it pops it, the second copy is thrown away and BFS stays correct, but the queue can hold one entry per edge (up to E) instead of one per node. If it does not skip, D's own neighbors are explored twice, and in a chain of such diamonds that duplication doubles at every diamond.

    Marking visited at the moment of discovery — right when you decide to enqueue a node — closes that window. By the time C looks at D, B has already claimed it, so C skips it. Every node is enqueued at most once, so the queue never holds more than V entries. The rule of thumb: in BFS, mark on enqueue; marking at pop-time is correct only together with a skip of already-visited nodes when they are popped, and it still lets the queue grow to E entries.

Coach discussion points
  1. 1.

    "Move your count += 1 inside the flood-fill function. What does your program now compute, and why?" — The number of land cells. Forces the student to articulate what the outer scan is for, which is the whole lesson of connected components.

  2. 2.

    "Your grid is 300 × 300 and entirely land. Walk me through what happens to your recursive solution." — Recursion depth. Then: what changes if you make it iterative, and does the space complexity actually improve? (It does not — O(R·C) either way — but the failure mode does.)

  3. 3.

    "You returned a topological order for a graph with a cycle in it. How would you have known?" — The len(order) == n check, discovered rather than recited. Follow up: "why is that check sufficient — could a cycle ever still produce a full-length order?"

  4. 4.

    "One of these edges now costs −3. Which of your three algorithms still work?" — Flood fill: unaffected (weights are irrelevant). Topological sort: unaffected. Dijkstra: broken, silently. Tests whether preconditions are held as real constraints or as decoration.

  5. 5.

    "You just solved Path With Minimum Effort. Now solve Swim in Rising Water without looking anything up -- what changes in your code, and what stays exactly the same?" — The min-heap, the visited/dist-table discipline, and the early-return-on-pop all stay identical; only the value being tracked moves from an edge quantity (abs(heights[nr][nc] - heights[r][c])) to a node quantity (grid[r][c]). Follow-up: "Can you also solve Swim in Rising Water with binary search plus BFS? Why does that alternate approach not work as naturally for Path With Minimum Effort?" (Because Path With Minimum Effort's bottleneck is an edge property, so "can I reach the destination using only edges with weight ≤ T" is a less natural reachability check than Swim in Rising Water's node-property version.)

Complexity analysis

Number of Islands — Time O(R · C), Space O(R · C) worst case. Every cell is examined by the outer scan once, and sunk by a flood at most once, so the total work is proportional to the number of cells. The space is the traversal frontier: an all-land grid means a single flood can hold R · C cells on the stack (or, recursively, R · C call frames — which on a 300 × 300 grid is 90,000 frames and will raise RecursionError in CPython). This is why the abstract template is iterative.

Topological sort — Time O(V + E), Space O(V + E). Building the adjacency list and in-degree array touches every edge once. Each node enters the queue at most once; each edge is relaxed once when its source is dequeued.

Dijkstra — Time O(E log V), Space O(V + E). Each edge can push one entry onto the heap, giving at most E heap operations at O(log V) each. (The lazy variant used above may hold up to E entries in the heap, hence log E — but log E ≤ 2 log V, so O(E log V) is the standard statement.) Say E log V, and be ready to explain that the log comes from the heap and nothing else.

The general statement for all three: O(V + E) is the floor for any traversal, because you must at minimum look at every node and every connection. Anything faster is not a traversal.

Production Systems Deployment & Motivation

A satellite photo of a coastline is stored as a grid of pixels, each labelled land or water. A geographer needs to count how many separate islands there are. Two land pixels belong to the same island when you can walk between them stepping only up, down, left, or right onto land. The count is not the number of land pixels — it is the number of connected clusters of them. The same computation counts connected components in a network outage map, distinct regions in a paint-bucket fill, and distinct clusters in a fraud graph.

Mastery Roadmap & Prerequisite Graph

💡 Don't worry if graph representations and connectivity rules feel complex right now! Most graph problems reduce to building an adjacency list and choosing the right traversal or disjoint-set primitive. As you progress through the Archetype Variants (from Number of Provinces and Kahn's Topological Sort to Dijkstra's Shortest Path and Accounts Merge), each hands-on challenge will cement this intuition until detecting cycles and managing visited states becomes second nature.

  • Counting land pixels instead of connected clusters: Incrementing the island count inside the recursive/queue expansion loop rather than exclusively in the outer grid scan. The counter must only increment when a new unvisited '1' is discovered.

  • Unbounded call stack overflow on large grids: Using recursion on a 300×300300 \times 300300×300 grid of solid land causes call stack depth of 90,000, exceeding Python's recursion limit. Iterative BFS or increasing recursion limit is required.

  • Assuming 8-directional diagonal connectivity: Treating diagonal neighbors as connected. The problem strictly specifies 4-directional horizontal and vertical adjacency.

Staff+ Engineering Perspective

Staff+ Systems Architecture & Hardware Considerations

ARCHITECTURAL SIGNALS & INTERVIEW TRIGGERS

Triggers: "relationships between entities", "shortest path in weighted network", "dependency resolution / cycle detection", "minimum spanning tree / network reliability". Discrete topological structures composed of vertices (VVV) and directed/undirected edges (EEE).

CONSTRAINTS & BOUNDS

Vertices V≤105V \le 10^5V≤105, edges E≤106E \le 10^6E≤106. Graph representation dictates memory: adjacency list requires O(V+E)O(V + E)O(V+E), while adjacency matrix requires O(V2)O(V^2)O(V2) memory and is strictly prohibited for sparse graphs.

FAANG PRODUCTION TRAPS & EDGE CASES

Confusing directed vs undirected edge insertions; Dijkstra's algorithm failing on graphs with negative edge weights (requires Bellman-Ford); unhandled disconnected components (must run search across an outer loop over all VVV); infinite cycles on cyclic networks.

© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•