Graph Connectivity Decompositions: SCCs, Articulation Points & Biconnected Components

Depth-first decompositions that expose directed mutual reachability and undirected single-point failures: strongly connected components, the condensation DAG, bridges, articulation points, and edge- and vertex-biconnected structure.

Connectivity is not a single yes/no property. In a directed graph, reachability may work in one direction but not the other. In an undirected graph, all vertices may currently be connected while one vertex or one edge is a hidden single point of failure. Connectivity decompositions turn those questions into explicit components and a smaller structural graph.

The two main settings use depth-first search (DFS), but their low-link values mean different things:

  • directed: strongly connected components (SCCs) group vertices that can reach one another;
  • undirected: bridges, articulation points, and biconnected blocks show where deleting one edge or vertex breaks connectivity.

Do not copy a recurrence from one setting into the other. The stack membership test in Tarjan’s SCC algorithm and the parent-edge rule in undirected DFS are different invariants.

Connectivity questions and their outputs #

Graph and failure model Question Decomposition or certificate
directed, no deletion which vertices are mutually reachable? SCC partition
directed, component-level flow how can SCCs reach one another? condensation DAG
undirected, delete one edge which edges disconnect their component? bridges and 2-edge-connected components
undirected, delete one vertex which vertices disconnect their component? articulation points and vertex-biconnected blocks
undirected, organize all blocks how are robust regions joined at cut vertices? block-cut forest

All of these decompositions are for a static snapshot. Maintaining them under arbitrary insertions and deletions is a dynamic graph problem with a different cost model.

Strongly connected components #

In a directed graph G=(V,E)G=(V,E), vertices uu and vv are strongly connected when each is reachable from the other. This relation is:

  • reflexive: every vertex reaches itself by a zero-edge path;
  • symmetric by definition;
  • transitive: paths u⇝v⇝wu\leadsto v\leadsto w and w⇝v⇝uw\leadsto v\leadsto u compose.

It is therefore an equivalence relation, and its equivalence classes partition VV. An SCC is a maximal such class. “Maximal” matters: two overlapping mutually reachable sets belong to one larger SCC, not two components.

A singleton vertex is always an SCC. It contains a directed cycle only if it has a self-loop; membership in an SCC does not itself imply a nonempty cycle.

The condensation graph is a DAG #

Contract every SCC to one component vertex. For each original edge u→vu\to v whose endpoints have different component IDs, add

C(u)→C(v).C(u)\to C(v).

Deduplicating parallel component edges gives the condensation graph. It must be acyclic. If distinct components lay on a directed component cycle, every vertex in each component could follow the cycle to every other component and return. Their union would be mutually reachable, contradicting SCC maximality.

This contraction separates cyclic internal behavior from acyclic global flow. Topological reasoning becomes valid on the condensation even when it is invalid on the original graph.

When building the condensation, component labels are arbitrary identifiers. Some SCC implementations happen to emit sink components first, but downstream code should topologically sort the constructed DAG unless the API explicitly promises an order.

Tarjan’s one-pass SCC algorithm #

Tarjan’s algorithm assigns each vertex a discovery index index[u], maintains low[u], and keeps active vertices on a stack. on_stack[u] means that uu has been discovered but not yet assigned to a completed SCC.

Tarjan-SCCtext
time = 0
stack = empty

strong_connect(u):
    index[u] = low[u] = time
    time += 1
    push u
    on_stack[u] = true

    for each directed edge u -> v:
        if index[v] is undefined:
            strong_connect(v)
            low[u] = min(low[u], low[v])
        else if on_stack[v]:
            low[u] = min(low[u], index[v])
        else:
            ignore the edge for low-link purposes

    if low[u] == index[u]:
        start a new component
        repeat:
            v = pop stack
            on_stack[v] = false
            assign v to this component
        until v == u

for every vertex u with undefined index:
    strong_connect(u)

For an active vertex uu, low[u] summarizes the smallest discovery index that uu’s still-active DFS region can reach without leaving the unresolved stack structure. The two update cases carry different evidence:

  1. after recursively visiting a tree child vv, the child’s entire unresolved region is known, so propagate low[v];
  2. for an edge to an already discovered vertex vv that is still on the stack, the edge directly reaches index[v].

An edge to a completed SCC is ignored. That component has already been sealed; letting its old low value flow backward could incorrectly merge components that have only one-way reachability.

Why low[u] == index[u] closes one SCC #

