250 23 5MB
German Pages [284] Year 2009
Fundamente der Informatik Ablaufmodellierung, Algorithmen und Datenstrukturen Von Peter Hubwieser und Gerd Aiglstorfer
Oldenbourg Verlag München Wien
Prof. Dr. Peter Hubwieser Dipl. Inform. Gerd Aiglstorfer Fakultät für Informatik der TU München Technische Universität München Boltzmannstr. 3 85748 Garching [email protected] [email protected]
Bibliografische Information Der Deutschen Bibliothek Die Deutsche Bibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über abrufbar.
© 2004 Oldenbourg Wissenschaftsverlag GmbH Rosenheimer Straße 145, D-81671 München Telefon: (089) 45051-0 www.oldenbourg-verlag.de Das Werk einschließlich aller Abbildungen ist urheberrechtlich geschützt. Jede Verwertung außerhalb der G renzen de s U rheberrechtsgesetzes is t ohne Zus timmung de s Ve rlages unzulässig und strafbar. Das gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Bearbeitung in elektronischen Systemen. Lektorat: Margit Roth Herstellung: Rainer Hartl Umschlagkonzeption: Kraxenberger Kommunikationshaus, München Gedruckt auf säure- und chlorfreiem Papier Druck: Grafik + Druck, München Bindung: R. Oldenbourg Graphische Betriebe Binderei GmbH ISBN 3-486-27572-0
Inhalt Inhal
t
V
Vorwort X
I
1. Teil Ablaufmodellierung
1
1
Modellierung von Abläufen
3
1.1
Modellierung als Arbeitstechnik ................................................................................ 3
1.2
Algorithmische Modellierung .................................................................................... 7
1.3
Aktionsstrukturen ....................................................................................................... 9
1.4
Zustandsmodellierung .............................................................................................. 11
1.5
Aufgaben .................................................................................................................. 12
2
Zustandsmodellierung 13
2.1
Zustandsdiagramme.................................................................................................. 13
2.2
Syntaxprüfung .......................................................................................................... 15
2.3
Aufgaben .................................................................................................................. 17
3
Algorithmen 19
3.1
Der Begriff „Algorithmus“....................................................................................... 19
3.2
Struktur von Algorithmen ........................................................................................ 22
3.3
Umsetzung in Programmiersprachen........................................................................ 24
3.4
Eigenschaften von Algorithmen ............................................................................... 25
3.5
Pseudocode und Struktogramme .............................................................................. 27
3.6
Aufgaben .................................................................................................................. 29
4
Programmiersprachen 31
4.1
Programmierbare Rechner........................................................................................ 31
4.2
Programmiersprachen und Maschinencode.............................................................. 32
VI
Inhalt
4.3
Übersetzerprogramme ...............................................................................................32
4.4
Syntax formaler Sprachen .........................................................................................33
4.5
Backus-Naur-Form....................................................................................................35
4.6
Syntaxprüfung mit endlichen Automaten..................................................................39
4.7
Die Ebenen der Softwareentwicklung.......................................................................40
4.8
Aufgaben...................................................................................................................41
5
Imperative Programmierung
5.1
Sprachen und Programmierumgebungen ..................................................................43
5.2
Das Variablenkonzept ...............................................................................................44
5.3
Einfache Sorten .........................................................................................................45
5.4
Zuweisung als Zustandsübergang .............................................................................47
5.5
Ein- und Ausgabeoperationen ...................................................................................50
5.6
Programme ................................................................................................................51
5.7 5.7.1 5.7.2 5.7.3
Zusammengesetzte Anweisungen .............................................................................52 Sequenzen .................................................................................................................52 Bedingte Anweisung bzw. Alternative......................................................................55 Wiederholung von Anweisungen ..............................................................................56
5.8 5.8.1 5.8.2 5.8.3
Zusammengesetzte Sorten.........................................................................................59 Felder (Arrays)..........................................................................................................59 Verbunde (Records) ..................................................................................................60 Tabellen als Kombination von Feldern und Records ................................................61
5.9 5.9.1 5.9.2 5.9.3 5.9.4 5.9.5 5.9.6
Programmieren in Python .........................................................................................61 Python als Taschenrechner........................................................................................62 Variablen, Vergleich und Ausgabeanweisung ..........................................................62 Sorten und Typen ......................................................................................................63 Ein- und Ausgabe......................................................................................................63 Zusammengesetzte Anweisungen .............................................................................66 Zusammengesetzte Sorten.........................................................................................69
5.10
Aufgaben...................................................................................................................71
6
Funktionale Modellierung
6.1
Datenflussdiagramme und Programme .....................................................................76
6.2
Aufteilung von Programmen in Unterprogramme ....................................................78
6.3
Deklaration und Aufruf von Prozeduren ...................................................................80
6.4
Globale und lokale Variable......................................................................................81
6.5
Bindung und Gültigkeit.............................................................................................83
43
75
Inhalt
VII
6.6
Parameter.................................................................................................................. 85
6.7 6.7.1 6.7.2 6.7.3
Ergebnisübergabe ..................................................................................................... 86 Schreibzugriff auf globale Variable ......................................................................... 86 Ausgangsparameter .................................................................................................. 87 Funktionskonzept ..................................................................................................... 88
6.8
Module ..................................................................................................................... 89
6.9
Unterprogramme in Python ...................................................................................... 90
6.10
Module in Python ..................................................................................................... 92
6.11
Aufgaben .................................................................................................................. 93
7
Funktionale Programmierung
7.1 7.1.1 7.1.2 7.1.3 7.1.4
Das Programm als Term........................................................................................... 98 Die Auswertung von Termen ................................................................................... 99 Terme, Funktionen und funktionale Programme.................................................... 100 Variable und Parameter .......................................................................................... 103 Terme und Datenflussdiagramme........................................................................... 103
7.2
Sortendeklarationen................................................................................................ 104
7.3
Sequenzen von Verarbeitungsschritten .................................................................. 106
7.4
Bedingte Terme ...................................................................................................... 107
7.5
Programmieren in Haskell...................................................................................... 108
7.6 7.6.1 7.6.2
Rekursive Strukturen.............................................................................................. 112 Rekursive Datenstrukturen ..................................................................................... 113 Rekursive Funktionen............................................................................................. 114
7.7
Parametrisierung von Datenstrukturen ................................................................... 116
7.8
Rekursive Funktionen und Datentypen in Haskell ................................................. 117
7.9 7.9.1 7.9.2 7.9.3 7.9.4 7.9.5
Formen der Rekursion ............................................................................................ 119 Dynamische Datenflussdiagramme ........................................................................ 120 Lineare Rekursion .................................................................................................. 121 Kaskadenartige Rekursion...................................................................................... 122 Vernestete Rekursion ............................................................................................. 125 Verschränkte Rekursion ......................................................................................... 128
7.10 7.10.1 7.10.2
Funktionen höherer Ordnung ................................................................................. 129 Funktionen als Argumente ..................................................................................... 130 Funktionen als Funktionswerte............................................................................... 132
7.11
Aufgaben ................................................................................................................ 136
8
Rekursion und Iteration
8.1
Iterative Darstellung repetitiver Rekursion ............................................................ 141
97
141
VIII
Inhalt
8.2
Darstellung linear rekursiver Funktionen................................................................143
8.3
Kellerspeicher (Stacks) ...........................................................................................146
8.4
Aufgaben.................................................................................................................151
2. Teil Algorithmen und Datenstrukturen
153
9
Grundlegendes 155
9.1 9.1.1 9.1.2
Rekursion ................................................................................................................156 Lineare Rekursion ...................................................................................................157 Vernestete Rekursion ..............................................................................................158
9.2 9.2.1 9.2.2 9.2.3
Asymptotische Analyse...........................................................................................159 Komplexitätsmaße ..................................................................................................160 Wachstumsverhalten von Funktionen .....................................................................162 Berechnung des Wachstums von rekursiven Funktionen........................................165
9.3
Aufgaben.................................................................................................................166
10
(Basis-) Datenstrukturen
10.1
Abstrakte Datentypen..............................................................................................167
10.2
Die Datenstruktur der Sequenzen............................................................................170
10.3
Die Datenstruktur der Warteschlangen ...................................................................171
10.4
Die Datenstruktur der Keller...................................................................................173
10.5
Die Datenstruktur der Binärbäume .........................................................................174
10.6 10.6.1 10.6.2 10.6.3
Verkettete Listen .....................................................................................................175 Zeiger ......................................................................................................................175 Einfach verkettete Listen.........................................................................................177 Zweifach verkettete Listen ......................................................................................179
10.7
Aufgaben.................................................................................................................181
11
Sortieren und Suchen
11.1
Sortieren durch Einfügen ........................................................................................183
11.2
Sortieren durch Auswählen .....................................................................................185
11.3
Bubblesort ...............................................................................................................187
11.4
Quicksort.................................................................................................................188
11.5 11.5.1 11.5.2
Heapsort ..................................................................................................................191 Heaps.......................................................................................................................191 Der Heapsort-Algorithmus......................................................................................196
11.6
Sequentielle Suche ..................................................................................................198
11.7
Binäre Suche ...........................................................................................................199
167
183
Inhalt
IX
11.8
Interpolationssuche................................................................................................. 200
11.9
Binärbaumsuche ..................................................................................................... 202
11.10
Aufgaben ................................................................................................................ 203
12
Hashing 205
12.1
Grundlagen ............................................................................................................. 205
12.2
Eine einfache Hashfunktion ................................................................................... 207
12.3
Perfektes Hashing................................................................................................... 207
12.4
Universelles Hashing.............................................................................................. 208
12.5
Chainingverfahren .................................................................................................. 210
12.6 12.6.1 12.6.2
Hashing mit offener Adressierung.......................................................................... 212 Lineares Sondieren ................................................................................................. 214 Quadratisches Sondieren ........................................................................................ 216
12.7
Aufgaben ................................................................................................................ 218
13
Bäume 21
13.1
Vor-, In- und Postordnung von Binärbäumen ........................................................ 219
13.2
AVL-Baum............................................................................................................. 221
13.3 13.3.1 13.3.2
Vorrangwarteschlangen.......................................................................................... 228 Binomial Queue...................................................................................................... 229 Fibonacci-Heap ...................................................................................................... 233
13.4
(a, b)-Baum............................................................................................................. 238
13.5
Aufgaben ................................................................................................................ 241
14
Graphen 243
14.1
Grundlagen ............................................................................................................. 243
14.2 14.2.1 14.2.2
Traversierung von Graphen.................................................................................... 245 Breitensuche (BFS-Algorithmus)........................................................................... 246 Tiefensuche (DFS-Algorithmus) ............................................................................ 247
14.3
Kürzeste Pfade (Dijkstra’s Algorithmus) ............................................................... 248
14.4
Minimale Spannbäume (Prim’s Algorithmus) ....................................................... 253
14.5
Aufgaben ................................................................................................................ 256
15
Allgemeine Optimierungsmethoden
15.1
Dynamisches Programmieren................................................................................. 259
15.2
Greedy-Algorithmen .............................................................................................. 261
9
259
X 15.3
Inhalt Backtracking ...........................................................................................................262 Anhang 2
65
16
Die Pseudo-Programmiersprachen PPS und FPPS 26
16.1
Merkblatt zu PPS ....................................................................................................267
16.2
Merkblatt zu FPPS..................................................................................................269 Literatur 271 Index 273
7
Vorwort Sowohl im Beruf wie auch i m Privatleben habe n wir es mit immer komplexeren Systemen und Abläufen zu tun: von komplizierten gesetzlichen Regelungen über immer neue St euervorschriften bis zu elektronischen System en. Von der Steuere rklärung übe r Heim computer und Videorekorder bis zur Elektronik im Auto oder zum Mobiltelefon fällt es uns oft nicht leicht, die Bedienung oder die Funktionsweise dieser Systeme auf Anhieb zu durchschauen. Die I nformatiker haben es sich von der E ntstehung I hres Fachgebietes a n z ur A ufgabe gemacht, Kom plexität zu thematisieren, zu messen un d Algo rithmen mit möglichst geringe r Komplexität zu entwickeln. Sie haben es i mmer schon m it so komplizierten Systemen (z.B. Rechenanlagen, Rechnernetzen ode r Software systemen) zu tun, dass s ie mit alltäglichen Mitteln (wie Sprache oder informellen Zeichnunge n) nicht mehr ausreichend genau beschrieben we rden können. Daher haben s ie im Lauf de r Zeit eine Vielzahl von Be schreibungs- (oder Modellierungs-) Techniken entwickelt, um solche Systeme besser strukturieren zu können. Diese Kenntnisse und Fertigkeiten können aber auch auß erhalb des eigentlichen Fachgebietes der Informatik sehr wertvolle Dienste leisten, we nn es darum geht, komplexe Systeme jeglicher Art zu beschreiben oder über sie zu kom munizieren. Deshal b tauc hen solche Modellierungstechniken als Lerninhalte zunehmend in der Schulausbildung auf, etwa im neuen Lehr plan f ür das P flichtfach Inf ormatik an bayerische n Gym nasien. Dem zufolge müssen sich die zukünftigen Informatiklehrerinnen und -lehrer (natürlich noch intensiver als später ihre Schülerinnen und Schüler) mit diesen Techniken und Konzepten auseinandersetzen. Aus diesen Überlegungen heraus haben sich Modellierungstechniken mittlerweile zum zentralen Thema der Lehreraus- und -weiterbildung in Bayern entwickelt. Die Entstehung Dieses Buch ist aus dem Beitrag der Technischen Universität München zu einer Weiterbildungsinitiative der bayerischen Staatsregierung (SIGNAL für Sofortprogramm Informatik am Gymnasium – Nachqualifikation von Lehrkräften) hervor gegangen. Inner halb dieser Initiative werden seit Herbst 2001 ca. 300 berufstätige Gymnasiallehrerinnen und -lehrer in zweijährigen Kursen an fünf Un iversitäten (TU und LMU Mün chen, Er langen-Nürnberg, Würzburg und Passau) zum Staatsexamen in Informatik geführt. Im ersten Kursjahr liegt die Betonung dabei auf betreutem Selbststudium, während das zweite Kursjahr auf wöchentliche Präsenzveranstaltungen setzt. Das Material für diese Initiative wurde in sehr unterschiedlicher Form von de n teilnehmenden Universitäten beigesteuert. Der Beitrag der Technischen Universität München bestand u.a. (neben der Konzeption und de r Ges amtkoordination der Initiative weiteren Modulen zur Technischen Informatik) aus einem Modul zum Them enbereich Ablaufmodellierung für das erste Studienjahr und einem Präsenzmodul zu Algorithmen
XII
Vorwort
und Datenstrukturen. Beide Module wurden inzwischen von zwei Kursgenerationen durchlaufen und haben damit einen Reifegrad erreicht, der eine Publikation in Form eines (dieses!) Lehrbuchs ermöglichte. Neben der Lehrerbildung haben Themen aus dem Bereich Modellierung auch massiven Einzug in die In formatikausbildung von Studierenden a nderer Fäc her (als der Informatik) ge funden. Die B egründung liegt natürlich in i hrem (oben be schriebenen) allgemein bildenden Wert: was f ür Schülerinnen und Sc hüler allg emein bildender Sc hulen notwendig erscheint, kann auch für zukünftige Ingenieure oder Betrie bswirte nützlich sein. So habe n auch einige Inhalte aus unserer Vorlesung für diesen Hörerkreis Einzug in dieses Buch gehalten. Die Zielgruppen Neben zukünftigen Lehrkräf ten und Stud ierenden de r Informatik im Nebenfac h em pfehlen wir das B uch aber a uch „ Vollblutinformatikern“ als Vorbereitung oder Ergä nzung zu den üblichen Einführungs vorlesungen. Da der Modellierung in der Inform atikausbildung leider nicht immer der Stellenwert zukommt, der ihr aufgrund ihrer e normen Bedeutung im späteren beruflichen Leben zustünde, kann eine Vertiefung und Systematisierung hier keinesfalls schaden. Wir erheben allerdings nic ht den Anspruch, mit diesem Buch die Breite und T iefe der o.g. Anfängervorlesungen abzudecken. Der Inhalt Zur Beschreibung eines Informatiksystems (darunter versteht m an eine Kom bination au s Hard- un d Softwarekom ponenten) sind Aussagen über di e zwei wesentlichen Aspekte der Informationsverarbeitung notwendig: 1. Struktur der Daten: „Was wird verarbeitet?“ 2. (dynamisches) zeitliches Verhalten: „Wie läuft die Verarbeitung ab? “ Die Struktur der Daten kann mit der klass ischen, im Datenba nkbereich sehr ve rbreiteten Entity-Relationship-Modellierung oder mit Objekt- und Klassendiagrammen (wie etwa in der Unified Modeling Language UML) besc hrieben werden. Der ge plante zweite Band dieser Reihe beschäftigt sich im Rahmen der Datenbanktheorie und der Objektorientierten Modellierung mit genau diesen Techniken. Wir werden uns in diesem ersten Band am Anfang (im ersten Teil) auf die M odellierung von Abläufen (also des zeitlichen Verhaltens) konzentrieren un d Daten(-strukturen) nu r do rt b esprechen, wo e s f ür die Be schreibung bestim mter Verarbeitungsvorgänge au s technischer Sich t no twendig ist. Im zweiten Teil dieses B andes werden Sie dann einige typische Datenstrukturen (und natürlich auch Algorithmen über diesen) kennen l ernen, die es erlauben, sc hwierige Aufgabenstellungen effizient (oder evtl. überhaupt) zu lösen. Anhand dieser Algorithmen wird auch der o.g. Begriff der Komplexität präzisiert und quantifiziert werden. Die einzelnen Kapitel sind folgenden Themen gewidmet: Nach einem kurzen Überblick über drei ve rschiedene Tec hniken zur Besc hreibung von A bläufen in Kapitel 1 wer den wi r uns zwei davon genauer ansehe n: in Kapitel 2 die Zustandsm odellierung und in Kapitel 3 die Modellierung mit Hilfe von Algorithmen. In Kapitel 4 und 5 werden Sie eine im perative
Vorwort
XIII
Programmiersprache kennen lernen, mit der Sie die Abläufe sowohl von Zustandsmodellen wie auch von Algo rithmen auf einem Rechne r sim ulieren können. Kapitel 6 führt in die funktionale M odellierung ei n. In Kapitel 7 er folgt eine Heranführung an den funktionalen Programmierstil, in dem funktionale Mode lle direkt in lauffähi ge Programme u mgesetzt werden könne n. Ka pitel 8 widm et sich der Um wandlung von re kursiven in iterative Programmstrukturen. Auf der Grundlage der Einführung in funktionale und imperative Programmierkonzepte führt Kapitel 9 in das Gebiet „Al gorithmen und Datenst rukturen“ ein. Mit Hilfe der asy mptotischen Analyse und den grundlege nden Dat enstrukturen a us Kapitel 10 lernt der Lese r in Kapitel 11 wichtige Sortier- und Suchverfahren der Informatik kennen. Eine effiziente Form des Suchens stellt das Hashing in Kapitel 12 dar. Die Kapitel über Bäume und Graphen führen in bekannte Datenstrukturen und Algorithmen dieser Art ein. Zum Abschluss geben wir in Kapitel 15 noch einen Ausblick auf allg emeine Optimierungsmethoden zum Entwurf von Algorithmen. Um dem Leser da s Üben des be handelten Stoffes zu e rmöglichen, wi rd je des Kapitel m it einer A ufgabensammlung a bgeschlossen. Lösungen z u diesen A ufgaben können unter http://www.oldenbourg.de/verlag im Menüpunkt „Downloads“ eingesehen werden. Die Programmiersprachen Die Wahl de r verwe ndeten Program miersprache hä ngt grundsätzlich im mer von ihrem Einsatzzweck (z.B. Au sbildung, Ech tzeitsteuerung, Datenbankprogrammierung) sowie von den sp eziellen Um ständen des j eweiligen Proj ektes (Altlasten, Hard- und Soft wareumgebung, Anforderungen an Verfügbarkeit, Sicherheit, Effizienz) ab. Sie sollte jedoch nicht von den (zufälligen) Programmierkenntnissen („Ich kann leider nur Java“) oder Vorlieben („Java ist die beste Programmiersprache überhaupt!“) der Entwickler abhängen. Da die o.g. Zwecke und Umstände m eist sehr ve rschiedene ( oft so gar widersprüchliche) A nforderungen a n die jeweilige Sprache stellen, wurden im Lauf der Zeit sehr viele verschiedene Sprachen entwickelt. Wie gut ein Informatiker seinen Beruf beherrscht, zeigt sich unter anderem darin, ob er in der Lage ist, für die jeweiligen Anforderungen die opti male Sprache auszuwählen und seinen (z unächst m öglichst sprac hunabhängigen) System entwurf in diese Sprac he a bzubilden. Diskussionen über „die beste Programmiersprache schlechthin“ dürften unter gut ausgebildeten Informatikern eigentlich nicht stattfinden. Leider vermitteln viele Bücher und Vorlesungen aus der Informatikausbildung immer noch den Eindruck, als ob (oft leider nur) eine Programmiersprache der Dreh- und Angelpunkt aller Überlegungen wäre. Bei den Zielgruppen dieses Buches tritt di e Bedeutung der Programmie rsprache noch weiter in den Hintergrund: Lehrkräfte, Schülerinnen und Schüler, Maschinenbau- bzw. Geodäsieingenieure oder Betriebswirte werd en ihren Lebe nsunterhalt n ur in den selten sten Fällen mi t Programmierung verdienen. Wenn überhaupt, dann werden sie nur gele gentlich in sehr kleinem Rah men ein eige nes P rogramm schreiben müssen. Damit verliert das E rlernen einer Programmiersprache jeglichen Selbstzweck. Programmieren in der Ausbildung dient in diesem Fall nur noch (u.a.) zur Veranschaulichung (meist sehr abstrakter Konzepte), der Vertiefung des Einblicks in die Funktionsweise von Hard- und Softwaresystemen oder auch nur der Motivierung der S tudierenden. Dennoch ist es auch für diese Zielgruppe sehr lohne nd, sich
XIV
Vorwort
einige ( bedeutende) Konzepte von P rogrammiersprachen nä her anz usehen, allerdi ngs ohne sich dem Zwang zum „perfekten“ Erlernen einer bestimmten Sprache aussetzen zu müssen. Dieses Buch is t daher aus drücklich nicht als Einführung in die Pr ogrammierung mit irgendeiner be stimmten Pr ogrammiersprache ge dacht. S olche B ücher fi nden S ie massenweise in den Buchläden (die Titel folgen m eist dem Schema „X mit Y“ oder „X i n Y Tage n“). Wir wollen hier vor allem Kenntnisse und Fertigkeiten über spezielle Techniken aus dem Bereich Modellierung vermitteln: Zustandsm odellierung, Algorithmen, funktionale Modellierung, formale Sprac hen. Dennoch könne n diese K onzepte (und ins besondere die Fe rtigkeiten) nicht ganz ohne die Behandlung von Programmiersprachen (z.B. als Darstellungsmöglichkeit für Algorithmen oder zur Simulation von Abläufen) verstanden werden. Die Bedeutung einer Idee oder eines Konzeptes lässt sich daran er messen, wie breit sein jeweiliger Einsatzbereich ist: ist nur eine bestim mte Program miersprache betroffen, eine ga nze Klasse von Sprachen oder vielleicht sogar alle Pr ogrammiersprachen? Dieses Kriterium ka nn m an nat ürlich nur anwenden, we nn man mehrere Sprachen kennt (oder sic h z umindest schnell in eine neue Sprache einarbeiten kann). Daher gehört es unserer Meinung nach zu einer guten Informatikausbildung (auch für Le hrkräfte oder Studierende im Nebenfach), mehrere Sprachen kennen zu lernen. Dies fördert die Einsicht, dass die wesentlichen Konzepte der Informatik nicht auf eine bestimmt e Programmiersprache zugeschnitte n si nd, sondern i n viele Sprachen umgesetzt werden können. Andererseits ist die Einarbeitung i n die Syntax einer Sprache und die meist unterschiedlichen Entwicklungsumgebungen sehr zeitaufwendig, so dass wir l eider nicht beliebig viele Sprachen behandeln können. Um d ie wich tigsten Struk turmerkmale v on Prog rammiersprachen b eschreiben zu könn en, greifen wir zu einem in der Informatikausbildung sehr bewährten Trick: Wir definieren uns eine eigene (für unse re speziellen Zwecke ideale ) Programmiersprac he (Ps eudoProgrammiersprache PPS, s päter i n der Abwandlung FPPS), die ke inerlei Rücksicht auf zufällige technische Randbedingu ngen nimmt. Diese Sprachen haben aber leider den Nachteil, dass es ke ine Möglichkeit gibt, die Prog ramme tatsächlich auf eine m Rechner a blaufen zulassen. Außerdem ist es auch ganz interessant, die Zwänge einer „realen“ Umsetzung einer Programmiersprache zu e rleben. Wir verwenden daher neben den o.g. Pseudosprachen auch zwei „echte“ Programmiersprachen (je eine für jeden der besproc henen Programm ierstile), nämlich Python und Haskell. Während es i m ersten Teil u.a. darum geht, wie man Programmiersprachen definieren kann oder welche Strukturen sie aufweisen, wir d die Sprache PPS im zweiten Teil als reines Hilfsmittel zu r Besch reibung b estimmter Algo rithmen un d Daten strukturen gebraucht. In einigen Aussagen weichen wir dort (um der Klarheit und Kompaktheit willen) öfter ganz bewusst von der Definition von PPS (aus dem ersten Teil) ab. Diese Abweichungen werden (soweit sie nicht offensichtlich sind) an den entsprechenden Stellen erläutert. Darüber hinaus verzichten wir ab Kapitel 12 ganz auf eine exakte Darstellung der behandelten Themen in Quellcode von PPS. Einige der Implementierungen für diese Probleme sind sehr aufwendig und umfangreich, was de n Rahmen dieses Buches sprengen würde. Es geht aus unse rer Si cht vielm ehr darum , dem Lese r eine n m öglichst einfac hen Zuga ng z um teilweise sehr komplexen Stoff zu ermöglichen. Darum beschränken wir uns in diesen Kapiteln auf eine m öglichst einfac he und kom pakte Da rstellung, die sich einer Mischung a us infor-
Vorwort
XV
mellen und formalen Mitteln bedient, so dass da s Wesentliche herausgestellt werden kann. Weiterführende Behandlungen können bei Bedarf in der entsprechenden Fachliteratur nachgelesen werden. Am Ende jede s Kapitels findet sich eine (u nterschiedlich umfangreiche) Folge von Aufga ben, die vor allem der Verti efung und de r Anregung zum Nachde nken diene n solle n. Sie können nicht den Besuch einer Übung oder die Bearbeitung spezieller Aufgabensammlungen ersetzen, die aber für sich genommen schon den Umfang dieses Buches annehmen würden. Danksagung Unser Kollege Alexander Staller hat uns während der gesamten Arbeit an diesem Buch durch fachliche, konstruktive Ratschläge und Korrekturen intensiv unterstützt, so dass ihm unser besonderer Dank gebührt. Einige der Aufgaben (z.B. die zu Kapitel 8) stammen ebenfalls aus seiner Feder. Ohne seine tatkräftige, intelligente und aufmerksame Mithilfe wäre die Qualität dieses Buchs (und auch der Lehrerkurse) weitaus niedriger ausgefallen. Eine wichtige Rolle spielte auch der Tutor der SIGNAL-Kurse an der TU München, Matthias Spohrer, der als erster Em pfänger jeglicher Kritik an unseren Texten herhalten musste. Er lieferte viele Tipps und Rückkopplungen aus der Lehrpraxis sowie zahlreiche nützliche Hinweise. Daneben seien noc h Markus Schneider, Margret Bauer und Stefan Winter erwähnt, die uns ebenfalls durch wertvolle Beiträge unterstützt haben. Beide Teile dieses Buches sind, wie oben bereits erwähnt, im Rahmen der Nachqualifikation von Gymnasiallehrkräften an fünf bayerischen Universitäten entstand en. Für die engagierte Mitarbeit der Kursteilnehmer und ihrer Tutoren Hermann Puhlmann (Erlangen), Peter Brichzin, Ha ns-Dietmar Jäger (L MU Münche n), Ute Heuer (Passa u) und Andrea s Schuster (Würzburg) wollen wir uns an dieser Stelle ebenfalls herzlich bedanken. Garching b. München Peter Hubwieser, Gerd Aiglstorfer
1. Teil Ablaufmodellierung
1
Modellierung von Abläufen
Wenn m an ein System (z.B. einen Fahrkartenautomaten, eine W aschmaschine oder eine Bundestagswahl) beschreibe n will, das nicht rein statisch (d h. zeitlich unveränderbar wi e z.B. eine Plakatwand) ist, da nn stellt sich dabei natürlicherweise die Frage nach sei nem zeitlichen Verhalten. Je nach Konstruktion bzw. Struktur hat dieses System oft viele verschiedene Möglichkeiten dafür (z.B. kann ein Getränkeautomat Limo, Wasser oder das ei ngeworfene Geld ausgeben; eine Bürgermeisterwahl kann nac h einem Wahlgang oder erst nach einer Stichwahl zum Ergebnis führen). Welche dies er Möglichkeiten ge wählt wir d, hängt u. U. vom Verhalten der Benutzer oder auch nur vom Zufall ab. Jede diese r Möglichkeiten st ellt einen bestimmten Ablauf dar. Zur umfassenden Beschreibung des zeitlichen Verhaltens eines Systems muss man daher angeben, welche möglichen Abläufe ihm möglich sind. Dazu kann man viele ve rschiedene Techniken einsetzen. In diesem Kapitel wollen wir uns einen Überblick über drei dieser Techni ken verschaffen, von denen zwei (alg orithmische Modellierung und Zustandsmodellierung) dann in den folg enden Kapi teln eingehend behandelt werden, während wir Elemente aus der dritten (Aktionsmodellierung) zur sauberen Formulierung der anderen beiden benötigen.
1.1
Modellierung als Arbeitstechnik
In der Informatik begegnet man dem Begriff „Modell“ auf vielen verschiedenen Ebenen und in zahlreichen Facetten. Da runter ve rsteht man unter a nderem jegliche gena uere Beschreibung von Vorgängen, die B eschreibung von P roblemen mit Hilfe von speziellen Modellierungsprogrammen (z.B. Stella), irgendeine graphische Darstellung (in beliebiger Form) aus dem betreffenden Problem kreis, eine m athematische Gleichung, ein Programm oder vieles andere mehr. Wir wollen in diesem Kurs von folgendem Modellbegriff ausgehen: Ein Modell ist eine abstra hierte Beschrei bung eine s reale n oder geplanten System s, d as die für ei ne be stimmte Zielse tzung wesent lichen Eigenschaften des Syst ems erhält. Di e Erstellung ei ner s olchen Be schreibung heißt Modellbildung oder Modellierung (sie he Broy, 1998, Band 1). Besondere Be deutung hat i n diesem Zusa mmenhang der Begri ff „ System“, der da s Objekt der Modellierung bezeichnet. Wir interpretieren ihn wie Wedekind et al. (1998):
4
1 Modellierung von Abläufen Als System im „weiteren Sinne“ ... gilt dabei eine Menge von Elementen (Systembestandteilen), die durch bestimmte Ordnungsbeziehungen miteinander verbunden und durch klar definierte Grenzen von ihrer Umwelt geschieden sind.
Der V organg der Modellbildung in unse rem Sinne um fasst dahe r vor allem die folgende n Arbeitsgänge, die nicht notwendigerweise nacheinander ablaufen müssen. Jeder der Arbeitsgänge ist auf das spezielle Ziel der M odellierung bz w. den Einsatzzweck des Model ls hin ausgerichtet: 1. Abgrenzen: Identifikation de r o.g. Grenzen des r elevanten Ausschnittes der realen bz w. vorausgedachten Erfahrungswelt, 2. Abstrahieren: Weglassen von nicht oder wenig bedeutsamen Details, von S onderfällen oder speziellen Ausprägungen allgemeinerer Eigenschaften, 3. Idealisieren: Korrigieren kleiner Abweichungen von idealen Eigenschaften in Ric htung einer leichteren Beschreibung, 4. Beschreiben: Anwendung spezieller Techniken zur Darstellung der wesentlichen Eigenschaften des z u besc hreibenden Systems: Systembestandteile (K omponenten) und Verbindungen dazwischen, Interaktion mit der Umgebung, statische Struktur und Ve rhalten des Systems. Als Beispiel wollen wir ei n Seilbahnsystem so modellieren, dass eine Si mulation des Gondelverkehrs möglich wird (siehe Abbildung 1.1). Die oben beschriebenen Arbeitsgänge sehen hier im Speziellen so aus: 1. Abgrenzen: Wir interessieren uns für eine Seilbahn mit zwei Abschnitten zwischen Tal-, Mittel- und Bergstation, auf der je weils zwei Gondeln im Pendel verkehr fahren s ollen. Wir interessieren uns (in diesem Fall!) nicht z.B. für die Gastronomie in de n Stationen, andere Seilbahnen, die Zubringerbusse oder die Heizung der Anlagen. 2. Abstrahieren: Die Far be und der Fußbodenbelag der Gondel, die a rchitektonischen Feinheiten der Stationen sind nicht relevant. 3. Idealisieren: Der Seilverlauf wird je weils als geradlini g ange nommen, die Gondel als quaderförmig, der Seilquerschnitt als kreisförmig, die Geschwindigkeit der Gondeln nach dem Anfahren und vor dem Bremsen als konstant. 4. Beschreiben: Das System wird m it unterschiedliche n M itteln beschrieben, z.B . einem Klassendiagramm fü r die st atische Struk tur (sieh e Abbildung 1.2) und ein em Alg orithmus je Teilstrecke.
1.1 Modellierung als Arbeitstechnik
Abb. 1.1
Ein Seilbahnsystem als Modellierungsbeispiel
5
6
1 Modellierung von Abläufen ist_aufgehängt_an
1 Seil
Station
Gondel
-Durchmesser -Länge -Stränge -Material -Hersteller -Baujahr
-Breite -Länge -Höhe -Gewicht -Zuladung -Personen
2 Zugseil
Tragseil
-Umlaufwiderstand -Zugkraft
-Rollwiderstand -Tragkraft
-Höhe -Breite -Tiefe
verkehrt_zwischen 1 2
2
hängt_an 1
2
1
Talstation
Bergstation
-Talöffnung.Breite -Talöffnung.Höhe
-Bergöffnung.Breite -Bergöffnung.Höhe
Mittelstation -Talöffnung.Breite -Talöffnung.Höhe -Bergöffnung.Breite -Bergöffnung.Höhe
wird_gezogen_von
Abb. 1.2
Das Klassenmodell der Seilbahn
Klassendiagramme werden in der Literatur zum Themenbereich „Objektorientierte Modellierung und Programmieru ng“ ein gehend behandelt und an gewendet. Hier so llen nur d ie Elemente dieser Diagramme kurz erklärt werden: Die Kästen repräsentieren Klassen von Objekten. Die Einträ ge unter dem (fett gedruckten) Bezeichner der je weiligen Klasse beschreiben die Attribute der Objekte (Behälter für Daten) . Die Linien zwischen de n Klassen stellen Beziehungen dar. Algorithmus Strecke1: Wiederhole solange Seilbahn in Betrieb Wiederhole bis Start freigegeben Warte Ende Wiederhole Falls Systemprüfung erfolgreich Gondeln anfahren bis Normalgeschwindigkeit gleichmäßig fahren bis zum Bremspunkt Gondeln abbremsen bis Stillstand Wiederhole bis Einfahrt erlaubt Warte Ende Wiederhole Gondeln anfahren bis Langsamfahrt
1.2 Algorithmische Modellierung
7
gleichmäßig in Station einfahren Gondeln abbremsen bis Stillstand Ende Falls Ende Algorithmus
1.2
Algorithmische Modellierung
Wie wir bereits anhand unseres Seilbahnbeispiels gesehen haben, können Abläufe m it Hilfe von Algorithmen be schrieben we rden. Die se Be schreibungstechnik ist schon se hr alt. Sie wurde bereits im Altertum zur Beschreibung von (numerischen) Rechenverfahren eingesetzt. Benannt wurde sie nach dem Mathe matiker Al-Khwa rizmi (ca. 780–850), der am Hof der Kalifen von Bagdad wirkte. Es gibt eine Vielzahl von Möglichkeiten zur Darstellung eines bestimmten Algorithmus. Wir werden meist textuelle Beschr eibungen verwenden, die aus einfachen und zusammengesetzten Verarbeitungsschritten (Seq uenzen, W iederholungen, bedi ngte Verarbeitungsschritte o.ä.) bestehen. Algorithmus Ampel: Schalte alle Lichter aus Wiederhole solange die Ampel eingeschaltet ist schalte rotes Licht ein warte eine Minute schalte gelbes Licht ein warte zwei Sekunden schalte rotes Licht aus schalte gelbes Licht aus schalte grünes Licht ein warte eine Minute schalte grünes Licht aus schalte gelbes Licht ein warte fünf Sekunden schalte gelbes Licht aus schalte rotes Licht ein Ende Wiederhole Schalte alle Lichter aus Ende Algorithmus Als weiteres Beispiel für ei nen Algorithmus nehmen wir uns den Einschalt- und Gesprächsvorgang eines Mobiltelefons vor: Algorithmus „Mobiltelefon“: Drücke roten Einschaltknopf Wiederhole bis PIN Abfrage erscheint Warte
8
1 Modellierung von Abläufen
Ende Wiederhole Wiederhole 4-mal Gib eine Ziffer zwischen 0 und 9 ein Ende Wiederhole Bestätige die Eingabe Falls die PIN akzeptiert wurde Wiederhole bis die Netzverbindung steht Warte Ende Wiederhole Sonst Wiederhole 4-mal Gib eine Ziffer zwischen 0 und 9 ein Ende Wiederhole Bestätige die Eingabe Falls die PIN akzeptiert wurde Wiederhole bis die Netzverbindung steht Warte Ende Wiederhole Sonst Wiederhole 4-mal Gib eine Ziffer zwischen 0 und 9 ein Ende Wiederhole Bestätige die Eingabe Falls die PIN akzeptiert wurde Wiederhole bis die Netzverbindung steht Warte Ende Wiederhole Sonst Meldung „Abbruch: 3x falsche PIN!“ Schalte das Telefon aus Ende Ende Falls Ende Falls Ende Falls Wähle die Telefonnummer des Partners Falls der Partner abhebt Gespräch abwickeln Ende Falls Auflegen Ende Algorithmus Mehr über Al gorithmen wer den Sie in Ka pitel 3 erfa hren, das sich a usschließlich diesem Thema widmet.
1.3 Aktionsstrukturen
1.3
9
Aktionsstrukturen
Eine z weite Möglichkeit z ur Bes chreibung von Abläufen wir d m it Hilfe der Darstellung durch Erei gnisse bz w. Aktionen eröffnet . Da wir diese Begriffe für die Zustandsmodellierung benötigen, besprechen wir diese Darstellungstechnik hier eingehender. Aktionsstrukturen spielen vor allem im Zusammenhang mit Betriebssystemen eine große Rolle (siehe auch Broy, 1998, Band 2). Ein Ereignis repräsentiert einen einmaligen Vorgang, der an einem bestimmten Ort zu einem bestimmten Zeitpunkt stattfindet, beispielsweise die „Abfahrt des ICE Nr. 602 auf Gleis 4 im Münchner Hauptbahnhof am 23.5. um 13:32“. Da es sehr m ühselig ist, mit solchen singulären Ereignissen zu arbeiten, beschreibt man eine Menge gleichartiger Ereignisse durch eine Aktion, z.B. die „Abfahrt des ICE Nr. 602“. Das o.g. Ereignis kann dann als Instanz dieser Aktion (am 23.5. um 13:32 auf Gleis 4 im Münchner Hauptbahnhof) betrachtet werden. Aktionen sind also vom konkreten Zeitpunkt und Ort abstrahierte Ereignisformen, die dadurch räumlich und zeitlich übertragbar werden. Anstatt des Bandwurmes „Das Ereignis e als Instanz der Aktion a findet zum Zeitpunkt t am Ort o statt“ sagen wir oft kurz: „a findet um t in o statt“. Unsere Ampel zeigt se hr sc hön de n Unters chied zwisch en Aktionen und E reignissen: Eine Aktion ist ge wissermaßen e ine Scha blone für (oft sehr viele) gleicha rtige Ereignisse . Die Aktion „Schalte rotes Li cht ein“ m anifestiert sich i n Deutschland in Form unzähliger Ereignisse auf einer Unmenge von Ampeln zu jedem beliebigen Zeitpunkt. Um Aussagen über die zeitliche Abfolge ei nzelner Ereignisse bzw. ihrer zuge ordneten Aktionen m achen zu können, benötigen wir eine M öglichkeit, sie zuei nander in Beziehung z u setzen, wie z.B. eine Kausalitätsrelation „→“: e1 → e2 bedeutet: „e2 kann erst stattfinden, nachdem e1 stattgefunden hat“. Diese Relation ermöglicht uns nun Aussagen über die zeitliche Anordnung einer Menge von Ereignissen, di e einem A mpelzyklus (a n einem bestim mten O rt zu eine r bestim mten Zeit) entsprechen. Tab. 1.1
Ereignisse und Aktionen eines Ampelzyklus
Ereignis zugeordnete Aktion e1, e9 schalte rotes Licht ein e2, e7 schalte gelbes Licht ein e3 schalte rotes Licht aus e4, e8 schalte gelbes Licht aus e5 schalte grünes Licht ein e6 schalte grünes Licht aus
10
1 Modellierung von Abläufen
Wenn wir diese Erei gnisse aus Tab elle 1.1 nun m it u nserer Vorrangrelation v erknüpfen, entsteht daraus ein Graph:
e3 e1
e2
e5
e6
e7
e8
e9
e4
Abb. 1.3
Ereignisdiagramm
Sie werden sich nun vielleicht fragen, warum sich die Kette der kausalen Abhängigkeiten vor e3 bzw . e4 ve rzweigt. Der Gr und dafür liegt darin, dass es (ausgehend von de r Situation „Rot-Gelb brennt“) keine R olle spielt, ob zuerst das gelbe oder zuerst das rote Licht ausgeschaltet wird (oder auch beide gleichzeitig). Dagegen darf die Ampel erst grün zeigen, wenn sowohl rot als auch gelb ausgeschaltet wurden. Wir haben in unserem Ereignisdiagramm übrigens darauf verzichtet, die Wartezeiten (die wir in unserem Am pelalgorithmus noc h auf geführt hatten) z u be rücksichtigen. Da für gibt es zwei Gründe: • Es ist sehr zweifelhaft, „Nichtstun“ (also „warten“) als Aktion zu definieren • Ereignisdiagramme zeigen di e mögliche zeitl iche Abfol ge von Ereignissen im Hinblick auf die F rage, welches Erei gnis vor welc hen anderen stattfinde n ka nn, sie sagen aber nichts darüber aus, wie viel Zeit zwischen zwei aufeinander folgenden Ereignissen vergehen kann. Es ist also sinnlos, darin Aussagen über Wartezeiten zwischen einzelnen Ereignissen zu m achen, wie z.B . die Da uer der Ampelphase „rot“ (die ja bei zwei Am peln an unterschiedlichen Standorten durchaus unterschiedlich lang sein kann). Eine Menge von Ereignissen zusammen mit einer Kausalitätsrelation darauf heißt Prozess. Genau genommen ge hört z u einem Prozes s auc h noch ei ne Vorschrift, die je dem Ereignis eine Aktion zuordnet (z.B. i n Form der obigen Tabelle). Da Prozesse damit auch Aus sagen über die mögliche Abfolge von Aktione n m achen, wer den sie auch als Aktionsstrukturen bezeichnet. Prozesse spielen in vielen Bereichen der Informatik eine wichtige Roll e, z.B. auc h bei Betriebssystemen. Hier wer den Prozesse durc h den Abla uf von Pr ogrammen auf einer re alen Maschine erzeugt. Da auf ei nem Rechner gleichzeitig sehr viele Prozesse aktiv werden können, ist es eine sehr schwierig e Aufg abe, den Zugriff auf die Betrieb smittel d es Syste ms (z.B. Prozessor, Speichermedien, Ein-Ausgabegeräte, etc.) so auf diese Prozesse zu verteilen, dass sie sich möglichst wenig gegenseitig stören.
1.4 Zustandsmodellierung
1.4
11
Zustandsmodellierung
Neben Algorithmen und Aktionsstrukturen wollen wir hier noch eine dritte Art der Modellierung von Abläufen behandeln, nämlich Zustands-Übergangsdiagramme. Unsere Ampel hat z.B. die folgenden Zustände: z1 = rot, z2 = rot-gelb, z3 = grün, z4 = gelb. Zwischen diesen Zuständen können genau diese Übergänge stattfinden: z1 nach z2, z2 nach z3, z3 nach z4, z4 nach z1. Solange sich ein System in einem bestimmten Zustand befindet, ändert sich keine seiner (für die Aufgabenstellung relevanten) Eigenschaften. Eine Transition beschreibt den (idealisiert: in unendlich kurzer Zeit stattfindenden) Übergang zwischen zwei Z uständen. Dabei werden die Zustände mit einem (möglichst aussagekräftigen) Bezeichner (wie „ rotes Licht bre nnt“) versehen, die Transitionen (mindestens) mit dem Bezeichner einer Aktion, die den Übergang auslöst (z.B. Eingabe des Be fehls „Umschalten“) und evtl . weiteren A ngaben, über die Sie im Kapitel 2 mehr erfahren. Wenn wir die Zustände als Kn oten zeichnen und die Übergä nge als Kanten, dann entsteht eine neue Art von Graph, nämlich ein Zustands-Übergangsdiagramm (kurz Z ÜD, siehe Abbildung 1.4).
Umschalten rot
ge b-rot
Umschalten
Umschalten
gelb
Abb. 1.4
Umschalten
grün
Zustands-Übergangsdiagramm
Bitte beachten Sie: Die Knote n im Ereignisdiagramm aus Abbildung 1.3 repräsentiere n Aktionen, ents prechen als o in e twa de n Ü bergängen (als o den Kanten) i m ZÜD aus Abbildung 1.4. Da Zustandsmodelle beliebig oft a blaufen können, stellen die Übergänge Schablonen für Ereignisse dar und können daher als Aktionen betrachtet werden.
12
1.5
1 Modellierung von Abläufen
Aufgaben
In diesem Kapitel haben Sie drei ve rschiedene Modellierungstechniken für Abläufe ke nnen gelernt. Zwei davon (Zustandsmodelle und Al gorithmen) werden in den folgenden Kapiteln 2 bzw. 3 einge hend behandelt. Daher finden Sie die passenden Aufgaben dazu ebenfalls in diesen Kapiteln. An dieser Stelle beschränken wir uns auf eine Au fgabe zur Aktionsm odellierung. Aufgabe 1.1: Beschreiben Sie den Startvorgang eines Automobils vom Öffnen der Garagentür bis zum Einreihen in den Straßenverkehr durch ein Er eignisdiagramm (analog z u Abbildung 1.3). Machen Sie dabei auch deutlich, welche Ereignisse parallel ablaufen können.
2
Zustandsmodellierung
Wie wi r im letzten Ka pitel gesehe n haben, k önnen Abläufe d urch Z ustands-Übergangsdiagramme (oder kurz Zustandsdiagramme bzw. Automaten) beschrieben werden. Die Zustandsmodellierung stam mt urs prünglich aus der T heoretischen I nformatik, wo m an m it ihrer Hilfe abstrakte Maschi nenmodelle wie endliche Automaten, Kellerm aschinen ode r Turing-Maschinen beschreibt. Im Folgenden wollen wir Zustandsmodelle kurz als Automaten bezeichnen.
2.1
Zustandsdiagramme
Anhand des Zustandsdiagramms e ines Mobiltelefons wollen wir uns diese Technik etwas genauer ansehen (siehe Abbildung 2.2). Um di e Beschreibungs mächtigkeit dieser Diagramme zu erhöhen , können an den Kanten folgende Informationen dargestellt werden (siehe Abbildung 2.1): 1. Zuerst die auslösende Aktion ai: Welche Aktion löst diesen Übergang aus? 2. In ec kigen Kl ammern eine Übergangsbedingung b: Unter welche r Bedingung da rf er stattfinden? 3. Nach einem Schrä gstrich evt l. durc h de n Übergang ausgelöste Aktion(en) ak, (ak+1, ...): Welche Aktion(en) werden durch den Übergang verursacht? Der schwarze Punkt symbolisiert dabei den Startzustand (der keine weiteren Eigenschaften hat und von dem aus im mer und sofort zum folgenden Zustand übergegangen wird). In den Zustandsdiagrammen symbolisiert also jeder Knoten ei nen Zustand zi und jede Kante einen Übergang zwischen zwei Zuständen (zi, zk).
auslösende Aktion ai [Bedingung b] / ausgelöste Aktion ak Zustand zi
Abb. 2.1
Zustand zk
Schema eines Übergangs zwischen zwei Zuständen
Der Übergang von einem Zustand zi zu einem Zustand zk findet genau dann statt, wenn
14 2
Zustandsmodellierung
1. sich das System im Zustand zi befindet und 2. die auslösende Aktion ai stattfindet (genauer: ein Ereignis zu dieser Aktion, siehe Ka pitel 1) und 3. unmittelbar vor dem Übergang die Bedingung b erfüllt ist. Dann wird mit dem Übergang auch die Aktion ak ausgelöst.
Warte auf PIN PIN eingeben [PIN nicht in Ordnung] einschalten PIN eingeben [PIN in Ordnung] anmelden [Keine Netzverbindung ]
1.falsche PIN
PIN eingeben [PIN in Ordnung]
Bereit
ausschalten
PIN eingeben [PIN nicht in Ordnung] PIN OK PIN eingeben [PIN in Ordnung] 2.falsche PIN
ausschalten anmelden [Netzverbindung ok]
PIN eingeben [PIN nicht in Ordnung]
wählen [Verbindung ok] Wäh bereit
auflegen
Verbindung
Gesperrt
wählen [Gegenstelle hebt nicht ab] Abb. 2.2
Zustandsdiagramm eines Mobiltelefons
Als weiteres Beispiel modellieren wir den Ablauf einer Überweisung (siehe Abbildung 2.3).
2.2 Syntaxprüfung
15
Auftrag abgeschlossen
Buchen
Datenübertragung abgeschlossen
Auftrag eingegangen
Quellkontodaten prüfen [Daten sind in Ordnung]
Quellkonto in Ordnung
Zielkontodaten prüfen [Daten sind in Ordnung]
Quel kontodaten prüfen [Daten sind NICHT in Ordnung]
Zielkontodaten prüfen [Daten sind NICHT in Ordnung]
Zielbank antwortet [Daten empfangen]
Auftrag abgelehnt
Betrag mit Kontostand und Dispo vergleichen [Betrag ist NICHT gedeckt] /Fehlversuch in Kundendaten vermerken Daten gesendet
Zielkonto in Ordnung
Daten an Zielbank übertragen [Übertragung in Ordnung]
Betrag mit Kontostand und Dispo vergleichen [Betrag ist gedeckt]
Zie bank antwortet [Daten nicht empfangen]
Betrag gedeckt
Daten an Zielbank übertragen [Übertragung NICHT in Ordnung]
Abb. 2.3
2.2
Zustandsdiagramm einer Überweisung
Syntaxprüfung
Eine weitere sehr wichtige Anwendung von Zustandsmodellen ist die Überprüfung von Wörtern aus künstlichen Sprachen (z.B. P rogrammiersprachen) auf korrekte Schreibweise (Syn-
16 2
Zustandsmodellierung
tax). Die A rt des A utomaten, de r zu E rkennung eine r sol chen Sprache notwendig ist, wird sogar zur Klassifizierung von künstlichen Sprach en verwendet. Mehr darüber können Sie in einem Buch über Theoretische Informatik erfahren. Als kleines Beispiel ist in Abbildung 2.4 ein Automat zur Erkennung eines syntaktisch richtigen algebraischen Terms ohne Klam mern mit den Varia blen a, b gezeigt, wie z .B.: a + b oder a + b*a oder –a + a + b – b – c.
Fehler!
+, -, *, :
Operator erkannt
+, -, *, : *, :
Vorzeichen erkannt
a, b, c
a, b, c
+, Start
Abb. 2.4
+, -, *, :
a, b, c
Variable erkannt
Endlicher Automat zur Syntaxüberprüfung von ungeklammerten algebraischen Termen
Dabei ist Folgendes zu beachten: 1. Die mit dem fetten Punkt verbundene Transition stellt den Start des Systems dar. 2. Die auslösende Aktion ist jeweils die Eingabe eines Zeichens. Die Übergänge werden nur mit diesem Zeichen beschriftet. Ausgelöste Aktion und Übergangsbedingung entfallen. 3. Stark um randete Zustä nde si nd E ndzustände. Eine einge gebene Z eichenfolge ist genau dann korrekt, wenn ihre zeichenweise Abarbeitung in einem Endzustand endet. Beispielsweise verläuft die E rkennung des Terms –a + b*a in diesem Automaten folgendermaßen: – Start
→
a Vorzeichen erkannt
→
+ Variable erkannt
→
b Operator erkannt
→
* Variable erkannt
→
a Operator erkannt
→
Variable erkannt
Da Variable erkannt ein Endzustand ist, wird der Term als korre kt erkannt. Dagegen führt die Erk ennung vo n a*+ b zum Z ustand Fehler. Dieser Term ist daher nicht korrekt . Die Verwendung des Zustands Fehler ist dabei reiner Luxus: Definitionsgemäß würde es für die Feststellung ei nes Syntaxfehlers reichen, w enn die Erke nnung in einem Zusta nd e ndet, de r kein Endzustand ist.
2.3 Aufgaben
17
Ein Zu standsdiagramm mit d en o.g. Ei nschränkungen 1–3 b eschreibt eine sp ezielle Klasse von Automaten (e ndliche A utomaten), die in de r Theoretischen Informatik eine sehr große Rolle spielt. Traditionellerweise werden bei endlichen Automaten Kreise anstatt abgerundeter Rechtecke als Symbole für die Zustände ve rwendet. In diesem Text bleiben wi r jedoch durchgehend bei der zweitgenannten Möglichkeit. Mit endliche n Automaten könne n nur se hr einfache S prachen ( reguläre Sprachen) er kannt werden. Nicht geeignet dafür is t z.B. die Sprache der (beliebig oft) geklammerten arithmetischen Ausdrücke, wie ((a + b) – c*(a – b)):a, denn: • Für jede neue öffnende Klammerebene benötigt man (mindestens) einen neuen Zustand, • ein Automat mit n Zuständen könnte also nur maximal n offene Klammern erkennen, • also keinen Term mit n+1 offenen Klammern. Für solche Sprachen verwendet man komplexere Automaten (Kellerautomaten oder T uringMaschinen). Diese und viele andere Aussagen über den Zusammenhang zwischen Sprachen und Automaten werden in der Theoretischen Informatik ausführlich besprochen.
2.3
Aufgaben
Aufgabe 2.1: Die Digitaluhr DIGICLOCK hat drei Tasten (A, B und C), mit denen man die Zeit und das Datum betrachten und einstellen kann: Aus der normalen Zeitanzeige (Stunden, Minuten und Sekunden) schaltet man durch Drücken der Taste A auf den Datumsmodus, in dem Tagesdatum und Wochentag angezeigt werde n. De r Wochentag wi rd aut omatisch aus dem Datum b erechnet. Ei n erneutes Drücken von A schaltet wieder in den Zeitm odus zurück. Aus dem jeweilige n Anzeigemodus (Z eit bzw. Datum) kann m an durc h Drüc ken der Taste B in de n Einstellm odus sc halten. Dabei ka nn z unächst die Stundena nzeige bz w. der Tag eingestellt werden. Weiteres Drücken der Taste B im Einstellmodus schaltet nacheinander z ur Einstel lung de r weiteren Anzeigen (Minuten, Se kunden im Zeit- bz w. M onat und Jahr im Datum smodus) und da nach wie der zurück in den jeweiligen Anzeigemodus. Die Taste C dient zur Einstellung der Werte („Hochblättern um eine Stufe“). Erstellen Sie e in Zusta nds-Übergangsdiagramm für die oben bes chriebenen A nzeige- bzw. Einstellmodi der Uhr. Die Funktion der Taste C dürfen Sie dabei unberücksichtigt lassen. Aufgabe 2.2: Ein einfaches Abspielgerät für Musik-CD’s verfügt über folgende Tasten: • • • •
Ein/Aus: Gerät ein- oder ausschalten, Laufwerk öffnen: CD-Halter ausfahren zur Ein- oder Ausgabe von CD’s, Start: Abspielvorgang starten, Stop: Abspielvorgang beenden.
18 2
Zustandsmodellierung
Nach Drücken der Taste „La ufwerk öffnen“ kann man eine CD einlege n und diese mit derselben Taste ei nfahren. Wenn eine C D im Laufwerk liegt, kann diese mit „Start“ abgespielt werden. Der Abspielvorgang läuft, bis e r mit „Stop“ unterbrochen oder bis die Taste „Laufwerk öffnen“ gedrückt wird (falls das Ende der CD e rreicht wird, beginnt er ei nfach wieder von vorne). Mit der Taste „La ufwerk öffnen“ kann die CD wieder ausgeworfen werden. Der CD-Träger wir d dann in leerem Zustand durc h erneutes Dr ücken von „Lauf werk öffnen“ wieder eingefahren. Mit de r Ein-Aus-Taste kann das Gerät eingeschaltet und wie der ausgeschaltet werden, falls keine CD eingelegt ist. Andernfalls wird lediglich das Laufwerk geöffnet, so als ob die Taste „Laufwerk öffnen“ gerückt worden wäre. Zeichnen Sie ein Zusta nds-Übergangsdiagramm, das das obe n beschriebene Verhalten des Gerätes darstellt. Aufgabe 2.3: Ein (stark ve reinfachter) Automat zum Verkauf von Schokoladentafeln funktioniert folgendermaßen: Als Geldeinwurf werden 1- und 2-Euro-St ücke akzeptiert. Mit zwei Druckknöpfen kann man zwischen einer großen und einer kleinen Tafel Schokolade wählen. Eine große Ta fel kostet 2 Euro , eine kleine 1 Euro. Bei Wahl einer kleinen Tafel und Einwurf eines 2-Euro-Stückes wird mit der Schokolade 1 Euro Wechselgeld ausgegeben (ebenso bei Wahl einer großen Tafel und Einwurf eines 1-Euro-Stückes, gefolgt von einem 2-EuroStück). Geben Sie für den Automaten ein Zustandsübergangsdiagramm an. Aufgabe 2.4: Geben Sie einen endlichen Automaten an, der die syntaktische Korrektheit von deutschen Autokennzeichen feststellt.
3
Algorithmen
Algorithmen sind eine der ältesten Modellierun gsformen für Abläufe. Bereits in der Antike kannte m an inform elle Beschrei bungen für R echenverfahren, wie de n berühmten Euklidischen Algorithmus zur Bestimmung der Primzahleigenschaft: „Nimmt man beim Vorliegen zweier ungleicher Zahlen abwechselnd immer die kleinere von der größeren weg, so müssen, wenn niemals ein Rest die vorangehende Zahl genau misst, bis die Einheit übrigbleibt, die ursprünglichen Zahle n gegeneinander prim sein “ (aus Euklid s Buch VII, §1, zitiert nach Gericke H.: Mathematik in Antike und Orient – Mathematik im Abendland. Fourier, Wiesbaden, 3. Aufl. 1994). Von diesem Algorithmus wird im Folgenden noch mehrfach die Rede se in. Der Name „Algorithmus“ weist (wie schon in Kapitel 1 erwähnt) auf Al-Khwarizmi hin, der viele Elemente der antiken und altindischen Mathematik im Nahen Osten verbreitete, u.a. auch viele solcher Rechenverfahren. Dem hohen Alter dieser Entwicklung gemäß gibt es eine Vielzahl von Möglichkeiten, einen Algorithmus darz ustellen, z.B. Zusta ndsdiagramme, T extersetzungssysteme, Ter mersetzungssysteme, Programme für abstrakte Maschinen (Turing-Maschinen, Registermaschinen) oder für reale Maschine n (formuliert in Programmiersprachen, z.B. im funktionalen ode r im imperativen St il), Form ulierungen in Um gangssprache oder in einer Ps eudoprogrammiersprache (d ie ei ner Prog rammiersprache ähnelt, für die es aber kei nen Übersetzer au f einer realen Maschine gibt). Wir werden in diesem Kurs u.a. die Darstellung in Programmiersprachen und in Pseudoprogrammiersprachen behandeln.
3.1
Der Begriff „Algorithmus“
Zuerst wollen wir festlegen, was unter diesem Begriff zu verstehen ist. Dem oben angesprochenen Alter dieses Be griffes ents prechend e xistieren na türlich se hr viele unte rschiedliche Definitionen. Wir wollen uns an die von M. Broy halten (siehe Broy, 1998, Band 1). „Ein Algorithmus ist ein Ve rfahren m it einer präzisen ( d.h. in eine r genau festgelegten Sprache abgefassten) endlichen Beschr eibung unter Verwendung effektiver (d h. tatsächlich ausführbarer) elementarer (Verarbeitungs-) Schritte.“
20 3
Algorithmen
In dieser Definition wird allerdings nicht festgelegt, wann eine zur Bes chreibung des Algorithmus verwendete Sprache präzise genug ist. Für viele Zwecke (etwa die Darstellung de r Ablaufstruktur) ge nügen inf ormelle Darste llungen ( Umgangssprache oder P seudosprachen ohne formale Festlegung ihrer Bedeutung). Für manche Aussagen über Algorithmen (detaillierte Laufzeitanalysen oder Verifikation) benötigt man dagegen formale (d h. im mathematischen Sinn eindeutige) Darstellungen. Algorithmus „Bubblesort“: Eingabe: Liste von Namen Wiederhole (Anzahl der Elemente der Liste – 1) mal Wiederhole für alle Namen vom ersten bis zum vorletzten Falls der betrachtete Name alphabetisch hinter den folgenden gehört Vertausche die beiden Namen Ende Falls Ende Wiederhole Ende Wiederhole Ausgabe: Sortierte Liste von Namen Ende Algorithmus Dieses Verfahren ist allerdings mit Vorsicht zu genießen, da es ziemlich ineffizient ist. Eine nähere Betrachtung verschiedener Sortieralgorithmen und ihrer Effizienz finden Sie im zweiten Teil dieses Buches, der den Themenbereich „Algorithmen und Datenstrukturen“ behandelt. Ein exemplarischer Ablauf von „Bubblesort“ könnte so aussehen: Hans Emma Emma Hans Emma Hans Emma Hans
Yuri Yuri Yuri Anna
Anna Anna Anna Yuri
Emma Hans Emma Hans Emma Anna Emma Anna
Anna Anna Hans Hans
Yuri Yuri Yuri Yuri
Emma Anna Anna Emma Anna Em ma Anna Em ma
Hans Hans Hans Hans
Yuri Yuri Yuri Yuri
1. Lauf der „äußeren“ Wiederholung 1. Lauf der „inneren“ Wiederholung 2. Lauf der „inneren“ Wiederholung 3. Lauf der „inneren“ Wiederholung Abschluss des 1. Laufes der „äußeren“ Wiederholung 2. Lauf der „äußeren“ Wiederholung 1. Lauf der „inneren“ Wiederholung 2. Lauf der „inneren“ Wiederholung 3. Lauf der „inneren“ Wiederholung Abschluss des 2. Laufes der „äußeren“ Wiederholung 3. Lauf der „äußeren“ Wiederholung 1. Lauf der „inneren“ Wiederholung 2. Lauf der „inneren“ Wiederholung 3. Lauf der „inneren“ Wiederholung Abschluss des 3. Laufes der „äußeren“ Wiederholung Ende des Algorithmus
3.1 Der Begriff „Algorithmus“
21
Ein Algorithmus stellt eine bestim mte Lösung für eine ganze Klasse von Aufgaben dar, in unserem Fall für die Sortierung ei ner endlichen Reihe von Nam en. Zu dieser Klasse gehört meist eine Vielzahl konkreter Aufgaben, hier z.B.: • Sortiere („Emil“, „Anna“, „Theo“) oder • Sortiere („Krokodil“, „Falter“, „Pferd“, „Elefant“). Für die Anwendung auf eine konkrete Aufgabe müssen die entspreche nden Daten (hier die Liste der Namen) dem Algorith mus als Eingabe übergeben w erden. D as End ergebnis wird vom Algorithm us dann wi ederum in For m einer Ausgabe an den Benu tzer zu rückgeliefert (siehe auch Abbildung 3.1).
(“Hans”, “Emma“, “Yuri”, “Anna”)
bubblesort
“Anna“, “Emma”, “Hans”, “Yuri” Abb. 3.1
Funktionale Sicht des Bubblesort-Algorithmus
Diesen Vorga ng kann m an mit Hilfe einer Funktion bubblesort, die je weils eine Liste von Namen (Eingabe) auf eine andere Liste (Ausgabe) abbildet, beschreiben: bubblesort(“Hans”, “Emma”, “Yuri”, “Anna”) = (“Anna”, “Emma”, “Hans”, “Yuri”). Eine solc he B eschreibung durc h eine F unktion ist allerdings nur dann zulässig, we nn der Algorithmus zu jeder Eingabe eine eindeuti ge Au sgabe liefert (also determiniert ist, s iehe Abschnitt 3.4). Ab Kapitel 6 werden wir uns eingehender mit dieser funktionalen Sichtweise beschäftigen. Andererseits gibt es für ei ne bestimmte Kla sse von Aufga ben meist auch eine Vielzahl verschiedener Algorithmen, so gibt es für die Sortierung einer Liste von Na men eine Unmenge von verschiedenen Ve rfahren z.B. Bu bblesort, S ortieren d urch Ei nfügen, Quicksort, us w. Auch davon wird im 2. Teil des Buc hes („Algorithmen und Datenstrukturen“) noch ausführlich die Rede sein. Einen bestimmten Algorithmus kann man zudem auf eine Vielzahl versc hiedener Arten darstellen (siehe oben). Wir haben zur Beschreibung von „Bubblesort“ oben z.B. eine informelle Notation in Umgangssprache verwendet. Dabei sy mbolisiert die Einrückung eine Bl ockbil-
22 3
Algorithmen
dung, d h. alle inne rhalb eines zusamm engesetzten Verarbeitungsschrittes (W iederholung, bedingter Verarbeitungsschri tt) auszuführe nden elem entaren Verarbeitungsschritte stehen auf dersel ben Einrückungsebene. W ir könnten denselben Algorithmus „Bubblesort“ aber auch mit Hilfe eines Struktogramms darstellen (siehe Abschnitt 3.5).
3.2
Struktur von Algorithmen
Alle Algorithmen weisen gewisse strukturelle Ge meinsamkeiten auf (die allerdi ngs in de n verschiedenen Darstellungsarten oft sehr unte rschiedlich besc hrieben werde n). Alle durch Algorithmen beschreibbaren Berechnungen kann man durch elementare Verarbeitungsschritte, bedi ngte Verarbeitungsschritte sowie Folgen bzw. W iederholungen von elementaren Verarbeitungsschritten darstellen. Diese Klassen vo n Bausteinen werden daher auch oft als Strukturelemente von Algorithmen bezeichnet. Elementare Verarbeitungsschritte Es gibt λ unteilbare Verarbeitungsschritte, die unbedingt ausgeführt werden, z.B.: schalte rotes Licht aus Neben elementaren Verarbeitungsschritten benötigt man zur Beschreibung von Abläufen drei Arten vo n zusammengesetzten Verarbeitungsschritten: Se quenzen, be dingte Verarbeitungsschritte, Wiederholungen. Sequenzen Hintereinander auszuführende elem entare Verarbeitungsschritte können zu Sequenzen zusammengefasst werden (j eder Schritt übernimmt dabei das Ergebnis seines Vorgängers), z.B.: schalte gelbes Licht aus schalte grünes Licht ein Für die Trennung der einzelnen Komponenten einer solchen Sequenz wird ein festes Trennzeichen vereinbart, z.B. ein Strichpunkt und/oder ein Zeilenwechsel. In Programmiersprachen ergibt sich oft der Bedarf, Sequenzen mit Hilfe von Be grenzungssymbolen zu einem Block zusammenzufassen. Gebräuchlich sind z.B. begin und end bei Pascal-ähnlichen Sprachen oder gesc hweifte Klam mern { und } in C, C++ ode r Java. I n manchen Sp rachen kön nen Blöcke auch du rch d ie A nordnung auf d erselben Einrückungsebene festgelegt werden (z.B. in Python). An vielen Stellen könne n solche Blöcke dann anstatt einzelner Verarbeitungsschritte verwendet werden.
3.2 Struktur von Algorithmen
23
Bedingte Verarbeitungsschritte Manche Verarbeitungsschritte sollen nur unter einer bestimmten Bedingung ausgeführt werden. Oft will man zusätzlich einen alternativen Verarbeitungsweg angeben, der auszuführen ist, falls die o.g. Bedingung nicht erfüllt ist. Beispiel 1: Falls der betrachtete Name alphabetisch hinter den folgenden gehört Vertausche die beiden Namen Ende Falls Beispiel 2: Falls Nenner ≠ 0 dann Dividiere Zähler durch Nenner sonst Melde einen Fehler Ende Falls Ebenso wie elementare können auch zusammengesetzte Verarbeitungsschritte einer be dingten Verarbeitung unterzogen werden. Beispiel 3: Falls die PIN akzeptiert wurde Wiederhole bis die Netzverbindung steht Warte Ende Wiederhole Sonst Meldung „Abbruch: 3x falsche PIN!“ Schalte das Telefon aus Ende Falls Wiederholung Sequenzen von Verarbeitungsschritten müssen oft wiederholt ausgeführt werden. Die Anzahl der W iederholungen wir d wieder um durch ei ne bestim mte Bedingung gere gelt. Auc h die Wiederholung von zusammengesetzten Verarbeitungsschritten ist möglich. Beispiel: Wiederhole für alle Namen vom ersten bis zum vorletzten Falls der betrachtete Name alphabetisch hinter den folgenden gehört Vertausche die beiden Namen Ende Falls Ende Wiederhole
24 3
Algorithmen
Dabei macht es einen erheblichen Unterschied, ob die Anzahl der Wiederholungen schon vor dem ersten Durchlauf feststeht oder ob sie sich erst im Lauf der einzelnen Wiederholungen ergibt, da es i m zweiten Fall nicht unbedi ngt klar ist, ob die Wiederholung auch irgendwann wieder abbricht („term iniert“, siehe Absch nitt 3.4). Diese Unterscheidung ist s ogar s o wesentlich, das s man di e M enge der F unktionen, di e überha upt m aschinell berechenbar sind, nach diesem Merkmal unterscheidet. Wir unterscheiden daher zwischen • Wiederholungen mit vorgegebener Wiederholungszahl und • Wiederholungen mit Anfangs- (bzw. End-) Bedingung. Erstere sind flexibler und erlauben die Berechnung einer größeren Vielfalt von F unktionen, letztere terminieren sicher.
3.3
Umsetzung in Programmiersprachen
Wie oben bereits erwähnt, gibt es eine Viel zahl von M öglichkeiten, einen Algorithmus darzustellen. Ei ne der wichtigst en und häufigsten Darstellungsarten ist di e durch (m aschinell ausführbare) Programme, also durc h Texte in bestim mten Programmiersprachen. I n Kapitel 4 werden wir genauer besprec hen, wie man solche Sprachen definieren und ver wenden kann. Insbesondere kann man Programmiersprachen nach Programmierstilen ordnen. Dabei findet man u .a. zw ei w ichtige Programmierstile (oft auc h als Programmierparadigmen bez eichnet): de n imperativen (z uweisungsorientierten) und de n funktionalen Stil. Den ersten Stil unterstützen z.B. die Sprac hen Pascal, C oder Basic, de n zweiten Haskell oder ML. Zu r Verdeutlichung der Unterschiede werden in der folgenden Tabelle 3.1 die typischen Umsetzungen der oben be schriebenen Strukturelemente von Algorithmen in beiden Stilen gegenübergestellt. Beide Program mierstile werden in den folgenden Kapiteln dieses Buches noch ausführlich be handelt: de r i mperative Stil in Kapitel 5 und 6 bzw. de r funktionale Stil in Kapitel 7.
3.4 Eigenschaften von Algorithmen Tab. 3.1
Strukturelemente im imperativen und funktionalen Stil
Strukturelement El. Verarbeitungsschritt(e) Sequenz
Auswahl Wiederholung Iteration
3.4
25
Typische imperative Umsetzung Zuweisung (von Werten an Variable), Ein- und Ausgabeoperationen. Anweisungsfolgen, wobei die Datenübergabe zwischen aufeinander folgenden Anweisungen durch Zwischenspeicherung von Ergebnissen in Variablen erfolgt. Bedingte Anweisung
Typische funktionale Umsetzung Funktionsanwendung (-applikation) Verkettung von Funktionen; das Ergebnis der aufgerufenen Funktion wird als Argument an die aufrufende Funktion übergeben. Bedingter Term Rekursion
Eigenschaften von Algorithmen
Für die Umsetzung und Beurteilung von Algorithmen sind einige Ei genschaften von besonderem Belang: Ein Algorithmus heißt • terminierend, wenn seine A usführung f ür jede m ögliche Einga be nac h einer endlichen Anzahl von Schritten endet, • deterministisch, wenn jede Eingabe für jede seiner auszuführenden Anweisungen jeweils eindeutig die Folgeanweisung festlegt (mit Ausnahme der letzten Anweisung, nach der er endet), • determiniert, wenn e r f ür e ine bestim mte Einga be bei allen Ablä ufen im mer dieselbe Ausgabe liefert. Diese Eige nschaften w urden urs prünglich f ür mathematisch definierte abstra kte Masc hinen (wie Turing-Maschinen oder endliche Automaten) eingeführt. Sie beziehen sic h daher ausschließlich auf Algorithmen, die nach dem E-V-A-Prinzip (Eingabe – Verarbeitung – Ausgabe) arbeiten, d h. während der Verarbeitung keine weiteren Eingaben des Benutzers zulassen. Sola nge keine formale Darstellung des Algorithmus vorliegt, bleibt zudem meist eine gewisse Unschärfe in der Zuordnung der Eigenschaften. Einige Beispiele sollen diese Eigenschaften verdeutlichen: Algorithmus Schach Eingaben: Schachbrett mit Figuren in Ausgangsposition Wiederhole bis Schwarz oder Weiß schachmatt Ziehe mit weißer Figur gemäß den Regeln des Schachspiels Ziehe mit schwarzer Figur gemäß den Regeln des Schachspiels Ende Wiederhole Ausgabe: Endposition der Figuren Ende Algorithmus
26 3
Algorithmen
Obwohl es fraglich ist, ob die Züge im Sinne der obigen Definition aus 3.1 als „elementare (Verarbeitungs-) Schritte“ betrachtet werden können, kann man doch fests tellen, dass dieser Algorithmus • weder terminierend (das Spiel kann in einen Zyklus geraten und so ewig dauern), • noch deterministisch (meist gibt es viele mögliche Züge), • noch determiniert (es gibt viele Möglichkeiten für den Endstand) ist. Algorithmus Notenwillkür Eingabe: Liste der Schüler einer Klasse Wiederhole für jeden Schüler, bis das Ende der Liste erreicht ist Wähle willkürlich aus: Gib dem Schüler eine 1 Gib dem Schüler eine 2 Gib dem Schüler eine 3 Ende Willkür Ende Wiederhole Ausgabe: Liste der Schüler mit der jeweiligen Note Ende Algorithmus Dieser Algorithmus ist • terminierend (er endet sicher mit dem Ende der Schülerliste), • nichtdeterministisch (es gibt drei mögliche Verarbeitungsschritte in der Wiederholung), • nichtdeterminiert (es gibt viele Möglichkeiten für die Notenliste). Algorithmus Bauernziehen Eingabe: Eine Position auf dem Schachbrett Wiederhole bis obere rechte Ecke erreicht Wähle willkürlich aus: Falls rechter Rand noch nicht erreicht: Ziehe nach rechts Falls oberer Rand noch nicht erreicht: Ziehe nach oben Ende Willkür Ende Wiederhole Ausgabe: Anzahl der Züge Ende Algorithmus Dieser Algorithmus ist • terminierend (er endet sicher an der rechten oberen Ecke),
3.5 Pseudocode und Struktogramme
27
• nichtdeterministisch (es gibt jeweils zw ei mögliche Verarbeitungsschritte solange die Ränder nicht erreicht sind), • aber determiniert (f ür je de Position gibt es ge nau ei ne Anzahl von Zügen z um rechten oberen Eck). Der Algorithmus Bubblesort aus 3.1 liefert schließlich ein Beispiel für einen der sowohl terminierend als auch deterministisch und determiniert ist.
Algorithmus,
Bei strenger Anwendung der Definition des Begriffes „Algorithmus“ aus 3. 1 folgt aus Determinismus und Term inierung zwangsläufig D eterminiertheit: W enn es an j eder Stelle des Ablaufs nur einen möglichen Verarbeitungsschritt gibt, dann kann es auch nur ein mögliches Ergebnis geben. Ließe man dagegen die V erwendung nichtdeterminierter (damit aber nicht mehr im strengen Sinne elementarer) Verarbeitungsschritte zu (z.B. di e Produktion einer Zufallszahl), dann wird dieser Zusammenhang durchbrochen: (Pseudo-)Algorithmus Zufallszahl Eingabe: Anfangswert Addiere eine zufällige Zahl zwischen 0 und 1 zum Anfangswert Ausgabe: Ergebnis der Addition Ende Algorithmus
3.5
Pseudocode und Struktogramme
Die Ve rwendung von „ec hten“ (d h. tatsächlich auf einer realen M aschine ausführbaren) Programmiersprachen zur Darstellung von Algorithmen hat neben dem Vorteil der Ausführbarkeit leider auch eini ge Nachteile: Zu m einen ha ndelt man sich dam it die Verpflichtung zur Ei nhaltung einer speziellen (oft zufällig ent worfenen ode r auf eine spezielle Maschine abgestimmten) Syntax ein, zum anderen si nd die Programme oft nicht gerade intuitiv verständlich. Daher stellt man Algorithmen oft in einer nicht maschinell ausführbaren Sprache oder Symbolik dar, vor allem, wenn man die logischen Strukturen in den Vordergrund stellen will. Eine Möglichkeit dazu bieten inform elle (d h. nicht exakt festge legte) Sprachen, die nahe an unserer Umgangssprache liegen und dennoch eine ausreichende Präzision aufweisen (Pseudocode). Alle bisher in diesem Buch darg estellten Algorithmen wurden in ei nem solchen Pseudocode formuliert, dessen wichtigste Elem ente wir nun noch der Übersicht halber zusammenstellen wollen (siehe Tabelle 3.2).
28 3 Tab. 3.2
Algorithmen Strukturelemente von Algorithmen in Pseudocode
Strukturelement Pseudocode elementarer Verarbeitungsschritt Sequenz
; ; …
;
Bedingter Verarbeitungsschritt
Falls
Dann
Sonst Wiederholung mit Anfangsbedingung
Ende Wenn
Wiederhole solange
Ende Wiederhole
Eine Möglichkeit, die Strukt ur kleinerer Algorithmen sehr übersichtlich darzustellen, bieten Struktogramme, die 1973 von Nassi und Shne iderman eingef ührt w urden (siehe Abbildung 3.2). Leider sind sie zur Darstellung komplexerer Algorithmen weniger geeignet.
Eingabe: Liste von Namen Wiederhole (Anzahl der Elemente der Liste - 1) mal Wiederhole für alle Namen vom ersten bis zum vorletzten
wahr
Falls der betrachtet Name alphabetisch hinter den folgenden gehört
falsch
Vertausche die beiden Namen Ausgabe: Sortierte Liste von Namen
Abb. 3.2
Struktogramm des Bubblesort-Algorithmus
Die folgenden Abbildungen 3.3 bis 3.6 zeigen, wie die einzelnen Strukturelemente von Algorithmen in Struktogramme umgesetzt werden.
Abb. 3.3
Elementarer Verarbeitungsschritt im Struktogramm
3.6 Aufgaben
29
...
Abb. 3.4
Sequenz im Struktogramm
wahr (erfüllt)
falsch (nicht erfüllt)
Abb. 3.5
Bedingter Verarbeitungsschritt im Struktogramm
Wiederhole solange
Abb. 3.6
3.6
Wiederholung mit Anfangsbedingung im Struktogramm
Aufgaben
Aufgabe 3.1: Sie telefonieren von eine r Telefonzelle mit einer Bekannten. Beschreiben Sie den gesamten Vorgang durch einen Algorithmus. Zeichnen Sie dazu auch ein Struktogramm. Aufgabe 3.2: Im Folgenden s ind mehrere Beispiele für Kandidaten von Algorithm en angegeben. Geben Sie jeweils an, ob es sic h um einen Algorithmus gemäß der I hnen bekannten Definition handelt. P rüfen Si e in diesem Fall, welche de r in A bschnitt 3. 4 beschriebenen Eigenschaften (terminierend, deterministisch, de terminiert) der Algorithmus aufweist. Begründen Sie Ihre Antworten! a)
Berechnen Sie die reelle Zahl „Qua dratwurzel von 2“ durch Intervallschachtelung. (Zur Erinnerung: Intervallschachtelung ist eine Folge immer kürze r festgelegter abgeschlossener Intervalle, wobei jedes Intervall vom vorhergehenden umfasst wird).
30 3
Algorithmen b) Kochrezept (Weinpunsch): 3 – 4 Ka ffeelöffel schwarzen Tee mit 1,5 Litern Wasser angießen und zugedeckt 3 – 4 Minuten ziehen lassen. Dazu gebe man ein Pfund Zucker i n Würfeln, de n Sa ft von drei Orangen und zwei Zitronen. Ma n nehm e ein Stückchen Zitr one und reibe mit diesem au f einem Stück Zuc ker einige Male hin und her, damit etwas Schale daran haftet, was dem Punsch einen feinen Geschmack verleiht. Man gebe 1,5 Liter guten Rotwein und 1 Flasche Weißwein hinzu, erhitze alles bis zum Sieden und ste lle die Flüssigkeit vom Feuer. Man gebe 0,25 Liter guten Arrak hinzu; es kann je doch nach Belieben mehr oder weniger genommen werden, ohne dass der Geschmack des Punsches beeinträchtigt wird. c)
Beschreiben die Regeln zur Durchführung des Schachspiels einen Algorithmus?
d) Zwei Straßen kreuzen sich; die Vorfahrt ist nur durch die Vorschrift rechts vor links geregelt. Bildet diese Regel einen Algorithmus, der die Fahrt über die Kreuzung beschreibt? Aufgabe 3.3: Sie lassen sich an einem Geldaut omaten mit Hilfe Ihre r E C-Karte einen bestimmten Betr ag ausza hlen. Beschrei ben S ie den gesam ten V organg durch eine n Al gorithmus und zeichnen Sie ein Struktogramm dazu.
4
Programmiersprachen
In der langen Geschichte der Informatik (bzw. EDV) wurde eine Vielzahl von Programmiersprachen mit z um Tei l sehr un terschiedlicher Zielsetzung entwickelt. Welche davon ist die beste? Darauf gibt es keine einde utige Ant wort: Je na ch Einsatzzweck und Ra hmenbedingungen kann die Verwendung der einen oder d er anderen Sprache günstiger sein. Ein Informatiker m uss dahe r m it mehreren S prachen verschiedener Sprac htypen (siehe unten) gut umgehen und sich in praktisch jede andere zumindest schnell einarbeiten können, um die für ein bestimmtes Projekt am besten „passende“ Sprache auswählen und anwenden zu können. Viele Programmiersprachen lassen sich in S prachklassen einteilen, die sich zum Teil erheblich in ihrer Konzeption un terscheiden. Wir werden in Kapitel 5 anha nd einer (speziell fü r diesen Z weck konzipierte n) Übungss prache ( PPS) eine n bestim mten Typ von S prachen (imperative Sprachen) einge hend bet rachten, de r relativ nahe a n de r tatsächlichen Arbeitsweise des Rechne rs liegt. Zusätzlich werden wir alle Programm e auch in eine r tatsächlich ausführbaren i mperativen Sprache (Python) formulieren. In Kapitel 7 werden Sie dann mit Haskell eine weitere „echte“ Programmiersprache eines anderen Sprachtyps kennen lernen. Im Inform atik-Duden (siehe Volker, Schwill, 2001), de n wir Ihne n als Nachschlagewerk ohnehin wärmstens ans Herz legen wollen, finden Sie auf S. 511 einen schönen Stammbaum der wichtigsten bisher entwickelten Programmiersprachen.
4.1
Programmierbare Rechner
Digitale elektronisch e Rechenanlagen werd en heute für unzählige Aufgaben ei ngesetzt. Dennoch haben all diese Einsatzszenarien zumindest eines gemeinsam: Ein Rechner oder ein Rechnernetz nimmt eine Folge von digitalen („Strom/Spannung ein“ bzw. “Strom/Spannung aus“) elektrischen Signalen entgegen und gibt nach einer Folge von Verarbeitungssc hritten eine andere Folge von digitalen elektrischen Signalen aus, die dann entweder zur Steuerung weiterer Geräte verwendet oder (z.B. a uf einem Bildschirm oder eine m Druc ker) in einer Form dargestellt werden, die von Menschen interpretierbar ist. Welche Ausgabe dabei durch eine bestim mte Eingabe ausgelöst wird, hängt von der H ardund S oftwarestruktur der Re chenanlage ab. De r große Er folg de r di gitalen Reche nanlagen wurde vor allem durch die Möglichkeit ihrer „freien“ Programmierung ermöglicht („Universalrechner“). Im Gegensatz zu „fest verdrahteten“ Anla gen, dere n Rechenvorgänge (Al gorithmen bzw. Funktionen) von vo rneherein durch die Struktur ihrer Hardware (bzw. unver-
32 4
Programmiersprachen
änderlicher S oftware) fe stgelegt sind (z .B. nichtprogrammierbare Tasc henrechner), werde n Universalrechner durch Programme gesteuert, die vom Benutzer entworfen, modifiziert und wieder gelöscht werden können.
4.2
Programmiersprachen und Maschinencode
Ein Programm ist eine Darst ellung eines bestimmten Algorithmus als Text in eine r speziellen Sprache, die vom Rechner autom atisch in terpretiert werden ka nn. Eine solche Sprache heißt Programmiersprache. Programme liegen bei ihrem Ablauf e benso wie die Daten, m it denen s ie arbeiten, im Arbeitsspeicher des Rechne rs (a ls ein Muster aus ve ränderbaren, digitalen Spa nnungszuständen) vor. Dieses Prinzip bezeichnet man auch als von-Neumann-Prinzip. Da die dire kte Eingabe dieser digitalen Muster sehr mühsam wäre, hat man so genannte höhere Programmiersprachen entwickelt, die ehe r die Str uktur der Aufgabenstellung als die der Rec henanlage widerspiegeln. In einer höheren S prache ( hier Python) lautet zu m Beispiel ein Programm zur Berec hnung und Ausgabe der ersten 100 Quadratzahlen: for i in range(1,101): print i*i Auf Maschinenebene wird dieses Programm durch ein Muster aus digitalen Signalen repräsentiert, die m an übliche rweise durc h die Sym bole 0 („ Strom/Spannung a us“) und 1 („Strom/Spannung ein“) symbolisiert. Es könnte z.B. so beginnen: 01010011 00101111 11001010 00101010 10101010 00101010 … Natürlich hängt dieser „Maschinencode“ (im Gegensatz zum Text eines Python-Programms) sehr sta rk von der jeweils ve rwendeten Ma schine a b: Jeder Maschi nentyp hat seine ei gene „Maschinensprache“. Die daraus resultierende m angelnde Übert ragbarkeit des Programms auf andere Maschine n stellt einen schwerwieg enden Nachteil der di rekten Maschinenprogrammierung dar.
4.3
Übersetzerprogramme
Da di gitale Rechenanlagen al so letztlich durc h Folgen aus binä ren („0“ ode r „1“) elektrischen Signalen gesteuert werden, stellt sich di e Frage, wie die Konstrukte höherer Programmiersprachen in solche Bitfol gen umgewandelt werden. Dafür gibt es spezielle Progra mme, die dafür sorgen, dass diese (sehr mühsame) Übe rsetzungsarbeit vom Rechner selbst übernommen werden kann. Diese Übersetzungsprogramme können in zwei Kategorien aufgeteilt werden:
4.4 Syntax formaler Sprachen
33
1. Compiler: Vor der A usführung des Programms wird der gesamte Programmtext der entsprechenden höhe ren Pr ogrammiersprache in Maschine ncode übersetzt, der im Hauptspeicher des Rechners aufbewahrt (oder auch für s pätere Verwendung auf die Festplatte ausgelagert) w ird, bis seine tatsächliche Abar beitung be ginnt. Dann e ntsteht dara us ein aktiver Prozess. Abbildung 4.1 zeigt ein ZÜD dieses Vorgangs. Übersetzen Programmtext fertig
Abb. 4.1
Prozess starten Maschinencode im Hauptspeicher
Prozess läuft
Vom Programmtext zum Prozess
2. Interpreter sind spezielle Programm e, die Anweisungen des Programmtextes (einer bestimmten höheren Programmiersprache) einzeln auswerten und sofort ausführen.
4.4
Syntax formaler Sprachen
Aber warum programmiert man Com puter nich t einfach in unserer Alltagssprache? Weil diese nicht eindeutig i nterpretierbar ist. Wie soll z.B. die Anweisung „Fahr nac h Ha use“ interpretiert werden? Mit dem Auto, Fahrrad oder mit dem Zug? Wie schnell? Auf welchem Weg? Die Bedeutung dieser Anweisung hängt stark vom zeitlichen, regionalen und persönlichen Kontext ab. Solc he Freiheiten in de r Inter pretation sind f ür die Pr ogrammierung von Rechenanlagen (in der Re gel) aber a bsolut nicht erwünscht. Daher verwendet man zur Programmierung spezielle künst lich entworfe ne Sp rachen, die m it form alen Mitteln exa kt zu beschreiben sind. Solche Sprachen nennt man daher formale Sprachen. Definition: Eine nichtleere Menge von Zeichen A heißt Alphabet. A* bezeichnet dann die Menge aller Zeichenketten mit Zeichen aus A (dazu gehört auch die leere Zeichenkette ε, die aus 0 Zeichen besteht). Jede Teilmenge F von A* heißt dann formale Sprache. Die Elemente von F heißen Wörter der Sprache. Beispiel: A = {0,1}; A* = {ε, 0, 00, 000, .. , 1, 11, 111, .. , 01, 010, 101, .. , usw.}. A* enthält alle Kombinationen aus den Zeichen 0 und 1. Folgende Mengen sind z.B. formale Sprachen über dem Alphabet A: F1 = {1, 11, 111, 1111, ...} F2 = {01, 0101, 010101, 01010101, ...} F3 = {ε, 01, 0011, 000111, 00001111, ...} Man kann natürlich auc h a ndere Al phabete ve rwenden, z.B. B = { ´A´, ´B ´, … , ´Z ´, 0 , 1, …, 9, _, –}, wobei _ für das Leerzeichen steht.
34 4
Programmiersprachen
Dann k ann man üb er dem Alphabet B et wa die Menge der zulässi gen Autoke nnzeichen F4 = {RO-K_345, M-PT_3232, K-RS_1212, ...} als formale Sprache definieren. Zur Vermeidung von Missverständnissen müsste man eigentlich alle Zeichen und Zeichenketten in Anführungszeichen einschließen, was aber die Lesbarkeit der Texte (besonders i n der Theoretischen Informatik) stark verschlechtern würde. Darum macht man das meist nur dann, wenn eine Verwechslung mit anderen Symbolen möglich ist (hier mit dem Bezeichner B unseres Alphabets). In der Regel enthalten formale Sprachen abzähl bar une ndlich viele Elemente. Es ist also meist nicht möglich, sie alle direkt anzugeben. Man benötigt daher eine endliche Beschreibung für die Erzeugung der Wörter einer Sprache. Eine solche Beschreibung heißt Syntax der Sprache. Die Syntax legt also fest, welche Z eichenketten zu einer Sprache gehören und welche nicht. Neben der Syntax spielt in der Informatik auch noch die Semantik (die Bedeutung) von Wörtern (genauer: von Folgen von Wörtern) einer Sprache eine große R olle. Die Semantik eines bestimmten Program ms beschrei bt, welc he Auswirkungen die Ausführung des Pr ogramms auf den Rechner hat. Um diesbezüglich keine Missverständnisse (mit evtl. fatalen Folgen wie der Fehlauslösung eines Airbags oder dem Absturz eines Flugzeugs) aufkommen zu lassen, versucht man, diese Sem antik m öglichst mit formalen (d h. m athematischen) Mitteln hiebund stichfest z u bes chreiben. Im Idealfall kann man da nn aus dem Program mtext die Korrektheit eines Programms beweisen. Hinsichtlich der verwendeten Sym bole finden sich zwei große Gru ppen von höheren Programmiersprachen, deren Notation sich u.a. in drei (sehr häufig gebrauchten) Punkten unterscheidet (sie he folge nde Ta belle). Viele (aber nicht alle) Program miersprachen ge hören zu einer der beiden Gruppen: Syntax C-ähnliche { Pascal-ähnliche
Klammern um Sequenzen (Blöcke) Zuweisung Vergleich } = = = begin … end := =
Beispiele für C-ähnliche Sprachen sind C++, Java oder Python, für Pascal-ähnliche Modula oder Oberon. Da man die Qualität eines inform atischen Fachbuches nicht danach beurteilen kann, welc he Sprache es zur Darstellung vo n Al gorithmen verwendet, erwa rtet m an von einem Informatiker, dass er beide N otationsformen gleicherm aßen be herrscht. Wir werden deshalb in die sem Buch ab Kapitel 5 eine „Pascal-ä hnliche“ Pse udo-Programmiersprache (für die es zw ar keinen Compiler gibt, m an aber jederzeit einen sc hreiben könnte) verwenden, während wir parallel eine C-ähnliche „echte“ Programmiersprache (Python) einführen.
4.5 Backus-Naur-Form
4.5
35
Backus-Naur-Form
Es gibt viele gute Möglichkei ten, die Syntax formaler Sprachen zu beschreiben. Eine besonders be kannte und leistungsf ähige Met hode ist die Backus-Naur-Form (BNF), die von Ji m Backus und Pete Naur zur Definition der Programmiersprache Algol 60 eingeführt wurde. Da diese Ur-Form der B NF ziemlich spart anisch wa r, wurde sie im Lauf de r Zeit i mmer wieder erweitert, leider nicht immer unter Berücksichtigung bereits erfolgter Erweiterungen. Daher existieren heute zahlreiche Formen einer „erweiterten BNF“ (EBNF) mit unterschiedlicher Verwendung derselbe n Sym bole. Mittle rweile gibt es glückliche rweise eine n ISOStandard (Nr. 14977, siehe auch Informatik-Duden) für EBNF, an den wir uns hier weitgehend halten w erden. Leider kann Ihne n in der Literatur aber jederzeit eine andere Form der EBNF begegnen. Die Syntax unserer ersten beiden Sprachbeispiele würde damit folgendermaßen formuliert: Syntax von F1: Syntax von F2:
::= 1* ::= 01 {01}
In den BNF-Regeln sind folgende Elemente zugelassen: 1. Syntaktische Variable: Platzhalter für synta ktische Elemente (Nichtterminale) in spitzen Klammern, z.B. , die auch rekursiv verwendet werden dürfen. 2. Genau ein Sym bol „::=“ im Sinne eine r Zuweisung eine s sprac hlichen Ausdrucks a uf der rechten Seite an eine syntaktische Variable auf der linken, in etwa mit der Bedeutung: „besteht aus“. 3. Terminalsymbole: Zeichen aus A oder Zeichenketten aus A*, die genau in dieser Form in Wörtern der Sprache enthalten sind, z.B. 0, 1, ´A´, ´B´, ´–´, „begin“, „end“. 4. Operatoren zur Verknüpfung von Terminalen und/oder syntaktischen Variablen: • Verkettung, symbolisiert durch Hintereinanderschreibung, getrennt durch ein Komma (ISO-BNF) oder oft auch nur ein Leerzeichen (wir verwenden aus Übersichtlichkeitsgründen ein Leerzeichen), • runde Klammern ( ) für die Klammerung von Ausdrücken (z.B. in den Zweigen einer Auswahl, siehe unten), • eine senkrechte Linie | für die Auswahl zwischen mehreren Möglichkeiten, • ein Stern * für die Wiederholung in beliebiger Anzahl (auch 0), bezogen auf das letzte Zeichen links davor, • geschweifte Klammern { } für die Wiederholung des Ausdrucks zwischen den beiden Klammern in beliebiger Anzahl (auch Anzahl = 0), • eckige Klammern [ ] für optionale Ausdrücke, die hinzugefügt werden können oder auch nicht. Auch diese Regeln legen übrigens eine Sprache fest, nämlich die Sprache der BNF-Regeln. Am Beispiel der Syntax von F4 wollen wi r das verdeutlichen. Die Synt ax besteht aus drei Regeln R1, R2, R3, mit:
36 4
Programmiersprachen
R1: ::= ´A´ | ´B´ | ... | ´Z´ R2: ::= 0 | 1 |.. | 9 R3: ::= [] [] – [] Leerzeichen [] [] [ … oder durch Anwendung von R1c, R1a, ... mit A2 = → – → – ´(´´)´ … beginnen, je nachdem, ob man erst seinen Minuenden oder erst seinen Subtrahenden erzeugt.
4.6
Syntaxprüfung mit endlichen Automaten
Bei einer bestimmten Unterklasse de r formalen S prachen (den regulären Sprachen, si ehe Literatur zur Theoretischen Informatik) kann die Überprüfung der syntaktischen Korrektheit eines Ausdruckes m it Hilfe eines erke nnenden endlichen Automaten durc hgeführt werden (siehe auch Kapitel 2). Abbildung 4.3 zeigt als Beispiel den endlichen Automaten zur Erkennung der Sprache F2 (siehe auch Abschnitt 4.5).
0,1
Fehler! 1
0 z0
1
0
1 z2
z3
0 Abb. 4.3
Automat für die Sprache F2 (siehe Abschnitt 4.5)
40 4
Programmiersprachen
Die Sprache F3 könnte man übrigens nicht mit einem endlichen Automaten erkennen, da sich der Automat die Anza hl der Nullen „merken“ muss, um prüfen zu können, ob ebenso viele Einsen folgen. Dafür braucht er je zusätzlicher Null auch einen zusätzlichen Zustand. Da die Anzahl der Nullen und Ei nsen jedoch nicht nach obe n begrenzt ist, gibt es für jeden endlichen Automaten mit n solchen Zuständen für n Nullen und n Einsen mindestens ein Wort, das der Automat nicht mehr erkennen kann (nämlich das m it n+1 Nullen und n+1 Einsen). Solche nichtregulären Sprachen kommen leider sehr hä ufig vor, z.B. wenn sie (wie viele Programmiersprachen) unbegrenzt tief geschachtelte Klammerpaare um beliebige Ausdrücke oder „begin“–„end“-Schachtelungen um Blöcke enthalten. Mehr darüber können Sie wiederum in der Literatur zur Theoretischen Informatik nachlesen.
4.7
Die Ebenen der Softwareentwicklung
Wie wir gesehen haben, gibt es einiges z u bedenken, bevor ein Programmtext in einer höhe ren Programmiersprache (z.B. Java) tatsächlich auf einer realen Maschine ausgeführt werden kann. Nimmt man di e Arbeiten hi nzu, di e bere its vor de m eigentlichen Verfassen dieses Programmtextes (Codieren) anfallen, so zeichnen sich fünf verschiedenen Ebenen ab, die bei der Softwareentwicklung von Interesse sind: 1. Menschliche Erfahrungswelt: hier sind die A ufgabenstellungen a ngesiedelt, m it dene n wir es zu tun haben. Das ist die W elt der Firmen, Geräte, der menschlichen Kommunikation, der sozialen Systeme. 2. Modellebene: Nach Abgrenzung , Abstraktion, Idealisierun g und geeigneter Darstellung gelangen wir zur Ebene der Automaten, Algorithmen, ER-Modelle, relationalen Modelle, usw. (siehe auch Kapitel 1). Diese Ebene ist gegenüber der menschlichen Erfahrungswelt stark vereinfacht, wodurch die Aufgabenstellung oft überhaupt erst lösbar wird. Die Modelle spiegeln die wesentlichen Eige nschaften de r Aufgabe wieder, aber (im Idealfall) keine speziellen Strukturen der Zielmaschine, auf der das Programm laufen soll oder der zu verwendenden Programmiersprache. 3. Programmtext: Gemäß dem Modell (oder den Modellen) wird der Programmtext in einer (oder auch mehreren) höheren Programmiersprachen geschrieben. Dieser Vorgang heißt Implementierung des Modells. 4. Maschinencode: Der Prog rammtext wird von ei nem Übersetzun gsprogramm in d irekt ausführbaren Maschinencode transformiert. 5. Prozess: Das Maschinenprogramm wird ausgeführt. Es entsteht ein realer Prozess. Der Übergang von der 2. z ur 3. E bene ( vom Modell zum Pr ogrammtext) wi rd i nzwischen zunehmend automatisiert. Werkzeuge aus dem Bereich des „Computer Aided Software Engineering“ (CASE ) nehm en den Softwa reentwicklern diese Arbeit in vielen Fällen ab. So kann heute aus einem Mode ll, das z.B. i n de r normierten M odellierungssprache Unified Modeling Language (UML) beschrieben ist, mit zahlreichen Werkzeugen zumindest teilweise dire kt a usführbarer P rogrammtext erzeugt werden (beispielsweise in Java oder C++).
4.8 Aufgaben
41
Damit werden viele Programmierfehler vermieden, die beim Eingeben des Programmtextes entstehen. Zwischen Programmtext und Maschinencode wird heute (z.B. bei Java) oft noch eine weitere Ebe ne eingeschobe n, inde m man mit dem Co mpiler zunäc hst Code für eine abst rakte virtuelle Maschine erzeugt, der da nn in einem zweiten Schritt in den für die jeweilige reale Maschine passenden Code übersetzt wird. D er Vorteil liegt vor allem darin, dass man damit kompilierte Programm e vertreiben ka nn (z.B. übe r das Internet), bei denen der Benutzer keinen Zugriff auf den Programmtext hat, di e aber dennoch auf jeder Maschine laufen können (sofern dafür ein Ü bersetzungsprogramm für den Zwischencode vorliegt). Für Maschinentypen, die erst nach der Fertigstellung ei nes bestimmten Progra mms (z.B. eines OfficePakets) gebaut werden, muss daher nur noch eine virtuelle Maschine je Programmiersprache (z.B. Java) programmiert und nicht mehr jedes Programm neu übersetzt werden.
4.8
Aufgaben
Aufgabe 4.1: Beschreiben Sie mit Hilfe von EBNF-Regeln die folgenden formalen Sprachen. Geben Sie je weils das ve rwendete Alphabet an . Leiten Sie jeweils das letzte angegebe ne Wortbeispiel mit Hilfe Ihrer Grammatik ab. a)
SUhr sei die Menge aller möglichen Anzeigen einer Digitaluhr mit Stunden, Minuten und Sekunden, z.B. 23:13:55.
b) Sab besteht aus allen Wörtern, in denen abwechselnd ’a’ und ’b’ auftreten (z.B. a, b, ab, ba, aba, bab, abab, baba usw.). c)
SP umfasst alle Palindr ome (d h. Wörter, die von vorne bzw. hinten gelesen gleich lauten), die man aus den Buchstaben ´a´, ´b´ und ´c´ bilden kann, z.B. ε, a, cc, aba , aabaa, abccba usw.
d) SAK ist die Menge aller Zeichenfolgen, die auf deutschen Autokennzeichen erlaubt ist (vgl. Aufgabe 2.5). Aufgabe 4.2: Beschreiben Sie die Syntax der römischen Zahlen m it Hilfe von B NF. Leiten Sie dam it die Zahl MCMD CVII he r. Geben Sie zusätz lich einen e rkennenden e ndlichen Automaten an.
5
Imperative Programmierung
In diesem Kapitel werd en wir un s mit ein em sp eziellen Typ von Pr ogrammiersprachen beschäftigen: de n imperativen Programmiersprachen. Da runter ve rsteht m an Sprachen, deren elementare Verarbeitungsschritte Zuweisungen von Werten an Variable sind. Man nennt sie deshalb auch zuweisungsorientierte Sprachen. Im Gegensatz dazu we rden wir uns in Kapitel 7 einen anderen Sprachtyp genauer ansehen: die funktionalen Sprachen, deren elementare Verarbeitungsschritte die Anwendung von Funktionen (Funktionsapplikation) sind. Neben diesen Untersc hieden findet man in den beiden Sprachtypen abe r a uch viele gem einsame Konzepte.
5.1
Sprachen und Programmierumgebungen
In diesem Buch werden gleich zwei imperative Sprachen eingesetzt: 1. von Anfang an eine Pseudo-Programm iersprache (für die es zwar kei nen Compiler gibt, den m an aber jederzeit schre iben könnte) mit „Pascal-ähnli cher“ Synta x, im Folgenden kurz mit PPS (Pseudo-Programmier-Sprache) bezeichnet, 2. etwas später eine „echte“ Programmiersprache mit „C-ähnlicher“ Syntax (Python). Die Syntax der Pse udosprache PPS ähnelt in vielerlei H insicht de r von M. Br oy in seinen Einführungsbüchern verwendeten Sprache (siehe Broy, 1998, Band 1). Für dieses „zweigleisige“ Konzept (das sich übri gens schon in zahlreic hen Einführungsvorlesungen an der Fa kultät f ür Inform atik der TU M ünchen be währt hat) gibt es viele gute Gründe, u.a.: • Eine Programmiersprache der „Pascal-Welt“ (wie PPS) ist für eine erst e Begegnung m it imperativen Sprachen im Rahmen eines Studiums wesentlich besser geeignet als eine „Cähnliche“, u.a. wegen der unmissverständlichen Formulierung der Zuweisung durch „:=“. Viele Aussagen über Sprachstrukturen können darin einfach de utlicher form uliert werden. • Eine Pseudosprache braucht keine Rücksicht auf Zwänge der Implementierung auf realen Maschinen (z.B. hinsichtlich der Ein- und Ausgabe von Daten) zu nehmen und kann sich daher besse r a uf die logischen Strukturen k onzentrieren. Dadu rch wird au ch d eutlich, dass diese Strukturen nicht von der jeweils verwendeten Sprache abhängen.
44 5
Imperative Programmierung
• Neben einer idealisierten Pseu dosprache s ollte der Lese r a ber a uch di e oben e rwähnten Zwänge der „realen“ Im plementierung kennen lernen, daher ist (parallel) auch die Verwendung einer „realen“ Sprache angebracht. • Mit Python begegnen Sie ei ner Sprache a us der anderen großen „Synt axwelt“ der „Cähnlichen“ Sprachen, womit Sie in die La ge ve rsetzt we rden, die Algorithmen in den meisten Fachbüchern lesen z u können, die meist in einer der beiden Syntaxa rten formuliert werden. • Python ist eine sehr gebräuchliche Skriptsprache, die vor allem im Netzwerkbereich sehr gut ei ngesetzt wer den kann. S o ist z. B. de r be kannte Webserver Zope (siehe www.zope.org) in Python programmiert. • Schließlich e röffnet Ihnen e ine reale Prog rammiersprache die Möglichkeit, Ihre P rogramme tatsächlich ablaufen zu lassen und damit auch (in gewissem Umfang) zu testen. Aus Gründen der Übersichtlichkeit und Klarheit führ en wir di e Konzepte der im perativen Programmierung zunächst mittels PPS ein und zeigen erst am Ende des Kapitels, wie sie in Python implementiert werden. Ein ständiger Wechsel zwischen den beiden Sprachen könnte hier leicht zur Verwirrung führen. Sollten Sie während der Bearbeitung des Kapitels j edoch das Verlangen nac h einem lauf fähigen Pr ogramm verspüren, s o em pfehlen wir Ihnen, de n Abschnitt 5.9 im Voraus zum restlichen Kapitel zu lesen.
5.2
Das Variablenkonzept
Da (wie be reits erwähnt ) de r wichti gste el ementare Verarbeitungsschritt bei im perativen Sprachen die Zuweisung von W erten an Variable ist, spielt das Variablenkonzept dieser Sprachen naturgemäß eine zentrale Rolle. In der Mathematik dienen Variablen als Platzhalter für k onkrete (konstante) Zahle n. Die Variable x bezeichnet dort also eine ganz bestimmte Zahl (oft auc h „Unbekannte“ genannt, weil man diese Zahl oft am Anfang einer Berechnung noch nicht kennt). Im Gegensatz dazu ist eine Variable im Sinne der Informatik ein (durc haus realer) Container, der durch einen Bezeichner („Namen“) ide ntifiziert wir d und genau eine n Wert einer bestimmten Sorte (von Werten, siehe 5.3) enthalten kann, wie z.B. eine ganze Za hl, eine Gleitkommazahl, ein Zeichen oder einen Text. Der Nam e Variable (im Gegensatz zu einer Konstanten) weist darauf hin, dass dieser Wert auch wieder geändert werden kann. Auf Maschinenebene wir d eine Var iable durch einen abgegrenzten Bereich des Arbeitsspeichers realisiert und (technisch) durch eine Anfangsadresse identifiziert. Beispiel: Die Variable mit dem Namen zaehler soll als Container für die Sorte der nat ürlichen Zahlen dienen. Sie könnte dann als Wert z.B. die Zahl 12 enthalten.
5.3 Einfache Sorten
45
12 zaehler
Abb. 5.1
5.3
Variable als Container
Einfache Sorten
In der Informatik bezeichnet Sorte eine bestimmte Menge von Werten, wie z.B. nat für die Menge der natürlichen Zahlen {0, 1, 2, 3, …}, bool für die Menge der Wahrheitswerte {true, false} oder string für die Menge aller Zeichenketten über einem bestimmten Alphabet. Leider ist eine Sorte ziemlich nutzlos, solange nicht mit Operationen bzw. Funktionen darauf gearbeitet werden kann (z.B. Addition, Subtraktion, Multiplikation und Division auf nat oder Verkettung zweier Zeichenketten auf string). Aus der Kombination einer (oder auch mehrerer) Sorte(n) und passender Operationen darauf erhält man einen Datentyp. Da Wert emengen i n der Informatik aber s elten ohne zuge ordnete Ope rationen a uftreten, we rden die Begriffe Sorte und (Daten-)Typ oft annähernd gleichbedeutend gebraucht. In allen Sprachen be nötigt man bestimmte einfache Sorten und geeignete Operationen darauf. Dazu gehören z.B. Sorten für Zahle n (Ganze Zahlen oder Fließkommazahlen), Zeichen oder Zeichenketten. In PPS werden wir vorerst die folgenden einfachen Sorten verwenden: • bool für die Menge der Wahrheitswerte: true, false. • char für die Zeichen des erweiterten ASCII-Zeichensatzes1, z.B. ´A´, ´B´, …, ´a´, …, ´z´, …, ´0´, ´1´, … usw. Werte dieser Sorte erke nnt man an den um schließenden einfachen Anführungszeichen. Das Zeichen ´1´ muss übrigens streng von der Zahl 1 unterschie den werden. Mit Zahlen kann man rechnen, mit Zeichen dagegen nicht. • nat für g anze Zah len zwischen zwei (o ft syste mabhängigen) Gren zen, z.B. -327 68 und +32767 • string f ür Zeichenketten, di e durc h Verkettung von Zeic hen de r S orte char entstehen, z.B. „Meisterprüfung“. Werte dieser Sorte erkennt man an den umschließenden doppelten Anführungszeichen. • float für alle (ganzen und gebrochenen) Zahlen r zwischen zwischen zwei (oft systemabhängigen) Grenzen (z.B. –3,4 ×1038 und 3,4 ×1038), die intern in der „Gleitkommaform“ r = Mantisse*Basis Exponent darstellbar sind (z.B. r = 0.2345 601*1012). Die Form ate von Mantisse und Exponent sowie der Wert der Basis sind ebenfalls abhängig vom speziellen 1
Der ASCII-Zeichensatz stellt eine wichtige Norm für die Zuordnung von darstellbaren Zeichen zu Zahlen da r.
Siehe z.B. http://www.informatik.uni-halle.de/lehre/c/c_ascii.html
46 5
Imperative Programmierung
System, z. B: 8 Stellen für Mantisse, 2 Stellen für Exponent und basis = 10 wie im obigen Beispiel. Für die Darstellung der Konstanten (Werte) dieser S orten gelten die in Anhang zur Syntax von PPS dargestellten Syntaxregeln. Beispiele für Konstante: ´ A´, ´C ´, ´ @´, 123243, 1000, „Otto ko mmt heute zu Besuch“, 0.1232E+12. Die Sorte des Wertes eine r Variablen be stimmt auf der Ebene de r Realisierung auc h de n Speicherplatzbedarf dieser Variablen. Daher bestehen manche Programmiersprachen darauf, die verwendeten Variablen vor dem ersten Gebrauch mitsamt ihrer Sorte zu dekla rieren (zu vereinbaren). In PPS lautet eine solche Deklaration beispielsweise: var nat zaehler; Damit legt man fest, dass die Variable mit dem Bezeichner zaehler Werte der Sorte nat aufnehmen soll. Technisch gesehen wird mit dieser Deklaration der dafür notwendige Speicherplatz (z.B. 2 Bytes) reser viert. Bei de r Übersetzung ei nes Pr ogramms (eine r höheren P rogrammiersprache) i n aus führbaren Masc hinencode (siehe Kapitel 4) wi rd de r Bezeichner dieser Variablen zusammen mit der A nfangsadresse des für die Variable reservierten Speicherbereichs in die Tabelle aller verwendeten Variablen (Variablentabelle) eingetragen. Manche Programmiersp rachen v erlangen j edoch kei ne derartig en Deklarationen (z.B. Python, siehe unten). In diesem Fall werden di e genannten technischen Maßnahmen bei der ersten Zuweisung eines Wertes an die Variable vorgenommen. Die (vorläufige) Syntax ei ner Deklaration lautet in PPS (siehe auch Anhang zur Syntax von PPS): ::= var {, } {; var {, } } Darin wird auf die Syntaxre geln für Ide ntifikatoren (Bezeichner) Bezug genommen, die wir noch festlegen müssen: ::= {} ::= | bool | char | nat | string Ein Bezeichner beginnt also mit einem Buchstaben, evtl. gefolgt von Ziffern und/oder Buchstaben, z.B. zaehler1, a1234. In den folgenden Texten werden wir (von vorneherein fest) reservierte Wörter der Sprache (d.h. Terminale, die aus mehr als einem Zeichen bestehen) fett drucken. Diese Wörter dürfen nicht anderweitig verwendet werden, z.B. auch nicht als Bezeichner für Variablen. Zunächst we rden wir uns bei der f olgenden Einführung in die im perative Prog rammierung auf Variable der Sorte nat beschränken.
5.4 Zuweisung als Zustandsübergang
5.4
47
Zuweisung als Zustandsübergang
Die Zuweisungsoperation dient zur Belegung einer Variablen mit einem Wert (Inhalt). zaehler := 5 Vor der ersten Zuweisung ist der Inhalt einer Variablen nicht definiert. Wenn der Wert einer Variablen durch eine Zuweisung geändert wird, so kann man diese Veränderung durch einen Zustandsübergang modellieren. Die W irkung der obigen Zuweisung ist also (falls zaehler vorher nicht definiert wurde) folgende:
zaehler := 5 zaehler n. def.
Abb. 5.2
zaehler = 5
Zustandsübergang
Der Zustand eines Programms zu ei nem bestim mten Zeitpunkt sei nes Ablaufs wi rd durch Werte, die alle deklarierten Variablen zu diesem Zeitpunkt haben, festgelegt (im obige n Beispiel ist das nur die Variable zaehler). Ändert eine dies er Variablen ihren Wert, so wird der Zustand des ganzen Systems verändert. Mathematisch stellt ein solcher Zustand eine Menge von Paaren dar, die sich je weils aus dem Bezeichner einer Variablen und ihrem aktuellen Wert (Belegung) zusammensetzen. So wird der Zustand, in dem die Variable zaehler den Wert 5 und die Variable nenner den Wert 12 hat, beschrieben durch: z1 = {(zaehler, 5), (nenner, 12)} Beispiel (Ringtausch): Wir wollen den Kehrwert eines Bruches ermitteln und dazu die Werte der Variable n zaehler un d nenner austausc hen. Da beim Übe rschreiben eines de r be iden ursprünglichen W erte (z.B. des W ertes vo n zaehler durch d en Wert v on nenner) der überschriebene Wert ein f ür allemal verloren ginge, benötigen wir für dessen Zwischenspeicherung noch eine Hilfsvariable temp. Vor dem Tausch soll zaehler den Wert 2 und nenner den Wert 3 haben. Dann ergibt sich folgende Zustandsfolge: Zustand Zuweisung z1 = {(zaehler, 2), (nenner, 3), (temp, n.d.2)} temp := zaehler z2 = {(zaehler, 2), (nenner, 3), (temp, 2)} 2
n.d. steht für einen nicht definierten Zustand einer Variablen: ihr wurde noch kein Inhalt zugewiesen.
48 5
Imperative Programmierung zaehler := nenner
z3 = {(zaehler, 3), (nenner, 3), (temp, 2)} nenner := temp z4 = {(zaehler, 3), (nenner, 2), (temp, 2)} Die Bezeichnung Ringtausch weist auf die zyklische Weitergabe der Werte hin (siehe Abbildung 5.3).
2
zaehler
nenner
1
3
temp
Abb. 5.3
Ringtausch
Auf de r rec hten Seite der Z uweisung ka nn anstatt eines konsta nten Wertes auch ein Term stehen, z.B. x := 3*y + 5*z In diesem Fall wird zunäc hst der Wert dieses Terms berechnet (mehr über Te rme erfahren Sie in Kapitel 7). Ansc hließend wird das Erge bnis der a uf de r linke n Seite bezeichneten Variablen als Wert zugewiesen. In BNF lautet die Syntax der Zuweisung: ::= := bezeichnet einen Ausdruck, der sic h aus Konstanten, Va riablennamen, Ope ratoren und Funktionsaufrufen zusa mmensetzt. Darauf wird we iter unte n näher einge gangen. Die (vorläufige) Definition von Ausdrücken lautet: ::= | | () | | | … 3 Als monadische (ein Argument) und dyadische (zwei Argumente) Operatoren lassen wir z.B. zu: 3
Die abschließenden Punkte weisen darauf hin, dass noch Erweiterungen folgen werden.
5.4 Zuweisung als Zustandsübergang
49
::= – | not ::= + | – | * | / | < | ≤ | = | ≠ | ≥ | > | and | or | ^ | … Das Symbol „^“ steht dabei für die Potenzierung (x^y für xy). Außerdem ist bei dieser Sy ntaxdefinition zu beachten, dass ein Ausdruc k in PPS nur mit einem Operator kombiniert werden darf, wenn er von der an dieser Stelle jeweils passenden Sorte ist, z.B. ist der Ausdruck 5 + 7 korrekt, nicht aber 5 + ´c´, weil der Additionsoperator einen Zahlenwert als Operanden erwartet. Besonders deutlich wird die durch eine Zuweisung ausgelöste Zustandsänderung, wenn die Variable, der ein Wert zugewiesen wird (auf de r linken Seite), im Term , dessen Wert ihr zugewiesen wird, selbst enthalten ist, z.B.: x := x + 1 Unter Verwendung des „alten“ Wertes der Variablen x (des Wertes, den die Variable unmittelbar vor der Zuweisung enthielt) wird hier der Wert des Terms auf de r rechte n Seite berechnet und dann derselben Variablen x als neuer Wert zugewiesen. Dem entspricht f olgendes Zustandsmodell (falls x vor der Zuweisung z.B. den Wert 5 hatte): Zustand Zuweisung z0 = {(x, 5)} x := x + 1 z1 = {(x , 6)} Die Zuweisung (Aktion) „x := 5“ m uss dabei streng von der Aussage „x = 5“ unterschieden werden. Letztere be hauptet, dass die Va riable x gegenwärtig den Wert 5 hat (steht also für den Z ustand z = {( x, 5)}. Diese Aussage kann wiederum die W erte „ wahr“ oder „ falsch“ annehmen. Genau genommen handelt es sich bei solchen Aussagen (wie „x = 5“) daher ebenfalls um Ausdrücke, die einen Wert der Sorte bool haben (true, false). Besonders klar wird de r Unt erschied zwischen Aktion und Aussage bei einer Zuweisung, die, wie oben beschrieben, auf ihrer rechten und linken Seite densel ben Variablenbezeichner enthält, z.B. „x := x + 1“. Die entspreche nde Gleichheitsaussage lautet dann „x = x + 1“ und ist bezüglich jeder beliebigen Grundmenge unerfüllbar, hat also immer den Wert „falsch“. Leider wird die Zuweisung in vielen Programmiersprachen durch das Gleichheitssymbol „=“ dargestellt, u.a. in C, C++, Java und Python. Diese Sprachen verwenden dann ein doppeltes Gleichheitssymbol „==“ für die Aussage „ist gleich“. Da mit der Ve reinbarung einer Variablen auch oft der Wunsch verbunden ist, sie möglichst bald mit einem Anfangswert zu belegen, bieten die meisten Programmiersprachen die Mög-
50 5
Imperative Programmierung
lichkeit an, das gleich im Rahmen der Deklaration zu erledi gen („initialisierende Deklaration“), z.B.: var nat zaehler := 0; Allerdings muss man sich bei der Verwendung dieses Konstruktes darüber im Klaren sein, dass bei seiner Ausführung zwei Aktionen ablaufen: 1. die Reservierung von Speicherplatz für eine Variable der Sorte nat, 2. eine Zuweisung des Anfangswertes an diese Variable. Insbesondere bei objektorientierten Sprachen wie Java oder C++ ist diese Unterscheidung von großer Bedeutung. Wir erweitern also die Syntax unserer Deklaration von Variablen (siehe Abschnitt 5.3): ::= var [:= ] {, [:= ] } {; var [:= ] {, [:= ] } }
5.5
Ein- und Ausgabeoperationen
Ein Programm, das nicht in der Lage ist, in ir gendeiner Form auf Benutzereinga ben zu reagieren, wird immer in de r gleichen Weise ablaufen und daher wenig flexibel sei n. Deshalb bieten alle Sprachen die M öglichkeit, mit Hilfe spezieller Anweisungen (Eingabeanweisungen) über die Tastatur, die Maus ode r andere Eingabegeräte Daten a n die Variablen laufender Programme zu übergeben. Ebenso notwendig ist nat ürlich die Übergabe des Ergebnisses einer Berechnung an den Benutzer des Programms (oder auch a n a ndere laufe nde Programme). Dazu gi bt es spezi elle Ausgabeanweisungen, die Daten auf einem Bildschirm, einem Drucker oder einem anderen Ausgabegerät darstellen können. Ein Programm, d as Ein- und Au sgabebefehle v erwendet, lässt sich m it dem altb ekannten E-V-A-Schema beschreiben: Es nim mt (über be stimmte Einga bekanäle) Date n e ntgegen, verarbeitet diese und gibt Ergebnisse (über bestimmte Ausgabekanäle) aus. In PPS lassen wir je einen Befehl für die Eingabe bzw. Ausgabe zu. Zunächst die Eingabe: ::= input() Es wird also vom Benutzer ein Wert entgegengenommen und der Variablen mit dem Identifikator zugewiesen. Beispiel: Die Eingabeanweisung input(zaehler)
5.6 Programme
51
bewirkt nach der Eingabe der Zahl 5 durch den Benutzer die Zuweisung zaehler := 5. Die Syntax des Ausgabebefehls lautet analog: ::= output() Der Ausdruck wird ausgewertet (d h. sein Wert berechnet). Dieser Wert wird dann in geeigneter Weise am Bildschirm ausgegeben. Beispiel: Die Anweisungsfolge y := 3; z := 5; output(2*y+z) bewirkt die Ausgabe der Zahl 11. Die Bezeichne r input und output sind im Grunde frei w ählbar. Sie be zeichnen Unterprogramme (Prozeduren), die zwar im System vordefiniert sind, aber im Prinzip diesel be Rolle spielen wie andere vom Benutzer definierte Prozeduren bzw. Funktionen (siehe Kapitel 6). Daher setzen wir input und output nicht fett. Wegen der involvierten Technik (Bildschirm, Drucker etc.) und der da mit verbundenen oft sehr komplizierten Zustandsänderungen (im Druckerpuffer etc.) ist die formale Beschreibung der Semantik (Wirkung) von Ein- und Aus gabeanweisungen etwas problematisch. Wir wollen deshalb im Rahmen dieses Moduls darauf verzichten (im Gegensatz zu den anderen Anweisungen, deren (Zustands-) Semantik wir ausführlich behandeln).
5.6
Programme
Leider benötigt man in realen Programmiersprachen (wie Python oder Java) über die Angabe der ei gentlich auszuführenden A nweisungen hi naus oft einen erhebliche n A ufwand an rein verwaltungstechnischen Maßnahmen, wie z.B.: • Import von Programmmodulen, z.B. für Ein- und Ausgabefunktionen, • Deklaration v on Hauptprozeduren (z.B. m ain() in Java oder C), die beim Start eines Programms automatisch aufgerufen werden und das „eigentliche Programm“ beinhalten, • Verzögerung des s ofortigen Versc hwindens des Aus gabefensters, indem man auf das Drücken einer beliebigen Taste wartet, usw. In unsere r Pseudosprache PPS können wir uns das alles sparen. Hier benötige n wir neben den eigentlichen Anweisungen nur noch dessen Namen und die Festlegung von Anfang und Ende des Programms, z.B.:
52 5
Imperative Programmierung
program erstprog:
var nat x;
Programmkopf mit Bezeichner des Programms Vereinbarung der Variablen
begin
Beginn der auszuführenden Anweisungen
input(x); output(x*x)
“Nutzcode”: auszuführende Anweisungen
end.
Ende der auszuführenden Anweisungen
Zu einem guten Programmierstil gehört auch die ausgiebige Verwendung von Kommentaren im Programmtext. Sie ve rbessern die Lesbarkeit des Programms und helfen, Missverständnisse z u verm eiden. K ommentare si nd Text e, die bei de r Ü bersetzung des P rogramms in Maschinencode ignoriert werden. Wir we rden durch z wei aufei nander folgende Schrägstriche „//“ kennzeichnen, dass der Rest der Zeile als Kommentar zu verstehen ist: program test: //Hier folgen die Variablendeklarationen var nat x; …
5.7
Zusammengesetzte Anweisungen
Wie wir in Kapitel 3 (Abschnitt 3.3 und 3.5) festgestellt haben, kann man Algorithmen (u.a.) durch die Kombination best immter Strukturelemente darstellen: ele mentare Verarbeitungsschritte, Sequenzen, bedingte Verarbeitungsschritte, Wiederholungen. Um Algorithm en aus einer s olchen Darstellung in im perative Programme u msetzen zu können, benötigen wir entsprechende Konstrukte in unseren Programmiersprachen. Neben elementaren (Zuweisung, Ein- und Aus gabe) bieten imperative S prachen daher auch zusammengesetzte Anweisungen an: Sequenz, bedingte Anweisung, Wiederholung. Diese Konstrukte werden oft als Kontrollstrukturen bezeichnet.
5.7.1
Sequenzen
Die einfachste Möglichkeit zur zeitlichen A bfolge einer Menge von Anweisunge n ist ihre (unbedingte) sequentielle Ausführung („Sequenz“) in einer festen Reihenfolge. Beispiel (Ringtausch, siehe Abschnitt 5.4): temp := zaehler; zaehler := nenner; nenner := temp.
5.7 Zusammengesetzte Anweisungen
53
Die Syntax einer Sequenz lautet folgendermaßen: ::= { ; } Aufeinanderfolgende Anweisungen werden in PPS also durch einen Strichpunkt getrennt. Die Definition von finden Sie im Anhang zur Syntax von PPS. An dieser Stelle genügt der Teil: ::= | | | | … Die Ausführung einer Sequenz von Anweisungen bewirkt eine Folge von Zustandsübergängen. Dabei arbeitet jede Anweisung auf dem Zustand, der von der vorausgegangenen Anweisung hinterlassen wurde. Beispiel (Kapitalentwicklung eines Sparkontos): Am Anfang (jahr = 0) sollen sich 1000 0 € auf unserem Sparkonto befinden. Der Zinssatz liegt konstant bei 5 %. Wie lautet der Kont ostand nach 2 Jahren? Zustand Zuweisung z0= {(jahr, n.d.), (kapital, n.d.)} jahr := 0; z1= {(jahr, 0), (kapital, n.d.)} kapital := 10000; z2= {(jahr, 0), (kapital, 10000)} jahr := 1; z3= {(jahr, 1), (kapital, 10000)} kapital := (1 + 0,05) * kapital; z4= {(jahr, 1), (kapital, 10500)} jahr := 2; z5= {(jahr, 2), (kapital, 10500)} kapital := (1 + 0,05) * kapital z6= {(jahr, 2), (kapital, 11025)} Wenn man die möglichen (diskreten) Werte einer Variablen auf einer Achse anordnet, kann man m it zwei Varia blen (wie in ei nem Koor dinatensystem) eine Ebe ne aufspa nnen. J eder Punkt dieser Ebene entspricht dann eine r bestimmten Wertekombination der beiden Variab-
54 5
Imperative Programmierung
len, z.B. der Punkt (1; 10000) der Wertekombination jahr = 1 und kapital = 10000. Der Ablauf des Programms bewirkt dann den Durchlauf einer Folge von Zuständen (einer „Spur“) in diesem Diagramm. Der Abla uf eine s im perativen Progra mms lässt sic h also als Spur im Zustandsraum auffassen (siehe Abbildung 5.4).
jahr =
n.d.
0
z0
z1
1
2
kapital = n.d. ... 10000
z2
z3
... 10500
z4
z5
... 11025
Abb. 5.4
z6
Ablauf eines Programms als Spur im Zustandsraum
Allgemein bewirkt eine Sequenz seq1 als Folge von Befehlen a1; …; an also eine Folge von Zustandsübergängen (siehe Abbildung 5.5).
Sequenz seq1 = a1; a2; .. an a1 z0
a2 z1
z2
...
an zn-1
zn
seq1
Abb. 5.5
Die Wirkung einer Sequenz
In de n m eisten Pr ogrammiersprachen können Se quenzen anstatt einfa cher A nweisungen verwendet werden, z.B. in de r bedingten Anweisung (siehe unten). Je nach Syntax der Sprache bzw. Sprachkonstrukt muss die Sequenz dazu allerdings oft durch spezielle Sprachmittel (z.B. begin und end in PPS und Pascal, geschweifte Klammern in Java) zu einem Block geklammert werden (siehe dazu auch Kapitel 6).
5.7 Zusammengesetzte Anweisungen
5.7.2
55
Bedingte Anweisung bzw. Alternative
Oft sollen Anweisungen nur unter bestim mten Bedingu ngen ausge führt werden . So kann beispielsweise eine Zahl a nur dann durch eine andere Zahl b dividiert wer den, wenn die letztere von Null verschieden ist: if b ≠ 0 then quotient := a/b endif Solche Konstrukte bezeichnet man als bedingte Anweisungen, da die jeweilige Operation (hier die Zuweisung) nur ausgeführt wird, wenn die Bedingung „b ≠ 0“ erfüllt ist (bzw. als Ausdruck der Sorte bool den Wert true hat). Noch eine Bemerkung zur Symbolik: Da das Ungleichheitszeichen ´≠´ nicht zu der Menge der ersten 128 ASCII-Zeichen (siehe oben) gehört (auf deren Vorhandensein man sich unter allen Umständen verlassen kann), verwenden die meisten realen Programmiersprachen eine Umschreibung durch ´#´ ( Oberon) oder durch die K ombinationen „“ (Pascal) oder „!=“ (Java). In Python ist sowohl „“ als auch „!=“ möglich, wobei letztere bevorzugt wird. Meist ist es sinnvoll, auch für den Fall, dass die Bedingung nicht erfüllt ist, eine alternative Anweisung (z.B. eine Fehlermeldung) ausführen zu lassen, z.B.: if b ≠ 0 then quotient := a/b else output(“Fehler: Divisor = 0”) endif Die Syntax der bedingten Anweisung lautet allgemein (vereinfacht): ::= if then [else ] endif Falls die Bedingung wahr (erfüllt) ist, wird die nach then aufgeführte Anwei sung ausgeführt, andernfalls die hinter else aufgeführte. Falls der else-Teil vorhanden ist, bezeichnet man das gesamte Konstrukt auch als Alternative. Auch bedingte Anweisungen bewirken Zustandsübergänge, die aller dings je nach Erfüllung der Bedingung in verschiedene Zielzustände münden (siehe Abbildung 5.6).
56 5
Imperative Programmierung
Anweisung: cond1 = if bed1 then anw1 else anw2 endif
anw1 [bed1 = true] z0
z1
anw2 [bed1 = false] z2
Abb. 5.6
5.7.3
Die Wirkung einer bedingten Anweisung
Wiederholung von Anweisungen
Wie wir bereits im Kapitel über Algorithmen gesehen haben, ist es für viele Aufga benstellungen sehr prak tisch, wenn es ein e Mög lichkeit zur (au tomatischen) Wiederholung v on Verarbeitungsschritten gibt. Manchmal steht dabei schon vor dem ersten dieser Schritte fest, wie oft de r Schritt wiederhol t werden m uss, manchmal aber auch nicht. Da diese bei den Varianten erhebliche U nterschiede hi nsichtlich der da durch zu ber echnenden F unktionen aufweisen, wollen wir sie gleich von Anfang an streng auseinander halten. Wiederholung mit vorgegebener Wiederholungszahl Die Anzahl der Durchläufe hängt in diesem Fall also nicht von den Berechnungen während der Wiederholungen ab. Beispiel (Berechnung der Fakultätsfunktion fak(n) = 1*2*…*n): fak := 1; for i := 1 to n do fak := fak * i endfor Voraussetzung dafür ist allerdings die Einhaltung der Regel, dass während der Wiederholungen nicht schreibend auf die Zählvariable (im obigen Beispiel i) zugegriffen wird. Das wäre ein Zeichen für einen überaus schlechten Programmierstil. Die Syntax der Wiederholung mit vorgegebener Wiederholungszahl lautet allgemein: ::= for := to do endfor Die Wirkung aus Zustandssi cht kann aus Abbildung 5.7 entnom men wer den: In je dem Durchlauf der Wiederholung wir d (a usgehend von dem Zustand, den der vorausgehende Durchlauf hinterlassen hat) eine Zustandsänderung gemäß der wiederholten Anweisung ausgeführt.
5.7 Zusammengesetzte Anweisungen
57
Anweisung repfix1 = for i:=1 to n do anw endfor
z0 anw (1. Wiederh.)
z1 anw (2. Wiederh.)
repfix
z2 ... zn-1 anw (n. Wiederh.)
zn
Abb. 5.7
Wirkung der Wiederholung mit vorgegebener Wiederholungszahl
Ein Vergleich der Abbildungen 5.5 und 5.7 führt zu der Erkenntnis, dass die Wirkung dieser Anweisungsstruktur stark der einer Sequenz ähnelt. Es wird ja auch tatsächlich eine Sequenz ausgeführt, die allerdings nicht aus verschiedenen A nweisungen, s ondern aus der m ehrfachen Ausführung einer Anweisung (bzw. einer anderen Sequenz) besteht. Bedingte Wiederholung Leider genügt die W iederholung mit vorgegebener Wiederholungszahl nicht zur L ösung bzw. Berechnung aller (überhaupt berechenbaren) Aufgabenstellungen. Oft stellt sich nämlich erst während der einzelnen Wiederholungen heraus, wie oft eine A nweisung oder eine Sequenz noch wie derholt werden m uss, um das vorgegebene Ziel z u erreiche n. Typische Beispiele sind etwa die Reaktion eines Menüsystems auf eine Benutzereingabe (bis zur Betätigung irgendeines Ausschalters) oder die Berechnung einer Wurzel auf eine bestimmte Genauigkeit. Die Fortsetzung de r Wiederholungen wird in diesen Fällen durc h eine Bedingung gesteuert, deren Wahrheitswert sich während der Ausführung der Wiederholung ändern kann.
58 5
Imperative Programmierung
Aus Be quemlichkeitsgründen bieten viele Progr ammiersprachen daf ür zwei versc hiedene Möglichkeiten an: Die W iederholungsbedingung kann vor der zu w iederholenden Sequ enz angegeben werden oder (negiert als Abbr uchbedingung) an de ren Ende. Da jede dieser beiden Wiederholungsvarianten durch die andere aus gedrückt wer den ka nn, be schränken wir uns hier auf die erstere. Beispiel (Ganzzahldivision): program ganzdiv: var nat dividend, divisor, ergebnis; begin input(dividend, divisor); ergebnis := 0; while dividend >= divisor do ergebnis := ergebnis + 1; dividend := dividend – divisor endwhile output(ergebnis) end. In diesem Fall kann nicht (zumindest nicht ohne weitere Berechnungen) vor der ersten Wiederholung vorausgesagt werden, wie oft der Divisor im Dividenden enthalten ist. Die Syntax der bedingten Wiederholung lautet: ::= while do endwhile Dieser Ty p von Wiederholungsanweisung tritt besonders häufi g im Zusamm enhang m it grafischen Be nutzeroberflächen bei de r B ehandlung der vom Benutzer aus gelösten Steuersignale (Ereignisse) auf (z.B. Mausklick, Tastendruck etc.): input(ereignis); while ereignis ≠ „Ausschalten“ do if ereignis = „Einfachklick“ then ObjektMarkieren endif if ereignis = „Doppelklick“ then ObjektÖffnen endif endwhile Die Wirkung hinsichtlich de r Zustandsübergänge ähnelt der einer Wiederholung mit vorgegebener W iederholungszahl, soweit dabei die gleiche An zahl von Wiederholungen abgearbeitet wird. Entscheidend für den A bbruch (die Terminierung) der Wiederholung ist jedoch , dass die Wiederholungsbedingung irge ndwann einm al auf false gesetzt wird. Ande rnfalls läuft die Wiederholung, bis s ie durch ei nen Ei ngriff ins S ystem ge stoppt wird (z.B. ei nen erzwungenen Neustart oder einen zwangsweisen Abbruch des laufenden Prozesses). Nichtterminierende W iederholungen sind ein möglicher Auslöser für den „Abst urz“ ei nes Programms.
5.8 Zusammengesetzte Sorten
59
Wiederholungskonstrukte werden oft auch (etwas salopp aber sehr kompakt) als „Schleifen“ bezeichnet („for-“ oder „while-Schleife“). Auch wir werden in diesem Buch im 2. Teil („Algorithmen und Datenstrukturen“) diese Begriffe verwenden.
5.8
Zusammengesetzte Sorten
Für viele Zwecke ist es pra ktisch, wenn man nicht jede Variable einzeln ans prechen muss, sondern Zugriff auf ein ga nzes Paket von Varia blen hat. Dafür gibt es zahlreiche M öglichkeiten. Sehr häufig will man den Zu griff auf eine begrenzte Menge von Variablen glei cher Sorte oder verschiedener Sorte bündeln. Im ersten Fall spr icht man von einem Feld (array), im zweiten von einem Verbund (record).
5.8.1
Felder (Arrays)
Felder si nd vor alle m dann sehr praktisch, wenn m an über ei nen Index auf eine Menge gleichartiger Variablen zugreifen will, z.B. auf eine Reihe von Ortsnamen: Ortsname 1 = „München“, Ortsname2 = „Wasserburg“, Ortsname3 = „Rosenheim“ usw. Wenn man sich eine Variable als Schachtel mit Bezeichner (Aufschrift) und Inhalt vorstellt, dann könnte man ein Feld als Stapel solcher (gleichförmiger) Schachteln verstehen:
„Rosenheim“ Ortsname1
Ortsname2 Ortsname3
Abb. 5.8
Ein Feld als Stapel gleichartiger Variablen
In PPS lautet die Deklaration eines Feldes (hier z.B. für 10 Ortsnamen): var [1:10] array string ortsname; Der Zusatz [1:10] array verwandelt also eine „einfache“ Variable ortsname vom Typ string in eine Menge indizierter Variablen gleichen Namens.
60 5
Imperative Programmierung
Nach dieser kann über einen Index (hier z wischen 1 und 10) auf die O rtsnamen zugegriffen werden: ortsname[1] := „München“; ortsname[2] := „Wasserburg“; ortsname[3] := „Rosenheim“; usw. Besonders praktisch ist die Verwendung solcher Felder in Wiederholungsanweisungen: for i := 1 to 10 do output(ortsname[i]) endfor Die allgemeine Syntax lautet: ::= [:] array
5.8.2
Verbunde (Records)
Mit Hilfe von Verbunden werden Va riablen verschiedener Sorten zusa mmengesetzt. Man kann sich die Struktur wie ein Magazin für Kleinteile aus dem Heimwerkermarkt vorstellen:
Strasse PLZ
Ortsname
Verbund: Adresse
Abb. 5.9
Verbund als Magazin
Da es sehr unübersichtlich wäre, alle für einen Verbund notwendigen Informationen in einer Variablendeklaration zusammenzufassen, zieht m an es in den meisten Sprachen vor, in solchen Fällen zunächst einen Namen für diese Sorte zu deklarieren: sort adresse = record string strasse; string PLZ; string ortsname end Die Syntax für die Deklaration neuer Sorten finden Sie im Anhang zur Syntax von PPS.
5.9 Programmieren in Python
61
Der Zugriff auf die Komponenten eines Verbundes erfolgt dann (nach der Deklaration einer Variablen von de r e ntsprechenden ne u definierten Sorte adresse) übe r deren Bezeic hner (Selektor), der mit einem Punkt vom Bezeichner der Verbundvariablen abgetrennt wird: var adresse kundenadresse; kundenadresse.strasse := „Münchnerstr. 17“; kundenadresse.PLZ := „83055“; kundenadresse.ort := „Geimersdorf“
5.8.3
Tabellen als Kombination von Feldern und Records
Aus der Kombination von Verbunden und Feldern kann man Tabellen zusammensetzen, wie beispielsweise eine Liste von Adressen: Ortsname PLZ München 8302 2 Rosenheim 8302 2 Bad Aibling 83043
Strasse Arcisstr. 21 Prinzregentenstr. 17 Westendstr. 4a
Allerdings ist die Anzahl der Zeilen (im Gege nsatz zu den Datensätzen von Tabellen relationaler Datenbanken) über die Länge des Feldes von vorneherein festgelegt. Diese Datenstruktur wäre in PPS folgendermaßen zu definieren: sort tabelle = [1..10] array of adresse; var tabelle kundentabelle; Danach kann man auf eine bestimmte Zelle der Tabelle zugreifen: kundentabelle[2].PLZ := „83022“.
5.9
Programmieren in Python
Nun wollen wir die Umsetzung der bisher behandelten Konzepte auf eine „reale“ Programmiersprache besprechen. Bevor Sie diesen Abschnitt in Angriff nehmen, sollten Sie auf jeden Fall eine Python-Programmierumgebung auf Ihrem R echner i nstallieren. Sie können die Software kostenlos von der Adresse http://www.Python.org herunterladen. Wir beziehen uns hier auf die Version 2.3.2 von Python mit der Programmieroberfläche Idle in Version 1.0. Sie können mit Idle in beschränktem Rahmen Befehle eingeben und sofort ausführen („interaktiver M odus“). S obald Sie zusam mengesetzte Anweis ungen ve rwenden, em pfiehlt sich jedoch, die Programme als Scripts in einem eigenen (neuen) Fenster einzugeben, als Dateien abzuspeichern und da nn erst au szuführen („Scriptmodus“). W ir bitten Sie um Verständnis, dass wir im Rahmen dieses B uches nicht näher auf die Installation und Bedienung der Soft-
62 5
Imperative Programmierung
ware eingehen können. Sie finden jedoch viele Informationen und Tutorials dazu ebenso wie die komplette Syntax von Python in BNF-Notation unter der o.g. Internet-Adresse. Wir arbeiten zunächst im interaktiven Modus und geben den gesamten Dialog m it der Programmieroberfläche (also a uch die Ei ngabeaufforderung >>> und die Ausga ben von Idle) wieder. Außerdem verzichten wir in den Python-Programmen auf die F ettsetzung von Terminalsymbolen.
5.9.1
Python als Taschenrechner
Im Gegensatz zu unserer Pseudosprache PPS (die nur vollständige Programme akzeptiert) ist in Python ein Term (analog zu in PPS) direkt interpretierbar. Es wird einfach nur sein Wert zurückgegeben. Dies ermöglicht es, Python als Taschenrechner zu verwenden: >>> 3+4*5 23 In Kapitel 7 werden wir im Rahmen der funktionalen Programmierung näher auf diese Form der Interaktion mit dem Interpreter eingehen.
5.9.2
Variablen, Vergleich und Ausgabeanweisung
Python verla ngt keine Deklaration von Va riablen. Sie we rden bei jeder Zuweisung an den Typ des zugewiesenen Wertes gebunden. Die Zuweisung wird im Gegensatz z u PPS durch ein Gleichheitszeichen symbolisiert, der Vergleich durch ein doppeltes Gleichheitszeichen. >>> zahl = 5 >>> zahl 5 >>> zahl == 9 False Hier haben wir zuerst einer (bis dahin nicht verwendeten) Variablen zahl den Wert 5 zugewiesen. Danach haben wir uns den Wert der Variablen (durch Eingabe eines Terms, der nur aus dieser Variablen besteht) ausgeben lassen. Schließlich liefert der Vergleich zahl == 9 (als boolescher Term) den Wert False. Ein besonderer Luxus von Python sind kollektive Zuweisungen: Man ka nn einer Reihe von Variablen mit einer Anweisung eine Reihe von Werten zuweisen: >>> i,k = 3,4 >>> i 3 >>> k 4
5.9 Programmieren in Python
63
Damit erübrigt sich die um ständliche Strategie für den Austausch der Werte zweier Variablen (Ringtausch, siehe 5.4): >>> >>> >>> >>> (3,
zaehler = 5 nenner = 3 zaehler, nenner = nenner, zaehler zaehler, nenner 5)
5.9.3
Sorten und Typen
Der Umgang mit ganzzahligen Werten der Sorte int ist völlig problemlos, solange man nicht (echte) Dezimalbrüche als Ergebnis erwartet: >>> 2 >>> -6 >>> 96 >>> 1
14-12 -2*3 12*8*(-2+3) 5/3
Die letzte Eingabe lässt jedoch Fragen aufkommen: Wieso liefert die Division von 5 durch 3 den Wert 1 anstatt des erwarteten Ergebnis ses 1.66… ? Die Ant wort liegt in der automatischen Typisierung von Python: Da der Divisionsoperator hier nur ganze Zahlen als Eingabe erhält, arbeitet er als Ganzz ahloperator und liefert auch nur ga nze Zahlen zurüc k (durc h Abschneiden der Nachkommastelle gewonnen). Wenn man dagegen einen der Operanden als Fließkommazahl (der Sorte float) eingibt, erhält man das korrekte Ergebnis: >>> 5.0/3 1.6666666666666667 Wie imm er be i num erischen Berechnungen arbeitet auch Python m it ein er be grenzten Anzahl von Stellen (hie r mit 16 Nac hkommastellen). Der überstehende „ Rest“ der Zahl wird daher auf die 16. Nachkommastellen gerundet.
5.9.4
Ein- und Ausgabe
Die Ausgabeanweisung Zunächst z ur Ausgabe: im intera ktiven Modus genügt e s, de n a uszugebenden Ausdruck (Term) in die Kommandozeile zu tippen:
64 5
Imperative Programmierung
>>> 999*999 998001 Für den Skriptmodus benötigt man auf jeden Fall die Aus gabeanweisung print. Wir verlassen hier den interaktiven Modus und erstellen unser erstes Script. Ab jetzt werden wir diese Scripts au ch als Programme bezeichnen. Dazu öffnen wir ein ne ues Fenster, ge ben in der ersten Zeile als Kom mentar (gekennzeichnet durc h das S ymbol ‚#’ ) de n Nam en des Programms an, unter dem wir es dann auch abspeichern. # script1.py zaehler = 5.0 nenner = 3 print 5/3 Schließlich lassen wir es mit Hilfe des Befehls run Module ablaufen: Danach erhalten wir im Hauptfenster von Idle das folgende Ergebnis: >>> 1 Offensichtlich hat hier unsere Typfestlegung auf float durch Zuweisung eines float-Wertes an eine der beiden Operandenvariablen nicht funktioniert. In der Tat muss man im Scriptmodus die Operatoren mit Hilfe der Umwandlungsfunktion float() explizit in den Fließkommamodus zwingen: # script1.py zaehler = 5.0 nenner = 3 print float(5)/3 Nun ist das Ergebnis korrekt. Die Funktion float() ist die erste (fest eingebaute) Funktion einer P rogrammiersprache, die uns in diesem Buch be gegnet. Von Funktionen wird im nächsten Kapitel 6 noch ausführlich die Rede sein. Hier wollen wir uns nur auf die Feststellung beschränken, dass eine Funktion im Gegensatz zu einer A nweisung einen Wert zurückliefert, den man z.B. einer Variablen übergeben kann (der Einfachheit halber wieder im interaktiven Modus): >>> zaehler = float(5) >>> zaehler 5.0 Sehr praktisch ist die Möglichkeit, mit print mehrere Argumente (durch Kommas getrennt) ausgeben zu lassen: # script2.py zaehler = input(„Bitte Zaehler eingeben:“); nenner = input(„Bitte Nenner eingeben:“);
5.9 Programmieren in Python
65
print zaehler, „ geteilt durch “, nenner, „ ergibt “, float(zaehler)/nenner Ablauf: >>> Bitte Zaehler eingeben: 5 Bitte Nenner eingeben: 3 5 geteilt durch 3 ergibt 1.66666666667 Die Eingabefunktionen Die Übergabe von Werten an Python-Programme macht eigentlich ebenfalls nur im SkriptModus Si nn: man will j a dasselbe Skri pt für verschiedene Eingabewerte verwenden. Im Gegensatz zur Ausgabeanweisung läuft die Eingabe in Python jedoch über Funktionen. Die einfachste Möglichkeit bietet die Funktion input: # script2.py zaehler = input(„Bitte Zaehler eingeben: “); nenner = input(„Bitte Nenner eingeben: “); print float(zaehler)/nenner Der Ablauf des Programms führt zum folgenden Dialog: >>> Bitte Zaehler eingeben: 5 Bitte Nenner eingeben: 3 1.66666666667 Die Eingabefunktionen erwarten also vom Benutzer einen Eingabewert und liefern diesen als Ergebnis zurück (so wie die obi ge Funktion float einen Fließkommawert zurückliefert). Zusätzlich kann als Seiteneffekt eine als Argument übe rgebene Zeic henkette als Hinweis für den Benutzer am Bildschirm ausgegeben werden. Leider ist input für die Eingabe von Texten nicht geeignet: >>> anrede = input("Bitte Anrede eingeben: ") Bitte Anrede eingeben: Herr Traceback (most recent call last): File "", line 1, in -toplevelanrede = input("Bitte Anrede eingeben: ") File "", line 0, in -toplevelNameError: name 'Herr' is not defined Die U rsache liegt da rin, dass die F unktion input eine n sy ntaktisch kor rekten Python Te rm erwartet:
66 5
Imperative Programmierung
>>> zahl = input() 12+3 >>> zahl = input() 12+*3 Traceback (most recent call last): File "", line 1, in -toplevelzahl = input() File "", line 1 12+*3 ^ SyntaxError: invalid syntax Das kann zwar sehr praktisch sein, wenn man mit dem Term weiterarbeiten will, führt aber zu Laufzeitfehlern bei der Eingabe nicht korrekter Terme (also i.A. bei Texten). Hier muss man die Funktion raw_input verwenden, die jede Eingabe akzeptiert und ohne Prüfung weiterleitet: # script3.py name = raw_input("Bitte Namen eingeben: "); vorname = raw_input("Bitte Vornamen eingeben: "); print "Sie heißen: ", vorname, name
5.9.5
Zusammengesetzte Anweisungen
Sequenzen Die einzelnen Anweisungen einer Sequenz werden durch Strichpunkte (im Scriptmodus auch durch Zeilenschaltungen, siehe oben) getrennt: >>> zaehler = 6; nenner = 2; print zaehler/nenner 2 Bedingte Anweisungen Das folgende Beispiel illustriert die Verwendung der bedingten Anweisung in Python. # script4.py zaehler = input(„Bitte Zaehler eingeben: “); nenner = input(„Bitte Nenner eingeben: “); if nenner != 0: print „Ergebnis: “, float(zaehler)/nenner else: print „FEHLER: Division durch 0!“
5.9 Programmieren in Python
67
Ein beispielhafter Ablauf: >>> Bitte Zaehler eingeben: 12 Bitte Nenner eingeben: 0 FEHLER: Division durch 0! Nach der Bedingung und nach else muss also ein Doppelpunkt stehen. Sollen in den Zweigen Sequenzen verwendet we rden, s o m üssen diese nic ht ge klammert wer den (wie in PPS mi t begin und end, vgl. Abschnitt 5.7.2), jedoch auf derselben Einrückungsebene stehen: # script5.py zaehler = input(„Bitte Zaehler eingeben: “); nenner = input(„Bitte Nenner eingeben: “); if nenner != 0: print „Ergebnis: “, float(zaehler)/nenner else: print „FEHLER: Division durch 0!“ print „Zweiter Versuch!“ zaehler = input(„Bitte Zaehler eingeben: “); nenner = input(„Bitte Nenner eingeben: “); if nenner != 0: print „Ergebnis: “, float(zaehler)/nenner else: print „FEHLER: Erneute Division durch 0!“ print „Abbruch!“ Ablauf: >>> Bitte Zaehler eingeben: 5 Bitte Nenner eingeben: 0 FEHLER: Division durch 0! Zweiter Versuch! Bitte Zaehler eingeben: 5 Bitte Nenner eingeben: 0 FEHLER: Erneute Division durch 0! Abbruch! Oft benötigt man eine Fallunterscheidung in mehr als zwei Fälle. Hier hilft die Möglichkeit, bedingte Anweisungen mit Hilfe von elif zu schachteln: if alter < 3: print „Kleinkind“ elif alter < 6: print „Kindergartenkind“ elif alter < 11: print „Grundschulkind“ else: print „weiterfuehrende Schule“
68 5
Imperative Programmierung
Wiederholung mit fester Wiederholungszahl Dieses Strukturelement wird in Python im Vergleich zu anderen Sprachen etwas unge wohnt implementiert: Anstatt den A nfangs- und Endwert für die Zählvariable anzugeben (for i:=1 to 10), wie z.B. in PPS (sie he 5.7.3), Pascal od er Java, erwartet Python die Angabe einer Liste von Werten, die die Zählvariable annehmen soll: >>> for i in [1,2,4,5,8]: print i 1 2 4 5 8 Hier begegnen wir e rstmals dem em inent praktischen vordefinierten Listenkonstruktor in Python (sym bolisiert durch eckige Klam mern), de r aus Elementen beliebiger S orten eine Liste konstruiert. In vielen Programmiersprachen (z.B. Pascal) muss man sich den Datentyp „Liste“ mühsam ( mit Hilfe von Zei gern) se lbst implementieren. Da rüber hinaus e rzwingen viele Sprachen die Beschränkung auf einen Datentyp für alle Elemente einer Liste. Auch hier bietet Python mehr Freiheit: Es dürfen Elemente belieb iger Typen zu ei ner Liste kombiniert werden (siehe auch das nächste Beispiel). Dies eröffnet in Kombination mit der Abstüt zung der Wiederholung auf Listen die Möglichkeit , die W iederholung über beliebige Typen und Mischungen daraus ausführen lassen: >>> for i in [“otto”, “emil”, “thea”, 1, 1.2, True, False]: print i otto emil thea 1 1.2 True False Viele andere Sprachen binden die Zä hlvariable der Wiederholung an einen Typ, der zudem meist ein Ordinaltyp (also ein Typ, deren Datenelemente eine natürliche Ordnung haben, wie z.B. die natürlichen Zahlen oder die Zeichen des ASCII-Codes) sein muss. Falls man sehr viele Durchläufe der Wiederholung programmieren will, ist es natürlich nicht sehr praktisch, wenn alle zu durchla ufenden Werte der Zählvariablen explizit anzugeben sind. Hier hilft die range-Funktion, die aus der Angabe des jeweiligen Anfangs - und Endwertes eine Liste produziert: >>> range(1,10) [1, 2, 3, 4, 5, 6, 7, 8, 9]
5.9 Programmieren in Python
69
Dabei ist Vors icht geboten: Der E ndwert wir d hier nic ht in die Liste aufgenommen! W ill man also z.B. die Quadrate der natürlichen Zahlen von 1 bis einschließlich 100 aus geben, so muss in der range-Funktion als Endwert 101 angegeben werden: >>> for i in range(1,101): print i*i 1 4 9 … 9801 10000 Wiederholung mit Endbedingung Hier gibt es außer kleinen syntaktischen Unterschieden keine nennenswerten Abweichungen von PPS: # Primzahl.py zahl = input(„Bitte Zahl eingeben: „) teiler = zahl/2 primzahl = True while teiler > 1: if zahl % teiler == 0: print zahl, „ hat Teiler „, teiler primzahl = False teiler = teiler - 1 if primzahl: print zahl, „ ist Primzahl!“ Zur Illustration zwei Abläufe: >>> Bitte Zahl eingeben: 111 111 hat Teiler 37 111 hat Teiler 3 >>> Bitte Zahl eingeben: 257 257 ist Primzahl!
5.9.6
Zusammengesetzte Sorten
Auch hier bietet Python viel Luxus, den man als Programmierer allerdings durch den erhöhten Zwang zur Selbstdisziplin bezahlen muss.
70 5
Imperative Programmierung
Listen und Felder Python unterscheidet nicht zwischen (in a nderen Programmiersprachen oft statisch be grenzten Feldern (oder arrays) und dynamischen (d h. zur Laufzeit beliebig verlängerbare n) Listen. Da es kei nen De klarationszwang gibt, wird eine List e mit der ersten Zuweis ung e ines Wertes angelegt. Auf die einzelne n Elemente einer Liste kann in Python (wie sonst oft nur auf Felder) durch Indizes zugegriffen werden. Das erste Element erhält dabei den Index 0: >>> liste = ["Berlin", "Oxford", "Frankfurt"] >>> liste[0] 'Berlin' >>> liste[2] 'Frankfurt' >>> liste[3] Traceback (most recent call last): File "", line 1, in -toplevelliste[3] IndexError: list index out of range Da Wiederholungen mit fester Wiederholungszahl direkt über Listen la ufen, kommt man in vielen Fällen jedoch ohne Verwendung der Indizes aus: >>> for i in liste: i 'Berlin' 'Oxford' 'Frankfurt' Mit Hilfe des Konkatenationsoperators (sy mbolisiert durch ein Pluszei chen) könne n Listen sehr einfach verknüpft oder verlängert werden: >>> liste = liste +["Dortmund"] >>> liste ['Berlin', 'Oxford', 'Frankfurt', 'Dortmund'] >>> liste[3] 'Dortmund' Verbunde Da eine Liste in Python Daten verschiedener Sorten aufnehmen kann, können auch Verbunde (records) über Listen implementiert werden: >>> strasse = "Badstrasse 12" >>> PLZ = 83003 >>> Ort = "Hausbach" >>> adresse1 = [strasse, PLZ, Ort] >>> adresse1 ['Badstrasse 12', 83003, 'Hausbach']
5.10 Aufgaben
71
Da man Listen auch schachteln kann, ist auch die Implementierung von Tabellen möglich: >>> strasse = "Malerweg 3" >>> PLZ = 99033 >>> Ort = "Sindelbach" >>> adresse2 = [strasse, PLZ, Ort] >>> adressbuch = [adresse1, adresse2] >>> adressbuch [['Badstrasse 12', 83003, 'Hausbach'], ['Malerweg 3', 99033, 'Sindelbach']] Selbstdefinierte Typen Die Definition eigener Typen ist in Python derzeit (Ve rsion 2) nur durc h Ä nderung des C-Quellcodes des Python Systems möglich. In der Regel kommt man jedoch mit der Definition von Klassen aus. Um diese Konzepte hier behandeln zu können, müssten wir eine Einführung in die objektorientierte Programmierung anbieten, was den Rahmen und die Zielsetzung dieses Buches sprengen würde.
5.10
Aufgaben
Entwerfen Sie zu den folgenden Aufgabenstellungen jeweils ein Programm in PPS und/oder Python. Testen Sie Ihre Python-Programme. Aufgabe 5.1: Der B ody-Mass-Index ka nn als Maß für Übergewicht benutzt werden. Die Formel lautet: BodyMassIndex = Gewicht/(Größe/100)2 Das Gewicht wird dabei in kg, die Größe in cm angegeben. Schreiben Sie ein Programm, das die notwe ndigen Einga ben übernimmt und de n B ody M ass Inde x aus gibt. Dabei s oll auf Fehleingaben (Nenner = 0!) passend reagiert werden. Aufgabe 5.2: Ein Temperaturwert soll wahlweise in Fahrenheit oder Celsius eingegeben und dann in die jeweils andere Skala umgerechnet werden. Die Umrechnungsformel lautet: GradCelsius = (GradFahrenheit – 32)/1,8 Aufgabe 5.3: Die Lösungen x1, 2 der quadratischen Gleichung ax2 + bx + c = 0 werden durch die folgende bekannte Formel bestimmt: x1, 2 = (-b ± W)/2a mit W2= D = b2 – 4ac (D ist die so genannte Diskriminante)
72
5 Imperative Programmierung
Schreiben Sie ein Programm, das die Werte der drei Parameter a, b, c einliest und dann nach einer vollständigen Fallunterscheidung (in Abhängigkeit von a und der Diskriminante D) die Lösungen bzw. passende Fehlermeldungen ausgibt. Aufgabe 5.4: Mit dem berühmten Näherungsverfahren von Heron kann man den Wert einer Quadratwurzel (aus x) näherungsweise berechnen: Startwert: x0 = x Nächster Wert: xn+1 = ½ (xn + x/xn) Schreiben Sie ein Programm, das den Wert x sowie eine Angabe zur gewünschten Genauigkeit der Berechnung (z.B. 0.0001) einliest und die o.g. Iteration so lange durchführt, bis die gewünschte Genauigkeit erreicht ist. Aufgabe 5.5: In der Einleitung zu Kapitel 3 finden Sie eine Formulierung des Euklidischen Algorithmus zur Prüfung der Primzahleigenschaft. Schreiben Sie ein Programm zur Bestimmung des größten gemeinsamen Teilers (ggT) zweier Zahlen n, m, das diesen Algorithmus verwendet. Dazu noch ein Hinweis: Der Algorithmus bricht ab, falls „ein Rest die vorangehende Zahl genau misst“. Diese Zahl ist dann der ggT. Falls der ggT gleich 1 ist, sind die beiden Zahlen m, n zueinander prim, d.h. teilerfremd. In PPS dürfen Sie die Funktion mod(a,b) verwenden, die den Rest der (ganzzahligen) Division von a durch b berechnet. In Python gibt es dafür den Operator a % b. Geben Sie für m = 10 und n = 18 den Ablauf des Programms als Folge von Zuständen an. Jeder dieser Zustände wird dabei durch die jeweiligen Werte (Belegung) aller im Programm verwendeten Variablen festgelegt. Aufgabe 5.6: Schreiben Sie ein Programm zur Berechnung des Notendurchschnitts einer Schulaufgabe. Nach der Eingabe der Klassensollstärke und der jeweiligen Anzahl der Schüler, die eine bestimmte Einzelnote erreicht haben, soll der Notendurchschnitt und die Anzahl der abwesenden Schüler ausgegeben werden. Aufgabe 5.7: In Abschnitt 3.1 haben Sie den Algorithmus Bubblesort kennen gelernt. Implementieren Sie diesen Algorithmus. Aufgabe 5.8: Zustandmodelle lassen sich schematisch in Algorithmen und damit in Programme transformieren. Dabei wird der jeweilige aktuelle Zustand in einer speziellen Variablen verwaltet (z.B. zustand). Ein Automat mit den Zuständen 1, 2, 3 und Transaktionen, an denen jeweils eine Eingabe (in Großbuchstaben) als auslösende Aktion und eine ausgelöste Aktion (in Kleinbuchstaben) notiert sind, führt zu folgendem Algorithmus: Wiederhole solange weiter = WAHR Falls Zustand = 1: Falls Eingabe = A: Aktion a; Zustand = 2 B: Aktion b; Zustand = 3 2: Falls Eingabe =
5.10 Aufgaben A: B: 3: Falls A: B:
73 Aktion c; Aktion d; Eingabe = Aktion e; Aktion f;
Zustand = 1 Zustand = 3 Zustand = 1 Zustand = 2
Implementieren Sie nac h diesem Schema den endlichen Automaten zur Erkennung syntaktisch kor rekter unge klammerter alge braischer Te rme aus Abbildung 2.4. De r z u untersuchende Term soll dabei zeichenweise eingegeben werden. Aufgabe 5.9: Eine Tabelle mit den A dressen der Kunden einer Firma soll alphabetisch nach den Namen der Kunden sortiert aufgebaut werden. Falls diese Namen bei zwei Kunden übereinstimmen, sollen die Vornamen zur Sortierung herangezogen werden. Die Tabelle soll je eine Spalte für die folge nden Daten enthalt en: Name, Vorname, PLZ, Ort, Straße . Die Zeilenzahl der Tabelle soll durch die Konstante maxzeil festgelegt sein. Schreiben Sie ein Pr ogramm, das die Einga be aller Daten erm öglicht und da bei die D aten jedes ne u ein gegebenen K unden i n d er (nach dem obi gen S ortierkriterium) richtigen Zeile einordnet. Daz u m uss ggf. ein an diese r Stelle befindlicher Datensatz z usammen mit allen folgenden um eine Zeile nach hinte n ve rschoben werden. Nach der voll ständigen Eingabe sollen alle Daten ausgegeben werden.
6
Funktionale Modellierung
Bei der Beschreibung komplexer Systeme stößt man meist auf zwei konkurrierende Anforderungen: Ei nerseits will man die Beschreibung übersichtlich, int uitiv und möglichst auf den ersten Blick ve rständlich gestalten, andererseits soll sie möglichst aussagekräftig und detailliert sein. Dies es Dilem ma k ann m an lösen, indem man die Beschrei bung i n zwei St ufen aufteilt. 1. Schritt („Black Box“-Sicht) Zunächst gliedert man das betrachtete System in Teilsysteme (Komponenten) und beschreibt die Interaktion bzw. Kommunikation zwischen diesen Komponenten: • Welche Information empfängt eine Komponente? • Welche Information gibt sie an andere Teilsysteme weiter? Nicht beschrieben wird die innere Struktur der Komponenten. Beantwortet werden also zunächst nur die beiden obigen Fragen. Oft ergibt sich die Aufteilung in Komponenten bereits aus der offensichtlichen Struktur des jeweiligen Systems. So drängt sich in größeren Firmen beispielsweise die Aufteilung in Abteilungen auf (siehe Abbildung 6.1). Da es i n dieser Sic htweise um die Funktion der Komponenten im Gesa mtsystem geht, be zeichnet man solche M odelle als funktionale Modelle. Als Beschrei bungstechnik verwendet man dabei meist Datenflussdiagramme (siehe Abschnitt 6.1). 2. Schritt („Glass Box“-Sicht) Erst im zweiten Sc hritt untersucht man für jede der Komponenten ihre innere Struktur: Wie arbeiten die Komponenten intern? In einer Firma würde man hierbei et wa die O rganisation der einzelnen Abteilungen beschreiben, z.B. durch Zustandsmodelle, Algorithmen oder A ktionsstrukturen.
76 6
Funktionale Modellierung
Kunde
Werbung, Preise
Marketing
Lieferzeiten, Preise
Preisgestaltung
Bestellung
Einkauf Bestellung
Lieferant
Rechnung Verkaufsdaten
Produktion
Ausgaben Vertrieb
Kosten Anforderung Produktionsergebnis
Bestand Lagerhaltung
Einnahmen
Überweisungen
Anforderung
Buchhaltung Produktdaten Steuererklärung
Bank
Kontostand
Lagerbestand
Steueranforderung Finanzamt
Abb. 6.1
6.1
Umsatzdaten
Datenflussdiagramm eines Betriebs
Datenflussdiagramme und Programme
Am Beispiel eines verarbeitenden Betriebs wollen wir uns die Bestandt eile eines fu nktionalen Modells (in F orm eines Datenflussdiagramms) klar m achen (siehe Abbildung 6.1). Zunächst haben wir (elliptisch gezeichnet) datenverarbeitende Prozesse vor uns: Sie nehm en Daten über L eitungen e ntgegen, ve rarbeiten diese und gebe n Ausga bedaten auf anderen Kanälen weite r. Die Verbindungslinien z wischen de n e inzelnen Prozessen sym bolisieren Datenflüsse: Leitungen, über die in einer oder auch in beiden Richtungen Daten transportiert werden. Die R echtecke sym bolisieren Datenquellen bzw. -senken: An diesen Stellen kommuniziert das System mit der „A ußenwelt“, d h. es empfängt Informationen von außen oder
6.1 Datenflussdiagramme und Programme
77
gibt s olche nach a ußen ab. Speicherkomponenten werden m it Ober- und Unterst rich (z.B. „Produktdaten“) dargestellt. Was haben nun Programme im Sinne von Kapitel 4 und 5 mit solchen funktionalen Modellen zu tun ? Z unächst ka nn m an ein laufendes Programm als Ganzes als datenverarbeitenden Prozess betrachten und damit als Ellipse in einem Datenfl ussdiagramm darstellen. Dann spiegelt dieses Diagramm die Kommunikation dieses Programms (über seine Ein- bzw. Ausgaben) m it anderen laufende n Programm en bz w. Prozessen (ebenfalls als Ellipsen dargestellt), mit den Benutzern (Rechtecke) oder mit Speicherkomponenten (mit Ober- und Unterstrich) wieder. So könnte in Abbildung 6.1 beispielsweise der Vertrieb automatisiert werden. Das ents prechende Programm wür de da nn Beste llungen, Preise und B estandsdaten e ntgegennehmen und Einnahmen, Verkaufsdaten sowie Rechnungen ausgeben. Bisher habe n wir Programme betrachtet, die aus „einem Stück“ besta nden: Z u bestimmten Eingaben w urde durc h das Programm jeweils eine bes timmte Ausga be erze ugt (E -V-APrinzip, siehe Abschnitt 5.5). Dies erlaubt es, ein Programm als eine Funktion zu betrachten, die in Abhä ngigkeit von ge wissen Eingabedaten aufgrund eines Algorit hmus Ausgabedaten erzeugt, z.B.: mittelwert(3, 5) = 4 In dieser funktionalen Sichtweise interessiert man sich vor allem für die (oft durch mathematische Aus drücke beschriebene) Zuordnung zw ischen Ein - und A usgabedaten und w eniger für den Algorithmus, der die Aus gabedaten aus de n Eingabedaten berechnet („Black-Box“Sicht, siehe oben): Sortiere(„Theo“, „Anna“, „Katharina“, „Emil“) = („Anna“, „Emil“, „Katharina“, „Theo“). In der funktionalen Sicht interessieren wir uns nur für die Tatsache, dass diese Funktion eine Liste von Zeic henketten ent gegennimmt und dieselben Ze ichenketten i n (a ufsteigend) sortierter Reih enfolge au sgibt, ab er nicht für den speziellen Algorithmus, der d iese Sortierung erzeugt (z.B. Sortieren durch Einfügen, Bubblesort, Quicksort etc.). In ei nem Datenfl ussdiagramm kann m an also ( wie obe n bes chrieben) ein Pr ogramm (als Funktion) durch einen informations verarbeitenden Prozess (Ellipse) symbolisieren, wie z.B. unser Sortierprogramm in Abbildung 6.2.
78 6
Funktionale Modellierung
(„Theo“, „Anna“, „Katharina”, „Emil”)
sortiere
(„Anna“, „Emil”, „Katharina”, „Theo“) Abb. 6.2
6.2
Ein Programm als datenverarbeitender Prozess
Aufteilung von Programmen in Unterprogramme
Neben der Kommunikation mit der A ußenwelt kann ein funktionales Modell aber auch die innere Struktur eines Programms darstellen. Dies ist vor alle m bei der Aufteilung eines Programms in relativ selbständige Teile hilfreich. Diese Teile nennt man Unterprogramme. Beispiel: Ein Programm zur Bruchrechnung könnte z.B. in folgende Unterprogramme aufgeteilt werden: • • • • • • • •
Eingabe von zwei Brüchen (jeweils Zähler und Nenner), Ausgabe eines Bruches, Kürzen eines Bruches, Erweitern eines Bruches mit einer bestimmten ganzen Zahl, Kehrwertbildung eines Bruches, Invertierung des Vorzeichens eines Bruches, Addition zweier Brüche, Multiplikation zweier Brüche.
Die Nutzung dieser Unte rprogramme zu weit eren Berechnungen kann wiederum durch Datenflussdiagramme dargestellt werd en. Abbildung 6.3 zeigt ein funktiona les Modell für die Division zweier Brüche unter Benutzung einiger dieser Unterprogramme.
6.2 Aufteilung von Programmen in Unterprogramme
Tastatur
bruch1 bruch2
79
Eingabe
bruch1
bruch2
Kehrwert
Multiplikation
bruch3 := bruch1/bruch2
Bildschirm
Abb. 6.3
Ausgabe
Funktionales Modell für die Division zweier Brüche
Eine solche Aufteilung in Unterprogramme kann aus verschiedenen Gründen nützlich sein: • Arbeitsteilung (Teamarbeit): Die Unterprogramme könne n gleichzeitig von je ei ner Arbeitsgruppe erstellt oder verändert werden. • Wiederverwendung von C ode: Ein Unterprogramm kann von m ehreren Stellen des Hauptprogramms aus aufge rufen we rden. So ka nn man seinen P rogrammtext mehrfach nutzen, ohne ihn m ehrfach schreiben zu müssen, was vor allem Probleme bei der nachträglichen Veränderung dieses Textes vermeidet. • Abstraktion von der konkreten A ufgabenstellung: D urch Parametrisierung (sie he Abschnitt 6.6) kann ei n Unterprogramm auf ei ne ganze Klasse von A ufgabenstellungen angewandt werden. • Die geschickte Aufteilung i n Unterprogramme (mit aussagekräftigen Bezeichnern) kann die Übersichtlichkeit eines Programmtextes enorm steigern, da so der Hauptalgorithmus sehr knapp dargestellt werden kann. Meist folgt man bei de r Erstellung solcher aufgeteilter Programme dem Schema der schrittweisen Verfeinerung: Man unterteilt die Aufgabenstellung zunächst in ei nige wenige grobe Teilaufgaben. In den folgenden Schritten wird diese Aufteilung weiter verfeinert, bis man ein System von Teilaufga ben er hält, die jewei ls durch eine n (Teil-) Algori thmus lösbar sind. Danach können diese Teilalgorithmen als Unterprogramme implementiert und schließlich die Unterprogramme zu einem Gesamtsystem kom biniert w erden. Bezoge n auf unser D atenflussdiagramm aus Abbildung 6.1 würde das z.B. der Aufteilung des Prozesses "Produktion" bei einer Automobilfabrik in die Teilprozes se "Produktion Fahrgestell", "Produkti on Karosserie" und "Produktion Antrieb" und damit einer Verfeinerung des Modells entsprechen.
80 6
Funktionale Modellierung
Leider kann man ein Programm durch ungeschickte Aufteilung und bzw. oder schlechte Umsetzung dieser Aufteilung auch sehr unübersichtlich gestalten. Insbesondere sollte man darauf achten, dass für jedes Unterprogramm möglichst klar wird, welche Variablen es verändert, um unerwünschte Nebeneffekte zu vermeiden. Davon wird weiter unten noch ausführlich die Rede sein. Die Bezeichnung von Unterprogrammen als Funktionen bzw. Prozeduren ist in der Literatur leider nicht ganz einheitlich. Wir werden in diesem Buch Unterprogramme zunächst als Prozeduren a uffassen und da raus Kriterien entwickel n, die eine Bezeichnung als Funktion rechtfertigen. In objekt orientierten S prachen werden Variablen und Unterprogramme (P rozeduren oder Funktionen) zu Objekten zusammengefasst. Die Struktur dieser Objekte wird in Klassenbeschreibungen festgehalten. Dabei bezeichne t man die Funktionen eines Objektes bzw. einer Klasse meist als Methoden. Mehr darüber erfahren Sie in der Literatur über „Objektorientierte Modellierung“.
6.3
Deklaration und Aufruf von Prozeduren
Am Beispiel eines Programms zum Zahlenraten soll nun die Verwendung von Prozeduren in PPS e rklärt w erden. Die Regeln für De klarationen von Unte rprogrammen finden Sie im Anhang zur Syntax von PPS. program prozedurtest: var nat eingabe, ratezahl := 17; var bool erraten := false; // Deklaration der Prozedur „treffer“ procedure treffer: begin output(„* WIR GRATULIEREN! *“); output(„**********************“); output(„Sie haben die Zahl erraten!“) endproc; // Deklaration der Prozedur „daneben“ procedure daneben: begin output(„# Leider daneben! #“); output(„##########################“); output(„Bitte versuchen Sie es noch mal!“) endproc; begin while erraten = false do input(eingabe); // Aufruf der beiden Prozeduren über ihren Namen:
6.4 Globale und lokale Variable
81
// „erraten“, „daneben“ if eingabe = ratezahl then treffer; erraten := true else daneben endif endwhile end. In eine r Prozedurdeklarati on werden alle wesentlichen Eigenschaften der Prozedur, insbesondere ihr Algorithmus, vereinbart. Da nach steht die Pr ozedur als neue Anweisung zum Aufruf zur Verfügung. Die Deklaration alleine löst allerdings nur die Organisation von Speicherplatz und einiger anderer Verwaltungsmaßna hmen aus. Der eigentliche Start einer Prozedur (im Sinne des Ablaufs ihres Algorithmus) wird durch den Namen der Prozedur (innerhalb des Hauptprogramms oder eines anderen Unterprogramms) ausgelöst.
6.4
Globale und lokale Variable
In Unterprogrammen kann m an spe zielle Variab len für interne Z wecke deklarieren. D iese Deklaration ist dann nur innerhalb dieses Unterprogramms gültig. Solche Variablen hei ßen deshalb lokale Variablen. procedure quadratzahlen: var nat i; begin for i := 1 to 100 do output(i*i) endfor endproc Falls man Variablen nur für Berechnungen innerhalb eines Unterprogramms benötigt, sollten diese auch nur lokal deklariert werden (so wie oben die Variable i). Das hat (verglichen m it einer Deklaration im Hauptprogramm außerhalb des Unterprogramms) zwei Vorteile: 1. Der Speic herplatz für diese Variablen wird nac h der Be endigung de s Unterprogramms wieder freigegeben. 2. Diese Variablen sind außerhalb des Unterpro gramms nicht sichtbar (siehe Abschnitt 6.5) und können daher dort auch nicht verändert werden. Eine Variable heißt also (von einem bestimmten Unterprogramm aus gesehen) lokal, wenn sie nur inner halb dieses Unterprogramms deklariert ist. Si e heißt global, wenn sie auch außerhalb des Unterprogramms gültig (d h. deklariert) ist: program mitarbeiter: var nat anzahl; var [1:100] array string name; procedure einstellen:
82 6
Funktionale Modellierung
var string einname; begin input(einname); if anzahl < 100 // Achtung, schlechter Stil: // Veränderung globaler Variablen! then anzahl := anzahl + 1; name[anzahl] := einname else output(„Maximale Mitarbeiterzahl erreicht“) endif endproc; begin … // Hier werden evtl. die globalen Variablen // anzahl und name[anzahl] von einstellen verändert: einstellen; … end. In diesem Programm sind von der Prozedur einstellen aus gesehen • die Variable einname lokal, • die Variablen anzahl und name dagegen global. Falls in einem Unterprogram m eine lokale Variable mit d em gleichen Bezeichner wie eine globale Variable deklariert wird, so kann über diesen Bezeichner innerhalb dieses Unterprogramms auch nur auf die lokale Variable zuge griffen werde n. Die globale Varia ble wird dann von der lokalen „verschattet“: program verschattung: var nat i; … procedure quadratzahlen: var nat i; begin for i := 1 to 100 do output(i*i) endfor endproc; … Innerhalb der Prozedur quadratzahlen ist die globale Variable i nicht sichtbar, da sie von der gleichnamigen lokalen verschattet wird.
6.5 Bindung und Gültigkeit
6.5
83
Bindung und Gültigkeit
Je nach dem Ort ihrer Deklaration kann man auf die Variablen eines Programms an manchen Stellen zugrei fen, a n anderen nicht. Der Be reich, inner halb dessen ei ne Deklaration überhaupt bekannt ist, heißt Bindungsbereich der Variablen. Der Bezeichner der Variablen ist in diesem Bereich an diese Deklaration gebunden und kann nicht beliebig anderweitig verwendet werden. Da es die Möglichkeit de r Versc hattung e iner globalen Varia blen durc h eine gleichnam ige lokale gibt (siehe oben), kann es vorkom men, dass man auf eine Va riable an m anchen Stellen ihres Bindungsbereichs nicht zugreifen kann. Man unterscheidet daher den Gültigkeitsbereich (Sic htbarkeitsbereich) e iner Variablen (inner halb dessen m an auf sie zugreife n kann) von ihrem Bindungsbereich. Beispiel: Im obigen Programm mitarbeiter sind Bindungs- und Gültigkeitsbereiche identisch (siehe Tabelle 6.1): Tab. 6.1
Bindungs- und Gültigkeitsbereich im Programm mitarbeiter
Variable
Bindungsbereich
anzahl name einname
Hauptprogramm Hauptpr Hauptprogramm Hauptpr Prozedur einstellen Prozedur
Gültigkeitsbereich ogramm ogramm einstellen
Beispiel: Im Programm verschattung unterscheiden s ich da gegen B indungs- und G ültigkeitsbereich der globalen Variablen i: Tab. 6.2
Bindungs- und Gültigkeitsbereich im Programm Verschattung
Variable globale Variable i lokale Variable i
Bindungsbereich Gültigkeitsbereich Hauptprogramm Hauptprogramm mit Ausnahme der Prozedur quadratzahlen Prozedur quadratzahlen Prozedur quadratzahlen
Wegen der Möglichkeit der Schachtelung von Prozeduren (man kann innerhalb einer Prozedur a ndere P rozeduren deklarieren, die dann nur lokal innerhalb diese r Prozedur be kannt sind), ist es oft nicht ganz einfach, den Gültigkeitsbereich einer Variablen festzustellen. Die fol gende Tabelle zeigt ein Beispiel für Bi ndungs- und G ültigkeitsbereiche eine r Reihe von Variablen.
84 6 B = Bindungsbereich G = Gültigkeitsbereich program hauptprogramm: var nat hvar; procedure aussen1: var nat avar1; //Lokale Prodezur innerhalb von aussen1: procedure innen:
hvar1
avar1 ivar
B B B B B
G G G G G
B B B
G G G
B B B B B B B
G G G G G G G
B B B B B B B
G G G G G G G
G G
begin … endproc;
B B B B B B
begin … end.
B B B
G G G
var nat ivar; begin … endproc; begin … endproc; procedure aussen2: var nat avar2, hvar;
1
Funktionale Modellierung
3 3 3 3
2
avar2 hvar
B B B B
G G G G
B B B B
G G G G
B B B B
G G G G
Globale Variable des Hauptprogramms
2
Lokale Variable des Unterprogramms aussen2 (mit gleichem Namen wie die globale Variable hvar) 3
Hier wird die globale Variable hvar durch die gleichnamige lokale verschattet
Über die Möglichkeit, Variablen (oder auch Sorten und Unterprogramme) in Unterprogrammen zu de klarieren, bieten m anche Programmiersprachen auch die Möglichkeit, Deklarationen und Anweisunge n m it Hilfe von speziellen Klammerelem enten (z.B. begin bzw. end oder Paare geschweifter Klammern) zu Blöcken zusammenzufassen, z.B.: begin var nat x, y; y := x * x; output(y) end Diese Blöc ke können (e benso wie U nterprogramme) wieder um geschac htelt und/ode r m it Unterprogrammen kombiniert werden. Die obigen Aussagen für die Gültigkeit bzw. Bindung
6.6 Parameter
85
von Variablen gelten dann entsprechend auch für solche Blöcke. Wir werden in diesem Modul aber auf deren Verwendung verzichten.
6.6
Parameter
Wie wir in Kapitel 2 erfa hren haben, beschreibt ein Algorithmus in de r Regel eine Lösung für ei ne ganze Klasse von Aufgaben. Ei n Algorithmus zu r Berech nung d er Quadratwurzel einer Zahl ka nn beispielsweise für alle pos itiven Zahlen angewandt werden. Im plementiert man einen Algorithmus in einer Prozedur, so will m an diese natürlich auch auf alle Aufgabenstellungen dieser Klasse anwenden können. Um diese Flexibilität in der Anwendung zu erreichen, verwendet man Parameter: Das Unterprogramm erhält für jeden zu übergebenden Wert einen formalen Parameter im Kopf der Deklaration. Diese form alen Param eter dienen als Platzhalter für konkrete Werte, die der Prozedur bei i hrem Aufruf übergeben werden, hier am Beispiel der Berechnung der Potenz xy mit ganzen Zahlen x, y und y>0: procedure x_hoch_y (nat px, py): var nat ergebnis, i; begin ergebnis := 1; for i := 1 to py do ergebnis := ergebnis * px endfor output(ergebnis) endproc Ein Aufruf könnte dann z.B. lauten: x_hoch_y(2,3) Das Ergebnis wäre die Ausgabe der Zahl 8. In vielen Sprachen (z.B. in unserer Sprache PPS oder in Java) werden bei der Deklaration von Unte rprogrammen die Sorten der formalen Param eter festgelegt. Dann sollten beim Aufruf des Unterprogramms auch nur W erte (bzw. im folgende n Abschnitt 6.7 Speicheradressen) de r je weils festgelegten Sorte an die Param eter überge ben we rden (z.B. natürliche Zahlen an Parameter der S orte nat). Die Re aktion auf Ve rletzungen dieser Regel hängt von den beiden Sorten und de r jeweiligen Programmiersprache ab. So ist z.B. die Übergabe von ganzzahligen Werten (nat) an Parameter vom Typ float meist unproblematisch, die Umkehrung dagegen meist nicht. Wir ge hen in die sem Skript grundsätzlich da von aus, dass diese Bedingung eingehalten wird.
86 6
6.7
Funktionale Modellierung
Ergebnisübergabe
Das obige Unterprogramm x_hoch_y kann zwar das Ergebnis der Berechnung von x y direkt (z.B. am Bildschirm ) aus geben, es je doch nicht dem Hauptprogramm für weitere Be rechnungen zur Verfügung stellen, wie es z.B. zur Aufsummierung aller 3er-Potenzen von 31 bis 310 notwendig wäre. Wie kann man die Ergebnisse eines Unterprogramms an das aufrufende Programm übermitteln? Dafür gibt es im Wesentlichen drei Möglichkeiten: 1. Schreibzugriff auf globale Variable, 2. Nutzung formaler Ausgangsparameter, 3. Implementierung des Unterprogramms als Funktion. Diese drei Möglichkeiten sollen nun eingehender betrachtet werden.
6.7.1
Schreibzugriff auf globale Variable
Globale Variablen sollten nur in Not fällen (wie z.B. dem Fall, dass eine relativ zum Speicherangebot sehr große Mengen einzel ner Daten übergeben werden soll) zur R ückgabe der Ergebnisse von Unte rprogrammen verwendet werden. Da diese Vorge hensweise zu sehr unübersichtlichen Abläufen (lokal „unsichtbare“ Veränderung von Variablen) führt, sollte sie ansonsten jedoch tunlichst vermieden werden (siehe auch das obige Programm mitarbeiter). program globale_rückgabe: var nat global; procedure x_hoch_y (nat px, py): var nat ergebnis, i; begin ergebnis := 1; for i := 1 to py do ergebnis := ergebnis * px endfor // ACHTUNG: Veränderung globaler Variablen: global := ergebnis endproc; // Beginn des Hauptprogramms begin global := 1; // Folgender Aufruf verändert global, // was an dieser Stelle nicht erkenntlich ist: x_hoch_y(2, 3); output(global) end.
6.7 Ergebnisübergabe
87
Das Programm würde in diesem Fall die Ausgabe 8 liefern. Wenn man sich vor Augen führt, dass große kommerzielle Programme nicht selten Hunderttausende bis Millionen Programmzeilen um fassen, ka nn man sich vorstellen, wi e problem atisch eine loka l nicht erkennbare (weil im Aufruf einer P rozedur versteckte) Veränderung einer globalen Variablen sein kann. Solche nicht direkt (d h. an der Aufrufstelle) sich tbaren Nebenwirkungen eines Unterprogramms nennt man auch Seiteneffekte. Trotz aller möglichen Probleme ist man in bestimmten Fällen zu Schreibzugriffen auf globale Variable ge zwungen. Ein Beispiel hierfür wä re die Zä hlung der laufe nden Prozesse einer bestimmten Funktion.
6.7.2
Ausgangsparameter
Eine zweite (in der Regel günstigere) Möglic hkeit zur Rückgabe der Ergebnisse eines Unterprogramms bieten Ausgangsparameter (bzw. Ergebnisparameter). Das sind spezielle Parameter, die in den m eisten Program miersprachen auc h al s solche ge kennzeichnet werden müssen, in PPS (wie in Pascal und Modula) durch das vorangestellte Schlüsselwort var: program ausgangsparameter: var nat globalx := 2, globaly := 3, globalz; procedure x_hoch_y (nat px, py, var nat pz): var nat ergebnis, i; begin ergebnis := 1; for i := 1 to py do ergebnis := ergebnis * px endfor // Zuweisung an Ausgangsparameter pz, // kenntlich durch „var“ in der Parameterliste pz := ergebnis endproc; // Beginn des Hauptprogramms begin // Folgender Aufruf weist das Ergebnis // der Variablen „globalz“ zu x_hoch_y(globalx, globaly, globalz); output(globalz) end. Auch hier würde das Progra mm wieder die Aus gabe 8 liefern. Im Ge gensatz zur obigen Rückgabe des Erge bnisses m ittels globale r Varia ble ist hier we nigstens sichtbar , das s die Variable globalz zumindest beteiligt ist (wenn auch die Rolle als Ausgangsvariable im Aufruf nicht sichtbar ist). Über die Kennzeichnung der Ausgangsparameter durch var im Kopf (der
88 6
Funktionale Modellierung
ersten Zeile) der P rozedurdeklaration könnte man außerdem automatisch eine Liste der Unterprogramme mit Ausgangsparametern erstellen und so im Programmcode lokalisieren, wo ein Sc hreibzugriff möglich is t (was bei Sc hreibzugriffen a uf globale Variablen nicht unbedingt machbar ist).
Wirkung des Aufrufs von x_hoch_y: x_hoch_y(globalx, globaly, globalz); enthält Parameter
Inhalt
px
2
py
3
var pz
1016
Startadresse
value call by value call by c a ll b
renc y refe
enthält Variable
1000
2
1008
3
globaly
1016
0
globalz
globalx
e
Speicher des Unterprogramms
Abb. 6.4
Inhalt
Speicher des Hauptprogramms
Vergleich der Übergabemechanismen für Parameter
Während es bei Eingangsparametern genügt, wenn nur der aktuelle Wert für den Parameter an die Pr ozedur ü bergeben wir d („call by value“), m uss bei Aus gangsparametern die Anfangsadresse des Speic herbereichs einer globalen Varia blen übergeben wer den, d amit do rt ein Schreibzugriff stattfinden kann („call by reference“), siehe auch Abbildung 6.4.
6.7.3
Funktionskonzept
Das dritte (und eleganteste) Übergabekonzept ist die I mplementierung des Unterprogramms als Funktion: Es wir d als (neu de finierte) Funktion im plementiert und auf gerufen, di e (ge nau!) einen R ückgabewert zurüc kliefert. Diese Rückgabe wird innerhalb der Deklaration durch die return-Anweisung festgelegt. Für die Deklaration solcher Funktionen (in diesem Sinne) gilt (im Vergleich zu Prozeduren) eine etwas veränderte Syntax: 1. Im Kopf der Deklaration wird anstatt procedure das Schlüsselwort function verwendet. 2. Nach der Liste der Parameter wird (du rch einen Doppelpunkt abgetrennt) die Sorte des Rückgabewertes der Funktion festgelegt. Falls möglich, sollte dieser Variante immer der Vorzug vor de n an deren beiden gegeben werden, da nur hier die zuweisende Wirkung auf die Variable (z.B. globalz) im Aufruf offensichtlich ist:
6.8 Module
89
program funktionskonzept: var nat globalx := 2, globaly := 3, globalz; function x_hoch_y (nat px, py): nat var nat ergebnis, i; begin ergebnis := 1; for i := 1 to py do ergebnis := ergebnis * px endfor // Rückgabe des Ergebnisses als Wert der Funktion return ergebnis endfct; // Beginn des Hauptprogramms begin // Folgender Aufruf weist das Ergebnis der Funktion // der Variablen „globalz“ zu globalz := x_hoch_y(globalx, globaly); output(globalz) end. Mit Hilfe von Funktionen können viele Auf gaben sehr kom pakt formuliert werden, z.B. die oben erwähnte Aufsummierung aller 3er-Potenzen zwischen 31 und 310: … var nat k, summe := 0; for k := 1 to 10 do summe := summe + x_hoch_y(3, k) endfor output(summe); … Dieses Konzept liegt sehr nahe am funktionalen Programmierstil, der im Kapitel 7 ausgiebig besprochen wird. D ort werden wi r F unktionen aller dings grundsätzlich als Ausdrücke und nicht mehr als Folgen von Anweisungen behandeln.
6.8
Module
In der Regel wird der Programmcode in modernen Programmiersprachen in mehrere Module aufgeteilt. Das hat zahlreiche Vorteile, u.a.:
90 6
Funktionale Modellierung
• Übersichtlichkeit: Überlange Programmtexte lassen sich schwer lesen. Auch könne n so korrespondierende Codestellen aus m ehreren Modulen in mehreren Fens tern ve rglichen und geändert werden, ohne dauernd im Programmtext blättern zu müssen. • Arbeitsteilung: Die Arbeit kann nach M odulen auf m ehrere Personen, Teams, Abteilungen, Firmen aufgeteilt werden. • Kapselung: Durch selektive Zuteilung von Schreibrechten kann man genau festlegen, wer welchen Modul ändern darf. Solche Module bestehen in der Regel aus einer Reihe von Deklarationen für Sorten, Variablen, Prozeduren und Funktionen. Besonders in der Objektorientierten Programmierung ist die Verwendung solcher M odule weit verbreitet. Hier ent halten sie m eist Klassende finitionen. Mehr da rüber erfahren Sie in der Liter atur über „Obj ektorientierte Modellierung und Programmierung“.
6.9
Unterprogramme in Python
Zunächst ist festzustellen, dass Python in der Deklaration keine Unterschiede zwischen Funktionen und Prozeduren macht. Wir werden also alle Python-Unterprogramme als Funktionen bezeichnen. Funktionen müssen in einem Script deklariert we rden, wie z.B. die folgende „prozedurartige“ Funktion (ohne Rückgabewert) quadratzahlen in quadratzahlen.py: # quadratzahlen.py def quadratzahlen(): for i in range(1,101): print i*i In der Interpreterumgebung wird die Funktion dann folgendermaßen aufgerufen: >>> quadratzahlen() 1 4 9 .. 9801 10000 Dabei ist zu beachten, dass nach dem Bezeichner der Funktion sowohl in der Deklaration als auch im Aufruf ein runde s Klammerpaar folgen muss (auch we nn keine Parameter benutzt werden). Im Folgenden machen wir den Aufruf der jeweiligen Funktionen nur noc h durch das Prompt des Python-Interpreters (>>>) deutlich. Wie aufgr und des gr oßzügigen Umgangs von Python m it Typen nicht a nders z u er warten, muss bei der Dekla ration von Fu nktionen der Typ de r P arameter nicht ange geben werden.
6.9 Unterprogramme in Python
91
Die Rückgabe des Ergebnisses sollte, wie in Abschnitt 6.7 ausgeführt, wenn irgend m öglich über return vollzogen werden: def x_hoch_y(px,py): if py == 0:return 1 else: ergebnis = 1 for i in range(1,py+1): ergebnis = ergebnis*px return ergebnis >>> x_hoch_y(3,4) 81 Mit einer Ausnahme (siehe unten) sind in Python alle Parameter Eingangsparameter (call-byvalue-Übergabe): def partest(px): px = 99 return px >>> x = 5 >>> partest(x) 99 >>> x 5 Der W ert der Varia blen x wurde d urch de n A ufruf partest(x) also n icht v erändert, obwoh l innerhalb v on partest ein Sc hreibzugriff a uf de n Pa rameter px stattfand. In Python gibt es kein Schlüsselwort (wie var in PPS oder Pascal), das die Kennzeichnung eines Parameters als Ausgangsparameter ermöglicht. Dennoch erlaubt die e inzige Ausnahme von diesem beinahe durchgehenden call-by-value Konzept die Verwendung von Ausgangsparametern: def partest(px): px[0] = 99 return px >>> partest(liste) [99, 2, 3] >>> liste [99, 2, 3] Komponenten von Listen werden also über call-by-reference (ganze Listen dagegen ebenfalls nur ü ber call-by-value) übe rgeben und können s o als A usgangsparameter ge nutzt w erden (indem man die zu verändernde Variable als Komponente einer Liste anlegt): def x_hoch_y(px,py, erg): if py == 0:ergebnis = 1
92 6
Funktionale Modellierung else: ergebnis = 1 for i in range(1,py+1): ergebnis = ergebnis*px erg[0] = ergebnis
>>> erg = [0] >>> x_hoch_y(3,4,erg) >>> erg [81] In Python ist gr undsätzlich kein Z ugriff a us eine r F unktion heraus auf globale Variablen möglich, es sei denn, dass diese Variablen ausdrücklich als global gekennzeichnet werden: def writeglobal(px): global gx gx = px return "fertig!" >>> gx = 5 >>> writeglobal(99) 'fertig!' >>> gx 99
6.10
Module in Python
Python verfügt über eine Unzahl von Modulen für beinahe jeden Zweck: von der Netzwerkprogrammierung bis zur M athematik. Diese Mo dule stellen Biblio theken für Kostanten, Datenstrukturen und Funktionen dar, aus denen man sich bei Be darf bedienen kann. Nähere Informationen finden Sie in der Python-Dokumentation Globale Module Index (von Idle aus über das Help-Menü zugänglich). Am Beispiel des Moduls random, der f ür die Erzeugung von Zufallszahlen zustä ndig ist, wollen wir den Mechanismus der Einbindung kurz aufzeigen: # wuerfeln.py import random zahl = random.randint(1,6) print zahl ratezahl = input("Bitte raten Sie die gewuerfelte Zahl (1-6): ") if ratezahl == zahl: print "Erraten!" else: print "Leider daneben!" >>>
6.11 Aufgaben
93
Wir wuerfeln! Bitte raten Sie die gewuerfelte Zahl (1-6): 1 Erraten! Der M odul random wir d also durc h die A nweisung import.random zugänglich gem acht. Danach kann auf seine Komponenten zugegriffen werden, z.B. mittels random.randint(a,b) auf die Funktion randint, di e eine ganze Zufallszahl zwischen (je weils inklusi ve) a und b liefert.
6.11
Aufgaben
Aufgabe 6.1: Erstellen Sie jeweils ein Datenflussdiagramm für die durch folgende Nutzungsfälle beschriebenen Systeme: a)
Flugbuchungssystem: Ein Kunde bu cht von einem Reisbüro aus eine n Flug ei ner bestimmten Fluggesellschaft und bezahlt mit Kreditkarte.
b) Kraftfahrzeug: Ein ABS -System steuer t das Brem sverhalten bei einer Vollbremsung. c)
Zwei Personen telefonieren mit ihre m Mob iltelefon über eine Landes- (Provider-) grenze hinweg.
d) Eine Frau bestellt sich bei einem Versandhaus per Telefon ein Kleid. Die Rechnung wird von ihrem Konto abgebucht. e)
Ein LKW-Mautsystem bucht die Mautgebühren nach Feststellung der Fahrtstrecken über GPS vom Konto der Fuhrunternehmen ab.
f)
Vereinfachte Lohnsteuerberechnung: Vom Bruttolohn werden die bezahlte Kirchensteuer, die Vorsorgepa uschale von 1000 € sowie die Werbungskoste n abgezogen. Der Rest r wird mit einem festen Steuersatz von 25% besteuert.
g) Notendurchschnitt Ihrer Schulleistungen in der 10. JGSt.: Die Durchschnittsnote ergibt sich aus dem schriftlichen und dem mündlichen Durc hschnitt im Verhältnis 2:1. Der schriftliche Durchsc hnitt ergibt sich aus 4 Sc hulaufgaben, de r mündliche aus 2 mündlichen Noten und 2 Extemporalien zu gleichen Teilen. h) Stellen Sie den f olgenden T erm als Da tenflussdiagramm dar: C = Quadrat wurzel(Summe(Quadrat(a), Quadrat(b))). Es handelt sich um eine Umformung des Satzes von Pythagoras: a2 + b2 = c2. Aufgabe 6.2: Die Firma CallCar betreibt ein innovatives, automatisiertes System zur Vermietung von Automobilen. An 5 Flughäfen (München, Köln/Bonn, Berlin, Hamburg und Frankfurt) kann man Automobile ausleihen und sie auch wieder abgeben. Nach der Anmeldung als Kunde (unter Einsendung einer amtlich beglaubigten Führerscheinkopie) kann man Fahrzeu-
94 6
Funktionale Modellierung
ge bis spätestens einen Tag vor der Abholung (unter Angabe von Name, Vorname, Adresse, Kreditkartendaten, Fahrzeugklasse, Tag, Uhrzeit und Ort von Abholung und Rückgabe) über das Internet re servieren. Falls das gewünschte Fahrzeug (oder ggf. nach Rückfrage eine Ersatzklasse) verfügbar ist, erhält man eine R eservierungsnummer. Mit dieser und de r bei der Reservierung angegebenen Kreditkarte erhält man an einem Automaten in der Flughafenhalle den Fahrzeugschlüssel sowie d ie Parkplatznummern für Abholung und Ab gabe und kann danach in der Parkgarage das Fahrzeug abholen. Die Rückgabe erfolgt, indem man das Fahrzeug auf dem vorgesehenen Parkplatz abst ellt und die Sc hlüssel in eine n Briefkasten wirft. Falls das Fa hrzeug nicht m it vollem Tank ode r beschädigt zurückgegeben wir d, bela stet CallCar die Kreditk arte entsprechend. Die Fa hrzeuge e nthalten Se nder, die de n a ktuellen Aufenthaltsort (übe r ein Global Positioning System) und Au sleihstatus alle 5 M inuten an CallCar melden. a)
Erstellen Sie e in Datenflussdiagramm, das die für die obigen Vo rgänge notwendigen Komponenten und Datenflüsse des Systems beschreibt. Sie können sich bei den Flughäfen und Fahrzeugen dabei jeweils auf einen Repräsentanten beschränken.
b) Beschreiben Sie den Ausleihvorgang durch ein Zustandsdiagramm. Aufgabe 6.3: Programmieren Sie in PPS und/oder Python ein Programm zur Bruchrechnung unter Verwendung der folgenden Funktionen (Unterprogramme): • • • • • • • •
Eingabe eines Bruches, Eingabe zweier Brüche (jeweils Zähler und Nenner), Ausgabe eines Bruches, Kürzen eines Bruches, Erweitern eines Bruches mit einer bestimmten ganzen Zahl, Kehrwertbildung eines Bruches, Addition bzw. Subtraktion zweier Brüche, Multiplikation zweier Brüche, Division zweier Brüche.
Sie können dabei auf den Euklidischen Algorithmus zur Berechnung des ggT zweier Za hlen (siehe Aufgabe 5.5) sowie auf die Beziehung ggT(a, b) = ab/kgV(a, b) zurückgreifen. Legen Sie das Hauptprogramm in Form eines Menüs an, das dem Benutzer die Wahl aus den o.g. Funktionen lässt. Aufgabe 6.4: Gegeben ist f olgendes P rogramm ( mit Zeilennum mern) in PPS, das eine Potenz xy berechnet: 1 2 3 4
program Potenz: var nat x, y; procedure berechnen (nat x, y): procedure x_hoch_y (nat x, y):
6.11 Aufgaben 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
95 var nat erg, i; begin erg := 1; for i := 1 to y do erg := erg * x endfor output(erg); endproc
begin x_hoch_y(x, y); endproc begin output(“Geben Sie bitte die erste Zahl ein:”); input(x); output(“Geben Sie bitte die zweite Zahl ein:”); input(y); berechnen(x, y); end.
Geben Sie für jedes Auftreten einer Variablen die Zeile ihrer Deklaration an! Geben Sie dann (analog zur Tab elle in Abschnitt 6.5) für jede Variable ih ren Bindungsbereich u nd ihren Gültigkeitsbereich an.
7
Funktionale Programmierung
Wie wir in den letzten Kapiteln ausfüh rlich dargelegt haben, spielt das Zustandskonzept (im Sinne des Belegungszustands aller Variablen eines Programms) eine entscheidende Rolle für das Verständnis d es Ab laufs i mperativer Prog ramme. Demzu folge eig nen sich im perative Programme vor allem zur Steuerung bzw. Simulation von Vorgängen, bei denen die Zustände des bet rachteten Syste ms wichtig si nd, wie es z.B. bei grafisc hen B enutzeroberflächen, der Simulation von Automaten oder bei elektronischen Einkäufen der Fall ist. Andererseits gibt es viele Aufgaben, bei denen es lediglich auf das Ergebnis der Berechnung ankommt, ohne dass da bei irgendwelche Zwisch enzustände eine Rolle spielen würden, z.B. bei der Berechnung des Wertes mathematischer Terme. Für solche Zwecke bietet sich oft ein anderer Stil de r Programmierung an, der funktionale Programmierstil. Er orientiert sich an der Berechnung von Termen, so wie Sie das von Ihrem Taschenrechner seit langer Zei t gewohnt sind. Zusätzlich bieten funktionale Sprachen (im Gegensatz zu ni cht programmierbaren Taschenrechnern) die Möglichkeit, eigene Funktionen zu de finieren. Aufgrund der starken Zentrierung auf Funktionen und dem Fehlen von Zuständen eignet sich dieser Stil au ch sehr gut zur Umsetzung von Datenflussdiagrammen. Ein weiterer Vorteil funktionaler Sprachen ist die (relativ) leichte Verifizierbarkeit funktionaler Programme, da man sich nicht u m Zwischenzustände und Seiteneffekte (das sind Veränderungen von Variablenwerten, die i m Programmcode nicht direkt ersichtlich sind, z.B. der Schreibzugriff aus einer Prozedur heraus auf eine globale Variable oder eine Ein- bzw. Ausgabeanweisung) zu kümmern braucht. Es soll hier nicht verschwiegen werden, dass ein strenger funktionaler Programmierstil auch starke Einschränkungen mit sich bringt (es gibt keinerlei Anweisungen, also auc h keine Zuweisung). Daher beinhalten die m eisten funktionalen Sprachen zusätzlich auch im perative Konzepte, die eine „imperative Programmierung durch di e Hinte rtür“ erlaube n (z.B. ML). Um die Unterschiede und Charakteristika der beiden Stile verdeutl ichen zu können, werden wir in diesem Kapitel jedoch auf solche „Zwitterprogramme“ verzichten und streng funktional programmieren. Zur Beschreibung funktionaler Programme werden wir in diesem Kapitel weiter die Sprache PPS verwenden, diese jedoch entsprechend einschränken bzw. ausbauen (z.B. mit bedingten Ausdrücken), w odurch w ir die f unktionale Variante FPPS (f ür funktionale PseudoProgrammiersprache, siehe auch Anhang zur Syntax von FPPS) erhalten. Parallel d azu können Sie d ie funk tionalen Kon zepte von FPPS (weit gehend) mit Python ausprobieren. Dies wird durch die Zwitterfunktion des Python Interpreters e rmöglicht, de r einerseits Anweisungsfolgen, andererseits aber auch Terme entgegennehmen und (im ersten Fall) abarbeiten bzw. (im zweiten Fall) auswerten kann. Die (sehr einfache) Übertragung der
98 7
Funktionale Programmierung
in FPPS defi nierten Funktionen nach Python überlassen wir gr ößtenteils dem geneigten Leser als Übung. Als dritte Sprache führen wir (i n wenigen ausg ewählten As pekten) ei ne seh r schöne un d einfache „real e“ funktionale Sprache ei n, nämlich Haskell. So können wi r eine rseits (in FPPS) alle funktionalen Sprachkonzepte ohne Rücksicht auf sp ezifische Zufälligkeiten einer realen Sprache sauber darstellen, andererseits lernen Sie dam it auch eine reale „streng funktionale“ Sprache mit einigen ihrer Vor- und Nachteile kennen. Zusätzlich hat die Weiterführung von PPS bzw. Python den V orteil, dass wir je weils anhand einer Sprache die Unterschiede zwischen dem imperativen und dem funktionalen Programmierstil sehr klar dar stellen können.
7.1
Das Programm als Term
Ein imperatives Program m beschrei bt, wie im letzten Kapitel dargele gt, mögliche Folgen von Zuständen. „Möglich“ heißt in diesem Fall: Je nach Fallunterscheidung durch bedingte Anweisungen oder je nach Anzahl der Wiederholungen einer bestimmten Wiederholungsanweisung wir d die eine ode r die ande re Zus tandsfolge ge wählt. De r A usgang de r Fallunte rscheidungen wird z.B. durch Eingaben oder die Startwerte des Programms geregelt. Ein funktionales Programm beschreibt dagegen einfach nur einen Term, z.B.: T1 = 3 + 4*7 T2 = sin(π/3) + cos(π/3). Der Ablauf eines imperativen Programms besteht aus der Abarbeitung einer der o.g. möglichen Zustandsfolgen, der Ablauf eines funktionalen Programms dagegen aus der Auswertung eines Terms und der A usgabe seine s Wertes. Zur Eingabe und Aus wertung dieser Terme stellen funkti onale Pr ogrammiersprachen i n de r Regel Interpreterumgebungen zur Ver fügung (ähnlich, wie Sie das bereits bei Python kennen gelernt haben). Die Arbeit mit einer solchen Umgebung (wie z.B. Idle für Python oder Hugs für die Sprache Haskell, siehe unten) gestaltet sich folgendermaßen: • Sie starten di e Inter preterumgebung wie jedes a ndere Program m (z.B. WinHugs fü r Haskell). • Nach diesem Start präsentie rt I hnen die U mgebung eine Eingabemöglichkeit für Ih ren Term. • Nach de r Eingabe des Te rms (am Ende der Eingabezeile) starten Sie m it der < return>Taste die Auswertung des Terms. • Die Umgebung wertet den Term aus und gibt seinen Wert in der nächsten Zeile aus. Am Bildschirm sieht das z.B. für Python und den Term T1 so aus:
7.1 Das Programm als Term
99
>>> 3+4*7 31 Anstatt eine r Anweisung oder eine r Fol ge von Anweisunge n erhält de r Interpreter bei der funktionalen P rogrammierung genau einen Term als Eingabe . Da de r Python-Interpreter beide Programmformen akzeptiert, können wir bis auf weiteres auch die vorgestellten funktionalen Konzepte direkt mit dieser Sprache umsetzen.
7.1.1
Die Auswertung von Termen
Zur Berechnung der Ausgabe aus den eingegebenen Daten wendet das Interpretersystem eine (intern vor programmierte) Auswertungsfunktion auf de n eingegebenen Term an, die m eist mit eval (für „ evaluate“) bezeichnet wird. In Python ist diese Funktion ebenfalls fest eingebaut: >>> eval('3+4*7') 31 Genau genommen wird eval jedes Mal automatisch aufgerufen , wenn ein Term in die Kom mandozeile des Interpreters eingegeben wird. Über diese Auswertungsfunktion kann man die Semantik (die Bedeutung) der Elemente einer funktionalen Programmiersprache exakt formulieren. Bei der A uswertung von Termen wird Programmtext eingelesen und Werte (in den entsprechenden Sorte n) zurückgegeben. Bei der Aus wertung ei ner F unktionsanwendung auf bestimmte Argumente m uss dabei ein Funktionssymbol (wie z.B. das Wurzelsymbol oder sin) in einen konkreten Rechenvorgang (bei sin eine kom plizierte num erische Berechnung des Sinuswertes) übersetzt werden. Entsprechendes gilt für die Operatorsymbole, so löst z.B. das ´+´-Zeichen eine tatsächliche Addition aus. Wir müssen also zwischen reinen Symbolen und den damit verbundenen Berechnungen unterscheiden. Für letztere ver wenden wir ein nachfolgendes Schlangensymbol, z.B. sin~ für die tatsächliche Berechnung, die durch das Symbol sin ausgelöst wird. Damit können wir nun sukzessive die Arbeit der eval-Funktion beschreiben: 1. Auswertung von Konstanten (nullstelligen Funktionen): eval[cons] = cons~ eval[pi] = 3.14159…~. 2. Auswertung von Operationen: Die Aus wertung eines A usdrucks exp1 op exp2 geht folgendermaßen vor sich: eval[exp1 op exp2] = eval[exp1] op~ eval[exp2].
100 7
Funktionale Programmierung
Die durch das Operatorsymbol op ausgelöste Berechung wird also für die Auswertungen der beiden Terme links und rechts davon durchgeführt, z.B.: eval[3*4 + (7 – 2)] = eval[3*4] +~ eval[7 – 2] = eval[3] *~ eval[4] +~ (eval[7] –~ eval[2]) = 3~*~ 4~+~ (7~ –~ 2~) = 12~ +~ 5~ = 17~.
7.1.2
Terme, Funktionen und funktionale Programme
Eine Funktion stellt, wie bereits in Kapite l 6 ausführlich besprochen, eine eindeutige Zuordnung dar, die für eine bestimmte Eingabe (die evtl. auc h aus mehreren Komponenten bestehen kann wie bei einem Verbund) genau einen Ausgabewert liefert. Terme stellen eine Möglichkeit dar, diese Zuordnung festzulegen. Über de n Ei nbau v on Parametern in de n T erm kann m an daf ür s orgen, dass es f ür unterschiedliche Eingabewerte auch unterschiedliche Ausgabewerte gibt, z.B.: T3(x) = x + 1, T4(a,b) = a2 + b2. Beide Term e liefern f ür ei ne bestim mte Einga be, d h. für eine bestim mte Belegung jedes Parameters mit je einem Wert (z.B. x = 10 bei T3 oder a = 2, b = 3 bei T4), jeweils genau eine Ausgabe: T3(10) = 10 + 1 = 11, T4(2, 3) = 22 + 32 = 4 + 9 = 13. Die Definitionsmenge eines Terms legt in solche n Fällen fest, welche Eingabewerte man für die Parameter verwenden darf, z.B.: D(T4; a) = N ; D(T4; b) = N oder kurz D(T4) = N×N. N steht da bei für die Menge der nat ürlichen Za hlen. N×N ist die Menge aller Zahlenpaare (a, b) mit a ∈ N und b ∈ N. T4 ordnet also jedem Paar von Werten für die Parameter a, b genau einen Ausgabewert zu: (1, 1) → 2; (1, 2) → 5; (1, 3) → 10; ... (2, 1) → 5; (2, 2) → 8; (2, 3) → 13; ...
7.1 Das Programm als Term
101
Auf diese Weise definiert der Term T4 eine Funktion f : N×N → N mit f(a, b) = T4(a, b) = a2 + b2. In diesem Sinn beschreibt e in f unktionales Pr ogramm über seine n Term also immer auc h eine Funktion. Im Term des Programms dürfen neben (vordefinierten) Standardfunktionen – z.B. konstante Funktionen wie pi (für π), trigonometrische (wie sin, cos, tan), die Exponentialfunktion exp oder Zeichenkettenfunktionen wie len (die Länge einer Z eichenkette) – auch selbstdefinierte Funktionen verwendet werden, wie z.B. die obige Funktion f. Ein funktionales Programm umfasst also: 1. einen Term, der dem Interpreter zur Auswertung übergeben wird, 2. ggf. eine Menge von Funktionsdefinitionen, 3. ggf. Deklarationen eige ner Sorten (siehe Abschnitt 7.2), was in Python leider nicht ohne weiteres möglich ist (siehe dazu Abschnitt 5.9.6). Nun soll a us unserer ( bisher rei n im perativ ve rwendeten) Pr ogrammiersprache PPS eine funktionale Sprache FPPS werde n. Dazu treffen wir fol gende Verei nbarungen (siehe auch Anhang zur Syntax von FPPS): 1. Funktionen be stehen in FPPS (vorerst) ausschließlich aus dem Kopf (d h. der e rsten Zeile) und genau einer Rückgabeanweisung, nach der genau ein Term folgt, z.B.: function quadrat (nat a): nat return a * a Später werden wir auch andere Konstrukte in Funktionen zulassen. 2. In FPPS gibt es außer der Rückgabeanweisung in Funktionen keine weiteren Anweisungen (also insbesondere keine Zuweisung). 3. Es gibt nur Deklarationen für Funktionen (in FPPS Definitionen genannt) und für Sorten, also keine für Prozeduren oder Variablen. Der Arbeitsaufwand beim funktionalen Programmieren besteht: • aus der Definition passender Funktionen und • ihrer Kombination zum „Hauptterm“, der das eigentliche Programm darstellt (und oft nur aus der Anwendung der „Hauptfunktion“ auf einen Satz passender Argumente besteht), z.B. f(2, 3). Die Defi nitionen und/oder Deklarationen werden dabei in einer (oder evtl. auch m ehreren) Textdatei(en) zusammengefasst. Die zugelassene Endung dieser Dateien hängt von der speziellen Umgebung ab (z.B. „.py“ in Python oder „ hs“ in Haskell). Für FPPS verwenden wir einfach „txt“. Ein Beispiel: //BEGINN der Datei Definitionen.txt function quadrat (nat a): nat return a * a
102 7
Funktionale Programmierung
function quadratsumme (nat a, b): nat return quadrat(a) + quadrat(b) //ENDE der Datei Definitionen.txt Nachdem man der Interpreterumgebung durch ei nen Befe hl zum Einlesen dieser Te xtdatei (auf der Kom mandozeile) di ese Definitionen be kannt gemacht hat, könne n sie in den Programmtermen verwendet werden. Das läuft schematisch so ab: Umgebung gestartet. Bitte Term eingeben> Ergebnis> Bitte Term eingeben> Ergebnis>
lies Definitionen.txt Definitionen.txt akzeptiert quadratsumme(3,4) 25
In Python erstellen und speichern Sie zunächst die Datei mit Ihren Funktionsdefinitionen: # quadratsumme.py def quadrat (a): return a*a def quadratsumme (a, b): return quadrat(a)+quadrat(b) In der Idle-Umgebung bewirkt der Befehl run Module auch dieses Einlesen von Definitionen aus einer Datei. Un mittelbar darauf könne n Sie die da rin definierten Funktione n in Ihrem Programmterm verwenden: >>> quadratsumme(3,4) 25 Hinweise zur Bedienung von Haskell finden Sie in Abschnitt 7.5. Was passiert nun ei gentlich bei der Auswert ung von funktionalen Programm en (also von Funktionsanwendungen)? Es wird ganz einfach mit Hilfe der eval-Funktion der Funktionsterm ausgewertet. Unter der Voraussetzung, dass eine Funktion f wie folgt definiert wurde (in FPPS): function f (nat x, y, z): nat return T(x, y, z) erfolgt bei der Auswertung des Programmterms f(1, 2, 3) also der folgende eval-Aufruf: eval [f(1, 2, 3)] = eval[T(1, 2, 3)].
7.1 Das Programm als Term
7.1.3
103
Variable und Parameter
„Streng“ funktionale Sprachen besitzen kein Zustandskonzept. Daher gibt es auch keine Variablen im Sinne im perativer Sprachen (bei denen jeder mögliche Wert einem Zustand ent spricht), sondern lediglich im Sinne von Parametern: Das sind rein formale Platzhalter, die beim Aufruf ei ner Funktion an bestimmte Werte gebunden werden. Der Begriff „ Variable“ wird im Kontext funktionaler Sprachen daher meist in diesem Sinne verwendet. Beispiel: Die Funktionsa nwendung quadrat(12) (der in obiger Datei Definitionen.txt definierten Funktion quadrat) bindet den Parameter (die Variable) a an den Wert 12. Für diesen Bindungsvorgang schreiben wir kurz: a#12. Die Auswertung eines Pa rameters liefert im Falle einer Bindung der Variablen mit dem Bezeichner varid an den Wert cons (kurz varid#cons) genau diesen Wert cons zurück: eval[varid#cons] = eval[cons] = cons~. Beispiel: Die Auswertung der Anwendung unserer Quadratfunktion auf den Wert 2 (quadrat(2)) liefert: eval[quadrat(2)] = eval[a#2*a#2] = eval[a#2]*~eval[a#2] = 2~*~2~ = 4~.
7.1.4
Terme und Datenflussdiagramme
Terme kann m an gut m it Datenflus sdiagrammen darstellen. Daher ka nn m an ihre Berechnungsstruktur damit auch sehr schö n vi sualisieren. Das arithm etische Mittel zweier Zahlen möge dafür als Beispiel dienen (siehe Abbildung 7.1). Der entsprechende Term lautet: T(a, b) = (a + b) : 2.
a
b 2
+ :
Abb. 7.1
Datenflussdiagramm für die Berechnung des arithmetischen Mittels
Mit diesem Term kann man eine Funktion mittel: R×R → R definieren (R steht für die Menge der reellen Zahlen):
104 7
Funktionale Programmierung mittel(a, b) = T(a, b) = (a + b):2.
In FPPS lautet die Definition dieser Funktion dann so: function mittel (float a, b): float return (a+b)/2 Das Datenflussdiagramm aus Abbildung 7.1 stellt damit auch die Struktur der Funktion mittel(a, b) dar.
7.2
Sortendeklarationen
In funktionalen Sprachen kann man ebenso wie in imperativen (leider nicht ohne weiteres in Python) eige ne Sorten deklarieren, z.B. Verbunde aus elem entaren Sorte n (siehe Abschnitt 5.8). Im Unterschied zu im perativen S prachen kann man di e Datenelemente zusammengesetzter Sorte n jedoch nicht durch Zuweisung von Werten an ihr e Kom ponenten aufbauen, da es keine Zuweisungen gibt. Beispiel (Bruchrechnung): In Bruch b kann durch ein Paar b = (z, n) aus Zähler und Nenner dargestellt werden. Dafür kann man eine Verbundsorte deklarieren: sort bruch = record nat zähler; nat nenner end In einer imperativen Sprache wie PPS könnte man einen neuen Bruch b = 5/3 nun folgendermaßen aufbauen: var bruch b; b.zähler := 5; b.nenner := 3; In ei ner funktionalen Sprache wie FPPS is t eine dera rtige Konstr uktion dage gen (m angels Zuweisung) nicht möglich. Wie in der funktionalen Welt nicht anders zu erwarten, löst man das Problem wiederum über eine Funktion, indem man vereinbart, dass mit jeder Sortendeklaration auch autom atisch die Defi nition einer speziellen Funktion verbunden ist, deren einziger Zwec k der Auf bau und die Rückgabe von Date nelementen dieser S orte ist. Eine solche Funktion heißt Konstruktor. Beispiel: Mit der Deklaration der Verbundsorte bruch wird automatisch auch eine Konstruktorfunktion bruch(a, b) definiert. function bruch (nat a, b): bruch // Rückgabe ist ein Datenelement von der Sorte bruch
7.2 Sortendeklarationen
105
// mit zähler = a und nenner = b Auf die Komponenten eines so erzeugten Bruches kann man nach dessen Erzeugung wie aus Abschnitt 5.8. gewohnt zugreifen (bruch.zähler bzw. bruch.nenner), e twa zur Be rechnung des Kehrwertes: function kehrwert (bruch b): bruch return bruch(b.nenner, b.zähler) Damit könne n wir nun z.B. die Addition zweier Brüc he programmieren. Dabei wird eine vordefinierte Funktion kgV(a, b) verwendet, die sich leicht mit Hilfe des gr ößten gemeinsamen Teilers ggT (die Definition dieser Funktion folgt in Abschnitt 7.6) definieren lässt: kgV(a, b) = a*b/ggT(a, b). Unter der Voraussetzung b1 = (z1, n1) und b2 = (z2, n2) lautet der (hier mathematisch formulierte) Funktionsterm: b3 = summe(b1, b2) = (z1 * kgV(n1, n2):n1 + z2 * kgV(n1, n2):n2, kgV(n1, n2)). In FPPS (der Übersichtlichkeit halber wurden die Bezeic hner „zähler“ und „nenner“ abgekürzt): sort bruch = record nat z; nat n end function summe (bruch b1, b2): bruch return bruch(b1.z * kgV(b1.n, b2.n)/b1.n + b2.z * kgV(b1.n, b2.n)/b2.n, kgV(b1.n, b2.n))
106 7
Funktionale Programmierung b1
b2 n2
z2
n1
z1
kgV
:
:
Nenner3
Erw Faktor1
E w.Faktor2
*
*
+ z3
n3
b3 = summe(b1,b2) Abb. 7.2
7.3
Datenflussdiagramm für die Addition zweier Brüche
Sequenzen von Verarbeitungsschritten
Innerhalb von Programmen will man sehr oft eine Reihe von Verarbeitungschritten hi ntereinander ausführen lasse n. Sie erinner n sic h siche r daran, das s m an be i imperativen Programmen mehrere Anweisungen ei nfach i m Programm hintereinander anordnen k ann, um sie nachei nander aus führen z u lassen. Die Zwischenergebnisse de r einze lnen Anweisungen werden dabei über die Zustände der Variablen von einer Anweisung an die folgende weitergereicht. Falls z.B. die Anweisung B nach der Anweisung A ausgeführt wird, arbeitet B auf dem Zustand, den A hinterlassen hat, weiter. Ein Beispiel dazu aus der imperativen Welt von PPS (Verzinsung eines Guthabens):
Anweisung
Zustand
zinssatz := 5; kapital := 1000; zinssatz = 5; kapital = 1000; kontostand = n.d.; zins = n.d. A: zins := kapital * (zinssatz/100); zinssatz = 5; kapital = 1000; kontostand = n.d.; zins = 50 B: kontostand := kapital + zins;
7.4 Bedingte Terme
107 zinssatz = 5; kapital = 1000; kontostand = 1050; zins = 50
Da funktionale Sprac hen jedoch keine Z ustände ve rwalten, ka nn dieses Prinzip hie r nicht angewandt werden. Stattdessen übergibt man Zwischenergebnisse durch Verkettung (Hintereinanderausführung) von F unktionen, z .B. l iefert B(A (Eingabe)) das E rgebnis der Anwendung von B auf das Ergebnis der Anwendung von A auf die Eingabe. Im Fall unserer obigen Verzinsung würde diese Berechnung in FPPS unter Verwendung der Funktionen function kontostand (float ka, zi): float return ka + zi function zins (float ka, zs): float return ka * (zs/100) so aussehen: kontostand(kapital, zins(kapital, zinssatz)) Eine Verkettung von zwei Funktionen g und f wird in f unktionalen Umgebungen folgendermaßen ausgewertet: eval[f(g(exp))] = f~(eval[g(exp)]) = f~(g~(eval[exp])).
7.4
Bedingte Terme
In funktionalen S prachen wird die be dingte Aus führung von P rogrammkonstrukten über bedingte Terme gesteuert (analog zu bedingten Anweisungen bei imperativen Sprachen): function maximum (nat a, b): nat return if a > b then a else b Bedingte Anweisungen imperativer Programme lösen bei m Ablauf des Programms je nach Ergebnis des booleschen Ausdrucks (zwischen if und then) die Ausführung einer der beiden (nach then bzw. else) aufgeführten Anweisungen aus, verzweigen also den Weg des Ablaufs im Zustandsraum. Im Gegensatz dazu wi rd bei der A uswertung eine s bedingten Terms je nach Ergebnis des booleschen Ausdrucks einer der beiden Terme ausgewertet. Im Gegensatz zu bedingten Anweisungen müssen bedingte Terme auch den alternativen Fall (Bedingung nicht erfüllt, else-Zweig) grundsätzlich immer behandeln, da der Term in jedem der beiden Fälle einen Wert zur ückliefern muss. Eine bedingte A nweisung kann da gegen
108 7
Funktionale Programmierung
u.U. auf die Behandlung de s Alternativfalles verzichten. Dann wird ei nfach keine Anweisung ausgeführt, falls die angegebene Bedingung nicht erfüllt ist. Genau genommen handelt es sich bei bedingten Termen wieder um Funktionen (Fallunterscheidungsfunktionen), die man alternativ (wie dies in vielen Tabellenkalkulationen der Fall ist) auch als if(a 3*4+15-1.25 25.75 Spannend wird es natürlich er st, wenn man seine eigenen Funktionen definieren kann. Dazu müssen Sie (wie in Python auch) ein Skript schreiben und mit der Dateiendung „ hs“ abspeichern. Natürlich könne n Sie das m it jedem be liebigen Te xteditor tun. Am bequem sten für unsere Zwecke ist aber die Verwe ndung des von Hugs aus erreichbaren Editors. Wir öffnen daher aus WinHugs heraus den voreingestellten Texteditor (Edit – Texteditor) und sind bereit zur Ei ngabe der De finition e iner neuen F unktion, z .B. z ur Berec hnung des arithm etischen Mittelwertes zweier Zahlen: -- mittel.hs mittel:: (Float, Float) -> Float mittel(a,b) = (a + b)/2 In der ersten Zeile finden Sie wieder (auskommentiert) den Namen, unter dem wir die Datei abspeichern. Damit wir beim Laden in de r Interpreterumgebung keine Pfadangaben machen müssen, speichern wir die Datei einfach im Unterverzeichnis lib des Installationsverzeichnisses von Hugs98 ab. Dann wechseln wir wiede r in die Interpreterumgebung W inHugs und gebe n den folgenden Befehl ein: Prelude> :l mittel.hs Darauf meldet der Interpreter (hoffentlich) das erfolgreiche Laden des neuen Moduls: Reading file "C:\Programme\Hugs98\lib\mittel.hs": Hugs session for: C:\Programme\Hugs98\lib\Prelude.hs C:\Programme\Hugs98\lib\mittel.hs Somit kann die darin definierte Funktion mittel in einem Programmterm verwendet werden: Main> mittel(3,4) 3.5 Funktionen in Haskell Nach diesem sehr knappen Ausflug in die Benutzerschulung kehren wir wieder zur eigentlichen Programmierung zurück und sehe n uns die Struktur unserer oben defi nierten Funktion mittel genauer a n. E ine F unktionsdefinition i n Haskell beginnt m it d er Funk tionalität d er Funktion:
110 7
Funktionale Programmierung
mittel :: (Float, Float) → Float Die Funktionalität ist nichts weiter als die Angabe der Sorten der Argumente (Eingangsparameter) und des Res ultats der F unktion. In di esem Fall gibt es z wei Argumente und ein Resultat, alle drei von der Sorte float. Die zweite Zeile der Deklaration definiert schließlich das Ergebnis der Funktion: mittel(a,b) = (a + b)/2. Die Quadratfunktion soll als weiteres (einfacheres) Beispiel dienen: quadrat :: Int → Int quadrat(x) = x*x Die bedingte Anweisung Die bedingte Anweisung wartet mit keinerlei Überraschungen auf: betrag :: Float -> Float betrag(x) = if x > 0 then x else –x Main> betrag(12) 12.0 Main> betrag(-33) 33.0 Vordefinierte Datentypen Haskell hält (ähnlich wie Python) bereits ein lu xuriöses Angebot an vordefinierten Datentypen bereit, u.a. auch Listen (mit derselben Syntax wie in Python notiert) oder Tupel: Main> length([1,2,3,4]) 4 Main> fst (13,2) 13 Main> snd(13,2) 2 Typenbezeichner werden in Haskell übrigens immer groß geschrieben. Im Gegensatz zu Python ist es in Haskell aber sehr einfach, eigene Sorten und Datentypen zu definieren. Dabei m uss m an zwei Fälle unterscheiden: bloße sy nonyme Bezeichnung und strukturell neue Typen. Synonyme für vorhandene Typen Hier handelt es sich lediglich um eine neue (synonyme) Bezeichnung eines bereits de finierten Datentyps. Dazu verwendet man das Schlüsselwort type:
7.5 Programmieren in Haskell type type type type
111
Strasse = String PLZ = Int Ort = String Adresse = (Strasse, PLZ, Ort)
Diese Typen existieren strukturell bereits vor diesen Definitionen (hier als String, Int oder 3Tupel). Sie we rden durch type lediglich (zusätzlich) mit einem neuen Na men versehen. Da es sich hier nur um Synonyme handelt, kann im obigen Beispiel jedes beliebige 3-Tupel mit passenden Sorten der Komponenten (String, Int, String) als Adresse verwendet werden, auch wenn es eigentlich eine ganz andere Bedeutung hat wie z.B. (Schülername, Note, Fach). Algebraische Typen Der Aufbau strukturell neuer Datentypen wird mit Hilfe von hierz u definierten Konstruktorfunktionen vorgenommen. H ierzu verwendet man das S chlüsselwort data. Solche T ypen heißen in Haskell auch algebraische Typen: -- flaeche.hs data Flaeche = Kreis (Float) | Rechteck (Float, Float) inhalt :: Flaeche -> Float inhalt (Kreis (r)) = pi*r*r inhalt (Rechteck (l, b)) = l*b Prelude> :l flaeche.hs Reading file "C:\Programme\Hugs98\lib\flaeche.hs": Main> inhalt(Kreis (3.5)) 38.4845 Main> inhalt (Rechteck (4.5, 7.33)) 32.985 In diesem Beispiel wird ein neuer Datentyp Flaeche als Variante (entweder ein Kreis oder ein Rechteck) definiert. Kreis u nd Rechtec k sin d wiederum neu de finierte Date ntypen, die sich auf dem vordefinierten Typ Float abstüt zen. Dabei werden eige ntlich nur die gleichnamigen Konstruktorfunktionen definiert, mit deren Hilfe bei der Anwendung die eigentlichen Daten (I nstanzen) aus einer (im Fall Kreis) bz w. zwei (im Fall Rechteck) Fließkom mazahl(en) aufgebaut werden. In diesem Beispiel bege gnen wir auch einer Technik, die bei der f unktionalen Programmierung sehr häufig ange wandt wird: pattern matching. W enn die Funktion inhalt auf einen Wert angewandt wird, wie z.B. inhalt(Kreis (3.5)), dann wird dieser Aufruf mit der Definition dieser Funktion verglichen: Der Interpreter stellt fest, das s es dort ein „M uster“ ( pattern) m it dem Arg ument Kreis (r) und ei n zweites mit dem Argument Rechteck (l, b) gibt. In unserem obigen Aufruf „passt“ offensichtlich das erste
112 7
Funktionale Programmierung
dieser beiden Muster. Daraufhin wird der Parameter r an den Wert 3.5 gebunden, dieser in den rechten Teil der Funktionsdefinition (pi*r*r, der den Wert des Funktionsterms definiert) eingesetzt und der resultierende Term ausgewertet (mit dem Ergebnis 38.4845). Durch Mustervergleich (pattern matching) wird hier als o sowohl der passende Fall de r Berechnung a usgewählt (also eine Fall unterscheidung simuliert) als a uch die im Muster vorkommenden Parameter (in diesem Fall r) an Werte gebunden. Es gibt übrigens auch Konstruktorfunktionen ohne Argumente. Dann handelt es sich einfach um Konstante (im Sinne nullstelliger Funktionen), wie etwa beim (vordefinieren) Typ Bool: data Bool = True | False. Nun verlassen wir die Haskell-Welt wieder (zugunsten von FPPS), um am Ende des Kapitels wieder dazu zurückzukehren.
7.6
Rekursive Strukturen
Wegen fehle nder Zusta ndsinformationen können f ür Wiederholungen i n f unktionalen Pr ogrammen nicht die in im perativen Sprachen üblicherweise verwendeten iterativen Mechanismen einges etzt werden: Die Wiederholung m it fester Wiederholungszahl ist z.B. vom Zustand der Zählva riable abhängig, die andere n Wiederholungsarten br echen bei einer Zustandsänderung der Wiederholungsbedingung ab. Wiederholungen werd en im fun ktionalen Programmierstil d aher üblicherweise m ittels Rekursion realisiert. De n größten gem einsamen Teile r zweier natürlicher Zahlen w ürde m an beispielsweise so berechnen: function ggt (nat a, b): nat return if a = b then a else if a < b then ggt(a, b–a) else ggt(a–b, b) Ein Zahlenbeispiel zur Folge der rekursiven Aufrufe dieser Funktion: ggt(20, 12) = ggt(8, 12) = ggt(8, 4) = ggt(4, 4) = 4. Eine F unktion heißt rekursiv, wenn ihr B ezeichner im Rumpf (d h. unterhalb der e rsten Zeile ihrer Deklaration) vorkommt (bzw. es bei verschränkter Rekursion ein zyklisches System von Abhängigkeiten zwischen mehreren Deklarationen gibt, siehe Abschnitt 7.9.5 dieses Kapitels). Wir ke nnzeichnen zunächst die re kursiven Aufrufe im Funk tionsrumpf du rch Unterstreichung, um dem Leser die Orientierung zu e rleichtern. Später werden wir diese Schreibweise wieder aufgeben. Natürlich wir d das Konze pt der Rekur sion ni cht nur de shalb so hä ufig ange wandt, weil in (rein) f unktionalen Sprac hen keine a ndere Art der Wiederholung m öglich ist. Rekursi on
7.6 Rekursive Strukturen
113
ermöglicht oft (relativ) leichte Verifikation (darunter versteht man den B eweis der Korrektheit eines Programms) oder hohe Effizienz für bestimmte Zwecke (z.B. Sortier- oder Suchverfahren). Man könnte also eher sagen, dass die funktionalen Sprache n so konstruiert wurden, dass Rekursion leicht und natürlich in ihnen umgesetzt werden kann. Neben F unktionen ( wie ggt) ver wendet m an Rekursion auch häufig bei der Konstr uktion dynamischer Datentype n ( das sind Date ntypen bei denen sich de r Um fang eines Datenelementes während de r Laufzeit des P rogramms ändern kann, wie z.B. Listen). Wir halten diesen Einstieg für günstiger (u.a. weil viele Funk tionen nur deshalb rekursiv sind, weil sie auf rekursiven Dat entypen ope rieren) und werden uns da her z uerst den re kursiven Datentypen zuwenden, um danach wieder zu rekursiven Funktionen zurückzukehren.
7.6.1
Rekursive Datenstrukturen
Zur Ve rwaltung einer Reihe gleichartiger Werte haben Sie in Kapitel 5 Felder als z usammengesetzte Datentypen kennen gelernt. Felder si nd oft als relativ starres Konstrukt realisiert, dere n Lä nge (und damit Speicherplatzverbrauch) bereits vor dem Übersetzen du rch eine Konstante festgelegt we rden muss (wie z.B. in Pascal). Solche Date ntypen werden als statisch bezeichnet. Dies führt bei großz ügiger Bemessung des Umfangs zu Verschwendung von Speicherplatz, bei zu knapper Auslegung zu Platzmangel für Daten. Im Gegensatz dazu kann bei dynamischen Datentypen ihr Umfang zur L aufzeit fortwährend an die zu jedem beliebigen Zeitpunkt auftretenden Anforderungen angepasst werden. Natürlich muss auch bei diesen Datentype n vor der Ü bersetzung je doch zumindest die Struktur (wenn schon nicht der Um fang) festgele gt werden. Die Dynamik wir d dort m it Hilfe von Rekursion eingebaut. Das ei nfachste Beispiel dafür ist die Sorte Liste (die Ihne n ja bereits aus den Python- und Haskell-Programmen bekannt ist), di e man strukturell folgendermaßen definieren kann: Eine Liste • ist entweder leer oder • besteht aus einem Kopf als erstem Element und einer weiteren Liste (Rest) als Rumpf. Beispiel (Liste der Aufgaben, die heute zu erledigen ist): Liste1 = [„Büro aufschließen“, „mit Meier telefonieren“, „Besprechung mit Huber und Schulz“]. Das Element „Büro a ufschließen“ ist dann der Kopf von Liste1 und Liste2 = [„mit Meier telefonieren“, „Besprechung mit Huber und Schulz“] der Rest von Liste1. Wir verwenden in diesem Buch ab jetzt (wie z.B. auch in Klammern, um Listen zu kennzeichnen.
Python oder Haskell) eckige
Wenn der Bezeichner einer Sorte (hier: Liste) auch im definierenden Text auftaucht, nennt man diese Sorte rekursiv. Ei ne Liste ist nach ihrer obigen Definition also eine rekursive Datenstruktur. Wenn man Listen gemäß der obigen Definition in einer Programmiersprache implementieren will, benötigt man zur Realisierung der „eingebauten“ Alternative (leere Liste oder Kombination aus Kopf und Rest) variante Sorten (bzw. Vererbungsmechanismen in objektorientier-
114 7
Funktionale Programmierung
ten Sprachen). In FPPS lassen wir dazu in den Sortendeklarationen einfach durch or getrennte Alternative n zu. Dam it könne n Listen für natürliche Zahlen fol gendermaßen defi niert werden: sort natlist = empty or record nat head; natlist rest end Wir führen hier das Schlüsselwort empty ein, mit dem für jede rekursive Datenstruktur ein leeres Datenelement produziert werden kann. Der Aufba u e iner Liste erfolgt wiede r mit Hilfe einer Konstruktorfunktion (siehe Abschnitt 7.2), die hier allerdings auch die Möglichkeit einer leeren Liste berücksichtigen muss. Ein Beispiel für den Aufbau eines Datenelementes (hier der Liste [3, 2, 1]) durch eine Konstruktorfunktion wäre: natlist (3, natlist (2, natlist (1, empty))) Mit der Einführung von empty wird gleichzeitig eine Funktion isem pty() definiert, die feststellt, ob ein Datenelement leer ist. Dafür gilt: isempty(empty) = true isempty(natlist(a, b)) = false
7.6.2
für beliebige nat a und natlist b.
Rekursive Funktionen
Für die Anwendung von Re kursion im Zusammenhang mit Funktionsdeklarationen gibt es, wie oben bereits erwähnt, eine Reihe von Beweggründen, z.B.: • bessere Verifikationsmöglichkeit, • Verwendung rekursiver Datenstrukturen, • Effizienz. Zunächst woll en wir rekursive F unktionen betrachten, die auf rekursiven Datenstrukturen arbeiten. Dazu bieten sich natürlich die soeben eingeführten Listen an. Beispiel 1 (Länge einer Liste): function length (natlist liste): nat return if isempty(liste) then 0 else length(liste.rest) + 1 Beispiel 2 (Umkehrung einer Zeichenkette als Liste von Zeichen): Als Hilfsfunktionen benötigen wir dafür das letzte Zeichen der Liste und den „vorderen Rest“ der Liste (nach Entfernung des letzten Zeichens): function last (charlist liste): char return if isempty(liste.rest) then liste.head else last(liste.rest)
7.6 Rekursive Strukturen
115
function init (charlist liste): charlist return if isempty(liste.rest) then empty else charlist(liste.head, init(liste.rest)) function revers (charlist liste): charlist return if isempty(list) then empty else charlist(last(liste), revers(init(liste))) Die Struktur dieser Funktionen ähnelt ( naturgemäß) sehr stark der Struktur der Sorte Liste, auf der sie arbeiten (siehe oben): • Entweder ist die Liste leer oder • es erfolgt ein rekursiver Aufruf der jeweiligen Funktion. Hier legt also die Datenstruktur die rekursive Struktur der Funktionen fest, die darauf arbeiten. Eine andere Motivation für die Verwendung von rekursiven Funktionen ist Effizienz hinsichtlich der Verarbeitungsgeschwindigkeit, wie beim Quicksort-Algorithmus, de r ei nes de r schnellsten bekannten Sortierverfahren darstellt (mehr darüber erfahren Sie im zweiten Teil des Buches über Algorithmen und Datenstrukturen). Beispiel 3 (Quicksort): Wir setzen hier die Verfügbarkeit der Hilfsfunktionen concat(), smaller(), equal(), greater() voraus, deren Programmierung wir Ihnen als Übung empfehlen. function qsort (natlist ls): natlist return if length(ls) Float x_hoch_y(x,y) = if y == 0 then 1 else x_hoch_y(x, y-1)*x Rekursive Datentypen Wenn man einen neuen rekursiven Datentyp (hier z.B. binäre Bäume) definieren will, muss man diesen als algebraischen Typ einführen (siehe Abschnitt 7.5): data BinTree = Empty | Node (String, BinTree, BinTree) Zur Veranschaulichung wollen wir diesen Datentyp (beinah e) vollständig i mplementieren. Dazu benötigen wir zumindest folgende Funktionen (bitte beachten Sie, dass sowohl Fallunterscheidung wie d ie Bin dung von Parametern m it Hilfe vo n pattern matching umgesetzt werden, siehe auch dazu Abschnitt 7.5): • da es sich um einen Varianten Typ (Empty oder Node()) handelt, müssen wir eine Funktion zur Unterscheidung der beiden Möglichkeit en (Diskri minator) im plementieren, hier isEmpty() genannt: isEmpty::BinTree -> Bool isEmpty Empty = True isEmpty tree = False • Zum Zugriff a uf die drei Komponenten de s Ve rbundtyps Node benötigen wir je eine Selektorfunktion: mark::BinTree->String mark (Node (nodemark,ltree, rtree)) = nodemark leftTree::BinTree->BinTree leftTree (Node (nodemark,ltree, rtree)) = ltree rightTree::BinTree->BinTree rightTree (Node (nodemark,ltree, rtree)) = rtree • Schließlich müssen wir die Funktion work zur Abarbeitung (Traversierung) des Baumes schreiben, die hier einfach alle Knotenmarkierungen in der Reihenfolge „linker Teilbaum – Markierung – rechter Teilbaum“ (Inorder genannt) ausgibt. Als Tre nnzeichen verwenden wir da bei einen senkrec hten Stric h zw ischen de n Elementen. D er O perator „ ++“
118 7
Funktionale Programmierung
dient zur Verkettung einzelne Listen oder auch (wie i n diesem Fall) einzelner Elemente von der Sorte String. work::BinTree -> String work tree = if isEmpty(tree) then "| " else work(leftTree(tree))++mark(tree)++work(rightTree(tree)) Mit diesen Hilfsm itteln wollen wir nun, ausgehe nd von se inem Stammbaum, die Vorfa hren des spanischen Prinzen Don Carlos ausgeben. Dazu muss der Baum zunächst m it Hilfe der Konstruktorfunktionen Empty und Node aufgebaut wer den, um dann al s Ar gument für die Funktion work zu diene n. Zweckmäßigerweise legen wi r diese Arbeiten ebenfalls in derselben Datei wie die obigen Definitionen ab, so dass wir in der Hugs-Kommandozeile nur noch die Funktion run aufrufen müssen. run = work(Node ("Don Carlos", Node ( "Philipp II", Node ( "Karl V", Node ("Philipp I",Empty,Empty), Node ("Johanna",Empty,Empty)), Node ( "Isabella ", Node ("Emanuel I",Empty,Empty), Node ("Maria von Portugal",Empty,Empty))), Node ("Maria von Portugal", Node (" Johann III", Node ("Emanuel I",Empty,Empty), Node ("Maria von Spanien",Empty,Empty)), Node ("Katharina", Node ("Philipp I von Spanien",Empty,Empty), Node ("Johanna",Empty,Empty))))) Das Ergebnis sieht schließlich folgendermaßen aus: Main> run "| Philipp I| Karl V| Johanna| Philipp II| Emanuel I| Isabella | Maria von Portugal| Don Carlos| Emanuel I| Johann III| Maria von Spanien| Maria von Portugal| Philipp I von Spanien| Katharina| Johanna| " Dieses Beispiel sollte vor alle m dazu dienen , dem Leser den Um gang mit dy namischen (rekursiven) Datenstrukturen näher zu bringen. Zu diesem Zweck wurde hier eine dynamische Datenstruktur statisch verwendet: vor dem Programmstart war ja schon klar, welche n Umfang das Datenelement haben würde. In der Regel ist das natürlich nicht der Fall, insbesondere bei den häufige n Anw endungen von rekursiven Datenstruk turen zur Effizienzsteigerung, von denen im 2. Teil dieses Werkes noch ausführlich die Rede sein wird.
7.9 Formen der Rekursion
119
Parametrisierung von Datenstrukturen Unsere soeben eingeführte Datenstruktur BinTree hat leider noch genau den in Abschnitt 7.7 aufgezeigten Makel: Sie ist nur für Knotenmarkierungen der Sorte String definiert und müsste für jede andere Sorte ( Int, Float, etc.) wieder ebenso ausführlich wie oben definiert werden. In Haskell kann m an sich diesen Aufwand leicht spare n, indem man den Date ntyp BinTree unter Verwendung ein es So rtenparameters a von vo rneherein als polymorph definiert: data BinTree a = Empty | Node (a, BinTree a, BinTree a) Es genügt, die se Ersetzunge n („ a“ a nstatt „String“ und „ BinTree a“ a nstatt „ BinTree“) i m gesamten Programm durchzuführen und die Funktion work anzupassen: Weil das Tre nnzeichen „|“ de n Datentyp a uf String festlegt, gebe n wi r statt der einzelne n Mar kierungen m it work nun die (wiederum polymorphe) gesamte Liste der Knotenmarkierungen aus: work::BinTree a -> [a] work tree = if isEmpty(tree) then [] else work(leftTree(tree))++[mark(tree)]++work(rightTree(tree)) Das Resultat des Aufrufs von run ist dann eben diese Liste: ["Philipp I","Karl V","Johanna","Philipp II","Emanuel I","Isabella ","Maria von Portugal","Don Carlos","Emanuel I"," Johann III","Maria von Spanien","Maria von Portugal","Philipp I von Spanien","Katharina","Johanna"] Der So rtenparameter a wird also bei der ersten Anwendung des Datentyp s au tomatisch an eine passende Sorte (hier wiederum String) gebunden. Danach muss in diesem Baum allerdings durchgehend diese eine Sorte ve rwendet werden. Eine Mischung verschie dener Sorten in einem Baum würde zu ei nem Laufzeitfehler f ühren. Für die weiteren Ausführungen kehren wir nun wieder zu FPPS zurück.
7.9
Formen der Rekursion
Anwendungsmöglichkeit und Effizienz reku rsiver Funk tionen h ängen seh r stark vo n der Struktur der R ekursion ab: Rekursive F unktionsanwendungen erze ugen ja zunäc hst einm al neue Prozesse, die in der Re gel die Struktur der Be rechnung (z umindest vor übergehend) komplizierter machen. Leider beschränken sich dabei nicht alle rekursiven Funktionen auf so einfache Ve ränderungen de r Berechnungsstruktur wie fak (siehe Abschnitt 7.9.1) oder ggT (siehe Abschnitt 7.6). In der Tat gehören di e beiden Funktionen zu einer Klasse rekursiver Funktionen m it relativ einfacher Struktur (den linear rekursiven Funktionen, siehe Abschnitt 7.9.2). In diesem Abschnitt sollen aber au ch noch andere, kompliziertere Rekursionsstrukturen besprochen werden. Zunächst wollen wir uns aber ansehen, wie man rekursive Funktionen in Datenflussdiagrammen darstellen kann.
120 7
7.9.1
Funktionale Programmierung
Dynamische Datenflussdiagramme
Was passiert eigentlich beim Aufruf ei ner rekursiven Funktion? Dies wollen wir uns (sehr einfachen) Beispiel der Fakultätsfunktion (in FPPS) genauer ansehen.
am
function fak (nat n): nat return if n = 0 then 1 else n * fak(n–1) Wie wird damit z.B. fak(3) berechnet? fak(3) = 3*fak(2) = 3*2*fak(1) = 3*2*1*fak(0) = 3*2*1*1 = 6. Der Aufruf ei ner re kursiven Funktion erze ugt also entweder eine Konst ante oder (mindestens) einen neuen (informationsverarbeitenden) Prozess der gleiche n Funktion. Daher können rekursive Vera rbeitungsabläufe nicht in einem Datenflussdiagramm dargestellt werden. Wegen der dynam ischen Erzeugung neuer Prozesse benötigt man jeweils eine Sequenz solcher Diagramme (wir ne nnen diese Seque nzen a b jetzt dynamische Datenflussdiagramme). Um den Ablauf di eser Aufrufe noch de utlicher zu m achen, tragen wi r die Ausgänge der Prozesse darin gestrichelt ein, soweit diese noch nicht aktiv sind, weil sie auf vorhergehende Berechnungen warten müssen, bzw. durchgezogen, sobald sie Daten liefern können, weil alle vorhergehenden Berechnungen abgeschlossen sind. Abbildung 7.3 zeigt die Abarbeitung der Funktionsanwendung als dynamisches Datenflussdiagramm. Noch eine didaktische Bem erkung dazu: L eider ist die Fakultätsfunktion zur Motivierung von Rekursion bei Einsteigern zie mlich ungeeig net, da si e ebenso gu t (und offensichtlich) iterativ berechnet werden kann. Sie eignet sich aber se hr gut als einfac hes Beispiel zur Untersuchung von rekursiven Mechanismen nach deren Einführung anhand anderer Funktionen (z.B. ggT).
7.9 Formen der Rekursion
121
Struktur
n
n-1
n fak
falls n > 0 fak
*
1
3 2
falls n = 0
3
3
3
2
1
2
3
2
1
1
fak
1 *
fak
* 1
*
*
fak
0
* 2
fak
*
*
*
*
6 Auswertung
Abb. 7.3
Dynamisches Datenflussdiagramm zur Fakultätsfunktion fak(n)
In den folgenden Überlegungen werden wi r immer wieder solche dynamischen Datenflussdiagramme zur Veranschaulichung rekursiver Strukturen verwenden.
7.9.2
Lineare Rekursion
Eine reku rsive Funktionsdeklaration hei ßt linear rekursiv, falls i n jedem Fallunterscheidungszweig höchstens ein rekursiver Aufruf der Funktion enthalten ist. Aus der Sicht unserer dynamischen Datenfluss diagramme enthält dann jedes D iagramm maximal einen Prozess dieser Funktion. Beispiele dafür sind, wie schon erwähnt, die Funktionen fak und ggT. Besonders einfach (und daher sehr e ffizient zu berechnen) sind linear rekursi ve Funktionen, bei deren Berechnung die rekursiven Aufrufe immer der letzte Verarbeitungsschritt sind, wie z.B. in ggT (siehe Abbildung 7.4). Solche Funktionen nennt man repetitiv rekursiv.
122 7
Funktionale Programmierung m
Struktur m
n
n
-
n falls m>n
18
ggT
12
12
12
6
12
-
ggT falls m0 UND k>0 UND k 0 und n > 0: acker(m, n) = acker(m–1, acker(m, n–1)) In FPPS wird die Ackermannfunktion folgendermaßen programmiert: function acker (nat m, n): nat return if m = 0 then n + 1 else if n = 0 then acker(m–1, 1) else acker(m–1, acker(m, n–1)) Die sehr auf wendige A uswertung der Ackermannfunktion zeige n wi r exem plarisch (und nicht vollständig) am Beispiel acker(3, 2): acker(3, 2) =
126 7
Funktionale Programmierung acker(2, acker(3, 1)) = acker(2, acker(2, acker(3, 0))) = acker(2, acker(2, acker(2, 1))) = acker(2, acker(2, acker(1, acker(2, 0)))) = acker(2, acker(2, acker(1, acker(1, 1)))) = acker(2, acker(2, acker(1, acker(0, acker(1, 0))))) = acker(2, acker(2, acker(1, acker(0, acker(0, 1))))) = acker(2, acker(2, acker(1, acker(0, 2)))) = acker(2, acker(2, acker(1, 3))) = acker(2, acker(2, acker(0, acker(1, 2)))) = acker(2, acker(2, acker(0, acker(0, acker(1, 1))))) = acker(2, acker(2, acker(0, acker(0, acker(0, acker(1, 0)))))) = acker(2, acker(2, acker(0, acker(0, acker(0, acker(0, 1))))))= acker(2, acker(2, acker(0, acker(0, acker(0, 2))))) = acker(2, acker(2, acker(0, acker(0, 3)))) = acker(2, acker(2, acker(0, 4))) = acker(2, acker(2, 5)) = usw.
Wie m an sieht, führen bereits Aufr ufe m it sehr kleine n Parameterwerten z u se hr la ngen Auswertungen, die wir hier gar nicht in voller Länge zeigen wollen. Abbildung 7.8 zeigt die erst en Schritte de r Auswert ung von acker(3, 2) im dynamischen Datenflussdiagramm. Man be achte dabei die Verkettung unausgewerteter F unktionsaufrufe (als wartende Prozesse), die zu einem hohen Verbrauch an Ressourcen führt.
7.9 Formen der Rekursion
127 m
n
m
m>0 UND n>0 n=0
acker m=0
1
m-1
n+1
m-1
n-1
acker
acker
2
acker
0
3
3
3
2 acker
2
acker
acker
1
2
2
acker
acker
acker
2
2
acker
acker
acker
acker
1
1
2
2
2
0
acker
acker
acker usw.
Auswertung Abb. 7.8
Dynamisches Datenflussdiagramm für die Ackermannfunktion
Tabelle 7.2 ze igt anha nd einiger Werte f ür ausgewählte Argumente, dass die Ac kermannfunktion enorm schnell wäc hst. Dieses se hr schnelle Wachstum (verbunden mit der lange n Auswertungsfolge) ist auc h genau der Grund für das Interesse an dieser Funktion. Es führt dazu, dass sich die A nzahl der notwendigen Berechnungen nicht vor dem Aufruf durch eine Konstante abschätzen lässt. Daher gehört die Ackermannfunktion nicht zur Klasse der primitiv rekursiven Funktionen, deren Werte sich ( bei im perativer Berechnung) durch Ve rwendung von Wiederholungen mit fester Wiederholungszahl berec hnen las sen. M ehr da rüber erfahren Sie i n de r Literatur zur Theoretischen Informatik unter dem Stichwort „Klassen berechenbarer Funktionen“.
128 7 Tab. 7.2
Funktionale Programmierung Werte der Ackermannfunktion
acker(m,n)
n=0
n=1
n=2
n=3
m=0 m=1 m=2 m=3 m=4 m=5 m=6
1 2 3 5 13 65533 acker(4,65533)
2 3 5 13 65533 acker(4,65533) acker(5,acker(4,65533))
3 4 7 29 265536-3 acker(4,acker(4,65533)) acker(5,acker(6,1))
4 5 9 61 2 hoch (2265536)-3 acker(4,acker(5,2)) acker(5,acker(6,2))
7.9.5
Verschränkte Rekursion
Die bisherigen Betrachtungen legen den Schluss nahe, dass eine Funktionsdeklaration genau dann als re kursiv bezeichnet werden kann, wenn sich in i hrem definierenden Term mindestens ein Aufruf derselben F unktion findet. Leider werden damit nicht alle rekursiven Funktionen erfasst, denn es bleibt daneben noch die Möglichkeit, dass eine Funktion einen rekursiven Aufruf gewissermaßen im Umweg über den Aufruf einer anderen Funktion (oder sogar einer Folge s olcher Aufrufe) „ve rsteckt“. Wenn die Re kursion übe r mehre re Stufen laufe n soll, muss diese ande re Funktion (bz w. di e le tzte in der Folge de r andere n aufge rufenen Funktionen) w iederum einen Aufruf der ursp rünglichen Funktion be inhalten. Ein Sy stem solcher Funktionen heißt dann verschränkt rekursiv. Beispiel (gerade oder ungerade Zahlen): Induktiv kann man die Eigenschaft einer natürlichen Zahl n≥1, gerade oder ungerade zu sein, folgendermaßen definieren: • 1 ist ungerade, • n>1 ist genau dann gerade, wenn n – 1 ungerade ist. • n>1 ist genau dann ungerade, wenn n – 1 gerade ist. Daraus lässt sich nun leicht ein System aus zwei verschränkt rekursiven Funktionen ableiten: function gerade (nat n): bool return if n = 1 then false else ungerade(n-1) function ungerade (nat n): bool return if n = 1 then true else gerade(n-1) Für den Wert n = 3 erhält man folgende Aufrufe: gerade(3) = ungerade(2) = gerade(1) = false ungerade(3) = gerade(2) = ungerade(1) = true Ein System verschränkt rekursiver Funktionen liegt also vor, falls die Deklarationen dieser Funktionen zu einer zyklischen Folge von Aufrufen führen. Dies kann man anhand des Stützgraphen dieses System s feststellen. Da bei werden die Funktionen als K noten des Gra phen aufgefasst und Aufrufe von Funktionen (im Rumpf von Deklarationen) als gerichtete Kanten.
7.10 Funktionen höherer Ordnung
129
Beispiel 1: Fakultätsfunktion, vgl. 7.9.1. Diese Funktion ruft die Subtraktions- und Multiplikationsoperatoren sowie den Vergleich (i m Sinne einer zweistelligen Funktion m it booleschem Rückgabewert, also z.B. a = b im Sinne von ist_gleich(a, b)) auf. Daneben findet sich ein rekursiver Aufruf (siehe Abbildung 7.9).
fak
*
=
Abb. 7.9
Stützgraph der Fakultätsfunktion
Beispiel 2: Di e Funktionen gerade(n) un d ungerade(n) (siehe obe n u nd Ab bildung 7 .10): Hier ruft z.B. gerade den Subtraktionsoperator, den Vergleich sowie die Funktion ungerade auf. In letzterer findet sich wiederum ein Aufruf von gerade.
gerade
ungerade =
Abb. 7.10
Stützgraph der Funktionen gerade und ungerade
Rekursion tritt in einem System von Funktionen genau dann auf, wenn der Stützgraph dieses Systems einen Zyklus beinhaltet. Verschränkt ist eine Rekursion ge nau dann, wenn ihr Zyklus über mehr als eine Funktion läuft.
7.10
Funktionen höherer Ordnung
Einer der wesentlichen Vorteile funktional er Programm iersprachen ist di e Möglichkeit, Funktionen ähnlich zu be handeln wie „normale“ Datentypen. Funktionen werden in funktionalen S prachen als (mit allen a nderen gleichberechtigte) Date nobjekte angese hen. Daher können Funktionen auch als Argumente oder Rückgabewerte anderer Funktionen verwendet werden, wa s viele Berechnunge n stark vereinfacht. F unktionen, die a ndere F unktionen als
130 7
Funktionale Programmierung
Argumente verwenden oder zurückgeben, heißen Funktionen höherer Ordnung oder Funktionale. Funktionale Programmiersprachen heißen übrigens u.a. auch deswegen so, weil sie Funkt ionen ebenso als „Daten erster Ordnung“ behandeln wie alle anderen Sorten. Ein Beispiel dazu aus der Mathematik: Der Ableitungsoperator abl (meist durch ´ bzw. d/dx bei Ableitung nach x symbolisiert) ist ein Funktional, da s als Eingabe eine Funktion aufnimmt und als A usgabe deren A bleitungsfunktion zurückliefert, z.B. angewandt a uf die Quadratfunktion: f(x) = x2; abl(f(x)) = f´(x) = 2x. Hier wird also jede (differenzierbare) Funktion f(x) auf ihre Ableitungsfunktion f´(x) abgebildet: abl: f(x) → f´(x).
7.10.1
Funktionen als Argumente
Zur Erklärung der Verwendung von Funktionen als A rgumente ande rer Funktione n wollen wir eini ge „ Universalfunktionen“ auf Listen behand eln, die in den m eisten fu nktionalen Sprachen bereits vordefiniert sind. Beispiel (Anwendung einer Funktion auf eine Liste): Sehr praktisch ist das in vielen funktionalen S prachen (z.B . in Python und i n Haskell) einge baute Funktional map, das als Argumente eine Funktion f und eine Liste lin erhält und eine Liste lout zurückliefert, die durch die Anwendung der Funktion f auf alle Elemente der Liste lin entsteht, z.B. mit f = sin und lin = [pi/3,pi/2,pi]: map (sin, [pi/3,pi/2,pi]) = [0.866025, 1.0, -8.74228e-008]. Zur Defin ition vo n Fu nktionalen in FPPS müssen wir die Syntax der Funktionsdeklaration erweitern: Wir lassen nun auch Funktionssorten in den Parameterlisten und im Ergebnis der Funktion zu (wir wollen aber darauf verzichten, dies in der BNF-Syntax von FPPS zu formulieren). Dabei soll z.B. nat → nat eine Funktion sym bolisieren, die natürliche Zahl en als Argumente übernimmt und Werte dieser Sorte zurückliefert. Damit könnte man die Funktion map (zunächst einges chränkt auf nat ürliche Zahl en) fol gendermaßen def inieren (die Sorte natlist wurde in 7.6.1. definiert): function map (nat → nat f, natlist lin): natlist return if isempty(lin) then empty else natlist(f(lin.head), map(f, lin.rest))
7.10 Funktionen höherer Ordnung
131
Meist spielt es allerdings keine Rolle, welcher Sorte die Elemente der Liste lin angehören, solange nur die Funktion f darauf anwendbar ist. Wir müssen daher in der obigen Definition noch Sortenparameter einführen (siehe auch Abschnitt 7.7): function map ( → f, list lin): list return if isempty(lin) then empty else list(f(lin.head), map(f, lin.rest)) Für die Platzhalter bzw. werden bei der Anwendung von map dann automatisch die passenden Sorten eingesetzt, wie das fol gende Beispiel zeigt. Dafü r benötigen wir allerdings noch eine vordefinierte Funktion ord: function ord (char zn): nat code return // Rückgabe der ASCII-Codenummer des Zeichens zn Damit setzt die folgende Anwendung die Sorte auf char bzw. auf nat: map(ord, [´A´, ´B´]) map(ord, [´A´, ´B´]) = list(ord(´A´), map(ord, [´B´])) = list(65, list(ord(´B´), map(ord, empty))) = list(65, list(66, empty)) = list(65, [66]) = [65, 66]. Funktionen, die (wie map) mit Hilfe von Sortenparametern definiert sind und da her au f verschiedene Sorten angewa ndt we rden ( und/oder auc h versc hiedene Sorten zurüc kliefern) können, heißen (analog zu Datentypen) polymorph. Es folgen weitere Beispiele für Funktionen, die andere Funktionen als Parameterwerte übernehmen (und ebenfalls sowohl in Python als auch in Haskell vordefiniert sind). Beispiel 1 (Filterfunktion): Die Funktion filter filtert aus einer Liste alle Ele mente, die ein bestimmtes Prädikat (das ist eine Funktion mit booleschem Rückgabewert) erfüllen: function filter ( → bool f, liste lin): liste return if isempty(lin) then empty else if f(lin.head)then list(lin.head, filter(f, lin.rest)) else filter(f, lin.rest) Z.B. kann man damit (unter Verwendung der in 7.9.5 definierten Funktion gerade) die geraden Zahlen aus einer beliebigen Liste von Zahlen extrahieren: filter(gerade, [1, 2, 3, 4]) = filter(gerade, [2, 3, 4]) = list(2, filter(gerade, [3, 4])) =
132 7
Funktionale Programmierung list(2, filter(gerade, [4])) = list(2, list(4, filter(gerade, empty))) = list(2, list(4, empty)) = list(2, [4]) = [2, 4].
Beispiel 2 (Faltung): Die Funktion fold verknüpft die Ele mente einer Liste mit Hilfe eines zweistelligen Operators. Verwendet m an als Operator z.B. die Addition, so liefert diese Funktion die Summe aller Ele mente der Liste. Dabei m uss nebe n de r je weiligen Operation (als zweistellige Funktion) und der Liste der Werte auch das neutrale Element der jeweiligen Operation (z.B. 0 bei Addition bz w. 1 bei Mu ltiplikation) als W ert der leeren Liste übergeben werden: function fold(× → f, neutral, list lin): return if isempty(lin) then neutral else f(lin.head, fold(f, neutral, lin.rest)) Eine beispielhafte Anwendung (unter Verwendung einer Funktion mult(a, b) anstatt a*b): fold(mult, 1, [2, 3, 4]) = mult(2, fold(mult, 1, [3, 4])) = mult(2, mult(3, fold(mult, 1, [4]))) = mult(2, mult(3, mult(4, fold(mult, 1, empty)))) = mult(2, mult(3, mult(4, 1))) = mult(2, mult(3, 4)) = mult(2, 12) = 24. Beispiel 3: Der Differenzenquotient (f(x + ∆x) – f(x))/∆x liefert für se hr kleine ∆x eine gute Näherung für die Ableitung f´(x) einer differenzierbaren Funktion f an der Stelle x. Er stellt auch die Basis für die Definition der Ableitungsfunktion dar (nach Grenzübergang ∆x → 0): function diffquot (float → float f, float x, delta_x): float return (f(x + delta_x) – f(x)) / delta_x Ein beispielhafter Aufruf dieser Funktion (unter Verwendung einer Funktion x_hoch_3(x) für x3): diffquot(x_hoch_3, 2, 0.1) = (x_hoch_3(2 + 0.1) – x_hoch_3(2)) / 0.1 = 12.61.
7.10.2
Funktionen als Funktionswerte
Damit haben wir geklärt, wie eine Funkti on als Argument über nommen we rden kann. I n funktionalen S prachen ka nn man meist auch Fu nktionen definieren, die ander e Fu nktionen als Werte zurückliefern. Diese Erzeugung einer neuen Funktion als Wert einer anderen Funktion bietet neben deren expliziter Definition (über function) eine alternative Möglichkeit zur
7.10 Funktionen höherer Ordnung
133
Einführung neuer Funktionen, die manchmal kompakter, übersichtlicher, leichter ve rifizierbar oder einfach nur eleganter sein kann. Beispiele wären z.B. die Funktionsverkettung, die aus zwei Funktionen f und g durch Hintereinanderausführung (d h. Anwendung von g auf das Ergebnis von f(x), also g(f(x))) eine neue Funktion h mit h(x) = g(f(x)) erzeugt oder die Erzeugung einer neuen Funktion über die Ableitung (Differentiation) einer anderen. In diesen Fällen liefert jeweils ein Funktionaloperator (Verkettungsoperator oder Ableitungsoperator) eine neue Funktion als Rückgabe. Eine andere Möglichkeit, wie man Funktionen als Rüc kgabewerte liefern kann, wird durch partielle Anwendung einer Funktion auf einen Teil ihre r Argumente eröffnet. So kann man beispielsweise mit Hilfe einer Funktion x_hoch_y(x, y), welche den Wert der Potenz xy zurückliefert, die Quadratfunktion definieren: quadrat(x) = x_hoch_y(x, 2). Die (allgemeinere) Funktion x_hoch_y wird also auf de n Wert 2 (für einen der beiden Parameter) angewandt. Der andere Parameter x bleibt ungebunden und e rzeugt so (als Spezialisierung von x_hoch_y) die Quadratfunktion quadrat. Im Wesentlichen kann man Funktionen als Ausgabewerte also auf zwei verschiedene Arten erzeugen: 1. durch Verwendung von vordefinierten Funktionaloperatoren (z.B. Verkettung, Differentiation) oder 2. durch partielle Anwendung einer Funktion auf einen Teil ihrer Argumente. Im Rest dieses Kapitels sollen diese beiden Möglichkeiten kurz vorgestellt werden. Funktionaloperatoren Darunter versteht man Operatoren, die Funktionen als Argumente verwenden und/oder Funktionen als Werte zurüc kliefern, wie z.B. di e Funktionskomposition (Verkettung oder „ Hintereinanderausführung“) zweier Funktionen f und g (z.B. in Haskell durch f.g symbolisiert): function komp ( → f, → g): → return // Die Funktion, die für alle Werte x, // für die g definiert ist, den Wert f(g(x)) liefert Damit könnte man z.B. die Funktion h(x) = sin(x2) einführen. M it f(x) = sin(x) un d g(x) = quadrat(x) ist dann h(x) = f(g(x)) = komp(sin, quadrat)(x). Man we ndet die Erge bnisse solcher F unktionale (als F unktionen) a uf Werte als o m it Hilfe einer zweiten Klammer für das Argument nach dem folgenden Muster an: komp(sin, quadrat) (x) = sin(quadrat(x)).
134 7
Funktionale Programmierung
Partielle Anwendung Die zweite Möglichkeit zur Erzeugung von Funk tionen über Werte anderer Funktionen ist, wie oben gezeigt, die Anwendung der (erzeugenden) Funktion auf ledigli ch einen Teil ihrer Argumente. Beispiel: Durch partielle Anwendung der Multiplikationsfunktion mult(a, b) auf den Wert 2 für das e rste Argument ka nn m an (alter nativ zu obige r Definition) die Funktion verdopple erzeugen: function verdopple (nat n): nat → nat return mult(2, n) Was geht hier eigentlich vor sich? Eine Funktion (die z.B. zu je dem Paar natürlicher Zahlen eine weitere natürliche Zahl berec hnet wie oben mult) erzeugt dabei durch partielle Anwendung auf einen ihrer beiden Parameter (hier auf den ersten, dem der Wert 2 übergeben wird) eine neue Funktion g: nat → nat (wie oben verdopple). Diese dabei erzeugte Funktion g hängt natürlich vom jeweiligen Wert ab, de r dem ersten Parameter übergeben wird. Verwendet man z.B. 3 anstatt 2, so er hält man die Funktion verdreifache: function verdreifache (nat n): nat → nat return mult(3, n) Eigentlich kann man die Funktion mult daher auch als ei ne Funktion fC: nat → (nat → nat) auffassen, die für jeden Wert des ersten Parameters eine Funktion ga: nat → nat ausgibt. Anstatt über f(a, b) = c aus der Verknüpfung zweier Werte a, b einen dritten W ert c zu berechnen, kann man also über partielle Anwendung auf das erste Argument (für jeden seiner Werte) eine neue (einstellige) Funktion ga: nat → nat definieren: ga = fC(a) und ga(b) = (fC(a))(b) = f(a, b). Das Ergebnis der Anwendung der zweistelligen Funktion f auf den Wert a ist also eine neue einstellige Funktion ga, deren Wert für das Argument b identisch mit dem Wert von f(a, b) ist. Da wir hier keine speziellen Angaben über die Struktur der verwendeten Funktion f verwendet haben, kann man dieses Prinzip allgemein formulieren (zunächst für die Sorte nat): Jede Funktion f: nat × nat → nat lässt sich auch so auffassen: fC: nat → (nat → nat).
7.10 Funktionen höherer Ordnung
135
Verallgemeinert man dies auf allgemeine Sorten, dann erhalten wir das Prinzip von Curry: Jede Funktion f: × → lässt sich auch so darstellen: fC: → ( → ). fC heißt dann curried Version von f, umgekehrt heißt f uncurried Version von fC. Dieses Prinzip wurde (wie übrigens auc h die Sprac he Haskell) nach de m amerikanischen Logiker Haskell B. Curry (1900–1982) benannt, obwohl es eigentlich zuerst vom deutschen Mathematiker M. Schönfinkel 1924 vorgeschlagen wurde. „Schönfinkeln“ klingt wohl etwas seltsam. Anstatt fC: nat → (nat → nat) schreibt man auch kurz fC: nat → nat → nat. Damit kann man (hier nur der Vollständigkeit halber) das Prinzip von Curry für eine beliebige Anzahl von Argumenten formulieren: Jede Funktion f: × × × ... × → lässt sich auch als curried Version darstellen: fC: → → → ... → → . Daraus erg ibt sich nu n ei ne Verei nfachung in d er Schreib weise, d ie in vielen fu nktionalen Sprachen häufig benutzt wird (u.a. auch in Haskell): Man ka nn nach de n o bigen Ausführungen jede Funktion auf eine Funktion m it höchstens einem Argument reduzieren und daher die Klammer bei der Anwendung immer weglassen: Statt f(a1, a2, a3, ..., an) schreibt man in der curried Version fC a1 a2 a3 ... an und meint damit die Anwe ndung der Funktion fC a1 a2 a3 ... an-1 (die sich aus der partiellen Anwendung vo n f auf a1, a2, a3, ..., an-1 ergibt) auf a n. Die Klammern um die Argumente werden daher bei funktionalen Sprachen oft weggelassen (soweit dies nicht die Eindeutigkeit bei geschachtelten Argumenten zerstört). Man schreibt in Haskell also anstatt mult(a, b) = a*b
136 7
Funktionale Programmierung
meist mult a b = a*b. Diese Sichtweise erlaubt nun sehr leicht die partielle Anwendung, wie in folgenden Beispielen. Beispiel 1: Die Multiplikation mit 2 (verdopple) entsteht durch Currying der normalen zweistelligen Multiplikation: mult: nat × nat → nat; mult(a, b) = c (unsere Ausgangsfunktion f) multC: nat → nat → nat; mult a b = c (curried Version fC) verdopple: nat → nat; verdopple b = mult 2 b Beispiel 2: Di e Nachfolgerfunktion succ(n) kann durch partielle Anwendung der Addition add(a, b) auf das erste Argument dargestellt werden: add: nat × nat → nat; add(a, b) = c (unsere Ausgangsfunktion f) addC: nat → nat → nat; add a b = c (curried Version fC) succ: nat → nat; succ b = add 1 b
7.11
Aufgaben
Aufgabe 7.1: Heron von Al exandria entwi ckelte bereits in der Antike die folgende Forme l für die Fläche A eines allgemeinen Dreiecks mit den Seiten a, b, c: A2= s(s – a)(s – b)(s – c) mit dem halben Dreiecksumfang s = (a + b + c)/2. a)
Stellen Sie ein Datenfluss diagramm auf, das die Berec hnung des Flächeninhalts A eines Dreiecks aus den drei Eingaben a, b, c nach dieser Heronischen Formel darstellt. Versuchen Sie dabei, die Berechnung von s nur einm al einzubauen und das Ergebnis durch Kopieren weiter zu verwenden.
b) Programmieren Sie Ihre Berechnung als Funktion in FPPS und/oder Haskell. Aufgabe 7.2: I n A bbildung 7.11 fi nden Sie ein Datenflus sdiagramm, das die Wahrscheinlichkeitsfunktion der Binomialverteilung beschreibt.
7.11 Aufgaben
137
n
p
k
1 -
potenz
bn
-
potenz
* *
f(n, p, k)
Abb. 7.11 Datenflussdiagramm für die Binomialverteilung
Die Funktion f(n, p, k) gibt dabei die Wahrscheinlichkeit an, bei eine r Stichprobe mit n Versuchen k Erfolge zu erzielen, wenn je der Einzelversuch die Erfolgswahrscheinlichkeit p hat. Die Funktion bn(x, y) steht dabei f ür de n Binomialkoeffizienten „ x über y“, die Funktion potenz(x, y) für die Potenzfunktion „x hoch y“. a)
Stellen Sie einen Te rm auf, der die Berechnung der Funktion f(n, p, k) gemäß dem obigen Datenf lussdiagramm beschreibt. Verwenden Sie darin ge nau dieselben Funktionen wie im Datenflussdiagramm in dersel ben Weise (bn und potenz in Präfix- bzw. Multiplikation und Subtraktion in Infixnotation).
b) Setzen Sie Ihr Datenflussdiagramm aus Teilaufga be a) in ein Haskell-Programm um. Aufgabe 7.3: Die Lösbarkeit einer quadratischen Gleichung ax2 + bx + c = 0 (nach der Variablen x) wird durch die Diskriminante D = b2 – 4ac bestimmt (siehe auch Aufgabe 5.3): 1. Fall : D > 0: 2 verschiedene Lösungen für x 2. Fall: D = 0: 1 Lösung 3. Fall: D < 0: keine Lösung a)
Stellen Sie einen Term T(a, b, c) auf, dessen Wert die Anzahl der Lösungen in Abhängigkeit von den Parametern a, b, c ist. Verwenden Sie dazu die Präfixfunktionen Quadrat() und Wenn() sowie die Grundrechenarten in Infix-Notation.
138 7
Funktionale Programmierung
b) Erstellen Sie e ine Haskell-Funktion, die unter Benutzung eines z u a) ä quivalenten Terms die Lösungen der Gleichung ausgibt. Aufgabe 7.4: Funktionen auf Listen in FPPS und Haskell a)
Schreiben Sie eine Funktion
function append (natlist ls, nat z): natlist, die eine natürliche Zahl ans Ende einer Liste solcher Zahlen anfügt. b) Schreiben Sie eine Funktion function concat (natlist l1, l2): natlist, die zwei Listen natürlicher Zahlen verkettet. Aufgabe 7.5: Program mieren Sie die fol genden Hilfs funktionen für Quicksort in und/oder Haskell:
FPPS
// Liste aller Elemente einer Liste ls, die kleiner als eine Zahl z sind function smaller(natlist ls, nat z): natlist // Liste aller Elemente einer Liste ls, die gleich einer Zahl z sind function equal(natlist ls, nat z): natlist // Liste aller Elemente einer Liste ls, die größer als eine Zahl z sind function greater(natlist ls, nat z): natlist Setzen Sie den Quicksort-Algorithmus (mitsamt aller Hilfsfunktionen) für die Sortierung von Zeichenketten in Haskell um und testen Sie Ihr Programm. Aufgabe 7.6: Beschreiben Sie die Abfolge der Auswertung des Terms 3 * quadratsumme(4, 5) mit Hilfe der Funktion eval. Die Definition der Funktion quadratsumme finden Sie in Abschnitt 7.1.2. Aufgabe 7.7 (Verschränkte Rekursion): Gegeben ist eine Liste ls von natürlichen Zahlen, wie z.B. ls = [3, 4, 2, 1, 7, 6, 7, 6, 1, 1, 5, 3]. Schreibe n Sie eine Funktion in FPPS und /oder Haskell, die feststellt, ob die Anzahl der Einsen in ls durch 3 teilbar ist. Verwenden Sie dazu eine rekursiv versc hränkte Hilfs-F unktion, ähnlich wie i n der Funktion gerade (siehe Abschnitt 7.9.5). Bei der Lösung dieser Aufgab enstellung können Sie alle bisher defi nierten Funktionen verwenden.
7.11 Aufgaben
139
Aufgabe 7.8 (Currying in Haskell): Schreiben Sie zunächst eine FPPS-Funktion function istEnthalten (charlist ls, char z): bool, die pr üft, ob e in Zeichen z in der Liste ls enthalten ist. Schreibe n Sie in Haskell di e zwei folgenden Funktionen (als „curried Versions“): istEnthaltenListe :: String -> (Char -> Bool) istEnthaltenZeichen :: Char -> (String -> Bool). Aufgabe 7.9 (Funktionen höherer Ordnung in Haskell): Scheiben Sie folgende Funktionen in Haskell mit Hilfe von Funktionen höherer Ordnung: a)
Eine Funktion listquad, die für eine Liste von Zahlen die Quadratsumme über den Zahlen zurückliefert.
b) Eine Funktion applthree, die eine übergebene Funktion (Double -> Double) dreimal hintereinander auf de n über gebenen Para meter (von de r Sorte Double) anwendet. Testen Sie Ihre Funktion mit den vordefinierten Funktionen von Haskell.
8
Rekursion und Iteration
Trotz aller Vorteile rekursiver Konzepte ist es oft notwendig oder wünschenswert, rekursive Funktionen iterativ ( d.h. m it Hilfe von Wiederholungsanweisungen) da rzustellen. Dafür können z.B . Effizienz gründe maßgeblich s ein. Außerdem kann keines der derzeit üblichen Rechnermodelle Rekursion a uf Masc hinenebene direkt umsetzen. Irgendw o auf dem Weg zwischen der Funktions deklaration bzw. - definition in einer höheren Programmiersprache (wie Python, Java, Haskell oder C ) bis z um lauffä higen Maschinencode müssen rekursive Ablaufstrukturen also auf jeden Fall in itera tive umgewandelt werden. Daher wollen wir in diesem Kapitel die wichtigsten Konzepte zur Abbildung rekursiver au f it erative Strukturen besprechen. Zur Programmierung der iterativen Algorithmen benötigen wir nun wiede r Zustandskonzepte, Variablen und Zuweisungen, weshalb wir dafür wieder zu unserer imperativen Pseudosprache PPS zurückkehren. Die re kursiven Algorithmen wer den wir dagege n weiter in FPPS darstellen.
8.1
Iterative Darstellung repetitiver Rekursion
Besonders einfach ist die Um setzung repetitiv-rekursiver Algorithm en, wie beispielsweise unseres ggT-Algorithmus aus Abschnitt 7.6. Dieses Beispiel wollen wir daher zunächst näher betrachten. Zuerst wiederholen wir die rekursive Formulierung. function ggT (nat m, n): nat return if m = n then m else if m > n then ggT(m–n, n) else ggT(m, n–m) Nun führen wir anstatt m, n die Variablen mvar und nvar ein und können damit eine äquivalente iterative Funktion formulieren: function ggTit (nat m, n): nat var nat mvar := m, nvar := n; begin while not mvar = nvar do if mvar > nvar then mvar := mvar – nvar else nvar := nvar – mvar endif endwhile
142
8 Rekursion und Iteration
return mvar endfct Da die Wiederholung mit Anfangsbedingung strukturell der repetitiven Rekursion entspricht, kann in diesen Fällen mit folgendem Schema gearbeitet werden: Sei f( x1, …, xn): < T> eine repet itiv-rekursive Funktion. Dann lässt sich f durch einen iterativen Algorithmus mit den Variablen xvar1, …, xvarn folgendermaßen darstellen: function fit ( x1, …, xn): var xvar1 := x1; … var xvarn := xn; begin while not do
endwhile return endfct Beispiel (Bestimmung, ob n eine (ganzzahlige) Potenz von m ist): Wir verwenden dabei die folgenden Operationen: • a div b für das Ergebnis der ganzzahligen Division von a durch b (z.B. 5 div 2 = 2) • a mod b für den Rest bei der ganzzahligen Division von a durch b (z.B. 5 mod 2 =1) function potenz (nat n, m): bool return if n = 1 or n = m then true else if n mod m ≠ 0 then false else potenz(n div m, m) Für die iterative Darstellung müssen wir diese Definition noch etwas abändern und die beiden Terminierungsbedingungen zusammenfassen: function potenz2 (nat return if (n = 1 or n (n = 1 or else potenz (n
n, m): bool = m) or (n mod m ≠ 0) then n = m) div m, m)
Im ersten Fall („then-Zweig“) gibt die Funktion also einfach den Wert des booleschen Terms (n =1 or n = m) zurück. Ein Vergleich der Rückgabewerte beider Funktionen in allen möglichen Fällen zeigt, dass die be iden obigen Darstellungen tats ächlich dieselbe Funktion beschreiben:
8.2 Darstellung linear rekursiver Funktionen n =1 or n = m true true true false false true false false
n mod m ≠ 0
143
potenz
potenz2
true true false potenz (n div m, m)
true true false potenz (n div m, m)
Nun können wir unser o.g. Schema auf die zweite dieser Darstellungen anwenden: function potenzit (nat n, m): bool var nat mvar := m, nvar := n; begin while not ((nvar = 1 or nvar = mvar) or (nvar mod mvar ≠ 0)) do nvar := nvar div mvar endwhile return (nvar = 1 or nvar = mvar) endfct Das Ganze können wir noch etwas vereinfachen, da mvar nicht notwendig ist (der Wert des Parameters m wird nicht verändert) und zud em d ie Wiederholungsbedingung mit Hil fe der Regeln der Booleschen Algebra vereinfacht werden kann: function potenzit2 (nat n, m): bool var nat nvar := n; begin while nvar ≠ 1 and nvar ≠ m and nvar mod m = 0 do nvar := nvar div m endwhile return (nvar = 1 or nvar = m) endfct
8.2
Darstellung linear rekursiver Funktionen
Linear re kursive F unktionen ka nn m an m it Hilfe der Einbettungstechnik durch r epetitivrekursive Funktionen darstellen und s o ebenfalls über das Schema des vora usgehenden Abschnittes iterativ darstellen. Beispiel (Fakultät): Wir betten die Fakultätsfunktion (vgl. Abschnitt 7.9.1) in die allgemeinere (repetitiv-rekursive) Funktion fakallg ein: function fakallg (nat n, k): nat return if n = 0 then k else fakallg(n–1, k*n) Die übliche Fakultätsfunktion ergibt sich dann aus:
144
8 Rekursion und Iteration fak(n) = fakallg(n, 1)
Also z.B. für den Funktionswert n = 4: fak(4) = fakallg(4, 1) = fakallg(3, 4) = fakallg(2, 12) = fakallg(1, 24) = fakallg(0, 24) = 24. Das Prinzip der Einbettung besteht also darin, die nach dem Abschluss des rekursiven Aufrufs noch benötigten Parameterwerte in einem speziell dafür eingeführte n Parameter mitzuführen. Im dynam ischen Da tenflussdiagramm der ursprünglichen Funktion sind das alle Werte, die unt erhalb des re kursiven Aufrufs noch weiterverarbeitet werden m üssen (siehe Abbildung 7.3). Das Erge bnis der Um wandlung is t dann eine repetitiv-rekursive Funkt ion, die schematisch durch einen iterativen Algorithmus ersetzt werden kann: function fakallgit var nat nvar := n, begin while nvar ≠ 0 do kvar := kvar nvar := nvar endwhile return kvar endfct
(nat n, k): nat kvar := k; * nvar; – 1
Bei der Reihenfol ge der Anweisungen in de r Wiederholung ist dabei Sor gfalt geboten: kvar soll j a m it dem „a lten“ Wert v on nvar multipliziert werden. Daher wird nvar erst da nach heruntergezählt. Beispiel (Länge einer Liste): function length (natlist liste): nat return if isempty(liste) then 0 else length(liste.rest) + 1 function lengthallg (natlist liste, nat len): nat return if isempty(liste) then len else lengthallg(liste.rest, len+1) Damit gilt: length(liste) = lengthallg(liste, 0) Ein beispielhafter Aufruf: length([4, 3, 2]) = lengthallg([4, 3, 2], 0) = lengthallg([4, 3], 1) = lengthallg([4], 2) = lengthallg(empty, 3) = 3 Die iterative Form lautet schließlich:
8.2 Darstellung linear rekursiver Funktionen
145
function lengthallgit (natlist liste, nat len): nat var natlist listvar := liste; nat lenvar = len; begin while not isempty(liste) do listvar := listvar.rest; lenvar := lenvar + 1 endwhile return lenvar endfct Neben der Möglichkeit der schematischen Umsetzung in eine iterative Darstellung spart die repetitive Variante gegenüber der ursprünglichen auch noch vi el Speicherplatz, da der Prozess der aufrufenden Funktion in diesem Fall unmittelbar nach dem Aufruf beendet werden und som it se inen Speicherplatz wieder freigeben kann. Im nicht-repetitiven Fall bleibt (schlimmstenfalls) jede r re kursiv aufge rufene Prozess bis zum Absc hluss de r gesa mten Rechnung a ktiv und verbraucht som it Speich erplatz für all seine aktuellen Param eterwerte (vgl. Abbildung 7.3). Bei d er Berechnung der Länge einer Liste mit n Elementen mit Hilfe der F unktion len müssten da nn jeweils alle Reste der Liste über alle n+1 Au frufe hinweg aufbewahrt werden. length([4, 3, 2]) = length([4, 3]) + 1 = (length([4]) + 1) + 1 = ((length(empty) + 1) + 1) + 1 = 0 + 1 + 1 + 1 = 3 Wenn man den Platzbedarf eines einzelnen Listenelementes mit a bezeichnet, benötigt man dafür (alleine für die Reste der Liste) Speicherplatz in de r Größenordnung von n2 ma l dem Platzbedarf eines einzelnen Listenelementes: 1. Aufruf: n*a 2. Aufruf: (n–1)*a + n*a … n. Aufruf: a + 2*a + … + n*a n+1. Aufruf: 0 + a + 2*a + … + n*a = a* (1 + … + n) = a*n*(n+1)/2 = a*(n2+ n)/2 Dazu ein Zahlenbeispiel: Die Anzahl der Kunden der Telekom dürfte in der Größenordnung von 50 Mi o. l iegen. Setzt m an f ür ei ne Kundennummer einen Platz bedarf von 4 By te an, dann benötigt man alleine für die (naive) Abzählung der Einträge der Kundenliste auf diese Weise Speic herplatz in der Gr ößenordnung von 10 16 Byte, das wäre n ca. 10000 Te rabyte oder ca. 1 Mio. Festplatten m it 100 Gigabyte Kapazität. Demgegenüber benötigt die eingebettete repetitiv-rekursive Variante für diese Aufgabe nur ca. 200 Mio. Byte, also ca. 200 MB und käme daher mit dem Arbeitsspeicher eines (derzeit handelsüblichen) Heimcomputers aus. Im 2. Teil dieses Werkes zum Thema „Algorithmen und Datenstrukturen“ werden Sie m ehr über die Effizienz von Algorithmen hören, insbesondere im Hinblick auf ihren Zeitbedarf.
146
8.3
8 Rekursion und Iteration
Kellerspeicher (Stacks)
Leider sind nicht alle rekursiven Funktionen linear rekursiv, daher durch Einbettung repetitiv darstellbar und somit schematisch in eine iterative Form zu bringen. Für kaskadierende (wie die Binomialkoeffizienten, siehe 7.9.3), vernestete (wie die Ackermannfunktion, siehe 7.9.4) oder verschränkte (wie gerade/ungerade, siehe 7.9.5) rekursive Funktionen muss man oft auf ein allgemein einsetzbares Konzept zur Umwandlung von Rekursion in Iteration zurückgreifen (das übrigens auf M aschinenebene generell für diesen Zwec k a ngewandt wird ): M an speichert zum Zeitpunkt eines rekursiven Aufrufs alle Variablenwe rte des aufrufenden Prozesses in einer speziellen Datenstruktur ab, um diese Werte nach der Beendigung des aufgerufenen Prozesses wieder abholen und weiterverwenden zu können. Dafür benötigt man eine Datenstruktur, die nach dem Prinzip „last-in -first-out“ (LIFO) arbeitet, denn bei einer Schachtelung rekursiver Aufrufe wird nach der Terminierung der Rekursion ja der aufrufende Prozess (dessen Werte zuletzt abgelegt wurden) zuerst fortgesetzt: function rek (…): …
endfct Für diesen Zweck benutzt man das von F. L. Bauer Anfang der 50er Jahre in Z usammenarbeit mit Klaus Sam elson an der TUM entwickelte Prinzi p des Kellerspeichers (auch Stapel oder Stack genannt). Dabei handelt es sich im Wesentlichen um eine lineare Liste mit zwei speziellen Funktionen: • Eine Funktion zum Ablegen eines Elementes an der S pitze der Liste (tra ditionellerweise mit push bezeichnet) • Eine Funktion zum Entfernen des Elem entes an der Spitze der Liste (traditionellerweise pop) Da die beiden Operationen push und pop in sehr vielen Algorithmusdarstellungen unter dieser Bezeichnung verwendet werden, formulieren wir unsere Algorithmen hier ebenfalls unter deren Verwendung. Leider wird die Sem antik der beiden Operationen in der Literat ur nicht einheitlich festgelegt, vor allem hinsichtlich pop: Mal handelt es sich um eine Funktion, mal um eine Prozedur. Mal wird der Rest des Kellers nach Entfernen des Elementes zurückgeliefert, mal das entfernte Element. In letzterem F all wird das Element mal vom Keller entfernt, mal auch wieder nicht. Unter Verwendung der linearen Listen au s Abschnitt 7.6 kann man die Datenstruktur keller folgendermaßen definieren (hier am Beispiel eines Kellers für natürliche Zahlen): sort keller = natlist Damit ist auch aut omatisch eine gleich namige Konstr uktorfunktion definiert (sie he Ab schnitt 7.2):
8.3 Kellerspeicher (Stacks)
147
function keller (nat el, keller kel): keller return // Ein Datenelement der Sorte keller, // das durch Anfügen von el an kel entsteht Die Prozedur push wird dann über diese Konstruktorfunktion definiert: procedure push (nat el, var keller kel): begin kel := keller(el, kel) endproc Die Prozedur pop formulieren wir folgendermaßen: procedure pop (var keller kel): begin if not isempty(kel) then kel := kel.rest endif endproc Unsere Va riante von pop entfernt (als Prozedur) also lediglich da s oberste Ele ment vom Keller, ohne einen Rückgabewert zu liefern. Auf das oberste Kellerelement greifen wir über kel.head zu. Damit verfügen wi r über die Hilfsmittel, um jede rekursive Funktion in iterativer Form zu schreiben, sogar vernestet re kursive Funktionen wie die Ackermannfunktion. Dazu wiederholen wir zunächst deren rekursive Darstellung aus Abschnitt 7.9.4: function acker (nat m, n): nat return if m = 0 then n + 1 else if n = 0 then acker(m–1, 1) else acker(m–1, acker(m, n–1)) Die äquivalente iterative Formulierung lautet dann: function ackerit (nat m, n): nat var nat mvar := m, nvar := n; var keller k := empty; begin push(mvar, k); push(nvar, k); while not isempty(k.rest) do nvar := k.head; pop(k); mvar := k.head; pop(k); if mvar = 0 then push(nvar+1, k) elseif nvar = 0 then push(mvar–1, k); push(1, k) else push(mvar–1, k); push(mvar, k); push(nvar–1, k)
148
8 Rekursion und Iteration
endif endwhile return k.head endfct Hier werden also nur die Parameterwerte sowie das Ergebnis im Terminierungsfall (n+1 falls m = 0) in der richtigen Reihenfolge auf dem Keller abgelegt und wieder abgeholt. Zur Illustration soll ein kleines Zahlenbeispiel dienen: acker(1, 2) = acker(0, acker(1, 1)) = acker(0, acker(0, acker(1, 0))) = acker(0, acker(0, acker(0, 1))) = acker(0, acker(0, 2)) = acker(0, 3) = 4. Der entsprechende Ablauf von ackerit sieht dann so aus: Anweisung(en)
danach:
m push(1, k); push(2, k); nvar := k.head; pop(k); mvar := k.head; pop(k); push(mvar–1, k); push(mvar, k); push(nvar–1, k) nvar := k.head; pop(k); mvar := k.head; pop(k); push(mvar–1, k); push(mvar, k); push(nvar–1, k) nvar := k.head; pop(k); mvar := k.head; pop(k); push(mvar–1, k); push(1, k); nvar := k.head; pop(k); mvar := k.head; pop(k); push(nvar+1, k) nvar := k.head; pop(k); mvar := k.head; pop(k); push(nvar+1, k) nvar := k.head; pop(k); mvar := k.head; pop(k); push(nvar+1, k)
Zustand der Variablen var
Kellerzustand
nvar 2
[2, 1]
1 1 1 [0]
2
[1, 1, 0]
1 1 0 [0,
1
[0, 1, 0, 0] 0]
1 0 1 [0,
0
[1, 0, 0, 0] 0]
1 1 2 []
0 1 [2, 0 2 [0]
0, 0]
0 2 [3, 0 3 []
0]
0 3 [4]
Schließlich terminiert der Algorithmus wegen isempty(k.rest) = true und liefert als Ergebnis: k.head = 4. Als weiteres Beispiel für eine iterative Darstellung mittels eines Kellers soll unsere kaskadierende Funktion zur Berechnung der Binomialkoeffizienten aus Abschnitt 7.9.3 dienen:
8.3 Kellerspeicher (Stacks)
149
function bn (nat n, k): nat return if n = 0 or k = 0 or k = n then 1 else bn(n–1, k–1) + bn(n–1, k) Die iterative Variante lautet: function bnit (nat n, k): nat var nat nvar := n, kvar := k, result := 0; var keller kel := empty; begin push(nvar, kel); push(kvar, kel); while not isempty(kel) do kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); if nvar = 0 or kvar = 0 or kvar = nvar then result := result + 1 else push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) endif endwhile return result endfct Dieser Algorithmus legt also wiederum die Parameterwerte auf den Stack (und holt sie natürlich auch zu gegebener Zeit wieder ab), addiert jedoch zusätzlich die Ergebnisse in den Terminierungsfällen (n = 0 oder k = 0 oder n = k) in der Variablen result zum Ergebnis auf. Auch diesen Ablauf wollen wir anhand eines Zahlenbeispiels veranschaulichen: bn(4, 2) = bn(3, 2) + bn(3, 1) = bn(2, 2) + bn(2, 1) + bn(2, 1) + bn(2, 0) = 1 + bn(1, 1) + bn(1, 0) + bn(1, 1) + bn(1, 0) + 1 = 1 + 1+ 1 + 1 + 1 + 1 = 6
150 Anweisung(en)
8 Rekursion und Iteration danach:
nvar result := 0; push(nvar, kel); push(kvar, kel); kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1 kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1 kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1 kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); push(nvar–1, kel); push (kvar–1, kel); push(nvar–1, kel); push (kvar, kel) kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1 kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1 kvar := kel.head; pop(kel); nvar := kel.head; pop(kel); result := result + 1
Zustand der Variablen kvar
Kellerzustand
42
result 0
[2, 4]
42
0
[]
42
0
[2, 3, 1, 3]
32
0
[1,3]
3
2
0
[2, 2, 1, 2, 1,3]
2
2
0
[1, 2, 1,3]
2 21
2
1 1
[1, 2, 1,3] [1,3]
2
1
1
[1, 1, 0, 1, 1, 3]
1
1
1
[0, 1, 1, 3]
1 10
1
2 2
[0, 1, 1, 3] [1, 3]
1 31
0
3 3
[1, 3] []
3
1
3
[1, 2, 0, 2]
3
[0, 2]
21 2
1
3
[1, 1, 0, 1, 0, 2]
1
1
3
[0, 1, 0, 2]
1 10
1
4 4
[0, 1, 0, 2] [0, 2]
1 20
0
5 5
[0, 2] []
2
0
6
[]
Der Algorithmus terminiert wegen isempty(kel) = true und liefert als Ausgabe: result = 6.
8.4 Aufgaben
8.4
151
Aufgaben
Aufgabe 8.1: Gegeben sei f olgende Funktion in FPPS zur Berechnung de r n-ten FibonacciZahl: function fib (nat n): nat return if n = 0 then 0 else if n = 1 then 1 else fib(n-1) + fib(n-2) a)
Von welchem Rekursionstyp ist die Funktion fib? Zeichnen Sie den Aufrufgraphen für fib(5).
b) Wandeln Sie die Funktion fib mit Hilfe eine s Kellerspeichers in eine iterative PPSFunktion fibit um und stellen Sie den Ablauf von fibit(4) dar. Aufgabe 8.2: Wir betrachten die folgende Funktion f: function f (nat n, k, a, b): nat return if k = n then b else f(n, k+1, a+b, a) a)
Von welchem Rekursionstyp ist die Funk tion f? Zeic hnen Sie den A ufrufgraphen für f(5, 0, 1, 0).
b) Wandeln Sie die Funktion f in eine iterative PPS-Funktion fit um. c)
Läßt sich f in irgendeine n Z usammenhang mit der Funktion fib aus Aufga be 8.1 bringen?
Aufgabe 8.3: Gegeben ist folgende in Python geschriebene Funktion: def exp(x, y): if y == 0: return 1 else: return x*exp(x,y-1) a)
Formulieren Sie eine Funktion in FPPS, die dasselbe leistet.
b) Warum ist die Funktion nicht repetitiv rekursiv? c)
Die in FPPS formulierte äquivalente Funkti on soll nun durch Ei nbettung repetitiv rekursiv gemacht werden. Folgendes Programmfragment sei dazu gegeben: exp2 :: Int -> Int -> Int exp2 x y = exp_embed x y 1 Schreiben Sie die repetitiv rekursive Funktion exp_embed in FPPS und Haskell.
2. Teil Algorithmen und Datenstrukturen
9
Grundlegendes
Das Themengebiet „Algorithmen und Datenstrukturen“ ist seit den Anfängen der Informatik wesentlicher Bestandteil bei der Verarbeitung von Daten. Datenstrukturen ermöglichen die Organisation, die Speicherung und den Zu griff durch Operationen auf Daten im Haupt- und Hintergrundspeicher. Sie stellen effektive Speichermöglichkeiten für bestim mte Problem e aus den unterschiedlichsten Sichtweisen zur Verfügung. Es gibt keine Datenstruktur, die alle Speicherprobleme in effizienter Weise bearbeiten kann. Beispiele für Datenstrukturen sind: • • • • • •
Felder, Listen, Bäume, Graphen, Heaps oder Tabellen.
Algorithmen verwenden Datenstrukturen, um spezielle Rechenprobleme schnell und zuverlässig in tatsächlich ausführbaren Schritten zu verarbeiten. Entschei dende Faktoren hierbei sind der Bedarf an Zeit und Speicherplatz. Oft hängt die Effizienz eines Algorithmus mit der Effizienz der Verwaltung der Daten durch eine passende Datenstruktur zusammen. Typische Einsatzbereiche von Algorithmen sind: • • • •
Suchverfahren, Sortierverfahren, Zugriffsoperationen auf Datenstrukturen oder heuristische Verfahren für Probleme, bei denen keine effiziente Lösung bekannt ist.
Beispiel: Berechnung von kürzesten Verbindungen zwischen mehreren Städten. Das Beispiel in A bbildung 9.1 zeigt, dass es durc haus möglich ist, die kü rzesten V erbindungen naiv zu berechnen, wenn es wenige Städte und Straßen gibt. Durch bloßes Ausprobieren aller Kombinationen bzw. normales Betrachten lässt sich schnell herausfinden, dass die kürze ste Strecke zwisc hen Landshut un d Rosenheim über Wasserburg am Inn führt. Wollen wi r die schnellsten Ve rbindungen aller große n und mittelgroßen Städte der E rde berec hnen, s o ist dies per Hand oder durch ein uneffizientes Verfahren (wie beispielsweise Ausprobieren aller Kombinationen) nicht mehr machbar. Eine effiziente Lösung dieses Problems werden wir zu einem späteren Zeitpunkt kennen lernen.
156 9
Grundlegendes Landshut
4
1 5
1
3
6
4
München
Wasserburg a. Inn
Plattling 1
10 Rosenheim
Abb. 9.1
Kürzeste Verbindungen zwischen Städten mit schematischer Angabe von Straßen und Entfernung
Zum Lösen anstehender Probleme brauchen wir also Folgendes: • einen Algorithmus, der das Problem löst, • eine Datenstruktur zur Verwaltung der benötigten Daten, • Möglichkeiten zur Berechnung und zum Nachweis der Effizienz unserer Lösungen. In de n folgenden Abschnitten werden solche Techniken zur Beurteilung von Al gorithmen und Datenstrukturen sowie verschiedene Lösungsansätze spezieller Probleme vorgestellt.
9.1
Rekursion
In diesem Abschnitt wollen wir zwei Formen der Rekursion einführen bzw. wiederholen. Die lineare Rekursion als einfachste Variante und die vernestete Rekursion als komplexere Art. Der Vollständigkeit halber zuerst die allgemeine Definition der Rekursion. Definition: (Rekursion) Eine Funktionsdeklaration heißt rekursiv, wenn der zu deklarierende Funktionsidentifikator auf der rechten Sei te der Deklaration auftritt (gilt nicht für verschränkte Rekursion, siehe Broy, 1998, Band 1, Seite 108). Anmerkung: Bei der tatsächlichen Ausführung von rekursiven Funktionen startet das Betriebssystem f ür jeden re kursiven Aufruf e inen neue n Pr ozess m it eigenen Pa rametern und dafür reserviertem Speicherplatz.
9.1 Rekursion
9.1.1
157
Lineare Rekursion
Als einführe ndes Beispiel bi etet sich der größte gemeinsame Teiler an. Er wird wie folgt definiert: falls a = b ⎧a ⎪ ggT(a , b) = ⎨ggT(a − b, b) falls a > b ⎪ggT(a , b − a ) falls a < b ⎩
Dadurch lässt er sich mit einer linear rekursiven Funktion berechnen. function ggT (nat a, b): nat return if a = b then a elseif a > b then ggT(a–b, b) else ggT(a, b–a) Beispiel: ggT(16, 20) = ggT(16, 4) = ggT(12, 4) = ggT(8, 4) = ggT(4, 4) = 4. Definition: (linear rekursiv) Tritt in einer rekursiven Funktionsdeklaration function f (m x): n return E in E ein A ufruf de r F unktion f in jedem Zweig ei ner Fallunterscheidung höchstens ei nmal auf, so heißt die Rechenvorschrift f linear rekursiv (vgl. Broy, 1998, Band 1, Seite 126). Zu jeder rekursiven Rechenvorschrift lässt sich ein iteratives Ve rfahren angeben. Betrachten wir dazu die Fakultätsfunktion. function fak (nat n): nat return if n = 0 then 1 else n · fak(n–1) Die iterative Variante kann nicht immer so einfach gefunden werden wie in diesem Fall. var nat y := 1; procedure fak (nat n): var nat i := 1; begin while i ≤ n do y := y · i; i := i + 1 endwhile endproc Untersuchen wir die beiden Beispiele genauer, so stellen wir fest, dass die lineare Rekursion zu einer linearen Aufruffolge führt. Jeder rekursive Aufruf endet in maximal einem weiteren rekursiven Aufruf (siehe Abbildung 9.2).
158 9
Grundlegendes
fak(3)
fak(2)
fak(1)
fak(0)
Abb. 9.2
9.1.2
Lineare Aufrufstruktur der Fakultätsfunktion
Vernestete Rekursion
Bei der ve rnesteten Rekursion treten i n de n Parameterausdrücken ei nes rekursiven A ufrufs weitere rekursive Aufrufe auf (siehe Broy, 1998, Band 1, Seite 129). Die Ackermannfunktion verdeutlicht dies. function ackermann (nat m, n): nat return if m = 0 then n + 1 elseif n = 0 then ackermann(m–1, 1) else ackermann(m–1, ackermann(m, n–1)) Wir wollen die Funktion nicht im Einzelnen diskutieren, da sie wenig pr aktische Bedeutung hat. Ih re Werte wach sen b ei Vergrö ßerung d er Argu mente au ßergewöhnlich schn ell, sie terminiert j edoch immer. Bei Berechnungen in der Pra xis scheitert die Ausf ührung meist schon bei kleinen Argumenten an der enormen Rekursionstiefe. Aufrufe von vernestet re kursiven Funktione n führen auf nicht lineare , baumartige Aufrufstrukturen. Beispiel: Wir kürzen ackermann durch ack ab: ack(1,1) = ack(0,ack(1,0)) = ack(0,ack(0,1)) = ack(0,2) = 3 ack(1,3) = ack(0,ack(1,2)) = ack(0,ack(0,ack(1,1))) = . . . = ack(0,ack(0,3)) = ack(0,4) = 5 ack(4,1) = . . . = 65533.
9.2 Asymptotische Analyse
9.2
159
Asymptotische Analyse
Mit der Rekursion haben wir ein wichtiges Verfahren zum Entwurf von Algorithmen kennen gelernt. Dies bezüglich s ei noch darauf hi ngewiesen, da ss sich kom plexe algorithm ische Probleme meist nicht ad hoc lösen lasse n und das Finden einer effizient en Lösung oft viel Geduld, Zeit und teilweise auch Glück oder Zufall benötigt. Haben wi r aber eine Lös ung unsere r Pr obleme zur Ha nd, könne n wi r diese unters uchen. Dabei kommt die asymptotische Analyse zum Einsatz. Sie ist ebenfalls wichtiger Bestandteil des Umgangs mit Algorithmen. Sie beschäftigt sich mit der Frage, mit welche m Aufwand ein Algorithmus ein Problem löst. Dabei sind zwei Größen von Belang: • der zeitliche Aufwand zur Lösung des Problems, • der Platzbedarf während der Berechnung. Wir wollen uns auf den ersten Punkt beschränken, da der zur Verfügung stehende Speicherplatz in der heutigen Zeit weiterh in steigt und sich m anche Problembereiche auch aufgrund der Verwendung von mehr Speicherplatz effizienter gestalten lassen. Es wäre nun denkbar, einen Algorithmus zu entwerfen, um dann durch Tests mit verschiedenen Ei ngabegrößen he rauszufinden, welche Lauf zeit er hat. Das ersche int bei gena uerer Überlegung je doch wenig pr aktikabel, da beispi elsweise die Unters uchung eines in nicht angemessener Zeit berechenbaren Problems keine Aussage zur Folge hätte. Darin liegt e benfalls ein Grund für unsere Beschränkung auf den ersten Fall. Eine Berechnung, die aufgrund zu großen Speicheraufwandes nicht funktioniert, ist expe rimentell i m Rahmen der Möglichkeiten lösbar, da ein solche s Programm bei Speicher überlauf abstürzt. Aber Vorsicht: Natürlich liegt auch dann keine allgemeingültige Aussage über das behandelte Problem vor. Darum benötigen wir mathematische Methoden, mit denen sich Algorithmen in geeigneter Weise untersuchen lassen. Beispiel: Betrachten wir zum Ein stieg die Fakultätsfunktion aus Abschnitt 9.1.1. Wie lange benötigt die Funktion zur Berechnung der Fakultät in Abhängigkeit von der Eingabe n? An diesem Beispiel erke nnen wir , da ss wi r ei ne feststehende Term inologie benötige n. Die Beantwortung der Frage ist ohne Hilfsmittel nicht möglich. Es stellen sich mehrere Fragen: • • • •
Wie kennzeichnen wir die Laufzeit in Abhängigkeit von der Länge n der Eingabe? Welche Wachstumsmaße gibt es? Wie drücken wir das Wachstumsverhalten aus? Wie berechnen wir das Wachstum?
Definition: (Laufzeit) Die Laufzeit eines Algorithmus mit der Eingabe der Länge n ist definiert durch die Funktion T(n). T(n) steht nicht für eine konkrete Aussage, wie z.B. eine Zahl in Sekunden. Sie gibt vielmehr
160 9
Grundlegendes
Auskunft da rüber, wie wir e ine Berechnung durc h eine Funktion in Abhängigkeit von n annähern (z.B. über die A nzahl der ta tsächlich auszuführenden Operationen). n kann dabei die tatsächliche Eingabe des Programms sein oder eine Aussage über die Struktur einer verwendeten Eingabemenge. Im Folgenden werden wir den Unterschied sehen und T(n) anhand von Beispielen berechnen. Für den Moment genügt es, dass T(n) die Laufzeit bezeichnet.
9.2.1
Komplexitätsmaße
Beispiel: Betrachten wir zunächst die drei Listen aus Abbildung 9.3. Alle drei sollen aufsteigend sortiert werden. Im besten Fall brauchen wir nicht sortieren, wobei im schlechtesten die Liste gena u anders herum sortiert ist. Der Aufwand zum Sortiere n de r ersten Liste ist i m Vergleich zur zweiten ge ring. Für die dritte wi rd de r Aufwand irge ndwo in der Mitte der beiden ersten liegen. Die Länge n der drei Listen ist jeweils 6, trotzdem wird ei n Algorithmus eine unterschiedliche Anzahl von Schritten zum Sortieren benötigen (T(n) ist also nicht identisch für die drei Fälle).
1
2
4
6
8
9
bester Fall
9
8
6
4
2
1
schlechtester Fall
8
2
4
9
1
6
dazwischen
Abb. 9.3
Sortieren einer Liste
Wir unterscheiden also z wischen drei ve rschiedenen Maßen, m it denen sich Aussagen über das Wachstum (oder auch Komplexität) treffen lassen (vgl. Güting, 1992): 1. Der beste Fall (best case) Tbest: Der Fall, bei dem die Lösung am schnellsten berechne t wird. 2. Der schlimmste oder sc hlechteste Fall ( worst case) Tworst: Der Fall, bei dem die Berechnung am längsten dauert. 3. Der Durchschnittsfall (average case) Tavg: Die durchschnittliche Dauer der Berechnung bei verschiedenen Eingaben. Im Beispiel de r Fakultät gilt in allen drei Fällen: T = n+1 (die Herleitung folgt am E nde dieses Kapitels). Im Unterschied zum obi gen Listen-Beispiel ( nbezeichnet dort die L änge der Liste) ist n hier die konkrete Eingabe, die verändert wird (herunterzählen von n bis 0). Es ergibt sich da durch kein Unterschied im best , worst und avera ge case, da die Anz ahl der durchzuführenden Schritte immer gleich ist. Der best case ist in vielen Fäl len nicht interessant. Wir beschränken uns meist auf die worst case-Analyse. Der durchschnittliche Fall ist i m Allge meinen mathematisch aufwendige r zu berechnen als der schlechteste Fall.
9.2 Asymptotische Analyse
161
Die nachstehenden Ausführungen werden für die restlichen Inhalte nicht benötigt. Der Vollständigkeit hal ber und um interessierten Le sern eine n A usblick zu geben, ge hen wir kurz tiefer in den Bereich von Kostenanalysen. Im Durchschni ttsfall müssen Annahmen über di e Wahrscheinlichkeitsverteilung de r a uftretenden Einga ben get roffen w erden. F ür Eingabe n x der Länge naus der Eingabemenge ∑ n gilt (sei K(x) das Komplexitätsmaß der Eingabe x, siehe Mayr, 1999): • Durchschnittsfall mit gleicher Wahrscheinlichkeit: 1 ⋅ ∑ K ( x ),
∑n
x =n
• Durchschnittsfall mit allgemeiner Wahrscheinlichkeit μ:
∑ µ(x ) ⋅ K( x ).
x ∈∑ n
Im Durchschnittsfall mit gleicher Wahrscheinlichkeit können wir die Summe aller Kom plexitätsmaße durch den Betrag der Eingabemenge (auch die Anza hl der Elemente in der Eingabemenge) teilen. Es liegt Gleichve rteilung vor. Im anderen Fall gi bt die Wahrscheinlichkeit μ das „ Gewicht“ der K omplexität einer ein zelnen E ingabe für die Gesam tlaufzeit vor (Beachte: die Summe aller Einzelwahrscheinlichkeiten ergibt 1). Wir benötigen den Durc hschnittsfall m it allgemeiner Wahrscheinlichkeit, wenn wir b eispielsweise ei ne A nwendung bet reiben, die in eine r Tei lfunktion Liste n ge neriert, die zu sortieren sind. Wenn die Listen zu 90 Prozent sortiert erzeugt werden, so ist die Verteilung über die Struktur der Eingaben aus der Eingabemenge eine andere und wir können eine andere Wahrscheinlichkeit annehmen. Es existiert noch eine weitere Betrachtungsweise für die asymptotische Analyse, die amortisierten Kosten. Sie geben Auskunft über die durchs chnittlichen Kosten von Operationen als Folgen von worst case -Betrachtungen. Da diese Berec hnungen a ber noch komplexer als average ca se-Analysen sind, beschränken wir uns hier nur auf die Einführung des Begriffs Potential, der wichtiger Bestandteil von amortisierten Kostenanalysen ist. Das Potential gibt Auskunft darüber, wie gut eine Struktur organisiert ist. Je besser die Anordnung der Elemente, umso geringer ist das Potential. Beispiel: Speicher n wir die gleichen Elem ente in einer L iste und in einem Bau m, so wäre das Potential des Baum es besser als das de r Liste. Vergleichen wir dazu die Tiefe de r Liste und des Baumes in Abbildung 9.4. Wenn wi r f ür jede n K noten den Abstand zur Wurzel be rechnen und danach darüber die Summe für de n Baum und die Liste bilden, so habe n wi r für diese bei den Strukt uren ein aussagekräftiges Potential. Verändern wir die Liste schrittweise zu ei nem Baum, so verbessert sich das Potential in gleicher Weise, da die Abstände einzelner Knoten zur Wurzel kleiner werden und somit auch die Summe über alle Knoten.
162 9
Grundlegendes
In unserem Beispiel habe jede Kante das G ewicht 1. Der Abstand eines Knotens zur Wurzel entspricht damit gena u der Höhe eines Ba umes auf der entsprechenden Ebe ne und für da s jeweilige Potential P ergibt sich: • im Fall der Liste: PListe = 1 + 2 + 3 + 4 + 5 = 15, • im Fall des Baumes: PBaum = 1 + 1 + 2 + 2 + 2 = 8.
1 2
1
2
1
2
2
3
4
5
Abb. 9.4
9.2.2
Potential einer Liste und eines Baumes
Wachstumsverhalten von Funktionen
Wir wissen nun, welche Maße es für das Wachstum von Funktionen gibt. Zum Ausdruck des Wachstumsverhaltens ve rwenden wir die O-Notation. Wir sprec hen a uch von de n La ndauSymbolen. In unse rem Beispiel de r Fa kultät wäc hst di e Funktion linear m it der Ei ngabe. Sie hat das Wachstum O(n). Die O-Notation gibt Auskunft über das Verhalten von Funk tionen im Unendlichen, das heißt multiplikative und additive Konstanten spielen keine Rolle und können vernachlässigt werden. Beispiel: n2 + 100n + 1500 = O(n2). Definition: (O-Notation) (siehe Güting, 1992, Seite 11) Seien f, g: N → R+ f = O(g) :⇔ ∃ n 0 ∈ N, c ∈ R, c > 0 : ∀ n ≥ n 0 : f (n ) ≤ c ⋅ g (n ).
9.2 Asymptotische Analyse
163
In Worten heißt dies nichts anderes, als dass f höchstens so schnell wie g wächst. g ist obere Schranke von f. Abbildung 9.5 zeigt, dass beispielsweise quadratische oder exponentielle Funktionen (Nummer 1 und 3) immer schneller wachsen als lineare (Nummer 2). Wir müssen nur die Eingabe der Funktionen ausreichend groß wählen, was insbesondere für Nummer 1 gilt, welche noch nicht schneller wächst als Nummer 2 (die Abbildung stellt dies bewusst so dar, um eine Vorstellung zu vermitteln, dass die Einga be unter Umständen sehr groß gewählt werden muss). Dies ist die Umkehrung der Aussage der obigen Definition: „W enn wir n noch so groß wählen, f wird nie schneller wachsen als g.“
3
y-Achse
2
1
x-Achse
Abb. 9.5
Wachstumsverhalten von Funktionen
Beispiele: Es gilt: n + 3 = O(n) 5n2 = O(n2) 2000n3 + 495n2 + 6n + 10000 = O(n3) 2n = O(2n) 2n + n10000 = O(2n). Wichtig:Es hat sich eingebürgert, die Aussagen in O-Notation als Gle ichungen zu s chreiben. Genau betrachtet ist dies falsch. Die Gleichungen dürfen nur von links nach rechts gelesen werden. Richtig wäre z.B. f(n) = n, f ∈ O(n). Beispiele: Welche Aussagen sind richtig? Begründung. f(n) = n, O(n) = f (falsch)
164 9
Grundlegendes O(n) = O(n/2) = O(23n)ψ f(n) = n, f = O(n) ∧ f = O(n2) ⇒ O(n) = O(n2) (Schlussfolgerung falsch) O(1) = O(n) = O(n2) = O(2n).
Wir teilen die Laufzeite n von Funktione n mit O-Notation in bestim mte Klassen ein (siehe Tabelle 9.1). Tab. 9.1
Klassifikation der O-Notation Sprechweise
O(1) konstant O(log n) logarithmisch O(n) linear O(n · log n) n · log n Wachstum O(n2) quadratisch O(nk), k ≥ 2 polynomiell O(2n) exponentiell
Beispiel: Warum gilt O(loga n) = O(logb n)? Begründung: logb x = logb a · loga x. logb a ist eine Konstante. Konstanten können wie bereits gesehen in der O-Notation vernachlässigt werden, da sie keinen Einfluss auf das Wachstumsverhalten haben. Die O-Notation ke nnt ne ben der Abschätzung nach oben noc h weiter e Sym bole, di e eine Aussage über das Wachstumsverhalten treffen und eine Verfeinerung der Thematik ermöglichen. Wir benötigen diese, um Komplexitätsbetrachtungen bei Bedarf lesen zu können. Definition: (allgemeine O-Notation) (siehe Güting, 1992, Seite 16): 1. f = Ω(g) („f wächst mindestens so schnell wie g“, g ist untere Schranke), falls g = O(f). 2. f = Θ(g) („ f u nd g wachsen größenordnungsmäßig gleich schnell“), falls f = O( g) u nd g = O(f). 3. f = ο(g) („f wächst langsamer als g“), wenn die Folge (f(n)/g(n))n ∈ N eine Nullfolge ist. 4. f = ω(g) („f wächst schneller als g“), falls g = ο(f). Diese Symbole dürfen genauso wie oben nur von links nach rechts gelesen werden. Praktisch gesehen haben wir nun d ie Möglichkeit, Fu nktionen größenordnungsmäßig zu v ergleichen (siehe Tabelle 9.2).
9.2 Asymptotische Analyse Tab. 9.2
165
Vergleich der Landau-Symbole
f = O(g) „ f = ο(g) f = Θ(g) f = ω(g) f = Ω(g)
f ≤ g“ „f < g“ „f = g“ „f > g“ „f ≥ g“
9.2.3
Berechnung des Wachstums von rekursiven Funktionen
Abschließend für dieses Kapitel bleibt nur noch die Frage offen, wie wir das Wachstumsverhalten von rekursiven Funktionen berechnen. Dies ist oft eine sehr trickreiche und komplizierte Aufgabenstellung. Eine Möglichkeit ist das Au fstellen einer Gleichung über die Laufzeit T(n), indem wir die Anzahl der Operatione n pro re kursiven Aufruf zä hlen und m it der Laufzeit des nächsten rekursiven Aufrufes addieren oder multiplizieren. Beispiel: Berechnung der L aufzeit T(n) der Fakultätsfunktion. Wir nehmen an, das s der Vergleich die Operation ist, die Zeit kostet . Die Multiplikation und Subtraktion sowi e die Zeit für den rekursiven Funktionsaufruf vernachlässigen wir, da es sich im Endeffekt nur um Konstanten handelt, welche, wie wir oben erfahren haben, nicht relevant in der O-Notation sind. T(n)
=
= T(n–1) + 1 = T(n–2) + 1 + 1 = T(n–3) + 1 + 1 + 1 =… = T(0) + 1 + … + 1 T(0) + n ⇒ T(n) = n + 1 = O(n).
Es muss natürlich gelten, dass T(0) = 1, was nach Definition von fak offensichtlich der Fall ist. Zur Erklärung obigen Vorgehens betrachten wir zuerst die erste Zeile: T(n) = T(n–1) + 1. Wir haben sie aus de r Struktur der Fakultätsfunktion hergeleitet. Für de n Rest bra uchen wir die Fakultätsfunktion nicht mehr heranzuziehen. Wir setzen in der linken Seite der Gleichung in T(n) den Wert n–1 ein und er halten: T(n–1) = T((n–1) – 1) + 1. Durch Einsetzen in die erste Zeile und etwas Um formung lässt sich genau die z weite ableiten. Fahren wir mit n–2, n–3, usw. genauso fort, entspricht dies exakt obiger Herleitung. Wir werden dieses Verfahren anhand der Algorithmen im Abschnitt „Sortieren und Suchen“ vertiefen und anwenden.
166 9
9.3
Grundlegendes
Aufgaben
Aufgabe 9.1: Klassifizieren Sie die folge nden Größen mit Hilfe der O-Notation: 1020n + 1, 1020nn + 1, n 20/n18 + 1, 124 100000, n 3 · log n. Setzen Sie die Gr ößen zueinander in Beziehung und stellen Sie einen Vergleich an. Aufgabe 9.2: Zur Lösung eines Problems stehen zwei Verfahren zur Verfügung. Verfahren A benötigt in Abhängigkeit von der Eingabegröße n 1000000 · n Operationen, das Verfahren B 0.000001 · n4 Operationen. a)
Für welche Werte von n ver wenden Sie Verfahren A und für welche Ver fahren B? Tipp: Die Anzahl der Operationen lässt sich in einem Koordinatensystem als Graph einer Funktion mit der Variablen n darstellen. Berechnen Sie den Schnittpunkt der beiden Graphen für die Verfahren A und B.
b) Setzen Sie das Vorgehen aus Aufgabe a) in Bezug z u folgender Definition der ONotation: f = O(g ) :⇔ ∃ n 0 ∈ N, c ∈ R, c > 0 : ∀ n ≥ n 0 : f (n ) ≤ c ⋅ g (n ).
10
(Basis-) Datenstrukturen
Im Laufe de r fol genden Kapitel werden wi r imm er wieder auf einfache Datenstrukt uren zurückgreifen, die uns ei ne geeignete Datenspeicherung und einen problemlos darzustellenden Zugriff auf die Datenele mente erlauben. Dazu gehören Sequenzen (die wie Felde r bzw. Listen funktioniere n), Warteschlangen, Ke ller, Binärbä ume und einfa ch sowie zwe ifach verkettete Listen, welche wir in diesem Kapitel betrachten. Von modernen Programmiersprachen (wie beispielsweise Java) werden diese Datenstrukturen normalerweise in der Standard-API zur Verfügung gestellt (das Application Programm Interface enthält vorgefertigte, für eige ne Soft ware ei nsatzbereite Programm e, welche in einer A PI-Dokumentation aufgeli stet und e rläutert sind) . Für die Ve rwendung m uss m an dann nur noch wissen, wie die Datenstruktur erzeugt wird (üblicherweise über eine n so genannten Konstruktor) und mit welchen Methoden man auf die Daten zugreift und diese manipulieren kann. Die innere Orga nisation der Datenstruktur spielt da bei keine wesentliche Rolle, da die inf ormelle Beschreibung der Funktionen i n de r AP I-Dokumentation a usreichend zur Benutzung in Programmen ist. Wir sprechen in diesem Zusammenhang auch von Information Hiding oder Black Box-Sicht, da für die Verwendung der Strukturen nur die Schnittstellen (Konstruktoren, Selektions- und Manipulationsmethoden) und de ren V erhalten wi chtig sind. Dieses Vorgehen bir gt einen entscheidenden Vorteil beim Erstellen jeglicher Art von Programmen. Solange sich das Verhalten einer D atenstruktur nach außen nicht ändert, können im Inneren Verbesserungen und Veränderungen durc hgeführt werde n (z.B . der A ustausch von Berec hnungsalgorithmen), ohne dabei di rekte Auswirkungen (wie beispielsweise unge wollte Fehlfunktionen) auf Software hervorzurufen, die die Datenstruktur verwendet. Eine Beschrei bungstechnik für die Schnittstellen (engl. interfaces) und das Ver halten von Datenstrukturen werden wir im Folgenden kennen lernen.
10.1
Abstrakte Datentypen
Bevor Datenstrukturen in Programmiersprachen umgesetzt werden, sollten sie in geei gneter Weise unabhängig von der verwendeten Zielsprache modelliert werden. In dieser Spezifikationsphase beschreibt man vor allem die Syntax der Zugriffsoperationen und das Verhalten der St ruktur nach auße n. Dies entspricht im Grunde genau dem , was der P rogrammierer benötigt, um die Datenstruktur in seiner Software anwenden zu können. Das Endprodukt der
168 10
(Basis-) Datenstrukturen
Spezifikationsphase ist die so ge nannte Spezifikation, die eine m öglichst formelle Beschreibung der Datenstruktur enthalten soll. Wir verwenden für die Spezifikation abstrakte Datentypen (in de r Literatur findet sic h auch der Begriff abstrakte Rechenstrukturen). Beispiel: W ir spezifiziere n den Datentype n Space, dessen ge naue F unktion z uerst einm al nicht definiert ist. Die Anforderungen seien nun die folgenden: Space soll leer und mit beliebigen Elem enten gefüllt sein können. Dadurch werden die Op erationen Einfügen und Löschen benötigt. Wir geben die Datenstruktur wie folgt an: abstractDatatype Space sort space m use bool functions emptySpace : space m isemptySpace (space m): bool joinSpace (space m, m): space m leaveSpace (space m): space m behaviour isemptySpace(emptySpace) = true isemptySpace(joinSpace(s, a)) = false leaveSpace(joinSpace(s, a)) = s endDatatype Mit dem Schlüsselwort abstractDatatype definieren wir den danach angegebenen Datentypen. sort gibt an, wie die S orte dieses Date ntyps heißen s oll (zur E rinnerung: Der Begriff Sorte entspricht nicht dem Begriff Datenstruktur, da dieser Sorten und Zugriffsfunktionen in sich vereint). In unserem Fall ist space eine polymorphe Sorte, d h. wir können beispielsweise einen space n at der nat ürlichen Zahlen erzeuge n. Der Abschnitt use zeigt an, welche Sorten wi r innerhal b des Datentyps verwenden. Da bei ist zu beac hten, dass unter use ausschließlich einfache (wie z.B. bool, nat) und bereits durch andere abstrakte Datentypen definierte Sorten aufgelistet werden dürfen. Unter functions werd en alle Kon struktoren u nd Zugriffsoperationen mit ihren Funktionalitäten angegeben (die Funktionalität bezeichnet die Sorten de r Eingabeparam eter und die Sorte des Ausgabeparam eters). Dieser Abschnitt des Datentyps beschreibt also die Schnittstelle der Datenstruktur. behaviour enthält die Definition des Verhaltens der Datenstruktur nach außen. Dies geschieht über Regeln auf den Sorten und Zugriffsoperatione n. Die Gesetze (Re geln) legen unter Beachtung der Funktionalitäten fest, wie die einzelnen Funktionen zueinander in Beziehung stehen und welche Eingaben auf welche Ausgaben führen. Im obigen Beispiel erzeugt emptySpace den leeren Space. Die Namen der anderen drei Funktionen lassen die beabsichtigte Funktionsweise erkennen. Ein wichtiger Bestandteil des abstrakten Datentyps ist die Verh altensbeschreibung über Gleichungen: isemptySpace soll true zurückgeben, wen n der Space leer ist, und false, falls mindestens ein Elem ent eingefügt wurde. Dieser Fall liegt vor, we nn wi r einmal joinSpace aufrufen (auc h wenn s der leere Space ist, können wir sicher sein, dass sich a im Space befindet). Nach der obigen Definition entfernt leaveSpace immer das zuletzt abgelegte Element.
10.1 Abstrakte Datentypen
169
Beispiel: Aus theoretischer Sicht können wir mit Space bereits Berechnungen durchführen. Die Datenstruktur wird von den definierten Funktionen erzeugt (wir stellen uns vor, dass die Zeichenfolgen der F unktionen den Inhalt der Da tenstruktur bilden). Wir fügen in ei nen leeren space nat s die Zahlen 4, 2 und 9 in dieser Reihenfolge ein und erhalten für s die folgende innere Struktur: emptySpace joinSpace(emptySpace, 4) joinSpace(joinSpace(emptySpace, 4), 2) joinSpace(joinSpace(joinSpace(emptySpace, 4), 2), 9) Zur Verwendung von Space in einem Program m reichen diese Regeln bzw. Gesetze s omit völlig aus. Die eigentliche Implementierung, also das Innenleben de r Datenstruktur (oder auch die Glass Box-Sicht), benötigt man nicht, da die obige formelle Beschreibung über das Verhalten Auskunft gibt. Ei n Vorteil liegt au f der Hand: Um die Funkti onsweise einer Datenstruktur z u verstehen, m uss nic ht das unt er Umständen sehr kom plexe I nnenleben (z.B. die verwendeten Algorithmen) untersucht werden. Die formelle Definition über Gesetze wird bei der Implementierung in der Regel durch eine aussagekräftige informelle Beschreibung in der API-Dokumentation ergänzt und erweitert. Wir fassen zunächst zusam men: Ein abstrakter Datentyp ist ein Paa r aus einer Menge von Sorten und einer Meng e von Funktionssymbolen. Die Funktionssymbole werden samt ihrer Funktionalität über den Sorten angegeben. Dadurch lassen sich Regel n mit Hilfe der Funktionen angeben, um das Verhalten des Datentyps zu definieren. Die angegebene Vorgehensweise lässt sich nicht direkt in einer Programmiersprac he umsetzen. Die A ngabe von abst rakten Datentype n erlaubt jedoch eine e xakte Spezifi kation einer Datenstruktur, was die Grundlage für gut konzipierte, dokumentierte und wieder verwendbare S oftware is t. Bei der Rea lisierung m uss dahe r ein Wechsel de r Abst raktionsebene zur Konkretisierung erfolgen. Wir können a uf diese Art und Weise modulare Software pa rallel entwi ckeln. Während der eine Programmierer noch an der Implementierung der Datenstruktur arbeitet, kann ein anderer diese schon verwende n, er kennt schließl ich die Schnittstelle aus der Spezifi kation. Ein abstrakter Dat entyp realisiert das bereits erwähnte Prinzip des Information Hiding beim Entwurf von Datenstrukturen. Abschließend wollen wir uns noch etwas genauer mit Space auseinandersetzen. Nehmen wir an, die Sorte space und die Schnittstellen unter functions genügen unseren Vorstellungen. Ist es dann zwingend notwendig, dass wir die Regeln unte r behaviour genauso anlegen, wie oben geschehen? Offensichtlich nicht, wie folgendes Beispiel zeigt. Beispiel: W ir geben nur noch das Verhalten an, da di e restlichen Defi nitionen den obigen entsprechen. abstractDatatype Space … behaviour isemptySpace(emptySpace) = true
170 10
(Basis-) Datenstrukturen
isemptySpace(joinSpace(s, a)) = false leaveSpace(joinSpace(joinSpace(s, b), a)) = joinSpace(s, a) endDatatype Die letzte Regel besagt, dass leaveSpace nicht das zuletzt eingefügte Element, sondern das vorletzte löscht. Das Verhalten in diesem Fall unterscheidet sich also vom obigen. Ein Programmierer müsste die Date nstruktur folgl ich a nders im plementieren. Es kann f ür ei n und dieselbe Schnittstelle viele v erschiedene Ausprägungen geben. Deswegen ist die Beschreibung des Verhaltens neben den Schnittstellen äußerst wichtig. Bei genauerer Betrachtung lässt sich auße rdem feststellen, dass unsere Regeln noch gewisse Fälle auskla mmern. Die Frage , was passiert, wenn wir beis pielsweise leaveSpace(emptySpace) ausführen, beantworten wir bewusst nicht. Wollen wir diese Frage erörtern, so müssten wir ein Fe hlerelement einführen (in mancher Literatur ist di eses Element durch das Symbol ⊥ gekennzeichnet, gesprochen bottom, siehe Broy, 1998, Band 1), was a ber für unsere Bedürfnisse zu weit f ühren würde. In gängigen Programmiersprachen werden in solchen Fe hlerfällen so ge nannte Exceptions e rzeugt, die da nn vom Progr amm in geeigneter Weise zu behandeln (abzufangen) sind. Eine andere (aus programmiertechnischer Sicht sehr elegante) Lösung wäre, jedes Mal, bevor die Entferne-Operation ausgeführt wird, mit isemptySpace zu prüfen, ob der Space leer ist oder nicht.
10.2
Die Datenstruktur der Sequenzen
Sortier- und Suchalgorithmen (siehe Kapitel 11) a rbeiten auf Sequenzen, die auch als Listen bezeichnet we rden und eine (unendliche ) Folge von Elementen da rstellen. Sie trage n manchmal den Namen Array (Feld), mit dem Unterschied, dass ein Array auf eine best immte, endliche Größe n beschrä nkt ist. Bei Se quenzen e rfolgt diese Ei nschränkung nic ht. Wir geben die Datenstruktur der polymorphen Sequenzen wie folgt an (vgl. Broy, 1998, Band 1, Seite 48): abstractDatatype Sequence sort seq m use bool, nat functions empty : seq m 〈_〉 (m): seq m ○ (seq m, seq m): seq m [_] (seq m, nat i): m isempty (seq m): bool first (seq m): m rest (seq m): seq m behaviour isempty(empty) = true isempty(〈a〉 ○ s) = false
10.3 Die Datenstruktur der Warteschlangen
171
(〈a1〉 ○ … ○ 〈ai〉 ○ … ○ 〈an〉)[i] = ai first(〈a〉 ○ s) = a rest(〈a〉 ○ s) = s endDatatype Die Funktion empty kreiert die leere Sequenz (im Gegensatz zur Da rstellung im ersten Teil dieses Buches wird empty nicht mehr fett gesc hrieben, da es nicht m ehr als Sc hlüsselwort, sondern als Funktion der Date nstruktur betrachtet wird). 〈_〉 ist der K onstruktor, der aus einem Element der Sorte m ein Element der Sorte seq m erzeugt (die Sc hreibweise ist eine Mischung a us Prä- und Postfixschreibweise). ○ i n Infi xschreibweise fügt zwei Seque nzen zusammen (genannt Konkatenation). Der Selektionsoperator [_] extrahiert das Elem ent mit Index i aus einer Sequenz s (dabei gilt für die Länge n von s: 1 ≤ i ≤ n). isempty ist selbsterklärend, first liefert das erste Element einer Sequenz und rest eine Sequenz ohne das erste. Beispiel: Mit dieser Spezifikation können wir Sequenzen natürlicher Zahlen seq nat bilden: s = 〈8〉 ○ 〈2〉 ○ 〈4〉 ○ 〈9〉 ○ 〈1〉 ○ 〈6〉. In Bezug auf die obigen Regeln liegt es auf de r Hand, dass die definierten Funktionen korrekt ar beiten. Beispie lsweise liefert first(s) offens ichtlich 8, rest(s) die Sequenz 〈2〉 ○ 〈4〉 ○ 〈9〉 ○ 〈1〉 ○ 〈6〉 und s[3] die 4. Eine graphische Darstellung zeigt Abbildung 10.1.
8
2
Abb. 10.1
10.3
4
9
1
6
Graphische Darstellung einer Sequenz
Die Datenstruktur der Warteschlangen
Warteschlangen (e ngl. queues) sind Datenstrukturen, die nach dem first-in-first-out-Prinzip (kurz: FIFO) a rbeiten. Das z uerst eingefügte Ele ment wird als erstes wieder a us der Warteschlange entnommen. Die Breitensuche (siehe Abschnitt 14.2.1) wird im Wesentlichen durch die Verwendung einer Queue realisiert. abstractDatatype Queue sort queue m use bool functions empty : queue m isempty (queue m): bool append (queue m, m): queue m first (queue m): m rest (queue m): queue m behaviour isempty(empty) = true isempty(append(q, a)) = false
172 10
(Basis-) Datenstrukturen
first(append(empty, a)) = a rest(append(empty, a)) = empty first(append(append(q, a1), a2)) = first(append(q, a1)) rest(append(append(q, a1), a2)) = append(rest(append(q, a1)), a2) endDatatype append legt ein ne ues Element in der Warteschlange ab, first gibt das zuerst eingefügte zurück und rest löscht es. Die beiden letzten Gesetze sind etwas umfangreicher, weil das zuerst eingefügte Element im „Inneren“ der Datenstruktur abgelegt wir d. Zum Extrahieren müssen wir uns erst durch die ande ren Daten „vorarbei ten“. Das folgende Beispiel verdeutlicht diesen Umstand. Beispiel: Wir arbeiten auf einer Warteschlange natürlicher Zahlen queue nat. Wir fügen 4, 2 und 9 in dieser Reihenfolge ein und erhalten für jeden Schritt: empty append(empty, 4) append(append(empty, 4), 2) append(append(append(empty, 4), 2), 9) Nun führen wir first aus. Nach der Definition einer Warteschlange müssen wir die 4 zurückbekommen. Diese steckt jedoch nicht in de r äußeren Klammer, sondern in de n inneren. Wir haben weiter oben schon erfahren, dass wir mit abstrakten Datentypen Berechnungen durchführen können. Diese Berechnungen sind nichts anderes als Anwendungen der Regeln. Eine Regel ist genau dann anwendbar, wenn das Muster der Regel auf die Ausprägung der Datenstruktur passt. Das q aus obiger Spezifikation ist dabei als Platzhalter für eine beliebige Warteschlange anzusehen. Für unser Beispiel können wir daher zweimal die fünfte Regel ausführen: first(append(append(append(empty, 4), 2), 9)) = first(append(append(empty, 4), 2)) = first(append(empty, 4)) = 4 Für die letzte Zeile ist die dritte Regel anwendba r und wir erhalten die 4. Für die graphische Darstellung wählen wir eine etwas anschaulichere Form (siehe Abbildung 10.2). Eine Warteschlange wächst auf der rechten Seite (append) und schrumpft auf der linken (rest).
4
Abb. 10.2
2
9
Graphische Darstellung einer Warteschlange
10.4 Die Datenstruktur der Keller
10.4
173
Die Datenstruktur der Keller
Der Gegensatz zum FIFO-Prinzip ist da s LIFO-Prinzip (last-in-first-out), nach welchem die so genannten Keller (engl. stacks) arbeiten. Das als letztes eingefügte Element wird als erstes wieder entfernt. Die Tiefensuche aus Abschnitt 14.2.2 entsprich t exakt der Breitensuche, nur dass an der Stelle einer Queue ein Stack zum Einsatz kommt. abstractDatatype Stack sort stack m use bool functions empty : stack m isempty (stack m): bool push (stack m, m): stack m top (stack m): m pop (stack m): stack m behaviour isempty(empty) = true isempty(push(s, a)) = false top(push(s, a)) = a pop(push(s, a)) = s endDatatype push legt ein neues Element auf den Keller, top nimmt das oberste weg (löscht es aber nicht) und pop entfernt das oberste. Beispiel: Wir legen 4, 2 und 9 auf einen Keller stack nat: empty push(empty, 4) push(push(empty, 4), 2) push(push(push(empty, 4), 2), 9) Wir erk ennen schnell, d ass ein Aufruf vo n top auf diese m Keller nach unse ren Re geln 9 ergibt. Die graphische Darstellung ist wieder etwas anschaulicher (siehe Abbildung 10.3).
9 2 4
Abb. 10.3
Graphische Darstellung eines Kellers
174 10
(Basis-) Datenstrukturen
10.5
Die Datenstruktur der Binärbäume
Bäume sind uns aus der Natur bekannt. Sie haben eine Wurzel, Äste, Astgabeln und Blätter. Wir betrachten Bäume seitenverkehrt mit der Wurzel nach oben. In einem Binärbaum haben die Wurzel und die Astgabeln maximal zwei Äste. Astgabeln bezeichnen wir als Knoten und Äste als Kant en. Wurzel, Knote n und Blätter dienen z ur Speiche rung von Inform ationen. Abbildung 10.4 zeigt ein Beispiel eines Binärbaumes mit natürlichen Zahlen.
5
3
10
Abb. 10.4
21
2
13
Graphische Darstellung eines Binärbaumes
Einige der später behandelten Algorithmen arbeiten auf binären Bäumen. Wir spezi fizieren Binärbäume als abstrakten Datentyp. abstractDatatype Binarytree sort bintree m use bool functions empty : bintree m isempty (bintree m): bool maketree (bintree m, m, bintree m): bintree m root (bintree m): m left (bintree m): bintree m right (bintree m): bintree m behaviour isempty(empty) = true isempty(maketree(lt, r, rt)) = false root(maketree(lt, r, rt)) = r left(maketree(lt, r, rt)) = lt right(maketree(lt, r, rt)) = rt endDatatype maketree ist d er Baumkonstruktor, root gibt die Wurzel eines Binä rbaumes zurück, left und right den entsprechenden Teilbaum. Beispiel: Der Binärbaum aus Abbildung 10.4 lässt sich mit obiger Definition darstellen. Wir bauen den Baum von unten nach oben auf: t1 = maketree(empty, 10, empty)
10.6 Verkettete Listen t2 t3 t4 t5 t6
= = = = =
175
maketree(empty, 2, empty) maketree(empty, 13, empty) maketree(t1, 3, t2) maketree(t3, 21, empty) maketree(t4, 5, t5)
10.6
Verkettete Listen
Aus Abschnitt 10.2 sind bereits Sequenzen bekannt, die auch als Felder oder Listen bezeichnet we rden. Dynamische Listen – wie sie in den Kapiteln 12.5 und 13. 3.2 verwendet werden – lassen sich auch durch Verkettung der einzelnen Elemente darstellen. Dazu be nötigen wir je doch ein Konstr ukt, welches wir bis dato noc h nic ht kenne n, s o gena nnte Verweise, Referenzen oder Zeiger (engl. Pointer). Bei Zeiger n handelt es sich u m ein maschinennahes Programmkonstrukt, weswegen wir sie nic ht als abstra kten Datentyp s ondern in Pseudoprogrammiersprache behandeln. C- bz w. C++-Programmierern si nd Zeige r wohl bekannte Program miermittel. Der Programmierer muss in diesen Sprachen Pointer explizit erzeugen und freigeben (Zeiger in C++ sind durch * gekennzeic hnet). In der Pr ogrammiersprache Java existieren ebenfalls Zeigerstrukturen. Da Java Referenzen aber implizit behandelt, muss sich der Programmierer nicht selbst um deren Verwaltung kümmern. Unter Entwicklern normalerweise bekannte Unterschiede in C++ und Java lassen sich durch die Behandlung de r Zeiger e rklären. C++-Programme sind bei Ausnutzung des Zeigerkonzeptes leistungsfähiger als da s Java-Äquivalent. Der Grund dafür ist die explizite Freigabe von nicht mehr benötigtem Speicherplatz im Hauptspeicher durch das Löschen von Zeigern. In Java kümmert sich de r Garbage Collector um die Freigabe de s Speichers. Er sucht nach nicht m ehr ref erenziertem Speich erplatz und gibt diesen dann frei. Es liegt auf de r Hand, dass dieses Vorgehen hohe Prozessorlast verursacht (dem Garbage Collector ist schließlich nicht von vornherein bekannt, wo nicht mehr benötigte Daten liegen). Wird in C++ die Freigabe des S peichers vergessen, s o führt die s zum „Verstopfe n“ de s Ha uptspeichers und ein Programm wird abstürzen, wenn nicht mehr genügend Speicherplatz vorhanden ist (unerfahrene P rogrammierer werden nicht selten Opfer dieses Fehlers ). Dies ist aber wiede rum ein Vorteil von Java, wo sich ein Entwickler nicht um die Freigabe des Speichers sorgen muss. Wir werden nun ein Zeigerko nzept für die Pseudos prache PPS einführen und damit einfach sowie zweifach verkettete Listen realisieren (vgl. Broy, 1998, Band 1).
10.6.1
Zeiger
Um die Darstellung und das Prinzip eines Zeige rs z u ver anschaulichen, betrachte n wir zunächst Abbildung 10.5. Ein Zeiger wird durch einen Pfe il dargestellt und seine Bezeichnung sei in diesem Beispiel x. Er verweist auf einen Speicherplatz (ist also im Grunde nichts ande-
176 10
(Basis-) Datenstrukturen
res als eine Speicheradresse im Hauptspeicher), in dem irgendein Datenelement abgelegt ist. Hat ein Zeiger kein Ziel, so trägt er den Wert null.
x
Abb. 10.5
10
Ein Zeiger
Die Werte der Datenzellen, auf die Zeiger verweisen, können ausgelesen werden, indem wir dem Zeiger in Pfeilrichtung folgen. Dieser Vorgang heißt Dereferenzieren. Wenn wir x dereferenzieren, erhalten wir den Wert 10. Dafür schreiben wir: x↓ Oder eingebettet in Progr ammcode (zur Erinnerung: output erze ugt ei ne Ausga be a uf der Standardausgabe, in diesem Fall wird also die 10 auf dem Bildschirm ausgegeben): … output(x↓); … Das Dereferenzieren eines null-Pointers hat einen Fe hler zur Folge, da dieser Verweis nicht auf eine Speicheradresse zeigt. Die Werte in den Speicherzellen können auch verändert werden. x↓ := 15; belegt die Speicherzelle, a uf die der Zeiger x zeigt, m it dem Wert 15. Eine Wertzuweisung auf x ohne das vorige Dereferenzieren hätte eine Veränderung der in x abgelegten Speicheradresse zur Folge, ein Z ugriff auf die gespeicherten Daten wäre som it nicht mehr möglich und ein Programmfehler die unvermeidbare Folge. Um Programme mit Zeigern zu schreiben, müssen wir di ese in geeigneter Form deklarieren können. Die Sortenvereinbarung sort pname = pointer name; sort name = record string first; string last end definiert zwei Sorten pname und name. Eine Deklaration der Form pname familie;
10.6 Verkettete Listen
177
erzeugt einen Zeiger familie. Zeiger tra gen zur Dynamisierung in Progra mmen bei, wie wir bereits an de r Problematik der Speiche rfreigabe in C++ gesehen haben. Würden wir Zeiger wie bis hier definiert ve rwenden, hätten wir no ch keine ander e Struktur als mit nor malen Variablen kreiert, da familie nach wie vor statisch ist (und ga nz davon abgesehen den Wert null trägt). Wie bereits bekannt, verweisen Zeige r auf Speiche rzellen, welche erst während des Programmablaufes reserviert werden können. Vor Programmstart is t schlie ßlich nicht be kannt, welche Speichereinheiten fre i sind. Für das Zuweisen eines Speic herplatzes benötige n wir somit eine weitere Funktion, die wir generate nennen. generate weist einem Zeiger zur Laufzeit eine noch nicht verwendete Speicheradresse zu, welche aus dem von der Laufzeitumgebung verwalteten Pool freier Speicherplätze stammt. Mit den bereits erfolgten Definitionen geschieht also Folgendes: … generate(familie); familie↓.first := „Hannelore“; familie↓.last := „Grassl“; … generate(familie) weist familie eine Adre sse zu und belegt die Speicherplätze für de n Verbund name (durch pname familie wurde nur die Variable zum Merken der Speiche radresse erzeugt). Wir können jetzt dereferenzieren und Daten im Speicher ablegen. generate(familie) darf beliebi g oft a usgeführt wer den u nd das P rogramm erhält eine unter Umständen sehr komplexe Dynam ik. Der Les er beac hte, da ss ein weitere s generate im obi gen P rogramm einen noch nicht belegten S peicherbereich reserviert und in familie eine Adresse able gt, die garantiert nicht der vorigen entspricht. Die abgespeicherten Daten wären unwiederbringlich verloren. Darin liegt auch die Schwierigkeit im Progra mmieren mit Zeigern, welche vom Entwickler hohe S orgfalt und präzise Vorüberlegungen zur Dynamik eines Pr ogramms erfordert. Wir werden dies im Folgenden an den Zugriffsoperationen von einfach und zweifach verketteten Listen sehen.
10.6.2
Einfach verkettete Listen
Eine einfach verkette Liste lä sst sich wie in Abbildung 10.6 darstellen. Der Zeiger first verweist auf das erste Element der Liste und der Punkt ke nnzeichnet den null-Pointer des letzten Elements. Durch Dereferenzieren sind alle Elemente der Liste erreichbar.
178 10
(Basis-) Datenstrukturen
first
Abb. 10.6
Einfach verkettete Liste
Wir ge ben die Definition für einfach verkettete Listen wie folgt an (wir lassen ohne Beschränkung der Allgemeinheit nur Listen über natürlichen Zahlen zu): sort pelist = pointer elist; sort elist = record nat item; pelist next end pelist first; Wir geben nun d ie zwei Zugrif fsoperationen insert und delete für einfac h verkettete Lis ten an. Vorab sei gesagt, dass zu r Vermeidung von Programmfehlern vor dem Dereferenzieren sichergestellt werden m uss, dass die Zeiger keine null-Pointer sind. Außerdem kann e s zum besseren Ve rständnis de r O perationen sehr h ilfreich sein, nebe n de n A bbildungen eigene Zeichnungen mit Papier und Bleistift anzufe rtigen, um die einzelnen Schritte Zeile für Zeile exakt nachvollziehen zu können. procedure insert (nat a, var pelist first): var pelist neu; begin generate(neu); neu↓.item := a; neu↓.next := first; first := neu endproc insert fügt ein neues Element vorne in die Liste ein. Zuerst erzeugen wir für das einzufügende Elem ent ein neues Listenelement und belege n den S peicher dafür. F alls die Liste nicht leer ist (first ≠ null), wird das ursprünglich erste Element zum zweiten. Ansonsten bleibt der next-Pointer von neu auf dem Wert null (das neue Element ist dann das einzige in der Liste). Zuletzt setzen wir first auf das ne ue Ele ment (Acht ung: Würden wir de n Zeiger first am Anfang aktualisieren, so würden wir alle Listenelemente verlieren). procedure delete (nat a, var pelist first): var pelist elem; begin while first ≠ null and first↓.item = a do first := first↓.next endwhile
10.6 Verkettete Listen
179
elem := first; while elem ≠ null and elem↓.next ≠ null do if elem↓.next↓.item = a then elem ↓.next := elem↓.next↓.next else elem := elem↓.next endif endwhile endproc Auf den ersten Blick erscheint delete etwas umständlich, da wir alle in der Liste vorhandenen a in zwei Schritten suchen. Dies ist jedoch not wendig, da wir uns i n first das erste Ele ment merken müssen, um den Listenanfang nicht zu verlieren. Die erste Schleife überprüft, ob das jeweils erste Listenelement a enthält und löscht es durch Verändern des Zeigers. Ist das nicht der Fall, so testet die zweite Schleife, welche der restlichen Listenelemente (falls vorhanden) zu entfe rnen s ind. Der Zei ger elem verwei st dabei im mer auf das Ele ment der Liste, das bekanntermaßen ungleich a ist. Ist dessen Nachfolger gleich a, s o lösc hen wir ih n d urch Umhängen des Zeigers und fahren an der aktuellen Position fort. Andernfalls wandern wir in der Liste um ein Element weiter. Abbildung 10.7 verdeutlicht dies an einem Beispiel.
first
c
Abb. 10.7
elem
b
a
d
Löschen in einer einfach verketteten Liste
Der Nac hfolger von elem ist ein zu l öschendes a, d.h. elem↓.next (b.next) ist auf das d (a.next) z u setzen. Wir ge hen übri gens davon aus, das s nicht m ehr ref erenzierte Ele mente von einem Garbage Collector aufgeräumt werden.
10.6.3
Zweifach verkettete Listen
Bei einfach ve rketteten Listen führt die Verke ttung der Z eiger daz u, da ss die Navigation durch die Liste in eine Richtun g möglich ist. In zwei fach ver ketteten Listen ka nn in beide Richtungen navigiert wer den, da jedes Elem ent einen Z eiger a uf se inen Vo rgänger u nd Nachfolger hat (siehe Abbildung 10.8).
first
Abb. 10.8
last
Zweifach verkettete Liste
180 10
(Basis-) Datenstrukturen
Im Unterschie d zu einfach verketteten Li sten führen wi r auf zweifach verketteten L isten einen weiteren Zeiger auf das letzte Element der Liste ein: sort pzlist = pointer zlist; sort zlist = record pzlist pre; nat item; pzlist next end pzlist first; pzlist last; pre ist der Vorgänge r und next ist der Nachfol ger. first und last könnten ebenfalls zu einem Verbund zusammengefasst werden. Aus Gr ünden der Einfachheit vernachlässigen wir diese Feinheit. Die Definition zweifach verketteter Listen erlaubt die Angabe der Zugriffsoperationen insert und delete. procedure insert (nat a, var pzlist first, last): var pzlist neu; begin generate(neu); neu↓.item := a; if first ≠ null then neu ↓.next := first; first ↓.pre := neu else last := neu endif first := neu endproc insert fügt ein neues Element am Anfang der zweifach verketteten Liste ein. Wichtig ist das Aktualisieren der Zei ger a uf Nachfol ger und Vorgänger. Wenn noch kein Elem ent in de r Liste ist (else-Zweig), so muss einmalig der Zeiger auf das letzte Element gesetzt werden. procedure delete (nat a, var pzlist first, last): var pzlist elem; begin while first ≠ null and first↓.item = a do first := first↓.next endwhile if first ≠ null then first↓.pre := null else last := null endif elem := first; while elem ≠ null and elem↓.next ≠ null do
10.7 Aufgaben
181
if elem↓.next↓.item = a then elem ↓.next := elem↓.next↓.next; if elem↓.next ≠ null then elem↓.next↓.pre := elem else last := elem endif else elem := elem↓.next endif endwhile endproc delete funktioniert prinzi piell gena uso wie das Entfernen von Daten in einfach verketteten Listen. Beim Herausnehmen von Elem enten m uss zusätzlich pre aktual isiert werden. Löschen wir Elem ente, die von first referenziert werden, so genügt es, einm alig pre des neue n ersten Elements auf null zu setzen. Falls da s letzte Ele ment gelöscht wurde, setzen wir last auf dessen Vorgänger. Zur Veranschaulichung der zweiten Schleife dient Abbildung 10.9.
first
elem
c
b
Abb. 10.9
last
a
d
Löschen in einer zweifach verketteten Liste
Der Nachfolger von elem wird gelösc ht. Wir setzen elem↓.next (b.next) auf d (a.next). Wir verändern an dieser Stelle elem↓.next, es könnte also wieder null sein. a.next ist jedoch nicht null, d h. wir müssen den V orgänger von d (d.pre) auf die Adresse von b aktualisieren. An dieser Stelle im Quellcode zeigt elem↓.next bereits auf d. Auf verketteten Listen sind noch einige andere Operationen vorstellbar. Für die Verwendung in den weiteren Kapiteln (siehe Chainingverfahren und Fibonacci-Heaps) genügt jedoc h die Kenntnis der prinzipiellen Funktionsweise und die Behandlung von weiteren Zugriffsfunktionen an dieser Stelle bleibt außen vor.
10.7
Aufgaben
Aufgabe 10.1: Der abstrakte Datentyp Space wurde in Abschnitt 10.1 umgestellt: abstractDatatype Space … behaviour isemptySpace(emptySpace) = true isemptySpace(joinSpace(s, a)) = false
182 10
(Basis-) Datenstrukturen
leaveSpace(joinSpace(joinSpace(s, b), a)) = joinSpace(s, a) endDatatype Erlauben diese Regeln das Löschen aller eingefügten Elemente? Geben Sie ein Beis piel an und begründen Sie Ihre Antwort. Aufgabe 10.2: Erweitern Sie den abst rakten Datentyp Sequence um die Fu nktionen last und front. Geben Sie dazu die Funktionen mit ihren Funktionalitäten sowie die benötigten Regeln an. last soll das letzte Element einer Liste zurückgeben und front die gesamte Liste ohne das letzte Element. Aufgabe 10.3: Erweitern Sie den abstrakten Datentyp Queue um eine Funktion concat, welche zwei Warteschlangen zu einer verschmilzt. Geben Sie dazu die Funktion mit ihrer Funktionalität sowie die benötigten Regeln an. Tipp: Überlegen Sie sich zuerst inform ell, wie die Funktion arbeiten soll. Aufgabe 10.4: Warteschlangen lassen sich auf der Grundlage von Seque nzen erstellen. Geben Sie eine Möglichkeit an, wie die Funktionen ei ner Warteschlange mit den Funktionen auf Sequenzen realisiert werden können. Aufgabe 10.5: Erweitern Sie den abstrakten Datentyp Stack um eine Funktion concat, welche zwei Keller zu einem verschm ilzt. Geben Sie dazu die Funktion mit ihrer Funktionalität sowie die benötigten Regeln a n. Tipp: Überlegen Sie sich zuerst informell, wie die Funktion arbeiten soll. Aufgabe 10.6: Erweitern Sie den abstrakten Datentyp Binarytree um die Funktion isbalance, welche prüft, ob der linke und rec hte Teilbaum die gleiche Höhe ha ben. Geben Sie dazu die Funktion mit ihrer Funktionalität sowie die benötigten Regeln an. Ti pp: Überlegen Sie sich zuerst informell, wie die Funktion arbeiten soll. Aufgabe 10.7: Realisieren Sie die Funktion search für einfach ve rkettete Listen. Sie soll die Anzahl der Elemente a in einer Liste zählen und diesen Wert zurückgeben. Aufgabe 10.8: Realisieren Sie die Funktion search für zweifach verkettete Listen. Sie soll die Anzahl der Elem ente a in einer Liste zähle n und diesen Wert z urückgeben. Setze n Si e die Funktion derart um, dass sie am Ende der Liste zu suchen beginnt.
11
Sortieren und Suchen
Aus Abschnitt 9 kennen wir di e grundlege nden Verfahren zum Entwurf und zur Unt ersuchung von Al gorithmen. Diese Techniken wollen wir nun anhand von Al gorithmen für Sor tier- und Suchprobleme anwenden und vertiefen. Die behandelten Algorithmen sind wesentlicher Bestandteil informatischen Grundwissens und gehören zu den wichtigsten Funktionen dieser Art. Viele Anwendungen im Bereich des täglichen Lebens verwenden Sortier- und Suchalgorithmen. Der Leser stelle sich nur einmal vor, in einem unsortierten Telef onbuch einen Namen mit der daz ugehörigen Tele fonnummer zu suchen. Die A lgorithmen kom men aber a uch in technischeren Bereichen (wie z.B. Datenbanken) zum Einsatz. Sortieren und Suchen geschieht in de r Regel auf so genannten Schlüsseln, eindeutigen Elementen zur Identifikation anderer Daten. Zur Vereinfachung bestehen unsere Schlüssel nur aus natürlichen Zahlen. Auf die explizite Benennung von Datenelementen verzichten wir, um das Wesentliche hervorzuheben.
11.1
Sortieren durch Einfügen
Sortieren durch Einfügen ist ein naiver Ansatz zum Sortieren von Daten. Er nimmt ein Element aus einer unsortierten Folge und f ügt es in eine sorti erte an der geeigneten Stelle ein. Die Algorithmen arbeiten auf der Datenstruktur der Sequenzen, deren Struktur und Zugriffsoperationen in Abschnitt 10.2 nachgelesen werden können. Damit lässt sich der Algorithmus für Sortieren durch Einfügen wie folgt angeben (vgl. Broy, 1998, Band 1, Seite 136). function insertsort (seq nat s): seq nat return insertseq(empty, s) function insertseq (seq nat s, r): seq nat return if isempty(r) then s else insertseq(insert(s, first(r)), rest(r)) function insert (seq nat s, nat a): seq nat return if isempty(s) then 〈a〉
184
11 Sortieren und Suchen elseif a ≥ first(s) then 〈a〉 ○ s else 〈first(s)〉 ○ insert(rest(s), a)
Bei etwas genauere r Betrachtung könne n wir feststellen, dass in den F unktionen insertseq und insert s je weils eine sort ierte Seque nz beinhaltet. insertsort nim mt die zu sortiere nde Sequenz und startet insertseq. Der erste Parameter wird mit der leeren Liste (empty) initialisiert, d a z u Be ginn noch nic hts sortiert ist. Die Funktion insert setzt das Elem ent a an die korrekte Stelle einer bereits sortierten Sequenz. insertseq macht nichts anderes, als insert so oft aufzurufen, bis alle Elemente sortiert sind. Ein Au fruf von insertsort liefert s omit eine absteige nd s ortierte Sequenz. Abbildung 11.1 zeigt dies beispielhaft.
8
2
4
9
1
2
4
9
1
6
4
9
1
6
9
1
6
1
6
6
6
Abb. 11.1
8 8
2
8
4
2
9
8
4
2
9
8
4
2
1
9
8
6
4
2
1
Sortieren durch Einfügen
Die Laufzeit von insertsort Die Laufzeit von insertsort ist maßgeblich vo n der Laufzeit von insertseq und insertabhängig. Da insertseq bei jedem rekursiven Aufruf insert initiiert, ergibt sich die Laufzeit von insertsort wie folgt: Tinsertsort = Tinsertseq · Tinsert Tinsertseq(n) = Tinsertseq(n–1) + 1 Tinsert(n) = Tinsert(n–1) + 2.
11.2 Sortieren durch Auswählen
185
Die Berech nung ist vo n d er Läng e n der Sequenze n bee influsst. Die Vergleiche seien die zeitkritischen Operationen. Ähnlich de r Analyse de r Fakultätsfunkti on haben insertseq und insert jeweils die worst case-Laufzeit O(n). Somit ist im worst case-Fall Tinsertsort = O(n2). Bei etwas anderer Betrachtung ergibt sich die Komplexität auch ohne Aufbrechen der Rekursionen. Für insert ist die sortierte Sequenz anfangs lee r, das heißt im ersten Schritt benötigt sie zu m Einfügen eine Operation, im zweiten zwei Ope rationen und im letzten maximal n Operationen. Anders ausgedrückt: n
Tinsertsort = 1 + 2 + 3 + … + n = ∑ i = i =1
11.2
n ⋅ (n + 1) = O(n 2 ). 2
Sortieren durch Auswählen
Sortieren durch Auswählen ist die Um kehrung von Sortieren durch Einfügen. Um eine Sequenz absteigend zu sortieren, wählt der Al gorithmus das größte Element der Se quenz aus, löscht es aus der uns ortierten Se quenz und fügt es am Ende der sortierten ei n (vgl. Broy, 1998, Band 1, Seite 136). Eine der F unktionen benutzt ein bi sher unbeka nntes Konstrukt, den let-Ausdruck. Viele funktionale Programmiersprachen stellen diese Form der Termauswertung zur Verfügung. Er ermöglicht die Berechnung e ines Terms, um das Ergebnis im darauf folgenden zu verwenden. Diese Art des Vorgehens kann m it der sequentiellen Komposition (Sequenzen von Anweisungen) in imperativen Programmiersprachen verglichen werden. function selectsort (seq nat s): seq nat return appendseq(empty, s) function appendseq (seq nat s, r): seq nat return if isempty(r) then s else let nat max = selectmax(rest(r), first(r)) in appendseq(s ○ 〈max〉, delete(r, max)) function selectmax (seq nat s, nat a): nat return if isempty(s) then a elseif a ≥ first(s) then selectmax(rest(s), a) else selectmax(rest(s), first(s)) function delete (seq nat s, nat a): seq nat return if isempty(s) then s
186
11 Sortieren und Suchen elseif a = first(s) then rest(s) else 〈first(s)〉 ○ delete(rest(s), a)
Wiederum enthält s in appendseq die s ortierte Sequenz. Ähnlich insertsort startet selectsort das eigentliche Sortieren. Die Funktion selectmax selektiert das Maximum aus der Sequenz s und delete löscht das Elem ent a aus s. appendseq extrahiert solange das maximale Element max, fügt es a n die sortierte Sequenz s a n und l öscht e s aus r, bis r leer und s vollständig sortiert ist. Einen beispielhaften Programmlauf von selectsort zeigt Abbildung 11.2.
8
2
4
9
1
8
2
4
1
6
2
4
1
6
2
4
1
2
1
6
1
Abb. 11.2
9 9
8
9
8
6
9
8
6
4
9
8
6
4
2
9
8
6
4
2
1
Sortieren durch Auswählen
Die Laufzeit von selectsort Auch i n diesem Fall ist die Laufzeit bestimmt durch die Kom plexität von appendseq. Bei Analyse von appendseq ergibt sie sich wie folgt: Tselectsort = Tappendseq + 2n + c. Der Summand 2n+c ergibt sich aus de n Laufzeiten von selectmax und delete, die bei jedem rekursiven Aufruf von appendseq linear in Abhängigkeit von der Länge n der Sequenz sind. Die Konstante c drückt eventuelle Unterschiede bei den Vergleichsoperationen aus. Sie kann vernachlässigt werden. Da die Se quenz r in appendseqbei jedem Durchlauf um eins kürzer wird ( delete lö scht je Durchlauf genau ein Element), gilt nun (T(0) ist vernachlässigbar):
11.3 Bubblesort T(n)
187 = T(n–1) + 2n = T(n–2) + 2n + 2(n–1) = T(n–3) + 2n + 2(n–1) + 2(n–2) =… = T(0) + 2n + 2(n–1) + 2(n–2) + … + 2 ≈ 2 · (n + (n–1) + (n–2) + … + 1) = O(n2).
Abschließend sei zu de n beiden Algorithmen „Sortieren durc h Einfügen“ und „Sortieren durch Auswählen“ noch gesagt, dass i n mancher Literatur nur auf einer Datenstruktur (statt auf zweien) gearbeitet wird. Dort kommt die Vorstellung von „Etwas in der einen Liste wegnehmen!“ und „Etwas in der anderen dazugeben!“ aber nicht so deutlich zum Ausdruck wie in diesen Ausführungen. Außerdem wird eine Verwaltung von Indizes für Anfang und Ende der beide n Teilbereiche be nötigt (sortierter und unsortierter Bereich ). B ezüglich der wo rst case-Analyse haben aber auch solche Versionen die Laufzeit O(n2).
11.3
Bubblesort
Ein weiteres bekanntes Sortierverfahren ist bubblesort. Bei bubblesort blubbern die zu sortierenden Elemente wie Luf tblasen im W asser nach oben. Wir geben den Al gorithmus wie folgt an (vgl. Ottmann/Widmayer, 2002, Seite 83): procedure bubblesort (var seq nat s): var nat i; var bool nichtvertauscht := false; begin while not nichtvertauscht do i := 1; nichtvertauscht = : true; while i ≤ n–1 do if s[i] > s[i+1] then s[i], s[i+1] := s[i+1], s[i]; nichtvertauscht := false endif i := i + 1 endwhile endwhile endproc Der Algorithmus beginnt am Index 1 von s. Sobald sich an Stelle s[i] ein größeres Element als an s[i+1] befindet, vertauschen wir die Elemente (die erste Zeile der if-Anweisung kann als Ab kürzung d es Rin gtauschs von V ariablen au fgefasst werden). D adurch w ird beispielsweise im ersten Durchlauf das größte Element an das Ende der Liste geschoben. Die Variable nichtvertauscht signalisiert, ob in ei nem Durchlauf mindestens eine Vertauschung durch-
188
11 Sortieren und Suchen
geführt wurde. Wenn dies der Fall ist, besteht die Möglichkeit, dass die Liste noch nicht ganz sortiert ist. Sobald nichtvertauscht true bleibt, w urde kein Element mehr vertauscht und die Liste ist komplett sortiert. Der Algorithmus sortiert die Sequenz aufsteigend, wie in Abbildung 11.3 zu erke nnen. Das Beispiel verdeutlicht für die ange gebene Sequenz genau den ersten Durchlauf de r äuße ren while-Schleife.
i = 1:
8
2
4
9
1
6
i = 4:
2
4
8
9
1
6
i = 2:
2
8
4
9
1
6
i = 5:
2
4
8
1
9
6
i = 3:
2
4
8
9
1
6
2
4
8
1
6
9
Abb. 11.3
Sortieren mit bubblesort
Die Laufzeit von bubblesort Zur Berechnung der Komplexität von bubblesort wollen wir etwas a nders vorgehen als bisher. Eine Annahme darüber zu treffen, wie oft die äußere while-Schleife durchlaufen wird, ist schwierig, da nichtvertauscht zu true ausgewertet wird, wenn die Sequenz s aufsteigend sortiert ist. Falls dies anfangs der Fall ist, liegt der best case vor: T(n) = n – 1 = O(n). Es erfolgt keine Vertauschung und de r Algorithmus bric ht nac h einem Durc hlauf ab. Im schlechtesten Fall ( s ist anfangs absteige nd sortiert) m uss für je des der n Elem ente ein Durchlauf mit n–1 Vergleichen durchgeführt werden. Daher ist die worst case-Komplexität T(n) = n · (n – 1) = O(n2). Ein Einwand wäre nun, dass der schlechteste Fall sehr selten auftritt. Darum sollte die Laufzeit wesentlich besser sei n. Wir verzichten zwar an dieser Stelle auf den Beweis, aber es ist nachweisbar, dass bubblesort im Durchschnittsfall ebenfall s die Komplexität O(n 2) hat. Der Algorithmus erreicht nur dann gute Laufzeiten, wenn die Sequenz fast vollständig vorsortiert ist (vgl. Ottmann/Widmayer, 2002).
11.4
Quicksort
Bisher kennen wir drei S ortierverfahren mit quadratischer Laufzeit. Dies erscheint bei großen n doch als sehr aufwendi g. Wir betrachten nun ein Verfahren, welches im Durchschnitt eine wesentlich bessere Laufzeit hat. Es nennt sich quicksort.
11.4 Quicksort
189
quicksort arbeitet mit der Strategie „Divide-and- Conquer“ (Teile und He rrsche). Diese Strategie zerteilt ein Problem so lange in kleinere Teilaufgaben, bis die Lö sungen trivial sind. Zur Berechnung der Gesamtaufgabe setzt sie die Teilergebnisse wieder zusammen. Der Algorithmus verwendet drei Funktionen, die die Laufzeit O(n) haben. Wir geben sie aber nicht genauer an (vgl. Broy, 1998, Band 1, Seite 128). function lowerpart (seq nat s, nat a): seq nat function equalpart (seq nat s, nat a): seq nat function higherpart (seq nat s, nat a): seq nat Als Eingabe erhalten die Funktionen eine Sequenz s von natürlichen Zahlen und eine natürliche Zahl a. lowerpart gibt alle Zahlen zurück, die kleine r als a sind. equalpart gibt alle Zahlen zurück, die gleich a sind. higherpart gibt alle Zahlen zur ück, die gr ößer als a sind. Die Laufzeiten er geben sic h a us dem linearen Durchlauf von s. Für jedes Element ist ein Vergleich nötig, damit es in lowerpart, equalpart oder higherpart eingereiht werden kann. Damit können wir den Algorithmus angeben: function quicksort (seq nat s): seq nat return if length(s) ≤ 1 then s else quicksort(higherpart(s, first(s))) ○ equalpart(s, first(s)) ○ quicksort(lowerpart(s, first(s))) quicksort s ortiert die Seque nz absteige nd. Die Funktion length be stimmt die Länge einer Sequenz. Durch das A ufspalten von s mit den drei Funktionen erhalten wir dr ei Sequenzen, die echt kleiner sind als die ursprüngliche („Di vide“). Die equalpart-Teile b rauchen nicht sortiert werden. Die beiden anderen stellen hingegen zwei weitere Sortierprobleme dar, welche wir durch je einen weiteren rekursiven Aufruf behandeln („Conquer“). ○ setzt die Resultate der Teilaufgaben zur Lösung des Gesamtproblems zusammen. Zur Verdeutlichung dient das Beispiel aus Abbildung 11.4.
8 9
2
4
8
2 4 6
Abb. 11.4
9
1 4
6
6 1 2
6 1
4
Sortieren mit quicksort
190
11 Sortieren und Suchen
Jede Ebene in der Abbildung zeigt das Aufspal ten der Liste durch die dr ei Hilfsfunktionen. Betrachten wir nur die Blätter des Aufrufbaumes und lesen von links nach rechts, so erhalten wir die sortierte Sequenz. Die Laufzeit von quicksort Wir analysieren quicksort mit zwei verschiedenen Annahmen. Einerseits nehmen wir an, die Eingabesequenz ist derart strukt uriert, dass das erste Elem ent im mer echt größer oder echt kleiner als fast alle anderen Elemente von s ist. In jedem „Divide“-Schritt bearbeitet dann der eine rekursive Aufruf den Großteil des Restpr oblems und der andere endet sehr schnell, da fast kei ne Elemente enthalten sind. Im zwe iten Fall ergibt je der „Divi de“-Schritt zwei Se quenzen, die je ungefähr die Hälfte der Elemente der Ursprungssequenz enthalten. Für den ersten Fall könne n wir einen rekursiven Aufruf vernachlässigen, da dieser nach unserer Annahme schnell terminiert und somit nur unwesentlich zur Laufzeit beiträgt. Wir erhalten also: T(n) = T(n–c) + 3n. Nach Annahme ist die Konst ante c bei jedem Schritt sehr klein (c m ist wege n der A bbruchbedingung der while-Schleife nicht möglich), so nehmen wir an, dass der and-Operator den gesamten Ausdruck zu false auswertet und der Zugriff auf s[j+1] nicht erfolgt (an diesem Index ist möglicherweise kein Zugriff möglich). Wenn s[i] < s[j] (Vaterknoten ist kleiner als Kindknoten), so m üssen wir die beiden Elemente tauschen und fortfahren (beim getauschten Kind j). Im anderen Fall (s[i] ≥ s[j], Heap-Bedingung gilt wieder) können wir das Versickern beenden. Die Laufzeit von siftdown Die Laufzeit von siftdown ist bestimmt durch die Tiefe des Baumes, der den Hea p repräsentiert. Wir bewegen ein Element über ei ne Kante des Baumes weiter bis spätestens ein Blatt erreicht ist (außer die Heap-Bedingung ist vorher erfüllt). Dam it gilt für die Komplexität im schlimmsten Fall O(log n). Begründung: Um n Elem ente in einem vollständige n Binärbaum unterz ubringen, m uss der Baum die logarithmische Tiefe ⎣log2 n⎦ haben. Beispiel: n = 7 ⇒ Höhe h = 2.
11.5.2
Der Heapsort-Algorithmus
Zum Sortieren einer Sequenz der Länge n wandeln wir diese mit siftdown in einen Heap um. Wir beginnen in der Mitte der Seque nz, da die zweite Hälfte der Folge die Elemente enthält, die im äquivalenten vollständigen Binä rbaum keine Kinder haben (sie könne n also nicht versickert werden), und das größte Element so am effizientesten an die Wurzel transportiert wird (wir bauen den Heap von unten auf). Das erste Elemen t ist n un das größte und das Sortieren kann beginnen. Wir tauschen es m it dem letzten der Sequenz und führen siftdown für die erst en n–1 Elemente aus, um aus der verbleibenden Sequenz wieder einen Heap zu erzeugen. Diese Schritte wiederholen wi r mit den ve rbleibenden n–1 Elementen, bis die Sequenz aufs teigend sortiert ist (vgl. Ottmann/Widmayer, 2002, Seite 103). procedure heapsort (var seq nat s): var nat i := n/2; begin while i ≥ 1 do siftdown(s, i, n); i := i – 1 endwhile i := n; while i ≥ 2 do s[1], s[i] := s[i], s[1];
11.5 Heapsort
197
siftdown(s, 1, i–1); i := i – 1 endwhile endproc Die erste Wiederholungsanweisung generiert aus der Se quenz eine n He ap und die zw eite führt das Sortieren aus. Als Beispiel sortieren wir wieder die bekannte Liste. Abbildung 11.9 veranschaulicht die Erzeugung eines Heaps (erste while-Schleife). Die obere n Pfeile zeigen die Kinder des aktuell betrachteten Knotens an (gekennzeichnet durch den unteren Pfeil).
i = n/2 = 3:
i = 2:
Abb. 11.9
8
2
4
9
1
6
8
2
6
9
1
4
8
2
6
9
1
4
8
9
6
2
1
4
i = 1:
8
9
6
2
1
4
9
8
6
2
1
4
Vorbereiten einer Folge für heapsort
Der Heap lässt sich nun sorti eren, wie Abbildung 11.10 zeigt (zweite while-Schleife). In der Zeile mit Index i sehen wir die Sequenz nach dem Tauschen von erstem und letztem Element (den Pfeil beachten). Die darauf folgende Zeile erhalten wir nach Versickern des ersten Elements bis zum Index i–1 (sie erfüllt bis dorthin die Heap-Bedingung).
i = 6:
i = 5:
Abb. 11.10
9
8
6
2
1
4
4
8
6
2
1
9
8
4
6
2
1
9
1
4
6
2
8
9
6
4
1
2
8
9
Sortieren mit heapsort
i = 4:
i = 3:
i = 2:
2
4
1
6
8
9
4
2
1
6
8
9
1
2
4
6
8
9
2
1
4
6
8
9
1
2
4
6
8
9
198
11 Sortieren und Suchen
Die Laufzeit von heapsort Wie wir bereits wissen, hat siftdown die Laufzeit O(log n). Die erste while-Schleife führt n/2 Schritte aus. Also gilt: T(n) = n/2 ⋅ log n = O(n ⋅ log n). Die zweite benötigt n–1 Durchläufe. Es gilt ebenfalls: T(n) = (n – 1) ⋅ log n = O(n ⋅ log n). Wodurch wir die worst case-Komplexität von heapsort bestimmen können: T(n) = O(n ⋅ log n). Wir kennen nun die wichtigsten Sortierverfahren, die uns einen Einblick in die unterschiedlichen M öglichkeiten des Sortierens ge ben. Eine weitere, wichtige P roblemgruppe sind die Verfahren zum Durchsuchen von Se quenzen, Arrays und Listen. Im Folgenden werden wir einige davon kennen lernen.
11.6
Sequentielle Suche
Wir beginnen wiederum mit dem einfachsten, naiven Fall. Gegeben sei e ine unsortierte Sequenz s, in de r nac h ei nem Element gesuc ht we rden s oll. Wir sprec hen von sequentieller oder linearer Suche, wenn wir alle Elem ente de r Se quenz durchlaufen und im Erf olgsfall oder am Ende der Se quenz stoppen, je nachdem ob das ge suchte Element gefunden wurde oder nicht. Finden wir das zu suchende Elemente nicht, so geben wir false zurück. Eine geeignete Wahl des Rückgabewertes hängt in de r Praxis von der verwendeten Umgebung und den Anforderungen der Anwendung a b. Ein Date nelement besteht norm alerweise aus einem Schl üssel (key) und einem Wert (value). Für die sequentielle Suche und die später behandelten Suchalgorithmen ist lediglich inte ressant, ob ein Schlüssel in der zu durc hsuchenden Sequenz enthalten ist ode r nicht. Deshalb genügt es, nur true oder false zurückzugeben, Wertelemente nicht zu betrachten und nur auf Schlüsselsequenzen zu suchen. function sequential (seq nat s, nat k): bool return if isempty(s) then false elseif k = first(s) then true else sequential(rest(s), k) Ein Beispiel mit Suche nach k = 4 zeigt die folgende Abbildung 11.11.
11.7 Binäre Suche
199
8
2
4
9
1
2
4
9
1
6
4
9
1
6
Abb. 11.11
6
Sequentielle Suche nach k = 4
Die Laufzeit von sequential Es ist offensichtlich, dass wir im schlimmsten Fall einm al die Sequenz durchlaufen und dabei 2n+1 Vergleiche ausführen. Wenn wir im Mittel eine Gleichverteilung de r Elemente in der Sequenz annehmen, so gilt:
1 n 2 n ⋅ (n + 1) ⋅ ∑ 2i = ⋅ = n + 1. n i =1 n 2
11.7
Binäre Suche
Natürlich gibt es andere Verfahren, die da s gesuchte Element schneller finde n. Dazu gehen wir davon aus, dass die Sequenz s aufsteigend sortiert ist. Die binäre Suche verwendetdie Strategie „Divide-and-Conquer“. Rekursiv können wir sie wie folgt angeben. Dabei sei s die sortierte Sequenz der Länge n und k das gesuchte Element (vgl. Ottmann/Widmayer, 2002, Seite 166): 1. Falls s leer ist, beenden wir die Suche ohne Erfolg; ansonsten betrachten wir das Element s[m] an der mittleren Position m in s. 2. Falls k < s[m], suchen wir k in de r linken Teilsequenz s[1], …, s[m–1] mit demselben Verfahren. 3. Falls k > s[m], suchen wir k in der rechten Teilsequenz s[m+1], …, s[n] mit demselben Verfahren. 4. Sonst gilt k = s[m] und das gesuchte Element ist gefunden. Bei der Umsetzung des Ver fahrens gebe n wir die Grenzen des nach k zu durchs uchenden Bereichs ( l und r) gesondert an. Die Suche erfolgt auf der Sequenz s der Länge n. Das Ergebnis „vorhanden“ oder „nicht vorhanden“ geben wir über die Hilfsvariable result zurück. function binary (seq nat s, var nat l, r, nat k): bool var bool result := true; var nat m := (l+r)/2; begin
200
11 Sortieren und Suchen
if l > r then result := false else if k < s[m] then result := binary(s, l, m–1, k) elseif k > s[m] then result := binary(s, m+1, r, k) endif endif return result endfct Falls die linke Gre nze größer als die rechte ist ( l > r), so endet die Suche erfolglos. Wenn k sowohl kleiner als auch größer als s[m] ist, so ha ben wir das Element gefunden. result wird mit true initialisiert, wodurch wir keinen weiteren else-Zweig mehr benötigen. Gegeben sei s aus Abbildung 11.12. Der Aufruf von binary sieht dann wie folgt aus: var nat i := 1, n := 6; nat k := 8; var bool result; result := binary(s, i, n, k);
m = 3:
1
2
4
6
8
9
m = 5:
1
2
4
6
8
9
Abb. 11.12
Binäre Suche nach k = 8
Die Laufzeit von binary Wenn wir uns an die Analyse von siftdown aus Abschnitt 11.5.1 erinne rn, stellen wir fest, dass sich beim Versicke rn die zu erreiche nden Knoten im Teilbau m p ro Schritt halbieren. Bei binary müssen wir in jedem Durchlauf nur die Hälfte der Elemente betrachten. Aus dieser Äquivalenz ergibt sich die Komplexität O(log n).
11.8
Interpolationssuche
Bei der Binärs uche betrachten wir ei n zu untersuchendes Element nur i n Abhängigkeit von der Länge des sortierten Suchbereichs. Wir ignorieren die Werte de r Schlüssel zur Bere chnung der Suchposition (vgl. Ottmann/Widmayer, 2002, Seite 172). Suchen wi r be ispielsweise in einer großen Menge sortierter Karteikarten nach einer Karte, deren eindeutige Bezeichnung mit „D“ anfängt, so beginnen wi r die Suche im vorderen Be -
11.8 Interpolationssuche
201
reich der Karteikartenbox. Ist der Schlüssel im Alphabet am Ende angesiedelt (z.B. Buchstabe „X“ ), s o sc hlagen wi r in der B ox im hinter en Teil na ch. Bevor wir also tatsächlich zu suchen be ginnen, ve rsuchen wir, die P osition des Suc helements zu schätzen. Ge nau dies geschieht auch bei der Interpolationssuche. Erinnern wir uns noch einmal an die binäre Suche. Wir berechnen den Index m für den Vergleich der Schlüsselwerte wie folgt: m=
l+r 1 = l + ⋅ (r − l). 2 2
Bei genauerer Überlegung ist der Faktor ½ nichts anderes als eine Schätzung der Position des Schlüssels. Es wird davon ausgegangen, dass sich das zu fi ndende Element in de r Mitte der Sequenz befindet. In unsere n Karteikarten beginnen wir nicht in de r Mitte, sonde rn nutzen die Schlüsselwerte zur V orbestimmung des Inde xes. Die Interpolationss uche setzt dies wie folgt um (k sei das gesuchte Element):
⎢ k − s[l] ⎥ ⋅ (r − l)⎥. m =l+⎢ ⎣ s[r ] − s[l] ⎦ Bei ungl ücklicher Verteilung der Sc hlüsselwerte ka nn auch eine A ufrundung nötig sein (Verwendung von ⎡.⎤ an statt ⎣.⎦). Abbildung 11.13 v erdeutlicht, dass die Größ e d es obigen Quotienten eine Aussa ge darüber trifft, wie nah k an s[r] liegt. Die beiden Randfälle zeigen dies: Ist k = s[l], so ist er 0, bei k = s[r] ist er 1.
k s[l]
s[r]
Abb. 11.13
Geschätzte Position des Suchschlüssels k
Beispiel: In Abbildung 11.14 suchen wir nach dem Schlüssel k = 8. Damit ergibt sich für m: ⎢8 −1 ⎥ m = 1+ ⎢ ⋅ (6 − 1)⎥ = 5. ⎣9 −1 ⎦
m = 5:
Abb. 11.14
1
2
4
6
8
9
Interpolationssuche nach k = 8
202
11 Sortieren und Suchen
Die Laufzeit der Interpolationssuche Im Mittel führt die Interpolationssuche log2 log2 n + 1 Schlüsselvergleiche aus . Die s gilt jedoch nur, wen n d ie n Schlüssel unabhängig und gleich verteilte Zufallszahlen si nd. Im schlimmsten Fall benö tigen wir sogar lin ear viele Schlüssel vergleiche ( vgl. Ottmann/Widmayer, 2002, Seite 172).
11.9
Binärbaumsuche
Neben de n bis lang vorgestell ten Suc hverfahren über List en kö nnen wir natü rlich au ch in Binärbäumen suchen. Dazu betrachten wir zunächst folgende Definition. Definition: (binärer Suchbaum) (siehe G üting, 1992, Se ite 111) Ein binärer Baum heißt binärer Suchbaum, wenn für den Schlüssel k in der Wurzel eines jeden Teilbaumes gilt: 1. Alle Schlüssel im linken Teilbaum sind echt kleiner als k. 2. Alle Schlüssel im rechten Teilbaum sind echt größer als k. Beispiel: Abbildung 11.15 zeigt links oben einen balancierten, links unten einen beliebigen und rechts einen degenerierten binären Suchbaum.
6 1 2
9 2
1
4
8 4
2 6 1
6 8 4
8 9 9
Abb. 11.15
Drei binäre Suchbäume
11.10 Aufgaben
203
Zum Suchen von Elem enten in einem binäre n Suchbaum wird die St ruktur de r Sc hlüssel ausgenutzt. Je nachdem, ob der gerade behandelte Schlüssel größer oder kleiner als die Wurzel ist, wählt die Operation den rechten oder linken Teilbaum. function binarytree (bintree nat b, nat a): bool return if isempty(b) then false elseif a < root(b) then binarytree(left(b), a) elseif a > root(b) then binarytree(right(b), a) else true Die Beispiele zeigen, das s diese Art des Suchens unter Umständen sehr ineffizient ist. Die Ausartung des Baumes hängt maßgeblich davon ab, in welcher Reihenfolge welche Operationen ausgeführt werden und ob die Schlüssel beim Einfügen geordnet sind oder nicht (eine Ordnung führt in der Regel zu einem degenerierten Suchbaum). Die Laufzeit der Binärbaumsuche Die Ef fizienz der Operationen auf ei nem bi nären S uchbaum hängt davon a b, wie gut der Baum balanciert ist. Es ist leicht zu er kennen, dass der Z ugriff in eine n balancierten Suchbaum wegen der logarithmischen Tiefe i n O(log n) geschieht. Liegt ein degenerierter Baum vor, der im äußersten Fall einer Liste entspricht, so beträgt die Laufzeit eines Zugriffs O(n). Im nächsten Abschnitt werden wir Suchprobleme aus einer etwas anderen Sichtweise kennen lernen. Für di e vier auf Ve rgleichen basierenden Suc hverfahren sei zum Abschluss noc h erwähnt, dass sie beim Auftreten von identischen Schlüsseln im Suchraum immer nur den als ersten gefundenen zurückgeben. Wir erhalten also keine Auskunft über die Anzahl der aktuell vorhandenen gleichen Schlüssel im Suchbereich.
11.10
Aufgaben
Aufgabe11.1: Gegeben sei die Seque nz 29, 10, 2, 90, 117, 33, 73, 42, 8. Sortiere n Sie diese Sequenz anha nd de r Verfahren S ortieren durc h Ei nfügen bzw. A uswählen, B ubblesort, Quicksort und Heap sort. Tipp: Bei Heap sort k ann es h ilfreich sein, fü r die Sequ enz Zeich nungen des äquivalenten Heaps anzufertigen. Aufgabe11.2: Die Algorithm en Sortieren durc h Ein fügen und Auswählen sortiere n absteigend. Ge ben Sie je weils einen Al gorithmus an, der m it dem selben V erfahren a ufsteigend sortiert. Aufgabe 11.3: Realisieren Sie Bubblesort mit absteigender Sortierung. Aufgabe 11.4: Die obige Variante von Bubblesort betrachtet bei je dem Durchlauf der äußeren Schleife alle Einträge der zu sortierenden Sequenz. Mit jedem Schleifendurchlauf wächst der sortierte Teil am Ende der Sequenz um eins (das jeweils maximale Element wird soweit
204
11 Sortieren und Suchen
wie m öglich an das Ende de r Liste versc hoben). Dies be deutet, dass da s Vergleichen aller bereits sortierten Elemente im Schleifendurchlauf unnötig ist. a)
Realisieren Sie eine Optimierung von Bubblesort, bei der die innere Schleife jeweils nur bis zum Beginn des sortierten Bereichs sortiert.
b) Hat diese Optimierung eine Verbesserung der worst case-Komplexität zur Fol ge? Tipp: Ve rsuchen Sie eine Ä quivalenz zu r Analyse von Sortieren dur ch Einfüg en herzustellen. Aufgabe 11.5: Realisieren Sie die linear rekursiven Hilfsfunktion von Quicksort lowerpart, equalpart, higherpart und length. Aufgabe 11.6: Realisieren Sie Quicksort mit aufsteigender Sortierung. Aufgabe 11.7: Quicksort teilt eine Sequenz in drei Teile. Dazu verwendet es ein Element aus der Liste, das so genannte Pivot-Element. Die obige Variante von Quicksort wählt immer das erste Element als Pivot-Element aus. Bei sc hlechter Verteilung der Schlüsselwerte kann dies zu einer ungleichen Teilung der Liste führen (mit der Folge einer Laufzeit von O(n2)). a)
Überlegen Sie sich informell ein Verfahren, das das Pivot-Element in Abhängigkeit von den aktuellen Schlüsselwerten bestimmt.
b) Formulieren Sie Anforderungen an ein optimal gewähltes Pivot-Element. Aufgabe 11.8: Heaps ort sort iert aufsteige nd. Realisieren Sie eine abst eigend sortierende Variante von Heapsort. Tipp: Der verwendete Heap ist ein so genannter Maximum-Heap mit dem maximalen Elem ent in der Wurzel eines (Teil-) Baum es. Defini eren Sie die HeapBedingung für den Minimum-Heap mit dem minimalen Element in de r Wurzel eines (Teil-) Baumes. Passen Sie danach – wenn nötig – die Prozeduren siftdown und heapsort an. Aufgabe 11.9: In Listen m it natürlichen Zahl en ist es m öglich, dass eine Zahl m ehrmals in der Liste auftritt. Überprüfen Sie die behandelten Sortierverfahren, ob diese Listen mit identischen Zahlen sortieren können. Begründen Sie Ihre Antwort. Aufgabe 11 .10: Gegeb en sei die Sequ enz 29 , 10, 2, 90, 117, 33, 73, 42, 8 . Su chen Sie d as Element 33 m it seque ntieller, binä rer, I nterpolations- un d Binärbaumsuche. Wen n nötig, sortieren Sie die Sequenz oder wandeln Sie sie in einen beliebigen binären Suchbaum um.
12
Hashing
Die Algorithmen in den Abschn itten 11.6 bis 11.9 realisieren Verfahren, die bestimmte Elemente über ihre Identifikatoren (Schlüssel, engl. keys) auffinden. Es liegt auf der Hand, dass für Datenstrukturen egal welcher Art, drei Operationen von essentieller Bedeutung sind: • eine Einfüge-Operation (insert), • eine Lösch-Operation (delete) und • eine Such-Operation (search). Diese Operationen führen wir in versc hiedenen Kombinationen nacheinander auf einer Datenstruktur aus. Dabei fügen wir neue Schlüssel ein und suchen irgendwann nach ihnen. In der Reg el v erwenden wi r nu r wenige der zu r Verfügung steh enden Schlüssel (z.B. einige Zahlen aus der Menge der natürlichen Zahlen oder Wörter über einem Alphabet). Die bisherigen Suchverfahren erlauben das Auffinden des gesuchten Schlüssels nur durch Vergleiche. Bei geeigneter Darstellung und Organisation der Schl üssel ist es möglich, die Position des gesuchten Schlüssels zu berechnen. Wir sprechen vom so genannten Hashing. Hashverfahren kommen beispielsweise in Compilern und Datenba nken zum Einsatz. Die in einem Programm deklarierten Variablen können durch Hashverfahren verwaltet werden. Die Speicherung des Inhalts ei ner Va riablen e rfolgt in eine r oder m ehreren Speicherzellen, die durch Hardwareadressen gekennzeichnet sind. Das Mapping zwischen Namen von Variablen und S peicheradressen e rfolgt durc h Hashing. Bei Datenbanke n beste ht die Möglichkeit, einen Join von Tabellen durch Hashing zu realisieren (vgl. Date, 2000). An dieser Stelle sei noch auf die Literatur Ottmann/Widmayer (2002, Seit en 183ff) verwiesen, welche Ideengeber für den Inhalt dieses Kapitels war.
12.1
Grundlagen
Gegeben sei ein Unive rsum (Familie von Mengen) K von Schlüsseln. Aus K verwenden wir eine (kleine) Teilmenge K. Ein Datenelement mit dem Schlüssel k ∈ K speichern wir in einer Sequenz mit den Indizes 0, …, t–1, genannt Hashtabelle (siehe Abbildung 12.1).
206 12
Hashing
htable: 0
Abb. 12.1
1
2
3
4
5
6
Eine Hashtabelle mit Länge t = 7
Die Berechnung des Indexes der Hashtabelle erfolgt durch die Funktion h, die Hashfunktion. Die Has hfunktion h : K → {0, …, t–1} bi ldet jede n Sc hlüssel k auf ei nen Index h(k) mi t 0 ≤ h(k) ≤ t–1 ab. h(k) heißt dann Hashadresse. In der Regel ist K eine sehr kleine Teilmenge von K. Daher ist di e Hashfunktion norm alerweise nicht inje ktiv, sondern weist de rselben Hashadresse verschiedene Schlüssel zu. Gilt für die Schl üssel k und k′: h(k) = h(k′), so sprechen wir von Synonymen. Werden zwei Synonyme in der Schlüsselmenge K verwendet, so e rhalten wir beim Ablegen des zweiten Schlüssels eine Adresskollision (wenn vorher der erste Schlüssel eingefügt wurde). Ohne Synonym e in K kann jeder Schlüssel an der ent sprechenden Stelle in der Ha shtabelle abgelegt werden. Adresskollisionen im anderen Fall müssen wir durch geeignete Behandlung auflösen. Beispiel: Die Funktion h mod t (Rest b ei ganzzahliger Division) ist eine Hashfunktion für natürliche Zahlen (Genaueres behandeln wir im folgenden Abschnitt). Beispiel: Die Hashfunktion h mod 7 erzeugt beim Einfügen der Schlüssel 13 und 20 in die Hashtabelle eine Adresskollision (13 mod 7 = 20 mod 7 = 6). Beispiel: Bildet eine gewählte Hashfunktion für die Schlüssel i1, i2 und i3 das erste Zeichen durch den ASCII-Code ab, so erhalten wir in jedem Fall eine Adresskollision. Daraus ergeben sich zwei Anforderungen an Hashverfahren: 1. Die Hashfunktion soll möglichst wenig Adresskollisionen verursachen. 2. Adresskollisionen müssen wir effizient auflösen. Im Folgenden lernen wir Has hfunktionen und Verfahren zur Auflösung von Adresskollisionen näher kennen. Leider gibt es keine noch so gute Hashfunk tion, die Kollisionen gänzlich vermeidet. Im schlimmsten Fall passiert es daher, dass Realisierungen von search, insert und delete ineffizient sind. Im Mittel sind sie jedoch wesentlich besser als Verfahren m it Schlüsselvergleichen. Beispielsweise ist die Zeit zum Suchen ei nes Sc hlüssels una bhängig von der Anzahl der verwendeten Schlüssel (Voraussetzung: Genügend Speicherplatz zum Speichern der Schlüssel vorhanden). Wir werden auch sehen, dass manche Hashverfahren nur gut arbeiten, wenn wir wenige delete-Operationen ausführen. Speichert die Hashtabelle de r Größe t ge rade n Sc hlüssel, so hat sie de n Belegungsfaktor α = n/t. Die Anzahl der Schritte für die drei Operationen hängt maßgeblich von diesem Faktor ab.
12.2 Eine einfache Hashfunktion
207
Bei der Effizienzbet rachtung geben wir für die durchschnittliche Laufzeit der drei Operationen jeweils zwei Erwa rtungswerte an. Da für insert und delete zuvor eine erfolglose beziehungsweise erfolgreiche Suche durchgeführt werden muss, sind die Laufzeiten dieser beiden Operationen durch den Erwartungswert von search bestimmt. Cn ist der Erwartungswert für die Anzahl der betrachteten Einträge der Hashtabelle bei erfolgreicher Suche, C′n der Erwartungswert für die Anzahl der betrachteten Einträge der Hashtabelle bei erfolgloser Suche. Wir bet rachten ausschließlich halbdynamische Has hverfahren, bei dene n die G röße t der Hashtabelle fest und nicht veränderbar ist.
12.2
Eine einfache Hashfunktion
Um den Vorteil von Hashverfahren nicht durch aufwendige mathematische Formalismen zu relativieren, sollte eine Hashfunkti on einfach und effizient berechenbar sein. Darüber hinaus ist zur Verm eidung von Adressk ollisionen eine gleichmäßige Verteilung der abzulegenden Schlüssel im Speicherbereich de r Ha shtabelle wüns chenswert. Diese Gleichverteilung der Hashadressen über 0, …, t–1 wollen wir auch dann erreichen, wenn die Schlüssel aus K nicht gleich verteilt sind. Beispiel: Das Verwenden von Variablen wie i, i1, i2, j, j1, j2, k, k1 und k2 im Quellcode von Programmen stellt eine nicht gleich verteilte Eingabemenge dar. Eine Möglichkeit zur Erzeugung der Hashadresse h(k) mi t 0 ≤ h(k) ≤ t–1 ist die DivisionsRest-Methode. Wir haben sie einem Beispiel schon betrachtet (sei Schlüssel k ∈ N0): h(k) = k mod t. Diese Hashfunktion arbeitet in der Praxis ausgezeichnet, wenn wir über t gewisse Annahmen machen. Darauf wollen wir aber nicht genauer eingehen (mehr dazu in Ottmann/Widmayer, Seite 185).
12.3
Perfektes Hashing
Nehmen wir nun an, dass die Anzahl der zu speichernden Hashadressen kleiner oder gleich der Länge der Hashtabelle ist. Formal bedeutet dies, dass für die Teilmenge K von K |K| ≤ t gilt. Beim Ablegen der Schlüssel können wir aufgrund dieser Rahm enbedingung ein kollisionsfreies S peichern e rreichen. Die Angabe einer i njektiven Hashfunktion h auf die Wertemenge {0, …, t–1} ist m öglich. Aber K ist unbe kannt und wir wissen auch nicht, ob es sich verändert. Diese bei den Punkte verhindern di e Definition einer Hashfunktion, welche injektiv sein soll.
208 12
Hashing
Beispiel: Sei nun K bekannt und fest (also nicht veränderbar). Wenn wir alle Schlüssel k ∈ K nach einem festgelegten Schema ordnen und die daraus resultierende Reihenfolge nummerieren, so entspricht j ede Nummer zu einem Schlüssel einer Hashadresse. Dieser Fall tritt beispielsweise bei der A bbildung von reservierten Schlüsselwörtern (Symbole) einer Programmiersprache in eine Hashtabe lle auf (siehe Abbildung 12.2). Jedes Symbol erhält seine eigene, feste Hashadresse.
while function begin end var return htable:
begin
end 0
Abb. 12.2
function 1
0 1 2 3 4 5
Sortieren
return 2
begin end function return var while
var 3
while 4
5
6
Perfektes Hashing
Sei h nun eine Hashfunktion, die den Voraussetzungen „fest und bekannt“ genügt. h produziert dann keine Adresskollisionen und heißt dadurch perfekte Hashfunktion. Im Regelfall werden wir diese Anna hmen nicht halten können, da die Teilm enge K ⊆ K unbekannt ist. Adresskollisionen werden auch dann auftreten, wenn |K| ≤ t gilt. Es existieren Verfahren zur Generierung perfekter Hashfunktionen, auf welche wir hier nicht näher eingehen, die aber für den interessierten Leser in Mehlhorn (1988) nachlesbar sind.
12.4
Universelles Hashing
Betrachten wir noch einmal das Beispiel au s Abschnitt 12.2 mit den Na men von Vari ablen im Quellcode. Die Schlüssel sind nicht gleich ve rteilt und sobal d wir uns auf eine spezielle Hashfunktion festlegen, werden wir ohne Probleme viele Schlüssel finden, die Adresskollisionen hervorrufen. Kennen wir die tatsächliche Verteilung der Eingaben nicht, so ist es schwierig, eine passende Hashfunktion zu wählen. Wir müssen also erreichen, dass auch nicht gleich verteilte Schlüssel gleich über die Hashtabelle verteilt werden. Dazu verwenden wir universelles Hashing. Universelles Hashing wählt die Hashfunktion h zufällig und gleich verteilt aus einer Menge H von Hashfunktionen, welche zwei verschiedene Schlüssel nur zu einem gewissen Anteil auf gleiche Hashadressen abbilden. Diese Vorgehensweise gehört zum Verfahren selbst und sc hließt Präferenzen von Seiten der Anwender bei der Festlegung von K a us. Bei de r Be stimmung von Has hadressen über eine ge wählte
12.4 Universelles Hashing
209
Hashfunktion h ∈ H können nach wie vor viele Kollisionen auftreten. Im Durchschnitt aller h ∈ H geschieht dies jedoch nicht mehr. Beispiel: Die Funktione n in Abbildun g 12.3 seien eine K lasse H von Hashfunktionen. Benutzen wir die se zum Einfügen von vier verschiedenen Schlüsseln k1, k2, k3 un d k4 in ein e Hashtabelle und nehm en an, dass alle vier Schlüssel für ein spezielles hi Adresskollisionen verursachen, so ziehen wir mit Wahrscheinlichkeit von je 1/84 viermal die gleiche Funktion hi mit i = 1, …, 8 und lösen Kollisionen aus. Andererse its erhalten wi r mit der Wahrscheinlichkeit 1 – 8 · 1/84 ir gendeine andere K ombination von Hashfunktionen, bei de nen die Wahrscheinlichkeit von drei nacheinander auftretenden Kollisionen kleiner als 1 ist.
H h1
h4 h6
h5 h8
h3 h7
h2
Abb. 12.3
Eine universelle Klasse von Hashfunktionen
Definition: (universelles Hashing) ( vgl. O ttmann/Widmayer, 2 002, Sei te 18 7) Sei H eine endliche Kollektion von Hashfunktionen, so dass jede Funktion aus H jeden Schlüssel aus K auf eine Hashadresse aus {0, …, t–1} abbildet. H heißt universell, wenn für je zwei verschiedene Schlüssel k1, k2 ∈ K gilt:
{h ∈ H : h (k1 ) = h (k 2 )} H
1 ≤ . t
Wir formulieren etwas anders : H ist unive rsell, wenn für jedes Paar von zwei nicht ide ntischen Sc hlüsseln höchstens der t-te Teil aller Funktionen aus H die gleiche Hashadresse berechnen. Die Wahrscheinlichkeit, dass die Hashadressen für zwei fest gewählte, verschiedene Schlüssel k1 und k2 eines universellen Hashverfahrens kollidieren, beträgt maximal 1/t. Beispiel: Die Schlüssel 14 und 21 fügen w ir in die Has htabelle der Lä nge t = 7 ein. Wir benutzen eine Menge H von Hashf unktionen. Zuerst wählen wir die Hashfunktion k mod 7 aus H zufällig aus und füge n 14 in die Hashtabelle. Würden wir keine Menge H von Hashfunktionen zur Bestimmung der Has hadresse heranziehen, so ve rwenden wir zum Einfügen von 21 wieder die gleiche Funktion. Auf diese Art und Weise erhalten wir mit Wahrscheinlichkeit 1 eine Adresskollision. Da wir unsere Abbildung aber über eine universelle Klasse H
210 12
Hashing
von Has hfunktionen be rechnen, wi rd m it Wahrscheinlichkeit von m indestens 1 – 1/t eine andere Hashfunktion gewählt, die den Schlüssel 21 auf eine andere Hashadresse setzt. Beispiel: (siehe Ottmann/Widmayer, 2002, Seite 189) Sei |K| = p ei ne P rimzahl und K = {0, …, p–1}. F ür zwei beliebige Za hlen i ∈ {1, …, p–1} und j ∈ {0, …, p–1} sei d ie Funktion hi, j : K → {0, …, t–1} wie folgt definiert: hi, j(x) = ((ix + j) mod p) mod t. Die Klasse H = {hi, j | 1 ≤ i < p ∧ 0 ≤ j < p} bildet eine universelle Klasse von Hashfunktionen. Die Autoren obiger Literatur geben dazu auch einen Beweis an. Wir fassen abschließend zus ammen: Universelles Hashing verteilt hom ogen gewählte Schlüssel so gut wie möglich auf den Adressbereich der Hashtabelle. Der von uns behandelte Fall ist ein Spezialfall des universellen Hashings, es heißt genau genommen 1-universell. Die Verallgemeinerung wi rd als c-universell (c ∈ R) bezeichnet (Nähe res dazu in Me hlhorn, 1988):
{h ∈ H : h (k1 ) = h (k 2 )} H
c ≤ . t
Wir haben nun einen Einblick in die Wahl von Hashfunktionen und können uns der Auflösung von Adresskollisionen zuwenden.
12.5
Chainingverfahren
Fügen wir in die Hashtabelle htable einen Schlüssel k′ an einer Hashadresse ein, an der sich bereits das Synonym k befindet, so ist k′ ein Überläufer und eine Adresskollision liegt vor. Eine Möglichkeit zum Auflösen der Kollision ist die so genannte Verkettung der Überläufer (engl. chaining). Beim Verketten werden die Überläufer in dynamisch anpassbaren Datenstrukturen abgelegt, die in geei gneter Weise mit der Has htabelle verbunden sind. Z u jeder Hashadresse existiert beispielsweise eine lineare Liste, in welche Übe rläufer eingefügt werden. Die Liste wird mit Hilfe von Zeigern mit der Hashtabelle verknüpft (siehe Abbildung 12.4).
12.5 Chainingverfahren
211
htable: 0
Abb. 12.4
1
2
3
4
5
6
Hashtabelle zur Verkettung von Überläufern
Beispiel: Sei die Länge de r Hashtabelle t = 7, das Uni versum K = {0, 1, …, 100} und die Hashfunktion h(k) = k mod t. Zur Kollisionsauflösung benutzen wir das Verketten der Überläufer. Die Reihenfolge der einzufügenden Schlüssel und die dazugehörige Hashadresse zeigt Tabelle 12.1. Sind alle Schl üssel in de r Hashtabelle abgelegt, so ergibt sich die Struktur in Abbildung 12. 5. S obald der Platz in der H ashtabelle bes etzt ist, erzeugt das Verfa hren ein neues Listenelement und hängt es a n das Ende de r Liste für den e ntsprechenden S peicherplatz. Tab. 12.1
Chainingverfahren Schlüssel und Hashadressen
k h(k)
77 13 06
htable:
77
99 16 12
99 0
21
16 1
64
4 46
4 2
3
48
76 21 60
64 1
13 4
5
6
48
76
Abb. 12.5
Hashtabelle nach Verkettung von Überläufern
Das hier angegebene Verfahren heißt auch separates Verketten der Überläufer (engl. separate chaining). Es speichert alle Schlüssel, die keine Überläufer sind, in der Hashtabelle. Es existiert ein weiteres Verfahren namens direktes Verketten der Überläufer (engl. direct chaining). Die Hashtabelle besteht hierbei nur aus Zeigern auf die Überlauflisten. Diese speichern alle einge fügten Schlüssel (Ge naueres dazu in Ottm ann/Widmayer, 2002, Seite 191).
212 12
Hashing
Analyse der separaten Verkettung Wie sc hon a nfang dieses Ka pitels erwä hnt, is t die Effizienz eine s Ha shverfahrens von der erfolgreichen und erfolglosen Suche abhängig. Wir verzichten an dieser Stelle auf die explizite Analyse von se parater Verkettung (vgl. Ottmann/Widmayer, 2002, Seite 194f) und geben nur die beiden Erwartungswerte Cn und C′n für die A nzahl der betrachteten Einträge bei erfolgreicher und erfolgloser Suche an: Cn ≈ 1 +
α 2
C′n ≈ α + e −α .
Daraus ergeben sich die Erwartungswerte durch Einsetzen des Belegungsfaktors für separate Verkettung (siehe Tabelle 12.2). Der Leser stelle sich für das Ve rständnis das Folgende vor. Nähert sich der Belegungsfaktor dem Wert 1, so sind immer mehr Plätze der Hashtabelle bei angenommener Gleichverteilung de r Schlüsselwerte besetzt. W ie wir bereits wissen, lassen sich Kollisionen nicht gä nzlich vermeiden und an der einen oder anderen Adresse wird eine Liste mit ein oder zwei Überläufern hängen. Diese wird mit linearem Aufwand durchsucht, welchen wir zum konstanten Wert der Adressberechnung addieren. Tab. 12.2
Erwartungswerte für separate chaining
Belegungsfaktor α 0.50 1. 0.90 1. 0.95 1. 0.99 1. 1.00 1.
erfolgreiche Suche Cn 250 450 475 495 500 1.
erfolglose Suche C′n 1.110 1.307 1.337 1.362 368
Chainingverfahren funktionieren selbst dann noch, wenn mehr Schlüssel in die Hashtabelle eingefügt werden, als Plätze vorhanden sind. Die echte Entfernung von Einträgen ist m öglich, was bei den im nächsten Abschnitt besprochenen Verfahren nicht so einfach realisierbar ist. Sind an einer Ha shadresse Überläufer vorhanden, so kann ein zu entferne ndes Element durch Umhängen der Zeiger entfernt werden. Ein Nachteil des Verfa hrens liegt darin, da ss wir zusätzlichen Speic herplatz verwe nden, obwohl die Hashtabelle vielleicht noc h gar nicht ganz gefüllt ist. Die Verfahren de r offenen Adressierung benötigen keinen weiteren Speicherplatz.
12.6
Hashing mit offener Adressierung
Anstatt Überläufer in anderen Speicherräumen als der Hashtabelle unterzubringen, versuchen wir bei offenen Hashverfahren eine offene Stelle zu finde n, an der ein Überläufer Platz hat. Wir speichern also nur innerhalb der Tabelle und können folglich maximal t Schlüsselpositionen verteilen. Ist die Position h(k′) für den Sc hlüssel k′ durch de n Sc hlüssel k belegt , so
12.6 Hashing mit offener Adressierung
213
berechnen wir nach einem festen Schema einen noch nicht belegten Platz, um dort k′ abzulegen. Beispiel: Wenn ein Autofahrer früh morgens in die mittelalterliche Innenstadt von Landshut fährt, so findet er ei ne Fülle von freien Parkplätzen. Er kann sofort in den ersten einparken. Im übertragenen Sinn ist also die erste Berec hnung einer Position in der Hashtabelle erfolgreich. Verschläft unser Autofahrer und kann erst mittags seine Erledigungen tätigen, so wird er wenig freie Parkplätze vorfinden und solange an den belegten Par kplätzen vorbeifahren, bis eine Lücke frei ist. Er sondiert die Parkplätze nach einem unbelegten Platz. Findet er im einen Fall eine n freien vor, so parkt er schnell ein (die Sondierung war erf olgreich), wohingegen er im Fall, dass alle Parkplätze belegt sind, seine Suche aufgeben wird (die Hashtabelle ist gefüllt). Das Beispiel verm ittelt das Prinzi p de r offene n Adressi erung. Uns ist in der Regel nicht bekannt, welche Plätze in der Hash tabelle fr ei und belegt sind. W ir benötige n daher ein Schema, nach welchem wir nach und nac h je den m öglichen S peicherplatz auf „ frei“ oder „belegt“ testen können. Dies geschieht am besten über eine Folge, eine Sondierungsfolge, die dies für jeden Schlüssel erledigt. Zum Einfügen ei nes Schlüssels k verwenden wir dann den ersten freien Platz in der Sondierungsfolge. Bei genauerer Überlegung stellen wir fest, dass das Löschen von Elementen aus der Hashtabelle nicht unproblematisch ist. Nehm en wir einen Schlüssel k aus de r Tabelle, so sind die Schlüssel, die durch Sondierung verschoben worden sind, nicht mehr erreichbar. Der Platz ist frei und som it kann das Verfahren nicht erkennen, wie viele Schlüssel noch in der Sondierungsfolge sind. Eine Lösungsmöglichkeit dieses Pr oblems ist die Reorganisation der H ashtabelle nach dem Entfernen, w odurch die Effi zienz bede utend be einträchtigt wird. Eine a ndere ist die Kennzeichnung des zu löschenden Schlüssels, ohne ihn tatsächlich herauszunehmen. Er wird dann bei erneutem Einfügen eines anderen Schlüssels ersetzt oder beim Suchen als vorhanden aber nicht verwendbar betrachtet. Wir werden i m Folgende n zwei S ondierungsverfahren betrachten. Da bei sei s(j, k) eine (Sondierungs-) Funktion über j und k, so dass (h(k) – s(j, k)) mod t für j = 0, 1, …, t–1 eine Sondierungsfolge berechnet (vgl. Ottmann/Widmayer, 2002, Seite 196). Wir pe rmutieren also die zur Ver fügung stehenden Hasha dressen, d. h. die Sondierungsfolge macht nichts anderes, als einen neuen Index für die Hashtabelle zu bestimmen. Es gelte die Annahme, dass in der Hashtabelle noch wenigstens ein Platz frei ist. Die Wahl von s(j, k) bestimmt die Sondier ungsfolge. Die beiden folgenden Ausprägungen, lineares und quadratisches Sondieren, sind zwei Beispiele dafür.
214 12
Hashing
12.6.1
Lineares Sondieren
Unser Autofahrer aus Landshut sucht in Fahrtrichtung einen Parkplatz nach dem anderen ab. Er sondiert linear (engl. linear probing). Sind die Parkplätze absteigend mit Speichera dressen num meriert und be ginnt der A utofahrer an A dresse h(k), so sub trahieren wir fü r j eden belegten Parkplatz den Wert 1 und erhalten die Sondierungsfolge: h(k), h(k) – 1, h(k) – 2, …, 0, t – 1, …, h(k) + 1. Dies entspricht der Sondierungsfunktion (für j = 0, 1, …, t–1): s(j, k) = j. Beispiel: Sei t = 7 und h(k) = 4. Tabelle 12.3 veranschaulicht die Sondierungsfolge. Tab. 12.3
Sondierungsfolge von linearem Sondieren
s(j, k) (h(k) – s(j, k)) mod 7 Ergebnis
0 4–0 4
12 4–1 32
4–2
34 4–3 10
4–4
5 (4 – 5) mod 7 6
6 (4 – 6) mod 7 5
Beispiel: Sei die Länge der Hashtabelle t = 7, das Universum K = {0, 1, …, 100}, die Hashfunktion h(k) = k mod t und die Sondierungsfunktion s(j, k) = j. Tabelle 12.4 zeigt die einzufügenden Schl üssel, die be rechnete Has hadresse sowie die eventu ell benötig te So ndierung, welche erst endet, wenn ein Speicherplatz frei ist. Abbildung 12.6 zeigt, wie die Hashtabelle belegt wird.
12.6 Hashing mit offener Adressierung Tab. 12.4
215
Lineares Sondieren Schlüssel, Sondierung und Hashadressen
k h(k) (h(k) – s(j, k)) mod 7
77 13 06 –
Speicheradresse 0
htable:
77
99 0
99 0
99 0
Abb. 12.6
6
1
2
3
4
2
3
4
5
21
16 1
21 0 (0 – 1) mod 7 = 6 (0 – 2) mod 7 = 5
5
64 1 (1 – 1) mod 7 = 0 (1 – 2) mod 7 = 6 (1 – 3) mod 7 = 5 (1 – 4) mod 7 = 4 4
13 2
16 1
77
htable:
–
16 1
77
htable:
–
99 16 12 –
64 2
3
13 5
21 4
6
6
13 5
6
Hashtabelle nach linearem Sondieren
Analyse von linearem Sondieren Wir ge ben hie r wiede r nur die beiden E rwartungswerte C n un d C ′n f ür die Anz ahl d er be trachteten Einträge bei erfolgreicher und er folgloser Suche an (vgl. Ottm ann/Widmayer, 2002, Seite 199): Cn ≈
1 ⎛ 1 ⎞ ⋅ ⎜1 + ⎟ 2 ⎝ 1− α ⎠
C ′n ≈
1 ⎛⎜ 1 ⋅ ⎜1 + 2 ⎝ (1 − α) 2
⎞ ⎟. ⎟ ⎠
Wiederum durch Einsetzen von α erhalten wir die W erte in Tabelle 12.5. Die W erte dienen der Orientierung und sind tatsächlich nur bei entsprechender Größe des Problems erreichbar. Tab. 12.5
Erwartungswerte für lineares Sondieren
Belegungsfaktor α 0.50 1. 0.90 5. 0.95 10. 0.99 50. 1.00 –
erfolgreiche Suche Cn 5 5 5 5
erfolglose Suche C′n 2.5 50.5 200.5 5000.5 –
Die Tabelle verm ittelt einen Eindruc k der Effizienz von linearem Sondieren. Je näher de r Belegungsfaktor dem W ert 1 kom mt, umso ineffizienter wir d das Ver fahren. Dies liegt an
216 12
Hashing
der so genannten primären Häufung (eng l. primary clustering) um diejeni gen Sc hlüssel herum, die im Verbund miteinander abgelegt sind. In Abbildung 12.6 (Mitte) ist bei Gleichverteilung der Schlüssel die Wahrscheinlichkeit 6/7, dass h(k) = 4 mit dem nächsten Element belegt wird, für h(k) = 3 jedoch nur 1/7. Treten also beim linearen Sondieren Häufungen auf, so wachsen di ese Bereiche wahrscheinlich schneller als andere. Dieses Verhalten ist um so stärker, je m ehr sich solc he Bereiche zu sammenschließen. Das qua dratische S ondieren im nächsten Abschnitt schwächt diese Erscheinung etwas ab.
12.6.2
Quadratisches Sondieren
Im Beisp iel zu r lin earen Sond ierung (sieh e Abb ildung 12.6) seh en wir, dass d as Verfahren ausschließlich links vom betrachteten Element nac h fre ien Plätzen sucht. Dies führt zum Problem der primären Häufung. Um diesem entgegenzuwirken, könnten wir nun rechts nach unbelegten Stellen suchen, wodurch die Problematik aber nicht gelöst wi rd, denn es ist egal, ob wir immer nur links oder rechts prüfen. Eine tatsächliche Verbesserung lässt sich durch das quadratische Sondieren erreichen. Dieses Verfahren sucht abwechselnd links und rechts in der Hashtabelle. Beispiel: An Skiliften kommt es öfters zu Warteschlangen. Diejeni gen Ski fahrer, die es besonders eilig haben, versuchen an den vor ihnen Wartenden vorbeizukommen. Dabei suchen sie aufmerksam links oder rechts nach Lücken und schlagen zu, wenn ein Weg frei ist. Diese Skifahrer sondieren in gewisser Weise quadratisch. Formell geben wir die quadratische Sondierungsfolge wie folgt an: h(k), h(k) + 1, h(k) – 1, h(k) + 4, h(k) – 4, … . Die Folge ist äquivalent zur Sondierungsfunktion (für j = 0, 1, …, t–1): s(j, k) = (⎡j/2⎤)2 (–1)j. Auf Anhieb ist nicht zu erkennen, dass diese Funktion alle Hashadressen permutiert. Wählen wir t als Primzahl in der Form 4i+3, so erzeugt diese Folge die Hashadressen 0, …, t–1 (vgl. Ottmann/Widmayer, 2002, Seite 199). Beispiel: Sei t = 7 und h(k) = 4. t ist eine Prim zahl obiger Form (4 · 1 + 3). Tabelle 12.6 veranschaulicht die quadratische Sondierungsfolge. Tab. 12.6
Sondierungsfolge von quadratischem Sondieren
j s(j, k) (h(k) – s(j, k)) mod 7 Ergebnis
0 0 4–0 4
1 –1 4+1 5
2 1 4–1 3
345 –4 (4 + 4) mod 7 106
4 4–4
–9 (4 + 9) mod 7
6 9 (4 – 9) mod 7 2
12.6 Hashing mit offener Adressierung
217
Beispiel: Sei die Länge der Hashtabelle t = 7, das Universum K = {0, 1, …, 100}, die Hashfunktion h(k) = k mod t und die Sondier ungsfunktion s(j, k) = (⎡j/2⎤)2 (–1)j. Wir fügen wie beim linearen Sondieren dieselben Schlüssel in die Hashtabelle ein und erhalten die Situation wie in Tabelle 12.7 und Abbildung 12.7. Tab. 12.7
Quadratisches Sondieren Schlüssel, Sondierung und Hashadressen
k h(k) (h(k) – s(j, k)) mod 7
77 13 06 –
Speicheradresse 0
htable:
77
99 0
htable:
77
htable:
99
77
Abb. 12.7
6
1
2
3
2
3
4
5
4
5
21
16 1
21 0 (0 + 1) mod 7 = 1 (0 – 1) mod 7 = 6 (0 + 4) mod 7 = 4 4
64 1 (1 + 1) mod 7 = 2 (1 – 1) mod 7 = 0 (1 + 4) mod 7 = 5 5
13 2
16 1
99 0
–
16 1
0
–
99 16 12 –
13
21 2
3
6
64 4
6
13 5
6
Hashtabelle nach quadratischem Sondieren
Analyse von quadratischem Sondieren Wiederum geben wir nur di e beiden E rwartungswerte Cn un d C′n f ür die durc hschnittliche Anzahl der betrachteten Einträ ge bei erf olgreicher und erfol gloser Suche a n (vgl. Ottmann/Widmayer, 2002, Seite 200): ⎛ 1 ⎞ α C n ≈ 1 + ln⎜ ⎟− ⎝1− α ⎠ 2
C′n ≈
1 ⎛ 1 ⎞ − α + ln⎜ ⎟. 1− α ⎝1− α ⎠
Durch Einsetzen des Belegungsfaktors α erhalten wir wieder eine Vorstellung von der Effizienz des Verfahrens (siehe Tabelle 12.8).
218 12 Tab. 12.8
Hashing Erwartungswerte für quadratisches Sondieren
Belegungsfaktor α 0.50 1. 0.90 2. 0.95 3. 0.99 5. 1.00 –
erfolgreiche Suche Cn
erfolglose Suche C′n
44 85 52 11
2.19 11.40 22.05 103.62 –
Die Tabelle zeigt, dass qua dratisches bes ser als lineares Sondiere n arbeitet. Die pri märe Häufung kann vermieden werden. Es tritt die sekundäre Häufung (e ngl. secondary clustering) auf. Diese entwickelt sich ähnlich der linearen Häufung, nur durch die andere Sondierungsfolge etwas abgeschwächt. Solange wir Sondierungsfunktionen mit direkter Abhängigkeit vom Schl üssel ge brauchen, werden im mer Häufunge n auftreten, w enn sich der Belegungsfaktor dem W ert 1 nähert. Es ex istieren Verfahren, die diese Pr oblematik u mgehen. Dazu sei Ottmann/Widmayer (2002) empfohlen.
12.7
Aufgaben
Aufgabe 12.1: Gegeben sei die Hashfunktion h(k) = k2 mod 7. Ist h(k) eine gute Hashfunktion? Untersuchen Sie dazu zuerst die Werte 0 < k ≤ 6 und beurteilen Sie die Schlüsselverteilung übe r die Hashtabelle. T esten Sie da nach de n Schlüssel k ′ = 7 · x + k mit x ≥ 0 un d 0 < k ≤ 6. Aufgabe 12.2: Abbildung 12.2 zeigt ein Bei spiel für pe rfektes Hashing. Ist es möglich, dass die Schlüsselwörter durch ihre ASCII-Darstellung und eine entsprechend zu wählende Hashfunktion eindeutig in die Hashtabelle abgebildet werden? Begründen Sie Ihre Antwort. Aufgabe 12.3: Abbildung 12.3 zeigt eine Klasse von Hashfunktionen. Ist diese Klass e für zwei verschiedene Schlüssel k1 und k2 universell? Berechnen Sie dazu unter den Voraussetzungen des obigen Beispiels den Quotienten für universelle Klassen von Hashfunktionen und beurteilen Sie das Ergebnis. Aufgabe 12.4: Gege ben sei die Has hfunktion h(k) = k m od 11. F ügen Sie die Werte 135, 102, 28, 14, 61, 6, 94 in der angegeben Reihenfolge in eine Hashtabelle mit t = 11 ein. V erwenden Sie zur Kollisionsauflösung: a)
separate Verkettung der Überläufer,
b) lineares Sondieren und c)
quadratisches Sondieren.
Erklären Sie anhand der Beispiele von linearem und quadratischem Sondieren die Phänomene der primären und sekundären Häufung.
13
Bäume
In den bei den letzten Kapite ln ha ben wir uns v orwiegend m it Sortier- und S uchverfahren beschäftigt. Wir kennen nun die grundlegenden Verfahren aus diese n Bereichen. Der Heap aus Abschnitt 11.5 ist eine baumartige Datenstruktur, die uns ermöglicht, ein Suchverfahren effizient zu gestalten. Dabei kommt uns die interne Organisation des Heaps zugute. Gerade bei Bäumen besteht die Möglichkeit, den internen Aufbau der D atenstruktur in unterschiedlichster W eise zu gestalten. Dies führt bei geeigneter Überlegung zu Zugriffsoperationen, die effizient für das Lösen eigentümlicher Probleme arbeiten. Aus der Vielzahl von baumartigen Datenstrukturen untersuchen wir ausgewählte Beispiele in diesem Kapitel. Wir kennen aus Abschnitt 10.5 eine einfache Baumstruktur, den Binärbaum. Zum Einstieg in dieses Ka pitel werden wir u ns im Folgen den m it Verfa hren zur Umwandlungeines Binärbaumes in eine Seque nz beschäftigen. Danach be handeln wir Datenstrukturen, die Eigenschaften z ur e ffizienten Realisierung gewisser Z ugriffsoperationen haben. Der AVL-Baum garantiert die Balancierung eines binären S uchbaumes. Die Binomial Queue und d er Fibonacci-Heap ermöglichen den schnellen Zugriff auf spezielle Elemente der Datenstruktur. Der (a, b)-Baum z eigt ein weiteres Beispiel für eine n höhe nbalancierten S uchbaum und hat als Spezialfall den B-Baum, eine bedeutende Speicherlösung für Externspeicher.
13.1
Vor-, In- und Postordnung von Binärbäumen
Den Aufbau von Bi närbäumen ke nnen wir bereits aus vora ngegangenen Ka piteln. Wollen wir einen Binärbaum in eine Sequenz umwandeln, so sind dafür viele verschiedene Möglichkeiten v orstellbar. Drei Verfahren haben d abei b esondere Bedeutung (vgl. Broy, 1 998, Band 1, Seite 219f). Die Vorordnung (engl. preorder) s chreibt die Wurzel eines Teilba umes an die vorderste Stelle der Sequenz , die Inordnung (engl. inorder) in die Mitte und die Nachordnung (engl. postorder) ans Ende. function preorder (bintree nat b): seq nat return if isempty(b) then empty else 〈root(b)〉 ○
220 13
Bäume preorder(left(b)) ○ preorder(right(b))
function inorder (bintree nat b): seq nat return if isempty(b) then empty else inorder(left(b)) ○ 〈root(b)〉 ○ inorder(right(b)) function postorder (bintree nat b): seq nat return if isempty(b) then empty else postorder(left(b)) ○ postorder(right(b)) ○ 〈root(b)〉 Beispiel: Für den in Abbildung 13.1 gezeigten Bi närbaum ergebe n die drei Ve rfahren die Sequenzen in Tabelle 13.1.
10
22
50
13
2
Abb. 13.1
Pre-, In- und Postorder bei Binärbäumen
Tab. 13.1
Berechnung von Pre-, In- und Postorder
preorder 10 22 50 inorder 50 22 postorder 50 2
2 22
2 10 13
13 13 10
Praktische Verwendung finden diese Algorithmen beim Erzeugen von Prä-, In- und Pos tfixschreibweise von Ausdrücken. Ein Ausdruck (arithmetisch, boolesch oder anderer) lässt sich über einen Operatorbaum darstellen, einem Binärbaum, dessen inter ne Knoten die Operatoren halten. Mit den obigen Rechenvorschriften ergeben sich die jeweiligen Schreibweisen. Beispiel: D er Baum au s A bbildung 13 .1 könnte au ch ein O peratorbaum f ür den Ausdruck (a + b) ⋅ c sein. Dabei wäre die 22 das Plus- und die 10 das Multiplikationszeichen. a, b und
13.2 AVL-Baum
221
c sind auch die Knoten 50, 2 und 13 verteilt. Durch Einsetzen in Tab elle 13.1 ergeben sich die drei Schreibweisen.
13.2
AVL-Baum
In Abschnitt 11.9 haben wi r bi näre Suchbä ume kenne n gelernt. Operationen auf di esen Bäumen sind dann e ffizient, wenn der Baum annähernd balanciert ist . Ve rgleichsweise schlechte La ufzeiten erhalten wir, falls de r zugrunde liege nde Baum degeneriert. Um eine Degeneration zu verhindern, müssen wir den Baum so organisieren, dass nach jeder Operation die Ausgeglichenheit erhalten bleibt. Dazu untersuchen wir nun den AVL-Baum (vgl. Ottmann/Widmayer, 2002, Seite 275), welcher nach den Erfindern Adelson-Velskij un d La ndis be nannt ist. Ein AVL -Baum ist ein binärer Suchbaum, der die Datenelemente nur in den Knoten speichert. Die Blätter ble iben leer. Jeder Knoten, der keine Kinderknoten hat, besitzt also 2 (lee re) Blätter. Wir sprechen von einem internen Suchbaum. Beispiel: Abbildung 13.2 zeigt links ein Beispiel für einen AVL-Baum (die Ellipsen symbolisieren die leeren Blätter). Der rechte Binärbaum ist kein AVL-Baum, weil er der folgende n Definition von AVL-Bäumen nicht genügt.
Abb. 13.2
Ein AVL-Baum und ein beliebiger Binärbaum
Definition: (AVL-Baum) Ein binäre r S uchbaum heißt AV L-Baum ( AVL-ausgeglichen ode r höhenbalanciert), wenn für jeden Knoten v die Differenz der Höhe des linken und des rechten Teilbaumes von v höchstens 1 ist: | Höhe linker Teilbaum von v – Höhe rechter Teilbaum von v | ≤ 1. AVL-Bäume haben die folgenden Eigenschaften:
222 13
Bäume
• Zugriffsoperationen lassen sich in La ufzeit O(log n) ausführen (Grund: Garantierte logarithmische Tiefe eines AVL-Baumes), • ein A VL-Baum der Höhe h hat m indestens Fh+2 Blätter ( Fi sei die i-te Fibonacci -Zahl: Fi = Fi–1 + Fi–2 mit F0 = 0 und F1 = 1). Beispielhafte Begründung des zweiten Punkte s: Zur E rinnerung zeigt T abelle 13.2 die „ersten“ Fibonacci-Zahlen. In Abbildung 13.3 sehen wir drei AVL-Bäume mit minimaler Blattzahl und den Höhen 1, 2 und 3. Tab. 13.2 i Fi
Fibonacci-Zahlen Fi 012345 011235
6 8
Der AVL-Baum der Höhe 1 hat 2 Blätter (≡ F1+2) und diejenigen mit Höhe 2 haben 3 Blätter (≡ F2+2). Verschmelzen wir einen A VL-Baum der Höhe 1 mit einem der Höhe 2, indem wir eine neue Wu rzel generieren und dara n die beiden Bäume hängen, so erhalten wir einen AVL-Baum der Höhe 3 mit minimaler Blattzahl (im Bild rechts). Im Bezug auf die Fibonacci-Zahlen und die Anzahl der Blätter heißt das: F3+2 = F1+2 + F2+2.
Abb. 13.3
Minimale AVL-Bäume der Höhen 1, 2 und 3
Allgemein ausgedrückt: Einen AVL-Baum der Höhe h = i+2 (mit i = 1, 2, 3, …) mit minimaler Blattzahl erhalten wir durch Anhängen von zwei AVL-Bäumen der H öhen i und i+1 mit jeweils minimaler Anzahl der Blätter an ei ne neue Wurzel. Die Anzahl der Blätter berechnet sic h dadurch wie bei de n Fibonacci-Zahlen a us de r Summ e der Blätter der bei den „Vorgänger“: Fh+2 = Fh + Fh+1. Wie schon erwähnt speichert der AVL-Baum Datenelemente nur an den Knoten. Gilt für die Verteilung der Schlüs sel die Bedingung des binären S uchbaumes, so ve rfolgen wir für die Operationen search, insert un d delete einen P fad durch den Ba um bis die ents prechende Operation aus führbar ist. O hne die O perationen auf A VL-Bäumen gena uer z u betrac hten, können wi r fe ststellen, dass beim Einfügen ein Teilbaum in de r Höhe um eins wäc hst und
13.2 AVL-Baum
223
beim Löschen ein Teilbaum um ein Element schrumpft. Dadurch wird irgendwann die AVLAusgeglichenheit verletzt. Di ese muss aber nach jeder Operation gewährleistet sein. Es entstehen nach beliebigen Kombinationen der Operationen die im Folgenden behandelten vier Fälle. Ist der Betrag der Differenz aus der Höhe des linken und des rechten Teilbaumes einer Wurzel v größer als 1, so er folgt die Wiederherstellung der AVL-Ausgeglichenheit (Rebalancierung) durch so genannte Rotationen und Doppelrotationen. Durch diese Rotationen wird die Degenerierung des Binärbaumes verm ieden und die Laufzeiteffizienz von O(log n) bleibt erhalten. Wir betrachten keine Spiegelsituationen und vernachlässigen Höhendifferenzen, die keine Verletzung der AVL-Ausgeglichenheit verursachen bzw. nur m arginale Unterschiede hervorrufen. 1. Fall: Abbildung 13.4 zeigt links einen B aum, bei dem der li nke Teilbaum die Höhe h+1 und de r rec hte die Höhe h–1 hat (die Dreiecke sym bolisieren Teilbäum e mit fortlaufender Nummerierung und der jeweiligen Höhe im Index von T). Die Gesamthöhe beträgt also h+2 (durch Zähl en der Teilbaum höhe und der zusä tzlichen Kanten bis zur Wurzel). Es gilt: |h+1 – (h–1)| = 2 und die Bedingung für einen AVL-Baum ist verletzt. Die Rebalanci erung erfolgt du rch ein e (Einfach-) Rotation nach rechts (sie he r echter Baum in de r A bbildung). Dabei wird x die neue Wurzel, y rechtes Kind von x und Teilbaum 2 linker Teilbaum von y. Wie wir schnell erkennen, hat jetzt sowohl der linke als auch der rechte Teilbaum der Wurzel die Höhe h+1 und die AVL-Ausgeglichenheit ist wieder hergestellt.
y
x
x
y T h-13 T h-1 2
Th1
T h-1 2
T h-13
T h1
Abb. 13.4
Rotation nach rechts
Der A VL-Baum ist ein bi närer Suchbaum (zur E rinnerung: Im linken Teilbaum speicher n wir alle Schl üssel kleiner als der Schlüssel in der Wurzel und im rechten alle größe r als der Wurzel-Schlüssel). Der linke Baum im Bild is t in irge ndeiner Weise durch die Operationen insert und delete auf einem korrekten AVL-Baum entstanden. Die Bedi ngungen eines binären Suchba umes sind erfüllt und unter de n Sc hlüsseln gelten die Beziehunge n in Tabelle 13.3.
224 13
Bäume
Tab. 13.3
Rotation nach rechts Schlüsselvergleich
Schlüssel im Teilbaum
Abbildung 13.4: linker Baum
Abbildung 13.4: rechter Baum
x∧
Die Verteilung der Schlüssel ist vor und nach der Rotation identisch. Sie verändert zwar die Struktur de s B aumes, die Bedingungen eines binären S uchbaumes bleiben je doch er halten. Es können nun weitere Operationen auf dem Baum ausgeführt werden. 2. Fall: Im zweiten Fall ve rletzt der rechte Teilbaum die AVL-Aus geglichenheit (siehe Abbildung 13.5): |h – (h+2)| = 2. Wir rebalancieren durch eine (Einfach-) Rotation nach links. Dabei wird z die neue Wurzel, y linkes Kind von z und Teilbaum 3 rechtes Kind von y.
y
z
x
z
y
x T h-1 1
T h-12 T h-11
T h3
T h-12
T h3
T h+1 4
T h+14
Abb. 13.5
Rotation nach links
Wie die Rotation nach rechts verändert auch die Rotation nach links die Sortierungsbedingungen eines binären Suchbaums auf den Schlüsseln nicht (sie he Ta belle 13. 4, die genaue Betrachtung erfolgt nur auf den Teilbäumen, die an einen neuen Knoten angehängt werden). Tab. 13.4
Rotation nach links Schlüsselvergleich
Schlüssel im Teilbaum Th–11 < Th–12 > Th3 Th+14
Abbildung 13.5: linker Baum
Abbildung 13.5: rechter Baum
x x >y∧z
x >y∧z
13.2 AVL-Baum
225
Bei den Einfac hrotationen wird jeweils ein Teilbaum an einen a nderen Knoten gehängt. Bei den so genannten Doppel rotationen (dritter und vierter Fal l) erhalten zwei Teilbäum e neue Väter. Bevor wir diese ge nauer betrachten, wollen wir noch erörtern, warum wir diese übe rhaupt benötigen. In Abbildung 13.6 haben wir eine ähnliche Situation wie im zweiten Fall. Wir tauschen lediglich Teilbaum 3 und 4 miteinander. Nach der Rotation nach links erhalten wir die Situation rec hts im Bild und übe rprüfen die AVL-Bedingung: |h+2 – h| = 2. W ir ha ben nichts erreicht, da nach wie vor kein AVL-Baum vorliegt.
y
z
x
z
y
x T h-1 1
T h-12
T h4 T h4
T h-11
T h-1 2
T h+13
Abb. 13.6
T h+1 3
Erfolglose Rotation nach links
Aber vielleicht funktioniert e s in der umgekehrten Situation de s ersten Falles (siehe Abbildung 13.7). Wir rotieren nach rechts und stellen fest: |h–1 – (h+1)| = 2.
y
x
x
y T h-13
T h-11
T h-11
T h-13 T h2
Abb. 13.7
Erfolglose Rotation nach rechts
T h2
226 13
Bäume
Wenn ä ußere Teilbäume nicht m ehr höhenbalanci ert sind, so können wir die H öhe durch Einfachrotationen wieder ausgleichen. Für innere Teilbäume ist dies nicht de r Fall. Doppelrotationen lösen das Problem. Sie rotieren einen inneren Unterbaum nach außen, um durch eine weitere Rotation wieder eine AVL-Ausgeglichenheit herzustellen. 3. Fall: Für die Doppelrotation stellen wir die Situation etwas anders dar. Den linken Baum aus Abbildung 1 3.7 f ormen wir in de n lin ken Baum in Ab bildung 13.8 um. Der Ba um ist nicht balanciert: |h+1 – (h–1)| = 2.
y
y
x
z T h-14
z
T h-1 4
x
T h-1 1
T h-1 3 T h-12
Abb. 13.8
T h-1 3
T h-1 1
T h-1 2
Doppelrotation nach links-rechts 1. Schritt
Im ersten Schritt rotieren wi r den Knoten z nach außen (Rotation nach links), indem x linkes Kind v on z und Teilba um 2 rechter Unterbaum von x wird. Die AVL-Bedingung ist noch nicht erfüllt und wi r rotieren z nochmals nach rechts (siehe Abbildung 13.9). Der zweite Schritt ist äquivalent zum ersten Fall.
y
z
z
x T h-14
x T h-13 T h-11
Abb. 13.9
y
T h-12
Doppelrotation nach links-rechts 2. Schritt
T h-1 1
T h-12
T h-13
T h-14
13.2 AVL-Baum
227
Zur Kontrolle zeigt Tabelle 13.5 die Korrektheit der inneren Sortierung des Suchbaumes. Tab. 13.5
Doppelrotation nach links-rechts Schlüsselvergleich
Schlüssel im Teilbaum
Abbildung 13.8: linker Baum
Abbildung 13.9: rechter Baum
x >x∧z∧y
x∧z∧y
Th–11 < Th–12 Th–13 Th–14
4. Fall: Es ble ibt noch die L ösung für den Fall in Abbildung 13.6. Um eine Doppelrotation ausführen zu können, wandeln wir den Baum wieder etwas um (siehe Abbildung 13.10 linker Baum).
y
y
x
z
x
w
w T h-11
z
T h-12
T h-1 1 Th5 T h3
Abb. 13.10
T h4
T h-12 T h3 Th4
T h5
Doppelrotation nach rechts-links 1. Schritt
Zuerst führen wir mit Knoten w eine Rotation nach rechts aus (w wird Vater von z und Teilbaum 4 linker Teilbaum von z). Die entstandene Situation ist identisch mit dem zweiten Fall. Eine weitere Rotation nach links rebalanciert den Baum (siehe Abbildung 13.11).
228 13
Bäume y
w
x
w
y
z T h-11
x
T h-12 T h3
T h-11 Th4
Abb. 13.11
z
T h-1 2
T h3
T h4
T h5
T h5
Doppelrotation nach rechts-links 2. Schritt
Die Kontrolle zum Erhalt der Sortierung im binären Suchbaum sei an dieser Stelle dem Leser überlassen. Das Vorgehen ist gena uso wie das Ergebnis das gleiche wie i n den anderen drei Fällen.
13.3
Vorrangwarteschlangen
Eine Vorrangwarteschlange (engl. priority queue) ist eine Datenstrukt ur, die Elem ente verwaltet, welche durch eine Prioritätsordnung definiert sind. Das Abarbeiten eines Aktenstapels oder von Emails erfolgt nach einer festgelegten oder frei gewählten Priorität. Es sei für uns nicht wichtig, wie die Prioritätenordnung zustande kommt oder wie sie genau definiert ist. Wir halten nur fest, dass es eine gibt und sie anwendbar ist. Typischerweise sind f ür Mengen, die durc h Prioritäten versehen sind, m ehrere Operationen von Bedeutung: • • • • • •
insert: Einfügen von Elementen, delete: Löschen von beliebigen Elementen, deleteMin: Löschen des kleinsten Elements, findMin: Finden des kleinsten Elements, decreaseKey: Herabsetzen eines beliebigen Schlüsselwertes, merge: Mischen von Mengen.
Bei der Vorstellung des A barbeitens eines Aktenstapels werden die Operationen klar. Neue Akten oder sogar Aktenstapel müssen eingefügt werden (insert und merge). Akten verl ieren ihre Gültigkeit (delete). Der wichtigste Akt wird gesucht und verarbeitet (findMin) sowie aus dem Stapel entfernt (deleteMin). Ein Akt gewinnt an Wichtigkeit (decreaseKey).
13.3 Vorrangwarteschlangen
229
Vorab sei e rwähnt, da ss Vorrangwarteschlangen keine S uchstrukturen si nd. Dafür m üssen andere Datenstrukturen verwendet werden. Zum Zugriff auf ein einzel nes Ele ment in e iner Priority Queue benötigen wir somit einen externen Zeiger. Wir betrachten nun zwei effiziente Implementierungen von Priority Queues. Vorrangwarteschlangen t ragen zur effizien ten Berech nung von Prob lemen b ei, d ie die g enannten O perationen oft ve rwenden. Z wei dieser Probleme mit Anwe ndung ei ner Priority Queue untersuchen wir in einem späteren Kapitel. Als Grundlage und zur weit eren Vertiefung di eses Abschnitts sei auf Ott mann/Widmayer (2002) sowie Mayr (1999) verwiesen.
13.3.1
Binomial Queue
Eine Möglichkeit zur Um setzung einer Priority Queue ist die so gena nnte Binomial Queue (oder auch binomial heap). Dazu verwenden wir Binomialbäume. Beispiel: Abbildung 13.12 zeigt die Binomialbäume B0, B1, B2 und B3. Aber Achtung, Binomialbäume sind keine Binärbäume.
B0
Abb. 13.12
B1
B2
B3
Die Binomialbäume B0, B1, B2 und B3
Definition: (Binomialbaum) (vgl. Ottmann/Widmayer, 2002, Seite 4 03) Ein Binomialbaum Bn mit n ≥ 0 ist wie folgt induktiv definiert: 1. B0 ist ein Baum mit exakt einem Knoten. 2. Bn+1 entsteht aus zwei Bäumen Bn, indem die Wurzel des einen Bn zum Sohn der Wurzel des anderen Bn wird. Beispiel: Abbildung 13.13 (oben links ) verdeutlicht die allgemeine Darstellung eines Binomialbaumes. Die beiden anderen Binomialbäume sind dazu äquivale nte Darstellungsformen. Es sei dem Leser überlasse n, sich die ve rschiedenen A rten anha nd des B3 aus Abbildung 13.12 klarzumachen.
230 13
Bäume Bn
B n-1
...
B n-1
B n-1
B n-2
B1
B0
B n-1
B n-2 ... B n-3
B0
B1
B0
Abb. 13.13
Darstellungsformen des Bn
Binomialbäume sind durch ve rschiedene Eige nschaften ge kennzeichnet ( vgl. Ottmann/Widmayer, 2002, Seite 403): 1. Bn besitzt exakt 2n Knoten. 2. Die Höhe eines Bn ist n. 3. Die Wurzel von Bn hat n Kinder (Grad n). ⎛n⎞ 4. In Tiefe i hat Bn ⎜⎜ ⎟⎟ Knoten. ⎝i⎠ Wegen Punkt 4 tragen Binomialbäume ihren Namen. Wiederum sei es dem Leser überlassen, sich die vier Punkte anhand des B3 aus Abbildung 13.12 zu verdeutlichen. Wir verwenden Binomialbäume zur S peicherung von Sc hlüsselmengen. Die Sc hlüssel sind natürliche Za hlen. Die P rioritätsordnung er gibt sic h a us der Minim um-Heap-Variante, d. h. der kleinste (aus Sicht der Priorität der wichtigste) Schlüssel steht in der Wurzel. Die Bedingung muss für alle Teilbäume erfüllt sein. Nun können w ir in einem Binom ialbaum bis zu 2nψElemente able gen. Die aus schließliche Speicherung von Elem enten, die in I hrer An zahl Zweie rpotenzen e ntsprechen, ersc heint wenig brauchbar. Wir könnten auch Knoten leer lassen, was aber eb enso wenig Sinn macht (Speicherverlust, schwierigere Handhabung). Betrachten wir dazu das folgende Beispiel.
13.3 Vorrangwarteschlangen
231
Beispiel: Wir wollen 13 Schlüssel in ei nem Binomialbaum speichern: 1, 4, 6, 9, 11, 18, 21, 24, 29, 33, 40, 51, 63. Das Ablegen in einem einzigen Binom ialbaum ist leider nicht möglich, da 13 keine Zweierpotenz ist. Die Lösung liegt im Speichern der 13 Elemente in mehreren Binomialbäumen, welche über die Dualzahldarstellung der Anzahl der Elemente gewählt werden: N = 13 = (1101)2. Die Einsen kennzeichnen die benötigten Binomialbäume, also B3, B2 und B0. Darin legen wir die Elemente unter Beachtung der minimalen Heap-Bedingung ab und erhalten einen Wald von Binomialbäumen. Abbildung 13.14 zeigt eine mögliche Verteilung der Schlüssel. Sie könnte durchaus anders sein, die Struktur jedoch nicht.
1
29
6
18
40
24
63
4
9
11
21
51
33
Abb. 13.14
Wald von Binomialbäumen
Wir verallgemeinern: Für eine beliebige Anzahl N von El ementen wählen wir die D ualzahl N = (bn–1bn–2…b0)2. Für je des k mi t bk = 1 erstellen wir d ann einen Binomialbaum Bk, de r gerade 2k Elemente speichert. Wir bekommen einen Wald von Binomialbäumen, die alle für sich de r obige n Hea p-Bedingung ge nügen. Diese Art der Darstellung heißt auch Binomial Queue. Eine Binomial Queue darf ke ine zwei gleic hen Bi enthalten und je der einzelne Knoten trägt einen Schl üssel (ist also nicht leer). Bei t atsächlicher R ealisierung einer Binom ial Queue könnten die Wurzeln der ei nzelnen Binom ialbäume über eine doppelt verkettete Liste ver bunden werden. In unseren Abbildungen verzichten wir aber auf eine solche explizite Kennzeichnung des Waldes. Es ist leicht zu erkennen, dass eine Binom ial Queue eine starre Struktur hat. Fügen wir ein neues Element ein ode r löschen ein vorhandenes, so e ntspricht der Rest der Warteschlange nicht unbedingt de n Voraussetzungen f ür die Da tenstruktur. Dieser Umstand er fordert eine Restrukturierung de r Bi nomial Que ue. Da s f olgende Verfahren e ntspricht d er Operation merge, welche zwei Binomial Queues solange verschmilzt, bis keine zwei identischen Binomialbäume mehr im Resultat vorhanden sind. Wir geben es anhand eines Beispieles an. Beispiel: Ge geben seien die beide n Bi nomial Queues a us Abbildung 13.15 (oben). Zum Zusammensetzen s uchen wir be ginnend m it dem kleinsten vorhandenen Bi zwei Binomialbäume, die gleich viele Elemente s peichern (Teilbäume mit Wurzel 11 und 18). Wir fügen
232 13
Bäume
sie u nter Beach tung d er Heap-Bedingung zu m n ächst größ eren Bino mialbaum zu sammen (die Wurzel m it dem größer en Sc hlüssel wird Sohn der Wurzel m it dem kleinere n). Wir wiederholen diesen Schritt mit dem entstandenen Wald mit den Unterbäumen 4 und 11. Das Resultat ist eine gültige Binom ial Queue, die nu r noc h B inomialbäume unterschiedlichen Wurzel-Ranges enthält.
29
6
18
40
24
1
9
33
40
33
11
21
63
18
1
4
63
9
24
11
9
63
51
21
51
4
18
11
51
6
29
4
21
6
29
1
40
33
24
Abb. 13.15
Verschmelzen zweier Binomial Queues
Wir errechnen die Anzahl der Binomialbäume anhand der Dualzahldarstellung der Anzahl N der benötigten Schlüssel. Im Beispiel vers chmelzen wir Binom ialbäume. Dies gesc hieht genau da nn, wenn bei de r Addition der beiden D ualzahldarstellungen der ursprünglichen Binomial Queues ein Übertrag auftritt:
13.3 Vorrangwarteschlangen
233 B3
N1 = 7 N2 = 6 Übertrag 1 Ergebnis N = 13
1
B2 1 1 1 1
B1 1 1 0 0
B0 1 0 1
Für die Spalte B1 und B2 liegt je weils ein Übertrag vor, d.h. es m üssen je zwei B1 und B2 verschmolzen werden. Im Beispiel aus Abbildung 13.15 ist das der Fall. Die Addition erfolgt spaltenweise von rechts nach links. In den Zeilen N1, N2 und Übertrag lässt sich a nhand der Einsen für jeden Additionsschritt i die Anzahl der momentan vorhandenen Bi ablesen. Abschließend sei noch gesagt, dass alle Oper ationen aus Abschnitt 13.3 bei der Realisierung durch eine B inomial Queue die worst case-Komplexität O(log n) ha ben (vgl. Ottmann/Widmayer, 2002, Seite 41 0). Wir verzich ten an dieser Stelle au f die ex akte Beh andlung der Operationen und die Komplexitätsberechnungen. Zum Verständnis beac hte der Leser jedoch, dass sowohl die Tiefe je des Binomialbaumes einer Binomial Queue als auch die A nzahl der einzel nen Binom ialbäume logarithmisch ist. Eine ei nfach z u realisierende Optimierung wäre das Einführen eines Zeigers auf das kleinste Element. Dadurch ergibt sich für findMin O(1) und bei Anpassungen in den Wurzeln der Binomialbäume müsste der Zeiger gegebenenfalls aktualisiert werden.
13.3.2
Fibonacci-Heap
Binomial Queues haben eine starre Struktur, da sie nach jeder Operation keine zwei gleichen Binomialbäume enthalten. Ei ne Invariante „Nur Bäume verschiedenen Wurzel-Ranges“ gibt es beim Fibonacci-Heap nicht. Ein Fibonacci-Heap ist ein Wald von Bäumen, von de nen je der fü r sich die MinimumVariante der Heap-Bedingung erfüllt. Weitere Bedingungen für den Fibonacci-Heap gibt es nicht. Wir werden aber sehen, dass die Operationen des Fibonacci-Hea ps eine Struktur festlegen, welche eng mit der einer Binomial Queue zusammenhängt. Die Ope rationen eine s Fi bonacci-Heaps si nd teilweise s chneller als die einer Bi nomial Queue (siehe Tabelle 13.6 und Mayr, 1999). Tab. 13.6
Fibonacci-Heap Komplexität der Operationen
Operation insert merge findMin decreaseKey delete deleteMin
worst case-Komplexität O(1) O(1) O(1) O(1) O(1) O(1) O(log n) O(log n) O(n) O(log
amortisierte Kosten
O(1) O(log n) n)
234 13
Bäume
Im Folgenden betrachten wir nur Schlüsselelemente. Datenelemente lassen wir a us Gründen der Vereinfachung weg. Definition: (Fibonacci-Heap) Die Datenstruktur des Fi bonacci-Heaps sei wie folgt definiert (vgl. Mayr, 1999): • Schlüssel sind an den Knoten der Bäume gespeichert, • Wurzelliste: Doppelt verkettete Liste a ller Bäume des Fibonacci-Hea ps, Zeiger auf di e minimale Wurzel (≡ Wurzel des Fibonacci-Heaps), • jeder Knoten ist gekennzeichnet durch: – Schlüssel und Wert, – Rang (Anzahl der Kinder), – Zeiger zum ersten Kind, – Zeiger zum Vater (null im Falle der Wurzel), – doppelt verkettete Liste der Kinder, – Markierung ∈ {0, 1} (nur die Wurzel hat diese Markierung nicht). Beispiel: Es gibt viele Mögl ichkeiten z ur Darstellung eines Fi bonacci-Heaps. Ei ne davon zeigt Abbildung 13.16. Das Quadrat sym bolisiert die Wurzelliste, de r Pfeil den Zeiger auf das Minimum und die einzelnen Knoten und Bäume die Teilbäume des Fibonacci-Heaps.
Abb. 13.16
Darstellung eines Fibonacci-Heaps (1)
Genau genommen spiegelt diese übersichtliche Darstellung nicht genau die obige Definition wieder. Abbildung 13.17 ve rdeutlicht anha nd nur eines Teilbaum es, wie kom plex eine genaue Veranschaulichung wird.
13.3 Vorrangwarteschlangen
Abb. 13.17
235
Darstellung eines Fibonacci-Heaps (2)
Auf das kleinste Element der Wurzelliste e xistiert laut Definition ein Zei ger. Legen wir aufgrund irgendeiner Operation ein weiteres El ement in die Wurzelliste, so m uss dieser möglicherweise aktualisiert werden. Das ist dann de r Fall, wenn wir durch ei nen einzi gen Vergleich feststellen, dass das neue Elem ent kleiner als das aktuelle Minimum ist. Es liegt auf der H and, dass findMin in O(1) realisierbar ist (durch Dereferenzieren des Zeige rs). insert hängt das neue Element in die Wurzelliste ein (auch hier gilt: O(1)). Die Operation merge ähnelt der Vorgehensweise der Binomial Queue. Zwei Knoten i n der Wurzelliste mit dem gleichen Wurzel-Rang werden derart verschmolzen, dass die Wurzel mit dem größeren Schlüssel Sohn der anderen Wurzel wird. Wiederum gilt: Die Laufzeit der Operation merge entspricht der Größenordnung O(1). Beachte: Mehrere Bäume mit gleichem Wurzel-Rang werde n nicht autom atisch von merge ve rbunden. Dies is t nicht Eigenschaft eines Fibonacci-Heaps. Bis jetzt haben wir die oben genannte Markierung noch nicht in unsere Betrachtungen einbezogen. Sie ist bei den Operationen delete und decreaseKey von Bedeutung. delete führt deleteMin aus, wenn das zu löschende Element das Minimum des Fibonacci-Heaps ist. Ansonsten sind die bei den Operationen nahezu identisch. delete lö scht d as en tsprechende Element und hängt alle seine Kinder in die Wurzelliste (Achtung: Wir beschäftigen uns nicht mit der Fra ge, wie wir das El ement finden). decreaseKey entfernt es ebe nfalls, hängt es mit dem veränderten Schlüssel und sei nen Kindern auch in die Wurzelliste, wo wi r bei Bedarf den Zeig er au f d as Mi nimum ak tualisieren. Nach d em j eweiligen En tfernen arb eiten b eide Operationen äquivalent weiter. Wenn der Vater des entfernten Elements markiert ist, so wird er unmarkiert samt Unterbaum in die Wurzelliste gehängt. Dies geschieht solange, bis der Vater des aktuellen Knotens nicht markiert ist oder kei ner mehr vorhanden ist. Hat der Vaterknoten keine Markierung, so wird er markiert. Die Markierungsvorschrift lässt sich wie folgt zusammenfassen (kaskardierendes Abschneiden, vgl. Mayr, 1999): • Ein Knoten wird unmarkiert, wenn – er durch ein merge Kind eines anderen Knoten wird oder – wir ihn in die Wurzelliste einfügen. • Verliert ein Knoten im Markierungszustand einen Sohn, so hängen wir ihn unmarkiert in die Wurzelliste.
236 13
Bäume
• Verliert ei n Knoten im Nicht-Markierungsz ustand ei nen Sohn, s o wird die Ma rkierung gesetzt. Beispiel: Abbildung 13.18 z eigt eine n m arkierten Fi bonacci-Heap. Die Punkte be deuten, dass die dazugehörigen Knoten markiert sind und bereits Kinder verloren haben (Knoten mit Schlüssel 21 und 34).
8
21
50
12
34
40
55
41
79
Abb. 13.18
Markierter Fibonacci-Heap
Entfernen wir das Element mit dem Schlüssel 40, so hängen wir 55 und 41 in die Wurzelliste (siehe Abbildung 13.19 links). Wenn wir nicht löschen, sondern auf das gleiche Element ein decreaseKey von 40 a uf 13 a usführen, so erhalten wir die Konstellation im rechten Teil des Bildes (der kom plette Unterbaum wandert nach oben). Der Vater von Knoten 40 hat bereits ein Kind verloren und wird dadurch in die Wurzelliste gehängt. Gleiches gilt für Knoten 21, der im ersten Schritt nach delete bzw. decreaseKey Knoten 34 ve rliert und e benfalls schon die Markierung trägt.
13.3 Vorrangwarteschlangen
21
34
8
50
237
55
12
41
21
79
50
34
8
12
13
55
41
79
Abb. 13.19
Fibonacci-Heap delete und decreaseKey
Bis auf deleteMin kennen wir nun alle O perationen. Diese verändern die Struktur nur in der Weise, dass die Tiefe des Fibonacci-Heaps immer kleiner wird, und die Anzahl der Elemente in der Wurzelliste steigt. deleteMin räumt d iese „Unordnu ng“ au f. Es en tfernt das kleinste Element und h ängt deren Kinder i n die Wurzelliste. Danach führt die Operation solange merge aus, bis keine zwei Bäume mit gleichem Wurzel-Rang mehr in der Wurzelliste vorhanden sind. Beispiel: W ir entfer nen das Minim um aus Abbildung 13 .19 (links) und führen merge aus (siehe Abbildung 13. 20). Zuerst vereinigen wir 12 und 55 und anschließend die Teilbäume mit den Wurzeln 12 und 41. Danac h liege n ke ine zwei Unterbäume mit gleichem WurzelRang mehr vor und deleteMin ist abgeschlossen.
21
34
12
55
41
50
21
50
34
79
12
41
21
55
79
50
34
12
41
55
79
Abb. 13.20
Fibonacci-Heap deleteMin (1)
In diesem Beispiel entsteht sogar eine Binomial Queue. Das ist norm alerweise nicht der Fall. Der Leser bea chte daz u die folgende Bem erkung sowie das Beis piel in Abbildung 13.21,
238 13
Bäume
welche links einen Fibonacci-Heap vor und rechts nach Ausführung von deleteMin zeigt. Der rechte Baum ist kein B3, da der Knoten 55 kein weiteres Kind hat.
21
50
2
8
12
13
55
8
41
55
Abb. 13.21
13
21
41
50
12
Fibonacci-Heap deleteMin (2)
Bemerkung: Wenn wir mit einem leeren Fibonacci-Heap starten und nur die aufbauenden Operationen insert und merge verwenden, so entstehen ausschließlich Binomialbäume. Diese können auch gleichen Wurze l-Ranges sein. Nach Ausführung der Opera tion deleteMin sind alle Binomialbäum e gleiche n Wurzel-Ranges ve rschmolzen und es liegt m omentan eine Binomial Queue vor (vgl. Ottmann/Widmayer, 2002, Seite 413).
13.4
(a, b)-Baum
Abschließend für das Kapitel übe r Bäum e lerne n wi r nun die G rundlage für eine wic htige Datenstruktur aus dem Datenbankbereich kennen. Wir beschäftigen uns jetzt nicht mehr wie in den letzten beiden Abschnitten mit heapgeor dneten Bäum en, sondern führen eine neue, der Schlüsselverteilung eines binären Suchbaumes sehr ähnliche Anordnung ein. In Abschnitt 13.2 haben wir ein Beispiel für einen höhenba lancierten Suchbaum kennen gelernt. Der AVL-Baum ist ein interner Suchbaum. Er speichert die Datenelem ente an internen Knoten. Im Gegensatz dazu legt ein externer Suchbaum die Daten nur an den Blättern ab. Die inne ren K noten halten Ve rwaltungsinformationen für die O perationen bereit. Der (a, b)-Baum ist ein externer Suchbaum (vgl. Mayr, 1999). Beispiel: Abbildung 13.22 zeigt einen (2, 4)-Baum. Die Struktur und A ufteilung der Knoten wird anhand der folgenden Definition klar.
13.4 (a, b)-Baum
239 6
4
4
Abb. 13.22
7 9 12
6
7
9
12
20
(2, 4)-Baum
Definition: ((a, b)-Baum) (vgl. Mayr, 1999) Ein (a, b)-Baum ist ein externer S uchbaum, für den gilt: • alle Blätter haben identische Tiefe, • für die Anzahl N der Kinder eines jeden internen Knotens gilt: a ≤ N ≤ b (die Wurzel ist von dieser Bedingung ausgenommen), • für die Wurzel gilt: 2 ≤ N ≤ b, • für b gilt: b ≥ 2a–1. Jeder interne Knoten speichert Verwaltungsinformationen. Ist N die Anzahl der Kinder eines Knotens, so beinhaltet er N–1 Verwaltungseinheiten. Ist ki die Verwaltungseinheit an Stelle i eines Knotens, so gilt: (Schlüssel im i-ten Teilbaum) ≤ ki < (Schlüssel im i+1-ten Teilbaum). Beispiel: Wir fügen in obigen Baum das Element 10 ein. Nach der Verwaltungseinheit in der Wurzel m uss das ne ue Element im rechten Teilbaum eingesetzt werden (siehe Abbildung 13.23 oben). Wir sortieren die 10 gemäß der Bedingung für die Verwaltungsknoten ein und stellen fes t, dass de r rec hte Teilbaum fünf Ki nder hat , was 5 ≤ b = 4 wi derspricht. Es existieren m ehrere M öglichkeiten, um wieder ei nen gültigen (2, 4)-Baum zu erstellen. Wir geben z wei der denkbaren Varia nten an (sie he die beiden unteren Bäum e in Abbildung 13.23).
240 13
Bäume 6
4
7 9 10 12
4
6
7
9
10
12
20
7
9 10 12
4 6
4
6
7
9
10
12
20
6 9
4
4
Abb. 13.23
7
6
7
10 12
9
10
12
20
(2, 4)-Baum insert
Die letzte Bedingung der Definition eines (a, b)-Baumes wird vor allem dann klar, wenn wir wie eben ein E lement einfügen. Hat ein interner Knoten bereits b Kinder und es kommt ein weiteres Element dazu, so m üssen wir e ventuell einen neuen Knoten erzeugen. Die beiden Knoten genügen der Bedingung a ≤ N, wenn zuvor b ≥ 2a–1. Beispiel: Ein (2, 3)-Baum habe zwei Teilbäum e mit j eweiliger Höhe 1, die beide komplett gefüllt sind. Füge n wi r ein weiteres Element ei n, so hat di e Wurzel eines Teilbaum es vi er Kinder. Mit dem anderen Teilbaum kann nicht wie im Beispiel oben getauscht werden, da er ebenfalls schon alle Plätze belegt hat. Wir erzeugen also einen ne uen Knoten und ve rteilen die vier Kinder zu gleichen Teilen auf den alten und neuen Unterbaum. Beide halten dann je zwei Kinder, die minimal erforderliche Anzahl. Unter realen B edingungen werden immer wieder insert, delete, merge und andere Operationen ausgeführt. Dabei wächst und sc hrumpft der zugrunde liegende (a, b)-Baum und es treten Situationen auf , in de nen mehrere Möglichkeiten zur Anpassung der Struktur verfügbar sind. Welche Wahl zur Umstrukturierung am besten ist oder wie eine optimale Strategie dazu aussehen könnte, wollen wir an dieser Stelle nicht tiefer behandeln. Es genügt, wenn wir uns der Problematik bewusst sind. Für weitere Studien sei Mehlhorn (1988) empfohlen.
13.5 Aufgaben
241
Abschließend noch z wei Be merkungen: Ein (a, b)-Baum hat a uf je den Fall loga rithmische Tiefe. Wählen wir b = 2a–1, so s prechen wir vom so genannten B-Baum. Der B-Baum ist eine der wichtigsten Datenstrukturen für Datenbank-Externspeicher. a wird zur Speicherung auf Platten groß ge wählt, z.B . a = 100, da der Z ugriff auf Hintergrunds peicher wese ntlich langsamer als auf Hauptspeicher ist und der Baum in die Breite wachsen kann.
13.5
Aufgaben
Aufgabe 13.1: Bei der pr ogrammtechnischen R ealisierung de s A VL-Baumes benötigt man eine Möglichkeit zur Feststellung der AVL-Ausg eglichenheit. Erarbeiten Sie dafür ei ne informelle Lösung. Ve rsuchen Sie dazu f ür jeden Knoten im AVL-Baum eine lokale L ösung zu erzeugen, welche die lokalen Werte der beiden Kinder verwendet. Aufgabe 13. 2: Erze ugen Sie f ür die Sc hlüssel 2, 4, 10, 16, 18 einen AVL -Baum mit der Wurzel 16. Beachten Sie dabei die Bedingung für binäre Suchbäume. a)
Fügen Sie die 1 an der korrekten Position in den Baum ein. Stellen Sie bei Bedarf die AVL-Ausgeglichenheit wieder her.
b) In den Baum aus Teilaufgabe a) sollen nun die Schlüssel 7 und 6 eingefügt werden. Falls nötig ist der Baum zu rebalancieren. Aufgabe 13.3: Legen Sie die Schlüssel 1, 2, 4, 6, 7, 10, 16, 18 in einer Binomial Queue ab. a)
Bestimmen Sie zuerst die be nötigten Binom ialbäume und f ügen Sie di e Schl üssel entsprechend der minimalen Heap-Bedingung ein.
b) Löschen Sie den Wert 18 aus der Binomial Queue und restrukturieren Sie den Baum in geeigneter Weise. c)
Beschreiben Sie den Löschvorgang informell. Gehen Sie davon aus, dass Sie auf jedes Element im Baum direkt zugreifen können.
Aufgabe 13.4: Füge n Sie die Schl üssel 1, 2, 4, 6, 7, 10, 16, 18 nac heinander m it insert in einen anfangs leeren Fibonacci-Heap ein und führen Sie danach deleteMin aus. Aufgabe 13.5: Legen Sie die Schlüssel 1, 2, 4, 6, 7, 10, 16, 18 in einem (2, 4)-Baum ab. Wie viele strukt urell versc hiedene Va rianten eine s (2, 4)-Baum es mit 8 Elementen existieren? Fertigen Sie entsprechende Zeichnungen an.
14
Graphen
In Abschnitt 9 habe n wir ei n P roblem von kürzesten Verbindungen z wischen Stä dten be trachtet (siehe Abbildung 9.1). Die Lösung des Problems scheitert bis jetzt schon daran, dass wir keine Datenstruktur kennen, um Städte und deren Verbindungen möglichst einfach darzustellen. Bäume aus dem letzten Abschnitt sind dazu nicht mächtig genug. Verallgemeinern wir den Begriff des Baumes, so erhalten wir so genannte Graphen. Knoten in Bäumen haben einen Vater und beliebig viele Kinder. In Graphen sprechen wir von Nachbarknoten. Lassen wir in Bäum en Kanten z u Knoten zu, die nicht Vater ode r Kind sind, so entstehen Beispiele für Graphen. Ein Baum ist also nichts andere s als ein Spezialfall eines Graphen. In diesem Kapitel beschäftigen wir uns zuerst m it der präzise n Definition de r Str uktur Graph. Die Breiten- un d Tiefensuche si nd Verfahren z um Durchla uf (Traversierung) v on Graphen. Die Algorithmen zur Berechnung von kürzesten Pfaden (eine Lösung des Problems auf Abbildung 9.1) und minimalen Spannbäumen runden diesen Abschnitt als Beispiel e für effiziente Berechnungen über Graphen ab. Die fachlichen Grundlagen für diesen Abschnitt stammen aus Mayr (1999).
14.1
Grundlagen
Betrachten wi r zuerst ein pa ar weitere Beispi ele für Gra phen (sie he Abbildung 14.1) . Die Darstellungsmöglichkeiten sind se hr vielfäl tig, s o dass di es nur ein klei ner Ausz ug ist. Es existieren – um zwei spezielle Beispiele zu nenne n – vollständige Graphen, bei denen jeder Knoten mit jedem anderen verbunden ist (siehe Abbildung 14.1 (a) und (b)), oder vollständig bipartite G raphen, bei denen je der K noten mit allen gege nüberliegenden ei ner Zweierreihe verknüpft ist (wie die Abbildung im Graphen (c) zeigt).
244 14
Graphen
(a)
(b)
Abb. 14.1
(c)
(d)
(e)
(f)
Beispiele für Graphen
Definition: (Graph) Ein Graph G = (V, E) ist wie folgt definiert: • V ist eine Menge von Knoten mit Anzahl |V| = n, • E ist eine Menge von Kanten mit Anzahl |E| = m, • eine Kante ist ein paar von zwei Knoten v, w ∈ V mit (v, w) ∈ E (E ⊆ V × V). Definition: (Teilgraph) Ein Graph H = (VH, EH) heißt Teilgraph des Graphen G = (VG, EG), wenn VH ⊆ VG und EH ⊆ EG gilt. Andere Bezeichnungen sind Subgraph oder Untergraph. Beispiel: In Abbildung 14.1 ist (b) Teilgraph von (a), (e) und (f) Teilgraphen von (d) und (e) Teilgraph von (f). Definition: (Nachbarschaft) Gegeben v ∈ V. Die Nachbarschaft N(v) von v bezeichnet die Knoten, die direkt durch eine Kante mit v verbunden sind: N(v) = {w | (v, w) ∈ E}. Den Betrag |N(v)| bezeichnen wir als den Grad deg(v) von v. Beispiel: In Abbildung 14.2 ist N(v1) = {v2, v4, v7} und deg(v1) = 3.
v1
v2 v4
v3
v5
Abb. 14.2
v7 v6
Graph mit 7 Knoten
Definition: (Pfad) Eine Folge von Kanten (v0, v1), (v1, v2), …, (vl–1, vl) heißt Pfad, wenn alle vi verschieden sind. v0 und vl sind der Start- und Endknoten und l die Länge des Pfades. Beispiel: In Abbildung 14.2 ist (v1, v4), (v4, v5), (v5, v6) ein Pfad der Länge 3. Definition: (Kreis) Ein Pfad mit vl = v0 heißt Kreis.
14.2 Traversierung von Graphen
245
Beispiel: In Abbildung 14.2 existiert ein Kreis: (v1, v2), (v2, v3), (v3, v5), (v5, v4), (v4, v1). Definition: (zusammenhängend) Ein G raph G hei ßt zus ammenhängend, we nn es für alle v, w ∈ V einen Pfad gibt, der v und w verbindet. Beispiel: In Abbildung 14.1 sind alle Graphen außer (e) zusammenhängend. Definition: (Wald) Enthält ein Graph keine Kreise, so sprechen wir von einem Wald. Beispiel: In Abbildung 14.1 sind (e) und (f) Wälder. Definition: (Baum) Ist ein Wald zusammenhängend, so bezeichnen wir ihn als Baum. Beispiel: In Abbildung 14.1 ist (f) zusätzlich ein Baum. Definition: (Spannbaum) Ist der Teilgraph T = (VT, ET) des G raphen G = (VG, EG) mit den Eigenschaften |VT| = n = |VG| und ET ⊆ EG ein Baum, so heißt T auch Spannbaum. Er enthält alle Knoten von G. Diese letzte Definition benötigen wir später für Prim’s Algorithmus zur Berechnung minimaler Spannbäume.
14.2
Traversierung von Graphen
Mit diesen Grundlagen können wir nun Betrachtungen zum Du rchlauf von Graphen anstellen. Es beste hen zwei na he liegende Möglic hkeiten, um alle Knote n in einem Graphe n z u besuchen. Daz u wä hlen wir einen A nfangsknoten beliebig aus. Die eine Methode be sucht zuerst alle Nachbarn der Startknotens, danach wiederum deren Nachbarn usw. Wir sprechen von so genannter Breitensuche (engl. Breadth-First-Search). Im anderen Fa ll besuchen wir den ersten (linken) Nachbarn, danach nochmals dessen er sten (linken) Nachbarn usw. bis ei n Knoten keine Nachbarn mehr hat. Am Ende kehren wir um, laufen zur er sten Kante zurück, die zu eine m noch nicht besuchten Knoten führt, und gehen wieder bis an das Ende des „ersten“ (linken) Pfades im Graphen. Diesen Algorithmus nennen wir Tiefensuche (engl. Depth-First-Search). Beispiel: Anhand eines Baumes lassen sich die bei den Verfahren gut veranschaulichen (siehe Abbildung 14.3). Die Breitensuche besucht zuerst alle Knoten in einer Ebene, um dann in der nächsten weiterzumachen. Es ergibt sich die Reihenfolge: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15. Die Tiefensuche dagegen steigt soweit wie möglich einen Pfad hinab und wir erhalten: 1, 2, 4, 8, 9, 5, 10, 11, 3, 6, 12, 13, 7, 14, 15.
246 14
Graphen 1
2 4
3 5 6
7
8 9 10 11 12 13 14 15
Abb. 14.3
Breiten- und Tiefensuche
Praktische A nwendung fi nden diese Ve rfahren in S uchmaschinen bei de r Indizier ung von Webseiten.
14.2.1
Breitensuche (BFS-Algorithmus)
Der Algorithmus für die Breitensuche, auch BFS-Algorithmus genannt, l ässt sich wie folgt beschreiben. Wir gehen da von a us, dass eine F IFO-Warteschlange queue zur Verfügung steht. Gegeben sei auße rdem ein Gra ph G = (V, E). Jeder Knoten v ∈ V trage eine Markierung. Alle Knoten seien zu Beginn unmarkiert und queue trage keine Elemente. Den für eine tatsächliche Ausführung benötigten Prozedurrahmen lassen wir zur Vereinfachung weg. while ∃ unmarkiertes v ∈ V do r := wähle einen beliebigen Knoten aus V; append(queue, r); while not isempty(queue) do v = : first(queue); queue = : rest(queue); if v nicht markiert then markiere v; for ∀ w ∈ N(v) and w unmarkiert do append(queue, w) endfor endif endwhile endwhile Um die Breitensuche ge nau wie im Beispiel zu Abbildung 14.3 beschrieben zu realis ieren, müssten wir eine O rdnung a uf den Knoten ei nführen. W ir vernachlässigen dies und gehen davon aus, dass es so funktioniert, wie oben durchgeführt. Die Bedingung de r Wiederholungsanweisung (for-Schleife) prüft für jeden Nachbarn w des aktuellen Knotens, ob er nicht markiert ist. Liegt dieser Fall vor, so wird w an die Warteschlange gehängt. Beispiel: Im Graphen aus Abbildung 14. 2 markieren wir die Knoten in der Reihe nfolge v1, v2, v4, v7, v3, v5, v6. Der Startknoten ist der Knoten v1. Während der Ausführung fügen wir die Nachbarn m it dem kleinsten I ndex je weils zuer st in di e W arteschlange ein, um die ge-
14.2 Traversierung von Graphen
247
wünschte Ordnung auf den Knoten zu erhalten. Die sieben Zustandsaufnahmen von queue in Abbildung 14.4 zeigen dessen Inhalt jeweils zu Beginn der inneren while-Schleife. Darunter finden wir de n da nach m arkierten Knoten (falls er noch nic ht markiert war) und rechts daneben de n Zustand der Wartesc hlange nach der Beendigung der for-Schleife (al so den Zustand, zu dem alle noch nicht markierten Nachbarn in der queue sind).
v1
v2
v1
v2
v3
v5
v3
Abb. 14.4
14.2.2
v4
v7
v4
v7
v3
v7
v4
v5
v5
v5
v5
v3
v5
v7
v6
v6 v6
BFS-Algorithmus
Tiefensuche (DFS-Algorithmus)
Ähnlich dem Verfahren de r Breitensuc he gebe n wir eine Methode f ür die Tiefensuc he an (auch als DFS-Algorithmus bezeichnet). Die Voraussetzungen sind die gleichen, anstelle von queue verwenden wir nun einen LIFO-Keller stack. while ∃ unmarkiertes v ∈ V do r := wähle einen beliebigen Knoten aus V; push(stack, r); while not isempty(stack) do v := top(stack); stack := pop(stack); if v nicht markiert then markiere v; for ∀ w ∈ N(v) and w unmarkiert do push(stack, w) endfor endif endwhile endwhile Im Vergleich zur Breitensuche sind die Warteschlangenoperationen lediglich durc h Kelleroperationen ersetzt worden, alles andere ist identisch. Zur Realisierung der Tiefensuche wie
248 14
Graphen
in Abbildung 14.3 bra uchen wir wieder eine spezielle Ord nung auf den Knoten. Wir gehen auch hier davon aus, dass diese vorhanden ist und von unseren Operationen beachtet wird. Beispiel: Im Graphen aus Abbildung 14. 2 markieren wir die Knoten in der Reihe nfolge v1, v2, v3, v5, v4, v6, v7. Der Startknoten ist wi eder v1. Die gewün schte Ord nung erhalten wir, wenn wir die Nachba rn mit höherem Index zuer st auf de n Keller setzen. F ür die Z ustände des Kellers in Abbildung 14.5 gilt das gleiche wie im Beispiel zur Breitensuche.
v1 v1
v2
v3
v5
v4
v4
v4
v7
v7
v7
v2
v3
v5
v4 v6
v6
v4
v4
v4
v7
v7
v7
v4
Abb. 14.5
14.3
v6
v7 v7
DFS-Algorithmus
Kürzeste Pfade (Dijkstra’s Algorithmus)
Aus Abbildung 9.1 ke nnen wir bereits das Pr oblem, welches wir in diesem Abschnitt lösen. Die Frage nach dem kürzesten Weg von einem Knoten x zu einem Knoten y. Betrachten wir Abbildung 14.6 genauer, so stellen wir fest, dass wir letztlich alle Knoten besuchen müssen, um herauszufinden, wo der kürzeste Pfad zwische n dem Knoten v1 und v7 entlang führt. Anfangs laufen wir der Kante mit der „1“ nach. Kommen wir bei der „10“ an, so könnten die beiden Wege auf der rechten Hälfte des Bildes kürzer sein. Also werden wir dort weiter nach einem kürzeren Pfad suchen.
14.3 Kürzeste Pfade (Dijkstra’s Algorithmus)
249
v1 4
1
v4
5 v2
6
v3 3
1
4 v6
v5 1
10 v7
Abb. 14.6
Dijkstra’s Algorithmus kantenmarkierter Graph
Beispiel: Wo ist ein s olches Verfahren in der Praxis brauchbar? Das schon m ehrmals angeführte Beispiel (siehe Abbildung 14.6) stellt eine Abstraktion der Routenplanung dar, wie sie mittlerweile auf mehreren Webseiten im Internet angeboten wird. Bevor wir uns dem Algorithm us für kürz este Pf ade ge nauer widm en, wollen wir noch ein paar Grundlagen betrachten. Gegeben sei ein Graph G = (V, E). Zur Darstellung de r Entfernungen führen wir eine Distanzfunktion d ein: d : E → R+. Sie gibt für eine Kante e ∈ E den positiven Abstand zwischen den beiden Knoten zurück. Für nicht vorhandene Kanten (x, y) ∈ E gilt d(x, y) = +∞. Probleme zur Berechnung von kürzesten Pfaden lassen sich in drei Klassen aufteilen: • single-pair-shortest-path (spsp): die kürzeste Entfernung zwischen zwei einzelnen Knoten x und y, • single-source-shortest-path (sssp): die kürzeste Entfernung von ei nem Knoten x zu allen anderen Knoten, • all-pairs-shortest-path (apsp): die kürzeste Entfernung zwischen allen Knoten eines Graphen. Bemerkung: Jeder Algorithmus, der das spsp-Problem löst, liefert auch gleichzeitig eine Lösung des sssp-Problems. Der im Anschluss betrachtete Algorithmus für das sssp-Problem wurde zuerst von Dijkstra angegeben und ist deswegen als Dijkstra’s Algorithmus bekannt. Bevor wir den Algorithm us ange ben, seie n die wichtigsten Va riablen er klärt. De r K noten s ∈ V sei unser Start knoten, dis[v] gibt de n Abstan d eines beliebi gen v ∈ V zu s an. D ie Menge S beinhaltet die bereits besuchten Knoten und ist anfangs leer. Der Array from merkt
250 14
Graphen
sich den kürzesten Pfad, indem er nach Ende des Algorithmus für jeden Knoten den Vorgängerknoten beinhaltet. initialisiere einen Fibonacci-Heap FH, der alle Knoten v ∈ V mit Schlüssel dis[v] enthält; setze S := ∅, dis[s] := 0, ∀ v ∈ V\{s}: dis[v] = +∞; for ∀ v ∈ V\{s} do from[v] := s endfor from[s] := null; while S ≠ V do v := findMin(FH); deleteMin(FH); S := S ∪ {v}; for ∀ w ∈ V – S and d(v, w) < +∞ do if dis[v] + d(v, w) < dis[w] then dis[w] := dis[v] + d(v, w); decreaseKey(FH, w, dis[w]); from[w] := v endif endfor endwhile Die ersten sieben Zeilen des Algorithm us init ialisieren alle nötigen Daten. Die restlichen führen die eigentliche Berechnung aus. Die äußere while-Schleife terminiert, wenn S und V identisch sind. Dies ist irgendwa nn der Fall, weil de r Fi bonacci-Heap alle Knoten a us V enthält und bei jedem Schleifendurchlauf ein Knoten aus FH entfernt und in S abgelegt wird. Die Bedingung der for-Schleife wählt alle benachbarten Knoten w von v, welche noch nicht in S sind (also alle noc h nicht bes uchten Nac hbarn). F ür die Knoten w prüft d ie ifAnweisung, ob die Pfadlänge von s über v nach w kürzer ist als der momentane Abstand von s nach w (d.h. wir suchen die optimale Lösung zu diesem Zeitpunkt). Falls ja, so speichern wir die kürzere Pfadlä nge in dis[w], akt ualisieren de n Schlüssel im Fibonacci-Hea p und merken uns den Vorgänger v von w in from. Beispiel: Gegeben sei der G raph in A bbildung 14. 6. v1 s ei der Start knoten. Die folgenden Tabellen geben die Belegung de r Variablen nach der Initialisierung und nach jedem Schleifendurchlauf an. Ist ein Ele ment noc h im Fibonacci-Heap enthalten, s o trägt die Zeile FH einen Punkt (•). Das Kreuz (×) signalisiert unter FH das Extrahieren des Minimums im aktuellen Schleifendurchlauf und unter Nachbar, dass dieser Knoten noch nicht besuchter Nachbar von v ist. An diesen Stellen ändern sich dis[vi] und from[vi].
14.3 Kürzeste Pfade (Dijkstra’s Algorithmus) S Init
1. Durchlauf
∅
v1 dis[vi] 0 FH Nachbar from[vi]
S v1 dis[v
]
i
FH Nachbar from[vi]
S 2. Durchlauf
v1, v2 dis[v
]
i
FH Nachbar from[vi]
3. Durchlauf
S v1, v2 dis[v i] v5 FH Nachbar from[vi]
S 4. Durchlauf
5. Durchlauf
v1, v2 dis[v i] v5, v4 FH Nachbar from[vi]
S v1, v2 dis[v i] v5, v4 FH v3 Nachbar from[vi]
251
v2
v3
v4
v5
v6
v7
+∞
+∞
+∞
+∞
+∞
+∞
•
•
•
•
•
•
•
null
v1
v1
v1
v1
v1
v1
v1 01
v2
v3 54
v4
v5 +∞ 6
v6
v7 +∞
×
• × v1
• × v1
v1
• × v1
•
null
• × v1
v1
v2
v3
v4
v5
v6
v7
×
54 •
•
•
+∞ •
null
v1
v1
v1
v1
v1
v1 01
v2
v3 54
v4
v5 26
v6
v7 12
•
×
•
v1
v2
v1
• × v5
v5
v6
v7
01
null
v1
• × v1
v1
v2
v3
v4
0
15 •
42 ×
•
26 • × v2
68 •
v1
• × v4
null
v1
v1
v1
v2
v1
v1 0
v2 15
v3
v4 42
v5
v6 68 •
v7
v1
v4
× null
v1
v1
v1
v2
•
252 14
Graphen S
6. Durchlauf
7. Durchlauf
v1, v2 dis[v i] v5, v4 FH v3, v6 Nachbar from[vi]
S v1, v2 dis[v i] v5, v4 FH v3, v6 Nachbar v7 fr om[vi]
v1
v2
01
v3
v4
54
v5
v6
26 ×
null
v1
v1
v1
v2
v1
v1 01
v2
v3 54
v4
v5 26
v6
v7 7 • × v6
v7 7 ×
null
v1
v1
v1
v2
v1
v6
Nach dem siebten Durchlauf können wir den kürzesten Pfa d von v1 nach v7 ablese n. Wir beginnen am Zielknoten unter from[v7] und erhalten v6 als Vorgänger. Dort finden wir in der from-Zeile bereits den Startknoten und kennen somit den kürzesten Pfad: v1, v6, v7. Seien nun |V| = n und |E| = m. Somit ergibt sich für den Algorithmus von Dijkstra unter der Verwendung von Fibonacci-Heaps die in Tabelle 14.1 a ngegebene La ufzeit. Wir fügen n Knoten via insert mit jeweiliger Laufzeit O(1) zur Initialisierung des Fibonacci-Heaps ein. Die anderen Schritte zu Beginn si nd ebenfalls linear und es gilt O(n) für den Start. J edes Element wird genau einmal aus FH entnommen und gelöscht, wodurch sich amortisiert nach Tabelle 13.6 die Laufzeiten von allen findMin- und deleteMin-Operationen ergeben. decreaseKey wird für jede Kante höchstens einmal initiiert, weil sie nur dann ausgeführt wird, wenn ein kürzere r P fad vorliegt, und de r benachbarte Knoten noch nicht besucht wurde. Hat der Algorithmus den Nachbarn w des Knoten v schon bearbeitet, so kommt (v, w) nicht mehr in die Aus wahl durc h die for-Schleife. Wir erhalten als obere Grenze m · O(1). Die Gesamtlaufzeit berechnen wi r durch Addition der ersten vier Zei len ( O(n) k ann im Verg leich zu O(n · log n) vernachlässigt werden). Tab. 14.1
Dijkstra’s Algorithmus Komplexität
Init O(n) findMin deleteMin decreaseKey Gesamt
n · O(1) n · O(log n) m · O(1) O(m + n · log n)
Abschließend sei noch erwähnt, dass sich uns ere Betrachtungen ledigli ch auf positive Entfernungen beziehen. Negative Kantengewichte (Abstände) betrachten wir nicht.
14.4 Minimale Spannbäume (Prim’s Algorithmus)
14.4
253
Minimale Spannbäume (Prim’s Algorithmus)
Der Al gorithmus von Di jkstra ist ein Beis piel für die be kannten Algorithmen zur Ber echnung von kürzesten Pfaden. Wir wollen diese aber nicht alle behandeln, sondern eine weitere Klasse brauchbarer Verfahren untersuchen, die Berechnung von minimalen Spannbäumen. Beispiel: Zur Realisierung des Broadcasts in lokalen Netzen (LANs) benötigen wir ein Wegewahl-Verfahren (R outing), da s alle Router kostenminimal erreicht. Eine M öglichkeit ist das Spanning Tree-Verfahren. Der Spanning Tree beinhaltet alle Router, abe r keine Schleifen, um keine unnötige Netzlast zu verursac hen und um zu verm eiden, dass ein Route r die gleiche Nachricht eines Broadcasts nicht mehrfach erhält. Jeder Router kennt seine Leitungen, die Bestandteil des Spannbaumes sind. Beim Broadcast schickt er das Nachrichtenpaket über alle ausgehenden Spanning Tree-Leitungen weiter (vgl. Tanenbaum, 1996). Aus Abschnitt 14.1 kennen wir die Definition eines Spannbaumes. Wir versehen im Graphen G = (V, E) alle Kanten mit einer Gewichtsfunktion d: d : E → R+. Sie liefert für jede Ka nte e ∈ E ein positives Gewicht . Sei der Teilgraph T ein Spannbau m von G. Ist die Summe der Kantengewichte von T kleiner als die Summe der Kantengewichte aller anderen möglichen Spannbäume von G, so heißt T minimaler Spannbaum (MSB). Beispiel: Abbildung 14.7 z eigt einen m inimalen Spa nnbaum zum Graphen a us Abbildung 14.6. Die durchgezogenen Kanten sind Teil des minimalen Spannbaums.
v1 4
1
v4
5 v2
6
v3 3
1
4 v6
v5 1
10 v7
Abb. 14.7
Minimaler Spannbaum
Der minimale Sp annbaum k ann mit d em Alg orithmus von Prim b erechnet werd en. Er verwendet einen Fibonacci-Heap FH und einen Array namens from, der wie bei Dijkst ra’s Al-
254 14
Graphen
gorithmus den Vorgänger eines Knotens v ∈ V speichert. Der Array key beinhaltet zu jedem Knoten w den aktuellen Schlüssel, d.h. er stellt das Gewicht der momentan minimalen Kante zu w zur Verfügung. initialisiere einen Fibonacci-Heap FH mit Knotenmenge V; setze alle Schlüssel key[vi] auf +∞; wähle einen Startknoten s beliebig aus; decreaseKey(FH, s, 0); from[s] := null; while FH ≠ ∅ do v := findMin(FH); deleteMin(FH); for ∀ w ∈ V mit (v, w) ∈ E do if w ∈ FH and d((v, w)) < key[w] then key[w] := d((v, w)); decreaseKey(FH, w, key[w]); from[w] = v endif endfor endwhile Die Zeilen ei ns bis fünf bereiten die Berechnung vor. Die W iederholung (while-Schleife) terminiert nach dem Entfernen des letzten Elements aus FH (je Durchlauf wird ein Element entfernt). Die Bedingung der for-Schleife extrahiert alle Nachbarn w des Knoten v. Wenn der Knoten w noch nicht besucht wurde (wir also noch nicht wissen, welche Kante die minimale für diesen Knoten ist) und das Gewicht der Kante (v, w) kleiner als der aktuelle Schlüssel ist (also (v, w) eine kürzere Verbindung zu w ist als die bisherige), s o ändern wir die ents prechenden Daten, um uns diese Ka nte z u m erken. Durch den Ei nsatz eine s Fibonacci-Heaps verwenden wir zu jedem Zeitpunkt der A usführung die optimale Lösung, als o im mer die minimale ausgehende Kante eines K noten zu m nächsten noc h nic ht besuchten. Dadurch erreichen wir jeden K noten im Graphen auf dem Weg mit der geringsten Summe der Kantengewichte. Beispiel: D er minimale Sp annbaum au s Ab bildung 14 .7 errechnet sich durch Au sführung des obigen Algorithmus. v1 ist wieder unse r Startknoten. Die Tabellen geben wie oben Auskunft übe r di e Belegun g de r Variablen na ch de r Initialisierung und je dem Durchl auf der while-Schleife. Die Punkte ( •) ke nnzeichnen die noch i n FH enthaltenen K noten und das Kreuz ( ×) den i m aktuellen Durchlauf ausgewählten. Ein Kre uzchen in der Zeile Nachbar gibt an, dass dieser Knoten noch nicht besuchter Nachbar des gerade bearbeiteten ist.
14.4 Minimale Spannbäume (Prim’s Algorithmus)
Init key[v FH fr
v1
v2
v3
v4
v5
v6
v7
•
+∞ •
+∞ •
+∞ •
+∞ •
+∞ •
+∞ •
v1 01
v2
v3 54
v4
v5 +∞ 6
v6
v7 +∞
×
• × v1
• × v1
•
• × v1
•
null
• × v1
v1
v2
v3
v4
v5
v6
v7
0
15 ×
•
41 •
6 •
+∞ •
]0
i
Nachbar om[vi]
1. Durchlauf FH Nachbar fr
key[vi]
2. Durchlauf FH
key[vi]
om[vi]
255
null
• × v2
Nachbar om[vi]
null
v1
v1
v1
v2 13
v4 41
v5
key[vi]
v1 0
v3
3. Durchlauf FH Nachbar fr
v6 6
v7 10
•
×
•
v1
v2
v1
• × v5
v5
v6
v7
4. Durchlauf FH
key[vi]
6 •
10 •
fr
fr
5. Durchlauf FH fr
om[vi]
null
v1
• × v5
v1
v2
v3
v4
0
13 ×
41 •
v1
Nachbar om[vi]
null
v1
v5
v1
v2
v1
v5
v1 0
v2 13
v3
v4 41 ×
v5
v6 64 •
v7
key[vi]
v1
v2
Nachbar om[vi]
null
v1
v5
v1
• × v4
256 14
Graphen v1
6. Durchlauf
fr
7. Durchlauf
fr
key[vi] FH Nachbar om[vi]
key[vi] FH Nachbar om[vi]
v2
01
v3
v4
34
v5
v6
11
null
v1
v5
v1
v2
v1 01
v2
v3 34
v4
v5 11
• × v7
v6
v7 4 × v4
v7 4
× null
v1
v5
v1
v2
v7
v4
In der letzten Zeile ( from[vi]) lässt sich nun der m inimale Spannba um ablesen: (v1, v2), (v1, v4), (v2, v5), (v3, v5), (v4, v7), (v6, v7). Für sieben Knoten benötigen wir nur sechs Kanten, darum enthält eine Spalte nach wie vor den Wert null. Mit |V| = n und |E| = m ergibt sich für Prim’s Algorithmus die Laufzeit aus Tabelle 14.2. Sie gilt nur bei Verwendung von Fibonacci-Heaps. Die Herleitung der Komplexitäten ist äquivalent zu jener von Dijkstra’s Algorithmus. Tab. 14.2
Prim’s Algorithmus Komplexität
Init O(n) findMin deleteMin decreaseKey Gesamt
n · O(1) n · O(log n) m · O(1) O(m + n · log n)
Die hier be handelte Variante heißt auch Pri m’s Algorithmus (1. Variante). Es existiert noc h eine 2. Variante, die wir an dieser Stelle jedoch nicht diskutieren.
14.5
Aufgaben
Aufgabe 14.1: Eine Vision: I m vereinten E uropa der Zukunft sollen i nnereuropäische Flugzeugbewegungen obsolet sein. Viele Metropo len sind durch Hochgeschwindigkeitsbahnstrecken m iteinander verbunden. Es existiere n die fol genden Strec ken m it Entfer nungs- und Reisezeiten (die Angaben sind zur Vereinfachung der Berechnungen nur schematisch):
14.5 Aufgaben Verbindung München – Hamburg München – Berlin München – Paris München – Wien München – Bern München – Rom Berlin – Paris Hamburg – Berlin Hamburg – Brüssel Paris – Brüssel Paris – Lyon Bern – Lyon Bern – Rom Bern – Wien Lyon – Madrid Wien – Rom Wien – Berlin
a)
257 Entfernung (in Kilometern)
Reisezeit (in Stunden)
900 950 750 400 300 600 1100 400 500 300 400 400 600 600 600 700 700
2 3 2 1 1 2 3 1 2 1 1 3 3 2 2 2 3
Modellieren Si e diese Verbindungen dur ch einen kantenmarkierten Graphen. Führen Sie für die Städtenamen geeignete Abkürzungen ein.
b) Ihr Standort sei München. Besuchen Sie alle Städte mit Hilfe der Breitensuche. c)
Ihr Standort sei München. Besuchen Sie alle Städte mit Hilfe der Tiefensuche.
d) Geben Sie die kürzesten Verbindungen von München zu allen anderen Städten an. Verwenden Sie dazu Dijkstra’s Algorithmus. e)
Geben Sie die schnellsten Verbindungen von München zu allen anderen Städten an. Verwenden Sie dazu ebenfalls Dijkstra’s Algorithmus.
f)
Auf allen Teilstrecken pe ndeln Hochge schwindigkeitszüge. Gewisse Güter sollen kostenminimal von eine r beliebigen Stadt auf alle anderen Städte verteilt werden können. Die Kosten steigen, wenn sich die Summe der Fahrzeiten der zur Beförderung beauftra gten Pendelzüge erhöht. Ge ben Sie eine optim ale Lösung unte r Verwendung von Prim’s Algorithmus an.
Aufgabe 14.2: Dijkstra’s Algorithm us ist mit Hilfe eines Fibo nacci-Heaps realisiert. Geben Sie die Gesam tkomplexität des Algorithm us unter V erwendung ei ner B inomial Queue an. Nehmen Sie an, dass alle Operationen der Binomial Queue die Laufzeit O(log n) haben.
15
Allgemeine Optimierungsmethoden
In allen bis herigen Ka piteln habe n wir f ür spezielle Problem stellungen entsprechende Lösungen besprochen und bea rbeitet. Allgemeine Vorge hensweisen z ur Erstellung oder Optimierung von Algorithmen kennen wir noch nicht. Optimierungsmethoden kommen oft dann zum Einsatz, wenn wir Probleme berechnen, deren Laufzeit exponentiell ist. Gerade da nn ist es wichtig, jede nur denkbare Möglichkeit zu nut zen, um eine Verbesserung der Laufzeit zu erreichen. Wir lernen drei Optimierungsverfahren kennen: • Dynamisches Programmieren, • Greedy-Algorithmen, • Backtracking. Vorab sei ges agt, da ss wir nicht vorhe rsagen könne n, wann und wie genau welche dieser Heuristiken brauchbar ist. Dies hängt immer von der Problemstellung ab. Es liegt vor allem in der Hand des Entwicklers, durch geschickten Einsatz einer oder mehrerer Methoden effizient und möglichst leicht verständlich und nachvollziehbar zur Lösung zu gelangen. Der Vollständigkeit hal ber se ien an diese r Stelle noch probabilistische Verfahren gen annt, die zufällig erzeugte Größen verwenden und in gewissen Wahrscheinl ichkeitsschranken terminieren oder ein korrektes Resultat für eine Problem stellung liefern (vgl. B roy, 1998, Band 2).
15.1
Dynamisches Programmieren
Wir betrachten zunächst eine Rechenvorschrift zur Bestimmung der n-ten Fibonacci-Zahl Fn. Diese ist wie folgt definiert: Fn = Fn–1 + Fn–2 mit n ≥ 2 und F1 = 1, F0 = 0. Die Berechnung lässt sich rekursiv einfach umsetzen. function fib (nat n): nat
260
15 Allgemeine Optimierungsmethoden
return if n = 0 then 0 elseif n = 1 then 1 else fib(n-1) + fib(n-2) Entfalten wir die Rekursion f ür ein beliebi ges n ≥ 2 mehrmals, so erhalten wir das folgende Bild: fib(n)
= fib(n–1) + fib(n–2) = fib(n–2) + fib(n–3) + fib(n–3) + fib(n–4) =…
Bereits in der zweiten Zeile fällt auf, dass fib(n–3) doppelt berechnet wird. Ziel ist nun, solche Aufrufe nur ei nmal auszuführen. Wir müssen uns ei ne Lösung irgendwie merken, wenn wir sie schon berechnet haben und eventuell später wieder brauchen können. Die FibonacciZahlen kann auch anders organisiert werden. function fibdyn (nat n, i, j, k): nat return if n = 0 then 0 elseif n = 1 then 1 elseif n = k then i else fibdyn(n, i+j, i, k+1) Zur Berechnung von Fn rufen wir fibdyn wie folgt auf: fibdyn(n, 1, 0, 1). Die Kontrolle, dass fibdyn tatsächlich die Fibonacci-Zahlen berechnet, sei dem Leser überlassen. Beispiel: Wir expandieren die Parameterwerte für n = 5: fibdyn(5, 1, 0, 1) fibdyn(5, 1, 1, 2) fibdyn(5, 2, 1, 3) fibdyn(5, 3, 2, 4) fibdyn(5, 5, 3, 5) Das Ergebnis lautet 5. Der entscheidende Vorteil liegt dari n, dass wir keine Doppelberechnungen mehr ausführen. Wir merken uns jeweils die beiden Vorgänger der Fibonacci-Zahl Fn. Somit kennen wir ein Pr oblem, das mit dynamischer Programmierung arbeitet. Bei naiven Ansätzen kommt es vor, dass Tei llösungen im Verlauf der Be rechnung mehrfach besti mmt werden. Durch geschickte Umorganisation schaffen wir es, uns diese Teilergebnisse zu merken, um zu gegebener Zeit darauf zurückzugreifen. Die Verbesserung der Laufzeit-Effizienz hat bei dynamischer Programmierung den Nachteil, dass m ehr Speicherplatz be nötigt wird (Abspeiche rung der Z wischenergebnisse i n Feldern oder Tabellen) . Diese und weitere Betrachtung en zu dynamischer Pr ogrammierung sind in Broy (1998, Band 2) und Aigner (1996) zu finden.
15.2 Greedy-Algorithmen
15.2
261
Greedy-Algorithmen
Betrachten wir die Algorithmen von Dijkstra und Prim aus den Abschnitten 14.3 und 14.4, so finden wir jeweils ein besonderes Merkm al. Zu jeder Zeit während der Berechnung wählen wir die derzeit optimale Lösung. Wir nehmen den Knoten aus dem Fibonacci-Heap, der mit geringstem Abstand oder Gewicht versehen ist. Wir arbeiten mit der gierigen Strategie (engl. greedy strategy). Sie besagt (siehe Ziegenbalg, 1996): Erledige immer als Nächstes den noch nicht bearbeiteten fettesten (d h. größten oder kleinsten, teuersten, billigsten, optimalsten, …) Teilbrocken des Problems. Was wir dabei unter fett verstehen, hängt vom Problem ab. Normalerweise führt die gierige Strategie zu recht guten Lösungen, vorausgesetzt sie ist anwendbar. Merke: Greedy-Algorithmen müssen nicht unbedingt zu e iner optimalen Lösung führen. Es ist auch nicht sicher, dass die best mögliche berechnet wird. Dies muss gesondert untersucht werden. Im Anschluss ein Beispiel dazu. Beispiel: W ir stellen uns vor, dass wir zwei Server betrei ben, die gleichzeitig eingehende Anfragen von versc hiedenen Clients bearbeiten und bea ntworten. Es sei bekannt, wie lange die Bearbeitung einer Anfrage dauert. Bei A nkunft werden die Anfragen in eine n Wartebereich (Puffer) abgelegt, aus dem sich die beiden Server bedienen und Ihre Aufgabe erledigen. Zum Zeitpunkt t seien nun sieben Aufträge mit den Be arbeitungszeiten in Tabelle 15. 1 vorhanden. Tab. 15.1
Bearbeitungszeit für Anfragen
Anfragenummer Bearbeitungszeit
A1 A2 A3 A4 A5 A6 A7 4434333
Zu jedem Zeitpunkt soll unsere Auswahlstrategie der nächsten Anfrage derart optimal sein, dass alle Aufträge in m inimaler Gesam tzeit bearbeitet werde n. Verwenden wir die gie rige Strategie und s eien die Anfragen mit längerer Bearbeitungszeit die fetten Brocken, so erhalten wir eine Gesamtdauer von 13 Zeiteinheiten, wenn beide Server sofort nach B eendigung der einen Aufgabe zur nächsten übergehen (siehe Abbildung 15.1).
262
15 Allgemeine Optimierungsmethoden 4
Server 1
A1
Server 2
A2
Abb. 15.1
8
12
A4
A6
A3
A5
A7
Client-Server mit Greedy Strategy
Wie Abbildung 15.2 zeigt, existiert eine bessere Auswahl, so dass wir eine Gesamtzeit von 12 erreichen. In diesem Fall ist die gierige Strategie also nicht die optimale Lösung.
4 Server 1 Server 2
8
A1
A3
A2
A5
Abb. 15.2
Client-Server ohne Greedy Strategy
15.3
Backtracking
12 A4
A6
A7
Aus Kapitel 14.2.2 kennen wir die Tief ensuche. Wir laufen ei nen Pfad in de n Graphen hinein, bis wir nicht mehr weiter kommen. Danach kehren wir um, gehen solange zurück, bis wir einen noch nicht besuchten Pfad finden und laufen diesen entlang. Diese Art und Weise des Vorgehens nennen wir Backtracking (vgl. Ziegenbalg, 1996). Mit dem Prinzip „Tiefe zuerst“ steigen wir in die Tiefe hinab, bis wir eine Lösung erkennen oder feststellen, dass es auf dem eingeschlagenen Pfad keine Lösung gibt. Falls wir auf dem Weg von oben nach unten eine „verbotene“ Stelle erreichen, kehren wir zum nächst höheren Knoten zurück (backtracken) und untersuchen die folgende nach unten führende Kante. Beispiel: Betrachten wir das 4-Damen-Problem. Die Aufgabe lautet, vier Damen auf einem 4x4-Schachbrett so unterzubringen, dass sie sich nach Schachregeln nicht gegenseitig schlagen können (siehe Abbildung 15.3 links). Für die Nicht-Schachspieler: eine Dame im Schach kann alle Figuren horizontal, vertikal und diagonal schla gen. Die Dame auf (1, 1) in Abbildung 15.3 (rechts) schlägt die Damen auf (1, 3), (3, 1) und (4, 4), jedoch nicht diejenige auf Position (2, 3).
15.3 Backtracking 1
2
3
263
4
1
1
1
2
2
3
3
4
4
Abb. 15.3
2
3
4
Ein 4x4-Schachbrett
Wir benötigen eine geei gnete Darstellung des Schachbrettes, um eine Lösung für das Problem zu suchen. Dafür verschieben wi r unsere vier Dam en nur in der jeweiligen Spalte des Schachbrettes. Dame 1 steht in der ersten und Dame 4 in der vierten S palte. Dadurch lassen sich alle Positione n der Dam en in einem Baum darstellen (siehe A bbildung 15.4). Die Ebenen 1 bis 4 si nd die S paltennummern (Damen 1 bis 4). Die Kantenmarkierungen geben die Zeilenposition der je weiligen Dam e auf de m Schachbret t an. Mit Kreuz markierte Knoten sind die verbotenen Positionen (also solche, bei denen eine Dam e ei ne andere schlagen kann). Bei der sukzessi ven Aufstellung der Fi guren hat die erste Dam e 4 P ositionen, die zweite 3, die dritte 2 und die vierte 1 Position zur Auswahl. Dadurch ergeben sich insgesamt 24 verschiedene Stellungen (4 ⋅ 3 ⋅ 2 ⋅ 1 = 24).
1
D1
D2
D3
D4
Abb. 15.4
4 2
2
3
4
1
3
3 4
1
3
Backtracking Baumdarstellung von Damenpositionen (4x4-Schachbrett)
Mit dem Back tracking-Verfahren könne n wir um kehren, falls ein Kreuz erreicht wir d. Die erste Lösung haben wir gefunden, wenn die Pfadlänge 4 ist (siehe Abbildung 15.5).
264
15 Allgemeine Optimierungsmethoden 1
2
3
4
1 2 3 4
Abb. 15.5
Geschickte Damenverteilung
Anhang
16
Die PseudoProgrammiersprachen PPS und FPPS
Im ersten Teil des vorliegenden Buc hes wurden die beiden Pse udo-Programmiersprachen PPS und FPPS eingeführt. Die Syntax der Sprachen wird in den beiden nächsten Abschnitten in BNF-Notation definiert. Im zweiten Teil „Algorithm en und Datenstrukturen“ kommt ebenfalls diese Syntax zur Anwendung. An manchen Stellen wird jedoch davon abgewichen, um eine kompaktere Darstellung zu erreichen, wodurch die Syntax in di esem Teil des Buches nicht immer exakt der im Folgenden definierten entspricht.
16.1
Merkblatt zu PPS
Konstante ::= | | | | ::= true | false ::= ´´ | ´´ ::= | ::= 0 | 1 | … | 9 ::= a | b | … | z ::= ! | „ | $ | … ::= ( 1 | 2 | … | 9) [] [] [] [] [] ::= „ {}“ ::= E ::= . {} ::= (+|-) [] Bezeichner ::= {}
268
16 Die Pseudo-Programmiersprachen PPS und FPPS
Variablendeklarationen ::= var [:= ] {, [:= ] } {; var [:= ] {, [:= ] } } ::= [.] [] ::= ´[´´]´ ::= Sortendeklarationen ::= sort = ::= | | ::= [:] array ::= record {; } end ::= | bool | char | nat | string Deklaration von Unterprogrammen ::= procedure [()]: {;} {;} begin
endproc ::= [var] {, }{, [var] {, } } ::= function ([]): {;} {;} {;} begin {;}
endfct Ausdrücke ::= | | () | | | ::= – | not ::= + | – | * | / | < | ≤ | = | ≠ | ≥ | > | and | or | ^ | … ::= < id> ([ {, }] )
16.2 Merkblatt zu FPPS
269
Anweisungen ::= | | | | | | | ::= := ::= input() ::= output() ::= { ; } ::= if then {elseif then }[else ] endif ::= for := to do endfor ::= while do endwhile ::= return ::= [( {, })] Programm ::= program : {;} {;} {;} {;} begin
end.
16.2
Merkblatt zu FPPS
Konstante ::= | | | | ::= true | false ::= ´´ | ´´ ::= | ::= 0 | 1 | … | 9 ::= a | b | … | z ::= ! | „ | $ | … ::= ( 1 | 2 | … | 9) [] [] [] [] []
270
16 Die Pseudo-Programmiersprachen PPS und FPPS
::= „ {}“ ::= E ::= . {} ::= (+|-) [] Bezeichner ::= {} Definition von Funktionen ::= |
::= {, id}{, {, id} } ::= function ([]): return Ausdrücke ::= | | () | | | | ::= – | not ::= + | – | * | / | < | ≤ | = | ≠ | ≥ | > | and | or | ^ | … ::= < id> ([ {, }] ) ::= if then else Sortendeklarationen ::= sort = ::= | | | | ::= [:] array ::= record {; } end ::= or { or } ::= | bool | char | nat | string | float | empty ::= → {→ }
Literatur M. Aigner: Diskrete Mathematik, 2. durchgesehe ne Aufla ge, Vie weg Braunschweig/Wiesbaden, 1996. M. Broy: Informatik – Eine gru ndlegende Einführung, Band 1: Pr ogrammierung und Rechnerstrukturen, 2. Auflage, Springer Verlag Berlin, 1998. M. Broy: I nformatik – Eine grundlege nde Einführung, Band 2: Systemstrukturen und T heoretische Informatik, 2. Auflage, Springer Verlag Berlin, 1998. C. J. Date: A n I ntroduction to Database Systems, 7th edition, Addison W esley Longman Inc., 2000. R. H. Güting: Datenstrukturen und Algorithmen, B. G. Teubner Stuttgart, 1992. E. W. Mayr: Effiziente Algorithmen und Datenstrukturen, Skriptum zur Vorlesung im Wintersemester 1998/99, Technische Universität München, 1999. K. Mehl horn: Datenstr ukturen und ef fiziente Algorithmen, Band 1: S ortieren und S uchen, 2. Auflage, B. G. Teubner Stuttgart, 1988. T. Ottmann, P. Widmayer: Algorithmen und Da tenstrukturen, 4. Auflage, Spektrum Akademischer Verlag Heidelberg, Berlin, 2002. A. S. Tanenbaum: Computer Networks, 3rd edition, Prentice Hall Inc., 1996. C. Volker, A. Schwill: Duden Informatik, 3. Auflage, Duden-Verlag Mannheim, 2001. H. Wedekind, G. Görz, R. Kötter, R. Inhet veen: Modellierung, Sim ulation, Visualisierung: Zu aktuellen Aufgaben der Informatik. Informatik-Spektrum 21:5, S. 265–272, 1998. J. Ziege nbalg: Algorithm en, Von Ham murapi bis Gödel, Spe ktrum A kademischer Verlag Heidelberg, 1996.
Index (a, b)-Baum 239 Ablauf 3 Ableitung 36 Adresskollision 206 Aktion 9, 13 Aktionsstruktur 10 Algorithmus 19, 81, 155 all-pairs-shortest-path 249 Alphabet 33 Alternative 55 amortisierte Kosten 161 Arbeitsspeicher 32 asymptotische Analyse 159 Aufrufgraph 124 Ausgabe 21, 31, 50, 100 Auswahl 35 Auswertungsfunktion 99 Automat 13 average case 160 AVL-Baum 221 Backtracking 262 Backus-Naur-Form 35
vollständiger 191 Bindungsbereich 83 Binomial Queue 231 Binomialbaum 229 Black Box 75, 167 Block 22, 84 BNF-Regel 35 Breitensuche 246 Bubblesort 187 call by reference 88 call by value 88 Codieren 40 Compiler 33 Computer Aided Software Engineering 40 Currying 135 Datenfluss 76 Datenflussdiagramm 75, 103 dynamisches 120 Datenstruktur 155 rekursive 114 Datentyp 45 abstrakter 169 dynamischer 113
Bedingung 23, 55, 57
Deklaration Funktions- 88, 130 Prozedur- 81 Sorten- 104 Variablen- 46, 50, 60
Belegungsfaktor 206
Dereferenzieren 176
best case 160
determiniert 25
Betriebsmittel 10
Determiniertheit 27
Betriebssystem 10
Determinismus 27
Binärbaum 174
deterministisch 25
Baum 245 B-Baum 241 bedingte Anweisung 55
274
Index
Divide-and-Conquer 189, 199
Information Hiding 167, 169
Divisions-Rest-Methode 207
Inordnung 219
dynamische Programmierung 260
Interpreter 33
Einbettung 143
Iteration 141
Eingabe 21, 31, 50, 100
Kante 11, 244
elementarer Verarbeitungsschritt 22, 44
kaskardierendes Abschneiden 235
endlicher Automat 17, 25, 39
kausale Abhängigkeit 10
Ereignis 9
Kausalitätsrelation 9
Ereignisdiagramm 10
Kellerautomat 17
Euklidischer Algorithmus 19
Kellerspeicher 146
E-V-A-Prinzip 25, 50
Klasse 80
Feld 59
Klassendiagramm 6
Fibonacci-Heap 234
Knoten 11, 244
first-in-first-out 171
Komplexität 160
formale Sprache 33
Komponente 75
Funktion 80, 88, 101 einstellige 134 höherer Ordnung 130 primitiv rekursive 127 rekursive 114 zweistellige 134
Konstruktor 104
Funktional 130
last-in-first-out 146, 173
Funktionaloperator 133
Laufzeit 159
Garbage Collector 175
leere Zeichenkette 33
gierige Strategie 261
Liste 113 einfach verkette 177 zweifach verkettete 179
Glass Box 75, 169 Grammatik 36 Graph 11, 244 Teil- 244 zusammenhängender 245
Konstruktorfunktion 114 Kontrollstruktur 52 Kreis 244 Landau-Symbole 162
Maschinencode 32, 40 Maschinenprogrammierung 32 Maschinensprache 32
Gültigkeitsbereich 83
Methode 80
Hashadresse 206
Modell 3, 40 funktionales 75
Hashfunktion 206 perfekte 208
Modellbildung 3, 4
Hashtabelle 205
Modellierung 3
Häufung primäre 216 sekundäre 218
Modul 89
Heap 192, 193 Heapsort 196 Implementierung 40
Nachbarschaft 244 Nachordnung 219 Nichtterminalzeichen 35 Objekt 80 O-Notation 162
Index allgemeine 164
275 verschränkte 128, 146
Operator 35
Rotation 223
Operatorbaum 220
Rückgabewert 88
Optimierung 259
Schleife 59
optionaler Ausdruck 35
Schlüssel 183, 205
Parameter 85, 100, 103 Ausgangs- 87 Ergebnis- 87 formaler 85
schrittweise Verfeinerung 79
Parametrisierung 116 partielle Anwendung 133 Pfad 244 Pointer 175 polymorph 116, 131 Potential 161 Prinzip von Curry 135 Priority Queue 228 Programm 24, 32 Programmierparadigma 24 Programmiersprache 24, 32 funktionale 97 höhere 32, 34 imperative 43 Pseudo- 43 Programmierstil 24 funktionaler 24, 97 imperativer 24 Programmtext 40 Prozedur 80 Prozess 10, 40 datenverarbeitender 76 Pseudocode 27 Queue 171 Quicksort 188
Seiteneffekt 87 Selektor 61 Semantik 34 Sequenz 22, 52, 170 single-pair-shortest-path 249 single-source-shortest-path 249 Softwareentwicklung 40 Sondieren lineares 214 quadratisches 216 Sondierungsfolge 213 Sorte 45, 59 einfache 45 Funktions- 130 polymorphe 116 variante 113 Sortenparameter 116, 131 Sortieren durch Auswählen 185 Sortieren durch Einfügen 183 Spannbaum 245 minimaler 253 Spur 54 Stack 146, 173 Stapel 146 Struktogramm 28 Strukturelement 22 Stützgraph 128
reguläre Sprache 17, 39
Suchbaum binärer 202 externer 238 interner 221
Rekursion 112, 156 kaskadenartige 122, 146 lineare 121, 143, 157 repetitive 121, 141 vernestete 125, 146, 158
Suche Binärbaum- 202 binäre 199 Interpolations- 201 lineare 198
Rebalancierung 223 Referenz 175
276 sequentielle 198 Synonyme 206 syntaktische Korrektheit 39 Syntax 16, 34 Syntaxregel 36 System 4 Tabelle 61 Term 48, 98, 101, 103 bedingter 107 Terminalzeichen 35 terminierend 25 Terminierung 27, 58 Tiefensuche 247 Transition 11 Turing-Maschine 17, 25 Übergangsbedingung 13 Überläufer 210 Übersetzungsprogramm 32 Universalrechner 31 universelles Hashing 209 Unterprogramm 78 Variable 44 globale 82, 86 lokale 81 syntaktische 35 Variablenkonzept 44 Verbund 60
Index Verifikation 113 Verkettung 35, 107, 210 direkte 211 separate 211 Versickern 194 Verweis 175 virtuelle Maschine 41 von-Neumann-Prinzip 32 Vorordnung 219 Wachstum 160 Wachstumsverhalten 162 Wald 245 Warteschlange 171 Wiederholung 23, 35, 56 worst case 160 Wörter 33 Zeiger 175 Zustand 11, 47 Zustandsänderung 49 Zustandsdiagramm 13 Zustandsmodell 11, 13 Zustandsraum 54 Zustandsübergang 13 Zustands-Übergangsdiagramm 11 Zuweisung 44, 47