Skip to content →

Union Find

「集合の合体(union)」と「要素の属す集合の発見(find)」を効率的に行うデータ構造 Union Find の解説をします。Union Find では次のクエリが高速に処理できます。

要素が相異なる一元集合が \(n\) 個あります。次の2種類のクエリが何度も与えられるので、処理してください。

  • \(\mathrm{find}(a)\) : 要素 \(a\) の属す集合(の代表元)を見つけてください。
  • \(\mathrm{union}(a, b)\) : 要素 \(a, b\) の属す集合を合体して、一つの集合にしてください。
  • \(\mathrm{equiv}(a, b)\) : 要素 \(a, b\) が同じ集合に属すか判定してください。

\(\mathrm{equiv}(a, b)\) は \(\mathrm{find}(a), \mathrm{find}(b)\) が等しいかどうかによって判定できます。

計算量はアッカーマンの逆関数 \(\alpha\) を用いてならし \(O(\alpha(n))\) 、最悪 \(O(\log(n))\) となります。

アルゴリズム

集合を根付き木で表します。同じ集合の元は同じ木の頂点です。

  • 集合 ↔ 木
  • 集合の要素 ↔ 木の頂点
  • 集合の代表元 ↔ 木の根

例えば、\(\{1,4\},\{5,6,8\},\{2\},\{1,3,9\}\) に対応する Union Find の木の一つに次のようなものがあります。代表元はそれぞれ \(4, 6, 2, 3\) です。

union

union(a, b) は a, b の属す二つの木を一つの木に合体させればよいです。一方の木の根を、もう一方の木の根の子とします。

find

find(a) は a の属す木の根(集合の代表元)を見つければよいです。a から親を辿れば height(a) ステップで根が見つかります。

union(a, b) は二つの木の根を繋ぐので、find(a) と find(b) を内包しています。根が見つかれば、繋ぐこと自体は \(O(1)\) です。従って find の計算量は union に完全に依存します。find は木の高さだけ計算量が必要でした。そこで、木の高さをできるだけ小さくすると find, union の計算量が小さくなります。木の高さを小さくするための工夫として、次に述べる Union by Size/Rank と Path Compression/Halving/Splitting があります。

ちなみに、ここでは紹介しませんが、根同士のリンクと find の計算量を平衡させて最悪計算量を最小化する k-uf という変種もあります。

Union By Size/Rank

木の頂点数が小さい方を子として、もう一方の木の根につなぐのが Union by Size です。また、木の高さが低い方を子として、もう一方の木の根につなぐのが Union by Rank です。

両方とも、単体では実時間 \(O(\log(n))\) / クエリ になります。これは、Union by Size/Rank では、親を辿るたびに部分木の大きさが二倍以上になり、木の高さが \(\log_2(n)\) で抑えられるからです。Union by Rank の場合、高さ \(k\) の木は高さ \(k-1\) の2つの木を Union したときにできます。従って帰納法により、親を辿るたびに部分木のサイズが二倍になることが言えます。Union by Size は構成法から明らかです。詳しい証明はUnion Find の計算量をご参照ください。

Find における高速化

find における高速化として、path compression, path halving, path splitting が知られています。いずれを使った場合も結果的な計算量は同じになるので、どれを使うかは好みだと思います。それぞれ単体ではならし \(O(\log(n))\) になり、union by size/rank と組み合わせるとアッカーマンの逆関数 \(O(\alpha(n))\) になります。詳しくは Union Find の計算量 をご覧ください。

Path Compression

find(x) としたとき、x から根までのパス上の頂点全てを、根に直接つなぐのが path compression です。C++のコードは次の通り。

int find(int x) {
    if (parent[x] == x) return x;
    else {
        parent[x] = find(parent[x]);
        return parent[x];
    }
}

Path Halving

find(x) としたとき、x と根を繋ぐパス上の頂点のうち高さ偶数のものを、祖父(親の親)につなぐのが path halving です。根の親は自分自身であるとすると実装が簡素になります。非再帰で書きやすいという特徴があります。C++のコードは次の通り。

int find(int x) {
    if (parent[x] == x) return x;
    else {
        parent[x] = parent[parent[x]];
        return find(parent[parent[x]]);
    }
}

Path Splitting

find(x) としたとき、x と根を繋ぐパス上の頂点を祖父(親の親)につなぐのが path splitting です。根の親は自分自身であるとすると実装が簡素になります。path halving と同じく、非再帰で書きやすいという特徴があります。C++のコードは次の通り。

