31.08.2013 Aufrufe

(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

MEHR ANZEIGEN
WENIGER ANZEIGEN

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.

Hurra! Ihre Datei wurde hochgeladen und ist bereit für die Veröffentlichung.

Erfolgreich gespeichert!

Leider ist etwas schief gelaufen!