One lowlink DFS on an undirected (multi)graph gives bridges, articulation points, 2-edge-connected components and biconnected components (blocks). Contracting them gives trees (the bridge tree, the block-cut tree) on which “must every path use this edge/vertex?” becomes a tree path question. Extensions: 3-edge-connected components, strong orientations (Robbins), bridges under edge insertions, ear decompositions and -numberings.

= smallest reachable from ‘s DFS subtree using tree edges downward and at most one back edge. For a tree edge :

  • is a bridge iff ;
  • is an articulation point iff for some child (non-root ), or the root has children;
  • when , the vertices popped from the stack down to , plus , form a block.

Skip the parent edge id, not the parent vertex, so parallel edges are handled.

struct Lowlink {                                           // undirected multigraph, edges given as pairs
    vector<vector<pair<int, int>>> g; vector<int> tin, low, isBridge, isArt; vector<vector<int>> blocks;
    Lowlink(int n, const vector<pair<int, int>>& E) : g(n), tin(n, -1), low(n), isBridge(sz(E)), isArt(n) {
        for (int i = 0; i < sz(E); i++) { auto [a, b] = E[i]; g[a].push_back({b, i}); if (a != b) g[b].push_back({a, i}); }
        int timer = 0; vector<int> st;
        auto dfs = [&](auto&& self, int u, int pe) -> void {
            tin[u] = low[u] = timer++; st.push_back(u); int kids = 0;
            for (auto [v, e] : g[u]) {
                if (e == pe) continue;
                if (tin[v] >= 0) { low[u] = min(low[u], tin[v]); continue; }
                kids++; self(self, v, e); low[u] = min(low[u], low[v]);
                if (low[v] > tin[u]) isBridge[e] = 1;
                if (low[v] >= tin[u]) {
                    if (pe >= 0 || kids > 1) isArt[u] = 1;
                    blocks.push_back({u});
                    while (true) { int x = st.back(); st.pop_back(); blocks.back().push_back(x); if (x == v) break; }
                }
            }
        };
        for (int s = 0; s < n; s++) if (tin[s] < 0) { dfs(dfs, s, -1); if (sz(st) == 1 && g[s].empty()) blocks.push_back({s}); st.clear(); }
    }
};

(Recursion depth = DFS depth; for -vertex paths use an explicit stack or a bigger stack.) Self-loops never belong to a block; a bridge appears as a 2-vertex block.

Bridge tree and block-cut tree

  • 2-edge-connected components: DSU over all non-bridge edges (or DFS that never crosses bridges). Contracting them leaves a forest whose edges are the bridges: the bridge tree.
  • Block-cut tree: a bipartite tree with one node per vertex and one node per block, joining each block to its vertices (the “articulation points only” variant drops non-cut vertices).
QuestionAnswer
bridges on every – pathbridge-tree path between their components
vertices on every simple – pathcut vertices on the block-cut tree path
vertices on some simple – pathunion of the blocks on the block-cut tree path
is there a simple cycle through and ?same block
pairs disconnected by deleting cut vertex subtree sizes around in the block-cut tree
min edges to add to make it 2-edge-connected, = leaves of the bridge tree ()
min edges to make it biconnected: leaf blocks, = most components left by deleting one cut vertex (Eswaran–Tarjan)
count of pairs separated by removing cut vertex root the block-cut tree anywhere; removing splits the graph into ‘s BCT-subtree pieces of size , answer
Edge versionVertex version
critical elementbridgearticulation point
components2-edge-connected componentsbiconnected components (blocks)
condensed structurebridge treeblock-cut tree
condensation partitionsverticesedges
DFS condition
disjoint-paths theoremMenger (edge): max edge-disjoint – paths = min edge cutMenger (vertex): max internally-disjoint – paths = min vertex cut, both via max flow on a vertex-split graph

The most common mistake is building a bridge tree when the problem needs a block-cut tree, or contracting vertices when the blocks should be edges.

Strong orientation (Robbins)

A connected undirected graph can be oriented to become strongly connected iff it is bridgeless. Construction: orient tree edges downwards and back edges upwards in a DFS. With bridges present, the same DFS orientation makes every 2-edge-connected component strongly connected, and each bridge is necessarily one-way between its two sides. Nash-Williams: every -edge-connected graph has a -arc-connected orientation.

