Disclaimer: Diese Mitschrift ist als Ergänzung zur Vorlesung gedacht. Wir erheben keinen Anspruch auf Vollständigkeit und Korrektheit. Wir sind jederzeit froh um Hinweise zu Fehlern oder Unklarheiten an grafdan@ethz.ch.
Wenn Sie an dieser Mitschrift mitarbeiten möchten, melden Sie sich hier.
Wir wollen heute die Techniken zum Entwurf und zur Analyse von Algorithmen, die wir letzte Woche gesehen haben, auf ein erstes Themengebiet anwenden: das Suchen in Datenmengen. Wir nehmen an, dass wir
Der Einfachheit halber nehmen wir meist an, dass diese Schlüssel positive ganze Zahlen sind. Es können aber auch genausogut Zeichenketten wie in einem Wörterbuch sein. Ein Beispiel:
Wir haben letzte Woche auch gelernt, dass niemand von Ihnen die Rektorin kennt. Darum dachte ich mir, schlage ich mal im ETH-Who-is-who nach, was ich über Sarah Springman herausfinde. Das ist ein dickes Buch mit 1521 Seiten und den Angaben zu rund 45'000 ETH-Absolventen.
Wenn wir nun die Rektorin darin von vorne nach hinten suchen kann das lange dauern. Man muss nicht Informatik studieren um zu sehen, dass es nicht sinnvoll ist, bei Abel und Abgottspon zu beginnen. Was würden Sie tun?
Das Verzeichnis ist zum Glück alphabetisch sortiert und das wollen wir natürlich ausnutzen. Schlagen wir das Buch in der Mitte auf bei 'Luginbühl' auf, so wissen wir sofort, dass sich die Rektorin nicht in der ersten Hälfte versteckt und können die ersten 760 Seiten von nun an getrost ignorieren. In der Mitte der zweiten Hälfte, auf Seite 1140, sind wir mit 'Sidler' schon ziemlich nahe und können erneut 380 Seiten (die erste Hälfte des Restes) wegwerfen. Nun schauen wir auf Seite
TODO Skizze der binären Suche im Buch
Nach rund sieben weiteren Schritten landen wir so auf Seite 1166 zwischen 'Springer' und 'Spross' und stellen fest, dass Prof. Springman nicht an der ETH studierte[1]. Uns interessiert heute aber auch nicht unbedingt der Werdegang der Rektorin, sondern das Suchverfahren, das wir soeben angewendet haben.
Wir haben ausgenutzt, dass das Alumni-Verzeichnis alphabetisch sortiert ist. Wir verlangen also von unseren Schlüsseln in
Wie wir Schlüssel sortieren, werden wir nächste Woche im Detail studieren. Für heute nehmen wir einfach an, dass wir
Das Divide-and-Conquer-Verfahren des wiederholten Halbierens und Wegwerfens, das wir vorhin angewandt haben, heisst binäre Suche. Divide-and-Conquer-Verfahren beschreiben wir am einfachsten rekursiv:
In jedem rekursiven Aufruf passen wir also entweder das linke oder das rechte Ende der noch relevanten Teilliste an. Wir können das Verfahren daher auch leicht iterativ formulieren indem wir Schritt für Schritt die Grenzen anpassen:
binary_search(A, k)
left := 1
right := n
while (left <= right) do
middle := ⌊(left+right)/2⌋
if (A[middle] = k) then
return "Gefunden an Position (middle)."
else if (k < A[middle]) then
right := middle-1
else
left := middle+1
return "Nicht vorhanden."
Wie bereits bekannt, stellen wir uns zu jedem neuen Algorithmus zwei Fragen: Ist er korrekt und ist er schnell?
Die Korrektheit der binären Suche zeigen wir am einfachsten mit Hilfe einer (aus Eiffel wohl bekannten) Schleifeninvarianten. Nach jeder Iteration gilt: "Falls das gesuchte Element in
Die Korrektheit folgt nun aus der Invariante und daraus, dass wir die Rekursion nur dann abbrechen, falls kein Elemente mehr übrig ist oder wir
In jeder Iteration halbiert sich der verbleibende Suchbereich in etwa. Um genau zu sein sind bei einer Suche mit
Wir teleskopieren
was uns zur Vermutung führt:
Induktionsbeweis
Somit liegt die Laufzeit der binären Suche in
Führen wir uns das (eindrückliche!) Resultat dieser Analyse nochmals vor Augen. Eine Suche im Telefonbuch von vorne nach hinten skaliert nur linear mit der Grösse des Buches. Fügen wir eine Seite hinzu, so müssen wir im schlimmsten Fall eine Seite mehr anschauen. Fügen wir 100 Seiten hinzu, sind es 100 Schritte mehr. Auch wenn wir etwas cleverer sind und jeweils 10 Seiten auf einmal weiterspringen senkt dies lediglich die Steigung unserer linearen Wachstumskurve und erzielt nichts fundamental Besseres.
Was wir intuitiv machen und soeben analysiert haben, ist ein exponentieller Speedup! Solange das Telefonbuch nicht mehr als doppelt so dick wird, genügt ein einziger zusätzlicher Suchschritt.
In der Praxis kann dies locker den Unterschied zwischen unglaublich langsam und instantan ausmachen. Binäre Suche ist nur einer von vielen Algorithmen mit dieser Eigenschaft und wir wollen zur Illustration kurz ein zweites Beispiel anschauen: Ich würde gerne zählen, wieviele Studierende momentan im Raum sind. Ich könnte nun jeden Sitzplatz durchzählen (
Kopfrechenkünste und einigermassen gleichmässiges Timing vorausgesetzt, können wir so die Anzahl Personen in logarithmisch vielen Schritten bestimmen. Selbst für einen doppelt so grossen Vorlesungssaal würde es also nicht doppelt so lange sondern lediglich einen Schritt länger dauern.
Kehren wir nun zur binären Suche zurück. Die Frage "Geht es noch schneller?", die wir uns letzte Woche antrainiert haben, bleibt noch zu beantworten. Vielleicht finden wir ja gar noch einen besseren Algorithmus in
Um zu argumentieren, dass es nicht schneller geht, können wir uns einen beliebigen, deterministischen Suchalgorithmus auch als Entscheidungsbaum vorstellen, der in jedem Knoten einen Schlüsselvergleich durchführt und dann je nach Ergebnis erfolgreich endet oder in einen von zwei Teilbäumen absteigt.
Dieser Suchbaum muss nun mindestens
Mit einem ähnlichen Argument, können wir auch zeigen, dass wir selbst im Mittel nicht schneller sein können als logarithmisch.
Die binäre Suche hat eine lange Geschichte und ist bekannt dafür, zwar einfach auszusehen aber trotzdem fehleranfällig in der Implementierung zu sein.
Donald Knuth schreibt in seinem Buch[3], dass die binäre Suche 1946 wohl als erster nichtnumerischer Algorithmus publiziert wurde, aber fünfzehn Jahre lang niemand korrigierte, dass das beschriebene Verfahren nur für
Es gibt aber auch viele durchaus sehr berechtigte Abwandlungen der binären Suche, die oft nützlich sind und wir nun betrachten wollen.
Vergessen wir die Algorithmik für den Moment und überlegen wir uns, wie wir selber in einem Telefonbuch suchen. Auf der Suche nach "Springman" schlagen wir das Telefonbuch wohl nicht genau in der Mitte sondern eher etwas im hinteren Bereich auf. Wir nehmen an, dass die Nachnamen übers Alphabet halbwegs gleichmässig verteilt sind und "S" daher etwa im letzten Drittel anzutreffen sein sollte.
Genau diese Idee macht sich auch die Interpolationssuche zu Nutze. Statt
Diese Schätzung ist natürlich nur dann gut, wenn die Schlüsselwerte im Suchbereich einigermassen gleich verteilt sind. Im schlimmsten Fall ist diese Schätzung miserabel und die Interpolationssuche betrachtet jeden einzelnen Schlüssel. Überlegen Sie sich doch als Übungsaufgabe eine Eingabe, bei der diese
Man kann jedoch auch zeigen (was wir hier jedoch nicht tun), dass die Interpolationssuche im Mittel nur rund
Denksportaufgabe: Überlegen Sie sich wie Sie die Vorzüge der binären Suche und Interpolationssuche in einen einzigen Algorithmus kombinieren können.
Die binäre Suche und die Interpolationssuche setzen voraus, dass wir die Grösse der Schlüsselmenge kennen, bzw. dass wir nur endlich viele Schlüssel vor uns haben.
Für sehr grosse
exponential_search(A, k)
r := 1
while (r <= n and k > A[r]) do
r := 2*r
return binary_search(A[1,..,min(r,n)], k)
Die Schleife in Zeile 3 und 4 endet spätestens nach
Manchmal wollen wir aber auch explizit erlauben, dass
Überlegen Sie sich, wie Sie den Code der binären Suche anpassen müssen, um diesen Index zu finden, ohne die Schleifeninvariante und die Laufzeitanalyse zu verletzen.
Für den Rest der Zeit vor der Pause wollen wir uns nun noch überlegen, wie schnell wir in unsortierten Schlüsselmengen suchen können. Das naheliegende, simple Verfahren, das die Elemente der Reihe nach mit
linear_search(A, k)
for i := 1 to n
if (A[i] = k) then
return "Gefunden an Position (i)."
return "Nicht vorhanden."
Die lineare Suche ist offensichtlich korrekt und braucht im schlimmsten Fall
Erstes Argument: Ein einfaches Argument, dass wir in unsortierten Mengen mindestens
Dieses Argument ist aber nicht korrekt! Ein Algorithmus darf auch die Elemente von
Good judgement comes from experience, and experience comes from bad judgement. - Nasreddin
Verbessertes Argument: Sei a die Zahl der Vergleiche, die
Zu Beginn ist die Gruppenzahl gleich
Um nun nach
Passen wir die lineare Suche minimal an, können wir damit auch das minimale Element in unsortierten
min_search(A, k)
min = A[1]
for i := 1 to n
if (min > A[i]) then
min = A[i]
return min
Eine interessante Frage dazu: Wenn wir sowohl das Minimum als auch das Maximum bestimen wollen, so können wir das leicht in
Hier ein Tipp:
Wir vergleichen die Elemente zuerst paarweise untereinander in
In der zweiten Stunde betrachten wir die Suche nach Rang statt nach Wert. Zuvor stellten wir die Frage "Kommt der Schlüssel
Ein Beispiel: Bei einer Eingabe von
Nehmen Sie doch alle kurz Ihre Legi hervor. Jede/r von Ihnen hat eine eindeutige achtstellige Leginummer (z.B. 10-916-617). Wir sind nun an der Median-Leginummer interessiert, um in etwa herauszufinden wie lange der "durchschnittliche" Student dieser Vorlesung schon immatrikuliert ist. Überlegen Sie sich kurz, wie sie dazu (mit oder ohne Computer) vorgehen würden? Gehen Sie reihum, um alle Leginummern auf Papier zu sammeln? Was machen Sie danach?
In dieser Stunde werden wir nun vier Verfahren sehen (zwei einfache, zwei etwas komplexere), die uns diesen Median bestimmen können.
Wiederholt das Minimum entfernen: Ein erster Ansatz verwendet, was wir vor der Pause gemacht haben: Wir bestimmen das Minimum in
Das ist nicht sehr praktikabel. Wir fragen "Wer hat die kleinste Leginummer?" und schicken ihn/sie vor die Tür. Das wiederholen wir
Sortieren: Ein weiterer naheliegender Ansatz ist die Elemente aufsteigend zu sortieren und dann einfach das
Häufig sind wir mit
Die Grundidee ist folgende: Wir wählen ein Element
Für diesen Aufteilungsschritt gruppieren wir die Schlüssel so um, dass der Pivot zwischen den Elementen kleiner
Was macht ein gutes Pivot-Element aus? Ein gutes Pivot-Element soll die Schlüsselmenge möglichst gleichmässig aufteilen, sodass wir möglichst viele Elemente fortan ignorieren können. Wenn wir wiederholt gute Pivot-Elemente finden, dann reduziert sich die Schlüsselmenge rapide und wir haben schnell nur noch wenige Schlüssel vor uns.
Im schlimmsten Fall, schliesst ein Pivot aber immer nur ein einziges Element aus, z.B. wenn wir versehentlich immer wieder das Maximum als Pivot wählen aber eigentlich das Minimum suchen. Dann bräuchten wir
Ein guter Pivot hat die Eigenschaft, dass er einen linearen Anteil der Schlüssel auf beiden Seiten hat, also mindestens
wobei wir in der letzten Zeile den Grenzwert der geometrischen Reihe benutzten, also dass
Es bleibt also lediglich die Frage: Wie finden wir einen solchen guten Pivot in linearer Zeit?
Für die erste Idee namens Quickselect, die von Tony Hoare aus dem Jahr 1961 stammt, nehmen wir uns den Zufall zu Hilfe. Wir wählen einen zufälligen Schlüssel als Pivot und zählen aus, ob er mindestens
Was ist die Wahrscheinlichkeit dass wir einen guten Pivot wählen? Von
Wieviele Wiederholungen brauchen wir im Mittel bis wir so einen guten Pivot finden? Sei
Somit ist die erwartete Laufzeit, um einen guten Pivot zu finden
quickselect(A, i)
while (true) // Suche einen guten Pivot
wähle einen zufälligen Pivot p
// Bestimme den Rang von p
r := 0
for j = 1 to n do
if A[j] <= p then
r := r+1
// Liegt p in der Mitte?
if n/4 <= r <= 3*n/4 then
break
// Sind wir fertig?
if i = r then
return i
// Pivotieren mit p
teile die Elemente um p herum auf
if i < r then
quickselect(A[1,..,r], i)
else
quickselect(A[r+1,..,n], i-r)
Der einzige Wermutstropfen bei Quickselect ist die Einschränkung auf den Erwartungswert. Wir können also nicht garantieren, dass Quickselect in jedem Fall nur linear viele Schritte braucht. Wenn es dumm läuft und wir nie einen guten Pivot treffen, was extrem unwahrscheinlich aber möglich ist, dann terminiert Quickselect nicht einmal.
Das Ziel des letzten Algorithmus ist deshalb deterministisch einen guten Pivot finden, ohne dass die asymptotische Laufzeit darunter leidet.
In den 70er Jahren hatten die Forscher Blum, Floyd, Pratt, Rivest und Tarjan die entscheidende Idee dazu, die heute als "Median der Mediane nach Blum" bekannt ist.
blum(A, i)
if (n < 100)
sortiere A
return A[i]
bilde Fünfergruppen A[1,..,5], A[6,..,10], ..
A' := Menge der Mediane pro Gruppe
p := blum(A',⌊⌈n/5⌉/2⌋)
// pivotiere mit p
r := Rang von p in A
teile die Elemente um p herum auf
if i < r then
blum(A[1,..,r], i)
else
blum(A[r+1,..,n], i-r)
Funktioniert diese komische Doppelrekursion wirklich? Finden wir so einen guten Pivot und bleibt die Laufzeit linear?
Wie gut ist dieser Pivot? Wie viele Elemente sind sicher kleiner als
Mindestens
Genau gleich viele Elemente sind sicher grösser als
Als Rekursionsgleichung erhalten wir somit:
Was wir noch zeigen wollen: Diese Rekursionsgleichung hat eine lineare geschlossene Form, also
Beweis durch Induktion
Nun wählen wir
Wir haben also mit
Warum Fünfergruppen? Mit Gruppengrösse drei oder vier kommt bei der Rekursion etwas nicht strikt kleineres heraus. Die beiden Teilprobleme zusammengenommen sind nicht mehr kleiner als
Prof. Springman studierte und promovierte in Geotechnik an der Universität Cambridge Homepage. ↩︎
Quelle: CS 50 @ Harvard, Prof. David Malan ↩︎
Donald Knuth. The Art of Computer Programming, Volume 3, Section 6.2.1, Seite 419 ↩︎
Diese Blogpostserie gibt eine ausführliche Abhandlung über die gängigsten Fehler in Implementierungen der binären Suche [Link]. ↩︎
Den ersten Vergleich dieser binären Suche können wir uns noch einsparen, wenn wir statt mit
Tipp: Divide-and-Conquer mit Basisfall
Man kann auch zeigen, dass es nicht besser als in
Pivot: Französisch für "Angelpunkt" oder "Drehpunkt" ↩︎
Wir wenden es quasi als Blackbox an, ohne genau zu verstehen wie es funktioniert. ↩︎