Publicado · en mejora
Guía de Union-Find · 4/6
Por ahora, este capítulo solo está disponible en inglés.
Union-find is often described as running in "nearly constant time". This chapter explains what that phrase really means, how the cost drops as each optimization is added, and how union-find compares with the alternatives.
Here n is the number of elements and m the number of find and union calls. union calls find twice internally, so it costs the same order as find.
| Implementation | One find (worst case) | m operations in total |
|---|---|---|
| Quick-find (label array) | O(1), but union is O(n) | O(m n) |
| Plain trees (no optimization) | O(n) | O(m n) |
| Union by size or rank only | O(log n) | O(m log n) |
| Path compression only | O(n), amortized O(log n) | O(m log n) |
| Both | O(log n) | O(m α(n)) |
The α(n) in the last row is the inverse Ackermann function.
The Ackermann function grows unimaginably fast: nudge its input up a little and the output explodes. The inverse Ackermann function α(n) asks the opposite question: how large must the input be before the Ackermann function reaches n? Because the Ackermann function grows absurdly fast, α(n) grows absurdly slowly.
In plain words: even if you had far more elements than there are atoms in the observable universe, α(n) would still be at most 4. So the total cost O(m α(n)) can be read in practice as "a small constant per operation". Strictly speaking it is not a constant, and Tarjan showed that this bound cannot be beaten by any pointer-based structure of this kind.
Placing it next to the logarithm makes the difference obvious.
| n | log2 n | α(n) |
|---|---|---|
| 16 | 4 | at most 3 |
| 65,536 | 16 | at most 4 |
| 10^80 (roughly the atoms in the observable universe) | about 266 | at most 4 |
With path compression, an individual find can still take a long time. But that find flattens the path it walked, so the calls that follow become cheaper. The cost is therefore measured as a total over all m operations rather than per call; this is amortized analysis. If the worst case of a single call matters, as in hard real-time systems, keep this in mind.
The code below compares the deepest node in a tree built without optimizations against one built with both optimizations.
def depth(parent, x):
d = 0
while parent[x] != x:
x = parent[x]
d += 1
return d
n = 1 << 12
naive = list(range(n))
for i in range(1, n):
naive[i - 1] = i # hang the previous root under the next element
print(max(depth(naive, x) for x in range(n))) # 4095
parent, size = list(range(n)), [1] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for i in range(1, n):
ra, rb = find(i - 1), find(i)
if size[ra] < size[rb]:
ra, rb = rb, ra
parent[rb] = ra
size[ra] += size[rb]
print(max(depth(parent, x) for x in range(n))) # 1With union by size, each new singleton is hung directly under the root of the big tree, so the maximum depth stays at 1.
Run 200,000 random unions while growing the number of elements a thousandfold. The time per operation stays in the low microseconds and does not grow in proportion to n. Depending on your machine it may creep up a little, mostly because larger arrays fit less well in the CPU cache, not because the algorithm does more work.
import random
import time
def ns_per_op(n, m=200_000):
parent, size = list(range(n)), [1] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
start = time.perf_counter()
for _ in range(m):
ra, rb = find(random.randrange(n)), find(random.randrange(n))
if ra != rb:
if size[ra] < size[rb]:
ra, rb = rb, ra
parent[rb] = ra
size[ra] += size[rb]
return (time.perf_counter() - start) / m * 1e9
for n in (10**3, 10**5, 10**6):
print(n, f"{ns_per_op(n):.0f} ns/op")Two arrays, parent and size (or rank), give O(n) space. Ranks never exceed log n, so a very small integer type is enough for them. A recursive find can push as many stack frames as the tree is tall in the worst case, so in languages with a low recursion limit, such as Python, the iterative version is the safe choice.
import sys
# If you must use a recursive find, you may need a higher limit
sys.setrecursionlimit(1 << 20)
def find_rec(parent, x):
if parent[x] != x:
parent[x] = find_rec(parent, parent[x])
return parent[x]| Approach | Add edge | Connectivity query | Delete edge | Notes |
|---|---|---|---|---|
| Union-find | nearly O(1) | nearly O(1) | not supported | merges only |
| BFS/DFS per query | O(1) | O(V + E) | O(1) | simple when queries are rare |
| Precomputed components | full recompute | O(1) | full recompute | for graphs that never change |
| Dynamic connectivity structures | O(log^2 n) amortized | O(log n) | supported | complex to implement |
With both optimizations, union-find runs m operations in O(m α(n)) time and O(n) space. Since α(n) is at most 4 for any realistic input, you can treat it as a constant. When edges are never deleted and queries are frequent, it is far more efficient than rerunning BFS or DFS.
0 comentarios
Iniciar sesión · Inicia sesión para dejar un comentario.
Sé el primero en comentar.