vector<pair<int, int>> strongOrientation(int n, const vector<pair<int, int>>& E) {   // empty if a bridge exists or disconnected
    vector<vector<pair<int, int>>> g(n); for (int i = 0; i < sz(E); i++) g[E[i].first].push_back({E[i].second, i}), g[E[i].second].push_back({E[i].first, i});
    vector<int> tin(n, -1), low(n); vector<pair<int, int>> res(sz(E)); int timer = 0; bool bad = false;
    auto dfs = [&](auto&& self, int u, int pe) -> void {
        tin[u] = low[u] = timer++;
        for (auto [v, e] : g[u]) {
            if (e == pe) continue;
            if (tin[v] < 0) { res[e] = {u, v}; self(self, v, e); low[u] = min(low[u], low[v]); if (low[v] > tin[u]) bad = true; }
            else { low[u] = min(low[u], tin[v]); if (tin[v] < tin[u]) res[e] = {u, v}; }
        }
    };
    dfs(dfs, 0, -1);
    if (bad || count(all(tin), -1)) return {};
    return res;
}

Mixed graphs (some edges already directed, others free): still polynomial, via the Boesch–Tindell reduction — model each undirected edge’s unknown orientation as contributing to a degree-balance constraint and solve a circulation with demands; strong orientability of the free edges is exactly feasibility of that circulation.

Bridges online

Edges arrive one by one; maintain the number of bridges and 2-edge-connected components in total: keep the forest of 2-edge-connected components with parent pointers and a DSU of connected components. An edge inside a component compresses the tree path between its ends into one component (every edge on it stops being a bridge); an edge between components re-roots the smaller tree and hangs it below the other.

struct OnlineBridges {
    vector<int> par, d2, dcc, csz, seen; int bridges = 0, it = 0;
    OnlineBridges(int n) : par(n, -1), d2(n), dcc(n), csz(n, 1), seen(n) { iota(all(d2), 0); iota(all(dcc), 0); }
    int f2(int v) { if (v < 0) return -1; int r = v; while (d2[r] != r) r = d2[r]; while (d2[v] != r) { int x = d2[v]; d2[v] = r; v = x; } return r; }
    int fcc(int v) { v = f2(v); int r = v; while (dcc[r] != r) r = dcc[r]; while (dcc[v] != r) { int x = dcc[v]; dcc[v] = r; v = x; } return r; }
    void makeRoot(int v) {
        int root = v, child = -1;
        while (v != -1) { int p = f2(par[v]); par[v] = child; dcc[v] = root; child = v; v = p; }
        csz[root] = csz[child];
    }
    void mergePath(int a, int b) {
        ++it; vector<int> pa, pb; int lca = -1;
        while (lca < 0) {
            if (a != -1) { a = f2(a); pa.push_back(a); if (seen[a] == it) { lca = a; break; } seen[a] = it; a = par[a]; }
            if (b != -1) { b = f2(b); pb.push_back(b); if (seen[b] == it) { lca = b; break; } seen[b] = it; b = par[b]; }
        }
        for (auto* p : {&pa, &pb}) for (int v : *p) { d2[v] = lca; if (v == lca) break; --bridges; }
    }
    void addEdge(int a, int b) {
        a = f2(a), b = f2(b); if (a == b) return;
        int ca = fcc(a), cb = fcc(b);
        if (ca != cb) { ++bridges; if (csz[ca] > csz[cb]) swap(a, b), swap(ca, cb); makeRoot(a); par[a] = dcc[a] = b; csz[cb] += csz[a]; }
        else mergePath(a, b);
    }
};

3-edge-connected components

iff no two edges separate them (local edge connectivity ). Tsin’s one-pass DFS: counts back edges crossing ‘s parent edge, and chains of vertices that must end in the same class are absorbed through a DSU; (Library Checker “Three-Edge-Connected Components”).

vector<vector<int>> threeEdgeCC(int n, const vector<pair<int, int>>& E) {
    vector<vector<int>> g(n); for (auto [a, b] : E) if (a != b) g[a].push_back(b), g[b].push_back(a);
    vector<int> dsu(n); iota(all(dsu), 0);
    auto find = [&](int x) { while (dsu[x] != x) x = dsu[x] = dsu[dsu[x]]; return x; };
    int timer = 0; vector<int> in(n), out(n), deg(n), low(n, INT_MAX), path(n, -1);
    auto absorb = [&](int a, int b) { int ra = find(a), rb = find(b); if (ra != rb) dsu[rb] = ra; deg[a] += deg[b]; };
    auto dfs = [&](auto&& self, int v, int p) -> void {
        in[v] = ++timer;
        for (int w : g[v]) {
            if (w == p) { p = -1; continue; }                // skip one copy of the parent edge
            if (in[w]) {
                if (in[w] < in[v]) { deg[v]++; low[v] = min(low[v], in[w]); }
                else { deg[v]--; int& u = path[v]; for (; u != -1 && in[u] <= in[w] && in[w] <= out[u]; u = path[u]) absorb(v, u); }
                continue;
            }
            self(self, w, v);
            if (path[w] == -1 && deg[w] <= 1) { deg[v] += deg[w]; low[v] = min(low[v], low[w]); continue; }
            if (!deg[w]) w = path[w];
            if (low[w] < low[v]) { low[v] = low[w]; swap(w, path[v]); }
            for (; w != -1; w = path[w]) absorb(v, w);
        }
        out[v] = timer;
    };
    for (int i = 0; i < n; i++) if (!in[i]) dfs(dfs, i, -1);
    vector<vector<int>> by(n), res; for (int i = 0; i < n; i++) by[find(i)].push_back(i);
    for (auto& c : by) if (sz(c)) res.push_back(c);
    return res;
}