If equality holds after all outgoing edges are processed, no active descendant in uu’s DFS region can reach an active vertex discovered before uu. Vertex uu is the earliest active vertex reachable from that region. The consecutive stack suffix from the top through uu is mutually reachable through DFS-tree paths downward and recorded return paths within the active region, so it forms one SCC. Popping the entire suffix makes the maximal component permanent.

Each vertex is pushed and popped once and each edge is examined once. With adjacency lists, time is O(∣V∣+∣E∣)O(|V|+|E|) and auxiliary space is O(∣V∣)O(|V|), in addition to the graph. An adjacency matrix still costs Θ(∣V∣2)\Theta(|V|^2) to scan because finding outgoing edges is no longer proportional to their count.

A complete SCC trace #

Use these directed edges, scanning them in the order shown:

text
A -> B
B -> C, D
C -> A
D -> E
E -> D, F
F -> (none)

DFS discovers A,B,C,D,E,F with indices 0 through 5.

Event Low-link consequence Stack after any pop
C→A reaches active index 0 low[C]=0, then low[B]=0 [A,B,C]
E→D reaches active index 3 low[E]=3 [A,B,C,D,E]
F has no outgoing edge low[F]=index[F]=5; pop {F} [A,B,C,D,E]
return from E to D low[D]=3; pop {E,D} at root D [A,B,C]
return from D to B completed {D,E} cannot lower B; low[B]=0 [A,B,C]
return to A low[A]=0; pop {C,B,A} []

The SCCs are {A,B,C}, {D,E}, and {F}. The condensation is the chain

{A,B,C}⟶{D,E}⟶{F}.\{A,B,C\}\longrightarrow\{D,E\}\longrightarrow\{F\}.

The edge B→D does not merge the first two SCCs because no path returns from {D,E} to {A,B,C}.

A two-pass alternative #

Kosaraju’s algorithm runs DFS once to compute decreasing finish order, then runs DFS in that order on the transposed graph. Each second-pass tree is one SCC. It has the same O(V+E)O(V+E) asymptotic time and is often easier to derive from existing traversal primitives, but it needs reverse adjacency or an explicit transpose and two graph passes. Tarjan avoids the transpose at the cost of a more delicate active-stack invariant.

Now let GG be undirected. Give every undirected edge a unique ID and store two adjacency entries with that same ID. DFS records discovery time tin[u]. For a DFS-tree vertex uu, define low[u] as the smallest discovery time reachable by:

  1. descending zero or more DFS-tree edges within uu’s subtree; then
  2. using at most one non-tree edge to an ancestor.

Initialize low[u]=tin[u]. For a tree edge from uu to an undiscovered child vv, recurse and then use

low[u]←min⁡(low[u],low[v]).low[u]\gets\min(low[u],low[v]).

For a non-tree edge from uu to an already discovered ancestor vv, use

low[u]←min⁡(low[u],tin[v]).low[u]\gets\min(low[u],tin[v]).

In an undirected DFS, every non-tree edge connects an ancestor and a descendant. Process it from the descendant endpoint, where tin[v] < tin[u]; this orientation also lets an edge stack store it exactly once.

The parent exception is an edge, not a vertex #

The tree edge by which DFS entered uu appears again in uu’s undirected adjacency list. Skip that exact parent_edge_id. Do not skip every neighbor equal to parent[u].

If two parallel edges connect parent pp and child uu, one may be the tree edge while the other is a legitimate non-tree edge back to pp. The second edge lowers low[u] to tin[p], proving that neither parallel edge is a bridge. A parent-vertex check loses that evidence and produces a false bridge.

Self-loops never connect two previously separated vertex regions, so they cannot be bridges and cannot make a vertex an articulation point. Report them separately from connectivity blocks; otherwise a loop-only “block” can make the usual block-cut forest incidence convention ambiguous.

An undirected edge is a bridge if deleting it increases the number of connected components. A DFS-tree edge (u,v)(u,v), where uu is the parent, is a bridge exactly when

low[v]>tin[u].low[v] > tin[u].

If the inequality is strict, no vertex in vv’s subtree has a non-tree edge to uu or any ancestor of uu. The tree edge is therefore the subtree’s only connection to the earlier graph. Conversely, if low[v] <= tin[u], the subtree has a return edge reaching uu or above, and tree paths plus that edge form a route around (u,v)(u,v).

