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