コンピュータサイエンス

ヒープと優先度キュー完全ガイド:配列で木を作る方法

データ構造のヒープはメモリのヒープと名前が同じだけです。全体をソートせず最小値を高速に取り出す規則、配列で木を表す添字計算、優先度キューの実務上の役割をまとめます。

読了 5 分
ヒープと優先度キュー完全ガイド:配列で木を作る方法のカバー画像

スタックとキューの記事の最後で予告したデータ構造シリーズ第2回です。今回の主役はヒープですが、その前に一つ誤解を解きます。

関連記事スタックとキュー完全ガイド:SwiftにStack型がない理由(removeFirstの罠まで)で、背景概念と応用例を確認できます。

**データ構造のヒープはメモリのヒープと名前が同じだけで、関係はありません。**メモリのヒープ領域は動的割り当て領域で、ここで扱うヒープは「最小値(または最大値)をO(1)で取り出す木」です。

メモリ割り当て領域の違いはスタック vs ヒープで分けて説明します。この記事ではデータ構造のHeapとPriority Queueに集中します。

スタック vs ヒープの記事でそのヒープを想像したなら、ここはまったく別の世界です。

ヒープが解決する問題は明確です。

「最も急ぎのものから処理したい。でも全体をソートしておくのはもったいない。」


ソートは過剰だ

最優先のタスクから取り出す待ち行列を作るとします。配列を毎回ソートすると、挿入のたびにO(n log n)です。

ソート済みを保って挿入位置を探しても、要素の移動でO(n)になります。

考えてみれば、必要なのは全体のソートではありません。今最も急ぎの一つがすぐ分かれば十分です。

2番目が誰かは、1番目がいなくなってから分かっても遅くありません。

ヒープが保証するのは、まさにこれだけです。だから挿入も削除もO(log n)で完了します。

「約束を減らすほど速くなる」は、データ構造設計の代表的なトレードオフです。


ヒープの規則:親は子より小さい。それだけ

最小ヒープは完全二分木で、規則は一つだけです。

すべての親は、自分の子以下です。

兄弟同士の大小は問いません。左部分木が右より小さい必要もありません。

親子関係だけ守ればよいのです。この緩さにより、根には常に全体の最小値がありますが、それ以上は保証しません。

挿入(sift up):木の末尾に追加し、親より小さければ交換して上へ移動します。木の高さ分しか動かないためO(log n)です。

最小値の取り出し(sift down):根を外し、末尾要素を根へ移して、二つの子の小さい方と交換しながら下げます。同じくO(log n)です。

最小ヒープの木と配列表現の添字対応を示す図
木なのにポインタが一つもありません

配列で木を作る魔法

ヒープの実装が美しいのはここです。完全二分木は空きを作らず左から埋まるため、ポインタなしで配列に順番に置けます。NIST Heap)。

インデックス:  0   1   2   3   4   5
値:     [1,  3,  2,  7,  4,  5]

添字計算だけで親子関係が分かります。

  • 親:(i - 1) / 2
  • 左の子:2i + 1、右の子:2i + 2

ノードオブジェクトも子ポインタもヒープメモリ割り当てもありません。配列 vs 連結リストで扱うキャッシュ局所性の利点を得ながら、木の論理構造だけを借りています。

Swiftで骨格を書くと次のようになります。

struct MinHeap<Element: Comparable> {
    private var elements: [Element] = []

    var min: Element? { elements.first }   // O(1)

    mutating func insert(_ value: Element) {  // O(log n)
        elements.append(value)
        siftUp(from: elements.count - 1)
    }

    mutating func removeMin() -> Element? {   // O(log n)
        guard !elements.isEmpty else { return nil }
        elements.swapAt(0, elements.count - 1)
        let min = elements.removeLast()
        siftDown(from: 0)
        return min
    }
}

siftUp/siftDownは、上で説明した交換の反復です。

なお、既存配列全体をヒープにするbuild-heapは、要素ごとに挿入するO(n log n)ではありません。下半分からsift downすれば**O(n)**で完了します。面接でよく問われる細部です。


実務でのヒープの立ち位置

**優先度キューはヒープそのものです。**スタック・キューの記事の表現なら、キューは到着順、優先度キューは緊急度順に取り出す待ち行列で、その標準実装がヒープです(NIST Priority Queue)。

  • OSスケジューラ:優先度の高いプロセスからCPUを割り当てる
  • ダイクストラ最短経路:「これまでに見つかった経路で最短のもの」を繰り返し取り出すアルゴリズム。ヒープがなければ性能が崩れます
  • タイマー管理:多数のタイマーのうち、最初に鳴るものだけ分かればよいシステム内部構造
  • ヒープソート:すべて入れてすべて出せばO(n log n)のソートになります。追加メモリなしでその場ソートできるのが強みです
  • Top-K問題:「ストリームから最大のK個を維持する」問題。サイズKの最小ヒープでO(n log K)に解ける、コーディングテスト定番のパターンです

Swift標準ライブラリにヒープはありません。スタック・キュー記事のDequeと同様、Appleのswift-collectionsパッケージがHeapを提供します(swift-collections Heap)。

minとmaxの両方をO(log n)で扱うmin-max heap実装です。コーディングテストのように外部パッケージを使えない環境では、上のMinHeapの骨格を身につけておくのが実践的です。

救急外来のトリアージのように、緊急の患者から処理する優先度キューのたとえを描いたイラスト
並んだ順ではなく、緊急度順に呼びます

まとめ

  • データ構造のヒープはメモリのヒープと名前が同じだけで、「最小値を即座に取り出す木」です
  • 規則は親≤子だけ。全体のソートを諦める代わりに、挿入・削除はO(log n)です
  • 完全二分木なので配列にポインタなしで置けます。親は(i-1)/2、子は2i+12i+2
  • 優先度キューの標準実装がヒープで、ダイクストラ、スケジューラ、Top-Kが代表的な用途です
  • Swiftではswift-collectionsのHeap、コーディングテストでは自前実装が定石です

次回はハッシュテーブルです。Swift DictionaryがどのようにO(1)を実現するのか、Hashableが要求することの本当の意味を扱います。

出典と確認基準