The proof of Tsin’s algorithm is long (it simulates contracting “2-edge cuts” along DFS paths); the code is verified against brute force over all pairs of removed edges. For 3-vertex-connectivity, SPQR trees (Hopcroft–Tarjan, linear but intricate) are the tool; contests use them rarely.

4-edge-connected components and general -edge-connectivity (static or dynamic, i.e. “does the graph stay -edge-connected under edge insertions/deletions”) extend the same idea but need genuinely more intricate machinery (sparsification, or the dynamic-connectivity structures of Dynamic Connectivity run per connectivity level); cited here rather than derived, since edge-connectivity is near the rarest end of what any contest asks for, and a from-scratch implementation under contest time is almost never the right call even when it is asked for.

Ear decompositions and -numbering

An ear decomposition writes a graph as a cycle plus paths (ears) whose endpoints lie on earlier ears. Whitney: a graph is 2-edge-connected iff it has an ear decomposition, and 2-vertex-connected iff it has an open one (ear endpoints distinct). An -numbering orders the vertices so that every other vertex has a neighbour before and after it; it exists iff the graph plus the edge is biconnected, and it yields an acyclic orientation with a single source and single sink (used in planarity testing and in “orient to make every vertex lie on an – path”).

vector<int> stNumbering(int n, vector<pair<int, int>> E, int s, int t) {   // empty if impossible
    E.insert(E.begin(), {s, t}); vector<vector<int>> g(n);
    for (auto [a, b] : E) g[a].push_back(b), g[b].push_back(a);
    vector<int> vis(n, -1), par(n, -1), ord, it(n); vector<vector<int>> back(n);
    { vector<int> st{s}; vis[s] = 0; ord.push_back(s);
      while (sz(st)) { int x = st.back(); if (it[x] == sz(g[x])) { st.pop_back(); continue; }
        int y = g[x][it[x]++];
        if (vis[y] < 0) vis[y] = sz(ord), ord.push_back(y), par[y] = x, st.push_back(y);
        else if (vis[y] < vis[x]) back[y].push_back(x); } }
    if (sz(ord) != n) return {};
    vector<char> done(n); vector<vector<int>> ears;
    for (int x : ord) {
        done[x] = 1;
        for (int y : back[x]) {
            vector<int> ear{x, y};
            while (!done[ear.back()]) done[ear.back()] = 1, ear.push_back(par[ear.back()]);
            if (ear.back() == x && ear != vector<int>{s, t, s}) return {};
            ears.push_back(ear);
        }
    }
    vector<int> w(n, 1);
    for (int i = sz(ears) - 1; i >= 1; i--) for (int j = 1; j + 1 < sz(ears[i]); j++) w[ears[i][0]] += w[ears[i][j]];
    if (w[s] + w[t] != n) return {};
    vector<pair<int, int>> iv(n, {-1, -1}); iv[s] = {0, w[s] - 1}; iv[t] = {w[s], n - 1};
    for (int i = 1; i < sz(ears); i++) {
        const auto& e = ears[i]; int r = e[0];
        for (int j = sz(e) - 2; j >= 1; j--) {
            int x = e[j];
            if (iv[e.front()].first < iv[e.back()].first) iv[x] = {iv[r].second - w[x] + 1, iv[r].second}, iv[r].second -= w[x];
            else iv[x] = {iv[r].first, iv[r].first + w[x] - 1}, iv[r].first += w[x];
        }
    }
    vector<int> res(n, -1);
    for (int i = 0; i < n; i++) { if (iv[i].first != iv[i].second || iv[i].first < 0) return {}; res[iv[i].first] = i; }
    return res;
}

Parallel algorithms: Tarjan–Vishkin computes blocks in PRAM time from any spanning tree plus Euler-tour prefix computations; sequentially just use the lowlink DFS.