Connectivity under edge insertions and deletions.
| Operations | Technique | Cost |
|---|---|---|
| insertions only | DSU | |
| deletions only | reverse time, then DSU | |
| both, offline | segment tree over time + rollback DSU | |
| both, online, graph stays a forest | link-cut tree (LCT) | |
| both, online, general | Holm–de Lichtenberg–Thorup (HDT) | amortised |
| offline, with a maximum spanning forest by deletion time | link-cut tree keyed by deletion time | |
| both, online, with MST maintenance | HDT extended with edge weights | amortised |
Offline: segment tree over time
Each edge is alive on a set of time intervals. Insert each interval into the segment-tree nodes covering it; DFS the tree, uniting a node’s edges on the way down and rolling them back on the way up. At leaf the DSU contains exactly the edges alive at time . The DSU uses union by size and no path compression, so each union is undone in .
struct RollbackDSU {
vector<int> p, s; vector<pair<int, int>> hist; int comps;
RollbackDSU(int n) : p(n), s(n, 1), comps(n) { iota(all(p), 0); }
int find(int v) const { while (p[v] != v) v = p[v]; return v; }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) { hist.push_back({-1, -1}); return false; }
if (s[a] < s[b]) swap(a, b);
hist.push_back({b, a}); p[b] = a; s[a] += s[b]; comps--; return true;
}
void undo() { auto [b, a] = hist.back(); hist.pop_back(); if (b >= 0) p[b] = b, s[a] -= s[b], comps++; }
};
struct OfflineConnectivity { // times 0..T-1
int T; vector<vector<pair<int, int>>> seg;
OfflineConnectivity(int T) : T(T), seg(4 * max(T, 1)) {}
void addEdge(int l, int r, pair<int, int> e) { add(1, 0, T, l, r, e); } // edge alive during [l, r)
void add(int x, int lo, int hi, int l, int r, pair<int, int> e) {
if (r <= lo || hi <= l) return;
if (l <= lo && hi <= r) { seg[x].push_back(e); return; }
int m = (lo + hi) / 2; add(2 * x, lo, m, l, r, e); add(2 * x + 1, m, hi, l, r, e);
}
template<class F> void run(RollbackDSU& d, F atLeaf, int x = 1, int lo = 0, int hi = -1) {
if (hi < 0) hi = T;
for (auto [u, v] : seg[x]) d.unite(u, v);
if (hi - lo == 1) atLeaf(lo);
else { int m = (lo + hi) / 2; run(d, atLeaf, 2 * x, lo, m); run(d, atLeaf, 2 * x + 1, m, hi); }
for (int i = 0; i < sz(seg[x]); i++) d.undo();
}
};
// lifetimes: map<pair<int,int>, int> start; on add: start[e] = t; on remove: addEdge(start[e], t, e), erase; at the end close the rest at TAnything a DSU maintains reversibly can be answered at the leaves: component count, sizes, sums per component, bipartiteness (parity DSU), “number of edges inside each component”, and even a linear basis or a knapsack per component if it supports undo.
Proof: correctness and complexity
A segment-tree node’s edge list is applied exactly while the DFS is inside that node’s time range, and the canonical decomposition of means the edge is applied at leaf iff . Rollback restores the DSU to its exact previous state because every union changes one parent pointer and one size, which are recorded. Each edge interval is stored in nodes, each union costs (union by size keeps trees of height , and without path compression
findwalks that height), so the total is plus leaf work.
Offline via maximum spanning forest by deletion time
Process time forward and maintain a spanning forest that is maximal with respect to deletion time: when inserting edge that closes a cycle, find the cycle edge with the earliest deletion time (path minimum in a link-cut tree), and if it is earlier than ‘s, replace it with . Then a tree edge’s deletion never has a replacement among non-tree edges (they all die earlier), so connectivity queries are answered by the forest. with an LCT.
Proof: no replacement is ever needed
Invariant: the forest is a maximum spanning forest of the current graph with weights = deletion times. A non-tree edge is never heavier than the lightest edge on its tree cycle (otherwise exchanging them would give a heavier forest). When the earliest-dying tree edge on some path is deleted, any non-tree edge that could reconnect its two sides lies on a cycle through , so dies no later than , i.e. is already gone. Hence deleting a tree edge truly disconnects the two sides, and the forest stays a maximum spanning forest.
Online: Holm–de Lichtenberg–Thorup
Each edge has a level ; = spanning forest of edges with level , with the invariants and “every component of has at most vertices”. Forests are kept as Euler tour trees (ETT) that can report a non-tree edge incident to a component.
- Insert: level 0; tree edge if it connects two components of , else non-tree.
- Delete a tree edge of level : for : take the smaller of the two split trees in , raise all its level- tree edges to , then scan its level- non-tree edges: one reaching the other side is the replacement (stop); one staying inside is raised to .
- Query: same tree of .
Proof sketch: amortised
Raising the smaller half keeps the size invariant: it has at most half of a component of size . So levels never exceed , and every scan that fails to find a replacement raises the scanned edge, which happens at most times per edge over its lifetime. Every raise or scan step costs in the Euler tour trees. Hence amortised per update. Correctness: a replacement for a level- tree edge must have level (by the nesting invariant applied to the maximum-spanning-forest property with respect to levels), so scanning levels down to 0 finds one if it exists.
In contests, the offline method is almost always enough; HDT is worth knowing for online problems and for dynamic MST / 2-edge-connectivity extensions (which add more levels of bookkeeping — dynamic bipartiteness is the same level machinery with a parity bit per level instead of just a spanning forest). Frederickson’s earlier structure gets worst-case (not just amortised) update time with a simpler sparsification-tree idea, predating HDT; HDT is asymptotically better and what’s actually implemented in practice.