ヒープと優先度付きキュー入門 - O(log n)で最小・最大を取り出すデータ構造

ヒープと優先度付きキュー入門 - O(log n)で最小・最大を取り出すデータ構造

作成日:
読了:18
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

ToDoリストの中から「いちばん急ぎの1件」だけをすばやく取り出したい——そんな場面はプログラミングのあちこちに現れます。毎回リスト全体を見渡して最小(または最大)を探すと O(n) かかりますが、優先度付きキューと、その代表的な実装である二分ヒープを使えば、取り出しも追加も O(log n) で済みます。この記事では、優先度付きキューという抽象データ型と二分ヒープの仕組みを、配列表現・sift-up / sift-down・計算量まで、Pythonの動くコードで順に整理します。最短経路を求めるグラフ探索アルゴリズム入門や、並べ替えのソートアルゴリズム入門と同じアルゴリズム入門シリーズの1本です。

優先度付きキューとは — 「次に処理すべき1件」を効率よく取り出す

優先度付きキュー(priority queue)は、具体的なデータの並べ方ではなく「どんな操作ができるか」を定めた抽象データ型(ADT)です。主な操作は次の通りです。

  • insert / push: 要素を優先度つきで追加する
  • extract-min / pop(または extract-max): 優先度が最小(または最大)の要素を取り出して削除する
  • peek / find-min / top: 取り出さずに先頭の要素だけを見る
  • decrease-key(任意): すでに入っている要素の優先度を下げる

ここで大事なのは、優先度付きキューは「操作の約束事」であって、内部の実装は自由だという点です。ソート済み配列でも、二項ヒープでも、平衡二分探索木でも実現できます。その中で二分ヒープが最もよく使われる実装というだけで、「優先度付きキュー=二分ヒープ」ではありません。この区別を押さえておくと、言語ごとのライブラリの違いも整理して理解できます。

二分ヒープの仕組み — 完全二分木を配列で表す

二分ヒープ(binary heap)は、次の2つの性質を満たす木です。

  • 完全二分木(complete binary tree): 最下段を除くすべての段が埋まっていて、最下段は左詰めになっている
  • ヒープ条件: min-heap では「親は子以下」で根が最小、max-heap では「親は子以上」で根が最大

注意したいのは、ヒープが定めるのは親子の大小関係だけ、という点です。兄弟どうしの大小は規定されない半順序なので、ヒープはソート済みではありません。「根が最小(または最大)であること」だけが保証されます。

完全二分木は隙間なく詰まっているため、ポインタを持つノードを作らなくてもただの配列で表せます。0-indexed(配列の添字が0から)なら、インデックス i のノードに対して次のように親子をたどれます。

  • 親: (i-1)//2
  • 左の子: 2*i+1
  • 右の子: 2*i+2
  • 根: index 0

教科書(CLRS)でよく使われる1-indexed版なら、親が i//2、左の子が 2*i、右の子が 2*i+1、根が index 1 になります。どちらも掛け算・割り算だけで親子を行き来できるのがヒープの気持ちよさです。

push と pop — sift-up / sift-down(Pythonで自作)

ヒープの追加・取り出しは、2つの「玉突き」操作でできています。

  • sift-up(up-heap / bubble-up): push で使います。まず末尾に要素を足し、親と比べてヒープ条件を破っていれば交換して上へ運びます。木の高さのぶんだけ動くので O(log n) です
  • sift-down(down-heap / bubble-down): pop で使います。根を取り出したあと末尾の要素を根に置き、min-heapなら小さい方の子と比べて条件を破っていれば交換して下へ運びます。こちらも O(log n) です

仕組みを体に入れるため、配列ベースのmin-heapを自作してみます。

