競技プログラミング練習会

2019 Normal

第7回 グラフ表現,構造体, Union-Find木

担当 :  laft

自己紹介

自己紹介

  • 京都大学工学部情報学科2回生
  • 本名:平井 雅人
  • C/C++を普段使ってます。
  • 最近codeforcesとお絵描きデビューしました。
  • Charlotteが好き

KMC-ID:laft

今日の内容

今日の内容

  • グラフとは
  • グラフ表現
    • 隣接リスト
    • 隣接行列
  • プログラミング基礎IV:構造体
  • Union-Find 木

グラフ理論is何?

グラフとは

  • グラフっていうと、思い浮かべるのは棒グラフ,折れ線グラフ,etcだと思う。
  • 今日やるのは、それとは全然違います!!

グラフとは(概要)

  • グラフ理論というのがあります。
  • そこで言うグラフは、2次元上の頂点同士を辺で結んだ図のこと。
  • 競プロでのグラフは主にこっち

こんなの→

グラフ理論

目次:グラフ理論(リンク付き)

グラフの定義

  • 集合V : 頂点(vertex), 節点(node)
  • 集合E : (edge),枝,(弧)
    • \(E\subset \{(x,y)\in V\times V\}\)
      • \((x,y)\in E\) は頂点xとyを結ぶ辺。
      • 要はEは二頂点の集合ってこと。
  • 図で描くときはさっきみたいに丸(or四角)を線分か矢印で繋ぐ。
  • グラフは集合G=(V,E)として表せる(集合の集合)
  • これからグラフでよく出てくる用語の説明をします

グラフの例

  • V={1,2,3,4,5}
  • E={(1,2),(1,4),(2,3),(2,4),(2,5),(3,4),(4,5)}

辺の向き

  • 有向グラフ
    • 辺に向きがあるグラフ。
    • 図では辺を矢印で表現。
  • 無向グラフ
    • 辺に向きがないグラフ。
    • 図では辺を線分で表現。
    • さっきまでのグラフはこっち。

有向グラフ

無向グラフ

