Algorithmen Datenstrukturen Funktionale Programmierung
Jürgen Wolff von Gudenberg unter Mitarbeit von Jens Klöcker
Al...
55 downloads
1581 Views
1MB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
Algorithmen Datenstrukturen Funktionale Programmierung
Jürgen Wolff von Gudenberg unter Mitarbeit von Jens Klöcker
Algorithmen Datenstrukturen Funktionale Programmierung Eine praktische Einführung mit Caml Light
Die Deutsche Bibliothek – CIP-Einheitsaufnahme Wolff von Gudenberg, Jürgen Frhr.: Algorithmen, Datenstrukturen, funktionale Programmierung: Eine praktische Einführung mit Caml Light / von Jürgen Wolff von Gudenberg. - Bonn: Addison-Wesley, 1996 ISBN 3-8273-1056-3
c 1996 Addison Wesley Longman Verlag GmbH Satz: Jens Klöcker, Würzburg. Gesetzt mit LATEX 2 Belichtung, Druck und Bindung: Bercker Graphischer Betrieb, Kevelaer Produktion: Claudia Lucht Umschlaggestaltung: Tandem Design, Berlin
Das verwendete Papier ist aus chlorfrei gebleichten Rohstoffen hergestellt und alterungsbeständig. Die Produktion erfolgt mit Hilfe umweltschonender Technologien und unter strengsten Auflagen in einem geschlossenen Wasserkreislauf unter Wiederverwendung unbedruckter, zurückgeführter Papiere. Text, Abbildungen und Programme wurden mit größter Sorgfalt erarbeitet. Verlag, Übersetzer und Autoren können jedoch für eventuell verbliebene fehlerhafte Angaben und deren Folgen weder eine juristische Verantwortung noch irgendeine Haftung übernehmen. Die vorliegende Publikation ist urheberrechtlich geschützt. Alle Rechte vorbehalten. Kein Teil dieses Buches darf ohne schriftliche Genehmigung des Verlages in irgendeiner Form durch Fotokopie, Mikrofilm oder andere Verfahren reproduziert oder in eine für Maschinen, insbesondere Datenverarbeitungsanlagen, verwendbare Sprache übertragen werden. Auch die Rechte der Wiedergabe durch Vortrag, Funk und Fernsehen sind vorbehalten. Die in diesem Buch erwähnten Soft- und Hardwarebezeichnungen sind in den meisten Fällen auch eingetragene Warenzeichen und unterliegen als solche den gesetzlichen Bestimmungen.
Vorwort Dieses Buch stellt grundlegende Algorithmen und Datenstrukturen in einer Form vor, die auch für Anfänger verständlich ist. Vorkenntnisse aus der Informatik, insbesondere die Kenntnis einer Programmiersprache, werden nicht vorausgesetzt. Unser Ziel ist eine praktische Einführung, in der dem Leser mit einer Vielzahl von Beispielen die wesentlichen Ideen, die hinter den gängigen Verfahren stecken, nahegebracht werden. Er soll aber auch das Handwerkszeug zur Analyse begreifen und anwenden lernen. Da die verwendete Beschreibungssprache vom Rechner interpretiert werden kann, lassen sich alle Algorithmen direkt ausprobieren. Weil andererseits die Algorithmen und die Eigenschaften der Datenstrukturen durch Funktionen und Wertemengen auf relativ abstrakter Ebene beschrieben werden, bietet das Buch auch dem theoretisch Interessierten einen Einstieg in Methoden, die Korrektheit von Algorithmen zu beweisen und ihre Laufzeit abzuschätzen. Für die funktionale Sichtweise, in der ein Programm oder ein Algorithmus eine Funktion ist, die für eine Eingabe eine Ausgabe berechnet, und die Verwendung von CAML LIGHT sprechen folgende Argumente: Der hohe Abstraktionsgrad erlaubt übersichtliche Programme. Funktionale Sprachen werden zur Spezifikation beim »Programmieren im Großen« verwendet und deshalb in Zukunft an Gewicht gewinnen. Die Semantik ist klar zu formulieren. Die Rekursion, eines der wesentlichen Hilfsmittel der Informatik, wird von Anfang an behandelt. Datenstrukturen lassen sich so eingeben, wie sie definiert sind. Bei Algorithmen ist die funktionale Spezifikation ausführbar. Durch Interpretation ist die direkte Interaktion zwischen Benutzer und Rechner gewährleistet.
6
Im ersten Teil des Buches werden die Algorithmen mit der Sprache CAML LIGHT kurz, prägnant und präzise im Sinne eines Pseudocodes entworfen. Auf diese Weise wird der Leser mit der funktionalen Programmierung vertraut gemacht. Der Vorteil dieser Vorgehensweise ist, daß der Pseudocode vollständige Programme beschreibt und somit die Entwürfe direkt ausführbar sind. Der zweite Teil bietet eine tutorielle Einführung in die Sprache CAML LIGHT und im letzten Kapitel eine vollständige Beschreibung des Sprachkerns. Die Sprache verfügt darüber hinaus über eine Vielzahl von Erweiterungsmodulen, die von der graphischen Darstellung einer Funktion bis zum animierbaren WWW-Browser reichen. Deren Behandlung würde den Rahmen dieses Buches bei weitem sprengen. CAML LIGHT ist eine leicht portierbare, typisierte funktionale Sprache, die interpretiert wird, aber bei Bedarf auch kompiliert werden kann. Das CAML LIGHTSystem wurde von der INRIA Rocquencourt entwickelt und ist frei und kostenlos erhältlich. Wir empfehlen dem Leser, sich Zugang zu einem CAML LIGHT-System zu verschaffen und während des Lesens die Beispiele durchzuarbeiten (siehe Anhang A.4).
Das Buch ist entsprechend aufbereitet. Die Beispiele wurden während des Setzens dem CAML LIGHT-Interpreter zugeleitet – das wird durch eine schreibende Hand ✍ verdeutlicht – und dessen Ausgaben – mit einem ausgestreckten Zeigefinger ☞ gekennzeichnet – in den Text eingefügt. In diesem Sinne ist das Buch ein interaktiv entstandenes Dokument. Um diese Interaktion nachvollziehen zu können, sollte sich der Leser die Beispiele vom WWW-Server des Verlages laden. Die Vorgehensweise ist in Anhang B beschrieben. Entsprechend seiner Gliederung in zwei Teile kann das Buch auf verschiedene Art gelesen werden. Der vornehmlich an Algorithmen und Datenstrukturen interessierte Leser wird mit dem ersten Teil vorlieb nehmen. Will er gleichzeitig noch etwas tiefer in die funktionale Programmierung einsteigen, wird ein verschränktes Vorgehen empfohlen, etwa in der Kapitelreihenfolge 1, 9, 10, 2, 3, 11, 12, 4, 5, 6, 7, 13, 8, 14. Kapitel 15 dient zum Nachschlagen. Hat ein Leser schon Vorkenntnisse über Algorithmen und will die funktionale Programmierung erlernen, kann er mit Teil 2 beginnen und die zitierten Beispiele bei Bedarf im ersten Teil nachschlagen. Dieses Buch ist aus der Grundvorlesung Praktische Informatik 1 an der Universität Würzburg entstanden, deren Inhalt eine Einführung in Algorithmen und Datenstrukturen ist. Allen, die zu ihrer Gestaltung und damit zum Gelingen des Bu-
7
ches beigetragen haben, sei herzlich gedankt. Hier sind vor allem Markus Klingspor, Jochen Seemann und Jens Klöcker zu nennen. Ganz besonders hervorzuheben ist der Beitrag von Jens Klöcker zu diesem Buch. Nicht nur, daß er die Umsetzung des Manuskriptes in die endgültige Form mit bewundernswerter Akribie besorgte oder einige Beispiele beisteuerte; er entwarf im Rahmen seiner Diplomarbeit auch den hier vorgestellten Heapsort-Algorithmus sowie weitere Einzelheiten und steuerte den Anhang über das CAML LIGHTSystem bei. Die Syntaxdiagramme in Kapitel 15 wurden mit einem von ihm entwickelten CAML LIGHT-Programm automatisch erzeugt. Auch meine Familie möchte ich in meinen Dank mit einbeziehen. So ließen mich Thilo, Diana und Laila trotz neuer interessanter Spiele ab und zu an meinen Rechner, und meine Frau Anna unterstützte das Buchprojekt in voller Hinsicht. Dem Addison-Wesley Verlag danke ich für die Aufnahme des Buches in seine Lehrbuchreihe und für die gute Zusammenarbeit mit den Lektoren Dr. Bernd Knappmann und Fernando Pereira. Würzburg, im Juli 1996
Jürgen Wolff von Gudenberg
Inhaltsverzeichnis T EIL I Kapitel 1
A LGORITHMEN
Der Algorithmusbegriff . . . . Programmentwicklungszyklus Programmierparadigmen . . . Von Quotienten und Teilern . . Darstellung von Algorithmen . Eigenschaften von Algorithmen
15 . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
Vollständige Induktion . . Einfache Endrekursion . . Schrittweise Verfeinerung Bottom-Up-Entwurf . . . Divide & Conquer . . . . . Iteration und Rekursion .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
Datenstrukturen im Überblick Funktionstypen . . . . . . . . Datenstrukturen . . . . . . . . Typkonstruktion . . . . . . . . Rekursive Datentypen . . . . Parametrisierte Typen . . . . Abstrakte Datentypen . . . .
Listen und ihre Implementierung 4.1 4.2
15 20 21 26 30 38 47
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
Datenstrukturen und Datentypen 3.1 3.2 3.3 3.4 3.5 3.6 3.7
Kapitel 4
13
Entwurf und Analyse von Algorithmen 2.1 2.2 2.3 2.4 2.5 2.6
Kapitel 3
D ATENSTRUKTUREN
Algorithmen und Programmierung 1.1 1.2 1.3 1.4 1.5 1.6
Kapitel 2
UND
47 49 57 62 66 73 85
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
85 87 88 93 95 96 97 101
Listen als abstrakte Datentypen . . . . . . . . . . . . . . . 101 Listen als Felder . . . . . . . . . . . . . . . . . . . . . . . . 109
10
Inhaltsverzeichnis
4.3 4.4 4.5 4.6 4.7 Kapitel 5
Kapitel 9
Einführung . . . . . . . . . . Elementare Sortierverfahren Sortieren durch Mischen . . Quicksort . . . . . . . . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
Bäume . . . . . . . . . . . . . . . . . . Der Heap als Prioritätswarteschlange Heapsort . . . . . . . . . . . . . . . . . Suchbäume . . . . . . . . . . . . . . . . AVL-Bäume . . . . . . . . . . . . . . . Selbstanordnende Bäume . . . . . . . 2-3-4-Bäume . . . . . . . . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
125 126 132 139 143
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
143 153 160 162 166 175 179
Definition und Datenstruktur . . . . . . . . . . . . . . . . 189 Offene Hashtabellen . . . . . . . . . . . . . . . . . . . . . 190 Kollisionsauflösung innerhalb der Tabelle . . . . . . . . . 194 201
Backtracking-Algorithmen . . . . . . . . . . . . . . . . . . 201 Branch & Bound-Verfahren . . . . . . . . . . . . . . . . . 210 Greedy-Algorithmen . . . . . . . . . . . . . . . . . . . . . 220
E INFÜHRUNG
IN
C AML L IGHT
Ausdrücke und Funktionen 9.1
113 114 117 118 121
189
Systematisches Probieren 8.1 8.2 8.3
T EIL II
. . . . .
125
Hashverfahren 7.1 7.2 7.3
Kapitel 8
. . . . .
Bäume und Suchbäume 6.1 6.2 6.3 6.4 6.5 6.6 6.7
Kapitel 7
. . . . .
Sortierverfahren 5.1 5.2 5.3 5.4
Kapitel 6
Verkettete Listen . . . . . . . . . . . . . Rekursive Listen . . . . . . . . . . . . . . Vergleich der Listenimplementierungen Keller oder Stapel . . . . . . . . . . . . . Schlangen . . . . . . . . . . . . . . . . .
225 227
Konstanten und Ausdrücke . . . . . . . . . . . . . . . . . 228
Inhaltsverzeichnis
9.2 9.3 Kapitel 10
11
Vereinbarung und Aufruf einfacher Funktionen . . . . . 233 Testen und Fehlerabbruch . . . . . . . . . . . . . . . . . . 243
Vordefinierte strukturierte Datentypen
249
10.1 Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 249 10.2 Paare und Tupel . . . . . . . . . . . . . . . . . . . . . . . . 255 10.3 Vektoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257 Kapitel 11
Definition neuer Typen 11.1 11.2 11.3 11.4
Kapitel 12
Vorhandene Typkonstruktoren Typoperatoren . . . . . . . . . . Verbunde . . . . . . . . . . . . . Varianten . . . . . . . . . . . . .
261 . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
Funktionen höherer Ordnung
261 263 265 267 271
12.1 Curry-Funktionen . . . . . . . . . . . . . . . . . . . . . . . 271 12.2 Funktionen höherer Ordnung . . . . . . . . . . . . . . . . 274 Kapitel 13
Module
287
13.1 Abstrakte Datentypen und Datenkapselung . . . . . . . . 287 13.2 Kernmodule . . . . . . . . . . . . . . . . . . . . . . . . . . 289 13.3 Standardmodule . . . . . . . . . . . . . . . . . . . . . . . . 293 Kapitel 14
Imperative Konstrukte 14.1 14.2 14.3 14.4 14.5
Kapitel 15
Ein- und Ausgabe . . . . . . . . . Anweisungsfolgen . . . . . . . . Referenzen und Speichervariable Ausnahmen . . . . . . . . . . . . Schleifen . . . . . . . . . . . . . .
Syntax und Semantik
299 . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
299 301 302 306 308 309
15.1 Formale Darstellung . . . . . . . . . . . . . . . . . . . . . 309 15.2 Syntaxdiagramme und informelle Semantik . . . . . . . . 312 Anhang A
Das Caml Light-System
331
12
Inhaltsverzeichnis
A.1 A.2 A.3 A.4
Interpreter . . . . . . Compiler . . . . . . . Sonstige Werkzeuge . Verfügbarkeit . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
331 334 336 337
Anhang B
Beschaffung der Beispiele
339
Anhang C
Verzeichnis der Algorithmen
341
Anhang D
Literatur
345
Teil I Algorithmen und Datenstrukturen
Kapitel 1 Algorithmen und Programmierung Der Entwurf von Algorithmen, ihre Programmierung und der Umgang mit Datenstrukturen gehören zum Handwerkszeug eines jeden Informatikers. Wir wollen in diesem Buch sowohl Algorithmen als auch Datenstrukturen vornehmlich aus funktionaler Sicht betrachten. Die Spezifikation eines Algorithmus’ und die wesentlichen Eigenschaften einer Datenstruktur werden auf relativ abstrakter Ebene durch Funktionen beschrieben, d. h. ein Programm oder ein Algorithmus ist eine Funktion, die für eine Eingabe eine Ausgabe berechnet. So wird es möglich sein, viele Algorithmen auf hohem Niveau, ohne eine Vielzahl von Details beachten zu müssen, präzise und übersichtlich zu formulieren und trotzdem direkt auf einem Rechner ablaufen zu lassen. Die Programmierung wird also nicht zu kurz kommen. Da wir auch hier eine funktionale Sprache – CAML LIGHT – verwenden, ist der Schritt vom theoretischen Entwurf bis zur ausführbaren Funktion äußerst klein, sofern überhaupt ein Unterschied besteht.
1.1
Der Algorithmusbegriff
Zuerst wollen wir erläutern, was wir unter einem Algorithmus verstehen. Die Herkunft des Wortes gibt uns keine Möglichkeit zu einer Übersetzung. Der Begriff wurde aus dem Namen des persischen Mathematikers und Astronomen A L H WÂRIZMÎ hergeleitet. Er bezeichnet ein systematisches, reproduzierbares Problemlösungsverfahren und ist keineswegs auf Mathematik oder Informatik beschränkt. Auch im täglichen Leben haben wir vielfach mit Algorithmen zu tun – Kochrezepte, Bauanleitungen, Gebrauchsanweisungen oder Bedienungsanleitungen seien hier als Beispiele genannt.
16
Kapitel 1
Algorithmen und Programmierung
Ein Algorithmus ist, da nachvollziehbar und »mechanisch« ausführbar, eine gute Grundlage für ein Programm. Der Algorithmenentwurf ist also eine Vorstufe zur Programmierung. Wir betrachten nun eines der angeführten Beispiele etwas genauer. A L G O R I T H M U S 1.1 N USSKUCHEN -1 Z UTATEN : 300 g Zucker, 9 Eier, 300 g Nüsse, Semmelbrösel, Marmelade, 1 Zitrone. M ETHODE : Zucker, Eigelb und Zitronensaft schaumig rühren, geriebene Nüsse und Semmelbrösel dazugeben, Eischnee unterziehen, 50–80 Minuten backen, auseinanderschneiden und mit Marmelade füllen. – Alg. 1.1 – Nun soll dieses Buch natürlich kein Backbuch werden. Deshalb wollen wir hier auch eine erste, einfache mathematische Aufgabe lösen, nämlich die Summe von gegebenen ganzen Zahlen bestimmen. Eine erste Formulierung des Summationsalgorithmus’ lautet A L G O R I T H M U S 1.2 S UMME -1 E INGABE : eine Menge von ganzen Zahlen. A USGABE : deren Summe. M ETHODE : Nimm eine Zahl nach der anderen und bilde ihre Summe. – Alg. 1.2 –
1.1
Der Algorithmusbegriff
17
Das ist allerdings noch recht vage und nicht viel mehr als eine Umformulierung der Aufgabe. Wir müssen noch festlegen, wie eine Zahl nach der anderen genommen werden soll. Wir brauchen einen Zugriff auf einzelne Zahlen. Setzen wir eine Funktion voraus, die eine Zahl aus der Ausgangsmenge auswählt, und legen fest, daß die Summe von null Zahlen gleich ist, dann kommen wir zu folgendem Verfahren: A L G O R I T H M U S 1.3 S UMME -2 E INGABE : eine Menge von ganzen Zahlen. A USGABE : deren Summe. M ETHODE : Falls die Menge leer ist, so ist ihre Summe . Sonst wähle eine Zahl aus und addiere ihren Wert zur Summe der restlichen Elemente. – Alg. 1.3 – Einen solchen Algorithmus, der sich selbst wieder aufruft, nennen wir rekursiv. Damit werden wir uns noch ausführlich beschäftigen. Nun leiten wir aus den zwei Beispielen mögliche Merkmale eines Algorithmus’ ab: Es gibt Zutaten oder Eingaben: – Eier, Zucker, Nüsse, – eine Menge von ganzen Zahlen. Es wird ein Ergebnis oder eine Ausgabe geliefert: – ein Kuchen, – die Summe der Elemente. Verschiedene Funktionseinheiten treten in Aktion: – Backofen, Küchenmaschine, – Addition.
18
Kapitel 1
Algorithmen und Programmierung
Es findet eine Zerlegung in einfachere Vorschriften (Teilalgorithmen) statt: – Eischnee schlagen, Teig rühren, Backofen einschalten, – Test auf leere Menge. Der Grad dieser Zerlegung hängt vom Kenntnisstand und der Ausrüstung des Handelnden ab: – Das Schlagen von Eischnee ist für Geübte klar. Anfänger sollten wissen, daß man vor dem Schlagen Eigelb und Eiweiß trennen muß. – Die ganzzahlige Addition ist bekannt. Die elementaren Operationen werden ausgeführt: – Eier aufschlagen, Nüsse mahlen, – Zahl addieren. Eine angepaßte Datenstruktur ist wichtig: – Wir brauchen eine Repräsentation der Zahlenmenge. Eine Zustandsänderung tritt ein: – Aus den Zutaten wird ein Kuchen. – Die Summe wird berechnet. Für einige Funktionen ist die zeitliche Abfolge wichtig: – Man muß zuerst den Teig rühren und dann Eischnee unterziehen. Verschiedene Fälle werden unterschieden: – Eine Menge kann leer sein, oder nicht. Wiederholungen der gleichen Operation werden durchgeführt: – Man muß 9 Eier aufschlagen. – Implizit enthält auch der Summationsalgorithmus eine Wiederholung, denn er wird ja für die restlichen Zahlen wieder aufgerufen. Es können Nebenwirkungen auftreten: – Die Küche wird verschmiert. Die Zutaten sind verbraucht. Der Algorithmus terminiert: – Beim Nußkuchen nach Ablauf der Backzeit.
1.1
Der Algorithmusbegriff
19
– Da endlich viele Zahlen gegeben sind und die Restmenge in jedem Schritt ein Element weniger enthält, wird sie am Ende schließlich leer sein und der Algorithmus mit Ausführung der ersten Alternative terminieren. Insbesondere die Nebenwirkungen sind natürlich unerwünscht. Die Beschreibung erfolgte hier in der angemessenen Fachsprache. Oft werden zusätzlich Bilder oder graphische Darstellungen eingesetzt. Wir werden in diesem Kapitel verschiedene Fachsprachen für Algorithmen aus dem Bereich der Informatik vorstellen und verwenden. Hierzu gehören auch graphische Repräsentationen. Eine Klasse solcher Fachsprachen – die funktionalen Sprachen – stellen den Aufruf von Funktionen in den Mittelpunkt. Als Beispiel noch einmal das Backrezept, jetzt im »Küchenfunktionalkauderwelsch«: A L G O R I T H M U S 1.4 N USSKUCHEN -2 M ETHODE : Rufe für die Zutaten die Funktion »Nußtorte backen« auf. Oder eine Stufe genauer: 1. Rufe auf »Nüsse mahlen«. 2. Rufe auf »Teigrühren« für Eigelb, Zitrone und Zucker. 3. Rufe auf »Eischnee schlagen« für Eiweiß 4. Rufe auf »Zusammenrühren« für die Ergebnisse von 1, 2 und 3. 5. Rufe auf »Backen« für das Ergebnis von 4. – Alg. 1.4 – Und unser Summationsbeispiel liest sich nun in funktionalem Pseudocode so: A L G O R I T H M U S 1.5 S UMME -3 M ETHODE : sei die Menge der Zahlen, hier dargestellt als Liste. Wir wählen immer de
ren erstes Element aus und bezeichnen es mit , die Restliste nennen wir und teste, ob die Liste leer sei.
20
Kapitel 1
✍
Algorithmen und Programmierung
– Alg. 1.5 – Zum Schluß dieses Abschnitts wiederholen wir noch einmal unsere erste Definition eines Algorithmus’. D EFINITION 1.1 Ein Algorithmus ist ein systematisches, reproduzierbares Problemlösungsverfahren.
1.2
Programmentwicklungszyklus
Algorithmenentwurf ist ein wesentlicher Teil des Lösungsvorgangs zu einem Problem. Wir gehen hier nur von Problemen aus, deren Lösung auf einem Computer bestimmt werden kann. Dann läßt sich der Problemlösungsprozeß, den man jetzt auch als Programmentwicklungsprozeß betrachten kann, grob in fünf Schritte untergliedern: Analyse des Problems, genaue Spezifikation, Modellbildung, Entwurf von Algorithmen und zugehörigen Datenstrukturen, Implementierung in einer Programmiersprache, Organisation, d. h. Einbau in existierende Rechnerumgebung, Tests, Unterstützung, Optimierung und Wartung. Dieses Vorgehensmodell, das wir in Anlehnung an die Anfangsbuchstaben der einzelnen Schritte das Vokalmodell nennen, wird nicht nur ein einziges Mal durchlaufen. Man wird in der Regel in jedem Schritt Fehler machen oder falsche Entscheidungen treffen, die erst im nächsten oder einem späteren Schritt bemerkt werden und deshalb ein Zurückgehen erfordern. Der ganze Prozeß läuft also zyklisch ab. Wir konzentrieren uns in diesem Buch auf den zweiten Schritt und dort wiederum auf Standardprobleme der Informatik wie z.B. Listenverwaltung und Sortierverfahren, die zwar sicher schon in einigen hundert Versionen existieren, aber auch ständig gebraucht und auf neue Situationen angepaßt werden müssen und die deshalb von jedem Informatiker beherrscht werden sollten.
1.3
Programmierparadigmen
21
Um eine zu trockene Vorgehensweise zu vermeiden, wollen wir auch den dritten Schritt behandeln: die Implementierung in einer Programmiersprache. Da dies auf möglichst hohem, abstraktem Niveau geschehen soll, um nicht durch Implementierungsdetails den Blick auf das Wesentliche zu verdecken, wählen wir die funktionale Programmiersprache CAML LIGHT, die uns zuerst als Pseudocode in der Algorithmenbeschreibung begegnen wird. Der Vorteil einer funktionalen Programmiersprache liegt auch darin, daß sowohl die formale Spezifikation als auch die Algorithmendarstellung funktional erfolgen kann, und somit kein Bruch in der Darstellung der ersten drei Schritte vorliegt. Diese Schritte werden in der Praxis immer verzahnt ablaufen – es gibt Entwicklungszyklen. Der vierte Schritt, der seinen Namen »Organisation« nur wegen des Vokalmodells erhielt, validiert gewissermaßen durch Tests die Gültigkeit des Algorithmus’. Er ist der erste für den ein Computereinsatz zwingend nötig ist. Natürlich kann ein Test nie die Korrektheit eines Programms beweisen. Wir werden deshalb beim Entwurf schon sehr sorgfältig vorgehen und versuchen, alle Sonderfälle zu berücksichtigen. Ein formaler Beweis der Korrektheit von Algorithmen würde jedoch in den meisten Fällen den Rahmen dieser Einführung sprengen. Besonders der fünfte Schritt wird immer wieder unterschätzt, obwohl in der Praxis oft mehr als 50 % des Aufwandes in ihm stecken. Stellen wir die ersten vier Schritte noch einmal zusammen: 1. Analyse des Problems und eventuell genauere Darstellung (Spezifikation). 2. Herausfinden eines Lösungsweges und Entwicklung eines Algorithmus’. 3. Übersetzung des Algorithmus’ in eine computerverständliche Form. 4. Einsatz des Computers zur Erstellung der Lösung. Wir konzentrieren uns hier auf Schritt 2. Bei den behandelten Problemen ist die genaue Spezifikation offensichtlich. Wir werden unsere formale Darstellung von Algorithmen sehr nahe an der Sprache CAML LIGHT wählen, so daß Schritt 3 trivial ist und wir sofort ausführbare Algorithmen erhalten.
1.3
Programmierparadigmen
Ein Paradigma ist eine Methodologie oder auch Vorgehensweise. In diesem Abschnitt wollen wir das funktionale und das imperative Programmierparadigma gegenüberstellen. Die Definitionen in diesem Kapitel sind bewußt etwas allgemein gehalten.
22
Kapitel 1
1.3.1
Algorithmen und Programmierung
Funktionales Programmieren
Den Begriff des Algorithmus’ als reproduzierbares Problemlösungsverfahren spezialisieren wir wie folgt: D EFINITION 1.2 Ein funktionaler Algorithmus ist ein Problemlösungsverfahren, das durch Hintereinanderausführung von elementaren Funktionen aus gegebenen Anfangswerten Resultate erzeugt. Hier ist natürlich noch einiges offen. Wir wollen von Fall zu Fall festlegen, welches die elementaren Funktionen sind. Wir kommen so zu einer Schachtelung von Funktionen oder zu einer schrittweisen Verfeinerung. So kann z.B. die Bestimmung des größten gemeinsamen Teilers für einen Algorithmus zur Bruchrechnung als elementare Funktion auftreten, die aber ihrerseits wieder durch einen Algorithmus beschrieben wird, der sich auf elementarere Funktionen wie etwa ganzzahlige Division mit Rest abstützt. Zur Vereinbarung einer Funktion gehört die Angabe des Definitions- und des Wertebereiches – es werden die Funktionsargumente eingeführt und in Form eines (arithmetischen oder sonstigen) Ausdrucks die Wirkung der Funktion auf die Argumente beschrieben. Man erhält aus einem Ausdruck, in dem Variablennamen vorkommen, durch Abstraktion eine Funktion, indem man einige Namen zu Parametern oder Argumenten der Funktion erklärt. Betrachten wir ein Beispiel. Aus dem Ausdruck die Nachfolgerfunktion für ganze Zahlen: ✍ ☞
erhält man durch Abstraktion
"!#
Um diese Funktion bequem für unterschiedliche Argumente aufrufen zu können, geben wir ihr einen Namen:
✍ ☞ $%!%&&'(#)#* +!
Mit Angabe des Definitions- und des Wertebereiches lautet die vollständige Funktionsvereinbarung:
1.3
✍ ☞
Programmierparadigmen
23
$! && "!#
oder in gewohnter mathematischer Schreibweise
mit
Die genaue Kommentierung dieser im Schreibmaschinenstil abgesetzten Programmblöcke verschieben wir bis zum Abschnitt 1.5. Innerhalb eines Funktionsausdrucks werden Argumente miteinander, mit benannten oder direkt hingeschriebenen Konstanten und mit Werten, die bei der Anwendung von anderen Funktionen als Ergebnis auftreten, verknüpft. Für die üblichen arithmetischen Operationen wird dabei die infix-Schreibweise verwendet, bei der der Operator zwischen den beiden Operanden steht. Ansonsten wird der Funktionsname vor die Argumente geschrieben. Letztere können selbst wieder das Ergebnis eines Funktionsaufrufes sein, die Funktionen werden verschachtelt aufgerufen oder hintereinander ausgeführt. D EFINITION 1.3 Ein funktionales Programm ist eine Folge von Wertbestimmungen. Darunter versteht man Funktionsaufrufe, die Anfangswerte auf Endwerte abbilden, oder auch Funktionsdefinitionen. Anfangs- und Endwerte können Zahlen, Zeichen, Wahrheitswerte, beliebige Datenstrukturen oder auch Funktionen sein. Eine Funktion wird aus elementaren Operationen oder Funktionen durch Komposition und Fallunterscheidung zusammengesetzt. Die einfachste Fallunterscheidung ist dabei ein bedingter Ausdruck, der einen von zwei möglichen Werten berechnet. Allgemein ist sie eine Funktion, die abhängig von einem Auswahlwert eine von mehreren Funktionen aufruft. Die Ausführung eines Programms bestimmt nacheinander die angegebenen Werte. Dabei kann es sich um Funktionen handeln, die üblicherweise einen Namen erhalten, oder Ausdrucksauswertungen (Funktionsanwendungen), die ebenfalls benannt werden können. A L G O R I T H M U S 1.6 A BSOLUTBETRAG E INGABE : eine ganze Zahl .
24
Kapitel 1
Algorithmen und Programmierung
A USGABE : ihr Absolutbetrag . M ETHODE : Verwende die Funktion:
✍
☞ $ ## * +!#
*
Die Berechnung des Absolutbetrages von
– Alg. 1.6 – wird nun durch den Aufruf
✍
☞ durchgeführt. Ein wichtiges Hilfsmittel ist auch die Rekursion. Damit bezeichnet man die Tatsache, daß sich eine Funktion selbst innerhalb ihrer Definition aufruft. Dieses Verfahren haben wir im Summationsalgorithmus 1.1.5 bereits verwendet.
1.3.2
Imperatives Programmieren
D EFINITION 1.4 Ein imperativer Algorithmus ist ein mit endlich langem Text beschriebenes Problemlösungsverfahren. Es enthält Objekte und Aktionen, wobei jede Aktion eindeutig ausführbar und die Reihenfolge der Aktionen eindeutig festgelegt ist. Aktionen sind Steuerungsaktionen oder Zuweisungsaktionen, die eine Zustandsänderung der Objekte bewirken. Der zentrale Begriff für das imperative Programmieren ist also der des Zustands eines Objekts. Ein Objekt ist im einfachsten Fall eine ganze Zahl, der Zustand ist dann der Wert. Dieses Modell lehnt sich eng an das übliche VON N EUMANNsche Rechnermodell an, in dem Werte für Objekte im Speicher des Rechners gehalten werden. Der Speicherbereich für ein Objekt wird durch eine Variable – einen symbolischen, frei gewählten Namen – angesprochen, seine Größe und die korrekte Interpretation des dort gespeicherten Bitmusters durch seinen Typ bestimmt. D EFINITION 1.5 Ein imperatives Programm beschreibt eine Transformation eines Anfangszustandes des Datenspeichers in einen Endzustand.
1.3
Programmierparadigmen
25
Dabei gehört das Anlegen des Speichers und seine korrekte Initialisierung durchaus zu den Aufgaben des Programms. Die Hintereinanderausführung der Anweisungen legt den Programmablauf fest. Dabei kommen Bedingungen und Wiederholungen (Schleifen) vor. Durch eine Zuweisungsaktion oder Einleseprozedur wird der Wert einer Variablen verändert. Besonders deutlich wird der Unterschied zwischen funktionaler und imperativer Sicht bei der einfachen Aufgabe, zwei Eingabewerte zu vertauschen. Im funktionalen Modell wird eine Funktion definiert: A L G O R I T H M U S 1.7 TAUSCH , FUNKTIONAL E INGABE : zwei Werte. A USGABE : die gleichen Werte in vertauschter Reihenfolge. M ETHODE : Verwende die Funktion:
"$ & "#!
✍ ☞ %!
– Alg. 1.7 – Im imperativen Modell wird eine Hilfsvariable benötigt. A L G O R I T H M U S 1.8 TAUSCH , IMPERATIV E INGABE : zwei Variable, d. h. Wertbezeichner. E RGEBNIS : Jede Variable bezeichne den anderen Wert. M ETHODE : Verwende eine Hilfsvariable und führe die Aktionen
26
Kapitel 1
Algorithmen und Programmierung
aus. – Alg. 1.8 – Die Namen für Wertbezeichner sind im funktionalen Fall relativ unwichtig, so daß man sich nicht darum kümmern wird, welchen Wert nun bezeichnet. Im imperativen Ansatz trägt genau das zur Zustandsbeschreibung bei. Genau genommen sind hier schon die Problemstellungen verschieden. Natürlich kann man aber auch die Aufgabe im jeweils anderen Modell lösen. Das imperative Paradigma liegt näher an der Hardware, das funktionale näher an der logischen Sicht eines Problems. Daraus kann man schließen, daß ein imperatives Programm in der Regel effizienter in Laufzeit und Speicherbedarf ist. In unserem Tauschbeispiel wird im funktionalen Modell in der Tat nicht nur eine Hilfsvariable benötigt, sondern es wird ein neues Zahlenpaar als Funktionsergebnis erzeugt. Daraus kann man aber auch schließen, daß die Programmierung im funktionalen Modell oft einfacher ist und übersichtlichere Programme ermöglicht. Diese Tatsache wollen wir uns in der Folge zunutze machen.
1.4
Von Quotienten und Teilern
Bevor wir unsere allgemeinen Betrachtungen über Algorithmen fortsetzen, wollen wir zwei einfache Beispiele besprechen – die Division mit Rest und die Berechnung des größten gemeinsamen Teilers. Als erstes Beispiel entwerfen wir einen Algorithmus, der für zwei natürliche Zah den ganzzahligen Quotienten und den Rest bestimmt, so len und mit daß
mit
gilt. Als Elementaroperationen stehen Addition und Subtraktion zur Verfügung.
Wir beobachten, daß im Fall die Lösung offensichtlich durch und gegeben ist. Im anderen Fall subtrahieren wir von und berechnen die zu
1.4
Von Quotienten und Teilern
und . Haben wir diese Aufgabe gehörenden für Werte gilt und . Daraus folgt und die Lösung des ursprünglichen Problems.
27
gelöst, so . Somit sind
Es sieht aber so aus, als hätten wir nichts gewonnen. Wir haben eine Aufgabe durch die gleiche Aufgabe mit unterschiedlichen Eingabewerten ersetzt. Wir bleiben hartnäckig und machen trotzdem weiter. Läßt sich das neue Problem wiederum nicht direkt lösen, so kann die Subtraktion von erneut durchgeführt werden. Wir wenden also gleichen Algorithmus rekursiv denvorausgesetzt an. Dieser Prozeß terminiert, weil war und für die fortgesetzte Subtraktion irgendwann einen Wert kleiner als liefert. Diese Problemtransformation kann man als Algorithmus auffassen und dieser führt in der Tat zum Ziel. Für die detaillierte Beschreibung wollen wir die zugehörige Funktion in zwei Schritten entwickeln. Im ersten kümmern wir uns um die Bestimmung des Divisionsrestes. A L G O R I T H M U S 1.9 MODULO
E INGABE : zwei natürliche Zahlen A USGABE : der Divisionsrest
.
.
M ETHODE : Verwende die Funktion: " ✍
☞ %!
und mit
+
# # ##+!#
– Alg. 1.9 – Die Definition einer rekursiven, sich selbst aufrufenden Funktion kennzeichnen . wir durch Voranstellen von Ein Aufruf dieser Funktion liefert nach entsprechend vielen rekursiven Aufrufen
den gewünschten Wert. Zum Beispiel erzeugt nacheinander die Aufrufe
28
Kapitel 1
Algorithmen und Programmierung
um das Ergebnis zu ermitteln. Um auch den ganzzahligen Quotienten bei der Division mit Rest zu bestimmen, müssen wir nur noch die Zahl der rekursiven Aufrufe, die Rekursionstiefe, mitzählen. Wir fügen also einen dritten Parameter hinzu und geben nun ein Zahlenpaar als Funktionswert zurück. A L G O R I T H M U S 1.10 GANZZAHLIGE D IVISION E INGABE : zwei natürliche Zahlen A USGABE : der Quotient
und mit
und der Rest
sowie die Zahl .
der Division von
durch .
M ETHODE : Folgende Funktion leistet das Gewünschte: ✍
"
"
☞ %
" +
# # # "!#
– Alg. 1.10 – Schachtelungen von Ausdrücken werden dabei durch die dargestellt.
Klausel
Der Algorithmus verwendete neben Addition und Subtraktion noch die bedingte Auswertung eines Ausdrucks, deren Bedeutung intuitiv klar ist. Wir haben durch Angabe des Algorithmus, von dessen Korrektheit und Terminierung wir uns überzeugt haben, bis auf die Eindeutigkeit folgenden Satz bewiesen.
1.4
Von Quotienten und Teilern
S ATZ 1.1 Für zwei natürliche Zahlen tient und der Rest , so daß
mit
und ,
29
, existieren der ganzzahlige Quo-
gilt. Dabei sind und eindeutig bestimmt. Der Beweis der Eindeutigkeit setzt die Existenz zweier verschiedener Lösungen voraus und zeigt dann, daß sie gleich sind. Die Beschreibung eines Algorithmus’ durch eine rekursive Funktion mag auf den ersten Blick ungewohnt erscheinen. Sie ermöglichte aber den Beweis der Korrektheit, den wir im obigen Beispiel nur informell geführt haben (es fehlte eine Induktion über die Rekursionstiefe), und ist trotzdem noch direkt ausführbar. Wir haben sogar zur Algorithmenbeschreibung die Syntax der realen Programmiersprache CAML LIGHT verwendet! Der Vorteil der funktionalen Vorgehensweise tritt stärker in Erscheinung, wenn wir von der Formulierung eines Satzes ausgehen und ihn nicht schrittweise entwickeln wollen. Dies führen wir in unserem zweiten Beispiel durch. Um den größten gemeinsamen Teiler (ggT) von zwei Zahlen zu bestimmen, verwenden wir den folgenden Satz von E UKLID. S ATZ 1.2 Der größte gemeinsame Teiler zweier natürlicher Zahlen gegeben durch falls
sonst
und ,
, ist
Der Beweis ist für die erste Alternative und folgt für die zweite aus dem Satz klar , dann muß jeder Teiler von der Division mit Rest: Ist mit auch teilen. Damit stimmt der größte von und gemeinsame Teiler von und mit dem von und überein. Dieser Satz läßt sich sofort in einen Algorithmus umsetzen. Wir verwenden anstelle der Funktion aus dem vorigen Beispiel den Operator , der den gleichen Wert liefert.
30
Kapitel 1
Algorithmen und Programmierung
A L G O R I T H M U S 1.11 GG T E INGABE : zwei natürliche Zahlen
und mit
.
A USGABE : der größte gemeinsame Teiler . M ETHODE : Satz von E UKLID: " ✍
☞
# # ##+!#
– Alg. 1.11 – Die Korrektheit dieses Algorithmus’ ist gegeben, da er mit dem Satz von Euklid fast wörtlich übereinstimmt.
1.5
Darstellung von Algorithmen
Algorithmen im täglichen Leben werden in der jeweils angemessenen Fachsprache formuliert. Oft wird die Darstellung durch Graphiken oder Bilder unterstützt, in denen etwa die einzelnen Schritte zum Zusammenbau eines Schrankes schematisch aufgezeichnet sind. Auch für Informatik-Algorithmen haben sich verschiedene, teilweise genormte Darstellungsformen durchgesetzt. Die einzelnen Formen unterscheiden sich dabei in ihrem Detaillierungsgrad, ihrem Abstraktionsniveau und der Striktheit, mit der die Beschreibungssprache definiert ist und angewendet werden muß. Die Skala reicht hierbei von einer verbalen Beschreibung bis zum ausführbaren Programm. Wir wollen die Algorithmen und Datenstrukturen vornehmlich in der funktionalen Sprache CAML LIGHT notieren. Das erlaubt eine präzise Formulierung und hat außerdem den Vorteil, daß sie direkt ausführbar sind. Wir brauchen uns nicht an eine Algorithmendarstellung und an eine unterschiedliche Programmiersprache zu gewöhnen. CAML LIGHT ist so einfach, daß wir uns ihren Gebrauch neben-
1.5
Darstellung von Algorithmen
31
bei aneignen können. Eine detailliertere Beschreibung der Sprache findet sich im zweiten Teil des Buches. Auch der Algorithmenentwurf geschieht wie die gesamte Problemlösung in mehreren Schritten oder Verfeinerungsstufen. Wir werden diese Stufen oft durchlaufen und jeweils die problemangepaßte Darstellung verwenden. Auch eine graphische Notation von Algorithmen werden wir kennenlernen. Generell gilt für alle Darstellungsformen: Jeder Algorithmus hat einen Namen, der den Zweck des Algorithmus bezeichnen sollte. Es folgt die Beschreibung der Eingabe, d. h. des Definitionsbereiches durch Angabe von Datentypen oder Bedingungen. Die vom Algorithmus verwendeten Größen sollten vollständig angegeben werden. Einige dieser Größen werden explizit als Parameter übergeben, andere sind aus der Umgebung bekannt. Der Wert, den der Algorithmus als Ausgabe liefern soll, ist ebenfalls anzugeben und durch seinen Datentyp und weitere einschränkende Bedingungen zu charakterisieren. Die bisher aufgezählten Punkte bieten eine Spezifikation des Problems. Sie beschreiben das »Was« eines Algorithmus’, aber nicht das »Wie«. Zu einer vollständigen Spezifikation gehört natürlich noch mehr, wie etwa die Schnittstellenbeschreibung sämtlicher verwendeter Elementaroperationen oder der benötigte Speicherplatz. So detailliert werden wir aber nicht vorgehen. Wir notieren Algorithmen gemäß folgender, bereits mehrfach angewendeter Schablone: A L G O R I T H M U S 1.12 S CHABLONE E INGABE : A USGABE : M ETHODE : – Alg. 1.12 –
32
Kapitel 1
1.5.1
Algorithmen und Programmierung
Verbale Beschreibung
Die eigentliche Beschreibung der Vorgehensweise des Algorithmus’ wird hier durch einen möglichst verständlich formulierten Klartext erläutert, im einfachsten Fall durch Angabe einer Formel als Berechnungsvorschrift oder durch eine zeitliche Abfolge von elementaren Funktionsaufrufen. Wir verwenden also Text oder Formeln, die wir oft durchnumerieren, um die Hintereinanderausführung zu umschreiben. Wiederholungen von Funktionsaufrufen sind üblich, etwa bis eine Bedingung über die Eingabewerte erfüllt ist. Durch die verbale Beschreibung wird ein Überblick über den Ablauf des Algorithmus’ vermittelt. Es besteht allerdings oft die Gefahr der Mißinterpretation oder der Ungenauigkeit. Die Ein- und Ausgabe wird von uns selten formal und nicht immer vollständig angegeben werden, weil aus dem Kontext ohnehin klar ist, was gemeint ist.
1.5.2
Pseudocode
Etwas formaler ist die Beschreibung durch Pseudocode. Wir setzen hier nur die elementaren Operationen voraus, die, wie z. B. die ganzzahlige Addition, immer vorhanden sind. Mit ihnen gebildete Ausdrücke können direkt hingeschrieben werden.
Namen für Werte werden mittels der -Klausel eingeführt. Zwischen und steht der Name, der den nach dem Gleichheitszeichen angegebenen Wert bezeichnet. Dieser Wert kann entweder durch einen normalen Ausdruck bestimmt werden oder eine Funktion sein. Funktionen notieren wir nach folgendem Mu ster mit einleitendem Wortsymbol , gefolgt von dem Argumentnamen und hinter dem Zuordnungspfeil , dem das Funktionsergebnis berechnenden Ausdruck.
%
"
Die aktuellen Namen und der Ausdruck sind einzusetzen. Hinter dem Funktionsnamen kann zur Klarstellung der Argumentbereich und der Wertebereich der Funktion durch getrennt angegeben werden. Die Schreibweise entspricht dann, wie bereits im Beispiel der Funktion aus Abschnitt 1.3.1 gesehen, ziemlich genau der üblichen mathematischen Notation.
1.5
Darstellung von Algorithmen
33
Treten mehrere Argumente auf, so sind sie vorerst zu klammern, sie entsprechen so einem Argument aus dem als kartesisches Produkt zusammengefaßten Definitionsbereich. Dieser Pseudocode ist nichts anderes als ein Teil der Sprache CAML LIGHT und damit ausführbar. Wir wollen den von uns angegebenen Code auch in den meisten Fällen ausführen bzw. interpretieren lassen. Betrachten wir hierzu einige Beispiele. Dem von uns eingegebenen Code wird in der ersten Zeile das Zeichen ✍ vorangestellt. Jede vollständige Phrase, das ist etwa ein Ausdruck oder eine Funktionsdefinition, wird durch ein doppeltes Semikolon abgeschlossen. Die Antwort des Interpreters erscheint durch ☞ eingeleitet darunter. B EISPIEL 1.1 Ein Ausdruck:
✍ ☞ #
Der Ausdruck hat den ganzzahligen Wert . Vereinbarung eines Wertes:
✍ ☞
#
✍
☞ ✍ ☞
#
#
Die Variable bezeichnet den ganzzahligen Wert , . Folglich ist ihre Summe . Nachfolgerfunktion: ✍ ☞ $!
&& "!#
Der Wert dieser Phrase ist eine Funktion, die ganze Zahlen auf ganze Zahlen abbildet und heißt. Der Funktionsaufruf kann durch
34
Kapitel 1
Algorithmen und Programmierung
✍ ☞ erfolgen. Funktion für ein Paar von Argumenten beliebigen Typs:
*
✍
* +!#
☞
oder ausführlich (eingeschränkt auf ganze Zahlen): ✍ ☞
*
# # ##+!#
Bedingte Ausdrücke formulieren wir als:
" %
Auch allgemeinere Formen der Fallunterscheidung sind zugelassen. So können etwa bestimmte Strukturen oder Werte der Eingabeargumente überprüft werden. Als Beispiel betrachten wir das Vorzeichen (Signum) einer Zahl, das durch
für für für
definiert ist. A L G O R I T H M U S 1.13 S IGNUM E INGABE : . A USGABE : .
1.5
Darstellung von Algorithmen
35
M ETHODE : Verwende: ✍
☞ $ "!#
– Alg. 1.13 – Wir geben für das Argument gewisse Muster vor und filtern den aktuellen Wert der Reihe nach gegen diese Muster. Sobald eines paßt, wird der zugehörige Ausdruck berechnet. Im Beispiel wird also nur für die erste Alternative ausgeführt. Das Ausfiltern von Mustern – englisch »pattern matching« – bietet vor allem bei der Unterscheidung von Strukturen eine sehr übersichtliche Form der Argumentzuordnung. Wir werden im Verlauf des Buches sehr viel mit Folgen oder Listen zu tun haben. Eine leere Liste bezeichnen wir mit , eine mit zwei Elementen mit und allgemein können wir eine Liste mit in das erste Element und die Restliste zerlegen. Solche Strukturen lassen sich beim »pattern matching«herausfiltern. A L G O R I T H M U S 1.14 M AXIMUM EINER L ISTE E INGABE : Liste von natürlichen Zahlen. A USGABE : deren Maximum. M ETHODE : Das Maximum der leeren Liste ist . Das Maximum einer einelementigen Liste ist das Element. Das Maximum einer längeren Liste ist das (in definierte) Maximum des ersten Elementes und des Maximums der Restliste. In CAML LIGHT lautet das: " ✍
36
Kapitel 1
Algorithmen und Programmierung
$%' " $ # * +!# ☞ "
– Alg. 1.14 – Man beachte, daß die einelementige Liste hier nur zur Demonstration von Mustern extra untersucht wurde. Sie ist eigentlich bereits als Spezialfall in der dritten Alternative enthalten.
1.5.3
Graphische Elemente und Datenflußdiagramme
Bei der Musterauswahl ist das textuelle Notieren oft umständlich, während ein kleines Bild sehr viel klarer ausdrücken kann, was gemeint ist. Deshalb werden zur Unterstützung des Pseudocodes auch häufig graphische Darstellungen verwendet. Setzt sich ein Algorithmus aus vielen Funktionen zusammen, so verschafft oft ein Datenflußdiagramm Übersicht. In diesem werden die wichtigsten Funktionen als »Teiche« dargestellt, die durch »Datenflüsse« verbunden sind. Ein Beispiel hierfür zeigt Abbildung 1.1. Eingabe
Funktion 1
Zwischenergebnis
Funktion 2
Ausgabe
Abbildung 1.1: Funktionen und Datenflüsse
Zur Darstellung von permanenten Speichern, Ein- bzw. Ausgabegeräten o.ä. gibt es, wie in Abbildung 1.2 zu sehen, graphische Elemente für externe Quellen und Senken. Datei
Eingabe
Funktion 1
Ausgabe
Bildschirm
Abbildung 1.2: Externe Quellen und Senken
1.5
Darstellung von Algorithmen
Datei
37
Datei Funktion 1
Tastatur
Bildschirm
Abbildung 1.3: Aufteilung und Kombination von Flüssen x
(1 - x) / 10
f (x)
Abbildung 1.4: Direkte Ausdrucksangabe in einer Funktion
Diese Art der Darstellung wird in der Softwaretechnik beim Programmieren im Großen verwendet, um den Weg der Daten durch ein Informationssystem zu veranschaulichen. Dabei werden nur die Schnittstellen der einzelnen Funktionen betrachtet und nicht ihre Rümpfe. Datenflüsse können außerdem aufgeteilt und mit einem Operator wieder zusammengefaßt werden. Abbildung 1.3 zeigt ein Beispiel. Wir wollen Datenflußdiagramme auch zur Beschreibung unserer doch recht kurzen Algorithmen einsetzen und dabei auch genauer den Funktionsablauf verfolgen. Wir schreiben dazu in einen »Teich«, der ja in unserem Modell eine aktive Einheit ist, den zu berechnenden Ausdruck wie in Abbildung 1.4 hinein. Um auch bedingte Ausdrücke und Fallunterscheidungen darstellen zu können, erweitern wir unsere »Wasserlandschaft« durch »Staudämme«, die den gesamten Einlauf in einen »Funktionsteich« unterbinden. Diese Staudämme werden von Bedingungen repräsentierenden »Schaltzentralen« gesteuert, die je nach Wahrheitswert eine andere Funktion aktivieren. In unseren Diagrammen wird dabei der Steuerstrom für den linken Ausgang geschaltet, falls die Bedingung erfüllt ist, und anderenfalls der rechte Zweig angestoßen. Insbesondere bei vielen ineinandergeschachtelten bedingten Ausdrücken oder Fallunterscheidungen ist eine graphische Darstellung dem textuellen Pseudocode überlegen. B EISPIEL 1.2 Es soll eine Funktion für die Ausführung der Aktionen »einzahlen« und »abheben« auf einem Bankkonto entworfen werden. Dazu wird zunächst die Schnittstelle der Funktion – also deren Ein- und Ausgabewerte – festgelegt. Das entsprechende Datenflußdiagramm zeigt Abbildung 1.6.
38
Kapitel 1
Algorithmen und Programmierung x
x= eps
x := 0.5 (x + a/x)
Abbildung 2.3: Das Schema einer
-Schleife
Die direkte Umsetzung in eine CAML LIGHT-Funktion lautet:
✍
"
" " "
! #+!#
☞
✍ ☞
Dieser Funktion entspricht das dem imperativen Paradigma entlehnte Ablaufdiagramm in Abbildung 2.3, welches üblicherweise als -Schleife implementiert wird. Der Schleifenrumpf wird solange ausgeführt, bis die oben stehende Bedingung falsch ist. Die Funktion ordnet eigentlich nur dem Parameter einen neuen Wert zu. Dieser hängt zwar von ab, aber doch mehr in dem Sinne, daß die Genauigkeitsschranke bekannt sein muß. Es ist deshalb nicht unbedingt ratsam, und zu einem Paar zusammenzufassen. Wir vereinbaren also als Funktion mit zwei (ungleichen) Argumenten:
✍
Die Argumente werden beim Aufruf, nur durch Leerzeichen getrennt, hinter den Funktionsnamen geschrieben. Diese Betrachtungsweise von Funktionen mit mehreren Parametern ist nicht ganz korrekt, hilft uns aber, Klammern zu sparen. Sie macht deshalb unsere Programme besser lesbar. Die korrekte Interpretation dieser Schreibweise erfolgt in Abschnitt 12.1. Eine etwas allgemeinere Vorgehensweise beschreibt die folgende Funktion. Sie setzt nicht voraus, daß das Ergebnis von vorneherein bekannt ist.
80
Kapitel 2
Entwurf und Analyse von Algorithmen
x := x1 x1 := 0.5 (x1 + a/x1) |x - x1| < eps
Abbildung 2.4: Das Schema einer
-Schleife
A L G O R I T H M U S 2.14 Q UADRATWURZEL , REPEAT E INGABE : Radikand , Startwert A USGABE : Näherung für
, Genauigkeitsschranke .
.
M ETHODE : Man iteriert solange, bis der Abstand zwischen zwei aufeinanderfolgenden Iterierten klein ist. Hier wird also im Gegensatz zu der ersten Version zuerst eine neue Iterierte berechnet und dann die Abbruchbedingung getestet. – Alg. 2.14 – Die entsprechende CAML LIGHT-Funktion: ✍
"
☞ !
"
'
"
"
%) * +!#
setzt diesen Algorithmus um. Im imperativen Modell entspricht diesem Vorge hen eine -Schleife, die solange durchlaufen wird, bis die unten stehende Bedingung erfüllt ist. Ein entsprechendes Ablaufdiagramm ist in Abbildung 2.4 zu sehen. Nun wollen wir dieses Programm verallgemeinern. Wir formulieren Iterationsschritt und Abbruchkriterium jeweils als eine Funktion:
2.6
✍
!
☞
Iteration und Rekursion
81
)
% +!#
Hier sind die veränderlichen Funktionen aus der eigentlichen Iteration herausgenommen worden. Sie stehen aber noch innerhalb der Wurzelberechnung. Wirk lich allgemein wird die -Funktion erst dann, wenn sie eigenständig auf gerufen werden kann und mit den Funktionen und (oder ) parametrisiert ist: ✍
☞
"
)
*
& & &
&
& &
+!#
Nun müssen natürlich für auch die Parameter und angegeben werden. Viel interessanter ist allerdings, daß wir hier Funktionen als Parameter verwenden. Wir wollen uns hier einmal die Meldung des Interpreters genau ansehen. Zuerst fällt auf, daß keine Typen festgelegt werden konnten, es sind ja auch keine Operationen angegeben. Ansonsten wird uns mitgeteilt, daß diese Funktion einen Ergebniswert vom Typ ’c ermittelt. Dazu erhält sie als Parameter von links nach rechts: einen Wert vom Typ ’a, der für das Abbruchkriterium gebraucht wird, eine Funktion die ein Paar auf einen Wert vom Typ ’c abbildet, einen (Anfangs-)Wert vom Typ ’c, ein Prädikat für das Abbruchkriterium, das für ein ’a und ein Paar von ’c einen Wahrheitswert bestimmt und schließlich noch einen Parameter vom Typ ’b.
Beim Aufruf werden die Parameterfunktionen durch aktuelle ersetzt: ✍
82
Kapitel 2
Entwurf und Analyse von Algorithmen
+
☞
Diese können auch ohne Namensangabe als sogenannte anonyme Funktionen direkt eingesetzt werden:
+ )
✍
☞
Das ist eine wichtige Vorgehensweise im funktionalen Programmieren. Solche Funktionen höherer Ordnung lassen es zu, daß man allgemeine Programmiermuster formuliert. Die Funktion ist aber noch nicht allgemein genug, denn sowohl Abbruchkriterium als auch die Iterationsvorschrift hängen von freien Parametern ab. Besser ist es, diese in die genannten Funktionen zu integrieren: ✍
☞
"
"!#
* * )
Man beachte hier wieder die Interpretermeldung über den Typ von – eine Funktion, die mit einer einparametrigen Funktion, einem zweistelligen Prädikat (Ergebnis wahr oder falsch) und einem Wert parametrisiert ist und einen Wert berechnet.
Beispiele für die Anwendung dieser Funktion sind: ✍
)
) +
☞
2.6
Iteration und Rekursion
83
zur Berechnung der dritten Wurzel von mit Startwert oder ✍
☞
# #
zur Bestimmung des größten gemeinsamen Teilers von und . Hier ist der behandelte Typ ein Paar. Funktionen höherer Ordnung ersetzen also einerseits die vom imperativen Programmieren her bekannten Schleifen, sie können aber ferner als eine Art Quantoren dienen, indem sie es ermöglichen, eine Funktion auf alle Elemente einer Datenstruktur anzuwenden. Für den fortgeschrittenen Programmierer verbergen sie ohnehin klare Details und erhöhen die Übersicht. Wir werden sie in späteren Kapiteln wieder benutzen, wollen uns jetzt aber erst einmal detailliert den Datenstrukturen widmen.
Kapitel 3 Datenstrukturen und Datentypen 3.1
Datenstrukturen im Überblick
Die Methode der schrittweisen Verfeinerung aus Abschnitt 2.3 darf nicht nur isoliert für den Algorithmenentwurf betrachtet werden, sondern sollte auch immer für eine Verfeinerung oder Konkretisierung der verwendeten Datenstruktur angewendet werden. Algorithmus und Datenstruktur beeinflussen sich gegenseitig. So haben wir im ersten Kapitel einige Algorithmen für ganze Zahlen betrachtet, für die die Grundrechenarten feststanden und als Einheit auftraten. Später wurde diese Einheit aufgebrochen, eine Zahl als Folge oder Liste von Ziffern dargestellt und die Grundrechenarten mit Hilfe von Ziffernoperationen realisiert. Die Zif beschrieben. fernoperationen selbst wurden mit Funktionen wie und Diese Funktionen haben die Eigenschaft, daß sie sich als Tabelle darstellen lassen.
Die Funktion etwa, die für zwei Ziffern im Dezimalsystem die Summenziffer berechnet, läßt sich mit der Tabelle
definieren. Der Funktionswert steht dabei am Schnittpunkt von Zeile und Spalte, in denen die entsprechenden Argumente angegeben sind.
86
Kapitel 3
Datenstrukturen und Datentypen
Mit dieser Verfeinerung der ganzen Zahlen sind wir natürlich meilenweit von der Realität im Rechner entfernt. Die von uns beschriebenen Algorithmen und Tabellen sind in der Hardware vorhanden. Deshalb sind die ganzen Zahlen und die Gleitkommazahlen ebenso wie Zeichen und logische Werte als Standardtyp in eigentlich jeder Programmiersprache vorhanden. Sie bilden zusammen mit dem Typ String für Zeichenketten die sogenannten einfachen Datentypen und sind gekennzeichnet durch ihren Wertebereich und die darauf definierten vorgegebenen Operationen. Für die meisten Anwendungen reichen die einfachen Datentypen nicht aus, sondern man braucht Zusammenfassungen mehrerer Daten zu einer neuen Einheit. D EFINITION 3.1 Eine Datenstruktur ist ein aus mehreren Komponenten zusammengesetztes Objekt, das einem Wertebereich angehört, welcher durch einen strukturierten Datentyp beschrieben wird. Ein strukturierter Datentyp beschreibt also einen Wertebereich, der aus mehreren Komponententypen gebildet wird. Der Aufbau von Datenstrukturen kann nach folgenden Gesichtspunkten kategorisiert werden: Die Anzahl der Komponenten – liegt von Anfang an fest und kann nicht verändert werden; – wird während der Laufzeit bestimmt, ist dann aber fest; Diese Unterscheidung gilt nicht für interpretierte Sprachen; – ist durch eine Obergrenze beschränkt; – ändert sich nach Bedarf während des Programmlaufs. Der Typ der Komponenten – ist einheitlich für alle; – variiert zur Laufzeit innerhalb einer festgelegten Bandbreite; – kann für jede einzelne Komponente völlig unterschiedlich sein. Der Zugriff auf die Komponenten – geschieht durch Angabe des Komponentennamens; – erfolgt durch Indexberechnung;
3.2 Funktionstypen
87
– erfolgt durch eigens angegebene Funktionen; – ist nur durch Ablauf der gesamten Struktur möglich. Die Veränderbarkeit der Komponenten – Die Komponenten können am Platz verändert werden; – Bei Änderung einer Komponente muß die ganze Datenstruktur neu angelegt werden. Die Veränderbarkeit hängt wesentlich vom Programmiermodell ab – im imperativen ist sie Normalfall, wohingegen in funktionalen Sprachen üblicherweise neue Strukturen aufgebaut werden. Zu einem Datentyp gehört also mehr als der bloße Wertebereich – auch die Operationen zum Zugriff auf die Komponenten tragen wesentlich zur Charakterisierung bei. In älteren Programmsprachen, wie etwa FORTRAN 77, ließen sich nur wenige Datenstrukturen vereinbaren. PASCAL führte dann die Definition von strukturierten Datentypen ein, und im Zuge der Modularisierung und Objektorientierung ist auch die Vereinbarung von Datentypen mit ihren zugehörigen Operationen möglich geworden. Es gibt typfreie funktionale Sprachen, während andere wie etwa CAML LIGHT über ein reichhaltiges Repertoire von Typkonstruktoren verfügen. Diese lassen sich als Operatoren verstehen, die aus gegebenen Typen neue erzeugen und beliebig miteinander kombinierbar sind. Wir wollen in den folgenden Abschnitten zuerst die in den meisten modernen Sprachen standardmäßig vorhandenen Datenstrukturen vorstellen. Dabei werden wir die oben angegebene Kategorisierung verwenden. Dann stellen wir Operatoren oder Hilfskonstruktionen vor, mit denen das Typsystem quasi beliebig erweitert werden kann. Zum Schluß gehen wir auf weiterführende Konzepte wie parametrisierte und abstrakte Datentypen ein.
3.2
Funktionstypen
Wir haben bereits mehrfach betont, daß Funktionen oft wie normale Werte behandelt werden können. Der Wertebereich der Funktionen bildet einen höheren Datentyp. Eine feinere Unterscheidung teilt den Bereich je nach Argument- und
88
Kapitel 3
Datenstrukturen und Datentypen
Ergebnistyp ein. Als »Komponententypen« dienen der Definitionsbereich, also der Argumenttyp, und der Wertebereich, der Ergebnistyp einer Funktion. Beide können wieder strukturierte Typen oder auch Funktionstypen sein. Die Vereinbarung von Funktionen als Objekten eines Funktionstyps haben wir schon oft genug gesehen. Die Interpretermeldung gibt jeweils den Funktionstyp an. So bilden Arithmetikfunktionen mit der Vereinbarung
✍ ☞ #
# ##+!#
ein kartesisches Produkt auf den Typ zwei Argumenten:
' ✍ ### * ☞
ab. Demgegenüber ist die Funktion mit
+!#
von einem anderen Typ. Sie läßt sich auch mit einem Argument aufrufen und beschreibt dann die Funktion, die den angegebenen Wert auf ihr Argument addiert:
✍
☞ ✍
"!#
☞
(#)#* +!
☞
(#)#* +!
✍
Wir wollen das hier nicht vertiefen und verweisen auf Abschnitt 12.1.
3.3
Datenstrukturen
3.3.1
Paare und Tupel
Die einfachste Datenstruktur ist ein Paar von Komponenten. Verallgemeinert man dieses Konzept, so kommt man zu einem geordneten Tripel, Quadrupel oder allgemein einem Tupel. Außer der vorgegebenen Anzahl wollen wir dabei nichts festlegen. Die Komponententypen sind frei wählbar. Sie können unterschiedlich, aber auch alle gleich sein. Die Anordnung der Komponenten soll aber eine Rolle
3.3
Datenstrukturen
89
spielen. Der Zugriff auf die Komponenten kann durch einen Komponentennamen, die Position oder eine spezielle Funktion erfolgen. Tupel treten in allen Sprachen implizit als Argumentlisten von Funktionen auf. Auch wir haben sie in dieser Form schon verwendet:
✍
☞
% +!#
✍
☞
' % # "!#
Auch als Ergebnis von Funktionen traten Paare und Tupel bereits auf:
✍
"!#
☞ #
In CAML LIGHT besteht auch die Möglichkeit, Tupel explizit als Typ zu vereinbaren. Die Komponenten können dabei von verschiedenem Typ sein. Sie werden durch Komma getrennt und sinnvollerweise in Klammern eingeschlossen:
✍ ☞
✍ ☞
! $
#$
& & #+!#
✍ ☞ # ✍ ☞
#
!#
& #+!#
Der Zugriff auf die erste Komponente eines Paares erfolgt mit der Funktion , der auf die zweite mit . Für andere Tupel existieren keine vordefinierten Zugriffsfunktionen.
90
Kapitel 3
3.3.2
Datenstrukturen und Datentypen
Arrays oder Felder
Homogene kartesische Produkte, also solche, bei denen alle Komponenten dem gleichen Typ angehören, treten vor allem in der Mathematik häufig auf. Deshalb lohnt es sich, für diesen Fall eine eigene, effiziente Datenstruktur vorzusehen. Diese heißt auf englisch Array und auf deutsch Feld oder Reihung und ist eigentlich in jeder (imperativen) Programmiersprache vorhanden. Die einzelnen Komponenten werden durch Angabe ihres Indexwertes selektiert und sind am Platz veränderbar. Der Indexwert gibt dabei die Komponentennummer an. Die Anzahl der Komponenten liegt nach dem Anlegen eines Feldes fest. Geschieht das bereits zur Übersetzungszeit, so spricht man von einem statischen Feld (etwa in PASCAL), wird sie dagegen erst zur Laufzeit bestimmt, so handelt es sich um ein dynamisches Feld (z. B. in ADA).
In CAML LIGHT wird ein Feldtyp durch das Schlüsselwort " nach dem Komponententyp eingeführt. Ein einzelnes Feld wird in eckige Doppelklammern ( und ) eingeschlossen, die Komponenten werden jeweils durch ein Semikolon getrennt. Der Index wird in runde Klammern eingeschlossen und zusätzlich mit einem Punkt vom Feldnamen abgesetzt: ✍ ☞
" "
✍
☞
✍ ☞
"#
"
&
&
Man kann ein Feld auch als Wertetafel einer Funktion ansehen, deren Definitionsbereich ein Anfangsstück der natürlichen Zahlen und deren Wertebereich der Komponententyp ist. Wir formulieren obiges Feld als Funktion: ✍
☞ & (#) * +!#
✍ ☞
3.3
Datenstrukturen
91
Ähnlich wie bei Funktionen können Felder mit zwei Indices (Matrizen) als einfach indiziertes Feld von Vektoren dargestellt werden. Entsprechendes gilt für mehrere Indexbereiche. Obwohl Felder eindeutig aus der imperativen Welt stammen, werden wir sie auch im weiteren Verlauf des Buches betrachten, weil einige Standardalgorithmen damit besonders effizient formuliert werden können.
3.3.3
Verbunde und Objekte
Werden die einzelnen Komponenten eines Tupels mit mehr Bedeutung beladen als einfach nur der Nummer in einer Aufzählung, so ist es angebracht, ihnen Namen zu geben und sie auch darüber anzusprechen. Ein Beispiel hierfür ist etwa die Darstellung einer komplexen Zahl mit Real- und Imaginärteil: ✍ ☞
✍ ☞
&
(&
+#
'
'
D EFINITION 3.2 Ein Record oder Verbund ist eine Datenstruktur, die aus einem Tupel besteht, dessen einzelne Komponenten mit Namen markiert sind. Ein Objekt ist ein Verbund, bei dem die direkten Zugriffsrechte auf die Datenkomponenten von außen verwehrt sein können. B EMERKUNG 3.1 Zur Einführung der Komponentennamen ist eine Typdefinition nötig. Es handelt sich also um eine Datenstruktur mit einer festen Anzahl von Komponenten verschiedenen Typs, die über ihren Namen angesprochen werden. Deshalb kann intern stets eine Anordnung der Komponenten (etwa in alphabetischer Reihenfolge) unabhängig vom Aufschrieb hergestellt werden. Die Veränderbarkeit kann in CAML LIGHT nach Bedarf eingestellt werden. Verbunde bieten sich immer dann an, wenn man eine feste Zahl von Größen unterschiedlichen Typs zu einer Einheit zusammenfassen will. Komponenten eines Verbundes können dabei auch Funktionen sein, welche die Schnittstelle oder Funktionalität des eingeführten Datentyps beschreiben. Mehr dazu findet sich in Abschnitt 3.7.
92
Kapitel 3
3.3.4
Datenstrukturen und Datentypen
Varianten
Bei den Verbunden, die sich mit Hilfe des kartesischen Produkts modellieren lassen, sind immer alle Komponenten gleichzeitig vorhanden. Der Wertebereich entsteht durch »Konjunktion« der einzelnen Komponentenbereiche – Bereich Bereich .
Oft will man aber eine »Disjunktion« verschiedener Bereiche ausdrücken. Zum Beispiel sind abhängig vom Familienstand unterschiedliche Daten zu verwalten, eine Zahl ist entweder ganzzahlig oder Gleitkommazahl, eine Liste ist leer oder besteht aus Kopf und Schwanz usw. Diese Art von Zusammenfassung bezeichnet man als disjunkte Vereinigung.
, , , Mengen. Dann heißt die disjunkte Vereinigung von , , , .
D EFINITION 3.3 Seien
Der eigentliche Wert ist die erste Komponente des Paares. Jeder Wert wird so oft in den Gesamtbereich aufgenommen, wie er in den einzelnen Mengen auftaucht. Die zweite Komponente des Paares bestimmt die Herkunftsmenge. So können auch gleiche Werte aus verschiedenen Mengen unterschieden werden. Die programmiersprachliche Umsetzung dieser mathematischen Konstruktion als Variante oder Union ist zum Glück sehr viel lesbarer. Anstelle der Indexkomponente vergibt man einen Namen, mit dessen Hilfe der richtige Verbund konstruiert werden kann. Die Feststellung »eine Zahl ist entweder ganzzahlig oder Gleitkommazahl« läßt sich zum Beispiel modellieren als ✍ ☞
✍
+#
✍ ☞ ☞
Die Namen und dienen als Konstruktor für die jeweilige Alternati ve. Der Wertebereich ist die Menge der -Zahlen vereinigt mit der Menge der
3.4
Typkonstruktion
93
-Zahlen, so daß jede Zahl ihre interne Darstellung behält. Es kann also noch
zwischen
und
unterschieden werden.
Noch einfacher ist die disjunkte Vereinigung von einelementigen Mengen (Konstanten): ✍ ☞ ✍ ☞
+#
Aus imperativen Sprachen sind Records mit Variantenteil bekannt. Das sind Verbunde, von denen eine Komponente eine Variante ist. Hier ist eine Obergrenze für die Komponentenanzahl gegeben, der Typ ist beliebig und der Zugriff erfolgt über den Komponentennamen, zu dem noch der Konstruktorname hinzukommen kann. In Zusammenarbeit mit den rekursiven Datentypen, die wir weiter unten besprechen wollen, bilden die disjunkten Verbunde das wohl mächtigste Hilfsmittel zur Konstruktion von für unsere Zwecke geeigneten Datenstrukturen.
3.3.5
Dynamische Listen
Wir brauchen für viele Anwendungen eine sich dynamisch anpassende Datenstruktur von einheitlichem Komponententyp. Das Aufsuchen aller Komponenten in einer festgelegten Reihenfolge soll effizient möglich sein. Die dynamischen Listen in CAML LIGHT, die wir bereits verwendet haben, sind eine Verwirklichung einer solchen Struktur.
3.4
Typkonstruktion
Neue Typen können aus vorhandenen mit Hilfe von Operatoren konstruiert werden, auch Hilfstypen zur Verkettung von Strukturen finden Verwendung. Wieder spielt die Rekursion eine entscheidende Rolle. Die Analogie zu arithmetischen Ausdrücken geht so weit, daß auch Typvariable auftreten können.
94
Kapitel 3
Datenstrukturen und Datentypen
1234
"abcd"
‘a‘
Abbildung 3.1: Eine dynamisch verkettete Datenstruktur
3.4.1
Typoperatoren
Die Operationen kartesisches Produkt, disjunkte Vereinigung und auch Funktionstypbildung dienen zur Konstruktion von neuen Typen. Als Operanden kommen Standardtypen, bereits definierte Typnamen und auch Typvariable vor. Genaueres zum Vorgehen in CAML LIGHT findet sich im Kapitel 11.
3.4.2
Zeigertypen
In imperativen Sprachen spielen Zeigertypen eine große Rolle, da mit ihrer Hilfe beliebige dynamische Strukturen aufgebaut werden können. Ein Zeiger oder Pointer ist dabei ein Typ, dessen Wert nicht ein »eigentlicher« Wert, sondern die Adresse einer Bezugsvariablen ist. Im Speicherzellenmodell gesprochen, liegt in der Speicherzelle des Zeigers kein Wert, sondern die Nummer der Speicherzelle der Bezugsvariablen. Diese kann zur Laufzeit dynamisch angelegt werden. Wenn die Bezugsvariable ein Verbund ist, der selbst wieder einen Zeiger enthält, lassen sich verkettete Strukturen wie in Abbildung 3.1 zusammensetzen. Das Programmieren mit Zeigern ist fehleranfällig, da nun mehrere Zugriffspfade für eine Variable existieren können und es passieren kann, daß ganze Strukturen durch einen falsch gesetzten Zeiger verloren gehen können. Einer der Gründe für die Wahl einer funktionalen Sprache als Basis dieser Einführung war es, diesen Schwierigkeiten aus dem Weg zu gehen. Trotzdem werden wir im nächsten Kapitel die Grundidee einer Listenimplementierung mit Zeigern vermitteln. Dabei beschränken wir uns allerdings auf das Zeichnen von Bildern als »Ersatzprogammen«.
3.5
3.5
Rekursive Datentypen
95
Rekursive Datentypen
In funktionalen Sprachen wird ein anderer Zugang zu dynamischen Datenstrukturen gewählt, der sich wieder einmal der Rekursion bedient. Wir haben Listen bisher intuitiv eingeführt als Strukturen, die entweder leer sind oder aus einem Element und einer Liste bestehen. Das war aber bereits eine rekursive Definition! Ähnlich wie bei Funktionen lassen wir nun zu, daß rekursive Typen vereinbart werden können. Um einen Abbruch der Rekursion zu ermöglichen, muß ein solcher Typ als disjunkter Verbund angelegt werden, bei dem eine Variante garantiert keine Rekursion enthält. In der Regel wird diese Variante einen argumentlosen Konstruktor für die leere Datenstruktur einführen. Das Parameterprofil der anderen Konstruktoren kann dagegen den Typnamen verwenden, der gerade definiert wird. Als Paradebeispiel definieren wir einen rekursiven Listentyp: ✍ ☞
# $
✍
+#
# " $ ✍ ☞ # " $ # ☞
#
kreiert die leere Liste und fügt ein Element an eine Der Konstruktor existierende Liste an. Durch Schreibweise und Namen wird suggeriert, daß das Anfügen am Anfang geschieht, was aber nicht zwingend ist. Mit Hilfe der Konstruktoren kann sofort eine Struktur dieses Datentyps angege ben werden. Bis auf die komfortablere Schreibweise ist der Standardtyp zu diesem Typ äquivalent. Wir können für diesen Typ auch die gängigen Operationen wie Zugriff auf Kopf und Schwanz oder Bestimmung der Listenlänge als Funktionen definieren: ✍
96
Kapitel 3
☞
✍
☞
(#" $ "!#
(#" $ " $% "!# " # $ # * +!#
✍ ☞
Datenstrukturen und Datentypen
Für eine leere Liste wird der Zugriff auf Kopf und Schwanz abgebrochen und die entsprechende Meldung ausgegeben.
3.6
Parametrisierte Typen
Die typischen Operationen für unsere rekursiv definierte Liste im vorigen Abschnitt machen an keiner Stelle davon Gebrauch, daß die Listenelemente ganzzahlig sind. Sie sehen für jeden anderen Listenelementtyp identisch aus, sind aber in dieser Form nicht aufrufbar. Auch die Listentypdefinition muß wiederholt werden: ✍ ☞
%" $
+#
Ähnlich wie bisher arithmetische Ausdrücke durch Parametrisierung zu Funktionen verallgemeinert wurden, können nun auch Typen durch Einführen von Typvariablen zu allgemeinen Typschablonen abstrahiert werden. Anstelle des Elementtypnamens setzen wir nun eine Typvariable ein, die als Platzhalter für einen Typ fungiert, welcher bei der Ausprägung der Schablone angegeben wird: ✍ ☞
" $
+#
✍
3.7
☞
Abstrakte Datentypen
97
" $ * +!
✍
% " $ $ #+!#
✍ "
☞ " $ # * +!#
☞
Damit ist nun eine Schablone für eine Liste mit beliebigem Elementtyp verein bart. Der Schablonenname bildet mit den vorangestellten Typparameternamen den neuen Typnamen. Innerhalb der Definition tritt die Typvariable wie ein normaler Typname auf. Bei der Bestimmung eines Wertes werden wie bisher einfach die Konstruktoren aufgerufen. Dabei ist für jedes Auftreten eines Wertes der Typvariablen konsistent der gleiche Typ anzunehmen: ✍ ☞
$ # ☞ " $ ✍ ☞ " $ # ✍
3.7
#
Abstrakte Datentypen
Wir haben schon am Anfang des Kapitels festgestellt, daß zu einem Datentyp auch die Operationen zum Zugriff auf die Komponenten gehören. Auch andere Standardoperationen wie die Längenbestimmung bei einer Liste charakterisieren einen Datentyp. Dabei ist es für den Benutzer dieses Typs oft unerheblich, wie diese Operationen programmiert sind. Das Wichtigste ist, daß die Schnittstelle des Typs bekannt ist – man muß also wissen, wie man die Operationen aufrufen kann und welche Wirkung sie haben. Dann ist es möglich, sinnvolle Programme zu schreiben, die auf diesem Typ aufbauen. Dessen genaue Implementierung kann später nachgereicht oder auch geändert werden. Ein solcher Typ wird abstrakter Datentyp genannt.
98
Kapitel 3
Datenstrukturen und Datentypen
Wert: Folge von Einzelzeichen Operationen: Name Eingabe String Anzahl und Zeichen zwei Strings String, Position und Länge
Rückgabe seine Länge String aus ’s Konkatenation Ausschnitt von ab Position
Zeichen
Tabelle 3.1: ADT String
D EFINITION 3.4 Ein abstrakter Datentyp besteht aus einem Wertebereich und darauf definierten Operationen, deren Schnittstelle festliegt und deren Wirkung (Semantik) genau beschrieben ist. Die Beschreibung der Semantik eines abstrakten Datentyps kann durch logische Prädikate, Gleichungen, eine formale Spezifikationssprache oder auch umgangssprachlich geschehen. Prinzipiell ist CAML LIGHT als Spezifikationssprache geeignet, indem man Gleichungen zwischen Aufrufen der verschiedenen zueinander in Beziehung stehenden Operationen angibt. Man bleibt aber gern noch etwas abstrakter und gibt Gleichungen zwischen Funktionen in mathematischer Notation an. Wir verwenden beim Entwurf eines abstrakten Datentyps zuerst eine umgangssprachliche Beschreibung, geben dann Tabellen an, die Ein- und Ausgabe beschreiben, und verfeinern diese Tabellen schließlich zu einer genauen Schnittstellenspezifikation, in der wir die Signatur, das Parameterprofil der Operationen, in CAML LIGHT-Syntax auflisten. Wir erläutern diese Vorgehensweise, indem wir einen Teil der Funktionalität des vorstellen. Standarddatentyps Ein String ist eine Zeichenkette, von der wir die Länge, also die Anzahl der Zei konchen, bestimmen können. Strings werden mit der Funktion struiert, wobei alle Zeichen auf den gleichen Wert gesetzt werden. Strings können aneinander gehängt werden, und es läßt sich ein Teilstring ausschneiden. Signaturen beschreiben den Funktionstyp 3.2 durch Angabe der Parametertypen und des Ergebnistyps. Sie sind wie die Meldungen des CAML LIGHT-Interpreters zu lesen. Der Rückgabetyp steht hinter dem letzten Pfeil, die anderen Pfeile, von denen mehrere auf der gleichen Schachtelungstiefe auftreten können, trennen die
3.7
Typ: Operationen: Name
Abstrakte Datentypen
Signatur
99
Wirkung Länge Initialisierung Konkatenation Ausschnitt
Tabelle 3.2: ADT String, Schnittstelle
Eingabeparameter. Klammerung ist zu beachten. Eine genaue Beschreibung der Funktionstypen steht wie schon erwähnt in Abschnitt 12.1.
✍ $ ☞ $ $ # +# ✍ ☞ # ✍ ☞ $ # ✍ ☞
! $ #
$ #
+#
+#
✍ ☞
$
Kapitel 4 Listen und ihre Implementierung Mit den im vorigen Kapitel definierten, grundlegenden strukturierten Datentypen und Typkonstruktoren lassen sich sehr gut »höhere« Datentypen formulieren. Wir wollen in diesem und in den folgenden Kapiteln die wichtigsten und zugleich einfachsten vorstellen, die vielfältig und immer wieder in Anwendungen auftreten. Es ist daher für jeden Informatiker und Programmierer unabdingbar, diese Strukturen wie Listen und Bäume zu beherrschen. Ihre Implementierung sollte sorgfältig überlegt sein, da die Operationen als Elementaroperationen in anderen Algorithmen auftreten und deshalb deren Effizienz maßgeblich bestimmen.
4.1 4.1.1
Listen als abstrakte Datentypen Wörterbuch und Liste
Wir betonen an dieser Stelle die effiziente Verarbeitung der sogenannten Wörterbuchoperationen. Unter einem Wörterbuch versteht man eine Struktur, in der Informationen abgespeichert werden. Dabei gibt es zu jedem Datensatz einen eindeutigen Schlüssel, mit dessen Hilfe wir den Datensatz finden und identifizieren können. Beispiele für Wörterbücher sind etwa Bestandslisten von Versandhäusern, in denen die Bestellnummer als Schlüssel dient. Auch ein normales DeutschEnglisch-Wörterbuch kann als Beispiel genannt werden – die Schlüssel sind die deutschen Wörter, die Informationen die englischen. Die Operationen, die auf solchen Strukturen durchgeführt werden sollen, sind das Einfügen, Suchen und Löschen eines Datensatzes. Da jeder Datensatz eindeutig durch seinen Schlüssel identifiziert werden kann und die Wörterbuchoperationen unabhängig vom eigentlichen Elementtyp sind, reicht es für unsere Zwecke aus, nur die Schlüssel zu betrachten und die Information zu ignorieren. Wir setzen also Schlüsseltyp gleich Elementtyp und bemerken noch, daß es günstig ist, auf diesem Typ über eine Ordnungsrelation zu verfügen. Ferner werden die Algorithmen übersichtlicher, wenn wir annehmen, daß alle Schlüssel verschieden sind.
102
Kapitel 4 Listen und ihre Implementierung
Da man immer eine Zuordnung definieren kann, die einen Schlüssel auf eine ganze Zahl abbildet, werden wir die Algorithmen vornehmlich für Datenstrukturen von ganzen Zahlen erläutern. Wir stellen ein Wörterbuch mit seinen drei Hauptoperationen als abstrakten Datentyp vor. Wert: Menge von verschiedenen Schlüsseln Operationen: Name Eingabe Suche Wörterbuch und Schlüssel Einfügen Wörterbuch und Schlüssel Löschen Wörterbuch und Schlüssel Konstruktor
Rückgabe Auskunft, ob enthalten Wörterbuch, das enthält Wörterbuch ohne leeres Wörterbuch
Tabelle 4.1: ADT Wörterbuch
Die einfachsten Realisierungen von Wörterbuchtypen sind lineare Listen. Darunter verstehen wir jetzt nicht nur die schon verwendeten rekursiven Listen, sondern allgemein einen Datentyp, dessen Wertebereich eine endliche Folge von Elementen gleichen Typs ist. Die Elemente sollen in der gegebenen Reihenfolge betrachtet werden können, es gibt also ein erstes und ein letztes Element der Folge. Allge mein läßt sich die Reihenfolge durch einen Datentyp " bestimmen, der im einfachsten Fall ein Anfangsstück der natürlichen Zahlen bildet, also mit beginnt. Für den allgemeinen Fall sehen wir eine auf einem solchen Anfangsstück " vor. Wir können aber auch die gandefinierte Funktion " ze Liste als eine Funktion auffassen.
D EFINITION 4.1 Eine Liste vom Elementtyp ist eine Funktion , die jedem , , einen Wert zuordnet. Der Wert heißt die Listenlänge.
Diese Definition ist – wenn auch mathematisch korrekt – nicht sehr hilfreich. Konstruktiver ist die folgende rekursive Variante. D EFINITION 4.2 Eine Liste ist entweder leer oder, falls ein Paar .
und eine Liste ist,
Wir verfeinern den Wörterbuchtyp zu einem abstrakten Datentyp Liste, indem wir beim Einfügen und Löschen unterscheiden, ob die Position vorgegeben ist ist oder nicht. fügt also irgendwo in die Liste ein und nach
4.1
Listen als abstrakte Datentypen
103
der eingefügte Schlüssel an der angegebenen Position. Dementsprechend wird auch die Suche durch zwei Funktionen realisiert, liefert den Wert, wäh rend die Position bestimmt. Falls der Schlüssel nicht in der Liste enthalten ist, kann eine Fehlermeldung erfolgen oder eine nicht vorkommende Position zurückgegeben werden, z. B. -1. Außerdem brauchen wir noch einige Hilfsoperationen, wie Test auf leere Liste und Bestimmung der Listenlänge. Wir vereinbaren die folgende Schnittstelle: Wert: Folge von Elementen gleichen Typs Operationen: Name Signatur empty () list is_empty list bool length list int pos int position search list element position access list position element insert list element list insert_at list element position list delete list element list delete_at list position list
Wirkung leere Liste Test auf leer Länge Positionsabbildung Position des Elements Wert des Elements Liste mit Element Liste mit Element an geg. Pos. entfernt El. entfernt El. an geg. Pos.
Tabelle 4.2: ADT Liste, Schnittstelle
4.1.2
Einfache Programme
Mit diesen Operationen lassen sich nun, ohne daß wir ihre Implementierung kennen, kleine Algorithmen auf Listen formulieren. A L G O R I T H M U S 4.1 K ONKATENATION E INGABE : zwei Listen
und .
A USGABE : die Liste, die durch Aneinanderhängen von
und
entsteht.
104
Kapitel 4 Listen und ihre Implementierung
M ETHODE : Falls leer ist, so . Sonst füge erstes Element von an Position die durch Anhängen von an den Rest von entsteht: " ✍
" " "
der Liste ein,
– Alg. 4.1 – Wenn wir für alle aufgerufenen Elementaroperationen konstante Ausführungszeit annehmen, ergibt sich der Aufwand in Abhängigkeit von , der Länge von , als Lösung der Rekursionsgleichung . also cat
Für das nächste Problem, das Umdrehen einer Liste, wollen wir drei verschiedene Algorithmen angeben, die sich in ihrer Komplexität unterscheiden. Um die Aufwandsberechnung präzise durchführen zu können, nehmen wir wie oben konstanten Aufwand für die elementaren Listenoperationen bis auf die Längenbestimmung an, die linear sei. A L G O R I T H M U S 4.2 U MDREHEN , REKURSIV E INGABE : Liste .
A USGABE : Liste , rückwärts gelesen.
M ETHODE : Wir hängen das erste Element hinten an die umgedrehte Restliste an: "
✍
4.1
Listen als abstrakte Datentypen
105
"
" "
– Alg. 4.2 – Offensichtlich ist das Ergebnis korrekt. Da die Restliste um ein Element kürzer ist, kommen wir durch die Rekursion zu einer leeren Liste, die gleich der umgedrehten leeren Liste ist, und der Algorithmus terminiert.
Diese Funktion verwendet die -Funktion. Die Komplexität ist quadratisch, da für alle Listenlängen von bis aufgerufen wird (siehe Satz 2.1). Eine andere Version dieses Algorithmus’ ruft ✍
" *
"
an der letzten Stelle auf:
"
Da nun jedesmal die Listenlänge bestimmt werden muß, ist aber nichts gewonnen – der Aufwand bleibt quadratisch. Wir versuchen daher einen Divide & Conquer-Algorithmus. A L G O R I T H M U S 4.3 U MDREHEN , DIVIDE & CONQUER E INGABE : Liste .
A USGABE : Liste , rückwärts gelesen.
M ETHODE : Die Liste wird, wenn sie nicht leer ist, in zwei Teillisten aufgeteilt. Diese werden umgedreht. Danach wird die erste Liste hinten an die zweite angehängt: " *
✍
106
Kapitel 4 Listen und ihre Implementierung
"
– Alg. 4.3 – Die Korrektheit und Terminierung des Algorithmus’ sind offensichtlich. In jedem Schritt wird die Länge bestimmt, die Liste aufgeteilt und die umgedrehten Teillisten aneinandergehängt. Der Aufwand ist Lösung der Rekursionsgleichung
cat
split_at
length
Falls also alle drei Einzelalgorithmen in linearer Zeit ablaufen, erhalten wir nach ist das nach Voraussetzung Satz 2.6 reverse_dq . Für und der Fall, müssen wir noch verfeinern.
Der zweite Parameter von bestimmt die Stelle, an der geteilt werden soll. Ist er , so ist die erste Teilliste leer. Ist er gleich , so entferne das erste Element aus der Liste und teile die Restliste an der Stelle . Füge dann das entfernte Element wieder vorne an die -elementige erste Teilliste ein, um die gewünschte -elementige Liste zu erhalten:
✍
"
" " "
Bei jedem rekursiven Aufruf werden nur konstante Operationen verwendet. Die Anzahl der Aufrufe ist durch den zweiten Parameter gegeben. In unserem Fall ist das die halbe Listenlänge. Damit ist die geforderte lineare Komplexität gesichert. Der Divide & Conquer-Ansatz bringt also eine deutliche Verbesserung, ist aber nicht optimal. Es müßte doch ausreichen, die Liste nur einmal zu durchlaufen und dabei die Werte in umgekehrter Reihenfolge abzuspeichern. Wenn wir eine zweite Liste, die das Ergebnis aufsammelt, als Parameter mitgeben, ist dieser Algorithmus sogar der einfachste.
4.1
Listen als abstrakte Datentypen
107
A L G O R I T H M U S 4.4 U MDREHEN MIT S AMMELLISTE E INGABE : Liste .
A USGABE : Liste , rückwärts gelesen.
M ETHODE : Wir vereinbaren eine Hilfsfunktion, die zwei Listen als Parameter erhält und das erste Element der ersten Liste vorne in die zweite Liste einfügt. Dies geschieht so lange, bis die erste Liste leer ist. Wird diese Funktion für eine Eingabeliste und eine anfangs leere Ausgabeliste aufgerufen, so ist die Ausgabeliste am Ende gerade die umgedrehte Eingabeliste, denn ein Element nach dem anderen wird vorne eingefügt. Diese eher iterative Vorgehensweise läßt sich auch ohne die in Abschnitt 2.6 vorgestellten Muster rekursiv programmieren: ✍
"
%
" "
%
"
"
Offensichtlich ist die Komplexität linear. – Alg. 4.4 – B EISPIEL 4.1 Wir veranschaulichen die verschiedenen Algorithmen an einem Beispiel. Es soll die Liste, die aus den fünf Buchstaben ‘n’, ‘e’, ‘g’, ‘e’ und ‘r’ besteht, umgedreht werden. Die Funktionsweise des rekursiven Algorithmus’ wird durch Einsetzen erläutert: ✍
"
108
Kapitel 4 Listen und ihre Implementierung
Bei der divide & conquer-Version beginnen wir mit dem Abrollen der Rekursion innerhalb der -Funktion ➘ ➘ ➘ ➚ ➚ ➚
Nun läßt sich die eigentliche Funktion leicht durch Einsetzen verdeutlichen. ✍
Die dritte Version mit Sammelliste wird folgendermaßen abgerollt:
➘ ➘ ➘ ➘ ➘ ➘ ➚
% % % % % %
Die Formulierung der Algorithmen mit den Funktionen des abstrakten Datentyps ist natürlich etwas umständlicher als der Aufruf der Standardlistenoperationen. Wir können aber nun verschiedene Implementierungen von Listen untersuchen und brauchen jedesmal nur die Methoden des abstrakten Datentyps anzugeben. Alle darauf aufbauenden Operationen, wie Umdrehen und Aneinanderhängen, bleiben erhalten. Wir behandeln in den drei nächsten Abschnitten drei verschiedene Listenimplementierungen.
4.2
1
Listen als Felder
2
3
109
4 Pegel
Abbildung 4.1: Implementierung einer Liste als Array
4.2
Listen als Felder
Die Datenstruktur besteht aus einem zusammenhängenden Feld von Werten, die am Platz veränderbar sind. Die maximale Größe ist fest, die aktuelle Größe wird durch den Index des letzten belegten Platzes bestimmt. Dieser Indexwert, den wir Pegel nennen wollen, gehört also zur Datenstruktur, die damit zu einem Record aus Feld und Pegel wird. Die Elemente sind angeordnet, der Typ " ist der Indexbereich des Feldes, also ein Anfangsstück der natürlichen Zahlen. Ein Beispiel ist in Abbildung 4.1 zu sehen. Wir beschreiben jetzt die Implementierung dieser Variante in CAML LIGHT. Eine Liste besteht aus Feld und Pegelwert: ✍ ☞
" $ + #
"
Die Listenelemente haben Indices (Positionen) zwischen und . Alle Feldelemente mit größerem Index gehören nicht zur Liste, und ihr Wert wird ignoriert. Da der Pegel beim Einfügen und Löschen verändert wird, wurde er als vereinbart.
Mit " wird ein -elementiges Feld angelegt und mit gefüllt. Diese Funktion legt damit die Maximalzahl der Listenelemente fest und ist am Anfang einmal aufzurufen. Da wir lediglich die Konstruktion einer leeren Liste im abstrakten Datentyp vorgesehen haben, kann das innerhalb der Funktion erfolgen:
✍ ☞
"
# " %$ " !#
Die leere Liste wird dabei durch charakterisiert. Die Länge des zugrundeliegenden Feldes wird dadurch nicht verändert.
110
Kapitel 4
Listen und ihre Implementierung
Die Länge einer beliebigen Liste ist durch Addition von zum Pegelwert in konstanter Zeit bestimmbar: ✍ ☞
) * '
" $ ) #* +!
Der Zugriff auf ein Element an Position der Liste ist durch ebenfalls in konstanter Zeit möglich. Das war ja gerade eine der charakteristischen Eigenschaften eines Feldes! Die Funktion fügt an der Position ein, verändert also dieses bisher undefinierte Element direkt. Außerdem wird der Pegelwert erhöht.
A L G O R I T H M U S 4.5 E INFÜGEN IN EIN F ELD M ETHODE : ✍
"
# $ " $
☞
" "
!#% +!#
– Alg. 4.5 – Die Suche durchläuft die Liste von Anfang an, und beim Einfügen an beliebiger Position und beim Löschen muß ein Teil der Liste umkopiert werden, um Platz zu schaffen oder Lücken zu schließen. Diese Algorithmen beschreiben wir nun detaillierter. A L G O R I T H M U S 4.6 S UCHE IM F ELD E INGABE : Liste , Wert .
A USGABE : Position des Wertes in der Liste. Falls er nicht enthalten ist, wird ben.
zurückgege-
4.2
M ETHODE : Beginne mit
Falls Falls
Listen als Felder
111
.
, so nicht gefunden.
, so ist der gesuchte Index.
Sonst prüfe die Stelle . Man beachte, daß der Wert auch für die leere Liste korrekt ist. Allerdings wird vorausgesetzt, daß das Feld mindestens Elemente enthält. – Alg. 4.6 – Eine Implementierung in CAML LIGHT sieht folgendermaßen aus:
✍
"
% '
%
%
$ &
☞
" $ # * +!#
Dieses ist noch ein rein funktionaler Algorithmus, der keine Nebenwirkungen hat. Beim Einfügen und Löschen ist das anders. Nun ändern sich sowohl der Pegel als auch die Werte einiger Feldelemente. A L G O R I T H M U S 4.7 L ÖSCHEN AN VORGEGEBENER P OSITION E INGABE : Liste und Position .
A USGABE : Liste ohne das Element an Position . M ETHODE : Falls , so Fehler (falsche Position), sonst verschiebe die Elemente an den Positionen bis um einen Platz nach vorne. – Alg. 4.7 –
112
Kapitel 4
Listen und ihre Implementierung
Wie oben formulieren wir eine Hilfsfunktion, die das Verschieben erledigt. Diese verändert die Werte des Feldes und hat keinen Rückgabewert. Der Name erinnert einerseits an , andererseits an " , weil ja nach fallenden Positionen verschoben wird. Nach dem Verschieben muß noch der Pegel erniedrigt werden: ✍
"
"
)
"
" $ #!#% * +!#
☞
B EISPIEL 4.2 Wir konstruieren eine leere Liste für fünf Elemente und fügen dann und ein. Danach suchen wir und und entfernen : ✍ ☞
" $
✍ ☞ " $
✍ ☞ " $
✍ ☞ ✍ ☞
✍ ☞ " $
4.3
Verkettete Listen
13
12
11
113
10
Abbildung 4.2: Darstellung einer Liste durch Zeiger
Zu beachten ist, daß durch das »dummy« Argument von der Liste festgelegt wird.
bereits der Typ
Die Listenimplementierung mittels eines Feldes hat schon stark imperative Aspekte – die Liste und der Pegel werden am Platz geändert. Einfügen und Löschen sind Funktionen ohne Rückgabewert, die nur über Nebenwirkungen ihre Aufgabe erfüllen. Abgesehen davon ist die feste Maximallänge ungünstig und das Verschieben der Elemente kostet Zeit. Vorteilhaft ist dagegen der direkte Zugriff auf einzelne Elemente. In Abschnitt 4.5 wird der Aufwand der Listenoperationen für verschiedene Implementierungen zusammengestellt.
4.3
Verkettete Listen
Der Zeigertyp dient vor allem zum Aufbau von verketteten Listen. Die Datenstruktur besteht aus einzelnen Elementen, von denen jedes einen Verweis auf das nachfolgende enthält. Die Anordnung der Elemente wird also durch den Zeigertyp bestimmt, der jetzt den Typ " realisiert. Der leere Verweis kennzeichnet die leere Liste, die ganze Liste kann durch einen Zeiger auf das erste Element dargestellt werden (Abbildung 4.2). Wir erläutern die Listenoperationen nur mit solchen Bildern, in denen Zeiger durch Pfeile symbolisiert werden. Die neu zu setzenden Pfeile zeichnen wir gestrichelt und numerieren sie in der Reihenfolge, in der sie erzeugt werden. Diese Reihenfolge ist wichtig! Der Zugriff auf ein Listenelement mit gegebenem Zeiger liest einfach dessen Bezugsvariable. Das Ermitteln der Position eines Elementes, also die Funktion " , erfordert nun allerdings das »Durchhangeln« durch die Liste und ist damit nicht mehr mit konstantem Aufwand durchführbar. Besonders einfach sind das Einfügen und Löschen nach einem Element, dargestellt in Abbildung 4.3 und 4.4. Das Löschen und Einfügen des ersten Elementes sind dabei als Sonderfall zu betrachten.
114
Kapitel 4
Listen und ihre Implementierung
x
2
1
y
z
nach
Abbildung 4.3: Einfügen von 1 y
x
z
Abbildung 4.4: Löschen des Elementes
Da das Einfügen am Anfang besonders einfach ist, nehmen wir es als Funktion.
4.4
-
Rekursive Listen
Zum Schluß wollen wir noch die uns schon bekannten rekursiven Listen zur Implementierung der Wörterbuchoperationen heranziehen. Für diese dem funktionalen Programmieren angepaßte Datenstruktur werden wir später auch die weiteren Algorithmen vorstellen und deren Implementierung auf Arrays nur kurz streifen. Listen können, wie in Abschnitt 3.5 erläutert, als rekursive Strukturen vom Programmierer definiert werden: ✍ ☞
" $
+#
Das ist eine direkte Umsetzung der am Anfang dieses Kapitels gegebenen rekursiven Definition 4.2.
Die leere Liste wird mit dem -Konstruktor angelegt, eine beliebige Liste durch geschachtelten Aufruf des -Konstruktors. Alle Elemente sind vom gleichen Typ. Wegen der großen Wichtigkeit dieses Typs ist er als Standardtyp im Kern von
CAML LIGHT vorhanden. Dessen Konstruktor für die leere Liste heißt , und der
4.4
Rekursive Listen
115
andere Konstruktor ist durch den infix Operator gegeben. Außerdem existiert . Ansonsten ist er zu dem die Listenschreibweise mit eckigen Klammern oben eingeführten Typ äquivalent. Wegen des Komforts verwenden wir den Stan dardtyp zur Darstellung von Listen als Wörterbücher. Der Typ " ist ein Anfangsstück der natürlichen Zahlen, die Funktion " die Identität. Natürlich sind viele der von uns jetzt vorgestellten Wörterbuchoperationen auch schon im Sprachkern vorhanden (siehe Abschnitt 10.1), wir machen aber an dieser Stelle keinen Gebrauch davon, weil wir ja gerade die Implementierung dieser Funktionen erlernen wollen. Es gelten generell die gleichen Beobachtungen wie bei verketteten Listen, die Analogie der Datenstruktur – eine verkettete Liste wird durch einen Zeiger dargestellt, dessen Bezugsvariable ein Record aus einem Elementwert und einem Zeiger, also einer Liste ist – überträgt sich auf die Algorithmen. Wir verwenden hier wie im funktionalen Programmieren üblich die Funktionen mit mehreren Parametern und fassen diese nicht zu einem Tupel zusammen. Damit verzichten wir darauf, die in Abschnitt 4.1.1 beschriebene Schnittstelle genau zu treffen. Wir nennen die Liste stets , die Position und das Element . Das Erzeugen der leeren Liste und eine entsprechende Abfrage sind klar: ✍ ☞
" $
✍
☞
$
$ * +!#
A L G O R I T H M U S 4.8 L ISTENLÄNGE M ETHODE : Die Listenlänge wird durch rekursives Durchlaufen bestimmt: " ✍
☞
" $ # * +!#
– Alg. 4.8 –
116
Kapitel 4
Listen und ihre Implementierung
A L G O R I T H M U S 4.9 S UCHE UND E LEMENTZUGRIFF
IN
L ISTE
M ETHODE : Wie bei den Feldern verwendet die Suche eine Hilfsfunktion, die die Position mitzählt:
✍
" " *" ☞ $ & $
"
##+!#
Beim Zugriff auf die Position der Liste wird der Kopf geliefert, sonst das -te Element der Schwanzliste. Falls die Positionsnummer zu groß ist, wird ein Fehler erzeugt: " " ✍
☞
"
"& & $ $ # " $ #+!#
– Alg. 4.9 – A L G O R I T H M U S 4.10 E INFÜGEN UND L ÖSCHEN IN L ISTE M ETHODE : Das einfache Einfügen geschieht am Kopf der Liste, die Operationen mit fester Position laufen nach dem gleichen Muster wie der Zugriff. Wir erlauben das Einfügen in die leere Liste an beliebiger Position und melden keinen Fehler, falls ein Element gelöscht werden soll, welches nicht in der Liste enthalten war. In diesem Fall geben wir die unveränderte Liste zurück:
✍ $ * +!# ☞ # $ * $ "
✍ "
4.5
Vergleich der Listenimplementierungen
117
$ ' # * $ " $ * +!# " ✍ " $ " $ +!# ☞
" ✍ ☞ ' # " $ " $ * +#! ☞
– Alg. 4.10 – Als Beispiel führen wir noch einmal die bei der Feldimplementierung angegebenen Operationen aus:
✍ ☞
$
✍
☞
# " $
✍
# " $ # #
☞ ✍ ☞ ✍ ☞
✍ ☞
# " $
✍
☞
4.5
# " $
Vergleich der Listenimplementierungen
Den Aufwand der Operationen für die verschiedenen Implementierungen entnehmen wir der folgenden Tabelle.
118
Kapitel 4
Operation search access insert insert-at delete delete-at Speicher
Listen und ihre Implementierung
Array statisch
Zeiger dynamisch
Rekursiv dynamisch
Tabelle 4.3: Vergleich der Listenimplementierungen
top
Abbildung 4.5: Ein Stapel mit Zugriff auf »top«
Vergleichen wir die drei Implementierungen, so scheint die Zeigerversion klare Vorteile zu besitzen. Zugriff, Einfügen und Löschen mit bekannter Position sind in konstanter Zeit ausführbar. Allerdings dürfen wir nicht vergessen, daß das Auffinden des Positionszeigers hier schon linearen Aufwand kostet. Die Feldimplementierung ist zwar beim Zugriff am effizientesten, gerät aber durch ihre starre Speicherstruktur ins Hintertreffen.
4.6
Keller oder Stapel
Eine wichtige Datenstruktur mit vielen Anwendungen in der Informatik ist eine Liste, bei der nur auf das erste Element zugegriffen wird und nur am Anfang eingefügt und gelöscht werden darf. Eine solche Liste nennen wir Keller, Stapel oder auch Stack. Dieses Speicherprinzip ist analog zu einem Holzstoß im Keller oder einem Tellerstapel im Küchenschrank, wo man tunlichst immer nur das oberste, zuletzt daraufgelegte Element entfernt (Abbildung 4.5). Es arbeitet also nach dem LIFOPrinzip (Last In First Out).
4.6
Keller oder Stapel
119
Wichtige Anwendungen sind im Übersetzerbau beim Erkennen von formalen Sprachen zu finden. Hier können etwa korrekte Klammerungen mit Hilfe eines Kellers erkannt werden, indem man die öffnende Klammer auf den Keller ablegt und beim Lesen einer schließenden wieder abräumt. Wird während der Überprüfung auf den leeren Keller zugegriffen, oder bleiben am Schluß noch Klammern übrig, so ist die Klammerschachtelung nicht korrekt 4.12. Auch beim Auflösen der rekursiven Aufrufe einer Funktion, die ja ebenfalls eine Schachtelung erzeugen, kann ein Keller eingesetzt werden. Die Auswertung von arithmetischen Ausdrücken, die in postfix-Notation – also zuerst die Operanden und dann der Operator – gegeben sind, entspricht direkt dem Kellerprinzip. Wenn ein Operator gelesen wird, holt man die beiden obersten Elemente vom Keller, verknüpft sie und schreibt das Ergebnis wieder auf den Keller. Auch das Umschreiben von einem in normaler infix-Schreibweise gegebenen Ausdruck in postfix-Notation ist mit Hilfe eines Kellers möglich. Wir wollen uns nicht um diese Anwendungen kümmern, sondern einen Keller als einen abstrakten Datentyp definieren und eine Implementierung angeben. Wert: Folge von Elementen Operationen: Name Eingabe Rückgabe leerer Stapel ist Stapel leer?
Stapel Element, Stapel Stapel mit neuem obersten Element Stapel oberstes Element Stapel Stapel ohne oberstes Element " Tabelle 4.4: ADT Stapel
Wir sehen, daß alle geforderten Operationen gerade die sind, die mit rekursiven Listen besonders gut, d. h. mit einem einzigen einfachen Zerlegen oder Zusammensetzen der Liste, realisiert werden können. Wir könnten einfach eine Liste als Datentyp wählen und darauf die Operationen definieren. Der Klarheit halber vereinbaren wir aber einen eigenen Datentyp und schränken die Operationen darauf ein.
120
Kapitel 4 Listen und ihre Implementierung
A L G O R I T H M U S 4.11 S TACKOPERATIONEN M ETHODE : ✍ ☞
$ " & + #
✍ ☞
$% "&
"
✍ ☞ $
$ "&
* +!#
" ! $ * $ "& $ "& * +!#
✍ ☞
☞
"
"
"
"
"
✍
$ "&
✍
☞
$ "&
$ "&
* +!#
* +!#
– Alg. 4.11 – Als Anwendung schreiben wir ein Programm, welches die korrekte Klammerung in einer Zeichenliste überprüft: A L G O R I T H M U S 4.12 K LAMMERSCHACHTELUNG E INGABE : Liste von Zeichen. A USGABE : Wahrheitswert, ob Klammerschachtelung korrekt ist. M ETHODE : Verwende Stapel: ✍
4.7
" "
"
"
☞
Schlangen
121
"
$ (& " $ #+!#
✍ ! ☞
✍ ☞
$
– Alg. 4.12 – Keller sind auch mit Feldern oder verketteten Listen effizient zu realisieren. Bei Feldern sind die Operationen und " genau das Einfügen und Löschen an die Stelle, auf die der zeigt, den wir nun besser Kellerzeiger oder Stackpoin ter nennen. Sie sind also ohne Umspeichern zu verwirklichen. Der Zugriff ist in Feldern immer in konstanter Zeit machbar. Bei verketteten Listen ist die Spitze des Kellers am Listenanfang, und folglich sind alle drei Operationen ohne Durchlaufen der Liste zu implementieren.
4.7
Schlangen
Eine ebenso wichtige Struktur wie der Keller ist eine Warteschlange, bei der nach dem FIFO-Prinzip (First In First Out) Elemente nur am Ende eingefügt und nur am Anfang gelöscht werden können. Der Zugriff auf das Anfangselement soll dabei ebenso wie das Einfügen und Löschen möglichst mit konstantem Aufwand ausführbar sein. Eine solche Datenstruktur nennen wir Schlange oder Queue. Hier sind im Gegensatz zum Keller der Zugriff auf und das Löschen des ersten Elementes zusammengefaßt, die Funktion liefert ein Element und eine Schlange zurück. Die Implementierung der drei Operationen in konstanter Zeit ist nicht ganz so einfach. Nehmen wir als Datenstruktur eine normale Liste, so bedeutet
122
Kapitel 4 Listen und ihre Implementierung
Wert: Folge von Elementen Operationen: Name Eingabe Rückgabe leere Schlange Queue Test auf leer
Element, Queue Queue mit Element am Ende Queue Anfangselement und restliche Schlange Tabelle 4.5: ADT Schlange (Queue)
das Einfügen am Ende. Mit den Methoden des abstrakten Datentyps Liste aus Abschnitt 4.1.1 heißt das: ✍
Selbst wenn die Länge der Liste bekannt ist, bedeutet das ein Durchlaufen der kompletten Liste. Versuchen wir einen besser passenden Datentyp mit ✍
zu definieren, hilft das nichts. Denn wiederum beim Einfügen muß nun der alte Schwanz der Liste in die mittlere eingefügt werden, d. h. ein rekursiver . Aufruf ist nötig, die Komplexität ist wieder in Wir müssen also das Einfügen effizienter gestalten. Das ging sehr gut mit einem Keller, und auch das Zugreifen und Entfernen ist mit einem Keller effizient. Es darf nur nicht derselbe sein! Wir stellen also jetzt zwei Keller bereit – einen für das Einfügen und einen für das Löschen. Falls der letztere leer ist, wird der Einfügekeller umgedreht und so zum neuen Löschkeller. A L G O R I T H M U S 4.13 S CHLANGENOPERATIONEN M ETHODE : Verwende zwei Keller: ✍ ☞
✍ ☞
% ! ! +#
! !
4.7
Schlangen
✍ ☞ $
* +!
! !
123
*
)
✍
% ! ! % ! ! % ! ! "!#
☞
" *
✍
% ! ! % ! ! % ! ! "!#
☞
– Alg. 4.13 –
Das Einfügen ist nun offensichtlich in konstanter Zeit möglich. Auch der Auf wand für das Löschen ist nun im Mittel . Zwar kann im Einzelfall die zweite Liste umgedreht werden müssen, was linearen Aufwand bedeutet, aber diese Elemente können dann mit je einer Operation entfernt werden. Folglich ist der Aufwand, Elemente zu entfernen, , und für jedes einzelne Element er gibt sich ein Aufwand von .
B EISPIEL 4.3 Als Beispiel sollen hier die eben definierten Operationen angewendet werden:
✍ ☞
%! !
%! !
! !
✍
☞
#
☞
#
✍
✍ ☞
# # %! !
# # %! !
✍ ☞
✍ ☞
! $
%
"!
#
% ! !
Ein echtes Anwendungsbeispiel befindet sich in Abschnitt 6.1.
% ! !
124
Kapitel 4 Listen und ihre Implementierung
Auch für Felder und verkettete Listen muß man etwas am Datentyp feilen, um eine effiziente Schlangenimplementierung zu erhalten. Eine Schlange als verkettete Liste stellen wir durch zwei Zeiger dar, einer verweist auf den Anfang, einer auf das Ende. Damit ist die Position für Einfügen und Löschen bekannt, und die Operationen benötigen konstante Zeit. Bei einem Feld führen wir zusätzlich zum Pegelzeiger, der jetzt das Listenende markiert, noch einen weiteren ein, der auf den Anfang verweist. Die Operation liest dieses Element aus und setzt den Zeiger hoch. So läuft die Schlange quasi durch das ganze Feld. Um den Speicherplatz besser auszunutzen, schließen wir das Feld zu einem Ring, d. h. wir führen die Indexrechnung modulo der Feldlänge durch.
Kapitel 5 Sortierverfahren 5.1
Einführung
Wie wir in Kapitel 4 gesehen haben, sind Listen keine sehr effiziente Datenstruktur zur Verwaltung von Wörterbüchern. Insbesondere das Suchen erforderte bei allen Implementierungen ein sequentielles Durchlaufen der Liste. Eine Beschleunigung kann man durch das Verfahren der binären Suche aus Abschnitt 2.5 erreichen, das allerdings ein Sortieren der Liste voraussetzt. Allgemein kann man feststellen, daß eine Aufbereitung von Informationen für Endnutzer eigentlich immer ein Sortieren der Daten erfordert. Das kann durch explizites Umordnen der Datensätze oder durch Darüberlegen einer eigenen Struktur geschehen, die ähnlich wie der Positionstyp bei Listen die neue Reihenfolge bestimmt. Es gibt eine Vielzahl verschiedener Sortieralgorithmen, die nach unterschiedlichen Gesichtspunkten optimiert sind. Wir wollen uns mit Sortieralgorithmen beschäftigen, die auf dem Vergleich von Elementen beruhen und als Ergebnis eine sortierte Liste liefern. Die abstrakte Spezifikation eines Sortierverfahrens lautet wie folgt. A L G O R I T H M U S 5.1 S ORTIERVERFAHREN E INGABE : Eine Liste tion .
von Werten und eine auf diesen definierte Ordnungsrela-
A USGABE : Eine sortierte Liste der gleichen Werte, d. h. für die Ergebnisliste gilt
126
Kapitel 5
Sortierverfahren
M ETHODE : Verwende Vergleichsoperationen. A UFWAND : Wird durch die Anzahl der Vergleiche bestimmt. – Alg. 5.1 – Wir werden die verschiedenen Algorithmen am Beispiel der ganzen Zahlen mit der natürlichen Ordnung vorstellen. Eine Verallgemeinerung auf andere Ordnungsrelationen und andere Datentypen ist offensichtlich. Wir bieten einen funktionalen Zugang zu den Algorithmen – unsere zugrundeliegende Datenstruktur ist also eine rekursive Liste. In fast allen Standardwerken werden solche Sortieralgorithmen auf Feldern durchgeführt und in diesem Fall noch dadurch unterschieden, ob sie die Elemente des Feldes am Platz tauschen können oder ein Hilfsfeld benötigen. Diese imperativen Aspekte deuten wir nur kurz an und verweisen auf die reichlich vorhandene Literatur.
5.2
Elementare Sortierverfahren
Zwei der drei hier vorgestellten Verfahren haben wir bereits als Beispiele für verschiedene Algorithmenentwurfsprinzipien in Kapitel 2 kennengelernt. Wir fassen diese noch einmal zusammen.
5.2.1
Sortieren durch Auswahl
A L G O R I T H M U S 5.2 S ORTIEREN DURCH A USWAHL E INGABE : eine Liste von ganzen Zahlen. A USGABE : die sortierte Liste. M ETHODE : Wende die folgende Funktion an: " ✍
5.2
☞
127
Elementare Sortierverfahren
$ $ ' " $ " $ "!#
– Alg. 5.2 –
Die Realisierung der Funktion
findet man in Abschnitt 2.4.
Den Aufwand können wir wieder über eine Rekursionsgleichung bestimmen. Wir setzen eine Liste mit Elementen voraus. In wird aufgerufen. Dann folgt der rekursive Aufruf für die um 1 verringerte Listenlänge. Die zugehörige Rekursionsgleichung lautet selsort
selsort
min_restlist
selsort
Für die Minimumsuche einer -elementigen Liste braucht man Vergleiche, deshalb hat Satz obige Rekursionsgleichung die Lösung nach dem folgenden
. Das ist unabhängig von der Anordnung der selsort Daten, beschreibt also den minimalen genauso wie den maximalen Aufwand.
S ATZ 5.1 Die Rekursionsgleichung
hat die Lösung
.
Der Beweis durch vollständige Induktion wird analog zu dem von Satz 2.1 geführt. B EISPIEL 5.1 Wir betrachten das Sortieren der Liste Schritt: ✍ ☞
# # " $
. Nach dem ersten
haben wir das Kopfelement ergeben:
der Ergebnisliste. Die weiteren rekursiven Schritte
128
Kapitel 5
✍ ☞ #
# " $ ✍ ☞
Sortierverfahren
# # " $
Auf dem Weg aus der Rekursion heraus entsteht dann die Ergebnisliste: ✍ ☞
5.2.2
" $
Sortieren durch Einfügen
A L G O R I T H M U S 5.3 S ORTIEREN DURCH E INFÜGEN E INGABE : eine Liste von ganzen Zahlen.
A USGABE : die aufsteigend sortierte Liste mit den gleichen Zahlen. M ETHODE :
Ist die leere Liste, so ist sortiert. Sonst ordne das erste Element in der sortierten Restliste ein. Das Sortieren der Restliste soll durch Rekursion mit dem gleichen Algorithmus
erfolgen: " ✍
☞
# $ " $ " $ * +!#
– Alg. 5.3 –
5.2
Elementare Sortierverfahren
129
entnehme man Abschnitt 2.3. Die Anzahl der VerDie Funktion durchführt, hängt vom Wert von ab. gleiche, die die Funktion Ist kleiner als der Kopf der sortierten Liste, so findet nur ein Vergleich statt, ist Vergleiche erforgrößer als deren Maximum, so sind derlich. Bei einer Listenlänge von wird -mal aufgerufen – für Listen mit Längen von 1 bis . Der Gesamtaufwand ist also mindestens
insort
und höchstens
insort
Im Mittel wird jedesmal die halbe Liste durchlaufen, der Aufwand bleibt also quadratisch.
. Nach dem Aufbau der B EISPIEL 5.2 Betrachtet wird wieder die Liste Rekursion werden, beginnend beim letzten, nacheinander alle Elemente in die sortierte Restliste eingefügt: ✍ ☞
# " $
✍ ☞
# " $%
✍ ☞
5.2.3
# " $
Sortieren durch Vertauschen
Etwas komplizierter ist die Herleitung des als Bubblesort bekannten Verfahrens, das auf dem Vertauschen von Nachbarelementen beruht. Die Grundlage ist diesmal direkt die abstrakte Spezifikation der Sortierverfahren, nach der eine Liste genau dann sortiert ist, wenn alle Nachbarelemente in der richtigen Relation stehen. Wir formulieren den Algorithmus im imperativen Stil mit Verwendung einer Schleife.
130
Kapitel 5
Sortierverfahren
A L G O R I T H M U S 5.4 B UBBLESORT E INGABE : eine Liste .
A USGABE : die sortierte Liste. M ETHODE :
1. Falls sortiert ist, ist nichts mehr zu tun.
2. Sonst gehe alle Elemente von durch. Falls Nachbarelemente in falscher Reihenfolge stehen, vertausche sie. 3. Setze Vorgang an Punkt 1 fort. – Alg. 5.4 – Für dessen funktionale Umsetzung entwickeln wir zuerst eine Funktion, die einen Vertauschungsdurchlauf durch die Liste beschreibt. Dazu vergleichen wir die ersten beiden Listenelemente, behalten das kleinere als Listenkopf und lassen das größere weiter durch die Restliste wandern. ✍
"
☞
! " $ " $ * +!#
Wir bemerken, daß nach einem solchen Durchlauf das Maximum der Liste am Ende steht und deshalb im nächsten Durchlauf nicht mehr berücksichtigt werden muß. Wir teilen also die Rückgabe in zwei Listen auf, von denen die zweite bereits sortiert ist. Wenn wir diesen Prozeß wiederholen, bis die zweite sortierte Liste die volle Länge erreicht hat, sind wir fertig. Dieser Fall tritt ein, denn die zweite Liste wächst in jedem Schritt um ein Element. Wenn wir uns außerdem merken, ob bereits die erste Liste sortiert ist, können wir den Algorithmus schon früher beenden. Zu diesem Zweck liefert die Funktion jetzt noch einen booleschen . Wert
5.2
✍
☞
Elementare Sortierverfahren
"
131
! ) " $ " $ " $
#+!#
zu setzen. Wir nehmen an, daß Beim ersten Aufruf ist der Parameter auf die Liste sortiert ist. Stimmt das nicht, wird einmal vertauscht, und der rekursive Aufruf erfolgt mit gleich .
Der Rest ist wieder einfach. Wir durchlaufen die Liste, oder besser gesagt ihren unsortierten Teil, solange, bis sie sortiert ist. Dann fügen wir die beiden Teillisten, die zurückgibt, zusammen: ✍
"
*
☞
! $ " $%) " $ "!#
Durch den Operator werden die zwei Listen aneinandergehängt. Für den Aufwand gilt hier ähnliches wie beim Sortieren durch Einfügen. Ist die Liste bereits sortiert, so wird das in einem Durchlauf erkannt und Vergleiche reichen -mal aufgerufen, die Liste wird in jeaus. Schlimmstenfalls wird dem Schritt um eins kleiner, und der Aufwand pro Schritt ist linear (ein Aufruf von und ein Aneinanderhängen). Der maximale Gesamtaufwand ist also bubblesort
ebenso der mittlere Aufwand. B EISPIEL 5.3 Wir betrachten wieder unsere Standardliste sionsschritte liefern dann:
. Die Rekur-
132
Kapitel 5
✍ ☞ ! (# $ # $ ✍ ☞
$
" $
Sortierverfahren
$
! # " $ $ # " $%
$ * +!
und das rekursive Zusammensetzen ergibt: ✍ ☞
" $
Alle elementaren Sortierverfahren sind also vom mittleren Aufwand her quadratisch. In den imperativen Versionen auf Feldern kommen sie ohne zusätzliche Hilfsfelder aus. Bei der Herleitung des Divide & Conquer-Prinzips in Abschnitt 2.5 haben wir bereits ein Sortierverfahren mit deutlich besserer Zeitkomplexität kennengelernt. Dieses und verwandte Verfahren stellen wir im nächsten Abschnitt vor.
5.3
Sortieren durch Mischen
Den folgenden Verfahren liegt die Beobachtung zugrunde, daß zwei sortierte Listen zu einer verschmolzen werden können und dabei höchstens so viele Vergleiche wie die Gesamtanzahl der Elemente gebraucht werden. Dieses Mischen wird mit dem folgenden Algorithmus realisiert. A L G O R I T H M U S 5.5 V ERSCHMELZEN ZWEIER L ISTEN E INGABE : zwei sortierte Listen
und .
A USGABE : die sortierte Liste , die die Elemente von
und
enthält.
5.3
Sortieren durch Mischen
M ETHODE : 1. Vergleiche die Listenköpfe von
133
und .
2. Entferne den kleineren aus seiner Liste – er bildet den Kopf von .
3. Den Schwanz von bilden die verschmolzenen Restlisten von
und .
Die Umsetzung in CAML LIGHT verwendet eine erweiterte Form der Musterfilterung. Hierbei wird das »pattern matching« auf mehr als einen Parameter angewendet: " ✍
☞
" $ " $%) " $ " $ "!#
– Alg. 5.5 – B EISPIEL 5.4 Wir verschmelzen nun als Beispiel einige Listen:
$ ✍ ☞ # " $
✍ ☞ # "
✍ ☞ # "
$
Die folgenden Mergesort-Algorithmen verwenden alle diese eine Mischfunktion. Sie unterscheiden sich nur in der Organisation des Aufteilens und Zusammenfügens sortierter Teillisten.
5.3.1
Rekursives Mischen
Der erste Algorithmus ist die bekannte Divide & Conquer-Version.
134
Kapitel 5
Sortierverfahren
A L G O R I T H M U S 5.6 S ORTIEREN DURCH M ISCHEN E INGABE : eine Folge von
ganzen Zahlen.
A USGABE : die sortierte Folge. M ETHODE : 1. Teile die Folge in zwei gleich lange Teilfolgen. 2. Sortiere beide Teilfolgen. 3. Verschmelze die sortierten Teilfolgen zu einer sortierten Gesamtfolge. – Alg. 5.6 – Das Aufteilen schreibt die vordersten Elemente wechselweise in die Teillisten und hat somit wie das Verschmelzen linearen Aufwand. Der Gesamtaufwand berechnet sich also aus der Rekursionsgleichung
und liegt somit nach Satz 2.6 in Maximalaufwand.
. Diese Formel gilt für Minimal- und
In CAML LIGHT implementiert lautet der Algorithmus: ✍
"
☞ % " $ " $
✍ " "
"
$ +!#
☞
&
"
" $ " $ " $ * +!#
5.3
Sortieren durch Mischen
B EISPIEL 5.5 Wir sortieren die Liste ergibt:
✍ ☞
# " %$ # " %$
✍ ☞ '# " $ # " $
. Das rekursive Aufteilen
✍ ' " $ ☞ " $ ✍ $ ☞ '# " # " $
✍ ' " $ ☞ " $
135
✍ ☞ ' " $ " $ ✍ ☞ ' " $ " $
Das Zusammenmischen liefert schließlich das Ergebnis: ✍
☞ # " $
136
Kapitel 5
5.3.2
Sortierverfahren
Direktes Mischen
Bei dieser Methode werden nach dem Aufbau der Rekursion erst 1-elementige dann 2-elementige und dann immer längere benachbarte Listen miteinander verschmolzen. A L G O R I T H M U S 5.7 S ORTIEREN DURCH DIREKTES M ISCHEN E INGABE : Liste .
A USGABE : sortierte Liste. M ETHODE :
1. Fasse als Liste von (sortierten) 1-elementigen Teillisten auf. 2. Verschmelze benachbarte Teillisten solange, bis die Gesamtliste 1-elementig ist, also eine sortierte Liste enthält. – Alg. 5.7 – Punkt 1 dieser Methode wird durch die Funktion erledigt: "
✍
" $ " $% $ #+!#
☞
Diese Funktion »packt« die Elemente einer Liste ein:
✍ ☞
$ # " $ " $
Für Punkt 2 formulieren wir eine Funktion, die einmal durch eine Liste (von Listen) läuft und dabei alle benachbarten Elemente verschmilzt: ✍
"
5.3
☞
$
Sortieren durch Mischen
137
" $% $ " $ " $%* +!
✍ ☞ # " $ " $
Diese Funktion wird so lange aufgerufen, bis die gewünschte 1-elementige Liste entstanden ist:
" )
✍
*
☞
" $ " $%) " $ "!#
✍ ☞ # " $
Der Aufwand in einem Schritt ist kleiner oder gleich der Gesamtanzahl der Elemente der Ausgangsliste. Da sich die Listenlänge in jedem Schritt verdoppelt, werden Schritte durchgeführt. Der Aufwand ist also wie beim rekursiven . Mischen im Minimal- und Maximalfall
, die dieses Verfahren steuert, braucht also nur aus einer Die Funktion Liste von Elementen eine Liste von Listen zu generieren, diese zu verschmelzen und dann das erste und einzige Element herauszulesen. Wir formulieren sie mit der Listenbildungsfunktion als Parameter, um sie im nächsten Verfahren wiederverwenden zu können:
✍ ☞
*
$
" $%
✍ ☞ $ " $
$ " $ * +!#
" $ * +!#
Wieder haben wir eine möglicherweise in der Liste bereits vorhandene Vorsortierung nicht ausgenutzt. Das geschieht im nächsten Verfahren – dem natürlichen Mischen.
138
Kapitel 5
5.3.3
Sortierverfahren
Natürliches Mischen
Im Gegensatz zum direkten Mischen wird die Liste in möglichst lange sortierte Teillisten zerlegt, die dann mit der gleichen Funktion verschmolzen werden: ✍
"
☞
$ & " $ " $%
$ #+!#
Diese Funktion liefert eine Liste von möglichst langen sortierten Teillisten. Für leere und 1-elementige Listen ist das offensichtlich. Im allgemeinen Fall bildet man zunächst eine solche Liste für den Schwanz. In diese wird dann das Kopfelement eingefügt – entweder als Kopf der ersten Liste oder als neue, eigene Liste. Wie bei der -Funktion wird die Liste einmal durchlaufen. B EISPIEL 5.6 Wir wenden auf ✍
# " $ " $
☞
Daraus entsteht: ✍
# " $ " $
☞
und daraus wiederum das Ergebnis: ✍ ☞
" $ " $%
an. Zunächst entsteht dadurch:
5.4
Quicksort
139
Zum Vergleich hier noch der direkte Aufruf: ✍ ☞
# " $ " $
Das natürliche Mischen wendet nun ✍ ☞
auf diese Funktion an:
$ " $ " $ * +!#
Der Maximalaufwand ist der gleiche wie beim direkten Mischen. Falls die Liste absteigend sortiert ist, sind alle Teillisten 1-elementig. Im günstigsten Fall – der sortierten Liste – genügt ein Durchlauf und es gilt
5.4
natmerge
Quicksort
Zum Abschluß dieses Kapitels wollen wir das Verfahren vorstellen, das sich in der Praxis als das schnellste herausgestellt hat. Es trägt deshalb nicht zu unrecht den Namen Quicksort. Es handelt sich ebenfalls um ein Divide & Conquer-Verfahren, bei dem jedoch – im Gegensatz zum Mischen – die wesentliche Arbeit beim Aufteilen der Folge geleistet wird. A L G O R I T H M U S 5.8 Q UICKSORT E INGABE : eine Liste .
A USGABE : die sortierte Liste. M ETHODE :
aus . Teile auf in und , wobei alle Elemente aus
aus größer als sind.
1. Nimm ein beliebiges Element 2.
kleiner und alle Elemente
140
Kapitel 5
3. Sortiere
Sortierverfahren
und .
4. Hänge die sortierten Listen aneinander. – Alg. 5.8 – Wie man sieht, ist das ausgewählte Element wesentlich für die Aufteilung der Liste. Es wird deshalb auch als ausgezeichnetes Element oder Pivotelement bezeichnet. Die Aufteilung an sich erfolgt mit einer allgemeinen Filterfunktion, die alle Elemente mit einer vorgegebenen Eigenschaft aus einer Liste »herausfiltert«: ✍
"
☞
++
*
$ $ #+!#
"
Diese Funktion höherer Ordnung wird in der eigentlichen Sortierfunktion mit Funktionen als Parameter aufgerufen, die auf »kleiner« oder »größer« als das Pivotelement testen: ✍
"
☞ $
$ " $ * +!#
)
Als Pivotelement wird jeweils der Kopf der Liste gewählt. Falls in der Liste mehrere Werte gleich auftreten dürfen, können diese entweder einer der beiden Listen zugeschlagen werden oder eine eigene Liste bilden, was noch effizienter ist. B EISPIEL 5.7 Wir wenden nun Quicksort auf die Liste ersten Filtervorgang erhalten wir:
✍ ☞
" $
✍
☞
✍
)
)
an. Im
5.4
# " %$ # " %$
☞
Quicksort
141
Mit und verfahren wir rekursiv genauso und erhalten so . Dann müssen wir nur noch die Listen aneinanderhängen:
✍ ☞
# " $
und
Das Filtern und das Aneinanderhängen haben linearen Aufwand. Der günstigste Fall liegt vor, wenn beide Listen gleich lang sind. Für den Gesamtaufwand gilt hier
mit der Lösung
qsort
Das gilt auch für den Aufwand im Mittel. Im schlechtesten Fall allerdings – etwa wenn die Liste bereits sortiert ist – gilt
. also qsort
In der Regel nimmt man es bei der Wahl des Pivotelementes etwas genauer, um nun diesen schlechtesten Fall möglichst unwahrscheinlich zu machen. Als gute Wahl hat sich der Median, das mittlere von drei Elementen – eines vom Anfang, eines vom Ende, das dritte aus der Listenmitte – erwiesen. Das Auffinden dieser Elemente ist in linearer Zeit möglich, so daß sich prinzipiell an der Komplexität nichts ändert. Besser ist die Situation noch bei einer Feldimplementierung, da hier der Zugriff in konstanter Zeit erfolgt. Eine solche Implementierung kommt auch mit dem Speicherplatz des Feldes aus. Zum Aufteilen laufen zwei Indexzeiger von links und rechts durch die Liste, bis der linke ein größeres, der rechte ein kleineres Element gefunden hat, die dann getauscht werden. Treffen sich die Zeiger, so wird das Pivotelement an diese Stelle getauscht. Alle Elemente davor sind nun
142
Kapitel 5
Verfahren Sortieren durch Auswahl Sortieren durch Einfügen Bubblesort rekursives Mischen direktes Mischen natürliches Mischen Quicksort
Sortierverfahren
Tabelle 5.1: Vergleich der Sortierverfahren
kleiner, die dahinter größer. Nach dem Sortieren steht das sortierte Feld am Platz des alten.
Im nächsten Kapitel werden wir noch ein Sortierverfahren kennenlernen, das in der imperativen Implementierung am Platz funktioniert und Maximalaufwand hat. Wir fassen die Komplexität der Sortierverfahren noch einmal in Tabelle 5.1 zusammen.
Betrachten wir das Sortieren von Listen noch einmal unter dem Aspekt der Wörterbuchoperationen, müssen wir feststellen, daß durch eine sortierte Liste als Wörterbuch ein wesentlicher Effizienzgewinn nur beim Suchen in Feldern erzielt . Einfüwerden kann. Durch die binäre Suche ist der Aufwand nun aus gen und Löschen können zwar durch binäre Suche die Position bestimmen, bleiben aber linear, weil Elemente verschoben werden müssen, um Platz zu schaffen oder Lücken zu schließen. Wir suchen also nach neuen, effizienteren Datentypen.
Kapitel 6 Bäume und Suchbäume 6.1 6.1.1
Bäume Datentypen und Anwendungen
Neben den linearen Listen sind wohl Bäume die wichtigsten Datenstrukturen. Das manifestiert sich durch Anwendungen in fast jedem Gebiet der Informatik. Bäume lassen sich als verallgemeinerte Listen auffassen. Eine Liste besteht aus einem Element und einer Restliste, ein Binärbaum dagegen aus einem ausgezeichneten Element, der Wurzel und zwei Teilbäumen – oder er ist leer. Die Elemente, die Information tragen, nennen wir Knoten. Formal können wir wieder eine rekursive Definition angeben.
D EFINITION 6.1 Ein Blatt ist ein (leerer) Binärbaum. Sind und Binärbäume ein Binärbaum über mit der Wurzel . und ist , so ist
Der Name Binärbaum deutet an, daß jeder Knoten genau zwei Teilbäume besitzt. Da diese Baumstruktur für unsere Anwendungen am häufigsten auftritt, sprechen wir auch oft nur von einem Baum. Zur Veranschaulichung zeichnen wir die Wurzel oben in die Mitte und unterscheiden zwischen linkem und rechtem Teilbaum. Den leeren Baum zeichnen wir als (Abbildung 6.1). Ein Baum kann zu einer linearen Liste degenerieren. Das ist aber, wie wir gleich sehen werden, unerwünscht. In CAML LIGHT können Binärbäume genau wie oben theoretisch besprochen implementiert werden. Sie bestehen entweder aus einem Blatt, das den leeren Baum repräsentiert, oder aus einem informationstragenden Knoten und zwei Teilbäu-
144
Kapitel 6
Bäume und Suchbäume
3
5
12
6
9
7
Abbildung 6.1: Ein einfacher Binärbaum
men. Wir geben auch gleich noch Zugriffsfunktionen auf die jeweiligen Teile des Datentyps an: A L G O R I T H M U S 6.1 Z UGRIFFSFUNKTIONEN FÜR B INÄRBAUM M ETHODE : ✍
☞
+#
✍
☞ % ✍
"!#
* +!#
☞ ✍ ☞
"!#
– Alg. 6.1 – Einen Binärbaum kann man explizit durch geschachtelten Konstruktoraufruf angeben, oder man schreibt eine spezielle Einfügefunktion und ruft diese auf.
6.1
Bäume
145
B EISPIEL 6.1 Wir wollen den Baum aus Abbildung 6.1 durch explizite Konstruktion erzeugen und danach seine Wurzel bestimmen: ✍
☞
(#
✍ ☞
#
Solche Binärbäume werden als Wörterbücher verwendet, wobei durch geeignete Bedingungen die Operationen effizient implementiert werden können (siehe 6.4). Fassen wir den Binärbaum als verkettete Struktur auf, so stellen wir fest, daß jeder Knoten genau zwei Nachfolger oder Söhne, und jedes Element außer der Wurzel genau einen Vorgänger oder Vater hat. Die Elemente ohne Nachfolger, in unserem Fall also die leeren Bäume, bezeichnen wir auch hier als Blätter. In der Informatik wachsen die Bäume so gesehen von oben nach unten. Die Einfügeoperationen generieren – wie in der Natur – meistens ein neues Blatt, oder es wird eine neue Wurzel gebildet und der ganze Baum geeignet aufgepfropft. Dazu später aber mehr. Von Fall zu Fall ist es günstig, wenn die Blätter ebenfalls Information tragen. Diese kann von einem anderen Typ sein als die der inneren Knoten:
146
Kapitel 6
✍
☞
Bäume und Suchbäume
+
Als Beispiel für die Anwendung eines solchen Baumes definieren wir einen Baumtyp, der einen arithmetischen Ausdruck darstellen kann. Die Blätter sind die Operanden – hier ganze Zahlen –, und die inneren Knoten enthalten die Operatoren: ✍ ☞
✍
# + " #
☞
! $
"!
$
$
Diese Definition spiegelt genau die rekursive Definition eines arithmetischen Ausdrucks wider: Ein Ausdruck ist entweder eine ganze Zahl oder eine Summe, Differenz, Produkt oder Quotient zweier Ausdrücke. Der Baum stellt den Ausdruck dar und ist noch einmal in Abbildung 6.2 dargestellt. Ein solcher Ausdruck kann leicht ausgewertet werden.
A L G O R I T H M U S 6.2 A USWERTUNG EINES ARITHMETISCHEN A USDRUCKS E INGABE : ein Baum, der den Ausdruck repräsentiert.
6.1
Bäume
147
+
4
*
5
+
6
7
Abbildung 6.2: Baumdarstellung von
A USGABE : der Wert des Ausdrucks. M ETHODE : In jedem inneren Knoten wird der Operator auf die Ergebnisse der Auswertung der Teilbäume angewendet. Die Auswertung eines Blattes ergibt die dargestellte Zahl: " ✍
# ☞ "!#
– Alg. 6.2 – Die Anwendung auf das letzte Beispiel, den Baum , ergibt: ✍ ☞
#
Weitere Anwendungen von Bäumen treten bei der Syntaxanalyse formaler Sprachen auf. Hierarchische Strukturen, wie z. B. ein Dateisystem, lassen sich mit Bäumen gut modellieren und Bäume strukturieren auch den Suchraum bei Algorithmen nach dem Versuch-und-Irrtum-Prinzip (siehe Abschnitt 8.1). Bei diesen Anwendungen, zu denen noch eine Vielzahl hinzugefügt werden könnte, handelt es sich allerdings meistens nicht um Binärbäume, sondern um
148
Kapitel 6
Bäume und Suchbäume
solche der Ordnung größer als . Unter der Ordnung eines Baumes verstehen wir die maximale Anzahl der Nachfolger, die ein innerer Knoten besitzen kann. Wir erlauben im allgemeinen Knoten unterschiedlicher Ordnung in einem Baum. Bei unserer Definition von Binärbäumen hatten wir die genaue Ordnung angenommen – jeder innere Knoten hat also genau zwei Nachfolger. Für Bäume mit leeren Blättern ist das keine Einschränkung, denn Knoten mit nur einem »echten« Nachfolger wird ein Blatt als zweiter Nachfolger zugeordnet. Für Ausdrucksbäume und allgemein solche mit Information tragenden Blättern muß hingegen der Datentyp erweitert werden, falls (innere) Knoten der Ordnung zulässig sind: ✍
☞
+
Als weitere Verallgemeinerung kann man den unären Knoten auch noch einen eigenen Typ zuordnen. So symbolisieren die unären Knoten bei arithmetischen Ausdrücken etwa Standardfunktionsaufrufe: ✍
☞
" # $ +# + #
Eine Anwendung wäre etwa die Darstellung des Ausdrucks : ✍ ☞
"! $
$
6.1
6.1.2
Bäume
149
Höhe von Bäumen
Eine wichtige Charakterisierungsgröße von Bäumen ist die Höhe. Darunter versteht man die maximale Rekursionstiefe für innere Knoten, die bei der Konstruktion des Baumes auftritt. Ein Baum, der nur aus einem Blatt besteht, hat die Höhe . Ansonsten kann die Höhe leicht rekursiv bestimmt werden. Die Höhe kennzeichnet den maximalen Abstand eines Blattes von der Wurzel. Sie ist also gleich der Zahl der inneren Knoten, die beim Abstieg von der Wurzel zu dem entferntesten Blatt durchlaufen werden. Wir beschränken unsere Betrachtungen jetzt wieder auf Binärbäume mit leeren Blättern. A L G O R I T H M U S 6.3 H ÖHE VON B INÄRBÄUMEN E INGABE : ein Baum. A USGABE : seine Höhe. M ETHODE : Der leere Baum hat die Höhe , und die Höhe eines beliebigen Baumes ergibt sich durch Addition von zum Maximum der Höhen seiner Unterbäume: " ✍
☞
"!#
– Alg. 6.3 – B EISPIEL 6.2 Wir bestimmen die Höhe des Baumes aus Abbildung 6.1:
✍ ☞ #
Die Komplexität vieler Algorithmen hängt von der Höhe des Baumes ab. Deswegen interessieren wir uns für die Beziehung zwischen Höhe und Anzahl der inneren Knoten.
150
Kapitel 6
Bäume und Suchbäume
S ATZ 6.1 Ein Binärbaum der Höhe enthält höchstens innere Knoten. Umgekehrt gilt für die Höhe eines Binärbaumes mit inneren Knoten.
B EWEIS . Zum Beweis bauen wir den Baum Stufe für Stufe auf. Die Wurzel allein mit zwei (leeren) Blättern ist ein Binärbaum der Höhe . Umgekehrt sind alle Bäume der Höhe von dieser Struktur. Für stimmt die Behauptung also. Genauso stimmt sie für .
Um einen Baum der Höhe mit maximaler Knotenzahl zu konstruieren wird jedes Blatt durch einen 1-elementigen Baum ersetzt. Die Anzahl der Knoten ist also , die der Blätter .
Genauso gehen wir für einen Baum der Höhe vor. Wir ersetzen seine durch 1-elementige Bäume. Für die Anzahl der Knoten gilt
Blätter
und wir haben durch vollständige Induktion die Aussage
bewiesen. Diese ist aber identisch mit der Behauptung
.
Die hier betrachteten Stufen eines Baumes bezeichnet man auch als Niveaus. OLGERUNG 6.2 Numeriert man die Stufen von F-ten Stufe höchstens Elemente.
bis
durch, so befinden sich auf der
D EFINITION 6.2 Ein Baum, der alle Niveaus bis auf das letzte voll besetzt hat, heißt vollständig.
F OLGERUNG 6.3 Für vollständige Bäume gilt . Schreibweise
oder in anderer
Die Höhe eines Baumes hängt zwar oft logarithmisch von der Anzahl der Elemente ab, ihre Berechnung allerdings ist nur mit linearem Aufwand möglich (wir zählen hier Additionen und Maximumbildungen), da die Lösung der Rekursionsgleichung ist. aus
6.1
6.1.3
Bäume
151
Baumdurchläufe
Man kann die Elemente, die in einem Baum gespeichert sind, auf verschiedene Arten nacheinander durchlaufen und so in eine lineare Liste einordnen. Eine erste Idee mag sein, die beim Satz über die Höhe definierten Stufen eine nach der anderen zu betreten. Dieser Breitendurchlauf verlangt in der Tat eine recht knifflige Lösung, weil er nicht der rekursiven Struktur des Datentyps entspricht. Wir verwenden eine Schlange von Bäumen als zwischenzeitliche Datenstruktur. In diese tragen wir anfangs den Ausgangsbaum ein. Falls das erste Element dieser Schlange ein echter Baum ist – also kein Blatt –, fügen wir seine Wurzel in die Ergebnisliste ein. Zu Beginn wird so die Baumwurzel als erstes Element eingetragen. Gleichzeitig reihen wir die beiden Teilbäume hinten in die Schlange ein. Der Rest der Ergebnisliste entsteht nun durch Umwandeln dieser neuen Schlange in eine Liste. Alle Knoten einer Stufe werden somit immer vor dem ersten aller ihrer Nachfolger in die Schlange eingefügt. Deshalb besitzt das Verfahren die geforderten Eigenschaften: A L G O R I T H M U S 6.4 B REITENDURCHLAUF E INGABE : ein Binärbaum. A USGABE : die Liste seiner Knoten gemäß Breitendurchlauf. M ETHODE : Verwende eine Schlange: " ✍
☞
%! ! " $ "!# %" $%'
✍
152
Kapitel 6
☞
)
$ #+!#
'
Bäume und Suchbäume
– Alg. 6.4 – Sehr viel einfacher sind die Durchläufe der Tiefe nach, bei denen die Teilbäume komplett durchsucht werden, bevor die Wurzel oder der nächste Teilbaum betreten wird. Üblicherweise gilt hier »links vor rechts«, so daß von den sechs Möglichkeiten noch drei übrigbleiben: Die symmetrische Reihenfolge Inorder »linker Teilbaum Teilbaum«,
Wurzel
die Präfixreihenfolge Preorder »Wurzel und
rechter Teilbaum«
linker Teilbaum
die Postfixreihenfolge Postorder »linker Teilbaum zel«.
rechter
rechter Teilbaum
Wur-
A L G O R I T H M U S 6.5 T IEFENDURCHLAUF E INGABE : ein Binärbaum. A USGABE : eine Liste der Knoten gemäß Tiefendurchlauf. Die entsprechenden Funktionen sind ganz einfach: " " ✍
☞
"
# $
– Alg. 6.5 –
"
" " " $ $ #+ +!#!# " $%* +!
6.2
Der Heap als Prioritätswarteschlange
153
B EISPIEL 6.3 Unser Beispielbaum aus Abbildung 6.1findet hier nochmals Verwendung: ✍ ☞ ✍ ☞ ✍ ☞ ✍ ☞
" "
# " $ # " $ # " $ # " $
Im Zusammenhang mit der symmetrischen Reihenfolge sind die Begriffe symmetrischer Vorgänger bzw. symmetrischer Nachfolger eines Knotens interessant. Das ist der dem Knoten vorangehende bzw. folgende Knoten, wenn man den Baum in symmetrischer Reihenfolge durchläuft. Anschaulich gesprochen handelt es sich um den »rechtesten« Knoten im linken Unterbaum und den »linkesten« im rechten Unterbaum. Bei arithmetischen Ausdrücken entspricht die Postfixnotation der klammerfreien, umgekehrt polnischen Schreibweise UPN, die manche Taschenrechner bevorzugen.
6.2
Der Heap als Prioritätswarteschlange
Als Anwendung der im letzten Abschnitt vorgestellten Bäume wollen wir einen Datentyp entwerfen, der besonders zur Verwaltung von Warteschlangen geeignet ist, die nach Priorität gesteuert werden. Der Zugriff auf die Struktur erfolgt immer auf das Element höchster Priorität, zum Beispiel das kleinste. Wir nennen einen solchen Datentyp eine Prioritätswarteschlange. Allgemein gesprochen muß eine Prioritätswarteschlange und folgende Operationen effizient ausführen: Zugriff auf das kleinste Element, Ersetzen des kleinsten Elements,
Schlüssel speichern
154
Kapitel 6
Bäume und Suchbäume
Entfernen des kleinsten Elements, Einfügen eines neuen Elements und Aufbau der Datenstruktur aus einer Liste von
Elementen.
Wir werden nun eine Datenstruktur bestimmen und dann die Operationen darauf implementieren. In einem Baum ist der Zugriff auf die Wurzel besonders einfach. Falls wir einen Baum hernehmen, bei dem das kleinste Element in der Wurzel steht und diese Eigenschaft natürlich auch für alle Teilbäume gilt, erfolgt der Zugriff auf das kleinste Element direkt, d. h. in konstanter Zeit. Beim Ersetzen des kleinsten Elementes durch ein neues kann nun die Wurzel größer sein als einer ihrer Söhne. In diesem Fall lassen wir sie einfach durch den Baum nach unten »sickern«. Dazu machen wir den kleineren der beiden Söhne zur neuen Wurzel des Gesamtbaumes und ersetzen die Wurzel des entsprechenden Teilbaums durch die alte Wurzel. Dieser Vorgang wird solange rekursiv wiederholt, bis das neue Element die Minimalitätsbedingung erfüllt – schlimmstenfalls so oft, wie die Höhe des Baumes beträgt. Wählen wir als Datenstruktur einen vollständigen Baum, so ist das Ersetzen der Wurzel in logarithmischer Zeit möglich. Eine solche Datenstruktur nennen wir einen Heap. D EFINITION 6.3 Ein Heap ist ein vollständiger Binärbaum, bei dem für jeden Teilbaum dessen Minimum in der Wurzel steht. In CAML LIGHT implementieren wir zunächst eine Baumstruktur und den Zugriff auf die Wurzel: ✍
☞
✍ ☞
+#
)
# #+!#
Das Versickern einer neuen Wurzel leistet der folgende Algorithmus; ein Beispiel findet man in Abbildung 6.3.
6.2
Der Heap als Prioritätswarteschlange
21
4
11
22
4
14
155
34
11
20
22
21
14
34
20
4
11
22
20
14
34
21
Abbildung 6.3: Versickern der Wurzel 21 in einem Heap
A L G O R I T H M U S 6.6 V ERSICKERN E INGABE : ein vollständiger Binärbaum , wobei und sind.
und
Heaps mit den Wurzeln
A USGABE : der gleiche Baum – jetzt mit Heapeigenschaft. M ETHODE : 1. Ist
, so fertig.
2. Ist der kleinste der drei Werte, so erzeuge dessen Wurzel durch ersetzt wurde. 3. Ist kleiner, so erzeuge .
, wobei
4. Behandle Sonderfälle, falls oder leer.
Bei der Implementierung muß man einige Sonderfälle extra betrachten: " % ✍
der Heap ist,
156
Kapitel 6
☞
Bäume und Suchbäume
%
%
#+!# $%)
– Alg. 6.6 – Der Zugriff auf und das Ersetzen des kleinsten Elementes sind zufriedenstellend gelöst. Nun müssen wir uns eine effiziente Einfügefunktion überlegen, bei der immer ein vollständiger Baum entsteht. Die erste Idee, zu prüfen welcher Teilbaum niedriger ist und dann in diesen einzufügen, müssen wir verwerfen, weil die Bestimmung der Höhe zu aufwendig ist. Nun könnte man erwägen, die Höhe oder wenigstens die Höhendifferenz zwischen den Teilbäumen in der Wurzel abzuspeichern und bei jeder Operation anzupassen. Das wäre machbar – aber es geht einfacher. Wir müssen ja die Höhe nicht kennen, sondern nur sicher sein, daß wir in den niedrigeren Teilbaum einfügen. Das lösen wir mit der »Brechstange«: Wir fügen jedesmal in den rechten Teilbaum ein und vertauschen dann die Teilbäume. A L G O R I T H M U S 6.7 E INFÜGEN IN H EAP E INGABE : ein Heap und ein Wert .
6.2
Der Heap als Prioritätswarteschlange
1
157
1
2
3
3
4
5
2
4
1
2
4
3
6
5
Abbildung 6.4: Struktur der Heaps mit , und Elementen
A USGABE : der gleiche Heap, der auch enthält. M ETHODE : Füge in ein, nenne diesen Heap dann und gib den Heap zurück: " ✍
☞ $
+!#
– Alg. 6.7 – Wenn wir mit einem leeren Baum anfangen, ist sofort klar, daß durch diesen Einfügealgorithmus entweder der linke Baum ein Element mehr enthält als der rechte, oder beide gleich viele. Mehr noch! Es ist sogar die vollständige Struktur der Bäume für jede Elementzahl vorgegeben. Als Beispiel sind in Abbildung 6.4 die Strukturen für , und Elemente angegeben. Zur Vereinfachung haben wir dabei auf die Darstellung der Blätter verzichtet. Die Zahlen in den Knoten geben jeweils die Reihenfolge des Einfügens wieder. An ihnen kann man sich deshalb die erfolgten Drehungen noch einmal verdeutlichen.
158
Kapitel 6 4
Bäume und Suchbäume
8
4
22
4
8
4
22
8
3
3
3
11 4
22
8
11
22
4
8
Abbildung 6.5: Einfügen der Schlüssel , , , und in einen anfangs leeren Heap
Wir können uns leicht überlegen, daß alle durch iteriertes Einfügen aus dem leeren Baum erhaltenen Bäume vollständig sind. Für , und Knoten ist das klar. Bäume mit oder mehr Knoten setzen sich aus zwei Heaps mit Elementen zusammen. Da diese vollständig sind, gilt das auch bzw. für den ganzen Baum, der zusätzlich die Minimalitätsbedingung erfüllt.
Das Einfügen erfolgt natürlich in logarithmischer Zeit, und wir können mit Auf wand durch iteriertes Einfügen eine Liste in einen Heap verwandeln: ✍ ☞ "
" )
$
" $ +!#
B EISPIEL 6.4 Wir fügen die Schlüssel , , , und nacheinander in einen anfangs leeren Heap ein. Die einzelnen Schritte kann man in Abbildung 6.5 verfolgen.
Da die Struktur der Heaps genau festgelegt ist, können wir durch Vorgehen »von unten nach oben« einen Heap theoretisch auch in Schritten aufbauen. Wir berechnen aus der Schlüsselanzahl die Höhe , die Anzahl der Knoten auf dem letzten Niveau sowie die Plätze auf dem vorletzten Niveau, welche Wurzel eines Heaps mit drei und zwei Elementen sind. Damit haben wir zwischen 50 % und 75 % der Knoten in Heaps der Höhe oder eingetragen. Von den verbleibenden versickert wiederum die Hälfte in Heaps der Höhe usw. Nur der letzte Knoten muß so in einen Heap der vollen Höhe eingebracht werden. Eine genaue Analyse zeigt, daß für diesen Algorithmus der Aufwand linear ist.
6.2
Der Heap als Prioritätswarteschlange
1
5
3
5
159
2
4
2
3
4
2
4
3
5
Abbildung 6.6: Entfernen der Wurzel in zwei Schritten
Es fehlt uns noch der Algorithmus zum Löschen des Minimums. Wenn wir die Wurzel einfach weglassen, müssen wir die zwei Teilbäume vereinigen. Da wir wissen, daß der linke ein Element mehr enthalten kann, entfernen wir seine Wurzel. Ist sie kleiner als die Wurzel des rechten Teilbaumes, so ist sie die neue Wurzel. Sonst nehmen wir die rechte Wurzel als Gesamtwurzel und versickern die linke Wurzel im rechten Teilbaum. In jedem Fall tauschen wir die Teilbäume. Dieses Vorgehen ist aber gar nicht so günstig, da wir durch das Versickern evtl. in beide Teilbäume absteigen müssen. Ein besserer Algorithmus entfernt ein Element der untersten Stufe – das ist in Bäumen immer besonders einfach – und ersetzt das Minimum durch dieses Element. Durch unsere besondere Heapstruktur ist der Knoten ganz links unten stets auf der untersten Stufe. Nach seinem Entfernen müssen die Teilbäume wieder vertauscht werden, um die Struktur zu erhalten (Abbildung 6.6). A L G O R I T H M U S 6.8 L ÖSCHEN DES M INIMUMS E INGABE : ein Heap. A USGABE : der gleiche Heap ohne das Minimum.
160
Kapitel 6
Bäume und Suchbäume
M ETHODE : 1. Ermittle und lösche linkestes Element (mit Vertauschen der Teilbäume!) und 2. ersetze die Wurzel durch diesen Wert.
"
✍
☞ ✍
☞
#+!#
%
# +!#
– Alg. 6.8 –
6.3
Heapsort
Mit Hilfe der im vorigen Abschnitt eingeführten Prioritätswarteschlange als Heap läßt sich unmittelbar ein Sortieralgorithmus formulieren, der auch im schlechte sten Fall eine Komplexität aufweist.
Falls ein Heap gegeben ist, kommen wir nämlich ganz einfach zu einer sortierten Liste – wir entfernen einfach das Minimum solange, bis der Heap leer ist: ✍ ☞
" )
" $ " $ + #!
Da hier eine Endrekursion vorliegt, die in jedem Schritt ein Entfernen der Wurzel aufruft, ist die Komplexität dieser Funktion aus . Im vorigen Abschnitt haben wir bereits gesehen, daß auch das Aufbauen eines Heaps in der gleichen, und sogar in linearer Zeit erfolgen kann. Wir erhalten also folgendes Sortierverfahren.
6.3
Heapsort
161
A L G O R I T H M U S 6.9 H EAPSORT E INGABE : eine Liste .
A USGABE : die sortierte Liste. M ETHODE : Konvertiere in einen Heap und anschließend wieder in eine (sortierte) Liste: ✍ ☞ $ ' $ " $ * +!#
– Alg. 6.9 –
Der dabei entstehende Aufwand beträgt
und
B EISPIEL 6.5 Als Beispiel sortieren wir wieder einmal die uns wohlbekannte Li ste
. Es entsteht zunächst der Heap ✍ ☞
#
und daraus dann die sortierte Liste
✍ ☞ # " $
Heaps können auch als Felder implementiert werden. Ein Breitendurchlauf durch den Baum bestimmt hier die Reihenfolge. Da der Baum vollständig ist, gilt nun als
162
Kapitel 6
Bäume und Suchbäume
5
3
7
6
12
9
Abbildung 6.7: Ein einfacher binärer Suchbaum
Minimalitätsbedingung , und das Entfernen des Minimums kann durch Vertauschen des ersten mit dem letzten Element und anschließendem Versickern ausgeführt werden.
6.4
Suchbäume
Bäume sind hervorragend geeignet, Information zu speichern, schnell wiederzufinden, einzufügen und zu löschen. Dazu muß jedoch eine Bedingung hergeleitet werden, die sicherstellt, daß diese Wörterbuchoperationen wirklich effizient implementiert werden können. Das Suchen ist sicher der zentrale Algorithmus, denn vor dem Einfügen und Löschen muß ja zunächst gesucht werden. Da ein Baum an der Wurzel betreten wird, sollte hier die Entscheidung fallen, ob der linke oder rechte Teilbaum zu durchsuchen ist. Fordert man etwa, daß alle Schlüssel im linken Teilbaum kleiner sind als die Wurzel und alle im rechten größer, so ist klar, wo weiter gesucht werden muß. D EFINITION 6.4 Ein binärer Suchbaum ist ein Binärbaum, bei dem alle Schlüssel im linken Teilbaum kleiner sind als die Wurzel und diese ist kleiner als alle Schlüssel im rechten Teilbaum. Außerdem müssen sowohl der linke als auch der rechte Teilbaum binäre Suchbäume sein. So ist zum Beispiel der Baum aus Abbildung 6.1 kein Suchbaum, der in Abbildung 6.7 (mit den gleichen Werten) ist hingegen einer.
6.4
Suchbäume
163
Durchläuft man einen binären Suchbaum in symmetrischer Reihenfolge, so erhält man eine sortierte Liste. A L G O R I T H M U S 6.10 S UCHEN IM B AUM E INGABE : ein binärer Suchbaum , ein Schlüsselwert . A USGABE :
, falls in enthalten ist, sonst. M ETHODE : Die Suche in einem leeren Baum ist erfolglos, sonst wird mit der Wurzel verglichen und je nach Größe aufgehört oder links oder rechts weitergesucht. Die Anzahl der dabei auszuführenden Vergleiche entspricht im Maximalfall der Höhe des Baumes. vorZur Implementierung wird der in Abschnitt 6.1.1 definierte Datentyp ausgesetzt: " ✍
☞
$ &
"!#
– Alg. 6.10 – Ebenso einfach ist das Einfügen. Entweder, der einzufügende Wert ist bereits im Baum und es ist nichts zu tun, oder die erfolglose Suche endet in einem Blatt. Dieses gibt dann die Stelle an, an die der Wert eingefügt werden muß. Diese eher globale, iterative Sicht formulieren wir nun lokal und rekursiv und erhalten sofort den folgenden Algorithmus. A L G O R I T H M U S 6.11 E INFÜGEN IN EINEN S UCHBAUM E INGABE : ein binärer Suchbaum und ein Schlüsselwert .
164
Kapitel 6
Bäume und Suchbäume
A USGABE : ein binärer Suchbaum , der zusätzlich enthält. M ETHODE : Ist leer, so erzeuge den Baum mit als Wurzel. Sonst höre auf, falls gleich der Wurzel ist. Füge anderenfalls in den passenden Teilbaum ein. ✍
☞
"
"!# # $ *
– Alg. 6.11 – Das Entfernen eines Knotens aus einem Baum, ist nur dann einfach, wenn mindestens einer seiner Teilbäume leer ist. Handelt es sich dagegen um einen inneren Knoten, so ersetzen wir ihn durch seinen symmetrischen Vorgänger bzw. Nachfolger und löschen diesen. Das ist einfach, da hier stets ein leerer Teilbaum vorhanden ist. Die Suchbaumeigenschaft wird durch diese Operation nicht gestört, denn genau so waren symmetrischer Vorgänger bzw. Nachfolger ja gerade definiert. A L G O R I T H M U S 6.12 L ÖSCHEN AUS EINEM S UCHBAUM E INGABE : ein binärer Suchbaum , ein Schlüsselwert . A USGABE : ein binärer Suchbaum , der nicht enthält. M ETHODE : Anwendung des eben beschriebenen Verfahrens, wobei wir zur Bestimmung des
6.4
Suchbäume
165
symmetrischen Nachfolgers eine Funktion verwenden, die das Minimum, also den Wert des am weitesten links postierten Knotens im Baum ermittelt: " ✍
☞ # " ✍
* +!
☞
"!#
– Alg. 6.12 – Der Maximalaufwand aller drei Operationen hängt also von der Höhe des binären Suchbaumes ab. Im günstigsten Fall ist der Baum vollständig, und der Aufwand ist damit logarithmisch beschränkt. Im ungünstigsten Fall allerdings degeneriert unser Suchbaum zu einer linearen Liste, und die Höhe ist gleich der Schlüsselanzahl . Das ist leider auch dann der Fall, wenn eine sortierte Liste in einen Baum eingetragen wird. Man kann aber zeigen, daß bei einer zufälligen Anordnung der Schlüssel durch iteriertes Einfügen ein Baum logarithmischer Höhe entsteht. B EISPIEL 6.6 Um die eben implementierten Algorithmen zu demonstrieren, benötigen wir zunächst eine Funktion, die eine Liste in einen Suchbaum umwandelt: ✍ ☞
"
$
" $ "!#
Nun können wir leicht einen größeren Baum erzeugen und auf diesen beispielhaft die Algorithmen anwenden:
166
Kapitel 6
✍ ☞
✍ ☞
*
✍ ☞
6.5
!
✍ ☞
$
✍ ☞
Bäume und Suchbäume
AVL-Bäume
Wir haben im vorigen Abschnitt gesehen, daß die drei Wörterbuchoperationen Suchen, Einfügen und Löschen für binäre Suchbäume zwar im Mittel in logarithmischer Zeit durchgeführt werden, im schlechtesten Fall aber jedes Element betrachtet werden muß. Wir wollen nun Bäume untersuchen, deren Höhe logarithmisch beschränkt ist und für die damit auch für den Maximalaufwand
gilt. Wir müssen also eine Bedingung an die Höhe der Bäume search stellen, und die Algorithmen müssen diese Bedingung invariant lassen.
Wählen wir als Datenstruktur einen vollständigen Baum, so ist seine Höhe nach Satz 6.1 gleich und damit minimal unter allen Bäumen mit Knoten.
6.5 AVL-Bäume
167
Abbildung 6.8: Minimale AVL-Bäume zu gegebener Höhe 1 und 2
h-1 h-2 h
Abbildung 6.9: Zusammenfügen zweier minimaler AVL-Bäume zu einem mit Höhe
Die Vollständigkeit ist allerdings eine so starke Bedingung, daß sie nicht einfach aufrecht zu erhalten ist. Wir ersetzen sie deshalb durch folgende Ausgeglichenheitsbedingung. D EFINITION 6.5 Ein Baum heißt ausgeglichen oder höhenbalanciert, wenn die Differenz der Höhen der Teilbäume in jedem Knoten betragsmäßig kleiner oder gleich ist. Einen höhenbalancierten binären Suchbaum nennen wir AVL-Baum. Der Name »AVL« kommt von A DELSSON -V ELSKIJ und L ANDIS, die solche Bäume als erste einführten. Die Bedingung an ausgeglichene Bäume ist zuerst einmal lokal. In einem AVL-Baum ist der linke Teilbaum um höher als der rechte oder umgekehrt; oder sie sind gleich hoch. Beide sind natürlich AVL-Bäume. Wir definieren die Balance eines Baumes als die Höhendifferenz zwischen rechtem und linkem Teilbaum. Für einen AVL-Baum liegt also die Balance in .
Um festzustellen, wie hoch ein solcher Baum bei gegebener Knotenzahl werden kann, konstruieren wir wie im Beweis zu Satz 6.1 zu gegebener Höhe AVL Bäume mit minimaler Knotenzahl . Für
und gilt, wie aus Abbildung 6.8 hervorgeht,
und .
168
Kapitel 6
Bäume und Suchbäume
Zum Aufbau eines AVL-Baumes der Höhe werden je ein AVL-Baum der Höhe und einer der Höhe mit einer neuen Wurzel zusammengefügt (Abbildung 6.9).
Also gilt . Die Knotenanzahl genügt demnach einem ähnlichen Gesetz wie die Fibonaccizahlen. Mittels vollständiger Induktion zeigt man leicht, daß
Wir wissen bereits aus Abschnitt 2.6, daß die Fibonaccizahlen exponentiell wachsen. Geben wir also umgekehrt die Anzahl vor, so ist die maximale Höhe durch die Umkehrfunktion der Fibonaccizahlen, also logarithmisch beschränkt. Genauer gilt
Nun betrachten wir die Algorithmen für Suchen, Einfügen und Löschen. Da ein AVL-Baum ein binärer Suchbaum ist, nehmen wir die bekannten Algorithmen als Ausgangspunkt. Zuerst führen wir eine Datenstruktur ein. Für jeden Knoten muß die Balance gespeichert werden. Während wir im Programm einen eigenen 3-elementigen Datentyp dafür verwenden werden, bleiben wir jetzt in der Herleitung bei .
Das Suchen erfolgt wie in binären Suchbäumen, die Balance wird ignoriert. Die Komplexität ist auf Grund der Ausgeglichenheit in . Das Einfügen und Löschen werden in den nächsten Abschnitten behandelt. Das Einfügen erfolgt im Prinzip wie in binären Suchbäumen – allerdings muß die Balance jetzt angepaßt werden, um die Ausgeglichenheit zu erhalten. In den Bildern deuten wir die Balance durch einen Punkt an der Seite an, welche höher ist. Außerdem kennzeichnen wir den Teilbaum, in den aktuell eingefügt wurde, mit einem schraffierten Punkt. Außerdem identifizieren wir hier die Knoten mit den in ihnen gespeicherten Schlüsseln.
6.5 AVL-Bäume
169
A L G O R I T H M U S 6.13 E INFÜGEN IN EINEN AVL-B AUM E INGABE : ein AVL-Baum mit Wurzel und ein Schlüssel . A USGABE : der AVL-Baum, der nun auch enthält. M ETHODE : Fallunterscheidung: Einfügen in den leeren Baum ergibt: x
Falls der Baum nicht leer ist, betrachten wir den Fall linken Teilbaum ein: p
und fügen in den
p’ x