Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Graphs  ›  Lesson

Union-Find & Artist Dedupe

Union Find 22 minMerging groups and asking who's together
You're building a piece ofTunebox — recommendations
This piece — merge_duplicates(): Collapses duplicate artist profiles into one.
Scenario Three labels uploaded the same artist under three spellings, and moderators keep reporting pairs. Tunebox merges each group into one canonical profile so plays and followers land in one place.
Your task
Build merge_duplicates(profiles, matches). `profiles` lists artist profile names; `matches` is a list of [a, b] pairs a moderator marked as the same artist, and sameness is transitive. Return a dict mapping every profile to the name its group collapses into — the alphabetically first name in that group. Example: merge_duplicates(["adele", "adele-music", "sza"], [["adele-music", "adele"]]) → {"adele": "adele", "adele-music": "adele", "sza": "sza"}.

Union-Find & Artist Dedupe

Three labels uploaded the same artist three times: theweeknd, the-weeknd and weeknd-official. Moderators report duplicates one pair at a time — "theweeknd is weeknd-official", then later "the-weeknd is theweeknd" — and Tunebox has to merge them transitively, then answer "are these the same artist?" instantly while reports keep arriving. Re-walking a graph for every question is too slow. Union-find (also called a disjoint-set) merges groups and answers that question in effectively constant time.

Every group has a representative

Union-find keeps a set of separate groups. Each group has one member chosen as its representative, or root, and every element holds a parent pointer leading toward it. At the start nobody has been merged, so everyone is their own parent:

python

A root is exactly an element whose parent is itself.

find — follow the pointers to the root

To learn which group something is in, follow parent pointers until you reach an element that points at itself:

What does this print?
parent = {"adele-music": "adele-hq", "adele-hq": "adele", "adele": "adele", "sza": "sza"}

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

print(find("adele-music"), find("sza"))

Two elements are in the same group exactly when find gives the same root.

union — hang one root under the other

Merging two groups is one pointer change: make one root the parent of the other root. Link the roots, not the elements you were handed — this version gets that wrong:

broken — fix it

All three profiles were reported as the same artist, so this should print True. It prints False. Fix union so merging never undoes an earlier merge.

Link roots and every earlier merge survives, because you only ever change a pointer that used to point at itself.

❓ Cross-question — "Why not store the matches as a graph and run DFS?" For one question on a fixed graph, that's fine. But DFS costs O(V + E) per question, and matches keep arriving between questions. Union-find handles an interleaved stream of merges and questions at almost O(1) each — and it never needs the edges again once they're merged.

Path compression

A long chain of parents makes find slow. Path compression fixes it while you walk: once you know the root, point every element you passed through straight at it. The next find on any of them is a single hop.

What does this print?
parent = {"d": "c", "c": "b", "b": "a", "a": "a"}

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

find("d")
print(parent["c"])

Union by rank

Compression repairs tall trees after the fact; union by rank avoids building them. Give each root a rank — an upper bound on its tree's height — and always hang the lower-ranked root under the higher one. Only when two ranks tie does the height grow, by one. Step through three merges:

step through it
1parent = {n: n for n in ["a", "b", "c", "d"]}
2rank = {n: 0 for n in parent}
3 
4def find(x):
5 while parent[x] != x:
6 x = parent[x]
7 return x
8 
9for x, y in [["a", "b"], ["c", "d"], ["a", "c"]]:
10 rx, ry = find(x), find(y)
11 if rx == ry:
12 continue
13 if rank[rx] < rank[ry]:
14 rx, ry = ry, rx
15 parent[ry] = rx
16 if rank[rx] == rank[ry]:
17 rank[rx] += 1
18print(parent, rank)

Together, the two tricks make a class worth keeping:

python

The iterative find does the same flattening as the recursive one, without any recursion depth to worry about.

Version Cost per find / union
Plain parent pointers O(n) worst case — one long chain
Union by rank only O(log n)
Path compression only O(log n) amortized
Both together O(α(n)) amortized — effectively constant
Kruskal's MST (below) O(E log E) — dominated by the sort

