Dev Study
← データ構造とアルゴリズムの土台
レッスン 11 / 13·約 9 分·第11期 データ構造とアルゴリズムの土台

ならし計算量は、平均ではない

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / DB今ここ

本コースの3本目で、ハッシュ表の平均 O(1) が何を仮定しているかを見ました。今回はもう一つの O(1)、合計を回数で割る「ならし」を扱います。

まず結論

配列の末尾への追加は、1回1回を見ると最悪 O(n) かかります。それでも n 回の合計を回数で割ると O(1) に収まります。これが「ならし (amortized)」です。

平均計算量とは別物です。ならしは合計を割った保証、平均は入力の分布を仮定した推定で、C++ 標準ドラフトはこの2語を別の対象に書き分けています。

? 読む前に予測

配列の末尾に要素を1つ足す操作が「ならし O(1)」だとします。この1回にかかる時間について正しいのはどれでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

合計で割る、という別の測り方

計算量には「1回あたり」を言うやり方のほかに、一続きの操作の合計を、操作の回数で割るやり方があります。後者が「ならし (amortized)」です。

配列の末尾への追加を考えます。空きがあるうちは置いて終わりですが、空きが尽きたら、より大きい領域を確保して既存の要素を全部そこへ移します。この回だけは要素数に比例した手間がかかります。つまり1回1回を見ると、最悪 O(n) です。

ところが確保のたびに容量を一定の倍率で増やしていくと、この重い回が来る間隔が開いていきます。n 回追加したときの移動の総量は、n に比例する範囲に収まります。合計が O(n) なら、回数 n で割って1回あたり O(1)。 これがならし計算量です。

ここに確率は出てきません。入力が何であれ合計はこの範囲に収まる、という保証です。本コースの3本目で見たハッシュ表の平均 O(1) とは前提が違います。 あちらは鍵がよく散らばるという仮定の上に立っていました。

具体実物

規格が2つの語を書き分けている

この違いは教える側の便宜ではありません。C++ 標準ドラフトが、同一文書の中で2語を別の対象に割り当てています。 列コンテナ側、[sequence.reqmts] パラグラフ70。この直後に push_back などが列挙されています。

Operations other than prepend_range and append_range are implemented so as to take amortized constant time.

ハッシュ表側、[unord.req] の冒頭。

The worst-case complexity for most operations is linear, but the average case is much faster.

push_back には amortized、ハッシュ表には average case と worst case。 同じ規格が、指すものに応じて語を使い分けています。そして [vector.modifiers] が、重い回の正体を明記しています。

Complexity: If reallocation happens, linear in the number of elements of the resulting vector; otherwise, linear in the number of elements inserted plus the distance to the end of the vector.

再確保が起きた回は linear。それでも全体としては amortized constant。「1回1回は最悪 O(n) なのに、ならすと O(1)」が、規格の2条項だけで言い切れます。

なお [vector.capacity] には逃げ道の規定もあります。reserve() を呼んだ後は容量を超えるまで再確保が起きません。代価を払いたくなければ、先に確保しておけばよいということです。

✓ ここまでの確認

C++ 標準ドラフトは push_back を amortized constant time と規定しています。[vector.modifiers] は再確保が起きた場合を何と書いていますか。

抽象原理

ならしが保証しないもの

ならしが保証するのは合計です。個々の1回については何も約束していません。 合計が安いことと最悪の1回が短いことは、別の主張です。

平均計算量のほうは崩れ方が違います。仮定を置いているので、仮定が外れると保証ごと消えます。 鍵が偏れば、平均 O(1) だったはずの検索が最悪 O(n) に落ちます。ならしは仮定を置いていないのでこの崩れ方はしませんが、代わりに跳ねた回そのものが残ります。

同じ「O(1)」でも、何を根拠にしているかが違う
ならし O(1)amortized
平均 O(1)average case
何を根拠にしているか
n 回の合計が O(n)回数で割った結果
入力の分布の仮定鍵がよく散らばる
どう崩れるか
崩れない跳ねた回は残る
1回の上限は未保証
仮定が外れると最悪 O(n) へ保証ごと消える
  • ・どちらも O(1) と書かれるが、外れ方が違う
  • ・規格は push_back に amortized、ハッシュ表に average case を割り当てている
具体実物

跳ねる回を、実測で見る

実物を測りました。Python の list に20万回 append し、1回ずつ time.perf_counter_ns() で計測。再確保が起きた回は sys.getsizeof の変化で検出しました。

