Hard#11929 min readGraphDFSTarjanLeetCode

Critical Connections in a Network

Tarjan's bridges with low-link DFS

Tarjan’s bridge-finding algorithm. Must-know for systems interviews.

Published Apr 16, 2026

Problem

You're given an undirected connected network of n servers (labeled 0..n-1) and a list of connections where connections[i] = [a, b] is a bidirectional link between servers a and b.

A critical connection (a.k.a. bridge) is an edge whose removal disconnects the graph. Return all critical connections, in any order.

Input
n = 4, connections = [[0,1],[1,2],[2,0],[1,3]]
Output
[[1,3]]
Explanation
Removing 1-3 isolates server 3 from the rest. The triangle 0-1-2 has no bridges because any single edge can be routed around.
Input
n = 2, connections = [[0,1]]
Output
[[0,1]]
Explanation
The lone edge is the only way to connect the two servers — removing it disconnects them.
Input
n = 4, connections = [[0,1],[1,2],[2,3],[3,0]]
Output
[]
Explanation
A 4-cycle has no bridges — every edge can be bypassed by walking around the other way.

Intuition

A brute-force check removes each edge and asks "is the graph still connected?" via DFS/BFS. That works but costs O(E · (V + E)).

The elegant observation: an edge (u, v) is a bridge if and only if there is no back-edge from the subtree rooted at v (in a DFS tree) that reaches u or any ancestor of u. Tarjan's algorithm captures this with two numbers per node:

  • disc[v] — the time v was first discovered by DFS (increasing counter).
  • low[v] — the lowest disc reachable from v's DFS subtree using tree edges plus at most one back edge.

If low[v] > disc[u] for tree edge u v, then v's subtree cannot reach u or above — removing (u, v) disconnects them. That's a bridge.

Approach

Brute Force — Remove Each Edge

TimeO(E · (V + E))
SpaceO(V + E)

For each edge, temporarily remove it, run a DFS from any vertex, and check whether all n nodes are still reachable. Simple and obviously correct, but O(E^2) in the worst case.

impl Solution {
    pub fn critical_connections(n: i32, connections: Vec<Vec<i32>>) -> Vec<Vec<i32>> {
        let n = n as usize;
        let mut result: Vec<Vec<i32>> = Vec::new();

        for skip in 0..connections.len() {
            let mut graph: Vec<Vec<usize>> = vec![Vec::new(); n];
            for (i, e) in connections.iter().enumerate() {
                if i == skip { continue; }
                graph[e[0] as usize].push(e[1] as usize);
                graph[e[1] as usize].push(e[0] as usize);
            }

            let mut visited = vec![false; n];
            let mut stack = vec![0usize];
            visited[0] = true;
            let mut count = 1;
            while let Some(u) = stack.pop() {
                for &v in &graph[u] {
                    if !visited[v] {
                        visited[v] = true;
                        count += 1;
                        stack.push(v);
                    }
                }
            }

            if count < n {
                result.push(connections[skip].clone());
            }
        }

        result
    }
}
Rust · runnable

Tarjan's Bridges (Low-Link DFS)

TimeO(V + E)
SpaceO(V + E)
Recommended

Run a single DFS. Assign each node a discovery time disc[v] on first visit. Initialize low[v] = disc[v]. When recursing into an unvisited neighbor w via the tree edge v w, recurse first, then update low[v] = min(low[v], low[w]). If after the recursive return low[w] > disc[v], then (v, w) is a bridge.

For back edges (to a non-parent already-visited neighbor), update low[v] = min(low[v], disc[neighbor]). Skip the parent edge so we don't mistake the tree edge itself for a back edge. For safety with parallel edges, track the edge by index rather than endpoint.

