Handboek · hoofdstuk 34Algoritmen en complexiteit
Een correct programma wordt pas bruikbaar als zijn werk binnen het budget past
De inspectierobot moet een meetpunt bereiken voordat zijn batterij leeg raakt. De Pi moet miljoenen logregels verwerken terwijl de regeling doorloopt. Je onderzoekt welke algoritmen hetzelfde antwoord leveren, hoeveel werk ze vragen en welke aannamen hun correctheid dragen.

- Je kan lineair en binair zoeken uitvoeren en hun voorwaarden verklaren.
- Je kan sorteeralgoritmen vergelijken met een bewerkingsmodel en O-notatie.
- Je kan recursieve functies volgen en een basisgeval en voortgang bewijzen.
- Je kan Dijkstra uitvoeren en een pad reconstrueren.
- Je kan een algoritme kiezen op basis van tijd, geheugen en worst-case eisen.
Van recept naar bewijs
Een algoritme beschrijft eindige, ondubbelzinnige stappen voor een klasse problemen. De specificatie legt invoer, uitvoer en uitzonderingen vast. ‘Zoek een temperatuur’ is onvolledig: zoek je een exact tijdstip, de eerste overschrijding of de dichtstbijzijnde waarde? Een correcte implementatie kan toch de verkeerde specificatie uitvoeren.
Drie bewijsstukken horen bij het ontwerp: een preconditie, een invariant tijdens de uitvoering en een postconditie. Lineair zoeken bewaart: alle eerder bekeken posities bevatten de gezochte sleutel niet. Wanneer een overeenkomst verschijnt, is de gevonden index juist; wanneer de lijst eindigt, is de sleutel afwezig. De lus eindigt omdat het aantal ongecontroleerde elementen telkens afneemt.
| contract | voorbeeld |
|---|---|
| invoer | lijst met tijdstempels, sleutel t |
| uitvoer | index of None |
| foutgeval | dubbele tijdstempels: eerste index |
| budget | hoogstens n vergelijkingen |
Zoeken: elke rij of telkens de helft
Lineair zoeken vraagt in het slechtste geval vergelijkingen. Binair zoeken vereist een gesorteerde reeks en snelle toegang tot het middelste element. Na een vergelijking blijft alleen een interval over: links als de sleutel kleiner is, rechts als hij groter is. Invariant: als de sleutel bestaat, ligt hij in het overblijvende interval.
Werk met een halfopen interval : begin met , . Kies . Bij een kleinere sleutel wordt , bij een grotere . Voor is het interval leeg. De verminderende intervalbreedte bewijst beëindiging, ook bij een lege lijst. Na halveringen blijven hoogstens elementen over: de groei is logaritmisch. Een gekoppelde lijst kent geen goedkope middentoegang; daar verdwijnt dit voordeel.
Een jaarlog doorzoeken
- 525 600 records
- één sleutelvergelijking kost in het model 0,2 µs
- Worst-case zoektijd bij lineair en binair zoeken
- 1Het aantal records is .
- 2Lineair: .
- 3Omdat en , volstaan ten hoogste 20 sleutelvergelijkingen: 4 µs in dit model.
Sorteren: wat kost orde maken?
Invoegsorteren houdt links een gesorteerd prefix bij. Elk nieuw element schuift naar zijn plaats. Bij omgekeerde invoer zijn er verschuivingen: . Bij reeds gesorteerde invoer is het lineair. Het algoritme kan stabiel zijn: records met dezelfde sleutel behouden hun volgorde.
Mergesort splitst de lijst in tweeën, sorteert beide helften en voegt ze samen. Samenvoegen kost ; op elke van ongeveer niveaus wordt in totaal werk gedaan. Dus . Een gewone arrayimplementatie gebruikt tijdelijk geheugen. Quicksort verdeelt rond een pivot: gemiddeld , maar bij slechte verdelingen. Een bibliotheeksortering is gewoonlijk verstandiger dan een eigen sorteerroutine, tenzij een meetbaar geheugen- of real-time contract anders vereist.
| algoritme | slechtste tijd | extra geheugen |
|---|---|---|
| invoegsorteren | O(n²) | O(1) |
| mergesort arrays | O(n log n) | O(n) |
| quicksort, basisvorm | O(n²) | O(n) recursiestapel in slechtste geval |
Zoeken, sorteren en recursie echt uitvoeren
De uitvoer telt drie verschillende soorten werk expliciet. Invoegsorteren geeft bij omgekeerde data 28, 120 en 496 verschuivingen. Mergesort geeft voor deze invoervorm 12, 32 en 80 sleutelvergelijkingen; dit is niet het slechtste geval. Naïeve Fibonacci doet voor n=10 precies 177 aanroepen; memoïsatie berekent slechts 11 verschillende deelproblemen. De uitvoer van zoeken toont elk interval en middenpunt, inclusief de afwezige sleutel.
from functools import cache def zoek(a, sleutel): l,r = 0,len(a) spoor = [] while l < r: m = (l+r)//2 spoor.append((l,r,m,a[m])) if a[m] == sleutel: return m,spoor if a[m] < sleutel: l=m+1 else: r=m return None,spoor def invoeg(a): a = list(a) verschuivingen = 0 for i in range(1,len(a)): sleutel = a[i] j = i while j>0 and a[j-1]>sleutel: a[j]=a[j-1]; j-=1; verschuivingen+=1 a[j]=sleutel return a,verschuivingen def merge(a): if len(a)<2: return list(a),0 m=len(a)//2 links,nl=merge(a[:m]); rechts,nr=merge(a[m:]) i=j=0; resultaat=[]; vergelijkingen=nl+nr while i<len(links) and j<len(rechts): vergelijkingen+=1 if links[i]<=rechts[j]: resultaat.append(links[i]); i+=1 else: resultaat.append(rechts[j]); j+=1 return resultaat+links[i:]+rechts[j:],vergelijkingen for sleutel in (13,8): print("zoeken",sleutel,zoek([2,4,7,9,13,18,25,31],sleutel))for n in (8,16,32): data=list(range(n,0,-1)) a,ni=invoeg(data); b,nm=merge(data) assert a==b==sorted(data) print("n",n,"verschuivingen",ni,"mergevergelijkingen",nm) aanroepen=0def fib(n): global aanroepen aanroepen+=1 return n if n<2 else fib(n-1)+fib(n-2)print("fib(10)",fib(10),"aanroepen",aanroepen)aanroepen=0@cachedef fib_geheugen(n): global aanroepen aanroepen+=1 return n if n<2 else fib_geheugen(n-1)+fib_geheugen(n-2)print("fib memo",fib_geheugen(10),"berekende deelproblemen",aanroepen)zoeken 13 (4, [(0, 8, 4, 13)]) zoeken 8 (None, [(0, 8, 4, 13), (0, 4, 2, 7), (3, 4, 3, 9)]) n 8 verschuivingen 28 mergevergelijkingen 12 n 16 verschuivingen 120 mergevergelijkingen 32 n 32 verschuivingen 496 mergevergelijkingen 80 fib(10) 55 aanroepen 177 fib memo 55 berekende deelproblemen 11
O is een bovengrens, geen stopwatch
betekent: er bestaan en zodat voor alle . Constanten en lagere machten verdwijnen uit de groeiklasse: . Met zeg je ook dat de orde van onderen begrensd is. Een algoritme is formeel tevens ; daarom vermeld je de scherpste nuttige grens.
Een werkmodel moet benoemen wat telt: vergelijkingen, vermenigvuldigingen, bytes of berichten. Op de Mega kan geheugengebruik de doorslag geven; op de Pi kan netwerk-I/O belangrijker zijn dan rekenen. Dezelfde asymptotische klasse kan een factor honderd verschillen. Meet met herhalingen, geef invoergrootte en omstandigheden en gebruik de mediaan; een kleine proef bewijst geen worst-case grens.
Wanneer het kwadratische model vastloopt
- Modeltijd T = 0,5 µs · n²
- tijdlimiet 1 s
- Grootste n binnen het modelbudget
- 1Los op: .
- 2Een geheel aantal records geeft n ≤ 1414; n = 1415 overschrijdt het budget.
- 3Een gesorteerde controle van opeenvolgende sleutels vraagt slechts n−1 vergelijkingen na het sorteren.
Recursie: een kleinere kopie van het probleem
Recursie heeft een basisgeval en een stap die aantoonbaar dichterbij dat geval komt. Voor faculteit: en voor geheel . De invoer moet gevalideerd worden; een negatieve n bereikt 0 nooit. Elke actieve aanroep bewaart een frame: bij faculteit zijn tijd en stapel beide . Een iteratieve lus gebruikt extra geheugen.
De naïeve Fibonacci-recursie herberekent dezelfde deelproblemen. De aanroepboom groeit exponentieel. Memoïsatie bewaart elk resultaat eenmaal; dan worden tijd en geheugen . Een iteratieve oplossing bewaart alleen de vorige twee waarden. Deel-en-heers zoals mergesort is nuttige recursie, omdat de deelproblemen zonder deze explosieve overlap ontstaan.
De gang wordt een graaf
Een kruising of meetplaats is een knoop; een berijdbare verbinding is een kant. De afstand is een gewicht. Eenrichtingsverkeer vraagt gerichte kanten. Verboden doorgangen haal je uit de graaf; een grote straf is ongeschikt als de route echt verboden is. Bij vaste snelheid is afstand een bruikbare doelfunctie; bij draaien, wachten en hellingen modelleer je reistijd of energie.
Een adjacentielijst bewaart per knoop de buren: geheugen. Een adjacentiematrix kost en biedt directe kantopzoeking. Niet elk paar knopen is verbonden; onbereikbaarheid hoort als oneindige afstand in de uitvoer. Het voorbeeld breidt de lusrobot van i33 uit met keuzeroutes, zonder de eerdere ganggeometrie als echte stationskaart te presenteren.
Voorlopig is nog niet definitief
- Alle kantgewichten staan in de voorbeeldgraaf
- De eerste drie extracties en relaxaties
- 1Laad met 0 wordt definitief. Relaxaties leveren A=2 en B=4; wachtrij (2,A),(4,B).
- 2A met 2 wordt genomen. B verbetert van 4 naar 3 via A; C krijgt 7 via A. De heap bevat (3,B),(4,B),(7,C).
- 3B met 3 wordt genomen. C verbetert naar 5 en D krijgt 8. De oude (4,B) wordt later verworpen, omdat afstand[B] inmiddels 3 is. De invariant betreft de beste afstand per knoop, niet elke heapentry.
Dijkstra: waarom de kleinste kandidaat definitief is
Begin met afstand 0 bij de bron en oneindig elders. Neem telkens de kleinste voorlopige afstand uit de prioriteitswachtrij. Relaxeer alle uitgaande kanten: . Bewaar bij een verbetering de voorganger. Het pad lees je achterwaarts terug.
Het correctheidsargument gebruikt niet-negatieve gewichten: een omweg door nog onbekende knopen kan een reeds kleinste afstand niet verkleinen. Met een negatieve kant breekt dat argument. Een heap biedt per extractie of invoeging; met een gebruikelijke efficiënte implementatie is de tijd . Deze lescode laat oude heapkandidaten staan: bij verwerking worden ze verworpen. De heap kan dan entries bewaren, met tijd ; voor eenvoudige grafen is dit dezelfde gebruikelijke logaritmische orde.
| definitief | afstand (m) | voorganger |
|---|---|---|
| Laad | 0 | — |
| A | 2 | Laad |
| B | 3 | A |
| C | 5 | B |
| D | 6 | C |
| E | 8 | D |
Kortste pad en een kleine meetronde
De uitvoer wordt bij het bouwen opnieuw berekend. De meetronde bezoekt A, C en E en keert terug; we testen voor slechts drie meetpunten alle zes volgordes. Dijkstra berekent de afstanden tussen meetpunten, maar kiest zelf geen optimale rondreis.
from heapq import heappush, heappopfrom itertools import permutations G = {k: {} for k in ("Laad", "A", "B", "C", "D", "E")}for a, b, w in [("Laad","A",2), ("Laad","B",4), ("A","B",1), ("A","C",5), ("B","C",2), ("B","D",5), ("C","D",1), ("C","E",4), ("D","E",2)]: G[a][b] = G[b][a] = w def dijkstra(g, start): afstand = {v: float("inf") for v in g} vorig = {} afstand[start] = 0 wachtrij = [(0, start)] while wachtrij: d, v = heappop(wachtrij) if d != afstand[v]: # oude kandidaat na een betere route continue for buur, w in g[v].items(): if w < 0: raise ValueError("Dijkstra vereist niet-negatieve gewichten") nieuw = d + w if nieuw < afstand[buur]: afstand[buur] = nieuw vorig[buur] = v heappush(wachtrij, (nieuw, buur)) return afstand, vorig def pad(vorig, doel): route = [doel] while route[-1] in vorig: route.append(vorig[route[-1]]) return route[::-1] d, v = dijkstra(G, "Laad")print("afstanden", d)print("route", " -> ".join(pad(v, "E")))print("rijtijd", d["E"] / 0.20, "s")stations = ("A", "C", "E")alle = {k: dijkstra(G, k)[0] for k in ("Laad",)+stations}def rondelengte(volgorde): p = ("Laad",)+volgorde+("Laad",) return sum(alle[a][b] for a,b in zip(p,p[1:]))best = min(permutations(stations), key=rondelengte)print("meetronde", best, rondelengte(best), "m")assert d["E"] == 8 and rondelengte(best) == 16afstanden {'Laad': 0, 'A': 2, 'B': 3, 'C': 5, 'D': 6, 'E': 8}
route Laad -> A -> B -> C -> D -> E
rijtijd 40.0 s
meetronde ('A', 'C', 'E') 16 mEén bestemming verschilt van alle bezoeken
Een kortste pad tussen twee knopen is een ander probleem dan een rondreis die alle inspectiepunten bezoekt. Het handelsreizigersprobleem heeft in een brute-force aanpak volgordes; symmetrie kan dat aantal verkleinen, maar de groei blijft explosief. Voor drie punten zijn zes volgordes klein, voor twintig zijn het circa .
Een heuristiek zoals telkens de dichtstbijzijnde onbezochte plek geeft snel een route, zonder garantie op het optimum. A* gebruikt een ondergrens op de resterende afstand: met een geschikte admissibele heuristiek blijft een optimale oplossing mogelijk. Bij veranderende obstakels moet de robot opnieuw plannen. Lokale obstakeldetectie en de noodstop blijven afzonderlijke functies: een optimale kaart mag nooit een actuele blokkade overrulen.
Een echte softwareles: bibliotheken hebben voorwaarden
De Python-documentatie beschrijft bisect als invoegposities zoeken in een gesorteerde lijst. De zoekstap is logaritmisch, maar invoegen in een array verschuift elementen en kost lineaire tijd. Een naam zoals ‘binaire invoeging’ maakt dus de volledige bewerking niet logaritmisch. Voor de stationslogger, die bijna altijd in tijdsvolgorde schrijft, is achteraan toevoegen eenvoudiger.
De documentatie van heapq beschrijft de min-heap: het kleinste element ligt bovenaan, terwijl de overige lijst niet volledig gesorteerd is. Een heap en een geordende lijst hebben verschillende invarianten. Bron: Python bisect en heapq, geraadpleegd 30-09-2026.
| structuur | snel | voorwaarde |
|---|---|---|
| bisect | positie zoeken | lijst blijft gesorteerd |
| heap | minimum verwijderen | heapinvariant |
| hash-tabel | sleutel opzoeken, gemiddeld | hash en gelijkheid correct |
Algoritmen kiezen met een contract
| symbool | betekenis | eenheid |
|---|---|---|
| aantal records | — |
| situatie | keuze | controle |
|---|---|---|
| één sleutel, ongesorteerd | lineair | eerste/laatste/afwezig |
| veel sleutels, geordend | binair | dubbele sleutels en intervalgrenzen |
| niet-negatieve routekosten | Dijkstra | onbereikbaar, nullen, duplicaten |
| harde deadline | begrensd algoritme | worst-case geheugen en uitvoering |
Rekenen, bewegen en plannen
Een algoritme verplaatst geen robot zonder correcte kaart, eenheidsafspraken en een actuatorlaag. De routeplanner produceert een lijst van knopen; de bestaande lijnvolger voert de verbindingen uit. Het meetbudget omvat rijden, stilstand, controleren en terugkeren.
De bestaande robot voert de route uit.
Planner en bestuurder worden aparte modules.
Een deadline vraagt een begrensde uitvoering.
Een afhankelijkheidsgraaf geeft ook een kritisch pad.
Habitat krijgt een routeplanner op de Pi; de microcontroller behoudt afstandsbewaking en stoppen. De gewogen graaf is een expliciet voorbeeldmodel, te vervangen door een opgemeten kaart.
In het kort
- Correctheid vraagt een contract en invariant; snelheid vraagt een bewerkingsmodel.
- Binair zoeken gebruikt orde; sorteren kan duurder zijn dan één zoekopdracht.
- Recursie moet eindigen en kan herhaald werk veroorzaken.
- Dijkstra lost niet-negatieve kortste paden op; een bezoekronde is een ander probleem.
Wat je nu kent
- tijdscomplexiteit
- De groei van het aantal elementaire bewerkingen als functie van de invoergrootte.
- invariant
- Een eigenschap die vóór en na elke lusiteratie waar blijft.
- recursie
- Een functie die kleinere exemplaren van hetzelfde probleem oplost door zichzelf aan te roepen.
- graaf
- Een verzameling knopen met verbindingen, eventueel met richting en gewicht.
- relaxatie
- Een voorlopige padafstand vervangen wanneer een kortere route wordt gevonden.