A cut vertex tells you where a network can break. A biconnected component tells you the pieces that cannot be broken by any single vertex failure, and how those pieces hang together. That second view is what you need when you ask whether two services stay connected after any one router dies, when you want to run an expensive algorithm on small independent pieces instead of the whole graph, or when a planarity or colouring routine needs its input split first.

This article covers three equivalent definitions, the edge-stack extension of Tarjan's depth-first search written iteratively, why multigraphs force you to track the parent edge rather than the parent vertex, and the block-cut tree. The code was checked against a brute-force oracle, and the outputs quoted are the outputs it printed. If low-link values are new to you, read the DFS tree and edge classification first; this page assumes them.

What a block is

Take a connected undirected graph. A cut vertex (articulation point) is a vertex whose removal disconnects it. A graph with at least three vertices and no cut vertex is biconnected. A biconnected component, usually called a block, is a maximal biconnected subgraph, with one convention: a bridge on its own, two vertices and one edge, also counts as a block.

Three statements describe the same partition, and you will use all of them:

  1. Cycles. Two distinct edges are in the same block exactly when some simple cycle passes through both. This makes "same block" an equivalence relation on edges.
  2. Disjoint paths. By Menger's theorem (Whitney's 1932 form), a graph with at least three vertices is biconnected exactly when every pair of vertices is joined by two paths that share no internal vertex. Inside a block you always have a spare route.
  3. No single separator. Two edges are in the same block exactly when no single vertex, endpoints included, separates them once deleted. This is the slow but obviously correct test the oracle below uses.

The crucial structural fact: blocks partition the edges, not the vertices. Every edge belongs to exactly one block. A vertex belongs to several blocks precisely when it is a cut vertex. An isolated vertex belongs to no block at all, and a self-loop never changes the answer, so implementations drop it. Forgetting that vertices are shared is the most common modelling error: code that assigns each vertex one component id cannot represent a block decomposition.

Vertex versus edge biconnectivity

There is a second, coarser notion that is often confused with this one. Delete every bridge and the remaining connected components are the 2-edge-connected components. They survive any single link failure, whereas blocks survive any single node failure. The two disagree on the simplest interesting graph: two triangles sharing one vertex, the bowtie. It has no bridge, so it is one 2-edge-connected component, but the shared vertex is a cut vertex, so it has two blocks.

Blocks (2-vertex-connected)2-edge-connected components
Partitionsedges; cut vertices sharedvertices; bridges belong to none
Survivesany one vertex failureany one edge failure
DFS testlow[c] >= disc[p]low[c] > disc[p]
Condensed structureblock-cut treebridge tree
Bowtie graph2 blocks1 component

Pick the one that matches the failure you are modelling. For routers and hosts, use blocks. For cables between always-on sites, the edge version is usually right; the bridges article covers that side, including how to add the fewest links to remove every bridge.

The edge-stack algorithm

Run a depth-first search. Give each vertex a discovery time disc[v] and compute low[v], the smallest discovery time reachable from v's subtree using tree edges downward and at most one back edge upward. In an undirected DFS every non-tree edge joins a vertex to one of its ancestors, which is what makes this work.

Now keep a stack of edges. Push every tree edge when you descend it, and every back edge when you first see it from the deeper endpoint. When the search returns from child c to parent p and finds low[c] >= disc[p], nothing in c's subtree can climb above p, so p separates that subtree from the rest. Every edge pushed since the tree edge (p, c), and that edge itself, lies in c's subtree or connects it to p, and none of them has been claimed by an earlier block. Pop them: that is one block. The parent p is a cut vertex unless it is the DFS root; the root is a cut vertex only when it has two or more DFS children. Every edge is pushed once and popped once, so the whole thing is O(V + E).

Why only back edges seen from the deeper end? Each back edge is examined from both endpoints. Seen from the ancestor it points to an already-finished descendant, and pushing it again would put the same edge into the stack twice. The test disc[w] < disc[v] keeps exactly one copy.

An iterative implementation

Recursive versions are shorter, but CPython's default recursion limit of 1,000 dies on a path graph with a few thousand vertices. This version keeps its own frame stack, and refers to edges by their index in the input list, which is what lets it handle parallel edges.