impl Solution {
    pub fn critical_connections(n: i32, connections: Vec<Vec<i32>>) -> Vec<Vec<i32>> {
        let n = n as usize;
        let mut graph: Vec<Vec<(usize, usize)>> = vec![Vec::new(); n];
        for (i, e) in connections.iter().enumerate() {
            let (u, v) = (e[0] as usize, e[1] as usize);
            graph[u].push((v, i));
            graph[v].push((u, i));
        }

        let mut disc = vec![-1i32; n];
        let mut low = vec![0i32; n];
        let mut timer = 0i32;
        let mut bridges: Vec<Vec<i32>> = Vec::new();

        // Iterative DFS stack of (node, parent_edge_index, neighbor_iter_index).
        let mut stack: Vec<(usize, i32, usize)> = Vec::new();
        for start in 0..n {
            if disc[start] != -1 { continue; }
            disc[start] = timer;
            low[start] = timer;
            timer += 1;
            stack.push((start, -1, 0));

            while let Some(&(u, parent_edge, idx)) = stack.last() {
                if idx < graph[u].len() {
                    let (v, edge_i)= graph[u][idx];
                    stack.last_mut().unwrap().2 = 1;
                    if edge_i as i32= parent_edge {
                        continue;
                    }
                    if disc[v]= -1 {
                        disc[v]= timer;
                        low[v]= timer;
                        timer = 1;
                        stack.push((v, edge_i as i32, 0));
                    } else {
                        low[u]= low[u].min(disc[v]);
                    }
                } else {
                    stack.pop();
                    if let Some(&(p, _, _))= stack.last() {
                        low[p]= low[p].min(low[u]);
                        if low[u] > disc[p] {
                            bridges.push(connections[parent_edge as usize].clone());
                        }
                    }
                }
            }
        }

        bridges
    }
}
Rust · runnable

A few notes:

  • Iterative DFS avoids stack overflow on 100k-node inputs (LeetCode's constraint).
  • Track edge index, not parent node — parallel edges between the same pair are rare in this problem but the index trick keeps us correct.
  • disc and low share the same type — we use -1 as the "unvisited" sentinel.

Trace

Tarjan's DFS on n = 4, connections = [[0,1],[1,2],[2,0],[1,3]] starting at node 0:

stepnodeactiondisclow
10enter (timer=0)[0,-,-,-][0,-,-,-]
21enter via edge 0[0,1,-,-][0,1,-,-]
32enter via edge 1[0,1,2,-][0,1,2,-]
42back edge 2→0[0,1,2,-][0,1,0,-]
52exit to 1[0,1,2,-][0,0,0,-]
63enter via edge 3[0,1,2,3][0,0,0,3]
73exit to 1[0,1,2,3][0,0,0,3]
81check 3: low[3]=3 > disc[1]=1 → bridge (1,3)[0,1,2,3][0,0,0,3]

The triangle 0-1-2 has low values all at 0 (the entry time), so no bridges there. Edge (1,3) is flagged because node 3 has no back-edge to anything at or above node 1.

Edge cases

  • Tree (no cycles) — every edge is a bridge; the algorithm reports n - 1 of them.
  • Single cycle — no bridges; every node can reach its predecessor via the rest of the ring.
  • Parallel edges — the edge-index trick prevents misclassifying a repeated edge as a back edge to the parent.
  • Multiple connected components — the outer for start in 0..n loop restarts DFS in each component. The problem guarantees connectivity, but this generalizes cleanly.
💡
Tip

Tarjan's bridges also find articulation points with one extra check — an internal node u is an articulation point if any child w has low[w] >= disc[u]. Same DFS, weaker inequality.

Takeaway

low[v] captures "the oldest ancestor reachable from v's subtree using at most one back edge" — and inequality against disc tells you whether a tree edge can be bypassed.

This DFS pattern shows up directly in:

  • Network reliability analysis — bridges are single points of failure between subnets.
  • Social graph analysis — articulation points identify influential brokers.
  • Compilers — dominator trees and strongly-connected components share the same low-link machinery.

More in Advanced Graph