プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

51
プログラミングコンテストでの データ構造~平衡二分探索木編~ 東京大学情報理工学系研究科 秋葉 拓哉 2012/3/20 NTTデータ駒場研修所 (情報オリンピック春合宿) 1

Upload: takuya-akiba

Post on 27-Nov-2014

17.886 views

Category:

Technology


0 download

DESCRIPTION

続き (動的木編) はこちら http://www.slideshare.net/iwiwi/2-12188845

TRANSCRIPT

Page 1: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

プログラミングコンテストでの

データ構造2 ~平衡二分探索木編~

東京大学情報理工学系研究科

秋葉 拓哉

2012/3/20 NTTデータ駒場研修所 (情報オリンピック春合宿)

1

Page 2: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

自己紹介

• 秋葉 拓哉 / @iwiwi

• 東京大学 情報理工学系研究科 コンピュータ科学専攻

• プログラミングコンテスト好き

• プログラミングコンテストチャレンジブック

2

Page 3: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

データ構造たち (もちろん他にもありますが)

• 二分ヒープ

• 組み込み辞書 (std::map)

• Union-Find 木

• Binary Indexed Tree

• セグメント木

• バケット法

• 平衡二分探索木

• 動的木

• (永続データ構造)

コンテストでの

データ構造1 (2010 年)

初級編

中級編

本講義

3

Page 4: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

話すこと

1. 平衡二分探索木

2. 動的木

3. 永続データ構造 (時間あれば)

• ちょっとむずい! 「へ~」ぐらいの気持ちでも OK!

• アイディアだけじゃなくて,実装にまで立ち入り,簡潔

に実装するテクを伝授したい

4

Page 5: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

平衡二分探索木

5

Page 6: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

普通の二分探索木

7

2

1 5

4

15

10

8 11

17

16 19

6

Page 7: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

普通の二分探索木: 検索 (find)

7

2

1 5

4

15

10

8 11

17

16 19

7と比較

15と比較

発見

10 を検索

7

Page 8: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

普通の二分探索木: 挿入 (insert)

7

2

1 5

4

15

10

8 11

17

16 19

7と比較

2と比較

5と比較

6

追加

6 を挿入

8

Page 9: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

普通の二分探索木: 削除 (erase)

7

2

1 5

4

11

10

8 11

17

16 19

15 削除

15 を削除

9

Page 10: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

普通の二分探索木の偏り

1, 2, 3, 4, … の順に挿入すると…?

1

2

3

4

5

高さが 𝑂 𝑛 !

処理に 𝑂 𝑛 時間!

やばすぎ!

こういった意地悪に耐える工夫をする二分探索木

= 平衡二分探索木が必要! 10

Page 11: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

…その前に!

平衡二分探索木は本当に必要?

• いつものじゃダメ?

– 配列,線形リスト

– std::set, std::map

– Binary Indexed Tree,バケット法,セグメント木

• 実装が面倒なので楽に避けられたら避けたい

• 実際のとこ,本当に必要になる問題はレア

11

Page 12: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

範囲の大きな Range Minimum Query

• ある区間の最小値を答えてください

• ある場所に数値を書いてください

ただし場所は 1~109.デフォルト値は ∞ .

(´・_・`) そんな大きな配列作れない…

セグメント木じゃできない…

( ・`д・´) セグメント木でもできるよ

クエリ先読みして座標圧縮しとけばいいよ

12

Page 13: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

範囲の大きな Range Minimum Query

• ある区間の最小値を答えてください

• ある場所に数値を書いてください

ただし場所は 1~109.デフォルト値は ∞ .

(´・_・`) でもクエリ先読みできないかも…

(情オリの interactive とか,計算したら出てくるとか)

( ・`д・´) 必要な場所だけ作るセグメント木でいいよ

13

Page 14: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

必要な場所だけ作るセグメント木

5 3 ∞ ∞ ∞ 8 2 ∞

2

3 2

3 ∞ 8 2

5 3 ∞ 8 2 ∞

• 以下が全てデフォルト値になってるノードは要らない

• 𝑂(クエリ数 𝐥𝐨𝐠(場所の範囲)) のノードしかできない

• 𝑂(𝐥𝐨𝐠 場所の範囲 ) でクエリを処理できる

春季選考合宿 2011 Day4 Apple 参照

14

Page 15: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

例 2

反転のある Range Minimum Query

• ある区間の最小値を答えてください

• ある区間を左右反転してください

(´・_・`) 反転なんてできない…

( ・`д・´) ・・・

おとなしく平衡二分探索木を書こう! 15

Page 16: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

平衡二分探索木 (&仲間)