append 1回ごとの所要時間(n=200000)コマンド出力
append 1回の中央値: 83.0 ns
append 1回の平均値: 70.1 ns
append 1回の最大値: 19208 ns (添字 9452)
合計: 14.02 ms / 200000 回 → 1回あたり平均 70.1 ns
 
再確保が起きた回の所要時間 中央値: 1312.5 ns (n=72)
それ以外の回の所要時間 中央値: 83.0 ns
 
最大値 19208 ns が 1回でも、200000回の平均は 70.1 ns
最大値 / 中央値 = 231 倍

測定環境は Apple Silicon 上の Docker、コンテナ内は Python 3.12.14 / Linux 6.10.14-linuxkit-aarch64、1環境での1回の測定です。絶対値は一般則ではありません。

読みどころは3つ。再確保の回は中央値 1312.5 ns、それ以外は 83.0 ns で約16倍。 最大の1回は 19208 ns で中央値の231倍。 それでも20万回の平均は 70.1 ns。跳ねた回の影響は、ならすと消えています。

再確保の回数も測りました。

n を変えたときの再確保回数と、容量の伸び方コマンド出力
n 再確保回数 log2(n)
10 3 3.3
100 11 6.6
1000 28 10.0
10000 47 13.3
100000 66 16.6
1000000 86 19.9
 
添字 推定容量 前回比
0 4 nan
4 8 2.000
8 16 2.000
16 24 1.500
24 32 1.333
...
70304 79096 1.125
79096 88988 1.125
88988 100116 1.125

n を10倍にしても、再確保回数は +19 / +19 / +20 しか増えません。 掛け算ではなく足し算、つまり log 的です。n=100万でも86回。だから1回あたりに割り振られる負担は、n が大きくなるほど薄まります。下段は容量の伸び方で、小さいうちは 2.0 倍ですが、大きくなると 1.125 (9/8) に収束しました。

✓ ここまでの確認

「Python の list は容量が足りなくなると2倍に増えます」の評価として適切なのはどれですか。

⇄ 2つを見比べる
具体例 A

ならし計算量 — vector の push_back

規格は amortized constant time と規定し、再確保が起きた回は linear だとも書いています。1回1回は最悪 O(n) でも、合計を回数で割ると O(1) です。入力が何であっても保証されます。実測では最大の1回が中央値の231倍まで跳ねましたが、20万回の平均は 70.1 ns でした。

具体例 B

平均計算量 — unordered_map の検索

規格は Average case O(1), worst case O(a.size()) と併記しています。鍵がよく散らばるという仮定の上での O(1) で、偏れば同じバケットに集まり、最悪は要素数に比例します。仮定が成り立つかはデータの側で決まります。

push_back もハッシュ表の検索も O(1) と呼ばれます。規格が別の語を割り当てているのは、何が違うからでしょうか。

「どうすればこの O(1) が成り立たなくなるか」を、それぞれで考えてみてください。

この章のまとめ

  • ならしは操作列の合計を回数で割った値で、分布の仮定を使わない。平均は入力の分布を仮定した期待値で、仮定が外れれば最悪へ落ちる。C++ 標準ドラフトは push_back に amortized、ハッシュ表に average case と worst case を割り当てている。
  • 同じ規格の [vector.modifiers] が「再確保が起きた回は linear」と明記。1回1回は最悪 O(n) なのに、ならすと O(1) が2条項で言い切れる。
  • 実測(Python 3.12.14・n=200000)では、再確保の回が中央値 1312.5 ns、それ以外が 83.0 ns。最大は中央値の231倍。それでも20万回の平均は 70.1 ns。
  • ただし遅かった上位20件のうち8件は再確保ではない。「遅い回はすべて再確保」とは言えない。
  • 増加率に公式の規定は無い。 実測の 1.125 は CPython 3.12.14 の挙動で、仕様ではない。
  • ならしは1回あたりの上限を保証しない。 応答時間に上限が要る場面の根拠にはならない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

「ならし (amortized)」と「平均 (average case)」の違いはどれですか。

用語集(3語)
ならし計算量 (amortized)
操作列の合計を操作の回数で割ったもの。確率は登場せず、入力が何であれ合計が収まることを保証する。個々の1回の上限は約束しない。
平均計算量 (average case)
入力の分布に仮定を置いたときの期待値。仮定が崩れれば最悪へ落ちる。C++ 標準ドラフトはハッシュ表にこの語を使い、最悪は線形だと併記している。
再確保 (reallocation)
容量が足りなくなったときに、より大きい領域を確保して既存の要素を移す操作。C++ 標準ドラフトは、これが起きた回の push_back は線形だと規定している。