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<0
-x
x abs (x)
abs (x)
Abbildung 1.5: Fallunterscheidung zur Datenflußsteuerung
Die erste Verfeinerungsstufe, dargestellt in Abbildung 1.7, läßt sich dann bereits direkt in ein Programm umsetzen. Zu beachten ist noch, daß das Generieren von Datenströmen hier mit einem Kreis dargestellt ist. Aktion, Kontostand, Betrag
Aktion
Erfolg, Kontostand
Abbildung 1.6: Erster Entwurf von Aktionen auf einem Bankkonto
Bei rekursiven Funktionen führen wir von der Stelle des rekursiven Aufrufs eine gepunktete Linie an den Eingang der Funktion zurück, um so den Selbstaufruf in Abbildung 1.8 auf zu symbolisieren. Als Beispiel stellen wir die Funktion Seite 40 graphisch dar.
1.6 1.6.1
Eigenschaften von Algorithmen Korrektheit
In diesem Abschnitt wollen wir kurz einige Eigenschaften von Algorithmen zusammenstellen. Die wichtigste ist dabei die Korrektheit. Der Algorithmus soll das Problem lösen. Dabei muß man sich erst einmal einigen, wie die genaue Problemstellung lautet. Das ist Aufgabe der Spezifikation. Geschieht diese formal, so kann die Korrektheit formal bewiesen werden. Für die einfachen Algorithmen, die wir in diesem Buch behandeln werden, ist das möglich. Wir werden solche Beweise aber nur selten durchführen.
1.6
Eigenschaften von Algorithmen
39
A abheben
einzahlen
Aktion A
Betrag B
S
Erfolg e
S
B
B
<=
+
B
S
e = true
e = false Kontostand S
Kontostand S -
e = true
S
Abbildung 1.7: Verfeinerung der Kontoaktionen
D EFINITION 1.6 Ein Algorithmus ist korrekt bezüglich der zugehörigen Spezifikation, falls gilt: Für alle Werte aus dem Definitionsbereich von terminiert und liefert – angewendet auf – ein Resultat , welches das zu gehörige Ergebnis ist. Diesen Korrektheitsbegriff bezeichnet man auch als totale Korrektheit eines Algorithmus’. Setzt man dagegen das Terminieren voraus, so erhält man die partielle Korrektheit. Der Nachweis der partiellen Korrektheit ist für einen funktionalen Algorithmus oft einfach, da die Spezifikation ebenso funktional vorgenommen wird. Bei rekursiven Algorithmen ist in der Regel eine vollständige Induktion über die Rekursionstiefe zu führen. Da diese oft von Anfang an festliegt – eine sogenannte primitive Rekursion –, ist auch die Terminierung leicht zu beweisen. Bei imperativen Algorithmen, die doch weiter von der Spezifikation entfernt sind, hilft man sich damit, Zusicherungen in Form von logischen Prädikaten zu betrachten. Korrektheitsbeweise sind normalerweise sehr aufwendig und oft viel schwieriger als die Herleitung des Algorithmus’ selbst. Deshalb begnügt man sich in der Regel damit, die Korrektheit durch Tests feststellen zu wollen.
40
Kapitel 1
Algorithmen und Programmierung
a
b mod
0? b b ggT
Abbildung 1.8: Der rekursive ggT-Algorithmus
Tests beweisen allerdings nicht die Korrektheit des Algorithmus’, sie zeigen nur Fehler auf. Durch systematische Tests lassen sich aber doch viele Fehler ausmerzen. Tests sollten für Anfangswerte durchgeführt werden, die aus dem »normalem« Bereich und aus den Randbereichen gewählt werden, um Sonderfälle abzuprüfen. Jeder Zweig von Fallunterscheidungen sollte durch entsprechende Argumente getestet werden. Teilalgorithmen werden separat getestet. Ein Algorithmus sollte ferner die Bedingung der Robustheit erfüllen. Dazu gehört, daß unzulässige Anfangswerte abgefangen und zuverlässige Fehlermeldungen ausgegeben werden.
1.6.2
Effizienz
Neben der Korrektheit eines Algorithmus’ sind seine Laufzeit und sein Speicherplatzbedarf wichtige Eigenschaften für seine praktische Durchführbarkeit. Was nutzt ein Algorithmus, der zwar korrekt ist, aber die schnellsten Rechner einige Jahrtausende beschäftigt, bevor er die Lösung gefunden hat? Unser Ziel wird sein, nicht nur korrekte, sondern auch effiziente Algorithmen zu entwickeln. Unterschiede treten hier vor allem bei der Laufzeit auf.
1.6
Eigenschaften von Algorithmen
41
Die Laufzeit eines Algorithmus’ hängt von der Größe des Problems selbst (etwa der Anzahl der Elemente in einer zu sortierenden Liste), der verwendeten Hardware, der Formulierung des Algorithmus’, den verwendeten Elementaroperationen und den verwendeten Datenstrukturen ab. Wir betrachten als den Aufwand oder die Komplexität eines Algorithmus’ die Anzahl der problemrelevanten Elementaroperationen, z. B. Vergleiche bei Sortierverfahren oder arithmetische Verknüpfungen bei Funktionsberechnungen. Dabei nehmen wir in Kauf, daß gerade bei modernen Rechnern die Laufzeit von wesentlich mehr Kriterien beeinflußt wird. So kann die Zeit für einen Funktionsaufruf, die wir normalerweise nicht zählen, um den Faktor 100 höher sein als eine Multiplikation, oder die Zugriffszeit für Daten dürfte eigentlich nicht vernachlässigt werden.
Der Aufwand ist also eine Funktion , die von der Problemgröße abhängt: . Wir unterscheiden dabei zwischen maximalem, minimalem und durchschnittlichem Aufwand, wobei wir in der Regel zufrieden sind, die Größenordnung oder die Wachstumsklasse des Algorithmus’ zu bestimmen. Wachstumsklassen sind Mengen von Funktionen, die für große etwa gleichen Verlauf aufweisen.
D EFINITION 1.7
Diese Klasse (sprich: »Groß Oh von « ) beinhaltet also alle Funktionen, die für große nicht wesentlich stärker wachsen als .
Wenn man diese Definition genau betrachtet, sieht man, daß alle konstanten Funktionen oder solche mit endlichem Grenzwert in der gleichen Wachstumsklasse liegen. Die Konstante ist als Quotient der Grenzwerte von und zu wählen. Die Klassen
unterscheiden sich in ihrem Wachstum gegen
. Folgende tre-
42
Kapitel 1
Funktion
Algorithmen und Programmierung
stellige Zahl
stellige Zahl
stellige Zahl
Tabelle 1.1: Wertetabelle einiger wichtiger Wachstumsklassen
ten als Repräsentanten der Wachstumsklas uns interessierenden verschiedenen , , . sen auf: , und
Bevor wir einige einfache Sätze über diese Wachstumsklassen herleiten, wollen wir uns anhand von Tabelle 1.1 einen Überblick über das Wachstum der charakteristischen Funktionen verschaffen. Wir stellen fest, daß bereits für relativ bescheidene Werte von große Unterschiede auftreten. Interpretieren wir diese Werte als auszuführende Elementaroperationen und gehen davon aus, daß ein Rechner Operationen pro Sekunde ausführen kann – es wird noch einige Zeit vergehen, bis so schnelle Rechner existieren, so dauert die Ausführung eines Algorithmus’ der Klasse für erheblich länger als die Lebenszeit der Erde von ihrer Entstehung bis zum Zeitpunkt, an dem sie von der Sonne verschluckt wird.
. Dann ist
.
für alle
für alle
B EWEIS . Wegen existieren und mit und wegen und mit
existieren Daraus folgt für alle
. und deshalb gilt
.
F OLGERUNG 1.4 Wegen
für beliebige
für beliebige
nur unwesentlich von multipli-
Das Wachstum der Funktionen hängt für große kativen oder additiven Konstanten ab. S ATZ 1.3 Sei
gilt
,.
1.6
Eigenschaften von Algorithmen
43
Da der Logarithmus zur Basis in der Informatik am häufigsten auftritt, bezeichnen wir ihn einfach mit ohne Basisangabe. Nach der Folgerung ist die Basis für Aufwandsabschätzungen ehedem unerheblich. Auch die Addition von Funktionen ändert die Wachstumsklasse nicht, während sich die Multiplikation sehr wohl auswirkt.
S ATZ 1.5 Für die Verknüpfung von Funktionen gilt: Seien Seien
. Dann gilt . , . Dann gilt
.
Wichtig für uns ist nicht die formale Betrachtungsweise dieser Sätze, sondern das Abschätzen des Aufwandes für einzelne Algorithmen. Neben einem gewissen Gefühl, das ein jeder wohl entwickeln muß, sollten dabei die folgenden Überlegungen hilfreich sein. Wir wollen die Wachstumsklasse eines Algorithmus’ bestimmen, der sich aus Elementaroperationen und Teilalgorithmen zusammensetzt, deren Komplexität konstant oder bekannt ist. Eine Methode, zu einem hinreichend komplexen Algorithmus zu kommen, ist die Hintereinanderausführung von Teilalgorithmen. Man führt erst Algorithmus 1 aus, dann Algorithmus 2. Prinzipiell gilt hier, daß sich die Aufwände addieren. Man muß allerdings bei solchen Algorithmen aufpassen, in denen die Größe von Eingabe und Ausgabe unterschiedlich ist. Denn der zweite Algorithmus wird auf die Ergebnisse des ersten angewendet und muß deshalb dessen Aus- als seine Eingabewerte übernehmen. Hierzu ein Beispiel.
B EISPIEL 1.3 Gegeben seien Zahlen. Aus diesen sollen zuerst alle möglichen geordneten Paare gebildet werden. Diese sind mit einem gegebenen Sortieralgo zu sortieren. Für den ersten Teilalgorithmus rithmus der Wachstumsklasse sei das Erzeugen eines Paares die relevante Elementaroperation. Offensichtlich ist jede Zahl mit jeder anderen und mit sich selbst zu paaren, es entstehen also Paare. Das anschließende Sortieren hat dementsprechend Eingabedaten. Dar aus folgt, daß nun das Wachstum erreicht wird. Nach dieser Überlegung kann man die einzelnen Klassen wieder additiv zusammenfassen und erhält
.
Ersetzt man innerhalb eines Algorithmus’ eine Elementaroperation durch einen Teilalgorithmus, so wird dieser so oft aufgerufen wie vorher die Elementaropera-
44
Kapitel 1
Algorithmen und Programmierung
tion, die üblicherweise konstante Zeit benötigt. Es kommt also zur Multiplikation beider Aufwände.
B EISPIEL 1.4 Als Beispiel betrachten wir die Summation über alle Elemente einer Matrix, also einer Tabelle mit Zeilen und Spalten. Wir wollen den Algorithmus durch Einsetzen entwickeln und ihn nur grob skizzieren: 1. Bilde die Summe über alle Zeilensummen. 2. Bilde die Zeilensumme durch Addition aller Elemente.
Der Aufwand für die Addition von Zahlen beträgt . Dies muß mal durchgeführt werden, um die Zeilensummen zu berechnen. Deren Addition kostet Operationen. Also beträgt der Gesamtaufwand . Das wußten wir natürlich schon vorher, weil wir Zahlen zu addieren hatten.
Diese Überlegungen lassen sich für den einfachsten Fall, das Ablaufen des ersten Algorithmus’ in linearer Zeit, auch direkt aus der Definition herleiten. S ATZ 1.6 Falls
und
, dann gilt
.
B EWEIS ist, existiert ein . Zuerst wird die Funktion ausgeführt. Da mit für alle . Nun wird auf das Resultat
von angewendet. Wegen folgt durch Einsetzen, daß ein mit
für alle existiert. Also gilt
.
Nach Studium der Sätze und ihrer Beweise ist klar, daß von den oben angegebenen charakteristischen Funktionen tatsächlich jede eine andere Wachstumsklasse repräsentiert – es kommt nicht auf konstante Faktoren oder Terme an, sondern der Exponent von bzw. die Funktion, die auf angewendet wird, bestimmt die Klasse. Wir fassen die für uns wichtigsten Wachstums- oder Aufwandsklassen nach steigender Komplexität zusammen:
: Konstanter Aufwand. Alle Funktionen sind durch eine Konstante beschränkt. : Logarithmisches Wachstum, sehr langsam. Eine Verdoppelung von bewirkt einen Anstieg um dem Summand . In diesen Funktionsklassen sind additive und multiplikative Konstanten wegen des geringen Wachstums stark wirksam.
1.6
Eigenschaften von Algorithmen
45
: Lineare Komplexität. : Überlineare Komplexität. Eine Verdoppelung von erwirkt etwas mehr als eine Verdoppelung des Wertes. : Polynomiale Laufzeit. Der höchste Exponent
bestimmt das Wachstum. Besonders häufig tritt quadratische Komplexität ( ) auf. : Exponentielles Wachstum. Nur für wirklich kleine durchführbar.
Wir werden die Algorithmen soweit analysieren, daß wir ihre Zugehörigkeit zu einer dieser Klassen beweisen können. Wir weisen noch einmal darauf hin, daß in der Praxis ein Algorithmus, dessen Aufwand in einer höheren Komplexitätsklasse liegt, durchaus schneller sein kann, weil die Komplexitätsaussagen asymptotischer Natur sind und erst für große gelten. Gründe hierfür sind, daß die einzelnen Faktoren nicht genau genug ermittelt und zu viele zeitrelevante Elementaroperationen vernachlässigt wurden oder die Speicherhierarchie den Zugriff auf Daten im scheinbar effizienteren Algorithmus verlangsamt.
Kapitel 2 Entwurf und Analyse von Algorithmen Wir haben bisher schon einige Algorithmen entworfen. Dabei gingen wir meist ganz intuitiv vor und versuchten, uns von der Anschauung leiten zu lassen. Dieses an sich begrüßenswerte Vorgehen wollen wir in diesem Kapitel etwas systematisieren und einige allgemeine Vorgehensweisen und Prinzipien beim Algorithmenentwurf vorstellen. Außerdem kümmern wir uns in diesem grundlegenden Kapitel auch etwas stärker um Beweise der angegebenen Verfahren und Aussagen als im weiteren Verlauf des Buches.
2.1
Vollständige Induktion
Im ersten Kapitel stellten wir fest, daß die Komplexität eines Algorithmus’ wesentlich von der Größe der Eingabe, d. h. von der Anzahl der zu bearbeitenden Elemente, abhängt. Die Formeln für ihre Berechnung sind demnach für beliebige natürliche Zahlen aufzustellen. Ferner gilt, daß die meisten Algorithmen, die wir betrachten wollen, mit Datenstrukturen arbeiten, die eine beliebige Anzahl von Elementen speichern. Die Korrektheit eines Algorithmus’ ist also für alle möglichen Elementzahlen nachzuweisen. Wir verwenden dazu das Beweisprinzip der vollständigen Induktion, das die Gültigkeit einer Aussage für alle natürlichen Zahlen nachweist. B EMERKUNG 2.1 Falls nichts anderes gesagt wird, beginnen die natürlichen Zahlen bei und werden wie üblich mit bezeichnet. Das Prinzip der vollständigen Induktion, läuft nach folgendem Muster ab: 1. Induktionsanfang: Zeige, daß
gilt.
2. Induktionsvoraussetzung: Nimm an, daß
gilt.
48
Kapitel 2
Entwurf und Analyse von Algorithmen
3. Induktionsschluß: Zeige, daß Dann gilt
aus
folgt.
.
für alle
B EMERKUNG 2.2 Manche Aussagen gelten nur für fest. wir den Induktionsanfang bei
mit
. Dann setzen
Wir zeigen also, daß aus der Gültigkeit einer Aussage für eine natürliche Zahl immer die Gültigkeit für deren Nachfolger folgt. Da wir diesen Schluß für allgemeines durchführen, braucht er nur einmal zu geschehen. Wichtig ist jedoch die Verankerung am Anfang – hier legen wir den Grundstein für unseren Beweis, der sich sonst nur im »luftleeren Raum« abspielen würde. Diese Beweistechnik ist oft relativ mechanisch durchzuführen, Fingerspitzengefühl und Intuition sind mehr bei der Aufstellung der zu beweisenden Formel oder Aussage aufzubringen.
der ersten natürlichen Zahlen bestimWir wollen als Beispiel die Summe men. Dazu könnten wir sofort einen Algorithmus entwickeln, der dieses Problem durch Ausführen von Additionen löst. Viel effizienter ist die folgende Vorgehensweise. Betrachten wir die Werte für kleine , also können wir vielleicht schon die Formel
,
,
,
, , so
raten. Diese Vermutung wird noch verstärkt, indem wir, wie schon von G AUSS berichtet wird, die Zahlen von bis nebeneinander schreiben und in umgekehrter Reihenfolge darunter und dann feststellen, daß wir so -mal den Wert addieren müssen, um die doppelte Summe zu erhalten. Da wir diese Aussage noch häufig brauchen werden und nicht nur als ein schönes Beispiel für vollständige Induktion bewundern wollen, formulieren wir sie als Satz. S ATZ 2.1 Für die Summe
der ersten natürlichen Zahlen bis
gilt
2.2
Einfache Endrekursion
49
B EWEIS . Der Beweis erfolgt durch vollständige Induktion. Zum Induktionsanfang setzen wir und erhalten
Es gelte nun als Induktionsvoraussetzung
Dann folgt für
womit alles bewiesen ist. Die vollständige Induktion hilft uns sowohl bei der Aufwandsberechnung als auch beim Korrektheitsbeweis. Für einfache Algorithmen wollen wir das im nächsten Abschnitt durchführen.
2.2
Einfache Endrekursion
Wir teilen nun die von uns betrachteten Algorithmen hinsichtlich der Art ihres Ablaufes in Gruppen ein. Da bis auf das wirklich einfache Hinschreiben eines mathematischen Ausdrucks als Funktion alle unsere Algorithmen rekursiv waren, wäre die Unterteilung in rekursive und nichtrekursive Algorithmen zu grob. Statt dessen verfeinern wir die Unterteilung der rekursiven Algorithmen weiter.
Die Funktionen , und aus Abschnitt 1.4 kommen ohne Datenstruktur aus und sind eigentlich nichts weiter als die Umsetzung einer mathematischen Formel in ein rekursives Ablaufschema. Bei diesen Algorithmen hängt der Aufwand von den Werten und nicht von der Struktur der Eingabedaten ab, deshalb sind allgemeine Angaben schwierig.
50
Kapitel 2
2.2.1
Entwurf und Analyse von Algorithmen
Algorithmen linearer Komplexität
Die Summe und das Maximum über die Zahlen in einer Liste sind Vertreter der Algorithmenart einfache Endrekursion. Bei diesen Verfahren wird eine Datenstruktur durch einen rekursiven Aufruf der Funktion am Schluß des Funktionsausdrucks genau einmal durchlaufen. Wir wiederholen die Summation noch einmal: ✍
"
☞
$%!
# " $ "!#
An diesem einfachen Beispiel wollen wir zwei Arten der Veranschaulichung eines rekursiven Algorithmus’ vorführen. Als erstes kann man den Algorithmus als eine Rechenvorschrift in Gleichungsform auffassen, bei der die interessierende Funktion auf der rechten und linken Seite des Gleichheitszeichens steht. Sie bildet somit einen Fixpunkt dieses Ausdrucks. Diese recht abstrakte Betrachtungsweise braucht uns aber kaum zu interessieren – sie dient zur Erklärung der Rekursion, mit der wir auch ohne diese Überlegung schon vertraut sind oder sicher noch werden. Von der Korrektheit des Verfahrens überzeugt man sich durch Einsetzen der Lö korrekt die Summe der Restliste sungsfunktion. Wir nehmen also an, daß berechnet. Dann ist aber auch * für eine nichtleere Liste korrekt, und die erste Alternative ermittelt die korrekte Summe für die leere Liste. In der anderen Art der Veranschaulichung faßt man den Algorithmus als Programm auf, welches Schritt für Schritt abgerollt wird. Diese Methode eignet sich allerdings nur für Ablaufbeispiele mit wenigen Daten. Stellt man etwa den Weg in die Rekursion durch ➘ und den Weg zurück mit ➚ dar, so ergibt sich für den Aufruf der Ablauf ➘ ➘ ➘ ➘ ➚ ➚
2.2
Einfache Endrekursion
51
Das Abrollen kann mit der in Abschnitt 1.5.3 eingeführten graphischen Notation noch anschaulicher dargestellt werden. Ein Beispiel hierzu kann man in Abbildung 2.2 auf Seite 76 sehen. Auch die Anwendung einer Funktion auf alle Listenelemente gehört zu den Algorithmen mit einfacher Endrekursion. A L G O R I T H M U S 2.1 L ISTE QUADRIEREN E INGABE : eine Liste von Zahlen. A USGABE : die Liste der Quadrate. M ETHODE : Das Problem wird durch folgende Funktion gelöst: " ✍
*
$ %! ☞
# " $%)#
$ #+!#
– Alg. 2.1 – In Abhängigkeit von der Listenlänge stoppt der Algorithmus oder er ruft sich nach einer Operation rekursiv für eine Liste der Länge auf. Sind alle aufgerufenen Operationen von konstantem Aufwand , so ist der Gesamtaufwand die Lösung der Rekursionsgleichung
S ATZ 2.2 Die Lösung der Rekursionsgleichung
. ist
B EWEIS . Der Beweis erfolgt wieder durch vollständige Induktion. Für den Induktionsanfang gilt die Formel offensichtlich. Nimmt man nun an, daß
52
Kapitel 2
.
Entwurf und Analyse von Algorithmen
richtig ist, folgt
Auch der Beweis der Korrektheit kann durch Induktion über die Rekursionstiefe geführt werden. Nehmen wir als Beispiel wieder die Summation von oben. Der Induktionsanfang für rekursive Aufrufe entspricht der ersten Alternative. Sie ist korrekt, da die leere Summe den Wert hat. , so spaltet die zweite Alternative eine Zahl ab und addiert sie zur Summe Ist der übrigen. Nimmt man an, daß diese nach Induktionsvoraussetzung als Summe von Zahlen korrekt ist, so folgt sofort die Korrektheit auch für Zahlen.
Wir führen im folgenden Abschnitt noch einige Beispiele für Verfahren mit einfacher Endrekursion an.
2.2.2
Polynome und natürliche Zahlen
Ein Polynom mit ganzzahligen Koeffizienten
kann durch die Liste seiner Koeffizienten dargestellt werden, da durch die Position innerhalb der Liste der Exponent von eindeutig bestimmt ist. Zwei Polynome werden addiert, indem man ihre Koeffizienten an der gleichen Position addiert. A L G O R I T H M U S 2.2 P OLYNOMADDITION E INGABE : zwei Polynome
A USGABE : die Summe
und
der Polynome.
2.2
M ETHODE : Es gilt
wobei
Einfache Endrekursion
das Maximum von gesetzt werden.
und
ist und
53
bzw.
– Alg. 2.2 – Für den Geübten ist die angegebene Formel schon Algorithmus genug – wir formulieren zur Übung aber noch ein CAML LIGHT-Programm. Hier kommt es vor allem auf die Fallunterscheidung an: ✍
" "
"
☞
# " $ # " $ # " $ "!#
Da wir nur Additionen zählen, hängt der Aufwand vom Minimum von und ab, ansonsten gilt die gleiche Rekursionsgleichung wie oben und auch der Korrektheitsbeweis ist analog. Ganz ähnlich können wir nun auch die arithmetischen Verknüpfungen für natürliche Zahlen erläutern, die wir bisher immer als unteilbare Elementaroperation angesehen haben. Jede natürliche Zahl läßt sich wie folgt als Polynom darstellen: S ATZ 2.3 Es seien und mit
als
natürliche Zahlen. Dann läßt sich jede natürliche Zahl
schreiben. Dabei gilt
, und die Darstellung ist eindeutig.
B EMERKUNG 2.3 Diese Art der Schreibweise nennt man -adische Darstellung. Die bekannte Schreibweise von natürlichen Zahlen als Dezimalzahlen entspricht dieser Darstellung für und die im Rechner benutzten Dualzahlen sind nichts anderes als eine -adische Darstellung. Die Polynomkoeffizienten wollen wir von jetzt an wie gewohnt als Ziffern bezeichnen.
54
Kapitel 2
Entwurf und Analyse von Algorithmen
B EWEIS . Wieder benutzen wie vollständige Induktion über . Der Induktionsanfang ist trivial, denn für ist , der Wert der leeren Summe, die einzige Zahl. Man erkennt hier auch, daß durch die leere Liste repräsentiert wird. Im Induktionsschluß zeigen wir nun, daß eine eindeutige -adische Darstellung mit Ziffern für jede Zahl mit
existiert.
Nach dem Satz von der Division mit Rest existieren für eindeutig zwei Zahlen und mit und . Wegen gilt nun aber . Auf ist deshalb die Induktionsvoraussetzung anwendbar
und man erhält
mit
und
Aus dem Satz folgt sofort die Abschätzung
wobei Gleichheit genau dann gilt, wenn alle kommen wir so zu der bekannten Formel
gleich
sind. Für
die in der Informatik häufig angewendet wird.
2.2.3
Addition natürlicher Zahlen
Es mag etwas seltsam anmuten, daß wir nun das Rechnen mit Polynomen voraussetzen, um das einfache Zahlenrechnen herzuleiten und zu beschreiben. Die kompakte mathematische Schreibweise führt uns jedoch unmittelbar zu einem ausführbaren Algorithmus. Schreibt man die Summe zweier Zahlen
und als einfache Polynomaddition
2.2
Einfache Endrekursion
55
gilt. Weil dadurch die so sieht man, daß für die neuen Ziffern
Bedingung der Ziffern für eine -adische Darstellung verletzt wird, muß man ein anderes Verfahren zur Addition suchen.
Dazu definieren wir zunächst die Funktionen falls , falls
und
falls , falls . Für diese gilt . Wir können jetzt einen Satz formulieren, auf dessen Basis wir dann einen Additionsalgorithmus entwickeln.
S ATZ 2.4 Es seien
gegeben. Dann gilt
mit den Überträgen
wobei für die Ziffern
und
,
für für für
,
für für
gilt.
,
B EWEIS . Diesen Satz können wir wieder durch vollständige Induktion beweisen. Wir begnügen uns aber damit zu erkennen, daß seine algorithmische Formulierung mit Berechnung der und genau der aus der Schule bekannten schriftlichen Addition entspricht. Wir beginnen rechts bei den niederwertigsten Ziffern,
56
Kapitel 2
Entwurf und Analyse von Algorithmen
berechnen deren Summe und den Übertrag, der sich dann auf die nächste Ziffer auswirkt. Am Schluß bleibt der letzte Übertrag übrig. B EMERKUNG 2.4 Im Satz weist die Summe eine Ziffer mehr auf als ihre Summanden. Das ist aber auch die maximale Ziffernzahl. Bei einer Listendarstellung einer natürlichen Zahl sind die führenden Ziffern zweckmäßig am Schwanz der Liste zu speichern, da die Addition von rechts mit den niederwertigsten Ziffern beginnt. B EMERKUNG 2.5 Die Verallgemeinerung auf Addition von zwei Zahen mit unterschiedlich vielen Ziffern ist offensichtlich. Der Algorithmus ist wieder eine Endrekursion. Weil der Übertrag als zusätzlicher Parameter mitgeführt werden muß, verstecken wir die Funktion, die die eigentliche Arbeit macht, in einer, die den Aufruf steuert. A L G O R I T H M U S 2.3 A DDITION E INGABE : zwei natürliche Zahlen A USGABE : deren Summe
und in ziffernweiser Darstellung.
.
M ETHODE : Der obige Satz wird (mit vereinfachten und ) in folgende Funktion umgesetzt:
* *
✍
$%! # # * +!# & ## "!#
✍ "
☞
( (
2.3
Schrittweise Verfeinerung
57
(
# " $ # $ # " $ * +!#
☞
– Alg. 2.3 –
Durch den Übertrag, der schlimmstenfalls die ganze Zahl durchlaufen kann, hängt der Aufwand nun vom Maximum beider Ziffernzahlen ab. Ähnlich wie . in Satz 2.2 erhalten wir als Aufwand Die Funktion beschreibt die normale Vorgehensweise beim schriftlichen ad
. Als Ergebnis dieren. So entspricht etwa dem Aufruf liefert das
#
Die Multiplikation von natürlichen Zahlen wird unser nächster Algorithmus sein. Wir wollen die aus der Schule bekannte Methode betrachten, die wir wieder aus der Polynomdarstellung herleiten. Die mathematischen Umformungen führen uns zu einem allgemeinen Entwurfsprinzip, der schrittweisen Verfeinerung.
2.3
Schrittweise Verfeinerung
Ein gängiges Vorgehen beim Entwurf von Algorithmen und Programmen ist das Prinzip der schrittweisen Verfeinerung. Dieses Prinzip, das auch unter funktionaler Dekomposition oder Top-Down-Entwurf bekannt ist, ist der funktionalen Denkweise genau angepaßt. Wir werden es deshalb vielfältig verwenden.
2.3.1
Multiplikation natürlicher Zahlen
Zur Herleitung des Verfahrens wenden wir das Distributivgesetz an. Es seien wieder
und
58
Kapitel 2
Entwurf und Analyse von Algorithmen
gegeben. Dann ist
Die dritte Zeile zeigt auf, welche Teilalgorithmen gebraucht werden – die Addition und die Multiplikation einer Zahl mit einer Ziffer oder mit der Basis . Die Addition kennen wir bereits, die Basismultiplikation geschieht einfach durch Anfügen einer , während die Multiplikation mit einer Ziffer ähnlich wie die Addition berechnet wird. Mit Hilfe der Division mit Rest werden die Ziffernprodukte in Produktziffer und Übertrag zerlegt. Den Multiplikationsalgorithmus leiten wir nun aus der letzten Zeile der obigen Gleichungskette her. A L G O R I T H M U S 2.4 M ULTIPLIKATION E INGABE : zwei natürliche Zahlen in ziffernweiser Darstellung. A USGABE : ihr Produkt. M ETHODE : Verwende ziffernweises Produkt und addiere die Teilprodukte: "
✍
– Alg. 2.4 – Es folgen einige Überlegungen zur Korrektheit. Der Algorithmus ist eine direkte Umsetzung der letzten Formel der obigen Gleichung. Er durchläuft alle Ziffern
2.3
Schrittweise Verfeinerung
59
von
und terminiert dann. Im Falle einer leeren Liste für wird der Wert für berechnet, sonst wird mit der letzten Ziffer von multipliziert und zu dem mit multiplizierten Restprodukt addiert. Wir stellen unseren Algorithmus in einem Beispiel noch einmal der Schulmethode gegenüber.
B EISPIEL 2.1 Die Schulmethode liefert die Multiplikation von
und als
Die Endnullen werden dabei üblicherweise nicht aufgeschrieben. Die Ausführung der Funktion sieht folgendermaßen aus:
#
#
# #
#
#
#
#
#
#
Bei der Analyse des Multiplikationsalgorithmus’ kommt als Elementaroperation die Multiplikation zweier Ziffern hinzu. Es ist berechtigt diese und die Division und Restbildung zur Bestimmung des Übertrages und der Endziffer als Verknüpfungen mit konstantem Aufwand zu betrachten, da sie nur für zweistellige Zahlen bezüglich der Basis gebraucht werden. Wieder wollen wir eine Rekursionsformel herleiten und die Formel zur Lösung mit vollständiger Induktion beweisen. Der Anfang ergibt sich als mul
60
Kapitel 2
Entwurf und Analyse von Algorithmen
Für wird eine Ziffernmultiplikation durchgeführt und die Multiplikation für Ziffern Dann werden Ergebnisse ad nochmal aufgerufen. diese zwei und zifmul , und weil das diert. Weil add Multiplikationsergebnis maximal Ziffern hat, gilt mul zifmul mul add mul
Diese Rekursionsgleichung hat die Lösung
Der Beweis erfolgt durch vollständige Induktion über und liefert mit dem Induktionsanfang
für beliebiges, aber festes
und unter Verwendung der Induktionsvoraussetzung was zu zeigen war.
2.3.2
Sortieren durch Einfügen
Allgemein wird bei der schrittweisen Verfeinerung der Algorithmus als eine Funktion spezifiziert, die aus den Eingabewerten die gewünschten Ausgabewerte erzeugt. Diese Funktion leistet aber nicht die ganze Arbeit in einem Schritt, sondern wird in einfachere Funktionen zerlegt, die hintereinander aufgerufen werden. Dieser Prozeß der Verfeinerung läuft über mehrere Schritte bis zum vollständigen, direkt programmierbaren Algorithmus. Da jeder Teilalgorithmus ein abgeschlossenes Teilproblem löst, ist der Beweis der Korrektheit des gesamten Algorithmus’ oft recht einfach. Wir erläutern diese Vorgehensweise an einem weiteren Beispiel, dem Sortieralgorithmus »Sortieren durch Einfügen«. A L G O R I T H M U S 2.5 S ORTIEREN DURCH E INFÜGEN E INGABE : eine Liste von ganzen Zahlen.
2.3
Schrittweise Verfeinerung
61
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. 2.5 – Für die zweite Stufe der Verfeinerung benötigen wir eine Funktion, die das Ein ordnen ( ) übernimmt. Falls die Funktion ihr erstes Argument an der richtigen Stelle in eine sortierte Liste einfügt, ist die entstehende Liste sortiert und der Algorithmus ist korrekt. Die Terminierung folgt aus der Tatsache, daß die zu sortierende Liste bei jedem Aufruf um ein Element kürzer wird. Wir geben nun die Funktion
an.
A L G O R I T H M U S 2.6 E INFÜGEN IN SORTIERTE L ISTE E INGABE : eine sortierte Liste und ein Wert .
A USGABE : eine sortierte Liste, die die Elemente von und enthält.
M ETHODE : Das Einfügen in eine leere Liste ist klar. Falls das einzufügende Element kleiner als der Kopf der Liste ist, wird es als neuer Kopf genommen, sonst in die Restliste eingefügt. Offensichtlich bleibt so die Ordnung innerhalb der Liste erhalten: " ✍
62
Kapitel 2
☞
Entwurf und Analyse von Algorithmen
# $ # " $ " $ " $ * +!#
– Alg. 2.6 – Die Funktion terminiert spätestens beim Auffinden der leeren Liste am Listenende und fügt das Element, das dann größer ist als alle vorher betrachteten, am Ende ein.
können wir wieder mit vollständiDie Korrektheit der Funktion ger Induktion über die Listenlänge beweisen. Doch zunächst einmal die genaue
gilt also
– und Aussage: Ist eine sortierte Liste – für alle
ein beliebiger Wert, dann ist die durch Einfügen von in mit der Funktion
erhaltene Liste sortiert.
ist die leere Liste, die natürlich sortiert ist. Das gilt auch für die Für einelementige Liste, die in diesem Fall durch die erste Alternative gebildet wird. Ansonsten zerlegen wir in und die sortierte Restliste , wobei für alle die Ungleichung gilt. Ist , so ist demzufolge die resultierende Liste sortiert und die Aussage gilt. Im anderen Fall ist die durch Einfügen von in erhaltende Liste nach Induktionsvoraussetzung sortiert und ist kleiner als jedes ihrer Elemente und die Aussage gilt auch hier.
Die Methode der schrittweisen Verfeinerung findet vor allem beim »Programmieren im Großen« Verwendung, wo ein Problem zuerst in kleinere, oft völlig unterschiedliche und vor allem unabhängige Teilprobleme zerlegt wird.
2.4
Bottom-Up-Entwurf
Auch die umgekehrte Vorgehensweise, der Entwurf eines Algorithmus’ von einfachen Grundfunktionen ausgehend, kommt häufig vor. Man setzt bei dieser Methode voraus, daß eine Anzahl von elementaren, wiederverwendbaren Funktionen zur Verfügung steht. Daraus werden neue, kompliziertere Funktionen zusammengebaut. Diese sollten auch wiederverwendbar sein, um so in mehreren Schritten das gestellte Problem lösen zu können. Man erstellt also einen bottomup-Entwurf . Eine solche Situation tritt im wissenschaftlichen Rechnen auf, wo genormte, gut dokumentierte Funktionsbibliotheken für verschiedene Problemkreise zur Verfü-
2.4
Bottom-Up-Entwurf
63
gung stehen. In unserem Kontext kann man die vorgegebenen Operationen für Listen oder andere noch einzuführende Datentypen als solche wiederverwendbare Methoden benutzen.
2.4.1
Auswertung von Polynomen
Wir wollen diese Entwurfsmethode an einem arithmetischen Beispiel, der Auswertung von Polynomen, erläutern. Gegeben seien ein Polynom mit ganzzahligen Koeffizienten
und die Grundverknüpfungen für ganze Zahlen. Gesucht ist eine Funktion, die den Wert von an der Stelle berechnet. Mit Hilfe der Multiplikation läßt sich sofort eine Potenzierungsfunktion formulieren: " "
✍ ☞
& #* # * + #! "
Damit kann nun die Funktion " definiert werden, die den intuitiven Ansatz »Einsetzen und Ausrechnen« realisiert:
" "
"
✍
☞
" "
" "
"
" # " $ # "!#
" "
Die Anzahl der Multiplikationen ist in diesem Algorithmus quadratisch. Zum Beweis betrachten wir zuerst die Funktion " . Als einfache Endrekursion genügt sie der Rekursionsgleichung
64
Kapitel 2
Entwurf und Analyse von Algorithmen
mit der Lösung nach Satz 2.2. Die gleiche Rekursionsgleichung gilt für die Zahl der Additionen in der Funktion " . Vor jeder Addition wird hier eine explizite Multiplikation und ein Aufruf der Funktion "" ausgeführt. Für die Gesamtzahl der Multiplikationen gilt deshalb
Diese Aufwandsberechnung ist ein typisches Beispiel für den Fall, daß innerhalb eines linearen Algorithmus’ ein Teilalgorithmus linearer Komplexität aufgerufen wird (vgl. Beispiel 1.4). Ein besserer Algorithmus nutzt die Tatsache aus, daß alle Potenzen von nacheinander gebraucht werden. Er kann durch fortgesetztes Ausklammern von hergeleitet werden und ist unter dem Namen H ORNER-Schema bekannt. A L G O R I T H M U S 2.7 H ORNER S CHEMA E INGABE : ein Polynom und eine Auswertungsstelle . A USGABE : . M ETHODE : Die Berechnung erfolgt nach der Formel
– Alg. 2.7 – Man sieht, daß innerhalb der Klammern wieder eine Polynomauswertung steht. Es liegt also eine einfache Endrekursion vor, die direkt in eine Funktion umgesetzt werden kann: ✍ ☞
" "
"
# # " $ # +!#
Die Anzahl der Multiplikationen ist wie die der Additionen gleich .
2.4
2.4.2
Bottom-Up-Entwurf
65
Sortieren durch Auswahl
Wenn die vorliegenden Algorithmen die Teilprobleme nicht genau lösen, wird man versuchen, sie zu erweitern oder leicht zu verändern. Diese Entwurfstechnik könnte man schrittweise Erweiterung nennen. Auch hierfür wollen wir einen Sortieralgorithmus, diesmal das sogenannte »Sortieren durch Auswahl«, als Beispiel angeben. Gegeben sei ein Algorithmus, der das Minimum einer Liste bestimmt: ✍
"
☞ #
$ ' $ "!#
"
Im nächsten Schritt wird das Minimum als Wert geliefert, aber aus der Liste entfernt. Die Restliste wird ebenfalls als Ergebnis zurückgegeben, was durch Erweitern des gegebenen Minimumalgorithmus’ leicht möglich ist: ✍
"
☞ #
)
$ " $% " $ " $ "!#
Der Rückgabewert ist ein Paar aus Minimum und Restliste, in die auch das bei der Minimumsuche weggelassene Element aufgenommen werden muß. Die Herausnahme des Minimums erfolgt in der zweiten Alternative. Diese Funktion wird nun für die Ausgangsliste rekursiv aufgerufen, so daß nacheinander das kleinste Element, das zweitkleinste Element ausgewählt wird:
66
Kapitel 2
Entwurf und Analyse von Algorithmen
A L G O R I T H M U S 2.8 S ORTIEREN DURCH A USWAHL E INGABE : eine Liste von ganzen Zahlen. A USGABE : die sortierte Liste. M ETHODE : Wende die folgende Funktion an: " ✍
☞
$ $ " $%) " $ "!#
– Alg. 2.8 – Das Minimum steht an erster Stelle, die Restliste ist sortiert, also ist der Algorithmus korrekt.
2.5
Divide & Conquer
Wir haben in den vorigen Abschnitten schon zwei einfache Sortierverfahren kennengelernt. Wir behandeln zwar Sortierverfahren erst in Kapitel 5 ausführlich, wollen hier aber trotzdem noch ein Beispiel anführen. Dieses erläutert zugleich die wohl für dieses Buch wichtigste Entwurfsmethode des Divide & Conquer. A L G O R I T H M U S 2.9 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 gleichlange Teilfolgen.
2.5
Divide & Conquer
67
2. Sortiere beide Teilfolgen. 3. Verschmelze die sortierten Teilfolgen zu einer sortierten Gesamtfolge. – Alg. 2.9 – Das Aufteilen in halblange Folgen wird solange durchgeführt, bis die entstehenden Folgen einelementig sind. Diese sind natürlich sortiert. Wir wollen den Algorithmus nicht weiter verfeinern, weil es uns hier nur aufs Prinzip ankommt. Wir überlegen uns aber dennoch, daß sowohl das Aufteilen als auch das Verschmelzen in linearer Zeit durchgeführt werden können. Beim Aufteilen bestimmt man die Mitte und trennt die Folge dort auf – oder man durchläuft die Liste und fügt abwechselnd in die Teillisten ein. Es ist dabei für das Gesamtverfahren unerheblich, ob eine exakte Zweiteilung erfolgen kann, oder ob eine Liste ein Element mehr enthält. Wir gehen in allen Verfahren davon aus, daß die Divisionen aufgehen, anderenfalls würden nur die Formeln komplizierter, ohne mehr Einsicht zu vermitteln. Beim Verschmelzen werden wir beide Teillisten gleichzeitig von Anfang an durchlaufen, die ersten Elemente vergleichen und das jeweils kleinere in die Ergebnisliste eintragen. Offensichtlich reicht auch hier ein Durchlauf. Da durch dieses Verschmelzen sicher eine sortierte Liste entsteht, ist das Verfahren korrekt. Der Aufwand beträgt Vergleiche pro Schritt. Da in jedem Schritt Stück. Für den Gedie Listenlänge halbiert wird, gibt es davon insgesamt . samtaufwand gilt also
Diese hier durchgeführte Vorgehensweise »Aufteilen eines Problems in Teilprobleme der gleichen Art, bis eine Lösung offensichtlich ist, anschließendes Zusammensetzen der Lösung aus den Teillösungen« ist charakteristisch für die Methode des Algorithmenentwurfs, die »Teile und Herrsche« oder auf englisch Divide & Conquer genannt wird. So verläuft auch das zweite Beispiel, das binäre Suchen eines Elementes in einer sortierten Folge. A L G O R I T H M U S 2.10 B INÄRE S UCHE E INGABE : ein Element und eine sortierte Folge von
Elementen.
68
Kapitel 2
Entwurf und Analyse von Algorithmen
A USGABE : Wahrheitswert, ob in der Folge enthalten ist oder nicht. M ETHODE : 1. Vergleiche mit dem Element in der Mitte der Folge. 2. Bei Gleichheit gib wahr zurück und fertig. 3. Sonst gib bei einelementiger Folge falsch zurück. 4. Falls noch weitere Elemente vorhanden sind, suche in der rechten Teilfolge falls größer als das mittlere Element ist, und sonst in der linken Teilfolge. – Alg. 2.10 –
Da die Folge sortiert ist, liefert der Algorithmus eine korrekte Aussage. Sein Auf Vergleiche, da in jedem Schritt die Folgenlänge halbiert wand beträgt wird.
2.5.1
Formeln zur Analyse
Beide eben dargestellten Beispiele laufen nach dem gleichen Entwurfsprinzip ab – Aufteilen, Lösen und Zusammensetzen. Wir wollen es hier als ein allgemeines Prinzip vorstellen und werden es noch vielfältig anwenden. A L G O R I T H M U S 2.11 D IVIDE & C ONQUER E INGABE : Problem. A USGABE : Lösung. M ETHODE : Divide: Teile das Problem in möglichst gleich große Teilprobleme. Conquer: Löse diese Teilprobleme. Merge: Setze Gesamtlösung aus den Teillösungen zusammen. – Alg. 2.11 –
2.5
Divide & Conquer
69
B EMERKUNG 2.6 Im zweiten Schritt wird das Verfahren rekursiv aufgerufen. Da die Teilprobleme so immer kleiner werden, wird deren Lösung irgendwann trivial, z. B. das Sortieren einer einelementigen Folge.
Wir sehen sofort, daß das Sortieren durch Mischen nach diesem Muster formuliert wurde. Die Liste wurde in zwei halb so lange Teillisten zerlegt. Deren Verschmel zen kostete Vergleiche. Für den Aufwand hatten wir herausgefunden. Um das auch formal zu beweisen, geben wir die Lösung von Rekursionsgleichungen an, die für Divide & Conquer-Verfahren typisch sind.
Beim Sortieren durch Mischen gilt für die Anzahl der Vergleiche Im zweiten Beispiel wurde die Folge ebenfalls in zwei gleich lange Folgen aufgeteilt, aber die Suche brauchte nur in einer fortgesetzt werden. Die gefundene Lösung des Teilproblems war außerdem gleich die Lösung des Gesamtproblems. Die Rekursionsgleichung lautete hier mit der Lösung .
Allgemein zerlegen wir ein Problem von der Größe in Teilprobleme Größe und für dasder trivialen Problems sei Aufteilen . Der Aufwand zum Lösen des oder Zusammensetzen werde benötigt. Wir nehmen für die folgenden Sätze und Beweise an, daß die Aufteilung immer restlos vorgenommen werden kann, d. h. . Dadurch bleiben die Formeln schön einfach. Für die Praxis ist das dennoch keine Einschränkung, denn alle Algorithmen laufen auch für Teilprobleme mit unterschiedlicher Größe.
Die Lösung der Rekursionsgleichung für das allgemeine Divide & ConquerVerfahren gibt der nächste Satz an.
S ATZ 2.5 Es seien
,
,
und
beliebig. Dann hat die Gleichung
70
Kapitel 2
die Lösung
Entwurf und Analyse von Algorithmen
B EWEIS . Wir führen eine vollständige Induktion über aus, da in einem Schritt die Lösung für aus Lösungen für erstellt wird. Der Induktionsanfang wird durch
geliefert, und den Induktionsschluß erhält man durch
Aus diesem Satz können wir nun für spezielle Wahl von , und handliche Formeln für die Abschätzung des Aufwandes praktisch relevanter Verfahren gewinnen.
S ATZ 2.6 Es seien Gleichung die Abschätzung
,
,
und
. Dann gilt für die Lösung der
B EWEIS . Für der Summe aus
falls falls falls
wenden wir den letzten Satz an und klammern
Nun unterscheiden wir drei Fälle.
aus
2.5
Divide & Conquer
1. Falls ist, so ist die Summe durch eine Konstante abschätzbar und dominiert. 2. Falls 3. Falls
, so hat die Summe den Wert , so überwiegt
71
.
.
Die häufigste Anwendung erlebt dieser Satz für , wie das auch für das Sortieren durch Mischen der Fall war. Das Ergebnis ist in diesem Fall . Der nächste Satz beschreibt das Verhalten bei konstantem Aufwand für Aufteilen und Zusammensetzen.
S ATZ 2.7 Es seien Gleichung
,
,
und
. Dann gilt für die Lösung der
die Abschätzung
falls falls
Ein Beispiel für die Anwendung dieses Satzes ist die binäre Suche. Das Auffinden der Mitte war als konstant vorausgesetzt worden. Danach brauchte nur noch eine halb so lange Folge durchsucht werden. Es ist also und , und somit gilt .
2.5.2
Multiplikation nach Karatsuba
Wir wollen versuchen, unseren Multiplikationsalgorithmus durch einen Divide & Conquer-Ansatz zu beschleunigen. Dabei wollen wir diesmal nur die Ziffernprodukte als Aufwandsmaß zählen. Das ist nicht ganz unberechtigt, denn auf vielen Rechnern ist die Multiplikation noch deutlich aufwendiger als die Addition. Für die Multiplikation einer -stelligen mit einer -stelligen Zahl brauchten wir in dem angegebenen Algorithmus Ziffernprodukte. Das ist unmittelbar aus dem Ablauf des Verfahrens zu sehen, oder man vernachlässigt in der Rekur-
72
Kapitel 2
Entwurf und Analyse von Algorithmen
sionsgleichung die Additionen. Man kann aber auch gleich die Formel
für die Polynomdarstellung betrachten.
Ausgehend von dieser Formel wollen wir nun eine Aufteilung von und nehmen. Der Einfachheit halber setzen wir . Nun zerlegen wir in
und
vor-
, wobei die , , ,
analog in sind. Dann gilt
Zahlen mit Ziffern
Die Multiplikation mit einer Potenz der Basis ist kostenfrei, also genügt unser Divide & Conquer-Algorithmus der Rekursionsgleichung
Der Aufwand für das Zusammensetzen des Ergebnisses besteht aus Additionen der vier Teilprodukte, ist also linear. Wir können Satz 2.6 anwenden und erhalten . Damit haben wir aber gar nichts gewonnen.
Nun kommt die Idee von K ARATSUBA ins Spiel, der eine andere Aufteilung vornimmt. Sie beruht auf der Formel
und führt zu folgendem Algorithmus:
2.6
Iteration und Rekursion
73
A L G O R I T H M U S 2.12 K ARATSUBA M ULTIPLIKATION E INGABE : zwei Zahlen
und in Polynomdarstellung.
A USGABE : das Produkt
.
M ETHODE : 1. Teile
und wie beschrieben auf. 2. Berechne , und 3. .
– Alg. 2.12 –
.
Für den Aufwand gilt die entsprechende für drei Teilprodukte der hal Gleichung ben Länge, nach Satz 2.6 folgt . B EMERKUNG 2.7 Produkte einfacher Dualzahlen ( ) werden wie erwähnt durch die Hardware ausgeführt. Karatsubas Algorithmus wird aber mit Gewinn in der Langzahlarithmetik (z. B. ) verwendet. Die dabei nötige Übertragsbehandlung haben wir hier unterschlagen. In der gegebenen Form ist er für Polynomprodukte verwendbar.
2.6 2.6.1
Iteration und Rekursion Einstufige Rekursionsformeln
Intuitiv haben wir Folgen bereits als Listen von aufeinanderfolgenden Zahlen verwendet. Das wollen wir nun präzisieren. D EFINITION 2.1 Eine Folge ist eine Funktion von den natürlichen Zahlen in . Die Funktionswerte werden üblichereine Wertemenge , also weise mit bezeichnet.
Die Berechnung des -ten Folgengliedes ist also einfach eine Funktionsauswertung. Die Funktion:
74
Kapitel 2
Entwurf und Analyse von Algorithmen
) ✍ ## * +!# ☞ $!
beschreibt zum Beispiel die Folge .
der Quadratzahlen mit
Eine Folge muß aber nicht durch einen geschlossenen Funktionsausdruck gegeben sein, sie kann auch induktiv definiert Das bedeutet, daß Anfangs derwerden. Wert aus vorhergehenden Werten werte gegeben sind und für alle berechnet wird. Im einfachsten Fall ist , und die Folge ist durch eine einstufige Rekursionsformel bestimmt.
Die Folge der Quadratzahlen wird auch durch die Rekursionsformel
für
definiert. Die Berechnung ihres -ten Elementes kann jetzt durch eine rekursive Funktion beschrieben werden: ✍ ☞ %!
"
'
## * +!#
Die Auswertung dieser rekursiven Funktion berechnet bei Eingabe von alle Elemente von bis zu . Diese Elemente dienen aber nur als Zwischenergebnisse und werden nicht abgespeichert oder ausgegeben.
Wir wollen an diesem Beispiel noch einmal ausführlich erläutern, was bei einem rekursiven Aufruf passiert. Dazu bauen wir mit Hilfe des Datenflußdiagramms für die Funktion einen »Aufrufturm«, der den Aufruf beschreibt. Zuerst betrachten wir jedoch das Datenflußdiagramm für in Abbildung 2.1, in dem einfache arithmetische Ausdrücke zusammengefaßt wurden. In Abbildung 2.2 sieht man nun die Ausführung von . Die den Selbstaufruf symbolisierende gepunktete Linie führen wir jetzt nicht in den Eingang der Funktion zurück, sondern erzeugen eine neue Inkarnation des Funktionsrumpfes. So verfahren wir für jeden rekursiven Aufruf. Die Funktionsrümpfe werden solange »aufgetürmt«, bis ein Aufruf mit erfolgt. Die oberste Inkarnation kann nun ihr Ergebnis berechnen und gibt dieses an die darunterliegende weiter. Das wird
2.6
Iteration und Rekursion
75
n 0
n
n
n-1
qua
+
2n-1
Abbildung 2.1: Die Funktion als DFD
durch die gepfeilten Linien versinnbildlicht. Außerdem schreiben wir noch die Werte der Parameter und der Ergebnisse an die Linien. Das Bild erläutert den von außen initiierten Aufruf .
2.6.2
Fibonaccizahlen
Als Beispiel für mehrstufige Rekursionsformeln betrachten wir die Folge der F IBONACCI -Zahlen , die durch
für
definiert ist. Auch diese Formel können wir direkt in ein Programm umsetzen: ✍
"
#* # * +!# ☞ "
Wir wollen nun feststellen, wie viele Additionen wir zur Berechnung von benötigen. Die Subtraktionen zur Bestimmung von und , die wir eigentlich
76
Kapitel 2
Entwurf und Analyse von Algorithmen
0 n 0 n n
n-1
qua +
2n-1
0
1 n 0 n n
n-1
qua 0 +
2n-1
1 2
0
n n
n
n-1
qua 1 +
2n-1
4
Abbildung 2.2: Der Rekursionsturm für
2.6
Iteration und Rekursion
77
auch mitzählen müßten, die aber größenordnungsmäßig nicht über der Addition liegen, werden von uns vernachlässigt. Die Rekursionsgleichung zur Bestimmung der Additionen hat offensichtlich eine sehr ähnliche Struktur wie die Formel für die Fibonacci , was durch vollständige zahlen selbst. In der Tat ist ihre Lösung Induktion leicht nachzuweisen ist.
Wie stark wachsen aber die Fibonaccizahlen für große ? Hierüber gibt folgender Satz, genannt B INETsche Formel, Auskunft. Sie ist ebenfalls mit vollständiger Induktion zu beweisen.
gilt
S ATZ 2.8 Für alle
Die Fibonaccizahlen wachsen also exponentiell!
Intuitiv erwarten wir, daß zur Berechnung von nicht mehr als Additionen gebraucht werden. Wir dürfen nur nicht, wie das oben geschehen ist, die gleichen Werte wieder und wieder berechnen, sondern schreiben eine Funktion, die aus den beiden letzten Werten die gesuchte Fibonaccizahl mit einer Addition bestimmt und sie mit der vorherigen als Ergebnis übergibt: ✍
☞
" '
" #* # * +!#
Dabei bestimmt die erste Komponente eines Paares. Der Aufwand für diese Funktion ist nach Satz 2.2 offenbar in . Vor allzu naiver Anwendung der Rekursion sei also gewarnt. Auch die Berechnung der Quadratzahlen durch direktes Umsetzen der Rekursionsformel oder von durch Aufaddieren sind dafür abschreckende Beispiele.
78
Kapitel 2
2.6.3
Entwurf und Analyse von Algorithmen
Allgemeine Rekursion
In der Mathematik dienen Iterationsverfahren dazu, den Grenzwert einer Folge zu bestimmen. Die Iterationsvorschrift, die einer Rekursionsformel entspricht, definiert eine Folge, für deren Grenzwert man sich interessiert. Die Iteration wird also nicht wie bisher durchgeführt, um das -te Glied einer Folge zu bestimmen, sondern solange, bis ein Abbruchkriterium erfüllt ist. Uns interessieren hier weniger die verschiedenen Verfahren und Abbruchbedingungen, sondern wir wollen an einem einfachen Beispiel die zwei Iterationsfunktionen entwickeln, die im imperativen Programmieren durch Standardschleifenkonstruktionen abgedeckt werden. Die Folge
beliebig
für
konvergiert für gegen . Wir schreiben ein Programm, das nach dieser Vorschrift Näherungen für bestimmt. Da wir die Lösung kennen und auch durch einen Standardfunktionsaufruf bestimmen können, führen wir die Iteration solange durch, bis sich nur wenig vom Grenzwert unterscheidet.
A L G O R I T H M U S 2.13 Q UADRATWURZEL , WHILE E INGABE : Radikand , Startwert A USGABE : Näherung für
, Genauigkeitsschranke .
.
M ETHODE : Falls
, ist
als Lösung gut genug, sonst berechne
und führe den Test wieder durch. – Alg. 2.13 –
2.6
Iteration und Rekursion
79
|x - sqrt (a)| >= 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
l
r
l’
r
– Falls der neue linke Teilbaum die gleiche Höhe wie aufweist, also nicht gewachsen ist, sind wir fertig und geben zurück. Auch dieser Baum ist dann nicht gewachsen. Die Information, ob ein Baum, in den eingefügt wurde, gewachsen ist, ist also wichtig und muß mit zurückgegeben werden.
– Ist gewachsen und betrug die Balance von vorher 1, so ist sie jetzt 0. Die Höhe ist aber gleich geblieben, es wurde nur der niedrigere Teilbaum aufgefüllt. – Betrug die Balance 0, so ändert sie sich nun zu , und der Baum ist gewachsen. Die AVL-Eigenschaft bleibt in allen bisherigen Fällen erhalten. – Das ist im dritten Fall anders, nämlich falls die Balance war. Nun ist durch das Einfügen der höhere, linke Teilbaum noch einmal gewachsen, die AVL-Eigenschaft ist also verletzt. Wir müssen sie wieder herstellen. Dazu nehmen wir eine weitere Fallunterscheidung vor.
170
Kapitel 6
Bäume und Suchbäume
links gewachsen: p’ y r
l’r l’l
Da gewachsen ist, muß die Balance von vorher 0 betragen haben und wird nun zu . hätte also die Balance . Das darf nicht sein. Folgende, Rotation nach rechts genannte Operation bringt den Baum wieder ins Lot. Man beachte, daß die Suchbaumeigenschaft durch die Rotation erhalten bleibt: y p’
l’l
l’r
r
Der neue Baum ist vollständig ausgeglichen und hat die gleiche Höhe wie vorher.
Jetzt ist rechts gewachsen, deshalb ist noch eine Fallunterscheidung bezüglich der Wurzel des gewachsenen rechten Teilbaumes erforderlich. –
. p’
w
y
y r
w l’l
l’l l’r.l
p’
l’r.l
l’r.r
r
l’r.r
Auch dieser Baum ist nicht ausgeglichen. Alle Knoten sind jedoch ihrem Wert gemäß auch in folgendem Baum korrekt untergebracht. Diese Umord-
6.5 AVL-Bäume
171
nung kann man sich als Doppelrotation veranschaulichen, zuerst rotieren und nach links und dann und nach rechts. –
. p’
w
y
y
p’
r
w l’l
l’l
l’r.l
l’r.r
r
l’r.l l’r.r
Die gleiche Doppelrotation biegt auch diesen Baum wieder zurecht, lediglich die Balancefaktoren unterscheiden sich. In beiden Fällen ist der zurückgegebene Baum gleich hoch wie der Ausgangsbaum. Das Einfügen in den rechten Teilbaum verläuft völlig analog. – Alg. 6.13 – Wir stellen die Eigenschaften des Algorithmus’ noch einmal zusammen: Durch das Einfügen in den leeren Baum steigt die Höhe. Die Information über das Wachsen wird beim Abbau der Rekursion verarbeitet. Im einfachsten Fall wird die Balance geändert. Wird dadurch die Ausgeglichenheit verletzt, so wird diese durch Rotation oder Doppelrotation wieder hergestellt. Der so erhaltene Baum hat die gleiche Höhe wie der Ausgangsbaum. Es tritt also höchstens eine (Doppel)rotation auf. In jedem Fall bleibt die Suchbaumeigenschaft erhalten. Eine Rotation hat konstanten Aufwand, die umgehängten Teilbäume brauchen nicht angeguckt zu werden.
Der Gesamtaufwand ist demzufolge proportional zur Höhe des Baumes . Man kann das Wachsen eines Baumes leicht aus der Balance vor und nach dem Einfügen berechnen. Vorher muß sie gewesen sein, und der Wert nachher gibt den gewachsenen Teilbaum an. Ähnlich wie beim Einfügen ist auch beim Löschen darauf zu achten, daß die Aus-
172
Kapitel 6
Bäume und Suchbäume
geglichenheit durch ein Schrumpfen eines Teilbaumes nicht verletzt wird. A L G O R I T H M U S 6.14 L ÖSCHEN IN EINEM AVL-B AUM E INGABE : ein AVL-Baum mit Wurzel und ein Schlüssel . A USGABE : der AVL-Baum, der nicht mehr enthält. M ETHODE : Wieder gehen wir wie in normalen binären Suchbäumen vor. Zuerst die einfachen Fälle. Ist der Baum leer, so melden wir je nach Geschmack einen Fehler oder lassen den Baum unverändert. Sonst wird der Knoten mit dem Wert gesucht. Besitzt der Baum zwei leere Teilbäume, so ist das Ergebnis der leere Baum. x
Besitzt er einen leeren Teilbaum, kann ohne Probleme entfernt werden. Der Baum wird niedriger, welches als Teil des Ergebnisses zum Anpassen der Balance und zum Ausgleich des Gesamtbaumes weitergereicht wird: x
y
y x
y
Ansonsten wird der symmetrische Nachfolger (oder Vorgänger) gesucht und
6.5 AVL-Bäume
173
dann aus dem entsprechenden Teilbaum gelöscht. Wir betrachten wieder beispielhaft das Löschen aus dem linken Teilbaum. p
p’ x
l
r
l’
r
bezeichne den neuen Teilbaum. Schrumpft dieser nicht, so sind wir fertig.
Sonst spielt wieder die Balance von eine Rolle. War sie 0, so wird sie nun 1, und der Baum ist nicht geschrumpft. War der linke Teilbaum vorher höher, also Balance , so ist er jetzt gleich hoch und die neue Balance beträgt 0. Der Baum schrumpft, bleibt aber ein AVL-Baum. Für den Fall, daß diese Eigenschaft verloren geht – also wenn der Baum schon vorher rechtslastig war – unterscheiden wir mehrere Ausgleichsoperationen. – Der rechte Teilbaum hat Balance . Eine einfache Linksrotation stellt die Ausgeglichenheit wieder her, und der Baum ist nicht geschrumpft, wir sind also fertig: p’
y’ y
p’
l’ r.r l’ r.l
r.r
r.l
– Entsprechendes gilt für Balance
:
p’
y’ y
p’
l’ l’
r.l r.r
Nun ist aber die Gesamthöhe niedriger.
r.l
r.r
174
Kapitel 6
Bäume und Suchbäume
– Im Fall der Linkslastigkeit des rechten Teilbaums reicht eine Linksrotation zum Ausgleich nicht aus. Wir müssen den linken Teilbaum von betrachten und eine Doppelrotation durchführen: p’
w’ y
l’
p’
y’
w
r.r r.l.l
l’
r.l.l
r.l.r
r.r
r.l.r
Das Bild zeigt den Fall Balance . Die gleiche Struktur ergibt sich für den Balancewert 0, hier ändert sich die Balance von auf 0. Das bleibt auch für Balance so, dabei wird aber Balance . In allen drei Fällen schrumpft der Ergebnisbaum. Das Löschen aus dem rechten Teilbaum funktioniert entsprechend. – Alg. 6.14 – Wie beim Einfügen ist aufgrund der Algorithmusbeschreibung durch die Bilder klar, daß der resultierende Baum ein AVL-Baum ist. Ebenfalls wird in einem rekursiven Durchlauf über die Höhe des Baumes die zu löschende Stelle gefunden und beim Abbau der Rekursion die Ausgeglichenheit wieder hergestellt. Hierzu kommt allerdings noch der Aufwand zum Bestimmen des symmetrischen Nachfolgers – also maximal noch ein Baumabstieg. Anders als beim Einfügen sorgen jetzt die Rotationen nicht dafür, daß die Höhe invariant bleibt. Es können mehrere Rotationen auftreten. Da der Suchpfad höchstens einmal zurückgelaufen wird und jede Rotation konstante Zeit kostet, ist der maximale Gesamtaufwand den noch in .
Damit haben wir die eingangs gestellte Forderung erfüllt. Die entsprechenden CAML LIGHT Programme können, wie in Anhang B beschrieben, bezogen werden.
6.6
6.6
Selbstanordnende Bäume
175
Selbstanordnende Bäume
Die AVL-Bäume erfüllen die Forderungen, daß alle Wörterbuchoperationen effizient, also in logarithmischer Zeit ausgeführt werden können. Das ist aber mehr ein theoretischer Aspekt. Für die Praxis geben wir uns vielleicht mit etwas weniger für jede Einzeloperation zufrieden, wenn in der Addition aller hintereinander durchgeführten Aktionen die Rechnung wieder stimmt. Für häufig auftretende Kombinationen sind AVL-Bäume auch geradezu ungünstig. Das Einfügen geschieht an den Blättern, erfordert also auch im Minimalfall Vergleiche. Falls es vorkommt, daß ein gerade eingefügtes Element wieder Vergleiche. Bei binären Suchbäumen gesucht wird, hat man noch einmal bleibt sogar der älteste Eintrag der mit dem effizientesten Zugriff. AVL-Bäume werden durch die Rotationen zwar ein wenig umgebaut, aber man versucht stets mit möglichst wenigen davon auszukommen, und neu eingefügte Schlüssel bleiben an den Blättern.
Wir wollen nun selbstanordnende Bäume betrachten, die nach folgender Philosophie entworfen werden: Die Datenstruktur ist ein binärer Suchbaum ohne Zusatzinformation. Ein neu eingefügtes Element bildet die Wurzel. Nach dem Suchen steht das gefundene Element an der Wurzel. Die erfolglose Suche befördert den symmetrischen Vorgänger zur Wurzel. Von diesen Bäumen kann man erwarten, daß sie im Mittel über viele Wörterbuchoperationen besser sind als AVL-Bäume. Die angestrebten Punkte werden durch den Umbau des Baumes mit den bekannten Rotationen verwirklicht. Wir stellen hier eine spezielle Variante vor, die auf folgender Operation beruhen. A L G O R I T H M U S 6.15 S PLAY E INGABE : ein binärer Suchbaum und ein Wert . A USGABE : ein binärer Suchbaum , der die gleichen Schlüssel wie enthält, und bei dem
176
Kapitel 6
Bäume und Suchbäume
die Wurzel bildet. Falls nicht in enthalten ist, bildet das größte Element, das kleiner als ist, die neue Wurzel. M ETHODE : Fallunterscheidung nach der Struktur des Baumes. Falls der Baum leer ist, gibt man diesen zurück. Ist er nicht leer, und steht der gesuchte Wert in der Wurzel, so gibt man wieder den ursprünglichen Baum als Ergebnis zurück. Es sei nun
die Wurzel des (nicht leeren) Baumes und
:
z
l
r
– Falls der linke Unterbaum leer ist, ist das Ergebnis der unveränderte Baum, denn der Wert ist nicht vorhanden, und ist das bisherige Minimum.
– Falls den Wert als Wurzel enthält, erzeugt eine Rotation nach rechts um die neue Gestalt: z
x
x
z r
a
b
a
b
c
– Im anderen Fall unterscheiden wir weitere Fälle nach der Gestalt von mit Wurzel . Falls , stellt eine Doppelrotation die Zielstruktur nach einem rekursiven Splay-Aufruf für (mit Ergebnis ) her. Der Wert oder dessen symmetrischer Vorgänger:
ist dabei gleich
6.6
Selbstanordnende Bäume
177
z
y
y
v
z
r v d
b
b
c
d
r
c a’ v
y b z c
d
r
, so erfolgt nach dem rekursiven Aufruf für (mit Ergebnis ) eine Ist Doppelrotation links-rechts: Der Wert ist wieder gleich oder dessen symmetrischer Vorgänger: z
z
y
v r
r
v
y
a
d
c
d
a
c
b’ v
y
a
z
c
d
r
178
Kapitel 6
Bäume und Suchbäume
Das Durchsuchen des rechten Teilbaumes ist völlig analog. – Alg. 6.15 – Die Wörterbuchoperationen Suchen, Einfügen und Löschen beginnen alle mit einer Umordnung des Baumes durch die Funktion Splay. A L G O R I T H M U S 6.16 W ÖRTERBUCHOPERATIONEN
MIT
S PLAY
M ETHODE : Nach einem Splay-Aufruf werden, abhängig von der Art der Operation, folgende Aktionen durchgeführt: Beim Suchen muß nur noch der gesuchte Schlüssel mit der Wurzel verglichen werden. Beim Einfügen liefert Splay einen Baum, dessen Wurzel der symmetrische Vorgänger von oder – falls das kleinste Element im neuen Baum ist – das kleinste Element ist. An beide Bäume läßt sich einfach als neue Wurzel anbauen: x
x>v
v l
v
r
x l
r v x
r
l
Beim Löschen liefert die Splay-Operation einen Baum mit als Wurzel – anderenfalls ist nicht im Baum enthalten und braucht nicht gelöscht zu werden. Falls der linke Unterbaum leer ist, bildet der rechte das Ergebnis. Sonst ist durch seinen symmetrischen Vorgänger zu ersetzen. Dieser kann durch einen Aufruf von Splay für das Maximum des linken Teilbaumes ermittelt werden.
6.7
2-3-4-Bäume
179
Dieses braucht nicht bekannt zu sein – man kann auch einfach nach dem (nun wirklich größten) Wert suchen. Der neue linke Teilbaum läßt sich mit dem rechten wieder einfach zum gewünschten Ergebnis zusammenbauen, indem man den (leeren!) rechten Unterbaum des linken Teilbaumes durch ihn ersetzt. – Alg. 6.16 – Alle diese Operationen sind auf normalen binären Suchbäumen durchführbar. Die Rotationen lassen die Suchbaumeigenschaft invariant. D EFINITION 6.6 Binäre Suchbäume, bei denen alle Wörterbuchoperationen mit Splay vorgenommen werden, heißen Splay-Bäume. Für Splay-Bäume gilt der folgende Satz. S ATZ 6.4 Das Ausführen von Wörterbuchoperationen in beliebiger Folge (beginnend mit einem leeren Splay-Baum), bei der die Anzahl der Schlüssel kleiner oder gleich bleibt, hat den Maximalaufwand
splay
Dabei wurde als Einheit die Neukonstruktion eines Baumes bei der Rotation gezählt. Eine einfache Rotation zählt also , und die Doppelrotationen zählen oder . Die Aussage des Satzes ist so zu interpretieren, daß der Maximalaufwand für jede der Operationen logarithmisch bezüglich der Maximalzahl der Knoten ist. Die Implementierung kann man wieder der Zusammenstellung der Beispiele (siehe Anhang B) entnehmen.
6.7
2-3-4-Bäume
AVL-Bäume erreichten ihre Ausgeglichenheit durch die Beschränkung des Höhenunterschiedes der Teilbäume. Wir wollen jetzt Bäume betrachten, bei denen alle Teilbäume die gleiche Höhe, d. h. alle Blätter den gleichen Abstand zur Wurzel haben. Um die Operationen effizient implementieren zu können, erlauben wir diesen Bäumen in die Breite zu wachsen. Hierzu lassen wir zu, daß ein Knoten auch mehr als einen Schlüssel aufbewahren darf. Wir erweitern also die Definition von binären Suchbäumen auf solche höherer Ordnung.
180
Kapitel 6
Bäume und Suchbäume
D EFINITION 6.7 Ein Suchbaum der (Maximal-)Ordnung besteht aus einer Wur Schlüssel enthält, und Suchbäumen der Ordnung als zel, die
Teilbäumen. Die Schlüssel sind der Größe nach sortiert und trennen die Teilbäume so, daß alle Werte des -ten Teilbaumes zwischen dem -ten und -ten Schlüssel liegen. Alle Schlüssel des ersten (am weitesten links postierten) Teilbaumes sind kleiner als der erste Schlüssel. Ebenso sind alle Schlüssel des letzten Teilbaumes größer als der letzte Schlüssel.
Für erhalten wir die Definition eines binären Suchbaumes. Die Werte des ten Teilbaumes repräsentieren das Intervall mit und .
Blätter in Suchbäumen höherer Ordnung brauchen nicht leer zu sein. Besonders interessant sind sogar Bäume, in denen jedes Blatt sehr viel Information enthält, etwa mehrere Hundert Datensätze. Solche Bäume dienen zur Repräsentation von Datenstrukturen, die nicht vollständig im Hauptspeicher Platz finden. Jeder Zugriff auf ein Blatt bedeutet dann einen Zugriff auf ein externes Speichermedium. In unserem Zusammenhang wollen wir Bäume betrachten, in denen die Wörterbuchoperationen effizient durchführbar sind. D EFINITION 6.8 Ein Suchbaum der Ordnung heißt B-Baum, falls alle Blätter den gleichen Abstand von der Wurzel haben und jeder Knoten bis auf die Wurzel mindestens Schlüssel enthält. Da wir keine leeren inneren Knoten zulassen, folgt aus dieser Definition, daß die Wurzel mindestens einen Schlüssel enthält. Besonders einfach sind B-Bäume der Ordnung 4, die Knoten der Ordnung 2, 3 oder 4 aufweisen. Diese nennt man 2-3-4 Bäume. Wieder können wir leicht feststellen, daß die Höhe von 2-3-4 Bäumen logarithmisch beschränkt ist. Der höchste 2-3-4-Baum, der Schlüssel speichert, ist ein vollständiger Binärbaum der Höhe
Der niedrigste hat auf jeder Stufe in jedem Knoten drei Schlüssel, deren Anzahl also
beträgt (vgl. Satz 6.1). Für die Höhe von 2-3-4-Bäumen gilt demnach
6.7
2-3-4-Bäume
181
Wurzel d
r Innerer Knoten
b
f
j
g
i
n
t
x Blatt
a
c
e
l
p
s
v
z
Abbildung 6.10: Ein 2-3-4-Baum
In einzelnen Knoten besteht etwas »Luft«, weshalb der Abstand von der Wurzel zu den Blättern, in die wir jetzt auch ein bis drei Schlüssel packen, mit relativ geringem Aufwand für alle gleich gehalten werden kann. Wir vereinbaren folgenden Datentyp: ✍
☞
+#
Es gibt also drei verschiedene Typen von Blättern, mit einem, zwei oder drei Schlüsseln. Das gleiche gilt für die Typen der inneren Knoten. Jeder Teilbaum ist wieder ein 2-3-4-Baum. Das Suchen in einem 2-3-4-Baum kann leicht als Erweiterung des Suchens in einem binären Suchbaum durchgeführt werden. A L G O R I T H M U S 6.17 S UCHE IN 2-3-4-B AUM E INGABE : Wert und 2-3-4-Baum . A USGABE : wahr, falls
, falsch sonst.
182
Kapitel 6
Bäume und Suchbäume
M ETHODE : Falls nicht in Wurzel, suche in passendem Teilbaum: " ✍
☞
$ &
* * * * +!#
– Alg. 6.17 –
Das Einfügen eines neuen Schlüssels kann natürlich die Baumstruktur verändern. Betrachten wir zunächst das Einfügen in ein Blatt . Es sind zwei Fälle zu unterscheiden:
1.
hat weniger als drei Schlüssel gespeichert. In diesem Fall ist noch nicht voll besetzt, und kann in eingefügt werden.
2.
hat bereits drei Schlüssel gespeichert. Beim Einfügen von in würde überlaufen. muß in diesem Fall aufgeteilt werden. Seien , und die Schlüssel von . Dann teile so in zwei Knoten und auf, daß den Schlüssel und den Schlüssel aufnimmt. Der neue Schlüssel wird in eingefügt, falls , sonst in . Der Schlüssel wird »hochgezogen«, d. h. er bildet die neue Wurzel. Es entsteht ein Baum der Höhe 1. 2-3-4-Bäume wachsen also an der Wurzel!
6.7
2-3-4-Bäume
183
k
f
k
l
f
p
i
l
p’
p"
Fügt man in einen höheren Baum ein, so wird wie beim Suchen der Teilbaum ermittelt, in den das neue Element gehört. Bleibt dieser durch das Einfügen in der Höhe konstant, so liegt ein korrekter 2-3-4-Baum vor, und wir sind fertig. Wächst der Teilbaum aber, so muß seine Wurzel in den aktuellen Knoten eingefügt werden. Dabei kann dieser überlaufen und muß – wie eben das Blatt – aufgeteilt werden. Die neuen Teilbäume ersetzen den Baum, in den eingefügt wurde. Das Teilen eines überlaufenden Knotens wird solange rekursiv in Richtung Wurzel ausgeführt, bis ein Knoten erreicht ist, der weniger als drei Schlüssel speichert oder bis die Wurzel erreicht ist. Muß die Wurzel geteilt werden, so entsteht eine neue Wurzel, der Baum wächst nach oben. So ist gewährleistet, daß alle Blätter dieselbe Tiefe behalten. Im folgenden Beispiel muß nur einmal aufgeteilt werden: d
b
f
m
k
d
l
z
b
k
f
p
m
i
l
p’
z
p"
Das Heraufwandern der Aufteilung über zwei Stufen bis zur Wurzel soll hier veranschaulicht werden: m
d
m
p
d
q b
f
k
p
q’ k
l
n
z
b
p
Wir skizzieren im folgenden den Algorithmus.
q"
f
i p’
l p"
n
z
184
Kapitel 6
Bäume und Suchbäume
A L G O R I T H M U S 6.18 E INFÜGEN IN 2-3-4-B AUM E INGABE : Element und 2-3-4-Baum . A USGABE : 2-3-4-Baum
, in den
eingefügt wurde und Information, ob
höher als ist.
M ETHODE : Wir unterscheiden das Einfügen in ein Blatt vom Einfügen in einen inneren Knoten. Im Blatt wird einfach der neue Wert einsortiert, in den inneren Knoten ein Teilbaum ausgewechselt. Die Einfügefunktion gibt neben dem neuen Baum noch einen Wert zurück, der anzeigt, ob der Baum gewachsen ist. Ein mögliches An " behandelt. wachsen wird in der Funktion " ✍
☞
"
" +!# # $ * * * * #&
Die aufgerufenen Funktionen setzen die Bilder direkt durch Konstruktoraufrufe " an: um. Wir führen exemplarisch die Funktion
" ) ✍
6.7
2-3-4-Bäume
185
☞
* $ # * "!#
Dabei bestimmt
)
"
)
)
ersetzt werden muß.
den Baum, der durch
– Alg. 6.18 – Hier noch ein Ablaufbeispiel, das zeigt, wie der Baum langsam an der Wurzel wächst:
✍
$ # # &
☞
✍
$ #
☞
✍ ☞ $
# &
# # & # &
$ # # & # &
$ # # &
# &
✍
☞
# &
✍
☞
# &
Der Aufwand ist proportional zur Höhe. Es erfolgt ein Abstieg, um das Blatt zu finden, in das eingefügt wird, und falls dieses Blatt überläuft, wird beim Rekur-
186
Kapitel 6
Bäume und Suchbäume
sionsabbau ein Ausgleich vorgenommen, der auf jeder Ebene in konstanter Zeit ausführbar ist. Man kann sich auch ein anderes Vorgehen denken. Schon beim Abstieg wird dafür gesorgt, daß niemals in ein volles Blatt eingefügt werden muß. Das erreicht man, indem auf dem Weg nach unten alle Knoten der Ordnung 4 zerschlagen werden. Ihr mittlerer Schlüssel wird dabei in den Vater aufgenommen, der ja sicher Ordnung 2 oder 3 besitzt. Ähnlich kann man auch beim Löschen vorgehen. Beim Abstieg im Baum werden vorsorglich schon Knoten zusammengefaßt, um zu garantieren, daß nicht aus einem Blatt mit einem Schlüssel gelöscht wird. A L G O R I T H M U S 6.19 L ÖSCHEN MIT V ORAUSSCHAU E INGABE : Element und 2-3-4-Baum , der
enthält.
A USGABE : ohne . M ETHODE : 1. Falls ein Blatt, lösche 2. Sei nun
aus .
in Wurzel .
a) Falls der Baum links von mindestens die Ordnung 3 hat, tausche symmetrischem Vorgänger und lösche diesen aus . b) Falls der Baum rechts von symmetrischem Nachfolger.
mit
mindestens die Ordnung 3 hat, tausche mit
c) Falls und beides Binärbäume sind, verschmelze ihre Wurzeln mit , dem Schlüssel, der sie trennt, zu einem -Knoten und lösche aus diesem Teilbaum.
6.7
2-3-4-Bäume
187
m
d
b
p
k
n
d
z
b
m
p
k
n
d
b
z
p
k
n
z
3. Ist nicht in Wurzel , so bestimme Teilbaum, der enthält und lösche daraus. Dabei ist ein Ausgleich vorzunehmen, falls dieser Teilbaum die Ordnung 2 hat: a) Hat dieser Teilbaum einen Bruder von höherer Ordnung, so »verschiebe« einen Schlüssel vom Bruder in Wurzel und den dortigen Trennschlüssel in . Lösche dann. d l
b
d
g k
m
b
k
g
l
b) Haben alle ein oder zwei direkten Brüder von nur Ordnung 2, so verschmelze mit dem Trennschlüssel und einem Bruder zu einem neuen Teilbaum der Ordnung 4 und lösche daraus. d
b
l
k
d
m
b
k l
– Alg. 6.19 – Dieser Algorithmus läuft eventuell zweimal von der Wurzel zu den Blättern, einmal um den symmetrischen Nachfolger zu holen und einmal, um ihn zu löschen. Die Komplexität ist wieder logarithmisch.
188
Kapitel 6
Bäume und Suchbäume
Da jedesmal auf dem Weg ein Knoten mit nur einem Schlüssel mit einem anderen Schlüssel angereichert wird, bevor er besucht wird, ist klar, daß alle Operationen auf Knoten der Ordnung mindestens 3 ausgeführt werden, und deshalb die Höhe unverändert bleibt. Das gilt nicht für den ersten Aufruf, also für die Wurzel, die durch Verschmelzen mit ihren Söhnen verschwinden kann, was das Schrumpfen des Baumes bedeutet. Die Implementierung entnehme man der Beispielsammlung.
Kapitel 7 Hashverfahren 7.1
Definition und Datenstruktur
Unsere bisherigen Realisierungen von Wörterbüchern führten die Suche ausschließlich mittels Schlüsselvergleichen durch. Durch Bereitstellung von geeigneten Datenstrukturen, etwa ausgeglichenen Bäumen, konnten wir eine recht gute Effizienz erreichen. Nun wollen wir noch eine Methode kennenlernen, die aufgrund des Schlüsselwertes versucht, seinen Platz direkt zu finden und dabei ohne viele, manchmal recht aufwendige Vergleiche auszukommen. Diese Vorgehensweise kommt dem im täglichen Leben praktizierten Verhalten auch viel näher als die Abspeicherung in Bäumen. Überlegen wir einmal, wie wir unser privates Adreßbuch verwalten. Normalerweise werden wir für jeden Anfangsbuchstaben eine Seite (oder mehr) freihalten und auf diese Seite alle entsprechenden Adressen der Reihe nach eintragen. Wir verwalten also nicht eine Liste, sondern ! Der Anfangsbuchstabe hilft uns beim Finden der richtigen Seite. Wir können das Adreßbuch natürlich auch als eine Liste auffassen, die aber nicht dicht, nicht lückenlos beschrieben ist. Wir verwenden also gestreute Speicherung und berechnen den Platz eines Eintrages aus seinem Wert. In der Informatik nennt man eine solche Datenstruktur eine Hashtabelle. D EFINITION 7.1 Eine Hashtabelle ist ein -elementiges Feld zur Speicherung von Datensätzen (
). Die Positionen der Datensätze in der Tabelle heißen Hashadressen, sie werden aus den Schlüsselwerten mittels einer Hashfunktion berechnet. Da ein direkter Zugriff auf alle Plätze der Hashtabelle wesentlich ist, wird sie als Feld implementiert. Ihre Indexwerte von bis bilden in der Regel nur einen kleinen Teilbereich der möglichen Schlüsselwerte, die ihrerseits vielleicht erst in ganze Zahlen konvertiert werden müssen. Deshalb kann die Hashfunktion nicht injektiv sein, und es wird zu Kollisionen kommen. Dies wird um so häufiger der
190
Kapitel 7
Hashverfahren
Fall sein, je höher ihr Belegungsfaktor
ist. Die Qualität eines Hashverfahrens hängt nun wesentlich von der Hashfunktion und der Strategie zur Kollisionsauflösung ab. Oft sind es Texte oder Zeichenketten, die in einer Hashtabelle abgelegt werden. Dabei ist jedem Zeichen eine natürliche Zahl zugeordnet. Die Hashfunktion sollte nun nicht, wie in unserem Adreßbuchbeispiel, nur vom ersten Zeichen abhängen. Ideal wäre eine Funktion, die von allen Zeichen abhängt und gut streut. Ähnliche Namen sollten auf weit auseinander liegende Adressen abgebildet werden. Andererseits darf die Berechnung der Hashfunktion nicht zu kompliziert sein. Als gute Wahl könnte man eine gewichtete Summe von mehreren Zeichen vom Anfang und vom Schluß des Wortes wählen.
Dieser Wert wird dann mittels der eigentlichen Hashfunktion auf den Indexbereich abgebildet. Hierfür verwendet man meistens die Funktion
wobei eine Primzahl ist. Zur Unterscheidung nennen wir die erste Funktion, die Schlüsselwerte auf ganze Zahlen abbildet, primäre Hashfunktion.
7.2
Offene Hashtabellen
Wie bereits erwähnt, kann es bei der Abbildung der Schlüsselwerte auf die Hashadressen zu Kollisionen kommen, wenn zwei unterschiedliche Schlüssel die gleiche Adresse haben. Wir wollen deshalb die Hashtabelle als ein Feld von Listen anlegen. Dabei werden die Synonyme der Hashfunktion in jeweils die gleiche Liste eingetragen: ✍ ☞
$ "#
"
Statt der Listen könnte man hier auch Bäume nehmen – meistens lohnt sich dieser Mehraufwand aber nicht. Wir verwenden also unsere bekannten Wörterbuchoperationen für Listen:
7.2
"
✍
☞
Offene Hashtabellen
191
$ " $ " $%) " $ " !#
Wir fügen am Ende ein, um Doppeleinträge zu vermeiden.
"
✍
" $ " $% ) " $ "!#
☞
" ✍
☞
!
"
$ " $%) "!#
Diese werden nach Anwendung der primären Hashfunktion für die entsprechende Liste aufgerufen. Wir benutzen hier zwei Funktionen höherer Ordnung, um das allgemeine Schema der Funktionen klar herauszustellen: %
✍ ☞
#
* * & &
& # & *
+!#
Wir wenden hierbei die Funktion auf den durch bestimmten Tabellenplatz an. Dabei ist von allgemeinem Typ und ordnet eine ganze Zahl zu. Im einfachen Fall, wenn die Schlüsselwerte schon ganze Zahlen sind, ist die Identität. Die zweite Funktion aktualisiert einen Tabellenplatz: ✍ ☞
"
! +!# !% #+!#
*
& #
192
Kapitel 7
Hashverfahren
Diese Schablonen werden nun für Einfügen, Löschen und Suchen benutzt. A L G O R I T H M U S 7.1 W ÖRTERBUCHOPERATIONEN
FÜR OFFENE
H ASHTABELLE
E INGABE : primäre Hashfunktion, Schlüssel und Hashtabelle. A USGABE : Hashtabelle mit Schlüssel, Hashtabelle ohne Schlüssel bzw. Auskunft, ob Hashtabelle Schlüssel enthält. M ETHODE : Einfügen, Löschen und Suchen benutzen die Funktionen: ✍ ✍
# $
% %
☞
!
*# * $ *!#% * + !#
☞ ✍
*# * $ *!#% * + !#
%
☞
'
*# * $ * * +!#
– Alg. 7.1 – Nun braucht nur noch eine Hashtabelle angelegt zu werden, und los geht’s:
☞
&
"
✍
✍ ☞
# $ * +!#
$
✍
☞
# $ # ## $ )!#% "!#
✍
7.2
☞
!
✍ ☞
#
$
#
$
✍ ☞ ! ✍ $ ☞ #
7.2.1
$
193
#+!#
✍ ☞
# #* #
Offene Hashtabellen
Aufwandsüberlegungen
Einfügen und Löschen setzen eine erfolglose bzw. erfolgreiche Suche voraus. Daher genügt es, den Aufwand dieser beiden Suchoperationen zu betrachten. Als Einheit betrachten wir einen Schlüsselvergleich. Beginnen wir mit der erfolglosen Suche. Der Minimalwert ist min denn die zur Hashadresse gehörige Liste kann leer sein, und es findet kein Schlüsselvergleich statt. Das Maximum beträgt max weil die gesamte Hashtabelle zu einer Liste degenerieren kann. Im Mittel wird die Listenlänge, die ja die Anzahl der Schlüsselvergleiche bestimmt, gleich sein. es gilt also
mit
Bei der erfolgreichen Suche muß mindestens ein Vergleich durchgeführt werden, im Maximalfall sind es aber wieder . Im Mittel wird man die halben Listen durchlaufen und auf jeden Fall mindestens einen Vergleich benötigen. Also gilt hier mit
194
Kapitel 7
Hashverfahren
Diese Zahlen wollen wir uns noch einmal bewußt machen. Für den schlechtesten Fall ist gegenüber den linearen Listen nichts gewonnen – im Gegenteil, es wird noch mehr Speicherplatz verwendet und der Suche geht noch eine Berechnung der Hashadresse voraus. Sorgt man aber durch eine gute Hashfunktion und eine realistische Abschätzung der Schlüsselanzahl dafür, daß
und alle Listen etwa gleich lang sind, so ist der mittlere Suchaufwand konstant – für die erfolglose Suche kleiner als und sonst kleiner als . Das ist natürlich nur möglich, weil wir das Auffinden einer leeren Liste nicht zählen. Eine Verbesserung der erfolglosen Suche kann erreicht werden, falls die Listen aufsteigend sortiert sind. Das ist ohne zusätzlichen Aufwand beim Einfügen möglich. Eine weitere theoretische Verbesserung der Suchzeiten ist durch Verwenden von Bäumen anstelle von Listen denkbar, in der Praxis jedoch oft zweifelhaft. Datensätze Ein anderer Vorteil dieser offenen Hashtabelle ist, daß mehr als aufgenommen werden können. In manchen Situationen ist allerdings eine dynamische Speicherbeschaffung nicht möglich oder erwünscht, so daß man dann versuchen muß, die Kollisionen innerhalb der Hashtabelle aufzulösen.
7.3
Kollisionsauflösung innerhalb der Tabelle
Tritt eine Kollision auf, soll also ein Wert an eine Stelle eingefügt werden, wo schon ein anderer Schlüssel steht, so soll nun innerhalb der Tabelle ein freier Platz gesucht werden. Den Vorgang der Suche nach einem freien Tabellenplatz bezeichnen wir dabei als Sondierung. Die einfachste Sondierung wird alle Plätze der Reihe nach durchsuchen. Das ist aber nicht sehr günstig, weil dieses Verfahren die Eigenschaft hat, daß lange lückenlose Folgen entstehen und demzufolge die Suche wieder fast alle Elemente betrachten muß. Es ist besser, die Hashtabelle mit größerem Versatz zu durchkämmen. Man muß dabei jedoch gewährleisten, daß alle Plätze der Tabelle getroffen werden. Allgemein formulieren wir eine Sondierungsfunktion und betrachten im ten Schritt die Adresse
7.3
Kollisionsauflösung innerhalb der Tabelle
195
alle Plätze der Hashtabelle durchlauDiese Adressen müssen für fen. Wir werden nur zwei Sondierungsfunktionen kennenlernen.
7.3.1
Lineares Sondieren
Die oben eingeführte einfache Sondierung der Nachbarplätze wird durch die Funktion beschrieben. Für einen Schlüssel ergibt sich die Sondierungsfolge von , , , , und dann von zurück bis .
7.3.2
Double Hashing
Besseres Verhalten zeigt die Sondierungsfunktion
denn hier wird normalerweise mit Versatz größer als gearbeitet, welcher außerdem vom Schlüsselwert abhängt, so daß Synonyme bzgl. der Hashfunktion in der Regel verschiedene Sondierungsfolgen durchlaufen. Da eine Primzahl ist, wird nicht von
geteilt, und die Sondierungsfolge durchläuft die gesamte Tabelle. Selbstverständlich sind alle Adressen modulo zu berechnen.
7.3.3
Algorithmen
Das Einfügen eines Schlüssels beginnt mit der Berechnung der Hashadresse. Ist der entsprechende Platz belegt und der Wert ungleich dem einzufügenden, so wird in der Sondierungsfolge weitergesucht, bis ein leerer Platz gefunden wird. Dort wird der Schlüssel eingetragen. Da beim Suchen die gleiche Folge durchlaufen wird, ist sichergestellt, daß der Wert auch gefunden wird. Soll ein Wert gelöscht werden, so endet die erfolgreiche Suche an einer Hashadresse. Deren Eintrag kann aber nicht einfach entfernt werden, da die Adresse auch innerhalb von Sondierungsfolgen für andere Werte liegen kann. Solche Werte müssen natürlich auch nach dem Entfernen eines Eintrags noch gefunden werden können. Deshalb unterscheiden wir in der Tabelle zwischen belegten Plätzen,
196
Kapitel 7
Hashverfahren
solchen die leer sind, aber schon einmal belegt waren, und solchen, die noch vom Anfang her frei sind. Wir formulieren die Algorithmen in CAML LIGHT. Die Tabelle ist wieder ein Feld, dem wir diesmal seine Länge als Attribut mitgeben. Die Komponenten dieses Feldes sind entweder frei, leer oder tragen einen Wert vom Schlüsseltyp: ✍
☞
$ "# + #
"
"
Am Anfang sind alle Plätze der Hashtabelle frei: ✍ ☞ &
" # $ * +!#
Die Algorithmen formulieren wir in Abhängigkeit von zwei Funktionen – der vorzuschaltenden Hashfunktion , die den Schlüsseltyp auf eine natürliche Zahl abbildet, und der Sondierungsfunktion . Die eigentliche Adreßbestimmung erfolgt modulo der Tabellengröße. Hier ist noch eine Korrektur der in CAML LIGHT implementierten modulo-Funktion für negative Argumente vorzunehmen, so daß das Ergebnis eine gültige Hashadresse im Bereich von darstellt: ✍ ☞
)# # +!#
A L G O R I T H M U S 7.2 W ÖRTERBUCHOPERATIONEN
)#)# * #
INNERHALB
H ASHTABELLE
E INGABE : primäre Hashfunktion, Sondierungsfunktion, Schlüssel und Hashtabelle. A USGABE : Hashtabelle mit Schlüssel, Hashtabelle ohne Schlüssel bzw. Auskunft, ob Hashtabelle den Schlüssel enthält.
7.3
Kollisionsauflösung innerhalb der Tabelle
197
M ETHODE : Einfügen, Löschen und Suchen werden durch die folgenden Funktionen realisiert: ✍
"
☞
%
"
%
$ *## ) * $ *# !#*% *# +!#)
✍
"
*
%
" %
%
*## ) * $ *# !#*% *# +!#)
☞
" % *
%
✍
☞
!
% %
#) *## ) *
# *
198
Kapitel 7
$
Hashverfahren
#+!#
– Alg. 7.2 – Alle drei Operationen arbeiten mit einer rekursiven Hilfsfunktion, die nur von der Anzahl der Sondierungsschritte abhängt. Es brauchen und dürfen höchstens
Sondierungsschritte durchgeführt werden. Bei voll belegter Tabelle terminieren die hier angegebenen Algorithmen nicht, falls der betreffende Wert nicht in der Hashtabelle steht. Die Korrektur ist aber einfach und in den zur Verfügung gestellten Beispielen verwirklicht (zur Verfügbarkeit siehe Anhang B). Für den üblicherweise als Beispiel gewählten Fall von ganzzahligen Schlüsselwerten spezialisieren sich die drei Operationen für lineares Sondieren zu: ✍
$%!%&
☞
✍
## $ * +!#
# ## $ !#% "!#
☞
✍
%! $ ## $ !#% "!#
☞
Für Double Hashing sehen sie folgendermaßen aus: ✍
☞
$%!%& ## $ * +#!
✍
☞
# ## $ !#% " #!
✍
☞
%! $ ## $ !#% " #!
Wir verzichten an dieser Stelle auf eine Aufwandsanalyse und geben nur die Ergebnisse für Double Hashing kurz an.
mit
7.3
und
mit
Kollisionsauflösung innerhalb der Tabelle
199
In jedem Fall geht der Aufwand für , ist aber für realistische gegen re Werte von durch eine kleine Konstante beschränkt. Für lineares Sondieren gelten ähnliche Formeln.
Kapitel 8 Systematisches Probieren 8.1
Backtracking-Algorithmen
Viele Probleme lassen sich nicht so einfach lösen wie etwa die Implementierung der arithmetischen Operationen. Auch Divide & Conquer-Verfahren, die beim Sortieren und Suchen mit Erfolg verwendet werden, sind kein Allheilmittel. Oft hilft nur systematisches Durchprobieren aller möglichen Lösungsvarianten. Systematisches Probieren ist natürlich für einen Rechner recht naheliegend – es erfordert keine kreative Idee. Man hüte sich allerdings vor kritiklosem Einsatz, denn die Effizienz ist in der Regel exponentiell, und die Algorithmen sind deshalb auch schon für recht bescheidene Problemgrößen praktisch nicht durchführbar. Durch einige Heuristiken und fallspezifische Überlegungen kann die Laufzeit im allgemeinen aber verbessert werden. Wir wollen hier zunächst an einem einfachen Beispiel den Grundalgorithmus vorstellen.
8.1.1
Wegesuche im Labyrinth
Nehmen wir uns die Suche eines Weges durch ein Labyrinth vor. Auch dieses Problem wurde schon von den alten Griechen gelöst. Um sinnlose Kreise auf seinem Ausweg aus dem Labyrinth des M INOTAURUS zu vermeiden, führte T HESEUS auf Rat von A RIADNE einen Faden mit, der ihm jedesmal anzeigte, daß er an einer gewissen Kreuzung schon gewesen war und daß der zuerst gewählte Weg offensichtlich nicht zum Ausgang führte. Betrachten wir das einfache rechteckige Labyrinth in Abbildung 8.1. Wir sollen beginnend mit Zelle einen Weg aus dem Labyrinth finden. Im ersten Schritt haben wir eine Entscheidung zu treffen, in welche Zelle wir gehen wollen. Wir sehen, daß wir von Zelle nach Zelle kommen können, also hilft uns ein Ausweg beginnend mit Zelle . Finden wir einen solchen, so sind wir fertig, sonst gehen wir von Zelle in Zelle und suchen dort einen Ausweg. Wieder liegt ein rekursives Vorgehen nahe.
202
Kapitel 8
Systematisches Probieren
1
2
3
4
5
6
7
8
9
10
11
12 13
Abbildung 8.1: Ein Labyrinth mit 12 Feldern
A L G O R I T H M U S 8.1 A RIADNE E INGABE : Labyrinth, Anfangsposition, Faden. A USGABE : Weg aus dem Labyrinth. M ETHODE : In jeder Zelle führen wir folgende Aktionen durch: Wähle die nächste Tür. Falls in dieser nächsten Zelle noch kein Faden liegt, gehe hinein und ziehe den Faden mit. Sind wir außerhalb des Labyrinths, so markiert der Faden unseren Weg und wir sind fertig. Sonst versuchen wir von dieser Zelle einen Ausweg zu finden. Gelingt uns dies nicht, so war die oben getroffene Entscheidung falsch, und wir gehen in die Ausgangszelle zurück. Dabei rollen wir natürlich unseren Faden wieder auf. Diese Aktionen wiederholen wir, bis keine weitere Tür mehr vorhanden ist. Dann führt der Weg aus dem Labyrinth nicht durch diese Zelle. – Alg. 8.1 – Wir legen noch die Wahl der nächsten Tür fest: Am Anfang sei das die Tür zu Zelle , ansonsten nehmen wir beim ersten Betreten einer Zelle die Tür rechts von der Eingangstür und wechseln dann gegen den Uhrzeigersinn zur nächsten Tür.
8.1
Backtracking-Algorithmen
203
Start
Ziel
Abbildung 8.2: Ein Weg durch das Labyrinth
Die Ausführung von A RIADNES Algorithmus liefert den Weg, der in Abbildung 8.2 zu sehen ist. Wir sehen, daß ein Ausweg gefunden wird, wenn auch nicht der kürzeste. Wir formulieren das algorithmische Grundmuster zum Finden einer Lösung durch systematisches Probieren noch einmal etwas formaler. Andere Namen, die für solche Vorgehensweisen gebraucht werden, sind etwa Programmieren durch Versuch und Irrtum, Generieren einer Lösung mit Zurücksetzen oder kurz und auf englisch Backtracking-Algorithmen. Wir wollen den Algorithmus ganz vorsichtig bottom-up entwickeln. In jedem Schritt testen wir eine Zelle dahingehend, ob sie schon das gewünschte Ziel darstellt, oder ob wir noch weiter suchen müssen. Die Zelle ist also ein Kandidat für die Fortsetzung unseres Weges. Wir suchen von ihr aus weiter, d. h. wir wenden denselben Algorithmus für den nächsten Kandidaten an. Die Funktion, die den ; sie liefert im ersten Ansatz einen Algorithmus implementiert, nennen wir Wahrheitswert, der angibt, ob das Ziel gefunden wurde oder nicht: ✍
"
"
"
Diese Version arbeitet ohne Zurücksetzen. Wenn sie einmal in eine Sackgasse läuft, hört sie auf und findet keinen Weg. Läuft sie dagegen in einem Kreis, so terminiert sie nie, da sie den Weg nicht aufzeichnet. Wir wollen sie trotz dieser Mängel zum Laufen bringen und überlegen uns deshalb eine Datenstruktur zur Repräsentation des Labyrinths. Alle Zellen sind nu-
204
Kapitel 8
Systematisches Probieren
meriert, für jede Zelle ist die Liste der Nachfolgezellen interessant. Wir merken uns also die Nummer der Zelle als ganze Zahl und die Nummern der direkten Nachfolger als Liste von Zahlen. Als Datenstruktur für das ganze Labyrinth bietet sich ein Feld an, da der direkte Zugriff wichtig ist. Somit wird unser Labyrinth wie folgt dargestellt:
✍
# #
☞
# $
&
#
"
& ✍ " ☞ & $
✍ $ ☞ +
, " und sind nun ebenfalls klar: ## " $ "!# # ##+!# $ * +!#
Die Funktionen " ✍ ☞
Ist wahr, so bedeutet das, daß wir am Ziel sind:
✍ ☞
# * +!#
ist nun ausführbar, und wir finden für den Startwert einen Die Funktion Ausweg, für nicht und kreisen für fast jede andere Zahl ewig im Labyrinth herum. Diese Fehler wollen wir nun ausbessern. Wir übergeben den bisher begangenen , die im Weg (durch den Faden markiert) als Parameter an die Funktion Erfolgsfall diesen Weg auch als Ergebnis liefert. Liegt der betrachtete Kandidat
8.1
Backtracking-Algorithmen
205
auf dem bereits beschrittenen Weg – dies führte zu einem Zyklus –, so werden andere noch existierende Kandidaten versucht. Wir müssen also jetzt eine Liste von noch zu überprüfenden Kandidaten übergeben. Diese Liste wird wie folgt aufgebaut. In jedem Schritt betreten wir die Zelle, die vorne in der Kandidatenliste liegt. Falls sie noch nicht auf dem Weg liegt, setzen wir sie an dessen Anfang und suchen einen Weg von einem ihrer Nachfolger, indem wir diese Liste als neues Argument übergeben. Falls die Kandidatenzelle schon auf dem Weg liegt, probieren wir andere Kandidaten: ✍
"
"
% # " $ " $ # " $%* +!
☞
Hierbei bezeichnet den Kopf der Liste und den Schwanz. Die Funktion prüft, ob das Ziel auf dem Weg liegt:
✍ ☞
+$ " $ * +!# # " $ " $%* +!
# " $%) "!#
Da der Weg in der Reihenfolge der besuchten Zellen aufgebaut wird, ist die Liste umzudrehen, oder der Weg erst beim Abbau der Rekursion zusammenzusetzen. Diese Variante formulieren wir als
206
Kapitel 8
Systematisches Probieren
A L G O R I T H M U S 8.2 W EGESUCHE E INGABE : Bei gegebenem Wegenetz eine Liste von Kandidaten für mögliche Startpunkte und eine Funktion, die feststellt, ob das vorgegebene Ziel erreicht ist. A USGABE : Ein Weg von einem der Startpunkte zum Ziel. M ETHODE : Versuch und Irrtum mit Aufzeichnen des Weges. " *
✍
"
☞
"
% # " $%)# $ # " $ * +!#
– Alg. 8.2 – Die Formulierung dieser Funktion haben wir bewußt allgemein gehalten. Wir können so einerseits den Lösungsvorgang anschaulich beschreiben und anderer seits dieses Programm auch auf andere Probleme anwenden. Die Funktion hängt von fünf Teilfunktionen ab. Sie setzt zudem voraus, daß die Lösung als Liste dargestellt werden kann und die Kandidaten als Liste eingebracht werden. Der erste Kandidat soll der Kopf der Kandidatenliste sein – das läßt sich durch Sortieren der Liste immer erreichen. Die Nachfolgerliste darf nur zulässige Kandidaten enthalten.
Damit liegen die Funktionen , und fest. Nur durch Ändern der anderen zwei Funktionen und erhalten wir ein Programm für andere, ähnliche Probleme. Eines wollen wir hier als etwas ausführlicheres Beispiel behandeln.
8.1
8.1.2
Backtracking-Algorithmen
Weg des Springers
207
Es soll ein Weg eines Springers über ein -Schachfeld bestimmt werden, bei dem jedes Feld einmal betreten wird. Wir wählen hier als Beispiel . Offensichtlich läßt sich dieses Problem durch versuchsweises Ziehen und Zurückziehen lösen. Ein Feld auf einem Schachbrett kann durch zwei ganze Zahlen zwischen und (Koordinaten) bestimmt werden. Ein Springerzug geschieht durch Addition oder Subtraktion von in einer Richtung und in der anderen. Der Springer darf das Schachbrett natürlich nicht verlassen. Deshalb sind nur Felder innerhalb des Schachbretts zulässige Nachfolger eines Feldes. Das läßt sich durch Angabe einer relativ schnell hinschreiben: Offset-Liste ✍ ☞
# ✍
☞ ✍
☞
( " # #
# " $ "
(
*
+!#
" &
# # #
#
"
$ "!#
Die Nachfolgerliste von wird bestimmt, indem die Funktion, die zu einer Liste einen zulässigen Nachfolger hinzufügt, beginnend mit der leeren Liste iteriert wird. Wir haben hier die Funktion über alle Elemente der Offset-Liste verwendet, die in Abschnitt 12.2 näher beschrieben wird. Wir hören auf, wenn alle ✍ ☞
Felder betreten wurden:
" $ * +!
208
Kapitel 8
Systematisches Probieren
Unser Programm ist jetzt fertig: ✍ ☞
!
8.1.3
#
"
$
Alle Lösungen und optimale Auswahl
auch mit den zwei Funktionen parametrisieren Wir könnten die Funktion und so die allgemeine Wegesuche als Funktion höherer Ordnung auffassen: ✍
☞
"
*
*
"
% " $ " $% +!#
*
$ " $ " $%
Das Finden einer Lösung kann natürlich dazu verwendet werden, alle Lösungen so, oder die optimale Lösung zu ermitteln. Wir modifizieren die Funktion daß beim Finden einer Lösung diese in eine Liste eingetragen und die Suche fortgesetzt wird.
8.1
Backtracking-Algorithmen
209
A L G O R I T H M U S 8.3 A LLE W EGE E INGABE : wie in Alg. 8.8.2. A USGABE : alle Wege von den Startpunkten bis zum Ziel. M ETHODE : Beim Auffinden eines Weges wird dieser vor die Wegeliste gehängt. Anschließend wird bei den noch nicht untersuchten Kandidaten weitergemacht. "
✍
"
☞
# # +!#
# #
"
" "
$ " $
$ $ " $
#
#
"
$
#
– Alg. 8.3 – Auf diese Art und Weise läßt sich auch ein optimaler Weg bestimmen – etwa ein kürzester. Man ermittelt einfach alle Wege und sucht dann das Optimum aus. A L G O R I T H M U S 8.4 S UCHE OPTIMALEN W EG E INGABE : Problem in geeigneter Darstellung und Bewertungsfunktion.
210
Kapitel 8
Systematisches Probieren
A USGABE : optimaler Weg. M ETHODE : Suche alle Wege, Bestimme das Optimum. – Alg. 8.4 – Dies ist natürlich die einfachste, aber keinesfalls die effizienteste Lösung des Problems der optimalen Auswahl, mit dem wir uns im nächsten Abschnitt ausführlicher beschäftigen.
8.2
Branch & Bound-Verfahren
8.2.1
Das Rucksackproblem
An einem anderen Beispiel, dem Rucksackproblem, wollen wir zeigen, daß brauchbare Verbesserungen durch frühzeitiges Erkennen einer optimalen Lösung eintreten. Wir werden aber auch sehen, daß die maximale Zeitkomplexität trotzdem exponentiell ist. Wer schon einmal eine Radtour oder eine längere Urlaubsreise mit vielen Personen geplant hat, kennt das Problem: »Was nehme ich mit? Ist ein Kassettenrecorder wertvoller als ein Paar Turnschuhe? Wieviel kann ich unterbringen bzw. überhaupt tragen?«
Es seien nun allgemein »Gegenstände« , , gegeben, von denen je der einen Wert und ein Gewicht hat. Gesucht ist eine Auswahl , deren Gesamtgewicht bei maximalem Wert durch eine vorgegebene Grenze beschränkt bleibt, also
maximal bei
Die Lösung läßt sich natürlich mit einem Backtracking-Algorithmus bestimmen. Der simpelste von diesen bestimmt, ohne auf das Gewicht zu achten, alle möglichen Auswahlen und prüft dann, ob sie zulässig sind, d. h. das Gewichtslimit
8.2
Branch & Bound-Verfahren
211
nicht überschreiten. Zuletzt ermittelt er diejenige Auswahl mit optimalem Wert. Alle möglichen Auswahlen zu bestimmen heißt aber, alle möglichen Teilmengen einer -elementigen Menge zu betrachten, also Stück. Dieser Auswahlprozeß läßt sich auch durch einen sogenannten Entscheidungsbaum veranschaulichen, der für Beispiel 8.1 in Abbildung 8.3 zu sehen ist.
Auf jeder Ebene des Baumes entscheidet man, ob ein Element hinzugefügt werden soll oder nicht. Jede der Mengen der vorigen Stufe wird also einmal unverändert übernommen und ein zweites Mal um das neue Element angereichert. Dadurch verdoppelt sich die Anzahl der Mengen – auf der -ten Ebene gibt es Kandidaten.
Um uns einer Lösung zu nähern, bestimmen wir zunächst die Potenzmenge einer gegebenen Menge mit einem ganz einfachen Programm. A L G O R I T H M U S 8.5 P OTENZMENGE E INGABE : eine Menge dargestellt als Liste. A USGABE : ihre Potenzmenge als Liste von Listen. M ETHODE : Zuerst bestimmen wir alle Teilmengen, die den Kopf von (der Ausgangsliste) enthalten, und dann diejenigen, in denen er nicht vorkommt. Ihre Vereinigung bildet die Potenzmenge. " ✍
" " " "
"
" "
+ $ '
"
"
$ " $ " % $ * +! ☞ # " $ " $
☞
✍ "
– Alg. 8.5 –
212
Kapitel 8
Systematisches Probieren
0,0
10,12
0,0
25,27
42,52
10,12
25,27
27,37
15,15
10,12
32,40
0,0
15,15
17,25
0,0
Abbildung 8.3: Die ersten drei Ebenen des Entscheidungsbaumes zu Bsp. 8.1
.
Fast genauso läßt sich eine optimale Auswahl mit maximalem Wert bestimmen. Statt jedesmal die Mengen zu vereinigen, bestimmen wir nur das Maximum ihrer Werte. Dabei ist der Wert einer Menge festzulegen. Falls wir wieder ganzzahlige Mengen betrachten, können wir die Summe der Elemente wählen: ✍
☞
"
+$
"
"
" $%) ) " $ * +!#
✍
☞
# " $ "!# ✍ ☞
Für das Rucksackproblem stellen wir die Gegenstände als Paare von Gewicht und Wert dar. Der Wert ist also die Summe der zweiten Komponenten: ✍
☞
#
"
$ "!#
B EISPIEL 8.1 Wir führen den Algorithmus für die Gegenstände , , und mit Gewichtslimit und dem Entscheidungsbaum aus Abbildung 8.3 durch. Zunächst die Gegenstände:
8.2
✍ ☞
Branch & Bound-Verfahren
%$ # # " $
213
Einen Überblick über alle Teilmengen erhalten wir durch: ✍ " ☞
# #
"
$% $
Diese Teilmengen charakterisieren die unterste Ebene des Entscheidungsbaums. Diese ist in Abbildung 8.3 nicht dargestellt. Die Auswahl wird durch
✍ ☞ #
getroffen. Wir stellen fest, daß mit dem bisherigen Verfahren das Gewichtslimit nicht berücksichtigt wird, denn zum erhaltenen Wert gehört ein Gewicht von ! Bis hierhin müssen wir offensichtlich als optimale Auswahl stets alle Gegenstände mitschleppen, da wir das Gewichtslimit außer acht gelassen haben. Dieses läßt sich aber genauso wie die Wertfunktion einbringen. Die Funktion " wird mit dem Gewichtslimit parametrisiert und ruft die Funktion auf, die wir nun " nennen wollen. Diese Funktion beschreibt allgemein den Prozeß der optimalen Auswahl. A L G O R I T H M U S 8.6 R UCKSACK 1 E INGABE : Rucksackproblem.
214
Kapitel 8
Systematisches Probieren
A USGABE : optimale Auswahl. M ETHODE : Betrachte alle zulässigen Auswahlen. ✍ " )
"
* *
" "
#
"
"
☞ !%& $ "& # ✍ " ☞
"
*
$ # * +!#
– Alg. 8.6 – Dieses Programm liefert uns also einen ersten Lösungswert des Rucksackproblems – hier speziell für Beispiel 8.1! Die Auswahlmenge selbst läßt sich durch leichte Modifikation bestimmen. Wir beschleunigen außerdem die Zulässigkeitsprüfung, indem wir die Summe nicht jedesmal komplett berechnen, sondern das Gewicht der momentanen Auswahl mitführen: ✍
"
"
"
) *
*
8.2
Branch & Bound-Verfahren
"
#
"
215
& $ & # # # " %$ )# # # $ * +! ✍ " ☞ # # " $ ☞ +!
Weil wir nur zulässige Mengen weiterverfolgen, beschränken wir die Auswahl bei der Entscheidung mit dem aktuellen Element. Nun wollen wir ein ähnliches Kriterium herleiten, das uns erlaubt, den Ausschluß eines Gegenstandes nur dann in Erwägung zu ziehen, falls mit der Restmenge eine Verbesserung des aktuellen Optimums möglich ist. Da wir die Werte aller Gegenstände kennen, können wir zu jeder Teilauswahl feststellen, welcher Wert mit den bisher getroffenen Entscheidungen noch erzielt werden kann. Ist dieser Wert kleiner als das bisherige Optimum, so brauchen wir gar nicht weiter zu rechnen, eine Verbesserung ist nicht mehr möglich. Im Gegensatz zur Zulässigkeit ist das eine dynamische Bedingung, der momentan erreichbare Wert und das bisherige Optimum werden also als Parameter übergeben. A L G O R I T H M U S 8.7 R UCKSACK 2 E INGABE : Rucksackproblem. A USGABE : optimale Auswahl. M ETHODE : Branch & Bound. ✍ "
*
216
Kapitel 8
Systematisches Probieren
"
"
"
"
☞
✍ ☞
%! & $ "& * +!#
#
" # " $
"
$ #
# #
"
$
– Alg. 8.7 – Dadurch werden eine Reihe von unnötigen Aufrufen gespart und je nach Beispiel können ganze Äste des Entscheidungsbaumes abgeschnitten werden. Eine solche Vorgehensweise, bei der also nun auch geprüft wird, ob ein Gegenstand überflüssig ist, ob es also sinnvoll ist, ohne ihn auszukommen, nennt man Branch & Bound-Verfahren. A L G O R I T H M U S 8.8 B RANCH & B OUND E INGABE : optimales Auswahlproblem in Form einer Liste und einer Wertfunktion. Zwei Funktionen – die eine prüft die Zulässigkeit eines Elements, die andere die Überflüssigkeit.
8.2
Branch & Bound-Verfahren
217
A USGABE : optimale Auswahl und deren Wert. M ETHODE : Wir geben die Methode im Pseudocode an:
"
*
– Alg. 8.8 – Hierbei steht stellvertretend für einen Wert, der garantiert nicht das Optimum ist. Der Aufruf dieser Funktion geschieht anfangs mit leerer Ergebnisliste . Diese fast prozedurale Darstellung des Algorithmus’ könnten wir durch eine Funktion höherer Ordnung ersetzen, aber wir haben seine funktionale Umsetzung weiter oben ausführlich behandelt und wollen es nun dabei belassen.
8.2.2
Aufwandsbetrachtungen
Zu Beginn dieses Abschnittes betrachten wir noch ein Beispiel, das sogenannte SOS-Problem. Bei einer gegebenen Zahlenmenge soll geprüft werden, ob eine Teilmenge existiert, deren Summe gleich einer vorgegebenen Zahl ist. (Der Name des Problems ist Abkürzung für »sum of subsets«.) Der folgende Algorithmus liefert eine Lösung. A L G O R I T H M U S 8.9 SOS M ETHODE : Entweder ist die erste Zahl in der gesuchten Teilmenge enthalten oder nicht. Au-
218
Kapitel 8
Systematisches Probieren
ßerdem ist klar, daß das Problem für die leere Menge nur dann erfüllt ist, wenn die Zahl gleich ist: " ✍ ☞ $
$ ## " $ #+!#
– Alg. 8.9 – An diesem Algorithmus können wir ganz einfach eine Rekursionsformel für den Aufwand herleiten. Dieser hängt offensichtlich von der Länge der Kandidatenliste ab. Falls ist, ist der Aufwand konstant, ansonsten gilt
Diese Rekursionsformel hat die Lösung
wie man leicht durch vollständige Induktion sieht. Also ist der Aufwand exponentiell. Nach der gleichen Formel berechnet sich der Aufwand für das Rucksackproblem – auch die Branch & Bound-Varianten brauchen zwei Aufrufe pro Stufe. Wir haben bei unserer Implementierung darauf geachtet, daß der Aufwand auf einer Rekursionsstufe konstant ist. Durch etwas sorgloseres Vorgehen hätten wir zum Beispiel zur Zulässigkeitsprüfung jedesmal die ganze Menge durchlaufen können. Aber das macht schon fast nichts mehr aus, denn die Rekursionsformel
hat die Lösung
und ist damit ebenfalls aus
.
Das Suchen im Labyrinth hängt wesentlich von der Anzahl der Nachfolger jeder Zelle ab. Ist diese , so lautet die Rekursionsformel für den Maximalaufwand
Denn falls für keinen der Nachfolger ein Weg gefunden wird, liegt die aktuelle Zelle nicht auf dem gesuchten Weg, und der Test wird für die anderen Kandidaten aufgerufen.
8.2
Branch & Bound-Verfahren
219
Betrachtet man als konstant, so ist
sein, und wir eine Lösung. Im ungünstigsten Fall kann allerdings haben wieder eine exponentielle Laufzeit. Diese ist allerdings hier nicht problembedingt. Wenn wir nämlich T HESEUS statt eines Fadens einen Pinsel mitgeben und ihn jede betretene Zelle des Labyrinths markieren lassen, ist klar, daß keine direkte Verbindung zweier Zellen mehrmals durchlaufen wird. Denn natürlich braucht eine markierte Zelle nicht nochmal betreten zu werden. Die Anzahl der Entscheidungen ist also durch die Anzahl der direkten Verbindungen beschränkt, und das sind bei Zellen höchstens . Eine dauerhafte Markierung können wir durch ein Feld von Marken erreichen. A L G O R I T H M U S 8.10 W EGESUCHE MIT M ARKIERUNG E INGABE : bei gegebenem Wegenetz eine Liste von Startpunkten und ein Ziel. A USGABE : Weg von einem Startpunkt zum Ziel, falls es einen gibt. M ETHODE : Markiere die durchlaufenen Zellen dauerhaft. ✍ ☞ & $ $ $ $ $ $ $ $ $ $ ✍ " "
"
$
$
$
"
"
"
220
Kapitel 8
Systematisches Probieren
1
10
2
4
5
3
6
7
8
9
Abbildung 8.4: Eine Landkarte
"
☞
"
"
% # " $%)# $ # " $ * +!#
– Alg. 8.10 – Der Unterschied der beiden Wegesuchalgorithmen wird bei der in Abbildung 8.4 dargestellten Landkarte deutlich. Während die neue Funktion auf dem Weg von Punkt 1 nach Punkt 10 die Punkte 2 und 3 nur einmal besucht und damit auch alle Sackgassen nur einmal durchläuft, versucht der erste Algorithmus dieses zweimal, da bei der Rückkehr nach Punkt 1 alle Markierungen wieder gelöscht sind.
8.3
Greedy-Algorithmen
Greedy-Algorithmen sind eine weitere Klasse von Algorithmen, die zur Lösung des Auswahlproblems oft eingesetzt werden, weil sie recht effizient arbeiten. Ein Greedy-Algorithmus (engl. greedy = gierig, geizig), bestimmt eine Auswahl, indem in jedem Schritt die Entscheidung getroffen wird, die die momentane Auswahl optimal macht. Einmal getroffene Entscheidungen werden nicht zurückgenommen. Es kann sein, daß eine aufgrund eines lokalen Kriteriums ausgewählte Teilmenge nicht zu einer optimalen Gesamtauswahl führt. Es gibt aber Probleme, bei denen die Struktur so regelmäßig ist, daß ein Greedy-Algorithmus bei geeigneter Auswahlfunktion die optimale Lösung bestimmt.
8.3
Greedy-Algorithmen
221
A L G O R I T H M U S 8.11 G REEDY E INGABE : optimales Auswahlproblem. A USGABE : eine zulässige Lösung, nicht notwendig optimal. M ETHODE : Verwende eine Auswahlfunktion, die den nächsten Kandidaten ermittelt und versuche Kandidaten aufzunehmen, solange die Lösung zulässig bleibt: " ✍
"
+ +
– Alg. 8.11 – Wir wollen diesen Algorithmus auf das Rucksackproblem anwenden und dabei mehrere Auswahlfunktionen versuchen. B EISPIEL 8.2 Nehmen wir das Beispiel von oben. Eine gute Strategie könnte es sein, die wertvollsten wird Gegenstände zuerst auszuwählen. Für nacheinander , und ausgewählt und so die optimale Auswahl gefunden. Für hingegen wählen wir – gierig wie wir sind – und und bleiben bei einem Wert von sitzen, während das Opti mum ist – mit , und .
Ebensowenig allgemeingültig ist die Strategie, jedesmal das leichteste Element zu und die Auswahl wählen. Hier erhalten wir für
, und mit dem Wert , also wieder nur einmal das Optimum.
Auch raffiniertere Auswahlfunktionen führen bei diesem Problem nicht zum Ziel. Wohl aber für ein verwandtes Problem, das Allgemeine oder BruchteilRucksackproblem. Im Gegensatz zum bisherigen Packen eines Rucksackes füllen wir nun Schüttgüter wie Tee, Zucker oder Kohlen in unseren Rucksack und können demzufolge auch Bruchteile der gesamten Gegenstände einpacken. Es ist klar,
222
Kapitel 8
Systematisches Probieren
daß wir so immer die Gewichtsgrenze erreichen werden. Ausgenommen ist der Trivialfall, daß das Gesamtgewicht kleiner als das Limit ist.
B EISPIEL 8.3 Für unser obiges Beispiel und können wir etwa auffüllen. und komplett einpacken und dann mit von Wir . Nehmen wir hingegen und ganz erhalten so einen Wert von und von , so beträgt der Wert – das ist maximal!
Ein Greedy-Algorithmus, der immer das Optimum für das allgemeine Rucksackproblem liefert, benutzt als Auswahlfunktion den sogenannten spezifischen Wert, d. h. er nimmt soviel wie möglich von dem Gegenstand mit dem größten Quotienten aus Wert und Gewicht. Um das einzusehen überlegen wir uns zwei Kriterien, die Probleme auszeichnen, die mit Greedy-Algorithmen lösbar sind: 1. Ein Problem weist optimale Unterstruktur auf, wenn jede Optimallösung des gesamten Problems Optimallösungen für Teilprobleme enthält. 2. Ein Problem besitzt die Greedy-Auswahleigenschaft, wenn die Optimallösung durch eine Folge von lokal optimalen Auswahlschritten bestimmt werden kann. Zum Beweis der ersten Eigenschaft für das allgemeine Rucksackproblem nehmen wir an, wir hätten eine optimale Auswahl, die den Gegenstand enthält. Nehmen wir nun heraus, so ist der Rest eine optimale Auswahl für das um reduzierte Problem mit herabgesetztem Gewichtslimit. Wäre das nämlich nicht so, so könnten wir die bessere Auswahl für das reduzierte Problem durch Hinzunahme von zu einer besseren Auswahl für das Originalproblem erweitern, im Widerspruch zur Optimalität dieser Auswahl. Das gilt übrigens auch für das normale Rucksackproblem. Das Rucksackproblem weist also optimale Unterstruktur auf. Für die Auswahleigenschaft nehmen wir eine optimale Lösung des BruchteilRucksackproblems her und zeigen, daß sie den vollen Anteil des spezifisch wertvollsten Gegenstandes enthält.
Nehmen wir an, das Gegenteil sei der Fall – sei also eine Optimallösung mit Faktor , wobei der Gegenstand ist, für den maximal ist
8.3
Greedy-Algorithmen
223
Aus dieser Lösung konstruieren wir eine neue Lösung , indem wir den Faktor auf setzen und dafür irgendeinen anderen Faktor erniedrigen, um das Gesamtgewicht zu erhalten. Das heißt, es gilt
Daraus folgt nun aber durch Multiplikation mit
wegen der Maximalität von . Für die Differenz der Werte ergibt sich daraus
enthält also mehr vom wertvollsten Gut, sein Wert ist deshalb größer als der von im Widerspruch zu der Optimalität. Deshalb ist die Annahme falsch.
8.3.1
Aufwandsbetrachtungen
Der Aufwand für Greedy-Algorithmen genügt der Rekursionsformel
choice
zulaessig
Die Komplexität der Hilfsfunktionen ist – wie in unserem Fall – oft linear und der Gesamtaufwand . In besonders einfachen Fällen ist die Zulässigkeitsprüfung in konstanter Zeit möglich, sortiert man dann die Kandidatenliste, so ist immer das erste Element auszuwählen. Der Greedy-Algorithmus braucht darf man allerdings nicht veralso nur lineare Zeit. Die Sortierzeit von gessen. Ein Beispiel dafür soll diesen Abschnitt abschließen.
B EISPIEL 8.4 Jemand möchte abends zwischen 8 und 10 Uhr möglichst viele komplette Fernsehsendungen sehen. Die Auswahl läßt sich mit einem GreedyAlgorithmus wie folgt bestimmen: Sortiere alle in Frage kommenden Sendungen aufsteigend nach ihren Schlußterminen. Wähle die erste Sendung aus und entferne alle, die sich mit ihr überlappen. Löse nun das Problem für die übrigbleibenden Sendungen.
224
Kapitel 8
Systematisches Probieren
Dieses Problem hat optimale Unterstruktur, denn jede Lösung enthält Teillösungen für Probleme mit späteren Anfangszeiten. Die Greedy-Auswahleigenschaft ist ebenfalls erfüllt. Wenn wir nämlich annehmen eine Optimallösung zu haben, die die erste Sendung nicht enthält, läßt sich daraus durch Auswechseln der Sendung mit dem frühesten Schlußtermin durch eine Optimallösung konstruieren, die enthält. Dieses Auswechseln ist möglich, da der Schlußtermin von vor dem von liegt. Also kann eine Optimallösung mit gefunden werden.
Teil II Einführung in Caml Light
Kapitel 9 Ausdrücke und Funktionen Im zweiten Teil des Buches wollen wir uns stärker auf die funktionale Programmierung und speziell auf die Sprache CAML LIGHT konzentrieren. Obwohl zahlreiche Querverweise zum ersten Teil auftreten, ist dieser Teil für sich lesbar. Er bietet eine tutorielle Einführung in die funktionale Sprache CAML LIGHT, die jetzt nicht nur als Mittel zur Spezifikation und Formulierung von Algorithmen benutzt wird, sondern als eigenständiges Programmierwerkzeug dargestellt wird. Wir beschränken uns dabei auf den Kern der Sprache und einige wichtige Module der Standardbibliothek. Das gesamte CAML LIGHT-System mit seinen Anschlüssen für Graphik, Gestaltung von Benutzeroberflächen, Übersetzerbauwerkzeugen und vieles mehr zu beschreiben, würde den Rahmen dieser Einführung sprengen. Programmieren lernt man nur durch Praxis. Deshalb empfehlen wir jedem Leser, sich ein CAML LIGHT-System zu installieren (siehe hierzu den Anhang A.4) und die angegebenen Beispiele auszuprobieren, zu erweitern und sich eigene Aufgaben zu stellen. Ein Vorteil dieses Systems für den Lernenden ist die unmittelbare Interpretation der eingegebenen Werte, so daß ein Programm im Dialog zwischen Rechner und Benutzer ausgeführt werden kann. Diese Interaktion nutzen wir auch weiterhin im Buch aus. Die Beispielprogramme sind durch eine vorangestellte schreibende Hand ✍
gekennzeichnet, während die Hand mit dem ausgestreckten Zeigefinger ☞
$
auf die vom Rechner produzierte Antwort zeigt.
228
Kapitel 9
9.1
Ausdrücke und Funktionen
Konstanten und Ausdrücke
9.1.1
Konstanten für einfache Datentypen
Ein funktionales Programm besteht aus einer Folge von Werten – im ersten Kapitel formulierten wir Wertbestimmungen, um ein allgemeines, interpretierbares Wort zu verwenden. Wir haben dann gesehen, daß eine Wertbestimmung ein Ausdruck sein kann, der sich aus Operatoranwendungen und Funktionsaufrufen zusammensetzt. Die einfachsten Ausdrücke sind Konstanten: ✍ ☞
✍ ☞
✍ ☞ ✍ ☞ ✍ ☞ ✍ ☞
!
*
& * $
$% # !
Die einzelnen Wertbestimmungen, also Ausdrücke, sind durch zwei aufeinanderfolgende Strichpunkte zu beenden. Im interaktiven Modus, den wir hier nur betrachten wollen, wird jeder Ausdruck sofort ausgewertet und das Ergebnis ausgegeben. Bei den Konstanten handelt es sich natürlich nur um eine Wiederholung des angegebenen Wertes. Zusätzlich wird für jeden Ausdruck sein Typ ermittelt. Wir haben in unserem Beispiel Konstante für alle einfachen Standarddatentypen von CAML LIGHT aufgezählt. Die Datentypen sind
, eine Teilmenge der ganzen Zahlen, , die Gleitkommazahlen als Approximation für reelle Zahlen,
"
, die Wahrheitswerte
und ,
, der Zeichenvorrat und
, Zeichenketten.
9.1
Konstanten und Ausdrücke
229
Man beachte, daß konstante Zeichen in Rückwärtsapostrophe (accent grave) eingeschlossen werden und Zeichenketten in hochgestellte Anführungszeichen. Ganze Zahlen bestehen aus optional vorzeichenbehafteten Ziffernfolgen, während Gleitkommazahlen einen Dezimalpunkt oder eine Exponentenangabe aufweisen. Letztere repräsentieren einen Teilbereich der rellen Zahlen mit beschränkter Genauigkeit. Alle diese Typen sind unterschiedlich, d. h. ihre Wertemengen sind disjunkt. Außerdem dürfen in Ausdrücken mit vordefinierten Operatoren nicht verschiedene Typen auftreten. Insbesondere ist und . Es gibt aber zum Glück Transferfunktionen zwischen den zwei numerischen Typen und zwischen und
:
✍ ☞ ✍ ☞ ✍ ☞ ✍ ☞
#
$ # $ % (& * $
In einem größeren Programm ist es ratsam und wichtig, Konstanten mit Namen versehen zu können. Das spart nicht nur Schreibarbeit und ist allein dadurch weniger fehleranfällig, sondern die Programme werden auch lesbarer und änderungsfreundlicher. Nehmen wir an, wir wollen ein »Mensch ärgere dich nicht«-Spiel simulieren, an dem bis zu 4 Spieler teilnehmen können, von denen jeder 4 Spielfiguren erhält, und bei dem anfangs jeder bis zu 4mal würfeln darf, um eine 6 zu erzielen. Die 4 tritt hier in drei verschiedenen Bedeutungen auf, und wir haben darauf zu achten, daß die Änderung der Spielerzahl die Figurenzahl nicht beeinflußt.
Eine Konstantendefinition besteht aus dem Wortsymbol gefolgt von dem Konstantennamen und – hinter einem Gleichheitszeichen – der Angabe des Wertes. Mehrere solcher Definitionen können, durch getrennt, hintereinander vorgenommen werden. Wir bezeichnen die Konstantendefinition vielleicht zutreffender als Wertbindung, denn ein Wert wird bestimmt und an den Namen gebunden. Mit diesem Namen
230
Kapitel 9
Ausdrücke und Funktionen
wird der Wert im weiteren Programm bezeichnet:
✍
✍ ☞
☞
✍ ☞ $
#
' %
"
%! $ #
&& !
Gültige Namen werden aus Buchstaben und Ziffern gebildet, außerdem können Unterstriche und Apostrophe auftreten. Das erste Zeichen muß aber ein Buchstabe sein. Allgemein geschieht eine Wertbindung nicht nur für einfache Typen an einen Namen, sondern auch für strukturierte Typen an ein charakteristisches Muster. Dazu später mehr in Kapitel 10.
9.1.2
Ausdrücke für einfache Datentypen
Der Wert des Ausdrucks wird nur in den einfachsten Fällen durch eine Konstante beschrieben werden können. Meistens bedarf es noch der Benutzung von Operatoren und Funktionsaufrufen. Solch ein Ausdruck wird dann ausgewertet und das Ergebnis an den Namen gebunden. Wie oben beschrieben, wird dieser Wert zusammen mit seinem Datentyp ermittelt und ausgegeben:
✍ ☞
✍ ☞
✍ ☞
%
✍ ☞
%
Hier traten nun zum erstenmal wirkliche Ausdrücke auf, und wir sehen, daß die Unterscheidung zwischen und soweit geht, daß die Operatorzeichen unterschiedlich sind.
9.1
Konstanten und Ausdrücke
231
Arithmetische Ausdrücke können – bis auf die ungewohnten Operatorzeichen für Gleitkommazahlen – wie üblich gebildet werden. Ein Operatorzeichen für besteht aus dem normalen Zeichen, gefolgt von einem Punkt. Die bekannten Operatorvorrangstufen gelten, so daß Punktrechnung vor Strichrechnung kommt. Im übrigen kann durch Klammerung jede Auswertungsreihenfolge erreicht werden. Vergleiche liefern einen Wahrheitswert, sind also Ausdrücke vom Typ " . Die logischen Operatoren heißen bzw. für »und« bzw. »oder« und " für die Negation:
✍ ☞
! *
✍ ☞ !
✍ ☞ &#
* !
Eine genauere Beschreibung der Operatoren erscheint uns hier noch nicht angebracht. Sie folgt in Abschnitt 15.2.
9.1.3
Sichtbarkeit von Namen
Das letzte Programmstück ist als Fortsetzung des weiter oben stehenden aufzufassen, so daß die Namen und bekannt sind. Allgemein erstreckt sich der Sichtbarkeitsbereich eines Namens von seiner ersten Bindung bis zum Programmende oder Neudefinition. Auch so etwas ist möglich, aber anfangs vielleicht etwas verwirrend und deshalb nicht sehr hilfreich: ✍ ☞
✍ ☞
#
#
Weil vom Entwurf her die Bedeutung eines Namens häufig lokal aufzufassen ist und wir das auch im Programm ausdrücken wollen, ist es wünschenswert, den Sichtbarkeitsbereich einer Namensbindung einzuschränken. Eine solche lokale Bindung setzt für den angegebenen Ausdruck eine eventuell vorhandene globale
232
Kapitel 9
Ausdrücke und Funktionen
Bindung des gleichen Namens außer Kraft. Nun kann nicht nur der gleiche Name in verschiedenen Teilausdrücken mit verschiedenem Wert verbunden werden, sondern auch in seiner alten Bedeutung außerhalb der lokalen Ausdrücke weiterverwendet werden:
✍ ☞
✍ ☞
✍ ☞ ✍ ☞
# #
& ' #
$% #! +
Lokale Sichtbarkeitsbereiche können geschachtelt werden, und mehrere Bindungen werden wieder mit zu einer syntaktischen Einheit zusammemgefaßt, so daß die beiden vorletzten Eingaben in diesem Fall die gleiche Bedeutung haben. Das dies nicht immer der Fall ist, zeigen die nächsten Beispiele: ✍ ☞ ✍ ☞ ✍ ☞ ✍ ☞ ✍ ☞
#
# ' # & ' $%
!# +#
Wir wollen zum Abschluß dieses Abschnitts noch ein Wort über die verwendeten Begriffe verlieren. Eine Konstantendefinition wertet einen Ausdruck aus und
9.2
Vereinbarung und Aufruf einfacher Funktionen
233
verbindet den Wert mit einem Namen. Dies kann ein neuer Name sein oder ein bereits verwendeter, der dann seine alte Bindung verliert. Im Gegensatz dazu steht die Variablenvereinbarung, die aus imperativen Sprachen bekannt ist. Sie führt einen Namen ein, dem im Laufe des Programms verschiedene Werte zugewiesen werden können. Trotz dieses offensichtlichen Unterschiedes werden die Konstantennamen oft als Variable bezeichnet, so auch in der Originalliteratur zu CAML LIGHT.
9.2
Vereinbarung und Aufruf einfacher Funktionen
9.2.1
Funktionsvereinbarung
Soll ein Ausdruck mehrmals für unterschiedliche Eingabewerte (Anfangswerte, Argumente, Parameter) ausgewertet werden, so bietet sich statt des wiederholten Hinschreibens des Ausdrucks eine Funktionsvereinbarung und mehrmaliger Aufruf der Funktion an. Statt:
✍ ☞ ✍ ☞ ✍ ☞
vereinbart man besser eine Funktion zur Umfangberechnung:
✍ ☞ ! +
* +!#
In der ersten Zeile wird hier aus dem Ausdruck mit der bekannten Konstante durch Abstraktion, d. h. durch Parametrisieren des Ausdruckes, ein Funktionswert
✍ ☞
#+!#
234
Kapitel 9
Ausdrücke und Funktionen
gewonnen, der eine Funktion mit einem Parameter (oder Argument) beschreibt und der an den Namen gebunden wird. Man kann, wie man oben sieht, auf die Benennung einer Funktion ebenso wie auf die Benennung einer Konstanten verzichten. Weil diese Form der Funktionsvereinbarung, die nicht die einzig mögliche in CAML LIGHT ist, sehr übersichtlich ist und zudem die umfassendsten Argumentangaben erlaubt, verwenden wir sie vornehmlich. Nach dem Wortsymbol kommt sie der mathematischen Notation sehr nahe: Es wird ein Name für ein Argument eingeführt, der lokal für den Funktionsausdruck sichtbar ist und hier den Wert des Eingabearguments bezeichnet. Der diesem Eingabeargument zugeordnete Funktionsausdruck steht, durch getrennt, direkt dahinter. Der Definitions- und der Wertebereich der Funktion werden üblicherweise aus dem Funktionsausdruck automatisch abgeleitet, man kann sie jedoch auch hinter dem Namen angeben. Definitions- und Wertebereich bestimmen den Typ der Funktion, der ebenso wie der Typ anderer Werte vom Interpreter ausgegeben wird. In einem Funktionsausdruck können globale Größen auftauchen (im Beispiel ), die dann mit dem zur Definitionszeit aktuellen Wert der globalen Größe in allen Aufrufen verwendet werden. Der Aufruf einer Funktion mit einem Argument geschieht durch Hintereinanderschreiben von Funktionsnamen und aktuellem Argument. Letzteres kann geklammert werden, üblicher ist aber die Klammerung des gesamten Aufrufes, etwa in geschachtelten Funktionsaufrufen. Das Argument wird ausgewertet, der Name des formalen Argumentes an diesen Wert gebunden und dann der Funktionsausdruck ausgewertet:
✍ ☞
✍ ☞
✍ ☞
Bisher haben wir nur Funktionen mit einem Argument behandelt. Das reicht aber auch aus! Denn wir können eine Funktion mit zwei Argumenten als eine Abbildung auffassen, die einem Paar einen Wert zuordnet. Wir fassen also die zwei Argumente zu einer Einheit zusammen. Ähnlich lassen sich drei Werte zu einem
9.2
Vereinbarung und Aufruf einfacher Funktionen
235
Tripel und allgemein Werte zu einem -Tupel vereinigen. Die Fläche einer Ellipse kann dann als Beispiel so implementiert werden:
✍ ☞ "
% ) * +!#
Ein Paar – allgemein ein -Tupel – wird durch in runde Klammern eingeschlossene, mit Komma getrennte Werte bezeichnet (siehe auch Abschnitt 10.2). Sowohl bei der Vereinbarung als auch beim Aufruf dürfen die Klammern also nicht fehlen, sie gehören zum Argument. (Diese Begründung der Klammern ist nicht ganz korrekt, aber vorerst die einfachste Merkregel.) Der Definitionsbereich ist nun das kartesische Produkt der einzelnen Bereiche, der entsprechende Funktionstyp kann wieder angegeben werden. Das formale Argument bei der Vereinbarung besteht aus zwei verschiedenen Namen, die im Funktionsausdruck in der Regel einzeln auftreten. So wirkt diese Vereinbarung wie die einer Funktion mit zwei Argumenten. Beim Aufruf werden dann alle Argumente ausgewertet, an die entsprechenden formalen Namen gebunden – die Reihenfolge ist dabei nicht festgelegt – und anschließend wird der Funktionsausdruck ausgewertet:
✍ ☞
Genau wie das Argument kann auch das Funktionsergebnis ein Paar oder ein Tupel sein. Allgemein sind beliebige Datentypen zugelassen. Wenn wir die Reihe unserer geometrischen Beispiele fortsetzen und das Volumen und die Oberfläche eines Zylinders bestimmen wollen, kommen wir zur Funktion: ✍
☞ ✍ ☞
9.2.2
'
* +!#
%
Alternative Formen der Funktionsvereinbarung
Es gibt, wie oben schon angedeutet, noch zwei weitere Arten Funktionen zu ver einbaren. Einmal können wir statt einfach nur schreiben. Neben der
236
Kapitel 9
Ausdrücke und Funktionen
Ersparnis von fünf Buchstaben hat das Konsequenzen auf die Angabe der Argumente. Oder wir lassen das Wortsymbol ganz weg und schreiben eine Funktion als parametrisierten Ausdruck. Nehmen wir uns hier die Ellipse noch einmal vor:
✍ " ☞
✍
☞
"
☞
"
✍
% +!#
% +!#
% +!#
Die Unterschiede zwischen den drei Alternativen werden erst später in Abschnitt 15.2 genau erklärt.
9.2.3
Der bedingte Ausdruck
Der Wert einer Funktion hängt von den Werten der aktuellen Argumente ab und zwar oft so, daß abhängig vom Argumentwert ein anderer Ausdruck das Funktionsergebnis bestimmt. Die Ausführung eines Teilalgorithmus’ ist ebenfalls oft an Bedingungen geknüpft. Das läßt sich mit Verwendung eines bedingten Ausdrucks programmieren. Wir betrachten als Beispiel die Berechnung des Absolutbetrages:
✍ ☞ $ ## * +!#
*
Die Bedeutung ist offensichtlich. Die Bedingung zwischen und wird aus gewertet. Ist sie erfüllt, bestimmt der nach stehende Ausdruck den Wert, sonst der auf folgende. Diese beiden Ausdrücke müssen von gleichem Typ sein. Natürlich können solche bedingten Ausdrücke geschachtelt werden, so daß sowohl der - als auch der -Ausdruck wieder bedingt sein können.
9.2.4
Rekursive Funktionen
Rekursive Funktionen sind eines der mächtigsten Konstrukte der funktionalen Programmierung. Sie enthalten im Funktionsausdruck einen Aufruf ihrer selbst
9.2
und werden mit ✍ ☞
Vereinbarung und Aufruf einfacher Funktionen
237
" vereinbart:
"
#* # * +!#
Hier ist besonders auf das Terminieren der rekursiven Aufrufkette zu achten. Eine rekursive Funktion wird also stets einen bedingten Ausdruck als Ergebnis berechnen oder eine andere Fallunterscheidung treffen. Außerdem sind noch Einschränkungen für die aktuellen Argumente denkbar. So terminiert unsere Fakultätsfunktion nur für natürliche Zahlen. Das ist natürlich kein guter Programmierstil, denn negative Zahlen sollten gesondert abgefangen werden. Es gibt in CAML LIGHT aber Mittel, problemspezifische Fehler zu erkennen und Ausnahmesituationen zu behandeln (siehe Abschnitt 14.4).
9.2.5
Ausfiltern von Argumenten
Funktionen mit endlichem Definitionsbereich, und nicht nur solche, können durch Fallunterscheidung definiert werden. So wird bei der Notengebung dem numerischen Wert ein String zugeordnet oder auch umgekehrt: ✍
☞
"
#
)
"!& # $ !% !% %
%! $ & & !# % $ ! $ %& $% & %& % ' $ ' ##+!#
Ein Aufruf dieser Funktion ist nur mit einer der fünf hier als Muster angegebenen Zeichenketten erlaubt, darauf deutet auch die Warnung hin. Will man solche
238
Kapitel 9
Ausdrücke und Funktionen
unvollständigen Auswahlen vermeiden, so ist als letztes Muster aufzuführen. Dieses dient als »Allesfänger«: ✍
"
☞
$ '# "!#
Der Argumentwert wird der Reihe nach mit den angegebenen Mustern, die hier Konstanten sind, verglichen. Bei Gleichheit bildet der dem Zuordnungspfeil folgende Ausdruck das Funktionsergebnis. Die Muster müssen alle vom gleichen Typ sein, aber sie brauchen nicht konstant zu sein, sondern können auch wieder formale Argumentnamen einführen. Die Fakultätsfunktion von oben läßt sich auch so schreiben: ✍ ☞
"
' ## * +#!
Trifft der aktuelle Wert auf einen Namen als Muster, so wird dieser an ihn gebunden. Hätten wir die beiden Zeilen vertauscht, so wäre auch gegen das ausgefiltert worden, und die Funktion terminierte auch für positive Zahlen nicht. Die Muster können auch zusammengesetzte Strukturen wie Paare oder Listen beschreiben, wie die folgende Potenzfunktion demonstriert: ✍
☞
"
"
'
""
"" "
# # ##+!#
Der zweite Fall kann durch Binden von an mit dem dritten und dem ersten erledigt werden und ist somit eigentlich überflüssig.
9.2
Vereinbarung und Aufruf einfacher Funktionen
239
Mehrere Muster können in einer Alternative zusammengefaßt werden, wenn sie keine Variablenbindung beschreiben: ✍
☞
)
$ $ ' * +!#
Für Muster lassen sich auch Aliasnamen angeben. Das ist besonders dann sinnvoll, wenn ein Muster für eine zusammengesetzte Struktur sowohl einen Namen für die Gesamtstruktur als auch für die einzelnen Komponenten einführen soll: ✍
'
☞ #
"!#
Das Ausfiltern von Mustern dient hauptsächlich zum Auffinden der richtigen Alternative in einer durch Fallunterscheidung definierten Funktion. Es ist aber nicht auf diese Anwendung beschränkt. Allgemein kann die Möglichkeit einen Wert gegen verschiedene Muster auszufiltern auch als normaler Ausdruck angesehen werden. Die obige Potenzierungsfunktion können wir auch so schreiben: ✍
☞
"
%
"
"
# ## * +!#
Die erste Vereinbarung ist aber deutlich übersichtlicher und wird deshalb bevorzugt verwendet.
Da zwischen und beliebige Muster stehen können, werden wir die zweite Klausel gelegentlich verwenden, falls ein Name für eine Teilstruktur eingeführt werden soll. Sie bietet auch eine (ungeschickte) Möglichkeit, das Ausfiltern von Argumenten auf die dritte Art der Funktionsvereinbarung zu übertragen: ✍
" "
240
Kapitel 9
Ausdrücke und Funktionen
" # # ##+!#
☞
9.2.6
Funktionen mit mehreren Argumenten
Mehrere Argumente einer Funktion können wie oben beschrieben zu einem Tupel zusammengefaßt werden. Wir können die Argumente aber auch eins nach dem anderen auswerten und darauf sukzessive die Funktion anwenden. Das erreichen wir durch einen Aufruf, bei dem die aktuellen Argumente ungeklammert, nur durch Leerzeichen getrennt hinter den Funktionsnamen geschrieben werden. Streng genommen handelt es sich hier um die sogenannte Curry-Version einer Funktion, die wir erst in Abschnitt 12.1 genauer besprechen. Bei der Vereinbarung einer solchen Funktion schreibt man die formalen Argumente bis auf das letzte zwischen den Funktionsnamen und das Gleichheitszeichen. Die rekursive Funktion aus Abschnitt 1.4 etwa berechnet Quotient und Rest bei der ganzzahligen Division zweier Zahlen größer als . Die Funktion liefert davon den Quotienten zurück: ✍
"
+
#### # #+!#
☞ %
✍ '
☞ %
# #* #* # * +!#
Wir sehen, daß bei Verwendung von das Ausfiltern von Mustern auf mehr als ein Argument anwendbar ist. Normalerweise sollte dabei allerdings die Hilfs funktion in geschachtelt werden.
9.2.7
Schachtelung von Funktionen
Bei unserer ganzzahligen Division im letzten Abschnitt brauchte die Funktion eine Hilfsfunktion mit drei Parametern, die das gewünschte Ergebnis für einen Aufruf mit drittem Parameter gleich lieferte. Ähnliche Fälle treten
9.2
Vereinbarung und Aufruf einfacher Funktionen
241
häufig auf, und man möchte in der Regel dem Benutzer die Hilfsfunktionen nicht zugänglich machen, da die zusätzlichen Parameter und Bedingungen diese zu kompliziert und fehleranfällig werden lassen. Deshalb wird man die Funktionen ineinander schachteln. Hierzu gibt es syntaktisch zwei Möglichkeiten, von denen wir die erste schon in Abschnitt 9.1 kennengelernt haben. Die erste besteht in der Vereinbarung vor Verwendung: ✍
"
☞
"
'
#'(#)#)#* +!
Der Parameter kann innerhalb der Funktion als globale Konstante betrachtet werden. Die zweite Möglichkeit ist die Verwendung vor Vereinbarung: ✍
☞
+
'
#'(#)#)#* +!
Hier muß der eigentliche Funktionsausdruck geklammert werden, da sich die -Klausel sonst nur auf das bezöge und so eine Stufe zu tief bekannt machen würde.
Der Verzicht auf das Wortsymbol und die Verwendung der Standardfunktion , die die erste Komponente eines Paares bestimmt, machen die letztere Funktionsvereinbarung noch kürzer: ✍
242
Kapitel 9
☞ %
Ausdrücke und Funktionen
+
# #* #* # * +!#
Die -Klausel kann nicht nur einen Funktionswert, sondern auch eine (lokale) Konstante bekannt machen:
✍ ☞
9.2.8
Operatorvereinbarung
Vor allem in arithmetischen Ausdrücken ist der Gebrauch von (infix-) Operatoren dem Aufruf von (präfix-) Funktionen vorzuziehen, weil alle mathematischen Grundverknüpfungen üblicherweise so notiert werden. CAML LIGHT bietet deshalb die Möglichkeit, existierende Operatorzeichen neu zu definieren oder auch neue infix-Operatoren einzuführen. Eine Operatorvereinbarung ist eine Funktionsvereinbarung mit dem Funktionsna men , wobei ein existierendes oder neues Operatorzeichen darstellt. Beim Aufruf kann dieses Zeichen zwischen die beiden Argumente geschrieben werden: ✍
"
'
+
"!# ✍ # ☞ ☞
✍ ☞
Die infix-Schreibweise ist natürlich auf zwei Argumente beschränkt. Die Verein barung allerdings nicht – kann als normaler Name verwendet werden.
9.3
9.3
Testen und Fehlerabbruch
243
Testen und Fehlerabbruch
Wir wollen uns nicht vormachen, daß alle Programme auf Anhieb fehlerfrei laufen. Auch wenn wir unseren Algorithmus sorgfältig überlegt haben, kann es sein, daß seine Umsetzung doch etwas anders reagiert als erwartet. So wird es hin und wieder vorkommen, daß unser Programm nicht terminiert, eine Funktion mit unzulässigem Argument aufruft oder einfach völlig falsche Ergebnisse liefert. Wir wollen einige Fehlermöglichkeiten an einer allgemeineren Potenzierungsfunktion diskutieren. Die Formel zur Exponentiation einer reellen Zahl mit einer ganzen
falls falls falls
haben wir in das folgende CAML LIGHT-Programm übertragen: ✍
☞
" "
"
#
& !# % $ $ ! $% !% % * +!#
Wir ignorieren die Warnung und rufen unsere Funktion auf. ✍ "
Nach einiger Zeit erhalten wir die Fehlermeldung:
Was können wir tun?
244
Kapitel 9
9.3.1
Ausdrücke und Funktionen
Aufzeichnen von Funktionsaufrufen
Durch schrittweises Verfolgen des Programmablaufes kann am Interpreter die Wirkung nachvollzogen werden. In CAML LIGHT kann durch die Funktion , der als Argument ein Funktionsname als String überreicht wird, die Aufruffolge dieser Funktion protokolliert werden. Für jeden Aufruf werden Funktionsname und Argumente ausgegeben, anschließend das Funktionsergebnis. Bei einer " wird diese rekursiven Funktion entsteht so eine Schachtelung. Durch Nachverfolgung wieder aufgehoben. Wir wenden das auf unser Beispiel an: ✍ ☞
"
!#
"
# !%
%
( & &
Jetzt rufen wir die Funktion wie oben auf: ✍ "
Es erscheinen die (nicht enden wollenden) Rückmeldungen:
" " " " " " " " " "
Wir erkennen, daß das zweite Argument ständig zwischen und pendelt, weil für der -Zweig der ersten Alternative und nicht die zweite Alternative aufgerufen wird. Darauf hat uns ja auch schon die Warnung des Interpreters hingewiesen!
9.3.2
Der leere Typ unit
" liefern eigentlich kein Ergebnis, ihr Aufruf ist Die Funktionen " und eine Anweisung, deren (Neben-)Wirkung das Ein- bzw. Ausschalten der Aufzeichnung der Funktion ist. Das paßt natürlich überhaupt nicht in das Konzept des
9.3
Testen und Fehlerabbruch
245
funktionalen Programmierens, in dem ja ein Programm eine Folge von Wertbestimmungen ist.
Wir behelfen uns mit einem Trick. Wir führen einen zusätzlichen Typ ein,
der nur einen Wert enthält. Dieser Wert, geschrieben ist Ergebnis der TraceFunktionen und auch der anderen imperativen Elemente in CAML LIGHT.
9.3.3
Fehlerabbruch
Durch Tauschen der beiden Fälle läßt sich der Fehler in unserer Funktion beheben:
" "
"
✍
☞
%
* +!#
Sie ist aber noch nicht vollständig korrekt. Der Aufruf
✍ " ☞
(
"
verursacht eine Division durch und liefert ein Ergebnis, mit dem wir recht wenig anfangen können. Das lag natürlich an der unvollständigen Spezifikation des Verfahrens. Wir wollen jetzt eine Fehlermeldung ausgeben, falls " für und
aufgerufen wird. Im Falle einer unzulässigen Operation, etwa dem Aufruf einer partiellen Funktion mit falschem Argument, z. B. der Zugriff auf den Kopf einer leeren Liste, erfolgt ein Fehlerabbruch. Es kann kein Wert ermittelt werden, also wird das Programm beendet. Der Abbruch eines Programms kann explizit durch Auslösen einer Ausnahme er folgen. Im einfachsten Fall kann die vordefinierte Ausnahme durch die Anweisung ausgelöst werden:
246
Kapitel 9
✍ ☞
Ausdrücke und Funktionen
% ! $ "!
!#
Wir werden später in Abschnitt 14.4 sehen, daß die Ausnahmen einen Typ bilden, der vom Programmierer erweitert werden kann, und daß sie auch eine Möglichkeit bieten, das Programm kontrolliert fortzusetzen: ✍
" "
☞
9.3.4
""
"
"
#) * +!#
Ausgabe von Zwischenergebnissen
Manchmal läßt sich ein Fehler schnell finden, wenn Zwischenergebnisse eingesehen werden können. Das Aufspalten einer Funktion in zwei Teile, deren erster das Zwischenergebnis berechnet, von dem aus der zweite dann den Funktionswert bestimmt, ist aber eine denkbar ungeeignete Methode, da sie große Teile des Programmtextes ändert. Einfacher ist es, eine Ausgabefunktion aufzurufen, die den Wert ihres Argumentes darstellt. In CAML LIGHT gibt es für jeden einfachen Datentyp eine solche Funktion, deren Name gefolgt vom Typnamen ist. Diese Funktionen berechnen keinen
Wert. Wir ordnen ihnen deshalb den leeren Wert des Einheitstyps als Ergebnis zu: ✍ ☞
!%
Durch das Zeichen bzw. die Anweisung erfolgt ein Zeilenwechsel. Man kann das letzte als Funktion ohne Argumente auffassen – tatsäch lich hat jedoch auch diese Funktion genau ein Argument vom Typ .
9.3
Testen und Fehlerabbruch
247
Die Ausgabe kann man auch verwenden, um den Ablauf einer Funktion zu verdeutlichen. Das ist vor allem dann nützlich, wenn man feststellen will, welche Alternativen ausgeführt werden. Wir erläutern das am Beispiel einer Divide & Conquer-Version (Abschnitt 2.5) des Potenzierungsalgorithmus’, jetzt wieder für ganze Zahlen mit positivem Exponent. A L G O R I T H M U S 9.1 P OTENZIERUNG , DIVIDE &
CONQUER
M ETHODE : Es gilt
✍
" "
☞
%
gerade ungerade
falls falls falls
)
# "!#
– Alg. 9.1 – Wir wollen ausgeben, welcher Zweig des bedingten Ausdrucks betreten wird. Die Funktion wird durch einen Ausdruck bestimmt. Wo sollen wir also die Druckanweisungen unterbringen?
9.3.5
Folgen von Ausdrücken
Sollen mehrere zusammengehörige Ausdrücke hintereinander ausgewertet werden, ohne daß die einzelnen Ergebnisse angezeigt werden, so sind sie durch einfache Strichpunkte zu trennen. Syntaktisch handelt es sich wieder um einen Aus-
248
Kapitel 9
Ausdrücke und Funktionen
druck, dessen Wert der des letzten Ausdrucks in der Folge ist. Zur Verdeutlichung
oder mit sollten solche Ausdrucksfolgen mit geklammert werden:
✍
✍ ☞
☞
✍
☞
Nun können wir die Funktion " folgen zu können: " "
✍
☞
✍ " ☞ !#
"
# #)#* + !
!#
kurzfristig ändern, um so einen Aufruf ver-
Kapitel 10 Vordefinierte strukturierte Datentypen Im ersten Kapitel des zweiten Teils haben wir mit Ausdrücken und Funktionen die wichtigsten Elemente von CAML LIGHT zur Formulierung von Algorithmen behandelt. Insbesondere haben wir einfache arithmetische Ausdrücke betrachtet und erläutert, wie man durch Abstraktion von Ausdrücken Funktionen vereinbaren kann. Rekursive Funktionen bieten eine Möglichkeit, gleiche Operationen zu wiederholen. Durch bedingte Ausdrücke und das Ausfiltern von Mustern können Bedingungen und Verzweigungen realisiert werden. Die einfachen Standard wurden kurz vorgestellt. typen , , " , und
Hier wollen wir nun die in CAML LIGHT vorhandenen strukturierten Datentypen beschreiben und die Vereinbarung von allgemeinen Datenstrukturen sowie die Typkonstruktionsmechanismen erläutern. Man vergleiche hierzu auch Kapitel 11.
10.1
Listen
Eine Liste ist die grundlegende Datenstruktur jeder funktionalen Sprache. In CAML LIGHT ist eine Liste eine homogene, dynamische Struktur, deren Elemente nicht am Platz veränderbar sind. Eine Liste ist entweder leer oder besteht aus einem Element, dem Kopf , und einer Liste, dem Schwanz. Die Elemente besitzen alle den gleichen Typ. Diese Definition verdeutlicht mehr als die erste die Implementierung von Listen und die zur Verfügung stehenden Operationen.
10.1.1
Erzeugen von Listen
Zur Erzeugung einer leeren Liste gibt es den Konstruktor . Eine Liste mit Elementen wird entweder durch den Operator konstruiert, der eine Funk-
250
Kapitel 10
Vordefinierte strukturierte Datentypen
tion von Elementtyp Listentyp Listentyp beschreibt, aber »infix« geschrieben wird, oder durch Aufschreiben der einzelnen Elemente in der Form gebildet. Der Elementtyp parametrisiert den Listentyp. Er wird durch Einsetzen eines Wertes festgelegt. Hier sind beliebige Typen zugelassen – also auch wieder Listentypen: ✍ ☞ ✍ ☞
" $
" $
✍ ☞
" $
✍ ☞
" $ " $%
Der Operator ist rechtsassoziativ, d. h. eine Klammerung wie in der zweiten Zeile ist unnötig. Allerdings ist natürlich keine Liste. Deshalb ist das nächste Beispiel falsch:
✍ ☞ #
!
#
$ ! $ ! &
$
$
# # " $
Ein Einfügen eines Elementes an beliebiger Stelle ist nicht vorgesehen, es wird immer am Kopf eingefügt.
10.1.2
Listen als Muster
Sowohl der »Vierpunkt« als auch die eckigen Klammern werden zur Kennzeichnung von Listen als Muster verwendet: ✍
10.1
☞
#
#
Listen
$ $
$ $
✍
☞
10.1.3
251
"!#
"!#
Zugriff auf Listenelemente
Es kann jeweils nur auf das erste Element einer Liste und auf die Restliste ohne das erste Element zugegriffen werden. Das geschieht entweder durch Ausfiltern von Mustern oder durch Standardfunktionen. Wir stellen in diesem Abschnitt die einfachen Standardfunktionen zusammen, die meisten davon haben wir schon in der einen oder anderen Weise in Teil 1 selbst definiert und gebraucht. Die hier angegebenen Implementierungen beschreiben die Wirkung und müssen im System nicht buchstabengetreu so ausgeführt werden. Sie bieten neben der Vorstellung der Standardfunktionen auch eine Einführung in die Formulierung rekursiver Funktionen. Fast alle sind gemäß der rekursiven Definition einer Liste so geschrieben, daß sie für die leere Liste terminieren und sonst nach einer Aktion für den Kopf die Voraussetzung geschaffen wird, daß sie rekursiv für den Schwanz angewendet werden können:
✍ ☞
)
*
" $ #+!# " $ " $ * +#!
) ✍
☞
Der Zugriff auf das -te Listenelement ist für nicht vorgesehen. Die Position eines bestimmten Elementes läßt sich aber mit einer ähnlichen Funktion wie in Abschnitt 4.4 bestimmen: ✍
*
" "
252
Kapitel 10
Vordefinierte strukturierte Datentypen
"
" ☞ # )
" $ "!#
Hier erfolgt der Fehlerabbruch mittels der Ausnahme
.
Oft genügt es festzustellen, ob ein Element in der Liste enthalten ist oder nicht: ✍
☞
" * *
" $ * +!#
Die Funktion ✍
"
☞
& *
$ " $ * +!#
liefert die Liste ohne das Element . Genauer gesagt wird nur das erste Auftreten von entfernt. Sie entspricht der in Abschnitt 4.4 vereinbarten Funktion . Im Gegensatz dazu entfernt ✍
" "
☞
"
alle Vorkommem von :
" "
$%! " & ' " $%) " $ "!#
10.1.4
Listen- und Mengenoperationen
Die Listenlänge bestimmt die schon mehrfach angesprochene Funktion: ✍
"
10.1
Listen
253
" $ # "!# ☞ $
Das Aneinanderhängen zweier Listen ist als infix-Operator vorhanden:
"
✍ ☞
$ " $ " $ +!#
Das Umdrehen, also Rückwärtslesen einer Liste, das wir in Abschnitt 4.1 ausführlich behandelt haben, zitieren wir hier in seiner einfachsten Form, obwohl die Implementierung der Standardfunktion sicherlich effizienter ist:
"
✍
" $%) " $ "!#
☞
Als Demonstration dieser Funktionen zeigen wir noch einige Aufrufe: ✍ ☞
# " $
✍ ☞ # " $
✍ ☞ #
Betrachtet man Listen als Implementierung von Mengen, so sind die Operationen Vereinigung, Durchschnitt und Differenzmenge interessant. Wir verwenden die Funktion , die ja für Mengen an sich schon nützlich ist. Jedesmal wird die erste Menge in Kopf und Schwanz zerlegt. Ist , so wird es nicht in Vereinigung oder Differenzmenge übernommen, wohl aber in den Durchschnitt. Falls umgekehrt , gehört es nicht in den Durchschnitt, aber in die beiden anderen Mengen: ✍
"
254
Kapitel 10
☞
Vordefinierte strukturierte Datentypen
*
$ " $ " $ +!#
!# #
✍ " "
* "
$%! " & ' " $ $ " $ * +!#
✍ " "
* "
" +!# ☞ # +$ & " $ " $ " $ * ☞
Man beachte, daß die drei Funktionen fast identisch aussehen. Insbesondere Vereinigung und Differenzmenge unterscheiden sich nur in der Behandlung der leeren Menge.
10.1.5
Eigene Listenfunktionen
Andere Listenfunktionen müssen selbst programmiert werden. Dabei bekommt man einen Zugriff auf alle Elemente durch Anwendung von rekursiven Funktionen. Man kann etwa auf alle Elemente die gleiche Funktion anwenden – hier das Erhöhen um 1: ✍
"
'
☞ #& " $%' " $ # " % $ * +! ✍ ☞ " $
Oder man faßt alle Elemente zusammen. Noch einmal die Summation: ✍
"
10.2
Paare und Tupel
255
☞ $! # " $ # "#! ✍ ☞ #
Verallgemeinerungen dieser Funktionen sind als Funktionen höherer Ordnung in CAML LIGHT vordefiniert. Wir werden diese in Abschnitt 12.2 behandeln.
10.2
Paare und Tupel
10.2.1
Paare
Die einfachste Datenstruktur ist ein Paar aus zwei Werten, die von verschiedenem Typ sein können – etwa in einer Telefonliste Name Telefonnummer – aber nicht sein müssen, zum Beispiel Realteil Imaginärteil als Darstellung einer komplexen Zahl. Paare und Tupel kamen bereits als Argumente und Ergebnisse von Funktionen vor. Paare schreiben wir in runde Klammern eingeschlossen, die Komponenten werden durch ein Komma getrennt. An einigen Stellen könnten wir die Klammern sogar weglassen, aber sie tragen zur Erhöhung der Übersicht bei. Die Reihenfolge spielt eine Rolle, es handelt sich um geordnete Paare:
✍ ☞
!
# *&
✍ ☞
#
$ ! $ !% &
$ & $ #
An dieser Fehlermeldung erkennt man, daß die beiden Paare einen völlig unterschiedlichen Typ besitzen. Der Zugriff auf die erste und zweite Komponente erfolgt mit den vordefinierten Funktionen und .
256
Kapitel 10
Vordefinierte strukturierte Datentypen
Beim Ausfiltern von Mustern kann ein Paar von Namen bzw. Mustern angegeben werden:
✍ ☞
& *
✍
✍ ☞
10.2.2
& *
☞
Listen und Paare
Eine Liste von Paaren kann in zwei Listen aufgeteilt werden. Umgekehrt können auch zwei Listen zu einem Paar zusammengefaßt werden: ✍
"
☞ $ "% " ✍
$ " $ $ #+!#
%
% ☞ & # " $% " $
10.2.3
"
$%* +!
Listen als Wörterbücher
Die nächsten beiden Funktionen arbeiten auf Listen von Paaren, die hier als Implementierungen der in Kapitel 4.1 eingeführten Wörterbücher angesehen werden – die erste Komponente bezeichnet den Schlüssel, die zweite den Wert. Sie sind natürlich den bereits vorgestellten Funktionen und sehr ähnlich: ✍
" "
10.3 Vektoren
☞
$$ &'
✍ " "
"
257
"
$ * +!
☞ $ $ & * $ + !#
10.2.4
Tupel
Eine einfache Verallgemeinerung von Paaren sind kartesische Produkte einer beliebigen, aber festen Anzahl von Typen. Die Daten, die man auch Tupel nennt, werden ebenso wie die Paare durch Auflisten der Komponenten in der vorgesehenen Reihenfolge durch Kommata getrennt und in runde Klammern eingeschlossen notiert:
✍ ☞
!
$ #
#
$
✍ $% # $% ☞
$ # $ ! # ✍ ☞ % % #
10.3 10.3.1
$ + #
$%
Vektoren Konstruktor und Komponentenzugriff
Homogene kartesische Produkte, also solche, bei denen alle Komponenten dem gleichen Typ angehören, sind eine wichtige und viel gebrauchte Datenstruktur. Es handelt sich um eine Umsetzung des mathematischen Begriffs Vektor, dargestellt durch veränderbare, dicht im Speicher liegende Werte. Ein einzelner Vektor oder Feld wird in CAML LIGHT in eckige Doppelklammern ) eingeschlossen. Die Komponenten werden durch Semikolon getrennt. ( Der Zugriff auf die Komponenten erfolgt durch Angabe eine Indexausdrucks, der
258
Kapitel 10
Vordefinierte strukturierte Datentypen
in runde Klammern eingeschlossen und zusätzlich mit einem Punkt vom Feldnamen abgesetzt wird. Die Indizierung beginnt bei 0. Alle Komponenten müssen den gleichen Typ aufweisen:
✍ ☞
&
✍ ☞
Die Komponenten eines Feldes sind direkt (am Platz) durch Aufruf des Operators " veränderbar, dessen Ergebnis wieder die leere Konstante des Typs ist, weil seine eigentliche Wirkung, die Zuweisung des rechts stehenden Ausdrucks an die Feldkomponente, ein Seiteneffekt ist:
✍ ☞ !%
Durch Aufruf des Konstruktors wird ein Vektor mit Elementen erzeugt, die alle mit initialisiert werden. Durch den Typ von wird der Elementtyp festgelegt:
✍ " ☞ &
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: ✍
☞ & (#) * +!# ✍ ☞
10.3 Vektoren
259
bestimmt die Anzahl der Vektorelemente. Wir verDie Funktion zichten auf die Angabe der weiteren Standardfunktionen für den Vektortyp. Im Gegensatz zu Listen und Tupeln können Vektoren nicht als Muster auftreten.
10.3.2
Matrizen
Strukturen mit mehr als einem Indexbereich, in Programmiersprachen gemeinhin als mehrdimensionale Felder bezeichnet, sind möglich und werden als Vektor von Vektoren vereinbart und behandelt. Zur Initialisierung zweidimensionaler Felder steht die in CAML LIGHT eingebaute zur Verfügung: Standardfunktion
✍ ☞ ✍ ☞
%
& &
% & &
✍ " ☞ !#% ✍ & & ☞
✍ ☞
& &
&
Kapitel 11 Definition neuer Typen In den vorigen Abschnitten haben wir gesehen, wie wir mit Listen arbeiten, Paare und Tupel verwenden oder Datenstrukturen als Vektoren vereinbaren können. Diese Funktionalität ist in CAML LIGHT vorhanden. Sie erlaubt dem Umgang mit Strukturen für beliebige, aber einheitliche Elementtypen. Wir haben dafür bisher vorgegebene Schreibweisen für Listen und Vektoren benutzt. Nun wollen wir uns der Definition von eigenen Typen zuwenden. Das kann auch für die schon behandelten Typen sinnvoll sein, denn durch neue eigene Typnamen können wir zwischen strukturell gleichen Typen unterscheiden. So kann eine Liste von ganzen Zahlen einmal eine Menge im mathematischen Sinne bedeuten, ein andermal die Startnummern in der Reihenfolge des Zieleinlaufes eines Marathons notieren. Wir werden Typen ähnlich aufbauen wie Ausdrücke. Aus Typkonstanten setzen wir mit Hilfe von Typkonstruktoren und Typoperatoren neue Typen zusammen. Dabei verwenden wir auch Rekursion.
11.1 11.1.1
Vorhandene Typkonstruktoren Konstante Typen
Die einfachen Standardtypen liegen fest. Ihre Namen fungieren in zusammengesetzten Typausdrücken als Konstante.
11.1.2
Parametrisierte Typen
Kann der Algorithmus, der zur Typfestlegung vom Interpreter verwendet wird, aufgrund der Umgebung den Typ nicht eindeutig erkennen, so setzt er eine Typvariable ein, deren Namen mit Apostroph beginnen:
262
Kapitel 11
✍ ☞ #
✍ ☞ # ✍ & ☞ &
Definition neuer Typen
'
# * +!#
Wie das Beispiel zeigt, wird hier der Typ der ersten Komponente erst beim Aufruf bestimmt. Der Funktionstyp selbst ist nur eine Schablone, aus der durch Einsetzen des gleichen Typs für das erste Argument und die erste Komponente des Resultats ein normaler Typ wird. Solche parametrisierten Typen sind mit Funktionen vergleichbar. Sie treten vor allem bei Datenstrukturen auf, die zur Speicherung von Werten verwendet werden und bei denen die Operationen mehr von der Struktur als vom Elementtyp abhängen.
11.1.3 Der Listentyp Der Listentyp ist ein mit dem Elementtyp parametrisierter Typ, der mit dem Typ konstruktor eingeführt wird. Selbstverständlich kann für den Elementtyp jeder beliebige Typ, also auch ein Listentyp eingesetzt werden. Wir können nun neue Typnamen für Listen mit festgelegtem Elementtyp einführen, die auch parametrisiert sein können. Das geschieht mit der Typnamendefinition, in der nach dem Wortsymbol der möglicherweise parametrisierte Typname steht, dem der nach dem doppelten Gleichheitszeichen folgende Typ zugeordnet wird. Wir führen hier also einen neuen, aussagekräftigen Namen für einen bekannten Typ ein: ✍ ☞ ✍ ☞
# $
$ " $%
✍
" $ " $%
☞
✍ ☞
"#
$
+#
$ " $
11.2 Typoperatoren
11.1.4
263
Der Vektortyp
In CAML LIGHT ist ein Feldtyp mit dem Komponententyp parametrisiert. Der Typkonstruktor heißt " : ✍ ☞
"
11.2
&
+#
Typoperatoren
Wir stellen nun die Typoperatoren zur Verknüpfung von Typen vor.
11.2.1
Kartesisches Produkt
Der Wertebereich eines Paartyps ist das kartesische Produkt aus den Wertebereichen der Komponententypen: ✍ ☞
&
✍
☞ ✍ ☞
+#
%
# " $
✍ ☞
✍
☞
$% #
+ #
#
+#
#
% $
Der Typoperator , angewendet auf zwei Typen, bezeichnet also das kartesische Produkt.
11.2.2
Funktionsbildung
Wir haben schon mehrfach betont, daß Funktionen oft wie normale Werte behandelt werden können. Die Funktionen bilden einen höheren Datentyp. Eine feinere
264
Kapitel 11
Definition neuer Typen
Unterscheidung teilt den Bereich in verschiedene Typen ein, je nach Argumentund Ergebnistyp. Als Komponenten dienen der Definitionsbereich – also der Argumenttyp – und der Wertebereich – der Ergebnistyp – einer Funktion. Beide können wieder strukturierte Typen oder auch Funktionstypen sein. Der Pfeil zwischen Argumenttyp und Ergebnistyp dient als Typoperator: ✍ ☞
#
%! $
✍ ☞
+#
"#
11.2.3 Typausdrücke Die Operationen »kartesisches Produkt« und »Funktionstypbildung« sind beliebig miteinander kombinierbar. Als Operanden kommen Standardtypen, bereits definierte Typnamen und auch Typvariable vor. Dabei bindet schwächer als . So bezeichnet etwa ✍ ☞
"#
eine Funktion von einer ganzen Zahl auf ein Paar, aber ✍ ☞
+#
ein Paar aus Funktion und ganzer Zahl. Klammern sind also möglich. Bei kartesischen Produkten verändern sie aber durchaus die Semantik. So ist ✍ ☞
+ #
ein Tripel von ganzen Zahlen, der Typ ✍ ☞
+
beschreibt dagegen ein Paar, dessen erste Komponente ein Paar ist.
11.3
Verbunde
265
Der Funktionsoperator ist demgegenüber rechtsassoziativ, d. h. die Typausdrücke: ✍ ☞
" # + #
bezeichnen beide den gleichen Typ: eine Funktion, die eine ganze Zahl auf eine Funktion abbildet. Dies kann mittels »Currying« (siehe auch Abschnitt 12.1) auch als Funktion von zwei ganzen Zahlen aufgefaßt werden, unterscheidet sich aber von ✍ ☞
+#
wo einer Funktion eine ganze Zahl zugeordnet wird.
11.3
Verbunde
Tupel sind zwar Zusammenfassungen von Werten verschiedenen Typs, bieten aber außer für Paare keinen einfachen Zugriff auf ihre Komponenten an. Es ist oft besser und übersichtlicher die Elemente mit einem Namen zu bezeichnen, als sie über ihre Position anzusprechen. Diese Möglichkeit bieten die Verbunde oder Records. Hier handelt es sich um einen heterogenen Datentyp mit einer festen Anzahl von Komponenten, die über ihren Namen angesprochen werden, und die je nach Vereinbarung am Platz veränderbar sind oder nicht. Die Typdefinition eines Verbundtyps führt die Namen der Komponenten und deren Typ ein. Diese Namen sind nur lokal innerhalb der Typvereinbarung und beim Zugriff auf Komponenten sichtbar, können also in verschiedenen Typen gleich sein. Dabei muß man aber beachten, daß zwei Verbundtypen gleich sind, wenn alle Komponentennamen gleich sind. Es ist also nicht möglich, gleichzeitig zwei verschiedene Verbundtypen mit gleichen Komponentennamen zu beutzen. Zur Veranschaulichung dieser Tatsache dient das folgende Beispiel (und die Fehlermeldung). ✍ ☞
+
266
Kapitel 11
✍ ☞
✍ ☞
✍ ☞
+ #
# # $ ! $ ! & $ #
! $ &
✍ ☞
Definition neuer Typen
Die Typdefinition unterscheidet sich von der Typnamendefinition auf den ersten Blick nur durch das einfache Gleichheitszeichen. Rechts von diesem steht nun aber ein wirklich neuer Typ und nicht ein mit bekannten Typen gebildeter Typausdruck. Hier noch einige Beispiele: ✍ ☞
$
✍ ☞
✍ ☞
$
" #
#"
Die dritte Zeile zeigt das Auftreten von Recordstrukturen als Muster. Der Komponentenzugriff geschieht durch ✍
"
Einzelne Komponenten können als veränderbar gekennzeichnet werden. Die Veränderung erfolgt wie bei Feldelementen durch den " Operator. Es soll als Beispiel eine Datenstruktur für eine Multimenge vereinbart werden. Das ist eine Datenstruktur, in der ein Wert mehrfach abgelegt werden kann und dabei die Häufigkeit mitgezählt wird. Beim Einfügen wird der Zähler erhöht:
11.4
✍ ☞
#
✍ ☞
"#
+#
☞
11.4
267
✍ "
$
Varianten
"
# #+!#
Varianten
Viel wichtiger als die Verbunde sind für unsere Zwecke die Varianten oder disjunkten Verbunde, die einen neuen Typ einführen, der verschiedene, alternative Wertebereiche aufweist. Für jeden Wertebereich existiert ein eigener Konstruktor. Auf diese Weise läßt sich zum Beispiel ausdrücken, daß eine Gleitkommazahl auch nicht definiert sein kann. Dabei steht für »Not a Number«. In Varianten werden die verschiedenen Alternativen mit senkrechtem Strich getrennt: ✍ ☞
✍ ☞
%
"#
$
%) * +!
Die Konstruktoren sind Funktionsnamen, die üblicherweise mit einem Großbuch staben beginnen und deren Argumenttyp hinter dem Wortsymbol steht. Fehlt diese -Deklaration, so handelt es sich um eine, nun neu eingeführte Konstante.
268
Kapitel 11
Definition neuer Typen
Der Aufruf eines Konstruktors mit einem aktuellen Argument vom passenden Typ oder einer Konstante liefert einen Wert des Variantentyps. Am einfachsten, aber vielleicht auch am ungebräuchlichsten, sind natürlich Varianten mit nur einer Alternative. Hier wird ein expliziter Konstruktor für einen bekannten Typ eingeführt, oder es kann eine neue Typschablone erklärt werden: ✍ ☞
✍ ☞
#
#
✍ ☞ ✍
+ # *
✍ ☞
☞
+
&
11.4.1 Rekursive Typen Erinnern wir uns an die Definition einer Liste: Eine Liste ist entweder leer oder besteht aus einem Element – dem Kopf – und einer Liste – dem Schwanz. Die Elemente besitzen alle den gleichen Typ. Sie läßt sich durch eine Variante beschreiben, in deren einer Alternative der zu definierende Typ wieder auftaucht. Solche rekursiven Typdefinitionen sind erlaubt. Wie bei einer rekursiven Funktion muß allerdings darauf geachtet werden, daß nicht endlos der rekursive Konstruktor aufgerufen wird und der Aufbau der Datenstruktur terminiert: ✍ ☞
" $
✍
+#
☞ " $ #
# ✍
#
11.4
☞
✍ ☞
Varianten
269
" $ "!#
#
Der Unterschied zwischen diesem Datentyp und den Standardlisten ist lediglich syntaktischer Natur. Die Bezeichnung der leeren Liste als und des Konstruktors als infix Operator erhöhen ebenso wie die Schreibweise in eckigen Klammern die Lesbarkeit.
Kapitel 12 Funktionen höherer Ordnung 12.1
Curry-Funktionen
Wir haben in Abschnitt 9.2.6 Funktionen mit mehreren Argumenten vereinbart, indem wir diese nacheinander nur durch Leerzeichen getrennt hinter den Funktionsnamen schrieben. Eigentlich handelt es sich dabei um mehr. Betrachten wir die Addition von ganzen Zahlen als Beispiel. Diese ist als infix-Operator gegeben, kann aber auch durch die Funktion
✍
☞
#'(#)#)#* +!
beschrieben werden. Der Aufruf erfolgt nun durch Angabe zweier Werte:
✍ ☞ #
Im Unterschied dazu hat die Funktion
✍ ☞
(# ## * +!#
ein Argument, und zwar ein Paar. Sie wird mit
✍ ☞ #
aufgerufen. Auch die erste Funktion ist genaugenommen keine Funktion von zwei Parametern, sondern eine mit einem Argument, deren Ergebnis wieder eine Funktion ist.
272
Kapitel 12
Funktionen höherer Ordnung
Wir machen das durch den Aufruf
✍ ☞
"!#
deutlich. Das Ergebnis ist die Funktion, die ihr einziges Argument um erhöht. Wir können ihr auch einen Namen geben:
✍ ☞ (#)#*
✍
☞
✍ ☞
+!
✍ ☞
✍ ☞ #
#
#
$! ! $ ! & $ !# #
& %!
Die ersten drei Aufrufe sind äquivalent, der letzte falsch. Die Funktionsanwendung einer solchen Funktion geschieht also von links nach rechts. Wir können die Ergebnisfunktion auch in der Vereinbarung direkt angeben: ✍ ☞
# & (#)#)#* +!
Wir haben hier den infix-Operator , dessen Funktionsname ja ist, auf ein Argument angewendet und erhalten so eine Funktion mit einem weiteren, hier nicht sichtbaren Argument als Ergebnis. Eine Funktionsanwendung wird unmittelbar von links nach rechts ausgeführt. So sind und äquivalent.
12.1
Curry-Funktionen
273
Noch kürzer ist die direkte Bindung der Funktion an den neuen Namen: ✍ ☞ # &
"!#
Bei der Angabe des Typs der Funktion ist der Operator rechtsassoziativ zu lesen, d. h. wir haben eine Funktion, deren Wertebereich aus Funktionen besteht. Denkt man mehr an die vollständige Auswertung durch Angabe aller Parameter, so liest man dagegen eher von links nach rechts. Allgemein gilt folgende Definition:
D EFINITION 12.1 Sei Curry-Version von .
Für alle Wird
und
Dann heißt die Funktion
gilt
auf ein Argument angewendet, so erhält man eine Funktion
Der Name kommt von dem amerikanischen Mathematiker H ASKELL C URRY, der viele Beiträge für die Grundlagen des funktionalen Programmierens leistete. Curry-Versionen können mit all ihren Argumenten aufgerufen werden und entsprechen so genau den Funktionen mit mehreren Parametern. Interessant ist aber, daß durch einen Aufruf mit weniger Parametern Funktionen auf allen Zwischenstufen erzeugt werden können, die man sonst bei Bedarf hätte explizit vereinbaren müssen. Curry-Versionen lassen sich effizienter implementieren und werden deshalb allgemein bevorzugt. Sie vermeiden eine implizite Tupelkonstruktion beim Funktionsaufruf. Wir haben im ersten Teil des Buches trotzdem oft die Normalversion verwendet, weil diese näher bei der gewohnten Schreibweise bleibt. Es lassen sich sogar Funktionen schreiben, die eine Funktion mit einem Paar als Argument in ihre Curry-Version verwandeln und umgekehrt: ✍
☞
&!
* &
&* +!
274
Kapitel 12
✍
!#& !
☞
✍
☞ ✍ ☞ ✍ ☞
Funktionen höherer Ordnung
& * & #+!#
# # ##+!# + "&'(#)#)#* +! + % ! $ # " !
"!
! +! &
Die Funktionen + und sind gleich. Der Test auf Gleichheit ist allerdings für Funktionen nicht erlaubt.
ist eine Funktion mit drei Argumenten, deren erstes eine Funktion
ist, die auf das Paar gebildet aus den beiden anderen angewendet wird. Beim Aufruf mit nur einer Funktion von passendem Typ wird so deren Curry-Version erzeugt. Entsprechend Umgekehrtes gilt für die Umkehrfunktion .
12.2
Funktionen höherer Ordnung
Funktionen treten im vorigen Abschnitt also nicht nur als eigens vereinbarte Implementierungen von Algorithmen auf, sondern auch als Parameter und Ergebnis von anderen Funktionen. Das ist aber gerade ein wesentliches Merkmal funktionaler Programmiersprachen – Funktionen sind ganz normale Werte! Trotzdem hat es sich eingebürgert, Funktionen, die andere Funktionen als Argumente besitzen, als Funktionen höherer Ordnung oder höherwertige Funktionen zu bezeichnen. Funktionen höherer Ordnung formulieren allgemeine und vielfach verwendbare Programmiermuster. Die spezifischen Eigenschaften eines Algorithmus’ werden durch Funktionen beschrieben. Diese Funktionen parametrisieren das den Algorithmus implementierende Programm, das nun zu einer Schablone wird, die von Fall zu Fall mit anderen Funktionen ausgeprägt werden kann. Unsere Sortieralgorithmen aus Kapitel 5 wurden etwa alle so definiert, daß als Vergleichsfunktion der Operator explizit verwendet wurde. So lautete das »Sortieren durch Einfügen«:
12.2 Funktionen höherer Ordnung
✍
"
"
☞
275
$ " $ " $%* +!
Nun ist zwar für beliebige Typen außer für Funktionen anwendbar, aber das Ergebnis wird für strukturierte Typen oft nicht dem erwarteten entsprechen – wir möchten gern unsere eigene Vergleichsfunktion einbringen. Dann können wir auch absteigend sortierte Folgen durch einfaches Austauschen der Vergleichsfunktion erzeugen. Obiges Beispiel sieht dann so aus: ✍
"
"
☞
*
# " $
✍ # " $ ☞
)
"
# " $
alle geraden Zahlen nach vorn sortiert werden.
$ " $ "!#
erzeugt eine absteigend sortierte Folge, während mit
Der Aufruf
✍ ☞
$
✍ ☞
276
Kapitel 12
Funktionen höherer Ordnung
Als weitere Beispiele stellen wir die Standardfunktionen höherer Ordnung für Listen vor.
12.2.1
Standardfunktionen höherer Ordnung für Listen
Die Standardfunktionen höherer Ordnung für Listen beschreiben im wesentlichen verschiedene Arten eine Liste zu durchlaufen und dabei auf alle Elemente eine Funktion anzuwenden oder alle Elemente zusammenzufassen. Mit ihrer Hilfe können Aussagen wie »addiere alle Listenelemente« oder »finde alle positiven Listenelemente« als ein Befehl (Funktionsaufruf) programmiert werden. Man ist also nun erst richtig in der Lage, eine Liste als eine Einheit aufzufassen und in ihrer Gesamtheit zu behandeln. Die Funktionen kapseln dadurch die Listenstruktur auch vor dem Benutzer und dienen als allgemeine Iteratoren. Wir wollen als erstes Beispiel eine Funktion auf alle Elemente einer Liste anwenden, das Ergebnis ist die Liste der Funktionsergebnisse. Für verschiedene spezielle Funktionen haben wir das schon ausgeführt. Jetzt formulieren wir die allgemeine Schablone, die mit der anzuwendenden Funktion parametrisiert wird:
"
✍
☞
* * " $ " $%* +!
Diese, wie auch die weiteren in diesem Abschnitt vorgestellten Funktionen, sind als Standardfunktionen im Kern (core library) von CAML LIGHT enthalten. Ein geeigneter Aufruf von kann etwa alle Listenelemente um erhöhen:
✍ ☞
* ✍ ☞ " $
" $
Sind die Ergebnisse der einzelnen Aufrufe unwichtig, so verwendet man die Funktion % :
12.2 Funktionen höherer Ordnung
✍ ☞
277
" %
% "
) ) " $ !% #+!#
$%'
Beispielsweise können so alle Elemente einer Liste ausgedruckt werden:
✍ % ☞
)
!%
Das Zusammenfassen einer Liste zu einem Wert, wie es etwa bei der Summenbil dung vorkommt, kann mit den Funktionen oder erzielt werden. Der Unterschied zwischen diesen beiden Funktionen besteht in der Reihenfolge der Zusammenfassung: ✍
"
" %$ ' ) * " ✍
'
☞ $ ' )
* " ☞
" $ "!#
$ "!#
Die Liste ist also einmal das dritte und einmal das zweite Argument, und die Funktion wird einmal zuerst auf das erste und einmal zuerst auf das letzte Listenelement angewendet.
Bei assoziativen Funktionen , z. B. bei der Berechnung der Listensumme, ist das Ergebnis gleich, aber im Allgemeinfall nicht:
✍ ☞ # ✍ ☞ # ✍ ☞ # ✍ ☞ #
278
Kapitel 12
Funktionen höherer Ordnung
Ein Abrollen der beiden Funktionen ergibt die leicht zu merkende Darstellung:
Alle diese Funktionen sind auch für zwei Listen verfügbar, deren Länge gleich sein muß:
"
✍
☞
☞ ✍
) & *
" %
" $ * * "!# "
% " $ *
* " $ * +! "
✍
"!#
✍
☞
☞ "
$
&
%
%
&
" $%) " $ !%
& *
% * * & & & #+!#
$ " $%) & " $
"
*
$ &
" $ " $
Damit lassen sich nun solche Operationen wie die elementweise Addition zweier Listen oder die Prüfung auf Gleichheit sofort hinschreiben. ✍
#
12.2 Funktionen höherer Ordnung
☞
279
"
$ ' # " $ # " $ " $ * +!#
* +! ☞ %! " $ " $ " $
✍
Die letzte Funktion, bei der der aufdatierende Vergleich als Argumentfunktion doch nicht ganz trivial ist, wollen wir uns noch einmal näher anschauen. Durch die Argumentfunktion werden die beiden Listenköpfe verglichen, und das Ergebnis dieses Vergleichs wird durch »und« mit derem ersten Parameter verknüpft, der mit initialisiert wurde. Durch die Rekursion werden so nacheinander alle Listenelemente verglichen und als Ergebnis nur dann geliefert, falls alle Vergleiche positiv ausfallen. Der Durchlauf durch alle Listenelemente läßt sich auch einfacher mit der Funktion realisieren. Analog dazu gibt es die Funktion die prüft, ob ein Prädikat wenigstens für ein Listenelement erfüllt ist. Wir formulieren diese beiden Funktionen mit Hilfe von Schritt von oben noch verallgemeinern:
✍ ☞
✍
☞
'
) " $ * +!
, indem wir den
$ $
*
$ +!#
In CAML LIGHT existiert noch eine weitere Variante der Funktion, die eine Funktion, die aus einem Wert eine Liste erzeugt, auf alle Elemente einer Liste anwendet und die entstehenden Listen aneinanderhängt: ✍
"
☞
*
$ " $ " $%* +!
Als Beispiel für eine listenerzeugende Funktion formulieren wir: ✍
" )
☞
# # " $ * + !#
'
Diese Funktion bestimmt für ein gegebenes die Folge der ersten Zahlen.
280
Kapitel 12
Funktionen höherer Ordnung
Der Unterschied zwischen und
✍ ☞ " $
" $%
✍ ☞
wird durch die Aufrufe
" $
deutlich. Während der erste eine Liste von Listen generiert, werden diese Listen beim zweiten Aufruf zu einer zusammengefaßt.
12.2.2
Divide & Conquer-Schablonen
Im Abschnitt 2.6 von Teil 1 haben wir schon gezeigt, daß mit Hilfe von Funktionen höherer Ordnung allgemeine Programmschablonen wie verschiedene Iterationsmuster oder Schleifenkonstrukte formuliert werden konnten. Auch BacktrackingAlgorithmen konnten so sinnvoll verallgemeinert werden (siehe Abschnitt 8.1). Als weitere Anwendung wollen wir uns nun das Entwurfsprinzip »Divide & Conquer« vornehmen. Divide & Conquer-Verfahren haben wir in Abschnitt 2.5 eingeführt und dann vielfach angewendet, etwa beim Sortieren und Suchen oder auch beim Umdrehen einer Liste oder der Exponentiation einer Zahl. Wir wollen hier nun allgemeine Schablonen für Divide & Conquer-Verfahren entwickeln, die wir aus dem in Algorithmus 2.2.11 angegebenen Entwurfsprinzip herleiten wollen. Hier noch einmal die Schritte: Divide: Teile das Problem in möglichst gleich große Teilprobleme. Conquer: Löse diese Teilprobleme. Merge: Setze Gesamtlösung aus den Teillösungen zusammen. Dabei zahlt es sich aus, wenn man sich hier auf die zwei häufigsten Muster konzentriert, die auch schon bei der Aufwandsanalyse auftraten (Vergleiche Satz 2.7 und Satz 2.6).
12.2 Funktionen höherer Ordnung
281
Im ersten Fall wurde das Problem gelöst, indem der Algorithmus nach Aufteilung der Eingabe auf nur eine Hälfte angewendet wurde. Wir nennen es deshalb »Halbierung«. Im anderen Fall wurde die Problemgröße ebenfalls halbiert, aber der Algorithmus muß nun auf beide Teile angewendet werden. Wir nennen dieses Muster »Zwei-Hälften«. Dem Halbierungsmuster entspricht das Suchen in Bäumen oder einer sortierten Liste, nach einer Entscheidung wird die Suche auf der halbierten Datenstruktur fortgesetzt. Beim Vorgehen nach dem Zwei-Hälften-Muster müssen, wie beim Sortieren durch Mischen, beide Hälften der Ausgangsstruktur weiterbehandelt werden. Beginnen wir mit dem Algorithmus des Halbierungsmusters. A L G O R I T H M U S 12.1 D & C _ HALB E INGABE : Problem, beschrieben durch Datenstruktur. Funktion, die die Lösung für einfache Struktur liefert. Funktion, die diese Einfachheit feststellt. Funktion zum Aufteilen des Problems. Funktion zum Zusammensetzen der Lösung. A USGABE : Lösung. M ETHODE : Falls das Problem einfach genug ist, bestimme die Lösung, Sonst teile Problem auf. – Wende Algorithmus auf einen Teil an und – setze Lösung zusammen. Diese Beschreibung setzen wir direkt in ein CAML LIGHT-Programm um: ✍ "
282
Kapitel 12
Funktionen höherer Ordnung
☞
&
* * ) *
*
+!#
– Alg. 12.1 –
Wie ist die Ausgabe des Interpreters zu lesen? Das Problem wird durch eine Datenstruktur beschrieben. Die Funktion bestimmt für eine Lösung vom Typ . Dabei verwendet sie folgende Hilfsfunktionen:
Die Funktion lösbar ist.
prüft, ob das Problem mit der Funktion
Ist das nicht der Fall, wird es mit aufgeteilt und die Lösung eines Teilproblems mit den Problemdaten zur Lösung des Gesamt problems durch zusammengesetzt. Wenden wir diese Schablone zuerst auf die Exponentiation aus Abschnitt 9.3.4 an. Das Problem wird hier durch ein Paar von ganzen Zahlen beschrieben. Es ist einfach, falls die zweite (der Exponent) gleich ist – in diesem Fall ist die Lösung gleich . Das Aufteilen geschieht durch Halbierung des Exponenten, beim Zusammensetzen werden die nötigen Multiplikationen ausgeführt:
✍
☞
✍ ☞
$%
# * +!#
✍ ☞
$ ## * +!#
) % #) ##+!#
✍ ☞
## #)#* +!
% ✍
12.2 Funktionen höherer Ordnung
☞
283
### * +!# #
✍ ☞
Es folgt nun noch die binäre Suche auf einem Feld aus Abschnitt 2.5. Die Problembeschreibung ist hier durch den zu suchenden Wert und ein Feld mit Anfangsund Endindex gegeben. Falls diese beiden Indices gleich sind, liefert ein einfacher Vergleich die Lösung. Sonst wird die Aufteilung des Feldes in der Mitte vorgenommen und je nach den Größenverhältnissen die linke oder rechte Hälfte des Feldes durchsucht. Hier steckt also die meiste Arbeit in der Funktion , nur das erste Argument reproduziert: wohingegen
✍ ☞ ✍ ☞
& &) "!#
$ & # #+!#
$%
✍
☞
"!#
✍ ☞
☞ ✍ ☞
✍ ☞
'
& # #
& #
*
* * +!#
✍ %
#$
* & ! $
& #* "!#
Auch für das Zwei-Hälften-Muster formulieren wir zuerst den allgemeinen Algorithmus, der dann sofort in ein CAML LIGHT-Programm mündet.
284
Kapitel 12
Funktionen höherer Ordnung
A L G O R I T H M U S 12.2 D & C _2 HAL E INGABE : Problem, beschrieben durch Datenstruktur. Funktion, die die Lösung für einfache Struktur liefert. Funktion, die diese Einfachheit feststellt. Funktion zum Aufteilen des Problems. Funktion zum Zusammensetzen der Lösung. A USGABE : die Lösung. M ETHODE : Falls das Problem einfach genug ist, so bestimme Lösung. Sonst teile Problem in zwei Teile: – Wende den Algorithmus auf beide Teile an und – setze Lösung zusammen.
"
✍
☞
&
% "
* * * * * *
* +!#
"
* *
– Alg. 12.2 – Der Unterschied zum vorigen Algorithmus ist lediglich, daß nun ein Paar zwei Lösungen kombiniert. von Teilproblemen liefert und
12.2 Funktionen höherer Ordnung
285
Die klassische Anwendung ist natürlich das Sortieren durch rekursives Mischen. Dieses hatten wir in Kapitel 5 schon in fast genau dieser Form geschrieben: "
✍
☞ " $
✍ "
☞
" $% " $ * +!#
" $ " $%) " $ " $ "!#
" ✍
☞
&
"
$ " $ * +!#
Wir sehen, daß wir mit ,
als Identität und eine korrekte Ausprägung des Musters erhalten. Auch Quicksort läßt sich aus dieser Schablone ableiten. Die beiden Funktionen und sind die gleichen wie beim Mischen. leistet hier die Hauptarbeit pro Schritt, indem es aus der Liste nacheinander die Elemen te ausfiltert, die kleiner bzw. größer als der Listenkopf sind. Die Funktion hängt einfach die Listen aneinander: ✍
286
Kapitel 12
☞
Funktionen höherer Ordnung
*
$%
" $%) "!#
✍ ) * +!# ☞ $
" ✍
☞
++
*
✍ )
☞ %
✍ ☞ ✍ ☞ ✍ ☞
"
$ $ #+!#
" $ " $ " $ +!#
$ %! & $
" $
" $ " $
" $ + !# " $ +!#
Kapitel 13 Module 13.1
Abstrakte Datentypen und Datenkapselung
Wir haben in Teil 1 schon viel von abstrakten Datentypen gesprochen, die durch Wertebereich und Operationen gegeben sind, bei denen aber sowohl der Wertebereich als auch die Operationen gekapselt sind, so daß der Benutzer weder die genaue Datenstruktur noch die Implementierung der Operationen kennt. In unseren Beispielen waren aber stets vollständige, global sichtbare Strukturen und Funktionen angeführt worden. Das erleichterte uns den Blick auf die Algorithmen. Nun wollen wir mit dem Modul eine CAML LIGHT-Konstruktion behandeln, die die Datenkapselung unterstützt und so die Realisierung abstrakter Datentypen durch Trennung von Schnittstelle und Implementierung verwirklicht. Als Beispiel implementieren wir den abstrakten Datentyp Wörterbuch, dessen Einträge jetzt nicht nur aus dem Schlüssel, sondern aus einem Paar Schlüssel Information bestehen. Als Operationen sehen wir Suchen, Einfügen und Löschen vor. Dabei soll die mit dem einzufügenden Schlüssel verbundene Information so eingefügt werden, daß vorher vorhandene Bindungen nicht gelöscht, sondern einfach überdeckt werden. Entsprechend sollen die alten Bindungen beim Löschen wieder sichtbar werden – es wird also nur die aktuelle Bindung entfernt. Das entspricht genau der Semantik des Einfügens am Kopf einer linearen rekursiven Liste (siehe Abschnitt 4.4). Das Suchen soll hier allerdings keine Position liefern, sondern nur Auskunft darüber geben, ob ein Paar mit angegebenem Schlüssel in der Struktur enthalten ist und seine aktuell gebundene Information zurückgeben. Ein Modul besteht wie oben erwähnt aus Schnittstelle und Implementierung. Wir beginnen mit der Schnittstelle. Hier kommt eine Definition eines abstrakten Typs
288
Kapitel 13
Module
vor, die nur den neuen Typnamen einführt, und dessen Struktur dann im Implementierungsteil nachgeholt werden muß: ✍ ☞
+ #
% &
Schlüsseltyp und Informationstyp parametrisieren den Wörterbuchtyp. Von den Funktionen werden nur Definitions- und Wertebereiche angegeben, die ja den Funktionstyp beschreiben. Solche Funktionschnittstellen werden durch Angabe eines Funktionsnamens angelegt, vor dem das Wortsymbol steht und dahinter mit einem Doppelpunkt abgesetzt der Funktionstyp:
*
✍
Leider können diese Schnittstellendefinitionen nicht vom Interpreter bearbeitet werden, sondern müssen vorkompiliert werden (siehe dazu Anhang A.2). In der zugehörigen Modulimplementierung folgen die Definition der Typstruktur und der Funktionsrümpfe. Das entspricht der bisher bekannten Syntax: ✍ ☞
% &
+ #
" ✍
☞
+ *
☞
✍
*
✍
"
☞
$ * +!#
"
*
"
$
$
$ #+!#
"
$ * +!#
Ein vorkompiliertes Modul muß im Interpreter zum aktuellen Programm mit der
13.2
Kernmodule
289
Systemfunktion
✍
dazugeladen werden. Dann sind die in ihm vereinbarten Größen aber noch nicht direkt sichtbar. Sie können nur angesprochen werden, indem vor ihren Namen der Modulname und zwei Unterstriche gestellt werden. Die direkte Sichtbarkeit wird durch eine ✍
%
Direktive erreicht. Ein Modul ist also eine abgeschlossene Einheit, die separat übersetzt werden kann und selbst noch in Schnittstellendefinition und Implementierungsteil aufgeteilt wird. Arbeiten mehrere Module in einem Programm zusammen, so gilt es die verschiedenen Stücke beeinander zu halten. Das geschieht, indem der Modulname gleich dem Namen der Datei gewählt wird, die den Programmtext enthält. Hier kommt also mit dem Dateinamen eine eigentlich CAML LIGHT-fremde Größe der Programmierumgebung ins Spiel. Mit etwas Programmierdisziplin ist das aber sicher die einprägsamste Verwaltung der Modulnamen. Durch das Modulkonzept ist dem Benutzer ein Werkzeug in die Hand gegeben, die Funktionalität der Sprache CAML LIGHT zu erweitern. Das ist natürlich schon vielfach angewendet worden, und sogar die Operationen der einfachen Standarddatentypen sind in sogenannten Kernmodulen zusammengefaßt. Dazu kommt eine Vielzahl von Standardmodulen, die zum Teil auch Anschlüsse für andere oft verwendete Werkzeuge wie die Behandlung und Programmierung graphisher Benutzeroberflächen schaffen, und damit den Anwendungsbereich von CAML LIGHT weit über die im Rahmen dieses Buches vorgestellte Realisierung von Algorithmen und Datenstrukturen hinaus erweitern. Einige der Kern- und Standardmodule wollen wir mehr oder weniger detailliert in den folgenden Abschnitten darstellen.
13.2
Kernmodule
Ein wesentlicher Teil der Sprache CAML LIGHT wird in Form von Kernmodulen zur Verfügung gestellt. Diese Module werden automatisch zu jedem
290
Kapitel 13
Module
CAML LIGHT-Programm dazugeladen und brauchen nicht explizit geöffnet wer-
den.
13.2.1
Standarddatentypen
Die Standarddatentypen sind in einem Modul mit dem Namen zusammengefaßt. Im Gegensatz zu den meisten anderen Modulen ist dieser nicht vollständig in CAML LIGHT geschrieben, denn er führt für einige Datentypen eine neue spezielle Syntax ein, wie etwa als Konstruktor für die leere Liste. Die Typen sind im einzelnen: Einfache Datentypen
*
Listen und Vektoren
#
Einheitstyp und Ausnahmen
Im Modul sind die Tests auf Gleichheit und alle weiteren Vergleichsoperatoren einschließlich Maximum und Minimum für alle Datentypen außer Funktionen vorgesehen. Normalerweise reicht die strukturelle Gleichheit, d. h. die Gleichheit der Werte. Für veränderliche Strukturen gibt es noch die physikalische Gleichheit, die wirklich nur für ein- und dasselbe Objekt erfüllt ist. Die Wirkung der und Operatoren auf selbstdefinierte Datentypen mag nicht der Vorstellung des Benutzers entsprechen. In diesem Fall sind eigene Funktionen oder Operatoren zu vereinbaren.
13.2
Kernmodule
291
Viele in Modulen bereitgestellte Datentypen sind parametrisiert und arbeiten mit Komponentenvergleichen. Um nicht jedesmal alle Vergleichsoperatoren angeben zu müssen, sind diese in der Funktion ✍
zusammengefaßt, die liefert bei Gleichheit, einen negativen Wert, falls das erste Argument kleiner als das zweite ist, und anderenfalls einen positiven Wert. So ist für ganze Zahlen die Subtraktion eine mögliche Vergleichsfunktion.
13.2.2
Operationen für einfache Datentypen
Für jeden der einfachen Datentypen existiert ein Modul, das die bekannten Operationen enthält. Das Modul " für logische Operationen. Es nimmt insofern eine Sonderstel lung ein, als daß für die Operatoren und der zweite Operand nur dann ausgewertet wird, wenn er für das Ergebnis noch gebraucht wird, wenn also bei bzw. bei ist. der erste
Im Modul sind neben den mathematischen Operatoren auch noch Funktionen für bitweise logische Operationen vorhanden. , , Das Modul enthält die mathematischen Standardfunktionen , , , , , , , und . konDie numerischen Datentypen sind untereinander und zum Typ vertierbar. Die Funktionen befinden sich im Modul
und im Modul
292
Kapitel 13
Module
Für gibt es nur Konvertierungen mit , die den ASCII-Code betreffen ( % und ), und eine, , die das Zeichen als darstellbaren String liefert.
Ein String ist eine Zeichenkette, in der die einzelnen Zeichen ähnlich wie bei einem Vektor mit beginnend durchnumeriert sind. Ein konstanter String (ohne Vorwird in eingeschlossen. Strings können auch mit besetzung) und
(Auffüllen mit gleichem Zeichen) erzeugt werden. Die üblichen Vergleiche werden auf Strings nach der lexikographischen Ordnung durchgeführt. Der Operator hängt zwei Strings aneinander und liefert den Ausschnitt von , der an der Position beginnt und Zeichen lang ist. Weitere Stringoperationen erlauben das Lesen und Verändern des n-ten Zeichens, Auffüllen eines Strings und Übertragen eines Ausschnittes in einen anderen String.
Anstelle von
# %
✍ ☞ $ '
kann auch
$ $ $ ✍ ☞ ' $ ! ✍
$ $ # ! ✍ ☞ $% # # ! ☞
13.2.3
geschrieben werden.
Listen, Vektoren und Paare
Die wichtigsten Operationen für Listen, Vektoren und Paare wurden in den Abschnitten 10.1 bis 10.3 und 12.2 bereits behandelt. Sie werden durch die Module , und bereitgestellt.
13.3
13.2.4
Standardmodule
293
Ein- und Ausgabe und Datenströme
Die Ein- und Ausgabeprozeduren wie etwa
stehen im Modul ) . Weitere Funktionen und Beispiele für Ein- und Ausgabe finden sich im Abschnitt 14.1.
stellt dem Benutzer allgemeine Datenströme, sogenannte StreDas Modul ams zur Verfügung, die mit beliebigen Funktionen manipuliert werden können.
13.3
Standardmodule
Standardmodule brauchen zwar nicht extra in den Interpreter geladen zu werden, müssen aber mit einer -Direktive geöffnet werden, um ihre Werte direkt sichtbar zu machen. Wir wollen hier nicht alle Module besprechen, sondern uns vielmehr auf eine kleine Auswahl beschränken. Für viele der von uns in Teil 1 vorgestellten Datenstrukturen existieren solche Standardmodule, allerdings mit etwas geänderter Semantik.
13.3.1
Schlangen und Keller
Die Module und veränderbar sind.
implementieren Schlangen und Keller, die am Platz
Für Schlangen lauten die Funktionen:
294
Kapitel 13
Module
Für Keller sehen sie so aus:
13.3.2
Wörterbücher und Mengen
Es existieren zwei verschiedene Standardmodule, die allgemeine Wörterbücher in der in Abschnitt 13.1 angegebenen Form verwirklichen – Hashtabellen im Modul und balancierte (AVL-) Bäume im Modul . Hashtabellen können am Platz verändert werden und sind in der Lage, ihre Kapazität dynamisch anzupassen. Es steht eine allgemein anwendbare Hashfunktion zur Verfügung, die einem Schlüssel von beliebigem Typ eine ganze Zahl zuordnet.
13.3
Standardmodule
*
*
*
295
Das Modul realisiert ein Wörterbuch als balancierten Binärbaum. Dieser ist nicht am Platz veränderbar, es wird also jedesmal eine neue Struktur erzeugt. Wir haben Teile seiner Schnittstelle schon in Abschnitt 13.1 erwähnt. Die Ordnungsrelation für den Schlüsseltyp wird in Form einer allgemeinen Vergleichsfunktion bei der Konstruktion des Wörterbuches spezifiziert.
* '
)
Mengen, also Strukturen, in denen jedes Element nur einmal vorkommt, können als lineare rekursive Listen implementiert werden. Eine effizientere Realisierung mit balancierten Bäumen bietet das Modul . Auch hier wird die Vergleichsfunktion bei der Kreation der leeren Menge festgelegt.
296
Kapitel 13
*
*
" "
Module
, das eine Form von Die Module und stützen sich auf das Modul ausgeglichenen Binärbäumen realisiert, die den im ersten Teil behandelten AVLBäumen ähneln. In diesem Modul wird die Vergleichsfunktion nicht einmal bei der Konstruktion des leeren Baumes festgelegt, sondern wird als einstellige Funktion allen Operationen übergeben. Der Benutzer hat darauf zu achten, daß jedesmal die gleiche oder wenigstens eine die gleiche Ordnung beschreibende Funktion verwendet wird.
* *
*
Die Eingaben ✍
bedeuten: Finde ein Element im Baum, für das gleich 0 ist. Als Funktion für ganzzahlige Einträge eignet sich etwa die Subtraktion von 0. Die Typdefinition ist hier nicht gekapselt. Als weitere Besonderheit taucht ein Optionstyp auf, der auch den Zugriff auf nicht existente Elemente ermöglicht. Der Wert eines solchen Optionstyp ist entweder eine Konstante, die den leeren Wert
13.3
Standardmodule
297
kennzeichnet oder ein mit einem einparametrigen Konstruktor gewonnener Wert. Es handelt sich also um eine einfache Variante:
✍
☞
Der Typ
# + $ & #
+#
wird in der Funktion
gebraucht, die einen Baum in (alle Elemente mit kleiner 0), Elemente mit größer 0) und ( , falls ein mit Baum vorhanden war oder sonst ) zerlegt.
13.3.3
(alle
gleich 0 im
Zufallszahlen
Ein weiteres interessantes Modul, das sehr gut zum Generieren größerer Testmen gen für unsere Algorithmen verwendbar ist, ist % , das Pseudozufallszahlen erzeugt. mit eiNach Initialisierung des Generators mit der Funktion % ner beliebigen ganzen Zahl liefern % " bzw. eine ganze bzw. reelle Zahl zwischen und " . Durch mehrmaliges Aufrufen erhält man so eine Folge von zufällig in dem Intervall verteilten Zahlen.
Kapitel 14 Imperative Konstrukte Das imperative Programmieren zeichnet sich aus durch veränderbare Variablen, deren Werte den Zustand des Programms beschreiben und die durch Anweisungen andere Werte zugewiesen bekommen. Anweisungen sind Funktionsaufrufe ohne Ergebnis, die erwünschte Zustandsänderung ist eigentlich ein Seiteneffekt. Solche Seiteneffekte können aber an einigen Stellen gewollt sein. So haben wir im Abschnitt 9.3 gesehen, daß die Ausgabe von Werten helfen kann, Fehler im Programm zu lokalisieren. Das Auslösen einer Ausnahme war ein anderer imperativer Aspekt. Aus Effizienzgründen werden zudem veränderbare Datenstrukturen oft vorgezogen. CAML LIGHT enthält deshalb auch eine Anzahl imperativer Konstrukte, die wir hier nur kurz beleuchten wollen. Wir versuchen anhand von bekannten Beispielen ihre Wirkung zu beschreiben und ihre Vorteile auszunutzen.
14.1
Ein- und Ausgabe
Ein nicht interaktiv interpretiertes Programm bekommt seine Eingabewerte von einer Datei oder einem anderen Eingabemedium und schreibt seine Resultate auf ein Ausgabegerät, z. B. einen Bildschirm. Im Modul ist eine Reihe von Ein- und Ausgabefunktionen zusammengestellt. Wir geben davon nur einige wenige an. kann ein Wert des Mit , , und entsprechenden Typs auf dem Standardausgabemedium % ausgegeben wer
den. Der Aufruf bewirkt einen Zeilenwechsel.
Für das Einlesen stehen drei Funktionen zur Verfügung. Alle lesen eine Zeile von liefert sie als String, während der Standardeingabe – bzw. sie in einen Zahlenwert konvertieren.
Weitere Funktionen stellen die Verbindung zu externen Dateien her und liefern
300
Kapitel 14
Werte vom Typ bzw. Ein- bzw. Ausgabekanäle:
Imperative Konstrukte
zurück, den internen Typen für
✍
Für diese externe Ein- und Ausgabe dienen die Funktionen
!% & !% #+!#
✍ ☞
und
✍ ☞ & #
* +!#
die beliebige Typen in interner Darstellung ausgeben und wieder einlesen. Hierbei trägt der Programmierer die Verantwortung, daß die tatsächlichen Typen von eingelesenem und ausgegebenem Datum übereinstimmen. Außerdem ist zu beachten, daß Ein- und Ausgabe üblicherweise gepuffert ablaufen. Der Puffer ist explizit zu leeren, damit die Ausgabe abgeschlossen wird. Dies geschieht mit der Funktion ✍ ☞
!% & !#% * +!#
Mit diesen Funktionen, die nur einen kleinen Teil des Moduls ausmachen, können wir bereits ganze Binärbäume ausgeben und einlesen: ✍ ☞
✍
+#
#
☞
✍
14.2
☞
Anweisungsfolgen
!% ! & # $ +
& * %$ + ✍ ☞ !#%
✍ ☞ $ #
✍ ☞
301
*
✍ ☞ ✍ ☞ +'!#%
14.2
Anweisungsfolgen
Eine Anweisungsfolge wird in Klammern oder und eingeschlossen, die einzelnen Anweisungen werden durch einfache Strichpunkte getrennt. Solche Folgen treten etwa beim Ausdrucken von Information aus. Wir wollen als Beispiel eine Funktion schreiben, die einen Binärbaum in geeigneter Form ausgibt. Wir wollen also, im Gegensatz zur im letzten Abschnitt besprochenen Dateiausgabe, eine lesbare Darstellung des Baumes erzeugen und die in einem Baum gespeicherten Werte ausgeben. A L G O R I T H M U S 14.1 B INÄRBAUM A USDRUCKEN E INGABE : . Binärbaum vom Typ A USGABE : nichts. N EBENWIRKUNG : Ausgabe des Baumes. M ETHODE : Wir durchlaufen den Baum in inorder-Reihenfolge und drucken die Knoten nacheinander aus.
302
Kapitel 14
"
✍
+#
☞
Imperative Konstrukte
# !#% +!#
– Alg. 14.1 – Als Beispiel drucken wir einen Baum aus: ✍ ☞
!%
Genauso einfach hätten wir den Baum auch in einen String umwandeln können, indem wir die einzelnen Teilstrings hintereinanderhängen.
14.3
Referenzen und Speichervariable
Felder sind am Platz veränderbare Datenstrukturen, in Verbunden können ein eingestellt werden. Auch einige zelne Komponenten nach Bedarf als Stringfunktionen verändern ihr Argument. Für einfache Datentypen können Referenzen auf Werte vereinbart werden. Sie bezeichnen ähnlich wie Zeiger einen Speicherplatz und lassen zu, daß der dort abgelegte Wert sich während des Programmlaufs ändern kann. Man kann Referenzen auch als Speichervariable auffassen, deren Wert durch explizite Dereferenzierung erhalten wird: ✍ ☞
✍
☞
14.3 Referenzen und Speichervariable
✍ ☞ ✍ ☞
#
*
✍ ☞
# #
#! % ✍ # * ☞ ✍ ☞
303
*
Das Beispiel zeigt sowohl die Syntax als auch die Gefahren, die mit solchen Konstrukten eingeschleppt werden. Der Name bezeichnet eine Referenz auf den Wert , dieser wird durch vorange stelltes Ausrufezeichen bestimmt und an gebunden. Der Name verweist auf die gleiche Stelle wie . Die Zuweisung ( ) an verändert also auch ! Trotzdem sind solche durch Zuweisungen beschriebenen Update-Operationen oft nützlich. So kann in einem Baumdurchlauf das Maximum in einer Speichervariablen aufdatiert werden. A L G O R I T H M U S 14.2 M AXIMUM IM B AUM E INGABE : Binärbaum. A USGABE : Maximum. M ETHODE : Verwende Speichervariable: ✍
"
304
Kapitel 14
☞
!
# # * + !#
Imperative Konstrukte
Man beachte hier übrigens die einseitige bedingte Anweisung, die zu
äquivalent ist. Das hätten wir natürlich viel einfacher rekursiv haben können: M ETHODE : Rekursiv: " ✍
☞
# #
)#* +!
– Alg. 14.2 – Oft läßt sich durch Verwenden von Speichervariablen ein Parameter einer Funktion einsparen, z. B. in der Branch & Bound-Funktion aus Abschnitt 8.2 zur Lösung des Rucksackproblems das Optimum. Oder man kann relativ unproblematisch die Anzahl der Funktionsaufrufe bestimmen oder nebenbei andere Größen mitzählen. Wir erweitern dazu die Maximumfunktion um zwei Zähler – einen für die Anzahl der inneren Knoten und einen für die Anzahl der Funktionsaufrufe. Letzterer muß für jedes Argumentmuster erhöht werden: ✍
'
14.3 Referenzen und Speichervariable
☞
%!
305
# # "!# (#
Solche Erweiterungen vermitteln oft zwischendurch Klarheit über gewisse Eigenschaften der Funktion, die so ohne Änderung der Struktur eingeflickt und genau so schnell wieder ausradiert werden können. Nicht zu vergessen ist auch der Speichervorteil. Wir bringen noch einmal die Umwandlung eines Baumes in einen String, diesmal imperativ. A L G O R I T H M U S 14.3 B AUMUMWANDLUNG E INGABE : Binärbaum, ein mit Leerzeichen gefüllter String ausreichender Länge, ein auf gesetzter Zähler. A USGABE : Nichts, aber der Baum wird in Stringdarstellung in den gegebenen String geschrieben. M ETHODE : Zähle die Position mit: " ✍
" '
" '
306
Kapitel 14
"
"
$ # "!#
☞
Imperative Konstrukte
$ # '#
' # !#%
– Alg. 14.3 – Vor dem Aufruf dieses Algorithmus’ sind folgende Initialisierungen vorzunehmen:
*
✍
☞
14.4
!#
Ausnahmen
Das Auslösen einer Ausnahme geschieht durch die -Anweisung, die einen Konstruktoraufruf für eine Ausnahme als Parameter erhält.
Vordefinierte Ausnahmen im Kernmodul
)
sind unter anderem
Darüber hinaus kann der Benutzer nach der gleichen Syntax selbst Ausnahmen definieren:
14.4
# ! ✍ # ! ☞ & ✍ ☞ &
Der Typ
+#
Ausnahmen
307
"#
ist also vom Benutzer erweiterbar.
Ausnahmen führen normalerweise zum Programmabbruch. Sie können aber abgefangen werden. Greifen wir als Beispiel die Potenzierungsfunktion aus Abschnitt 9.3 noch einmal auf: ✍
" "
" '
"
# ## * +!#
☞
%
☞
%
✍ "
! $
$%
Will man das vermeiden und als Ergebnis lieber ausgeben, so kann man natürlich die Fallunterscheidung erweitern. Man kann aber auch die Ausnahme abfangen: ✍
☞
" "
* "
%
"
# ## * +!#
✍ " ☞
#
Das Abfangen von Ausnahmen sollte aber nicht eine korrekte Fallunterscheidung ersetzen, sondern nur zum Vermeiden des Programmabbruchs in Ausnahmesituationen, etwa durch unvorhergesehene Eingabedaten, benutzt werden.
308
Kapitel 14
14.5
Imperative Konstrukte
Schleifen
Wir haben in Abschnitt 2.6 die wichtigsten Schleifenmuster eingeführt und ihre rekursive Implementierung als Funktionen höherer Ordnung besprochen. In imperativen Sprachen sind Schleifen wichtige Strukturierungsmittel. Aus Effizienzgründen sind in CAML LIGHT auch zwei Schleifenanweisungen vorhanden: " % %
%
%
Die erste Schleife wird solange ausgeführt, bis die Bedingung falsch ist, die zweite für alle Werte zwischen und . Als Beispiel für eine while-Schleife bringen wir das in Abschnitt 2.6 schon behandelte Iterationsverfahren zur Bestimmung der Quadratwurzel: ✍
☞ !
%
% "!#
Die zweite Art von Schleifen tritt typischerweise bei der Vektor- und Matrixrechnung auf, etwa bei der Addition zweier Vektoren gleicher Elementanzahl: ✍
"
% "
%
Kapitel 15 Syntax und Semantik 15.1
Formale Darstellung
In den voranstehenden Kapiteln haben wir die Sprache CAML LIGHT informell beschrieben. Zum Abschluß des Buches wollen wir diese Beschreibung etwas stärker formalisieren, um so auch eine schnelle Nachschlagemöglichkeit zu bieten, falls Einzelheiten unklar oder in Vergessenheit geraten sind. Eine Programmiersprache wird wie auch die natürlichen Sprachen durch Syntax und Semantik beschrieben. Zur Syntax gehört die Angabe des Alphabets, des Zeichenvorrats, mit dem Programme geschrieben werden. Zweckmäßigerweise werden hier einige Schlüsselwörter (Wortsymbole) oder Zeichenkombinationen als Einzelzeichen aufgefaßt (z.B. oder ). Regeln, wie aus diesen Zeichen gültige Programme geformt werden, bestimmen dann die Grammatik der Sprache. Um die Grammatik einfach zu halten, werden einige Regeln, z. B. die Überprüfung, ob ein Name schon an einen Wert gebunden ist und welchen Typ dieser Wert hat, bei der Beschreibung von Programmiersprachen nicht zur Syntax, sondern zur statischen Semantik gezählt. Statisch deshalb, weil diese Regeln vom Interpreter oder Übersetzer vor dem Programmablauf geprüft werden und nicht von der Ausführung abhängen. Syntax und statische Semantik sagen noch nicht viel über die Bedeutung eines Programms aus. Es lassen sich syntaktisch korrekte, aber völlig unsinnige Programme formulieren und solche, die nicht ganz den Zweck erfüllen, also fehlerhaft sind. Durch die Grammatik wird auch die Bedeutung der verwendeten Symbole nicht festgelegt, dies geschieht erst durch die Semantik. Man muß z.B. angeben, daß das Zeichen die Addition von Gleitkommazahlen bedeutet, wie
310
Kapitel 15
Syntax und Semantik
diese Approximation an die reelle Addition ausgeführt wird und was im Falle des Überlaufs aus dem vom Rechner darstellbaren Bereich geschieht.
15.1.1
Syntaxdiagramme
Die Syntax wird hier durch Diagramme beschrieben, die wie folgt aufgebaut sind: Jedes Diagramm besitzt einen oder mehrere (grammatikalische) Bezeichner, die unter das betreffende Diagramm in Kursivschrift gesetzt sind. Die Bezeichner benennen das durch das Diagramm dargestellte Sprachelement. Ein Symbol des Alphabets ist in ein Oval, Bezeichner von anderen Diagrammen sind in Rechtecke eingeschlossen. Die Verbindung von Bezeichnern und Symbolen des Alphabets geschieht durch Linien, die in Pfeilrichtung zu durchlaufen sind. Die Diagramme sind mit erläuternden Kommentaren versehen. Sie sollen das semantische Erfassen des betreffenden Sprachelements erleichtern. Durch schematisches Durchlaufen des Diagramms Caml wird ein syntaktisch korrektes Programm erzeugt. Der Durchlauf durch ein Diagramm beginnt am linken oberen Eingang. Die Durchlaufrichtung wird durch die Pfeile angegeben. Der Durchlauf durch ein Diagramm ist beendet, wenn das Diagramm am rechten unteren Ende verlassen wird. Treten bei Durchlaufen eines Diagramms Symbole des Alphabets auf, so werden diese zum Zeitpunkt des Auftretens notiert und der Durchlauf fortgesetzt. Tritt beim Durchlaufen eines Diagramms U ein Diagrammbezeichner W auf (wobei W gleich U zulässig ist), so ist an dieser Stelle das Durchlaufen von U zu unterbrechen, das mit W bezeichnete Diagramm zu durchlaufen und danach der Durchlauf durch das Diagramm U an der Stelle fortzusetzen, an der er unterbrochen wurde. Beim Durchlaufen einer Linie können ein oder mehrere Trennzeichen notiert werden. Trennzeichen sind Leerzeichen, Zeilenwechsel oder Kommentare,
d. h. in und eingeschlossene Zeichenfolgen.
15.1
Formale Darstellung
311
Diese Trennzeichen sind dann zwingend vorgeschrieben, wenn sonst der Sinn verfälscht würde, also immer dann, wenn zwei aufeinanderfolgende Namen oder Konstrukte durch Hintereinanderschreiben ein einziges Konstrukt bilden.
15.1.2
Semantikregeln
Die Semantik bestimmt die Bedeutung des Programms, d. h. sie legt fest, wie der Wert eines Ausdrucks berechnet wird. Das kann durch Termersetzungsregeln beschrieben werden. Dabei werden den einzelnen syntaktischen Konstrukten Werte oder Auswertungsregeln zugeordnet. Die Schreibweise dieser Regeln ist Voraussetzungen Folgerung Sowohl Voraussetzungen als auch Folgerung sind Wertbestimmungen. Eine Wertbestimmung wird als syntaktisches Konstrukt
Wert
geschrieben. Im einfachsten Fall gibt es keine Voraussetzungen Konstante
Konstante
Eine Konstante, die ja als Zeichenkette im Programm notiert ist, wird also zu dem entsprechenden Wert reduziert. Den Wert eines Ausdrucks bestimmt man nun, indem man den Ausdruck in seine syntaktischen Komponenten zerlegt, für jede Komponente den Wert ermittelt und den Wert des Ausdrucks gemäß der Komponentenwerte zusammensetzt. Dabei sind die Komponenten Operanden oder Operatoren. Wenn den Wert und den den Wert hat und beide vom Typ sind, hat Wert . Hier ist das erste ein Symbol, während das zweite die ganzzahlige Addition bezeichnet.
Ähnliche Regeln lassen sich für jedes Konstrukt angeben, z. B. für den bedingten Ausdruck oder den Funktionsaufruf. Um die Semantik eines Programms zu
312
Kapitel 15
Syntax und Semantik
bestimmen sind diese Regeln nach und nach solange anzuwenden, bis eine Reduktion auf einen Wert vollzogen ist. Dies ist ein recht formaler Vorgang. Viele der so niedergeschriebenen Regeln sind offensichtlich und auch die anderen können wir umgangssprachlich klar und unmißverständlich formulieren. Deshalb verzichten wir möglichst auf diese formalen Regeln und geben die Semantik umgangssprachlich an. Auch die korrekte Typisierung gehört zur Semantik. Wir erläutern die wichtigsten Typregeln bereits bei den Syntaxdiagrammen durch Kommentare.
15.2
Syntaxdiagramme und informelle Semantik
15.2.1
Namen, Konstanten und Operatoren
Die einfachen, grundlegenden Konstrukte wie Namen und Konstanten werden durch die Syntaxdiagramme 15.1 und 15.2 beschrieben.
#
Syntaxdiagramm 15.1: Name
15.2 Syntaxdiagramme und informelle Semantik
313
Int
Float
Char
String
Syntaxdiagramm 15.2: Konstante
Bei Namen wird zwischen Groß- und Kleinbuchstaben unterschieden. Namen werden als Wertbezeichner, wozu auch die Funktionsnamen gehören, als Konstruktoren, Typnamen und noch an weiteren Stellen eingeführt. Operatornamen dienen zur Einführung von infix oder präfix aufzurufenden Operatoren, können aber auch für andere Zwecke mißbraucht werden.
314
Kapitel 15
Syntax und Semantik
Die einzelnen Standardoperatoren erläutern wir in Tabelle 15.1 auf Seite 320. B EISPIEL 15.1 Beispiele für die Benutzung von Namen sind etwa: ✍
"
%
Normale Namen können auch Operatoren bezeichnen, sie werden mit geführt:
#
✍
15.2.2
Programm
) Syntaxdiagramm 15.3: Programm
ein-
15.2 Syntaxdiagramme und informelle Semantik
315
Ein Programm (Diagramm 15.3) besteht aus einer Folge von Direktiven, Vereinbarungen und Ausdrücken. Die Vereinbarungen führen neue Typen oder Ausnahmen ein, oder sie definieren Konstanten oder Funktionen. Die Bindung eines Namens an seinen Wert bleibt bis zur nächsten Vereinbarung für den gleichen Namen aufrecht erhalten.
15.2.3
Ausdruck
Die ganze Funktionalität des Programms ist letzlich die Auswertung eines Ausdrucks nach Abarbeitung vorausgegangener Vereinbarungen. Auch solche Konstrukte wie Fallunterscheidungen oder Anweisungsfolgen werden deshalb als Ausdrücke aufgefaßt. Wir haben die einzelnen Bestandteile nach fallender Auswertungsreihenfolge gruppiert. Da Operatoren ziemlich frei neu definiert werden können, treffen die gewählten Namen nur für den Normalfall zu. Wir beginnen den Aufbau eines Ausdrucks mit dem eines Operanden in Diagramm 15.4.
Syntaxdiagramm 15.4: Operand
Im einfachsten Fall ist ein Operand eine Konstante ✍
oder ein Standardkonstruktoraufruf, dargestellt in Diagramm 15.5.
316
Kapitel 15
Array
Liste
Syntax und Semantik
Stream
Verbund
Syntaxdiagramm 15.5: St_Konstruktor
Beispiele hierfür währen:
✍
Außerdem sind geklammerte Ausdrücke Operanden, oder es kann ein einstelliger Operator davorgesetzt werden:
✍
"
Der Zugriff auf Komponenten folgt in Diagramm 15.6. Durch den unteren leeren Zweig wird gewährleistet, daß eine Komponente im einfachsten Fall ein Operand ist. So bauen wir den allgemeinen Ausdruck Stufe für Stufe: ✍
15.2 Syntaxdiagramme und informelle Semantik
317
Array
String Verbund
Syntaxdiagramm 15.6: Komponente
Ein Ausdruck kann auch ein Aufruf einer eigenen oder einer Standardfunktion sowie ein Aufruf eines selbstdefinierten Konstruktors sein (Diagramm 15.7). Hier beachte man die einfache Trennung von Funktionsnamen und Argumenten durch Leerzeichen und daß ein Funktionsname auch ein Ausdruck sein kann, also Ergebnis einer Berechnung.
"
Funktion
%"
Parameter
Syntaxdiagramm 15.7: Funktionsaufruf
B EISPIEL 15.2 Funktionsaufrufe sind:
✍
Ein unärer Operator bindet am stärksten. Ansonsten erfolgt die Anwendung von links nach rechts.
✍
)
318
Kapitel 15
Syntax und Semantik
Die Semantik eines Funktionsaufrufes, wozu auch die Komponentenzugriffe, Konstruktoraufrufe und Operatoranwendungen gehören, ist folgendermaßen definiert. Wir können uns auf einstellige Funktionen beschränken. Der Typ der Funktion und des Argumentes wird bestimmt. Falls der Argumenttyp gleich dem Definitionsbereich ist, ist der Ergebnistyp gleich dem Wertebereich, sonst erfolgt eine Fehlermeldung zur Übersetzungszeit. Die Funktion und das Argument werden ausgewertet. Standardoperatoren werden auf den Argumentwert angewendet und liefern so den Ergebniswert. Bei selbstdefinierten Funktionen wird im Rumpfausdruck der formale Argumentname durch den aktuellen Argumentwert ersetzt und dann der Rumpf ausgewertet. Vor dieser Ersetzung sind alle gebundenen Vorkommen des formalen Argumentnamens, d. h. solche in Funktionsausdrücken oder lokalen Vereinbarungen, konsistent zu ersetzen. Diese Betrachtungsweise gilt auch für rekursiven Funktionsaufruf. Sie entspricht dem Abrollen der Funktion, bis kein rekursiver Aufruf mehr erfolgt. Einmaliges Abrollen entspricht dem Lösen der Fixpunktgleichung, die die rekursive Funktion bestimmt, durch Einsetzen. B EISPIEL 15.3 Die Wirkung eines Funktionsaufrufes sieht man an:
" "
"
"
+
" +
✍
"
15.2 Syntaxdiagramme und informelle Semantik
"
319
In einem Ausdruck werden nun, wenn die Operanden feststehen, die Operatoren nach ihrer Rangfolge aufgerufen. Wir deuten dies durch die verschiedenen Diagramme an, die nach fallender Rangfolge auftreten.
%"
)
Syntaxdiagramm 15.8: Ari_Ausdruck
Die Prioritäten der Operatoren eines arithmetischen Ausdrucks in Diagramm 15.8 entsprechen der üblichen Rangfolge. Operatoren gleicher Priorität werden von links nach rechts ausgeführt. Verschiedene arithmetische Typen dürfen nicht gemischt werden.
✍
*
Tabelle 15.1 auf Seite 320 zeigt die wichtigsten Standardoperatoren mit ihrer Be deutung. Die arithmetischen Operatoren, im Diagramm 15.8 als bezeichnet, bilden die ersten beiden Blöcke. In Ausdrücken dürfen ganze Zahlen und Gleitkommazahlen nicht gemeinsam auftreten.
Eine Erweiterung läßt zu, daß der Benutzer neue Operatorsymbole definieren kann. Dabei beginnen präfix-Operatoren mit oder , infix-Operatoren mit einem vorhandenen Operatorsymbol oder mit , dann folgen Kombinationen beliebiger Operatorzeichen: ✍
'
320
Kapitel 15
Operator
(infix)
(prefix)
(infix) (prefix)
"
Syntax und Semantik
Bedeutung Addition von ganzen Zahlen Subtraktion von ganzen Zahlen Negation von ganzen Zahlen Multiplikation von ganzen Zahlen Division von ganzen Zahlen Modulo von ganzen Zahlen Addition von Gleitkommazahlen Subtraktion von Gleitkommazahlen Negation von Gleitkommazahlen Multiplikation von Gleitkommazahlen Division von Gleitkommazahlen Listenkonkatenation Stringkonkatenation Test auf strukturelle Gleichheit Test auf strukturelle Ungleichheit Test auf physikalische Gleichheit Test auf physikalische Ungleichheit Test auf »kleiner« von ganzen Zahlen Test auf »kleiner gleich« von ganzen Zahlen Test auf »größer« von ganzen Zahlen Test auf »größer gleich« von ganzen Zahlen Test auf »kleiner« von Gleitkommazahlen Test auf »kleiner gleich« von Gleitkommazahlen Test auf »größer« von Gleitkommazahlen Test auf »größer gleich« von Gleitkommazahlen Negation logisches »Und« logisches »Oder« Tabelle 15.1: Die wichtigsten Standardoperatoren
15.2 Syntaxdiagramme und informelle Semantik
321
Die Rangfolge entspricht bei arithmetischen Operatoren dem Anfangssymbol. Allerdings wird für Exponetiationsoperatoren, die mit beginnen, eine eigene Prioritätsstufe eingerichtet. Alle anderen infix-Operatoren weisen die gleiche Priorität wie die Vergleichsoperatoren auf.
Syntaxdiagramm 15.9: Listenausdruck
Listen oder Strings können, wie in Diagramm 15.9 zu sehen, aus arithmetischen Ausdrücken gebildet werden. Man beachte bei Listen, daß die mit abgesetzten Ausdrücke vom Elementtyp sind.
✍ +
Syntaxdiagramm 15.10: Vergleich
Die Standardvergleichsoperatoren in einem Vergleich (Diagramm 15.10) sind für alle Typen anwendbar, deren Anordnung arithmetisch oder lexikographisch definiert ist: ✍ +
"
Man unterscheidet zwischen physikalischer und wertmäßiger Gleichheit. Erstere ist besonders für veränderliche Strukturen interessant. Sie können nämlich wertmäßig gleich sein, aber andere Speicherplätze belegen. Die logischen Operationen »und« und »oder« (Diagramm 15.11) arbeiten als Kurzschlußversion, d. h. es wird nur bei Bedarf der zweite Operand ausgewertet:
322
Kapitel 15
"
Syntax und Semantik
"
Syntaxdiagramm 15.11: Logischer_Ausdruck
Sie sind nicht neu definierbar.
Syntaxdiagramm 15.12: Tupel
Wegen der geringen Priorität des Kommas sind Tupel (Diagramm 15.12) fast immer zu klammern. Bei Anwendung einer Funktion auf ein Tupel werden alle Komponenten ausgewertet, auch wenn die Funktion nicht alle braucht. Gleiches gilt bei Listen. ✍
Syntaxdiagramm 15.13: Bedingter_Ausdruck
Die in einem bedingten Ausdruck (Diagramm 15.13) auf und folgenden Ausdrücke müssen von gleichem Typ sein. Hier wird zuerst der logische Ausdruck ausgewertet und dann je nach seinem Wert der relevante Ausdruck:
✍
Auch die mehr imperativen Konstruktionen können, wie in Diagramm 15.14 zu sehen, als Ausdrücke aufgefaßt werden. Der Wert einer Anweisung ist immer die
leere Konstante vom Typ .
15.2 Syntaxdiagramme und informelle Semantik
Syntaxdiagramm 15.14: Anweisung 323
324
Kapitel 15
Syntax und Semantik
Syntaxdiagramm 15.15: Folge
"
Syntaxdiagramm 15.16: Ausdruck
Der Wert einer Folge (Diagramm 15.15) ist der des letzten Ausdrucks, und ein Ausdruck ist schließlich durch Diagramm 15.16 gegeben.
15.2 Syntaxdiagramme und informelle Semantik
325
Ein arithmetischer Ausdruck ist als einfaches Tupel auch eine Folge und deshalb ein Ausdruck. Wie schon mehrfach betont, sind auch Funktionen normale Werte und deshalb Ausdrücke. Des weiteren treten lokale Wertbindungen, allgemeine Fallunterscheidungen und Ausnahmebehandlung auf.
15.2.4
Muster
Muster treten bei der Wertbindung auf der linken Seite und beim Ausfiltern von Funktionsargumenten oder in einem -Ausdruck auf. Wir unterscheiden zwischen einfachen (Diagramm 15.17), normalen (Diagramm 15.18) und Datenstrommustern (Diagramm 15.19).
beliebiges Muster
Typfestlegung ( ) : unit
neuer Name
Listenmuster
Verbundmuster Intervall
Syntaxdiagramm 15.17: Einfach_Muster
Ein Muster, das auf der linken Seite einer Wertbindung (Diagramm 15.20) steht, sollte einen ungebundenen Namen enthalten. Die obere Zeile führt die drei verschiedenen Funktionsvereinbarungen zusammen. Der Ausdruck bezeichnet entweder den Rumpfausdruck der neuen Funktion, oder diese wird als Funktionsausdruck angegeben.
326
Kapitel 15
"
Syntax und Semantik
Aliasname Kopf und Schwanz
Tupel
Konstruktor
Konstanten
Syntaxdiagramm 15.18: Muster
"
Syntaxdiagramm 15.19: Stream_Muster
Die untere Zeile beschreibt die Wertbindung an ein Muster:
" "
"
✍
* '
Syntaxdiagramm 15.20: Wertbindung
15.2 Syntaxdiagramme und informelle Semantik
15.2.5
Typen
327
Syntaxdiagramm 15.21: Typvereinbarung
Eine Typvereinbarung (Diagramm 15.21) führt einen neuen Namen als Abkürzung für einen mit den Typoperatoren für kartesisches Produkt und für Funktionstyp gebildeten Typ ein, oder sie definiert einen neuen Verbund- oder Variantentyp.
Typausprägung
Funktion kartesisches Produkt
Syntaxdiagramm 15.22: Typ
In einem Typ (Diagramm 15.22) beginnen die Namen von Typvariablen mit ei nem . Die im Kern vorhandenen Typkonstruktoren heißen für Listen und . Von den " für Vektoren. Bei Hinzunahme der Datenströme folgt noch Typoperatoren bindet stärker als , es gilt also
Für einen Verbund (Diagramm 15.23) werden die Komponentennamen eingeführt
328
Kapitel 15
Syntax und Semantik
Syntaxdiagramm 15.23: Verbund_Typ
Syntaxdiagramm 15.24: Varianten_Typ
und deren Typ festgelegt. Für eine Variante (Diagramm 15.24) werden die Konstruktoren und die Typen ihrer Argumente angegeben. Mehrere Argumente müssen als Tupel geklammert werden. Typen können mit einer oder mehreren Typvariablen parametrisiert werden, diese sind bei der Ausprägung durch existierende Typen zu ersetzen.
15.2.6
Ausnahmebehandlung
Syntaxdiagramm 15.25: Ausnahme_Definition
Eine Vereinbarung einer Ausnahme (Diagramm 15.25) erweitert den Typ .
-Ausdruck (Diagramm 15.16) dient zum Abfangen einer Ausnahme. Löst Ein stehenden Ausdrucks eine Ausnahme aus, so wird die Berechnung des hinter diese gegen die angegebenen Muster gefiltert und als Ergebniswert der entsprechende Ausdruck geliefert. So läßt sich nach einer Erweiterung der ganzen Zahlen auch die Division durch berechnen, ohne daß die normale Division durch eine zusätzliche Abfrage langsamer wird: ✍
15.2 Syntaxdiagramme und informelle Semantik
☞
#"+
✍
☞
15.2.7
329
+
#" ###""# * +!#
Module
Im Schnittstellendefinitionsteil (Diagramm 15.26) eines Moduls werden die exportierten Größen angegeben. Das geschieht in der Regel durch eine abstrakte Typvereinbarung und durch eine spezielle Exportwertvereinbarung (Diagramm 15.27), die nur die Schnittstelle der Funktionen bzw. Werte angibt.
Syntaxdiagramm 15.26: Schnittstellen_Definition
Syntaxdiagramm 15.27: Wert_Deklaration
✍
# *
Der Implementationsteil entspricht dem Diagramm 15.3.
Anhang A Das Caml Light-System In diesem Anhang wollen wir einen kurzen und zusammenfassenden Überblick über das CAML LIGHT-System und dessen Werkzeuge geben. CAML LIGHT wurde am französischen Institut INRIA in Rocquencourt hauptsächlich von X AVIER L EROY und D AMIEN D OLIGEZ entwickelt. Die Arbeit am System wurde 1996 abgeschlossen, so daß es jetzt in der stabilen und endgültigen Version 0.7 vorliegt. Der dort festgelegte Sprachumfang bildet die Grundlage der Beschreibung in diesem Buch.
A.1
Interpreter
Der Interpreter, auch Toplevel-System, ist die in diesem Buch verwendete Schnittstelle zum CAML LIGHT-System. Er ist vor allem zum Entwickeln und Testen von Programmteilen (Funktionen) geeignet, läßt sich aber auch – wie durch dieses Buch hoffentlich klar wird – hervorragend zum Lernen und Lehren der Sprache einsetzen. Vorteile gegenüber einem Compiler sind zum Beispiel die direkte Auswertung der Eingaben, die Möglichkeit des sofortigen Tests und eventueller Änderungen, ohne das System verlassen zu müssen und erweiterte Fähigkeiten zur Fehlerauffindung und -behebung durch »Nachzeichnen« (engl. trace) von Funktionen. Das Toplevel-System arbeitet in einer sogenannten »read-eval-print-Schleife«. Dabei werden die Eingaben (auch Phrasen genannt) des Benutzers gelesen, auf Typkorrektheit getestet, in die Sprache einer internen abstrakten Maschine übersetzt und von dieser ausgeführt. Als Rückmeldung vom System erscheint der Typ der eingebenen Phrase und das Ergebnis ihrer Auswertung.
332
Anhang A
Das Caml Light-System
Als CAML LIGHT-Phrase kommen alle im Diagramm 15.3 aus Kapitel 15 erwähnten Konstrukte in Frage. Da diese der Implementierung eines Moduls entsprechen, werden sie – und damit auch alle Definitionen – als Realisierung des sog. -Moduls behandelt.
A.1.1
Aufruf und Optionen
Der Aufruf des Systems erfolgt (teilweise systemabhängig) durch den Befehl
wobei eckige Klammern hier »optional« bedeuten. Durch das optionale Argu ment Toplevel kann eine mit erzeugte, an spezielle Bedürfnisse angepaßte Toplevel-Bibliothek geladen werden. Mögliche Optionen sind:
Diese Option startet den Interpreter in einem speziellen FehlerbehebungsModus (engl. debugging mode). Dies hat insbesondere Auswirkungen auf die Sichtbarkeit von nicht im Interface erscheinenden Werten eines Moduls. Diese können nachgezeichnet und ausgegeben werden.
Verzeichnisse Die mit dieser Option eingeführten Verzeichnisse werden bei der Anforderung von Modulen nach der entsprechenden übersetzten Schnittstelle des Moduls durchsucht. Ländercode Hierdurch werden alle Meldungen des Interpreters statt in englisch in der durch Ländercode bezeichneten Sprache ausgegeben. Möglichkeiten sind hier für französisch, für spanisch und für deutsch. Die Meldungen in diesem Buch wurden durch den Aufruf
erreicht. Modulauswahl Dadurch wird die Art der automatisch zu öffnenden Module festgelegt. lädt die Es kann zwischen drei verschiedenen gewählt werden: Standardauswahl. Für einige Module, z. B. für Gleitkommazahlen und Vektoren, gibt es Versionen, die auf das Abfangen von Laufzeitfehlern verzich ten. Diese werden durch geöffnet. " unterdrückt alle Module.
A.1 Interpreter
A.1.2
333
Steuerfunktionen
Durch das standardmäßig geladene Modul werden die folgenden Funktionen zur Kontrolle des Toplevel-Systems, zur Behandlung von Quelldateien und zur Steuerung der Fehlerbehandlung bereitgestellt:
Verlassen des Interpreters.
Lesen und Auswerten einer Quelltextdatei. Die Phrasen erscheinen dabei, als ob sie im Modul eingegeben wurden.
Diese Funktion entspricht . Es wird aber ein neues Modul (mit dem gegebenen Namen) angelegt, in dem die Phrasen eingegeben werden.
Damit wird eine gegebene Quelldatei vom Compiler übersetzt.
"
Laden einer bereits übersetzten Datei in den Interpreter.
Nachzeichnen einer Funktion.
"
Beenden des Nachzeichnens der Funktion. " "
Schaltet mit die Systemmeldungen an und mit ab. wird eine sog. Printerfunktion installiert. Durch Mit dieser Funktion werden die Werte ihres Argumenttyps vom System als Meldung nach einer Eingabe an den Benutzer ausgegeben.
Entfernt eine Printerfunktion.
Begrenzt die Tiefe der Ausgabe des Toplevel-Systems in den Meldungen.
Begrenzt die Länge der Ausgabe des Toplevel-Systems in den Meldungen.
"*
Schaltet den bereits bei den Optionen erwähnten Fehlerbehebungsmodus an und ab (Option ).
334
Anhang A
Das Caml Light-System
Wechselt das aktuelle Verzeichnis.
"
"
Diese Funktion entspricht der Option
A.2
.
Compiler
Der Compiler dient dem nicht-interaktiven Übersetzen von Modulschnittstellen und Modulimplementierungen (Programmen) und dem Binden dieser dann im Format der abstrakten Maschine vorliegenden Teile zu einer ausführbaren Einheit. Die übersetzten Programme liegen also nicht im Maschinenformat des jeweiligen Computers, sondern in einem systemunabhängigen Bytecodeformat vor, (meist automatisch und für den Aufruwelches dann vom Programm fer unsichtbar) ausgeführt werden kann. Die Vorteile der Systemunabhängigkeit und leichten Portierbarkeit erkauft man sich allerdings mit Einbußen in der Ausführungsgeschwindigkeit. Es gibt aber bereits Weiterentwicklungen der Sprache CAML LIGHT (zum Beispiel OBJECTIVE CAML), die einen »echten« Compiler besitzen.
A.2.1
Aufruf und Optionen
Der Compiler wird mit dem Kommando
für ein oder mehrere Eingabedateien aufgerufen. Diese werden anhand ihrer Endung vom Compiler wie folgt identifiziert:
wird die übersetzte Version
Modulschnittstelle. erzeugt.
Modulimplementierung. Eine Datei wird in die Bytecode-Version % übersetzt. Existiert eine Datei , erfolgt eine Konsistenz . Die Schnittstelle muß also immer vor prüfung der Schnittstelle der Implementierung übersetzt werden. Existiert kein Interface , erzeugt, das alles exportiert. wird automatisch ein
Aus einer
A.2
%
Compiler
335
Bytecode-Datei. Diese Dateien werden zum erzeugten Code zusammen mit der Standardbibliothek gebunden.
Als Optionen sind gültig:
Es wird nur übersetzt und nicht gebunden. Durch diese Option ist es möglich, Module separat zu übersetzen, ohne ein ausfürbares Programm zu erzeugen.
gebunden. Zum erzeugten Programm wird das Laufzeitsystem Dadurch wird das Programm zwar größer, kann aber ohne ein installiertes ausgeführt werden. Diese Option erlaubt außerdem das Einbinden von C-Code. Optionen Falls im -Modus gebunden wurde, werden die Optionen an den CCompiler bzw. Linker weitergegeben.
Dateien
Diese Option bearbeitet die durch Leerzeichen getrennte Liste von Dateien so, als ob sie als Argumente des Compilers erschienen wären.
Zum erzeugten Code werden zusätzliche Informationen zur Fehlerlokalisierung und -behebung durch den Debugger generiert.
Diese Option weist den Compiler an, die abgeleiteten Typen der definierten Werte in die Standardausgabe zu schreiben. Da dies in der gleichen Syntax wie eine Schnittstellenbeschreibung geschieht, kann diese Option zur Erzeugung einer solchen benutzt werden.
Verzeichnisse Entspricht der gleichen Option des Interpreters. Die zu durchsuchenden Direktive eingeVerzeichnisse können aber auch mit Hilfe der " stellt werden. Ländercode Die Meldungen des Compilers erscheinen in der entsprechenden Sprache (siehe Beschreibung des Interpreters).
) Datei
Festlegung des Namens der durch das Binden entstehenden, ausführbaren Datei. Module Entspricht der gleichen Option des Interpreters.
336
Anhang A
Das Caml Light-System
Zum erzeugten Code werden zusätzliche Informationen zur späteren Be handlung durch den Profiler generiert.
Zeigt die Versionsnummer des Compilers an.
Es werden extra Warnungen ausgegeben.
A.3
Sonstige Werkzeuge
Außer dem Interpreter und dem Compiler werden noch folgende Werkzeuge mit CAML LIGHT geliefert:
Dieses Programm ermöglicht das Erzeugen spezieller Toplevel-Bibliotheken.
Das Laufzeitsystem enthält die abstrakte Maschine, die den erzeugten Bytecode ausführt.
Mit diesem Tool können mehere Module in eine Datei – eine Bibliothek – gepackt werden.
und Diese Compilerbauwerkzeuge erlauben die Generierung von Scannern und Parsern.
Der Debugger ermöglicht durch Schrittweise Ausführung und spezielle Kommandos die Fehlersuche in komplexen Programmen.
Dieses Werkzeug führt ein Programm aus und erzeugt eine kommentierte Quellcodedatei mit Informationen über die Anzahl der einzelnen Funktionsaufrufe.
Außer den eben beschriebenen existieren noch andere Tools und Bibliotheken, die zum Beispiel die Einbindung von TCL und TK zur Erzeugung von graphischen Benutzeroberflächen unter dem X WINDOW-System, oder die Anbindung des Interpreters an den bekannten UNIX Editor EMACS ermöglichen. Eine ausführliche Diskussion würde aber den Rahmen diese Buches sprengen. Wir verweisen deshalb auf die zahlreich vorhandenen Dokumentationen zu diesen Hilfsmitteln.
A.4 Verfügbarkeit
A.4
337
Verfügbarkeit
Das komplette CAML LIGHT-System und viele Erweiterungsmodule kann für privaten Gebrauch kostenlos vom Server der INRIA geladen werden. Es gibt unter anderem Versionen für LINUX, DOS, MS-WINDOWS und MACINTOSH-Systeme. Die Adresse des Servers ist
% %
Er wird außerdem auf den Servern
% " "
% "
gespiegelt. Mehr Informationen über CAML LIGHT findet man im WWW unter %
Anhang B Beschaffung der Beispiele Die Beispielprogramme dieses Buches können im WWW unter
vom Verlag, oder unter
"
vom Autor kostenlos bezogen werden. Sie sind außerdem auf dem
im Verzeichnis
erhältlich.
-Server
Anhang C Verzeichnis der Algorithmen 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 1.11 1.12 1.13 1.14
Nußkuchen-1 Summe-1 Summe-2 Nußkuchen-2 Summe-3 Absolutbetrag Tausch, funktional Tausch, imperativ modulo ganzzahlige Division ggT Schablone Signum Maximum einer Liste
16 16 17 19 19 23 25 25 27 28 30 31 34 35
2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 2.10 2.11 2.12 2.13 2.14
Liste quadrieren Polynomaddition Addition Multiplikation Sortieren durch Einfügen Einfügen in sortierte Liste Horner Schema Sortieren durch Auswahl Sortieren durch Mischen Binäre Suche Divide & Conquer Karatsuba Multiplikation Quadratwurzel, while Quadratwurzel, repeat
51 52 56 58 60 61 64 66 66 67 68 73 78 80
342
Anhang C
Verzeichnis der Algorithmen
4.1 4.2 4.3 4.4 4.5 4.6 4.7 4.8 4.9 4.10 4.11 4.12 4.13
Konkatenation Umdrehen, rekursiv Umdrehen, divide & conquer Umdrehen mit Sammelliste Einfügen in ein Feld Suche im Feld Löschen an vorgegebener Position Listenlänge Suche und Elementzugriff in Liste Einfügen und Löschen in Liste Stackoperationen Klammerschachtelung Schlangenoperationen
103 104 105 107 110 110 111 115 116 116 120 120 122
5.1 5.2 5.3 5.4 5.5 5.6 5.7 5.8
Sortierverfahren Sortieren durch Auswahl Sortieren durch Einfügen Bubblesort Verschmelzen zweier Listen Sortieren durch Mischen Sortieren durch direktes Mischen Quicksort
125 126 128 130 132 134 136 139
6.1 6.2 6.3 6.4 6.5 6.6 6.7 6.8 6.9 6.10 6.11 6.12 6.13 6.14 6.15
Zugriffsfunktionen für Binärbaum Auswertung eines arithmetischen Ausdrucks Höhe von Binärbäumen Breitendurchlauf Tiefendurchlauf Versickern Einfügen in Heap Löschen des Minimums Heapsort Suchen im Baum Einfügen in einen Suchbaum Löschen aus einem Suchbaum Einfügen in einen AVL-Baum Löschen in einem AVL-Baum Splay
144 146 149 151 152 155 156 159 161 163 163 164 169 172 175
Anhang C Verzeichnis der Algorithmen
343
6.16 6.17 6.18 6.19
Wörterbuchoperationen mit Splay Suche in 2-3-4-Baum Einfügen in 2-3-4-Baum Löschen mit Vorausschau
7.1 7.2
Wörterbuchoperationen für offene Hashtabelle 192 Wörterbuchoperationen innerhalb Hashtabelle 196
8.1 8.2 8.3 8.4 8.5 8.6 8.7 8.8 8.9 8.10 8.11
Ariadne Wegesuche Alle Wege Suche optimalen Weg Potenzmenge Rucksack 1 Rucksack 2 Branch & Bound SOS Wegesuche mit Markierung Greedy
9.1
Potenzierung, divide & conquer 247
12.1 12.2
d&c_halb 281 d&c_2hal 284
14.1 14.2 14.3
Binärbaum Ausdrucken 301 Maximum im Baum 303 Baumumwandlung 305
178 181 184 186
202 206 209 209 211 213 215 216 217 219 221
Anhang D Literatur T. H. C ORMEN , C. E. L EISERSON , R. L. R IVEST: Introduction to Algorithms The MIT Press, Cambridge, 1990. G. C OUSINEAU , M. M AUNY: Approche fonctionnelle de la programmation Ediscience international, Paris, 1995. B. M AC L ENNAN: Functional Programming. Practice and Theory Addison-Wesley, Reading (Mass.), 1990. T. O TTMANN , P. W IDMAYER: Algorithmen und Datenstrukturen, 2. Auflage BI-Wiss.-Verl., Mannheim, 1993. L. C. PAULSON: ML for the Working Programmer Cambridge University Press, Cambridge, 1992. R. R ICHTER , P. S ANDER , W. S TUCKY: Problem–Algorithmus–Programm Teubner, Stuttgart, 1993. P. T HIEMANN: Grundlagen der funktionalen Programmierung Teubner, Stuttgart, 1994. H. WALDSCHMIDT, E. H. WALDSCHMIDT, H. K. WALTER: Grundzüge der Informatik BI-Wiss.-Verl., Mannheim, 1993. N. W IRTH: Algorithmen und Datenstrukturen in Modula-2, 4. Auflage Teubner, Stuttgart, 1986.
Index -Funktion
249 253 2-3-4 Bäume 180
-Funktion
Abrollen 50 -Funktion 24 abstrakter Datentyp 98 Abstraktion 22, 233 -Funktion 56 A DELSSON -V ELSKIJ 167 A L -H WÂRIZMÎ (um 825) 15 Algorithmenentwurf 20, 31 Algorithmus 15, 20 Beschreibung durch Pseudocode 32–36 graphisch 36–38 verbale 32 funktionaler 22 imperativer 24 Merkmale 17 Aliasnamen 239 Alphabet 309 Anweisung 244, 299, 322 Anweisungsfolge 301 A RIADNE 201 arithmetischer Ausdruck 230, 319 Array 90 " -Funktion 256 Aufruf einer Funktion 234 Aufwand 41 Ausdruck 22, 228, 324 arithmetischer 319 bedingter 236, 322 Listen- 321 Ausfiltern von Mustern 239 ausgeglichen 167 Ausgeglichenheitsbedingung 167 ausgezeichnetes Element 140
Ausnahme 245, 306 Abfangen einer 307 Ausnahmedefinition 328 AVL-Baum 167 B-Baum 180 Backtracking 203 Balance 167 -Modul 296 bedingter Ausdruck 236, 322 Belegungsfaktor 190 -adische Darstellung 53 -Funktion 151 Binärbaum 143 binäre Suche 67, 283 binärer Suchbaum 162 Bindung lokale und globale 231 B INET (1786–1856) 77 Blatt 143, 145 " -Modul 291 bottom-up-Entwurf 62 Branch & Bound-Verfahren 216 Breitendurchlauf 151 Bruchteil-Rucksackproblem 221 130 -Funktion -Funktion 131 Bubblesort 129–132 -Modul 290
-Funktion 56
-Funktion 104 -Funktion 256
Compiler 331, 334 C URRY (1900–1982) 273 Curry-Version 240, 273 Datei 299
Index
Datenflußdiagramm 36 Datenkapselung 287 Datenstruktur 86 Datentyp 87 Definitionsbereich 264 Dereferenzierung 302 Differenzmenge 253 -Funktion 137 disjunkte Vereinigung 92 -Funktion 28 -Funktion 134 Divide & Conquer 66 Division mit Rest 26–29 -Funktion 276 -Funktion 278 D OLIGEZ, D AMIEN 331 Durchschnitt 253 einfache Endrekursion 50 einfaches Muster 325 Entscheidungsbaum 211 -Modul 290 E UKLID (um 300 v.Chr.) 29 -Funktion 147 -Modul 306 -Funktion 252 -Funktion 279 Feld 90, 257 mehrdimensionales 259
-Funktion 75, 77 F IBONACCI (1170–1240) 75 FIFO-Prinzip 121
-Funktion 140 Fixpunkt 50 -Funktion 279 -Modul 291 Folge 73, 324 -Funktion 279 Funktion 22, 23 funktionale Dekomposition 57
347
Funktionen höherer Ordnung 82, 274 Funktionsaufruf 317 Funktionsvereinbarung 22, 233 ganzzahlige Division 26 G AUSS (1777–1855) 48 gestreute Speicherung 189 ggT 29 -Funktion 30 Gleitkommazahlen 229 globale Bindung 232 Grammatik 309 Greedy-Auswahleigenschaft 222 größter gemeinsamer Teiler 29–30
Halbierungsmuster 281 Hashadresse 189 Hashfunktion 189 primäre 190 Hashtabelle 189 -Modul 294 -Funktion 251 Heap 154 -Funktion 161 Heapsort 160–161 -Funktion 149 Höhe 149 höhenbalanciert 167 " -Funktion 64 H ORNER (1786–1837) 64
-Funktion 251 infix-Schreibweise 23, 119
" -Funktion 152 Inorder 152
-Funktion 61
-Funktion 61, 128
-Modul 291 Interpreter 331
" -Funktion 253
348
-Modul 293, 299, 300
-Funktion 277
-Funktion 278
Iterator 276
Kanal 300 K ARATSUBA 72 kartesisches Produkt 263 Keller 118 Knoten 143 Kollision 189 Komplexität 41 Komponente 316 Konstanten 228, 312 Konstantendefinition 229, 232 Konstruktor 92 Kopf 249 Korrektheit 38, 50 Labyrinth 201 L ANDIS 167 Laufzeit 41 L EROY, X AVIER 331 LIFO-Prinzip 118 -Modul 292 -Funktion 277 -Funktion 278 -Funktion 252 Liste 102, 249 Listenausdruck 321 Listenlänge 102 lokale Bindung 231
-Funktion 276 -Modul 294–296 -Funktion 278 -Funktion 252 -Funktion 137 -Funktion 133 -Funktion 137 Mergesort 132–139
Index
direktes Mischen 136 natürliches Mischen 138 rekursives Mischen 133 M INOTAURUS 201 Modul 287 Implementierung 329, 334 Schnittstelle 329, 334 -Funktion 27 -Funktion 58 Multimenge 266 Muster 35, 237, 325 einfaches 325 Stream- 325 Nachfolgerfunktion 22 Namen 230, 312 -Funktion 139 Niveaus 150
-Notation 41 Objekt 24, 91 Operand 315 Operatorvereinbarung 242 optimale Unterstruktur 222 Optionstyp 296 Ordnung eines Baumes 148 Paar 88, 255 -Modul 292 Paradigma 21 pattern matching 35 Phrase 331 physikalische Gleichheit 290 Pivotelement 140 Pointer 94 " -Funktion 63 Polynom 52 postfix-Notation 119 " -Funktion 152 Postorder 152 " -Funktion 211
Index
-Funktion 152
Preorder 152 Prioritätswarteschlange 153 Programm 315 funktionales 23 imperatives 24 Programmentwicklungsprozeß 21 Programmiersprache 21 funktionale 21 Pseudocode 19, 21, 32
-Funktion 140 293 Queue 121 Quicksort 139–142, 285 -Modul
% -Modul 297 " -Funktion 134
Record 91, 265 Reihung 90 Rekursion 24 primitive 39 -Funktion 253 Robustheit 40 -Funktion 214, 215 Rucksackproblem 210
Schablone 274 Schlange 121 Schlüsselwort 309 Schnittstelle 287 Schnittstellendefinition 329 schrittweise Erweiterung 65 schrittweise Verfeinerung 22, 57 Schwanz 249 Seiteneffekt 299 -Funktion 66, 126 Semantik 309 -Modul 295, 296 Sichtbarkeitsbereich 231 % -Funktion 155
349
-Funktion 35 Signatur 98 Sohn 145 Sondierung 194 lineare 195 Sondierungsfunktion 194 Sortieren allgemein 125 durch Auswahl 65, 126–128 durch Einfügen 60, 128–129 durch Mischen 66, 132–139 durch Vertauschen 129–132 Heapsort 160–161 Quicksort 139–142 SOS-Problem 217 Splay-Bäume 179 -Funktion 256 Springerzug 207 " -Modul 293 Stack 118 Standarddatentypen 228 Standardkonstruktor 315 Stapel 118 -Modul 293 Streammuster 325 Streams 293 strukturelle Gleichheit 290 strukturierter Datentyp 86 " -Funktion 252, 253 -Funktion 22 Suchbaum der Ordnung 179 -Funktion 56 symmetrischer Nachfolger 153 symmetrischer Vorgänger 153 Syntax 309 -diagramm 310 systematisches Probieren 201 Termersetzungsregel 311 T HESEUS 201
350
-Funktion 251 -Modul 332, 333
Top-Down-Entwurf 57 -Modul 333 Toplevel-System 331 -Funktion 203, 205, 206, 208, 209, 219 Tupel 88, 257, 322 Typ 24, 228, 327 Varianten- 327 Verbund- 327 Typdefinition 265 rekursive 268 Typkonstanten 261 Typkonstruktoren 261 Typnamendefinition 262 Typoperatoren 261 Typschablonen 96 Typvariable 96, 261 Typvereinbarung 327 Übertrag 55 -Funktion 253 Union 92 UPN 153 Variable 24 Variablenvereinbarung 233 Variante 92, 267 Variantentyp 267, 327 Vater 145 -Modul 292 Vektor 257 Verbund 91, 265 disjunkter 267 Verbundtyp 327 Vereinigung 253 Vergleich 231, 321 verketteten Listen 113 Versuch und Irrtum 203 Vokalmodell 20
Index
vollständige Induktion 47 vollständiger Baum 150 VON N EUMANN (1903–1957) 24 Wachstumsklasse 41 Warteschlange 121 Weg des Springers 207 Wertbestimmung 228, 311 Wertbindung 229, 325 Wertebereich 264 Wörterbuchoperationen 101 Wortsymbol 309 Wurzel 143 Zeiger 94, 302 Ziffern 53 136 -Funktion Zustand 24 Zuweisung 25 Zwei-Hälften-Muster 281