Alle onderwerpen
Algoritmen & datastructuren

Binaire zoekbomen uitgelegd: invoegen, verwijderen en de vier doorloopvolgordes

Hoe werkt een binaire zoekboom? Leer invoegen, zoeken, verwijderen met de opvolger en preorder, inorder, postorder en level-order, met code in Python en Java.

Een binaire zoekboom (binary search tree, BST) combineert het beste van een gesorteerde array en een gelinkte lijst. Je kunt erin zoeken zoals bij binair zoeken, maar invoegen en verwijderen gaan zonder alles op te schuiven.

Op het tentamen is dit een vaste kluif: "voeg deze sleutels in, teken de boom", "verwijder 3", "geef de inorder-volgorde". In sollicitaties zijn boomvragen populair omdat ze recursief denken testen. En in de praktijk zitten gebalanceerde zoekbomen achter TreeMap in Java, database-indexen en de bestandssystemen op je laptop.

De BST-eigenschap

Elke knoop heeft een sleutel en hoogstens twee kinderen. Voor elke knoop geldt:

  • alle sleutels in de linker deelboom zijn kleiner;
  • alle sleutels in de rechter deelboom zijn groter.

Dat geldt dus voor de hele deelboom, niet alleen voor de directe kinderen. In de opgaven komen geen dubbele sleutels voor.

Invoegen

Begin bij de wortel. Is de nieuwe sleutel kleiner, ga naar links; groter, ga naar rechts. Kom je bij een lege plek, dan hang je de nieuwe knoop daar op. Een nieuwe sleutel wordt dus altijd een blad.

We voegen 8, 3, 10, 1, 6, 14, 4, 7, 13 in deze volgorde in:

SleutelRouteWordt
8lege boomwortel
38 → linkslinkerkind van 8
108 → rechtsrechterkind van 8
18 → links, 3 → linkslinkerkind van 3
68 → links, 3 → rechtsrechterkind van 3
148 → rechts, 10 → rechtsrechterkind van 10
48 → links, 3 → rechts, 6 → linkslinkerkind van 6
78 → links, 3 → rechts, 6 → rechtsrechterkind van 6
138 → rechts, 10 → rechts, 14 → linkslinkerkind van 14

Het resultaat, de voorbeeldboom voor de rest van deze pagina:

            8
          /   \
         3     10
        / \      \
       1   6      14
          / \    /
         4   7  13

De invoegvolgorde bepaalt de vorm. Dezelfde sleutels in een andere volgorde geven een andere boom.

Zoeken

Zoeken volgt dezelfde route als invoegen. Bij elke knoop vergelijk je en ga je links of rechts.

ZoekBezochte knopenResultaat
78, 3, 6, 7gevonden
58, 3, 6, 44 heeft geen rechterkind: niet gevonden

Je bezoekt hoogstens één knoop per niveau. Het aantal stappen is dus de hoogte h van de boom.

Verwijderen: drie gevallen

Zoek eerst de knoop. Wat je daarna doet, hangt af van het aantal kinderen.

Geval 1: blad (geen kinderen). Haal de knoop gewoon weg. Verwijder 13: het linkerkind van 14 wordt leeg, de rest van de boom blijft hetzelfde.

Geval 2: één kind. Het kind schuift op naar de plek van de knoop. Verwijder 10: de deelboom 14 (met 13) wordt het rechterkind van 8.

            8
          /   \
         3     14
        / \    /
       1   6  13
          / \
         4   7

Geval 3: twee kinderen. Zoek de opvolger (in-order successor): de kleinste sleutel in de rechter deelboom. Die vind je door één keer naar rechts te gaan en dan zo ver mogelijk naar links. Kopieer de sleutel van de opvolger naar de knoop en verwijder daarna de opvolger uit de rechter deelboom. De opvolger heeft nooit een linkerkind, dus dat is altijd geval 1 of 2.

