Skip to content

DSU & Kruskal

In one sentence

A DSU (Disjoint Set Union, also called Union-Find) keeps track of groups that only ever merge. It answers "are \(x\) and \(y\) in the same group?" almost instantly. Kruskal's algorithm uses a DSU to build the cheapest network that connects everything.

1. What problem does it solve?

Example — CF 1167C · News Distribution

There are \(n\) users and \(m\) friend groups. A user who learns some news tells every group they belong to, those people tell their groups, and so on.

For each user \(x\): if \(x\) starts spreading the news, how many users end up knowing it?

Limits: \(n, m \le 5 \cdot 10^5\), total group sizes \(\le 5 \cdot 10^5\).

Input          Output
7 5
3 2 5 4        4 4 1 4 4 2 2
0
2 1 2
1 1
2 6 7

Being in the same group is contagious. If \(2, 5, 4\) share a group and \(1, 2\) share a group, then \(1, 2, 4, 5\) all hear the same news. So the users split into components, and the answer for \(x\) is the size of \(x\)'s component:

graph LR
  1 --- 2
  2 --- 5
  2 --- 4
  3
  6 --- 7

Components: \(\{1, 2, 4, 5\}\) has size 4, \(\{3\}\) has size 1, and \(\{6, 7\}\) has size 2. That matches the output 4 4 1 4 4 2 2.

We need two operations, many times:

operation meaning here
union(u, v) merge the groups of \(u\) and \(v\) a friend group links its members
find(x) name of \(x\)'s group (its root) whose component is \(x\) in?

2. How it works

2.1 Groups as trees

Every group is stored as a tree. Each element points to a parent, and the root points to itself. The root is the group's name.

DSU forest

  • Start: parent[x] = x. Every element is its own one-element group.
  • find(x): follow parent links until you reach an element that points to itself.

find walks up to the root

  • union(u, v): find both roots. If they differ, hang one root under the other. It is one assignment.

union links one root under the other

Naive union: root \(1\) is hung under root \(6\). With union by size (§2.4) it would be the other way round, because \(1\)'s tree has 5 elements and \(6\)'s has 3.

Two elements are in the same group exactly when find(u) == find(v).

2.2 Why the naive version can be slow

If we always hang the second root under the first, a bad order of unions builds a chain:

union(2,1), union(3,2), union(4,3), ...      1 <- 2 <- 3 <- 4 <- ... <- n

Now find(n) walks \(n\) steps, and \(n\) such calls cost \(O(n^2)\). There are two fixes, and we use both.

2.3 Fix 1 — path compression

While walking up to the root, reconnect every visited element directly to the root. The next find on any of them takes one step.

12345 12345 find(4) before: 4 → 3 → 1 after: 4 → 1 and 3 → 1 (5 untouched)

Each line goes up to the parent. The red lines are the path find(4) walked, and the links it rewired.

This changes the shape of the tree, but not who is in which group, because every element still has the same root.

2.4 Fix 2 — union by size

When merging, hang the smaller tree under the larger one, and keep sz[root] = number of elements in the group.

Claim: with union by size, every tree has height at most \(\log_2 n\).

Why: an element's depth increases by \(1\) only when its tree is hung under a tree that is at least as big. After that merge its group is at least twice as large as before. A group can double at most \(\log_2 n\) times before it contains all \(n\) elements, so the depth is at most \(\log_2 n\).

version find cost
naive \(O(n)\) worst case
union by size \(O(\log n)\)
path compression + union by size \(O(\alpha(n))\), where \(\alpha(n) \le 4\) for any real input: effectively constant

2.5 Kruskal's algorithm (Minimum Spanning Tree)

Problem. A connected, undirected graph has \(n\) vertices and weighted edges. Choose edges so that all vertices are connected and the total weight is as small as possible. The result is a tree with \(n - 1\) edges, called the minimum spanning tree (MST).

Algorithm.

  1. Sort the edges by weight, smallest first.
  2. For each edge \((u, v)\) in that order: if \(u\) and \(v\) are not already connected, take the edge and union(u, v). Otherwise skip it, because it would create a cycle.
  3. Stop after taking \(n - 1\) edges.

The DSU answers "already connected?" in step 2.

Example1095F · Make It Connected (6-DSU · I, rating 1900):

Problem

\(n\) vertices with numbers \(a_1, \dots, a_n\) and no edges. Adding edge \((x, y)\) costs \(a_x + a_y\). There are also \(m\) special offers \((x, y, w)\): you may add edge \((x, y)\) for \(w\) instead. Find the minimum cost to make the graph connected.

