Advanced DSU variants
Variants of union-find that undo unions, track parity or numeric differences, carry extra data per set, and a note on what 'DSU on tree' really means.
Learning goals
- Undo unions with a rollback DSU and know why compression is not allowed
- Detect odd cycles with a parity DSU
- Keep value differences with a weighted (potential) DSU
- Maintain per-set metadata and tell DSU on tree from union-find
Prerequisites
The plain structure answers one question: same set or not. Attach a little more information to each parent link, and the same tree can also say 'different colour', 'five more than' or 'undo that last merge'.
Rollback DSU
A **rollback DSU** can undo the most recent union. Union by size changes exactly one parent entry and one size entry, so push those two facts on a stack and, to undo, pop them and restore the values. Path compression changes many entries at once and cannot be undone cheaply, so a rollback DSU uses union by size alone; find then costs O(log n).
It is the tool for offline divide-and-conquer over time: add edges while descending a segment tree over time, answer at the leaves, undo while coming back up. In the animation's "Rollback: unite, then undo" preset, three unions 0-1 2-3 1-2 give one big set, the undo pops the last merge and splits it back, and the final 0?3 query shows 0 and 3 not connected.
1class RollbackDsu {2final int[] parent, size;3private final Deque<int[]> history = new ArrayDeque<>(); // each entry {child root, parent root}4int components;5RollbackDsu(int n) {6parent = new int[n]; size = new int[n]; components = n;7for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }8}9int find(int x) { while (parent[x] != x) x = parent[x]; return x; } // no path compression: it could not be undone10boolean union(int a, int b) {11int ra = find(a), rb = find(b);12if (ra == rb) return false;13if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }14parent[rb] = ra; size[ra] += size[rb];15history.push(new int[]{rb, ra}); // remember exactly what changed16components--;17return true;18}19void undo() { // reverts the most recent successful union20int[] h = history.pop();21size[h[1]] -= size[h[0]];22parent[h[0]] = h[0];23components++;24}25}
Union-find variants: rollback, parity and weighted (potential) DSU
Interactive · edit the inputforest (no compression, so every union can be undone) · 6 nodes
size 1
size 1
size 1
size 1
size 1
size 1
parent[x]
history stack (child root → parent root), top last
A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time.
1int[] parent, size; Deque<int[]> history = new ArrayDeque<>(); // each entry {child root, parent root}2int find(int x) { while (parent[x] != x) x = parent[x]; return x; } // no path compression: it could not be undone3boolean union(int a, int b) {4int ra = find(a), rb = find(b);5if (ra == rb) return false;6if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }7parent[rb] = ra; size[ra] += size[rb];8history.push(new int[]{rb, ra}); // remember exactly what changed9return true;10}11void undo() {12int[] h = history.pop();13size[h[1]] -= size[h[0]]; parent[h[0]] = h[0];14}15int[] parent, parity; // parity[x]: 0 if x has the same colour as parent[x], 1 if different16int find(int x) { // returns the root; afterwards parity[x] is relative to it17if (parent[x] == x) return x;18int p = parent[x], root = find(p);19parity[x] ^= parity[p]; parent[x] = root;20return root;21}22boolean addDifferent(int a, int b) { // a and b must get different colours; false on conflict23int ra = find(a), rb = find(b);24if (ra == rb) return parity[a] != parity[b]; // same set: they must already differ25parent[ra] = rb; parity[ra] = parity[a] ^ parity[b] ^ 1;26return true;27}28int[] parent; long[] diff; // diff[x] = value[x] - value[parent[x]]29int find(int x) {30if (parent[x] == x) return x;31int p = parent[x], root = find(p);32diff[x] += diff[p]; parent[x] = root; // now relative to the root33return root;34}35boolean addConstraint(int a, int b, long d) { // value[b] - value[a] == d36int ra = find(a), rb = find(b);37if (ra == rb) return diff[b] - diff[a] == d;38parent[rb] = ra; diff[rb] = diff[a] + d - diff[b]; // value[rb] - value[ra]39return true;40}41Long query(int a, int b) { return find(a) == find(b) ? diff[b] - diff[a] : null; }
Live state
n
6
Execution trace (dry run table)Show 8 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | n=6 | A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time. |
| 2 | L8 | op=0-1 pushed=1→0 components=5 | 0-1: unite by size: parent[1] = 0, size[0] = 2. Push {1 → 0} on the history. |
| 3 | L8 | op=2-3 pushed=3→2 components=4 | 2-3: unite by size: parent[3] = 2, size[2] = 2. Push {3 → 2} on the history. |
| 4 | L8 | op=1-2 pushed=2→0 components=3 | 1-2: unite by size: parent[2] = 0, size[0] = 4. Push {2 → 0} on the history. |
| 5 | L13 | op=undo restored=2 components=4 | undo: pop {2 → 0}: 2 becomes a root again and 0 loses its size. The state is exactly as before that union. |
| 6 | L8 | op=4-5 pushed=5→4 components=3 | 4-5: unite by size: parent[5] = 4, size[4] = 2. Push {5 → 4} on the history. |
| 7 | L2 | op=0?3 connected=false | 0?3: roots 0 and 2: not connected. |
| 8 | L14 | return=3 unions=4 undos=1 | 3 sets remain after 4 unions and 1 undo. Find costs O(log n) without compression, but every change can be reverted. |
Parity DSU
A **parity DSU** stores, for each element, whether its colour differs from its parent's. The parity of an element relative to its root is the XOR of the parities on the path, and path compression keeps it correct by XOR-ing as it re-points. An edge a-b that must join two different colours is consistent if the sets differ (link them with the right parity) or, when both are in one set, if their parities relative to the root differ. Otherwise there is an odd cycle: the graph is not bipartite.
The animation's "Parity: an even cycle is bipartite" preset (0-1 1-2 2-3 3-0) is accepted, while "Parity: an odd cycle conflicts" (0-1 1-2 2-0 2-3) stops at 2-0, which closes a triangle. Parity DSU also answers online 'is the graph still bipartite after adding this edge?' in near-constant time per edge.
1class ParityDsu {2final int[] parent, parity; // parity[x]: 0 if x has the same colour as parent[x], 1 if different3ParityDsu(int n) {4parent = new int[n]; parity = new int[n];5for (int i = 0; i < n; i++) parent[i] = i;6}7int find(int x) { // returns the root; afterwards parity[x] is relative to it8if (parent[x] == x) return x;9int p = parent[x], root = find(p);10parity[x] ^= parity[p];11parent[x] = root;12return root;13}14boolean addDifferent(int a, int b) { // a and b must get different colours; false on conflict15int ra = find(a), rb = find(b);16if (ra == rb) return parity[a] != parity[b];17parent[ra] = rb;18parity[ra] = parity[a] ^ parity[b] ^ 1;19return true;20}21}
Union-find variants: rollback, parity and weighted (potential) DSU
Interactive · edit the inputforest (no compression, so every union can be undone) · 6 nodes
size 1
size 1
size 1
size 1
size 1
size 1
parent[x]
history stack (child root → parent root), top last
A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time.
1int[] parent, size; Deque<int[]> history = new ArrayDeque<>(); // each entry {child root, parent root}2int find(int x) { while (parent[x] != x) x = parent[x]; return x; } // no path compression: it could not be undone3boolean union(int a, int b) {4int ra = find(a), rb = find(b);5if (ra == rb) return false;6if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }7parent[rb] = ra; size[ra] += size[rb];8history.push(new int[]{rb, ra}); // remember exactly what changed9return true;10}11void undo() {12int[] h = history.pop();13size[h[1]] -= size[h[0]]; parent[h[0]] = h[0];14}15int[] parent, parity; // parity[x]: 0 if x has the same colour as parent[x], 1 if different16int find(int x) { // returns the root; afterwards parity[x] is relative to it17if (parent[x] == x) return x;18int p = parent[x], root = find(p);19parity[x] ^= parity[p]; parent[x] = root;20return root;21}22boolean addDifferent(int a, int b) { // a and b must get different colours; false on conflict23int ra = find(a), rb = find(b);24if (ra == rb) return parity[a] != parity[b]; // same set: they must already differ25parent[ra] = rb; parity[ra] = parity[a] ^ parity[b] ^ 1;26return true;27}28int[] parent; long[] diff; // diff[x] = value[x] - value[parent[x]]29int find(int x) {30if (parent[x] == x) return x;31int p = parent[x], root = find(p);32diff[x] += diff[p]; parent[x] = root; // now relative to the root33return root;34}35boolean addConstraint(int a, int b, long d) { // value[b] - value[a] == d36int ra = find(a), rb = find(b);37if (ra == rb) return diff[b] - diff[a] == d;38parent[rb] = ra; diff[rb] = diff[a] + d - diff[b]; // value[rb] - value[ra]39return true;40}41Long query(int a, int b) { return find(a) == find(b) ? diff[b] - diff[a] : null; }
Live state
n
6
Execution trace (dry run table)Show 8 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | n=6 | A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time. |
| 2 | L8 | op=0-1 pushed=1→0 components=5 | 0-1: unite by size: parent[1] = 0, size[0] = 2. Push {1 → 0} on the history. |
| 3 | L8 | op=2-3 pushed=3→2 components=4 | 2-3: unite by size: parent[3] = 2, size[2] = 2. Push {3 → 2} on the history. |
| 4 | L8 | op=1-2 pushed=2→0 components=3 | 1-2: unite by size: parent[2] = 0, size[0] = 4. Push {2 → 0} on the history. |
| 5 | L13 | op=undo restored=2 components=4 | undo: pop {2 → 0}: 2 becomes a root again and 0 loses its size. The state is exactly as before that union. |
| 6 | L8 | op=4-5 pushed=5→4 components=3 | 4-5: unite by size: parent[5] = 4, size[4] = 2. Push {5 → 4} on the history. |
| 7 | L2 | op=0?3 connected=false | 0?3: roots 0 and 2: not connected. |
| 8 | L14 | return=3 unions=4 undos=1 | 3 sets remain after 4 unions and 1 undo. Find costs O(log n) without compression, but every change can be reverted. |
Weighted/potential DSU
A **weighted** (potential) DSU stores for each element the difference diff[x] = value[x] - value[parent[x]]. Constraints of the form value[b] - value[a] = d then join sets and fix the offset between their roots, and any later query value[b] - value[a] inside one set is diff[b] - diff[a] after the finds. A constraint inside a set is consistent only if it matches the stored difference: otherwise the system is contradictory.
Typical uses: ratios between variables (take logarithms or work with fractions), distances along a line, 'A is heavier than B by 3'. In the preset "Weighted: consistent differences" (0-1:3 1-2:2) the query 0?2 returns 5, 2?0 returns -5, and 0?4 is unknown because 4 is in no set with 0. "Weighted: a contradiction" shows 0-2:4 rejected after 5 was already implied.
1class WeightedDsu {2final int[] parent;3final long[] diff; // diff[x] = value[x] - value[parent[x]]4WeightedDsu(int n) {5parent = new int[n]; diff = new long[n];6for (int i = 0; i < n; i++) parent[i] = i;7}8int find(int x) {9if (parent[x] == x) return x;10int p = parent[x], root = find(p);11diff[x] += diff[p]; // now relative to the root12parent[x] = root;13return root;14}15boolean addConstraint(int a, int b, long d) { // value[b] - value[a] == d; false on a contradiction16int ra = find(a), rb = find(b);17if (ra == rb) return diff[b] - diff[a] == d;18parent[rb] = ra;19diff[rb] = diff[a] + d - diff[b]; // value[rb] - value[ra]20return true;21}22Long query(int a, int b) { return find(a) == find(b) ? diff[b] - diff[a] : null; } // null: not related23}
Union-find variants: rollback, parity and weighted (potential) DSU
Interactive · edit the inputforest (no compression, so every union can be undone) · 6 nodes
size 1
size 1
size 1
size 1
size 1
size 1
parent[x]
history stack (child root → parent root), top last
A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time.
1int[] parent, size; Deque<int[]> history = new ArrayDeque<>(); // each entry {child root, parent root}2int find(int x) { while (parent[x] != x) x = parent[x]; return x; } // no path compression: it could not be undone3boolean union(int a, int b) {4int ra = find(a), rb = find(b);5if (ra == rb) return false;6if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }7parent[rb] = ra; size[ra] += size[rb];8history.push(new int[]{rb, ra}); // remember exactly what changed9return true;10}11void undo() {12int[] h = history.pop();13size[h[1]] -= size[h[0]]; parent[h[0]] = h[0];14}15int[] parent, parity; // parity[x]: 0 if x has the same colour as parent[x], 1 if different16int find(int x) { // returns the root; afterwards parity[x] is relative to it17if (parent[x] == x) return x;18int p = parent[x], root = find(p);19parity[x] ^= parity[p]; parent[x] = root;20return root;21}22boolean addDifferent(int a, int b) { // a and b must get different colours; false on conflict23int ra = find(a), rb = find(b);24if (ra == rb) return parity[a] != parity[b]; // same set: they must already differ25parent[ra] = rb; parity[ra] = parity[a] ^ parity[b] ^ 1;26return true;27}28int[] parent; long[] diff; // diff[x] = value[x] - value[parent[x]]29int find(int x) {30if (parent[x] == x) return x;31int p = parent[x], root = find(p);32diff[x] += diff[p]; parent[x] = root; // now relative to the root33return root;34}35boolean addConstraint(int a, int b, long d) { // value[b] - value[a] == d36int ra = find(a), rb = find(b);37if (ra == rb) return diff[b] - diff[a] == d;38parent[rb] = ra; diff[rb] = diff[a] + d - diff[b]; // value[rb] - value[ra]39return true;40}41Long query(int a, int b) { return find(a) == find(b) ? diff[b] - diff[a] : null; }
Live state
n
6
Execution trace (dry run table)Show 8 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | n=6 | A rollback DSU unites by size and never compresses paths, so each union changes exactly one parent and one size. A stack of those changes lets undo() restore the previous state exactly, which is the basis of offline divide-and-conquer on time. |
| 2 | L8 | op=0-1 pushed=1→0 components=5 | 0-1: unite by size: parent[1] = 0, size[0] = 2. Push {1 → 0} on the history. |
| 3 | L8 | op=2-3 pushed=3→2 components=4 | 2-3: unite by size: parent[3] = 2, size[2] = 2. Push {3 → 2} on the history. |
| 4 | L8 | op=1-2 pushed=2→0 components=3 | 1-2: unite by size: parent[2] = 0, size[0] = 4. Push {2 → 0} on the history. |
| 5 | L13 | op=undo restored=2 components=4 | undo: pop {2 → 0}: 2 becomes a root again and 0 loses its size. The state is exactly as before that union. |
| 6 | L8 | op=4-5 pushed=5→4 components=3 | 4-5: unite by size: parent[5] = 4, size[4] = 2. Push {5 → 4} on the history. |
| 7 | L2 | op=0?3 connected=false | 0?3: roots 0 and 2: not connected. |
| 8 | L14 | return=3 unions=4 undos=1 | 3 sets remain after 4 unions and 1 undo. Find costs O(log n) without compression, but every change can be reverted. |
DSU with metadata
Any summary that can be merged can be stored at the root of each set: the size, the smallest or largest member, the sum of the members, the number of edges, a bitmask. When two sets are united, combine the metadata of the two roots into the new root. Queries read the root's data after a find. The cost stays near-constant per operation if the merge is O(1).
The code keeps the size, the minimum element and the sum for every set. Remember to update only the surviving root and to read from the root, never from other members; their copies may be stale.
1class MetaDsu {2final int[] parent, size, min;3final long[] sum;4MetaDsu(int n) {5parent = new int[n]; size = new int[n]; min = new int[n]; sum = new long[n];6for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; min[i] = i; sum[i] = i; }7}8int find(int x) {9while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }10return x;11}12boolean union(int a, int b) {13int ra = find(a), rb = find(b);14if (ra == rb) return false;15if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }16parent[rb] = ra;17size[ra] += size[rb];18min[ra] = Math.min(min[ra], min[rb]); // combine the metadata of the two roots19sum[ra] += sum[rb];20return true;21}22int sizeOf(int x) { return size[find(x)]; }23int minOf(int x) { return min[find(x)]; }24long sumOf(int x) { return sum[find(x)]; }25}
Union-find: find, union, path compression, union by size and rank
Interactive · edit the inputdisjoint-set forest (arrow = parent, roots on top) · 6 nodes
parent[x]
6 elements, each in its own set: parent[x] = x makes every element a root (the set's representative). Union links one root under the other with no balancing.
1int[] parent, size, rank; // parent[x] == x for a root2int findPlain(int x) { while (parent[x] != x) x = parent[x]; return x; } // follow parents to the root3int findCompress(int x) { // path compression: point everyone on the path at the root4if (parent[x] != x) parent[x] = findCompress(parent[x]);5return parent[x];6}7int findHalving(int x) { // path halving: skip one level as you climb8while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; }9return x;10}11boolean unionNaive(int a, int b) { // link the first root under the second12int ra = find(a), rb = find(b);13if (ra == rb) return false;14parent[ra] = rb;15return true;16}17boolean unionBySize(int a, int b) { // the smaller tree goes under the larger18int ra = find(a), rb = find(b);19if (ra == rb) return false;20if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }21parent[rb] = ra; size[ra] += size[rb];22return true;23}24boolean unionByRank(int a, int b) { // the lower-rank root goes under the higher one25int ra = find(a), rb = find(b);26if (ra == rb) return false;27if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }28parent[rb] = ra;29if (rank[ra] == rank[rb]) rank[ra]++;30return true;31}
Live state
n
6
components
6
Execution trace (dry run table)Show 25 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | n=6 components=6 | 6 elements, each in its own set: parent[x] = x makes every element a root (the set's representative). Union links one root under the other with no balancing. |
| 2 | L2 | who=find(0) root=0 | find(0): 0 is its own parent: it is the root. |
| 3 | L2 | who=find(1) root=1 | find(1): 1 is its own parent: it is the root. |
| 4 | L14 | op=0-1 child=0 top=1 components=5 | 0-1: parent[0] = 1: the root of the first element goes under the root of the second. 5 sets left. |
| 5 | L2 | who=find(1) root=1 | find(1): 1 is its own parent: it is the root. |
| 6 | L2 | who=find(2) root=2 | find(2): 2 is its own parent: it is the root. |
| 7 | L14 | op=1-2 child=1 top=2 components=4 | 1-2: parent[1] = 2: the root of the first element goes under the root of the second. 4 sets left. |
| 8 | L2 | who=find(2) root=2 | find(2): 2 is its own parent: it is the root. |
| 9 | L2 | who=find(3) root=3 | find(3): 3 is its own parent: it is the root. |
| 10 | L14 | op=2-3 child=2 top=3 components=3 | 2-3: parent[2] = 3: the root of the first element goes under the root of the second. 3 sets left. |
| 11 | L2 | who=find(3) root=3 | find(3): 3 is its own parent: it is the root. |
| 12 | L2 | who=find(4) root=4 | find(4): 4 is its own parent: it is the root. |
| 13 | L14 | op=3-4 child=3 top=4 components=2 | 3-4: parent[3] = 4: the root of the first element goes under the root of the second. 2 sets left. |
| 14 | L2 | who=find(4) root=4 | find(4): 4 is its own parent: it is the root. |
| 15 | L2 | who=find(5) root=5 | find(5): 5 is its own parent: it is the root. |
| 16 | L14 | op=4-5 child=4 top=5 components=1 | 4-5: parent[4] = 5: the root of the first element goes under the root of the second. 1 set left. |
| 17 | L2 | who=find(0) from=0 to=1 hops=1 | find(0): 0 is not a root, follow parent[0] = 1. |
| 18 | L2 | who=find(0) from=1 to=2 hops=2 | find(0): 1 is not a root, follow parent[1] = 2. |
| 19 | L2 | who=find(0) from=2 to=3 hops=3 | find(0): 2 is not a root, follow parent[2] = 3. |
| 20 | L2 | who=find(0) from=3 to=4 hops=4 | find(0): 3 is not a root, follow parent[3] = 4. |
| 21 | L2 | who=find(0) from=4 to=5 hops=5 | find(0): 4 is not a root, follow parent[4] = 5. |
| 22 | L2 | who=find(0) root=5 depth=5 | find(0): the root is 5, 5 steps up. |
| 23 | L2 | who=find(5) root=5 | find(5): 5 is its own parent: it is the root. |
| 24 | L1 | op=0?5 root of a=5 root of b=5 connected=true | 0?5: the roots are 5 and 5, so 0 and 5 are connected. |
| 25 | L1 | return=1 tallest tree=5 find hops=5 parent changes=0 | 1 set remains. The tallest tree has height 5, and all the finds together followed 5 parent links. |
DSU on tree distinction
'DSU on tree' (also called Sack or small-to-large merging) is **not** the union-find data structure, even though the names are similar. It is a technique for answering questions about every subtree of a rooted tree, such as 'how many distinct colours are in each subtree?': keep a set per node, and when merging a child's set into its parent's, always merge the smaller set into the larger. Each element moves O(log n) times, because every move at least doubles its set, so the total is O(n log n) set operations.
The two ideas share the **merge the smaller into the larger** argument, but they solve different problems: union-find maintains a partition under merges and answers connectivity; DSU on tree answers per-subtree aggregates on a fixed tree. The function below counts distinct colours per subtree of a tree given by a parent array with parent[i] < i, processing nodes from the highest index down so every child is merged before its parent is.
1static int[] distinctColours(int[] parent, int[] color) { // parent[0] = -1, parent[i] < i2int n = parent.length;3List<Set<Integer>> sets = new ArrayList<>();4for (int i = 0; i < n; i++) { Set<Integer> s = new HashSet<>(); s.add(color[i]); sets.add(s); }5int[] answer = new int[n];6for (int v = n - 1; v >= 0; v--) {7answer[v] = sets.get(v).size(); // all children of v have been merged into it already8if (v == 0) break;9int p = parent[v];10Set<Integer> small = sets.get(v), large = sets.get(p);11if (small.size() > large.size()) { Set<Integer> t = small; small = large; large = t; } // merge the smaller into the larger12large.addAll(small);13sets.set(p, large);14}15return answer;16}
Practice ladder
Now try it yourself
Easy
- Is Graph Bipartite?
Medium
- Possible Bipartition
- Evaluate Division
- Satisfiability of Equality Equations
Advanced
- Dynamic Connectivity (offline, rollback)
- Number of Distinct Colors in Subtree (small-to-large)
- Minimum Cost to Make Graph Connected with Constraints
Key takeaways
- Rollback DSU: union by size without compression, plus a stack of changes to undo.
- Parity and weighted DSUs store a relation to the parent on every link and combine it along the path.
- Metadata lives at the root; DSU on tree is small-to-large merging on subtrees, a different technique.