Loading learning content...
In the previous pages, we developed deep conceptual understanding of articulation points and bridges. We saw how the naive approach—removing each vertex or edge and checking connectivity—leads to O(V(V+E)) complexity, far too slow for large networks.
Now we turn to Tarjan's algorithm, an elegant algorithm developed by computer scientist Robert Tarjan in the 1970s. This algorithm finds all articulation points and bridges in a single depth-first search traversal, achieving optimal O(V + E) time complexity.
Tarjan's insight was recognizing that the DFS tree structure, combined with carefully tracked metadata about reachability, contains all the information needed to identify critical vertices and edges without explicitly testing their removal.
By the end of this page, you will fully understand Tarjan's algorithm: the discovery time and low-link values, the detection conditions for both articulation points and bridges, the complete implementation with proper edge case handling, and complexity analysis. You'll be able to implement this from first principles.
Before diving into code, let's establish the conceptual framework. The algorithm relies on three key ideas:
1. DFS Tree Structure
When we perform DFS on a connected undirected graph, we implicitly construct a spanning tree (the DFS tree) where:
2. Discovery Time (disc)
We assign each vertex a timestamp when DFS first visits it. This creates a linear ordering of vertices that reflects the DFS traversal order:
disc[v] = the step number when v is first visited
3. Low-Link Value (low)
For each vertex, we compute the earliest (lowest) discovery time reachable from its subtree:
low[v] = minimum of:
- disc[v] (can reach itself)
- low[u] for all children u in DFS tree (reaches of descendants)
- disc[w] for all back edges (v, w) (direct reach to ancestors)
Think of low[v] as answering: 'What's the earliest-discovered vertex that v or any of v's descendants can reach by going down tree edges and then up at most one back edge?' This captures how far 'back' the subtree can reach.
| Element | Condition | Intuition |
|---|---|---|
| Articulation Point (non-root) | Has child u where low[u] ≥ disc[v] | Child's subtree can't escape above v |
| Articulation Point (root) | Has ≥ 2 children in DFS tree | Removing root separates children |
| Bridge (u→v tree edge) | low[v] > disc[u] | Child's subtree can't even reach u |
Why the difference between ≥ and > ?
For articulation points: If low[u] = disc[v], the subtree can reach v itself (via a back edge to v), but not above. Removing v still disconnects the subtree from everything above v.
For bridges: If low[v] = disc[u], there's a back edge from v's subtree to u, creating a cycle that includes edge (u,v). The edge is not a bridge because the cycle provides an alternative path.
Here's the complete, production-ready implementation that finds both articulation points and bridges in a single DFS traversal.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
interface ArticulationBridgeResult { articulationPoints: number[]; bridges: [number, number][];} /** * Finds all articulation points and bridges in an undirected graph. * Uses Tarjan's algorithm with O(V + E) time complexity. * * @param graph - Adjacency list representation (vertex -> neighbors) * @returns Object containing arrays of articulation points and bridges */function findCriticalElements( graph: Map<number, number[]>): ArticulationBridgeResult { const n = graph.size; if (n === 0) return { articulationPoints: [], bridges: [] }; // Track discovery time and low values const disc: Map<number, number> = new Map(); const low: Map<number, number> = new Map(); // Track parent for each vertex in DFS tree const parent: Map<number, number | null> = new Map(); // Results const articulationPoints: Set<number> = new Set(); const bridges: [number, number][] = []; // Timer for discovery times let timer = 0; /** * DFS function that computes disc and low values * while identifying articulation points and bridges. */ function dfs(vertex: number): void { // Initialize discovery time and low value disc.set(vertex, timer); low.set(vertex, timer); timer++; // Count children in DFS tree (for root check) let childCount = 0; for (const neighbor of (graph.get(vertex) || [])) { if (!disc.has(neighbor)) { // Unvisited vertex: tree edge childCount++; parent.set(neighbor, vertex); // Recurse on child dfs(neighbor); // After DFS returns, update low value low.set(vertex, Math.min(low.get(vertex)!, low.get(neighbor)!)); // Check for articulation point (non-root case) // If child can't reach above this vertex, this vertex is critical if (parent.get(vertex) !== null && low.get(neighbor)! >= disc.get(vertex)!) { articulationPoints.add(vertex); } // Check for bridge // If child can't reach this vertex or above, edge is a bridge if (low.get(neighbor)! > disc.get(vertex)!) { bridges.push([vertex, neighbor]); } } else if (neighbor !== parent.get(vertex)) { // Already visited and not parent: back edge // Update low value with neighbor's discovery time low.set(vertex, Math.min(low.get(vertex)!, disc.get(neighbor)!)); } // If neighbor is parent, we ignore (not a back edge) } // Check for articulation point (root case) // Root is an articulation point iff it has ≥ 2 children if (parent.get(vertex) === null && childCount >= 2) { articulationPoints.add(vertex); } } // Handle disconnected graphs by running DFS from all unvisited vertices for (const vertex of graph.keys()) { if (!disc.has(vertex)) { parent.set(vertex, null); // Mark as root of DFS tree dfs(vertex); } } return { articulationPoints: Array.from(articulationPoints), bridges: bridges, };}When processing neighbors, we must distinguish between back edges (neighbor is ancestor but not parent) and the edge to our parent. If we don't skip the parent, we'd incorrectly treat the tree edge as a back edge, corrupting low values.
Let's trace through the algorithm on a concrete example to solidify understanding.
123456789101112131415
Graph Structure: 0 ─── 1 ─── 2 │ \ │ │ \ │ 3 ──── 4 Adjacency List: 0: [1] 1: [0, 2, 3, 4] 2: [1, 4] 3: [1, 4] 4: [1, 2, 3] Starting DFS from vertex 0...123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
STEP 1: dfs(0) Visit 0: disc[0] = 0, low[0] = 0, parent[0] = null Neighbors of 0: [1] STEP 2: Process neighbor 1 (unvisited → tree edge) childCount for 0 = 1 parent[1] = 0 STEP 3: dfs(1) Visit 1: disc[1] = 1, low[1] = 1, parent[1] = 0 Neighbors of 1: [0, 2, 3, 4] Process neighbor 0: visited AND is parent → skip STEP 4: Process neighbor 2 (unvisited → tree edge) childCount for 1 = 1 parent[2] = 1 STEP 5: dfs(2) Visit 2: disc[2] = 2, low[2] = 2, parent[2] = 1 Neighbors of 2: [1, 4] Process neighbor 1: visited AND is parent → skip STEP 6: Process neighbor 4 (unvisited → tree edge) childCount for 2 = 1 parent[4] = 2 STEP 7: dfs(4) Visit 4: disc[4] = 3, low[4] = 3, parent[4] = 2 Neighbors of 4: [1, 2, 3] Process neighbor 1: visited, not parent (1 ≠ 2) → Back edge! low[4] = min(3, disc[1]) = min(3, 1) = 1 Process neighbor 2: visited AND is parent → skip STEP 8: Process neighbor 3 (unvisited → tree edge) childCount for 4 = 1 parent[3] = 4 STEP 9: dfs(3) Visit 3: disc[3] = 4, low[3] = 4, parent[3] = 4 Neighbors of 3: [1, 4] Process neighbor 1: visited, not parent → Back edge! low[3] = min(4, disc[1]) = min(4, 1) = 1 Process neighbor 4: visited AND is parent → skip childCount for 3 = 0 (no tree children) parent[3] = 4 ≠ null, not root No articulation point check for root applies Return from dfs(3) Back in dfs(4): Update low[4] = min(low[4], low[3]) = min(1, 1) = 1 Articulation point check: parent[4] = 2 ≠ null low[3] = 1 ≥ disc[4] = 3? NO (1 < 3) → 4 is NOT an articulation point (based on child 3) Bridge check: low[3] = 1 > disc[4] = 3? NO → (4,3) is NOT a bridge childCount for 4 = 1 parent[4] = 2 ≠ null, not root Return from dfs(4) Back in dfs(2): Update low[2] = min(low[2], low[4]) = min(2, 1) = 1 Articulation point check: low[4] = 1 ≥ disc[2] = 2? NO → 2 is NOT an articulation point Bridge check: low[4] = 1 > disc[2] = 2? NO → (2,4) is NOT a bridge childCount for 2 = 1 Return from dfs(2) Back in dfs(1): Update low[1] = min(low[1], low[2]) = min(1, 1) = 1 Articulation point check: low[2] = 1 ≥ disc[1] = 1? YES! → 1 IS an articulation point! Bridge check: low[2] = 1 > disc[1] = 1? NO → (1,2) is NOT a bridge STEP 10: Process neighbor 3 in dfs(1) 3 is already visited (disc[3] = 4), not parent (parent[1] = 0) → Back edge! low[1] = min(1, disc[3]) = min(1, 4) = 1 STEP 11: Process neighbor 4 in dfs(1) 4 is already visited, not parent → Back edge! low[1] = min(1, disc[4]) = min(1, 3) = 1 childCount for 1 = 1 (only 2 was a tree child) parent[1] = 0 ≠ null, not root Return from dfs(1) Back in dfs(0): Update low[0] = min(low[0], low[1]) = min(0, 1) = 0 Articulation point check: low[1] = 1 ≥ disc[0] = 0? YES! → 0 IS an articulation point! Bridge check: low[1] = 1 > disc[0] = 0? YES! → (0,1) IS a bridge! childCount for 0 = 1 parent[0] = null, is root Root articulation point check: childCount = 1 ≥ 2? NO Return from dfs(0) FINAL VALUES: Vertex | disc | low | parent -------|------|-----|------- 0 | 0 | 0 | null 1 | 1 | 1 | 0 2 | 2 | 1 | 1 3 | 4 | 1 | 4 4 | 3 | 1 | 2 RESULTS: Articulation Points: {1} (0 was added but let's verify - wait, we added 0 above!) Let me re-check: In step when we processed (0,1) tree edge... parent[0] = null, but we checked parent.get(vertex) !== null for the articulation check. So 0 was NOT added via non-root path. For root check: childCount = 1, not ≥ 2, so 0 NOT added. Articulation Points: {1} Bridges: {(0,1)}Let's verify: Remove vertex 1 → components {0} and {2,3,4}. Correct! Remove edge (0,1) → {0} disconnected from {1,2,3,4}. Correct! The cycle 1-2-4-1 and 1-3-4-1 protect those edges from being bridges, but can't save vertex 1.
Production implementations must handle various edge cases correctly. Let's examine each.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
function findCriticalElementsRobust( graph: Map<number, number[]>): ArticulationBridgeResult { // Edge case: empty graph if (graph.size === 0) { return { articulationPoints: [], bridges: [] }; } // Edge case: single vertex if (graph.size === 1) { return { articulationPoints: [], bridges: [] }; } const disc: Map<number, number> = new Map(); const low: Map<number, number> = new Map(); const parent: Map<number, number | null> = new Map(); const articulationPoints: Set<number> = new Set(); const bridges: [number, number][] = []; let timer = 0; function dfs(vertex: number): void { disc.set(vertex, timer); low.set(vertex, timer); timer++; let childCount = 0; for (const neighbor of (graph.get(vertex) || [])) { // Edge case: self-loop (skip) if (neighbor === vertex) continue; if (!disc.has(neighbor)) { childCount++; parent.set(neighbor, vertex); dfs(neighbor); low.set(vertex, Math.min( low.get(vertex)!, low.get(neighbor)! )); // Articulation point check (non-root) if (parent.get(vertex) !== null && low.get(neighbor)! >= disc.get(vertex)!) { articulationPoints.add(vertex); } // Bridge check if (low.get(neighbor)! > disc.get(vertex)!) { bridges.push([vertex, neighbor]); } } else if (neighbor !== parent.get(vertex)) { // Back edge low.set(vertex, Math.min( low.get(vertex)!, disc.get(neighbor)! )); } } // Root articulation point check if (parent.get(vertex) === null && childCount >= 2) { articulationPoints.add(vertex); } } // Handle disconnected graphs for (const vertex of graph.keys()) { if (!disc.has(vertex)) { parent.set(vertex, null); dfs(vertex); } } return { articulationPoints: Array.from(articulationPoints), bridges: bridges, };}For multigraphs with parallel edges, the simple parent check isn't sufficient. We need to track which specific edge we came from, not just which vertex. One approach: use edge indices instead of parent vertices, or count edges to parent and only treat as back edge if we've used all edges to the parent vertex.
The root of the DFS tree requires special handling for articulation point detection. Understanding why reveals important insights about the algorithm's design.
Why the normal condition fails for the root:
The normal articulation point condition is: vertex v is an articulation point if it has a child u with low[u] ≥ disc[v].
For the root r with disc[r] = 0:
The correct interpretation:
The intuition behind low[u] ≥ disc[v] is: 'No back edges escape u's subtree to above v.'
But for the root, there IS no 'above.' The root has no ancestors. So we need a different criterion: does removing the root disconnect its children from each other?
123456789101112131415161718192021222324252627282930313233343536373839404142
CASE 1: Root with 1 DFS child═══════════════════════════════ 1 (root) If we remove 1: │ The remaining graph is {2,3,4,5} 2 Still connected! / \ → 1 is NOT an articulation point 3 4 │ 5 Only 2 is a DFS child of 1 (3,4,5 are descendants but not direct children). CASE 2: Root with ≥2 DFS children ════════════════════════════════════ 1 (root) If we remove 1: / \ {2,3} and {4,5} become disconnected! 2 4 → 1 IS an articulation point │ │ 3 5 Both 2 and 4 are DFS children of 1.Removing 1 disconnects their subtrees. CASE 3: Root with multiple neighbors but 1 DFS child═══════════════════════════════════════════════════════ 1 (root) DFS from 1 might go: 1→2→3→4→1(back)→5→1(back) /│\ Only 2 is a tree child! (3,4 via 2; 5 has back edge) 2 4 5──┐ │ │ │ Actually wait, let me redo: 3─┘ │ DFS: 1→2→3→4(back to 1 or 2?)→5 │ If there are edges 1-2, 2-3, 3-4, 4-1, 1-5: Tree edges from 1: to 2, and to 5 (if 5 visited after 4's back edge)That's 2 DFS children → 1 IS articulation point. Key insight: DFS children ≠ neighbors. It depends on discovery order.The algorithm correctly identifies all articulation points regardless of which vertex we choose as the DFS root. A vertex that's an articulation point will be detected whether it's the root (via the child count check) or a non-root (via the low value check).
Different textbooks and implementations define the low value slightly differently. Understanding these variations prevents confusion when comparing implementations.
| Variant | Definition | Back Edge Update |
|---|---|---|
| Standard (disc) | min reachable disc time | low[v] = min(low[v], disc[ancestor]) |
| Alternative (low) | min reachable low time | low[v] = min(low[v], low[ancestor]) |
Important subtlety for bridges:
With the standard definition (using disc[ancestor] for back edges), the bridge condition low[v] > disc[u] works correctly.
With the alternative definition (using low[ancestor]), we must be more careful. Consider:
Vertex x has disc[x] = 1, low[x] = 0 (due to back edge to disc=0)
Vertex y discovered after x, has back edge to x
With alternative definition: low[y] could become 0 (taking low[x]) With standard definition: low[y] becomes 1 (taking disc[x])
For bridges, the standard definition is typically preferred for correctness.
12345678910111213141516171819202122232425262728
// STANDARD FORMULATION (Recommended for bridges)// Back edge update uses discovery timeif (neighbor !== parent.get(vertex)) { low.set(vertex, Math.min( low.get(vertex)!, disc.get(neighbor)! // Use disc, not low ));} // Bridge check: works correctlyif (low.get(neighbor)! > disc.get(vertex)!) { bridges.push([vertex, neighbor]);} // ───────────────────────────────────────────── // ALTERNATIVE FORMULATION (Some implementations)// Back edge update uses low valueif (neighbor !== parent.get(vertex)) { low.set(vertex, Math.min( low.get(vertex)!, low.get(neighbor)! // Use low instead ));} // Bridge check: may need adjustment for correctness// The condition low[v] > disc[u] may incorrectly identify// some non-bridges as bridges in specific graph configurations.Use disc[neighbor] (not low[neighbor]) when updating low values via back edges. This is the standard definition and works correctly for both articulation point and bridge detection without modification.
Let's rigorously analyze the time and space complexity of Tarjan's algorithm.
| Aspect | Naive Approach | Tarjan's Algorithm |
|---|---|---|
| Time Complexity | O(V × (V + E)) | O(V + E) |
| Space Complexity | O(V + E) | O(V) |
of DFS Traversals | V (one per vertex test) | 1 |
| For |V|=1000, |E|=5000 | ~6 million ops | ~6 thousand ops |
| For |V|=1M, |E|=10M | ~10¹³ ops (years) | ~10⁷ ops (seconds) |
Any algorithm for finding articulation points or bridges must examine every vertex and edge at least once—an edge could be the single bridge, and we can't know without checking. Thus Ω(V + E) is a lower bound, and Tarjan's O(V + E) achieves this optimum.
The recursive implementation may cause stack overflow on very deep graphs. Here's an iterative version using explicit stack management.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
interface StackFrame { vertex: number; neighborIndex: number; // Which neighbor are we processing? childCount: number; // DFS tree children count} function findCriticalElementsIterative( graph: Map<number, number[]>): ArticulationBridgeResult { if (graph.size === 0) return { articulationPoints: [], bridges: [] }; const disc: Map<number, number> = new Map(); const low: Map<number, number> = new Map(); const parent: Map<number, number | null> = new Map(); const articulationPoints: Set<number> = new Set(); const bridges: [number, number][] = []; let timer = 0; for (const startVertex of graph.keys()) { if (disc.has(startVertex)) continue; const stack: StackFrame[] = []; parent.set(startVertex, null); // Initialize start vertex disc.set(startVertex, timer); low.set(startVertex, timer); timer++; stack.push({ vertex: startVertex, neighborIndex: 0, childCount: 0 }); while (stack.length > 0) { const frame = stack[stack.length - 1]; const vertex = frame.vertex; const neighbors = graph.get(vertex) || []; if (frame.neighborIndex < neighbors.length) { const neighbor = neighbors[frame.neighborIndex]; frame.neighborIndex++; // Skip self-loops if (neighbor === vertex) continue; if (!disc.has(neighbor)) { // Tree edge: push new frame frame.childCount++; parent.set(neighbor, vertex); disc.set(neighbor, timer); low.set(neighbor, timer); timer++; stack.push({ vertex: neighbor, neighborIndex: 0, childCount: 0 }); } else if (neighbor !== parent.get(vertex)) { // Back edge low.set(vertex, Math.min( low.get(vertex)!, disc.get(neighbor)! )); } } else { // Finished processing vertex, pop and update parent stack.pop(); if (stack.length > 0) { const parentFrame = stack[stack.length - 1]; const parentVertex = parentFrame.vertex; // Update parent's low value low.set(parentVertex, Math.min( low.get(parentVertex)!, low.get(vertex)! )); // Articulation point check (non-root) if (low.get(vertex)! >= disc.get(parentVertex)!) { articulationPoints.add(parentVertex); } // Bridge check if (low.get(vertex)! > disc.get(parentVertex)!) { bridges.push([parentVertex, vertex]); } } else { // Root check if (frame.childCount >= 2) { articulationPoints.add(vertex); } } } } } return { articulationPoints: Array.from(articulationPoints), bridges: bridges, };}Use iterative implementation when: (1) Graph depth may exceed recursion limit (typically thousands), (2) Language has expensive function call overhead, (3) You need explicit control over stack memory. For most practical purposes, recursion is cleaner.
Even experienced programmers make mistakes implementing this algorithm. Here are the most common bugs and how to avoid them.
neighbor !== parent[vertex].disc[neighbor] in the min update.> (strictly greater); articulation points use >= (greater or equal). Mixing these up causes wrong results.1234567891011121314151617181920212223242526272829
// BUG 1: Missing parent check// WRONG:for (const neighbor of graph.get(vertex)!) { if (disc.has(neighbor)) { low[vertex] = Math.min(low[vertex], disc[neighbor]); } // This updates low even for parent, corrupting values!} // CORRECT:for (const neighbor of graph.get(vertex)!) { if (disc.has(neighbor) && neighbor !== parent[vertex]) { low[vertex] = Math.min(low[vertex], disc[neighbor]); }} // BUG 2: Wrong low update // WRONG (using low for back edge):low[vertex] = Math.min(low[vertex], low[neighbor]); // CORRECT (using disc for back edge):low[vertex] = Math.min(low[vertex], disc[neighbor]); // BUG 3: Wrong inequality// WRONG (using > for articulation points):if (low[neighbor] > disc[vertex]) articulationPoints.add(vertex); // CORRECT (using >= for articulation points):if (low[neighbor] >= disc[vertex]) articulationPoints.add(vertex);Test your implementation on: (1) A simple tree (all edges are bridges), (2) A single cycle (no bridges, no articulation points), (3) Two cycles connected by one edge (one bridge), (4) A disconnected graph, (5) A star graph (center is articulation point). Verify against the naive O(V²) algorithm on small inputs.
We've covered Tarjan's algorithm in full detail. Let's consolidate the essential points.
What's next:
The final page explores the practical applications of articulation point and bridge detection: network reliability analysis, infrastructure planning, social network analysis, and more. You'll see how this elegant algorithm solves real-world problems.
You now fully understand Tarjan's algorithm for finding articulation points and bridges. You can implement it from scratch, handle edge cases, analyze its complexity, and debug common issues. Next, we'll apply this to real-world network reliability problems.