超いっぱいあります AVL 木,赤黒木,AA 木,2-3 木,2-3-4 木,スプレー木,

Scapegoat 木,Treap,Randomized Binary Search Tree,Tango 木,Block Linked List,Skip List,…

• ガチ勢: 赤黒木 (std::map とか)

– (定数倍的な意味で) かなり高速

– でも実装が少し面倒

• コンテスト勢: 実装が楽なのを組もう!

16

Page 17: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

コンテストでの平衡二分探索木

• 大抵,列を管理するために使われる (探索木という感じより,ただの順序を持った列を高機能に扱う感じが多い)

• よく必要になるもの

– 𝑘 番目に挿入 (insert),𝑘 番目を削除 (erase)

– 0, 𝑘 と 𝑘, 𝑛 の 2 つの列に分割 (split)

– 2 つの列を連結 (merge)

– 値に関する質問・更新 (sum, add 等)

これをサポートする木を作ろう!

17

Page 18: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap の思想

• 根がランダムに選ばれるようにする!

– 全ノードが等確率で根になるようにする

– 部分木に関しても再帰的に,根をランダムに選ぶこ

とを繰り返す

• これだけで高さが 𝑂 log 𝑛

• なぜ? 乱択クイックソートと同じ.

18

Page 19: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap の思想

乱択クイックソート

ランダムに選ばれた

ピボット

↑ ピボットより

小さい値

↑ ピボットより

大きい値

Treap / RBST

ランダムに選ばれた

↓ 根より

小さい値

根より

大きい値

19

Page 20: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap の思想 (別解釈)

普通だと,挿入順は木にどう影響する?

ナイーブな二分探索木に c → b → d → a と挿入

c

b d

a

c

b d

c

b

c

先に挿入したものが上,後に挿入したものが下

20

Page 21: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap の思想 (別解釈)

• 普通の二分探索木でも,もしランダム順に挿入

されてたら必ず高さ 𝑂 log 𝑛

• 実際の挿入順に構わず,ランダム順に挿入され

たかのように扱おう!

– 常に std::random_shuffle した後で挿入されたかのう

21

Page 22: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap

• 乱数を用いる平衡二分探索木

• 各ノードは,キーの他,優先度を持つ

– 優先度は挿入時にランダムで決める

キー

優先度

22

Page 23: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap

以下の 2 つの条件を常に保つ

1. キーを見ると二分探索木

2. 優先度を見ると二分ヒープ (Tree + Heap なので Treap という名前らしい…ワロスwww)

キー 小 大

優先度

23

Page 24: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap

• 優先度が最高のやつが根になる

– これは,全ノード等確率! → 平衡!

– 部分木に関しても再帰的に同様

キー 小 大

優先度

24

Page 25: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap の実装法

大まかに 2 つの方法があります

insert-erase ベース (insert, erase を実装し,それらの組み合わせで merge, split)

merge-split ベース (merge, split を実装し,それらの組み合わせで insert, erase)

25

Page 26: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: ノード構造体

struct node_t {

int val; // 値

node_t *ch[2]; // = {左, 右};

int pri; // 優先度

int cnt; // 部分木のサイズ

int sum; // 部分木の値の和

node_t(int v, double p) : val(v), pri(p), cnt(1), sum(v) {

ch[0] = ch[1] = NULL;

}

};

26

Page 27: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: update

int count(node_t *t) { return !t ? 0 : t->cnt; }

int sum(node_t *t) { return !t ? 0 : t->sum; }

node_t *update(node_t *t) {

t->cnt = count(t->ch[0]) + count(t->ch[1]) + 1;

t->sum = sum(t->ch[0]) + sum(t->ch[1]) + t->val;

return t; // 便利なので t 返しとく

}

部分木に関する情報を計算しなおす

子が変わった時などに必ず呼ぶようにする

27

Page 28: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: insert (insert-erase ベース)

まず優先度を無視して普通に挿入

28

Page 29: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: insert (insert-erase ベース)

優先度の条件が満たされるまで回転して上へ

29

Page 30: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

回転

左右の順序を保ちつつ親子関係を変える

上図のようにポインタを貼り直す

30

Page 31: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: 回転 (insert-erase ベース)

node_t *rotate(node_t *t, int b) {

node_t *s = t->ch[1 - b];

t->ch[1 - b] = s->ch[b];

s->ch[b] = t;

update(t); update(s);

return s;

}

子を,別の変数でなく,配列にすると,

左右の回転が 1 つの関数でできる

親の親のポインタを張り替えなくて良いのは,

各操作が常に部分木の根を返すように実装してるから (次)

31

Page 32: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: insert (insert-erase ベース)

// t が根となっている木の k 番目に 値 val,優先度 pri のノード挿入

