最近、Online Dynamic Connectivityとか言われているデータ構造を実装しようと奮闘しています。
これを実装するのにEuler Tour Treeというのが必要なのですが、結構面白かったので備忘録です。
ちなみに、以下のスライドを参考にしているというか、なぞっているだけです。
https://web.stanford.edu/class/cs166/lectures/15/Slides15.pdf
Online Dynamic Connectivityはこちらのスライドが詳しいです。
https://web.stanford.edu/class/cs166/lectures/16/Slides16.pdf
Euler Tour Treeとは
辺のlink/cutや、根の変更(reroot)、2頂点が同じ木に属しているか(are-connected)を時間で行うことのできる、動的木と呼ばれるデータ構造の一種です。
森の状態の管理にEuler Tourを利用します。
Euler Tourを利用した各クエリの捌き方の概要
どのようにEuler Tourを利用するのか、link/cut/reroot/are-connectedのやり方を見ていきます。
link
例として、以下のような森を考えます。
0 / 1 2 / \ 3 4
このとき
- を根とする木のEuler Tourは
- を根とする木のEuler Tourは
となります。
この2つの木を、間でlinkするにはどうすればいいでしょうか?
結論から言えば、2つのEuler Tourをを挟んで連結して、最後にを加えると、2つの木を間で連結したEuler Tourを作ることができます。
実際、
は、新しい木のEuler Tourになっていることがわかります。
このように、木の根同士を連結する場合には、2つの木のEuler Tourを適当な頂点対を挟んで連結して片割れを最後にくっつければ、linkができます。
木の根同士ではないときは、後述のrerootで連結したい頂点を根に変更すればよいです。
cut
先ほどの例のlink後の状態から、さらにの下に頂点が増えた木のEuler Tourを考えます。
0 / \ 1 2 / \ 3 4 / 5
これを間でcutすることを考えます。
まずはを削除してみます。
↓
は、cut後のを頂点とする木のEuler Tourになっていそうです。
前後の列について考えると、間をcutすることをの部分木のEuler Tourをなかったことにすると考えれば、真ん中の列を無視してくっつければそのままを根とする木のEuler Tourになりそうです。
実際、
は、間をcutした後のを根とする木のEuler Tourです。
このように、cutする辺を列から削除し、真ん中を無視して前後の列をくっつけることで、cutは達成できます。
reroot
linkの例の木について、根をからに変更することを考えます。
0 2 / \ /|\ 1 2 -> 0 3 4 / \ / 3 4 1
左の木のEuler Tourは以下の通りです。
唐突ですが、この列をローテートして、末尾のが先頭に来るようにしてみます。
↓
ビックリですが、ローテート後の列は上記の絵の右の木のEuler Tourになっています。
このように、rerootは根にしたい頂点が始点になっている辺が先頭に来るように列のローテートを行うだけで達成できます。
例えば、が先頭に来るようにローテートしても、rerootは達成できます。
↓
このEuler Tourは、以下のような木を表し、やはりが根になります。
2 /|\ 3 4 0 / 1
でローテートしてもよいですが、例は省略です。
are-connected
これは方法自体は簡単で、チェックしたい2頂点が含まれる辺が同じEuler Tourの列に含まれるかどうかを確認すればよいです。
データの持ち方
今まで見てきたように、列を任意の点で効率的に分割・連結できることが要求されます。
このようなデータ構造としてはまず双方向連結リストと辞書の組み合わせが考えられます。
辺が列のどこにあるかはハッシュテーブルのようなデータ構造の辞書であればで求められ、列の分割・連結も連結リストならなので、非常に効率が良いです。
しかし、are-connectedはどうやってやればいいでしょうか?
たぶんいい方法はなくて、列をで走査して確認するしかないです。なので、双方向連結リスト+辞書はパスです。
次に、平衡二分木を検討します。
分割・連結はに悪化しますが、are-connectedは、辞書でポインタを持ち、根まで遡ってみるなどの方法ででできますから現実的です。
また、頂点固有の情報の持ち方についても考える必要があります。
Euler Tourは辺の情報しか持たないので、頂点の情報を引き出すのはツライです。
どこかの辺に押し付けることは可能ですが、どの辺が頂点の情報を持っているのかを効率的に求めることは難しいです。
そこで、自己ループをEuler Tourに混ぜ込み、それを頂点をみなすことで解決します。
実装
Rustで実装していきます。
平衡二分木に何を使うかですが、私はSplay木しか書けないので、Splay木でいきます。
すべての左部分木の頂点はEuler Tourにおいて自身より前に位置する辺、右部分木は自身より後ろに位置する辺、というルールにしておきます。
実用的には何か演算を乗せて使うことが多いのでしょうが、とりあえずは上記のクエリが捌けるものを作っていきましょう。
基本的にソラで書いていくので、ちょっとずつ仕様が変わっていくかもしれません。
完全な実装例だけが必要なら、後述のremove-packetまで実装したやつを見るのがよいと思います。
Splay木の実装
実装例:Rust Playground
循環データ構造が必要です。たぶんRust的にお行儀よく書くにはRc/Weakなどの参照カウンタを用いるのがよいですが、どうせ内部データ構造で外に露出しないので、ポインタでいきます。
親・子へのポインタには生ポインタを持ってもよいですが、Option<NonNull>
を使うのが便利です。
NULLポインタ最適化でサイズも生ポインタと変わらなくなるはずなので、効率を落とさず、NULLの扱いを便利にできます。
NonNull<Node>
のままだと扱いにくいので、NodeRef
というラッパを作っていい感じに使えるようにします。
Box::leak
で'static
な参照を得られます。解放するときはBox::from_raw
で所有権を取り戻して捨てればよいです。
split
/merge
が必要になるかもしれませんが、とりあえずsplay
さえ使えればいいので、今のところはここまででOKです。
Euler Tour Treeの初期化
実装例:Rust Playground
各頂点を表す自己ループへのポインタへのアクセスは軽いほうがよいので、配列で持ちます。
それ以外の辺へのポインタは辞書で持ちます。
ちょっとSplay木のNode
の定義を変えて、辺の始点・終点を持つようにしました。
index
はこれを代わりに使えるので、削除です。
are-connectedの実装
実装例:Rust Playground
対象の2つの頂点をsplay
して順に根に持ってきます。
同じ木に属するのであれば、先にsplay
したほうは根ではなくなるはずです。
コーナーケースとして、与えられた頂点が同じ、というのがあるので気をつけましょう。当然2回splay
しても根のままです。
rerootの実装
実装例:Rust Playground
根にしたい頂点が始点である辺の直前ならどこで切ってもいいのですが、せっかく自己ループには効率的にアクセスできるので、自己ループの前で切る、というルールにします。
自己ループをsplay
して平衡二分木の根にし、左部分木を切って最も右の子の右部分木としてくっつけます。
split
/merge
を実装してもよいですが、まあそこまでしなくていいかと思ったので実装しません。
linkの実装
実装例:Rust Playground
are-connected/rerootを実装したので、これを利用してlinkが実装できます。
まず、同じ頂点かどうかや、すでに辺があるかどうかは簡単に判定できるので弾きます。
また、木じゃなくなるとマズいので、are-connectedでlinkしたい頂点が同じ木に属していないことも確認します。
こういった不正なクエリをどうするかですが、とりあえず無視することにしておきます。
Result
を返すようにしておくと、デバッグなどで役に立つこともあるかもしれません。
次はlinkしたい頂点をrerootでそれぞれの木の根に変更します。
その後は先述の通り、適切な辺を生成して平衡二分木のマージを行います。
新しく生成した辺は辞書に収めておきます。
cutの実装
実装例:Rust Playground
自己ループや、辺がない頂点間のcutはできないので、冒頭で弾きます。
辺がある場合は辞書から対になる辺と併せて引き出します。
これを削除して列の前後をくっつけるわけですが、引き出した2つの辺のどっちがEuler Tourで前に位置するかをわかっていないといけません。
私は、とりあえず片方splay
して左右部分木を切り離した後、もう片方をsplay
し、部分木の根の変化を調べることで前後を調べることにしました。
あるいは、Node
に部分木サイズを持たせるようにし、それぞれが前から何番目かを都度計算して調べるとかでもできると思います。
最後は不要になった辺を削除しますが、確保した領域を解放しないともれなくメモリリーク発生です。
valgrind --tool=memcheck
などで試験しておくといいと思います。
ここまで実装するとテストらしいテストができるようになるので、テストコードも最下部に追加しました。
雑なテストですが、Splay木の実装がバグっていたのを修正すると、だいたいのケースで上手くいきそうかなというのは確認できました。
Euler Tour Treeの機能拡張
ここまでで最低限の機能はできましたが、参考にしたスライドでは、
- ある頂点が属する木の頂点数を調べるsize
- ある頂点にパケットを付加するadd-packet
- ある頂点から到達可能な範囲にある頂点に付加されたパケットをどれか1つ取り除くremove-packet
という拡張が紹介されています。
これらはEuler Tour Treeを利用したOnline Dynamic Connectivityの実装に役立つので、ついでに実装していきます。
sizeの実装
実装例:Rust Playground
Node
を拡張してsize
というメンバを持たせ、平衡二分木の部分木の値の集約を実装します。
自己ループのNode
以外は集計してはいけないので、Node::new
とNode::update
の実装に注意です。
平衡二分木の部分木の値集約が実装出来たら、あとは引数として与えられた頂点を表す自己ループをsplay
して、size
の値を返せばよいです。
テストにsizeのクエリを追加しました。まあ多分ですが、上手く動いているでしょう…
add-packetの実装
実装例:Rust Playground
ここでは、各頂点が持つパケットの個数情報はNode
で持たないことにします。
個数情報を各Node
で持つのはメモリ効率が悪すぎるので、必要なら別途それを管理するデータ構造を用意したほうが良いと思います。
これを踏まえて、Node
には、
- 自身がパケットを持っているかを示す
has_packet
フラグ - 自身を根とする部分木の頂点のうち、自身を除くいずれかがパケットを持っているかを示す
has_packet_subtree
フラグ
を持たせます。ただ、それぞれをbool
で持つのはメモリの無駄なので、u8
でビットマップにして持ちます。
実装では、0ビット目がhas_packet
フラグ、1ビット目がhas_packet_subtree
フラグです。
これらのデータの管理も、平衡二分木の部分木の値集約でできるので、Node::update
の実装を修正します。
EulerTourTree
側のメインの処理としては、対象の頂点を平衡二分木の根にしてフラグをセットし、Node::update
を呼ぶ、といったシンプルなものになります。
remove-packetの実装
実装例:Rust Playground
スライドの通り、どの頂点のパケットを除くかは指定できないことにしておきます。
この実装を利用してfind-packet(パケットを持つ到達可能頂点のどれかを返す)を実装し、狙い撃ちでパケットを除去するバージョンを作ることもできます。
実装は単純で、has_packet
フラグを持つ頂点に辿り着くまで、has_packet
かhas_packet_subtree
が立っている子を辿ります。
has_packet
フラグを持つ頂点が見つかったら、splay
で平衡二分木の根にして、Node::remove_packet
を呼び出してフラグをクリアし、Node::update
で整合性を取ります。
add-packetの分もクエリを追加してテストを回してみると、上手くいっていそうです。
ここまでで、スライドに載っているクエリはすべて捌けるようになったと思います。
おわりに
結局このデータ構造って、オイラーツアーを平衡二分木に乗せました!っていうただそれだけなわけですが、それだけでこんな色々なことができるんだなあって思いました。
同じ動的木でも、Link-Cut Treeはもっとものすごいことやっていたのでさすがにソラで実装できる気はしないですが、Euler Tour Treeはその気になればその場で書けちゃいそうです。
あとは値と演算を乗せるとか、作用を伝播させるとかができるとよいですが、パフォーマンスの面でも実用できるレベルにするにはもう少し工夫が必要そうです。
ちょっと疑問な部分としては、部分木全体の値集約は簡単にできますけど、パスの値集約ってEuler Tour Treeでも捌けるんですかね?
それは未だに分かっていません。