(1/4) :*: c' (1/4) - Technische Fakultät - Universität Bielefeld
(1/4) :*: c' (1/4) - Technische Fakultät - Universität Bielefeld
(1/4) :*: c' (1/4) - Technische Fakultät - Universität Bielefeld
Erfolgreiche ePaper selbst erstellen
Machen Sie aus Ihren PDF Publikationen ein blätterbares Flipbook mit unserer einzigartigen Google optimierten e-Paper Software.
Algorithmen und Datenstrukturen I<br />
phrase1<br />
phrase2<br />
=<br />
c’<br />
e’<br />
c’<br />
e’<br />
c’ (1/4)<br />
(1/4)<br />
(1/4)<br />
(1/4)<br />
(1/4) :*:<br />
:*:<br />
:*:<br />
:*:<br />
:*: d’<br />
f’<br />
d’<br />
f’<br />
d’ (1/4)<br />
(1/4)<br />
(1/4)<br />
(1/4)<br />
(1/4) :*:<br />
:*:<br />
:*:<br />
:*:<br />
:*: e’<br />
g’<br />
e’<br />
g’<br />
e’ (1/4)<br />
(1/2)<br />
(1/4)<br />
(1/2)<br />
(1/4) :*: c’ (1/4)<br />
phrase3 = g’<br />
e’<br />
g’<br />
e’<br />
g’ (1/8)<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
:*:<br />
:*:<br />
:*:<br />
:*:<br />
a’<br />
f’<br />
a’<br />
f’<br />
(1/8)<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
:*:<br />
:*:<br />
:*:<br />
:*:<br />
g’<br />
g’<br />
g’<br />
g’<br />
(1/8)<br />
(1/2)<br />
(1/8)<br />
(1/2)<br />
:*:<br />
g’<br />
e’<br />
g’<br />
e’<br />
(1/8)<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
e’ (1/4)<br />
:*:<br />
:*:<br />
:*:<br />
:*:<br />
a’<br />
:*:<br />
a’<br />
f’<br />
a’<br />
f’<br />
(1/8)<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
c’ (1/4)<br />
:*:<br />
:*:<br />
:*:<br />
:*:<br />
g’<br />
g’<br />
g’<br />
g’<br />
(1/8)<br />
(1/2)<br />
(1/8)<br />
(1/2)<br />
:*:<br />
g’<br />
e’<br />
g’<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
(1/8)<br />
:*:<br />
e’ (1/4)<br />
:*:<br />
:*:<br />
:*:<br />
f’<br />
:*:<br />
a’<br />
f’<br />
a’<br />
(1/4)<br />
(1/8)<br />
(1/4)<br />
(1/8)<br />
:*: g’ (1/2)<br />
c’ (1/4)<br />
:*:<br />
:*:<br />
:*: g’<br />
g’<br />
g’ (1/8)<br />
(1/2)<br />
(1/8) :*: f’ (1/8)<br />
phrase4 = c’<br />
:*:<br />
(1/8) :*: a’ (1/8) :*: g’ (1/8) :*: f’ (1/8)<br />
c’<br />
:*:<br />
g’<br />
:*:<br />
g’ (1/8)<br />
(1/4)<br />
e’ (1/4)<br />
:*:<br />
:*: (transponiere<br />
:*:<br />
(transponiere<br />
:*:<br />
a’<br />
:*:<br />
a’ (1/8)<br />
c’ (1/4)<br />
:*: g’ (1/8) :*: f’ (1/8)<br />
c’<br />
:*:<br />
g’ (1/8)<br />
(1/4)<br />
e’ (1/4)<br />
:*: a’<br />
:*: (transponiere<br />
:*:<br />
a’ (1/8)<br />
c’ (1/4)<br />
:*: g’ (1/8) :*: f’ (1/8)<br />
strophe<br />
phrase4<br />
strophe<br />
phrase4<br />
= wdh<br />
c’<br />
wdh<br />
c’ (1/4)<br />
phrase1<br />
:*:<br />
:*:<br />
(transponiere<br />
wdh phrase2<br />
(-12)<br />
:*: wdh<br />
(g’<br />
phrase3<br />
(1/4)))<br />
:*:<br />
:*:<br />
wdh<br />
c’’<br />
phr<br />
(1<br />
endlos<br />
strophe<br />
endlos<br />
strophe<br />
= wdh phrase1 :*: wdh phrase2 :*: wdh phrase3 :*: wdh phr<br />
phrase1 c’ (1/4) :*: d’ (1/4) :*: e’ (1/4) :*: c’ (1/4)<br />
ad_infinitum<br />
wdh phrase1 :*:<br />
strophe<br />
wdh phrase2 :*: wdh phrase3 :*: wdh phr<br />
bruderJakob<br />
endlos<br />
bruderJakob<br />
endlos<br />
wdh phrase1 :*: wdh phrase2 :*: wdh phrase3 :*: wdh phr<br />
= Tempo<br />
ad_infinitum<br />
Tempo<br />
ad_infinitum<br />
wdh<br />
ad_infinitum<br />
wdh phrase1 :*:<br />
andante<br />
strophe<br />
wdh phrase2 :*: wdh phrase3 :*: wdh phr<br />
Tempo<br />
ad_infinitum<br />
wdh phrase1 :*:<br />
andante<br />
strophe<br />
wdh phrase2 :*: wdh phrase3 :*: wdh phr<br />
Tempo<br />
(<br />
Tempo<br />
ad_infinitum<br />
Tempo<br />
ad_infinitum<br />
andante<br />
strophe<br />
(<br />
Tempo<br />
ad_infinitum<br />
Tempo<br />
ad_infinitum<br />
Tempo<br />
ad_infinitum<br />
Tempo<br />
ad_infinitum<br />
Tempo andante<br />
strophe<br />
einsatz<br />
andante<br />
strophe<br />
einsatz<br />
andante<br />
strophe<br />
(0/1)<br />
(Instr<br />
endlos<br />
VoiceAahs<br />
(einsatz<br />
(<br />
(einsatz<br />
(<br />
Tempo<br />
(<br />
Tempo andante (Instr VoiceAahs<br />
(einsatz<br />
(<br />
Tempo<br />
einsatz<br />
andante<br />
(0/1)<br />
(Instr VoiceAahs<br />
(einsatz<br />
einsatz<br />
andante<br />
(0/1)<br />
(Instr<br />
(2/1)<br />
endlos<br />
VoiceAahs<br />
(einsatz<br />
einsatz<br />
andante<br />
(0/1)<br />
(Instr<br />
(2/1)<br />
endlos<br />
VoiceAahs<br />
(einsatz (2/1) (transponiere 12 endlos))<br />
:+:<br />
(einsatz<br />
(einsatz<br />
(4/1)<br />
(2/1)<br />
endlos)<br />
(transponiere 12 endlos)) :+:<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(6/1)<br />
(4/1)<br />
(6/1)<br />
(4/1)<br />
(2/1)<br />
(4/1)<br />
(2/1)<br />
endlos<br />
endlos)<br />
endlos<br />
endlos)<br />
(transponiere<br />
endlos)<br />
(transponiere 12 endlos))<br />
(einsatz<br />
(einsatz<br />
(einsatz<br />
(6/1)<br />
(4/1)<br />
(2/1)<br />
endlos<br />
endlos)<br />
(transponiere 12 endlos))<br />
(einsatz (6/1) endlos )))<br />
:+:<br />
Robert Giegerich · Ralf Hinze · <strong>Universität</strong> <strong>Bielefeld</strong>
A&D interaktiv<br />
<strong>Universität</strong> <strong>Bielefeld</strong><br />
<strong>Technische</strong> <strong>Fakultät</strong><br />
WS 2003/2004
Inhaltsverzeichnis<br />
1. Einleitung 1<br />
1.1. Rechnen und rechnen lassen . . . . . . . . . . . . . . . . . . . . . . . . . . 1<br />
1.2. Die Aufgabengebiete der Informatik . . . . . . . . . . . . . . . . . . . . . 3<br />
1.3. Einordnung der Informatik in die Familie der Wissenschaften . . . . . . . 5<br />
2. Modellierung 7<br />
2.1. Eine Formelsprache für Musik . . . . . . . . . . . . . . . . . . . . . . . . . 7<br />
2.2. Typen als Hilfsmittel der Modellierung . . . . . . . . . . . . . . . . . . . . 11<br />
2.3. Die Rolle der Abstraktion in der Modellierung . . . . . . . . . . . . . . . . 13<br />
2.4. Modellierung in der molekularen Genetik . . . . . . . . . . . . . . . . . . 14<br />
2.5. Anforderungen an Programmiersprachen . . . . . . . . . . . . . . . . . . . 23<br />
3. Eine einfache Programmiersprache 25<br />
3.1. Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25<br />
3.1.1. Datentypdefinitionen . . . . . . . . . . . . . . . . . . . . . . . . . . 25<br />
3.1.2. Typsynonyme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30<br />
3.1.3. Typdeklarationen, Typprüfung und Typinferenz . . . . . . . . . . . 30<br />
3.1.4. Typklassen und Typkontexte . . . . . . . . . . . . . . . . . . . . . . 32<br />
3.2. Wertdefinitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34<br />
3.2.1. Muster- und Funktionsbindungen . . . . . . . . . . . . . . . . . . . 34<br />
3.2.2. Bewachte Gleichungen . . . . . . . . . . . . . . . . . . . . . . . . . 36<br />
3.2.3. Gleichungen mit lokalen Definitionen . . . . . . . . . . . . . . . . 37<br />
3.2.4. Das Rechnen mit Gleichungen . . . . . . . . . . . . . . . . . . . . . 40<br />
3.2.5. Vollständige und disjunkte Muster . . . . . . . . . . . . . . . . . . 41<br />
3.3. Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43<br />
3.3.1. Variablen, Funktionsanwendungen und Konstruktoren . . . . . . . 43<br />
3.3.2. Fallunterscheidungen . . . . . . . . . . . . . . . . . . . . . . . . . . 44<br />
3.3.3. Funktionsausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . 46<br />
3.3.4. Lokale Definitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . 48<br />
3.4. Anwendung: Binärbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
ii Inhaltsverzeichnis<br />
3.5. Vertiefung: Rechnen in Haskell . . . . . . . . . . . . . . . . . . . . . . . . 53<br />
3.5.1. Eine Kernsprache/Syntaktischer Zucker . . . . . . . . . . . . . . . . 53<br />
3.5.2. Auswertung von Fallunterscheidungen . . . . . . . . . . . . . . . . 53<br />
3.5.3. Auswertung von Funktionsanwendungen . . . . . . . . . . . . . . 54<br />
3.5.4. Auswertung von lokalen Definitionen . . . . . . . . . . . . . . . . . 56<br />
4. Programmiermethodik 59<br />
4.1. Spezifikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59<br />
4.2. Strukturelle Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62<br />
4.2.1. Strukturelle Rekursion auf Listen . . . . . . . . . . . . . . . . . . . 62<br />
4.2.2. Strukturelle Rekursion auf Bäumen . . . . . . . . . . . . . . . . . . 66<br />
4.2.3. Das allgemeine Rekursionsschema . . . . . . . . . . . . . . . . . . 67<br />
4.2.4. Verstärkung der Rekursion . . . . . . . . . . . . . . . . . . . . . . . 67<br />
4.3. Strukturelle Induktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69<br />
4.3.1. Strukturelle Induktion auf Listen . . . . . . . . . . . . . . . . . . . 69<br />
4.3.2. Strukturelle Induktion auf Bäumen . . . . . . . . . . . . . . . . . . 72<br />
4.3.3. Das allgemeine Induktionsschema . . . . . . . . . . . . . . . . . . 73<br />
4.3.4. Verstärkung der Induktion . . . . . . . . . . . . . . . . . . . . . . . 74<br />
4.3.5. Referential transparency . . . . . . . . . . . . . . . . . . . . . . . . 76<br />
4.4. Anwendung: Sortieren durch Fusionieren . . . . . . . . . . . . . . . . . . 76<br />
4.4.1. Phase 2: Sortieren eines Binärbaums . . . . . . . . . . . . . . . . . 76<br />
4.4.2. Phase 1: Konstruktion von Braun-Bäumen . . . . . . . . . . . . . . 78<br />
4.5. Wohlfundierte Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80<br />
4.5.1. Das Schema der wohlfundierten Rekursion . . . . . . . . . . . . . . 80<br />
4.6. Vertiefung: Wohlfundierte Induktion . . . . . . . . . . . . . . . . . . . . . 82<br />
5. Effizienz und Komplexität 85<br />
5.1. Grundlagen der Effizienzanalyse . . . . . . . . . . . . . . . . . . . . . . . . 85<br />
5.1.1. Maßeinheiten für Zeit und Raum beim Rechnen . . . . . . . . . . . 85<br />
5.1.2. Detaillierte Analyse von insertionSort . . . . . . . . . . . . . . 86<br />
5.1.3. Asymptotische Zeit- und Platzeffizienz . . . . . . . . . . . . . . . . 89<br />
5.2. Effizienz strukturell rekursiver Funktionen . . . . . . . . . . . . . . . . . . 92<br />
5.3. Effizienz wohlfundiert rekursiver Funktionen . . . . . . . . . . . . . . . . . 97<br />
5.4. Problemkomplexität . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98<br />
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort . . . 101<br />
5.5.1. Varianten der Teile-Phase . . . . . . . . . . . . . . . . . . . . . . . 102<br />
5.5.2. Berücksichtigung von Läufen . . . . . . . . . . . . . . . . . . . . . 106<br />
5.6. Datenstrukturen mit konstantem Zugriff: Felder . . . . . . . . . . . . . . . 107<br />
5.7. Anwendung: Ein lineares Sortierverfahren . . . . . . . . . . . . . . . . . . 112<br />
5.8. Vertiefung: Rolle der Auswertungsreihenfolge . . . . . . . . . . . . . . . . 115
Inhaltsverzeichnis iii<br />
6. Abstraktion 117<br />
6.1. Listenbeschreibungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117<br />
6.2. Funktionen höherer Ordnung . . . . . . . . . . . . . . . . . . . . . . . . . 121<br />
6.2.1. Funktionen als Parameter . . . . . . . . . . . . . . . . . . . . . . . 121<br />
6.2.2. Rekursionsschemata . . . . . . . . . . . . . . . . . . . . . . . . . . 125<br />
6.2.3. foldr und Kolleginnen . . . . . . . . . . . . . . . . . . . . . . . . 128<br />
6.2.4. map und Kolleginnen . . . . . . . . . . . . . . . . . . . . . . . . . . 133<br />
6.3. Typklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137<br />
6.3.1. Typpolymorphismus und Typklassen . . . . . . . . . . . . . . . . . 137<br />
6.3.2. class- und instance-Deklarationen . . . . . . . . . . . . . . . . 140<br />
6.3.3. Die Typklassen Eq und Ord . . . . . . . . . . . . . . . . . . . . . . 141<br />
6.3.4. Die Typklassen Show und Read . . . . . . . . . . . . . . . . . . . . 143<br />
6.3.5. Die Typklasse Num . . . . . . . . . . . . . . . . . . . . . . . . . . . 145<br />
6.4. Anwendung: Sequenzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148<br />
6.4.1. Klassendefinition . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148<br />
6.4.2. Einfache Instanzen . . . . . . . . . . . . . . . . . . . . . . . . . . . 150<br />
6.4.3. Generische Instanzen . . . . . . . . . . . . . . . . . . . . . . . . . . 152<br />
6.4.4. Schlangen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152<br />
6.4.5. Konkatenierbare Listen . . . . . . . . . . . . . . . . . . . . . . . . . 154<br />
A. Lösungen aller Übungsaufgaben 155<br />
B. Mathematische Grundlagen<br />
oder: Wieviel Mathematik ist nötig? 167<br />
B.1. Mengen und Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167<br />
B.1.1. Der Begriff der Menge . . . . . . . . . . . . . . . . . . . . . . . . . 167<br />
B.1.2. Der Begriff der Funktion . . . . . . . . . . . . . . . . . . . . . . . . 169<br />
B.2. Relationen und Halbordnungen . . . . . . . . . . . . . . . . . . . . . . . . 169<br />
B.3. Formale Logik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170<br />
B.3.1. Aussagenlogik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170<br />
B.3.2. Prädikatenlogik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 171<br />
B.3.3. Natürliche und vollständige Induktion . . . . . . . . . . . . . . . . 172
iv Inhaltsverzeichnis
1. Einleitung<br />
1.1. Rechnen und rechnen lassen<br />
Man kann vorzüglich Rechnen lernen, ohne sich jemals zu fragen, was denn das Rechnen<br />
vom sonstigen Gebrauch des Verstandes unterscheidet. Wir stellen diese Frage jetzt und<br />
betrachten dazu das Rechnen so wie es uns im Leben zuerst begegnet — als Umgang mit<br />
den Zahlen. Wir werden also die Natur des Rechnens an der Arithmetik studieren, und<br />
dabei am Ende feststellen, daß die Zahlen bei weitem nicht das Einzige sind, womit wir<br />
rechnen können.<br />
Zweifellos ist Rechnen ein besonderer Gebrauch des Verstandes. Eine gewisse Ahnung<br />
vom Unterschied zwischen Denken im allgemeinen und Rechnen im besonderen hat jeder,<br />
der einmal mit seinem Mathematiklehrer darüber diskutiert hat, ob der Fehler in seiner<br />
Schularbeit „bloß“ als Rechenfehler, oder aber als „logischer“ Fehler einzuordnen sei. Grit Garbo ist Mathematikerin der alten Schule. Für sie gibt es<br />
Richtig Rechnen heißt Anwendung der Rechenregeln für Addition, Multiplikation etc.<br />
Dies allein garantiert das richtige Ergebnis — und neben ihrer Beachtung und Anwendung<br />
ist als einzige weitere Verstandesleistung die Konzentration auf diese eintönige Tätigkeit<br />
gefragt. Kein Wunder, daß nur wenige Menschen zum Zeitvertreib siebenstellige Zahlen<br />
multiplizieren! 1<br />
Damit soll nicht etwa das Rechnen diffamiert werden — es entspricht so gerade der<br />
Natur dessen, worum es dabei geht, nämlich den Zahlen. Diese sind Größen, abstrakte<br />
Quantitäten ohne sonstige Eigenschaft. Bringe ich sie in Verbindung, ergeben sich neue,<br />
andere Größen, aber keine andere Qualität. Der Unterschied von 15 und 42 ist 27, und<br />
daraus folgt — nichts. Die mathematische Differenz zweier Größen ist, so könnte man<br />
sagen, der unwesentliche Unterschied.<br />
Nur zum Vergleich: Stelle ich meinen Beitrag zum Sportverein und meine Einkommenssteuer<br />
einander gegenüber, so ergibt sich auch ein Unterschied im Betrag. Daneben aber<br />
auch einer in der Natur der Empfänger, dem Grad der Freiwilligkeit, meine Einflußmöglichkeiten<br />
auf die Verwendung dieser Gelder und vieles andere mehr. Dies sind wesentliche<br />
Unterschiede, aus ihnen folgt allerhand. Unter anderem erklären sie auch die Differenz<br />
der Beträge, und rechnen sie nicht bloß aus.<br />
Für das Rechnen jedoch spielt es keine Rolle, wovon eine Zahl die Größe angibt. Jede<br />
sonstige Beschaffenheit der betrachteten Objekte ist für das Rechnen mit ihrer Größe oh-<br />
1 Denkaufgabe: Was folgt daraus, wenn einer gut rechnen kann?<br />
eigentlich keine Wissenschaft der Informatik — soweit Informatik<br />
wissenschaftlich ist, gehört sie zur Mathematik, alles andere<br />
ist Ingeniertechnik oder Programmierhandwerk. Fragen der<br />
Programmiersprachen oder des Software Engineering sieht sie<br />
als bloße Äußerlichkeiten an. Sie kann es nicht ganz verschmerzen,<br />
daß die Mathematik von ihrer Tochter Informatik heute in<br />
vielerlei Hinsicht überflügelt wird.
Harry Hacker ist ein alter Programmierfuchs, nach dem Motto:<br />
Eine Woche Programmieren und Testen kann einen ganzen<br />
Nachmittag Nachdenken ersetzen. Er ist einfallsreich und ein<br />
gewitzter Beobachter; allerdings liegt er dabei oft haarscharf<br />
neben der Wahrheit. Zu Programmiersprachen hat er eine dezidiert<br />
subjektive Haltung: Er findet die Sprache am besten, die<br />
ihm am besten vertraut ist.<br />
2 1. Einleitung<br />
ne Belang. Deshalb kann das Rechnen in starre Regeln gegossen werden und nimmt darin<br />
seinen überraschungsfreien Verlauf. Die kreative gedankliche Leistung liegt woanders: Sie<br />
manifestiert sich in der Art und Weise, wie man die abstrakten Objekte der Zahlen konkret<br />
aufschreibt. Wir sind daran gewöhnt, das arabische Stellenwertsystem zu verwenden.<br />
Dieses hat sich im Laufe der Geschichte durchgesetzt, da sich darauf relativ einfach Rechenregeln<br />
definieren und auch anwenden lassen. Das römische Zahlensystem mit seinen<br />
komplizierten Einheiten und der Vor- und Nachstellung blieb auf der Strecke: Den Zahlen<br />
XV und XLII sieht man den Unterschied XXVII nicht an. Insbesondere das Fehlen eines<br />
Symbols für die Null macht systematisches Rechnen unmöglich — gerade wegen der Null<br />
war das Rechnen mit den arabischen Zahlen im Mittelalter von der Kirche verboten.<br />
Einstweiliges Fazit: Das Rechnen ist seiner Natur nach eine äußerliche, gedankenlose, in<br />
diesem Sinne mechanische Denktätigkeit. Kaum hatte die Menschheit Rechnen gelernt,<br />
schon tauchte die naheliegende Idee auf, das Rechnen auf eine Maschine zu übertragen.<br />
Die Geschichte dieser Idee — von einfachen Additionsmaschinen bis hin zu den ersten<br />
programmgesteuerten Rechenautomaten — ist an vielen Stellen beschrieben worden und<br />
soll hier nicht angeführt werden. An ihrem Endpunkt steht der Computer, der bei entsprechender<br />
Programmierung beliebig komplexe arithmetische Aufgaben lösen kann —<br />
schneller als wir, zuverlässiger als wir und (für uns) bequemer.<br />
Wenn dieses Gerät existiert, stellt sich plötzlich eine ganz neue Frage: Jetzt, wo wir<br />
rechnen lassen können, wird es interessant, Aufgaben in Rechenaufgaben zu verwandeln,<br />
die es von Natur aus nicht sind (und die wir mit unserem Verstand auch nie so behandeln<br />
würden). Es gilt, zu einer Problemstellung die richtigen Abstraktionen zu finden, sie durch<br />
Formeln und Rechengesetze, genannt Datenstrukturen und Algorithmen, so weitgehend<br />
zu erfassen, daß wir dem Ergebnis der Rechnung eine Lösung des Problems entnehmen<br />
können. Wir bilden also abstrakte Modelle der konkreten Wirklichkeit, die diese im Falle<br />
der Arithmetik perfekt, in den sonstigen Fällen meist nur annähernd wiedergeben. Die<br />
Wirklichkeitstreue dieser Modelle macht den Reiz und die Schwierigkeit dieser Aufgabe,<br />
und die Verantwortung des Informatikers bei der Software-Entwicklung aus.<br />
Die eigentümliche Leistung der Informatik ist es also, Dinge in Rechenaufgaben zu verwandeln,<br />
die es von Natur aus nicht sind. Die Fragestellung „Was können wir rechnen<br />
lassen?“, ist neu in der Welt der Wissenschaften — deshalb hat sich die Informatik nach<br />
der Konstruktion der ersten Rechner schnell aus dem Schoße der Mathematik heraus zu<br />
einer eigenständigen Disziplin entwickelt. Die Erfolge dieser Bemühung sind faszinierend,<br />
und nicht selten, wenn auch nicht ganz richtig, liest man darüber in der Zeitung: „Computer<br />
werden immer schlauer“.
1.2. Die Aufgabengebiete der Informatik 3<br />
1.2. Die Aufgabengebiete der Informatik<br />
Informatik ist die Wissenschaft vom maschinellen Rechnen. Daraus ergeben sich verschiedene<br />
Fragestellungen, in die sich die Informatik aufgliedert.<br />
Zunächst muß sie sich um die Rechenmaschine selbst kümmern. In der <strong>Technische</strong>n Informatik<br />
geht es um Rechnerarchitektur und Rechnerentwurf, wozu auch die Entwicklung<br />
von Speichermedien, Übertragungskanälen, Sensoren usw. gehören. Der Fortschritt dieser<br />
Disziplin besteht in immer schnelleren und kleineren Rechnern bei fallenden Preisen. In<br />
ihren elementaren Operationen dagegen sind die Rechner heute noch so primitiv wie zur<br />
Zeit ihrer Erfindung. Diese für den Laien überraschende Tatsache erfährt ihre Erklärung in<br />
der theoretischen Abteilung der Informatik. Lisa Lista ist die ideale Studentin, den Wunschvorstellungen ei-<br />
Mit der Verfügbarkeit der Computer erfolgt eine Erweiterung des Begriffs „Rechnen“.<br />
Das Rechnen mit den guten alten Zahlen ist jetzt nur noch ein hausbackener Sonderfall.<br />
Jetzt werden allgemeinere Rechenverfahren entwickelt, genannt Algorithmen, die<br />
aus Eingabe-Daten die Ausgabe-Daten bestimmen. Aber — was genau läßt sich rechnen,<br />
und was nicht? Mit welcher Maschine? Die Theoretische Informatik hat diese Frage beantwortet<br />
und gezeigt, daß es nicht berechenbare Probleme 2 gibt, d.h. Aufgaben, zu deren<br />
Lösung es keinen Algorithmus gibt. Zugleich hat sie gezeigt, daß alle Rechner prinzipiell<br />
gleichmächtig sind, sofern sie über einige wenige primitive Operationen verfügen. Dagegen<br />
erweisen sich die berechenbaren Aufgaben als unterschiedlich aufwendig, so daß die<br />
Untersuchung des Rechenaufwands, Komplexitätstheorie, heute ein wichtiges Teilgebiet<br />
der Theoretischen Informatik darstellt.<br />
Zwischen den prinzipiellen Möglichkeiten des Rechnens und dem Rechner mit seinen<br />
primitiven Operationen klafft eine riesige Lücke. Die Aufgabe der Praktischen Informatik<br />
ist es, den Rechner für Menschen effektiv nutzbar zu machen. Die „semantische Lücke“<br />
wird Schicht um Schicht geschlossen durch Programmiersprachen auf immer höherer Abstraktionsstufe.<br />
Heute können wir Algorithmen auf der Abstraktionsstufe der Aufgabenstellung<br />
entwickeln und programmieren, ohne die ausführende Maschine überhaupt zu<br />
kennen. Dies ist nicht nur bequem, sondern auch wesentliche Voraussetzung für die Übertragbarkeit<br />
von Programmen zwischen verschiedenen Rechnern. Ähnliches gilt für die Benutzung<br />
der Rechner (Betriebssysteme, Benutzeroberflächen), für die Organisation großer<br />
Datenmengen (Datenbanken) und sogar für die Kommunikation der Rechner untereinander<br />
(Rechnernetze, Verteilte Systeme). Das World Wide Web (WWW) ist das jüngste und<br />
bekannteste Beispiel für das Überbrücken der semantischen Lücke: Es verbindet einfach-<br />
2 Man kann solche Aufgaben auch als Ja/Nein-Fragestellungen formulieren und nennt sie dann „formal unentscheidbare“<br />
Probleme. Läßt man darin das entscheidende Wörtchen „formal“ weg, eröffnen sich tiefsinnige,<br />
aber falsche Betrachtungen über Grenzen der Erkenntnis.<br />
nes überarbeitetenn Hochschullehrers entsprungen. Sie ist unvoreingenommen<br />
und aufgeweckt, greift neue Gedanken auf<br />
und denkt sie in viele Richtungen weiter. Kein Schulunterricht<br />
in Informatik hat sie verdorben. Lisa Lista ist 12 Jahre alt.
Peter Paneau ist Professor der Theoretischen Informatik. Er<br />
sieht die Informatik im wesentlichen als eine formale Disziplin.<br />
Am vorliegenden Text stört ihn die saloppe Redeweise und die<br />
Tatsache, daß viele tiefgründige theoretische Probleme grob vereinfacht<br />
oder einfach umgangen werden. An solchen Stellen erhebt<br />
er mahnend der Zeigefinger . . .<br />
4 1. Einleitung<br />
ste Benutzbarkeit mit globalem Zugriff auf Informationen und andere Ressourcen in aller<br />
Welt. Heute bedarf es nur eines gekrümmten Zeigefingers, um tausend Computer in aller<br />
Welt für uns arbeiten zu lassen.<br />
In der Angewandten Informatik kommt endlich der Zweck der ganzen Veranstaltung<br />
zum Zuge. Sie wendet die schnellen Rechner, die effizienten Algorithmen, die höheren<br />
Programmiersprachen und ihre Übersetzer, die Datenbanken, die Rechnernetze usw. an,<br />
um Aufgaben jeglicher Art sukzessive immer vollkommener zu lösen. Je weiter die Informatik<br />
in die verschiedensten Anwendungsgebiete vordringt, desto spezifischer werden<br />
die dort untersuchten Fragen. Vielleicht werden wir bald erleben, daß sich interdisziplinäre<br />
Anwendungen als eigenständige Disziplinen von der „Kerninformatik“ abspalten.<br />
Wirtschaftsinformatik und Bioinformatik sind mögliche Protagonisten einer solchen Entwicklung.<br />
Während es früher Jahrhunderte gedauert hat, bis aus den Erfolgen der Physik<br />
zunächst die anderen Natur-, dann die Ingenieurwissenschaften entstanden, so laufen<br />
heute solche Entwicklungen in wenigen Jahrzehnten ab.<br />
Wenn die Rechner heute immer mehr Aufgaben übernehmen, für die der Mensch den<br />
Verstand benutzt, so ist insbesondere für Laien daran längst nicht mehr erkennbar, auf<br />
welche Weise dies erreicht wird. Es entsteht der Schein, daß das analoge Resultat auch<br />
in analoger Weise zustande käme. Diesen Schein nennt man künstliche Intelligenz, und<br />
seiner Förderung hat sich das gleichnamige Arbeitsfeld der Informatik gewidmet. Es lohnt<br />
sich, die Metapher von der Intelligenz des Rechners kurz genauer zu betrachten, weil<br />
sie — unter Laien wie unter Informatikern — manche Verwirrung stiftet. Es ist nichts<br />
weiter dabei, wenn man sagt, daß ein Rechner (oder genauer eine bestimmte Software)<br />
sich intelligent verhält, wenn er (oder sie) eine Quadratwurzel zieht, eine WWW-Seite<br />
präsentiert oder einen Airbus auf die Piste setzt. Schließlich wurden viele Jahre Arbeit<br />
investiert, um solche Aufgaben rechnergerecht aufzubereiten, damit der Rechner diese<br />
schneller und zuverlässiger erledigen kann als wir selbst. Nimmt man allerdings die Metapher<br />
allzu wörtlich, kommt man zu der Vorstellung, der Rechner würde dadurch selbst<br />
so etwas wie eine eigene Verständigkeit erwerben. Daraus entsteht gelegentlich sogar die<br />
widersprüchliche Zielsetzung, den Rechner so zu programmieren, daß er sich nicht mehr<br />
wie ein programmierter Rechner verhält. Der Sache nach gehört die „Künstliche Intelligenz“<br />
der Angewandten Informatik an, geht es ihr doch um die erweiterte Anwendbarkeit<br />
des Rechners auf immer komplexere Probleme. Andererseits haben diese konkreten<br />
Anwendungen für sie nur exemplarischen Charakter, als Schritte zu dem abstrakten Ziel,<br />
„wirklich“ intelligente künstliche Systeme zu schaffen. So leidet diese Arbeitsrichtung unter<br />
dem Dilemma, daß ihr Beitrag zum allgemeinen Fortschritt der Informatik vom Lärm<br />
der immer wieder metaphorisch geweckten und dann enttäuschten Erwartungen übertönt<br />
wird.<br />
Unbeschadet solch metaphorischer Fragen geht der Vormarsch der Informatik in allen<br />
Lebensbereichen voran — der „rechenbare“ Ausschnitt der Wirklichkeit wird ständig er-
1.3. Einordnung der Informatik in die Familie der Wissenschaften 5<br />
weitert: Aus Textverarbeitung wird DeskTop Publishing, aus Computerspielen wird Virtual<br />
Reality, und aus gewöhnlichen Bomben werden „intelligent warheads“. Wie gesagt: die<br />
Rechner werden immer schlauer. Und wir?<br />
1.3. Einordnung der Informatik in die Familie der<br />
Wissenschaften<br />
Wir haben gesehen, daß die Grundlagen der Informatik aus der Mathematik stammen,<br />
während der materielle Ausgangspunkt ihrer Entwicklung die Konstruktion der ersten Rechenmaschinen<br />
war. Mutter die Mathematik, Vater der Computer — wissenschaftsmoralisch<br />
gesehen ist die Informatik ein Bastard, aus einer Liebschaft des reinen Geistes mit<br />
einem technischen Gerät entstanden.<br />
Die Naturwissenschaft untersucht Phänomene, die ihr ohne eigenes Zutun gegeben<br />
sind. Die Gesetze der Natur müssen entdeckt und erklärt werden. Die Ingenieurwissenschaften<br />
wenden dieses Wissen an und konstruieren damit neue Gegenstände, die selbst<br />
wieder auf ihre Eigenschaften untersucht werden müssen. Daraus ergeben sich neue Verbesserungen,<br />
neue Eigenschaften, und so weiter.<br />
Im Vergleich dieser beiden Abteilungen der Wissenschaft steht die Informatik eher den<br />
Ingenieurwissenschaften nahe: Auch sie konstruiert Systeme, die — abgesehen von denen<br />
der <strong>Technische</strong>n Informatik — allerdings immateriell sind. Wie die Ingenieurwissenschaften<br />
untersucht sie die Eigenschaften ihrer eigenen Konstruktionen, um sie weiter zu<br />
verbessern. Wie bei den Ingenieurwissenschaften spielen bei der Informatik die Aspekte<br />
der Zuverlässigkeit und Lebensdauer ihrer Produkte eine wesentliche Rolle.<br />
Eine interessante Parallele besteht auch zwischen Informatik und Rechtswissenschaft.<br />
Die Informatik konstruiert abstrakte Modelle der Wirklichkeit, die dieser möglichst nahe<br />
kommen sollen. Das Recht schafft eine Vielzahl von Abstraktionen konkreter Individuen.<br />
Rechtlich gesehen sind wir Mieter, Studenten, Erziehungsberechtigte, Verkehrsteilnehmer,<br />
etc. Als solche verhalten wir uns entsprechend der gesetzlichen Regeln. Der Programmierer<br />
bildet reale Vorgänge in formalen Modellen nach, der Jurist muß konkrete Vorgänge<br />
unter die relevanten Kategorien des Rechts subsumieren. Die Analogie endet allerdings,<br />
wenn eine Diskrepanz zwischen Modell und Wirklichkeit auftritt: Bei einem Programmfehler<br />
behält die Wirklichkeit recht, bei einem Rechtsbruch das Recht.<br />
Eine wissenschaftshistorische Betrachtung der Informatik und ihre Gegenüberstellung<br />
mit der hier gegebenen deduktiven Darstellung ist interessant und sollte zu einem späteren<br />
Zeitpunkt, etwa zum Abschluß des Informatik-Grundstudiums, nachgeholt werden.<br />
Interessante Kapitel dieser Geschichte sind das Hilbert’sche Programm, die Entwicklung<br />
der Idee von Programmen als Daten, die oben diskutierte Idee der „Künstlichen Intelligenz“,<br />
der Prozeß der Loslösung der Informatik von der Mathematik, die Geschichte der
6 1. Einleitung<br />
Programmiersprachen, die Revolution der Anwendungssphäre durch das Erscheinen der<br />
Mikroprozessoren, und vieles andere mehr.
2. Modellierung<br />
Wir haben im ersten Kapitel gelernt: Programmieren heißt, abstrakte Modelle realer Objekte<br />
zu bilden, so daß die Rechenregeln im Modell die relevanten Eigenschaften in der<br />
Wirklichkeit adäquat nachbilden.<br />
Wir werden den Vorgang der Modellierung nun anhand von zwei Beispielen kennen<br />
lernen.<br />
2.1. Eine Formelsprache für Musik<br />
Wir haben gelernt, daß die Welt der Zahlen wegen ihres formalen Charakters die natürliche<br />
Heimat des Rechnens ist. Es gibt ein zweites Gebiet, in dem ein gewisser Formalismus<br />
in der Natur der Sache liegt: die Musik.<br />
Das Spielen eines Musikstückes mag vieles sein – ein künstlerischer Akt, ein kulturelles<br />
oder emotionales Ereignis. Im Kern jedoch steht das Musikstück – es bringt die Musikvorstellung<br />
des Komponisten in eine objektive Form und ist nichts anderes als ein Programm<br />
zur Ausführung durch einen Musiker (oder heute über eine Midi-Schnittstelle auch durch<br />
einen elektronischen Synthesizer). Wer immer als erster ein Musikstück niedergeschrieben<br />
hat, hat damit den Schritt vom Spielen zum Spielen lassen begründet.<br />
Diese kulturhistorische Leistung ist viel älter als die Idee einer Rechenmaschine. In der<br />
traditionellen Notenschrift ist der formale Anteil der Musik manifestiert. Die Modellierung<br />
ist hier also bereits erfolgt, allerdings ausschließlich im Hinblick auf die Wiedergabe durch<br />
einen menschlichen Interpreten, und nicht bis hin zur Entwicklung von dem Rechnen<br />
vergleichbaren Regeln. Zwar sind viele Gesetze z.B. der Harmonielehre bekannt, man kann<br />
sie aber in der Notenschrift nicht ausdrücken. So kann man zwar jeden Dur-Dreiklang<br />
konkret hinschreiben – aber die allgemeine Aussage, daß ein Dur-Dreiklang immer aus<br />
einem großen und einem kleinen Terz besteht, muß auf andere Art erfolgen. In diesem<br />
Abschnitt setzen wir die Notenschrift (natürlich nur einen kleinen Ausschnitt davon) in<br />
eine Formelsprache samt den zugehörigen Rechenregeln um.<br />
Musikstücke sind aus Noten zusammengesetzt, die gleichzeitig oder nacheinander gespielt<br />
werden. Jede Note hat einen Ton und eine Dauer. Dem Stück als Ganzem liegt ein<br />
bestimmter Takt zugrunde, und die Dauer einzelner Töne wird in Takt-Bruchteilen gemessen.<br />
Die Tonskala besteht aus 12 Halbtönen, die sich in jeder Oktave wiederholen.
8 2. Modellierung<br />
Wir legen das tiefe C als untersten Ton zugrunde und numerieren die Halbtöne von 0<br />
ab aufwärts. Angaben zum Spieltempo (in Taktschlägen pro Minute) und zur Wahl des<br />
Instruments werden global für ganze Musikstücke gemacht.<br />
Formeln, die Musik bedeuten, können wir uns z.B. so vorstellen:<br />
Note 0 (1/4) -- Viertelnote tiefes C<br />
Note 24 (1/1) -- ganze Note c’<br />
Pause (1/16) -- Sechzehntelpause<br />
Note 24 (1/4) :*: Note 26 (1/4) :*: Note 28 (1/4)<br />
-- Anfang der C-Dur Tonleiter<br />
Note 24 (1/4) :+: Note 28 (1/4) :+: Note 31 (1/4)<br />
-- C-Dur Akkord<br />
Tempo 40 m -- Musik m als Adagio<br />
Tempo 100 m -- Musik m als Presto<br />
Instr Oboe m -- Musik m von Oboe gespielt<br />
Instr VoiceAahs m -- Musik m gesummt<br />
Wir konstruieren nun die Formelwelt, deren Bürger die obigen Beispiele sind. Wir gehen<br />
davon aus, daß es Formeln für ganze Zahlen, Brüche und ein bißchen Arithmetik bereits<br />
gibt in der Formelwelt und führen neue Typen von Formeln ein: Ton, Dauer, Instrument<br />
und Musik.<br />
infixr 7 :*:<br />
infixr 6 :+:<br />
type GanzeZahl = Int -- Ganze Zahlen und Brueche setzen wir<br />
type Bruch = Rational -- als gegeben voraus.<br />
type Ton = GanzeZahl<br />
type Dauer = Bruch<br />
data Instrument = Oboe | HonkyTonkPiano |<br />
Cello | VoiceAahs deriving Show<br />
data Musik = Note Ton Dauer |<br />
Pause Dauer |<br />
Musik :*: Musik |<br />
Musik :+: Musik |<br />
Instr Instrument Musik |<br />
Tempo GanzeZahl Musik deriving Show<br />
Die neuen Formeltypen Ton und Dauer sind nur Synonyme für ganze Zahlen und Brüche<br />
entsprechend der besonderen Bedeutung, in der wir sie gebrauchen. Sie tragen keine<br />
neuen Formeln bei. Anders bei Instrument und Musik. Die Symbole Note, Pause,<br />
:*:, :+:, Instr, Tempo sind die Konstruktoren für neuartige Formeln, die Musikstücke<br />
darstellen. In der Formel m1 :*: m2 dürfen (und müssen!) m1 und m2 beliebige<br />
Formeln für Musikstücke sein, insbesondere selbst wieder zusammengesetzt.
2.1. Eine Formelsprache für Musik 9<br />
In Anlehnung an die Punkt-vor-Strich Regel der Algebra geben wir der Verknüpfung<br />
von Noten zu einer zeitlichen Abfolge (:*:) Vorrang vor der zeitlich parallelen Verknüpfung<br />
mehrerer Stimmen (:+:). Damit ist die Formel m1 :+: m2 :*: m3 zu lesen als<br />
m1 :+: (m2 :*: m3).<br />
Umfangreiche Musikstücke können nun als wahre Formelgebirge geschrieben werden.<br />
Zuvor erleichtern wir uns das Leben in der Formelwelt, indem wir einige Abkürzungen<br />
einführen. Etwa für die Pausen:<br />
gP = Pause (1/1)<br />
hP = Pause (1/2)<br />
vP = Pause (1/4)<br />
aP = Pause (1/8)<br />
sP = Pause (1/16)<br />
Diese Definitionen sind zugleich Rechenregeln. Sie bedeuten einerseits, daß man die<br />
neuen Symbole gP, hP, vP, aP, sP überall in Musikstücken einsetzen kann – es erweitert<br />
sich damit die Formelwelt. Zugleich kann man jedes Symbol jederzeit durch die<br />
rechte Seite der Definition ersetzen – so daß die Musikstücke in der Formelwelt nicht<br />
mehr geworden sind.<br />
Hier sind klangvolle Namen für einige Tempi:<br />
adagio = 70; andante = 90; allegro = 140; presto = 180<br />
Die üblichen Bezeichnungen für die 12 Halbtöne führen wir so ein:<br />
ce = 0; cis = 1; des = 1<br />
de = 2; dis = 3; es = 3<br />
eh = 4; eis = 5; fes = 4<br />
ef = 5; fis = 6; ges = 6<br />
ge = 7; gis = 8; as = 8<br />
ah = 9; ais = 10; be = 10<br />
ha = 11; his = 12<br />
Diese Töne sind in der untersten Oktave angesiedelt, und wir brauchen sie hier kaum.<br />
Dagegen brauchen wir eine bequeme Notation für Noten (beliebiger Dauer) aus der C-<br />
Dur Tonleiter in der dritten Oktave:<br />
c’ u = Note (ce+24) u; d’ u = Note (de+24) u<br />
e’ u = Note (eh+24) u; f’ u = Note (ef+24) u<br />
g’ u = Note (ge+24) u; a’ u = Note (ah+24) u<br />
h’ u = Note (ha+24) u; c’’ u = Note (ce+36) u<br />
Als Beispiele hier der C-Dur Akkord in neuer Schreibweise, sowie eine (rhythmisch beschwingte)<br />
C-Dur Tonleiter:
10 2. Modellierung<br />
cDurTonika = c’ (1/1) :+: e’ (1/1) :+: g’ (1/1)<br />
cDurSkala = Tempo allegro(<br />
c’ (3/8) :*: d’ (1/8) :*: e’ (3/8) :*: f’ (4/8)<br />
:*: g’ (1/8) :*: a’ (1/4) :*: h’ (1/8) :*: c’’ (1/2))<br />
Allgemein gesagt, besteht ein Dur-Dreiklang aus einen Grundton t sowie der großen<br />
Terz und der Quinte über t.<br />
Dieses Gesetz als Formel:<br />
durDreiklang t = Note t (1/1) :+: Note (t+4) (1/1) :+: Note (t+7) (1/1)<br />
Wie wär’s mit einer allgemeinen Regel zur Umkehrung eines Dreiklangs (der unterste<br />
Ton muß eine Oktave nach oben)?<br />
umk ((Note t d) :+: n2 :+: n3) = n2 :+: n3 :+: (Note (t+12) d)<br />
Als komplexe Operation auf ganzen Musikstücken betrachten wir das Transponieren.<br />
Es bedeutet, daß Töne um ein gewisses Intervall verschoben werden, während alles andere<br />
gleich bleibt. Wie muß die Rechenregel lauten, um Musikstück m um Intervall i zu<br />
transponieren? Wir geben eine Regel für jede mögliche Form von m an:<br />
transponiere i (Pause d) = Pause d<br />
transponiere i (Note t d) = Note (t+i) d<br />
transponiere i (m1 :*: m2) = (transponiere i m1) :*: (transponiere i m2)<br />
transponiere i (m1 :+: m2) = (transponiere i m1) :+: (transponiere i m2)<br />
transponiere i (Instr y m) = Instr y (transponiere i m)<br />
transponiere i (Tempo n m) = Tempo n (transponiere n m)<br />
gDurTonika = transponiere 7 cDurTonika<br />
Da das Transponieren Intervalle nicht ändert, führt es einen Dur-Dreiklang wieder in<br />
einen Dur-Dreiklang über. Es gilt folgendes Gesetz für beliebigen Grundton g und beliebiges<br />
Intervall i:<br />
transponiere i (durDreiklang t) = durDreiklang(t+i)<br />
Zur Überprüfung rechnen wir beide Seiten mittels der Definition von transponiere<br />
und durDreiklang aus und erhalten beide Male<br />
Note (t+i) (1/1) :+: Note (t+4+i) (1/1) :+: Note (t+7+i) (1/1)
2.2. Typen als Hilfsmittel der Modellierung 11<br />
Hier haben wir eine Methode zur Validierung von Modellen kennengelernt: Wenn man<br />
beweisen kann, daß ein Gesetz der realen Welt auch in der Formelwelt gilt, so gibt das<br />
Modell die Wirklichkeit zumindest in dieser Hinsicht treu wieder.<br />
Bevor wir ein komplettes Lied zusammenstellen, hier zwei Regeln zur Wiederholung:<br />
wdh m = m :*: m<br />
ad_infinitum m = m :*: ad_infinitum m<br />
Einen um Dauer d verzögerten Einsatz erhält man durch Vorschalten einer Pause.<br />
einsatz d m = (Pause d) :*: m<br />
Unser letztes Beispiel beschreibt den Kanon „Bruder Jakob“, wobei wir die 2. Stimme<br />
eine Oktave über den anderen notieren.<br />
phrase1 = c’ (1/4) :*: d’ (1/4) :*: e’ (1/4) :*: c’ (1/4)<br />
phrase2 = e’ (1/4) :*: f’ (1/4) :*: g’ (1/2)<br />
phrase3 = g’ (1/8) :*: a’ (1/8) :*: g’ (1/8) :*: f’ (1/8)<br />
:*: e’ (1/4) :*: c’ (1/4)<br />
phrase4 = c’ (1/4) :*: (transponiere (-12) (g’ (1/4))) :*: c’’ (1/2)<br />
strophe = wdh phrase1 :*: wdh phrase2 :*: wdh phrase3 :*: wdh phrase4<br />
endlos = ad_infinitum strophe<br />
bruderJakob = Tempo andante (Instr VoiceAahs<br />
(einsatz (0/1) endlos :+:<br />
(einsatz (2/1) (transponiere 12 endlos)) :+:<br />
(einsatz (4/1) endlos) :+:<br />
(einsatz (6/1) endlos )))<br />
Natürlich gehört zu einer praktischen Formelsprache für Musik noch vieles, was wir hier<br />
nicht betrachtet haben – Text, Artikulation, Kontrapunkt, eine Möglichkeit zur Übersetzung<br />
der Formeln in die traditionelle Notation und in eine Folge von Midi-Befehlen, um<br />
damit einen Synthesizer zu steuern.<br />
2.2. Typen als Hilfsmittel der Modellierung<br />
In der realen Welt verhindert (meistens) die Physik, daß unpassende Operationen mit<br />
untauglichen Objekten ausgeführt werden. Man kann eben einen Golfball nicht unter einer<br />
Fliege verstecken oder einen Rosenbusch mit einer Steuererklärung paaren. Macht
12 2. Modellierung<br />
es Sinn, einer streikenden (?) Waschmaschine gut zuzureden? Das Problem in einer Formelwelt<br />
ist, daß man zunächst alles hinschreiben kann, weil die Formeln sich nicht selbst<br />
dagegen sträuben.<br />
Aus diesem Grund gibt man jedem Objekt des Modells einen Typ, der eine Abstraktion<br />
der natürlichen Eigenschaften des realen Objekts ist.<br />
In der Formelsprache für Musik haben wir die Typen Ton (synonym für ganzeZahl) und<br />
Dauer (synonym für positive rationale Zahl) vorausgesetzt und die Typen Musik und<br />
Instrument neu eingeführt. Die Typendeklaration legt nicht nur fest, daß Musik auf<br />
sechs verschiedene Weisen zusammengesetzt werden kann, und zwar durch die (Formel-)<br />
Konstruktoren Note bis Tempo. Sie sagt auch, welche Formeln zusammengefügt werden<br />
dürfen. Note muß stets einen Ton mit einer Dauer verknüpfen. Mit :*: kann man zwei<br />
Musikstücke verknüpfen, nicht aber ein Musikstück mit einem Ton. Folgende Formeln<br />
sind fehlerhaft:<br />
Note 3/4 ce Pause (1/2 :*: 1/4)<br />
Pause Cello Instr (Cello :+: Oboe) cDurTonika<br />
Tempo Oboe 100 c’ :+: e’ :+: g’<br />
Möglicherweise hat sich der Autor dieser Formeln etwas Sinnvolles gedacht – aber es<br />
muß in unserer Formelsprache anders ausgedrückt werden. Auch die durch Definitionen<br />
eingeführten Namen erhalten einen Typ. Er ergibt sich aus der rechten Seite der definierenden<br />
Gleichung. Wir schreiben a, b, c :: t für die Aussage „Die Namen (oder Formeln)<br />
a, b, c haben Typ t“ und stellen fest:<br />
ce, cis, des, de, dis, es, eh, eis, fes, ef, fis :: Ton<br />
ges, ge, gis, as, ah, ais, be, ha, his :: Ton<br />
gP, hP, vP, aP, sP :: Musik<br />
cDurTonika :: Musik<br />
adagio, andante, allegro, presto :: GanzeZahl<br />
Die Namen c’, d’ etc. bezeichnen Funktionen, die eine Dauer als Argument und<br />
Musik als Ergebnis haben. umk, wdh und ad_infinitum wandeln Musikstücke um.<br />
c’, d’, e’, f’, g’, a’, h’, c’’ :: Dauer -> Musik<br />
umk, wdh, ad_infinitum :: Musik -> Musik<br />
transponiere schließlich hat zwei Argumente, was wir – etwas gewöhnugsbedürftig<br />
– mit zwei Funktionspfeilen ausdrücken.<br />
transponiere :: GanzeZahl -> Musik -> Musik
2.3. Die Rolle der Abstraktion in der Modellierung 13<br />
Hinter dieser Notation steht die Sichtweise, daß man einer zweistelligen Funktion wie<br />
transponiere manchmal nur ein Argument geben möchte: Unter<br />
(transponiere 7) stellen wir uns eine abgeleitete Funktion vor, die Musikstücke um<br />
eine Quinte nach oben transponiert. Natürlich hat (transponiere 7) dann den „restlichen“<br />
Typ Musik -> Musik.<br />
2.3. Die Rolle der Abstraktion in der Modellierung<br />
Unsere Formelsprache für Musik erlaubt es, in direkter Analogie zur Notenschrift Musikstücke<br />
als Formeln hinzuschreiben. Darüberhinaus können wir aber auch abstrakte Zusammenhänge<br />
zwischen Musikstücken darstellen, wie etwa den Aufbau eines Dur-Dreiklangs<br />
oder das Transponieren. Worauf beruht dies Möglichkeit?<br />
Sie beginnt damit, daß wir beliebige Formeln mit einem Namen versehen können, wie<br />
etwa bei gP, hP oder phrase1 bis phrase4. Diese Namen sind kürzer und (hoffentlich)<br />
lesbarer als die Formeln, für die sie stehen, aber noch nicht abstrakter. Die Abstraktion<br />
kommt mit dem Übergang zu Funktionen ins Spiel. Vergleichen wir die beiden folgenden<br />
Definitionen eines Zweiklangs:<br />
tritonus_f_1 = Note ef (1/1) :+: Note ha (1/1)<br />
tritonus t d = Note t d :+: Note (t+6) d<br />
tritonus_f_1 beschreibt einen Zweiklang von konkreter Tonlage und Dauer.<br />
tritonus dagegen abstrahiert von Tonlage und Dauer, und hält nur fest, was für den als<br />
Tritonus bekannten Zweiklang wesentlich ist — ein Intervall von 6 Halbtonschritten.<br />
Dieser Unterschied macht sich auch an den Typen bemerkbar. Wir finden:<br />
tritonus_f_1 :: Musik<br />
tritonus :: Ton -> Dauer -> Musik<br />
Hier verrät uns bereits der Typ, daß in der Formel tritonus erst Ton und Dauer konkretisiert<br />
werden müssen, ehe spielbare Musik entsteht. Bei den Noten der eingestrichenen<br />
C-Dur-Skala (c’, d’, ...) haben wir von der Dauer abtrahiert, sonst hätten wir Definitionen<br />
für 40 oder noch mehr Namen einführen müssen. Andererseits haben wir hier<br />
natürlich nicht vom Ton abstrahiert, wir wollten ja Namen für bestimmte Töne einführen.<br />
Und außerdem gibt es diese Abstraktion bereits: Eine Note von beliebigem Ton und<br />
Dauer wird gerade von dem Konstruktor Note repräsentiert, dem wir daher auch den Typ<br />
Ton -> Dauer -> Musik zuordnen.<br />
Typen halten die Objekte der Formelwelt auseinander, Funktionen stiften Zusammenhänge.<br />
Wollten wir ohne Abstraktion das Prinzip der Umkehrung eines Dreiklangs beschreiben,<br />
kämen wir über konkrete Beispiele nicht hinaus. Die Funktion umk leistet das
14 2. Modellierung<br />
Gewünschte mühelos. Und schließlich hält uns nichts davon ab (Übungsaufgabe!), im Kanon<br />
bruderJakob von der eigentlichen Melodie zu abstrahieren und ein allgemeines<br />
Konstruktionsprinzip für einen vierstimmigen Kanon anzugeben.<br />
Hier haben wir die Methode der funktionalen Abstraktion kennengelernt. Technisch<br />
gesehen besteht sie lediglich darin, in einer Formel einige Positionen als variabel zu betrachten.<br />
So wird aus der Formel eine Funktion, die mit einem Namen versehen und in<br />
vielfältiger Weise eingesetzt werden kann, und die zugleich selbst ein abstraktes Prinzip<br />
repräsentiert. Wir werden später noch andere Formen der Abstraktion kennenlernen, aber<br />
die funktionale Abstraktion ist sicher die grundlegendste von allen.<br />
2.4. Modellierung in der molekularen Genetik<br />
Die Basen Adenin, Cytosin, Guanin und Thymin bilden zusammen mit Phosphor und Ribose<br />
die Nukleotide, die Bausteine der Erbsubstanz.<br />
data Nucleotide = A | C | G | T deriving (Eq, Show)<br />
Die 20 Aminosäuren sind die Bausteine aller Proteine:<br />
data AminoAcid = Ala -- Alanin<br />
| Arg -- Arginin<br />
| Asn -- Asparagin<br />
| Asp -- Aspartat<br />
| Cys -- Cystein<br />
| Gln -- Glutamin<br />
| Glu -- Glutamat<br />
| Gly -- Glycin<br />
| His -- Histidin<br />
| Ile -- Isoleucin<br />
| Leu -- Leucin<br />
| Lys -- Lysin<br />
| Met -- Methionin<br />
| Phe -- Phenylalanin<br />
| Pro -- Prolin<br />
| Ser -- Serin<br />
| Thr -- Threonin<br />
| Trp -- Tryptophan<br />
| Tyr -- Tyrosin<br />
| Val -- Valin<br />
| Stp -- keine Aminosaeure; siehe unten<br />
deriving (Eq, Show)
2.4. Modellierung in der molekularen Genetik 15<br />
Lange Ketten von Nukleotiden bilden die Nukleinsäure, lange Ketten von Aminosäuren<br />
bilden Proteine. Chemisch werden diese Ketten auf unterschiedliche Weise gebildet:<br />
Bei den Nukleotiden verknüpfen die Phosphor-Atome die Ribose-Ringe, zwischen den<br />
Aminosäuren werden Peptidbindungen aufgebaut. Da wir aber die Chemie der Moleküle<br />
nicht modellieren wollen, wollen wir auch nicht unterscheiden, auf welche Weise die<br />
Ketten konstruiert werden. Als Formel für Kettenmoleküle schreiben wir<br />
A:C:C:A:G:A:T:T:A:T:A:T: ..., oder<br />
Met:Ala:Ala:His:Lys:Lys:Leu: ...<br />
Im Unterschied zur Molekularbiologie kennt also unsere Formelwelt einen Konstruktor<br />
(:), der Ketten jeglicher Art aufbaut:<br />
data [a] = [] -- leere Kette<br />
| a:[a] -- (:) verlaengert Kette von a’s um ein a.<br />
Den Datentyp [a] nennt man einen polymorphen Listentyp. Hier ist a ein Typparameter.<br />
Dies bedeutet, das Elemente beliebigen, aber gleichen Typs mittels der Konstruktoren<br />
(:) und [] zu Listen zusammengefügt werden können. Für Listen führen wir eine spezielle<br />
Notation ein: Statt a:b:c:[] schreiben wir auch [a,b,c].<br />
Damit können wir nun sagen, welcher Datentyp in der Formelwelt den realen Nukleinsäuren<br />
und Proteinen entsprechen soll:<br />
type DNA = [Nucleotide]<br />
type Protein = [AminoAcid]<br />
type Codon = (Nucleotide, Nucleotide, Nucleotide)<br />
Im Ruhezustand (als Informationsspeicher) ist die DNA allerdings ein Doppelmolekül:<br />
Zwei DNA-Stränge bilden die berühmte, von Watson und Crick entdeckte Doppelhelix.<br />
Sie beruht darauf, daß bestimmte Basen zueinander komplementär sind - sie können Wasserstoffbrückenbindungen<br />
aufbauen, wenn sie einander gegenüber stehen.<br />
wc_complement A = T<br />
wc_complement T = A<br />
wc_complement C = G<br />
wc_complement G = C<br />
Wir können die dopplesträngige DNA auf zwei verschiedene Weisen darstellen, als Paar<br />
von Listen oder als Liste von Paaren.<br />
type DNA_DoubleStrand = (DNA,DNA) -- als Paar zweier Einzelstraenge<br />
type DNA_DoubleStrand’ = [(Nucleotide,Nucleotide)]<br />
-- als Kette von Watson-Crick-Paaren<br />
dnaDS_Exmpl1 = ([A,C,C,G,A,T],[T,G,G,C,T,A])<br />
dnaDS_Exmpl2 = [(A,T),(C,G),(C,G),(G,C),(A,T),(T,A)]
16 2. Modellierung<br />
Dabei ist die erste Version deutlich vorzuziehen. Sie deutet an, was in der Zelle auch<br />
stattfindet: Das Aufspalten oder Zusammenfügen des Doppelstrangs aus zwei Einzelsträngen.<br />
Die zweite Version dagegen suggeriert, daß existierende Watson-Crick Basenpaare zu<br />
einer Doppelkette verknüpft werden. Einzelne solche Paare sind jedoch wegen der sehr<br />
schwachen Wasserstoffbindung nicht stabil und kommen daher in der Zelle nicht vor.<br />
Wir entscheiden uns also für den Datentyp DNA_DoubleStrand. So naheliegend seine<br />
Definition auch erscheint, sie ist nicht ohne Gefahren: Formeln vom Typ<br />
DNA_DoubleStrand sollen beliebige doppelsträngige DNA-Moleküle darstellen. Das können<br />
sie auch. Aber darüber hinaus können wir formal korrekte Formeln des Typs<br />
DNA_DoubleStrand hinschreiben, denen keine Doppelhelix entspricht.<br />
incorrectDoubleStrand = ([A,C,C,G,A,T],[T,G,G,C,A,T,C])<br />
incorrectDoubleStrand ist in zweifacher Weise falsch: Erstens sind die jeweiligen<br />
Basen an mehreren Stellen nicht komplementär, und zweitens haben die beiden Einzelstränge<br />
gar noch unterschiedliche Länge. Wir stoßen hier auf eine grundlegende Schwierigkeit<br />
bei jeder Modellierung: Es gibt Objekte in der Modellwelt, die nichts bedeuten,<br />
d.h. denen keine Objekte der Wirklichkeit entsprechen. Solche Formeln, die wohlgebaut,<br />
aber ohne Bedeutung sind, nennt man syntaktisch korrekt, aber semantisch falsch. Alles<br />
geht gut, solange beim Rechnen mit den Formeln auch keine solchen Objekte erzeugt<br />
werden. Wenn doch, sind die Folgen meist unabsehbar. Im besten Fall bleibt die Rechnung<br />
irgendwann stecken, weil eine Funktion berechnet werden soll, die für solche Fälle<br />
keine Regel vorsieht. Manchmal passen die Rechenregeln auch auf die nicht vorgesehenen<br />
Formeln, und dann kann es im schlimmsten Fall geschehen, daß die Rechnung ein<br />
Ergebnis liefert, dem man nicht ansieht, daß es falsch ist.<br />
Wir werden sehen, daß selbst die Natur im Falle der DNA-Replikation dieses Problem<br />
erkannt und einen Weg zu seiner Lösung gefundenhat. Zunächst betrachten wir<br />
den „Normalfall“ der Replikation. Weil das Komplement jeder Base eindeutig definiert<br />
ist, kann man zu jedem DNA-Einzelstrang den komplementären Doppelstrang berechnen.<br />
In der Tat geschieht dies bei der Zellteilung. Der Doppelstrang wird aufgespalten, und<br />
die DNA-Polymerase synthetisiert zu jedem der beiden Stränge den jeweils komplementären<br />
Strang. Die DNA-Polymerase können wir auf zwei verschiedene Weisen darstellen:<br />
Enerseits ist sie ein Enzym, also selbst ein Protein:<br />
dnaPolymerase_Sequenz = Met:Ala:Pro:Val:His:Gly:Asp:Asp:Ser ...<br />
Dies hilft uns allerdings nicht dabei, die Wirkungsweise dieses Enzyms auszudrücken.<br />
Noch viele Jahrzehnte werden vergehen, ehe die Wissenschaft die Funktion eines Proteins<br />
aus der Kette seiner Aminosäuren ableiten kann. Was liegt also näher, als die Funktion der<br />
Polymerase durch eine Funktion zu modellieren:
2.4. Modellierung in der molekularen Genetik 17<br />
dnaPolymerase :: DNA -> DNA_DoubleStrand<br />
dnaPolymerase x = (x, complSingleStrand x) where<br />
complSingleStrand [] = []<br />
complSingleStrand (a:x) = wc_complement a:complSingleStrand x<br />
Will man prüfen (lassen), ob ein Dopplestrang tatsächlich korrekt aufgebaut ist, kann<br />
man dies nun einfach tun.<br />
data Bool = True | False -- die abstrakten Urteile Wahr und Falsch<br />
wellFormedDoubleStrand :: DNA_DoubleStrand -> Bool<br />
wellFormedDoubleStrand (x,y) = (x,y) == dnaPolymerase x<br />
(Zur Unterscheidung von der definierenden Gleichung schreiben wir den Vergleich zweier<br />
Formeln als ==. Dabei werden beide Formeln zunächst ausgerechnet und dann verglichen.)<br />
Blickt man etwas tiefer ins Lehrbuch der Genetik, so findet man, daß auch die Natur<br />
nicht ohne Fehler rechnet: Mit einer (sehr geringen) Fehlerrate wird in den neu polymerisierten<br />
Strang ein falsches (nicht komplementäres) Nukleotid eingebaut. Dadurch wird die<br />
Erbinformation verfälscht. Daher gibt es ein weiteres Enzym, eine Exonuclease, die fehlerhafte<br />
Stellen erkennt und korrigiert. Sie muß dabei ein subtiles Problem lösen: Gegeben<br />
ein Doppelstrang mit einem nicht komplementären Basenpaar darin – welcher Strang ist<br />
das Original, und welcher muß korrigiert werden? Die Natur behilft sich dadurch, daß der<br />
Originalstrang beim Kopieren geringfügig modifiziert wird – durch Anhängen von Methylgruppen<br />
an seine Nukleotide.<br />
Diese Technik können wir nicht einsetzen, da wir die Chemie der Nukleotide nicht modellieren.<br />
Wir behelfen uns auf eine nicht weniger subtile Art: Im Unterschied zur Wirklichkeit<br />
(Doppelhelix) kann man im Modell (DNA_DoubleStrand) einen „linken“ und<br />
„rechten“ Strang unterscheiden. Übereinstimmend mit der Modellierung der Polymerase<br />
legen wir fest, daß stets der „linke“ Strang als Original gilt, der „rechte“ als die Kopie.<br />
Auch die Exonuclease modellieren wir durch ihre Funktion:<br />
exonuclease :: DNA_DoubleStrand -> DNA_DoubleStrand<br />
exonuclease ([],[]) = ([],[])<br />
exonuclease ([],x) = ([],[]) -- Kopie wird abgeschnitten<br />
exonuclease (x,[]) = dnaPolymerase x -- Kopie wird verlaengert<br />
exonuclease (a:x,b:y) = if b == ac then (a:x’,b:y’)<br />
else (a:x’,ac:y’)<br />
where ac = wc_complement a<br />
(x’,y’) = exonuclease (x,y)<br />
Damit erhalten wir z.B. die erwünschte Korrektur des fehlerhaften Doppelstrangs:
18 2. Modellierung<br />
exonuclease incorrectDoubleStrand ==> ([A,C,C,G,A,T],[T,G,G,C,T,A])<br />
Unsere Definition der Exonuclease ist der Natur abgelauscht. Vom Standpunkt des Rechenergebnisses<br />
aus betrachtet (man nennt diesen Standpunkt extensional) können wir<br />
das Gleiche auch einfacher haben: Verläßt man sich darauf, daß unsere DNA-Polymerase<br />
im Modell keine Fehler macht, kann man einfach den kopierten Strang gleich neu polymerisieren.<br />
dnaCorr :: DNA_DoubleStrand -> DNA_DoubleStrand<br />
dnaCorr (x,y) = dnaPolymerase x<br />
Es ist nicht ganz überflüssig, die gleiche Funktion auf verschiedene Weisen zu beschreiben.<br />
Daraus ergibt sich die Möglichkeit, das Modell an sich selbst zu validieren. Es muß<br />
ja nun die folgende Aussage gelten:<br />
Für alle d::DNA_DoubleStrand gilt dnaCorr d == exonuclease d.<br />
Außerdem muß die dnaPolymerase eine Idempotenzeigenschaft aufweisen:<br />
Für alle x::DNA gilt: x == z where (x,y) == dnaPolymerase x<br />
(y,z) == dnaPolymerase y<br />
Wir formulieren solche Eigenschaften, die wir aus der Wirklichkeit kennen, in der Sprache<br />
der Modellwelt. Können wir sie dort als gültig nachweisen, so wissen wir erstens,<br />
daß unser Modell im Hinblick auf genau diese Eigenschaften der Realität entspricht. Aber<br />
noch mehr: Sofern wir diese Eigenschaften beim Formulieren des Modells nicht explizit<br />
bedacht haben, fördert ihr Nachweis auch unser generelles Vertrauen in die Tauglichkeit<br />
des Modells. Wir werden später Techniken kennenlernen, wie man solche Eigenschaften<br />
nachweist.<br />
Fast ohne Ausnahme benutzen alle Lebewesen den gleichen genetischen Code. Bestimmte<br />
Dreiergruppen (genannt Codons) von Nukleotiden codieren für bestimmte Aminosäuren.<br />
Da es 4 3 = 64 Codons, aber nur 20 Aminosäuren gibt, werden viele Aminosäuren<br />
durch mehrere Codons codiert. Einige Codons codieren für keine Aminosäure; man<br />
bezeichnet sie als Stop-Codons, da ihr Auftreten das Ende eines Gens markiert.<br />
Der genetische Code ist also eine Funktion, die Codons auf Aminosäuren abbildet. (Wir<br />
abstrahieren hier davon, daß die DNA zunächst in RNA transkribiert wird, wobei die Base<br />
Thymin überall durch Uracil ersetzt wird.)<br />
genCode :: Codon -> AminoAcid<br />
genCode (A,A,A) = Lys; genCode (A,A,G) = Lys<br />
genCode (A,A,C) = Asn; genCode (A,A,T) = Asn<br />
genCode (A,C,_) = Thr
2.4. Modellierung in der molekularen Genetik 19<br />
genCode (A,G,A) = Arg; genCode (A,G,G) = Arg<br />
genCode (A,G,C) = Ser; genCode (A,G,T) = Ser<br />
genCode (A,T,A) = Ile; genCode (A,T,C) = Ile<br />
genCode (A,T,T) = Ile<br />
genCode (A,T,G) = Met<br />
genCode (C,A,A) = Glu; genCode (C,A,G) = Glu<br />
genCode (C,A,C) = His; genCode (C,A,T) = His<br />
genCode (C,G,_) = Arg<br />
genCode (C,C,_) = Pro<br />
genCode (C,T,_) = Leu<br />
genCode (G,A,A) = Glu; genCode (G,A,G) = Glu<br />
genCode (G,A,C) = Asp; genCode (G,A,T) = Asp<br />
genCode (G,C,_) = Ala<br />
genCode (G,G,_) = Gly<br />
genCode (G,T,_) = Val<br />
genCode (T,A,A) = Stp; genCode (T,A,G) = Stp<br />
genCode (T,G,A) = Stp<br />
genCode (T,A,C) = Tyr; genCode (T,A,T) = Tyr<br />
genCode (T,C,_) = Ser<br />
genCode (T,G,G) = Trp<br />
genCode (T,G,C) = Cys; genCode (T,G,T) = Cys<br />
genCode (T,T,A) = Leu; genCode (T,T,G) = Leu<br />
genCode (T,T,C) = Phe; genCode (T,T,T) = Phe<br />
Ein Gen beginnt stets mit dem „Startcodon“ (A,T,G) , setzt sich dann mit weiteren<br />
Codons fort und endet mit dem ersten Auftreten eines Stopcodons. Die Translation, d.h.<br />
die Übersetzung des Gens in das codierte Protein erfolgt am Ribosom. Dies ist ein sehr<br />
komplexes Molekül, ein Verbund aus Proteinen und Nukleinsäuren. Wir modellieren es<br />
durch seine Funktion:<br />
ribosome :: DNA -> Protein<br />
ribosome (A:T:G:x) = Met:translate (triplets x) where<br />
triplets :: [a] -> [(a,a,a)]<br />
triplets [] = []<br />
triplets (a:b:c:x) = (a,b,c):triplets x<br />
translate :: [Codon] -> Protein<br />
translate [] = []<br />
translate (t:ts) = if aa == Stp then []<br />
else aa:translate ts where<br />
aa = genCode t<br />
Auch hier haben wir wieder einige Modellierungsentscheidungen getroffen, die man<br />
sich klarmachen muß. Dies betrifft die Behandlung inkorrekt aufgebauter Gene, die auch
20 2. Modellierung<br />
in der Natur gelegentlich vorkommen. Fehlt das Startcodon, wird kein Protein produziert.<br />
Ist das Gen unvollständig, d.h. bricht es ohne Stopcodon ab, so ist ein unvollständiges<br />
Proteinprodukt entstanden, das entsorgt werden muß. Im Modell ist dies so ausgedrückt:<br />
• Fehlt das Startcodon, gibt es keine passende Rechenregel für ribosome, und die<br />
Rechnung beginnt gar nicht erst.<br />
• Ist das letzte Triplett unvollständig, bleibt die Rechnung in einer Formel der Art<br />
triplets [a] oder triplets[a,b] stecken, für die wir keine Rechenregel vorgesehen<br />
haben. Ein Fehlen des Stopcodons wird verziehen. Wer dies nicht will,<br />
streicht die erste Regel für translate, so daß ohne Stopcodon die Rechnung am Ende<br />
in translate [] steckenbleibt.<br />
Eine frühere Entscheidung hat sich hier gelohnt: Da wir den Listentyp polymorph eingeführt<br />
haben, können wir hier auch ganz nebenbei Listen von Codons, d.h. den Typ<br />
[Codon] verwenden.<br />
Bisher haben wir Vorgänge modelliert, die in der Natur selbst vorkommen. Zum Abschluß<br />
nehmen wir den Standpunkt des Naturforschers ein. Heute liefern die großen<br />
Sequenzierprojekte dank weitgehender Automatisierung eine Fülle von DNA-Sequenzen<br />
immer mehr Organismen. Von einigen ist sogar bereits das komplette Genom bekannt,<br />
etwa vom Koli-Bakterium (ca. 3 Millionen Basenpaare) oder der Bäckerhefe (ca. 14 Millionen<br />
Basenpaare). Den Forscher interessiert nun unter anderem, welche Gene eine neue<br />
DNA-Sequenz möglicherweise enthält. Sequenzabschnitte, die den Aufbau eines Gens haben,<br />
nennt man ORFs (open reading frames). Ob es sich bei ihnen tatsächlich um Gene<br />
handelt, hängt davon ab, ob sie jemals vom Ribosom als solche behandelt werden. Dies<br />
kann man der Sequenz heute nicht ansehen. Wohl aber ist es interessant, in einer neuen<br />
DNA-Sequenz nach allen ORFs zu suchen und ihre Übersetzung in hypothetische Proteine<br />
vorzunehmen. Wir wollen also für unsere Kollegin aus der Molekularbiologie eine<br />
Funktion definieren, die genau dieses tut.<br />
Damit ist unsere Aufgabe so bestimmt: Wir gehen von einem DNA-Doppelstrang aus,<br />
der zu untersuchen ist. Als Ergebnis möchten wir alle ORFs in übersetzter Form sehen, die<br />
in dem Doppelstrang auftreten.<br />
Wir wollen diese Aufgabe durch eine Funktion analyseORFs lösen. Schon beim Versuch,<br />
den Typ dieser Funktion anzugeben, wird klar, daß die Aufgabe zwar halbwegs ausreichend,<br />
aber keineswegs vollständig beschrieben ist.<br />
Was genau bedeutet „alle ORFs“? Weder in der DNA, noch in unserem Modell liegt<br />
eine feste Einteilung der Nukleotidsequenz in Codons vor. Das Auftreten einer Dreiergruppe<br />
A,T,G definiert implizit die Lage der folgenden Codons. Ein einzelnes Nukleotid<br />
kann so drei verschiedenen Leserastern angehören, in denen es jeweils als 1., 2. oder 3.<br />
Element eines Codons interpretiert wird. Damit kann ein DNA-Abschnitt überlappende
2.4. Modellierung in der molekularen Genetik 21<br />
ORFs enthalten. Das gleiche gilt für den Gegenstrang, der dabei in umgekehrter Richtung<br />
abzulesen ist.<br />
„Alle ORFs“ zu finden ist also etwas komplizierter als es zunächst erscheint. Das führt<br />
zu der Frage, wie eigentlich das Ergebnis aussehen soll. Einfach eine Liste aller ORFs,<br />
die vorkommen? Oder will man zu jedem ORF auch wissen, in welchem Leseraster auf<br />
welchem Strang er liegt? Möchte man gar zusätzliche Informationen erhalten, wie etwa<br />
Länge eines ORF und seine Lage relativ zum Anfang der DNA-Sequenz? Schließlich –<br />
wir wissen ja was unsere Kollegin letzlich interessiert – könnte man die hypothetischen<br />
Proteine noch mit einer Proteindatenbank abgleichen, um zu sehen, ob ein solches (oder<br />
ein ähnliches) Protein an anderer Stelle bereits nachgewiesen wurde ...<br />
Es ist immer ein gutes Entwurfsprinzip<br />
• möglicherweise interessante Information, die während der Rechnung anfällt, auch<br />
im Ergebnis sichtbar zu machen. Dies gilt hier für die Verteilung der ORFs auf die<br />
Leseraster.<br />
• die Funktionalität eines Entwurfs nicht zu überfrachten. Was gut und logisch einleuchtend<br />
durch zwei getrennte Funktionen realisiert werden kann, wird auch getrennt.<br />
Dadurch entstehen kleine Bausteine von überschaubarer Komplexität, die<br />
möglicherweise auch in anderem Kontext wiederverwendbar sind.<br />
Wir entscheiden, daß das Ergebnis eine Liste von 6 Listen übersetzter ORFs sein soll,<br />
getrennt nach Leseraster. Wir halten diesen Beschluß durch die entsprechende Typdeklaration<br />
fest.<br />
analyzeORFs :: DNA_DoubleStrand -> [[Protein]]<br />
Da ein Startcodon an beliebiger Stelle stehen kann, bilden wir zunächst 6 verschiedene<br />
Leseraster, die dann alle nach ORFs durchsucht werden.<br />
frames3 :: DNA -> [[Codon]]<br />
frames3 x = if length x < 3 then [[],[],[]]<br />
else [triplets x, triplets (tail x),<br />
triplets (tail(tail x))] where<br />
triplets :: [a] -> [(a,a,a)]<br />
triplets [] = []<br />
triplets [_] = []<br />
triplets [_,_] = []<br />
triplets (a:b:c:x) = (a,b,c):triplets x<br />
findStartPositions :: [Codon] -> [[Codon]]<br />
findStartPositions [] = []<br />
findStartPositions (c:x) = if c == (A,T,G) then (c:x):findStartPositions x<br />
else findStartPositions x
22 2. Modellierung<br />
analyzeORFs (strain,antistrain)<br />
= map (map translate) orfs where<br />
sixframes = frames3 strain ++ frames3 (reverse antistrain)<br />
orfs = map findStartPositions sixframes<br />
Der polymorphe Listendatentyp wird hier in verschiedener Weise benutzt; das Ergebnis<br />
z.B. ist eine Liste von Listen von Listen von Aminosäuren. Wir unterstellen Funktionen<br />
(++) und reverse, die Listen aller Art verketten bzw. umkehren. Die Umkehrung des<br />
Gegenstrangs ist notwendig, weil dieser am Ribosom in gegenläufiger Richtung gelesen<br />
wird. Die Funktion map schließlich wendet eine Funktion f auf alle Elemente einer Liste<br />
an, so daß z.B. gilt map f [x,y,z] = [f x, f y, f z].<br />
Einige Beispiele:<br />
dna_seq3 = dnaPolymerase [A,A,T,G,T,C,C,A,T,G,A,A,T,G,C]<br />
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]<br />
analyzeORFs dna_seq3 ==><br />
[[],<br />
[[Met, Ser, Met, Asn], [Met, Asn]],<br />
[[Met]],<br />
[[Met, Asp, Ile]],<br />
[],<br />
[]]<br />
analyzeORFs dna_seq4 ==><br />
[[[Met, Met, Asn, Ala, Gly, Ile, His, His],<br />
[Met, Asn, Ala, Gly, Ile, His, His]],<br />
[[Met, Pro, Ala, Phe, Ile]],<br />
[],<br />
[[Met, Met, Asn, Ala, Gly, Ile, His, His],<br />
[Met, Asn, Ala, Gly, Ile, His, His]],<br />
[[Met, Pro, Ala, Phe, Ile]],<br />
[]]<br />
Daß bei dna_seq4 auf Strang und Gegenstrang die gleichen hypothetischen Proteine<br />
gefunden werden, liegt am besonderen Aufbau dieser Beispielsequenz: Strang und reverser<br />
Gegenstrang (in umgekehrter Leserichtung) sind gleich.
2.5. Anforderungen an Programmiersprachen 23<br />
2.5. Anforderungen an Programmiersprachen<br />
Aus der Sprache der Mathematik haben wir den Umgang mit Formeln übernommen. Dabei<br />
zeigt sich gleich ein wichtiger Unterschied: Die „Sprache“ der Mathematik ist im wesentlichen<br />
fertig – die meisten Anwender kommen ein Leben lang mit den einmal erlernten<br />
Formeln und Begriffen – sagen wir einmal aus Arithmetik, Algebra und Analysis – aus.<br />
Erweiterungen dieser Sprache, die Einführung neuer Begriffe und Notationen bleibt einer<br />
kleinen Gemeinde von Forschern vorbehalten, die in dieser Hinsicht keinen Regeln unterliegen.<br />
In der Informatik ist das Gegenteil der Fall. Modellierung bedeutet Schaffung<br />
neuer Formelwelten. Jeder Programmierer führt neue Objekte (Typen) und Beziehungen<br />
zwischen ihnen (Funktionen) ein. Wenn ein Formalismus ständig erweitert wird und trotzdem<br />
allgemein verständlich und sogar durch den Computer ausführbar sein soll, dann muß<br />
es einen festen Rahmen dafür geben, was man wie hinschreiben darf. Dies begründet die<br />
besondere Rolle der Programmiersprachen in der Informatik. Ihre Syntax legt fest, wie<br />
etwas aufgeschrieben wird. Ihre Semantik bestimmt, wie mit diesen Formeln zu rechnen<br />
ist, sei es durch uns selbst, sei es durch den Computer. Eine präzise definierte Syntax und<br />
Semantik ist die Mindestanforderung an eine Programmiersprache.<br />
Anhand der vorangehenden Abschnitte können wir einige Beobachtungen dazu machen,<br />
was eine Programmiersprache darüber hinaus leisten muss.<br />
• Ein strenges, aber flexibles Typkonzept hält die Formelwelt in Ordnung. Streng bedeutet,<br />
daß jeder Formel ein Typ zugeordnet wird und sich daher fast alle Programmierfehler<br />
bereits als Typfehler bemerkbar machen. Flexibel heißt andererseits, daß<br />
uns das Typkonzept nicht zwingen darf, die gleiche Operation mehrfach zu programmieren,<br />
nur weil sich die Typen unwesentlich unterscheiden.<br />
• Ein hierarchisch organisierter Namensraum erlaubt die Kontrolle über die Sichtbarkeit<br />
von Namen. Definitionen können ihrerseits lokale Bezeichnungen einführen, die<br />
nach außen hin nicht sichtbar sind. Die Funktion triplets haben wir zweimal und<br />
leicht unterschiedlich definiert, jeweils als lokale Definition innerhalb der Definitionen<br />
von ribosome und frames3. Ohne lokal beschränkte Sichtbarkeit könnte<br />
man sich bei größeren Programmen vor Namenskonflikten nicht retten – man denke<br />
allein an die vielfache Verwendung der Variablen „x“ . . . .<br />
• Methoden zum Nachweis von Programmeigenschaften erlauben uns, die Übereinstimmung<br />
des Modells mit wesentlichen Aspekten der modellierten Wirklichkeit zu<br />
verifizieren. Auch die Korrektheit einzelner Funktionen sollte man einfach nachweisen<br />
können, indem man algebraische Eigenschaften wie im Fall der exonuclease<br />
benutzt.
24 2. Modellierung<br />
• Ein hohes Abstraktionsniveau der Programmiersprache gewährleistet eine überschaubare<br />
Beziehung zwischen Modell und Wirklichkeit. Wir wollen Rechenregeln formulieren,<br />
die für uns nachvollziehbar sind. Schließlich müssen zuerst wir selbst unsere<br />
Programme verstehen. Vom Computer nehmen wir einfach an, daß er damit klarkommt.<br />
(Dafür zu sorgen ist natürlich die Aufgabe des Übersetzers für die jeweilige<br />
Programmiersprache.) Sprachen auf geringem Abstraktionsniveau verlangen doppelten<br />
Aufwand, weil die formale Beschreibung der Modellwelt und die Programmierung<br />
auseinanderfallen.<br />
Wir haben Haskell als Programmiersprache gewählt, weil es derzeit die obigen Kriterien<br />
am besten erfüllt. Wir werden im Laufe der Vorlesung noch weitere Anforderungen an<br />
Programmiersprachen kennenlernen, und nicht alle sehen Haskell als Sieger.
3. Eine einfache Programmiersprache<br />
Ziele des Kapitels: Im Kapitel Modellierung haben wir uns darauf konzentriert, wie man<br />
Objekte und Zusammenhänge der Realität durch Formeln und ihr Verhalten rechnerisch<br />
nachbildet. Wir haben uns darauf verlassen, daß jeder mit Formeln rechnen kann, auch<br />
wenn diese etwas anders aussehen als aus der Mathematik gewohnt. In diesem Kapi- Grit Garbo: Mit anderen Worten: ganz schön ungewohnt.<br />
tel nehmen wir die umgekehrte Perspektive ein und betrachten näher, wie die Formeln<br />
aussehen, mit denen wir rechnen (lassen) wollen. Mit anderen Worten: wir legen eine<br />
Programmiersprache fest. Diese Sprache wollen wir möglichst einfach halten, da sie uns<br />
nur als Vehikel dient, um über weitere Grundkonzepte der Informatik reden zu können.<br />
Wir werden also nur einen Auschnitt aus der Sprache Haskell einführen.<br />
Gliederung des Kapitels: Ein Haskell-Programm besteht aus einer Folge von Definitionen<br />
und Deklarationen: Definiert werden Werte, meistens Funktionen, und neue Datentypen;<br />
deklariert werden die Typen von Werten. Für Rechnungen spielen Deklarationen keine<br />
Rolle; sie sind nur der „Ordnung halber“ da. Aber wie sagt man so schön: „Ordnung ist<br />
das halbe Leben.“.<br />
3.1. Datentypen<br />
3.1.1. Datentypdefinitionen<br />
Neue Typen und die dazugehörigen Werte werden mittels einer Datentypdefinition definiert.<br />
Sie führen Daten-Konstruktoren (kurz Konstruktoren) ein, die unsere Formelwelt<br />
bereichern. Beispiele aus Abschnitt 2.1 sind Musik und Instrument, die wir hier noch<br />
einmal wiederholen:<br />
data Instrument = Oboe<br />
| HonkyTonkPiano<br />
| Cello<br />
| VoiceAahs<br />
data Musik = Note Ton Dauer<br />
| Pause Dauer<br />
| Musik :*: Musik<br />
| Musik :+: Musik
Übung 3.1 Definiere (&&), (||) und not.<br />
26 3. Eine einfache Programmiersprache<br />
| Instr Instrument Musik<br />
| Tempo GanzeZahl Musik<br />
Der Typ Instrument führt nur nullstellige Konstruktoren ein. Solche Typen nennt man<br />
Aufzählungstypen. Der Typ Musik hat einen einstelligen Konstruktor (Pause) und fünf<br />
zweistellige Konstruktoren (Note, (:*:), (:+:), Instr, Tempo). Diese Konstruktoren<br />
nehmen wiederum Argumente eines bestimmten Typs, hier etwa Ton und Dauer für Note<br />
und Instrument und Musik bei Instr.<br />
Die Typen der Konstruktor-Argumente können auch Typparameter sein, wie wir das<br />
schon beim Listentyp gesehen haben. Alle Typparameter werden als Argumente des Typnamens<br />
auf der linken Seite der Definition angegeben. Die Datentypdefinition selbst wird<br />
mit dem Schlüsselwort 1 data eingeleitet. Die allgemeine Form einer Datentypdefinition<br />
hat somit die Gestalt<br />
data T a1 . . . am = C1 t11 . . . t1n1<br />
| . . .<br />
| Cr tr1 . . . trnr<br />
mit m 0, r 1 und ni 0. Im Falle m > 0 bezeichnet man den neuen Typ T auch<br />
als Typkonstruktor, weil er in Analogie zu den Daten-Konstruktoren Typen als Argumente<br />
nimmt und daraus Typausdrücke bildet. Allerdings treten diese nur in Typangaben auf und<br />
sind keine Formeln, mit denen gerechnet wird.<br />
Programmierhinweis: Die Namen von Typen bzw. Typkonstruktoren und Konstruktoren müssen<br />
großgeschrieben werden (genauer: der erste Buchstabe groß, alle folgenden beliebig). Die Namen<br />
von Werten bzw. Funktionen, Parametern und Typparametern müssen hingegen klein geschrieben<br />
werden (genauer: der erste Buchstabe klein, alle folgenden beliebig). Jeder Name darf nur einmal<br />
verwendet werden. Ausnahme: Für einen Typ und einen Konstruktor bzw. für eine Variable und<br />
einen Typparameter darf der gleiche Name benutzt werden, da sich Werte und Typen „nicht in die<br />
Quere kommen“.<br />
Wir sehen uns nun einige Datentypen von allgemeiner Nützlichkeit an. Wir geben jeweils<br />
die Typdefinition an, erläutern die Semantik des Typs, und gehen gegebenenfalls auf<br />
spezielle Notationen ein.<br />
Wahrheitswerte<br />
data Bool = False | True -- vordefiniert<br />
Die Konstruktoren False und True bezeichnen die Wahrheitswerte Falsch und Wahr.<br />
Der Typ Bool ist ein Aufzählungstyp. Die Boole’schen Funktionen Konjunktion, Disjunktion<br />
und Negation werden in Haskell mit (&&), (||) und not notiert.<br />
1 Ein Schlüsselwort ist ein reservierter Name, der nicht für Funktionen etc. verwendet werden kann.
3.1. Datentypen 27<br />
ifThenElse :: Bool -> a -> a -> a<br />
ifThenElse True a a’ = a<br />
ifThenElse False a a’ = a’<br />
Für die Funktion ifThenElse gibt es als besondere Schreibweise die sogenannte Mixfix-<br />
Notation, bei der der Funktionsname zwischen die Argumente eingestreut wird, etwa wie<br />
in if x >= 0 then x else -x.<br />
Ganze Zahlen<br />
Ganze Zahlen sind vordefiniert als die Typen Integer und Int. Während Integer ganze<br />
Zahlen beliebiger Größe enthält, sind die Zahlen im Typ Int auf einen bestimmten<br />
Zahlenbereich begrenzt, der mindestens das Intervall [−2 29 , 2 29 − 1] umfaßt. Die Zahlen<br />
vom Typ Int heißen auch Maschinenzahlen, während ihre Kolleginnen vom Typ Integer<br />
mathematische Zahlen genannt werden. Auf letzteren kann mit beliebiger Genauigkeit gerechnet<br />
werden, wobei man die Genauigkeit mit einer kleinen Geschwindigkeitseinbuße<br />
erkauft. Auf ganzen Zahlen sind die üblichen arithmetische Operationen vordefiniert: Harry Hacker: Lieber schnell und falsch als langsam und genau.<br />
(+), (-), (*), div, mod und (ˆ). Man beachte, daß es keine „echte Division“ auf den<br />
ganzen Zahlen gibt: div bezeichnet die Division mit Rest. Es gilt der folgende Zusammenhang:<br />
für alle ganzen Zahlen a und b.<br />
Tupeltypen<br />
data Pair a b = Pair a b<br />
data Triple a b c = Triple a b c<br />
(a ‘div‘ b) * b + (a ‘mod‘ b) == a (3.1)<br />
Der Typkonstruktor Pair bildet Paare. Genauer: Pair a b umfaßt alle Paare, deren<br />
erste Komponente vom Typ a und deren zweite vom Typ b ist. Entsprechend umfaßt<br />
Triple a b c alle 3-Tupel. Für beide Typkonstruktoren gibt es äquivalente, vordefinierte<br />
Typkonstruktoren: statt Pair a b schreibt man (a, b), statt Triple a b c schreibt<br />
man (a, b, c). Nicht nur Paare und Tripel sind vordefiniert, sondern beliebige n-Tupel.<br />
Bei Typkonstruktoren, die nur einen Datenkonstruktor haben, ist es üblich, für beide<br />
den gleichen Namen zu verwenden: Pair 3 False ist ein Element vom Typ Pair<br />
Integer Bool. Oder, in der Tupel-Notation: (3, False) ist ein Element vom Typ<br />
(Integer, Bool). Die Konvention, für Werte und Typen die gleiche Notation zu verwenden,<br />
ist am Anfang oft verwirrend. Hat man sich aber erst einmal daran gewöhnt, ist<br />
es sehr bequem, da man sich nur eine Notation merken muß.<br />
Für Paare sind Selektorfunktionen für das erste bzw. zweite Element vordefiniert.<br />
Übung 3.2 Definiere Selektorfunktionen first, second und<br />
third für 3-Tupel.
Lisa Lista: Kann man Folgen nicht auch anders modellieren? Wie<br />
wär’s mit:<br />
data List a = Empty<br />
| Single a<br />
| App (List a) (List a)<br />
Der Konstruktor Empty ist wie gehabt die leere Liste; Single a<br />
meint eine einelementige Liste und App hängt zwei Listen aneinander.<br />
Harry Hacker: Witzig. Ich hab schon mal weitergeblättert: in<br />
Abschnitt 3.4 wird was ähnliches eingeführt. Die nennen das<br />
aber anders: Tree statt List, Nil statt Empty, Leaf statt<br />
Single und Br statt App.<br />
Prof. Paneau: Liebe StudentInnen, nun wollen wir uns nicht an<br />
Namen stören: allein die Struktur zählt. Lisa greift etwas vor;<br />
aber sie hat Recht: beide Darstellungen sind denkbar. Und: beide<br />
haben ihre Vor- und Nachteile. Frau Lista, ich empfehle Ihnen,<br />
(++), head, tail und reverse auf Ihrer Variante des<br />
Datentyps List zu definieren.<br />
Lisa Lista: Hey, (++) ist simpel:<br />
(++) :: List a -> List a -> List a<br />
(++) = App<br />
Oh je, head und tail sind komplizierter.<br />
Übung 3.3 Hilf Lisa bei der Definition von head und tail.<br />
Lisa Lista: Die Definition von reverse ist elegant.<br />
reverse’ :: List a -> List a<br />
reverse’ Empty = Empty<br />
reverse’ (Single a) = Single a<br />
reverse’ (App l r) = App (reverse’ r) (reverse’ l)<br />
28 3. Eine einfache Programmiersprache<br />
fst :: (a,b) -> a -- vordefiniert<br />
fst (a,b) = a<br />
snd :: (a,b) -> b -- vordefiniert<br />
snd (a,b) = b<br />
Listen<br />
data List a = Empty | Front a (List a)<br />
Listen modellieren Folgen oder Sequenzen, also Sammlungen von Elementen, bei denen<br />
die Reihenfolge der Elemente und die Häufigkeit eines Elements eine Rolle spielt. Dies unterscheidet<br />
sie von Mengen, die sowohl von der Reihenfolge als auch von der Häufigkeit<br />
abstrahieren.<br />
Der Typ List a umfaßt alle Listen über dem Grundtyp a: Empty ist die leere Liste,<br />
Front a x ist die Liste, deren erstes Element a ist, und deren restliche Elemente mit den<br />
Elementen von x übereinstimmen; a wird als Kopfelement und x als Restliste bezeichnet.<br />
Der Typ List a ist rekursiv definiert, da List a auch als Argumenttyp der Konstructors<br />
Front auftritt. Rekursive Typen haben wir auch schon im Falle von Musik kennengelernt.<br />
Neu ist hier nur, daß im Falle eines Typkonstruktors auch auf der rechten Seite<br />
der Typparameter angegeben werden muß.<br />
Auch für List a gibt es einen äquivalenten, vordefinierten Typ, den wir bereits aus<br />
Abschnitt 2.4 kennen: Statt List a schreibt man [a], Empty wird zu [] und Front e x<br />
zu e:x. Um Listen mit n Elementen aufzuschreiben, gibt es eine abkürzende Schreibweise:<br />
Statt e1:(e2:· · ·(en:[])· · ·) schreibt man kurz [e1,e2,...,en].<br />
Listen sind allgegenwärtig, und es gibt viele vordefinierte Funktionen. Der Operator<br />
(++) hängt zwei Listen aneinander; head und tail greifen das Kopfelement bzw. die<br />
Restliste heraus; reverse kehrt die Reihenfolge der Elemente um.<br />
(++) :: [a] -> [a] -> [a]<br />
[] ++ bs = bs<br />
(a:as) ++ bs = a:(as++bs)<br />
head :: [a] -> a<br />
head (a:as) = a<br />
tail :: [a] -> [a]<br />
tail (a:as) = as<br />
reverse :: [a] -> [a]<br />
reverse [] = []<br />
reverse (a:as)= reverse as ++ [a]
3.1. Datentypen 29<br />
Der Typ Maybe<br />
data Maybe a = Nothing | Just a -- vordefiniert<br />
Der Typ Maybe a enthält die Elemente von Typ a (in der Form Just a) und zusätzlich<br />
das Element Nothing. Oft wird Maybe zur Fehlerindikation und Fehlerbehandlung eingesetzt:<br />
Nothing zeigt an, daß eine Operation nicht erfolgreich war; Just e zeigt an, daß<br />
die Operation erfolgreich mit dem Resultat e abgeschlossen worden ist. Auch Maybe a<br />
ist vordefiniert. Wie List a und Pair a b ist Maybe a ein parametrisierter Typ, ein<br />
Typkonstruktor.<br />
Als Beispiel nehmen wir an, wir wollen das kleinste Element einer Liste von Zahlen<br />
bestimmen. Bei einer nicht-leeren Liste ist dies klar:<br />
minimum1 :: [Integer] -> Integer<br />
minimum1 [a] = a<br />
minimum1 (a:as) = min a (minimum1 as)<br />
Die Funktion minimum1 ist für leere Listen nicht definiert. Will man auch den Fall leerer<br />
Listen handhaben, muß man die Funktion etwas allgemeiner definieren:<br />
minimum0 :: [Integer] -> Maybe Integer<br />
minimum0 [] = Nothing<br />
minimum0 (a:as) = Just (minimum1 (a:as))<br />
Wenn man so will, spielt Nothing hier die Rolle von +∞, dem neutralen Element von<br />
min.<br />
Zeichen und Zeichenketten<br />
data Char = ... | ’0’ | ’1’ ... -- Pseudo-Haskell<br />
| ... | ’A’ | ’B’ ...<br />
| ... | ’a’ | ’b’ ...<br />
type String = [Char]<br />
Der Typ Char umfaßt die Zeichen des ASCII-Alphabets. Die Zeichen werden in Apostrophe<br />
gesetzt, um sie von den sonst im Programm verwendeten Bezeichnungen zu unterscheiden.<br />
Der Datentyp String beinhaltetet Zeichenketten, auch Texte genannt. Er ist<br />
eine Spezialisierung des polymorphen Listentyps: Zeichenketten sind Listen mit Elementtyp<br />
Char. Er führt keine neuen Konstruktoren ein, sondern wird als Typsynonym definiert.<br />
(Mehr zu Typsynonymen findet man in Abschnitt 3.1.2.) Da Zeichenketten häufig verwendet<br />
werden, gibt es eine bequeme Notation: statt der Liste [’M’,’a’,’r’,’v’,’i’,<br />
’n’] schreibt man "Marvin", statt [] einfach "". Auf Zeichenketten sind natürlich alle<br />
Lisa Lista: Diese Definition ist nicht geheuer. Schließlich kann<br />
man [a] ebensogut als a:[] schreiben. Welche Gleichung gilt<br />
dann? Die zweite Gleichung führt zu der verbotenen Anwendung<br />
minimum1 [].<br />
Lisa Lista: Hmm — dann muß [] den Typ [a] haben, aber ""<br />
den Typ String.
30 3. Eine einfache Programmiersprache<br />
Listenoperationen anwendbar, wie (++) oder reverse.<br />
Programmierhinweis: Ein doppelter Anführungsstrich beendet eine Zeichenkette. Damit stellt sich<br />
die Frage, wie man einen Anführungsstrich in einer Zeichenkette unterbringt: wie schreibt man<br />
[’"’, ’a’, ’"’] kurz? Zu diesem Zweck gibt es sogenannte Ersatzdarstellungen, die mit einem<br />
Backslash „\“ eingeleitet werden: "\"a\"". Den gleichen Trick verwendet man, um einen einfachen<br />
Anführungsstrich als Zeichen zu schreiben: ’\’’. Da der Backslash nun seinerseits eine Sonderrolle<br />
einnimmt, muß auch er mit einer Ersatzdarstellung notiert werden: "\\". Der Haskell-Report<br />
führt in §2.5 alle Ersatzdarstellungen auf. Die Funktion isSpace illustriert die Verwendung von<br />
Ersatzdarstellungen; sie überprüft, ob ein Zeichen ein Leerzeichen ist.<br />
isSpace :: Char -> Bool<br />
isSpace c = c == ’ ’ || c == ’\t’ || c == ’\n’ ||<br />
c == ’\r’ || c == ’\f’ || c == ’\v’<br />
3.1.2. Typsynonyme<br />
Typsynonyme bereichern die Formelwelt nicht. Manchmal macht es jedoch Sinn, einem<br />
bestimmten Typ einen zweiten Namen zu geben. Oft ist dies nur bequem im Sinne einer<br />
Abkürzung. Manchmal will man andeuten, daß man die Objekte dieses Typs in einer<br />
besonderen, eingeschränkten Weise verwendet. Verschiedene Typsynonyme haben wir<br />
schon gesehen (Ton, Dauer, DNA, Protein, String). Ein weiteres Beispiel zeigt zugleich<br />
Nutzen und Gefahr: Stellen wir uns vor, wir wollen in einem Programm Listen (Typ [a])<br />
verwenden, die aber stets geordnet sein sollen. Wir können ein Typsynonym einführen,<br />
das diese Absicht ausdrückt:<br />
type OrdList a = [a]<br />
merge :: OrdList a -> OrdList a -> OrdList a<br />
Im Typ der Funktion merge können wir damit ausdrücken, daß diese Funktion zwei<br />
geordnete Listen verknüpft und eine geordnete Liste als Ergebnis hat. Wir denken dabei<br />
an ein Pedant der (++)-Funktion auf geordneten Listen. So weit so gut — unsere Absicht<br />
haben wir damit ausgedrückt. Ob wir unser Ziel auch erreichen, liegt jedoch ausschließlich<br />
daran, wie wir merge programmieren. Was die Typen betrifft, sind Ordlist a und [a]<br />
gleich, so daß von daher niemand garantiert, daß das Ergebnis von merge tatsächlich<br />
geordnet ist. In diesem Sinn können Typsynonyme auch einmal falsche Sicherheit stiften.<br />
3.1.3. Typdeklarationen, Typprüfung und Typinferenz<br />
Wenn wir neue Bezeichner einführen, werden wir stets ihre Typen deklarieren. Dies geschieht<br />
in der Form, die wir in Abschnitt 2.2 kennengelernt haben. Die Deklaration<br />
x :: τ besagt, daß wir für den Bezeichner x den Typ τ vorsehen. Streng genommen
3.1. Datentypen 31<br />
müssen die Typen nicht deklariert werden, da sie aus der Definition der Bezeichner abgeleitet<br />
werden können. Diesen Vorgang nennt man Typinferenz. Es ist nützlich dieses<br />
Verfahren zumindest in groben Zügen zu kennen. Denn: viele Programmierfehler äußern<br />
sich als Tippfehler; um dem Fehler auf die Schliche zu kommen, muß man die Fehlermeldung<br />
verstehen. Betrachten wir die Definition der vordefinierten Funktion map:<br />
map f [] = [] -- vordefiniert<br />
map f (a:as) = f a : map f as<br />
Wir leiten in mehreren Schritten einen immer genaueren Typ für map ab. Dabei sind c,<br />
d, e etc Typvariablen, die zunächst frei gewählt sind, dann aber durch Betrachtung der<br />
Definition von map weiter spezifiziert werden.<br />
Beobachtungen über map Typ von map<br />
map hat zwei Argumente. c -> d -> e<br />
Das Ergebnis ist eine Liste. c -> d -> [b]<br />
Das zweite Argument ist eine Liste. c -> [a] -> [b]<br />
Das erste Argument ist eine Funktion, (f -> g) -> [a] -> [b]<br />
die Argumente vom Typ a erhält, (a -> g) -> [a] -> [b]<br />
und Elemente der Ergebnisliste vom Typ b liefert. (a -> b) -> [a] -> [b]<br />
Die gewonnene Typaussage läßt sich etwas wortreich so formulieren: map ist eine Funktion,<br />
die auf beliebige Funktionen f und Listen as anwendbar ist, vorausgesetzt der Argumenttyp<br />
von f stimmt mit dem Elementtyp von as überein. Das Ergebnis ist dann eine<br />
Liste von Elementen, die den Ergebnistyp von f haben. Zum Glück kann man dies auch<br />
kürzer sagen, eben<br />
map :: (a -> b) -> [a] -> [b]<br />
Wir haben darauf geachtet, daß der Typ von map nicht unnötig eingeschränkt wird. Hätten<br />
wir grundlos den gleichen Typ für Argument- und Ergebnisliste angenommen, etwa [a],<br />
hätten wir insgesamt den spezifischeren Typ (a -> a) -> [a] -> [a] erhalten. Bei<br />
der Typinferenz wird immer der allgemeinste Typ hergeleitet, um den Einsatzbereich einer<br />
Funktion nicht unnötig zu verengen.<br />
Der Typ von map ist polymorph, da er Typparameter enthält. Wird map auf konkrete<br />
Argumente angewandt, spezialisiert sich der Typ in der Anwendung.<br />
Harry Hacker: Muß ich mich wirklich mit diesem Typenwahn<br />
herumschlagen? Korrekte Programme brauchen keine Typen. Jede<br />
Funktion angewandt auf’s richtige Argument — da kann ja<br />
gar nichts schiefgehen.<br />
Lisa Lista: Mag schon sein, Harry. Aber wie kommst Du zu den<br />
korrekten Programmen?
Harry Hacker: Also enthält Eq alle Typen. Warum dieser Aufwand?<br />
Lisa Lista: Hmm — wie steht’s mit Funktionen? Kann man<br />
f == g für zwei Funktionen überhaupt implementieren? Und<br />
was ist mit Typen, die Funktionen enthalten?<br />
32 3. Eine einfache Programmiersprache<br />
Spezialisierung von<br />
Anwendung von map a b<br />
map c’ [1/4, 1/8, 1/8, 1/4] Dauer Musik<br />
map (transponiere 5)<br />
[cDurTonika, cDurSkala, bruderJakob] Musik Musik<br />
map genCode cs Codon AminoAcid<br />
map (map translate) orfs zwei Auftreten verschiedenen Typs!<br />
inneres Auftreten Codon Protein<br />
äußeres Auftreten [[Codon]] [Protein]<br />
Der Haskell-Übersetzer führt stets die Typinferenz durch. Dies ist zugleich die Typ-Überprüfung:<br />
Stößt die Typinferenz auf einen Widerspruch, so daß sich kein Typ ableiten läßt,<br />
liegt eine nicht wohl-getypte Formel, kurz ein Typfehler vor. Daneben kann es geschehen,<br />
daß der abgeleitete Typ spezieller ist als der deklarierte Typ. Auch in diesem Fall erfolgt<br />
eine Fehlermeldung des Übersetzers, die man vermeiden kann, wenn man die Typdeklaration<br />
löscht. Wesentlich klüger ist es allerdings in einem solchen Fall, noch einmal darüber<br />
nachzudenken, welche Funktion man eigentlich programmieren wollte. Der umgekehrte<br />
Fall ist erlaubt: der abgeleitete Typ ist allgemeiner als der deklarierte. In diesem Fall wird<br />
die Anwendbarkeit der Funktion bewußt oder vielleicht unbewußt eingeschränkt.<br />
3.1.4. Typklassen und Typkontexte<br />
Typklassen und Typkontexte werden hier nur kurz besprochen, insoweit es für die nächsten<br />
Kapitel erforderlich ist. Wir werden sie im Kapitel über Typabstraktion genauer kennen<br />
lernen.<br />
Typklassen fassen Typen zusammen, die gewisse Operationen gemeinsam haben. Hier<br />
soll nur gesagt werden, daß es u. a. vordefinierte Typklassen Eq, Ord, Num, Integral<br />
und Show gibt. Die Typklasse<br />
• Eq enthält alle Typen, deren Elemente man auf Gleichheit testen kann, und definiert<br />
die folgenden Funktionen:<br />
(==) :: (Eq a) => a -> a -> Bool<br />
(/=) :: (Eq a) => a -> a -> Bool<br />
• Ord enthält alle Typen, deren Elemente man bezüglich einer Ordnungsrelation vergleichen<br />
kann, und definiert u.a. die folgenden Funktionen:<br />
() :: (Ord a) => a -> a -> Bool<br />
max, min :: (Ord a) => a -> a -> a
3.1. Datentypen 33<br />
• Num enthält alle numerischen Typen und definiert die grundlegenden arithmetischen<br />
Operationen.<br />
(+), (-), (*) :: (Num a) => a -> a -> a<br />
negate :: (Num a) => a -> a<br />
Die Funktion negate entspricht dem unären Minus.<br />
• Integral enthält die ganzzahligen Typen Int und Integer und definiert u.a. die<br />
folgenden Funktionen:<br />
div, mod :: (Integral a) => a -> a -> a<br />
even, odd :: (Integral a) => a -> Bool<br />
• Show enthält alle Typen, die eine externe Darstellung als Zeichenketten haben. Die<br />
Funktion show liefert diese Darstellung.<br />
show :: (Show a) => a -> String<br />
Für alle vordefinierten Typen ist natürlich bereits festgelegt, welchen Typklassen sie angehören.<br />
Bei neu deklarierten Typen kann man dies für die Klassen Eq, Ord und Show<br />
erreichen, indem der Typdefinition die Klausel deriving (Eq, Ord, Show) hinzugefügt<br />
wird. Dies bewirkt, daß automatisch die oben genannten Operationen auch auf dem<br />
neuen Datentyp definiert sind.<br />
Typkontexte schränken den Polymorphismus von Funktionen ein: Die Schreibweise<br />
(K a) => ... besagt, daß der Typparameter a auf Typen eingeschränkt ist, die der Klasse<br />
K angehören. So haben wir zum Beispiel in der Definition der Funktion minimum1 vorausgesetzt,<br />
daß die Listenelemente mittels min verglichen werden können. Da min den<br />
Typ (Ord a) => a -> a - > a hat, müssen die verglichenen Listenelemente der Typklasse<br />
Ord angehören. Dies ist der Fall: der Typ Integer gehört der Typklasse Ord an.<br />
Wollen wir eine polymorphe minimum-Funktion definieren, so geschieht dies analog zu<br />
minimum1 unter Benutzung eines Typkontextes (Ord a) =>.<br />
minimum :: (Ord a) => [a] -> a -- vordefiniert<br />
minimum [a] = a<br />
minimum (a:as) = min a (minimum as)<br />
Hier erkennt man die enorme praktische Bedeutung der Typklassen: Die obige Definition<br />
definiert minimum auf allen Listen geeigneten Typs. Speziellere Versionen wie unser<br />
minimum1 sind überflüssig.
Lisa Lista: Hmm — monotonie ist doch identisch mit dem<br />
Funktionsaufruf adInfinitum (c’ (1/1)). Aber auch die<br />
rekursive Definition von adInfinitum ist ungewöhnlich.<br />
34 3. Eine einfache Programmiersprache<br />
3.2. Wertdefinitionen<br />
3.2.1. Muster- und Funktionsbindungen<br />
Musterbindungen<br />
Mit Hilfe einer oder mehrerer Gleichungen definieren wir einen oder mehrere Bezeichner.<br />
Im einfachsten Fall hat die Gleichung die Form x = e, wobei x der definierte Bezeichner<br />
ist und e der definierende Ausdruck. Gleichungen dieser Form heißen auch Variablenbindungen,<br />
da die Variable an den Wert der rechten Seite gebunden wird.<br />
theFinalAnswer :: Integer<br />
theFinalAnswer = 42<br />
aShortList :: [Integer]<br />
aShortList = [1,2,3]<br />
helloWorld :: String<br />
helloWorld = "Hello World"<br />
Das Gleichheitszeichen drückt aus, daß die linke Seite und die rechte Seite per definitionem<br />
gleich sind; somit können wir theFinalAnswer stets durch 42 ersetzen und<br />
umgekehrt, ohne dabei die Bedeutung des Programms zu verändern. Weiterhin tut es<br />
einer Definition keinen Abbruch, wenn der Ausdruck auf der rechten Seite erst noch auszurechnen<br />
ist:<br />
theFinalAnswer’ :: Integer<br />
theFinalAnswer’ = 6*7<br />
aShortList’ :: [Integer]<br />
aShortList’ = reverse ([3]++[2,1])<br />
helloWorld’ :: String<br />
helloWorld’ = "Hello" ++ " " ++ "World"<br />
Diese Bezeichner haben den gleichen Wert wie die zuvor definierten. Interessantere<br />
Beispiele für Variablenbindungen sind cDurSkala und bruderJakob aus Kapitel 2.<br />
Bezeichner können auch rekursiv definiert sein. Von Funktionen sind wir das mittlerweile<br />
gewohnt; neu und überraschend ist vielleicht, daß Elemente beliebiger Datentypen<br />
rekursiv definiert sein können.<br />
monotonie :: Musik<br />
monotonie = c’ (1/1) :*: monotonie
3.2. Wertdefinitionen 35<br />
Was ist an dieser Definition ungewöhnlich? Genau — das zu definierende Objekt wird<br />
durch sich selbst erklärt. Daß eine solche Definition nicht sinnlos ist, kann man sich auf<br />
zwei Arten klarmachen. Beginnt man einfach, das Musikstück monotonie mit Hilfe seiner<br />
Definition auszurechnen, sieht man schnell, daß das (potentiell) unendliche Stück<br />
c’ (1/1) :*: c’ (1/1) :*: · · · entsteht. Fragt man sich, ob es ein Musikstück gibt,<br />
das die Definitionsgleichung von monotonie erfüllt, kommt man (nach einigen Fehlversuchen<br />
mit endlichen Stücken) ebenfalls auf die Antwort c’ (1/1) :*: c’ (1/1) :*:<br />
· · ·. Unendliche Objekte in der Art von monotonie sind kein Problem in Haskell, man<br />
darf sie nur nie ganz ausrechnen (lassen).<br />
Auf der linken Seite einer Gleichung darf auch ein sogenanntes Muster stehen. In diesem<br />
Fall spricht man von einer Musterbindung; diese sind von der Form p = e, wobei p ein<br />
Muster und e ein Ausdruck ist. Ein Muster ist eine Formel, die ausschließlich aus Variablen<br />
und Konstruktoren aufgebaut ist: (a, b), a : a’ : as oder m1 :*: m2 sind Beispiele<br />
für Muster. In einem Muster darf keine Variable mehrfach enthalten sein: (a, a) ist<br />
kein gültiges Muster. Musterbindungen treten in erster Linie im Zusammenhang mit lokalen<br />
Definitionen auf, die wir in Abschnitt 3.2.3 kennenlernen werden. Aus diesem Grund<br />
verzichten wir hier auf die Angabe von Beispielen.<br />
Funktionsbindungen<br />
Datentypen erlauben uns, Formeln zu bilden, Funktionen erlauben uns, mit ihnen zu rechnen.<br />
Eine Funktion wird durch eine oder mehrere Gleichungen definiert. Generell gilt, daß<br />
diese Gleichungen unmittelbar aufeinander folgen müssen, und nicht etwa im Programm<br />
verteilt stehen dürfen.<br />
Mehrfache Gleichungen dienen einer Fallunterscheidung über die Argumente. Im einfachsten<br />
und häufigsten Fall wird diese Unterscheidung anhand der Konstruktoren getroffen,<br />
aus denen die Argumente aufgebaut sind. Entsprechend dem Typ der Argumente gibt<br />
es im einfachsten Fall genau eine Gleichung pro Konstruktor. Fast alle bisherigen Definitionen<br />
haben wir so geschrieben. Hier ein weiteres Beispiel:<br />
length :: [a] -> Int -- vordefiniert<br />
length [] = 0<br />
length (a:as) = 1 + length as<br />
Der Typ [a] hat die zwei Konstruktoren [] und (:), die gerade die beiden relevanten<br />
Fälle unterscheiden helfen. Analog in der Definition von transponiere in Kapitel 2: Der<br />
Typ Musik hat sechs Konstruktoren, entsprechend werden sechs Gleichungen geschrieben.
Übung 3.4 Definiere member ohne Wächter unter Verwendung<br />
der Boole’schen Funktionen (&&) und (||).<br />
36 3. Eine einfache Programmiersprache<br />
Der allgemeine Fall ist etwas flexibler: Die Definition einer n-stelligen Funktion f durch<br />
Mustergleichungen geschieht durch k 1 Gleichungen der Form<br />
f p11 . . . p1n = e1<br />
. . . = . . .<br />
f pk1 . . . pkn = ek<br />
Dabei sind die pij Muster und die ei Ausdrücke. Es gilt die Einschränkung, daß die Muster<br />
einer Gleichung keine Variable mehrfach enthalten dürfen. Die Ausdrücke ei dürfen<br />
jeweils die Variablen aus den Mustern pi1, . . . , pin enthalten. Da Muster beliebig kompliziert<br />
aufgebaut sein können, kann es mehr Gleichungen als Konstruktoren geben.<br />
3.2.2. Bewachte Gleichungen<br />
Nicht immer lassen sich die relevanten Fälle allein anhand von Mustern unterscheiden.<br />
Fallunterscheidungen können aus diesem Grund auch mit Hilfe von sogenannten Wächtern<br />
erfolgen. Die Funktion dropSpaces entfernt führende Leerzeichen aus einer Zeichenkette.<br />
dropSpaces :: String -> String<br />
dropSpaces [] = []<br />
dropSpaces (c:cs)<br />
| isSpace c = dropSpaces cs<br />
| otherwise = c : dropSpaces cs<br />
Der Boole’sche Ausdruck nach dem senkrechten Strich heißt Wächter, da er über die<br />
Anwendung der jeweiligen Gleichung wacht. Der Ausdruck otherwise ist ein Synonym<br />
für True und tritt — wenn überhaupt — als letzter Wächter auf. Damit ist schon gesagt,<br />
daß die Anzahl der Wächter nicht auf zwei beschränkt ist.<br />
Die Funktion squeeze komprimiert Wortzwischenräume auf ein einzelnes Leerzeichen.<br />
squeeze :: String -> String<br />
squeeze [] = []<br />
squeeze (c:c’:cs)<br />
| isSpace c && isSpace c’ = squeeze (c’:cs)<br />
squeeze (c:cs) = c : squeeze cs<br />
Die zweite Gleichung behandelt den Fall, daß die Zeichenkette mindestens zwei Elemente<br />
umfaßt und beide Zeichen als Leerzeichen qualifizieren. Ist dies gegeben, so wird<br />
ein Leerzeichen entfernt.<br />
Drei Wächter kommen bei der Definition von member zum Einsatz: member a bs<br />
überprüft, ob das Element a in der aufsteigend geordneten Liste bs enthalten ist.
3.2. Wertdefinitionen 37<br />
member :: (Ord a) => a -> OrdList a -> Bool<br />
member a [] = False<br />
member a (b:bs)<br />
| a < b = False<br />
| a == b = True<br />
| a > b = member a bs<br />
Ist das gesuchte Element a kleiner als der Listenkopf, kann die Suche unmittelbar abgebrochen<br />
werden.<br />
3.2.3. Gleichungen mit lokalen Definitionen<br />
Rechnen ist selten eine geradlinige Angelegenheit: Man führt Hilfsrechnungen durch,<br />
stellt Zwischenresultate auf, verwendet diese in weiteren Zwischenrechnungen usw. Es<br />
ist nur natürlich, dieses Vorgehen auch in unserer Programmiersprache zu ermöglichen.<br />
Der Zutaten bedarf es nicht vieler: Eine oder mehrere Formeln, die zwischengerechnet<br />
werden, einen oder mehrere Namen für die Ergebnisse der Rechnungen und eine Formel,<br />
in der die Namen verwendet werden.<br />
Das folgende Programm, das die n-te Potenz einer ganzen Zahl x berechnet, illustriert<br />
die Verwendung einer lokalen Definition.<br />
power :: (Num a, Integral b) => a -> b -> a<br />
power x n<br />
| n == 0 = 1<br />
| n ‘mod‘ 2 == 0 = y<br />
| otherwise = y*x<br />
where y = power (x*x) (n ‘div‘ 2)<br />
Nach dem Schlüsselwort where werden die Hilfsdefinitionen angegeben (hier ist es<br />
nur eine). Mit Ausnahme von Typdefinitionen können alle Arten von Definitionen und<br />
Deklarationen, die wir bisher kennengelernt haben, auch lokal erfolgen. Die Funktion<br />
splitWord, die ein Wort von einer Zeichenkette abtrennt, illustriert die Verwendung<br />
von lokalen Musterbindungen.<br />
splitWord :: String -> (String, String)<br />
splitWord [] = ([],[])<br />
splitWord (c:cs)<br />
| isSpace c = ([], c:cs)<br />
| otherwise = (c:w, cs’)<br />
where (w, cs’) = splitWord cs<br />
Harry Hacker: Warum ist die Definition so kompliziert? Wie<br />
wär’s mit<br />
pow :: (Num a) => a -> Integer -> a<br />
pow x n<br />
| n == 0 = 1<br />
| otherwise = x * pow x (n-1)<br />
Lisa Lista: Hmm — rechne mal pow 2 1024 aus.<br />
Harry Hacker: Ja, ja, Du hast wieder mal recht. Aber daß die<br />
Definition von power korrekt ist, sehe ich noch nicht.<br />
Grit Garbo: Typisch, die Informatiker kennen die einfachsten<br />
Dinge nicht. Hier handelt es sich um eine triviale Anwendung<br />
der Potenzgesetze:<br />
x n = x 2(n div 2)+n mod 2 = (x 2 ) n div 2 n mod 2<br />
x
Harry Hacker: Wer so programmiert, ist selber schuld!<br />
38 3. Eine einfache Programmiersprache<br />
Auf der linken Seite der lokalen Definition steht ein Muster, woran das Ergebnis der<br />
rechten Seite gebunden wird. Auf diese Weise werden mehrere Variablen gleichzeitig definiert.<br />
Musterbindungen treten typischerweise bei der Definition rekursiver Funktionen<br />
auf, die Paare oder n-Tupel zum Ergebnis haben.<br />
Gültigkeits- oder Sichtbarkeitsbereiche<br />
Die where-Klausel führt neue Namen ein, genau wie andere definierende Gleichungen.<br />
Diese neuen Namen haben einen lokalen Bereich, in dem sie benutzt werden können.<br />
Diesen Bereich nennt man Gültigkeits- oder Sichtbarkeitsbereich. Er erstreckt sich auf die<br />
rechte Seite der Gleichung, mit der die where-Klausel assoziiert ist, und auf die rechten<br />
Seiten aller Gleichungen innerhalb der where-Klausel, einschließlich darin verschachtelter<br />
where-Klauseln. Führt eine lokale Definition einen neuen Namen ein, der im umgebenden<br />
Kontext bereits definiert ist, so gilt innerhalb des lokalen Sichtbarkeitsbereichs die<br />
lokale Definition. Man sagt, die globale Definition wird verschattet. Schauen wir uns ein<br />
(Negativ-) Beispiel an:<br />
f :: Int -> Int -- Negativ-Beispiel<br />
f x = f (x+x)<br />
where x = 1<br />
f x = x<br />
Der Bezeichner f wird zweimal definiert, die Variable x wird gar an drei Stellen eingeführt:<br />
zweimal als Parameter und einmal in der lokalen Definition x = 1. Die inneren<br />
Definitionen verschatten die äußeren, so daß f zu f1 mit<br />
f1 :: Int -> Int<br />
f1 x1 = f2 (x2+x2)<br />
where x2 = 1<br />
f2 x3 = x3<br />
äquivalent ist. Man sieht, daß die Definition der Funktion f insbesondere nicht rekursiv<br />
ist, da die lokale Definition bereits für den Ausdruck f (x + x) sichtbar ist und das<br />
global definierte f verschattet. Da Programme in der Art von f schwer zu lesen und<br />
zu verstehen sind, empfehlen wir, in lokalen Definitionen grundsätzlich andere („neue“)<br />
Bezeichner zu verwenden.<br />
Abseitsregel<br />
Eine Frage ist noch offen: Es ist klar, daß der lokale Sichtbarkeitsbereich der where-Klausel<br />
nach dem Schlüsselwort where beginnt. Aber wo endet er eigentlich? Woher wissen wir,
3.2. Wertdefinitionen 39<br />
daß die Gleichung f x = x Bestandteil der where-Klausel ist, und nicht etwa eine zweite<br />
(wenn auch überflüssige) Gleichung für das global definierte f darstellt?<br />
Antwort: der Übersetzer erkennt das Ende der where-Klausel am Layout des Programmtextes.<br />
Wir haben die lokale Definition entsprechend dem Grad ihrer Schachtelung<br />
eingerückt. Diese Formatierung erleichtert es, ein Programm zu lesen und zu verstehen.<br />
Der Übersetzer macht sich diese Konvention zunutze, um das Ende einer lokalen Definition<br />
zu erkennen. Es gilt die folgende sogenannte Abseitsregel:<br />
• Das erste Zeichen der Definition unmittelbar nach dem where bestimmt die Einrücktiefe<br />
des neu eröffneten Definitionsblocks.<br />
• Zeilen, die weiter als bis zu dieser Position eingerückt sind, gelten als Fortsetzungen<br />
einer begonnenen lokalen Definition.<br />
• Zeilen auf gleicher Einrücktiefe beginnen eine neue Definition im lokalen Block.<br />
• Die erste geringer eingerückte Zeile beendet den lokalen Block.<br />
Lisa Lista: Die Folgen dieser anderen Interpretation kommen<br />
mir etwas unheimlich vor!<br />
Harry Hacker: Ist das noch zu fassen — die Bedeutung des<br />
Programms ist vom Layout abhängig! Das ist ja wie in alten<br />
FORTRAN-Tagen! Noch dazu jede Definition auf einer neuen<br />
Zeile. Da werden die Programme viel länger als nötig.<br />
Die Abseitsregel erlaubt die Einsparung von Abgrenzungssymbolen wie begin, end oder<br />
geschweiften Klammern und Semikola, die man in anderen Programmiersprachen verwenden<br />
muß. Außerdem trägt sie dazu bei, daß Programme verschiedener Autoren in<br />
einem relativ einheitlichen Stil erscheinen. Trotz dieser Vorzüge ist die Verwendung der<br />
Abseitsregel optional — es gibt sie doch, die formatfreie Schreibweise: Eingeschlossen in Harry Hacker: Na also, sagt es doch gleich!<br />
geschweifte Klammern und getrennt durch Semikolons (also in der Form {d1; ...; dn})<br />
können die Definitionen eines Blocks beliebig auf eine Seite drapiert werden. Ein Fall, wo<br />
dies sinnvoll ist, ist eine lange Reihe sehr kurzer Definitionen.<br />
Die allgemeine Form von Gleichungen<br />
Jetzt haben wir alle Konstrukte kennengelernt, um die allgemeine Form einer Funktionsbindung<br />
einzuführen: Eine n-stellige Funktion f mit n 1 kann durch k Gleichungen<br />
definiert werden:<br />
f p11 . . . p1n m1<br />
. . .<br />
f pk1 . . . pkn mk<br />
Die pij sind beliebige Muster und die mi nehmen wahlweise eine der beiden folgenden<br />
Formen an:<br />
= e where { d1; . . . ;dp }
40 3. Eine einfache Programmiersprache<br />
oder<br />
| g1 = e1<br />
. . .<br />
| gq = eq<br />
where { d1; . . . ;dp }<br />
Der where-Teil kann jeweils auch entfallen. Bezüglich der Sichtbarkeit der Bezeichner gilt<br />
folgendes: Die in den di eingeführten Bezeichner sind in dem gesamten Block sichtbar,<br />
d.h. in den Wächtern gi, in den rechten Seiten ei und in den Definitionen di selbst. Den<br />
gleichen Sichtbartkeisbereich haben auch die Variablen, die in den Mustern auf der linken<br />
Seite eingeführt werden: die in pi1, . . . , pin enthaltenen Variablen sind in mi sichtbar<br />
(nicht aber in den anderen Gleichungen).<br />
Musterbindungen haben die allgemeine Form p m, wobei p ein Muster ist und m wie im<br />
Fall von Funktionsbindungen aussieht. Im Unterschied zu Funktionsbindungen definiert<br />
eine Musterbindung unter Umständen gleichzeitig mehrere Bezeichner.<br />
3.2.4. Das Rechnen mit Gleichungen<br />
Kommen wir zum Rechnen. Einen Teil seiner Schulzeit verbringt man damit, Rechnen zu<br />
lernen: Ist zum Beispiel die Funktion q gegeben durch<br />
q :: (Num a) => a -> a<br />
q x = 3*xˆ2 + 1<br />
dann wird man den Ausdruck q 2 - 10 unter Zuhilfenahme der Rechenregeln für die<br />
Grundrechenarten ohne große Schwierigkeiten zu 3 ausrechnen:<br />
q 2 - 10<br />
⇒ (3 * 2ˆ2 + 1) - 10 (Definition von q)<br />
⇒ (3 * 4 + 1) - 10 (Definition von (ˆ))<br />
⇒ (12 + 1) - 10 (Definition von (*))<br />
⇒ 13 - 10 (Definition von (+))<br />
⇒ 3 (Definition von (-))<br />
Das Rechnen in oder mit Haskell funktioniert nach dem gleichen Prinzip: Ein Ausdruck<br />
wird relativ zu einer Menge von Definitionen zu einem Wert ausgerechnet. Die Notation<br />
e ⇒ v verwenden wir, um dies zu formalisieren. Beachte: das Zeichen „⇒“ ist kein Bestandteil<br />
der Programmiersprache, sondern dient dazu, über die Programmiersprache zu<br />
reden.<br />
Wir unterscheiden zwei Formen des Rechnens: Da ist einmal das Ausrechnen eines<br />
Ausdrucks, seine Auswertung. Dabei werden die Gleichungen stets von links nach rechts
3.2. Wertdefinitionen 41<br />
angewandt: Tritt irgendwo in dem Ausdruck eine linke Seite einer Gleichung auf, so kann<br />
sie durch die rechte Seite ersetzt werden (wobei die Variablen aus den Mustern der linken<br />
Seite an Unterausdrücke gebunden werden, die in die rechte Seite einzusetzen sind).<br />
Dabei ist zu beachten: Wird ein Bezeichner durch mehrere Gleichungen definiert, so muß<br />
die erste (im Sinne der Aufschreibung) passende Gleichung verwendet werden. Die Gleichung<br />
f p1 . . . pn | g = e paßt auf eine Anwendung f a1 . . . an, wenn die Argumente a1,<br />
. . . , an aus den Konstruktoren aufgebaut sind, die durch die Muster p1, . . . pn vorgegeben<br />
sind und der Wächter g zu True auswertet. Die Rechnung endet, wenn keine Gleichung<br />
anwendbar ist. Man sagt dann, der Ausdruck ist in Normalform. Ein berühmter Satz von<br />
Church und Rosser besagt, daß die Normalform, wenn sie existiert, eindeutig ist. Wir können<br />
also den Wert eines Ausdrucks mit seiner Normalform identifizieren. Hat ein Ausdruck<br />
keine Normalform — d. h. die Rechnung endet nie — so ist sein Wert undefiniert. Auswertung<br />
eines Ausdrucks auf Normalform — das ist Rechnen lassen in einer funktionalen<br />
Programmiersprache.<br />
Die zweite Form des Rechnens tritt auf, wenn wir selbst nicht nur Werte ausrechnen,<br />
sondern über Programme nachdenken. Uns steht es dabei frei, Mustergleichungen auch<br />
von rechts nach links anzuwenden, oder mit Gleichungen zu rechnen, die allgemeine<br />
Programmeigenschaften beschrieben und keine Mustergleichungen sind. Mehr dazu im<br />
Kapitel 4.<br />
3.2.5. Vollständige und disjunkte Muster<br />
Im Allgemeinen achtet man darauf, daß die Mustergleichungen vollständig und disjunkt sind. Vollständig<br />
heißt, daß die Funktion für alle möglichen Fälle definiert ist. Disjunkt heißt, daß die Muster<br />
so gewählt sind, dß für jeden konkreten Satz von Argumenten nur eine Gleichung paßt. Die<br />
Definition von unique, das aufeinanderfolgende Duplikate aus einer Liste entfernt, erfüllt diese<br />
Forderungen.<br />
unique [] = []<br />
unique [a] = [a]<br />
unique (a:(b:z)) = if a == b then unique (b:z)<br />
else a : unique (b:z)<br />
Hier unterscheiden die Muster den Fall der null-, ein- oder mehrelementigen Liste.<br />
Wir betrachten nun Situationen, in der die Forderungen Vollständigkeit und Disjunktheit nicht<br />
erfüllt werden können:<br />
Nehmen wir die Funktion head mit head [a1,...,an] = a1. Ist die Liste leer, ist das Funktionsergebnis<br />
nicht definiert. Was bedeutet dies für das Rechnen? Nun, ist man gezwungen head []<br />
auszurechnen, so bleibt einem nicht anderes übrig als abzubrechen und aufzugeben. Das Beste,<br />
was man als Programmiererin machen kann, ist den Abbruch der Rechnung mit einer Meldung zu<br />
garnieren, die die Ursachen des Abbruchs beschreibt. Diesem Zweck dient die Funktion error.
42 3. Eine einfache Programmiersprache<br />
head :: [a] -> a -- vordefiniert<br />
head [] = error "head of empty list"<br />
head (a:x) = a<br />
Diese Definition von head ist also eine Verbesserung der früher gegebenen, die nun einen vollständigen<br />
Satz von Mustergleichungen aufweist. Ähnlich kann man im Falle der Funktion tail<br />
vorgehen.<br />
Zur Übung: Definiere tail mit tail [a1,...,an] = [a2,...,an].<br />
Sowohl head als auch tail bleiben auch in dieser Form partielle Funktionen, weil die Funktion<br />
error ja kein Ergebnis liefert, sondern die Rechnung abbricht. Alternativ zur Benutzung von error<br />
können wir beide Funktionen auch zu totalen Funktionen machen: Der Ergebnistyp wird um ein Element<br />
erweitert, das gerade „undefiniert“ repräsentiert. Diesem Zweck dient ja der Typkonstruktor<br />
Maybe.<br />
head’ :: [Integer] -> Maybe Integer<br />
head’ [] = Nothing<br />
head’ (a:x) = Just a<br />
Zur Übung: Definiere ein Pendant zu tail.<br />
Welche der beiden Varianten man verwendet, hängt stark vom Anwendungsfall ab. In der Regel<br />
wird es die erste sein, da diese bequemer ist: Z.B. sind head x:tail x oder tail (tail x)<br />
wohlgetypte Ausdrücke, head’ x:tail’ x oder tail’ (tail’ x) hingegen nicht. Aber man<br />
muß als Programmiererin darauf achten, daß head und tail stets richtig aufgerufen werden. Die<br />
zweiten Varianten ermöglichen, nein sie erzwingen es sogar, daß ein möglicher Fehler behandelt<br />
wird.<br />
Die Disjunktheit der Mustergleichungen ist verletzt, wenn es Argumente gibt, für die mehrere<br />
Gleichungen passen. Ein Beispiel dafür ist die Definition von words:<br />
words :: String -> [String]<br />
words xs = wds [] xs<br />
ws :: String -> String -> [String]<br />
wds "" "" = []<br />
wds ws "" = [reverse ws]<br />
wds "" (’ ’:xs) = wds "" xs<br />
wds "" ( x :xs) = wds [x] xs<br />
wds ws (’ ’:xs) = (reverse ws) : wds "" xs<br />
wds ws ( x :xs) = wds (x:ws) xs<br />
Die dritte Gleichung für wds paßt auch dann, wenn die zweite paßt: Schließlich kann x ja ein<br />
beliebiges Zeichen, also auch ’ ’ sein. Ebenso paßt die fünfte Gleichung auch dann, wenn die<br />
vierte paßt. Klar: um diese Situation zu vermeiden, müßte man statt Gleichung 3 je eine Gleichung<br />
für alle ASCII-Zeichen außer dem Leerzeichen anführen.<br />
Im Falle nicht disjunkter Mustergleichungen gilt die Regel: Es ist stets die erste (im Sinne der<br />
Aufschreibung) passende Gleichung anzuwenden.
3.3. Ausdrücke 43<br />
3.3. Ausdrücke<br />
Wenn wir rechnen, manipulieren wir Ausdrücke. Von daher ist es höchste Zeit, daß wir uns<br />
den Aufbau von Ausdrücken genauer anschauen. Neben der Vertiefung bereits bekannter<br />
Konstrukte werden wir drei neue Arten von Ausdrücken kennenlernen. Diese zählen zu<br />
den grundlegenden Sprachkonstrukten von Haskell – grundlegend in dem Sinn, daß sie<br />
zwar weiter von der gewohnten mathematischen Notation entfernt, dafür aber allgemeiner<br />
sind und sich viele bisher besprochenen Konstrukte als vereinfachte Schreibweise aus<br />
ihnen ableiten lassen. Die grundlegenden Konstrukte werden wir insbesondere verwenden,<br />
um in Abschnitt 3.5 das Rechnen in Haskell formal zu definieren.<br />
3.3.1. Variablen, Funktionsanwendungen und Konstruktoren<br />
Im einfachsten Fall besteht ein Ausdruck aus einem Bezeichner. Damit ein solcher Ausdruck<br />
Sinn macht, muß der Bezeichner irgendwo eingeführt worden sein, entweder in<br />
einer Definition oder als Parameter einer Funktion. Egal hingegen ist, ob der Bezeichner<br />
eine Funktion bezeichnet, eine Liste oder ein Musikstück.<br />
Die Anwendung einer Funktion f auf Argumente e1, . . . , en notieren wir einfach, indem<br />
wir die Funktion und die Argumente hintereinanderschreiben: f e1 . . . en. Sowohl f<br />
als auch die ei können wiederum beliebige Ausdrücke sein, vorausgesetzt die Typen der<br />
Ausdrücke ei passen zu den Typen der Funktionsparameter: Damit f e1 . . . en wohlgetypt<br />
ist, muß f den Typ σ1-> · · · ->σn->τ und ei den Typ σi haben. In diesem Fall besitzt<br />
f e1 . . . en den Typ τ. Sind die Argumente wiederum Funktionsanwendungen, müssen diese<br />
in Klammern gesetzt werden wie z.B. in min a (minimum as). Lassen wir die Klammern<br />
fälschlicherweise weg, versorgen wir min ungewollt mit drei Argumenten, wobei<br />
das zweite Argument eine Funktion ist.<br />
Die Elemente eines Datentyps werden durch Konstruktoren aufgebaut. Die Anwendung<br />
eines Konstruktors auf Argumente wird genauso notiert wie die Anwendung einer Funktion.<br />
Konstruktoren unterscheiden sich von Funktionen nur dadurch, daß sie in Mustern<br />
vorkommen dürfen.<br />
Infix-Notation<br />
Als Infix-Operatoren bezeichnet man (zweistellige) Funktionen, die zwischen ihre Argumente<br />
geschrieben werden. Um Klammern zu sparen, werden Operatoren Prioritäten<br />
oder Bindungsstärken zugeordnet: die Multiplikation (+) bindet z.B. stärker als die Addition<br />
(+); somit entspricht a + b * c dem Ausdruck a + (b * c). Außerdem legt<br />
man fest, ob Operatoren links, rechts oder gar nicht assoziativ sind: Die Subtraktion<br />
Grit Garbo: Das ist doch wahrlich nicht aufregend: Punkt- vor<br />
Strichrechnung kennt man doch schon aus dem Kindergarten.
Lisa Lista: Witzig: Wenn (-) rechts assoziierte, dann wären<br />
a - b - c und a - b + c gleichbedeutend.<br />
Bindungs- links- nicht rechtsstärke<br />
assoziativ assoziativ assoziativ<br />
9 !! .<br />
8 ˆ, ˆˆ, **<br />
7 *, /,<br />
‘div‘, ‘mod‘,<br />
‘rem‘, ‘quot‘<br />
6 +, -<br />
5 \\ :, ++<br />
4 ==, /=, =,<br />
‘elem‘,<br />
‘notElem‘<br />
3 &&<br />
2 ||<br />
1 >>, >>=<br />
0 $, ‘seq‘<br />
Tabelle 3.1: Bindungsstärken der vordefinierten Operatoren<br />
Grit Garbo: Das ist mir völlig unverständlich! Warum sind (+)<br />
und (*) linksassoziativ, (++) aber rechtsassoziativ? Alle diese<br />
Operatoren sind doch assoziativ.<br />
Übung 3.5 Versuche Grit zu helfen.<br />
44 3. Eine einfache Programmiersprache<br />
(-) zum Beispiel assoziiert links, d.h., die Klammern in a - b - c sind so einzufügen:<br />
(a - b) - c. Die Regeln für in Haskell vordefinierte Operatoren findet man in<br />
Tabelle 3.1.<br />
Führen wir selbst neue Infix-Operatoren ein, so müssen ihre Bindungsstärken und assoziativen<br />
Eigenschaften deklariert werden. Wie dies geht, haben wir schon bei (:+:) und<br />
(:*:) (in Kapitel 2) gesehen. Dies muß am Programmanfang geschehen, weil sonst der<br />
Übersetzer solche Formeln gar nicht erst richtig lesen kann.<br />
Die meisten Funktionen scheibt man in Präfix-Schreibweise, also vor ihre Argumente.<br />
Dabei bindet eine Präfix-Funktion immer stärker als Infix-Operatoren. Daher ist f x + 1<br />
gleichbedeutend mit (f x) + 1, und nicht etwa mit f (x + 1). Meint man das Letztere,<br />
muß man eben die Klammern explizit schreiben. Diese Regel erklärt auch die Notwendigkeit<br />
der Klammern in Ausdrücken wie Note ce (1/4). Hat man erst einmal etwas<br />
Haskell-Erfahrung gesammelt, wird man sehen, daß diese Regeln sehr komfortabel sind.<br />
Programmierhinweis: Wir verwenden zwei verschiedene Arten von Bezeichnern: alphanumerische<br />
Bezeichner wie z.B. Note oder transponiere und symbolische wie z.B. „:“ oder „+“. In Haskell<br />
werden symbolische Bezeichner stets infix und alphanumerische stets präfix notiert. Von dieser Festlegung<br />
kann man abweichen, indem man symbolische Bezeichner in runde Klammern einschließt<br />
bzw. alphanumerische in Backquotes: a + b wird so zu (+) a b und div a b zu a ‘div‘ b.<br />
Symbolische Bezeichner können auch für Konstruktoren verwendet werden; einzige Bedingung: der<br />
Name muß mit einem Doppelpunkt beginnen.<br />
3.3.2. Fallunterscheidungen<br />
Elemente eines Datentyps werden mit Hilfe von Konstruktoren konstruiert; sogenannte<br />
case-Ausdrücke erlauben es, Elemente eines Datentyps zu analysieren und entsprechend<br />
ihres Aufbaus zu zerlegen. Konstruktor-basierte Fallunterscheidungen kennen wir schon<br />
von Funktionsbindungen; case-Asudrücke stellen Fallunterscheidungen in Reinform dar<br />
— sie ermöglichen eine Analyse, ohne daß eine Funktionsdefinition vorgenommen werden<br />
muß. Hier ist die Definition von length unter Verwendung eines case-Ausdrucks:<br />
length’ :: [a] -> Int -- vordefiniert<br />
length’ as = case as of<br />
[] -> 0<br />
a:as’ -> 1 + length’ as’<br />
Nach dem Schlüsselwort case wird der Ausdruck aufgeführt, der analysiert werden<br />
soll, der sogenannte Diskriminatorausdruck. Die Behandlung der verschiedenen Fälle oder<br />
Alternativen wird nach dem Schlüsselwort of angegeben, nach dem sich ein lokaler Sichtbarkeitsbereich<br />
für die Alternativen eröffnet. Jede Alternative hat im einfachsten Fall die<br />
Form p -> e, wobei p ein Muster ist und e der Rumpf der Alternative.
3.3. Ausdrücke 45<br />
Ähnlich wie length läßt sich jede Funktionsbindung mit Hilfe von case umschreiben.<br />
Funktionsbindungen können wir somit als eine schönere Notation ansehen für Definitionen,<br />
die als erstes eine case-Unterscheidung auf einem Funktionsargument vornehmen.<br />
Im Vergleich zur Fallunterscheidung mittels Gleichungen sind case-Ausdrücke flexibler<br />
— zum einen da es Ausdrücke sind und zum anderen weil ein Ausdruck und nicht nur ein<br />
Funktionsparameter analysiert werden kann. Betrachte:<br />
last’ :: [a] -> a -- vordefiniert<br />
last’ as = case reverse as of a:as’ -> a<br />
Die Funktion last entnimmt das letzte Element einer Liste, sofern vorhanden, durch<br />
eine Fallunterscheidung auf der umgekehrten Liste.<br />
Von der Form sind case-Ausdrücke Funktionsbindungen jedoch sehr ähnlich. Jede Alternative<br />
kann z.B. Wächter oder lokale Definitionen enthalten:<br />
case e of { p1 m1; . . . ;pn mn }<br />
Die pi sind beliebige Muster und die mi nehmen wahlweise eine der beiden folgenden<br />
Formen an:<br />
oder<br />
-> e where { d1; . . . ;dp }<br />
| g1 -> e1<br />
. . .<br />
| gq -> eq<br />
where { d1; . . . ;dp }<br />
Der where-Teil kann jeweils auch entfallen. Dies entspricht der Form von Funktionsbindungen<br />
mit dem einzigen Unterschied, daß anstelle des Gleichheitszeichens ein Pfeil<br />
steht. Auch für case-Ausdrücke gilt die Abseitsregel, so daß das Ende der Alternativen<br />
bzw. das Ende des gesamten Ausdrucks entweder mittels Layout oder durch Angabe von<br />
Trennsymbolen angezeigt werden kann.<br />
Schauen wir uns noch zwei Beispiele an: Die Funktion words spaltet eine Zeichenkette<br />
in eine Liste von Zeichenketten auf, indem sie an den Leerzeichen trennt.<br />
words :: String -> [String] -- vordefiniert<br />
words cs = case dropSpaces cs of<br />
[] -> []<br />
cs’ -> w : words cs’’<br />
where (w,cs’’) = splitWord cs’
46 3. Eine einfache Programmiersprache<br />
Hier ist eine alternative Definition von minimum0, bei der eine Fallunterscheidung über<br />
das Ergebnis des rekursiven Aufrufs vorgenommen wird.<br />
minimum0’ :: (Ord a) => [a] -> Maybe a<br />
minimum0’ [] = Nothing<br />
minimum0’ (a:as) = case minimum0’ as of<br />
Nothing -> Just a<br />
Just m -> Just (min a m)<br />
3.3.3. Funktionsausdrücke<br />
Betrachten wir einen Ausdruck wie n + 1. In einem Kontext, in dem n gegeben ist,<br />
bezeichnet er einen Wert, den man ausrechnen kann, nachdem man den Wert von n ausgerechnet<br />
hat. Betrachten wir dagegen n als variabel, so stellt der Ausdruck eine Funktion<br />
dar, die in Abhängigkeit von der Variablen n ein Ergebnis berechnet. Genauer gesagt haben<br />
wir: f n = n + 1. Der Name f ist natürlich mehr oder weniger willkürlich gewählt.<br />
Manchmal lohnt es nicht, sich für eine Hilfsfunktion einen Namen auszudenken: Wer viel<br />
programmiert weiß, daß das Erfinden von aussagekräftigen Bezeichnern zu den schwierigsten<br />
Aufgaben gehört. Aus diesem Grund gibt es sogenannte anonyme Funktionen:<br />
Wollen wir ausdrücken, daß n + 1 eine Funktion in Abhängigkeit von n ist schreiben wir<br />
\n -> n + 1<br />
Einen Ausdruck dieser Form nennen wir Funktionsausdruck, da er eine Funktion beschreibt.<br />
Der Schrägstrich (ein abgemagertes λ) läutet den Funktionsausdruck ein, es folgt der formale<br />
Parameter der Funktion und nach dem Pfeil „->“ der Rumpf der Funktion. Wir haben<br />
bereits gesehen, daß in Haskell für Ausdrücke und Typen häufig die gleiche Notation verwendet<br />
wird. Funktionsausdrücke sind dafür ein gutes Beispiel: Wenn x den Typ σ und<br />
e den Typ τ hat, dann besitzt der Ausdruck \x -> e den Typ σ -> τ. Etwas abstrakter<br />
gesehen ist „->“ wie auch Pair ein 2-stelliger Typkonstruktor. Im Unterschied zu Pair<br />
können wir „->“ aber nicht selbst definieren.<br />
Im allgemeinen hat ein Funktionsausdruck die Form<br />
\p1 . . . pn -> e .<br />
Die pi sind beliebige Muster und e ist ein Ausdruck. Hat ein Funktionsausdruck mehr als<br />
einen Parameter, kann er als Abkürzung aufgefaßt werden für entsprechend geschachtelte<br />
Funktionsausdrücke: \p1 p2 -> e ist gleichbedeutend mit \p1 -> \p2 -> e. Für den<br />
zugehörigen Typausdruck gilt dies in ähnlicher Weise: haben wir x1 :: σ1, x2 :: σ2 und<br />
e :: τ, dann ist \x1 -> \x2 -> e vom Typ σ1 -> (σ2 -> τ). Wir vereinbaren, daß<br />
wir rechtsassoziative Klammern weglassen: somit schreiben wir den Typ σ1 -> (σ2 -> τ)<br />
kurz als σ1 -> σ2 -> τ.
3.3. Ausdrücke 47<br />
Die „umgekehrte“ Regel gilt für die Funktionsanwendung: f e1 e2 ist gleichbedeutend<br />
mit (f e1) e2, d.h., die Funktionsanwendung bindet linksassoziativ.<br />
Gestaffelte Funktionen<br />
Wir haben oben so nebenbei angemerkt, daß wir den Ausdruck \p1 p2 -> e als Abkürzung<br />
für \p1 -> \p2 -> e auffassen können. Diesen Sachverhalt wollen wir in diesem<br />
Abschnitt etwas näher beleuchten. Betrachten wir die folgende Definition von add.<br />
add :: Integer -> Integer -> Integer<br />
add m n = m + n<br />
Als Funktionsausdruck geschrieben erhalten wir die folgenden Definition.<br />
add’ :: Integer -> (Integer -> Integer)<br />
add’ = \m -> \n -> m + n<br />
Man ist gewohnt, die Addition zweier Zahlen als zweistellige Funktion zu interpretieren.<br />
Die Definition von add zeigt, daß es auch eine andere — und wie wir gleich sehen<br />
werden pfiffigere — Interpretation gibt: add ist eine einstellige Funktion, die eine einstellige<br />
Funktion als Ergebnis hat. Somit ist add 3 eine einstellige Funktion, die ihr Argument<br />
um 3 erhöht: \n -> 3 + n. Die Argumente werden also peu à peu übergeben. Aus diesem<br />
Grund nennen wir Funktionen des Typs σ1 -> σ1 -> · · · σn -> τ mit n 2 auch<br />
gestaffelt. Sie werden auch „curryfizierte“ oder „geschönfinkelte“ Funktionen genannt,<br />
nach den Logikern Haskell B. Curry und Moses Schönfinkel. Daher erklärt sich der Name<br />
Haskell.<br />
Bevor wir zu den Vorteilen gestaffelter Funktionen kommen, merken wir an, daß add<br />
alternativ auch auf Paaren definiert werden kann.<br />
add0 :: (Integer,Integer) -> Integer<br />
add0 (m,n) = m+n<br />
Die Funktion add0 ist ebenfalls einstellig — auch wenn man in der Mathematik Funktionen<br />
auf Cartesischen Produkten n-stellig nennt. Der einzige Parameter ist eben ein<br />
Paar. Im Unterschied zu „echten“ mehrstelligen Funktionen kann das Argument von add0<br />
ein beliebiger Ausdruck sein, der zu einem Paar auswertet.<br />
dup :: a -> (a, a)<br />
dup a = (a,a)<br />
double :: Integer -> Integer<br />
double n = add0 (dup n)<br />
Grit Garbo: Schließlich bin ich kein Computer und kann den<br />
Typ sehen wie ich will — ob Klammern oder nicht.
Übung 3.6 Wann sind Funktionen auf Cartesischen Produkten<br />
besser geeignet?<br />
48 3. Eine einfache Programmiersprache<br />
Der Vorteil von gestaffelten Funktionen gegenüber ihren Kolleginnen auf Cartesischen<br />
Produkten ist, daß sie Ableger bilden, wenn man ihnen nur einen Teil der Argumente gibt.<br />
Betrachten wir die folgende Definition:<br />
note :: Int -> Int -> Dauer -> Musik<br />
note oct h d = Note (12 * oct + h) d<br />
Füttert man note nach und nach mit Argumenten, so erhält man folgende Ableger:<br />
note Eine Note in einer beliebigen Oktave und von beliebiger Höhe und Dauer.<br />
note 2 Eine Note in der dritten Oktave und von beliebiger Höhe und Dauer.<br />
note 2 ef Die Note f in der dritten Oktave und von beliebiger Dauer.<br />
note 2 ef (1/4) ergibt Note 29 (1/4).<br />
Durch die gestaffelte Form ist eine Funktion also vielseitiger einsetzbar als bei Verwendung<br />
des Cartesischen Produkts als Argumentbereich.<br />
Noch eine terminologische Bemerkung: Man spricht von Funktionen höherer Ordnung,<br />
wenn Argument- oder Ergebnistyp oder beide Funktionstypen sind. Somit sind add und<br />
note Funktionen höherer Ordnung. Im Grunde sind fast alle Programme, die wir bisher<br />
kennengelernt haben, aus Funktionen höherer Ordnung aufgebaut. Auch wenn man nicht<br />
immer daran denkt.<br />
3.3.4. Lokale Definitionen<br />
Lokale Definitionen können nicht nur mit Gleichungen, sondern mit beliebigen Ausdrücken<br />
verknüpfen werden. Letzteres geschieht durch sogenannte let-Ausdrücke. Die<br />
folgende Definition von power benutzt let statt where und if anstelle von Wächtern.<br />
power’ :: (Integral b, Num a) => a -> b -> a<br />
power’ x n = if n == 0 then 1<br />
else let y = power’ (x*x) (n ‘div‘ 2)<br />
in if n ‘mod‘ 2 == 0 then y<br />
else y*x<br />
Nach dem Schlüsselwort let werden die Hilfsdefinitionen angegeben (hier ist es nur<br />
eine), entweder in geschweifte Klammern eingeschlossen und durch jeweils ein Semikolon<br />
getrennt oder unter Benutzung der Abseitsregel. Der Ausdruck, in dem die Namen gültig<br />
sind, folgt nach dem Schlüsselwort in. Den obigen let-Ausdruck liest man „sei y gleich<br />
. . . in . . . “. Im allgemeinen haben let-Ausdrücke die Form<br />
let {e1; . . . ; en} in e .
3.4. Anwendung: Binärbäume 49<br />
Ein let-Ausdruck führt neue Namen ein, genau wie case-Ausdrücke und Funktionsausdrücke.<br />
Schauen wir uns ihre Sichtbarkeitsbereiche genauer an:<br />
case e of { . . . ;pi mi; . . . } Die in pi eingeführten Variablen sind in mi sichtbar.<br />
\p1 . . . pn -> e Die Variablen in p1, . . . , pn sind in e sichtbar.<br />
let {d1; . . . ;dn} in e Die in den di eingeführten Variablen sind in e und in den Definitionen<br />
di selbst sichtbar.<br />
Im Unterschied zur where-Klausel ist der let-Ausdruck ein Ausdruck und kann überall<br />
da stehen, wo Ausdrücke erlaubt sind. Die where-Klausel ist hingegen syntaktischer<br />
Bestandteil einer Gleichung.<br />
Der bis hierher eingeführte Ausschnitt aus der Programmiersprache Haskell reicht für<br />
die nun folgenden Anwendungen aus. Wir haben diesen Ausschnitt so gewählt, daß er<br />
einen einfachen intuitiven Zugang bietet, insbesondere was das Rechnen (lassen) betrifft.<br />
Wer mehr über die Sprache Haskell wissen möchte, insbesondere auch präzisere Definitionen<br />
zur Frage „Wie rechnet Haskell“ sehen möchte, findet dies in dem vertiefenden<br />
Abschnitt 3.5. Andernfalls kann man sich auch nach dem Studium des nächsten Abschnitts<br />
getrost den allgemeineren Themen der nächsten Kapitel zuwenden.<br />
3.4. Anwendung: Binärbäume<br />
In Kapitel 2 haben wir an zwei Beispielen gesehen, wie man Objekte der Realität durch<br />
Formeln nachbildet. Nun entspringen nicht alle Formeln, mit denen sich die Informatik<br />
beschäftigt, einem derartigen Modellierungsprozeß. Viele Formeln, genannt Datenstrukturen,<br />
sind Informatik-intern entwickelt worden, um Probleme mittelbar besser lösen zu<br />
können. Mit einer derartigen Datenstruktur, sogenannten Binärbäumen, beschäftigen wir<br />
uns in diesem Abschnitt. Die einleitenden Worte deuten darauf hin, daß Binärbäume keine<br />
biologischen Bäume modellieren. Die biologische Metapher dient hier lediglich als<br />
Lieferant für Begriffe: So haben Binärbäume eine Wurzel, sie haben Verzweigungen und<br />
Blätter.<br />
Zwei Beispiele für Binärbäume sind in Abbildung 3.1 aufgeführt. Zunächst einmal fällt<br />
auf, daß die Informatik die Bäume auf den Kopf stellt: die Wurzel der Bäume ist oben<br />
und die Blätter sind unten. 2 Binärbäume bestehen aus drei verschiedenen Komponenten:<br />
Es gibt binäre Verzweigungen (dargestellt als Kreise), beschriftete Blätter (dargestellt<br />
als Quadrate) und unbeschriftete Blätter (symbolisiert durch ɛ). 3 Für die Darstellung von<br />
2Diese Darstellung erklärt sich mit der im westlichen Kulturkreis üblichen Lese- und Schreibrichtung von oben<br />
nach unten.<br />
3Es gibt auch Varianten von Binärbäumen, die beschriftete Verzweigungen und unbeschriftete Blätter haben.<br />
1 2<br />
3 1 ɛ<br />
2 3 ɛ 4<br />
Abbildung 3.1: Beispiele für Binärbäume
Übung 3.7 Notiere die folgenden Bäume als Terme:<br />
1 2<br />
ɛ 1 2<br />
3 ɛ 4 5<br />
1 2 3<br />
(1) (2) (3)<br />
4<br />
7 8<br />
5 6<br />
Übung 3.8 Zeichne die zu den folgenden Formeln korrespondierenden<br />
Bäume.<br />
1. Br Nil (Br (Leaf 1) (Leaf 2))<br />
2. Br (Br (Br (Leaf 1) (Leaf 2))<br />
(Br Nil (Leaf 3)))<br />
(Br (Leaf 4) (Leaf 5))<br />
3. Br (Br (Leaf 1) (Leaf 2))<br />
(Br (Br (Br (Br (Leaf 3) (Leaf 4))<br />
(Leaf 5))<br />
(Leaf 6))<br />
(Br (Leaf 7) (Leaf 8)))<br />
Lisa Lista: Harry, probier leaves doch mal aus!<br />
Harry Hacker: Hey, Du hast wohl den Übergang vom Rechnen<br />
zum Rechnen lassen geschafft :-). [Harry wirft seinen Missile<br />
DX mit Turbo-Haskell an und tippt die Definitionen ab.] Was<br />
sollen wir denn rechnen lassen?<br />
Lisa Lista: Wie wär’s mit leaves (leftist [0 .. 9])?<br />
Harry Hacker: [Harry tippt den Ausdruck ein und die Liste<br />
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0] erscheint prompt am<br />
Bildschirm.] Dauert doch gar nicht so lange wie die sagen.<br />
Lisa Lista: [Lisa schiebt Harry beiseite und tippt eifrig<br />
leaves (leftist [0 .. n]) für immer größere n ein. Für<br />
n = 5000 kann man die Ausgabe der Elemente gemütlich am<br />
Bildschirm verfolgen.] Langsam, aber sicher geht Dein Missile<br />
DX in die Knie, Harry.<br />
50 3. Eine einfache Programmiersprache<br />
Binärbäumen mit Formeln, müssen wir uns Namen für die aufgeführten Bestandteile ausdenken:<br />
data Tree a = Nil<br />
| Leaf a<br />
| Br (Tree a) (Tree a)<br />
Der Typ Tree a umfaßt somit binäre Bäume über dem Grundtyp a: Ein Baum ist entweder<br />
leer, oder ein Blatt, das ein Element vom Typ a enthält, oder eine Verzweigung<br />
(engl.: Branch) mit zwei Teilbäumen. Wie der Listentyp ist auch Tree ein Typkonstruktor:<br />
Tree ist mit dem Typ der Blattmarkierungen parametrisiert.<br />
Mit Hilfe der neuen Formeln können wir die Bäume aus Abbildung 3.1 als Terme notieren:<br />
t1, t2 :: Tree Integer<br />
t1 = Br (Br (Leaf 1) (Leaf 2)) (Leaf 3)<br />
t2 = Br (Br (Leaf 1) Nil)<br />
(Br (Br (Leaf 2) (Leaf 3))<br />
(Br Nil (Leaf 4)))<br />
Stellen wir uns die Aufgabe, die Blätter eines Baums in einer Liste aufzusammeln. Für<br />
t1, den linken Baum aus Abbildung 3.1, ergibt sich:<br />
leaves (Br (Br (Leaf 1) (Leaf 2)) (Leaf 3)) ⇒ [1, 2, 3]<br />
Man sieht, daß die Reihenfolge der Elemente in der Liste der Reihenfolge der Blätter<br />
im Baum entspricht (jeweils von links nach rechts gelesen). Hier ist eine einfache — und<br />
wie wir gleich sehen werden naive — Lösung für das Problem:<br />
leaves :: Tree a -> [a]<br />
leaves Nil = []<br />
leaves (Leaf a) = [a]<br />
leaves (Br l r) = leaves l ++ leaves r<br />
Im letzten Fall — die Wurzel ist eine Verzweigung — werden die Blätter des linken und<br />
des rechten Teilbaums mit (++) konkateniert. Die Definition ist elegant, aber in einigen<br />
Fällen auch sehr rechenintensiv. Dies liegt an der Verwendung der Listenkonkatenation:<br />
Sei ni die Länge der Liste xi; um x1 ++ x2 auszurechnen, muß die zweite Gleichung für<br />
(++) genau n1 mal angewendet werden. Ist nun der linke Teilbaum jeweils sehr groß und<br />
der rechte klein oder leer, dann wird wiederholt in jedem Rekursionsschritt eine große<br />
Liste durchlaufen. Die folgende Funktion generiert bösartige Fälle:<br />
leftist :: [a] -> Tree a<br />
leftist [] = Nil<br />
leftist (a:as) = Br (leftist as) (Leaf a)
3.4. Anwendung: Binärbäume 51<br />
Da die Listenkonkatenation die Rechenzeit in die Höhe treibt, stellt sich die Frage, ob<br />
wir bei der Definition von leaves auch ohne auskommen. Identifizieren wir zunächst die<br />
einfachen Fälle: Ist der Baum leer oder ein Blatt, können wir das Ergebnis sofort angeben.<br />
Wenn der Baum eine Verzeigung ist, deren linker Teilbaum leer oder ein Blatt ist, kommen<br />
wir ebenfalls ohne (++) aus.<br />
leaves’ :: Tree a -> [a]<br />
leaves’ Nil = []<br />
leaves’ (Leaf a) = [a]<br />
leaves’ (Br Nil r) = leaves’ r<br />
leaves’ (Br (Leaf a) r) = a : leaves’ r<br />
Es fehlt der Fall, daß der linke Teilbaum selbst wieder eine Verzweigung ist. Jetzt wenden<br />
wir einen Trick an: Wir machen den linken Teilbaum kleiner, indem wir die Bäume<br />
umhängen, und rufen leaves wieder rekursiv auf. Beim Umhängen müssen wir darauf<br />
achten, daß sich die Reihenfolge der Elemente nicht ändert.<br />
leaves’ (Br (Br l’ r’) r) = leaves’ (Br l’ (Br r’ r))<br />
Auf diese Weise nähern wir uns Schritt für Schritt einem der oben aufgeführten, einfachen<br />
Fälle. Das Umhängen der Bäume ist in Abbildung 3.2 grafisch dargestellt. Aus naheliegenden<br />
Gründen wird die Transformation auch als Rechtsrotation bezeichnet. Rechnen<br />
wir leaves’ t1 aus. In den Kommentaren geben die Zahlen die jeweils verwendete<br />
Gleichung an.<br />
leaves’ (Br (Br (Leaf 1) (Leaf 2)) (Leaf 3))<br />
⇒ leaves’ (Br (Leaf 1) (Br (Leaf 2) (Leaf 3))) (Def. leaves’.5)<br />
⇒ 1 : leaves’ (Br (Leaf 2) (Leaf 3)) (Def. leaves’.4)<br />
⇒ 1 : 2 : leaves’ (Leaf 3) (Def. leaves’.4)<br />
⇒ 1 : 2 : [3] (Def. leaves’.2)<br />
Der Nachweiß, daß leaves’ für die Erledigung der gleichen Aufgabe tatsächlich mit<br />
weniger Schritten auskommt, steht natürlich noch aus. Wir führen ihn in Kapitel 5, in<br />
dem wir uns intensiv mit dem Thema Effizienz auseinandersetzen.<br />
Programmieren wir eine Umkehrung von leaves, d.h. eine Funktion build mit<br />
leaves (build x) = x für alle Listen x. Diese Eigenschaft läßt uns Spielraum, denn es<br />
gibt viele Bäume, die von leaves auf die gleiche Liste abgebildet werden: leaves ist<br />
surjektiv. Wir nutzen diese Wahlmöglichkeit und konstruieren einen Baum, bei dem die<br />
Blätter möglichst gleichmäßig auf die Äste verteilt sind. Hat die Liste n Elemente, so verteilen<br />
wir ⌊n/2⌋ Elemente auf den linken und ⌈n/2⌉ Elemente auf den rechten Teilbaum.<br />
Somit unterscheidet sich für jede Verzweigung die Größe der Teilbäume um maximal ein<br />
Element. Bäume mit dieser Eigenschaft nennt man ausgeglichen.<br />
Harry Hacker: Jetzt vergleichen wir mal! [Harry gibt den<br />
Ausdruck leaves’ (leftist [0 .. 5000]) ein. Die Liste<br />
[5000, 4999 .. 0] saust über den Bildschirm.] Ich bin beeindruckt.<br />
Übung 3.9 Programmiere leaves’ mit case-Ausdrücken. Verwende<br />
dabei nur einfache Muster (ein Konstruktor angewendet<br />
auf Variablen ist ein einfaches Muster).<br />
t u<br />
v<br />
=⇒<br />
t<br />
Abbildung 3.2: Rechtsrotation<br />
u<br />
v
Übung 3.10 Definiere die Funktion drop, die eine Liste auf die<br />
Liste ohne ihre ersten k Elemente abbildet.<br />
1 2 3<br />
4 5<br />
6<br />
7 8<br />
9<br />
10 11<br />
Abbildung 3.3: Ausgeglichener Baum der Größe 11<br />
52 3. Eine einfache Programmiersprache<br />
build :: [a] -> Tree a<br />
build [] = Nil<br />
build [a] = Leaf a<br />
build as = Br (build (take k as)) (build (drop k as))<br />
where k = length as ‘div‘ 2<br />
Die Hilfsfunktion take entnimmt die ersten k Elemente einer Liste. Die Funktion drop<br />
wird in Übung 3.10 besprochen.<br />
take :: Int -> [a] -> [a] -- vordefiniert<br />
take (n+1) (a:as) = a : take n as<br />
take _ _ = []<br />
In Abbildung 3.3 ist das Ergebnis von build [1 .. 11] grafisch dargestellt. Die<br />
Struktur ergibt sich aus der wiederholten Halbierung von 11:<br />
11<br />
= 5 + 6<br />
= (2 + 3) + (3 + 3)<br />
= (1 + 1) + (1 + 2) + (1 + 2) + (1 + 2) .<br />
Auch für build geben wir noch eine bessere, weil schnellere Definition an. Eine Inspektion<br />
von build zeigt, daß bei jedem rekursiven Aufruf die Argumentliste dreimal<br />
durchlaufen wird: einmal in gesamter Länge von length und zweimal jeweils bis zur<br />
Hälfte von take und drop. Wir können die wiederholten Durchläufe vermeiden, indem<br />
wir mehrere Schritte zusammenfassen. Zu diesem Zweck definieren wir eine Hilfsfunktion<br />
buildSplit mit der folgenden Eigenschaft: Sei 0 n length x, dann gilt<br />
buildSplit n x = (build (take n x), drop n x)<br />
Die Funktion build erhalten wir, indem wir buildSplit mit der Listenlänge aufrufen.<br />
build’ :: [a] -> Tree a<br />
build’ as = fst (buildSplit (length as) as)<br />
Die Funktion buildSplit ergibt sich zu:<br />
buildSplit :: Int -> [a] -> (Tree a, [a])<br />
buildSplit 0 as = (Nil, as)<br />
buildSplit 1 as = (Leaf (head as), tail as)<br />
buildSplit n as = (Br l r, as’’)<br />
where k = n ‘div‘ 2<br />
(l,as’) = buildSplit k as<br />
(r,as’’) = buildSplit (n-k) as’
3.5. Vertiefung: Rechnen in Haskell 53<br />
Man kann die Definition von buildSplit sogar systematisch aus der obigen Eigenschaft<br />
herleiten. Dazu mehr in Kapitel 4. Den Nachweis, daß build’ besser ist als build,<br />
holen wir in Kapitel 5 nach.<br />
3.5. Vertiefung: Rechnen in Haskell<br />
3.5.1. Eine Kernsprache/Syntaktischer Zucker<br />
Reduktion auf Kernsprache: Mustergleichungen lassen sich auf einfachere Gleichungen zurückführen,<br />
die keine Fallunterscheidung auf der linken Seite treffen, sondern stattdessen<br />
rechts case-Ausdrücke verwenden.<br />
Wo wir gerade bei Abkürzungen sind: Funktionsdefinitionen können wir ebenfalls als<br />
eine abkürzende Schreibweise auffassen: f x = e kürzt f = \x -> e ab. Dito für mehrstellige<br />
Funktionsdefinitionen: g x1 x2 = e kürzt g = \x1 x2 -> e ab. where-<br />
Klauseln wiederum können wir durch let-Ausdrücke ersetzen.<br />
Eine Rechnung wird durch die Angabe eines Ausdrucks gestartet, der relativ zu den im<br />
Programm aufgeführten Definitionen ausgerechnet wird. Im Prinzip kann man ein Programm<br />
als großen let-Ausdruck auffassen: Nach dem let stehen alle Definitionern des<br />
Programms, nach dem in der zu berechnende Ausdruck. Vordefinierte Typen und Funktionen<br />
haben globale Sichtbarkeit.<br />
Hat man sich einmal auf diese Kernsprache beschränkt, kann man das Rechnen in Haskell<br />
durch drei Rechenregeln — für Funktionsanwendungen, case- und let-Ausdrücke<br />
— erklären.<br />
3.5.2. Auswertung von Fallunterscheidungen<br />
Voraussetzung: nur einfache case-Ausdrücke.<br />
Ein case-Ausdruck kann ausgerechnet werden, wenn der Diskriminatorausdruck ein<br />
Konstruktorausdruck ist: case C e1 ... ek of .... In diesem Fall wissen wir, welche<br />
der nach of angegeben Alternativen vorliegt. Nehmen wir an, C x1 ... xk -> e ist<br />
die passende Alternative, dann läßt sich der case-Ausdruck zu e vereinfachen, wobei wir<br />
die Argumente des Konstruktors e1, . . . , ek für die Variablen x1, . . . , xk einsetzen müssen.<br />
Das Muster fungiert also als eine Art Schablone.<br />
abs :: (Ord a, Num a) => a -> a -- vordefiniert<br />
abs n = case n >= 0 of<br />
True -> n<br />
False -> -n<br />
encode :: (Num a) => Maybe a -> a<br />
Harry Hacker: Hmm — die Laufzeiten von build und build’<br />
unterscheiden sich nicht so dramatisch.
Übung 3.11 Stelle abgeleitete Regeln für if auf.<br />
54 3. Eine einfache Programmiersprache<br />
encode x = case x of<br />
Nothing -> -1<br />
Just n -> abs n<br />
encode (Just 5)<br />
⇒ case Just 5 of {Nothing -> -1; Just n -> abs n} (Def. encode)<br />
⇒ abs 5 (case − Regel)<br />
⇒ case 5 >= 0 of {True -> 5; False -> -5} (Def. abs)<br />
⇒ case True of {True -> 5; False -> -5} (Def. >=)<br />
⇒ 5 (case − Regel)<br />
Für die gleichzeitige Ersetzung von Variablen durch Ausdrücke verwenden wir die Notation<br />
e[x1/e1, . . . , xn/en]. Damit bezeichnen wir den Ausdruck, den man aus e erhält, wenn<br />
man alle Vorkommen von xi gleichzeitig durch ei ersetzt. Damit läßt sich unsere erste<br />
Rechenregel formalisieren.<br />
case C e1...ek of {...; C x1...xk -> e;...} (case-Regel)<br />
⇒ e[x1/e1, . . . , xn/en]<br />
Mit den Auslassungspunkten „. . . “ sollen jeweils die nicht passenden Alternativen symbolisiert<br />
werden.<br />
3.5.3. Auswertung von Funktionsanwendungen<br />
Wir erklären nun formal, wie man mit Funktionsausdrücken rechnet.<br />
Eine Funktionsanwendung f a kann erfolgen, wenn f ein Funktionsausdruck ist: (\x -><br />
e) a. Dann läßt sich die Anwendung zu e vereinfachen, wobei wir den aktuellen Parameter<br />
a für den formalen Parameter x einsetzen. Etwas ähnliches haben wir schon beim<br />
Ausrechnen von case-Ausdrücken kennengelernt. Die neue Rechenregel läßt sich einfach<br />
formalisieren:<br />
\(x -> e) a ⇒ e[x/a] (β-Regel) (3.2)<br />
Der Name β-Regel ist genau wie der Schrägstrich bei Funktionsausdrücken, der ein λ<br />
symbolisiert, historisch begründet. Aus nostalgischen Gründen übernehmen wir beides.
3.5. Vertiefung: Rechnen in Haskell 55<br />
Wiederholen wir noch einmal die Auswertung von encode (Just 5) aus Abschnitt 3.5.2.<br />
Wir werden sehen, daß wir die β-Regel schon heimlich verwendet haben, ohne uns große<br />
Gedanken darüber zu machen. Zunächst schreiben wir encode und abs als Funktionsausdrücke<br />
auf:<br />
abs = \n -> case n >= 0 of {True -> n; False -> -n}<br />
encode = \x -> case x of {Nothing -> -1; Just n -> abs n}<br />
Die Rechnung nimmt jetzt den folgenden Verlauf.<br />
encode (Just 5) (Def. encode)<br />
⇒ (\x-> case x of {Nothing-> -1; Just n-> abs n}) (Just 5)<br />
⇒ case Just 5 of {Nothing -> -1; Just n -> abs n} (β − Regel)<br />
⇒ abs 5 (case − Regel)<br />
⇒ (\n -> case n >= 0 of {True -> n; False -> -n}) 5 (Def. abs)<br />
⇒ case 5 >= 0 of {True -> 5; False -> -5} (β − Regel)<br />
⇒ case True of {True -> 5; False -> -5} (Def. (>=))<br />
⇒ 5 (case − Regel)<br />
In der ersten Rechnung haben wir zwei Schritte zusammengefaßt: Das Einsetzen der<br />
Definition von encode bzw. von abs und die Anwendung der β-Regel. Schauen wir uns<br />
noch ein weiteres Beispiel an. [Das Beispiel gehört in die Schublade der akademischen<br />
Beispiele; es erlaubt aber, die Anwendung der β-Regel innerhalb von 9 Schritten immerhin<br />
6-mal einzuüben.]<br />
twice :: (a -> a) -> a -> a<br />
twice = \f -> \a -> f (f a)<br />
Wir rechnen den Ausdruck twice twice inc aus. Ist klar, was als Ergebnis herauskommt?<br />
twice twice inc<br />
⇒ (\f -> \a -> f (f a)) twice inc (Def. twice)<br />
⇒ (\a -> twice (twice a) inc (β − Regel)<br />
⇒ twice (twice inc) (β − Regel)<br />
⇒ twice ((\f -> \a -> f (f a)) inc) (Def. twice)<br />
⇒ twice (\a -> inc (inc a)) (β − Regel)<br />
⇒ (\f -> \a -> f (f a)) (\a -> inc (inc a)) (Def. twice)<br />
⇒ \a -> (\a -> inc (inc a)) ((\a -> inc (inc a)) a) (β − Regel)<br />
⇒ \a -> (\a -> inc (inc a)) (inc (inc a)) (β − Regel)<br />
⇒ \a -> inc (inc (inc (inc a))) (β − Regel)
56 3. Eine einfache Programmiersprache<br />
Man sieht, wir können rechnen, ohne uns Gedanken über die Bedeutung machen zu<br />
müssen. Und das ist auch gut so, schließlich wollen wir ja rechnen lassen.<br />
Trotzdem ist es interessant zu fragen, was twice twice eigentlich bedeutet. Nun, der Ausdruck<br />
twice inc bezeichnet laut Definition die 2-fache Anwendung von inc auf ein Argument;<br />
die Rechnung zeigt, daß twice twice inc der 4-fachen Anwendung von inc entspricht. Und<br />
twice twice twice inc oder gar twice twice twice twice inc?<br />
Versuchen wir die Ausdrücke intelligent auszurechnen, indem wir Gesetzmäßigkeiten aufdecken.<br />
Jetzt müssen wir erfinderisch sein und mit den Formeln spielen. Die Arbeit nimmt uns kein Rechner<br />
ab, Gott sei Dank, oder? Als erstes erfinden wir eine Notation für die n-fache Anwendung: 〈n〉.<br />
Also, twice = 〈2〉 und twice twice = 〈4〉. Definieren können wir 〈n〉 mithilfe der Komposition:<br />
〈n〉 f = f ◦ · · · ◦ f.<br />
Wenden wir nun 〈m〉 auf 〈n〉 an, komponieren wir 〈n〉 m-mal mit sich selbst. Das<br />
| {z }<br />
n-mal<br />
führt uns zu der Frage, was 〈m〉 ◦ 〈n〉 bedeutet? Nun, 〈n〉 f ist f ◦ · · · ◦ f , mit 〈m〉(〈n〉 f) wiederholt<br />
| {z }<br />
n-mal<br />
sich dieser Ausdruck m-mal: (f ◦ · · · ◦ f) ◦ · · · ◦ (f ◦ · · · ◦ f) . Ergo, erhalten wir 〈mn〉. Nun können<br />
| {z }<br />
m-mal<br />
wir auch 〈m〉 〈n〉 erklären: 〈m〉 〈n〉 = 〈n〉 ◦ · · · ◦ 〈n〉 = 〈n<br />
| {z }<br />
m-mal<br />
m 〉. Somit ist (〈2〉 〈2〉) 〈2〉 = 〈4〉 〈2〉 = 〈16〉<br />
und ((〈2〉 〈2〉) 〈2〉) 〈2〉 = 〈16〉 〈2〉 = 〈65536〉. Geschafft!<br />
3.5.4. Auswertung von lokalen Definitionen<br />
Voraussetzung: nur einfache let-Ausdrücke.<br />
Die let-Ausdrücke haben uns noch gefehlt: Mit ihnen ist unsere Programmiersprache<br />
komplett. Im folgenden erklären wir, wie man mit ihnen rechnet.<br />
Wenn die Definitionen nicht rekursiv sind, ist die Rechenregel einfach: Die definierenden<br />
Ausdrücke werden für die Bezeichner in den Rumpf des let-Ausdrucks eingesetzt.<br />
let {x1 = e1;...;xn = en} in e<br />
⇒ e[x1/e1, . . . , xn/en]<br />
Wenn die Definitionen rekursiv sind, geht das nicht so einfach. Die Bezüge für die in den<br />
rechten Seiten auftretenden Bezeichner gïngen sonst verloren. Eine rekursive Definition<br />
wird unter Umständen wiederholt benötigt, etwa für jede rekursive Anwendung einer<br />
Funktion. Um für diesen Fall gerüstet zu sein, ersetzen wir xi statt durch ei durch den<br />
Ausdruck let {x1 = e1;...;xn = en} in ei.
3.5. Vertiefung: Rechnen in Haskell 57<br />
let {x1 = e1;...;xn = en} in e<br />
⇒ e[x1/let {x1 = e1;...;xn = en} in e1, . . . , (let-Regel)<br />
xn/let {x1 = e1;...;xn = en} in en]<br />
Die lokalen Definitionen werden auf diese Weise einmal aufgefaltet. Das bläht den Ausdruck<br />
zunächst einmal gewaltig auf — aber danach können wir ein Stück weiterrechnen<br />
und ihn (vielleicht) wieder vereinfachen. Beachte, daß die Regel auch funktioniert, wenn<br />
die Definitionen nicht rekursiv sind. Schauen wir uns ein Beispiel an.<br />
rep 2 8<br />
⇒ let x = 8:x in take 2 x (Def. rep)<br />
⇒ take 2 (let x = 8:x in 8:x) (let − Regel)<br />
⇒ take 2 (8:let x = 8:x in 8:x) (let − Regel)<br />
⇒ 8:take 1 (let x = 8:x in 8:x) (Def. take)<br />
⇒ 8:take 1 (8:let x = 8:x in 8:x) (let − Regel)<br />
⇒ 8:8:take 0 (let x = 8:x in 8:x) (Def. take)<br />
⇒ 8:8:[] (Def. take)<br />
Man sieht, die merkwürdige Definition von rep funktioniert. Wir haben uns auch geschickt<br />
angestellt. Für’s erste verlassen wir uns darauf, daß sich auch der Rechner geschickt<br />
anstellt. Später werden wir uns überlegen, warum wir uns darauf verlassen können.<br />
In der obigen Rechnung haben wir einige Schritte zusammengefaßt: Die Anwendung<br />
von take auf die beiden Argumente und die folgenden Fallunterscheidungen erscheinen<br />
als einziger Rechenschritt. Sobald man sich mit der Funktionsweise einer Funktion vertraut<br />
gemacht hat, ist es natürlich und bequem dies zu tun: Wir fassen die Definition von take<br />
sozusagen als abgeleitete Rechenregel auf.<br />
Fazit: Unsere Programmiersprache umfaßt nur sechs grundlegende Arten von Ausdrücken:<br />
Variablen, Konstruktorausdrücke, case-Ausdrücke, Funktionsausdrücke, Funktionsanwendungen<br />
und let-Ausdrücke. Durch die case-, let- und die β-Regel ist das Rechnen in<br />
Haskell formal definiert. Damit können wir alles programmieren, aber nicht unbedingt<br />
lesbar, schön und elegant.<br />
Harry Hacker: Wetten, daß ich diese Rechnung auch mit der<br />
einfachen let-Regel hinkriege?<br />
Lisa Lista: Wetten, daß nicht?<br />
Übung 3.12 Was wäre ungeschickt gewesen?
58 3. Eine einfache Programmiersprache
4. Programmiermethodik<br />
Ziele des Kapitels: In Kapitel 3 haben wir das Werkzeug kennengelernt, das es uns ermöglicht,<br />
rechnen zu lassen. In diesem Kapitel gehen wir einen Schritt weiter und beschäftigen<br />
uns mit dem systematischen Gebrauch dieses Werkzeugs. Dabei gehen wir<br />
vereinfachend davon aus, daß der Prozeß der Modellierung — den wir in Kapitel 2 an<br />
zwei Beispielen kennengelernt haben — bereits abgeschlossen ist. Innerhalb einer vorgegebenen<br />
Modellwelt werden wir uns damit auseinandersetzen, wie man zu ebenfalls<br />
vorgebenen Problemen systematisch Rechenregeln — sprich Programme — entwirft. Als<br />
durchgehendes Beispiel dient uns dabei neben anderen das Sortierproblem.<br />
Eine Bemerkung zur Terminologie sei noch vorausgeschickt: In der Literatur wird oft<br />
eine feinsinnige Unterscheidung zwischen den Begriffen Algorithmus und Programm getroffen.<br />
Ein Algorithmus ist ein Rechenverfahren, daß so präzise beschrieben ist, daß eine<br />
automatische Durchführung im Prinzip möglich ist. Dies gegeben, darf der Algorithmus<br />
auf beliebig hohem Abstraktionsgrad und in beliebiger, hoffentlich dem Problem adäquater<br />
Notation beschrieben sein. Ein Programm ist die Formulierung des Algorithmus in einer<br />
Programmiersprache, so daß man es (bzw. ihn) auch tatsächlich rechnen lassen kann. Früher,<br />
als Programmiersprachen noch wesentlich primitiver waren als heute, spielte dieser<br />
Unterschied eine wesentliche Rolle, und der Übergang vom Algorithmus zum Programm,<br />
manchmal Codierung genannt, war eine nicht zu unterschätzende Fehlerquelle. Dank moderner<br />
Programmiersprachen wie unserem Haskell wird die semantische Lücke zwischen<br />
Algorithmus und Programm ein gutes Stück weit geschlossen, und wir werden die Begriffe<br />
Algorithmus und (Haskell-)Programm praktisch synonym verwenden. Unsere Einführung<br />
in die Informatik kommt dadurch wesentlich zügiger voran, als dies früher möglich war.<br />
4.1. Spezifikation<br />
Bevor man sich daran setzt, Rechenregeln aufzuschreiben, muß man zunächst das Problem<br />
durchdringen, das es zu lösen gilt. Je genauer man das Problem beschreiben kann,<br />
desto besser wird später auch der Entwurf von Rechenregeln gelingen. Das hört sich einleuchtend<br />
an, aber wir werden schnell sehen, daß die Beschreibung eines Problems — in<br />
Fachjargon die Problemspezifikation — alles andere als einfach ist. Wir gehen im folgenden<br />
davon aus, daß das Problem in der Berechnung einer Funktion im mathematischen<br />
Prof. Paneau: Die Autoren vergessen darauf hinzuweisen —<br />
und das sollte man mit der gebotenen Eindringlichkeit tun —<br />
daß nur beständiges Üben zum Erfolg führt.
Harry Hacker: Aber alle meine Programme sind interaktiv; kann<br />
ich jetzt weiterblättern (goto Kapitel 5)?<br />
60 4. Programmiermethodik<br />
Sinne besteht: Einer Eingabe wird eindeutig eine Ausgabe zugeordnet. Wir kümmern uns<br />
damit bewußt nicht um Systeme, die mit einer Benutzerin oder allgemein mit einer Um-<br />
gebung interagieren.<br />
Wie gesagt, wir verwenden das Sortieren als durchgehendes Beispiel. Die informelle<br />
Beschreibung des Sortierproblems ist relativ einfach: Gesucht ist eine Funktion sort, die<br />
eine gegebene Liste von Elementen aufsteigend anordnet. Schauen wir uns ein paar Beispiele<br />
an:<br />
sort [8, 3, 5, 3, 6, 1] ⇒ [1, 3, 3, 5, 6, 8]<br />
sort "hello world" ⇒ " dehllloorw"<br />
sort ["Bein", "Anfall", "Anna"] ⇒ ["Anfall", "Anna", "Bein"]<br />
sort [(1, 7), (1, 3), (2, 2)] ⇒ [(1, 3), (1, 7), (2, 2)]<br />
Kümmern wir uns zunächst um den Typ der Funktion. Die Festlegung des Typs sollte<br />
stets der erste Schritt bei der Spezifikation sein: durch den Typ wird ja gerade die Art der<br />
Eingabe und der Ausgabe beschrieben. Im obigen Beispiel sind Argument und Ergebnis<br />
jeweils Listen; auf den Typ der Listenelemente wollen wir uns nicht festlegen, wir müssen<br />
lediglich annehmen, daß auf den Elementen eine Vergleichsfunktion definiert ist. Somit<br />
erhalten wir:<br />
sort :: (Ord a) => [a] -> OrdList a<br />
Wie läßt sich nun das Sortierproblem formal spezifizieren? Klar, die Ergebnisliste muß<br />
geordnet sein. Diese Eigenschaft kann man mit einer Haskell-Funktion beschreiben.<br />
ordered :: (Ord a) => [a] -> Bool<br />
ordered [] = True<br />
ordered [a] = True<br />
Harry Hacker: Geht das nicht einfacher? Wie wär’s mit „für alle<br />
ordered (a1:a2:as) = a1 [a] muß gelten . . . “. Wir wagen einen ersten Spezifikationsversuch: Sei τ ein beliebiger Typ, auf dem eine<br />
Ordnung definiert ist, für alle Listen x :: [τ] muß gelten<br />
ordered (sort x) = True . (4.1)<br />
Reicht das? Leider nein: die Funktion \x -> [] erfüllt (4.1), aber nicht unsere Erwartungen.<br />
Wir müssen noch fordern, daß alle Elemente der Argumentliste auch in der Ergebnisliste<br />
auftreten. Als zweiten Versuch könnten wir zusätzlich die Listen x und sort x in<br />
Mengen überführen und verlangen, daß beide Mengen gleich sind. Damit haben wir von<br />
der Anordnung der Elemente abstrahiert — aber leider auch von ihrer Anzahl. Schließlich<br />
müssen wir garantieren, daß ein mehrfach auftretendes Element auch noch nach dem Sortieren<br />
in gleicher Anzahl auftritt (siehe erstes Beispiel). Dies kann man mit Mengen nicht<br />
modellieren. Wir spezifizieren diese Eigenschaft, indem wir beide Listen auf eine Struktur
4.1. Spezifikation 61<br />
abbilden, in der die Reihenfolge keine Rolle spielt, aber Elemente mehrfach vorkommen<br />
können: die Struktur der Multimenge.<br />
Die Eigenschaft der geordneten Liste haben wir mit einem Haskell-Programm spezifiziert.<br />
Dies hat den Vorteil, daß wir mit der Spezifikation auch rechnen können, z.B. können<br />
wir sie verwenden, um Sortierprogramme auszutesten. Die Angabe eines Haskell-<br />
Programms für die zweite Eigenschaft ist prinzipiell auch möglich, würde uns aber vom<br />
eigentlichen Thema ablenken. Aus diesem Grund nehmen wir an, daß der Typ Bag a,<br />
Multimengen über dem Grundtyp a, mit folgenden Operationen vorgegeben ist:<br />
∅ die leere Multimenge,<br />
a die einelementige Multimenge, die genau ein Vorkommen von a enthält,<br />
x ⊎ y die Vereinigung der Elemente von x und y; das „+“ im Vereinigungszeichen deutet<br />
an, daß sich die Vorkommen in x und y akkumulieren.<br />
Die Hinzunahme neuer Typen und Operationen mit bestimmten Eigenschaften hat den<br />
Vorteil, daß die Spezifikation einfacher und lesbarer wird. Schließlich ist die Spezifikation<br />
kein Selbstzweck; wir werden sie später verwenden, um die Korrektheit unserer Sortierprogramme<br />
zu beweisen. Für diese Beweise benutzen wir die folgenden Eigenschaften<br />
der Operationen: ∅ ist ein neutrales Element von (⊎) und (⊎) selbst ist assoziativ und<br />
kommutativ.<br />
∅ ⊎ x = x (4.2)<br />
x ⊎ ∅ = x (4.3)<br />
x ⊎ y = y ⊎ x (4.4)<br />
(x ⊎ y) ⊎ z = x ⊎ (y ⊎ z) (4.5)<br />
Die Funktion bag, die eine Liste in eine Multimenge überführt, illustriert die Verwendung<br />
der Operationen.<br />
bag :: [a] -> Bag a<br />
bag [] = ∅<br />
bag (a:as) = a ⊎ bag as<br />
Eine Liste x enthält alle Elemente von y, falls bag x = bag y. In diesem Fall heißt x<br />
Permutation von y. Somit können wir nun spezifizieren: Sei τ ein beliebiger Typ, auf dem<br />
eine Ordnung definiert ist. Für alle Listen x :: [τ] muß gelten<br />
ordered (sort x) = True ∧ bag (sort x) = bag x . (4.6)<br />
Dadurch ist sort als mathematische Funktion, nicht aber als Programm, eindeutig bestimmt.<br />
Harry Hacker: Was unterscheidet jetzt Multimengen von Mengen;<br />
diese Gesetze gelten doch auch für Mengen.<br />
Lisa Lista: Das stimmt Harry. Aber Mengen erfüllen noch eine<br />
weitere Eigenschaft, x ∪ x = x, die auf Multimengen nicht zutrifft.<br />
Übung 4.1<br />
(a) Spezifiziere die Funktion<br />
merge :: (Ord a) =><br />
OrdList a -> OrdList a -> OrdList a<br />
die zwei geordnete Listen in eine geordnete Liste überführt.<br />
(b) Spezifiziere die Funktion<br />
mergeMany :: (Ord a) =><br />
[OrdList a] -> OrdList a<br />
die eine Liste geordneter Listen in eine geordnete Liste überführt.
Übung 4.2 Zu implementieren ist eine Funktion<br />
split :: Int -> [a] -> [[a]]<br />
die eine Liste in n möglichst gleich lange Listen aufteilt. Lisa<br />
Lista hat diese Funktion folgendermaßen spezifiziert. Für alle<br />
natürlichen Zahlen n und alle Listen x muß gelten:<br />
concat (split n x) = x ∧ length (split n x) = n .<br />
Harry Hacker hat sofort eine Funktion gefunden, die diese Spezifikation<br />
erfüllt: die Liste x wird in n − 1 leere Listen und die<br />
Liste x selbst „aufgeteilt“. Helfen Sie Lisa bei einer besseren Spezifikation<br />
und bei der Implementierung.<br />
Harry Hacker: [Seufzt] Das kenne ich, meinen Missile DX mußte<br />
ich zum Händler zurückschleppen, weil die Fließkommaarithmetik<br />
des Prozessors fehlerhaft war.<br />
62 4. Programmiermethodik<br />
Manchmal kann man sehr einfach zu einer brauchbaren Spezifikation gelangen, indem<br />
man bereits existierende Funktionen benutzt. Dies haben wir zum Beispiel im Falle von<br />
build getan: Wir haben gefordert, daß build eine Inverse zu leaves sein soll mit der<br />
Eigenschaft leaves (build x) = x für alle x. Damit ist build durchaus nicht eindeu-<br />
tig spezifiziert: Auch<br />
Einstweiliges Fazit: Schon die Spezifikation einfacher Probleme ist nicht unbedingt einfach.<br />
Da wundert es nicht, daß oftmals die Mühe gescheut wird, ein Problem formal<br />
zu spezifizieren. Dabei sind formale Spezifikationen unerläßlich, wenn wir eine Chance<br />
haben wollen, die Korrektheit unserer Programme nachzuweisen. Aber bleiben wir realistisch:<br />
Nach dem heutigen Stand der Forschung wäre es vermessen zu erwarten, daß ein<br />
großes Programmpaket jemals vollständig spezifiziert und als korrekt nachgewiesen wird.<br />
Selbst wenn dies gelänge, hieße das noch nicht, daß „alles läuft“: die Spezifikation kann<br />
falsch sein, oder der Übersetzer oder der Rechner selbst, oder jemand kann den Stecker<br />
herausziehen. Der Schluß, daß es sich damit bei der Programmverifikation um ein nutzlo-<br />
ses Unterfangen handelt, ist aber genauso falsch. Denn mit jedem Korrektheitsnachweis<br />
— auch wenn nur ein kleiner Teil eines umfangreichen Programms berücksichtigt wird —<br />
steigt die Zuverlässigkeit und damit unser Vertrauen in die Software. Kurz gesagt: Lieber<br />
etwas Korrektheit als gar keine.<br />
4.2. Strukturelle Rekursion<br />
Zur Lösung eines Problems braucht man in der Regel eine Idee. Das ist beim Programmieren<br />
nicht anders. Gott sei Dank steht man nicht alleine da. Es gibt einen kleinen Fundus<br />
von Methoden, die sich beim Lösen von Problemen bewährt haben. Eine einfache werden<br />
wir im folgenden kennenlernen, eine weitere in Abschnitt 4.5.<br />
4.2.1. Strukturelle Rekursion auf Listen<br />
Bevor wir uns dem Sortierproblem zuwenden, schauen wir uns zunächst einmal zwei listenverarbeitende<br />
Funktionen aus vorangegangenen Kapiteln an.<br />
translate :: [Codon] -> Protein<br />
translate [] = []<br />
translate (triplet:triplets)<br />
| aminoAcid == Stp = []<br />
| otherwise = aminoAcid:translate triplets<br />
where aminoAcid = genCode triplet<br />
length :: [a] -> Int -- vordefiniert<br />
length [] = 0
4.2. Strukturelle Rekursion 63<br />
length (a:as) = 1 + length as<br />
Beide Funktionen lösen unterschiedliche Probleme; nichtsdestotrotz folgen ihre Definitionen<br />
dem gleichen Schema, das sich eng an der Definition des Listentyps orientiert. Für<br />
jeden in der Datentypdefinition aufgeführten Konstruktor, [] und (:), gibt es eine Gleichung;<br />
der Konstruktor (:) ist rekursiv im zweiten Argument, just über dieses Argument<br />
erfolgt jeweils der rekursive Aufruf. Man sagt, die Funktionen sind strukturell rekursiv<br />
definiert.<br />
Für jeden Datentyp gibt es ein zugehöriges Rekursionsschema, das es erlaubt, Probleme<br />
zu lösen, die Eingaben dieses Datentyps involvieren. Für Listen nimmt das Rekursionsschema<br />
die folgende Form an:<br />
Rekursionsbasis ([]) Das Problem wird für die leere Liste [] gelöst.<br />
Rekursionsschritt (a:as) Um das Problem für die Liste a:as zu lösen, wird nach dem<br />
gleichen Verfahren, d.h. rekursiv, zunächst eine Lösung für as bestimmt, die anschließend<br />
zu einer Lösung für a:as erweitert wird.<br />
Das ganze etwas formaler: Wir sagen, die listenverarbeitende Funktion f ist strukturell<br />
rekursiv definiert, wenn die Definition von der Form<br />
f :: [σ] -> τ<br />
f [] = e1<br />
f (a : as) = e2<br />
where s = f as<br />
ist, wobei e1 und e2 Ausdrücke vom Typ τ sind und e2 die Variablen a, as und s (nicht<br />
aber f) enthalten darf. Mit s wird gerade die Lösung für as bezeichnet; tritt s nur einmal<br />
in e2 auf, kann man natürlich für s auch direkt f as einsetzen.<br />
Wenden wir uns der Lösung des Sortierproblems zu. Es ist eine gute Idee, die Funktionsdefinition<br />
zunächst so weit aufzuschreiben, wie das Rekursionsschema es vorgibt. Wir<br />
erhalten:<br />
insertionSort :: (Ord a) => a -> OrdList a -> OrdList a<br />
insertionSort [] = e1<br />
insertionSort (a : as) = e2<br />
where s = insertionSort as<br />
Wir werden verschiedene Sortieralgorithmen kennenlernen; die Funktionen erhalten jeweils<br />
die in der Literatur gebräuchlichen Namen. Die Lücke in der ersten Gleichung, der<br />
Rekursionsbasis, ist schnell ausgefüllt: die leere Liste ist bereits sortiert, also ersetzen wir<br />
e1 durch []. Zum Rekursionsschritt: Wir müssen die bereits sortierte Liste s um das Element<br />
a erweitern, genauer: a muß in s gemäß der vorgegebenen Ordnung eingefügt werden.<br />
Das hört sich nach einem neuen (Teil-) Problem an. Wie gehen wir weiter vor? Ganz<br />
Prof. Paneau: Das Schema ist doch primitiv, primitiv rekursiv<br />
meine ich.
Prof. Paneau: Ich würde den Autoren empfehlen, ihre Worte<br />
vorsichtiger zu wählen. Die Erweiterung ist doch keine: jede<br />
nach dem g-Schema definierbare Funktion läßt sich bereits mit<br />
dem f-Schema definieren.<br />
Harry Hacker: Wie soll das funktionieren? Die Funktion f hat<br />
einen Parameter, die Funktion g aber zwei!<br />
Prof. Paneau: Lieber Herr Hacker, das ist doch eine Illusion: die<br />
Ausdrücke e1 und e2 können doch funktionswertig sein. Lassen<br />
Sie mich dies am Beispiel der Funktion insert verdeutlichen;<br />
mit dem f-Schema erhalten wir (oBdA habe ich die Parameter<br />
vertauscht):<br />
ins [] = \a -> [a]<br />
ins (a’:as) = \a -> if a [a] -> OrdList a<br />
insertionSort [] = []<br />
insertionSort (a:as) = insert a (insertionSort as)<br />
Wenden wir uns dem neuen Problem zu. Bei der Definition von insert verwenden wir<br />
das Kochrezept ein zweites Mal an. Aber halt, insert hat zwei Parameter; das Rekursionsschema<br />
sieht nur einen Parameter vor. Wir müssen das Schema zunächst erweitern:<br />
Wir sagen, die listenverarbeitende Funktion g ist strukturell rekursiv definiert, wenn die<br />
Definition von der Form<br />
g :: σ1 -> [σ2] -> τ<br />
g i [] = e1<br />
g i (a : as) = e2<br />
where s = g e3 as<br />
ist, wobei e1 die Variable i, e2 die Variablen i, a, as und s und schließlich e3 die Variablen<br />
i, a und as enthalten darf. Häufig, aber nicht immer, ist e3 gleich i, so daß man sich auf<br />
die Wahl von e1 und e2 konzentrieren kann.<br />
Wie oben schreiben wir die Funktionsdefinition für insert zunächst so weit auf, wie<br />
das Rekursionsschema es vorgibt.<br />
insert :: (Ord a) => a -> OrdList a -> OrdList a<br />
insert a [] = e1<br />
insert a (a’ : as) = e2<br />
where s = insert e3 as<br />
Die Rekursionsbasis ist wieder einfach: e1 ist die einlementige Liste [a]. Zum Rekursionsschritt:<br />
Die Argumentliste ist sortiert, damit ist a’ ihr kleinstes Element. Um das erste<br />
Element der Ergebnisliste zu ermitteln, müssen wir a mit a’ vergleichen. Wir erhalten:<br />
insert a [] = []<br />
insert a (a’ : as)<br />
| a a -> [a] -> [a]<br />
insert a [] = [a]
4.2. Strukturelle Rekursion 65<br />
insert a (a’:as)<br />
| a
Übung 4.3 Definiere analog zu size die Funktion<br />
branches :: Tree a -> Integer, die die Anzahl der Verzweigungen<br />
in einem Baum bestimmt.<br />
Übung 4.4 In Abschnitt 3.4 haben wir zwei Definitionen für<br />
leaves angegeben, welche ist strukturell rekursiv?<br />
66 4. Programmiermethodik<br />
4.2.2. Strukturelle Rekursion auf Bäumen<br />
Wir haben bereits angemerkt, daß es für jeden Datentyp ein zugehöriges Rekursionsschema<br />
gibt. Schauen wir uns als weiteres Beispiel das Schema für den Datentyp Tree an, den<br />
wir in Abschnitt 3.4 eingeführt haben.<br />
Rekursionsbasis (Nil) Das Problem wird für den leeren Baum gelöst.<br />
Rekursionsbasis (Leaf a) Das Problem wird für das Blatt Leaf a gelöst.<br />
Rekursionsschritt (Br l r) Um das Problem für den Baum Br l r zu lösen, werden<br />
rekursiv Lösungen für l und r bestimmt, die zu einer Lösung für Br l r erweitert<br />
werden.<br />
Im Unterschied zu Listen gibt es zwei Basisfälle und beim Rekursionsschritt müssen zwei<br />
Teillösungen zu einer Gesamtlösung kombiniert werden. Formal heißt eine Funktion f<br />
strukturell rekursiv, wenn die Definition von der Form<br />
f :: Tree σ -> τ<br />
f Nil = e1<br />
f (Leaf a) = e2<br />
f (Br l r) = e3<br />
where sl = f l<br />
sr = f r<br />
ist. Programmieren wir als Anwendung zwei Funktionen, die Größe bzw. die Tiefe 1 von<br />
Bäumen bestimmen. Die Größe eines Baums setzen wir gleich mit der Anzahl seiner Blätter;<br />
die Tiefe entspricht der Länge des längsten Pfads von der Wurzel bis zu einem Blatt.<br />
size :: Tree a -> Integer<br />
size Nil = 1<br />
size (Leaf _) = 1<br />
size (Br l r) = size l + size r<br />
depth :: Tree a -> Integer<br />
depth Nil = 0<br />
depth (Leaf _) = 0<br />
depth (Br l r) = max (depth l) (depth r) + 1<br />
Auf diese Funktionen werden wir in den nächsten Abschnitten wiederholt zurückkommen,<br />
so daß es sich lohnt, sich ihre Definition einzuprägen.<br />
1 Da die Bäume in der Informatik von oben nach unten wachsen, spricht man eher von der Tiefe eines Baums<br />
und seltener von seiner Höhe.
4.2. Strukturelle Rekursion 67<br />
4.2.3. Das allgemeine Rekursionsschema<br />
Nachdem wir zwei Instanzen des Rekursionsschemas kennengelernt haben, sind wir (hoffentlich)<br />
fit für das allgemeine Kochrezept. Ausgangspunkt ist dabei die allgemeine Form<br />
der Datentypdeklaration, wie wir sie in Abschnitt 3.1.1 kennengelernt haben.<br />
data T a1 . . . am = C1 t11 . . . t1n1<br />
| . . .<br />
| Cr tr1 . . . trnr<br />
Für die Definition des Schemas müssen wir bei den Konstruktoren Ci zwei Arten von<br />
Argumenten unterscheiden: rekursive (d.h. tij ist gleich T a1 . . . am) und nicht-rekursive.<br />
Seien also li1, . . . , lipi mit 1 li1 < li2 < · · · < lipi ni die Positionen, an denen der<br />
Konstruktor Ci rekursiv ist. Das Rekursionsschema besteht nun aus r Gleichungen; für<br />
jeden Konstruktor gibt es eine Gleichung, die sich seiner annimmt.<br />
f :: T σ1 . . . σm -> τ<br />
f (C1 x11 . . . x1n1) = e1<br />
where s11 = f x1l11<br />
. . .<br />
. . .<br />
s1p1 = f x1l1p1 f (Cr xr1 . . . xrnr) = er<br />
where sr1<br />
. . .<br />
= f xrlr1<br />
srpr = f xrlrpr<br />
Der Ausdruck ei darf die Variablen xi1, . . . , xini und die Variablen si1, . . . , sipi enthalten.<br />
Ist pi = 0, so spricht man von einer Rekursionsbasis, sonst von einem Rekursionsschritt.<br />
4.2.4. Verstärkung der Rekursion<br />
Es ist eine gute Idee, als erstes das Schema der strukturellen Rekursion heranzuziehen,<br />
um eine Funktion zu programmieren. Aber nicht immer führt dieser Ansatz zum Erfolg<br />
bzw. nicht immer ist das Ergebnis zufriedenstellend, das man auf diesem Wege erhält.<br />
Schauen wir uns ein Beispiel an: Das Problem lautet, die Reihenfolge der Elemente einer<br />
Liste umzukehren. Wenden wir das Schema der strukturellen Rekursion an, erhalten wir<br />
die folgende oder eine ähnliche Definition.<br />
reverse’ :: [a] -> [a] -- vordefiniert<br />
reverse’ [] = []<br />
reverse’ (a:as) = reverse’ as ++ [a]<br />
Übung 4.5 Welche Funktionen aus den vorangegangenen Kapiteln<br />
sind strukturell rekursiv definiert und welche nicht?
68 4. Programmiermethodik<br />
Mit der Listenkonkatenation wird jeweils das erste Element an die gespiegelte Liste<br />
angehängt; dabei muß die Liste jeweils vollständig durchlaufen werden. Die Tatsache,<br />
daß die geschachtelte Verwendung von (++) sehr rechenintensiv ist, kennen wir bereits<br />
aus Abschnitt 3.4 — in der Tat sind reverse und leaves nah verwandt, es gilt<br />
reverse = leaves . leftist. Wie können wir eine bessere Lösung für reverse systematisch<br />
herleiten? Die grundlegende Idee ist die folgende: Wir programmieren eine<br />
Funktion, die ein schwierigeres Problem lößt als verlangt, aber die es uns erlaubt, den<br />
Rekursionsschritt besser zu bewältigen. Diese Technik nennt man „Verstärkung des Rekursionsschritts“<br />
oder „Programmieren durch Einbettung“. Schauen wir uns die Technik<br />
zunächst am obigen Beispiel an. Im Rekursionsschritt müssen wir die Restliste spiegeln<br />
und an das Ergebnis eine Liste anhängen. Diese Beobachtung führt zu der Idee, eine<br />
Funktion zu programmieren, die beide Aufgaben gleichzeitig löst. Also:<br />
reel :: [a] -> [a] -> [a]<br />
reel x y = reverse x ++ y<br />
Die Spezifikation verkörpert sozusagen den kreativen Einfall. Interessant ist, daß wir die<br />
Definition von reel aus dieser Spezifikation systematisch ableiten können. Anhaltspunkt<br />
für die Fallunterscheidung ist natürlich wieder das Schema der strukturellen Rekursion.<br />
Rekursionsbasis (x = []):<br />
Rekursionsschritt (x = a:as):<br />
reel [] y<br />
= reverse [] ++ y (Spezifikation)<br />
= [] ++ y (Def. reverse)<br />
= y (Def. (++))<br />
reel (a:as) y<br />
= reverse (a:as) ++ y (Spezifikation)<br />
= (reverse as ++ [a]) ++ y (Def. reverse)<br />
= reverse as ++ ([a] ++ y) (Ass. (++))<br />
= reverse as ++ (a:y) (Def. (++))<br />
= reel as (a:y) (Spezifikation)<br />
Somit erhalten wir die folgende Definition von reel:<br />
reel :: [a] -> [a] -> [a]<br />
reel [] y = y<br />
reel (a:as) y = reel as (a:y)<br />
Wenn man sich die rekursiven Aufrufe anschaut, sieht man, daß immer ein Listenelement<br />
vom ersten zum zweiten Parameter „wandert“. Dieser Vorgang begegnet uns im
4.3. Strukturelle Induktion 69<br />
täglichen Leben, wenn wir einen Stapel von Blättern umdrehen: Das oberste Blatt des<br />
Stapels wird auf einen weiteren, anfangs leeren Stapel gelegt; das wiederholen wir solange,<br />
bis der erste Stapel leer ist. Der neue Stapel enthält dann die Blätter in umgekehrter<br />
Reihenfolge.<br />
Die ursprüngliche Funktion, reverse, erhalten wir schließlich durch Spezialisierung<br />
von reel.<br />
reverse’’ :: [a] -> [a]<br />
reverse’’ as = reel as []<br />
Fassen wir zusammen: Die vielleicht überraschende Erkenntnis ist zunächst, daß ein<br />
schwieriges Problem nicht unbedingt auch schwieriger zu lösen ist. Dies liegt im wesentlichen<br />
daran, daß im Rekursionsschritt eine „bessere“ Lösung zur Verfügung steht, die<br />
leichter zu einer Gesamtlösung ausgebaut werden kann. Der kreative Moment liegt in der<br />
Formulierung einer geeigneten Rekursionsverstärkung; eine genaue Analyse, warum der<br />
erste Lösungsansatz scheiterte, liefert dafür in der Regel wichtige Hinweise.<br />
4.3. Strukturelle Induktion<br />
Wir haben bisher das Sortierproblem aus zwei verschiedenen Blickwinkeln betrachtet:<br />
Wir haben das Problem formal spezifiziert und wir haben für das Problem eine Lösung<br />
angegeben. Es ist an der Zeit, die beiden losen Enden wieder zu zusammenzuführen. Wir<br />
werden in diesem Abschnitt zeigen, daß das Sortierprogramme korrekt ist, d.h., daß es die<br />
Spezifikation des Sortierproblems erfüllen. So wie es Programmiermethoden gibt, kennt<br />
man auch Beweismethoden, die uns bei der Programmverifikation unterstützen.<br />
4.3.1. Strukturelle Induktion auf Listen<br />
Das Programm insertionSort sieht richtig aus. Aber, können wir das auch beweisen<br />
— und wenn, dann wie? Es überrascht wahrscheinlich nicht: Der Beweis wird nach einem<br />
ähnlichen Schema geführt, wie wir es für die Programmierung von insertionSort<br />
verwendet haben. Für Listen nimmt das Schema die folgende Form an:<br />
Induktionsbasis ([]): Wir zeigen die Aussage zunächst für die leere Liste [].<br />
Induktionsschritt (a:as): Wir nehmen an, daß die Aussage für die Liste as gilt, und<br />
zeigen, daß sie unter dieser Voraussetzung auch für a:as gilt.<br />
Übung 4.6 Wende die Technik der Rekursionsverstärkung auf<br />
die strukturell rekursive Definition von leaves an.<br />
Lisa Lista: Harry — kannst Du mir erklären, warum die Anwendung<br />
dieser Beweisregel irgendetwas beweist?<br />
Harry Hacker: Nee - habe in der Schule gelernt, das Schlußregeln<br />
so etwas wie Axiome sind, also auf Annahmen oder Absprachen<br />
beruhen. Hat mich nie überzeugt.<br />
Lisa Lista: Ist doch auch albern, oder? Wenn ich eine Aussage<br />
mit Beweisregeln beweise, die selbst nichts als Annahmen sind,<br />
kann ich doch gleich die Aussage als Annahme annehmen.<br />
Prof. Paneau: Ich muß doch sehr um etwas mehr Respekt gegenüber<br />
der Wissenschaftstheorie bitten! In diesem konkreten<br />
Fall aber gebe ich ihnen Recht: Natürlich läßt sich diese Beweisregel<br />
begründen. Man hat damit nämlich eine Konstruktionsvorschrift<br />
angegeben, wie für jede konkrete Liste ein Beweis geführt<br />
werden kann: Man beginnt mit Φ([]), nimmt dann sukzessive die<br />
Listenelemente hinzu und wendet jedesmal den Beweis des Induktionsschritt<br />
an, um zu zeigen, daß auch mit diesem Element<br />
die Aussage noch gilt. Das geht, weil der Induktionsschritt ja<br />
für ein beliebiges Element bewiesen wurde. Was das Induktionsschema<br />
also zeigt, ist, daß man für jede Liste einen Beweis<br />
führen kann — und damit gilt die Aussage Φ natürlich für alle<br />
Listen.<br />
Übung 4.7 Benutze die Erläuterung von Prof. Paneau, um zu erklären,<br />
warum das hier angegebene Schema nicht für den Nachweis<br />
von Eigenschaften unendlicher Listen zu gebrauchen ist.
70 4. Programmiermethodik<br />
Diese Beweisregel kann man formal wie folgt darstellen:<br />
Φ([])<br />
(∀a, as) Φ(as) =⇒ Φ(a:as)<br />
(∀x) Φ(x)<br />
Dabei symbolisiert Φ die Eigenschaft, die wir für alle Listen nachweisen wollen. Über<br />
dem waagerechten Strich werden die Voraussetzungen aufgeführt, unter dem Strich steht<br />
die Schlußfolgerung, die man daraus ziehen kann. Man kann die Regel als Arbeitsauftrag<br />
lesen: Um (∀x) Φ(x) zu zeigen, müssen Φ([] und die Implikation (∀a, as) Φ(as) =⇒<br />
Φ(a:as) nachgewiesen werden. Die im Induktionsschritt getätigte Annahme Φ(as) nennt<br />
man auch Induktionsvoraussetzung; im Beweise notieren wir ihre Anwendung mit dem<br />
Kürzel „I.V.“.<br />
Allgemein gilt, daß sich die Organisation eines Beweises stark an der Organisation der<br />
beteiligten Funktionen orientiert. Also: Die Funktion insertionSort stützt sich auf die<br />
Hilfsfunktion insert ab. Entsprechend weisen wir zunächst die Korrektheit von insert<br />
nach. Hier ist ihre Spezifikation:<br />
ordered x = True =⇒ ordered (insert \(a\) \(x\)) = True (4.7)<br />
bag (insert a x) = a ⊎ bag x (4.8)<br />
Wir verlangen, daß insert a eine geordnete Liste in eine geordnete Liste überführt und<br />
daß insert a x eine Permutation von a:x ist. Beachte, daß Aussage 4.7 eine Vorbedingung<br />
formuliert: "wir weisen die Korrektheit von insert a x nur für den Fall nach,<br />
daß x geordnet ist.<br />
Wir zeigen zunächst Aussage (4.8): Diesen einen Beweis führen wir, ohne etwas auszulassen<br />
oder „mit der Hand zu wedeln“. Es ist ganz nützlich, die Aussage Φ noch einmal<br />
genau aufzuschreiben.<br />
Induktionsbasis (x = []):<br />
Φ(x) ⇐⇒ (∀a) bag (insert a x) = a ⊎ bag x (4.9)<br />
bag (insert a [])<br />
= bag [a] (Def. insert)<br />
= a ⊎ bag [] (Def. bag)<br />
Induktionsschritt (x = a’:as): Im Rumpf von insert wird an dieser Stelle zwischen<br />
zwei Fällen unterschieden, die wir im Beweis nachvollziehen. Fall a
4.3. Strukturelle Induktion 71<br />
Fall a
Übung 4.8 Führe den Korrektheitsbeweis für insertionSort<br />
durch!<br />
Übung 4.9 Zeige die folgenden Eigenschaften mittels Strukturinduktion:<br />
take n x ++ drop n x = x (4.10)<br />
bag (x ++ y) = bag x ⊎ bag y (4.11)<br />
x ++ (y ++ z) = (x ++ y) ++ z .(4.12)<br />
Übung 4.10 Programmiere eine Funktion complete n, die<br />
einen vollständigen Baum der Tiefe n konstruiert.<br />
72 4. Programmiermethodik<br />
Mit Hilfe der eben bewiesenen Hilfssätze können wir uns nun daran machen, die Korrektheit<br />
von insertionSort nachzuweisen. Die Beweise sind allerdings so einfach, daß<br />
wir sie der geneigten Leserin zur Übung überlassen.<br />
Todo: Notation, e1 = e2, insbesondere e1
4.3. Strukturelle Induktion 73<br />
Induktionsbasis (t = Leaf a): analog. Induktionsschritt (t = Br l r): Jetzt können wir<br />
annehmen, daß die Aussage sowohl für l als auch für r gilt.<br />
size (Br l r)<br />
= size l + size r (Def. size)<br />
2ˆdepth l + 2ˆdepth r (I.V.)<br />
2 * 2ˆ(max (depth l) (depth r)) (Eig. max)<br />
= 2ˆ(max (depth l) (depth r) + 1) (Eig. (ˆ))<br />
= 2ˆdepth (Br l r) (Def. depth)<br />
Mit der obigen Formel können wir die Größe nach oben abschätzen. Formen wir die<br />
Ungleichung um, erhalten wir<br />
depth t log 2 (size t) (4.15)<br />
und können die Tiefe nach unten abschätzen. Beide Aussagen werden wir noch häufiger<br />
benötigen.<br />
4.3.3. Das allgemeine Induktionsschema<br />
Nachdem wir zwei Instanzen des Induktionsschemas kennengelernt haben, sind wir (hoffentlich)<br />
fit für das allgemeine Rezept. Wie auch beim allgemeinen Rekursionsschema<br />
ist der Ausgangspunkt die allgemeine Form der Datentypdeklaration, wie wir sie in Abschnitt<br />
3.1.1 kennengelernt haben.<br />
data T a1 . . . am = C1 t11 . . . t1n1<br />
| . . .<br />
| Cr tr1 . . . trnr<br />
Wir müssen wiederum zwischen rekursiven und nicht-rekursiven Konstruktorargumenten<br />
unterscheiden: Seien also li1, . . . , lipi mit 1 li1 < li2 < · · · < lipi ni die Positionen,<br />
an denen der Konstruktor Ci rekursiv ist. Das Induktionsschema besteht nun aus r Prämissen;<br />
für jeden Konstruktor gibt es eine Beweisobligation.<br />
(∀x11 . . . x1n1) Φ(x1l11) ∧ · · · ∧ Φ(x1l1p 1 ) =⇒ Φ(C1 x11 . . . x1n1)<br />
. . .<br />
(∀xr1 . . . xrnr) Φ(xrlr1) ∧ · · · ∧ Φ(xrlrpr ) =⇒ Φ(Cr xr1 . . . xrnr)<br />
(∀x) Φ(x)<br />
Ist pi = 0, so spricht man von einer Induktionsbasis, sonst von einem Induktionsschritt.<br />
Übrigens — die natürliche Induktion, die man aus der Schule kennt (siehe Abschnitt B.3.3),<br />
ist ein Spezialfall der strukturellen Induktion. Um dies zu sehen, muß man nur die natürlichen<br />
Zahlen als Datentyp mit zwei Konstruktoren einführen, etwa<br />
Übung 4.11 Zeige den folgenden Zusammenhang zwischen der<br />
Anzahl der Blätter (size) und der Anzahl der Verzweigungen in<br />
einem Binärbaum (branches, siehe Übung 4.3).<br />
branches t + 1 = size t (4.14)<br />
Übung 4.12 Leite aus dem allgemeinen Induktionsschema eine<br />
Instanz für den Typ Musik ab.<br />
Harry Hacker: Wie rechnet man denn mit diesen Zahlen:<br />
Succ Zero + Succ (Succ Zero) klappt irgendwie nicht.<br />
Lisa Lista: Du mußt eben entsprechende Rechenregeln aufstellen,<br />
z.B. für die Addition zweier Zahlen:<br />
addN :: Natural -> Natural -> Natural<br />
addN Zero n = n<br />
addN (Succ m) n = Succ (addN m n)<br />
Übung 4.13 Hilf Harry bei der Definition der Multiplikation auf<br />
Natural (Hinweis: Kochrezept anwenden).
Übung 4.14 Definiere eine Funktion, die überprüft, ob ein<br />
Baum die Braun-Eigenschaft erfüllt.<br />
74 4. Programmiermethodik<br />
data Natural = Zero | Succ Natural<br />
Die Zahl n schreiben wir damit als n-fache Anwendung von Succ auf Zero: 1 entspricht<br />
Succ Zero, 2 entspricht Succ (Succ Zero) usw. Die Beweisregel für die Natürliche<br />
Induktion nimmt dann die Form der Strukturellen Induktion über dem Typ Natural an:<br />
Φ(Zero)<br />
(∀n) Φ(n) =⇒ Φ(Succ n)<br />
(∀n ∈ Nat) Φ(n)<br />
Die Verallgemeinerung der vollständigen Induktion auf beliebige Datentypen lernen wir<br />
im Vertiefungsabschnitt 4.6 kennen.<br />
4.3.4. Verstärkung der Induktion<br />
Die Formulierung von Rechenregeln und die Formulierung von Beweisen sind zwei sehr<br />
nah verwandte Tätigkeiten. Wir haben versucht, die Zusammenhänge durch den identischen<br />
Aufbau der Abschnitte 4.2 und 4.3 zu verdeutlichen. Entsprechend beschäftigen<br />
wir uns in diesem Abschnitt mit dem beweistheoretischen Analogon zur Rekursionsverstärkung.<br />
Übertragen auf Induktionsbeweise haben wir es mit dem folgenden Phänomen zu tun:<br />
Wir versuchen eine Aussage durch Induktion zu zeigen, bleiben aber im Induktionsschritt<br />
stecken; die Induktionsannahme ist zu schwach, um den Induktionsschritt durchzuführen.<br />
Wir entkommen dem Dilemma, indem wir eine stärkere (schwierigere) Aussage zeigen,<br />
die es uns aber erlaubt, den Induktionsschritt erfolgreich zu absolvieren.<br />
Verdeutlichen wir diese Technik vermittels eines Beispiels: Betrachten wir die Bäume,<br />
die die Funktion build aus Abschnitt 3.4 erzeugt. Es fällt auf, daß die Blätter sich höchstens<br />
auf zwei Ebenen befinden, d.h., die Länge des kürzesten und des längsten Pfades<br />
von der Wurzel bis zu einem Blatt unterscheidet sich höchstens um eins. Für die Spezifikation<br />
dieser Eigenschaft benötigen wir neben depth die Funktion undepth, die die<br />
minimale Tiefe eines Baums bestimmt.<br />
undepth :: Tree a -> Integer<br />
undepth Nil = 0<br />
undepth (Leaf _) = 0<br />
undepth (Br l r) = min (undepth l) (undepth r) + 1<br />
Die Funktion undepth unterscheidet sich von depth nur in der Verwendung von min<br />
statt max. Die Funktion build konstruiert einen Baum, indem sie ⌊n/2⌋ Elemente auf den<br />
linken und ⌈n/2⌉ Elemente auf den rechten Teilbaum verteilt. Bäume mit dieser Knotenverteilung<br />
heißen auch Braun-Bäume: t heißt Braun-Baum, wenn jeder Teilbaum der Form<br />
Br l r die Eigenschaft size r - size l ∈ {0, 1} erfüllt.
4.3. Strukturelle Induktion 75<br />
Die Aussage, die wir zeigen wollen, lautet somit: Sei t ein Braun-Baum, dann gilt<br />
depth t - undepth t ∈ {0, 1} . (4.16)<br />
Führt man für diese Aussage unmittelbar einen Induktionsbeweis durch, scheitert man im<br />
Induktionsschritt: aus den Induktionsvoraussetzungen läßt sich nicht die gewünschte Aussage<br />
herleiten. Man merkt nach einigem Herumprobieren, daß man die Tiefe der Bäume<br />
mit ihrer Größe in Verbindung setzen muß. Konkret: Sei t ein Braun-Baum, dann gilt<br />
2 n size t < 2 n+1<br />
2 n−1 < size t 2 n<br />
=⇒ undepth t = n , (4.17)<br />
=⇒ depth t = n . (4.18)<br />
Ist die Größe einer 2-er Potenz, so ist der Baum vollständig ausgeglichen; alle Blätter<br />
befinden sich auf einer Ebene. Anderenfalls liegt ein Höhenunterschied von eins vor.<br />
Wenden wir uns dem Beweis zu. Induktionsbasis (t = Nil): Die Aussage folgt direkt<br />
aus size Nil = 1 und undepth Nil = depth Nil = 0. Induktionsbasis (t =<br />
Leaf a): analog. Induktionsschritt (t = Br l r): Da t ein Braun-Baum ist, gilt size l =<br />
size t⌉. Damit folgt für den linken Teilbaum<br />
⌊ 1<br />
1<br />
2size t⌋ und size r = ⌈ 2<br />
und entsprechend für den rechten<br />
Insgesamt erhalten wir<br />
2 n size t < 2 n+1<br />
=⇒ 2 n−1 size l < 2 n<br />
=⇒ undepth l = n − 1 (I.V.)<br />
2 n size t < 2 n+1<br />
=⇒ 2 n−1 size r 2 n<br />
=⇒ undepth r n − 1 (I.V.)<br />
undepth (Br l r)<br />
(Arithmetik)<br />
(Arithmetik)<br />
= min (undepth l) (undepth r) + 1 (Def. undepth)<br />
= n (obige Rechnungen)<br />
Die Aussage für depth zeigt man analog.<br />
Die ursprüngliche Behauptung ergibt sich als einfache Folgerung aus dieser Eigenschaft.<br />
Wir sehen: Die schwierigere Aussage ist einfacher nachzuweisen als die ursprüngliche.<br />
Denn: im Induktionsschritt können wir auf stärkeren Voraussetzungen aufbauen. Aber:<br />
die Formulierung der Induktionsverstärkung erfordert auch eine tiefere Durchdringung<br />
des Problems. Der entscheidende Schritt war hier, die minimale und maximale Tiefe nicht<br />
nur zueinander, sondern zur Größe in Beziehung zu setzen.
[8, 3, 5]<br />
[3, 5, 8]<br />
[8, 3, 5, 3, 6, 1]<br />
[3, 5]<br />
8 3 5 3 6 1<br />
[3, 5]<br />
[1, 3, 3, 5, 6, 8]<br />
[3, 6, 1]<br />
[6, 1]<br />
[1, 6]<br />
[1, 3, 6]<br />
Abbildung 4.1: Sortieren durch Fusionieren<br />
Harry Hacker: Was bedeutet denn der Punkt in der Definition<br />
von mergeSort?<br />
Grit Garbo: Den mißglückten Versuch, die Komposition von<br />
Funktionen zu notieren . . .<br />
Lisa Lista: Ich hab mal nachgeschlagen: der Punkt ist so definiert:<br />
(.) :: (b -> c) -> (a -> b) -> (a -> c)<br />
(f . g) a = f (g a)<br />
Nichts Aufregendes, verdeutlicht vielleicht besser die Unterteilung<br />
in zwei Phasen.<br />
76 4. Programmiermethodik<br />
4.3.5. Referential transparency<br />
Gleichungslogik, Gesetz von Leibniz, Vergleich zu Pascal (x ++ x mit Seiteneffekten).<br />
4.4. Anwendung: Sortieren durch Fusionieren<br />
Kommen wir noch einmal auf das Sortierproblem zurück. Der in Abschnitt 4.2.1 entwickelte<br />
Algorithmus „Sortieren durch Einfügen“ wird häufig von Hand beim Sortieren von<br />
Spielkarten verwendet. Man kann natürlich auch anders vorgehen, insbesondere wenn es<br />
Helfer gibt: Der Kartenstapel wird halbiert, beide Hälften werden getrennt sortiert und<br />
anschließend werden die beiden sortierten Teilstapel zu einem sortieren Gesamtstapel fusioniert.<br />
Das Sortieren der Hälften kann unabhängig voneinander und insbesondere nach<br />
dem gleichen Prinzip durchgeführt werden.<br />
Typisch für dieses Verfahren ist die (gedankliche) Unterteilung in zwei Phasen: In der<br />
ersten Phase wird jeder Stapel wiederholt halbiert, bis sich entweder keine Helfer mehr<br />
finden oder der Stapel nur noch aus einer Karte besteht. In der zweiten Phase werden die<br />
einzelnen Teilstapel wieder zusammengeführt, bis zum Schluß ein sortierter Stapel übrigbleibt.<br />
In Abbildung 4.1 wird das Verfahren exemplarisch für die Liste [8, 3, 5, 3, 6, 1]<br />
vorgeführt. Man sieht sehr schön die beiden Phasen des Verfahrens: in der oberen Hälfte<br />
der Abbildung wird geteilt, in der unteren wird zusammengeführt.<br />
Betrachtet man die Graphik in Abbildung 4.1 genauer, so entdeckt man, daß die obere<br />
Hälfte gerade einem Binärbaum entspricht — wenn man von den redundanten Beschriftungen<br />
der Verzweigungen absieht. Und: wir haben die erste Phase bereits in Abschnitt<br />
3.4 unter dem Namen build programmiert. Nennen wir die zweite Phase sortTree,<br />
dann erhalten wir<br />
mergeSort :: (Ord a) => [a] -> OrdList a<br />
mergeSort = sortTree . build<br />
Das Sortierverfahren „Sortieren durch Fusionieren“ ist nach der zentralen Operation der<br />
zweiten Phase, dem Fusionieren zweier geordneter Listen (engl. merge), benannt. 2<br />
4.4.1. Phase 2: Sortieren eines Binärbaums<br />
Für die Definition von sortTree verwenden wir — wie nicht anders zu erwarten — das<br />
Schema der strukturellen Rekursion auf Bäumen.<br />
2 In der Literatur wird „merge“ häufig und schlecht mit Mischen übersetzt. Unter dem Mischen eines Kartenspiels<br />
versteht man freilich eher das Gegenteil.
4.4. Anwendung: Sortieren durch Fusionieren 77<br />
sortTree :: (Ord a) => Tree a -> OrdList a<br />
sortTree Nil = []<br />
sortTree (Leaf a) = [a]<br />
sortTree (Br l r) = merge (sortTree l) (sortTree r)<br />
Im Rekursionsschritt müssen zwei bereits geordnete Listen zu einer geordneten Liste fusioniert<br />
werden. Die Funktion merge, die sich dieser Aufgabe annimmt, läßt sich ebenfalls<br />
strukturell rekursiv definieren.<br />
merge :: (Ord a) => OrdList a -> OrdList a -> OrdList a<br />
merge [] bs = bs<br />
merge (a:as) [] = a:as<br />
merge (a:as) (b:bs)<br />
| a OrdList a -> OrdList a -><br />
OrdList a<br />
merge’ [] = id<br />
merge’ (a:as) = insert<br />
where<br />
insert [] = a:as<br />
insert (b:bs)<br />
| a Bag a<br />
bag Nil = ∅<br />
bag (Leaf a) = a<br />
bag (Br l r) = bag l ⊎ bag r
Übung 4.16 Zeige, daß sortTree ihre Spezifikation erfüllt.<br />
78 4. Programmiermethodik<br />
Wir verwenden bag im folgenden sowohl auf Listen als auch auf Bäumen. Das Phänomen<br />
der Überladung — die Verwendung des gleichen Namens für unterschiedliche, aber verwandte<br />
Funktionen — kennen wir bereits von unserem Haskell: (==) verwenden wir auf<br />
allen Typen, die in der Typklasse Eq enthalten sind.<br />
Für die Funktion build muß gelten:<br />
bag (build x) = bag x (4.22)<br />
Beachte, daß die Struktur des generierten Baums für den Nachweis der Korrektheit keine<br />
Rolle spielt. Beim Nachweis der obigen Aussage versagt das Schema der strukturellen<br />
Induktion: die Funktion build selbst verwendet ein allgemeineres Rekursionsschema,<br />
das wir in Abschnitt 4.5 näher untersuchen. Das beweistheoretische Analogon besprechen<br />
wir im Vertiefungsabschnitt 4.6, so daß wir der Aussage an dieser Stelle einfach Glauben<br />
schenken.<br />
Für sortTree muß schließlich gelten:<br />
ordered (sortTree \(t\)) (4.23)<br />
bag (sortTree t) = bag t (4.24)<br />
Beide Aussagen lassen sich mit dem Induktionsschema für Tree einfach nachweisen<br />
(siehe Aufgabe 4.16).<br />
Wir haben kurz angemerkt, daß für den Nachweis der Korrektheit die Struktur des in<br />
der ersten Phase erzeugten Baums keine Rolle spielt. Natürlich können wir mit leftist<br />
bzw. rightist auch einen „Strunk“ erzeugen. Wir erhalten dann:<br />
insertionSort’ :: (Ord a) => [a] -> OrdList a<br />
insertionSort’ = sortTree . rightist<br />
4.4.2. Phase 1: Konstruktion von Braun-Bäumen<br />
Wir haben in Abschnitt 4.3.4 besprochen, daß die Funktion build sogenannte Braun-<br />
Bäume konstruiert. Zur Erinnerung: der linke und der rechte Teilbaum sind jeweils gleich<br />
groß, vorausgesetzt die Gesamtzahl der Blätter ist gerade; anderenfalls ist der rechte Teilbaum<br />
ein Element größer. Die beiden Varianten von build, die wir in Abschnitt 3.4<br />
vorgestellt haben, sind beide nicht strukturell rekursiv definiert — sie implementieren die<br />
Idee des wiederholten Halbierens der Ausgangsliste sehr direkt und natürlich.<br />
In diesem Abschnitt wollen wir der — vielleicht akademisch anmutenden — Frage nachgehen,<br />
wie sich Braun-Bäume strukturell rekursiv erzeugen lassen. Aus dem Rekursionsschema<br />
ergibt sich mehr oder minder zwangsläufig die folgende Definition.<br />
type Braun a = Tree a
4.4. Anwendung: Sortieren durch Fusionieren 79<br />
build’ :: [a] -> Braun a<br />
build’ [] = Nil<br />
build’ (a:as) = extend a (build as)<br />
Im Rekursionsschritt stellt sich eine interessante Teilaufgabe: ein Braun-Baum muß um<br />
ein Element erweitert werden. Die Basisfälle sind schnell gelöst:<br />
extend :: a -> Braun a -> Braun a<br />
extend a Nil = Leaf a<br />
extend a (Leaf b) = Br (Leaf b) (Leaf a)<br />
Im Rekursionsschritt haben wir einen Baum der Form Br l r mit size r - size l ∈<br />
{0, 1} gegeben, der um ein Element erweitert werden muß. Interessanterweise ergibt sich<br />
das weitere Vorgehen zwangsläufig: Das Element a muß in den kleineren Teilbaum, also<br />
l, eingefügt werden; da der erweiterte Baum extend a l größer ist als r, müssen wir<br />
die beiden Teilbäume zusätzlich vertauschen. Anderenfalls wäre die Braun-Eigenschaft<br />
verletzt. Wir erhalten:<br />
extend a (Br l r) = Br r (extend a l)<br />
Diese Vorgehensweise ist typisch für Operationen auf Braun-Bäumen. Trotz der knappen<br />
Definition von extend erfordert es einige Kopfakrobatik, die Konstruktion größerer<br />
Bäume nachzuvollziehen. In Abbildung 4.2 sind die Bäume dargestellt, die man erhält,<br />
wenn man nacheinander 0, 1, . . . , 7 in den anfangs leeren Baum einfügt. Beachte, daß<br />
build’ die Elemente anders auf die Blätter verteilt als die Funktionen gleichen Namens<br />
aus Abschnitt 3.4.<br />
Kommen wir zur Korrektheit der Operationen: Zunächst haben wir die übliche Forderung<br />
nach dem „Erhalt der Elemente“:<br />
bag (extend a t) = a ⊎ bag t (4.25)<br />
bag (build x) = bag x (4.26)<br />
Zusätzlich müssen build und extend Braun-Bäume erzeugen:<br />
braun t =⇒ braun (extend a t) (4.27)<br />
braun (build x) (4.28)<br />
Genau wie bei insert und merge geben wir auch bei extend eine Vorbedingung an:<br />
wir weisen die Korrektheit von extend a t nur für den Fall nach, daß t ein Braun-Baum<br />
ist.<br />
0 0 1<br />
1<br />
0 2 0 2 1 3<br />
1 3 2<br />
(a) (b) (c) (d) (e)<br />
2<br />
0 4<br />
3<br />
1 5<br />
3<br />
1 5 0 4 2 6<br />
(f) (g)<br />
0 4 2 6 1 5 3 7<br />
(h)<br />
Abbildung 4.2: Braun-Bäume der Größe 1 bis 8<br />
0 4<br />
Übung 4.17 Zeige die Korrektheit von build und extend. Ist<br />
die Spezifikation der Funktionen eindeutig?
80 4. Programmiermethodik<br />
4.5. Wohlfundierte Rekursion<br />
4.5.1. Das Schema der wohlfundierten Rekursion<br />
Nicht alle rekursiven Funktionen, die wir bisher kennengelernt haben, verwenden das<br />
Schema der strukturellen Rekursion. Ausnahmen sind power (Abschnitt 3.2.3), leaves’,<br />
build und buildSplit (alle Abschnitt 3.4). Schauen wir uns noch einmal die Definition<br />
von build an.<br />
build :: [a] -> Tree a<br />
build [] = Nil<br />
build [a] = Leaf a<br />
build as = Br (build (take k as)) (build (drop k as))<br />
where k = length as ‘div‘ 2<br />
In der dritten Gleichung wird die Argumentliste halbiert und die rekursiven Aufrufe<br />
erfolgen auf den beiden Hälften. Strukturelle Rekursion liegt nicht vor, da take n as<br />
keine Unterstruktur von as ist (wohl aber drop k as). Die Funktion ist trotzdem wohldefiniert,<br />
da die Länge der Teillisten echt kleiner ist als die Länge der Argumentliste. Die<br />
rekursiven Aufrufe erhalten somit immer kleinere Listen, bis irgendwann der einfache Fall<br />
der höchstens einelementigen Liste erreicht ist. Daß der einfache Fall erreicht wird, ist so<br />
sicher wie das Amen in der Kirche: Man sagt, die Rekursion ist wohlfundiert.<br />
Im Gegensatz zur strukturellen Rekursion kann das Schema der wohlfundierten Rekursion<br />
universell für beliebige Datentypen verwendet werden. Hier ist das Kochrezept:<br />
Ist das Problem einfach, wird es mit ad-hoc Methoden gelöst. Anderenfalls<br />
wird es in einfachere Teilprobleme aufgeteilt, diese werden nach dem gleichen<br />
Prinzip gelöst und anschließend zu einer Gesamtlösung zusammengefügt.<br />
Das Rekursionsschema heißt manchmal auch etwas kriegerisch „Teile und Herrsche“-<br />
Prinzip. Betrachten wir noch einmal die oben erwähnten Funktionen: Die Funktion build<br />
unterteilt ein Problem in zwei Teilprobleme; dies ist häufig der Fall, muß aber nicht so<br />
sein. Die Funktionen power und leaves enthalten lediglich einen rekursiven Aufruf. Daß<br />
die rekursiven Aufrufe tatsächlich „einfachere“ Probleme bearbeiten, ist bei allen Funktionen<br />
— mit Ausnahme von leaves — einsichtig. Im Fall von leaves geht der Baum<br />
Br (Br l’ r’) r in den Baum Br l’ (Br r’ r) über. Inwiefern ist der zweite Baum<br />
einfacher als der erste? Die Anzahl der Blätter wird zum Beispiel nicht geringer.<br />
Damit die Rekursion wohlfundiert ist, müssen die Argumente der rekursiven Aufrufe<br />
stets kleiner werden. Und: der Abstieg über immer kleiner werdende Elemente muß irgendwann<br />
auf einen Basisfall zulaufen. Diese Eigenschaft muß man als Programmiererin<br />
nachweisen; sie wird einem nicht durch das Rekursionsschema geschenkt. Im Gegenteil:
4.5. Wohlfundierte Rekursion 81<br />
der Nachweis der Wohlfundiertheit ist Voraussetzung für die sinnvolle Anwendung des<br />
Schemas.<br />
Formal zeigen wir diese Voraussetzung durch die Angabe einer wohlfundierten Relation:<br />
Die Relation „≺“ heißt wohlfundiert, wenn es keine unendliche absteigende Kette<br />
· · · ≺ xn ≺ · · · ≺ x2 ≺ x1<br />
gibt. Beispiele für wohlfundierte Relationen sehen wir in Kürze; ein Gegenbeispiel stellt<br />
die Relation „
82 4. Programmiermethodik<br />
das Gewicht eines Baums um eins: Ein schwarzer wird zu einem weißen Knoten (Abbildung<br />
4.4). Die Relation „≺“ wird somit definiert durch<br />
t ≺ u ⇐⇒ weight t < weight u .<br />
Überzeugen wir uns, daß das Gewicht des rekursiven Aufrufs tatsächlich kleiner wird.<br />
weight (Br (Br l’ r’) r)<br />
= 2*size (Br l’ r’) + weight r - 1 (Def. weight)<br />
= 2*size l’ + 2*size r’ + weight r - 1 (Def. size)<br />
> 2*size l’ + 2*size r’ + weight r - 2 (Arithmetik)<br />
= 2*size l’ + weight (Br r’ r) - 1 (Def. weight)<br />
= weight (Br l’ (Br r’ r)) (Def. weight)<br />
Damit ist gezeigt, daß leaves wohlfundiert ist.<br />
Übrigens — die strukturelle Rekursion ist ein Spezialfall der wohlfundierten Rekursion.<br />
Die Relation ≺ liegt bei der strukturellen Rekursion implizit zugrunde: y ≺ x ⇐⇒<br />
y ist Substruktur von x. Dieser Zusammenhang macht auch einen Vorzug der strukturellen<br />
Rekursion deutlich: wir bekommen den Nachweis der Wohlfundiertheit geschenkt.<br />
4.6. Vertiefung: Wohlfundierte Induktion<br />
Der Beweis von Aussage 4.22 steht noch aus. Daß build x und x stets die gleichen<br />
Elemente enthalten, läßt sich nicht — oder jedenfalls nicht direkt — mit struktureller Induktion<br />
beweisen. Dies liegt im wesentlichen daran, daß build das Schema der wohlfundierten<br />
Rekursion verwendet. Glücklicherweise gibt es aber auch ein korrespondierendes<br />
Beweisschema, daß es uns erlaubt, den Beweis direkt zu führen.<br />
Wie auch beim Rekursionsschema muß zunächst eine wohlfundierte Relation festgelegt<br />
werden, die beschreibt, was unter „einfacher“ bzw. „kleiner“ zu verstehen ist. Das Schema<br />
der wohlfundierten Induktion nimmt dann die folgende Form an:<br />
Wir zeigen die Aussage für alle Elemente, jeweils unter der Voraussetzung, daß<br />
die Aussage für alle kleineren Elemente wahr ist.<br />
Die formale Definition macht die Beweis-Obligation deutlich:<br />
(∀y) ((∀z ≺ y) Φ(z)) =⇒ Φ(y)<br />
(∀x) Φ(x)<br />
Im Unterschied zur strukturellen Induktion gibt es keine explizite Induktionsbasis und<br />
keinen Induktionsschritt: Die Aussage Φ(y) muß für alle y gezeigt werden, jeweils unter
4.6. Vertiefung: Wohlfundierte Induktion 83<br />
der Annahme, daß Φ(z) für alle z ≺ y gilt. Um diese Allaussage nachzuweisen, wird<br />
man in der Regel eine Fallunterscheidung über die Elemente des beteiligten Datentyps<br />
vornehmen. Ist die Prämisse (∀z ≺ y) Φ(z) leer, d.h., es gibt keine kleineren Elemente als<br />
y, dann liegt implizit ein Basisfall vor. Übung 4.19 Zeige, daß power korrekt ist.<br />
Wenden wir uns dem Nachweis von 4.22 zu: Die Relation „≺“ ist wie in 4.29 definiert,<br />
d.h., wir führen den Beweis über die Länge von x. Für die Fälle x = [] und x = [a] ist<br />
die Aussage wahr. In allen anderen Fällen gilt 1 k < length x. Daraus folgt<br />
length (take k x) = k < length x<br />
length (drop k x) = length x - k<br />
< length x.<br />
Somit kann die Induktionsannahme für die Argumente von build angewendet werden.<br />
Wir erhalten:<br />
bag (build x)<br />
= bag (Br (build (take k x)) (build (drop k x))) (Def. build)<br />
= bag (build (take k x)) ⊎ bag (build (drop k x)) (Def. bag)<br />
= bag (take k x) ⊎ bag (drop k x) (I.V.)<br />
= bag (take k x ++ drop k x) (4.11)<br />
= bag x (4.10)<br />
Übung 4.20 Zeige<br />
bag (leaves t) = bag t (4.30)<br />
mittels wohlfundierter Rekursion.<br />
Übung 4.21 Zeige 4.30 mittels struktureller Induktion.
84 4. Programmiermethodik
5. Effizienz und Komplexität<br />
Ziele des Kapitels:<br />
Jeder von uns hat schon manches Mal eine Rechnung abgebrochen — weil uns die Anzahl<br />
der Rechenschritte zu groß wurde oder uns die Unhandlichkeit eines immer größer<br />
wachsenden Formelberges entmutigt hat. Für uns ist dies immer der Anstoß, einen geschickteren<br />
Lösungsweg zu suchen (oder jemand anders, der die Rechnung durchführt).<br />
Niemand würde deshalb auf die Idee kommen, eine allgemeine Theorie des Rechenaufwands<br />
zu entwickeln. Anders liegt die Sache, wenn wir umfangreiche Rechnungen auf<br />
den Computer übertragen. Denn mit gegebenem Programm liegt ja der Lösungsweg fest<br />
— und damit auch der zugehörige Rechenaufwand. Die Frage nach der Effizienz von Programmen<br />
kommt damit auf den Tisch.<br />
Dieser Frage ist ein ganzes Teilgebiet der Informatik gewidmet — die Komplexitätstheorie.<br />
Die Beurteilung der Effizienz von Programmen ist für sie nur der Ausgangspunkt.<br />
Darüber hinaus geht es in ihr um die allgemeinere Frage, mit welchem Aufwand sich<br />
welche Klassen von Problemen algorithmisch lösen lassen.<br />
Von dieser Theorie behandeln wir hier im wesentlichen nur die Ausgangsfrage, nämlich<br />
die Effizienzbeurteilung von Programmen. Die dabei benutzten Begriffe und Techniken<br />
gehören zum elementaren Handwerkszeug jeden Informatikers. Über den Bedarf<br />
eines Programms an Rechenzeit und Speicherplatz sollte man sich klar sein, bevor man<br />
ein Programm startet. Im allgemeinen möchten wir ja auch den Computer nicht unnötig<br />
beschäftigen.<br />
5.1. Grundlagen der Effizienzanalyse<br />
5.1.1. Maßeinheiten für Zeit und Raum beim Rechnen<br />
Zeit mißt man in Sekunden, Raum in Metern. Beides taugt natürlich nicht, wenn wir den<br />
Aufwand zur Durchführung eines Programms messen wollen. Sekunden eignen sich nicht<br />
zur Bestimmung der Rechenzeit — verschiedene Rechner, elektronische wie menschliche,<br />
werden ein unterschiedliches Tempo an den Tag legen, was aber nichts mit den Eigenschaften<br />
des gerechneten Programms zu tun hat. Gleiches gilt für den Platzbedarf beim<br />
Rechnen. Wir können schlecht die Quadratmeter Papier zählen, die im Zuge einer Rechnung<br />
mit Formeln vollgekritzelt werden. Menschliche Rechner weisen enorme Unterschie-
Grit Garbo: In der Tat gelingt es mir stets, auch die komplizierteste<br />
Rechnung auf einer DIN A4 Seite unterzubringen. Tafelwi-<br />
86 5. Effizienz und Komplexität<br />
de im Papierverbrauch auf, ohne daß dies als Problem gesehen wird. Dagegen gleicht der<br />
Tafelanschrieb eines Dozenten schon eher einem bewußten Magagement von begrenzten<br />
Platzressourcen und damit dem, was auch beim maschinellen Rechnen mit beschränktem<br />
Speicherplatz berücksichtigt werden muß. Es gilt also, Maßeinheiten für Rechenzeit und<br />
schen vermeide ich grundsätzlich. Platzbedarf festzulegen, die vom ausführenden Organ unabhängig sind und so nur die<br />
Eigenschaften des Programms wiedergeben.<br />
Ein etwas abstrakteres Maß der Rechenzeit ist ein Rechenschritt. Dabei gibt es verschiedene<br />
Möglichkeiten, was man als einen Rechenschritt zählen will, und wir wählen<br />
die naheliegendste: Ein Rechenschritt ist die Anwendung einer definierenden Gleichung.<br />
Ist die Gleichung bewacht, zählen die Rechenschritte zur Auswertung der Wächter plus<br />
Lisa Lista: Dabei gibt es doch sehr einfache und beliebig komplizierte<br />
Gleichungen, und z.B. die Anwendung der let-Regel<br />
kommt mir ganz besonders aufwendig vor. Wenn von all dem<br />
abstrahiert wird — wie können wir dann ein realistisches Effizienzmaß<br />
erhalten?<br />
Harry Hacker: Mit Maschineninstruktionen und Bytes als Einheiten<br />
für Zeit und Platz wäre mir wohler. Da weiß man was<br />
man hat.<br />
Lisa Lista: Konkreter wär’s allemal, Harry, aber wir müßten die<br />
Haskell-Programme erst in Maschinenbefehle übersetzen, ehe<br />
wir sie beurteilen können...<br />
ein extra Schritt zur Auswahl der rechten Seite. Wenn unsere Programme let-, caseoder<br />
Funktionsausdrücke enthalten, zählen wir auch die Anwendung jeder let-, case-<br />
oder β-Regel als einen Schritt. Was wir nicht zählen, ist die Anwendung eines Kon-<br />
struktors auf seine Argumente — schließlich sind Formeln, soweit sie aus Konstruktoren<br />
aufgebaut sind, ja bereits ausgerechnet.<br />
Im Hinblick auf den Platzbedarf einer Rechnung müssen wir zuächst festlegen, was die<br />
Größe einer Formel ist. Sie soll bestimmt sein durch die Anzahl ihrer Symbole (Funktionen,<br />
Konstruktoren, Variablen). Klammern zählen wir nicht mit. Eine Rechnung geht von<br />
einer gegebenen Formel aus, die es auszuwerten gilt. Als Platzbedarf der Rechnung, in<br />
deren Verlauf eine Formel ja wachsen und wieder schrumpfen kann, betrachten wir die<br />
maximale Formelgröße, die in der Rechnung auftritt. Dies entspricht also eher dem Modell<br />
des Tafelanschriebs mit Löschen erledigter Rechnungsteile (und Wiederverwendung<br />
der Tafelfläche), und weniger dem verschwenderischen Papierverbrauch.<br />
Es ist offensichtlich, daß wir uns bei der Festlegung der Einheiten an den Gegebenheiten<br />
der Sprache Haskell orientiert haben. Will man Programme in anderen Sprachen<br />
beurteilen, kann man in ganz analoger Weise vorgehen. Die Techniken, die wir nun kennenlernen,<br />
sind auf alle Programmiersprachen anwendbar.<br />
5.1.2. Detaillierte Analyse von insertionSort<br />
Wir haben zwei Sortierprogramme kennengelernt. Da beide korrekt sind, realisieren sie<br />
die gleiche mathematische Funktion: isort = mergeSort. Während der Mathematiker<br />
jetzt verschnauft, fängt für den Informatiker die Arbeit an: Es gilt die von den beiden Programmen<br />
benötigten Resourcen, Rechenzeit und Speicherplatz, zu bestimmen. Wenn wir<br />
das erreicht haben, werden wir sehen, daß mergeSort in den meisten Fällen kostengünstiger<br />
arbeitet als isort und somit bei Anwendungen den Vorzug erhält.<br />
Es ist nützlich sich eine Notation für die Bestimmung des Rechenaufwands auszudenken:<br />
T ime(e ⇒ v) bezeichnet die Anzahl der Rechenschritte, um den Ausdruck e zum<br />
Wert v auszurechnen. Sind wir an v nicht interessiert, schreiben wir auch kurz T ime(e).
5.1. Grundlagen der Effizienzanalyse 87<br />
Den Platzbedarf, um e zu v auszurechnen, bezeichnen wir entsprechend mit Space(e ⇒ v)<br />
oder kurz mit Space(e).<br />
Nähern wir uns dem Thema langsam und versuchen eine Analyse des Zeitbedarfs von<br />
isort — der Platzbedarf ist weniger interessant, wie wir später sehen werden. Die Funktion<br />
isort stützt sich auf die Funktion insert ab; also beginnen wir mit der Analyse<br />
von insert. Um die genaue Anzahl der Rechenschritte zu bestimmen, schreiben wir die<br />
Definition von insert noch einmal auf.<br />
insert a [] = [a] (insert.1)<br />
insert a (a’:as) (insert.2)<br />
| a
88 5. Effizienz und Komplexität<br />
Die Gleichungen beschreiben unser bisheriges Wissen über die Funktion T ime. Sie reichen<br />
aus, um z.B. T ime(isort [8,3,5,3,0,1]) zu bestimmen. [Was kommt heraus?]<br />
Das ist nett, stellt uns aber nicht zufrieden: In der Regel wird man die Rechenzeit nicht<br />
für ein bestimmtes Argument, sondern für beliebige Eingaben, d.h. in Abhängigkeit von<br />
der Eingabe, bestimmen wollen. Und: Die Abhängigkeit der Laufzeit von der Eingabe<br />
soll möglichst griffig dargestellt werden. Schließlich wollen wir ja die Effizienz eines Programms<br />
insgesamt bewerten und mit der Effizienz anderer Programme vergleichen. Nun<br />
besteht aber wenig Aussicht, T ime(isort [a1,...,an]) mit einer geschlossenen Formel<br />
auszudrücken. Wir vereinfachen uns das Leben, indem wir etwas abstrahieren und die<br />
Rechenzeit in Abhängigkeit von der Größe der Eingabe bestimmen, d.h., wir kümmern uns<br />
nicht um die Elemente der zu sortierenden Liste, sondern nur um ihre Länge. Eigentlich<br />
ist dies auch vernünftig, da wir in der Regel eher genaue Kenntnis über die Anzahl denn<br />
über die Ausprägung von Daten haben.<br />
Nun ist die Laufzeit von isort nicht funktional abhängig von der Größe der Liste:<br />
isort [1,2,3] ist schneller ausgerechnet als isort [3,2,1]. Wir helfen uns, indem<br />
wir eine obere und eine untere Schranke für die Rechenzeit bestimmen und die Rechenzeit<br />
auf diese Weise einkreisen. In anderen Worten: Wir fragen, wie schnell kann man<br />
isort [a1,...,an] im besten und im schlechtesten Fall ausrechnen. Wenn wir das<br />
Rechnen anderen — z.B. einem Rechner — überlassen, sagt uns die obere Schranke, ob<br />
es sich lohnt, auf das Ergebnis der Rechnung zu warten. Die untere Schranke hilft uns<br />
bei der Entscheidung, ob wir vorher einen Kaffee trinken gehen. Formal suchen wir zwei<br />
Funktionen T isort und T isort, die die Rechenzeit einkreisen:<br />
T isort (n) T ime(isort [a1,...,an]) T isort(n)<br />
Die untere und die obere Schranke sollten natürlich möglichst exakt sein:<br />
T isort (n) = min{ T ime(isort x) | length x = n }<br />
T isort(n) = max{ T ime(isort x) | length x = n }<br />
Daß wir mit der Größe der Eingabe ausgedrückt als natürliche Zahl arbeiten, hat einen<br />
weiteren nicht zu unterschätzenden Vorteil: Wir sind gewohnt mit Funktionen auf N zu<br />
rechnen und von Seite der Mathematik werden wir kräftig unterstützt.<br />
Aus den obigen Gleichungen für T ime können wir Gleichungen für T isort und T isort<br />
ableiten, indem wir annehmen, daß der Wächter in insert immer zu True oder immer
5.1. Grundlagen der Effizienzanalyse 89<br />
zu False auswertet.<br />
T insert (0) = 1<br />
T insert (n + 1) = 3<br />
T insert(0) = 1<br />
T insert(n + 1) = 3 + T insert(n)<br />
T isort (0) = 1<br />
T isort (n + 1) = 1 + T insert (n) + T isort (n)<br />
T isort(0) = 1<br />
T isort(n + 1) = 1 + T insert(n) + T isort(n)<br />
Aus den rekursiven Gleichungen für insert und isort erhalten wir rekursiv definierte<br />
Kostenfunktionen. In der Literatur heißen solche Gleichungen auch Rekurrenzgleichungen,<br />
da die definierte Funktion auf der rechten Seite wieder auftritt (lat. recurrere). In den<br />
folgenden Abschnitten werden wir Techniken kennenlernen, um diese und etwas kompliziertere<br />
Rekurrenzgleichungen aufzulösen. Für den Moment begnügen wir uns damit, die<br />
geschlossenen Formen der Kostenfunktionen anzugeben:<br />
T insert (0) = 1<br />
T insert (n + 1) = 3<br />
T insert(n) = 3n + 1<br />
T isort (0) = 1<br />
T isort (n + 1) = 4n + 1<br />
T isort(n) = (3/2)n 2 + (1/2)n + 1<br />
Im besten Fall muß insert das Element an den Anfang der Liste setzen, im schlechtesten<br />
Fall an das Ende der Liste; die Anzahl der Rechenschritte ist dann proportional zur Länge<br />
der Liste. Sortieren durch Einfügen liebt somit aufsteigend sortierte Listen und verabscheut<br />
absteigend sortierte Listen. Bevor wir zur Analyse der Sortierfunktion mergeSort<br />
kommen, stellen wir ein paar allgemeine Betrachtungen an.<br />
5.1.3. Asymptotische Zeit- und Platzeffizienz<br />
In unseren bisherigen Überlegungen paßt etwas nicht zusammen: Einerseits, um die Kostenfunktionen<br />
zu ermitteln, haben wir recht pedantisch die Rechenschritte gezählt. Andererseits,<br />
bei der Bestimmung der Einheit „Rechenschritt“ haben wir großzügig jede Regel<br />
mit Kosten von eins belegt. Tatsächlich könnte es aber sein, daß die case-Regel aufwendiger<br />
ist als die let-Regel; die Kosten für die let-Regel könnten abhängen von der<br />
Anzahl der Hilfsdefinitionen. Die Anwendung einer definierenden Gleichung könnte von
90 5. Effizienz und Komplexität<br />
der Anzahl der Parameter und von der Größe der Muster abhängen. Zudem war die Einheit<br />
Rechenschritt selbst schon eine Abstraktion von realer, zum Rechnen benötigter Zeit:<br />
Auf dem Rechner Warp III mit dem Übersetzer Turbo-Haskell könnten die Programme<br />
um den Faktor 10 schneller ablaufen als auf dem Rechner Slack I mit dem Übersetzer<br />
Tiny-Haskell. Jede dieser Überlegungen könnte dazu führen, daß die errechneten Kosten<br />
um einen konstanten Faktor verringert oder vergrößert werden müssen.<br />
Die gleiche etwas abstraktere Sichtweise wenden wir auch die Kostenfunktionen an.<br />
Wenn wir die Effizienz eines Programms beurteilen bzw. zwei Programme bezüglich ihrer<br />
Effizienz vergleichen, werden wir zunächst nicht danach fragen, ob 3n2 oder 15n2 Rechenschritte<br />
benötigt werden. Für eine erste Einordnung interessiert uns das asymptotische<br />
Wachstum der Kostenfunktionen: Wie verhält sich die Rechenzeit, wenn die Eingaben<br />
groß — sehr groß — werden. Ein Programm mit einer Rechenzeit von 15n2 is asymptotisch<br />
besser als ein anderes mit einer Rechenzeit von 1<br />
15n3 : Die kleinere Konstante ist für<br />
n 152 = 225 vom höheren Exponenten aufgebraucht.<br />
Um das asymptotische Verhalten von Funktionen zu beschreiben, gibt es einige wichtige<br />
Notationen, die wir im folgenden einführen. Zu einer gegebenen Funktion g : N → N<br />
bezeichnet Θ(g) die Menge aller Funktionen mit der gleichen Wachstumsrate wie g:<br />
Θ(g) = { f | (∃n0, c1, c2)(∀n n0) c1g(n) f(n) c2g(n) } (5.1)<br />
Ist f ∈ Θ(g), so heißt g asymptotische Schranke von f. Funktionen werden in diesem<br />
Zusammenhang überlicherweise durch Ausdrücke beschrieben: 15n 2 meint die Funktion<br />
n ↦→ 15n 2 . Welche Variable die Rolle des formalen Parameters übernimmt, ergibt sich aus<br />
dem Kontext. 1 Wenn wir somit kurz 15n 2 ∈ Θ(n 2 ) schreiben, meinen wir, daß g mit g(n) =<br />
n 2 eine asymptotische Schranke von f mit f(n) = 15n 2 ist. Das asymptotische Verhalten<br />
der Kostenfunktionen von insert und isort können wir wie folgt beschreiben:<br />
T insert (n) ∈ Θ(1)<br />
T insert(n) ∈ Θ(n)<br />
T isort (n) ∈ Θ(n)<br />
T isort(n) ∈ Θ(n 2 )<br />
Wir sagen, die „best case“-Laufzeit von isort ist Θ(n) und die „worst case“-Laufzeit ist<br />
Θ(n 2 ). Man sieht, daß Konstanten und kleine Terme, z.B. 2n + 1 in 3n 2 + 2n + 1, unter den<br />
Tisch fallen. Geht n gegen unendlich, dominiert der Term mit dem größten Exponenten.<br />
1 In unserer Programmiersprache unterscheiden wir sorgfältig zwischen Ausdrücken, die Zahlen bezeichnen,<br />
und Ausdrücken, die Funktionen bezeichnen: 15*nˆ2 ist eine Zahl, die wir ausrechnen können, wenn wir<br />
n kennen. Wollen wir ausdrücken, daß 15*nˆ2 eine Funktion in n ist, schreiben wir \n -> 15*nˆ2. Aus<br />
Gründen der Bequemlichkeit — eine Bequemlichkeit, die uns ein Rechner nicht gestattet — verwenden wir<br />
an dieser Stelle für Funktionen einfach Ausdrücke, ohne den formalen Parameter explizit zu kennzeichnen.
5.1. Grundlagen der Effizienzanalyse 91<br />
Um nicht immer auf die Definition von Θ zurückgreifen zu müssen, ist es nützlich, sich<br />
ein paar Eigenschaften von Θ zu überlegen. Hier ist eine Liste der wichtigsten:<br />
f ∈ Θ(f) (Reflexivität) (5.2)<br />
f ∈ Θ(g) ∧ g ∈ Θ(h) ⇒ f ∈ Θ(h) (Transitivität) (5.3)<br />
f ∈ Θ(g) ⇒ g ∈ Θ(f) (Symmetrie) (5.4)<br />
cf ∈ Θ(f) (5.5)<br />
n a + n b ∈ Θ(n a ) für a > b (5.6)<br />
log a n ∈ Θ(log b n) (5.7)<br />
In der letzten Beziehung haben wir unsere Notation stillschweigend auf Funktionen über<br />
R ausgedehnt. Das erlaubt es uns, ohne „floor“- und „ceiling“-Funktionen zu rechnen.<br />
Neben exakten asymptotischen Schranken gibt es auch Notationen für untere und obere<br />
asymptotische Schranken:<br />
Ω(g) = { f | (∃n0, c)(∀n n0) cg(n) f(n) } (5.8)<br />
O(g) = { f | (∃n0, c)(∀n n0) f(n) cg(n) } (5.9)<br />
Ist f ∈ Ω(g), so heißt g untere asymptotische Schranke von f. Für f ∈ O(g) heißt g entsprechend<br />
obere asymptotische Schranke von f. Die asymptotische Laufzeit von isort<br />
ist z.B. Ω(n) und O(n 2 ). Etwas Vorsicht ist bei der Verwendung von Ω und O geboten.<br />
Auch die folgende Aussage ist wahr: Die asymptotische Laufzeit von isort ist Ω(log n)<br />
und O(n 10 ). Die Schranken müssen eben nicht genau sein. Aus diesem Grund ist es besser<br />
zu sagen: Die „best case“-Laufzeit von isort ist Θ(n) und die „worst case“-Laufzeit<br />
von isort ist Θ(n 2 ). Denkaufgabe: Gibt es eine Funktion f, so daß die Aussage „Die<br />
asymptotische Laufzeit von isort ist Θ(f)“ korrekt ist?<br />
Die asymptotische Betrachtungsweise hat viele Vorteile: Sie setzt nachträglich unsere<br />
zu Beginn dieses Kapitels getroffenen Abstraktionen ins Recht. Wenn wir die asymptoti-<br />
sche Laufzeit von Funktionen analysieren, ist es in der Tat korrekt, die Anwendung einer<br />
Gleichung als einen Schritt zu zählen. Warum? Nun, die Gestalt der Gleichungen hängt<br />
nicht von der Eingabe ab, ist also konstant. Jede Gleichung faßt eine feste Anzahl (einfacherer)<br />
Rechenschritte zusammen; das Ergebnis der Analyse kann somit nur um einen<br />
konstanten Faktor verfälscht werden. Die Konstante fällt aber in der Θ-Notation wieder<br />
unter den Tisch. Gleiches gilt für den Einfluß des Compilers: Sofern jede Haskell- Rechenregel<br />
auf eine feste Anzahl von Maschineninstruktionen abgebildet wird, kommt es für die<br />
asymptotische Effizienz aufs Gleiche heraus, ob wir Maschineninstruktionen oder Haskell-<br />
Regeln als Einheit wählen. Und schließlich macht auch die unterschiedliche Taktrate der<br />
Rechner Warp III und Slack I nur einen konstanten Faktor aus, für die asymptotische<br />
Effizienz also keinen Unterschied.<br />
Wir dürfen sogar noch konsequenter sein und nur „laufzeitbestimmende“ Operationen<br />
zählen: Getreu der asymptotischen Betrachtungsweise können wir eine Operation X als<br />
Lisa Lista: Harry, damit sind unsere früheren Bedenken gegen<br />
die Einheit Rechenschritt ausgeräumt, oder?<br />
Harry Hacker: Sind sie — bis auf einen letzten Punkt. Vielleicht<br />
sollte ich mal den Source-Code des Haskell-Compilers durchlesen.
Prof. Paneau: Wobei nicht verschwiegen werden sollte, daß<br />
sich bei der Bestimmung der bestimmenden Operation schon<br />
mancher verschätzt hat. Strenggenommen muß für jede Regel<br />
nachgewiesen werden, daß für sie T Y CT X gilt, und zwar für<br />
ein festes C. Die Informatiker gehen damit oft sehr intuitiv um.<br />
Übung 5.2 Nimm an, daß das Einfügen eines Elementes in eine<br />
Liste durch insert im Durchschnitt in der Mitte der Liste erfolgt.<br />
Leite unter dieser Annahme die average-case Effizienz von<br />
isort ab. Wo liegt der Unterschied zum worst case?<br />
92 5. Effizienz und Komplexität<br />
bestimmend ansehen, wenn für jede andere Operationen Y gilt T Y ∈ O(T X). Die Sortierfunktionen<br />
basieren z.B. auf dem Vergleich von Elementen; die Anzahl der durchgeführten<br />
Vergleichsoperationen bestimmt somit im wesentlichen die Laufzeit der Funktionen. Im<br />
nächsten Abschnitt werden wir sehen, daß die Analyse von isort die gleiche asymptoti-<br />
sche Laufzeit ermittelt, wenn wir nur die Vergleichsoperationen zählen.<br />
Die Vorteile der asymptotischen Betrachtungsweise sind auch ihre Nachteile: Ist die<br />
verborgene Konstante sehr groß, kann ein Verfahren trotz guter asymptotischer Laufzeit<br />
in der Praxis unbrauchbar sein. Auf die Algorithmen, die wir betrachten, trifft das Gott sei<br />
Dank nicht zu.<br />
Noch eine letzte Bemerkung zur Art der Analyse. Bisher haben wir den „best case“ und<br />
den „worst case“ untersucht. Man kann auch den Resourcenverbrauch für den „average<br />
case“ berechnen. Die Analyse des durchschnittlichen Falls ist aber im allgemeinen sehr<br />
schwierig: Sie setzt voraus, daß man Kenntnis über die Häufigkeitsverteilung der Eingaben<br />
hat. Selbst wenn man — ob nun gerechtfertigt oder nicht — annimmt, daß alle Eingaben<br />
gleich wahrscheinlich sind, bleibt die Analyse aufwendig.<br />
5.2. Effizienz strukturell rekursiver Funktionen<br />
Bestimmen wir noch einmal die „worst case“-Laufzeit von isort, indem wir nur die Anzahl<br />
der benötigten Vergleichsoperationen bestimmen. Das vereinfacht das Zählen und<br />
auch die Rekurrenzgleichungen.<br />
T insert(0) = 0<br />
T insert(n + 1) = 1 + T insert(n)<br />
T isort(0) = 0<br />
T isort(n + 1) = T insert(n) + T isort(n)<br />
Die Gleichungen haben eine besonders einfache Form, die gut als Summenformel geschrieben<br />
werden kann. Man rechnet schnell nach, daß eine Kostenfunktion der Form<br />
die geschlossene Form<br />
C(0) = c<br />
C(n + 1) = f(n + 1) + kC(n)<br />
C(n) = k n c +<br />
nX<br />
k n−i f(i) (5.10)<br />
hat. Im Fall von T insert und T isort ist die Konstante k gleich 1, da die Funktionen wie<br />
auch der Datentyp Liste linear rekursiv sind: In der definierenden Gleichung kommt das<br />
i=1
5.2. Effizienz strukturell rekursiver Funktionen 93<br />
definierte Objekt genau einmal auf der rechten Seite vor. Mithilfe der Summenformel lassen<br />
sich die Kostenfunktionen für insert und isort leicht in eine geschlossene Form<br />
bringen.<br />
T insert(n) =<br />
T isort(n) =<br />
nX<br />
1 = n ∈ Θ(n)<br />
i=1<br />
nX<br />
i=1<br />
i − 1 = 1<br />
2 n(n − 1) ∈ Θ(n2 )<br />
Wir sehen: Wenn wir durch die asymptotische Brille blicken, erhalten wir das gleiche<br />
Ergebnis wie in Abschnitt 5.1.2.<br />
Bisher haben wir den Zeitbedarf von isort analysiert. Widmen wir uns jetzt dem Platzbedarf.<br />
Bei der Analyse kommen uns die Überlegungen zur asymptotischen Komplexität<br />
ebenfalls zu Gute: Auch hier spielen konstante Faktoren keine Rolle. Schauen wir uns den<br />
typischen Verlauf einer Rechnung an.<br />
isort [a1, ..., an−1, an]<br />
⇒ insert a1 (· · · (insert an−1 (insert an [])) · · ·)<br />
⇒ [aπ(1), ..., aπ(n−1), aπ(n)]<br />
In der Mitte der Rechnung harren n Anwendungen von insert ihrer Abarbeitung. Während<br />
der Abarbeitung von insert ai x vergrößert sich die Formel kurzfristig etwas; am<br />
Ende steht die um ai erweiterte Liste x. Summa summarum ist der Platzbedarf proportional<br />
zur Länge der Eingabeliste.<br />
Space(insert a x) ∈ Θ(length x)<br />
Space(isort x) ∈ Θ(length x)<br />
Strukturell rekursive Funktionen auf Listen sind in der Regel gut zu analysieren, da die<br />
resultierenden Rekurrenzgleichungen eine einfache Form haben. Funktionen auf Bäumen<br />
stellen uns vor größere Probleme; dafür ist der Erkenntnisgewinn auch größer. Die Funktion<br />
sortTree, die einen Baum in eine sortierte Liste überführt, illustriert dies eindrucksvoll.<br />
sortTree :: Tree Integer -> [Integer]<br />
sortTree (Leaf a) = [a]<br />
sortTree (Br l r) = merge (sortTree l) (sortTree r)<br />
[In den nachfolgenden Rechnungen kürzen wir sortTree mit sT ab.] Wir wollen die<br />
„worst case“-Laufzeit von sortTree bestimmen; wie bereits praktiziert zählen wir zu<br />
diesem Zweck nur die Vergleichsoperationen. Das Verhalten von merge ist schnell geklärt:<br />
T merge(m, n) = m + n − 1 für m, n 1
Übung 5.3 Zeige, daß 1<br />
n(n − 1) die obige Rekurrenzgleichung<br />
2<br />
tatsächlich erfüllt.<br />
Übung 5.4 Beweise die Eigenschaft 2ˆ(depth t - 1) <<br />
size t 2ˆdepth t .<br />
94 5. Effizienz und Komplexität<br />
Der schlechteste Fall für merge liegt vor, wenn beide Listen wie ein Reißverschluß verzahnt<br />
werden müssen: merge [0,2..98] [1,3..99]. [Ideal wäre es, wenn alle Elemente<br />
der kürzeren Liste kleiner sind als die der längeren Liste.]<br />
Die Größe der Eingabe entspricht bei listenverarbeitenden Funktionen der Länge der<br />
Liste. Was ist die bestimmende Eingabegröße im Fall von sortTree? Zur Listenlänge<br />
korrespondiert die Anzahl der Blätter eines Baums — zu length korrespondiert size.<br />
Versuchen wir unser Glück:<br />
T sT(1) = 0<br />
T sT(n) = n − 1 + max{ T sT(i) + T sT(n − i) | 0 < i < n }<br />
Da wir nur die Größe des Baums kennen, nicht aber die Verteilung der Blätter auf den<br />
linken und rechten Teilbaum, sind wir gezwungen alle möglichen Kombinationen durchzuprobieren.<br />
Schließlich wollen wir ja die „worst case“-Laufzeit bestimmen. Es ist immer<br />
hilfreich, unbekannte Funktionen für kleine Werte zu tabellieren:<br />
Man sieht, wir erhalten:<br />
n 1 2 3 4 5 6 7 8 9<br />
T sT(n) 0 1 3 6 10 15 21 28 36<br />
T sT(n) =<br />
nX<br />
i=1<br />
i − 1 = 1<br />
2 n(n − 1) ∈ Θ(n2 )<br />
Der schlechteste Fall tritt somit ein, wenn der linke Teilbaum nur einen Knoten enthält<br />
und der rechte alle übrigen, oder umgekehrt. Dann gilt gerade: size t = depth t + 1.<br />
Die Verwendung von size als Maß für die Größe eines Baums ist eigentlich nicht<br />
besonders motiviert, genausogut könnten wir die Tiefe eines Baumes heranziehen. Los<br />
geht’s:<br />
T ′<br />
sT(0) = 0<br />
T ′<br />
sT(n + 1) = 2 n+1 − 1 + 2T ′<br />
sT(n)<br />
Die Kosten für den Aufruf von merge, sprich die Anzahl der Knoten im Baum, haben wir<br />
mit Gleichung (4.13) grob nach oben abgeschätzt. Die Rekurrenzgleichungen können wir<br />
mit (5.10) lösen:<br />
T ′<br />
sT(n) =<br />
nX<br />
2 n−i (2 i − 1) =<br />
i=1<br />
nX<br />
2 n − 2 n−i = n2 n − 2 n + 1 ∈ Θ(n2 n )<br />
i=1<br />
Hier ist der schlechteste Fall kurioserweise der ausgeglichene Baum 2 , dessen Größe durch<br />
2ˆ(depth t - 1) < size t 2ˆdepth t eingeschränkt ist. Jetzt betrachten wir<br />
2 Zur Erinnerung: Ein Baum heißt ausgeglichen, wenn sich die Größe der Teilbäume für jede Verzweigung um<br />
maximal eins unterscheidet.
5.2. Effizienz strukturell rekursiver Funktionen 95<br />
beide Ergebnisse noch einmal und gleichen die Resultate mit den jeweiligen Beziehungen<br />
zwischen Größe und Tiefe der Bäume ab. In beiden Fällen erhalten wir depth t*size t<br />
als „worst case“-Laufzeit. Die Laufzeit wird durch das Produkt von Größe und Tiefe bestimmt:<br />
T ime(sortTree t) depth t*size t<br />
Wir sehen: Die Laufzeit von sortTree läßt sich am genauesten bestimmen, wenn wir<br />
sowohl die Größe als auch die Tiefe des Baums berücksichtigen.<br />
T ′′<br />
sT(s, d) = sd<br />
Für unsere Mühen werden wir freilich auch belohnt: Mit der Analyse von sortTree<br />
haben wir so nebenbei auch isort und mergeSort erledigt! Die Aufrufstruktur von<br />
isort entspricht gerade einem rechtsentarteten Baum. Aus size t = depth t+1 folgt<br />
das bereits bekannte Resultat:<br />
T isort(n) = T ′′<br />
sT(n, n − 1) ∈ Θ(n 2 )<br />
Der von mergeSort abgearbeitete Baum ist hingegen ausgeglichen. Wir wissen ja bereits,<br />
daß build stets ausgeglichene Bäume erzeugt. Aus 2ˆ(depth t - 1) < size t <br />
2ˆdepth t folgt dann<br />
und<br />
T mergeSort(n) = T sortTree(n) + T build(n)<br />
n⌊log 2 n⌋ T ′′<br />
sT (n, ⌊log 2 n⌋) T mergeSort(n) T ′′<br />
sT (n, ⌈log 2 n⌉) n⌈log 2 n⌉<br />
Da build überhaupt keine Vergleiche durchführt, gilt<br />
T build = 0<br />
und somit (das Weglassen der Basis des Logarithmus ist durch 5.7 gerechtfertigt)<br />
T mergeSort (n) ∈ Θ(n log n).<br />
Aus den Ergebnissen läßt sich unmittelbar folgern, daß Sortieren durch Fusionieren eine<br />
asymptotisch bessere „worst case“-Laufzeit als Sortieren durch Einfügen hat.<br />
Der Platzbedarf von mergeSort ist genau wie der von insertionSort linear in der<br />
Länge der Eingabeliste. Dazu überlegt man sich, wieviele Knoten der Aufrufbaum von<br />
mergeSort enthält.<br />
mergeSort [a1, ..., an−1, an]<br />
⇒ merge (· · ·(merge [a1] [a2])· · ·) (· · ·(merge [an−1] [an])· · ·)<br />
⇒ [aπ(1), ..., aπ(n−1), aπ(n)]<br />
Übung 5.5 Zeige durch strukturelle Induktion über t, daß die<br />
Beziehung T ime(sortTree t) depth t*size t gilt.
96 5. Effizienz und Komplexität<br />
In der Mitte der Rechnung harren n − 1 merge-Aufrufe ihrer Abarbeitung. Somit ist die<br />
maximale Größe der Formel proportional zur Größe der Eingabeliste.<br />
Space(mergeSort x) ∈ Θ(length x)<br />
Jetzt haben wir alle Informationen zusammen, um die beiden Sortierverfahren quantitativ<br />
miteinander zu vergleichen. Da der Platzbedarf asymptotisch gleich ist, betrachten<br />
wir nur den Bedarf an Rechenzeit. Klar, die Funktion n log n ist kleiner als n 2 , also ist<br />
mergeSort effizienter als insertionSort. Aber, um wieviel effizienter? Ein paar Zahlen<br />
verdeutlichen den Unterschied:<br />
n n log 2 n n 2<br />
10 3 ≈ 10, 0 ∗ 10 3 10 6<br />
10 4 ≈ 13, 3 ∗ 10 4 10 8<br />
10 5 ≈ 16, 6 ∗ 10 5 10 10<br />
10 6 ≈ 19, 9 ∗ 10 6 10 12<br />
Ist n gleich Tausend, ist Sortieren durch Fusionieren 100-mal schneller als Sortieren durch<br />
Einfügen; für n gleich eine Million beträgt der Faktor schon 50.000. Je größer die Listen<br />
werden, desto größer ist der Geschwindigkeitsgewinn.<br />
Harry Hacker und Lisa Lista erhalten den Auftrag einen, Datensatz mit 1.000.000 Einträgen nach<br />
bestimmten Kriterien zu sortieren. Harry verläßt sich auf sein Equipment, Warp III mit dem Übersetzer<br />
Turbo-Haskell, und implementiert zu diesem Zweck Sortieren durch Einfügen. Durch geschickte<br />
Programmierung und aufgrund der Verwendung eines optimierenden Übersetzers, benötigt das resultierende<br />
Programm 2n 2 Rechenschritte, um eine n-elementige Liste zu sortieren. Lisa Lista hat<br />
sich vorher in die Literatur vertieft und Sortieren durch Fusionieren gefunden. Ihre Implementierung<br />
ist nicht besonders ausgefeilt und da sie einen schlechten Übersetzer verwendet, benötigt ihr Programm<br />
80n log 2 n Rechenschritte. Harrys Warp III schafft durchschnittlich 750.000 Rechenschritte<br />
pro Sekunde, Lisas Slack I nur 50.000. Trotzdem wartet Harry — falls er so viel Geduld hat — rund<br />
einen Monat auf das Ergebnis<br />
2 · (10 6 ) 2<br />
750.000<br />
≈ 2.666.666 Sekunden ≈ 30, 9 Tage,<br />
während Lisa nach rund neun Stunden den sortierten Datensatz vorliegen hat:<br />
80 · (10 6 ) · log 2 (10 6 )<br />
50.000<br />
≈ 31.891 Sekunden ≈ 8, 9 Stunden.<br />
In einigen Fällen schneidet insertionSort allerdings besser ab als mergeSort: Gerade<br />
dann, wenn die Liste im wesentlichen schon sortiert ist. Dann benötigt insertionSort<br />
statt quadratischer nur noch lineare Laufzeit, während sich an dem Verhalten von mergeSort
5.3. Effizienz wohlfundiert rekursiver Funktionen 97<br />
asympotisch nichts ändert. Nur der konstante Faktor wird etwas besser. Nachrechnen!<br />
Ob der Fall der (fast) vorsortierten Liste häufig auftritt, hängt von der jeweiligen Anwendung<br />
ab. Manchmal ist es sinnvoll, ihn zu berücksichtigen. Wir kommen darauf in<br />
Abschnitt 5.5.2 zurück.<br />
5.3. Effizienz wohlfundiert rekursiver Funktionen<br />
Einige der bisher definierten Funktionen basieren auf dem Prinzip der wohlfundierten Rekursion:<br />
power, leaves und build. Die Funktion power analysieren wir in Abschnitt 5.8,<br />
build und leaves kommen hier an die Reihe. Beginnen wir mit build. Wir gehen hier<br />
etwas genauer vor und zählen alle Rechenschritte, weil wir später (Abschnitt 5.5) auch für<br />
die konstanten Faktoren interessieren werden. Auch die Konstruktoranwendungen zählen<br />
mit, schließlich besteht der Zweck von build gerade im Baumaufbau. Wir gehen von der<br />
ursprünglichen Definition von build aus:<br />
build [] = Nil<br />
build [a] = Leaf a<br />
build (a:as) = Br (build (take k as))(build (drop (n-k) as))<br />
where k = length as ‘div‘ 2<br />
Die Effizienz von take und drop haben wir bereits analysiert; build verwendet außerdem<br />
length, was besonders einfach zu analysieren ist:<br />
T build(0) = 1<br />
T build(1) = 1<br />
T take(k, n) = T drop(k, n) = min(k, n)<br />
T length(n) = T length (n) = n + 1 ∈ Θ(n).<br />
T build(n) = 1 + T length(n) + T take(k, n) + T drop(k, n) + T build(⌊n/2⌋) + T build(⌈n/2⌉) für n > 1<br />
= 1 + (n + 1) + 2(⌊n/2⌋ + 1) + T build(⌊n/2⌋) + T build(⌈n/2⌉)<br />
Zur Vereinfachung nehmen wir an, daß die Division durch 2 immer aufgeht, also n = 2 m<br />
gilt. Wir erhalten damit<br />
T build(2 m ) = 1<br />
T build(2 m ) = 2 ∗ 2 m + 4 + 2T build(2 m−1 )<br />
= (m + 2)2 m+1 + 2 m − 4
Prof. Paneau: Natürlich erwarte ich von einer gewissenhaften<br />
Studentin, daß sie die Summenformel aufstellt und die geschlossene<br />
Form von T build nachrechnet.<br />
Grit Garbo: Ehrenwert, Herr Kollege, aber mühsam. Ich würde<br />
den Ansatz T build(2 m ) = (m + x)2 m+1 − y aufstellen und die<br />
Parameter x und y aus den beiden Rekursionsgleichungen bestimmen.<br />
Harry Hacker: Und wenn wir T build ∈ Θ(n 2 ) berechnet hätten?<br />
Lisa Lista: Dann wäre auch T mergeSort ∈ Θ(n 2 ), und unsere frühere<br />
Analyse allein auf Basis der Zahl der Vergleiche wäre falsch<br />
gewesen.<br />
Prof. Paneau: Genau, liebe Studenten. Bei der Bestimmung der<br />
bestimmenden Operation hat sich schon mancher vergriffen.<br />
Auch das Vereinfachen will eben gelernt sein.<br />
98 5. Effizienz und Komplexität<br />
Ersetzen wir 2 m wieder durch n, so ergibt sich<br />
T build(0) = 1<br />
T build(n) = 1<br />
T build(n) = 2n(log n + 2) + n − 4 ∈ Θ(n log n)<br />
Da wir den Fall, daß die Division durch 2 nicht aufgeht, durch einen konstanten Faktor<br />
abschätzen können, gilt die asymptotische Aussage für beliebige Werte von n. Damit<br />
fällt build in die gleiche Effizienzklasse wie sortTree, und unsere Aussage T mergeTree ∈<br />
Θ(n log n) ist endgültig gerechtfertigt.<br />
Wenden wir uns der wohlfundiert rekursiven Funktion leaves zu, die die Blätter eines<br />
Baums ermittelt. Wie im Fall von sortTree haben wir das Problem, wie wir die bestimmende<br />
Eingabegröße von leaves wählen: Sowohl size als auch depth scheitern, da die<br />
dritte Gleichung von leaves zu der paradoxen Rekurrenzgleichung<br />
T leaves(n) = 1 + T leaves(n)<br />
führt. Beide Maße auf Bäumen scheitern, da sie die algorithmische Idee von leaves<br />
nicht berücksichtigen. Was tun? Nun, eigentlich haben wir das Problem schon gelöst:<br />
Der Induktionsbeweis in Abschnitt 4.6 verwendet ein Maß auf Bäumen, weight, dessen<br />
Wert für jeden rekursiven Aufruf gerade um eins abnimmt. Also erhalten wir ohne weitere<br />
Überlegungen:<br />
T ime(leaves t) = weight t + 1<br />
Wir müssen noch eins addieren für den Fall, daß t ein Blatt ist. Aus size t weight t <<br />
2*size t folgt:<br />
T leaves(n) ∈ Θ(n)<br />
Wir sehen: Unsere damaligen Anstrengungen werden belohnt. Die Relation „≺“ bzw. die<br />
Funktion weight klären nicht nur, warum leaves terminiert, sondern auch wie schnell.<br />
5.4. Problemkomplexität<br />
Wir haben bisher zwei Sortierverfahren kennengelernt: isort mit einer „worst case“-<br />
Laufzeit von Θ(n 2 ) und mergeSort mit einer „worst case“-Laufzeit von Θ(n log n). Es<br />
stellt sich die Frage, ob mit Sortieren durch Fusionieren bereits das Ende der Fahnenstange<br />
erreicht ist, oder ob man andere Verfahren mit einer noch besseren asymptotischen<br />
Laufzeit entwickeln kann. In diesem Abschnitt beschäftigen wir uns mit der Komplexität<br />
des Sortierproblems: Wie schnell kann man durch Vergleich von Elementen sortieren?<br />
Man sollte sich zunächst klarmachen, daß diese Fragestellung von einer anderen Qualität
5.4. Problemkomplexität 99<br />
ist als die in den letzten Abschnitten behandelte: Wir analysieren nicht die Effizienz eines<br />
Sortierverfahrens, sondern die Komplexität des Sortierproblems. Dabei ist die (asymptotische)<br />
Komplexität eines Problems bestimmt als die (asymptotische) Effizienz des besten<br />
Programms, das dieses Problem löst.<br />
Vorher halten wir noch einen Moment inne, denn es ist keine Selbstverständlichkeit,<br />
daß dies überhaupt eine vernünftige Frage ist. Es ist zwar eine verbreitete Redensart, von<br />
der Komplexität von Problemen aller Art zu sprechen. Nun macht man als Wissenschaftler<br />
täglich die Erfahrung, daß zwar manche Dinge zunächst kompliziert erscheinen, aber<br />
immer einfacher werden, je besser man gedanklich erfaßt hat. Wenn also jemand von der<br />
Komplexität eines Problems spricht, so ist es — im einfachsten Fall — eine Aussage über<br />
sein eigenes, mangelhaftes Verständnis. Mag sein, daß auch einer angeben möchte mit<br />
der Komplexität der Dinge, mit denen er sich beschäftigt. Oder er will gar andere davon<br />
abhalten, hier allzusehr auf Klärung zu bestehen ... Was immer es auch sei, eines gibt es<br />
nicht: Eine am Problem selbst festzumachende, objektivierbare Komplexität.<br />
In der Algorithmik allerdings gibt es sie tatsächlich. Das liegt einfach daran, daß wir beim<br />
algorithmischen Problemlösen uns auf einen festen Satz von Rechenregeln festlegen — auf<br />
den Befehlssatz unseres Rechners, oder die Rechenregeln unserer Sprache Haskell. Darauf<br />
bezogen kann man nachweisen, daß es untere Schranken für die Effizienz der Lösung<br />
eines Problems gibt, die sich auf der Basis dieser Rechenregeln nicht unterbieten lassen.<br />
Deshalb ist es korrekt, hier von der Problemkomplexität zu sprechen, ohne damit dem<br />
sonst so fragwürdigen Gebrauch des Begriffes Komplexität recht zu geben.<br />
Fangen wir klein an und überlegen, wieviele Vergleichsoperationen man mindestens<br />
benötigt, um eine 2- oder 3-elementige Liste zu sortieren. Die Sortierprogramme lassen<br />
sich gut durch Entscheidungsbäume darstellen: Die Verzweigungen sind mit Vergleichsoperationen<br />
markiert, die Blätter mit den Permutationen der zu sortierenden Liste. Zwei<br />
Entscheidungsbäume sind nachfolgend abgebildet.<br />
a1
100 5. Effizienz und Komplexität<br />
Anzahl der Permutationen einer n-elementigen Liste n!. Mit (4.15) können wir die Anzahl<br />
der benötigten Vergleichsoperationen nach unten abschätzen:<br />
T imesort(n) log 2(n!)<br />
Die <strong>Fakultät</strong>sfunktion läßt sich mit der Stirlingschen Formel abschätzen:<br />
Insgesamt erhalten wir<br />
T imesort(n) log 2<br />
n! √ 2πn<br />
√ 2πn<br />
<br />
n<br />
n .<br />
e<br />
<br />
n<br />
n ∈ Θ(n log n)<br />
e<br />
Daraus ergibt sich, daß Ω(n log n) eine untere Schranke für das Sortieren durch Vergleichen<br />
ist. Da Mergesort eine „worst case“-Laufzeit von Θ(n log n) hat, wird die Schranke auch<br />
erreicht. Verfahren, deren „worst case“-Laufzeit mit der unteren Schranke der für das<br />
Problem ermittelten Komplexität zusammenfällt, nennt man auch asymptotisch optimal.<br />
Asymptotisch optimale Sortierverfahren sind Mergesort und Heapsort. Nicht optimal sind<br />
Sortieren durch Einfügen, Minimumsortieren, Bubblesort und Quicksort.<br />
Wir betonen noch einmal, daß Ω(n log n) eine untere Schranke für das Sortieren durch<br />
Vergleichen ist. Wenn wir zusätzliche Annahmen über die zu sortierenden Elemente machen<br />
können, läßt sich das Sortierproblem unter Umständen schneller lösen. Wissen wir<br />
z.B., daß alle Elemente in dem Intervall [l . . . r] liegen, wobei r −l nicht sehr groß ist, dann<br />
können wir auch in Θ(n) sortieren, siehe Abschnitt 5.6.<br />
Im Idealfall sieht der Weg zu einer guten Problemlösung so aus:<br />
1. Man verschafft sich Klarheit über die Komplexität des zu lösenden Problems.<br />
2. Man entwickelt einen Algorithmus, dessen Effizienz in der Klasse der Problemkomplexität<br />
liegt. Asymptotisch gesehen, ist dieser bereits „optimal“.<br />
3. Man analysiert die konstanten Faktoren des Algorithmus und sucht diese zu verbessern.<br />
An den konstanten Faktoren eines Algorithmus zu arbeiten, der nicht der asymptotisch<br />
optimalen Effizienzklasse angehört, macht in der Regel keinen Sinn, weil das aymptotische<br />
bessere Verhalten letztlich (also für große n) immer überwiegt. Allerdings darf man<br />
sich auf diese Regel nicht blind verlassen und muß sich fragen, wie groß die Eingabe praktisch<br />
werden kann. Hat man z.B. ein Problem der Komplexität Θ(n) und ein asymptotisch<br />
optimales Programm p1 mit großem konstantem Faktor C, so ist eventuell ein asymptotisch<br />
suboptimales Programm p2 mit T imep2(n) ∈ Θ(n log n) vorzuziehen, wenn es einen
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort 101<br />
kleinen konstanten Faktor c hat. Ist zum Beispiel C = 10 und c = 1, so ist Programm p2<br />
bis hin zu n = 10 10 schneller. In den meisten Anwendungen würde dies den Ausschlag für<br />
p2 geben.<br />
Die Problemkomplexität Θ(f) heißt polynomial, wenn f ein Polynom in n ist, und exponentiell,<br />
wenn n im Exponenten auftritt. Probleme exponentieller Komplexität nennt man<br />
gerne „nicht praktikabel“ (untractable), um anzudeuten, daß bereits für sehr kleine Eingaben<br />
der Aufwand nicht mehr mit den verfügbaren Zeit-und Platzresourcen zu bewältigen<br />
ist. Probleme mit polynomialer Effizienz gelten als „praktikabel“ (tractable), allerdings darf<br />
der Grad des Polynoms nicht allzu hoch sein.<br />
Hierzu einige Beispiele aus der molekularen Genetik:<br />
• Das Problem, eine kurze DNA-Sequenz in einer längeren zu finden, liegt in Θ(n). Ist<br />
die lange Sequenz z.B. ein komplettes bakterielles Genom, so liegt n etwa bei 3∗10 6 .<br />
• Der Ähnlichkeitsvergleich zweier DNA-Sequenzen besteht darin, daß man beide<br />
durch Enfügen von Lücken so untereinander arrangiert, daß möglichst viele gleiche<br />
Nukleotide untereinander stehen. Der Vergleich zweier Sequenzen der Länge<br />
n hat die Komplexität Θ(n 2 ). Hier ist n praktisch auf wenige tausend Nukleotide<br />
beschränkt.<br />
• Die zweidimensionale Faltungsstruktur einer RNA (das ist eine einsträngige Variante<br />
der DNA) läßt sich mit einem thermodynamischen Modell berechnen. Die Effizienz<br />
des Verfahrens liegt bei Θ(n 3 ) mit einem großen konstanten Faktor, so daß Strukturen<br />
von RNA-Molekülen nur bis zu einer Länge von ca. 500 Nukleotiden berechnet<br />
werden können.<br />
• Der simultane Vergleich von k DNA-Sequenzen der Länge n ist eine zentrale Frage<br />
der Molekularbiologie, weil er Rückschlüsse auf evolutionäre Verwandschaft und<br />
gemeinsame Funktionen gibt. Er hat die Effizienz Θ(2 k−1 n k ) und kann daher nur für<br />
wenige oder sehr kurze Sequenzen durchgeführt werden.<br />
Allerdings finden Informatiker auch im Falle von nicht praktikablen Problemen in der<br />
Regel einen Ausweg. Dieser kann zum Beispiel darin bestehen, daß das Problem nicht<br />
exakt, sondern nur näherungsweise (aber effizienter) gelöst wird. Hier eröffnet sich ein<br />
weites Feld der Algorithmik, das wir aber nicht betreten wollen.<br />
5.5. Anwendung: Optimierung von Programmen am<br />
Beispiel mergeSort<br />
Optimierung von Programmen hat wenig mit Optimalität zu tun — es ist nur ein schlechter,<br />
aber üblicher Ausdruck für die Verbesserung der Effizienz. Sinnvolle Optimierung setzt
102 5. Effizienz und Komplexität<br />
voraus, daß man sich im klaren ist über die „Schwächen“ des vorliegenden Programms und<br />
die Chancen, seine Effizienz zu verbessern. Im diesem Abschnitt geht es um die Verbesserung<br />
der konstanten Faktoren.<br />
Wir haben in Abschnitt 5.4 gesehen, daß Mergesort ein asymptotisch optimales Verfahren<br />
ist. Trotzdem kann man noch viel verbessern; die Tatsache, daß Mergesort asymptotisch<br />
optimal ist, sagt uns, daß es auch sinnvoll ist, bei Mergesort anzusetzen. Wir<br />
werden zwei Verbesserungen vorführen, eine offensichtliche und eine vielleicht weniger<br />
offensichtliche. Die offensichtliche Verbesserung besteht darin, aus einer Vor- oder Teilsortierung<br />
der Liste Nutzen zu ziehen. Dies zu tun ist naheliegend und auch angebracht,<br />
denn die Laufzeit von mergeSort ist weitestgehend unabhängig von der Anordnung der<br />
Elemente in der Liste: Auch die „best case“-Laufzeit ist Θ(n log n). Hier schneidet isort<br />
mit einer „best case“-Laufzeit von Θ(n) besser ab (scheitert allerdings, wenn die Liste<br />
absteigend sortiert ist). Die weniger offensichtliche Verbesserung zielt darauf ab, die unproduktive<br />
Teile-Phase zu optimieren. Fangen wir mit der weniger offensichtlichen Verbesserung<br />
an.<br />
5.5.1. Varianten der Teile-Phase<br />
Wir nehmen nun die Effizienz der Funktion build näher unter die Lupe.<br />
Wie können wir den build-Schritt verbessern? Die wiederholte Längenbestimmung<br />
der Teillisten ist überflüssig, wenn man die Länge als zusätzliches Argument mitführt:<br />
build’’ :: [a] -> Tree a<br />
build’’ as = buildn (length as) as<br />
where buildn :: Int -> [a] -> Tree a<br />
buildn 1 (a:as) = Leaf a<br />
buildn n as = Br (buildn k (take k as))<br />
(buildn k (drop k as))<br />
where k = n ‘div‘ 2<br />
Was genau ändert sich dadurch? Vergleichen wir buildn mit build, das wir in Abschnitt<br />
5.3 analysiert haben. Dort ist anstelle von T length(n) nun T div + T − einzusetzen,<br />
der Beitrag n + 1 reduziert sich zu 2. Damit gilt: Die Funktion buildn löst also das Problem<br />
ebenfalls in einer Laufzeit von Θ(n log n). Der konstante Faktor halbiert sich jedoch<br />
gegenüber build.<br />
Ein schöner Fortschritt, könnte man meinen. Allerdings — da war ja noch die mittels<br />
buildSplit definierte Funktion build’ aus Abschnitt 3.4 . Die Analyse der Effizienz<br />
hatten wir auf später vertagt — hier ist sie.<br />
buildSplit verwendet weder length noch take. Die entscheidende Gleichung ist<br />
buildSplit n as = (Br l r, as’’)
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort 103<br />
where k = n ‘div‘ 2<br />
(l,as’) = buildSplit k as<br />
(r,as’’) = buildSplit (n-k) as’<br />
Für Arithmetik und Konstruktoren fallen 6 Rechenschritte an, und wir erhalten<br />
T buildSplit(n) = 6 + T buildSplit (⌊n/2⌋) + T buildSplit (⌈n/2⌉)<br />
T buildSplit(1) = 1<br />
Als Lösung dieses Gleichungssystems ergibt sich für n = 2 k genau T buildSplit(2 k ) =<br />
6(2 k+1 − 1) = 12n − 6 ∈ Θ(n). Damit liegt build’ sogar in einer besseren Effizienzklasse<br />
als build’’, allerdings mit einem etwa zehnfach schlechteren konstanten Faktor. Erst ab<br />
log n = 12, also n = 2 12 wird die asymptotisch bessere Funktion build’ auch praktisch<br />
besser sein.<br />
Aber kann man den Baumaufbau nicht ganz weglassen, wenn man die aufgespaltenen<br />
Listen gleich fusioniert? Wir eliminieren damit die Datenstrukur, die die beiden Phasen<br />
trennt. Das funkioniert ganz systematisch: Wir rechnen einige Schritte mit den definierenden<br />
Gleichungen von mergeSort. Ausgangspunkt ist die Gleichung<br />
mergeSort as = sortTree (build as)<br />
Wir setzen für as die Fälle [], [a] und (a:as) ein und leiten neue Gleichungen ab:<br />
mergeSort [] = sortTree(build []) = sortTree Nil = []<br />
mergeSort [a] = sortTree(build [a]) = sortTree (Leaf a) = [a]<br />
mergeSort (a:as) = sortTree(build (a:as))<br />
= sortTree(Br (build (take k as)) (build (drop k as)))<br />
where k = length as ‘div‘ 2<br />
= merge (sortTree (build (take k as))<br />
sortTree (build (drop k as)))<br />
where k = length as ‘div‘ 2<br />
= merge (mergeSort (take k as)<br />
mergeSort (drop k as)))<br />
where k = length as ‘div‘ 2<br />
Im letzten Schritt haben wir die definierende Gleichung von mergeSort gleich zweimal<br />
angewandt (und zwar von rechts nach links), um die Funktionen sortTree und build<br />
endgültig zum Verschwinden zu bringen. Als Fazit erhalten wir eine „baumfreie“ Variante<br />
von mergeSort:<br />
mergeSort’ [] = []<br />
mergeSort’ [a] = [a]<br />
mergeSort’ (a:as) = merge (mergeSort (take k as))<br />
(mergeSort (drop k as))<br />
where k = length as ‘div‘ 2<br />
Übung 5.6 Analysiere die Effizienz von mergeSort anhand der<br />
hier abgeleiteten Definition.
104 5. Effizienz und Komplexität<br />
Was haben wir erreicht? Die Datenstruktur des Baumes ist nur scheinbar verschwunden<br />
— sie tritt nun als Aufrufufstruktur von mergeSort’ auf: Wo bisher eine Verzweigung<br />
Br l r zwei Teilaufgaben zusammenhielt, ist es nun ein Aufruf der Form merge xs ys,<br />
der nicht ausgerechnet werden kann ehe die Teilprobleme xs und ys gelöst sind. An der<br />
aymptotischen Effizienz hat sich nichts geändert, und einen dramatischen Vorteil bei den<br />
konstanten Faktoren dürfen wir auch nicht erwarten: Das Anwenden der Konstruktoren<br />
Br, Nil und Leaf fällt weg, dafür hat merge ein Argument mehr als sortTree und<br />
kostet vielleicht eine Kleinigkeit mehr. Wie diese Abwägung ausgeht, hängt eindeutig vom<br />
Compiler ab und wird daher am einfachsten durch einen Test geklärt. Wir wählen eine<br />
sortierte Liste, damit der Aufwand für merge minimal ausfällt, und andere Unterschiede<br />
besser zu sehen sind:<br />
mtest = mergeSort [1..10000]<br />
mtest’ = mergeSort’ [1..10000]<br />
Vom Standpunkt des systematischen Programmierens allerdings spricht alles für das ursprüngliche<br />
mergeSort: Die Trennung in zwei Phasen führt zu einem sehr übersichtlichen<br />
Programm, und noch dazu kann für die erste Phase die Funktion build wiederverwendet<br />
werden. Das ist nicht nur bequem, sondern auch sicher. Entwickelt man mergeSort’ direkt,<br />
muß man build implizit neu erfinden, und — Hand auf’s Herz — manch einer hätte<br />
dabei vielleicht die Notwendigkeit der zweiten Gleichung übersehen und zunächst ein<br />
fehlerhaftes Programm abgeliefert. Und last not least haben wir uns ja auch über die Effizienz<br />
von build schon Gedanken gemacht, was zu der besseren Variante build geführt<br />
hat. Auch diese Verbesserung müßte man bei mergeSort’ noch einmal programmieren.<br />
Wir bleiben damit bei der Trennung in zwei Phasen und betrachten das Problem noch<br />
einmal aus einem anderen Blickwinkel.<br />
Alle Varianten von build konstruieren den Baum von oben nach unten (engl. top<br />
down). Die Liste [a1,...,an] wir durch build in wenigen Schritten in den Baum Br<br />
(build [a1,...,a ⌊n/2⌋]) (build [a ⌊n/2⌋+1,...,an]) überführt. Die oberste Verweigung<br />
wird „zuerst“ erzeugt, die Teilbäume in weiteren Schritten. Da wir Bäume durch<br />
Ausdrücke beschreiben, ergibt sich diese Vorgehensweise fast zwangläufig.<br />
Aber eben nur fast; genausogut können wir einen Baum Ebene für Ebene von unten<br />
nach oben aufbauen (engl. bottom up). Das funktioniert so: Nehmen wir an, wir hätten<br />
schon Bäume der Tiefe k erzeugt:<br />
/\ /\ /\ /\ /\ /\ /\<br />
/t1\ /t2\ /t3\ /t4\ /t5\ /t6\ /t7\<br />
---- ---- ---- ---- ---- ---- ----<br />
Bäume der Tiefe k + 1 erhalten wir, indem wir zwei jeweils zwei benachbarte Bäume<br />
zusammenfassen; eventuell bleibt ein Baum einer kleineren Tiefe als „Rest“.
5.5. Anwendung: Optimierung von Programmen am Beispiel mergeSort 105<br />
o o o<br />
/ \ / \ / \<br />
/\ /\ /\ /\ /\ /\ /\<br />
/t1\ /t2\ /t3\ /t4\ /t5\ /t6\ /t7\<br />
---- ---- ---- ---- ---- ---- ----<br />
Diesen Schritt wiederholen wir so oft, bis nur noch ein Baum verbleibt, also gerade ⌈log 2n⌉<br />
mal. Um dieses Verfahren zu programmieren, muß man sich nur noch überlegen, daß man<br />
die Zwischenergebnisse durch Listen von Bäumen repräsentiert. Im ersten Schritt ersetzen<br />
wir jedes Listenelement a durch den Baum Leaf a.<br />
bubuild :: [a] -> Tree a<br />
bubuild = buildTree . map Leaf<br />
Die Funktion buildLayer baut eine Ebene auf; buildTree iteriert buildLayer solange,<br />
bis ein Baum übrigbleibt.<br />
buildTree :: [Tree a] -> Tree a<br />
buildTree [t] = t<br />
buildTree ts = buildTree (buildLayer ts)<br />
buildLayer :: [Tree a] -> [Tree a]<br />
buildLayer [] = []<br />
buildLayer [t] = [t]<br />
buildLayer (t1:t2:ts) = Br t1 t2:buildLayer ts<br />
Wie effizient ist buildTree? In jedem Schritt wird die Anzahl der Bäume halbiert,<br />
wobei buildLayer n Konstruktoranwendungen für eine Liste mit n Bäumen benötigt.<br />
T buildLayer (n) = n<br />
T buildTree(1) = 0<br />
T buildTree(n) = n + T buildTree (⌈n/2⌉) für n > 1<br />
Lösen wir die Rekurrenzgleichung auf, erhalten wir T buildTree(n) ∈ Θ(n). Welches Ver-<br />
fahren besser ist, buildn oder bubuild hängt letztlich noch vom verwendeten Übersetzer<br />
ab; dazu lassen sich schlecht allgemeingültige Aussagen machen. Allerdings hat das<br />
„bottom up“-Verfahren einen kleinen Nachteil: Es werden keine ausgeglichenen Bäume<br />
erzeugt; der resultierende Baum ist am rechten Rand ausgefranst. Für n = 2 m + 1 erhalten<br />
wir einen Baum, dessen linker Teilbaum ein vollständiger Binärbaum der Größe 2 m ist und<br />
dessen rechter Baum die Größe 1 hat: In jedem Iterationsschritt bleibt der letzte Baum als<br />
Rest.<br />
Übung 5.7 Knobelaufgabe: Lisa Lista hat die folgende Variante<br />
von buildTree entwickelt, die den letztgenannten Nachteil<br />
mildern soll.<br />
buildTree’ [t] = t<br />
buildTree’ (t:ts) = buildTree’<br />
(buildLayer (ts++[t]))<br />
Ist das tatsächlich eine Verbesserung? Welche Eigenschaft haben<br />
die resultierenden Bäume? Ist diese Variante im Hinblick auf die<br />
Verwendung in mergeSort sinnvoll? Wie kann man den Aufruf<br />
von (++) „wegoptimieren“?
106 5. Effizienz und Komplexität<br />
5.5.2. Berücksichtigung von Läufen<br />
Mergesort läßt sich relativ einfach so modifizieren, daß eine Vor- oder Teilsortierung der<br />
Listen ausgenutzt wird. Eine auf- oder absteigend geordnete, zusammenhängende Teilliste<br />
nennen wir Lauf (engl. run). Da für zwei aufeinanderfolgende Listenelemente stets ai <br />
ai+1 oder ai ai+1 gilt, hat ein Lauf mindestens die Länge zwei. [Wenn wir von der<br />
einelementigen Liste mal absehen.] Die Folge<br />
enthält z.B. die folgenden fünf Läufe:<br />
16 14 13 4 9 10 11 5 1 15 6 2 3 7 8 12<br />
16 14 13 4 | 9 10 11 | 5 1 | 15 6 2 | 3 7 8 12.<br />
Knobelaufgabe: Wieviele verschiedene Unterteilungen in 5 Läufe gibt es? Machen wir<br />
uns daran, die Unterteilung einer Liste in eine Liste von Läufen zu programmieren. Wir<br />
vergleichen zunächst die ersten beiden Elemente, um festzustellen, ob die Liste mit einem<br />
auf- oder absteigendem Lauf beginnt. Für beide Fälle definieren wir eine Hilfsfunktion, die<br />
den entsprechenden Lauf abtrennt.<br />
runs :: [a] -> [[a]]<br />
runs [] = [[]]<br />
runs [a] = [[a]]<br />
runs (a:b:x) = if a [a] -> [a] -> [[a]]<br />
ascRun a as [] = [reverse (a:as)]<br />
ascRun a as (b:y) = if a
5.6. Datenstrukturen mit konstantem Zugriff: Felder 107<br />
Ein Sortierverfahren, das umso schneller sortiert je weniger Läufe die Liste enthält, heißt<br />
geschmeidig (engl. smooth). Sortieren durch Fusionieren machen wir geschmeidig, indem<br />
wir in der Teile-Phase Bäume konstruieren, die in den Blättern Läufe statt einzelne Elemente<br />
enthalten.<br />
smsort :: Ord a => [a] -> [a]<br />
smsort = mergeRuns . build’ . runs<br />
[Statt build’ können wir natürlich auch bubuild verwenden.] Die Herrsche-Phase<br />
müssen wir leicht modifizieren:<br />
mergeRuns :: Tree [a] -> [a]<br />
mergeRuns (Leaf x) = x<br />
mergeRuns (Br l r) = merge (mergeRuns l) (mergeRuns r)<br />
Wir beschließen den Abschnitt mit einer kurzen Überlegung zur Effizienz von smsort.<br />
Die Tiefe der konstruierten Binärbäume wird nicht mehr von der Anzahl der Elemente<br />
bestimmt, sondern von der Anzahl der Läufe. An den Kosten pro Ebene ändert sich hingegen<br />
nichts; diese sind weiterhin proportional zur Anzahl der Elemente. Ist l die Zahl der<br />
Läufe, dann hat smsort eine Laufzeit von Θ(n log l).<br />
5.6. Datenstrukturen mit konstantem Zugriff: Felder<br />
Die Komplexität eines gegebenen Problems läßt sich nicht verbessern — es ist ja gerade<br />
ihr Begriff, daß sie sich nicht unterbieten läßt. Allerdings ist es manchmal möglich, die<br />
Problemstellung etwas einzuschränken, und dadurch in eine bessere Effizienzklasse zu<br />
kommen. So haben wir bisher angenommen, daß wir beliebige Daten sortieren, die der<br />
Typklasse Ord angehören. Wir wußten also nichts über die Daten außer daß uns eine<br />
Vergleichsoperation Tree a. Wo wir bisher einen Tree a aufgebaut haben,<br />
bauen wir nun mit der gleiche Funktion einen Tree [a]<br />
auf.<br />
Harry Hacker: Ist mir gar nicht aufgefallen!
108 5. Effizienz und Komplexität<br />
[1 .. 99] Liste der positiven Zahlen bis einschließlich 99,<br />
[1, 3 ..] Liste der ungeraden, positiven Zahlen,<br />
[1, 3 .. 99] Liste der ungeraden, positiven Zahlen bis einschließlich 99.<br />
Die Listen sind jeweils vom Typ [Integer]. Die untere und die obere Grenze können<br />
durch beliebige Ausdrücke beschrieben werden. Die Schrittweite ergibt sich als Differenz<br />
des ersten und zweiten Folgenglieds.<br />
Um Funktionen auf Listen zu definieren, haben wir bis dato rekursive Gleichungen verwendet.<br />
Manchmal können listenverarbeitende Funktionen einfacher und lesbarer mit<br />
Listenbeschreibungen programmiert werden. Schauen wir uns ein paar Beispiele an: Die<br />
Liste der ersten hundert Quadratzahlen erhält man mit<br />
squares :: [Integer]<br />
squares = [n*n | n [a]<br />
divisors n = [d | d [a]<br />
primes = [n | n
5.6. Datenstrukturen mit konstantem Zugriff: Felder 109<br />
qsort’’ :: (Ord a) => [a] -> [a]<br />
qsort’’ [] = []<br />
qsort’’ (a:x) = qsort’’ [b | b
110 5. Effizienz und Komplexität<br />
Element zugeordnet. Auch der Ausdruck array (l, u) vs, der ein Feld konstruiert,<br />
enthält alle Zutaten einer Funktionsdefinition. Der Typ des Ausdrucks, Array a b, legt<br />
den Vor- und Nachbereich fest, das Intervall (l, u) den Definitionsbereich; der Graph<br />
der Funktion wird durch die Liste vs von Paaren beschrieben, wobei ein Paar (a, b) angibt,<br />
daß dem Argument a der Wert b zugeordnet wird. Diese Zuordnung muß eindeutig<br />
sein: Für (a, v) ‘elem‘ xs und (a, w) ‘elem‘ xs gilt stets v == w.<br />
Felder sind sehr flexibel: Sowohl der Definitionsbereich als auch der Graph können<br />
durch beliebige Ausdrücke beschrieben werden. Im obigen Beispiel ist der Graph durch<br />
eine Listenbeschreibung gegeben. Felder sind effizient: Der Zugriff auf ein Feldelement,<br />
der als Infixoperator (!) vordefiniert ist, erfolgt in konstanter Zeit.<br />
squares’!7 ⇒ 17*17 ⇒ 49<br />
Um den Zugriff in konstanter Zeit durchführen zu können, muß an den Indextyp allerdings<br />
eine Anforderung gestellt werden: Das Intervall (l, u) muß stets endlich sein. Dies ist<br />
gewährleistet, wenn l und u ganze Zahlen sind. Auch Tupel ganzer Zahlen sind erlaubt;<br />
auf diese Weise können mehrdimensionale Felder realisiert werden. Das folgende Feld<br />
enthält das kleine 1 × 1:<br />
multTable :: Array (Int, Int) Int<br />
multTable = array ((0,0),(9,9))<br />
[((i,j),i*j) | i [a]<br />
inRange :: (Ix a) => (a,a) -> a -> Bool<br />
array :: (Ix a) => (a,a) -> [(a,b)] -> Array a b<br />
bounds :: (Ix a) => Array a b -> (a,a)<br />
assocs :: (Ix a) => Array a b -> [(a,b)]<br />
(!) :: (Ix a) => Array a b -> a -> b<br />
Mit range (l,u) erhält man die Liste aller Indizes, die in dem Intervall liegen; der<br />
durch das Intervall beschriebene Bereich wird sozusagen als Liste aufgezählt. Die Funktion<br />
inRange überprüft, ob ein Index in einem Intervall liegt. Ein Feld wird wie gesagt mit der<br />
Funktion array konstruiert; mit bounds und assocs werden der Definitionsbereich und<br />
der Graph eines Feldes ermittelt. Der Operator (!) entspricht der Funktionsanwendung.<br />
Betrachtet man seinen Typ, so sieht man, daß der Indizierungsoperator ein Feld auf eine<br />
Funktion abbildet.
5.6. Datenstrukturen mit konstantem Zugriff: Felder 111<br />
Genug der Theorie, wenden wir uns weiteren Beispielen zu: Die Funktion tabulate<br />
tabelliert eine Funktion in einem gegebenen Bereich.<br />
tabulate :: (Ix a) => (a -> b) -> (a,a) -> Array a b<br />
tabulate f bs = array bs [(i, f i) | i a*a) (0, 99). Eine Liste läßt sich leicht in ein Feld überführen.<br />
listArray :: (Ix a) => (a,a) -> [b] -> Array a b<br />
listArray bs vs = array bs (zip (range bs) vs)<br />
Die vordefinierte Funktion zip überführt dabei zwei Listen in eine Liste von Paaren:<br />
zip [a1,a2,...] [b1,b2,...] = [(a1,b1),(a2,b2),...]. Die Länge der kürzeren<br />
Liste bestimmt die Länge der Ergebnisliste. Mit zip [0..] x werden die Elemente von<br />
x z.B. durchnumeriert.<br />
Ein Feld heißt geordnet, wenn i j ⇒ a!i a!j für alle Indizes i und j gilt. Stellen<br />
wir uns die Aufgabe, ein Element in einem geordneten Feld zu suchen. Wenn wir das<br />
Feld von links nach rechts durchsuchen, benötigen wir im schlechtesten Fall Θ(n) Schritte,<br />
wobei n die Größe des Feldes ist. Sehr viel schneller ist das folgende Verfahren: Ist<br />
(l,r) das Suchintervall, dann vergleichen wir das zu suchende Element mit dem mittleren<br />
Feldelement a!m wobei m = (l + r) ‘div‘ 2 ist. Je nach Ausgang des Vergleichs<br />
ist das neue Suchintervall (l, m-1) oder (m+1, r). In jedem Schritt wird die Größe des<br />
Suchintervalls halbiert: Die asymptotische Laufzeit beträgt Θ(log n).<br />
binarySearch :: (Ord b, Integral a, Ix a) => Array a b -> b -> Bool<br />
binarySearch a e = within (bounds a)<br />
where within (l,r) = l within (l, m-1)<br />
EQ -> True<br />
GT -> within (m+1, r)<br />
Denkaufgabe: Warum heißt das Verfahren binäre Suche?<br />
In der Liste von Argument-/Wertpaaren, die der Funktion array als zweites Argument<br />
übergeben wird, darf jeder Index höchstens einmal auftreten. Die Funktion accumArray<br />
erlaubt beliebige Listen; zusätzlich muß jedoch angegeben werden, wie Werte mit dem<br />
gleichen Index kombiniert werden.<br />
accumArray :: (Ix a) => (b -> c -> b) -> b -><br />
(a,a) -> [(a,c)] -> Array a b
112 5. Effizienz und Komplexität<br />
Der Ausdruck accumArray (*) e bs vs ergibt das Feld a, wobei das Feldelement a!i<br />
gleich (· · ·((e*c1)*c2)· · ·)*ck ist, wenn vs dem Index i nacheinander die Werte c1, . . . ,<br />
ck zuordnet. Beachte: Ist die Operation (*) nicht kommutativ, spielt die Reihenfolge der<br />
Elemente in vs eine Rolle.<br />
5.7. Anwendung: Ein lineares Sortierverfahren<br />
Wir illustrieren die Verwendung von accumArray mit zwei Sortierverfahren, die ganz anders<br />
arbeiten, als die in Kapitel 4 vorgestellten. In beiden Fällen wird zusätzliches Wissen<br />
über die zu sortierenden Elemente ausgenutzt. Gilt es, ganze Zahlen zu sortieren, von<br />
denen wir wissen, daß sie in einem kleinen Intervall liegen, so zählen wir einfach die<br />
Häufigkeit der einzelnen Elemente.<br />
countingSort :: (Ix a) => (a, a) -> [a] -> [a]<br />
countingSort bs x = [ a | (a,n) [[a]]<br />
listSort bs xs<br />
| drop 8 xs == [] = insertionSort xs<br />
| otherwise = [[] | []
5.7. Anwendung: Ein lineares Sortierverfahren 113<br />
(//) :: (Ix a) => Array a b -> [(a, b)] -> Array a b<br />
Das Feld a//vs ist identisch mit a bis auf die in vs angegeben Stellen. Die folgende<br />
Funktion, die eine Einheitsmatrix einer gegebenen Größe berechnet, demonstriert die<br />
Verwendung von (//):<br />
unitMatrix :: (Ix a, Num b) => (a,a) -> Array (a,a) b<br />
unitMatrix bs@(l,r) = array bs’ [(ij,0) | ij LabTree b -> [(a,b)]<br />
repr i Void = []<br />
repr i (Node l a r) = [(i,a)] ++ repr (2*i) l ++ repr (2*i+1) r<br />
treeArray :: (Num b, Ix b) => LabTree a -> Array b (Maybe a)<br />
treeArray t = array bs [(i,Nothing) | i
114 5. Effizienz und Komplexität<br />
Elements. Die freien Einträge enthalten in Wirklichkeit Nullen, aus Gründen der Übersichtlichkeit<br />
sind sie nicht aufgeführt. Die Zahlen des Pascalschen Dreiecks erfüllen unzähligen<br />
Eigenschaften, ein paar führen wir weiter unten auf. Zunächst kümmern wir uns darum,<br />
ein Programm zu schreiben, das ein Dreick der Größe n erzeugt. Die Umsetzung ist sehr<br />
direkt:<br />
pascalsTriangle :: Int -> Array (Int,Int) Int<br />
pascalsTriangle n = a<br />
where a = array ((0,0),(n,n)) (<br />
[((i,j),0) | i
5.8. Vertiefung: Rolle der Auswertungsreihenfolge 115<br />
wobei n! = 1 · 2 · . . . · n die <strong>Fakultät</strong> von n ist. Denkaufgabe: Wie rechnet man die obige Formel<br />
geschickt aus? Auch die Randwerte<br />
!<br />
!<br />
!<br />
i<br />
0<br />
= 1,<br />
i<br />
i<br />
= 1,<br />
i<br />
j<br />
= 0 für i < j<br />
und die Beziehung<br />
!<br />
i<br />
=<br />
j<br />
!<br />
i − 1<br />
+<br />
j<br />
!<br />
i − 1<br />
,<br />
j − 1<br />
die wir bei der Konstruktion des Pascalschen Dreiecks verwendet haben, lassen sich bezüglich dieser<br />
Interpretation deuten. Überlegen! Summieren wir die Elemente der n-ten Zeile, erhalten wir gerade<br />
2 n . Warum?<br />
5.8. Vertiefung: Rolle der Auswertungsreihenfolge<br />
Wir sind bei unseren Überlegungen bisher stillschweigend davon ausgegangen, daß alle<br />
Ausdrücke ganz ausgerechnet werden müssen. Diese Annahme haben wir zum Beispiel<br />
beim Aufstellen der Rekursionsgleichungen für insertionSort gemacht:<br />
T ime(insertionSort (a:x)) = 3 + T ime(insertionSort x ⇒ v) + T ime(insert a v)<br />
Zur Erinnerung: Mit T ime(e ⇒ v) haben wir die Anzahl der Schritte bezeichnet, um den<br />
Ausdruck e zum Wert v auszurechnen. Die obige Gleichung setzt somit voraus, daß wir<br />
insertionSort x in toto ausrechnen müssen, um insert a (insertionSort x)<br />
ausrechnen zu können. Ist diese Annahme gerechtfertigt? Um mit Radio Eriwan zu antworten:<br />
„Im Prinzip ja, aber . . . “. Es kommt darauf an, ob wir die gesamte sortierte Liste<br />
benötigen. Nehmen wir an, wir wollen das Minimum einer Liste von ganzen Zahlen berechnen.<br />
Hier ist eine einfache Lösung:<br />
minimum :: [Integer] -> Integer<br />
minimum x = head (insertionSort x)<br />
Unter der — hier falschen — Annahme, daß alle Ausdrücke ausgerechnet werden müssen,<br />
erhalten wir Θ(n log n) als „worst case“-Laufzeit von minimum. Rechnen wir nur das aus,<br />
was wirklich benötigt wird, beträgt die Laufzeit Θ(n): Um insert a x ausrechnen zu<br />
können, muß x die Form [] oder _:_ haben; dann kann insert a x in konstanter Zeit<br />
ebenfalls zu einer Liste dieser Form ausgerechnet werden.<br />
Wie wird nun in Haskell gerechnet? Es ist in der Tat so, daß jeder Ausdruck nur dann<br />
ausgerechnet wird, wenn er im Laufe der Rechnung benötigt wird. Aus diesem Grund<br />
ist die Laufzeit unter Umständen besser als unsere Rechnungen dies vermuten lassen.<br />
Ein Beispiel: Um den Ausdruck let x=e in e’ auszurechnen, rechnen wir zunächst e’
116 5. Effizienz und Komplexität<br />
aus. Stellen wir dabei fest, daß wir den Wert von x benötigen, werten wir e in einer<br />
Nebenrechnung aus und setzen das Ergebnis für x ein. Benötigt wird x z.B., wenn wir eine<br />
Fallunterscheidung durchführen müssen, case x of {...}, oder wenn x Argument<br />
arithmetischer Operationen ist: x*x. Auf diese Weise wird e höchstens einmal ausgerechnet.<br />
Sollten wir auf die Idee kommen, e für jedes Auftreten von x neu auszurechnen, kann sich die<br />
Rechenzeit und der Platzbedarf dramatisch erhöhen. Eine Mehrfachungauswertung könnte aus Versehen<br />
vorkommen, wenn wir e unausgewertet für x in e’ einsetzen — die let-Regel wird zu früh<br />
angewendet — und mechanisch weiterrechnen.<br />
Analysieren wir zur Illustration des Offensichtlichen die Funktion power aus Abschnitt 3.2.3. Der<br />
Wert von y in let {y = power x (n ‘div‘ 2)} in ... wird offenbar benötigt. Die Laufzeit<br />
von power x n ist abhängig vom Exponenten n; wir zählen die Anzahl der Multiplikationsoperationen.<br />
Zunächst die Analyse des tatsächlichen Verhaltens:<br />
T power(0) = 0<br />
T power(2n − 1) = T power(n − 1) + 2 für n > 0<br />
T power(2n) = T power(n) + 1 für n > 0<br />
Eine geschlossene Form für T power(n) läßt sich gut bestimmen, wenn wir n binär darstellen, z.B. 13 =<br />
(1101)2. Mit jedem rekursiven Aufruf wird das letzte Bit, das „least significant bit“, abgeknipst. Ist<br />
es 0, erhöhen sich die Kosten um eins, ist es 1 entsprechend um zwei. Wir erhalten:<br />
T power(n) = ⌊log 2 n⌋ + ν(n) + 1 ∈ Θ(log n) für n > 0,<br />
wobei ν(n) gleich der Anzahl der Einsen in der Binärrepräsentation von n ist. Der Platzbedarf von<br />
power ist ebenfalls Θ(log n).<br />
Nehmen wir nun an, daß wir den Wert von y für jedes Vorkommen neu ausrechnen. Dann ergeben<br />
sich folgende Kostengleichungen:<br />
T ′<br />
power(0) = 0<br />
T ′<br />
power(2n − 1) = 2T ′<br />
power(n − 1) + 2 für n > 0<br />
T ′<br />
power(2n) = 2T ′<br />
power(n) + 1 für n > 0<br />
Lösen wir die Rekurrenzgleichungen auf, erhalten wir<br />
T ′<br />
power(n) = Θ(n) für n > 0.<br />
Wir sehen: Wenn wir naiv rechnen, erhöht sich die Laufzeit und unter Umständen auch der Platzbedarf<br />
drastisch, nämlich exponentiell.
6. Abstraktion<br />
6.1. Listenbeschreibungen<br />
Listen sind die am häufigsten verwendete Datenstruktur. Aus diesem Grund lohnt es sich,<br />
weitere Notationen einzuführen, die die Konstruktion und die Verarbeitung von Listen<br />
vereinfachen. So erlaubt Haskell z.B. die Notation arithmetischer Folgen:<br />
[1 ..] Liste der positiven Zahlen,<br />
[1 .. 99] Liste der positiven Zahlen bis einschließlich 99,<br />
[1, 3 ..] Liste der ungeraden, positiven Zahlen,<br />
[1, 3 .. 99] Liste der ungeraden, positiven Zahlen bis einschließlich 99.<br />
Die Listen sind jeweils vom Typ [Integer]. Die untere und die obere Grenze können<br />
durch beliebige Ausdrücke beschrieben werden. Die Schrittweite ergibt sich als Differenz<br />
des ersten und zweiten Folgenglieds.<br />
Um Funktionen auf Listen zu definieren, haben wir bis dato rekursive Gleichungen verwendet.<br />
Manchmal können listenverarbeitende Funktionen einfacher und lesbarer mit<br />
Listenbeschreibungen programmiert werden. Schauen wir uns ein paar Beispiele an: Die<br />
Liste der ersten hundert Quadratzahlen erhält man mit<br />
squares’’ :: [Integer]<br />
squares’’ = [n*n | n
118 6. Abstraktion<br />
Die vordefinierte Funktion or verallgemeinert die logische Disjunktion auf Listen Boolescher<br />
Werte.<br />
Mit Hilfe einer Listenbeschreibung können auch Elemente einer Liste mit bestimmten<br />
Eigenschaften ausgewählt werden. Die Funktion divisors n bestimmt die Liste aller<br />
Teiler von n. Mit ihrer Hilfe läßt sich gut beschreiben, wann eine Zahl eine Prinzahl ist: n<br />
ist prim, wenn divisors n==[1,n]. Beachte: 1 ist keine Primzahl.<br />
divisors’ :: (Integral a) => a -> [a]<br />
divisors’ n = [d | d [a]<br />
primes’ = [n | n [a] -> [a]<br />
sieve (a:x) = a:sieve [n | n [a]<br />
primes’’ = sieve [2..]<br />
Ein bekanntes Sortierverfahren, Quicksort von C.A.R. Hoare, läßt sich gut mit Hilfe von<br />
Listenbeschreibungen definieren.<br />
qsort’’’ :: (Ord a) => [a] -> [a]<br />
qsort’’’ [] = []<br />
qsort’’’ (a:x) = qsort’’’ [b | b
6.1. Listenbeschreibungen 119<br />
Die Variable a wird zunächst mit 0 belegt, b nimmt nacheinander die Werte von 1 bis 3<br />
an. Der Ausdruck im Kopf wird jeweils bezüglich dieser Belegungen ausgerechnet. Man<br />
sieht: Weiter rechts stehende Generatoren variieren schneller.<br />
Genau wie ein let-Ausdruck führt auch ein Generator neue Variablen ein. Die Sichtbarkeit<br />
dieser Variablen erstreckt sich auf den Kopf und auf weiter rechts stehende Generatoren<br />
und Filter. Die Funktion concat, die eine Liste von Listen konkateniert, illustriert<br />
dies.<br />
concat’ xs = [a | x
120 6. Abstraktion<br />
Die Hilfsfunktion place erhält vier Argumente: die Nummer der aktuellen Spalte, die<br />
Liste der noch nicht verwendeten Reihenpositionen und die Nummern der bereits belegten<br />
auf- und absteigenden Diagonalen.<br />
place :: Integer -> [Integer] -> [Integer] -> [Integer] -> [Board]<br />
place c [] ud dd = [[]]<br />
place c rs ud dd = [q:qs | q
6.2. Funktionen höherer Ordnung 121<br />
rep’ n a = [a | i Bool) -> [a] -> [a]<br />
isortBy (
Übung 6.1 Mit isortBy lassen sich Listen beliebigen Typs sortieren,<br />
auch Listen auf Funktionen. Warum muß a nicht Instanz<br />
von Ord sein? sortieren:<br />
Übung 6.2 Definiere mergeBy und msortBy.<br />
122 6. Abstraktion<br />
isort [] = []<br />
isort (a:x) = insert a (isort x)<br />
insert a [] = [a]<br />
insert a x@(b:y)<br />
| a components p dateOfBirth p
6.2. Funktionen höherer Ordnung 123<br />
sucht ein Element in einem geordneten Feld. In Analogie zu den Sortierfunktionen könnten<br />
wir die zugrundeliegende Vergleichsoperation zum Parameter machen. Aber es geht<br />
noch sehr viel allgemeiner: Betrachten wir die Funktionsdefinition von binarySearch<br />
genauer: Die Vergleichsoperation compare wird nur in dem Ausdruck compare e (a!m)<br />
verwendet; von Aufruf zu Aufruf ändert sich jedoch lediglich m. Von daher liegt es nahe,<br />
die Binäre Suche mit einer Funktion zu parametrisieren, die m direkt auf ein Element von<br />
Ordering abbildet. Daß wir in einem Feld suchen, tritt nunmehr in den Hintergrund.<br />
Indem wir das Suchintervall übergeben, beseitigen wir die Abhängigkeit ganz. Noch eine<br />
letzte Änderung: Sind wir bei der Suche erfolgreich, geben wir m in der Form Just m<br />
zurück; anderenfalls wird Nothing zurückgegeben.<br />
binarySearchBy :: (Integral a) => (a -> Ordering) -> (a,a) -> Maybe a<br />
binarySearchBy cmp = within<br />
where within (l,r) = if l > r then Nothing<br />
else let m = (l+r) ‘div‘ 2<br />
in case cmp m of<br />
LT -> within (l, m-1)<br />
EQ -> Just m<br />
GT -> within (m+1, r)<br />
Die Funktion binarySearchBy implementiert das folgende „Spiel“: Der Rechner fordert<br />
die Benutzerin auf, sich eine Zahl zwischen l und r auszudenken. Der Rechner rät:<br />
„Ist die gesuchte Zahl m?“. Die Benutzerin sagt, ob die Zahl gefunden wurde, ob sie kleiner<br />
oder größer ist. Nach Θ(log(r − l)) Schritten weiß der Rechner die Lösung — falls<br />
bei den Antworten nicht gemogelt wurde. Im Programm entspricht die Funktion cmp der<br />
Benutzerin: Ist x die ausgedachte Zahl, so beschreibt cmp = \i -> compare x i ihr<br />
Verhalten. Liegt x im Intervall (l,r), so ergibt binarySearchBy cmp (l,r) gerade<br />
Just x.<br />
Was heißt es eigentlich, daß bei den Antworten gemogelt wird? Anders gefragt: Welche<br />
Anforderungen muß cmp erfüllen, damit die Binäre Suche vernünftig arbeitet? Nun — wir<br />
erwarten, daß es höchstens ein Element k gibt mit cmp k = EQ, für alle i < k muß<br />
cmp i = GT gelten, für alle j > k entsprechend cmp j = LT. Die Funktion cmp unterteilt<br />
(l, r) somit maximal in drei Bereiche:<br />
l . . . k − 1<br />
<br />
GT<br />
k<br />
<br />
EQ<br />
k + 1 . . . r<br />
<br />
LT<br />
Umfaßt der mittlere Bereich mehr als ein Element, funktioniert die Binäre Suche auch.<br />
In diesem Fall wird irgendein Element aus dem mittleren Drittel zurückgegeben. Formal<br />
muß cmp lediglich antimonoton sein: i j ⇒ cmp i cmp j mit LT < EQ < GT. Aufgabe:<br />
Verallgemeinere die Binäre Suche, so daß sie gerade das mittlere Drittel als Intervall<br />
bestimmt.
124 6. Abstraktion<br />
Wie immer, wenn man eine Funktion verallgemeinert, ist es interessant zu sehen, wie<br />
man die ursprüngliche Funktion durch Spezialisierung der Verallgemeinerung erhält:<br />
binSearch a e = case r of Nothing -> False; Just _ -> True<br />
where r = binarySearchBy (\i -> compare e (a!i)) (bounds a)<br />
Als weitere Anwendung stellen wir uns die Aufgabe, zu einem englischen Begriff die<br />
deutsche Übersetzung herauszusuchen. Das der Suche zugrundeliegende und aus Platzgründen<br />
sehr klein geratene Wörterbuch stellen wir als Feld dar:<br />
englishGerman :: Array Int (String, String)<br />
englishGerman = listArray (1,2) [("daffodil", "Narzisse"),<br />
("dandelion","Loewenzahn")]<br />
Das Englisch-Deutsch-Wörterbuch ist naturgemäß nach den englischen Wörtern geordnet;<br />
es taugt nicht als Deutsch-Englisch-Wörterbuch. Übung: Programmiere eine Funktion,<br />
die ein X-Y-Wörterbuch in ein Y-X-Wörterbuch überführt. Die Funktion german<br />
realisiert die Übersetzung:<br />
german :: [Char] -> Maybe [Char]<br />
german x = case r of Nothing -> Nothing<br />
Just m -> Just (snd (englishGerman!m))<br />
where r = binarySearchBy (\i -> compare x (fst (englishGerman!i)))<br />
(bounds englishGerman)<br />
Der zu übersetzende Begriff wird jeweils mit dem englischen Wort verglichen, bei erfolgreicher<br />
Suche wird das deutsche Wort zurückgegeben.<br />
Wenn nur die linke Grenze des Suchintervalls gegeben ist, können wir die Binären Suche leicht<br />
variieren: Zunächst wird die rechte Intervallgrenze bestimmt, also ein beliebiges Element r mit<br />
cmp r = LT. Als Kandidaten werden nacheinander l, 2l, 4l, . . . herangezogen; der Suchraum wird<br />
somit in jedem Schritt verdoppelt. Ist ein r gefunden, folgt eine normale Binäre Suche.<br />
openbinSearchBy :: (Integral a) => (a -> Ordering) -> a -> Maybe a<br />
openbinSearchBy cmp l = lessThan l<br />
where lessThan r = case cmp r of<br />
LT -> binarySearchBy cmp (l,r)<br />
EQ -> Just r<br />
GT -> lessThan (2*r)<br />
Die Suche im offenen Intervall terminiert natürlich nicht, wenn es kein r mit cmp r = LT gibt.
6.2. Funktionen höherer Ordnung 125<br />
6.2.2. Rekursionsschemata<br />
In Abschnitt 4.2 haben wir das Schema der strukturellen Rekursion auf Listen eingeführt.<br />
Der Motor des Schemas ist der Rekursionsschritt. Zitat: „Um das Problem für die Liste<br />
a:x zu lösen, wird [. . . ] zunächst eine Lösung für x bestimmt, die anschließend zu einer<br />
Lösung für a:x erweitert wird.“ Überlegen wir noch einmal genauer, was im Rekursionsschritt<br />
zu tun ist: Gegeben ist eine Lösung s, unter Verwendung von a und x gilt es, eine<br />
Gesamtlösung s’ zu konstruieren. Im Prinzip müssen wir eine Funktion angeben, die a,<br />
x und s auf s’ abbildet. Dies erkannt, kann man das Rekursionsschema mit Hilfe einer<br />
Funktion höherer Ordnung programmieren; aus dem Kochrezept wird ein Programm:<br />
structuralRecursionOnLists :: solution -- base<br />
-> (a -> [a] -> solution -> solution) -- extend<br />
-> [a] -> solution<br />
structuralRecursionOnLists base extend = rec<br />
where rec [] = base<br />
rec (a:x) = extend a x (rec x)<br />
Auf diese Weise läßt sich das Rekursionsschema formal definieren; wenn Zweifel bestehen,<br />
ob eine gegebene Funktion strukturell rekursiv ist, können diese ausgeräumt werden,<br />
indem man die Funktion in Termini von sROL formuliert oder zeigt — was ungleich<br />
schwerer ist —, daß dies nicht möglich ist.<br />
Obwohl denkbar wird nicht empfohlen, sROL bei der Programmierung zu verwenden<br />
— im nächsten Abschnitt lernen wir eine abgemagerte Variante kennen, die im Programmieralltag<br />
ihren festen Platz hat. Dessen ungeachtet wollen wir uns trotzdem anschauen,<br />
wie die Funktionen isort und insert — die ersten, die wir nach dem Kochrezept der<br />
Strukturellen Rekursion gebraut haben — mit Hilfe von sROL formuliert werden.<br />
isort’ :: (Ord a) => [a] -> [a]<br />
isort’ = structuralRecursionOnLists<br />
[]<br />
(\a _ s -> insert’’ a s)<br />
insert’’ :: (Ord a) => a -> [a] -> [a]<br />
insert’’ a = structuralRecursionOnLists<br />
[a]<br />
(\b x s -> if a ... jeweils die<br />
Lösung von x darstellt: In der Definition von isort ist s gleich isort x, in der Definition
Übung 6.3 Schreibe perms und insertions als Anwendungen<br />
des Rekursionsschemas um.<br />
Übung 6.4 Diskutiere Vor- und Nachteile beider Spezifikationen.<br />
126 6. Abstraktion<br />
von insert ist s gleich insert a x. Bei der letztgenannten Funktion nutzen wir aus,<br />
daß die Funktion gestaffelt ist: nicht insert wird strukturell rekursiv definiert, sondern<br />
insert a. Knobelaufgabe: Wie ändert sich die Definition, wenn man die Parameter von<br />
insert vertauscht?<br />
Programmiert man tatsächlich Funktionen mit Hilfe von sROL, wird man gezwungen,<br />
sich genau an das Rekursionsschema zu halten. Insbesondere wenn man mit der Strukturellen<br />
Rekursion noch nicht per Du ist, erweist sich dieses Diktat oftmals als lehrreich.<br />
Bewahrt es uns doch insbesondere davor, ad-hoc Lösungen zu formulieren.<br />
Nehmen wir die Gelegenheit wahr und schauen uns noch ein Beispiel zur Strukturellen<br />
Rekursion an: Es gilt die Liste aller Permutationen einer gegeben Liste zu bestimmen. Die<br />
Liste [2,6,7] z.B. läßt sich auf sechs verschiedene Arten anordnen. Strukturell rekursiv<br />
erhält man sie wie folgt: Wir dürfen annehmen, daß wir das Problem für die Restliste<br />
[6,7] bereits gelöst haben: [[6,7], [7,6]] ist die gesuchte Liste der Permutationen.<br />
Jede einzelne Permutation müssen wir um das Element 2 erweitern. Aber — an welcher<br />
Position fügen wir es ein? Aus der Aufgabenstellung leitet sich ab, daß alle Positionen zu<br />
berücksichtigen sind. Also, aus der Liste [6,7] werden [2,6,7], [6,2,7] und [6,7,2]<br />
und aus [7,6] erhalten wir [2,7,6], [7,2,6] und [7,6,2]. Das Einfügen an allen<br />
Positionen „riecht“ nach einer Teilaufgabe, die wir wiederum strukturell rekursiv lösen.<br />
Überlegen! Das Aufsammeln der Teillösungen erledigen wir mit Listenbeschreibungen.<br />
Schließlich müssen wir uns noch Gedanken über die Rekursionsbasis machen: Es gibt nur<br />
eine Permutation der leeren Liste, die leere Liste selbst.<br />
perms :: [a] -> [[a]]<br />
perms [] = [[]]<br />
perms (a:x) = [z | y [[a]]<br />
insertions a [] = [[a]]<br />
insertions a x@(b:y) = (a:x):[b:z | z [a] -> [a]<br />
sort x = head [s | s
6.2. Funktionen höherer Ordnung 127<br />
Auch das Teile- und Herrsche-Prinzip läßt sich mit Hilfe einer Funktion höherer Ordnung<br />
definieren. Vier Bausteine werden benötigt: Eine Funktion, die entscheidet, ob ein<br />
Problem einfach ist; eine Funktion, die ein einfaches Problem löst; eine Funktion, die ein<br />
Problem in Teilprobleme zergliedert und schließlich eine Funktion, die Teillösungen zu<br />
einer Gesamtlösung zusammenführt.<br />
divideAndConquer :: (problem -> Bool) -- easy<br />
-> (problem -> solution) -- solve<br />
-> (problem -> [problem]) -- divide<br />
-> ([solution] -> solution) -- conquer<br />
-> problem -> solution<br />
divideAndConquer easy solve divide conquer = rec<br />
where rec x = if easy x then solve x<br />
else conquer [rec y | y [a] -> [a]<br />
msort’’ = divideAndConquer<br />
(\x -> drop 1 x == [])<br />
(\x -> x)<br />
(\x -> let k = length x ‘div‘ 2 in [take k x, drop k x])<br />
(\[s,t] -> merge s t)<br />
Im Fall Quicksort ist es umgekehrt: Nur in der Teile-Phase werden Elemente verglichen;<br />
in der Herrsche-Phase werden die sortierten Teillisten lediglich aneinandergefügt.<br />
qsort :: (Ord a) => [a] -> [a]<br />
qsort = divideAndConquer<br />
(\x -> drop 1 x == [])<br />
(\x -> x)<br />
(\(a:x) -> [[ b | b x++y++z)
128 6. Abstraktion<br />
6.2.3. foldr und Kolleginnen<br />
Magert man die strukturelle Rekursion auf Listen etwas ab, erhält man ein Rekursionsschema,<br />
das im Programmieralltag seinen festen Platz hat. Schauen wir uns zunächst einmal<br />
zwei einfache strukturell rekursive Funktionen an.<br />
sum’’ :: (Num a) => [a] -> a<br />
sum’’ [] = 0<br />
sum’’ (a:x) = a + sum’’ x<br />
or’’ :: [Bool] -> Bool<br />
or’’ [] = False<br />
or’’ (a:x) = a || or’’ x<br />
Übung 6.5 Definiere die Funktionen product und and, die<br />
eine Liste ausmultiplizieren bzw. ver„und“en. Inwiefern sind sum und or besonders einfach? Nun, im Rekursionsschritt wird nur das<br />
Kopfelement a mit der Teillösung s kombiniert; der Listenrest x wird nicht benötigt. Diese<br />
Eigenschaft teilen die Funktionen mit isort, nicht aber mit insert. Vergleichen! Das<br />
abgemagerte Rekursionsschema nennt sich foldr. Wie der Name motiviert ist, erklären<br />
wir gleich, zunächst die Definition:<br />
foldr’ :: (a -> solution -> solution) -> solution -> [a] -> solution<br />
foldr’ (*) e = f<br />
where f [] = e<br />
f (a:x) = a * f x<br />
Man sieht: Die Vereinfachung besteht darin, daß wir den dreistelligen Parameter extend<br />
durch den zweistelligen Parameter (*) ersetzt haben, den wir aus Gründen der Übersichtlichkeit<br />
zusätzlich infix notieren. [Aus historischen Gründen wird die Induktionsbasis erst<br />
als zweites Argument angegeben.] Die Definitionen von sum und or werden zu Einzeilern,<br />
wenn foldr verwendet wird.<br />
sum’ = foldr (+) 0<br />
product’ = foldr (*) 1<br />
and’ = foldr (&&) True<br />
or’ = foldr (||) False<br />
Die Arbeitsweise von foldr kann man sich einfach merken: foldr ersetzt den Listenkonstruktor<br />
(:) durch (*) und die leere Liste [] durch e; aus der Liste a1:(a2:· · ·:(an−1<br />
:(an:[]))· · ·) wird der Ausdruck a1*(a2*· · ·:(an−1*(an:e))· · ·). Man sagt auch, die<br />
Liste wird aufgefaltet. Der Buchstabe r im Namen deutet dabei an, daß der Ausdruck wie<br />
auch die ursprüngliche Liste rechtsassoziativ geklammert wird. Eine Kollegin von foldr,<br />
deren Namen mit l endet, lernen wir in Kürze kennen. Schauen wir uns vorher noch ein<br />
paar Beispiele an:
6.2. Funktionen höherer Ordnung 129<br />
isort = foldr insert []<br />
length = foldr (\n _ -> n + 1) 0<br />
x ++ y = foldr (:) y x<br />
reverse = foldr (\a x -> x ++ [a]) []<br />
concat = foldr (++) []<br />
Um es noch einmal zu betonen: Das erste Argument von foldr definiert den Rekursionsschritt,<br />
das zweite Argument die Rekursionsbasis. Knobelaufgabe: Was macht die<br />
folgende Funktion:<br />
mystery x = foldr (\a -> foldr () [a]) [] x<br />
where a (b:x) = if a b * a. Aus dieser Eigenschaft und unter Verwendung der<br />
Definition von reverse aus Abschnitt 5.5.2 läßt sich die folgende Definition von foldl<br />
ableiten.<br />
foldl’’ :: (solution -> a -> solution) -> solution -> [a] -> solution<br />
foldl’’ (*) e x = f x e<br />
where f [] e = e<br />
f (a:x) e = f x (e*a)
130 6. Abstraktion<br />
Die Hilfsfunktion f korrespondiert dabei mit der Hilfsfunktion revTo. Einziger Unterschied:<br />
der Listenkonstruktor (:) wird sogleich durch flip (*) ersetzt. Spezialisieren<br />
wir foldl wieder, erhalten wir eine effiziente Definition von reverse.<br />
reverse’’’ = foldl (flip (:)) []<br />
Kommen wir zu weiteren Anwendungen: sum, or, concat etc. können auch via foldl<br />
definiert werden:<br />
sum’’’ = foldl (+) 0<br />
or’’’ = foldl (||) False<br />
concat’’’ = foldl (++) []<br />
Daß die Definitionen äquivalent zu den ursprünglichen sind, liegt daran, daß (+) und<br />
0, (&&) und False, (++) und [] jeweils ein Monoid formen: Die Operation ist assoziativ<br />
und das Element ist neutral bezüglich der Operation. Natürlich stellt sich die Frage,<br />
welcher Definition der Vorzug zu geben ist. Also, wie ist es jeweils um den Speicher- und<br />
Zeitbedarf bestellt? Vergleichen wir folgende Rechnungen:<br />
sum [1..9] sum’ [1..9]<br />
⇒ f [1..9] ⇒ f’ [1..9] 0<br />
⇒ 1 + f [2..9] ⇒ f’ [2..9] 1<br />
⇒ 1 + (2 + f [3..9]) ⇒ f’ [3..9] 3<br />
. . . . . .<br />
⇒ 1 + (2 + ... + (9 + f [])) ⇒ f’ [] 45<br />
⇒ 1 + (2 + ... + (9 + 0)) ⇒ 45<br />
⇒ 45<br />
Die Anzahl der Schritte ist gleich, aber der Platzbedarf ist unterschiedlich. Wird foldr<br />
verwendet, entsteht ein arithmetischer Ausdruck, der proportional zur Länge der Liste<br />
wächst. Erst im letzten Schritt kann der Ausdruck ausgerechnet werden. Wird sum mit<br />
foldl definiert, ist der Platzbedarf hingegen konstant, da die arithmetischen Ausdrücke<br />
in jedem Schritt vereinfacht werden können. 1 Im Fall von or erweist sich die Definition<br />
1 Tatsächlich muß man bei der Auswertung der Teilausdrücke etwas nachhelfen.
6.2. Funktionen höherer Ordnung 131<br />
mit foldr als besser:<br />
or [False,True,False] or’ [False,True,False]<br />
⇒ f [False,True,False] ⇒ f’ [False,True,False] False<br />
⇒ False || f [True,False] ⇒ f’ [True,False] (False || False)<br />
⇒ f [True,False] ⇒ f’ [True,False] False<br />
⇒ True || f [False] ⇒ f’ [False] (True || False)<br />
⇒ True ⇒ f’ [False] True<br />
⇒ f’ [] (False || True)<br />
⇒ f’ [] True<br />
⇒ True<br />
Im Unterschied zur Addition läßt sich die logische Disjunktion auch ausrechnen, wenn<br />
lediglich der erste Parameter bekannt ist. Ist dieser gleich True wird der zweite Parameter<br />
zudem gar nicht benötigt. Aus diesem Grund wird die Liste Boolescher Werte nur bis zum<br />
ersten True durchlaufen. Auch im Fall von concat ist foldr der Gewinner — aus den<br />
gleichen Gründen, aus denen (++) als rechtsassoziativ vereinbart wird.<br />
Nicht immer ist die leere Liste eine geeignete Induktionsbasis: Nehmen wir an, wir<br />
wollen das kleinste Element einer Liste bestimmen. Was ist das kleinste Element der leeren<br />
Liste? Als Induktionsbasis wird hier sinnvollerweise die einelementige Liste gewählt. Die<br />
Funktionen foldr1 und foldl1 realisieren das entsprechende Rekursionsschema. Wir<br />
geben nur die Typen an und überlassen die Definition der geneigten Leserin.<br />
foldr1 :: (a -> a -> a) -> [a] -> a<br />
foldl1 :: (a -> a -> a) -> [a] -> a<br />
Wie foldr erzeugt auch foldr1 einen rechtsentarteten „Ausdrucksbaum“. Nur — die<br />
Blätter bestehen ausschließlich aus Listenelementen, aus diesem Grund ist der Typ auch<br />
etwas spezieller. Einen „richtigen“ rechts- oder linksentarteten Baum können wir erzeugen,<br />
indem wir als Operator den Konstruktor Br angeben. Da binäre Bäume — so wie wir<br />
sie definiert haben — nicht leer sind, müssen wir als Induktionsbasis die einelementige<br />
Liste verwenden.<br />
righty, lefty :: [a] -> Tree a<br />
righty = foldr1 Br . map Leaf<br />
lefty = foldl1 Br . map Leaf<br />
Verschiedene Probleme, verschiedene Lösungen: Wir haben gesehen, daß je nach Aufgabenstellung<br />
Anwendungen von foldr und foldl sich stark im Zeit- und Platzbedarf<br />
unterscheiden. Manchmal ist foldr die gute Wahl, manchmal ihre Kollegin foldl. Natürlich<br />
gibt es auch Fälle, in denen foldr und foldl gleichermaßen ungeeignet sind:<br />
Betrachten wir z.B. die Aufgabe, eine Liste von Läufen zu fusionieren. Ein verwandtes
132 6. Abstraktion<br />
Problem haben wir bereits einer ausführlichen Analyse unterzogen: Man erinnere sich an<br />
die Funktion mergeTree aus Abschnitt 5.2, die einen Baume in eine sortierte Liste überführt.<br />
Die Analyse ergab, daß mergeTree ausgeglichene Bäume bevorzugt. Damit ist klar,<br />
daß weder foldr merge [] noch foldl merge [] gute Lösungen für das obige Problem<br />
darstellen. Es liegt nahe, foldr und foldl ein weiteres Rekursionsschema foldm<br />
an die Seite zu stellen, das einen ausgeglichenen Ausdrucksbaum erzeugt: aus der Liste<br />
a1:(a2:· · ·:(an−1:(an:[]))· · ·) wird der Ausdruck<br />
(· · ·(a1 * a2)· · · * · · ·(ai−1 * ai)· · ·) * (· · ·(ai+1 * ai+2)· · · * · · ·(an−1 * n)· · ·)<br />
mit i = ⌊n/2⌋. Der Ausdrucksbaum verdeutlicht die Struktur besser:<br />
: *<br />
/ \ / \<br />
a1 : * *<br />
/ \ foldm (*) e / \ / \<br />
a2 .. ------------> .. .. .. ..<br />
\ / \ / \<br />
: * * ... * *<br />
/ \ / \ / \ / \ / \<br />
an [] a1 a2 ai-1 ai ai+1 ai+2 an-1 an<br />
Die Implementierung von foldm ist aufwendiger als die ihrer Kolleginnen. Das ist auch<br />
klar, da wir sozusagen „gegen“ die rechtsassoziative Listenstruktur arbeiten. Allerdings<br />
müssen wir das Rad nicht neu erfinden, denn — etwas ähnliches haben wir schon programmiert:<br />
die Funktion build aus Abschnitt 3.4 überführt eine Liste in einen ausgeglichenen<br />
Baum. Ersetzen wir in der Definition von build den Konstruktor Br durch den<br />
Operator (*), erhalten wir im wesentlichen die Definition von foldm:<br />
foldm :: (a -> a -> a) -> a -> [a] -> a<br />
foldm (*) e [] = e<br />
foldm (*) e x = fst (f (length x) x)<br />
where f n x = if n == 1 then (head x, tail x)<br />
else let m = n ‘div‘ 2<br />
(a,y) = f m x<br />
(b,z) = f (n-m) y<br />
in (a*b,z)<br />
foldm1 (*) = foldm (*) (error "foldm1 []")<br />
Mit Hilfe von foldm läßt sich die anfangs gestellte Aufgabe effizient lösen. Die Funktion<br />
balanced entspricht gerade der Funktion build.
6.2. Funktionen höherer Ordnung 133<br />
mergeList :: (Ord a) => [[a]] -> [a]<br />
mergeList = foldm merge []<br />
balanced :: [a] -> Tree a<br />
balanced = foldm1 Br . map Leaf<br />
Mit der Funktion mergeList sind wir schon fast wieder beim Sortierproblem angelangt:<br />
Die top-down Variante von msort und ihre „geschmeidige“ Verbesserung smsort<br />
lassen sich mit foldm erschreckend (?) kurz formulieren.<br />
msortBy, smsortBy :: (a -> a -> Bool) -> [a] -> [a]<br />
msortBy ( b) -> Tree a -> b<br />
foldTree leaf br = f<br />
where f (Leaf a) = leaf a<br />
f (Br l r) = br (f l) (f r)<br />
Viele Funktionen auf Bäumen lassen sich kurz und knapp mit foldTree definieren.<br />
size’ = foldTree (\a -> 1) (+)<br />
depth’ = foldTree (\a -> 0) (\m n -> max m n + 1)<br />
mergeTree’ = foldTree (\a -> [a]) merge<br />
mergeRuns’ = foldTree id merge Übung 6.6 Definiere mit Hilfe von foldTree die Funktion<br />
branches :: Tree a -> Int, die die Anzahl der Verzeigungen<br />
in einem Baum bestimmt.<br />
6.2.4. map und Kolleginnen<br />
Die Funktion map, die eine Funktion auf alle Elemente einer Liste anwendet, haben wir<br />
schon kennengelernt. Hier ist noch einmal ihre Definition:<br />
map :: (a -> b) -> [a] -> [b]<br />
map f [] = []<br />
map f (a:x) = f a:map f x<br />
Übung 6.7 Wie läßt sich foldm auf foldTree und build zurückführen?<br />
Welcher Zusammenhang besteht zwischen struktureller<br />
Rekursion auf Bäumen und dem Rekursionsschema<br />
foldTree?
134 6. Abstraktion<br />
Ein ideales Anwendungsgebiet für map und ihre Kolleginnen sind Operationen auf Vektoren<br />
und Matrizen. Einen Vektor stellen wir einfach durch eine Liste dar — Felder wären<br />
eine Alternative, die wir aber nicht verfolgen, da wir auf die Vektorelemente nie direkt<br />
zugreifen müssen. Eine Matrix repräsentieren wir durch eine Liste von Zeilenvektoren.<br />
type Vector a = [a]<br />
type Matrix a = [Vector a]<br />
Die Matrix 3 8 0<br />
2 17 1<br />
wird z.B. durch den Ausdruck<br />
[[3,8,0],[2,17,1]]<br />
dargestellt. Als erste Operation realisieren wir die Multiplikation eines Skalars mit einem<br />
Vektor (für die Notation der Operationen erfinden wir eine Reihe von Operatoren).<br />
():: (Num a) => a -> Vector a -> Vector a<br />
k x = map (\a -> k*a) x<br />
Um die Summe und das innere Produkt zweier Vektoren zu programmieren, erweist<br />
sich eine Kollegin von map als hilfreich, die eine zweistellige, gestaffelte Funktion auf zwei<br />
Listen anwendet.<br />
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]<br />
zipWith f (a:x) (b:y) = f a b:zipWith f x y<br />
zipWith _ _ _ = []<br />
Die Funktion heißt auf englisch Reißverschluß, da f wiederholt jeweils auf die beiden<br />
ersten Listenelemente angewendet wird. Beachte: Die Argumentlisten müssen nicht die<br />
gleiche Länge haben; die kürzere bestimmt die Länge der Ergebnisliste. Summe und inneres<br />
Produkt zweier Vektoren sind schnell definiert.<br />
() :: (Num a) => Vector a -> Vector a -> Vector a<br />
x y = zipWith (+) x y<br />
() :: (Num a) => Vector a -> Vector a -> a<br />
x y = sum (zipWith (*) x y)<br />
Ein nützlicher Spezialfall von zipWith ist zip: die zweistellige Operation ist hier der<br />
Paarkonstruktor.<br />
zip :: [a] -> [b] -> [(a,b)]<br />
zip = zipWith (\a b -> (a,b))
6.2. Funktionen höherer Ordnung 135<br />
Die Funktion zip wird oft in Verbindung mit Listenbeschreibungen verwendet. Hier sind<br />
noch einmal alle Vektoroperationen unter Verwendung von Listenbeschreibungen definiert.<br />
k x = [k*a | a Matrix a<br />
a b = zipWith () a b<br />
Das Produkt zweier Matrizen A und B erhalten wir, indem wir jede Zeile von A mit jeder<br />
Spalte von B multiplizieren. Genauer: Ist x ein Zeilenvektor von A, so ist der entsprechende<br />
Zeilenvektor der Produktmatrix gleich [ x y | y Matrix a -> Matrix a -> Matrix a<br />
m n = [[x y | y
136 6. Abstraktion<br />
Die Mogelei hat tatsächlich auch einen Vorteil: transpose kann auch mit Teillisten arbeiten,<br />
die unendlich lang sind. Dieser Fall tritt bei den hier betrachteten Matrizenoperationen nicht auf;<br />
in anderen Anwendungen aber sehr wohl. Die Listen könnten z.B. Meßreihen sein; mit Hilfe von<br />
transpose können diese als Tabellen ausgegeben werden: Bei der Ausgabe der Liste x = [[1..],<br />
[2..],[3..]] wird die zweite Teilliste niemals erreicht; transpose x macht aus den drei unendlichen<br />
Listen eine unendliche Liste von dreielementigen Listen, die ohne Probleme ausgegeben<br />
werden kann.<br />
Die folgende Variante von transpose verarbeitet auch obere Dreiecksmatrizen der Form [[1,2,<br />
3],[4,5],[6]]; das Ergebnis ist wieder eine obere Dreiecksmatriz, bei der nur die Enträge oberhalb<br />
der Diagonalen aufgeführt werden.<br />
transpose’’ = foldr (\v vs -> zipWith (:) v (vs ++ repeat [])) []<br />
Interessanterweise verarbeiten auch die Addition und Multiplikation — sofern sie<br />
diese Variante von transpose verwendet — obere Dreieickmatrizen fehlerfrei. Warum?<br />
Übung: Nachfolgend ist eine dritte Variante von transpose aufgeführt. Diskutiere Vor- und<br />
Nachteile der drei Definitionen.<br />
transpose’’’ a<br />
| and (map null a) = []<br />
| otherwise = map head a:transpose’’’ (map tail a)<br />
Die vordefinierte Funktion null :: [a] -> Bool ergibt True gdw. das Argument die leere<br />
Liste ist.<br />
Das Gauß’sche Eliminationsverfahren:<br />
deleteMax :: (Ord b) => (a -> b) -> [a] -> (a, [a])<br />
deleteMax key [a] = (a,[])<br />
deleteMax key (a:x)<br />
| key a >= key b = (a,x)<br />
| otherwise = (b,a:y)<br />
where (b,y) = deleteMax key x<br />
gaussElim :: (Ord a, Fractional a) => Matrix a -> Matrix a<br />
gaussElim [row] = [row]<br />
gaussElim rows = prow:gaussElim (map elim rows’)<br />
where (prow@(p:ps),rows’) = deleteMax (\row -> abs (head row)) rows<br />
elim (x:xs) = xs ((-x/p) ps)<br />
solve :: (Fractional a) => Matrix a -> Vector a<br />
solve [] = [-1]<br />
solve ((x:xs):rows) = -(solns xs)/x:solns<br />
where solns = solve rows<br />
Zur Übung: Definiere die gleichen Operationen auf Feldern.
6.3. Typklassen 137<br />
6.3. Typklassen<br />
6.3.1. Typpolymorphismus und Typklassen<br />
Im letzten Kapitel haben wir uns mit dem Sortieren von Listen beschäftigt. Um die Algorithmen<br />
vorzustellen und vorzuführen, sind wir stets davon ausgegangen, daß die Listen<br />
Elemente eines (fast) beliebigen Basistyps enthalten. Aus theoretischer Sicht ist dieser Typ<br />
ohne Belang: Beim Nachweis der Korrektheit oder bei der Analyse der asymptotischen<br />
Laufzeit spielt es keine Rolle, ob Zahlen oder Personendaten sortiert werden. Aus praktischer<br />
Sicht ist eine Einschränkung hier wenig annehmbar: Natürlich möchte man sowohl<br />
Zahlen als auch Personendaten sortieren und noch vieles andere mehr. Lisa Lista: Eigentlich ist dies eine Selbstverständlichkeit, oder?<br />
Die Tatsache, daß der Grundtyp der Listen beliebig ist, drücken wir aus, indem wir bei<br />
der Typdeklaration eine Typvariable für den Grundtyp der Listen einsetzen.<br />
(++) :: [a] -> [a] -> [a]<br />
Lies „für alle Typen a ist [a] -> [a] -> [a] ein Typ von (++)“. Beide Argument müssen<br />
somit Listen über dem gleichen Typ sein, dieser ist aber beliebig. Wir sagen, (++) ist<br />
eine polymorphe Funktion. Sie kann für beliebige Ausprägungen von a verwendet werden:<br />
"hello " ++ "world" ⇒ "hello world"<br />
[19, 9] ++ [7] ⇒ [19, 9, 7]<br />
["hello ","world"] ++ ["it’s","me"] ⇒ ["hello ","world","it’s","me"]<br />
Um es noch einmal zu betonen: In beiden Fällen handelt es sich um die gleiche Funktion.<br />
Fast alle der in den letzten Kapiteln definierten Funktionen besitzen einen polymorphen<br />
Typ; hier eine kleine Auswahl:<br />
head :: [a] -> a<br />
tail :: [a] -> [a]<br />
leaves :: Tree a -> [a]<br />
build :: [a] -> Tree a<br />
map :: (a -> b) -> ([a] -> [b])<br />
Der Typ von map ist sehr allgemein: Das erste Argument ist eine beliebige Funktion;<br />
Argument- und Ergebnistyp dieser Funktion sind nicht festgelegt, insbesondere müssen<br />
sie nicht gleich sein: map (\n -> n+1), map (\a -> Leaf a) und auch map map<br />
sind korrekte Aufrufe.<br />
Was ist nun der Typ der Sortierfunktionen? Alle Sortierfunktionen stützen sich auf die<br />
Vergleichsoperation (
138 6. Abstraktion<br />
Auch Listen lassen sich vergleichen, wenn wir die Listenelemente vergleichen können:<br />
[] [a] ein<br />
Typ von isort“. Der Teil (Ord a) => ist ein sogenannter Kontext, der die Ausprägung<br />
der Typvariable a einschränkt. Dabei ist Ord eine einstellige Relation auf Typen, eine<br />
sogenannte Typklasse, die sich durch folgende Regeln beschreiben läßt (wir führen nur<br />
eine Auswahl auf):<br />
Ord Integer<br />
Ord Char<br />
Ord a ∧ Ord b ⇒ Ord (a, b)<br />
Ord a ⇒ Ord [a]<br />
Lies „Integer ist eine Instanz der Typklasse Ord“. Wir haben oben gesehen, wie (
6.3. Typklassen 139<br />
In Haskell sind eine Reihe von Typklassen vordefiniert; die wichtigsten werden wir im<br />
folgenden kennenlernen.<br />
In der Typklasse Show sind alle vordefinierten Typen enthalten; sie stellt Funktionen zur<br />
Verfügung, mit deren Hilfe sich Werte in ihre textuelle Repräsentation überführen lassen.<br />
show :: (Show a) => a -> String<br />
Schauen wir uns einige Anwendungen an:<br />
show 123 ⇒ "123"<br />
show [1, 2, 3] ⇒ "[1, 2, 3]"<br />
show "123" ⇒ "\"123\""<br />
Das letzte Beispiel illustriert, wie Anführungsstriche in einem String untergebracht werden<br />
können; ihnen ist ein Backslash „\“ voranzustellen.<br />
Die Typklasse Eq umfaßt alle Typen, deren Elemente sich auf Gleichheit testen lassen,<br />
das sind alle Typen mit Ausnahme von Funktionen. Die Funktionen unique und element<br />
verwenden den Test auf Gleichheit, ihre allgemeinsten Typen lauten:<br />
unique :: (Eq a) => [a] -> [a]<br />
element :: (Eq a) => a -> [a] -> Bool<br />
Die Typklasse Ord wird für total geordnete Datentypen verwendet; die meisten Funktionen<br />
des letzten Kapitels haben einen Ord-Kontext:<br />
insert :: (Ord a) => a -> [a] -> [a]<br />
merge :: (Ord a) => [a] -> [a] -> [a]<br />
Neben den üblichen Vergleichsoperationen a -> a -> Ordering<br />
Eine geringfügig effizientere Variante der Funktion uniqueInsert läßt sich mit Hilfe von<br />
compare definieren.<br />
uniqueInsert’ a [] = [a]<br />
uniqueInsert’ a x@(b:y) = case compare a b of<br />
LT -> a:x<br />
EQ -> x<br />
GT -> b:uniqueInsert’ a y
Übung 6.9 Warum ist Num keine Untertypklasse von Ord?<br />
140 6. Abstraktion<br />
Typen, auf denen Addition, Subtraktion und Multiplikation definiert sind, gehören zur<br />
Klasse Num. Dazu zählen alle in Haskell vordefinierten numerischen Typen wir Int, Integer,<br />
Float und Double. Verwendet eine Funktion die Grundrechenarten (+), (-) oder (*),<br />
erhält sie einen Num-Kontext.<br />
size :: (Num n) => Tree a -> n<br />
depth :: (Num n, Ord n) => Tree a -> n<br />
Werden die Funktionen in dieser Allgemeinheit nicht benötigt, steht es der Programmiererin<br />
natürlich frei, den Typ einzuschränken; nichts anderes haben wir in den letzten Kapitel<br />
gemacht. Aus Gründen der Effizienz hat man sich z.B. entschlossen, die vordefinierten<br />
Funktionen length, take und drop auf Int zu spezialisieren:<br />
length :: [a] -> Int<br />
take, drop :: Int -> [a] -> [a]<br />
Man unterscheidet zwischen zwei Divisionsoperatoren: der ganzzahligen Division div<br />
und der normalen Division (/). Entsprechend gibt es zwei verschiedene Klassen: Integral<br />
für numerische Typen, die div anbieten, und Fractional für Typen, die (/) anbieten.<br />
Instanzen von Integral sind Int und Integer, Float und Double sind Instanzen von<br />
Fractional. Die Funktion power, die wir in Abschnitt 3.2.3 definiert haben, erhält den<br />
folgenden Typ:<br />
power :: (Num a, Integral b) => a -> b -> a<br />
Der Exponent muß aufgrund der Verwendung von div und mod eine ganze Zahl sein,<br />
die Basis hingegen ist beliebig. In der Definition von power wird zusätzlich der Test auf<br />
Gleichheit verwendet, warum muß Eq b nicht als Kontext aufgeführt werden? Nun, die<br />
Typklassen sind hierarchisch angeordnet: Eine Instanz von Integral muß auch Instanz<br />
von Num sein, eine Instanz von Num muß auch Instanz von Eq sein. Wir sagen Integral<br />
ist eine Untertypklasse von Num. Welchen Sinn würde es auch machen, div ohne (*)<br />
anzubieten?<br />
Eq Show<br />
/ \ /<br />
Ord Num<br />
\ / \<br />
Integral Fractional<br />
Da Integral a somit Eq a umfaßt, muß nur der erste Kontext aufgeführt werden.<br />
6.3.2. class- und instance-Deklarationen<br />
Ausgangspunkt: polymorphe Typen. Motivation für Typklassen:
6.3. Typklassen 141<br />
• Operation wie (==) oder ( Bool<br />
x /= y = not (x == y)<br />
Der Name der Typklasse is Eq, danach folgt eine Typvariable (allgemeine Form C a) (==)<br />
und (/=) sind Methoden der Klasse. Für (/=) ist eine Default-Methode angegeben. Die<br />
Zugehörigkeit zu einer Klasse wird explizit mit Hilfe einer Instanzdeklaration angegeben.<br />
Beispiel: Rationale Zahlen.<br />
instance Eq Rat where<br />
Rat x y == Rat x’ y’ = x*y’ == x’*y<br />
Unschön. Bei jedem Gleichheitstest werden zwei Multiplikationen durchgeführt. Ausweg: ‘smart’<br />
Konstruktor:<br />
rat :: Int -> Int -> Rat<br />
rat x y = norm (x * signum y) (abs y)<br />
where norm x y = let d = gcd x y in Rat (x ‘div‘ d) (y ‘div‘ d)<br />
Wenn wir davon ausgehen, daß Elemente vom Typ Rat immer normiert sind, dann kann die<br />
Standarddefinition von (==) verwendet werden.<br />
Beispiel: Binärbäume:
142 6. Abstraktion<br />
instance (Eq a) => Eq (Tree a) where<br />
Leaf a == Leaf b = a == b<br />
(Br l r) == Br l’ r’ = l == l’ && r == r’<br />
_ == _ = False<br />
Diese Definition wird mit deriving automatisch erzeugt. Allgemeine Form einer Instanzdeklaration:<br />
(C1 β1,...,Cm βm) => C(T α1 . . . αn) mit {β1, . . . , βm} ⊆ {α1, . . . , αn}.<br />
Definition der Typklasse Ord als Untertypklasse von Eq:<br />
class (Eq a) => Ord a where<br />
compare :: a -> a -> Ordering<br />
() :: a -> a -> Bool<br />
max, min :: a -> a -> a<br />
compare x y | x == y = EQ<br />
| x y = compare x y == GT<br />
max x y | x >= y = x<br />
| otherwise = y<br />
min x y | x
6.3. Typklassen 143<br />
Leaf _ String<br />
showChar :: Char -> ShowS
144 6. Abstraktion<br />
showChar = (:)<br />
showString :: String -> ShowS<br />
showString = (++)<br />
Bei der Typklasse Show wird zusätzlich die Bindungsstärke von Operatoren berücksichtigt,<br />
um Klammern zu sparen. Beispiel: unäres Minus (Bindungsstärke 6), vergleiche<br />
[-7,8]<br />
Br (Leaf (-7)) (Leaf 8)<br />
In Abhängigkeit vom Kontext wird -7 mal geklammert mal nicht; 8 wird nie geklammert.<br />
Deshalb gibt es einen zusätzlichen Parameter, der Informationen über den Kontext<br />
überbringt. Definition der Typklasse Show:<br />
class Show a where<br />
showsPrec :: Int -> a -> ShowS<br />
showList :: [a] -> ShowS<br />
showList [] = showString "[]"<br />
showList (a:x) = showChar ’[’ . shows a . showRest x<br />
where showRest [] = showChar ’]’<br />
showRest (a:x) = showString ", " . shows a . showRest x<br />
shows :: (Show a) => a -> ShowS<br />
shows = showsPrec 0<br />
show :: (Show a) => a -> String<br />
show x = shows x ""<br />
showParen :: Bool -> ShowS -> ShowS<br />
showParen b p = if b then showChar ’(’ . p . showChar ’)’ else p<br />
Der Parameter p besagt: Operatoren der Bindungsstärke < p müssen geklammert werden:<br />
0 niemals klammern, 10 immer klammern. Beispiele für Instanzdeklarationen:<br />
instance Show Rat where<br />
showsPrec p (Rat x y) = showParen (p > 9)<br />
(showString "Rat " . showsPrec 10 x<br />
. showsPrec 10 y)<br />
Beispiel: Binärbäume.
6.3. Typklassen 145<br />
instance (Show a) => Show (Tree a) where<br />
showsPrec p Nil = showString "Nil"<br />
showsPrec p (Leaf a) = showParen (p > 9)<br />
(showString "Leaf " . showsPrec 10 a)<br />
showsPrec p (Br l r) = showParen (p > 9)<br />
(showString "Br " . showsPrec 10 l<br />
. showChar ’ ’ . showsPrec 10 r )<br />
Wird statt Br der Infix-Operator :ˆ: verwendet<br />
infix 4 :ˆ:<br />
ändert sich die letzte Gleichung wie folgt:<br />
showsPrec p (l :ˆ: r) = showParen (p > 4)<br />
(showsPrec 5 l . showString " :ˆ: "<br />
. showsPrec 5 r)<br />
Die Klasse Read führen wir nicht auf. Um die Definition verstehen zu können, muß man<br />
schon ein bischen von Parsen kennen. Hier nur die wichtigste Funktion:<br />
read :: (Read a) => String -> a<br />
Problem: Der Übersetzer muß anhand der Typen entscheiden können, welche Instanzen<br />
gemeint sind. Die folgende Definition erweist sich als problematisch:<br />
whoops :: String -> String<br />
whoops x = show (read x)<br />
Abhilfe: explizite Typangabe:<br />
whoops :: String -> String<br />
whoops x = show (read x :: Tree Int)<br />
6.3.5. Die Typklasse Num<br />
Definition der Typklasse Num:<br />
class (Eq a, Show a) => Num a where<br />
(+), (-), (*) :: a -> a -> a<br />
negate :: a -> a<br />
abs, signum :: a -> a<br />
fromInteger :: Integer -> a<br />
x-y = x + negate y
146 6. Abstraktion<br />
Behandlung numerischer Literale: 5 wird interpretiert als fromInteger (5::Integer).<br />
Beispiel für eine Instanzdeklaration:<br />
instance Num Rat where<br />
Rat x y + Rat x’ y’ = Rat (x*y’ + x’*y) (y*y’)<br />
Rat x y * Rat x’ y’ = Rat (x*x’) (y*y’)<br />
negate (Rat x y) = Rat (negate x) y<br />
abs (Rat x y) = Rat (abs x) (abs y)<br />
signum (Rat x y) = Rat (signum (x*y)) 1<br />
fromInteger x = Rat (fromInteger x) 1<br />
fromInt x = Rat (fromInt x) 1 -- Hugs-spezifisch<br />
Eleganter: Rat parametrisiert mit einem numerischen Typ.<br />
data (Integral a) => Rat a = Rat a a<br />
Gibt’s vordefiniert als Ratio in der Bibliothek Rational. Gag:<br />
instance Num Bool where<br />
(+) = (||)<br />
(*) = (&&)<br />
negate = not<br />
fromInteger n = n /= 0<br />
fromInt n = n /= 0 -- Hugs-spezifisch<br />
Größeres Beispiel: Zahlen mit ∞ und −∞.<br />
data Inf n = Fin n | Inf Bool<br />
deriving (Eq)<br />
oo = Inf True<br />
finite = Fin<br />
isFinite (Fin _) = True<br />
isFinite (Inf _) = False<br />
instance (Ord n) => Ord (Inf n) where<br />
Fin m
6.3. Typklassen 147<br />
showsPrec p (Inf True) = showString "oo"<br />
instance (Num n) => Num (Inf n) where<br />
Fin m + Fin n = Fin (m+n)<br />
Fin _ + Inf b = Inf b<br />
Inf b + Fin _ = Inf b<br />
Inf b + Inf c<br />
| b == c = Inf b<br />
| otherwise = error "oo - oo"<br />
Fin m * Fin n = Fin (m*n)<br />
Fin m * Inf b = Inf (b == (signum m == 1))<br />
Inf b * Fin n = Inf (b == (signum n == 1))<br />
Inf b * Inf c = Inf (b == c)<br />
negate (Fin m) = Fin (negate m)<br />
negate (Inf b) = Inf (not b)<br />
abs (Fin m) = Fin (abs m)<br />
abs (Inf b) = Inf True<br />
signum (Fin m) = Fin (signum m)<br />
signum (Inf False) = Fin (-1)<br />
signum (Inf True) = Fin 1<br />
fromInteger i = Fin (fromInteger i)<br />
fromInt i = Fin (fromInt i) -- Hugs-spezifisch<br />
minimum’, maximum’ :: (Ord n, Num n) => [Inf n] -> Inf n<br />
minimum’ = foldr min oo<br />
maximum’ = foldr max (-oo)<br />
minimum’ [4,8,2,56,8]<br />
Problem der Eindeutigkeit (da capo):<br />
phyth :: (Floating a) => a -> a -> a<br />
phyth x y = sqrt (xˆ2 + yˆ2)<br />
Die Funktion (ˆ) hat den Typ (Num a, Integral b) => a -> b -> a, 2 hat den<br />
Typ (Num a) => a; somit erhält xˆ2 den Typ ((Num a, Integral b) => a). Problem:<br />
Welcher Typ soll für b gewählt werden? Int oder Integer? Da Mehrdeutigkeiten<br />
bei numerischen Typen besonders häufig auftreten, kann das mit Hilfe einer default-<br />
Deklaration festgelegt werden.<br />
default (Int,Double)<br />
Grit Garbo: FALSCH: 0 * oo ist mathematisch nicht definiert!!!
148 6. Abstraktion<br />
6.4. Anwendung: Sequenzen<br />
6.4.1. Klassendefinition<br />
Binärbäume lassen sich als Sequenzen deuten: Leaf a formt eine einelementige Liste, Br<br />
hängt zwei Listen aneinander. Vergleichen wir die Typen von (++) und Br (wir notieren<br />
den Listenkonstruktor präfix: statt [a] einfach [] a).<br />
(++) :: [] a -> [] a -> [] a<br />
Br :: Tree a -> Tree a -> Tree a<br />
Beachte: es wird über einen Typkonstruktor abstrahiert.<br />
class Sequence s where<br />
empty :: s a<br />
single :: a -> s a<br />
isEmpty, isSingle :: s a -> Bool<br />
( s a -> s a<br />
(|>) :: s a -> a -> s a<br />
hd :: s a -> a<br />
tl :: s a -> s a<br />
() :: s a -> s a -> s a<br />
len :: s a -> Int<br />
fromList :: [a] -> s a<br />
toList :: s a -> [a]<br />
Je nachdem, ob die Klassenmethoden Sequenzen zusammensetzen, in Komponenten<br />
zerlegen oder Eigenschaften feststellen, nennt man sie Konstruktoren, Selektoren oder<br />
Observatoren.
6.4. Anwendung: Sequenzen 149<br />
Die Definition subsumiert Stapel (engl. stacks): (), hd, tl. Spezifikation:<br />
Default-Methoden:<br />
empty = 〈〉<br />
single a = 〈a〉<br />
isEmpty 〈a1 . . . an〉 = n = 0<br />
isSingle 〈a1 . . . an〉 = n = 1<br />
a a = 〈a1 . . . ana〉<br />
hd 〈a1 . . . an〉 = a1<br />
tl 〈a1 . . . an〉 = 〈a2 . . . an〉<br />
〈a1 . . . am〉 〈b1 . . . bn〉 = 〈a1 . . . amb1 . . . bn〉<br />
len 〈a1 . . . an〉 = n<br />
fromList [a1, . . . , an] = 〈a1 . . . an〉<br />
toList 〈a1 . . . an〉 = [a1, . . . , an]<br />
single a = a b -> s a -> b<br />
foldrS (*) e s<br />
| isEmpty s = e<br />
| otherwise = hd s * foldrS (*) e (tl s)<br />
Als Anwendung programmieren wir eine stabile Version von Quicksort. Ein Sortierverfahren<br />
heißt stabil, wenn Elemente mit gleichem Schlüssel beim Sortieren ihre relative<br />
Anordnung beibehalten. Diese Eigenschaft erlaubt sukzessives Sortieren nach verschiedenen<br />
Kriterien.<br />
Wir verwenden (|>) anstelle von (:).<br />
Übung 6.10 Definiere foldlS und foldr1S.<br />
Übung 6.11 Zeige, daß das Programm von Qsort aus Abschnitt<br />
6.1 nicht stabil ist. Dafür gibt es sogar zwei Gründe!
150 6. Abstraktion<br />
qsortS :: (Sequence s, Sequence t, Ord a) => s a -> t a<br />
qsortS x<br />
| isEmpty x = empty<br />
| otherwise = partitionS (tl x) empty (hd x) empty<br />
partitionS :: (Sequence s, Sequence t, Ord a) => s a -> s a -> a -> s a -> t a<br />
partitionS x l a r<br />
| isEmpty x = qsortS l (single a qsortS r)<br />
| a b)<br />
| otherwise = partitionS (tl x) (l |> b) a r<br />
where b = hd x<br />
Beachte: der Typ wäre nicht so allgemein, wenn wir in der ersten Gleichung von qsortS<br />
den Ausdruck empty durch x ersetzen würden.<br />
6.4.2. Einfache Instanzen<br />
Allgemein gilt, daß wir die Default-Methoden nur dann ersetzen, wenn eine bessere asymptotische<br />
Laufzeit erreicht wird.<br />
Listen sind Sequenzen ([] ist der Typkonstruktor):<br />
instance Sequence [] where<br />
empty = []<br />
isEmpty = null<br />
(
6.4. Anwendung: Sequenzen 151<br />
isEmpty t = False<br />
() = Br<br />
etc.<br />
Allerdings scheitert diese Idee an Fällen wie isEmpty (Nil Nil) = False, die<br />
im Widerspruch zur Spezifikation stehen. Das Problem liegt darin, daß der Datentyp Tree<br />
unterschiedliche Repräsentationen der gleiche Sequenz erlaubt. Grundsätzlich gibt es zwei<br />
Wege, dieses Problem zu lösen: Man kann die Sequenz-Konstruktoren so definieren, daß<br />
sie keine „unerwünschten“ Repräsentationen (wie oben Br Nil Bil statt einfach Nil)<br />
erzeugen. Oder man programmiert die Observatoren und Selektoren so, daß sie mit allen<br />
möglichen Repräsentationen korrekt umgehen.<br />
Zu Demonstrationszwecken wenden wir hier beide Techniken an.<br />
instance Sequence Tree where<br />
empty = Nil<br />
isEmpty Nil = True<br />
isEmpty (Leaf _) = False<br />
isEmpty (Br l r) = isEmpty l && isEmpty r<br />
single = Leaf<br />
isSingle Nil = False<br />
isSingle (Leaf _) = True<br />
isSingle (Br l r) = isSingle l /= isSingle r<br />
hd (Leaf a) = a<br />
hd (Br l r)<br />
| isEmpty l = hd r<br />
| otherwise = hd l<br />
tl (Br Nil r) = tl r<br />
tl (Br (Leaf _) r) = r<br />
tl (Br (Br l’ r’) r) = tl (Br l’ (Br r’ r)) -- Rechtsrotation<br />
() = br where -- smart Konstruktor<br />
br Nil r = r<br />
br l Nil = l<br />
br l r = Br l r<br />
toList = leaves<br />
fromList [] = Nil<br />
fromList [a] = Leaf a<br />
fromList (a:as) = a
Übung 6.14 Sequenzen über beliebigen Alphabeten lassen sich<br />
nach einer Technik von Gödel als Zahlen codieren. Hier am Beispiel<br />
über dem Alphabet der positiven ganzen Zahlen:<br />
data GoedelNr = G Integer deriving Show<br />
goedelize :: [Integer] -> GoedelNr<br />
goedelize ns = G (g primes ns)<br />
where g ps [] = 1<br />
g (p:ps) (n:ns) = pˆn * g ps ns<br />
Führe diese Technik für beliebige Alphabete a durch und mache<br />
den Typ GoedelNr a zur Instanz von Sequence.<br />
Übung 6.15 Listen mit Löchern als (nicht-generische) Instanz.<br />
Vorübung zur nächsten Aufgabe.<br />
data LList a = LL ([a] -> [a])<br />
abra, kad :: LList Char<br />
abra = LL ("abra"++) -- Beispieldaten<br />
kad = LL ("kad"++)<br />
abrakadabra = toList (abra (kad abra))<br />
Erkläre LList zur Instanz von Sequence.<br />
Übung 6.16 Sequenzen mit Löchern als generische Instanz.<br />
data Iso s a = Iso (s a -> s a)<br />
152 6. Abstraktion<br />
Schlechtes Verhalten bei:<br />
6.4.3. Generische Instanzen<br />
t0 = empty . . . , ti+1 = ti |> ai, hd ti+1 . . .<br />
Erweiterung eines Sequenzenkonstruktors, so daß die Länge in konstanter Zeit berechnet<br />
wird. WithLen hat Kind von (* -> *) -> (* -> *).<br />
data WithLen s a = Len (s a) Int<br />
Beachte: deriving (Show) geht nicht, da die Voraussetzung Show (s a) nicht zulässig<br />
ist.<br />
instance (Sequence s) => Sequence (WithLen s) where<br />
empty = Len empty 0<br />
single a = Len (single a) 1<br />
isEmpty (Len _ n) = n == 0<br />
isSingle (Len _ n) = n == 1<br />
a a) (n+1)<br />
hd (Len s _) = hd s<br />
tl (Len s n) = Len (tl s) (n-1)<br />
Len s m Len t n = Len (s t) (m+n)<br />
len (Len _ n) = n<br />
fromList x = Len (fromList x) (length x)<br />
toList (Len s _) = toList s<br />
Ein angenehmer Seiteneffekt: Auch isEmpty und isSingle laufen in konstanter Zeit.<br />
Anwendung:<br />
type List = WithLen []<br />
Durch diese Defintion erhalten wir einen Listentyp, der sozusagen die Länge der Listen<br />
„cached“ und effizienten Zugriff gestattet.<br />
6.4.4. Schlangen<br />
Ziel: (|>), hd und tl in konstanter Zeit. Das sind die typischen Operationen für Schlangen<br />
(engl. queues). Ausgangspunkt: (
6.4. Anwendung: Sequenzen 153<br />
data FrontRear s t a = FrontRear (s a) (t a)<br />
Wir tragen Sorge, daß die erste Liste immer mindestens ein Element enthält. Invariante<br />
für FrontRear f r:<br />
isEmpty f =⇒ isEmpty r (6.1)<br />
instance (Sequence s, Sequence t) => Sequence (FrontRear s t) where<br />
empty = FrontRear empty empty<br />
isEmpty (FrontRear f r) = isEmpty f<br />
FrontRear f r |> a = frontRear f (a s a -> t a -> FrontRear s t a<br />
frontRear f r<br />
| isEmpty f = FrontRear (rev r) empty<br />
| otherwise = FrontRear f r<br />
Amortisierte Analyse: gutartig für (Queue wird ephemeral verwendet):<br />
q0, hd q0, q1 = tl q0, . . . , hd qn−1, qn = tl qn−1<br />
Schlechtes Verhalten bei (Queue wird persistent verwendet):<br />
q = FrontRear [a] r, qi = tl (q |> ai), hd qi<br />
Idee: das Umdrehen muß frühzeitig erfolgen. Einführung einer Balanzierungsbedingung.<br />
Invariante für FrontRear f r:<br />
len f len r (6.2)<br />
Beachte: (6.1) impliziert (6.2). Voraussetzung: erfolgt inkrementell. Und: len in konstanter<br />
Zeit.<br />
frontRear :: (Sequence s, Sequence t) => s a -> t a -> FrontRear s t a<br />
frontRear f r<br />
| len f < len r = FrontRear (f rev r) empty<br />
| otherwise = FrontRear f r
154 6. Abstraktion<br />
Amortisierte Laufzeit von Θ(1). Worst case Laufzeit von Θ(n). Anwendung:<br />
type Queue = FrontRear List List<br />
6.4.5. Konkatenierbare Listen<br />
Ziel: hd, tl und () in konstanter Zeit. Binärbäume bieten schon () in konstanter<br />
Zeit. Left-spine view:<br />
data GTree s a = GNode a (s (GTree s a))<br />
instance (Sequence s) => Sequence (GTree s) where<br />
single a = GNode a empty<br />
isSingle (GNode _ s) = isEmpty s<br />
hd (GNode a _) = a<br />
tl (GNode _ s)<br />
| isEmpty s = error "tl of singleton"<br />
| otherwise = foldr1S () s<br />
GNode a s g = GNode a (s |> g)<br />
Anwendung:<br />
type CatList = AddEmpty (GTree Queue)
A. Lösungen aller Übungsaufgaben<br />
3.1 Hier ist die Definition von not:<br />
not :: Bool -> Bool -- vordefiniert<br />
not True = False<br />
not False = True<br />
Die Definitionen von (&&) und (||) bedienen sich im wesentlichen der entsprechenden<br />
Wertetabelle für die Konjunktion bzw. für die Disjunktion. Gleiche Fälle werden zusammengefaßt.<br />
(&&), (||) :: Bool -> Bool -> Bool -- vordefiniert<br />
False && y = False<br />
True && y = y<br />
True || y = True<br />
False || y = y<br />
[(&&) und (||) sind lazy!]<br />
3.2 Definition von first, second und third:<br />
first :: (a, b, c) -> a<br />
first (a, b, c) = a<br />
second :: (a, b, c) -> b<br />
second (a, b, c) = b<br />
third :: (a, b, c) -> c<br />
third (a, b, c) = c<br />
3.3 Definition von head’’:<br />
head’’ :: List a -> a<br />
head’’ Empty = error "head’’ of empty list"<br />
head’’ (Single a) = a<br />
head’’ (App Empty r) = head’’ r<br />
head’’ (App (Single a) r) = a<br />
head’’ (App (App ll lr) r) = head’’ (App ll (App lr r))
156 A. Lösungen aller Übungsaufgaben<br />
Wenn man vereinbart, daß Empty nie unterhalb von App auftritt, dann ist die Definition<br />
einfacher.<br />
tail’ :: List a -> List a<br />
tail’ Empty = error "tail’ of empty list"<br />
tail’ (Single a) = Empty<br />
tail’ (App Empty r) = tail’ r<br />
tail’ (App (Single a) r) = r<br />
tail’ (App (App ll lr) r) = tail’ (App ll (App lr r))<br />
3.4 Definition von member:<br />
member’ :: (Ord a) => a -> OrdList a -> Bool<br />
member’ a [] = False<br />
member’ a (b:bs) = a >= b && (a == b || member’ a bs)<br />
Beachte: die Klammern sind notwendig, da (&&) stärker bindet als (||).<br />
3.5 Man sollte Grit zunächst klarmachen, daß links- und rechtsassoziativ rein syntaktische<br />
Eigenschaften sind, die dem Haskell-Übersetzer mitteilen, wie fehlende Klammern<br />
zu ergänzen sind. Mathematisch gesehen ist die Listenkonkatenation assoziativ: Wie man<br />
x1++x2++x3 auch klammert, der Wert des Ausdrucks ist stets der gleiche. Warum vereinbart<br />
man (++) dann als rechtsassoziativ? Nun, das hat Effizienzgründe: Sei ni die Länge<br />
der Liste xi; um (x1++x2)++x3 auszurechnen, benötigt man 2n1 + n2 Anwendungen von<br />
„:“, für x1++(x2++x3) hingegen nur n1 + n2. Die arithmetischen Operatoren werden aus<br />
Gründen der Einheitlichkeit als linksassoziativ deklariert: die Operatoren (-) und (/)<br />
sind es auch und dies aus gutem Grund, wie wir gesehen haben.<br />
3.6 Funktionen auf Cartesischen Produkten sind die bessere Wahl, wenn das Paar oder<br />
das n-Tupel semantisch zusammengehört; etwa wenn ein Paar eine Koordinate in der<br />
Ebene repräsentiert.<br />
3.7 Die Ausdrücke bezeichnen die folgenden Bäume:<br />
1. Br (Br (Leaf 1) (Leaf 2)) Nil<br />
2. Br (Br (Leaf 1) (Leaf 2))<br />
(Br (Br (Leaf 3) Nil)<br />
(Br (Leaf 4) (Leaf 5)))<br />
3. Br (Br (Br (Leaf 1) (Leaf 2))<br />
(Br (Leaf 3)<br />
(Br (Leaf 4)<br />
(Br (Leaf 5) (Leaf 6)))))<br />
(Br (Leaf 7) (Leaf 8))
3.8 Die Ausdrücke bezeichnen die folgenden Bäume:<br />
ɛ<br />
1 2 1 2 ɛ 3<br />
4 5<br />
1 2<br />
3 4<br />
(1) (2) (3)<br />
5<br />
6 7 8<br />
Diese Bäume erhält man übrigens durch Spiegelung der Bäume aus Aufgabe 3.7 (die Blätter<br />
müssen zusätzlich neu durchnumeriert werden).<br />
3.9 Definition von leaves’ mit case-Ausdrücken:<br />
leaves’’ :: Tree a -> [a]<br />
leaves’’ t = case t of<br />
Nil -> []<br />
Leaf a -> [a]<br />
Br l r -> case l of<br />
Nil -> leaves’’ r<br />
Leaf a -> a : leaves’’ r<br />
Br l’ r’ -> leaves’’ (Br l’ (Br r’ r))<br />
3.10 Definition von drop:<br />
drop :: Int -> [a] -> [a]<br />
drop n (a:as) | n > 0 = drop (n-1) as -- vordefiniert<br />
drop _ as = as<br />
3.11 Rechenregeln für if:<br />
if True then e1 else e2 ⇒ e1<br />
if False then e1 else e2 ⇒ e2<br />
Schreibt man die Rechenregeln als Programm, erhält man eine Boole’sche Funktion<br />
ifThenElse.<br />
ifThenElse’ :: Bool -> a -> a -> a<br />
ifThenElse’ True a a’ = a<br />
ifThenElse’ False a a’ = a’<br />
3.12 Es ist ungeschickt, zunächst alle Parameter von take auszurechnen, da die Auswertung<br />
von x nicht terminiert.<br />
157
158 A. Lösungen aller Übungsaufgaben<br />
4.1 [Didaktische Anmerkung: (a) übt Vorbedingungen ein, (b) den Umgang mit Quantoren.]<br />
(a) Die Vorbedingung an die Argumente von merge drücken wir mittels einer<br />
Implikation aus:<br />
ordered x = True ∧ ordered y = True =⇒ ordered (merge x y) = True (A.1)<br />
bag (merge x y) = bag x ⊎ bag y (A.2)<br />
(b) Im Fall von mergeMany müssen wir „alle Elemente der Eingabeliste“ formalisieren.<br />
member :: (Eq a) => a -> [a] -> Bool<br />
member a [] = False<br />
member a (b:x) = a == b || member a x<br />
Mit a ∈ x kürzen wir member a x = True ab.<br />
((∀a ∈ x) ordered a = True) =⇒ ordered (mergeMany x) = True (A.3)<br />
] bag a | a ∈ x = bag (mergeMany x) (A.4)<br />
[ und . . . | . . . erklären.]<br />
4.2 Zusätzlich kann man fordern: Die Längen der Teillisten nehmen zu und die Länge<br />
zweier benachbarter Teillisten unterscheidet sich höchstens um 1.<br />
upstairs :: [Int] -> Bool<br />
upstairs [] = True<br />
upstairs [n] = True<br />
upstairs (n1:n2:ns) = up n1 n2 && upstairs (n2:ns)<br />
where up n1 n2 = n1 == n2 || n1+1 == n2<br />
Sei xs = split n x, dann muß zusätzlich upstairs (map length xs) gelten.<br />
Eine Liste der Länge l läßt sich damit in n − l mod n Listen der Länge ⌊l/n⌋ und in<br />
l mod n Listen der Länge ⌈l/n⌉ zerlegen.<br />
split :: Int -> [a] -> [[a]]<br />
split n as = repeatedSplit ns as<br />
where (d,r) = divMod (length as) n<br />
ns = replicate (n-r) d ++ replicate r (d+1)<br />
Die vordefinierte Funktion divMod faßt div und mod zusammen.<br />
repeatedSplit :: [Int] -> [a] -> [[a]]<br />
repeatedSplit [] as = []<br />
repeatedSplit (n:ns) as = x : repeatedSplit ns as’<br />
where (x, as’) = splitAt n as
Die vordefinierte Funktion splitAt faßt take und drop zusammen. In [GKP94] wird<br />
das folgende Verfahren vorgeschlagen:<br />
split’ n as = splitn (length as) n as<br />
splitn l 1 as = [as]<br />
splitn l n as = x : splitn (l-k) (n-1) as’<br />
where k = l ‘div‘ n<br />
(x, as’) = splitAt k as<br />
Daß die Funktion das gleiche leistet, ist nicht unmittelbar einsichtig; die interessierte<br />
Leserin sei für den Nachweis auf [GKP94] verwiesen.<br />
4.3 Definition von branches:<br />
branches :: Tree a -> Integer<br />
branches Nil = 0<br />
branches (Leaf a) = 0<br />
branches (Br l r) = 1 + branches l + branches r<br />
159<br />
4.4 Die erste Definition von leaves in Abschnitt 3.4 ist strukturell rekursiv; die zweite<br />
ist es nicht, da der rekursive Aufruf leaves (Br l’ (Br r’ r)) nicht über eine<br />
Teilstruktur des Arguments erfolgt.<br />
4.5 Die folgenden Funktionen aus Kapitel 2 sind strukturell rekursiv definiert:<br />
transponiere, wc_complement, complSingleStrand, exonuclease, genCode,<br />
ribosome, triplets, translate, findStartPositions.<br />
Dito für Kapitel 3:<br />
ifThenElse, fst, snd, (++), head, tail, reverse, minimum1, map, minimum,<br />
length, dropSpaces, squeeze, member, splitWord, minimum0’, leaves,<br />
leftist, take.<br />
4.6 Wir definieren zunächst eine Hilfsfunktion leavesTo mit der folgenden Eigenschaft.<br />
leavesTo :: Tree a -> [a] -> [a]<br />
leavesTo t y = leaves t ++ y<br />
Analog zur Vorgehensweise bei reverse läßt sich die folgende Definition von leavesTo<br />
ableiten.<br />
leavesTo :: Tree a -> [a] -> [a]<br />
leavesTo Nil y = y<br />
leavesTo (Leaf a) y = a:y<br />
leavesTo (Br l r) y = leavesTo l (leavesTo r y)
160 A. Lösungen aller Übungsaufgaben<br />
Die Funktion leaves ergibt sich wiederum durch durch Spezialisierung von leavesTo.<br />
leaves’ :: Tree a -> [a]<br />
leaves’ t = leavesTo t []<br />
4.7 Die Begründung ist hier nicht stichhaltig — erstens weil unendliche Listen nicht in<br />
einem [] enden, zweitens weil der Beweis bei der elementweisen Konstruktion nie fertig<br />
werden würde.<br />
4.10 Die Funktion complete n konstruiert einen vollständigen Baum der Tiefe n.<br />
complete :: Integer -> Tree a<br />
complete 0 = Nil<br />
complete (n+1) = Br t t<br />
where t = complete n<br />
4.11 Nachweis von Aussage 4.14. Induktionsbasis (t = Nil):<br />
branches Nil + 1<br />
= 1 (Def. branches)<br />
= size Nil (Def. size)<br />
Induktionsbasis (t = Leaf a): analog. Induktionsschritt (t = Br l r):<br />
branches (Br l r) + 1<br />
= (branches l + branches r + 1) + 1 (Def. branches)<br />
= size l + size r (I.V.)<br />
= size (Br l r) (Def. size)<br />
4.13 Die Multiplikation auf Natural:<br />
mutlN :: Natural -> Natural -> Natural<br />
mutlN Zero n = Zero<br />
mutlN (Succ m) n = addN (mutlN m n) n<br />
Möchte man — wie Harry — die üblichen Symbole zum Rechnen verwenden, muß man<br />
Natural zu einer Instanz der Typklasse Num machen.<br />
instance Num Natural where<br />
(+) = addN<br />
Zero - n = Zero<br />
Succ m - Zero = Succ m<br />
Succ m - Succ n = m-n<br />
(*) = mutlN<br />
fromInteger 0 = Zero<br />
fromInteger (n+1) = Succ (fromInteger n)
Mehr dazu in Abschnitt 6.3.<br />
4.14 Eine erste (naive) Definition könnte so aussehen:<br />
naiveBraun :: Tree a -> Bool<br />
naiveBraun Nil = True<br />
naiveBraun (Leaf a) = True<br />
naiveBraun (Br l r) = size l += size r && naiveBraun l && naiveBraun r<br />
(+=) :: (Num a) => a -> a -> Bool<br />
m += n = m == n || m+1 == n<br />
Diese Definition krankt daran, daß für jeden Teilbaum erneut die Größe ausgerechnet<br />
wird. Durch Rekursionsverstärkung läßt sich dieses Manko beheben.<br />
braun :: Tree a -> Bool<br />
braun t = fst (check t)<br />
where<br />
check :: Tree a -> (Bool, Integer)<br />
check Nil = (True, 1)<br />
check (Leaf a) = (True, 1)<br />
check (Br l r) = (sl += sr && bl && br, sl + sr)<br />
where (bl, sl) = check l<br />
(br, sr) = check r<br />
161<br />
Die Hilfsfunktion check bestimmt zwei Dinge gleichzeitig: die Braun-Eigenschaft und<br />
die Größe des Baums.<br />
4.17 Die Braun-Eigenschaft legt einen Baum in seiner Struktur fest, nicht aber in der<br />
Beschriftung der Blätter. Deshalb ist die Spezifikation nicht eindeutig. Um 4.27 nachzuweisen,<br />
zeigt man zuerst<br />
size (extend a t) = size t + 1 . (A.5)<br />
4.20 Die Fälle t = Nil und t = Leaf a folgen direkt aus den Definitionen. Fall t =<br />
Br Nil \(r\): Da weight t = 1 + weight \(r\) ist die Induktionsvorausetzung auf<br />
r anwendbar.<br />
=<br />
bag (leaves (Br (Leaf a) r))<br />
bag (a:leaves r) (Def. leaves)<br />
= a ⊎ bag (leaves r) (Def. bag)<br />
= a ⊎ bag r (I.V.)<br />
= bag (Leaf a) ⊎ bag r (Def. bag)<br />
= bag (Br (Leaf a) r) (Def. bag)
162 A. Lösungen aller Übungsaufgaben<br />
Fall t = Br (Leaf a) r: analog. Fall t = Br (Br l’ r’) r: Wir haben bereits nachgerechnet,<br />
daß die Induktionsvorausetzung auf Br l’ (Br r’ r) anwendbar ist.<br />
5.1<br />
bag (leaves (Br (Br l’ r’) r))<br />
= bag (leaves (Br l’ (Br r’ r))) (Def. leaves)<br />
= bag (Br l’ (Br r’ r)) (I.V.)<br />
= bag l’ ⊎ (bag r’ ⊎ bag r) (Def. bag)<br />
= (bag l’ ⊎ bag r’) ⊎ bag r (Ass. (⊎))<br />
= bag (Br (Br l’ r’) r)) (Def. bag)<br />
insert = \a -> \x -> case x of {<br />
[] -> a:[];<br />
b:y -> case a a:(b:x);<br />
False -> b:((insert a) y)}}<br />
5.2 Der Unterschied liegt nur im konstanten Faktor.<br />
5.6 Da weder length noch take noch drop Vergleichsoperationen verwenden, ergeben<br />
sich folgende Rekurrenzgleichungen für mergeSort:<br />
T mergeSort (0) = 0<br />
T mergeSort (1) = 0<br />
T mergeSort (n) = n − 1 + T mergeSort (⌊n/2⌋) + T mergeSort (⌈n/2⌉) für n > 1<br />
Diese Form der Rekurrenzgleichung ist typisch für Programme, die auf dem „Teile und<br />
Herrsche“-Prinzip basieren. Rechnen wir die Kostenfunktion zunächst einmal für den Fall<br />
aus, daß die Halbierung stets aufgeht:<br />
T mergeSort (2 m ) = 2 m − 1 + 2T mergeSort(2 m−1 )<br />
Einer ähnlichen Rekurrenz sind wir schon im letzten Abschnitt begegnet:<br />
T mergeSort (2 m ) = T ′<br />
sT(m) = m2 m − 2 m + 1
Die obige Formel läßt sich gut mit Hilfe des Rekursionsbaums von mergeSort illustrieren<br />
(für m = 4):<br />
1*(16-1) o o 1*(16-1)-3<br />
/ \ / \<br />
2*( 8-1) o o o o 2*( 8-1)-3<br />
/ \ / \ / \ / \<br />
4*( 4-1) o o o o o o o o 4*( 4-1)-3<br />
/\ /\ /\ /\ /\ /\ /\ /\<br />
8*( 2-1) o o o o o o o o o o o o o o o o 8*( 2-1)-3<br />
/\/\/\/\/\/\/\/\ \/\ \/\ \/\/\/\<br />
163<br />
Wenn die Länge der zu sortierenden Liste keine Potenz von 2 ist, erhalten wir einen Baum<br />
dessen unterste Ebene ausgefranst ist: Sei m = ⌈log 2 n⌉, dann fehlen 2 m −n Elemente. Um<br />
diese Anzahl verringern sich gerade die Kosten pro Ebene. Da es m Ebenen gibt, erhalten<br />
wir:<br />
6.10<br />
T mergeSort(n) = T mergeSort(2 m ) − m(2 m − n) = mn − 2 m + 1 mit m = ⌈log 2 n⌉<br />
T mergeSort (n) ∈ Θ(n log n).<br />
foldlS :: (Sequence s) => (a -> b -> a) -> a -> s b -> a<br />
foldlS (*) e = f e<br />
where f e s | isEmpty s = e<br />
| otherwise = f (e * hd s) (tl s)<br />
foldr1S :: (Sequence s) => (a -> a -> a) -> s a -> a<br />
foldr1S (*) s<br />
| isSingle s = hd s<br />
| otherwise = hd s * foldr1S (*) (tl s)<br />
Beispiele:<br />
rev :: (Sequence s, Sequence t) => s a -> t a<br />
rev = foldlS (flip (
164 A. Lösungen aller Übungsaufgaben<br />
6.12 Im ersten Fall muss man dafür sorgen, daß zur Konstruktion von Sequenzen nur die<br />
Konstruktoren der Klasse, aber nicht unmittelbar die des Datentyps verwendet werden.<br />
Im zweiten Fall können sehr ineffiziente Repräsentationen entstehen.<br />
6.14 – Nur Vorüberlegungen zum Spezialfall!<br />
split :: GoedelNr -> (Integer,GoedelNr)<br />
split (G m) = splt primes 0 m<br />
where<br />
splt (p:ps) e m<br />
| m ‘mod‘ p == 0 = splt (p:ps) (e+1) (m ‘div‘ p)<br />
| e == 0 = splt ps 0 m<br />
| otherwise = (e, G m)<br />
Achtung: bei split (G m) = (u,G v) ist (G v) NICHT die GoedelNr. der verkürzten<br />
Liste.<br />
degoedelize :: GoedelNr -> [Integer]<br />
degoedelize (G m) | m == 1 = []<br />
| otherwise = n:degoedelize ns<br />
where (n,ns) = split (G m)<br />
instance (Integral a) => Sequence (GoedelNr a )<br />
where empty = G 1<br />
isEmpty (G m) = (m == 1)<br />
hd (G m) = n<br />
where (n,ns) = split (G m)<br />
tl (G m) = ns<br />
where (n,ns) = split (G m)<br />
(G m) |> n = let k = len (G m)<br />
in G (m* (primes !! (k+1))ˆn)<br />
(G m) (G n) | isEmpty (G n) = (G m)<br />
| otherwise = ((G m) |> a) (G as)<br />
where (a:as) = split n<br />
fromList = goedelize<br />
toList = degoedelize
6.15<br />
instance Sequence LList where<br />
empty = LL id<br />
isEmpty (LL s) = isEmpty (s [])<br />
single a = LL (a:)<br />
isSingle (LL s) = isSingle (s [])<br />
(LL s) (LL t) = LL (s . t)<br />
hd (LL s) = head (s [])<br />
tl (LL s) = LL h where<br />
h r = tail (s r)<br />
len (LL s) = len (s [])<br />
165
166 A. Lösungen aller Übungsaufgaben
B. Mathematische Grundlagen<br />
oder: Wieviel Mathematik ist nötig?<br />
B.1. Mengen und Funktionen<br />
B.1.1. Der Begriff der Menge<br />
Eine Menge ist eine abstrakte Zusammenfassung unterschiedener Objekte. Wir schreiben<br />
x ∈ M, wenn x ein Element von M ist, und x /∈ M, wenn x kein Element von M ist.<br />
In der Schule haben wir es häufig mit Mengen von Zahlen zu tun. Wir verwenden N<br />
für die Menge der natürlichen Zahlen 1 , Z für die Menge der ganzen Zahlen und R für die<br />
Menge der reellen Zahlen.<br />
Mengen werden z.B. durch Aufzählung definiert.<br />
N37 := {3, 4, 5, 6, 7}<br />
HS := {Harry, Sally}<br />
M1 := {M, A, T, H, E, I, K} (Buchstaben des Wortes MATHEMATIK)<br />
Bool := {False, True} (Menge der „Wahrheitswerte“)<br />
Abstrakt ist die Menge als Zusammenfassung ihrer Elemente in dreifacher Hinsicht:<br />
1. die Elemente stehen in keiner besonderen Anordnung, z.B. {M, A, T, H, E, I, K} =<br />
{A, E, I, H, K, T, M},<br />
2. die Elemente gehören der Menge nicht mit unterschiedlicher Intensität oder Häufigkeit<br />
an, z.B. {M, A, T, H, E, I, K} = {M, A, T, H, E, M, A, T, I, K},<br />
3. es wird nicht verlangt, daß die zusammengefaßten Elemente überhaupt in irgendeinem<br />
logischen Zusammenhang stehen.<br />
Der letzte Punkt bedeutet, daß mathematisch gegen die Definition der Menge<br />
Allerlei := {Lisa Listig’s PC, die Oper „Don Giovanni“,<br />
der Lombardsatz der Deutschen Bundesbank}<br />
1 Im Unterschied zum Gebrauch in der Mathematik zählt die Informatik die 0 zu den natürlichen Zahlen.
168 B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?<br />
nichts einzuwenden ist, obwohl man sich schwerlich einen Zusammenhang vorstellen<br />
kann, indem diese Menge kein Unfug ist. Diese Gleichgültigkeit in bezug darauf, welche<br />
Art von Objekten man in eine Menge faßt, darf man allerdings nicht ungestraft auf die<br />
Spitze treiben. Als Russell’sche Antinomie berühmt ist die Menge aller Mengen, die sich<br />
selbst nicht enthalten. Solche Gefahren werden für uns keine Rolle spielen und wir gehen<br />
nicht weiter auf sie ein.<br />
Mengen werden oft durch Angabe einer charakterisierenden Eigenschaft definiert.<br />
Prim := { x | x ∈ N und x ist Primzahl }<br />
N37 := { x | x ∈ N und 3 x 7 }<br />
NullSt := { x | x 5 − x 3 = 8x 2 − 9 = 0 }<br />
N := { x | x = 0 oder (x = y + 1 und y ∈ N) }<br />
Eine Definition durch eine charakteristische Eigenschaft unterstellt eine umfassendere<br />
Menge, deren Elemente auf diese Eigenschaft hin zu prüfen sind. Bei Prim und N37 ist<br />
diese Menge N. Die Definition von NullSt ist gerade deswegen anfechtbar, weil die Bezugsmenge<br />
nicht genannt wird. Sind ganze, reelle oder komplexe Nullstellen gemeint? Die<br />
hier gegebene Definition von N dagegen bezieht sich auf sich selbst und setzt dabei das<br />
Bildungsgesetz der natürlichen Zahlen — das Zählen — voraus. Solche Definitionen nennt<br />
man rekursiv. Das Zählen ist nichts als der Übergang von einer natürlichen Zahl zur nächsten.<br />
Wir haben es hier durch die Operation +1 bezeichnet, so daß unsere natürlichen<br />
Zahlen nach dieser Definition statt des vertrauten 0, 1, 2 . . . das ungewöhnliche Aussehen<br />
0, 0 + 1, (0 + 1) + 1 usw. haben.<br />
Eine Menge M ist Teilmenge von N (M ⊆ N), wenn jedes Element von M auch Element<br />
von N ist.<br />
M ⊆ N ⇐⇒ (∀a) a ∈ M ⇒ a ∈ N<br />
Die Vereinigung von M und N (M ∪ N) ist definiert durch<br />
M ∪ N = { a | a ∈ M oder a ∈ N }<br />
Dabei ist das „oder“ nicht ausschließend zu verstehen. Der Durchschnitt von M und N<br />
(M ∩ N) ist definiert durch<br />
M ∩ N = { a | a ∈ M und a ∈ N }.<br />
Mit den gegebenen Definitionen läßt sich z.B. zeigen, daß ∪ und ∩ assoziative Operationen<br />
sind:<br />
K ∪ (M ∪ N) = (K ∪ M) ∪ N<br />
K ∩ (M ∩ N) = (K ∩ M) ∩ N
B.2. Relationen und Halbordnungen 169<br />
Die Potenzmenge einer Menge M ist die Menge aller Teilmengen von M:<br />
P(M) = { T | T ⊆ M }<br />
Da für alle Mengen M stets ∅ ⊆ M und M ⊆ M gilt, enthält P(M) stets ∅ und M. Das<br />
Cartesische Produkt zweier Mengen M und N ist die Menge aller möglichen Elementpaare:<br />
M × N = { (x, y) | x ∈ M und y ∈ N }<br />
Das Cartesische Produkt verallgemeinert man auf n 2 Mengen und spricht dann von<br />
n-Tupeln.<br />
B.1.2. Der Begriff der Funktion<br />
Seien A, B Mengen. Eine partielle Funktion f von A nach B hat den Vorbereich A, den<br />
Nachbereich B und ordnet gewissen Elementen aus A (den Argumenten) jeweils ein Element<br />
aus B zu (den Wert). Man schreibt f : A → B und f(a) = b oder f : a ↦→ b. Sind<br />
A und B Mengen, so bezeichnet A → B die Menge der partiellen Funktionen mit Vorbereich<br />
A und Nachbereich B. Ist die Menge A ein kartesisches Produkt A = (A1, . . . , An),<br />
so nennt man f auch n-stellige Funktion.<br />
Die Menge<br />
Def (f) := { a ∈ A | es gibt b ∈ B mit f(a) = b }<br />
heißt Definitionsbereich von f, die Menge<br />
Im(f) := { b ∈ B | es gibt a ∈ A mit f(a) = b }<br />
heißt Bildbereich von f. Ist Def (f) = A, heißt f totale Funktion.<br />
Bekannte Beispiele von Funktionen sind die Grundrechnungsarten, Polynome, trigonometrische<br />
Funktionen usw. über R. Davon sind z.B. Division und Tangens partielle Funktionen,<br />
da Divisor 0 bzw. π<br />
2 nicht zum Definitionsbereich gehören. Das Differenzieren<br />
kann man selbst als Funktion (·) ′ ansehen:<br />
(·) ′<br />
: (R → R) → (R → R).<br />
B.2. Relationen und Halbordnungen<br />
Eine Funktion ordnet gewissen Elementen des Vorbereichs ein Element des Nachbereichs<br />
zu. Relationen geben im Unterschied dazu beliebige Beziehungen zwischen zwei Mengen
170 B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?<br />
an: Einem Element des Vorbereichs können durchaus mehrere Elemente des Nachbereichs<br />
zugeordnet werden. Formal ist eine zweistellige Relation eine Teilmenge R ⊆ M × N. Statt<br />
(a, b) ∈ R schreibt man auch oft aRb.<br />
Eine Relation R ⊆ M × M heißt Halbordnung auf M, wenn R<br />
1. reflexiv, (∀x ∈ M) xRx,<br />
2. antisymmetrisch, (∀x, y ∈ M) (xRy und yRx) ⇒ x = y, und<br />
3. transitiv, (∀x, y, z ∈ M) (xRy und yRz) ⇒ xRz, ist.<br />
Ein Beispiel für eine Halbordnung ist etwa die Teilmengenbeziehung (⊆). Das Wörtchen<br />
„Halb“ deutet an, daß es in der Regel Elemente gibt, die nicht miteinander vergleichbar<br />
sind: Die Mengen {1, 2} und {2, 3} sind z.B. unvergleichbar. Ist jedes Element mit jedem<br />
vergleichbar, spricht man von einer totalen Ordnung.<br />
B.3. Formale Logik<br />
B.3.1. Aussagenlogik<br />
Um Sachverhalte, Eigenschaften etc. präzise aufschreiben zu können, verwendet man aussagenlogische<br />
bzw. prädikatenlogische Formeln. Wir haben selbst schon einige Formeln<br />
verwendet, um z.B. die Mengeninklusion (⊆) oder Eigenschaften von Relationen (Transitivität)<br />
zu notieren.<br />
In der klassischen Logik ist eine Formel entweder wahr oder falsch. Für die Menge<br />
dieser Wahrheitswerte sind verschiedene Schreibweisen gebräuchlich:<br />
Bool = {False, True} (in der Programmierung)<br />
= {0, 1} (im Hardware-Entwurf)<br />
= {F, W } (in der formalen Logik).<br />
Die wichtigsten Boole’schen Funktionen sind „und“ (∧), „oder“ (∨) und „nicht“ (¬), definiert<br />
durch die folgende Wertetabelle:<br />
a b a ∧ b a ∨ b ¬a<br />
False False False False True<br />
False True False True True<br />
True False False True False<br />
True True True True False
B.3. Formale Logik 171<br />
Eine Formel ist aus False, True, ∧, ∨, ¬ und Variablen zusammengesetzt. Wir können<br />
„und“, „oder“ und „nicht“ auch durch Gleichungen definieren:<br />
False ∧ x = False<br />
True ∧ x = x<br />
False ∨ x = x<br />
True ∨ x = True<br />
¬False = True<br />
¬True = False<br />
Durch eine vollständige Fallunterscheidung über das erste Argument sind ∧, ∨ und ¬<br />
totale Funktionen. Mit Hilfe dieser Definitionen zeigt man wichtige Eigenschaften: Für<br />
alle a, b, c ∈ Bool gilt z.B.:<br />
a ∧ a = a (Idempotenz)<br />
(a ∧ b) ∧ c = a ∧ (b ∧ c) (Assoziativität)<br />
a ∧ b = b ∧ a (Kommutativität)<br />
a ∧ (a ∨ b) = a (Absorption)<br />
a ∧ (b ∨ c) = (a ∧ b) ∨ (a ∧ c) (Distributivität)<br />
¬¬a = a (doppelte Negation)<br />
Interessanterweise gelten die Gleichungen auch, wenn man konsistent ∧ durch ∨ und ∨<br />
durch ∧ ersetzt. Wir beweisen das Absorptionsgesetz: Fall a = False:<br />
False ∧ (False ∨ b) = False (Definition von ∧)<br />
Fall a = True:<br />
True ∧ (True ∨ b) = True ∨ b (Definition von ∧)<br />
= True (Definition von ∨)<br />
In Formeln verwendet man oft noch weitere Boole’schen Funktionen wie ⇒ (Implikation)<br />
und ⇐⇒ (Äquivalenz), die sich einfach auf die bisher bekannten zurückführen lassen.<br />
a ⇒ b = ¬a ∨ b<br />
a ⇐⇒ b = (a ⇒ b) ∧ (b ⇒ a)<br />
Zur Übung: Wie sehen die Wahrheitstafeln von ⇒ und ⇐⇒ aus? Beachte: ⇐⇒<br />
formalisiert die Gleichheit Boole’scher Werte.<br />
B.3.2. Prädikatenlogik<br />
Formeln sind natürlich nicht nur aus False, True, ∧ etc. zusammengesetzt. Interessant wird<br />
es erst, wenn man auch Aussagen über Individuen machen kann, wie z.B. in x ∈ M ⇒<br />
x ∈ N. Hier ist x eine Individuenvariable, ein Platzhalter für Individuen. Häufig macht<br />
man Aussagen, daß es mindestens ein Individuum mit einer bestimmten Eigenschaft gibt,
172 B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?<br />
oder daß eine Aussage für alle Individuen gilt. Zu diesem Zweck verwendet man den<br />
Existenzquantor (∃) und den Allquantor (∀).<br />
(∃x) Φ(x)<br />
(∀x) Φ(x)<br />
Hier ist Φ(X) irgendeine Aussage über x, z.B. Φ(x) = x ∈ M ⇒ x ∈ N. Gebräuchlich sind<br />
auch Quantoren, bei denen der Individuenbereich direkt angegeben wird:<br />
(∃x ∈ M) Φ(x) ⇐⇒ (∃x) x ∈ M ∧ Φ(x)<br />
(∀x ∈ M) Φ(x) ⇐⇒ (∀x) x ∈ M ⇒ Φ(x)<br />
B.3.3. Natürliche und vollständige Induktion<br />
Nehmen wir an, Φ(n) ist eine Eigenschaft, die wir für alle natürlichen Zahlen n nachweisen<br />
wollen. Dazu kann man das Prinzip der natürlichen Induktion 2 verwenden:<br />
Induktionsbasis: Wir zeigen Φ(0).<br />
Induktionsschritt: Wir nehmen an, daß Φ(n) gilt (Induktionsvoraussetzung) und zeigen<br />
unter dieser Annahme, daß auch Φ(n + 1) gilt.<br />
Diese Beweisregel kann man formal wie folgt darstellen:<br />
Φ(0) (∀n ∈ N) Φ(n) ⇒ Φ(n + 1)<br />
(∀n ∈ N) Φ(n)<br />
Über dem Strich werden die Voraussetzungen aufgeführt, unter dem Strich steht die<br />
Schlußfolgerung, die man daraus ziehen kann. Die Beweisregel kann man auch als Arbeitsprogramm<br />
lesen (von unten nach oben): Um · · · zu zeigen, muß man · · · zeigen.<br />
Warum leistet die natürliche Induktion das Gewünschte? Oder: Warum ist die natürliche<br />
Induktion korrekt. Klar, es gilt Φ(0), mit dem Induktionsschritt erhalten wir Φ(1), durch<br />
erneute Anwendung Φ(2) etc. Auf diese Weise erhalten wir Φ(n) für jede natürliche Zahl.<br />
Wir demonstrieren das Induktionsprinzip an einem einfachen Beispiel:<br />
Φ(n) ⇐⇒<br />
nX<br />
2 i = 2 n+1 − 1<br />
i=0<br />
2 Andere Bücher, andere Begriffe: „Natürliche Induktion“ wird auch „mathematische Induktion“ genannt. Dieser<br />
Begriff sagt allerdings wenig aus: Sind andere Induktionsprinzipien etwa unmathematisch? Leider ist auch der<br />
Name „vollständige Induktion“ gebräuchlich. Leider deshalb, weil wir ihn für ein anderes Induktionsprinzip<br />
reservieren.
B.3. Formale Logik 173<br />
Induktionsbasis: Die Rechnung ist einfach 0 i=0 2i = 1 = 21 − 1. Induktionsschritt:<br />
n+1 X<br />
2 i = 2 n+1 nX<br />
+ 2 i<br />
(Arithmetik)<br />
i=0<br />
i=0<br />
= 2 n+1 + 2 n+1 − 1 (Induktionsvoraussetzung)<br />
= 2 n+2 − 1 (Arithmetik)<br />
Bei der natürlichen Induktion stützt man sich beim Induktionsschritt auf die Voraussetzung<br />
ab, daß die Aussage für den unmittelbaren Vorgänger gilt. Wir können großzügiger<br />
sein und verwenden, daß die Aussage für alle kleineren Zahlen gilt. Dies formalisiert das<br />
Prinzip der vollständigen Induktion.<br />
(∀m) ((∀m ′ < m) Φ(m ′ )) ⇒ Φ(m)<br />
(∀n) Φ(n)<br />
Zur Übung: Sind beide Induktionsprinzipien gleich mächtig?
174 B. Mathematische Grundlagenoder: Wieviel Mathematik ist nötig?
Literaturverzeichnis<br />
[GKP94] Ronald L. Graham, Donald E. Knuth, and Oren Patashnik. Concrete mathematics.<br />
Addison-Wesley Publishing Company, Reading, Massachusetts, second edition<br />
edition, 1994.