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