// 根のノードを返す

node_t *insert(node_t *t, int k, int val, double pri) {

if (!t) return new node_t(val, pri);

int c = count(t->ch[0]), b = (k > c);

t->ch[b] = insert(t->ch[b], k - (b ? (c + 1) : 0), val, pri);

update(t);

if (t->pri > t->ch[b]->pri) t = rotate(t, 1 - b);

}

このように,新しい親のポインタを返す実装にすると楽

(親はたまに変わるので.)

32

Page 33: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: erase (insert-erase ベース)

1. 削除したいノードを葉まで持っていく

– 削除したいノードの優先度を最低にする感じ

– 回転を繰り返す

2. そしたら消すだけ

33

Page 34: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: merge / split (insert-erase ベース)

• insert, erase が出来たら merge, split は超簡単

• merge(𝑙, 𝑟)

– 優先度最強のノード 𝑝 を作る

– 𝑝 の左の子を 𝑙,右の子を 𝑟 にする

– 𝑝 を erase

• split(𝑡, 𝑘)

– 優先度最強のノード 𝑝 を木 𝑡 の 𝑘 番目に挿入

– 𝑝 の左の子と右の子をそっと取り出す

34

Page 35: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: insert / erase (merge-split ベース)

• 逆に,merge, split が出来たら insert, erase は超簡単

• insert(木 t, 場所 k, 値 v)

– 木 t を場所 k で split

– 左の部分木,値 v のノードだけの木,右の部分木を merge

• erase(木 t, 場所 k)

– 木 t を場所 k - 1 と場所 k で 3 つに split (split 2 回やればいい)

– 一番左と一番右の部分木を merge

今度は, merge, split を直接実装してみよう

35

Page 36: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: merge (merge-split ベース)

• 優先度の高い方の根を新しい根にする

• 再帰的に merge

a

A B

b

C D

a

A

B

b

C D

+ =

36

Page 37: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: merge (merge-split ベース)

node_t *merge(node_t *l, node_t *r) {

if (!l || !r) return !l ? r : l;

if (l->pri > r->pri) { // 左の部分木の根のほうが優先度が高い場合

l->rch = merge(l->rch, r);

return update(l); } else { // 右の部分木の根のほうが優先度が高い場合

r->lch = merge(l, r->lch);

return update(r);

}

}

※ merge-split ベースだと,子を ch[2] みたいに配列で管理するメリットは薄い

上では代わりに lch, rch としてしまっている

37

Page 38: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装: split (merge-split ベース)

