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 A der Größe n gegeben. Es ist sortiert, es gilt also A[0]A[1]A[n1]. Lassen Sie sich von dem mathematischen Symbol nicht täuschen. Es muss sich bei den Elementen A[i] nicht um Zahlen handeln. Ganz generell kommen die Elemente aus einem Universum U, auf dem eine lineare Ordnung definiert ist. Ohne jetzt definieren zu wollen, was das genau bedeutet, sei nur dies gesagt: die Elemente lassen sich vergleichen. In englishVocabulary.txt beispielsweise haben wir Strings gegeben, die wir alphabetisch (Mathematiker würden sagen: lexikographisch) vergleichen können.

Des Weiteren haben wir einen Suchwert x gegeben. Ziel ist es, die Position i mit A[i]=x zu finden oder zu dem Schluss zu kommen, dass x im Array A nicht vorkommt. Hierfür führen wir zwei Indizes l und u, welche für lower bound und upper bound stehen. Für diese gilt immer (1)A[l]x<A[u] gilt. Wir wählen anfangs l=1 und u=n, wobei wir die Konvention im Hinterkopf haben, dass A[i]= für i<0 und A[i]= für in.

Konvention: 2.1.1 A[i]= für alle i<0 und A[i]= für alle in. Insbesondere also A[1]= und A[n]=.
Mit diesen "Randwerten" ist A immer noch sortiert; allerdings müssen wir bei der Implementierung aufpassen, diese Array-Position nicht anzufassen. Die binäre Suche bildet nun das (abgerundete) arithmetische Mittel zwischen l und u, nämlich m:=l+u2 und schaut nach, ob A[m] kleiner oder größer als x oder im Besten Fall gleich x ist. Wenn x<A[m] gilt, setzen wir u:=m; wenn A[m]x gilt, dann setzen wir l:=m; in jedem Fall gilt (1) auch für die neuen Schranken l und u. Wir wiederholen diesen Schritt, bis u=l+1 gilt. In diesem Falle gilt entweder A[l]=x und wir haben x gefunden, oder aber (2)A[l]<x<A[l+1] , woraus wir schlussfolgern können, dass x gar nicht in A vorkommt. In der Praxis würden wir natürlich sofort abbrechen, wenn A[m]=x gilt; für eine theoretische Worst-Case-Analyse spielt dies allerdings keine Rolle.

Algorithmus 2.1.2 (Binäre Suche, Pseudocode)

Gegeben ein sortiertes Array A[0..n1] und einen Key x.

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 j{1,0,,n1} zurück, für den A[j]x<A[j+1] gilt.

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 n zurückgibt, falls x größer ist als alle Elemente in dem sortieren Array. Dafür müssten wir den Pseudocode etwas modifizieren.

Extremfälle: Falls x kleiner ist als alle Elemente in A, so behauptet das Theorem, dass binarySearch den Wert 1 zurückgibt, weil ja per Konvention A[1]= gilt und somit A[1]=<x<A[0]. Falls x größer als alle Elemente in A ist, soll binarySearch den Wert n1 zurückgeben, da dann A[n1]<x<A[n]= gilt. Falls A ein leeres Array ist, also n=0 gilt, dann ist {1,,n1}={1} und es 1 ist die korrekte Antwort, weil =A[1]<x<A[0]=A[n]= ist. Falls x nicht im Array ist, gilt A[j]<x<A[j+1], was dann auch zertifiziert, dass x nicht im Array vorhanden ist.

Beweis des Theorems. Wir formulieren eine Schleifeninvariante; das ist eine Behauptung über den Zustand der Variablen im Code. Wir zeigen, dass die Schleifeninvariante nach i Schleifendurchläufen gilt; i=0 heißt dabei vor dem ersten Schleifendurchlauf; dann zeigen wir, dass aus ihrer Gültigkeit in Zeile 9 die Korrektheit folgt, also die Behauptung im Theorem. Um die Schleifeninvariante zu formulieren, definieren wir

(3)li:=der Wert von l, nachdem i Schleifendurchläufe durchlaufen worden sind(4)ui:=der Wert von u, nachdem i Schleifendurchläufe durchlaufen worden sind .

Es gilt also l0=1 und u0=n.

Schleifeninvariante. Es gelten li<ui und A[li]x<A[ui] , für jedes i, sofern die Schleife mindestens i mal durchlaufen wird.

Beweis. Wir verwenden Induktion über i. Im Basisfall haben wir i=0 und befinden wir uns vor dem ersten Schleifendurchlauf. Es gelten l0=1 und u0=n; somit gilt schon einmal l0<u0 (auch falls n=0!); da nach Konvention A[l0]= und A[u0]=+ gilt, gilt auch A[l0]x<A[u0]. Die Invariante gilt also für i=0.

