Advanced graph
Past BFS, DFS, and Dijkstra is a tier of graph algorithms that each unlock a class of problems the basics cannot touch. Negative weights → Bellman-Ford. Heuristic search → A*. Connected components in directed graphs → Tarjan or Kosaraju. Bridges → low-link. Bipartite matching → Hopcroft-Karp. Maximum flow → Edmonds-Karp or Dinic's. Each is the right hammer for a specific nail; the senior skill is naming which nail you're looking at.
1 · Why these matter
The Tier A graph trio. BFS, DFS, Dijkstra. Solves "is there a path", "shortest unweighted path", "shortest non-negative weighted path". For about 80% of interview graph questions, that's all you need. The other 20%. Senior-loop hards. Compete in companies. Codeforces-style contests. Require a specific advanced algorithm that none of the trio can express.
- Negative edge weights. Dijkstra is wrong. Negative weights break the "first time we settle a node, we have the shortest path" invariant. Use Bellman-Ford O(VE) or SPFA. Bellman-Ford also detects negative cycles, which Dijkstra can't even define.
- Huge search spaces with a good distance estimate. A* is Dijkstra plus a heuristic. The right heuristic prunes 99% of the explored nodes. Used in pathfinding, puzzle solvers, route planners.
- Structural properties of directed graphs. Strongly connected components (SCC): Tarjan O(V+E) or Kosaraju O(V+E). The building block for 2-SAT, condensation, deadlock detection.
- Structural properties of undirected graphs. Bridges are edges whose removal disconnects the graph. Articulation points are vertices whose removal disconnects. Both via low-link in one DFS.
- Bipartite matching. Pair up jobs with workers, students with schools, tasks with machines. Hopcroft-Karp O(E√V) or simpler augmenting-paths O(VE). The Hungarian algorithm for weighted matching.
- Maximum flow / minimum cut. The most general "throughput" question. Edmonds-Karp O(VE²), Dinic's O(V²E) or O(E√V) on unit graphs. Max-flow min-cut is the master theorem behind dozens of "is this configuration feasible" questions.
2 · Bellman-Ford: the negative-weight tool
The problem. Single-source shortest paths on a weighted directed graph where edge weights can be negative. Bellman-Ford handles it in O(VE), and detects negative cycles on the way out.
The idea
A shortest path on a graph with V vertices uses at most V−1 edges (no repeats, otherwise there's a cycle). So if we relax every edge V−1 times, every shortest distance must have stabilised. If one more round of relaxations still improves anything, there's a negative cycle.
Pseudocode:
dist[source] = 0; dist[v] = +inf for all other v
repeat V - 1 times:
for each edge (u, v, w) in graph:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
// negative-cycle check
for each edge (u, v, w):
if dist[u] + w < dist[v]:
report "negative cycle reachable from source"Go implementation
type Edge struct {
From, To int
Weight int
}
// bellmanFord returns shortest distances from source, and whether a
// negative cycle is reachable from source.
func bellmanFord(n int, edges []Edge, source int) ([]int, bool) {
const INF = int(1e18)
dist := make([]int, n)
for i := range dist {
dist[i] = INF
}
dist[source] = 0
// V - 1 rounds of relaxation
for i := 0; i < n-1; i++ {
updated := false
for _, e := range edges {
if dist[e.From] == INF {
continue // skip unreachable so far
}
if dist[e.From]+e.Weight < dist[e.To] {
dist[e.To] = dist[e.From] + e.Weight
updated = true
}
}
if !updated {
break // converged early
}
}
// negative-cycle detection: one more pass
for _, e := range edges {
if dist[e.From] == INF {
continue
}
if dist[e.From]+e.Weight < dist[e.To] {
return dist, true // negative cycle exists
}
}
return dist, false
}When Bellman-Ford, when Dijkstra
| Property | Dijkstra | Bellman-Ford |
|---|---|---|
| Negative weights | No (incorrect) | Yes |
| Negative cycle detection | — | Yes |
| Time complexity | O((V+E) log V) with heap | O(VE) |
| Implementation effort | Heap + visited set | Two nested loops |
| Bounded-hops shortest path | Awkward | Natural: stop after K rounds |
Walkthrough: LC 787 Cheapest Flights Within K Stops
Problem. Cities connected by directed flights with prices. Find the cheapest price from src to dst using at most k stops (i.e. at most k + 1 edges). Edge weights are positive, so why not Dijkstra? Because Dijkstra optimises total cost while ignoring the hop budget: a cheap path with too many stops is invalid.
Bellman-Ford with exactly k + 1 rounds is the clean solution. After round i, dist[v] is the cheapest cost from src using at most i edges. Key wrinkle: use the previous round's distances when relaxing; otherwise one round can "stack" multiple edges.
func findCheapestPrice(n int, flights [][]int, src, dst, k int) int {
const INF = int(1e18)
dist := make([]int, n)
for i := range dist {
dist[i] = INF
}
dist[src] = 0
// k + 1 rounds = paths of at most k + 1 edges = at most k stops
for i := 0; i <= k; i++ {
snapshot := make([]int, n)
copy(snapshot, dist) // CRUCIAL — relax against snapshot
for _, f := range flights {
u, v, w := f[0], f[1], f[2]
if snapshot[u] == INF {
continue
}
if snapshot[u]+w < dist[v] {
dist[v] = snapshot[u] + w
}
}
}
if dist[dst] == INF {
return -1
}
return dist[dst]
}u → v, then immediately relax v → w using the just-updated dist[v], counting two edges as one round. The snapshot enforces "this round can extend a path by exactly one more edge".3 · SPFA: Bellman-Ford with a queue
SPFA (Shortest Path Faster Algorithm) is Bellman-Ford with a small optimisation: only nodes whose distance changed in the previous round can possibly cause relaxations in the current round. So put them in a queue, process one at a time, push neighbours when they get relaxed. Average case it's noticeably faster than naive Bellman-Ford; worst case it's still O(VE).
func spfa(n int, adj [][]Edge, source int) ([]int, bool) {
const INF = int(1e18)
dist := make([]int, n)
for i := range dist {
dist[i] = INF
}
dist[source] = 0
inQueue := make([]bool, n)
count := make([]int, n) // number of times each vertex enqueued
q := []int{source}
inQueue[source] = true
for len(q) > 0 {
u := q[0]
q = q[1:]
inQueue[u] = false
for _, e := range adj[u] {
if dist[u]+e.Weight < dist[e.To] {
dist[e.To] = dist[u] + e.Weight
if !inQueue[e.To] {
q = append(q, e.To)
inQueue[e.To] = true
count[e.To]++
if count[e.To] >= n {
return dist, true // negative cycle
}
}
}
}
}
return dist, false
}4 · A* search: Dijkstra with a brain
A* is the heuristic-guided version of Dijkstra. Instead of always expanding the node with the smallest g(n) (cost from start), A* expands the node with the smallest f(n) = g(n) + h(n), where h(n) is an estimate of the cost from n to the goal.
Admissibility: the rule that keeps A* optimal
A heuristic is admissible if it never overestimates the true remaining cost. h(n) ≤ h*(n) for all n. As long as h is admissible, A* returns the optimal path. If h over-estimates even slightly, A* may return a suboptimal path and find it "quickly": fast but wrong.
- h(n) = 0 everywhere. A* degenerates to Dijkstra. Always admissible, never helpful.
- h(n) = true remaining cost. A* expands only nodes on an optimal path. Admissible by construction but usually uncomputable; if you could compute it you wouldn't need the search.
- h(n) somewhere in between. The art. For grids: Manhattan distance (4-connected), Chebyshev (8-connected), Euclidean (any-angle). Each is admissible for its movement model.
Diagram: A* prunes the search
Both algorithms find the same optimal path. Dijkstra explores in a disc around the start until it touches the goal; A* with an admissible heuristic biases exploration toward the goal and ends up looking at a fraction of the cells.
Go implementation
import "container/heap"
type aItem struct {
node, f int
}
type aHeap []aItem
func (h aHeap) Len() int { return len(h) }
func (h aHeap) Less(i, j int) bool { return h[i].f < h[j].f }
func (h aHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *aHeap) Push(x any) { *h = append(*h, x.(aItem)) }
func (h *aHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x }
// aStar returns the shortest distance from start to goal.
// adj is an adjacency list of (neighbour, edge-cost) pairs.
// h is an admissible heuristic h(n) <= true cost from n to goal.
func aStar(n, start, goal int, adj [][][2]int, h func(int) int) int {
const INF = int(1e18)
g := make([]int, n)
for i := range g {
g[i] = INF
}
g[start] = 0
open := &aHeap{}
heap.Push(open, aItem{start, h(start)})
for open.Len() > 0 {
it := heap.Pop(open).(aItem)
u := it.node
if u == goal {
return g[u]
}
// Skip stale heap entries — common A* optimisation
if it.f > g[u]+h(u) {
continue
}
for _, e := range adj[u] {
v, w := e[0], e[1]
cand := g[u] + w
if cand < g[v] {
g[v] = cand
heap.Push(open, aItem{v, cand + h(v)})
}
}
}
return -1
}Walkthrough: LC 1091 Shortest Path in Binary Matrix
Problem. N×N grid, 0 = open and 1 = blocked. Move from top-left to bottom-right in 8 directions. Return the shortest path length (number of cells), or −1.
BFS works in O(N²) since edges are unweighted. A* with the Chebyshev distance as heuristic (h(r, c) = max(N-1-r, N-1-c)) is the lower bound on remaining cells in 8-connected movement. It's admissible and usually visits far fewer cells than BFS.
func shortestPathBinaryMatrix(grid [][]int) int {
n := len(grid)
if grid[0][0] == 1 || grid[n-1][n-1] == 1 {
return -1
}
h := func(r, c int) int {
// Chebyshev distance — admissible for 8-connected grid
dr := n - 1 - r
dc := n - 1 - c
if dr > dc {
return dr + 1
}
return dc + 1
}
// Open list ordered by g + h
type state struct{ r, c, g, f int }
open := &aHeap{} // reuse the heap from above
g := make([][]int, n)
for i := range g {
g[i] = make([]int, n)
for j := range g[i] {
g[i][j] = 1<<30
}
}
g[0][0] = 1
heap.Push(open, aItem{0, 1 + h(0, 0)}) // pack (r,c) into one int if needed
// (For brevity the code packs (r, c) as r*n+c; expand similarly to aStar above.)
// ...return g[n-1][n-1] or -1.
return -1
}h = 0 is Dijkstra. A* with h = true remaining cost expands only cells on the optimal path. A* with h = some-overestimate is fast but returns wrong answers. The art is finding a heuristic that's tight enough to prune aggressively but still admissible. For grid pathfinding: Manhattan for 4-connected, Chebyshev for 8-connected, Euclidean for any-angle. Each is admissible by construction.5 · Tarjan's SCC: one DFS, low-link, one stack
A strongly connected component in a directed graph is a maximal set of vertices where every vertex can reach every other. Tarjan's algorithm finds all SCCs in a single DFS, in O(V+E). The trick is a per-vertex value called lowlink: the smallest discovery index reachable from the subtree rooted at v, using tree edges plus at most one back edge.
The pieces
disc[v]: the order in whichvwas first visited (DFS discovery time).low[v]: the lowestdiscvalue reachable fromv's DFS subtree, via tree edges and at most one back edge to a vertex still on the stack.- The stack: every vertex pushed when discovered, popped when its SCC is finalised. A vertex is on the stack iff its SCC hasn't been emitted yet.
- The SCC condition: when
low[v] == disc[v]at the end of DFS(v),vis the "root" of an SCC. Pop the stack down to and includingv; those are the component.
Diagram: SCC stack and back edges
Go implementation
type tarjan struct {
n int
adj [][]int
disc, low []int
onStack []bool
stack []int
timer int
sccs [][]int
}
func newTarjan(n int, adj [][]int) *tarjan {
return &tarjan{
n: n,
adj: adj,
disc: make([]int, n),
low: make([]int, n),
onStack: make([]bool, n),
}
}
func (t *tarjan) run() [][]int {
for i := range t.disc {
t.disc[i] = -1
}
for i := 0; i < t.n; i++ {
if t.disc[i] == -1 {
t.dfs(i)
}
}
return t.sccs
}
func (t *tarjan) dfs(u int) {
t.timer++
t.disc[u], t.low[u] = t.timer, t.timer
t.stack = append(t.stack, u)
t.onStack[u] = true
for _, v := range t.adj[u] {
if t.disc[v] == -1 {
t.dfs(v)
// tree edge — propagate low from child
if t.low[v] < t.low[u] {
t.low[u] = t.low[v]
}
} else if t.onStack[v] {
// back edge to vertex still on stack — use its disc, NOT low
if t.disc[v] < t.low[u] {
t.low[u] = t.disc[v]
}
}
// else: cross edge to a finished SCC — ignore
}
// Root of an SCC — pop until we hit u
if t.low[u] == t.disc[u] {
var comp []int
for {
top := t.stack[len(t.stack)-1]
t.stack = t.stack[:len(t.stack)-1]
t.onStack[top] = false
comp = append(comp, top)
if top == u {
break
}
}
t.sccs = append(t.sccs, comp)
}
}v that's still on the stack, update low[u] = min(low[u], disc[v]), not low[v]. Using low[v] works on many graphs but breaks on certain DAG-with-back-edge configurations. The classical Tarjan formulation uses disc[v]; stick with it.6 · Kosaraju's SCC: two DFS, simpler to remember
Kosaraju gets to the same answer as Tarjan with two passes, no lowlink, no stack-membership flag. Conceptually it's easier: many implementations prefer it for teaching even though Tarjan is one DFS and faster in practice.
- DFS pass 1. On the original graph, push each vertex onto a stack as its DFS finishes.
- Transpose. Build the reverse graph (every edge reversed).
- DFS pass 2. Pop vertices from the stack in order; for each unvisited one, DFS on the transposed graph. Each such DFS visits exactly one SCC.
func kosaraju(n int, adj [][]int) [][]int {
// PASS 1 — finish order on the original graph
visited := make([]bool, n)
var order []int
var dfs1 func(int)
dfs1 = func(u int) {
visited[u] = true
for _, v := range adj[u] {
if !visited[v] {
dfs1(v)
}
}
order = append(order, u)
}
for i := 0; i < n; i++ {
if !visited[i] {
dfs1(i)
}
}
// TRANSPOSE
radj := make([][]int, n)
for u := 0; u < n; u++ {
for _, v := range adj[u] {
radj[v] = append(radj[v], u)
}
}
// PASS 2 — DFS in reverse finish order on transpose
for i := range visited {
visited[i] = false
}
var sccs [][]int
var comp []int
var dfs2 func(int)
dfs2 = func(u int) {
visited[u] = true
comp = append(comp, u)
for _, v := range radj[u] {
if !visited[v] {
dfs2(v)
}
}
}
for i := len(order) - 1; i >= 0; i-- {
u := order[i]
if !visited[u] {
comp = nil
dfs2(u)
sccs = append(sccs, comp)
}
}
return sccs
}7 · Bridges & articulation points: same low-link, undirected
A bridge in an undirected graph is an edge whose removal disconnects the graph. An articulation point is a vertex whose removal disconnects. Both fall out of one DFS that maintains the same disc / low values as Tarjan's SCC, adapted slightly for undirected edges.
The conditions
- Bridge. Edge
(u, v)wherevis a child ofuin the DFS tree andlow[v] > disc[u]. Meaning: no back edge fromv's subtree reachesuor higher, so the edge is the only connection. - Articulation point (non-root). Vertex
uwith a DFS-tree childvsuch thatlow[v] ≥ disc[u]. - Articulation point (root). The root of the DFS tree is an articulation point iff it has at least 2 DFS-tree children.
Diagram: bridges in an undirected graph
Go implementation: bridge finder
func criticalConnections(n int, connections [][]int) [][]int {
adj := make([][]int, n)
for _, c := range connections {
u, v := c[0], c[1]
adj[u] = append(adj[u], v)
adj[v] = append(adj[v], u)
}
disc := make([]int, n)
low := make([]int, n)
for i := range disc {
disc[i] = -1
}
var bridges [][]int
timer := 0
var dfs func(u, parent int)
dfs = func(u, parent int) {
timer++
disc[u], low[u] = timer, timer
for _, v := range adj[u] {
if v == parent {
continue // skip the edge we came from
}
if disc[v] == -1 {
dfs(v, u)
if low[v] < low[u] {
low[u] = low[v]
}
// BRIDGE TEST
if low[v] > disc[u] {
bridges = append(bridges, []int{u, v})
}
} else {
// back edge
if disc[v] < low[u] {
low[u] = disc[v]
}
}
}
}
for i := 0; i < n; i++ {
if disc[i] == -1 {
dfs(i, -1)
}
}
return bridges
}if v == parent. This breaks on parallel edges (multiple edges between the same pair of vertices); for those, track the edge index instead of the parent vertex.8 · Bipartite matching: augmenting paths
A matching in a bipartite graph (vertices split into L and R, edges only between the parts) is a set of edges with no shared endpoints. The maximum matching problem: find the largest such set. Two algorithms: simple augmenting-path DFS (Hungarian DFS, O(VE)) and Hopcroft-Karp (O(E√V)).
The augmenting-path idea
An augmenting path alternates unmatched and matched edges, starts at an unmatched L vertex, ends at an unmatched R vertex. Flipping the matched/unmatched status along the path increases the matching by 1. Berge's theorem: a matching is maximum iff no augmenting path exists. So: while you can find one, augment.
Go implementation: Hungarian DFS
// matchL[u] = R-vertex matched with L-vertex u, or -1
// matchR[v] = L-vertex matched with R-vertex v, or -1
// adj[u] = list of R-vertices reachable from L-vertex u
func maxBipartiteMatching(nL, nR int, adj [][]int) int {
matchR := make([]int, nR)
for i := range matchR {
matchR[i] = -1
}
var tryMatch func(u int, seen []bool) bool
tryMatch = func(u int, seen []bool) bool {
for _, v := range adj[u] {
if seen[v] {
continue
}
seen[v] = true
if matchR[v] == -1 || tryMatch(matchR[v], seen) {
matchR[v] = u
return true
}
}
return false
}
matched := 0
for u := 0; u < nL; u++ {
seen := make([]bool, nR)
if tryMatch(u, seen) {
matched++
}
}
return matched
}Hopcroft-Karp speeds this up by finding many disjoint augmenting paths per BFS-layered phase. The bound O(E√V) is tight and matters only on large bipartite graphs (V > 10⁴). For interview-scale, the simple DFS version is sufficient.
9 · Max-flow / min-cut
The setup. A directed graph with edge capacities, a source s and a sink t. Find the maximum amount of "flow" that can be pushed from s to t such that each edge carries no more than its capacity and every intermediate vertex conserves flow (in = out).
The residual graph: the key concept
For every edge u → v with capacity c, the residual graph has a forward edge with residual capacity c − f (how much more you can push) and a reverse edge with residual capacity f (how much you can "undo"). The reverse edges let later augmenting paths cancel earlier flow decisions. Without them, greedy flow gets stuck in suboptimal local maxima.
Ford-Fulkerson: the meta-algorithm
Ford-Fulkerson:
while there exists an augmenting path P from s to t in the residual graph:
bottleneck = min residual capacity along P
push 'bottleneck' units of flow along P
update residual capacities
return total flow
(Termination requires integer capacities; otherwise can loop forever.)Edmonds-Karp: Ford-Fulkerson with BFS
Edmonds-Karp specifies the augmenting path is the shortest one (fewest edges), found by BFS. This gives O(VE²) worst case, polynomial in graph size: Ford-Fulkerson alone is only pseudo-poly (O(F · E) where F is max flow value).
// cap[u][v] = remaining capacity from u to v
// Returns total max flow from s to t.
func edmondsKarp(n int, cap [][]int, s, t int) int {
total := 0
for {
parent := make([]int, n)
for i := range parent {
parent[i] = -1
}
parent[s] = s
q := []int{s}
for len(q) > 0 && parent[t] == -1 {
u := q[0]
q = q[1:]
for v := 0; v < n; v++ {
if parent[v] == -1 && cap[u][v] > 0 {
parent[v] = u
q = append(q, v)
}
}
}
if parent[t] == -1 {
break // no augmenting path
}
// Find bottleneck capacity along the path
bottleneck := int(1e18)
for v := t; v != s; v = parent[v] {
if cap[parent[v]][v] < bottleneck {
bottleneck = cap[parent[v]][v]
}
}
// Update capacities — forward decreased, reverse increased
for v := t; v != s; v = parent[v] {
u := parent[v]
cap[u][v] -= bottleneck
cap[v][u] += bottleneck // residual back-edge
}
total += bottleneck
}
return total
}Dinic's: level graph plus blocking flow
Dinic's algorithm partitions the residual graph into BFS layers (the level graph), then finds a blocking flow, a flow that saturates at least one edge on every s-to-t path in the level graph, using DFS. Repeat until no augmenting path exists. Complexity: O(V²E) general, O(E√V) on unit-capacity graphs (which includes bipartite matching), O(E√E) on bipartite graphs in the Hopcroft-Karp sense.
Max-flow min-cut theorem
S of vertices reachable from s in the residual graph after max flow is found; the cut is all original edges from S to the complement.Why it matters. Many problems framed as "remove a minimum set of edges / vertices to disconnect X from Y" reduce to min-cut, which reduces to max-flow. Project-selection, image segmentation, baseball elimination, escape-room puzzles: all min-cut in disguise.
10 · Worked hard. LC 1192 Critical Connections
Problem. A network of n servers, numbered 0 to n−1, connected by undirected connections. A critical connection is an edge whose removal disconnects some pair of servers. Return all critical connections.
Recognise the pattern
- "Edge whose removal disconnects" is the textbook bridge definition. No DP, no flow, no clever insight needed: this is Tarjan's bridge algorithm verbatim.
- One DFS with disc / low. Compute
disc[u]andlow[u]for each vertex; an edge(u, v)is a bridge ifflow[v] > disc[u]wherevis a DFS-tree child ofu. - Total O(V + E). Single DFS, constant work per edge.
Trace through a small example
Take n = 4, connections = [[0,1],[1,2],[2,0],[1,3]]. The graph: triangle 0-1-2, plus edge 1-3. Which edges are bridges?
| Vertex | disc | low | Reasoning |
|---|---|---|---|
| 0 (start) | 1 | 1 | Root of DFS tree. |
| 1 | 2 | 1 | Back-edge to 0 via 2: low becomes 1. |
| 2 | 3 | 1 | Back-edge to 0 directly: low becomes 1. |
| 3 | 4 | 4 | Leaf; no back-edges. low stays at disc. |
Edge (1, 3): low[3] = 4 > disc[1] = 2 → bridge. All triangle edges: low of the subtree reaches back to disc[u] or lower → not bridges. Correct answer: [[1, 3]].
Full Go solution
func criticalConnectionsLC1192(n int, connections [][]int) [][]int {
adj := make([][]int, n)
for _, c := range connections {
adj[c[0]] = append(adj[c[0]], c[1])
adj[c[1]] = append(adj[c[1]], c[0])
}
disc := make([]int, n)
low := make([]int, n)
for i := range disc {
disc[i] = -1
}
var out [][]int
timer := 0
var dfs func(u, parent int)
dfs = func(u, parent int) {
timer++
disc[u] = timer
low[u] = timer
for _, v := range adj[u] {
if v == parent {
continue
}
if disc[v] == -1 {
dfs(v, u)
if low[v] < low[u] {
low[u] = low[v]
}
if low[v] > disc[u] {
out = append(out, []int{u, v})
}
} else if disc[v] < low[u] {
low[u] = disc[v]
}
}
}
for i := 0; i < n; i++ {
if disc[i] == -1 {
dfs(i, -1)
}
}
return out
}n up to 10⁵. A recursive DFS at that depth blows Go's default stack (~8 KB initial, grows but slow). For LeetCode submission, either rewrite iteratively with an explicit stack, or invoke the DFS via debug.SetMaxStack / a worker goroutine with larger stack. This is a common cause of "fails on the large test" without an algorithm bug.11 · Worked hard. LC 787 Cheapest Flights Within K Stops
Walked already in section 2: here's the deeper why.
Why Dijkstra doesn't quite work
Dijkstra would find the cheapest path ignoring the hop constraint, which may be too long. You could try to retrofit by tracking (city, stops) pairs in the heap, expanding only states with stops ≤ k. That works and is sometimes faster on sparse graphs, but the state space blows up to O(V · K), and the heap can hold duplicates of (city, stops) pairs.
Why Bellman-Ford fits naturally
Each round of Bellman-Ford extends paths by exactly one more edge. After round i, dist[v] is the cheapest cost using at most i edges. So k + 1 rounds gives "at most k stops" exactly. The snapshot (copy(snapshot, dist) at the start of each round) ensures one round adds one edge, not a chain.
Alternative: Dijkstra with state expansion
// State: (cost, city, stops_used).
// Heap-ordered by cost. Expand only if stops_used < k.
// Faster on graphs where most short paths cost much more than the optimum.
// Bellman-Ford is the cleaner "correct by construction" answer.12 · Sister algorithms worth knowing the names of
- Johnson's algorithm: all-pairs shortest paths with possibly negative weights, no negative cycles. O(V² log V + VE). Trick: add a virtual vertex with zero-weight edges to all, run Bellman-Ford from it to get vertex potentials, then re-weight all edges to be non-negative and run V Dijkstras. Cleaner than Floyd-Warshall O(V³) when the graph is sparse.
- 2-SAT: satisfiability for boolean formulas in 2-CNF. Each clause
(a ∨ b)becomes implications¬a → band¬b → a. Build an implication graph on 2n literals, run SCC. Formula is satisfiable iff no variable and its negation are in the same SCC. Linear time. - Borůvka's MST: minimum spanning tree by parallel contractions; each round every component picks its cheapest outgoing edge. O((V+E) log V) but parallelises trivially, making it the algorithm of choice for distributed MST.
- Kruskal with link-cut trees, when you need MST dynamically (edges inserted/removed online), maintain a forest via link-cut trees in amortised O(log n) per op.
- Eulerian path / Hierholzer's algorithm: traversal that uses every edge exactly once. O(V + E). LC 332 (Reconstruct Itinerary) is Hierholzer's.
- Chinese Postman: minimum-weight closed walk that traverses every edge. Reduces to matching odd-degree vertices.
- Stoer-Wagner global min-cut: minimum cut between any pair of vertices in an undirected graph. O(V³) but no max-flow needed.
- Push-relabel: alternative max-flow algorithm with better practical performance than Edmonds-Karp on dense graphs. O(V² √E) in the highest-label variant.
13 · How to solve hard graph problems step-by-step
- Classify the graph. Directed or undirected? Weighted or unweighted? Sparse (E ≈ V) or dense (E ≈ V²)? Edges fixed or evolving? The answer rules out 60% of candidate algorithms immediately.
- Inspect the weights. Non-negative weights → Dijkstra. Negative weights but no negative cycle → Bellman-Ford or Johnson's. Weights ∈ {0, 1} → 0/1 BFS (deque, O(V+E)). Weights all 1 → plain BFS.
- Identify the query shape. "Single source to all" → Dijkstra / Bellman-Ford. "All pairs" → Floyd-Warshall O(V³) or Johnson's. "Specific s to specific t" with constraints → variants of the above.
- Look for a structural property. "Bridges" / "articulation points" → low-link DFS. "Strongly connected components" → Tarjan or Kosaraju. "Topological order exists" → Kahn or DFS. "Bipartite" → 2-colouring.
- Reduce to flow or matching. "Maximum number of disjoint paths" → max-flow. "Match jobs to workers" → bipartite matching. "Minimum edges to remove to disconnect X from Y" → min-cut = max-flow.
- Look for hop / cost constraints. "Within K stops" → bounded-rounds Bellman-Ford or state-augmented Dijkstra. "Within budget B" → DP over (vertex, budget).
- If none fit, think about state augmentation. Many "graph + constraint" problems become standard graph problems on the product graph (state, position). The graph just got bigger.
14 · Complexity reference
| Algorithm | Time | Space | What it solves |
|---|---|---|---|
| BFS / DFS | O(V + E) | O(V) | Reachability, unweighted shortest path, traversal. |
| Dijkstra (binary heap) | O((V + E) log V) | O(V) | SSSP, non-negative weights. |
| Dijkstra (Fibonacci heap) | O(V log V + E) | O(V) | Same, theoretical-best; rarely worth the constant factor. |
| Bellman-Ford | O(VE) | O(V) | SSSP with negative weights, negative-cycle detection. |
| SPFA | O(VE) worst; often O(kE) | O(V) | Same as Bellman-Ford, usually faster in practice. |
| Floyd-Warshall | O(V³) | O(V²) | All-pairs shortest paths. |
| Johnson's | O(V² log V + VE) | O(V²) | APSP with negative weights, sparse graphs. |
| A* | O((V + E) log V) worst; usually much less | O(V) | SSSP with admissible heuristic. |
| Tarjan SCC | O(V + E) | O(V) | Strongly connected components. |
| Kosaraju SCC | O(V + E) | O(V + E) | Same; two passes, easier to teach. |
| Bridges / articulation | O(V + E) | O(V) | Low-link DFS, undirected. |
| Hungarian DFS matching | O(VE) | O(V) | Maximum bipartite matching. |
| Hopcroft-Karp | O(E √V) | O(V) | Same; faster on large graphs. |
| Edmonds-Karp max-flow | O(VE²) | O(V²) | Maximum s-t flow. |
| Dinic's max-flow | O(V²E); O(E√V) on unit | O(V + E) | Same, much faster in practice. |
| Push-relabel | O(V² √E) | O(V + E) | Same; fastest on dense. |
15 · What breaks
- Bellman-Ford without the negative-cycle pass. If a negative cycle is reachable, the distances are nonsense (they keep dropping each round). The extra pass detects it; without it, "shortest path" is undefined and your code lies.
- Bellman-Ford without the snapshot in bounded-hops mode. Without copying
distat the start of each round, one round can chain multiple edges, and "at most k stops" silently becomes "any number of stops". This is the LC 787 trap. - A* with an inadmissible heuristic. Returns the first path the heuristic finds, which may not be optimal. Manhattan distance on a 4-connected grid is admissible; using it on an 8-connected grid is also admissible (it overestimates "movements" but underestimates "diagonal moves count as one"... it's admissible there too, just loose). Euclidean on an 8-connected grid is admissible; Euclidean on Manhattan-only movement is inadmissible.
- A* with a non-consistent (but admissible) heuristic. May re-expand nodes; you need to allow updates after a node is popped. Most implementations get this right by skipping stale entries in the heap.
- Tarjan SCC using
low[v]on back edges. The classical formulation usesdisc[v]on back edges;low[v]works in many cases but breaks on certain back-edge configurations. Stick todisc[v]. - Bridge algorithm without parent-edge handling. In undirected DFS, the edge to the parent looks like a back edge. Skip it explicitly. On parallel edges, track the edge index, not the parent vertex.
- Max-flow with very large capacities. Ford-Fulkerson with adversarial path choice can take O(F · E) augmentations where F is the max flow value: exponential in input size. Edmonds-Karp (BFS) bounds it polynomially. Always pick BFS for the augmenting path unless you know your graph well.
- Max-flow with floating-point capacities. Ford-Fulkerson may not terminate. There's a famous example by Zwick using Fibonacci-like capacities. Use rationals or scale to integers.
- Recursive DFS at scale. 10⁵ vertices in a near-line graph blows the default stack in most languages. Either bump the stack or rewrite iteratively. Tarjan and bridges are both prone to this on LeetCode.
- Forgetting reverse edges in max-flow. Without residual back-edges, "cancellation" of flow can't happen and the algorithm returns a suboptimal flow. Every edge addition must add both a forward and a reverse residual edge.
16 · Where this gets asked
| Company | Common framing | Why it fits their stack |
|---|---|---|
| Critical Connections (LC 1192), Cheapest Flights Within K Stops (LC 787), Word Ladder II (LC 126) | Bridges and bounded-hop pathfinding are L5+ signals. Map / Search routing teams use these directly. | |
| Meta | Network Delay Time (LC 743), Reconstruct Itinerary (LC 332), Min Cost to Connect All Points (LC 1584) | Graph routing on social-graph adjacency and ads-network propagation. |
| Amazon | Find All People With Secret (LC 2092), Shortest Path Visiting All Nodes (LC 847), 2-SAT-style configuration checks | Logistics + dependency resolution at AWS / fulfilment scale. |
| Uber / Lyft | A* in routing, max-flow for surge-pricing / driver assignment, Hungarian for ride matching | Real-time routing and bipartite matching are the literal business. |
| Stripe / payments | Bipartite matching for ledger reconciliation, max-flow for capacity planning | Money-flow validity is a flow-network question by construction. |
| HFT / quant firms | Min-cut for risk allocation, all-pairs for routing | Microsecond-budget graph problems on order-book topology. |
17 · Try it yourself
- Cheapest Flights Within K Stops · LC 787
- Bellman-Ford with
k + 1rounds; snapshot the distance array each round. Hint: the bug-magnet is forgetting the snapshot, without it, a single round can chain multiple edges and you'll silently allow more than k stops. - Critical Connections · LC 1192
- Tarjan's bridge algorithm with low-link. Hint: track
discandlow, skip the parent edge in undirected DFS, an edge(u, v)is a bridge ifflow[v] > disc[u]. Watch for the stack-overflow at n = 10⁵. - Shortest Path in Binary Matrix · LC 1091
- BFS works; A* with Chebyshev heuristic explores fewer cells. Hint: in 8-connected movement,
h(r, c) = max(N−1−r, N−1−c)is admissible. Use it as the priority along withg. - Network Delay Time · LC 743
- Single-source shortest path; Dijkstra fits. Hint: classic warm-up before the harder Bellman-Ford / A* questions. If you can't solve this fast, the advanced ones aren't ready yet.
- Stretch: Reconstruct Itinerary · LC 332
- Eulerian path via Hierholzer's algorithm. Hint: greedy DFS with edges visited in lexicographic order; emit vertices on the way back up the call stack. Reverse the emission list at the end.
Further reading
- CP-Algorithms: graph section. The single best free reference for advanced graph algorithms. Concise pseudocode, proofs, complexity.
https://cp-algorithms.com/graph/. - Sedgewick & Wayne: Algorithms (4th ed), Chapter 4. Graphs from BFS to flow. Java-focused but the algorithm descriptions are language-agnostic.
- CLRS: Introduction to Algorithms, Chapters 22–26. The textbook treatment. Dense but definitive, especially the max-flow chapter.
- MIT 6.006 lectures (OCW). Erik Demaine's lectures on shortest paths, max-flow, and amortised analysis are uniquely good free videos.
- Tarjan's original paper (1972): "Depth-first search and linear graph algorithms". Short, dense, foundational. Worth reading once for the SCC and bridges algorithms.
- Kleinberg & Tardos: Algorithm Design. The matching and flow chapters are particularly clear, with motivating real-world reductions.
- Adjacent: graph algorithms (Tier B page). The BFS/DFS/Dijkstra foundation this page builds on.
- Adjacent: union-find. The other graph data structure; especially relevant for offline-MST and Kruskal.