pair<node_t*, node_t*> split(node_t *t, int k) { // [0, k), [k, n) if (!t) return make_pair(NULL, NULL); if (k <= count(t->lch)) { pair<node_t*, node_t*> s = split(t->lch, k); t->lch = s.second; return make_pair(s.first, update(t)); } else { pair<node_t*, node_t*> s = split(t->rch, k - count(t->lch) - 1); t->rch = s.first; return make_pair(update(t), s.second); } }

split は優先度の事を何も考えないで再帰的に切るだけ

(部分木内の任意のノードは根より優先度小なので大丈夫)

38

Page 39: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

Treap 実装法の比較

insert-erase ベース (insert, erase を実装し,それらの組み合わせで merge, split)

merge-split ベース (merge, split を実装し,それらの組み合わせで insert, erase)

• どっちでも良いです,好きな方で

• ただ,個人的には,merge-split ベースのほうが遥かに楽!!

– 回転が必要ない,を筆頭に,頭を使わなくて済む

– コードも少し短い

– あと,コンテストでは,insert, erase より merge, split が必要にな

ることの方が多い

39

Page 40: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

その他,実装について

• malloc・new

– 解放・メモリリークやオーバーヘッドが気になる?

– グローバル変数としてノードの配列を 1 つ作っておき,そこか

ら 1 つずつ取って使うと良い

• merge-split ベースでの真面目な insert

1. 優先度が insert 先の木より低ければ再帰的に insert

2. そうでなければ,insert 先の木を split してそいつらを子に

– という真面目な実装をすると,定数倍すこし高速

– erase も同様:再帰していって merge

40

Page 41: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

例題

反転のある Range Minimum Query

• ある区間の最小値を答えてください

• ある区間を左右反転してください

(´・_・`) …結局これはどうやるの? 反転って?

( ・`д・´) 2 つの方法があるよ

41

Page 42: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

反転: 方法 1

真面目に反転を処理する

struct node_t {

int val; // 値

node_t *ch[2]; // = {左, 右};

int pri; // 優先度

int cnt; // 部分木のサイズ

int min; // 部分木の値の最小 (RMQ のため)

bool rev; // 部分木が反転していることを表すフラグ

};

まずは構造体にフィールドを追加

42

Page 43: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

反転: 方法 1

区間 [l, r) を反転したいとする

1. 場所 l, r で split → 3 つの木を a, b, c とする

2. b の根ノードの rev フラグをトグル

3. 木 a, b, c を merge

rev フラグはどのように扱う?

43

Page 44: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

反転: 方法 1

• rev フラグの扱い

void push(node_t *t) {

if (t->rev) {

swap(t->lch, t->rch);

if (t->lch) t->lch->rev ^= true; // 子に反転を伝搬

if (t->rch) t->rch->rev ^= true; // 子に反転を伝搬

t->rev = false;

}

}

rev フラグによる反転を実際に反映する関数 push ノードにアクセスするたびに最初にこれを呼ぶようにする

セグメント木における更新遅延と同様のテクニック

(いっぱい push 書きます,書き忘れに注意!)

※数値の更新なども同様に,フラグ的変数作って push する

(区間への一様な加算など)

44

Page 45: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

反転: 方法 2

はじめから 2 本の列をツリー t1, t2 で管理

• t1:順向き

• t2:逆向き

区間 [l, r) を反転したいとする

• t1 の [l, r) と,t2 の [l, r) を split して切り出す

• 交換して merge

簡単.

(ただし,無理なケースも.)

1 2 3 4 5 6

6 5 4 3 2 1

t1

t2

45

Page 46: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

他色々: RBST (Randomized Binary Search Tree)

• Treap と同様に,ランダムなノードを根に来させる

• ただし,ノードに優先度など余分な情報が不要!

• merge(a, b)

– n ノードの木 a と m ノードの木 b マージの場合

– 全体 (n + m) ノードから根が等確率で選ばれていれば良い

– 確率 𝑛

𝑛+𝑚 で a の根を新しい根,確率

𝑚

𝑛+𝑚 で b の根を新しい根に

– これを,必要に応じて乱数を発生して決める

Treap よりこっちのほうが構造体がシンプルになってカッコイイかも

46

Page 47: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

他色々: スプレー木

• 一見よくわからん回転を繰り返す

• でも実はそれで平衡される!という不思議系データ構造

• 基本: ノード 𝑥 にアクセスする際,そのついでに回転を

繰り返して 𝑥 を木の根まで持ってくる

– この行為をスプレーと呼ぶ.splay(𝑥)

– ただし,回転のさせ方にちょっと工夫

• ならし 𝑂(log 𝑛) 時間で操作 (ポテンシャル解析)

• コンテスト界でそこそこ人気

47

Page 48: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

他色々: スプレー木 回転ルール: 下図を覚えさえすれば OK

x

y

z

z

y

x

y

z

x

• x が上に行くようにどんどん回転!

• ただし,2 つ親まで見る

– そこまで直線になってたら直線のままになるように y, x の順で回転 (上図)

– そうなってなかったら普通に x を上に行かせる回転 2 連発

• 詳しくは http://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%97%E3%83%AC%E3%83%BC%E6%9C%A8

普通にやるとこっち

になっちゃう

48

Page 49: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

他色々: Block Linked List

• 平方分割をリストでやろう的な物

• サイズ 𝑛 程度のブロックに分けて,スキップできるように

• ブロックのサイズが変化してきたら調整

– 2 𝑛 を超えたら 2 つに分割

– 連続する 2 ブロックのサイズの和が 𝑛/2 未満になったら併合

– こうしとけば常にどこでも 𝑂( 𝑛) で辿れる!

• Wikipedia の中国語にだけ載ってる

(木じゃないですが似たような機能ができるので仲間ということで)

49

Page 50: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

他色々: Skip List

• リストの階層

– 最下層は通常のソートされた連結リスト

– 層 𝑖 に存在する要素は確率 0.5 で層 𝑖 + 1 に存在

• 高い層をできるだけ使って移動,𝑂 log 𝑛

• Path-copying による永続化ができない

(やっぱり木じゃないですが似たような機能ができるので仲間)

[http://en.wikipedia.org/wiki/Skip_list]

50

Page 51: プログラミングコンテストでのデータ構造 2 ~平衡二分探索木編~

平衡二分探索木まとめ

• まずはもっと容易な道具を検討!

– 配列, リスト, std::map,BIT,セグメント木,バケット法

– 必要な場所だけ作るセグメント木

• 実装が楽な平衡二分探索木を選ぼう – 今回: Treap / Randomized Binary Search Tree

– 他: スプレー木, Scapegoat 木, Block Linked List, Skip List

• 実装しよう

– insert / erase ベース vs. merge / split ベース

– 更新遅延

51