数理情報系 4 年
甲本健太
互いに素な複数の集合を併合 (merge) していくプロセスが色々な計算に現れる. (c.f. クラスカル法)
ここで,問題を抽象化し,次の 2 つの操作ができる構造(素集合データ構造)を考える.
簡単のため,
それぞれ選ぶとする.
最も単純な実装方法は,右の図のように配列 set_name\mathrm{set\_name}set_name を用意し, j∈Sij\in S_ij∈Si を set_name[j]=i\mathrm{set\_name}[j] = iset_name[j]=i によって表すことである.
このとき,計算量は次ページのようになる.
FIND(j)\mathrm{FIND}(j)FIND(j) をする際には,set_name[j]\mathrm{set\_name}[j]set_name[j] の番号を読むだけで良い. →\to→ 定数時間で実行可能
MERGE(Si,Sk)\mathrm{MERGE}(S_i, S_k)MERGE(Si,Sk) を実行するには,
for j in range(N): # すべてのjに対し, if set_name[j] == i: # 集合名が S_i の集合を見つけたとき set_name[j] = k # 集合名を S_k に更新
を実行しなければならない.→\to→ 時間計算量は O(N)O(N)O(N)
各集合を右の図のように 2 つの配列 set\mathrm{set}set と element\mathrm{element}element を用意して表現する.
FIND(j)\mathrm{FIND}(j)FIND(j) をする際には,element.set[j]\mathrm{element.set}[j]element.set[j] の番号を読むだけで良い. →\to→ 定数時間で実行可能
MERGE(Si,Sk)\mathrm{MERGE}(S_i, S_k)MERGE(Si,Sk) を行う.新しい集合名を SkS_kSk とするとき,以下のアルゴリズムを用いる
set.size[k]=set.size[i]+set.size[k];set.first[k]=set.first[i];set.size[i]=0; set.first[i]=−1;// Si を空集合に更新\begin{align*} &\mathrm{set.size}[k] = \mathrm{set.size}[i] + \mathrm{set.size}[k];\\ &\mathrm{set.first}[k] = \mathrm{set.first}[i];\\ &\mathrm{set.size}[i] = 0; ~\mathrm{set.first}[i] = -1; &// ~S_i~ \text{\small を空集合に更新} \end{align*} set.size[k]=set.size[i]+set.size[k];set.first[k]=set.first[i];set.size[i]=0; set.first[i]=−1;// Si を空集合に更新
所要時間は O(∣Si∣)O(|S_i|)O(∣Si∣) である.
マージの際に以下のような工夫をすると,全体の計算量を大きく減らすことができる.
SiS_iSi と SkS_kSk を併合する際,常に小さい方の集合名を大きい方に修正することにする. このとき要素すべてを併合するのに要する時間は O(Mlog2N)O(M \log_2 N)O(Mlog2N) 時間である.
証明 要素 jjj に注目し,jjj が所属する集合が変更される回数を考える.jjj が所属する集合がマージされ,jjj の所属が変更されるとき,jjj が所属する集合の大きさは 222 倍以上になる.よって,全体の要素数が NNN のとき,要素 jjj の移動は高々 ⌊log2N⌋\lfloor\log_2 N\rfloor⌊log2N⌋ 回しか行われない. 併合の回数が最大 M−1M-1M−1 回であることを考慮すると,全体では O(Mlog2N)O(M\log_2 N)O(Mlog2N) 時間である
連結リストのように表記すると以下のようになる.
集合 SiS_iSi に所属するすべての要素を一つの木の節点として表し.そのような集合族を森のように実現することもできる.(先ほどとポインタの向きが逆なことに注意)
FIND(j)\mathrm{FIND}(j)FIND(j) を行うときは,節点 jjj から根までたどれば集合名がわかる. → 所要時間は jjj の深さに比例するが,要素数 NNN の対数で抑えられる.
SiS_iSi と SkS_kSk を併合するとき,常に小さい方を大きい方の子節点として加えていくことにする.このとき FIND(j)\mathrm{FIND}(j)FIND(j) 1 回の計算量は O(log2N)O(\log_2 N)O(log2N) である.
証明 節点 jjj が併合され,高さが変更されるとき,jjj を含む集合の大きさは 222 倍以上になるため,jjj の高さは高々 ⌊log2N⌋\lfloor\log_2 N\rfloor⌊log2N⌋ 回しか変更されない.一回の併合により節点の高さは 111 しか増えないことから,計算の途中で得られる任意の木 SiS_iSi の高さは ⌊log2N⌋\lfloor\log_2 N\rfloor⌊log2N⌋ 以下である.
さらに,FIND(j)\mathrm{FIND}(j)FIND(j) を行うときに,節点 jjj から根へのパス上にある節点をすべて根の子とする路の圧縮と呼ばれる操作を行うとより高速になることが知られている.
併合の工夫と路の圧縮を同時に行った場合,MERGE,FIND\mathrm{MERGE},\mathrm{FIND}MERGE,FIND をどのような順序で行っても mmm 回の FIND\mathrm{FIND}FIND 操作を O(mα(m,N))O(m\alpha(m,N))O(mα(m,N)) 時間で実行できる. →\rightarrow→ よって,併合全体の計算量は O(Nα(N))O(N\alpha(N))O(Nα(N)) 時間となる.
ただし α(m,N)\alpha(m,N)α(m,N) はアッカーマン関数の逆関数であり,非常にゆっくりと増加する.
例として,アッカーマン関数 AAA について,A(4,3)=2265536−3A(4,3) = 2^{2^{65536}} - 3A(4,3)=2265536−3 である.[1]
[1] https://ja.wikipedia.org/wiki/アッカーマン関数
1 つ目のwhile文で根を見つけ,2 つ目のwhile文で路の圧縮を行っている.
while
集合 i,ji,ji,j の根をそれぞれfindで調べ, ∣Si∣>∣Sj∣|S_i| > |S_j|∣Si∣>∣Sj∣ だった場合に i,ji,ji,j を交換することで,常に大きい集合(∣Sj∣|S_j|∣Sj∣)にマージするようにしている.
find
class UnionFind: def __init__(self, N): self._parent = list(range(N)) self._size = [1] * N def find(self, i) -> int: p = i while self._parent[p] != p: p = self._parent[p] # 路の圧縮 while self._parent[i] != i: i = self._parent[i] self._parent[i] = p return p def merge(self, i, j): i = self.find(i) j = self.find(j) if self._size[i] > self._size[j]: i, j = j, i # 併合 self._parent[i] = j self._size[j] += self._size[i] self._size[i] = 0