コンピュータサイエンスにおいて、互いに素な集合データ構造(ユニオン検索データ構造またはマージ検索セットとも呼ばれる)は、互いに素な(重複しない)集合の集合を格納するデータ構造です。言い換えれば、集合を互いに素な部分集合に分割したものを格納します。このデータ構造は、新しい集合の追加、集合のマージ(和集合との置き換え)、および集合の代表要素の検索といった操作を提供します。最後の操作により、任意の2つの要素が同じ集合に属するか、異なる集合に属するかを効率的に判断できます。
互いに素な集合データ構造を実装する方法はいくつかありますが、実際には、それらはしばしば「互いに素な集合フォレスト」と呼ばれる特定の実装と同一視されます。この特殊なタイプのフォレストは、結合と検索操作をほぼ一定の償却時間で実行します。n個のノードを持つ互いに素な集合フォレストでm回の加算、結合、または検索操作を実行する場合、必要な合計時間はO ( mα ( n ))となります。ここで、α( n )は非常にゆっくりと増加する逆アッカーマン関数です。互いに素な集合フォレストは、操作ごとにこの時間を保証するものではありませんが、各操作は(ツリー圧縮によって)構造を再調整するため、後続の操作が高速化されます。その結果、互いに素な集合フォレストは漸近的に最適であり、かつ実用的にも効率的です。
互いに素な集合データ構造は、グラフの最小全域木を求めるクラスカルのアルゴリズムにおいて重要な役割を果たします。最小全域木の重要性から、互いに素な集合データ構造は多種多様なアルゴリズムをサポートしています。さらに、これらのデータ構造は、記号計算やコンパイラ、特にレジスタ割り当て問題において応用されています。
互いに素な集合の森は、1964年にバーナード・A・ギャラーとマイケル・J・フィッシャーによって初めて記述されました。 [ 2 ] 1973年には、その時間計算量は制限されました。の反復対数ホップクロフトとウルマンによる。[ 3 ] 1975年、ロバート・タージャンは、(逆アッカーマン関数)アルゴリズムの時間計算量の上限。[ 4 ]彼はまた、それがタイトであることを証明した。1979年、彼はこれがガラー・フィッシャー構造を含む特定のクラスのアルゴリズム、ポインタアルゴリズムの下限であることを示した。[ 5 ] 1989年、フレッドマンとサックスは、(償却された)言葉ビットは、操作ごとに任意の非連結集合データ構造によってアクセスされなければならない[ 6 ]。これにより、このモデルにおけるデータ構造の最適性が証明される。
1991年、ガリルとイタリアーノは、互いに素な集合のデータ構造に関する調査を発表した。[ 7 ]
1994年、リチャード・J・アンダーソンとヘザー・ウォルは、ブロックする必要のないUnion-Findの並列化バージョンについて説明した。[ 8 ]
2007年、シルヴァン・コンションとジャン=クリストフ・フィリアトルは、分離集合フォレストデータ構造の半永続版を開発し、証明支援システムRocq(当時はCoq)を使用してその正当性を形式化した。[ 9 ]「半永続」とは、構造の以前のバージョンが効率的に保持されるが、データ構造の以前のバージョンにアクセスすると、それ以降のバージョンが無効になることを意味する。彼らの最速の実装は、非永続アルゴリズムとほぼ同等の効率性を実現している。彼らは複雑性分析は行っていない。
限定されたクラスの問題でより優れたパフォーマンスを発揮する、互いに素な集合データ構造の変種も検討されています。Gabow と Tarjan は、可能な和集合が特定の方法で制限されている場合、真に線形時間のアルゴリズムが可能であることを示しました。[ 10 ]特に、「和集合木」が事前に与えられている場合は、線形時間を実現できます。これは、集合のすべての要素を含む木です。p[ v ] を木の親とすると、和集合演算は、あるvに対してunion ( v , p[ v ])の形式である必要があるという仮定があります。
本節および次節では、 親ポインタツリーのフォレストとして表現される、分離集合データ構造の最も一般的な実装について説明します。この表現はガラー・フィッシャーツリーとして知られています。
互いに素な集合の森の各ノードは、ポインタと補助情報(サイズまたはランクのいずれか、ただし両方ではない)で構成されます。ポインタは親ポインタツリーを作成するために使用され、ツリーのルートではない各ノードは親を指します。ルートノードを他のノードと区別するために、親ポインタには、ノードへの循環参照や番兵値などの無効な値があります。各ツリーは、森に格納されている集合を表し、集合のメンバーはツリー内のノードです。ルートノードは集合の代表を提供します。2 つのノードが同じ集合に属するのは、それらのノードを含むツリーのルートが等しい場合のみです。
フォレスト内のノードは、アプリケーションにとって都合の良い方法で格納できますが、一般的な手法は配列に格納することです。この場合、親ノードは配列のインデックスで指定できます。各配列エントリには、親ポインタ用にΘ(log n )ビットのストレージが必要です。エントリの残りの部分には、同等またはそれ以下のストレージが必要なので、フォレストを格納するために必要なビット数はΘ( n log n )です。実装で固定サイズのノードを使用する場合(これにより格納できるフォレストの最大サイズが制限されます)、必要なストレージはnに比例します。
互いに素な集合のデータ構造は、次の3つの操作をサポートします。新しい要素を含む新しい集合を作成する。指定された要素を含む集合の代表要素を見つける。2つの集合をマージする。
このMakeSet操作では、新しい要素のみを含む新しいセットに新しい要素が追加され、その新しいセットがデータ構造に追加されます。データ構造をセットの分割とみなす場合、このMakeSet操作は新しい要素を追加することでセットを拡大し、新しい要素のみを含む新しい部分セットに新しい要素を追加することで既存の分割を拡張します。
互いに素な集合の森では、MakeSetノードの親ポインタとノードのサイズまたはランクを初期化します。ルートが自身を指すノードで表される場合、要素の追加は次の擬似コードを使用して記述できます。
function MakeSet( x ) is if x is not already in the forest then x .parent := x x .size := 1 // ノードがサイズを格納する場合x .rank := 0 // ノードがランクを格納する場合end if end function
この操作は線形時間計算量を持つ。特に、n個のノードを持つ互いに素な集合の森を初期化するにはO ( n )の 時間が必要となる。
ノードに親ノードが割り当てられていないということは、そのノードがフォレスト内に存在しないことを意味します。
実際には、x をMakeSet保持するためのメモリを割り当てる操作が先行する必要があります。メモリ割り当てが、優れた動的配列の実装の場合のように、償却定数時間操作である限り、ランダムセットフォレストの漸近的なパフォーマンスは変わりません。
この操作は、指定されたクエリノードxFindから親ポインタの連鎖をたどり、ルート要素に到達するまで続きます。このルート要素は、 x が属する集合を表し、 x自身で ある場合もあります。そして、到達したルート要素を返します。Find
操作を実行することは、Findフォレストを改善する重要な機会となります。Find操作にかかる時間は親ポインタを追跡することに費やされるため、ツリーがフラットであればあるほどFind操作は速くなります。Find操作を実行する際、各親ポインタを順番にたどる以外にルートに到達する最速の方法はありません。しかし、この探索中に訪問した親ポインタは、ルートにより近い方向を指すように更新できます。ルートへの経路で訪問したすべての要素は同じセットの一部であるため、これによりフォレストに格納されているセットは変更されません。しかし、Findクエリノードとルートの間のノードだけでなく、それらの子孫ノードについても、今後の操作が速くなります。この更新は、非連結セットフォレストの償却パフォーマンス保証の重要な部分です。
漸近的に最適な時間計算量を実現するアルゴリズムはいくつか存在します。パス圧縮Findと呼ばれるアルゴリズム群では、クエリノードとルートノードの間のすべてのノードがルートノードを指すようにします。パス圧縮は、次のような単純な再帰を使用して実装できます。
function Find( x ) is if x .parent ≠ x then x .parent := Find( x .parent) return x .parent else return x end if end function
この実装では、ツリーを上方向と下方向の2回走査します。クエリノードからルートまでのパスを格納するのに十分なスクラッチメモリが必要です(上記の擬似コードでは、パスはコールスタックを使用して暗黙的に表現されています)。両方の走査を同じ方向に行うことで、必要なメモリ量を一定量に減らすことができます。一定メモリの実装では、クエリノードからルートまで2回走査します。1回目はルートを見つけるため、2回目はポインタを更新するためです。
function Find( x ) is root := x while root .parent ≠ root do root := root .parent end whilewhile x.parent ≠ root do parent := x.parent x.parent := root x : = parent end whileルートエンド関数を返す
TarjanとVan Leeuwenは、最悪ケースの複雑さFindは同じだが実際にはより効率的なワンパスアルゴリズムも開発した。 [ 4 ] これらはパス分割とパス半減と呼ばれる。どちらも、クエリノードとルート間のパス上のノードの親ポインタを更新する。 パス分割では、そのパス上のすべての親ポインタをノードの祖父母へのポインタに置き換える。
function Find( x ) is while x .parent ≠ x do ( x , x .parent) := ( x .parent, x .parent.parent) end while return x end function
パスの半減も同様に機能しますが、親ポインタを一つおきに置き換えるだけです。
function Find( x ) is while x .parent ≠ x do x .parent := x .parent.parent x := x .parent end while return x end function
MakeSet8つのシングルトンを作成します。Union、いくつかの集合がグループ化されます。この操作では、 xを含む集合とyを含む集合を、それらの和集合に置き換えます。 まず、xとyを含むツリーのルートを決定します。ルートが同じであれば、それ以上何もする必要はありません。そうでなければ、2 つのツリーをマージする必要があります。これは、xのルートの親ポインタをyの親ポインタに設定するか、yのルートの親ポインタをxの親ポインタに設定することによって行われます。Union(x, y)UnionFind
どのノードを親にするかという選択は、ツリーに対する今後の操作の複雑さに影響を及ぼします。不用意に選択すると、ツリーが過度に高くなる可能性があります。たとえば、xUnionを含むツリーをyを含むツリーのサブツリーに常にすると仮定します。要素で初期化されたばかりのフォレストから始めます。そして、、、、、を実行しますUnion(1, 2)。Union(2, 3)結果として得られるフォレストには、ルートがnである単一の木が含まれ、1 からnへのパスは木内のすべてのノードを通過します。このフォレストの実行時間はO ( n )です。Union(n - 1, n)Find(1)
効率的な実装では、ツリーの高さはサイズによる結合、またはランクによる結合によって制御されます。どちらの方法も、ノードが親ポインタだけでなく、他の情報も格納する必要があります。この情報は、どのルートが新しい親になるかを決定するために使用されます。どちらの方法も、ツリーが深くなりすぎないようにします。
サイズによる結合の場合、ノードはそのサイズ、つまりそのノード自身を含む子孫ノードの数を格納します。根ノードがxとyであるツリーがマージされると、子孫ノードの数が多い方のノードが親ノードになります。2つのノードの子孫ノードの数が同じ場合は、どちらでも親ノードになることができます。どちらの場合も、新しい親ノードのサイズは、そのノードの新しい子孫ノードの総数に設定されます。
function Union( x , y ) is // ノードをルートに置き換えるx := Find( x ) y := Find( y ) if x = y then return // xとyは既に同じセットに含まれているend if// 必要に応じて変数を交換し、// x が y と同じ数以上の子孫を持つよう にします。if x.size < y.size then ( x , y ) := ( y , x ) end if// x を新しいルートにするy.parent := x // xのサイズを更新する x.size := x.size + y.size end function
サイズを格納するために必要なビット数は、明らかにnを格納するために必要なビット数と同じです。これにより、フォレストに必要なストレージ容量に定数係数が加算されます。
ランクによる結合の場合、ノードは高さの上限であるランクを格納します。ノードが初期化されると、ランクはゼロに設定されます。ルートxとyを持つツリーをマージするには、まずそれらのランクを比較します。ランクが異なる場合、ランクの高い方のツリーが親となり、xとyのランクは変わりません。ランクが同じ場合、どちらが親になっても構いませんが、新しい親のランクは 1 ずつ増加します。ノードのランクは明らかにその高さと関連していますが、高さを格納するよりもランクを格納する方が効率的です。ノードの高さはFind操作中に変化する可能性があるため、ランクを格納することで、高さを正しく維持するための余分な作業を回避できます。擬似コードでは、ランクによる結合は次のようになります。
function Union( x , y ) is // ノードをルートに置き換えるx := Find( x ) y := Find( y ) if x = y then return // xとyは既に同じセットに含まれているend if// 必要に応じて、変数名を変更して、x のランクが y のランク以上になるようにします 。if x.rank < y.rank then ( x , y ) := ( y , x ) end if // x を新しいルートにするy.parent := x // 必要に応じて、x のランクをインクリメントするif x.rank = y.rank then x.rank := x.rank + 1 end if end function
すべてのノードにはランクがあることが示せる。またはそれ以下。[ 11 ]その結果、各ランクはO (log log n )ビット に格納でき、すべてのランクはO ( n log log n )ビットに格納できます。これにより、ランクはフォレストのサイズの漸近的に無視できる部分になります。
上記の実装例から明らかなように、ノードがツリーのルートでない限り、ノードのサイズとランクは重要ではありません。ノードが子ノードになると、そのサイズとランクは二度とアクセスされません。
ユーザーが生成された集合の代表値を指定する操作のバリエーションも存在するUnion。この機能は、効率を損なうことなく上記のアルゴリズムに追加することは難しくない。
Find親ポインタを更新せず、Union木の高さを制御しようとしない、互いに素な集合の森の実装では、高さがO ( n )の木を持つことができます。このような状況では、FindおよびUnion操作にはO ( n ) の時間が必要です。
実装がパス圧縮のみを使用する場合、n 回MakeSetの操作、それに続く最大n − 1 回Unionの操作、そしてf 回のFind操作のシーケンスの最悪実行時間は[ 11 ]
ランクによる結合を使用するが、その間親ポインタを更新しない場合Find、実行時間は次のようになる。任意のタイプのm個の操作に対して、そのうちn 個までがMakeSet操作である。[ 11 ]
パス圧縮、分割、または半分化と、サイズまたはランクによる結合の組み合わせにより、m個の任意のタイプの操作の実行時間が短縮されます。そのうちnMakeSet個は操作です。[ 4 ] [ 5 ] これにより、各操作の償却実行時間がこれは漸近的に最適であり、つまりすべての非連結集合データ構造はこれを使用する必要がある。操作ごとの償却時間。[ 6 ] ここで、関数は逆アッカーマン関数です。逆アッカーマン関数は非常にゆっくりと増加するため、この係数は、物理世界で実際に記述できる任意のnに対して4以下になります。これにより、互いに素な集合の操作は実質的に償却定数時間になります。
互いに素な集合の森のパフォーマンスを正確に分析することはやや複雑です。しかし、n個のオブジェクトを含む互いに素な集合の森に対する任意のmFind回の操作の償却時間はO ( m log * n )であることを証明する、はるかに単純な分析があります。ここで、log *は反復対数を表します。[ 12 ] [ 13 ] [ 14 ] [ 15 ]Union
補題1:find関数がルートに向かってパスをたどるにつれて、遭遇するノードのランクは増加する。
データセットに Find および Union 操作を適用しても、この事実は時間の経過とともに変わらないと主張します。各ノードが自身のツリーのルートである初期状態では、これは自明に真です。ノードのランクが変更される可能性があるのは、Union by Rank操作が適用される場合のみです。この場合、ランクの低いツリーがランクの高いツリーに接続されるのであって、その逆ではありません。また、find 操作中は、パスに沿って訪問されたすべてのノードがルートに接続されます。ルートは子ノードよりもランクが高いため、この操作によってもこの事実は変わりません。
補題2:ランクrのサブツリーのルートであるノードuは、少なくともノード。
最初は各ノードが自身の木の根である場合、これは自明に真です。ランクrのノードu が少なくとも2 r個のノードを持つと仮定します。すると、ランクrの 2 つの木が「ランクによる結合」操作を使用してマージされると、ランクr + 1の木が生成され、その根は少なくとも 2 r 個のノードを持ちます。ノード。

