8.2 ヒープ構築¶
場合によっては、リスト内のすべての要素を使ってヒープを構築したいことがあります。この過程を「ヒープ構築」と呼びます。
8.2.1 ヒープへの挿入操作による実現¶
まず空のヒープを作成し、次にリストを走査して、各要素に対して順に「ヒープへの挿入操作」を実行します。つまり、要素をヒープの末尾に追加してから、その要素に対して「下から上へ」のヒープ化を行います。
要素が1つヒープに挿入されるたびに、ヒープの長さは1増加します。ノードは上から下へ順に二分木へ追加されるため、ヒープは「上から下へ」構築されます。
要素数を \(n\) とすると、各要素のヒープへの挿入操作には \(O(\log{n})\) の時間がかかるため、このヒープ構築法の時間計算量は \(O(n \log n)\) です。
8.2.2 走査によるヒープ化で実現¶
実際には、より効率的なヒープ構築法を実現でき、全体は2つの手順に分かれます。
- リストのすべての要素をそのままヒープに追加します。この時点では、ヒープの性質はまだ満たされていません。
- ヒープを逆順で走査し(レベル順走査の逆順)、各非葉ノードに対して順に「上から下へ」のヒープ化を実行します。
あるノードをヒープ化するたびに、そのノードを根とする部分木は合法な部分ヒープになります。また、逆順で走査するため、ヒープは「下から上へ」構築されます。
逆順走査を選ぶのは、この方法なら現在のノードの下にある部分木がすでに合法な部分ヒープであることを保証でき、そのうえで現在のノードをヒープ化してはじめて有効になるからです。
なお、葉ノードには子ノードがないため、それ自体が自然に合法な部分ヒープであり、ヒープ化は不要です。以下のコードが示すように、最後の非葉ノードは最後のノードの親ノードであり、そこから逆順に走査してヒープ化を実行します。
/* コンストラクタ。スライスからヒープを構築する */
MaxHeap *newMaxHeap(int nums[], int size) {
// すべての要素をヒープに入れる
MaxHeap *maxHeap = (MaxHeap *)malloc(sizeof(MaxHeap));
maxHeap->size = size;
memcpy(maxHeap->data, nums, size * sizeof(int));
for (int i = parent(maxHeap, size - 1); i >= 0; i--) {
// 葉ノード以外のすべてのノードをヒープ化
siftDown(maxHeap, i);
}
return maxHeap;
}
/* 最大ヒープ */
class MaxHeap(nums: MutableList<Int>?) {
// 配列ではなくリストを使うことで、拡張を考慮する必要がない
private val maxHeap = mutableListOf<Int>()
/* コンストラクタ。入力リストに基づいてヒープを構築する */
init {
// リスト要素をそのままヒープに追加
maxHeap.addAll(nums!!)
// 葉ノード以外のすべてのノードをヒープ化
for (i in parent(size() - 1) downTo 0) {
siftDown(i)
}
}
/* 左子ノードのインデックスを取得 */
private fun left(i: Int): Int {
return 2 * i + 1
}
/* 右子ノードのインデックスを取得 */
private fun right(i: Int): Int {
return 2 * i + 2
}
/* 親ノードのインデックスを取得 */
private fun parent(i: Int): Int {
return (i - 1) / 2 // 切り捨て除算
}
/* 要素を交換 */
private fun swap(i: Int, j: Int) {
val temp = maxHeap[i]
maxHeap[i] = maxHeap[j]
maxHeap[j] = temp
}
/* ヒープのサイズを取得 */
fun size(): Int {
return maxHeap.size
}
/* ヒープが空かどうかを判定 */
fun isEmpty(): Boolean {
/* ヒープが空かどうかを判定 */
return size() == 0
}
/* ヒープ先頭要素にアクセス */
fun peek(): Int {
return maxHeap[0]
}
/* 要素をヒープに追加 */
fun push(_val: Int) {
// ノードを追加
maxHeap.add(_val)
// 下から上へヒープ化
siftUp(size() - 1)
}
/* ノード i から始めて、下から上へヒープ化 */
private fun siftUp(it: Int) {
// Kotlin の関数引数は不変のため、一時変数を作成する
var i = it
while (true) {
// ノード i の親ノードを取得
val p = parent(i)
// 「根ノードを越えた」または「ノードの修復が不要」になったらヒープ化を終了
if (p < 0 || maxHeap[i] <= maxHeap[p]) break
// 2 つのノードを交換
swap(i, p)
// ループで下から上へヒープ化
i = p
}
}
/* 要素をヒープから取り出す */
fun pop(): Int {
// 空判定の処理
if (isEmpty()) throw IndexOutOfBoundsException()
// 根ノードと最も右の葉ノードを交換(先頭要素と末尾要素を交換)
swap(0, size() - 1)
// ノードを削除
val _val = maxHeap.removeAt(size() - 1)
// 上から下へヒープ化
siftDown(0)
// ヒープ先頭要素を返す
return _val
}
/* ノード i から始めて、上から下へヒープ化 */
private fun siftDown(it: Int) {
// Kotlin の関数引数は不変のため、一時変数を作成する
var i = it
while (true) {
// ノード i, l, r のうち値が最大のノードを ma とする
val l = left(i)
val r = right(i)
var ma = i
if (l < size() && maxHeap[l] > maxHeap[ma]) ma = l
if (r < size() && maxHeap[r] > maxHeap[ma]) ma = r
// ノード i が最大、またはインデックス l, r が範囲外なら、ヒープ化は不要なので抜ける
if (ma == i) break
// 2 つのノードを交換
swap(i, ma)
// ループで上から下へヒープ化
i = ma
}
}
/* ヒープ(二分木)を出力 */
fun print() {
val queue = PriorityQueue { a: Int, b: Int -> b - a }
queue.addAll(maxHeap)
printHeap(queue)
}
}
コードの可視化
8.2.3 計算量の分析¶
以下では、2つ目のヒープ構築法の時間計算量を求めてみましょう。
- 完全二分木のノード数を \(n\) とすると、葉ノード数は \((n + 1) / 2\) です。ここで \(/\) は切り捨て除算を表します。したがって、ヒープ化が必要なノード数は \((n - 1) / 2\) です。
- 上から下へのヒープ化の過程では、各ノードは最大で葉ノードまでヒープ化されるため、最大反復回数は二分木の高さ \(\log n\) です。
上の2つを掛け合わせると、ヒープ構築過程の時間計算量は \(O(n \log n)\) となります。しかし、この見積もりは正確ではありません。二分木では下層のノード数が上層よりはるかに多いという性質を考慮していないためです。
次に、より正確な計算を行います。計算を簡単にするため、ノード数が \(n\) 、高さが \(h\) の「満二分木」を仮定します。この仮定は計算結果の正しさに影響しません。

図 8-5 満二分木の各層のノード数
上図に示すように、ノードを「上から下へヒープ化」する最大反復回数は、そのノードから葉ノードまでの距離に等しく、この距離こそが「ノードの高さ」です。したがって、各層の「ノード数 \(\times\) ノードの高さ」を合計すれば、**すべてのノードのヒープ化反復回数の総和**が得られます。
上式を簡約するには中学の数列の知識を用います。まず \(T(h)\) に \(2\) を掛けると、次のようになります。
ずらして引く方法を用い、下式の \(2 T(h)\) から上式の \(T(h)\) を引くと、次が得られます。
上式を見ると、\(T(h)\) は等比数列であることがわかるため、和の公式を直接用いて、時間計算量は次のように求められます。
さらに、高さ \(h\) の満二分木のノード数は \(n = 2^{h+1} - 1\) であるため、計算量は容易に \(O(2^h) = O(n)\) とわかります。以上の導出は、**入力リストからヒープを構築する時間計算量が \(O(n)\) であり、非常に効率的である**ことを示しています。