Cairn
data structures · disjoint set · O(α(n)) amortized

back to Disjoint Set

Weighted Union-Find

Union-Find answers one question fast: "are x and y in the same group?" Weighted Union-Find (also called Union-Find with potentials) answers a sharper one: "given everything I've been told, what's the known numeric difference between x and y?" Every union now carries a weight — not just "these two are related," but "y is exactly w more than x." That single addition also buys something plain Union-Find can't offer at all: if a new constraint contradicts what earlier unions already imply, the structure catches it, in the same near-constant time as everything else. This is the standard trick behind checking a system of relative constraints for consistency — "B is 5 more than A," "C is 2 less than B," does that ever imply something impossible? — without re-deriving every value from scratch on each new fact.

Try it

Same eight elements as Union-Find, 0 through 7, but every union now also carries a weight. Click a node to select it as A (dashed border), click a second to select it as B (solid border), set the weight field, then press Union to record "B = A + weight." Press Find on a single selected node to see its offset from its set's root — the number under each non-root node is that live offset. If you union two elements already in the same set, the demo checks the weight against what's already implied: consistent, and it's a no-op; contradictory, and it's rejected and flagged in red instead of silently applied.

Click one or two nodes, set a weight, then Find or Union.

Why it works

Give every element x a hidden, never-fully-known value value(x). Nothing in the structure ever learns any single element's absolute value — only differences between values, supplied one union at a time. Alongside the usual parent pointer, each non-root element x stores a potential: value(x) - value(parent(x)), its offset from its own parent. find(x) walks up to the root exactly as before, but now sums potentials along the way, so it returns both the root and value(x) - value(root) — the offset from x all the way to its set's representative.

Union. union(x, y, w) means "value(y) - value(x) = w." Find both roots first. If they're already the same root, the union adds no new information — check whether the two elements' already-known offsets agree with w; if they don't, that's a genuine contradiction between constraints, not a bug, and it's rejected. If the roots differ, attach one under the other (by rank, exactly as plain Union-Find does) and give the attached root a potential that makes the whole merged tree consistent. Working out that potential is direct algebra: if root rx attaches under root ry, then potential(rx) = value(rx) - value(ry), and substituting value(x) = value(rx) + offset(x) and the known relation value(y) - value(x) = w gives potential(rx) = offset(y) - offset(x) - w. Attach the other way and the same substitution gives the mirror-image formula with the sign flipped.

Worked example. Start empty, then union(0, 1, 3) — "value(1) = value(0) + 3" — and union(1, 2, 2) — "value(2) = value(1) + 2." Nothing states value(2) relative to value(0) directly, but the structure already implies it: find(2) returns offset +5 from root 0 (3 + 2), with no extra bookkeeping. A third union(0, 2, 5) is then consistent with what's already known (implied offset is also 5) and is a no-op; union(0, 2, 6) instead contradicts it — the structure already knows the answer is 5, not 6 — and is rejected. That check costs exactly two find calls, the same near-constant operation as everything else here.

Reference implementation

Matches the demo above. The important line is the one a first draft is most likely to skip: path compression has to rewrite both the pointer and the potential, together, or the shortcut it just took silently stops meaning what its stored number says it means (see Pitfalls):

class WeightedDisjointSet {
  #parent;
  #potential;
  #rank;

  constructor(n) {
    this.#parent = Array.from({ length: n }, (_, i) => i);
    this.#potential = new Array(n).fill(0);
    this.#rank = new Array(n).fill(0);
  }

  // returns { root, offset } where offset === value(x) - value(root)
  find(x) {
    if (this.#parent[x] === x) return { root: x, offset: 0 };
    const { root, offset } = this.find(this.#parent[x]);
    const total = this.#potential[x] + offset;
    this.#parent[x] = root;      // path compression: repoint...
    this.#potential[x] = total;  // ...and re-express the offset relative to the NEW parent (the root)
    return { root, offset: total };
  }

  // records "value(y) - value(x) === w"; returns false if that contradicts what's already known
  union(x, y, w) {
    const a = this.find(x), b = this.find(y);
    if (a.root === b.root) return (b.offset - a.offset) === w;

    let small, big, pot;
    if (this.#rank[a.root] < this.#rank[b.root]) {
      small = a.root; big = b.root; pot = b.offset - a.offset - w;
    } else if (this.#rank[a.root] > this.#rank[b.root]) {
      small = b.root; big = a.root; pot = w + a.offset - b.offset;
    } else {
      small = b.root; big = a.root; this.#rank[a.root]++; pot = w + a.offset - b.offset;
    }
    this.#parent[small] = big;
    this.#potential[small] = pot;
    return true;
  }

  // value(y) - value(x), or null if x and y aren't known to be related at all
  diff(x, y) {
    const a = this.find(x), b = this.find(y);
    return a.root === b.root ? b.offset - a.offset : null;
  }
}

Pitfalls

Path compression must carry the offset, not just the pointer — verified with real numbers, not just argued. Build an 8-element tree via seven weighted unions so that node 7 sits three hops from root 0 (parent chain 7 → 6 → 4 → 0, potentials +4, -3, +7 respectively). The correct offset for node 7 is +8 relative to root 0, and a correct implementation returns 8 from find(7) every time it's called, including a second call right after the first. A buggy variant that repoints parent[7] straight to the root during compression but forgets to rewrite potential[7] to match — leaving it at its old value, still relative to the node it used to point at, not the root — returns the correct 8 on the first call (it still walks the real chain that time) but 4 on the very next call: the shortcut is now in place, so the second call trusts the stale local potential directly instead of re-deriving it, and 4 was never the right answer for anything. No crash, no exception, no visible sign anything is wrong — just a wrong number that looks exactly as confident as a right one. Run both implementations back to back and this is exactly what happens; it isn't a hypothetical.

Rejecting a contradiction doesn't tell you which constraint was wrong. Same limit as plain Union-Find's "merging is one-way" — there's no way to retract an earlier union that turns out, in hindsight, to have been the bad one. A rejected union just means "this new fact conflicts with something already accepted"; deciding which of the two conflicting facts to keep is a judgment call outside the structure, not something union can resolve for you.

"Not yet related" isn't "consistent." Contradiction detection only fires between elements the union history actually connects, directly or transitively. Two elements in components that have never been unioned have no stored relationship at all — diff(x, y) returns null, not 0 and not an error. That's not a gap in the implementation; there is genuinely no information to check yet.

Weights and rollback are separate extensions, not stackable for free. This page trades plain Union-Find's near-constant time for the ability to track differences; Union-Find with Rollback trades it instead for the ability to undo the most recent merge, and it can only do that by giving up path compression — which is exactly the mechanism this page's own potentials ride along during. Combining both ideas in one structure is possible in principle but isn't free: it needs care neither page's implementation provides on its own.

Complexity

Time: identical to plain Union-Find — O(α(n)) amortized per find or union with both union-by-rank and path compression, since tracking a potential alongside the pointer is O(1) extra work per node touched, changing nothing about the amortized bound. The first Pitfall above is entirely about correctness, not performance — a build that forgets to update potentials runs at exactly the same speed as one that doesn't, and gives wrong answers just as fast. Space: O(n) — one extra number per element beyond plain Union-Find's parent and rank arrays.

This site's guide, Choosing a Union-Find Variant, compares this entry against the other three Disjoint Set structures that extend the same core contract side by side.