部分グラフ

  • 部分グラフ
    • 元のグラフからいくつかの頂点といくつかの辺を取ってきたグラフ
      • \(G'=(V',E'),V'\subset V,E'\subset E\)
  • 全域部分グラフ
    • 元のグラフから全ての頂点といくつかの辺を取ってきたグラフ
      • \(G'=(V,E'),E'\subset E\)

G=(V,E)

隣接

  • 隣接
    • uとvが辺で繋がっている時、「頂点u,vは隣接している」と言う
      • \((u,v)\in E\)
  • 頂点vの隣接点
    • vと隣接してる頂点
    • \((u,v)\in E\)のとき、uとvは互いの隣接点

G=(V,E)

完全グラフ

  • 完全グラフ
    • 全ての頂点が互いに隣接している。
    • \(n\)頂点完全グラフの辺の数は\(\frac{n(n+1)}{2}\)

特殊な辺

  • ループ(自己辺)
    • 両端の頂点が同じ
  • 多重辺
    • 同じ辺が複数
  • 単純グラフ(simple graph)
    • ループと多重辺を含まないグラフ

次数

  • 頂点vの次数
    • vに繋がっている辺の個数
  • 孤立点
    • 次数0の頂点

入次数・出次数

  • 有向グラフの時のみの概念
    • 入次数
      • 自分に向かう辺の数
    • 出次数
      • 自分から出て行く辺の数

2の入次数は2

2の出次数は1

道(パス)

  • (path)
    • いくつかの頂点をある順序で通る通り道
    • 元のグラフの部分グラフ\(P=(V,E)\)のこと
      • \(V=\{v_0,v_1,..,v_k\}\)
      • \(E=\{(v_0,v_1),..,(v_{k-1},v_k)\}\)
    • 通る順序が分かれば良いので、頂点を通る順に並べられる
    • 始点終点の2点をまとめて端点と呼ぶ

道 {4,1,2,3}

閉路

  • 閉路(cycle)
    • 始点と終点が同じ
    • つまり、ぐるっと一周できる道

道{4,5,2,1,4}

距離

  • 頂点uからvまでの距離
    • 頂点uからvまでの最小の辺の数。
    • 辺に長さ(重み)がある場合もある(そのときの距離は最小の長さの和)

連結

  • 連結
    • どの2つの頂点間にも道が存在するグラフ
    • 要は、全頂点が間接的に繋がってるグラフ

連結成分

  • 連結成分
    • 空でなく、極大な(これ以上大きくできない)連結な部分グラフ

  • その辺を除くと、グラフが連結でなくなるような辺。

    • 連結で閉路のないグラフ
      • 多重辺,ループ\(\in\)閉路なのでない
      • cf. アルカン
    • 任意の2頂点間に道が1つしかない
    • \(|V|-1\)本の辺を持つ。
    • 木の中の次数が1の点

根つき木

  • 根つき木
    • 根という特別な頂点を定めた木
    • 一般に根を上に持ってきて図示する
    • uとvが隣接していて、uの方が根に近い(上にある)とき、
      • uをvの親と呼ぶ
      • vをuの子と呼ぶ
      • 家系図みたいな雰囲気
    • 家系図とか樹形図が根つき木に当たる。

根つき木

さっきの木の図で2を根にした

根つき木

根つき木:補足

  • 1つの頂点に対して親が2つ以上あることはあり得ない。
    • 閉路がないため。
    • 1つの辺を持ち上げると、他の辺は全て下側に落ちるため。

グラフ表現

グラフ表現

  • さっきまでやってたグラフをプログラミングでどのように表現(実装)するの???
  • 要は頂点と辺の状態があれば良い。

グラフ表現:目次

  1. 隣接行列
  2. 隣接リスト (重要!)
  3. 辺の集合で管理
  4. 接続行列
  5. (発展)ラプラシアン行列

1. 隣接行列

サイズ\(|V|\times |V|\)の二次元配列\(A=a_{uv}\)

{\large a_{uv}=} \begin{cases} 1&\text{(u,vが隣接している)}\\ \infty&\text{(u,vが隣接していない)} \end{cases}
  • 計算量大きくなりがち
  • \(a_{uv}=w_{uv}\)とすれば簡単に辺の重みに対応できる。
  • 多重辺には対応できない。
  • \(a_{uv}= a_{vu}\)とすれば無向辺、\(a_{uv}\)≠ \(a_{vu}\)とすれば有向辺に対応可。

1. 隣接行列_図(無向辺)

const int INF = 1e7;  //とても大きい値を無限としておく。
int V,E;
cin >> V >> E;  //Vは与えられた頂点数,Eは辺の数
vector<vector<int>> a(V,vector<int>(V,INF));//V*Vを無限で初期化
for(int i=0;i<E;++i){
    //s→tへの有向辺として入力が与えられたとする。//wは重み
    int s,t;       //,w
    cin >> s >> t; //>>w
    a[s][t] = 1;   //a[s][t] = w;
    //a[t][s] = 1; (s-tの無向辺の場合)
}

1. 隣接行列_コード例

有向辺の時はa[s][t]をs→tと設定しよう。

無向辺の時は逆向きにも有向辺を張って対応しよう

2. 隣接リスト

サイズ\(|V|\)の配列\(A=a_{u}\)

a_{u}=
  • 計算量的にこれを使うことがかなり多い。
  • 辺の重みに対応するときは工夫が必要。
    • \(a_u\)に(頂点番号,重み)のペア(or構造体)を入れるなど

{uと隣接してる頂点たち}

a[1] = {2,4}

a[2] = {3,4,5}

a[3] = {2,4}

a[4] = {1,2,3,5}

a[5] = {2,4}

2. 隣接リスト_図(無向辺)

2. 隣接リスト_コード例

int V,E;
cin >> V >> E;  //Vは与えられた頂点数,Eは辺の数
vector<vector<int>> a(V);//V個の空ベクトルを含むベクトル
for(int i=0;i<E;++i){
    //s→tへの有向辺として入力が与えられたとする。
    int s,t; 
    cin >> s >> t;
    a[s].push_back(t);
    //a[t].push_back(s); (s-tの無向辺の場合)
}

このとき、a[u]にはuと隣接している頂点の配列が入っている。

3. 辺の集合で管理

サイズ\(|E|\)の集合\(A\)

A=
  • グラフの定義に割と近い
  • (u,v)と(v,u)を区別すれば有向辺でもOK
  • 入力は大抵この形式なので、隣接リストとかに変換しよう。
  • そのまま使うことはそんなになさそう…
  • 全ての辺に何かしたいときは使うかも

{辺の頂点のペア(u,v)たち}

3. 辺集合_図(無向辺)

4. 接続行列

サイズ\(|V|\times |E|\)の二次元配列\(A=a_{ve}\)

  • これもそんなに使わない…
  • 配列の各(縦の)列が一辺を表す。
    • なので各列の2つだけが1
    • (s,t)という辺ならs行目とt行目が1
  • 片方を-1とかにすれば有向辺にもできる。
{\large a_{uv}=} \begin{cases} 1&\text{(e番目の辺が頂点vを含む)}\\ 0&\text{(それ以外)} \end{cases}

4. 接続行列_図

5. (発展)ラプラシアン行列

サイズ\(|V|\times |V|\)の二次元配列\(A=a_{uv}\)

  • 隣接行列の進化版
    • 1の代わりに-1を使ったり、対角成分に次数を入れたり
  • 色んなメリットがあるらしいけど、必要になった時に改めて学べば良さそう
    • グラフが連結\(\Leftrightarrow\) rankA \(= |V|-1\)
    • (行列木定理)Aの任意の余因子は全域木の個数と等しい
{\large a_{uv}=} \begin{cases} \text{頂点u(=v)の次数}&\text{(u=v)}\\ -1&\text{(u,vが隣接している)}\\ \infty&\text{(else)} \end{cases}

5. ラプラシアン行列_図

グラフ表現まとめ

  • 色々やったけど、まずは隣接リストを習得しよう。
  • 多分今日やる問題は隣接リストを使う問題。

構造体struct

構造体is何

  • 自分で型(みたいなの)作りたい!と思ったことありますよね!!!
    • 例えば1つの変数に2つ以上の値を持っときたい時とか
  • 他にも、その構造体の中で使える関数も定義できる!
  • クラスみたいなもの(分かる人向け)

構造体の書き方

struct person{
    string name;
    int age;
    void hoge(){}
};
  • struct <構造体名> { };で構造体を宣言
  • {}の中に変数や関数の宣言をする。
  • これだけ!

構造体の使い方

struct person{
    string name;
    int age;
};
int main(){
    person p;
    p.name = "Mike";
    p.age = 29;
    cout << p.name << " " << p.name << endl;
}
  • main関数のなかで型と同様に宣言できる
  • '.'で構造体の中の変数(メンバ変数と呼ぶ)にアクセスできる

Mike 29

出力:

構造体の使い方2

struct person{
    string name;
    int age;
    void print(){
        cout << name << " " << age << endl;
    }
};
int main(){
    person p;
    p.name = "Mike";
    p.age = 29;
    p.print();
}
  • '.'で構造体の中の関数(メンバ関数と呼ぶ)にアクセスできる

Mike 29

出力:

構造体の代入(リスト)

struct person{
    string name;
    int age;
};
int main(){
    person p = {"Mike",29};
    cout << p.name << " " << p.name << endl;
}
  • 初期化子リストを使って、順番が分かってれば代入できる。

Mike 29

出力:

構造体の代入

struct person{
    string name;
    int age;
};
int main(){
    person p = {"laft",19};
    person q = p;
    cout << q.name << " " << q.age << endl;
}
  • 同じ構造体なら'='で代入できる。

laft 19

出力:

コンストラクタ

struct person{
    string name;
    int age;
    person(string s,int a){
        name = s;
        age = a;
    }
};
int main(){
    person p("laft",19);
    cout << p.name << " " << p.age << endl;
}
  • 宣言時に変数名に()をつけて初期化する。
  • これを関数のようにして構造体内で定義(戻り値はないので型はない,関数名の代わりに構造体名を書く)

laft 19

出力:

構造体の配列

struct person{
    string name;
    int age;
};
int main(){
    vector<person> ps(1); //サイズ1のpersonのvectorを宣言
    ps[0] = {"aotsuki",21}; //代入
    ps.push_back(person{"laft",19});  //push_back!!
    cout << ps[0].name << " " << ps[1].name;
}

aotsuki laft

出力:

隣接リスト(重み付き,有向)

struct edge{
    int to,weight;
};
int main(){
    int V,E; cin >> V >> E;  //Vは与えられた頂点数,Eは辺の数
    vector<vector<edge>> a(V);//V個の空ベクトルを含むベクトル
    for(int i=0;i<E;++i){
        //s→tへの有向辺で重みwとして入力が与えられたとする。
        int s,t,w; cin >> s >> t >> w;
        a[s].push_back(edge{t,w});
    }
}
  • 二次元配列にedgeという行き先と重みを持つ構造体を作って入れる。
  • これにより、重みを実装した隣接リストが組める。

構造体まとめ

  • メンバ変数+メンバ関数=構造体(ほんまか)
  • 型のように扱える(メンバ関数があるのでvectorとかに近いかな)
  • こういう型があると便利だなと思った時に、自分で作ってしまおう!
  • 注意:構造体の定義時の最後のセミコロンを忘れがちなので気をつけよう。

構造体の補足

構造体の補足

  • 完成!!!!!!!!!!!!
  • 頑張って作ったので読んでね!!!

アロー演算子

  • 構造体の配列を使う時、イテレータからメンバ変数にアクセスしたい時に使う。
  • itr->nameでitrの示す要素(構造体)のメンバ変数に行ける。
  • ->が矢印みたいなのでアロー演算子
struct person{
    string name;
    int age;
};
int main(){
    vector<person> ps;
    // ... (psに入力などしたとする)
    auto itr = ps.begin();
    //itr->nameは(*itr).nameと同じ
    cout << itr -> name << endl;
}

コンストラクタの別の書き方

  • コンストラクタの初期化文の書き方に、メンバ初期化子を用いる方法がある。
  • これを行うと、さっきまでのコンストラクタの代入と同様に初期化できる。{}の手前に":"をおいて書く。
struct person{
    string name;
    int age;
    //person(string s,int a){name=s;age=s;}と等価
    person(string s,int a):name(s),age(a){}
};
int main(){
    person p("laft",19);
    cout << p.name << " " << p.age << endl;
}

こっちを使う利点は競プロではそんなにないけど、人のコードを見るとき用に知っておこう。

コンストラクタの他の使い方

  • "構造体名(コンストラクタ)"とすると、それは一時的な構造体となる。
  • よってそれをそのまま代入できる。
struct person{
    string name;
    int age;
    person(string s,int a):name(s),age(a){}
};
int main(){
    person p("laft",19);
    p = person("aotsuki",21);
    cout << p.name << endl;
}

aotsuki

出力:

コンストラクタの他の使い方

  • "構造体名(コンストラクタ)"とすると、それは一時的な構造体となる。
  • よってそれをpush_backすることもできる
struct person{
    string name;
    int age;
    person(string s,int a):name(s),age(a){}
};
int main(){
    vector<person> ps;
    ps.push_back(person("laft",19));
    cout << ps[0].name << endl;
}

laft

出力:

コンストラクタのデメリット

  • コンストラクタを使わないとき、構造体を宣言すると、自動的に型のデフォルト値が入る。
    • 例えばstringだと""(空文字列),intだと0
  • コンストラクタを使ってしまうと、これを暗黙的にしてくれなくなる。
  • つまり、宣言だけを行うことができなくなる。
struct person{
    string name;
    int age;
    person(string s,int a):name(s),age(a){}
};
int main(){
    person q = person("aotsuki",21);//これはok
    person p; //error,初期値が分からないため
    p = person("laft",19);
}

コンストラクタのデメリット

  • コンストラクタを使わないときの例
struct person{
    string name;
    int age;
    //person(string s,int a):name(s),age(a){}
};
int main(){
    person p; //ok,初期値は暗黙的に代入される
    p = {"laft",19};//コンストラクタは宣言してないので
    cout << p.name << endl;
}

laft

出力:

コンストラクタのデメリット(配列

  • コンストラクタを使うと、宣言だけを行うことができなくなる。
  • 要素数のみを指定したvectorも使えない。
  • 要素数0で後からpush_backする分には問題ない。
struct person{
    string name;
    int age;
    person(string s,int a):name(s),age(a){}
};
int main(){
    vector<person> ps(3); //error!3つの要素に初期値を与えられない
    vector<person> ps(3,person("laft",19)) //ok!初期値を指定してる
    vector<person> ps;    //ok! 要素がないので初期値問題は発生しない
    vector<vector<person>> pss(3) //ok! 要素の3つのvectorに要素はない
}

コンストラクタのデメリットの対処

  • コンストラクタを使ってしまうと、暗黙的にデフォルト値の代入をしてくれなくなる。
  • 明示的にしよう!!!
  • 無を引数にしたコンストラクタを追加で作ろう(デフォルトコンストラクタと呼ぶ)
struct person{
    string name;
    int age;
    person(string s,int a):name(s),age(a){}
    person():name(""),age(0){}
};
int main(){
    person p; //ok,初期値は明示的に代入される
    p = person("laft",19);
    cout << p.name << endl;
}

laft

出力:

関数のデフォルト引数

  • デフォルト引数って知ってる???
  • 関数の宣言時に引数を=で指定しておくと、呼び出すときに引数が足りないと自動的にそれに補完される
  • ただし、足りないときは、手前(下の例の場合はa)から明示的に代入される。
int sub(int a=0,int b=0){
    return a-b;
}
int main(){
    cout << sub(3,5) << " " << sub(3) << " " << sub() << endl;
    //       3-5=-2            3-0=3            0-0=0
}

-2 3 0

出力:

関数のデフォルト引数

  • ただし、足りないときは、手前(下の例の場合はa)から明示的に代入される。
  • →デフォルト引数は後ろから設定しないとコンパイルエラー
int sub(int a=0,int b){ //error
    return a-b;
}
int sub(int a,int b=0){ //ok!
    return a-b;
}
int sub(int a=0,int b=0){ //もちろんok!
    return a-b;
}

コンストラクタのデフォルト引数

  • コンストラクタは特殊なメンバ関数なので、デフォルト引数を設定できる。
  • 全ての引数にデフォルト値を指定したコンストラクタはデフォルトコンストラクタとしても使える
struct person{
    string name;
    int age;
    person(string s="",int a=0):name(s),age(a){}
};
int main(){
    person p; //ok,初期値は明示的に代入される
    p = person("laft",19);
    cout << p.name << endl;
}

sort(STL)と自作構造体

  • 作った構造体でsort(or priority_queue)したい!
  • けど、構造体同士を比較できない…(定義してないので、何が大きいとか分からない)
  • 自分でO(NlogN)のソートを書きたくない…
struct person{
    string name;
    int age;
    person(string s="",int a=0):name(s),age(a){}
};
int main(){
    vector<person> ps;
    //...(入力なり代入なりしたとしよう)
    sort(ps.begin(),ps.end()) //error!何が小さいか分からない
}

対策1:sort(STL)の第三引数

  • STLのsortの第三引数には比較基準の関数が入れられる。(デフォルトでは'<'の結果をそのまま返す関数が入ってる)
  • そこに、自分の構造体用の比較関数を入れよう!
  • このやり方ではpriority_queueに対応できない…
struct person{
    //省略    
};
//名前がstring的に小さい時にtrue,名前が同じなら年齢が低いときにtrueを返す。
//そうでない時にはfalseを返す。このような関数を比較関数という。
bool lessper(person left,person right){
    if(left.name==right.name) return left.age<right.age;
    return left.name < right.name;
}
int main(){
    vector<person> ps;
    //...(入力なり代入なりしたとしよう)
    sort(ps.begin(),ps.end(),lessper) //ok!何が小さいか定義ずみ!
}

対策2:演算子の上書き

  • STLのsortの第三引数には比較基準の関数が入れられる。(デフォルトでは'<'の結果をそのまま返す関数が入ってる)
  • 実はC++では、演算子を上書きできる。(operatorのoverloadという)
  • 自作クラスの演算子'<'を上書きしよう!
  • こっちはpriority_queueでもOK!
struct person{
    //(ry    
};
bool operator<(person left,person right){
    if(left.name==right.name) return left.age<right.age;
    return left.name < right.name;
}
int main(){
    vector<person> ps;
    //...(入力なり代入なりしたとしよう)
    priority_queue<person> q; //ok!下に同じ!
    sort(ps.begin(),ps.end()) //ok!何が小さい定義ずみ!
}

ところで...

  • sortはデフォルトで昇順priority_queueはデフォルトで降順になるように実装されている。(ややこしい)
  • どちらもデフォルトでの演算子は'<'。
  • 降順ソート:sort(v.begin(),v.end(),greater<>());
  • 昇順pq:

priority_queue<int,vector<int>,greater<>> pq;

  • これらを使うときの演算子は'>'で定義されているので、こっちを使うときは'>'を上書きしよう
  • (注意):sortのgreater<>の後には()があって、pqのgreater<>の後には()がないのは仕様。

greaterを使うとき

  • これらを使うときの演算子は'>'で定義されているので、こっちを使うときは'>'を上書きしよう
struct person{
    //(ry    
};
bool operator>(person left,person right){
    if(left.name==right.name) return left.age>right.age;
    return left.name > right.name;
}
int main(){
    vector<person> ps;
    //...(入力なり代入なりしたとしよう)
    priority_queue<person,vector<person>,greter<>> q; //ok!下に同じ!
    sort(ps.begin(),ps.end(),greater<>()) //ok!何が大きいか定義ずみ!
}

privateとpublic

  • 競プロでは必要ない知識だけど、後でclassを紹介するためにやります。雰囲気だけ掴んでね。
  • 構造体の変数や関数にはpublicprivateという状態を付与できる。
  • publicの変数や関数は外部からも参照できる(今までと同じ)
  • privateの変数や関数は外部からは参照できない
  • "public:"と書くと、それ以降の行は全てpublicになる
  • 逆に"private:"と書くと、それ以降は全てprivate
  • 構造体はデフォルトではpublic
  • 競プロではあまり必要ない知識。。。
  • なので、privateの変数・関数とかなんでいるの??という質問は個別にどうぞ

privateとpublicの例

  • publicの変数や関数は外部からも参照できる
  • privateの変数や関数は外部からは参照できない
struct person{
    string name;
    int age;
    person(string s="",int a=0):name(s),age(a){
        birthyear = 2019-age;
    }
    int get_birthyear(){
        return birthyear;
    }
 private:
    int birthyear;
};
int main(){
    person p("laft",19);
    cout << p.birthyear << endl; //error!外部からprivateにアクセスできない
    cout << p.get_birthyear() << endl; //ok!
}

classについて

  • C++ではクラスは構造体とほとんど同じ。
  • 違うのはデフォルトでprivateということだけ。
  • class使う人は割といる。(他言語では一般的な名称)
  • class使ってる他人のコード見た時に理解できた方が良いので、ここまでの競プロではほとんど使わないpublicとprivateをやった感じです。
//以下は今まで使ってた構造体と全く等価。
class person{
public:
    string name;
    int age;
    person(string s="",int a=0):name(s),age(a){}
};

Union-Find木

Union-Find木って?

  • 互いに交わらない集合を扱う時に使う。
  • 分類して、それがどこにあるかを調べられる構造体。

例題:Union Find

AtCoder Typical Contest 001 B(改題)

  • N個の品物をグループ分けすることを考える。
  • 最初は全ての品物が別々のグループにある。
  • 以下の2種類のクエリがQ回与えられる。
    • 連結:品物A,Bを含むそれぞれのグループをまとめる。
    • 判定:品物A,Bが同じグループか判定(Yes,No)
  • クエリを上から処理して、判定クエリに回答せよ。
  • 制約:\(N\leqq10^5,Q\leqq2\times10^5\)

例:連結クエリ

連結:1,3

これを……

例:連結クエリ

連結:1,3

こう

例:判定クエリ1

判定:2,6

例:判定クエリ1

判定:2,6

同じグループ!

よって"Yes"を出力

例:判定クエリ2

判定:1,4

例:判定クエリ2

判定:1,4

違うグループ!

よって"No"を出力

愚直解(配列)

配列を用意して、その配列に情報を入れる

1 2 3 4 5 6
1 2 2 3 1 2

グループ1

グループ2

グループ3

愚直解(配列)_計算量

1 2 3 4 5 6
1 2 2 3 1 2

グループ1

グループ2

グループ3

例えば連結クエリ:1,3の時にこの配列を全て見て、1→2にするという処理が必要

よって連結クエリで\(O(N)\)

全体で最悪\(O(NQ)\)間に合わない…

Union-Find木

  • この操作をもっと効率的に行えるデータ構造
    • unite(A,B): AとBを同じグループにする
    • find(A)      : Aの属するグループを求める
  • (STLはないので自分で実装しよう.(今日のところはコピペでもいいけど))

Union-Find木のしくみ

  • 各集合ごとに代表となる要素を決めておく。
    • 最初は全部バラバラなので自分自身が代表
    • 代表の要素を根として、根付き木の構造で持っておく。
  • unite(A,B) :「Aの集合の代表」の親を「Bの集合の代表」にする。(もちろん逆でもOK)
  • find(A)       : 順に親を辿っていき、根に着いたらそれを返す。
  • Union-Find木では子から親にしか行かないので、グラフは配列で作れる。

Union-Find木を配列で表現

赤い頂点が根

1 2 3 4 5 6 7 8 9 10
1 3 3 4 4 7 2 1 5 5

各頂点の親を入れた配列par

unite(A,B)

unite(A,B)

unite(A,B)

Union-Find第一形態_コード例

struct union_find_tree1{
    vector<int> par;//0-indexed
    union_find_tree1(int sz){ //コンストラクタ
        par.resize(sz); //サイズをszに変更
        for(int i=0;i<sz;++i) par[i] = i; //最初は自分が根
    }
    int find(int a){
        while(a!=par[a]){ //親が自分自身の時、自分が根である
            a = par[a];   //そうでないなら自分に親を代入して辿る
        }
        return a;         //引数aの根が計算されたのでそれを返す。
    }
    void unite(int a,int b){
        int root_a = find(a),root_b = find(b);  //aの根,bの根を調べて代入。
        par[root_a] = root_b;       //根aの親をbにする
    }
    bool same(int a,int b){  //aとbが同じ集合なら1,違うなら0
        return find(a)==find(b);
    }
};
int main(){  //雑だけど、構造体の使い方の例を載せときます
    int sz = 10;
    union_find_tree1 uf(sz);  //union_find木の変数ufをサイズszで宣言
    uf.unite(1,2);            //頂点1と2をunite
    if(uf.same(2,3)) puts("Yes"); //2と3が同じならYes
}

Union-Find第一形態再帰コード

struct union_find_tree1{
    vector<int> par;//0-indexed
    union_find_tree1(int sz){
        par.resize(sz); //サイズをszに変更
        for(int i=0;i<sz;++i) par[i] = i; //最初は自分が根
    }
    int find(int a){
        if(a==par[a]) return a;     //親が自分自身の時、自分が根である
        else return find(par[a]);   //そうでないなら自分に親を代入して辿る
    }
    void unite(int a,int b){
        int root_a = find(a),root_b = find(b);  //aの根,bの根を調べて代入。
        par[root_a] = root_b;       //根aの親をbにする
    }
    bool same(int a,int b){
        return find(a)==find(b);
    }
};

計算量考察

  • find(int a) : aから順番に上に辿る。
    • 一切分岐がない木だったら最悪\(O(N)\)
  • unite(int a,int b):付け替えは\(O(1)\)だが、内部でfindを使っているので\(O(N)\)

 

遅い!!!

というわけで高速化します。

高速化その1:rank

  • rank : 各木に紐ついた、木の高さを示す値
    • 最初は全て0, unite時に更新
  • 出来るだけfindで辿る回数を減らしたい。
    • →常に木が低くなるようにuniteする
  • 木A(rank=k)と木B(rank=m)の時(k>m)
    • AにBをくっつける。新しい木のrankはk
  • 木A(rank=k)と木B(rank=k)の時
    • どっちにしても同じ。新しい木のrankはk+1

rank

高速化その1:計算量考察

  • rank=kの木を構成するには少なくとも\(2^k\)の要素が必要
    • rank=0の木を構成するのに要素1つ必要
    • rank=k+1の木を構成するのにrank=kの木が2つ必要
    • 帰納的に考えると、\(2^k\)になる
  • よってn要素のUnion-Find木の高さは高々\(\log_{2}{n}\)

高速化その1:計算量考察

  • find : \(O(\log N)\)
    • 最大でも木の高さ分しか辿らない。
  • unite : \(O(\log N)\)
    • ​中でfindを(ry

十分速いけど、まだ高速化できる!

Union-Find 第二形態-1 コード

struct union_find2_1{//rankで高速化
    vector<int> par;//parは添字の親(または自分が根の時自身)を示す。
    vector<int> rank;//rankは木の高さ(根についてのみ実装)
    union_find2_1(int sz){
        par.resize(sz);
        rank.assign(sz,0);
        for(int i=0;i<sz;++i) par[i]=i;
    }
    int find(int a){//根を探す
        if(par[a]==a) return a;
        else return find(par[a]);
    }
    void unite(int a,int b){//小さい方の木の根に大きい方の根を上書きする
        int root_a = find(a),root_b = find(b);
        if(root_a==root_b) return; //既に同じなら終了
        if(rank[root_a]>rank[root_b]){
            par[root_b] = root_a;
        }else if(rank[root_a]<rank[root_b]){
            par[root_a] = root_b;
        }else{
            rank[root_a]++;
            par[root_b] = root_a;
        }
    }
};

高速化その2:経路圧縮

  • findする時に、ついでに全ての頂点を根に直接繋ぐ
  • 集合の代表さえ分かれば良いので、どの親がどう繋がってるかはどうでもいい。

高速化その2:経路圧縮

高速化その2:経路圧縮

高速化その2:経路圧縮

高速化その2:経路圧縮

高速化その2:経路圧縮

高速化その2:経路圧縮

高速化その2:計算量

  • find:\(O(\log N)\)
  • unite:\(O(\log N)\)
    • ​中でfind(ry

これは償却計算量(計算量の平均みたいな)

なんでこうなるのかは難しいらしい…

僕は知らないので、興味ある人は調べてみてね

Union-Find 第二形態-2

struct union_find2_2{//経路圧縮で高速化
    vector<int> par;//parは添字の親(または自分が根の時自身)を示す。
    union_find2_2(int sz){
        par.resize(sz);
        for(int i=0;i<sz;++i) par[i]=i;
    }
    int find(int a){//根を探す
        if(par[a]==a) return a;
        else return par[a] = find(par[a]); //経路圧縮!!!
    }
    void unite(int a,int b){
        int root_a = find(a),root_b = find(b);  //aの根,bの根を調べて代入。
        par[root_a] = root_b;                   //根aの親をbにする
    }
    bool same(int a,int b){
        return find(a)==find(b);
    }
};

高速化その1+その2

  • rankの計算と経路圧縮の両方を行う。
  • 経路圧縮すると、木の高さは変わるが、その際はrankを再計算しなくても良い。
    • 要するに、経路圧縮してない時と同じでOK
    • もともと「こっちにつけると\(\log N\)になるよ」というヒントなので厳密でなくて良い

高速化その1+その2

これも償却計算量(計算量の平均みたいな)

  • find:\(O(\alpha(N))\)
    • \(\alpha(n)\)はアッカーマン関数の逆関数
    • アッカーマン関数はすごい勢いで爆発するので実質定数と言われるほど
  • unite:\(O(\alpha(N))\)
    • ​中で(ry

Union-Find 最終形態_コード例

struct union_find{//rank,経路圧縮で高速化
    vector<int> par;//parは添字の親(または自分が根の時自身)を示す。
    vector<int> rank;//rankは木の高さ(根についてのみ実装,0-indexed)
    union_find(int sz){//コンストラクタ,sz個のノードを作成し、自分を親につける
        par.resize(sz);
        rank.assign(sz,0); //rankは最初0
        for(int i = 0;i < sz;++i) par[i]=i;
    }
    int find(int a){//根を探す
        if(par[a]==a) return a; //親が自身を指す時、根である
        else return par[a] = find(par[a]);//根に直接つける
    }
    void unite(int a,int b){//小さい方の木の根に大きい方の根を上書きする
        int root_a,root_b;
        root_a = find(a),root_b = find(b);
        if(rank[root_a]>rank[root_b]){ //rankの高い方の根に低い方の根をつける
            par[root_b] = root_a;
        }else if(rank[root_a]<rank[root_b]){
            par[root_a] = root_b;
        }else{ //rankが同じ時、適当な方につけ、つけられた方のrankを1増やす
            rank[root_a]++;
            par[root_b] = root_a;
        }
    }
    bool same(int a,int b){return find(a)==find(b);}
};

Union-Find 経路圧縮(not struct)

vector<ll> par;
ll root(ll x){
  if (par[x] == x){ //根
    return x;
  }
  else {
    return par[x] = root(par[x]); //経路圧縮
  }
}
void unite(ll x,ll y){
  x = root(x);
  y = root(y);
  if (x == y){}
  else{
    par[x] = y;     //xの親をyの親にする
  }
}
ll same(ll x,ll y){
  return root(x) == root(y);
}

int main(){
  ll n;
  cin>>n;
  par.assign(n,0);
  for(ll i=0;i<n;i++){
    par[i]=i;
  }
}

参考:アッカーマン関数

\(\alpha(n)\)はA(n,n)の逆関数

つまりは高々4

定義とかは暇な時にでも自分で見てね

コンテスト

  • コンテストはAizu Online Judgeで行います
  • まだしてない人は会員登録をしましょう
  • 下記のURLにアクセスしてください

コンテスト

http://judge.u-aizu.ac.jp/onlinejudge/index.jsp?lang=ja

もしくは

「 AOJ 」で検索

  • 下記のURLにアクセスしてください
  • パスワードは "y" です
  • コンテスト開始までしばしお待ちください

コンテスト

終わり!

解説して欲しいものありますか?

ごはん食べよう!!!

参考文献

第7回:グラフ表現, Union-Find 木

By procon2019

第7回:グラフ表現, Union-Find 木

発表日時 2019年5月31日(金) 18:30-21:00 https://onlinejudge.u-aizu.ac.jp/beta/room.html#kmc2019_n_7

  • 880