Verwijder 3 uit de oorspronkelijke boom. De rechter deelboom van 3 is 6, 4, 7; de kleinste daarin is 4. Dus 4 komt op de plek van 3, en het blad 4 verdwijnt:

            8
          /   \
         4     10
        / \      \
       1   6      14
            \    /
             7  13

Waarom werkt dit? De opvolger is groter dan alles links en kleiner dan alles wat rechts overblijft, dus de BST-eigenschap blijft intact.

De vier doorloopvolgordes

Een boom doorlopen (traversal) betekent elke knoop één keer bezoeken. Voor de voorbeeldboom:

VolgordeRegelResultaat
Preorderknoop, links, rechts8, 3, 1, 6, 4, 7, 10, 14, 13
Inorderlinks, knoop, rechts1, 3, 4, 6, 7, 8, 10, 13, 14
Postorderlinks, rechts, knoop1, 4, 7, 6, 3, 13, 14, 10, 8
Level-orderper niveau, van links naar rechts8, 3, 10, 1, 6, 14, 4, 7, 13

Ezelsbruggetje: pre, in en post zeggen wanneer de knoop zelf aan de beurt is: vóór, tussen of na zijn deelbomen.

  • Inorder geeft bij een BST altijd de gesorteerde volgorde. Handige controle voor je antwoord.
  • Preorder is handig om een boom te kopiëren of op te slaan: voeg je de preorder-volgorde opnieuw in, dan krijg je exact dezelfde boom terug.
  • Postorder verwerkt kinderen vóór de ouder, bijvoorbeeld bij het opruimen van een boom of het uitrekenen van een expressieboom.
  • Level-order is breedte-eerst zoeken en gebruikt een wachtrij, net als BFS bij grafen (zie ook stapels en wachtrijen).

Code

from collections import deque


class Knoop:
    def __init__(self, sleutel):
        self.sleutel = sleutel
        self.links = None
        self.rechts = None


def voeg_toe(knoop, sleutel):
    if knoop is None:
        return Knoop(sleutel)  # nieuwe sleutel wordt een blad
    if sleutel < knoop.sleutel:
        knoop.links = voeg_toe(knoop.links, sleutel)
    elif sleutel > knoop.sleutel:
        knoop.rechts = voeg_toe(knoop.rechts, sleutel)
    return knoop


def zoek(knoop, sleutel):
    while knoop is not None and knoop.sleutel != sleutel:
        knoop = knoop.links if sleutel < knoop.sleutel else knoop.rechts
    return knoop


def verwijder(knoop, sleutel):
    if knoop is None:
        return None
    if sleutel < knoop.sleutel:
        knoop.links = verwijder(knoop.links, sleutel)
    elif sleutel > knoop.sleutel:
        knoop.rechts = verwijder(knoop.rechts, sleutel)
    else:
        if knoop.links is None:  # blad of alleen rechterkind
            return knoop.rechts
        if knoop.rechts is None:  # alleen linkerkind
            return knoop.links
        opvolger = knoop.rechts  # twee kinderen: kleinste in rechter deelboom
        while opvolger.links is not None:
            opvolger = opvolger.links
        knoop.sleutel = opvolger.sleutel
        knoop.rechts = verwijder(knoop.rechts, opvolger.sleutel)
    return knoop


def preorder(k):
    return [] if k is None else [k.sleutel] + preorder(k.links) + preorder(k.rechts)


def inorder(k):
    return [] if k is None else inorder(k.links) + [k.sleutel] + inorder(k.rechts)


def postorder(k):
    return [] if k is None else postorder(k.links) + postorder(k.rechts) + [k.sleutel]


def level_order(wortel):
    resultaat, wachtrij = [], deque([wortel] if wortel else [])
    while wachtrij:
        k = wachtrij.popleft()
        resultaat.append(k.sleutel)
        for kind in (k.links, k.rechts):
            if kind is not None:
                wachtrij.append(kind)
    return resultaat


wortel = None
for s in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
    wortel = voeg_toe(wortel, s)
print(inorder(wortel))  # [1, 3, 4, 6, 7, 8, 10, 13, 14]

