2.1 Binäre Suche
Abstraktion
Wir abstrahieren nun von unserem Beispiel mit den englischen Vokabeln und
versuchen, die Aufgabenstellung und das Prinzip der binären Suche
genauer zu verstehen. Wir haben ein Array
Des Weiteren haben wir einen Suchwert
Gegeben ein sortiertes Array
binarySearch(Array A, key x):
l := -1, u := len(A)
while u - l > 1:
m := (l+u)/2
if A[l] <= x then
l = m
else
u = m
return l
Um zu zeigen, dass der obige Code korrekt ist, müssen wir erst genau formulieren, was wir denn von der binären Suche erwarten:
Theorem 2.1.3 Die Funktion binarySearch(A,x)
gibt
den
eindeutigen Index
Hinweis: Ob die Aussage im Theorem das beschreibt, was Sie wollen, ist eventuell
Geschmackssache. Eine Variante wäre zum Beispiel, zu verlangen, dass der Algorithmus
Extremfälle: Falls binarySearch
den Wert binarySearch
den Wert
Beweis des Theorems.
Wir formulieren eine Schleifeninvariante; das ist eine Behauptung über den Zustand
der Variablen im Code. Wir zeigen, dass die Schleifeninvariante nach
Es gilt also
Schleifeninvariante. Es gelten
Beweis. Wir verwenden Induktion über
Sei nun
gelten. Nun sei
Fall 1:
Die Invariante gilt also auch für
Fall 2:
Per Induktion gilt die Behauptung für alle
Aus dem Beweis der Invariante folgt auch, dass
Für den return-Wert
Theorem 2.1.4 (Laufzeit der binären Suche).
Auf einem Array der Länge
Der etwas sperrige Ausdruck
Beweis.
Wir betrachten den Wert
Behauptung. Sei
Beweis. Betrachten wir wieder die if-Bedingung in Zeile 5 und die beiden Möglichkeiten. Wenn Zeile 6 aufgerufen wird, dann gilt
und somit
Falls Zeile 8 aufgerufen wird gilt
und somit
Nun ist immer
Es genügt also zu zeigen, dass
Fall 0:
Fall 1:
In jedem Fall gilt also
Rekapitulieren wir: der Wert
Wenn er nach
Beobachtung. Sei
Wie oft
müssen wir, beginnend bei
Als Beispiel nehmen wir die Binärzahl
was in Dezimalschreibweise der Halbier-und-Abrunde-Sequenz
entspricht. Auf einem Array mit
Implementierung in Python
Schreiben wir nun eine kleine Funktion in Python, die binäre Suche implementiert.
def binarySearch (data, x):
lower = -1
upper = len(data)
while (upper - lower > 1):
lookup = (upper + lower) // 2
if (data[lookup] <= x):
lower = lookup
else:
upper = lookup
# end if
return lower
Python-Programme sind oft sehr kurz, weil Sie im Gegensatz zu Java keine großen Typendeklarationen machen müssen und Fehlerbehandlung gänzlich ausblenden können. Daher eignet sich Python wunderbar, um Algorithmen quick and dirty zu implementieren. Verwenden Sie aber Python bitte nicht, um die Steuerungssoftware eines Atomkraftwerks zu schreiben!
Übungsaufgabe 2.1.1
Kopieren Sie den Python-Code in eine Datei binarySearch.py
und speichern Sie die Datei in einem sinnvollen Verzeichnis, beispielsweise
home/AuK/code/binarySearch/
. Speichern Sie auch
englishVocabulary.py dort ab.
Fügen Sie die folgende Zeile an den Anfang der Datei binarySearch.py
ein:
from englishVocabulary import wordlist_sorted, wordlist_unsorted
Öffnen Sie eine Konsole und starten Sie das Python-Programm im Repl-Modus:
~/AuK/code/binarySearch $ python3 -i binarySearch
binarySearch(wordlist_sorted, "yak")
57885
wordlist_sorted[57885]
'yak'
Geben Sie per Hand ein sortiertes Array ein, also
beispielsweise data = [1,4,9,16,25]
und spielen damit rum.
Testen Sie Grenzfälle wie Suchwörter, die kleiner sind als alle Einträge oder solche, die größer sind als alle. Was geschieht bei leeren Arrays?
Übungsaufgabe 2.1.2
Schreiben Sie eine Funktion linearSearch
, die
das Array in linearer Suche durchgeht und dadurch auch auf
unsortierten Arrays funktioniert.
Testen Sie sie auf der unsortierten Wortliste
wordlist_unsorted
, die ebenfalls
englishVocabulary.py definiert
ist.
Laufzeit mesen
Mit Python können Sie recht einfach die Laufzeit Ihrer Funktionen messen.
Um das Ihnen noch leichter zu machen, habe ich ein
kleines Modul timeMyPrograms.py geschrieben.
Kopieren
Sie die Datei in Ihren Verzeichnis .../code/binarySearch
und fügen Sie oben
in binarySearch.py
die Code-Zeile
from timeMyPrograms import *
ein. Dann können Sie die Laufzeit wie folgt messen:
~/AuK/code/binarySearch $ python3 -i binarySearch
binarySearch(wordlist_sorted, "processor")
39136
measure_alg(binarySearch, wordlist_sorted, "processor")
1.2775002687703818e-05
Ganz allgemein können Sie mit
measure_alg(alg, p_1, p_2, ..., p_n)
die Laufzeit
des Aufrufs von alg(p_1, p_2, ..., p_n)
empirisch messen.
Beachten Sie, dass die Zahl 1.2775002687703818e-05
die Floating-Point-Schreibweise ist für
Übungsaufgabe 2.1.3
Messen Sie empirisch die Laufzeit Ihrer Implementierungen von
binarySearch
und linearSearch
. Um welchen
Faktor unterscheiden sie sich?
Implizite binäre Suche
Binäre Suche taucht auch oft auf, ohne dass explizit ein Array vorliegt. Beispielsweise, wenn wir die ganzzahlige Wurzel einer Zahl berechnen wollen:
Wir wollen hier explizit nicht auf
die bereits vorhandene Bibliotheksfunktion math.sqrt
zurückgreifen. Diese
arbeitet
insbesondere mit Floating-Point-Zahlen und liefert daher auch schnell falsche Ergebnisse:
~/AuK/code/binarySearch $ python3 -i binarySearch
def sqrtInt(n): return math.floor(math.sqrt(n))
sqrtInt(16):
4
n = 3**40
sqrt(n*n) - n
-33 # Ouch!
Wir müssen es also selbst implementieren. Denken wir nach:
def intSqrt(n):
k = 0
while (k*k <= n):
k = k+1
return k-1
Übungsaufgabe 2.1.4
Experimentieren Sie mit der Funktion intSqrt
. Wie weit kommen Sie
mit vertretbarer Laufzeit? Was ist der Zusammenhang zwischen
intSqrt
?
Was ist die Größe einer ganzen Zahl?
Die Laufzeit von intSqrt(n)
ist ungefähr proportional
zu der Anzahl der Schleifendurchläufe und somit zu
intSqrt
bräuchte
einen Prozessortakt, und darüberhinaus ist alles perfekt
parallelisierbar (also auf die 12 CPU-Kerne) verteilbar, so
dass das Macbook pro Sekunde
In diesem (unrealistisch optimistischen) Szenario würde
intSqrt(2**80)
also intSqrt(2**100)
bereits tausendmal so lange, also fast einen halben Tag.
Ist das gerechtfertigt? Ist 2**100
eine "große" Zahl? Fragen wir Python:
2**100
1267650600228229401496703205376
Das passt wunderbar auf eine Zeile. Gemessen an der Anzahl an Stellen, die wir
brauchen, um diese Zahl zu repräsentieren, ist sie recht klein.
Im Allgemeinen bemisst man die Größe einer Zahl (im Kontext von Algorithmen) als
ihre Bitlänge, also
die Anzahl der Bits in ihrer Binärdarstellung. Wie bereits weiter oben diskutiert hat die
Zahl
Anders herum gesagt: eine intSqrt(n)
benötigt also mindestens
Schleifendurchläufe. Somit ist die Laufzeit exponentiell in der
Bitlänge
Quadratwurzel mit binärer Suche
Wenn wir ein Array mit den Quadratzahlen
~/AuK/code/binarySearch $ python3 -i binarySearch.py
array = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
binarySearch(array, 48)
6
Wir können also mittels binärer Suche die Quadratwurzel berechnen.
Natürlich müssen (dürfen) wir das Array
if (data[lookup] <= x):
durch einen Funktionsaufruf ersetzen.
Schreiben Sie in Python eine Funktion
intSqrt (n)
, die die Funktion
Übungsaufgabe 2.1.6
Messen Sie die Laufzeit Ihres Algorithmus mit
measure_alg (intSqrt, 2**100)
, beispielsweise.
Legen Sie eine Tabelle an: wie entwickelt sich die empirisch gemessene Laufzeit mit der Bitlänge der Eingabezahl? Tip: Sie müssen schon sehr große Zahlen eingaben (tausendstellige, zehntausendstellige), um die Laufzeit zu "spüren".
Wenn Sie die Bitlänge verdoppeln, wie verändert sich die Laufzeit?
Untere Schranken
Ich habe Ihnen einen speziellen Algorithmus vorgestellt, der
das gegenwärtige Suchareal in jedem Schritt halbiert. Ist dies optimal?
Versuchen Sie zu beweisen, dass man im Schlimmsten Fall auch
-
Formulieren Sie in Gedanken die binäre Suche als Spiel . Bob kennt das Array
. In jedem Schritt fragt Alice einen index an und Bob antwortet mit . Alice will möglichst schnell den Wert finden, und Bob will dies verzögern. -
Tun Sie so, als wäre Bob ein adaptiver Gegner. Das heißt, er kann sich das
Array
Schritt für Schritt während des Spiels ausdenken. Er muss aber immer konsistent antworten, d.h. wenn Alice sowohl die Indizes angefragt hat, müssen Bobs Antworten die Bedingung erfüllen. -
Zeigen Sie, dass Sie den adaptiven Bob durch einen nicht-adaptiven Bob ersetzen
können
(also durch
ein Array
, dass von vornherein feststeht), so dass Alice dennoch nicht schneller zum Ziel kommt (hier ist es wichtig, dass Alice deterministisch ist, also keinen Zufall verwenden darf).