Graphs/Bridges & Articulation Points

Lesson 6.104,842 words

Bridges & Articulation Points

A bridge is an edge whose removal disconnects the graph; an articulation point is a vertex whose removal does. Both are single points of failure in a network.

╌╌╌╌

We have seen depth-first search lay a tree over a graph, and we used that tree to direct edges, find cycles, and decompose a digraph into strongly connected components. This lesson turns the same machinery on a different question, one that matters whenever a graph models a network: which parts of it are fragile? If a single fiber link is cut or a single router fails, the network may split into pieces that can no longer reach one another. These single points of failure have names.

A graph with no bridges is 2-edge-connected: you must cut at least two edges to disconnect it, so every pair of vertices is joined by two edge-disjoint paths. A graph with no articulation points (and at least three vertices) is 2-vertex-connected, or biconnected: two vertex-disjoint paths between every pair.1 The maximal biconnected subgraphs are the biconnected components; bridges and cut vertices are the seams where they meet. So the question where is my network fragile? is the question find the cut edges and cut vertices, and one DFS answers it.

2-edge-connected (left, every edge on a cycle) vs a graph with a bridge (right, cut)

The DFS-tree view

Run DFS from any vertex of a connected, undirected graph. Classify each edge by when DFS first traverses it. The decisive structural fact is that only two kinds survive.

This is what makes undirected connectivity tractable: the only way to leave a DFS subtree is a back edge climbing to an ancestor. Bridges and cut vertices are both about whether such an escape exists. To detect it we time the search and track how high each subtree can climb.

undirected DFS yields only tree edges (solid) and back edges (dashed) — no cross or forward edges

In words, is the smallest discovery time reachable from 's DFS subtree using any number of tree edges (downward) plus at most one back edge (the final hop up). It measures the highest ancestor the subtree rooted at can reach without going through 's parent. Both quantities are computed in the same recursion: set on entry, then relax against each child's finished and each back edge's target .

DFS tree (solid = tree edge, dashed = back edge); bridge

In the figure each node is labeled . The back edge pulls , so the subtrees of and can all climb back above their parents: none of , is a bridge. But is a dead end, , nothing in 's subtree reaches above , so cutting strands . The edge is a bridge too, since 's only escape, the back edge , stays inside 's own subtree.

The bridge criterion

The criterion is a decision test on a single edge — is this a bridge? — and the iff is its two-sided correctness in the sense of the foundations. The forward direction ( implies the edge really is a bridge) is its soundness: the test never flags a safe edge. The reverse (a true bridge always satisfies , since otherwise a back edge offers an alternative route) is its completeness: no bridge is missed.

The articulation criterion

Cut vertices need one more case, because removing deletes all of 's incident edges at once, including the tree edges to each child.

cut vertex (or root with children)

Here the back edge gives . The subtree of can climb to but no higher, so holds and is a cut vertex: deleting it strands and from . Yet the edge is not a bridge, since is not strictly greater. The same local data, two thresholds apart, distinguishes fragile edges from fragile vertices.

One DFS finds them all

Both criteria read off and , so a single recursion computes everything. The one subtlety is the parent edge: in an undirected graph the edge back to the parent must not be mistaken for a back edge that lowers . Guarding by parent vertex is correct only when there are no multi-edges (two distinct edges between the same pair); with multi-edges, a second –parent edge is a genuine back edge and the second copy must be allowed to lower . The reliable fix is to track the parent edge id rather than the parent vertex.

