- 物理機リージョン/AZ
- ハイパーバイザVM exit で捕まえる
- VMゲストOS
- コンテナnamespace / cgroup
- プロセスアプリ / DB今ここ
第11期『取り出す順序が、解ける問題を決める』で heap invariant と費用の書かれ方を見ました。今回はその中身、配列の添字で木を表す仕組みを扱います。
まず結論
ヒープは、隙間の無い二分木を上の段から左詰めで配列に並べた構造です。そうすると k 番の子は 2k+1 と 2k+2、親は (k-1)//2 と、添字の計算だけで決まります。ポインタも、節点を作り直す手間も要りません。
使っている前提は「木の形が完全であること」の1つです。全体を一度に積み上げる heapify が線形で済むのも同じ形のおかげで、深い段ほど節点が少ないからです。
100,000 → 1 の降順に並んだ10万個からヒープを作ります。heapq.heapify を1回呼ぶ方法と、空のリストに heappush を10万回呼ぶ方法では、比較回数はどうなるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
隙間が無ければ、番号を振るだけで親子が決まる
木はふつう、節点ごとに子への参照(ポインタ)を持たせます。ヒープはこれを持ちません。代わりに木の形を1つに決めてしまいます。
最下段以外はすべて埋まっていて、最下段も左から詰まっている形です。この形なら、上の段から左へ順に 0, 1, 2, … と番号を振ると、番号だけで位置が決まります。
- k 番の子は 2k+1 と 2k+2
- k 番の親は (k-1)//2(切り捨ての割り算)
配列の添字をそのまま節点の番号に使えば、木は連続した配列1本に収まります。
- ・逆向きは (k-1)//2。k=5 の親は (5-1)//2 = 2
- ・どの親も子以下になっているが、配列全体は整列していない(8 の右に 4 がある)
heap invariant の定義と、取り出しの費用の書かれ方は、第11期『取り出す順序が、解ける問題を決める』で見ました。ここで足すのは、その木がなぜ配列1本で済むのかです。
heapq.py は、この計算を不変式に書いている
CPython の Lib/heapq.py(3.14 ブランチ、2026-10-07 取得)の冒頭です。
Heaps are arrays for which a[k] <= a[2k+1] and a[k] <= a[2k+2] for all k, counting elements from 0. For the sake of comparison, non-existing elements are considered to be infinite.
木という語より先に arrays とあり、不変式も添字の計算 2*k+1 で書かれています。同じファイルの解説(François Pinard)は、0〜30 の番号を木の形に並べた図のあとでこう述べます。
In the tree above, each cell 'k' is topping '2k+1' and '2k+2'.
降順の7個を heapify に掛けて、親を (k-1)//2 で引いてみます(Python 3.14.6 / macOS arm64、2026-10-07)。
[3, 8, 4, 9, 15, 20, 11]k=1 値= 8 親 k=0 値= 3 親<=子: Truek=2 値= 4 親 k=0 値= 3 親<=子: Truek=3 値= 9 親 k=1 値= 8 親<=子: Truek=4 値=15 親 k=1 値= 8 親<=子: Truek=5 値=20 親 k=2 値= 4 親<=子: Truek=6 値=11 親 k=2 値= 4 親<=子: True
入力 [20, 15, 11, 9, 8, 4, 3] は [3, 8, 4, 9, 15, 20, 11] になりました。整列はしていませんが、どの親も子以下です。
ヒープが、ポインタを持たない配列だけで親子をたどれるのは、どんな前提があるからですか。
積み上げが線形なのは、深い節点ほど少ないから
配列全体をヒープに直す heapify の実装は、要約すると for i in reversed(range(n//2)): _siftup(x, i) です(heapq.py では2行に分かれています)。最後の親から根へ向かって、1つずつ下へ沈めて(ふるい下げて)いきます。1つの節点にかかる手間は、そこから葉までの段数で決まります。
ここで段ごとに数えます。n = 100,000 の木で、各節点から最も深い葉までの段数(高さ)と節点数を数えました。
| 最も深い葉までの段数 | 節点数 | 段数 × 節点数 |
|---|---|---|
| 0(葉) | 50,000 | 0 |
| 1 | 25,000 | 25,000 |
| 2 | 12,500 | 25,000 |
| 3 | 6,250 | 18,750 |
| … | … | … |
| 16(根) | 1 | 16 |
| 合計 | 100,000 | 99,994 |
半分は葉で、仕事が0です。 深く沈む必要がある節点ほど数が少ないので、合計は 99,994 となり n を超えません。n log₂ n ≈ 1,660,964 とは桁が違います。
heappush を n 回呼ぶ方法では、これが逆になります。新しい要素は末尾に入り、そこから上ります。後半の5万個は、どれも深さ15段以上の位置に入ります。遠くまで上る要素ほど多いのです。降順では毎回根まで上るので、比較回数は各要素が入った位置の深さの合計 1,468,946 と一致しました。
入力 heapify heappush n 回昇順 199,983 (2n−17) 99,999 (n−1)降順 149,988 (1.500n) 1,468,946 (14.7n)乱数 164,730 (1.647n) 228,389 (2.28n) ※乱数は1回の試行の値(種と引く順で変わる)
正直に書いておくべき点が2つあります。
- 昇順では、heapify が 2n をわずかに下回るだけです。 CPython は、小さいほうの子をいったん葉まで持ち上げてから、元の値を戻します。下りと戻りの両方を払うので、上限は 2 × 99,994 = 199,988 回です。実測の 199,983 はその手前でした。
- 昇順では heappush n 回(n−1 回)のほうが少なくなります。 新しい値が親以上なので、1回比べて止まるからです。heapify が勝つのは降順と乱数の場合です。
「線形」は上限の増え方についての言い方で、どの入力でも勝つという意味ではありません。
Go のタイマーは、同じ計算を4で割っている
同じ「隙間の無い木を配列に詰める」形を、分岐4で使っている実物があります。Go ランタイムの src/runtime/time.go です(master、2026-10-07 取得)。
const timerHeapN = 4
p := int(uint(i-1) / timerHeapN) // parent
leftChild := i*timerHeapN + 1
親は (i-1)/4、最初の子は i*4+1 で、2 が 4 に変わっただけです。この値は master・go1.25・go1.24・go1.23 で 4 です。go1.22 は定数ではなく (i - 1) / 4 の直書きで、// The heap is 4-ary. See siftupTimer and siftdownTimer. とコメントされています。
積み上げの費用も、ソースのコメントに書かれています。
// initHeap reestablishes the heap order in the slice ts.heap. // It takes O(n) time for n=len(ts.heap), not the O(n log n) of n repeated add operations.
分岐を増やすと何が起きるかは、添字の計算から出せます。10万件の末尾の深さは、二分で16段、4分で9段です。代わりに siftDown は、1段ごとに最大4つの子と比べます。段数を減らし、1段の仕事を増やす交換です。第11期『木を低くすると、読む回数が減る』で見た、扇出しと段数の関係と同じ形です。
ただし、なぜ4なのかはソースに書かれていません。 この「交換」という説明は教材側の解釈で、Go のコメントの主張ではありません。
Go ランタイムのタイマーは、二分ではなく4分のヒープを使っています。分岐を2から4にすると何が変わりますか。ソースから言える範囲で選んでください。
Python heapq(2分ヒープ)
子は 2k+1 と 2k+2、親は (k-1)//2。不変式そのものが a[k] <= a[2*k+1] と添字で書かれている。heapify は in linear time と公式が書く。
Go ランタイムのタイマー(4分ヒープ)
親は (i-1)/timerHeapN、最初の子は i*timerHeapN+1。timerHeapN = 4。initHeap は O(n) とコメントにある。4を選んだ理由は書かれていない。
分岐2のヒープと分岐4のヒープ。どちらもポインタを持たずに済んでいる理由と、分岐を変えると何が動くかを説明してください。
まず「添字の計算が成り立つ条件」を言い、次に10万件で段数がどう変わり、1段で何回比べるかを並べてみてください。
この章のまとめ
- 隙間の無い木は、配列1本に詰められる。 子は 2k+1・2k+2、親は (k-1)//2。ポインタは要らない。
- 前提は木の形だけ。 だから追加は末尾、取り出しは末尾を根へ移してから沈める。値は全体が整列している必要はない。
- 積み上げ(heapify)が線形なのは、深い節点ほど少ないから。 n=10万で、最も深い葉までの段数の合計は 99,994 で n を超えない。
- 線形は「どの入力でも勝つ」ではない。 昇順では heapify が 199,983 回(2n−17)、heappush n 回が 99,999 回だった。heapify が勝つのは降順(149,988 対 1,468,946)と乱数。
- Go のタイマーは同じ計算を4で割っている。 段数が減り、1段で比べる子が増える。4を選んだ理由はソースに無い。
- 既存クイズでは、「アルゴリズムとデータ構造」の algorithm-15、「II(応用編)」の algorithm-2-3・algorithm-2-19 がこの章の結論の一行版です。
CLRS(『アルゴリズムイントロダクション』)の目次や索引で heap / heapsort(ヒープ) を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「heapify は、heappush を n 回呼ぶより常に比較が少ない」という説明の評価として正しいのはどれですか。
用語集(4語)
- 完全二分木(配列に詰められる形)
- 最下段以外が埋まり、最下段も左から詰まった二分木。上から番号を振ると k 番の子が 2k+1・2k+2、親が (k-1)//2。
- ふるい下げ(sift-down)
- 親が子以下という条件を満たさない節点を、小さいほうの子と入れ替えながら下へ沈める操作。手間はその節点の高さで決まる。CPython ではこの関数名が _siftup。
- 積み上げ(heapify)
- 配列全体をその場でヒープに直す操作。最後の親から根へ向かって順にふるい下げる。半分は葉で何もせず、深い節点ほど少ないので合計は線形。
- d 分ヒープ
- 各節点が最大 d 個の子を持つヒープ。親は (i-1)/d、最初の子は i*d+1。d が大きいほど段数が減り、1段で比べる子が増える。Go のタイマーは d=4。
出典(一次ソース)
- CPython Lib/heapq.py(3.14 ブランチ) ↗
- Python 公式ドキュメント — heapq ↗
- Go src/runtime/time.go(master) ↗
- Go src/runtime/time.go(release-branch.go1.22) ↗
- Williams, J. W. J. Algorithm 232: Heapsort. CACM 7(6), 347–348, 1964 ↗
- Floyd, R. W. Algorithm 245: Treesort(Crossref の表記。Floyd の業績一覧では Treesort 3). CACM 7(12), 701, 1964 ↗