Maintain the connected components of a graph while edges are added and removed. The right technique depends entirely on which operations you need and whether the queries are known in advance.

The decision table

OperationsTechniqueComplexity
Add edges onlyDSU amortized
Remove edges onlyprocess in reverse with DSU amortized
Add + remove, queries known in advancesegment tree on time + rollback DSU
Add + remove, onlineHDT with Euler tour trees amortized
Forest only (no cycles), onlinelink-cut trees amortized
Add + remove + MSTHDT extension

Almost always: go offline

Competitive programming problems give you the whole query list up front. That means the offline segment-tree technique applies, and it is dramatically easier than HDT.

Offline dynamic connectivity

The idea. Each edge is present during a set of time intervals. Build a segment tree over the time axis and insert each edge into the nodes covering its lifetime. Then DFS the segment tree: on the way down, unite the edges stored at the node; on the way back up, roll those unions back. At a leaf, all currently-present edges are united, so answer that query.

struct RollbackDSU {
    vector<int> par, sz;
    vector<pair<int,int>> hist;              // (child root, its old parent)
    int comps;
    RollbackDSU(int n) : par(n), sz(n, 1), comps(n) { iota(par.begin(), par.end(), 0); }
 
    int find(int v) { while (par[v] != v) v = par[v]; return v; }   // NO path compression
 
    void unite(int a, int b) {
        a = find(a); b = find(b);
        if (a == b) { hist.push_back({-1, -1}); return; }
        if (sz[a] < sz[b]) swap(a, b);
        hist.push_back({b, a});
        par[b] = a; sz[a] += sz[b]; comps--;
    }
    void rollback() {
        auto [b, a] = hist.back(); hist.pop_back();
        if (b == -1) return;
        par[b] = b; sz[a] -= sz[b]; comps++;
    }
};
 
vector<vector<pair<int,int>>> seg;           // edges stored per segment tree node
 
void addEdge(int node, int l, int r, int ql, int qr, pair<int,int> e) {
    if (qr <= l || r <= ql) return;
    if (ql <= l && r <= qr) { seg[node].push_back(e); return; }
    int m = (l + r) / 2;
    addEdge(2*node, l, m, ql, qr, e);
    addEdge(2*node+1, m, r, ql, qr, e);
}
 
void solve(int node, int l, int r, RollbackDSU& dsu, vector<int>& ans) {
    int saved = dsu.hist.size();
    for (auto [u, v] : seg[node]) dsu.unite(u, v);
    if (r - l == 1) ans[l] = dsu.comps;
    else {
        int m = (l + r) / 2;
        solve(2*node, l, m, dsu, ans);
        solve(2*node+1, m, r, dsu, ans);
    }
    while ((int)dsu.hist.size() > saved) dsu.rollback();
}

No path compression

Rollback requires the DSU’s structure to be exactly restorable, so use union by size only. find becomes , which is where the extra log factor comes from. Attempting path compression with rollback silently corrupts the structure.

Computing edge lifetimes

Sweep the query list keeping a map from edge the time it was added. On removal, record the interval and erase from the map. Edges still present at the end get .

map<pair<int,int>, int> alive;
for (int t = 0; t < q; t++) {
    if (type[t] == ADD)    alive[e[t]] = t;
    else if (type[t] == DEL) { addEdge(1, 0, q, alive[e[t]], t, e[t]); alive.erase(e[t]); }
}
for (auto [e, t] : alive) addEdge(1, 0, q, t, q, e);

What the leaves can answer

Anything a DSU can maintain rollback-ably:

  • number of components,
  • component sizes,
  • “are and connected?”,
  • bipartiteness (with a parity DSU),
  • number of edges/cycles in each component,
  • the maximum/minimum vertex label per component.

Path queries and MST maintenance need link-cut trees instead.

See also: DSU Variants · Link-Cut Trees · Offline Query Processing