class MinHeap:
    def __init__(self):
        self.data = []                      # 0-indexed の配列で完全二分木を表す
 
    def peek(self):
        return self.data[0]                 # 根が最小、O(1)
 
    def push(self, x):
        self.data.append(x)                 # まず末尾に追加
        self._sift_up(len(self.data) - 1)   # 親と比べて上へ、O(log n)
 
    def pop(self):
        last = self.data.pop()              # 末尾を取り出す
        if not self.data:
            return last                     # 要素が1つだけならそれが最小
        root = self.data[0]
        self.data[0] = last                 # 末尾を根へ移し
        self._sift_down(0)                  # 子と比べて下へ、O(log n)
        return root
 
    def _sift_up(self, i):
        while i > 0:
            parent = (i - 1) // 2           # 親のインデックス
            if self.data[i] < self.data[parent]:
                self.data[i], self.data[parent] = self.data[parent], self.data[i]
                i = parent
            else:
                break
 
    def _sift_down(self, i):
        n = len(self.data)
        while True:
            left, right = 2 * i + 1, 2 * i + 2   # 左の子・右の子
            smallest = i
            if left < n and self.data[left] < self.data[smallest]:
                smallest = left
            if right < n and self.data[right] < self.data[smallest]:
                smallest = right
            if smallest == i:
                break                        # これ以上動かないので終了
            self.data[i], self.data[smallest] = self.data[smallest], self.data[i]
            i = smallest
 
 
h = MinHeap()
for x in [5, 1, 8, 3, 2]:
    h.push(x)
print(h.peek())                      # 1
print([h.pop() for _ in range(5)])   # [1, 2, 3, 5, 8]

これをmax-heapにしたいときは、比較の向きを反転するだけです。_sift_up_sift_down の中の < をすべて > に変えれば、根が最大のヒープになります。

計算量 — なぜ build-heap は O(n) で済むのか

ヒープの主な操作の計算量をまとめます。

  • peek / find-min: O(1)(根を見るだけ)
  • push: O(log n)
  • pop: O(log n)
  • 任意の要素の探索(search): O(n)。ヒープは「根が最小か最大か」しか保証しないので、途中の要素を探すのは苦手です

ここで押さえたいのが、n個の配列から一気にヒープを作るbuild-heap(heapify)です。素朴には「空のヒープにn回pushすればいい」と考えますが、それだと O(n log n) かかります。ノードのおよそ半分が最深段にあり、pushのたびに長い距離を上へ運ぶことになるからです。

ところが、末尾(下の段)から順にsift-downしていくボトムアップ構築(Floyd法)を使うと O(n) で済みます。直感的な理由はこうです。sift-downで各ノードが動く距離は、そのノードの高さに比例します。葉(全体の約半数)は高さ0で仕事はゼロ、上に行くほどノード数は少なくなります。つまり多数派である下の段ほど移動距離が短いので、全体の仕事量が抑えられるのです。数式でも全ノードの高さの総和は O(n) に収まります(Σ h / 2^h が2に収束するため)。

「build-heapは O(n)、n回pushは O(n log n)」——この差は実務でも効いてくるので覚えておくと得です。計算量の記法そのものに不安があれば、先に計算量とBig-O記法 入門を読むと以降の比較がすっと入ってきます。

ヒープソート — その場で O(n log n)

ヒープを使えば、そのまま並べ替えができます。ヒープソート(heapsort)の手順は次の通りです。

  1. 配列全体を O(n) でmax-heap化する(build-heap)
  2. 根(最大値)を末尾の要素と交換し、ヒープのサイズを1減らして根をsift-downする
  3. 2をn回繰り返すと、配列が昇順に並ぶ

各回のsift-downが O(log n) で、それをn回行うので全体は O(n log n) です。最悪・平均・最良のいずれも O(n log n) で安定していて、追加メモリが O(1)in-place(その場)ソートである点が長所です。一方で安定ソートではありません(同じ値の相対順序が保たれない)。

Pythonの heapq(後述、min-heap)を使うと、簡易版は次のように書けます。ただしこれは元の配列とは別に配列を持つため、教科書的なin-place版とは別物である点に注意してください。

import heapq
 
def heapsort(a):
    heap = a[:]                    # コピーを取る(この簡易版は in-place ではない)
    heapq.heapify(heap)            # O(n) でヒープ化
    return [heapq.heappop(heap) for _ in range(len(heap))]   # 各 pop が O(log n)
 
print(heapsort([5, 1, 8, 3, 2]))   # [1, 2, 3, 5, 8]

他の並べ替え手法との比較はソートアルゴリズム入門にまとめています。

各言語の標準ライブラリ(表)

主要言語のヒープ/優先度付きキューは、デフォルトがmin-heapかmax-heapかで作法が分かれます。2026年時点の一次ソースをもとに整理すると次の通りです。