Every bridge belongs to the DFS tree; a non-tree edge already closes a cycle and therefore has an alternate path between its endpoints.

Removing every bridge leaves the 2-edge-connected components (also called bridge-connected components): maximal vertex sets in which every pair has two edge-disjoint connecting paths. These components form a vertex partition. Contracting them and retaining the bridges produces a forest.

Articulation points: root and non-root rules differ #

An articulation point or cut vertex is a vertex whose deletion, together with its incident edges, increases the number of connected components.

For a non-root DFS vertex uu, the condition is

∃ tree child v:low[v]≥tin[u].\exists\text{ tree child }v:\quad low[v]\ge tin[u].

The non-strict inequality is intentional. If the best return from vv’s subtree reaches uu itself but no proper ancestor, deleting uu still separates that subtree from the earlier graph.

For a DFS root rr, the correct condition is different:

r is an articulation point  ⟺  r has at least two DFS-tree children.r\text{ is an articulation point}\iff r\text{ has at least two DFS-tree children.}

Different root children cannot have an undirected edge between their subtrees; if such an edge existed, DFS would have reached the later child through the first subtree. Deleting the root therefore separates those child subtrees. With zero or one child, removing the root cannot split its DFS tree into multiple remaining regions.

The difference between bridge > and articulation >= captures two failure models. A return edge from a child subtree to uu itself gives an alternate route around the tree edge, so that edge is not a bridge. But every such route still passes through vertex uu, so deleting the vertex disconnects the subtree.

Worked undirected decomposition #

Consider:

  • triangle A-B-C-A;
  • edge B-D;
  • triangle D-E-F-D;
  • leaf edge D-G.

With DFS tree edges A-B, B-C, B-D, D-E, E-F, D-G, discovery and low values are:

Vertex Parent tin low Decisive return
A — 0 0 DFS root
B A 1 0 descendant C returns to A
C B 2 0 non-tree edge C-A
D B 3 3 no return above D
E D 4 3 descendant F returns to D
F E 5 3 non-tree edge F-D
G D 6 6 no return edge

Consequences:

  • B-D is a bridge because low[D]=3>tin[B]=1low[D]=3>tin[B]=1;
  • D-G is a bridge because low[G]=6>tin[D]=3low[G]=6>tin[D]=3;
  • B is an articulation point because child D has low[D]≥tin[B]low[D]\ge tin[B];
  • D is an articulation point because children E and G cannot reach above D;
  • root A has one DFS child, so it is not an articulation point.

Under the block convention used below, the vertex-biconnected blocks are the two triangles {A,B,C} and {D,E,F}, plus bridge blocks {B,D} and {D,G}.

Now add a second parallel edge between D and G. One D-G edge becomes the tree edge and the other becomes a return edge, so low[G]=tin[D]=3. Neither parallel edge is a bridge because the bridge test is strict. Vertex D remains an articulation point because low[G]≥tin[D]low[G]\ge tin[D]: deleting D still isolates G from the rest.

Vertex-biconnected blocks #

A connected undirected graph is vertex-biconnected when it has no articulation point under the chosen small-graph convention. A block decomposition represents maximal regions that cannot be split by deleting an internal vertex.

Terminology for graphs with fewer than three vertices varies. This chapter uses the common edge-block convention useful in implementations:

  • every non-loop edge belongs to exactly one block;
  • a bridge forms a two-vertex, one-edge block;
  • articulation vertices may occur in several blocks;
  • isolated vertices are reported separately as singleton components;
  • self-loops, if supported, are reported separately and do not affect low-link tests.

This convention distinguishes vertex blocks from 2-edge-connected components. Removing bridges produces a vertex partition. Vertex-biconnected blocks instead partition the non-loop edges, while articulation vertices are shared between blocks.

Extracting blocks with an edge stack #

Push every DFS-tree edge when traversed. Push a non-tree edge only from its descendant endpoint to its ancestor, so each undirected edge enters the stack once. After returning from child vv of uu, whenever

low[v]≥tin[u],low[v]\ge tin[u],

pop through the tree edge (u,v)(u,v). Those popped edges form one block. They cannot connect above uu without passing through uu, and delaying the pop would incorrectly merge across an articulation boundary.

The following implementation simultaneously finds bridges, articulation points, and edge blocks. edges[eid] stores the endpoints; each non-loop adjacency entry carries the same stable eid at both endpoints.

