という前提で、
unite(x, y)
: 要素 x の属するグループと、y の属するグループを統合するis_same(x, y)
: 要素 x と y が同じグループに属するか調べるという2つのクエリを効率的に処理するのが、Union-Find木
Disjoint Set Union、略してDSUともいう。
1 4 8 ←1,4,8が代表 ↗↑ ↑ 2 5 6 ←2や5の親は1, 3の親は2 ↑ ↗ ↖ 3 7 9
これをもとに、先ほどの2つの操作を言い換えると、以下のようになる。
unite(x, y)
: 一方の代表を、もう一方の代表につなぐ(親子にする)is_same(x, y)
: x と y から親を辿って同じ代表に行き着くか調べるまた、そのために以下の操作も持つ。
root(x)
: x の根(代表)を取得するこれを根を見つける(Find)と捉え、2つの操作を合わせてUnion-Findとなる。
◎はやい ×おそい 1 1 ↗↑↖ ↑ 2 3 4 2 ↑ 3 ↑ 4
root(4)
操作によって4→3→2→1と調べて1が代表だとわかったら、2~4の親を1に書き換えるunite
時、ランクの低い方のグループを、高い方のグループにつなぐこの2つをuniteする場合| こうすると | 低い方に高い方をつなげると Rank:2 Rank:4 | Rank:4のまま | Rank:5になっちゃう 1 5 | 5 | 1 ↗↑↖ ↑ | ↑↖ | ↗↗↑↖ 2 3 4 6 | 1 6 |23 4 5 ↑ | ↗↗↑ ↑ | ↑ 7 | 23 4 7 | 6 ↑ | ↑ | ↑ 8 | 8 | 7 | | ↑ | | 8
上記の2つの改善を行って、unite, is_same, root
1回にかかる計算量はいずれも O(α(N)) となる(らしい)。
これはいずれもならし計算量で、何回もやると平均がこの値に近づいていく。
ただし α(N) はアッカーマン関数 Ack(N,N) の逆関数で、N が少し大きくなると急速に小さくなるので、ほとんど定数と考えてよい。
table
を作り、-1で初期化するroot
で代表を再帰的に探索する
unite()
のd1,d2は実際の値を正負逆転させているので、大小関係に注意。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# Python3 class UnionFind: def __init__( self , n): # 負 : 根であることを示す。絶対値はランクを示す # 非負: 根でないことを示す。値は親を示す self .table = [ - 1 ] * n def root( self , x): stack = [] tbl = self .table while tbl[x] > = 0 : stack.append(x) x = tbl[x] for y in stack: tbl[y] = x return x def is_same( self , x, y): return self .root(x) = = self .root(y) def unite( self , x, y): r1 = self .root(x) r2 = self .root(y) if r1 = = r2: return # ランクの取得 d1 = self .table[r1] d2 = self .table[r2] if d1 < = d2: self .table[r2] = r1 if d1 = = d2: self .table[r1] - = 1 else : self .table[r1] = r2 if __name__ = = '__main__' : ins = UnionFind( 4 ) ins.unite( 0 , 1 ) ins.unite( 2 , 3 ) print (ins.is_same( 0 , 3 )) # False ins.unite( 1 , 2 ) print (ins.is_same( 0 , 3 )) # True |
ランクは木の深さでなく、要素数で考えることもある。
実際、どちらを選んでも計算量は変わらない(経路圧縮を行う場合で O(α(N))、行わない場合で O(logN))し、速度は体感できるほど変わらない。
木の深さってあまり使い道が無いが、「x が含まれるグループの要素数」は任意の時点で取得できると嬉しい場合はある。
よって、ランクは要素数としておいた方が、何かと便利かも知れない。
unite()
の中身が少し変化する。また、get_size()
を追加。
上記の例では、グループの要素数はわかっても、具体的に現時点でのグループに含まれる要素はわからない。
通常の機能に加え、以下の機能も追加したい。
get_group(x)
: x が属するグループの要素一覧を取得大きく2つの実装がある。基本的には後者の方が実行は速いが、前者の方が視覚的にはわかりやすい。
unite()
の計算量が O(logN) に落ちる。unite()
の計算量は O(α(N)) のまま。
tableとは別にもう1つグループを管理する配列 G を用意する。初期状態は G[i]={i} のように、それぞれ自身1要素だけが含まれる集合(Set)である。
unite(i,j)
で連結成分をマージする際、G 上で i が含まれる集合と j が含まれる集合も統合していく。
unite(x,y)
でグループを統合する際は、G[root(x)] と G[root(y)] の2つのグループの要素数を比較して、大きい方へ小さい方を統合する。
unite()
部分のならし計算量は、O(α(N)) から O(logN) になる。
もはや“Tree”ではなくなるが、root()
を廃し、集合のマージだけで管理する実装もできる。
親を辿ったり経路圧縮などの実装も不要で、マージテクの知識だけで実装できる。
また、意外と(?)実装してみると①より速かったりする。
unite(x,y)
:is_same(x,y)
:get_group(x)
:
※PythonにおけるSet型の変数は、厳密にはSet型への参照である点がポイント。
G[z]=G[x] と代入した後で G[x] を更新した場合、G[z] にも同じ更新が反映される。
また、is_same()
で G[x]==G[y] としてしまうと比較に O(N) かかってしまうが、
G[x] is G[y] の場合は同じオブジェクトを参照しているかの判定なので、O(1) でおこなえる。
tableとは別にもう1つグループを管理する配列 L も用意して、実際に統合していく。
初期状態は L[i]=i である。(1要素のみの循環)
unite(i,j)
で連結成分をマージする際、L[i] と L[j] をスワップすることで、2つの循環をマージできる。
x が含まれるグループを取得する際は、x から L[x],L[L[x]],... を辿ることを繰り返して x に戻ってくるまでに訪れた要素が同一グループとなる。
Undo付きUnion-Findなどとも呼ばれる。履歴を持ち、直前で結合したグループを戻せる。
root(x)
: x の根を取得unite(x, y)
: x と y の所属グループを結合is_same(x, y)
: x と y が同じグループか判定rollback()
: 直前のuniteを取り消す(繰り返し実行した場合は更にその前の操作も順に取り消す)通常のUnion-Findに、rollbackの機能が加わったもの。
計算量は O(α(N)) から 若干悪化して O(logN) となるが、それでも十分高速。
Union-Findにおいてtableの変更が行われるのは、
root(x)
: 代表を取得するときの経路圧縮x
から代表に至るまでに辿った要素 v
の tabel[v]
が、全て代表に張り直されるunite(x,y)
: 結合table[x]
が新たな代表 y
になるy
の table[y]
のランク(or要素数)が更新されるこのうち、経路圧縮をおこなうのは以下の理由からやや難しい。
root()
はis_same()
やunite()
を呼んだときにも実行されることがあり、1回の「Undo」がどの操作を戻すことを指すのか分かりづらくなる
一方、結合時は変更が確実に「unite()
した時のみ」であり(これは「Undo」という操作の直感にも従う)、変更箇所も少ない。
「変更した要素 x,y、変更前の値 table[x],table[y]」の4つを、履歴を管理するためのスタックに積んでおけば復元できる。
細かな実装の違いとして、uniteしようとした結果、既に同グループだった場合、履歴に残すかどうかがあるが、 有効だったかどうかを呼び出し側が管理しないで済むので、基本的には残した方がよさそう。
過去の状態に遡って is_same
できる。英語では Partially Persistent Union-Find。
root(x, t)
: 時刻 t における x の根を取得unite(x, y)
: x と y の所属グループを結合し、内部時刻を1進めるis_same(x, y, t)
: 時刻 t において、x と y が同じグループか判定
更新は最新の状態に対してのみ行える。
(過去の状態からも更新可能にしたものは、「完全永続」という)
基本的に、クエリが先読みできるならis_same()
を適切なタイミングに割り込ませることで
通常のUnion-Findで対応可能なので、有用な場面は限られる?
こちらもRollback Union-Findと同様、経路圧縮は難しいので、計算量はいずれの操作も O(logN) となる。
通常のUnionFindではtableは現在値のみを持つところ、「(更新時刻, 更新値)のタプル」を基本単位として持つ。
機能(※)の有無により、必要な実装が若干異なる。
get_size(x, t)
: 時刻 t 時点の x が属するグループの要素数、など
uniteとis_sameしかできない実装を①、get_sizeなども可能な実装を②とする。
②はわずかに必要メモリが増加する。(追加で O(クエリ数) くらい。まぁ微差)
通常の初期化: table = [-1, -1, -1, ...] 値のリスト 部分永続①の初期化: table = [(0, -1), (0, -1), (0, -1), ...] (更新時刻, 更新値)のリスト 部分永続②の初期化: table = [[(0, -1)], [(0, -1)], [(0, -1)], ...] (更新時刻, 更新値)のリストのリスト (他に記録したい値があるならそれもタプルに含める) 部分永続①の更新: table[x] = (t, value) 上書き 部分永続②の更新: table[x].append( (t, value) ) リストにappendし、過去の値を残す
部分永続が通常のUnionFindと異なるのは、主に根を特定する処理 root(x, t)
。
少しテクニカルな考え方によって、「最終更新時刻と更新値」のみから根が特定できる。
table[x]
が更新されるのは、根である場合に限られるunite
によってある要素の子になったら、以降は更新されないなので、過去の履歴がなくても、最終の更新時刻と更新値があればよい。
また、部分永続②において get_size(x, t)
を求める場合は、以下でできる。(過去の履歴はここで必要になる)
table[r]
内を時刻 t で二分探索、t 以下で最も大きい更新時刻を特定全永続~~、あるいは単に永続~~ともいう。英語では Fully Persistent Union-Find。
root(x, t)
: バージョン t の x の根を取得unite(x, y, t)
: バージョン t の状態から、x と y を統合し、最新バージョンとするis_same(x, y, t)
: バージョン t で、x と y が同じグループか判定
部分永続とは異なり、unite
も t を引数に取るようになりどこからでも更新が可能になった。
(t は、時刻というよりバージョン、
あるいはもっと直接的に「何回目のunite
操作後の状態を示すか」と捉えた方がわかりやすいかもしれない。
t は、変更元の t に依らずに回数でナンバリングされていくので、
t−1 や t+1 との間に必ずしも関連性があるわけではないが、「時刻」だとあるかのように錯覚しかねないので)
部分永続Union-Findは、根しか更新対象にならないというUnion-Find特有の性質を上手く使っていたが、 完全永続Union-Findは、Union-Find特有と言うよりは、より汎用的な「永続配列」を使って実装する。
元のUnion-Findからの主な変更点は、 「状態を保持する配列 table を、永続配列に置き換える」ことくらいなので、 あまり完全永続Union-Find独自の特長として語ることはないか。
一応、Union-Findは一度のuniteでtable
配列を2箇所、書き換えるが、
永続配列を「どこか1箇所書き換えるたびにバージョンが1進む」ようになっていると、
Union-Findと永続配列でバージョンの進みがずれてきてしまうので、
「同時に複数箇所書き換えられる」ようにした永続配列を使えれば少し混乱しにくい。
未検証。
重み付きUnion-Findともいう。
頂点間の「距離」も管理できるUnion-Find。
「各頂点 i に何らかの値 Wi が決まっているが直接は観測できなくて、頂点間の差分だけがわかる」というときに、 矛盾するかしないか、しない場合に指定した2頂点間の差分はいくつになるか、を求める。
① ④ -2↗↖5 ↑3 ② ③ ⑥ 6↑ ↑1 ⑤ ⑦ ② - ① = -2 ③ - ① = 5 ⑤ - ① = 4 ③ - ② = 7 ⑦ - ⑤ = 2 ⑥ - ② = 不定(同じ連結成分では無いため)
以下のような特徴を持ったデータ構造である。
root(x)
: x の根を取得unite(x, y, d)
: x と y の所属グループを、Wy−Wx=d となるように統合(あるいは矛盾を検知)is_same(x, y)
: x と y が同じグループか判定diff(x, y)
: x と y が同じグループか判定、同じなら値の差分(Wy−Wx)を取得
通常のUnion-Findに加え、親を 0 とした時の各頂点の値を記録していく。
根(代表)まで辿っていけば、「根を 0 とした時の自身の値」がわかり、同一グループ内なら比較ができるようになる。
値の更新に気をつければ経路圧縮もできるので、各計算量は O(α(N)) となる。