補題3:ランクrのノードの最大数は最大で
補題2より、ランクrのサブツリーのルートであるノードuは少なくともノード。ランクrの各ノードが、正確に を持つ木の根である場合、ランクrのノードの最大数が得られます。ノード。この場合、ランクrのノード数は
実行中の任意の時点で、グラフの頂点をランクに応じて「バケット」にグループ化できます。バケットの範囲は帰納的に次のように定義します。バケット 0 にはランク 0 の頂点が含まれます。バケット 1 にはランク 1 の頂点が含まれます。バケット 2 にはランク 2 と 3 の頂点が含まれます。一般に、B番目のバケットに区間内のランクの頂点が含まれる場合すると、(B+1)番目のバケットには、区間内のランクを持つ頂点が含まれる。
のために、 させて.それからバケット区間内のランクを持つ頂点を持つ。

バケツの大きさに関して、2つの点が観察できる。
Fは実行された「find」操作のリストを表し、
すると、 m 個の合計コストは
各検索操作はルートにつながる走査をちょうど 1 回行うので、T 1 = O ( m )となります。
また、上記のバケット数の上限から、T 2 = O ( m log * n )となります。
T 3では、 uからvへのエッジをたどっていると仮定します。ここで、uとvはバケット[ B , 2 B − 1]のランクを持ち、vはルートではありません (このたどっている時点では。そうでなければ、このたどりはT 1で考慮されます)。uを固定し、シーケンスを考えます。異なる検索操作でvの役割を果たすノード。パス圧縮とルートへのエッジを考慮しないことから、このシーケンスには異なるノードのみが含まれており、補題 1により、このシーケンスのノードのランクは厳密に増加していることがわかります。両方のノードがバケット内にあることから、シーケンスの長さk (ノードuが同じバケット内の異なるルートに接続されている回数) は、バケットBのランクの数以下、つまり最大で
したがって、
したがって、
ランクによる結合または重量による結合を行うFindツリーにおける操作の最悪ケース時間は(つまり、それはそしてこの境界はタイトです)。1985年、N. Blumはパス圧縮を使用しないが、ツリーを圧縮する操作の実装を提供しました。彼の実装は1回の操作あたりの時間[ 16 ]は、GallerとFischerの構造と比較すると、1回の操作あたりの最悪ケース時間は優れているが、償却時間は劣る。1999年、Alstrupらは、最悪ケース時間が最適な構造を提示した。逆アッカーマン償却時間と併せて。[ 17 ]
通常の実装では、要素の削除に対して好ましい反応は示さず、Find要素数の減少によって実行時間が改善されることはありません。しかし、定数時間での削除を可能にし、実行時間が現在の要素数Findに依存する最新の実装が存在します[ 18 ] [ 19 ]。
特定の非連結集合フォレスト構造を拡張してバックトラッキングを可能にすることは可能です。バックトラッキングの基本形式は、 Backtrack(1)最後の操作を取り消す操作を許可することですUnion。より高度な形式では、最後の i 個の和集合を取り消す操作を許可します。次の複雑性の結果が知られています。 とをBacktrack(i)サポートするデータ構造が存在します。UnionFind操作ごとの時間、Backtrackそして時間。[ 20 ]この結果では、形成されたセットの代表を選択する自由が不可欠です。分離可能なポインタアルゴリズムUnionのクラス内では、より優れた償却時間を達成することはできません。[ 20 ]

互いに素な集合のデータ構造は、集合の分割をモデル化します。たとえば、無向グラフの連結成分を追跡するために使用します。このモデルは、2 つの頂点が同じ成分に属しているかどうか、またはそれらの間にエッジを追加するとサイクルが発生するかどうかを判断するために使用できます。Union-Find アルゴリズムは、ユニフィケーションの高性能実装で使用されます。[ 21 ]
このデータ構造は、Boost Graph Libraryがインクリメンタル連結成分機能を実装するために使用されます。また、グラフの最小全域木を求めるクラスカル法を実装する上でも重要な構成要素です。
ホシェン・コペルマンアルゴリズムは、ユニオンファインドを使用します。
ユニオンファインドは、比較的高いパフォーマンスを発揮する型推論アルゴリズムを実装するために使用できる。
集合の和集合問題の
任意の CPROBE(log
n ) 実装は、
n個の単一集合から始めて、
m
回の Find と
n
−
1 回の Union を実行するために Ω(
m
α(
m
,
n
)) 時間を必要とします
。