Vorlesungsskript zu Informatik I Algorithmieren und Programmieren

Werbung
Vorlesungsskript zu
Informatik I
Algorithmieren und Programmieren
Peter Bachmann
BTU Cottbus
Lehrstuhl Programmiersprachen und Compilerbau
Wintersemester 2002/2003
Inhaltsverzeichnis
1 Vorbemerkungen
1.1 Algorithmen . . . . . . . . . . . . . . .
1.1.1 Begriff . . . . . . . . . . . . . .
1.1.2 Geschichte . . . . . . . . . . . .
1.2 Computer . . . . . . . . . . . . . . . .
1.2.1 Aufbau und Wirkungsweise . .
1.2.1.1 Grundstruktur . . . .
1.2.1.2 Nutzung . . . . . . .
1.2.1.3 Dateiverwaltung . . .
1.2.1.4 Der Speicher . . . . .
1.2.1.5 Der Prozessor (CPU)
1.2.2 Geschichte . . . . . . . . . . . .
1.3 Programmierung . . . . . . . . . . . .
1.3.1 Programmiersprachen . . . . .
1.3.1.1 Begriff . . . . . . . .
1.3.1.2 Klassifikation . . . . .
1.3.1.3 Verarbeitung . . . . .
1.3.2 Geschichte . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
5
5
5
6
7
7
7
8
8
9
14
16
20
20
20
20
21
21
2 Entwurf und Analyse von Algorithmen
2.1 Vom Problem zur Lösung . . . . . . . . . . . . . . . .
2.2 Induktionsprinzipien . . . . . . . . . . . . . . . . . . .
2.2.1 Induktive Definition und Induktionsbeweis . . .
2.2.2 Fallstudie: Wortmengen . . . . . . . . . . . . .
2.2.3 Fallstudie: Funktionen . . . . . . . . . . . . . .
2.2.4 Fallstudie: Datenstrukturen . . . . . . . . . . .
2.3 Effizienz . . . . . . . . . . . . . . . . . . . . . . . . . .
2.3.1 Aufwand . . . . . . . . . . . . . . . . . . . . .
2.3.2 Entwicklungsprinzipien . . . . . . . . . . . . .
2.3.3 Fallstudie: Maximum und Minimum einer Liste
2.3.4 Fallstudie: Sortieren . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
23
23
24
24
26
29
31
34
34
35
36
37
3 Funktionale Programmierung mit Haskell/Gofer
3.1 Allgemeines . . . . . . . . . . . . . . . . . . . . . .
3.2 Sitzungen . . . . . . . . . . . . . . . . . . . . . . .
3.3 Scripts . . . . . . . . . . . . . . . . . . . . . . . . .
3.4 Einführende Programmierbeispiele . . . . . . . . .
3.5 Wichtige Ergänzungen . . . . . . . . . . . . . . . .
3.6 Abschließende Fallstudien . . . . . . . . . . . . . .
3.6.1 Sortieren . . . . . . . . . . . . . . . . . . .
3.6.2 Suchbäume . . . . . . . . . . . . . . . . . .
3.6.2.1 AVL-Bäume . . . . . . . . . . . .
3.6.2.2 (min,max)-Bäume . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
41
41
41
42
45
48
50
50
51
51
55
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
INHALTSVERZEICHNIS
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
58
58
58
60
60
62
62
64
64
65
4 Imperative Programmierung mit C++
4.1 Semantische Grundkonzepte . . . . . . . . . . . . . . . . .
4.1.1 Programmstruktur . . . . . . . . . . . . . . . . . .
4.1.2 Vereinbarungen und Typen . . . . . . . . . . . . .
4.1.2.1 Atomare Typen . . . . . . . . . . . . . .
4.1.2.1.1 Ordinale Typen . . . . . . . . .
4.1.2.1.2 Gleitkomma-Typen . . . . . . .
4.1.2.1.3 Leerer Typ . . . . . . . . . . . .
4.1.2.2 Abgeleitete bzw. strukturierte Typen . .
4.1.2.2.1 Felder . . . . . . . . . . . . . . .
4.1.2.2.2 Strukturen . . . . . . . . . . . .
4.1.2.2.3 Klassen . . . . . . . . . . . . . .
4.1.2.2.4 Vereinigungen . . . . . . . . . .
4.1.2.2.5 Zeiger . . . . . . . . . . . . . . .
4.1.2.2.6 Funktionen . . . . . . . . . . . .
4.1.3 Anweisungen . . . . . . . . . . . . . . . . . . . . .
4.1.3.1 Atomare Anweisungen . . . . . . . . . . .
4.1.3.1.1 Ausdrucks-Anweisungen . . . . .
4.1.3.1.2 Leere Anweisung . . . . . . . . .
4.1.3.1.3 SprungAnweisungen . . . . . . .
4.1.3.2 Strukturierte Anweisungen . . . . . . . .
4.1.3.2.1 Blöcke . . . . . . . . . . . . . . .
4.1.3.2.2 Bedingte Anweisungen . . . . .
4.1.3.2.3 Iterations-Anweisungen . . . . .
4.1.4 Ein- und Ausgabe . . . . . . . . . . . . . . . . . .
4.2 Einführende Programmierbeispiele . . . . . . . . . . . . .
4.2.1 Allgemeines . . . . . . . . . . . . . . . . . . . . . .
4.2.2 Rekursion versus Iteration . . . . . . . . . . . . . .
4.2.3 Sortieren . . . . . . . . . . . . . . . . . . . . . . .
4.3 Der Weg zur objektorientierten Programmierung . . . . .
4.3.1 Dynamische Objekte . . . . . . . . . . . . . . . . .
4.3.2 Kapselung . . . . . . . . . . . . . . . . . . . . . . .
4.3.3 Vererbung und Polymorphismus . . . . . . . . . .
4.4 Abschließende Fallstudien . . . . . . . . . . . . . . . . . .
4.4.1 Speichern und Suchen . . . . . . . . . . . . . . . .
4.4.1.1 AVL-Bäume . . . . . . . . . . . . . . . .
4.4.1.2 (min,max)-Bäume . . . . . . . . . . . . .
4.4.1.3 Hashing . . . . . . . . . . . . . . . . . . .
4.4.1.3.1 Hashfunktionen . . . . . . . . .
4.4.1.3.2 Rehash bei offener Adressierung
4.4.1.3.3 Zur Effizienz . . . . . . . . . . .
4.4.1.3.4 Modifikationen . . . . . . . . . .
4.4.2 Flüsse und Wege in gerichteten Graphen . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
67
67
67
69
69
69
70
70
70
70
70
71
71
71
71
73
73
73
75
75
76
76
77
78
79
84
84
85
87
90
90
95
97
102
102
102
104
107
107
108
108
109
111
3.6.3
3.6.4
3.6.2.3 TRIES . . . . . . . . . . . . .
Flüsse und Wege in gerichteten Graphen
3.6.3.1 Flüsse nach Ford/Fulkerson . .
3.6.3.2 Wege . . . . . . . . . . . . . .
3.6.3.2.1 Ford/Moore . . . . .
3.6.3.2.2 Floyd/Warshall . . .
3.6.3.2.3 Dijkstra . . . . . . . .
Gerüste in ungerichteten Graphen . . .
3.6.4.1 Kruskal . . . . . . . . . . . . .
3.6.4.2 Prim . . . . . . . . . . . . . .
3
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
4
INHALTSVERZEICHNIS
4.4.2.1
4.4.2.2
4.4.2.3
4.4.2.4
4.4.2.5
4.4.2.6
Flüsse nach Ford/Fulkerson
Wege nach Ford/Moore . .
Wege nach Floyd/Warshall
Wege nach Dijkstra . . . .
Gerüste nach Kruskal . . .
Gerüste nach Prim . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
113
115
116
117
118
118
Kapitel 1
Vorbemerkungen
1.1
Algorithmen
1.1.1
Begriff
Unser Ziel ist:
Lösung einer Aufgabe oder eines Problems mit Hilfe des Computers.
Aufgabe: Das Lösungsverfahren (vornehm: Algorithmus) und seine Nutzung ist bekannt.
Problem: Das Lösungsverfahren ist unbekannt.
Fragen:
• Was ist ein Algorithmus?
• Zu welchen Problemen existieren Algorithmen?
• Wie findet man Algorithmen?
• Wie schwer bzw. aufwendig (was ist das?) sind Algorithmen?
Algorithmenbegriffe:
• rekursive Funktionen,
• Turingmaschinen,
• λ-Kalkül,
• Markov-Algorithmen,
• Thue-Systeme,
• ...
• Registermaschinen/Flußdiagramme
Alle bisher entwickelten formalen Algorithmenbegriffe erwiesen sich als gleich ausdrucksstark: was der eine kann,
das kann der andere auch! (evtl. besser oder schlechter)
5
6
KAPITEL 1. VORBEMERKUNGEN
Vier intuitive Forderungen an Algorithmen:
1. Ein Algorithmus ist mit endlich vielen Regeln unmißverständlich zu formulieren.
2. Es gibt eine eindeutig bestimmte Regel, die als erste anzuwenden ist.
3. Es ist eine Reihenfolge bei der Anwendung der Regeln vorgegeben, d.h. nach Anwendung einer Regel kann
festgestellt werden, ob der Algorithmus zu beenden ist oder welche Regel als nächste anzuwenden ist.
4. Jede Regel ist rein mechanisch ausführbar, d.h. ihre Ausführung ist im gegebenen Zusammenhang bekannt.
1.1.2
Geschichte
Im Rechnen mit Zahlen liegt die Wurzel des Begriffs ”Algorithmus”:
Muhammad Ibn Musa al Hwarizmi (um 825, Bagdad) entwickelte Verfahren zum Rechnen mit dem indischen
(d.h. dezimalen) Zahlensystem. Lateinische Übersetzung seiner Arbeit:
Algorithmi de numero Inderum
Lullus (1300, Spanien) sucht nach einem universellem Algorithmus zum Finden aller Wahrheiten (Lösen aller
Probleme).
Schade: ein solcher existiert nicht!
Konsequenz: es gibt Probleme, für die kein Algorithmus existiert!
Mathematische Untersuchungen dazu wurden angeregt durch
David Hilbert: Darlegung von 23 interessanten Problemen auf dem internationalen Mathematikerkongreß in Paris 1900. Das 10. Problem lautet:
Entscheidbarkeit der Lösung einer diophantischen Gleichung.
”
Eine diophantische Gleichung mit irgendwelchen Unbekannten und mit ganzen rationalen Zahlenkoeffizienten
sei vorgelegt:
Man soll eine Verfahren angeben, nach welchem sich mittels einer endlichen Anzahl von Operationen entscheiden läßt, ob die Gleichung in ganzen rationalen Zahlen lösbar ist.”
Erst 1970 findet Matijasevic (Russland) die Antwort: Ein solches Verfahren existiert nicht!
1.2. COMPUTER
1.2
7
Computer
1.2.1
Aufbau und Wirkungsweise
1.2.1.1
Grundstruktur
ist immer noch:
Die von Neumann Architektur
Bus
6
6
6
?
?
?
E/A
Prozessor(en)
Speicher
Tastatur
CPU
RAM
Bildschirm
Grafik
Cache
Floppy
Netz
···
CD
Koprozessor
···
···
Wichtig:
Software spezialisiert die universelle Hardware!
Software=Menge von (mehr oder weniger gut) aufeinander abgestimmter Programme, strukturiert in Schichten
• Anwendungen
– Anwendungspakete
– Programmierwerkzeuge
• Betriebssystem
– Dienstprogramme (z.B. Explorer)
– Programm-Manager
– Treiber
– ···
8
KAPITEL 1. VORBEMERKUNGEN
1.2.1.2
Nutzung
Grundvorgang:
• Einschalten (falls ausgeschaltet)
• POST (Power on self test) wird ausgeführt, d.h.
– Grundkomponenten werden auf Funktionstüchtigkeit geprüft,
– Konfiguration wird geprüft und gespeichert,
– Änderungen sind durch Eingriff ins SETUP möglich.
• Bootstrap lädt Grundkomponenten des Betriebssystem, wobei zunächst auf Laufwerk A versucht wird,
dann auf C,
(kann im SETUP eingestellt werden),
• weitere Komponenten des Betriebssystems werden geladen,
• WINDOWS Oberfläche erscheint (hoffentlich).
Programmaufruf durch:
• Doppelklick auf Symbol auf Desktop
• Doppelklick auf Eintrag im Startmenü
• Aufruf über Ausführen
Einstellungen von Windows über Systemsteuerung zu:
• Anzeige
• Benutzer und Kennwörter
• Datum/Uhrzeit
• Drucker
• ···
1.2.1.3
Dateiverwaltung
Datei = sequentielle Sammlung von Daten, abgespeichert auf Datenträger
(RAM, CD, HD, Floppy).
Achtung: die sequentielle Anordnung der Daten in einer Datei hat keine unmittelbare physische Entsprechung
auf dem Datenträger!
Datenträger muß initialisiert sein, dabei wird eine FAT (File Access Table) angelegt.
Hierarchische Einteilung der Datenträger in Ordner
1.2. COMPUTER
9
Dateispezifikation: Pfad Datei-Bezeichnung
wobei
• Pfad hat die Form [d:][\][Restpfad] und
– d ist ein Laufwerkbuchstabe,
– \ kennzeichnet das Wurzelverzeichnis,
– Restpfad hat die Form Bezeichnung[\Restpfad] und
deutet an, daß der entsprechende Teil fehlen kann;
• Datei-Bezeichnung hat die Form Name[.Erweiterung] und
– Name ist eine Zeichenfolge,
– Erweiterung ist eine Folge von bis zu drei Zeichen, die Hinweise auf die Art der Datei gibt.
(z.B. exe, txt, doc, pdf, ...)
Dateiattribute legen Eigenschaften einer Datei fest, wie
• schreibgeschützt,
• versteckt,
• System,
• Archiv.
1.2.1.4
Der Speicher
Speichereinheiten:
• Bit - kleinste Einheit
• Byte = 8 Bit - kleinste adressierbare Einheit
• Wort - Länge hängt vom System ab (2, 4, 8 Byte)
Speichergrößen:
• 1 KB (Kilobyte) = 210 Byte = 1.024 Byte
• 1 MB (Megabyte) = 210 KB = 1.048.576 Byte
• 1 GB (Gigabyte) = 210 MB = 1.073.741.824 Byte
• 1 TB (Terabyte) = 210 GB = 1.099.511.627.776 Byte (für die Zukunft)
Informationsdarstellung extern:
• Stellenwertcode zur Basis b hat b verschiedene Ziffern (zi ):
z(zn−1 . . . z1 z0 ) =
n−1
X
zi ∗ b i , 0 ≤ zi < b
i=0
• Binärzahlen: b = 2, d.h. eine Ziffer benötigt 1 Bit (wichtig!).
• Oktalzahlen: b = 8, d.h. eine Ziffer benötigt 3 Bit.
• Dezimalzahlen: b = 10 - bekannt
10
KAPITEL 1. VORBEMERKUNGEN
• Hexadezimalzahlen: b = 16, d.h. zwei Ziffern benötigen 1 Byte (wichtig!).
Hexadezimalziffern: 0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F
Beispiele: z(12) = 18(dezimal), z(AA) = 170
Zur Berechnung von
z=
n−1
X
z i ∗ b i , 0 ≤ zi < b
i=0
mittels Hornerschema:
hk :=
n−1
X
zi ∗ bi−k ⇒ h0 = z, hn−1 = zn−1
i=k
wobei man erhält:
n−1
X
hk−1 =
zi ∗ bi−k+1 =
i=k−1
n−1
X
zi ∗ bi−k ∗ b + zk−1 ∗ b0 = hk ∗ b + zk−1
i=k
Schema:
k
n−1
n−2
zk
◦
◦
hk
?
+
?
?
◦∗b
◦∗b
k
◦
k−1
◦
◦
?
?
+
+
?
?
◦∗b
◦∗b
- Arbeitsrichtung
0
-
?
+
?
z
1.2. COMPUTER
11
Konvertierung:
• gegeben: z
• gesucht: zi
hk :=
n−1
X
zi ∗ bi−k ⇒ h0 = z, hn−1 = zn−1
i=k
⇒ zk−1 = hk−1 mod b , hk = hk−1 div b
wobei
x = (x div y) ∗ y + (x mod y)
Schema:
k
zk
hk
n−1
k−1
k
◦m
o
6
d
b
◦ divb ◦
◦m
◦m
o
o
6
6
d
d
b
b
divb ◦ divb ◦ divb ◦
Arbeitsrichtung
Konsequenz bei der Darstellung von z als Binärzahl:
• Konvertierung ins Oktalsystem: b = 8 = 23
div b entspricht Abschneiden von 3 Binärstellen ganz rechts,
mod b entspricht Wert der abgeschnittenen 3 Binärstellen.
• Konvertierung ins Hexadezimalsystem: b = 16 = 24
div b entspricht Abschneiden von 4 Binärstellen ganz rechts,
mod b entspricht Wert der abgeschnittenen 4 Binärstellen.
0
◦m
o
6
d
b
divb z
12
KAPITEL 1. VORBEMERKUNGEN
Informationsdarstellung intern (im Computer):
• bit∈ {0, 1}
• natürliche Zahlen (cardinals, unsigned integers) in Binärdarstellung, d.h.:
z(zn−1 . . . z1 z0 ) =
n−1
X
zi ∗ 2i , zi ∈ {0, 1}
i=0
Zahlenbereich: 0 ≤ z < 2n .
• ganze Zahlen (integer) in Binärdarstellung, jedoch: das bit linksaußen wird als Vorzeichen benutzt. Zwei
Varianten:
– Vorzeichen und Zahl getrennt, d.h. für die Zahl steht n − 1 Bit zur Verfügung. Es folgt:
z(zn−2 . . . z1 z0 )
falls zn−1 = 0
z(zn−1 . . . z1 z0 ) =
−1 ∗ z(zn−2 . . . z1 z0 ) falls zn−1 = 1
Zahlenbereich: −2n−1 < z < 2n−1
– Zweierkomplement, es wird −z durch 2n − z ersetzt, d.h. z + (−z) = 2n .
z(zn . . . z1 z0 ) =
n−1
X
zi ∗ 2i − zn ∗ 2n
i=0
Zahlenbereich: −2n−1 ≤ z < 2n−1
• rationale Zahlen in Gleitkommadarstellung in den drei Teilen:
v(Vorzeichen- 1 Bit), e(Exponent -k Bit), m(Mantisse - l Bit).
Prinzip:
z(vem) = −1v ∗ 2z(e) ∗ z(m).
k, l und die Berechnung von z(e) sowie z(m) hängen vom System ab.
• Zeichen in Binärdarstellung, ein Byte pro Zeichen nach ASCII-Code (American Standard Code of Information Interchange)
Adressierung:
Jedes byte (das ist eine Gruppe von 8 bits) hat eine Adresse, beginnend bei 0 bis 2n für ein gewisses n (warum
2n ?→ siehe Informationsdarstellung!).
Virtueller Speicher: Es wird mehr Speicher (bis zu 4GB) vorgetäuscht, als physikalisch vorhanden ist. Physikalisch nicht existierender Speicher wird auf der Festplatte (später!) angelegt. Beim Zugriff wird der benötigte
Bestandteil (page) in den Speicher gebracht (bzw. zurück): paging.
Beschleunigung des Speicherzugriffs: Cache ist ein schneller Pufferspeicher.
Zugriffsmethode: Erst wird der Zugriff im Cache versucht, falls Adresse dort vorhanden (Cache Hit) werden
Daten aus dem Cache gelesen. Falls Adresse nicht im Cache (Cache Miss) wird aus dem Hauptspeicher in den
Cache geladen.
Problem: Aktualisierung des Cache bei Schreibvorgängen im Hauptspeicher.
1.2. COMPUTER
13
Beispiel: Coprozessor 8087 mit den vier verschiedenen Formate für Gleitkommazahlen.
Abkürzung:
l
k−1
X
X
i
zi ∗ 2 , zz(m) :=
zl−i ∗ 2−i .
zz(e) :=
i=0
i=1
Real Type: k=8, l=39;
Falls
Falls
0 < zz(e) < 255 dann z(vem) = −1v ∗ 2zz(e)−129 ∗ (1 + zz(m))
0 = zz(e) dann z(vem) = 0
Single Type: k=8, l=23;
Falls
Falls
Falls
Falls
Falls
0 < zz(e) < 255
0 = zz(e) ∧ zz(m) 6= 0
0 = zz(e) ∧ zz(m) = 0
zz(e) = 255 ∧ zz(m) = 0
zz(e) = 255 ∧ zz(m) 6= 0
dann
dann
dann
dann
dann
z(vem) = −1v ∗ 2zz(e)−127 ∗ (1 + zz(m))
z(vem) = −1v ∗ 2−126 ∗ zz(m)
z(vem) = −1v ∗ 0
z(vem) = −1v ∗ Inf
z(vem) = N aN
Bemerkung: Inf und N aN sind spezielle Kennzeichen, um Überlauf zu behandeln.
Double Type: k=11, l=52;
Falls
Falls
Falls
Falls
Falls
0 < zz(e) < 2047
0 = zz(e) ∧ zz(m) 6= 0
0 = zz(e) ∧ zz(m) = 0
zz(e) = 2047 ∧ zz(m) = 0
zz(e) = 2047 ∧ zz(m) 6= 0
dann
dann
dann
dann
dann
z(vem) = −1v ∗ 2zz(e)−1023 ∗ (1 + zz(m))
z(vem) = −1v ∗ 2−1022 ∗ zz(m)
z(vem) = −1v ∗ 0
z(vem) = −1v ∗ Inf
z(vem) = N aN
Extended Type: k=15, l=63;
Falls
Falls
Falls
0 ≤ zz(e) < 32767 dann z(vem) = −1v ∗ 2zz(e)−16383 ∗ (1 + zz(m))
zz(e) = 32767 ∧ zz(m) = 0 dann z(vem) = −1v ∗ Inf
zz(e) = 32767 ∧ zz(m) 6= 0 dann z(vem) = N aN
14
KAPITEL 1. VORBEMERKUNGEN
1.2.1.5
Der Prozessor (CPU)
• holt Befehl für Befehl aus dem Speicher und
• führt jeden Befehl aus.
Bestandteile sind
• das Steuerwerk mit
– dem Befehlszähler (BZ), das ist ein Register, in dem die Adresse des aktuellen Befehls gespeicher ist,
– dem Befehlsregister (BR), in dem der auszuführende Befehl gespeichert ist und
– der Dekodiereinheit, die feststellt, um welchen Befehl es sich handelt
• das Rechenwerk mit
– Arbeitsregistern und der
– ALU (Arithmetic Logic Unit).
Programmaufbau: Ein (Maschinen-) Programm ist eine (endliche) Folge von Befehlen.
Jeder Befehl besteht aus
• Operationsteil, enthält den Code, der die globale Wirkung beschreibt,
• Adressteil, enthält die Adresse(n) des(der) Operanden.
Code:Adresse1,Adresse2,Adresse3
Adressen
1
2
3
1. Operand
Akku
Adresse1
Adresse1
2. Operand
Adresse1
Adresse2
Adresse2
Resultat
Akku
Adresse1/2
Adresse3
Grundlegende Befehlsarten sind:
• arithmetische Befehle (Addition, Subtraktion, Multiplikation,...)
• logische Befehle (und, oder, exklusives oder, Verschieben,...)
• Transportbefehle (vom und zum Speicher, Registern)
• Sprungbefehle (unterbrechen die normale“ Abarbeitungsreihenfolge im Programm)
”
Achtung, ein Programm ist eine Folge von bits (bytes), also kann man es als Datum betrachten! Später: ein
Programm ist ein Text, der speziellen Regeln genügt (wichtige Auffassung!).
Die zentrale Steuerschleife gibt das Grundprinzip der Programmabarbeitung an:
1. Befehl mit Adresse aus BZ vom Speicher ins BR holen,
2. Befehl aus BR dekodieren,
3. Befehl ausführen und BR modifizieren,
4. wenn Unterbrechung angemeldet, gehe zu 5 sonst zu 1,
5. Unterbrechung bearbeiten, dann gehe zu 1.
1.2. COMPUTER
15
Hinsichtlich des Befehlsumfanges unterscheidet man:
• RISC - (Reduced Instruction Set Computer) - eingeschränkter Befehlssatz:
Coprozessor
6
-
Bus-
Steuereinheit
ALU
SchnittRegister
stelle
Prefetch
-
-
Ausführungseinheit
Queue
Prinzip: jeder Befehlscode kann unmittelbar ausgeführt werden.
Busschnittstelle: stellt Verbindung zu anderen Einheiten her (später).
Prefetch Queue: holt Befehle des abzuarbeitenden Programms und stellt sie in einer Warteschlange zur
Abarbeitung bereit.
Steuereinheit: koordiniert die Gesamtausführung.
Ausführungseinheit: führt die Befehle unmittelbar aus, benutzt dazu ALU, Register und evtl. zusätzliche
Recheneinheiten.
ALU: (arithmetic logical unit) bearbeitet arithmetische und logische Operationen.
Register: dient der internen und vor allem schnellen Speicherung von Operanden und von Resultaten.
Coprozessor: realisiert spezielle Operationen (z.B. Gleitkomma-operationen).
Abarbeitungsprinzip:
– Sobald Platz in Prefetch Queue (eine Warteschlange) frei ist, werden Befehle des abzuarbeitenden
Programms aus dem Speicher geholt und in Queue eingereiht.
– Die Steuereinheit prüft, ob der aktuell zu bearbeitende Befehl in der Prefetch Queue enthalten ist.
∗ ja: er wird von dort zur Abarbeitung geholt, damit wird Platz in Prefetch Queue frei und diese
kann nachgeladen werden.
∗ nein: Prefetch Queue wird geleert und mit dem benötigten (und den nachfolgenden) Befehl(en)
geladen.
– Die Steuereinheit sorgt dafür, daß bei Transportbefehlen die Daten zwischen Speicher und Registern
transportiert werden.
– Die Ausführungseinheit weist der ALU die entsprechenden Operationen zu.
Wichtig: Abarbeitung erfolgt verzahnt!
16
KAPITEL 1. VORBEMERKUNGEN
• CISC - (Complex Instruction Set Computer) - umfangreicher Befehlssatz:
Coprozessor
6
Mikrocode-
- Steuereinheit
festwertspeicher
Bus-
ALU
Schnitt-
6
Register
stelle
Prefetch
-
Decodier-
Queue
einheit
Mikrocode
Queue
- Ausführungseinheit
Prinzip: ein Befehlscode repräsentiert im allgemeinen ein Mikroprogramm.
Mikrocodefestwertspeicher: enthält die zur Ausführung der Befehle notwendigen Programme.
Decodier-Einheit: decodiert den Befehlscode und bringt das zugehörige Mikroprogramm in die Mikrocode
Queue. Das kann aufwendig sein!
1.2.2
Geschichte
Diese Entwicklung geschah relativ unabhängig von den Bemühungen um die Schaffung von (mechanischen,
elektrischen, elektronischen) Rechenanlagen.
Dazu die Jahrestafel, natürlich nur grob und sehr individuell gesehen:
• 15.Jh.: Abakus
– Wer: unbekannt
– Wo: in Asien
– Wozu: Addition, Subtraktion, (Multiplikation, Division)
– Wie viele: ungezählt
– Was sonst: kein Stromverbrauch, absolut zuverlässig
• 1623: Erste mechanische Rechenmaschine
– Wer: Wilhelm Schickard (berichtet in einem Schreiben an Kepler darüber)
– Wo: Tübingen
– Wozu: Addition, Subtraktion
– Wie viele: eine
– Was sonst: Nachbau im Rathaus der Stadt Tübingen
1.2. COMPUTER
17
• 1642: Pascaline, im Dresden Zwinger ausgestellt)
– Wer: Blaise Pascal
– Wo: Frankreich
– Wozu: Addition, Subtraktion
– Wie viele: über 50
– Was sonst: Pascal gewann damit einen selbst ausgeschriebenen Wettbewerb zum Schnellrechnen
• 1673: Multiplizierer
– Wer: Gottfried Wilhelm Leibniz
– Wo: Deutschland, vor der London Royal Society vorgestellt
– Wozu: Addition, Subtraktion, Multiplikation
– Wie viele: eine
– Was sonst: vorsichtige Bedienung erforderlich, sonst Gefahr der Verklemmung, deshalb die pessimistische Einschätzung der Vorführung durch Hooke:
”Die Addition und Subtraktion wird am zweckmässigsten mit Schreibzeug und Papier erledigt, das
ist schneller und sicherer als mit jeglichem Instrument.”
Nachbau durch TU-Dresden
• 1820: Die Ära der Massenproduktion
– begann durch Charles Xavier Thomas mit einer Staffelwalzenmaschine, insgesamt 1500 Stück hergestellt,
– 1878 gründete Arthur Burkhardt in Glashütte die Erste Rechenmaschinenfabrik in Deutschland,
jedoch lief Produktion sehr zögerlich. Einschätzung durch Dietzhold (Konstrukteur des Produktes
und Studienfreund von Burkhardt):
Praktischen Wert hat dieselbe heute keinen, obgleich beständig zu ihrem Baue, namentlich von Seiten
der Kaufleute, gedrängt wird, die sie dann aber nicht kaufen, weil sie ihnen das Kolonnenaddieren
durchaus nicht erleichtert, vielmehr noch die Erlangung einer neuen Fertigkeit aufladet. Auch sonst
bietet ihre Fabrikation nur Aussicht auf Mißerfolge; denn das, was eine Maschine bezweckt, nämlich
die Vermeidung von Fehlern, wird nicht erreicht, weil neue Fehlerquellen hinzugebracht werden.
• 1823: Difference Engine, Analytical Engine
– Wann: 1823-1833 Arbeiten zur Difference Engine,
1834 Erste Zeichnungen zur Analytical Engine
– Wer: Charles Babbage (1792 -1871),
– Wo: Cambridge, England
– Wozu: Berechnung beliebiger mathematischer Funktionen,
– Wie viele: eine
– Was sonst: britische Regierung spendiert zur Unterstützung der Difference Engine 17.000 Pfund,
als Babbage zur Analytical Engine übergeht wird Unterstützung eingestellt.
(Siehe auch www-Seiten zu Babbage)
18
KAPITEL 1. VORBEMERKUNGEN
Das Prinzip der Analytical Engine
∗ Mill - Rechenwerk
· führt die Operationen (Addition, Subtraktion, Multiplikation, Division) aus,
· besteht aus einem System rotierender Arme, die über mit Riemen versehener Getriebe und
Kupplungen verbunden sind.
∗ Store - Speicher
· nimmt alle Zahlen auf, die verarbeitet werden sollen oder die das Resultat von Verarbeitungen
sind,
· unterteilt in Spalten“, in denen die Zahlen gespeichert sind,
”
· jede Spalte besteht aus einer Reihe von Rädern, deren Position eine Stelle der gespeicherten
Zahl beschreibt.
∗ Variable cards - Lochkarten für Ein- und Ausgabe
· enthalten spaltenweise die Ziffern der Zahlen als Anzahl von Löchern,
· werden über Mechanismen zu den ihnen zugeordneten Spalten des Speicherwerkes übertragen,
· können durch das Rechenwerk erzeugt werden.
∗ Operation cards - Lochkarten für Steuerung
· enthalten die Beschreibung einer auszuführenden Operation und
· die Beschreibung der Variablen (Spalten des Speichers) für die Operanden sowie des Resultates.
• 1890: erste Lochkartenanlage
– Wer: Hermann Hollerith (1860-1929)
– Wo: USA
– Wozu: Volkszählung 1890 in USA,
– Wie viele: unbekannt
– Was sonst: Auswertungsaufwand bei Volkszählung sank von sieben Jahren (bei Volkszählung 1880)
auf sechs Wochen.
1896 wurde die Tabulating Machine Company gegründet, 1924 ging daraus die International
Business Machines (IBM) hervor.
• 1938: erster programmgesteuerten Rechner Z1,
– Wann: 1941
– Wer: Konrad Zuse (1911-1995)
– Wo: Deutschland
– Wozu: Flügelberechnungen (Flatterproblem),
– Wie viele: eine und ein Nachbau
– Was sonst:
∗
∗
∗
∗
∗
∗
∗
Technik: 600 Relais für Rechenwerk, 1600 Relais für Speicherwerk
Taktfrequenz: 5-10 Hertz
Geschwindigkeit: Addition - 0,7 s, Mult./Div. - 3 s
Speicher: 64 Worte mit je 22 Bit
Eingabe: Dezimaltastatur
Ausgabe: Lampen
Erster funktionierender elektromechanischer programmgesteuerter Rechner der Welt, 1944 durch
Bomben zerstört, Nachbau im Deutschen Museum in München.
(Siehe auch www-Seiten zu Zuse)
1.2. COMPUTER
• 1939: Z2,..
• 1943 ENIAC
– Wer: Eckert und Mauchly
– Wo: USA
– Wozu: Universalrechner, Numerik,
– Wie viele: eine
– Was sonst:
∗
∗
∗
∗
∗
∗
Technik: 17468 Röhren, 15000 Relais.
Taktfrequenz: 100 kHz
Geschwindigkeit: Addition - 0,2 ms, Mult./Div. - 2,8 ms
Gewicht: 30 Tonnen
Stromverbrauch: 174 KW
Erster elektronischer Rechner (auf Röhrenbasis).
• dann geht es Schlag auf Schlag
• 1963: Prototyp des PC
– Wer: N.J. Lehmann (1921-1998)
– Wo: Deutschland (Dresden)
– Wozu: Kleinrechner,
– Wie viele: Serienfertigung als Cellatron 820x in Zella Mehlis
– Was sonst:
∗
∗
∗
∗
∗
∗
∗
∗
Technik: 200 Transistoren, Trommelspeicher
Taktfrequenz: 100 kHz
Geschwindigkeit: Addition - 0,5 ms, Mult./Div. - 5 ms
Grösse: 60 × 42 × 45 cm
Speicher: 4000 Worte
Eingabe: Tastatur und Lochstreifen
Ausgabe: Streifendrucker
Vorausgegangen waren D1 (1948-1956) und D2 (1959)
(Siehe auch .pdf File zu N.J. Lehmann)
19
20
KAPITEL 1. VORBEMERKUNGEN
1.3
Programmierung
1.3.1
Programmiersprachen
1.3.1.1
Begriff
Ein Programm dient zur Steuerung des Ablaufs im Computer und legt fest, welche Aktionen im Computer in
welcher Ordnung abzuarbeiten sind. Damit repräsentiert ein Progrann einen vom Computer abzuarbeitenden
Algorithmus.
Zur Beschreibung von Programmen bedient man sich einer Programmiersprache.
Jede Sprache ist an Signale gebunden. Aus Signalen werden Zeichen aufgebaut und eine Sprache ist eine gewisse
Menge von Zeichenfolgen.
Abstrakt: Ein Alphabet ist eine Menge Σ, deren Elemente als Zeichen dienen. Mit Σ∗ bezeichnen wir die
Menge aller Zeichenfolgen (Zeichenketten, Texte), deren Elemente aus Σ sind.
Eine formale Sprache L (von language) über einem Alphabet Σ ist eine Untermenge von Σ∗ (L ⊆ Σ∗ ).
• Syntax ist die charakteristische Funktion von L, d.h. syn : L → {f alse, true} mit
L = {α | syn(α) = true}
• Semantik ist eine Funktion, die zu jedem Element der Sprache (Wort, Satz, Text) dessen Bedeutung
angibt, d.h. wenn B die Menge aller Bedeutungen ist, dann ist sem : L → B.
Also: Eine Programmiersprache ist eine (künstliche) Sprache, deren Texte Beschreibungen von Programmen
sind. Die Bedeutungen der Elemente einer Programmiersprachen sind Programme.
1.3.1.2
Klassifikation
• Anwendungsbereich
– Universalsprachen sind für beliebige Anwendungen gedacht. Sie verfügen über einen großen Ausdrucksreichtum, sind oft sehr umfangreich und mitunter schwer handhabbar (Assembler, ALGOL
68, PL/I, SIMULA 67, PASCAL (?); MODULAII, C, C++, ADA,...).
– Spezialsprachen sind auf spezielle Anwendungen ausgelegt. Sie besitzen eingeschränkte und spezielle
Ausdrucksmittel, die ihren Einsatz in gewissen Problemklassen besonders lukrativ machen
(FORTRAN (?), RPG, COBOL, SIMULA, LISP, PROLOG,...).
• Maschinennähe
– maschinenabhängige Sprachen sind zur Programmierung eines speziellen Rechnermodells ausgelegt,
sie lassen sich auf anderen Rechnern nicht oder nur unter Einschränkungen nutzen (Assembler,
PL/1,...).
– maschinenunabhängige Sprachen sind nicht auf einen speziellen Rechner orientiert, sondern sollen
die Portabilität der Programme bewußt unterstützen.
– maschinenorientierte Sprachen besitzen Ausdrucksmittel, die eine Bezugnahme auf Rechnerspezifika
(z.B. Register) erlauben (BLISS, ALMO).
– problemorientierte Sprachen besitzen Ausdrucksmittel, die eine Bezugnahme auf Problemspezifika
erlauben (ALGOL, FORTRAN, PASCAL, COBOL,...).
1.3. PROGRAMMIERUNG
21
• Semantik
– imperative Sprachen haben als Bedeutungen Zustandsänderungen und beschreiben somit (in der
entsprechenden Abstraktion) das Verhalten des Rechners.
– applikative Sprachen haben als Bedeutungen Funktionen (funktionale Sprachen: LISP, SML, Miranda, Scheme, Haskell) bzw. Relationen (logische Sprachen: Prolog) und beschreiben somit das Ergebnis
des Verhaltens.
1.3.1.3
Verarbeitung
• Interpretieren: dazu wird ein Programm (Interpreter genannt) genutzt, das einen Computer simuliert,
der die Programmiersprache direkt versteht (SMALLTALK).
• Compilieren: dazu wird ein Programm (Compiler genannt) genutzt, das jedes Programm
(Quellprogramm genannt), geschrieben in der Programmiersprache, in ein Programm (Zielprogramm)
einer Sprache übersetzt, die vom Computer verstanden wird oder für die ein Interpreter existiert. Oft
besteht das Zielprogramm nur aus einem Fragment (Objektmodul), das mit anderen Teilen durch den
Linker zu einem ausführbaren Programm verbunden werden muß (C,C++). Meist erlauben die Compiler den Zielcode in verschiedenen Varianten (bezüglich Optimierung, Fehlertestung) zu erzeugen. Das
Einstellen dieser Varianten erfolgt über Optionen und Direktiven (siehe Hilfe!).
1.3.2
Geschichte
1952 Entwicklung der symbolischen Programmierung, es entstehen die ersten AUTOCODES für spezielle Rechner.
– Zuse: Plankalkül
– Rutishauser: automatische Rechenplanfertigung
– Ljapunov: Operatorprogrammierung
1954 J.Backus entwickelt das Konzept von FORTRAN (FORmula TRANslator).
1955 FORTRAN veröffentlicht,
GAMM (Gesellschaft für angewandte Mathematik und Mechanik) setzt einen Programmierungsausschuß
ein. Ein Unterausschuß für algorithmische Sprachen (Bauer, Bottenbruch, Rutishauser, Samelson) erarbeitet bis 1957 Unterlagen für den Bau eines gemeinsamen Programms zur Formelübersetzung.
1956 es entstehen die ersten kommerziell orientierten Sprachen (FLOW-MATIC, AIMACO),
APT als Prozeßsteuersprache.
1957 Vervollständigung von FORTRAN, erster Compiler für die IBM 704 fertiggestellt.
In den USA findet die erste Tagung über die Erleichterung des Informationsaustausches zwischen Rechenzentren statt. Im Rahmen der ACM (Association for Computing Machinery) wird ein Ausschuß für die
Entwicklung einer universellen Programmiersprache gebildet.
1958 Herausgabe von FORTRAN II.
27.5.-2.6.: In Zürich erscheint ein vorläufiger Bericht vom Unterausschuß GAMM und Ausschuß ACM über
die Sprache ALGOL (als ALGOL 58 bekannt), noch viele Unklarheiten, Grundlage für Diskussion, das
von Peter Naur herausgegebene ALGOL-Bulletin dient als Kommunikationsmittel.
1960 11.1.-16.1.: in Paris entsteht der Bericht über ALGOL 60, 13 Wissenschaftler aus Dänemark, Deutschland,
Holland, Schweiz und USA waren beteiligt.
Ein ALGOL 60 Compiler für die X1 entsteht.
COBOL (COmmon Bussiness Oriented Language) wird herausgegeben.
1962 APL von Iverson.
1963 revidierter Bericht zu ALGOL 60.
22
KAPITEL 1. VORBEMERKUNGEN
1964 von IBM wird PL/I als Synthese von FORTRAN, ALGOL und COBOL geschaffen.
1965 LISP von McCarthy.
1967 in Norwegen erschafft Dahl SIMULA 67, zum ersten Mal umfangreiche Berücksichtigung der Programmierung paralleler Prozesse, Einführung des Klassenkonzeptes, die Grundlage der objektorientierten Programmierung.
1968 die im Rahmen der IFIP arbeitende WG 2.1 schafft ALGOL 68.
1970 PROLOG wird von Colmerauer und Roussell als logische“ Programmiersprache entwickelt.
”
1971 N.Wirth hat sich von WG 2.1 getrennt und veröffentlicht seine Sprache PASCAL in Konkurrenz zu ALGOL
68, gleichzeitig dazu wird ein Compiler und eine Bootstrapping Methode angeboten.
1972 SMALLTALK ist die erste explizit für die objektorientierte Programmierung im Xerox Palo Alto Research
Center entwickelte Sprache.
1973 C kommt als UNIX Sprache heraus, bei BELL Laboratories entwickelt.
1980 ADA heißt die Gewinnersprache eines vom US Department of Defense veranstalteten Wettbewerbs um
die Entwicklung einer neuen universellen Sprache. Dazu wurden die folgenden fünf Anforderungskataloge
formuliert:
– 1975 - strowman
– 1975 - woodenman
– 1976 - tinman
– 1978 - ironman
– 1979 - steelman
Der Gewinner war Cii-Honeywell Bull of France, aus 16 Vorschlägen. Ada Augusta Byron, Countess of
Lovelace war die Programmiererin der Analytical Engine von Charles Babbage.
C++ wurde in den Bell Laboratories als Erweiterung von C für die objektorientierte Programmierung
entwickelt.
1983 ML als funktionale Sprache an der Universität Edinburgh.
1989 Wirth entwickelt Oberon, um zu zeigen, daß die Software nicht alle Ressourcen eines Rechners auffressen
muß.
1990 Firma Sun startet das Green(Oak) Projekt, aus dem JAVA hervorgeht.
1995 Lancierung von Java durch Folge von Pressekonferenzen.
Kapitel 2
Entwurf und Analyse von Algorithmen
2.1
Vom Problem zur Lösung
Insgesamt ist auf dem Weg vom Problem zur Lösung zu erarbeiten:
• Was (welches Ziel) soll
• Wie (mit welcher Methode) und
• Womit (mit welchen Instrumenten)
erreicht werden.
Das Was wird durch die Problemspezifikation beschrieben. Sie unterteilt sich in
• die funktionelle Spezifikation, in der die Funktion des Systems beschrieben wird, d.h. seine Ein- und
Ausgangsgrößen und ihre Beziehungen,
• die Anforderungsspezifikation, in der Nutzungsformen, Nutzungsrechte, Nutzungsdauer, Nutzungshäufigkeit, Sicherheitsanforderungen, Zeitbeschränkungen, finanzielle Rahmen, usw. angegeben sind.
Das Wie wird beschrieben durch:
• Die Lösungsvorschrift (Algorithmus), die die notwendigen Lösungsschritte in ihrer Reihenfolge und
Kombination angibt. Die Hauptprinzipien zur Erarbeitung einer Lösungsvorschrift sind:
– Verallgemeinerung: konkret - abstrakt - konkret,
– Zerlegung: Dekomposition - Komposition,
– Ordnung: Über Teilziele zum Ziel.
Im allgemeinen werden diese Prinzipien kombiniert genutzt. Zwei Richtungen sind denkbar:
– top-down, die Entwicklung der Lösungsvorschrift geht vom Abstrakten zum Konkreten, vom Allgemeinen zum Besonderen und Einzelnen, von der Ganzheit zur Einzelheit.
Nachdem in der funktionellen Spezifikation und in der Anforderungsspezifikation die allgemeine Problemstellung charakterisiert wurde, werden Teilprobleme herausgearbeitet, die, geeignet kombiniert,
das Gesamtproblem lösen. Diese Teilprobleme repräsentieren (zunächst abstrakte) Lösungsschritte
(Objekte in Form von Operatoren und Operanden). Bei der weiteren Verfeinerung wird jedes der Teilprobleme als eigenständiges Problem aufgefaßt und entsprechend zerlegt, bis man auf Teilprobleme
kommt, deren Lösung bekannt ist. Auf diese Weise entstehen Schichten, die hierarchisch aufeinander
aufbauen. Jede Schicht kann man als ein virtuelles System verstehen, das seine Funktionalität dem
über ihm liegenden System zur Verfügung stellt. Bei der Zerlegung eines Problems in Teilprobleme
sind Entscheidungen zu fällen.
23
24
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Einige Richtlinien hierfür sind:
∗ Realisierung der leichtesten Entscheidung zuerst,
∗ Zurückstellen jener Entscheidungen solange wie möglich, die Darstellungsfragen (z.B. Datendarstellungen) betreffen,
∗ Benutzung von Kriterien wie Klarheit und Konsistenz der Struktur, Effizienz,
∗ Berücksichtigung von alternativen Möglichkeiten,
∗ Zerlegung jeder Schicht in kleine Portionen (wenig Teilprobleme).
– bottom-up, die Entwicklung geht vom Detaillierten zum Aggregierten. Durch die schrittweise Komposition von elementaren Komponenten zu komplexeren versucht man sich an die Zielvorstellung
anzunähern. Das ist im allgemeinen schwieriger, da schwer zu erkennen ist, wie die Details auf den
unteren Ebenen geeignet zu kombinieren sind.
Beide Richtungen werden in der Praxis kombiniert angewendet, so daß sich der Entwicklungsprozeß als eine
Jo-Jo Methode gestaltet. Oft sind bereits vorgefertigte komplexe Lösungsschritte vorhanden, die geeignet
nachgenutzt werden sollen.
• Die statische Struktur des Software-Systems (Aufteilung in Moduln), die sich aus der funktionalen
Dekomposition ableitet und auf diese zurückwirkt. Moduln (auch Objekte) versorgen ihre Umwelt mit
einer Menge von Lösungsschritten (Verfahren, Methoden). Es ist sinnvoll, solche Methoden in einem Modul
(Objekt) zusammenzustellen, die sich in ihrer Anwendung, Wirkung und Realisierung ähnlich sind.
Das Womit wird beschrieben durch
• die zu nutzenden Computer und sonstige Technik,
• die Programmiersprache,
• die Compiler,
• das Entwicklungssystem.
Oft sind hierzu (leider) die Randbedingungen so stark, daß kaum Entscheidungsfreiheiten bleiben.
2.2
2.2.1
Induktionsprinzipien
Induktive Definition und Induktionsbeweis
Hauptmethode bei der Entwicklung und Beschreibung von Algorithmen.
Gegeben sei:
• eine Grundmenge M ,
• eine Anfangsmenge A ⊆ M ,
S
• Mengen von Operationen O := {Om | m ∈ IN },
wobei für m ∈ IN : Om ⊆ {◦ | ◦ : M m → M } .
Für eine gegebene Menge O und für beliebige Mengen B ⊆ M setzen wir
O(B) := {◦(x1 , . . . , xm ) | m ∈ IN ∧ ◦ ∈ Om ∧ x1 , . . . , xm ∈ B}
und erhalten das Prinzip der induktiven Definition einer Menge D ⊆ M über A, O:
1. A ⊆ D,
2. O(D) ⊆ D,
3. D enthält keine weiteren Elemente.
2.2. INDUKTIONSPRINZIPIEN
25
Exakter:
Es ist D definiert als bezüglich Mengeninklusion kleinste Menge, die der Gleichung
D = A ∪ O(D)
genügt.
Beispiel: Wir führen die Menge IN der natürlichen Zahlen als kleinste Menge ein, die der Gleichung
IN = {∅} ∪ {n ∪ {n} | n ∈ IN }
genügt und bezeichnen mit
0 := ∅
1 := 0 ∪ {0} = {0}
2 := 1 ∪ {1} = {0, 1}
·
n + 1 := n ∪ {n} = {0, . . . , n}
·
·
·
Beweisprinzip für Eigenschaften e : M → {f alse, true} über A, O induktiv definierter Mengen D ⊆ M .
Wir bilden E := {d | d ∈ M ∧ e(d) = true}, also d ∈ E ⇔ e(d) = true.
Es gilt:
D ⊆ E ⇐⇒ A ⊆ E ∧ O(E) ⊆ E.
Also:
1. Für alle d ∈ A muß e(d) bewiesen werden.
2. Für alle ◦ ∈ Om , x1 , . . . , xm ∈ D ist zu beweisen: e(x1 ) ∧ . . . ∧ e(xm ) ⇒ e(◦(x1 , . . . , xm )).
Beispiel: Um eine Eigenschaft e für natürliche Zahlen zu beweisen, hat man zu zeigen:
1. e(0) und
2. e(n) ⇒ e(n + 1).
(vollständige Induktion genannt).
Problem: Wie kann man für eine induktiv definierte Menge D feststellen, ob x ∈ D?
Für eine mit A ⊆ M, O induktiv definierte Menge D bilden wir eine Funktion F : ℘(M ) → ℘(M ) durch
F (B) := A ∪ O(B)
und es sei
F 0 (B) := B, F n+1 (B) := F (F n (B)).
Behauptung: F ∗ := {F n (∅) | n ∈ IN } = D
Beweis:
Es ist O monoton, d.h. für B ⊆ B 0 gilt O(B) ⊆ O(B 0 ), also ist auch F monoton.
1. Wir zeigen, daß F ∗ = A ∪ O(F ∗ ), also D ⊆ F ∗ .
1.1 Wir zeigen, daß F ∗ ⊆ A ∪ O(F ∗ ).
Für x ∈ F ∗ existiert ein n + 1 ∈ IN mit x ∈ F n+1 (∅). Das heißt x ∈ A oder x ∈ O(F n (∅)).
Falls x ∈ O(F n (∅)), so gilt wegen F n (∅) ⊆ F ∗ und der Monotonie von O, daß O(F n (∅)) ⊆ O(F ∗ ), also
x ∈ O(F ∗ ).
S
26
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
1.2 Wir zeigen, daß A ∪ O(F ∗ ) ⊆ F ∗ .
Wegen A ⊆ F ∗ ist nur O(F ∗ ) ⊆ F ∗ zu zeigen.
Falls x ∈ O(F ∗ ), dann gibt es ein ◦ ∈ Om und x = ◦(x1 , . . . , xm ) mit gewissen x1 , . . . , xm ∈ F ∗ .
Für jedes i ∈ {1, . . . , m} existiert deshalb ein ni mit xi ∈ F ni (∅). Für k := max{n1 , . . . , nm } ergibt sich wegen
der Monotonie von F , daß x1 , . . . , xm ∈ F k (∅). Das bedeutet x = ◦(x1 , . . . , xm ) ∈ O(F k (∅)) ⊆ F k+1 (∅) ⊆ F ∗ .
2. Wir zeigen, daß F ∗ ⊆ D.
Es gilt F 0 (∅) = ∅ ⊆ D und wenn F n (∅) ⊆ D, so folgt aus der Monotonie von F , daß F n+1 (∅) ⊆ F (D) = D und
damit F ∗ ⊆ D.
Folgerung:
x ∈ D ⇐⇒ es gibt ein i ∈ IN : x ∈ F i (∅).
Sprechweise: Falls x ∈ D, so heißt das kleinste i mit x ∈ F i (∅) die Stufe der Erzeugung von x.
Kompaktierung:
Zu jeder Operation ◦ ∈ Om ordnen wir eine Operation • : ℘(M )m → ℘(M ) zu mit
•(B1 , . . . , Bm ) := {◦(x1 , . . . , xm ) | (x1 , . . . , xm ) ∈ B1 × . . . × Bm }.
S
Es ergibt sich nun: O(B) := {•(B, . . . , B ) | m ∈ IN ∧ ◦ ∈ Om }.
| {z }
m−mal
Verallgemeinerung: Simultane induktive Definition von Mengen D1 , . . . , Dn mittels
• Grundmengen M1 , . . . , Mn ,
• Anfangsmengen A1 ⊆ M1 , . . . , An ⊆ Mn und
• Operationen Oϕi ⊆ {◦ | ◦ : Mϕ(1) × . . . × Mϕ(m) → Mi }
für die Funktion ϕ : {1, . . . , m} → {1, . . . , n} und i ∈ {1, . . . , n}.
Kompaktierung:
Zu jeder Operation ◦ ∈ Oϕi ordnen wir eine Operation • : ℘(Mϕ(1) ) × . . . × ℘(Mϕ(m) ) → ℘(Mi ) zu mit
•(B1 , . . . , Bm ) := {◦(x1 , . . . , xm ) | (x1 , . . . , xm ) ∈ B1 × . . . × Bm }.
Es ergibt sich nun
Oi (B1 , . . . , Bn ) :=
[
{•(Bϕ(1) , . . . , Bϕ(m) ) | ϕ : {1, . . . , m} → {1, . . . , n} ∧ ◦ ∈ Oϕi }
und die Mengen D1 , . . . , Dn sind definiert als die kleinste Lösung des Gleichungssystems
D1 =A1 ∪O1 (D1 , . . . , Dn )
D2 =A2 ∪O2 (D1 , . . . , Dn )
.........................
Dn =An ∪On (D1 , . . . , Dn )
Die Berechnung erfolgt wie im einfachen Fall iterativ, beginnend mit der leeren Menge ∅ für alle Di .
2.2.2
Fallstudie: Wortmengen
Mit AB bezeichnen wir die Menge aller totalen Funktionen von B in A, d.h.
AB := {f | f : B → A}.
Für eine beliebige Menge Σ und eine natürliche Zahl n kann man eine Funktion α ∈ Σn nun auch als eine
Zeichenfolge der Länge n auffassen, nämlich als Folge α(0)α(1) · · · α(n − 1).
Da Σ0 = 1 = {0} = {∅}, gibt es nur eine Zeichenfolge der Länge 0, nämlich 0(= ∅). Um zu kennzeichnen, daß
die natürliche Zahl 0 als leere Zeichenfolge benutzt werden soll, benutzen wir dafür den griechischen Buchstaben
ε.
2.2. INDUKTIONSPRINZIPIEN
27
Wir definieren
Σ∗ :=
[
{Σn | n ∈ IN }
und erhalten damit die Menge aller (endlich langen) Zeichenketten. Für α ∈ Σ∗ gilt auch α ∈ Σ|α| , also ist |α|
die Länge von α.
Auf dieser Grundlage definieren wir eine Operation Verkettung ◦ : Σ∗ → Σ∗ von Zeichenfolgen durch
α(i)
falls i ∈ |α|
|α ◦ β| = |α| + |β| und (α ◦ β)(i) :=
β(i − |α|)
sonst
Offensichtlich gilt: α ◦ ε = ε ◦ α = α.
Da die Verkettung das Aneinanderfügen von Zeichenfolgen ist, lassen wir das Operationszeichen im allgemeinen
weg, d.h. wir schreiben αβ statt α ◦ β.
Das Zeichen • verwenden für die Operation Komplexprodukt • : ℘(Σ∗ )2 → ℘(Σ∗ ), die (analog zur Kompaktierung) definiert ist durch
A • B := {αβ | α ∈ A ∧ β ∈ B}.
Zur induktiven Definition von Wortmengen erlauben wir die Benutzung von
• endlichen Wortmengen für die Anfangsmenge A und
• Operationen, die sich nur aus dem Komplexprodukt bilden lassen.
BNF (Backus-Naur-Form) ist eine Sprache, die zur simultanen Beschreibung von Wortmengen, d.h. der Syntax
von Sprachen benutzt wird. Die Darstellung ist nicht ganz einheitlich.
Das Grundprinzip ist:
• Die Bezeichnung (auch Metasymbole genannt) der einzelnen zu definierenden Wortmengen erfolgt durch
kursiv gesetzte Zeichenketten.
• Anfangsmengen sind als Vereinigung von Einermengen darzustellen. Statt einer Einermenge {α} wird nur
die Zeichenkette α in Schreibmaschinenschrift (courier) angegeben.
• Das Zeichen für das Komplexprodukt wird weggelassen.
• Statt dem Vereinigungszeichen ∪ wird ein senkrechter Strich | benutzt, die durch | getrennten Elemente
werden auch Alternativen des entsprechenden Metasymbols genannt.
• Statt dem Gleichheitszeichen wird oft die Kombination ::= oder auch := oder einfach : benutzt.
Beispiel:
Das Gleichungssystem
A
B
=
{a} • A • {a} ∪ {a} • B • {a}
= {ε} ∪ {b} • B • {b} ∪ {b} • A • {b}
hat in BNF die Beschreibung
A
B
::= a A a | a B a
::= ε | b B b | b A b
Die BNF wird in unterschiedlichen Details in der Praxis zur Beschreibung der Syntax von Programmiersprachen
eingesetzt. Dabei nutzt man den Vorteil, die Metasymbole so bezeichnen zu können, daß die Bezeichnung bereits
auf die Art der definierten Wortmenge hinweist. Ein Ausschnitt aus einer solchen praktisch relevanten Definition
ist:
Buchstabe
Zif f er
Bezeichneranfang
Bezeichner
::=
::=
::=
::=
a | b | c |...| z
0 | 1 | 2 |...| 9
Buchstabe |
Bezeichneranfang | Bezeichner Bezeichneranfang | Bezeichner
Um Metasymbole einzusparen, werden oft Erweiterungen der BNF genutzt.
Ziffer
28
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Wir gehen nur auf die folgenden ein:
• {α1 | α2 | . . . | αn } kann anstelle des Metasymbols Auswahl und der Regel
Auswahl ::= α1 | α2 | . . . | αn
genutzt werden (n = 0 möglich).
• [α1 | α2 | . . . | αn ] kann anstelle des Metasymbols Auswahloption und der Regel
Auswahloption ::= ε | α1 | α2 | . . . | αn
genutzt werden (n = 1 möglich).
• Wenn M ein Metasymbol ist, so kann M + anstelle des Metasymbols Miteration und der Regel
Miteration ::= M | M M iteration
genutzt werden.
• Wenn M ein Metasymbol ist, so kann M ∗ anstelle von [M ]+ genutzt werden.
Im obigen Beispiel kann man auf das Metasymbol BezeichnerAnfang verzichten, indem man die Erweiterungen
folgendermaßen nutzt:
Bezeichner ::= { | Buchstabe} { | Buchstabe | Ziffer}∗
Viele andere Regeln, die die gleiche Syntax beschreiben, sind denkbar.
Grammatiken sind eine alternative Möglichkeit, um Wortmengen zu definieren.
Kontextfreie Grammatiken sind Spezialformen von Grammatiken. Sie bestehen aus
• einer endlichen Menge Σ (Menge der Terminale oder Grundsymbole genannt),
• einer endlichen Menge M (Menge der Nichtterminale oder Metasymbole genannt),
• einem ausgezeichneten Symbol S ∈ M und
• einer endlichen Regelmenge R ⊆ M × V ∗ mit V = Σ ∪ M .
Für eine Grammatik G = (Σ, M, S, R) heißt eine Relation →G ⊆ V ∗ × V ∗ mit
ψ →G ψ 0 :⇔ ∃(A, α) ∈ R ∃ψ1 ∈ V ∗ ∃ψ2 ∈ V ∗ : ψ = ψ1 Aψ2 ∧ ψ 0 = ψ1 αψ2
direkte Ableitung.
Der reflexive und transitive Abschluß →∗G von →G heißt Ableitung.
Die durch eine Grammatik G = (Σ, M, S, R) erzeugte Sprache L(G) ist definiert durch
L(G) = {γ | γ ∈ Σ∗ ∧ S →∗G γ}.
Behauptung:
Zu jeder (nicht erweiterten) BNF B (d.h. zu jedem Gleichungssystem) kann man eine kontextfreie Grammatik
G zuordnen und zu jeder kontextfreien Grammatik G eine BNF B, so daß für jede in B definierte Menge D ein
Metasymbol A in G existiert und zu jedem Metasymbol A in G eine in B definierte Menge D existiert und es
gilt für alle α ∈ Σ∗ :
A →∗G α ⇐⇒ α ∈ D.
Beweis:
Wir legen zuerst die Zuordnung fest und zeigen dann, daß diese Zuordnung das Verlangte leistet.
In der BNF wurden die Bezeichnungen für die Mengen bereits Metasymbole genannt. Die Metasymbole der
BNF benutzten wir also auch als Metasymbole der Grammatik und umgekehrt.
Zu jeder Alternative ”α0 ” A1 ”α1 ”. . . ” Ak ”αk ” des Metasymbols A, wobei die Ai alle in der Alternativen enthaltenen Metasymbole darstellen und die ”αi ” terminale Zeichenketten (möglicherweise leer) sind, ordnen wir
eine Regel (A, α0 A1 α1 . . . Ak αk ) zu und umgekehrt.
1.: Wir zeigen induktiv: zu jeder Ableitung A →∗ α der Länge n mit α ∈ Σ∗ wird α nach endlich vielen Iterationen erzeugt.
Induktionsanfang: wenn n = 1, so ist (A, α) eine Regel der Grammatik und demzufolge ”α” eine Alternative zu
2.2. INDUKTIONSPRINZIPIEN
29
A in der BNF. Demnach wird α in der ersten Stufe zu A erzeugt.
Induktionsschritt: für n sei die obige Aussage wahr und die Ableitung A →∗ α habe die Länge n + 1. Dann hat
diese Ableitung die Form
A → α0 A1 α1 . . . Ak αk →∗ α = α0 α10 α1 . . . αk0 αk ,
wobei (A, α0 A1 α1 . . . Ak αk ) eine Regel ist und alle Ableitungen Ai →∗ αi0 für i ∈ {1, . . . , k} eine Länge kleiner
gleich n haben. Demzufolge werden alle αi0 nach endlich vielen Schritten erzeugt und sind deshalb nach einer
gewissen Stufe s der Erzeugung in den Mengen Ai . Also wird α in der Stufe s + 1 für die Menge A erzeugt.
2.: Analog zu 1. kann man induktiv zeigen: wenn nach n Iterationsschritten eine Zeichenkette α für die Menge
A erzeugt wird, so existiert eine Ableitung A →∗ α.
2.2.3
Fallstudie: Funktionen
◦ Y ist eine Menge von Paaren f ⊆ X × Y , für die gilt:
Eine partielle Funktion f : X −→
(x, y), (x, y 0 ) ∈ f =⇒ y = y 0 .
Wir bezeichnen
dom f := {x | ∃ y : (x, y) ∈ f } und im f := {y | ∃ x : (x, y) ∈ f }.
Für die induktive Definition einer solchen Menge ergibt sich das Schema:
1. A ⊆ f ,
2. {◦((x1 , y1 ), . . . , (xm , ym )) | ◦ ∈ Om ∧ (x1 , y1 ), . . . , (xm , ym ) ∈ f } ⊆ f
Wir passen die Schreibweise der üblichen mathematischen Notation an.
• Die Menge A schreiben wir als Funktion fA und für (x, y) ∈ A schreiben wir y = fA (x).
• Für ◦ ∈ Om und (x, y) = ◦((x1 , y1 ), . . . , (xm , ym )) seien ◦x : X m × Y m → X und oy : X m × Y m → Y
Funktionen mit
x = ◦x (x1 , . . . , xm , y1 , . . . , ym ), y = ◦y (x1 , . . . , xm , y1 , . . . , ym )
bzw. unter Nutzung der Schreibweise y = f (x)
x = ◦x (x1 , . . . , xm , f (x1 ), . . . , f (xm )), y = ◦y (x1 , . . . , xm , f (x1 ), . . . , f (xm )).
Jetzt ergibt sich das Definitionsschema:
1. x ∈ domfA ⇒ f (x) = fA (x),
2. ∀m ∈ IN ∀◦ ∈ Om : x = ◦x (x1 , . . . , xm , f (x1 ), . . . , f (xm )) ⇒ f (x) = ◦y (x1 , . . . , xm , f (x1 ), . . . , f (xm )),
3. f (x) ist undefiniert, sonst.
Achtung: damit entsteht nicht notwendig eine Funktion!
Damit f eine Funktion wird muss aus
◦x (x1 , . . . , xm , f (x1 ), . . . , f (xm )) = ◦0x (x01 , . . . , x0m0 , f (x01 ), . . . , f (x0m0 ))
folgen, dass
◦y (x1 , . . . , xm , f (x1 ), . . . , f (xm )) = ◦0y (x01 , . . . , x0m0 , f (x01 ), . . . , f (x0m0 )).
Um dies zu erreichen fordern wir die für jede Operation ◦ ∈ Om die Existenz eines Prädikates p◦ : X → {0, 1}
mit der Eigenschaft
∀x ∈ X : p◦ (x) = 1 ∧ p◦0 (x) = 1 ⇒ ◦ = ◦0
und für jedes ◦ ∈ Om sollen Funktionen c◦,1 , . . . , c◦,m : X → X mit
x = ◦x (c◦,1 (x), . . . , c◦,m (x), f (c◦,1 (x)), . . . , f (c◦,m (x)))
existieren.
30
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Wenn pA : X → {0, 1} die charakteristische Funktion von domfa ist, so liefert das folgende Definitionsschema
immer eine Funktion f :
1. pA (x) ⇒ f (x) = fA (x),
2. ∀◦ ∈ Om : p◦ (x) ⇒ f (x) = ◦y (c◦,1 (x), . . . , c◦,m (x), f (c◦,1 (x)), . . . , f (c◦,m (x))),
3. f (x) ist undefiniert, sonst.
Wir nennen eine partielle Funktion f berechenbar, falls sie entsprechend obigem Schema induktiv definiert
ist, wobei die Funktionen pA , fA , p◦ , c◦,i und ◦y ”rein mechanisch ausführbar” sind oder auch berechenbar sind.
Das Schema beschreibt einen Algorithmus in folgender Weise: um f (x) zu berechnen führe aus:
1. Prüfe, ob pA (x) = 1.
Wenn ja, dann berechne y = fA (x) und stoppe mit dem Ergebnis y,
sonst gehe zu 2.
2. Bestimme die Operation ◦ mit p◦ (x) = 1. Falls keine solche Operation existiert, dann stoppe mit dem
Ergebnis ”undefiniert”, sonst berechne
y = ◦y (c◦,1 (x), . . . , c◦,m (x), f (c◦,1 (x)), . . . , f (c◦,m (x)))
und stoppe mit dem Ergebnis y.
Korrektheit einer induktiven Definition (Algorithmus) bedeutet:
• die (induktive) Definition beschreibt die gewünschte Funktion bzw.
• die Funktion hat gewünschte Eigenschaften.
Unterteilung in
• partielle Korrektheit:
V (x) und x ∈ domf ⇒ N (x, f (x)).
V : Vorbedingung N : Nachbedingung.
• Termination:
V (x) ⇒ x ∈ domf .
• totale Korrektheit (partielle Korrektheit und Termination):
V (x) ⇒ x ∈ domf und N (x, f (x)).
Beweisprinzip für partielle Korrektheit:
(Induktionsbeweis für laut Definitionsschema induktiv definierte Funktionen)
1. V (x) ∧ pA (x) ⇒ N (x, fA (x)),
2. ∀◦ ∈ Om : V (x) ∧ p◦ (x) ∧ ∀i : 1 ≤ i ≤ m ⇒ V (c◦,i (x)) ⇒ N c◦,i (x), f (c◦,i (x))
⇒ N x, ◦y (c◦,1 (x), . . . , c◦,m (x), f (c◦,1 (x)), . . . , f (c◦,m (x))) .
Beweisprinzip für Termination:
Es sei <⊆ (X ×X) eine wohlfundierte Ordnung, d.h. jede nichtleere Teilmenge von X hat ein minimales Element!
1. x ist minimal ∧ V (x) ⇒ pA (x),
2. x ist nicht minimal ∧ V (x) ⇒ ∃◦ ∈ Om :
p◦ (x) ∧ ∀i : 1 ≤ i ≤ m ⇒ c◦,i (x) < x ∧ V (c◦,i (x)) ∧
(c◦,1 (x), . . . , c◦,m (x), f (c◦,1 (x)), . . . , f (c◦,m (x))) ∈ dom ◦y
2.2. INDUKTIONSPRINZIPIEN
2.2.4
31
Fallstudie: Datenstrukturen
Anliegen ist die induktive Definition von speziell strukturierten Mengen, die zur Modellierung von realen
Objekten genutzt werden. Solche Mengen können oft einfacher mittels gängiger mathematischer Begriffe (z.B.
Relationen, Funktionen,...) definiert werden. Die induktive Definition soll den algorithmischen Zugang erlauben
und hängt in ihrer Art damit sehr vom eingesetzten Algorithmus ab.
Grundprinzip ist:
• Es werden keine Grundmengen vorausgesetzt.
• Alle Elemente werden über Operationen ◦ ∈ O, hier Konstruktoren genannt, erzeugt.
• Zu jedem Konstruktor ◦ ∈ Om mit m > 0 und jedem i ∈ {1, . . . , m} existieren Selektoren so,i und es gilt:
so,i (◦(x1 , . . . , xm )) = xi .
Listen: sind endliche Folgen, d.h. Worte über einer Menge A, also Elemente von A∗ .
Es gilt: eine Liste ist leer oder wird durch Voranstellen eines Elementes aus einer Menge A an die Liste erzeugt.
Wenn [] der Konstruktor für die leere Liste ist und . : . : A × L → L der Konstruktur ist, der durch Voranstellen
eines Elements a ∈ A an die Liste l die Liste a : l erzeugt, dann ist die Menge L aller Listen mit Elementen aus
A definiert als kleinste Lösung der Gleichung
L = {[]} ∪ {a : l | a ∈ A ∧ l ∈ L}.
Jede Liste l, die nicht leer ist, ist mit Hilfe des Konstruktors : erzeugt, hat also die Form l = a : t. Wenn man,
wie anfangs angedeutet, eine Liste l über einer Menge A als Funktion l : n → A auffaßt, so gilt für l = a : t, daß
l(0) = a, l(i + 1) = t(i).
Über seine zugehörigen Selektoren kann man eindeutig den Anfang (head) a und den Rest (tail) t selektieren.
Man beschreibt das, indem gefordert wird, daß l = a : t, wobei a und t Parameter sind. a : t wird auch als Muster
bezeichnet. Bei der Substitution der Parameter des Musters und Anwendung des Konstruktors ”:” entsteht ein
Wert, der mit l übereinstimmen muß. Die Bestimmung der korrekten Werte der Parameter nennt man auch
Musteranpassung und diese erfolgt mit Hilfe der Selektoren.
Diese Technik erlaubt eine elegante Beschreibung induktiver Definitionen, indem im Induktionsschema die
Bedingungen p(x) für die Anwendung der einzelnen Fälle durch
• ein anzupassendes Muster für das Argument x oder
• if p(x) then α else β
angegeben werden. Im zweiten Fall wird α benutzt, falls p(x) zutrifft, sonst β. Der else-Zweig kann auch fehlen.
Die Länge einer Liste l ist die Anzahl ihrer Elemente, also (mathematisch): length(l) = |l|.
Die induktive Definition lautet:
length([]) = 0, length(a : l) = 1 + length(l).
Auch die Verkettung läßt sich induktiv definieren:
[] ◦ l = l, (a : t) ◦ l = a : (t ◦ l).
Graphen sind Paare (E, N ), wobei
• N eine beliebige Menge ist, deren Elemente Knoten (engl. nodes) genannt werden und
• E
für gerichtete Graphen E ⊆ N × N eine Relation ist,
für ungerichtete Graphen E ⊆ {{m, n} | m, n ∈ N ∧ m 6= n} eine Menge von Zweiermengen ist,
deren Elemente Kanten (engl. edges) genannt werden.
32
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Oft existiert eine
• Knotenmarkierung cn : N → C und/oder
• Kantenmarkierung ce : E → C,
wobei C eine Menge ist, die gewisse Kosten darstellen sollen.
Weniger gebräuchlich ist die induktive Definition von Graphen, dafür aber mehr für
Bäume, das sind
• gerichtete Graphen (E, N ), die
• gepunktet sind, d.h. für den Fall N 6= ∅ existiert ein Knoten r ∈ N , Wurzel (engl.: root) genannt mit
N = {n | (r, n) ∈ E ∗ } und die
• azyklisch sind, d.h. E + ist asymmetrisch und die
• maschenfrei sind, d.h. EE −1 ⊆ {(n, n) | n ∈ N }.
Ein Knoten n ∈ N heißt
• der Vater von s, falls (n, s) ∈ E,
• ein Sohn von v, falls v der Vater von n,
• ein Nachfolger von m, falls er ein Sohn von m oder ein Sohn eines Nachfolgers von m ist,
• ein Vorgänger von m, falls m ein Nachfolger von n ist,
• ein Blatt, falls n keine Söhne hat.
Ein Baum heißt geordnet, falls alle Söhne jedes Knotens total geordnet sind. Dann kann man den Söhnen
natürliche Zahlen als Nummern zuordnen, wobei für s < s0 gilt, daß num(s) < num(s0 ).
Im folgenden wird unter einem Baum immer ein geordneter Baum verstanden!
Jedem Knoten n eines Baumes kann man eine Adresse adr(n) aus IN ∗ eineindeutig zuordnen mittels:
• adr(r) := ε, falls r die Wurzel des Baumes ist und
• adr(n) := αi, falls der Vater von n die Adresse α hat und i = num(n) ist.
Zu einem Baum (E, N ) gehört dann eine Adressenmenge B := {adr(n) | n ∈ N } mit den folgenden Eigenschaften:
E1 ∀α ∈ IN ∗ ∀i ∈ IN : αi ∈ B ⇒ α ∈ B
E2 (m, n) ∈ E ⇐⇒ ∃i ∈ IN : adr(m)i ∈ B.
Werden die Nummern für die Söhne eines Knotens konsekutiv vergeben, so gilt zusätzlich
E3 ∀α ∈ IN ∗ ∀i ∈ IN : α(i + 1) ∈ B ⇒ αi ∈ B
Da adr : N → B eine bijektive Funktion ist, kann man als Knoten auch gleich die Adressen wählen und hat als
Baum das Paar (E, B) mit E = {(α, αi) | αi ∈ B}. Da die Kantenmenge E eindeutig durch die Adressenmenge
B beschrieben ist, reicht für einen geordneten Baum die Angabe der Adressenmenge aus und, wenn eine Menge
B ⊆ IN ∗ die Eigenschaften E1 und E3 erfüllt, so beschreibt sie eindeutig einen Baum (E, B).
Für jeden Baum B und jeden Knoten α ∈ B ist
B/α = {β | αβ ∈ B}
der Unterbaum an α.
2.2. INDUKTIONSPRINZIPIEN
33
Die Höhe eines Knotens α ∈ B ist sein Abstand von der Wurzel, also |α|. Die Höhe (height) eines Baumes B
ist um Eins größer als die größte Höhe eines seiner Blätter:
height(∅) := 0, height(B) := max{|α| | α ∈ B} + 1.
Hauptanwendung von Bäumen ist die Anbindung von Informationen (Schlüsseln, engl.: keys) an die Knoten,
beschrieben als Funktionen key : B → K, wobei K eine gegebene Schlüsselmenge sei. Das Paar (B, key) heißt
auch ein markierter Baum.
Für α ∈ B definieren wir:
• key/α : B/α → K mit (key/α)(β) = key(αβ) und
• (B, key)/α = (B/α, key/α).
Linearisierungen ordnen die zu den Knoten gehörenden Schlüssel als Wort κ ∈ K ∗ an.
prälin ordnet den Vater vor die Linearisierung aller seiner Unterbäume an:
prälin(∅) = ε, prälin(B) = key(ε) ◦ prälin(B/0) ◦ · · · ◦ prälin(B/n)
wobei n = max{i | i ∈ IN ∩ B}.
postlin ordnet den Vater hinter die Linearisierung aller seiner Unterbäume an:
postlin(∅) = ε, postlin(B) = postlin(B/0) ◦ · · · ◦ postlin(B/n) ◦ key(ε).
Eine induktive Definition der Menge B von geordneten Bäumen kann mittels der Konstruktoren
• E:→ B der Stelligkeit 0 für den leeren Baum und
• T:LB → B der Stelligkeit 1, wobei LB die Menge aller Listen ist, deren Elemente Bäume sind,
erfolgen. Es gilt dann
B = {E} ∪ {T(l) | l ∈ LB }.
Die Menge B der Adressen eines Baumes aus B kann man nun induktiv bestimmen durch:
B(E) := ∅, B(T(l)) := {ε} ∪ {iα | i ∈ length(l) ∧ α ∈ B(l(i))}.
Für die Höhe height ergibt sich
height(E) := 0, height(T(l)) := max{height(l(i)) | i ∈ length(l)} + 1,
wobei max ∅ := 0 gesetzt wird.
Beachte, daß verschiedene induktiv definierte Bäume gleiche Adressenmengen haben können!
Um markierte Bäume zu erhalten, ist der Konstruktor T nur um ein Argument, die Marke (Schlüssel), zu
erweitern, d.h. T: K × LB → B und wir erhalten die Definitionsgleichung
B = {E} ∪ {T(k, l) | k ∈ K ∧ l ∈ LB }.
Die zu einem Baum t gehörende Funktion key(t) erhält man über die Definition:
key(T(k, l))(ε) = k, key(T(k, l))(iα) = key(l(i))(α).
Achtung: key(E) ist hier nicht definiert, da man in einen leeren Baum keine Information einspeichern kann.
Um eine induktive Definition für prälin zu erhalten, ordnen wir die Schlüssel in Listen LK an und benutzen
eine Hilfsfunktion prälin∗ : LB → LK :
prälin(E) = [], prälin(T(k, l)) = k : (prälin∗ (l))
wobei
prälin∗ ([]) = [], prälin∗ (a : t) = prälin(a) ◦ prälin∗ (t).
34
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
2.3
Effizienz
2.3.1
Aufwand
Der Aufwand wird gemessen in der Zeit und/oder dem Platz (später), den ein Algorithmus benötigt.
Hier: Beschränkung auf die Zeit, gemessen in
• der Schrittzahl, die ein Algorithmus für die Bestimmung einer Lösung benötigt, etwa die Stufe der Erzeugung für das Resultat in der induktiven Definition, oder
• der Anzahl der notwendigen Ausführung gewisser zeitkritischer Operationen (z.B. Vergleiche, Multiplikationen).
Ordnung des Aufwandes:
Falls f : IN → IR∗ (Aufwandsfunktion) so ist
O(f ) := {t : IN → IR∗ | ∃c ∈ IR+ ∃m ∈ IN ∀n > m : t(n) ≤ cf (n)}
Ω(f ) := {t : IN → IR∗ | ∃c ∈ IR+ ∃m ∈ IN ∀n > m : cf (n) ≤ t(n)}
Es bezeichnet
• IR∗ alle nicht negativen reellen Zahlen und
• IR+ alle positiven (größer Null) reellen Zahlen.
Aufwand ist abhängig von
• der Größe der Eingabedaten (z.B. Länge des Eingabewortes, Größe eines Wertes, Länge eines Feldes,...)
und
• der Konstellation der Eingabedaten (z.B. Anzahl der notwendigen Vertauschungen in einem zu sortierendem Feld).
Je nach Konstellation unterscheidet man den
• besten Fall (best case), der eintritt, wenn für Eingabedaten einer bestimmter Größe der geringste Aufwand
erforderlich ist,
• schlechtesten Fall (worst case), der eintritt, wenn für Eingabedaten einer bestimmter Größe der größte
Aufwand erforderlich ist, und
• erwarteten Fall (average case), der im Mittel aller Fälle eintritt, aber oft nicht einfach zu bestimmen ist.
Die generelle Methode für die Bestimmung des average-case ist:
1. Einführung einer Verteilungsfunktion P
p : K → [0, 1], die jeder Konstellation k ∈ K eine Wahrscheinlichkeit p(k) ∈ [0, 1] so zuordnet, daß k∈K p(k) = 1.
P
2. Berechnung des Erwartungswertes e = k∈K p(k) ∗ f (k), wobei f (k) den Aufwand bei der Konstellation k bezeichnet.
Aufwandsabschätzungen, insbesondere bei induktiv definierten Algorithmen, führen oft zu Rekursionsgleichungen der Form:
sn = c1 sn−1 + c2 sn−2 + · · · + ck sn−k + rn
wobei sn der Aufwand bei n-Schritten ist.
2.3. EFFIZIENZ
35
Lösungsansatz:
∞
X
G(z) =
sn z n
n=0
Es ergibt sich
1−
k
X
!
ci z
i
G(z)
=
∞
X
sn z
n
−
n=0
i=1
=
∞
X
k
∞
X
X
i=1

X
sn −

ci sn−i  z n
i=1
k−1
X
sn −
n=0
⇒ g(z)G(z)
ci sn z
n=0
min(n,k)
n=0
=
!
i+n
n
X
!
ci sn−i
z
n
+
i=1
∞
X
= p(z) +
∞
X
sn −
n=k
k
X
!
ci sn−i
zn
i=1
rn z n
n=k
Hier ist
p(z) =
k−1
X
sn −
n=0
n
X
n
ci sn−i z , g(z) = 1 −
i=1
und insgesamt:
G(z) =
!
ci z i
i=1
P∞
p(z) +
k
X
n=k rn z
n
g(z)
=
Q(z)
.
g(z) ∗ h(z)
Wenn G(z) eine rationale Funktion ist, so liefert die Taylorentwicklung von G(z) : sn =
G(n) (0)
.
n!
Bestimmung der sn : Es seien {wi : i = 1, . . . , l} alle reellen Nullstellen von g(z) ∗ h(z), die Nullstelle wi habe
Pl
die Vielfachheit µi , und es sei i=1 µi = grad(g ∗ h). Es folgt (über Partialbruchzerlegung von g(z) ∗ h(z)):
sn =
l
X
µ
i −1
X
i=1
j=0
j
Aij
n
win
!
Die Aij lassen sich nun aus den Anfangswerten der sn ermitteln (lineares Gleichungssystem!).
2.3.2
Entwicklungsprinzipien
Grundprinzipien sind:
• Rekursion bzw. Induktion,
d.h.Zurückführen auf ein einfacheres, der Lösung ”näheres” Problem.
• Teile und Herrsche,
d.h. Zerlegung in mehrere Teilprobleme.
• Ausbalancieren,
d.h.Teilprobleme möglichst gleich ”schwer” machen.
• dynamische Programmierung,
d.h. Vermeidung von Doppelberechnungen.
Einflußgrößen auf Effizienz sind:
• Abbruchkriterien und
• die Balance.
36
2.3.3
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Fallstudie: Maximum und Minimum einer Liste
Gegeben: Eine total geordnete Menge (A, ≤) und eine Folge α ∈ An mit n ∈ IN+ .
Gesucht: Maximum und Minimum aller Elemente α(i).
Aufwandsmaß: sn ist Anzahl der erforderlichen Vergleiche.
Variante 0: Abbruch bei n = 1,
Aufwand: sn = 2n − 2
mami(a) = (a, a)
mami(aα) = (ma, mi)
wobei
(mat, mit) = mami(α)
ma=if mat > a then mat else a
mi=if mit < a then mit else a
Variante 1: Abbruch bei n = 2,
Aufwand: sn = 2n − 3 für n ≥ 2.
mami(a) = (a, a)
mami(ab) = if a > b then (a, b) else (b, a)
mami(aα) = (ma, mi)
wobei
(mat, mit) = mami(α)
ma=if mat > a then mat else a
mi=if mit < a then mit else a
Variante 2: Teile und Herrsche
Prinzip: Zerlegung in k Teilprobleme mit n = n1 + . . . + nk .
Aufwand:
tn = tn1 + . . . + tnk + fn ,
wobei fn der Aufwand fürs Zusammensetzen der Teillösungen zur Gesamtlösung ist.
Teile und Herrsche bringt Vorteile, falls
sn1 + . . . + snk + fn < sn .
Hier: k = 2, n1 = 2, n2 = n − 2, f (n) = 2
Obige Variante 0: (4 − 2) + (2(n − 2) − 2) + 2 = 2n − 2, bringt keine Verbesserung!
Obige Variante 1: (4 − 3) + (2(n − 2) − 3) + 2 = 2n − 4 < 2n − 3, bringt Verbesserung, nämlich den
Aufwand: n gerade
⇒ sn = 32 n − 2, n ungerade
⇒ sn = 32 (n − 1)
mami(a)
= (a, a)
mami(ab)
= if a > b then (a, b) else (b, a)
mami(aαβ) = (ma, mi)
wobei
(ma1, mi1) = mami(aα)
(ma2, mi2) = mami(β)
ma=if ma1 > ma2 then ma1 else ma2
mi=if mi1 < mi2 then mi1 else mi2
Ausbalancieren (|α| = |β|) bringt keinen Gewinn!
2.3. EFFIZIENZ
2.3.4
37
Fallstudie: Sortieren
Gegeben: Eine total geordnete Menge (A, ≤) und eine Folge α ∈ An mit n ∈ IN+ .
Gesucht: Eine Bijektion π : n → n, so daß ∀i ∈ (n − 1) : α(π(i)) ≤ α(π(i + 1)) gilt.
Vereinfachung: statt π zu bestimmen wird eine Folge α0 ∈ An mit ∀i ∈ n : α0 (i) = α(π(i)) ermittelt.
Aufwandsmaß: s(n) ist Anzahl der erforderlichen Vergleiche.
Die Sortieralgorithmen unterscheiden sich stark in
• ihrer Effizienz für verschiedene Konstellationen,
• vorhandenen Nebenbedingungen wie
– ob die Menge A endlich ist und relativ wenig Elemente hat oder ob sie unendlich ist oder relativ viel
Elemente hat,
– ob die gesamte Folge α insgesamt zur Verfügung steht oder nur schrittweise verfügbar wird (für lange
Folgen).
Fall 1: A hat nur wenig Elemente ⇒ Radix-sort:
Für jedes a ∈ A wird ein Fach Fa eingerichtet. Ein Element α(i) wird ins Fach Fα(i) abgelegt. Anschließend
werden die Elemente aller Fächer entsprechend der Ordnung verkettet.
Aufwand (worst case=average case=best case): O(n)
Fall 2: A hat viele Elemente oder ist unendlich.
In diesem Fall muß die Permutation durch Vergleich der Elemente gefunden werden. Die Frage ist: wie gut kann
ein Sortier-Algorithmus im worst-case sein, wobei die Anzahl der durchgeführten Vergleiche als Maß genommen
wird?
Es gibt n! verschiedene Permutationen π : n → n. Um die notwendige Permutation herauszufinden, sind log2 (n!)
Vergleiche notwendig, also (unter Nutzung der Stirlingschen Formel)
n! ≈
n n √
e
1
2πn =⇒ log(n!) ≈ n(log n − log e) + (log 2π + log n)
2
Minimal zu erreichender Aufwand im worst case: O(n ln n).
Wir behandeln drei Sortierverfahren für den Fall 2:
• Sortieren durch Mischen in zwei Varianten,
• Sortieren durch Trennen und
• Sortieren durch Vertauschen in zwei Varianten.
Sortieren durch Mischen:
Teile und Herrsche ist Grundprinzip, wobei die zu sortierende Folge in zwei Teilfolgen αβ aufgeteilt wird, die
sortiert und dann zu einer Folge zusammengemischt werden. Das Mischen erfolgt durch
merge(ε, α) = merge(α, ε) = α
merge(aα, bβ) = if a < b then a : merge(α, bβ)
else b : merge(aα, β)
Aufwand für merge im worst case: |αβ| − 1.
38
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Sortieren:
mergesort(αβ) =if |αβ| = 1 then αβ
else merge(mergesort(α), mergesort(β))
Fall 1 insort: |α| = 1.
Rekursionsgleichung : sn = sn−1 + n − 1, s1 = 0, s2 = 1
=⇒ (1 − z)G(z) = p(z) +
∞
X
(n − 1)z n = p(z) +
n=1
Wegen T (z) =
P∞
n=0
n
z =
1
1−z
∞
X
nz n −
n=1
∞
X
zn
n=1
folgt
∞
X
n=1
nz n = z
∞
X
nz n−1 = zT 0 (z) =
n=1
=⇒ G(z) =
z
(1 − z)2
q(z)
(1 − z)3
=⇒ sn = A10 + A11 n + A12 n2
=⇒ sn =
1 2
(n − n)
2
Aufwand im worst case: O(n2 )
Wir erreichen eine Verbesserung durch das Prinzip des Balancierens!
Fall 2 mergesort: |α| = n2
Rekursionsgleichung : sn = 2sb n c + n − 1, s1 = 0
2
Substitution n = 2m ergibt: gm = 2gm−1 + 2m − 1, g0 = 0
=⇒ gm = 1 − 2m + m2m
Rücksubstitution m = log n ergibt: sn = 1 − n + n log n
Aufwand im worst case: O(n ln n)
Hinweis:
Vielleicht können wir durch Ausbalancieren auch das insort verbessern, indem statt merge(a, β) (es hatte ja α
die Länge 1, besteht somit nur aus einem Zeichen a) ein spezielleres Verfahren einsetzen, das das Zeichen a in
die sortierte Folge β binär einordnet. Das heißt, β wird wieder in zwei möglichst gleiche Teile β1 , β2 und das
mittlere Zeichen b unterteilt. Wenn a < b wird a nach β1 eingeordnet, sonst in β2 .
Frage: Wie sieht der Aufwand jetzt aus? (Übung)
insort(aα) = if α = ε then a
else binin(a, insort(α))
binin(a, ) = a
binin(a, β1 bβ2 ) = if a < b then binin(a, β1 ) ◦ bβ2
else β1 b ◦ binin(a, β2 )
2.3. EFFIZIENZ
39
Sortieren durch Trennen: quicksort
Für ein beliebiges Element a = α(i) werden alle Elemente der Folge in zwei Fächer: F< (a, α), F≥ (a, α) aufgeteilt.
In F< (a, α) werden alle Elemente von α eingeordnet, die kleiner als a sind und in F≥ (a, α) alle Elemente, die
größergleich a sind (außer a selbst, warum?). Die Fächer F< (a, α) und F≥ (a, α) werden durch quicksort sortiert
und dann verbunden, wobei das Element a in die Mitte gestellt wird:
quicksort(α) = if α = ε then α
else quicksort(F< (a, α)) ◦ a ◦ quicksort(F≥ (a, α))
Der Aufwand hängt sehr stark davon ab, ob die zwei Fächer gut ausbalanciert sind. Wir nehmen vereinfachend
an, jedes Element kommt nur einmal vor und die Wahrscheinlichkeit, daß |F< | = j − 1 für jedes j mit 1 ≤ j ≤ n,
ist n1 . Wenn cn der Aufwand für die Bildung der Fächer ist, dann erhalten wir für den Erwartungswert
sn =
n
n−1
X
1
2X
(sj−1 + sn−j ) + cn =
sj + cn .
n
n j=0
j=1
Durch vollständige Induktion kann man zeigen, daß
sn ≤ 2cn ln n.
Sortieren durch Vertauschen: bubblesort
Das Prinzip entspricht den aufsteigenden Luftblasen. Je größer eine Luftblase, desto höher steigt sie. Wenn kein
Steigen mehr beobachtet wird, kann abgebrochen werden. Im besten Fall ist nichts zu vertauschen und nur n − 1
Vergleiche sind durchzuführen.
bubble(a) = (a, True)
bubble(abα) = if a > b then (bβ, F alse) else (aβ 0 , c0 )
wobei
(β, c) = bubble(aα)
(β 0 , c0 ) = bubble(bα)
bsort(α) = if c then β else bsort(β)
wobei (β, c) = bubble(α)
Aufwand im worst case: O(n2 ), im best case O(n).
Sortieren durch Vertauschen: heapsort
Ein Heap ist ein binärer markierter Baum (B, key), für den gilt: ∀αi ∈ B : key(α) < key(αi).
Grundprinzip:
1. Aufbau eines Heap aus der gegebenen Folge σ ∈ An .
2. Abbau des Heap und Erzeugung einer sortierten Folge, wobei in jedem Schritt
• das Element key(ε) entfernt wird,
• aus den Unterbäumen B/0, B/1 ein neuer Heap gebildet wird.
Achtung: Man erreicht den Aufwand O(n ∗ ln n), falls der Heap höhenbalanciert ist, d.h.
∀α ∈ B : |height(B/0) − height(B/1)| ≤ 1.
40
KAPITEL 2. ENTWURF UND ANALYSE VON ALGORITHMEN
Grundoperationen:
• keyhα : ki : B → S mit keyhα : ki(β) =
k
für α = β
key(β) sonst
• num : B → IN numeriert die Knoten, so daß gilt: num(ε) = 0, num(αi) = 2 ∗ num(α) + i + 1.
• last(B) ∈ B ist ein Knoten mit num(last(B)) = max{num(α) | α ∈ B},
• corrheap macht aus einem markiertem Baum, dessen linker und rechter Unterbaum bereits ein heap
ist, einen heap, indem eine neue Schlüsselfunktion key 0 berechnet wird, falls das erforderlich ist, d.h.
corrheap(B, key) = key 0 wobei
falls key(ε) ≤ key(0) und key(ε) ≤ key(1), dann
key 0 = key und
falls für ein i ∈ {0, 1} : key(ε) > key(i) und key(1 − i) ≥ key(i) dann
key 0 (ε) = key(i), key 0 /(1 − i) = key/(1 − i), key 0 /i = corrheap(B/i, keyhi : key(ε)i/i).
Zu 1.:
1.1 B := {α | num(α) ∈ n}, key := {(α, σ(num(α)) | α ∈ B},
1.2 heapif y(B, key) = (B, corrheap(B, key 0 )), wobei
key 0 /0 = heapif y(B/0, key/0), key 0 /1 = heapif y(B/1, key/1).
Zu 2.:
heapsort(B, key) = key(ε) ◦ heapsort(B 0 , corrheap(B 0 , key 0 )), wobei
• B 0 = B − {last(B)},
• key 0 = keyhε : key(last(B)i.
Kapitel 3
Funktionale Programmierung mit
Haskell/Gofer
3.1
Allgemeines
”Haskell” ist einer der Vornamen von Haskell B. Curry, einem Logiker, der wesentlichen Anteil an der Entwicklung des λ-Kalküls hatte.
Für Haskell stehen mehrere Systeme für die verschiedensten Plattformen zur Verfügung, weit genutzt und
bekannt sind
• Gofer (eine ältere Variante, als .zip Datei ladbar) und
• Hugs98 (in der letzten Version vom Dezember 2001, von der Original-Seite ladbar).
Beides sind Implementierungen unter der Federführung von Mark P. Jones, von der Oxford Universität. Dabei
wurde die Originalversion von Haskell jeweils leicht modifiziert. Diese Modifikationen werden in der Vorlesung
aber nicht zum Tragen kommen.
Die Systeme bestehen jeweils aus
• dem Interpreter, der die Kommandos einer Sitzung ausführt und
• dem Compiler, der die in den Scripts (Dateien mit Erweiterung .gs für Gofer und .hs für Haskell)
enthaltenen Definitionen auf syntaktische Korrektheit prüft und in eine interne Form transformiert.
3.2
Sitzungen
Eine Sitzung zerfällt in einzelne Sitzungsschritte, bestehend aus
• einem vom Nutzer einstellbaren Prompt (z.B. ?), auf das eine Nutzeraktion, nämlich
– ein Kommando oder
– ein Ausdruck
erwartet wird und
• die Reaktion des Systems, nämlich
– eine Antwort auf das Kommando oder
– der Wert des Ausdrucks.
41
42
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
Wichtige Kommandos sind:
• :? - liefert Übersicht über alle Kommandos,
• :q - Verlassen des Systems,
• :n - gibt eine Liste aller aktuell definierten Bezeichner aus ,
• :l Dateiname - wechselt zu einem neuen Script und veranlaßt dessen Übersetzung, falls notwendig,
• :r - veranlaßt die Neuübersetzung des aktuell genutzten Script, falls erforderlich,
• :t Ausdruck - gibt den Typs von Ausdruck zurück.
Ein Ausdruck besteht aus:
• Bezeichner: eine Zeichenfolge, bestehend aus Buchstaben (groß oder klein), eventuell gefolgt von Buchstaben, Ziffern, Apostroph (’) und Unterstreichung ( ) oder
• Literal: Beschreibung von Konstanten (Siehe Grundtypen) oder
• Funktionsanwendung: f α
wobei f der Funktionsname (Bezeichnung, mit kleinem Buchstaben beginnend) und α ein Ausdruck ist
oder
• Operator-Ausdruck: α1 o α2
wobei α1 , α2 Ausdrücke sind und o ein Operator ist.
3.3
Scripts
Grundprinzipien bei der Definition:
Die in Scripts enthaltenen Vereinbarungen von Bezeichnern bestehen aus
• Der (optionalen) Spezifikation des Typs eines Bezeichners: name::τ
wobei name ein Bezeichner und τ sein Typ ist und
• Definition des Wertes eines Bezeichners.
Dabei ist zu beachten:
• Jeder Bezeichner kann global nur einmal definiert werden, er hat immer einen konstanten Wert.
• Die Reihenfolge bei der Definition unterschiedlicher Bezeichner ist frei, insbesondere können Bezeichner
vor ihrer Definition verwendet werden.
• Die Definition eines Bezeichners kann aus mehreren Teilen bestehen, die Reihenfolge zwischen den Teilen
hat Bedeutung. Alle diese Teile müssen hintereinander folgen.
Grundtypen:
• Int hat
– als Werte: ganze Zahlen (Genauigkeit hängt von der Implementierung ab!),
– als Literale: Ziffernfolgen, wahlweise mit Vorzeichen +,-,
– als Operatoren: + - * / und die Vergleiche ==, <, >, <=, >=, /=.
3.3. SCRIPTS
43
• Float hat
– als Werte: Gleitkommazahlen (Genauigkeit hängt von der Implementierung ab!),
– als Literale: Darstellungen mit Dezimalpunkt und wahlweise Exponent, analog zu Pascal bzw. C,
– als Operatoren: + - * / und die Vergleiche ==, <, >, <=, >=, /=.
• Char hat
– als Werte: Zeichen
– als Literale: ’z’, wobei z ein beliebiges Zeichen oder eine Escape-Folge ist,
– als Operatoren: die gewöhnlichen Vergleiche.
• Bool hat
– als Werte: die Wahrheitswerte,
– als Literale: False, True,
– als Operatoren: && (und), || (oder), not (nicht),
Typ-Konstrukte:
• (τ1 , . . . , τn ) hat
– als Werte: n-Tupel, wobei die i-te Komponente vom Typ τi ist,
– als Literale: (l1 , . . . , ln ), wobei li ein Literal vom Typ τi ist.
• [τ ] hat
– als Werte: Listen vom Typ τ , d.h. endliche Folgen von Werten aus τ ,
– als Literale:
∗ [] - die leere Liste,
∗ [l1 , . . . , lk ] - Listen der Länge k, wobei die li alle Literale vom Typ τ sind,
∗ z1 . . . zk - als Abkürzung für [’z1 ’, . . . ,’zk ’], falls τ =char.
– als Operatoren: den Listenkonstruktor (:) und die Verkettung (++) von Listen.
• τ1 ->τ2 hat
– als Werte: alle partiellen Funktionen aus τ1 in τ2 ,
– als Literale: keine,
– als Operatoren: die Funktionsanwendung und die Funktionskomposition (.).
Definition von Bezeichnern:
• für Bezeichner:
Muster = Ausdruck
Muster (pattern) besteht aus
– Bezeichnern,
– der Untersteichung ( ),
– Literalen sowie
– Konstruktoren (Siehe 3.5).
und enthält mindestens einen zu definierenden Bezeichner. In einem Muster darf ein Bezeichner nur einmal
auftreten.
Der Musterangleich (pattern matching) ist erfolgreich, falls bei der Ersetzung der Bezeichner im Muster
mit entsprechenden Werten Übereinstimmung mit dem Wert des Ausdrucks hergestellt ist.
44
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
• für n-stellige Funktionen:
Funktionsbezeichner Muster1 . . .Mustern
Rechte Seite
Rechte Seite besteht aus
– der Kombination der Form
oder
= Ausdruck
– einer Fallunterscheidung der Form
|
|
|
Bedingung1
Bedingung2
.
.
.
Bedingungm
=
=
Ausdruck1
Ausdruck2
=
Ausdruckm
Eine spezielle, immer zutreffende Bedingung, ist der Bezeichner otherwise. Diese Bedingung wird oft als
letzte Bedingung benutzt, um einen alternativen Fall zu beschreiben.
Lokale Definitionen:
Die Gültigkeit von Definitionen kann lokal auf eine andere Definition eingeschränkt (mittels Schlüsselwort where)
werden. Die lokal eingeführten Bezeichner sind nur dort gültig und können in anderen Definitionen neu definiert
werden.
Typographie:
Kommentare sind möglich als
• Zeilenkommentare, eingeleitet durch -- und beendet durch das Zeilenende und
• eingebettete Kommentare, eingeleitet durch die Kombination {- und beendet durch die Kombination -}.
Alle Teile einer Definition können durch Klammerung mittels {} zusammengefaßt und durch Semikolon ; getrennt werden.
Auf die Klammerung und Trennung kann unter Nutzung der Abseitsregel verzichtet werden. Diese lautet grob:
Alle Zeilen mit einer größeren Einrückung als die vorangegangene Definition gehören zu dieser. Alle lokalen
Definitionen müssen deshalb geklammert sein oder die gleiche Einrückung besitzen.
Die Funktionsanwendung:
f γ1 . . . γk
(wobei f ein Funktionsbezeichner und γ1 . . . γk Ausdrücke seien)
wird beim Vorliegen der Definitionsgleichungen
f
.
.
.
f
m1,1 . . . m1,k
rs1
mn,1 . . . mn,k
rsn
(wobei mi,j Muster und rsi rechte Seiten seien)
in folgenden Schritten abgearbeitet:
1. Es wird nacheinander der Musterangleich
m1,1 . . . m1,k
m2,1 . . . m2,k
mn,1 . . . mn,k
versucht.
mit
mit
bis
mit
γ1 . . . γk
γ1 . . . γk
γ1 . . . γk
3.4. EINFÜHRENDE PROGRAMMIERBEISPIELE
45
2. Wenn kein Musterangleich erfolgreich ist, so ist die Funktionsanwendung undefiniert. Sonst sei ml,1 . . . ml,k
die erste Folge von Mustern mit erfolgreichem Angleich.
3. Es wird die rechte Seite rsl der Form
|
|
|
β1
β2
.
.
.
βm
=
=
α1
α2
=
αm
ausgewertet. Dabei werden in der gegebenen Reihenfolge die Bedingungen βi (das sind Ausdrücke mit
dem Ergebnistyp bool) ausgewertet, bis für ein p die Bedingung βp erstmalig True liefert. Der Wert des
Ausdrucks αp ist der Funktionswert.
Falls keine der Bedingungen True liefert, so ist die Funktionsanwendung undefiniert. Die Bedingung
otherwise liefert immer den Wert True!
3.4
Einführende Programmierbeispiele
Orientierung am Induktionsprinzip bedeutet:
• induktiv definierte Algorithmen können direkt in Programme transformiert werden. (Fakultät, Binomialkoeffizienten)
• Nicht induktiv definierte Algorithmen sollten in induktive Definitionen überführt werden(Summe).
• Induktiv definierte Funktionen direkt berechnen, falls möglich (Auflösen von Rekursionsgleichungen)(Summe).
• Induktionsanfang (kann auch als Abbruchbedingung angesehen werden) wird als spezielle Definitionsgleichung(en) vorangestellt oder in die Fallunterscheidung eingebaut.
• Geschickt Eigenschaften der Funktionen (Algorithmen) nutzen. (Größter gemeinsamer Teiler, Binomialkoeffizienten).
• Durch Mitführung von Zwischenresultaten als zusätzlicher Parameter (Entfaltung) können Expansion der
Induktion (Fakultät) und Doppelberechnungen (Binomialkoeffizienten) vermieden werden.
Fakultät: 0! = 1, (n + 1)! = n! ∗ (n + 1)
ist Anzahl der Bijektionen π : n → n
-- Fakultät
ff 0 = 1
ff (n+1) = (n+1)* ff(n)
--bzw.
fff n | n==0 =1 | otherwise = n*fff(n-1)
Binomialkoeffizienten: bk(0, k + 1) = 0, bk(n, 0) = 1, bk(n, k) = bk(n − 1, k) + bk(n, k − 1)
ist Anzahl der k-elementigen Teilmengen einer n-elementigen Menge
-- n über k
bk(0,k+1) = 0
bk(n,0)
= 1
bk(n+1,k+1) = bk(n,k+1)+bk(n,k)
46
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
Summation: s(n) =
Pn
i=0
i induktive Form: s(0) = 0, s(n + 1) = s(n) + n + 1
-- Summe
s 0 = 0
s (n+1) = s n + n + 1
--bzw.
ss n | n==0 =1 | otherwise = s(n-1) + n
--bzw.
sss n = n*(n+1)/2
Größter gemeinsamer Teiler:
• m|n :⇔ ∃p ∈ IN : m ∗ p = n
• ggt(m, n) = t :⇔ t|m ∧ t|n ∧ ∀t0 : (t0 |m ∧ t0 |n ⇒ t0 |t)
--größter gemeinsamer Teiler
ggt::(Int,Int)->Int
ggt(0,n) = n
ggt(m,0) = m
ggt(m,n) | m>=n = ggt(m-n,n) | otherwise = ggt(m,n-m)
--beschleunigter ggt
ggt::(Int,Int)->Int
ggt(m,0) = abs(m)
ggt(m,n) = ggt(n,m‘mod‘n) -- m==n*(m‘div‘n)+(m‘mod‘n)
Euklidischer Algorithmus: euk(m, n) = (ggt, x, y) wobei m ∗ x + n ∗ y = ggt(m, n)
--Euklidischer Algorithmus
euk(m,0) = (abs(m),x,0)
where x | m<0 = -1 | otherwise = 1
euk(m,n) = (m’,y,x-(m‘div‘n)*y)
where (m’,x,y) = euk(n,m‘mod‘n)
Entfaltung:
-- entfaltete Fakultät
fakh (i,f,n) | n==i = f | otherwise = fakh (i+1,f*(i+1),n)
ff n = fakh (0,1,n)
Direkte Berechnung der Binomialkoeffizienten: n ≥ k ⇒ bk(n, k) =
n!
k!(n−k)!
-- modifizerter Binomialkoeffizient
mbk::(Int,Int)->Int
mbk(n,k) | n<k = 0 | otherwise = (ff n)‘div‘((ff k) * ff (n-k))
-- Verbesserungen zum Binomialkoeffizient
-- durch Entfaltung und Symmetrie
bkh(n,k,i,q) | (n-k)<i = q
| otherwise = bkh(n,k,i+1,(q*(k+i))‘div‘i)
vbk(n,k) | n<k = 0
| k<(n-k) = bkh(n,n-k,1,1)
| otherwise = bkh(n,k,1,1)
Listenfunktionen:
head::[Int]->Int
head [] = error "für leere Liste nicht definiert"
head (h:t) = h
3.4. EINFÜHRENDE PROGRAMMIERBEISPIELE
-- Rest
tail::[Int]->[Int]
tail [] = []
tail (a:t) = t
--Länge
length::[Int]->Int
length [] = 0
length (a:t) = 1+(length t)
--Summe aller Elemente einer Liste
su::[Int]->Int
su [] = 0
su (i:l) = i+ su l
--Summe der Quadrate aller Elemente
suq::[Int]->Int
suq [] = 0
suq (i:l) = (i*i)+ suq l
--Horner-Schema, höchster Koeffizient steht rechts
h::([Float],Float)->Float
h ([a],x) = a
h (a:t,x) = a + x * h(t,x)
--Maximum und Minimum aus einer Liste
--Variante 0
mami::[Int]->(Int,Int)
mami [a] = (a,a)
mami (a:t) = (ma,mi)
where
(mat,mit)= mami t
ma | mat>a = mat | otherwise = a
mi | mit<a = mit | otherwise = a
--Variante 1
mami::[Int]->(Int,Int)
mami [a] = (a,a)
mami [a,b] | a>b = (a,b) | otherwise = (b,a)
mami (a:b:t) = (ma,mi)
where
(mat,mit)= mami (b:t)
ma | mat>a = mat | otherwise = a
mi | mit<a = mit | otherwise = a
--Variante 2
mami::[Int]->(Int,Int)
mami [a] = (a,a)
mami [a,b] | a>b = (a,b) | otherwise = (b,a)
mami (a:b:t) = (ma,mi)
where
(ma1,mi1)= mami [a,b]
(ma2,mi2)= mami t
ma | ma1>ma2 = ma1 | otherwise = ma2
mi | mi1<mi2 = mi1 | otherwise = mi2
47
48
3.5
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
Wichtige Ergänzungen
Funktionen höheren Typs haben Funktionen als Argumente und/oder Resultat!
Funktion als Argument:
--Summe von Funktionsanwendungen
sf::(Int->Int,[Int])->Int
sf(f,[]) = 0
sf(f,h:t) = f(h)+(sf(f,t))
--Summe von Quadraten
qu::Int->Int
qu x = x*x
Wir erhalten sf(qu,[1,2,3])=14
Funktion als Resultat:
sqrx::(Int,Int)->Int
-- sqrx(x,y) ist groesstes z mit x<z und z*z<=y, falls x*x<=y
sqrx(x,y) | x*x+2*x+1>y = x | otherwise = sqrx(x+1,y)
sqr::Int->(Int->Int)
sqr x = g where g(y) = sqrx(x,y)
sq = sqr 0
Wir erhalten sq 19=4
Funktion als Argument und Resultat:
sf::(Int->Int)->([Int]->Int)
sf f = g
where
g [] = 0
g (h:t) = f(h)+ g(t)
squ = sf qu
Wir erhalten squ([1,2,3])=14
Allgemeine Form mittels Currying:
Für
f::(τ1 , . . . , τn ) → τ
ist die Currying-Variante
wobei gilt:
cf a1 ... an = f(a1,...,an).
Abkürzung:
Statt:
τ1 → (τ2 → (τ3 . . . (τn → τ ) . . .))
klammerfrei:
τ1 → τ2 → τ3 . . . τ n → τ
Obige Beispiele als Currying-Varianten:
--Summe von Funktionsanwendungen
sf::(Int->Int)->[Int]->Int
sf f [] = 0
sf f (h:t) = f h + (sf f t)
--Summe von Quadraten
squ = sf qu where qu x = x*x
cf::τ1 → . . . → τn → τ
3.5. WICHTIGE ERGÄNZUNGEN
49
sqr::Int->Int->Int
sqr x y | x*x+2*x+1>y = x | otherwise = sqr (x+1) y
sq = sqr 0 -- größte ganzzahlige Wurzel
--Summe von Wurzeln
sfsq = sf sq
Allgemeiner kann man Listen LA mit Elementen vom Typ A aggregieren, indem man zusätzlich für die Aggregation eine Funktion g : A × LA → LA als Argument einsetzt. Man läßt nun keine leeren Listen zu, sonst muß
ein Anfangswert explizit als weiterer Parameter angegeben werden.
--allgemeine Aggregation mit Funktionen f und g
ag g f [x] = f x
ag g f (h:t) = g (f h) (ag g f t)
--Summe von Quadraten
asqu = ag (+) qu where qu x = x*x
--Produkt von Quadraten
apqu = ag (*) qu where qu x= x*x
--Summe von Wurzeln
asqr = ag (+) sq
Polymorphe Typen:
sind Typen, die Typvariable, das sind mit kleinen Buchstaben beginnende Bezeichner, enthalten. Ein polymorpher Typ beschreibt die Gesamtheit aller Typen, die bei einer beliebigen Ersetzung der Typvariablen durch
einen Typ , auch durch einen polymorphen Typ, entstehen.
Polymorphismus:
erlaubt Funktionen mit polymorphen Typen.
Typisches Beispiel sind Funktionen über Listen:
head:: [a] -> a -- aus Prelude
tail:: [a] -> [a] -- aus Prelude
concat :: [[a]] -> [a] -- aus Prelude
ag :: (a -> a -> a) -> (b -> a) -> [b] -> a
Haskell erlaubt, entsprechend der induktiven Definition Datenstrukturen einzuführen. Das Schema dafür ist
data Datenstrukturbezeichner T ypvariable1 . . . T ypvariablem = Konstruktion1 | . . . | Konstruktionn
wobei Konstruktioni die Form
Konstruktori T yp1 . . . T ypni
hat und Datenstrukturbezeichner sowie Konstruktori frei wählbare Bezeichner sind, die mit einem Großbuchstaben zu beginnen haben. Die auf der linken Seite aufgeführten T ypvariablen sind diejenigen, die in einen der
Typen einer Konstruktion auftreten und erlauben damit Polymorphismus bei induktiv definierten Datenstrukturen. Die im Abschnitt 2.2.4 definierten geordneten Bäume haben in Haskell die Form:
data B k = E | T k [B k]
Hierbei bezeichnet B k den Typ der markierten geordneten Bäume,k ist eine Typvariable, die erlaubt, den Typ
für den Schlüssel offen zu halten, E ist der nullstellige Konstruktor für den leeren Baum und T der einstellige
Konstruktor, dessen Typ eine Liste von markierten Bäumen ist.
50
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
3.6
Abschließende Fallstudien
3.6.1
Sortieren
Einfache Aufgaben sind die Implementierung von insort, mergesort, quicksort und bubblesort.
Für heapsort wurde folgende Datenstruktur gewählt:
data BT = E | T Int BT BT
-- E bezeichnet den leeren Baum,
-- T konstruiert aus einer Zahl und zwei Bäumen einen neuen Baum
Bei der Implementierung muss der im Abschnitt 2.3.4 für heapsort explizit definierte Schritt 1.1 und bei 2. das
Löschen des letzten Elementes sowie das Ersetzen der Wurzel durch das letzte Elemente in eine induktive Form
gebracht werden.
Der Schritt 1.1 erfordert das Einfügen der Elemente einer Liste als letztes Element des Baumes, wobei auf einen
gleichmäßgen Aufbau zu achten ist. Dazu werden Hilfsfunktion full und height eingesetzt. full stellt fest,
ob ein Baum voll ausgelastet ist, so daß beim Einfügen eine neue Ebene einzuführen ist. height bestimmt die
Höhe eines Baumes.
full::BT->Bool
full E = True
full (T _ E E)
full (T _ _ E)
full (T _ l r)
-- gibt an, ob Baum gleichmässig gefüllt ist
= True
= False
= ( full l)&&(full r)
height::BT->Int -- bestimmt Höhe des Baumes
height E = 0
height (T _ l r) | hl>=hr = hl+1 | otherwise = hr+1
where
hl = height l
hr = height r
inslast::BT->Int->BT -- fügt an Baum hinten einen Knoten an
inslast E z = (T z E E)
inslast (T w E E) z = (T w (T z E E) E)
inslast (T w l E) z = (T w l (T z E E))
inslast (T w l r) z
| (not (full l)) || full r && (height l)==(height r) = (T w (inslast l z) r)
| otherwise = (T w l (inslast r z))
build::[Int] -> BT -- macht aus Liste einen Baum
build [] = E
build (a:t) = inslast (build t) a
Das Bestimmen des letzten Baumelementes wird mit lastval und das Löschen des letzten Baumelementes mit
dellast implementiert. Dazu wird nur die Höhe benötigt.
lastval::BT->Int -- gibt letzten Wert aus Heap aus
lastval E = error "leerer Baum"
lastval (T z E E) = z
lastval (T z l r)
| height l>height r = lastval l
| otherwise = lastval r
dellast::BT->BT -- löscht letzten Knoten aus Heap, Übungsaufgabe
3.6. ABSCHLIESSENDE FALLSTUDIEN
51
Der Rest ist nun recht einfach analog zur induktiven Definition des Algorithmus implementierbar. Zur endgüligen Beschreibung von heapsort wird der Punktoperator (im Prelude definiert) genutzt, der das Hintereinanderausführen von Funktionen f, g festlegt. Es gilt
(f.g)(x) = f (g(x)).
heapsort::[Int]->[Int]
heapsort = heaps.heapify.build
-- also heapsort l = heaps(heapify(build l))
heapify::BT->BT -- macht aus Baum einen Heap
heapify E = E
heapify (T z l r) = corrheap (T z (heapify l) (heapify r))
corrheap::BT -> BT -- korrigiert Baum zum Heap,
-- falls Unterbäume bereits Heap
corrheap E = E
corrheap (T z E E) = (T z E E)
corrheap (T z l E)
| z<=lz = (T z l E)
| otherwise = (T lz (corrheap(T z ll lr)) E)
where
(T lz ll lr)=l
corrheap (T z l r)
| z<=lz && z<=rz = (T z l r)
| lz<z && lz<=rz = (T lz (corrheap (T z ll lr)) r)
| otherwise = (T rz l (corrheap (T z rl rr)))
where
(T lz ll lr)=l
(T rz rl rr)=r
heaps::BT->[Int] -- macht aus Heap eine Liste
heaps E = []
heaps (T z l r) = z:(heaps (corrheap (dellast(T (lastval (T z l r)) l r))))
3.6.2
Suchbäume
Suchbäume werden zum effizienten Speichern und Wiederauffinden von Informationen genutzt. Jedes Informationselement (k, v) besteht dabei aus einem Schlüssel k, der das Element eindeutig identifiziert und einem
Informationswert v. Daraus ergibt sich, daß jeder Schlüssel höchstens einmal in der gespeicherten Informationsmenge enthalten ist.
Zur Vereinfachung verzichten wir zunächst auf die Betrachtungen zum Informationswert und beschränken uns
auf den Schlüssel. Somit sind Suchbäume markierte Bäume (B, key).
3.6.2.1
AVL-Bäume
In einem binären Suchbaum T = (B, key) gilt: ∀α ∈ B : key(α0) < key(α) < key(α1), falls die entsprechenden
Adressen existieren.
Operationen:
• search(k, T ) sucht nach der Adresse, an der der Schlüssel k vorliegt, wenn k nicht vorhanden, so wird α!
geliefert, wobei 
α die Adresse ist, an der k einzufügen wäre:
ε
falls key(ε) = k



!
falls T = (∅, ∅)
search(k, T ) =
0 search(k, T /0) falls k < key(ε)



1 search(k, T /1) falls k > key(ε)
52
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
• insert(k, T ) fügt den Schlüssel k in den Suchbaum ein, falls er noch nicht vorhanden ist, d.h., falls
search(k, T ) = α!, so
insert(k, T ) = (B ∪ {α}, keyhα : ki).
• delete(k, T ) löscht den Schlüssel k aus dem Suchbaum, falls er enthalten ist, d.h. α = search(k, T ) ∈ B:
α ist ein Blatt ⇒
delete(k, T ) = (B − {α}, key − {(α, key(α))})
α hat nur den einen Sohn αi ⇒
delete(k, T )/α = T /αi, sonst bleibt T unverändert.
α hat zwei Söhne, γ = α0β ist die Adresse mit key(α0β) = max{key(α0δ) | δ ∈ B/α0} ⇒
delete(k, T ) = delete(k, (B, keyhα : key(γ)ihγ : ki))
Forderung: Der Suchaufwand soll minimal sein, höchstens von O(log|B|).
Optimal sind gewichtsbalancierte Bäume, d.h. ∀α ∈ B : ||B/α0| − |B/α1|| ≤ 1.
Problem: Schwierige Korrekturen bei insert und delete!
Suboptimal (höschstens 45% höher als gewichtsbalancierte) sind höhenbalancierte Bäume (AVL-Bäume genannt), d.h. ∀α ∈ B : |height(B/α0) − height(B/α1)| ≤ 1.
Korrekturen: corr(T ) = T 0 = (B 0 , key 0 ), i ∈ {0, 1}, i = 1 − i
1. Einsetzen: α sei die längste Adresse, so daß an α die Balance verletzt wird.
1.1. Einsetzen an αiiβ ⇒ αi-Rotation:
key 0 (α) = key(αi), key 0 (αi) = key(α)
T 0 /αi = T /αii, T 0 /αii = T /αii, T 0 /αii = T /αi
1.2. Einsetzen an αiiβ ⇒ αi-Doppelrotation:
key 0 (α) = key(αii), key 0 (αi) = key(αi), key 0 (αi) = key(α)
T 0 /αii = T /αii, T 0 /αii = T /αiii, T 0 /αii = T /αiii, T 0 /αii = T /αi
2. Löschen an αiβ, wobei α die längste Adresse ist, an der die Balance verletzt wird:
2.1. height(B/αii) ≤ height(B/αii) ⇒ αi-Rotation.
2.2. height(B/αii) > height(B/αii) ⇒ αi-Doppelrotation.
2.3. Falls height(B 0 /α) < height(B/α) dann Korrektur wiederholen.
a
H
HH
H
bP
C
P
P
C
C
C
C
C
C
C
C
C
C
C
C
C
C
C
C C
@
@
b
H
HH
H
a
C
PP
P
C
C
C
C
C
C
C
C
C
C
C
C
C
C
C
C C
B/α00 B/α01
B/α1
Schema für α0-Rotation: a = key(α), b = key(α0)
3.6. ABSCHLIESSENDE FALLSTUDIEN
53
a
Z
Z
Z
Z
b
Z
aa
"
Z
"
aa
"
D
c
"
HH
D
D
H
D
D
C
C
D
C
C
D
D
C
C
D
D
C
C
D
D
C
C
D
C
C
D @
@
c
HH
HH
HH
b
a
HH
HH
H
H
C
C
D
D
C
C
D
D
C
C
D
D
C
C
D
D
C
C
D
D
C
C
D
D
D
D
B/α00
B/α010
B/α011
B/α1
Schema für α0-Doppelrotation: a = key(α), b = key(α0), c = key(α01)
Implementierung:
Als Datenstruktur wird wieder ein binärer Baum konstruiert. Da beim Ausgleich die Höhen der Unterbäume
ein wesentliches Unterscheidungskriterium darstellen, wird zur Effektivierung in jedem T-Knoten zusätzlich die
Information zu seiner Höhe abgespeichert. Diese Informationen müssen beim Einsetzen, Löschen und Balancieren
jeweils neu eingestellt werden:
data AVL = E | T Key Height AVL AVL
type Key = Int
type Height = Int
--binary tree
-- Hilfsfunktionen
height::AVL->Int -- selektiert Höhe des Baumes
height E = 0
height (T _ h _ _) = h
seth::AVL->AVL->Height -- berechnet Höhe aus Unterbäumen
seth b0 b1 = 1 + max (height b0) (height b1)
Zum Balancieren werden zwei Korrekturfunktionen benutzt, die an der Adresse α die eventuell notwendige
Rotation bzw. Doppelrotation aktivieren. Die Programmierung ist wegen der Symmetrieeigenschaften recht
einfach. Der Code könnte unter Nutzung der Symmetrie sogar noch verkürzt werden (wie?).
corr0::AVL -> AVL
-- Korrigiert Balance, falls linker Unterbaum eventuell größere Höhe
corr0 bb
| (height b0)<=(1+height b1) = bb -- keine Korrektur erforderlich
| (height b01)<=(height b00) = rot0 bb
| otherwise = drot0 bb
where
(T _ _ b0 b1) = bb
(T a _ (T b _ b00 b01) _) = bb
corr1::AVL -> AVL
-- Korrigiert Balance, falls rechter Unterbaum eventuell größere Höhe
-- symmetrisch zu corr0
rot0::AVL -> AVL -- 0-Rotation
rot0 (T a _ (T b h0 b00 b01) b1) = T b (1+hxy) b00 (T a hxy b01 b1)
where hxy=1+height b01
54
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
rot1::AVL -> AVL -- 1-Rotation -- symmetrisch zu rot0
rot1 (T a _ b0 (T b h1 b10 b11)) = T b (1+hxy) (T a hxy b0 b10) b11
where hxy=1+height b10
drot0::AVL -> AVL -- 0-Doppelrotation
drot0 (T a _ (T b _ b00 (T c _ b010 b011)) b1)
= T c (1+hxx) (T b hxx b00 b010) (T a hxx b011 b1)
where hxx=1+height b1
drot1::AVL -> AVL -- 1-Doppelrotation - symmetrisch zu drot0
Es verbleibt das Einsetzen und Löschen:, wobei zu beachten ist, daß jeder modifizierter Unterbaum zunächst
einer Korrektur unterworfen wird, bevor er als Ergebnis zurückgegeben wird. Damit erfolgt das Balancieren
immer in bottom-up Richtung!
insert::AVL -> Key -> AVL -- Einsetzen
insert E k = (T k 1 E E)
insert (T k h b0 b1) k’
| k’==k = (T k h b0 b1)
| k’<k
= corr0 (T k (seth b0’ b1) b0’ b1)
| otherwise = corr1 (T k (seth b0 b1’) b0 b1’)
where
b0’ = insert b0 k’
b1’ = insert b1 k’
delete::AVL -> Key -> AVL -- Löschen
delete E k = E
delete (T k h E E) k’
| k==k’ = E
| otherwise = (T k h E E)
delete (T k h b0 E) k’
| k==k’ = b0
| k’<k = (T k (seth b0’ E) b0’ E)
| otherwise = (T k h b0 E)
where b0’= delete b0 k’
delete (T k h E b1) k’
| k==k’ = b1
| k<k’ = (T k (seth E b1’) E b1’)
| otherwise = (T k h E b1)
where b1’= delete b1 k’
delete (T k h b0 b1) k’
| k==k’ = corr1 ( T ml (seth b0’’ b1) b0’’ b1)
| k’<k = corr1 ( T k (seth b0’ b1) b0’ b1)
| otherwise = corr0 (T k (seth b0 b1’) b0 b1’)
where
ml = maxval b0
b0’’= delete b0 ml
b0’ = delete b0 k’
b1’ = delete b1 k’
maxval::AVL->Key
maxval E = error "nicht definiert"
maxval (T k _ _ E) = k
maxval (T _ _ _ b1) = maxval b1
3.6. ABSCHLIESSENDE FALLSTUDIEN
3.6.2.2
55
(min,max)-Bäume
Ein Baum B erfüllt die min-max-Bedingung, falls gilt:
• min, max ∈ IN mit 2 ≤ min und 2 · min − 1 ≤ max,
• jeder Knoten, der nicht Wurzel und nicht Blatt ist, mindestens min und höchstens max Söhne hat,
• die Wurzel mindestens zwei und höchstens max Söhne hat,
• ∀α ∈ B ∀i, j ∈ IN : height(B/αi) = height(B/αj).
Ein (min, max)-Baum (in der Literatur oft (a, b)-Baum genannt) ist ein markierter Baum T = (B, key), für
den B die min-max-Bedingung erfüllt und für den die Markierung key folgendermaßen gewählt ist.
Informationswerte werden nur in den Blättern abgelegt. Jeder Nicht-Blatt-Knoten ist mit einer aufsteigend
sortierten Folge κ ∈ K ∗ von Schlüsseln markiert. Damit gilt: key : B → (K ∗ ∪ V ), wobei V die Menge der
Informationswerte ist und
V
falls α Blatt
key(α) ∈
K ∗ sonst.
Für einen Nicht-Blatt-Knoten αi wird in die Folge key(α) der letzte Schlüssel aus key(αi) aufgenommen.
Für ein Blatt αi ist key(αi) = v ∈ V und key(α)(i) = k ∈ K enthält den Schlüssel k zum Informationswert v.
Falls also B 0 die Menge aller Nicht-Blatt-Knoten und lα die Anzahl der Söhne von α ist, dann gilt:
E1 ∀αi ∈ B − B 0 : (key(α)(i), key(αi)) ist gespeichertes Informationselement,
E2 ∀α ∈ B 0 ∀ I : 0 ≤ i < (lα − 1) ⇒ key(α)(i) < key(α)(i + 1),
E3 ∀αi ∈ B 0 : key(α)(i) = key(αi)(lαi − 1) und
E4 ∀α(i + 1) ∈ B 0 : key(αi) < key(α(i + 1))(0).
Wir sagen: ein Blatt αi ist dem Schlüssel key(α)(i) zugeordnet.
Operationen:
• search(k, T ) sucht nach der Adresse α des Blattes, das dem Schlüssel k zugeordnet ist. Falls kein solches
Blatt existiert, so wird α! geliefert, falls beim Einfügen dem Blatt α der Schlüssel k zugeordnet werden
müßte.
search benutzt eine Hilfsfunktion index : K × K ∗ → IN , definiert durch:
i
falls i = min{j | 0 ≤ j < |κ| und k ≤ κ(j)}
index(k, κ) =
|κ| − 1 falls κ(|κ| − 1) < k

i
falls i = index(k, key(ε)) ist Blatt und k = key(ε)(i)



i!
falls i = index(k, key(ε)) ist Blatt und k < key(ε)(i)
search(k, T ) =
(i
+
1)!
falls i = index(k, key(ε)) ist Blatt und k > key(ε)(i)



i search(k, T /i) falls i = index(k, key(ε)) ist kein Blatt.
• insert(k, T ) fügt das Informationselement (k, v) in T ein.
Falls search(k, T ) = αi, so ist lediglich für den neuen Schlüssel key 0 (αi) = v zu setzen.
Falls search(k, T ) = αi!, so ist k ein noch nicht enthaltener Schlüssel und es ist
– ein neues Blatt αlα aufzunehmen,
– für den neuen Schlüssel zu setzen:
key 0 (α)(i) = k, ∀ i ≤ j < lα : key 0 (α)(j + 1)) = key(α)(j) und
key 0 (αi) = v, ∀ i ≤ j < lα : key 0 (α(j + 1)) = key(αj)
– sowie eine Korrektur der Eigenschaft E3 sowie der min-max-Bedingung herzustellen.
56
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
Eine Korrektur der min-max-Bedingung und der Eigenschaften E1 bis E4 ist erforderlich, wenn nach dem
Einsetzen der Knoten α mehr als max Söhne hat. Dann wird in folgender Reihenfolge verfahren:
– wenn α einen jüngeren Bruder mit weniger als max Söhnen hat, so wird diesem der eigene jüngste
Sohn als dessen ältester Sohn abgetreten, sonst
– wenn α einen älteren Bruder mit weniger als max Söhnen hat, so wird diesem der eigene älteste Sohn
als dessen jüngster Sohn abgetreten, sonst
– wird ein neuer nächstälterer Bruder zu α geschaffen, die Söhne von α werden auf α und dessen neuen
Bruder aufgeteilt und die Korrektur nach oben fortgesetzt.
• delete(a, T ) löscht das Blatt , das dem Schlüssel a zugeordnet ist. Es sei αi die Adresse dieses Blattes und
lα = |key(α)| die Anzahl der Söhne von α = α0 j. Dann ist durchzuführen:
– die Informationen aus jedem Blatt α(k + 1) mit i ≤ k < lα in das Blatt αk übernehmen,
– das Blatt α(lα − 1) streichen,
– den Schlüssel a aus key(α) entfernen.
Eine Korrektur der min-max-Bedingung und der Eigenschaften E1 bis E4 ist erforderlich, wenn nach dem
Löschen der Knoten α weniger als min Söhne hat. Dann wird in folgender Reihenfolge verfahren:
– wenn α einen jüngeren Bruder mit mehr als min Söhnen hat, so wird diesem der älteste Sohn gestohlen
und als eigener jüngster Sohn adoptiert, sonst
– wenn α einen älteren Bruder mit mehr als min Söhnen hat, so wird diesem der jüngste Sohn gestohlen
und als eigener ältester Sohn adoptiert, sonst
– werden von einem Bruder, der genau min Söhne hat, alle Söhne gestohlen und adoptiert sowie der
Bruder gelöscht und anschließend wird die Korrektur nach oben fortgesetzt.
Anmerkungen:
• In allen Knoten α, deren Söhne keine Blätter sind, kann man auf die Speicherung des letzten Elementes von
key(α) verzichten. Dadurch wird Speicherplatz gespart, das Suchen leicht einfacher, Korrekturmaßnahmen
leicht komplizierter.
• Wenn man die den Blättern zugeordneten Schlüssel zusätzlich auch in den Blättern speichert, so kann
man obige Maßnahme auf alle Nicht-Blatt-Knoten ausdehnen.
Implementierung:
Als Datenstruktur wurde gewählt:
type TE = (Key,MNT) -- Baumelemente
type Val = Int -- Informationswerte
type Key = Int -- Schlüssel
type Dat =(Key,Val)
data MNT = B Val | T [TE] -- (bmin,bmax) - Bäume
-- B v repräsentiert ein Blatt mit dem Informationswert v
-- T l repräsentiert eine Liste von Baumelementen,
-- ein Baumelement (k,b) besteht aus
-- einem Schlüssel k und
-- dem entsprechenden Sohn b.
Die Funktion insert nutzt die Funktion ins, die das Einsetzen an einer Adresse αi versucht und gleichzeitig
einen Wahrheitswert zurückgibt, der, falls True, eine Korrektur veranlaßt. Die Eigenschaft E3 wird im top-down
Richtung gleich mit korregiert, falls erforderlich.
3.6. ABSCHLIESSENDE FALLSTUDIEN
insert::Dat -> MNT -> MNT
-- insert (k,v) setzt Wert v für Schlüssel k ein
insert (k,v) ( T []) = T [(k, B v)]
insert d (T lt)
| cc = (T [(al,T ll),(ar,T rr)])
| otherwise = T anfrest
where
(anf,rest,cc) = ins d ([],lt)
anfrest = concat[anf,rest]
((al,_),ll,(ar,_),rr) = half anfrest
ins::Dat -> ([TE],[TE]) -> ([TE],[TE],Bool)
ins (k,v) (anf,(k’,B v’):rest)
| k==k’ = (anf,(k,B v):rest,False)
| k<k’ = (anf,(k,B v):(k’,B v’):rest,(length anf)+(length rest)+2>bmax)
| null rest = (anf,[(k’,B v’),(k,B v)],(length anf)+2>bmax)
| otherwise = ins (k,v) (concat[anf,[(k’,B v’)]],rest)
ins (k,v) (anf,(k’,T t):rest)
| k<=k’ = icorr(anf,(k’,T t’):rest,cc)
| null rest = icorr(anf,[(k,T t’)],cc)
| otherwise = ins (k,v) (concat[anf,[(k’,T t)]],rest)
where
(anf’,rest’,cc) = ins (k,v) ([],t)
t’ = concat[anf’,rest’]
icorr::([TE],[TE],Bool) -> ([TE],[TE],Bool) -- Korrektur nach Einsetzen
icorr (anf,(k,T t):rest,c)
| not c = (anf,(k,T t):rest,c)
| not(null anf) && (length tl)<bmax
= (concat[init anf,[(kf,T (concat[tl,[(kf,tf)]]))],[(k,T (tail t))]],rest,False)
| not(null rest) && (length tr)<bmax
= (anf,(k’, T (init t)):(k’’,T ((last t):tr)):(tail rest),False)
|otherwise = (anf,(al, T ll):(ar, T rr):rest,(length anf)+(length rest)+2>bmax)
where
(kf,tf) = head t
(kl,T tl) = last anf
(k’,_)= last (init t)
(k’’,T tr)= head rest
((al,_),ll,(ar,_),rr) = half t
delete::Key -> MNT ->MNT
-- als Übung überlassen
Einige Hilfsfunktionen werden noch benutzt:
bmin::Int
bmin=2
bmax::Int
bmax=3
-- null::[a]->Bool -- testet ob Liste leer (aus Prelude)
--concat [[a]] -> [a] -- Verkettung einer Liste von Listen (aus Prelude)
57
58
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
half::[a] -> (a,[a],a,[a]) -- Aufteilung der Söhne
half l = halfh ((length l)‘div‘2) l
halfh::Int -> [a] -> (a,[a],a,[a])
halfh 1 (z:l) = (z,[z],last l,l)
halfh (n+1) (z:l) = (ll,(z:l’),lr,r)
where (ll,l’,lr,r) = halfh n l
isl::MNT -> Bool -- testet, ob Blatt
isl ( B _) = True
isl _ = False
3.6.2.3
TRIES
Grundprinzip:
Die Menge der Adressen aller Blätter eines Baumes wird als Schlüsselmenge benutzt, wobei alle Blätter gleiche
Höhe haben sollen.
Problem:
Hoher Speicherbedarf bei kleinen Mengen!
Modifikation:
Falls eine Knotenfolge α, αi1 , αi1 i2 , . . . , αi1 i2 . . . ik auftritt, für die α und jeder Knoten αi1 . . . il mit 0 < l < k
genau einen Sohn hat, so werden alle Knoten αi1 . . . il mit 0 < l ≤ k eliminiert, der Knoten α erhält alle Söhne
von αi1 i2 . . . ik und im Knoten α wird die Zusatzinformation i1 . . . ik abgespeichert. Beim Durchlaufen eines
Pfades muß diese Zusatzinformation bei der Adressenbildung berücksichtigt werden.
Bei statischen Mengen sind weitere (kompliziertere) Komprimierungen möglich! Implementierung: verbleibt als
Aufgabe!
3.6.3
Flüsse und Wege in gerichteten Graphen
3.6.3.1
Flüsse nach Ford/Fulkerson
Gegeben: Gerichteter Graph mit Kantenmarkierung ce : E → IR+ und zwei ausgezeichneten Knoten q ∈ N
(Quelle genannt) und s ∈ N (Senke genannt).
Gesucht: Ein maximaler Fluß ϕ von q zu s, d.h. eine Kantenmarkierung ϕ : E → IR+ mit
• Kapazitätsbeschränkung:
∀e ∈ E : ϕ(e) ≤ ce(e),
• Bilanzgleichung:
X
∀n ∈ (N − {q, s}) :
X
ϕ(m, n) =
(m,n)∈E
ϕ(n, l),
(n,l)∈E
• Maximalbedingung:
v(ϕ) =
X
(q,m)∈E
ϕ(q, m) −
X
(n,q)∈E
ϕ(n, q) =
X
(m,s)∈E
ϕ(m, s) −
X
ϕ(s, n)
(s,n)∈E
ist maximal.
Eine Kantenmarkierung ω : E → IR heißt für einen gegebenen Fluß ϕ ein zunehmender Weg, falls eine Knotenfolge q = w1 , w2 , . . . , wk = s und eine Zahl r ∈ IR+ existiert, für die gilt:
3.6. ABSCHLIESSENDE FALLSTUDIEN
59
• ∀i : 1 ≤ i < k ⇒ (wi , wi+1 ) ∈ E ∪ E −1 ,
• ∀i, j : 1 ≤ i, j ≤ k

 r
−r
• ω(m, n) =

0
⇒ (wi = wj ⇒ i = j),
falls ∃i : m = wi , n = wi+1
falls ∃i : n = wi , m = wi+1
sonst
• ∀e ∈ E : 0 ≤ ϕ(e) + ω(e) ≤ ce(e).
Algorithmus:
• Beginne mit dem Fluß ϕ mit ∀e ∈ E : ϕ(e) = 0.
• while ein zunehmender Weg ω für ϕ existiert do ϕ := ϕ + ω.
((ϕ + ω)(e) := ϕ(e) + ω(e)).
Bemerkung: Der Algorithmus konvergiert zum maximalen Fluß, falls Kanten nur mit rationalen Zahlen markiert
sind.
Implementierung:
Der gerichtete Graph wird als Liste seiner Kanten modelliert, wobei zu jeder Kante e der aktuelle Fluss Fl: ϕ(e)
und die Restkapazität Rk: ce(e) − ϕ(e) angegeben ist. Zu Beginn ist der aktuelle Fluss 0 und die Restkapazität
gleich der Kantenmarkierung ce.
type
type
type
type
type
Knoten = Char
Fl = Int -- aktueller Fluss
Rk = Int -- Restkapazität
Graph =[(Knoten,Knoten,Fl,Rk)]
Weg = ([Knoten],Fl)
Die Berechnung eines zunehmenden Weges beruht auf einem bereits berechneten Fluß und den entsprechenden
Restkapazitäten
zunweg :: Graph -> (Knoten,Knoten) -> Weg
-- zunweg g (q,s) berechnet auf der Basis von g
-- einen zunehmenden Weg von q nach s
zunweg [] (q,s) = ([],0)
zunweg ((a,e,f,c):g) (q,s)
| (a==q && e==s && c>0) = ([q,s],c)
| (a==s && e==q && f>0) = ([q,s],f)
| (a==q && 0<ca) = (q:wa,ca)
| (e==q && 0<ce) = (q:we,ce)
| (q==a || q==e) = zunweg g (q,s)
| notin g q = ([],0)
| otherwise = zunweg (appa (a,e,f,c) g) (q,s)
where
(wa,c’) = zunweg g (e,s)
ca | c<c’ = c | otherwise = c’
(we,c’’) = zunweg g (a,s)
ce | f<c’’ = f | otherwise = c’’
-- Hilfsfunktionen
-- appa::a->[a]->[a]
-- appa a l setzt das Element a hinten an die Liste l an
-- notin::Graph->Knoten->Bool
-- notin g a ist genau dann True, falls der Knoten a nicht in g
60
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
Mit Hilfe der Funktion modgr, die einen Graphen mittels berechneten zunehmenden Weg modifiziert, erhält
man maxfluss, die solange die Berechnung iteriert, wie ein zunehmender Weg gefunden wird.
maxfluss g ae
| w==[] = (g,0) -- kein zunehmender Weg gefunden
| otherwise = (g’,f+f’)
where
(w,f) = zunweg g ae
(g’,f’)=maxfluss (modgr g (w,f)) ae
modgr
modgr
modgr
modgr
:: Graph -> Weg -> Graph -- erhöht Fluss um zunehmenden Weg
g ([],t) = g
g ([a],t) = g
g (a:b:l,t) = modgr (modhgr g (a,b,t)) (b:l,t)
modhgr:: Graph ->(Knoten,Knoten,Int) -> Graph
modhgr [] e = []
modhgr ((a’,e’,f,c):g) (a,e,t)
| (a’==a && e’==e) = (a’,e’,f+t,c-t):g
| (a’==e && e’==a) = (a’,e’,f-t,c+t):g
| otherwise = (a’,e’,f,c):modhgr g (a,e,t)
3.6.3.2
Wege
Gegeben: Gerichteter Graph mit Kantenmarkierung ce : E → IR+ . und zwei ausgezeichneten Knoten q, s ∈ N .
Gesucht: Den kleinsten (bzw. größten) Wert ω(q, s), für den eine Knotenfolge q = w1 , . . . , wk = s mit
∀i : 1 ≤ i < k ⇒ (wi , wi+1 ) ∈ E (ein Weg) existiert wobei
ω(q, s) =
k−1
X
ce(wi , wi+1 ).
i=1
3.6.3.2.1
Ford/Moore hat als Grundidee: Induktion über die Anzahl der Kanten im Weg.
• Hilfsfunktion φ : N × N → IR+ ∪ {>} mit

0
falls m = n

ce(m, n) falls (m, n) ∈ E und m 6= n
φ(m, n) =

>
sonst
• modifizierte Addition: x + > = > + x = >.
Algorithmus:
• Beginne mit ∀n ∈ N − {q} : ω(q, n) := >, ω(q, q) := 0.
• Verbessere so oft wie möglich durch ∀n ∈ N : ω(q, n) := min{ω(q, m) + φ(m, n) | m ∈ N }.
Bemerkungen:
• In einem Verbesserungsschritt kann ein Knoten m einen Beitrag nur dann leisten, wenn
– ω(q, m) < >,
– φ(m, n) < > und
– ω(q, m) im vorangegangenen Schritt verbessert wurde.
Das kann zur Effektivierung genutzt werden.
3.6. ABSCHLIESSENDE FALLSTUDIEN
61
• Der Algorithmus kann zur Bestimmung der längsten Wegstrecke (kritischer Weg) genutzt werden, falls
– > durch ⊥ ersetzt wird,
– für die Addition x + ⊥ = ⊥ + x = x gesetzt wird,
– statt dem Minimum das Maximum benutzt wird,
– der Graph keine Zyklen enthält.
Implementierung:
type Knoten = Char
type Graph = [(Knoten,Knoten,Int)]
type Wege = [([Knoten],Int)]
-- (kf,c) ist billigster Weg mit Kosten c
-- kf ist die inverse Knotenfolge des Weges von q nach head von kf
anf::Graph -> Knoten -> Wege -- alle billigsten Wege der Länge 1
anf [] _ = []
anf ((a,e,c):g) q
| a==q = ([e,a],c): anf g q
| otherwise = anf g q
eextend:: Wege -> (Knoten,Knoten,Int) ->Wege
-- geht mit einer Kante zur Wegelänge l+1 über
eextend [] ka = []
eextend ((a:r,c):w) (a’,e,c’)
| a==a’ = (a:r,c):(e:a:r,c+c’):w
| otherwise = (a:r,c): eextend w (a’,e,c’)
extend:: Wege -> Graph -> Wege
-- geht mit Graph zur Wegelänge l+1 über
extend w [] = w
--extend w (k:g) = concat[(eextend w k),( extend w g)]
extend w (k:g) = eextend ( extend w g ) k --Verbesserung
eclear::([Knoten],Int) -> Wege -> Wege
-- der Weg w ist schon sauber, es wird auf (a,c) gesäubert
eclear k [] = [k]
eclear (a:r,c) ((a’:r’,c’):w)
| a==a’ && c<=c’ = (a:r,c):w
| a==a’ && c’<c = (a:r’,c’):w
| otherwise = (a’:r’,c’):(eclear (a:r,c) w)
clear:: Wege -> Wege -- lässt nur die billigsten Wege drin
clear [] = []
clear (a:w) = eclear a (clear w)
step:: Graph -> Knoten -> Int -> Wege
step g q 0 = anf g q
step g q (n+1) = clear ( extend (step g q n) g )
fm:: Graph -> Knoten -> Wege
fm g q = step g q (length g)
62
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
3.6.3.2.2
Floyd/Warshall
hat als Grundidee: Induktion über die erlaubten Wegpunkte.
• Die Knotenmenge sei nummeriert, bzw.: N = {1, . . . , l}.
• Hilfsfunktion φ wie bei Ford/Moore.
• Hilfsfunktionen ωi : N × N → (IR+ ∪ {>}) für i ∈ {0, 1, . . . , l}.
Algorithmus:
• ω0 := φ,
• ∀i ∈ N ∀m, n ∈ N : ωi (m, n) := min{ωi−1 (m, n) , ωi−1 (m, i) + ωi−1 (i, n)},
• ω := ωl .
Bemerkung: Analog zu Ford/Moore kann der Algorithmus für kritische Wege modifiziert werden.
Implementierung:
type
type
type
type
Knoten = Char
Graph = [(Knoten,Knoten,Int)]
Weg = ([Knoten],Int)
W = Graph -> Knoten -> Knoten -> Knoten -> Weg
fw::Graph -> Knoten -> Knoten -> Weg
fw g q s = ww g (kl g) q s
kl::Graph->[Knoten] -- bestimmt Knotenliste aus Kantenliste
kl [] = []
kl ((a,e,c):g) = adjoin a (adjoin e (kl g))
where
adjoin k [] = [k]
adjoin k (k’:l) | k==k’ = k:l | otherwise = k’:(adjoin k l)
ww::Graph -> [Knoten] -> Knoten -> Knoten -> Weg
ww [] [] m n = ([],0)
ww ((a,e,c):g) [] m n
| a==m && e==n = ([m,n],c)
| otherwise = ww g [] m n
ww g (i:l) m n = minweg (ww g l m n) (ww g l m i) (ww g l i n)
minweg::Weg -> Weg -> Weg -> Weg
minweg w ([],_) _ = w
minweg w _ ([],_) = w
minweg ([],_) (w’,c’) (i:w’’,c’’) = (concat[w’,w’’],c’+c’’)
minweg (w,c) (w’,c’) (i:w’’,c’’)
| c<=c’+c’’ = (w,c)
| otherwise = (concat[w’,w’’],c’+c’’)
3.6.3.2.3
Dijkstra hat als Grundidee: Induktion über die verbrauchte Zeit.
Für eine beliebige Menge U ⊆ N von Knoten sei
• EU = {(m, n) | (m, n) ∈ E ∧ m ∈ U ∧ n ∈
/ U },
• χ : U → IR∗ eine Funktion.
3.6. ABSCHLIESSENDE FALLSTUDIEN
Algorithmus:
• Beginne mit U := {q}, χ(q) := 0,
• Solange EU 6= ∅
– bestimme die Kante (m, n) ∈ EU , für die χ(m) + ce(m, n) = min{χ(k) + ce(k, l) | (k, l) ∈ EU },
– setze U := U ∪ {n} und
– setze χ(n) := χ(m) + ce(m, n).
• Wenn beim Halt s ∈ U , so ist ω(q, s) = χ(s),
sonst existiert kein Weg von q nach s.
Implementierung
type Knoten = Char
type Graph = [(Knoten,Knoten,Int)]
type Weg = ([Knoten],Int)
type Env = Knoten -> Weg
-- Umgebung als Funktion, liefert den kürzesten Weg
-- in der bisher betrachteten Zeit
dij::Graph->Knoten->Knoten->Weg
dij g q s = extend (anfe q) g s
anfe::Knoten -> Env -- liefert Anfangsumgebung zur Zeit 0
anfe q q’ | q==q’ = ([q],0) | otherwise =([],0)
extend:: Env -> Graph -> Env - erweitert Umgebung sooft als möglich
extend e g | t = extend e’ g | otherwise = e
where (e’,t) = eextend e g
eextend::Env->Graph->(Env,Bool) -- erweitert Umgebung um einen Schritt
eextend e g = (e’,t)
where
t = (w/=[])
(w,c)=minout e g
e’ n
| ine e n || not t || n/=head w = e n
| otherwise = (w,c)
minout::Env -> Graph -> Weg
minout e [] = ([],0)
minout e ((m,n,c):g)
| not (ine e m) || ine e n = (w,c’)
| w==[] || c+c’’<c’ = (n:w’,c+c’’)
| otherwise = (w,c’)
where
(w,c’)=minout e g
(w’,c’’)= e m
63
64
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
3.6.4
Gerüste in ungerichteten Graphen
Wir betrachten endliche (N ist endlich) ungerichtete Graphen G = (E, N ) mit einer Kantenmarkierung
ce : E → IR+ .
Ein Kreis in einem ungerichteten Graphen ist eine Knotenfolge n1 n2 . . . nk für die gilt:
• ni = nj ⇔ i = 1 ∧ j = k und
• 1 ≤ i < k ⇒ {ni , ni+1 } ∈ E.
Wir definieren E + induktiv durch:
• E ⊆ E+,
• {{m, n} | m 6= n ∧ ∃p : (m, p) ∈ E + ∧ (p, n) ∈ E + } ⊆ E + .
Eine Menge S ⊆ E heißt ein Gerüst von G = (E, N ), falls
• S + = E + und
• (S, N ) keine Kreise hat.
Ein Gerüst S heißt ein Minimalgerüst von G, falls
3.6.4.1
P
e∈S
ce(e) minimal unter allen Gerüsten von G ist.
Kruskal
Grundidee: Es wird eine Menge von Teilgerüsten, beschrieben durch die zugehörigen Knotenmengen, aufgebaut.
Die Kanten des Graphen werden in der Reihenfolge ihrer Bewertung aufgenommen, sofern sie nicht Knoten
verbinden, die bereits zu einem der entstandenen Teilgerüste gehören.
Für G = (E, N ) sei N eine Zerlegung von N , d.h.
• N ⊆ ℘(N ),
S
• N = N,
• ∅∈
/ N,
• ∀N 0 , N 00 ∈ N : N 0 ∩ N 00 6= ∅ ⇒ N 0 = N 00 .
Für n ∈ N sei [n] ∈ N mit n ∈ [n].
Algorithmus:
• Beginne mit N := {{n} | n ∈ N }, S := ∅.
• Solange E − S + 6= ∅ bestimme aus E − S + die Kante e = {m, n} mit dem minimalen Wert ce(e) und setze
– N := N − {[m], [n]} ∪ {[m] ∪ [n]},
– S := S ∪ {e}.
• Falls E − S + = ∅, so ist S ein Minimalgerüst.
Bemerkungen:
• Wenn die Kantenmenge E aufsteigend nach ihrer Markierung sortiert wird, d.h. E = {e1 , . . . , el } mit
ce(ei ) ≤ ce(ei+1 ) für 1 ≤ i < l, so erhält man im Schritt i: e = {m, n} := ei , falls ei ∈ E − S + , sonst wird
ei übergangen und man geht zu i := i + 1 über.
• Der Hauptaufwand liegt dabei in der Identifikation der Teilmengen [m], [n] und, falls {m, n} ∈ E − S + ,
der Bildung der Vereinigung [m] ∪ [n], bekannt als union-find-Problem.
3.6. ABSCHLIESSENDE FALLSTUDIEN
65
Implementierung: Ein ungerichteter Graph wird wieder als Liste seiner Kanten repräsentiert. Als Knoten werden
natürliche Zahlen benutzt und bei einer Kante {m, n} wird als Tripel (m, n, c) mit m < n und c = ce(m, n)
dargestellt.
type Edge = (Node,Node,Cost)
type Node= Int
type Cost= Int
type Graph = [Edge]
type Geruest = [Edge]
-- type Nodesets hängt von der Implementierung von krus ab!
Bei der Berechnung von kruskal wird das Paar S, N ) bestimmt.
kruskal::Graph->(Graph,Nodesets)
kruskal g = krusk (sort g) (ininodesets g)
krusk::Graph->Nodesets->(Graph,Nodesets)
krusk [] ns = ([],ns)
krusk (e:g) ns
| cc = (e:g’,ns’’)
| otherwise = (g’,ns’)
where
(g’,ns’) = krusk g ns
(ns’’,cc) = krus e ns’
krus::Edge->Nodesets->(Nodesets,Bool)
-- führt Schritt mit Kante aus und
-- falls Kante (a,e) zum Gerüst gehört, wird True geliefert, aber
-- bleibt als Aufgabe
3.6.4.2
Prim
Es sei G = (E, N ) ein zusammenhängender Graph, d.h. E + = {{m, n} | m, n ∈ N ∧ m 6= n} und für B ⊆ N sei
EB = {{m, n} | {m, n} ∈ E ∧ m ∈ B ∧ n ∈ (N − B)}.
Algorithmus: Grundidee: Es wird nur ein Gerüst, repräsentiert durch eine Knotenmenge und beginnend mit
einem beliebigen Knoten, konstruiert. In jedem Schritt wird die Kante mit der geringsten Bewertung hinzugenommen, die aus dem bisher aufgebauten Gerüst hinausführt.
• Beginne mit S := ∅, B := {n} für einen beliebigen Knoten n ∈ N .
• Solange N − B 6= ∅ bestimme aus EB die Kante e = {m, n}, m ∈ B mit dem minimalen Wert ce(e) und
setze B := B ∪ {n}; S := S ∪ {e}.
• Falls N − B = ∅, so ist S ein Minimalgerüst.
Implementierung:
Als Datenstruktur für eine Knotenmenge wird die charakteristische Funktion dieser Menge gewählt!
type
type
type
type
type
type
Edge = (Node,Node,Cost)
Node= Int
Cost= Int
Graph = [Edge]
Geruest = [Edge]
Nodeset=Node->Bool
Die Initialisierung von Nodeset ist naheliegend, auch die Erweiterung der charakteristischen Funktion ist einfach.
66
KAPITEL 3. FUNKTIONALE PROGRAMMIERUNG MIT HASKELL/GOFER
inienv::Nodeset -- Initialisierung
inienv 1 = True
inienv _ = False
extenv::Nodeset->Node->Nodeset -- Erweiterung um einen Knoten
extenv env n n’
| n==n’ = True
| otherwise = env n’
Die Funktion prim liefert als Ergebnis das Paar (S, E − S).
prim::Graph->(Graph,Graph)
prim g = (s,g’) where (_,s,g’)= pri(inienv,[],sort g)
pri::(Nodeset,Graph,Graph)->(Nodeset,Graph,Graph)
-- solange eine Kante aus env hinausführt, wird
-- diese zum Gerüst hinzugenommen und vom Restgraphen
-- entfernt
pri::(Nodeset,Graph,Graph)->(Nodeset,Graph,Graph)
pri (env,s,g)
| cond = (env,s,g)
| otherwise = pri (extenv env e, (a,e,c):s , remove (a,e,c) g)
where ((a,e,c),cond) = mini env g
sort::Graph->Graph -- sortiert die Kanten nach Bewertung
remove::Edge->Graph->Graph --entfernt Kante aus Graph
mini::Nodeset->Graph->(Edge,Bool)
-- bestimmt billigste Kante, die aus env hinausführt
Kapitel 4
Imperative Programmierung mit C++
4.1
Semantische Grundkonzepte
4.1.1
Programmstruktur
Ein Programm ist eine Bearbeitungsvorschrift für Objekte. Eine Bearbeitungsvorschrift enthält
• Vereinbarungen, die Objekte definieren und
• Anweisungen, die Zustandsänderungen veranlassen.
Ein Objekt besteht aus
• einer Identifikation, die die Bezugnahme auf das Objekt erlaubt und
– extern aus einer Bezeichnung besteht sowie
– intern durch eine Adresse (Pointer) repräsentiert wird.
sowie aus
• der Beschreibung, die
– Eigenschaften und
– Wert
des Objektes festlegt.
Die Grundstruktur eines C++ - Programms ist eine Folge von
• Vereinbarungen von Typen,
• Vereinbarungen von Variablen,
• Vereinbarungen von Funktionen und
• Compilerinstruktionen.
Wichtig ist:
• Was genutzt wird muss vorher vereinbart sein.
• Genau eine Funktion muss den Bezeichner main haben.
• Der Quelltext kann auf verschiedene Dateien zerstreut sein, die getrennt übersetzt werden.
67
68
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
• Quelltexte aus anderen Dateien (z.B. *.h - header files) können mittels Compilerinstruktion
#include <Dateiname> bzw.
#include "Dateiname" (aus dem Projektverzeichnis)
eingebunden werden.
Wichtige Headerdateien sind:
alloc.h
Speicherverwaltungsfunktionen
conio.h
Funktionen zum Aufruf der I/O-Routinen von DOS
dir.h
Umgang mit Verzeichnissen und Pfadnamen
direct.h
Umgang mit Verzeichnissen und Pfadnamen
io.h
Strukturen und Definitionen für Ein-/Ausgabe-Routinen
iostream.h Basis-Stream-Routinen
math.h
Mathematischen Funktionen
stdio.h
Typen und Makros für Standard-Ein-/Ausgaben
string.h
Routinen zur String- und Speichermanipulation
• Kommentare existieren als
– Zeilenkommentare, eingeleitet durch // und
– globale Kommentare, eingeschlossen in /* und */.
Beispiel
#include <iostream.h>
// Header-Datei f. I/O-Operationen
int max(int a, int b)
// Kopf der Funktionsvereinbarung
{
if (a>b) return a;
else return b;
// Block als Funktionskörper
}
int min(int, int);
// Prototyp-Vereinbarung
int a,b;
// Variablenvereinbarung
void main()
// Haupt-Funktionsvereinbarung
{ cout << "Zwei Zahlen bitte:";// Eingabe-Aufforderung
cin >> a >> b;
// Eingabe der Werte
cout << "\nMaximum : "
<< max(a,b)
// Ausgabe der Ergebnisse
<< "\nMinimum : "
<< min(a,b);
} /* Hier ist das Ende der Hauptfunktion,
weitere Vereinbarungen können folgen */
int min(int a, int b)
// Implementierung zum Prototyp
{ return (a<b ? a : b ); }
Beziehung zwischen Identifikation und Beschreibung:
Die Identifikation dient der Bezugnahme auf die Objekte im Programm. Wir unterscheiden zwischen
• direkter Bezugnahme durch die Bindung einer Bezeichnung an einen Wert:
@
@ Bezeichnung
Wert
Eine solche Bindung ist statisch, d.h. im Gültigkeitsbereich (später) nicht veränderbar. Man erhält eine
Konstante.
4.1. SEMANTISCHE GRUNDKONZEPTE
69
Die Bindung kann in zwei Arten erfolgen:
– implizit wenn der Wert aus der Bezeichnung eindeutig hervorgeht, man nennt diese Konstruktion ein
Literal, z.B.: ’Bachmann’ TRUE FALSE 3.1416.
– explizit wenn in einer Konstanten V ereinbarung Bezeichnung und Wert explizit angegeben sind.
Konstanten Vereinbarung ::= const Typ Bezeichner Initialisierung ;
Initialisierung ::= = Ausdruck
Beispiel: const unsigned pi = 3.14;
• indirekter Bezugnahme durch die Bindung einer Bezeichnung an eine Adresse (Zeiger). Die Adresse bildet
dann den internen Identifikator und referiert einen Wert:
@
@ Bezeichnung
Z
Z
Adresse
Z
Z
-
Wert
Eine solche Referenz ist dynamisch, man erhält eine Variable, ein variables Objekt.
Variablenvereinbarung ::= [Speicherklasse] Typ Variablenliste ;
Variablenliste ::= Listenelement {, Listenelement}*
Listenelement ::= Bezeichner [Initialisierung]
Speicherklasse ::= auto | static | extern | register
4.1.2
Vereinbarungen und Typen
Vereinbarungen stellen eine Bindung zwischen der Identifikation und der Beschreibung eines Objektes her.
Wichtig: Was nicht vereinbart ist, das kann nicht benutzt werden!
Ein Typ ist die Beschreibung einer Menge von Werten, d.h. die Gesamtheit der Eigenschaften aller Elemente
dieser Menge. Der Typ gibt somit an, welche Operationen mit dem Wert ausführbar sind.
Typvereinbarung ::= typedef Typ Bezeichner Liste ;
Bezeichner Liste ::= Bezeichner {, Bezeichner}*
4.1.2.1
Atomare Typen
haben als Objektwerte unteilbare Einheiten, die immer als Ganzes angesprochen werden müssen.
4.1.2.1.1 Ordinale Typen , die charakterisiert sind durch ihre Abzählbarkeit. Alle Werte eines ordinalen
Types sind total geordnet. Jeder Wert verschieden vom Minimum hat einen eindeutig definierten Vorgänger
und jeder Wert verschieden vom Maximum hat einen eindeutig definierten Nachfolger.
• char mit Literalen ’a’ ’+’ ’\n’
• int mit den Modifikatoren short long unsigned
und den Literalen
– oktal, erste Ziffer 0, z.B.: 034
– dezimal, erste Ziffer ungleich 0, z.B.: 28
– hexadezimal, beginnend mit 0x oder 0X, z.B.: 0x1C
• enum - Aufzählungstyp, informieren Sie sich selbst
70
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
4.1.2.1.2 Gleitkomma-Typen , die rationale Zahlen in Gleitkommadarstellung als Werte besitzen, die
entsprechende Gleitkommaoperationen erlauben.
float double long double mit Literalen 314e-2
4.1.2.1.3
void
4.1.2.2
Leerer Typ - hat keine Werte.
Abgeleitete bzw. strukturierte Typen
haben Werte, die auf der Basis anderer Datentypen aufgebaut und i.a. aus mehreren Werten zusammengesetzt
sind.
4.1.2.2.1 Felder (Arrays, Reihungen) haben Werte, die aus einer Anzahl von Komponenten gleichen Types
zusammengesetzt sind. Für jeden Wert (Index) aus einem ordinalen Typ existiert eine Komponente. Mit Hilfe
der Indizes wird der Zugriff auf einzelne Komponenten möglich.
Typ Bezeichner [ [ Anzahl ] ] [ Initialisierung ];
Typ Bezeichner [Anzahl1 ][Anzahl2 ]. . . [Anzahld ][Initialisierung];
Zugriff durch:
Bezeichner [Ausdruck1 ][Ausdruck2 ]. . . [Ausdruckd ]
Beispiele:
int a1[10];
a1[3]=7;
a1[10]=7;
int a2[3]={3,4,5};
a2[0]=a2[2];
int a3[]={3,4,5};
int a4[2][3]={{1,2,3},{4,5,6}};
a4[1][2]=6;
int a5[2][3]={1,2,3,4,5};
int a6[2][3]={{1,2},{4,5,6}};
char a7[]="Peter";
//
//
//
//
//
//
//
//
//
//
//
//
//
nicht initialisiert
korrekte Zuweisung
falscher Zugriff
vollständig initialisiert
Zuweisung
vollständig initialisiert
vollständig initialisiert
korrekt aber überflüssig
unvollständig initialisiert
a5[1][2]=0
unvollständig initialisiert
a6[0][2]=0
Zeichenkette, 6 (!) Elemente
4.1.2.2.2 Strukturen (Records) haben Werte, die aus einer Anzahl von Komponenten, i.a. verschiedener
Typen, zusammengesetzt sind. Der Zugriff auf die einzelnen Komponenten erfolgt mittels Bezeichner.
struct [ Typ-Bezeichner ]{ Komponente *}[Bezeichner Liste];
Komponente ::= Typ Bezeichner Liste;
Zugriff durch:
Qualifikator.Bezeichner
Beispiele:
struct lager {unsigned char bezeichnung[20];
unsigned int anzahl;
} var1;
// lager ist der Typ!
lager var2;
var2.bezeichnung="intel 8086"; // Zuweisung
var2.anzahl=7;
struct S1 {int a; char b,c[10]};
4.1. SEMANTISCHE GRUNDKONZEPTE
S1 v1;
struct {float a; int b ; S1 s} v2;
v1.a
// die int Komponente
v2.a
// die float Komponente
v2.s.a
// die int Komponenten
v2.b
// die int Komponente
v2.s.b
// die int Komponente
v2.s.c
// die char[10] Komponente
/*oder kurz v2.c*/
4.1.2.2.3 Klassen sind verallgemeinerte Strukturen und ermöglichen die
objektorientierte Programmierung.
4.1.2.2.4
Vereinigungen
(Unions, Variants) haben Werte, die aus verschiedenen Typen sein können.
union [ Typ-Bezeichner ]{ Komponente * }[Bezeichner Liste];
Zugriff durch:
Qualifikator.Bezeichner
Beispiele:
union {short
u.c.l=0x01
u.c.r=0x0f
u.i=256+15
4.1.2.2.5
int i; struct{char r,l;} c;} u;
// linke Hälfte von u.i wird gesetzt
// rechte Hälfte von u.i wird gesetzt
// gleiche Wirkung
Zeiger , der Adressen (Zeiger, Referenzen) als Werte besitzt.
Typ *Bezeichner [ Initialisierung ];
Initialisierung erfolgt mit dem Adreßoperator & oder mit new bzw. malloc
Zugriff erfolgt direkt über Bezeichner oder indirekt über *Bezeichner.
Beispiele:
int* ptr1, var1=10;
// ptr1 ist Zeiger auf int
int *ptr2=NULL,*ptr3=&var1; // NULL ist spezieller Zeiger
// ptr3 hat Adresse von var1
// *ptr3 hat den Wert 10
int *ptr4=ptr2;
// ptr4 hat Wert NULL
int **ptr5=&ptr3;
// ptr5 ist Zeiger auf Zeiger
int *ptr6=new int;
// new allokiert Speicher und
// erzeugt Adresse
4.1.2.2.6
Funktionen
, die Bearbeitungsvorschriften (Blöcke) für Objekte als Werte besitzen.
Die Vereinbarung einer Funktion kann (muß nicht) zweigeteilt sein:
• in einen Prototyp und
• eine Implementierung.
Bei der Vereinbarung des Prototyps wird der Typ der Funktion beschrieben:
Rückgabe Typ Funktions Bezeichner ( [ Parameter Liste ]) ;
Rückgabe Typ ::= Typ
Parameter Liste ::= Parameter , Parameter *
Parameter ::= Typ [Bezeichner] [Initialisierung]
71
72
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Der Rückgabetyp kann ein beliebiger Typ, außer einer Funktion oder einem Zeiger auf eine Funktion, also auch
void sein. Im letzteren Falle wird kein Wert zurück gegeben. Die Parameter Liste kann leer sein oder nur den
Typ void enthalten. Dann werden keine Parameter erwartet.
In der Parameter Liste wird für jeden Parameter der Typ, und optional (ich empfehle weglassen), ein Bezeichner
angegeben, der zugleich die Art der Parametervermittlung festlegt:
• Aufruf über Wert (als Standardmethode): Für den formalen Parameter wird beim Aufruf der Funktion
eine zum Funktionsblock lokale Variable erzeugt. Der Wert des entsprechenden aktuellen Parameters wird
zum Wert dieser Variablen gemacht.
Beispiel:
int f(int, char);
f ist ein Funktionstyp mit dem Rückgabetyp int und zwei Werteparametern, der erste vom Typ int, der
zweite vom Typ char.
• Aufruf über Referenz: Der Typ des Parameters ist eine Referenz. Für den formalen Parameter wird beim
Aufruf der Funktion ein Alias zum entsprechenden aktuellen Parameter erzeugt.
Beispiel:
float g(int &, char *);
Der erste Parameter ist ein Referenzparameter, der zweite ein Werteparameter vom Typ Zeiger auf char.
Zusätzlich zum Typ kann eine Initialisierung von Werteparametern erfolgen. In diesem Falle kann beim Aufruf
auf die Angabe des entsprechenden aktuellen Parameters verzichtet werden. Solche initialisierten Parameter
müssen alle am Ende der Parameter-Typ-Liste stehen.
Beispiel:
int f(int, char=’c’);
Jetzt ist der Aufruf von f auch mit nur einem Parameter möglich, für den zweiten wird dann das Zeichen c als
Wert angenommen.
Zwar sind als Rückgabetypen Funktionen und Zeiger auf Funktionen nicht zugelassen, man kann aber Variable
vom Typ Zeiger auf Funktionen vereinbaren. Das erfolgt durch:
Rückgabetyp (*Bezeichner ) ( [ Parameter Liste ] ) ;
Beispiel:
int (*h)(int, char);
ist die Vereinbarung einer Variablen mit dem Bezeichner h vom Typ Zeiger auf Funktion. Man kann dieser
Variablen einen Zeiger auf eine entsprechende Funktion zuweisen: h= &f;
Da Funktionen immer mit Zeiger auf Funktionen gleichgesetzt werden, ist diese Zuweisung vereinfacht möglich:
h= f;
Wie in jeder Vereinbarung kann man auch initialisieren: int (*h)(int, char) = f;
Im Gegensatz zu Zeigern auf Funktionen ist
Typ * Bezeichner ( [ Parameter-Typ-Liste ] ) ;
ein Funktionstyp, der als Rückgabetyp einen Zeiger auf den Typ hat.
Beispiel:
int * f (int,char);
Die Angabe einer Prototyp-Vereinbarung ist nicht zwingend. Sie dient jedoch der übersichtlichen Gestaltung des
Programms. Prototypen sind aber erforderlich, falls die Funktions-Definition in einer anderen Programmdatei
erfolgt, wenn indirekte Rekursion programmiert werden soll oder für Member-Funktionen von Klassen. Bei der
Funktions-Definition wird die Zuordnung eines Blockes an eine Funktion vorgenommen. Das kann dann an einer
anderen Stelle erfolgen, indem die Vereinbarung des Funktionstyps wiederholt und statt dem Semikolon ein
Block angehangen wird:
Rückgabe Typ Funktions Bezeichner
( [ Parameter
Liste ]) Block
Parameter Liste ::= Parameter , Parameter *
Parameter ::= Typ Bezeichner [Initialisierung]
4.1. SEMANTISCHE GRUNDKONZEPTE
73
Die Angabe einer Parameterbezeichnung ist jetzt zwingend. Ist ein Prototyp vorhanden und enthält dieser auch
Parameterbezeichnungen, so müssen beide in den entsprechenden Positionen übereinstimmen.
Initialisierungen von Parametern im Prototyp dürfen in der Funktions-Definition nicht überschrieben werden,
zusätzliche Initialisierungen können aber gemacht werden.
Jeder Parameter-Bezeichner ist im gesamten Block der Funktion sichtbar, falls er nicht durch eine andere
Vereinbarung verdeckt wird.
Beispiele:
int f(int, int=7);
// Das ist der Prototyp
/* jetzt können andere Vereinbarungen in beliebiger Weise folgen, die auch
bereits auf f bezug nehmen können.
Zum Beispiel kann f hier bereits mit nur einem Parameter aufgerufen
werden, da der zweite Parameter einen Anfangswert (nämlich 7) hat. */
int f(int i,int j){return i+j;} // Das ist die Vereinbarung mit Block
Funktionen sind immer in der gesamten Programmdatei sichtbar. Eine wiederholte Vereinbarung mit dem
gleichen Bezeichner und den gleichen Parametertypen ist nicht erlaubt.
Ein Funktionsbezeichner kann jedoch wiederholt an eine Funktion vergeben werden, falls sich die Parameter
in Anzahl oder Typen unterscheiden. Dann sagt man, daß der Funktionsbezeichner überladen ist. Beim Aufruf
wird aus den aktuellen Parametern abgeleitet, welche Vereinbarung gemeint ist.
Beispiel:
int f(int x){return x+10;}
// ein int-Parameter
int f(int x, int y){return x+y;} // zwei int-Parameter
int f(char x){return x+10;}
// ein char-Parameter
Einige Aufrufe sind:
f(3)
f(2,3)
f(’c’)
4.1.3
Anweisungen
4.1.3.1
Atomare Anweisungen
4.1.3.1.1 Ausdrucks-Anweisungen
Funktionen.
// erste Funktion
// zweite Funktion
// dritte Funktion
dienen dem Aktivieren von Berechnungen mittels Operatoren und
Ausdruck ;
wobei ein Ausdruck aufgebaut ist aus
• Operanden, das sind
– Literale,
– Variablenbezeichner,
– Funktionsaufrufe oder
– Ausdrücke und
• Operatoren.
Funktionsaufrufe haben die Form
Funktions Bezeichner ( [Ausdrucks
Liste] ) Ausdrucks Liste ::= Ausdruck , Ausdruck *
74
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Beim Aufruf einer Funktion wird folgendermaßen verfahren:
1. Jeder Ausdruck in der Ausdrucks Liste wird von links nach rechts ausgewertet, wobei Werte w1 , . . . , wn
(für n Ausdrücke) entstehen.
2. Zu jedem Werteparameter in der Parameter Liste wird eine Variable lokal zum Funktionsblock erzeugt.
Die i-te Variable wird mit dem Wert wi initialisiert.
3. Es wird in den Funktionsblock eingetreten und dieser abgearbeitet.
4. Beim Abarbeiten einer return-Anweisung wird
(a) der zugehörige Ausdruck ausgewertet, wobei ein Wert w entsteht,
(b) alle im Funktionsblock vereinbarten Variablen und Parametervariablen vernichtet und der Funktionsblock verlassen und
(c) zur Aufrufstelle zurückgekehrt, wobei w als Resultat des Aufrufs verwendet wird.
Operatoren gibt es viele, in der folgenden Tabelle auf Seite 83 ist eine Übersicht über die Operatoren von C++
mit ihrer Priorität bei der Abarbeitung und der Reihenfolge der Abarbeitung bei Operatoren gleicher Priorität
(R : von rechts nach links, L : von links nach rechts) angegeben.
Einige Operatoren sollen detaillierter behandelt werden:
• Die Zuweisungsoperatoren dienen dazu, den Wert einer Variablen zu verändern. Im einfachen Fall haben
Zuweisungen die Form:
linke Seite = rechte Seite
Die linke Seite muß als Wert einen Zeiger z ergeben. Die rechte Seite ist ein Ausdruck, der einen beliebigen
Wert haben kann. Die Auswertung beginnt mit der rechten Seite. Dann wird die linke Seite ausgewertet
und der Wert der rechten Seite wird dort im Speicher abgelegt, wo z hin verweist. Der Wert einer Zuweisung
ist gleich dem Wert der rechten Seite. Damit können Zuweisungen geschachtelt werden.
Beispiel: a = b = 4 //Beide Variable erhalten die Zahl vier als Wert.
Der Zuweisungsoperator kann mit einigen anderen Operatoren kombiniert werden (siehe Tabelle auf Seite
83). Die Bedeutung eines Ausdruckes der Form v op= b ist äquivalent zu v = v op b.
• unäre Inkrementierung und Dekrementierung ++,-- sind als Präfix- und Suffix-Operatoren verfügbar. Sie
erhöhen bzw. erniedrigen den Wert des Operanden um 1, wobei der neue Wert im Operanden abgespeichert
wird.
Als Präfix-Operator ist das Ergebnis der neue Wert, als Suffix-Operator der alte Wert.
Beispiel: i=1; a=++i; b=a++; - es hat i und b den Wert 2, a den Wert 3.
• Relationale Operatoren (<, <=, >, >=, ==, !=) haben als Ergebnis einen Wert vom Typ int. Trifft der
entsprechende Vergleich zu, so ist das Ergebnis 1, sonst 0 (Null).
• Logische Operatoren (&&, ||, !) haben als Ergebnis einen Wert vom Typ int. Der Wert von e1 && e2
ist 1, falls die Werte von e1 und e2 beide ungleich 0 sind, sonst 0. Der Wert von e1 || e2 ist 0, falls die
Werte von e1 und e2 beide 0 sind, sonst 1. Der Wert von !e ist 0, falls der Wert von e ungleich 0, sonst 1.
• Der bedingte Operator ?: erlaubt, die Ausführung zweier Ausdrücke von einer Bedingung abhängig zu
machen. In einem Ausdruck der Form e0?e1:e2 wird zunächst e0 ausgewertet. Erhält man einen Wert
ungleich 0, so wird e1 ausgewertet, sonst e2 und jeweils als Wert des bedingten Ausdrucks zurückgegeben.
Beispiel: a==b?0:1 entspricht a!=b
Achtung: a=b?0:1 entspricht a=(b?0:1)!
4.1. SEMANTISCHE GRUNDKONZEPTE
75
• Dynamische Speicherverwaltungs-Operatoren (new, delete) stellen Speicher bereit bzw. geben Speicher
frei. Mittels
new type
wird aus dem heap soviel Speicherplatz ausgefaßt, wie zur Speicherung eines Wertes vom Typ type erforderlich ist. Der Wert dieses Ausdruckes ist ein Zeiger auf diesen Speicherbereich. Falls nicht mehr ausreichend
Speicher vorhanden ist, so wird der Zeiger Null als Wert erhalten.
Mittels delete wird Speicher dynamisch wieder freigegeben. Der Operand ist ein Zeiger auf einen
Speicherbereich, der zuvor mittels new oder malloc angefordert wurde.
Wurde mittels new Speicher für ein array bereitgestellt, so sind beim Freigeben hinter delete die Klammern [] anzufügen.
Beispiele:
int *i
int *k
...
delete
delete
= new int; //Zeiger auf ganze Zahl, mit 4 bytes Speicher initialisiert
= new [10] int; //Zeiger auf ganze Zahl, mit 40 bytes Speicher initialisiert
i;
// 4 bytes Speicher freigegeben
[] k; // 40 bytes Speicher freigegeben
• Der Kommaoperator hat zwei Operanden und liefert als Wert den Wert des zweiten Operanden. Der erste
Operand hat nur durch Seiteneffekte Sinn, zum Beispiel über die Nutzung des Zuweisungsoperators.
Beispiel: i=1, ++i; //jetzt hat i den Wert 2
• Typumwandlungen werden in vielen Fällen implizit durchgeführt, wenn die Operanden eines Operators
verschiedene Typen haben. Die genauen Regeln sollte man sich aus dem benutztem System abfragen.
Daneben kann durch den Programmierer eine explizite Typumwandlung veranlaßt werden. Das erfolgt
durch
(Typ-Bezeichner) Ausdruck
Der Wert von Ausdruck wird in den durch den Typ-Bezeichner beschriebenen Typ umgewandelt. Auch
hier sind die Einzelheiten aus dem benutzten System zu entnehmen.
Bemerkungen:
• Viele der Operatoren haben eine vom Typ der Argumente abhängige Wirkung, sie sind überladen. In der
Tabelle ist darauf nicht eingegangen.
• Ein Überladen der Operatoren ist, außer für . .* :: ?: auch durch den Nutzer möglich. Dabei wird die
Priorität und Abarbeitungsreihenfolge beibehalten, die Typen der Operanden und des Resultats sowie die
Funktion des Operators kann verändert werden. Auf die Einzelheiten wird nicht weiter eingegangen.
• Die Veränderungen der Priorität der Operatoren ist wie gewöhnlich durch explizite Klammerung möglich.
4.1.3.1.2
Leere Anweisung hat keine Wirkung
;
4.1.3.1.3
SprungAnweisungen
bewirken eine Unterbrechung der normalen Abarbeitungsreihenfolge.
break;
continue;
return [Ausdruck] ;
• Die break-Anweisung darf nur in einer switch-Anweisung oder einer Iterationsanweisung auftreten und
bewirkt, daß die Programmsteuerung die innerste switch-Anweisung bzw. Iterationsanweisung, in der die
break-Anweisung auftritt, verläßt.
76
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Beispiel:
switch(i)
{ case 1: i=7; break;
case 2: i=11; break;
default: i=0;
}
Dies ist äquivalent zu: i= (i==1? 7 : i==2 ? 11 : 0);
• Die continue-Anweisung darf nur in einer Iterationsanweisung auftreten und bewirkt, daß die Programmsteuerung direkt zur Prüfung der Abbruchbedingung übergeht und anschließend die Abarbeitung der
Interationsanweisung wie normal fortsetzt. Das heißt,
– in einer while-Anweisung oder do-Anweisung wird zur Auswertung von Ausdruck übergegangen,
– in einer for-Anweisung wird zunächst Ausdruck2 ausgewertet und dann Ausdruck1.
• Die return-Anweisung darf nur im Block einer Funktion auftreten und bewirkt, daß zum Funktionsaufruf
zurückgekehrt wird. Hat die Funktion den Rückgabetyp void, so wird Ausdruck weggelassen, sonst ist der
Wert von Ausdruck der Rückgabewert.
4.1.3.2
Strukturierte Anweisungen
4.1.3.2.1 Blöcke fassen mehrere Anweisungen als eine Anweisung zusammen und erlauben, Objekte, aber
keine Funktionen lokal zum Block zu vereinbaren.
n
o
{ Anweisung | Vereinbarung * }
Durch die Blockstruktur eines Programmes werden für Objekte und Bezeichner unterschiedliche Bereiche für
deren Gültigkeit und Sichtbarkeit wirksam.
Gültigkeit = Bereich im Programm, in dem das Objekt an seinen Bezeichner gebunden ist, in dem es existiert.
Sichtbarkeit = Textbereich im Programm, in dem eine Variable benutzt werden kann.
Variable ist vereinbart
auf Programmebene
lokal zum Block
auf Programmebene
lokal zum Block
es beginnt
es endet
Gültigkeit
am Programmstart am Programmende
am Block-Eintritt
am Block-Austritt
Sichtbarkeit
an der Vereinbarung am Programmende
an der Vereinbarung
Block-Ende
Ein lokales Objekt verdeckt innerhalb seiner Gültigkeit alle außerhalb vereinbarten Objekte gleicher Bezeichnung.
Die Sichtbarkeit der verdeckten Objekte geht verloren, sie hat dort ein Loch, die Gültigkeit bleibt aber erhalten.
4.1. SEMANTISCHE GRUNDKONZEPTE
{ /*B0*/ int a, b;
a=0; b=0;
{ /*/B1*/ int a1; unsigned b;
a1=1; b=1;
{ /*B11*/ int a11; float b;
a11=11; b=3.14;
a1=a11+a;
} /*E11*/
{ /*B12*/ int a12;
a12=12;
b=((a12+a1)<a);
} /*E12*/
77
a
cout << a1;
cout << b;
} /*E1*/
{ /*B2*/ int a2;
a2=2;
b=b+a2*4+a;
} /*E2*/
b
p
p
p
p
p
p
p
p
p
p
p
p
p
p
p
p
p
a1
b
p
p
p
p
p
a11
b
a12
a2
cout << a;
cout << b;
} /*E0*/
4.1.3.2.2 Bedingte Anweisungen machen die Ausführung der Anweisung oder Teilen der Anweisung von
Bedingungen abhängig.
if-Anweisung:
if (Ausdruck) Anweisung1 [else Anweisung2]
Zuerst wird der Ausdruck ausgewertet. Ist der Wert ungleich 0 (Null), so wird Anweisung1 ausgeführt, sonst
Anweisung2. Fehlt else Anweisung2, so findet keine Aktion statt.
Achtung: Die Auswertung von Ausdruck kann bereits Nebeneffekte haben.
Beispiel:
if (a=b*a) --a; else ++a;
/* a ergibt sich aus b*a, wenn a ungleich 0, so wird a um 1 erniedrigt,
sonst wird a ums 1 erhöht. */
switch, case, default-Anweisung:
switch ( Ausdruck ) Block
wobei im Block Anweisungen folgendermaßen markiert sein sollten:
case konstanter Ausdruck:
oder
default:
In der switch-Anweisung wird zuerst der Ausdruck ausgewertet, der einen ganzzahligen Wert besitzen muß.
Dann wird der Block, beginnend mit der ersten zutreffend markierten Anweisung abgearbeitet. Ist im Block
keine zutreffend markierte Anweisung enthalten, so wird der gesamte Block übergangen.
Eine markierte Anweisung trifft zu, falls der Wert von konstanter Ausdruck mit dem Wert von Ausdruck aus der
switch-Anweisung übereinstimmt oder ein default Element der Liste ist (dann sind allerdings die folgenden
Elemente in der Liste überflüssig). Mehrere Markierungen mit gleichen Werten sind verboten.
78
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Bemerkungen:
• Es ist zu berücksichtigen, daß nach der ersten zutreffend markierten Anweisung alle nachfolgenden Anweisungen abgearbeitet werden, auch, falls diese nicht zutreffend markiert sind. Soll dies vermieden werden,
so ist eine break-Anweisung einzufügen.
• switch-Anweisungen können beliebig geschachtelt werden. Jeder Fall bezieht sich dann immer auf die
innerste switch-Anweisung.
• Die Einbindung einer case- oder default-Anweisung in if-Anweisungen oder Iterations-Anweisungen
ist zu vermeiden, da die Wirkung dann nicht definiert ist und die verschiedenen Systeme unterschiedlich
reagieren.
Beispiel:
switch(i)
{ i=4; // diese Anweisung ist überflüssig und wird immer übergangen
case 1:++i;
case 2:++i;
case 3:++i;
}
Diese Anweisung ist äquivalent zu
if ((0<j)&&(j<4)) j=4;
4.1.3.2.3
Iterations-Anweisungen dienen der wiederholten Abarbeitung von Anweisungen.
whileAnweisung:
while (Ausdruck) Anweisung
Zunächst wird der Ausdruck ausgewertet. Der Wert muß eine ganze Zahl oder ein Zeiger sein. Wenn der Wert
0 (bzw. NULL) ist, so wird die Anweisung übergangen, sonst wird diese abgearbeitet und die gesamte whileAnweisung wiederholt.
Beispiel:
s=0; while (i>0) s += i--; //bildet die Summe aller positiven i-Werte
do-Anweisung:
do Anweisung while (Ausdruck)
Diese do-Anweisung ist äquivalent zu
Anweisung while (Ausdruck)Anweisung
Beispiel:
f9=1; i=2; do f9*=i; while (++i<10);//Fakultät von 9
for-Anweisung:
for (Initialisierung Ausdruck1; Ausdruck2) Anweisung
Die Initialisierung kann eine Ausdrucks-Anweisung oder eine Vereinbarung sein.
Diese for-Anweisung ist äquivalent zu
Initialisierung while (Ausdruck1){Anweisung Ausdruck2 ;}
Beispiel:
for (int i=0; i<10; ++i) a[i]=i;
4.1. SEMANTISCHE GRUNDKONZEPTE
4.1.4
79
Ein- und Ausgabe
Bei der Ein- und Ausgabe werden Daten von Dateien (engl.: files) gelesen und auf Dateien geschrieben. Eine
Datei ist ein Datenstrom, der sich auf einem Datenträger befindet.
Spezielle Dateien sind
• die Tastatur (stdin), von der nur gelesen werden kann, und
• der Bildschirm (Konsole) (stdout), auf den nur geschrieben werden kann.
Bei der Arbeit mit Dateien (Files) gibt es eine Fülle unterschiedlicher Konzepte, abhängig vom Niveau der
Bearbeitung, d.h. davon, wie direkt man die im Betriebssystem bereitgestellten Grundfunktionen nutzt.
Für die Arbeit mit Tastatur und Bildschirm stellt C++ die überladenen Operatoren >> und << bereit, deren
linker Operand cin bzw. cout ist. Beim Einlesen von Werten erscheint ein Echo auf dem Bildschirm. Um diese
Operatoren nutzen zu können, ist die Datei iostream.h mittels #include einzubinden.
Beispiele:
cin>>a>>b;
Es werden zwei Werte über die Tastatur eingelesen, der erste wird zum Wert von a, der zweite zum Wert von b.
cout>>3+a>>"Peter";
Es wird der Wert des Ausdrucks 3+a und direkt anschließend das Wort Peter ausgegeben.
Bei der Arbeit mit beliebigen Dateien ist die folgende prinzipielle Vorgehensweise zu beachten:
• Es sind File-Variable vom Typ FILE* zu vereinbaren.
• Die zu bearbeitende Datei ist zu öffnen, wobei
– eine Zuordnung zwischen der File-Variablen und dem Dateinamen hergestellt wird,
– der Datei ein Übertragungsmodus (Lesen, Schreiben, Lesen/Schreiben) zugeordnet wird und
– die Existenz der Datei geprüft wird (eventuell wird eine Datei angelegt).
• Datenübertragungen sind von bzw. zu der Datei auszuführen. Dafür existieren entsprechende Funktionen,
die unterschiedliche Formate (Text, Block) unterstützen, Tests auf Korrektheit der Übertragung und auf
Dateiende sowie Positionierungen in der Datei erlauben.
• Die Datei ist zu schließen.
Wir behandeln in Kurzform die Ein- und Ausgabe von Text- und Binärdateien, wie sie über die StandardEin/Ausgabe mittels stdio.h verfügbar ist.
Dateivereinbarung erfolgt mit
FILE *Bezeichner;
Öffnen: erfolgt mit
fopen(const char * Dateiname, const char * Modus)
Dabei wird
1. Eine Datei geöffnet mit dem angegebenen Modus, die unter Dateiname im aktuellen Verzeichnis gesucht
wird. Falls ein voller Pfadname (ab Laufwerk oder Wurzel) angegeben ist, dann beginnt die Suche dort.
2. Ein Zeiger auf FILE zurückgegeben wird. Falls keine Datei mit dem angegebenen Namen gefunden wird,
wird in Abhängigkeit von Modus verfahren.
Die Bedeutung von Modus ist:
• "r" - öffnet zum Lesen, falls Datei nicht existiert, wird der Zeiger NULL zurückgegeben.
• "w" - öffnet zum Schreiben, falls Datei existiert, dann wird alter Inhalt gelöscht (!!!), sonst wird die Datei
neu angelegt und ein entsprechender Zeiger zurückgegeben.
80
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
• "a" - öffnet zum Schreiben, an existierende Datei wird neuer Inhalt an alten angehangen, sonst neue Datei
angelegt.
• "r+" - öffnet zum Lesen und Schreiben, falls Datei nicht existiert, wird der Zeiger NULL zurückgegeben.
• "w+" - öffnet zum Lesen und Schreiben, falls Datei existiert, alter Inhalt wird zerstört.
• "a+" - öffnet zum Lesen und Schreiben, an existierende Datei wird neuer Inhalt an alten angehangen,
sonst neue Datei angelegt.
Achtung: Um sicher zu sein, ob die Datei existiert oder nicht kann man die Funktion access (über io.h
verfügbar) benutzen:
access(Dateiname,0) gibt 0 zurück falls die Datei existiert, sonst −1
Noch mehr über access, und über hilfreiche Funktionen zur Arbeit mit Ordnern (über direct.h verfügbar)
kann man aus der Dokumentation erfragen.
Schließen: erfolgt mit
fclose( FILE* stream)
wobei stream die File-Variable im Programm ist. Es ist immer angebracht, geöffnete Dateien im Programm vor
dem Programmende explizit zu schließen, damit Reste aus dem E/A-Puffer noch übertragen werden.
Zwei Arten von Dateibehandlungen werden unterschieden:
• Textdateien werden als Folge von Zeichen aufgefaßt. Die Ein- und Ausgabe erfolgt textorientiert. Mittels
Formatangaben wird zwischen interner binärer Darstellung und der externen textuellen Darstellung transformiert. Dazu dienen die Funktionen
fprintf für die Ausgabe und fscanf für die Eingabe.
• Binärdatein werden als Folge von Bytes aufgefaßt. Die Ein- und Ausgabe erfolgt blockorientiert in der
internen binären Darstellung der Werte. Dazu dienen die Funktionen
fwrite für die Ausgabe und fread für die Eingabe.
fprintf hat die Form
int fprintf( FILE * stream, const char * format {, Ausdruck}∗ );
Hier sind
• stream die Bezeichnung für die File-Variable im Programm,
• format eine Zeichenkette, die die Formatierung festlegt und
• am Ende legt die Liste der Ausdrücke die auszugebenden Werte fest.
Die Zeichenkette für die Formatierung besteht aus einer Folge von Textfragmenten und Formatierungsanweisungen.
Die Textfragmente werden exakt so ausgeben, wie sie angegeben sind.
An die Stelle der Formatierungsanweisungen werden die durch die Liste der Ausdrücke bestimmten Werte in
der entsprechenden Reihenfolge und in der angegebenen Formatierung ausgegeben.
Formatierungsanweisungen haben die Form:
%[Breite][.Genauigkeit]Typ Zeichen
wobei die Breite die Minimalzahl der auszugebenden Zeichen angibt und die Genauigkeit in Abhängigkeit vom
Typ Zeichen die Maximalzahl auszugebender Zeichen festlegt.
4.1. SEMANTISCHE GRUNDKONZEPTE
81
Die wichtigsten Typ Zeichen veranlassen folgende Transformationen:
d - ganze Dezimalzahl int (Genauigkeit wird ignoriert).
u - natürliche Dezimalzahl unsigned (Genauigkeit wird ignoriert).
e,f - Gleitpunktdarstellung: [-]d. . . d.d. . . d bzw. [-]d.d. . . de{+/-}dd
wobei d Dezimalziffern sind und die Genauigkeit die Anzahl der Zeichen nach dem Dezimalpunkt angibt.
c - ein Zeichen.
s - eine Zeichenkette.
Es empfielt sich sehr, die genauen Angaben aus der Dokumentation zu entnehmen, falls man zur Dateiarbeit
schreitet.
fscanf hat die Form
int fscanf( FILE * stream, const char * format{,Zeigervariable}∗ );
Hier haben stream und format die analoge Bedeutung wie bei fprintf, die Zeigervariablen geben an, in welche
Variable die eingegebenen Werte zu speichern sind.
fwrite hat die Form
size t fwrite( const void * ptr, size t size, size t n, FILE * stream)
Hier ist
• ptr ein Zeiger auf den zu übertragenden Speicherbereich,
• size die Größe (in Bytes) eines zu übertragenden Blockes,
• n die Anzahl der zu übertragenden Blöcke und
• stream die File-Variable im Programm.
Der Rückgabewert gibt die Anzahl der tatsächlich übertragenen Bytes an und kann zur Überprüfung der korrekten Ausführung benutzt werden.
fread hat die analoge Form mit gleicher Bedeutung der Parameter:
size t fread( const void * ptr, size t size, size t n, FILE * stream)
Beispiel: Schreiben einer Textdatei
#include<stdio.h>
#include<direct.h>
FILE * f;
void main()
{
chdir("\\usr\\Gi");
//Wechsel in neue Verzeichnis
f=fopen("text.dat","w");
//Öffnen der Datei zum Schreiben
fprintf(f,"%u ",10);
//Ausgabe der Anzahl der auszugebenen Zahlen
for (unsigned i=1;i<11;++i) fprintf(f,"%u ",i*3); //Ausgabe von 10 Zahlen
fclose(f);
//Schließen der Datei
}
82
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Beispiel: Lesen einer Textdatei
#include<stdio.h>
#include<direct.h>
#include<io.h>
FILE * f;
unsigned n,w;
void main()
{
chdir("\\usr\\Gi");
if(access("text.dat",0)){printf("Datei nicht vorhanden");return;}
printf("Datei da\n");
f=fopen("text.dat","r");
fscanf(f,"%u",&n); printf("%u\n",n);
for (unsigned i=1;i<=n;++i) { fscanf(f,"%u",&w); printf("%u ",w); }
fclose(f);
}
Beispiel: Schreiben einer Binärdatei
#include<stdio.h>
#include<direct.h>
FILE * f;
unsigned u;
void main()
{
chdir("\\usr\\Gi");
f=fopen("binaer.dat","wb");
for (unsigned i=1;i<11;++i) { u=3*i; fwrite(&u,sizeof(unsigned),1,f); }
fclose(f);
}
Beispiel: Lesen einer Binärdatei
#include<stdio.h>
#include<direct.h>
#include<io.h>
FILE * f;
unsigned u;
void main()
{
chdir("\\usr\\Gi");
if(access("binaer.dat",0)){printf("Datei nicht vorhanden");return;}
printf("Datei da\n");
f=fopen("binaer.dat","rb");
while(fread(&u,sizeof(unsigned),1,f)) printf("%u ",u);
fclose(f);
}
4.1. SEMANTISCHE GRUNDKONZEPTE
Operator
::
::
->
.
[]
()
sizeof
++
-~
!
+
&
*
()
new
delete
delete[]
.*
->*
*
/
%
+
<<
>>
<
<=
>
>=
==
!=
&
^
|
&&
||
?:
=
*=
/=
%=
+=
-=
<<=
>>=
&=
|=
^=
,
Funktion
globaler Bezugsrahmen (unär)
lokaler Bezugsrahmen (binär)
Indirekte Auswahl
Direkte Auswahl
Array-Index
Funktionsaufruf
Typgröße bzw. Objektgröße
Inkrementierung
Dekrementierung
bitweises NOT
logisches NOT
unäres Plus (Vorzeichen)
unäres Minus (Vorzeichen)
Adreßoperator
Dereferenzierung
Typkonvertierung (cast)
Erzeugen
Löschen
Löschen Vektor
direkter Member-Zeiger - Auswahl
indirekter Member-Zeiger - Auswahl
Multiplikation
Division
Modulo
Addition
Subtraktion
Bitverschiebung nach links
Bitverschiebung nach rechts
relationaler Operator: kleiner als
relationaler Operator: kleiner gleich
relationaler Operator: größer als
relationaler Operator: größer gleich
Gleichheit
Ungleichheit
Bitweises AND
Bitweises XOR
Bitweises OR
Logisches AND
Logisches OR
Arithmetisches IF
Zuweisung
Zuweisungsprodukt
Zuweisungsquotient
Zuweisungsmodulo
Zuweisungssumme
Zuweisungsdifferenz
Bitweises Linksschieben und zuweisen
Bitweises Rechtsschieben und zuweisen
Bitweises AND und zuweisen
Bitweises OR und zuweisen
Bitweises XOR und zuweisen
Komma-Operator
83
Priorität
17
17
16
16
16
16
15
15
15
15
15
15
15
15
15
15
15
15
15
14
14
13
13
13
12
12
11
11
10
10
10
10
9
9
8
7
6
5
4
3
2
2
2
2
2
2
2
2
2
2
2
1
Reihenfolge
R
L
L
L
L
L
R
R
R
R
R
R
R
R
R
R
R
R
R
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
L
R
R
R
R
R
R
R
R
R
R
R
L
84
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
4.2
4.2.1
Einführende Programmierbeispiele
Allgemeines
Programmierprinzipien haben
• inhaltliche Aspekte (Effizienz, Speicherbedarf) und
• gestalterische Aspekte (Lesbarkeit, Wartbarkeit, Wiederverwendbarkeit).
Beide Aspekte können sich ergänzen, aber auch widersprechen und hängen ab
• vom Programmierparadigma (funktional, logisch, imperativ),
• von der eingesetzten Programmiersprache,
• von der Größe des zu implementierenden Systems,
• von den Konventionen des Softwarehauses und
• vom Geschmack des Programmierers.
Wir behandeln hier ”Programmieren im Kleinen” (keine Plattenbauten).
Dafür versuche ich für mich folgende inhaltliche Prinzipien einzuhalten:
• Bestimme Was-Wie-Womit laut Abschnitt 2.1.
• Entwerfe die den eingesetzten Algorithmen adequaten Datenstrukturen.
• Konzipiere eine Teststrategie und leite daraus die Reihenfolge der Implementierungsschritte ab.
• Implementiere und teste schrittweise.
• Setze solche Konzepte der Programmiersprache ein, von deren Wirkung und Effizienz Du im gegebenen
Zusammenhang überzeugt bist.
Gestalterisch achte ich auf folgende (leider wenige) Prinzipien:
• Füge Kommentare mindestens an die Vereinbarungen der Objekte an, die inhaltlich bedingt sind.
• Trenne Funktionen in solche, die inhaltlicher Natur (Beitrag zum Lösungsverfahren) und solche, die organisatorischer Natur (Ein- und Ausgabe, Protokolle, Tests,...) sind. Ordne dies Funktionen auf getrennten
Quelldateien an.
• Bevorzuge lokale vor globalen Vereinbarungen.
• Ordne alle lokalen Vereinbarungen an den Beginn eines Blockes an, streue keine Vereinbarungen zwischen
Anweisungen.
• Vermeide gleiche Bezeichner für globale und lokale Objekte.
• Bevorzuge globale Variable statt mit static vereinbarte.
• Vermeide Seiteneffekte bei Funktionen, wenn diese nicht explizite Wirkung haben. Seiteneffekte sollten
kommentiert sein.
• Setze alternativ mögliche Programmkonstruktionen bewußt nach gewissen Kriterien ein, nämlich:
– Rekursion hat den Vorzug gegenüber Iteration, wenn dadurch die Programmbeschreibung kürzer und
übersichtlicher wird.
– Bedingte Ausdrücke haben Vorzug vor if-Anweisungen.
– Versuche statt if-Anweisungen, die mehr als zweimal eingeschachtelt sind, switch-Anweisungen zu
nutzen.
4.2. EINFÜHRENDE PROGRAMMIERBEISPIELE
85
– Nutze for-Anweisungen, falls die Iterationszahl vorgegeben ist, sonst while- bzw. do-Anweisungen.
– Mache die Programmstruktur durch geeignete Einrückungen deutlich.
– Versuche, den Programmtext eines Funktionskörpers auf einer Seite unterzubringen, indem eventuell
Hilfsfunktionen eingeführt werden.
Für mich gilt immer noch: Programmieren ist eine Kunst! Da spielt der individuelle Geschmack zur Gestaltung
eine große Rolle (wie etwa auch in der Architektur).
Besser ist es 5 Stunden nachdenken und ein Programm mit 5 Zeilen produzieren, als nur 5 Minuten nachdenken
und ein Programm von 300 Zeilen entwickeln.
4.2.2
Rekursion versus Iteration
Rekursion ist der direkte oder indirekte Aufruf einer Funktion über sich selbst:
Beispiel: Fakultät rekursiv
unsigned fak(unsigned n)
{
if(!n) return 1; return n*fak(n-1);
}//fak
Beispiel: Fakultät iterativ
unsigned fak(unsigned n)
{
unsigned f=1,i=2;
while(i<=n) f*=i++; return f;
}//fak
Beispiel: Fakultät iterativ mit do-Anweisung
unsigned fak(unsigned n)
{
unsigned f=1,i=1;
do f*=i; while(i++<n); return f;
}//fak
Beispiel: Fakultät iterativ mit for-Anweisung
unsigned fak(unsigned n)
{
for(unsigned f=1,i=2;i<=n;++i) f*=i; return f;
}//fak
Beispiel: Euklidscher Algorithmus.
Problem: Es sind drei Werte zurückzugeben.
Lösung: Der ggt wird über den Rückgabewert zurückgegeben, die Werte für x, y über Parameter vom Zeigertyp.
#include<iostream.h>
unsigned euklid(int m, int n, int *x, int *y)
{
unsigned ggt; int hx;
if(!n) { *x=(m<0?-1:1); *y=0; return m<0?-m:m; }
ggt=euklid(n,m%n,x,y); hx=*x;
*x=*y; *y=hx-(m/n)*(*y); return ggt;
}//euklid
86
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
main()
{ int m,n, //Eingaben
x,y; //Resultate
cout<<"m="; cin>>m; cout<<"n="; cin>>n;
cout<<"ggt="<<euklid(m,n,&x,&y);
cout<<" x="<<x<<" y="<<y;
}//main
Alternative Lösung über Reference-Parameter
#include<iostream.h>
unsigned euklid(int m, int n, int &x, int &y)
{
unsigned ggt; int hx;
if(!n) { x=(m<0?-1:1); y=0; return m<0?-m:m; }
ggt=euklid(n,m%n,x,y); hx=x;
x=y; y=hx-(m/n)*y; return ggt;
}//euklid
main()
{ int m,n, //Eingaben
x,y; //Resultate
cout<<"m="; cin>>m; cout<<"n="; cin>>n;
cout<<"ggt="<<euklid(m,n,x,y); //es werden keine Adressen übergeben,
cout<<" x="<<x<<" y="<<y;
}//main
Alternative Lösung: Rückgabe einer Struktur
#include<iostream.h>
struct Euk
{ unsigned ggt;
int x,y;
};
Euk euklid(int m, int n)
{
Euk euk={m,m<0?-1:1,0}; int hx;
if(n)
{
euk=euklid(n,m%n); hx=euk.x;
euk.x=euk.y; euk.y=hx-(m/n)*euk.y;
}
return euk;
}//euklid
main()
{
int m,n; //Eingaben
Euk euk; //Resultate
cout<<"m="; cin>>m; cout<<"n="; cin>>n;
euk=euklid(m,n);
cout<<"ggt="<<euk.ggt;
cout<<" x="<<euk.x<<" y="<<euk.y;
}//main
4.2. EINFÜHRENDE PROGRAMMIERBEISPIELE
87
Nichtrekursive Lösung: Wie kommt man dazu, bzw. warum ist das korrekt?
#include<iostream.h>
struct Euk
{ unsigned ggt;
int x,y;
};
Euk euklid(int m, int n)
{ int p,r,h,xm=1,xn=0,ym=0,yn=1;
Euk euk;
while(n)
{ r=m%n; p=m/n;
m=n; n=r;
h=xn; xn=xm-p*xn; xm=h;
h=yn; yn=ym-p*yn; ym=h;
}
if(m<0){ m=-m; xm=-xm; ym=-ym;}
euk.ggt=m; euk.x=xm; euk.y=ym;
return euk;
}//euklid
P∞
Beispiel: Berechnung von Summen: π = 14 i=0
(−1)i
2i+1
mit Genauigkeitsforderung als Abbruchkriterium.
#include<stdio.h>
#include<math.h>
double pi(float eps)
{
double sa=0,sn=1,
//Variable für alte und neue Summe
z=1,n=1;
//Zaehler, Nenner
while((fabs(sa-sn))>eps) { sa=sn; z*=-1; n+=2; sn+=z/n; }
return 4*sn;
}//pi
main()
{
float eps;
printf("Genauigkeit= ");
scanf("%f",&eps);
printf("Pi=%10.8f\n",pi(eps));
}//main
4.2.3
Sortieren
Beispiel: mergesort, wobei nichtrekursiv gemischt wird.
void merge( int a[], unsigned la, int b[], unsigned lb, int c[])
{ unsigned lc=la+lb-1;
while (la&&lb)
if (a[la-1]>b[lb-1]) c[lc--]=a[--la];
else
c[lc--]=b[--lb];
while (la) c[lc--]=a[--la];
while (lb) c[lc--]=b[--lb];
}//merge
88
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void msort(int a[], unsigned la, int h[])
{ unsigned hla,hlb;
if (la<=1) return;
hla=la/2;hlb=hla+(la%2);
msort(a,hla,h); msort(&a[hla],hlb,h);
merge(a,hla,&a[hla],hlb,h);
while(la--)a[la]=h[la]; // Umspeichern auf Originalfeld a
}//msort
Aufruf durch:
#include<stdio.h>
FILE *input,*output;
extern void msort(int[],unsigned,int[]);
void main()
{ int a[100],b[100];
unsigned l,i;
if(!(input=fopen("input.dat","r")))
{ printf("Datei %s nicht vorhanden"); return; }
output=fopen("output.dat","w");
fscanf(input,"%u",&l); for(i=0;i<l;++i) fscanf(input,"%d",&a[i]);
msort(a,l,b);
for(i=0;i<l;++i) fprintf(output,"%d ",b[i]);
}//main
Problem: Programm erlaubt nur Felder mit Maximallänge 100
Lösung: dynamische Speicherallokation mittels malloc
#include<stdio.h>
#include<malloc.h>
FILE *input,*output;
extern void msort(int[],unsigned,int[]);
void main()
{ int *a,*b; unsigned l,i;
if(!(input=fopen("input.dat","r")))
{ printf("Datei %s nicht vorhanden"); return; }
output=fopen("output.dat","w");
fscanf(input,"%u",&l);
a=(int*)(malloc(l*sizeof(int)));b=(int*)malloc(l*sizeof(int));
for(i=0;i<l;++i) fscanf(input,"%d",&a[i]);
msort(a,l,b);
for(i=0;i<l;++i) fprintf(output,"%d ",b[i]);
}//main
Problem: msort verlangt ein Umspeichern!
Lösung: Aufgabe!
4.2. EINFÜHRENDE PROGRAMMIERBEISPIELE
89
Beispiel: Quicksort, wobei das Sortieren am Ort geschieht.
void exchange(int a[], unsigned i, unsigned j)//tauscht Werte mit Indizes i und j
{ int h=a[i]; a[i]=a[j]; a[j]= h; }//exchange
unsigned med(int a[])//bestimmt den Index des mittleren Wertes
{ return a[0]<a[1]?(a[1]<a[2]?1:(a[0]<a[2]?2:0)):(a[0]<a[2]?0:(a[1]<a[2]?2:1)); }
//med
void qsort(int a[], unsigned l)
{ unsigned lp=1,rp=l-1;
switch(l)
{ case 0 : case 1: return;
case 2 : if(a[0]>a[1]) exchange(a,0,1); return;
case 3 : exchange(a,1,med(a)); if(a[0]>a[2])exchange(a,0,2); return;
default: exchange(a,0,med(a));
while(lp<rp)
{ while((a[lp]<=a[0])&&(lp<l))++lp;
while((a[rp]>a[0]) &&(rp>1))--rp;
if(lp<rp) exchange(a,lp,rp);
}
exchange(a,0,--lp);
qsort(a,lp); qsort(&a[lp+1],l-lp-1);
return;
}
}//qsort
Beispiel: bubblesort bleibt als Aufgabe!
Beispiel: heapsort hat jetzt als Datenstruktur ein Feld.
Die Folge σ ∈ An kann als höhenbalancierter Baum interpretiert werden, indem σ(num(α)) = key(α). Für den
Algorithmus ergibt sich damit:
Zu 1.:
1.1 Entfällt!
1.2 Entfällt!
1.3 for i := n downto 1 do heapif y(i, n), wobei
heapif y(i, j) = if 2i ≤ j und ai > a2i+1−k ≥ a2i+k für k ∈ {0, 1}
then ai ↔ a2i+k ; heapif y(2i + k, j) fi.
Zu 2.:
heapsort(n) = for i := n downto 2 do a1 ↔ ai ; heapif y(1, i − 1) od.
void exchange(int a[], unsigned i, unsigned j)//tauscht Werte mit Indizes i und j
{ int h=a[i]; a[i]=a[j]; a[j]= h; }
//exchange
void heapify(int a[], unsigned i, unsigned j)
{
if(j<2*i+1) return;
if(j==2*i+1){ if(a[2*i+1]>a[i])exchange(a,2*i+1,i);return; }
if(a[2*i+1]>a[i]&&a[2*i+1]>=a[2*i+2])
{ exchange(a,2*i+1,i); heapify(a,2*i+1,j); return; }
if(a[2*i+2]>a[i]&&a[2*i+2]>=a[2*i+1])
{ exchange(a,2*i+2,i); heapify(a,2*i+2,j); return; }
}//heapify
90
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void heapsort(int a[], unsigned l)
{
for(int i=--l; i>=0; --i) heapify(a,i,l);
for(i=l; i>0; --i) { exchange(a,0,i); heapify(a,0,i-1); }
}//heapsort
4.3
Der Weg zur objektorientierten Programmierung
Objektorientierte Programmierung heißt:
• Dynamische Objekte +
• Kapselung +
• Vererbung +
• Polymorphismus.
4.3.1
Dynamische Objekte
Grundprinzip:
• Das Erzeugen und Vernichten von Objekten wird mittels spezieller Operatoren (new, delete) explizit
veranlaßt.
• Es existiert nur eine interne Identifikation (Zeiger, Adresse), die in einer Variablen vom Typ Zeiger gespeichert ist.
@
@ Bezeichnung
Z
Z
Adresse
Z
Z
-
Z
Z
Adresse
Z
Z
-
Wert
Variable vom Typ Zeiger
dynamisches Objekt
Bei den nachfolgenden Fallstudien werden verschiedene Dateien geführt, auf die das gesamte Programm aufgeteilt ist. Grundprinzip ist:
• Die Vereinbarung von Datenstrukturen und Prototypen erfolgt in .h, d.h. Header-Dateien.
• Implementierungen werden in .cpp, d.h. Quellcode-Dateien abgelegt.
Fallstudie ”Dynamische Listen”
In eine Liste unbeschränkter Länge werden Elemente, die (in der hier einfachen Form) nur einen Schlüssel (key)
enthalten, gespeichert.
Variante 1: Elemente können an den Anfang und an das Ende der Liste angefügt werden (add first, add last),
aber nur vom Anfang gelöscht werden (del first). Elemente mit gleichem Schlüssel werden mehrfach aufgenommen. Es existiert eine Abfrage, ob ein Element mit einem gewissen Schlüssel in der Liste enthalten ist
(is in). Die Liste kann insgesamt auf dem Bildschirm ausgegeben werden (print).
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
Datenstruktur und Prototypen: in Datei Listen.h
struct T_elem
{
unsigned key;
T_elem * next;
};
//Prototypen
void add_first (unsigned);
void add_last (unsigned);
void del_first ();
T_elem* is_in (unsigned);
void print();
Implementierungen: in Datei Listen.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Listen.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
//Zeiger auf Beginn und Ende der Liste:
T_elem * p_list_start=NULL, *p_list_stop=NULL;
void add_first (unsigned k)
{
T_elem * h_p_list=p_list_start;
p_list_start=new T_elem;
p_list_start->key=k;
p_list_start->next=h_p_list;
if(!p_list_stop) p_list_stop=p_list_start;
}//add_first
void del_first ()
{
T_elem * h_p_list=p_list_start;
if(!p_list_start) return;
p_list_start=p_list_start->next;
if(!p_list_start) p_list_stop=NULL;
delete h_p_list;
}//del_first
T_elem* is_in_after(unsigned k,T_elem* p)
{
if(!p->next) return NULL;
p=p->next;
if(p->key==k) return p;
return is_in_after(k,p);
}//is_in_after
T_elem* is_in(unsigned k)
{
if(!p_list_start) return NULL;
if(p_list_start->key==k) return p_list_start;
return is_in_after(k,p_list_start);
}//is_in
91
92
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void add_last (unsigned k)
{
T_elem * h_p_list=p_list_stop;
p_list_stop=new T_elem;
p_list_stop->key=k;
p_list_stop->next=NULL;
if(!h_p_list) { p_list_start=p_list_stop; return; }
h_p_list->next=p_list_stop;
}//add_last
void print_from(T_elem * p)
{
if(!p) { printf(" !\n"); return; }
printf(" %2u",p->key);
print_from(p->next);
}//print_from
void print()
{
print_from(p_list_start); }
Hauptteil: als Beispiel in Datei main.cpp
#include"Listen.h"
#include<stdio.h>
main()
{
add_first(3);add_first(7);add_last(2);del_first();
print();
}//main
Variante 2: Es können auch Elemente hinten gelöscht werden. Die Liste ist jetzt doppelt verkettet.
Datenstruktur und Prototypen: in Datei Listen.h
struct T_elem
{
unsigned key;
T_elem * prev, * next;
};
//Prototypen
void add_first (unsigned);
void add_last (unsigned);
void del_first ();
void del_last ();
void print();
Implementierungen: in Datei Listen.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Listen.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
//Zeiger auf Beginn und Ende der Liste:
T_elem *p_list_start=NULL,*p_list_stop=NULL;
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
void add_first (unsigned k)
{
if(!p_list_start)
{
p_list_start=p_list_stop=new T_elem;
p_list_start->key=k;
p_list_start->prev=p_list_start->next=NULL;
return;
}
p_list_start->prev=new T_elem;
p_list_start->prev->next=p_list_start;
p_list_start=p_list_start->prev;
p_list_start->key=k;
p_list_start->prev=NULL;
}//add_first
void del_first ()
{
if(!p_list_start) return;
if(!p_list_start->next)
{
delete p_list_start;
p_list_start=p_list_stop=NULL;
return;
}
p_list_start=p_list_start->next;
delete p_list_start->prev;
p_list_start->prev=NULL;
}//del_first
void add_last (unsigned k)
{
if(!p_list_start) { add_first(k); return; }
p_list_stop->next=new T_elem;
p_list_stop->next->prev=p_list_stop;
p_list_stop=p_list_stop->next;
p_list_stop->key=k;
p_list_stop->next=NULL;
}//add_last
void del_last ()
{
if(!p_list_start) return;
if(!p_list_start->next)
{
delete p_list_start;
p_list_start=p_list_stop=NULL;
return;
}
p_list_stop=p_list_stop->prev;
delete p_list_stop->next;
p_list_stop->next=NULL;
}//del_last
93
94
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void print_from(T_elem * p)
{
if(!p) { printf(" !\n"); return; }
printf(" %2u",p->key);
print_from(p->next);
}//print_from
void print()
{
print_from(p_list_start); }
Variante 3: Alle Elemente werden nach ihrem Schlüssel aufsteigend sortiert eingefügt. Jeder Schlüssel erscheint
höchstens einmal in der Liste. Der Beginn der Liste ist jetzt als dummy-Element angelegt.
Datenstruktur und Prototypen: in Datei Listen.h
struct T_elem
{
unsigned key;
T_elem* next;
};
//Prototypen
T_elem* is_in (unsigned, T_elem *);
void add (unsigned);
void del (unsigned);
void print();
Implementierungen:in Datei Listen.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Listen.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
//Dummy-Element:
T_elem list_start={0,NULL};
T_elem* is_in(unsigned k, T_elem * p)
{
if(!p->next) return p;
if(k<=p->next->key) return p;
return is_in(k,p->next);
}//is_in
void add(unsigned k)
{ T_elem* p=is_in(k,&list_start),*pp;
if(p->next&&k==p->next->key) return;
pp=new T_elem; pp->key=k; pp->next=p->next;
p->next=pp;
}//add
void del(unsigned k)
{ T_elem* p=is_in(k,&list_start),*pp;
if(!p->next||k!=p->next->key) return;
pp=p->next; p->next=p->next->next;
delete pp;
}//del
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
95
void print_from(T_elem * p)
{
if(!p) { printf(" !\n"); return; }
printf(" %2u",p->key);
print_from(p->next);
}//print_from
void print()
{
print_from(list_start.next); }
4.3.2
Kapselung
Grundprinzip:
• Das Programm wird in ”Moduln” aufgeteilt.
• Die Interaktion jedes Moduls mit der Umgebung wird über die Schnittstelle beschrieben:
– Exportiert wird, was der Modul nach außen zur Verfügung stellt,
– Importiert wird, was der Modul von außen benutzt.
• Verstecken von Informationen (information hiding), insbesondere von Details der Implementierung schränkt
die Auswirkung von Änderungen ein, falls die Schnittstelle nicht verändert wird.
Bei der objektorientierten Programmierung, insbesondere C++:
• Das Programm wird auf verschiedene Dateien, die getrennt übersetzt werden können, aufgeteilt.
• Die Bezugnahmen zwischen den Dateien wird über extern im Text hergestellt und durch den Linker
aufgelöst.
• Eine Klasse ist eine Struktur, in der
– private (private) Komponenten zur Nutzung nach außen gesperrt sind,
– öffentliche (public) Komponenten die Schnittstelle darstellen und zur Nutzung von außen freigegeben
sind,
– Methoden, das sind Funktionen, als Komponenten der Klasse existieren können, die den Zugriff auf
und die Arbeit mit allen Komponenten ermöglichen.
– Konstruktoren und Destruktoren sind spezielle Methoden, die automatisch unmittelbar nach dem
Erzeugen bzw. unmittelbar vor dem Vernichten von Objekten einer Klasse aufgerufen werden. Sie
können genutzt werden, um einen definierten Anfangszustand herzustellen bzw. notwendige Aufräumaktionen (z.B. Vernichten abhängiger Objekte) durchzuführen.
Fallstudie ”Dynamische Listen”, Variante 4: Wie Variante 3, aber mitKlassen und Methoden. Außerdem
ist es möglich, die Liste komplett zu löschen (del all).
Klasse und Prototypen: in Datei Listen.h
class Elem
{
unsigned key;
Elem* next;
public:
Elem (unsigned, Elem*);
Elem* isin_from(unsigned);
void add_here(unsigned);
void del_here(unsigned);
void del_rest();
void print_rest();
96
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
};
//Prototypen
void add(unsigned);
void del(unsigned);
void del_all();
void print();
Implementierungen: in Datei Listen.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Listen.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
//Dummy-Element:
Elem list_start(0,NULL);
Elem::Elem(unsigned k,Elem* p)
{
key=k; next=p; }
Elem* Elem::isin_from(unsigned k)
{
if(!next) return this;
if(k<=next->key) return this;
return next->isin_from(k);
}//isin_from
void Elem::add_here(unsigned k)
{
if(next&&k==next->key) return;
next=new Elem(k,next);
}//add_here
void Elem::del_here(unsigned k)
{
//als Aufgabe
}//del_here
void Elem::del_rest()
{
//als Aufgabe }
void Elem::print_rest()
{
if(next) { printf("%u ",next->key); next->print_rest(); }
else printf("!");
}//print_rest
void add(unsigned k)
{
list_start.isin_from(k)->add_here(k);
}
void del(unsigned k)
{
list_start.isin_from(k)->del_here(k);
}
void del_all()
{
//als Aufgabe }
void print()
{
list_start.print_rest();
}
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
4.3.3
97
Vererbung und Polymorphismus
Grundprinzip:
• Bildung von Klassen-Hierarchien,
• jeder Nachkomme erbt alle Komponenten der Vorfahren,
• neue Komponenten können hinzugefügt werden,
• vererbte Komponenten können redefiniert werden,
• die Vererbung der Sichtbarkeit kann beeinflußt werden:
private
protected
public
Basiskomponente erhält bei obiger Vererbung Zugriffsrecht wie
private
kein Zugriff
private
private
protected kein Zugriff protected
protected
public
kein Zugriff protected
public
• Polymorphie wird in eingeschränktem Maße über das Überladen von Funktionen und Operatoren sowie
durch die Nutzung virtueller Funktionen realisiert.
Variante 5: Über eine Klassenhierarchie werden unterschiedliche Arten von Listenelementen eingeführt.
Basisklasse Elem n mit
• einem Schlüssel key,
• einem Zeiger next auf das nächste Element,
• dem Konstruktor, der den Schlüssel und den Zeiger initialisiert,
• der Methode isin from zum Suchen eines Schlüssels, der Rückgabewert ist ein Zeiger auf das Element,
was auf den Schlüssel zeigt, falls dieser vorhanden oder ein Zeiger auf das Element, hinter das der Schlüssel
eingefügt werden muß,
• der Methode get next next, die den Zeiger auf das übernächste Element zurückgibt, falls dieses vorhanden,
• der Methode is next, die prüft, ob ein Schlüssel zum nächsten Element gehört,
• der Methode get key, die den Schlüssel zurückgibt.
Klasse: in Datei Elem n.h
class Elem_n;
typedef Elem_n * P_Elem_n;
class Elem_n
{protected:
unsigned key;
P_Elem_n next;
public:
Elem_n(unsigned, P_Elem_n);
P_Elem_n isin_from(unsigned);
P_Elem_n get_next();
P_Elem_n get_next_next();
unsigned char is_next(unsigned);
unsigned get_key();
};
98
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Implementierung: in Datei Elem n.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Elem_n.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
Elem_n::Elem_n(unsigned k,P_Elem_n p)
{ key=k; next=p; }
P_Elem_n Elem_n::get_next()
{ return next; }
P_Elem_n Elem_n::get_next_next()
{ return (next)?next->next:NULL;
}
unsigned char Elem_n::is_next(unsigned k)
{
if (!next) return 0;
return (k==next->key)?1:0;
}//is_next
P_Elem_n Elem_n::isin_from(unsigned k)
{
if (!next) return this;
if (k<=next->key) return this;
return next->isin_from(k);
}//isin_from
unsigned Elem_n::get_key()
{ return key; }
Abgeleitete Klasse Elem 1, die zusätzlich über die folgenden Methoden verfügt:
• den eigenen Konstruktor,
• insert next, die ein Element hinter sich einsetzt,
• delete next, die das folgende Element löscht,
• delete rest, die alle folgenden Elemente löscht,
• print, die alle folgenden Elemente ausdruckt.
Klasse: in Datei Elem 1.h
#include"Elem_n.h"
class Elem_1;
typedef Elem_1* P_Elem_1;
class Elem_1 :public Elem_n
{
public:
Elem_1(unsigned, P_Elem_n);
void insert_next(unsigned);
void delete_next();
void delete_rest();
void print();
};
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
99
Implementierung: in Datei Elem 1.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Elem_1.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
Elem_1::Elem_1(unsigned k,P_Elem_n p):Elem_n(k,p)
{}
void Elem_1::insert_next(unsigned k)
{ next=new Elem_1(k,next); }
void Elem_1::delete_next()
{ P_Elem_n p=next; if(p){ next=get_next_next(); delete p; }
void Elem_1::delete_rest()
{ if(next){P_Elem_1(next)->delete_rest(); delete next;
next=NULL;
}//delete_rest
void Elem_1::print()
{
if(!next){printf("!\n");return;}
printf("%4u",next->get_key());
P_Elem_1(next)->print();
}//print
Basisklasse Elem p, die lediglich
• einen Zeiger prev auf das vorangegangene Element besitzt,
• den eigenen Konstruktor hat, der den Zeiger prev initialisiert,
• die Methode set prev hat, die auch den Zeiger prev setzt.
Klasse: in Datei Elem p.h
class Elem_p;
typedef Elem_p* P_Elem_p;
class Elem_p
{
protected:
P_Elem_p prev;
public:
Elem_p(P_Elem_p);
void set_prev(P_Elem_p);
};
Implementierung: in Datei Elem p.cpp
#include <stdlib.h>
#include <stdio.h>
#include "Elem_p.h"
//für NULL
//für Ein- und Ausgabe
//für Vereinbarungen
Elem_p::Elem_p(P_Elem_p p)
{
prev=p; }
void Elem_p::set_prev(P_Elem_p p)
{ prev=p; }
}
}
100
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
abgeleitete Klasse Elem 1p, die zur Verfügung stellt:
• den eigenen Konstruktor,
• die Methode insert next, die die entsprechende Methode von Elem 1 überschreibt,
• die Methode delete next, die die entsprechende Methode von Elem 1 überschreibt und
• die Methode print back, die ab dem eigenen Element alle Elemente der Liste rückwärts ausdruckt.
Klasse: in Datei Elem 1p.h
#include"Elem_1.h"
#include"Elem_p.h"
class Elem_1p;
typedef Elem_1p *P_Elem_1p;
class Elem_1p :public Elem_1, public Elem_p
{
public:
Elem_1p(unsigned,P_Elem_n,P_Elem_p);
void insert_next(unsigned);
void delete_next();
void print_back();
};
Implementierung: in Datei Elem 1p.cpp
#include <stdlib.h> //für NULL
#include <stdio.h>
//für Ein- und Ausgabe
#include "Elem_1p.h" //für Vereinbarungen
Elem_1p::Elem_1p(unsigned k, P_Elem_n p1,P_Elem_p p2)
:Elem_1(k,p1),Elem_p(p2)
{
if(p1) P_Elem_1p(p1)->prev=this;
}
void Elem_1p::insert_next(unsigned k)
{ next=new Elem_1p(k,next,this); }
void Elem_1p::delete_next()
{
P_Elem_1p p=P_Elem_1p(next);
if(p)
{
next=get_next_next();
if(next)P_Elem_p(next)->set_prev(this);
delete p;
}
}//delete_next
void Elem_1p::print_back()
{
if(!prev){ printf("!\n");return;}
printf("%4u",key);
P_Elem_1p(prev)->print_back();
}//print_back
4.3. DER WEG ZUR OBJEKTORIENTIERTEN PROGRAMMIERUNG
Implementierung der Funktionen add, del, del all, print und print back: in Datei Prototypen.h
#include"Elem_1p.h"
void
void
void
void
void
add(unsigned);
del(unsigned);
del_all();
print();
print_back(unsigned);
Implementierung in Datei Impl.cpp
#include <stdlib.h> //für NULL
#include <stdio.h>
//für Ein- und Ausgabe
#include "Prototypen.h" //für Vereinbarungen
Elem_1p start_elem(0,NULL,NULL);
void add (unsigned k)
{
P_Elem_1p p=P_Elem_1p(start_elem.isin_from(k));
if(p->is_next(k)) return;
p->insert_next(k);
}//add
void del (unsigned k)
{
P_Elem_1 p=P_Elem_1(start_elem.isin_from(k));
if(p->is_next(k))p->delete_next();
}//del
void del_all()
{ start_elem.delete_rest();
}
void print()
{ start_elem.print(); }
void print_back(unsigned k)
{
P_Elem_1p p=P_Elem_1p(start_elem.isin_from(k));
if(!p->is_next(k)){ printf("Nicht da!\n"); return; }
p=P_Elem_1p(p->get_next());
p->print_back();
}//print_back
101
102
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
4.4
Abschließende Fallstudien
4.4.1
Speichern und Suchen
4.4.1.1
AVL-Bäume
Das Prinzip der AVL-Bäume wurde bereits im Abschnitt 3.6.2.1 beschrieben.
Die Klasse AVL beschreibt die Struktur eines Knotens und stellt alle benötigten Hilfsfunktionen bereit. Zur
Vereinfachung wurden alle Komponenten öffentlich gemacht. Die Söhne eines Knotens werden über Zeiger (Typ
P AVL) referiert.
Im wesentlichen wird dem Vorgehen der HASKELL-Implementierung gefolgt. Da die Zeiger auf die beiden
Söhne als Elemente eines Feldes angelegt sind, die über die Adresse indiziert werden kann die Korrektur günstig
als eine Funktion implementiert werden, der die Korrekturart (0 oder 1) als Parameter mitgegeben wird. Das
Gleiche gilt für Rotation und Doppelrotation.
Datenstruktur und Prototypen: in Datei AVL.h
class AVL;
typedef AVL* P_AVL;
class AVL
{
public:
int key;
unsigned height;
P_AVL st [2];
//Schlüssel
//Höhe
//Zeiger zu den Söhnen
AVL(int,P_AVL,P_AVL);
//Konstruktor für neuen Knoten
unsigned get_height(P_AVL);
void set_height();
//Korrigiert die Höhe
void ins(int);
//Ensetzen ab this
P_AVL del(int);
//Löschen ab this
P_AVL last();
//liefert Zeiger auf letztes Blatt
void corr(unsigned);
//Korrektur
void rot(unsigned);
//Rotation
void drot(unsigned);
//Doppelrotation
void print();
//Ausdruck ab this
};
Implementierungen in AVL.cpp
#include <stdlib.h>
#include <stdio.h>
#include "AVL.h"
//für NULL
//für Ein- und Ausgabe
AVL::AVL(int k,P_AVL p0,P_AVL p1)
{ key=k; st[0]=p0,st[1]=p1; set_height(); }
unsigned AVL::get_height(P_AVL t)
{
return t?t->height:0; }
void AVL::set_height()
{
//Aufgabe!
}//get_height
4.4. ABSCHLIESSENDE FALLSTUDIEN
void AVL::ins(int k)
{
unsigned lr;
if(k==key) return;
if(st[lr=1-(k<key)]) st[lr]->ins(k);
else st[lr]=new AVL(k,NULL,NULL);
corr(lr);set_height();
}//ins
P_AVL AVL::del(int k)
{
P_AVL p; unsigned lr=(k>key);
if(k==key)
{
if(!st[0]){ p=st[1]; delete this; return p; }
if(!st[1]){ p=st[0]; delete this; return p; }
p=st[0]->last(); key=p->key; p->key=k;
st[0]=st[0]->del(k); corr(1); set_height(); return this;
}
if(!st[lr]) return this;
st[lr]=st[lr]->del(k); corr(1-lr); set_height(); return this;
}//del
P_AVL AVL::last()
{ //Aufgabe!
}
void AVL::corr(unsigned lr)
{
if(get_height(st[lr])<=(1+get_height(st[1-lr]))) return;
if(get_height(st[lr]->st[1-lr])<=get_height(st[lr]->st[lr])) rot(lr);
else drot(lr);
}//corr
void AVL::rot(unsigned x)
{
P_AVL stx=st[x], stxx=stx->st[x], stxy=stx->st[1-x], sty=st[1-x];
unsigned k=key;
key=st[x]->key; stx->key=k;
st[x]=stxx;
st[1-x]=stx; stx->st[x]=stxy; stx->st[1-x]=sty;
stx->set_height();
}//rot
void AVL::drot(unsigned x)
{
P_AVL stx=st[x], stxx=stx->st[x], stxy=stx->st[1-x], stxyx=stxy->st[x],
stxyy=stxy->st[1-x], sty=st[1-x];
unsigned k=key;
key=stxy->key;stxy->key=k;
stx->st[x]=stxx; stx->st[1-x]=stxyx; stx->set_height();
st[1-x]=stxy; stxy->st[x]=stxyy; stxy->st[1-x]=sty; stxy->set_height();
}//drot
103
104
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
4.4.1.2
(min,max)-Bäume
Das Prinzip der (min,max)-Bäume wurde bereits im Abschnitt 3.6.2.2 beschrieben.
Bei der Implementierung wurde neben der Klasse MNnode, deren Instanzen die Knoten repräsentieren, eine
Klasse MNBaum bereitgestellt, in der Methoden zur Manipulation des gesamten Baumes enthalten sind.
In jeden Knoten werden für die Söhne ein Feld vom Typ Elem der Länge max bereitgestellt, in das jeweils
die maximalen Schlüssel der Söhne und der Wert, falls der Sohn ein Blatt ist, oder der Zeiger auf den Sohn,
eingetragen sind. Die Komponente filled gibt den aktuellen Füllstand dieses Feldes, das heißt die Anzahl der
Söhne an.
Datenstruktur und Prototypen: in Datei minmaxBaum.h
const unsigned amin=2, bmax=3;
class MNnode;
typedef MNnode * P_MNnode;
union Son
{
P_MNnode p_node;
int value;
};
struct Elem
{
unsigned key;
Son son;
};
class MNnode
{
public:
unsigned height;
unsigned filled;
Elem elem[bmax];
MNnode(unsigned, unsigned);
P_MNnode ins(
unsigned,
int,
P_MNnode,
unsigned);
void del(unsigned);
void print();
};
class MNBaum
{
P_MNnode root;
public:
MNBaum(unsigned,unsigned);
void insert(unsigned,int);
void mdelete(unsigned);
void print();
};
//Schlüssel
//Informationswert
//Zeiger auf Vater
//Index auf aktuell betrachteten Sohn
4.4. ABSCHLIESSENDE FALLSTUDIEN
105
Implementierungen in minmaxBaum.cpp
#include<stdio.h>
#include<stdlib.h>
#include"minmaxBaum.h"
MNnode::MNnode(unsigned h, unsigned f)
{ height=h; filled=f; }
P_MNnode MNnode::ins(unsigned k, int v, P_MNnode father, unsigned fi)
{
unsigned i=0,j,jj;
//Laufvariable
P_MNnode p_brother, //Zeiger auf Bruder
p_newnode; //Zeiger auf neuen Knoten
Elem el;
while(i<filled && k>elem[i].key) ++i;
if(height)
{
if(i==filled){ --i; elem[i].key=k; }//Korrektur E3,
//tritt nur bei maximalem Schlüssel auf
if(!(el.son.p_node=elem[i].son.p_node->ins(k,v,this,i))) return NULL;
//keine Korrektur erforderlich
else el.key=el.son.p_node->elem[el.son.p_node->filled-1].key;
}
else
if(k==elem[i].key){ elem[i].son.value=v; return NULL; }//Schlüssel vorhanden
else
{ el.key=k; el.son.value=v; }
//neuer Knoten ist einzusetzen
if(filled<bmax)//einfaches Einsetzen
{
for(j=filled;j>i;--j) elem[j]=elem[j-1];//Feld verschieben
elem[i]=el;
++filled;
return NULL;
}
//Knoten abgeben oder spalten
if(father)
{
if(fi && (p_brother=father->elem[fi-1].son.p_node)->filled<bmax)
//links abgeben
{
if(!i)p_brother->elem[p_brother->filled]=el;//einzufügendes Element abgeben
else //umlagern
{
p_brother->elem[p_brother->filled]=elem[0];
for(j=0;j<i-1;++j) elem[j]=elem[j+1];//Felder verschieben
elem[--i]=el; //Element einsetzen
}
father->elem[fi-1].key=p_brother->elem[p_brother->filled++].key;//Korrektur E3
return NULL;
}
106
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
if(fi+1<father->filled && (p_brother=father->elem[fi+1].son.p_node)->filled<bmax)
//rechts abgeben
{
//Nachbarfeld verschieben
for(j=p_brother->filled;j>0;--j) p_brother->elem[j]=p_brother->elem[j-1];
p_brother->elem[0]=elem[filled-1]; //umlagern
++p_brother->filled;
for(j=filled-1;j>i;--j) elem[j]=elem[j-1];//Feld verschieben
elem[i]=el;
father->elem[fi].key=elem[filled-1].key;//Korrektur E3
return NULL;
}
}
//Knoten spalten
p_newnode=new MNnode(height,(++filled)/2);
filled-=p_newnode->filled;
//im neuen Knoten umlagern und eventuell einsetzen
for(j=jj=0;j<p_newnode->filled;++j)
p_newnode->elem[j]=(j==i)?el:elem[jj++];
//im alten Knoten umlagern und eventuell einsetzen
for(j=0;j<filled;++j)
elem[j]=(j+p_newnode->filled==i)?el:elem[jj++];
return p_newnode;
}//ins
void MNnode::del(unsigned k)
{
//Aufgabe!
}
void MNnode::print()
{
unsigned i;
if(!height)
{
for(i=0;i<filled;++i)printf("(%u,%u):%u,%d ",height,i,elem[i].key,elem[i].son.value);
printf("\n");
}
else
for(i=0;i<filled;++i)
{ printf("(%u:%u):%u\n",height,i,elem[i].key); elem[i].son.p_node->print(); }
}//print
4.4. ABSCHLIESSENDE FALLSTUDIEN
107
MNBaum::MNBaum(unsigned h, unsigned f)
{ root= new MNnode(h,f); }
void MNBaum::insert(unsigned k, int v)
{
P_MNnode pa=root,pn=root->ins(k,v,NULL,0);
if(!pn) return;
root=new MNnode(pa->height+1,2);
root->elem[0].key=pn->elem[pn->filled-1].key;
root->elem[0].son.p_node=pn;
root->elem[1].key=pa->elem[pa->filled-1].key;
root->elem[1].son.p_node=pa;
return;
}//insert
void MNBaum::mdelete(unsigned k)
{
P_MNnode p=root;
root->del(k);
if(root->height && root->filled<2)
{
root=root->elem[0].son.p_node;
delete p;
}
return;
}//mdelete
void MNBaum::print()
{ root->print(); printf("\n!\n"); }
4.4.1.3
Hashing
Hashing ist eine Technik, um Informationen mit einem Schlüssel in Tabellenform abzuspeichern.
Grundprinzip:
Mittels einer Hashfunktion h : S → {1, . . . , m} wird jedem Schlüssel a ∈ S eine Adresse h(a) ∈ {1, . . . , m}
zugeordnet, unter der die Information abzulegen ist.
Problem:
Im allgemeinen kann h nicht injektiv gestaltet werden und Kollisionen h(a) = h(a0 ) (hash-clash) müssen aufgelöst
werden.
Hauptmethoden:
• Hashing mit Verkettung: In einer Hashtabelle T der Länge m werden nur Verweise auf die abgespeicherten Informationen abgelegt. Alle Informationen mit Schlüsseln a, die die gleiche Hashadresse h(a)
haben, werden (mit ihrem Schlüssel) in einer verketteten Liste abgespeichert, deren Anker T (h(a)) ist.
• Hashing mit offener Adressierung: Es steht eine Tabelle zur Verfügung, in die die Informationen mit
ihrem Schlüssel eingetragen werden. Ist für den Schlüssel a der Eintrag h(a) belegt, so wird mittels einer
Rehashfunktion rh : {1, . . . , m} → {1, . . . , m} ein neuer Eintrag ermittelt usw.
4.4.1.3.1
Hashfunktionen werden nach verschiedenen Prinzipien gestaltet:
Divisions-Methode:
• h(a) = wa mod m, wobei wa der numerische Wert des Schlüssels a ist.
108
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
• Falls a = a1 . . . an , ai ∈ A und wa =
Zeichen, so sollte:
Pn
i=0
ai ri , mit r = |A|, d.h. r (Radix) ist die Anzahl der darstellbaren
– m Primzahl sein (Zm hat dann keine Nullteiler), damit möglichst viele Positionen von a Einfluß auf
h(a) haben,
P
k
– r mod m 6= 1, sonst wäre wa mod m =
a
mod m.
i
i=0
Konsequenz: Schlüssel mit gleicher Buchstabenkombination haben die gleiche Hashadresse!
Multiplikations-Methode:
• h(a) = bm · f rac(c · wa )c mit f rac(x) = x − bxc,
• c = kz , also c · wa =
z·wa
k
mit den Varianten
– z, k = 2b ganzzahlig, teilerfremd, b ist Wortlänge (einfache Implementierung!),
m = 2p ⇒ m · f rac(c · wa ) sind die p führenden bits des gebrochenen Anteils von c · wa .
√
≈ 0.6180339887 - Fibonacci-Hash mit guten Verteilungseigenschaften bei aufeinanderfol– c ≈ 5−1
2
genden Schlüsseln.
Quadratmittel-Methode:
• h(a) wird gebildet aus den mittleren k-bits von wa · wa .
4.4.1.3.2 Rehash bei offener Adressierung ist eine schrittweise Suche, im Schritt i wird gesucht in
rhi (a), wobei
rh0 (a) = h(a), rhi+1 (a) = rh(rhi (a)).
• Lineares Rehash: rh(x) = (x + c) mod m
Problem: Clusterbildung, falls h(a0 ) mit einer Rehash-Adresse rhi (a) übereinstimmt.
• Doppeltes Rehash: rh(x, y) = (x + y) mod m wobei rhi+1 (a) = rh(rhi (a), i + 1).
Clusterbildung, falls h(a0 ) = h(a).
• Rehash mit zweiter Hashfunktion h0 : rh(x, y) = (x + y) mod m mit rhi+1 (a) = rh(rhi (a), h0 (a)).
4.4.1.3.3 Zur Effizienz sind Erwartungswerte E für Anzahl der Zugriffe bei großen m und für
Anzahl der Einträge
bekannt.
α=
m
• Hashing mit Verkettung:
α
0.1
0.5
0.75
0.9
1.5
2.0
E =1+
1.05
1.25
1.38
1.45
1.75
2.00
α
2
• Hashing mit offener Adressierung, lineares Rehash:
α
0.1
0.5
0.75
0.9
1− α
E = 1−α2
1.06
1.50
2.50
5.50
4.4. ABSCHLIESSENDE FALLSTUDIEN
109
• Hashing mit offener Adressierung, doppeltes Rehash:
α
0.1
0.5
0.75
0.9
E = − log(1−α)
α
1.05
1.39
1.83
2.56
Literatur:
R.Morris: Scatter Storage Techniques, CACM, Vol11 No.1 January 1968, pp. 38
4.4.1.3.4
Modifikationen sind
• Universelle Hashfunktionen: Da für eine spezielle Anwendung die bereitgestellte Hashfunktion ungünstig
sein kann, wird mit einer Klasse von Hashfunktionen gearbeitet, aus der jeweils zufällig eine (oder zwei
bei doppeltem Rehash) ausgewählt wird.
• Erweiterbares Hashing: Die Länge der verketteten Listen, Körbe genannt, wird beschränkt. Dadurch kann
gezielter gesucht werden. Eventuell müssen neue Körbe geschaffen werden.
Die Implementierung ist für die offene Adressierung vorgenommen. Dem Konstruktor der Klasse Htable muss
eine Hashfunktion mitgeteilt werden.
Datenstruktur und Prototypen: in Datei hash.h
typedef char Name [50];
typedef float Val;
struct Entry
{
Name name;
Val val;
};
class Htable
{
Entry * table;
//Tabelle
unsigned length; //aktuelle Länge
unsigned filling;//aktueller Füllstand
unsigned (*hf)(Name);//Zeiger auf Hashfunktion
public:
Htable(unsigned,unsigned (*p)(Name));//Konstruktor
void extend();//Erweiterung
int addr_of(Name);
void insert(Name,Val);
void remove(Name);
};
110
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
Implementierungen in hash.cpp
#include<malloc.h>
#include<math.h>
#include<string.h>
#include <stdio.h>
#include"hash.h"
Htable::Htable(unsigned l,unsigned (*p)(Name))
{
table=(Entry *)(malloc(l*sizeof(Entry)));
hf=p;
length=l;
filling=0;
for(unsigned i=0;i<l;++i) table[i].name[0]=0;
}//Htable
void Htable::extend()
{
//Aufgabe: bei einem Füllstand von 75\% erweitern!
}
int Htable::addr_of(Name name)
{
int addr=hf(name),i=0;
while(table[addr].name[0])
if(strcmp(name,table[addr].name))addr=(addr+(++i))%l; else return addr;
return -(addr+1);
}//addr_of
void Htable::insert(Name name, Val val)
{
int addr=addr_of(name);
if(addr<0)
{
strcpy(table[addr=-(addr+1)].name,name);
if(++filling>(3*l)/4) extend();
}
table[addr].val=val;
}//insert
void Htable::remove(Name name)
{
//Aufgabe!
}
unsigned hf(Name n)//Fibonacci-Hash
{
double d=0;
unsigned u,i=0;
while(u=n[i++])
{
if(u<65||u>122||(u>90&&u<97))continue;
if(u>96)u-=70; else u-=64;
d=d*52+u;
}
d*=0.6180339887; d-=floor(d);
return unsigned(l*d);
}//hf
4.4. ABSCHLIESSENDE FALLSTUDIEN
4.4.2
111
Flüsse und Wege in gerichteten Graphen
Es wird zunächst eine Klasse Digraph vorgestellt, die gerichtete Graphen aufzubauen und zu manipulieren
gestattet und für die nachfolgenden Algorithmen als Basisklasse fungiert und Grundfunktionen bereitstellt. Wie
sich herausstellen wird, ist es, je nach speziellem Algorithmus aber erforderlich, die Datenstruktur anzupassen.
Bei der Eingabe eines gerichteten Graphen wird der Speicher ratenweise allokiert. Dabei bestimmt knoten rate
die Größe der Rate, um die der Speicher für die Knoten allokiert wird und grad rate die Rate, um die der
Speicher für alle von einem festen Knoten abgehenden Kanten zugeordnet wird. Falls der Speicher nicht ausreicht,
wird weiterer allokiert.
Datenstruktur und Prototypen: in Datei graph.h
struct TA
{
unsigned ende;
float kosten;
};
//Kante mit festem Anfang
//Endknoten
//Kosten
class Digraph
{
protected:
unsigned knoten_anz;
//Knotenanzahl=Allokierter Speicher
unsigned knoten_rate;
//Rate für Knoten-Erweiterungen
unsigned grad_rate;
//Rate für Grad-Erweiterungen
TA ** kante;
//Kanten
unsigned *grad;
//Ausgangsgrad für Knoten
void knoten_erw(unsigned); //Speichererweiterung für Knotenmenge
void grad_erw(unsigned);
//Speichererweiterung für Knotengrad
void add(unsigned,unsigned,float);//fügt Kante hinzu
void lesen(const char*);
//Liest Graph aus einer Textdatei ein
public:
Digraph(const char*,unsigned=16,unsigned=4);
void drucken();
};
Implementierung: in Datei graph.cpp
#include<stdio.h>
#include<malloc.h>
#include<io.h>
#include"graph.h"
Digraph::Digraph(const char* name, unsigned nr,unsigned dr)
{
knoten_anz=knoten_rate=nr; grad_rate=dr;
kante = (TA **)malloc(knoten_anz*sizeof(TA*));
grad = (unsigned *)malloc(knoten_anz*sizeof(unsigned));
for(unsigned i=0;i<knoten_anz;++i)grad[i]=0;
lesen(name);
}//Digraph
112
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void Digraph::drucken()
{
for(unsigned i=1;i<knoten_anz;++i)
{
printf("%u-> ",i);
for(unsigned j=0;j<grad[i];++j)
printf("(%u:%f)",kante[i][j].ende,kante[i][j].kosten);
printf("\n");
}
printf("!\n");
}//drucken
void Digraph::grad_erw(unsigned k)
{
if(!grad[k]){ kante[k]=(TA *)malloc(grad_rate*sizeof(TA)); return; }
kante[k]=(TA*)realloc(kante[k],(k+grad_rate)*sizeof(TA));
}//grad_erw
void Digraph::knoten_erw(unsigned k)
{
unsigned ostore=knoten_anz;
knoten_anz=((k/knoten_rate)+1)*knoten_rate;
kante=(TA**)realloc(kante,knoten_anz*sizeof(TA*));
grad=(unsigned *)realloc(grad,knoten_anz*sizeof(unsigned));
for(unsigned i=ostore;i<knoten_anz;++i)grad[i]=0;
}\\knoten_erw
void Digraph::add(unsigned k, unsigned l, float c)
{
unsigned i=0,j;
if(k>=knoten_anz) knoten_erw(k);
if(!grad[k])
{
grad_erw(k); grad[k]=1;
kante[k][0].ende=l; kante[k][0].kosten=c;
return;
}
while(i<grad[k] && l>kante[k][i].ende)++i;
if(i<grad[k] && l==kante[k][i].ende){ kante[k][i].kosten=c; return; }
if(!(grad[k]%grad_rate)) grad_erw(k);
for(j=grad[k];j>i;--j)kante[k][j]=kante[k][j-1];
kante[k][i].ende=l; kante[k][i].kosten=c;
++grad[k];
return;
}//add
void Digraph::lesen(const char * name)
{
FILE *f;
unsigned k,l; float c;
if(access(name,0)){printf("Datei \"%s\" nicht vorhanden!\n",name);return;}
f=fopen(name,"r");
while(EOF!=fscanf(f,"%u%u%f",&k,&l,&c)) add(k,l,c);
fclose(f);
}//lesen
4.4. ABSCHLIESSENDE FALLSTUDIEN
4.4.2.1
113
Flüsse nach Ford/Fulkerson
Das Prinzip wurde bereits im Abschnitt 3.6.3.1 beschrieben. Der Hauptunterschied zur HASKELL-Implementierung besteht darin, daß iterativ statt induktiv vorgegangen wird. Deshalb werden alle Kanten noch
einmal in einem Feld abgespeichert.
Datenstruktur und Prototypen: in Datei ffgraph.h
#include"graph.h"
struct KANTE
{
unsigned a,
e;
float c,
fluss;
};
struct WEG
{
float r;
unsigned next;
};
//Anfangsknoten
//Endknoten
//Kapazität der Kante
//aktueller Fluss
//aktueller Wert für zun. Weg
//nächster Knoten
class FFDigraph:public Digraph
{
unsigned kanten_anz,
//Anzahl der Kanten
quelle,
//Quellknoten
senke;
//Senkenknoten
KANTE * kante;
WEG * weg;
float zun_weg();
//bestimmt einen zunehmenden Weg
void mod_fluss(float); //modifiziert Fluss für gegebenen zun. Weg
public:
FFDigraph(const char *);
void max_fluss(unsigned,unsigned);
};
Implementierung: in Datei ffgraph.cpp
#include"ffgraph.h"
#include<malloc.h>
FFDigraph::FFDigraph(const char *name):Digraph(name)
{
unsigned i,j,k=0;
kanten_anz=0;
for(i=0;i<knoten_anz;++i)kanten_anz+=grad[i],
kante=(KANTE*)calloc(kanten_anz,sizeof(KANTE));
weg=(WEG*)calloc(knoten_anz,sizeof(WEG));
for(i=0;i<knoten_anz;++i)
for(j=0;j<grad[i];++j)
{
kante[k].a=i;
kante[k].e=Digraph::kante[i][j].ende;
kante[k++].c=Digraph::kante[i][j].kosten;
}
}//FFDigraph
114
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
float FFDigraph::zun_weg()
{
unsigned weiter,i;
float rest,wegr;
do
{
weiter=0;
for(i=0;i<kanten_anz;++i)
{
if(kante[i].a==senke || kante[i].e==quelle) continue;
if(!weg[kante[i].a].r//kein zun. Weg von a
&& (kante[i].fluss<kante[i].c))//Restkapazität vorhanden
{
if(kante[i].e==senke)
{
weg[kante[i].a].r=kante[i].c-kante[i].fluss;
weg[kante[i].a].next=senke;
if(kante[i].a==quelle) return weg[kante[i].a].r;
++weiter; continue;
}
if(wegr=weg[kante[i].e].r) //ein zun. Weg von e
{
weg[kante[i].a].r=(rest=kante[i].c-kante[i].fluss)<wegr?rest:wegr;
weg[kante[i].a].next=kante[i].e;
if(kante[i].a==quelle) return weg[kante[i].a].r;
++weiter; continue;
}
}
if(!weg[kante[i].e].r//kein zun. Weg von e
&& (rest=kante[i].fluss)//Rücknahme von Flüssen möglich
&& weg[kante[i].a].r)//ein zun. Weg von a
{
weg[kante[i].e].r=rest<(wegr=weg[kante[i].a].r)?rest:wegr;
weg[kante[i].e].next=kante[i].a;
++weiter; continue;
}
}
} while(weiter);
return weg[quelle].r;
}//zun_weg
void FFDigraph::mod_fluss(float r)
{
//Aufgabe!
}
void FFDigraph::max_fluss(unsigned q, unsigned s)
{
float r; quelle=q; senke=s;
while(r=zun_weg())mod_fluss(r);
}//max_fluss
4.4. ABSCHLIESSENDE FALLSTUDIEN
4.4.2.2
115
Wege nach Ford/Moore
Das Prinzip wurde bereits im Paragraph 3.6.3.2.1 beschrieben.
Datenstruktur und Prototypen: in Datei fmgraph.h
#include"graph.h"
struct WEG
{
unsigned m;
//vom Anfang erreichter Knoten
float kosten;
//Kosten des bisher kürzesten Weges
unsigned vorgaenger;//Vorgänger von m auf bisher kürzestem Weg
};
class FMDigraph : public Digraph
{
unsigned weg_anz;
//Anzahl der bisher erreichten Knoten
WEG* kweg;
public:
FMDigraph(const char*,unsigned=16,unsigned=4);
void wegberechnung(unsigned);
};
Implementierung: in Datei fmgraph.cpp
#include"fmgraph.h"
#include<malloc.h>
void FMDigraph::wegberechnung(unsigned anfang)
{
unsigned weiter,i,j,weg_anz=1,g,m,n,minda;
float min,minh;
kweg[0].m=anfang; kweg[0].kosten=0;
do
{
weiter=0;
for(n=1;n<knoten_anz;++n)
{
minda=0;//noch keinen neuen Knoten erreicht
for(i=0;i<weg_anz;++i)
{
m=kweg[i].m;//bisher erreichter Knoten
if(!(g=grad[m])) continue; //keine Kante von m zu n
j=0; while(j<g && n>kante[m][j].ende)++j;
if(j==g || n!=kante[m][j].ende) continue; //keine Kante von m zu n
if(!minda){ min=kweg[i].kosten+kante[m][j].kosten; minda=m; }
else if(min>(minh=kweg[i].kosten+kante[m][j].kosten))
{ min=minh; minda=m; }
}
if(minda)
{
j=0;while(j<weg_anz && n!=kweg[j].m)++j;
if(j==weg_anz) { ++weiter; ++weg_anz; }
if(weiter || min<kweg[j].kosten)
{ ++weiter; kweg[j].kosten=min; kweg[j].m=n; kweg[j].vorgaenger=minda; }
}
}
} while(weiter);
}//wegberechnung
FMDigraph::FMDigraph(const char * name,unsigned nr, unsigned dr)
:Digraph(name,nr,dr)
{ kweg=(WEG*)calloc(knoten_anz,sizeof(WEG)); }
116
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
4.4.2.3
Wege nach Floyd/Warshall
Das Prinzip wurde bereits im Paragraph 3.6.3.2.2 beschrieben. Auch hier wird eine iterative Implementierung
gewählt!
Datenstruktur und Prototypen: in Datei fwgraph.h
#include"graph.h"
typedef enum {T,D} ZU;
struct FWG
{
ZU zustand;
float kosten;
};
class FWDigraph : public Digraph
{
FWG **fwg;
void fw();//Berechnung der Kosten aller kürzesten Wege
public:
FWDigraph(const char* name);
void kw(unsigned,unsigned);//Bestimmung des kürzesten Weges
};
Implementierung: in Datei fmgraph.cpp
#include"fwgraph.h"
#include"stdio.h"
#include<malloc.h>
FWDigraph::FWDigraph(const char *name):Digraph(name)
{
--knoten_anz;
fwg=(FWG**)malloc(knoten_anz*sizeof(FWG*));
for(unsigned i=0;i<knoten_anz;++i)
fwg[i]=(FWG*)calloc(knoten_anz,sizeof(FWG));
for(i=1;i<=knoten_anz;++i)
for(unsigned j=0;j<grad[i];++j)
{
fwg[i-1][kante[i][j].ende-1].zustand=D;
fwg[i-1][kante[i][j].ende-1].kosten=kante[i][j].kosten;
}
fw();
}//FWDigraph
void FWDigraph::kw(unsigned start,unsigned stop)
{ //Aufgabe!
}
4.4. ABSCHLIESSENDE FALLSTUDIEN
void FWDigraph::fw()
{
float min;
for(unsigned i=0;i<knoten_anz;++i)
for(unsigned m=0;m<knoten_anz;++m)
for(unsigned n=0;n<knoten_anz;++n)
{
if(!(fwg[m][i].zustand && fwg[i][n].zustand)) continue;
min=fwg[m][i].kosten+fwg[i][n].kosten;
if((!fwg[m][n].zustand)||min<fwg[m][n].kosten)
{
fwg[m][n].kosten=min;
fwg[m][n].zustand=D;
}
}
}//fw
4.4.2.4
Wege nach Dijkstra
Das Prinzip wurde bereits im Paragraph 3.6.3.2.3 beschrieben.
Datenstruktur und Prototypen: in Datei dgraph.h
#include"graph.h"
struct UMG
{
float kosten;
//Kosten des bisher kürzesten Weges
unsigned vorg;//Vorgänger von m auf bisher kürzestem Weg
};
class DDigraph : public Digraph
{
unsigned weg_anz;
//Anzahl der bisher erreichten Knoten
UMG* umg;
public:
DDigraph(const char*,unsigned=16,unsigned=4);
void kw(unsigned,unsigned);
};
Implementierung: in Datei dgraph.cpp
#include"dgraph.h"
#include<stdio.h>
#include<malloc.h>
DDigraph::DDigraph(const char * name,unsigned nr, unsigned dr)
:Digraph(name,nr,dr)
{
umg=(UMG*)calloc(knoten_anz,sizeof(UMG));
}//DDigraph
117
118
KAPITEL 4. IMPERATIVE PROGRAMMIERUNG MIT C++
void DDigraph::kw(unsigned anfang, unsigned ende)
{
unsigned i,j,minda,a,e;
float min,minh;
umg[anfang].vorg=anfang;
do
{
minda=0;
for(i=1;i<knoten_anz;++i)
if(umg[i].vorg)//Knoten i liegt in der Umgebung
for(j=0;j<grad[i];++j)
{
if(umg[kante[i][j].ende].vorg) continue;//Kante führt nicht aus Umgebung
if(!minda)
{
min=umg[i].kosten+kante[i][j].kosten;
a=i;
e=kante[i][j].ende;
++minda;
continue;
}
if((minh=umg[i].kosten+kante[i][j].kosten)<min)
{
min=minh;
a=i;
e=kante[i][j].ende;
}
}
if(minda)
{
umg[e].vorg=a; umg[e].kosten=min; }
} while(minda && e!=ende);
//Ausgabe des Weges verbleibt als Aufgabe
}//kw
4.4.2.5
Gerüste nach Kruskal
Das Prinzip wurde bereits im Abschnitt 3.6.4.1 beschrieben.
Die Implementierung in C++ verbleibt als Aufgabe!
4.4.2.6
Gerüste nach Prim
Das Prinzip wurde bereits im Abschnitt 3.6.4.2 beschrieben.
Die Implementierung in C++ verbleibt als Aufgabe!
Herunterladen