問題
値域 $[0, U)$($U = 2^{20}$)の整数集合を管理し、Q個のクエリを順に処理せよ。
I x: 集合に $x$ を追加する(既に存在する場合は何もしない)D x: 集合から $x$ を削除する(存在しない場合は何もしない)S x: $x$ より真に大きい最小の要素(successor)を出力。無ければ-1P x: $x$ より真に小さい最大の要素(predecessor)を出力。無ければ-1
入力形式
Q
query_1
...
query_Q
制約
$1 \le Q \le 2\times10^5$
$0 \le x < 2^{20}$
入出力例
入力例1
8
I 3
I 7
I 9
I 14
S 5
P 10
I 6
S 5
出力例1
7
9
6
入力例2(直前の状態から継続)
2
D 7
S 6
出力例2
9
概念図: 代表元だけを俯瞰しバケット内部を分業する
ヒント(段階的開示)
ヒント1: 方向性
X-fast Trie(ビット文字列トライ+各レベルのハッシュテーブル)はsuccessor/predecessorを$O(\log\log U)$で処理できるが、メモリを$O(N\log U)$消費する。Y-fast Trieは「集合全体を管理するのではなく、$O(\log U)$個おきの代表元だけをX-fast Trieに乗せ、残りは代表元ごとの小さな構造に分散させる」ことでメモリを$O(N)$に削減する。
ヒント2: アプローチ
値域を幅$\Theta(\log U)$の連続したバケットに分割し、各バケットの代表元だけを疎な集合として管理する。挿入・削除はまず$x$がどのバケットに属するかを特定し、そのバケット内部の小さな集合に対して操作を行う。
ヒント3: 誘導(コード骨格)
import bisect
BUCKET_BITS = 10 # バケット幅 = 2^10 = 1024
representatives = [] # バケット代表元(id)のソート済みリスト
buckets = {} # bucket_id -> ソート済みリスト
模範解答 (Python)
import sys
import bisect
def solve():
data = sys.stdin.buffer.read().split()
idx = 0
Q = int(data[idx]); idx += 1
BUCKET_BITS = 10
buckets = {}
rep_list = []
def bucket_id(x):
return x >> BUCKET_BITS
def insert(x):
bid = bucket_id(x)
if bid not in buckets:
buckets[bid] = []
bisect.insort(rep_list, bid)
lst = buckets[bid]
pos = bisect.bisect_left(lst, x)
if pos == len(lst) or lst[pos] != x:
lst.insert(pos, x)
def delete(x):
bid = bucket_id(x)
if bid not in buckets:
return
lst = buckets[bid]
pos = bisect.bisect_left(lst, x)
if pos < len(lst) and lst[pos] == x:
lst.pop(pos)
if not lst:
del buckets[bid]
rp = bisect.bisect_left(rep_list, bid)
rep_list.pop(rp)
def successor(x):
bid = bucket_id(x)
if bid in buckets:
lst = buckets[bid]
pos = bisect.bisect_right(lst, x)
if pos < len(lst):
return lst[pos]
rp = bisect.bisect_right(rep_list, bid)
if rp < len(rep_list):
nb = rep_list[rp]
return buckets[nb][0]
return -1
def predecessor(x):
bid = bucket_id(x)
if bid in buckets:
lst = buckets[bid]
pos = bisect.bisect_left(lst, x)
if pos > 0:
return lst[pos - 1]
rp = bisect.bisect_left(rep_list, bid)
if rp > 0:
pb = rep_list[rp - 1]
return buckets[pb][-1]
return -1
out = []
for _ in range(Q):
t = data[idx]; idx += 1
x = int(data[idx]); idx += 1
if t == b'I':
insert(x)
elif t == b'D':
delete(x)
elif t == b'S':
out.append(str(successor(x)))
else:
out.append(str(predecessor(x)))
print('\n'.join(out))
solve()
計算量: 本解答はバケット幅を固定した簡略版であり、各操作はバケット内の`bisect`挿入(最悪O(bucket size))に依存する。真のY-fast Trieでは代表元側をハッシュベースのX-fast Trieに、バケット側を平衡二分探索木にすることで償却$O(\log\log U)$を達成する。入力例1・2の手計算シーケンスと突き合わせ、全出力が一致することを確認済み。
Step-by-Step 解説
1X-fast Trieの弱点
各レベルにハッシュテーブルを持たせるため挿入のたびに$O(\log U)$個のノードを生成し、メモリが$O(N\log U)$に膨らむ。
各レベルにハッシュテーブルを持たせるため挿入のたびに$O(\log U)$個のノードを生成し、メモリが$O(N\log U)$に膨らむ。
2バケット化による疎化
値域を$\Theta(\log U)$幅のバケットに分割し、各バケットの代表元だけを俯瞰する構造に登録することで全体のメモリを$O(N)$に抑える。
値域を$\Theta(\log U)$幅のバケットに分割し、各バケットの代表元だけを俯瞰する構造に登録することで全体のメモリを$O(N)$に抑える。
3successorクエリの2段階探索
まず$x$が属するバケット内を調べ、なければ次に空でないバケットを代表元リストから探す。
まず$x$が属するバケット内を調べ、なければ次に空でないバケットを代表元リストから探す。
4バケットの生成・消滅管理
最初の要素が入るときに代表元リストへ登録し、最後の要素が消えるときに代表元リストから削除する。
最初の要素が入るときに代表元リストへ登録し、最後の要素が消えるときに代表元リストから削除する。
5理論上の計算量との対応
真のY-fast Trieでは代表元側をX-fast Trie、バケット側を平衡二分探索木で持たせ、全操作を償却$O(\log\log U)$にできる。
真のY-fast Trieでは代表元側をX-fast Trie、バケット側を平衡二分探索木で持たせ、全操作を償却$O(\log\log U)$にできる。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| バケットが空になったのに代表元リストから消し忘れる | バケット内の要素だけ消して満足してしまう | バケットが空になったらdel buckets[bid]しrep_listからも削除する |
| successorで同じバケット内の探索を怠り次バケットへ進む | バケット構造を単なる目印としか思っていない | まず現在のバケット内でbisect_rightにより候補を探す |
| バケット幅を大きくしすぎて1バケットに集中する | BUCKET_BITSの意味を軽視する | Nの見積もりに応じてBUCKET_BITSを調整する |
| 存在しない要素をDELETEしようとしてエラーになる | 削除は必ず成功するという前提でコードを書く | bisect_leftで見つかった場合のみpopする |
次のステップ
- 発展: 代表元側を真のX-fast Trieに置き換え、バケット側を平衡二分探索木にして理論通りの償却$O(\log\log U)$を達成する。
- 次回予告: Stable Roommates Problem(安定ルームメイト問題・Irvingのアルゴリズム)