def biconnected_components(n, edges):
    """Blocks as lists of edge ids, plus the set of cut vertices. O(V + E)."""
    adj = [[] for _ in range(n)]
    for i, (u, v) in enumerate(edges):
        if u != v:                          # self-loops never matter
            adj[u].append((v, i))
            adj[v].append((u, i))
    disc, low = [-1] * n, [0] * n
    timer, blocks, cut, estack = 0, [], set(), []
    for root in range(n):
        if disc[root] != -1:
            continue
        disc[root] = low[root] = timer; timer += 1
        children = 0
        stack = [(root, -1, 0)]             # (vertex, parent edge id, next index)
        while stack:
            v, pe, i = stack[-1]
            if i < len(adj[v]):
                stack[-1] = (v, pe, i + 1)
                w, eid = adj[v][i]
                if eid == pe:               # skip the parent EDGE, not vertex
                    continue
                if disc[w] == -1:           # tree edge
                    estack.append(eid)
                    disc[w] = low[w] = timer; timer += 1
                    children += v == root
                    stack.append((w, eid, 0))
                elif disc[w] < disc[v]:     # back edge, seen from below
                    estack.append(eid)
                    low[v] = min(low[v], disc[w])
            else:
                stack.pop()
                if stack:
                    p = stack[-1][0]
                    low[p] = min(low[p], low[v])
                    if low[v] >= disc[p]:   # p separates v's subtree
                        if p != root:
                            cut.add(p)
                        block = []
                        while True:
                            e = estack.pop()
                            block.append(e)
                            if e == pe:
                                break
                        blocks.append(block)
        if children >= 2:
            cut.add(root)
    return blocks, cut

Worked example, with a parallel edge

The graph in the figure has nine vertices A to I and twelve edges: triangle ABC, the edge CD, triangle DEF with the edge DE present twice, the edge DI, and triangle FGH. Feeding it to the function prints five blocks and three cut vertices:

block ['F', 'G', 'H'] ['FG', 'GH', 'HF']
block ['D', 'E', 'F'] ['DE', 'EF', 'FD', 'DE']
block ['D', 'I'] ['DI']
block ['C', 'D'] ['CD']
block ['A', 'B', 'C'] ['AB', 'BC', 'CA']
cut ['C', 'D', 'F']
Five blocks in a nine-vertex graph, and the block-cut tree they formx2ABCDEFGHIYellow = cut vertex. Colour = block. Grey edges are bridges, each a block of its own.ABCCCDDDIDEFFFGH
Left: the worked example coloured by block. Right: its block-cut tree, with a square node per block and a circle per cut vertex.

Trace the first pop. The search runs A, B, C, D, E, F, G, H. At H the back edge HF gives low[H] = disc[F]. Returning to G and then to F, the test low[G] >= disc[F] holds, so the three edges FG, GH and HF come off the stack: block FGH, and F is a cut vertex. The DEF block comes next and carries both copies of DE. The bridges CD and DI pop as single-edge blocks, and ABC pops last at the root.

Now the parallel edge. Suppose the code skipped the parent vertex instead of the parent edge id. Arriving at E from D along the first DE, it would also skip the second DE, so that edge would never be pushed and would belong to no block. In this graph the triangle hides the damage, because F still links E back to D. In a graph where two vertices are joined only by a doubled link, the same bug reports a bridge where the network actually has redundancy. Doubled links are exactly how real networks are built, so this is not an edge case.

The block-cut tree

Make one node per block and one node per cut vertex, and join each cut vertex to every block containing it. The result is a tree for a connected graph and a forest otherwise. It is a tree because a cycle in it would pass through two different blocks along two separate routes, and then those blocks would lie on a common cycle and be one block.

The tree turns path questions into tree questions. Map each vertex to a tree node: a cut vertex to its own node, any other vertex to its unique block. Then every path from a to b in the graph passes through cut vertex z exactly when z lies on the tree path between the nodes for a and b. With lowest-common-ancestor preprocessing each query takes O(1) or O(log V). The number of cut vertices on that tree path is the number of single points of failure between a and b. In the example, every route from A to H passes through C, D and F, and no route from E to I needs anything except D.

def block_cut_tree(n, edges, blocks, cut):
    """Node ids: 0..len(blocks)-1 are blocks, then one id per cut vertex."""
    cut_id = {v: len(blocks) + k for k, v in enumerate(sorted(cut))}
    tree, home = {}, [None] * n
    for b, eids in enumerate(blocks):
        verts = {x for e in eids for x in edges[e]}
        for v in verts:
            if v in cut_id:
                tree.setdefault(b, set()).add(cut_id[v])
                tree.setdefault(cut_id[v], set()).add(b)
            else:
                home[v] = b                 # non-cut vertices live in one block
    for v, t in cut_id.items():
        home[v] = t
    return tree, home                       # isolated vertices keep home None