Undirected-low-link-decompositionpython
def decompose_undirected(vertex_count, edges):
    adjacency = [[] for _ in range(vertex_count)]
    blocks = []
    loop_edges = []

    for edge_id, (a, b) in enumerate(edges):
        if a == b:
            loop_edges.append(edge_id)
        else:
            adjacency[a].append((b, edge_id))
            adjacency[b].append((a, edge_id))

    tin = [-1] * vertex_count
    low = [-1] * vertex_count
    edge_stack = []
    bridges = set()
    articulations = set()
    time = 0

    def dfs(u, parent_edge=-1):
        nonlocal time
        tin[u] = low[u] = time
        time += 1
        tree_children = 0

        for v, edge_id in adjacency[u]:
            if edge_id == parent_edge:
                continue

            if tin[v] == -1:
                tree_children += 1
                edge_stack.append(edge_id)
                dfs(v, edge_id)
                low[u] = min(low[u], low[v])

                if low[v] > tin[u]:
                    bridges.add(edge_id)

                if low[v] >= tin[u]:
                    if parent_edge != -1:
                        articulations.add(u)

                    block = []
                    while True:
                        popped = edge_stack.pop()
                        block.append(popped)
                        if popped == edge_id:
                            break
                    blocks.append(block)

            elif tin[v] < tin[u]:
                # Process each undirected non-tree edge only at its
                # descendant endpoint; parallel parent edges land here.
                edge_stack.append(edge_id)
                low[u] = min(low[u], tin[v])

        if parent_edge == -1 and tree_children >= 2:
            articulations.add(u)

    for root in range(vertex_count):
        if tin[root] == -1:
            dfs(root)
            assert not edge_stack

    nonloop_isolated = [u for u in range(vertex_count) if not adjacency[u]]
    return (bridges, articulations, blocks, nonloop_isolated,
            loop_edges, tin, low)

The comment records a correctness constraint rather than implementation history: changing the comparison to v != parent[u] breaks parallel-edge handling. The algorithm examines each adjacency entry a constant number of times, and every edge is pushed and popped at most once, so it runs in O(V+E)O(V+E) time and uses O(V+E)O(V+E) auxiliary/output storage.

The returned nonloop_isolated list includes a vertex carrying only self-loops because loops were intentionally excluded from connectivity adjacency. The separate loop_edges result preserves those edges without pretending that they affect connectivity. If an API defines loop-degree differently, expose separate “no non-loop incident edge” and “degree zero” predicates rather than silently changing the decomposition contract.

The block-cut forest #

Create one node for every vertex-biconnected block and one node for every articulation vertex. Connect articulation node aa to block node BB exactly when aa belongs to BB.

For each original connected component containing an edge, this block-cut graph is a tree; across a disconnected graph it is a forest. It is connected because adjacent original blocks meet through their cut vertices. It cannot contain a cycle: a cycle would give two distinct block-level routes between shared cut vertices, contradicting the maximal separation of the blocks. An isolated original vertex can be added as a singleton block node when a uniform representation of every connected component is useful.

The block-cut forest answers structural queries cleanly:

  • removing an articulation node separates the incident block branches;
  • the unique block-cut path shows which cut vertices mediate travel between two robust regions;
  • dynamic programming can operate on the forest after complex cyclic interiors are contracted.

Do not confuse it with the bridge forest. The bridge forest contracts 2-edge-connected vertex components and keeps bridges as edges. The block-cut forest represents vertex blocks and cut vertices as two different node types.

The following properties should be test assertions, not informal expectations:

  1. tin[u] is assigned once, and low[u] <= tin[u] after DFS finishes uu.
  2. The exact undirected parent edge ID is skipped once; a parallel parent edge is processed normally.
  3. A tree edge (u,v)(u,v) is a bridge exactly when low[v] > tin[u].
  4. A non-root uu is an articulation point exactly when some child has low[v] >= tin[u].
  5. A DFS root is an articulation point exactly when it has at least two tree children.
  6. Every non-loop edge appears in exactly one emitted vertex block under this chapter’s convention.
  7. Contracted SCC edges never form a directed cycle.

Useful adversarial fixtures include an isolated vertex, one edge, a simple cycle, a path, two cycles sharing one vertex, parallel edges, a self-loop, and a disconnected union of all of them.

Boundaries and common mistakes #