Sei nun i0. Wir nehmen per Induktionshypothese an, dass die Invariante nach dem i-ten Durchlauf, also zu Beginn des (i+1)-ten gilt, und müssen zeigen, dass sie auch nach dem (i+1)-ten gelten wird. Wir können nach Induktionshypothese also davon ausgehen, dass

li<ui und A[li]x<A[ui] ,

gelten. Nun sei m:=li+ui2. Da es einen i-ten Durchlauf gibt, gilt uili2 und somit li<m<ui. Wie in Zeile 5 des Codes müssen wir zwei Fälle unterscheiden:

Fall 1: A[m]x. In diesem Fall gelten dann per Zeile 6 des Codes li+1=m und ui+1=ui und somit

(Annahme dieses Falles)A[li+1]x(weil ui+1=ui und Induktionshypothese)x<A[ui+1](weil li+1=m<ui.)li+1<ui+1

Die Invariante gilt also auch für i+1.

Fall 2: A[m]>x. In diesem Fall gelten dann per Zeile 8 des Codes li+1=li und ui+1=m und somit

(weil li+1=li und Induktionshypothese)A[li+1]x(Annahme dieses Falles)x<A[ui+1](weil li+1=m<ui.)li+1<ui+1

Per Induktion gilt die Behauptung für alle i0, sofern es überhaupt einen i-ten Schleifendurchlauf gibt.

Aus dem Beweis der Invariante folgt auch, dass ui+1li+1uili1; der Abstand schrumpft also in jedem Schritt um mindestens 1. Daher gibt es nur endlich viele Durchläufe, sagen wir t viele. Nach dem t-ten Durchlauf gelten:

(weil sonst die Schleife weiterlaufen würde)ut=lt+1(wegen der Invariante)A[lt]x<A[ut]

Für den return-Wert j=lt gilt also A[j]x<A[j+1], wie behauptet.

Theorem 2.1.4 (Laufzeit der binären Suche). Auf einem Array der Länge n tätigt binarySearch(array,x) maximal log(n+1) Schleifendurchläufe.

Der etwas sperrige Ausdruck log(n+1) hat eine ganz einfache Interpretation: es ist die Anzahl der Bits in der Binärdarstellung von n (wenn wir 0 als leeren String mit null Bits darstellen).

Beweis. Wir betrachten den Wert di:=uili1 und wie er sich im Verlauf der Ausführung verändert; die Werte li und ui sind wie oben in (3) und (4) im Beweis des vorherigen Theorems definiert.

Behauptung. Sei i0. Wenn es überhaupt einen (i+1)-ten Schleifendurchlauf gibt, dann gilt

di+1di2

Beweis. Betrachten wir wieder die if-Bedingung in Zeile 5 und die beiden Möglichkeiten. Wenn Zeile 6 aufgerufen wird, dann gilt

li+1=li+ui2ui+1=ui

und somit

(5)ui+1li+11=uili+ui21

Falls Zeile 8 aufgerufen wird gilt

li+1=liui+1=li+ui2

und somit

(6)ui+1li+11=li+ui2li1

Nun ist immer (6)(5), denn:

(6)=li+ui2li1li+ui2li1=uiui+li21uiui+li21=(5) .

Es genügt also zu zeigen, dass (5)=uili21 ist. Zeigen wir das. Wir unterscheiden zwei Fälle: ob ui+li (und ebenso uili) gerade oder ungerade ist.

Fall 0: ui+li ist gerade. Dann ist

(5)=uiui+li21=uiui+li21(da uili1 ungerade ist)=uili21=uili12

Fall 1: ui+li ist ungerade. Dann ist

(5)=uiui+li21=uiui+li121(da uili1 gerade ist)=uili121=uili12

In jedem Fall gilt also di+1=ui+1li+11(5)=uili12 und somit haben wir die Behauptung.

Rekapitulieren wir: der Wert di ist am Anfang, also für i=0, gleich n(1)1=n. In jedem Schritt halbiert und rundet er sich ab:

di+1di2 .

Wenn er nach t Schritten den Wert 0 erreicht, gilt dt=utlt1=0, also utlt=1, und die Schleife bricht ab. Die Zahl t der Schleifendurchläufe ist also gleich der Anzahl der Halbierung-und-Abrundungsschritte, die der Wert di durchläuft. Die Funktion f:xx2 hat eine ganz einfache Interpretation:

Beobachtung. Sei xN+ eine positive ganze Zahl. Dann erhalten wir f(x), indem wir x in Binärdarstellung schreiben und die rechteste Stelle löschen. In diesem Zusammenhang stellen wir die Zahl 0 als leeren String dar, also als Bitstring der Länge null.

