※この記事は説明の都合上AtCoderで出題された問題に関するネタバレが含まれています。ご了承ください。
はじめに
平衡二分(探索)木というデータ構造があります。(Wikipedia)
名前 | 操作 | 計算量 |
---|---|---|
insert(x) |
要素 |
|
erase(x) |
要素 |
|
find(x) |
要素 |
などの操作が可能で、C++ではstd::set
やstd::map
等の連想コンテナの実装に用いられています。
このstd::set
, std::map
というのは優れもので、標準ライブラリ中のデータ構造でありながら
-
lower_bound(x)
: 以上の最小の要素 -
upper_bound(x)
: 以下の最大の要素
を
「……よって、平衡二分木(C++ではsetなど)を使うことで解くことができます。」
と書かれていることがしばしばあります。
残念ながら、Pythonにはそのような組み込みのデータ構造が存在しないため、解説の通りにコードを組むことができません。そこで本記事では、平衡二分木が欲しい場面で代用できる(ある程度汎用的な)手法をいくつか紹介したいと思います。
扱う問題
ABC128-E Roadwork を題材として使用します。(問題リンク)
解説にも書かれているように、この問題は
クエリ | 操作 |
---|---|
insert(x) |
集合に要素 |
erase(x) |
集合から要素 |
minimum() |
集合内の最小の要素を検索 |
というクエリをこなす問題です。最小の要素は、充分小さな数を-INF
としてlower_bound(-INF)
を計算すればよいので、まさに平衡二分木の出番、という問題です。
解決法1: 平衡二分木を作る
身も蓋もないですが、平衡二分木がないなら自分で作ればよいのです。
幸いにも自作の平衡二分木ライブラリを公開してくれている方が何人もいらっしゃるので、自力で実装するのは難しい……という方でも簡単に使うことができます。大感謝ですね。
じゅっぴーさんのブログ
(2020/10/06 追記:URLが切れていたので差し替えました)
ただし、自作の複雑なデータ構造は往々にして最適化が働きづらく、定数倍はかなり重くなります。そのため、せっかく平衡二分木を用意したのにTLE、となってしまうことが少なくありません。
平衡二分木を使わずに、平衡二分木と同じ操作はできないでしょうか?
補足:
AVL木、実はedamatさん(@edamat1 )がPypy用に書き直してくれた(再帰をなくしたりしてくれた)のがあり、そちらだと割と通るそう
— じゅっぴー (@juppyjappy) May 26, 2020
解決法2: BIT(またはセグメント木)
おそらくこれが一番汎用的な手法かと思います。それぞれのデータ構造を知らない人は調べてください。実装は大差がないため、ここではBITを用いることとします。BITの配列を
先述した平衡二分木に対する操作を、以下のように対応付けて考えます。
クエリ | 操作 |
---|---|
insert(x) |
|
erase(x) |
|
minimum() |
|
lower_bound(x) |
こうすると、平衡二分木と同様にこれらの操作をすべて
挿入する要素
解決策2.5: 座標圧縮 + BIT(またはセグメント木)
今回の問題はオフラインクエリであり、最初の段階で将来挿入する値がすべてわかります。そこで、これらの値を全てまとめて昇順にソートし、新たに
提出コード(手持ちのライブラリではlower_bound(x)
の定義が「累積和が
TLEしてしまいました……
解決法3: 優先度付きキュー
ところで、erase
クエリがない場合を考えてみましょう。
クエリ | 操作 |
---|---|
insert(x) |
集合に要素 |
minimum() |
集合内の最小の要素を検索 |
これならば、Pythonにある優先度付きキューのライブラリを用いることで実現できます。
しかし、erase
クエリを行うには、先頭から線形探索して削除する必要があり、
これを解決するために、優先度付きキューを二本使った以下のようなテクニックが使えます。
まず、
import heapq
p = list()
q = list()
def insert(x):
heapq.heappush(p, x)
return
def erase(x):
heapq.heappush(q, x)
return
def minimum():
while q and p[0] == q[0]:
heapq.heappop(p)
heapq.heappop(q)
return p[0]
(これは疑似コードであり、変数のスコープの関係でこのままでは動作しません。)
このアイデアの核は、「現時点での最小値を削除するのでないかぎり、erase
を後回しにしてもminimum
の結果には影響しない」ということです。そこで、erase
クエリは後回し用の優先度付きキュー
minimum
の結果を返す際、現時点での最小値 erase
を行う必要があります。こうして更新された最小値もまた削除する必要があるかもしれないので、削除されていない本当の最小値が確定するまでこれを繰り返します。
一見この操作には
ちなみに
また、以上の議論では不当なerase
(そもそも存在しない要素を削除しようとすること)が存在しないと暗に仮定していましたが、別途「現時点で各要素
提出コード
間に合いました!無事ACです。
解決法4: setでゴリ押し
要素の追加、削除はsetを使えば minimum
には
そこで、各クエリあたりの計算量を抑えるのではなく、クエリ計算する回数自体をを減らすことを考えてみます。発想としては先ほどの「後回しにしてもよい」に近いです。以下の疑似コードをご覧ください。
S = set()
curmin = 10**18 # 現時点での最小値
flag = False # 更新が必要か
def insert(x):
S.add(x)
if x <= curmin:
curmin = x
flag = False
return
def erase(x):
S.remove(x)
if x == curmin:
flag = True
return
def minimum():
if flag:
curmin = min(S)
flag = False
return curmin
コードを見てもらえばなんとなく分かるとは思いますが、なるべく結果を保存しようとしています。このように無駄なminimum
の計算を最大限抑えることで、クエリ一回当たりの平均計算量をなるべく抑えることができます(計算回数はテストケースに依存します)。意地悪なケースではほとんど改善されませんが、完全にランダムなケースでは実行時間が大幅に短くなることもあります。
提出コード
優先度付きキューの解法より速くなりました!驚き
番外編: 別の解法を考える
視点を変えることで別の解法が適用できる場合があります。この問題では二分探索を上手に使う解法があったりします。
おわりに
紹介した各手法は、平衡二分木の代用としてだけではなく、他の問題を解くうえで計算量を削減するヒントにもなるテクニックです。なんとなく覚えておくと役に立つときが来るかもしれません。
コメント