Mistake Why it fails Correct boundary
treating weakly connected directed vertices as one SCC ignoring direction proves only undirected reachability require paths in both directed directions
adding component edges but not deduplicating semantics stay valid but downstream degrees and work are inflated store a set or sort/unique component pairs
using an SCC’s completed low value through an outgoing edge one-way reachability can merge distinct SCCs only active-stack edges affect directed low-link
using low[v] for every directed non-tree edge it imports reachability not certified by the direct edge use index[v] when v is on stack
skipping all edges to the parent vertex loses a parallel return edge skip only parent_edge_id
using >= for bridges a return to the parent gives an alternate edge route bridge test is strict >
using the non-root articulation rule at the root a one-child root is falsely marked root needs at least two tree children
assuming blocks partition vertices cut vertices legitimately belong to several blocks vertex blocks partition edges under the stated convention
applying these bridge rules to directed graphs directed deletion sensitivity is different strong bridges/strong articulation points need directed algorithms
using disjoint-set union to find articulation points DSU tracks current partitions, not sensitivity to deletion use DFS low-link for a static snapshot

Disjoint-Set Union is excellent for connectivity under edge insertions and for Kruskal-style processing, but it does not retain the DFS ancestry and return-edge evidence needed to identify cuts. Graph Traversals supplies the discovery/finish-state foundation, while Graph Representations explains why stable edge identity is part of a multigraph API rather than an optional optimization.

Recursion depth is another operational boundary. A path graph produces depth VV. Languages with shallow call stacks need an explicit frame stack that stores the current vertex, parent edge ID, and next adjacency position. Merely pushing bare vertices changes the return-time at which low[child] becomes available and is not an equivalent translation.

Choosing the decomposition #

Need Output Baseline method Cost with adjacency lists
mutual reachability in a directed graph SCC ID per vertex Tarjan or Kosaraju O(V+E)O(V+E)
acyclic summary of a directed graph condensation DAG SCCs, then contract cross-component edges O(V+E)O(V+E) expected with hashing
single-edge failures in an undirected graph bridges DFS tin/low O(V+E)O(V+E)
regions robust to one edge deletion 2-edge-connected vertex components remove/ignore bridges, then traverse O(V+E)O(V+E)
single-vertex failures articulation points DFS tin/low with root rule O(V+E)O(V+E)
maximal vertex-robust edge regions vertex-biconnected blocks DFS plus edge stack O(V+E)O(V+E)
hierarchy of blocks and cut vertices block-cut forest incidence construction after blocks O(V+E)O(V+E)

The asymptotic result assumes the graph snapshot and its adjacency lists fit the computational model. Extremely large graphs can make adjacency layout and I/O dominate even though the abstract algorithm is linear.

Exercises #

  1. Prove directly that contracting SCCs cannot introduce a directed cycle between distinct components.
  2. In a DFS tree, give an example where low[child] == tin[parent]. Explain why the tree edge is not a bridge but the parent can be an articulation point.
  3. Run the undirected algorithm on two vertices joined by two parallel edges. Record the edge stack and every inequality.
  4. Explain why an undirected DFS has no non-tree edge between two unrelated DFS subtrees.
  5. Compare the partitions produced by deleting all bridges with the overlapping vertex sets of biconnected blocks on two triangles sharing one vertex.
Checks
  1. A component cycle supplies directed paths around the cycle in both directions between every pair of its component vertices, so maximality would merge them.
  2. In triangle u-v-w-u with tree edges u-v, v-w, low[v]=tin[u]. Edge u-v has alternate route u-w-v, so it is not a bridge. If u is a non-root joining that triangle to an ancestor region, deleting u can still separate the triangle because its return reaches no proper ancestor.
  3. One parallel edge is the tree edge; the other is processed at the child as an ancestor edge, lowering the child to the parent’s tin. The bridge test sees equality, not strict inequality; the block pop contains both edge IDs.
  4. If an edge joined the subtrees, DFS would traverse it while exploring the first subtree and discover the other endpoint there, so they would not remain unrelated subtrees.
  5. There are no bridges, so deleting bridges leaves one 2-edge-connected vertex component. The vertex blocks are the two triangles and share their common articulation vertex.

Sources

  1. paperDepth-First Search and Linear Graph Algorithms (Tarjan, 1972)
  2. paperAlgorithm 447: Efficient Algorithms for Graph Manipulation (Hopcroft and Tarjan, 1973)
  3. docsBoost Graph Library — Strongly Connected Components
  4. docsBoost Graph Library — Biconnected Components and Articulation Points

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close