Wie oft müssen wir, beginnend bei n, die rechteste Stelle löschen, bis wir eine leere Zahl (also die Null) erhalten? So oft, wie n Stellen hat. Wie viele Stellen hat die Zahl n in Binärdarstellung? Genau log2(n+1) viele (wobei 0 durch den leeren String repräsentiert wird). Wir folgern: auf einem Array der Länge n startet der Wert di bei d0=n und es gilt di+1f(di). Die Länge der Binärdarstellung von di schrumpft also in jedem Schleifendurchlauf um mindestens 1, und somit haben wir nach höchstens t=log2(n+1) Durchläufen dt=0 und somit das Ende erreicht.

Als Beispiel nehmen wir die Binärzahl (111001)2=(57)10. Wenn wir f wiederholt anwenden, erhalten wir

111001f11100f1110f111f11f1f0

was in Dezimalschreibweise der Halbier-und-Abrunde-Sequenz

5728147310

entspricht. Auf einem Array mit 57 Elementen tätigt binarySearch also maximal sechs Schleifendurchläufe.

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

1.2775002687703818×105 . Die Laufzeit beträgt also eine gute hunderttausendstel Sekunde.

Ü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:

intSqrt:NNnn

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: intSqrt(n) ist die größte ganze Zahl k mit k2n. Daher der folgende Algorithmus:

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 n und der Laufzeit von 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 n selbst. Das klingt jetzt gar nicht so schlecht. Nehmen wir aber als Beispiel einmal die Zahl n:=260. Offensichtlich ist n=230. Dies ist ungefähr eine Milliarde. Unser Algorithmus braucht also eine Milliarde Schleifendurchläufe. Auf einem schnellen Rechner und mit der C-Implementierung ginge das vermutlich in erträglicher Zeit über die Bühne. Ein zeitgenössisches Macbook (Stand Herbst 2023) mit einem Apple M2 Pro Prozessor hat 12 CPU-Kerne, von denen jeder bis zu 3,5 GHz hat. Seien wir unrealistisch optimitisch und nehmen an, ein Schleifendurchlauf von 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 123.5109= 42 Milliarden Durchläufe schafft.

In diesem (unrealistisch optimistischen) Szenario würde intSqrt(2**80) also 2804210931 Sekunden dauern. 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 n die Bitlänge

log2(n+1)

Anders herum gesagt: eine k-stellige Zahl n (in Bits) ist mindestens 2k1 und höchstens 2k1. Die Funktion intSqrt(n) benötigt also mindestens

2k1=2k12

Schleifendurchläufe. Somit ist die Laufzeit exponentiell in der Bitlänge k. Intuitiver ausgedrückt: wenn wir die Bitlänge um 2 Bits erhöhen, dann verdoppelt sich die Laufzeit.

Quadratwurzel mit binärer Suche

Wenn wir ein Array mit den Quadratzahlen A=[0,1,4,9,16,...,m2] gegeben haben, dann können wir für eine Eingabezahl 0nk2 per binärer Suche das größte k lokalisieren, für das A[k]n gilt. Da A[k]=k2, so gilt dann eben auch k=sqrtInt(n).

~/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 A nicht explizit konstruieren (das würde zu viel Zeit und Platz beanspruchen). Wir müssen also eine implizite binäre Suche durchführen, in welcher der Array-Zugriff

        if (data[lookup] <= x):

durch einen Funktionsaufruf ersetzen.

Übungsaufgabe 2.1.5

Schreiben Sie in Python eine Funktion intSqrt (n), die die Funktion nn berechnet. Ihre Funktion soll ohne Probleme die Quadratwurzel hundertstelliger Zahlen berechnen können.

Ü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

Übungsaufgabe 2.1.7

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 log2(n+1) Schritte benötigt.

  1. Formulieren Sie in Gedanken die binäre Suche als Spiel . Bob kennt das Array A. In jedem Schritt fragt Alice einen index i an und Bob antwortet mit A[i]. Alice will möglichst schnell den Wert x finden, und Bob will dies verzögern.
  2. Tun Sie so, als wäre Bob ein adaptiver Gegner. Das heißt, er kann sich das Array A Schritt für Schritt während des Spiels ausdenken. Er muss aber immer konsistent antworten, d.h. wenn Alice sowohl die Indizes i<j angefragt hat, müssen Bobs Antworten die Bedingung A[i]<A[j] erfüllen.
  3. Zeigen Sie, dass Sie den adaptiven Bob durch einen nicht-adaptiven Bob ersetzen können (also durch ein Array A, 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).