The leaf blocks matter for design work. To make a connected graph biconnected by adding edges, you need at least ceil(L / 2) new edges, where L is the number of leaf blocks, because each new edge can relieve at most two leaves. Eswaran and Tarjan (1976) gave the exact minimum, which also accounts for a cut vertex shared by many blocks. Hsu and Ramachandran (1993) later published a corrected linear-time algorithm for it.

Testing and scale

Graph code fails on shapes you did not think of, so test it against something slow and obviously correct. The oracle merges two edges whenever no single vertex deletion separates them, and finds cut vertices by deleting each vertex and counting components. The harness generated 1,500 random multigraphs with 1 to 8 vertices and 0 to 12 edges, self-loops and parallel edges included, and checked three things on each: the blocks match the oracle exactly, the cut vertices match, and every non-loop edge appears in exactly one block. All 1,500 passed. The third invariant is the cheap one to keep in production code, because it catches the dropped-parallel-edge bug immediately.

For scale, a 200,000-vertex graph made of a long path plus 300,000 random extra edges took 1.08 seconds in CPython. Random graphs like this collapse into one giant block, so treat the number as throughput only.

What blocks are for

Several algorithms work block by block and then combine the answers cheaply:

  • Planarity. A graph is planar exactly when each block is planar, so linear-time planarity testing starts by splitting into blocks.
  • Colouring. The chromatic number of a graph with an edge is the largest among its blocks, so exact colouring can run block by block.
  • Resilience reviews. Rank cut vertices by how many vertices their removal strands; the articulation points article computes those piece sizes in the same DFS.
  • Parallel computation. Tarjan and Vishkin (1985) avoid depth-first search entirely: blocks become connected components of an auxiliary graph.

Operational guidance

Normalise the input first: map identifiers to dense integers, drop self-loops, and keep parallel edges if they represent real redundancy. Report isolated vertices separately, and store blocks as edge-id lists, deriving the overlapping vertex sets on demand.

When the graph changes, linear-time recomputation is usually fast enough. Deletions are much harder to handle incrementally; fully dynamic biconnectivity is a research topic, not a library call. For the bridge-tree view, see the low-link article.

Failure modes

  • Parent vertex instead of parent edge. Drops parallel edges and invents bridges. Fix: skip by edge id.
  • Pushing back edges from both ends. The same edge lands in the stack twice and corrupts a later block. Fix: push only when disc[w] < disc[v].
  • Root rule forgotten. The root always satisfies low[c] >= disc[root], so without the two-children rule every root is reported as a cut vertex. The block popping is still correct.
  • One component id per vertex. Cannot express shared cut vertices. Use edge-to-block labels.
  • Wrong failure model. Reporting 2-edge-connected components when the risk is node failure overstates resilience, as the bowtie shows.

Trade-offs

The single DFS is optimal and simple, and gives cut vertices, blocks and, with one changed comparison, bridges together. It is inherently sequential, which costs you on distributed graphs. Schmidt's 2013 chain decomposition gives the same answer plus a certificate you can check independently. Biconnectivity only protects against one failure. For k failures you need k-vertex-connectivity, which is a max-flow problem by Menger's theorem and costs far more than a linear scan.

What to do next

  1. Copy the iterative function and add the every-edge-in-one-block assertion to your test suite.
  2. Write the brute-force oracle (delete each vertex, compare) and run it on a few thousand random small multigraphs, parallel edges included.
  3. Decide whether your failure model is node or link failure, and choose blocks or 2-edge-connected components accordingly.
  4. Build the block-cut tree for your real topology and list the cut vertices on the paths between your most critical service pairs.
  5. Count the leaf blocks to estimate how many links a redundancy upgrade needs.
  6. If an expensive algorithm runs on the whole graph, check whether it decomposes over blocks and run it per block instead.
Key takeaway: Blocks partition a graph's edges into pieces that survive any single vertex failure, with cut vertices shared between them. One depth-first search with an edge stack finds them in linear time: pop a block whenever low[child] >= disc[parent]. Skip the parent edge by id so parallel links count, use an explicit stack, and test against a brute-force oracle. Then use the block-cut tree to answer which single points of failure lie between any two vertices.