Algorithm:Bridges-AP(G)\textsc{Bridges-AP}(G) — find all bridges and articulation points in O(V+E)O(V+E)
  1. 1
    timer0timer \gets 0;\ \ disc[]0disc[\,\cdot\,] \gets 0 (0 = unvisited)
  2. 2
    for each vertex ss in VV do
  3. 3
    if disc[s]=0disc[s] = 0 then Dfs(s, nil)\textsc{Dfs}(s,\ \text{nil})
    nil = no parent edge
  4. 4
  5. 5
    procedure Dfs(u, pe)\textsc{Dfs}(u,\ pe):
    pepe = id of edge to parent
  6. 6
    timertimer+1timer \gets timer + 1
  7. 7
    disc[u]low[u]timerdisc[u] \gets low[u] \gets timer
  8. 8
    children0children \gets 0
  9. 9
    for each incident edge e={u,w}e = \{u,w\} do
  10. 10
    if e=pee = pe then continue
    skip arrival edge
  11. 11
    if disc[w]=0disc[w] = 0 then
    tree edge
  12. 12
    childrenchildren+1children \gets children + 1
  13. 13
    Dfs(w, e)\textsc{Dfs}(w,\ e)
  14. 14
    low[u]min(low[u], low[w])low[u] \gets \min(low[u],\ low[w])
  15. 15
    if low[w]>disc[u]low[w] > disc[u] then report bridge {u,w}\{u,w\}
  16. 16
    if penilpe \ne \text{nil} and low[w]disc[u]low[w] \ge disc[u] then mark uu articulation
  17. 17
    else
    back edge
  18. 18
    low[u]min(low[u], disc[w])low[u] \gets \min(low[u],\ disc[w])
  19. 19
    if pe=nilpe = \text{nil} and children2children \ge 2 then mark uu articulation

Each vertex is discovered once and each edge is examined a constant number of times (twice over the whole run, once from each endpoint), so the search is , asymptotically free on top of the DFS we were already running. The outer loop over makes it work on disconnected graphs as well: it finds the bridges and cut vertices of every component. The same low-link bookkeeping, with a stack of edges, also peels off the biconnected components directly (pop the stack down to whenever ), which is Tarjan's original formulation.23

A complete trace

Here is the whole computation, step by step, on a seven-vertex graph built from two triangles joined by a chain: triangle , bridge , triangle , bridge . Deleting , , or splits the graph, so those three are its cut vertices.

The input graph: triangles and joined by the bridge , with a pendant bridge . Cut vertices , , are ringed.