Coming from Java/JS: textbook union-find uses int[] parent indexed by element number. With string ids, a dict gives you the same thing without first mapping names to integers.

Kruskal's minimum spanning tree

Tunebox wants to link its offices with private cables. Each possible cable has a price; the goal is to connect every office for the lowest total. That's a minimum spanning tree, and Kruskal's algorithm builds it greedily: sort the cables cheapest first, and keep each one only if it joins two groups that aren't connected yet. Union-find answers "already connected?" at almost no cost.

python

A cable whose ends already share a root would only close a loop, so it's skipped. With V offices, the tree is finished after exactly V − 1 kept cables.


Idioms & real-world patterns

Let union report whether it merged

Returning True/False from union turns common questions into one-liners. Count the groups: start at n, subtract one per successful union. Spot a loop in an undirected graph: the first edge whose union returns False joins two things that were already connected.

python

Picking a readable representative

The root is whichever element the unions happened to leave on top — fine internally, but arbitrary to show a user. When you need a stable answer, choose one after merging: group elements by root, then take, say, the alphabetically first name in each group.

⚡ Advanced — how constant is "effectively constant"?

α(n) is the inverse Ackermann function. It grows so slowly that α(n) ≤ 4 for any n you could ever store. The bound is amortized: an individual find can still walk a few steps, but no long sequence of operations averages worse.

⚡ Advanced — what union-find can't do

It only merges. There's no cheap way to split a group again — un-merging a wrongly reported duplicate means rebuilding from the remaining matches. When deletions matter, systems keep the match list as the source of truth and rebuild the sets offline.


🎯 Your turn

Write merge_duplicates(profiles, matches). profiles lists artist profile names; matches holds [a, b] pairs a moderator marked as the same artist, and sameness is transitive. Return a dict mapping every profile to the name its group collapses into — the alphabetically first name in that group:

  • merge_duplicates(["adele", "adele-music", "sza"], [["adele-music", "adele"]]){"adele": "adele", "adele-music": "adele", "sza": "sza"}
  • merge_duplicates([], []){}

Hint — union every match, then walk the profiles in sorted order: the first name you meet for each root is that group's canonical name.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of the app is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upFind the root

Write find_root(parent, name)parent is a union-find parent-pointer dict, where a root is its own parent — returning the root of name's group. Example: {"a": "b", "b": "c", "c": "c"}, "a""c".

union-findwhile
DrillCount the groups

Write count_groups(names, pairs) returning how many separate groups remain after joining every pair in pairs (joining is transitive). Use union-find: start with one group per name, and every union that joins two different groups leaves one fewer. Example: ["a", "b", "c", "d"], [["a", "b"], ["b", "c"]]2.

disjoint-setunion-findpath-compression
BuildSame artist?

Moderators ask 'are these the same artist?' all day. Write same_artist(profiles, matches, queries) returning a list of booleans, one per [a, b] query, saying whether a and b are in the same group once every match in matches has been merged. Use union by rank and path compression. Example: ["a", "b", "c"], [["a", "b"]], [["a", "b"], ["a", "c"]][True, False].

union-findunion-by-rankpath-compression
BossMerge by shared handles

Not every duplicate gets reported. profiles maps each artist profile name to its list of external handles (a social account, a store link…). Two profiles that share any handle are the same artist — and that's transitive. Write merge_by_handles(profiles) returning the groups of profile names: each group sorted, and groups ordered by their first name. Example: {"sza": ["@sza"], "sza-music": ["@sza", "store/sza"], "lorde": ["@lorde"]}[["lorde"], ["sza", "sza-music"]].

union-findhash-mappath-compression
CapstoneConnect every office

Tunebox is linking its offices into one private network. Some cables already exist (existing, [a, b] pairs — free to use). Carriers have quoted for new ones (offers, [a, b, cost], undirected). Write upgrade_cost(sites, existing, offers) returning the minimum extra cost to connect every site, or -1 if even buying every offer can't do it. No sites, or one site, costs 0.

kruskalunion-findsorted-key
merge_duplicates.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
merge_duplicates(profiles, matches) → dictEvery profile mapped to its group's canonical (alphabetically first) name.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.