Heaps uitgelegd: binaire heap, sift-up, sift-down en priority queues
Wat is een binaire heap? Leer invoegen met sift-up, het minimum verwijderen met sift-down, heapify en heapsort, stap voor stap met code in Python en Java.
Een hoop (heap) is een datastructuur die maar één ding heel goed doet: steeds het kleinste (of grootste) element snel teruggeven. Daarmee is hij de motor achter elke prioriteitswachtrij (priority queue): de taakplanner van een besturingssysteem, het algoritme van Dijkstra (zie grafen) en "geef me de tien grootste waarden uit een miljoen".
Op het tentamen krijg je bijna altijd een trace-opgave: "voeg deze getallen in, hoe ziet de array eruit?" In sollicitaties is "gebruik een heap" het standaardantwoord op top-k-vragen. Op deze pagina werken we met een min-heap: het kleinste element staat bovenaan.
Een volledige boom in een array
Een binaire heap is een binaire boom met twee eigenschappen:
- Vorm: de boom is volledig (complete). Alle niveaus zijn vol, behalve misschien het onderste, en dat wordt van links naar rechts gevuld.
- Heap-eigenschap: elke ouder is kleiner dan of gelijk aan zijn kinderen. De wortel is dus altijd het minimum.
Let op: tussen broers of tussen verschillende takken geldt geen volgorde. Een heap is niet gesorteerd.
Omdat de boom volledig is, hoef je geen pointers op te slaan. Je zet de knopen niveau voor niveau in een array (0-geïndexeerd), en rekent de familie uit:
| Gezocht | Formule |
|---|---|
ouder van i | ⌊(i - 1) / 2⌋ |
linkerkind van i | 2i + 1 |
rechterkind van i | 2i + 2 |
Voorbeeld: de array [1, 6, 2, 10, 13, 3, 5] is deze boom:
1 index 0
/ \
6 2 index 1, 2
/ \ / \
10 13 3 5 index 3, 4, 5, 6
Het kind op index 4 (13) heeft als ouder ⌊(4 - 1) / 2⌋ = 1, en dat is 6. Klopt.
Invoegen met sift-up
Invoegen gaat in twee stappen:
- Zet het nieuwe element achteraan de array (de eerste vrije plek in de boom).
- Laat het omhoog zakken (sift-up): zolang het kleiner is dan zijn ouder, wissel je ze om.
We voegen 3, 6, 2, 10, 13, 1, 5 in een lege min-heap in:
| Stap | Actie | Array |
|---|---|---|
| 1 | voeg 3 toe | [3] |
| 2 | voeg 6 toe, 6 > 3: klaar | [3, 6] |
| 3 | voeg 2 toe | [3, 6, 2] |
| 2 < ouder 3: wissel | [2, 6, 3] | |
| 4 | voeg 10 toe, 10 > 6: klaar | [2, 6, 3, 10] |
| 5 | voeg 13 toe, 13 > 6: klaar | [2, 6, 3, 10, 13] |
| 6 | voeg 1 toe (index 5) | [2, 6, 3, 10, 13, 1] |
| 1 < ouder 3 (index 2): wissel | [2, 6, 1, 10, 13, 3] | |
| 1 < ouder 2 (index 0): wissel | [1, 6, 2, 10, 13, 3] | |
| 7 | voeg 5 toe (index 6), 5 > ouder 2: klaar | [1, 6, 2, 10, 13, 3, 5] |
Elke wissel brengt het element één niveau omhoog. Een boom met n knopen heeft ongeveer log n
niveaus, dus invoegen kost hoogstens O(log n).
Het minimum verwijderen met sift-down
Het minimum staat altijd op index 0. Het weghalen gaat zo:
- Onthoud de wortel (dat is het antwoord).
- Verplaats het laatste element naar de wortel en maak de array één korter.
- Laat het omlaag zakken (sift-down): wissel met het kleinste kind zolang dat kind kleiner is. Zijn beide kinderen gelijk, kies dan het linker.
Vanaf [1, 6, 2, 10, 13, 3, 5] halen we twee keer het minimum weg:
| Stap | Actie | Array |
|---|---|---|
| 1 | verwijder 1, laatste (5) naar de wortel | [5, 6, 2, 10, 13, 3] |
| kinderen 6 en 2, kleinste is 2 < 5: wissel | [2, 6, 5, 10, 13, 3] | |
| kind (index 5) is 3 < 5: wissel | [2, 6, 3, 10, 13, 5] | |
| 2 | verwijder 2, laatste (5) naar de wortel | [5, 6, 3, 10, 13] |
| kinderen 6 en 3, kleinste is 3 < 5: wissel | [3, 6, 5, 10, 13] | |
| 5 op index 2 heeft geen kinderen: klaar | [3, 6, 5, 10, 13] |
Waarom met het kleinste kind wisselen? Wissel je met het grootste, dan wordt dat de ouder van het kleinere kind, en is de heap-eigenschap direct kapot.
Een gelijkspel ziet er zo uit: uit [1, 3, 3, 5] het minimum halen geeft eerst [5, 3, 3]. Beide
kinderen zijn 3, dus wissel je met het linker: [3, 5, 3].
Code
class MinHeap:
def __init__(self):
self.a = []
def voeg_toe(self, x):
self.a.append(x) # achteraan
i = len(self.a) - 1
while i > 0: # sift-up
ouder = (i - 1) // 2
if self.a[i] >= self.a[ouder]:
break
self.a[i], self.a[ouder] = self.a[ouder], self.a[i]
i = ouder
def verwijder_min(self):
minimum = self.a[0]
laatste = self.a.pop()
if self.a:
self.a[0] = laatste # laatste naar de wortel
self._sift_down(0)
return minimum
def _sift_down(self, i):
n = len(self.a)
while True:
links, rechts = 2 * i + 1, 2 * i + 2
kleinste = i
if links < n and self.a[links] < self.a[kleinste]:
kleinste = links
# strikt kleiner: bij gelijke kinderen blijft het linker gekozen
if rechts < n and self.a[rechts] < self.a[kleinste]:
kleinste = rechts
if kleinste == i:
return
self.a[i], self.a[kleinste] = self.a[kleinste], self.a[i]
i = kleinste
h = MinHeap()
for x in [3, 6, 2, 10, 13, 1, 5]:
h.voeg_toe(x)
print(h.a) # [1, 6, 2, 10, 13, 3, 5]
print(h.verwijder_min()) # 1
print(h.a) # [2, 6, 3, 10, 13, 5]
Heapify: een hele array in O(n)
Heb je al een array, dan kun je de elementen één voor één invoegen: n keer O(log n). Het kan
sneller. Bij heapify (bottom-up heap construction) roep je sift-down aan op elke knoop die kinderen
heeft, van achter naar voren: van index ⌊n/2⌋ - 1 terug naar 0.
Voor [9, 4, 7, 1, 8, 2, 6] (dus n = 7, start bij index 2):
| Stap | Sift-down op | Array |
|---|---|---|
| start | [9, 4, 7, 1, 8, 2, 6] | |
| 1 | index 2 (7): wissel met kind 2 | [9, 4, 2, 1, 8, 7, 6] |
| 2 | index 1 (4): wissel met kind 1 | [9, 1, 2, 4, 8, 7, 6] |
| 3 | index 0 (9): wissel met 1, daarna met 4 | [1, 4, 2, 9, 8, 7, 6] |
Dit is O(n) en niet O(n log n), omdat de meeste knopen onderaan zitten en nauwelijks hoeven te
zakken. De helft van de knopen zijn bladeren en zakken helemaal niet.
Heapsort
Heapsort gebruikt dezelfde bouwstenen: maak met heapify een heap en haal dan n keer de wortel weg.
Met een min-heap komen de elementen er oplopend uit. De klassieke in-place variant gebruikt een
max-heap en zet het weggehaalde maximum steeds achteraan in dezelfde array. Heapsort is altijd
O(n log n), ook in het slechtste geval, en gebruikt O(1) extra geheugen, maar is niet stabiel.
Meer over sorteeralgoritmen lees je bij sorteren.
Priority queues in Python en Java
In de praktijk gebruik je de ingebouwde heap.
import heapq
taken = []
heapq.heappush(taken, (2, "mail beantwoorden"))
heapq.heappush(taken, (1, "server herstarten"))
heapq.heappush(taken, (3, "koffie halen"))
while taken:
prioriteit, taak = heapq.heappop(taken) # kleinste prioriteit eerst
print(prioriteit, taak)
# een bestaande lijst in O(n) omzetten
getallen = [9, 4, 7, 1, 8, 2, 6]
heapq.heapify(getallen)
print(getallen) # [1, 4, 2, 9, 8, 7, 6]
heapq werkt op een gewone lijst en is altijd een min-heap; voor een max-heap sla je -x op. Java's
PriorityQueue is standaard ook een min-heap; met Comparator.reverseOrder() maak je er een max-heap
van. Let op: heapq.heappop gebruikt intern een andere, snellere variant van sift-down. Het minimum
klopt altijd, maar bij gelijke waarden kan de array er anders uitzien dan met de tentamenmethode
([1, 3, 3, 5] wordt bij heapq [3, 3, 5] in plaats van [3, 5, 3]). Traceer op het tentamen dus
met de hand, niet met heapq.
Complexiteit
| Operatie | Tijd |
|---|---|
| Minimum bekijken | O(1) |
| Invoegen (sift-up) | O(log n) |
| Minimum verwijderen (sift-down) | O(log n) |
Heapify van n elementen | O(n) |
| Heapsort | O(n log n) |
| Willekeurig element zoeken | O(n) |
Veelgemaakte fouten op het tentamen
- Met het verkeerde kind wisselen. Bij sift-down wissel je met het kleinste kind, niet met het linker of met het eerste kind dat kleiner is dan de ouder. Bij gelijke kinderen: het linker.
- De wortel weghalen en alles opschuiven. Je verplaatst het laatste element naar de wortel; de rest van de array blijft staan.
- Verkeerde indexformules. Bij een 0-geïndexeerde array zijn de kinderen
2i + 1en2i + 2. De formules2ien2i + 1horen bij een 1-geïndexeerde heap. - Denken dat een heap gesorteerd is.
[1, 6, 2, 10, 13, 3, 5]is een geldige heap, maar geen gesorteerde array. - Te vroeg stoppen met sift-up of sift-down. Een element kan meerdere niveaus moeten verhuizen; controleer na elke wissel opnieuw.
Samenvatting
- Een binaire min-heap is een volledige boom in een array waarin elke ouder
≤zijn kinderen is. - Ouder van
iis⌊(i - 1)/2⌋, kinderen zijn2i + 1en2i + 2. - Invoegen: achteraan toevoegen en sift-up. Minimum verwijderen: laatste naar de wortel en sift-down
met het kleinste kind (bij gelijkspel links). Beide
O(log n). - Heapify bouwt een heap in
O(n); heapsort sorteert inO(n log n). - Gebruik in de praktijk
heapq(Python) ofPriorityQueue(Java) als prioriteitswachtrij.
