Complexiteit en Big-O: zo schat je de looptijd van een algoritme
Big-O en Θ zonder wiskundejargon: rekenregels, lussen tellen, recurrente betrekkingen en de mastermethode, met uitgewerkte voorbeelden en tentamenvalkuilen.
Twee programma's kunnen precies hetzelfde antwoord geven en toch totaal verschillend presteren. Het ene sorteert een miljoen getallen in een fractie van een seconde, het andere is na een uur nog bezig. Complexiteit (complexity) is de taal waarmee je dat verschil beschrijft zonder een stopwatch: je telt hoe het aantal stappen groeit als de invoer groter wordt.
Je hebt dit nodig op elk tentamen algoritmen, in vrijwel elk technisch sollicitatiegesprek ("wat is de complexiteit van jouw oplossing?") en in de praktijk, zodra je data groter wordt dan je testset. Alle andere onderwerpen hier, van sorteren tot grafen, gebruiken deze notatie.
Big-O en Θ: het idee
Noem de grootte van de invoer n (het aantal elementen in een lijst, het aantal knopen in een graaf).
De looptijd T(n) is het aantal basisstappen: een vergelijking, een toekenning, een optelling.
- Big-O (
O) is een bovengrens:T(n) = O(f(n))betekent datT(n)voor grotenhooguit een constante keerf(n)is. - Theta (
Θ) is een strakke grens:T(n)groeit precies zo snel alsf(n), op een constante factor na. Zowel boven- als ondergrens.
Strikt genomen is een enkele lus ook O(n²), want n² is óók een bovengrens. Maar dat is een
nutteloos antwoord. Op Practicely en op de meeste tentamens bedoelen we met Big-O de strakke grens:
een enkele lus is O(n), punt.
Rekenregels
Je hoeft nooit precies te tellen. Twee regels doen het meeste werk:
- Constanten vallen weg.
3n + 7isO(n).n/2is ookO(n). - Alleen de dominante term telt.
n² + 100n + 5000isO(n²), want voor grotenmaakt de rest niets meer uit.
Zie het zo: bij n = 1000 is n² een miljoen en 100n maar honderdduizend. Bij n = 1.000.000 is
het verschil een factor tienduizend.
Verder geldt: het grondtal van een logaritme maakt in Big-O niet uit, omdat log₁₀ n en log₂ n
een constante factor van elkaar verschillen. Staat er log n, dan bedoelen we log₂ n.
De veelvoorkomende klassen
Van snel naar traag:
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ)
| Klasse | Naam | Voorbeeld | Stappen bij n = 1000 (orde) |
|---|---|---|---|
O(1) | constant | element a[i] opvragen | 1 |
O(log n) | logaritmisch | binair zoeken | 10 |
O(n) | lineair | maximum van een lijst zoeken | 1.000 |
O(n log n) | n-log-n | merge sort | 10.000 |
O(n²) | kwadratisch | insertion sort, alle paren bekijken | 1.000.000 |
O(2ⁿ) | exponentieel | alle deelverzamelingen opsommen | meer dan atomen in het heelal |
Het verschil tussen O(n log n) en O(n²) is bij duizend elementen al een factor honderd. Daarom
loont het om de klasse van je algoritme te kennen.
Lussen tellen
Het meeste werk bij het bepalen van de complexiteit is: kijk hoe vaak de binnenste opdracht wordt uitgevoerd.
Enkele lus
for i in range(n):
totaal += a[i]
De body draait n keer: O(n).
Geneste lussen
for i in range(n):
for j in range(i + 1, n):
if a[i] == a[j]:
gevonden = True
Voor i = 0 draait de binnenste lus n-1 keer, voor i = 1 n-2 keer, enzovoort tot 0. Samen:
(n-1) + (n-2) + … + 1 + 0 = n(n-1)/2
Bij n = 16 zijn dat 120 vergelijkingen, bij n = 1000 precies 499.500. Constante (½) weg,
dominante term (n²) houden: O(n²). Dat de binnenste lus "maar half zo lang" is, verandert de
klasse niet.
Gehalveerde of verdubbelde teller
i = n
while i > 0:
i //= 2 # i wordt telkens gehalveerd
Bij n = 16 neemt i de waarden 16, 8, 4, 2, 1 aan: 5 iteraties. Elke stap halveert het werk,
dus het aantal stappen is ongeveer log₂ n: O(log n).
Hetzelfde geldt voor een teller die verdubbelt:
i = 1
while i < n:
i *= 2 # 1, 2, 4, 8, ... tot n
| Lus | n = 16 | n = 1000 | Klasse |
|---|---|---|---|
i //= 2 vanaf n tot 0 | 5 | 10 | O(log n) |
i *= 2 vanaf 1 tot n | 4 | 10 | O(log n) |
Let op het verschil met i -= 2 of i += 2: dat zijn n/2 stappen, dus gewoon O(n).
Sequentiële stukken
Twee stukken code ná elkaar tel je op, en dan houd je de grootste:
for i in range(n): # O(n)
totaal += a[i]
for i in range(n): # O(n²)
for j in range(n):
paren += 1
O(n) + O(n²) = O(n²). Nesten vermenigvuldigt, achter elkaar zetten telt op.
Een lus met een logaritmische binnenlus
for i in range(n):
j = 1
while j < n:
j *= 2
De buitenste lus draait n keer, de binnenste elke keer log n keer: O(n log n).
Recursie en recurrente betrekkingen
Bij een recursieve functie schrijf je de looptijd op als een recurrente betrekking
(recurrence relation): een formule die T(n) uitdrukt in T van een kleinere invoer.
Een paar standaardgevallen:
| Code-patroon | Recurrente betrekking | Oplossing |
|---|---|---|
één recursieve aanroep op n-1, constant werk | T(n) = T(n-1) + 1 | O(n) |
één aanroep op n-1, lineair werk (bv. een lus) | T(n) = T(n-1) + n | O(n²) |
| één aanroep op de helft, constant werk (binair zoeken) | T(n) = T(n/2) + 1 | O(log n) |
| twee aanroepen op de helft, lineair werk (merge sort) | T(n) = 2T(n/2) + n | O(n log n) |
twee aanroepen op n-1 (naïeve Fibonacci) | T(n) = 2T(n-1) + 1 | O(2ⁿ) |
Een voorbeeld: de som van een lijst, recursief.
def som(a, n):
# basisgeval: een lege lijst heeft som 0
if n == 0:
return 0
# één recursieve aanroep op n-1, plus constant werk
return som(a, n - 1) + a[n - 1]
print(som([4, 1, 7, 3], 4)) # 15
Dit is T(n) = T(n-1) + 1. Uitschrijven ("ontrollen") laat zien wat er gebeurt:
T(n) = T(n-2) + 2 = T(n-3) + 3 = … = T(0) + n, dus O(n).
De mastermethode
Voor recursies die het probleem in gelijke stukken delen, is er een kant-en-klaar recept: de mastermethode (master theorem). Hij werkt voor betrekkingen van de vorm
T(n) = a · T(n/b) + n^d
met a ≥ 1 deelproblemen, elk van grootte n/b, en n^d werk buiten de recursie (splitsen en
samenvoegen). Je vergelijkt a met b^d:
| Geval | Voorwaarde | Oplossing | Wat domineert |
|---|---|---|---|
| 1 | a < b^d | Θ(n^d) | het werk bovenaan de boom |
| 2 | a = b^d | Θ(n^d log n) | elk niveau evenveel werk |
| 3 | a > b^d | Θ(n^(log_b a)) | de bladeren onderaan |
Uitgewerkt:
| Betrekking | a | b | d | b^d | Vergelijking | Oplossing |
|---|---|---|---|---|---|---|
T(n) = 2T(n/2) + n (merge sort) | 2 | 2 | 1 | 2 | a = b^d | Θ(n log n) |
T(n) = T(n/2) + 1 (binair zoeken) | 1 | 2 | 0 | 1 | a = b^d | Θ(log n) |
T(n) = 2T(n/2) + n² | 2 | 2 | 2 | 4 | a < b^d | Θ(n²) |
T(n) = 8T(n/2) + n² | 8 | 2 | 2 | 4 | a > b^d | Θ(n^(log₂ 8)) = Θ(n³) |
T(n) = 4T(n/2) + n | 4 | 2 | 1 | 2 | a > b^d | Θ(n^(log₂ 4)) = Θ(n²) |
Let op het tweede voorbeeld: constant werk is n⁰, dus d = 0 en b^d = 1.
De intuïtie: teken de recursieboom. In geval 1 doet de wortel het meeste werk, in geval 3 zitten de
kosten in de enorme hoeveelheid bladeren, en in geval 2 kost elk van de log n niveaus evenveel.
De mastermethode werkt niet voor T(n) = T(n-1) + n: daar wordt n niet gedeeld maar met één
verminderd. Die los je op door te ontrollen, zoals hierboven.
Complexiteit in één tabel
| Situatie | Complexiteit |
|---|---|
vaste hoeveelheid werk, onafhankelijk van n | O(1) |
| teller halveert of verdubbelt | O(log n) |
| één lus over alle elementen | O(n) |
lus van n met daarbinnen een log n-lus | O(n log n) |
twee geneste lussen over n | O(n²) |
| twee lussen ná elkaar | de grootste van de twee |
| alle deelverzamelingen proberen | O(2ⁿ) |
Veelgemaakte fouten op het tentamen
- Elke geneste lus is
O(n²)noemen. Kijk naar wat de binnenste lus doet. Halveert de teller, dan is hetO(n log n). Loopt de binnenste lus een vast aantal keer (bijvoorbeeld 3), dan is het gewoonO(n). - Optellen en vermenigvuldigen door elkaar halen. Lussen ná elkaar: optellen, de grootste houden. Lussen ín elkaar: vermenigvuldigen.
- Een te ruime grens geven.
O(n²)voor een enkele lus is technisch niet fout, maar levert op het tentamen geen punten op. Geef de strakke grens. dverkeerd aflezen bij de mastermethode. Constant werk betekentd = 0, nietd = 1. En vergelijkametb^d, niet metb.- De mastermethode toepassen op
T(n-1). Alleen bij delen doorbwerkt het recept; bij aftrekken ontrol je de recursie.
Samenvatting
- Big-O beschrijft hoe de looptijd groeit met
n; constanten en lagere termen vallen weg. - Op het tentamen geef je de strakke grens: een enkele lus is
O(n), nietO(n²). - Geneste lussen vermenigvuldig je, opeenvolgende lussen tel je op; een halverende teller geeft
log n. - Recursie schrijf je als recurrente betrekking; bij
T(n) = aT(n/b) + n^dvergelijk jeametb^d. - Volgorde om te onthouden:
O(1)<O(log n)<O(n)<O(n log n)<O(n²)<O(2ⁿ).
