25 / 25 · Tier C
Patterns / 25

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.
The recognition trick. If a problem feels like a graph question but the standard trio doesn't fit: "shortest path with at most K stops", "minimum cost to make graph reachable", "maximum number of disjoint paths". Those are Tier C signals. Re-read the problem, match to one of the families below, and the algorithm picks itself.

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

PropertyDijkstraBellman-Ford
Negative weightsNo (incorrect)Yes
Negative cycle detectionYes
Time complexityO((V+E) log V) with heapO(VE)
Implementation effortHeap + visited setTwo nested loops
Bounded-hops shortest pathAwkwardNatural — 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]
}
Why the snapshot. Without copying, in a single round you might first relax 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
}
SPFA's reputation. Codeforces eventually started constructing adversarial graphs (grid-like meshes with specific weight patterns) that push SPFA to its worst case. The competitive-programming joke is "SPFA died". On random or sparse graphs it's still fast; on contest judges that test for it, fall back to Bellman-Ford or Johnson's. For interviews, mentioning SPFA as an optimisation is fine; don't lean on it as a guaranteed speedup.

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

DIJKSTRA — uninformedstartgoalA* — Manhattan heuristicstartgoalexpands ~πr² nodesexpands ~r nodes

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
}
The heuristic dial. A* with 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 which v was first visited (DFS discovery time).
  • low[v]: the lowest disc value reachable from v'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), v is the "root" of an SCC. Pop the stack down to and including v; those are the component.

Diagram: SCC stack and back edges

ABCbackSCC: low[C]→disc[A]DESCC: cycle D↔EFsingleton SCCDASHED = back edge (drops lowlink); SOLID = tree / cross edge

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)
    }
}
The disc-vs-low gotcha. On a back edge to a vertex 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.

  1. DFS pass 1. On the original graph, push each vertex onto a stack as its DFS finishes.
  2. Transpose. Build the reverse graph (every edge reversed).
  3. 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
}
Tarjan vs Kosaraju. Same big-O. Tarjan touches each edge once in one DFS; Kosaraju touches each edge twice (forward + the transpose). Tarjan is roughly 2× faster in tight benchmarks. Kosaraju is easier to explain on a whiteboard. Pick by audience.

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) where v is a child of u in the DFS tree and low[v] > disc[u]. Meaning: no back edge from v's subtree reaches u or higher, so the edge is the only connection.
  • Articulation point (non-root). Vertex u with a DFS-tree child v such that low[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

ABCBRIDGEDEFlow[D] = disc[D] > disc[C] — the edge (C,D) is a bridgeTriangles A-B-C and D-E-F each have back-edges; the connecting edge has none.

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
}
The parent-edge subtlety. In undirected DFS, the edge to your parent looks like a back edge — but it isn't. Skip it explicitly with 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.

The Hungarian algorithm (weighted). For weighted bipartite matching ("minimum cost assignment"), the Hungarian algorithm runs in O(V³). Different beast: uses potentials and alternating trees, not augmenting paths directly. Comes up in operations-research interviews (Uber dispatch, Lyft routing) more than LC.

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

The theorem. In any flow network, the maximum value of an s-t flow equals the minimum capacity of an s-t cut. Equivalently: the value of any flow ≤ capacity of any cut, with equality at the optimum. The cut achieving the minimum: take the set 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] and low[u] for each vertex; an edge (u, v) is a bridge iff low[v] > disc[u] where v is a DFS-tree child of u.
  • 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?

VertexdisclowReasoning
0 (start)11Root of DFS tree.
121Back-edge to 0 via 2 — low becomes 1.
231Back-edge to 0 directly — low becomes 1.
344Leaf; no back-edges. low stays at disc.

Edge (1, 3): low[3] = 4 > disc[1] = 2bridge. 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
}
Stack overflow at scale. LC 1192 allows 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.
Interview signal. If the candidate jumps to Dijkstra and adds a hops counter, that's fine — they've reasoned about the constraint. If they recognise it as a bounded-hops Bellman-Ford problem, that's senior. If they explain why the snapshot is needed, that's "this person has internalised the algorithm".

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 → b and ¬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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Look for hop / cost constraints. "Within K stops" → bounded-rounds Bellman-Ford or state-augmented Dijkstra. "Within budget B" → DP over (vertex, budget).
  7. 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.
The fallback. If after five minutes you haven't matched a known algorithm — state your brute force first (it's usually some form of BFS/DFS), then think out loud about what structure of the problem would let you shortcut. The interviewer often nudges toward the right family if your reasoning is sound.

14 · Complexity reference

AlgorithmTimeSpaceWhat it solves
BFS / DFSO(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-FordO(VE)O(V)SSSP with negative weights, negative-cycle detection.
SPFAO(VE) worst; often O(kE)O(V)Same as Bellman-Ford, usually faster in practice.
Floyd-WarshallO(V³)O(V²)All-pairs shortest paths.
Johnson'sO(V² log V + VE)O(V²)APSP with negative weights, sparse graphs.
A*O((V + E) log V) worst; usually much lessO(V)SSSP with admissible heuristic.
Tarjan SCCO(V + E)O(V)Strongly connected components.
Kosaraju SCCO(V + E)O(V + E)Same; two passes, easier to teach.
Bridges / articulationO(V + E)O(V)Low-link DFS, undirected.
Hungarian DFS matchingO(VE)O(V)Maximum bipartite matching.
Hopcroft-KarpO(E √V)O(V)Same; faster on large graphs.
Edmonds-Karp max-flowO(VE²)O(V²)Maximum s-t flow.
Dinic's max-flowO(V²E); O(E√V) on unitO(V + E)Same, much faster in practice.
Push-relabelO(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 dist at 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"... actually 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 uses disc[v] on back edges; low[v] works in many cases but breaks on certain back-edge configurations. Stick to disc[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

CompanyCommon framingWhy it fits their stack
GoogleCritical 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.
MetaNetwork 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.
AmazonFind All People With Secret (LC 2092), Shortest Path Visiting All Nodes (LC 847), 2-SAT-style configuration checksLogistics + dependency resolution at AWS / fulfilment scale.
Uber / LyftA* in routing, max-flow for surge-pricing / driver assignment, Hungarian for ride matchingReal-time routing and bipartite matching are the literal business.
Stripe / paymentsBipartite matching for ledger reconciliation, max-flow for capacity planningMoney-flow validity is a flow-network question by construction.
HFT / quant firmsMin-cut for risk allocation, all-pairs for routingMicrosecond-budget graph problems on order-book topology.

17 · Try it yourself

Cheapest Flights Within K Stops · LC 787
Bellman-Ford with k + 1 rounds; 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 disc and low, skip the parent edge in undirected DFS, an edge (u, v) is a bridge iff low[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 with g.
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.
Found this useful?