言語 / ライブラリデフォルトmin / max の切り替え主な計算量
Python heapqmin-heap符号を反転、または Python 3.14+ の max 系関数push / pop が O(log n)heapifyO(n)
Java PriorityQueuemin-heap(自然順序)Comparator.reverseOrder() を渡すoffer / poll が O(log n)、peek が O(1)
C++ std::priority_queuemax-heap第3テンプレート引数に std::greaterpush / pop が O(log n)、top が O(1)
Go container/heapmin-heap ベース自作の Less の向きで決めるpush / pop が O(log n)
Rust BinaryHeapmax-heap要素を std::cmp::Reverse で包むpush / pop が O(log n)、peek が O(1)
JavaScript標準なし(2026年時点)ライブラリか自作で対応実装依存

Pythonの heapq はヒープ本体がただの list で、専用のクラスを作りません。基本操作は次の通りです。

import heapq
 
# heapq はただの list を min-heap として扱う
tasks = []
heapq.heappush(tasks, (2, "レビュー対応"))
heapq.heappush(tasks, (1, "本番障害の調査"))
heapq.heappush(tasks, (3, "議事録の清書"))
 
# 優先度(タプルの第1要素)が小さいものから取り出される
while tasks:
    priority, name = heapq.heappop(tasks)
    print(priority, name)
# 1 本番障害の調査
# 2 レビュー対応
# 3 議事録の清書
 
# 既存のリストをまとめてヒープ化(線形時間 O(n))
nums = [5, 1, 8, 3, 2]
heapq.heapify(nums)
print(nums[0])                       # 1  (根が最小)
 
# 大きい順トップ2は nlargest が手軽
print(heapq.nlargest(2, [5, 1, 8, 3, 2]))   # [8, 5]
 
# max-heap が欲しいときは符号を反転して入れる(Python 3.13 以前の定番)
max_heap = []
for x in [5, 1, 8, 3, 2]:
    heapq.heappush(max_heap, -x)
print(-heapq.heappop(max_heap))      # 8

heapq はmin-heap専用で、Python 3.14でmax-heap用の関数(heappush_max など)が追加されましたが、それ以前は符号反転が定番でした。なお heapq はスレッドセーフではないため、複数スレッドで共有するなら queue.PriorityQueue を使います。

JavaやC++、Go、Rustにはそれぞれ標準のヒープがある一方、JavaScriptには2026年時点で標準の優先度付きキューやヒープが存在しません。正式なTC39提案(Stage 1以上)も確認できず、将来入るかは未確認です。実務ではnpmライブラリか自作で対応します。

実務での使いどころ(Dijkstra・ハフマン・Top-K・中央値)

優先度付きキューは「次に処理すべき1件」を選ぶ場面で幅広く登場します。

  • Dijkstra法の最短経路: まだ確定していない頂点のうち、距離が最小のものを取り出すのに使います(min優先度付きキュー)。二分ヒープ版の計算量は O((V+E) log V) です。詳しくはグラフ探索アルゴリズム入門を参照してください。A*探索も同じ枠組みです
  • ハフマン符号化: 頻度が最小の2つのノードを繰り返し結合していく処理でmin-heapを使います(データ圧縮の基礎入門で扱っています)
  • イベント駆動シミュレーション: 「次に起こる=時刻が最小」のイベントを取り出すのに使います
  • タスクスケジューラ/ジョブの優先度管理: 優先度が高いものから処理します
  • K個のソート済みリストのマージ: 各リストの先頭をヒープに入れて最小を取り出すと O(N log k) でマージできます

理論の補足として、フィボナッチヒープを使うとdecrease-keyが償却 O(1) になり、Dijkstraを O(E + V log V) に改善できます(二項ヒープのdecrease-keyは O(log n))。ただし定数が大きく実装も複雑なため、実務では二分ヒープと後述の遅延削除を組み合わせた方が速いことが多いです。

代表的な応用として、上位k件を求めるTop-Kを見ておきます。全部をソートせず、サイズkのmin-heapを保つのがコツで、計算量は O(n log k) です。

import heapq
 
