Binair zoeken uitgelegd: van lineair zoeken naar O(log n)
Lineair en binair zoeken stap voor stap: een trace met lo, hi en mid, de off-by-one-valkuilen, het eerste voorkomen vinden en code in Python en Java.
Zoeken in een lijst klinkt triviaal: loop alles af tot je het vindt. Dat werkt, maar bij een miljoen elementen kost het in het slechtste geval een miljoen vergelijkingen. Is de lijst gesorteerd, dan kan het met twintig. Dat is het verschil tussen lineair en binair zoeken.
Binair zoeken (binary search) is een van de meest gevraagde algoritmes op tentamens en in
sollicitatiegesprekken, juist omdat het er simpel uitziet en toch vol zit met kleine valkuilen: <
of <=, mid of mid+1? Op deze pagina zie je precies welke variant we gebruiken en waarom die
klopt.
Lineair zoeken
Lineair zoeken (linear search) bekijkt de elementen één voor één, van voor naar achter, tot het gezochte element gevonden is of de lijst op is.
def lineair_zoeken(a, x):
for i in range(len(a)):
if a[i] == x:
return i # gevonden op index i
return -1 # niet gevonden
Het voordeel: de lijst hoeft niet gesorteerd te zijn, en het werkt ook op een
gelinkte lijst. Het nadeel: O(n). Staat het element
achteraan of helemaal niet in de lijst, dan bekijk je alles.
Binair zoeken: het idee
Denk aan een woordenboek. Je zoekt "kwartel" en slaat het boek ergens in het midden open bij "mango". Omdat de woorden alfabetisch staan, weet je meteen: het moet in de eerste helft zitten. De tweede helft hoef je nooit meer te bekijken.
Binair zoeken doet hetzelfde met een gesorteerde array. Je houdt twee grenzen bij, lo en hi,
waartussen het element moet zitten als het er is. Elke stap:
- Bereken
mid = ⌊(lo + hi) / 2⌋(naar beneden afronden). - Is
a[mid]gelijk aanx? Gevonden. - Is
a[mid] < x? Dan zitxrechts vanmid:lo = mid + 1. - Anders zit
xlinks vanmid:hi = mid - 1.
Je begint met lo = 0 en hi = n - 1, en je stopt als lo > hi: dan is het zoekgebied leeg en
staat x niet in de array.
Uitgewerkt voorbeeld: gevonden
We zoeken x = 30 in deze array van tien elementen:
| index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| a | 3 | 8 | 12 | 17 | 21 | 25 | 30 | 34 | 41 | 50 |
| Stap | lo | hi | mid | amid | Vergelijking | Actie |
|---|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 21 | 21 < 30 | lo = 5 |
| 2 | 5 | 9 | 7 | 34 | 34 > 30 | hi = 6 |
| 3 | 5 | 6 | 5 | 25 | 25 < 30 | lo = 6 |
| 4 | 6 | 6 | 6 | 30 | gelijk | gevonden op index 6 |
De waarden die achtereenvolgens worden vergeleken zijn 21, 34, 25, 30. Let op stap 3: (5 + 6) / 2 = 5,5, afgerond naar beneden geeft mid = 5.
Uitgewerkt voorbeeld: niet gevonden
Nu zoeken we x = 10 in dezelfde array:
| Stap | lo | hi | mid | amid | Vergelijking | Actie |
|---|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 21 | 21 > 10 | hi = 3 |
| 2 | 0 | 3 | 1 | 8 | 8 < 10 | lo = 2 |
| 3 | 2 | 3 | 2 | 12 | 12 > 10 | hi = 1 |
| – | 2 | 1 | – | – | lo > hi | stop: niet gevonden |
Vergeleken waarden: 8 en 12 liggen naast elkaar en 10 zit ertussen. Na drie vergelijkingen
(21, 8, 12) is het zoekgebied leeg. De eindwaarde van lo (hier 2) is precies de plek waar je 10
zou moeten invoegen om de array gesorteerd te houden; dat is handig om te onthouden.
De code
def binair_zoeken(a, x):
lo, hi = 0, len(a) - 1
while lo <= hi: # zolang het zoekgebied niet leeg is
mid = (lo + hi) // 2 # naar beneden afronden
if a[mid] == x:
return mid
elif a[mid] < x:
lo = mid + 1 # x zit rechts van mid
else:
hi = mid - 1 # x zit links van mid
return -1 # niet gevonden
In Java schrijven we lo + (hi - lo) / 2 in plaats van (lo + hi) / 2. Voor niet-negatieve lo
en hi levert dat dezelfde mid op, maar het voorkomt dat lo + hi groter wordt dan de maximale
int bij hele grote arrays. In Python bestaat dat probleem niet.
Off-by-one-valkuilen
Binair zoeken is berucht om fouten van één positie. De drie keuzes in onze variant horen bij elkaar:
| Keuze | Waarom |
|---|---|
hi = n - 1 | hi is de laatste index die nog kandidaat is (inclusief) |
while lo <= hi | als lo == hi is er nog precies één kandidaat die je moet bekijken |
lo = mid + 1, hi = mid - 1 | a[mid] is al bekeken, die hoeft niet meer in het zoekgebied |
Wat gaat er mis als je een van de drie verandert?
while lo < hi: in het eerste voorbeeld stop je na stap 3 metlo = hi = 6, zondera[6]te bekijken. Je meldt "niet gevonden" terwijl30er wel staat.lo = midin plaats vanmid + 1: metlo = 5, hi = 6krijg jemid = 5, en alsa[5] < xblijftloop 5 staan. Het zoekgebied krimpt niet: een oneindige lus.hi = nmet<=: dan kanmidgelijk worden aannen lees je buiten de array.
Er bestaan ook correcte varianten met hi = n (exclusief) en while lo < hi. Die werken prima, maar
meng de twee stijlen nooit. Op Practicely gebruiken we altijd de variant hierboven.
Variant: het eerste voorkomen
Staat x meerdere keren in de array, dan geeft gewoon binair zoeken een willekeurige van de
voorkomens terug. In [1, 3, 3, 3, 3, 7, 9] vindt het 3 al bij de eerste stap, op index 3. Wil je
het eerste voorkomen, dan ga je na een treffer verder zoeken in de linkerhelft en onthoud je de
beste index tot nu toe:
| Stap | lo | hi | mid | amid | Actie | Beste tot nu toe |
|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 3 | 3 | gelijk, zoek links: hi = 2 | 3 |
| 2 | 0 | 2 | 1 | 3 | gelijk, zoek links: hi = 0 | 1 |
| 3 | 0 | 0 | 0 | 1 | 1 < 3: lo = 1 | 1 |
Daarna is lo > hi en is het antwoord index 1.
def eerste_voorkomen(a, x):
lo, hi = 0, len(a) - 1
antwoord = -1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == x:
antwoord = mid # onthouden en links verder zoeken
hi = mid - 1
elif a[mid] < x:
lo = mid + 1
else:
hi = mid - 1
return antwoord
Het laatste voorkomen werkt spiegelbeeldig: na een treffer lo = mid + 1. Met eerste en laatste
voorkomen samen tel je in O(log n) hoe vaak een waarde voorkomt.
Complexiteit
Elke stap halveert het zoekgebied. Na k stappen zijn er hooguit n / 2ᵏ kandidaten over, dus na
ongeveer log₂ n stappen is er nog één. Als recurrente betrekking: T(n) = T(n/2) + 1, en volgens
de mastermethode (zie complexiteit) is dat O(log n).
| Lineair zoeken | Binair zoeken | |
|---|---|---|
| Voorwaarde | geen | array gesorteerd, directe toegang via index |
| Best case | O(1) (eerste element) | O(1) (middelste element) |
| Gemiddeld | O(n) | O(log n) |
| Slechtst | O(n) | O(log n) |
| Extra geheugen | O(1) | O(1) (iteratief) |
| Vergelijkingen bij n = 1.000.000 | tot 1.000.000 | hooguit 20 |
Moet je maar één keer zoeken in ongesorteerde data, dan is lineair zoeken sneller dan eerst sorteren
(O(n log n)) en dan binair zoeken. Zoek je vaak in dezelfde data, dan verdient het sorteren zich
snel terug. Nog sneller opzoeken kan met een hashtabel, en voor data
die steeds verandert is een binaire zoekboom handig.
Veelgemaakte fouten op het tentamen
- Binair zoeken op een ongesorteerde array. Het algoritme geeft dan gewoon een fout antwoord, zonder foutmelding. Controleer altijd eerst de voorwaarde.
midnaar boven afronden.(5 + 6) / 2wordt5, niet6. Bij een traceervraag verschuift één verkeerdemidalle volgende stappen.while lo < higebruiken bij de inclusieve variant, waardoor je het laatste kandidaat-element nooit bekijkt.lo = midofhi = midschrijven in plaats vanmid + 1enmid - 1. Dat kan een oneindige lus opleveren.- Bij de traceervraag de laatste vergelijking vergeten. Ook de vergelijking waarin
xgevonden wordt, hoort in de lijst van vergeleken waarden.
Samenvatting
- Lineair zoeken werkt altijd en kost
O(n); binair zoeken eist een gesorteerde array en kostO(log n). - Onze variant:
lo = 0,hi = n - 1,mid = ⌊(lo + hi) / 2⌋,while lo <= hi. - Bij
a[mid] < xwordtlo = mid + 1, andershi = mid - 1; zo krimpt het zoekgebied altijd. - Voor het eerste voorkomen zoek je na een treffer links verder en onthoud je de index.
