Algorithmen und Datenstrukturen

Werbung
Universität Bielefeld
Vorlesungsskript
Algorithmen und Datenstrukturen
Robert Giegerich und Ralf Hinze
12. Juni 2012
Dieses Werk bzw. Inhalt steht unter einer Creative Commons Namensnennung-Weitergabe
unter gleichen Bedingungen 3.0 Unported Lizenz:
http://creativecommons.org/licenses/by-sa/3.0/
Inhaltsverzeichnis
1. Einleitung
1.1. Rechnen und rechnen lassen . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2. Die Aufgabengebiete der Informatik . . . . . . . . . . . . . . . . . . . . . .
1.3. Einordnung der Informatik in die Familie der Wissenschaften . . . . . . . .
7
7
9
11
2. Modellierung
2.1. Eine Formelsprache für Musik . . . . . . . . . .
2.2. Typen als Hilfsmittel der Modellierung . . . . .
2.3. Die Rolle der Abstraktion in der Modellierung .
2.4. Modellierung in der molekularen Genetik . . .
2.5. Anforderungen an Programmiersprachen . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
13
13
17
18
19
28
3. Eine einfache Programmiersprache
3.1. Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1.1. Datentypdefinitionen . . . . . . . . . . . . . . . . . . .
3.1.2. Typsynonyme . . . . . . . . . . . . . . . . . . . . . . .
3.1.3. Typdeklarationen, Typprüfung und Typinferenz . . .
3.1.4. Typklassen und Typkontexte . . . . . . . . . . . . . .
3.2. Wertdefinitionen . . . . . . . . . . . . . . . . . . . . . . . . .
3.2.1. Muster- und Funktionsbindungen . . . . . . . . . . . .
3.2.2. Bewachte Gleichungen . . . . . . . . . . . . . . . . . .
3.2.3. Gleichungen mit lokalen Definitionen . . . . . . . . . .
3.2.4. Das Rechnen mit Gleichungen . . . . . . . . . . . . . .
3.2.5. Vollständige und disjunkte Muster . . . . . . . . . . .
3.3. Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.1. Variablen, Funktionsanwendungen und Konstruktoren
3.3.2. Fallunterscheidungen . . . . . . . . . . . . . . . . . . .
3.3.3. Funktionsausdrücke . . . . . . . . . . . . . . . . . . .
3.3.4. Lokale Definitionen . . . . . . . . . . . . . . . . . . . .
3.4. Anwendung: Binärbäume . . . . . . . . . . . . . . . . . . . .
3.5. Vertiefung: Rechnen in Haskell . . . . . . . . . . . . . . . . .
3.5.1. Eine Kernsprache/Syntaktischer Zucker . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
31
31
31
36
36
38
39
39
42
43
46
47
48
49
50
51
53
54
58
58
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
4
Inhaltsverzeichnis
3.5.2. Auswertung von Fallunterscheidungen . . . . . . . . . . . . . . . . .
3.5.3. Auswertung von Funktionsanwendungen . . . . . . . . . . . . . . . .
3.5.4. Auswertung von lokalen Definitionen . . . . . . . . . . . . . . . . . .
4. Programmiermethodik
4.1. Spezifikation . . . . . . . . . . . . . . . . . . . . .
4.2. Strukturelle Rekursion . . . . . . . . . . . . . . . .
4.2.1. Strukturelle Rekursion auf Listen . . . . . .
4.2.2. Strukturelle Rekursion auf Bäumen . . . . .
4.2.3. Das allgemeine Rekursionsschema . . . . .
4.2.4. Verstärkung der Rekursion . . . . . . . . .
4.3. Strukturelle Induktion . . . . . . . . . . . . . . . .
4.3.1. Strukturelle Induktion auf Listen . . . . . .
4.3.2. Strukturelle Induktion auf Bäumen . . . . .
4.3.3. Das allgemeine Induktionsschema . . . . . .
4.3.4. Verstärkung der Induktion . . . . . . . . . .
4.3.5. Referential transparency . . . . . . . . . . .
4.4. Anwendung: Sortieren durch Fusionieren . . . . . .
4.4.1. Phase 2: Sortieren eines Binärbaums . . . .
4.4.2. Phase 1: Konstruktion von Braun-Bäumen
4.5. Wohlfundierte Rekursion . . . . . . . . . . . . . . .
4.5.1. Das Schema der wohlfundierten Rekursion .
4.6. Vertiefung: Wohlfundierte Induktion . . . . . . . .
58
59
61
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
63
63
66
66
69
70
70
72
72
74
76
76
78
78
79
81
82
82
84
5. Effizienz und Komplexität
5.1. Grundlagen der Effizienzanalyse . . . . . . . . . . . . . . . . . . . . .
5.1.1. Maßeinheiten für Zeit und Raum beim Rechnen . . . . . . . .
5.1.2. Detaillierte Analyse von insertionSort . . . . . . . . . . . .
5.1.3. Asymptotische Zeit- und Platzeffizienz . . . . . . . . . . . . .
5.2. Effizienz strukturell rekursiver Funktionen . . . . . . . . . . . . . . .
5.3. Effizienz wohlfundiert rekursiver Funktionen . . . . . . . . . . . . . .
5.4. Problemkomplexität . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort .
5.5.1. Varianten der Teile-Phase . . . . . . . . . . . . . . . . . . . .
5.5.2. Berücksichtigung von Läufen . . . . . . . . . . . . . . . . . .
5.6. Datenstrukturen mit konstantem Zugriff: Felder . . . . . . . . . . . .
5.7. Anwendung: Ein lineares Sortierverfahren . . . . . . . . . . . . . . .
5.8. Vertiefung: Rolle der Auswertungsreihenfolge . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
87
87
87
88
91
93
97
99
102
102
106
107
111
114
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Inhaltsverzeichnis
6. Abstraktion
6.1. Listenbeschreibungen . . . . . . . . . . . . .
6.2. Funktionen höherer Ordnung . . . . . . . .
6.2.1. Funktionen als Parameter . . . . . .
6.2.2. Rekursionsschemata . . . . . . . . .
6.2.3. foldr und Kolleginnen . . . . . . . .
6.2.4. map und Kolleginnen . . . . . . . . .
6.3. Typklassen . . . . . . . . . . . . . . . . . .
6.3.1. Typpolymorphismus und Typklassen
6.3.2. class- und instance-Deklarationen
6.3.3. Die Typklassen Eq und Ord . . . . .
6.3.4. Die Typklassen Show und Read . . .
6.3.5. Die Typklasse Num . . . . . . . . . .
6.4. Anwendung: Sequenzen . . . . . . . . . . .
6.4.1. Klassendefinition . . . . . . . . . . .
6.4.2. Einfache Instanzen . . . . . . . . . .
6.4.3. Generische Instanzen . . . . . . . . .
6.4.4. Schlangen . . . . . . . . . . . . . . .
6.4.5. Konkatenierbare Listen . . . . . . .
5
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
A. Lösungen aller Übungsaufgaben
B. Mathematische Grundlagen
oder: Wieviel Mathematik ist nötig?
B.1. Mengen und Funktionen . . . . . . . . . . . .
B.1.1. Der Begriff der Menge . . . . . . . . .
B.1.2. Der Begriff der Funktion . . . . . . . .
B.2. Relationen und Halbordnungen . . . . . . . .
B.3. Formale Logik . . . . . . . . . . . . . . . . . .
B.3.1. Aussagenlogik . . . . . . . . . . . . . .
B.3.2. Prädikatenlogik . . . . . . . . . . . . .
B.3.3. Natürliche und vollständige Induktion
117
117
120
121
124
126
132
135
135
138
139
140
142
144
144
146
148
149
150
151
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
163
163
163
165
165
166
166
167
167
Literaturverzeichnis
169
Index
169
1. Einleitung
1.1. Rechnen und rechnen lassen
Man kann vorzüglich Rechnen lernen, ohne sich jemals zu fragen, was denn das Rechnen
vom sonstigen Gebrauch des Verstandes unterscheidet. Wir stellen diese Frage jetzt und
betrachten dazu das Rechnen so wie es uns im Leben zuerst begegnet — als Umgang mit
den Zahlen. Wir werden also die Natur des Rechnens an der Arithmetik studieren, und dabei
am Ende feststellen, daß die Zahlen bei weitem nicht das Einzige sind, womit wir rechnen
können.
Zweifellos ist Rechnen ein besonderer Gebrauch des Verstandes. Eine gewisse Ahnung
vom Unterschied zwischen Denken im allgemeinen und Rechnen im besonderen hat jeder,
der einmal mit seinem Mathematiklehrer darüber diskutiert hat, ob der Fehler in seiner
Schularbeit „bloß“ als Rechenfehler, oder aber als „logischer“ Fehler einzuordnen sei.
Richtig Rechnen heißt Anwendung der Rechenregeln für Addition, Multiplikation etc.
Dies allein garantiert das richtige Ergebnis — und neben ihrer Beachtung und Anwendung
ist als einzige weitere Verstandesleistung die Konzentration auf diese eintönige Tätigkeit
gefragt. Kein Wunder, daß nur wenige Menschen zum Zeitvertreib siebenstellige Zahlen
multiplizieren!1
Damit soll nicht etwa das Rechnen diffamiert werden — es entspricht so gerade der Natur
dessen, worum es dabei geht, nämlich den Zahlen. Diese sind Größen, abstrakte Quantitäten
ohne sonstige Eigenschaft. Bringe ich sie in Verbindung, ergeben sich neue, andere Größen,
aber keine andere Qualität. Der Unterschied von 15 und 42 ist 27, und daraus folgt — nichts.
Die mathematische Differenz zweier Größen ist, so könnte man sagen, der unwesentliche
Unterschied.
Nur zum Vergleich: Stelle ich meinen Beitrag zum Sportverein und meine Einkommenssteuer einander gegenüber, so ergibt sich auch ein Unterschied im Betrag. Daneben aber auch
einer in der Natur der Empfänger, dem Grad der Freiwilligkeit, meine Einflußmöglichkeiten
auf die Verwendung dieser Gelder und vieles andere mehr. Dies sind wesentliche Unterschiede,
aus ihnen folgt allerhand. Unter anderem erklären sie auch die Differenz der Beträge, und
rechnen sie nicht bloß aus.
Für das Rechnen jedoch spielt es keine Rolle, wovon eine Zahl die Größe angibt. Jede
sonstige Beschaffenheit der betrachteten Objekte ist für das Rechnen mit ihrer Größe ohne
1 Denkaufgabe:
Was folgt daraus, wenn einer gut rechnen kann?
Grit Garbo ist Mathematikerin der alten Schule. Für sie gibt
es eigentlich keine Wissenschaft der Informatik — soweit Informatik wissenschaftlich ist, gehört sie zur Mathematik, alles
andere ist Ingeniertechnik oder Programmierhandwerk. Fragen
der Programmiersprachen oder des Software Engineering sieht
sie als bloße Äußerlichkeiten an. Sie kann es nicht ganz verschmerzen, daß die Mathematik von ihrer Tochter Informatik
heute in vielerlei Hinsicht überflügelt wird.
8
Harry Hacker ist ein alter Programmierfuchs, nach dem Motto: Eine Woche Programmieren und Testen kann einen ganzen
Nachmittag Nachdenken ersetzen. Er ist einfallsreich und ein gewitzter Beobachter; allerdings liegt er dabei oft haarscharf neben
der Wahrheit. Zu Programmiersprachen hat er eine dezidiert
subjektive Haltung: Er findet die Sprache am besten, die ihm
am besten vertraut ist.
1. Einleitung
Belang. Deshalb kann das Rechnen in starre Regeln gegossen werden und nimmt darin
seinen überraschungsfreien Verlauf. Die kreative gedankliche Leistung liegt woanders: Sie
manifestiert sich in der Art und Weise, wie man die abstrakten Objekte der Zahlen konkret
aufschreibt. Wir sind daran gewöhnt, das arabische Stellenwertsystem zu verwenden. Dieses
hat sich im Laufe der Geschichte durchgesetzt, da sich darauf relativ einfach Rechenregeln
definieren und auch anwenden lassen. Das römische Zahlensystem mit seinen komplizierten
Einheiten und der Vor- und Nachstellung blieb auf der Strecke: Den Zahlen XV und XLII
sieht man den Unterschied XXVII nicht an. Insbesondere das Fehlen eines Symbols für die
Null macht systematisches Rechnen unmöglich — gerade wegen der Null war das Rechnen
mit den arabischen Zahlen im Mittelalter von der Kirche verboten.
Einstweiliges Fazit: Das Rechnen ist seiner Natur nach eine äußerliche, gedankenlose,
in diesem Sinne mechanische Denktätigkeit. Kaum hatte die Menschheit Rechnen gelernt,
schon tauchte die naheliegende Idee auf, das Rechnen auf eine Maschine zu übertragen. Die
Geschichte dieser Idee — von einfachen Additionsmaschinen bis hin zu den ersten programmgesteuerten Rechenautomaten — ist an vielen Stellen beschrieben worden und soll hier
nicht angeführt werden. An ihrem Endpunkt steht der Computer, der bei entsprechender
Programmierung beliebig komplexe arithmetische Aufgaben lösen kann — schneller als wir,
zuverlässiger als wir und (für uns) bequemer.
Wenn dieses Gerät existiert, stellt sich plötzlich eine ganz neue Frage: Jetzt, wo wir rechnen
lassen können, wird es interessant, Aufgaben in Rechenaufgaben zu verwandeln, die es von
Natur aus nicht sind (und die wir mit unserem Verstand auch nie so behandeln würden).
Es gilt, zu einer Problemstellung die richtigen Abstraktionen zu finden, sie durch Formeln
und Rechengesetze, genannt Datenstrukturen und Algorithmen, so weitgehend zu erfassen,
daß wir dem Ergebnis der Rechnung eine Lösung des Problems entnehmen können. Wir
bilden also abstrakte Modelle der konkreten Wirklichkeit, die diese im Falle der Arithmetik
perfekt, in den sonstigen Fällen meist nur annähernd wiedergeben. Die Wirklichkeitstreue
dieser Modelle macht den Reiz und die Schwierigkeit dieser Aufgabe, und die Verantwortung
des Informatikers bei der Software-Entwicklung aus.
Die eigentümliche Leistung der Informatik ist es also, Dinge in Rechenaufgaben zu verwandeln, die es von Natur aus nicht sind. Die Fragestellung „Was können wir rechnen lassen?“,
ist neu in der Welt der Wissenschaften — deshalb hat sich die Informatik nach der Konstruktion der ersten Rechner schnell aus dem Schoße der Mathematik heraus zu einer eigenständigen Disziplin entwickelt. Die Erfolge dieser Bemühung sind faszinierend, und nicht
selten, wenn auch nicht ganz richtig, liest man darüber in der Zeitung: „Computer werden
immer schlauer“.
1.2. Die Aufgabengebiete der Informatik
9
1.2. Die Aufgabengebiete der Informatik
Informatik ist die Wissenschaft vom maschinellen Rechnen. Daraus ergeben sich verschiedene
Fragestellungen, in die sich die Informatik aufgliedert. Zunächst muß sie sich um die Rechenmaschine selbst kümmern. In der Technischen Informatik geht es um Rechnerarchitektur
und Rechnerentwurf, wozu auch die Entwicklung von Speichermedien, Übertragungskanälen,
Sensoren usw. gehören. Der Fortschritt dieser Disziplin besteht in immer schnelleren und
kleineren Rechnern bei fallenden Preisen. In ihren elementaren Operationen dagegen sind
die Rechner heute noch so primitiv wie zur Zeit ihrer Erfindung. Diese für den Laien überraschende Tatsache erfährt ihre Erklärung in der theoretischen Abteilung der Informatik.
Mit der Verfügbarkeit der Computer erfolgt eine Erweiterung des Begriffs „Rechnen“. Das
Rechnen mit den guten alten Zahlen ist jetzt nur noch ein hausbackener Sonderfall. Jetzt
werden allgemeinere Rechenverfahren entwickelt, genannt Algorithmen, die aus EingabeDaten die Ausgabe-Daten bestimmen. Aber — was genau läßt sich rechnen, und was nicht?
Mit welcher Maschine? Die Theoretische Informatik hat diese Frage beantwortet und gezeigt,
daß es nicht berechenbare Probleme2 gibt, d.h. Aufgaben, zu deren Lösung es keinen Algorithmus gibt. Zugleich hat sie gezeigt, daß alle Rechner prinzipiell gleichmächtig sind, sofern
sie über einige wenige primitive Operationen verfügen. Dagegen erweisen sich die berechenbaren Aufgaben als unterschiedlich aufwendig, so daß die Untersuchung des Rechenaufwands,
Komplexitätstheorie, heute ein wichtiges Teilgebiet der Theoretischen Informatik darstellt.
Zwischen den prinzipiellen Möglichkeiten des Rechnens und dem Rechner mit seinen primitiven Operationen klafft eine riesige Lücke. Die Aufgabe der Praktischen Informatik ist es,
den Rechner für Menschen effektiv nutzbar zu machen. Die „semantische Lücke“ wird Schicht
um Schicht geschlossen durch Programmiersprachen auf immer höherer Abstraktionsstufe.
Heute können wir Algorithmen auf der Abstraktionsstufe der Aufgabenstellung entwickeln
und programmieren, ohne die ausführende Maschine überhaupt zu kennen. Dies ist nicht nur
bequem, sondern auch wesentliche Voraussetzung für die Übertragbarkeit von Programmen
zwischen verschiedenen Rechnern. Ähnliches gilt für die Benutzung der Rechner (Betriebssysteme, Benutzeroberflächen), für die Organisation großer Datenmengen (Datenbanken)
und sogar für die Kommunikation der Rechner untereinander (Rechnernetze, Verteilte Systeme). Das World Wide Web (WWW) ist das jüngste und bekannteste Beispiel für das
Überbrücken der semantischen Lücke: Es verbindet einfachste Benutzbarkeit mit globalem
Zugriff auf Informationen und andere Ressourcen in aller Welt. Heute bedarf es nur eines
gekrümmten Zeigefingers, um tausend Computer in aller Welt für uns arbeiten zu lassen.
In der Angewandten Informatik kommt endlich der Zweck der ganzen Veranstaltung zum
2 Man
kann solche Aufgaben auch als Ja/Nein-Fragestellungen formulieren und nennt sie dann „formal
unentscheidbare“ Probleme. Läßt man darin das entscheidende Wörtchen „formal“ weg, eröffnen sich
tiefsinnige, aber falsche Betrachtungen über Grenzen der Erkenntnis.
Lisa Lista ist die ideale Studentin, den Wunschvorstellungen
eines überarbeitetenn Hochschullehrers entsprungen. Sie ist unvoreingenommen und aufgeweckt, greift neue Gedanken auf und
denkt sie in viele Richtungen weiter. Kein Schulunterricht in
Informatik hat sie verdorben. Lisa Lista ist 12 Jahre alt.
10
Peter Paneau ist Professor der Theoretischen Informatik. Er
sieht die Informatik im wesentlichen als eine formale Disziplin.
Am vorliegenden Text stört ihn die saloppe Redeweise und
die Tatsache, daß viele tiefgründige theoretische Probleme grob
vereinfacht oder einfach umgangen werden. An solchen Stellen
erhebt er mahnend der Zeigefinger . . .
1. Einleitung
Zuge. Sie wendet die schnellen Rechner, die effizienten Algorithmen, die höheren Programmiersprachen und ihre Übersetzer, die Datenbanken, die Rechnernetze usw. an, um Aufgaben
jeglicher Art sukzessive immer vollkommener zu lösen. Je weiter die Informatik in die verschiedensten Anwendungsgebiete vordringt, desto spezifischer werden die dort untersuchten Fragen. Vielleicht werden wir bald erleben, daß sich interdisziplinäre Anwendungen als
eigenständige Disziplinen von der „Kerninformatik“ abspalten. Wirtschaftsinformatik und
Bioinformatik sind mögliche Protagonisten einer solchen Entwicklung. Während es früher
Jahrhunderte gedauert hat, bis aus den Erfolgen der Physik zunächst die anderen Natur-,
dann die Ingenieurwissenschaften entstanden, so laufen heute solche Entwicklungen in wenigen Jahrzehnten ab.
Wenn die Rechner heute immer mehr Aufgaben übernehmen, für die der Mensch den
Verstand benutzt, so ist insbesondere für Laien daran längst nicht mehr erkennbar, auf
welche Weise dies erreicht wird. Es entsteht der Schein, daß das analoge Resultat auch in
analoger Weise zustande käme. Diesen Schein nennt man künstliche Intelligenz, und seiner
Förderung hat sich das gleichnamige Arbeitsfeld der Informatik gewidmet. Es lohnt sich,
die Metapher von der Intelligenz des Rechners kurz genauer zu betrachten, weil sie — unter
Laien wie unter Informatikern — manche Verwirrung stiftet. Es ist nichts weiter dabei, wenn
man sagt, daß ein Rechner (oder genauer eine bestimmte Software) sich intelligent verhält,
wenn er (oder sie) eine Quadratwurzel zieht, eine WWW-Seite präsentiert oder einen Airbus
auf die Piste setzt. Schließlich wurden viele Jahre Arbeit investiert, um solche Aufgaben
rechnergerecht aufzubereiten, damit der Rechner diese schneller und zuverlässiger erledigen
kann als wir selbst. Nimmt man allerdings die Metapher allzu wörtlich, kommt man zu
der Vorstellung, der Rechner würde dadurch selbst so etwas wie eine eigene Verständigkeit
erwerben. Daraus entsteht gelegentlich sogar die widersprüchliche Zielsetzung, den Rechner
so zu programmieren, daß er sich nicht mehr wie ein programmierter Rechner verhält. Der
Sache nach gehört die „Künstliche Intelligenz“ der Angewandten Informatik an, geht es
ihr doch um die erweiterte Anwendbarkeit des Rechners auf immer komplexere Probleme.
Andererseits haben diese konkreten Anwendungen für sie nur exemplarischen Charakter, als
Schritte zu dem abstrakten Ziel, „wirklich“ intelligente künstliche Systeme zu schaffen. So
leidet diese Arbeitsrichtung unter dem Dilemma, daß ihr Beitrag zum allgemeinen Fortschritt
der Informatik vom Lärm der immer wieder metaphorisch geweckten und dann enttäuschten
Erwartungen übertönt wird.
Unbeschadet solch metaphorischer Fragen geht der Vormarsch der Informatik in allen
Lebensbereichen voran — der „rechenbare“ Ausschnitt der Wirklichkeit wird ständig erweitert: Aus Textverarbeitung wird DeskTop Publishing, aus Computerspielen wird Virtual
Reality, und aus gewöhnlichen Bomben werden „intelligent warheads“. Wie gesagt: die Rechner werden immer schlauer. Und wir?
1.3. Einordnung der Informatik in die Familie der Wissenschaften
11
1.3. Einordnung der Informatik in die Familie der
Wissenschaften
Wir haben gesehen, daß die Grundlagen der Informatik aus der Mathematik stammen,
während der materielle Ausgangspunkt ihrer Entwicklung die Konstruktion der ersten Rechenmaschinen war. Mutter die Mathematik, Vater der Computer — wissenschaftsmoralisch
gesehen ist die Informatik ein Bastard, aus einer Liebschaft des reinen Geistes mit einem
technischen Gerät entstanden.
Die Naturwissenschaft untersucht Phänomene, die ihr ohne eigenes Zutun gegeben sind.
Die Gesetze der Natur müssen entdeckt und erklärt werden. Die Ingenieurwissenschaften
wenden dieses Wissen an und konstruieren damit neue Gegenstände, die selbst wieder auf
ihre Eigenschaften untersucht werden müssen. Daraus ergeben sich neue Verbesserungen,
neue Eigenschaften, und so weiter.
Im Vergleich dieser beiden Abteilungen der Wissenschaft steht die Informatik eher den
Ingenieurwissenschaften nahe: Auch sie konstruiert Systeme, die — abgesehen von denen der
Technischen Informatik — allerdings immateriell sind. Wie die Ingenieurwissenschaften untersucht sie die Eigenschaften ihrer eigenen Konstruktionen, um sie weiter zu verbessern. Wie
bei den Ingenieurwissenschaften spielen bei der Informatik die Aspekte der Zuverlässigkeit
und Lebensdauer ihrer Produkte eine wesentliche Rolle.
Eine interessante Parallele besteht auch zwischen Informatik und Rechtswissenschaft.
Die Informatik konstruiert abstrakte Modelle der Wirklichkeit, die dieser möglichst nahe
kommen sollen. Das Recht schafft eine Vielzahl von Abstraktionen konkreter Individuen.
Rechtlich gesehen sind wir Mieter, Studenten, Erziehungsberechtigte, Verkehrsteilnehmer,
etc. Als solche verhalten wir uns entsprechend der gesetzlichen Regeln. Der Programmierer
bildet reale Vorgänge in formalen Modellen nach, der Jurist muß konkrete Vorgänge unter
die relevanten Kategorien des Rechts subsumieren. Die Analogie endet allerdings, wenn eine
Diskrepanz zwischen Modell und Wirklichkeit auftritt: Bei einem Programmfehler behält die
Wirklichkeit recht, bei einem Rechtsbruch das Recht.
Eine wissenschaftshistorische Betrachtung der Informatik und ihre Gegenüberstellung mit
der hier gegebenen deduktiven Darstellung ist interessant und sollte zu einem späteren Zeitpunkt, etwa zum Abschluß des Informatik-Grundstudiums, nachgeholt werden. Interessante
Kapitel dieser Geschichte sind das Hilbert’sche Programm, die Entwicklung der Idee von Programmen als Daten, die oben diskutierte Idee der „Künstlichen Intelligenz“, der Prozeß der
Loslösung der Informatik von der Mathematik, die Geschichte der Programmiersprachen, die
Revolution der Anwendungssphäre durch das Erscheinen der Mikroprozessoren, und vieles
andere mehr.
2. Modellierung
Wir haben im ersten Kapitel gelernt: Programmieren heißt, abstrakte Modelle realer Objekte
zu bilden, so daß die Rechenregeln im Modell die relevanten Eigenschaften in der Wirklichkeit
adäquat nachbilden.
Wir werden den Vorgang der Modellierung nun anhand von zwei Beispielen kennen lernen.
2.1. Eine Formelsprache für Musik
Wir haben gelernt, daß die Welt der Zahlen wegen ihres formalen Charakters die natürliche
Heimat des Rechnens ist. Es gibt ein zweites Gebiet, in dem ein gewisser Formalismus in der
Natur der Sache liegt: die Musik.
Das Spielen eines Musikstückes mag vieles sein – ein künstlerischer Akt, ein kulturelles
oder emotionales Ereignis. Im Kern jedoch steht das Musikstück – es bringt die Musikvorstellung des Komponisten in eine objektive Form und ist nichts anderes als ein Programm zur
Ausführung durch einen Musiker (oder heute über eine Midi-Schnittstelle auch durch einen
elektronischen Synthesizer). Wer immer als erster ein Musikstück niedergeschrieben hat, hat
damit den Schritt vom Spielen zum Spielen lassen begründet.
Diese kulturhistorische Leistung ist viel älter als die Idee einer Rechenmaschine. In der
traditionellen Notenschrift ist der formale Anteil der Musik manifestiert. Die Modellierung
ist hier also bereits erfolgt, allerdings ausschließlich im Hinblick auf die Wiedergabe durch
einen menschlichen Interpreten, und nicht bis hin zur Entwicklung von dem Rechnen vergleichbaren Regeln. Zwar sind viele Gesetze z.B. der Harmonielehre bekannt, man kann sie
aber in der Notenschrift nicht ausdrücken. So kann man zwar jeden Dur-Dreiklang konkret
hinschreiben – aber die allgemeine Aussage, daß ein Dur-Dreiklang immer aus einem groß
en und einem kleinen Terz besteht, muß auf andere Art erfolgen. In diesem Abschnitt setzen
wir die Notenschrift (natürlich nur einen kleinen Ausschnitt davon) in eine Formelsprache
samt den zugehörigen Rechenregeln um.
Musikstücke sind aus Noten zusammengesetzt, die gleichzeitig oder nacheinander gespielt
werden. Jede Note hat einen Ton und eine Dauer. Dem Stück als Ganzem liegt ein bestimmter Takt zugrunde, und die Dauer einzelner Töne wird in Takt-Bruchteilen gemessen.
Die Tonskala besteht aus 12 Halbtönen, die sich in jeder Oktave wiederholen.
Wir legen das tiefe C als untersten Ton zugrunde und numerieren die Halbtöne von 0
ab aufwärts. Angaben zum Spieltempo (in Taktschlägen pro Minute) und zur Wahl des
14
2. Modellierung
Instruments werden global für ganze Musikstücke gemacht.
Formeln, die Musik bedeuten, können wir uns z.B. so vorstellen:
Note
0 (1/4)
-Note 24 (1/1)
-Pause
(1/16)
-Note 24 (1/4) :*: Note 26 (1/4)
-Note 24 (1/4) :+: Note 28 (1/4)
-Tempo
40 m
-Tempo
100 m
-Instr Oboe m
-Instr VoiceAahs m
--
Viertelnote tiefes C
ganze Note
c’
Sechzehntelpause
:*: Note 28 (1/4)
Anfang der C-Dur Tonleiter
:+: Note 31 (1/4)
C-Dur Akkord
Musik m als Adagio
Musik m als Presto
Musik m von Oboe gespielt
Musik m gesummt
Wir konstruieren nun die Formelwelt, deren Bürger die obigen Beispiele sind. Wir gehen
davon aus, daß es Formeln für ganze Zahlen, Brüche und ein bißchen Arithmetik bereits gibt
in der Formelwelt und führen neue Typen von Formeln ein: Ton, Dauer, Instrument und
Musik.
infixr 7 :*:
infixr 6 :+:
type GanzeZahl
type Bruch
type Ton
type Dauer
data Instrument
data Musik
=
=
=
=
=
Int
-- Ganze Zahlen und Brueche setzen wir
Rational
-- als gegeben voraus.
GanzeZahl
Bruch
Oboe | HonkyTonkPiano |
Cello | VoiceAahs
deriving Show
= Note Ton Dauer |
Pause Dauer |
Musik :*: Musik |
Musik :+: Musik |
Instr Instrument Musik |
Tempo GanzeZahl Musik
deriving Show
Die neuen Formeltypen Ton und Dauer sind nur Synonyme für ganze Zahlen und Brüche
entsprechend der besonderen Bedeutung, in der wir sie gebrauchen. Sie tragen keine neuen
Formeln bei. Anders bei Instrument und Musik. Die Symbole Note, Pause, :*:, :+:, Instr, Tempo
sind die Konstruktoren für neuartige Formeln, die Musikstücke darstellen. In der Formel
m1 :*: m2 dürfen (und müssen!) m1 und m2 beliebige Formeln für Musikstücke sein, insbesondere selbst wieder zusammengesetzt.
In Anlehnung an die Punkt-vor-Strich Regel der Algebra geben wir der Verknüpfung
von Noten zu einer zeitlichen Abfolge (:*:) Vorrang vor der zeitlich parallelen Verknüpfung
mehrerer Stimmen (:+:). Damit ist die Formel m1 :+: m2 :*: m3 zu lesen als m1 :+: (m2 :*: m3).
2.1. Eine Formelsprache für Musik
15
Umfangreiche Musikstücke können nun als wahre Formelgebirge geschrieben werden.
Zuvor erleichtern wir uns das Leben in der Formelwelt, indem wir einige Abkürzungen
einführen. Etwa für die Pausen:
gP
hP
vP
aP
sP
=
=
=
=
=
Pause
Pause
Pause
Pause
Pause
(1/1)
(1/2)
(1/4)
(1/8)
(1/16)
Diese Definitionen sind zugleich Rechenregeln. Sie bedeuten einerseits, daß man die neuen
Symbole gP, hP, vP, aP, sP überall in Musikstücken einsetzen kann – es erweitert sich
damit die Formelwelt. Zugleich kann man jedes Symbol jederzeit durch die rechte Seite der
Definition ersetzen – so daß die Musikstücke in der Formelwelt nicht mehr geworden sind.
Hier sind klangvolle Namen für einige Tempi:
adagio = 70; andante = 90; allegro = 140; presto = 180
Die üblichen Bezeichnungen für die 12 Halbtöne führen wir so ein:
ce
de
eh
ef
ge
ah
ha
=
=
=
=
=
=
=
0;
2;
4;
5;
7;
9;
11;
cis
dis
eis
fis
gis
ais
his
=
=
=
=
=
=
=
1;
3;
5;
6;
8;
10;
12
des
es
fes
ges
as
be
=
=
=
=
=
=
1
3
4
6
8
10
Diese Töne sind in der untersten Oktave angesiedelt, und wir brauchen sie hier kaum. Dagegen brauchen wir eine bequeme Notation für Noten (beliebiger Dauer) aus der C-Dur Tonleiter in der dritten Oktave:
c’
e’
g’
h’
u
u
u
u
=
=
=
=
Note
Note
Note
Note
(ce+24)
(eh+24)
(ge+24)
(ha+24)
u;
u;
u;
u;
d’
f’
a’
c’’
u
u
u
u
=
=
=
=
Note
Note
Note
Note
(de+24)
(ef+24)
(ah+24)
(ce+36)
u
u
u
u
Als Beispiele hier der C-Dur Akkord in neuer Schreibweise, sowie eine (rhythmisch beschwingte)
C-Dur Tonleiter:
cDurTonika = c’ (1/1) :+: e’ (1/1) :+: g’ (1/1)
cDurSkala = Tempo allegro(
c’ (3/8) :*: d’ (1/8) :*: e’ (3/8) :*: f’ (4/8)
:*: g’ (1/8) :*: a’ (1/4) :*: h’ (1/8) :*: c’’ (1/2))
16
2. Modellierung
Allgemein gesagt, besteht ein Dur-Dreiklang aus einen Grundton t sowie der groß en Terz
und der Quinte über t.
Dieses Gesetz als Formel:
durDreiklang t = Note t (1/1) :+: Note (t+4) (1/1) :+: Note (t+7) (1/1)
Wie wär’s mit einer allgemeinen Regel zur Umkehrung eines Dreiklangs (der unterste Ton
muß eine Oktave nach oben)?
umk ((Note t d) :+: n2 :+: n3) = n2 :+: n3 :+: (Note (t+12) d)
Als komplexe Operation auf ganzen Musikstücken betrachten wir das Transponieren. Es
bedeutet, daß Töne um ein gewisses Intervall verschoben werden, während alles andere gleich
bleibt. Wie muß die Rechenregel lauten, um Musikstück m um Intervall i zu transponieren?
Wir geben eine Regel für jede mögliche Form von m an:
transponiere
transponiere
transponiere
transponiere
transponiere
transponiere
i
i
i
i
i
i
(Pause d)
(Note t d)
(m1 :*: m2)
(m1 :+: m2)
(Instr y m)
(Tempo n m)
=
=
=
=
=
=
Pause d
Note (t+i) d
(transponiere i m1) :*:
(transponiere i m1) :+:
Instr y (transponiere i
Tempo n (transponiere n
(transponiere i m2)
(transponiere i m2)
m)
m)
gDurTonika = transponiere 7 cDurTonika
Da das Transponieren Intervalle nicht ändert, führt es einen Dur-Dreiklang wieder in
einen Dur-Dreiklang über. Es gilt folgendes Gesetz für beliebigen Grundton g und beliebiges
Intervall i:
transponiere i (durDreiklang t) = durDreiklang(t+i)
Zur Überprüfung rechnen wir beide Seiten mittels der Definition von transponiere und
durDreiklang aus und erhalten beide Male
Note (t+i) (1/1)
:+: Note (t+4+i) (1/1) :+: Note (t+7+i) (1/1)
Hier haben wir eine Methode zur Validierung von Modellen kennengelernt: Wenn man
beweisen kann, daß ein Gesetz der realen Welt auch in der Formelwelt gilt, so gibt das
Modell die Wirklichkeit zumindest in dieser Hinsicht treu wieder.
Bevor wir ein komplettes Lied zusammenstellen, hier zwei Regeln zur Wiederholung:
2.2. Typen als Hilfsmittel der Modellierung
17
wdh m = m :*: m
ad_infinitum m = m :*: ad_infinitum m
Einen um Dauer d verzögerten Einsatz erhält man durch Vorschalten einer Pause.
einsatz
d m = (Pause d) :*: m
Unser letztes Beispiel beschreibt den Kanon „Bruder Jakob“, wobei wir die 2. Stimme eine
Oktave über den anderen notieren.
phrase1 = c’ (1/4) :*: d’ (1/4) :*: e’ (1/4) :*: c’ (1/4)
phrase2 = e’ (1/4) :*: f’ (1/4) :*: g’ (1/2)
phrase3 = g’ (1/8) :*: a’ (1/8) :*: g’ (1/8) :*: f’ (1/8)
:*: e’ (1/4) :*: c’ (1/4)
phrase4 = c’ (1/4) :*: (transponiere (-12) (g’ (1/4))) :*: c’’ (1/2)
strophe = wdh phrase1 :*: wdh phrase2 :*: wdh phrase3 :*: wdh phrase4
endlos = ad_infinitum strophe
bruderJakob = Tempo andante (Instr VoiceAahs
(einsatz (0/1) endlos
:+:
(einsatz (2/1) (transponiere 12 endlos)) :+:
(einsatz (4/1) endlos)
:+:
(einsatz (6/1) endlos )))
Natürlich gehört zu einer praktischen Formelsprache für Musik noch vieles, was wir hier
nicht betrachtet haben – Text, Artikulation, Kontrapunkt, eine Möglichkeit zur Übersetzung
der Formeln in die traditionelle Notation und in eine Folge von Midi-Befehlen, um damit
einen Synthesizer zu steuern.
2.2. Typen als Hilfsmittel der Modellierung
In der realen Welt verhindert (meistens) die Physik, daß unpassende Operationen mit untauglichen Objekten ausgeführt werden. Man kann eben einen Golfball nicht unter einer Fliege verstecken oder einen Rosenbusch mit einer Steuererklärung paaren. Macht es
Sinn, einer streikenden (?) Waschmaschine gut zuzureden? Das Problem in einer Formelwelt
ist, daß man zunächst alles hinschreiben kann, weil die Formeln sich nicht selbst dagegen
sträuben.
Aus diesem Grund gibt man jedem Objekt des Modells einen Typ, der eine Abstraktion
der natürlichen Eigenschaften des realen Objekts ist.
In der Formelsprache für Musik haben wir die Typen Ton (synonym für ganzeZahl)
und Dauer (synonym für positive rationale Zahl) vorausgesetzt und die Typen Musik und
Instrument neu eingeführt. Die Typendeklaration legt nicht nur fest, daß Musik auf sechs
18
2. Modellierung
verschiedene Weisen zusammengesetzt werden kann, und zwar durch die (Formel-) Konstruktoren Note bis Tempo. Sie sagt auch, welche Formeln zusammengefügt werden dürfen.
Note muß stets einen Ton mit einer Dauer verknüpfen. Mit :*: kann man zwei Musikstücke
verknüpfen, nicht aber ein Musikstück mit einem Ton. Folgende Formeln sind fehlerhaft:
Note 3/4 ce
Pause Cello
Tempo Oboe 100
Pause (1/2 :*: 1/4)
Instr (Cello :+: Oboe) cDurTonika
c’ :+: e’ :+: g’
Möglicherweise hat sich der Autor dieser Formeln etwas Sinnvolles gedacht – aber es muß in
unserer Formelsprache anders ausgedrückt werden. Auch die durch Definitionen eingeführten
Namen erhalten einen Typ. Er ergibt sich aus der rechten Seite der definierenden Gleichung.
Wir schreiben a, b, c :: t für die Aussage „Die Namen (oder Formeln) a, b, c haben
Typ t“ und stellen fest:
ce, cis, des, de, dis, es, eh, eis, fes, ef, fis :: Ton
ges, ge, gis, as, ah, ais, be, ha, his
:: Ton
gP, hP, vP, aP, sP
:: Musik
cDurTonika
:: Musik
adagio, andante, allegro, presto
:: GanzeZahl
Die Namen c’, d’ etc. bezeichnen Funktionen, die eine Dauer als Argument und Musik
als Ergebnis haben. umk, wdh und ad_infinitum wandeln Musikstücke um.
c’, d’, e’, f’, g’, a’, h’, c’’ :: Dauer -> Musik
umk, wdh, ad_infinitum
:: Musik -> Musik
transponiere schließlich hat zwei Argumente, was wir – etwas gewöhnugsbedürftig – mit
zwei Funktionspfeilen ausdrücken.
transponiere :: GanzeZahl -> Musik -> Musik
Hinter dieser Notation steht die Sichtweise, daß man einer zweistelligen Funktion wie
transponiere manchmal nur ein Argument geben möchte: Unter
(transponiere 7) stellen wir uns eine abgeleitete Funktion vor, die Musikstücke um eine
Quinte nach oben transponiert. Natürlich hat (transponiere 7) dann den „restlichen“ Typ
Musik -> Musik.
2.3. Die Rolle der Abstraktion in der Modellierung
Unsere Formelsprache für Musik erlaubt es, in direkter Analogie zur Notenschrift Musikstücke als Formeln hinzuschreiben. Darüberhinaus können wir aber auch abstrakte Zusammenhänge zwischen Musikstücken darstellen, wie etwa den Aufbau eines Dur-Dreiklangs oder
das Transponieren. Worauf beruht dies Möglichkeit?
2.4. Modellierung in der molekularen Genetik
19
Sie beginnt damit, daß wir beliebige Formeln mit einem Namen versehen können, wie etwa
bei gP, hP oder phrase1 bis phrase4. Diese Namen sind kürzer und (hoffentlich) lesbarer
als die Formeln, für die sie stehen, aber noch nicht abstrakter. Die Abstraktion kommt mit
dem Übergang zu Funktionen ins Spiel. Vergleichen wir die beiden folgenden Definitionen
eines Zweiklangs:
tritonus_f_1 = Note ef (1/1) :+: Note ha (1/1)
tritonus t d = Note t d
:+: Note (t+6) d
tritonus_f_1 beschreibt einen Zweiklang von konkreter Tonlage und Dauer.
tritonus dagegen abstrahiert von Tonlage und Dauer, und hält nur fest, was für den als
Tritonus bekannten Zweiklang wesentlich ist — ein Intervall von 6 Halbtonschritten.
Dieser Unterschied macht sich auch an den Typen bemerkbar. Wir finden:
tritonus_f_1 :: Musik
tritonus
:: Ton -> Dauer -> Musik
Hier verrät uns bereits der Typ, daß in der Formel tritonus erst Ton und Dauer konkretisiert
werden müssen, ehe spielbare Musik entsteht. Bei den Noten der eingestrichenen C-DurSkala (c’, d’, ...) haben wir von der Dauer abtrahiert, sonst hätten wir Definitionen für
40 oder noch mehr Namen einführen müssen. Andererseits haben wir hier natürlich nicht
vom Ton abstrahiert, wir wollten ja Namen für bestimmte Töne einführen. Und außerdem
gibt es diese Abstraktion bereits: Eine Note von beliebigem Ton und Dauer wird gerade von
dem KonstruktorNote repräsentiert, dem wir daher auch den Typ Ton -> Dauer -> Musik
zuordnen.
Typen halten die Objekte der Formelwelt auseinander, Funktionen stiften Zusammenhänge. Wollten wir ohne Abstraktion das Prinzip der Umkehrung eines Dreiklangs beschreiben,
kämen wir über konkrete Beispiele nicht hinaus. Die Funktion umk leistet das Gewünschte
mühelos. Und schließlich hält uns nichts davon ab (Übungsaufgabe!), im Kanon bruderJakob
von der eigentlichen Melodie zu abstrahieren und ein allgemeines Konstruktionsprinzip für
einen vierstimmigen Kanon anzugeben.
Hier haben wir die Methode der funktionalen Abstraktion kennengelernt. Technisch gesehen besteht sie lediglich darin, in einer Formel einige Positionen als variabel zu betrachten.
So wird aus der Formel eine Funktion, die mit einem Namen versehen und in vielfältiger
Weise eingesetzt werden kann, und die zugleich selbst ein abstraktes Prinzip repräsentiert.
Wir werden später noch andere Formen der Abstraktion kennenlernen, aber die funktionale
Abstraktion ist sicher die grundlegendste von allen.
2.4. Modellierung in der molekularen Genetik
Die Basen Adenin, Cytosin, Guanin und Thymin bilden zusammen mit Phosphor und Ribose
die Nukleotide, die Bausteine der Erbsubstanz.
20
2. Modellierung
data Nucleotide = A | C | G | T
deriving (Eq, Show)
Die 20 Aminosäuren sind die Bausteine aller Proteine:
data AminoAcid
=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Ala
-- Alanin
Arg
-- Arginin
Asn
-- Asparagin
Asp
-- Aspartat
Cys
-- Cystein
Gln
-- Glutamin
Glu
-- Glutamat
Gly
-- Glycin
His
-- Histidin
Ile
-- Isoleucin
Leu
-- Leucin
Lys
-- Lysin
Met
-- Methionin
Phe
-- Phenylalanin
Pro
-- Prolin
Ser
-- Serin
Thr
-- Threonin
Trp
-- Tryptophan
Tyr
-- Tyrosin
Val
-- Valin
Stp
-- keine Aminosaeure; siehe unten
deriving (Eq, Show)
Lange Ketten von Nukleotiden bilden die Nukleinsäure, lange Ketten von Aminosäuren
bilden Proteine. Chemisch werden diese Ketten auf unterschiedliche Weise gebildet: Bei den
Nukleotiden verknüpfen die Phosphor-Atome die Ribose-Ringe, zwischen den Aminosäuren
werden Peptidbindungen aufgebaut. Da wir aber die Chemie der Moleküle nicht modellieren
wollen, wollen wir auch nicht unterscheiden, auf welche Weise die Ketten konstruiert werden.
Als Formel für Kettenmoleküle schreiben wir
A:C:C:A:G:A:T:T:A:T:A:T: ..., oder
Met:Ala:Ala:His:Lys:Lys:Leu: ...
Im Unterschied zur Molekularbiologie kennt also unsere Formelwelt einen Konstruktor (:),
der Ketten jeglicher Art aufbaut:
data [a] = []
| a:[a]
-- leere Kette
-- (:) verlaengert Kette von a’s um ein a.
2.4. Modellierung in der molekularen Genetik
21
Den Datentyp [a] nennt man einen polymorphen Listentyp. Hier ist a ein Typparameter. Dies bedeutet, das Elemente beliebigen, aber gleichen Typs mittels der Konstruktoren
(:) und [] zu Listen zusammengefügt werden können. Für Listen führen wir eine spezielle
Notation ein: Statt a:b:c:[] schreiben wir auch [a,b,c].
Damit können wir nun sagen, welcher Datentyp in der Formelwelt den realen Nukleinsäuren und Proteinen entsprechen soll:
type DNA
= [Nucleotide]
type Protein = [AminoAcid]
type Codon
= (Nucleotide, Nucleotide, Nucleotide)
Im Ruhezustand (als Informationsspeicher) ist die DNA allerdings ein Doppelmolekül:
Zwei DNA-Stränge bilden die berühmte, von Watson und Crick entdeckte Doppelhelix. Sie
beruht darauf, daß bestimmte Basen zueinander komplementär sind - sie können Wasserstoffbrückenbindungen aufbauen, wenn sie einander gegenüber stehen.
wc_complement
wc_complement
wc_complement
wc_complement
A
T
C
G
=
=
=
=
T
A
G
C
Wir können die dopplesträngige DNA auf zwei verschiedene Weisen darstellen, als Paar
von Listen oder als Liste von Paaren.
type DNA_DoubleStrand = (DNA,DNA) -- als Paar zweier Einzelstraenge
type DNA_DoubleStrand’ = [(Nucleotide,Nucleotide)]
-- als Kette von Watson-Crick-Paaren
dnaDS_Exmpl1 = ([A,C,C,G,A,T],[T,G,G,C,T,A])
dnaDS_Exmpl2 = [(A,T),(C,G),(C,G),(G,C),(A,T),(T,A)]
Dabei ist die erste Version deutlich vorzuziehen. Sie deutet an, was in der Zelle auch
stattfindet: Das Aufspalten oder Zusammenfügen des Doppelstrangs aus zwei Einzelsträngen.
Die zweite Version dagegen suggeriert, daß existierende Watson-Crick Basenpaare zu einer
Doppelkette verknüpft werden. Einzelne solche Paare sind jedoch wegen der sehr schwachen
Wasserstoffbindung nicht stabil und kommen daher in der Zelle nicht vor.
Wir entscheiden uns also für den Datentyp DNA_DoubleStrand. So naheliegend seine Definition auch erscheint, sie ist nicht ohne Gefahren: Formeln vom Typ
DNA_DoubleStrand sollen beliebige doppelsträngige DNA-Moleküle darstellen. Das können
sie auch. Aber darüber hinaus können wir formal korrekte Formeln des Typs
DNA_DoubleStrand hinschreiben, denen keine Doppelhelix entspricht.
incorrectDoubleStrand = ([A,C,C,G,A,T],[T,G,G,C,A,T,C])
22
2. Modellierung
incorrectDoubleStrand ist in zweifacher Weise falsch: Erstens sind die jeweiligen Basen
an mehreren Stellen nicht komplementär, und zweitens haben die beiden Einzelstränge gar
noch unterschiedliche Länge. Wir stoßen hier auf eine grundlegende Schwierigkeit bei jeder
Modellierung: Es gibt Objekte in der Modellwelt, die nichts bedeuten, d.h. denen keine
Objekte der Wirklichkeit entsprechen. Solche Formeln, die wohlgebaut, aber ohne Bedeutung
sind, nennt man syntaktisch korrekt, aber semantisch falsch. Alles geht gut, solange beim
Rechnen mit den Formeln auch keine solchen Objekte erzeugt werden. Wenn doch, sind die
Folgen meist unabsehbar. Im besten Fall bleibt die Rechnung irgendwann stecken, weil eine
Funktion berechnet werden soll, die für solche Fälle keine Regel vorsieht. Manchmal passen
die Rechenregeln auch auf die nicht vorgesehenen Formeln, und dann kann es im schlimmsten
Fall geschehen, daß die Rechnung ein Ergebnis liefert, dem man nicht ansieht, daß es falsch
ist.
Wir werden sehen, daß selbst die Natur im Falle der DNA-Replikation dieses Problem
erkannt und einen Weg zu seiner Lösung gefundenhat. Zunächst betrachten wir den „Normalfall“ der Replikation. Weil das Komplement jeder Base eindeutig definiert ist, kann man zu
jedem DNA-Einzelstrang den komplementären Doppelstrang berechnen. In der Tat geschieht
dies bei der Zellteilung. Der Doppelstrang wird aufgespalten, und die DNA-Polymerase
synthetisiert zu jedem der beiden Stränge den jeweils komplementären Strang. Die DNAPolymerase können wir auf zwei verschiedene Weisen darstellen: Enerseits ist sie ein Enzym,
also selbst ein Protein:
dnaPolymerase_Sequenz = Met:Ala:Pro:Val:His:Gly:Asp:Asp:Ser ...
Dies hilft uns allerdings nicht dabei, die Wirkungsweise dieses Enzyms auszudrücken.
Noch viele Jahrzehnte werden vergehen, ehe die Wissenschaft die Funktion eines Proteins
aus der Kette seiner Aminosäuren ableiten kann. Was liegt also näher, als die Funktion der
Polymerase durch eine Funktion zu modellieren:
dnaPolymerase :: DNA -> DNA_DoubleStrand
dnaPolymerase x = (x, complSingleStrand x) where
complSingleStrand []
= []
complSingleStrand (a:x) = wc_complement a:complSingleStrand x
Will man prüfen (lassen), ob ein Dopplestrang tatsächlich korrekt aufgebaut ist, kann man
dies nun einfach tun.
data Bool = True | False
-- die abstrakten Urteile Wahr und Falsch
wellFormedDoubleStrand :: DNA_DoubleStrand -> Bool
wellFormedDoubleStrand (x,y) = (x,y) == dnaPolymerase x
2.4. Modellierung in der molekularen Genetik
23
(Zur Unterscheidung von der definierenden Gleichung schreiben wir den Vergleich zweier
Formeln als ==. Dabei werden beide Formeln zunächst ausgerechnet und dann verglichen.)
Blickt man etwas tiefer ins Lehrbuch der Genetik, so findet man, daß auch die Natur nicht
ohne Fehler rechnet: Mit einer (sehr geringen) Fehlerrate wird in den neu polymerisierten
Strang ein falsches (nicht komplementäres) Nukleotid eingebaut. Dadurch wird die Erbinformation verfälscht. Daher gibt es ein weiteres Enzym, eine Exonuclease, die fehlerhafte Stellen
erkennt und korrigiert. Sie muß dabei ein subtiles Problem lösen: Gegeben ein Doppelstrang
mit einem nicht komplementären Basenpaar darin – welcher Strang ist das Original, und
welcher muß korrigiert werden? Die Natur behilft sich dadurch, daß der Originalstrang beim
Kopieren geringfügig modifiziert wird – durch Anhängen von Methylgruppen an seine Nukleotide.
Diese Technik können wir nicht einsetzen, da wir die Chemie der Nukleotide nicht modellieren. Wir behelfen uns auf eine nicht weniger subtile Art: Im Unterschied zur Wirklichkeit (Doppelhelix) kann man im Modell (DNA_DoubleStrand) einen „linken“ und „rechten“ Strang unterscheiden. Übereinstimmend mit der Modellierung der Polymerase legen wir
fest, daß stets der „linke“ Strang als Original gilt, der „rechte“ als die Kopie.
Auch die Exonuclease modellieren wir durch ihre Funktion:
exonuclease
exonuclease
exonuclease
exonuclease
exonuclease
:: DNA_DoubleStrand -> DNA_DoubleStrand
([],[])
= ([],[])
([],x)
= ([],[])
-- Kopie wird abgeschnitten
(x,[])
= dnaPolymerase x -- Kopie wird verlaengert
(a:x,b:y) = if b == ac then (a:x’,b:y’)
else (a:x’,ac:y’)
where ac
= wc_complement a
(x’,y’) = exonuclease (x,y)
Damit erhalten wir z.B. die erwünschte Korrektur des fehlerhaften Doppelstrangs:
exonuclease incorrectDoubleStrand ==> ([A,C,C,G,A,T],[T,G,G,C,T,A])
Unsere Definition der Exonuclease ist der Natur abgelauscht. Vom Standpunkt des Rechenergebnisses aus betrachtet (man nennt diesen Standpunkt extensional) können wir das Gleiche auch einfacher haben: Verläßt man sich darauf, daß unsere DNA-Polymerase im Modell
keine Fehler macht, kann man einfach den kopierten Strang gleich neu polymerisieren.
dnaCorr :: DNA_DoubleStrand -> DNA_DoubleStrand
dnaCorr (x,y) = dnaPolymerase x
Es ist nicht ganz überflüssig, die gleiche Funktion auf verschiedene Weisen zu beschreiben.
Daraus ergibt sich die Möglichkeit, das Modell an sich selbst zu validieren. Es muß ja nun
die folgende Aussage gelten:
24
2. Modellierung
Für alle d::DNA_DoubleStrand gilt dnaCorr d == exonuclease d.
Außerdem muß die dnaPolymerase eine Idempotenzeigenschaft aufweisen:
Für alle x::DNA gilt: x == z where
(x,y) == dnaPolymerase x
(y,z) == dnaPolymerase y
Wir formulieren solche Eigenschaften, die wir aus der Wirklichkeit kennen, in der Sprache
der Modellwelt. Können wir sie dort als gültig nachweisen, so wissen wir erstens, daß unser
Modell im Hinblick auf genau diese Eigenschaften der Realität entspricht. Aber noch mehr:
Sofern wir diese Eigenschaften beim Formulieren des Modells nicht explizit bedacht haben,
fördert ihr Nachweis auch unser generelles Vertrauen in die Tauglichkeit des Modells. Wir
werden später Techniken kennenlernen, wie man solche Eigenschaften nachweist.
Fast ohne Ausnahme benutzen alle Lebewesen den gleichen genetischen Code. Bestimmte
Dreiergruppen (genannt Codons) von Nukleotiden codieren für bestimmte Aminosäuren.
Da es 43 = 64 Codons, aber nur 20 Aminosäuren gibt, werden viele Aminosäuren durch
mehrere Codons codiert. Einige Codons codieren für keine Aminosäure; man bezeichnet sie
als Stop-Codons, da ihr Auftreten das Ende eines Gens markiert.
Der genetische Code ist also eine Funktion, die Codons auf Aminosäuren abbildet. (Wir
abstrahieren hier davon, daß die DNA zunächst in RNA transkribiert wird, wobei die Base
Thymin überall durch Uracil ersetzt wird.)
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
genCode
:: Codon -> AminoAcid
(A,A,A)
= Lys;
(A,A,C)
= Asn;
(A,C,_)
= Thr
(A,G,A)
= Arg;
(A,G,C)
= Ser;
(A,T,A)
= Ile;
(A,T,T)
= Ile
(A,T,G)
= Met
(C,A,A)
= Glu;
(C,A,C)
= His;
(C,G,_)
= Arg
(C,C,_)
= Pro
(C,T,_)
= Leu
(G,A,A)
= Glu;
(G,A,C)
= Asp;
(G,C,_)
= Ala
(G,G,_)
= Gly
(G,T,_)
= Val
(T,A,A)
= Stp;
genCode (A,A,G)
genCode (A,A,T)
= Lys
= Asn
genCode (A,G,G)
genCode (A,G,T)
genCode (A,T,C)
= Arg
= Ser
= Ile
genCode (C,A,G)
genCode (C,A,T)
= Glu
= His
genCode (G,A,G)
genCode (G,A,T)
= Glu
= Asp
genCode (T,A,G)
= Stp
2.4. Modellierung in der molekularen Genetik
genCode
genCode
genCode
genCode
genCode
genCode
genCode
(T,G,A)
(T,A,C)
(T,C,_)
(T,G,G)
(T,G,C)
(T,T,A)
(T,T,C)
=
=
=
=
=
=
=
Stp
Tyr;
Ser
Trp
Cys;
Leu;
Phe;
25
genCode (T,A,T)
= Tyr
genCode (T,G,T)
genCode (T,T,G)
genCode (T,T,T)
= Cys
= Leu
= Phe
Ein Gen beginnt stets mit dem „Startcodon“ (A,T,G) , setzt sich dann mit weiteren Codons
fort und endet mit dem ersten Auftreten eines Stopcodons. Die Translation, d.h. die Übersetzung des Gens in das codierte Protein erfolgt am Ribosom. Dies ist ein sehr komplexes
Molekül, ein Verbund aus Proteinen und Nukleinsäuren. Wir modellieren es durch seine
Funktion:
ribosome :: DNA -> Protein
ribosome (A:T:G:x) = Met:translate (triplets x) where
triplets :: [a] -> [(a,a,a)]
triplets []
= []
triplets (a:b:c:x) = (a,b,c):triplets x
translate :: [Codon] -> Protein
translate []
= []
translate (t:ts) = if aa == Stp then []
else aa:translate ts where
aa
= genCode t
Auch hier haben wir wieder einige Modellierungsentscheidungen getroffen, die man sich klarmachen muß. Dies betrifft die Behandlung inkorrekt aufgebauter Gene, die auch in der Natur
gelegentlich vorkommen. Fehlt das Startcodon, wird kein Protein produziert. Ist das Gen
unvollständig, d.h. bricht es ohne Stopcodon ab, so ist ein unvollständiges Proteinprodukt
entstanden, das entsorgt werden muß. Im Modell ist dies so ausgedrückt:
• Fehlt das Startcodon, gibt es keine passende Rechenregel für ribosome, und die Rechnung beginnt gar nicht erst.
• Ist das letzte Triplett unvollständig, bleibt die Rechnung in einer Formel der Art
triplets [a] oder triplets[a,b] stecken, für die wir keine Rechenregel vorgesehen
haben. Ein Fehlen des Stopcodons wird verziehen. Wer dies nicht will, streicht die erste
Regel für translate, so daß ohne Stopcodon die Rechnung am Ende in translate []
steckenbleibt.
Eine frühere Entscheidung hat sich hier gelohnt: Da wir den Listentyp polymorph eingeführt haben, können wir hier auch ganz nebenbei Listen von Codons, d.h. den Typ [Codon]
verwenden.
26
2. Modellierung
Bisher haben wir Vorgänge modelliert, die in der Natur selbst vorkommen. Zum Abschluß nehmen wir den Standpunkt des Naturforschers ein. Heute liefern die großen Sequenzierprojekte dank weitgehender Automatisierung eine Fülle von DNA-Sequenzen immer
mehr Organismen. Von einigen ist sogar bereits das komplette Genom bekannt, etwa vom
Koli-Bakterium (ca. 3 Millionen Basenpaare) oder der Bäckerhefe (ca. 14 Millionen Basenpaare). Den Forscher interessiert nun unter anderem, welche Gene eine neue DNA-Sequenz
möglicherweise enthält. Sequenzabschnitte, die den Aufbau eines Gens haben, nennt man
ORFs (open reading frames). Ob es sich bei ihnen tatsächlich um Gene handelt, hängt
davon ab, ob sie jemals vom Ribosom als solche behandelt werden. Dies kann man der Sequenz heute nicht ansehen. Wohl aber ist es interessant, in einer neuen DNA-Sequenz nach
allen ORFs zu suchen und ihre Übersetzung in hypothetische Proteine vorzunehmen. Wir
wollen also für unsere Kollegin aus der Molekularbiologie eine Funktion definieren, die genau
dieses tut.
Damit ist unsere Aufgabe so bestimmt: Wir gehen von einem DNA-Doppelstrang aus, der
zu untersuchen ist. Als Ergebnis möchten wir alle ORFs in übersetzter Form sehen, die in
dem Doppelstrang auftreten.
Wir wollen diese Aufgabe durch eine Funktion analyseORFs lösen. Schon beim Versuch,
den Typ dieser Funktion anzugeben, wird klar, daß die Aufgabe zwar halbwegs ausreichend,
aber keineswegs vollständig beschrieben ist.
Was genau bedeutet „alle ORFs“? Weder in der DNA, noch in unserem Modell liegt
eine feste Einteilung der Nukleotidsequenz in Codons vor. Das Auftreten einer Dreiergruppe
A,T,G definiert implizit die Lage der folgenden Codons. Ein einzelnes Nukleotid kann so
drei verschiedenen Leserastern angehören, in denen es jeweils als 1., 2. oder 3. Element eines
Codons interpretiert wird. Damit kann ein DNA-Abschnitt überlappende ORFs enthalten.
Das gleiche gilt für den Gegenstrang, der dabei in umgekehrter Richtung abzulesen ist.
„Alle ORFs“ zu finden ist also etwas komplizierter als es zunächst erscheint. Das führt
zu der Frage, wie eigentlich das Ergebnis aussehen soll. Einfach eine Liste aller ORFs, die
vorkommen? Oder will man zu jedem ORF auch wissen, in welchem Leseraster auf welchem
Strang er liegt? Möchte man gar zusätzliche Informationen erhalten, wie etwa Länge eines
ORF und seine Lage relativ zum Anfang der DNA-Sequenz? Schließlich – wir wissen ja was
unsere Kollegin letzlich interessiert – könnte man die hypothetischen Proteine noch mit einer
Proteindatenbank abgleichen, um zu sehen, ob ein solches (oder ein ähnliches) Protein an
anderer Stelle bereits nachgewiesen wurde ...
Es ist immer ein gutes Entwurfsprinzip
• möglicherweise interessante Information, die während der Rechnung anfällt, auch im
Ergebnis sichtbar zu machen. Dies gilt hier für die Verteilung der ORFs auf die
Leseraster.
• die Funktionalität eines Entwurfs nicht zu überfrachten. Was gut und logisch einleuchtend durch zwei getrennte Funktionen realisiert werden kann, wird auch getrennt.
2.4. Modellierung in der molekularen Genetik
27
Dadurch entstehen kleine Bausteine von überschaubarer Komplexität, die möglicherweise auch in anderem Kontext wiederverwendbar sind.
Wir entscheiden, daß das Ergebnis eine Liste von 6 Listen übersetzter ORFs sein soll, getrennt nach Leseraster. Wir halten diesen Beschluß durch die entsprechende Typdeklaration
fest.
analyzeORFs :: DNA_DoubleStrand -> [[Protein]]
Da ein Startcodon an beliebiger Stelle stehen kann, bilden wir zunächst 6 verschiedene
Leseraster, die dann alle nach ORFs durchsucht werden.
frames3 :: DNA -> [[Codon]]
frames3 x = if length x < 3 then [[],[],[]]
else [triplets x, triplets (tail x),
triplets (tail(tail x))] where
triplets :: [a] -> [(a,a,a)]
triplets []
= []
triplets [_]
= []
triplets [_,_]
= []
triplets (a:b:c:x) = (a,b,c):triplets x
findStartPositions :: [Codon] -> [[Codon]]
findStartPositions []
= []
findStartPositions (c:x) = if c == (A,T,G) then (c:x):findStartPositions x
else findStartPositions x
analyzeORFs (strain,antistrain)
= map (map translate) orfs where
sixframes = frames3 strain ++ frames3 (reverse antistrain)
orfs
= map findStartPositions sixframes
Der polymorphe Listendatentyp wird hier in verschiedener Weise benutzt; das Ergebnis
z.B. ist eine Liste von Listen von Listen von Aminosäuren. Wir unterstellen Funktionen (++)
und reverse, die Listen aller Art verketten bzw. umkehren. Die Umkehrung des Gegenstrangs ist notwendig, weil dieser am Ribosom in gegenläufiger Richtung gelesen wird. Die
Funktion map schließlich wendet eine Funktion f auf alle Elemente einer Liste an, so daß
z.B. gilt map f [x,y,z] = [f x, f y, f z].
Einige Beispiele:
dna_seq3 = dnaPolymerase [A,A,T,G,T,C,C,A,T,G,A,A,T,G,C]
dna_seq4 = dnaPolymerase [A,T,G,A,T,G,A,A,T,G,C,C,G,G,C,A,T,T,C,A,T,C,A,T]
28
2. Modellierung
analyzeORFs dna_seq3 ==>
[[],
[[Met, Ser, Met, Asn], [Met, Asn]],
[[Met]],
[[Met, Asp, Ile]],
[],
[]]
analyzeORFs dna_seq4 ==>
[[[Met, Met, Asn, Ala, Gly, Ile, His, His],
[Met, Asn, Ala, Gly, Ile, His, His]],
[[Met, Pro, Ala, Phe, Ile]],
[],
[[Met, Met, Asn, Ala, Gly, Ile, His, His],
[Met, Asn, Ala, Gly, Ile, His, His]],
[[Met, Pro, Ala, Phe, Ile]],
[]]
Daß bei dna_seq4 auf Strang und Gegenstrang die gleichen hypothetischen Proteine gefunden werden, liegt am besonderen Aufbau dieser Beispielsequenz: Strang und reverser
Gegenstrang (in umgekehrter Leserichtung) sind gleich.
2.5. Anforderungen an Programmiersprachen
Aus der Sprache der Mathematik haben wir den Umgang mit Formeln übernommen. Dabei
zeigt sich gleich ein wichtiger Unterschied: Die „Sprache“ der Mathematik ist im wesentlichen
fertig – die meisten Anwender kommen ein Leben lang mit den einmal erlernten Formeln und
Begriffen – sagen wir einmal aus Arithmetik, Algebra und Analysis – aus. Erweiterungen
dieser Sprache, die Einführung neuer Begriffe und Notationen bleibt einer kleinen Gemeinde
von Forschern vorbehalten, die in dieser Hinsicht keinen Regeln unterliegen. In der Informatik
ist das Gegenteil der Fall. Modellierung bedeutet Schaffung neuer Formelwelten. Jeder Programmierer führt neue Objekte (Typen) und Beziehungen zwischen ihnen (Funktionen) ein.
Wenn ein Formalismus ständig erweitert wird und trotzdem allgemein verständlich und sogar
durch den Computer ausführbar sein soll, dann muß es einen festen Rahmen dafür geben, was
man wie hinschreiben darf. Dies begründet die besondere Rolle der Programmiersprachen
in der Informatik. Ihre Syntax legt fest, wie etwas aufgeschrieben wird. Ihre Semantik bestimmt, wie mit diesen Formeln zu rechnen ist, sei es durch uns selbst, sei es durch den
Computer. Eine präzise definierte Syntax und Semantik ist die Mindestanforderung an eine
Programmiersprache.
Anhand der vorangehenden Abschnitte können wir einige Beobachtungen dazu machen,
was eine Programmiersprache darüber hinaus leisten muss.
2.5. Anforderungen an Programmiersprachen
29
• Ein strenges, aber flexibles Typkonzept hält die Formelwelt in Ordnung. Streng bedeutet, daß jeder Formel ein Typ zugeordnet wird und sich daher fast alle Programmierfehler bereits als Typfehler bemerkbar machen. Flexibel heißt andererseits, daß
uns das Typkonzept nicht zwingen darf, die gleiche Operation mehrfach zu programmieren, nur weil sich die Typen unwesentlich unterscheiden.
• Ein hierarchisch organisierter Namensraum erlaubt die Kontrolle über die Sichtbarkeit
von Namen. Definitionen können ihrerseits lokale Bezeichnungen einführen, die nach
außen hin nicht sichtbar sind. Die Funktion triplets haben wir zweimal und leicht
unterschiedlich definiert, jeweils als lokale Definition innerhalb der Definitionen von
ribosome und frames3. Ohne lokal beschränkte Sichtbarkeit könnte man sich bei
größeren Programmen vor Namenskonflikten nicht retten – man denke allein an die
vielfache Verwendung der Variablen „x“ . . . .
• Methoden zum Nachweis von Programmeigenschaften erlauben uns, die Übereinstimmung des Modells mit wesentlichen Aspekten der modellierten Wirklichkeit zu verifizieren.
Auch die Korrektheit einzelner Funktionen sollte man einfach nachweisen können, indem man algebraische Eigenschaften wie im Fall der exonuclease benutzt.
• Ein hohes Abstraktionsniveau der Programmiersprache gewährleistet eine überschaubare
Beziehung zwischen Modell und Wirklichkeit. Wir wollen Rechenregeln formulieren, die
für uns nachvollziehbar sind. Schließlich müssen zuerst wir selbst unsere Programme
verstehen. Vom Computer nehmen wir einfach an, daß er damit klarkommt. (Dafür zu
sorgen ist natürlich die Aufgabe des Übersetzers für die jeweilige Programmiersprache.)
Sprachen auf geringem Abstraktionsniveau verlangen doppelten Aufwand, weil die formale Beschreibung der Modellwelt und die Programmierung auseinanderfallen.
Wir haben Haskell als Programmiersprache gewählt, weil es derzeit die obigen Kriterien
am besten erfüllt. Wir werden im Laufe der Vorlesung noch weitere Anforderungen an Programmiersprachen kennenlernen, und nicht alle sehen Haskell als Sieger.
3. Eine einfache Programmiersprache
Ziele des Kapitels: Im Kapitel Modellierung haben wir uns darauf konzentriert, wie man
Objekte und Zusammenhänge der Realität durch Formeln und ihr Verhalten rechnerisch
nachbildet. Wir haben uns darauf verlassen, daß jeder mit Formeln rechnen kann, auch wenn
diese etwas anders aussehen als aus der Mathematik gewohnt. In diesem Kapitel nehmen
wir die umgekehrte Perspektive ein und betrachten näher, wie die Formeln aussehen, mit
denen wir rechnen (lassen) wollen. Mit anderen Worten: wir legen eine Programmiersprache
fest. Diese Sprache wollen wir möglichst einfach halten, da sie uns nur als Vehikel dient, um
über weitere Grundkonzepte der Informatik reden zu können. Wir werden also nur einen
Auschnitt aus der Sprache Haskell einführen.
Gliederung des Kapitels: Ein Haskell-Programm besteht aus einer Folge von Definitionen
und Deklarationen: Definiert werden Werte, meistens Funktionen, und neue Datentypen;
deklariert werden die Typen von Werten. Für Rechnungen spielen Deklarationen keine Rolle;
sie sind nur der „Ordnung halber“ da. Aber wie sagt man so schön: „Ordnung ist das halbe
Leben.“.
3.1. Datentypen
3.1.1. Datentypdefinitionen
Neue Typen und die dazugehörigen Werte werden mittels einer Datentypdefinition definiert.
Sie führen Daten-Konstruktoren (kurz Konstruktoren) ein, die unsere Formelwelt bereichern.
Beispiele aus Abschnitt 2.1 sind Musik und Instrument, die wir hier noch einmal wiederholen:
data Instrument =
|
|
|
Oboe
HonkyTonkPiano
Cello
VoiceAahs
data Musik
Note Ton Dauer
Pause Dauer
Musik :*: Musik
Musik :+: Musik
Instr Instrument Musik
=
|
|
|
|
Grit Garbo: Mit anderen Worten: ganz schön ungewohnt.
32
3. Eine einfache Programmiersprache
| Tempo GanzeZahl
Musik
Der Typ Instrument führt nur nullstellige Konstruktoren ein. Solche Typen nennt man
Aufzählungstypen. Der Typ Musik hat einen einstelligen Konstruktor (Pause) und fünf zweistellige Konstruktoren (Note, (:*:), (:+:), Instr, Tempo). Diese Konstruktoren nehmen
wiederum Argumente eines bestimmten Typs, hier etwa Ton und Dauer für Note und Instrument
und Musik bei Instr.
Die Typen der Konstruktor-Argumente können auch Typparameter sein, wie wir das schon
beim Listentyp gesehen haben. Alle Typparameter werden als Argumente des Typnamens
auf der linken Seite der Definition angegeben. Die Datentypdefinition selbst wird mit dem
Schlüsselwort1 data eingeleitet. Die allgemeine Form einer Datentypdefinition hat somit die
Gestalt
data T a1 . . . am
=
C1 t11 . . . t1n1
|
...
|
Cr tr1 . . . trnr
mit m > 0, r > 1 und ni > 0. Im Falle m > 0 bezeichnet man den neuen Typ T auch
als Typkonstruktor, weil er in Analogie zu den Daten-Konstruktoren Typen als Argumente
nimmt und daraus Typausdrücke bildet. Allerdings treten diese nur in Typangaben auf und
sind keine Formeln, mit denen gerechnet wird.
Programmierhinweis: Die Namen von Typen bzw. Typkonstruktoren und Konstruktoren müssen
großgeschrieben werden (genauer: der erste Buchstabe groß, alle folgenden beliebig). Die Namen
von Werten bzw. Funktionen, Parametern und Typparametern müssen hingegen klein geschrieben
werden (genauer: der erste Buchstabe klein, alle folgenden beliebig). Jeder Name darf nur einmal
verwendet werden. Ausnahme: Für einen Typ und einen Konstruktor bzw. für eine Variable und
einen Typparameter darf der gleiche Name benutzt werden, da sich Werte und Typen „nicht in die
Quere kommen“.
Wir sehen uns nun einige Datentypen von allgemeiner Nützlichkeit an. Wir geben jeweils
die Typdefinition an, erläutern die Semantik des Typs, und gehen gegebenenfalls auf spezielle
Notationen ein.
Wahrheitswerte
data Bool = False | True
Übung 3.1 Definiere (&&), (||) und not.
-- vordefiniert
Die Konstruktoren False und True bezeichnen die Wahrheitswerte Falsch und Wahr. Der
Typ Bool ist ein Aufzählungstyp. Die Boole’schen Funktionen Konjunktion, Disjunktion und
Negation werden in Haskell mit (&&), (||) und not notiert.
1 Ein
Schlüsselwort ist ein reservierter Name, der nicht für Funktionen etc. verwendet werden kann.
3.1. Datentypen
33
ifThenElse :: Bool -> a -> a -> a
ifThenElse True a a’ = a
ifThenElse False a a’ = a’
Für die Funktion ifThenElse gibt es als besondere Schreibweise die sogenannte MixfixNotation, bei der der Funktionsname zwischen die Argumente eingestreut wird, etwa wie in
if x >= 0 then x else -x.
Ganze Zahlen
Ganze Zahlen sind vordefiniert als die Typen Integer und Int. Während Integer ganze
Zahlen beliebiger Größe enthält, sind die Zahlen im Typ Int auf einen bestimmten Zahlenbereich begrenzt, der mindestens das Intervall [−229 , 229 − 1] umfaßt. Die Zahlen vom Typ
Int heißen auch Maschinenzahlen, während ihre Kolleginnen vom Typ Integer mathematische Zahlen genannt werden. Auf letzteren kann mit beliebiger Genauigkeit gerechnet werden,
wobei man die Genauigkeit mit einer kleinen Geschwindigkeitseinbuße erkauft. Auf ganzen
Zahlen sind die üblichen arithmetische Operationen vordefiniert: (+), (-), (*), div, mod und
(ˆ). Man beachte, daß es keine „echte Division“ auf den ganzen Zahlen gibt: div bezeichnet
die Division mit Rest. Es gilt der folgende Zusammenhang:
(a ‘div‘ b) * b + (a ‘mod‘ b)
==
a
Harry Hacker: Lieber schnell und falsch als langsam und
genau.
(3.1)
für alle ganzen Zahlen a und b.
Tupeltypen
data Pair a b
= Pair a b
data Triple a b c = Triple a b c
Der Typkonstruktor Pair bildet Paare. Genauer: Pair a b umfaßt alle Paare, deren erste
Komponente vom Typ a und deren zweite vom Typ b ist. Entsprechend umfaßt Triple a b c
alle 3-Tupel. Für beide Typkonstruktoren gibt es äquivalente, vordefinierte Typkonstruktoren: statt Pair a b schreibt man (a, b), statt Triple a b c schreibt man (a, b, c).
Nicht nur Paare und Tripel sind vordefiniert, sondern beliebige n-Tupel.
Bei Typkonstruktoren, die nur einen Datenkonstruktor haben, ist es üblich, für beide den
gleichen Namen zu verwenden: Pair 3 False ist ein Element vom Typ Pair Integer Bool.
Oder, in der Tupel-Notation: (3, False) ist ein Element vom Typ (Integer, Bool). Die
Konvention, für Werte und Typen die gleiche Notation zu verwenden, ist am Anfang oft
verwirrend. Hat man sich aber erst einmal daran gewöhnt, ist es sehr bequem, da man sich
nur eine Notation merken muß.
Für Paare sind Selektorfunktionen für das erste bzw. zweite Element vordefiniert.
Übung 3.2 Definiere Selektorfunktionen first, second und
third für 3-Tupel.
34
Lisa Lista: Kann man Folgen nicht auch anders modellieren?
Wie wär’s mit:
data List a = Empty
| Single a
| App (List a) (List a)
Der Konstruktor Empty ist wie gehabt die leere Liste; Single a
meint eine einelementige Liste und App hängt zwei Listen
aneinander.
Harry Hacker: Witzig. Ich hab schon mal weitergeblättert:
in Abschnitt 3.4 wird was ähnliches eingeführt. Die nennen das
aber anders: Tree statt List, Nil statt Empty, Leaf statt Single
und Br statt App.
Prof. Paneau: Liebe StudentInnen, nun wollen wir uns nicht
an Namen stören: allein die Struktur zählt. Lisa greift etwas vor;
aber sie hat Recht: beide Darstellungen sind denkbar. Und: beide
haben ihre Vor- und Nachteile. Frau Lista, ich empfehle Ihnen,
(++), head, tail und reverse auf Ihrer Variante des Datentyps
List zu definieren.
Lisa Lista: Hey, (++) ist simpel:
(++) :: List a -> List a -> List a
(++) = App
Oh je, head und tail sind komplizierter.
Übung 3.3 Hilf Lisa bei der Definition von head und tail.
Lisa Lista: Die Definition von reverse ist elegant.
reverse’
reverse’
reverse’
reverse’
:: List a ->
Empty
=
(Single a) =
(App l r) =
List a
Empty
Single a
App (reverse’ r) (reverse’ l)
3. Eine einfache Programmiersprache
fst :: (a,b) -> a
fst (a,b) = a
-- vordefiniert
snd :: (a,b) -> b
snd (a,b) = b
-- vordefiniert
Listen
data List a = Empty | Front a (List a)
Listen modellieren Folgen oder Sequenzen, also Sammlungen von Elementen, bei denen die
Reihenfolge der Elemente und die Häufigkeit eines Elements eine Rolle spielt. Dies unterscheidet sie von Mengen, die sowohl von der Reihenfolge als auch von der Häufigkeit abstrahieren.
Der Typ List a umfaßt alle Listen über dem Grundtyp a: Empty ist die leere Liste,
Front a x ist die Liste, deren erstes Element a ist, und deren restliche Elemente mit den
Elementen von x übereinstimmen; a wird als Kopfelement und x als Restliste bezeichnet.
Der Typ List a ist rekursiv definiert, da List a auch als Argumenttyp der Konstructors
Front auftritt. Rekursive Typen haben wir auch schon im Falle von Musik kennengelernt.
Neu ist hier nur, daß im Falle eines Typkonstruktors auch auf der rechten Seite der Typparameter angegeben werden muß.
Auch für List a gibt es einen äquivalenten, vordefinierten Typ, den wir bereits aus Abschnitt 2.4 kennen: Statt List a schreibt man [a], Empty wird zu [] und Front e x zu
e:x. Um Listen mit n Elementen aufzuschreiben, gibt es eine abkürzende Schreibweise: Statt
e1 :(e2 :· · ·(en :[])· · ·) schreibt man kurz [e1 ,e2 ,...,en ].
Listen sind allgegenwärtig, und es gibt viele vordefinierte Funktionen. Der Operator (++)
hängt zwei Listen aneinander; head und tail greifen das Kopfelement bzw. die Restliste
heraus; reverse kehrt die Reihenfolge der Elemente um.
(++) :: [a] -> [a] -> [a]
[]
++ bs = bs
(a:as) ++ bs = a:(as++bs)
head :: [a] -> a
head (a:as) = a
tail :: [a] -> [a]
tail (a:as) = as
reverse :: [a] -> [a]
reverse []
= []
reverse (a:as)= reverse as ++ [a]
3.1. Datentypen
35
Der Typ Maybe
data Maybe a = Nothing | Just a
-- vordefiniert
Der Typ Maybe a enthält die Elemente von Typ a (in der Form Just a) und zusätzlich das
Element Nothing. Oft wird Maybe zur Fehlerindikation und Fehlerbehandlung eingesetzt:
Nothing zeigt an, daß eine Operation nicht erfolgreich war; Just e zeigt an, daß die Operation erfolgreich mit dem Resultat e abgeschlossen worden ist. Auch Maybe a ist vordefiniert.
Wie List a und Pair a b ist Maybe a ein parametrisierter Typ, ein Typkonstruktor.
Als Beispiel nehmen wir an, wir wollen das kleinste Element einer Liste von Zahlen bestimmen. Bei einer nicht-leeren Liste ist dies klar:
minimum1 :: [Integer] -> Integer
minimum1 [a]
= a
minimum1 (a:as) = min a (minimum1 as)
Lisa Lista: Diese Definition ist nicht geheuer. Schließlich kann
man [a] ebensogut als a:[] schreiben. Welche Gleichung gilt
dann? Die zweite Gleichung führt zu der verbotenen Anwendung
minimum1 [].
Die Funktion minimum1 ist für leere Listen nicht definiert. Will man auch den Fall leerer
Listen handhaben, muß man die Funktion etwas allgemeiner definieren:
minimum0 :: [Integer] -> Maybe Integer
minimum0 []
= Nothing
minimum0 (a:as) = Just (minimum1 (a:as))
Wenn man so will, spielt Nothing hier die Rolle von +∞, dem neutralen Element von
min.
Zeichen und Zeichenketten
data Char
= ... | ’0’ | ’1’ ...
| ... | ’A’ | ’B’ ...
| ... | ’a’ | ’b’ ...
-- Pseudo-Haskell
type String = [Char]
Der Typ Char umfaßt die Zeichen des ASCII-Alphabets. Die Zeichen werden in Apostrophe
gesetzt, um sie von den sonst im Programm verwendeten Bezeichnungen zu unterscheiden.
Der Datentyp String beinhaltetet Zeichenketten, auch Texte genannt. Er ist eine Spezialisierung des polymorphen Listentyps: Zeichenketten sind Listen mit Elementtyp Char. Er
führt keine neuen Konstruktoren ein, sondern wird als Typsynonym definiert. (Mehr zu
Typsynonymen findet man in Abschnitt 3.1.2.) Da Zeichenketten häufig verwendet werden,
gibt es eine bequeme Notation: statt der Liste [’M’,’a’,’r’,’v’,’i’, ’n’] schreibt man
"Marvin", statt [] einfach "". Auf Zeichenketten sind natürlich alle Listenoperationen
anwendbar, wie (++) oder reverse.
Lisa Lista: Hmm — dann muß [] den Typ [a] haben, aber ""
den Typ String.
36
3. Eine einfache Programmiersprache
Programmierhinweis: Ein doppelter Anführungsstrich beendet eine Zeichenkette. Damit stellt
sich die Frage, wie man einen Anführungsstrich in einer Zeichenkette unterbringt: wie schreibt man
[’"’, ’a’, ’"’] kurz? Zu diesem Zweck gibt es sogenannte Ersatzdarstellungen, die mit einem
Backslash „\“ eingeleitet werden: "ä\"". Den gleichen Trick verwendet man, um einen einfachen
Anführungsstrich als Zeichen zu schreiben: ’\’’. Da der Backslash nun seinerseits eine Sonderrolle einnimmt, muß auch er mit einer Ersatzdarstellung notiert werden: "\\". Der Haskell-Report
führt in §2.5 alle Ersatzdarstellungen auf. Die Funktion isSpace illustriert die Verwendung von
Ersatzdarstellungen; sie überprüft, ob ein Zeichen ein Leerzeichen ist.
isSpace :: Char -> Bool
isSpace c = c == ’ ’ || c == ’\t’ || c == ’\n’ ||
c == ’\r’ || c == ’\f’ || c == ’\v’
3.1.2. Typsynonyme
Typsynonyme bereichern die Formelwelt nicht. Manchmal macht es jedoch Sinn, einem
bestimmten Typ einen zweiten Namen zu geben. Oft ist dies nur bequem im Sinne einer Abkürzung. Manchmal will man andeuten, daß man die Objekte dieses Typs in einer
besonderen, eingeschränkten Weise verwendet. Verschiedene Typsynonyme haben wir schon
gesehen (Ton, Dauer, DNA, Protein, String). Ein weiteres Beispiel zeigt zugleich Nutzen und
Gefahr: Stellen wir uns vor, wir wollen in einem Programm Listen (Typ [a]) verwenden, die
aber stets geordnet sein sollen. Wir können ein Typsynonym einführen, das diese Absicht
ausdrückt:
type OrdList a = [a]
merge :: OrdList a -> OrdList a -> OrdList a
Im Typ der Funktion merge können wir damit ausdrücken, daß diese Funktion zwei geordnete Listen verknüpft und eine geordnete Liste als Ergebnis hat. Wir denken dabei an ein
Pedant der (++)-Funktion auf geordneten Listen. So weit so gut — unsere Absicht haben
wir damit ausgedrückt. Ob wir unser Ziel auch erreichen, liegt jedoch ausschließlich daran,
wie wir merge programmieren. Was die Typen betrifft, sind Ordlist a und [a] gleich, so
daß von daher niemand garantiert, daß das Ergebnis von merge tatsächlich geordnet ist. In
diesem Sinn können Typsynonyme auch einmal falsche Sicherheit stiften.
3.1.3. Typdeklarationen, Typprüfung und Typinferenz
Wenn wir neue Bezeichner einführen, werden wir stets ihre Typen deklarieren. Dies geschieht
in der Form, die wir in Abschnitt 2.2 kennengelernt haben. Die Deklaration x :: τ besagt,
daß wir für den Bezeichner x den Typ τ vorsehen. Streng genommen müssen die Typen nicht
deklariert werden, da sie aus der Definition der Bezeichner abgeleitet werden können. Diesen
Vorgang nennt man Typinferenz. Es ist nützlich dieses Verfahren zumindest in groben Zügen
3.1. Datentypen
37
zu kennen. Denn: viele Programmierfehler äußern sich als Tippfehler; um dem Fehler auf die
Schliche zu kommen, muß man die Fehlermeldung verstehen. Betrachten wir die Definition
der vordefinierten Funktion map:
map f []
= []
map f (a:as) = f a : map f as
-- vordefiniert
Wir leiten in mehreren Schritten einen immer genaueren Typ für map ab. Dabei sind c, d, e
etc Typvariablen, die zunächst frei gewählt sind, dann aber durch Betrachtung der Definition
von map weiter spezifiziert werden.
Beobachtungen über map
map hat zwei Argumente.
Das Ergebnis ist eine Liste.
Das zweite Argument ist eine Liste.
Das erste Argument ist eine Funktion,
die Argumente vom Typ a erhält,
und Elemente der Ergebnisliste vom Typ b liefert.
Typ von map
c -> d -> e
c -> d -> [b]
c -> [a] -> [b]
(f -> g) -> [a] -> [b]
(a -> g) -> [a] -> [b]
(a -> b) -> [a] -> [b]
Die gewonnene Typaussage läßt sich etwas wortreich so formulieren: map ist eine Funktion,
die auf beliebige Funktionen f und Listen as anwendbar ist, vorausgesetzt der Argumenttyp
von f stimmt mit dem Elementtyp von as überein. Das Ergebnis ist dann eine Liste von
Elementen, die den Ergebnistyp von f haben. Zum Glück kann man dies auch kürzer sagen,
eben
map :: (a -> b) -> [a] -> [b]
Wir haben darauf geachtet, daß der Typ von map nicht unnötig eingeschränkt wird. Hätten wir grundlos den gleichen Typ für Argument- und Ergebnisliste angenommen, etwa
[a], hätten wir insgesamt den spezifischeren Typ (a -> a) -> [a] -> [a] erhalten. Bei
der Typinferenz wird immer der allgemeinste Typ hergeleitet, um den Einsatzbereich einer
Funktion nicht unnötig zu verengen.
Der Typ von map ist polymorph, da er Typparameter enthält. Wird map auf konkrete
Argumente angewandt, spezialisiert sich der Typ in der Anwendung.
Anwendung von map
map c’ [1/4, 1/8, 1/8, 1/4]
map (transponiere 5)
[cDurTonika, cDurSkala, bruderJakob]
map genCode cs
map (map translate) orfs
inneres Auftreten
äußeres Auftreten
Spezialisierung von
a
b
Dauer
Musik
Musik
Musik
Codon
AminoAcid
zwei Auftreten verschiedenen Typs!
Codon
Protein
[[Codon]] [Protein]
Harry Hacker: Muß ich mich wirklich mit diesem Typenwahn
herumschlagen? Korrekte Programme brauchen keine Typen.
Jede Funktion angewandt auf’s richtige Argument — da kann
ja gar nichts schiefgehen.
Lisa Lista: Mag schon sein, Harry. Aber wie kommst Du zu
den korrekten Programmen?
38
3. Eine einfache Programmiersprache
Der Haskell-Übersetzer führt stets die Typinferenz durch. Dies ist zugleich die Typ-überprüfung: Stößt die Typinferenz auf einen Widerspruch, so daß sich kein Typ ableiten läßt,
liegt eine nicht wohl-getypte Formel, kurz ein Typfehler vor. Daneben kann es geschehen,
daß der abgeleitete Typ spezieller ist als der deklarierte Typ. Auch in diesem Fall erfolgt
eine Fehlermeldung des Übersetzers, die man vermeiden kann, wenn man die Typdeklaration löscht. Wesentlich klüger ist es allerdings in einem solchen Fall, noch einmal darüber
nachzudenken, welche Funktion man eigentlich programmieren wollte. Der umgekehrte Fall
ist erlaubt: der abgeleitete Typ ist allgemeiner als der deklarierte. In diesem Fall wird die
Anwendbarkeit der Funktion bewußt oder vielleicht unbewußt eingeschränkt.
3.1.4. Typklassen und Typkontexte
Typklassen und Typkontexte werden hier nur kurz besprochen, insoweit es für die nächsten
Kapitel erforderlich ist. Wir werden sie im Kapitel über Typabstraktion genauer kennen
lernen.
Typklassen fassen Typen zusammen, die gewisse Operationen gemeinsam haben. Hier soll
nur gesagt werden, daß es u. a. vordefinierte Typklassen Eq, Ord, Num, Integral und Show
gibt. Die Typklasse
Harry Hacker: Also enthält Eq alle Typen. Warum dieser
Aufwand?
Lisa Lista: Hmm — wie steht’s mit Funktionen? Kann man
f == g für zwei Funktionen überhaupt implementieren? Und
was ist mit Typen, die Funktionen enthalten?
• Eq enthält alle Typen, deren Elemente man auf Gleichheit testen kann, und definiert
die folgenden Funktionen:
(==) :: (Eq a) => a -> a -> Bool
(/=) :: (Eq a) => a -> a -> Bool
• Ord enthält alle Typen, deren Elemente man bezüglich einer Ordnungsrelation vergleichen kann, und definiert u.a. die folgenden Funktionen:
(<), (<=), (>=), (>) :: (Ord a) => a -> a -> Bool
max, min
:: (Ord a) => a -> a -> a
• Num enthält alle numerischen Typen und definiert die grundlegenden arithmetischen
Operationen.
(+), (-), (*) :: (Num a) => a -> a -> a
negate
:: (Num a) => a -> a
Die Funktion negate entspricht dem unären Minus.
• Integral enthält die ganzzahligen Typen Int und Integer und definiert u.a. die
folgenden Funktionen:
3.2. Wertdefinitionen
39
div, mod :: (Integral a) => a -> a -> a
even, odd :: (Integral a) => a -> Bool
• Show enthält alle Typen, die eine externe Darstellung als Zeichenketten haben. Die
Funktion show liefert diese Darstellung.
show :: (Show a) => a -> String
Für alle vordefinierten Typen ist natürlich bereits festgelegt, welchen Typklassen sie angehören. Bei neu deklarierten Typen kann man dies für die Klassen Eq, Ord und Show erreichen,
indem der Typdefinition die Klausel deriving (Eq, Ord, Show) hinzugefügt wird. Dies
bewirkt, daß automatisch die oben genannten Operationen auch auf dem neuen Datentyp
definiert sind.
Typkontexte beschränken den Polymorphismus von Funktionen ein: Die Schreibweise
(K a) => ... besagt, daß der Typparameter a auf Typen eingeschränkt ist, die der Klasse
K angehören. So haben wir zum Beispiel in der Definition der Funktion minimum1 vorausgesetzt, daß die Listenelemente mittels min verglichen werden können. Da min den Typ
(Ord a) => a -> a - > a hat, müssen die verglichenen Listenelemente der Typklasse Ord
angehören. Dies ist der Fall: der Typ Integer gehört der Typklasse Ord an. Wollen wir
eine polymorphe minimum-Funktion definieren, so geschieht dies analog zu minimum1 unter
Benutzung eines Typkontextes (Ord a) =>.
minimum :: (Ord a) => [a] -> a
minimum [a]
= a
minimum (a:as) = min a (minimum as)
-- vordefiniert
Hier erkennt man die enorme praktische Bedeutung der Typklassen: Die obige Definition
definiert minimum auf allen Listen geeigneten Typs. Speziellere Versionen wie unser minimum1
sind überflüssig.
3.2. Wertdefinitionen
3.2.1. Muster- und Funktionsbindungen
Musterbindungen
Mit Hilfe einer oder mehrerer Gleichungen definieren wir einen oder mehrere Bezeichner. Im
einfachsten Fall hat die Gleichung die Form x = e, wobei x der definierte Bezeichner ist und
e der definierende Ausdruck. Gleichungen dieser Form heißen auch Variablenbindungen, da
die Variable an den Wert der rechten Seite gebunden wird.
40
3. Eine einfache Programmiersprache
theFinalAnswer :: Integer
theFinalAnswer = 42
aShortList :: [Integer]
aShortList = [1,2,3]
helloWorld :: String
helloWorld = "Hello World"
Das Gleichheitszeichen drückt aus, daß die linke Seite und die rechte Seite per definitionem
gleich sind; somit können wir theFinalAnswer stets durch 42 ersetzen und umgekehrt, ohne
dabei die Bedeutung des Programms zu verändern. Weiterhin tut es einer Definition keinen
Abbruch, wenn der Ausdruck auf der rechten Seite erst noch auszurechnen ist:
theFinalAnswer’ :: Integer
theFinalAnswer’ = 6*7
aShortList’
aShortList’
helloWorld’
helloWorld’
Lisa Lista: Hmm — monotonie ist doch identisch mit
dem Funktionsaufruf adInfinitum (c’ (1/1)). Aber auch die
rekursive Definition von adInfinitum ist ungewöhnlich.
:: [Integer]
= reverse ([3]++[2,1])
:: String
= "Hello" ++ " " ++ "World"
Diese Bezeichner haben den gleichen Wert wie die zuvor definierten. Interessantere Beispiele
für Variablenbindungen sind cDurSkala und bruderJakob aus Kapitel 2.
Bezeichner können auch rekursiv definiert sein. Von Funktionen sind wir das mittlerweile
gewohnt; neu und überraschend ist vielleicht, daß Elemente beliebiger Datentypen rekursiv
definiert sein können.
monotonie :: Musik
monotonie = c’ (1/1) :*: monotonie
Was ist an dieser Definition ungewöhnlich? Genau — das zu definierende Objekt wird
durch sich selbst erklärt. Daß eine solche Definition nicht sinnlos ist, kann man sich auf zwei
Arten klarmachen. Beginnt man einfach, das Musikstück monotonie mit Hilfe seiner Definition auszurechnen, sieht man schnell, daß das (potentiell) unendliche Stück c’ (1/1) :*: c’ (1/1) :*: · · ·
entsteht. Fragt man sich, ob es ein Musikstück gibt, das die Definitionsgleichung von monotonie
erfüllt, kommt man (nach einigen Fehlversuchen mit endlichen Stücken) ebenfalls auf die
Antwort c’ (1/1) :*: c’ (1/1) :*: · · ·. Unendliche Objekte in der Art von monotonie
sind kein Problem in Haskell, man darf sie nur nie ganz ausrechnen (lassen).
Auf der linken Seite einer Gleichung darf auch ein sogenanntes Muster stehen. In diesem
Fall spricht man von einer Musterbindung; diese sind von der Form p = e, wobei p ein
Muster und e ein Ausdruck ist. Ein Muster ist eine Formel, die ausschließlich aus Variablen
3.2. Wertdefinitionen
41
und Konstruktoren aufgebaut ist: (a, b), a : a’ : as oder m1 :*: m2 sind Beispiele für
Muster. In einem Muster darf keine Variable mehrfach enthalten sein: (a, a) ist kein gültiges
Muster. Musterbindungen treten in erster Linie im Zusammenhang mit lokalen Definitionen
auf, die wir in Abschnitt 3.2.3 kennenlernen werden. Aus diesem Grund verzichten wir hier
auf die Angabe von Beispielen.
Allgemein: p = e, mit p Muster, e Ausdruck
Funktionsbindungen
Datentypen erlauben uns, Formeln zu bilden, Funktionen erlauben uns, mit ihnen zu rechnen.
Eine Funktion wird durch eine oder mehrere Gleichungen definiert. Generell gilt, daß diese
Gleichungen unmittelbar aufeinander folgen müssen, und nicht etwa im Programm verteilt
stehen dürfen.
Mehrfache Gleichungen dienen einer Fallunterscheidung über die Argumente. Im einfachsten und häufigsten Fall wird diese Unterscheidung anhand der Konstruktoren getroffen,
aus denen die Argumente aufgebaut sind. Entsprechend dem Typ der Argumente gibt es
im einfachsten Fall genau eine Gleichung pro Konstruktor. Fast alle bisherigen Definitionen
haben wir so geschrieben. Hier ein weiteres Beispiel:
length :: [a] -> Int
length []
= 0
length (a:as) = 1 + length as
-- vordefiniert
Der Typ [a] hat die zwei Konstruktoren [] und (:), die gerade die beiden relevanten Fälle
unterscheiden helfen. Analog in der Definition von transponiere in Kapitel 2: Der Typ
Musik hat sechs Konstruktoren, entsprechend werden sechs Gleichungen geschrieben.
42
3. Eine einfache Programmiersprache
Der allgemeine Fall ist etwas flexibler: Die Definition einer n-stelligen Funktion f durch
Mustergleichungen geschieht durch k > 1 Gleichungen der Form
f p11 . . . p1n
=
e1
...
=
...
f pk1 . . . pkn
=
ek
Dabei sind die pij Muster und die ei Ausdrücke. Es gilt die Einschränkung, daß die Muster
einer Gleichung keine Variable mehrfach enthalten dürfen. Die Ausdrücke ei dürfen jeweils die
Variablen aus den Mustern pi1 , . . . , pin enthalten. Da Muster beliebig kompliziert aufgebaut
sein können, kann es mehr Gleichungen als Konstruktoren geben.
3.2.2. Bewachte Gleichungen
Nicht immer lassen sich die relevanten Fälle allein anhand von Mustern unterscheiden. Fallunterscheidungen können aus diesem Grund auch mit Hilfe von sogenannten Wächtern
erfolgen. Die Funktion dropSpaces entfernt führende Leerzeichen aus einer Zeichenkette.
dropSpaces :: String -> String
dropSpaces []
= []
dropSpaces (c:cs)
| isSpace c
= dropSpaces cs
| otherwise
= c : dropSpaces cs
Der Boole’sche Ausdruck nach dem senkrechten Strich heißt Wächter, da er über die
Anwendung der jeweiligen Gleichung wacht. Der Ausdruck otherwise ist ein Synonym für
True und tritt — wenn überhaupt — als letzter Wächter auf. Damit ist schon gesagt, daß
die Anzahl der Wächter nicht auf zwei beschränkt ist.
Die Funktion squeeze komprimiert Wortzwischenräume auf ein einzelnes Leerzeichen.
squeeze :: String -> String
squeeze []
= []
squeeze (c:c’:cs)
| isSpace c && isSpace c’ = squeeze (c’:cs)
squeeze (c:cs)
= c : squeeze cs
Übung 3.4 Definiere member ohne Wächter unter Verwendung
der Boole’schen Funktionen (&&) und (||).
Die zweite Gleichung behandelt den Fall, daß die Zeichenkette mindestens zwei Elemente
umfaßt und beide Zeichen als Leerzeichen qualifizieren. Ist dies gegeben, so wird ein Leerzeichen entfernt.
Drei Wächter kommen bei der Definition von member zum Einsatz: member a bs überprüft, ob das Element a in der aufsteigend geordneten Liste bs enthalten ist.
3.2. Wertdefinitionen
member :: (Ord a)
member a []
=
member a (b:bs)
| a < b
=
| a == b
=
| a > b
=
43
=> a -> OrdList a -> Bool
False
False
True
member a bs
Ist das gesuchte Element a kleiner als der Listenkopf, kann die Suche unmittelbar abgebrochen werden.
3.2.3. Gleichungen mit lokalen Definitionen
Rechnen ist selten eine geradlinige Angelegenheit: Man führt Hilfsrechnungen durch, stellt
Zwischenresultate auf, verwendet diese in weiteren Zwischenrechnungen usw. Es ist nur
natürlich, dieses Vorgehen auch in unserer Programmiersprache zu ermöglichen. Der Zutaten bedarf es nicht vieler: Eine oder mehrere Formeln, die zwischengerechnet werden, einen
oder mehrere Namen für die Ergebnisse der Rechnungen und eine Formel, in der die Namen
verwendet werden.
Das folgende Programm, das die n-te Potenz einer ganzen Zahl x berechnet, illustriert die
Verwendung einer lokalen Definition.
power :: (Num a, Integral b) => a -> b -> a
power x n
| n == 0
= 1
| n ‘mod‘ 2 == 0 = y
| otherwise
= y*x
where y = power (x*x) (n ‘div‘ 2)
Nach dem Schlüsselwort where werden die Hilfsdefinitionen angegeben (hier ist es nur
eine). Mit Ausnahme von Typdefinitionen können alle Arten von Definitionen und Deklarationen, die wir bisher kennengelernt haben, auch lokal erfolgen. Die Funktion splitWord, die
ein Wort von einer Zeichenkette abtrennt, illustriert die Verwendung von lokalen Musterbindungen.
splitWord :: String -> (String, String)
splitWord []
= ([],[])
splitWord (c:cs)
| isSpace c = ([], c:cs)
| otherwise = (c:w, cs’)
where (w,cs’) = splitWord cs
Auf der linken Seite der lokalen Definition steht ein Muster, woran das Ergebnis der
rechten Seite gebunden wird. Auf diese Weise werden mehrere Variablen gleichzeitig definiert.
Harry Hacker: Warum ist die Definition so kompliziert? Wie
wär’s mit
pow :: (Num a) => a -> Integer -> a
pow x n
| n == 0
= 1
| otherwise = x * pow x (n-1)
Lisa Lista: Hmm — rechne mal pow 2 1024 aus.
Harry Hacker: Ja, ja, Du hast wieder mal recht. Aber daß die
Definition von power korrekt ist, sehe ich noch nicht.
Grit Garbo: Typisch, die Informatiker kennen die einfachsten
Dinge nicht. Hier handelt es sich um eine triviale Anwendung
der Potenzgesetze:
xn = x2(n div 2)+n mod 2 = (x2 )n div 2 xn mod 2
44
3. Eine einfache Programmiersprache
Musterbindungen treten typischerweise bei der Definition rekursiver Funktionen auf, die
Paare oder n-Tupel zum Ergebnis haben.
Gültigkeits- oder Sichtbarkeitsbereiche
Die where-Klausel führt neue Namen ein, genau wie andere definierende Gleichungen. Diese
neuen Namen haben einen lokalen Bereich, in dem sie benutzt werden können. Diesen Bereich nennt man Gültigkeits- oder Sichtbarkeitsbereich. Er erstreckt sich auf die rechte Seite
der Gleichung, mit der die where-Klausel assoziiert ist, und auf die rechten Seiten aller Gleichungen innerhalb der where-Klausel, einschließlich darin verschachtelter where-Klauseln.
Führt eine lokale Definition einen neuen Namen ein, der im umgebenden Kontext bereits
definiert ist, so gilt innerhalb des lokalen Sichtbarkeitsbereichs die lokale Definition. Man
sagt, die globale Definition wird verschattet. Schauen wir uns ein (Negativ-) Beispiel an:
f :: Int -> Int
f x = f (x+x)
where x
= 1
f x = x
Harry Hacker: Wer so programmiert, ist selber schuld!
-- Negativ-Beispiel
Der Bezeichner f wird zweimal definiert, die Variable x wird gar an drei Stellen eingeführt:
zweimal als Parameter und einmal in der lokalen Definition x = 1. Die inneren Definitionen
verschatten die äußeren, so daß f zu f1 mit
f1 :: Int -> Int
f1 x1 = f2 (x2+x2)
where x2
= 1
f2 x3 = x3
äquivalent ist. Man sieht, daß die Definition der Funktion f insbesondere nicht rekursiv
ist, da die lokale Definition bereits für den Ausdruck f (x + x) sichtbar ist und das global
definierte f verschattet. Da Programme in der Art von f schwer zu lesen und zu verstehen
sind, empfehlen wir, in lokalen Definitionen grundsätzlich andere („neue“) Bezeichner zu
verwenden.
Abseitsregel
Lisa Lista: Die Folgen dieser anderen Interpretation kommen
mir etwas unheimlich vor!
Eine Frage ist noch offen: Es ist klar, daß der lokale Sichtbarkeitsbereich der where-Klausel
nach dem Schlüsselwort where beginnt. Aber wo endet er eigentlich? Woher wissen wir, daß
die Gleichung f x = x Bestandteil der where-Klausel ist, und nicht etwa eine zweite (wenn
auch überflüssige) Gleichung für das global definierte f darstellt?
Antwort: der Übersetzer erkennt das Ende der where-Klausel am Layout des Programmtextes. Wir haben die lokale Definition entsprechend dem Grad ihrer Schachtelung eingerückt.
3.2. Wertdefinitionen
45
Diese Formatierung erleichtert es, ein Programm zu lesen und zu verstehen. Der Übersetzer
macht sich diese Konvention zunutze, um das Ende einer lokalen Definition zu erkennen. Es
gilt die folgende sogenannte Abseitsregel:
• Das erste Zeichen der Definition unmittelbar nach dem where bestimmt die Einrücktiefe des neu eröffneten Definitionsblocks.
Harry Hacker: Ist das noch zu fassen — die Bedeutung des
Programms ist vom Layout abhängig! Das ist ja wie in alten
FORTRAN-Tagen! Noch dazu jede Definition auf einer neuen
Zeile. Da werden die Programme viel länger als nötig.
• Zeilen, die weiter als bis zu dieser Position eingerückt sind, gelten als Fortsetzungen
einer begonnenen lokalen Definition.
• Zeilen auf gleicher Einrücktiefe beginnen eine neue Definition im lokalen Block.
• Die erste geringer eingerückte Zeile beendet den lokalen Block.
Die Abseitsregel erlaubt die Einsparung von Abgrenzungssymbolen wie begin, end oder
geschweiften Klammern und Semikola, die man in anderen Programmiersprachen verwenden muß. Außerdem trägt sie dazu bei, daß Programme verschiedener Autoren in einem relativ einheitlichen Stil erscheinen. Trotz dieser Vorzüge ist die Verwendung der Abseitsregel
optional — es gibt sie doch, die formatfreie Schreibweise: Eingeschlossen in geschweifte
Klammern und getrennt durch Semikolons (also in der Form {d1 ; ...; dn }) können die
Definitionen eines Blocks beliebig auf eine Seite drapiert werden. Ein Fall, wo dies sinnvoll
ist, ist eine lange Reihe sehr kurzer Definitionen.
Die allgemeine Form von Gleichungen
Jetzt haben wir alle Konstrukte kennengelernt, um die allgemeine Form einer Funktionsbindung einzuführen: Eine n-stellige Funktion f mit n > 1 kann durch k Gleichungen
definiert werden:
f
p11 . . . p1n m1
...
f
pk1 . . . pkn mk
Die pij sind beliebige Muster und die mi nehmen wahlweise eine der beiden folgenden Formen
an:
= e where { d1 ; . . . ;dp }
oder
| g1 = e1
...
| gq = eq
where { d1 ; . . . ;dp }
Der where-Teil kann jeweils auch entfallen. Bezüglich der Sichtbarkeit der Bezeichner gilt
folgendes: Die in den di eingeführten Bezeichner sind in dem gesamten Block sichtbar, d.h. in
Harry Hacker: Na also, sagt es doch gleich!
46
3. Eine einfache Programmiersprache
den Wächtern gi , in den rechten Seiten ei und in den Definitionen di selbst. Den gleichen
Sichtbartkeisbereich haben auch die Variablen, die in den Mustern auf der linken Seite
eingeführt werden: die in pi1 , . . . , pin enthaltenen Variablen sind in mi sichtbar (nicht aber
in den anderen Gleichungen).
Musterbindungen haben die allgemeine Form p m, wobei p ein Muster ist und m wie im
Fall von Funktionsbindungen aussieht. Im Unterschied zu Funktionsbindungen definiert eine
Musterbindung unter Umständen gleichzeitig mehrere Bezeichner.
3.2.4. Das Rechnen mit Gleichungen
Kommen wir zum Rechnen. Einen Teil seiner Schulzeit verbringt man damit, Rechnen zu
lernen: Ist zum Beispiel die Funktion q gegeben durch
q :: (Num a) => a -> a
q x = 3*xˆ2 + 1
dann wird man den Ausdruck q 2 - 10 unter Zuhilfenahme der Rechenregeln für die
Grundrechenarten ohne große Schwierigkeiten zu 3 ausrechnen:
q 2 - 10
⇒
(3 * 2ˆ2 + 1) - 10
⇒
(3 * 4 + 1) - 10
(Definition von (ˆ))
⇒
(12 + 1) - 10
(Definition von (*))
⇒
13 - 10
(Definition von (+))
⇒
3
(Definition von (-))
(Definition von q)
Das Rechnen in oder mit Haskell funktioniert nach dem gleichen Prinzip: Ein Ausdruck wird
relativ zu einer Menge von Definitionen zu einem Wert ausgerechnet. Die Notation e ⇒ v
verwenden wir, um dies zu formalisieren. Beachte: das Zeichen „⇒“ ist kein Bestandteil der
Programmiersprache, sondern dient dazu, über die Programmiersprache zu reden.
Wir unterscheiden zwei Formen des Rechnens: Da ist einmal das Ausrechnen eines Ausdrucks, seine Auswertung. Dabei werden die Gleichungen stets von links nach rechts angewandt: Tritt irgendwo in dem Ausdruck eine linke Seite einer Gleichung auf, so kann sie durch
die rechte Seite ersetzt werden (wobei die Variablen aus den Mustern der linken Seite an Unterausdrücke gebunden werden, die in die rechte Seite einzusetzen sind). Dabei ist zu beachten: Wird ein Bezeichner durch mehrere Gleichungen definiert, so muß die erste (im Sinne
der Aufschreibung) passende Gleichung verwendet werden. Die Gleichung f p1 . . . pn | g = e
paßt auf eine Anwendung f a1 . . . an , wenn die Argumente a1 , . . . , an aus den Konstruktoren
aufgebaut sind, die durch die Muster p1 , . . . pn vorgegeben sind und der Wächter g zu True
auswertet. Die Rechnung endet, wenn keine Gleichung anwendbar ist. Man sagt dann, der
3.2. Wertdefinitionen
47
Ausdruck ist in Normalform. Ein berühmter Satz von Church und Rosser besagt, daß die
Normalform, wenn sie existiert, eindeutig ist. Wir können also den Wert eines Ausdrucks mit
seiner Normalform identifizieren. Hat ein Ausdruck keine Normalform — d. h. die Rechnung
endet nie — so ist sein Wert undefiniert. Auswertung eines Ausdrucks auf Normalform —
das ist Rechnen lassen in einer funktionalen Programmiersprache.
Die zweite Form des Rechnens tritt auf, wenn wir selbst nicht nur Werte ausrechnen,
sondern über Programme nachdenken. Uns steht es dabei frei, Mustergleichungen auch von
rechts nach links anzuwenden, oder mit Gleichungen zu rechnen, die allgemeine Programmeigenschaften beschrieben und keine Mustergleichungen sind. Mehr dazu im Kapitel 4.
3.2.5. Vollständige und disjunkte Muster
Im Allgemeinen achtet man darauf, daß die Mustergleichungen vollständig und disjunkt sind. Vollständig heißt, daß die Funktion für alle möglichen Fälle definiert ist. Disjunkt heißt, daß die Muster
so gewählt sind, daß für jeden konkreten Satz von Argumenten nur eine Gleichung paßt. Die Definition von unique, das aufeinanderfolgende Duplikate aus einer Liste entfernt, erfüllt diese Forderungen.
unique []
= []
unique [a]
= [a]
unique (a:(b:z)) = if a == b then unique (b:z)
else a : unique (b:z)
Hier unterscheiden die Muster den Fall der null-, ein- oder mehrelementigen Liste.
Wir betrachten nun Situationen, in der die Forderungen Vollständigkeit und Disjunktheit nicht
erfüllt werden können:
Nehmen wir die Funktion head mit head [a1 ,...,an ] = a1 . Ist die Liste leer, ist das Funktionsergebnis nicht definiert. Was bedeutet dies für das Rechnen? Nun, ist man gezwungen head []
auszurechnen, so bleibt einem nicht anderes übrig als abzubrechen und aufzugeben. Das Beste,
was man als Programmiererin machen kann, ist den Abbruch der Rechnung mit einer Meldung zu
garnieren, die die Ursachen des Abbruchs beschreibt. Diesem Zweck dient die Funktion error.
head :: [a] -> a
-- vordefiniert
head []
= error "head of empty list"
head (a:x) = a
Diese Definition von head ist also eine Verbesserung der früher gegebenen, die nun einen vollständigen Satz von Mustergleichungen aufweist. Ähnlich kann man im Falle der Funktion tail
vorgehen.
Zur Übung: Definiere tail mit tail [a1 ,...,an ] = [a2 ,...,an ].
Sowohl head als auch tail bleiben auch in dieser Form partielle Funktionen, weil die Funktion
error ja kein Ergebnis liefert, sondern die Rechnung abbricht. Alternativ zur Benutzung von error
können wir beide Funktionen auch zu totalen Funktionen machen: Der Ergebnistyp wird um ein
48
3. Eine einfache Programmiersprache
Element erweitert, das gerade „undefiniert“ repräsentiert. Diesem Zweck dient ja der Typkonstruktor
Maybe.
head’ :: [Integer] -> Maybe Integer
head’ []
= Nothing
head’ (a:x) = Just a
Zur Übung: Definiere ein Pendant zu tail.
Welche der beiden Varianten man verwendet, hängt stark vom Anwendungsfall ab. In der Regel
wird es die erste sein, da diese bequemer ist: Z.B. sind head x:tail x oder tail (tail x) wohlgetypte
Ausdrücke, head’ x:tail’ x oder tail’ (tail’ x) hingegen nicht. Aber man muß als Programmiererin darauf achten, daß head und tail stets richtig aufgerufen werden. Die zweiten Varianten
ermöglichen, nein sie erzwingen es sogar, daß ein möglicher Fehler behandelt wird.
Die Disjunktheit der Mustergleichungen ist verletzt, wenn es Argumente gibt, für die mehrere
Gleichungen passen. Ein Beispiel dafür ist die Definition von words:
words :: String -> [String]
words xs = wds [] xs
ws :: String ->
wds "" ""
wds ws ""
wds "" (’ ’:xs)
wds "" ( x :xs)
wds ws (’ ’:xs)
wds ws ( x :xs)
String -> [String]
= []
= [reverse ws]
= wds "" xs
= wds [x] xs
= (reverse ws) : wds "" xs
= wds (x:ws) xs
Die dritte Gleichung für wds paßt auch dann, wenn die zweite paßt: Schließlich kann x ja ein
beliebiges Zeichen, also auch ’ ’ sein. Ebenso paßt die fünfte Gleichung auch dann, wenn die vierte
paßt. Klar: um diese Situation zu vermeiden, müßte man statt Gleichung 3 je eine Gleichung für
alle ASCII-Zeichen außer dem Leerzeichen anführen.
Im Falle nicht disjunkter Mustergleichungen gilt die Regel: Es ist stets die erste (im Sinne der
Aufschreibung) passende Gleichung anzuwenden.
3.3. Ausdrücke
Wenn wir rechnen, manipulieren wir Ausdrücke. Von daher ist es höchste Zeit, daß wir uns den
Aufbau von Ausdrücken genauer anschauen. Neben der Vertiefung bereits bekannter Konstrukte
werden wir drei neue Arten von Ausdrücken kennenlernen. Diese zählen zu den grundlegenden
Sprachkonstrukten von Haskell – grundlegend in dem Sinn, daß sie zwar weiter von der gewohnten
mathematischen Notation entfernt, dafür aber allgemeiner sind und sich viele bisher besprochenen
Konstrukte als vereinfachte Schreibweise aus ihnen ableiten lassen. Die grundlegenden Konstrukte werden wir insbesondere verwenden, um in Abschnitt 3.5 das Rechnen in Haskell formal zu
definieren.
3.3. Ausdrücke
49
3.3.1. Variablen, Funktionsanwendungen und Konstruktoren
Im einfachsten Fall besteht ein Ausdruck aus einem Bezeichner. Damit ein solcher Ausdruck Sinn
macht, muß der Bezeichner irgendwo eingeführt worden sein, entweder in einer Definition oder als
Parameter einer Funktion. Egal hingegen ist, ob der Bezeichner eine Funktion bezeichnet, eine Liste
oder ein Musikstück.
Die Anwendung einer Funktion f auf Argumente e1 , . . . , en notieren wir einfach, indem wir die
Funktion und die Argumente hintereinanderschreiben: f e1 . . . en . Sowohl f als auch die ei können
wiederum beliebige Ausdrücke sein, vorausgesetzt die Typen der Ausdrücke ei passen zu den Typen
der Funktionsparameter: Damit f e1 . . . en wohlgetypt ist, muß f den Typ σ1 -> · · · ->σn ->τ und
ei den Typ σi haben. In diesem Fall besitzt f e1 . . . en den Typ τ . Sind die Argumente wiederum
Funktionsanwendungen, müssen diese in Klammern gesetzt werden wie z.B. in min a (minimum as).
Lassen wir die Klammern fälschlicherweise weg, versorgen wir min ungewollt mit drei Argumenten,
wobei das zweite Argument eine Funktion ist.
Die Elemente eines Datentyps werden durch Konstruktoren aufgebaut. Die Anwendung eines Konstruktors auf Argumente wird genauso notiert wie die Anwendung einer Funktion. Konstruktoren
unterscheiden sich von Funktionen nur dadurch, daß sie in Mustern vorkommen dürfen.
Grit Garbo: Das ist doch wahrlich nicht aufregend: Punkt- vor
Strichrechnung kennt man doch schon aus dem Kindergarten.
Lisa Lista: Witzig: Wenn (-) rechts assoziierte, dann wären
a - b - c und a - b + c gleichbedeutend.
Bindungsstärke
9
8
7
6
5
4
4
linksassoziativ
!!
3
2
1
0
rechtsassoziativ
.
ˆ, ˆˆ, **
\\
==, /=, <,
<=, >, >=,
‘elem‘,
‘notElem‘
:, ++
*, /,
‘div‘, ‘mod‘,
‘rem‘, ‘quot‘
+, -
Infix-Notation
Als Infix-Operatoren bezeichnet man (zweistellige) Funktionen, die zwischen ihre Argumente geschrieben werden. Um Klammern zu sparen, werden Operatoren Prioritäten oder Bindungsstärken
zugeordnet: die Multiplikation (*) bindet z.B. stärker als die Addition (+); somit entspricht a + b * c
dem Ausdruck a + (b * c). Außerdem legt man fest, ob Operatoren links, rechts oder gar nicht
assoziativ sind: Die Subtraktion (-) zum Beispiel assoziiert links, d.h., die Klammern in a - b - c
sind so einzufügen: (a - b) - c. Die Regeln für in Haskell vordefinierte Operatoren findet man
in Tabelle 3.1. Führen wir selbst neue Infix-Operatoren ein, so müssen ihre Bindungsstärken und
assoziativen Eigenschaften deklariert werden. Wie dies geht, haben wir schon bei (:+:) und (:*:)
(in Kapitel 2) gesehen. Dies muß am Programmanfang geschehen, weil sonst der Übersetzer solche
Formeln gar nicht erst richtig lesen kann.
Die meisten Funktionen scheibt man in Präfix-Schreibweise, also vor ihre Argumente. Dabei
bindet eine Präfix-Funktion immer stärker als Infix-Operatoren. Daher ist f x + 1 gleichbedeutend
mit (f x) + 1, und nicht etwa mit f (x + 1). Meint man das Letztere, muß man eben die Klammern explizit schreiben. Diese Regel erklärt auch die Notwendigkeit der Klammern in Ausdrücken
wie Note ce (1/4). Hat man erst einmal etwas Haskell-Erfahrung gesammelt, wird man sehen, daß
diese Regeln sehr komfortabel sind.
Programmierhinweis: Wir verwenden zwei verschiedene Arten von Bezeichnern: alphanumerische
Bezeichner wie z.B. Note oder transponiere und symbolische wie z.B. „:“ oder „+“. In Haskell
werden symbolische Bezeichner stets infix und alphanumerische stets präfix notiert. Von dieser Festlegung kann man abweichen, indem man symbolische Bezeichner in runde Klammern einschließt
bzw. alphanumerische in Backquotes: a + b wird so zu (+) a b und div a b zu a ‘div‘ b. Symbolische Bezeichner können auch für Konstruktoren verwendet werden; einzige Bedingung: der Name
muß mit einem Doppelpunkt beginnen.
nicht
assoziativ
&&
||
>>, >>=
$, ‘seq‘
Tabelle 3.1: Bindungsstärken der vordefinierten Operatoren
Grit Garbo: Das ist mir völlig unverständlich! Warum sind (+)
und (*) linksassoziativ, (++) aber rechtsassoziativ? Alle diese
Operatoren sind doch assoziativ.
Übung 3.5 Versuche Grit zu helfen.
50
3. Eine einfache Programmiersprache
3.3.2. Fallunterscheidungen
Elemente eines Datentyps werden mit Hilfe von Konstruktoren konstruiert; sogenannte case-Ausdrücke
erlauben es, Elemente eines Datentyps zu analysieren und entsprechend ihres Aufbaus zu zerlegen. Konstruktor-basierte Fallunterscheidungen kennen wir schon von Funktionsbindungen; caseAusdrücke stellen Fallunterscheidungen in Reinform dar — sie ermöglichen eine Analyse, ohne daß
eine Funktionsdefinition vorgenommen werden muß. Hier ist die Definition von length unter Verwendung eines case-Ausdrucks:
length’ :: [a] -> Int
length’ as = case as of
[]
-> 0
a:as’ -> 1 + length’ as’
Nach dem Schlüsselwort case wird der Ausdruck aufgeführt, der analysiert werden soll, der sogenannte Diskriminatorausdruck. Die Behandlung der verschiedenen Fälle oder Alternativen wird
nach dem Schlüsselwort of angegeben, nach dem sich ein lokaler Sichtbarkeitsbereich für die Alternativen eröffnet. Jede Alternative hat im einfachsten Fall die Form p -> e, wobei p ein Muster ist
und e der Rumpf der Alternative.
Ähnlich wie length läßt sich jede Funktionsbindung mit Hilfe von case umschreiben. Funktionsbindungen können wir somit als eine schönere Notation ansehen für Definitionen, die als erstes
eine case-Unterscheidung auf einem Funktionsargument vornehmen. Im Vergleich zur Fallunterscheidung mittels Gleichungen sind case-Ausdrücke flexibler — zum einen da es Ausdrücke sind
und zum anderen weil ein Ausdruck und nicht nur ein Funktionsparameter analysiert werden kann.
Betrachte:
last’ :: [a] -> a
last’ as = case reverse as of a:as’ -> a
Die Funktion last entnimmt das letzte Element einer Liste, sofern vorhanden, durch eine Fallunterscheidung auf der umgekehrten Liste.
Von der Form sind case-Ausdrücke Funktionsbindungen jedoch sehr ähnlich. Jede Alternative
kann z.B. Wächter oder lokale Definitionen enthalten:
case e of { p1 m1 ; . . . ;pn mn }
Die pi sind beliebige Muster und die mi nehmen wahlweise eine der beiden folgenden Formen an:
-> e where { d1 ; . . . ;dp }
oder
| g1 -> e1
...
| gq -> eq
where { d1 ; . . . ;dp }
3.3. Ausdrücke
51
Der where-Teil kann jeweils auch entfallen. Dies entspricht der Form von Funktionsbindungen
mit dem einzigen Unterschied, daß anstelle des Gleichheitszeichens ein Pfeil steht. Auch für caseAusdrücke gilt die Abseitsregel, so daß das Ende der Alternativen bzw. das Ende des gesamten
Ausdrucks entweder mittels Layout oder durch Angabe von Trennsymbolen angezeigt werden kann.
Schauen wir uns noch zwei Beispiele an: Die Funktion words spaltet eine Zeichenkette in eine
Liste von Zeichenketten auf, indem sie an den Leerzeichen trennt.
words :: String -> [String]
-- vordefiniert
words cs = case dropSpaces cs of
[] -> []
cs’ -> w : words cs’’
where (w,cs’’) = splitWord cs’
Hier ist eine alternative Definition von minimum0, bei der eine Fallunterscheidung über das Ergebnis des rekursiven Aufrufs vorgenommen wird.
minimum0’ :: (Ord a) => [a] -> Maybe
minimum0’ []
= Nothing
minimum0’ (a:as) = case minimum0’ as
Nothing -> Just
Just m -> Just
a
of
a
(min a m)
3.3.3. Funktionsausdrücke
Betrachten wir einen Ausdruck wie n + 1. In einem Kontext, in dem n gegeben ist, bezeichnet er
einen Wert, den man ausrechnen kann, nachdem man den Wert von n ausgerechnet hat. Betrachten
wir dagegen n als variabel, so stellt der Ausdruck eine Funktion dar, die in Abhängigkeit von
der Variablen n ein Ergebnis berechnet. Genauer gesagt haben wir: f n = n + 1. Der Name f ist
natürlich mehr oder weniger willkürlich gewählt. Manchmal lohnt es nicht, sich für eine Hilfsfunktion
einen Namen auszudenken: Wer viel programmiert weiß, daß das Erfinden von aussagekräftigen
Bezeichnern zu den schwierigsten Aufgaben gehört. Aus diesem Grund gibt es sogenannte anonyme
Funktionen: Wollen wir ausdrücken, daß n + 1 eine Funktion in Abhängigkeit von n ist schreiben
wir
\n -> n + 1
Einen Ausdruck dieser Form nennen wir Funktionsausdruck, da er eine Funktion beschreibt. Der
Schrägstrich (ein abgemagertes λ) läutet den Funktionsausdruck ein, es folgt der formale Parameter
der Funktion und nach dem Pfeil „->“ der Rumpf der Funktion. Wir haben bereits gesehen, daß in
Haskell für Ausdrücke und Typen häufig die gleiche Notation verwendet wird. Funktionsausdrücke
sind dafür ein gutes Beispiel: Wenn x den Typ σ und e den Typ τ hat, dann besitzt der Ausdruck \x -> e den Typ σ -> τ . Etwas abstrakter gesehen ist „->“ wie auch Pair ein 2-stelliger
Typkonstruktor. Im Unterschied zu Pair können wir „->“ aber nicht selbst definieren.
52
3. Eine einfache Programmiersprache
Im allgemeinen hat ein Funktionsausdruck die Form
\p1 . . . pn -> e .
Die pi sind beliebige Muster und e ist ein Ausdruck. Hat ein Funktionsausdruck mehr als einen
Parameter, kann er als Abkürzung aufgefaßt werden für entsprechend geschachtelte Funktionsausdrücke: \p1 p2 -> e ist gleichbedeutend mit \p1 -> \p2 -> e. Für den zugehörigen Typausdruck
gilt dies in ähnlicher Weise: haben wir x1 :: σ1 , x2 :: σ2 und e :: τ , dann ist \x1 -> \x2 -> e
vom Typ σ1 -> (σ2 -> τ ). Wir vereinbaren, daß wir rechtsassoziative Klammern weglassen: somit
schreiben wir den Typ σ1 -> (σ2 -> τ ) kurz als σ1 -> σ2 -> τ .
Die „umgekehrte“ Regel gilt für die Funktionsanwendung: f e1 e2 ist gleichbedeutend mit (f e1 ) e2 ,
d.h., die Funktionsanwendung bindet linksassoziativ.
Gestaffelte Funktionen
Wir haben oben so nebenbei angemerkt, daß wir den Ausdruck \p1 p2 -> e als Abkürzung für
\p1 -> \p2 -> e auffassen können. Diesen Sachverhalt wollen wir in diesem Abschnitt etwas näher
beleuchten. Betrachten wir die folgende Definition von add.
add :: Integer -> Integer -> Integer
add m n = m + n
Als Funktionsausdruck geschrieben erhalten wir die folgenden Definition.
add’ :: Integer -> (Integer -> Integer)
add’ = \m -> \n -> m + n
Man ist gewohnt, die Addition zweier Zahlen als zweistellige Funktion zu interpretieren. Die
Definition von add zeigt, daß es auch eine andere — und wie wir gleich sehen werden pfiffigere
— Interpretation gibt: add ist eine einstellige Funktion, die eine einstellige Funktion als Ergebnis
hat. Somit ist add 3 eine einstellige Funktion, die ihr Argument um 3 erhöht: \n -> 3 + n. Die
Argumente werden also peu à peu übergeben. Aus diesem Grund nennen wir Funktionen des Typs
σ1 -> σ1 -> · · · σn -> τ mit n > 2 auch gestaffelt. Sie werden auch „curryfizierte“ oder „geschönfinkelte“ Funktionen genannt, nach den Logikern Haskell B. Curry und Moses Schönfinkel. Daher
erklärt sich der Name Haskell.
Bevor wir zu den Vorteilen gestaffelter Funktionen kommen, merken wir an, daß add alternativ
auch auf Paaren definiert werden kann.
add0 :: (Integer,Integer) -> Integer
add0 (m,n) = m + n
Grit Garbo: Schließlich bin ich kein Computer und kann den
Typ sehen wie ich will — ob Klammern oder nicht.
Die Funktion add0 ist ebenfalls einstellig — auch wenn man in der Mathematik Funktionen auf
Cartesischen Produkten n-stellig nennt. Der einzige Parameter ist eben ein Paar. Im Unterschied
zu „echten“ mehrstelligen Funktionen kann das Argument von add0 ein beliebiger Ausdruck sein,
der zu einem Paar auswertet.
3.3. Ausdrücke
53
dup :: a -> (a,a)
dup a = (a,a)
double :: Integer -> Integer
double n = add0 (dup n)
Der Vorteil von gestaffelten Funktionen gegenüber ihren Kolleginnen auf Cartesischen Produkten
ist, daß sie Ableger bilden, wenn man ihnen nur einen Teil der Argumente gibt. Betrachten wir die
folgende Definition:
note :: Int -> Int -> Dauer -> Musik
note oct h d = Note (12 * oct + h) d
Füttert man note nach und nach mit Argumenten, so erhält man folgende Ableger:
note Eine Note in einer beliebigen Oktave und von beliebiger Höhe und Dauer.
note 2 Eine Note in der dritten Oktave und von beliebiger Höhe und Dauer.
note 2 ef Die Note f in der dritten Oktave und von beliebiger Dauer.
note 2 ef (1/4) ergibt Note 29 (1/4).
Durch die gestaffelte Form ist eine Funktion also vielseitiger einsetzbar als bei Verwendung des
Cartesischen Produkts als Argumentbereich.
Noch eine terminologische Bemerkung: Man spricht von Funktionen höherer Ordnung, wenn
Argument- oder Ergebnistyp oder beide Funktionstypen sind. Somit sind add und note Funktionen
höherer Ordnung. Im Grunde sind fast alle Programme, die wir bisher kennengelernt haben, aus
Funktionen höherer Ordnung aufgebaut. Auch wenn man nicht immer daran denkt.
3.3.4. Lokale Definitionen
Lokale Definitionen können nicht nur mit Gleichungen, sondern mit beliebigen Ausdrücken verknüpft
werden. Letzteres geschieht durch sogenannte let-Ausdrücke. Die folgende Definition von power
benutzt let statt where und if anstelle von Wächtern.
power’ :: (Integral b, Num a) => a -> b -> a
power’ x n = if n == 0 then 1
else let y = power’ (x*x) (n ‘div‘ 2)
in if n ‘mod‘ 2 == 0 then y
else y*x
Nach dem Schlüsselwort let werden die Hilfsdefinitionen angegeben (hier ist es nur eine), entweder
in geschweifte Klammern eingeschlossen und durch jeweils ein Semikolon getrennt oder unter Benutzung der Abseitsregel. Der Ausdruck, in dem die Namen gültig sind, folgt nach dem Schlüsselwort
in. Den obigen let-Ausdruck liest man „sei y gleich . . . in . . . “. Im allgemeinen haben let-Ausdrücke
die Form
Übung 3.6 Wann sind Funktionen auf Cartesischen Produkten
besser geeignet?
54
3. Eine einfache Programmiersprache
let {e1 ; . . . ; en } in e .
Ein let-Ausdruck führt neue Namen ein, genau wie case-Ausdrücke und Funktionsausdrücke.
Schauen wir uns ihre Sichtbarkeitsbereiche genauer an:
case e of { . . . ;pi mi ; . . . } Die in pi eingeführten Variablen sind in mi sichtbar.
\p1 . . . pn -> e Die Variablen in p1 , . . . , pn sind in e sichtbar.
let {d1 ; . . . ;dn } in e Die in den di eingeführten Variablen sind in e und in den Definitionen di
selbst sichtbar.
Im Unterschied zur where-Klausel ist der let-Ausdruck ein Ausdruck und kann überall da stehen, wo
Ausdrücke erlaubt sind. Die where-Klausel ist hingegen syntaktischer Bestandteil einer Gleichung.
Der bis hierher eingeführte Ausschnitt aus der Programmiersprache Haskell reicht für die nun
folgenden Anwendungen aus. Wir haben diesen Ausschnitt so gewählt, daß er einen einfachen intuitiven Zugang bietet, insbesondere was das Rechnen (lassen) betrifft. Wer mehr über die Sprache
Haskell wissen möchte, insbesondere auch präzisere Definitionen zur Frage „Wie rechnet Haskell“
sehen möchte, findet dies in dem vertiefenden Abschnitt 3.5. Andernfalls kann man sich auch nach
dem Studium des nächsten Abschnitts getrost den allgemeineren Themen der nächsten Kapitel
zuwenden.
3.4. Anwendung: Binärbäume
3
1
2
1
2
3
4
Abbildung 3.1: Beispiele für Binärbäume
In Kapitel 2 haben wir an zwei Beispielen gesehen, wie man Objekte der Realität durch Formeln
nachbildet. Nun entspringen nicht alle Formeln, mit denen sich die Informatik beschäftigt, einem
derartigen Modellierungsprozeß. Viele Formeln, genannt Datenstrukturen, sind Informatik-intern
entwickelt worden, um Probleme mittelbar besser lösen zu können. Mit einer derartigen Datenstruktur, sogenannten Binärbäumen, beschäftigen wir uns in diesem Abschnitt. Die einleitenden
Worte deuten darauf hin, daß Binärbäume keine biologischen Bäume modellieren. Die biologische
Metapher dient hier lediglich als Lieferant für Begriffe: So haben Binärbäume eine Wurzel, sie haben
Verzweigungen und Blätter.
Zwei Beispiele für Binärbäume sind in Abbildung 3.1 aufgeführt. Zunächst einmal fällt auf, daß
die Informatik die Bäume auf den Kopf stellt: die Wurzel der Bäume ist oben und die Blätter sind
unten.2 Binärbäume bestehen aus drei verschiedenen Komponenten: Es gibt binäre Verzweigungen
(dargestellt als Kreise), beschriftete Blätter (dargestellt als Quadrate) und unbeschriftete Blätter
(symbolisiert durch ).3 Für die Darstellung von Binärbäumen mit Formeln, müssen wir uns Namen
für die aufgeführten Bestandteile ausdenken:
2 Diese
Darstellung erklärt sich mit der im westlichen Kulturkreis üblichen Lese- und Schreibrichtung von
oben nach unten.
3 Es gibt auch Varianten von Binärbäumen, die beschriftete Verzweigungen und unbeschriftete Blätter
haben.
3.4. Anwendung: Binärbäume
data Tree a = Nil
| Leaf a
| Br (Tree a) (Tree a)
55
deriving Show
Übung 3.7 Notiere die folgenden Bäume als Terme:
Der Typ Tree a umfaßt somit binäre Bäume über dem Grundtyp a: Ein Baum ist entweder
leer, oder ein Blatt, das ein Element vom Typ a enthält, oder eine Verzweigung (engl.: Branch)
mit zwei Teilbäumen. Wie der Listentyp ist auch Tree ein Typkonstruktor: Tree ist mit dem Typ
der Blattmarkierungen parametrisiert. Mit Hilfe der neuen Formeln können wir die Bäume aus
Abbildung 3.1 als Terme notieren:
t1, t2 :: Tree Integer
1
t1 = Br (Br (Leaf 1) (Leaf 2)) (Leaf 3)
2
(1)
t2 = Br (Br (Leaf 1) Nil)
(Br (Br (Leaf 2) (Leaf 3))
(Br Nil (Leaf 4)))
8
7
1
1
2
2
3
4
3
4
5
(2)
5
6
(3)
Übung 3.8 Zeichne die zu den folgenden Formeln korrespondierenden Bäume.
1. Br Nil (Br (Leaf 1) (Leaf 2))
Stellen wir uns die Aufgabe, die Blätter eines Baums in einer Liste aufzusammeln. Für t1, den
linken Baum aus Abbildung 3.1, ergibt sich:
leaves (Br (Br (Leaf 1) (Leaf 2)) (Leaf 3))
⇒
[1, 2, 3]
Man sieht, daß die Reihenfolge der Elemente in der Liste der Reihenfolge der Blätter im Baum
entspricht (jeweils von links nach rechts gelesen). Hier ist eine einfache — und wie wir gleich sehen
werden naive — Lösung für das Problem:
leaves
leaves
leaves
leaves
:: Tree a -> [a]
Nil
= []
(Leaf a) = [a]
(Br l r) = leaves l ++ leaves r
Im letzten Fall — die Wurzel ist eine Verzweigung — werden die Blätter des linken und des
rechten Teilbaums mit (++) konkateniert. Die Definition ist elegant, aber in einigen Fällen auch
sehr rechenintensiv. Dies liegt an der Verwendung der Listenkonkatenation: Sei ni die Länge der
Liste xi ; um x1 ++ x2 auszurechnen, muß die zweite Gleichung für (++) genau n1 mal angewendet
werden. Ist nun der linke Teilbaum jeweils sehr groß und der rechte klein oder leer, dann wird
wiederholt in jedem Rekursionsschritt eine große Liste durchlaufen. Die folgende Funktion generiert
bösartige Fälle:
leftist :: [a] -> Tree a
leftist []
= Nil
leftist (a:as) = Br (leftist as) (Leaf a)
2. Br (Br (Br (Leaf 1) (Leaf 2))
(Br Nil (Leaf 3)))
(Br (Leaf 4) (Leaf 5))
3. Br (Br (Leaf 1) (Leaf 2))
(Br (Br (Br (Br (Leaf 3) (Leaf 4))
(Leaf 5))
(Leaf 6))
(Br (Leaf 7) (Leaf 8)))
Lisa Lista: Harry, probier leaves doch mal aus!
Harry Hacker: Hey, Du hast wohl den Übergang vom Rechnen
zum Rechnen lassen geschafft :-). [Harry wirft seinen Missile
DX mit Turbo-Haskell an und tippt die Definitionen ab.] Was
sollen wir denn rechnen lassen?
Lisa Lista: Wie wär’s mit leaves (leftist [0 .. 9])?
Harry Hacker: [Harry tippt den Ausdruck ein und die Liste
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0] erscheint prompt am Bildschirm.] Dauert doch gar nicht so lange wie die sagen.
Lisa Lista: [Lisa schiebt Harry beiseite und tippt eifrig
leaves (leftist [0 .. n]) für immer größere n ein. Für n =
5000 kann man die Ausgabe der Elemente gemütlich am Bildschirm verfolgen.] Langsam, aber sicher geht Dein Missile DX in
die Knie, Harry.
56
3. Eine einfache Programmiersprache
Da die Listenkonkatenation die Rechenzeit in die Höhe treibt, stellt sich die Frage, ob wir bei
der Definition von leaves auch ohne auskommen. Identifizieren wir zunächst die einfachen Fälle:
Ist der Baum leer oder ein Blatt, können wir das Ergebnis sofort angeben. Wenn der Baum eine
Verzeigung ist, deren linker Teilbaum leer oder ein Blatt ist, kommen wir ebenfalls ohne (++) aus.
Harry Hacker: Jetzt vergleichen wir mal! [Harry gibt
den Ausdruck leaves’ (leftist [0 .. 5000]) ein. Die Liste
[5000, 4999 .. 0] saust über den Bildschirm.] Ich bin beeindruckt.
Übung 3.9 Programmiere leaves’ mit case-Ausdrücken. Verwende dabei nur einfache Muster (ein Konstruktor angewendet
auf Variablen ist ein einfaches Muster).
leaves’
leaves’
leaves’
leaves’
leaves’
:: Tree a -> [a]
Nil
= []
(Leaf a)
= [a]
(Br Nil r)
= leaves’ r
(Br (Leaf a) r) = a : leaves’ r
Es fehlt der Fall, daß der linke Teilbaum selbst wieder eine Verzweigung ist. Jetzt wenden wir
einen Trick an: Wir machen den linken Teilbaum kleiner, indem wir die Bäume umhängen, und rufen
leaves wieder rekursiv auf. Beim Umhängen müssen wir darauf achten, daß sich die Reihenfolge
der Elemente nicht ändert.
leaves’ (Br (Br l’ r’) r) = leaves’ (Br l’ (Br r’ r))
v
t
=⇒
Auf diese Weise nähern wir uns Schritt für Schritt einem der oben aufgeführten, einfachen Fälle.
Das Umhängen der Bäume ist in Abbildung 3.2 grafisch dargestellt. Aus naheliegenden Gründen
wird die Transformation auch als Rechtsrotation bezeichnet. Rechnen wir leaves’ t1 aus. In den
Kommentaren geben die Zahlen die jeweils verwendete Gleichung an.
t
u
u
v
leaves’ (Br (Br (Leaf 1) (Leaf 2)) (Leaf 3))
Abbildung 3.2: Rechtsrotation
⇒
leaves’ (Br (Leaf 1) (Br (Leaf 2) (Leaf 3)))
(Def. leaves’.5)
⇒
1 : leaves’ (Br (Leaf 2) (Leaf 3))
(Def. leaves’.4)
⇒
1 : 2 : leaves’ (Leaf 3)
(Def. leaves’.4)
⇒
1 : 2 : [3]
(Def. leaves’.2)
Der Nachweis, daß leaves’ für die Erledigung der gleichen Aufgabe tatsächlich mit weniger Schritten auskommt, steht natürlich noch aus. Wir führen ihn in Kapitel 5, in dem wir uns intensiv mit
dem Thema Effizienz auseinandersetzen.
Programmieren wir eine Umkehrung von leaves, d.h. eine Funktion build mit
leaves (build x) = x für alle Listen x. Diese Eigenschaft läßt uns Spielraum, denn es gibt viele
Bäume, die von leaves auf die gleiche Liste abgebildet werden: leaves ist surjektiv. Wir nutzen
diese Wahlmöglichkeit und konstruieren einen Baum, bei dem die Blätter möglichst gleichmäßig auf
die Äste verteilt sind. Hat die Liste n Elemente, so verteilen wir bn/2c Elemente auf den linken und
dn/2e Elemente auf den rechten Teilbaum. Somit unterscheidet sich für jede Verzweigung die Größe
der Teilbäume um maximal ein Element. Bäume mit dieser Eigenschaft nennt man ausgeglichen.
build :: [a] -> Tree a
build [] = Nil
3.4. Anwendung: Binärbäume
57
build [a] = Leaf a
build as = Br (build (take k as)) (build (drop k as))
where k = length as ‘div‘ 2
Die Hilfsfunktion take entnimmt die ersten k Elemente einer Liste. Die Funktion drop wird in
Übung 3.10 besprochen.
take :: Int -> [a] -> [a]
take (n+1) (a:as) = a : take n as
take _ _
= []
-- vordefiniert
In Abbildung 3.3 ist das Ergebnis von build [1 .. 11] grafisch dargestellt. Die Struktur ergibt
sich aus der wiederholten Halbierung von 11:
Übung 3.10 Definiere die Funktion drop, die eine Liste auf die
Liste ohne ihre ersten k Elemente abbildet.
11
=
5+6
=
(2 + 3) + (3 + 3)
=
((1 + 1) + (1 + 2)) + ((1 + 2) + (1 + 2))
=
((1 + 1) + (1 + (1 + 1))) + ((1 + (1 + 1)) + (1 + (1 + 1))) .
Auch für build geben wir noch eine bessere, weil schnellere Definition an. Eine Inspektion von
build zeigt, daß bei jedem rekursiven Aufruf die Argumentliste dreimal durchlaufen wird: einmal in
gesamter Länge von length und zweimal jeweils bis zur Hälfte von take und drop. Wir können die
wiederholten Durchläufe vermeiden, indem wir mehrere Schritte zusammenfassen. Zu diesem Zweck
definieren wir eine Hilfsfunktion buildSplit mit der folgenden Eigenschaft: Sei 0 6 n 6 length x,
dann gilt
buildSplit n x
=
1
2
3
6
4
5
9
7
8
10
11
Abbildung 3.3: Ausgeglichener Baum der Größe 11
(build (take n x), drop n x)
Die Funktion build erhalten wir, indem wir buildSplit mit der Listenlänge aufrufen.
build’ :: [a] -> Tree a
build’ as = fst (buildSplit (length as) as)
Die Funktion buildSplit ergibt sich zu:
buildSplit :: Int -> [a] -> (Tree a, [a])
buildSplit 0 as = (Nil, as)
buildSplit 1 as = (Leaf (head as), tail as)
buildSplit n as = (Br l r, as’’)
where k
= n ‘div‘ 2
(l,as’) = buildSplit
k as
(r,as’’) = buildSplit (n-k) as’
Man kann die Definition von buildSplit sogar systematisch aus der obigen Eigenschaft herleiten.
Dazu mehr in Kapitel 4. Den Nachweis, daß build’ besser ist als build, holen wir in Kapitel 5 nach.
Harry Hacker: Hmm — die Laufzeiten von build und build’
unterscheiden sich nicht so dramatisch.
58
3. Eine einfache Programmiersprache
3.5. Vertiefung: Rechnen in Haskell
3.5.1. Eine Kernsprache/Syntaktischer Zucker
Reduktion auf Kernsprache: Mustergleichungen lassen sich auf einfachere Gleichungen zurückführen,
die keine Fallunterscheidung auf der linken Seite treffen, sondern stattdessen rechts case-Ausdrücke
verwenden.
Wo wir gerade bei Abkürzungen sind: Funktionsdefinitionen können wir ebenfalls als eine abkürzende
Schreibweise auffassen: f x = e kürzt f = \x -> e ab. Dito für mehrstellige Funktionsdefinitionen:
g x1 x2 = e kürzt g = \x1 x2 -> e ab. whereKlauseln wiederum können wir durch let-Ausdrücke ersetzen.
Eine Rechnung wird durch die Angabe eines Ausdrucks gestartet, der relativ zu den im Programm
aufgeführten Definitionen ausgerechnet wird. Im Prinzip kann man ein Programm als großen letAusdruck auffassen: Nach dem let stehen alle Definitionern des Programms, nach dem in der zu
berechnende Ausdruck. Vordefinierte Typen und Funktionen haben globale Sichtbarkeit.
Hat man sich einmal auf diese Kernsprache beschränkt, kann man das Rechnen in Haskell durch
drei Rechenregeln — für Funktionsanwendungen, case- und let-Ausdrücke — erklären.
3.5.2. Auswertung von Fallunterscheidungen
Voraussetzung: nur einfache case-Ausdrücke.
Ein case-Ausdruck kann ausgerechnet werden, wenn der Diskriminatorausdruck ein Konstruktorausdruck ist: case C e1 ... ek of .... In diesem Fall wissen wir, welche der nach of angegeben
Alternativen vorliegt. Nehmen wir an, C x1 ... xk -> e ist die passende Alternative, dann läßt
sich der case-Ausdruck zu e vereinfachen, wobei wir die Argumente des Konstruktors e1 , . . . , ek
für die Variablen x1 , . . . , xk einsetzen müssen. Das Muster fungiert also als eine Art Schablone.
abs :: (Ord a, Num a) => a -> a
abs n = case n >= 0 of
True -> n
False -> -n
encode :: (Num a) => Maybe a -> a
encode x = case x of
Nothing -> -1
Just n -> abs n
-- vordefiniert
encode (Just 5)
⇒
case Just 5 of {Nothing -> -1; Just n -> abs n}
(Def. encode)
⇒
abs 5
(case − Regel)
⇒
case 5 >= 0 of {True -> 5; False -> -5}
(Def. abs)
⇒
case True of {True -> 5; False -> -5}
(Def. >=)
⇒
5
(case − Regel)
3.5. Vertiefung: Rechnen in Haskell
59
Für die gleichzeitige Ersetzung von Variablen durch Ausdrücke verwenden wir die Notation e[x1 /e1 , . . . , xn /en ].
Damit bezeichnen wir den Ausdruck, den man aus e erhält, wenn man alle Vorkommen von xi gleichzeitig durch ei ersetzt. Damit läßt sich unsere erste Rechenregel formalisieren.
case C e1 ...ek of {...; C x1 ...xk -> e;...}
⇒
(case-Regel)
e[x1 /e1 , . . . , xn /en ]
Mit den Auslassungspunkten „. . . “ sollen jeweils die nicht passenden Alternativen symbolisiert werden.
3.5.3. Auswertung von Funktionsanwendungen
Wir erklären nun formal, wie man mit Funktionsausdrücken rechnet.
Eine Funktionsanwendung f a kann erfolgen, wenn f ein Funktionsausdruck ist: (\x ->
e) a. Dann läßt sich die Anwendung zu e vereinfachen, wobei wir den aktuellen Parameter a für
den formalen Parameter x einsetzen. Etwas ähnliches haben wir schon beim Ausrechnen von caseAusdrücken kennengelernt. Die neue Rechenregel läßt sich einfach formalisieren:
(\x -> e) a ⇒ e[x/a]
(β-Regel)
(3.2)
Der Name β-Regel ist genau wie der Schrägstrich bei Funktionsausdrücken, der ein λ symbolisiert,
historisch begründet. Aus nostalgischen Gründen übernehmen wir beides.
Wiederholen wir noch einmal die Auswertung von encode (Just 5) aus Abschnitt 3.5.2. Wir
werden sehen, daß wir die β-Regel schon heimlich verwendet haben, ohne uns große Gedanken
darüber zu machen. Zunächst schreiben wir encode und abs als Funktionsausdrücke auf:
abs
=
encode =
\n -> case n >= 0 of {True -> n; False -> -n}
\x -> case x of {Nothing -> -1; Just n -> abs n}
Die Rechnung nimmt jetzt den folgenden Verlauf.
encode (Just 5)
⇒
(\x-> case x of {Nothing-> -1; Just n-> abs n}) (Just 5)
⇒
case Just 5 of {Nothing -> -1; Just n -> abs n}
(Def. encode)
(β − Regel)
⇒
abs 5
⇒
(\n -> case n >= 0 of {True -> n; False -> -n}) 5
(case − Regel)
(Def. abs)
⇒
case 5 >= 0 of {True -> 5; False -> -5}
(β − Regel)
⇒
case True of {True -> 5; False -> -5}
(Def. (>=))
⇒
5
(case − Regel)
Übung 3.11 Stelle abgeleitete Regeln für if auf.
60
3. Eine einfache Programmiersprache
In der ersten Rechnung haben wir zwei Schritte zusammengefaßt: Das Einsetzen der Definition von
encode bzw. von abs und die Anwendung der β-Regel. Schauen wir uns noch ein weiteres Beispiel an.
[Das Beispiel gehört in die Schublade der akademischen Beispiele; es erlaubt aber, die Anwendung
der β-Regel innerhalb von 9 Schritten immerhin 6-mal einzuüben.]
twice :: (a -> a) -> a -> a
twice = \f -> \a -> f (f a)
Wir rechnen den Ausdruck twice twice inc aus. Ist klar, was als Ergebnis herauskommt?
twice twice inc
⇒
(\f -> \a -> f (f a)) twice inc
⇒
(\a -> twice (twice a) inc
(β − Regel)
⇒
twice (twice inc)
(β − Regel)
⇒
twice ((\f -> \a -> f (f a)) inc)
(Def. twice)
⇒
twice (\a -> inc (inc a))
(β − Regel)
(Def. twice)
⇒
(\f -> \a -> f (f a)) (\a -> inc (inc a))
(Def. twice)
⇒
\a -> (\a -> inc (inc a)) ((\a -> inc (inc a)) a)
(β − Regel)
⇒
\a -> (\a -> inc (inc a)) (inc (inc a))
(β − Regel)
⇒
\a -> inc (inc (inc (inc a)))
(β − Regel)
Man sieht, wir können rechnen, ohne uns Gedanken über die Bedeutung machen zu müssen. Und
das ist auch gut so, schließlich wollen wir ja rechnen lassen.
Trotzdem ist es interessant zu fragen, was twice twice eigentlich bedeutet. Nun, der Ausdruck
twice inc bezeichnet laut Definition die 2-fache Anwendung von inc auf ein Argument; die Rechnung zeigt, daß twice twice inc der 4-fachen Anwendung von inc entspricht. Und twice twice twice inc
oder gar twice twice twice twice inc?
Versuchen wir die Ausdrücke intelligent auszurechnen, indem wir Gesetzmäßigkeiten aufdecken.
Jetzt müssen wir erfinderisch sein und mit den Formeln spielen. Die Arbeit nimmt uns kein Rechner
ab, Gott sei Dank, oder? Als erstes erfinden wir eine Notation für die n-fache Anwendung: hni.
Also, twice = h2i und twice twice = h4i. Definieren können wir hni mithilfe der Komposition:
hni f = f ◦ · · · ◦ f . Wenden wir nun hmi auf hni an, komponieren wir hni m-mal mit sich selbst. Das
|
{z
n-mal
}
führt uns zu der Frage, was hmi ◦ hni bedeutet? Nun, hni f ist f ◦ · · · ◦ f , mit hmi(hni f ) wiederholt
|
{z
n-mal
}
sich dieser Ausdruck m-mal: (f ◦ · · · ◦ f ) ◦ · · · ◦ (f ◦ · · · ◦ f ). Ergo, erhalten wir hmni. Nun können
|
{z
}
m-mal
wir auch hmi hni erklären: hmi hni = hni ◦ · · · ◦ hni = hnm i. Somit ist (h2i h2i) h2i = h4i h2i = h16i
|
{z
m-mal
}
und ((h2i h2i) h2i) h2i = h16i h2i = h65536i. Geschafft!
3.5. Vertiefung: Rechnen in Haskell
61
3.5.4. Auswertung von lokalen Definitionen
Voraussetzung: nur einfache let-Ausdrücke.
Die let-Ausdrücke haben uns noch gefehlt: Mit ihnen ist unsere Programmiersprache komplett.
Im folgenden erklären wir, wie man mit ihnen rechnet.
Wenn die Definitionen nicht rekursiv sind, ist die Rechenregel einfach: Die definierenden Ausdrücke werden für die Bezeichner in den Rumpf des let-Ausdrucks eingesetzt.
let {x1 = e1 ;...;xn = en } in e
⇒
e[x1 /e1 , . . . , xn /en ]
Wenn die Definitionen rekursiv sind, geht das nicht so einfach. Die Bezüge für die in den rechten Seiten auftretenden Bezeichner gingen sonst verloren. Eine rekursive Definition wird unter Umständen
wiederholt benötigt, etwa für jede rekursive Anwendung einer Funktion. Um für diesen Fall gerüstet
zu sein, ersetzen wir xi statt durch ei durch den Ausdruck let {x1 = e1 ;...;xn = en } in ei .
let {x1 = e1 ;...;xn = en } in e
⇒
e[x1 /let {x1 = e1 ;...;xn = en } in e1 , . . . ,
(let-Regel)
xn /let {x1 = e1 ;...;xn = en } in en ]
Die lokalen Definitionen werden auf diese Weise einmal aufgefaltet. Das bläht den Ausdruck zunächst
einmal gewaltig auf — aber danach können wir ein Stück weiterrechnen und ihn (vielleicht) wieder
vereinfachen. Beachte, daß die Regel auch funktioniert, wenn die Definitionen nicht rekursiv sind.
Schauen wir uns ein Beispiel an.
rep 2 8
⇒
let x = 8:x in take 2 x
⇒
take 2 (let x = 8:x in 8:x)
(let − Regel)
⇒
take 2 (8:let x = 8:x in 8:x)
(let − Regel)
⇒
8:take 1 (let x = 8:x in 8:x)
(Def. take)
⇒
8:take 1 (8:let x = 8:x in 8:x)
(let − Regel)
⇒
8:8:take 0 (let x = 8:x in 8:x)
(Def. take)
⇒
8:8:[]
(Def. take)
Harry Hacker: Wetten, daß ich diese Rechnung auch mit der
einfachen let-Regel hinkriege?
Lisa Lista: Wetten, daß nicht?
(Def. rep)
Man sieht, die merkwürdige Definition von rep funktioniert. Wir haben uns auch geschickt angestellt.
Für’s erste verlassen wir uns darauf, daß sich auch der Rechner geschickt anstellt. Später werden
wir uns überlegen, warum wir uns darauf verlassen können.
In der obigen Rechnung haben wir einige Schritte zusammengefaßt: Die Anwendung von take auf
die beiden Argumente und die folgenden Fallunterscheidungen erscheinen als einziger Rechenschritt.
Übung 3.12 Was wäre ungeschickt gewesen?
62
3. Eine einfache Programmiersprache
Sobald man sich mit der Funktionsweise einer Funktion vertraut gemacht hat, ist es natürlich und
bequem dies zu tun: Wir fassen die Definition von take sozusagen als abgeleitete Rechenregel auf.
Fazit: Unsere Programmiersprache umfaßt nur sechs grundlegende Arten von Ausdrücken: Variablen, Konstruktorausdrücke, case-Ausdrücke, Funktionsausdrücke, Funktionsanwendungen und
let-Ausdrücke. Durch die case-, let- und die β-Regel ist das Rechnen in Haskell formal definiert.
Damit können wir alles programmieren, aber nicht unbedingt lesbar, schön und elegant.
4. Programmiermethodik
Ziele des Kapitels: In Kapitel 3 haben wir das Werkzeug kennengelernt, das es uns ermöglicht,
rechnen zu lassen. In diesem Kapitel gehen wir einen Schritt weiter und beschäftigen uns mit dem
systematischen Gebrauch dieses Werkzeugs. Dabei gehen wir vereinfachend davon aus, daß der
Prozeß der Modellierung — den wir in Kapitel 2 an zwei Beispielen kennengelernt haben — bereits
abgeschlossen ist. Innerhalb einer vorgegebenen Modellwelt werden wir uns damit auseinandersetzen,
wie man zu ebenfalls vorgebenen Problemen systematisch Rechenregeln — sprich Programme —
entwirft. Als durchgehendes Beispiel dient uns dabei neben anderen das Sortierproblem.
Eine Bemerkung zur Terminologie sei noch vorausgeschickt: In der Literatur wird oft eine feinsinnige Unterscheidung zwischen den Begriffen Algorithmus und Programm getroffen. Ein Algorithmus ist ein Rechenverfahren, daß so präzise beschrieben ist, daß eine automatische Durchführung
im Prinzip möglich ist. Dies gegeben, darf der Algorithmus auf beliebig hohem Abstraktionsgrad
und in beliebiger, hoffentlich dem Problem adäquater Notation beschrieben sein. Ein Programm
ist die Formulierung des Algorithmus in einer Programmiersprache, so daß man es (bzw. ihn) auch
tatsächlich rechnen lassen kann. Früher, als Programmiersprachen noch wesentlich primitiver waren
als heute, spielte dieser Unterschied eine wesentliche Rolle, und der Übergang vom Algorithmus
zum Programm, manchmal Codierung genannt, war eine nicht zu unterschätzende Fehlerquelle.
Dank moderner Programmiersprachen wie unserem Haskell wird die semantische Lücke zwischen
Algorithmus und Programm ein gutes Stück weit geschlossen, und wir werden die Begriffe Algorithmus und (Haskell-)Programm praktisch synonym verwenden. Unsere Einführung in die Informatik
kommt dadurch wesentlich zügiger voran, als dies früher möglich war.
Prof. Paneau: Die Autoren vergessen darauf hinzuweisen —
und das sollte man mit der gebotenen Eindringlichkeit tun —
daß nur beständiges Üben zum Erfolg führt.
4.1. Spezifikation
Bevor man sich daran setzt, Rechenregeln aufzuschreiben, muß man zunächst das Problem durchdringen, das es zu lösen gilt. Je genauer man das Problem beschreiben kann, desto besser wird
später auch der Entwurf von Rechenregeln gelingen. Das hört sich einleuchtend an, aber wir werden
schnell sehen, daß die Beschreibung eines Problems — in Fachjargon die Problemspezifikation —
alles andere als einfach ist. Wir gehen im folgenden davon aus, daß das Problem in der Berechnung
einer Funktion im mathematischen Sinne besteht: Einer Eingabe wird eindeutig eine Ausgabe zugeordnet. Wir kümmern uns damit bewußt nicht um Systeme, die mit einer Benutzerin oder allgemein
mit einer Umgebung interagieren.
Wie gesagt, wir verwenden das Sortieren als durchgehendes Beispiel. Die informelle Beschreibung
des Sortierproblems ist relativ einfach: Gesucht ist eine Funktion sort, die eine gegebene Liste von
Harry Hacker: Aber alle meine Programme sind interaktiv;
kann ich jetzt weiterblättern (goto Kapitel 5)?
64
4. Programmiermethodik
Elementen aufsteigend anordnet. Schauen wir uns ein paar Beispiele an:
sort [8, 3, 5, 3, 6, 1]
⇒
[1, 3, 3, 5, 6, 8]
sort "hello world"
⇒
" dehllloorw"
sort ["Bein", Änfall", Änna"]
⇒
[Änfall", Änna", "Bein"]
sort [(1, 7), (1, 3), (2, 2)]
⇒
[(1, 3), (1, 7), (2, 2)]
Kümmern wir uns zunächst um den Typ der Funktion. Die Festlegung des Typs sollte stets der
erste Schritt bei der Spezifikation sein: durch den Typ wird ja gerade die Art der Eingabe und
der Ausgabe beschrieben. Im obigen Beispiel sind Argument und Ergebnis jeweils Listen; auf den
Typ der Listenelemente wollen wir uns nicht festlegen, wir müssen lediglich annehmen, daß auf den
Elementen eine Vergleichsfunktion definiert ist. Somit erhalten wir:
sort :: (Ord a) => [a] -> OrdList a
Wie läßt sich nun das Sortierproblem formal spezifizieren? Klar, die Ergebnisliste muß geordnet
sein. Diese Eigenschaft kann man mit einer Haskell-Funktion beschreiben.
Harry Hacker: Geht das nicht einfacher? Wie wär’s mit „für
alle Listen x :: (Ord a) => [a] muß gelten . . . “.
ordered
ordered
ordered
ordered
:: (Ord a)
[]
[a]
(a1:a2:as)
=> [a] -> Bool
= True
= True
= a1 <= a2 && ordered (a2:as)
Wir wagen einen ersten Spezifikationsversuch: Sei τ ein beliebiger Typ, auf dem eine Ordnung
definiert ist, für alle Listen x :: [τ ] muß gelten
ordered (sort x)
=
True .
(4.1)
Reicht das? Leider nein: die Funktion \x -> [] erfüllt (4.1), aber nicht unsere Erwartungen. Wir
müssen noch fordern, daß alle Elemente der Argumentliste auch in der Ergebnisliste auftreten. Als
zweiten Versuch könnten wir zusätzlich die Listen x und sort x in Mengen überführen und verlangen, daß beide Mengen gleich sind. Damit haben wir von der Anordnung der Elemente abstrahiert
— aber leider auch von ihrer Anzahl. Schließlich müssen wir garantieren, daß ein mehrfach auftretendes Element auch noch nach dem Sortieren in gleicher Anzahl auftritt (siehe erstes Beispiel).
Dies kann man mit Mengen nicht modellieren. Wir spezifizieren diese Eigenschaft, indem wir beide
Listen auf eine Struktur abbilden, in der die Reihenfolge keine Rolle spielt, aber Elemente mehrfach
vorkommen können: die Struktur der Multimenge.
Die Eigenschaft der geordneten Liste haben wir mit einem Haskell-Programm spezifiziert. Dies
hat den Vorteil, daß wir mit der Spezifikation auch rechnen können, z.B. können wir sie verwenden,
um Sortierprogramme auszutesten. Die Angabe eines Haskell-Programms für die zweite Eigenschaft
ist prinzipiell auch möglich, würde uns aber vom eigentlichen Thema ablenken. Aus diesem Grund
nehmen wir an, daß der Typ Bag a, Multimengen über dem Grundtyp a, mit folgenden Operationen
vorgegeben ist:
∅ die leere Multimenge,
4.1. Spezifikation
65
*a+ die einelementige Multimenge, die genau ein Vorkommen von a enthält,
x ] y die Vereinigung der Elemente von x und y; das „+“ im Vereinigungszeichen deutet an, daß
sich die Vorkommen in x und y akkumulieren.
Die Hinzunahme neuer Typen und Operationen mit bestimmten Eigenschaften hat den Vorteil, daß
die Spezifikation einfacher und lesbarer wird. Schließlich ist die Spezifikation kein Selbstzweck; wir
werden sie später verwenden, um die Korrektheit unserer Sortierprogramme zu beweisen. Für diese
Beweise benutzen wir die folgenden Eigenschaften der Operationen: ∅ ist ein neutrales Element von
(]) und (]) selbst ist assoziativ und kommutativ.
∅]x
=
x
x]∅
=
x
(4.3)
x]y
=
y]x
(4.4)
(x ] y) ] z
=
x ] (y ] z)
(4.5)
(4.2)
Die Funktion bag, die eine Liste in eine Multimenge überführt, illustriert die Verwendung der
Operationen.
bag :: [a] -> Bag a
bag []
= ∅
bag (a:as) = *a+ ] bag as
merge :: (Ord a) =>
OrdList a -> OrdList a -> OrdList a
die zwei geordnete Listen in eine geordnete Liste überführt.
(b) Spezifiziere die Funktion
Eine Liste x enthält alle Elemente von y, falls bag x = bag y. In diesem Fall heißt x Permutation
von y. Somit können wir nun spezifizieren: Sei τ ein beliebiger Typ, auf dem eine Ordnung definiert
ist. Für alle Listen x :: [τ ] muß gelten
ordered (sort x) = True ∧ bag (sort x) = bag x .
Harry Hacker: Was unterscheidet jetzt Multimengen von
Mengen; diese Gesetze gelten doch auch für Mengen.
Lisa Lista: Das stimmt Harry. Aber Mengen erfüllen noch eine
weitere Eigenschaft, x ∪ x = x, die auf Multimengen nicht
zutrifft.
Übung 4.1
(a) Spezifiziere die Funktion
mergeMany :: (Ord a) =>
[OrdList a] -> OrdList a
die eine Liste geordneter Listen in eine geordnete Liste überführt.
(4.6)
Übung 4.2 Zu implementieren ist eine Funktion
Dadurch ist sort als mathematische Funktion, nicht aber als Programm, eindeutig bestimmt.
Manchmal kann man sehr einfach zu einer brauchbaren Spezifikation gelangen, indem man bereits
existierende Funktionen benutzt. Dies haben wir zum Beispiel im Falle von build getan: Wir haben
gefordert, daß build eine Inverse zu leaves sein soll mit der Eigenschaft leaves (build x) = x
für alle x. Damit ist build durchaus nicht eindeutig spezifiziert: Auch Einstweiliges Fazit: Schon
die Spezifikation einfacher Probleme ist nicht unbedingt einfach. Da wundert es nicht, daß oftmals
die Mühe gescheut wird, ein Problem formal zu spezifizieren. Dabei sind formale Spezifikationen
unerläßlich, wenn wir eine Chance haben wollen, die Korrektheit unserer Programme nachzuweisen.
Aber bleiben wir realistisch: Nach dem heutigen Stand der Forschung wäre es vermessen zu erwarten,
daß ein großes Programmpaket jemals vollständig spezifiziert und als korrekt nachgewiesen wird.
Selbst wenn dies gelänge, hieße das noch nicht, daß „alles läuft“: die Spezifikation kann falsch
sein, oder der Übersetzer oder der Rechner selbst, oder jemand kann den Stecker herausziehen.
Der Schluß, daß es sich damit bei der Programmverifikation um ein nutzloses Unterfangen handelt,
ist aber genauso falsch. Denn mit jedem Korrektheitsnachweis — auch wenn nur ein kleiner Teil
eines umfangreichen Programms berücksichtigt wird — steigt die Zuverlässigkeit und damit unser
Vertrauen in die Software. Kurz gesagt: Lieber etwas Korrektheit als gar keine.
split :: Int -> [a] -> [[a]]
die eine Liste in n möglichst gleich lange Listen aufteilt. Lisa
Lista hat diese Funktion folgendermaßen spezifiziert. Für alle
natürlichen Zahlen n und alle Listen x muß gelten:
concat (split n x) = x ∧ length (split n x) = n .
Harry Hacker hat sofort eine Funktion gefunden, die diese Spezifikation erfüllt: die Liste x wird in n − 1 leere Listen und die
Liste x selbst „aufgeteilt“. Helfen Sie Lisa bei einer besseren
Spezifikation und bei der Implementierung.
Harry Hacker: [Seufzt] Das kenne ich, meinen Missile DX
mußte ich zum Händler zurückschleppen, weil die Fließkommaarithmetik des Prozessors fehlerhaft war.
66
4. Programmiermethodik
4.2. Strukturelle Rekursion
Zur Lösung eines Problems braucht man in der Regel eine Idee. Das ist beim Programmieren nicht
anders. Gott sei Dank steht man nicht alleine da. Es gibt einen kleinen Fundus von Methoden, die
sich beim Lösen von Problemen bewährt haben. Eine einfache werden wir im folgenden kennenlernen,
eine weitere in Abschnitt 4.5.
4.2.1. Strukturelle Rekursion auf Listen
Bevor wir uns dem Sortierproblem zuwenden, schauen wir uns zunächst einmal zwei listenverarbeitende Funktionen aus vorangegangenen Kapiteln an.
translate :: [Codon] -> Protein
translate []
= []
translate (triplet:triplets)
| aminoAcid == Stp = []
| otherwise
= aminoAcid:translate triplets
where aminoAcid = genCode triplet
Prof. Paneau: Das Schema ist doch primitiv, primitiv rekursiv
meine ich.
length :: [a] -> Int
length []
= 0
length (a:as) = 1 + length as
-- vordefiniert
Beide Funktionen lösen unterschiedliche Probleme; nichtsdestotrotz folgen ihre Definitionen dem
gleichen Schema, das sich eng an der Definition des Listentyps orientiert. Für jeden in der Datentypdefinition aufgeführten Konstruktor, [] und (:), gibt es eine Gleichung; der Konstruktor (:) ist
rekursiv im zweiten Argument, just über dieses Argument erfolgt jeweils der rekursive Aufruf. Man
sagt, die Funktionen sind strukturell rekursiv definiert. Für jeden Datentyp gibt es ein zugehöriges
Rekursionsschema, das es erlaubt, Probleme zu lösen, die Eingaben dieses Datentyps involvieren.
Für Listen nimmt das Rekursionsschema die folgende Form an:
Rekursionsbasis ([]) Das Problem wird für die leere Liste [] gelöst.
Rekursionsschritt (a:as) Um das Problem für die Liste a:as zu lösen, wird nach dem gleichen
Verfahren, d.h. rekursiv, zunächst eine Lösung für as bestimmt, die anschließend zu einer
Lösung für a:as erweitert wird.
Das ganze etwas formaler: Wir sagen, die listenverarbeitende Funktion f ist strukturell rekursiv
definiert, wenn die Definition von der Form
f
f []
f (a : as)
where s
::
=
=
=
[σ] -> τ
e1
e2
f as
ist, wobei e1 und e2 Ausdrücke vom Typ τ sind und e2 die Variablen a, as und s (nicht aber f )
enthalten darf. Mit s wird gerade die Lösung für as bezeichnet; tritt s nur einmal in e2 auf, kann
man natürlich für s auch direkt f as einsetzen.
4.2. Strukturelle Rekursion
67
Wenden wir uns der Lösung des Sortierproblems zu. Es ist eine gute Idee, die Funktionsdefinition
zunächst so weit aufzuschreiben, wie das Rekursionsschema es vorgibt. Wir erhalten:
insertionSort
insertionSort []
insertionSort (a : as)
where s
::
=
=
=
(Ord a) => [a] -> OrdList a
e1
e2
insertionSort as
Wir werden verschiedene Sortieralgorithmen kennenlernen; die Funktionen erhalten jeweils die in
der Literatur gebräuchlichen Namen. Die Lücke in der ersten Gleichung, der Rekursionsbasis, ist
schnell ausgefüllt: die leere Liste ist bereits sortiert, also ersetzen wir e1 durch []. Zum Rekursionsschritt: Wir müssen die bereits sortierte Liste s um das Element a erweitern, genauer: a muß
in s gemäß der vorgegebenen Ordnung eingefügt werden. Das hört sich nach einem neuen (Teil-)
Problem an. Wie gehen wir weiter vor? Ganz einfach: das Problem erhält einen Namen, sagen wir
insert; den Namen verwenden wir, um die Definition von insertionSort abzuschließen.
insertionSort :: (Ord a) => [a] -> OrdList a
insertionSort []
= []
insertionSort (a:as) = insert a (insertionSort as)
Wenden wir uns dem neuen Problem zu. Bei der Definition von insert verwenden wir das Kochrezept
ein zweites Mal an. Aber halt, insert hat zwei Parameter; das Rekursionsschema sieht nur einen
Parameter vor. Wir müssen das Schema zunächst erweitern: Wir sagen, die listenverarbeitende
Funktion g ist strukturell rekursiv definiert, wenn die Definition von der Form
g
g i []
g i (a : as)
where s
::
=
=
=
σ1 -> [σ2 ] -> τ
e1
e2
g e3 as
ist, wobei e1 die Variable i, e2 die Variablen i, a, as und s und schließlich e3 die Variablen i, a
und as enthalten darf. Häufig, aber nicht immer, ist e3 gleich i, so daß man sich auf die Wahl von
e1 und e2 konzentrieren kann. Wie oben schreiben wir die Funktionsdefinition für insert zunächst
so weit auf, wie das Rekursionsschema es vorgibt.
insert
insert a []
insert a (a’ : as)
where s
::
=
=
=
(Ord a) => a -> OrdList a -> OrdList a
e1
e2
insert e3 as
Die Rekursionsbasis ist wieder einfach: e1 ist die einlementige Liste [a]. Zum Rekursionsschritt: Die
Argumentliste ist sortiert, damit ist a’ ihr kleinstes Element. Um das erste Element der Ergebnisliste
Prof. Paneau: Ich würde den Autoren empfehlen, ihre Worte
vorsichtiger zu wählen. Die Erweiterung ist doch keine: jede nach
dem g-Schema definierbare Funktion läßt sich bereits mit dem
f -Schema definieren.
Harry Hacker: Wie soll das funktionieren? Die Funktion f
hat einen Parameter, die Funktion g aber zwei!
Prof. Paneau: Lieber Herr Hacker, das ist doch eine Illusion: die Ausdrücke e1 und e2 können doch funktionswertig sein.
Lassen Sie mich dies am Beispiel der Funktion insert verdeutlichen; mit dem f -Schema erhalten wir (oBdA habe ich die Parameter vertauscht):
ins []
= \a -> [a]
ins (a’:as) = \a -> if a <= a’
then a:a’:as
else a’:(ins as) a
Lisa Lista: Diese Art der Definition ist aber recht gewöhnungsbedürftig. Ich sehe das so: das g-Schema ist ein abgeleitetes
Schema, das es erlaubt, Funktionen bequemer hinzuschreiben,
aber prinzipiell nichts Neues bietet. Mich würde jetzt noch interessieren, ob sich alle Programme in diese Form bringen lassen?
Prof. Paneau: Das ist eine gute Frage, aber keine die leicht zu
beantworten ist. Vielleicht nur so viel: ein Programm, das einen
Rechner simuliert, können Sie damit nicht definieren.
68
4. Programmiermethodik
zu ermitteln, müssen wir a mit a’ vergleichen. Wir erhalten:
insert a []
insert a (a’ : as)
| a <= a’
| otherwise
where s
=
[]
=
=
=
e21
e22
insert e3 as
Im ersten Fall ist a das kleinste Element und e21 somit a : a’ : as. Anderenfalls fängt die Ergebnisliste mit a’ an und wir müssen a rekursiv in as einfügen, d.h., wir ersetzen e22 durch a’ : s und
e3 durch a.
insert :: (Ord a) => a -> [a] -> [a]
insert a []
= [a]
insert a (a’:as)
| a <= a’
= a:a’:as
| otherwise = a’:insert a as
Lisa Lista: Wenn die Eingabeliste länger wird, ist man aber
eine Weile beschäftigt.
Harry Hacker: Papperlapapp! Du sollst ja nicht selbst rechnen, sondern rechnen lassen. Wenn ich meinen Missile DX
mit Turbo-Haskell anwerfe, hast Du das Ergebnis in nullkommanix. [Harry tippt das Programm ab und läßt den Ausdruck
sum (insertionSort [1 .. 3000]) ausrechnen.] Siehst Du, die
Antwort kommt blitzschnell.
Lisa Lista: Stimmt. Ich bin beeindruckt. Aber probier doch mal
sum (insertionSort (reverse [1 .. 3000])). [Harry gibt
den Ausdruck ein und beide warten . . . ]
Dieses Verfahren nennt sich aus naheliegenden Gründen „Sortieren durch Einfügen“. Geduldige
Leute verwenden es schon mal beim Sortieren von Spielkarten. Schauen wir uns die Arbeitsweise
von insertionSort an einem Beispiel an: Es gilt die Liste [8,3,5,3,
6,1] zu sortieren. Unter Verwendung der Rechenregeln, die wir in Abschnitt 3.2.4 kennengelernt
haben, ist es leicht, wenn auch etwas langwierig, den Ausdruck
insertionSort [8,3,5,3,6,1] auszurechnen. Schauen wir uns die wichtigsten Etappen an (wir
kürzen insertionSort mit isort und insert mit ins ab).
isort (8 : 3 : 5 : 3 : 6 : 1 : [])
⇒
ins 8 (isort (3 : 5 : 3 : 6 : 1 : []))
(Def. isort)
⇒
ins 8 (ins 3 (isort (5 : 3 : 6 : 1 : [])))
(Def. isort)
⇒
...
⇒
ins 8 (ins 3 (ins 5 (ins 3 (ins 6 (ins 1 [])))))
⇒
ins 8 (ins 3 (ins 5 (ins 3 (ins 6 (1 : [])))))
(Def. isort)
(Def. ins)
⇒
ins 8 (ins 3 (ins 5 (ins 3 (1 : ins 6 []))))
(Def. ins)
⇒
ins 8 (ins 3 (ins 5 (1 : ins 3 (ins 6 []))))
(Def. ins)
⇒
...
⇒
1 : ins 8 (ins 3 (ins 5 (ins 3 (ins 6 []))))
(Def. ins)
Nach 7 + 6 = 13 Schritten haben wir das erste Element der sortierten Liste, sprich das Minimum,
ausgerechnet. Nach weiteren 5 Schritten erhalten wir das zweitkleinste Element usw. An diesem
Beispiel lassen sich noch einmal die Grundprinzipien des Rechnens (mit Haskell) verdeutlichen. Eine
konkrete Instanz eines Problems wird durch einen Ausdruck beschrieben, hier insertionSort [8,3,5,3,6,1].
Durch Ausrechnen des Ausdrucks erhalten wir die Lösung des Problems, hier [1,3,3,5,6,8]. Ausrechnen meint dabei die sture Anwendung der aufgestellten Rechenregeln. Das Ausrechnen können
4.2. Strukturelle Rekursion
69
wir getrost einem Rechner überlassen; die eigentliche Arbeit bzw. der Gehirnschmalz steckt in der
Formulierung der Rechenregeln.
Die obige Vorgehensweise bei der Definition von insertionSort ist übrigens typisch für einen
sogenannten „top-down Entwurf“ (oder: Methode der schrittweisen Verfeinerung). Das Gesamtproblem wird schrittweise in immer kleinere Teilprobleme zergliedert, bis man auf triviale oder
bereits gelöste Probleme stößt.
4.2.2. Strukturelle Rekursion auf Bäumen
Wir haben bereits angemerkt, daß es für jeden Datentyp ein zugehöriges Rekursionsschema gibt.
Schauen wir uns als weiteres Beispiel das Schema für den Datentyp Tree an, den wir in Abschnitt 3.4
eingeführt haben.
Rekursionsbasis (Nil) Das Problem wird für den leeren Baum gelöst.
Rekursionsbasis (Leaf a) Das Problem wird für das Blatt Leaf a gelöst.
Rekursionsschritt (Br l r) Um das Problem für den Baum Br l r zu lösen, werden rekursiv
Lösungen für l und r bestimmt, die zu einer Lösung für Br l r erweitert werden.
Im Unterschied zu Listen gibt es zwei Basisfälle und beim Rekursionsschritt müssen zwei Teillösungen zu einer Gesamtlösung kombiniert werden. Formal heißt eine Funktion f strukturell rekursiv,
wenn die Definition von der Form
f
f Nil
f (Leaf a)
f (Br l r)
where sl
sr
::
=
=
=
=
=
Tree σ -> τ
e1
e2
e3
f l
f r
ist. Programmieren wir als Anwendung zwei Funktionen, die Größe bzw. die Tiefe1 von Bäumen bestimmen. Die Größe eines Baums setzen wir gleich mit der Anzahl seiner Blätter; die Tiefe entspricht
der Länge des längsten Pfads von der Wurzel bis zu einem Blatt.
size
size
size
size
depth
depth
depth
depth
:: Tree a -> Integer
Nil
= 1
(Leaf _) = 1
(Br l r) = size l + size r
1 Da
:: Tree a -> Integer
Nil
= 0
(Leaf _) = 0
(Br l r) = max (depth l) (depth r) + 1
die Bäume in der Informatik von oben nach unten wachsen, spricht man eher von der Tiefe eines
Baums und seltener von seiner Höhe.
Übung 4.3
Definiere analog zu size die Funktion
branches :: Tree a -> Integer, die die Anzahl der Verzweigungen in einem Baum bestimmt.
Übung 4.4 In Abschnitt 3.4 haben wir zwei Definitionen für
leaves angegeben, welche ist strukturell rekursiv?
70
4. Programmiermethodik
Auf diese Funktionen werden wir in den nächsten Abschnitten wiederholt zurückkommen, so daß
es sich lohnt, sich ihre Definition einzuprägen.
4.2.3. Das allgemeine Rekursionsschema
Nachdem wir zwei Instanzen des Rekursionsschemas kennengelernt haben, sind wir (hoffentlich) fit
für das allgemeine Kochrezept. Ausgangspunkt ist dabei die allgemeine Form der Datentypdeklaration, wie wir sie in Abschnitt 3.1.1 kennengelernt haben.
data T a1 . . . am
=
C1 t11 . . . t1n1
|
...
|
Cr tr1 . . . trnr
Für die Definition des Schemas müssen wir bei den Konstruktoren Ci zwei Arten von Argumenten
unterscheiden: rekursive (d.h. tij ist gleich T a1 . . . am ) und nicht-rekursive. Seien also li1 , . . . , lipi
mit 1 6 li1 < li2 < · · · < lipi 6 ni die Positionen, an denen der Konstruktor Ci rekursiv ist. Das
Rekursionsschema besteht nun aus r Gleichungen; für jeden Konstruktor gibt es eine Gleichung, die
sich seiner annimmt.
f
f (C1 x11 . . . x1n1 )
where s11
...
s1p1
...
f (Cr xr1 . . . xrnr )
where sr1
...
srpr
Übung 4.5 Welche Funktionen aus den vorangegangenen
Kapiteln sind strukturell rekursiv definiert und welche nicht?
::
=
=
T σ1 . . . σm -> τ
e1
f x1l11
=
f x1l1p1
=
=
er
f xrlr1
=
f xrlrpr
Der Ausdruck ei darf die Variablen xi1 , . . . , xini und die Variablen si1 , . . . , sipi enthalten. Ist
pi = 0, so spricht man von einer Rekursionsbasis, sonst von einem Rekursionsschritt.
4.2.4. Verstärkung der Rekursion
Es ist eine gute Idee, als erstes das Schema der strukturellen Rekursion heranzuziehen, um eine
Funktion zu programmieren. Aber nicht immer führt dieser Ansatz zum Erfolg bzw. nicht immer ist
das Ergebnis zufriedenstellend, das man auf diesem Wege erhält. Schauen wir uns ein Beispiel an:
Das Problem lautet, die Reihenfolge der Elemente einer Liste umzukehren. Wenden wir das Schema
der strukturellen Rekursion an, erhalten wir die folgende oder eine ähnliche Definition.
reverse’’ :: [a] -> [a]
-- vordefiniert
reverse’’ []
= []
reverse’’ (a:as) = reverse’’ as ++ [a]
4.2. Strukturelle Rekursion
71
Mit der Listenkonkatenation wird jeweils das erste Element an die gespiegelte Liste angehängt;
dabei muß die Liste jeweils vollständig durchlaufen werden. Die Tatsache, daß die geschachtelte
Verwendung von (++) sehr rechenintensiv ist, kennen wir bereits aus Abschnitt 3.4 — in der Tat
sind reverse und leaves nah verwandt, es gilt reverse = leaves . leftist. Wie können wir
eine bessere Lösung für reverse systematisch herleiten? Die grundlegende Idee ist die folgende:
Wir programmieren eine Funktion, die ein schwierigeres Problem löst als verlangt, aber die es
uns erlaubt, den Rekursionsschritt besser zu bewältigen. Diese Technik nennt man „Verstärkung des
Rekursionsschritts“ oder „Programmieren durch Einbettung“. Schauen wir uns die Technik zunächst
am obigen Beispiel an. Im Rekursionsschritt müssen wir die Restliste spiegeln und an das Ergebnis
eine Liste anhängen. Diese Beobachtung führt zu der Idee, eine Funktion zu programmieren, die
beide Aufgaben gleichzeitig löst. Also:
reel
reel x y
::
=
[a] -> [a] -> [a]
reverse x ++ y
Die Spezifikation verkörpert sozusagen den kreativen Einfall. Interessant ist, daß wir die Definition
von reel aus dieser Spezifikation systematisch ableiten können. Anhaltspunkt für die Fallunterscheidung ist natürlich wieder das Schema der strukturellen Rekursion. Rekursionsbasis (x = []):
reel [] y
=
reverse [] ++ y
(Spezifikation)
=
[] ++ y
(Def. reverse)
=
y
(Def. (++))
Rekursionsschritt (x = a:as):
reel (a:as) y
=
reverse (a:as) ++ y
(Spezifikation)
=
(reverse as ++ [a]) ++ y
(Def. reverse)
=
reverse as ++ ([a] ++ y)
(Ass. (++))
=
reverse as ++ (a:y)
(Def. (++))
=
reel as (a:y)
(Spezifikation)
Somit erhalten wir die folgende Definition von reel:
reel :: [a] -> [a] -> [a]
reel []
y = y
reel (a:as) y = reel as (a:y)
Wenn man sich die rekursiven Aufrufe anschaut, sieht man, daß immer ein Listenelement vom
ersten zum zweiten Parameter „wandert“. Dieser Vorgang begegnet uns im täglichen Leben, wenn
wir einen Stapel von Blättern umdrehen: Das oberste Blatt des Stapels wird auf einen weiteren,
anfangs leeren Stapel gelegt; das wiederholen wir solange, bis der erste Stapel leer ist. Der neue
Stapel enthält dann die Blätter in umgekehrter Reihenfolge.
Die ursprüngliche Funktion, reverse, erhalten wir schließlich durch Spezialisierung von reel.
Übung 4.6 Wende die Technik der Rekursionsverstärkung auf
die strukturell rekursive Definition von leaves an.
72
4. Programmiermethodik
reverse’’’ :: [a] -> [a]
reverse’’’ as = reel as []
Fassen wir zusammen: Die vielleicht überraschende Erkenntnis ist zunächst, daß ein schwieriges
Problem nicht unbedingt auch schwieriger zu lösen ist. Dies liegt im wesentlichen daran, daß im
Rekursionsschritt eine „bessere“ Lösung zur Verfügung steht, die leichter zu einer Gesamtlösung
ausgebaut werden kann. Der kreative Moment liegt in der Formulierung einer geeigneten Rekursionsverstärkung; eine genaue Analyse, warum der erste Lösungsansatz scheiterte, liefert dafür in
der Regel wichtige Hinweise.
Lisa Lista: Harry — kannst Du mir erklären, warum die Anwendung dieser Beweisregel irgendetwas beweist?
Harry Hacker: Nee - habe in der Schule gelernt, daß
Schlußregeln so etwas wie Axiome sind, also auf Annahmen oder
Absprachen beruhen. Hat mich nie überzeugt.
Lisa Lista: Ist doch auch albern, oder? Wenn ich eine Aussage
mit Beweisregeln beweise, die selbst nichts als Annahmen sind,
kann ich doch gleich die Aussage als Annahme annehmen.
Prof. Paneau: Ich muß doch sehr um etwas mehr Respekt
gegenüber der Wissenschaftstheorie bitten! In diesem konkreten
Fall aber gebe ich ihnen Recht: Natürlich läßt sich diese Beweisregel begründen. Man hat damit nämlich eine Konstruktionsvorschrift angegeben, wie für jede konkrete Liste ein Beweis geführt werden kann: Man beginnt mit Φ([]), nimmt dann
sukzessive die Listenelemente hinzu und wendet jedesmal den
Beweis des Induktionsschritt an, um zu zeigen, daß auch mit
diesem Element die Aussage noch gilt. Das geht, weil der Induktionsschritt ja für ein beliebiges Element bewiesen wurde.
Was das Induktionsschema also zeigt, ist, daß man für jede Liste
einen Beweis führen kann — und damit gilt die Aussage Φ natürlich für alle Listen.
Übung 4.7 Benutze die Erläuterung von Prof. Paneau, um
zu erklären, warum das hier angegebene Schema nicht für den
Nachweis von Eigenschaften unendlicher Listen zu gebrauchen
ist.
4.3. Strukturelle Induktion
Wir haben bisher das Sortierproblem aus zwei verschiedenen Blickwinkeln betrachtet: Wir haben
das Problem formal spezifiziert und wir haben für das Problem eine Lösung angegeben. Es ist an
der Zeit, die beiden losen Enden wieder zu zusammenzuführen. Wir werden in diesem Abschnitt
zeigen, daß das Sortierprogramm korrekt ist, d.h., daß es die Spezifikation des Sortierproblems
erfüllt. So wie es Programmiermethoden gibt, kennt man auch Beweismethoden, die uns bei der
Programmverifikation unterstützen.
4.3.1. Strukturelle Induktion auf Listen
Das Programm insertionSort sieht richtig aus. Aber, können wir das auch beweisen — und wenn,
dann wie? Es überrascht wahrscheinlich nicht: Der Beweis wird nach einem ähnlichen Schema
geführt, wie wir es für die Programmierung von insertionSort verwendet haben. Für Listen nimmt
das Schema die folgende Form an:
Induktionsbasis ([]): Wir zeigen die Aussage zunächst für die leere Liste [].
Induktionsschritt (a:as): Wir nehmen an, daß die Aussage für die Liste as gilt, und zeigen, daß
sie unter dieser Voraussetzung auch für a:as gilt.
Diese Beweisregel kann man formal wie folgt darstellen:
Φ([])
(∀a, as) Φ(as) =⇒ Φ(a:as)
(∀x) Φ(x)
Dabei symbolisiert Φ die Eigenschaft, die wir für alle Listen nachweisen wollen. Über dem waagerechten Strich werden die Voraussetzungen aufgeführt, unter dem Strich steht die Schlußfolgerung, die
man daraus ziehen kann. Man kann die Regel als Arbeitsauftrag lesen: Um (∀x) Φ(x) zu zeigen,
müssen Φ([] und die Implikation (∀a, as) Φ(as) =⇒ Φ(a:as) nachgewiesen werden. Die im Induktionsschritt getätigte Annahme Φ(as) nennt man auch Induktionsvoraussetzung; im Beweise
notieren wir ihre Anwendung mit dem Kürzel „I.V.“.
4.3. Strukturelle Induktion
73
Allgemein gilt, daß sich die Organisation eines Beweises stark an der Organisation der beteiligten
Funktionen orientiert. Also: Die Funktion insertionSort stützt sich auf die Hilfsfunktion insert
ab. Entsprechend weisen wir zunächst die Korrektheit von insert nach. Hier ist ihre Spezifikation:
ordered x = True =⇒ ordered (insert \(a\) \(x\)) = True
(4.7)
bag (insert a x) = *a+ ] bag x
(4.8)
Wir verlangen, daß insert a eine geordnete Liste in eine geordnete Liste überführt und daß
insert a x eine Permutation von a:x ist. Beachte, daß Aussage 4.7 eine Vorbedingung formuliert:
"wir weisen die Korrektheit von insert a x nur für den Fall nach, daß x geordnet ist.
Wir zeigen zunächst Aussage (4.8): Diesen einen Beweis führen wir, ohne etwas auszulassen oder
„mit der Hand zu wedeln“. Es ist ganz nützlich, die Aussage Φ noch einmal genau aufzuschreiben.
⇐⇒
Φ(x)
(∀a) bag (insert a x) = *a+ ] bag x
(4.9)
Induktionsbasis (x = []):
bag (insert a [])
=
bag [a]
=
*a+ ] bag []
(Def. insert)
(Def. bag)
Induktionsschritt (x = a’:as): Im Rumpf von insert wird an dieser Stelle zwischen zwei Fällen
unterschieden, die wir im Beweis nachvollziehen. Fall a <= a’ = True:
bag (insert a (a’:as))
=
bag (a:a’:as)
=
*a+ ] bag (a’:as)
(Def. insert)
(Def. bag)
Fall a <= a’ = False: Hier erfolgt der rekursive Aufruf im Programm, entsprechend ist zu erwarten, daß wir die Induktionsvoraussetzung anwenden müssen.
bag (insert a (a’:as))
=
bag (a’:insert a as)
=
*a’+ ] bag (insert a as)
=
=
=
*a’+ ] (*a+ ] bag as)
*a+ ] (*a’+ ] bag as)
*a+ ] bag (a’:as)
(Def. insert)
(Def. bag)
(I.V.)
(Ass., Komm. ])
(Def. bag)
Damit ist schon einmal die Aussage (4.8) bewiesen: insert a x verliert weder die Elemente von x,
noch fügt es mehr als a hinzu.
74
4. Programmiermethodik
Bleibt die Frage, ob das Element a auch an die richtige Stelle rückt. Wenden wir uns also dem
Nachweis von Aussage (4.7) zu. Die Rekursionsbasis ist einfach zu zeigen; der Rekursionsschritt
untergliedert sich in drei Fälle.
1. x = a1 :as ∧ a<=a1 = True
2. x = a1 :as ∧ a<=a1 = False ∧ as = []
3. x = a1 :as ∧ a<=a1 = False ∧ as = a2 :as0
Die ersten beiden Fälle sind jeweils leicht nachzuweisen. Im dritten Fall gilt gemäß der Voraussetzung
ordered as und somit a1 <=a2 = True und ordered as0 = True. Teilfall a<=a2 = True:
ordered (insert a (a1 :as))
=
ordered (a1 :insert a as)
(Def. insert)
=
ordered (a1 :a:as)
(Def. insert)
=
a1 <=a && a<=a2 && ordered as
=
True
(Def. ordered)
(Vor.)
Teilfall a<=a2 = False:
ordered (insert a (a1 :as))
=
Übung 4.8 Führe den Korrektheitsbeweis für insertionSort
durch!
Übung 4.9 Zeige die folgenden Eigenschaften mittels Strukturinduktion:
take n x ++ drop n x
bag (x ++ y)
x ++ (y ++ z)
=
=
=
x
(4.10)
bag x ] bag y
(4.11)
(x ++ y) ++ z . (4.12)
ordered (a1 :insert a as)
(Def. insert)
0
=
ordered (a1 :a2 :insert a as )
(Def. insert)
=
a1 <=a2 && ordered (a2 :insert a as0 )
(Def. ordered)
=
ordered (a2 :insert a as0 )
=
ordered (insert a as)
=
True
(Vor. undDef. (&&))
(Def. insert)
(Vor. und I.V.)
Das wär’s.
Mit Hilfe der eben bewiesenen Hilfssätze können wir uns nun daran machen, die Korrektheit
von insertionSort nachzuweisen. Die Beweise sind allerdings so einfach, daß wir sie der geneigten
Leserin zur Übung überlassen.
Todo: Notation, e1 = e2 , insbesondere e1 <=e2 = True, dafür schreiben wir im folgenden kurz
e1 6 e2 , entsprechend für e1 <=e2 = False schreiben wir e1 > e2 .
4.3.2. Strukturelle Induktion auf Bäumen
So wie es für jeden rekursiven Datentyp ein Rekursionsschema gibt, so läßt sich für jeden Datentyp
eine entsprechende Beweisregel aufstellen. Betrachten wir als zweites Beispiel den Typkonstruktor
Tree, den wir in Abschnitt 3.4 definiert haben. Die zugehörige Induktionsregel lautet:
Induktionsbasis (Nil): Wir zeigen die Aussage für den leeren Baum Nil.
4.3. Strukturelle Induktion
75
Induktionsbasis (Leaf a): Wir zeigen die Aussage für das Blatt Leaf a.
Induktionsschritt (Br l r): Wir nehmen an, daß die Aussage für den Teilbaum l und für den
Teilbaum r gilt, und zeigen, daß sie unter dieser Voraussetzung auch für den Baum Br l r
gilt.
Auch dieses Schema wollen wir wieder etwas formaler als Beweisregel darstellen.
Φ(Nil)
(∀a) Φ(Leaf a)
(∀l, r) Φ(l) ∧ Φ(r) =⇒ Φ(Br l r)
(∀t) Φ(t)
Wir verwenden das Prinzip der Strukturinduktion, um die Größe von Bäumen mit ihrer Tiefe in
Beziehung zu setzen: Wieviele Blätter enthält ein Baum der Tiefe n maximal? Nehmen wir an, wir
hätten einen Baum t der Tiefe n mit k Blättern konstruiert. Dann hat der Baum Br t t die Tiefe
n + 1 und 2k Blätter. Wir sehen: Erhöht sich die Tiefe um eins, kann sich die Anzahl der Blätter
höchstens verdoppeln. Somit erhalten wir die folgende Aussage:
size t
6
2ˆdepth t
Übung 4.10 Programmiere eine Funktion complete n, die
einen vollständigen Baum der Tiefe n konstruiert.
(4.13)
Beachte, daß wir bereits von den obigen Vereinbarungen zur Schreibvereinfachung Gebrauch
machen. Beweisen wir die Aussage mittels Strukturinduktion über Bäumen. Induktionsbasis (t =
Nil):
size Nil
=
1
(Def. size)
=
2ˆ0
(Def. (ˆ))
=
2ˆdepth Nil
(Def. depth)
Induktionsbasis (t = Leaf a): analog. Induktionsschritt (t = Br l r): Jetzt können wir annehmen,
daß die Aussage sowohl für l als auch für r gilt.
size (Br l r)
=
size l + size r
6
2ˆdepth l + 2ˆdepth r
(Def. size)
6
2 * 2ˆ(max (depth l) (depth r))
(Eig. max)
=
2ˆ(max (depth l) (depth r) + 1)
(Eig. (ˆ))
=
2ˆdepth (Br l r)
(I.V.)
Übung 4.11 Zeige den folgenden Zusammenhang zwischen der
Anzahl der Blätter (size) und der Anzahl der Verzweigungen
in einem Binärbaum (branches, siehe Übung 4.3).
(Def. depth)
Mit der obigen Formel können wir die Größe nach oben abschätzen. Formen wir die Ungleichung
um, erhalten wir
depth t > log2 (size t)
(4.15)
und können die Tiefe nach unten abschätzen. Beide Aussagen werden wir noch häufiger benötigen.
branches t + 1
=
size t
(4.14)
76
Übung 4.12 Leite aus dem allgemeinen Induktionsschema eine
Instanz für den Typ Musik ab.
4. Programmiermethodik
4.3.3. Das allgemeine Induktionsschema
Nachdem wir zwei Instanzen des Induktionsschemas kennengelernt haben, sind wir (hoffentlich) fit
für das allgemeine Rezept. Wie auch beim allgemeinen Rekursionsschema ist der Ausgangspunkt
die allgemeine Form der Datentypdeklaration, wie wir sie in Abschnitt 3.1.1 kennengelernt haben.
data T a1 . . . am
Harry Hacker: Wie rechnet man denn mit diesen Zahlen:
Succ Zero + Succ (Succ Zero) klappt irgendwie nicht.
Lisa Lista: Du mußt eben entsprechende Rechenregeln aufstellen, z.B. für die Addition zweier Zahlen:
addN :: Natural -> Natural -> Natural
addN Zero
n = n
addN (Succ m) n = Succ (addN m n)
Übung 4.13 Hilf Harry bei der Definition der Multiplikation
auf Natural (Hinweis: Kochrezept anwenden).
=
C1 t11 . . . t1n1
|
...
|
Cr tr1 . . . trnr
Wir müssen wiederum zwischen rekursiven und nicht-rekursiven Konstruktorargumenten unterscheiden: Seien also li1 , . . . , lipi mit 1 6 li1 < li2 < · · · < lipi 6 ni die Positionen, an denen
der Konstruktor Ci rekursiv ist. Das Induktionsschema besteht nun aus r Prämissen; für jeden
Konstruktor gibt es eine Beweisobligation.
(∀x11 . . . x1n1 ) Φ(x1l11 ) ∧ · · · ∧ Φ(x1l1p1 ) =⇒ Φ(C1 x11 . . . x1n1 )
...
(∀xr1 . . . xrnr ) Φ(xrlr1 ) ∧ · · · ∧ Φ(xrlrpr ) =⇒ Φ(Cr xr1 . . . xrnr )
(∀x) Φ(x)
Ist pi = 0, so spricht man von einer Induktionsbasis, sonst von einem Induktionsschritt.
Übrigens — die natürliche Induktion, die man aus der Schule kennt (siehe Abschnitt B.3.3), ist
ein Spezialfall der strukturellen Induktion. Um dies zu sehen, muß man nur die natürlichen Zahlen
als Datentyp mit zwei Konstruktoren einführen, etwa
data Natural = Zero | Succ Natural
Die Zahl n schreiben wir damit als n-fache Anwendung von Succ auf Zero: 1 entspricht Succ Zero,
2 entspricht Succ (Succ Zero) usw. Die Beweisregel für die Natürliche Induktion nimmt dann die
Form der Strukturellen Induktion über dem Typ Natural an:
Φ(Zero)
(∀n) Φ(n) =⇒ Φ(Succ n)
(∀n ∈ Nat) Φ(n)
Die Verallgemeinerung der vollständigen Induktion auf beliebige Datentypen lernen wir im Vertiefungsabschnitt 4.6 kennen.
4.3.4. Verstärkung der Induktion
Die Formulierung von Rechenregeln und die Formulierung von Beweisen sind zwei sehr nah verwandte Tätigkeiten. Wir haben versucht, die Zusammenhänge durch den identischen Aufbau der
Abschnitte 4.2 und 4.3 zu verdeutlichen. Entsprechend beschäftigen wir uns in diesem Abschnitt
mit dem beweistheoretischen Analogon zur Rekursionsverstärkung.
4.3. Strukturelle Induktion
77
Übertragen auf Induktionsbeweise haben wir es mit dem folgenden Phänomen zu tun: Wir versuchen eine Aussage durch Induktion zu zeigen, bleiben aber im Induktionsschritt stecken; die
Induktionsannahme ist zu schwach, um den Induktionsschritt durchzuführen. Wir entkommen dem
Dilemma, indem wir eine stärkere (schwierigere) Aussage zeigen, die es uns aber erlaubt, den Induktionsschritt erfolgreich zu absolvieren.
Verdeutlichen wir diese Technik vermittels eines Beispiels: Betrachten wir die Bäume, die die
Funktion build aus Abschnitt 3.4 erzeugt. Es fällt auf, daß die Blätter sich höchstens auf zwei
Ebenen befinden, d.h., die Länge des kürzesten und des längsten Pfades von der Wurzel bis zu einem
Blatt unterscheidet sich höchstens um eins. Für die Spezifikation dieser Eigenschaft benötigen wir
neben depth die Funktion undepth, die die minimale Tiefe eines Baums bestimmt.
undepth
undepth
undepth
undepth
:: Tree a -> Integer
Nil
= 0
(Leaf _) = 0
(Br l r) = min (undepth l) (undepth r) + 1
Die Funktion undepth unterscheidet sich von depth nur in der Verwendung von min statt max.
Die Funktion build konstruiert einen Baum, indem sie bn/2c Elemente auf den linken und dn/2e
Elemente auf den rechten Teilbaum verteilt. Bäume mit dieser Knotenverteilung heißen auch BraunBäume: t heißt Braun-Baum, wenn jeder Teilbaum der Form Br l r die Eigenschaft size r - size l ∈
{0, 1} erfüllt.
Die Aussage, die wir zeigen wollen, lautet somit: Sei t ein Braun-Baum, dann gilt
depth t - undepth t ∈ {0, 1} .
(4.16)
Führt man für diese Aussage unmittelbar einen Induktionsbeweis durch, scheitert man im Induktionsschritt: aus den Induktionsvoraussetzungen läßt sich nicht die gewünschte Aussage herleiten. Man merkt nach einigem Herumprobieren, daß man die Tiefe der Bäume mit ihrer Größe in
Verbindung setzen muß. Konkret: Sei t ein Braun-Baum, dann gilt
2n 6 size t < 2n+1
n−1
2
n
< size t 6 2
=⇒
undepth t = n ,
(4.17)
=⇒
depth t = n .
(4.18)
Ist die Größe einer 2-er Potenz, so ist der Baum vollständig ausgeglichen; alle Blätter befinden sich
auf einer Ebene. Anderenfalls liegt ein Höhenunterschied von eins vor.
Wenden wir uns dem Beweis zu. Induktionsbasis (t = Nil): Die Aussage folgt direkt aus size Nil =
1 und undepth Nil = depth Nil = 0. Induktionsbasis (t = Leaf a): analog. Induktionsschritt
(t = Br l r): Da t ein Braun-Baum ist, gilt size l = b 12 size tc und size r = d 12 size te. Damit
folgt für den linken Teilbaum
2n 6 size t < 2n+1
=⇒
2n−1 6 size l < 2n
(Arithmetik)
=⇒
undepth l = n − 1
(I.V.)
Übung 4.14 Definiere eine Funktion, die überprüft, ob ein
Baum die Braun-Eigenschaft erfüllt.
78
4. Programmiermethodik
und entsprechend für den rechten
2n 6 size t < 2n+1
=⇒
2n−1 6 size r 6 2n
(Arithmetik)
=⇒
undepth r > n − 1
(I.V.)
Insgesamt erhalten wir
undepth (Br l r)
=
min (undepth l) (undepth r) + 1
=
n
(Def. undepth)
(obige Rechnungen)
[8, 3, 5, 3, 6, 1]
[8, 3, 5]
[3, 6, 1]
[3, 5]
8
3
5
[6, 1]
3
[3, 5]
[3, 5, 8]
6
1
[1, 6]
[1, 3, 6]
[1, 3, 3, 5, 6, 8]
Abbildung 4.1: Sortieren durch Fusionieren
Harry Hacker: Was bedeutet denn der Punkt in der Definition
von mergeSort?
Grit Garbo: Den mißglückten Versuch, die Komposition von
Funktionen zu notieren . . .
Lisa Lista: Ich hab mal nachgeschlagen: der Punkt ist so
definiert:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
(f . g) a = f (g a)
Nichts Aufregendes, verdeutlicht vielleicht besser die Unterteilung in zwei Phasen.
Die Aussage für depth zeigt man analog.
Die ursprüngliche Behauptung ergibt sich als einfache Folgerung aus dieser Eigenschaft. Wir
sehen: Die schwierigere Aussage ist einfacher nachzuweisen als die ursprüngliche. Denn: im Induktionsschritt können wir auf stärkeren Voraussetzungen aufbauen. Aber: die Formulierung der
Induktionsverstärkung erfordert auch eine tiefere Durchdringung des Problems. Der entscheidende
Schritt war hier, die minimale und maximale Tiefe nicht nur zueinander, sondern zur Größe in
Beziehung zu setzen. Man sieht sehr schön die beiden Phasen des Verfahrens: in der oberen Hälfte
der Abbildung wird geteilt, in der unteren wird zusammengeführt.
Betrachtet man die Graphik in Abbildung 4.1 genauer, so entdeckt man, daß die obere Hälfte
gerade einem Binärbaum entspricht — wenn man von den redundanten Beschriftungen der Verzweigungen absieht. Und: wir haben die erste Phase bereits in Abschnitt 3.4 unter dem Namen build
programmiert. Nennen wir die zweite Phase sortTree, dann erhalten wir
4.3.5. Referential transparency
Gleichungslogik, Gesetz von Leibniz, Vergleich zu Pascal (x ++ x mit Seiteneffekten).
4.4. Anwendung: Sortieren durch Fusionieren
Kommen wir noch einmal auf das Sortierproblem zurück. Der in Abschnitt 4.2.1 entwickelte Algorithmus „Sortieren durch Einfügen“ wird häufig von Hand beim Sortieren von Spielkarten verwendet. Man kann natürlich auch anders vorgehen, insbesondere wenn es Helfer gibt: Der Kartenstapel
wird halbiert, beide Hälften werden getrennt sortiert und anschließend werden die beiden sortierten
Teilstapel zu einem sortieren Gesamtstapel fusioniert. Das Sortieren der Hälften kann unabhängig
voneinander und insbesondere nach dem gleichen Prinzip durchgeführt werden.
Typisch für dieses Verfahren ist die (gedankliche) Unterteilung in zwei Phasen: In der ersten
Phase wird jeder Stapel wiederholt halbiert, bis sich entweder keine Helfer mehr finden oder der
Stapel nur noch aus einer Karte besteht. In der zweiten Phase werden die einzelnen Teilstapel
wieder zusammengeführt, bis zum Schluß ein sortierter Stapel übrigbleibt. In Abbildung 4.1 wird
das Verfahren exemplarisch für die Liste [8, 3, 5, 3, 6, 1] vorgeführt.
4.4. Anwendung: Sortieren durch Fusionieren
79
mergeSort :: (Ord a) => [a] -> OrdList a
mergeSort = sortTree . build
Das Sortierverfahren „Sortieren durch Fusionieren“ ist nach der zentralen Operation der zweiten
Phase, dem Fusionieren zweier geordneter Listen (engl. merge), benannt.2
4.4.1. Phase 2: Sortieren eines Binärbaums
Für die Definition von sortTree verwenden wir — wie nicht anders zu erwarten — das Schema der
strukturellen Rekursion auf Bäumen.
sortTree
sortTree
sortTree
sortTree
:: (Ord a)
Nil
=
(Leaf a) =
(Br l r) =
=> Tree a -> OrdList a
[]
[a]
merge (sortTree l) (sortTree r)
Im Rekursionsschritt müssen zwei bereits geordnete Listen zu einer geordneten Liste fusioniert
werden. Die Funktion merge, die sich dieser Aufgabe annimmt, läßt sich ebenfalls strukturell rekursiv
definieren.
merge
merge
merge
merge
|
|
:: (Ord a) =>
[]
bs
(a:as) []
(a:as) (b:bs)
a <= b
otherwise
OrdList a -> OrdList a -> OrdList a
= bs
= a:as
= a:merge as (b:bs)
= b:merge (a:as) bs
Das Rekursionsschema wird hier geschachtelt verwendet: zunächst erfolgt eine Fallunterscheidung
über das erste Argument, ist dieses nicht-leer erfolgt eine weitere Fallunterscheidung über das zweite
Argument.
merge’ :: (Ord a) => OrdList a -> OrdList a -> OrdList a
merge’ []
= id
merge’ (a:as) = insert
where
insert []
= a:as
insert (b:bs)
| a <= b
= a:merge’ as (b:bs)
| otherwise = b:insert bs
2 In
der Literatur wird „merge“ häufig und schlecht mit Mischen übersetzt. Unter dem Mischen eines Kartenspiels versteht man freilich eher das Gegenteil.
Prof. Paneau: Na ja. Die Schachtelung sieht man nicht sehr
deutlich. Meiner Definition ist dieses Manko nicht zu eigen.
merge’ :: (Ord a) => OrdList a -> OrdList a ->
OrdList a
merge’ []
= id
merge’ (a:as) = insert
where
insert []
= a:as
insert (b:bs)
| a <= b
= a:merge’ as (b:bs)
| otherwise = b:insert bs
Die Funktion id ist nebenbei bemerkt die Identitätsfunktion.
80
4. Programmiermethodik
Die Funktion merge verallgemeinert im gewissen Sinne die Funktion insert, die wir in Abschnitt 4.2.1 programmiert haben. Es gilt:
insert a x
=
merge [a] x.
(4.19)
Kommen wir zum Nachweis der Korrektheit von mergeSort. Im wesentlichen müssen wir uns um
die Eigenschaften der Hilfsfunktionen kümmern. Die Funktion merge erfüllt die beiden folgenden
Eigenschaften (die übrigens in Aufgabe 4.1 nachgefragt wurden):
Übung 4.15 Zeige, daß merge ihre Spezifikation erfüllt.
ordered x ∧ ordered y =⇒ ordered (merge x y)
(4.20)
bag (merge x y) = bag x ] bag y
(4.21)
Der Nachweis der beiden Eigenschaften erfolgt analog zur Definition von merge via einer geschachtelten strukturellen Induktion. Wir überlassen sie der geneigten Leserin zur Übung (siehe 4.15).
Für die Spezifikation von build und sortTree müssen wir die Funktion bag auf Bäume übertragen.
bag
bag
bag
bag
:: Tree a -> Bag a
Nil
= ∅
(Leaf a) = *a+
(Br l r) = bag l ] bag r
Wir verwenden bag im folgenden sowohl auf Listen als auch auf Bäumen. Das Phänomen der Überladung — die Verwendung des gleichen Namens für unterschiedliche, aber verwandte Funktionen —
kennen wir bereits von unserem Haskell: (==) verwenden wir auf allen Typen, die in der Typklasse
Eq enthalten sind.
Für die Funktion build muß gelten:
bag (build x) = bag x
(4.22)
Beachte, daß die Struktur des generierten Baums für den Nachweis der Korrektheit keine Rolle
spielt. Beim Nachweis der obigen Aussage versagt das Schema der strukturellen Induktion: die
Funktion build selbst verwendet ein allgemeineres Rekursionsschema, das wir in Abschnitt 4.5
näher untersuchen. Das beweistheoretische Analogon besprechen wir im Vertiefungsabschnitt 4.6,
so daß wir der Aussage an dieser Stelle einfach Glauben schenken.
Für sortTree muß schließlich gelten:
ordered (sortTree t)
(4.23)
bag (sortTree t) = bag t
(4.24)
build :: [a] -> Tree a
build [] = Nil
build [a] = Leaf a
build as = Br (build (take k as)) (build (drop k as))
where k = length as ‘div‘ 2
4.4. Anwendung: Sortieren durch Fusionieren
81
Beide Aussagen lassen sich mit dem Induktionsschema für Tree einfach nachweisen (siehe Aufgabe 4.16).
Wir haben kurz angemerkt, daß für den Nachweis der Korrektheit die Struktur des in der ersten
Phase erzeugten Baums keine Rolle spielt. Natürlich können wir mit leftist bzw. rightist auch
einen „Strunk“ erzeugen. Wir erhalten dann:
insertionSort’ :: (Ord a) => [a] -> OrdList a
insertionSort’ = sortTree . rightist
4.4.2. Phase 1: Konstruktion von Braun-Bäumen
Wir haben in Abschnitt 4.3.4 besprochen, daß die Funktion build sogenannte Braun-Bäume konstruiert. Zur Erinnerung: der linke und der rechte Teilbaum sind jeweils gleich groß, vorausgesetzt
die Gesamtzahl der Blätter ist gerade; anderenfalls ist der rechte Teilbaum ein Element größer. Die
beiden Varianten von build, die wir in Abschnitt 3.4 vorgestellt haben, sind beide nicht strukturell
rekursiv definiert — sie implementieren die Idee des wiederholten Halbierens der Ausgangsliste sehr
direkt und natürlich.
In diesem Abschnitt wollen wir der — vielleicht akademisch anmutenden — Frage nachgehen,
wie sich Braun-Bäume strukturell rekursiv erzeugen lassen. Aus dem Rekursionsschema ergibt sich
mehr oder minder zwangsläufig die folgende Definition.
type Braun a = Tree a
build’ :: [a] -> Braun a
build’ []
= Nil
build’ (a:as) = extend a (build as)
Im Rekursionsschritt stellt sich eine interessante Teilaufgabe: ein Braun-Baum muß um ein Element erweitert werden. Die Basisfälle sind schnell gelöst:
extend :: a -> Braun a -> Braun a
extend a Nil
= Leaf a
extend a (Leaf b) = Br (Leaf b) (Leaf a)
Im Rekursionsschritt haben wir einen Baum der Form Br l r mit size r - size l ∈ {0, 1}
gegeben, der um ein Element erweitert werden muß. Interessanterweise ergibt sich das weitere
Vorgehen zwangsläufig: Das Element a muß in den kleineren Teilbaum, also l, eingefügt werden;
da der erweiterte Baum extend a l größer ist als r, müssen wir die beiden Teilbäume zusätzlich
vertauschen. Anderenfalls wäre die Braun-Eigenschaft verletzt. Wir erhalten:
extend a (Br l r) = Br r (extend a l)
Übung 4.16 Zeige, daß sortTree ihre Spezifikation erfüllt.
82
4. Programmiermethodik
Diese Vorgehensweise ist typisch für Operationen auf Braun-Bäumen. Trotz der knappen Definition von extend erfordert es einige Kopfakrobatik, die Konstruktion größerer Bäume nachzuvollziehen. In Abbildung 4.2 sind die Bäume dargestellt, die man erhält, wenn man nacheinander 0, 1,
. . . , 7 in den anfangs leeren Baum einfügt. Beachte, daß build’ die Elemente anders auf die Blätter
verteilt als die Funktionen gleichen Namens aus Abschnitt 3.4.
Kommen wir zur Korrektheit der Operationen: Zunächst haben wir die übliche Forderung nach
dem „Erhalt der Elemente“:
1
0
(a)
0
0
1
(b)
0
2
2
(c)
0
4
2
3
1
0
(d)
3
2
3
1
4
(e)
bag (extend a t)
=
bag (build x)
=
*a+ ] bag t
bag x
(4.25)
(4.26)
Zusätzlich müssen build und extend Braun-Bäume erzeugen:
braun t =⇒ braun (extend a t)
(4.27)
braun (build x)
(4.28)
3
5
1
5
1
0
(f)
2
4
6
Genau wie bei insert und merge geben wir auch bei extend eine Vorbedingung an: wir weisen die
Korrektheit von extend a t nur für den Fall nach, daß t ein Braun-Baum ist.
(g)
4.5. Wohlfundierte Rekursion
4.5.1. Das Schema der wohlfundierten Rekursion
0
4
2
6
1
5
3
7
(h)
Abbildung 4.2: Braun-Bäume der Größe 1 bis 8
Übung 4.17 Zeige die Korrektheit von build und extend. Ist
die Spezifikation der Funktionen eindeutig?
Nicht alle rekursiven Funktionen, die wir bisher kennengelernt haben, verwenden das Schema der
strukturellen Rekursion. Ausnahmen sind power (Abschnitt 3.2.3), leaves’, build und buildSplit
(alle Abschnitt 3.4). Schauen wir uns noch einmal die Definition von build an.
build :: [a] -> Tree a
build [] = Nil
build [a] = Leaf a
build as = Br (build (take k as)) (build (drop k as))
where k = length as ‘div‘ 2
In der dritten Gleichung wird die Argumentliste halbiert und die rekursiven Aufrufe erfolgen auf
den beiden Hälften. Strukturelle Rekursion liegt nicht vor, da take n as keine Unterstruktur von as
ist (wohl aber drop k as). Die Funktion ist trotzdem wohldefiniert, da die Länge der Teillisten echt
kleiner ist als die Länge der Argumentliste. Die rekursiven Aufrufe erhalten somit immer kleinere
Listen, bis irgendwann der einfache Fall der höchstens einelementigen Liste erreicht ist. Daß der
einfache Fall erreicht wird, ist so sicher wie das Amen in der Kirche: Man sagt, die Rekursion ist
wohlfundiert.
Im Gegensatz zur strukturellen Rekursion kann das Schema der wohlfundierten Rekursion universell für beliebige Datentypen verwendet werden. Hier ist das Kochrezept:
4.5. Wohlfundierte Rekursion
83
Ist das Problem einfach, wird es mit ad-hoc Methoden gelöst. Anderenfalls wird es in
einfachere Teilprobleme aufgeteilt, diese werden nach dem gleichen Prinzip gelöst und
anschließend zu einer Gesamtlösung zusammengefügt.
Das Rekursionsschema heißt manchmal auch etwas kriegerisch „Teile und Herrsche“-Prinzip. Betrachten wir noch einmal die oben erwähnten Funktionen: Die Funktion build unterteilt ein Problem
in zwei Teilprobleme; dies ist häufig der Fall, muß aber nicht so sein. Die Funktionen power und
leaves enthalten lediglich einen rekursiven Aufruf. Daß die rekursiven Aufrufe tatsächlich „einfachere“ Probleme bearbeiten, ist bei allen Funktionen — mit Ausnahme von leaves — einsichtig.
Im Fall von leaves geht der Baum Br (Br l’ r’) r in den Baum Br l’ (Br r’ r) über. Inwiefern
ist der zweite Baum einfacher als der erste? Die Anzahl der Blätter wird zum Beispiel nicht geringer.
Damit die Rekursion wohlfundiert ist, müssen die Argumente der rekursiven Aufrufe stets kleiner werden. Und: der Abstieg über immer kleiner werdende Elemente muß irgendwann auf einen
Basisfall zulaufen. Diese Eigenschaft muß man als Programmiererin nachweisen; sie wird einem
nicht durch das Rekursionsschema geschenkt. Im Gegenteil: der Nachweis der Wohlfundiertheit ist
Voraussetzung für die sinnvolle Anwendung des Schemas.
Formal zeigen wir diese Voraussetzung durch die Angabe einer wohlfundierten Relation: Die
Relation „≺“ heißt wohlfundiert, wenn es keine unendliche absteigende Kette
· · · ≺ xn ≺ · · · ≺ x2 ≺ x1
gibt. Beispiele für wohlfundierte Relationen sehen wir in Kürze; ein Gegenbeispiel stellt die Relation
„<“ auf den ganzen Zahlen Z dar, da · · · < −3 < −2 < −1 < 0 unendlich absteigend ist.
Die
Wahl der Relation „≺“ hängt natürlich vom jeweiligen Problem ab. Auf den natürlichen Zahlen N
wird man für „≺“ in der Regeln die normale, strikte Ordnungsrelation „<“ wählen. Damit läßt sich
z.B. die Wohlfundiertheit von power zeigen.3 Für listenverarbeitende Funktionen werden wir in der
Regel die folgende Relation verwenden:
x≺y
⇐⇒
length x < length y .
Übung 4.18 Beweise, daß die in 4.29 definierte Relation
wohlfundiert ist.
(4.29)
Damit lassen sich z.B. build und buildSplit als wohlfundiert nachweisen. Kommen wir zu der
spannenden Frage, wie im Fall von leaves die Relation „≺“ zu wählen ist. Es ist nützlich, sich
die Definition von leaves aus Abschnitt 3.4 ins Gedächnis zu rufen. Die entscheidende Gleichung
lautet:
Abbildung 4.3: Gewichtung eines Binärbäums
leaves (Br (Br l’ r’) r) = leaves (Br l’ (Br r’ r))
Der linke Teilbaum wird bei jedem rekursiven Aufruf von leaves verkleinert, bis schließlich ein
Blatt erreicht wird, dann kommt die dritte oder vierte Gleichung von leaves zur Anwendung.
Die Verzweigungen aller linken Teilbäume werden somit zweimal angefaßt. Bei der Gewichtung der
Bäume zählen sie doppelt:
3 Streng
genommen ist power nicht wohlfundiert, da der Aufruf power x n nur für nicht-negative n arbeitet.
Intendiert ist, power auf die natürlichen Zahlen einzuschränken. Haskell verfügt aber nur über ganze
Zahlen.
v
t
u
=⇒
t
u
Abbildung 4.4: Gewichtete Rotation
v
84
4. Programmiermethodik
weight
weight
weight
weight
:: Tree a -> Integer
Nil
= 0
(Leaf a) = 0
(Br l r) = 1+2*(size l - 1) + weight r
Beachte, daß wir nur die Verzweigungen zählen: size l - 1 entspricht gerade der Anzahl der
Verzweigungen in l (siehe Aufgabe 4.11). Die Gewichtung eines Baums läßt sich gut graphisch
darstellen: Doppelt zählende Knoten malen wir schwarz, einfach zählende weiß. Abbildung 4.3 zeigt
ein Beispiel für einen gewichteten Baum. Man sieht, daß nur die Verzweigungen auf dem Weg zum
rechten Blatt einfach zählen. Diese konstituieren gerade die in einem Baum verborgene Listenstruktur. Eine Rechtsrotation verkleinert das Gewicht eines Baums um eins: Ein schwarzer wird zu einem
weißen Knoten (Abbildung 4.4). Die Relation „≺“ wird somit definiert durch
t≺u
⇐⇒
weight t < weight u .
Überzeugen wir uns, daß das Gewicht des rekursiven Aufrufs tatsächlich kleiner wird.
weight (Br (Br l’ r’) r)
=
2*size (Br l’ r’) + weight r - 1
(Def. weight)
=
2*size l’ + 2*size r’ + weight r - 1
(Def. size)
>
2*size l’ + 2*size r’ + weight r - 2
(Arithmetik)
=
2*size l’ + weight (Br r’ r) - 1
(Def. weight)
=
weight (Br l’ (Br r’ r))
(Def. weight)
Damit ist gezeigt, daß leaves wohlfundiert ist.
Übrigens — die strukturelle Rekursion ist ein Spezialfall der wohlfundierten Rekursion. Die Relation ≺ liegt bei der strukturellen Rekursion implizit zugrunde: y ≺ x ⇐⇒ y ist Substruktur von x.
Dieser Zusammenhang macht auch einen Vorzug der strukturellen Rekursion deutlich: wir bekommen den Nachweis der Wohlfundiertheit geschenkt.
4.6. Vertiefung: Wohlfundierte Induktion
Der Beweis von Aussage 4.22 steht noch aus. Daß build x und x stets die gleichen Elemente
enthalten, läßt sich nicht — oder jedenfalls nicht direkt — mit struktureller Induktion beweisen.
Dies liegt im wesentlichen daran, daß build das Schema der wohlfundierten Rekursion verwendet.
Glücklicherweise gibt es aber auch ein korrespondierendes Beweisschema, daß es uns erlaubt, den
Beweis direkt zu führen.
Wie auch beim Rekursionsschema muß zunächst eine wohlfundierte Relation festgelegt werden,
die beschreibt, was unter „einfacher“ bzw. „kleiner“ zu verstehen ist. Das Schema der wohlfundierten
Induktion nimmt dann die folgende Form an:
Wir zeigen die Aussage für alle Elemente, jeweils unter der Voraussetzung, daß die
Aussage für alle kleineren Elemente wahr ist.
4.6. Vertiefung: Wohlfundierte Induktion
85
Die formale Definition macht die Beweis-Obligation deutlich:
(∀y) ((∀z ≺ y) Φ(z)) =⇒ Φ(y)
(∀x) Φ(x)
Im Unterschied zur strukturellen Induktion gibt es keine explizite Induktionsbasis und keinen Induktionsschritt: Die Aussage Φ(y) muß für alle y gezeigt werden, jeweils unter der Annahme, daß
Φ(z) für alle z ≺ y gilt. Um diese Allaussage nachzuweisen, wird man in der Regel eine Fallunterscheidung über die Elemente des beteiligten Datentyps vornehmen. Ist die Prämisse (∀z ≺ y) Φ(z)
leer, d.h., es gibt keine kleineren Elemente als y, dann liegt implizit ein Basisfall vor. Wenden wir
uns dem Nachweis von 4.22 zu: Die Relation „≺“ ist wie in 4.29 definiert, d.h., wir führen den Beweis
über die Länge von x. Für die Fälle x = [] und x = [a] ist die Aussage wahr. In allen anderen
Fällen gilt 1 6 k < length x. Daraus folgt
length (take k x)
=
k < length x
length (drop k x)
=
length x - k
<
length x.
Somit kann die Induktionsannahme für die Argumente von build angewendet werden. Wir erhalten:
Übung 4.19 Zeige, daß power korrekt ist.
Übung 4.20 Zeige
bag (build x)
=
bag (Br (build (take k x)) (build (drop k x)))
(Def. build)
=
bag (build (take k x)) ] bag (build (drop k x))
=
bag (take k x) ] bag (drop k x)
(I.V.)
=
bag (take k x ++ drop k x)
(4.11)
=
bag x
(4.10)
(Def. bag)
bag (leaves t)
=
bag t
mittels wohlfundierter Rekursion.
Übung 4.21 Zeige 4.30 mittels struktureller Induktion.
(4.30)
5. Effizienz und Komplexität
Ziele des Kapitels:
Jeder von uns hat schon manches Mal eine Rechnung abgebrochen — weil uns die Anzahl der
Rechenschritte zu groß wurde oder uns die Unhandlichkeit eines immer größer wachsenden Formelberges entmutigt hat. Für uns ist dies immer der Anstoß, einen geschickteren Lösungsweg zu suchen
(oder jemand anders, der die Rechnung durchführt). Niemand würde deshalb auf die Idee kommen,
eine allgemeine Theorie des Rechenaufwands zu entwickeln. Anders liegt die Sache, wenn wir umfangreiche Rechnungen auf den Computer übertragen. Denn mit gegebenem Programm liegt ja der
Lösungsweg fest — und damit auch der zugehörige Rechenaufwand. Die Frage nach der Effizienz
von Programmen kommt damit auf den Tisch.
Dieser Frage ist ein ganzes Teilgebiet der Informatik gewidmet — die Komplexitätstheorie. Die
Beurteilung der Effizienz von Programmen ist für sie nur der Ausgangspunkt. Darüber hinaus geht
es in ihr um die allgemeinere Frage, mit welchem Aufwand sich welche Klassen von Problemen
algorithmisch lösen lassen.
Von dieser Theorie behandeln wir hier im wesentlichen nur die Ausgangsfrage, nämlich die Effizienzbeurteilung von Programmen. Die dabei benutzten Begriffe und Techniken gehören zum elementaren Handwerkszeug jeden Informatikers. Über den Bedarf eines Programms an Rechenzeit und
Speicherplatz sollte man sich klar sein, bevor man ein Programm startet. Im allgemeinen möchten
wir ja auch den Computer nicht unnötig beschäftigen.
5.1. Grundlagen der Effizienzanalyse
5.1.1. Maßeinheiten für Zeit und Raum beim Rechnen
Zeit mißt man in Sekunden, Raum in Metern. Beides taugt natürlich nicht, wenn wir den Aufwand
zur Durchführung eines Programms messen wollen. Sekunden eignen sich nicht zur Bestimmung der
Rechenzeit — verschiedene Rechner, elektronische wie menschliche, werden ein unterschiedliches
Tempo an den Tag legen, was aber nichts mit den Eigenschaften des gerechneten Programms zu
tun hat. Gleiches gilt für den Platzbedarf beim Rechnen. Wir können schlecht die Quadratmeter
Papier zählen, die im Zuge einer Rechnung mit Formeln vollgekritzelt werden. Menschliche Rechner weisen enorme Unterschiede im Papierverbrauch auf, ohne daß dies als Problem gesehen wird.
Dagegen gleicht der Tafelanschrieb eines Dozenten schon eher einem bewußten Magagement von begrenzten Platzressourcen und damit dem, was auch beim maschinellen Rechnen mit beschränktem
Speicherplatz berücksichtigt werden muß. Es gilt also, Maßeinheiten für Rechenzeit und Platzbedarf festzulegen, die vom ausführenden Organ unabhängig sind und so nur die Eigenschaften des
Programms wiedergeben.
Grit Garbo: In der Tat gelingt es mir stets, auch die komplizierteste Rechnung auf einer DIN A4 Seite unterzubringen.
Tafelwischen vermeide ich grundsätzlich.
88
Lisa Lista: Dabei gibt es doch sehr einfache und beliebig komplizierte Gleichungen, und z.B. die Anwendung der let-Regel
kommt mir ganz besonders aufwendig vor. Wenn von all dem
abstrahiert wird — wie können wir dann ein realistisches Effizienzmaß erhalten?
Harry Hacker: Mit Maschineninstruktionen und Bytes als
Einheiten für Zeit und Platz wäre mir wohler. Da weiß man
was man hat.
Lisa Lista: Konkreter wär’s allemal, Harry, aber wir müßten
die Haskell-Programme erst in Maschinenbefehle übersetzen, ehe
wir sie beurteilen können...
5. Effizienz und Komplexität
Ein etwas abstrakteres Maß der Rechenzeit ist ein Rechenschritt. Dabei gibt es verschiedene
Möglichkeiten, was man als einen Rechenschritt zählen will, und wir wählen die naheliegendste: Ein
Rechenschritt ist die Anwendung einer definierenden Gleichung. Ist die Gleichung bewacht, zählen
die Rechenschritte zur Auswertung der Wächter plus ein extra Schritt zur Auswahl der rechten
Seite. Wenn unsere Programme let-, case- oder Funktionsausdrücke enthalten, zählen wir auch
die Anwendung jeder let-, case- oder β-Regel als einen Schritt.
Was wir nicht zählen, ist die
Anwendung eines Konstruktors auf seine Argumente — schließlich sind Formeln, soweit sie aus
Konstruktoren aufgebaut sind, ja bereits ausgerechnet.
Im Hinblick auf den Platzbedarf einer Rechnung müssen wir zuächst festlegen, was die Größe
einer Formel ist. Sie soll bestimmt sein durch die Anzahl ihrer Symbole (Funktionen, Konstruktoren,
Variablen). Klammern zählen wir nicht mit. Eine Rechnung geht von einer gegebenen Formel aus,
die es auszuwerten gilt. Als Platzbedarf der Rechnung, in deren Verlauf eine Formel ja wachsen und
wieder schrumpfen kann, betrachten wir die maximale Formelgröße, die in der Rechnung auftritt.
Dies entspricht also eher dem Modell des Tafelanschriebs mit Löschen erledigter Rechnungsteile
(und Wiederverwendung der Tafelfläche), und weniger dem verschwenderischen Papierverbrauch.
Es ist offensichtlich, daß wir uns bei der Festlegung der Einheiten an den Gegebenheiten der
Sprache Haskell orientiert haben. Will man Programme in anderen Sprachen beurteilen, kann man
in ganz analoger Weise vorgehen. Die Techniken, die wir nun kennenlernen, sind auf alle Programmiersprachen anwendbar.
5.1.2. Detaillierte Analyse von insertionSort
Wir haben zwei Sortierprogramme kennengelernt. Da beide korrekt sind, realisieren sie die gleiche
mathematische Funktion: isort = mergeSort. Während der Mathematiker jetzt verschnauft, fängt
für den Informatiker die Arbeit an: Es gilt die von den beiden Programmen benötigten Resourcen,
Rechenzeit und Speicherplatz, zu bestimmen. Wenn wir das erreicht haben, werden wir sehen, daß
mergeSort in den meisten Fällen kostengünstiger arbeitet als isort und somit bei Anwendungen
den Vorzug erhält.
Es ist nützlich sich eine Notation für die Bestimmung des Rechenaufwands auszudenken: T ime(e ⇒
v) bezeichnet die Anzahl der Rechenschritte, um den Ausdruck e zum Wert v auszurechnen. Sind wir
an v nicht interessiert, schreiben wir auch kurz T ime(e). Den Platzbedarf, um e zu v auszurechnen,
bezeichnen wir entsprechend mit Space(e ⇒ v) oder kurz mit Space(e).
Nähern wir uns dem Thema langsam und versuchen eine Analyse des Zeitbedarfs von isort —
der Platzbedarf ist weniger interessant, wie wir später sehen werden. Die Funktion isort stützt sich
auf die Funktion insert ab; also beginnen wir mit der Analyse von insert. Um die genaue Anzahl
der Rechenschritte zu bestimmen, schreiben wir die Definition von insert noch einmal auf.
Übung 5.1 Schreibe die Definition von insert in let-, caseund Funktionsausdrücke um und führe die folgenden Schritte
mit dieser Version von insert durch. Was ändert sich?
insert a []
= [a]
insert a (a’:as)
| a <= a’
= a:a’:as
| otherwise = a’:insert a as
(insert.1)
(insert.2)
(insert.2.a)
(insert.2.b)
Schauen wir uns als erstes an, wieviele Rechenschritte wir benötigen, um den Ausdruck (insert c) (d:z)
auszurechnen. Wir erhalten zwei unterschiedlichr Rechnungen, je nachdem, ob der Wächter der
5.1. Grundlagen der Effizienzanalyse
89
zweiten Gleichung True oder False ergibt.
(insert c) (d:z)
⇒
⇒
⇒
| c <= d
= c:d:z
| otherwise
= d:insert c z
| True
= c:d:z
| otherwise
= d:insert c z
(insert.2)
(<=)
(insert.2.a)
c : d : z
beziehungsweise
⇒
⇒
| False
= c:d:z
| otherwise
= d:insert c z
(<=)
(insert.2.b)
d:insert c z
Nach 3 Rechenschritten sind wir entweder zum rekursiven Aufruf von insert gelangt, oder wir
haben den Ausdruck c:(d:z) erhalten. Das Ausrechnen von insert c [] schließlich kostet einen
Rechenschritt. Fassen wir zusammen:
T ime(insert c [])
=
1
T ime(insert c (d:z))
=
3,
T ime(insert c (d:z))
=
3 + T ime(insert c z),
falls c 6 d
falls c > d
Aus ähnlichen Überlegungen resultieren weitere Gleichungen, die Aussagen über die Kosten von
isort machen.
T ime(isort [])
=
1
T ime(isort (a:x))
=
1 + T ime(isort x ⇒ v) + T ime(insert a v)
Die Gleichungen beschreiben unser bisheriges Wissen über die Funktion T ime. Sie reichen aus,
um z.B. T ime(isort [8,3,5,3,0,1]) zu bestimmen. [Was kommt heraus?] Das ist nett, stellt uns
aber nicht zufrieden: In der Regel wird man die Rechenzeit nicht für ein bestimmtes Argument,
sondern für beliebige Eingaben, d.h. in Abhängigkeit von der Eingabe, bestimmen wollen. Und:
Die Abhängigkeit der Laufzeit von der Eingabe soll möglichst griffig dargestellt werden. Schließlich
wollen wir ja die Effizienz eines Programms insgesamt bewerten und mit der Effizienz anderer
Programme vergleichen. Nun besteht aber wenig Aussicht, T ime(isort [a1 ,...,an ]) mit einer
geschlossenen Formel auszudrücken. Wir vereinfachen uns das Leben, indem wir etwas abstrahieren
und die Rechenzeit in Abhängigkeit von der Größe der Eingabe bestimmen, d.h., wir kümmern uns
nicht um die Elemente der zu sortierenden Liste, sondern nur um ihre Länge. Eigentlich ist dies auch
vernünftig, da wir in der Regel eher genaue Kenntnis über die Anzahl denn über die Ausprägung
von Daten haben.
90
5. Effizienz und Komplexität
Nun ist die Laufzeit von isort nicht funktional abhängig von der Größe der Liste: isort [1,2,3]
ist schneller ausgerechnet als isort [3,2,1]. Wir helfen uns, indem wir eine obere und eine untere
Schranke für die Rechenzeit bestimmen und die Rechenzeit auf diese Weise einkreisen. In anderen
Worten: Wir fragen, wie schnell kann man isort [a1 ,...,an ] im besten und im schlechtesten Fall
ausrechnen. Wenn wir das Rechnen anderen — z.B. einem Rechner — überlassen, sagt uns die obere
Schranke, ob es sich lohnt, auf das Ergebnis der Rechnung zu warten. Die untere Schranke hilft uns
bei der Entscheidung, ob wir vorher einen Kaffee trinken gehen. Formal suchen wir zwei Funktionen
T isort und T isort , die die Rechenzeit einkreisen:
T isort (n) 6 T ime(isort [a1 ,...,an ]) 6 T isort (n)
Die untere und die obere Schranke sollten natürlich möglichst exakt sein:
T isort (n)
=
min{ T ime(isort x) | length x = n }
T isort (n)
=
max{ T ime(isort x) | length x = n }
Daß wir mit der Größe der Eingabe ausgedrückt als natürliche Zahl arbeiten, hat einen weiteren
nicht zu unterschätzenden Vorteil: Wir sind gewohnt mit Funktionen auf N zu rechnen und von
Seite der Mathematik werden wir kräftig unterstützt.
Aus den obigen Gleichungen für T ime können wir Gleichungen für T isort und T isort ableiten,
indem wir annehmen, daß der Wächter in insert immer zu True oder immer zu False auswertet.
T insert (0)
=
T insert (n + 1)
=
3
T insert (0)
=
1
T insert (n + 1)
=
3 + T insert (n)
T isort (0)
=
1
1
T isort (n + 1)
=
1 + T insert (n) + T isort (n)
T isort (0)
=
1
T isort (n + 1)
=
1 + T insert (n) + T isort (n)
Aus den rekursiven Gleichungen für insert und isort erhalten wir rekursiv definierte Kostenfunktionen. In der Literatur heißen solche Gleichungen auch Rekurrenzgleichungen, da die definierte
Funktion auf der rechten Seite wieder auftritt (lat. recurrere). In den folgenden Abschnitten werden
wir Techniken kennenlernen, um diese und etwas kompliziertere Rekurrenzgleichungen aufzulösen.
Für den Moment begnügen wir uns damit, die geschlossenen Formen der Kostenfunktionen anzugeben:
T insert (0)
=
1
T insert (n + 1)
=
3
T insert (n)
=
3n + 1
T isort (0)
=
1
T isort (n + 1)
=
4n + 1
T isort (n)
=
(3/2)n2 + (1/2)n + 1
5.1. Grundlagen der Effizienzanalyse
91
Im besten Fall muß insert das Element an den Anfang der Liste setzen, im schlechtesten Fall an das
Ende der Liste; die Anzahl der Rechenschritte ist dann proportional zur Länge der Liste. Sortieren
durch Einfügen liebt somit aufsteigend sortierte Listen und verabscheut absteigend sortierte Listen.
Bevor wir zur Analyse der Sortierfunktion mergeSort kommen, stellen wir ein paar allgemeine
Betrachtungen an.
5.1.3. Asymptotische Zeit- und Platzeffizienz
In unseren bisherigen Überlegungen paßt etwas nicht zusammen: Einerseits, um die Kostenfunktionen zu ermitteln, haben wir recht pedantisch die Rechenschritte gezählt. Andererseits, bei der
Bestimmung der Einheit „Rechenschritt“ haben wir großzügig jede Regel mit Kosten von eins belegt.
Tatsächlich könnte es aber sein, daß die case-Regel aufwendiger ist als die let-Regel; die Kosten
für die let-Regel könnten abhängen von der Anzahl der Hilfsdefinitionen. Die Anwendung einer
definierenden Gleichung könnte von der Anzahl der Parameter und von der Größe der Muster abhängen. Zudem war die Einheit Rechenschritt selbst schon eine Abstraktion von realer, zum Rechnen
benötigter Zeit: Auf dem Rechner Warp III mit dem Übersetzer Turbo-Haskell könnten die Programme um den Faktor 10 schneller ablaufen als auf dem Rechner Slack I mit dem Übersetzer
Tiny-Haskell. Jede dieser Überlegungen könnte dazu führen, daß die errechneten Kosten um einen
konstanten Faktor verringert oder vergrößert werden müssen.
Die gleiche etwas abstraktere Sichtweise wenden wir auch die Kostenfunktionen an. Wenn wir die
Effizienz eines Programms beurteilen bzw. zwei Programme bezüglich ihrer Effizienz vergleichen,
werden wir zunächst nicht danach fragen, ob 3n2 oder 15n2 Rechenschritte benötigt werden. Für
eine erste Einordnung interessiert uns das asymptotische Wachstum der Kostenfunktionen: Wie
verhält sich die Rechenzeit, wenn die Eingaben groß — sehr groß — werden. Ein Programm mit
1 3
einer Rechenzeit von 15n2 ist asymptotisch besser als ein anderes mit einer Rechenzeit von 15
n :
2
Die kleinere Konstante ist für n > 15 = 225 vom höheren Exponenten aufgebraucht.
Um das asymptotische Verhalten von Funktionen zu beschreiben, gibt es einige wichtige Notationen, die wir im folgenden einführen. Zu einer gegebenen Funktion g : N → N bezeichnet Θ(g) die
Menge aller Funktionen mit der gleichen Wachstumsrate wie g:
Θ(g)
=
{ f | (∃n0 , c1 , c2 )(∀n > n0 ) c1 g(n) 6 f (n) 6 c2 g(n) }
(5.1)
Ist f ∈ Θ(g), so heißt g asymptotische Schranke von f . Funktionen werden in diesem Zusammenhang überlicherweise durch Ausdrücke beschrieben: 15n2 meint die Funktion n 7→ 15n2 . Welche
Variable die Rolle des formalen Parameters übernimmt, ergibt sich aus dem Kontext.1 Wenn wir
somit kurz 15n2 ∈ Θ(n2 ) schreiben, meinen wir, daß g mit g(n) = n2 eine asymptotische Schranke
von f mit f (n) = 15n2 ist. Das asymptotische Verhalten der Kostenfunktionen von insert und
1 In
unserer Programmiersprache unterscheiden wir sorgfältig zwischen Ausdrücken, die Zahlen bezeichnen,
und Ausdrücken, die Funktionen bezeichnen: 15*nˆ2 ist eine Zahl, die wir ausrechnen können, wenn wir
n kennen. Wollen wir ausdrücken, daß 15*nˆ2 eine Funktion in n ist, schreiben wir \n -> 15*nˆ2. Aus
Gründen der Bequemlichkeit — eine Bequemlichkeit, die uns ein Rechner nicht gestattet — verwenden wir
an dieser Stelle für Funktionen einfach Ausdrücke, ohne den formalen Parameter explizit zu kennzeichnen.
92
5. Effizienz und Komplexität
isort können wir wie folgt beschreiben:
T insert (n)
∈
Θ(1)
T insert (n)
∈
Θ(n)
T isort (n)
∈
Θ(n)
T isort (n)
∈
Θ(n2 )
Wir sagen, die „best case“-Laufzeit von isort ist Θ(n) und die „worst case“-Laufzeit ist Θ(n2 ).
Man sieht, daß Konstanten und kleine Terme, z.B. 2n + 1 in 3n2 + 2n + 1, unter den Tisch fallen.
Geht n gegen unendlich, dominiert der Term mit dem größten Exponenten.
Um nicht immer auf die Definition von Θ zurückgreifen zu müssen, ist es nützlich, sich ein paar
Eigenschaften von Θ zu überlegen. Hier ist eine Liste der wichtigsten:
f ∈ Θ(f )
f ∈ Θ(g) ∧ g ∈ Θ(h) ⇒ f ∈ Θ(h)
f ∈ Θ(g) ⇒ g ∈ Θ(f )
(Reflexivität)
(5.2)
(Transitivität)
(5.3)
(Symmetrie)
(5.4)
cf ∈ Θ(f )
(5.5)
na + nb ∈ Θ(na ) für a > b
(5.6)
loga n ∈ Θ(logb n)
(5.7)
In der letzten Beziehung haben wir unsere Notation stillschweigend auf Funktionen über R ausgedehnt. Das erlaubt es uns, ohne „floor“- und „ceiling“-Funktionen zu rechnen.
Neben exakten asymptotischen Schranken gibt es auch Notationen für untere und obere asymptotische Schranken:
Lisa Lista: Harry, damit sind unsere früheren Bedenken gegen
die Einheit Rechenschritt ausgeräumt, oder?
Harry Hacker: Sind sie — bis auf einen letzten Punkt. Vielleicht sollte ich mal den Source-Code des Haskell-Compilers durchlesen.
Ω(g)
=
{ f | (∃n0 , c)(∀n > n0 ) cg(n) 6 f (n) }
(5.8)
O(g)
=
{ f | (∃n0 , c)(∀n > n0 ) f (n) 6 cg(n) }
(5.9)
Ist f ∈ Ω(g), so heißt g untere asymptotische Schranke von f . Für f ∈ O(g) heißt g entsprechend
obere asymptotische Schranke von f . Die asymptotische Laufzeit von isort ist z.B. Ω(n) und O(n2 ).
Etwas Vorsicht ist bei der Verwendung von Ω und O geboten. Auch die folgende Aussage ist wahr:
Die asymptotische Laufzeit von isort ist Ω(log n) und O(n10 ). Die Schranken müssen eben nicht
genau sein. Aus diesem Grund ist es besser zu sagen: Die „best case“-Laufzeit von isort ist Θ(n)
und die „worst case“-Laufzeit von isort ist Θ(n2 ). Denkaufgabe: Gibt es eine Funktion f , so daß
die Aussage „Die asymptotische Laufzeit von isort ist Θ(f )“ korrekt ist?
Die asymptotische Betrachtungsweise hat viele Vorteile: Sie setzt nachträglich unsere zu Beginn
dieses Kapitels getroffenen Abstraktionen ins Recht. Wenn wir die asymptotische Laufzeit von
Funktionen analysieren, ist es in der Tat korrekt, die Anwendung einer Gleichung als einen Schritt zu
zählen. Warum? Nun, die Gestalt der Gleichungen hängt nicht von der Eingabe ab, ist also konstant.
Jede Gleichung faßt eine feste Anzahl (einfacherer) Rechenschritte zusammen; das Ergebnis der
Analyse kann somit nur um einen konstanten Faktor verfälscht werden. Die Konstante fällt aber
in der Θ-Notation wieder unter den Tisch. Gleiches gilt für den Einfluß des Compilers: Sofern jede
5.2. Effizienz strukturell rekursiver Funktionen
93
Haskell- Rechenregel auf eine feste Anzahl von Maschineninstruktionen abgebildet wird, kommt es
für die asymptotische Effizienz aufs Gleiche heraus, ob wir Maschineninstruktionen oder HaskellRegeln als Einheit wählen. Und schließlich macht auch die unterschiedliche Taktrate der Rechner
Warp III und Slack I nur einen konstanten Faktor aus, für die asymptotische Effizienz also keinen
Unterschied.
Wir dürfen sogar noch konsequenter sein und nur „laufzeitbestimmende“ Operationen zählen: Getreu der asymptotischen Betrachtungsweise können wir eine Operation X als bestimmend ansehen,
wenn für jede andere Operationen Y gilt T Y ∈ O(T X ). Die Sortierfunktionen basieren z.B. auf dem
Vergleich von Elementen; die Anzahl der durchgeführten Vergleichsoperationen bestimmt somit im
wesentlichen die Laufzeit der Funktionen. Im nächsten Abschnitt werden wir sehen, daß die Analyse von isort die gleiche asymptotische Laufzeit ermittelt, wenn wir nur die Vergleichsoperationen
zählen.
Die Vorteile der asymptotischen Betrachtungsweise sind auch ihre Nachteile: Ist die verborgene
Konstante sehr groß, kann ein Verfahren trotz guter asymptotischer Laufzeit in der Praxis unbrauchbar sein. Auf die Algorithmen, die wir betrachten, trifft das Gott sei Dank nicht zu.
Noch eine letzte Bemerkung zur Art der Analyse. Bisher haben wir den „best case“ und den „worst
case“ untersucht. Man kann auch den Resourcenverbrauch für den „average case“ berechnen. Die
Analyse des durchschnittlichen Falls ist aber im allgemeinen sehr schwierig: Sie setzt voraus, daß man
Kenntnis über die Häufigkeitsverteilung der Eingaben hat. Selbst wenn man — ob nun gerechtfertigt
oder nicht — annimmt, daß alle Eingaben gleich wahrscheinlich sind, bleibt die Analyse aufwendig.
5.2. Effizienz strukturell rekursiver Funktionen
Bestimmen wir noch einmal die „worst case“-Laufzeit von isort, indem wir nur die Anzahl der
benötigten Vergleichsoperationen bestimmen. Das vereinfacht das Zählen und auch die Rekurrenzgleichungen.
T insert (0)
=
0
T insert (n + 1)
=
1 + T insert (n)
T isort (0)
=
0
T isort (n + 1)
=
T insert (n) + T isort (n)
Die Gleichungen haben eine besonders einfache Form, die gut als Summenformel geschrieben werden
kann. Man rechnet schnell nach, daß eine Kostenfunktion der Form
C(0)
=
c
C(n + 1)
=
f (n + 1) + kC(n)
die geschlossene Form
C(n)
=
n
k c+
n
X
i=1
kn−i f (i)
(5.10)
Prof. Paneau: Wobei nicht verschwiegen werden sollte, daß
sich bei der Bestimmung der bestimmenden Operation schon
mancher verschätzt hat. Strenggenommen muß für jede Regel
nachgewiesen werden, daß für sie T Y 6 CT X gilt, und zwar für
ein festes C. Die Informatiker gehen damit oft sehr intuitiv um.
Übung 5.2 Nimm an, daß das Einfügen eines Elementes in
eine Liste durch insert im Durchschnitt in der Mitte der Liste
erfolgt. Leite unter dieser Annahme die average-case Effizienz
von isort ab. Wo liegt der Unterschied zum worst case?
94
5. Effizienz und Komplexität
hat. Im Fall von T insert und T isort ist die Konstante k gleich 1, da die Funktionen wie auch der
Datentyp Liste linear rekursiv sind: In der definierenden Gleichung kommt das definierte Objekt
genau einmal auf der rechten Seite vor. Mithilfe der Summenformel lassen sich die Kostenfunktionen
für insert und isort leicht in eine geschlossene Form bringen.
T insert (n)
=
n
X
1 = n ∈ Θ(n)
i=1
n
T isort (n)
=
X
i−1=
i=1
1
n(n − 1) ∈ Θ(n2 )
2
Wir sehen: Wenn wir durch die asymptotische Brille blicken, erhalten wir das gleiche Ergebnis wie
in Abschnitt 5.1.2.
Bisher haben wir den Zeitbedarf von isort analysiert. Widmen wir uns jetzt dem Platzbedarf.
Bei der Analyse kommen uns die Überlegungen zur asymptotischen Komplexität ebenfalls zu Gute:
Auch hier spielen konstante Faktoren keine Rolle. Schauen wir uns den typischen Verlauf einer
Rechnung an.
isort [a1 , ..., an−1 , an ]
⇒
insert a1 (· · · (insert an−1 (insert an [])) · · ·)
⇒
[aπ(1) , ..., aπ(n−1) , aπ(n) ]
In der Mitte der Rechnung harren n Anwendungen von insert ihrer Abarbeitung. Während der
Abarbeitung von insert ai x vergrößert sich die Formel kurzfristig etwas; am Ende steht die um ai
erweiterte Liste x. Summa summarum ist der Platzbedarf proportional zur Länge der Eingabeliste.
Space(insert a x)
∈
Θ(length x)
Space(isort x)
∈
Θ(length x)
Strukturell rekursive Funktionen auf Listen sind in der Regel gut zu analysieren, da die resultierenden Rekurrenzgleichungen eine einfache Form haben. Funktionen auf Bäumen stellen uns vor
größere Probleme; dafür ist der Erkenntnisgewinn auch größer. Die Funktion sortTree, die einen
Baum in eine sortierte Liste überführt, illustriert dies eindrucksvoll.
sortTree :: Tree Integer -> [Integer]
sortTree (Leaf a) = [a]
sortTree (Br l r) = merge (sortTree l) (sortTree r)
[In den nachfolgenden Rechnungen kürzen wir sortTree mit sT ab.] Wir wollen die „worst case“Laufzeit von sortTree bestimmen; wie bereits praktiziert zählen wir zu diesem Zweck nur die
Vergleichsoperationen. Das Verhalten von merge ist schnell geklärt:
T merge (m, n)
=
m+n−1
für m, n > 1
5.2. Effizienz strukturell rekursiver Funktionen
95
Der schlechteste Fall für merge liegt vor, wenn beide Listen wie ein Reißverschluß verzahnt werden
müssen: merge [0,2..98] [1,3..99]. [Ideal wäre es, wenn alle Elemente der kürzeren Liste kleiner
sind als die der längeren Liste.]
Die Größe der Eingabe entspricht bei listenverarbeitenden Funktionen der Länge der Liste. Was
ist die bestimmende Eingabegröße im Fall von sortTree? Zur Listenlänge korrespondiert die Anzahl
der Blätter eines Baums — zu length korrespondiert size. Versuchen wir unser Glück:
T sT (1)
=
0
T sT (n)
=
n − 1 + max{ T sT (i) + T sT (n − i) | 0 < i < n }
Da wir nur die Größe des Baums kennen, nicht aber die Verteilung der Blätter auf den linken und
rechten Teilbaum, sind wir gezwungen alle möglichen Kombinationen durchzuprobieren. Schließlich
wollen wir ja die „worst case“-Laufzeit bestimmen. Es ist immer hilfreich, unbekannte Funktionen
für kleine Werte zu tabellieren:
n
T sT (n)
1
0
T sT (n)
=
2
1
3
3
4
6
5
10
6
15
7
21
8
28
9
36
Man sieht, wir erhalten:
n
X
i−1=
i=1
1
n(n − 1) ∈ Θ(n2 )
2
Der schlechteste Fall tritt somit ein, wenn der linke Teilbaum nur einen Knoten enthält und der
rechte alle übrigen, oder umgekehrt. Dann gilt gerade: size t = depth t + 1.
Die Verwendung von size als Maß für die Größe eines Baums ist eigentlich nicht besonders
motiviert, genausogut könnten wir die Tiefe eines Baumes heranziehen. Los geht’s:
0
T sT (0)
=
0
0
T sT (n
=
2n+1 − 1 + 2T sT (n)
+ 1)
Übung 5.3 Zeige, daß 12 n(n − 1) die obige Rekurrenzgleichung
tatsächlich erfüllt.
0
Die Kosten für den Aufruf von merge, sprich die Anzahl der Knoten im Baum, haben wir mit
Gleichung (4.13) grob nach oben abgeschätzt. Die Rekurrenzgleichungen können wir mit (5.10)
lösen:
0
T sT (n)
=
n
X
i=1
2n−i (2i − 1) =
n
X
2n − 2n−i = n2n − 2n + 1 ∈ Θ(n2n )
i=1
Hier ist der schlechteste Fall kurioserweise der ausgeglichene Baum2 , dessen Größe durch 2ˆ(depth t - 1) <
Übung 5.4 Beweise die Eigenschaft 2ˆ(depth t - 1) <
size t 6 2ˆdepth t eingeschränkt ist. Jetzt betrachten wir beide Ergebnisse noch einmal und glesize t 6 2ˆdepth t .
ichen die Resultate mit den jeweiligen Beziehungen zwischen Größe und Tiefe der Bäume ab. In
2 Zur
Erinnerung: Ein Baum heißt ausgeglichen, wenn sich die Größe der Teilbäume für jede Verzweigung
um maximal eins unterscheidet.
96
5. Effizienz und Komplexität
beiden Fällen erhalten wir depth t*size t als „worst case“-Laufzeit. Die Laufzeit wird durch das
Produkt von Größe und Tiefe bestimmt:
T ime(sortTree t)
Übung 5.5 Zeige durch strukturelle Induktion über t, daß die
Beziehung T ime(sortTree t) 6 depth t*size t gilt.
6
depth t*size t
Wir sehen: Die Laufzeit von sortTree läßt sich am genauesten bestimmen, wenn wir sowohl die
Größe als auch die Tiefe des Baums berücksichtigen.
00
T sT (s, d)
=
sd
Für unsere Mühen werden wir freilich auch belohnt: Mit der Analyse von sortTree haben wir so
nebenbei auch isort und mergeSort erledigt! Die Aufrufstruktur von isort entspricht gerade einem
rechtsentarteten Baum. Aus size t = depth t + 1 folgt das bereits bekannte Resultat:
T isort (n)
=
00
T sT (n, n − 1) ∈ Θ(n2 )
Der von mergeSort abgearbeitete Baum ist hingegen ausgeglichen. Wir wissen ja bereits, daß build
stets ausgeglichene Bäume erzeugt. Aus 2ˆ(depth t - 1) < size t 6 2ˆdepth t folgt dann
T mergeSort (n) = T sortTree (n) + T build (n)
und
00
00
nblog2 nc 6 T sT (n, blog2 nc) 6 T mergeSort (n) 6 T sT (n, dlog2 ne) 6 ndlog2 ne
Da build überhaupt keine Vergleiche durchführt, gilt
T build = 0
und somit (das Weglassen der Basis des Logarithmus ist durch 5.7 gerechtfertigt)
T mergeSort (n)
∈
Θ(n log n).
Aus den Ergebnissen läßt sich unmittelbar folgern, daß Sortieren durch Fusionieren eine asymptotisch bessere „worst case“-Laufzeit als Sortieren durch Einfügen hat.
Der Platzbedarf von mergeSort ist genau wie der von insertionSort linear in der Länge der
Eingabeliste. Dazu überlegt man sich, wieviele Knoten der Aufrufbaum von mergeSort enthält.
mergeSort [a1 , ..., an−1 , an ]
⇒
merge (· · ·(merge [a1 ] [a2 ])· · ·) (· · ·(merge [an−1 ] [an ])· · ·)
⇒
[aπ(1) , ..., aπ(n−1) , aπ(n) ]
In der Mitte der Rechnung harren n − 1 merge-Aufrufe ihrer Abarbeitung. Somit ist die maximale
Größe der Formel proportional zur Größe der Eingabeliste.
Space(mergeSort x)
∈
Θ(length x)
5.3. Effizienz wohlfundiert rekursiver Funktionen
97
Jetzt haben wir alle Informationen zusammen, um die beiden Sortierverfahren quantitativ miteinander zu vergleichen. Da der Platzbedarf asymptotisch gleich ist, betrachten wir nur den Bedarf an
Rechenzeit. Klar, die Funktion n log n ist kleiner als n2 , also ist mergeSort effizienter als insertionSort.
Aber, um wieviel effizienter? Ein paar Zahlen verdeutlichen den Unterschied:
n
103
104
105
106
n log2 n
≈ 10, 0 ∗ 103
≈ 13, 3 ∗ 104
≈ 16, 6 ∗ 105
≈ 19, 9 ∗ 106
n2
106
108
1010
1012
Ist n gleich Tausend, ist Sortieren durch Fusionieren 100-mal schneller als Sortieren durch Einfügen;
für n gleich eine Million beträgt der Faktor schon 50.000. Je größer die Listen werden, desto größer
ist der Geschwindigkeitsgewinn.
Harry Hacker und Lisa Lista erhalten den Auftrag einen, Datensatz mit 1.000.000 Einträgen nach
bestimmten Kriterien zu sortieren. Harry verläßt sich auf sein Equipment, Warp III mit dem Übersetzer Turbo-Haskell, und implementiert zu diesem Zweck Sortieren durch Einfügen. Durch geschickte Programmierung und aufgrund der Verwendung eines optimierenden Übersetzers, benötigt das
resultierende Programm 2n2 Rechenschritte, um eine n-elementige Liste zu sortieren. Lisa Lista hat
sich vorher in die Literatur vertieft und Sortieren durch Fusionieren gefunden. Ihre Implementierung
ist nicht besonders ausgefeilt und da sie einen schlechten Übersetzer verwendet, benötigt ihr Programm 80n log2 n Rechenschritte. Harrys Warp III schafft durchschnittlich 750.000 Rechenschritte
pro Sekunde, Lisas Slack I nur 50.000. Trotzdem wartet Harry — falls er so viel Geduld hat —
rund einen Monat auf das Ergebnis
2 · (106 )2
≈ 2.666.666 Sekunden ≈ 30, 9 Tage,
750.000
während Lisa nach rund neun Stunden den sortierten Datensatz vorliegen hat:
80 · (106 ) · log2 (106 )
≈ 31.891 Sekunden ≈ 8, 9 Stunden.
50.000
In einigen Fällen schneidet insertionSort allerdings besser ab als mergeSort: Gerade dann, wenn
die Liste im wesentlichen schon sortiert ist. Dann benötigt insertionSort statt quadratischer nur
noch lineare Laufzeit, während sich an dem Verhalten von mergeSort asympotisch nichts ändert.
Nur der konstante Faktor wird etwas besser. Nachrechnen! Ob der Fall der (fast) vorsortierten
Liste häufig auftritt, hängt von der jeweiligen Anwendung ab. Manchmal ist es sinnvoll, ihn zu
berücksichtigen. Wir kommen darauf in Abschnitt 5.5.2 zurück.
5.3. Effizienz wohlfundiert rekursiver Funktionen
Einige der bisher definierten Funktionen basieren auf dem Prinzip der wohlfundierten Rekursion:
power, leaves und build. Die Funktion power analysieren wir in Abschnitt 5.8, build und leaves
98
5. Effizienz und Komplexität
kommen hier an die Reihe. Beginnen wir mit build. Wir gehen hier etwas genauer vor und zählen
alle Rechenschritte, weil wir später (Abschnitt 5.5) auch für die konstanten Faktoren interessieren
werden. Auch die Konstruktoranwendungen zählen mit, schließlich besteht der Zweck von build
gerade im Baumaufbau. Wir gehen von der ursprünglichen Definition von build aus:
build []
= Nil
build [a]
= Leaf a
build (a:as) = Br (build (take k as))(build (drop k as))
where k = length as ‘div‘ 2
Die Effizienz von take und drop haben wir bereits analysiert; build verwendet außerdem length,
was besonders einfach zu analysieren ist:
Prof. Paneau: Natürlich erwarte ich von einer gewissenhaften Studentin, daß sie die Summenformel aufstellt und
die geschlossene Form von T build nachrechnet.
Grit Garbo: Ehrenwert, Herr Kollege, aber mühsam. Ich
würde den Ansatz T build(2m ) = (m + x)2m+1 − y aufstellen und
die Parameter x und y aus den beiden Rekursionsgleichungen
bestimmen.
Harry Hacker: Und wenn wir T build ∈ Θ(n2 ) berechnet hätten?
Lisa Lista: Dann wäre auch T mergeSort ∈ Θ(n2 ), und unsere
frühere Analyse allein auf Basis der Zahl der Vergleiche wäre
falsch gewesen.
Prof. Paneau: Genau, liebe Studenten. Bei der Bestimmung
der bestimmenden Operation hat sich schon mancher vergriffen.
Auch das Vereinfachen will eben gelernt sein.
T take (k, n)
=
T drop (k, n) = min(k, n)
T length (n)
=
T length (n) = n + 1 ∈ Θ(n).
T build (0)
=
1
T build (1)
=
1
T build (n)
=
1 + T length (n) + T take (k, n) + T drop (k, n) + T build (bn/2c) + T build (dn/2e)
=
1 + (n + 1) + 2(bn/2c + 1) + T build (bn/2c) + T build (dn/2e)
für n > 1
Zur Vereinfachung nehmen wir an, daß die Division durch 2 immer aufgeht, also n = 2m gilt. Wir
erhalten damit
T build (2m )
m
T build (2 )
=
1
=
2 ∗ 2m + 4 + 2T build (2m−1 )
=
(m + 2)2m+1 + 2m − 4
Ersetzen wir 2m wieder durch n, so ergibt sich
T build (0)
=
1
T build (n)
=
1
T build (n)
=
2n(log n + 2) + n − 4 ∈ Θ(n log n)
Da wir den Fall, daß die Division durch 2 nicht aufgeht, durch einen konstanten Faktor abschätzen
können, gilt die asymptotische Aussage für beliebige Werte von n. Damit fällt build in die gleiche
Effizienzklasse wie sortTree, und unsere Aussage T mergeTree ∈ Θ(n log n) ist endgültig gerechtfertigt.
Wenden wir uns der wohlfundiert rekursiven Funktion leaves zu, die die Blätter eines Baums
ermittelt. Wie im Fall von sortTree haben wir das Problem, wie wir die bestimmende Eingabegröße
5.4. Problemkomplexität
99
von leaves wählen: Sowohl size als auch depth scheitern, da die dritte Gleichung von leaves zu
der paradoxen Rekurrenzgleichung
T leaves (n)
=
1 + T leaves (n)
führt. Beide Maße auf Bäumen scheitern, da sie die algorithmische Idee von leaves nicht berücksichtigen. Was tun? Nun, eigentlich haben wir das Problem schon gelöst: Der Induktionsbeweis in
Abschnitt 4.6 verwendet ein Maß auf Bäumen, weight, dessen Wert für jeden rekursiven Aufruf
gerade um eins abnimmt. Also erhalten wir ohne weitere Überlegungen:
T ime(leaves t)
=
weight t + 1
Wir müssen noch eins addieren für den Fall, daß t ein Blatt ist. Aus size t 6 weight t < 2*size t
folgt:
T leaves (n)
∈
Θ(n)
Wir sehen: Unsere damaligen Anstrengungen werden belohnt. Die Relation „≺“ bzw. die Funktion
weight klären nicht nur, warum leaves terminiert, sondern auch wie schnell.
Ende 5.2 und 5.3 fehlen.
5.4. Problemkomplexität
Bisher:
isort: „worst case“-Laufzeit von Θ(n2 )
mergeSort: „worst case“-Laufzeit von Θ(n log n)
Effizienz eines Sortierverfahrens
Jetzt:
Komplexität des Sortierproblems
Wir haben bisher zwei Sortierverfahren kennengelernt: isort mit einer „worst case“-Laufzeit von
Θ(n2 ) und mergeSort mit einer „worst case“-Laufzeit von Θ(n log n). Es stellt sich die Frage, ob
mit Sortieren durch Fusionieren bereits das Ende der Fahnenstange erreicht ist, oder ob man andere
Verfahren mit einer noch besseren asymptotischen Laufzeit entwickeln kann. In diesem Abschnitt
beschäftigen wir uns mit der Komplexität des Sortierproblems: Wie schnell kann man durch Vergleich von Elementen sortieren? Man sollte sich zunächst klarmachen, daß diese Fragestellung von
einer anderen Qualität ist als die in den letzten Abschnitten behandelte: Wir analysieren nicht
die Effizienz eines Sortierverfahrens, sondern die Komplexität des Sortierproblems. Dabei ist die
(asymptotische) Komplexität eines Problems bestimmt als die (asymptotische) Effizienz des besten
Programms, das dieses Problem löst.
Vorher halten wir noch einen Moment inne, denn es ist keine Selbstverständlichkeit, daß dies
überhaupt eine vernünftige Frage ist. Es ist zwar eine verbreitete Redensart, von der Komplexität
von Problemen aller Art zu sprechen. Nun macht man als Wissenschaftler täglich die Erfahrung,
daß zwar manche Dinge zunächst kompliziert erscheinen, aber immer einfacher werden, je besser
man gedanklich erfaßt hat. Wenn also jemand von der Komplexität eines Problems spricht, so ist es
100
5. Effizienz und Komplexität
— im einfachsten Fall — eine Aussage über sein eigenes, mangelhaftes Verständnis. Mag sein, daß
auch einer angeben möchte mit der Komplexität der Dinge, mit denen er sich beschäftigt. Oder er
will gar andere davon abhalten, hier allzusehr auf Klärung zu bestehen ... Was immer es auch sei,
eines gibt es nicht: Eine am Problem selbst festzumachende, objektivierbare Komplexität.
In der Algorithmik allerdings gibt es sie tatsächlich. Das liegt einfach daran, daß wir beim algorithmischen Problemlösen uns auf einen festen Satz von Rechenregeln festlegen — auf den Befehlssatz
unseres Rechners, oder die Rechenregeln unserer Sprache Haskell. Darauf bezogen kann man nachweisen, daß es untere Schranken für die Effizienz der Lösung eines Problems gibt, die sich auf der
Basis dieser Rechenregeln nicht unterbieten lassen. Deshalb ist es korrekt, hier von der Problemkomplexität zu sprechen, ohne damit dem sonst so fragwürdigen Gebrauch des Begriffes Komplexität
recht zu geben.
Fangen wir klein an und überlegen, wieviele Vergleichsoperationen man mindestens benötigt, um
eine 2- oder 3-elementige Liste zu sortieren. Die Sortierprogramme lassen sich gut durch Entscheidungsbäume darstellen: Die Verzweigungen sind mit Vergleichsoperationen markiert, die Blätter mit
den Permutationen der zu sortierenden Liste. Zwei Entscheidungsbäume sind nachfolgend abgebildet.
a1<=a2
/
\
[a1,a2] [a2,a1]
a1<=a2
/
\
a1<=a3
a2<=a3
/
\
/
\
a2<=a3 [a3,a1,a2]
a1<=a3 [a3,a2,a1]
/
\
/
\
[a1,a2,a3] [a1,a3,a2] [a2,a1,a3] [a2,a3,a1]
Die Tiefe des Entscheidungsbaums entspricht gerade der „worst case“-Laufzeit. Denkaufgabe: Was
entspricht der „best case“-Laufzeit? Die Frage ist nun, wie tief ein Entscheidungsbaum mindestens
sein muß, um n Elemente zu sortieren — wir suchen also den besten der schlechtesten Fälle. Ist
n = 2 gibt es 2 Permutationen und somit werden dlog2 2e = 1 Vergleiche benötigt; für n = 3 ist die
Tiefe dlog2 6e = 3. Allgemein ist die Anzahl der Permutationen einer n-elementigen Liste n!. Mit
(4.15) können wir die Anzahl der benötigten Vergleichsoperationen nach unten abschätzen:
T imesort (n) > log2 (n!)
Die Fakultätsfunktion läßt sich mit der Stirlingschen Formel abschätzen:
n
√
n
.
n! > 2πn
e
Insgesamt erhalten wir Daraus ergibt sich, daß Ω(n log n) eine untere Schranke für das Sortieren
durch Vergleichen ist. Da Mergesort eine „worst case“-Laufzeit von Θ(n log n) hat, wird die Schranke
auch erreicht. Verfahren, deren „worst case“-Laufzeit mit der unteren Schranke der für das Problem
ermittelten Komplexität zusammenfällt, nennt man auch asymptotisch optimal. Asymptotisch optimale Sortierverfahren sind Mergesort und Heapsort. Nicht optimal sind Sortieren durch Einfügen,
Minimumsortieren, Bubblesort und Quicksort.
5.4. Problemkomplexität
101
Wir betonen noch einmal, daß Ω(n log n) eine untere Schranke für das Sortieren durch Vergleichen
ist. Wenn wir zusätzliche Annahmen über die zu sortierenden Elemente machen können, läßt sich
das Sortierproblem unter Umständen schneller lösen. Wissen wir z.B., daß alle Elemente in dem
Intervall [l . . . r] liegen, wobei r − l nicht sehr groß ist, dann können wir auch in Θ(n) sortieren, siehe
Abschnitt 5.6.
Im Idealfall sieht der Weg zu einer guten Problemlösung so aus:
1. Man verschafft sich Klarheit über die Komplexität des zu lösenden Problems.
2. Man entwickelt einen Algorithmus, dessen Effizienz in der Klasse der Problemkomplexität
liegt. Asymptotisch gesehen, ist dieser bereits „optimal“.
3. Man analysiert die konstanten Faktoren des Algorithmus und sucht diese zu verbessern.
An den konstanten Faktoren eines Algorithmus zu arbeiten, der nicht der asymptotisch optimalen
Effizienzklasse angehört, macht in der Regel keinen Sinn, weil das aymptotische bessere Verhalten
letztlich (also für große n) immer überwiegt. Allerdings darf man sich auf diese Regel nicht blind
verlassen und muß sich fragen, wie groß die Eingabe praktisch werden kann. Hat man z.B. ein Problem der Komplexität Θ(n) und ein asymptotisch optimales Programm p1 mit großem konstantem
Faktor C, so ist eventuell ein asymptotisch suboptimales Programm p2 mit T imep2 (n) ∈ Θ(n log n)
vorzuziehen, wenn es einen kleinen konstanten Faktor c hat. Ist zum Beispiel C = 10 und c = 1,
so ist Programm p2 bis hin zu n = 1010 schneller. In den meisten Anwendungen würde dies den
Ausschlag für p2 geben.
Die Problemkomplexität Θ(f ) heißt polynomial, wenn f ein Polynom in n ist, und exponentiell, wenn n im Exponenten auftritt. Probleme exponentieller Komplexität nennt man gerne „nicht
praktikabel“ (untractable), um anzudeuten, daß bereits für sehr kleine Eingaben der Aufwand nicht
mehr mit den verfügbaren Zeit-und Platzresourcen zu bewältigen ist. Probleme mit polynomialer
Effizienz gelten als „praktikabel“ (tractable), allerdings darf der Grad des Polynoms nicht allzu hoch
sein.
Hierzu einige Beispiele aus der molekularen Genetik:
• Das Problem, eine kurze DNA-Sequenz in einer längeren zu finden, liegt in Θ(n). Ist die lange
Sequenz z.B. ein komplettes bakterielles Genom, so liegt n etwa bei 3 ∗ 106 .
• Der Ähnlichkeitsvergleich zweier DNA-Sequenzen besteht darin, daß man beide durch Enfügen
von Lücken so untereinander arrangiert, daß möglichst viele gleiche Nukleotide untereinander
stehen. Der Vergleich zweier Sequenzen der Länge n hat die Komplexität Θ(n2 ). Hier ist n
praktisch auf wenige tausend Nukleotide beschränkt.
• Die zweidimensionale Faltungsstruktur einer RNA (das ist eine einsträngige Variante der
DNA) läßt sich mit einem thermodynamischen Modell berechnen. Die Effizienz des Verfahrens
liegt bei Θ(n3 ) mit einem großen konstanten Faktor, so daß Strukturen von RNA-Molekülen
nur bis zu einer Länge von ca. 500 Nukleotiden berechnet werden können.
• Der simultane Vergleich von k DNA-Sequenzen der Länge n ist eine zentrale Frage der Molekularbiologie, weil er Rückschlüsse auf evolutionäre Verwandschaft und gemeinsame Funktionen
gibt. Er hat die Effizienz Θ(2k−1 nk ) und kann daher nur für wenige oder sehr kurze Sequenzen
durchgeführt werden.
102
5. Effizienz und Komplexität
Allerdings finden Informatiker auch im Falle von nicht praktikablen Problemen in der Regel
einen Ausweg. Dieser kann zum Beispiel darin bestehen, daß das Problem nicht exakt, sondern nur
näherungsweise (aber effizienter) gelöst wird. Hier eröffnet sich ein weites Feld der Algorithmik, das
wir aber nicht betreten wollen.
5.5. Anwendung: Optimierung von Programmen am Beispiel
mergeSort
Optimierung von Programmen hat wenig mit Optimalität zu tun — es ist nur ein schlechter, aber
üblicher Ausdruck für die Verbesserung der Effizienz. Sinnvolle Optimierung setzt voraus, daß man
sich im klaren ist über die „Schwächen“ des vorliegenden Programms und die Chancen, seine Effizienz zu verbessern. Im diesem Abschnitt geht es um die Verbesserung der konstanten Faktoren.
Wir haben in Abschnitt 5.4 gesehen, daß Mergesort ein asymptotisch optimales Verfahren ist.
Trotzdem kann man noch viel verbessern; die Tatsache, daß Mergesort asymptotisch optimal ist, sagt
uns, daß es auch sinnvoll ist, bei Mergesort anzusetzen. Wir werden zwei Verbesserungen vorführen,
eine offensichtliche und eine vielleicht weniger offensichtliche. Die offensichtliche Verbesserung besteht darin, aus einer Vor- oder Teilsortierung der Liste Nutzen zu ziehen. Dies zu tun ist naheliegend
und auch angebracht, denn die Laufzeit von mergeSort ist weitestgehend unabhängig von der Anordnung der Elemente in der Liste: Auch die „best case“-Laufzeit ist Θ(n log n). Hier schneidet isort
mit einer „best case“-Laufzeit von Θ(n) besser ab (scheitert allerdings, wenn die Liste absteigend
sortiert ist). Die weniger offensichtliche Verbesserung zielt darauf ab, die unproduktive Teile-Phase
zu optimieren. Fangen wir mit der weniger offensichtlichen Verbesserung an.
5.5.1. Varianten der Teile-Phase
Wir nehmen nun die Effizienz der Funktion build näher unter die Lupe.
Wie können wir den build-Schritt verbessern? Die wiederholte Längenbestimmung der Teillisten
ist überflüssig, wenn man die Länge als zusätzliches Argument mitführt:
build’’ :: [a] -> Tree a
build’’ as = buildn (length as) as
where buildn :: Int -> [a] -> Tree a
buildn 1 (a:as) = Leaf a
buildn n as
= Br (buildn k (take k as))
(buildn (n-k) (drop k as))
where k = n ‘div‘ 2
T build (n) = 5n + 2n log2 n − 4
T build00 (n) = 5n + n log2 n − 4
Was genau ändert sich dadurch? Vergleichen wir buildn mit build, das wir in Abschnitt 5.3
analysiert haben. Dort ist anstelle von T length (n) nun T div + T − einzusetzen, der Beitrag n + 1
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort
103
reduziert sich zu 2. Damit gilt: Die Funktion buildn löst also das Problem ebenfalls in einer Laufzeit
von Θ(n log n). Der konstante Faktor halbiert sich jedoch gegenüber build.
Ein schöner Fortschritt, könnte man meinen. Allerdings — da war ja noch die mittels buildSplit
definierte Funktion build’ aus Abschnitt 3.4 . Die Analyse der Effizienz hatten wir auf später vertagt
— hier ist sie.
buildSplit verwendet weder length noch take. Die entscheidende Gleichung ist
buildSplit n as = (Br l r, as’’)
where k = n ‘div‘ 2
(l,as’) = buildSplit
k as
(r,as’’) = buildSplit (n-k) as’
Für Arithmetik und Konstruktoren fallen 6 Rechenschritte an, und wir erhalten
T buildSplit (n)
=
6 + T buildSplit (bn/2c) + T buildSplit (dn/2e)
T buildSplit (1)
=
1
Als Lösung dieses Gleichungssystems ergibt sich für n = 2k genau T buildSplit (2k ) = 6(2k+1 − 1) =
12n − 6 ∈ Θ(n). Damit liegt build’ sogar in einer besseren Effizienzklasse als build’’, allerdings
mit einem etwa zehnfach schlechteren konstanten Faktor. Erst ab log n = 12, also n = 212 wird die
asymptotisch bessere Funktion build’ auch praktisch besser sein.
Aber kann man den Baumaufbau nicht ganz weglassen, wenn man die aufgespaltenen Listen gleich
fusioniert? Wir eliminieren damit die Datenstrukur, die die beiden Phasen trennt. Das funkioniert
ganz systematisch: Wir rechnen einige Schritte mit den definierenden Gleichungen von mergeSort.
Ausgangspunkt ist die Gleichung
mergeSort as = sortTree (build as)
Wir setzen für as die Fälle [], [a] und (a:as) ein und leiten neue Gleichungen ab:
mergeSort []
= sortTree(build [])
= sortTree Nil
mergeSort [a]
= sortTree(build [a])
= sortTree (Leaf
mergeSort (a:as) = sortTree(build (a:as))
= sortTree(Br (build (take k as)) (build
where k = length
= merge (sortTree (build (take k as))
sortTree (build (drop k as)))
where k = length
= merge (mergeSort (take k as)
mergeSort (drop k as)))
where k = length
a)
= []
= [a]
(drop k as)))
as ‘div‘ 2
as ‘div‘ 2
as ‘div‘ 2
Im letzten Schritt haben wir die definierende Gleichung von mergeSort gleich zweimal angewandt (und zwar von rechts nach links), um die Funktionen sortTree und build endgültig zum
Verschwinden zu bringen. Als Fazit erhalten wir eine „baumfreie“ Variante von mergeSort:
Übung 5.6 Analysiere die Effizienz von mergeSort anhand der
hier abgeleiteten Definition.
104
5. Effizienz und Komplexität
mergeSort’ []
= []
mergeSort’ [a]
= [a]
mergeSort’ (a:as) = merge (mergeSort (take k as))
(mergeSort (drop k as))
where k = length as ‘div‘ 2
Was haben wir erreicht? Die Datenstruktur des Baumes ist nur scheinbar verschwunden — sie tritt
nun als Aufrufufstruktur von mergeSort’ auf: Wo bisher eine Verzweigung Br l r zwei Teilaufgaben
zusammenhielt, ist es nun ein Aufruf der Form merge xs ys, der nicht ausgerechnet werden kann
ehe die Teilprobleme xs und ys gelöst sind. An der aymptotischen Effizienz hat sich nichts geändert,
und einen dramatischen Vorteil bei den konstanten Faktoren dürfen wir auch nicht erwarten: Das
Anwenden der Konstruktoren Br, Nil und Leaf fällt weg, dafür hat merge ein Argument mehr als
sortTree und kostet vielleicht eine Kleinigkeit mehr. Wie diese Abwägung ausgeht, hängt eindeutig
vom Compiler ab und wird daher am einfachsten durch einen Test geklärt. Wir wählen eine sortierte
Liste, damit der Aufwand für merge minimal ausfällt, und andere Unterschiede besser zu sehen sind:
mtest = mergeSort [1..10000]
mtest’ = mergeSort’ [1..10000]
Vom Standpunkt des systematischen Programmierens allerdings spricht alles für das ursprüngliche
mergeSort: Die Trennung in zwei Phasen führt zu einem sehr übersichtlichen Programm, und noch
dazu kann für die erste Phase die Funktion build wiederverwendet werden. Das ist nicht nur bequem,
sondern auch sicher. Entwickelt man mergeSort’ direkt, muß man build implizit neu erfinden, und
— Hand auf’s Herz — manch einer hätte dabei vielleicht die Notwendigkeit der zweiten Gleichung
übersehen und zunächst ein fehlerhaftes Programm abgeliefert. Und last but not least haben wir uns
ja auch über die Effizienz von build schon Gedanken gemacht, was zu der besseren Variante build
geführt hat. Auch diese Verbesserung müßte man bei mergeSort’ noch einmal programmieren.
Wir bleiben damit bei der Trennung in zwei Phasen und betrachten das Problem noch einmal
aus einem anderen Blickwinkel.
Alle Varianten von build konstruieren den Baum von oben nach unten (engl. top down). Die
Liste [a1 ,...,an ] wir durch build in wenigen Schritten in den Baum Br
(build [a1 ,...,abn/2c ]) (build [abn/2c+1 ,...,an ]) überführt. Die oberste Verweigung wird „zuerst“
erzeugt, die Teilbäume in weiteren Schritten. Da wir Bäume durch Ausdrücke beschreiben, ergibt
sich diese Vorgehensweise fast zwangläufig.
Aber eben nur fast; genausogut können wir einen Baum Ebene für Ebene von unten nach oben
aufbauen (engl. bottom up). Das funktioniert so: Nehmen wir an, wir hätten schon Bäume der Tiefe
k erzeugt:
/\
/t1\
----
/\
/t2\
----
/\
/t3\
----
/\
/t4\
----
/\
/t5\
----
/\
/t6\
----
/\
/t7\
----
Bäume der Tiefe k+1 erhalten wir, indem wir zwei jeweils zwei benachbarte Bäume zusammenfassen;
eventuell bleibt ein Baum einer kleineren Tiefe als „Rest“.
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort
o
/
/\
/t1\
----
o
\
/\
/t2\
----
/
/\
/t3\
----
105
o
\
/\
/t4\
----
/
/\
/t5\
----
\
/\
/t6\
----
/\
/t7\
----
Diesen Schritt wiederholen wir so oft, bis nur noch ein Baum verbleibt, also gerade dlog2 ne mal. Um
dieses Verfahren zu programmieren, muß man sich nur noch überlegen, daß man die Zwischenergebnisse durch Listen von Bäumen repräsentiert. Im ersten Schritt ersetzen wir jedes Listenelement a
durch den Baum Leaf a.
bubuild :: [a] -> Tree a
bubuild = buildTree . map Leaf
Die Funktion buildLayer baut eine Ebene auf; buildTree iteriert buildLayer solange, bis ein
Baum übrigbleibt.
buildTree
buildTree
buildTree
buildTree
buildLayer
buildLayer
buildLayer
buildLayer
:: [Tree a] -> Tree a
[] = Nil
[t] = t
ts = buildTree (buildLayer ts)
:: [Tree a] -> [Tree a]
[]
= []
[t]
= [t]
(t1:t2:ts) = Br t1 t2:buildLayer ts
Wie effizient ist buildTree? In jedem Schritt wird die Anzahl der Bäume halbiert, wobei buildLayer
n Konstruktoranwendungen für eine Liste mit n Bäumen benötigt.
T buildLayer (n)
=
n
T buildTree (1)
=
0
T buildTree (n)
=
n + T buildTree (dn/2e)
Übung 5.7 Knobelaufgabe: Lisa Lista hat die folgende Variante von buildTree entwickelt, die den letztgenannten Nachteil
mildern soll.
für n > 1
Lösen wir die Rekurrenzgleichung auf, erhalten wir T buildTree (n) ∈ Θ(n). Welches Verfahren besser
ist, buildn oder bubuild hängt letztlich noch vom verwendeten Übersetzer ab; dazu lassen sich
schlecht allgemeingültige Aussagen machen. Allerdings hat das „bottom up“-Verfahren einen kleinen
Nachteil: Es werden keine ausgeglichenen Bäume erzeugt; der resultierende Baum ist am rechten
Rand ausgefranst. Für n = 2m +1 erhalten wir einen Baum, dessen linker Teilbaum ein vollständiger
Binärbaum der Größe 2m ist und dessen rechter Baum die Größe 1 hat: In jedem Iterationsschritt
bleibt der letzte Baum als Rest.
buildTree’ [t]
= t
buildTree’ (t:ts) = buildTree’
(buildLayer (ts++[t]))
Ist das tatsächlich eine Verbesserung? Welche Eigenschaft haben
die resultierenden Bäume? Ist diese Variante im Hinblick auf die
Verwendung in mergeSort sinnvoll? Wie kann man den Aufruf
von (++) „wegoptimieren“?
106
5. Effizienz und Komplexität
5.5.2. Berücksichtigung von Läufen
Mergesort läßt sich relativ einfach so modifizieren, daß eine Vor- oder Teilsortierung der Listen
ausgenutzt wird. Eine auf- oder absteigend geordnete, zusammenhängende Teilliste nennen wir
Lauf (engl. run). Da für zwei aufeinanderfolgende Listenelemente stets ai 6 ai+1 oder ai > ai+1
gilt, hat ein Lauf mindestens die Länge zwei. [Wenn wir von der einelementigen Liste mal absehen.]
Die Folge
16 14 13 4 9 10 11 5 1 15 6 2 3 7 8 12
enthält z.B. die folgenden fünf Läufe:
16 14 13 4 | 9 10 11 | 5 1 | 15 6 2 | 3 7 8 12.
Knobelaufgabe: Wieviele verschiedene Unterteilungen in 5 Läufe gibt es? Machen wir uns daran,
die Unterteilung einer Liste in eine Liste von Läufen zu programmieren. Wir vergleichen zunächst
die ersten beiden Elemente, um festzustellen, ob die Liste mit einem auf- oder absteigendem Lauf
beginnt. Für beide Fälle definieren wir eine Hilfsfunktion, die den entsprechenden Lauf abtrennt.
runs
runs
runs
runs
:: [a] ->
[]
=
[a]
=
(a:b:x) =
[[a]]
[[]]
[[a]]
if a<=b then ascRun b [a] x
else descRun b [a] x
Die beiden Hilfsfunktionen erhalten drei Parameter: das letzte Element des bis dato erkannten Laufs,
den Lauf selbst und die aufzuteilende Liste.
ascRun, descRun :: a -> [a] -> [a] -> [[a]]
ascRun a as []
= [reverse (a:as)]
ascRun a as (b:y) = if a<=b then ascRun b (a:as) y
else reverse (a:as):runs (b:y)
descRun a as []
= [a:as]
descRun a as (b:y) = if a<=b then (a:as):runs (b:y)
else descRun b (a:as) y
Wenn man sich die rekursiven Aufrufe anschaut, sieht man, daß immer ein Listenelement vom
dritten zum zweiten Parameter „wandert“. Dieser Vorgang ist uns bereits bei der Funktion reel
begegnet. Wie dort dreht sich dabei die Reihenfolge der Elemente um. Wenn wir einen absteigenden
Lauf abarbeiten, ist diese Umkehrung sehr erwünscht: Wir erhalten den Lauf aufsteigend sortiert.
Anders verhält es sich, wenn wir einen aufsteigenden Lauf abarbeiten: Dann ist die Reihenfolge
verkehrt und wir müssen den Lauf mit reverse wieder in die richtige Reihenfolge bringen.
Ein Sortierverfahren, das umso schneller sortiert je weniger Läufe die Liste enthält, heißt geschmeidig (engl. smooth). Sortieren durch Fusionieren machen wir geschmeidig, indem wir in der TeilePhase Bäume konstruieren, die in den Blättern Läufe statt einzelne Elemente enthalten.
5.6. Datenstrukturen mit konstantem Zugriff: Felder
107
smsort :: Ord a => [a] -> [a]
smsort = mergeRuns . build’ . runs
[Statt build’ können wir natürlich auch bubuild verwenden.] Die Herrsche-Phase müssen wir
leicht modifizieren:
mergeRuns :: Ord a => Tree [a] -> [a]
mergeRuns (Leaf x) = x
mergeRuns (Br l r) = merge (mergeRuns l) (mergeRuns r)
Wir beschließen den Abschnitt mit einer kurzen Überlegung zur Effizienz von smsort. Die Tiefe
der konstruierten Binärbäume wird nicht mehr von der Anzahl der Elemente bestimmt, sondern von
der Anzahl der Läufe. An den Kosten pro Ebene ändert sich hingegen nichts; diese sind weiterhin
proportional zur Anzahl der Elemente. Ist l die Zahl der Läufe, dann hat smsort eine Laufzeit von
Θ(n log l).
5.6. Datenstrukturen mit konstantem Zugriff: Felder
Die Komplexität eines gegebenen Problems läßt sich nicht verbessern — es ist ja gerade ihr Begriff, daß sie sich nicht unterbieten läßt. Allerdings ist es manchmal möglich, die Problemstellung
etwas einzuschränken, und dadurch in eine bessere Effizienzklasse zu kommen. So haben wir bisher angenommen, daß wir beliebige Daten sortieren, die der Typklasse Ord angehören. Wir wußten
also nichts über die Daten außer daß uns eine Vergleichsoperation <= zur Verfügung steht. Und
für das Sortieren durch Vergleichen, das haben wir gezeigt, ist die Problemkomplexität nun einmal
Θ(n log n). Wir schränken nun das Problem etwas ein und setzen voraus, daß unsere Daten der
Typklasse Ix, den Indextypen angehören. Dafür werden wir zeigen, daß das Sortieren sogar mit
linearem Aufwand möglich ist. Allerdings brauchen wir dazu einen speziell auf die Benutzung der
Indextypen zugeschnittenen Datentyp.
Zunächst ein Nachtrag: Listenbeschreibungen.
Listen sind die am häufigsten verwendete Datenstruktur. Aus diesem Grund lohnt es sich, weitere
Notationen einzuführen, die die Konstruktion und die Verarbeitung von Listen vereinfachen. So
erlaubt Haskell z.B. die Notation arithmetischer Folgen:
[1 ..] Liste der positiven Zahlen,
[1 .. 99] Liste der positiven Zahlen bis einschließlich 99,
[1, 3 ..] Liste der ungeraden, positiven Zahlen,
[1, 3 .. 99] Liste der ungeraden, positiven Zahlen bis einschließlich 99.
Die Listen sind jeweils vom Typ [Integer]. Die untere und die obere Grenze können durch beliebige
Ausdrücke beschrieben werden. Die Schrittweite ergibt sich als Differenz des ersten und zweiten
Folgenglieds.
Um Funktionen auf Listen zu definieren, haben wir bis dato rekursive Gleichungen verwendet.
Manchmal können listenverarbeitende Funktionen einfacher und lesbarer mit Listenbeschreibungen programmiert werden. Schauen wir uns ein paar Beispiele an: Die Liste der ersten hundert
Quadratzahlen erhält man mit
Lisa Lista: Wie gut, daß build den polymorphen Typ
[a] -> Tree a. Wo wir bisher einen Tree a aufgebaut haben,
bauen wir nun mit der gleiche Funktion einen Tree [a] auf.
Harry Hacker: Ist mir gar nicht aufgefallen!
108
5. Effizienz und Komplexität
squares :: [Integer]
squares = [n*n | n <- [0..99]]
Abstrahieren wir von einer speziellen Funktion und einer speziellen Liste, erhalten wir die Funktion map:
map’ f x = [f a | a <- x]
Somit ist squares = map (\n -> n * n) [0..99]. Listenbeschreibungen ermöglichen oft eine
erstaunlich kurze Formulierung von Programmen: Die vordefinierte Funktion a ‘elem‘ x überprüft,
ob a ein Element der Liste x ist. Definieren läßt sie sich wie folgt:
a ‘elem‘ x = or [a==b | b <- x]
Die vordefinierte Funktion or verallgemeinert die logische Disjunktion auf Listen Boolescher Werte.
Mit Hilfe einer Listenbeschreibung können auch Elemente einer Liste mit bestimmten Eigenschaften ausgewählt werden. Die Funktion divisors n bestimmt die Liste aller Teiler von n. Mit
ihrer Hilfe läßt sich gut beschreiben, wann eine Zahl eine Prinzahl ist: n ist prim, wenn divisors n==[1,n].
Beachte: 1 ist keine Primzahl.
divisors :: (Integral a) => a -> [a]
divisors n = [d | d <- [1..n], n ‘mod‘ d == 0]
primes :: (Integral a) => [a]
primes = [n | n <- [2..], divisors n == [1,n]]
Ein bekanntes Sortierverfahren, Quicksort von C.A.R. Hoare, läßt sich gut mit Hilfe von Listenbeschreibungen definieren.
qsort’’ :: (Ord a) => [a] -> [a]
qsort’’ []
= []
qsort’’ (a:x) = qsort’’ [b | b <- x, b < a]
++ [a]
++ qsort’’ [ b | b <- x, b >= a]
Übung 5.8 Überlege wie Quicksort arbeitet, beweise seine Korrektheit und analysiere die Effizienz.
Etwas Terminologie ist hilfreich, um über Listenbeschreibungen zu reden: Eine Listenbeschreibung
wird durch den senkrechten Strich in den Kopf und in den Rumpf unterteilt. Der Kopf enthält
einen beliebigen Ausdruck; der Rumpf besteht aus einer Folge von Generatoren und Filtern: Ein
Generator hat die Form p<-l, wobei p ein Muster und l ein listenwertiger Ausdruck ist; ein Filter
ist ein Boolescher Ausdruck. Enthält eine Listenbeschreibung mehrere Generatoren, werden diese
geschachtelt abgearbeitet.
[(a,b) | a <- [0,1], b <- [1..3]]
⇒
[(0,1),(0,2),(0,3),(1,1),(1,2),(1,3)]
5.6. Datenstrukturen mit konstantem Zugriff: Felder
109
Die Variable a wird zunächst mit 0 belegt, b nimmt nacheinander die Werte von 1 bis 3 an. Der
Ausdruck im Kopf wird jeweils bezüglich dieser Belegungen ausgerechnet. Man sieht: Weiter rechts
stehende Generatoren variieren schneller.
Genau wie ein let-Ausdruck führt auch ein Generator neue Variablen ein. Die Sichtbarkeit dieser
Variablen erstreckt sich auf den Kopf und auf weiter rechts stehende Generatoren und Filter. Die
Funktion concat, die eine Liste von Listen konkateniert, illustriert dies.
Stellen wir uns die Aufgabe, die folgende kleine Tabelle darzustellen, die die ersten n Quadratzahlen
enthält:
0 1 2 3
n
0 1 4 9 · · · n2
Natürlich gibt es noch eine zusätzliche Bedingung: Der Zugriff auf den i-ten Eintrag soll in konstanter
Zeit möglich sein. Damit scheiden Listen aus: Der Zugriff, der übrigens als Infix-Operator (!!)
vordefiniert ist, benötigt Θ(i) Schritte. Verwenden wir ausgeglichene Binärbäume, läßt sich die
Zugriffszeit von linear auf logarithmisch drücken, konstant wird sie nicht. Tupel gestatten zwar
konstanten Zugriff auf die Komponenten, aber ihre Größe ist fix. Selbst wenn n bekannt und klein
ist, sind Tupel praktisch ungeeignet. Warum?
Mit den uns zur Verfügung stehenden Mitteln können wir die Aufgabe nicht lösen. Aus diesem
Grund gibt es einen vordefinierten Datentyp, der gerade die geforderten Eigenschaften erfüllt: das
Feld (engl. array). Die Tabelle der Quadratzahlen ist schnell definiert:
squares’ :: Array Int Int
squares’ = array (0,99) [(i,i*i) | i <- [0..99]]
Schaut man sich die Ausgangstabelle noch einmal an, sieht man, daß sie im Prinzip eine endliche
Abbildung realisiert: Jedem i, dem sogenannten Index (pl. Indizes), wird ein Element zugeordnet.
Auch der Ausdruck array (l, u) vs, der ein Feld konstruiert, enthält alle Zutaten einer Funktionsdefinition. Der Typ des Ausdrucks, Array a b, legt den Vor- und Nachbereich fest, das Intervall (l, u) den Definitionsbereich; der Graph der Funktion wird durch die Liste vs von Paaren
beschrieben, wobei ein Paar (a, b) angibt, daß dem Argument a der Wert b zugeordnet wird. Diese
Zuordnung muß eindeutig sein: Für (a, v) ‘elem‘ xs und (a, w) ‘elem‘ xs gilt stets v == w.
Felder sind sehr flexibel: Sowohl der Definitionsbereich als auch der Graph können durch beliebige
Ausdrücke beschrieben werden. Im obigen Beispiel ist der Graph durch eine Listenbeschreibung
gegeben. Felder sind effizient: Der Zugriff auf ein Feldelement, der als Infixoperator (!) vordefiniert
ist, erfolgt in konstanter Zeit.
squares’!7 ⇒ 7*7 ⇒ 49
Um den Zugriff in konstanter Zeit durchführen zu können, muß an den Indextyp allerdings eine
Anforderung gestellt werden: Das Intervall (l, u) muß stets endlich sein. Dies ist gewährleistet,
wenn l und u ganze Zahlen sind. Auch Tupel ganzer Zahlen sind erlaubt; auf diese Weise können
mehrdimensionale Felder realisiert werden. Das folgende Feld enthält das kleine 1 × 1:
multTable :: Array (Int, Int) Int
multTable = array ((0,0),(9,9))
[((i,j),i*j) | i <- [0..9], j <- [0..9]]
110
5. Effizienz und Komplexität
Listen kommen als Indextyp nicht in Frage: Zwischen zwei beliebigen Listen x und y liegen stets
unendlich viele Listen. Nachdenken! Typen, die als Indextypen verwendet werden können, sind in
der Typklasse Ix zusammengefaßt: Instanzen von Ix sind Int, Integer, Char, Bool und Tupel von
Indextypen. Folgende Funktionen auf Indextypen sind vordefiniert:
range
inRange
array
bounds
assocs
(!)
::
::
::
::
::
::
(Ix
(Ix
(Ix
(Ix
(Ix
(Ix
a)
a)
a)
a)
a)
a)
=>
=>
=>
=>
=>
=>
(a,a)
(a,a)
(a,a)
Array
Array
Array
-> [a]
-> a -> Bool
-> [(a,b)] -> Array a b
a b -> (a,a)
a b -> [(a,b)]
a b -> a -> b
Mit range (l,u) erhält man die Liste aller Indizes, die in dem Intervall liegen; der durch das
Intervall beschriebene Bereich wird sozusagen als Liste aufgezählt. Die Funktion inRange überprüft,
ob ein Index in einem Intervall liegt. Ein Feld wird wie gesagt mit der Funktion array konstruiert;
mit bounds und assocs werden der Definitionsbereich und der Graph eines Feldes ermittelt. Der
Operator (!) entspricht der Funktionsanwendung. Betrachtet man seinen Typ, so sieht man, daß
der Indizierungsoperator ein Feld auf eine Funktion abbildet.
Genug der Theorie, wenden wir uns weiteren Beispielen zu: Die Funktion tabulate tabelliert eine
Funktion in einem gegebenen Bereich.
tabulate :: (Ix a) => (a -> b) -> (a,a) -> Array a b
tabulate f bs = array bs [(i, f i) | i <- range bs]
Die Tabelle der ersten hundert Quadratzahlen erhält man alternativ durch den Aufruf
tabulate (\a -> a*a) (0, 99).
Eine Liste läßt sich leicht in ein Feld überführen.
listArray :: (Ix a) => (a,a) -> [b] -> Array a b
listArray bs vs = array bs (zip (range bs) vs)
Die vordefinierte Funktion zip überführt dabei zwei Listen in eine Liste von Paaren:
zip [a1 ,a2 ,...] [b1 ,b2 ,...] = [(a1 ,b1 ),(a2 ,b2 ),...]. Die Länge der kürzeren Liste bestimmt
die Länge der Ergebnisliste. Mit zip [0..] x werden die Elemente von x z.B. durchnumeriert.
Ein Feld heißt geordnet, wenn i 6 j ⇒ a!i 6 a!j für alle Indizes i und j gilt. Stellen wir uns die
Aufgabe, ein Element in einem geordneten Feld zu suchen. Wenn wir das Feld von links nach rechts
durchsuchen, benötigen wir im schlechtesten Fall Θ(n) Schritte, wobei n die Größe des Feldes ist.
Sehr viel schneller ist das folgende Verfahren: Ist (l,r) das Suchintervall, dann vergleichen wir das
zu suchende Element mit dem mittleren Feldelement a!m wobei m = (l + r) ‘div‘ 2 ist. Je nach
Ausgang des Vergleichs ist das neue Suchintervall (l, m-1) oder (m+1, r). In jedem Schritt wird
die Größe des Suchintervalls halbiert: Die asymptotische Laufzeit beträgt Θ(log n).
5.7. Anwendung: Ein lineares Sortierverfahren
111
binarySearch :: (Ord b, Integral a, Ix a) => Array a b -> b -> Bool
binarySearch a e = within (bounds a)
where within (l,r) = l <= r
&& let m = (l + r) ‘div‘ 2
in case compare e (a!m) of
LT -> within (l, m-1)
EQ -> True
GT -> within (m+1, r)
Denkaufgabe: Warum heißt das Verfahren binäre Suche?
In der Liste von Argument-/Wertpaaren, die der Funktion array als zweites Argument übergeben
wird, darf jeder Index höchstens einmal auftreten. Die Funktion accumArray erlaubt beliebige Listen;
zusätzlich muß jedoch angegeben werden, wie Werte mit dem gleichen Index kombiniert werden.
accumArray :: (Ix a) => (b -> c -> b) -> b ->
(a,a) -> [(a,c)] -> Array a b
Der Ausdruck accumArray (*) e bs vs ergibt das Feld a, wobei das Feldelement a!i gleich (· · ·((e*c1 )*c2 )· · ·)*ck
ist, wenn vs dem Index i nacheinander die Werte c1 , . . . , ck zuordnet. Beachte: Ist die Operation
(*) nicht kommutativ, spielt die Reihenfolge der Elemente in vs eine Rolle.
5.7. Anwendung: Ein lineares Sortierverfahren
Wir illustrieren die Verwendung von accumArray mit zwei Sortierverfahren, die ganz anders arbeiten,
als die in Kapitel 4 vorgestellten. In beiden Fällen wird zusätzliches Wissen über die zu sortierenden
Elemente ausgenutzt. Gilt es, ganze Zahlen zu sortieren, von denen wir wissen, daß sie in einem
kleinen Intervall liegen, so zählen wir einfach die Häufigkeit der einzelnen Elemente.
countingSort :: (Ix a) => (a, a) -> [a] -> [a]
countingSort bs x = [ a | (a,n) <- assocs t, i <- [1..n]]
where t = accumArray (+) 0 bs [(a,1) | a <- x, inRange bs a]
Wenn das Intervall bs die Größe m und x die Länge n hat, sortiert countingSort in Θ(m + n).
Entwickeln wir die Idee weiter, können wir auch Listen sortieren. Die Voraussetzung ist ähnlich: Die Elemente der zu sortierenden Listen müssen in einem kleinen Intervall liegen. Geeignete
Eingaben sind z.B. Nachnamen; bestehen die Nachnamen nur aus Großbuchstaben, ist das Intervall
gerade (’A’,’Z’) und damit klein genug. Für jedes Element aus dem Intervall gibt es einen Topf,
die Listen werden nach ihrem Kopfelement in die Töpfe geworfen: "HACKER" wandert als ÄCKER" in
den mit ’H’ markierten Topf, "LISTA" als "ISTA" in den Topf mit der Aufschrift ’L’. Die Inhalte
der einzelnen Töpfe werden rekursiv nach dem gleichen Prinzip sortiert.
listSort :: (Ix a) => (a, a) -> [[a]] -> [[a]]
listSort bs xs
| drop 8 xs == [] = insertionSort xs
112
5. Effizienz und Komplexität
| otherwise
= [[] | [] <- xs] ++
[a:x | (a, ys) <- assocs t, x <- listSort bs ys]
where t = accumArray (\y b -> b:y) [] bs [(a,x) | (a:x) <- xs]
Ist n die Länge von xs und k die maximale Länge der in xs enthaltenen Listen, beträgt die Laufzeit
Θ(kn). Da das Verfahren nur für größere n lohnend ist, wird für n 6 8 konventionell sortiert.
Neben array und accumArray gibt es auch die Möglichkeit, ein Feld zu konstruieren, indem ein
gegebenes Feld an einigen Stellen modifiziert wird.
(//) :: (Ix a) => Array a b -> [(a, b)] -> Array a b
Das Feld a//vs ist identisch mit a bis auf die in vs angegeben Stellen. Die folgende Funktion, die
eine Einheitsmatrix einer gegebenen Größe berechnet, demonstriert die Verwendung von (//):
unitMatrix :: (Ix a, Num b) => (a,a) -> Array (a,a) b
unitMatrix bs@(l,r) = array bs’ [(ij,0) | ij <- range bs’]
// [((i,i),1) | i <- range bs]
where bs’ = ((l,l),(r,r))
Ein Feld, das nur Nullen enthält, wird in der Diagonalen mit Einsen überschrieben. Aufgabe:
Formuliere unitMatrix mit array und accumArray und vergleiche die verschiedenen Definitionen.
Wie bilde ich einen Baum auf ein Feld ab?
data LabTree a = Void | Node (LabTree a) a (LabTree a)
repr :: (Num a) => a -> LabTree b -> [(a,b)]
repr i Void
= []
repr i (Node l a r) = [(i,a)] ++ repr (2*i) l ++ repr (2*i+1) r
treeArray :: (Num b, Ix b) => LabTree
treeArray t = array bs [(i,Nothing) |
// [(i, Just a) | (i,a)
where as = repr 1 t
bs = (1, maximum [i | (i,a)
a -> Array b (Maybe a)
i <- range bs]
<- as]
<- as])
Wie Funktionen dürfen auch Felder rekursiv definiert werden. Schauen wir uns ein populäres
Beispiel an: Die nachfolgende Tabelle enthält das sogenannte Pascalsche Dreieck.
0
1
2
3
4
5
6
7
8
0
1
1
1
1
1
1
1
1
1
1
2
3
4
5
6
7
8
1
2
3
4
5
6
7
8
1
3
6
10
15
21
28
1
4
10
20
35
56
1
5
15
35
70
1
6
21
56
1
7
28
1
8
1
5.7. Anwendung: Ein lineares Sortierverfahren
113
Die Elemente der ersten Spalte und der Diagonalen sind gleich eins; alle anderen Elemente ergeben
sich durch Addition des links darüberliegenden und des direkt darüberliegenden Elements. Die
freien Einträge enthalten in Wirklichkeit Nullen, aus Gründen der Übersichtlichkeit sind sie nicht
aufgeführt. Die Zahlen des Pascalschen Dreiecks erfüllen unzähligen Eigenschaften, ein paar führen
wir weiter unten auf. Zunächst kümmern wir uns darum, ein Programm zu schreiben, das ein Dreieck
der Größe n erzeugt. Die Umsetzung ist sehr direkt:
pascalsTriangle :: Int -> Array (Int,Int) Int
pascalsTriangle n = a
where a = array ((0,0),(n,n)) (
[((i,j),0) | i <- [0..n], j <- [i+1..n]]
++ [((i,0),1) | i <- [0..n]]
++ [((i,i),1) | i <- [1..n]]
++ [((i,j),a!(i-1,j) + a!(i-1,j-1)) | i <- [2..n],
j <- [1..i-1]])
Der Eintrag in der Zeile i und der Spalte j ist gleich a!(i,j). Die Elemente der vier Bereiche — oberes Dreieck, erste Spalte, Diagonale und unteres Dreieck — werden jeweils durch eine
Listenbeschreibung definiert. Die Anzahl der Additionsoperationen, die für die Berechnung der
gesamten
Tabelle benötigt werden, läßt sich direkt aus der letzten Listenbeschreibung ablesen:
Pn
i − 1 = 12 n(n − 1). Aber — wie immer gilt, daß nur das berechnet wird, was tatsächlich
i=2
benötigt wird. Wird auschließlich auf den Eintrag (m, k) mit m > k zugegriffen, beträgt die Anzahl
der Additionsoperationen (m − k)k, da nur die Elemente (i, j) in dem Bereich 2 6 i 6 m und
max(1, i − k) 6 j 6 min(i − 1, k) ausgerechnet werden müssen. [Dieser Bereich hat die Form einer
Raute, die Kosten entsprechen der Fläche der Raute.] Jeder weitere Zugriff auf Element aus diesem
Bereich ist allerdings kostenlos, da jeder Eintrag höchstens einmal berechnet wird. Denkaufgabe:
Nehmen wir an, daß wir nur an dem Eintrag (m, k) interessiert sind. Warum ist es keine gute Idee,
für diesen Zweck statt des Feldes eine entsprechend rekursiv definierte Funktion zu programmieren?
Die Elemente des Pascalschen Dreiecks heißen auch Binomialkoeffizienten, weil das Element in
Zeile n und Spalte k dem Koeffizienten des Binoms xk y n−k
entspricht, das man erhält, wenn man
die Formel (x + y)n ausmultipliziert. Als Notation ist nk gebräuchlich (sprich: n über k), wobei n
der Zeile und k der Spalte entspricht.
(x + y)
n
=
n X
n
k=0
k
xk y n−k
In der Kombinatorik sind Binomialkoeffizienten allgegenwärtig, da nk der Anzahl der k-elementigen
Teilmengen einer n-elementigen Menge entspricht. Aus dieser Interpretation leitet sich auch die
folgende Definition ab:
n
k
=
n!
(n−k)!k!
0
06k6n
06n<k
,
114
5. Effizienz und Komplexität
wobei n! = 1 · 2 · . . . · n die Fakultät von n ist. Denkaufgabe: Wie rechnet man die obige Formel
geschickt aus? Auch die Randwerte
i
0
i
i
= 1,
= 1,
i
j
=0
für i < j
und die Beziehung
i
j
=
i−1
j
+
i−1
,
j−1
die wir bei der Konstruktion des Pascalschen Dreiecks verwendet haben, lassen sich bezüglich dieser
Interpretation deuten. Überlegen! Summieren wir die Elemente der n-ten Zeile, erhalten wir gerade
2n . Warum?
5.8. Vertiefung: Rolle der Auswertungsreihenfolge
Wir sind bei unseren Überlegungen bisher stillschweigend davon ausgegangen, daß alle Ausdrücke
ganz ausgerechnet werden müssen. Diese Annahme haben wir zum Beispiel beim Aufstellen der
Rekursionsgleichungen für insertionSort gemacht:
T ime(insertionSort (a:x))
=
3 + T ime(insertionSort x ⇒ v) + T ime(insert a v)
Zur Erinnerung: Mit T ime(e ⇒ v) haben wir die Anzahl der Schritte bezeichnet, um den Ausdruck e
zum Wert v auszurechnen. Die obige Gleichung setzt somit voraus, daß wir insertionSort x in toto
ausrechnen müssen, um insert a (insertionSort x) ausrechnen zu können. Ist diese Annahme
gerechtfertigt? Um mit Radio Eriwan zu antworten: „Im Prinzip ja, aber . . . “. Es kommt darauf an,
ob wir die gesamte sortierte Liste benötigen. Nehmen wir an, wir wollen das Minimum einer Liste
von ganzen Zahlen berechnen. Hier ist eine einfache Lösung:
minimum :: [Integer] -> Integer
minimum x = head (insertionSort x)
Unter der — hier falschen — Annahme, daß alle Ausdrücke ausgerechnet werden müssen, erhalten
wir Θ(n log n) als „worst case“-Laufzeit von minimum. Rechnen wir nur das aus, was wirklich benötigt
wird, beträgt die Laufzeit Θ(n): Um insert a x ausrechnen zu können, muß x die Form [] oder _:_
haben; dann kann insert a x in konstanter Zeit ebenfalls zu einer Liste dieser Form ausgerechnet
werden.
Wie wird nun in Haskell gerechnet? Es ist in der Tat so, daß jeder Ausdruck nur dann ausgerechnet
wird, wenn er im Laufe der Rechnung benötigt wird. Aus diesem Grund ist die Laufzeit unter
Umständen besser als unsere Rechnungen dies vermuten lassen. Ein Beispiel: Um den Ausdruck
let x=e in e’ auszurechnen, rechnen wir zunächst e’ aus. Stellen wir dabei fest, daß wir den
Wert von x benötigen, werten wir e in einer Nebenrechnung aus und setzen das Ergebnis für x
ein. Benötigt wird x z.B., wenn wir eine Fallunterscheidung durchführen müssen, case x of {...},
oder wenn x Argument arithmetischer Operationen ist: x*x. Auf diese Weise wird e höchstens einmal
ausgerechnet.
5.8. Vertiefung: Rolle der Auswertungsreihenfolge
115
Sollten wir auf die Idee kommen, e für jedes Auftreten von x neu auszurechnen, kann sich
die Rechenzeit und der Platzbedarf dramatisch erhöhen. Eine Mehrfachungauswertung könnte aus
Versehen vorkommen, wenn wir e unausgewertet für x in e’ einsetzen — die let-Regel wird zu früh
angewendet — und mechanisch weiterrechnen.
Analysieren wir zur Illustration des Offensichtlichen die Funktion power aus Abschnitt 3.2.3. Der
Wert von y in let {y = power x (n ‘div‘ 2)} in ... wird offenbar benötigt. Die Laufzeit von
power x n ist abhängig vom Exponenten n; wir zählen die Anzahl der Multiplikationsoperationen.
Zunächst die Analyse des tatsächlichen Verhaltens:
T power (0)
=
0
T power (2n − 1)
=
T power (n − 1) + 2
T power (2n)
=
T power (n) + 1
für n > 0
für n > 0
Eine geschlossene Form für T power (n) läßt sich gut bestimmen, wenn wir n binär darstellen, z.B. 13 =
(1101)2 . Mit jedem rekursiven Aufruf wird das letzte Bit, das „least significant bit“, abgeknipst. Ist
es 0, erhöhen sich die Kosten um eins, ist es 1 entsprechend um zwei. Wir erhalten:
T power (n)
blog2 nc + ν(n) + 1 ∈ Θ(log n)
=
für n > 0,
wobei ν(n) gleich der Anzahl der Einsen in der Binärrepräsentation von n ist. Der Platzbedarf von
power ist ebenfalls Θ(log n).
Nehmen wir nun an, daß wir den Wert von y für jedes Vorkommen neu ausrechnen. Dann ergeben
sich folgende Kostengleichungen:
0
T power (0)
0
T power (2n −
0
T power (2n)
1)
=
0
=
2T power (n − 1) + 2
=
0
2T power (n)
0
+1
für n > 0
für n > 0
Lösen wir die Rekurrenzgleichungen auf, erhalten wir
0
T power (n)
=
Θ(n)
für n > 0.
Wir sehen: Wenn wir naiv rechnen, erhöht sich die Laufzeit und unter Umständen auch der Platzbedarf drastisch, nämlich exponentiell.
6. Abstraktion
6.1. Listenbeschreibungen
Listen sind die am häufigsten verwendete Datenstruktur. Aus diesem Grund lohnt es sich, weitere
Notationen einzuführen, die die Konstruktion und die Verarbeitung von Listen vereinfachen. So
erlaubt Haskell z.B. die Notation arithmetischer Folgen:
[1 ..] Liste der positiven Zahlen,
[1 .. 99] Liste der positiven Zahlen bis einschließlich 99,
[1, 3 ..] Liste der ungeraden, positiven Zahlen,
[1, 3 .. 99] Liste der ungeraden, positiven Zahlen bis einschließlich 99.
Die Listen sind jeweils vom Typ [Integer]. Die untere und die obere Grenze können durch beliebige
Ausdrücke beschrieben werden. Die Schrittweite ergibt sich als Differenz des ersten und zweiten
Folgenglieds.
Um Funktionen auf Listen zu definieren, haben wir bis dato rekursive Gleichungen verwendet.
Manchmal können listenverarbeitende Funktionen einfacher und lesbarer mit Listenbeschreibungen programmiert werden. Schauen wir uns ein paar Beispiele an: Die Liste der ersten hundert
Quadratzahlen erhält man mit
squares’’ :: [Integer]
squares’’ = [n*n | n <- [0..99]]
Abstrahieren wir von einer speziellen Funktion und einer speziellen Liste, erhalten wir die Funktion map:
map’’ f x = [f a | a <- x]
Somit ist squares = map (\n -> n * n) [0..99]. Listenbeschreibungen ermöglichen oft eine
erstaunlich kurze Formulierung von Programmen: Die vordefinierte Funktion a ‘elem‘ x überprüft,
ob a ein Element der Liste x ist. Definieren läßt sie sich wie folgt:
a ‘elem‘ x = or [a == b | b <- x]
Die vordefinierte Funktion or verallgemeinert die logische Disjunktion auf Listen Boolescher Werte.
Mit Hilfe einer Listenbeschreibung können auch Elemente einer Liste mit bestimmten Eigenschaften ausgewählt werden. Die Funktion divisors n bestimmt die Liste aller Teiler von n. Mit
ihrer Hilfe läßt sich gut beschreiben, wann eine Zahl eine Primzahl ist: n ist prim, wenn divisors n==[1,n].
Beachte: 1 ist keine Primzahl.
118
6. Abstraktion
divisors’ :: (Integral a) => a -> [a]
divisors’ n = [d | d <- [1..n], n ‘mod‘ d == 0]
primes’ :: (Integral a) => [a]
primes’ = [n | n <- [2..], divisors’ n == [1,n]]
Viel effizienter ist es allerdings, die Primzahlen mit dem „Sieb des Erathosthenes“ zu bestimmen.
sieve :: (Integral a) => [a] -> [a]
sieve (a:x) = a:sieve [n | n <- x, n ‘mod‘ a /= 0]
primes’’ :: (Integral a) => [a]
primes’’ = sieve [2..]
Ein bekanntes Sortierverfahren, Quicksort von C.A.R. Hoare, läßt sich gut mit Hilfe von Listenbeschreibungen definieren.
qsort’’’ :: (Ord a) => [a] -> [a]
qsort’’’ []
= []
qsort’’’ (a:x) = qsort’’’ [b | b <- x, b < a]
++ [a]
++ qsort’’’ [b | b <- x, b >= a]
Zur Übung: Überlege wie Quicksort arbeitet, beweise seine Korrektheit und analysiere die Effizienz.
Etwas Terminologie ist hilfreich, um über Listenbeschreibungen zu reden: Eine Listenbeschreibung
wird durch den senkrechten Strich in den Kopf und in den Rumpf unterteilt. Der Kopf enthält
einen beliebigen Ausdruck; der Rumpf besteht aus einer Folge von Generatoren und Filtern: Ein
Generator hat die Form p <- l, wobei p ein Muster und l ein listenwertiger Ausdruck ist; ein Filter
ist ein Boolescher Ausdruck. Enthält eine Listenbeschreibung mehrere Generatoren, werden diese
geschachtelt abgearbeitet.
[(a,b) | a <- [0,1], b <- [1..3]]
⇒
[(0,1),(0,2),(0,3),(1,1),(1,2),(1,3)]
Die Variable a wird zunächst mit 0 belegt, b nimmt nacheinander die Werte von 1 bis 3 an. Der
Ausdruck im Kopf wird jeweils bezüglich dieser Belegungen ausgerechnet. Man sieht: Weiter rechts
stehende Generatoren variieren schneller.
Genau wie ein let-Ausdruck führt auch ein Generator neue Variablen ein. Die Sichtbarkeit dieser
Variablen erstreckt sich auf den Kopf und auf weiter rechts stehende Generatoren und Filter. Die
Funktion concat, die eine Liste von Listen konkateniert, illustriert dies.
concat’ xs = [a | x <- xs, a <- x]
6.1. Listenbeschreibungen
119
Als abschließendes Beispiel lösen wir das 8-Damen-Problem: Es gilt 8 Damen auf einem Schachbrett
so zu plazieren, daß sie sich nach den Regeln des Schachs nicht bedrohen. Wir verallgemeinern das
Problem natürlich auf n: Es gilt alle gültigen Stellungen für ein n × n-Schachbrett zu bestimmen.
Zunächst müssen wir überlegen, wie wir eine Stellung repräsentieren. Da jede Spalte genau eine
Dame enthält, können wir z.B. die Liste der Zeilenpositionen verwenden.
4
3
2
1
Q
Q
Q
1
[2,4,1,3]
2
Q
3
4
Die Repräsentation legt nahe, das Brett spaltenweise von links nach rechts zu belegen. Sind wir in der
i-ten Spalte angelangt, wählen wir eine noch nicht verwendete Zeilenposition aus und überprüfen,
ob die Dame über eine Diagonale bedroht wird. Um diesen Test schnell durchführen zu können,
numerieren wir die auf- bzw. absteigenden Diagonalen wie folgt durch:
4
3
2
1
−3
−2
−1
0
1
−2
−1
0
1
2
−1
0
1
2
3
0
1
2
3
4
4
3
2
1
5
4
3
2
1
6
5
4
3
2
7
6
5
4
3
8
7
6
5
4
Die Nummer der aufsteigenden Diagonale ergibt sich als Differenz von Spalten- und Reihenposition,
die Nummer der absteigenden Diagonale entsprechend durch die Summe.
type Board = [Integer]
queens :: Integer -> [Board]
queens n = place 1 [1..n] [] []
Die Hilfsfunktion place erhält vier Argumente: die Nummer der aktuellen Spalte, die Liste der
noch nicht verwendeten Reihenpositionen und die Nummern der bereits belegten auf- und absteigenden Diagonalen.
place :: Integer -> [Integer] -> [Integer] -> [Integer] -> [Board]
place c [] ud dd = [[]]
place c rs ud dd = [q:qs | q <- rs,
let uq = q - c,
let dq = q + c,
uq ‘notElem‘ ud,
dq ‘notElem‘ dd,
qs <- place (c+1) (delete q rs) (uq:ud) (dq:dd)]
delete q (x:xs) | q == x
= xs
| otherwise = x:delete q xs
120
6. Abstraktion
Mit let {d1; ...; dn} werden lokale Definitionen innerhalb einer Listenbeschreibung eingeführt;
die Sichtbarkeit der Bezeichner erstreckt sich auf die weiter rechts stehenden Konstrukte und auf
den Kopf der Listenbeschreibung.
Die Beispiele haben gezeigt: Listenbeschreibungen sind eine feine Sache; hat man sie erst einmal
für die Programmierung entdeckt, möchte man sie nicht mehr missen. Kann man sich vorstellen,
wie die Funktion place ohne Listenbeschreibungen aussieht? Wir haben eine erkleckliche Anzahl
von Listenbeschreibungen verwendet, ohne daß wir uns darum gekümmert haben, was sie genau
bedeuten. Das spricht für Listenbeschreibungen. Für Zweifelsfälle ist es trotzdem gut, wenn man
die Bedeutung festlegt. Das wollen wir jetzt nachholen. Durch die folgenden vier Eigenschaften
werden Listenbeschreibungen vollständig beschrieben:
[ e | p <- l ]
=
map (\p -> e) l
[ e | b ]
=
if b then [e] else []
[ e | let ds ]
=
let ds in [e]
[ e | q1 , q2 ]
=
concat [ [ e | q2 ] | q1 ]
Die Gleichungen können von links nach rechts verwendet werden, um Listenbeschreibungen zu
eliminieren. Zur Übung: Entferne aus der Definition von place alle Listenbeschreibungen. Läßt
sich das Ergebnis weiter vereinfachen?
Listenbeschreibungen haben viel gemeinsam mit der Definition von Mengen mittels Angabe einer
charakterisierenden Eigenschaft. Die Notation ist ähnlich: Vergleiche etwa { n2 | n ∈ {0, . . . , 99} ∧
n mod 2 = 0 } mit [nˆ2 | n <- [0..99], n ‘mod‘ 2 == 0]. Auch die Bedeutung ist ähnlich: Vergleiche { (x, y) | x ∈ M ∧ y ∈ N } mit [(x,y) | x <m, y <- n]. Neben den Gemeinsamkeiten gibt es natürlich auch Unterschiede: Eine Listenbeschreibung bezeichnet eine Liste; Reihenfolge und Anzahl der Elemente spielen im Unterschied zur Menge
eine Rolle. Schauen wir uns ein Beispiel an:
rep’ n a = [a | i <- [1..n]]
Die Variable i wird nacheinander mit den Werten von 1 bis n belegt, jeweils bezüglich dieser
Belegung wird der Kopf der Listenbeschreibung ausgewertet. Daß i im Kopf nicht auftritt spielt keine
Rolle. Das Ergebnis ist die n-elementige Liste [a,...,a]. Dagegen macht die Mengenbeschreibung
{ a | i ∈ {1 . . . n} } wenig Sinn.
6.2. Funktionen höherer Ordnung
Dieser Abschnitt unterscheidet sich von den beiden vorhergehenden darin, daß wir keine neuen
Sprachkonstrukte einführen, sondern bereits Bekanntes aus einem neuen Blickwinkel betrachten.
Dabei lernen wir gleichzeitig eine neue Programmiertechnik kennen, die — eine gute Beherrschung
vorausgesetzt — uns viel Arbeit ersparen kann.
6.2. Funktionen höherer Ordnung
121
6.2.1. Funktionen als Parameter
In Abschnitt 6.3.1 werden wir sehen, daß die Sortierfunktionen aus Kapitel 4 Listen fast beliebiger
Typen sortieren können — ohne daß wir uns groß anstrengen müssen. Abhängig vom Typ der zu
sortierenden Liste wird automatisch die entsprechende Vergleichsfunktion verwendet. Keine Automatik ohne Nachteile, denn nicht immer leistet die Voreinstellung das Gewünschte. Betrachten
wir den Datentyp Person
data Person
= Person Sex FirstName LastName Date
deriving (Eq,Ord)
data Date
= Date Int Int Int deriving (Eq,Ord)
data Sex
= Female | Male
deriving (Eq,Ord)
type FirstName = String
type LastName = String
Da lexikographisch verglichen wird, werden Elemente vom Typ Person zuerst nach dem Geschlecht,
dann nach dem Vornamen, dem Nachnamen und dem Geburtsdatum angeordnet. Selbst wenn wir
die Vergleichsoperation selbst definieren könnten — in Abschnitt 6.3.2 zeigen wir wie —, bleiben
wir unflexibel: Wie können nicht einmal aufsteigend nach dem Nachnamen und ein anderes Mal
absteigend nach dem Alter zu sortieren.
Die Lösung für dieses Problem ist einfach: Wir machen die Ordnungsrelation (<=) zum Parameter
der Sortierfunktionen; dann können wir für jede Anwendung eine maßgeschneiderte Ordnungsrelation angeben. Hier ist die Verallgemeinerung von isort :
isortBy :: (a -> a -> Bool) -> [a] -> [a]
isortBy (<=) = isort
where
isort []
= []
isort (a:x)
= insert a (isort x)
insert a []
= [a]
insert a x@(b:y)
| a <= b
= a:x
| otherwise = b:insert a y
Die ursprüngliche Funktion isort erhalten wir, wenn wir für den Parameter wieder die vordefinierte
Ordnungsrelation einsetzen: isortBy (<=); mit isortBy (>=) sortieren wir absteigend. Listen von
Personen lassen sich nach vielen unterschiedlichen Kriterien sortieren:
sortByLastName = isortBy (\p q -> components p <= components q)
where components (Person s f l d) = (l,f,d)
sortByDateOfBirth = isortBy (\p q -> dateOfBirth p <= dateOfBirth q)
where dateOfBirth (Person _ _ _ d) = d
Harry Hacker: Was heißt hier maßgeschneiderte Ordnungsrelation? Es wird ja doch wieder nach (<=) sortiert.
Lisa Lista: Aber das (<=) steht hier auf der linken Seite und
ist nur ein Parameter, der bei einem Aufruf von isortby an ein
Sortierkriterium gebunden wird. Zum Beispiel an (>=).
Harry Hacker: Okay, schon gut. Aber laß das Grinsen!
Übung 6.1 Mit isortBy lassen sich Listen beliebigen Typs
sortieren, auch Listen auf Funktionen. Warum muß a nicht Instanz von Ord sein?
122
Übung 6.2 Definiere mergeBy und msortBy.
6. Abstraktion
Die Definition der Funktion sortByDateOfBirth wirft eine interessante Frage auf: Wie werden
zwei Personen mit dem gleichen Geburtsdatum angeordnet? Sortieren durch Einfügen verhält sich
erfreulich: Die relative Anordnung von Personen mit dem gleichen Geburtsdatum wird nicht verändert; sind p und q zwei Personen, die am gleichen Tag geboren sind, so gilt:
sortByDateOfBirth [..., p, ..., q, ...]
=
[..., p, ..., q, ...].
Sind die Personen bereits nach dem Nachnamen sortiert, so bleibt diese Anordnung je Geburtsdatum
erhalten.
Es lohnt sich, das Phänomen noch einmal genauer zu betrachten. Bisher sind wir davon ausgegangen, daß „6“ eine totale Ordnung definiert; insbesondere muß „6“ antisymmetrisch sein:
a 6 b ∧ b 6 a ⇒ a = b. Lassen wir — wie oben — diese Eigenschaft fallen, haben wir nur noch
eine Präordnung vor uns. Die Sortieralgorithmen „funktionieren weiterhin“, nur — die Spezifikation aus Kapitel 4 ist unter diesen abgeschwächten Voraussetzungen nicht mehr eindeutig, da es
mehrere Permutationen einer Liste geben kann, die aufsteigend geordnet sind: Die Reihenfolge von
Elementen a und b mit a 6 b ∧ b 6 a ist eben nicht festgelegt. Verändert ein Sortierlagorithmus die Reihenfolge dieser Elemente nicht, so heißt er stabil. Wir haben gesehen, daß Stabilität
eine wünschenswerte Eigenschaft ist. Von der bisher eingeführten Sortieralgorithmen ist nur smsort
nicht stabil. Übung: smsort läßt sich durch eine kleine Änderung in der Hilfsfunktion descRun
stabilisieren. Durch welche?
Wir wollen die Verwendung von Funktionen als Parametern noch an einem weiteren Beispiel
studieren, der binären Suche. Die Funktion binarySearch aus Abschnitt 5.6 sucht ein Element in
einem geordneten Feld. In Analogie zu den Sortierfunktionen könnten wir die zugrundeliegende
Vergleichsoperation zum Parameter machen. Aber es geht noch sehr viel allgemeiner: Betrachten
wir die Funktionsdefinition von binarySearch genauer: Die Vergleichsoperation compare wird nur
in dem Ausdruck compare e (a!m) verwendet; von Aufruf zu Aufruf ändert sich jedoch lediglich m.
Von daher liegt es nahe, die Binäre Suche mit einer Funktion zu parametrisieren, die m direkt auf ein
Element von Ordering abbildet. Daß wir in einem Feld suchen, tritt nunmehr in den Hintergrund.
Indem wir das Suchintervall übergeben, beseitigen wir die Abhängigkeit ganz. Noch eine letzte
Änderung: Sind wir bei der Suche erfolgreich, geben wir m in der Form Just m zurück; anderenfalls
wird Nothing zurückgegeben .
binarySearchBy :: (Integral a) => (a -> Ordering) -> (a,a) -> Maybe a
binarySearchBy cmp = within
where within (l,r) = if l > r then Nothing
else let m = (l+r) ‘div‘ 2
in case cmp m of
LT -> within (l, m-1)
EQ -> Just m
GT -> within (m+1, r)
Die Funktion binarySearchBy implementiert das folgende „Spiel“: Der Rechner fordert die Benutzerin auf, sich eine Zahl zwischen l und r auszudenken. Der Rechner rät: „Ist die gesuchte Zahl
6.2. Funktionen höherer Ordnung
123
m?“. Die Benutzerin sagt, ob die Zahl gefunden wurde, ob sie kleiner oder größer ist. Nach Θ(log(r −
l)) Schritten weiß der Rechner die Lösung — falls bei den Antworten nicht gemogelt wurde. Im Programm entspricht die Funktion cmp der Benutzerin: Ist x die ausgedachte Zahl, so beschreibt cmp =
\i -> compare x i ihr Verhalten. Liegt x im Intervall (l,r), so ergibt binarySearchBy cmp (l,r)
gerade Just x.
Was heißt es eigentlich, daß bei den Antworten gemogelt wird? Anders gefragt: Welche Anforderungen muß cmp erfüllen, damit die Binäre Suche vernünftig arbeitet? Nun — wir erwarten,
daß es höchstens ein Element k gibt mit cmp k = EQ, für alle i < k muß cmp i = GT gelten, für alle
j > k entsprechend cmp j = LT. Die Funktion cmp unterteilt (l, r) somit maximal in drei Bereiche:
l . . . k − 1 |{z}
k k + 1...r
| {z }
GT
EQ
|
{z
LT
}
Umfaßt der mittlere Bereich mehr als ein Element, funktioniert die Binäre Suche auch. In diesem
Fall wird irgendein Element aus dem mittleren Drittel zurückgegeben. Formal muß cmp lediglich
antimonoton sein: i 6 j ⇒ cmp i > cmp j mit LT < EQ < GT. Aufgabe: Verallgemeinere die Binäre
Suche, so daß sie gerade das mittlere Drittel als Intervall bestimmt.
Wie immer, wenn man eine Funktion verallgemeinert, ist es interessant zu sehen, wie man die
ursprüngliche Funktion durch Spezialisierung der Verallgemeinerung erhält:
binSearch a e = case r of Nothing -> False; Just _ -> True
where r = binarySearchBy (\i -> compare e (a!i)) (bounds a)
Als weitere Anwendung stellen wir uns die Aufgabe, zu einem englischen Begriff die deutsche
Übersetzung herauszusuchen. Das der Suche zugrundeliegende und aus Platzgründen sehr klein
geratene Wörterbuch stellen wir als Feld dar:
englishGerman :: Array Int (String, String)
englishGerman = listArray (1,2) [("daffodil", "Narzisse"),
("dandelion","Loewenzahn")]
Das Englisch-Deutsch-Wörterbuch ist naturgemäß nach den englischen Wörtern geordnet; es
taugt nicht als Deutsch-Englisch-Wörterbuch. Übung: Programmiere eine Funktion, die ein X-YWörterbuch in ein Y-X-Wörterbuch überführt. Die Funktion german realisiert die Übersetzung:
german :: [Char] -> Maybe [Char]
german x = case r of Nothing -> Nothing
Just m -> Just (snd (englishGerman!m))
where r = binarySearchBy (\i -> compare x (fst (englishGerman!i)))
(bounds englishGerman)
Der zu übersetzende Begriff wird jeweils mit dem englischen Wort verglichen, bei erfolgreicher
Suche wird das deutsche Wort zurückgegeben.
Wenn nur die linke Grenze des Suchintervalls gegeben ist, können wir die Binären Suche leicht
variieren: Zunächst wird die rechte Intervallgrenze bestimmt, also ein beliebiges Element r mit
cmp r = LT. Als Kandidaten werden nacheinander l, 2l, 4l, . . . herangezogen; der Suchraum wird
somit in jedem Schritt verdoppelt. Ist ein r gefunden, folgt eine normale Binäre Suche.
124
6. Abstraktion
openbinSearchBy :: (Integral a) => (a -> Ordering) -> a -> Maybe a
openbinSearchBy cmp l = lessThan l
where lessThan r = case cmp r of
LT -> binarySearchBy cmp (l,r)
EQ -> Just r
GT -> lessThan (2*r)
Die Suche im offenen Intervall terminiert natürlich nicht, wenn es kein r mit cmp r = LT gibt.
6.2.2. Rekursionsschemata
In Abschnitt 4.2 haben wir das Schema der strukturellen Rekursion auf Listen eingeführt. Der
Motor des Schemas ist der Rekursionsschritt. Zitat: „Um das Problem für die Liste a:x zu lösen,
wird [. . . ] zunächst eine Lösung für x bestimmt, die anschließend zu einer Lösung für a:x erweitert
wird.“ Überlegen wir noch einmal genauer, was im Rekursionsschritt zu tun ist: Gegeben ist eine
Lösung s, unter Verwendung von a und x gilt es, eine Gesamtlösung s’ zu konstruieren. Im Prinzip
müssen wir eine Funktion angeben, die a, x und s auf s’ abbildet. Dies erkannt, kann man das
Rekursionsschema mit Hilfe einer Funktion höherer Ordnung programmieren; aus dem Kochrezept
wird ein Programm:
structuralRecursionOnLists :: solution
-> (a -> [a] -> solution -> solution)
-> [a] -> solution
structuralRecursionOnLists base extend = rec
where rec []
= base
rec (a:x) = extend a x (rec x)
-- base
-- extend
Auf diese Weise läßt sich das Rekursionsschema formal definieren; wenn Zweifel bestehen, ob
eine gegebene Funktion strukturell rekursiv ist, können diese ausgeräumt werden, indem man die
Funktion in Termini von sROL formuliert oder zeigt — was ungleich schwerer ist —, daß dies nicht
möglich ist.
Obwohl denkbar wird nicht empfohlen, sROL bei der Programmierung zu verwenden — im nächsten Abschnitt lernen wir eine abgemagerte Variante kennen, die im Programmieralltag ihren festen
Platz hat. Dessen ungeachtet wollen wir uns trotzdem anschauen, wie die Funktionen isort und
insert — die ersten, die wir nach dem Kochrezept der Strukturellen Rekursion gebraut haben —
mit Hilfe von sROL formuliert werden.
isort’ :: (Ord a) => [a] -> [a]
isort’ = structuralRecursionOnLists
[]
(\a _ s -> insert’’ a s)
insert’’ :: (Ord a) => a -> [a] -> [a]
insert’’ a = structuralRecursionOnLists
6.2. Funktionen höherer Ordnung
125
[a]
(\b x s -> if a <= b then a:b:x else b:s)
Die Verwendung des Rekursionsschemas macht die Struktur der Funktionen deutlich; Rekursionsbasis und Rekursionsschritt werden explizit benannt. Bei der Interpretation des Rekursionsschritts
muß man sich vergegenwärtigen, daß s in \a x s -> ... jeweils die Lösung von x darstellt: In der
Definition von isort ist s gleich isort x, in der Definition von insert ist s gleich insert a x.
Bei der letztgenannten Funktion nutzen wir aus, daß die Funktion gestaffelt ist: nicht insert wird
strukturell rekursiv definiert, sondern insert a. Knobelaufgabe: Wie ändert sich die Definition,
wenn man die Parameter von insert vertauscht?
Programmiert man tatsächlich Funktionen mit Hilfe von sROL, wird man gezwungen, sich genau
an das Rekursionsschema zu halten. Insbesondere wenn man mit der Strukturellen Rekursion noch
nicht per Du ist, erweist sich dieses Diktat oftmals als lehrreich. Bewahrt es uns doch insbesondere
davor, ad-hoc Lösungen zu formulieren.
Nehmen wir die Gelegenheit wahr und schauen uns noch ein Beispiel zur Strukturellen Rekursion
an: Es gilt die Liste aller Permutationen einer gegeben Liste zu bestimmen. Die Liste [2,6,7]
z.B. läßt sich auf sechs verschiedene Arten anordnen. Strukturell rekursiv erhält man sie wie
folgt: Wir dürfen annehmen, daß wir das Problem für die Restliste [6,7] bereits gelöst haben:
[[6,7], [7,6]] ist die gesuchte Liste der Permutationen. Jede einzelne Permutation müssen wir
um das Element 2 erweitern. Aber — an welcher Position fügen wir es ein? Aus der Aufgabenstellung
leitet sich ab, daß alle Positionen zu berücksichtigen sind. Also, aus der Liste [6,7] werden [2,6,7],
[6,2,7] und [6,7,2] und aus [7,6] erhalten wir [2,7,6], [7,2,6] und [7,6,2]. Das Einfügen
an allen Positionen „riecht“ nach einer Teilaufgabe, die wir wiederum strukturell rekursiv lösen.
Überlegen! Das Aufsammeln der Teillösungen erledigen wir mit Listenbeschreibungen. Schließlich
müssen wir uns noch Gedanken über die Rekursionsbasis machen: Es gibt nur eine Permutation der
leeren Liste, die leere Liste selbst.
perms :: [a] -> [[a]]
perms []
= [[]]
perms (a:x) = [z | y <- perms x, z <- insertions a y]
insertions :: a -> [a] -> [[a]]
insertions a []
= [[a]]
insertions a x@(b:y) = (a:x):[b:z | z <- insertions a y]
Man sieht: Programme lassen sich sehr systematisch entwickeln. Trotz der Systematik ist Kreativität gefragt: Man muß lernen, Teilprobleme zu identifizieren, die man besser separat löst.Mit zunehmender
Programmiererfahrung wird man Teilprobleme auf bereits Bekanntes und Erprobtes zurückführenünd
„Das Rad nicht immer neu erfinden“.
Kurzer Themenwechsel: Mit Hilfe von perms können wir eine andere Spezifikation für das Sortierproblem formulieren. Die Funktion sort x ermittelt die erste Permutation von x, die geordnet ist.
sort :: (Ord a) => [a] -> [a]
sort x = head [s | s <- perms x, ordered s]
Übung 6.3 Schreibe perms und insertions als Anwendungen
des Rekursionsschemas um.
126
Übung 6.4 Diskutiere Vor- und Nachteile beider Spezifikationen.
6. Abstraktion
Im Gegensatz zur Spezifikation aus Abschnitt 4.1 ist diese Spezifikation ein legales Haskell-Programm.
Allerdings wird dringend davon abgeraten, es auszuführen. Warum?
Auch das Teile- und Herrsche-Prinzip läßt sich mit Hilfe einer Funktion höherer Ordnung definieren.
Vier Bausteine werden benötigt: Eine Funktion, die entscheidet, ob ein Problem einfach ist; eine
Funktion, die ein einfaches Problem löst; eine Funktion, die ein Problem in Teilprobleme zergliedert
und schließlich eine Funktion, die Teillösungen zu einer Gesamtlösung zusammenführt.
divideAndConquer :: (problem -> Bool)
--> (problem -> solution)
--> (problem -> [problem])
--> ([solution] -> solution)
--> problem -> solution
divideAndConquer easy solve divide conquer = rec
where rec x = if easy x then solve x
else conquer [rec y | y
easy
solve
divide
conquer
<- divide x]
Anhand der Typangabe sieht man, daß dAC im Gegensatz zu sROL unabhängig von einem bestimmten Datentyp ist. Zur Illustration des Rekursionsschemas greifen wir nichtsdestotrotz auf
Bekanntes zurück. Bis dato haben wir zwei Sortierverfahren (mindestens zwei) kennengelernt, die
nach dem Teile- und Herrsche-Prinzip arbeiten: Sortieren durch Fusionieren und Quicksort. Beim
erstgenannten Verfahren steckt die Hauptarbeit — nämlich das Vergleichen von Elementen — in
der Herrsche-Phase, die dem Verfahren auch seinen Namen gibt.
msort’’ :: (Ord a) => [a] -> [a]
msort’’ = divideAndConquer
(\x -> drop 1 x == [])
(\x -> x)
(\x -> let k = length x ‘div‘ 2 in [take k x, drop k x])
(\[s,t] -> merge s t)
Im Fall Quicksort ist es umgekehrt: Nur in der Teile-Phase werden Elemente verglichen; in der
Herrsche-Phase werden die sortierten Teillisten lediglich aneinandergefügt.
qsort :: (Ord a) => [a] -> [a]
qsort = divideAndConquer
(\x -> drop 1 x == [])
(\x -> x)
(\(a:x) -> [[ b | b <- x, b < a],[a],[b | b <- x, b >= a]])
(\[x,y,z] -> x++y++z)
6.2.3. foldr und Kolleginnen
Magert man die strukturelle Rekursion auf Listen etwas ab, erhält man ein Rekursionsschema,
das im Programmieralltag seinen festen Platz hat. Schauen wir uns zunächst einmal zwei einfache
strukturell rekursive Funktionen an.
6.2. Funktionen höherer Ordnung
127
sum’’ :: (Num a) => [a] -> a
sum’’ []
= 0
sum’’ (a:x) = a + sum’’ x
or’’ :: [Bool] -> Bool
or’’ []
= False
or’’ (a:x) = a || or’’ x
Inwiefern sind sum und or besonders einfach? Nun, im Rekursionsschritt wird nur das Kopfelement
a mit der Teillösung s kombiniert; der Listenrest x wird nicht benötigt. Diese Eigenschaft teilen die
Funktionen mit isort, nicht aber mit insert. Vergleichen! Das abgemagerte Rekursionsschema
nennt sich foldr. Wie der Name motiviert ist, erklären wir gleich, zunächst die Definition:
foldr’ :: (a -> solution -> solution) -> solution -> [a] -> solution
foldr’ (*) e = f
where f []
= e
f (a:x) = a * f x
Man sieht: Die Vereinfachung besteht darin, daß wir den dreistelligen Parameter extend durch den
zweistelligen Parameter (*) ersetzt haben, den wir aus Gründen der Übersichtlichkeit zusätzlich infix
notieren. [Aus historischen Gründen wird die Induktionsbasis erst als zweites Argument angegeben.]
Die Definitionen von sum und or werden zu Einzeilern, wenn foldr verwendet wird.
sum’
product’
and’
or’
=
=
=
=
foldr
foldr
foldr
foldr
(+) 0
(*) 1
(&&) True
(||) False
Die Arbeitsweise von foldr kann man sich einfach merken: foldr ersetzt den Listenkonstruktor
(:) durch (*) und die leere Liste [] durch e; aus der Liste a1 :(a2 :· · ·:(an−1
:(an :[]))· · ·) wird der Ausdruck a1 *(a2 *· · ·:(an−1 *(an :e))· · ·). Man sagt auch, die Liste wird
aufgefaltet. Der Buchstabe r im Namen deutet dabei an, daß der Ausdruck wie auch die ursprüngliche Liste rechtsassoziativ geklammert wird. Eine Kollegin von foldr, deren Namen mit
l endet, lernen wir in Kürze kennen. Schauen wir uns vorher noch ein paar Beispiele an:
isort
length
x ++ y
reverse
concat
=
=
=
=
=
foldr
foldr
foldr
foldr
foldr
insert []
(\n _ -> n + 1) 0
(:) y x
(\a x -> x ++ [a]) []
(++) []
Um es noch einmal zu betonen: Das erste Argument von foldr definiert den Rekursionsschritt, das
zweite Argument die Rekursionsbasis. Knobelaufgabe: Was macht die folgende Funktion:
mystery x = foldr (\a -> foldr (<->) [a]) [] x
where a <-> (b:x) = if a <= b then a:b:x else b:a:x
Übung 6.5 Definiere die Funktionen product und and, die eine
Liste ausmultiplizieren bzw. ver„und“en.
128
6. Abstraktion
Kommen wir zur Kollegin von foldr: Die Funktion foldl überführt die Liste a1 :(a2 :
· · ·:(an−1 :(an :[]))· · ·) in den Ausdruck (· · ·((e*a1 )*a2 )*· · ·*an−1 )*an ; im Unterschied zu foldr
wird der Ausdruck linksassoziativ geklammert und fängt mit e an. Die graphische Darstellung der
Ausdrücke verdeutlicht die Unterschiede:
*
/ \
a1 *
/ \
foldr (*) e
a2 .. <-----------\
*
/ \
an e
:
/ \
a1
:
/ \
a2 ..
*
/ \
*
an
foldl (*) e
------------>
\
:
/ \
an []
/
..
/
*
/ \
e
a1
Zwischen foldr und foldl gibt es eine Reihe von Beziehungen. Eine kann man direkt aus der obigen
Grafik ablesen: Der rechte Baum läßt sich in den linken überführen, indem man ihn erst spiegelt —
in jedem Knoten werden linker und rechter Teilbaum vertauscht – und anschließend die Reihenfolge
der Blätter umgekehrt. Formal gilt:
foldr (*) e (reverse x)
=
foldl (flip (*)) e x,
mit flip (*) = \a b -> b * a. Aus dieser Eigenschaft und unter Verwendung der Definition von
reverse aus Abschnitt 5.5.2 läßt sich die folgende Definition von foldl ableiten.
foldl’’ :: (solution -> a -> solution) -> solution -> [a] -> solution
foldl’’ (*) e x = f x e
where f []
e = e
f (a:x) e = f x (e*a)
Die Hilfsfunktion f korrespondiert dabei mit der Hilfsfunktion revTo. Einziger Unterschied: der Listenkonstruktor (:) wird sogleich durch flip (*) ersetzt. Spezialisieren wir foldl wieder, erhalten
wir eine effiziente Definition von reverse.
reverse’’’’ = foldl (flip (:)) []
Kommen wir zu weiteren Anwendungen: sum, or, concat etc. können auch via foldl definiert
werden:
sum’’’
= foldl (+) 0
or’’’
= foldl (||) False
concat’’’ = foldl (++) []
Daß die Definitionen äquivalent zu den ursprünglichen sind, liegt daran, daß (+) und 0, (&&) und
False, (++) und [] jeweils ein Monoid formen: Die Operation ist assoziativ und das Element ist
6.2. Funktionen höherer Ordnung
129
neutral bezüglich der Operation. Natürlich stellt sich die Frage, welcher Definition der Vorzug zu
geben ist. Also, wie ist es jeweils um den Speicher- und Zeitbedarf bestellt? Vergleichen wir folgende
Rechnungen:
⇒
⇒
⇒
⇒
⇒
⇒
sum’’ [1..9]
f [1..9]
1 + f [2..9]
1 + (2 + f [3..9])
...
1 + (2 + ... + (9 + f []))
1 + (2 + ... + (9 + 0))
45
⇒
⇒
⇒
⇒
⇒
sum’’’ [1..9]
f’ [1..9] 0
f’ [2..9] 1
f’ [3..9] 3
...
f’ [] 45
45
Die Anzahl der Schritte ist gleich, aber der Platzbedarf ist unterschiedlich. Wird foldr verwendet,
entsteht ein arithmetischer Ausdruck, der proportional zur Länge der Liste wächst. Erst im letzten
Schritt kann der Ausdruck ausgerechnet werden. Wird sum mit foldl definiert, ist der Platzbedarf
hingegen konstant, da die arithmetischen Ausdrücke in jedem Schritt vereinfacht werden können.1
Im Fall von or erweist sich die Definition mit foldr als besser:
or’’’ [False,True,False]
⇒ f’ [False,True,False] False
⇒ f’ [True,False] (False || False)
⇒ f’ [True,False] False
⇒ f’ [False] (True || False)
⇒ f’ [False] True
⇒ f’ [] (False || True)
⇒ f’ [] True
⇒ True
Im Unterschied zur Addition läßt sich die logische Disjunktion auch ausrechnen, wenn lediglich
der erste Parameter bekannt ist. Ist dieser gleich True wird der zweite Parameter zudem gar nicht
benötigt. Aus diesem Grund wird die Liste Boolescher Werte nur bis zum ersten True durchlaufen.
Auch im Fall von concat ist foldr der Gewinner — aus den gleichen Gründen, aus denen (++)
als rechtsassoziativ vereinbart wird. Nicht immer ist die leere Liste eine geeignete Induktionsbasis:
Nehmen wir an, wir wollen das kleinste Element einer Liste bestimmen. Was ist das kleinste Element
der leeren Liste? Als Induktionsbasis wird hier sinnvollerweise die einelementige Liste gewählt. Die
Funktionen foldr1 und foldl1 realisieren das entsprechende Rekursionsschema. Wir geben nur die
Typen an und überlassen die Definition der geneigten Leserin.
⇒
⇒
⇒
⇒
⇒
or’’ [False,True,False]
f [False,True,False]
False || f [True,False]
f [True,False]
True || f [False]
True
foldr1 :: (a -> a -> a) -> [a] -> a
foldl1 :: (a -> a -> a) -> [a] -> a
Wie foldr erzeugt auch foldr1 einen rechtsentarteten „Ausdrucksbaum“. Nur — die Blätter
bestehen ausschließlich aus Listenelementen, aus diesem Grund ist der Typ auch etwas spezieller.
1 Tatsächlich
muß man bei der Auswertung der Teilausdrücke etwas nachhelfen.
130
6. Abstraktion
Einen „richtigen“ rechts- oder linksentarteten Baum können wir erzeugen, indem wir als Operator
den Konstruktor Br angeben. Da binäre Bäume — so wie wir sie definiert haben — nicht leer sind,
müssen wir als Induktionsbasis die einelementige Liste verwenden.
righty, lefty :: [a] -> Tree a
righty = foldr1 Br . map Leaf
lefty = foldl1 Br . map Leaf
Verschiedene Probleme, verschiedene Lösungen: Wir haben gesehen, daß je nach Aufgabenstellung
Anwendungen von foldr und foldl sich stark im Zeit- und Platzbedarf unterscheiden. Manchmal
ist foldr die gute Wahl, manchmal ihre Kollegin foldl. Natürlich gibt es auch Fälle, in denen foldr
und foldl gleichermaßen ungeeignet sind: Betrachten wir z.B. die Aufgabe, eine Liste von Läufen
zu fusionieren. Ein verwandtes Problem haben wir bereits einer ausführlichen Analyse unterzogen:
Man erinnere sich an die Funktion mergeTree aus Abschnitt 5.2, die einen Baum in eine sortierte
Liste überführt. Die Analyse ergab, daß mergeTree ausgeglichene Bäume bevorzugt. Damit ist klar,
daß weder foldr merge [] noch foldl merge [] gute Lösungen für das obige Problem darstellen.
Es liegt nahe, foldr und foldl ein weiteres Rekursionsschema foldm an die Seite zu stellen, das
einen ausgeglichenen Ausdrucksbaum erzeugt: aus der Liste a1 :(a2 :· · ·:(an−1 :(an :[]))· · ·) wird
der Ausdruck
(· · ·(a1 * a2 )· · · * · · ·(ai−1 * ai )· · ·) * (· · ·(ai+1 * ai+2 )· · · * · · ·(an−1 * n)· · ·)
mit i = bn/2c. Der Ausdrucksbaum verdeutlicht die Struktur besser:
:
*
/ \
/
\
a1
:
*
*
/ \
foldm (*) e
/
\
/
\
a2 .. ------------>
..
..
..
..
\
/
\
/
\
:
*
* ... *
*
/ \
/ \
/ \
/ \
/ \
an []
a1 a2 ai-1 ai ai+1 ai+2 an-1 an
Die Implementierung von foldm ist aufwendiger als die ihrer Kolleginnen. Das ist auch klar, da
wir sozusagen „gegen“ die rechtsassoziative Listenstruktur arbeiten. Allerdings müssen wir das Rad
nicht neu erfinden, denn — etwas ähnliches haben wir schon programmiert: die Funktion build aus
Abschnitt 3.4 überführt eine Liste in einen ausgeglichenen Baum. Ersetzen wir in der Definition von
build den Konstruktor Br durch den Operator (*), erhalten wir im wesentlichen die Definition von
foldm:
foldm :: (a
foldm (*) e
foldm (*) e
where f
-> a -> a) -> a -> [a] -> a
[] = e
x = fst (f (length x) x)
n x = if n == 1 then (head x, tail x)
6.2. Funktionen höherer Ordnung
131
else let m
= n ‘div‘ 2
(a,y) = f m
x
(b,z) = f (n-m) y
in (a*b,z)
foldm1 (*) = foldm (*) (error "foldm1 []")
Mit Hilfe von foldm läßt sich die anfangs gestellte Aufgabe effizient lösen. Die Funktion balanced
entspricht gerade der Funktion build.
mergeList :: (Ord a) => [[a]] -> [a]
mergeList = foldm merge []
balanced :: [a] -> Tree a
balanced = foldm1 Br . map Leaf
Mit der Funktion mergeList sind wir schon fast wieder beim Sortierproblem angelangt: Die
top-down Variante von msort und ihre „geschmeidige“ Verbesserung smsort lassen sich mit foldm
erschreckend (?) kurz formulieren.
msortBy, smsortBy :: (a -> a -> Bool) -> [a] -> [a]
msortBy (<=) = foldm (mergeBy (<=)) [] . map (\a -> [a])
smsortBy (<=) = foldm (mergeBy (<=)) [] . runsBy (<=)
Wir haben die Gelegenheit wahrgenommen und beide mit der Vergleichsoperation parametrisiert.
Übung: Definiere die Funktion runsBy.
Nachdem wir uns nun einige Seiten lang fast ausschließlich mit Listen beschäftigt haben, ist
es höchste Zeit, sich darauf zu besinnen, daß es noch andere Datentypen gibt. Denn — auf jeder
Datenstruktur läßt sich ein fold-Funktional definieren und dies mit Gewinn. Die Verallgemeinerung
ist nicht schwierig: foldr ersetzt [] durch eine Konstante und (:) durch eine zweistellige Funktion.
Das fold-Funktional auf Tree ersetzt entsprechend Leaf durch eine einstellige Funktion und Br
durch eine zweistellige.
foldTree :: (a -> b) -> (b -> b -> b) -> Tree a -> b
foldTree leaf br = f
where f (Leaf a) = leaf a
f (Br l r) = br (f l) (f r)
Viele Funktionen auf Bäumen lassen sich kurz und knapp mit foldTree definieren.
size’
depth’
mergeTree’
mergeRuns’
=
=
=
=
foldTree
foldTree
foldTree
foldTree
(\a -> 1) (+)
(\a -> 0) (\m n -> max m n + 1)
(\a -> [a]) merge
id merge
Übung 6.6 Definiere mit Hilfe von foldTree die Funktion
branches :: Tree a -> Int, die die Anzahl der Verzeigungen
in einem Baum bestimmt.
Übung 6.7 Wie läßt sich foldm auf foldTree und build zurückführen? Welcher Zusammenhang besteht zwischen struktureller
Rekursion auf Bäumen und dem Rekursionsschema foldTree?
132
6. Abstraktion
6.2.4. map und Kolleginnen
Die Funktion map, die eine Funktion auf alle Elemente einer Liste anwendet, haben wir schon
kennengelernt. Hier ist noch einmal ihre Definition:
map :: (a -> b) -> [a] -> [b]
map f []
= []
map f (a:x) = f a:map f x
Ein ideales Anwendungsgebiet für map und ihre Kolleginnen sind Operationen auf Vektoren und
Matrizen. Einen Vektor stellen wir einfach durch eine Liste dar — Felder wären eine Alternative,
die wir aber nicht verfolgen, da wir auf die Vektorelemente nie direkt zugreifen müssen. Eine Matrix
repräsentieren wir durch eine Liste von Zeilenvektoren.
type Vector a = [a]
type Matrix a = [Vector a]
Die Matrix
3
2
8
17
0
1
wird z.B. durch den Ausdruck
[[3,8,0],[2,17,1]]
dargestellt. Als erste Operation realisieren wir die Multiplikation eines Skalars mit einem Vektor
(für die Notation der Operationen erfinden wir eine Reihe von Operatoren).
(<*>):: (Num a) => a -> Vector a -> Vector a
k <*> x = map (\a -> k*a) x
Um die Summe und das innere Produkt zweier Vektoren zu programmieren, erweist sich eine
Kollegin von map als hilfreich, die eine zweistellige, gestaffelte Funktion auf zwei Listen anwendet.
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
zipWith f (a:x) (b:y) = f a b:zipWith f x y
zipWith _ _
_
= []
Die Funktion heißt auf englisch Reißverschluß, da f wiederholt jeweils auf die beiden ersten Listenelemente angewendet wird. Beachte: Die Argumentlisten müssen nicht die gleiche Länge haben;
die kürzere bestimmt die Länge der Ergebnisliste. Summe und inneres Produkt zweier Vektoren
sind schnell definiert.
(<+>) :: (Num a) => Vector a -> Vector a -> Vector a
x <+> y = zipWith (+) x y
(<.>) :: (Num a) => Vector a -> Vector a -> a
x <.> y = sum (zipWith (*) x y)
6.2. Funktionen höherer Ordnung
133
Ein nützlicher Spezialfall von zipWith ist zip: die zweistellige Operation ist hier der Paarkonstruktor.
zip :: [a] -> [b] -> [(a,b)]
zip = zipWith (\a b -> (a,b))
Die Funktion zip wird oft in Verbindung mit Listenbeschreibungen verwendet. Hier sind noch einmal
alle Vektoroperationen unter Verwendung von Listenbeschreibungen definiert.
k <*> x = [k*a | a <- x]
x <+> y = [a+b | (a,b) <- zip x y]
x <.> y = sum [a*b | (a,b) <- zip x y]
Kommen wir zur Addition und Multiplikation von Matrizen. Die Addition ist einfach:
(<++>) :: (Num a) => Matrix a -> Matrix a -> Matrix a
a <++> b = zipWith (<+>) a b
Das Produkt zweier Matrizen A und B erhalten wir, indem wir jede Zeile von A mit jeder Spalte
von B multiplizieren. Genauer: Ist x ein Zeilenvektor von A, so ist der entsprechende Zeilenvektor
der Produktmatrix gleich [ x <.> y | y <- transpose b ]. Die Funktion transpose überführt
dabei eine Liste von Zeilenvektoren in eine Liste von Spaltenvektoren. [Interpretieren wir die Zeilen
als Spalten, erhalten wir gerade die Transponierte der ursprünglichen Matrix; daher der Name der
Funktion.]
(<**>) :: (Num a) => Matrix a -> Matrix a -> Matrix a
m <**> n = [[x <.> y | y <- transpose’ n] | x <- m]
Bei der Definition von transpose können wir noch einmal das Prinzip der strukturellen Induktion
einüben. Fangen wir mit dem Induktionsschritt an: Gegeben ist die erste Zeile x und die aus den
restlichen Zeilen gebildeten Spaltenvektoren ys.
a11
a21
a31
..
.
am1
a12
a22
a32
..
.
am2
···
···
a1n
a2n
a3n
..
.
amn
a11
a21
a31
..
.
am1
a12
a22
a32
..
.
am2
···
a1n
a2n
a3n
..
.
amn
Jeder Spaltenvektor yi muß um das entsprechende Element ai der ersten Zeile zu ai :yi erweitert werden. Dies erledigt der Aufruf zipWith (:) x ys. Bei der Induktionsbasis mogeln wir etwas: Im Prinzip ist die leere Liste durch eine Liste von n leeren Listen zu ersetzen. Wir geben
stattdessen eine potentiell unendliche Liste von leeren Listen an und nutzen aus, daß der erste Aufruf
von zipWith nur die benötigte Anzahl verwendet: zipWith (:) [am1 ,...,amn ] (repeat []) ⇒
[am1 :[],...,amn :[]].
134
6. Abstraktion
transpose’ :: Matrix a -> Matrix a
transpose’ = foldr (zipWith (:)) (repeat [])
Die Mogelei hat tatsächlich auch einen Vorteil: transpose kann auch mit Teillisten arbeiten,
die unendlich lang sind. Dieser Fall tritt bei den hier betrachteten Matrizenoperationen nicht auf;
in anderen Anwendungen aber sehr wohl. Die Listen könnten z.B. Meßreihen sein; mit Hilfe von
transpose können diese als Tabellen ausgegeben werden: Bei der Ausgabe der Liste x = [[1..],
[2..],[3..]] wird die zweite Teilliste niemals erreicht; transpose x macht aus den drei unendlichen
Listen eine unendliche Liste von dreielementigen Listen, die ohne Probleme ausgegeben werden kann.
Die folgende Variante von transpose verarbeitet auch obere Dreiecksmatrizen der Form [[1,2,
3],[4,5],[6]]; das Ergebnis ist wieder eine obere Dreiecksmatrix, bei der nur die Enträge oberhalb
der Diagonalen aufgeführt werden.
transpose’’ = foldr (\v vs -> zipWith (:) v (vs ++ repeat [])) []
Interessanterweise verarbeiten auch die Addition <++> und Multiplikation <**> — sofern sie diese
Variante von transpose verwenden — obere Dreieickmatrizen fehlerfrei. Warum?
Übung: Nachfolgend ist eine dritte Variante von transpose aufgeführt. Diskutiere Vor- und
Nachteile der drei Definitionen.
transpose’’’ a
| and (map null a) = []
| otherwise
= map head a:transpose’’’ (map tail a)
Die vordefinierte Funktion null :: [a] -> Bool ergibt True gdw. das Argument die leere Liste
ist.
Das Gauß’sche Eliminationsverfahren:
deleteMax :: (Ord b) => (a -> b) -> [a] -> (a, [a])
deleteMax key [a]
= (a,[])
deleteMax key (a:x)
| key a >= key b = (a,x)
| otherwise
= (b,a:y)
where (b,y) = deleteMax key x
gaussElim :: (Ord a, Fractional a) => Matrix a -> Matrix a
gaussElim [row] = [row]
gaussElim rows = prow:gaussElim (map elim rows’)
where (prow@(p:ps),rows’) = deleteMax (\row -> abs (head row)) rows
elim (x:xs)
= xs <+> ((-x/p) <*> ps)
solve :: (Fractional a) => Matrix a -> Vector a
solve []
= [-1]
solve ((x:xs):rows) = -(solns <.> xs)/x:solns
where solns = solve rows
Zur Übung: Definiere die gleichen Operationen auf Feldern.
6.3. Typklassen
135
6.3. Typklassen
6.3.1. Typpolymorphismus und Typklassen
Im letzten Kapitel haben wir uns mit dem Sortieren von Listen beschäftigt. Um die Algorithmen
vorzustellen und vorzuführen, sind wir stets davon ausgegangen, daß die Listen Elemente eines
(fast) beliebigen Basistyps enthalten. Aus theoretischer Sicht ist dieser Typ ohne Belang: Beim
Nachweis der Korrektheit oder bei der Analyse der asymptotischen Laufzeit spielt es keine Rolle,
ob Zahlen oder Personendaten sortiert werden. Aus praktischer Sicht ist eine Einschränkung hier
wenig annehmbar: Natürlich möchte man sowohl Zahlen als auch Personendaten sortieren und noch
vieles andere mehr.
Die Tatsache, daß der Grundtyp der Listen beliebig ist, drücken wir aus, indem wir bei der
Typdeklaration eine Typvariable für den Grundtyp der Listen einsetzen.
(++) :: [a] -> [a] -> [a]
Lies „für alle Typen a ist [a] -> [a] -> [a] ein Typ von (++)“. Beide Argumente müssen somit
Listen über dem gleichen Typ sein, dieser ist aber beliebig. Wir sagen, (++) ist eine polymorphe
Funktion. Sie kann für beliebige Ausprägungen von a verwendet werden:
"hello " ++ "world"
⇒
"hello world"
[19, 9] ++ [7]
⇒
[19, 9, 7]
["hello ","world"] ++ ["it’s","me"]
⇒
["hello ","world","it’s","me"]
Lisa Lista: Eigentlich ist dies eine Selbstverständlichkeit, oder?
Harry Hacker: Nicht in den Programmiersprachen, die ich als
Kind benutzt habe.
Prof. Paneau: Man darf nicht ganz verschweigen, daß diese
Selbstverständlichkeit besonderer Aufmerksamkeit bei der Übersetzung einer Programmiersprache bedarf.
Um es noch einmal zu betonen: In beiden Fällen handelt es sich um die gleiche Funktion.
Fast alle der in den letzten Kapiteln definierten Funktionen besitzen einen polymorphen Typ;
hier eine kleine Auswahl:
head
tail
leaves
build
map
::
::
::
::
::
[a] -> a
[a] -> [a]
Tree a -> [a]
[a] -> Tree a
(a -> b) -> ([a] -> [b])
Der Typ von map ist sehr allgemein: Das erste Argument ist eine beliebige Funktion; Argumentund Ergebnistyp dieser Funktion sind nicht festgelegt, insbesondere müssen sie nicht gleich sein:
map (\n -> n+1), map (\a -> Leaf a) und auch map map sind korrekte Aufrufe.
Was ist nun der Typ der Sortierfunktionen? Alle Sortierfunktionen stützen sich auf die Vergleichsoperation (<=) ab, also klären wir zunächst deren Typ. Dazu fragen wir sehr prinzipiell, was sich
überhaupt vergleichen läßt? Zahlen und Zeichen, klar; Paare auch, wenn wir wissen, wie man die
Komponenten vergleicht:
(a,b) <= (c,d) = a < c || a == c && b <= d
Auch Listen lassen sich vergleichen, wenn wir die Listenelemente vergleichen können:
Übung 6.8
map (1:) ?
Was bedeutet map map? Was ist der Typ von
136
6. Abstraktion
[]
<= x
= True
(a:x) <= []
= False
(a:x) <= (b:y) = a < b || a == b && x <= y
Paare und Listen werden hier lexikographisch angeordnet. Lexika verwenden die gleiche Ordnung:
„Anfang“ kommt vor „Beginn“, aber nach „Anfall“. Auf fast allen Typen läßt sich eine entsprechende
Ordnung definieren. Es gibt nur eine einzige Ausnahme: Funktionen. Vielleicht kommt man nicht
auf die Idee, Funktionen miteinander zu vergleichen. In diesem Fall sollte man sich vor Augen
führen, daß Korrektheitsbeweise oft nichts anderes machen. Nachdem die Korrektheit von isort und
msort gezeigt war, wußten wir isort = msort. Man stelle sich vor, die Gleichheit von Funktionen
könnte durch Rechnen nachgewiesen werden: Wir hätten uns damit begnügt, die Korrektheit von
isort zu zeigen und dann den Ausdruck isort == msort in den Rechner eingetippt. Leider eine
Illusion, wir stoßen hier an die Grenzen der Mechanisierbarkeit: Die Gleichheit von Funktionen ist
formal unentscheidbar. Wir begnügen uns damit, dies festzustellen und verweisen auf weiterführende
Literatur zur Theoretischen Informatik.
Was bedeutet dies für den Typ der Sortierfunktionen? [a] -> [a] ist zu allgemein, wir müssen
die Belegungen der Typvariable a einschränken:
isort :: (Ord a) => [a] -> [a]
Lies „für alle Typen a, auf denen Vergleichsoperationen definiert sind, ist [a] -> [a] ein Typ von
isort“. Der Teil (Ord a) => ist ein sogenannter Kontext, der die Ausprägung der Typvariable a
einschränkt. Dabei ist Ord eine einstellige Relation auf Typen, eine sogenannte Typklasse, die sich
durch folgende Regeln beschreiben läßt (wir führen nur eine Auswahl auf):
Ord Integer
Ord Char
Ord a ∧ Ord b ⇒ Ord (a, b)
Ord a ⇒ Ord [a]
Lies „Integer ist eine Instanz der Typklasse Ord“. Wir haben oben gesehen, wie (<=) jeweils
definiert wird. In Abhängigkeit vom Typ der Liste greift isort auf die entsprechende Vergleichsfunktion zu; dies geschieht automatisch ohne unser Zutun.
isort ["Beginn","Anfall","Anfang"]
⇒
["Anfall","Anfang","Beginn"]
isort [(3,1),(1,7),(1,3),(2,2)]
⇒
[(1,3),(1,7),(2,2),(3,1)]
In Haskell sind eine Reihe von Typklassen vordefiniert; die wichtigsten werden wir im folgenden
kennenlernen.
In der Typklasse Show sind alle vordefinierten Typen enthalten; sie stellt Funktionen zur Verfügung, mit deren Hilfe sich Werte in ihre textuelle Repräsentation überführen lassen.
6.3. Typklassen
137
show :: (Show a) => a -> String
Schauen wir uns einige Anwendungen an:
show 123
⇒
"123"
show [1, 2, 3]
⇒
"[1, 2, 3]"
show "123"
⇒
"\"123\""
Das letzte Beispiel illustriert, wie Anführungsstriche in einem String untergebracht werden können;
ihnen ist ein Backslash „\“ voranzustellen.
Die Typklasse Eq umfaßt alle Typen, deren Elemente sich auf Gleichheit testen lassen, das sind
alle Typen mit Ausnahme von Funktionen. Die Funktionen unique und element verwenden den
Test auf Gleichheit, ihre allgemeinsten Typen lauten:
unique :: (Eq a) => [a] -> [a]
element :: (Eq a) => a -> [a] -> Bool
Die Typklasse Ord wird für total geordnete Datentypen verwendet; die meisten Funktionen des
letzten Kapitels haben einen Ord-Kontext:
insert :: (Ord a) => a -> [a] -> [a]
merge :: (Ord a) => [a] -> [a] -> [a]
Neben den üblichen Vergleichsoperationen <=, < etc. gibt es noch die Funktion compare, es erlaubt,
die genaue Anordnung zweier Elemente mit einem Vergleich zu ermitteln. Hier sieht man, daß Ord
nur totale Ordnungen beherbergt.
data Ordering = LT | EQ | GT
compare :: (Ord a) => a -> a -> Ordering
Eine geringfügig effizientere Variante der Funktion uniqueInsert läßt sich mit Hilfe von compare
definieren.
uniqueInsert’ a []
= [a]
uniqueInsert’ a x@(b:y) = case
LT
EQ
GT
compare a b of
-> a:x
-> x
-> b:uniqueInsert’ a y
Typen, auf denen Addition, Subtraktion und Multiplikation definiert sind, gehören zur Klasse Num.
Dazu zählen alle in Haskell vordefinierten numerischen Typen wir Int, Integer, Float und Double.
Verwendet eine Funktion die Grundrechenarten (+), (-) oder (*), erhält sie einen Num-Kontext.
size :: (Num n) => Tree a -> n
depth :: (Num n, Ord n) => Tree a -> n
138
6. Abstraktion
Werden die Funktionen in dieser Allgemeinheit nicht benötigt, steht es der Programmiererin natürlich frei, den Typ einzuschränken; nichts anderes haben wir in den letzten Kapitel gemacht. Aus
Gründen der Effizienz hat man sich z.B. entschlossen, die vordefinierten Funktionen length, take
und drop auf Int zu spezialisieren:
length
:: [a] -> Int
take, drop :: Int -> [a] -> [a]
Man unterscheidet zwischen zwei Divisionsoperatoren: der ganzzahligen Division div und der
normalen Division (/). Entsprechend gibt es zwei verschiedene Klassen: Integral für numerische
Typen, die div anbieten, und Fractional für Typen, die (/) anbieten. Instanzen von Integral
sind Int und Integer, Float und Double sind Instanzen von Fractional. Die Funktion power, die
wir in Abschnitt 3.2.3 definiert haben, erhält den folgenden Typ:
power :: (Num a, Integral b) => a -> b -> a
Der Exponent muß aufgrund der Verwendung von div und mod eine ganze Zahl sein, die Basis
hingegen ist beliebig. In der Definition von power wird zusätzlich der Test auf Gleichheit verwendet, warum muß Eq b nicht als Kontext aufgeführt werden? Nun, die Typklassen sind hierarchisch
angeordnet: Eine Instanz von Integral muß auch Instanz von Num sein, eine Instanz von Num muß
auch Instanz von Eq sein. Wir sagen Integral ist eine Untertypklasse von Num. Welchen Sinn würde
es auch machen, div ohne (*) anzubieten?
Übung 6.9 Warum ist Num keine Untertypklasse von Ord?
Eq
Show
/
\
/
Ord
Num
\
/ \
Integral Fractional
Da Integral a somit Eq a umfaßt, muß nur der erste Kontext aufgeführt werden.
6.3.2. class- und instance-Deklarationen
Ausgangspunkt: polymorphe Typen. Motivation für Typklassen:
• Operation wie (==) oder (<=) nicht für alle Typen sinnvoll,
• je nach Typ müssen (==) oder (<=) unterschiedlich implementiert werden.
Beispiel: Rationale Zahlen.
data Rat = Rat Int Int
Der Bruch 67/18 wird durch Rat 67 18 dargestellt. Die gleiche Zahl hat viele verschiedene
Darstellungen: z.B. 67/18 = 8241/2214. Demzufolge ist die folgende Definition von (==) auf Rat
falsch (diese Definition würde mit deriving (Eq) erzeugt werden):
Rat x y == Rat x’ y’ =
x == x’ && y == y’
Richtige Definition:
Rat x y == Rat x’ y’ = x*y’ == x’*y
6.3. Typklassen
139
6.3.3. Die Typklassen Eq und Ord
Definition der Typklasse Eq:
class Eq a where
(==), (/=) :: a -> a -> Bool
x /= y = not (x == y)
Der Name der Typklasse is Eq, danach folgt eine Typvariable (allgemeine Form C a) (==) und (/=)
sind Methoden der Klasse. Für (/=) ist eine Default-Methode angegeben. Die Zugehörigkeit zu einer
Klasse wird explizit mit Hilfe einer Instanzdeklaration angegeben. Beispiel: Rationale Zahlen.
instance Eq Rat where
Rat x y == Rat x’ y’ =
x*y’ == x’*y
Unschön. Bei jedem Gleichheitstest werden zwei Multiplikationen durchgeführt. Ausweg: ‘smart’
Konstruktor:
rat :: Int -> Int -> Rat
rat x y = norm (x * signum y) (abs y)
where norm x y = let d = gcd x y in Rat (x ‘div‘ d) (y ‘div‘ d)
Wenn wir davon ausgehen, daß Elemente vom Typ Rat immer normiert sind, dann kann die
Standarddefinition von (==) verwendet werden.
Beispiel: Binärbäume:
instance (Eq
Leaf a
(Br l r)
_
a)
==
==
==
=> Eq (Tree a) where
Leaf b
= a == b
Br l’ r’ = l == l’ && r == r’
_
= False
Diese Definition wird mit deriving automatisch erzeugt. Allgemeine Form einer Instanzdeklaration:
instance (C1 β1 ,...,Cm βm ) => C(T α1 . . . αn ) mit {β1 , . . . , βm } ⊆ {α1 , . . . , αn }.
Definition der Typklasse Ord als Untertypklasse von Eq:
max x y |
|
min x y |
|
x >= y
otherwise
x <= y
otherwise
=
=
=
=
x
y
x
y
Beachte: Die Default-Methoden sind so gewählt, daß nur <= oder nur compare angegeben werden
muß. Beispiele für Instanzdeklarationen: Rationale Zahlen.
instance Ord Rat where
Rat x y <= Rat x’ y’ = x*y’ <= x’*y
140
6. Abstraktion
Beachte: mit deriving würden die Argumente von Rat lexikographisch geordnet: Rat 2 1 <= Rat 2 3.
Beispiel: Binärbäume:
instance
Nil
Leaf
Leaf
Leaf
Br _
Br _
Br l
(Ord
<=
_ <=
a <=
_ <=
_ <=
_ <=
r <=
a) => Ord (Tree a) where
t
= True
Nil
= False
Leaf b
= a <= b
Br _ _
= True
Nil
= False
Leaf _
= False
Br l’ r’ = l < l’ || l == l’ && r <= r’
Sehr ineffizient, da unter Umständen sowohl l < l’ als auch l == l’ berechnet wird. Diese
Definition wird mit deriving automatisch erzeugt.
6.3.4. Die Typklassen Show und Read
Probleme bei der Überführung eines Baums in einen String. Ziel: Den Wert so ausgeben, daß man
ihn auch wieder einlesen kann.
showTree
showTree
showTree
showTree
:: Tree String -> String
Nil
= "Nil"
(Leaf a) = "Leaf " ++ a
(Br l r) = "Br " ++ showTree l ++ " " ++ showTree r
[Wir kümmern uns erst einmal nicht um die fehlenden Klammern.] In Analogie zu leaves hat
showTree quadratische Laufzeit. Einen AUsweg beitet wieder die Technik der Einbettung: Wir
führen einen akkumulierenden Parameter ein.
showTree’
showTree’
showTree’
showTree’
:: Tree String -> String -> String
Nil s
= "Nil" ++ s
(Leaf a) s = "Leaf " ++ a ++ s
(Br l r) s = "Br " ++ showTree’ l (" " ++ showTree’ r s)
Das kann man noch etwas schöner schreiben mit Hilfe der Funktionskomposition und einigen
vordefinierten Funktionen.
showTree’’
showTree’’
showTree’’
showTree’’
Mit
:: Tree String -> ShowS
Nil
= showString "Nil"
(Leaf a) = showString "Leaf " . showString a
(Br l r) = showString "Br " . showTree’’ l . showChar ’ ’
. showTree’’ r
6.3. Typklassen
type ShowS =
141
String -> String
showChar :: Char -> ShowS
showChar = (:)
showString :: String -> ShowS
showString = (++)
Bei der Typklasse Show wird zusätzlich die Bindungsstärke von Operatoren berücksichtigt, um
Klammern zu sparen. Beispiel: unäres Minus (Bindungsstärke 6), vergleiche
[-7,8]
Br (Leaf (-7)) (Leaf 8)
In Abhängigkeit vom Kontext wird -7 mal geklammert mal nicht; 8 wird nie geklammert. Deshalb
gibt es einen zusätzlichen Parameter, der Informationen über den Kontext überbringt. Definition
der Typklasse Show:
shows :: (Show a) => a -> ShowS
shows = showsPrec 0
show :: (Show a) => a -> String
show x = shows x ""
showParen :: Bool -> ShowS -> ShowS
showParen b p = if b then showChar ’(’ . p . showChar ’)’ else p
Der Parameter p besagt: Operatoren der Bindungsstärke < p müssen geklammert werden: 0 niemals
klammern, 10 immer klammern. Beispiele für Instanzdeklarationen:
instance Show Rat where
showsPrec p (Rat x y) = showParen (p > 9)
(showString "Rat " . showsPrec 10 x
. showsPrec 10 y)
Beispiel: Binärbäume.
instance (Show a) => Show (Tree a) where
showsPrec p Nil
= showString "Nil"
showsPrec p (Leaf a) = showParen (p > 9)
(showString "Leaf " . showsPrec 10 a)
showsPrec p (Br l r) = showParen (p > 9)
(showString "Br " . showsPrec 10 l
. showChar ’ ’ . showsPrec 10 r )
142
6. Abstraktion
Wird statt Br der Infix-Operator :ˆ: verwendet
infix 4 :ˆ:
ändert sich die letzte Gleichung wie folgt:
showsPrec p (l :ˆ: r) = showParen (p > 4)
(showsPrec 5 l . showString " :ˆ: "
. showsPrec 5 r)
Die Klasse Read führen wir nicht auf. Um die Definition verstehen zu können, muß man schon
ein bischen von Parsen kennen. Hier nur die wichtigste Funktion:
read :: (Read a) => String -> a
Problem: Der Übersetzer muß anhand der Typen entscheiden können, welche Instanzen gemeint
sind. Die folgende Definition erweist sich als problematisch:
whoops :: String -> String
whoops x = show (read x)
Abhilfe: explizite Typangabe:
whoops :: String -> String
whoops x = show (read x :: Tree Int)
6.3.5. Die Typklasse Num
Definition der Typklasse Num:
class (Eq a, Show
(+), (-), (*)
negate
abs, signum
fromInteger
a)
::
::
::
::
=> Num a where
a -> a -> a
a -> a
a -> a
Integer -> a
x-y = x + negate y
Behandlung numerischer Literale: 5 wird interpretiert als fromInteger (5::Integer). Beispiel
für eine Instanzdeklaration:
instance Num Rat where
Rat x y + Rat x’ y’
Rat x y * Rat x’ y’
negate (Rat x y)
abs (Rat x y)
signum (Rat x y)
fromInteger x
fromInt x
=
=
=
=
=
=
=
Rat
Rat
Rat
Rat
Rat
Rat
Rat
(x*y’ + x’*y) (y*y’)
(x*x’) (y*y’)
(negate x) y
(abs x) (abs y)
(signum (x*y)) 1
(fromInteger x) 1
(fromInt x) 1
-- Hugs-spezifisch
6.3. Typklassen
143
Eleganter: Rat parametrisiert mit einem numerischen Typ.
data (Integral a) => Rat a = Rat a a
Gibt’s vordefiniert als Ratio in der Bibliothek Rational. Gag:
instance Num Bool
(+)
(*)
negate
fromInteger n
fromInt n
where
= (||)
= (&&)
= not
= n /= 0
= n /= 0
-- Hugs-spezifisch
Größeres Beispiel: Zahlen mit ∞ und −∞.
data Inf n =
Fin n | Inf Bool
deriving (Eq)
oo
finite
isFinite (Fin _)
isFinite (Inf _)
=
=
=
=
Inf True
Fin
True
False
instance (Ord n)
Fin m <= Fin
Fin _ <= Inf
Inf b <= Fin
Inf b <= Inf
=> Ord (Inf n) where
n = m <= n
b = b
_ = not b
c = b <= c
instance (Show n) =>
showsPrec p (Fin
showsPrec p (Inf
showsPrec p (Inf
instance (Num n) =>
Fin m + Fin n
Fin _ + Inf b
Inf b + Fin _
Inf b + Inf c
| b == c
| otherwise
Fin m * Fin n
Fin m * Inf b
Inf b * Fin n
Inf b * Inf c
Show (Inf n) where
n)
= showsPrec p n
False) = showParen (p > 6) (showString "-oo")
True) = showString öo"
Num (Inf n) where
= Fin (m+n)
= Inf b
= Inf b
=
=
=
=
=
=
Inf b
error öo - oo"
Fin (m*n)
Inf (b == (signum m == 1))
Inf (b == (signum n == 1))
Inf (b == c)
144
6. Abstraktion
Grit Garbo: FALSCH: 0 * oo ist mathematisch nicht
definiert!!!
negate (Fin
negate (Inf
abs (Fin m)
abs (Inf b)
signum (Fin
signum (Inf
signum (Inf
fromInteger
fromInt i
m)
b)
=
=
=
=
m)
=
False) =
True) =
i
=
=
Fin
Inf
Fin
Inf
Fin
Fin
Fin
Fin
Fin
(negate m)
(not b)
(abs m)
True
(signum m)
(-1)
1
(fromInteger i)
(fromInt i)
-- Hugs-spezifisch
minimum’, maximum’ :: (Ord n, Num n) => [Inf n] -> Inf n
minimum’ = foldr min
oo
maximum’ = foldr max (-oo)
minimum’ [4,8,2,56,8]
Problem der Eindeutigkeit (da capo):
phyth :: (Floating a) => a -> a -> a
phyth x y = sqrt (xˆ2 + yˆ2)
Die Funktion (ˆ) hat den Typ (Num a, Integral b) => a -> b -> a, 2 hat den Typ (Num a) => a;
somit erhält xˆ2 den Typ ((Num a, Integral b) => a). Problem: Welcher Typ soll für b gewählt
werden? Int oder Integer? Da Mehrdeutigkeiten bei numerischen Typen besonders häufig auftreten,
kann das mit Hilfe einer default-Deklaration festgelegt werden.
default (Int,Double)
6.4. Anwendung: Sequenzen
6.4.1. Klassendefinition
Binärbäume lassen sich als Sequenzen deuten: Leaf a formt eine einelementige Liste, Br hängt zwei
Listen aneinander. Vergleichen wir die Typen von (++) und Br (wir notieren den Listenkonstruktor
präfix: statt [a] einfach [] a).
(++) :: [] a
-> [] a
-> [] a
Br
:: Tree a -> Tree a -> Tree a
Beachte: es wird über einen Typkonstruktor abstrahiert.
class Sequence s where
empty
:: s a
single
:: a -> s a
6.4. Anwendung: Sequenzen
145
isEmpty, isSingle :: s a -> Bool
(<|)
:: a -> s a -> s a
(|>)
:: s a -> a -> s a
hd
:: s a -> a
tl
:: s a -> s a
(<>)
:: s a -> s a -> s a
len
:: s a -> Int
fromList
:: [a] -> s a
toList
:: s a -> [a]
Je nachdem, ob die Klassenmethoden Sequenzen zusammensetzen, in Komponenten zerlegen oder
Eigenschaften feststellen, nennt man sie Konstruktoren, Selektoren oder Observatoren.
Die Definition subsumiert Stapel (engl. stacks): (<|), hd, tl und Schlangen (engl. queues): (|>),
hd, tl. Spezifikation:
empty
=
hi
single a
=
hai
isEmpty ha1 . . . an i
=
n=0
isSingle ha1 . . . an i
=
n=1
a <| ha1 . . . an i
=
haa1 . . . an i
ha1 . . . an i |> a
=
ha1 . . . an ai
hd ha1 . . . an i
=
a1
tl ha1 . . . an i
=
ha2 . . . an i
ha1 . . . am i <> hb1 . . . bn i
=
ha1 . . . am b1 . . . bn i
len ha1 . . . an i
=
n
fromList [a1 , . . . , an ]
=
ha1 . . . an i
toList ha1 . . . an i
=
[a1 , . . . , an ]
Default-Methoden:
single a
isSingle s
a <| s
s |> a
x <> y
len
inc x y
fromList
toList
=
=
=
=
=
=
=
=
=
a <| empty
not (isEmpty s) && isEmpty (tl s)
single a <> s
s <> single a
foldrS (<|) y x
foldrS inc 0 where
y+1
foldr (<|) empty
foldrS (:) []
Beachte: die Default-Methoden sind zyklisch definiert: Entweder man gibt empty und (<|) an
oder single und (<>). Abgeleitete Funktionen.
146
6. Abstraktion
foldrS :: (Sequence s) => (a -> b -> b) -> b -> s a -> b
foldrS (*) e s
| isEmpty s = e
| otherwise = hd s * foldrS (*) e (tl s)
Übung 6.10 Definiere foldlS und foldr1S.
Übung 6.11 Zeige, daß das Programm von Qsort aus Abschnitt 6.1 nicht stabil ist. Dafür gibt es sogar zwei Gründe!
Als Anwendung programmieren wir eine stabile Version von Quicksort. Ein Sortierverfahren heißt
stabil, wenn Elemente mit gleichem Schlüssel beim Sortieren ihre relative Anordnung beibehalten.
Diese Eigenschaft erlaubt sukzessives Sortieren nach verschiedenen Kriterien.
Wir verwenden (|>) anstelle von (:).
qsortS :: (Sequence s, Sequence t, Ord a) => s a -> t a
qsortS x
| isEmpty x = empty
| otherwise = partitionS (tl x) empty (hd x) empty
partitionS :: (Sequence s, Sequence t, Ord a) => s a -> s a -> a -> s a -> t a
partitionS x l a r
| isEmpty x = qsortS l <> (single a <> qsortS r)
| a <= b
= partitionS (tl x) l
a (r |> b)
| otherwise = partitionS (tl x) (l |> b) a r
where b = hd x
Beachte: der Typ wäre nicht so allgemein, wenn wir in der ersten Gleichung von qsortS den
Ausdruck empty durch x ersetzen würden.
6.4.2. Einfache Instanzen
Allgemein gilt, daß wir die Default-Methoden nur dann ersetzen, wenn eine bessere asymptotische
Laufzeit erreicht wird.
Listen sind Sequenzen ([] ist der Typkonstruktor):
instance Sequence [] where
empty
= []
isEmpty = null
(<|)
= (:)
hd
= head
tl
= tail
toList
= id
fromList = id
Das Überschreiben der Defaults im Falle von toList und fromList ergibt sich daraus, daß hier
die Listen zugleich Sequenzen sind. Diese Instanz ist besonders einfach, da Sequenzen und ihre
Repräsentation auf Listen sich eineindeutig entsprechen. Dies ändert sich im nächsten Beispiel.
6.4. Anwendung: Sequenzen
147
Binärbäume sind eine attraktive Repräsentation von Sequenzen: Die Verkettung <> wird durch
Br implementiert und erfolgt (im Unterschied zu ++ auf Listen) in konstanter Zeit. Andererseits
erreichen wir dies auf Kosten längerer Laufzeit von hd und tl.
Die folgende Implementierung liegt nahe:
instance Sequence Tree where
empty
= Nil
isEmpty Nil = True
isEmpty t
= False
(<>)
= Br
etc.
Allerdings scheitert diese Idee an Fällen wie isEmpty (Nil <> Nil) = False, die im Widerspruch
zur Spezifikation stehen. Das Problem liegt darin, daß der Datentyp Tree unterschiedliche Repräsentationen der gleiche Sequenz erlaubt. Grundsätzlich gibt es zwei Wege, dieses Problem zu lösen:
Man kann die Sequenz-Konstruktoren so definieren, daß sie keine „unerwünschten“ Repräsentationen (wie oben Br Nil Bil statt einfach Nil) erzeugen. Oder man programmiert die Observatoren
und Selektoren so, daß sie mit allen möglichen Repräsentationen korrekt umgehen.
Zu Demonstrationszwecken wenden wir hier beide Techniken an.
instance Sequence Tree where
empty
= Nil
isEmpty Nil
= True
isEmpty (Leaf _)
= False
isEmpty (Br l r)
= isEmpty l && isEmpty r
single
= Leaf
isSingle Nil
= False
isSingle (Leaf _)
= True
isSingle (Br l r)
= isSingle l /= isSingle r
hd (Leaf a)
= a
hd (Br l r)
| isEmpty l
= hd r
| otherwise
= hd l
tl (Br Nil r)
= tl r
tl (Br (Leaf _) r)
= r
tl (Br (Br l’ r’) r) = tl (Br l’ (Br r’ r)) -- Rechtsrotation
(<>)
= br where
-- smart Konstruktor
br Nil r
= r
br l Nil
= l
br l r
= Br l r
toList
= leaves
fromList []
= Nil
fromList [a]
= Leaf a
fromList (a:as)
= a <| fromList as
Übung 6.12 Beide Möglichkeiten haben auch ihre Nachteile
— welche?
Übung 6.13
Ergänze diese Implementierung um explizite Fehlermeldungen in den Fällen, wo die Operationen
auf Sequenzen nicht spezifiziert sind. Warum können für
(<|), (|>), fromList, len und toList die default-Definition
übernommen werden?
Übung 6.14 Sequenzen über beliebigen Alphabeten lassen
sich nach einer Technik von Gödel als Zahlen codieren. Hier
am Beispiel über dem Alphabet der positiven ganzen Zahlen:
data GoedelNr = G Integer
deriving Show
goedelize :: [Integer] -> GoedelNr
goedelize ns = G (g primes ns)
where g ps
[]
= 1
g (p:ps) (n:ns) = pˆn * g ps ns
Führe diese Technik für beliebige Alphabete a durch und mache
den Typ GoedelNr a zur Instanz von Sequence.
Übung 6.15 Listen mit Löchern als (nicht-generische) Instanz.
Vorübung zur nächsten Aufgabe.
data LList a =
LL ([a] -> [a])
abra, kad :: LList Char
abra = LL (äbra"++)
kad = LL ("kad"++)
-- Beispieldaten
abrakadabra = toList (abra <> (kad <> abra))
Erkläre LList zur Instanz von Sequence.
Übung 6.16 Sequenzen mit Löchern als generische Instanz.
data Iso s a = Iso (s a -> s a)
148
6. Abstraktion
Beachte: die dritte Gleichung von tl ist sehr bewußt gewählt. Wir verwenden nicht tl (Br (Br l’ r’) r) = Br (tl
Bei der Analyse der Laufzeit kann die Analyse von leaves helfen. Amortisierte Analyse: Gutes Verhalten bei
t0 , hd t0 , t1 = tl t0 , . . . , hd tn−1 , tn = tl tn−1
Schlechtes Verhalten bei:
t0 = empty . . . , ti+1 = ti |> ai , hd ti+1 . . .
6.4.3. Generische Instanzen
Erweiterung eines Sequenzenkonstruktors, so daß die Länge in konstanter Zeit berechnet wird.
WithLen hat Kind von (* -> *) -> (* -> *).
data WithLen s a = Len (s a) Int
Beachte: deriving (Show) geht nicht, da die Voraussetzung Show (s a) nicht zulässig ist.
instance (Sequence s) =>
empty
=
single a
=
isEmpty (Len _ n) =
isSingle (Len _ n) =
a <| Len s n
=
Len s n |> a
=
hd (Len s _)
=
tl (Len s n)
=
Len s m <> Len t n =
len (Len _ n)
=
fromList x
=
toList (Len s _)
=
Sequence (WithLen s) where
Len empty 0
Len (single a) 1
n == 0
n == 1
Len (a <| s) (n+1)
Len (s |> a) (n+1)
hd s
Len (tl s) (n-1)
Len (s <> t) (m+n)
n
Len (fromList x) (length x)
toList s
Ein angenehmer Seiteneffekt: Auch isEmpty und isSingle laufen in konstanter Zeit. Anwendung:
type List = WithLen []
Durch diese Defintion erhalten wir einen Listentyp, der sozusagen die Länge der Listen „cached“
und effizienten Zugriff gestattet.
6.4. Anwendung: Sequenzen
149
6.4.4. Schlangen
Ziel: (|>), hd und tl in konstanter Zeit. Das sind die typischen Operationen für Schlangen (engl. queues). Ausgangspunkt: (<|), hd und tl arbeiten in konstanter Zeit wie z.B. bei Listen.
Ansatz: die Sequenz ha1 . . . ai ai+1 . . . an i wird durch zwei Sequenzen repräsentiert: ha1 . . . ai i und
han . . . ai+1 i.
data FrontRear s t a = FrontRear (s a) (t a)
Wir tragen Sorge, daß die erste Liste immer mindestens ein Element enthält. Invariante für
FrontRear f r:
isEmpty f
=⇒
isEmpty r
(6.1)
instance (Sequence s, Sequence t) => Sequence (FrontRear s t) where
empty
= FrontRear empty empty
isEmpty (FrontRear f r) = isEmpty f
FrontRear f r |> a
= frontRear f (a <| r)
hd (FrontRear f r)
| isEmpty f
= error "hd of empty"
| otherwise
= hd f
tl (FrontRear f r)
| isEmpty f
= error "tl of empty"
| otherwise
= frontRear (tl f) r
Der Pseudo-Konstruktor (smart construktor) frontRear ist wie folgt definiert:
frontRear :: (Sequence s, Sequence t) => s a -> t a -> FrontRear s t a
frontRear f r
| isEmpty f = FrontRear (rev r) empty
| otherwise = FrontRear f
r
Amortisierte Analyse: gutartig für (Queue wird ephemeral verwendet):
q0 , hd q0 , q1 = tl q0 , . . . , hd qn−1 , qn = tl qn−1
Schlechtes Verhalten bei (Queue wird persistent verwendet):
q = FrontRear [a] r, qi = tl (q |> ai ), hd qi
Idee: das Umdrehen muß frühzeitig erfolgen. Einführung einer Balanzierungsbedingung. Invariante für FrontRear f r:
len f
>
len r
(6.2)
Beachte: (6.1) impliziert (6.2). Voraussetzung: <> erfolgt inkrementell. Und: len in konstanter Zeit.
150
6. Abstraktion
frontRear :: (Sequence s, Sequence t) => s a -> t a -> FrontRear s t a
frontRear f r
| len f < len r = FrontRear (f <> rev r) empty
| otherwise
= FrontRear f
r
Amortisierte Laufzeit von Θ(1). Worst case Laufzeit von Θ(n). Anwendung:
type Queue = FrontRear List List
6.4.5. Konkatenierbare Listen
Ziel: hd, tl und (<>) in konstanter Zeit. Binärbäume bieten schon (<>) in konstanter Zeit. Left-spine
view:
data GTree s a = GNode a (s (GTree s a))
instance (Sequence s) => Sequence (GTree s) where
single a
= GNode a empty
isSingle (GNode _ s) = isEmpty s
hd (GNode a _)
= a
tl (GNode _ s)
| isEmpty s
= error "tl of singleton"
| otherwise
= foldr1S (<>) s
GNode a s <> g
= GNode a (s |> g)
Anwendung:
type CatList = AddEmpty (GTree Queue)
A. Lösungen aller Übungsaufgaben
3.1 Hier ist die Definition von not:
not :: Bool -> Bool
not True = False
not False = True
-- vordefiniert
Die Definitionen von (&&) und (||) bedienen sich im wesentlichen der entsprechenden Wertetabelle
für die Konjunktion bzw. für die Disjunktion. Gleiche Fälle werden zusammengefaßt.
(&&),
False
True
True
False
(||)
&& y
&& y
|| y
|| y
:: Bool -> Bool -> Bool
= False
= y
= True
= y
-- vordefiniert
[(&&) und (||) sind lazy!]
3.2 Definition von first, second und third:
first
first
:: (a, b, c) -> a
(a, b, c) = a
second :: (a, b, c) -> b
second (a, b, c) = b
third
third
:: (a, b, c) -> c
(a, b, c) = c
3.3 Definition von head’’:
head’’
head’’
head’’
head’’
head’’
head’’
:: List a -> a
Empty
(Single a)
(App Empty r)
(App (Single a) r)
(App (App ll lr) r)
=
=
=
=
=
error "head’’ of empty list"
a
head’’ r
a
head’’ (App ll (App lr r))
Wenn man vereinbart, daß Empty nie unterhalb von App auftritt, dann ist die Definition einfacher.
152
tail’
tail’
tail’
tail’
tail’
tail’
A. Lösungen aller Übungsaufgaben
:: List a -> List a
Empty
(Single a)
(App Empty r)
(App (Single a) r)
(App (App ll lr) r)
=
=
=
=
=
error "tail’ of empty list"
Empty
tail’ r
r
tail’ (App ll (App lr r))
3.4 Definition von member:
member’ :: (Ord a) => a -> OrdList a -> Bool
member’ a []
= False
member’ a (b:bs) = a >= b && (a == b || member’ a bs)
Beachte: die Klammern sind notwendig, da (&&) stärker bindet als (||).
3.5 Man sollte Grit zunächst klarmachen, daß links- und rechtsassoziativ rein syntaktische Eigenschaften sind, die dem Haskell-Übersetzer mitteilen, wie fehlende Klammern zu ergänzen sind. Mathematisch gesehen ist die Listenkonkatenation assoziativ: Wie man x1 ++x2 ++x3 auch klammert, der
Wert des Ausdrucks ist stets der gleiche. Warum vereinbart man (++) dann als rechtsassoziativ? Nun,
das hat Effizienzgründe: Sei ni die Länge der Liste xi ; um (x1 ++x2 )++x3 auszurechnen, benötigt
man 2n1 + n2 Anwendungen von „:“, für x1 ++(x2 ++x3 ) hingegen nur n1 + n2 . Die arithmetischen
Operatoren werden aus Gründen der Einheitlichkeit als linksassoziativ deklariert: die Operatoren
(-) und (/) sind es auch und dies aus gutem Grund, wie wir gesehen haben.
3.6 Funktionen auf Cartesischen Produkten sind die bessere Wahl, wenn das Paar oder das n-Tupel
semantisch zusammengehört; etwa wenn ein Paar eine Koordinate in der Ebene repräsentiert.
3.7 Die Ausdrücke bezeichnen die folgenden Bäume:
1. Br (Br (Leaf 1) (Leaf 2)) Nil
2. Br (Br (Leaf 1) (Leaf 2))
(Br (Br (Leaf 3) Nil)
(Br (Leaf 4) (Leaf 5)))
3. Br (Br (Br (Leaf 1) (Leaf 2))
(Br (Leaf 3)
(Br (Leaf 4)
(Br (Leaf 5) (Leaf 6)))))
(Br (Leaf 7) (Leaf 8))
3.8 Die Ausdrücke bezeichnen die folgenden Bäume:
153
2
1
6
4
1
2
1
2
(1)
5
3
7
8
5
3
4
(2)
(3)
Diese Bäume erhält man übrigens durch Spiegelung der Bäume aus Aufgabe 3.7 (die Blätter müssen
zusätzlich neu durchnumeriert werden).
3.9 Definition von leaves’ mit case-Ausdrücken:
leaves’’ :: Tree a -> [a]
leaves’’ t = case t of
Nil
-> []
Leaf a -> [a]
Br l r -> case l of
Nil
-> leaves’’ r
Leaf a
-> a : leaves’’ r
Br l’ r’ -> leaves’’ (Br l’ (Br r’ r))
3.10 Definition von drop:
drop :: Int -> [a] -> [a]
drop n (a:as) | n > 0 = drop (n-1) as
drop _ as
= as
-- vordefiniert
3.11 Rechenregeln für if:
if True then e1 else e2
⇒
e1
if False then e1 else e2
⇒
e2
Schreibt man die Rechenregeln als Programm, erhält man eine Boole’sche Funktion ifThenElse.
ifThenElse’ :: Bool -> a -> a -> a
ifThenElse’ True a a’ = a
ifThenElse’ False a a’ = a’
3.12 Es ist ungeschickt, zunächst alle Parameter von take auszurechnen, da die Auswertung von
x nicht terminiert.
154
A. Lösungen aller Übungsaufgaben
4.1 [Didaktische Anmerkung: (a) Übt Vorbedingungen ein, (b) den Umgang mit Quantoren.] (a)
Die Vorbedingung an die Argumente von merge drücken wir mittels einer Implikation aus:
ordered x = True ∧ ordered y = True =⇒ ordered (merge x y) = True
(A.1)
bag (merge x y) = bag x ] bag y
(A.2)
(b) Im Fall von mergeMany müssen wir „alle Elemente der Eingabeliste“ formalisieren.
member :: (Eq a) => a -> [a] -> Bool
member a []
= False
member a (b:x) = a == b || member a x
Mit a ∈ x kürzen wir member a x = True ab.
[
U
((∀a ∈ x) ordered a = True) =⇒ ordered (mergeMany x) = True
(A.3)
]
(A.4)
* bag a | a ∈ x + = bag (mergeMany x)
und * . . . | . . . + erklären.]
4.2 Zusätzlich kann man fordern: Die Längen der Teillisten nehmen zu und die Länge zweier benachbarter Teillisten unterscheidet sich höchstens um 1.
upstairs :: [Int] -> Bool
upstairs []
= True
upstairs [n]
= True
upstairs (n1:n2:ns) = up n1 n2 && upstairs (n2:ns)
where up n1 n2 = n1 == n2 || n1+1 == n2
Sei xs = split n x, dann muß zusätzlich upstairs (map length xs) gelten.
Eine Liste der Länge l läßt sich damit in n − l mod n Listen der Länge bl/nc und in l mod n
Listen der Länge dl/ne zerlegen.
split :: Int -> [a] -> [[a]]
split n as = repeatedSplit ns as
where (d,r) = divMod (length as) n
ns
= replicate (n-r) d ++ replicate r (d+1)
Die vordefinierte Funktion divMod faßt div und mod zusammen.
repeatedSplit
repeatedSplit
repeatedSplit
where (x,
:: [Int] -> [a] -> [[a]]
[]
as = []
(n:ns) as = x : repeatedSplit ns as’
as’) = splitAt n as
Die vordefinierte Funktion splitAt faßt take und drop zusammen. In [GKP94] wird das folgende
Verfahren vorgeschlagen:
155
split’ n as =
splitn l 1 as
splitn l n as
where k
(x,
splitn (length as) n as
= [as]
= x : splitn (l-k) (n-1) as’
= l ‘div‘ n
as’) = splitAt k as
Daß die Funktion das gleiche leistet, ist nicht unmittelbar einsichtig; die interessierte Leserin sei
für den Nachweis auf [GKP94] verwiesen.
4.3 Definition von branches:
branches
branches
branches
branches
:: Tree a -> Integer
Nil
= 0
(Leaf a) = 0
(Br l r) = 1 + branches l + branches r
4.4 Die erste Definition von leaves in Abschnitt 3.4 ist strukturell rekursiv; die zweite ist es nicht,
da der rekursive Aufruf leaves (Br l’ (Br r’ r)) nicht über eine Teilstruktur des Arguments
erfolgt.
4.5 Die folgenden Funktionen aus Kapitel 2 sind strukturell rekursiv definiert:
transponiere, wc_complement, complSingleStrand, exonuclease, genCode, ribosome,
triplets, translate, findStartPositions.
Dito für Kapitel 3:
ifThenElse, fst, snd, (++), head, tail, reverse, minimum1, map, minimum, length,
dropSpaces, squeeze, member, splitWord, minimum0’, leaves, leftist, take.
4.6 Wir definieren zunächst eine Hilfsfunktion leavesTo mit der folgenden Eigenschaft.
leavesTo
leavesTo t y
::
=
Tree a -> [a] -> [a]
leaves t ++ y
Analog zur Vorgehensweise bei reverse läßt sich die folgende Definition von leavesTo ableiten.
leavesTo
leavesTo
leavesTo
leavesTo
:: Tree a ->
Nil
y =
(Leaf a) y =
(Br l r) y =
[a] -> [a]
y
a:y
leavesTo l (leavesTo r y)
Die Funktion leaves ergibt sich wiederum durch durch Spezialisierung von leavesTo.
leaves’ :: Tree a -> [a]
leaves’ t = leavesTo t []
4.7 Die Begründung ist hier nicht stichhaltig — erstens weil unendliche Listen nicht in einem []
enden, zweitens weil der Beweis bei der elementweisen Konstruktion nie fertig werden würde.
156
A. Lösungen aller Übungsaufgaben
4.10 Die Funktion complete n konstruiert einen vollständigen Baum der Tiefe n.
complete :: Integer -> Tree a
complete 0
= Nil
complete (n+1) = Br t t
where t = complete n
4.11 Nachweis von Aussage 4.14. Induktionsbasis (t = Nil):
branches Nil + 1
=
1
=
size Nil
(Def. branches)
(Def. size)
Induktionsbasis (t = Leaf a): analog. Induktionsschritt (t = Br l r):
branches (Br l r) + 1
=
(branches l + branches r + 1) + 1
=
size l + size r
=
size (Br l r)
(Def. branches)
(I.V.)
(Def. size)
4.13 Die Multiplikation auf Natural:
mutlN :: Natural -> Natural -> Natural
mutlN Zero
n = Zero
mutlN (Succ m) n = addN (mutlN m n) n
Möchte man — wie Harry — die üblichen Symbole zum Rechnen verwenden, muß man Natural
zu einer Instanz der Typklasse Num machen.
instance Num Natural where
(+)
= addN
Zero
- n
= Zero
Succ m - Zero
= Succ m
Succ m - Succ n
= m-n
(*)
= mutlN
fromInteger 0
= Zero
fromInteger (n+1) = Succ (fromInteger n)
Mehr dazu in Abschnitt 6.3.
4.14 Eine erste (naive) Definition könnte so aussehen:
157
naiveBraun
naiveBraun
naiveBraun
naiveBraun
:: Tree a -> Bool
Nil
= True
(Leaf a) = True
(Br l r) = size l += size r && naiveBraun l && naiveBraun r
(+=) :: (Num a) => a -> a -> Bool
m += n = m == n || m+1 == n
Diese Definition krankt daran, daß für jeden Teilbaum erneut die Größe ausgerechnet wird. Durch
Rekursionsverstärkung läßt sich dieses Manko beheben.
braun
braun t
where
check
check Nil
check (Leaf a)
check (Br l r)
where (bl, sl)
(br, sr)
:: Tree a -> Bool
= fst (check t)
::
=
=
=
=
=
Tree a -> (Bool, Integer)
(True, 1)
(True, 1)
(sl += sr && bl && br, sl + sr)
check l
check r
Die Hilfsfunktion check bestimmt zwei Dinge gleichzeitig: die Braun-Eigenschaft und die Größe
des Baums.
4.17 Die Braun-Eigenschaft legt einen Baum in seiner Struktur fest, nicht aber in der Beschriftung
der Blätter. Deshalb ist die Spezifikation nicht eindeutig. Um 4.27 nachzuweisen, zeigt man zuerst
size (extend a t) = size t + 1 .
(A.5)
4.20 Die Fälle t = Nil und t = Leaf a folgen direkt aus den Definitionen. Fall t = Br Nil \(r\):
Da weight t = 1 + weight \(r\) ist die Induktionsvorausetzung auf r anwendbar.
bag (leaves (Br (Leaf a) r))
=
bag (a:leaves r)
=
*a+ ] bag (leaves r)
=
*a+ ] bag r
(Def. leaves)
(Def. bag)
(I.V.)
=
bag (Leaf a) ] bag r
(Def. bag)
=
bag (Br (Leaf a) r)
(Def. bag)
Fall t = Br (Leaf a) r: analog. Fall t = Br (Br l’ r’) r: Wir haben bereits nachgerechnet, daß
158
A. Lösungen aller Übungsaufgaben
die Induktionsvorausetzung auf Br l’ (Br r’ r) anwendbar ist.
bag (leaves (Br (Br l’ r’) r))
=
bag (leaves (Br l’ (Br r’ r)))
=
bag (Br l’ (Br r’ r))
(Def. leaves)
(I.V.)
=
bag l’ ] (bag r’ ] bag r)
(Def. bag)
=
(bag l’ ] bag r’) ] bag r
(Ass. (]))
=
bag (Br (Br l’ r’) r))
(Def. bag)
5.1
insert = \a -> \x -> case x of {
[] -> a:[];
b:y -> case a<=b of {
True -> a:(b:x);
False -> b:((insert a) y)}}
5.2 Der Unterschied liegt nur im konstanten Faktor.
5.6 Da weder length noch take noch drop Vergleichsoperationen verwenden, ergeben sich folgende
Rekurrenzgleichungen für mergeSort:
T mergeSort (0)
=
0
T mergeSort (1)
=
0
T mergeSort (n)
=
n − 1 + T mergeSort (bn/2c) + T mergeSort (dn/2e)
für n > 1
Diese Form der Rekurrenzgleichung ist typisch für Programme, die auf dem „Teile und Herrsche“Prinzip basieren. Rechnen wir die Kostenfunktion zunächst einmal für den Fall aus, daß die Halbierung stets aufgeht:
T mergeSort (2m )
=
2m − 1 + 2T mergeSort (2m−1 )
Einer ähnlichen Rekurrenz sind wir schon im letzten Abschnitt begegnet:
T mergeSort (2m )
=
0
T sT (m) = m2m − 2m + 1
159
Die obige Formel läßt sich gut mit Hilfe des Rekursionsbaums von mergeSort illustrieren (für
m = 4):
1*(16-1)
o
/
2*( 8-1)
4*( 4-1)
8*( 2-1)
o
\
o
o
/ \
/ \
o o
o o
/\ /\ /\ /\
o o o o o o o o
/\/\/\/\/\/\/\/\
1*(16-1)-3
/
\
o
o
/ \
/ \
o o
o o
/\ /\ /\ /\
o o o o o o o o
\/\ \/\ \/\/\/\
2*( 8-1)-3
4*( 4-1)-3
8*( 2-1)-3
Wenn die Länge der zu sortierenden Liste keine Potenz von 2 ist, erhalten wir einen Baum dessen
unterste Ebene ausgefranst ist: Sei m = dlog2 ne, dann fehlen 2m − n Elemente. Um diese Anzahl
verringern sich gerade die Kosten pro Ebene. Da es m Ebenen gibt, erhalten wir:
T mergeSort (n)
=
T mergeSort (2m ) − m(2m − n) = mn − 2m + 1
T mergeSort (n)
∈
mit m = dlog2 ne
Θ(n log n).
6.10
foldlS :: (Sequence s) => (a -> b -> a) -> a -> s b -> a
foldlS (*) e = f e
where f e s | isEmpty s = e
| otherwise = f (e * hd s) (tl s)
foldr1S :: (Sequence s) => (a -> a -> a) -> s a -> a
foldr1S (*) s
| isSingle s = hd s
| otherwise = hd s * foldr1S (*) (tl s)
Beispiele:
rev :: (Sequence s, Sequence t) => s a -> t a
rev = foldlS (flip (<|)) empty
6.11 Erstens werden die Teillisten jedesmal umgedreht; zweitens müssen Elemente, die gleich dem
Vergleichselement sind, in die rechte Teilliste (statt in die linke) eingebaut werden.
6.12 Im ersten Fall muss man dafür sorgen, daß zur Konstruktion von Sequenzen nur die Konstruktoren der Klasse, aber nicht unmittelbar die des Datentyps verwendet werden. Im zweiten Fall
können sehr ineffiziente Repräsentationen entstehen.
160
A. Lösungen aller Übungsaufgaben
6.14 – Nur Vorüberlegungen zum Spezialfall!
split :: GoedelNr -> (Integer,GoedelNr)
split (G m) = splt primes 0 m
where
splt (p:ps) e m
| m ‘mod‘ p == 0 = splt (p:ps) (e+1) (m ‘div‘ p)
| e == 0
= splt ps 0 m
| otherwise
= (e, G m)
Achtung: bei split (G m) = (u,G v) ist (G v) NICHT die GoedelNr. der verkürzten Liste.
degoedelize :: GoedelNr -> [Integer]
degoedelize (G m) | m == 1
= []
| otherwise = n:degoedelize ns
where (n,ns) = split (G m)
instance (Integral a) => Sequence (GoedelNr a )
where empty = G 1
isEmpty (G m) = (m == 1)
hd (G m) = n
where (n,ns) = split (G m)
tl (G m) = ns
where (n,ns) = split (G m)
(G m) |> n = let k = len (G m)
in G (m* (primes !! (k+1))ˆn)
(G m) <> (G n) | isEmpty (G n) = (G m)
| otherwise
= ((G m) |> a) <> (G as)
where (a:as) = split n
fromList = goedelize
toList = degoedelize
161
6.15
instance Sequence LList where
empty
= LL id
isEmpty (LL s)
= isEmpty (s [])
single a
= LL (a:)
isSingle (LL s) = isSingle (s [])
(LL s) <> (LL t) = LL (s . t)
hd (LL s)
= head (s [])
tl (LL s)
= LL h where
h r
= tail (s r)
len (LL s)
= len (s [])
B. Mathematische Grundlagen
oder: Wieviel Mathematik ist nötig?
B.1. Mengen und Funktionen
B.1.1. Der Begriff der Menge
Eine Menge ist eine abstrakte Zusammenfassung unterschiedener Objekte. Wir schreiben x ∈ M ,
wenn x ein Element von M ist, und x ∈
/ M , wenn x kein Element von M ist.
In der Schule haben wir es häufig mit Mengen von Zahlen zu tun. Wir verwenden N für die Menge
der natürlichen Zahlen1 , Z für die Menge der ganzen Zahlen und R für die Menge der reellen Zahlen.
Mengen werden z.B. durch Aufzählung definiert.
N37
:=
{3, 4, 5, 6, 7}
HS
:=
{Harry, Sally}
M1
:=
{M, A, T, H, E, I, K}
Bool
:=
{False, True}
(Buchstaben des Wortes MATHEMATIK)
(Menge der „Wahrheitswerte“)
Abstrakt ist die Menge als Zusammenfassung ihrer Elemente in dreifacher Hinsicht:
1. die Elemente stehen in keiner besonderen Anordnung, z.B. {M, A, T, H, E, I, K} = {A, E, I, H, K, T, M },
2. die Elemente gehören der Menge nicht mit unterschiedlicher Intensität oder Häufigkeit an,
z.B. {M, A, T, H, E, I, K} = {M, A, T, H, E, M, A, T, I, K},
3. es wird nicht verlangt, daß die zusammengefaßten Elemente überhaupt in irgendeinem logischen Zusammenhang stehen.
Der letzte Punkt bedeutet, daß mathematisch gegen die Definition der Menge
Allerlei
:=
{Lisa Listig’s PC, die Oper „Don Giovanni“,
der Lombardsatz der Deutschen Bundesbank}
nichts einzuwenden ist, obwohl man sich schwerlich einen Zusammenhang vorstellen kann, indem
diese Menge kein Unfug ist. Diese Gleichgültigkeit in bezug darauf, welche Art von Objekten man
in eine Menge faßt, darf man allerdings nicht ungestraft auf die Spitze treiben. Als Russell’sche
Antinomie berühmt ist die Menge aller Mengen, die sich selbst nicht enthalten. Solche Gefahren
werden für uns keine Rolle spielen und wir gehen nicht weiter auf sie ein.
1 Im
Unterschied zum Gebrauch in der Mathematik zählt die Informatik die 0 zu den natürlichen Zahlen.
164
B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?
Mengen werden oft durch Angabe einer charakterisierenden Eigenschaft definiert.
Prim
:=
{ x | x ∈ N und x ist Primzahl }
N37
:=
{ x | x ∈ N und 3 6 x 6 7 }
NullSt
:=
{ x | x5 − x3 = 8x2 − 9 = 0 }
N
:=
{ x | x = 0 oder (x = y + 1 und y ∈ N) }
Eine Definition durch eine charakteristische Eigenschaft unterstellt eine umfassendere Menge, deren
Elemente auf diese Eigenschaft hin zu prüfen sind. Bei Prim und N37 ist diese Menge N. Die
Definition von NullSt ist gerade deswegen anfechtbar, weil die Bezugsmenge nicht genannt wird.
Sind ganze, reelle oder komplexe Nullstellen gemeint? Die hier gegebene Definition von N dagegen
bezieht sich auf sich selbst und setzt dabei das Bildungsgesetz der natürlichen Zahlen — das Zählen
— voraus. Solche Definitionen nennt man rekursiv. Das Zählen ist nichts als der Übergang von
einer natürlichen Zahl zur nächsten. Wir haben es hier durch die Operation +1 bezeichnet, so daß
unsere natürlichen Zahlen nach dieser Definition statt des vertrauten 0, 1, 2 . . . das ungewöhnliche
Aussehen 0, 0 + 1, (0 + 1) + 1 usw. haben.
Eine Menge M ist Teilmenge von N (M ⊆ N ), wenn jedes Element von M auch Element von N
ist.
M ⊆N
⇐⇒
(∀a) a ∈ M ⇒ a ∈ N
Die Vereinigung von M und N (M ∪ N ) ist definiert durch
M ∪N
=
{ a | a ∈ M oder a ∈ N }
Dabei ist das „oder“ nicht ausschließend zu verstehen. Der Durchschnitt von M und N (M ∩ N )
ist definiert durch
M ∩N
=
{ a | a ∈ M und a ∈ N }.
Mit den gegebenen Definitionen läßt sich z.B. zeigen, daß ∪ und ∩ assoziative Operationen sind:
K ∪ (M ∪ N )
=
(K ∪ M ) ∪ N
K ∩ (M ∩ N )
=
(K ∩ M ) ∩ N
Die Potenzmenge einer Menge M ist die Menge aller Teilmengen von M :
P(M ) = { T | T ⊆ M }
Da für alle Mengen M stets ∅ ⊆ M und M ⊆ M gilt, enthält P(M ) stets ∅ und M . Das Cartesische
Produkt zweier Mengen M und N ist die Menge aller möglichen Elementpaare:
M × N = { (x, y) | x ∈ M und y ∈ N }
Das Cartesische Produkt verallgemeinert man auf n > 2 Mengen und spricht dann von n-Tupeln.
B.2. Relationen und Halbordnungen
165
B.1.2. Der Begriff der Funktion
Seien A, B Mengen. Eine partielle Funktion f von A nach B hat den Vorbereich A, den Nachbereich
B und ordnet gewissen Elementen aus A (den Argumenten) jeweils ein Element aus B zu (den Wert).
Man schreibt f : A → B und f (a) = b oder f : a 7→ b. Sind A und B Mengen, so bezeichnet A → B
die Menge der partiellen Funktionen mit Vorbereich A und Nachbereich B. Ist die Menge A ein
kartesisches Produkt A = (A1 , . . . , An ), so nennt man f auch n-stellige Funktion.
Die Menge
Def (f )
:=
{ a ∈ A | es gibt b ∈ B mit f (a) = b }
heißt Definitionsbereich von f , die Menge
Im(f )
:=
{ b ∈ B | es gibt a ∈ A mit f (a) = b }
heißt Bildbereich von f . Ist Def (f ) = A, heißt f totale Funktion.
Bekannte Beispiele von Funktionen sind die Grundrechnungsarten, Polynome, trigonometrische
Funktionen usw. über R. Davon sind z.B. Division und Tangens partielle Funktionen, da Divisor 0
bzw. π2 nicht zum Definitionsbereich gehören. Das Differenzieren kann man selbst als Funktion (·)0
ansehen:
(·)0
:
(R → R) → (R → R).
B.2. Relationen und Halbordnungen
Eine Funktion ordnet gewissen Elementen des Vorbereichs ein Element des Nachbereichs zu. Relationen geben im Unterschied dazu beliebige Beziehungen zwischen zwei Mengen an: Einem Element
des Vorbereichs können durchaus mehrere Elemente des Nachbereichs zugeordnet werden. Formal
ist eine zweistellige Relation eine Teilmenge R ⊆ M × N . Statt (a, b) ∈ R schreibt man auch oft
aRb.
Eine Relation R ⊆ M × M heißt Halbordnung auf M , wenn R
1. reflexiv, (∀x ∈ M ) xRx,
2. antisymmetrisch, (∀x, y ∈ M ) (xRy und yRx) ⇒ x = y, und
3. transitiv, (∀x, y, z ∈ M ) (xRy und yRz) ⇒ xRz, ist.
Ein Beispiel für eine Halbordnung ist etwa die Teilmengenbeziehung (⊆). Das Wörtchen „Halb“
deutet an, daß es in der Regel Elemente gibt, die nicht miteinander vergleichbar sind: Die Mengen
{1, 2} und {2, 3} sind z.B. unvergleichbar. Ist jedes Element mit jedem vergleichbar, spricht man
von einer totalen Ordnung.
166
B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?
B.3. Formale Logik
B.3.1. Aussagenlogik
Um Sachverhalte, Eigenschaften etc. präzise aufschreiben zu können, verwendet man aussagenlogische bzw. prädikatenlogische Formeln. Wir haben selbst schon einige Formeln verwendet, um z.B. die
Mengeninklusion (⊆) oder Eigenschaften von Relationen (Transitivität) zu notieren.
In der klassischen Logik ist eine Formel entweder wahr oder falsch. Für die Menge dieser Wahrheitswerte
sind verschiedene Schreibweisen gebräuchlich:
Bool
=
{False, True}
=
{0, 1}
(im Hardware-Entwurf)
=
{F, W }
(in der formalen Logik).
(in der Programmierung)
Die wichtigsten Boole’schen Funktionen sind „und“ (∧), „oder“ (∨) und „nicht“ (¬), definiert durch
die folgende Wertetabelle:
a
False
False
True
True
b
False
True
False
True
a∧b
False
False
False
True
a∨b
False
True
True
True
¬a
True
True
False
False
Eine Formel ist aus False, True, ∧, ∨, ¬ und Variablen zusammengesetzt. Wir können „und“, „oder“
und „nicht“ auch durch Gleichungen definieren:
False ∧ x
True ∧ x
=
=
False
x
False ∨ x
True ∨ x
=
=
x
True
¬False
¬True
=
=
True
False
Durch eine vollständige Fallunterscheidung über das erste Argument sind ∧, ∨ und ¬ totale Funktionen. Mit Hilfe dieser Definitionen zeigt man wichtige Eigenschaften: Für alle a, b, c ∈ Bool gilt
z.B.:
a∧a
=
a
(Idempotenz)
(a ∧ b) ∧ c
=
a ∧ (b ∧ c)
a∧b
=
b∧a
(Assoziativität)
(Kommutativität)
a ∧ (a ∨ b)
=
a
a ∧ (b ∨ c)
=
(a ∧ b) ∨ (a ∧ c)
¬¬a
=
a
(Absorption)
(Distributivität)
(doppelte Negation)
Interessanterweise gelten die Gleichungen auch, wenn man konsistent ∧ durch ∨ und ∨ durch ∧
ersetzt. Wir beweisen das Absorptionsgesetz: Fall a = False:
False ∧ (False ∨ b)
=
False
(Definition von ∧)
B.3. Formale Logik
167
Fall a = True:
True ∧ (True ∨ b)
=
True ∨ b
(Definition von ∧)
=
True
(Definition von ∨)
In Formeln verwendet man oft noch weitere Boole’schen Funktionen wie ⇒ (Implikation) und ⇐⇒
(Äquivalenz), die sich einfach auf die bisher bekannten zurückführen lassen.
a⇒b
=
¬a ∨ b
a ⇐⇒ b
=
(a ⇒ b) ∧ (b ⇒ a)
Zur Übung: Wie sehen die Wahrheitstafeln von ⇒ und ⇐⇒ aus? Beachte: ⇐⇒ formalisiert
die Gleichheit Boole’scher Werte.
B.3.2. Prädikatenlogik
Formeln sind natürlich nicht nur aus False, True, ∧ etc. zusammengesetzt. Interessant wird es erst,
wenn man auch Aussagen über Individuen machen kann, wie z.B. in x ∈ M ⇒ x ∈ N . Hier ist x eine
Individuenvariable, ein Platzhalter für Individuen. Häufig macht man Aussagen, daß es mindestens
ein Individuum mit einer bestimmten Eigenschaft gibt, oder daß eine Aussage für alle Individuen
gilt. Zu diesem Zweck verwendet man den Existenzquantor (∃) und den Allquantor (∀).
(∃x) Φ(x)
(∀x) Φ(x)
Hier ist Φ(X) irgendeine Aussage über x, z.B. Φ(x) = x ∈ M ⇒ x ∈ N . Gebräuchlich sind auch
Quantoren, bei denen der Individuenbereich direkt angegeben wird:
(∃x ∈ M ) Φ(x)
⇐⇒
(∃x) x ∈ M ∧ Φ(x)
(∀x ∈ M ) Φ(x)
⇐⇒
(∀x) x ∈ M ⇒ Φ(x)
B.3.3. Natürliche und vollständige Induktion
Nehmen wir an, Φ(n) ist eine Eigenschaft, die wir für alle natürlichen Zahlen n nachweisen wollen.
Dazu kann man das Prinzip der natürlichen Induktion 2 verwenden:
Induktionsbasis: Wir zeigen Φ(0).
Induktionsschritt: Wir nehmen an, daß Φ(n) gilt (Induktionsvoraussetzung) und zeigen unter
dieser Annahme, daß auch Φ(n + 1) gilt.
2 Andere
Bücher, andere Begriffe: „Natürliche Induktion“ wird auch „mathematische Induktion“ genannt.
Dieser Begriff sagt allerdings wenig aus: Sind andere Induktionsprinzipien etwa unmathematisch? Leider
ist auch der Name „vollständige Induktion“ gebräuchlich. Leider deshalb, weil wir ihn für ein anderes
Induktionsprinzip reservieren.
168
B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?
Diese Beweisregel kann man formal wie folgt darstellen:
Φ(0)
(∀n ∈ N) Φ(n) ⇒ Φ(n + 1)
(∀n ∈ N) Φ(n)
Über dem Strich werden die Voraussetzungen aufgeführt, unter dem Strich steht die Schlußfolgerung,
die man daraus ziehen kann. Die Beweisregel kann man auch als Arbeitsprogramm lesen (von unten
nach oben): Um · · · zu zeigen, muß man · · · zeigen.
Warum leistet die natürliche Induktion das Gewünschte? Oder: Warum ist die natürliche Induktion korrekt. Klar, es gilt Φ(0), mit dem Induktionsschritt erhalten wir Φ(1), durch erneute
Anwendung Φ(2) etc. Auf diese Weise erhalten wir Φ(n) für jede natürliche Zahl.
Wir demonstrieren das Induktionsprinzip an einem einfachen Beispiel:
Φ(n)
n
X
⇐⇒
2i = 2n+1 − 1
i=0
Induktionsbasis: Die Rechnung ist einfach
n+1
X
2i
=
2n+1 +
i=0
=
=
n
X
2
n+2
i=0
2i = 1 = 21 − 1. Induktionsschritt:
2i
i=0
n+1
2n+1 + 2
P0
−1
−1
(Arithmetik)
(Induktionsvoraussetzung)
(Arithmetik)
Bei der natürlichen Induktion stützt man sich beim Induktionsschritt auf die Voraussetzung ab,
daß die Aussage für den unmittelbaren Vorgänger gilt. Wir können großzügiger sein und verwenden, daß die Aussage für alle kleineren Zahlen gilt. Dies formalisiert das Prinzip der vollständigen
Induktion.
(∀m) ((∀m0 < m) Φ(m0 )) ⇒ Φ(m)
(∀n) Φ(n)
Zur Übung: Sind beide Induktionsprinzipien gleich mächtig?
Literaturverzeichnis
[Bir98]
Richard Bird. Introduction to functional programming using Haskell. Prentice Hall, 2nd
edition, 1998.
[GKP94] Ronald L. Graham, Donald E. Knuth, and Oren Patashnik.
Addison-Wesley, 2nd edition, 1994. A
Concrete Mathematics.
[Hud12] Paul Hudak. The Haskell School of Music. 2.4 edition, 2012. Available from: http:
//www.cs.yale.edu/homes/hudak/Papers/HSoM.pdf.
[Tho11]
Simon Thompson. Haskell: The Craft of Functional Programming. Addison Wesley, 3rd
edition, 2011.
Index
– Symbole –
Ω . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .92
Θ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91, 92
∩ . . . . . . . . . . . . . . . . . . . . . . . siehe Durchschnitt
∪ . . . . . . . . . . . . . . . . . . . . . . . . .siehe Vereinigung
∅ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
∃ . . . . . . . . . . . . . . . . . . . . siehe Existenzquantor
∀ . . . . . . . . . . . . . . . . . . . . . . . . . . siehe Allquantor
N . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
R . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
¬ . . . . . . . . . . . . . . . . . . . . . . . . . . . siehe Negation
× . . . . . . . . . . . . . . . siehe Cartesisches Produkt
] . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
∨ . . . . . . . . . . . . . . . . . . . . . . siehe logisches UND
∧ . . . . . . . . . . . . . . . . . . . . siehe logisches ODER
–A–
Abkürzungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
Abseitsregel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
Absorption . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Abstraktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
accumArray . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
8-Damen-Problem . . . . . . . . . . . . . . . . . . . . . . 119
place . . . . . . . . . . . . . . . . . . . . . . . . . .119–120
queens . . . . . . . . . . . . . . . . . . . . . . . . . . . . .119
Addition von Matrizen <++> . . . . . . . . . . 133
Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
Allquantor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
Aminosäuren . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
analyseORFs . . . . . . . . . . . . . . . . . . . . . . . . . 26–28
Angewandte Informatik . . . . . . . . . . . . . . . . . . . 9
antimonoton . . . . . . . . . . . . . . . . . . . . . . . . . . . .123
Äquivalenz . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
arithmetische Folgen . . . . . . . . . . . . . . . . . . . .117
arithmetische Operationen . . . . . . . . . . . . . . . 33
Array . . . . . . . . . . . . . . . . . . . . . . . . . . . . siehe Feld
ascRun . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
Assoziativität . . . . . . . . . . . . . . . . . . . . . . . . . . 166
asymptotisch optimal . . . . . . . . . . . . . . . . . . . 100
asymptotische Komplexität eines Problems 99
asymptotisches Verhalten . . . . . . . . . . . . . . . . 91
asymptotisches Wachstum . . . . . . . . . . . . . . . 91
Ausdruck . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . 48, 62
average case . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
–B–
bag . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
Basen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
best case . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
β-Regel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59–60
Bezeichner
alphanumerisch . . . . . . . . . . . . . . . . . . . . . 49
symbolisch . . . . . . . . . . . . . . . . . . . . . . . . . .49
Bildbereich . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
Binärbaum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
beschriftete Blätter . . . . . . . . . . . . . . . . . 54
binäre Verzweigungen . . . . . . . . . . . . . . . 54
unbeschriftete Blätter . . . . . . . . . . . . . . . 54
Bindungsstärken . . . . . . . . . . . . . . . . . . . . . . . . . 49
Binomialkoeffizienten . . . . . . . . . . . . . . . . . . . 113
Bool . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22, 32
bottom up . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .104
Braun-Bäume . . . . . . . . . . . . . . . . . . . . . . . . 77, 81
Braun-Eigenschaft . . . . . . . . . . . . . . . . . . . . . . . 77
Bruder Jakob . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
build . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56–57
build’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57, 81
172
Index
buildSplit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
–C–
C-Dur Tonleiter . . . . . . . . . . . . . . . . . . . . . . . . . 15
Cartesisches Produkt . . . . . . . . . . . . . . . . . . . 164
case . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50, 58–59
Char . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35–36
Codons . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
–D–
Datenstrukturen und Algorithmen . . . . . . . . 8
Datentypdefinition . . . . . . . . . . . . . . . . . . . 31–32
Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
Definitionsbereich . . . . . . . . . . . . . . . . . . . . . . 165
depth . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .70
deriving . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
descRun . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
disjunkte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
Diskriminatorausdruck . . . . . . . . . . . . . . . . . . .50
Distributivität . . . . . . . . . . . . . . . . . . . . . . . . . . 166
divideAndConquer . . . . . . . . . . . . . . . . . . . . . 126
divisors . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .108
divisors’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 118
DNA-Doppelstrang . . . . . . . . . . . . . . . . . . . . . . 21
DNA-Polymerase . . . . . . . . . . . . . . . . . . . . . . . . 22
doppelte Negation . . . . . . . . . . . . . . . . . . . . . . 166
dropSpaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Dur-Dreiklang . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
Durchschnitt . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
–E–
Effizienzanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . 88
Binäre Suche
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 110
build
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . . 98
buildSplit
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 103
buildTree
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 105
countingSort
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 111
insertionSort . . . . . . . . . . . . . . . . . . . . . . . .88
Laufzeit . . . . . . . . . . . . . . . . . . . . . . 88, 93
Platzbedarf . . . . . . . . . . . . . . . . . . . . . . 94
listSort
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 112
mergeSort
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . . 96
Platzbedarf . . . . . . . . . . . . . . . . . . . . . . 96
smsort
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . 107
sortTree
Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . . 94
Eine Formelsprache für Musik . . . . . . . . . . . 13
Einfache Instanzen: Sequence . . . . . . . . . . . 146
Entscheidungsbäume . . . . . . . . . . . . . . . . . . . 100
Existenzquantor . . . . . . . . . . . . . . . . . . . . . . . . 167
Exonuklease . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
extend . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
–F–
Fallunterscheidung . . . . . . . . . . . . . . . . . . . . . . . 50
Feld . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
foldl . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128
Zeit- Platzbedarf . . . . . . . . . . . . . . . . . . 129
foldl1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
foldm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130–131
foldr . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
Zeit- Platzbedarf . . . . . . . . . . . . . . . . . . 129
foldr1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
foldTree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
Formel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Formeln, die Musik bedeuten . . . . . . . . . . . . 14
Formeltypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
Funktion höherer Ordnung . . . . . . . . . . . . . . .53
Teile- und Herrsche-Prinzip . . . . . . . . 126
Mergesort: msort” . . . . . . . . . . . . . . . 126
Quicksort: qsort . . . . . . . . . . . . . . . . . 126
Funktion(en) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Funktionen als Parameter
binarySearchBy . . . . . . . . . . . . . . . 122–123
isort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
openbinSearchBy . . . . . . . . . . . . . 123–124
Funktionen auf Indextypen . . . . . . . . . . . . . 110
! . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109, 110
Index
173
array . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
assocs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
bounds . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
inRange . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
range . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
Funktionen auf Listen
!! . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27, 34
drop . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
head . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
reverse . . . . . . . . . . . . . . . . . . . . . . . . . . 27, 34
tail . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
take . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
zip . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
Funktionsausdruck . . . . . . . . . . . . . . . . . . . . . . .51
Funktionsbindung . . . . . . . . . . . . . . . . . . . . . . . 45
–G–
Gauß’sche Eliminationsverfahren . . . . . . . 134
deleteMax . . . . . . . . . . . . . . . . . . . . . . . . . 134
gaussElim . . . . . . . . . . . . . . . . . . . . . . . . . 134
solve . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
Gen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
Genetischer Code . . . . . . . . . . . . . . . . . . . . 24–25
gestaffelte Funktionen . . . . . . . . . . . . . . . . . . . 52
Gültigkeitsbereich . . . . . . . . . . . . . . . . . . . . . . . 44
–H–
Halbordnung . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
Halbtöne . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
–I–
Idempotenz . . . . . . . . . . . . . . . . . . . . . . . . . 24, 166
Identitätsfunktion . . . . . . . . . . . . . . . . . . . . . . . 79
if...then...else . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
Implikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
in . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .109
Infix-Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . 49
Informatik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
inneres Produkt zweier Vektoren <.> . . . 132
Instanzdeklarationen . . . . . . . . . . . . . . . . . . . 139
Int . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
Integer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
–K–
Kernsprache . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
Klassendefinition: Sequence . . . . . . . . 144–145
Kommutativität . . . . . . . . . . . . . . . . . . . . . . . . 166
Komplexitätstheorie . . . . . . . . . . . . . . . . . . . . . 87
konkatenierbare Listen . . . . . . . . . . . . . . . . . 150
Konstruktor(en) . . . . . . . 14, 18, 20, 31–32, 49
Kontext . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
–L–
last . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
Lauf . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .106
leaves . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
leaves’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
length . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50, 66
let . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53, 61
linear rekursiv . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
List,Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
listArray . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
Listenbeschreibungen . . . . . . . . . . . . . . 107, 117
Semantik . . . . . . . . . . . . . . . . . . . . . . . . . . 120
Terminologie . . . . . . . . . . . . . . . . . .108, 118
logisches ODER . . . . . . . . . . . . . . . . . . . . . . . . 166
logisches UND . . . . . . . . . . . . . . . . . . . . . . . . . . 166
lokale Definitionen . . . . . . . . . . . . . . . . . . . . . . . 53
–M–
map . . . . . . . . . . . . . . . . . . . . . 108, 117, 132, 135
Maybe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .35
Just . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
Nothing . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
mehrdimensionale Felder . . . . . . . . . . . . . . . 109
member . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42–43
Menge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .163
merge’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
mergeSort
baumfrei . . . . . . . . . . . . . . . . . . . . . . . . . . 103
Modellierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Monoid . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .128
Multimenge . . . . . . . . . . . . . . . . . . . . . . . . . . 64–65
Multiplikation eines Skalars mit einem Vektor
<*> . . . . . . . . . . . . . . . . . . . . . . . . . .132
174
Index
Multiplikation von Matrizen <**> . . . . . 133
multTable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
Muster . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
Musterbindung . . . . . . . . . . . . . . . . . . . . . . . . . . 40
lokale Musterbindung . . . . . . . . . . . . . . . 43
Musterbindungen . . . . . . . . . . . . . . . . . . . . . . . . 46
–N–
n-Tupel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
natürliche Induktion . . . . . . . . . . . . . . . . . . . . 167
negate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
Negation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
Normalform . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
Nukleinsäure . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
Nukleotide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
Praktische Informatik . . . . . . . . . . . . . . . . . . . . . 9
primes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
primes’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 118
Problemkomplexität . . . . . . . . . . . . . . . . . . . . 100
Problemkomplexität
exponentiell . . . . . . . . . . . . . . . . . . . . . . . 101
nicht praktikabel . . . . . . . . . . . . . . . . . . 101
polynomial . . . . . . . . . . . . . . . . . . . . . . . . 101
praktikabel . . . . . . . . . . . . . . . . . . . . . . . . 101
Problemlösung . . . . . . . . . . . . . . . . . . . . . . . . . 101
Programm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
Programmieren . . . . . . . . . . . . . . . . . . . . . . . 8, 13
Proteine . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
–Q–
Queues . . . . . . . . . . . . . . . . . . . . . siehe Schlangen
–O–
O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
of . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50, 58–59
Open Reading Frames(ORFs) . . . . . . . . 26–27
Optimierung von Programmen . . . . . . . . . . 102
mergeSort . . . . . . . . . . . . . . . . . . . . . . . . . 102
bubuild . . . . . . . . . . . . . . . . . . . . . . . . . 105
build” . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
buildLayer . . . . . . . . . . . . . . . . . . . . . . 105
buildTree . . . . . . . . . . . . . . . . . . . . . . . 105
ordered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
otherwise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
–R–
Rechenzeit . . . . . . . . . . . . . . . . . . . . . . . . . . . 87–88
Rechnen in Haskell . . . . . . . . . . . . . . . . . . . . . 114
Rekurrenzgleichungen . . . . . . . . . . . . . . . . . . . .90
Rekursionsschema . . . . . . . . . . . . . . . . . . . . . . 124
Relation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 165
rep . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
Ribosom . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .25
run . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . siehe Lauf
runs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
–S–
–P–
partielle Funktion . . . . . . . . . . . . . . . . . . . . . . 165
Pascalsches Dreieck . . . . . . . . . . . . . . . . . . . . . 112
pascalsTriangle . . . . . . . . . . . . . . . . . . . . . . . . . 113
Pause . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
perms . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
Permutation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
Platzbedarf . . . . . . . . . . . . . . . . . . . . . . . . . . 87–88
polymorph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
polymorphe Funktion . . . . . . . . . . . . . . . . . . . 135
polymorpher Listentyp . . . . . . . . . . . . . . . . . . 21
Potenzmenge . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
power . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
Prädikatenlogik . . . . . . . . . . . . . . . . . . . . . . . . 167
Schlangen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
Selektorfuntkionen . . . . . . . . . . . . . . . . . . . . . . . 33
fst . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
snd . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
Semantik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
show . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Sichtbarkeitsbereich siehe Gültigkeitsbereich
Sieb des Erathosthenes . . . . . . . . . . . . . . . . . 118
primes” . . . . . . . . . . . . . . . . . . . . . . . . . . . .118
sieve . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 118
size . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63–64
Sortieren durch Einfügen . . . . . . . . . . . . . . . . 67
Sortieren durch Fusionieren . . . . . . . . . . . . . . 78
Index
175
Sortierverfahren
countingSort . . . . . . . . . . . . . . . . . . . . . . 111
geschmeidig . . . . . . . . . . . . . . . . . . . . . . . 106
insertionSort . . . . . . . . . . . . . . . . . . . . . . . .67
insert . . . . . . . . . . . . . . . . . . . . . . . . . 67–68
listSort . . . . . . . . . . . . . . . . . . . . . . . 111–112
mergeSort . . . . . . . . . . . . . . . . . . . . . . . . . . 79
build . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
merge . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
sortTree . . . . . . . . . . . . . . . . . . . . . . . . . . 79
Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . .118
qsort” . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
qsort”’ . . . . . . . . . . . . . . . . . . . . . . . . . . 118
smooth . . . . . . . . . . . . . . siehe geschmeidig
smsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . .106
mergeRuns . . . . . . . . . . . . . . . . . . . . . . 107
Stabilität . . . . . . . . . . . . . . . . . . . . . . . . . . 122
splitWord . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
squares’ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
squares” . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117
squeeze . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Stirlngsche Formel . . . . . . . . . . . . . . . . . . . . . . 100
structuralRecursionOnLists . . . . . . . . . . . . . 124
strukturell rekursiv . . . . . . . . . . . . . . . . . . . . . . 66
strukturelle Induktion . . . . . . . . . . . . . . . . . . . 72
allgemeines Schema . . . . . . . . . . . . . . . . . 76
auf Bäumen . . . . . . . . . . . . . . . . . . . . . . . . 74
auf Listen . . . . . . . . . . . . . . . . . . . . . . . . . . 72
strukturelle Induktion transpose . . . 133–134
strukturelle Rekursion . . . . . . . . . . . . . . . . . . . 66
allgemeines Schema . . . . . . . . . . . . . . . . . 70
auf Bäumen . . . . . . . . . . . . . . . . . . . . . . . . 69
auf Listen . . . . . . . . . . . . . . . . . . . . . . . . . . 66
Suchverfahren
Binäre Suche . . . . . . . . . . . . . . . . . . . . . . 111
Summe zweier Vektoren <+> . . . . . . . . . . 132
Syntax . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Syntax und Semantik . . . . . . . . . . . . . . . . . . . . 22
–T–
tabulate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
Technische Informatik . . . . . . . . . . . . . . . . . . . . 9
Teile und Herrsche-Prinzip . . . . . . . . . . . . . . .83
Teilmenge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
Theoretische Informatik . . . . . . . . . . . . . . . . . . 9
top-down Entwurf . . . . . . . . . . . . . . . . . . . . . . . 69
totale Ordnung . . . . . . . . . . . . . . . . . . . . . . . . . 165
translate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
Translation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
Transponierung . . . . . . . . . . . . . . . . . . . . . . . . . . 16
Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
Tupel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .33
Typ(en) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17–18
Typinferenz . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
Typklassen . . . . . . . . . . . . . . . . . . . . . . . . . 38, 136
Eq . . . . . . . . . . . . . . . . . . . . . . . . 38, 137, 139
Fractional . . . . . . . . . . . . . . . . . . . . . . . . . 138
Integral . . . . . . . . . . . . . . . . . . . . . . . . 38, 138
Ix . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107, 110
Num . . . . . . . . . . . . . . . . . 38, 137–138, 142
Ord . . . . . . . . . . . . . . . . . . 38, 136–137, 139
compare . . . . . . . . . . . . . . . . . . . . . . . . 137
Read . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
Show . . . . . . . . . . . . . . . . . 39, 136–137, 141
Typkonstruktor . . . . . . . . . . . . . . . . . . . . . . . . . . 32
Typkontexte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Typparameter . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Typsynonyme . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
Typüberprüfung . . . . . . . . . . . . . . . . . . . . . . . . . 38
–U–
Umkehrung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
undepth . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
unique . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
untere asymptotische Schranke . . . . . . . . . . 92
Untertypklassen . . . . . . . . . . . . . . . . . . . . . . . . 138
–V–
Variablenbindung
Bruder Jakob . . . . . . . . . . . . . . . . . . . . . . . 40
C-Dur Leiter . . . . . . . . . . . . . . . . . . . . . . . 40
Variablenbindungen . . . . . . . . . . . . . . . . . . . . . 39
Vereinigung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 164
Vergleichsoperator . . . . . . . . . . . . . . . . . 135–136
Verstärkung der Induktion . . . . . . . . . . . . . . . 77
Verstärkung der Rekursion . . . . . . . . . . . . . . 71
176
Index
vollständig . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
vollständige Induktion . . . . . . . . . . . . . . . . . . 168
–W–
Wächter . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Watson-Crick-Komplement . . . . . . . . . . . . . . 21
weight . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
Wert . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
where . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43, 44, 54
wohlfundierte Induktion . . . . . . . . . . . . . . . . . 84
wohlfundierte Rekursion . . . . . . . . . . . . . . . . . 82
wohlfundierte Relation . . . . . . . . . . . . . . . . . . .83
words . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
worst case . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
–Z–
zip . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
zipWith . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
Herunterladen