Dev Study
← アルゴリズムの手法 — CLRS を読むための地図
レッスン 5 / 13·約 9 分·第29期 アルゴリズムの手法 — CLRS を読むための地図

配列に詰めた木 — ヒープは添字で親子をたどる

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / 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本に収まります。

配列 [3, 8, 4, 9, 15, 20, 11] を木として読む(数字は添字 k)
k=0値 3
k=0 の子は 2·0+1=1 と 2·0+2=2
k=1値 8
k=2値 4
k=1 の子は 3・4、k=2 の子は 5・6
k=3値 9
k=4値 15
k=5値 20
k=6値 11
  • ・逆向きは (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)。

$ python3 -(下の「現場で確かめる」の1つ目)コマンド出力
[3, 8, 4, 9, 15, 20, 11]
k=1 値= 8 親 k=0 値= 3 親<=子: True
k=2 値= 4 親 k=0 値= 3 親<=子: True
k=3 値= 9 親 k=1 値= 8 親<=子: True
k=4 値=15 親 k=1 値= 8 親<=子: True
k=5 値=20 親 k=2 値= 4 親<=子: True
k=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,0000
125,00025,000
212,50025,000
36,25018,750
………
16(根)116
合計100,00099,994

半分は葉で、仕事が0です。 深く沈む必要がある節点ほど数が少ないので、合計は 99,994 となり n を超えません。n log₂ n ≈ 1,660,964 とは桁が違います。

heappush を n 回呼ぶ方法では、これが逆になります。新しい要素は末尾に入り、そこから上ります。後半の5万個は、どれも深さ15段以上の位置に入ります。遠くまで上る要素ほど多いのです。降順では毎回根まで上るので、比較回数は各要素が入った位置の深さの合計 1,468,946 と一致しました。

__lt__ を数えた比較回数 n=100,000(Python 3.14.6 / macOS arm64、2026-10-07)コマンド出力
入力 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にすると何が変わりますか。ソースから言える範囲で選んでください。

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

Python heapq(2分ヒープ)

子は 2k+1 と 2k+2、親は (k-1)//2。不変式そのものが a[k] <= a[2*k+1] と添字で書かれている。heapify は in linear time と公式が書く。

具体例 B

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(ヒープ) を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

「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。