- 物理機リージョン/AZ
- ハイパーバイザVM exit で捕まえる
- VMゲストOS
- コンテナnamespace / cgroup
- プロセスアプリ / 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) と書かれるが、外れ方が違う
- ・規格は push_back に amortized、ハッシュ表に average case を割り当てている
跳ねる回を、実測で見る
実物を測りました。Python の list に20万回 append し、1回ずつ time.perf_counter_ns() で計測。再確保が起きた回は sys.getsizeof の変化で検出しました。
append 1回の中央値: 83.0 nsappend 1回の平均値: 70.1 nsappend 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 再確保回数 log2(n)10 3 3.3100 11 6.61000 28 10.010000 47 13.3100000 66 16.61000000 86 19.9添字 推定容量 前回比0 4 nan4 8 2.0008 16 2.00016 24 1.50024 32 1.333...70304 79096 1.12579096 88988 1.12588988 100116 1.125
n を10倍にしても、再確保回数は +19 / +19 / +20 しか増えません。 掛け算ではなく足し算、つまり log 的です。n=100万でも86回。だから1回あたりに割り振られる負担は、n が大きくなるほど薄まります。下段は容量の伸び方で、小さいうちは 2.0 倍ですが、大きくなると 1.125 (9/8) に収束しました。
「Python の list は容量が足りなくなると2倍に増えます」の評価として適切なのはどれですか。
ならし計算量 — vector の push_back
規格は amortized constant time と規定し、再確保が起きた回は linear だとも書いています。1回1回は最悪 O(n) でも、合計を回数で割ると O(1) です。入力が何であっても保証されます。実測では最大の1回が中央値の231倍まで跳ねましたが、20万回の平均は 70.1 ns でした。
平均計算量 — 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回あたりの上限を保証しない。 応答時間に上限が要る場面の根拠にはならない。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「ならし (amortized)」と「平均 (average case)」の違いはどれですか。
用語集(3語)
- ならし計算量 (amortized)
- 操作列の合計を操作の回数で割ったもの。確率は登場せず、入力が何であれ合計が収まることを保証する。個々の1回の上限は約束しない。
- 平均計算量 (average case)
- 入力の分布に仮定を置いたときの期待値。仮定が崩れれば最悪へ落ちる。C++ 標準ドラフトはハッシュ表にこの語を使い、最悪は線形だと併記している。
- 再確保 (reallocation)
- 容量が足りなくなったときに、より大きい領域を確保して既存の要素を移す操作。C++ 標準ドラフトは、これが起きた回の push_back は線形だと規定している。