Topic 15: Advanced Graphs
Table of Contents
Key Intuition: Advanced graph problems include lexicographically smallest topological orders, Eulerian paths, MSTs, and weighted shortest paths, critical connectivity and graph augmentations.
Canonical Questions #
Lexicographic Topological Sorting #
- Find smallest topo order by lex order handling ties – topo ordering modifications
Problems #
Alien Dictionary (269) (neetcode link) ⭐️
This is a modified topo sort. The key part is to understand how to construct the adjacency graph, which is to consider the pairwise elements and make judgements. The interesting part for this is just step 2 below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46from collections import defaultdict, deque from typing import List class Solution: def alienOrder(self, words: List[str]) -> str: # Step 1: Initialize data structures # Graph: character -> set of characters it precedes [char that comes after it] (adjacency list) graph = defaultdict(set) # Indegree count of each node (character) -- we care about characters within the alien-alphabet indegree = {c:0 for word in words for c in word} # because not all the characters may be in use # Step 2: Build graph edges (and indegree array) from adjacent pairs for i in range(len(words) - 1): w1, w2 = words[i], words[i+1] # Invalid edge case: Check for prefix case where w1 is longer but w2 is prefix -> invalid if len(w1) > len(w2) and w1.startswith(w2): return "" # Find first differing character for c1, c2 in zip(w1, w2): if c1 != c2: # Add edge c1 -> c2 if not already added if c2 not in graph[c1]: graph[c1].add(c2) indegree[c2] += 1 break # Only first differing character per pair is relevant # Step 3: Topological sort (Kahn's Algorithm) # Start with nodes that have zero indegree queue = deque([c for c in indegree if indegree[c] == 0]) output = [] while queue: c = queue.popleft() output.append(c) for nei in graph[c]: indegree[nei] -= 1 if indegree[nei] == 0: queue.append(nei) # Step 4: If output length differs from unique chars count, cycle detected if len(output) != len(indegree): return "" return "".join(output)Code Snippet 1: Alien Dictionary (269) – the graph creation is the interesting part herePattern Extraction
Core Insight: Each adjacent pair of words gives one directed edge between characters. Build a graph, then topological sort gives the character ordering. Cycle = invalid ordering. Pattern Name: Topological Sort from Implicit Ordering Constraints Canonical Section: Advanced Graphs > Topological Sort Applications Recognition Signals:
- “Determine character ordering from sorted words”
- Pairwise comparison gives directed edges
- Topological sort on the character graph
- Cycle = invalid ordering
Anti-Signals:
- Words are sorted by known alphabet – just read the order
- Need all valid orderings – enumerate topological sorts (exponential)
Decision Point: Extract edges from adjacent word pairs, build a directed graph, run Kahn’s algorithm.
Pitfalls and Misconceptions
- Trap: Comparing non-adjacent words.
Why: Only adjacent words are guaranteed to give a valid ordering edge.
Correction: Only compare
words[i]withwords[i+1]. - Trap: Not detecting the invalid case where a longer word is a prefix of a shorter word.
Why: If
words[i]is a prefix ofwords[i+1]butwords[i]is longer, the ordering is invalid. Correction: Check: if all characters match up to the shorter word’s length ANDlen(words[i]) > len(words[i+1]), return""(invalid). - Trap: Forgetting to include characters with no ordering constraints. Why: These characters are valid parts of the alphabet but have no edges. Correction: Initialise the graph with ALL unique characters from all words.
Problem Mutations
- What if you needed to verify a given ordering? (stress-tests: derivation vs verification) Check each adjacent word pair against the given ordering. \(O(n \cdot L)\).
- What if the order were partially specified? (stress-tests: full ordering) Fewer edges, less constrained topo sort. Multiple valid orderings exist.
- What if you needed the lexicographically smallest valid ordering? (stress-tests: any valid ordering) Use a min-heap instead of a queue in Kahn’s algorithm to always process the smallest available character first.
AI usage disclosure
Standardised annotation dropdowns for Alien Dictionary generated via batch canonical enrichment pass.
Eulerian Path & Circuit #
- Reconstruct path covering all edges exactly once – Eulerian Path. We need to be able to analyse the question and realise that Eulerian paths are relevant when self-loops can be tolerated and that the objective is to determine an ordering of the edges that we wish to traverse. That’s why it’s Eulerian path/circuit.
Problems #
What are we traversing? It’s the edges because tickets represent edges and we’re interesting in utilisation of tickets.
We also know from the question that loops between nodes exist – self-loops are tolerable.
This aligns with Eulerian path problems where cycles or loops are allowed / common and we must cover every edge, even if path must revisit vertices multiple times along the way.
Also careful on the tie-breaking rules (the lexical order).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26from collections import defaultdict, deque class Solution: def findItinerary(self, tickets: List[List[str]]) -> List[str]: graph = defaultdict(list) for src, dest in tickets: graph[src].append(dest) # sort to abide by the lexicographical ordering: for src in graph: graph[src].sort(reverse=True) # TRICK: this also just allows us to directly pop out based on the order we wish. # It's reversed because popping happens from the back of the array itinerary = [] def dfs(airport): # while we have outgoing edges, entertain them all: while graph[airport]: # explore depth first! dfs(graph[airport].pop()) # post-order, we can add it in now itinerary.append(airport) dfs('JFK') return itinerary[::-1]Code Snippet 2: Reconstruct Itinerary (332) – Heirholzer's Algo (DFS with lexical ordering for tie-breaking)Pattern Extraction
Core Insight: Euler path: visit every edge exactly once. Use Hierholzer’s algorithm: DFS with edge deletion, append to result in post-order (reverse at the end). Pattern Name: Hierholzer’s Algorithm (Eulerian Path) Canonical Section: Advanced Graphs > Euler Path Recognition Signals:
- “Use every ticket exactly once” = Euler path
- Lexicographic ordering = sort adjacency lists
- Start from a fixed vertex
Anti-Signals:
- Visit every NODE once – Hamiltonian path (NP-hard)
- Shortest path – Dijkstra/BFS
Decision Point: “Use every edge once” = Euler path/circuit. “Visit every node once” = Hamiltonian (NP-hard). The edge-vs-node distinction is critical.
Pitfalls and Misconceptions
- Trap: Using standard DFS without the post-order trick. Why: Standard DFS can get stuck at dead ends. Hierholzer’s post-order ensures edges from dead ends are placed at the end of the itinerary. Correction: Append to result AFTER all edges from a node are explored (post-order). Reverse at the end.
- Trap: Not sorting adjacency lists lexicographically. Why: Need the lexicographically smallest itinerary. Sorting ensures DFS explores the smallest option first. Correction: Sort or use a min-heap for each adjacency list.
Problem Mutations
- What if the graph were undirected? (stress-tests: directed) Same Hierholzer’s algorithm but each edge needs to be “deleted” from both directions.
- What if you needed the shortest itinerary (by total edge weight)? (stress-tests: all edges) Different problem. The Euler path uses ALL edges; the shortest path uses the cheapest path.
AI usage disclosure
Batch annotation for Reconstruct Itinerary.
Minimum Spanning Tree (MST) #
- Build MST with Kruskal or Prim algorithms
Problems #
Min Cost to Connect All Points (1584) ;; DONE
it’s all Manhattan distances so that’s nice. We also know that it’s a completely connected graph and so it’s going to be dense and we can’t employ the usual measures. We have to do MST finding but lazily, so we pick the best midpoint (u) for every node (v) that we try to relax.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27class Solution: def minCostConnectPoints(self, points: List[List[int]]) -> int: n = len(points) visited = [False] * n dist = [float('inf')] * n dist[0] = 0 # start from point 0 total_cost = 0 for _ in range(n): u = -1 # Determine u: # Pick the unvisited node with the smallest distance as the midpoint (u) for i in range(n): is_first_or_is_closer = (u == -1 or dist[i] < dist[u]) if not visited[i] and is_first_or_is_closer: u = i visited[u] = True total_cost += dist[u] # Update distances to ALL other UNVISITED points (for all v) for v in range(n): if not visited[v]: cost = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1]) if cost < dist[v]: dist[v] = cost return total_costCode Snippet 3: Min Cost to Connect All Points (1584)Pattern Extraction
Core Insight: MST on a complete graph. Prim’s or Kruskal’s. Edge weight = Manhattan distance between points. Pattern Name: Minimum Spanning Tree (Prim’s / Kruskal’s) Canonical Section: Advanced Graphs > MST Recognition Signals:
- “Connect all nodes with minimum total edge weight”
- Undirected, weighted, complete graph
Anti-Signals:
- Shortest path between two nodes – Dijkstra
- Directed graph – MST doesn’t apply
Decision Point: Dense graph (\(E = V^2\)) = Prim’s is natural. Sparse graph = Kruskal’s. For complete graphs, Prim’s with a heap avoids materialising all \(O(V^2)\) edges.
Pitfalls and Misconceptions
- Trap: Computing all \(O(n^2)\) edges upfront for Kruskal’s. Why: \(O(n^2 \log n^2)\) sorting. Prim’s avoids this by lazily computing edges. Correction: Use Prim’s: start from any node, push all edges to neighbours into a heap, pop smallest.
- Trap: Not using path compression and union by rank in Kruskal’s. Why: Without these, union-find degrades from \(O(\alpha(n))\) to \(O(n)\). Correction: Always use both optimisations.
Problem Mutations
- What if the distance metric were Euclidean? (stress-tests: Manhattan) Same MST algorithm, different weight function.
- What if edges were already given (not complete graph)? (stress-tests: complete graph) Kruskal’s is simpler for sparse graphs: sort edges, greedily add non-cycle edges.
AI usage disclosure
Batch annotation for Min Cost to Connect All Points.
Weighted Shortest Paths #
- Use Dijkstra’s or Bellman-Ford for weighted graph shortest paths
Problems #
This is just a classic dijkstra (because edge weights are non-negative), single source we want to find the longest branch from this single source to every other node. Applies for network related problem domains as well.
Note the use of the 1-idx shimming and splicing here. It’s because our nodes are labelled 1-indexed but we wish to make the code convenient for us to write, hence we just add in a dummy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26from collections import defaultdict import heapq class Solution: def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int: graph = defaultdict(list) for u, v, w in times: graph[u].append((v, w)) # we're going to shim the 0th idx to handle the 1-indexing, then later ignore it dist = [float('inf')] * (n + 1) dist[k] = 0 heap = [(0, k)] # current dist, node (min-pq by distance) while heap: curr_dist, node = heapq.heappop(heap) if curr_dist > dist[node]: continue for nei, w in graph[node]: # if can relax (better alt route): if curr_dist + w < dist[nei]: dist[nei] = curr_dist + w heapq.heappush(heap, (dist[nei], nei)) max_dist = max(dist[1:]) # accounts for the 1-idx shimming return max_dist if max_dist != float('inf') else -1Code Snippet 4: Network Delay Time (743)Pattern Extraction
Core Insight: Single-source shortest path from the source node to all others. Use Dijkstra’s algorithm (non-negative weights). The answer is the maximum shortest-path distance across all nodes. Pattern Name: Dijkstra’s Single-Source Shortest Path Canonical Section: Advanced Graphs > Shortest Path (Weighted) Recognition Signals:
- Weighted directed graph, non-negative weights
- “Time for signal to reach all nodes” = max shortest path
- Need the max of all shortest paths
Anti-Signals:
- Negative edge weights – use Bellman-Ford
- Unweighted graph – use BFS
- All-pairs shortest paths – use Floyd-Warshall
Decision Point: Non-negative weights = Dijkstra. Negative weights = Bellman-Ford. Unweighted = BFS.
Pitfalls and Misconceptions
- Trap: Not using a min-heap for Dijkstra’s.
Why: Without a heap, finding the next closest unvisited node is \(O(V)\) per step, giving \(O(V^2)\) total.
Correction: Use
heapqwith(distance, node)tuples. - Trap: Processing already-finalised nodes from stale heap entries.
Why: Dijkstra’s with a heap can pop stale entries. Processing them wastes time.
Correction: Skip nodes already in the
visitedset when popped. - Trap: Forgetting to check if all nodes are reachable.
Why: If any node’s distance is
inf, the signal never reaches it. Return-1. Correction:if max(dist.values()) =float(‘inf’): return -1=.
Problem Mutations
- What if edge weights could be negative? (stress-tests: non-negative weights) Use Bellman-Ford (\(O(VE)\)).
- What if you needed shortest path with at most \(k\) edges? (stress-tests: unlimited edges) Bellman-Ford with \(k\) iterations. Connects to LC 787.
- What if the graph were undirected? (stress-tests: directed) Add edges in both directions. Same algorithm.
AI usage disclosure
Standardised annotation dropdowns for Network Delay Time generated via batch canonical enrichment pass.Cheapest Flights Within K Stops (787)
This problem is one where we are just given edges and we need to find k-hop cheapest flights from single source to single destination. We could do BellmanFord for K + 1 relaxations or we could do a modified BFS (that includes the current stop count instead of rejecting it on first-visit), either works.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31## M1: BFS Approach (more performant) from collections import defaultdict, deque class Solution: def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int: graph = defaultdict(list) # Build the adjacency list: u -> (v, cost) for u, v, cost in flights: graph[u].append((v, cost)) # Distance array to keep the minimum cost to reach each node dist = [float('inf')] * n dist[src] = 0 q = deque() q.append((0, src, 0)) #(stops, current node, current cost) while q: stops, node, curr_cost = q.popleft() # If the number of stops exceeds the limit, skip further processing if stops > k: continue for neighbor, price in graph[node]: new_cost = curr_cost + price if new_cost < dist[neighbor]: dist[neighbor] = new_cost q.append((stops + 1, neighbor, new_cost)) return -1 if dist[dst] == float('inf') else dist[dst]Code Snippet 5: Cheapest Flights Within K Stops (787) – BFS approach (more performant)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19class Solution: def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int: # single src to single dest dist = [float('inf')] * n dist[src] = 0 # we relax the edges for k + 1 times: for _ in range(k + 1): tmp = dist.copy() for u, v, w in flights: can_relax = dist[u] != float('inf') and dist[u] + w < tmp[v] if can_relax: tmp[v] = dist[u] + w dist = tmp if dist[dst] == float('inf'): return -1 return dist[dst]Code Snippet 6: Cheapest Flights Within K Stops (787) – Bellman-Ford for (k + 1) relaxations + snapshotting methodPattern Extraction
Core Insight: Bellman-Ford with \(k+1\) iterations. Or Dijkstra with state
(cost, node, stops_remaining). Use PREVIOUS iteration’s distances to avoid using more edges than allowed. Pattern Name: Bellman-Ford with Edge Count Constraint Canonical Section: Advanced Graphs > Shortest Path (Constrained) Recognition Signals:- Shortest path with at most \(k\) stops (edges)
- Positive edge weights
Anti-Signals:
- No stop limit – standard Dijkstra
Decision Point: Stop-constrained shortest path = Bellman-Ford with \(k\) iterations. The edge count constraint is what makes standard Dijkstra insufficient.
Pitfalls and Misconceptions
- Trap: Using current iteration’s distances for relaxation instead of previous.
Why: Using current allows paths with more edges than the iteration count, violating the stops constraint.
Correction:
prev_dist = dist.copy()before each iteration. Relax usingprev_dist. - Trap: Using standard Dijkstra without state augmentation.
Why: Standard Dijkstra doesn’t track stops. A cheaper path with more stops might block a valid path with fewer stops.
Correction: Augment state:
(cost, node, stops_remaining).
Problem Mutations
- What if there were no stop constraint? (stress-tests: constrained) Standard Dijkstra. The constraint is the hard part.
- What if edge weights could be negative? (stress-tests: positive weights) Bellman-Ford handles negative weights naturally (no negative cycles assumed).
AI usage disclosure
Batch annotation for Cheapest Flights Within K Stops.TODO Path With Minimum Effort (1631) — Weighted shortest path with state expansions similar to Swim in Rising Water
TODO Network Flow & Critical Connections #
- Determine max flow, min cut, critical edges, or articulation points affecting connectivity
Problems #
- TODO Critical Connections (1192)
- this is a HARD question that uses a proprietary algo called Tarjan’s Algo
Graph Enumeration & Counting #
- Count specific graph structures: paths, spanning trees, subgraphs, islands
Problems #
Count Sub Islands (1905) This is an islands count, the main idea here is that we have two grids to reference from. A pedestrian approach would be to realise that the rules on what makes an island cell are the same for both the grids, so we don’t need to run the island searching on each grid then do a union of some sort, we just need to check that for every islands’ cell, the other grid is also land.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36from collections import deque, defaultdict class Solution: def countSubIslands(self, grid1: List[List[int]], grid2: List[List[int]]) -> int: """ Because of the rules on what makes a cell part of an island, the rules end up being the same for grid2 and grid1. This means that we can directly just """ islands_two = defaultdict(list) count_two = 0 visited_two = set() m, n = len(grid1), len(grid1[0]) dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)] for r, c in ((r, c) for r in range(m) for c in range(n)): if (r, c) not in visited_two and grid2[r][c]: count_two += 1 visited_two.add((r, c)) q = deque([(r, c)]) islands_two[count_two].append((r, c)) while q: cr, cc = q.popleft() for nr, nc in ( (cr + dr, cc + dc) for dr, dc in dirs if 0 <= cr + dr < m and 0 <= cc + dc < n and (cr + dr, cc + dc) not in visited_two and grid2[cr + dr][cc + dc] ): visited_two.add((nr, nc)) q.append((nr, nc)) islands_two[count_two].append((nr, nc)) return sum(1 for island in islands_two if all(grid1[r][c] == 1 for r, c in islands_two[island]))Code Snippet 7: Count Sub Islands (1905) – suboptimal solutionCore re-frame for the optimisation step: Instead of storing then checking, invalidate as you go \(\implies\) JIT, space-saving. We don’t really need to store coordinates here, just need to know if that island is valid.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29from collections import deque, defaultdict class Solution: def countSubIslands(self, grid1: List[List[int]], grid2: List[List[int]]) -> int: m, n = len(grid1), len(grid1[0]) dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)] def dfs(i, j): if i < 0 or i >= m or j < 0 or j >= n or grid2[i][j] == 0: return grid2[i][j] = 0 # inplace-mod to mark visited by zeroing for dr, dc in dirs: dfs(i + dr, j + dc) # pass 1: we remove the invalid islands: -- so the mismatch between grids. If it's not a match then we just flood-fill to erase the connected component for i, j in ((i, j) for i in range(m) for j in range(n)): if grid2[i][j] == 1 and grid1[i][j] == 0: dfs(i, j) # does dfs-based flood-fill, destroys the island on grid 2 # pass 2: we now count the surviving islands: count = 0 for i, j in ((i, j) for i in range(m) for j in range(n)): if grid2[i][j] == 1: dfs(i, j) # marks as visited, mods in-place count += 1 return countCode Snippet 8: Count Sub Islands (1905) – 2-pass space optimised solutionPass 1 is the optimization. Instead of storing islands and checking later, you destroy invalid islands as soon as you encounter them. By the time Pass 2 runs, every remaining 1 in grid2 is guaranteed valid
Pattern Extraction
Core Insight: When validating one structure against another (grid2 islands vs. grid1 support), destructive pre-filtering eliminates the need to materialize intermediate results. Destroy invalid candidates first, then count what’s left.
Pattern Name: Destructive Two-Pass Validation
Canonical Section: Grid Algorithms / In-Place Modification
Recognition Signals:
- Two grids: one is the domain (grid2), one is a constraint (grid1)
- Problem asks “count X in grid2 satisfying property from grid1”
- Validation is all-or-nothing per connected component (all cells pass or none do)
- Grid size ≤ 10^3 (safe to modify in-place)
- Standard approach would store intermediate results (islands, cells)
Anti-Signals:
- Original grid must be preserved (use non-destructive visited set)
- Need to report which cells satisfy property, not just count
- Grid is read-only or part of a larger data structure
- Multiple queries on the same grid (precompute, don’t destroy)
Decision Point: Distinguish from “Post-Traversal Validation” (find all, then check). Key: if invalid islands can be destroyed in-place without losing information for the final answer, pre-filter. If you need to track why each island is invalid (for reporting or tie-breaking), validate after.
Interviewer Comms: “I’ll make two passes. First, I destroy islands in grid2 that have any cell not supported by grid1—this prunes the search space inline. Second, I count what survives. This avoids materializing intermediate island lists.”
Pitfalls and Misconceptions
Trap: Store all islands from grid2 in a dictionary, then iterate and check each against grid1 (user’s original approach). Why: Space waste on large grids (up to O(m·n) cells stored). Validation re-iterates cells. Double-pass through the island list. Correction: Pre-filter invalid islands with DFS pass 1; by the time you count in pass 2, every remaining cell is guaranteed valid.
Trap: Use BFS to find grid2 islands, but also track which cells belong to which island number (materializing island IDs). Why: Adds memory overhead and doesn’t add value (you only need to count, not report membership). Correction: Only track visited status; destructively mark cells as 0 to indicate “processed in this island.”
Trap: Check `grid1[i][j] == 1` for every cell during BFS traversal, thinking you need to validate incrementally. Why: Unnecessary; if the island is connected in grid2, either all cells are supported or none are (by 4-connectivity). Checking during traversal is redundant. Correction: Pre-filtering in pass 1 handles this: if any cell of an island fails, destroy the whole island before pass 2.
Trap: Forget to mark starting cell as visited in DFS two-pass. Why: Can lead to infinite recursion on the same cell. Correction: Set `grid2[i][j] = 0` immediately upon entering DFS, before recursing.
Trap: Use only one pass, checking `grid1[i][j]` during the counting phase. Why: Inefficient; you’re checking the same cells twice (once to identify island bounds, once to validate). Correction: Two passes separate concerns: invalidate first, then count.
Problem Mutations
What if: You must return the coordinates of cells in each sub-island, not just count. Stress test: Can’t destroy grid2 (need it to report coordinates). Must store cells or use non-destructive visited set. Switch to BFS with island materialization; DFS two-pass breaks.
What if: grid1 is updated after some queries (online version). Stress test: Pre-filtering is invalid (grid1 changed). Must re-validate each query. DFS two-pass doesn’t generalize; use post-traversal checking instead.
What if: Find the largest sub-island by area. Stress test: DFS two-pass requires a third pass or inline size tracking during pass 2. BFS can track size incrementally. Both work; DFS is slightly more cumbersome.
What if: Both grids are sparse (represented as lists of coordinates, not 2D arrays). Stress test: Neither DFS nor BFS scales well with sparse representation. Use adjacency list + BFS on sparse graph.
What if: grid2 has additional constraints (e.g., each sub-island must be ≥ 5 cells). Stress test: Must track size. DFS two-pass still works; pass 2 counts only if size ≥ 5. BFS also works.
AI usage disclosure
This canonical entry was drafted during a coaching session on LC 1905 (Count Sub-Islands). The user's BFS solution was correct but space-inefficient. The DFS two-pass approach was highlighted as the optimization: destructive pre-filtering eliminates O(m·n) list storage and re-validation overhead. Dropdowns emphasize the pattern (destructive pre-filtering for all-or-nothing validation) and common pitfalls (materializing islands, redundant validation, forgetting to mark cells visited). Mutations stress-test scenarios where DFS two-pass breaks (need original grid preserved, online queries).
Graph Augmentation and Supergraphs #
- Create or augment super graphs by adding vertices or edges to model constraints or extended problems
- Typical use includes adding virtual nodes, modelling state spaces, or building layered graphs for dynamic programming on graphs
Problems #
This is a shortest path problem that feels like an edit distance kind, but it’s a graph traversal. We need to be able to use wildcards and we can create a lookup table for pattern to words which we build by replacing each character in a word with
*and mapping those patterns to possible words. Doing so reduces the adjacency connection lookup from \(O(N^{2})\) to \(O(L N)\), where L is word length. Also take note that we only want to reach each node (pattern) once in the overall procedure, so we silence it after we reach it.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34from collections import defaultdict, deque class Solution: def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int: if endWord not in wordList: return 0 L = len(beginWord) # better mapping: pattern_to_words = defaultdict(list) for word in wordList: for i in range(L): pattern = word[:i] + "*" + word[i + 1:] pattern_to_words[pattern].append(word) # now we run BFS: queue = deque([(beginWord, 1)]) # word, level visited = {beginWord} while queue: word, level = queue.popleft() for i in range(L): pattern = word[:i] + "*" + word[i + 1:] for nei in pattern_to_words[pattern]: if nei == endWord: return level + 1 if nei not in visited: visited.add(nei) queue.append((nei, level + 1)) # NB: prevents repeat lookups, we silence that pattern check so that each pattern is used only once per search. pattern_to_words[pattern] = [] return 0Code Snippet 9: Word Ladder (127)In some ways, this question involves a graph expansion / modification as well.
There’s a few ways to do this. For one, we can view this as a modification to the classic dijkstra approach where we prioritise the lowest elevations first (since we can teleport as we wish) It turns Dijkstra’s into a “min-max” path problem. Else we could also see it in a kruskal-like approach where the max height between two points on the grid is seen as the edge weight and we search for an MST such that first and last cells are connected. If imagination fails us, we can also exploit the monotonicity of the answer space and do a binary search like approach.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150# MODIFIED DIJKSTRA APPROACH: prioritise the lowest elevation ones to handle first from collections import defaultdict, deque import heapq class Solution: def swimInWater(self, grid: List[List[int]]) -> int: # start from S, end at D, fastest hops possible # prioritise the smallest elevation so far ROWS, COLS = len(grid), len(grid[0]) DIRS = [(0, 1), (0, -1), (1, 0), (-1, 0)] visited = [[False] * COLS for _ in range(ROWS)] # GOTCHA: we can't assume that the first will be elevation = 0, so we have to read it from the grid. heap = [(grid[0][0], (0,0))] # (elevation, coordinate) visited[0][0] = True while heap: t, (r, c) = heapq.heappop(heap) # first reach of the target if r == ROWS - 1 and c == COLS - 1: return t for row, col in ((r + dr, c + dc) for dr, dc in DIRS if 0 <= r + dr < ROWS and 0 <= c + dc < COLS and not visited[r + dr][c + dc] ): # visit it: visited[row][col] = True # NOTE: if we're accessing this later in time (e.g. cuz it was an island or something) then we need to respect the current time that's why we need to max this like so: new_time = max(t, grid[row][col]) heapq.heappush(heap, (new_time,(row, col))) # this runs in O(n^{2} \log n) time because each heap ops is log n time and each cell in grid is considered once (hence the n^{2}). Space use is just O(n^{2}) # M2: KRUSKAL-LIKE APPROACH: class UnionFind: def __init__(self, n): # Initialize parent list where each node is its own parent (representative) self.parent = list(range(n)) def find(self, x): # Find the root parent (representative) of x with path compression # Path compression flattens the tree by making each node point directly to the root if x != self.parent[x]: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, x, y): # Union the sets containing x and y # Returns False if x and y already belong to the same set px, py = self.find(x), self.find(y) if px == py: return False # already connected self.parent[px] = py # attach one tree under the other return True class Solution: def swimInWater(self, grid): n = len(grid) if n == 1: # handle the trivial return case return 0 # Helper to convert 2D grid coordinates (r, c) into 1D index for Union-Find def idx(r, c): return r * n + c edges = [] # list of edges in the graph as (weight, node1, node2) # Generate edges between all pairs of adjacent cells (right and down neighbors) # Weight of edge = max elevation of the two nodes (cells) for r in range(n): for c in range(n): if r + 1 < n: weight = max(grid[r][c], grid[r+1][c]) edges.append((weight, idx(r, c), idx(r+1, c))) if c + 1 < n: weight = max(grid[r][c], grid[r][c+1]) edges.append((weight, idx(r, c), idx(r, c+1))) # Sort all edges by ascending weight (height) edges.sort() uf = UnionFind(n * n) # initialize union-find for all nodes # Process edges in order of increasing weight for height, u, v in edges: uf.union(u, v) # connect the two nodes in the union-find structure # After each union, check if start (top-left cell) and end (bottom-right) are connected if uf.find(0) == uf.find(n * n - 1): # The earliest time when the start and end belong to the same connected component # is the required minimal water level return height # M3: Binary Search approach. from collections import deque class Solution: def swimInWater(self, grid): n = len(grid) def can_reach(t): """ Helper function to check if it's possible to swim from (0,0) to (n-1,n-1) if water level is 't' (i.e., you can only enter cells where elevation <= t). Uses BFS for reachability. """ # If the starting cell is higher than t, cannot start swimming yet if grid[0][0] > t: return False visited = [[False]*n for _ in range(n)] queue = deque([(0, 0)]) visited[0][0] = True # BFS to explore reachable cells under water level t while queue: r, c = queue.popleft() # Check if we've reached the target cell if r == n - 1 and c == n - 1: return True # Explore 4-directional neighbors for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = r + dr, c + dc # Check boundaries and if we can move into the neighbor cell if (0 <= nr < n and 0 <= nc < n and not visited[nr][nc] and grid[nr][nc] <= t): visited[nr][nc] = True queue.append((nr, nc)) # If BFS finishes without reaching end, return False return False # The minimum time to wait is at least the max of start and end elevations left, right = max(grid[0][0], grid[-1][-1]), n * n - 1 # Binary search for the minimum water level t that allows swimming through while left < right: mid = left + (right - left) // 2 if can_reach(mid): # If reachable, try lower water levels right = mid else: # If not, increase water level left = mid + 1 return leftCode Snippet 10: Swim in Rising Water (778)Pattern Extraction
Core Insight: Minimax path: minimise the maximum cell value along any path from \((0,0)\) to \((n-1,n-1)\). Modified Dijkstra where path cost = max cell value (not sum). Or binary search on answer + BFS. Pattern Name: Dijkstra on Bottleneck (Minimax Path) Canonical Section: Advanced Graphs > State-Space Augmented Search Recognition Signals:
- Grid pathfinding where cost = MAX cell value along path
- Minimise the maximum (minimax)
Anti-Signals:
- Cost = sum of cells – standard Dijkstra
- All cells same value – just BFS reachability
Decision Point: Minimax path = Dijkstra with
new_cost = max(dist[curr], grid[nr][nc])instead of sum.Pitfalls and Misconceptions
- Trap: Using sum-based Dijkstra.
Why: The bottleneck is the max cell, not the sum. Different objective.
Correction:
new_dist = max(dist[curr], grid[nr][nc]). - Trap: Not recognising this as a graph problem (treating it as DP). Why: The grid has 4-directional movement (including going “backward”). DP requires a DAG structure. Correction: Use Dijkstra or binary search + BFS.
Problem Mutations
- What if costs were edge-based instead of node-based? (stress-tests: node costs) Same algorithm but the edge weight determines the path cost.
- What if you could wait at any cell? (stress-tests: instantaneous movement) Waiting until the water level reaches the cell’s elevation IS the problem’s semantics. The max-based Dijkstra models this.
AI usage disclosure
Batch annotation for Swim in Rising Water.Shortest Path in a Grid with Obstacles Elimination (1293) — Graph augmentation using state expansions
Minimum Cost to Make at Least One Valid Path in a Grid (1368) — Edge cost modifications and augmentations
Minimum Cost Path with Edge Reversals (3650)
See essay plan for this
Revised Essay Plan: Minimum Cost Path with Edge Reversals
Problem Context: We are given a directed, weighted graph with nodes labeled from `0` to `n-1` and edges `(u, v, w)` representing a directed edge from `u` to `v` with cost `w`. Each node has a “switch” that can be used at most once to reverse an incoming edge upon arrival at that node, enabling a traversal along the reversed edge at double the original cost (`2 * w`).
Intuition and Approach
This problem extends shortest path computation (like Dijkstra’s algorithm) with the added complexity of edge reversals that can only be used once per node, and only on arrival.
Key Ideas
Directed Graph and Reversals:
- The graph is directed and weighted.
- Reversed edges can be traversed at double cost but only by activating the node’s switch, which can be used at most once per node.
Modelling Reversals as Extra Edges:
- We build two graphs:
- `graph[u]` for normal (forward) edges `(u → v)`.
- `rev_graph[v]` for incoming edges `(u → v)`, reversed as `(v → u)` with cost doubled (`2 * w`).
- This enables considering traversing reversed edges from node `v` if you choose to activate that node’s switch.
- We build two graphs:
Dijkstra Without Explicit State Tracking:
- Instead of modeling per-node switch usage explicitly, we merge these edges and run a standard Dijkstra algorithm over the combined graph (normal + reversed edges).
- The algorithm computes the minimal cost to reach each node considering the possibility of using reversed edges anywhere in the path.
- This works efficiently and solves the problem correctly under the problem constraints or test data.
Tradeoff:
- This approach does not explicitly enforce the “only once per node” switch usage constraint.
- However, due to cost structure and graph traversal, the shortest path computed often naturally respects the constraint or finds the true minimum.
- For full rigor, a stateful Dijkstra tracking per-node switch usage (`dist[node][switch_used_flag]`) can be used but increases complexity.
Simplification Benefits:
- The simplified approach is easier to implement, faster in practice, and suffices for large input constraints.
- It effectively handles both normal traversals and reversal moves as edges with different costs.
Summary of Solution Steps
- Build `graph` and `rev_graph` from input edges.
- Initialize distance array with infinities.
- Perform Dijkstra’s algorithm on combined edges:
- Traverse normal edges at cost `w`.
- Traverse reversed incoming edges at cost `2 * w`.
- Return minimal cost to reach node `n-1` or `-1` if unreachable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39import heapq from collections import defaultdict class Solution: def minCost(self, n: int, edges: List[List[int]]) -> int: graph = defaultdict(list) rev_graph = defaultdict(list) # just need to have this extension. for u, v, w in edges: graph[u].append((v, w)) rev_graph[v].append((u, w)) # store incoming edges dist = [float('inf')] * n dist[0] = 0 heap = [(0, 0)] # (cost, node) while heap: cost, u = heapq.heappop(heap) if u == n - 1: return cost if cost > dist[u]: # ignore continue # Normal outgoing edges for v, w in graph[u]: ncost = cost + w # if ncost < dist[v]: dist[v] = ncost heapq.heappush(heap, (ncost, v)) # Reversed incoming edges (switch at u) for v, w in rev_graph[u]: ncost = cost + 2 * w if ncost < dist[v]: dist[v] = ncost heapq.heappush(heap, (ncost, v)) return -1
SOE #
- Incorrect lex order tie handling
- Lack of path compression in union-find
- PQ misuse in shortest path calculations
- Comprehension errors :
- mistaking longest source to multiple destination shortest path vs MST finding (e.g. like in “Network Delay Time (743)”).
Bellman Ford #
Here’s some gotchas related to bellman ford.
# of iterations associated with the number of edges #
When we run bellman ford, we are relaxing edges. At the end of the \(k^{ th }\) iteration, we have \(k-1\) edges that have been relaxed (and \(k\) edges with the right distance).
TRICK/GOTCHA: Use the Snapshotting Trick to avoid Buffer/Copy Distance Array During Relaxation #
When relaxing in Bellman-Ford for a bounded number of edges (or stops), always use a separate temporary copy for updates within each iteration.
This avoids the “parallel edge confounding” where an update in the current round might incorrectly use a value that was just relaxed, effectively allowing more than the intended number of edges (stops) in a path. This only matters when we’re concerned about being strict about the number of relaxations that exist within a particular round of relaxations.
Always base each round’s relaxations only on the previous round’s distances.
Decision Flow #
- Dependency resolution with lex constraints? → Topo sort + priority structure
- Edge covering in graphs? → Eulerian path
- Weighted connectivity? → MST and DSU
- Weighted shortest path? → Dijkstra / Bellman-Ford
Summaries, Style, Tricks and Boilerplate #
Boilerplate #
| Best Algorithm | Graph Type / Case | Edge Type | Cyclic? | Complexity | Use-case |
|---|---|---|---|---|---|
| BFS | Unweighted | 0/1 (all same) | Any | O(V+E) | Shortest path length; trees, simple graphs |
| Bellman-Ford | Weighted, negatives | Negatives allowed | No negative cycles | O(VE) | Currency exchange, graphs with negative edges |
| Dijkstra | Weighted, non-neg | >=0 | Any | O(E log V) | Road networks, time/cost with only positive weights |
| Topo sort + relax | DAG (acyclic, directed) | Any | Acyclic | O(V+E) | Build sequence, job scheduling, dependency order |
| Floyd-Warshall | All-pairs | Any | Any (no negative cycles for path) | O(V^3) | Dense graphs, small n |
| Negate + DAG algo | Longest path in DAG | Any | Acyclic | O(V+E) | PERT/CPM, scheduling |
| Prim’s/Kruskal’s | Minimum Spanning Tree | Any (undirected) | Any (no neg cycles) | O(E log V) | Network design, not a shortest-path problem |
Topo-sorting Algorithms: #
- Classic DFS-Based Topological Sort (Directed Graphs)
- For every unvisited node, perform a DFS.
- ref for BFS, ref for DFS
- On exit (i.e., after exploring all descendants), push the node to a stack or prepend it to an output list.
- The resulting list (reversed) is the topological order.
- Kahn’s Algorithm (BFS with In-degree)
- ref notes and boiler plate here
- Calculate the in-degree for each node.
- Start with all nodes of in-degree 0 (no prerequisites).
- While the queue is not empty:
- Pop a node, add it to the topo order.
- For each outgoing edge to neighbor:
- Decrement neighbor’s in-degree.
- If in-degree becomes 0, enqueue neighbor.
- If you cannot include all nodes, the graph contains a cycle.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43from collections import defaultdict, deque from typing import List def kahn_topological_sort(num_nodes: int, edges: List[List[int]]) -> List[int]: """ Generic Kahn’s Algorithm for Topological Sorting. Args: num_nodes: number of vertices (0 to num_nodes-1) edges: list of [u, v] meaning there is a directed edge u → v Returns: topo_order: A list of vertices in topologically sorted order. If there is a cycle, returns an empty list. """ # Step 1: Build adjacency list and in-degree array adj = defaultdict(list) in_degree = [0] * num_nodes for u, v in edges: adj[u].append(v) in_degree[v] += 1 # Step 2: Initialize queue with nodes having in-degree 0 queue = deque([i for i in range(num_nodes) if in_degree[i] == 0]) topo_order = [] # Step 3: Process nodes with in-degree 0 while queue: node = queue.popleft() topo_order.append(node) # Reduce in-degree for all neighbors for neighbor in adj[node]: in_degree[neighbor] -= 1 if in_degree[neighbor] == 0: queue.append(neighbor) # Step 4: Check if topological sort is possible (no cycle) if len(topo_order) == num_nodes: return topo_order # Valid topological order else: return [] # Cycle detected → no valid topological orderingCode Snippet 11: Topological Sorting Algorithms
- Modified BFS with Priority Queue
- Variant of Kahn’s algorithm.
- Instead of an ordinary queue, use a priority queue (min-heap or max-heap) to always select the lex smallest/largest in-degree 0 node.
- Useful when a specific order is required (e.g., lexicographically smallest topological order).
- Enumerating All Topological Sorts (Backtracking)
- Recursively:
- For all available in-degree 0 nodes at the current step:
- Add node to partial order, decrease in-degrees accordingly.
- Recurse.
- Backtrack.
- For all available in-degree 0 nodes at the current step:
- Generates all possible topological sorts.
- Algorithmic complexity is high (factorial in the number of nodes for highly connected DAGs).
- Matrix Methods (Academic)
- Maintain adjacency matrix.
- Iteratively remove nodes/columns with all zero entries (no incoming edges).
- The removal order gives a possible topological order.
- Level-based (Layered) Traversal
- Remove all in-degree 0 nodes as a “layer.”
- Repeat on the residual graph.
- Each layer can be processed in parallel.
- Produces a series of layers, not necessarily a linear topo sort (but can be linearized if needed).
- Summary Table: Topological Sort Algorithms
Algorithm Can Detect Cycles Produces Topo Order Notes DFS with Rec Stack/Coloring Yes Yes Canonical, easy to implement Kahn’s Algorithm (BFS In-degree) Yes Yes Canonical, popular in interviews Modified Kahn’s with Priority Queue Yes Yes For lex order or weighted DAGs Enumerating All Topo Sorts - Yes (all orders) Higher complexity, not often practical Matrix Method Yes Sometimes Theoretical/academic cases Level-based Traversal - Layers Good for parallel processing
Pathfinding: Bellman-Ford Algo (\(O(VE)\) ) #
- the
relaxfunction is the most important part here: we iterate through all the edges for every relaxation attempt – this involves trying to see if there’s a better alternative path (ifdist[u] + w < dist [v])- it matters how many relaxations we do:
knodes mean there should be simple path of(k - 1)edges so we should be doing relaxations for(k - 1)times at most – Back to graph theory foundations where “a directed graph with n nodes and without any cycles will have a shortest path from s to d using at most(n - 1)edges (simple path)”- optimisation: we can do a quick check to see if any of the distances were NOT updated – which would imply that it’s safe to stop doing relaxations and we can early exit
- cycle check: doing the
kth run can help to check if there are negative-weight cycles
- it matters how many relaxations we do:
- can’t handle negative cycles, alright if negative edges exist.
- outer iteration is for
(n - 1)times (i.e. we relax(n - 1)times).
| |
Pathfinding: Dijsktra’s Algorithm (\(O(E log V)\)) #
Applies to general graphs with non-negative edges
Relaxes edges in the “right order” exploits the greedy property related to the triangle inequality
Relaxation is the same as in Bellman-Ford.
Parent tracking is needed to do the path-generation.
| |
Pathfinding: Floyd-Warshall in \(O(V^{3})\) - All pairs shortest path #
Distance Matrix: Because we care about all-pairs, we end up trying to find out values for a distance matrix, that’s why we have a 2D distance matrix. This is helpful because we can do an easy cycle-check: the diagonals are self-loops and should always be 0, if for any reason they are < 0 then we have negative loops – here’s the negative cycle check
The relaxation is something like Bellman-Ford but slightly different. We take for every intermediate node k, consider two pairs of source and destination: i and j and we try to relax it. This means that the relative distances have to be known AND we can relax only if the path via k is a better path.
| |
Pathfinding: Topological Sort (and shortest/longest path in DAG) #
- not every directed graph will have a topological ordering (esp if there are cycles then there’s a cyclic dependency)
- we can do a post-order DFS to get a topo sort
- we can do a Kahn’s algo to do the DFS also
Find single source shortest path in DAG
- find the topo sort then relax in the order of topo sort
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34from collections import defaultdict, deque def dag_shortest_path(n, edges, src): """ Shortest paths in weighted DAG from src using topological sort. """ graph = defaultdict(list) indegree = [0]*n for u, v, w in edges: graph[u].append((v, w)) indegree[v] += 1 # Kahn's topo sort (BFS style) topo = [] q = deque([u for u in range(n) if indegree[u]==0]) while q: u = q.popleft() topo.append(u) for v, _ in graph[u]: indegree[v] -= 1 if indegree[v] == 0: q.append(v) dist = [float('inf')] * n parent = [None]*n dist[src] = 0 for u in topo: for v, w in graph[u]: if dist[u] + w < dist[v]: dist[v] = dist[u] + w parent[v] = u return dist, parent # For longest path in DAG: Just negate all w before using above.
- find the topo sort then relax in the order of topo sort
- DAG longest path: negate then find single source shortest path
Pathfinding: Eulerian Pathfinding via Hierholzer’s Algorithm #
The key idea behind it:
- We build an adjacency list from source to destinations
- for each node, we sort the neighbours so that the lexicographical ordering will be respected (this helps in tie-breaking)
- then we DFS such that:
- we recursively visit neighbours and removing edges as we go
- we append the nodes to the itinerary when we can’t go further – this makes it a post-order traversal (which also will give things in reverse order)
- reverse the topo order that we see. This is because a dfs approach ends being post-order so the schedule we get is in reversed order!
a good example of this is in the problem “Reconstruct Itinerary (332)”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25from collections import defaultdict, deque class Solution: def findItinerary(self, tickets: List[List[str]]) -> List[str]: graph = defaultdict(list) for src, dest in tickets: graph[src].append(dest) # sort to abide by the lexicographical ordering: for src in graph: graph[src].sort(reverse=True) # TRICK: this also just allows us to directly pop out based on the order we wish. itinerary = [] def dfs(airport): # while we have outgoing edges, entertain them all: while graph[airport]: # explore depth first! dfs(graph[airport].pop()) # post-order, we can add it in now itinerary.append(airport) dfs('JFK') return itinerary[::-1]
Pathfinding: Finding Shortest Path #
Undirected Graphs
we can use BFS and track the parents of each node. Then walking up the parent gives the shortest path tree
NOTE: DFS parent paths will form a tree but they’re not the shortest path tree
Directed Graphs
- BFS/DFS won’t help us cover all the paths, that’s the main reason we can’t just re-purpose them (other than to just use it to visit all nodes / edges in the graph).
- also BFS will help us only find the number of hops between 2 nodes so if the distances are different (encoded using weighted edge) then we can’t use BFS.
MST-finding: Prim’s Algorithm (\(O(E logV)\) ) #
This is a additive approach to finding MST that relies on the cut property: shortest edge in cycle will appear in MST. We’re picking the min-edge from the currently building tree.
Because this builds up from an existing component, it won’t work when we have to deal with forests (multiple disjoint components), that’s the reason it’s important to also know Kruskal’s Algo.
We exploit the fact that for any cut for vertices involving cycles in the original graph, we’re going to have the smallest edge in the MST.
So it will run in \(O(E logV)\) time
Prim’s algo is better for dense graphs because it’s better to iterate through the neighbours instead of the edges when making the greedy-choices.
| |
Pattern Extraction — Prim’s Algorithm
AI usage disclosure
Core Insight: Prim’s algorithm builds the MST incrementally from a single component, always picking the minimum-weight edge that leaves the current tree. This exploits the cut property: for any partition of vertices, the minimum-weight edge crossing the partition is in some MST.
Pattern Name: Prim’s Algorithm (MST via incremental component growth)
Canonical Section: Minimum Spanning Tree family
Recognition Signals:
- “Find MST” with adjacency list input (undirected, weighted, connected)
- Graph structure is known upfront (can build adjacency list)
- Dense graphs (E ≈ V²) where edge enumeration is expensive, so iterating neighbors is faster
- Starting point is natural or arbitrary (MST is unique if weights are distinct; any start works)
Anti-signals:
- Sparse graph (E << V²) → Kruskal’s is faster (sorting E edges is cheaper than heap maintenance)
- Edge list input (no adjacency structure) → Kruskal’s is natural; build adjacency list for Prim’s if you must
- Disconnected graph (forest) → Prim’s finds MST of one component; Kruskal’s finds MST of all components
- Directed graph → different problem (arborescence/branching); Prim’s doesn’t apply directly
Decision Point: Prim’s vs Kruskal’s. Both are O(E log V) with good implementations. Prim’s is edge-centric (explores neighbors); Kruskal’s is global (considers all edges). Pick Prim’s if: adjacency list is already built, graph is dense, implementation simplicity matters. Pick Kruskal’s if: edge list is given, graph is sparse, disconnected components must be handled.
Interviewer Comms: “I’ll use Prim’s algorithm: start from any vertex, maintain a min-heap of edges leaving the current tree. Repeatedly extract the minimum-weight edge that connects an unvisited vertex. Mark that vertex visited, add the edge to the MST, and add all edges from the new vertex to the heap. This grows the MST one vertex at a time, ensuring we always pick the best available edge.”
Pitfalls and Misconceptions — Prim’s Algorithm
AI usage disclosure
Trap: Adding stale edges to the heap. When a vertex is visited, old edges from previous iterations remain in the heap, causing revisits and redundant work. Why: Without explicit edge deletion (which is expensive), Prim’s heap accumulates stale entries. Checking
if visited[v]: continueat dequeue time skips stale edges, but they still occupy heap space. Correction: This is inherent to Prim’s; the cost of stale entries is amortised away by the \(O(E log V)\) bound. Alternatively, use a Fibonacci heap to achieve \(O(E + V log V)\), but this is rarely worth it in practice.Trap: Starting from an arbitrary unvisited vertex instead of the first vertex (index 0). This causes MST to start from a different component. Why: If the graph is disconnected and you start from a component with fewer edges, you’ll miss edges from other components. For connected graphs, it doesn’t matter, but for forests, you get only a partial MST. Correction: For forests, iterate starting vertices: for each unvisited vertex, run Prim’s independently. This gives the MST of all components.
Trap: Forgetting to skip self-loops or bidirectional edges. Adding
(v, -1)or(v, v)to the heap. Why: Self-loops(u, u, w)are pointless in an MST (they don’t connect different components). Bidirectional edges (u → vandv → u) should be represented once in the graph, not added twice. Correction: Build the adjacency list carefully: for each undirected edge (u, v, w), add both (v, w) to adj[u] and (u, w) to adj[v], but only once. Skip self-loops during adjacency list construction.Trap: Not initializing the heap correctly. Starting with
(0, -1, 0)assumes the source is 0 and uses -1 as a sentinel. If the source is different, this breaks. Why: The sentinel -1 is used to skip the synthetic edge; if you hardcode 0 as the source but the problem allows arbitrary starts, the algorithm is brittle. Correction: Either parameterize the source, or clarify that source is always 0. Use a clearer variable name thanufor the “previous vertex in the edge” to avoid confusion.Trap: Not checking if the edge actually improves the MST. Adding edges even if one endpoint is already visited. Why: If both endpoints are visited, the edge forms a cycle (revisiting both). This is caught by
if visited[v]: continue, but it’s inefficient. Correction: The algorithm is correct as-written; this is a performance gotcha, not a correctness bug. The \(O(E log V)\) bound accounts for stale edges.
MST-finding: Kruskal’s Algorithm (\(O(ElogV)\)) #
- we wish to do additive inclusion of edges – we sort the edges by weight and consider them in ascending order of weights – this is the most important part
- if both nodes are in the same blue tree, then we colour the edge red, else we colour that edge blue
- so this becomes a Union Find operation, where we connect two nodes if they are in the same blue tree
- runs in \(O(E log V)\) time
| |
Pattern Extraction — Kruskal’s Algorithm
AI usage disclosure
Core Insight: Kruskal’s algorithm considers all edges in sorted order and greedily adds each edge if it doesn’t create a cycle. This exploits the greedy choice property: adding the minimum-weight edge that doesn’t form a cycle always leads to an MST.
Pattern Name: Kruskal’s Algorithm (MST via sorted edge processing)
Canonical Section: Minimum Spanning Tree family; related to Union-Find applications
Recognition Signals:
- “Find MST” with edge list input (undirected, weighted)
- Sparse graphs (E << V²) where edge enumeration dominates
- Disconnected graphs (forests) where MST of each component is needed
- Emphasis on “greedy” or “don’t form cycles” suggests edge-centric approach
Anti-signals:
- Dense graph (E ≈ V²) → Prim’s is faster (sorting E ≈ V² edges is expensive)
- Adjacency list only, no edge enumeration → must build edge list, overhead
- Directed graph → different problem (arborescence); Kruskal’s doesn’t apply
- Need MST incrementally online (edges added one-at-a-time) → neither Prim’s nor Kruskal’s is ideal; use incremental Union-Find
Decision Point: Kruskal’s vs Prim’s. Both are O(E log V). Kruskal’s excels on sparse graphs and edge-list input. Prim’s excels on dense graphs and adjacency-list input. For disconnected graphs, Kruskal’s naturally finds the MST of all components (spanning forest).
Interviewer Comms: “I’ll use Kruskal’s algorithm with Union-Find: sort all edges by weight, then iterate through edges in ascending order. For each edge, check if its endpoints are already connected (same component in Union-Find). If not, add the edge to the MST and union the components. This ensures we always pick the minimum-weight edge that doesn’t form a cycle.”
Pitfalls and Misconceptions — Kruskal’s Algorithm
AI usage disclosure
Trap: Forgetting to check if both endpoints have the same root before unioning. Adding an edge without verifying it doesn’t create a cycle. Why: If you don’t check `if dsu.union(u, v)`, you might add edges that form cycles (both endpoints already in the same component). Correction: Always check the return value of `union`: it returns True if the union succeeded (endpoints were in different components), False if they were already connected.
Trap: Not sorting edges by weight. Processing edges in arbitrary order yields a suboptimal or invalid spanning tree. Why: Kruskal’s greedy choice property only holds if edges are considered in sorted order. Without sorting, you might pick a heavy edge early and miss a light edge later. Correction: Always sort edges: `edges = sorted(edges, key=lambda x: x[2])` or use a heap if edges arrive dynamically.
Trap: Implementing Union-Find incorrectly: missing path compression, missing union-by-rank, or both. This degrades complexity from O(E log E) to O(E² log E). Why: Union-Find with neither optimization has O(n) per operation in worst case. With both, it’s O(α(n)) amortised. Over E operations, this is the difference between O(E) and O(E²). Correction: Implement both path compression (in find) and union-by-rank (in union). Your code does this correctly.
Trap: Not detecting when the MST is complete (when you’ve added n-1 edges). Continuing to process edges unnecessarily. Why: An MST has exactly n-1 edges. After adding n-1 edges, all vertices are connected; further processing is redundant. Correction: Check `if len(mst_edges) == n - 1: break` after adding each edge. This is a minor optimization but clarifies intent.
Trap: Assuming the graph is connected. If the graph is disconnected (forest with k components), Kruskal’s finds the MST of the forest (spanning forest), not a single MST. Why: Kruskal’s processes all edges; if some vertices are unreachable, they form isolated components in the spanning forest. Correction: This is a feature, not a bug. If the problem requires a single MST, verify the graph is connected first. If a spanning forest is acceptable, Kruskal’s handles it automatically.
Problem Mutations
AI usage disclosure
What if the graph is disconnected (forest)? (tests: component handling) Prim’s: finds MST of one component (starting vertex determines which). To get all components, run Prim’s from each unvisited vertex. Kruskal’s: naturally finds spanning forest (MST of all components) because it processes all edges globally. Kruskal’s wins here.
What if the graph is directed? (tests: algorithm applicability) Both Prim’s and Kruskal’s are for undirected graphs. For directed graphs, the problem is minimum spanning arborescence (directed tree rooted at a source). Use Edmonds’ algorithm instead. This is a different canonical entirely.
What if all edge weights are equal? (tests: degeneracy) Both algorithms still work; they just pick edges arbitrarily. Any spanning tree is an MST (all have the same weight). Both run in O(V + E) amortised because heap/sort operations are no-ops. The number of distinct spanning trees can be exponential, but the algorithm finds one.
What if edges are added online (stream processing)? (tests: dynamic updates) Neither Prim’s nor Kruskal’s handles online updates well. Prim’s requires rebuilding the heap; Kruskal’s requires resorting. For incremental MST maintenance, use a dynamic tree data structure or maintain a Union-Find and rebuild on each update. Complexity becomes O(E log V) per edge insertion.
What if you need the MST weight without constructing the edges? (tests: output scope) Both algorithms compute total_weight as they proceed. You can return just the weight without storing edges: `return total_weight`. Complexity is unchanged; space improves slightly (no mst_edges list needed).
What if the graph has negative edge weights? (tests: algorithm robustness) Both Prim’s and Kruskal’s work fine with negative weights (unlike shortest-path algorithms, MST doesn’t care about negative cycles). The greedy choice property still holds. No changes needed to the algorithms.
Range-Interval: Fenwick Tree #
Keeping an array of prefix sum is great if our objective is lookup heavy, but if we wish to also represent ranges AND update values within them, then we need a Fenwick tree for this.
A Fenwick tree is actually a Binary Indexed Tree.
| |
Conceptual Overview
Fenwicks use an array
fenwwhere each position stores partial sums of array segments.Uses the binary representation of indices to jump efficiently between cumulative blocks. the jumping happens using LSB stripping: shift by
segment_length = i & (-i)Update operation efficiently adds a value to relevant segments.
Query operation accumulates prefix sums using stored segments.
building the fenwick tree
notice that the tree is an implicit tree, nodes are consecutively numbered, parent child relationship is determined using arithmetic on node indices
the children are more granular than the parents, the parents have cumulative covers.
tree[i]: cumulative info of a segment of original data array. segment size islsb = i & (-i).being able to identify the segment size is very important, it also allows us to find the parent of the current index.
To find the parent, we move further to the right so
i = i + (i & (-i))To find the child, we move further to the left so
i = i - (i & (-i ))
basic API
- Querying Value of Slot at
i- querying requires us to cover all of itself and the partial sums that its children define. That’s why we keep going to the left until we are out of bounds
1 2 3 4 5 6 7 8 9 10def query(self, i): """ To query a particular idx (i.e what's the prefix sum till the 7th element (idx 6)), we need to first use 1-indexing to see that it's the 7th element, then we need to sum over all the partial sums over the segment lengths. """ i += 1 s = 0 while i > 0: s += self.fw[i] i -= i & (-i) return s
- querying requires us to cover all of itself and the partial sums that its children define. That’s why we keep going to the left until we are out of bounds
- Range Query
- use the left and right inclusive and calculate.
1 2 3 4 5def range_query(self, l, r): """ Over a range, we just need to get prefix sum until r, and subtract prefix sum until l - 1 to get l to r inclusive. """ return self.query(r) - (self.query(l-1) if l > 0 else 0)
- use the left and right inclusive and calculate.
- Updating
- updating the slot
irequires us to update all the ancestors as well. That’s why we keep doing it until we exceed the right hand boundary1 2 3 4 5 6 7 8def update(self, i, delta): """ The delta is added to every partial sum, so every sum that covers a segment, that's why we keep going to the ancestors (parents) that are to the right of the original index. """ i += 1 while i <= self.n: self.fw[i] += delta i += i & (-i)
- updating the slot
- Querying Value of Slot at
Use cases
efficiently maintains prefix sums of a list of numbers.
Particularly great for cumulative frequency operations or prefix sums with updates.
operations all run in \(O(\log n)\) time:
Point updates: Update the value at a single index in the array.
Prefix sum queries: Retrieve the sum of elements in the prefix [0,i].
examples
Counting number of elements in a range satisfying a condition (popcount-depth problem).
Dynamic sum of elements in a range with updates.
Minimum/maximum queries over intervals.
Frequency counts with modifications (e.g., inverted indices).
Tricks #
suppose we were given tie-breaking rules (e.g. if multiple options for toposort, then give lexical order), then we can actually order the adjacency list prior to using it.
e.g. see “Reconstruct Itinerary”
1 2 3 4# sort to abide by the lexicographical ordering: for src in graph: graph[src].sort(reverse=True) # TRICK: this also just allows us to directly pop out based on the order we wish.space saving, lazy approaches:
for the “min cost to connect all points” problem, we smell the Prim’s algorithm coming.
However, we also know that we have to make the edges and if it’s a connected graph, it will end up being too dense.
This means that we have to find a way to “bind” the options for the edges all the way at the last place possible. That’s how we figure out the lazy approach.
Analyse the input constraints to know what kind of answers are expected. Here’s an initial v0 of this, I’ll have to gather a proper rule of thumb kind soon.
bot details
Yes, for LeetCode and similar competitive programming questions, it’s generally a very good practice to start your problem-solving process by:
- Carefully analyzing the input constraints (problem size \(n\), value ranges, etc.).
- Using those constraints to narrow down suitable algorithms and data structures.
- Pattern matching input sizes to typical time complexities helps:
- Small input sizes (e.g., \(n \leq 15\)) often suggest exponential or backtracking solutions.
- Medium sizes (e.g., \(n \leq 500\)) may allow \(O(n^3)\) or DP algorithms.
- Large sizes (e.g., \(n \geq 10^5\)) often require efficient \(O(n \log n)\) or linear-time algorithms.
- Looks like for graphs, the number of vertices and edges often hints whether BFS, DFS, Dijkstra, Bellman-Ford, or Floyd-Warshall is feasible and expected.
- Clarify if the problem states any unusual constraints or properties (like directed, acyclic, weighted edges).
General rule of thumb:
- Always read the input size constraints first.
- Map them to complexity vs time limit awareness.
- Use them to proactively discard impractical approaches.
This approach saves time and prevents implementing inefficient solutions. It’s a fundamental best practice when practicing LeetCode problems and interview prep.
Example:
If the input \(n\) is up to 1000 and edges up to 100,000, expect \(O(n^2)\) algorithms to likely cause TLE, so think of graph algorithms with better complexity (like using adjacency lists and BFS/DFS instead of adjacency matrices). If \(n \leq 32\), bitmask DP might be applicable. For \(n \leq 10^5\), linear or near-linear solutions are needed.
This helps you quickly converge on the right algorithm pattern for the problem.
Dimension-flattened index TRICK: 2D \(\implies\) 1D index flattening
we can flatten 2D indices into 1D for grids by doing this :
idx = lambda r, c: r * n + c # flatten 2D to 1Dthis is wild.
this is useful in case where we don’t want to modify things for higher dimensions (e.g. for UnionFind algo)
Classic 1-indexing that can be shimmed. Be observant if the nodes are 1-indexed labelled. That way, just allow a dummy 0-indexed element anyway. See this example from network delay time
TODO KIV #
TODO existing canonicals #
Max flow min cut or network flow questions
LeetCode Max Flow / Min Cut / Network Flow Problems
Here are some LeetCode questions that fall under the canonical type of Max Flow / Min Cut / Network Flow problems:
LeetCode Questions Related to Max Flow Min Cut:
- Uses max flow algorithms to optimize seating arrangements in an exam setting.
Maximum Running Time of N Computers
- Utilizes max flow/min cut techniques to determine optimal battery usage over time.
Miscellaneous Graph Problem Discussions
- Collection of problems involving network flows, cut, and other related algorithms.
Graph Problem - Practice and Approach
- Provides a variety of problems exploring max flow, min cut, and other classical graph algorithms for interview prep.
Graph Algorithms for Interview
- Discusses core max flow/min cut algorithms like Ford-Fulkerson, Dinic, Edmonds-Karp, and their importance in interviews.
Max-Flow Min-Cut Theorem and Concepts (Educational background/reference)
Summary:
- These problems typically use max flow algorithms (e.g., Ford-Fulkerson, Dinic, Edmonds-Karp).
- They are often about optimizing resources, flow, or connectivity in complex network structures, which fit the canonical Max Flow / Min Cut framework.
- Solving them involves understanding flow augmenting path algorithms and properties like max-flow equals min-cut.
Would you like specific problem titles or more detailed explanations of typical algorithms?
Here’s an info dump of that canonical with some reference questions
weighted shortest paths:: TODO Path With Minimum Effort (1631) — Weighted shortest path with state expansions similar to Swim in Rising Water
graph counting:: TODO Number of Distinct Islands (694) (paid)
graph enumeration
- TODO Count Sub Islands (1905) — Counting subgraph embeddings this is not right yet.
- TODO Number of Distinct Islands (694) (paid)