Bridges and Articulation Points: Find Every Graph Weakness in O(V+E)

- Bridges are edges whose removal disconnects an undirected graph; articulation points (cut vertices) are nodes whose removal does the same.
- Both are found in one DFS pass using disc[] and low[] arrays, running in O(V+E) time and O(V) auxiliary space.
- Bridge condition:
low[v] > disc[u](strict greater-than); articulation point condition:low[v] >= disc[u]— the >= vs > distinction is the nuance interviewers probe. - The DFS root is an articulation point only when it has more than one DFS child; the low[] comparison is undefined for the root.
- The parent-vertex skip breaks on multigraphs — use edge indices rather than vertex IDs to avoid skipping both parallel edges.
- LeetCode 1192 (Critical Connections in a Network) is the canonical interview problem: critical connections are exactly bridges.
- To make a graph 2-edge-connected, find its bridge tree and add ceil(leaves/2) edges connecting leaf nodes pairwise.
Your network has a hundred nodes and two hundred connections. Traffic flows fine. Then one link goes down, and thirty nodes go dark. Not because thirty things broke. Because one thing broke, and nobody had mapped the dependency.
That link is a bridge. The router at its endpoint is an articulation point. Finding bridges and articulation points in one DFS pass is the algorithm you reach for whenever a graph has structural vulnerabilities worth mapping. It runs in time proportional to the size of your graph.
Every on-call engineer's first guess. This article tells you exactly which edge to actually blame.
What a Bridge Is (and Why Your Cluster Cares)
In an undirected graph, a bridge (also called a cut edge) is an edge whose removal disconnects the graph. Remove it, and at least one vertex becomes unreachable from at least one other.
An articulation point (cut vertex) is a vertex whose removal, along with all edges incident to it, disconnects the graph. Pull the node out, and the graph splits into pieces.
They're related but not identical. A bridge endpoint might be an articulation point, but a vertex can be an articulation point without any individual incident edge being a bridge. Picture a hub with two separate cycles attached to it. Removing the hub disconnects those cycles from each other. But no single edge touching the hub is a bridge, because each cycle has its own internal redundancy.
You reach for this algorithm when a problem asks about structural vulnerabilities in a graph: network single points of failure, critical roads in a city layout, essential links in a dependency graph, or anything asking what can be safely removed versus what would cause a catastrophic split.
The DFS Tree Is the Foundation
Run depth-first search on an undirected graph. The edges you traverse to discover new vertices form the DFS tree. The remaining edges, the ones leading to already-visited vertices, are back edges.
In an undirected DFS, every non-tree edge is a back edge. There are no cross edges and no forward edges.
This is the structural fact the whole algorithm rests on. In a directed graph you can have edges going between unrelated DFS branches. In an undirected graph, when you follow edge (u, v) and find v already visited, v must be an ancestor of u in the DFS tree. If v were in some other branch, the undirected edge (u, v) would have been the tree edge that discovered v on the way down. Cross edges simply can't happen.
The DFS tree plus back edges gives you complete information about the graph. Back edges are the only alternate routes available beyond the tree itself. For each tree edge, the algorithm determines whether those alternate routes make it redundant or whether it is the sole connection between two parts of the graph.
Left: original graph with a cycle. Right: its DFS tree. Node 2 reaches back to node 0 via the only back edge. No cross edges anywhere in sight.
Two Arrays That Know Everything
The algorithm assigns two values to each vertex.
disc[u] is the discovery time of u: a monotonically increasing counter assigned the moment DFS first visits u. Discovery times are unique across all vertices.
low[u] is the low value of u: the minimum discovery time reachable from u's subtree by going down any number of tree edges and then up at most one back edge.
The exact formula:
low[u] = min(
disc[u],
disc[w] for each back edge (u, w),
low[v] for each DFS child v of u
)
Think of low[u] as the best emergency exit from u's subtree. How high up the DFS tree can anything in that subtree escape to, using a single back edge as a ladder?
The low value tells you how high in the DFS tree u's subtree can escape to.
A small low value means the subtree has a back edge reaching far up. A large low value, one equal to disc[u] itself, means the subtree is trapped with no way out. When DFS finishes a child v and returns to u, the update low[u] = min(low[u], low[v]) propagates that escape information upward. By the time DFS exits u, low[u] reflects the best escape route available to any vertex in that entire subtree.
Node 3 has a back edge reaching node 0 (disc=0), so low[3]=0. That minimum bubbles up through 2 and 1. Node 4 has no back edge at all: low[4]=4, trapped at its own discovery time.
Why Bridge Detection Works
An edge (u, v) is a bridge if and only if low[v] > disc[u], where u is the parent of v in the DFS tree.
If low[v] > disc[u], no vertex in v's subtree has a back edge reaching u or any ancestor of u. The minimum discovery time reachable from that subtree is strictly greater than disc[u], which means the subtree cannot escape to disc[u] or any earlier vertex. Earlier discovery times correspond to ancestors of u and u itself, so v's subtree is completely trapped. The only connection from v's subtree to the rest of the graph runs through the single tree edge (u, v). Remove that edge, and the subtree is isolated. Bridge confirmed.
If low[v] <= disc[u], some vertex in v's subtree has a back edge going to u or above. Even after cutting (u, v), that back edge provides an alternate route. The edge is redundant. Not a bridge.
Left: v's subtree is sealed off. low[v]=5 > disc[u]=3. The tree edge is the only exit. Right: low[v]=1 <= disc[u]=3. A back edge escapes far above u. Cutting the tree edge changes nothing structurally.
Why Articulation Point Detection Works
A non-root vertex u is an articulation point if and only if it has a DFS child v where low[v] >= disc[u].
The condition is >=, not >. If low[v] == disc[u], v's subtree can reach u itself via a back edge but cannot get above u. Removing u severs the path from v's subtree to u's ancestors. They disconnect even though v's subtree has a back edge that touches u: u is gone, and a back edge to a missing node does nothing.
If low[v] > disc[u], v's subtree cannot even reach u. That makes the edge (u, v) a bridge, and u is definitely an articulation point.
So the AP condition (>=) is a strict superset of the bridge-endpoint condition (>). Every bridge endpoint on the child side is an AP. Some APs have children that reach exactly to them but no further. The one-character difference between > and >= is where most people get this wrong in an interview.
The root requires special treatment. For the DFS root there is no parent to disconnect from, so the low[] comparison breaks down entirely. The fix is direct: the root is an articulation point if and only if it has more than one child in the DFS tree. One child means the root sits at the end of a chain, removable without splitting anything. Two or more children means those subtrees were connected during DFS only through the root itself.
Top row: root cases decided purely by child count. Bottom row: non-root cases decided by the >= condition. The bottom-right case is the subtle one most people miss.
Both Problems in One DFS Pass
Both problems live in one DFS. Here is the full implementation before the traced example:
import sys from collections import defaultdict sys.setrecursionlimit(200_000) # raise for large inputs def find_bridges_and_aps( n: int, edges: list[tuple[int, int]] ) -> tuple[list[tuple[int, int]], list[int]]: graph: dict[int, list[int]] = defaultdict(list) for u, v in edges: graph[u].append(v) graph[v].append(u) disc = [-1] * n low = [-1] * n bridges: list[tuple[int, int]] = [] aps: set[int] = set() timer = [0] def dfs(u: int, parent: int) -> None: disc[u] = low[u] = timer[0] timer[0] += 1 children = 0 for v in graph[u]: if v == parent: # skip the edge we came from continue if disc[v] == -1: # tree edge children += 1 dfs(v, u) low[u] = min(low[u], low[v]) if low[v] > disc[u]: # bridge bridges.append((u, v)) if parent != -1 and low[v] >= disc[u]: # AP (non-root) aps.add(u) else: # back edge low[u] = min(low[u], disc[v]) if parent == -1 and children > 1: # root AP aps.add(u) for i in range(n): if disc[i] == -1: dfs(i, -1) return bridges, sorted(aps)
The if v == parent: continue check prevents the parent edge from acting as a back edge and falsely lowering low[u]. This works fine for simple graphs. Multigraphs break it: if two edges connect u and its parent, both get skipped, and the second one should actually be treated as a distinct back edge. The fix is to track edge indices and skip only the specific index you arrived on. It's a one-line change, and it bites you exactly once.
Let's Trace One
0 --- 1 --- 2 --- 3
|
4
DFS from vertex 0, parent sentinel -1:
- Visit 0: disc[0]=0, low[0]=0
- Visit 1: disc[1]=1, low[1]=1
- Visit 2: disc[2]=2, low[2]=2
- Visit 3: disc[3]=3, low[3]=3. No unvisited neighbors. Returns.
- low[2] = min(2, low[3]) = min(2, 3) = 2
- low[3]=3 > disc[2]=2: edge (2,3) is a bridge
- Visit 4: disc[4]=4, low[4]=4. Returns.
- low[1] = min(low[1], low[4]) = min(1, 4) = 1
- low[4]=4 > disc[1]=1: edge (1,4) is a bridge
- low[4]=4 >= disc[1]=1 and parent != -1: 1 is an AP
- Back at 2, returns to 1:
- low[1] = min(1, low[2]) = min(1, 2) = 1
- low[2]=2 > disc[1]=1: edge (1,2) is a bridge
- low[2]=2 >= disc[1]=1: 1 is an AP (already marked)
- Back at 1, returns to 0:
- low[0] = min(0, low[1]) = min(0, 1) = 0
- low[1]=1 > disc[0]=0: edge (0,1) is a bridge
- Root 0 has 1 child: not an AP.
Result: bridges = [(2,3), (1,2), (1,4), (0,1)], articulation points = [1].
No cycles means no back edges. No back edges means no escape routes. Every edge is a bridge, and node 1 is the only AP because it's the only vertex connecting more than one component.
Now add edge (1, 3). The cycle 1-2-3-1 forms. Re-run:
- Visit 3: neighbor 1 is already visited (back edge), low[3] = min(3, disc[1]) = min(3, 1) = 1
- Back at 2: low[2] = min(2, low[3]) = min(2, 1) = 1
- low[3]=1 > disc[2]=2? No. Edge (2,3) is no longer a bridge.
- low[3]=1 >= disc[2]=2? No. Vertex 2 is not an AP for this child.
- Back at 1: low[1] = min(1, low[2]) = min(1, 1) = 1
- low[2]=1 > disc[1]=1? No. Edge (1,2) is no longer a bridge.
- low[2]=1 >= disc[1]=1? Yes. Vertex 1 is still an AP (because of child 4 and the subtree of 0).
- Edge (0,1) and edges (1,4) remain bridges as before.
One back edge in the cycle eliminated two bridges. The cycle made that portion of the graph 2-edge-connected.
How Fast Is It?
| Operation | Time | Space |
|---|---|---|
| Build adjacency list | O(V + E) | O(V + E) |
| Find all bridges | O(V + E) | O(V) |
| Find all articulation points | O(V + E) | O(V) |
| Combined in one pass | O(V + E) | O(V) |
Time is O(V + E) because every vertex is visited exactly once and every edge is examined exactly twice. Each tree edge triggers one recursive call. Each back edge triggers one min update. Every per-vertex and per-edge operation is O(1). Total work: proportional to V + E.
Space is O(V) for disc[], low[], and parent[], plus O(V) for the DFS recursion stack (worst case: a path graph with V levels of recursion). Output space for the list of bridges is O(E) in the worst case (a tree has no cycles, so every edge is a bridge), but that is counted separately from auxiliary space.
One Structure, Every Language
import sys from collections import defaultdict sys.setrecursionlimit(200_000) def find_bridges_and_aps( n: int, edges: list[tuple[int, int]] ) -> tuple[list[tuple[int, int]], list[int]]: graph: dict[int, list[int]] = defaultdict(list) for u, v in edges: graph[u].append(v) graph[v].append(u) disc = [-1] * n low = [-1] * n bridges: list[tuple[int, int]] = [] aps: set[int] = set() timer = [0] def dfs(u: int, parent: int) -> None: disc[u] = low[u] = timer[0] timer[0] += 1 children = 0 for v in graph[u]: if v == parent: continue if disc[v] == -1: children += 1 dfs(v, u) low[u] = min(low[u], low[v]) if low[v] > disc[u]: bridges.append((u, v)) if parent != -1 and low[v] >= disc[u]: aps.add(u) else: low[u] = min(low[u], disc[v]) if parent == -1 and children > 1: aps.add(u) for i in range(n): if disc[i] == -1: dfs(i, -1) return bridges, sorted(aps)
Where This Algorithm Shows Up
Network reliability. The canonical application. Given a graph of routers and links, find every link whose failure disconnects a segment (bridges) and every router whose failure does the same (articulation points). Infrastructure teams call these single points of failure. Now you can locate them in one graph traversal instead of guessing.
Biconnected components. A biconnected component is a maximal subgraph with no articulation points: at least two vertex-disjoint paths connect every pair of vertices inside it. You can enumerate all biconnected components in O(V + E) by maintaining a stack of edges during DFS and popping off one component each time you identify an articulation point. The result is a block-cut tree, a compact representation of the graph's higher-level connectivity.
2-edge-connected components. Contracting each bridge-free subgraph into a single node and keeping only the bridges gives you the bridge tree. Two nodes are 2-edge-connected if the path between them in the bridge tree uses zero bridges. Useful for redundancy analysis.
Minimum edges to make a graph 2-edge-connected. Find bridges. Contract each 2-edge-connected component to a node. The result is a tree (the bridge tree). To make a tree 2-edge-connected, you need to connect its leaves in pairs. The minimum number of edges needed is ceil(leaves / 2), where leaves is the count of nodes in the bridge tree with degree 1.
Dependency graph analysis. In build systems or microservice architectures, if services form a graph of dependencies, an articulation point is a service that, if removed, breaks the system into isolated pieces that can no longer communicate. This is the algorithm you run before you turn off a machine for maintenance.
Five Signals That Point Here
Five signals that a problem wants bridge/AP detection:
- "Find all critical connections" or "critical nodes" in a graph.
- "What is the minimum number of edges (or nodes) whose removal disconnects the graph?"
- "Find single points of failure" in a network.
- The graph has cycles, the problem is not asking for shortest paths, and it asks about connectivity under removals.
- The problem mentions "2-edge-connected" or "biconnected component" by name.
The signal that should make you pause and not reach for this: the problem asks about directed graphs, or asks which vertices are reachable after a removal. Directed graphs need Tarjan's SCC or Kosaraju's algorithm. Reachability queries after removal need different preprocessing.
Worked Example 1: LeetCode 1192, Critical Connections in a Network
Problem. There are n servers numbered 0 to n-1, connected by undirected connections. A critical connection is one whose removal makes some servers unable to communicate with some others. Return all critical connections.
Why bridges. A critical connection is exactly a bridge. Run the algorithm and return the bridge list.
def criticalConnections(n: int, connections: list[list[int]]) -> list[list[int]]: bridges, _ = find_bridges_and_aps(n, [(u, v) for u, v in connections]) return [list(e) for e in bridges]
The constraint 2 <= n <= 10^5 and n - 1 <= connections.length <= 10^5 fits comfortably within O(V + E). The solution is as fast as it can possibly be given you need to read the input.
Worked Example 2: Minimum Edges to Ensure Full Redundancy
Problem. Given a connected network graph, find the minimum number of new links to add so that no single link failure can disconnect the network.
Why bridges. If no bridges exist, the network is already 2-edge-connected. Otherwise, bridges are the exact edges that need covering. After identifying all bridges, build the bridge tree. Each bridge becomes an edge in this tree. The minimum number of edges to add to make any tree 2-edge-connected is ceil(L / 2), where L is the number of leaves in the bridge tree.
def min_edges_for_redundancy(n: int, edges: list[tuple[int, int]]) -> int: bridges, _ = find_bridges_and_aps(n, edges) if not bridges: return 0 # already 2-edge-connected # Build the bridge tree by contracting 2-edge-connected components. # Each component gets an ID via DFS with bridges removed. bridge_set = set(bridges) | {(v, u) for u, v in bridges} graph = [[] for _ in range(n)] for u, v in edges: if (u, v) not in bridge_set: graph[u].append(v) graph[v].append(u) comp = [-1] * n comp_id = 0 for i in range(n): if comp[i] == -1: stack = [i] while stack: node = stack.pop() if comp[node] != -1: continue comp[node] = comp_id for nb in graph[node]: if comp[nb] == -1: stack.append(nb) comp_id += 1 # Count degree of each node in the bridge tree. degree = [0] * comp_id for u, v in bridges: degree[comp[u]] += 1 degree[comp[v]] += 1 leaves = sum(1 for d in degree if d == 1) return (leaves + 1) // 2
This runs in O(V + E) and produces the optimal answer.
The Algorithm at a Glance
- A bridge is an edge whose removal disconnects the graph. An articulation point is a vertex whose removal does the same.
- DFS on an undirected graph produces only tree edges and back edges. No cross edges.
disc[u]is the discovery timestamp.low[u]is the lowest timestamp reachable from u's subtree via at most one back edge.- Bridge condition:
low[v] > disc[u](strict greater-than). - Articulation point condition:
low[v] >= disc[u]for a non-root vertex, OR the root has more than one DFS child. - The
>=vs>distinction matters: APs include cases where the subtree reaches exactly to u but no further. - The parent-vertex skip fails for multigraphs. Use edge indices there instead.
- Time: O(V + E). Space: O(V).
- Primary interview problem: LeetCode 1192 (Critical Connections in a Network).
Explaining why the conditions use > for bridges but >= for APs is the kind of nuance that separates "ran the algorithm" from "understands it." If you want to practice articulating reasoning like that under interview pressure, SpaceComplexity runs realistic voice-based DSA mock interviews and scores you on exactly that dimension. A correct algorithm with a shaky explanation scores lower than you expect.
For deeper coverage of DFS as a foundation, see the depth-first search algorithm guide. For the graph representation choices that affect how you build the adjacency list, see Graph Data Structure for Interviews. And if you are unsure whether a problem calls for DFS or BFS in the first place, BFS vs DFS covers that decision.
Further Reading
- Bridge (graph theory) on Wikipedia: formal definition, properties, and the connection to 2-edge-connected components.
- Biconnected component on Wikipedia: block-cut trees, the relationship between bridges and biconnected components, and Hopcroft-Tarjan.
- Bridges in a Graph on GeeksforGeeks: step-by-step walkthrough with diagrams.
- Articulation Points (or Cut Vertices) in a Graph on GeeksforGeeks: parallel walkthrough for the AP detection case.
- LeetCode 1192: Critical Connections in a Network: the standard interview problem that directly tests bridge detection.