informatik grundkurs teil A skript

Werbung
skript
informatik grundkurs
teil A
Inhaltsverzeichnis
1 Rekursion
1.1 Die Fakultät einer Zahl . .
1.2 Die Fibonacci-Zahlen . . . .
1.3 Die Türme von Hanoi . . .
1.4 Von Hanoi zu Sierpinski . .
1.5 Varianten bei der Rekursion
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
3
3
5
10
15
17
2 Dynamische Datenstrukturen
2.1 Einfache Datentypen . . .
2.2 Arrays ( Felder ) . . . . .
2.3 Listen . . . . . . . . . . .
2.4 Schlange (Queue) . . . . .
2.5 Stapel (Stack) . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
20
20
20
24
24
24
. . . . . . .
. . . . . . .
. . . . . . .
Suchbaum
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
25
25
27
30
33
41
44
45
49
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
50
50
52
53
54
54
5 Suchverfahren
5.1 Sequentielle Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2 Binäre Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
56
56
.
.
.
.
.
3 Bäume
3.1 Ein wenig Graphentheorie . . . . . .
3.2 Besondere Graphen . . . . . . . . . .
3.3 Binäre Bäume . . . . . . . . . . . . .
3.4 Implementation der Struktur Binärer
3.5 Traversierungen . . . . . . . . . . . .
3.6 *Alternative Implementation . . . .
3.7 Die Höhe eines Baums . . . . . . . .
3.8 Huffmann-Bäume . . . . . . . . . . .
4 Sortierverfahren
4.1 Ein erstes Verfahren : Bubblesort . . . .
4.2 Selectionsort (Sortieren durch Auswahl)
4.3 Insertionsort (Sortieren durch Einfügen)
4.4 Ein Vergleich der bisherigen Verfahren .
4.5 Rekursive Verfahren . . . . . . . . . . .
.
.
.
.
.
2
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
1 Rekursion
“To understand recursion, you must first understand recursion.” ( Anonym )
1.1 Die Fakultät einer Zahl
Nehmen wir an, dass sich 4 Personen für ein Foto nebeneinander stellen möchten. Wie viele mögliche
Reihenfolgen und damit auch mögliche Fotos gibt es?
Wir vergeben Buchstaben ( A,B,C und D ) für die Personen und können dann anfangen alle Möglichkeiten aufzulisten :ABCD, ABDC, ..., DCBA
A
B
C
D
Abbildung 1.1: Vier Personen in einer Reihe
Auf diese Weise kommt man bei entsprechender Geduld und Sorgfalt zu 24 möglichen Anordnungen
der vier Personen. Erhöhen wir die Anzahl auf 6 Personen, so wird die Aufgabe aber deutlich schwieriger. Sicherlich sind es jetzt mehr mögliche Anordnungen aber das Hinschreiben durch Buchstaben
wird schon sehr mühselig.
Daher verallgemeinern wir die Frage : Wie viele mögliche Anordnungen gibt es bei n Personen?
Platzieren wir die erste Person für das Foto ganz links, so haben wir noch alle n Möglichkeiten. Bei
der anschließenden Person reduziert sich die Auswahl ( egal wen wir konkret gewählt haben ) auf n 1
Personen. Dies können wir weiter fortsetzen bis uns für die letzte Position ganz rechts nur noch eine
einzige Wahl bleibt.
Aus den einzelnen Zahlen kommen wir durch die Multiplikation n · (n 1) · (n 2) · · · 3 · 2 · 1 zur
Gesamtzahl der Anordnungen.
Bei 6 Personen hätten wir also schon 6 · 5 · 4 · 3 · 2 · 1 = 720 mögliche Anordnungen aufzuschreiben.
Diese Zahl wird der Einfachheit mit 6 ! abgekürzt und als Fakultät von 6 bezeichnet.
Definition. Für alle ganzzahligen n > 0 ist n! = n · (n
von n bezeichnet.
1) · (n
2) · · · 3 · 2 · 1 und wird als Fakultät
Die Fakultät zeigt ein sehr rasches Ansteigen und liefert bei kleinen Werten von n schon überraschend
große Zahlen. Wer hätte schon vorher gedacht, dass man bei nur 15 Leuten mehr als eine Billion
möglicher Aufstellungen für ein Foto vornehmen kann?
n
n!
1
1
2
2
3
6
4
24
5
120
6
720
7
5040
8
40320
10
3628800
15
1,308 Billionen
Aus Gründen, die eher im Bereich der Wahrscheinlichkeitsrechnung bzw. Kombinatorik liegen, erweitert man die bisherige Definition und ergänzt :
3
1 Rekursion
Definition. Für die Fakultät der Zahl Null gilt : 0! = 1
Die Berechnung der Fakultät erfolgt umgesetzt in Java als Methode mit Hilfe einer einfachen Schleife
auf folgende Weise :
public int Fakultät(int n){
int produkt = 1;
int i=1;
while ( i <= n){
produkt = produkt * i;
i++;
}
return produkt;
}
Diese Methode liefert auch für n = 0 den korrekten Wert, denn in diesem Fall wird die komplette
while-Schleife übersprungen ( die Bedingung i<=n schlägt fehl ) und es wird 1 als Wert zurückgegeben.
Die Fakultät - rekursiv programmiert
Eine interessante Variante der Programmierung ergibt sich, wenn man sich folgenden Sachverhalt
klarmacht.
n! = n · (n
1) · (n
2) · · · 3 · 2 · 1 = n · (n
1)!
bzw
F akultät(n) = n · F akultät(n
1)
Die Zeile macht klar, dass man eine bestimmte Fakultät dadurch ausrechnen kann, indem man sie
zurück auf eine kleinere Fakultät führt. Sollten wir z.B. in der obigen Tabelle noch den Wert von 11!
ergänzen wollen, so müssen wir eben nicht alle Faktoren von 11 bis 1 durchmultiplizieren, sondern
können einfach 11 · 10! verwenden und den gegebenen Wert von 10! nutzen. Dieses Zurückführen einer
Fakultätsberechnung auf eine kleinere, einfachere Fakultätsberechnung wird als Rekursion bezeichnet
und folgendermaßen in Java implementiert :
public int Fakultät(int n){
if (n==1)
return 1;
else
return ( n*Fakultät(n-1) );
}
Den genauen Weg zur Berechnung können wir durch ein Beispiel verdeutlichen. Angenommen, wir
wollen Fakultät(4) berechnen. Dann zeigt der Quelltext, dass der else-Teil diese Zahl nicht direkt
berechnet, sondern als 4 ⇤ Fakultät(3) zurückgibt. Da dies noch keine konkrete Zahl ist, muss erst
noch der Wert von Fakultät(3) berechnet werden. Somit kommt es zu folgendem Rechenweg :
Fakultät(4) = 4 ⇤ Fakultät(3) = 4 ⇤ (3 ⇤ Fakultät(2)) = 4 ⇤ (3 ⇤ (2 ⇤ Fakultät(1)))
= 4 ⇤ (3 ⇤ (2 ⇤ 1)) = 4 ⇤ (3 ⇤ 2) = 4 ⇤ 6 = 24
Das Programm ruft sich daher mit immer kleineren Werten von n auf, bis bei Fakultät(1) der
grundlegende Fall erreicht ist, der direkt angegeben werden kann.
Manchmal ist es übersichtlicher die einzelnen Aufrufe als Kästen untereinander darzustellen. Dadurch
wird klarer, dass es zunächst zu einem Rekursionsabstieg kommt ( d.h. Fakultät benötigt weitere Aufrufe von sich selbst um den geforderten Wert zu berechnen) bis ein Rekursionsanfang ( n = 1 ) erreicht
ist. Anschließend kann jetzt dieser konkrete Wert benutzt werden, um in einem Rekursionsaufstieg
wieder zum eigentlich geforderten Wert zu kommen.
4
Fakultät(4)
=4*Fakultät(3)
4*6=24
Rekursionsschritt
Fakultät(3)
=3*Fakultät(2)
3*2=6
Rekursionsschritt
Fakultät(2)
=2*Fakultät(1)
Rekursionsschritt
Fakultät(1)=1
2*1=2
Rekursionsaufstieg
Rekursionsabstieg
1 Rekursion
Rekursionsanfang
(auch Rek.anker )
Abbildung 1.2: Rekursion, Abstieg&Aufstieg
Warum übrigens bei unserer programmierten Methode ab n = 17 seltsame Rückgabewerte auftreten
( siehe Abbildung 1.3 ) wird im Anhang A erklärt.
Abbildung 1.3: Seltsame Werte bei großen Fakultäten
Zusammengefasst
Unter Rekursion versteht man ein Programm, das sich beim Ablauf selbst wieder aufruft. Dabei ist
es einerseits wichtig, dass bei jedem Aufruf das zu lösende Problem einfacher wird, und andererseits
muss es einen Rekursionsanfang geben, der eine direkte Berechnung ohne weiteren Aufruf erlaubt.
Programme, die statt einer Rekursion eine direkte Berechnung mit einer oder mehreren Schleifen
aufweisen, werden oft iterativ genannt.
1.2 Die Fibonacci-Zahlen
Züchter von Sonnenblumen erfreuen sich an den leuchtend gelben Blütenblätter und den zahlreichen
Samen in der Mitte der Blüte. Ein Blick auf die Anordnung der Samenkörner zeigt, dass diese nicht
zufällig im Inneren verteilt sind. Folgt man von einem Samenkorn zum nächsten gelangt man gebogenen
Spiralen, die je nach Blick mit oder gegen den Uhrzeigersinn laufen.
5
1 Rekursion
Abbildung 1.4: Spiralen bei Sonnenblumen
Als wäre das nicht seltsam genug kommt noch hinzu, dass die Anzahl der Spirallinien ( hier : 21 bzw.
34 ) zu besonderen Zahlen führt. Beide Zahlen sind immer in einer der bekanntesten Zahlenfolgen der
Mathematik enthalten, der Fibonacci-Zahlenfolge1 . Diese Folge beginnt mit zwei Einsen. Alle weiteren
Zahlen der Folge sind immer die Summe der beiden vorherigen Fibonacci-Zahlen. Die Folge lautet
demnach :
1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, . . .
Um eine einzelne Zahl in der Folge genau zu benennen, verwendet man die Abkürzungen F1 , F2 , F3 , . . . ,
d.h. es ist
F1 = 1, F2 = 1, F3 = 2, F4 = 3, F5 = 5, F6 = 8, . . .
Definition. Unter der Fibonacci-Folge (Fn ) versteht man die Folge der Zahlen, die festlegt ist durch
:
1. F1 = F2 = 1
2. Fn = Fn 1 + Fn 2 für alle n 2 N>2
Die Fibonacci-Folge weist also schon in ihrer mathematischen Definition eine Rekursion auf und kann
daher von uns sehr leicht mit rekursiver Programmierung umgesetzt werden. Hier ist eine naheliegende
Möglichkeit :
// Fibonacci-Zahlen rekursiv berechnen
public int fibo(int n){
if (n<=2)
return 1;
else
return ( fibo(n-1) + fibo(n-2) );
}
Soll die Methode fibo(int n) nicht rekursiv programmiert werden, so verwenden wir eine Schleife,
die mit den beiden Startzahlen F1 und F2 anfängt und sich durch Reihe der Fibonacci-Zahlen addiert
bis die gewünschte Stelle Fn erreicht ist.
// Fibonacci-Zahlen iterativ berechnen
public int fibo(int n){
if (n<=2)
return 1;
else{
int a=1;
int b=1;
1
Sie ist auch die einzige Zahlenfolge zu der seit 1963 viermal im Jahr das Magazin “Fibonacci Quaterly” mit neuesten
Forschungsergebnissen erscheint.
6
1 Rekursion
for (int i=1; i<n; i++){
int c=a+b;
// nächste Fibozahl
a=b;
// und weiterrücken
b=c;
}
return a;
}
}
Berechnen wir mit beiden Methoden beliebige Fibonacci-Zahlen, so zeigt sich z.B. bei der Berechnung von fibo(45) ein recht seltsamer Effekt. Während die iterative Methode direkt das Ergebnis
liefert, müssen wir bei der rekursiv programmierten Methode mehrere Sekunden warten2 . Klar wird
das, wenn wir beispielhaft uns ansehen, wie die rekursive Methode vorgeht, um fibo(5) auszurechnen.
Wie bei der vorher behandelten Fakultät ruft die Methode sich selbst wieder auf, d.h. es kommt zu
einem Rekursionsabstieg bis zum Rekursionsanfang. Hier kommt hinzu, dass wir zur Berechnung von
fibo(5) sowohl fibo(4) als auch fibo(3) benötigen. Wieder wird es übersichtlicher, wenn wir jeden
Methodenaufruf mit einem Kasten darstellen :
fibo(5)
=fibo(4) + fibo(3)
fibo(4)
=fibo(3) + fibo(2)
fibo(3)
=fibo(2) + fibo(1)
fibo(2)
=1
fibo(3)
=fibo(2) + fibo(1)
fibo(2)
=1
fibo(2)
=1
fibo(2)
=1
fibo(2)
=1
Abbildung 1.5: Rekursionsabstieg bei fibo
Der Aufruf von fibo(5) erzeugt also ( siehe blaue Pfeile ) 8 weitere Aufrufe der Methode fibo()
und führt damit zu insgesamt 9 Aufrufen. In der gleichen Abbildung lassen sich noch weitere Anzahlen
ablesen, die als Tabelle zusammengefasst werden :
n
Anzahl Aufrufe von fibo(n)
1
1
2
1
3
3
4
5
5
9
6
15
7
25
8
41
...
...
20
6766
...
...
45
1,1 Milliarden
Die Anzahl der nötigen Aufrufe wächst rasch an und bereits bei fibo(45) kommen wir auf über
eine Milliarde Rekursionsschritte, die ja anschließend noch von zahlreichen weiteren Schritten im
Rekursionsaufstieg gefolgt werden. Diese immense Zahl an Berechnungen zeigt, dass die rekursive
Methode zwar mit kurzem, gut lesbarem Code in Java implementiert ist aber in der Ausführung nicht
sonderlich effektiv ist.
Rekursive Aufrufe und der Stack
Wenn ein ablaufendes Hauptprogramm eine Methode aufruft, dann stellen wir uns das vereinfacht
so vor : Das eigentliche Hauptprogramm liegt im Speicher und wird Schritt für Schritt abgearbeitet. Kommt jetzt der Aufruf einer Methode, so wird das Hauptprogramm unterbrochen und an einer
2
Eine Möglichkeit die Wartezeit zu messen besteht in der Verwendung der Methode currentTimeMillis() aus der
System-Klasse. Diese Methode gibt die Zeit in Millisekunden seit dem 1. Januar 1970 zurück. Diese Angabe ist wenig
hilfreich aber wenn man die Zeit vor und hinter einer Ausführung zweimal speichert, kann man über die Differenz
berechnen wieviel Zeit dazwischen vergangen ist.
7
1 Rekursion
anderen Stelle im Speicher gesprungen. Allerdings muss sich der Computer vor dem Abarbeiten des
Unterprogramms merken, wo es danach wieder im Hauptprogramm weitergeht. Und genau diese Informationen sowie eventuell vorher übermittelte Parameterwerte werden in einem Extraspeicher namens
Stack ( dt. Stapelspeicher ) abgelegt.
Zwischenspeicher
(stack)
Speicher
01
Befehl 1
02
Befehl 2
03
…
04
unterprogramm
05
…
06
…
07
…
08
…
merken :
–Folgestelle 05
–evtl. Parameter für unterprogramm
Unterprogramm
…
…
zurück zur gemerkten Stelle 05,
…
Abbildung 1.6: Der Stack
Kommt es daher wie bei unserer bisherigen fibo-Implementation dazu, dass Milliardenfach die Methode sich selbst aufruft, so erfordert das viele Schreib- und Leseaktionen beim Stack. Möglicherweise
kann es auch dazu kommen, dass der Stack nicht groß genug ist, um alle Werte zu speichern. Dann
erhält man eine Fehlermeldung der Art “Stack Overflow” ( mit Glück ) oder das Programm stürzt
ins Nirvana ab. Die übersichtliche Programmierweise von rekursiven Methoden wird oft mit großem
Speichereinsatz erkauft und führt nicht immer zu schnell ablaufenden Programmen.
Die iterative Vorgehensweise ist im Fibonacci-Beispiel deutlich schneller, benötigt allerdings auch
noch eine Schleife, die sich letztlich durch alle Fibonacci-Zahlen bewegt, um fibo(n) zu berechnen.
Einen direkten Weg zu fibo(n) gibt es nur, wenn wir uns einige mathematische Eigenschaften der
Fibonacci-Zahlen ansehen.
Mathematische Betrachtungen zu den Fibonacci-Zahlen
Starten wir noch einmal mit einer Tabelle der ersten Fibonacci-Zahlen und ergänzen für weitere
Berechnungen auch noch einen passenden Wert für n = 0 :
n
Fn
0
0
1
1
2
1
3
2
4
3
5
5
6
8
7
13
8
21
9
34
10
55
11
89
12
144
Die Zahlen werden zunehmend größer und es wäre naheliegend ein exponentielles Ansteigen zu vermuten. Dazu müssten wir dann von Schritt zu Schritt immer den gleichen Wachstumsfaktor finden,
d.h. es müsste dazu Fn+1 = k · Fn sein. Daher ergänzen wir in der Tabelle die Quotienten Fn+1 /Fn
gerundet auf vier Nachkommastellen :
n
Fn+1
Fn
1
1
2
2
3
1, 5000
4
1, 6667
5
1, 6000
6
1, 6250
7
1, 6154
8
1, 6190
9
1, 6176
10
1, 6182
11
1, 6180
Die untersuchten Quotienten sind nicht konstant, d.h. es liegt keine einfache exponentielle Funktion
vor. Allerdings hat es den Anschein, als wenn die Quotienten bei wachsendem n sich immer mehr
der Zahl 1, 618 nähern und erst bei sehr großen Zahlen exponentiell wachsen. Was ist das für eine
geheimnisvolle Zahl?
Daher versuchen wir mit dem Ansatz Fn = a·bn die Fibonacci-Zahlen exponentiell darzustellen. Diesen
8
1 Rekursion
Ansatz setzen wir in die mathematische, rekursive Definition ein :
Fn = Fn
n
=) a · b = a · b
=) bn = bn
Die letzte Zeile wird durch bn
2
1+
n 1
1
Fn
2
+ a · bn
+ bn
2
2
dividiert
b2 = b + 1
und nach b aufgelöst :
b2
b
1=0
r
1
1
b= ±
2
4
r
1
5
b= ±
2
4
p
1+ 5
b=
⇡ 1, 618
2
_
b=
( 1)
p
1
2
5
⇡
0, 618
p
Die erste Lösung wird auch als goldener Schnitt
= 1+2 5 ⇡ 1, 6180339 . . . bezeichnet und ist
neben ⇡ und e eine der bekannteren Konstanten in der Mathematik. Der goldene Schnitt tritt in
vielen Problemstellungen der Mathematik auf, hat aber auch seine Berechtigung in der Kunst oder in
der Biologie.
Ignorieren wir kurz die zweite Lösung, so kommen wir zu Fn = a· n . Mit ein wenig mehr Mathematik
( an dieser Stelle ausgelassen ) lässt sich auch noch der Vorfaktor a bestimmen.
Satz. Für die Fibonacci-Zahlen gilt :
1
Fn ⇡ p
5
n
p !n
1+ 5
2
1
=p
5
bzw. näherungsweise :
1
Fn ⇡ p · 1, 618n
5
Die Fibonacci-Zahlen steigen daher annähernd exponentiell an.
Die nachfolgende Tabelle zeigt zweierlei :
1. Mit zunehmendem n kommen die Werte von
Fibonacci-Zahlen heran.
p1
5
n
immer näher an die wahren Werte der
2. Rundet man die Werte von p15 n auf die nächste, ganze Zahl, so sind die Werte bereits ab n = 1
die korrekten Fibonacci-Zahlen.
n
Fn
p1
5
n
1
1
0,724
2
1
1,171
3
2
1,894
4
3
3,065
5
5
4,960
6
8
8,025
...
...
...
10
55
55,004
15
610
609,9997
Implementieren wir das Vorgehen in Java, müssen wir zunächst aus die in Java vorhandene MathKlasse importieren. Diese bietet alles was wir mathematisch brauchen, d.h. sowohl die Wurzel ( engl.
: square root, kurz sqrt ) als auch die Potenz ( engl. power, kurz pow ) und das Runden ( engl. round
) stehen zur Verfügung :
9
1 Rekursion
import java.lang.Math;
public long fiboWurzel(long n){
// gold. Schnitt festlegen
double phi=(1+Math.sqrt(5))/2;
// phi hoch n berechnen
double f=Math.pow(phi, n);
// und auf ganze Zahl runden
return Math.round( f / Math.sqrt(5) );
}
Aufrufe bei der rekursiven Programmierung der Fibonacci-Zahlen
Vor dem kleinen mathematischen Ausflug und dem goldenen Schnitt hatten wir erkannt, dass der
rekursive Ablauf zum Berechnen der Fibonacci-Zahlen sehr aufwändig ist. Betrachten wir erneut die
oben behandelte Tabelle mit den Anzahlen der Aufrufe :
n
Anzahl Aufrufe von fibo(n)
1
1
2
1
3
3
4
5
5
9
6
15
7
25
8
41
...
...
20
6766
...
...
45
1,1 Milliarden
Wollten wir z.B. die Anzahl beim Aufrufen von fibo(9) ergänzen, so zählt alleine diese Zeile als erster Aufruf. Doch fibo(9) ruft ja seinerseits fibo(8) und fibo(7) auf und die dafür nötigen Anzahlen
kennen wir. Folglich kommen wir auf 1 + 25 + 41 = 67 nötige Aufrufe. Letztlich erfüllen also die Aufrufanzahlen cn eine ähnliche Rekursionsgleichung ( bis auf die zusätzliche +1 ) wie die Fibonaccizahlen
selbst.
1.3 Die Türme von Hanoi
Eines der klassischen Beispiele zur Rekursion stammt von einem Denkspiel namens “La tour d’Hanoi”,
das der Mathematiker Édouard Lucas 1883 unter dem Pseudonym Lucas d’Amiens3 herausbrachte.
Für das Spiel erfand er folgende Legende :
Im Großen Tempel von Benares in der Mitte der Welt ruht eine Messingplatte. Darin sind drei
Diamantnadeln befestigt, jede eine Elle hoch und so stark wie der Körper einer Biene. Bei der
Erschaffung der Welt hat der Gott Brahma vierundsechzig Scheiben aus purem Gold auf eine der
Nadeln gesteckt, wobei die größte Scheibe auf der Messingplatte ruht und die übrigen nach oben
kleiner werdend folgen. Das ist der heilige Turm des Brahma. Tag und Nacht sind einige Priester
damit beschäftigt, die Scheiben nach den von Brahma auferlegten Regeln auf die dritte Diamantnadel
zu bewegen. Wenn dies vollbracht ist, vergeht der Turm, die Priester und die Welt.
Die im Text erwähnten Regeln von Brahma sind sehr einfach :
1. Man darf immer nur eine Scheibe zur Zeit bewegen.
2. Niemals liegt eine größere Scheibe über einer kleineren.
3
Amiens war die Geburtsstadt von Lucas.
10
1 Rekursion
Abbildung 1.7: Türme von Hanoi ( allerdings nur mit 8 Scheiben )
Gehen wir nicht unbedingt von 64 Scheiben aus, sondern starten mit n Scheiben, so ist das Spiel
für n = 1 trivial, für n = 2 auch nicht viel schwerer und selbst bei n = 3 kommt man mit ein wenig
Probieren darauf, wie man es in 7 Zügen schaffen kann.
Start
1. Zug A -> C
4. Zug A -> C
2. Zug A -> B
5. Zug B -> A
6. Zug B -> C
3. Zug C -> B
7. Zug A -> C
Abbildung 1.8: Bewegungen der Scheiben bei n = 3
Bei vier, fünf oder acht Scheiben ist die Bewegung der Scheiben aber nicht mehr so offensichtlich
und es bleibt auch unklar, wie viele Züge man wohl benötigt.
Der rekursive Ansatz
Um beim Problem der Türme voranzukommen, lohnt es sich die Lösung rekursiv anzugehen, d.h. wir
führen einen komplizierten Fall zurück auf einfachere Fälle und beachten dabei, dass diese Rückführung
aber irgendwo einen Anfang haben muss.
Sind die n Scheiben gegeben, so besteht der Trick darin, die unterste Scheibe zunächst zu ignorieren.
Das ist problemlos möglich, da sie ja die größte ist und damit die Bewegung aller anderen Scheiben
in keinster Weise behindert. Alle darüber liegenden n 1 Scheiben können wir nach Belieben ( unter
Beachtung der üblichen Regeln ) bewegen. Damit eröffnet sich dann für uns eine mögliche Strategie.
Wir benennen die drei Stäbe mit A, B und C und gehen wie folgt vor :
Bewegung aller n Scheiben von A nach C :
1. Bewege die obersten n
1 Scheiben von A nach B.
2. Bewege die einzelne Scheibe von A nach C.
3. Bewege die n
1 Scheiben von B nach C.
11
1 Rekursion
Abbildung 1.9: Bewegung von n Scheiben in drei Phasen
Machen wir uns das Vorgehen erneut an einem konkreten Beispiel klar und bemühen dafür eine
militärische Ordnung:
Wir engagieren einen Gefreiten, dessen einziger Job es ist, einen Einerturm (n = 1) von einer
vorgegebenen Stange zu einer anderen gewünschten Stange zu transportieren. Für den Fall mit zwei
Scheiben, verwenden wir den nächsthöheren Dienstgrad, den Obergefreiten, dessen Job es insgesamt
ist, einen Zweierturm von einer gewünschten Stange zu einer anderen zu transportieren. Aber natürlich
kann schon dieser Obergefreite auf die Dienste des Gefreiten zurückgreifen :
◆
⇣
✓
⌘
◆
⇣
✓
⌘
Gefreiter, schleppen Sie eine Scheibe von A nach B.
Gefreiter, schleppen Sie eine Scheibe von A nach C.
Und jetzt schleppen Sie noch eine Scheibe von B nach C.
Das Prinzip lässt sich jetzt fortsetzen auf den nächsten Dienstgrad ( Hauptgefreiter, verantwortlich
für Dreiertürme ). Wieder delegiert er seine Arbeit an seine beiden Untergebenen :
Obergefreiter, transportieren Sie einen Zweierturm von A nach B.
Gefreiter, schleppen Sie mal eine Scheibe von A nach C.
Obergefreiter, jetzt transportieren Sie mal noch den Zweierturm von B nach C.
Abbildung 1.10: Befehlskette ( Rekursiv )
12
1 Rekursion
Implementation in Java
Wir verwenden eine Methode namens bewegeTurm(int n, char von, char nach, char hilfe) mit vier
Parametern. Diese Methode entspricht dem Bewegen von n Scheiben, die von einer Stange zu einer
anderen Stange mit Hilfe einer dritten Stange bewegt werden. Benennen wir die drei vorhandenen
Stangen mit A, B und C, so können wir durch den Aufruf bewegeTurm(3,0 A0 ,0 C0 ,0 B0 ) 3 Scheiben von A
nach C bringen ( und dabei B als Zwischenlager benutzen ). Die auf den ersten Blick seltsame Wahl
des Datentyps char hat den Vorteil, dass man den Namen des Turms als Buchstabe zur Verfügung hat
und leichter bei einzelnen Zugbewegungen ausgeben kann.
public void bewegeTurm(int n, char von, char nach, char hilfe)
{
if (anzahl==1) {
System.out.println("Zug : " + von + " -> " + nach );
}
else{
bewegeTurm(n-1, von, hilfe, nach);
bewegeTurm( 1 , von, nach, hilfe);
bewegeTurm(n-1, hilfe, nach, von);
}
}
Verfolgen wir diese Methode am Beispiel des Aufrufs bewegeTurm(3, �A�, �C�, �B�) grafisch. Dabei
gilt es zu beachten, dass im else-Teil die eigentliche Rekursion stattfindet und anders als in den
bisherigen Beispielen zu drei Aufrufen von bewegeTurm() führt. Der erste Aufruf führt dabei aber
erneut zu drei Aufrufen. Die Reihenfolge ist in der Abbildung mit den Ziffern 1,2 und 3 dargestellt.
von
nach
hilfe
bewege
Turm(3,’A’,’C’,’B’)
1
2
bewege
Turm(2,’A’,’B’,’C’)
1
2
bewege
Turm(1,’A’,’C’,’B’)
bewege
Turm(1,’A’,’B’,’C’)
3
bewege
Turm(1,’A’,’C’,’B’)
3
bewege
Turm(2,’B’,’C’,’A’)
1
bewege
Turm(1,’C’,’B’,’A’)
bewege
Turm(1,’B’,’A’,’C’)
2
3
bewege
Turm(1,’B’,’C’,’A’)
bewege
Turm(1,’A’,’C’,’B’)
Abbildung 1.11: Rekursion im Detail
Arbeitet man sich entlang der Ziffern durch die Grafik und berücksichtigt, dass nur bei einer Einturmbewegung eine Ausgabe des Zugs erfolgt, so kommen wir zu folgender Zugreihenfolge :
Zug : A
> C, Zug : A
> B, Zug : C
> B, Zug : A
Zug : B
> A, Zug : B
> C, Zug : A
> C
> C
Diese Liste entspricht genau der Zugfolge in Abbildung 1.8. Allerdings ist jetzt klar, dass wir dann
für einen Viererturm genau 15 Züge benötigen ( 7 Züge für einen Dreierturm, 1 Zug unterste Scheibe
und erneut 7 Züge für den Dreierturm ). Nennen wir Hn die Anzahl der nötigen Züge um einen Turm
aus n Scheiben zu bewegen, so ergibt sich :
13
1 Rekursion
n
Anzahl Hn
1
1
2
3
3
7
4
15
Mathematisch liegt hier also eine Folge Hn vor, für die gilt :
• H1 = 1
• Hn = 2Hn
1
+ 1 für alle n > 2.
Damit lassen sich Schritt für Schritt alle weiteren Werte H5 , H6, . . . berechnen aber wenn wir auf
die 64 legendären Scheiben im Tempel von Benares zurückommen, so dauert es doch zu lange sich
dermaßen durch die Folge zu hangeln. Daher wenden wir einen mathematischen Kniff ( Substitution)
an und definieren eine neue Folge :
Gn = Hn + 1
Damit können wir Hn bzw. Hn
1
bzw. Hn = Gn
1
ersetzen :
Hn = 2Hn
Gn
1
1 = 2 (Gn
Gn = 2Gn
+1
1
1) + 1
1
Bei der Folge Gn ergibt sich also jede weitere Zahl einfach als das Doppelte der vorherigen Zahl. Womit
startet diese Folge?
G1 = H1 + 1 = 1 + 1 = 2
Dann ist aber klar, dass Gn die Folge der Zweierpotenzen 2, 4, 8, 16, 32, . . . ist, d.h. Gn = 2n und
damit haben wir auch eine Formel für Hn gefunden :
Hn = 2n
1 für alle n > 1
Somit würden die Mönche im Tempel von Benares 264 1 = 1, 84 · 1018 Züge brauchen, bis die
Scheiben umgesetzt wären. Sollten sie so flink sein und pro Sekunde eine Scheibe bewegen können, dann
benötigen sie also mindestens 1, 84 · 1018 s = 5, 85 · 1011 a. Diese Zeitspanne von 585 Milliarden Jahren
kann uns beruhigt schlafen lassen, denn das gesamte Universum ist nach heutigem Kenntnisstand nur
14 Milliarden Jahre alt!
Hanoi - Iterativ
Die Liste der nötigen Züge um die Scheiben beim Hanoispiel zu bewegen, ergibt sich bei rekursiver
Programmierung automatisch. Wie aber erhält man die Zugliste, wenn man auf eine rekursive Vorgehensweise verzichten möchte. Gibt es eine einfache Schleife, die man abarbeiten kann und die dann
unsere Liste ergibt?
Erst im Jahr 1980 ( also fast 100 Jahre nach Lucas ) fanden Bunemann und Levy eine sehr simple
Vorgehensweise ohne Rekursion, die auch das Verschieben des Turms bewirkt. Dazu platzieren wir die
drei Stangen A,B und C in einer Art Kreis und gehen von einer voll besetzten Stange A aus.
B
C
A
Abbildung 1.12: Stangen in Kreisanordnung
Jetzt lassen wir folgende Anweisungen ablaufen :
14
1 Rekursion
1. Verschiebe die kleinste Scheibe S_min eine Stange weiter
im Uhrzeigersinn.
2. Falls jetzt eine von S_min verschiedene Scheibe sich bewegen kann,
so verschiebe sie.
Wiederholt man ständig diese zwei Anweisungen, so ergibt sich die gleiche Zugfolge wie im rekursiven
Fall. Auf einen Beweis, dass dieses Verfahren für alle Scheibenanzahlen n korrekt funktioniert, soll an
dieser Stelle verzichtet werden.
1.4 Von Hanoi zu Sierpinski
Nehmen wir an, dass beim Hanoi-Spiel eine gewisse Zahl an Scheiben schon in mehreren Zügen entsprechend den Regeln bewegt wurden und wir mittendrin im Spiel stoppen müssen, da uns die Zeit
ausgeht. Wird das Spiel samt Scheiben aber wieder in die Packung gelegt, so ist der aktuelle Zustand
verloren gegangen. Wie erhalten wir unseren erreichten Zustand der Scheiben für die Nachwelt?
Definition. Die drei Stangen beim Hanoi-Turmspiel bezeichnen wir mit A, B und C. Liegt eine Verteilung der vorhandenen Scheiben auf die drei Stangen vor, so sprechen wir von einem Zustand. Jeder
mögliche Zustand lässt sich dadurch angeben, dass wir von der größten zur kleinsten Scheibe immer
den zugehörigen Buchstaben angeben. Dadurch können wir dann jeden Zustand ( = Scheibenverteilung
) als eine Buchstabenfolge angeben.
Beispiel. a) Bei drei Scheiben beschreibt die Folge AAA die Ausgangssituation mit allen Scheiben
auf Stange A.
b) Bei drei Scheiben beschreibt die Folge BAC den Fall mit der größten Scheibe auf B , der mittleren
Scheibe auf A und der kleinsten Scheibe auf C.
c) Die Folge AAABB gehört zu fünf Scheiben, von denen die drei größten auf A und die zwei
kleinsten auf B stehen.
Bewegen wir jetzt wie üblich eine der Scheiben, so verändert sich auch unsere Buchstabenfolge auch
genau um einen Buchstaben.
Interessant wird es jetzt dadurch, wenn wir jede mögliche Verteilung ( d.h. jede mögliche Buchstabenfolge ) als einen Punkt markieren und zwischen zwei Punkten eine Verbindungsstrecke einzeichnen,
wenn wir durch einen erlaubten Zug von einer Verteilung zu einer anderen Verteilung kommen können. Ein solches Gebilde aus einer Menge an Punkten und einer Menge an Kanten zwischen jeweils
zwei Punkten heißt in der Mathematik Graph und ist der Schwerpunkt eines eigenen Teilgebiets der
Mathematik, der sogenannten Graphentheorie.
Für den Fall n = 2 ergibt sich folgendes Bild, in dem wir jeden möglichen Zustand sowohl als
Buchstabenfolge als auch als Verteilung der zwei Scheiben erkennen können :
BB
BC
BA
AC
AA
CA
AB
CB
CC
Abbildung 1.13: Darstellungen von Zuständen als Graph
15
1 Rekursion
Das Bild zeigt viele weitere Details :
• Es gibt neun mögliche Zustände. Da wir ja zwei Buchstaben haben und an jeder Stelle drei
Auswahlmöglichkeiten, kommen wir auch rechnerisch auf 3 · 3 = 9 auf diese Anzahl.
• In den Eckpunkten ( AA, BB, CC ) sehen wir die drei Zustände, in denen die Scheiben vollständig
an einer Stelle liegen.
• Von AA aus gelangen wir in drei Schritten zu CC, d.h. hier erkennen wir erneut, dass wir in
drei Schritten einen Zweierturm von A nach C bewegen können.
• Von jedem Zustand ( d.h. egal wo wir starten ) gelangen wir in höchstens drei Schritten zu jedem
anderen Zustand.
Verallgemeinern wir die Abbildung, so können wir uns vorab überlegen, dass es bei n Scheiben zu
3n möglichen Zuständen kommt und wir von jeder der Ecken ( n-mal gleicher Buchstabe ) in 2n 1
Schritten zu einer anderen Ecke kommen.
BBB
AAA
CCC
Abbildung 1.14: Graph der Zustände für n = 3
Die genauen Zwischenschritte sind dabei teilweise mühsam zu konstruieren aber nach ein wenig
Draufschauen erkennt man wieder den rekursiven Charakter des Hanoispiels. Die große Figur setzt
sich aus drei Teilgraphen wie im Fall n = 2 zusammen ( in drei Farben markiert ). Die blauen Kanten
stellen dann die entsprechenden Übergänge dar bei denen sich die unterste Scheibe bewegt hat bzw.
sich der vorderste Buchstabe ändert. Wieder erkennt man die Mindestzahl von 7 Zügen, um von einer
Ecke zu einer anderen zu kommen und wie gehabt gilt auch, dass man in 7 Zügen von einem Zustand
zu einem beliebigen anderen Zustand kommt.
Das Sierpinski-Dreieck
Der polnische Mathematiker Wacław Sierpiński beschrieb unabhängig vom Problem des Hanoiturms
bereits eine geometrische Form, in der sich innerhalb von Dreiecken wieder Dreiecke finden. Zur Konstruktion des sogenannten Sierpinski-Dreiecks, einer bestimmten Menge an Punkten, geht man folgendermaßen vor :
16
1 Rekursion
1. Beginne mit allen Punkten in einem gleichseiten Dreieck.
2. Verbinde die Seitenmitten der drei Seiten miteinander. Dadurch wird das Dreieck in vier kongruente Teildreiecke zerlegt.
3. Entferne das mittlere Dreieck und wiederhole mit den anderen drei verbleibenden Dreiecken die
Teilung wie in Schritt 2.
Führt man das Aufteilen und Entfernen unendlich oft fort ( zumindest in Gedanken ), so nennt man die
Menge aller Punkte, die dann noch bleiben das Sierpinski-Dreieck. Eine Bildfolge wie in der folgenden
Abbildung kann naturgemäß nur den Anfang der Konstruktion zeigen.
Abbildung 1.15: Erste Schritte beim Sierpinski Dreieck
1.5 Varianten bei der Rekursion
Zum Abschluss des Themas Rekursion streifen wir kurz einige Sonderfälle, die sich bei rekursiver
Programmierung ergeben können.
Indirekte Rekursion
Betrachte den folgenden Quelltext, der zwei Methoden implementiert :
public void m1(int n){
if (n==0) {
System.out.println("Uff.Fertig.");
}
else{
m2(n);
}
}
public void m2(int n){
if (n>0) {
m1(n-1);
17
1 Rekursion
}
else{
if (n<0){
m1(n+1);
}
}
}
Eine Betrachtung der beiden Methoden zeigt, dass die Methode m1 bei einem Parameter n6=0 die
Methode m2 aufruft. Diese ruft laut Quelltext aber wieder die Methode m1 auf. So gesehen kommt es
immer abwechselnd zu einem gegenseitigen Aufrufen der beiden Methoden bis bei der ersten Methode
n der Wert Null erreicht. Anders als bisher ist es nicht mehr eine Methode, die sich selbst erneut
aufruft, sondern es kommt zu einem Hin und Her verschiedener Methoden.
Definition. Rufen zwei ( oder mehr ) Methoden sich gegenseitig auf, so spricht man von einer gegenseitigen Rekursion.
Der Name ergibt sich aus einer Art äußeren Sicht, die die verschiedenen Aufrufe wie eine Art PingPong betrachtet. Die folgende Abbildung zeigt dazu den Aufruf von m1(3), der zahlreiche weitere
Ausrufe nach sich zieht, bis durch den Aufruf von m1(0) der Rekursionsanfang erreicht ist.
m1(3)
m2(3)
m1(2)
m2(2)
m1(1)
m2(1)
m1(0)
Ausgabe : Uff.Fertig.
Abbildung 1.16: Gegenseitige Rekursion
Beachte : Für das gegenseitige, rekursive Aufrufen gibt es eine weitere Bezeichnung.
Definition. a) Eine Methode, die sich bei ihrem Ablauf selbst wieder aufruft, heißt direkt rekursiv.
b) Zwei Methoden, die beide in ihrem Ablauf die jeweils andere Methode aufrufen, heißen indirekt
rekursiv.
Ob man also von einer gegenseitigen Rekursion zweier Methoden spricht ( und damit beide gleichzeitig meint ) oder zwei einzelne, indirekt rekursive Methoden meint, ist nur Geschmackssache.
Endrekursion
Einer der großen Nachteile bei unseren bisherigen Rekursionsbeispielen war der enorme Speicher- und
Zeitaufwand, den diese Art der Programmierung mit sich bringt. Oft war die iterative Programmierung mit Schleifen schneller4 . Genauer analysiert war aber nicht der rekursive Aufruf an sich das
Problem sondern der mühsame Rekursionsaufstieg, bei dem dann aus einem nach vielen Schritten
gefundenen Rekursionsanfang das Ergebnis berechnet wird. So wurde z.B. bei der rekursiven Berechnung der Fibonaccizahlen so lange wieder die Methode fibo aufgerufen, bis wir bei fibo(2) = 1 bzw.
bei fibo(1) = 1 waren. Im Grunde werden im Rekursionaufstieg lauter entsprechende Einsen addiert,
was nicht nach einer effektiven Vorgehensweise klingt.
Doch es gibt eine Abhilfe. Betrachten wir dazu einen weiteren Quelltext, der sich der Berechnung der
Fakultät widmet ( vgl. Abschnitt 1.1).
4
vgl. etwa den großen Zeitaufwand für die rekursive Berechnung der Fibonacci-Zahlen
18
1 Rekursion
public int fakultät(int n, int prod){
if (n<=1) {
return prod;
}
else{
return fakultät(n-1, n*prod);
}
}
Diese Methode verwendet - auf den ersten Blick irritierenderweise - zwei Parameter. Wollen wir
z.B. 5! berechnen lassen, so erhalten wir die Zahl mit dem Aufruf fakultät(5, 1). Der Trick besteht
darin, dass hier zwar wieder eine rekursive Programmierung vorliegt, diese aber vor dem nächsten
Aufruf schon beim zweiten Parameter eine Multiplikation durchgeführt hat. Die Folge der Aufrufe
verdeutlicht dies :
fakultät(5,1)
fakultät(4,5)
fakultät(3,20)
fakultät(2,60)
fakultät(1,120)
Abbildung 1.17: Folge der Aufrufe
Ist der Rekursionsanfang bei n = 1 erreicht, so ist kein weiterer Aufstieg mehr nötig und der Inhalt
von prod kann als korrekte Fakultät zurückgegeben werden. Eine derartige Vorgehensweise bei der
Rekursion wird als Endrekursion bezeichnet.
Definition. Eine Methode heißt endrekursiv, wenn der letzte Aufruf der eigenen Methode direkt den
Rückgabewert liefert. Eine nachfolgende Berechnung per Rekursionsaufstieg entfällt dann.
Gute Compiler können eine endrekursive Programmierung vorab erkennen und dann das Programm
so übersetzen, dass direkt eine Schleife verwendet wird. Dadurch wird der Speicher des Stacks geschont
und die Rechenzeit verkürzt. Äußerlich erkennt man eine endrekursive Programmierung fast immer
daran, dass im else-Teil nur ein Aufruf der eigenen Methode steht und nicht noch mit dem Ergebnis
gerechnet wird.
Betrachten wir zum Abschluss die Programmierung der Fibonacci-Zahlen als endrekursive Methode,
die auch bei großen Zahlen nicht in die Knie geht. Die Methode verwendet drei Parameter, einmal
n um anzugeben um welche Fibonaccizahl es geht und zum anderen v1, v2 mit den Startwerten 1.
Die 10. Fibonaccizahl erhalten wir durch den Aufruf fibo(10, 1, 1). Zum besseren Verständnis wird
empfohlen sich den Rekursionsabstieg aufzuzeichnen.
public int fibo(int n, int v1, int v2)
{
if (n==1) {
return v2;
}
else{
return fibo(n-1, v1+v2, v1);
}
}
19
2 Dynamische Datenstrukturen
“Smart data structures and dumb code works a lot better than the other way around.”
(Eric Raymond)
2.1 Einfache Datentypen
Verwenden wir in einem Programm eine Variable, um dort z.B. eine Zahl zu speichern, so müssen
wir uns um wenig kümmern. Wir geben den gewünschten Datentyp an ( z.B. Integer ), legen einen
Namen für die Variable fest ( z.B. xPosition ) und können dann dieser Variablen nach Belieben Werte
zuweisen.
Im Normalfall wird für eine Variable ein Platz im vorhandenen Speicher des Computers belegt. Bei den
einfachen Datentypen in Java ( auch primitive Datentypen genannt ) belegt eine Variable im Speicher
immer die gleiche Anzahl an Bytes. So benötigt eine deklarierte Integervariable wie int x = 5; immer
4 Byte=32 Bit im Speicher. Mit den 32 Bit, die an jeder Stelle ja nur eine Eins oder eine Null
besitzen, lassen sich 232 verschiedene Zahlen darstellen. Die Abbildung zeigt die Speicherbelegung für
verschiedene primitive Datentypen :
byte
short
int
long
8
16
32
64
Bit
Bit
Bit
Bit
1 Byte
=8 Bit
float 32 Bit
double 64 Bit
boolean 1 Bit
char
16 Bit
Abbildung 2.1: Primitive Datentypen
Der für einen Datentyp immer gleiche, vorgesehe Speicherplatz hat auch zur Folge, dass man nicht
beliebig große Werte in einer Variablen speichern kann. So belegt der sparsame Datentyp byte eben
nur 1 Byte=8 Bit an Speicherplatz, kann aber auch nur 28 = 256 verschiedene Zahlen darstellen. Mit
Vorzeichen sind das gerade die Zahlen 128, 127, . . . , 0, 1, 2, . . . , 127.
2.2 Arrays ( Felder )
Angenommen, wir möchten für eine Wetterstation in jeder Minute eines Tages die Temperatur in °C
messen, auf eine ganze Zahl runden und speichern. Dann sind das in einer Stunde bereits 60 Messwerte
und im Laufe eines Tags 1440 Temperaturwerte.
20
2 Dynamische Datenstrukturen
Bei einer so großen Zahl an Werten bietet es sich an, dass wir ein Array verwenden, d.h. eine
Datenstruktur, die unter einem gewählten Bezeichner Zugriff auf die einzelnen 1440 Werte erlaubt.
int[] temperatur = new int[1440];
Durch diese Deklaration wurde unter dem Namen temperatur ein Array der Länge 1440 angelegt,
d.h. es existieren 1440 einzelne Integerwerte. Vom Speicherplatz her leuchtet ein, dass ein solches
Array 1440 mal den Platz einer Integervariable benötigt, d.h. die obige Deklaration sorgt dafür, dass
wir 1440 · 4 = 5760 Byte an Speicher brauchen.
Den Unterschied zwischen einer einzelnen Integervariable und einem Array vom Typ Integer erkennt
man auch in BlueJ. Dazu betrachten wir einen Ausschnitt einer Klasse namens ArrayTest :
public class ArrayTest
{
private int test=20;
private int[] temperatur=new int[1440];
// Konstruktor weggelassen
...
}
Erzeugen wir ein Objekt a dieser Klasse, so finden wir unsere festgelegten Attribute test und
temperatur[] auch im Objektinspektor von BlueJ wieder. Allerdings verwendet BlueJ beim Darstellen unterschiedliche Arten. Die Variable test zeigt wie zu erwarten den Wert 20 aber beim Array
temperatur[] sehen wir keinen direkten Wert, sondern einen gekrümmten Pfeil. Dieser Pfeil ist die
grafische Darstellung von BlueJ für einen Verweis ( auch Zeiger oder Referenz genannt ) auf eine
größere Struktur im Speicher. Durch einen Doppelklick auf den Pfeil offenbart sich die zugehörige
Speicherbelegung.
Abbildung 2.2: Zeiger beim Array
Allgemein können wir also in Java zwischen primitiven Datentypen und Referenz-Datentypen unterscheiden. Die erste Art führt uns unter demNamen der Variablen direkt zum dort gespeicherten
Wert, während Referenzen eben nur auf eine Stelle im Speicher verweisen, an der dann die eigentlichen
Daten abgelegt sind.
Den Unterschied stellen wir in Zukunft folgendermaßen mit einer einfachen Zeichnung dar. Das
folgende Beispiel zeigt die Vorgehensweise.
1. Wir erzeugen eine Integervariable mit der Anweisung int x; . Dadurch wird ein bestimmter
Speicherplatz ( 4 Bytes, siehe 2.1 ) reserviert und ist unter der Bezeichnung xdirekt zu verwenden,
21
2 Dynamische Datenstrukturen
d.h. mit der Anweisung x = 8; können wir anschließend einen Wert in den Speicher schreiben.
In beiden Fällen verwenden wir ein Quadrat für den Speicherplatz.
int x;
x
x=8;
x
8
Abbildung 2.3: Darstellung einfacher Datentyp
2. Erstellen wir aber ein Array mit int[] a; , so wird auch in diesem Fall zunächst nur ein Speicherplatz reserviert, d.h. wieder können wir die Variable aauch als Quadrat darstellen. Die anschließende Zeile a = new int[4]; legt dann die Größe des Arrays fest, der nötige Speicher wird
reserviert und - das ist der entscheidende Unterschied - mit der Variablen a haben wir einen
Zeiger auf den reservierten Speicher1 .
int[] a;
a=new int[4];
a
a
0
0
0
0
Abbildung 2.4: Darstellung Zeiger/Referenz
Haben wir eigene Klassen ( oder aus der Library importierte Klassen ), und erstellen Objekte dieser
Klassen, so finden wir unter den verschiedenen Namen der Objekte auch immer nur Zeiger, die auf
die eigentlichen Daten verweisen. Im Normalfall bemerken wir wenig von diesen Zeigern aber bei
folgendem Programm entsteht ein seltsamer Effekt, der sich nur durch Zeiger erklären lässt :
a=new int[60];
b=new int[60];
for (int i=0; i<a.length; i++){
a[i]=1;
b[i]=2;
}
b=a;
b[2]=99;
In diesem kleinen Programmausschnitt werden zunächst zwei Arrays der Länge 60 deklariert. Anschließend wird das Array amit lauter Einsen und das Array b mit lauter Zweien gefüllt. Was aber
bewirken die beiden letzten Zeilen?
Einfach gedacht könnte man der Meinung, dass zunächst b die Werte von aübernimmt und anschließend das dritte Feld bei b auf den Wert 99 abgeändert wird.
Eine Ausgabe beider Arrays sollte also beim ersten Arrays nach wie vor alle Einsen und beim
Zweiten die Einsen mit abgeänderter 99 ergeben.
1
In Java werden bei Integer-Arrays automatisch alle Felder auf Null gesetzt.
22
2 Dynamische Datenstrukturen
vorher
nachher?
a
1
1
1
1
…
1
1
a
1
1
1
1
…
1
1
b
2
2
2
2
…
2
2
b
1
1
99
1
…
1
1
Abbildung 2.5: Falsche Idee
So weit die Idee aber die Realität schlägt uns ein Schnippchen, denn eine reale Durchführung führt
zu folgendem Ergebnis :
vorher
was wirklich passiert
a
1
1
1
1
…
1
1
a
1
1
99
1
…
1
1
b
2
2
2
2
…
2
2
b
1
1
99
1
…
1
1
Abbildung 2.6: Das passiert wirklich...
Überraschenderweise ändern sich auch die Werte im Array von a, obwohl wir hier gar keine Zuweisung vorliegen haben. Wie das?
Die Lösung ergibt sich, wenn wir daran denken, dass wir unter den Namen a und b nur einen Zeiger
auf die Daten ( hier die 60 Einsen bzw. Zweien vorliegen haben. ).
Eine Zeile wie b = a ändert also den Wert des Zeigers von b und gibt ihm den Wert des Zeigers von
a, d.h. nach dieser Zeile zeigen a und b beide auf die gleiche Stelle im Speicher. Ändern wir jetzt mit
b[2] = 99 den Wert, so greifen wir direkt auf die Daten des Arrays zu.
a
1
1
1
1
…
1
1
b
2
2
2
2
…
2
2
a
1
1
1
1
…
1
1
b
2
2
2
2
…
2
2
a
1
1
99
1
…
1
1
b[2]=99; b
2
2
2
2
…
2
2
b=a;
Abbildung 2.7: Erklärung mit Zeigern
Nebenbei bemerkt kann man sich durchaus die Frage stellen, was denn nun mit dem im Speicher
abgelegten Zweien passiert. Der Zugriff darauf ist durch das Abändern des Zeigers ja verloren gegangen,
die Daten sind unerreicht, es bleibt eine Art Datenmüll zurück. Beim Ablauf eines Java-Programms
wird aber im Hintergrund unbemerkt protokolliert, wenn ein Speicherbereich nicht mehr durch einen
Zeiger erreichbar ist und dann wird - allerdings oft mit einer unbekannten Zeitverzögerung - dieser
Speicher wieder freigegeben. Bei diesem Vorgang spricht man von einer Garbage Collection ( dt.
Müllsammlung ). Für uns ist nur wichtig, dass wir uns nicht mehr darum kümmern müssen, reservierten
Speicher manuell zu befreien.
23
2 Dynamische Datenstrukturen
2.3 Listen
2.4 Schlange (Queue)
2.5 Stapel (Stack)
24
3 Bäume
“Wer Bäume setzt, obwohl er weiß, dass er nie in ihrem Schatten sitzen wird, hat zumindest
angefangen, den Sinn des Lebens zu begreifen.” ( Rabindranath Tagore )
3.1 Ein wenig Graphentheorie
Die russische Stadt Kaliningrad liegt in einem Teil von Russland, der gerne übersehen wird. Begrenzt
von Polen und Litauen gibt es an der Ostsee zwischen Danzig und Kaunas eine russische Exklave.
Unter dem Namen Königsberg hat diese Stadt eine lang zurückreichende Geschichte. Mitten durch
Kaliningrad fließt die Pregel. Sie teilt den alten Stadtkern in mehrere Teile auf, die durch Brücken
miteinander verbunden sind. Auf der Karte von Google Maps lässt sich der Fluss und die Stadtteile (
hier einfach mit A bis D benannt ) gut erkennen :
Abbildung 3.1: Erster Blick auf Kaliningrad
Eine Frage für Leute, die sich gerne zu Fuß durch Städte bewegen, ist die Frage, ob man sich so
durch die Stadt bewegen kann, dass man über jede Brücke genau einmal geht. Noch schöner wäre es,
wenn man dann auch wieder an seinem Ausgangspunkt zurückkäme ( also eine Art Rundreise durch
Kaliningrad angetreten hat ).
Genau diese Frage wurde schon zu Beginn des 18. Jahrhunderts als “Königsberger Brückenproblem”
bekannt, auch wenn damals die Brücken über die Pregel noch anders verliefen. Gelöst wurde das Problem damals durch den Mathematiker Leonard Euler, der damit auch gleichzeitig ein neues Teilgebiet
der Mathematik, die sogenannte Graphentheorie erfand.
Sein Ansatz bestand darin, den Kern des Problems mathematisch vereinfacht darzustellen. Dazu
wurde jeder Stadtteil reduziert auf einen Punkt und jede Brücke zwischen Stadtteilen zu einer Verbindungslinie :
25
3 Bäume
A
B
D
C
Abbildung 3.2: Mathematische Vereinfachung von Königsberg
Man erkennt, dass z.B. die Route C ! B ! A ! D ! B einen Weg liefert, der jede Brücke wirklich
nur einmal benutzt. Eine Rundreise mit dem Ziel gleich dem Start ist hier nicht möglich.
Wählt man einen größeren Ausschnitt von Königsberg, finden sich weitere Brücken, so dass wir auch
weitere Verbindungen zwischen den Punkten ergänzen müssen. Wieder suchen wir nach einem Weg,
so dass wir jede Brücke verwenden aber auch jede nur ein einziges Mal und trotz viel Herumprobieren
will sich ein solcher Weg nicht finden lassen.
A
B
A
D
C
B
D
C
Abbildung 3.3: Eine weitere Brücke kommt hinzu
Erst wenn wir noch weiter nach Westen gehen, erscheint eine weitere Brücke über die Pregel und siehe da - auf einmal findet sich wieder ein Weg ( hier etwa : B ! A ! C ! D ! A ! D ! C !
B ! D ).
A
A
B
C
B
D
D
C
Abbildung 3.4: Eine weitere Brücke im Westen
Der Begriff des Graphen
Die Abstraktion in Königsberg führte zu einer Menge an Punkten ( = den Stadtteilen ), zwischen
denen Verbindungen (= den Brücken über die Pregel ) existieren. Lösen wir uns von diesem Kontext,
26
3 Bäume
so gelangen wir zu einem allgemeinen Begriff des Graphen:
Definition. Eine Menge an Punkten ( oft Knoten oder Ecken genannt ) und eine weitere Menge an
Kanten ( als Verbindungen von genau zwei Punkten ) heißt Graph.
Beachte dabei, dass es beim Begriff des Graph nicht um eine zeichnerische Darstellung geht ( auch
wenn wir natürlich oft entsprechende Bilder wie im obigen Beispiel anfertigen ), sondern nur darum, ob
zwischen zwei Knoten eine oder mehrere Verbindungen existieren oder nicht. Letztlich steht bei einem
Graph im Hintergrund eher die Frage “Wer ist mit wem verbunden?”. Die zeichnerische Darstellung
ist eher zweitranging. Insofern zeigt die folgende Abbildung den gleichen Graph nur unterschiedlich
dargestellt :
B
A
C
A
D
E
D
C
B
F
E
G
F
G
Abbildung 3.5: Der gleiche Graph auf zwei Arten
Ein anderes Beispiel für einen Graph ist die Darstellung von Bekanntschaften in sozialen Netzwerken.
Verwenden wir als Knoten einzelne Personen und als Kanten die Beziehung “...ist befreundet mit...”,
so zeigt ein Graph übersichtlich die Zusammenhänge auf :
maren
konstantin
caroline
robin
lukas
anna
saskia
Abbildung 3.6: Graph für soziale Netzwerke
3.2 Besondere Graphen
Aus der bunten Vielfalt aller möglichen Graphen benötigen wir in diesem Grundkurs nur solche,
die bestimmte Eigenschaften aufweisen. Zur Erläuterung dieser Eigenschaften führen wir einige neue
27
3 Bäume
Begriffe ein :
Definition. Wenn a, b zwei Knoten des Graphen sind, die über eine Folge von Kanten miteinander
verbunden sind, so nennt man diese Kantenfolge einen Weg ( auch Pfad oder Kantenzug genannt )
von a nach b .
Solche Wege zwischen zwei Knoten müssen keineswegs eindeutig sein. So zeigt das Beispiel in Abb.
3.6. einen Weg von lukas zu anna mit Hilfe einer Kante oder mit Hilfe von zwei Kanten über saskia .
Definition. Ein Graph heißt zusammenhängend, wenn es zwischen allen beliebig gewählten Knoten a, b einen Weg gibt. Von jedem beliebigen Startpunkt können wir in einem zusammenhängenden
Graphen jeden beliebigen anderen Punkt erreichen.
Das Beispiel in Abb. 3.6. zeigt einen zusammenhängenden Graphen. Entfernen wir aber die Kante
von lukas zu caroline , so ist er nicht mehr zusammenhängend. Weiterhin ist es aber möglich, dass
man ja auf verschiedenen Wegen von einer Kante zur anderen kommen kann. Dies beseitigt die folgende
Definition :
Definition. Ein Graph besitzt einen Kreis, wenn es einen Knoten a als Startpunkt eines Wegs gibt,
der über lauter verschiedene Kanten wieder zu a zurück führt.
Hat ein Graph keinen einzigen Kreis, so heißt er kreislos ( oder kreisfrei ).
Abermals kann uns Abbildung 3.6 helfen, die Definition zu verstehen. Die Folge lukas ! anna !
saskia ! lukas ist ein Kreis, ebenso wie robin ! caroline ! konstantin ! maren ! robin.
Entfernen wir bestimmte Kanten, so wird der Graph kreisfrei :
maren
konstantin
caroline
robin
lukas
anna
saskia
Abbildung 3.7: Umgewandelt in einen kreisfreien Graphen
Definition. Ein Graph, der sowohl zusammenhängend als auch kreisfrei ist, wird Baum genannt.
Die Graphen, die gleichzeitig zusammenhängend und kreisfrei sind, lassen sich auch so beschreiben,
dass man von jedem Punkt aus jeden anderen Punkt erreichen kann (!zusammenhängend ) und der
Verbindungsweg eindeutig (!kreisfrei ) ist.
Bäume sind demnach spezielle Arten von Graphen, denen man meist noch eine Wurzel zuweist.
Darunter versteht man einen beliebig wählbaren Knoten des Baums, der in der grafischen Darstellung
als oberster Knoten gezeichnet wird. Von der Wurzel folgen die Knoten, die direkt mit der Wurzel
verbunden sind und dann wieder deren weitere Verbindungen bis der Baum vollständig dargestellt ist.
28
3 Bäume
Allerdings ist zu beachten, dass die Definition von Baum schon vorsieht, dass man von jedem Knoten
aus jeden anderen Knoten auf einem einzigen Weg erreichen kann und demzufolge kann jeder Knoten
als Wurzel dienen.
A
B
C
G
D
F
J
H
E
I
G
F
C
A
B
G
D
E
H
F
H
J
C
I
J
A
B
I
D
E
Abbildung 3.8: Graph in Baumdarstellung mit verschiedenen Wurzeln
Baut man einen Baum von der Wurzel her auf, so liegt streng genommen ein gerichteter Baum
vor, d.h. man startet stets an der Wurzel und läuft üblicherweise jede Kante nur weg von der Wurzel
entlang. Nur durch diese Richtungsvorgabe ist es möglich auch von Nachfolgern im Baum zu sprechen.
In vielen zeichnerischen Darstellungen lässt man beim Zeichnen der Kanten aber die Richtungen weg.
Definition. Unter den Nachfolgern eines Knotens k im Baum verstehen wir alle Knoten, die wir
erreichen können, indem wir von k aus uns weiter von der Wurzel entfernen.
Weitere Begriffe, die es so nur bei Bäumen gibt, schließen sich an :
Definition. Es sei ein Baum mit einem der Knoten als Wurzel gegeben. Dann definieren wir :
a) Ein Blatt des Baums ist ein Knoten ohne Nachfolger.
b) Ein innerer Knoten ist ein Knoten ungleich der Wurzel, der mindestens noch einen Nachfolger
besitzt.
c) Alle von einem Knoten k erreichbaren Nachfolger werden auch Töchter von k genannt. Umgekehrt
spricht man auch davon, dass k der Vater der Töchter ist.
d) Zu jedem Knoten im Baum gibt es einen eindeutigen Weg von der Wurzel aus. Die Anzahl der
Kanten bezeichnet man als Länge des Wegs oder auch als Tiefe des Knotens.
Die Abbildung verdeutlicht noch einmal alle neuen Begriffe :
29
3 Bäume
Wurzel
ein Pfad
der Länge 3
ein innerer
Knoten
ein Blatt
Vater der drei Töchter
ein Knoten
der Tiefe 4
Drei Töchter von k
Abbildung 3.9: Begriffe zum Thema Baum
Anwendung von Bäumen
Die Struktur eines Baums ist immer dann eine geeignete Wahl, wenn die Informationen bzw. Daten
eine hierarchische Struktur aufweisen, d.h. gewisse Elemente sind anderen über- bzw. untergeordnet.
Solche Anordnung finden sich im realen Leben ( Militärische Struktur, Firmenstrukturen, . . . ) aber
auch im Computerbereich. So verwenden die meisten Betriebssysteme eine Darstellung der Inhalte
einer Festplatte, die der Baumstruktur entspricht. Ausgehend von der Wurzel ( z.B. C : ) finden sich
viele Unterordner bzw. darin enthaltene Dateien. Jeder Knoten als Nachfolger der Wurzel ist demnach
entweder eine Datei ohne weitere Nachfolger oder ein Ordner, der wieder weitere Unterordner und
Dateien als Nachfolger besitzen kann.
C:\
Fotos
Musik
Mathe
Klasse5
Abbildung 3.10: Baum für Dateiverzeichnisse
3.3 Binäre Bäume
Mitunter finden sich in Zeitungen oder Zeitschriften Entscheidungshelfer, die dem Leser durch Beantwortung mehrerer Fragen dabei helfen wollen, eine Entscheidung zu treffen. Man beginnt mit einer
ersten Frage und folgt wie bei einem Faden der Antwort durch die grafische Anordnung zur nächsten
Frage.
Nehmen wir als Beispiel die in der Weihnachtszeit immer wieder auftretende Frage : Welche Art
von Weihnachtsbaum soll ich mir kaufen?
Dabei könnte folgendes Schema helfen :
30
3 Bäume
NEIN
Hättest du
gerne einen
echten Baum?
JA
Plastikbaum
JA
NEIN
Soll dein
Baum
buschig und
groß sein?
NEIN
Soll dein
Baum
nach
Weihnachten
riechen?
JA
Nordmann
Blau
fichte
JA
Wird dein
Baum
von oben bis
unten
geschmückt?
Fraser
Tanne
NEIN
Rot
fichte
Abbildung 3.11: Welchen Weihnachtsbaum?
Die grafische Ausarbeitung täuscht bei solchen Entscheidungsbäumen oft darüber hinweg, dass die
dahinter liegende Struktur sehr simpel ist. Bei jeder Frage gibt es nur zwei mögliche Antworten, die
dann entweder zu einer Antwort zu einer weiteren Frage führen.
31
3 Bäume
N
J
Plastikbaum
N
J
Frasertanne
J
N
Nordmann
N
Rotfichte
J
Blaufichte
Abbildung 3.12: Grafisch vereinfachter Entscheidungsbaum
Als Graph betrachtet liegt hier ein Baum vor, der sich dadurch auszeichnet, dass von jedem Knoten aus maximal zwei mögliche Kanten ausgehen. Solche Bäume sind ein Spezialfall der allgemeinen
Baumstruktur.
Definition. Ein Baum, bei dem in jedem Knoten höchstens zwei Kanten enden/beginnen, wird binärer
Baum genannt.
Üblicherweise werden Bäume zeichnerisch von oben nach unten dargestellt, so dass man die nachfolgenden Knoten auch als linken bzw. rechten Nachfolger bezeichnet. Betrachtet man einen einzelnen
Knoten k, so fasst man alle Knoten, die sich aus seinem linken Folgeknoten und weiteren, sich anschließenden Knoten ergeben, als linken Teilbaum. In entsprechender Weise spricht man auch vom
rechten Teilbaum.
Geordnete binäre Bäume
In den bisherigen Beispielen für Bäume spielten die Knoten eine wichtige Rolle, da sie ja Ansatzpunkt
für weitere Kanten waren. Üblicherweise haben Knoten aber auch eigene Inhalte ( Zahlen, Namen,
Fragen zum perfekten Weihnachtsbaum, . . . ), die wir in Zukunft als Wert eines Knotens bezeichnen1 .
Wenn diese Werte von der Art sind, dass man sie untereinander vergleichen kann und jeden Vergleich
mit “größer” bzw. “kleiner” bewerten kann, so lässt sich diese Ordnung nutzen, um daraus einen
geordneten Baum aufzubauen.
Definition. Unter einem geordneten binären Baum verstehen wir einen Baum, der so aufgebaut ist,
dass bei jedem Knoten k der nachfolgende linke Teilbaum nur Knoten mit Werten kleiner als dem
Wert bei k und der rechte Teilbaum nur Knoten mit Werten größer als dem Wert bei k enthält.
1
Man spricht auch vom Inhalt eines Knotens oder dem Schlüssel eines Knotens.
32
3 Bäume
15
20
33
20
25
9
17
15
11
11
40
33
17
25
40
9
Abbildung 3.13: Beispiel für einen ungeordneten Baum (links) und einen geordneten Baum
Ist der Baum wie im gerade gezeigten Beispiel aus Zahlen aufgebaut, so lässt sich das übliche < bzw.
> als Ordnung verwenden, während bei String-Inhalten das < bzw. > sich durch die alphabetische
Anordnung ergibt.
Sobald ein geordneter binärer Baum vorliegt, kann man diese Struktur verwenden, um darin bestimmte Werte zu suchen. Angenommen wir wüssten nicht mehr genau, welche Zahlen überhaupt im
Baum vorhanden sind und wollen wissen, ob die Zahl 17 in einem Knoten als Wert vorliegt. Wir
starten mit der Wurzel, vergleichen unsere 17 mit der 20 und stellen fest, dass unsere gesuchte Zahl
dann allenfalls noch im linken Teilbaum der 20 vorliegen kann. Auf diese Weise können wir den Baum
durchlaufen und verengen unsere Suche immer wieder nur auf einen kleineren Teilbaum, d.h. wir müssen gar nicht alle 8 Werte durchgehen. Daher ist eine derartige Baumstruktur ideal um darin Werte
zu suchen.
Definition. Ein geordneter binärer Baum wird auch binärer Suchbaum genannt.
3.4 Implementation der Struktur Binärer Suchbaum
Bevor wir uns mit einer konkreten Umsetzung in Java beschäftigen, klären wir vorab, was wir von der
Struktur Baum allgemein erwarten und wie unsere Daten in die Baumstruktur eingeordnet werden.
Gehen wir also davon aus, dass eine gewisse Zahl an Werten ( Zahlen, Strings, . . . ) vorliegen und
aus jenen soll ein binärer Suchbaum aufgebaut werden. Dann benötigen wir im Wesentlichen zunächst
folgende Möglichkeiten ( diese sind sogar unabhängig von einer Programmiersprache ):
• einfügen : In den vorhandenen ( evtl. sogar leeren ) Baum fügen wir einen Knoten mit dem
gewünschten Wert ein
• entfernen : Aus dem Baum wird der Knoten mit dem vorgesehenen Wert entfernt ( sofern der
Wert überhaupt im Baum vorhanden ist ).
• isEmpty : Überprüft, ob der Baum Knoten mit Werten enthält ( false ) oder leer ist ( true )
• clear : Löscht den gesamten Baum mitsamt allen Knoten/Werten
Für eine erste Umsetzung ( eine Alternative wird im Anhang vorgestellt ) verwenden wir eine Klasse
Knoten, die für die entsprechenden Werte der Knoten und für die Zeiger auf die nachfolgenden Knoten
verantwortlich ist. Als Werte entscheiden wir uns konkret für Integerzahlen und ähnlich wie bei der
33
3 Bäume
zuvor behandelten Liste gibt es wieder Zeiger, die kontrolliert auf null zeigen, um dadurch anzudeuten,
dass sich kein weiterer Knoten in dieser Richtung mehr anschließt.
Eine zweite Klasse Baum verwenden wir, um den Baum wiederzugeben. Ein wichtiges Attribut ist
ein Zeiger namens wurzel (engl. root), der es erlaubt überhaupt einen Einstieg in die Baumstruktur zu
erhalten. Die Wurzel zeigt auf den obersten Knoten. Die eigentlich Baumstruktur, d.h. die Verknüpfung
der Knoten untereinander, wird dann schon durch die Klasse Knoten mit den passenden Zeigern
erreicht, so dass sich die Klasse Baum darum nicht mehr kümmern muss.
Bei den Methoden der Klasse Baum starten wir zunächst mit den bereits oben erwähnten vier
wichtigen Vorgängen :
Baum
– wurzel : Knoten
+ anhängen(zahl : Integer)
+ entfernen(zahl : Integer)
+ isEmpty() : Boolean
+ clear()
Knoten
– wert : Integer
– links : Knoten
– rechts : Knoten
+ gibWert() : Integer
+ gibLinks() : Knoten
+ gibRechts() : Knoten
Abbildung 3.14: Klassenkarten für die erste Implementation
Die Klasse Knoten
Bei der Klasse Knoten gibt es kaum Überraschungen ( übliche getter&setter-Methoden), so dass der
Quelltext folgendermaßen aussieht:
public class Knoten
{
private int zahl;
private Knoten links;
private Knoten rechts;
// Konstruktor
public Knoten(int z){
zahl = z;
links = null;
rechts = null;
}
// METHODEN
public Knoten gibLinks(){
return links;
}
public Knoten gibRechts(){
return rechts;
}
public int gibZahl(){
return zahl;
34
3 Bäume
}
public void setzeLinks( Knoten k ){
links = k;
}
public void setzeRechts( Knoten k ){
rechts = k;
}
public void setzeZahl( int z ){
zahl = z;
}
}
Die Klasse Baum
Das Grundgerüst der Klasse Baum ist rasch dargestellt, die einzelnen Methoden werden anschließend
der Reihe nach erläutert.
/**
* Baumstruktur Grundgerüst
*/
public class Baum
{
private Knoten wurzel;
// Konstruktor
public Baum(){
wurzel = null;
// leerer Baum zu Beginn
}
// METHODEN
public void setzeWurzel(Knoten k){
wurzel = k;
}
public Knoten gibWurzel(){
return wurzel;
}
}
Die Methoden isEmpty() und clear()
Beide Methoden hängen direkt mit der Wurzel zusammen, denn beim Erzeugen eines neuen, leeren
Baums wird die Wurzel auf null gesetzt ( siehe Konstruktor oben ), so dass man daran direkt erkennt,
ob der Baum noch leer ist oder nicht.
public boolean isEmpty(){
if (wurzel==null){
return true;
}
else{
return false;
}
}
35
3 Bäume
Durch ein kompaktere Schreibweise lässt sich dies noch verkürzen, auch wenn es inhaltlich dennoch
bei einem Vergleich der Wurzel mit dem Nullzeiger bleibt :
// Alternative Programmierung isEmpty()
public boolean isEmpty(){
return
(wurzel==null) ;
}
Das Löschen des gesamten Baums mit der Methode clear() lässt sich auch einfach mit der Wurzel
erreichen :
public void clear(){
wurzel=null;
}
Im ersten Moment scheint dies zu einfach gedacht, denn was ist mit all den Knoten und dem dafür
reservierten Speicher? Hier macht es einem Java sehr bequem, denn im Hintergrund wird für all die
vorher angelegten Knoten des Baums Buch geführt, wo und wieviel Speicher im RAM dafür gebraucht
wurde. Geht der Zugriff auf die Knoten verloren, so sorgt eine sogenannte Garbage Collection im
Hintergrund dafür, dass der nicht mehr zugängige RAM-Speicher wieder freigegeben wird.
Die Methode einfügen()
Bei einem leeren Baum ist es sehr einfach, einen neuen Knoten anzuhängen. Wir erzeugen ein neues
Knotenobjekt ( z.B. neuerKnoten ) und lassen den Zeiger wurzel statt auf null auf diese neue Objekt
zeigen durch wurzel = neuerKnoten.
Kniffliger wird die Situation, wenn bereits zahlreiche Knoten im Baum vorhanden sind. Dann heißt
es zunächst einmal die korrekte Stelle zum Anhängen zu finden. Welcher bisherige Nullzeiger muss auf
den neuen Knoten umgebogen werden?
Betrachten wir das Beispiel der Abbildung . Der Wert 13 soll in den Baum eingefügt werden und
durch die im Baum vorhandene Ordnung brauchen wir ja nur den linken bzw. rechten Zeigern zu
folgen und finden rasch heraus, dass die 13 rechts vom Knoten mit der 11 angehängt werden muss.
Um diesen rechten Zeiger zu ändern ( in der Abbildung grün ) können wir ja die setzeRechts()
Methode verwenden, allerdings nur dann, wenn wir Zugriff auf den Knoten 11 erhalten und genau
dafür benötigen wir einen zweiten Zeiger ( orange dargestellt )2 .
2
Die gleiche Problematik gab es schon beim Anhängen in einer verketteten Liste.
36
3 Bäume
wurzel
20
33
17
11
9
19 25 40
13 Ziel
Abbildung 3.15: Beispiel zum Einfügen
Möglich wird dies durch den Einsatz zweier Zeiger namens tochter und vater, die sich einander
folgend durch den Baum bewegen. Werfen wir einen Blick auf den Quelltext :
public void anhängen(int z){
Knoten neuerKnoten = new Knoten(z);
if ( isEmpty() ){
// einfacher Fall : Baum leer
wurzel = neuerKnoten;
}
else{
// Baum nicht leer
Knoten vater = null;
Knoten tochter = wurzel;
while ( tochter != null){
vater = tochter;
if ( z
< tochter.gibZahl() ){
tochter = tochter.gibLinks();
}
else{
tochter = tochter.gibRechts();
}
}
if ( z < vater.gibZahl() ){
vater.setzeLinks ( neuerKnoten );
}
else{
vater.setzeRechts( neuerKnoten );
}
}
}
Die erste if-Abfrage überprüft, ob ein leerer Baum vorliegt oder nicht. Bei einem nicht leeren Baum
sorgt die while-Schleife dafür, dass sich der Zeiger tochter korrekt durch den Baum bewegt und der
37
3 Bäume
Zeiger vater ihr folgt. Zeigt tochter schließlich auf null ist ein Blatt im Baum erreicht und eine
letzte if-Abfrage überprüft dann nur noch, ob der neue Knoten links bzw. rechts angehängt wird.
Beachte, dass diese Methode nicht abfragt, ob eine einzufügende Zahl bereits im Baum vorhanden
ist. Mehrfach auftretende Knoten mit gleichem Wert können später bei Ausgaben für Probleme sorgen,
so dass man unter Umständen einen solchen Fall gar nicht erst erzeugen möchte3 .
Die Methode entfernen()
Neben dem Einfügen von Knoten ist es auch wichtig, dass gezielte Werte und ihre zugehörigen Knoten wieder aus dem Baum entfernt werden können. Natürlich lassen sich nur Werte aus dem Baum
herausnehmen, die überhaupt in ihm vorhanden sind, daher muss in einem ersten Schritt der Baum
durchsucht werden, ob der zu entfernden Wert überhaupt enthalten ist. Dazu verwenden wir erneut
das Vorgehen mit den zwei Zeigern tochter und vater, das wir schon beim Einfügen benutzt haben
(s.o.). Wurde der passende Knoten mit dem zu löschenden Wert gefunden, zeigt der Zeiger tochter
auf den zu löschenden Knoten und vater zeigt auf den Vorgänger.
Allerdings ergeben sich an dieser Stelle verschiedene Fälle, wie man den Knoten aus dem Baum
entfernt. Gehen wir sie der Reihe nach durch:
Fall 1 : Der zu löschende Knoten hat keinen Nachfolger
In dem Fall, dass der Knoten ein Blatt ist ( d.h. keinen Nachfolger hat ) wird der Zeiger gesucht, der
auf den zu löschenden Knoten zeigt und auf null gesetzt.
wurzel
wurzel
20
17
löschen
11
20
28
19
wurzel
17
auf null
gesetzt
20
28
19
17
28
19
Abbildung 3.16: Löschen eines Blatts
Fall 2 : Der zu löschende Knoten hat genau einen Nachfolger
Betrachten wir die Abbildung und stellen uns vor, dass wir den Knoten mit dem Wert 13 löschen
sollen. Vom Knoten mit der 25 aus, gibt es einen Zeiger (linker Zeiger), der auf Knoten mit der 13
verweist. Dieser Zeiger wird einfach abgeändert, so dass er auf den einen Nachfolger der 13 zeigt.
Dadurch wird der Knoten “überbrückt” und der zugehörige Speicher automatisch gelöscht.
3
Eine Möglichkeit wäre es der Methode anhängen() einen Rückgabetyp Boolean zu geben und diesen so zu interpretieren, dass true ein erfolgreiches Anhängen angibt, während ein false darauf hinweist, dass das Einfügen nicht
möglich ist. Ergänzend sollte innerhalb der while-Schleife im else-Teil noch eine weitere if-Abfrage überprüfen, ob
z == tochter.gibZahl() ist und - falls dies eintritt - die Methode durch return false; beenden.
38
3 Bäume
wurzel
wurzel
25
13
löschen
wurzel
25
50
25
13
50
7
50
Zeiger
ändern
7
7
Abbildung 3.17: Löschen eines Knotens mit genau einem Nachfolger
Fall 3 : Der zu löschende Knoten hat zwei Nachfolger
Das Vorgehen in diesem Fall versteht man wieder besser, wenn man es an einem konkreten Baum
betrachtet. Die Abbildung zeigt einen Baum und wir nehmen uns vor, den Knoten mit der 40 zu
löschen. Sicherlich müssen wir den rechten Zeiger des Knotens mit der 28 ändern aber wohin soll
er jetzt zeigen? Egal wie wir uns entscheiden, der rechte Zeiger der 28 kann nicht gleichzeitig beide
Nachfolger der 40 erfassen.
wurzel
20
17
11
28
19
25
40
36
32
linker
Teilbaum
löschen
52
45
60
rechter
Teilbaum
Abbildung 3.18: Ein Knoten mit zwei Nachfolgern soll gelöscht werden
Eine schlechte, aber durchführbare Idee wäre es, den rechten Teilbaum an die 28 anzuhängen und
den linken Teilbaum links von der 45 anzubringen. Da der linke Teilbaum ja nur Zahlen kleiner 40
enthält, wäre er auch ein korrekter Teilbaum links von der 45. Der Nachteil besteht aber darin, dass
die Tiefe des Baums dadurch stark ansteigt. Zwar steigt der rechte Teilbaum eine Ebene im Baum
hoch aber anschließend wächst die Baumtiefe durch das Anhängen des linken Teilbaums enorm an.
Da die Tiefe des Baums eine wichtige Rolle spielt ( siehe später), ist dieses Vorgehen nicht sinnvoll.
39
3 Bäume
wurzel
wurzel
20
20
17
11
28
19
wurzel
25
20
17
40
11
36
28
19
25
11
52
32
45
17
36
60
32
28
19
52
25
45
52
45
60
36
60
Schlechte Idee :
Linken Teilbaum unter
rechten anhängen
32
Abbildung 3.19: Schlechte Idee des Anhängens
Eine bessere Variante ist es, nicht direkt den rechten Teilbaum anzuhängen, sondern im rechten
Teilbaum den Knoten mit dem kleinsten Wert zu suchen. Dieser nimmt den Platz des zu löschenden
Knoten ein und der gesamte linke Teilbaum wird mit diesem verbunden. Wieder ist dafür eine wichtige
Voraussetzung, dass selbst der kleinste Wert im rechten Teilbaum noch größer als alle Elemente im
linken Teilbaum ist und damit die Ordnung des Baums erhalten bleibt4 .
wurzel
wurzel
20
20
17
11
28
19
wurzel
25
17
40
36
32
20
11
28
19
25
52
45
32
Bessere Idee :
Im rechten Teilbaum das kleinste
Element suchen und als Ersatz für
das zu löschende Element nehmen
11
45
36
60
17
28
19
25
52
45
36
60
32
52
60
Jetzt noch die Nachfolger von 45
festlegen und den linken Nachfolger
von 52 auf null setzen
Abbildung 3.20: Besseres Löschen
Die Umsetzung in der Programmiersprache Java soll an dieser Stelle nicht thematisiert werden,
4
Eine gleichwertige Idee wäre es auch im linken Teilbaum den Knoten mit dem größten Wert als Ersatz für den zu
löschenden Knoten zu verwenden.
40
3 Bäume
befindet sich aber im Anhang.
3.5 Traversierungen
Mitunter möchte man alle Werte der Baumknoten ausgeben. Dabei hilft eine Strategie, wie man sich
so durch die Baumstruktur bewegt, dass auch alle Elemente erfasst werden. Ein Verfahren, dass jeden
Knoten eines Baums besucht, wird Traversierung genannt. Bei den binären Bäumen haben sich drei
übliche Traversierungen entwickelt, die nacheinander vorgestellt werden :
Die PreOrder-Traversierung
Diese Traversierung ist - wie aber auch die beiden später folgenden Methoden - eine rekursive Vorgehensweise, d.h. man startet mit dem ganzen Baum und überträgt dann das Verfahren auf zwei
Teilbäume. Der Name PreOrder resultiert daraus, dass man zunächst einen Wert ausgibt bevor ( engl.
pre ) man zu den Teilbäumen übergeht. Konkret ausgedrückt :
preOrder-Traversierung
–besuche die Wurzel
dann
–traversiere den linken
Teilbaum
dann
– traversiere den rechten
Teilbaum
kurz : WLR
Abbildung 3.21: Das PreOrder-Verfahren
Betrachten wir das Vorgehen an einem konkreten Beispiel. Der Baum in der Abbildung 3.22 soll mit
dem PreOrder-Verfahren durchlaufen werden. Wir starten mit der Wurzel und geben den Wert der
Wurzel ( hier : 22 ) aus. Anschließend arbeiten wir den markierten linken Teilbaum erst vollständig ab
und erst dann kommen wir zum rechten Teilbaum. Der linke Teilbaum wird nun einfach selbst wieder
als Baum mit der Wurzel 17 betrachtet, d.h. wir geben die 17 aus und erkennen, dass von der 17 aus
wieder zwei weitere Teilbäume zu finden sind. Der linke Teilbaum ( aus 11 und 9 bestehend ) wird
erst vollständig durchlaufen bevor der rechte Teilbaum ( nur die 19 ) besucht wird.
wurzel
wurzel
22
22
17
11
33
19
25
17
linker
Teilbaum
40
11
9
33
19
25
rechter
Teilbaum
40
9
Abbildung 3.22: Beispiel zur PreOrder-Traversierung
41
3 Bäume
Insgesamt ergibt sich folgende Ausgabe:
PreOrder : 22, 17, 11, 9, 19, 33, 25, 40
Wie schon am Beispiel zu erkennen ist, erhalten wir nicht - wie vielleicht erwartet - die Zahlen in aufsteigender Reihenfolge sondern in einer Abfolge, deren Sinn sich nicht sofort erschließt. Der Vorteil der
PreOrder-Ausgabe besteht darin, dass man aus dieser Reihenfolge den Baum wieder vollständig erzeugen kann, d.h. dass man eine exakte Kopie des Baumes erhält. Man nimmt die erste Zahl als Wurzel
und jede weitere wird wie mit der oben beschriebenen Methode zum Anhängen in die Baumstruktur
eingefügt.
Die InOrder-Traversierung
Die zweite Möglichkeit alle Knoten durchzugehen bietet die InOrder-Traversierung. Sie ist ebenfalls
rekursiv und der einzige Unterschied besteht darin, dass - sofern vorhanden - erst dem linken Teilbaum
gefolgt , dann die Wurzel ausgegeben und letztlich dem rechten Teilbaum gefolgt wird.
InOrder-Traversierung
–traversiere den linken
Teilbaum
dann
–besuche die Wurzel
dann
– traversiere den rechten
Teilbaum
kurz : LWR
Abbildung 3.23: Die InOrder-Traversierung
Der gleiche Baum in der Abbildung 3.22 führt dann zu folgender Ausgabe :
InOrder : 9, 11, 17, 19, 22, 25, 33, 40
und es lässt sich erkennen, dass wir mit dieser Methode die Zahlen wunderbar in aufsteigender Reihenfolge erhalten5 .
Die PostOrder-Traversierung
Als drittes Verfahren im Bunde betrachten wir das PostOrder-Verfahren. Dabei werden erst die Teilbäume links und rechts abgearbeitet und erst zuletzt wird die Wurzel ausgegeben. Anders formuliert
könnte man das Verfahren so beschreiben, dass es immer tiefer den Baum herabsteigt bis in die Blätter
und erst dann zu einer Ausgabe kommt.
5
Beachte aber, dass man aus dieser Reihenfolge nicht zurück auf den Baum schließen kann. Jeder Baum mit den gleichen
Zahlen wie im betrachteten Beispiel führt - unabhängig von seinen Kanten - zur gleichen InOrder-Reihenfolge.
42
3 Bäume
postOrder-Traversierung
– traversiere den linken
Teilbaum
dann
– traversiere den rechten
Teilbaum
dann
–besuche die Wurzel
kurz : LRW
Abbildung 3.24: Die PostOrder-Traversierung
Angewandt auf den schon zweimal betrachteten Baum der Abbildung 3.22 ergibt sich :
PostOrder : 9, 11, 19, 17, 25, 40, 33, 22
Implementation der Traversierungen
Bei allen drei besprochenen Traversierungen war schon die Beschreibung des Vorgehens so angelegt,
dass man den rekursiven Charakter der Aufgabe erkannte. So folgte die InOrder-Ausgabe dem linken
Teilbaum und wenn dieser wieder einen linken Teilbaum hatte, so folgte man auch diesem weiter und
weiter bis zum kleinsten Element. Daher bietet es sich an, dass man bei einer Implementation in Java
auch rekursive Programmiertechniken verwendet. Betrachten wir die folgende Methode :
public void ausgabePreOrder(Knoten zeiger){
if ( zeiger != null)
{
System.out.println( zeiger.gibZahl() );
ausgabePreOrder( zeiger.gibLinks() );
ausgabePreOrder( zeiger.gibRechts() );
}
}
Die Methode benötigt als Parameter einen Zeiger auf den Knoten, bei dem sie mit der Traversierung
anfangen soll. Üblicherweise ist der erste Aufruf ausgabePreOrder(wurzel) aber im weiteren Verlauf
kann der Zeiger dann eben auch ein Zeiger auf den nachfolgenden linken bzw. rechten Teilbaum sein.
Schon im Quelltext erkennen wir, dass sich die Methode zweimal selbst aufruft und der Rekursionsanfang liegt darin, dass der Zeiger auf null zeigt und eben kein Nachfolger mehr zu finden ist. Auch
dass es sich um die PreOrder-Ausgabe handelt erkennt man daran, dass zuerst die Ausgabe auf der
Konsole erfolgt und erst danach der linke und rechte Teilbaum durchgegangen werden.
Bei der InOrder und der PostOrder-Traversierung muss nur die Reihenfolge der Konsolenausgabe
und der rekursiven Aufrufe geändert werden :
public void ausgabeInOrder(Knoten zeiger){
if ( zeiger != null)
{
ausgabeInOrder( zeiger.gibLinks() );
System.out.println( zeiger.gibZahl() );
ausgabeInOrder( zeiger.gibRechts() );
}
}
43
3 Bäume
public void ausgabePostOrder(Knoten zeiger){
if ( zeiger != null)
{
ausgabePostOrder( zeiger.gibLinks() );
ausgabePostOrder( zeiger.gibRechts() );
System.out.println( zeiger.gibZahl() );
}
}
3.6 *Alternative Implementation
Die bisherige Umsetzung der Struktur Baum in die Programmiersprache Java verwendete zwei Klassen
( Baum und Knoten ) und ist dadurch nicht besonders pflegeleicht. Sämtliche Zahlenwerte waren vom
Typ Integer und wollten wir für eine andere Aufgabe einen Baum mit Kommazahlen erstellen, so
müssten wir beide Klassen durchgehen und Dinge abändern.
Auf der anderen Seite zeigte sich bei vielen Stellen die rekursive Struktur von Bäumen, die sich
bei der rekursiven Programmierung von Traversierungen auch in kurzen Quelltexten zeigte. Daher ist
möglich die Struktur des Baums auch durch eine einzige Klasse umzusetzen. In dieser Denkweise ist
dann ein Baum ein zu speichernder Wert, an den sich bis zu zwei weitere Teilbäume anschließen.
Konkret legen wir folgende Klasse fest :
public class Baum
{
private Integer wert; // Integer, nicht int
private Baum links;
private Baum rechts;
/**
* Konstruktor, der alles auf null setzt
*/
public Baum(){
// feines Detail, null setzen klappt nur wenn wert Integer (Objekttyp von int ) ist
wert = null;
links = null;
rechts = null;
}
public Baum (int zahl){
wert
= zahl;
links
=null;
rechts =null;
}
Die Klasse besitzt drei Attribute, eine Integerzahl als Wert des Knotens sowie zwei Zeiger auf
nachfolgende Bäume, also genau die Attribute, die zuvor in der Klasse Knoten angelegt waren. Ein
kleines Detail gilt es aber auch hier schon zu beachten, denn warum schreiben wir beim ersten Attribut
private Integer wert; und nicht wie gewohnt private int wert; ?
Der Unterschied zwischen int und Integer besteht nicht in den zu benutzenden Zahlen sondern
eher in der Art und Weise wie die Integerzahlen intern verarbeitet werden. Bei int handelt es sich
um einen grundlegenden Datentyp, d.h. eine Variable vom Datentyp int erlaubt den direkten Zugriff
44
3 Bäume
auf die Zahl. Bei Integer hingegen liegt ein Zeigerkonzept vor, d.h. eine Variable von einem solchen
Typ ist ein Zeiger auf einen Bereich, in dem ein Integerwert liegt. Wie jeder Zeiger kann daher eine
Integer Variable auf null gesetzt werden, eine int-Variable dagegen nicht. Doch warum ist das hier
so wichtig?
Wenn wir dem Attribut wert nicht null zuordnen können, sondern es als klassisches int-Attribut
einführen, dann hat diese Variable immer einen Wert auch ohne dass wir einen festlegen. Üblicherweise werden int-Variablen in Java genullt und darin liegt das Problem, denn dann sehen wir keinen
Unterschied mehr zwischen einem leeren Baum ohne bisher eingefügte Zahlen und einem Baum, in
dem wir ganz bewusst nur die Zahl Null eingefügt haben. Man würde nicht erkennen, ob wir die Zahl
Null meinen oder ob wir meinen, dass dort noch gar kein Wert vorhanden ist.
Wir wiederholen an dieser Stelle nicht die gesamte Implementation sondern geben nur exemplarisch
die Methode anhängen für diese Klasse an. Ein Vergleich mit der Implementation in 3.4 zeigt wie
einfach die rekursive Programmierung hier ansetzt.
public void anhängen(int z){
Baum neuerBaum = new Baum(z);
if ( isEmpty() ){
// noch leerer Baum ?
wert = z;
//-> einfach z als wert eintragen
}
else{
if ( z < wert ){
if ( links==null)
links=neuerBaum;
// gibt es schon einen linken Teilbaum?
// nein, dann neuen Baum dort anhängen
else
links.anhängen(z);
// sonst rekursiv links weitergehen
}
else{
if (rechts==null)
// gibt es schon einen rechten Teilbaum?
rechts=neuerBaum;
// nein, dann rechts anhängen
else
rechts.anhängen(z);
// oder rekursiv rechts weitergehen
}
}
}
3.7 Die Höhe eines Baums
Bei all den Details zur Implementation in Java haben wir für einen Moment vielleicht aus den Augen
verloren, warum die Struktur eines Baums überhaupt eine solch große Bedeutung hat. Warum legen
wir all die Integer-Zahlen nicht einfach in einem Array ab?
Betrachten wir als Beispiel die Zahlen 3, 10, 17, 24, 41, 53 und 60 , einmal der Größe nach in einer
Liste (Array) abgelegt und ein weiteres Mal eingebunden in eine Baumstruktur :
45
3 Bäume
Baum
24
Liste
10
3 10 17 24 41 53 60
3
53
17
41
60
Abbildung 3.25: Liste und Baum im Vergleich
Nehmen wir an, dass wir in dieser Zahlenmenge eine gewisse Zahl suchen. Dabei betrachten wir
sowohl die Situation, dass die gesuchte Zahl gar nicht in der Menge vorhanden ist ( z.B. Suchen der
Zahl 58 ) oder eben doch irgendwo auftritt ( z.B. die 53 ).
Suchen in einer Liste
Wir verwenden die naheliegende Vorgehensweise und durchkämmen die Liste, bis wir entweder die
gewünschte Zahl gefunden oder das Ende der Liste erreicht haben. Bei jeder vorhandenen Zahl der
Liste vergleichen wir mit unserer Zahl und erhalten :
• Bei der Zahl 58 wird nach 7 Vergleichen erkannt, dass sie nicht in der Liste ist.
• Die Zahl 53 wird nach 6 Vergleichen in der Liste gefunden.
Suchen im Baum
Beim Suchen im Baum der Abbildung ergibt sich :
• Bei der Zahl 58 wird nach 3 Vergleichen erkannt, dass der Baum sie nicht enthält.
• Die Zahl 53 wird nach 2 Vergleichen gefunden.
War das jetzt ein fairer Vergleich? Lag es an den Zahlen 58 und 53?
Sind wir mal ganz gründlich und gehen einfach alle vorhandenen Zahlen durch ( von den nicht
vorhandenen Zahlen gibt es leider unendlich viele ) und überlegen jeweils, wie viele Vergleiche mit den
gegebenen Zahlen nötig sind, bis wir sie finden.
Zahl
Anzahl Vergleich Liste
Anzahl Vergleiche Baum
3
1
2
10
2
2
17
3
2
24
4
1
41
5
2
53
6
2
60
7
2
Damit kommen wir im Durchschnitt bei der Liste auf 28/7 = 4 Vergleiche, während wir im Baum nur
13/7 = 1, 86 im Mittel benötigen. Schon jetzt ist klar erkennbar, dass auch bei wenigen Zahlen das Suchen
im
Baum
deutlich
schneller
abläuft6 .
Jedoch müssen wir beachten, dass unser Vergleich nur für den speziellen Baum gilt, der mit
sehr wenigen Vergleichen direkt zu den Endknoten des Baums führt. Ein schlecht aufgebauter
Suchbaum (vgl. Abbildung ) bringt beim Suchen
dann kaum noch Vorteile. Grob gesagt, je mehr
ein Baum einer Liste ähnelt, desto sinnloser wird
die Verwendung der Baumstruktur.
6
53
41
60
24
17
10
Ganz fairerweise muss man allerdings sagen, dass es für sortierte Arrays deutlich
bessere Suchverfahren gibt, als nur
3
die Liste von vorne bis hinten zu durchlaufen. Diese Möglichkeit gibt es aber nur, wenn man einen Direktzugriff auf
die Elemente hat wie bspw. über den Index eines Arrays.
Abbildung 3.26: Ein schlechter Suchbaum
46
3 Bäume
Einige Begriffe
Legen wir ein paar Begriffe fest, um damit einen
möglichst perfekten Suchbaum zu beschreiben.
Definition. Gegeben sei ein binärer Suchbaum
B mit den n Knoten k1 , . . . , kn . Dann definieren
wir :
Unter der Tiefe eines Knotens ki verstehen wir die Anzahl der nötigen Kanten auf dem eindeutigen
Weg von der Wurzel zu ki .
Unter der Höhe eines Baums verstehen wir die größte auftretende Tiefe eines Knotens im Baum.
Ein guter Suchbaum zeichnet sich durch eine geringe Höhe aus und wir können allgemein für n Knoten die Frage stellen, wie hoch dann ein Baum im besten Falle ( kleinste Höhe ) und im schlechtesten
Falle ( große Höhe ) ist. Für die Praxis sind oft nicht diese extremen Möglichkeiten wichtig, sondern
eher die Frage wie hoch der Baum durchschnittlich ist.
Suchbaum
mit
geringster
Tiefe
Suchbaum
mit
durchschnittlicher
Tiefe
Suchbaum
mit
größtmöglicher
Tiefe
Abbildung 3.27: Das Spektrum der Suchbäume
Bester und schlechtester Suchbaum
Um eine möglichst große Höhe eines Baums zu erreichen, müssen wir lediglich den Baum wie eine
Art Liste aufbauen, d.h. zu jedem Knoten gibt es nur einen Nachfolger. Dadurch kommt man bei n
Knoten zu einer Höhe von n 1.
Den günstigsten Baum erreichen wir durch das genaue Gegenteil, d.h. wir geben - sofern möglich allen Knoten immer zwei nachfolgenden Knoten. Dadurch nutzen wir die gesamte Breite aus, bevor
wir eine neue, tiefere Ebene erreichen. Starten wir mit einer kleinen Übersichtstabelle :
Anzahl der Knoten n
Höhe h im besten Fall
1
0
2
1
3
1
4
2
5
2
6
2
7
2
8
3
9
3
10
3
11
3
12
3
13
3
14
3
15
3
16
4
Wir richten unser Augenmerk auf die besonderen Anzahlen n , bei denen neue gewisse Höhe beginnt
oder endet. So gibt es z.B. einen Bereich von n = 8 bis n = 15 Knoten, die durch geschicktes Anordnen
immer zu einem Baum der Höhe h = 3 führen.
Der Beginn eines jeden Bereichs ist schnell gefunden, genau bei den Zahlen n = 2, 4, 8, 16, . . . wird
der Baum eine Ebene tiefer und offenbar passiert dies genau, wenn n eine Zweierpotenz ist. Allgemein
gilt :
n > 2h
Zu weit kann sich n aber auch nicht von 2h entfernen, wie der folgende, hilfreiche Satz zeigt :
Satz. In einem vollständig gefüllten Baum der Tiefe h befinden sich 2h+1
1 Knoten.
Beweis. In jeder neuen Ebene eines Baums kann man doppelt so viele Knoten anbringen wie in der
vorherigen, da ja jeder bisherige Endknoten zwei neue Nachfolger haben kann. Addieren wir alle
47
3 Bäume
Knoten Ebene für Ebene gelangen wir zu 1 + 2 + 22 + . . . + 2h Knoten. Durch einen mathematischen
Kniff können wir die Gesamtknotenanzahl n rasch berechnen :
n = 1 + 2 + 22 + . . . + 2h
) 2n = 2 + 22 + 23 + . . . + 2h+1
Subtrahieren wir die obere Zeile von der unteren, so ergibt sich auf der linken Seite einfach n,
während sich auf der rechten Seite genau die Potenzen wegheben, die in beiden Summen auftreten :
n = 2h+1
1
Zusammengefasst :
Satz. Bei einer gegebenen Knotenzahl n lässt sich immer ein Baum finden, so dass für die Höhe h
des Baums gilt : 2h+1 > n > 2h
Anders formuliert : Im besten Falle liegt die Knotenzahl zwischen zwei Zweierpotenzen.
Beispiel. Wenden wir unsere bisherigen Erkenntnisse auf ein Beispiel an. Angenommen, wir wollten
n = 8000 deutsche Städtenamen in einem Suchbaum darstellen. Dann könnten wir durch konsequentes
Durchgehen aller Zweierpotenzen finden :
212 = 4096 < n < 213 = 8192
Erst ab 8192 Knoten/Städten bräuchten wir einen Baum der Tiefe 13, d.h. wir können alle Städte
im besten Falle in einem Baum der Tiefe 12 anordnen.
Eine einfachere Möglichkeit direkt die Höhe im besten Fall zu berechnen, ist es die Ungleichung
n > 2h nach dem h umzustellen. Dafür gibt es allgemeine Logarithmen, von denen wir hier den
Zweierlogarithmus verwenden :
n = 2h () h = log2 (n)
Der Logarithmus ist eine streng monoton wachsende Funktion, d.h. bei größeren Werte von n steigt
auch der Logarithmus, so dass wir auch die Ungleichung übernehmen können :
n > 2h
()
log2 (n) > h
Auf das Beispiel mit 8000 Städten angewandt, ergäbe sich :
h 6 log2 (8000) ⇡ 12, 966
Da h eine ganze Zahl ist, können wir hier durch Abrunden ( durch die neuen Symbole b und c
dargestellt ) entnehmen :
h = b12, 966c = 12
Satz. Bei einer gegebenen Knotenzahl n gilt für die Höhe des Baumes h im besten Fall :
h = blog2 (n)c
Anders formuliert : Die Höhe der Suchbäume steigt im besten Fall logarithmisch an.
48
3 Bäume
Zum Abschluss zeigt die Abbildung 3.28die erarbeiteten Zusammenhänge noch einmal grafisch. Im
schlechtesten Fall entsteht bei n Knoten ein Baum der Höhe n 1 , d.h. die Höhe wächst linear. Im
besten Fall wächst die Baumhöhe logarithmisch und im durchschnittlichen Fall - ohne Beweis hier
mitgeteilt - erhalten wir auch eine Kurve, die annäherend logarithmisch wächst.
worst case
Höhe(h)
average case
best case
Anzahl der Knoten (n)
Abbildung 3.28: Baumhöhe bei n Knoten
3.8 Huffmann-Bäume
49
4 Sortierverfahren
Sortieren ist ein Vorgang, der in vielen Anwendungen immer wieder auftaucht. Bei größeren Datenmengen kann eine Sortierung viel Zeit in Anspruch nehmen. Daher ist es gut, sich vorab Gedanken zu
machen, wie man beim Sortieren vorgehen will. So etwas nennt man ein Sortierverfahren oder auch
einen Sortieralgorithmus.
Im täglichen Leben bedient man sich diverser Sortierverfahren zumeist ohne groß darüber nachzudenken. Bücher werden alphabetisch geordnet, DVDs geordnet in ein Regal gestellt, Spielkarten
werden auf der Hand sortiert und Kleider im Schrank in eine gewisse Ordnung gebracht (hoffentlich).
In allen folgenden Verfahren gehen wir davon aus, dass wir Daten sortieren, bei denen man feststellen
kann, ob ein Datenelement größer, gleich oder kleiner als ein anderes ist. Bei Zahlen liegt dies auf der
Hand, bei Texten oder Buchstaben verwenden wir die lexikographische Ordnung, die man aus Telefonbüchern oder Lexika kennt. Die Daten sollen in einem Array vorliegen, so dass wir durch Zugriff
auf die einzelnen Felder an alle benötigten Datenelemente leicht herankommen können (nicht wie in
einer verketteten Liste).
Weiterhin werden wir die Daten immer aufsteigend sortieren aber das ist keine wirkliche Einschränkung. In allen zu betrachtenden Verfahren lässt sich die Sortierreihenfolge durch geringfügige Änderungen leicht umkehren.
4.1 Ein erstes Verfahren : Bubblesort
Ein erstes Verfahren um systematisch Elemente zu ordnen bietet die folgende Vorgehensweise:
• Vergleiche die ersten zwei Elemente. Sind sie in falscher Reihenfolge, so tausche die beiden.
• Vergleiche das zweite und das dritte Element. Tausche bei falscher Reihenfolge.
• Führe diese Vergleiche und möglichen Vertauschungen von Nachbarn fort, bis die letzten beiden
Elemente verglichen wurden.
Betrachten wir dies am Beispiel von sechs Buchstaben :
50
4 Sortierverfahren
vergleichen
vergleichen
d C A D L X E
C L A D X E
vergleichen
vergleichen
e C A D L X E
b C L A D X E
tauschen
tauschen
vergleichen
sortiert
c C A L D X E
f
C A D L E X
tauschen
Abbildung 4.1: Erster Durchgang BubbleSort
Nach diesem ersten Durchgang sind die Buchstaben noch nicht insgesamt korrekt sortiert. Lediglich
der letzte Buchstabe ( hier das X ) ist bereits an der korrekten Stelle, denn egal wo im Array das X
gewesen wäre, so wäre es doch bei jedem Vergleich nach rechts getauscht worden und dann anschließend bei jedem folgenden Vergleich immer weiter nach rechts gebracht worden. Dieses Aufsteigen von
Elementen durch Vergleiche ist der Namensgeber des Verfahrens. Man spricht von Sortieren durch
Aufsteigen oder - wesentlich häufiger - von Bubblesort.
An diesen ersten Durchgang schließt sich ein zweiter Durchgang an, der allerdings nur noch die
ersten fünf Buchstaben untersuchen muss und den bereits sortierten Bereich ignorieren kann.
vergleichen
vergleichen
sortiert
sortiert
d A C D L E X
a C A D L E X
tauschen
tauschen
vergleichen
sortiert
sortiert
b A C D L E X
e A C D E L X
vergleichen
sortiert
c A C D L E X
Abbildung 4.2: Zweiter Durchgang BubbleSort
In jedem Durchgang wird dem sortierten Bereich ein weiterer Buchstabe hinzugefügt, so dass in
diesem Beispiel nach dem fünften Durchgang nur noch ein Buchstabe im unsortierten Bereich übrig
bleibt. Dieser ist dann aber automatisch schon an der korrekten Position. Allgemein erfordert das
Verfahren bei n Daten insgesamt n 1 Durchgänge.
51
4 Sortierverfahren
Implementation von Bubblesort
Die Umsetzung des Ablaufs in Java verwendet hier zwei ineinander verschachtelte for-Schleifen.
// Bubblesort
for (int ende=array.length; ende>0; ende--){
for (int i=0; i<ende-1; i++){
if ( array[i] > array[i+1] ){
tausche(i, i+1);
}
}
}
Die äußere Schleife mit der Variable ende beginnt beim letzten Element von array und bewegt sich
absteigend herunter bis zum Fall ende = 1 . Damit markiert diese Variable für uns den Beginn des
sortierten Bereichs. In der inneren Schleife durchläuft die Variable i alle Elemente im unsortierten
Bereich und überprüft mit einer if-Zeile, ob gegebenenfalls zwei Elemente vertauscht werden sollen.
4.2 Selectionsort (Sortieren durch Auswahl)
Eine andere beliebte Methode beim Sortieren besteht darin, aus der zu sortierenden Menge das kleinste
Element zu bestimmen. Dieses bildet den Anfang des sortierten Bereichs. Aus den restlichen unsortierten Elementen bestimmt man dann erneut das kleinste Element und kommt so Schritt um Schritt
zu einer Sortierung aller Elemente. Geht man davon aus, dass im Speicher des Rechners die Daten
nur einmal vorliegen sollen, dann müssen die in den einzelnen Durchgängen gefundenen, kleinsten
Elemente natürlich noch an die richtigen Positionen getauscht werden. Die Abbildung verdeutlicht das
Vorgehen mit Hilfe von zu sortierenden Buchstaben :
sortiert
a
tauschen
tauschen
d A C D L X E
C L A D X E
kleinstes Element
kleinstes Element
n
n
tauschen
tauschen
b
e A C D E X L
A L C D X E
kleinstes Element
kleinstes Element
n
n
tauschen
c
f A C D E L X
A C L D X E
kleinstes Element
letzter Buchstabe
automatisch einsortiert
n
Abbildung 4.3: Ablauf bei Selectionsort
Dieses Verfahren heißt Sortieren durch Auswahl (engl. selection sort).
52
4 Sortierverfahren
Implementation von Selectionsort
Wie schon in der Abbildung zu sehen, verwenden wir eine Variable n, die sich in einer Schleife von der
ersten bis zur vorletzten Stelle durch das zu sortierende Array bewegt. Für jeden Wert von nwird im
unsortierten Bereich ( d.h. bei allen Positionen rechts von n ) das Minimum gesucht. Dessen Position
im Array geben wir mit Hilfe der Variablen minPos an. Beginnend bei n selbst, bewegen wir uns mit
einer zweiten Schleife durch alle Positionen > n und überprüfen alle Zahlen, ob sie evtl. kleiner als
unser aktuelles Minimum sind. Falls ja, wird minPos aktualisiert.
// Selectionsort
for (int n=0; n<array.length-1;n++){
int minPos=n; // Minimum bei Position n
for (int k=n+1; k<array.length; k++){
if ( array[k]<array[minPos] ){
minPos = k; // neues Minimum gefunden
}
}
tausche (n, minPos);
}
4.3 Insertionsort (Sortieren durch Einfügen)
Bei vielen Kartenspielen sortieren die Spieler ihre Karten auf der Hand aufsteigend. Einige nehmen
dabei alle Karten gleichzeitig auf und sortieren dann um (eine Art Sortieren durch Auswahl), aber
andere verwenden ein anderes Verfahren. Sie nehmen eine Karte nach der anderen auf und ordnen jede
neu dazukommende Karte in die bereits sortierten Handkarten richtig ein. Ein derartiges Verfahren
nennt man Sortieren durch Einfügen (engl. insertion sort). Der Algorithmus startet mit dem ersten
Element der Liste und nimmt es in der sortierten Bereich auf. Bei jedem weiteren Schritt wird der
sortierte Bereich von rechts (d.h. zu kleiner werdenden Zahlen) durchsucht und immer verglichen, ob
das hinzukommende Element noch kleiner ist. Dadurch lässt sich bestimmen, welche Position im Array
die korrekte zum Einfügen ist.
Nach Finden der Position muss aber erst einmal im Array Platz geschaffen werden und dies geschieht
bei diesem Verfahren dadurch, dass man alle Element ab der gefundenen Position um einen Platz nach
rechts schiebt. Ist das geschehen, lässt sich die neue Zahl in den sortierten Bereich einbauen.
53
4 Sortierverfahren
sortiert
n
n
a C L A D X E
d A C D L X E
n
n
b C L A D X E
e A C D L X E
n
c A C L D X E
f A C D E L X
Abbildung 4.4: Ablauf bei Insertionsort
Implementation von Selectionsort
Der Java-Quelltext spiegelt das beschriebene Vorgehen wieder. In einer Schleife (Variable n) wird bei
jedem Durchgang das n-te Element des Arrays in der Variablen vElement (v wie Vergleichselement)
zwischengespeichert. Die Zeile pos = n 1 lässt die Variable pos links von n(d.h. im sortierten Bereich)
starten und vermindert diese in einer inneren while-Schleife.
//Insertionsort
for (int n=1; n<array.length; n++){
int vElement=zahlen[n]; // die einzusortierende Zahl
int pos=n-1;
while ( pos>=0 && array[pos] > vElement ){
array[pos+1] = array[pos];
pos--;
}
array[pos+1]=vElement;
}
4.4 Ein Vergleich der bisherigen Verfahren
Nachdem wir bislang drei klassische Sortierverfahren besprochen haben, halten wir kurz inne und
vergleichen diese Verfahren miteinander. Für welchen dieser Algorithmen würden wir uns entscheiden,
wenn wir nur einen aussuchen dürften?
Naheliegend ist es, ein Array mit zufällig gewählten Zahlen zu füllen und die drei Verfahren das
gleiche Array sortieren zu lassen. Stoppen wir bei allen Verfahren die Zeit, so haben wir einen ersten
Anhaltspunkt für die Schnelligkeit. Wiederholt man die Zeitmessung für verschieden große Arrays,
ergibt sich auf einem bestimmten Computer folgendes Bild:
BILD einfügen, Text ergänzen
4.5 Rekursive Verfahren
Wie schon in Kapitel 2 ausführlich besprochen, lassen sich viele Methoden linear oder rekursiv programmieren. Alle bisher betrachteten Sortierverfahren arbeiten sich mit Hilfe von Schleifen durch
54
4 Sortierverfahren
die gegebenen Zahlen und waren nicht rekursiv. Zum Abschluss betrachten wir daher zwei rekursiv
arbeitende Sortierverfahren:
Das Quicksort-Verfahren
Quicksort wurde 1962 vom britischen Informatiker Charles Antony Hoare erfunden und verwendet
ein rekursives Verfahren zum Sortieren von Daten. Die vorhandene Menge an unsortierten Elementen
wird dabei in kleinere Teilbereiche zerlegt, die wieder in Teilbereiche zerlegt werden, usw. Quicksort
gehört damit zu den Algorithmen vom Typ „Divide and Conquer“, d.h. ein Problem wird in kleinere
Teilprobleme zerlegt (divide), die dann einzeln gelöst werden (conquer) und aus allen zusammen erhält
man die Gesamtlösung.
Die grobe Idee bei Quicksort besteht darin, sich zunächst ein beliebiges Element der Liste zu wählen
(man spricht vom Vergleichselement oder Pivot-Element). Dabei verwendet man oft das allererste
Element oder auch das Element in der Mitte des Arrays, aber die Wahl ändert nichts am eigentlichen
Ablauf. Der nun folgende Ablauf hat zum Ziel die übrigen Bestandteile des Arrays so zu verteilen,
dass links vom Vergleichselement nur kleinere Elemente und rechts vom Vergleichselement nur größere
Elemente zu finden sind. Dadurch ist die Liste nicht zwangsläufig sortiert aber zumindest vorsortiert.
Betrachten wir das Verfahren schrittweise an einem grafischen Beispiel, bei dem wir einfach Balken
verwenden, die der Größe nach aufsteigend sortiert werden sollen :
Beispiel aktualisieren
1. Ausgangspunkt ist eine Anzahl von unsortierten Elementen. Von diesen wählt man ein Vergleichselement (hier das mittlere Element) und setzt zwei Markierungen (L und R) an den linken und
rechten Rand. In diesem Fall ist dann VElement = 8 .
2. Da links vom VElement nur kleinere Elemente stehen sollen, verschiebt man die linke Marke
weiter nach rechts bis man ein Element findet, das größer ist als das VElement. Dies steht dann
gewissermaßen „auf der falschen Seite“. Auf die gleiche Weise wird die rechte Marke nach links
geschoben, bis man ein Element findet, das kleiner als das VElement ist. (in diesem Fall muss
R gar nicht verschoben werden, da schon das Element ganz rechts kleiner als das VElement ist)
3. Auf beiden Seiten hat man an den Stellen L und R zwei Elemente gefunden, die „auf der falschen
Seite“ stehen. Diese beiden Elemente werden direkt miteinander vertauscht.
4. Nach dem Tausch können die beiden Markierungen L und R weiter bewegt werden, da ja dann
die beiden Elemente an den bisherigen Positionen auf jeden Fall richtig positioniert sind.
5. Wie in Schritt 2 wird die linke Marke weiterbewegt bis man eine Zahl
anschließend die rechte Marke bis man eine Zahl VElement findet.
VElement findet und
6. Nach einem weiteren Tausch und anschließendem Weiterbewegen von L und R kommt man zu der
Situation, dass sich die beiden Markierungen L und R aneinander vorbeibewegt haben. Damit
ist das Feld vorsortiert ( links vom VElement nur die kleineren, rechts die größeren Zahlen )
aber noch nicht komplett sortiert.
7. Jetzt kann man das Verfahren rekursiv auf die beiden erzeugten Teilbereiche erneut anwenden,
d.h. zuerst auf die Zahlen links vom VElement und anschließend auf die Zahlen rechts vom
VElement.
Hier noch Mergesort ergänzen
55
5 Suchverfahren
“Sehr viele und vielleicht die meisten Menschen müssen, um etwas zu finden, erst wissen,
dass es da ist..” ( Georg Christoph Lichtenberg)
In diesem Kapitel geht es darum, einen bestimmten Schlüssel (Name, Zahl, o.ä.) innerhalb einer Reihe
von Daten zu prüfen, ob ein gewisser Schlüssel überhaupt enthalten ist. So könnten wir z.B. daran
interessiert sein im Telefonbuch von Paris (mit Umland 12,4 Millionen Einwohner) einen bestimmten
Namen zu finden. In diesem Fall wäre es natürlich hilfreich, dass unsere Reihe (= das Telefonbuch)
schon vorsortiert ist. Davon können wir aber nicht immer ausgehen und daher starten wir mit dem
naheliegendsten Verfahren.
5.1 Sequentielle Suche
Eine sehr simple Methode ist es, einfach von vorne bis hinten alles durchzugehen und jedes Element der
Liste mit dem Schlüssel zu vergleichen bis wir am Ende des Feldes angelangt sind. Diese Vorgehensweise
wird sequentielle Suche oder auch lineare Suche genannt.
Auch wenn diese Methode zunächst sehr stupide klingt, so benötigt sie doch für ein Array der Länge
n höchsten n Vergleiche, d.h. im worst-case ergibt sich ein Aufwand der Art O(n) .
Im besten Falle ist das erste untersuchte Element der Liste schon gleich dem gesuchten Schlüssel
und dann wären wir mit einem einzigen Vergleich fertig, d.h. eine Vergrößerung von n hätte überhaupt
keine Auswirkung. Es ergibt sich ein zeitlicher Aufwand der Art O(1) .
5.2 Binäre Suche
Die bisherige Methode der sequentiellen Suche setzte die Tatsache, dass die Liste vorsortiert ist, nicht
voraus, profitiert von einer Vorsortierung aber auch in keinster Weise. Dies lässt erwarten, dass man
bei einem vorsortierten Feld eine schnellere Suchmethode finden kann. Ein mögliches Verfahren ist die
binäre Suche. Bei der binären Suche wählt man das Element in der Mitte der Liste und vergleicht
es mit dem Suchschlüssel. Entsprechen sich die beiden Daten ist der Schlüssel gefunden worden. Ist
der Suchschlüssel kleiner als das mittlere Element kann man sich die Suche im rechten Teil der Liste
sparen und muss nur noch im linken Teil weitersuchen. Entsprechend reduziert sich die Suche auf den
rechten Teil der Liste falls der Suchschlüssel größer als das mittlere Element ist.
BILD
Im nächsten Schritt setzt man diese Strategie fort, d.h. aus der neuen Hälfte bestimmt man erneut
das mittlere Element, vergleicht es und setzt die Suche in der entsprechenden neuen Hälfte fort bis
man bei einem letzten, einzelnen Element angekommen ist. Entweder findet man dann schließlich den
Schlüssel durch diesen letzten Vergleich oder der Suchschlüssel war überhaupt nicht in den gegebenen
Elementen enthalten.
Implementation der binären Suche in Java
Die Umsetzunge der binären Suche kann entweder mit Hilfe von rekursiven Methoden oder durch
geschickten Einsetz von Schleifen umgesetzt werden. Rest
56
5 Suchverfahren
A : Der Datentyp Integer
überarbeiten
Seltsamkeiten bei der Fakultät in Java
Unsere bisherigen Programme zum Berechnen der Fakultät ( egal ob nun iterativ oder rekursiv ) zeigen
alle einem gewissen n einen seltsamen Fehler. Beginnend ab F akultät(17) sind die ausgegebenen Zahlen
negativ, obwohl es laut Programmablauf dafür keinen Grund gibt. Der Grund ist ein wenig versteckt
und liegt am verwendeten Datentyp Integer. Klar ist ja, dass jede Integerzahl irgendwo im SpeicherRAM des Computers abgelegt wird. Dabei hat man dem Datentyp Integer eine gewisse feste Zahl an
Bits ( = kleinste Informationseinheit im Speicher, entweder 0 oder 1 ) vorgegeben. Standardmäßig
verwendet Java 32 Bit für jede Integerzahl.
B : Die Klasse String
C : Löschen im Baum in Java
Das Löschen von Knoten in einem binären Suchbaum erfordert eine Fallunterscheidung ( siehe Abschnitt »Die Methode entfernen()« im Kapitel 3.4 ). Diese findet sich auch in einer Implementation
in Java wieder. Die Methode entferne(int zahl) wird mit einem zu löschenden Wert aufgerufen und
sollte der Wert gar nicht im Baum vorhanden sein, endet die Methode mit der Rückgabe von false.
Der Aufbau benutzt zwei Zeiger ( aktuell und vater ), die sich durch den Baum bewegen, bis
aktuell auf das zu löschenden Knoten zeigt und vater auf den passenden Vorgänger. Diese beiden
Zeiger verraten aber noch nicht, ob später der rechte oder der linke Zeiger von Vorgängerknoten zu
ändern ist. Darum benutzt das Programm trickreich eine weitere boolesche Variable namens vonLinks
, die bei jeder Bewegung des Zeigers aktuell angepasst wird.
public boolean entferne(int zahl){
Knoten aktuell = wurzel;
Knoten vater = wurzel;
boolean vonLinks = true;
while( aktuell.gibZahl() != zahl)
{
vater = aktuell;
if(zahl < aktuell.gibZahl())
// nach links?
{
vonLinks = true;
aktuell = aktuell.gibLinks();
}
else
// oder doch rechts?
{
vonLinks = false;
aktuell = aktuell.gibRechts();
}
if(aktuell == null)
return false;
}
// zahl war nicht enthalten, false zurückgeben
// end while
57
5 Suchverfahren
An dieser Stelle schließt sich die schon beschriebene Fallunterscheidung an mitsamt den entsprechenden Anpassungen des Vaterknotens. Ob beim zu löschenden Knoten ein Blatt vorliegt wird dadurch
geprüft, dass beide Zeiger gleichzeitig ( und-Verknüpfung mit && ) gleichzeitig Nullzeiger sind.
// zu löschen Knoten wurde gefunden
// 1. Fall : keine Nachfolger, einfach löschen
if(aktuell.gibLinks()==null && aktuell.gibRechts()==null)
{
if(aktuell == wurzel)
wurzel = null;
else
if(vonLinks)
vater.setzeLinks( null );
// den passenden Zeiger abändern auf null
else
vater.setzeRechts( null );
}
Beim zweiten Fall (genau ein Nachfolger) ist ein wenig mehr Arbeit nötig, da ja noch unterschieden
werden muss, ob der eine Nachfolger links oder rechts vom zu löschenden Knoten sitzt.
// 2. Fall : Variante A, nur links ein Nachfolger
else if(aktuell.gibRechts()==null)
if(aktuell == wurzel)
wurzel = aktuell.gibLinks();
else
if(vonLinks)
vater.setzeLinks( aktuell.gibLinks() );
else
vater.setzeRechts( aktuell.gibLinks() );
// 2. Fall : Variante B, nur rechts ein Nachfolger
else if(aktuell.gibLinks()==null)
if(aktuell == wurzel)
wurzel = aktuell.gibRechts();
else
if(vonLinks)
vater.setzeLinks( aktuell.gibRechts() );
else
vater.setzeRechts( aktuell.gibRechts() );
Schlussendlich noch der letzte Teil, der das Löschen behandelt, wenn es zwei Nachfolger gibt. Wie
bei den bisherigen Abschnitten auch, empfiehlt es sich das Vorgehen Zeile für Zeile an einem konkreten
Baum durchzugehen.
// 3. Fall : zwei Nachfolger, dann durch kleinstes Element im rechten Teilbaum ersetzen
else
{
// erst einmal Nachfolger von zu löschendem Knoten ( aktuell ) holen
Knoten nachfolger = gibNachfolger(aktuell);
// jetzt den Vater mit dem Ersatz verbinden
if(aktuell == wurzel)
wurzel = nachfolger;
else
58
5 Suchverfahren
if(vonLinks)
vater.setzeLinks( nachfolger );
else
vater.setzeRechts( nachfolger );
// und an das Ersatzelement noch den linken Teilbaum
nachfolger.setzeLinks( aktuell.gibLinks() );
}
// Ende else mit zwei Nachfolgern
return true;
}
// ende von entfernen
Der 3. Fall benutzt zur Suche des kleinsten Elements im rechten Teilbaum selbst wieder eine Methode
namens gibNachfolger(), die letztlich vom übergebenen Knoten nur einmal rechts und dann so oft
wie möglich links abbiegt. Durch diesen Weg erreicht man genau die gewünschte kleinste Zahl.
public Knoten gibNachfolger(Knoten löschKnoten){
Knoten nachfolgerVater = löschKnoten;
Knoten nachfolger = löschKnoten;
Knoten aktuell = löschKnoten.gibRechts();
while(aktuell != null){
// erst nach rechts in den rechten Teilbaum
// nach links bis nichts mehr kommt
nachfolgerVater = nachfolger;
nachfolger = aktuell;
aktuell = aktuell.gibLinks();
}
if(nachfolger != löschKnoten.gibRechts() )
{
nachfolgerVater.setzeLinks( nachfolger.gibRechts() );
nachfolger.setzeRechts( löschKnoten.gibRechts() );
}
return nachfolger;
}
C : Die Klasse ArrayList
Lorem Ipsum
D : Die Binet’sche Formel für Fibonacci-Zahlen
Im Abschnitt 1.2 ( siehe dort ) kamen wir beim Ansatz Fn = a · bn auf zwei mögliche Lösungen für b :
b2
b
1=0
r
1
1
b= ±
2
4
r
1
5
b= ±
2
4
p
1+ 5
b=
⇡ 1, 618
2
_
59
b=
( 1)
1
p
2
5
⇡
0, 618
5 Suchverfahren
p
Nur die erste Lösung b = = 1+2 5 wurde damals weiterverfolgt.
Geben
wir der zweiten Lösung auch einen schicken griechischen Buchstaben und nennen sie
=
p
1
5
n
n
oder auf Fn = a2 ·
( wobei wir die Vorfaktoren a1 und a2
2 . Dann kämen auf Fn = a1 ·
natürlich noch nicht kennen. ) . Durch Addition erstellen wir daraus eine gemeinsame Lösung :
n
Fn = a 1 ·
+ a2 ·
n
Insbesondere muss diese Formel für n = 0 und n = 1 stimmen, daher setzen wir zunächst n = 0 ein :
,
0 = F0 = a 1 + a 2
a2 =
a1
(I)
Die Vorfaktoren unterscheiden sich also nur um ein Minuszeichen. Wir setzen n = 1 ein:
1 = F1 = a 1 + a 2
(II)
Da wir und
als Zahlenwerte kennen, haben wir ein lineares Gleichungssystem für die noch fehlenden Variablen a1 und a2 . Mit der Zeile (I) können wir a2 in (II) ersetzen :
1 = a1 + a2
= a1
Aufgelöst nach a1 :
a1
= a1 (
)
1
a1 =
und mit umgekehrtem Vorzeichen :
a2 =
1
a1 =
Die im Nenner auftretende Differenz lässt sich leicht berechnen :
p
p
p
1+ 5 1
5
2 5 p
=
=
= 5
2
2
2
Alles in allem ergibt sich :
Fn = a 1 ·
1
Fn = p ·
5
n
+ a2 · n
1
n
p ·
5
n
1
=p (
5
n
n
)
Ersetzen wir zu guter Letzt noch die exakten Zahlenwerte, so kommen wir zu einer Formel für die n-te
Fibonacci-Zahl, die bereits im Jahr 1718 von deMoivre bewiesen wurde :
p !n
p !n !
1
1+ 5
1
5
Fn = p
2
2
5
60
Herunterladen