スキップリストと確率的平衡
回転もリバランスもない単純なリンクリストだけで、平衡木と同じ期待O(log n)探索が手に入る。コイン投げで層を決める確率的構造の仕組みと、並行実装で平衡木より有利になる理由を原理から押さえる。
- 1.ソート済み連結リストに飛び越し用の上位層を重ね、各ノードを確率pで上層へ昇格させることで期待O(log n)の探索・挿入・削除を実現する。
- 2.平衡条件を厳密に保つ赤黒木と違い、平衡は確率的に成立する。回転がなくポインタ付け替えが局所的なので実装が単純で、ロックフリー化しやすい。
- 3.最悪計算量はO(n)だが、その確率は指数的に小さい。Redisのsorted setやLevelDBのMemTableなど実システムで広く使われる。
連結リストに「飛び越し」を足す
ソート済みの連結リストは、順序こそ保つものの探索は O(n) です。先頭から1つずつ辿るしかなく、二分探索のような飛び越しができません。配列なら添字でジャンプできますが、リンクリストには添字がない。これがスキップリスト(skip list, William Pugh, 1990年)が解こうとした問題です。
発想はシンプルで、上位に「間引いた」リンクリストを重ねる。最下層(レベル0)は全ノードを含む通常のソート済みリスト。その上のレベル1は2つに1つ、レベル2はさらにその半分…と、上に行くほど疎なリストを積みます。探索は最上層の粗いリストで大きく飛び、行き過ぎたら1つ下の層に降りて細かく辿る。これを繰り返せば、各層で対象範囲を半分に絞り込めます。
レベル3: HEAD ───────────────────────────────→ NIL
レベル2: HEAD ──────────→ 17 ──────────────────→ NIL
レベル1: HEAD ──→ 9 ───→ 17 ──────→ 31 ────────→ NIL
レベル0: HEAD → 6 → 9 → 12 → 17 → 25 → 31 → 38 → NIL
19 を探す: L3でNIL手前まで→L2の17で止まる→L1の17→31の手前→
L0で17の次25を見て「19は無い」と確定。
各層で半分に絞れるなら層の数は約 log2(n)、各層で辿る歩数も平均的に定数。よって探索は期待 O(log n) になります。これは平衡木が回転で達成するのと同じオーダーを、回転なしのリンクリストだけで得ている点が肝です。計算量記法の前提は計算量(Big-O)を参照してください。
平衡を「決定的」でなく「確率的」に保つ
ここで難問が1つあります。「2つに1つを上層へ」を厳密に維持しようとすると、挿入・削除のたびに全体のインデックスを振り直す羽目になり、結局 O(n) かかってしまう。決定的に間引きを保つのは高くつくのです。
Pugh の発想の核心は、間引きをサイコロに任せることでした。ノードを挿入するとき、そのノードを何層まで積むかをコイン投げで決めます。確率 p(典型的には p = 1/2)で「もう1段上げる」を繰り返し、裏が出たら止める。
function randomLevel():
level = 1
while random() < p and level < MAX_LEVEL:
level = level + 1
return level
この手続きでは、レベルが k 以上になる確率は p の (k-1) 乗。つまりレベル k のノードは期待的に全体の p の (k-1) 乗の割合だけ存在します。p = 1/2 なら、レベル0が全ノード、レベル1がその半分、レベル2が4分の1…と、期待値として理想的な間引きが自動的に実現されます。誰も明示的にバランスを取らないのに、確率の法則が平衡を生む。これが「確率的平衡(probabilistic balancing)」です。
個々のノードの層数はランダムですが、層 k のノード数の期待値は n かける p の (k-1) 乗。これにより層の総数は期待 log(1/p) を底とする n の対数、探索で1層あたりに辿る歩数の期待は 1/p で頭打ちになります。両者の積が期待探索コストで、p = 1/2 のとき最小付近に落ち、おおむね 2 log2(n) 回の比較に収まります。重要なのは、入力データの並びに依存せず、乱数の質だけで保証が成立する点です。
挿入と削除 ― 各層の「直前ノード」をつなぎ替えるだけ
挿入は2段階です。まず探索と同じ要領で目的位置まで降りつつ、各層で「挿入位置の直前ノード」を記録します(update 配列)。次に randomLevel() で新ノードの層数を決め、記録した直前ノードのポインタを、新ノードを挟むようにレベル0から決めた層まで付け替えます。
function insert(key):
update[] = 各レベルでkey直前のノードを探索しながら記録
lvl = randomLevel()
new = ノード(key, lvl)
for i in 0 .. lvl-1:
new.next[i] = update[i].next[i]
update[i].next[i] = new # 局所的なポインタ付け替えのみ
ポイントは、触れるのは新ノードが属する層の直前ノードだけで、木のような広域のリバランスが一切起きないこと。削除も対称で、各層で対象の直前ノードのポインタを「対象の次」へ繋ぎ替えるだけです。回転も色塗りもない。この操作の局所性こそ、スキップリストの実装が平衡木より格段に短く書ける理由です。
| 観点 | 赤黒木 / AVL木 | スキップリスト |
|---|---|---|
| 平衡の保証 | 決定的(不変条件を厳守) | 確率的(期待値で成立) |
| 平衡操作 | 回転・色塗替え | なし(ポインタ付け替えのみ) |
| 探索・挿入・削除 | 最悪 O(log n) | 期待 O(log n)・最悪 O(n) |
| 実装の複雑さ | 高い(場合分けが多い) | 低い(数十行で書ける) |
| 範囲・順序走査 | 中順序走査が必要 | レベル0を辿るだけで自然に順序 |
| 並行化 | リバランスが広域で難しい | 局所更新でロックフリー化しやすい |
最悪計算量と、それを恐れない理由
スキップリストの最悪計算量は O(n) です。理論上は、すべてのノードがレベル0にしか積まれない(全部コイン投げで即裏が出た)並びがあり得て、その場合はただの連結リストに退化します。にもかかわらず実用上これが問題にならないのは、その事象の確率が指数的に小さいからです。
n 個の要素で探索が O(log n) を大きく超える確率は n に対して急速にゼロへ近づきます。クイックソートの最悪 O(n^2) が現実にはまず起きないのと同じ理屈で、確率的アルゴリズムの「最悪」は敵対的入力でも避けられる点が決定版アルゴリズムと異なります。配列をソートしてから挿入しても、層数は入力順でなく乱数で決まるため偏りません。性能のばらつきを期待値で評価する考え方はならし計算量とも通じます。
確率的平衡は乱数の独立性に依存します。攻撃者が randomLevel の乱数を予測できると、意図的に全ノードを同一レベルへ寄せてO(n)に劣化させる攻撃が可能です。外部入力をキーにする用途では、シードを秘匿するか暗号論的乱数を使うべきです。ハッシュテーブルのハッシュ衝突攻撃と同種の注意点です。
なぜ並行処理で有利なのか
スキップリストが現代的に重要なのは、並行(concurrent)環境での扱いやすさです。平衡木のリバランスは、回転が木の広い範囲のポインタを同時に書き換えるため、複数スレッドで安全に更新するには広いロックや精緻なロックフリー設計が要ります。回転の途中で他スレッドが読むと木が不整合に見えるからです。
対してスキップリストの更新は、各層で「直前ノードの1本のポインタ」を差し替えるだけの局所操作です。これは CAS(compare-and-swap)一発で原子的に行いやすく、レベル0から上へ順に繋げば、探索中のスレッドからは常に整合した状態に見えます。実際、Java の ConcurrentSkipListMap はロックフリーのスキップリストで、ソート済みの並行マップを提供します。この性質はロックフリープログラミングの典型的な題材です。
「スキップリストの探索計算量は?」→ 期待 O(log n)、最悪 O(n)。「平衡はどう保つ?」→ 回転ではなくコイン投げによる確率的な層割り当て。「赤黒木に対する利点は?」→ 実装が単純で、局所的なポインタ更新ゆえ並行化(ロックフリー化)しやすい。「実例は?」→ Redisのsorted set、LevelDB/RocksDBのMemTable、Java ConcurrentSkipListMap。この4点が定番です。
まとめ
スキップリストは、ソート済み連結リストに確率的に間引いた上位層を重ねるだけで、平衡木と同じ期待 O(log n) を回転なしに実現するデータ構造です。要は「決定的に平衡を作り込む代わりに、コイン投げで期待的に平衡を生む」という発想の転換にあります。最悪 O(n) という弱点は確率が指数的に小さいため実用上無視でき、その代わりに実装の単純さと更新の局所性ゆえの並行化のしやすさという、平衡木にはない実利が得られます。これが、順序付き集合・マップを支える基盤として、データ構造の重要な選択肢にスキップリストが並ぶ理由です。
プログラミング Article
スキップリストと確率的平衡を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
スキップリスト
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
平衡条件を厳密に保つ赤黒木と違い、平衡は確率的に成立する。回転がなくポインタ付け替えが局所的なので実装が単純で、ロックフリー化しやすい。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「スキップリスト / アルゴリズム」に近いか確認する。
- 強みである「ソート済み連結リストに飛び越し用の上位層を重ね、各ノードを確率pで上層へ昇格させることで期待O(log n)の探索・挿入・削除を実現する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。