Start DFS at with alphabetical adjacency lists. The recursion discovers the chain (each vertex's first unvisited neighbor happens to be the next letter), and the interesting work happens on the way back up:

stepeventeffect
1discover
2discover via tree edge
3discover via tree edge
4 sees : back edge
5discover via tree edge
6discover via tree edge
7discover via tree edge
8 sees : back edge
9discover via tree edge ; has no other neighbor, returns
10 returns to ; : bridge ; : is a cut vertex
11 returns to ; : no bridge, no cut
12 returns to ; : is a cut vertex (but : is no bridge)
13 sees : second view of edge , no change
14 returns to ; : bridge ; : is a cut vertex
15 returns to ; : nothing
16 returns to ; root has tree child: not a cut vertex
17 sees : second view of edge , no change

Steps 13 and 17 show a quiet feature of undirected DFS: every non-tree edge is examined twice, once from each endpoint. The view from the ancestor side ( looking at , looking at ) relaxes against a larger discovery time and never changes anything; only the view from the descendant side (steps 4 and 8) matters. Both sightings are harmless as long as neither is mistaken for the parent edge.

The final state, drawn on the DFS tree — which for this graph is a single chain — with each vertex labeled :

The DFS tree of the trace (a chain), back edges dashed, each vertex labeled . Bridges in blue: exactly the tree edges with ; cut vertices , , ringed.

Reading the picture against the two criteria: the back edge pins at across the first triangle, so nothing in is separated by removing one edge; the back edge pins at across the second. The only tree edges whose child cannot climb past the parent are () and () — the two bridges. And the children , , certify , , as cut vertices via , while the root , with a single tree child, is exempt.

Common pitfalls

  • The root always passes the non-root test. Since is the smallest discovery time in its tree, every child satisfies . Apply the non-root rule to the root and it is flagged unconditionally. The root must be special-cased by counting tree children — and tree children, not neighbors: in a triangle the root has degree but only one tree child (the other neighbor is reached around the cycle), and it is correctly not a cut vertex.
  • Skipping the parent by vertex instead of by edge. With parallel edges, ignore neighbors equal to my parent skips both copies of a doubled edge, so the child looks trapped and the pair is reported as a bridge — but a doubled edge is never a bridge, since the twin copy survives any single cut. Track the id (or adjacency-list index) of the arrival edge and skip only that one; the second copy then acts as a legitimate back edge and lowers .
  • Relaxing back edges with instead of . The definition permits at most one back edge per escape route, and the proofs lean on it. While is still gray, is a moving target, and chaining through it can credit with escape routes that pass through vertices whose deletion is under test. Back edges relax against , tree edges against the child's finished .
  • Testing the wrong pair. The bridge test compares the child's low against the parent's disc: . Both nearby variants fail. Comparing flags every tree edge, since children are always discovered later. Comparing breaks when has its own escape: attach a triangle below a vertex , plus a back edge from to 's parent . Then , so misreports the cycle edge as a bridge even though sit on a common cycle.
  • Recursion depth. The DFS tree of a path graph is the whole path; on a million-vertex chain the recursive version overflows most default call stacks. An explicit-stack rewrite must still perform the post-visit relaxation when a child is popped, which takes some care to get right; test it on long chains.

The parallel-edge trap deserves a picture, because the buggy and correct runs differ on the smallest possible graph:

Parallel edges : guarding by parent vertex skips both copies and misreports a bridge (left); guarding by the arrival edge's id lets the twin copy act as a back edge, , no bridge (right).

On the left, 's only edges both lead to its parent, both are skipped, and stays at : the doubled edge is declared a bridge, wrongly. On the right, only the arrival copy is skipped; the twin is processed as a back edge (dashed), drops to , and the strict test correctly reports nothing.

When to reach for this

The low-link machinery answers what breaks if one element fails, and it shows up wherever that question does: single points of failure in a computer or transport network, load-bearing joints in a mesh, and the decomposition of a graph into biconnected components before running algorithms that assume 2-connectivity. Two contrasts are worth keeping straight. First, bridges and cut vertices concern undirected connectivity; the analogous directed question is answered by strongly connected components, and Tarjan's SCC algorithm reuses this same low-link idea with a stack. Second, the criteria are about single failures only — surviving the loss of any edges or vertices is -connectivity, which calls for maximum-flow techniques rather than one DFS.

bridges.pypython
from collections.abc import Hashable
from typing import Optional, TypeVar

from dfs_lowlink import EdgeIdentity
from graph import Graph

Label = TypeVar("Label", bound=Hashable)

def find_bridges(graph: Graph[Label]) -> set[frozenset[Label]]:
  """
    Every bridge of an undirected `graph`, each as a two-label frozenset.\n
    Works on disconnected graphs: the outer loop launches a DFS from every\n
    component. A duplicated edge between the same pair shares no id with its\n
    parallel copy, so it acts as a genuine back edge and (correctly) keeps\n
    the other copy from being reported as a bridge.\n
  """
  # canonical edge ids plus the per-vertex disc/low bookkeeping.
  identity: EdgeIdentity[Label] = EdgeIdentity(graph)
  discovery: dict[Label, int] = {}
  low_link: dict[Label, int] = {}
  bridges: set[frozenset[Label]] = set()
  timer: int = 0

  def explore(source: Label, parent_edge: Optional[int]) -> None:
    """
      Depth-first visit recording disc/low and reporting bridges.\n
    """
    # stamp discovery time; low-link starts at the vertex's own time.
    nonlocal timer
    timer += 1
    discovery[source] = low_link[source] = timer

    for edge in graph.vertex(source).outgoing:
      neighbor: Label = edge.target.label
      this_edge: int = identity.of(edge)

      # never walk back along the edge we arrived on.
      if this_edge == parent_edge:
        continue

      # tree edge: recurse, fold up the child's low-link, and test for a bridge.
      if neighbor not in discovery:
        explore(neighbor, this_edge)
        low_link[source] = min(low_link[source], low_link[neighbor])
        if low_link[neighbor] > discovery[source]:
          bridges.add(frozenset((source, neighbor)))

      # back edge: the subtree can climb to this already-seen ancestor.
      else:
        low_link[source] = min(low_link[source], discovery[neighbor])

  # launch a DFS in every component so disconnected graphs are covered.
  for vertex in graph.vertices:
    if vertex.label not in discovery:
      explore(vertex.label, None)
  return bridges
articulation_points.pypython
from collections.abc import Hashable
from typing import Optional, TypeVar

from dfs_lowlink import EdgeIdentity
from graph import Graph

Label = TypeVar("Label", bound=Hashable)

def find_articulation_points(graph: Graph[Label]) -> set[Label]:
  """
    Every articulation point (cut vertex) of an undirected `graph`.\n
    Handles disconnected graphs and multi-edges. Each component's DFS root is\n
    judged by its tree-child count; every other vertex by whether any child\n
    subtree can climb no higher than the vertex itself.\n
  """
  # canonical edge ids plus the per-vertex disc/low bookkeeping.
  identity: EdgeIdentity[Label] = EdgeIdentity(graph)
  discovery: dict[Label, int] = {}
  low_link: dict[Label, int] = {}
  cut_vertices: set[Label] = set()
  timer: int = 0

  def explore(source: Label, parent_edge: Optional[int]) -> None:
    """
      Depth-first visit recording disc/low and marking cut vertices.\n
    """
    # stamp discovery time; low-link starts at the vertex's own time.
    nonlocal timer
    timer += 1
    discovery[source] = low_link[source] = timer

    # count tree children so the root can be judged after the loop.
    tree_children: int = 0
    is_root: bool = parent_edge is None

    for edge in graph.vertex(source).outgoing:
      neighbor: Label = edge.target.label
      this_edge: int = identity.of(edge)

      # never walk back along the edge we arrived on.
      if this_edge == parent_edge:
        continue

      # tree edge: recurse, fold up the child's low-link, test the cut rule.
      if neighbor not in discovery:
        tree_children += 1
        explore(neighbor, this_edge)
        low_link[source] = min(low_link[source], low_link[neighbor])
        if not is_root and low_link[neighbor] >= discovery[source]:
          cut_vertices.add(source)

      # back edge: the subtree can climb to this already-seen ancestor.
      else:
        low_link[source] = min(low_link[source], discovery[neighbor])

    # a DFS root is a cut vertex exactly when it forks two or more subtrees.
    if is_root and tree_children >= 2:
      cut_vertices.add(source)

  # launch a DFS in every component so disconnected graphs are covered.
  for vertex in graph.vertices:
    if vertex.label not in discovery:
      explore(vertex.label, None)
  return cut_vertices
biconnected_components.pypython
from collections.abc import Hashable
from typing import Optional, TypeVar

from dfs_lowlink import EdgeIdentity
from graph import Graph

Label = TypeVar("Label", bound=Hashable)

def biconnected_components(
  graph: Graph[Label],
) -> list[list[frozenset[Label]]]:
  """
    The biconnected components of an undirected `graph`.\n
    Each component is a list of its edges (two-label frozensets); a lone\n
    bridge appears as a one-edge component. Handles disconnected graphs and\n
    multi-edges. Isolated vertices belong to no edge and so appear in no\n
    component.\n
  """
  identity: EdgeIdentity[Label] = EdgeIdentity(graph)
  discovery: dict[Label, int] = {}
  low_link: dict[Label, int] = {}
  components: list[list[frozenset[Label]]] = []

  # the stack holds edges (as ordered label pairs) in discovery order; a
  # component is the run popped off the top down to the splitting tree edge.
  edge_stack: list[tuple[Label, Label]] = []
  timer: int = 0

  def pop_component(boundary: tuple[Label, Label]) -> None:
    """
      Pop edges down to and including `boundary` into one component.\n
    """
    # drain the stack top until the splitting tree edge comes off.
    component: list[frozenset[Label]] = []
    while True:
      source, target = edge_stack.pop()
      component.append(frozenset((source, target)))
      if (source, target) == boundary:
        break
    components.append(component)

  def explore(source: Label, parent_edge: Optional[int]) -> None:
    """
      Depth-first visit that pushes edges and pops finished components.\n
    """
    # stamp discovery time; low-link starts at the vertex's own time.
    nonlocal timer
    timer += 1
    discovery[source] = low_link[source] = timer

    for edge in graph.vertex(source).outgoing:
      neighbor: Label = edge.target.label
      this_edge: int = identity.of(edge)

      # never walk back along the edge we arrived on.
      if this_edge == parent_edge:
        continue

      # tree edge: push it, recurse, then cut off a component at the threshold.
      if neighbor not in discovery:
        edge_stack.append((source, neighbor))
        explore(neighbor, this_edge)
        low_link[source] = min(low_link[source], low_link[neighbor])
        if low_link[neighbor] >= discovery[source]:
          pop_component((source, neighbor))

      # back edge: push once (only when ancestor) and lower the low-link.
      elif discovery[neighbor] < discovery[source]:
        edge_stack.append((source, neighbor))
        low_link[source] = min(low_link[source], discovery[neighbor])

  # launch a DFS in every component so disconnected graphs are covered.
  for vertex in graph.vertices:
    if vertex.label not in discovery:
      explore(vertex.label, None)
  return components
graph.pypython
from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar


Label = TypeVar("Label", bound=Hashable)


class Edge(Generic[Label]):
  """
    A directed connection from `source` to `target`, carrying a weight.\n
  """

  def __init__(
    self,
    source: Vertex[Label],
    target: Vertex[Label],
    weight: float = 1.0,
  ) -> None:
    self.source: Vertex[Label] = source
    self.target: Vertex[Label] = target
    self.weight: float = weight

  def __repr__(self) -> str:
    return f"Edge({self.source.label!r} -> {self.target.label!r}, w={self.weight})"


class Vertex(Generic[Label]):
  """
    A graph vertex: a label plus the list of edges leaving it.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.outgoing: list[Edge[Label]] = []

  def neighbors(self) -> list[Vertex[Label]]:
    """
      The vertices reachable from this one by a single edge.\n
    """
    return [edge.target for edge in self.outgoing]

  def edge_to(self, label: Label) -> Optional[Edge[Label]]:
    """
      The outgoing edge to the vertex with `label`, or None.\n
    """
    for edge in self.outgoing:
      if edge.target.label == label:
        return edge
    return None

  def __repr__(self) -> str:
    return f"Vertex({self.label!r})"


class Graph(Generic[Label]):
  """
    A graph of Vertex objects linked by Edge objects.\n
    Pass `directed=True` for a digraph; otherwise each `add_edge` inserts\n
    the reverse edge too.\n
  """

  def __init__(self, directed: bool = False) -> None:
    self.directed: bool = directed
    self._vertices: dict[Label, Vertex[Label]] = {}

  def add_vertex(self, label: Label) -> Vertex[Label]:
    """
      Return the vertex for `label`, creating it if it is absent.\n
    """
    # reuse the existing vertex, or mint and register a fresh one.
    vertex = self._vertices.get(label)
    if vertex is None:
      vertex = Vertex(label)
      self._vertices[label] = vertex
    return vertex

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    weight: float = 1.0,
  ) -> None:
    """
      Connect two labels (creating either vertex as needed).\n
      Adds the reverse edge as well when the graph is undirected.\n
    """
    source = self.add_vertex(source_label)
    target = self.add_vertex(target_label)

    # link source to target, and mirror it back when undirected.
    source.outgoing.append(Edge(source, target, weight))
    if not self.directed:
      target.outgoing.append(Edge(target, source, weight))

  def vertex(self, label: Label) -> Vertex[Label]:
    """
      The vertex carrying `label` (raises KeyError if absent).\n
    """
    return self._vertices[label]

  @property
  def vertices(self) -> list[Vertex[Label]]:
    """
      Every vertex, in insertion order.\n
    """
    return list(self._vertices.values())

  @property
  def labels(self) -> list[Label]:
    """
      Every vertex label, in insertion order.\n
    """
    return list(self._vertices)

  def edges(self) -> Iterator[Edge[Label]]:
    """
      Each edge once — an undirected edge is yielded a single time.\n
    """
    # track undirected endpoint pairs so each is emitted only once.
    seen: set[frozenset[Label]] = set()

    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        # skip an undirected edge already yielded from the other endpoint.
        if not self.directed:
          endpoints = frozenset((edge.source.label, edge.target.label))
          if endpoints in seen:
            continue
          seen.add(endpoints)

        yield edge

  def __contains__(self, label: Label) -> bool:
    return label in self._vertices

  def __iter__(self) -> Iterator[Vertex[Label]]:
    return iter(self._vertices.values())

  def __len__(self) -> int:
    return len(self._vertices)

Connectivity, statically and dynamically

The block-cut tree. Grouping the graph's biconnected components (blocks) and articulation points into a tree — a node per block, a node per cut vertex, an edge whenever a cut vertex lies on a block — gives the block-cut tree, a compact map of exactly how the graph can fall apart.4 Any two vertices in the same block survive the loss of one vertex; a path between blocks in the tree passes through precisely the cut vertices that would disconnect them. Many is the graph still connected if I delete queries become tree lookups after one preprocessing pass. The edge analogue, contracting each 2-edge-connected component, yields the bridge tree, in which each tree edge is a bridge of the graph.

Beyond single failures. Bridges and cut vertices are the case of a hierarchy. Surviving any two edge failures is 3-edge-connectivity; the general question is the graph -connected is answered by max-flow (Menger's theorem equates the minimum cut with the number of vertex- or edge-disjoint paths), and global -edge-connectivity by the Stoer-Wagner minimum-cut algorithm. The single-DFS low-link trick is special to — it is what makes that one case linear.

Dynamic connectivity. When edges are inserted and deleted online, recomputing bridges each time is wasteful. Holm, de Lichtenberg, and Thorup (2001) maintain full connectivity — and 2-edge-connectivity — under arbitrary updates in amortized time per operation using a hierarchy of spanning forests.5 This is the data structure behind interactive network-reliability tools and incremental mesh processing, where the graph changes faster than a from-scratch DFS could keep up.

Takeaways

  • A bridge (cut edge) and an articulation point (cut vertex) are the single points of failure of a network: removing one increases the component count. Their absence is 2-edge-connectivity and 2-vertex-connectivity (biconnectivity); cut edges and vertices are the seams between biconnected components.
  • DFS on an undirected graph produces only tree edges and back edges, with no cross or forward edges, so the only way out of a subtree is a back edge to an ancestor.
  • The low-link is the smallest discovery time reachable from 's subtree via tree edges plus at most one back edge; compute it alongside in one recursion.
  • Bridge criterion: tree edge is a bridge . Articulation criterion: a non-root is a cut vertex some child has ; the root is a cut vertex it has children. The strict-vs-nonstrict gap ( vs ) is the whole distinction.
  • A single DFS reports all bridges and cut vertices (and biconnected components). Guard the parent edge by id, not the parent vertex, to stay correct under multi-edges.

Footnotes

  1. Skiena, §5 — Graph Traversal: edge- and vertex-connectivity, and articulation vertices as the weak points found by DFS low-links.
  2. CLRS, Ch. 22 — Elementary Graph Algorithms (DFS): an undirected DFS yields only tree and back edges; the parenthesis/white-path structure that grounds the low-link argument.
  3. Erickson, Ch. 6 — Depth-First Search: Tarjan's low-link computation for bridges, articulation points, and biconnected components in linear time.
  4. Hopcroft, J. & Tarjan, R. E. (1973), Algorithm 447: efficient algorithms for graph manipulation, Communications of the ACM 16(6), 372–378 — biconnected components and the block-cut structure via DFS.
  5. Holm, J., de Lichtenberg, K. & Thorup, M. (2001), Poly-logarithmic deterministic fully-dynamic algorithms for connectivity, minimum spanning tree, 2-edge, and biconnectivity, Journal of the ACM 48(4), 723–760.
Practice

╌╌ END ╌╌