Gebalanceerd of scheef

Alle operaties kosten O(h), met h de hoogte van de boom. Hoe hoog is een boom met n knopen?

  • Gebalanceerd: elk niveau is (bijna) vol. Een boom met hoogte h bevat dan tot 2^(h+1) - 1 knopen, dus h ≈ log n. Met een miljoen sleutels heb je ongeveer twintig stappen nodig.
  • Scheef (degenerate tree): voeg je 1, 2, 3, 4, 5 in deze volgorde in, dan gaat elke sleutel naar rechts. Je krijgt een gelinkte lijst en h = n - 1. Zoeken kost dan O(n).
1
 \
  2
   \
    3
     \
      4
       \
        5

Gesorteerde invoer is precies het geval dat in de praktijk vaak voorkomt (oplopende id's, tijdstempels). Daarom gebruik je in echte software een zelfbalancerende boom.

AVL-bomen en rotaties

Een AVL-boom is een BST die na elke invoeging en verwijdering controleert of bij elke knoop de hoogtes van de linker en rechter deelboom hoogstens 1 verschillen. Zo niet, dan herstelt hij de balans met een rotatie: een kleine herschikking van drie knopen die de BST-eigenschap behoudt.

  • Rotatie naar rechts bij een te zware linkerkant (links-links-geval).
  • Rotatie naar links bij een te zware rechterkant (rechts-rechts-geval).
  • Dubbele rotaties (links-rechts en rechts-links) als de zware kant een "knik" heeft.

Een rotatie kost O(1), dus een AVL-boom garandeert O(log n) voor alle operaties. Een rood-zwarte boom (red-black tree) doet hetzelfde met iets soepelere regels; die zit achter Java's TreeMap en TreeSet.

Complexiteit

OperatieGebalanceerdScheef (slechtste geval)
ZoekenO(log n)O(n)
InvoegenO(log n)O(n)
VerwijderenO(log n)O(n)
Doorlopen (alle vier)O(n)O(n)
Minimum / maximumO(log n)O(n)

Voor een AVL- of rood-zwarte boom geldt de middelste kolom altijd. Vergelijk dit met een hashtabel: die is gemiddeld sneller (O(1)), maar een zoekboom houdt de sleutels gesorteerd, zodat je ook "geef alle sleutels tussen 20 en 50" efficiënt kunt beantwoorden.

Veelgemaakte fouten op het tentamen

  1. Alleen met het directe kind vergelijken. De BST-eigenschap geldt voor de hele deelboom. Een boom met 5 als wortel en 7 ergens in de linker deelboom is geen BST, ook al klopt elk ouder-kindpaar.
  2. Met de verkeerde opvolger vervangen. Bij twee kinderen neem je de kleinste in de rechter deelboom, niet het rechterkind zelf. Bij het verwijderen van 3 is dat 4, niet 6.
  3. Preorder en postorder verwisselen. Preorder begint met de wortel, postorder eindigt ermee.
  4. Level-order als "eerst alles links" lezen. Level-order gaat niveau voor niveau: 8, 3, 10, …, niet 8, 3, 1, … (dat is preorder).
  5. Vergeten dat de invoegvolgorde de vorm bepaalt. Gesorteerde invoer geeft een scheve boom met hoogte n - 1, geen gebalanceerde.

Samenvatting

  • In een BST staat alles links van een knoop kleiner en alles rechts groter; inorder geeft de gesorteerde volgorde.
  • Invoegen en zoeken volgen één pad vanaf de wortel; nieuwe sleutels worden altijd een blad.
  • Verwijderen kent drie gevallen; bij twee kinderen vervang je de sleutel door de opvolger, de kleinste in de rechter deelboom.
  • Alle operaties kosten O(h): O(log n) als de boom gebalanceerd is, O(n) als hij scheef is.
  • AVL- en rood-zwarte bomen houden de boom met rotaties in balans.

Oefenen

Je antwoord wordt direct nagekeken. De oefeningen volgen precies de werkwijze uit de uitleg hierboven.