コーディング · データ構造
Union-Find(素集合データ構造)とは?仕組み・コード・計算量を解説
結論
Union-Findは、素集合データ構造(disjoint set union、DSU)とも呼ばれ、互いに重ならないグループに分けられた要素を管理します。操作は2つあります。find(x)はxのグループの代表を返し、union(x, y)はxとyのグループを併合します。各グループは、親ポインタによる木として格納されます。経路圧縮と、ランクまたはサイズによる併合を組み合わせると、どんな操作列でも、1操作あたりほぼ定数の償却時間で実行できます。
Union-Findが解決する問題
ケーブルが1本ずつ追加されていくコンピュータのネットワークを想像してください。ケーブルを追加するたびに、「コンピュータAとBはつながっているか?」に答える必要があります。質問のたびにグラフ探索を実行すると、毎回O(n + m)かかります。Union-Findは、ケーブルの追加に合わせて連結なグループを保持することで、各質問にほぼO(1)で答えます。
同じ要件は、クラスカル法による最小全域木(この2つの端点は、すでに同じ木に属しているか?)、無向グラフの閉路検出、同じメールアドレスを共有するアカウントのグループ化、パーコレーションのシミュレーション、画像処理での連結領域のラベル付けにも現れます。
イメージで理解する
すべての要素は親を指しています。自分自身を指す要素が根で、根がグループ全体の代表です。xのグループを見つけるには、根に着くまで親ポインタをたどります。2つのグループを併合するには、両方の根を見つけ、一方の根をもう一方に向けます。
2つの最適化で、木を平らに保ちます。
- ランク(またはサイズ)による併合:低い(または小さい)木を、高い(または大きい)木の下に付けます。こうすると、同じ大きさの木を併合したときだけ、木の高さが増えます。
- 経路圧縮:
findの最中に、経路上のすべてのノードを根に直接向けます。こうすると、次の検索は1ステップで済みます。
具体例:5つの要素
0から4までの要素から始め、それぞれが自分だけのグループに入っています。親の配列は[0, 1, 2, 3, 4]です。サイズによる併合を使い、サイズが等しいときは2つ目の根を子にします。
| 操作 | 見つかった根 | 操作後の親の配列 | グループ |
|---|---|---|---|
| 開始 | - | [0, 1, 2, 3, 4] | {0} {1} {2} {3} {4} |
| union(0, 1) | 0と1、サイズが等しい | [0, 0, 2, 3, 4] | {0, 1} {2} {3} {4} |
| union(2, 3) | 2と3、サイズが等しい | [0, 0, 2, 2, 4] | {0, 1} {2, 3} {4} |
| union(1, 3) | 0と2、サイズが等しい | [0, 0, 0, 2, 4] | {0, 1, 2, 3} {4} |
| find(3) | 3から2、0へ。根は0 | [0, 0, 0, 0, 4] | 経路圧縮:3が0を直接指すようになった |
| connected(1, 4) | 根は0と4 | 変化なし | False、別のグループ |
Pythonでの実装
このバージョンは、サイズによる併合と、経路圧縮付きの反復型findを使うので、再帰の深さの問題がありません。unionは、2つの要素がすでに同じグループにあった場合にFalseを返します。これは閉路検出に便利です。
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.groups = n
def find(self, x):
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # path compression
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected
if self.size[ra] < self.size[rb]:
ra, rb = rb, ra # ra is the larger root
self.parent[rb] = ra
self.size[ra] += self.size[rb]
self.groups -= 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
uf = UnionFind(5)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 3)
print(uf.find(3), uf.parent) # 0 [0, 0, 0, 0, 4]
print(uf.connected(1, 4), uf.groups) # False 2例:無向グラフの閉路検出
辺を1本ずつ処理します。辺の両端がすでに同じ根を持っているなら、その辺は閉路を作ります。クラスカル法は、辺を重みでソートしたあと、まさにこの判定を使います。
def has_cycle(n, edges):
uf = UnionFind(n)
for a, b in edges:
if not uf.union(a, b):
return True
return False
print(has_cycle(4, [(0, 1), (1, 2), (2, 3)])) # False
print(has_cycle(4, [(0, 1), (1, 2), (2, 0)])) # True時間計算量と空間計算量
| バージョン | find / union(1操作あたりの最悪ケース) | n個の要素に対するm回の操作 |
|---|---|---|
| 最適化なし | O(n) | O(m n)、木が鎖状になりうる |
| ランクまたはサイズによる併合のみ | O(log n) | O(m log n) |
| 経路圧縮のみ | 償却O(log n) | 償却O(m log n) |
| 両方の最適化 | 償却O(α(n)) | O(m α(n)) |
α(n)は逆アッカーマン関数で、非常にゆっくり増えるため、現実のコンピュータに収まるどんなnでも、せいぜい4です。実用上は、1操作あたり定数時間という意味です。この上界は、1975年にRobert Tarjanが証明しました。空間は、親とサイズの配列でO(n)です。
種類と、よくある間違い
- 根ではなく要素そのものを比較する:
unionは、aとbそのものではなく、find(a)とfind(b)をつなぐ必要があります。 parent[x]が代表だと思い込む。併合を行ったあとで正しいのはfind(x)だけです。親でグループ分けする前に、すべての要素にfindを呼びましょう。- ランクによる併合なしで、大きな入力に再帰的な
findを使うと、Pythonの再帰の上限に達することがあります。上のような反復版なら避けられます。 - 要素が0からn-1の整数ではない場合:先に
dictでインデックスに対応付けるか、親をdictに格納して、要素を遅延的に追加します。 - Union-Findはグループを分割できません。辺が削除される場合は、別のデータ構造か、削除を逆順に処理するオフラインの手法が必要です。
- 重み付きUnion-Findは、各ノードに親へのオフセットを保存し、「aはbよりどれだけ重いか?」といった問いに答えます。
使いどころと、典型的な問題
グループが併合されるだけで、2つの要素が同じグループにあるか、あるいはグループがいくつあるかを知りたいときに、Union-Findを使います。静的なグラフで連結性を1回だけ調べるなら、素のBFSやDFSのほうが単純です。実際の経路が必要なときは、Union-Findが分かるのは所属だけなので、グラフ探索を使いましょう。
典型的な問題:連結成分の数、島の数(グリッドのセルを要素とする)、冗長な接続(閉路を作る辺)、アカウントの併合、a == b, b != cのような等式の充足可能性、クラスカル法による最小全域木などです。
よくある質問
Union-FindとDSUの違いは何ですか?
同じデータ構造の2つの名前です。素集合データ構造(disjoint set union、DSU)や、素集合森(disjoint-set forest)という名前もよく使われます。
Union-Findがほぼ O(1) なのはなぜですか?
ランクによる併合と経路圧縮を使うと、1操作あたりの償却コストはO(α(n))になります。αは逆アッカーマン関数で、実用的な入力サイズでは4を超えません。
ランクによる併合と経路圧縮の両方が必要ですか?
どちらか片方だけでも償却O(log n)になり、通常は十分に高速です。両方を使うと定数に近い上界が得られ、必要なコードは数行増えるだけです。
Union-Findで、グループのメンバーを知ることはできますか?
直接はできません。すべての要素にfindを呼び、根でグループ分けすればO(n α(n))でできます。あるいは、根ごとにメンバーのリストを持ち、union時にリストを併合する方法もあります。
ランクによる併合とサイズによる併合のどちらがよいですか?
どちらも漸近的な上界は同じです。各グループのサイズ自体が役に立つ情報なので、サイズによる併合のほうが好まれることが多いです。