Limits: \(n, m \le 2 \cdot 10^5\), \(a_i, w \le 10^{12}\).

Input      Output
3 2
1 3 3
2 3 5
2 1 1      5

4 0
1 3 3 7    16

5 4
1 2 3 4 5
1 2 8
1 3 10
1 4 7
1 5 15     18

This is an MST, but on a complete graph with \(\frac{n(n-1)}{2}\) ordinary edges, which is far too many to sort. The trick: let \(v_0\) be the vertex with the smallest \(a\). Any ordinary edge \((x, y)\) with \(x, y \ne v_0\) costs \(a_x + a_y \ge a_{v_0} + a_y\). So if an MST used \((x, y)\), removing it splits the tree into two parts; \(v_0\) is in one of them, and the edge from \(v_0\) to whichever of \(x, y\) is in the other part reconnects them for no more cost. Therefore we only need the \(n - 1\) edges \((v_0, i)\) plus the \(m\) offers: about \(4 \cdot 10^5\) edges.

Trace on the first sample: \(a = [1, 3, 3]\), so \(v_0 = 1\). Edges: \((1, 2)\) costs \(4\), \((1, 3)\) costs \(4\), and the offers \((2, 3)\) for \(5\) and \((2, 1)\) for \(1\).

graph LR
  1 ---|"offer 1"| 2
  1 ---|"a1+a3 = 4"| 3
  1 ---|"a1+a2 = 4"| 2
  2 ---|"offer 5"| 3
  linkStyle 0,1 stroke:#4caf50,stroke-width:4px
edge (sorted) weight same component already? action total
2 – 1 (offer) 1 no take, union(2, 1) 1
1 – 3 4 no take, union(1, 3); now \(2 = n - 1\) edges, stop 5
1 – 2 4 (yes)
2 – 3 (offer) 5 (yes)

The green edges form the MST, with total weight \(5\).

Why greedy is correct (the exchange argument). Suppose Kruskal takes edge \(e\), but some optimal MST \(T\) does not contain it. Adding \(e\) to \(T\) creates a cycle. That cycle must contain another edge \(f\) that crosses between the two components \(e\) was joining. Kruskal had not yet taken \(f\) at that moment, so \(f\) comes later in sorted order and \(w(f) \ge w(e)\). Swap \(f\) out and \(e\) in: the result is still a spanning tree, and its weight is not larger. Repeating this turns any optimal tree into Kruskal's tree without increasing the weight, so Kruskal's tree is optimal.

Cost: sorting takes \(O(m \log m)\), and the DSU work is almost linear. So \(O(m \log m)\) overall.

3. Templates

These are the templates we use in class. Use them exactly as written.

Naive DSU

parent = [i for i in range(n + 1)]


def find(x):
    while parent[x] != x:
        x = parent[x]
    return x


def union(u, v):
    pu = find(u)
    pv = find(v)

    if pu != pv:
        parent[pv] = pu

DSU with Path Compression

def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])
    return parent[x]

Only find changes.

DSU with Path Compression + Union by Size

parent = [i for i in range(n + 1)]
sz = [1 for _ in range(n + 1)]


def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])
    return parent[x]


def union(u, v):
    pu = find(u)
    pv = find(v)

    if pu != pv:
        if sz[pu] < sz[pv]:
            pu, pv = pv, pu

        parent[pv] = pu
        sz[pu] += sz[pv]

Kruskal

def solve():
    n, m = map(int, input().split())

    edges = []
    for _ in range(m):
        u, v, w = map(int, input().split())
        edges.append((w, u, v))
    edges.sort()

    parent = [i for i in range(n + 1)]
    sz = [1 for _ in range(n + 1)]

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(u, v):
        pu = find(u)
        pv = find(v)
        if pu == pv:
            return False
        if sz[pu] < sz[pv]:
            pu, pv = pv, pu
        parent[pv] = pu
        sz[pu] += sz[pv]
        return True

    ans = 0
    cnt = 0
    for w, u, v in edges:
        if union(u, v):
            ans += w
            cnt += 1
            if cnt == n - 1:
                break
    print(ans)

t = int(input())
for _ in range(t):
    solve()

4. Worked solution — News Distribution

Union every member of a group with the group's first member. The answer for \(x\) is sz[find(x)].

import sys

input = sys.stdin.readline