def top_k_largest(nums, k):
    heap = []                          # サイズ k の min-heap を保つ
    for x in nums:
        if len(heap) < k:
            heapq.heappush(heap, x)
        elif x > heap[0]:              # 現在の最小より大きければ入れ替える
            heapq.heapreplace(heap, x) # pop してから push、O(log k)
    return sorted(heap, reverse=True)
 
print(top_k_largest([5, 1, 8, 3, 9, 2, 7], 3))   # [9, 8, 7]

もう一つの定番が、データを流し込みながら中央値を追う2ヒープ法です。下位半分をmax-heap、上位半分をmin-heapに持ち、両者のサイズ差を1以内に保つと、更新が O(log n)、中央値の取得が O(1) になります。

よくある落とし穴

ヒープを使うときに詰まりやすい点を挙げておきます。

  • heapqはmin-heap固定: 最大を取りたいなら符号反転か、Python 3.14以降の heappop_max などを使います
  • タプルの同点比較エラー(Python): (priority, item) で優先度が同点だと第2要素 item を比較します。item が比較不能(dict など)だと TypeError で落ちます。対策は、単調増加カウンタを挟んで (priority, counter, item) の3要素にするか、@dataclass(order=True) で優先度以外を field(compare=False) にする方法です
  • decrease-keyが標準ヒープに無い: 定石は遅延削除(lazy deletion)です。古いエントリは消さず、新しい優先度で再挿入し、pop時に無効なエントリをスキップします。heapq でDijkstraを書くときの定番イディオムです(Goは heap.Fix / heap.Remove で更新できます)
  • 非スレッドセーフ: heapq、Javaの PriorityQueue、C++、Go、Rustのヒープはいずれも並行アクセスを想定していません。並行用途ではPythonの queue.PriorityQueue やJavaの PriorityBlockingQueue を使います
  • ヒープはソート済みではない: 根以外の順序は保証されません。並べ替えたいならpopを繰り返します(それがヒープソートです)
  • build-heapの誤解: n回pushは O(n log n) です。O(n) にしたいなら heapify / make_heap を使います

同じアルゴリズム入門シリーズの動的計画法(DP)入門二分探索入門と合わせて読むと、データ構造とアルゴリズムの引き出しが広がります。

まとめ

  • 優先度付きキューは「操作の約束事」を定めた抽象データ型で、二分ヒープはその最も一般的な実装(両者はイコールではない)
  • 二分ヒープは完全二分木を配列で表し、親は (i-1)//2、子は 2*i+12*i+2 でたどれる(0-indexed)
  • push(sift-up)とpop(sift-down)は O(log n)、peekは O(1)。要素の探索は O(n) で苦手
  • build-heapはボトムアップ構築なら O(n)。多数派の下の段ほど移動距離が短いのが理由。n回pushの O(n log n) とは別物
  • ヒープソートは O(n log n)・in-placeだが安定ではない。Dijkstra・ハフマン・Top-K・中央値の2ヒープ法など応用は幅広い

まずは配列の親子インデックスと、sift-up / sift-downの2つの玉突きを手でトレースしてみてください。この2つが腑に落ちれば、ヒープはとても素直なデータ構造だと感じられるはずです。

参考リンク

動的計画法(DP)入門 - メモ化とテーブル化で解く定番アルゴリズム

動的計画法(DP)入門 - メモ化とテーブル化で解く定番アルゴリズム

17

動的計画法(DP)を、部分問題の重なりと最適部分構造という2条件から解説。メモ化(トップダウン)とテーブル化(ボトムアップ)の違い、フィボナッチ・コイン・0-1ナップサック・編集距離の実装と計算量、状態と遷移の設計手順まで、初学者向けにPythonコードで具体的に整理します。

ソートアルゴリズム 入門 - 計算量・安定性・実際の標準ライブラリまで

ソートアルゴリズム 入門 - 計算量・安定性・実際の標準ライブラリまで

13

バブル・選択・挿入・マージ・クイック・ヒープソートの仕組みと計算量を、初中級エンジニア向けに整理します。安定ソートとは何か、比較を使わない計数・基数ソート、そしてPython・Java・Rust・JavaScriptの標準ライブラリが実際に採用しているTimsortやpdqsort系のアルゴリズムまで、一次情報をもとに解説します。