int find(int x) {
    if (parent[x] == x) return x;
    else {
        int next = parent[x];
        parent[x] = parent[parent[x]];
        return find(next);
    }
}

C++の実装

union by size と path-splitting で実装した C++ のコードは次の通りです。union は予約語だったので merge にしています。


#include "vector"

using namespace std;

struct UnionFind {
  vector<int> parent; // parent[x]=y のとき、x の親が y
  vector<int> size;
  
  UnionFind(int n) {
    parent.resize(n);
    size.resize(n);
    for (int i = 0; i < n; ++i) parent[i] = i;//根は自分自身が親とする。
  }
  // x の属す集合の代表元を返す
  int find(int x) {
      if (parent[x] == x) return x;
      else {
          parent[x] = parent[parent[x]];// x の親を x の祖父とする
          return find(parent[parent[x]]);
      }
   }
  // x, y が属す集合を合体する
  void merge(int x, int y) {
    x = find(x);
    y = find(y);
    if (x == y) return;
    // マージテク
    if (size[x] < size[y]) swap(x, y);
    size[x] += size[y];
    parent[y] = x;
  }
  
  // x, y が同じに属すならtrue, さもなくば、false
  bool equiv(int x, int y) {
    return find(x) == find(y);
  }
};

演習問題

グラフ\(G\)が二部グラフであるか判定せよ。

解答 : 隣接する頂点が同じ色にならないような、白と黒の2色の塗り分けが存在するか判定すれば良い。つまり、任意の辺 \((x, y)\) について、

  • \(x\) は白、\(y\) は黒
  • \(x\) は黒、\(y\) は白

のどちらかが成り立てば二部グラフである。各頂点 \(v\) について、白で塗った \(v_1\) と黒で塗った \(v_2\) を用意する。各辺 \(x, y\) について、\(x_1\) と \(y_2\)、\(x_2\) と \(y_1\) を同じグループに合体する。\(v_1, v_2\) が同じグループになるような頂点 \(v\) が存在しなければ2部グラフである。

同じ頂点集合上の2つのグラフ \(G_1, G_2\) が与えられる。\(G_1, G_2\) の両方で連結な頂点の組 \((x, y)\) は何個存在するか。(出典 : ABC049 D)

解答 : グラフ \(G\) の連結成分の頂点集合を管理する Union Find の find を \(\mathrm{find}_{G}\) と書くことにします。
\(G_1, G_2\) の両方で \((x, y)\) が連結 \(\Leftrightarrow\) \((\mathrm{find}_{G_1}(x), \mathrm{find}_{G_2}(x))=(\mathrm{find}_{G_1}(y), \mathrm{find}_{G_2}(y))\) です。従って、\((\mathrm{find}_{G_1}(x), \mathrm{find}_{G_2}(x))\) が等しいものの個数を数えればよいです。基数ソートにより、この組は \(O()\) でソートできるので全体の計算量は \(O(|V|+|E|\alpha(|V|))\) です。

\(1, 2,\ldots, N\) を要素とする一元集合がある。次のクエリがまず与えられる。

  • equiv(x, y, w) : \(0 \leq i < w\) について union(x+i, y+i)を行え。

その後、次のクエリを処理せよ。

  • equiv(x, y) : x, y は同じ集合に属すか判定せよ。

(出典 : みんなのプロコン 2018決勝D)

解答 : Union Find を \(\log_2(N)\) 個用意して、i 番目の Union Find を uf(i) と置く。uf(i).equiv(\(x, y\)) \(\Leftrightarrow\) \(x+j, y+j\) (\(0 \leq j < 2^i\)) が同じ集合とする。すると、sparse table と同様にすれば、高々2個の union でこの問題の連続する union が表せる。具体的には、\(w \leq 2^i\) となる最小の \(i\) を用いて、

  • uf(i).union(\(x, y\))
  • uf(i).union(\(x+w-1-2^i, y+w-1-2^i\))

とすれば良い。union が全て済んだら、その後、\(i=1\) まで伝搬すれば良い。

関連記事

Union Find の計算量
最悪計算量を最小化した Union Find
部分永続 Union Find

一番好きなデータ構造かも。

Published in データ構造

Comments

コメントを残す

メールアドレスが公開されることはありません。

CAPTCHA