def solve():
    n, m = map(int, input().split())

    parent = [i for i in range(n + 1)]
    sz = [1 for _ in range(n + 1)]

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(u, v):
        pu = find(u)
        pv = find(v)

        if pu != pv:
            if sz[pu] < sz[pv]:
                pu, pv = pv, pu

            parent[pv] = pu
            sz[pu] += sz[pv]

    for _ in range(m):
        g = list(map(int, input().split()))
        k = g[0]
        for i in range(2, k + 1):
            union(g[1], g[i])

    for x in range(1, n + 1):
        print(sz[find(x)], end=" ")
    print()


solve()

Why this passes and a naive version gets TLE

With \(5 \cdot 10^5\) users, unions in a bad order can build long chains. Union by size keeps every tree shallow, and path compression flattens it further. The recursion in find is safe here because union by size keeps the depth at most \(\log_2 n \approx 19\).

5. Worked solution — Make It Connected

This is the Kruskal template on one test case. The edge list is the \(m\) offers plus the \(n - 1\) edges from the smallest vertex \(v_0\) (see §2.5 for why no other ordinary edge is needed).

import sys

input = sys.stdin.readline


def solve():
    n, m = map(int, input().split())
    a = [0] + list(map(int, input().split()))

    edges = []
    for _ in range(m):
        u, v, w = map(int, input().split())
        edges.append((w, u, v))

    v0 = 1
    for i in range(2, n + 1):
        if a[i] < a[v0]:
            v0 = i
    for i in range(1, n + 1):
        if i != v0:
            edges.append((a[v0] + a[i], v0, i))

    edges.sort()

    parent = [i for i in range(n + 1)]
    sz = [1 for _ in range(n + 1)]

    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]

    def union(u, v):
        pu = find(u)
        pv = find(v)
        if pu == pv:
            return False
        if sz[pu] < sz[pv]:
            pu, pv = pv, pu
        parent[pv] = pu
        sz[pu] += sz[pv]
        return True

    ans = 0
    cnt = 0
    for w, u, v in edges:
        if union(u, v):
            ans += w
            cnt += 1
            if cnt == n - 1:
                break

    print(ans)


solve()

6. Common mistakes

Linking the elements instead of the roots

parent[v] = u is wrong. It rewires only \(v\) and can break other trees. Always link roots: parent[pv] = pu.

Updating sz on a non-root

sz is only meaningful at roots. Read the size with sz[find(x)], not sz[x].

Comparing parent[u] == parent[v]

Two elements can be in the same group with different parents. Compare find(u) == find(v).

Off-by-one on array size

Vertices are numbered \(1..n\), so allocate n + 1, as in the templates.

Deep recursion in find

Recursive find without union by size can reach depth \(n\) and crash with RecursionError. Use the full template (compression and size). If you must use the naive union, use the iterative find from the naive template.

Kruskal: forgetting to sort, or sorting by the wrong key

Store edges as (w, u, v) so edges.sort() sorts by weight first.

7. Practice

Problem Set Rating Idea
217A · Ice Skating 6-DSU · E 1200 components = answer + 1
1167C · News Distribution 6-DSU · B 1400 component sizes (this page)
277A · Learning Languages 6-DSU · A 1400 union employees through languages
1559D1 · Mocha and Diana (Easy) 6-DSU · H 1400 two DSUs at once
1702E · Split Into Two Sets 6-DSU · D, 5-Graph · D 1600 components + odd cycles
25D · Roads not only in Berland 6-DSU · G 1900 redundant edges vs. components
1095F · Make It Connected 6-DSU · I 1900 Kruskal with extra "cheapest vertex" edges
1245D · Shichikuji and Power Grid 6-DSU · C 1900 Kruskal with a virtual power-station vertex

Make It Connected can always be connected (every pair of vertices has an ordinary edge), so the solution never needs an "impossible" case.


Credits & licenses
  • DSU figures in §2.1 (the forest, find, and union): copied unchanged from OI Wiki — Disjoint Set Union by HeRaNO, JuicyMio, Xeonacid, sailordiary, ouuan, Pig-Eat-Earth and other OI Wiki contributors, licensed CC BY-SA 4.0. The path-compression figure is ours, because the original has a Chinese label.
  • Choice of example problems and parts of the practice list: follow USACO Guide — Disjoint Set Union by Benjamin Qi, Andrew Wang and Nathan Gong, and USACO Guide — Minimum Spanning Trees by Benjamin Qi, Andrew Wang and Kuan-Hung Chen, licensed CC BY-NC-SA 4.0.
  • Everything else (text, proofs, the Kruskal trace, the worked solutions) is ours. The templates in §3 are from our lessons.