Praktische Informatik 1 - Institut für Informatik - Humboldt

Werbung
HUMBOLDT-UNIVERSITÄT ZU BERLIN
MATHEMATISCH - NATURWISSENSCHAFTLICHE FAKULTÄT II
INSTITUT FÜR INFORMATIK
ARBEITSGRUPPE SPEZIFIKATION, VERIFIKATION UND TESTTHEORIE
PROF. DR. HOLGER SCHLINGLOFF
SS 2010
Skriptum zur Vorlesung
Grundlagen der
Programmierung
Inhalt
Kapitel 0: Einführung ......................................................................................................... 0-4
0.1 Inhalt der Vorlesung, Organisatorisches, Literatur..................................................... 0-4
0.2 Einführungsbeispiel .................................................................................................. 0-8
0.3 Was ist Informatik? ................................................................................................. 0-15
Kapitel 1: Mathematische Grundlagen .............................................................................. 1-20
1.1 Mengen, Multimengen, Tupel, Funktionen, Halbordnungen .................................... 1-20
1.2 Induktive Definitionen und Beweise ....................................................................... 1-24
1.3 Alphabete, Wörter, Bäume, Graphen....................................................................... 1-27
Kapitel 2: Informationsdarstellung .................................................................................... 2-32
2.1 Bits und Bytes, Zahl- und Zeichendarstellungen...................................................... 2-32
2.2 Sprachen, Grammatiken, Syntaxdiagramme ............................................................ 2-41
2.3 Darstellung von Algorithmen .................................................................................. 2-46
Kapitel 3: Rechenanlagen ................................................................................................. 3-53
3.1 Historische Entwicklung ......................................................................................... 3-53
3.2 von-Neumann-Architektur ...................................................................................... 3-61
3.3 Aufbau PC/embedded system, Speicher .................................................................. 3-65
Kapitel 4: Programmiersprachen und –umgebungen ......................................................... 4-70
4.1 Programmierparadigmen ......................................................................................... 4-70
4.2 Historie und Klassifikation von Programmiersprachen ............................................ 4-73
4.3 Java und Groovy ..................................................................................................... 4-74
4.4 Programmierumgebungen am Beispiel Eclipse........................................................ 4-77
Kapitel 5: Applikative Programmierung ........................................................................... 5-79
5.1 λ-Kalkül.................................................................................................................. 5-79
5.2 Rekursion, Aufruf, Terminierung ............................................................................ 5-80
Kapitel 6: Konzepte imperativer Sprachen ........................................................................ 6-85
6.1 Variablen, Datentypen, Ausdrücke .......................................................................... 6-85
6.2 Anweisungen und Kontrollstrukturen ...................................................................... 6-89
6.3 Sichtbarkeit und Lebensdauer von Variablen .......................................................... 6-91
0-1
Kapitel 7: Objektorientierung ........................................................................................... 7-93
7.1 abstrakte Datentypen, Objekte, Klassen .................................................................. 7-93
7.2 Klassen, Objekte, Methoden, Datenfelder, Konstruktoren ....................................... 7-97
7.3 Vererbung, Polymorphismus, dynamisches Binden ............................................... 7-101
Kapitel 8: Modellbasierte Softwareentwicklung .............................................................. 8-105
8.1 UML Klassendiagramme und Zustandsmaschinen ................................................ 8-105
8.2 Codegenerierung und Modelltransformationen ...................................................... 8-105
Kapitel 9: Spezielle Programmierkonzepte ..................................................................... 9-106
9.1 Benutzungsschnittstellen, Ereignisbehandlung ...................................................... 9-106
9.2 Abstrakte Klassen, Interfaces, generische Typen ................................................... 9-112
9.3 Fehler, Ausnahmen, Zusicherungen ...................................................................... 9-113
9.4 Parallelität............................................................................................................. 9-116
Kapitel 10: Algorithmen und Datenstrukturen................................................................10-123
10.1 Listen, Bäume, Graphen .....................................................................................10-123
10.2 Graphalgorithmen ..............................................................................................10-130
10.3 Suchen und Sortieren .........................................................................................10-132
0-2
Hinweis zur Nutzung dieses Skriptes:
Achtung, drucken Sie das Skript (noch) nicht aus! Es wird parallel zur Vorlesung erstellt und
laufend aktualisiert. Wenn Sie jetzt schon ihren Drucker bemühen, ist das entweder eine
Verschwendung von Papier (weil Sie es später nochmal drucken) oder sie erhalten ein evtl.
inkonsistentes Dokument (wenn Sie verschiedene Teile zusammenheften).
Mit Abschluss des Semesters wird Ihnen das gesamte Skript zur Verfügung stehen!
Dieses Dokument enthält außerdem Hyperlinks zu weiteren Quellen im Internet, die in Word
oder mit dem Acrobat Reader durch Anklicken abrufbar sind. Für die Aktualität der Links
übernehme ich keinerlei Gewähr..
HS, 22.4.2010
0-3
Kapitel 0: Einführung
0.1 Inhalt der Vorlesung, Organisatorisches, Literatur
Die Vorlesung „Grundlagen der Programmierung“ hat laut der Modulbeschreibung des
Bachelor-Studienganges Informatik folgende Lern- und Qualifikationsziele:
Studierende verstehen die Funktionsweise von Computern und die
Grundlagen der Programmierung. Sie beherrschen eine objektorientierte
Programmiersprache und kennen andere Programmierparadigmen.
Daraus ergeben sich folgende Vorlesungsinhalte:
•
•
•
•
•
•
•
Grundlagen: Algorithmus, von-Neumann-Rechner, Programmierparadigmen
Konzepte imperativer Programmiersprachen: Grundsätzlicher Programmaufbau;
Variablen: Datentypen, Wertzuweisungen, Ausdrücke, Sichtbarkeit, Lebensdauer;
Anweisungen: Bedinge Ausführung, Zyklen, Iteration; Methoden: Parameterübergabe;
Rekursion;
Konzepte der Objektorientierung: Objekte, Klassen, Abstrakte Datentypen; Objekt Variablen/-Methoden, Klassen -Variablen/-Methoden; Werte und Referenztypen;
Vererbung, Sichtbarkeit, Überladung, Polymorphie; dynamisches Binden;
Ausnahmebehandlung; Oberflächenprogrammierung; Nebenläufigkeit (Threads)
Einführung in eine konkrete objektorientierte Sprache (z.B. JAVA): Grundaufbau
eines Programms, Entwicklungsumgebungen, ausgewählte Klassen der Bibliothek,
Programmierrichtlinien für eigene Klassen, Techniken zur Fehlersuche (Debugging)
Einfache Datenstrukturen und Algorithmen: Listen, Stack, Mengen, Bäume, Sortieren
und Suchen
Softwareentwicklung: Softwarelebenszyklus, Software-Qualitätsmerkmale
Alternative Konzepte: Zeiger, maschinennahe Programmierung, alternative
Modularisierungstechniken
Die Vorlesung (4 SWS) ist nur mit begleitender Übung (2 SWS), Praktikum (2 SWS),
Selbststudium, Vorlesungsmitschrift, Hausaufgaben (in Zweiergruppen bearbeitet, korrigiert
und bewertet, in der Übung besprochen) sinnvoll. Als Prüfung findet eine Abschlussklausur
(120 Minuten Dauer) statt; die Zulassung zur Klausur ist an die Erreichung einer bestimmten
Punktzahl in Übungen und Praktikum gebunden.
Für die erfolgreiche Teilnahme gibt es 12 Studienpunkte nach dem ECTS-System (European
Credit Transfer System). Ein Studienpunkt (SP) entspricht 30 Zeitstunden Arbeitsaufwand;
d.h., der Gesamtaufwand liegt bei ca. 360 Arbeitsstunden:
• Vorlesung Grundlagen der Programmierung;
4 SWS, 6 SP, 60 Anwesenheitsstunden (4h/Woche), 120 h Aufbereitung (8h/Woche)
• Übung Grundlagen der Programmierung;
2 SWS, 3 SP, 30 Anwesenheitsstunden (2h/Woche), 60 h Aufbereitung (4h/Woche)
• Praktikum Grundlagen der Programmierung;
2 SWS, 3 SP, 30 Anwesenheitsstunden (2h/Woche), 60 h Aufbereitung (4h/Woche)
Für die Übungen wird 14-tägig ein Übungsblatt verfügbar gemacht, dessen Lösungen zwei
Wochen später elektronisch abzugeben sind. Das Aufgabenblatt wird in den Übungen vorund nachbesprochen. Für die Bearbeitung eines Aufgabenblattes sind also ca. 8h erforderlich.
0-4
Während für Vorlesung und Übung die Anwesenheit obligatorisch ist, kann beim Praktikum
auf eine Anwesenheit verzichtet werden, wenn der/die Teilnehmer anderweitig über einen
Rechner (Laptop) verfügt, auf dem die Aufgaben bearbeitet werden können. Die Gesamtzeit
für die Bearbeitung der Praktikumsaufgaben beträgt ca. 6h/Woche.
Literaturhinweise
Leider gibt es kein einzelnes Buch, welches genau den Stoff der Vorlesung enthält.
Als Literatur zur Vorlesung empfehle ich das Buch von Gumm und Sommer.
(Aktuell: 8. Auflage; frühere Auflagen gibt es zum Teil im Sonderangebot)
Als Ergänzung dazu ist nachfolgend eine Liste relevanter Lehrbücher angegeben..
Lehrbücher: Einführung in die Informatik
•
•
•
•
•
•
•
•
M. Broy: Informatik, eine grundlegende Einführung. Band 1: Programmierung und
Rechnerstrukturen, Band 2: Systemstrukturen und theoretische Informatik. SpringerLehrbuch (+ Aufgabensammlung) (Monumentalwerk)
P. Levi, U. Rembold: Einführung in die Informatik für Naturwissenschaftler und
Ingenieure, Hanser, (konzise)
G. Goos: Vorlesungen über Informatik (vier Bände: Bd. 1: Grundlagen und
funktionales Programmieren, Bd. 2: Objektorientiertes Programmieren und
Algorithmen, Bd. 3: Berechenbarkeit, formale Sprachen, Spezifikationen, Bd. 4:
Paralleles Rechnen und nicht-analytische Lösungsverfahren)
F. L. Bauer, G. Goos: Informatik - Eine einführende Übersicht, 4. Auflage.
Bd. 1+2, Springer („der Klassiker“, etwas veraltet)
L. Goldschlager, A. Lister: Informatik, Eine moderne Einführung. Hanser
Studienbücher, (ebenfalls etwas veraltet; in der Bibliothek 40 Ex. vorhanden)
F. Kröger: Einführung in die Informatik – Algorithmenentwicklung. Springer
Lehrbuch(formale Grundlagen, keine OO-Konzepte, als Ergänzung empfohlen)
H. Balzert: Lehrbuch Grundlagen der Informatik (als Ergänzung der
Vorlesungsthemen)
H.-J. Appelrath, J. Ludewig: Skriptum Informatik - Eine konventionelle Einfürung.
Teubner/VdF (+ Aufgabensammlung) (Betonung auf „konventionell“!)
0-5
•
•
A. Aho, J. Ullman: Informatik - Datenstrukturen und Konzepte der Abstraktion.
(deutsche Fassung von: Foundations of Computer Science) Thomson Publishing /
Computer Science Press (für Fortgeschrittene)
R. Sedgewick: Algorithmen in Java (auch für Fortgeschrittene)
Lehrbücher: Programmieren in Groovy
In der Vorlesung „Grundlagen der Programmierung“ und besonders im zugehörigen
Praktikum sollen auch Programmierkenntnisse unterrichtet werden. Trotzdem ist diese
Veranstaltung auf keinen Fall ein Programmierkurs; es wird erwartet, dass sich die
Studierenden selbständig in programmiersprachliche Details an Hand geeigneter
Lehrmaterialien (Bücher oder Online-Handbücher) einarbeiten.
Als notationelle Basis dient dabei zunächst die Skriptsprache Groovy, die auf der verbreiteten
Programmiersprache Java aufbaut und einige moderne Erweiterungen vorsieht. Später gehen
wir dann auf Java zurück. Das Haupt-Referenzbuch zu Groovy ist das Buch von König et. al.
Weitere Bücher zu Groovy sind
• K. Barclay, J. Savage: Groovy Programming: An Introduction for Java Developers
Morgan Kaufmann, 2006.
• S. Davis: Groovy Recipes – Greasing the Wheels of Java. Pragmatic Bookshelf, 2008.
• V. Subramaniam: Programming Groovy: Dynamic Productivity for the Java
Developer. Pragmatic Bookshelf, 2008.
• C. Judd, J. Nusairat: Beginning Groovy and Grails: From Novice to Professional.
Apress 2008.
• B. Abdul-Jawad: Groovy and Grails Recipes – a Problem-Solution Approach. Apress
2008.
Darüber hinaus gibt es zu Groovy eine umfangreiche Online-Dokumentation, siehe
http://groovy.codehaus.org/Documentation.
0-6
Literatur zu Java
Zum Erlernen der Programmiersprache Java kann entweder das Buch von Bell und Parr oder
das von Bishop dienen. Wer sich intensiver mit Java auseinander setzen möchte, dem sei das
Buch von Gosling als ultimative Referenz empfohlen.
•
•
•
•
•
•
•
J. Bishop: Java lernen (dt. Ausgabe von Java Gently), Addison Wesley / Pearson, 2.
Aufl. 2001 (südafrikanisches Flair)
K. Arnold, J. Gosling, D. Holmes: Die Programmiersprache Java (dt. Ausgabe von
The Java Programming Language). Addison-Wesley 1996 („die Referenz“)
D. Barnes, M. Kölling: Objektorientierte Programmierung mit Java (deutsche Ausgabe
von: Objects First with Java - A Practical Introduction using BlueJ). Prentice Hall /
Pearson, Sept. 2003 (für Vorlesung empfohlen)
D. Bell, M. Parr: Java für Studenten – Grundlagen der Programmierung. Prentice Hall
/ Pearson, 3. Aufl. 2003 (systematischer Java-Lehrgang)
E.-E. Doberkat, S. Dißmann: Einführung in die objektorientierte Programmierung mit
Java. Oldenbourg-Verlag, 2. Auflage 2002 (Wiener Hofzwerge betreiben Informatik)
H. W. Lang: Algorithmen in Java. Oldenbourg-Verlag 2003
Küchlin, Weber: Einführung in die Informatik – Objektorientiert mit Java
0-7
0.2 Einführungsbeispiel
Um sich der Frage zu nähern, was eigentlich praktische Informatik ist, betrachtet man am
besten ein Beispiel. Ein bekanntes Problem der Informatik ist das Problem des
Handlungsreisenden (Travelling Salesman Problem, TSP). Ein Handlungsreisender muss bei
seiner Arbeit Kunden besuchen, die in verschiedenen Orten wohnen. Aufgabe der Sekretärin
ist es nun, eine Tour zu planen, die ihn zu jedem Kunden genau einmal führt und am Schluss
wieder zum Ausgangspunkt zurück bringt. Natürlich sollte die Tour optimal sein, d.h.,
möglichst wenig Ressourcen verbrauchen. Abhängig davon, welchen Begriff von Ressource
man zu Grunde legt, gibt es verschiedene Varianten der Aufgabenstellung. Beim allgemeinen
TSP gehen wir davon aus, dass der Handlungsreisende z.B. mit dem Flugzeug unterwegs ist:
da es nicht immer von jedem beliebigen Punkt zu jedem anderen eine direkte Flugverbindung
gibt, muss die Sekretärin das Streckennetz der Fluggesellschaften und die jeweiligen
Flugzeiten (oder Ticketpreise) berücksichtigen. Beim euklidischen TSP bewegt sich der
Handlungsreisende zu Fuß oder mit dem Auto, d.h. er kommt überall hin und die Kosten sind
proportional zu den Entfernungen zwischen den Punkten auf der Landkarte. Das metrische
TSP ist eine Verallgemeinerung des euklidischen und ein Spezialfall des allgemeinen TSP:
zwischen je zwei Punkten besteht eine Verbindung, und die Dreiecksungleichung gilt (die
Summe der Kosten von A nach B und der Kosten von B nach C ist größer gleich der Kosten
von A nach C).
Hier ist ein Beispiel für das allgemeine TSP:
Die beiden angegebenen Lösungen haben die Länge 33 und 22. Welches ist die optimale
Tour?
Hier ist ein Beispiel für das euklidische TSP (nach http://mathsrv.kueichstaett.de/MGF/homes/grothmann/java/TSP/):
Dieses euklidische TSP hat 250 Punkte. Angegeben sind eine Näherungslösung (Weglänge
12,91) und eine optimale Lösung (Weglänge 12,48).
Was ist nun eine geeignete Methode, um das Problem zu lösen? Eine häufige
Herangehensweise der Informatik ist es, ein Problem auf ein einfacheres zurückzuführen. Wir
wissen, wie die Lösung eines TSP mit nur 2 Punkten (Firmensitz und ein Kunde) aussieht:
Der Handlungsreisende muss hin- zurückfahren. Angenommen, wir wissen, wie wir eine Tour
0-8
mit n Punkten löst. Dann kommen wir zu einer Lösung für (n+1) Punkten, indem wir einen
beliebigen Punkt zunächst weglassen, eine optimale Tour für n Punkte konstruieren, und den
weggelassenen Punkt dann als erstes Ziel in die Tour einfügen. Das ist natürlich noch nicht
die optimale Lösung, aber wenn man das für alle Punkte der Reihe nach macht und die Tour
mit der minimalen Länge wählt, bekommt man dadurch das Optimum.
Diesen Algorithmus könnte man etwa wie folgt notieren:
Wir nehmen an, der Startpunkt (Firmensitz) sei fest gegeben und notieren eine Tour durch die
Folge der zu besuchenden Punkte (ohne den Startpunkt S) und durch die zugehörige Länge.
Tour minTour (Punktmenge M) {
Wenn M einelementig (M={A}) dann
Rückgabe ((A, 2* |SA|))
Sonst {
// Berechne die kürzeste Tour rekursiv
Sei minT eine neue Tour, wobei zunächst
Punkte (minT) = undefiniert, Länge(minT)=unendlich;
Für jedes x aus M {
Tour rek = minTour (M-{x});
Sei A der erste Ort von rek;
Sei Tour try gegeben durch
Punkte (try) = append(x, Punkte(rek)));
Länge (try) = Länge(rek) - |SA| + |Sx| + |xA|;
Wenn Länge(try) < Länge(minT) {minT=try};
}
Rückgabe minT;
}
}
Wenn wir den Ablauf dieser rekursiven Funktion z.B. für die Punkte {ABC} betrachten,
stellen wir folgende Aufrufe fest:
minTour ({ABC}) ruft
minTour({BC}) ruft
minTour({C}) und
minTour({B}).
minTour({AC}) ruft
minTour({C}) und
minTour({A}).
minTour({AB}) ruft
minTour({B}) und
minTour({A}).
Das bedeutet, ein Aufruf mit n Punkten stützt sich auf n Aufrufe mit (n-1) Punkten ab, jeder
davon stützt sich auf (n-1) Aufrufe mit (n-2) Punkten ab usw. Insgesamt gibt es
n
n * (n - 1) * (n - 2) * … * 3 * 2 * 1 = ∏ i = n!
i =1
Aufrufe. Im Wesentlichen konstruiert der Algorithmus sämtliche Permutationen der Folge der
Punkte. Die Anzahl der Permutationen von n Elementen ist n!. Wir sagen, der Algorithmus
hat die Komplexität O(n!).
Nachfolgende Tabelle gibt einen Überblick über das Wachstum verschiedener Funktionen.
0-9
n
1
2
3
10
50
256
1000
10000
log(n) 256*n
n2
17*n3
2n
n!
nn
0,0
256
1
17
2
1
1
0,3
512
4
136
4
2
4
0,5
768
9
459
8
6
27
1,0
2560
100
17000
1024
3628800 10000000000
1,7
12800
2500
2125000 1,1259E+15 3,04141E+64 8,88178E+84
2,4
65536
65536 285212672 1,15792E+77
#ZAHL!
#ZAHL!
3,0 256000
1000000
1,7E+10 1,0715E+301
#ZAHL!
#ZAHL!
4,0 2560000 100000000
1,7E+13
#ZAHL!
#ZAHL!
#ZAHL!
Wenn wir den Algorithmus auf einem schnellen Pentium DualCore ausführen, der bis zu 4
Milliarden Operationen pro Sekunde ausführt, dann müssen wir für n=10 etwa eine
Millisekunde warten. Für n=50 erhöht sich unsere Wartezeit allerdings auf etwa
1028=3.000.000.000.000.000.000.000.000.000 oder 3 Quatrilliarden Jahre. Das heisst, für
große Werte von n ist der Algorithmus praktisch nicht verwendbar. Auf der anderen Seite ist
es ein offenes Problem, ob es tatsächlich einen substantiell besseren Algorithmus gibt! Wer
einen Algorithmus entdeckt, welcher das TSP mit einer polynomialen Anzahl von Schritten
löst (abhängig von n), wird mit Sicherheit weltberühmt werden.
Natürlich gibt es einige Tricks, um die Laufzeit des Algorithmus zu verbessern. Zum Beispiel
fällt auf, das während der Rekursion der gleiche Aufruf mehrmals stattfindet. Hier kann man
sich mit dem Abspeichern von Zwischenergebnissen behelfen. Dann kann man die Suche
stark parallelisieren. Auch hängt es sehr stark von der Implementierung ab, ob man den
rekursiven Aufruf naiv oder raffiniert implementiert. Mit solchen Tricks ist es im Jahr 2004
gelungen, ein TSP mit 24.978 schwedischen Städten zu lösen (http://www.tsp.gatech.edu/
http://www.math.princeton.edu/tsp/d15sol/)! Der Weltrekord liegt laut Wikipedia
(http://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden) bei der Lösung eines
Planungsproblems für das Layout integrierter Schaltkreise mit 85.900 Knoten. Zur Lösung
solcher Probleme werden Netze von über hundert Hochleistungscomputern mit einer
Gesamtsumme von über 20 Jahren Rechenzeit verwendet. Zum Vergleich: Im Jahr 1977 lag
der Rekord noch bei 120 Städten!
0-10
Was macht man aber nun, wenn man das Problem in der Praxis (zum Beispiel in einer
mittelständischen Reisebüro-Software) lösen will, wo man kein Hochleistungs-Rechnernetz
zur Verfügung hat? Die Antwort der praktischen Informatik heißt Heuristik. Das Wort
„Heuristik“ kommt aus der Seefahrt und bezeichnete früher Verfahren, um seine Position
annähernd zu bestimmen. Heute bezeichnen wir damit Näherungsverfahren, die zwar nicht die
optimale Lösung, aber eine hinreichend genaue Annäherung liefern. Java-Animationen mit
verschiedenen Heuristiken sind zu finden unter
(http://web.telia.com/~u85905224/tsp/TSP.htm) und (http://www-e.unimagdeburg.de/mertens/TSP/index.html).
Heuristik 1: nächster Nachbar
Bei dieser Heuristik sucht der Reisende, ausgehend von einem beliebigen Punkt, zunächst den
nächsten Nachbarn des aktuellen Punktes auf der Landkarte. Dann bewegt er sich zu diesem
und wendet die Heuristik erneut an. Wenn er alle Kunden besucht hat, fährt er nach Hause
zurück. Obwohl diese Heuristik sehr häufig im täglichen Leben angewendet wird, liefert sie
schlechte Resultate, da der Heimweg und andere unterwegs „vergessene“ Punkte meist hohe
Kosten verursachen.
0-11
Heuristik 2: gierige Dreieckstour-Erweiterung
Die Sekretärin wählt zunächst zwei Kunden willkürlich aus und startet mit einer einfachen
„Dreieckstour“. Diese wird dann nach und nach erweitert, und zwar wird jede Stadt da in die
Tour eingefügt, wo sie am besten „passt“, d.h., wo sie die gegebene Tour am wenigsten
erweitert. Solche Strategien nennt man „gierig“, da sie nur auf lokale und Optimierung
ausgerichtet sind und das Gesamtergebnis nicht berücksichtigen.
In der geschilderten Form sind noch zwei Zufallselemente enthalten: Die Wahl der
ursprünglichen Dreieckstour, und die Reihenfolge, in der die Knoten hinzugefügt werden.
Heuristik 3: Hüllen-Erweiterung
Eine andere Variante dieser Heuristik startet mit der konvexen Hülle aller Punkte (d.h., den
Punkten, die am weitesten außen liegen), und fügt der Reihe nach diejenigen Knoten hinzu,
die die wenigsten Kosten verursachen. Das heißt, es werden der Reihe nach die Knoten
hinzugefügt, die den geringsten Abstand von der bisher konstruierten Tour haben. Das ist
0-12
wieder ein „gieriger“ Algorithmus, und zwar in doppelter Hinsicht: bei der Auswahl der
Punkte und bei der Bestimmung der Einfügestelle.
Heuristik 4: inverse Hüllen-Erweiterung
Wieder starten wir mit der konvexen Hülle aller Punkte und fügen der Reihe nach diejenigen
Knoten hinzu, die die meisten Zusatzkosten verursachen. Das heißt, es werden die Knoten
hinzugefügt, die am weitesten von der bisherigen Tour entfernt liegen. Dadurch wird das
Gesamtergebnis (die Form der Tour) frühzeitig stabilisiert.
Heuristik 5: Lin-Kernighan
Hier versucht man, eine bestehende Tour zu verbessern, indem man zwei Kantenpaare AB
und CD sucht und durch die Kanten AC und BD ersetzt (so genannte 2-opt-Strategie). Im
endgültigen Lin-Kernighan-Verfahren werden nicht nur Kantenpaare, sondern Mengen von
Kanten ersetzt.
0-13
Wie wir sehen, führen konträre Ideen manchmal beide zu guten Ergebnissen. Es ist sehr
schwer, die „Güte“ von Heuristiken abzuschätzen, da die Leistung immer vom verwendeten
Beispiel abhängt. Für jede der genannten Heuristiken lassen sich Beispiele konstruieren, so
dass das Ergebnis schlecht ist (die doppelte Länge der optimalen Tour oder noch mehr hat).
Ist es nicht möglich, eine Heuristik zu finden, die „auf jeden Fall“ ein akzeptables Resultat
liefert?
Auch hier hat die praktische Informatik Beiträge zu liefern. S. Arora konstruierte 1996 einen
„nahezu linearen“ randomisierten Algorithmus für das euklidische TSP, der für eine beliebige
Konstante c eine (1+1/c)-Approximation in O(n log(n)O(c)) Zeit konstruiert
(http://www.cs.princeton.edu/~arora/pubs/tsp.ps). Das heißt, z.B. für c=10 bekommt man eine
Tour, die höchstens 10% schlechter als die optimale ist, indem wir jeden Knoten
durchschnittlich log(n)10 mal betrachten. Wie wir oben gesehen haben, ist log(n) „fast“ eine
Konstante (in allen praktischen Fällen kleiner als 5). Daher ist dies „fast“ ein linearer
Algorithmus. Der Algorithmus basiert auf raffinierten geometrischen Überlegungen, nämlich
einer baumartigen Zerlegung der Ebene in Quadrate und einer Normierung der Schnittkanten
der Verbindungslinien zwischen den Punkten.
0-14
0.3 Was ist Informatik?
Das Wort „Informatik“ ist ein Kunstwort, welches aus den Bestandteilen „Information“ und
„Automatik“ zusammengesetzt ist. Der Begriff „Informatique“ wurde 1962 in Frankreich von
P. Dreyfuss geprägt und 1968 vom Forschungsminister Stoltenberg in Berlin bei der
Eröffnung einer Tagung übernommen (http://zeitung.informatica-feminale.de/?p=72,
http://atrax.uni-muenster.de:8010/Studieren/Scripten/Lippe/geschichte/pdf/Kap1.pdf). Da die
Informatik also eine vergleichsweise junge Wissenschaft ist, die eine stürmische Entwicklung
durchläuft, gibt es die verschiedensten Definitionen davon, was unter Informatik zu verstehen
ist.
Als Beispiel sei hier die Studienordnung der HU von 2003 angeführt:
(1) Die Informatik erforscht die grundsätzlichen Verfahrensweisen der Informationsverarbeitung und die allgemeinen Methoden der Anwendung solcher Verfahren in den
verschiedensten Bereichen. Ihre Aufgabe ist es, durch Abstraktion und Modellbildung von
speziellen
Gegebenheiten
sowohl
der
technischen
Realisierung
existierender
Datenverarbeitungsanlagen als auch von Besonderheiten spezieller Anwendungen abzusehen
und dadurch zu den allgemeinen Gesetzen, die der Informationsverarbeitung zugrunde liegen,
vorzustoßen sowie Standardlösungen für Aufgaben der Praxis zu entwickeln. Die Informatik
befasst sich deshalb mit
— der Struktur, der Wirkungsweise, den Fähigkeiten und den Konstruktionsprinzipien von
Informations- und Kommunikationssystemen und ihrer technischen Realisierung.
— Strukturen, Eigenschaften und BeschreibungsmögIichkeiten von Informationen und von
Informationsprozessen,
— Möglichkeiten der Strukturierung, Formalisierung und Mathematisierung von
Anwendungsgebieten sowie der Modellbildung und Simulation.
Dabei spielen Untersuchungen über die Effizienz der Verfahren und über Sinn und Nutzen
ihrer Anwendung in der Praxis eine wichtige Rolle.
Andere Fachbereiche haben ähnliche Festlegungen des Studieninhaltes. Als minimaler
Konsens für den Begriff „Informatik“ kann dabei die Definition angesehen werden, welche
sich aus der Wortbedeutung ergibt:
Informatik ist die Wissenschaft
der automatischen Verarbeitung von Informationen.
(Im Buch von Gumm/Sommer: „Informatik ist die Wissenschaft von der maschinellen
Informationsverarbeitung“.) In dieser Definition sind ein paar weitere undefinierte
Grundbegriffe enthalten: „Information“, „Verarbeitung“, „automatisch“ oder „maschinell“.
Der Begriff „Information“ ist ein metaphysischer Grundbegriff, mit dem wir uns noch näher
beschäftigen werden. An dieser Stelle sei nur bemerkt, dass wir unter „Information“ eine
Beschreibung irgendeines Sachverhaltes der uns umgebenden (materiellen oder ideellen) Welt
verstehen wollen. Vom Wortstamm her ist eine „Information“ etwas, was in eine bestimmte
Form gebracht worden ist, also auf eine bestimmte Weise repräsentiert wird (wenn wir eine
Information zur Kenntnis nehmen, bringen wir unseren Verstand in einen bestimmten
Zustand). Sehr einfache, wenig strukturierte Informationen bezeichnen wir als Daten (daher
der altertümliche Begriff „EDV“), eine Menge komplexer Informationen über ein
zusammenhängendes Gebiet bezeichnen wir als Wissen.
Unter der „Verarbeitung“ von Informationen verstehen wir den Prozess der Umformung, d.h.
der Veränderung von Informationen aus einer Form in eine andere. Da Informatik sich als
Wissenschaft mit der Verarbeitung von Informationen beschäftigt, sind ihr Gegenstand die
Verfahren, mit denen diese Umformung bewerkstelligt wird: solche Verfahren nennt man
0-15
Algorithmen. Häufig erfolgt heutzutage der räumliche Transport von Informationen dadurch,
dass sie beim Absender in eine einfachere Form gebracht (codiert) werden, durch einen
elementaren physikalischen Prozess (elektrische Ströme, Funkwellen etc.) übermittelt und
beim Empfänger wieder in die ursprüngliche Form zurückübersetzt (decodiert) werden. Daher
betrachtet man heute auch die Erforschung von Techniken zur Übertragung von
Informationen als Teilgebiet der Informatik.
Der dritte undefinierte Grundbegriff aus der obigen Definition ist „automatisch“. Damit soll
ausgedrückt werden, dass sich die Informatik nicht mit der Informationsverarbeitung durch
Menschen oder andere Lebewesen beschäftigt, sondern durch Automaten, d.h. vom Menschen
konstruierte Maschinen. Daher gehört zur Informatik auch das Wissen um den Aufbau und
die Entwicklung von Technologien zur Konstruktion informationsverarbeitender Geräte. Aus
historischen Gründen bezeichnet man diese Geräte oft auch als „Rechner“ (numerische
Berechnungen waren die ersten automatisierten informationsverarbeitenden Prozesse) oder
„Computer“. Daher hat sich im Englischen der Begriff „computer science“ für die Informatik
durchgesetzt. Dieser Begriff ist allerdings etwas missverständlich, da er suggerieren könnte,
dass Informatik die „Wissenschaft von den informationsverarbeitenden Geräten“ ist. Einige
Leute verwenden daher die Bezeichnung „computing science“.
Aus der Bestimmung des Gegenstands der Informatik ergibt sich unmittelbar, dass die
Informatik viele Bezüge zu anderen Disziplinen hat: Der Gleichklang zum Wort
„Mathematik“ ergibt sich nicht von ungefähr. Man kann mit Fug und Recht behaupten, dass
die Informatik aus der Mathematik erwachsen ist, so wie die Mathematik ihre Wurzeln in der
philosophischen Logik hat. Abgesehen davon, dass die Automatisierung numerischer
Berechnungen schon immer ein ureigenstes Interesse der Mathematik war, ist auch die
Beschäftigung mit abstrakten Begriffen wie „Berechnungsverfahren“ oder „Umformung“ ein
Gegenstand der Mathematik. Viele Pioniere der Informatik (Pascal, Leibniz, Babbage, Turing,
von Neumann, …) waren Mathematiker oder Logiker und haben sich mit den theoretischen
Grundlagen automatischer Berechnungsverfahren beschäftigt, bevor es überhaupt Computer
gab.
Die zweite Wurzel der Informatik ist die Elektrotechnik. Erst durch den Einsatz elektrischer
Schaltungen und Verfahren nach dem zweiten Weltkrieg (durch Zuse, Aiken und andere)
wurde gegenüber den davor existierenden mechanischen Rechnern (Hollerith) ein so großer
Durchbruch erzielt, dass man über numerische Rechnungen hinausgehen konnte. Da praktisch
alle heute existierenden informationsverarbeitenden Prozesse in Automaten auf der Bewegung
von elektrischen Ladungen beruhen, ist klar, dass auch heute noch eine enge Verwandschaft
zwischen Informatik und Elektrotechnik besteht. Auf der anderen Seite gehören Computer
heute mit zu den wichtigsten strombetriebenen Geräten, weshalb sich die Elektrotechnik heute
gerne auch als „Informationstechnik“ bezeichnet,
Durch die Inhalte der Informatik ergeben sich weiterhin eine ganze Reihe von Querbezügen
zu anderen Disziplinen. Wenn Informationsverarbeitung zur Steuerung mechanischer Geräte,
etwa von Robotern oder Fertigungsstraßen, genützt wird, müssen Informatiker mit
Maschinenbauern und Produktionstechnikern zusammenarbeiten. Durch den Einsatz von
Computern zur Übertragung von Informationen per Funkwellen ist eine enge Beziehung zur
Nachrichtentechnik gegeben. Wegen der Notwendigkeit der Interaktion von Automaten und
Menschen (auf Benutzungs- und Konstruktionsebene) muss die Informatik auf Grundlagen
und Ergebnisse der Psychologie, Linguistik, Kommunikationswissenschaften und anderer
Fächer zurückgreifen. Da in fast allen Fächern informationsverarbeitende Prozesse
vorkommen, die bislang entweder von Menschen durchgeführt wurden oder wegen des hohen
Arbeitsaufwandes gar nicht durchgeführt werden konnten, werden Methoden der Informatik
in diesen Fächern für die Automatisierung der Verarbeitung von Informationen angewendet.
0-16
Dadurch haben sich eine Reihe spezialisierter Studiengänge gebildet, die so genannten
Bindestrich-Informatiken, die die Anwendung der Informatik in anderen Fächern betonen. Zu
nennen sind hier Wirtschafts-Informatik, Bio-Informatik, Medien-Informatik, GeoInformatik, Umwelt-, Rechts- oder Medizininformatik, und viele mehr. Wichtig: Mit einer
Ausbildung als Informatiker (ohne Bindestrich) kann man sich später für jede dieser
Disziplinen weiter qualifizieren!
Aus den geschilderten Wurzeln ergibt sich die heute übliche Struktur der Informatik: Man
teilt sie ein in
• theoretische Informatik
• praktische Informatik
• technische Informatik, und
• angewandte Informatik.
Die theoretische Informatik beschäftigt sich mit den formalen Grundlagen, die praktische
Informatik mit den Verfahren, die technische Informatik mit den Maschinen zur Verarbeitung
von Informationen. In der angewandten Informatik werden die Anwendungen der
Informationsverarbeitung für andere Fächer (z.B. Robotik, Bioinformatik, medizinische
Bildverarbeitung) untersucht. Oftmals wird die angewandte Informatik als Teil der
praktischen Informatik betrachtet; an einigen Universitäten studiert man im Grundstudium
zunächst ein beliebiges Nebenfach und spezialisiert sich dann im Hauptstudium auf die
angewandte Informatik in diesem Nebenfach.
0-17
Geschichte der Informatik
Informatik im eigentlichen Sinne gibt es erst seit dem Endes des zweiten Weltkrieges.
Die Wurzeln der Informatik reichen dagegen bis ins Mittelalter bzw. ins Altertum zurück:
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
300 v. Chr:
Euklid entwickelt sein Verfahren zur Bestimmung des größten gemeinsamen
Teilers (ggT)
um 820:
Al-Chwarizmi fasst in einem Buch Lösungen zu bekannten mathematischen
Problemen zusammen.
1524:
A. Riese veröffentlicht ein Buch über die Grundrechenarten
17-18.Jh.:
G. W. Leibniz (1646-1714) entwickelt das Dualsystem (1679) und baut eine
Rechenmaschine (1673/1694), Pascal, Schickard u.a. entwickeln ebenfalls mechanische
Rechenmaschinen, Babbage konzipiert „difference engine“, Ada Lovelace die erste
Programmiersprache dafür
Ende 19./ Anf. 20. Jh.: Formalisierung der logischen und mathematischen Grundlagen durch
Frege (“Begriffschrift”, 1879), Russell u. Whitehead (“Prinicipia mathematica", 1910-13),
Peano u.a.
Ende 19./ Mitte 20. Jh.: Perfektionierung mechanischer Rechenmaschinen;
1930-40:
Theorie der Berechenbarkeit, Vollständigkeits- und Entscheid-barkeitssätze
(Gödel, Turing, Tarski, Church, Kleene, Post, Markov u.a.)
1930-40:
erste elektromechanische Computer: Zuses Z1 (1936), Z3 (1943), Aikens
Mark1 (1944), Eckert+Mauchlys ENIAC (1946)
1948-49:
Konrad Zuse entwickelt seinen “Plankalkül”, C. Shannon seine
“Informationstheorie”, J. v. Neumann entwickelt den nach ihm benannten Rechnertyp: Daten
und Befehle werden gemeinsam im Rechner gespeichert und ähnlich behandelt.
1955:
Erfindung des Transistors
1959-60:
erste “höhere Programmiersprachen”: J. McCarthy entwickelt die funktionale
Programmiersprache LISP und begründet die “Artificial Intelligence”, Fortran (Masch.bau),
Cobol (BWL) und Algol (Math) werden definiert.
1969-70:
Entwicklung universaler Programmiersprachen wie Algol68 und PL/I
ab 1970:
Informatikstudium in Deutschland
ab 1980:
Objektorientierte Sprachen und Systeme
ab 1990:
Internet (Gopher, Mosaic), Mobilfunk (1991 D-Netz, 1994 E-Netz), WLAN
(ca. 1995), PDAs (1997), Java (1995), Java2 (2002)
Die Informatik ist heute in fast alle Aspekte unseres Lebens vorgedrungen:
• Einen Großteil ihres Studiums werden Sie mit „e-Learning“ verbringen, die
notwendigen Informationen beschaffen Sie sich im Internet. Vielleicht bestellen Sie
hier auch Waren oder vergleichen zumindest bei eBay die Preise.
• Dokumente (Briefe, Steuererklärungen usw.) verfassen Sie natürlich am Computer;
mit Ihren Kommilitonen nebenan und dem Onkel in Amerika tauschen Sie e-Mails
aus, die den Empfänger in wenigen Sekunden erreichen.
• Wahrscheinlich haben Sie auch ein Mobiltelefon, vielleicht sogar ein Notebook mit
WLAN, oder einen elektronischen Organizer.
• Ihr Bankkonto wird von einem Computer geführt, Bargeld holen Sie am
Geldautomaten, vielleicht habe Sie auch eine elektronische Brieftasche.
• Wenn Sie mit dem Auto nach Hause fahren, begleiten Sie bis zu 80 eingebaute
Steuergeräte, das Auto ist weitgehend von Robotern gebaut worden. Sogar die
Schnittmuster Ihrer Kleidung wurden vom Computer optimiert.
• Ihre Armbanduhr, Foto- oder Filmapparat, Ton- und Bildwiedergabegeräte sind schon
längst nicht mehr mechanisch, ganz zu schweigen von der Türschließanlage,
Fahrstuhlsteuerung, Kühlschrank, Mikrowelle, Waschmaschine, und anderen Geräten
zu Hause.
0-18
Das ist aber keinesfalls das Ende der Entwicklung. In ein paar Jahren wird Sie wahrscheinlich
die Türschließanlage an Ihrem Aussehen und Fingerabdruck erkennen, Sie werden sich
vielleicht wie im Roman „per Anhalter durch die Galaxis“ mit dem Fahrstuhl unterhalten, der
Kühlschrank könnte Vorräte selbsttätig nachbestellen, der Herd sich Rezepte aus dem Internet
holen und die Waschmaschine wissen, wie heiß die Wäsche gewaschen werden muss.
Alle diese „Wunder der Technik“ werden möglich durch systematische Vorschriften für die
Verarbeitung von Informationen (Algorithmen, Programme) und Maschinen, die diese
Vorschriften ausführen können (Computer, Prozessoren). Natürlich können wir uns in einer
Vorlesung nicht mit allen oben genannten Anwendungen beschäftigen, aber die zentralen
Gesichtspunkte die in allen gleichermaßen vorhanden sind, bilden den Gegenstand der
Vorlesung: Algorithmen und ihre Ausführung auf Rechenanlagen.
Das zentrale Ziel der Vorlesung ist es, eine „algorithmische Denkweise“ zu vermitteln: Ein
Verständnis dafür, wann und wie ein (informationsbezogenes) Problem mit welchem
Aufwand durch eine Maschine gelöst werden kann. Inhaltlich gibt die Vorlesung einen
Überblick über das Gebiet der praktischen Informatik. Dazu gehören unter anderem folgende
Themen:
• Repräsentation von Informationen in Rechenanlagen
• programmiersprachliche Konzepte
• Methoden der Softwareentwicklung
• Algorithmen und Datenstrukturen
• Korrektheit und Komplexität von Programmen
In den weiteren Vorlesungen des Bachelor-Studiums werden diese Themen ergänzt und
vertieft.
0-19
Kapitel 1: Mathematische Grundlagen
1.1 Mengen, Multimengen, Tupel, Funktionen, Halbordnungen
Eine Menge ist eine Zusammenfassung von (endlich oder unendlich vielen) verschiedenen
Dingen unserer Umwelt oder Vorstellungswelt, welche Elemente dieser Menge genannt
werden. Wir schreiben x ∈ M , um auszusagen, dass das Ding x Element der Menge M ist.
Andere Sprechweisen: x ist in M enthalten, oder M enthält x .
Um auszudrücken, dass x nicht in M enthalten ist, schreiben wir x ∉ M . Die Schreibweise
x1 ,..., x n ∈ M steht für x1 ∈ M und … und x n ∈ M , und {x1 ,..., x n } ist die Menge, die genau
die Elemente x1 ,..., x n enthält.
Beispiele für Mengen sind:
•
N: Die Menge der natürlichen Zahlen 1,2,3,…
•
N0: Die Menge der Kardinalzahlen (natürlichen Zahlen einschließlich der Null):
0,1,2,3,…
•
Z: Menge der ganzen Zahlen …, -3, -2, -1, 0, 1, 2, 3, …
•
Q, R: Menge der rationalen bzw. reellen Zahlen
•
•
∅ oder { }: leere Menge
B oder boolean = {true, false} oder {1,0} oder {tt, ff} oder {w, f} oder
{L,O}: Menge der Wahrheitswerte
{Adam, Eva}: Menge der ersten Menschen
{A, B, C, …, Z}: Menge der Großbuchstaben im lateinischen Alphabet
•
•
In den Programmiersprachen Groovy und Java gibt es die folgenden vordefinierten Mengen:
• Integer oder int: {-2147483648 … 2147483647}
• Short: {-32768, …, 32767}
• Byte: {-128, …, -127} Menge der ganzen Zahlen zwischen -128 und 127
• Long: {-9223372036854775808, …,-9223372036854775808} (Postfix „L“)
• BigInteger: „große“ natürliche Zahlen (Postfix „g“, z.B. 45g)
• Dezimalzahlen, Gleitkommazahlen,
• Characters und Strings, Boolean
Die Menge M 1 ist eine Teilmenge von M 2 ( M 1 ⊆ M 2 ), wenn jedes Element von M 1 auch
Element von M 2 ist. Die Mengen M 1 und M 2 sind gleich ( M 1 = M 2 ), wenn sie die gleichen
Elemente enthalten (Extensionalitätsaxiom). Für jede (endliche) Menge M bezeichnet | M | die
Kardinalität, d.h. die Anzahl ihrer Elemente. Neben der Aufzählung ihrer Elemente können
Mengen durch eine charakterisierende Eigenschaft gebildet werden (Komprehensionsaxiom).
Beispiel: byte = {x∈ Z | -128≤x und x≤127}. Auf diese Weise können auch unendliche
Mengen gebildet werden.
Die unbeschränkte Verwendung der Mengenkomprehension kann zu Schwierigkeiten führen
(Russells Paradox: „die Menge aller Mengen, die sich nicht selbst enthalten“), daher erlaubt
man in der axiomatischen Mengenlehre nur gewisse Eigenschaften.
Auf Mengen sind folgende Operationen definiert:
Durchschnitt: M 1 ∩ M 2 = {x | x ∈ M 1 ∧ x ∈ M 2 } .
Vereinigung: M 1 ∪ M 2 = {x | x ∈ M 1 ∨ x ∈ M 2 } .
1-20
-
Differenz: M 1 − M 2 = {x | x ∈ M 1 ∧ x ∉ M 2 } .
Beispiele: N∪N0=N0 , N∩N0=N , N – N0 =∅, N0 – N =0. Durchschnitt und Vereinigung sind
kommutativ und assoziativ. Daher kann man diese Operationen auf beliebige Mengen von
Mengen ausweiten:
{M 1 , M 2 ,..., M n } = {x | x ∈ M 1 ∧ x ∈ M 2 ∧ ... ∧ x ∈ M n } .
-

i∈I
M i = {x | x ∈ M i für ein i ∈ I } .
Von Mengen kommt man zu „Mengen höherer Ordnung“ durch die Potenzmengenbildung:
Wenn M eine Menge ist, so bezeichnet ℘(M ) oder 2 M die Menge aller Teilmengen von M .
Cantor bewies, dass die Potenzmenge einer Menge immer mehr Elemente enthält als die
Menge selbst. Speziell gilt für jede endliche Menge M : Wenn | M |= n , so ist | ℘( M ) |= 2 n .
Beispiel: ℘(∅) = {∅} , ℘({∅}) = {∅, {∅}} und
℘({ A, B, C}) = {∅, { A}, {B}, {C}, { A, B}, { A, C}, {B, C}, { A, B, C}} .
Multimengen
Während Mengen die grundlegenden Daten der Mathematik sind, hat man es in der
Informatik oft mit Multimengen zu tun, bei denen Elemente „mehrfach“ vorkommen können.
Beispiele sind
• eine Tüte mit roten, gelben und grünen Gummibärchen,
• die Vornamen der Studierenden dieser Vorlesung,
• die Multimenge der Buchstaben eines bestimmten Wortes, usw.
Multimengen können notiert werden, indem man zu jedem Element die entsprechende
Vielfachheit angibt, z.B. ist {A:3, B:1, N:2} die Multimenge der Buchstaben im Wort
BANANA. Formal können Multimengen definiert werden als Funktionen von einer
Grundmenge in die Menge N0 der Kardinalzahlen, siehe unten.
Folgen
Aus Mengen lassen sich durch Konkatenation Tupel und Folgen bilden. Der einfachste Fall ist
dabei die Paarbildung mit dem kartesischen Produkt. Wenn M 1 und M 2 Mengen sind, so
bezeichnet M 1 × M 2 = {( x, y ) | x ∈ M 1 ∧ y ∈ M 2 } die Menge aller Paare von Elementen,
deren erster Bestandteil ein Element aus M 1 und deren zweiter eines aus M 2 ist. Da die
runden Klammern für vielerlei Zwecke verwendet werden, verwendet man manchmal zur
Kennzeichnung von Paaren auch spitze oder, besonders in Programmiersprachen, eckige
Klammern.
Beispiele: N×B ={(1,tt),(1,ff),(2,tt),(2,ff), (3,tt),…}, N×∅=∅,
N×{0}={(1,0), (2,0), (3,0), …}, N×N0={(1,0), (1,1), (1,2), …, (2,0), (2,1), …}
Eine Verallgemeinerung ist das n-stellige kartesische Produkt, mit dem n-Tupel gebildet
werden:
M 1 × M 2 × ⋅ ⋅ ⋅ × M n = {( x1 , x 2 ,..., x n ) | x1 ∈ M 1 ∧ x 2 ∈ M 2 ∧ ... ∧ x n ∈ M n }
2-Tupel sind also Paare, statt 3-, 4- oder 5-Tupel sagt man auch Tripel, Quadrupel, Quintupel
usw. Die zur Produktbildung umgekehrten Operationen, mit denen man aus einem Produkt
die einzelnen Bestandteile wieder erhält, bezeichnet man als Projektionen:
Π i ( x1 , x 2 ,..., x n ) = xi
Falls alle M i gleich sind ( M 1 = M 2 = ⋅ ⋅ ⋅ = M n = M ) , so schreiben wir statt
M × M × ⋅ ⋅ ⋅ × M auch M n und nennen ( x1 , x 2 ,..., x n ) eine Folge oder Sequenz der Länge n
1-21
über M . In Programmiersprachen heißen Folgen auch Arrays, Felder oder Reihungen.
Wichtige Spezialfälle sind n = 1 und n = 0 . Im ersten Fall ist die einelementige Folge (x)
etwas anderes als das Element x. Im zweiten Fall ist die leere Folge () unabhängig von der
verwendeten Grundmenge. Achtung: Die leere Folge ist nicht zu verwechseln mit der leeren
Menge!
M n enthält nur Sequenzen einer bestimmten fest vorgegebenen Länge n. Unter M * verstehen
wir die Menge, die alle beliebig langen Folgen über M enthält:
M * = {M i | i ∈ N 0 } .
Wenn wir nur nichtleere Folgen betrachten wollen, schreiben wir M + :
M+ = M* −M0 .
Eine Liste in der Programmiersprache Groovy ist ein Element von M * , wobei M die Menge
aller Objekte (Zahlen, Buchstaben, Listen, …) ist.
Beispiele: [1, 3, 5, 7] oder [17.5, "GP", 1.234f, 'a', 1e99]
Hier sind einige Groovy-Tatsachen zu Listen (http://groovy.codehaus.org/JN1015Collections). In Java können diese Listen mit dem public interface List nachgebildet
werden (http://java.sun.com/j2se/1.4.2/docs/api/java/util/List.html).
assert [0, "a", 3.14].class == java.util.ArrayList
assert [0, 4, 7] + [11] == [0, 4, 7, 11]
assert [0, 4, 7] - [4] == [0, 7]
assert [0, "a", 3.14] * 2 == [0, "a", 3.14, 0, "a", 3.14]
assert [0, 4, 7] != [0, 7, 4]
assert [] != [0]
assert [] != [[]]
assert [[],[]] != [[]]
assert [[],[]].size == 2
assert [0,4,7][2] == 7
x=[0,4,7]; assert x[2] == 7
x=[0,4,7]; x[2]=3; assert x == [0,4,3]
x=[0,4,7]; x[2]=3; x[5]=2; assert x == [0, 4, 3, null, null, 2]
x=[0,4,7]; assert x.contains(4)
x=[0,4,7]; assert (7 in x)
x=[0,4,7]; x.each{println(it + " sq = " + (it*it))}
Mengen können als spezielle (ungeordnete) Listen aufgefasst werden, bei denen jedes
Element nur einmal vorkommt und die Reihenfolge egal ist:
Set x=[0,4,7], y= [7,4,0]; assert x == y
Set z=[0,4,7,4,0]; assert z == y
Relationen und Funktionen
Eine Relation zwischen zwei Mengen M 1 und M 2 ist eine Teilmenge von M 1 × M 2 . Wenn
zum Beispiel M={Anna, Beate} und J={Claus, Dirk, Erich}, so ist liebt={(Anna, Claus),
(Beate, Dirk), (Beate, Erich)} eine Relation zwischen M und J. Relationen schreibt man meist
in Infixnotation, d.h., statt (Beate, Dirk)∈liebt schreibt man (Beate liebt Dirk). Falls
M 1 = M 2 = M , so sagen wir, dass die Relation über M definiert ist. Typische Beispiele sind
die Relationen ≤ und = über den natürlichen Zahlen, oder die Verbindungsrelation zwischen
Städten im Streckennetz der Air Berlin. Eine Relation R heißt (links-)total, wenn es zu jedem
x ∈ M 1 ein y ∈ M 2 mit (xRy ) gibt. Sie heißt (rechts-)eindeutig, wenn es zu jedem
1-22
x ∈ M 1 höchstens ein y ∈ M 2 mit (xRy ) gibt. Eine eindeutige Relation nennt man Abbildung
oder partielle Funktion, eine totale und eindeutige Relation heißt Funktion. Bei Funktionen
schreiben wir f : M 1 
→ M 2 und f ( x) = y für f ⊆ M 1 × M 2 und ( x, y ) ∈ f . Die Menge
der x ∈ M 1 , für die es ein y ∈ M 2 mit (xRy ) gibt, heißt der Definitionsbereich oder
Urbildbereich (domain) der Abbildung; die Menge der y ∈ M 2 , für die es ein
x ∈ M 1 mit (xRy ) gibt, heißt der Wertebereich oder Bildbereich (range) der Funktion oder
Abbildung.
Eine Funktion mit endlichem Definitionsbereich lässt sich angeben durch Auflistung der
Menge der Paare (x,y) mit f ( x) = y . In Groovy nennt man eine solche Funktion Map und
notiert sie [x1:y1, x2:y2,....,xn:yn], also z.B.
[1:5, 2:10, 3:15] oder
["Name":"Anton", "id":573328], aber auch
[3.14:'a', "a":2010, 10:10, 7e5:0]
Beachte: [1:5, 2:10, 3:15] == [2:10, 3:15, 1:5]
Hier wieder einige Groovy Tatsachen über Maps (http://groovy.codehaus.org/JN1035-Maps):
assert [A:3, B:1, N:2]
assert [A:3, B:1, N:2].B == 1
assert [A:3, B:1, N:2] == [B:1, A:3, N:2]
assert [A:3, B:1, N:2] + [C:5] == [A:3, B:1, N:2, C:5]
assert [A:3, B:1, N:2] + [A:1] == [A:1, B:1, N:2]
// [A:3, B:1, N:2] - [A:1] ist undefiniert
// [97:"a", 98:"b", 99:"c"].98 == 'b' ist ein Syntaxfehler
assert [97:"a", 98:"b", 99:"c"].get(98) == "b"
assert [97:"a", 98:"b", 99:"c"].get(100) == null
x=[:]; x[97]="a"; x[98]="b"; assert x == [97:"a", 98:"b"]
Genau wie oben lassen sich auch die Begriffe Relation und Funktion verallgemeinern. Eine nstellige Relation zwischen den Mengen M 1 , M 2 ,⋅ ⋅ ⋅, M n ist eine Teilmenge von
M 1 × M 2 × ⋅ ⋅ ⋅ × M n . Einstellige Relationen heißen auch Prädikate. Auch für Prädikate
schreiben wir (Px) anstatt von (x) ∈ P. Eine n-stellige Funktion f von M 1 , M 2 ,⋅ ⋅ ⋅, M n nach
M ist eine (n+1)-stellige Relation zwischen M 1 , M 2 ,⋅ ⋅ ⋅, M n und M , so dass für jedes nTupel ( x1 , x 2 ,..., x n ) ∈ M 1 × M 2 × ⋅ ⋅ ⋅ × M n genau ein y ∈ M existiert mit ( x1 , x 2 ,..., x n , y ) ∈ f .
Eine Funktion f : M × M × ⋅ ⋅ ⋅ × M 
→ M heißt (n-stellige) Operation auf M . Beispiele
für zweistellige Operationen sind + und * auf N, Z, Q und R. Die Differenz – ist auf Z, Q
und R eine Operation, auf N ist sie nur partiell (nicht total). Die Division ist in jedem Fall nur
partiell. Typische Prädikate auf natürlichen Zahlen sind prim oder even.
Wir haben Funktionen als spezielle Relationen definiert, Es gibt auch die Auffassung, dass
der Begriff „Funktion“ grundlegender sei als der Begriff „Relation“, und dass Relationen eine
spezielle Art von Funktionen sind. Sei M 1 ⊆ M 2 . Dann ist die charakteristische Funktion
ζ : M2 
→ B von M 1 in M 2 definiert durch ζ (x) = true falls x ∈ M 1 und ζ (x) = false
falls x ∉ M 1 . Mit Hilfe der charakteristischen Funktion kann jede Relation zwischen den
Mengen M 1 , M 2 ,⋅ ⋅ ⋅, M n als Funktion von M 1 , M 2 ,⋅ ⋅ ⋅, M n nach B aufgefasst werden. Diese
Auffassung findet man häufig in Programmiersprachen, bei denen Prädikate als boolesche
Funktionen realisiert werden.
1-23
Ordnungen
Die Relation R über M heißt
•
reflexiv, wenn für alle x ∈ M gilt, dass xRx .
•
irreflexiv, wenn für kein x ∈ M gilt dass xRx .
•
transitiv, wenn für alle x, y, z ∈ M mit xRy und yRz gilt, dass xRz .
•
symmetrisch, wenn für alle x, y ∈ M mit xRy gilt, dass yRx .
• antisymmetrisch, wenn für alle x, y ∈ M mit xRy und yRx gilt x = y .
Eine reflexive, transitive, symmetrische Relation heißt Äquivalenzrelation.
Eine reflexive, transitive, antisymmetrische Relation heißt Halbordnung oder partielle
Ordnung. Eine irreflexive, transitive, antisymmetrische Relation heißt strikte Halbordnung.
Eine partielle Ordnung heißt totale oder lineare Ordnung, wenn für alle x, y ∈ M gilt,
dass xRy oder yRx . Bei einer totalen Ordnung lassen sich alle Elemente „der Reihe nach“
anordnen. Die Relation ≤ ist eine totale Ordnung auf natürlichen und reellen Zahlen, nicht
aber auf komplexen Zahlen. Ein einfacheres Beispiel für eine Halbordnung über Zahlen ist die
Relation „ist Teiler von“.
Beispiel für reflexive, aber nicht antisymmetrische Relation:
Beispiel für eine antisymmetrische, aber nicht reflexive Relation:
1.2 Induktive Definitionen und Beweise
Für fast alle in der Informatik wichtigen Datentypen besteht ein direkter Zusammenhang
zwischen ihrer rekursiven Definition (ihrem rekursiven Aufbau) und induktiven Beweisen
von Eigenschaften dieser Daten. Wir wollen uns diese Dualität am Beispiel der natürlichen
Zahlen betrachten.
Die natürlichen Zahlen lassen sich durch die folgenden so genannten Peano-Axiome
definieren:
• 1 ist eine natürliche Zahl.
• Für jede natürliche Zahl gibt es genau eine natürliche Zahl als Nachfolger.
• Verschiedene natürliche Zahlen haben auch verschiedene Nachfolger.
• 1 ist nicht der Nachfolger irgendeiner natürlichen Zahl.
• Sei P eine Menge natürlicher Zahlen mit folgenden Eigenschaften:
o 1 ist in P
o Für jede Zahl in P ist auch ihr Nachfolger in P.
Dann enthält P alle natürlichen Zahlen.
Das letzte dieser Axiome ist das so genannte Induktionsaxiom. Es wird oft in der Form
gebraucht, dass P eine Eigenschaft natürlicher Zahlen ist:
• Sei P eine Eigenschaft natürlicher Zahlen, so dass P(1) gilt und aus P(i) folgt P(i+1).
Dann gilt P für alle natürlichen Zahlen.
Der erste Mathematiker, der einen formalen Beweis durch vollständige Induktion angab, war
der italienische Geistliche Franciscus Maurolicus (1494 -1575). In seinem 1575
veröffentlichten Buch „Arithmetik“ benutzte Maurolicus die vollständige Induktion unter
1-24
anderem dazu, zu zeigen, dass alle Quadratzahlen sich als Summe der ungeraden Zahlen bis
zum doppelten ihrer Wurzel ergeben:
1 + 3 + 5 + ... + (2n-1)=n*n
Beweis: Sei P die Menge natürlicher Zahlen, die diese Gleichung erfüllen. Um zu beweisen,
dass P alle natürlichen Zahlen enthält, müssen wir zeigen
1=1*1
Wenn 1 + 3 + 5 + ... + (2n-1)=n*n,
dann 1 + 3 + 5 + ... + (2n-1)+(2(n+1)-1)=(n+1)*(n+1)
Die Wahrheit dieser Aussagen ergibt sich durch einfaches Ausrechnen.
Hier ist ein geringfügig komplizierteres Beispiel zum selber machen: Die Summe der
Kubikzahlen bis n ist das Quadrat der Summe der Zahlen bis n.
2
 n 
=
i
∑i
∑
i =1
 i =1 
Ein wichtiger Gesichtspunkt beim Induktionsaxiom ist, dass die natürlichen Zahlen als
„induktiv aufgebaut“ dargestellt werden gemäß den folgenden Regeln:
n
3
• 1∈N.
• Wenn i∈N, dann auch i+1∈N.
• Außer den so erzeugten Objekten enthält N keine weiteren Zahlen.
Mit anderen Worten, jede Zahl wird erzeugt durch die endlich-oft-malige Anwendung der
Operation (+1) auf die Zahl 1.
Das Induktionsaxiom erlaubt es, Funktionen über den natürlichen Zahlen rekursiv zu
definieren. Eine rekursive Definition nimmt dabei auf sich selbst Bezug. Solche Definitionen
können leicht schief gehen („das Gehalt berechnet sich immer aus dem Gehalt des letzten
Jahres plus 3%“ oder „Freiheit ist immer die Freiheit der Andersdenkenden“ oder „GNU is
short for »GNU is Not Unix«“). Ein Begriff, der durch solch eine zirkuläre Definition erklärt
wird, ist nicht wohldefiniert. Eine Funktion ist nur dann wohldefiniert, wenn sich der
Funktionswert eindeutig aus den Argumenten ergibt. Das Prinzip der vollständigen Induktion
erlaubt es nun, eine Funktion über den natürlichen Zahlen dadurch zu deklarieren, dass man
den Funktionswert für n=1 angibt, und indem man zeigt, wie sich der Funktionswert für (n+1)
aus dem Funktionswert für n berechnen lässt. Wenn man nämlich für P die Aussage „der
Funktionswert ist eindeutig bestimmt“ einsetzt, so besagt dass Induktionsprinzip, dass dann
der Funktionswert für alle natürlichen Zahlen eindeutig bestimmt ist. Zum Beispiel lässt sich
die Fakultätsfunktion n!=1*2*…*n ohne „Pünktchen“ dadurch definieren, dass wir festlegen
• 1! = 1
• Wenn n!=x, dann ist (n+1)!=(n+1)*x
Eine andere Schreibweise der zweiten Zeile ist
• (n+1)!=(n+1)*n!
Da in dieser Formel „n“ nur ein Stellvertreter für eine beliebige Zahl ist, können wir auch
schreiben
• Wenn n>1, dann ist n!=n*(n-1)!
Diese Schreibweise ist sehr nahe an der Schreibweise in Programmiersprachen, beispielsweise
(in Groovy):
• def fac(n){ if (n==1) return(1) else return(n*fac(n-1)) }
• def fac(n){ (n==1)? 1 : n*fac(n-1) }
In der vorgestellten Fassung erlaubt es das Induktionsprinzip nur, bei der Definition von f(n)
auf den jeweils vorherigen Wert f(n-1) zurückzugreifen. Eine etwas allgemeinere Fassung ist
das Prinzip der transfiniten Induktion:
1-25
•
Sei P eine Eigenschaft natürlicher Zahlen, so dass für alle x∈N gilt:
Falls P(y) für alle y<x, so auch P(x). Dann gilt P für alle natürlichen Zahlen.
Dieses Prinzip gilt nicht nur für die natürlichen Zahlen, sondern für beliebige fundierte
Ordnungen (in denen es keine unendlich langen absteigenden Ketten gibt). Der
Induktionsanfang ergibt sich dadurch, dass es keine kleinere natürliche Zahl als 1 gibt und für
die 1 daher nichts vorausgesetzt werden kann. Im Induktionsschritt erlaubt uns dieses Prinzip,
auf beliebige vorher behandelte kleinere Zahlen zurückzugreifen. Das Standardbeispiel sind
hier die Fibonacci-Zahlen (nach Leonardo di Pisa, filius Bonacci, 1175-1250, der das
Dezimalsystem in Europa einführte)
1, falls n ≤ 2

fib(n) = 
 fib(n − 1) + fib(n − 2), sonst
oder, in der programmiersprachlichen Fassung (in Groovy),
def fib(n){ (n<=2)? 1 : fib(n-1) + fib(n-2) }
Die Werte dieser Funktion sind, der Reihe nach, 1,1,2,3,5,8,13,21,… und sollen das
Bevölkerungswachstum von Kaninchenpaaren nachbilden. Als Beispiel für einen Beweis, der
auf mehrere Vorgänger zurückgreift, zeigen wir die Formel von Binet:
n
n
1  1 + 5   1 − 5  
 −
 .

fib(n) =
5  2   2  


1+ 5
1− 5
Als Lemma benötigen wir, dass für ξ =
gilt ξ + 1 = ξ 2 , und ebenso für ς =
.
2
2
Das sieht man durch einfaches Ausrechnen, ebenso die Gültigkeit der Aussage für n=1,2.
1 n −1
ξ − ς n −1 und
Damit können wir als Induktionsannahme voraussetzen, dass fib(n − 1) =
5
1 n−2
ξ − ς n − 2 . Mit fib(n) = fib(n − 1) + fib(n − 2) ergibt sich
fib(n − 2) =
5
1 n −1
fib(n) =
ξ + ξ n − 2 − ς n −1 − ς n − 2 , d.h.,
5
1
1 2 n−2
1 n
fib(n) =
ξ ξ − ς 2ς n − 2 =
ξ − ς n , was zu zeigen
(ξ + 1)ξ n − 2 − (ς + 1)ς n − 2 =
5
5
5
war.
[
[
]
]
[
]
[
]
[
]
[
]
Die Berechnung der Fibonacci-Zahlen mit der Formel von Binet geht erheblich schneller als
mittels der rekursiven Definition:
def binet(n){
((((1 + Math.sqrt(5))/2)**n - ((1-Math.sqrt(5))/2)**n)/Math.sqrt(5))}
binet(50) ergibt sofort 1.258626902500002E10
Im Allgemeinen ist es nicht immer möglich, solch eine geschlossene (nichtrekursive) Formel
für eine rekursiv definierte Funktion zu finden.
Wir haben das Induktionsprinzip für natürliche Zahlen und der fundierten Ordnungsrelation <
angewendet. Dies ist nur ein Spezialfall des folgenden allgemeinen Prinzips für induktiv
erzeugte Mengen.
Das sind Mengen, die definiert werden durch
1-26
•
•
die explizite Angabe gewisser Elemente der Menge,
Regeln zur Erzeugung weiterer Elemente aus schon vorhandenen Elementen der
Menge
sowie der expliziten oder impliziten Annahme, dass die Menge nur die so erzeugten
Elemente enthält.
Für induktiv erzeugte Mengen gilt folgendes allgemeine Induktionsprinzip:
Sei P eine Eigenschaft, die Elemente der Menge haben können oder nicht, so dass
• P für alle explizit angegebenen Elemente der Menge gilt, und
• P für alle gemäß den Bildungsregeln erzeugten Elemente gilt, falls es für die bei der
Erzeugung verwendeten Elemente gilt.
Dann gilt P für alle Elemente der Menge.
Beispiele für dieses Erzeugungsprinzip werden später betrachtet.
1.3 Alphabete, Wörter, Bäume, Graphen
Unter einem Alphabet A versteht man eine endliche Menge von Zeichen A={a1, …, an}. Das
bekannteste Beispiel ist sicher das lateinische Alphabet mit den Zeichen A, B, C, …, Z. Aber
bereits davon gibt es verschiedene Varianten, man denke nur an das deutsche Alphabet mit
Umlauten ä, ö, ü und der Ligatur ß. Im Laufe der Zeit haben sich bei den Völkern Hunderte
von Alphabeten gebildet, von Keilschriften und Hieroglyphen bis hin zu Runen- und
Geheimschriften (http://www.schriftgrad.de/). Das chinesische Alphabet umfasst etwa 56000
Zeichen, im Alltag kann man mit 6.000 Schriftzeichen schon relativ gut auskommen; der
chinesische Zeichensatz für Computer enthält 7.445 Schriftzeichen. Der ASCII-Zeichensatz
enthält 128 bzw. (in der erweiterten Form Latin-1) 256 Druckzeichen, siehe Tabelle. Der
Unicode- oder UCS-Zeichensatz umfasst etwa 100.000 Zeichen
http://de.wikipedia.org/wiki/Unicode. Man beachte, dass in manchen Alphabeten das
Leerzeichen als ein Zeichen enthalten ist; als Ersatzdarstellung wählt man häufig eine
Unterstrich-Variante. Ein für die Informatik wichtiges Alphabet ist die Menge B der
Wahrheitswerte.
Eine (endliche) Folge w∈A* von Zeichen über einem Alphabet A heißt Wort oder Zeichenreihe (string) über A. Normalerweise schreibt man, wenn es sich um Wörter handelt, statt w =
(a1,a2,…,an) kurz w = "a1a2…an", manchmal werden die Anführungszeichen auch weggelassen. Die leere Zeichenreihe wird mit dem Symbol ε oder mit "" bezeichnet. Über Wörtern
ist die Konkatenation (Hintereinanderschreibung) als Operation definiert: Wenn v = a1a2…an
und w = b1b2…bm, dann ist v°w = a1a2…anb1b2…bm. Da die Operation ° assoziativ ist ((u ° v) °
w = u ° (v ° w)), wird das Operationssymbol ° manchmal auch einfach weggelassen.Die leere
Zeichenreihe ε ist bezüglich ° ein neutrales Element (w ° ε = ε ° w = w).
1-27
Eine Menge mit einer assoziativen Operation und einem neutralem Element nennt man auch
Monoid; da die Menge A* (mit der Operation ° und dem neutralen Element ε ) keinen weiteren
Einschränkungen unterliegt, heißt sie auch der freie Monoid über A (wenn a1a2…an =
b1b2…bm, mit (ai, bi ∈A), so ist n=m und a1= b1 und… und an= bn).
Die Menge der Wörter über einem gegebenen Alphabet lässt sich auch induktiv erzeugen:
• ε ∈A*
• Wenn a∈A und w∈A*, so ist (a°w)∈A*.
Hierbei bezeichnet (a°w) diejenige Zeichenreihe, die als erstes Zeichen a enthält und danach
das Wort w. Alternativ dazu hätten wir Wörter induktiv durch das Anfügen (append) von
Zeichen an Zeichenreihen erzeugen können. Diese Charakterisierung der Menge der
Zeichenreihen erlaubt es, induktive Beweise zu führen und rekursive Funktionen über
Wörtern zu definieren.
Sei first(w) die partielle Funktion, die zu einem nichtleeren Wort dessen erstes Zeichen liefert,
und rest(w) die Funktion, die das Wort ohne das erste Zeichen liefert. Programmiersprachlich
etwa
def first(w){w[0]}
def rest(w){w.substring(1)}
Hier sind ein paar rekursiv definierte Funktionen über Wörtern.
def laenge(w){if(w=="") return(0) else return(1+laenge(rest(w)))}
liefert die Länge eines Wortes. In Groovy/Java schreibt man "" für die leere Zeichenreihe ε,
und + für das Konkatenationssymbol °.
def invertiere(w){
if(w=="") return(w) else return(invertiere(rest(w))+first(w))}
liefert das umgedrehte Wort, also etwa ’negaldnurG’ zu ’Grundlagen’.
def ersetze(w,a,b){
if(w=="") return(w) else
if(first(w)==a) return(b+ersetze(rest(w),a,b)) else
return(first(w)+ersetze(rest(w),a,b))}
ersetzt jedes a in w durch b, z.B. ergibt ersetze("hallo",'l','r')=="harro".
Wir werden später noch ähnliche solche Funktionen kennen lernen.
Wenn w=u°v, so sagen wir, dass u ein Anfangswort von w ist. Wenn w=v1°u°v2, so nennen
wir u ein Teilwort von w. Auch die Anfangswortrelation lässt sich leicht induktiv definieren:
def anfangswort(w,u){
if(u=="") return(true)
else if(first(w)!= first(u)) return(false)
else return(anfangswort(rest(w), rest(u)))}
Auf Alphabeten ist häufig eine totale Ordnungsrelation erklärt; meist wird diese durch die
Reihenfolge der Aufschreibung der Zeichen unterstellt. Wenn A ein Alphabet mit einer
totalen Ordnungsrelation ≤ ist, so kann ≤ zur lexikographischen Ordnung auf A* ausgeweitet
werden:
Sei x= x1x2…xn und y= y1y2…ym. Dann gilt x≤ y, wenn
1-28
• x ein Anfangswort von y ist, oder
• es gibt ein Anfangswort z von x und y (x=z°x’, y=z°y’) und first(x’) <first(y’).
(a < b bedeutet a ≤ b und nicht a = b)
Beispiele für lexikographisch geordnete Wörter über dem lateinischen Alphabet sind
"ANTON" < "BERTA", "AACHEN" < "AAL", "AAL" < "AALBORG" und ε < "A".
Es ist nicht schwer zu sehen, dass die lexikographische Ordnung eine totale Ordnung ist. Die
Buchstaben des deutschen Alphabets sind nicht linear geordnet (a und ä stehen
nebeneinander, ß ist nicht eingeordnet), daher entspricht die Reihenfolge der Wörter in einem
deutschen Lexikon nicht der lexikographischen Ordnung.
Bäume
Bäume sind – neben Tupeln, Folgen und Wörtern – eine weitere in der Informatik sehr
wichtige Datenstruktur. In der induktiven Definition von Zeichenreihen besteht ein Wort w
aus der Konkatenation von first(w) mit rest(w). Ein Binärbaum ist dadurch gekennzeichnet,
dass es zwei verschiedene „Reste“ gibt: den linken und den rechten Unterbaum. Daraus ergibt
sich folgende induktive Definition der Menge der Binärbäume über einem gegebenen
Alphabet A:
• ε ∈A^
• Wenn a∈A und l∈A^ und r∈A^, so ist (a,l,r)∈A^.
a heißt Wurzel, l und r sind Unterbäume des Baumes (a,l,r). Die Wurzeln von l und r heißen
die Kinder oder Nachfolger von a. Ein Baum y ist Teilbaum eines Baumes x, wenn x=y oder y
Teilbaum eines Unterbaumes von x ist. Wenn y nichtleerer Teilbaum von x ist, so sagen wir,
die Wurzel von y ist ein Knoten von x. Ein Knoten ohne Nachfolger (d.h. ein Teilbaum der
Gestalt (a, ε , ε ) ) heißt Blatt.
Als Beispiel für Bäume betrachten wir Formelbäume über dem Alphabet (x,y,z,+,*). Die
Formel x*y + x*z (mit „Punkt-vor-Strich-Regelung“) kann durch den Baum
(+,(*,(x, ε , ε ),(y, ε , ε )),(*,(x, ε , ε ),(z, ε , ε ))) repräsentiert werden. Übersichtlicher ist eine
graphische Darstellung:
+
*
x
*
y
x
z
Wir werden später verschiedene Algorithmen, die auf Bäumen basieren, kennen lernen.
Es ist klar, dass sich die obige Definition direkt auf Binärbäume über einer beliebigen
Grundmenge verallgemeinern lässt. Eine weitere nahe liegende Erweiterung sind n-äre
Bäume, bei denen jeder Knoten entweder keinen oder n Nachfolger hat. Wenn wir erlauben,
dass jeder Knoten eine beliebige (endliche) Zahl von Nachfolgern haben kann, sprechen wir
von endlich verzweigten Bäumen.
Aufrufbäume
Eine spezielle Art von (endlich verzweigten) Bäumen sind die Aufrufbäume einer rekursiven
Funktion. Die Wurzel eines Aufrufbaumes ist der Name der Funktion mit den Eingabewerten.
Die Nachfolger jeden Knotens sind die bei der Auswertung aufgerufenen Funktionen mit
ihren Eingabewerten.
1-29
Beispiel:
inverse(„ABC“)
rest(„ABC“)
inverse(„BC“)
first(„ABC“)
+
rest(„BC“)
inverse(„C“)
first(„BC“)
+
+
(In diesem Baum haben wir die Funktionen „==“ und „if“ nicht weiter berücksichtigt.)
Beim verkürzten Aufrufbaum lässt man alle Knoten weg außer denen, die die rekursive
Funktion selbst betreffen.
Beispiel:
inverse(„“)
rest(„C“)
first(„C“)
fib(5)
fib(3)
fib(4)
fib(3)
fib(2)
fib(2)
fib(2)
fib(1)
fib(1)
Unter der Aufrufkomplexität einer rekursiven Funktion verstehen wir die Anzahl der Knoten
im verkürzten Aufrufbaum. Die Zeit, die benötigt wird, um eine rekursive Funktion zu
berechnen, hängt im Wesentlichen von der Aufrufkomplexität ab. Als Beispiel betrachten wir
die Aufrufkomplexität der Fibonacci-Funktion. Aus obigem Beispiel ist sofort klar:
1, falls n ≤ 2

fibComp (n) = 
1 + fibComp (n − 1) + fibComp (n − 2), sonst
Die rekursive Formulierung hilft leider noch nicht, die Aufrufkomplexität abzuschätzen. Per
Induktion nach n zeigen wir: fibComp (n) = 2 * fib(n) − 1 . Für n=1,2 ist dies klar, für n>2 gilt
fibComp (n) = 1 + fibComp (n − 1) + fibComp (n − 2) = I .V . 1 + 2 * fib(n − 1) − 1 + 2 * fib(n − 2) − 1
= 2 * ( fib(n − 1) + fib(n − 2)) − 1 = 2 * fib(n) − 1
Mit der früher bewiesenen Gleichung von Binet erhalten wir
n
n
2  1 + 5   1 − 5  
 −
  −1.

fibComp (n) =
5  2   2  


Eine Wertetabelle für einige Zahlenwerte ist nachfolgend angegeben. Daraus folgt: wenn in
einer Sekunde 10.000 Aufrufe erfolgen, benötigt die Rechnung für n=100 etwa 2,2 Milliarden
Jahre! (üblicherweise ist vorher der Speicher erschöpft oder ein Zahlbereichsüberlauf
eingetreten,).
n
3
5
10
20
30
40
50
60
70
80
90
100
fibComp(n)
3
9
109
13529
1.6*106
2*108
2.5*1010
3*1012
3.8*1014
4.6*1016
5.7*1018
7*1020
1-30
Graphen
Während ein Wort in der Informatik nur eine spezielle Art von Folgen ist, versteht man unter
einem Graphen nur eine spezielle Art von Relationen: Ein Graph ist die bildliche Darstellung
einer binären Relationen über einer endlichen Grundmenge. Die Elemente der Grundmenge
werden dabei in Kreisen (Knoten) gezeigt. Zwischen je zwei Knoten zeichnet man einen Pfeil
(eine Kante), falls das betreffende Paar von Elementen in der Relation enthalten ist. Beispiel:
B
A
C
D
Dies ist die Relation {(A,B),(B,C),(C,B),(C,A),(B,D),(C,D)}. Für symmetrische Relationen
weisen die Pfeile immer in beide Richtungen; man spricht hier von ungerichteten Graphen.
Eine Alternative zur obigen Definition besteht darin, einen Graphen als Tupel (V,E) zu
definieren, wobei V eine endliche Menge von Knoten (vertices) und E eine endliche Menge
von Kanten (edges) ist, so dass zu jeder Kante genau ein Anfangs- und ein Endknoten gehört.
Knoten, die nicht Endknoten sind, heißen Quelle, Knoten, die nicht Anfangsknoten sind,
heißen Senke im Graphen. Knoten, die weder Anfangs- noch Endknoten sind, heißen isoliert.
Eine dritte Art der Definition von Graphen ist durch die so genannte Adjazenzmatrix: Nach
dieser Auffassung ist ein Graph eine endliche Matrix (Tabelle) mit booleschen Werten. Die
Zeilen und Spalten der Tabelle sind dabei mit der Grundmenge beschriftet; ein Eintrag gibt
an, ob das entsprechende Paar (Zeile, Spalte) in der Relation enthalten ist oder nicht.
A
A
B
C
D
X
B
X
X
C
D
X
X
X
Im Gegensatz zu Bäumen können Graphen Zyklen enthalten, daher existiert keine einfache
induktive Definition. Umgekehrt können endlich verzweigte Bäume als zyklenfreie Graphen
mit nur einer Quelle betrachtet werden.
1-31
Kapitel 2: Informationsdarstellung
2.1 Bits und Bytes, Zahl- und Zeichendarstellungen
(siehe Gumm/Sommer * Kap.1.2/1.3)
Damit Informationen von einer Maschine verarbeitet werden können, müssen sie in der
Maschine repräsentiert werden. Üblich sind dabei Repräsentationsformen, die auf Tupeln
oder Folgen über der Menge B aufbauen. Ein Bit (binary digit) ist die kleinste Einheit der
Informationsdarstellung: es kann genau zwei Werte annehmen, z. B. 0 oder 1. Genau wie es
viele verschiedene Notationen der Menge B gibt, gibt es viele verschiedene
Realisierungsmöglichkeiten eines Bits: an/aus, geladen/ungeladen, weiss/schwarz,
magnetisiert/entmagnetisiert, reflektierend/lichtdurchlässig, …
Lässt eine Frage mehrere Antworten zu, so lassen sich diese durch eine Bitfolge (mehrere
Bits) codieren.
Beispiel: Die Frage, aus welcher Himmelsrichtung der Wind weht, lässt 8 mögliche
Antworten zu. Diese lassen sich durch Bitfolgen der Länge 3 codieren:
000 = Nord
001 = Nordost
010 = Ost
011 = Südwest
100 = Süd
101 = Südost
110 = West
111 = Nordwest
Offensichtlich verdoppelt jedes zusätzliche Bit die Anzahl der möglichen Bitfolgen, so dass es
genau 2n mögliche Bitfolgen der Länge n gibt (|B|=2  |Bn|=2n)
Ein Byte ist ein Oktett von Bits: 8 Bits = 1 Byte. Oft betrachtet man Bytefolgen anstatt von
Bitfolgen. Ein Byte kann verwendet werden, um z.B. folgendes zu speichern:
ein codiertes Zeichen (falls das Alphabet weniger als 28 Zeichen enthält)
eine Zahl zwischen 0 und 255,
eine Zahl zwischen -128 und +127,
die Farbcodierung eines Punkts in einer Graphik, genannt „Pixel“ (picture element)
Gruppen von 16 Bits, 32 Bits, 64 Bits bzw. 128 Bits werden häufig als Halbwort, Wort,
Doppelwort bzw. Quadwort bezeichnet. Leider gibt es dafür unterschiedliche Konventionen.
Zwischen 2-er und 10-er Potenzen besteht (näherungsweise) der Zusammenhang:
210 = 1024 ≅ 1000 = 103
Für Größenangaben von Dateien, Disketten, Speicherbausteinen, Festplatten etc. benutzt man
daher folgende Präfixe:
≅ 103
(k = Kilo)
• k = 1024 = 210
• M = 10242 = 1048576=220 ≅ 106
(M = Mega)
3
30
9
• G = 1024 = 2
≅ 10
(G = Giga)
4
40
12
• T = 1024 = 2
≅ 10
(T = Tera)
5
50
15
• P = 1024 = 2
≅ 10
(P = Peta)
• E = 10246 = 260
≅ 1018
(E = Exa)
Die Ungenauigkeit der obigen Näherungsformel nimmt man dabei in Kauf.
2-32
Mit 1 GByte können also entweder 230 = 10243 = 1.073.741.824 oder 109 = 1.000.000.000
Bytes gemeint sein.
Anhaltspunkte für gängige Größenordnungen von Dateien und Geräten:
• eine SMS: ~140 B (160 Zeichen zu je 7 Bit)
• ein Brief: ~3 kB
• ein „kleines“ Programm: ~300 kB
• Diskettenkapazität: 1,44 MB
• ein „mittleres“ Programm: ~ 1 MB
• ein Musiktitel: ~40 MB (im MP3-Format ~4 MB)
• CD-ROM Kapazität: ~ 680 MB
• Hauptspeichergröße: 1-8 GB
• DVD (Digital Versatile Disk): ~ 4,7 bzw. ~ 9 GB
• Festplatte: 250-1000 GB.
Für Längen- und Zeiteinheiten werden auch in der Informatik die gebräuchlichen Vielfachen
von 10 benutzt. So ist z.B. ein 400 MHz Prozessor mit 400×106 = 400.000.000 Hertz
(Schwingungen pro Sekunde) getaktet.
Das entspricht einer Schwingungsdauer von 2,5×10-9 sec, d.h. 2,5 ns. Der Präfix n steht
hierbei für nano, d.h. den Faktor 10-9. Weitere Präfixe für Faktoren kleiner als 1 sind:
•
•
•
•
•
m = 1/1000 = 10-3
µ = 1/1000000 = 10-6
n = 1/1000000000 = 10-9
p = ... = 10-12
f = ... = 10-15
(m = Milli)
(µ = Mikro)
(n = Nano)
(p = Pico)
(f = Femto)
Beispiele: 1mm = 1 Millimeter; 1 ms = 1 Millisekunde.
Für Längenangaben wird neben den metrischen Maßen eine im Amerikanischen immer noch
weit verbreitete Einheit verwendet: 1" = 1 in = 1 inch = 1 Zoll = 2,54 cm = 25,4 mm. Teile
eines Zolls werden als Bruch angegeben. Beispiel - Diskettengröße: 3 1/2".
Darstellung natürlicher Zahlen, Stellenwertsysteme
Die älteste Form der Darstellung von Zahlen ist die Strichdarstellung, bei der jedes
Individuum durch einen Strich oder ein Steinchen repräsentiert wird (calculi=Kalksteinchen,
vgl. kalkulieren). Bei dieser Darstellung ist die Addition besonders einfach (Zusammen- oder
Hintereinanderschreibung von zwei Strichzahlen), allerdings wird sie für große Zahlen schnell
unübersichtlich. Die Ägypter führten deshalb für Gruppen von Strichen Abkürzungen ein
(http://de.wikipedia.org/wiki/Ägyptische_Zahlen, http://www.informatik.unihamburg.de/WSV/teaching/vorlesungen/WissRep-Unterlagen/WR03Einleitung-1.pdf).
Daraus entstand dann das römische Zahlensystem:
2-33
Aus Übersichtlichkeitsgründen werden die großen Zahlen dabei zuerst geschrieben;
prinzipiell spielt in solchen direkten Zahlensystemen die Position einer Ziffer keine Rolle. Die
Schreibweise IV = 5-1 ist erst viel später entstanden!
Direkte Zahlensysteme haben einige Nachteile: Die Darstellung großer Zahlen kann sehr lang
werden, und arithmetische Operationen lassen sich in solchen Systemen nur schlecht
durchführen. In Indien (und bei den Majas) wurde ein System mit nur zehn verschiedenen
Ziffernsymbolen verwendet, bei der die Position jeder Ziffer (von rechts nach links) ihre
Wertigkeit angibt. Die wesentliche Neuerung ist dabei die Erfindung der Zahl Null (ein leerer
Kreis). Der schon genannte Muhammed ibn Musa al-Khwarizmi verwendete das
Dezimalsystem in seinem Arithmetikbuch, das er im 8. Jahrhundert schrieb. Bereits im 10.
Jahrhundert wurde das System in Europa eingeführt, durchsetzen konnte es sich jedoch erst
im 12. Jahrhundert mit der Übersetzung des genannten Arithmetikbuchs ins Lateinische
(durch Fibonacci, siehe oben).(*)
Wir haben schon erwähnt, dass das Dualzahlen- oder Binärsystem mit nur zwei Ziffern in
Europa 1673 von Gottfried Wilhelm Leibnitz (wieder-)erfunden wurde. Allgemein gilt: In
Stellenwertsystemen wird jede Zahl als Ziffernfolgen xn-1... x0 repräsentiert, wobei - bezogen
auf eine gegebene Basis b - jede Ziffer xi einen Stellenwert (xi*bi) bekommt:
n −1
[xn−1  x0 ]b = ∑ xi ∗ b i
i =0
Werden dabei nur Ziffern xi mit Werten zwischen 0 und b-1 benutzt, so ergibt sich eine
eindeutige Darstellung; dafür werden offenbar genau b Ziffern benötigt. Zu je zwei Basen b
und b' gibt es eine umkehrbar eindeutige Abbildung, die [xn-1... x0]b und [ yn'-1... y0]b' mit 0 ≤ xi
≤ b und 0 ≤ yi ≤ b' ineinander überführt.
2-34
Beispiele:
b = 2:
b = 3:
b = 8:
b = 10:
[10010011]2 = 1*27 + 1*24 + 1*21 + 1*20 = [147]10
[12110]3 = 1*34 + 2*33 + 1*32 + 1*31 = [147]10
[223]8 = 2*82 + 2*81 + 3*80 = [147]10
[147]10 = 1*102 + 4*101 + 7*100=[10010011]2
Wichtige Spezialfälle sind b=10, 2, 8 und 16. Zahlen mit b=8 bezeichnet man als Oktalzahlen.
Unter Sedezimalzahlen (oft auch Hexadezimalzahlen genannt) versteht man
Zifferndarstellungen von Zahlen zur Basis 16. Sie dienen dazu, Dualzahlen in komprimierter
(und damit leichter überschaubarer) Form darzustellen und lassen sich besonders leicht
umrechnen. Je 4 Dualziffern werden zu einer „Hex-ziffer“ zusammengefasst. Da man zur
Hexadezimaldarstellung 16 Ziffern benötigt, nimmt man zu den Dezimalziffern 0 ... 9 die
ersten Buchstaben A ... F hinzu.
Beispiele: Umwandlung von Dezimal - in Oktal- / Hexadezimalzahlen und umgekehrt:
[1]10=[1]8=[1]16=[1]2
…
[7]10=[7]8=[7]16=[111]2
[8]10=[10]8=[8]16=[1000]2
[9]10=[11]8=[9]16=[1001]2
[10]10=[12]8=[A]16=[1010]2
[11]10=[13]8=[B]16=[1011]2
[12]10=[14]8=[C]16=[1100]2
[13]10=[15]8=[D]16=[1101]2
[14]10=[16]8=[E]16=[1110]2
[15]10=[17]8=[F]16=[1111]2
[16]10=[20]8=[10]16
[17]10=[21]8=[11]16
…
[32]10=[40]8=[20]16
[33]10=[41]8=[21]16
…
[80]10=[100]8=[50]16
…
[160]10=[240]8=[A0]16
…
[255]10=[377]8=[FF]16
[256]10=[400]8=[100]16
[1000]10=[3E8]16
…
[4096]10=[10000]8=[1000]16
…
[10000]10=[2710]16
…
[45054]10=[AFFE]16
[65535]10=[177777]8=[FFFF]16
[106]10=[F4240]16
[4294967295]10=[FFFFFFFF]16
Diese Umrechnung ist so gebräuchlich, dass sie von vielen Taschenrechnern bereit gestellt
wird.
Um mit beliebig großen natürlichen oder ganzen Zahlen rechnen zu können, werden diese als
Folgen über dem Alphabet der Ziffern repräsentiert. Diese Darstellung wird beispielsweise im
bc verwendet. Numerische Algorithmen mit solchen Repräsentationen sind allerdings häufig
komplex, und in vielen Anwendungen wird diese Allgemeinheit nicht benötigt. Daher werden
Zahlen oft als Dual- oder Binärwörter einer festen Länge n repräsentiert. Mit Hilfe von n Bits
lassen sich 2n Zahlenwerte darstellen:
• die natürlichen Zahlen von 0 bis 2n - 1 oder
• die ganzen Zahlen zwischen -2n-1 und 2n-1 - 1 oder
• ein Intervall der reellen Zahlen (mit begrenzter Genauigkeit)
2-35
Beispiel:
Länge
4
8
16
32
darstellbare Zahlen
0 .. 15
0 .. 255
0 .. 65535
0 .. 4 294 967 295
Darstellung ganzer Zahlen
Für die Darstellung ganzer Zahlen Z wird ein zusätzliches Bit (das "Vorzeichen-Bit")
benötigt. Mit Bitfolgen der Länge n kann also (ungefähr) der Bereich [-2n-1 .. 2n-1] dargestellt
werden. Nahe liegend ist die dabei Vorzeichendarstellung: Das erste Bit repräsentiert das
Vorzeichen (0 für '+' und 1 für '-') und der Rest den Absolutwert.
Nachteile dieser Darstellung:
• Die Darstellung der 0 ist nicht mehr eindeutig.
• Beim Rechnen "über die 0" müssen umständliche Fallunterscheidungen gemacht
werden, Betrag und Vorzeichen sind zu behandeln.
Beispiel:3 + (-5) = 0011+1101=1010
Eine geringfügige Verbesserung bringt die Einserkomplement-Darstellung, bei der jedes Bit
der Absolutdarstellung umgedreht wird. Meist wird jedoch die so genannte
Zweierkomplement-Darstellung (kurz: 2c) benutzt. Sie vereinfacht die arithmetischen
Operationen und erlaubt eine eindeutige Darstellung der 0. Bei der
Zweierkomplementdarstellung gibt das erste Bit das Vorzeichen an, das 2. bis n-te Bit ist das
Komplement der um eins verringerten positiven Zahl. Die definierende Gleichung für die
Zweierkomplementdarstellung ist:
[-xn-1... x0]2c + [xn-1...x0]2c = [10...0]2c = 2n+1
Um zu einer gegebenen positiven Zweierkomplement-Zahl die entsprechende negative zu
bilden, invertiert man alle Bits und addiert 1. Die Addition kann mit den üblichen Verfahren
berechnet werden (Beweis: eigene Übung!).
Beispiele: n = 4: +5 → [0101]2c , also -5 → (1010 + 0001)2c = [1011]2c
3 + (-5) =[0011]2c +[1011]2c =[1110]2c
4 + 5 =[0100]2c +[0101]2c → [1001]2c = -7 (Achtung!!!)
2-36
Wichtiger Hinweis: In vielen Programmiersprachen wird ein Zahlbereichsüberlauf nicht
abgefangen und kann beispielsweise zur Folge haben, dass die nagelneue Rakete abstürzt!
Darstellung rationaler und reeller Zahlen
Prinzipiell kann man rationale Zahlen Q als Paare ganzer Zahlen (Zähler und Nenner)
darstellen. Ein Problem ist hier die Identifikation gleicher Zahlen: (7,-3)=(-14,6). Hier müsste
man nach jeder Rechenoperation mit dem ggT normieren; das wäre sehr unpraktisch. Daher
werden in der Praxis rationale Zahlen Q meist wie reelle Zahlen R behandelt.
Für reelle Zahlen gilt:
• Es gibt überabzählbar viele reelle Zahlen R. Also gibt es auch reelle Zahlen, die sich
nicht in irgend einer endlichen Form aufschreiben lassen, weder als endlicher oder
periodischer Dezimalbruch noch als arithmetischer Ausdruck oder Ergebnis eines
Algorithmus. Echte reelle Zahlen lassen sich also nie genau in einem Computer
speichern, da es für sie definitionsgemäß keine endliche Darstellung gibt.
• Die so genannten „reals“ im Computer sind mathematisch gesehen immer
Näherungswerte für reelle Zahlen mit endlicher Genauigkeit.
Für reelle Zahlen R gibt es die Festkomma- und die Gleitkommadarstellung. Bei der
Festkommadarstellung steht das Komma an einer beliebigen, festen Stelle. Für
x=[xn-1xn-2…x1x0x-1x-2…x-m]2 ist x =
n −1
∑x
i =− m
i
⋅ 2 i . Diese Darstellung gestattet nur einen kleinen
Wertebereich und hat auch sonst einige Nachteile (Normierung von Zahlen erforderlich).
Daher verwendet man sie in elektronischen Rechenmaschinen nur in Ausnahmefällen (z.B.
beim Rechnen mit Geld). Ziel der Gleitkommadarstellung (IEEE754 Standard) ist es,
• ein möglichst großes Intervall reeller Zahlen zu umfassen,
• die Genauigkeit der Darstellung an die Größenordnung der Zahl anzupassen: bei
kleinen Zahlen sehr hoch, bei großen Zahlen niedrig.
Daher speichert man neben dem Vorzeichen und dem reinen Zahlenwert - der so genannten
Mantisse - auch einen Exponenten (in der Regel zur Basis 2 oder 10), der die Kommaposition
in der Zahl angibt.
- Das Vorzeichenbit v gibt an, ob die vorliegende Zahl positiv oder negativ ist.
- Die Mantisse m besteht aus einer n–stelligen Binärzahl m1....mn
2-37
-
Der Exponent e ist eine L-stellige ganze Zahl (zum Beispiel im Bereich -128 bis
+127), die angibt, mit welcher Potenz einer Basis b die vorliegende Zahl zu
multiplizieren ist.
Das Tripel (v, m, e) wird als (-1)v * m * be-n interpretiert. Bei gegebener Wortlänge von 32 Bit
verwendet man beispielsweise 24 Bit für Vorzeichen und Mantisse (n=23) sowie L=8 Bit für
den Exponenten. Beispiele mit n=L=4: (0, 1001, 0000) = 1 * 9 * 2-4 = [0,1001]2=0,5625 und
(1, 1001, 0110) = -1 * 9 * 26-4 = [-100100]2 = -36 und (0, 1001, 1010) = 1 * 9 * 2-6-4 =
0,00878906… Anstatt wie in diesen Beispielen e in Zweierkomplementdarstellung
abzuspeichern, verwendet man die sogenannte biased-Notation: E=e+e’, wobei e’=2L-1-1.
Damit ist 0≤E≤(2L-1) positiv, und (v, m, E) wird als (-1)v * m * bE -(e’+n) interpretiert. Zum
Beispiel ist für L=4 der Wert von e’=7, also ein Exponent E=1010 entspricht e=3. Damit
ergibt sich (0, 1001, 1010) = 9 * 23-4 = 4,5. Bei 32-Bit Gleitkommazahl mit 8-Bit Exponent
gilt: e’=127, bei 64-Bit Gleitkommazahlen ist e’=1023.
In Groovy und Java gibt es die folgenden Datentypen für Gleitpunktzahlen:
• Datentyp float (32 Bit) mit dem Wertebereich (+/-)1.4*10-45..3.4*1038.(7 relevante
Dezimalstellen: 23 Bit Mantisse, 8 Bit Exponent)
• Datentyp double (64 Bit) mit dem Wertebereich (+/-)4.9*10-324..1.79*10308.(15
relevante Stellen: 52 Bit Mantisse, 11 Bit Exponent)
• Datentyp BigDecimal mit fast beliebiger Präzision, bestehend aus Mantisse und 32bit Exponent, Wert: (Mantisse * (10 ** (-Exponent)))
Beispiel: new BigDecimal(123, 6 ) == 0.000123
Zeichendarstellung
Zeichen über einem gegebenen Alphabet A werden meist als Bitfolgen einer festen Länge
n≥log(|A|) codiert. Oft wird n so gewählt, dass es ein Vielfaches von 4 oder von 8 ist.
Beispiel: Um Texte in einem Buch darzustellen, benötigt man ein Alphabet von 26
Kleinbuchstaben, ebenso vielen Großbuchstaben, einigen Satzzeichen wie etwa Punkt,
Komma und Semikolon und Spezialzeichen wie "+", "&", "%". Daher hat eine normale
Schreibmaschinentastatur eine Auswahl von knapp hundert Zeichen, für die 7 Bit ausreichen.
Bereits in den 1950-ern wurden die Codes ASCII und EBCDIC hierfür entwickelt. (siehe
ASCII-Tabelle in Kap.1.3 Alphabete).
Mit einem Byte lassen sich 256 Zeichen codieren. Viele PCs benutzen den Code-Bereich [128
.. 255] zur Darstellung von sprachspezifischen Zeichen wie z.B. "ä" (Wert 132 in der
erweiterten Zeichentabelle), "ö" (Wert 148) "ü" (Wert 129) und einigen Sonderzeichen
anderer Sprachen. Leider ist die Auswahl der sprachspezifischen Sonderzeichen eher zufällig
und bei weitem nicht ausreichend für die vielfältigen Symbole fremder Schriften. Daher
wurden von der „International Standardisation Organisation“ (ISO) verschiedene ASCIIErweiterungen normiert. In Westeuropa ist dazu die 8-Bit ASCII-Erweiterung „Latin-1“
nützlich, die durch die Norm ISO8859-1 beschrieben wird.
Mit steigender Speicherverfügbarkeit geht man heutzutage von 8-Bit Codes zu 16-Bit oder
noch mehr stelligen Codes über. Hier gibt es den Standard UCS bzw. Unicode.
- [ISO10646]: "Information Technology -- Universal Multiple-Octet Coded Character
Set (UCS) -- Part 1: Architecture and Basic Multilingual Plane", ISO/IEC 106461:1993.
- [UNICODE]: "The Unicode Standard: Version 2.0", The Unicode Consortium,
Addison-Wesley Developers Press, 1996. Siehe auch http://www.unicode.org
2-38
Die ersten 128 Zeichen dieser Codes sind ASCII-kompatibel, die ersten 256 Zeichen sind
kompatibel zu dem oben genannten Code ISO-Latin-1. Darüber hinaus codieren sie alle
gängigen Zeichen dieser Welt.
Herkömmliche Programmiersprachen lassen meist keine Zeichen aus ASCII-Erweiterungen
zu. Java und Groovy erlauben die Verwendung beliebiger Unicode-Zeichen in Strings.
(Allerdings heißt dies noch lange nicht, dass jede Java-Implementierung einen Editor zur
Eingabe von Unicode mitliefern würde!)
Zeichenketten (strings) werden üblicherweise durch Aneinanderfügen einzelner codierter
Zeichen repräsentiert; da die Länge oftmals statisch nicht festliegt, werden sie intern mit
einem speziellen Endzeichen abgeschlossen..
Beispiel:
Dem Text "Hallo Welt" entspricht die Zeichenfolge
"H", "a", "l", "l", "o", " ", "W", "e", "l", "t"
Diese wird in ASCII folgendermaßen codiert:
072 097 108 108 111 032 087 101 108 116.
In Hexadezimal-Schreibweise lautet diese Folge:
48 61 6C 6C 6F 20 57 65 6C 74.
Dem entspricht die Bitfolge:
01001000 01100001 01101100 01101100 01101111
00100000 01010111 01100101 01101100 01110100.
Obwohl diese Repräsentation sicher nicht die speichereffizienteste ist, ist sie weit verbreitet;
bei Speichermangel greift man eher auf eine nachträgliche Komprimierung als auf andere
Codes zurück.
Zeit- und Raumangaben
Oft muss man Datumsangaben wie „4. Mai 2010, 9 Uhr 15 pünktlich“ oder Raumangaben,
etwa „0310, Rudower Chaussee 26, 12489 Berlin-Adlershof“ in Programmen repräsentieren.
Für Termine könnte man das Tripel (4, 5, 2010) durch drei natürliche Zahlen oder mit Uhrzeit
als (4, 5, 2010, 9, 15, 0) in sechs Zahlen (Speicherwörtern) ablegen. Um Platz zu sparen, kann
man auch eine Folge von 14, 8 oder 6 Zeichen verwenden: "20100504091500", "20100504",
"100504". Im letzten Fall bekommt man allerdings ein Y2K-Problem, was sich aber auch im
Januar 2010 im Kreditkartenwesen wiederholte.
Um den aktuellen Tag in nur einer ganzen Zahl zu codieren, könnte man eine
Stellenwertrechnung wie bei den Gleitkommazahlen verwenden. Dies ist jedoch zu
aufwändig; besser ist es, die Zeiteinheiten ab einem bestimmten Anfangszeitpunkt zu zählen.
Die Römer zählten ab der vermuteten Gründung der Stadt; in der christlichen Zeit zählt man
ab der vermuteten Geburt Jesu. In der Unixzeit zählt man die Sekunden ab dem 1.1.1970,
wobei Schaltsekunden nicht mitgezählt werden. Dies kann ggf. zu einem „Jahr-20382-39
Problem“ führen. Die Umrechnung von Unixzeit in christliche Zeit erfolgt mit dem
Kommando date. Da die inkrementelle Zeitrechnung eine immer größer werdende Differenz
zur durch die Erdrotation gegebene „natürliche“ Zeit aufweist, fanden öfters Anpassungen
und Korrekturen statt, die einschneidendste durch die Einführung des Gregorianischen
Kalenders am 4.10./14.10.1582. Seither erfolgt diese Korrektur durch die Einführung von
Schalttagen und Schaltsekunden. Ein Schaltjahr ist definiert dadurch, dass die Jahreszahl
durch 4 teilbar ist, aber nicht durch 100, oder durch 400.
boolean schaltjahr (x) {(x % 4 == 0) & (x % 100 != 0) | (x % 400 == 0)}
assert schaltjahr (2012) & schaltjahr (2000) & ! schaltjahr(1900)
Um zu einem gegebenen Datum im Gregorianischen Kalender den Wochentag zu ermitteln,
kann man die Zellersche Formel (siehe http://de.wikipedia.org/wiki/Zellers_Kongruenz)
verwenden.
Um die Zeitrepräsentation auf der ganzen Erde einheitlich referenzieren zu können, wurde
1968 die koordinierte Weltzeit UTC eingeführt. Die lokale Zeit ergibt sich durch Angabe des
Offsets zur UTC, etwa 11:30 UTC+1:00 für die mitteleuropäische Ortszeit (MEZ oder CET),
die der UTC eine Stunde voraus ist. Die mitteleuropäische Sommerzeit (CEST) ist UTC+2:00.
Als Alternative zur koordinierten Weltzeit wurde 1998 die Internetzeit eingeführt (und von
der Firma Swatch propagiert), bei der ein Tag aus 1000 Zeiteinheiten besteht und die überall
gleich gezählt wird.
In Groovy und in der Java-Bibliothek gibt es die Klassen Date und GregorianCalendar, mit
der man direkt mit Datumsangaben rechnen kann:
def t = new Date ()
println t // heutiges Datum
println t+7 // eine Woche später
println t.time // Unixzeit
def c=new GregorianCalendar()
println c.time // heutiges Datum
println c.timeInMillis // etwas später...
Zur Repräsentation von Ortsinformationen auf der Erde gibt es sehr viele unterschiedliche
Postanschriftssysteme. Für Koordinatenzuweisungen beim Geo-tagging hat sich das System
(Längengrad, Breitengrad) durchgesetzt.
2-40
Darstellung sonstiger Informationen
Natürlich lassen sich in einem Computer nicht nur Bits, Zahlen und Zeichen repräsentieren,
sondern z.B. auch visuelle und akustische Informationen. Für Bilder gibt es dabei prinzipiell
zwei verschiedene Verfahren, nämlich als Vektor- und als Pixelgrafik. Auch für Töne gibt es
verschiedene Repräsentationsformen: als Folge von Noten (Midi), Schwingungsamplituden
(wav, au) oder komprimiert (mp3). Diese Repräsentationsformen lassen sich jedoch auffassen
als strukturierte Zusammensetzungen einfacher Datentypen; wir werden später noch detailliert
auf verschiedene Strukturierungsmöglichkeiten für Daten eingehen.
Wichtig: Der Bitfolge sieht man nicht an, ob sie die Repräsentation einer Zeichenreihe, einer
Folge von ganzen Zahlen oder reellen Zahlen in einer bestimmten Genauigkeit ist. Ohne
Decodierungsregel ist eine codierte Nachricht wertlos!
2.2 Sprachen, Grammatiken, Syntaxdiagramme
Die obigen Darstellungsformen sind geeignet zur Repräsentation einzelner Objekte. Häufig
steht man vor dem Problem, Mengen von gleichartigen Objekten repräsentieren zu müssen.
Für endliche Mengen kann dies durch die Folge der Elemente geschehen; für unendliche
Mengen geht das im Allgemeinen nicht. Daher muss man sich andere (symbolische)
Darstellungsformen überlegen.
Darstellung von Sprachen
Ein besonders wichtiger Fall ist die Darstellung von unendlichen Mengen von Wörtern, die
einem bestimmten Bildungsgesetz unterliegen; zum Beispiel die Menge der syntaktisch
korrekten Eingaben in einem Eingabefeld, oder die Menge der Programme einer
Programmiersprache.
Eine Sprache ist eine Menge von Wörtern über einem Alphabet A.
- z.B. {a, aab, aac}
- z.B. Menge der grammatisch korrekten Sätze der dt. Sprache
- z.B. Menge der Groovy-Programme
Man unterscheidet zwischen natürlichen Sprachen wie z.B. deutsch und englisch und
formalen Sprachen wie z.B. Java, Groovy, C++ oder der Menge der Primzahlen in
Hexadezimaldarstellung. Da Leerzeichen in der Lehre von den formalen Sprachen genau wie
andere Zeichen behandelt werden, gibt es hier keinen Unterschied zwischen Wörtern und
Sätzen.
Unter Syntax versteht man die Lehre von der Struktur einer Sprache
• Welche Wörter gehören zur Sprache?
• Wie sind sie intern strukturiert?
z.B. Attribut, Prädikatverbund, Adverbialkonstruktion
Unter Semantik versteht man die Lehre von der Bedeutung der Sätze
• Welche Information transportiert ein Wort der Sprache?
In der Linguistik betrachtet man manchmal noch den Begriff Pragmatik, darunter versteht
man die Lehre von der Absicht von sprachlichen Äußerungen
• Welchen Zweck verfolgt der Sprecher mit einem Wort?
Berühmtes Beispiel für den Unterschied zwischen Semantik und Pragmatik ist die BeifahrerAussage „Die Ampel ist grün“.
Grammatiken
Grammatiken sind Werkzeuge zur Beschreibung der Syntax einer Sprache. Eine Grammatik
stellt bereit
2-41
•
•
A : Alphabet oder „Terminalzeichen“
H : Hilfssymbole = syntaktische Einheiten (<Objekt<, <Attribut>,..) oder
„Nonterminalzeichen“
Aus A und H bildet man Satzformen (Schemata korrekter Sätze)
z.B. “<Subjekt> <Prädikat> <Objekt>”, “ Heute <Prädikat> < Subjekt> <Objekt> “
• R: Ableitungsregeln – erlaubte Transformationen auf Satzformen
• s: Ein ausgezeichnetes Hilfssymbol („Gesamtsatz”, „Axiom“)
Formal ist eine Grammatik ein Tupel G = [A, H, R, s], wobei
• A und H Alphabete sind mit A ∩ H = Ø
• R ⊆ (A ∪ H)+ × (A ∪ H)*
• s∈H
Die Relation R wird meist mit dem Symbol  oder ::= (in Infixschreibweise) notiert.
Zwischen Satzformen definieren wir eine Relation –> (direkte Ableitungsrelation)
w–>w’, falls w = w1°u°w2, w’ = w1°v°w2 und (u,v) ∈ R
Die Ableitungsrelation => ist die reflexiv-transitive Hülle der direkten Ableitungsrelation:
w=>w’, falls w=w’ oder es gibt ein v ∈ (A ∪ H)* mit w–>v und v=>w’
Die von der Grammatik G beschriebene Sprache LG ist definiert durch
LG = { w | s => w und w ∈ A* }
Beispiel
A={“große“, “gute“, “jagen“, “lieben”, “Katzen”, “Mäuse”}
H={<attribut>,<objekt>,<prädikatsverband>,<satz>,<subjekt>,<substantiv>,<verb>}
s=<satz>
R:
<satz>  <subjekt> <prädikatsverband> “.”
<subjekt>  <substantiv>
<subjekt>  <attribut> <substantiv>
<attribut>“gute”
<attribut>“große”
<substantiv>“Katzen”
<substantiv>“Mäuse”
<prädikatsverband><verb> <objekt>
<verb>“lieben”
<verb>“jagen”
<objekt><substantiv>
<objekt><attribut> <substantiv>
Beispiel für Generierung:
<satz> –> <subjekt> <prädikatverband> “.”
–> <attribut> <substantiv> <verb> <objekt> “.”
–> “gute” “Katzen” “jagen” “Mäuse” “.”
Beispiel für Akzeptierung:
“Katzen
lieben
große
Mäuse
<–
<substantiv> <verb>
<attribut>
<substantiv>
<–
<subjekt>
<verb>
<objekt>
<–
<subjekt>
<prädikatverband>
<–
<satz>
2-42
.”
“.”
“.”
“.”
Noch ein Beispiel
<S>  <E>
<S>  <S> “+” <E>
<E>  <T>
<E>  <E> “•” <T>
<T>  <F>
<T>  “-” <F>
<F>  “x”
<F>  “0”
<F>  “1”
<F>  “(“ <S> “)”
Ableitung aus dem Beispiel: <S> => “1•(1+x)+0•-1”
<S> –>
<S> “+” <E>
–>
<E> “+”<E> “•” <T>
–>
<E>“•” <T> “+”<T>“ •” “-” <F>
–>
<T>“•” <F> “+” <F>“•” “-” “1”
–>
<F>“•” “ (“ <S> “)” “+” “0” “•” “-” “1”
–>
“1” “•” “(“ <S>“+” <E> “)” “+” “0” “•” “-” “1”
–>
“1” “•” “(“ <E>“+” <T> “)” “+” “0” “•” “-” “1”
–>
“1” “•” “(“ <T>“+” <F> “)” “+” “0” “•” “-” “1”
–>
“1” “•” “(“ <F>“+” “x” “)” “+” “0” “•” “-” “1”
–>
“1” “•” “(“ “1” “+” “x” “)” “+” “0” “•” “-” “1”
Grammatiktypen – Die Chomsky-Hierarchie
Die Lehre von den Grammatiken wurde vom amerikanischen Linguisten
Noam Chomsky (geb. 1928,
http://web.mit.edu/linguistics/people/faculty/chomsky/index.html)
(„America's most prominent political dissident“,
http://www.zcommunications.org/chomsky/index.cfm) in den späten
1950-ern entwickelt. Chomsky unterscheidet vier Typen von
Grammatiken:
• Typ 0 - beliebige Grammatiken
• Typ 1 - kontextsensitive Grammatiken
o Ersetzt wird ein einziges Hilfssymbol, nichtverkürzend
o Alle Regeln haben die Form u+h+w  u+v+w mit h ∈ H
o u , w heißen linker bzw. rechter Kontext
o Sonderregel für das leere Wort
• Typ 2 – kontextfreie Grammatiken
o Ersetzt wird ein einziges Hilfssymbol, egal in welchem Kontext
o Alle Regeln haben die Form h  v mit h ∈ H
• Typ 3 – reguläre Grammatiken
o Wie Typ 2, in v kommt aber max. ein neues Hilfssymbol vor, und zwar ganz
rechts
Abhängig vom Typ spricht man auch von einer Chomsky-i-Grammatik.
Beispiele (A={a, b, c}, H={s, x, y, z, …}):
Chomsky-0-Regeln sind z.B. die folgenden: xyz ::= zyx, abc ::= x, axby ::= abbxy
Eine Chomsky-0-Grammatik für {anbncn | n>0} erhält man durch folgende Regeln:
s::= xs’z; s’ ::= s’s’; s’ ::= abc; ba ::= ab; ca ::= ac; cb ::= bc;
xa ::= ax; x ::= y; yb ::= by; y ::= z; zc ::= cz; zz ::=ε
2-43
xyz ::= xyxyz (Chomsky-1-Regel) Anwendung: axyzxa -> axyxyza -> axyxyxyza -> …
y ::= yxy (Chomsky-2-Regel)
x::= ax; x::= b (Chomsky-3-Sprache a*b )
Eine Sprache heißt Chomsky-i-Sprache, wenn es eine Chomski-i-Grammatik für sie gibt,
aber keine Chomsky-(i-1)-Grammatik. Die vier Typen bilden eine echte Hierarchie, d.h., für
i=0,1,2 kann man jeweils eine Sprache finden, die durch eine Chomsky-i-Grammatik, nicht
aber durch eine Chomsky-i+1-Grammatik beschreibbar ist. Es gilt:
- Mit beliebigen Grammatiken lassen sich alle Sprachen beschreiben, die überhaupt
berechenbar sind
- Kontextsensitive Grammatiken sind algorithmisch beherrschbar
- Für die meisten Programmiersprachen wird die Syntax in Form kontextfreier
Grammatiken angegeben.
- Einfache Konstrukte innerhalb von Programmiersprachen (z.B. Namen, Zahlen)
werden durch reguläre Grammatiken beschrieben.
Aufschreibkonventionen für kontextfreie Grammatiken
Backus-Naur-Form (BNF) verwendet u ::= v | w als Abkürzung für {u ::= v, u ::= w}
Beispiel:
<S> ::= <Expression> | <S> + <Expression>
<Expression> ::= <Term> | <Expression> • <Term>
<Term> ::= <Factor> | - <Factor>
<Factor> ::= x | 0 | 1 | ( <S> )
Erweiterte Backus-Naur-Form (EBNF) löst direkte Rekursion durch beliebigmalige
Wiederholungsklammern {} auf
Beispiel:
S = Expression { “+” Expression }
Expression = Term { “•” Term }
Term = [ “-” ] Factor
Factor = “x” | “0” | “1” | “(” S “)”
Manchmal verwendet man auch zählende Wiederholungen [...]i mit der Bedeutung
„…mindestens i und höchstens j mal“, wobei j auch durch einen Stern ersetzt werden kann
1
*
(für „beliebig mal“). Es gilt {x} = [x ]0 und [x ] = [x ]0 .
j
Beispiele:
[a ]30 = {ε , a, aa, aaa} ; [a]*2
2-44
= {aa, aaa, aaaa, …}
Syntaxdiagramme
Syntaxdiagramme werden zur anschaulichen Notation von Programmiersprachen verwendet.
Reguläre Ausdrücke
Für reguläre Sprachen gibt es die Möglichkeit, sie durch reguläre Ausdrücke aufzuschreiben.
Das sind Ausdrücke, die gebildet sind aus
• der leeren Sprache, die keine Wörter enthält
• den Sprachen bestehend aus einem Wort bestehend aus einem Zeichen des Alphabets
• der Vereinigung von Sprachen (gekennzeichnet durch +)
• Konkatenation (gekennzeichnet durch Hintereinanderschreibung, ⋅ oder ;)
• beliebiger Wiederholung (gekennzeichnet durch *)
Beispiele:
(aa)* (gerade Anzahl von a)
aa* (mindestens ein a, auch a+ geschrieben)
((a + b)b)* (jedes zweite Zeichen ist b)
Reguläre Ausdrücke werden z.B. in Editoren verwendet, um bestimmte zu suchende
Zeichenreihenmengen zu repräsentieren. Beispiel für eine Suche ist: „Suche eine
Zeichenreihe, die mit c beginnt und mit c endet und dazwischen eine gerade Anzahl von a
enthält.“
In Groovy sind reguläre Ausdrücke („patterns“) ein fester Bestandteil der Sprache. Sie
werden in / … / eingeschlossen, und es gibt vielfältige Operatoren:
==~ prüft ob eine Zeichenreihe in einem regulären Ausdruck enthalten ist
=~ gibt ein Matcher-Objekt
2-45
Beispiele:
"aaaa" ==~ /(aa)+/
"abbbab" ==~ /(.b)*/
"..." ==~ /\.*/
"Hallo Welt" ==~ /\w+\s\w+/
["Hut", "Rot", "Rat"].each {assert it ==~ /(H|R)[aeiou]t/}
["cc", "caac", "cabac"].each {assert it ==~ /c[^a]*(a[^a]*a)*[^a]*c/}
Endliche Automaten sind Syntaxdiagramme ohne Abkürzungen, d.h. es gibt keine
„Kästchen“. Ein endlicher Automat hat genau einen Eingang und mehrere mögliche
Ausgänge.
a
Beispiele:
b
a
a
a
a
b
Üblicherweise werden bei Automaten die Kreuzungen als Kreise gemalt und Zustände (states)
genannt, die Zustände werden mit Pfeilen verbunden (sogenannten Transitionen, transitions),
die mit Terminalsymbolen beschriftet sind.
a
a
a
a
b
a
b
Ein fundamentaler Satz der Theorie der formalen Sprachen besagt, dass mit regulären
Ausdrücken genau die durch reguläre Grammatiken definierbaren Sprachen beschrieben
werden können, welches wiederum genau die Sprachen sind, die durch endliche Automaten
beschrieben werden können.
2.3 Darstellung von Algorithmen
Ein Algorithmus ist ein präzises, schrittweises und endliches Verfahren zur Lösung eines
Problems oder einer Aufgabenstellung (insbesondere zur Verarbeitung von Informationen,
vgl. Kap. 0). Das bedeutet, an einen Algorithmus sind folgende Anforderungen zu stellen:
• Präzise Beschreibung (relativ zu den Kommunikationspartnern) der zu bearbeitenden
Informationen und ihrer Repräsentation
2-46
•
Explizite, eindeutige und detaillierte Beschreibung der einzelnen Schritte (relativ zu
der Person oder Maschine, die den Algorithmus ausführen soll)
• Endliche Aufschreibung des Algorithmus, jeder Einzelschritt ist in endlicher Zeit
effektiv ausführbar, und jedes Ergebnis wird nach endlich vielen Schritten erzielt.
Um Algorithmen von einem Computer ausführen zu lassen, müssen sie (genau wie andere
Informationen) in einer für die jeweilige Maschine verständlichen Form repräsentiert werden.
Eine solche Repräsentation nennt man Programm. Zum Beispiel kann das eine Folge von
Maschinenbefehlen sein, die ein bestimmter Rechner ausführen kann. Die tatsächliche
Ausführung eines Algorithmus bzw. Programms nennt man einen Prozess. Sie findet auf
einem (menschlichen oder maschinellen) Prozessor statt. Ein Algorithmus bzw. Programm
terminiert, wenn seine Ausführung nach einer endlichen Zahl von Schritten (Befehlen)
abbricht. Ein Algorithmus (bzw. ein Programm) heißt deterministisch, wenn für jeden Schritt
der nächste auszuführende Schritt eindeutig definiert ist. Er bzw. es heißt determiniert, wenn
die Berechnung nur ein mögliches Ergebnis hat.
Beispiel: Wechselgeldbestimmung (nach Kröger / Hölzl / Hacklinger)
Aufgabe: Bestimmung des Wechselgeldes w eines Fahrscheinautomaten auf 5 Euro bei einem
Preis von p Euro (zur Vereinfachung fordern wir, p sei ganzzahliges Vielfaches von 10 Cent;
es werden nur 10-, 20- und 50-Cent Münzen zurückgegeben).
Der Algorithmus ist nicht determiniert! Damit das Ergebnis eindeutig bestimmt ist, legen wir
zusätzlich fest: „es sollen möglichst wenige Münzen zurückgegeben werden“.
Als erstes muss die Repräsentation der Schnittstellen festgelegt werden: p könnte zum
Beispiel als natürliche Zahl in Cent, als rationale Zahl oder als Tupel (Euro, Cent)
repräsentiert werden, und w als Multimenge oder Folge von Münzen. Dann erfolgt die
Aufschreibung des Algorithmus.
1. Darstellung in natürlicher Sprache: „Die Rückgabe enthält maximal eine 10-Cent-Münze,
zwei 20-Cent-Münzen, und den Rest in 50-Cent-Münzen. 10-Cent werden zurückgegeben,
falls dies nötig ist, um im Nachkommabereich auf 0, 30, 50, oder 80 Cent zu kommen. Eine
oder zwei 20-Cent-Münzen werden zurückgegeben, um auf 0, 40, 50 oder 90 Cent zu
kommen. Wenn der Nachkommaanteil 0 oder 50 ist, werden nur 50-Cent-Münzen
zurückgegeben.“
Ein Vorteil der natürlichen Sprache ist, dass sie sehr flexibel ist und man damit (fast) alle
möglichen Ideen aufschreiben kann. Nachteile bei der Darstellung von Algorithmen in
natürlicher Sprache sind, dass keine vollständige formale Syntax bekannt ist, d.h. es ist nicht
immer leicht festzustellen, ob eine Beschreibung syntaktisch korrekt ist oder nicht, und dass
die Semantik weitgehend der Intuition überlassen bleibt. Dadurch bleibt immer eine gewisse
Interpretationsfreiheit, verschiedene Prozessoren können zu verschiedenen Ergebnissen
gelangen.
2. Darstellung als nichtdeterministischer Pseudo-Code:
Sei w={} Multimenge;
Solange p<5 tue
[] erste Nachkommastelle von p ∈{2,4,7,9}:
p = p + 0.10, w = w ∪ {10c}
[] erste Nachkommastelle von p ∈{1,2,3,6,7,8}:
p = p + 0.20, w = w ∪ {20c}
[] erste Nachkommastelle von p ∈{0,1,2,3,4,5}:
p = p + 0.50, w = w ∪ {50c}
Ergebnis w
2-47
Auch Pseudo-Code verwendet keine feste Syntax, es dürfen jedoch nur solche
Sprachelemente verwendet werden, deren Bedeutung (Semantik) klar definiert ist. Damit ist
die Menge der möglichen Ergebnisse zu einer Eingabe eindeutig bestimmt.
Berechnungsbeispiel:
p=3.20, w={}  p=3.70, w={50c}  p=3.80, w={50c, 10c}  p=4.00, w={50c, 10c, 20c}
 p=4.50, w={50c, 10c, 20c, 50c}  p=5.00, w={50c, 10c, 20c, 50c, 50c}
3. Darstellung mathematisch-rekursiv:
Wechselgeld (p) =







{}, falls p=5
0,50+Wechselgeld (p+0.50), falls p≠5 und 5-p ≥ 0.50
0,20+Wechselgeld (p+0.20), falls p≠5, 5-p < 0.50 und 5-p ≥ 0.20
0,10+Wechselgeld (p+0.10), sonst
Beispiel zum Berechnungsablauf:
Wechselgeld(3.20) =0.50+Wechselgeld(3.70) =0.50+0.50+Wechselgeld(4.20)
=0.50+0.50+0.50+Wechselgeld(4.70) =0.50+0.50+0.50+0.20+Wechselgeld(4.90)
=0.50+0.50+0.50+0.20+0.10+Wechselgeld(5.00) =0.50+0.50+0.50+0.20+0.10
Die Syntax und Semantik in dieser Aufschreibung folgt den üblichen Regeln der Mathematik
bzw. Logik, ist fest definiert, aber jederzeit erweiterbar. In der Schreibweise der Informatik
könnte man dasselbe etwa wie folgt aufschreiben:
Wechselgeld (p) =
Falls 5-p=0 dann Rückgabe({}) sonst
falls 5-p ≥ 0.50 dann Rückgabe(0.50+Wechselgeld(p+0.50)) sonst
falls 5-p ≥ 0.20 dann Rückgabe(0.20+Wechselgeld(p+0.20)) sonst
Rückgabe(0.10+Wechselgeld(p+0.10))
Diese Notation lässt sich unmittelbar in funktionale Programmiersprachen übertragen. Als
Groovy--Programm aufgeschrieben sieht das etwa so aus:
int wg(p){
if (5-p==0) return(0) else
if (5-p>=0.50) return(100+wg(p+0.50)) else
if (5-p>=0.20) return(10+wg(p+0.20)) else
return(1+wg(p+0.10))}
Hier wählen wir zur Darstellung des Ergebnisses eine dreistellige natürliche Zahl, deren erste
Stelle die Anzahl der 50-Cent-Münzen, die zweite die Anzahl der 20-Cent-Münzen und die
letzte die Anzahl der 10-Cent-Münzen angibt.
wg(2.70)
411
wg(3.10)
320
Eine andere Form der Ergebnisdarstellung (Multimenge!) wäre etwa als Liste:
2-48
def wg(p){
if (5-p==0) return([]) else
if (5-p>=0.50) return([50] + wg(p+0.50)) else
if (5-p>=0.20) return([20] + wg(p+0.20)) else
return([10] + wg(p+0.10))}
assert wg(2.70) == [50, 50, 50, 50, 20, 10]
4. Darstellung als Ablaufdiagramm oder Flussdiagramm.
Start
Int(Fract(p)*10)
∈ {3, 8}?
w={}
Int(Fract(p)*10)
∈ {2,4,7,9}?
n
y
w+={10c}
p +=0.1
n
y
w+={20c}
p +=0.2
n
p<5?
y
w+={50c}
p +=0.5
Int(Fract(p)*10)
∈ {1, 6}?
n
y
w+={20c}
p +=0.2
Stop
Ein Ablaufdiagramm ist prinzipiell ein endlicher Automat! Zusätzlich dürfen jedoch
Variablen, Bedingungen, Zuweisungen, Unterprogramme und andere Erweiterungen
verwendet werden.
Ausführungsbeispiel:
p=3.20, w={}  p=3.30, w={10c}  p=3.50, w={10c, 20c}  p=4.00, w={10c, 20c, 50c}
 p=4.50, w={10c, 20c, 50c, 50c}  p=5.00, w={10c, 20c, 50c, 50c, 50c}
2-49
5. Darstellung als Groovy- oder Java-Programm (mit heftigem Gebrauch von Mathematik)
Groovy:
int fuenfzigCentStuecke(float p) {
((5 - p) * 10 / 5)}
int zehnCentStuecke(float p) {
int cent = (p-(int)(p)) * 100
cent in [20, 40, 70, 90] ? 1 : 0}
int zwanzigCentStuecke(float p) {
int tencent = ((p-(int)(p)) * 10)
coins = [0,2,1,1,0,0,2,1,1,0]
coins[tencent]}
Java:
static int fünfzigCentStücke(double p) {
return (int)((5 - p) * 10 / 5) ;}
static int zehnCentStücke(double p) {
int cent = (int)((p-(int)(p)) * 100);
return((cent==20 || cent==40 || cent==70 || cent==90) ? 1 : 0) ;}
static int zwanzigCentStücke(double p) {
int tencent = (int)((p-(int)(p)) * 10);
int[] coins = {0,2,1,1,0,0,2,1,1,0};
return coins[tencent] ;}
Syntax und Semantik sind eindeutig definiert, jedes in einer Programmiersprache
aufschreibbare Programm ist normalerweise determiniert und deterministisch (Ausnahme:
Verwendung paralleler Threads). Wie man in diesem Fall auch leicht sieht, terminiert das
Programm auch für jede Eingabe. Eine wichtige Frage ist die nach der Korrektheit, d.h.
berechnet das Programm wirklich das in der Aufgabenstellung verlangte?
6. Darstellung als Assemblerprogramm
In der Frühzeit der Informatik wurden Rechenmaschinen programmiert, indem eine Folge von
Befehlen angegeben wurde, die direkt von der Maschine ausgeführt werden konnte.
Assemblersprachen sind Varianten solcher Maschinensprachen, bei denen die
Maschinenbefehle in mnemotechnischer Form niedergeschrieben sind. Häufig verwendete
Adressen (Nummern von Speicherzellen) können mit einem Namen versehen werden und
dadurch referenziert werden. Viele Assemblersprachen erlauben auch indirekte Adressierung,
d.h. der Inhalt einer Speicherzelle kann die Adresse einer anderen Speicherzelle. Die in einer
Assemblersprache verfügbaren Befehle hängen stark von der Art der zu programmierenden
Maschine ab. Typische Assemblerbefehle sind z.B. mov rx ry (Bedeutung: transportiere /
kopiere den Inhalt von rx nach ry) oder jgz rx lbl (Bedeutung: wenn der Inhalt von rx größer
als Null ist, gehe zur Sprungmarke lbl)
2-50
// Eingabe: Preis in Register p in Cent (wird zerstört)
// Ergebnisse in fc, zc, tc (fünfzigCent, zwanzigCent, tenCent)
mov 0, fc
mov 0, zc
mov 0, tc
loop:
mov p, ac // lade p in den Akkumulator
sub ac, 450 // subtrahiere 450
jgz hugo // jump if greater zero to hugo
add ac, 500 // addiere 500
mov ac, p // speichere Akkumulator nach p
mov fc, ac
add ac, 1
mov ac, fc // fc := fc + 1
goto loop
hugo:
mov p, ac // wie oben
sub ac, 480
jgz erna
add ac, 500
mov ac, p
mov zc, ac
add ac, 1
mov ac, zc
goto hugo
erna:
mov p, ac
sub ac, 490
jgz fertig
mov 1, tc
fertig:
7. Darstellung als Maschinenprogramm
Ein Maschinenprogramm ist eine Folge von Befehlen, die direkt von einer Maschine eines
dafür bestimmten Typs ausgeführt werden kann. Jedem Assemblerbefehl entspricht dabei eine
bestimmte Anzahl von Bytes im Maschinenprogramm.
2-51
Programmiersprachen
Ein Programm ist, wie oben definiert wurde, die Repräsentation eines Algorithmus in einer
Programmiersprache. Die Menge der syntaktisch korrekten Programme einer bestimmten
Programmiersprache (JAVA, C, Delphi, …) wird im Allgemeinen durch eine kontextfreie
Grammatik beschrieben. Maschinen- und Assemblersprachen sind dabei sehr einfache
Sprachen (die sogar durch reguläre Grammatiken definiert werden könnten). Das
Programmieren in einer Maschinensprache oder Assembler ist außerordentlich mühsam und
sehr fehleranfällig. Höhere Programmiersprachen sind nicht an Maschinen orientiert,
sondern an den Problemen. Programme, die in einer höheren Programmiersprache
geschrieben sind, können nicht unmittelbar auf einem Rechner ausgeführt werden. Sie werden
entweder von einem speziellen Programm interpretiert (d.h., direkt ausgeführt) oder von
einem Compiler in eine Folge von Maschinenbefehlen übersetzt und erst dann ausgeführt.
Bei einigen Prgrammiersprachen (Java, C#, USCD-Pascal) erfolgt die Übersetzung zunächst
in die Maschinensprache einer virtuellen Maschine, d.h. einer nicht in Hardware realisierten
Maschine, welche daher unabhängig von einer speziellen Hardwaretechnologie ist. Die
Ausführung von Maschinenprogrammen der virtuellen auf einer realen Maschine erfolgt von
speziellen Interpretern (der natürlich für jede reale Maschine neu entwickelt oder angepasst
werden muss). Die Sprachen, die einer maschinellen Behandlung nicht zugänglich sind und
von Menschen in Programme überführt werden müssen, nennen wir Spezifikationssprachen.
Seit Beginn der Informatik wurden mehr als 1000 unterschiedliche Programmiersprachen
erfunden, von denen die meisten in vielen verschiedenen Varianten und Dialekten existieren.
Beispielsweise gibt es von Java inzwischen sieben Hauptvarianten (Versionen), und Groovy
kann als eine Erweiterung von Java betrachtet werden. Im vierten Kapitel werden wir
Möglichkeiten zur Klassifikation von Programmiersprachen betrachten.
2-52
Kapitel 3: Rechenanlagen
Anmerkung: Dieses Kapitel wird zum Selbststudium und der Vollständigkeit halber zur
Verfügung gestellt. Inhaltlich ist es durch die beiden Exkursionen, zum Deutschen
Technikmuseum und zum Potsdamer Platz, abgedeckt.
Um Programme auszuführen, ist ein Prozessor erforderlich, der die einzelnen Schritte tätigt.
Das kann ein Mensch oder eine Maschine (auf mechanischer, elektronischer oder
biochemischer Basis) sein, oder sogar ein anderes Programm, welches eine
Ausführungsmaschine nur simuliert.
3.1 Historische Entwicklung
Die Entwicklung und den Aufbau moderner Rechner begreift man besser, wenn man sich ihre
historischen Wurzeln betrachtet.
•
•
•
•
•
1842 Charles Babbage / Ada Lovelace: „Die analytische Maschine“; Konzept einer
programmierbaren mechanischen Rechenanlage zur Lösung von
Differentialgleichungen. Dieser „erste Computer der Weltgeschichte“ wurde jedoch
nie realisiert, da Kosten, Machbarkeit und Haltbarkeit nicht einschätzbar waren
1936: Alonzo Church (1903-1995): „lambda-Kalkül“, Begriff der berechenbaren
Funktion
1936: Alan Turing: Computer als universelle Maschine; Äquivalenz von Programm
und Daten („Turing-Maschine“)
1941 Konrad Zuse: Z3: vollautomatischer, programmierbarer, in binärer
Gleitkommarechnung arbeitender Rechner mit Speicher und einer
Zentralrecheneinheit aus Telefonrelais
1946 Johan von Neumann (EDVAC-Report): konkrete Vorschläge für Aufbau („vonNeumann-Computer“)
Programmierparadigmen:
- Analytische Maschine: Rechnen als Durchführung arithmetischer Operationen
- lambda-Kalkül, Lisp-Maschine: Rechnen als Termersetzung
- Turing-Maschine (vgl. ThI): Rechnen als Schreiben von Zeichen auf ein Band
- von Neumann: Rechnen als Modifikation von Wörtern im Speicher.
Die analytische Maschine
Babbage’s „analytische Maschine“ (*) war das „Nachfolgemodell“ der
„Differenzmaschine“ (1821-1833), die arithmetische Berechnungen
durchführen können sollte, aber nie funktionierte. Zitat eines
Zeitgenossen (L. F. Menabrea, *): “Mr. Babbage has devoted some
years to the realization of a gigantic idea. He proposed to himself
nothing less than the construction of a machine capable of executing
not merely arithmetical calculations, but even all those of analysis, if
their laws are known.” Historisches Vorbild waren mit so genannten
Jaquard-Lochkarten „programmierbare“ Webstühle (mit bis zu 24000
Karten). Die Rechenmaschine sollte ein „Mill“ genanntes Rechenwerk,
ein „Store“ genanntes Speicherwerk (1000 fünfzigstellige Zahlen), Lochkartenleser und stanzer als Ein- und Ausgabe und einen Drucker als Ausgabe enthalten. Die Maschine sollte
3-53
mit Dampf angetrieben und frei programmierbar sein. Ein Programm sollte drei Kartentypen
enthalten:
• Operationskarten
enthalten mögliche Operationen: Addition,
Subtraktion, Multiplikation und Division. Die
Maschine hat einen Schalter für den
auszuführenden Operationstyp, der in seiner
Stellung bleibt bis er durch eine Operationskarte
umgestellt wird.
• Zahlenkarten
enthalten numerische Konstanten und dienen als
externer Speicher, damit nicht alle benötigten
Zahlen im (teuren) Speicherwerk bereit gehalten
werden müssen. Auf einer Zahlenkarte steht jeweils neben dem Wert auch die
Nummer des Speichers, in welchen dieser Wert geschrieben werden soll.
Zwischenergebnisse können von der Maschine auf Karten gestanzt und später wieder
eingelesen werden.
• Variablenkarten
steuern den Transfer von Werten aus dem Store zur Mill und zurück („Adressierung“).
Die Maschine besitzt zwei Operandenregister („Ingress-Achsen“, je zweimal 50
Stellen: I1 und I1´, I2 und I2´) und ein Resultatregister („Egress-Achse“, zweimal 50
Stellen: E und E´); es gibt Karten zum Transport einer Variable (eines Speicherwerts)
in die Ingress-Achsen und zum Transport der Egress-Achse in den Speicher.
Spezielle Karten sind kombinatorische und Indexkarten, die im Kartenstapel vor- und
zurückblättern können und somit Sprünge realisieren. Für Verzweigungen gibt es einen
„Alarmhebel“, der hochgesetzt wird, falls
• bei einer arithmetischen Operation ein Überlauf oder eine Division durch Null auftritt
• das Ergebnis einer arithmetischen Operation ein anderes Vorzeichen hat als das erste
Argument (d.h. Egress-Achse E hat ein anderes Vorzeichen als die Ingress-Achse I1 )
Ferner gibt es Kontrollkarten wie „Stopp“ und „Pause“.
Aus den vorhandenen Dokumenten lässt sich rekonstruieren, dass für die analytischen
Maschine folgende Befehle vorgesehen waren (Ausschnitt):
<Programm> ::= {Karte}
<Karte> ::= <Zahlkarte> | <Opkarte> | <Varkarte>
<Zahlkarte> ::= “N”[z ]13 _ [“+|“-][z ]50
0
Die Zahl wird an der bezeichneten Stelle in den Speicher eingetragen
<Opkarte> ::= “+” | “-” | “*” | “/”
Die Operation wird für nachfolgende Befehle eingestellt
<Varkarte> ::= <Transferkarte> | <Kartenkarte> | <Atkarte>
<Transferkarte> ::= (“L”|“Z”|“S”)[z ]13 [“´”]
Lzzz: Transfer des Inhalts der Variable zzz in die Mill Ingress Achse
Zzzz: Wie Lzzz, wobei Variable zzz gleichzeitig auf Null gesetzt wird
Szzz: Transfer der Egress-Achse in Variable zzz
Falls ein ´ nach der Adresse steht, sind die zweiten 50 Stellen betroffen
Transfer in I2 löst die Ausführung der eingestellten Operation aus
<Kartenkarte> ::= “C”(“F”|“B”)(“+”|“?”)[z ]50
0
blättert die angegebene Zahl von Karten vor (F) oder zurück (B)
+ bedeutes unbedingtes, ? bedingtes Blättern (falls Alarmhebel hochgesetzt)
3-54
<Atkarte> ::= “B” | “H” | “P”
B läutet eine Glocke um den Operateur zu verständigen
H hält die Maschine an (keine weiteren Karten werden gelesen)
P druckt den Wert der Egress-Achse auf dem Druckapparat
Beispiel: drucke 17 + 4
N001
+00000000000000000000000000000000000000000000000017
N002
+00000000000000000000000000000000000000000000000004
+
L001
L002
P
Beispiel: Variable in Speicher 003 := 10000 div 28, Variable 004 := 10000 mod 28,
N1 10000
N2 28
/
L1
L2
S3'
S4
Beispiel: Fakultätsfunktion S2 := S1!
N1 6
N2 1
N3 1
*
L2
L1
S2
L1
L3
S1
L3
L1
CB?11
Beispiel: drucke Tabelle für f(x) = x2 + 6x + 6, x=1..10
V1 = x
V2 = x2
V3 = 6
V4 = x2, x2+6x, x2+6x+6
V5 = 6x
N1 10
N3 6
*
L1
L1
S4
L1
L3
S5
+
L4
3-55
L5
S4
L4
L3
S4
…
Umformung als f(x) = (x+3)2 – 3 bringt eine Verbesserung:
V1 = x
V2 = 3
V3 = x+3, (x+3)2, (x+3)2 – 3
N1 10
N2 3
+
L1
L2
S3
*
L3
L3
S3
L3
L2
S3
Ada Lovelace schlägt etliche solcher Verbesserungs-Transformationen vor.
Ähnliche (einfachere) Optimierungen heute im Code-Generator guter Compiler enthalten.
Ada Lovelace schreibt: “the Analytical Engine does
not occupy common ground with mere `calculating
machines´”… “on the contrary, (it) is not merely
adapted for tabulating the results of one particular
function and of no other, but for developing and
tabulating any function whatever. In fact the engine
may be described as being the material expression
of any indefinite function of any degree of
generality and complexity.“ …
“It may be desirable to explain, that by the word
operation, we mean any process which alters the
mutual relation of two or more things, be this
relation of what kind it may. This is the most
general definition, and would include all subjects in
the universe.” …
“Supposing, for instance, that the fundamental
relations of pitched sounds in the science of
harmony and of musical composition were
susceptible of such expression and adaptations, the
engine might compose elaborate and scientific
pieces of music of any degree of complexity or
extent.“ …
3-56
Gedanken zur Universalität mathematischer Funktionen
Die Turingmaschine
Nahezu 100 Jahre später (1936) untersuchte Alan Turing die Grenzen
des Berechenbaren. Die Arbeit „On Computable Numbers, with an
Application to the Entscheidungsproblem“ kann als der Beginn der
modernen Informatik betrachtet werden. Turing definierte darin eine
hypothetische Maschine, die die „Essenz des Rechnens“ durchführen
können soll. Eine „berechenbare“ reelle Zahl ist eine, deren
(unendliche Folge von) Nachkommastellen endliche Mittel (d.h. durch
einen Algorithmus) berechnet werden kann. Turing schreibt:
„Computing is normally done by writing certain symbols on paper. We may suppose this
paper is divided into squares like a child's arithmetic book. In elementary arithmetic the twodimensional character of the paper is sometimes used. But such a use is always avoidable, and
I think that it will be agreed that the two-dimensional character of paper is no essential of
computation. I assume then that the computation is carried out on one-dimensional paper, i.e.
on a tape divided into squares. I shall also suppose that the number of symbols which may be
printed is finite. If we were to allow an infinity of symbols, then there would be symbols
differing to an arbitrarily small extent. …
The behaviour of the computer at any moment is determined by the symbols which he is
observing, and his "state of mind" at that moment. We may suppose that there is a bound B to
the number of symbols or squares which the computer can observe at one moment. If he
wishes to observe more, he must use successive observations. We will also suppose that the
number of states of mind which need be taken into account is finite. The reasons for this are
of the same character as those which restrict the number of symbols. If we admitted an
infinity of states of mind, some of them will be "arbitrarily close" and will be confused.“
Eine Turingmaschine ist also gegeben durch
• einen endlichen Automaten zur Programmkontrolle, und
• ein (potentiell unbegrenztes) Band auf dem die Maschine Zeichen über einem
gegebenen Alphabet notieren kann.
Zu jedem Zeitpunkt kann die Maschine genau eines der Felder des Bandes lesen (abtasten,
„scannen“), und, ggf.. abhängig von der Inschrift dieses Feldes, das Feld neu beschreiben,
zum linken oder rechten Nachbarfeld übergehen, und einen neuen Zustand einstellen.
Hier ist eine Syntax, die Turings Originalschreibweise nahe kommt.
<Turingtabelle> ::= {<Zeile>}
<Zeile> ::= <Zustand> <Abtastzeichen> <Operation> <Zustand>
<Zustand> ::= <Identifier>
<Abtastzeichen> ::= <Zeichen>
<Operation> ::= {R | L | P<Symbol>}
Dabei wird angenommen, dass für jeden Zustand und jedes Abtastzeichen des Alphabets
genau eine Zeile der Tabelle existiert, welche Operation und Nachfolgezustand festlegt.
(Turing nennt solche Maschinen “automatisch”, wir nennen sie heute “deterministisch”. In
Turings Worten: „If at any stage the motion of a machine is completely determined by the
3-57
configuration, we shall call the machine an ‚automatic machine’ ... For some purposes we
might use machines (choice machines or c-machimes) whose motion is only partially
determined by the configuration… When such a machine reaches one of these ambiguous
configurations, it cannot go on until some arbitrary choice has been made by an external
operator… In this paper I deal only with automatic machines.’’)
Da die Tabellen für deterministische Turingmaschinen oft sehr groß werden, darf man Zeilen,
die nicht benötigte werden, weglassen, und Zeilen, die sich nur durch das Abtastzeichen
unterscheiden, zusammenfassen. In der entsprechenden Zeile sind beliebige Mengen von
Abtastzeichen zugelassen. „any“ steht dann für ein beliebiges Abtastzeichen, d.h. für das
gesamte Alphabet. Ferner fordert Turing, dass das Alphabet immer ein spezielles Leerzeichen
„none“ enthält, und „E“ eine Abkürzung für „P none“ ist..
Beispiel für eine Maschine, die das Muster „0 11 0 11 0 11…“ auf ein leeres Band schreibt:
s0
s1
any
any
P0,R,R s1
P1,R,P1,R,R
s0
Ein äquivalentes Programm mit nur einem Zustand s0 ist
s0 none P0 s0
s0 0 R,R,P1,R,P1 s0
s0 1 R,R,P0 s0
Beispiel für eine Maschine, die die Sequenz 0 01 011 0111 01111 … erzeugt:
Arbeitsweise dieser Maschine:
3-58
Die von Turing verwendete Tabellenschreibweise für Programme betrachten wir heute als
unleserlich. Eine moderne Variante („Turing-Assembler“) wäre etwa:
<Turingprogram> ::= {<statement>}
<statement> ::= <label>“:” | “print” <symbol> “;” |
“left;” | “right;” | “goto” <label>“;”
<label> ::= <Identifier>
In dieser Notation sähe unser Beispielprogramm etwa so aus:
label0:
print 0;
right;
right;
print 1;
right;
print 1;
right;
right;
goto s0;
Im Internet sind viele Turingmaschinen-Simulatoren verfügbar, versuchen Sie z.B.
http://math.hws.edu/TMCM/java/labs/xTuringMachineLab.html
3-59
Andere empfohlene Beispiele:
http://www.matheprisma.uni-wuppertal.de/Module/Turing/
http://ais.informatik.uni-freiburg.de/turing-applet/turing/TuringMachineHtml.html
Zuse Z3
erster voll funktionsfähiger programmierbarer Digitalrechner
viele Merkmale moderner Rechner:
• Relais-Gleitkommaarithmetikeinheit für Arithmetik
• einem Relais-Speicher aus 64 Wörtern, je 22 bit
• einem Lochstreifenleser für Programme auf Filmstreifen
• eine Tastatur mit Lampenfeld für Ein- und Ausgabe von Zahlen und der
manuellen Steuerung von Berechnungen.
• Taktung durch Elektromotor, der Taktwalze antreibt (5rps)
Programmiersprache: Plankalkül
http://www.zib.de/zuse/Inhalt/Programme/Plankalkuel/Compiler/plankalk.html
Von-Neumann-Rechner, EDVAC & ENIAC
John von Neumann wurde vor hundert Jahren (im Dezember 1903) in
Budapest geboren. 1929 wurde er als jüngster Privatdozent in der
Geschichte der Berliner Universität habilitiert. Von Neumann wurde
binnen kurzer Zeit weltberühmt durch seine vielfältigen Interessen auf dem
Gebieten Mathematik, Physik und Ökonomie. 1930 emigrierte er wegen
der Nazis nach USA. In Princeton schuf von Neumann dann mit dem von
ihm erdachten Rechnerkonzept die Grundlagen für den Aufbau
elektronischer Rechenanlagen, die noch bis heute gültig sind. Er gilt daher
als einer der Begründer der Informatik
EDVAC, ENIAC (Electronic Numerical Integrator and Computer)
• Elektronenröhren zur Repräsentation von Zahlen
• elektrische Pulse für deren Übertragung
• Dezimalsystem
• Anwendung: H-Bomben-Entwicklung
• Programmierung durch Kabel und Drehschalter
3-60
3.2 von-Neumann-Architektur
1945 First Draft of a Report on the EDVAC (Electronic Discrete Variable Automatic
Computer): Befehle des Programms werden wie zu verarbeitenden Daten behandelt, binär
kodiert und im internen Speicher verarbeitet (vgl. Zuse, Turing)
Ein von-Neumann-Computer enthält mindestens die folgenden fünf Bestandteile:
1. Input unit (kommuniziert mit der Umgebung)
2. Main memory (Speicher für Programme und Daten)
3. Control unit (führt die Programme aus)
4. Arithmetic logical unit (für arithmetische Berechnungsschritte)
5. Output unit (kommuniziert mit der Umgebung)
Rechenwerk (central processing unit, CPU)
Steuerwerk
(control unit)
Rechenwerk
(arithmetic logical unit, ALU)
Hauptspeicher (Main memory)
Eingabe (input)
Ausgabe (output)
3-61
Peripherie
Konsole
Bildschirm
Tastatur
(+Maus)
Drucker
Plattenspeicher
Prozessor
(CPU - central processing unit)
Steuerwer
k
Register:
Rechenwerk
CDLaufwerk
E/A
Prozessor
(einzelne Operationen)
Register:
Bus (Verbindung)
Hauptspeicher (Arbeitsspeicher)
Prinzipien:
• Der Rechner enthält zumindest Speicher, Rechenwerk, Steuerwerk und
Ein/Ausgabegeräte. „EVA-Prinzip“: Eingabe – Verarbeitung – Ausgabe
• Der Rechner ist frei programierbar, d.h., nicht speziell auf ein zu bearbeitendes
Problem zugeschnitten; zur Lösung eines Problems wird ein Programm im
Speicher ablegt. Dadurch ist jede nach der Theorie der Berechenbarkeit mögliche
Berechnung programmierbar.
o Programmbefehle und Datenworte liegen im selben Speicher und werden je
nach Bedarf gelesen oder geschrieben.
o Der Speicher ist unstrukturiert; alle Daten und Befehle sind binär codiert.
o Der Speicher wird linear (fortlaufend) adressiert. Er besteht aus einer Folge
von Plätzen fester Wortlänge, die über eine bestimmte Adresse einzeln
angesprochen werden können und bit-parallel verarbeitet werden.
o Die Interpretation eines Speicherinhalts hängt nur vom aktuellen Kontext
des laufenden Programms ab. Insbesondere: Befehle können Operanden
anderer Befehle sein (Selbstmodifikation)!
• Der Befehlsablauf wird vom Steuerwerk bestimmt. Er folgt einer sequentiellen
Befehlsfolge, streng seriell und taktgesteuert.
o Zu jedem Zeitpunkt führt die CPU nur einen einzigen Befehl aus, und
dieser kann (höchstens) einen Datenwert verändern (single-instructionsingle-data).
o Die normale Verarbeitung der Programmbefehle geschieht fortlaufend in
der Reihenfolge der Speicherung der Programmbefehle. Diese sequentielle
Programmabarbeitung kann durch Sprungbefehle oder datenbedingte
Verzweigungen verändert werden.
3-62
• Die ALU führt arithmetische Berechnungen durch, indem sie ein oder zwei
Datenwerte gemäß eines Befehls verknüpft und das Ergebnis in ein vorgegebenes
Register schreibt. Zwei-Phasen-Konzept der Befehlsverarbeitung:
o In der Befehlsbereitstellungs- und Decodierphase-Phase wird, adressiert
durch den Befehlszähler, der Inhalt einer Speicherzelle geholt und als
Befehl interpretiert.
o In der Ausführungs-Phase werden die Inhalte von einer oder zwei
Speicherzellen bereitgestellt und entsprechend den Opcode als Datenwerte
verarbeitet.
• Datenbreite, Adressierungsbreite, Registeranzahl und Befehlssatz als Parameter
der Architektur
• Ein- und Ausgabegeräte sind z.B. Schalter und Lämpchen, aber auch entfernte
Speichermedien (Magnetbänder, Lochkarten, Platten, …). Sie sind mit der CPU
prinzipiell auf die selbe Art wie der Hauptspeicher verbunden.
Zentrale Befehlsschleife (aus *):
Vergleiche: Assemblersprache, Ausführung eines Befehles
Vor- und Nachteile der von-Neumann-Architektur:
+ minimaler Hardware-Aufwand, Wiederverwendung von Speicher
+ Konzentration auf wesentliche Kennzahlen: Speichergröße, Taktfrequenz
- Verbindungseinrichtung CPU – Speicher stellt einen Engpass dar („von-NeumannFlaschenhals“)
- keine Strukturierung der Daten, Maschinenbefehl bestimmt Operandentyp
„von-Neumann-bottleneck“ John Backus, Turing-Award-Vorlesung 1978:
When von Neumann and others conceived it [the von Neumann computer] over thirty years ago, it was
an elegant, practical, and unifying idea that simplified a number of engineering and programming
problems that existed then. Although the conditions that produced its architecture have changed
3-63
radically, we nevertheless still identify the notion of "computer" with this thirty [jetzt fast sechzig] year
old concept.
In its simplest form, a von Neumann computer has three parts" a central processing unit (or CPU), a
store, and a connecting tube that can transmit a single word between the CPU and the store (and send an
address to the store). I propose to call this tube the von Neumann bottleneck. The task of a program is to
change the store in a major way; when one considers that this task must be accomplished entirely by
pumping single words back and forth through the von Neumann bottleneck, the reason for its name
becomes clear.
Wie oben erwähnt, sind auch heute noch die meisten Computer nach der von-NeumannArchitektur konstruiert. Beispiel: Architektur des Intel Pentium-Prozessors (*):
Realisierung der einzelnen Baugruppen durch Mengen von Halbleiterschaltern; z.B. eines
Halbaddierers (Summe= E1 XOR E2, Übertrag=E1 AND E2):
3-64
Beschreibung der Hardware auf verschiedenen Ebenen: Physikalische Ebene, TransistorEbene, Gatter-Ebene (s.o.), Register-Ebene, Funktionsebene (siehe TI)
Abweichungen und Varianten der von-Neumann-Architektur:
• Spezialisierte Eingabe-Ausgabe-Prozessoren
o z.B. Grafikkarte mit 3D-Rendering
o z.B. Modem oder Soundkarte zur Erzeugung von Tönen
o z.B. Tastatur-, Netzwerk- oder USB-Controller, die auf externe Signale warten
• Parallelität zwischen/innerhalb von Funktionseinheiten, z.B.
o Blocktransfer von Daten
o Pipelining in CPU
• Duplikation von Funktionseinheiten
o z.B. Mehrprozessorrechner, Mehrkern-Architektur: Zwei oder mehr CPU
auf einem Chip, Kopplung durch speziellen Memory-Control-Bus; z.B.
Pentium Extreme Edition 840 (April 2005), Preis 999 Dollar, „dürfte allerdings
nur wenig Käufer finden“; Aktuell: Quadcore, Octocore (Sun UltraSparc, Intel
Nehalem 2008);
• komplexere Speicherstrukturen, z.B.
o Register (einzelne Speicherwörter direkt in der CPU)
o Caches (schnelle Pufferspeicher) und Bus-Hierarchien für Verbreiterung des
Flaschenhalses
o Harvard-Architektur (Trennung von Daten- und Befehlsspeicher)
• komplexere Verbindungsstrukturen
o externe Standardschnittstellen: IDE, SCSI, USB, IEEE1394/Firewire/iLink
o ISA/PCI/AGP: hierarchischer bzw. spezialisierter Aufbau des Bussystems
• komplexere Befehle, z.B.
o mehrere Operanden
o indirekte Adressierung („Adresse von Adresse“)
o CISC versus RISC
• Programmunterbrechung durch externe Signale
o Interrupt-Konzept
o Mehrbenutzer-Prozesskonzept
3.3 Aufbau PC/embedded system, Speicher
Heutige PCs sind meist prinzipiell nach der von-Neumann-Architektur , mit o.g.
Erweiterungen, konstruiert. Beispiel (Gumm/Sommer p55)
3-65
Prinzipiell ist dieser Aufbau auch in den meisten eingebetteten Steuergeräten zu finden.
Beispiel: ein Steuergerät im Audi quattro, welches zwei separate Prozessoren für Zündung
und Ladung enthält, und ein seriell einstellbares Drehzahlsteuergerät für Elektromotoren.
3-66
Unterschiede zur „normalen“ Rechnerarchitektur:
• Ein- und Ausgabegeräte sind Sensoren und Aktuatoren
• Wandlung analoger in digitale Signale und umgekehrt auf dem Chip (A/D D/A)
• Der Speicher ist oft nichtflüchtig und manchmal mit dem Prozessor integriert
• Meist wird nur wenig Datenspeicher benötigt, der Programmspeicher wird nur bei der
Produktion oder Wartung neu beschrieben ( andere Speicherkonzepte)
• Hohe Stückzahlen verursachen Ressourcenprobleme (Speicherplatz)
• Oft komplizierte Berechnungen mit reellen Zahlen (DSP, digitale Signalprozessoren),
spezialisierte ALUs für bestimmte numerische Algorithmen.
Speicher
Speicher dienen zur temporären oder permanenten Aufbewahrung von (binär codierten)
Daten. Sie können nach verschiedenen Kriterien klassifiziert werden
• Permanenz: flüchtig – nichtflüchtig (bei Ausfall der elektrischen Spannung)
o Halbleiterspeicher sind meist flüchtig, magnetische / optische Speichermedien
nicht.
o So genannte Flash Speicher sind nichtflüchtige Halbleitermedien, die
elektrisch beschrieben und gelöscht werden können. Bei Flash-Speichern ist
nicht jedes einzelne Bit adressierbar, die Zugriffe erfolgen auf Sektorebene
ähnlich wie bei Festplatten. Vorteil: keine mechanisch bewegten Teile.
• Geschwindigkeit: Zugriffszeit (Taktzyklen oder ns) pro Wort (mittlere, maximale)
o Gängige Zugriffszeiten liegen bei 1-2 Taktzyklen für Register in der ALU, 250 ns für einen schnellen Cache, 100-300 für einen Hauptspeicherzugriff, 1050ms für einen Plattenzugriff, 90 ms für eine CD-ROM, Sekundenbereich für
Floppy Disk, Minutenbereich für Magnetbänder
• Preis: Cent pro Byte
3-67
•
o Ein 512 MB Speicherbaustein oder CompactFlash kostet 100 Euro (2*10-5
c/B=20c/MB), eine 120GB Festplatte etwa genauso viel (8.3*10-8 c/B
=83c/GB), ein 700MB CD-Rohling 25 cent (3.5*10-8c/B=35c/GB)
Größe: gemessen in cm2 oder cm3 pro Bit
o Papier: 6000 Zeichen/630cm²=10B/cm²; Floppy: 1.44MB/ 80cm2=18 KB/cm2;
Festplatte: 10-20 GB/cm2, physikalisch machbar 1000GB/cm2 m
o für mechanisch bewegte Speicher muss der Platz für Motor und
Bewegungsraum mit berücksichtigt werden.
Wie man sieht, sind Geschwindigkeit und Preis umgekehrt proportional. Üblicherweise wird
der verfügbare Speicherplatz daher hierarchisch strukturiert. Das Vorhalten von Teilen einer
niedrigeren Hierarchieebene in einer höheren nennt man Caching.
Cache („Geheimlager“): kleiner, schneller Vordergrundspeicher, der Teile der Daten des
großen, langsamen Hintergrundspeichers abbildet („spiegelt“).
Konzept: Hauptspeicher = Folge von Tupeln (Adresse, Inhalt)
Cache-Speicher = Folge von Quadrupeln Cache-Zeilen:
(Index, Statusbit, Adresse, Inhalt)
Index: Adresse im Cache
Statusbits: modifiziert?, gültig?, exklusiv?, …
Adresse: Speicherzelle die gespiegelt wird
Falls die CPU ein Datum dat einer bestimmten Adresse adr benötigt, wird zunächst geprüft,
ob es im Cache ist (d.h. (idx, sbt, adr, dat) im Cache). Zwei Fälle:
Cache Hit: D.h. (idx, sbt, adr, dat) im Cache gespiegelt: dat wird
Cache Miss: kein idx mit (idx, adr, …) im Cache. Die Speicherzelle adr mit Inhalt dat
muss aus dem Hauptspeicher nachgeladen werden; ggf. muss dafür ein bereits belegter
Platz im Cache geräumt werden (Verdrängungsstrategie)
SPEICHER
3786:
3787:
3788:
3789:
3790:
3791:
3792:
3793:
…
17
"c"
3.1415
3786
"x"
123456
3790
NIL
…
CACHE
c1: 0
c2: 1
c3: 0
1
1
1
0
0
0
3787
3788
3792
"c"
3.1415
3790
c1: 0
c2: 1
c3: 0
1
1
1
0
0
0
3790
3788
3792
"x"
3.1415
3790
Falls jede Hintergrundadresse prinzipiell in jede Cache-Zelle geladen werden kann, ist der
Cache assoziativ (fully associative).
Vorteil: Flexibilität.
3-68
Nachteil: gesamter Cache muss durchsucht werden, ob Hit oder Miss.
Falls eine Cache-Zelle nur bestimmte Hintergrundadressen abbilden kann, sagen wir der
Cache ist direkt abgebildet (direct mapped)
Beispiel: C0 für H00, H10, H20, …, C1 für H01, H11, H21, …Suche H63 nur in C3!
Vorteil: schnelle Bestimmung ob Hit oder Miss; Nachteil: Unflexibilität
Hitrate ist entscheidend für Leistungssteigerung durch Cache; Verdrängungsstrategie
beeinflusst Hitrate entscheidend
• LRU: Least recently used
• FIFO: First-In, First-Out
• LFU: Least frequently used
Cache Coherency Problem: Mehrere Prozesse greifen auf Speicher zu, jeder Prozess hat einen
eigenen Cache: Wie wird die Konsistenz sichergestellt?
3-69
Kapitel 4: Programmiersprachen und –umgebungen
Zur Wiederholung: Informatik ist die Wissenschaft von der automatischen Verarbeitung von
Informationen; ein Algorithmus ist ein präzises, schrittweises und endliches Verfahren zur
Verarbeitung (Umformung) von Informationen, und ein Programm ist die Repräsentation
eines Algorithmus zur Ausführung auf einer Maschine
Während v. Neumann noch der Ansicht war, dass „Rechenzeit zu wertvoll für niedere
Aufgaben wie Übersetzung“ ist, sind heute dagegen die Personalkosten der Hauptfaktor bei
den Softwarekosten. Darüber hinaus ist die Codierung in Maschinensprache oder Assembler
sehr fehleranfällig (wie jeder, der die Haufgaben bearbeitet hat, wohl gemerkt haben dürfte).
Daher sucht man nach problemorientierten statt maschinenorientierten Beschreibungsformen.
Die Verständlichkeit eines Programms ist oftmals wichtiger als die optimale Effizienz.
Vorteile höherer Programmiersprachen zeigen sich
• bei der Erstellung von Programmen:
o schnellere Programmerstellung
o sichere Programmierung.
• bei der Wartung von Programmen:
o bessere Lesbarkeit
o besseres Verständnis der Algorithmen.
• wenn Programme wiederverwendet werden sollen:
o Verfügbarkeit auf vielen unterschiedlichen Rechnern; vom Zielrechner
unabhängige Entwicklungsrechner.
4.1 Programmierparadigmen
Unter einem Programmierparadigma versteht man ein Sprachkonzept, welches als Muster
prägend für die Programme einer bestimmten Gruppe oder Sprache ist. Gängige
Programmierparadigmen sind
o funktional / applikativ
o imperativ
o objektorientiert
o logikbasiert
o deklarativ
o visuell / datenflussorientiert
o funktionale oder applikative Sprachen betrachten ein Programm als mathematische
Funktion f, die eine Eingabe I in eine Ausgabe O überführt: O = f(I). Variablen sind
Platzhalter im Sinne der Mathematik (Parameter).
Ausführung = Berechnung des Wertes mittels Termersetzung.
Beispiele: Lisp, SML, Haskell, ...
SML:
fun fak (n) = if n < 1 then 1 else n * fak (n-1)
LISP:
(defun fak (n)
(cond (le n 1) 1 (* n (fak (– n 1)))))
o imperative Sprachen unterstützen das ablauforientierte Programmieren. Werte werden
sog. Variablen (= Speicherplätzen) zugewiesen. Diese können ihren Zustand ändern, d.h.
im Laufe der Zeit verschiedene Werte annehmen (vgl. von-Neumann-Konzept).
Ausführung = Folge von Variablenzuweisungen.
Beispiele: Algol, C, Delphi, ...
4-70
// Fakultätsfunktion in C:
#include <stdio.h>
int fakultaet(int n) {
int i, fak = 1;
for ( i=2; i<=n; i++ )
fak *= i;
return fak;
}
int main(void) {
int m, n;
for ( n=1; n<=17; n++ )
printf("n = %2d n! = %10d\n", n, fakultaet(n));
}
o objektorientierte Sprachen sind imperativ, legen aber ein anderes, in Objekten
strukturiertes Speichermodell zugrunde. Objekte können eigene lokale Daten und
Methoden speichern, voneinander erben und miteinander kommunizieren.
Ausführung = Interaktion von Agenten.
Beispiele: Smalltalk, C++, Java
//Java Polymorphie-Beispiel
import java.util.Vector;
abstract class Figur
{ abstract double getFlaeche();
}
class Rechteck extends Figur
{ private double a, b;
public Rechteck ( double a, double b )
{ this.a = a;
this.b = b; }
public double getFlaeche() {return a * b;}
}
class Kreis extends Figur
{ private double r;
public Kreis( double r )
{ this.r = r; }
public double getFlaeche()
{return Math.PI * r * r;
}
}
public static void main( String[] args )
{ Rechteck re1 = new Rechteck( 3, 4 );
Figur kr2 = new Kreis( 8 );
…
o logikbasierte Sprachen betrachten ein Programm als (mathematisch-) logischen Term t,
dessen Auflösung (im Erfolgsfall) zu gegebenen Eingabewerten I passende Ausgabewerte
O liefert: t(I,O) → true.
4-71
Ausführung = Lösen eines logischen Problems.
Beispiel: Prolog
fak(0,1).
fak(N,X):- N > 0, M is N - 1, fak(M,Y), X is N * Y.
mutter (eva, maria).
mutter (maria, uta).
grossmutter(X,Y) :- mutter(X,Z), mutter(Z,Y).
?- grossmutter (eva, uta)
yes.
?- grossmutter (uta, eva)
no.
o deklarative Sprachen betrachten ein Programm als eine Menge von Datendefinitionen
(Deklarationen) und darauf bezogenen Abfragen.
Ausführung = Suche in einem Datenbestand.
Beispiel: SQL
SELECT A_NR,
A_PREIS As Netto,
0.16 As MwSt,
A_PREIS * 1.16 As Brutto,
FROM ARTIKEL
WHERE A_PREIS <= 100
ORDER BY A_PREIS DESC
o datenflussorientierte Sprachen stellen ein Programm dar, indem sie den Fluss der Signale
oder Datenströme durch Operatoren (Addierer, Integratoren) beschreiben
Ausführung = Transformation von Datenströmen
Beispiel: Simulink, Microsoft Visual Programming Language (VPL)
4-72
4.2 Historie und Klassifikation von Programmiersprachen
Zur obigen Grafik gibt es viele Varianten und Alternativen, je nach Vorliebe des DiagrammErstellers.
Programmiersprachen lassen sich klassifizieren nach
 Anwendungsgebiet: Ingenieurwissenschaften (Fortran), kommerzieller Bereich (Cobol),
künstliche Intelligenz (Prolog, Lisp), Systemsoftware (Assembler, C), Steuerungssoftware
(C, Ada, Simulink), Robotik (C, NQC, VPL), Internet / Arbeitsplatzsoftware (Java, C++),
Datenbanken (SQL), …
4-73



•
•
o Spezialsprachen („DSL, domain specific languages“): SPSS (Statistik);
TeX/LaTeX, PostScript (Textverarbeitung); Lex, Yacc (Compilerbau); Z, B, CSP
(Programmspezifikation); UML, SimuLink (Modellierung)
Verbreitungsgrad: (derzeit )industrierelevante, überlieferte und akademische Sprachen
Historischer Entwicklung: „Stammbaum“ der Programmiersprachen, siehe oben
Programmiersprachengeneration: Abstraktionsgrad;
o 1. Generation: Maschinensprachen
o 2. Generation: Assemblersprachen (x86-MASM, ASEM-51)
o 3. Generation: Algorithmische Sprachen (Algol, Delphi, …)
o 4. Generation: Anwendungsnahe Sprachen (VBA, SQL, …)
o 5. Generation: Problemorientierte Sprachen, KI-Sprachen (Prolog, Haskell, …)
Programmierparadigmen (siehe Kapitel 4.1)
Verfügbaren Sprachelementen
o Kontrollfluss, Datentypen
o Rekursion oder iterative Konstrukte
o Sequentielle oder parallele Ausführung
o Interpretiert oder compiliert
o Realzeitfähig oder nicht realzeitfähig
o Streng typisiert, schwach typisiert oder untypisiert
o Statisch oder dynamisch typisiert
o Textuell oder graphisch
o …
50
44
45
47
45
40
35
30
25
19
20
15
10
10
13
22
14
6
5
0
Fortran
Delphi
Pascal
Java
Cobol
C++
Das Balkendiagramm zeigt die in deutschen Softwarehäusern überwiegend verwendeten
Sprachen, zitiert nach (Bothe, Quelle: Softwaretechnik-Trends Mai 1998).
Mehrfachnennungen waren bei der Befragung möglich.
Die „babylonische Sprachvielfalt“ der verschiedenen Programmiersprachen verursacht
Kosten: Portabilitätsprobleme, Schulungsmaßnahmen, Programmierfehler. Daher gab es
immer wieder Versuche, Sprachen zu vereinheitlichen (ADA, ANSI-C, …). Trotzdem kam es
in der Forschung immer wieder zu neuen Ideen, neuen Konzepten und in der Folge zu neuen
Programmiersprachen. Daher wird es auf absehbare Zeit viele verschiedene Sprachen und
Dialekte geben. Für Informatiker ist es deshalb wichtig, die verschiedenen Konzepte zu
kennen und sich schnell in neue Sprachen einarbeiten zu können.
4.3 Java und Groovy
Java wurde seit 1995 bei der Firma Sun Microsystems entwickelt. Ursprünglich war die
Sprache zur Implementierung von sicheren, plattform-unabhängigen Anwendungen gedacht,
die über das Internet verbreitet und bezogen werden können. Ein wichtiges Konzept ist das
4-74
sog. Applet, eine Mini-Anwendung, die innerhalb einer Web-Seite läuft. Inzwischen sind
praktisch alle Internet-Browser „Java-fähig“ und können Applets ausführen. Künftige
Anwendungen von Java liegen voraussichtlich im Bereich der kommunizierenden Geräte
(„Internet der Dinge“). Vorzüge von Java sind:
o Java ist eine mächtige Programmiersprache, die die Sprachkonzepte herkömmlicher
Programmiersprachen wie C oder Pascal mit OO-Konzepten und Konzepten zur
Parallelverarbeitung und Netz-Verteilung verbindet. Zielsetzung war es, eine möglichst
schlanke Sprache zu schaffen, die das Klassenkonzept ins Zentrum der Sprache stellt.
o Das Konzept des Java Byte Code und der Java Sandbox erlauben eine einfache
Portierbarkeit und sichere Ausführbarkeit auf den unterschiedlichsten Plattformen. Die
Offenheit und hohe Portabilität von Java macht die Sprache zu einem guten Werkzeug für
das Programmieren von Netz-Anwendungen.
o Java gilt als robuste Sprache, die viele Fehler vermeiden hilft, z.B. aufgrund ihres
Typkonzepts und der automatischen Speicherbereinigung. Java-Compiler sind im
Allgemeinen vergleichsweise schnell und produzieren effizienten Code.
o Durch die große Beliebtheit in akademischen Kreisen und bei Open-Source-Anwendern
hat Java eine umfangreiche Klassenbibliothek, und es gibt viele Unterstützungswerkzeuge.
Im Kern ist Java jedoch eine Programmiersprache mit imperativen und rekursiven Konzepten
ähnlich wie Pascal oder C.
 So weist Java die meisten aus diesen Sprachen bekannten Konzepte wie
Variablen, Zuweisungen, Datentypen, Ausdrücke, Anweisungen etc. auf - mit
einer keineswegs verbesserten Syntax
 Viele syntaktische und strukturelle Mängel und Ungereimtheiten sind nur mit
der C / C++ Historie zu erklären
Die Ausführung eines Java-Programms geschieht im Allgemeinen in folgenden Schritten:
(1) Eingabe des Programmtextes in einem Editor oder einer IDE (integrierten
Entwicklungsumgebung).
(2) Compilation, d.h. Übersetzung des Programms (oder von Teilen des Programms) in
Byte-Code für die „virtuelle Java-Maschine“ (JVM).
(3) Binden und Laden, d.h. Zusammentragen aller verwendeten Bibliotheksklassen,
Ersetzung von Sprungadressen usw., und ggf. Übertragung des Programms auf die
Zielplattform.
(4) Aufruf des Programms und Start des Prozesses.
Bei der so genannten „just-in-time-compilation“ wird Schritt (2), (3) und (4) verschränkt zur
Laufzeit ausgeführt, d.h., es werden immer nur die Teile übersetzt (z.B. von ByteCode in
Maschinencode), die gerade benötigt werden. Die meisten modernen Compiler bzw.
Laufzeitumgebungen unterstützen diese Methode.
Konzept der virtuellen Maschine:
Bis in die 1990-Jahre musste für jede Sprache und jede mögliche Hardware-Plattform ein
eigener Compiler erstellt werden. Das Konzept einer „virtuellen Maschine“, d.h., eines
standardisierten Befehlssatzes, erlaubt es, Compiler zu erstellen, die Code für diese
idealisierte Maschine erstellen (in Java: Byte Code für die Java Virtual Machine). Auf den
einzelnen Plattformen muss dann nur noch eine „Laufzeitumgebung“ definiert werden, die
diese virtuelle Maschine mit der realen Hardware simuliert.
4-75
Delphi
Sun-Ray
Java
Groovy
Scala
Java
PC
C
C++
C++
Android
JVM
Groovy
C
C#
Sun-Ray
C#
PC
.NET
Android
Java-Historie
ab 1991 Bei der Firma Sun wird von J. Gosling und Kollegen auf der Basis von C++ eine
Sprache namens Oak (Object Application Kernel) für den Einsatz im Bereich der
Haushalts- und Konsumelektronik entwickelt. Ziele: Plattform-Unabhängigkeit,
Erweiterbarkeit der Systeme und Austauschbarkeit von Komponenten.
1993
Oak wird im Green-Projekt zum Experimentieren mit graphischen BenutzerSchnittstellen eingesetzt und später (wegen rechtlicher Probleme) in Java
umbenannt. Zu diesem Namen wurden die Entwickler beim Kaffeetrinken
inspiriert.
1994
Das WWW beginnt sich durchzusetzen. Java wird wegen der Applet-Technologie
„die Sprache des Internets“
seit 1995 Sun bietet sein Java-Programmiersystem JDK (Java Development Kit) mit einem
Java-Compiler und Interpreter kostenlos an.
ab 1996 Unter dem Namen JavaBeans wird eine Komponenten-Architektur für JavaProgramme entwickelt und vertrieben.
ab 2001 Eclipse-Projekt: integrierte Entwicklungsumgebung für Java und (darauf
aufbauend) andere Sprachen und Systeme.
2006-2007 JDK als Open-Source freigegeben (OpenJDK)
Groovy
Groovy ist eine Erweiterung von Java, die 2003 definiert wurde mit den folgenden Zielen:
• skriptartige Sprache, d.h., einzelne Anweisungen können sofort ausgeführt werden
• dynamische Typisierung, d.h., es ist möglich, den Typ von Objekten zur Laufzeit vom
System bestimmen zu lassen
• funktionale Programmierung mit Closures, d.h. Auffassung von Code als Daten, der zur
Laufzeit analysiert und übersetzt wird
• originäre Unterstützung von Listen, Mengen, endlichen Funktionen; reguläre Ausdrücke
und Mustervergleich für Textbearbeitungsaufgaben
• Schablonensystem für HTML, SQL; Scripting von Office- und anderen Anwendungen
• einfachere, „sauberere“ Syntax als Java
• weitgehende Kompatibilität zu Java, Code für die Java Virtual Machine
Auf Grund der einfachen Handhabung in der Groovy-Konsole eignet sich Groovy besonders
für Programmieranfänger und für die „schnelle“ Erstellung von Programmen („agile
Software-Entwicklung“).
4-76
4.4 Programmierumgebungen am Beispiel Eclipse
Während der Erstellung eines Programms sind vom Programmierer verschiedene Aufgaben
zu erledigen. Dafür stehen verschiedene Werkzeuge zur Verfügung:
• Eingabe des Textes – Texteditor, syntaxgesteuerte Formatierer
• Übersetzung in Maschinencode – Compiler bzw. Interpreter
• Binden zu einem lauffähigen Programm – Linker, Object Code Loader
• Finden von semantischen Fehlern – Debugger, Object Inspector
• Optimierung des Programms – Profiler, Tracer
• Auffinden von Bibliotheksroutinen – Library Class Browser
• Design der graphischen Benutzungsoberfläche – GUI-Builder
• Modellierung des Problems – Modeling Tools
• Verwaltung verschiedener Versionen – Versionskontrollsystem
• Dokumentation – Dokumentationsgeneratoren, Klassenhierarchieanzeiger
• Testen – Testgeneratoren
Ursprünglich waren alle diese Funktionen in separaten Werkzeugen realisiert. Das ist recht
umständlich, weil der Programmierer ständig zwischen den Werkzeugen wechseln muss.
Daher begann man bereits in den 1970-er Jahren, die verschiedenen Aktivitäten beim
Übersetzen und Binden durch Skripten zusammenzufassen (Make-files). Später wurden
integrierte Entwicklungsumgebungen (integrated development environments, IDE)
geschaffen, die den Texteingabe-, Übersetzungs- und Ausführungsprozess zusammenfassten.
2001 begann, initiiert durch die IBM, die Entwicklung der Eclipse IDE, einer freien,
erweiterbaren Entwicklungsumgebung. Ursprünglich war Eclipse nur eine IDE für Java.
Durch den Plug-In-Mechanismus ist es beliebigen Entwicklern möglich, Erweiterungen (auch
für andere Programmiersprachen) vorzunehmen, so dass heute über 100 verschiedene
integrierbare Werkzeuge vorliegen.
Eclipse zeichnet sich vor allem aus durch:
• minimale Kernfunktionalität, extreme Erweiterbarkeit
• Persistenz (gesamter Entwicklungszustand bleibt erhalten)
• verschiedene Sichten auf ein Projekt („Views“), projektspezifisch konfigurierbare
Perspektiven (Fenster, Leisten,…)
4-77
•
•
•
syntaxgesteuerte Editoren, Just-in-Time Compiler, …
JDT, CDT: Java / C++ Development Tools
EMF, GEF: Eclipse Modelling Frameworks, Graphical Editing Framework
Zu Eclipse gibt es eine umfangreiche online-Dokumentation im Programm selbst.
Die aktuelle Version von Eclipse kann bezogen werden unter http://www.eclipse.org/
Zur Installation des Groovy-Plugins ist in Eclipse der folgende Server anzugeben:
http://dist.springsource.org/milestone/GRECLIPSE/e3.5/
4-78
Kapitel 5: Applikative Programmierung
In der applikativen Programmierung wird ein Programm als eine mathematische Funktion von
Eingabe-in Ausgabewerte betrachtet. Das Ausführen eines Programms besteht dann in der
Berechnung des Funktionswertes zu einem gegebenen Eingabewert. Historisch erwachsen ist
dieses Paradigma aus dem Church’schen λ–Kalkü (lambda-Kalkül)l.
5.1 λ-Kalkül
Gegeben sei ein Alphabet A={x1,…,xn}. Ein λ–Term ist definiert durch folgende Grammatik:
λ–Term ::= Variable | (λ–Term λ–Term) | λ Variable . λ–Term
Variable ::= x1 | … | xn
λ–Terme der Art (t1 t2) heißen Applikation, λ–Terme der Art λx.t heißen λ-Abstraktion.
In der λ-Abstraktion (λx.t) ist die Variable x innerhalb von t gebunden. Intuitiv wird der λ–
Term (t1 t2) gelesen als „die Funktion t1 angewendet auf Argument t2“ und der λ–Term λx.t als
„diejenige Funktion, die x als Argument hat und t als Ergebnis liefert“.
Die Auswertung von λ–Termen ist definiert durch die Regel der β-Konversion:
(λ x. t1) t2 = t1 [x:=t2]
Hierbei bezeichnet t1 [x:=t2] den Term t1, wobei jedes freie (d.h. nicht durch ein λ gebundene)
Vorkommnis von x durch t2 ersetzt wird. Klammern werden, soweit eindeutig, weggelassen,
wobei Linksassoziativität unterstellt wird: (x y z) = ((x y) z)
Eine weitere Regel ist die der α-Konversion, d.h. die Umbenennung von gebundenen
Variablen:
λ x. t = λ y. t[x:=y]
Beispiel für eine Reduktion ist etwa die folgende:
((λx. x x) ((λz. z y) x)) 
((λz. z y) x) ((λz. z y) x) 
((λz. z y) x) (x y) 
(x y) (x y)
Eine andere Ableitung für denselben Term wäre z.B.:
((λx. x x) ((λz. z y) x)) 
(λx. x x) (x y) 
(x y) (x y)
Alonzo Church zeigte, dass man mit dem λ-Kalkül und einem kleinen „Trick“ alle
arithmetischen Funktionen berechnen kann:
Sei
0 ≡ λf.λx. x
1 ≡ λf.λx. f x
2 ≡ λf.λx. f (f x)
3 ≡ λf.λx. f (f (f x))
...
n ≡ λf.λx. fn x
Dann lassen sich die arithmetischen Funktionen wie folgt definieren:
succ ≡ λn.λf.λx.n f (f x)
5-79
plus ≡ λm.λn.λf.λx. m f (n f x)
mult ≡ λm.λn.λf. n (m f)
Als Beispiel zeigen wir, dass 1+1=2:
(λm.λn.λf.λx. m f (n f x)) (λf.λx. f x)(λf.λx. f x)
= (λf.λx. (λf.λx. f x) f ((λf.λx. f x) f x))
= (λf.λx. (λx. f x) ((λx. f x) x))
= (λf.λx. (λx. f x) (f x))
= (λf.λx. f (f x))
Auch die Kodierung boolescher Ausdrücke ist möglich.
Anforderung an True, False und If:
(If True M N = M) und (If False M N = N)
Definitionen: True = (λx.λy.x), False = (λx.λy.y), If = (λi.λt.λe.ite)
Beweis:
If True M N
= (λi.λt.λe.ite) (λx.λy.x) M N
= (λt.λe.(λx.λy.x) te) M N
= (λe.(λx.λy.x) M e) N
= (λx.λy.x) M N
= (λy.M) N
= M
If False M N
= (λiλtλe.ite)(λx.λy.y) M N
= (λx.λy.y) M N
= (λy.y) N
= N
Auf diese Weise lässt sich jedes Programm einer beliebigen Programmiersprache auch im λKalkül ausdrücken (sogenannte Church’sche These oder Church-Turing-These).
Anmerkung: Natürlich lässt sich der λ-Kalkül direkt in Groovy ausdrücken. Beispiel:
nul = { f -> { x -> x}}
one = { f -> { x -> f (x) }}
two = { f -> { x -> f (f (x))}}
plus = { m -> { n -> { f -> { x -> (m (f)) ((n(f)) (x))}}}}
Leider schlägt aber der folgende Vergleich fehl:
assert ((plus (one)) (one)) == two
Das liegt daran, dass keine Programmiersprache die Gleichheit von Funktionen feststellen
kann! Abhilfe kann dadurch geschaffen werden, dass wir für f etwa die Funktion "I"+x und
für x die leere Zeichenreihe einsetzen:
f = {x -> "I"+x}
assert ((plus (one)) (one)) (f) ("") == two (f) ("")
(plus (one) (one)) (plus (one) (one)) (f) ("")
5.2 Rekursion, Aufruf, Terminierung
Eine Funktion oder Methode (auch: Funktionsprozedur) besteht aus einem Funktionskopf
(auch Signatur genannt) und einem Funktionsrumpf. Der Kopf besteht im Wesentlichen aus
dem Namen, den Parametern (oder Argumenten) und dem Ergebnistyp der Funktion. In Java
5-80
kann optional noch eine Anzahl von Modifikatoren (public, private, protected, static, final) davor
stehen. Die folgenden Syntaxdiagramme sind aus http://www.infosun.fim.unipassau.de/cl/passau/kuwi05/SyntaxDiagrams.pdf
In Groovy gibt es die Möglichkeit, durch Angabe von def den Ergebnistyp dynamisch
bestimmen zu lassen. Der Rumpf der Funktion ist (in Java) ein Anweisungsblock bzw. (in
Groovy) eine Closure. Ein Anweisungsblock ist ein in {…} durch Semikolon getrennte Liste
von Anweisungen. In Groovy kann ein solcher Anweisungsblock auch einer Variablen
zugewiesen werden, so dass er später aufgerufen werden kann (closed block oder closure).
Ein solcher Anweisungsblock darf auch – genau wie im λ–Kalkül – formale Parameter
enthalten.
def f(x) {3*(x**2) + 2*x + 5}
assert f(5) == 90
vollkommen gleichwertig dazu sind folgende Groovy-Anweisungen:
def g = {x -> 3*x**2+2*x+5}
assert g(5) == 90
Das bedeutet, der Term λ x. t wird in Groovy notiert als {x -> t}.
Natürlich kann eine Funktion mehrere Parameter unterschiedlichen Typs enthalten:
def gerade (int a,b, float x) {return a*x+b}
def parabel = { int a,b,c, float x -> a*x**2+b*x+c}
Rekursion
Der Wert einer Funktion ist das Ergebnis der letzen ausgeführten Anweisung. Falls innerhalb
des Blocks eine Anweisung einen Ausdruck enthält, der den Namen der Funktion selbst
enthält, sagt man, die Funktion ist rekursiv. Bei einer einfachen Rekursion gibt es nur einen
einzigen rekursiven Aufruf (Beispiel: Fakultätsfunktion); falls dieser jeweils die letzte Aktion
bei der Ausführung ist, sprechen wir von einer Endrekursion (Tail-end-Rekursion). Bei einer
Kaskadenrekursion gibt es mehrere rekursive Aufrufe auf der gleichen Schachtelungstiefe
5-81
(Beispiel: Fibonacci). In einer geschachtelten Rekursion kommen rekursive Aufrufe innerhalb
von rekursiven Aufrufen vor; Beispiel ist die McCarthy’sche 91-er Funktion:
f = {n -> (n>100)? (n-10) : f(f(n+11))}
g = {n -> (n>100)? (n-10) : 91}
assert (1..200).each {f(it) == g(it)}
Intuitiv lässt sich folgende Analogie herstellen:
Endrekursion – reguläre (Chomsky-3) Grammatik
einfache Rekursion – kontextfreie (Chomsky-2) Grammatik
Kaskadenrekursion – kontextsensitive (Chomsky-1) Grammatik
geschachtelte Rekursion – allgemeine (Chomsky-0) Grammatik
Diese Analogie gilt allerdings nur bedingt, weil sich bei Verfügbarkeit von Zuweisungen jede
Funktion als Endrekursion darstellen lässt. Da sich Endrekursionen auf von-NeumannRechnern besonders effizient ausführen lassen (mit nur einer Schleife), war das Thema der
„Entrekursivierung“ lange Zeit von Bedeutung.
Die so genannte Ackermann-Péter-Funktion ist ein Beispiel für eine arithmetische Funktion,
die sich nicht mit einfachen Mitteln entrekursivieren lässt:
ack(0,m) = m+1
ack(n+1,0) = ack(n,1)
ack(n+1,m+1) = ack(n, ack(n+1,m))
oder in Java/Groovy:
def ack(n,m) {(n==0)? m+1 : (m==0)? ack (n-1,1) : ack(n-1, ack(n,m-1))}
Hier sind die Werte der Funktion für verschiedene Eingabeparameter
m=0
m=1
m=2
m=3
m=4
m=5
m=6
m=7
m=8
m=9
n=0 n=1 n=2 n=3
1
2
3
5
2
3
5
13
3
4
7
29
4
5
9
61
5
6 11 125
6
7 13 253
7
8 15 509
8
9 17 1021
9 10 19 2045
10 11 21 4093
Es ist eine interessante Übung, die Werte für n≥4 zu berechnen.
Aufrufmechanismen
Falls innerhalb eines λ-Terms ein Teilterm (…((λ x. t1) t2 )…) vorkommt, so kann auf diesen
die β-Konversion angewendet werden. Im Falle mehrerer solcher Teilterme erlaubt es der λKalkül, diese Regel an beliebiger Stelle anzuwenden. In einer Programmiersprache muss
hierfür jedoch eine Reihenfolge festgelegt werden.
Beispiel: f= {x,y -> (x==y)? x : f(f(x,y),f(x,y))}
Wie erfolgt die Berechnung von f(0,1)?
5-82
Unter der “leftmost-innermost”-oder normalen Auswertungsregel versteht man die Regel,
dass jeweils der am weitesten links stehende Ausdruck, der keinen weiteren rekursiven Aufruf
mehr enthält, expandiert wird.
Beispiel: f(0,1)
= (0==1)? 0: f(f(0,1), f(0,1))
= f(f(0,1), f(0,1))
= f((0==1)? 0: f(f(0,1), f(0,1)), f(0,1))
= f(f(f(0,1), f(0,1)), f(0,1))
= f(f(f(f(0,1), f(0,1)), f(0,1)), f(0,1))
=…
Bei der „leftmost-outermost“ oder verzögerten Ausführung („lazy evaluation“) wird jeweils
der äußerste linke Aufruf expandiert:
Beispiel: f(0,1)
= (0==1)? 0: f(f(0,1), f(0,1))
= f(f(0,1), f(0,1))
= (f(0,1) == f(0,1))? f(0,1) : f(f(f(0,1),f(0,1)),f(f(0,1),f(0,1)))
= f(0,1) = …
Bei der „full substitution“-Regel werden alle Aufrufe gleichzeitig expandiert. Für g= {x,y ->
(x==y)? 0 : g(g(x,y),g(x,y))} terminiert die verzögerte, nicht jedoch die normale Auswertung.
Java/Groovy verwenden wie die meisten anderen Programmiersprachen die normale
Auswertungsregel:
fun = {x,y -> println ("Aufruf mit x: "+x+", y: "+y);
(y==0)? y : fun (1, y-1) + fun (2, y-1) }
fun(0,2)
ergibt
Aufruf mit
Aufruf mit
Aufruf mit
Aufruf mit
Aufruf mit
Aufruf mit
Aufruf mit
Result: 0
x:
x:
x:
x:
x:
x:
x:
0,
1,
1,
2,
2,
1,
2,
y:
y:
y:
y:
y:
y:
y:
2
1
0
0
1
0
0
Das Church-Rosser-Theorem im λ–Kalkül besagt, dass die Relation , mit t1  t2 g.d.w. t2
durch β-Reduktion aus t1 entstanden ist, die Rauteneigenschaft („diamond property“) besitzt:
Für alle Terme x, y z gilt: falls xy und xz,
so existiert ein Term w mit yw und zw.
Daraus folgt unmittelbar, dass die Rauteneigenschaft auch für Ableitungsfolgen gilt:
Falls x*y und x*z, so existiert w mit y*w und z*w.
Daraus lässt sich folgern, dass wenn ein Term nicht mehr weiter reduziert werden kann, das
Ergebnis eindeutig sein muss:
Falls x*y und x*z und y ist irreduzibel, so gilt z*y
Also ist für terminierende Berechnungen die Frage der Reihenfolge letztlich nur für die
Effizienz der Berechnung, nicht aber für das richtige Ergebnis wichtig.
5-83
Terminierungsbeweise
Um für eine rekursive Funktion zu beweisen, dass sie terminiert, müssen wir eine Abbildung
der Parameter in die natürlichen Zahlen (oder, allgemeiner, in eine fundierte Ordnung) finden,
so dass für jeden Aufruf die aufgerufenen Parameterwerte echt kleiner sind als die
aufrufenden.
Beispiel:
fun = { x -> (x>=10)? x**2 : x * fun(x+1) }
Um zu zeigen, dass die Funktion fun terminiert, betrachten wir die Abbildung
f: Z  N0 , f(x) = max(10 – x, 0).
Dann ist für x ≤ 10 auch f(x+1) = 10 – (x+1) = 9 – x < 10 – x = f(x).
Also können wir folgern, dass fun(x) für alle x aus Z terminiert.
Formal ist das ein Schluss nach dem Schema der transfiniten Induktion, siehe Kap. 1.2:
Aussage: Für alle i und alle x gilt: Wenn f(x) = i, so terminiert fun(x)
(a) Induktionsanfang: Wenn f(x) = 0, so terminiert fun(x)
Beweis: Wenn f(x)=0, so ist x≥10, in diesem Fall terminiert fun
Induktionsvoraussetzung: Für alle x mit f(x) < i gilt dass fun(x) terminiert, zeige: Wenn
f(x)=i, so terminiert fun(x). Beweis: fun(x) ruft fun(x+1); oben gezeigt: f(x+1)<f(x), also
f(x+1)<i, also terminiert fun(x+1), also auch fun(x).
5-84
Kapitel 6: Konzepte imperativer Sprachen
Imperative Sprachen waren historisch die ersten „höheren Programmiersprachen“ (3.
Generation). Da Paradigma imperativer Sprachen (Programmieren als strukturierte Folge von
Speicheränderungen) entspricht dem Konzept der von-Neumann-Architektur. Das
Basiskonzept der imperativen Programmierung besteht darin, dass Anweisungen den Wert
von Speicherzellen (Variablen) verändern. Strukturierungskonzepte sind die Gruppierung von
Anweisungen in Anweisungsblöcken, Fallunterscheidungs- und Wiederholungsanweisungen,
und Prozeduren oder Funktionen.
6.1 Variablen, Datentypen, Ausdrücke
Betrachten wir als Beispiel zwei Java-Implementierungen der Fakultätsfunktion:
static int fakRek(int x) {
return (x<=0) ? 1 : x * fakRek(x-1);
}
static int fakIt(int x) {
int result = 1;
while (x>0) { result *= x--; };
return result;
}
Typische Sprachelemente imperativer Sprachen, die hier auftreten, sind
Methodendeklarationen und Methodenaufrufe, Parameter und lokale Variablen, Terme mit
arithmetischen und logischen Operatoren, Zuweisungen und Dekremente, bedingte Terme
bzw. Fallunterscheidungen, und Wiederholungsanweisungen. Die einzelnen Sprachelemente
werden nachfolgend erläutert. Für weitere Informationen verweisen wir auf den „MiniJavakurs“ unter http://java.sun.com/docs/books/tutorial/java/nutsandbolts/index.html.
Variablen haben in typisierten Sprachen wie Java immer einen festgelegten Datentyp. Jede
Variable entspricht einer Speicherzelle. Dies gilt natürlich nur, falls die Variable einen Typ
hat, der durch ein Speicherwort repräsentiert werden kann; eine Variable eines komplexen
Typs enthält einen Verweis auf ein Objekt (Gruppe von Speicherzellen). Es gibt mehrere
Arten von Variablen: Methoden-Parameter, lokale Variablen, Instanzvariablen und
Klassenvariablen.
• Methoden haben Parameter (Ein/Ausgabevariablen)
• Methoden können lokale Variablen haben
• Jedes Objekt hat Instanzvariablen (nicht statisch)
(Das Wort „Instanz“ ist eine schlechte Übersetzung des englischen „instance“, die sich aber nichtsdestotrotz
eingebürgert hat.)
• Klassen können Klassenvariablen besitzen (statisch)
Instanzvariablen und Klassenvariablen heißen auch die Datenfelder der Klasse. Betrachten
wir ein (willkürliches) Beispiel:
public class Main {
static double wechselkurs = 1.32;
int betrag = 7;
static int zehnCentStücke(double p) {
int cent = (int)((p-(int)(p)) * 100);
return((cent==20) ? 1 : 0) ;}
public static void main(String[] args) {
double p = 2.70;
System.out.print("p= ");
System.out.println(p);
}
6-85
}
Hier ist p in main eine lokale Variable, p in zehnCentStücke ein Parameter, betrag eine
Instanzvariable und .wechselkurs eine Klassenvariable.
Java kennt folgende primitive Datentypen:
• Zahlen: int, byte, short, long, float, double (43.8F, 2.4e5)
• Zeichen: char (z.B. 'x') Sonderzeichen '\n', '\”', '\\', '\uFFF0'
• Zeichenreihen (Strings): “Infor“ + “matik“
(streng formal sind Zeichenreihen keine primitiven Objekte, obwohl man sie als Konstante aufschreiben kann)
• Wahrheitswerte: boolean (true, false)
• „Leerer Typ“ void
Der leere Typ wird aus formalen Gründen gebraucht, wenn z.B. eine Methode kein Ergebnis
liefert. Hier sind einige Beispiele von Datendeklarationen:
boolean result = true;
char capitalC = 'C';
byte b = 100;
short s = 10000;
int i = 100000;
int decVal = 26;
int octVal = 032;
int hexVal = 0x1a;
double d1 = 123.4;
double d2 = 1.234e2;
float f1 = 123.4f;
String s = „ABCDE“;
Benötigt man Variablen für mehrere gleichförmige Daten (d.h. viele Daten mit gleichem
Typ), so kann man eine Reihung (Array) deklarieren
(http://java.sun.com/docs/books/tutorial/java/nutsandbolts/index.html).
Die Reihung könnte etwa wie im folgenden Beispielprogramm verwendet werden.
class ArrayDemo {
public static void main(String[] args) {
int[] anArray; // eine Reihung ganzer Zahlen
anArray = new int[10]; // Speicherallokation
anArray[8] = 500;
System.out.println("Element at index 8: " + anArray[8]);
}
}
Der Rumpf einer Java-Methode besteht aus einem Block, (Groovy: closed block oder closure)
d.h. einer Folge von Anweisungen (statements). Bei der Ausführung werden diese der Reihe
6-86
nach abgearbeitet. Die wichtigste Art von Anweisungen sind Ausdrücke (expressions). Bei
ihrer Auswertung liefern sie einen Wert ab und können per Nebeneffekt den Inhalt von
Variablen verändern.
Hier ist ein Ausschnitt aus der Syntax von Java in BNF, in der Ausdrücke definiert werden
(vgl. http://java.sun.com/docs/books/jls/second_edition/html/syntax.doc.html, Kap. 18):
•
•
•
•
•
•
•
Expression::=Expr1 [AssignmentOperator Expr1]
Expr1::=Expr2 [? Expression : Expr1]
Expr2 ::=Expr3 {Infixop Expr3}
Infixop::= + | * | < | == | != | || | && | …
Expr3::= PrefixOp Expr3 |
( Expr | Type ) Expr3 |
Primary {Selector} {PostfixOp}
Prefixop::= ++ | -- | ! | ~ |+ | Primary::= ( Expression ) | Literal | new Creator |
Identifier { . Identifier }[ IdentifierSuffix] | …
ACHTUNG: nach dieser Syntax ist eine Zuweisung also auch ein Ausdruck! Es stellt sich die
Frage, was der Wert einer Zuweisung (etwa x = 3) ist: der Wert ist bei einer Zuweisung
immer der zugewiesene Wert (d.h. das Ergebnis der rechten Seite, im Beispiel also 3). Daher
ist es möglich, eine Zuweisung auf der rechten Seite einer Zuweisung zu verwenden (also
etwa y = (x = 3) oder x = y = z)
Beispielweise könnten Ausdrücke etwa wie folgt aussehen:
17 + 4
x = x + 1
b = b != !b
a += (a = 3)
v[i] != other.v[i]
cube[17][x+3][++y]
"Länge = " + ia.length + "," + ia[0].length + " Meter"
Java verlangt, dass jede lokale Variable vor ihrer Verwendung initialisiert wurde, und zwar
so, dass es statisch erkennbar ist. Diese Bedingung wird vom Compiler mit einer so genannten
statischen Analyse überprüft und in Eclipse ggf. bereits beim Tippen des Programmtextes als
Fehler markiert. Allerdings kann die statische Analyse nur bedingt erfolgreich sein. Es gibt
keinen Compiler, der in jedem Fall erkennt, ob alle Variablen korrekt initialisiert sind! Eine
gute Faustregel ist es, alle Variablen gleich bei der Deklaration zu initialisieren.
In Java gibt es folgende Operatoren (vergleiche die oben angegebene Syntax):



•
•
Arithmetische Operatoren: +, -, *, /, %, ++ und ––
/ und % sind (ganzzahlige) Division und Rest
++ und –– als Post- und Präfixoperatoren; a++ erhöht a um 1 und ergibt a, während
++a den Wert a+1 liefert
+ dient auch zur Konkatenation von Zeichenreihen!
Relationale Operatoren: ==, !=, <, <=, > und >=
Achtung! == und != vergleichen in Java bei Objekten nur Referenzen!
Logische Operatoren: !, &&, ||, &, | und ^
a || b wertet b nur aus falls a nicht gilt
Zuweisungsoperatoren = , +=, -=, *=, /=, %, &=, |=, ^=, <<=, >>= und >>>=
Fallunterscheidung (dreistellig) x? y: z
6-87
•
•
Bitoperatoren ~, |, &, ^, >>, >>> und <<; Bitoperatoren können auch auf numerische
Typen (int, long usw.) angewendet werden (siehe Bit-Repräsentation)
Operatoren new und instanceof
a instanceof b gibt an, ob Objekt a eine Instanz des Typs b oder einer ihrer
Unterklassen ist ; in Groovy kann man das auch mit dem Attribut .class erreichen:
int i=5; println i.class
•
Operatoren für Member- und Array-Zugriff
MeineKlasse.meinDatenfeld
meinArray[7]
Hier sind noch drei Anmerkungen für Spezialisten:
public class Demo
{
static public int sampleMethod() {
int i = 0;
boolean b = ++i==i++ | ++i==i++;
return i;
}
static public int sampleMethod2() {
int i = 0;
boolean b = ++i==i++ || ++i==i++;
return i;
}
}
Das Ergebnis von sampleMethod und sampleMethod2 unterscheidet sich, da im
zweiten Fall der hintere Teil der Disjunktion nicht ausgewertet wird. Merke: Dies ist kein
Beispiel für gute Programmierung! Man sagt, dass ein Ausdruck einen Nebeneffekt (oder
auch Seiteneffekt) hat, wenn er neben dem eigentlichen Ergebnis weitere Variablen verändert.
Ausdrücke mit Nebeneffekten sind schwer verständlich und sollten deshalb möglichst wenig
verwendet werden!
public class
static int
static int
static int
}
Rechtsshift {
i = -64;
j = i>>2;
k = i>>>2;
In diesem Beispiel hat i den Wert -64, j den Wert -16 und k den Wert 1073741808. Merke:
Bit-Shifts sollten nur mit Vorsicht verwendet werden!
In Groovy gibt es die Möglichkeit simultaner Mehrfachzuweisungen:
def (a, b) = [1, 2]
ergibt a==1 und b==2.
Wie bereits oben erwähnt, sind Java und Groovy streng typisierte Sprachen. Das bedeutet,
Alle Operatoren in Java sind strikt typisiert, d.h., es wird geprüft, ob der Typ dem verlangten
entspricht. Z.B. kann << nicht auf float oder String angewendet werden. Zur
Typumwandlung gibt es entsprechende Konversions- (Casting-)Operatoren; z.B.
(int)3.14 // ergibt 3
(double)3.14f // Ausweitung
Dabei ist zu beachten, dass bestimmte Typen inkompatibel sind
(z.B. boolean und int)
6-88
In Groovy wird der Typ einer Variablen, wenn er nicht vom Benutzer angegeben wurde,
automatisch bestimmt. Dadurch können auch Konversionen automatisch berechnet werden.
Es ist trotzdem ein guter Programmierstil, den Typ von Variablen statisch festzulegen und
Konversionen explizit anzugeben.
Ausdrücke werden gemäß den „üblichen“ Operator-Präzedenz-Regeln ausgewertet.
Regeln für die Auswertungsreihenfolge sind:
 linker vor rechter Operator, dann Operation
 gemäß Klammerung
 gemäß Operator-Hierarchie
Achtung: Die Reihenfolge der Auswertung von Ausdrücken kann das Ergebnis beeinflussen!
Beispiel:
int i=2, j = (i=3)*i; // ergibt 9
int i=2, j = i*(i=3); // ergibt 6
Überraschenderweise ist die Multiplikation also nicht kommutativ! Generell ist zu sagen, dass
die Verwendung von Nebeneffekten schlechter Programmierstil ist!
6.2 Anweisungen und Kontrollstrukturen
In Java gibt es folgende Arten von Anweisungen:
• Leere Anweisung ;
• Block, d.h. eine mit {…} geklammerte Folge von Anweisungen; Blöcke dürfen auch
geschachtelt werden
• Lokale Variablendeklaration
• Ausdrucksanweisung
 Zuweisung
 Inkrement und Dekrement
 Methodenaufruf
 Objekterzeugung
Ein wichtiges programmiersprachliches Mittel ist der Aufruf von Methoden der API
(applications programming interface). Dazu muss die entsprechende Bibliothek importiert
werden (falls sie es nicht schon standardmäßig ist): import java.awt.*.
(Grafik: © Bothe 2009)
Zugriff auf Bibliotheksfunktionen erfolgt mit Qualifizierern, z.B.
System.out. println(„Hallo“)
java.util.Date d = new java.util.Date ();
6-89
Zur Steuerung des Ablaufs werden Fallunterscheidungen verwendet.
• if-Anweisung
 if (ausdruck) anweisung; [else anweisung;]
 üblicherweise sind die Anweisungen Blöcke.
 hängendes else gehört zum innersten if
Beispiel: if (b1) if (b2) a1 else a2
wird a2 ausgeführt, wenn b1 wahr und b2 falsch ist?
• switch-Anweisung
 switch (ausdruck) {
{case constant : anweisung}
[default: anweisung]}
D.h., in einer switch-Anweisung kommen beliebig viele case-Anweisungen, optional
gefolgt von einer default-Anweisung. Bei der Auswertung wird zunächst der Ausdruck
ausgewertet und dann in den entsprechenden Zweig verzweigt. Switch-Anweisungen sind tief
geschachtelten if-Anweisungen vorzuziehen, da sie normalerweise übersichtlicher und
effizienter sind.
Kontrollflusselemente
Folgende Wiederholungsanweisungen und Steuerungsbefehle werden häufig gebraucht:
• while-Schleife
 allgemeine Form:
while (ausdruck) anweisung;
Die while-Schleife ist abweisend: Vor der Ausführung der Anweisung wird
auf jeden Fall der Ausdruck geprüft. Beispiel:
i=fak=1; while (i++<n) fak*=i;

In Java gibt es zusätzlich die nichtabweisende Form
do anweisung; while (ausdruck);
•
diese Schleife wird auf jeden Fall einmal durchlaufen, dann wird geprüft.
Beispiel:
do fak *= i++; while (i<=n)
Jedes do A while (B) kann gleichwertig geschrieben werden als
A; while (B) do A; (Achtung: Code-Replikation!)
for-Schleife
 allgemeine Form:
for (init; test; update) anweisung;
Beispiel:
for (int i= fak = 1; i<=n; i++){ fak *= i;};

In Java darf die Initialisierung mehrere Anweisungen (mit Komma getrennt!)
enthalten (auch gar keine); in Groovy wird an Hand der Zahl der Anweisungen
entschieden, was Initialisierung, Test und Update ist (Benutzung nicht
empfohlen!)
• continue, break und return
 continue verlässt den Schleifendurchlauf
 return verlässt die aufgerufene Methode
 break verlässt die Schleife komplett
Es kann auch zu einer markierten äußeren Schleife gesprungen werden:
outer: while (A) {
inner: while (B) {
dosomething;
if (C) break outer
}
}
6-90
In Groovy sind darüber hinaus folgende vereinfachte Schreibweisen erlaubt:
• 5.times{…}
• (1..5).each{fak *= it} //Schleifenvariable 'it'
• 1.upto(5){fak *= it}
• for (i in [1,2,3,4,5]){fak *= i}
• for (i in 1..5){fak *= i}
• for( c in "abc" ){…}
6.3 Sichtbarkeit und Lebensdauer von Variablen
Unter der Sichtbarkeit (Skopus) einer Variablen versteht man den Bereich im Quellcode, in
dem dieser Variablenname verwendet werden darf. Der Gültigkeitsbereich ist derjenige Teil
der Sichtbarkeit, in der man auf den Inhalt der Variablen zugreifen kann. Die Lebensdauer ist
die Zeitspanne, während der für die Variable Speicherplatz reserviert werden muss
Für jede der verschiedenen Variablenarten gibt es detaillierte Festlegungen hierzu.
Beispiel:
public class xyz {
int x;
public xyz() {
x = 1;
}
public int sampleMethod(int y) {
int z = 2;
return x + y + z;
}
}
In diesem Beispiel bezieht sich x innerhalb von xyz und in sampleMethod auf die
Instanzvariable, y in sampleMethod auf den Parameter, und z auf die lokale Variable.
Variablen können sich auch gegenseitig verschatten: Unter Verschattung versteht man die
Einschränkung des Gültigkeitsbereichs einer Variablen durch eine andere Variable gleichen
Namens. Es gelten folgende Regeln:
• Lokale Variablen werden in einem Block (einer Folge von Anweisungen) deklariert
und sind nur für diesen verwendbar.
• Lokale Variable sind ab ihrer Deklaration bis zum Ende des Blocks oder der Methode
sichtbar.
• Instanzvariable können von gleichnamigen lokalen Variablen verschattet werden, d.h.,
sie sind sichtbar aber nicht gültig.
Verschattung ist hilfreich, um das Lokalitätsprinzip zu unterstützen (z.B. in
Laufanweisungen). Es ist nicht erlaubt, dass lokale Variablen sich gegenseitig oder
Methodenparameter verschatten
6-91
Beispiel für Verschattung:
public class xyz {
int x;
public xyz() {
x = 1;
}
public int sampleMethod(int y) {
int x = 2;
return x + y;
}
}
Gültigkeitsbereich und Sichtbarkeit kann in Java/Groovy auch durch Modifikatoren
eingeschränkt werden. Öffentliche (public) Klassenvariablen sind überall sichtbar; private
Klassenvariablen (private) sind in der sie enthaltenden Klasse überall sichtbar, dienen also als
„gemeinsamer Speicher“ der Objekte. Methodenvariablen sind nur innerhalb der Methode
sichtbar, sind also „Hilfsvariablen“ zur Ausführung der Methode; der Speicherplatz wird nur
während der Abarbeitung der Methode reserviert. Es gehört zum guten Programmierstil, alle
Variablen so lokal wie möglich zu deklarieren, um die Gültigkeitsbereiche überschaubar zu
halten.
Als Konvention hat sich eingebürgert, Variablennamen immer mit Kleinbuchstaben beginnen
zu lassen und Teilworte mit Großbuchstaben direkt anzufügen. Beispiel:
int diesIstEineIntegerVariable.
Finale Variablen (Konstanten) werden in Großbuchstaben geschrieben, wobei Teilworte
durch _ getrennt werden. Beispiel:
final float MAX_TEMPO = 5.0;
Methodennamen werden wie Variablennamen geschrieben und sollten Verben enthalten,
Beispiel:
public void fahreRueckwaerts (float tempo);
Klassennamen beginnen mit Großbuchstaben und sollten substantivisch sein. Beispiel:
class FahrzeugReifen
Für weitere Informationen lesen Sie bitte die Java Coding Conventions von SUN unter
http://java.sun.com/docs/codeconv/html/CodeConventionsTOC.doc.html.
6-92
Kapitel 7: Objektorientierung
Das Grundparadigma der objektorientierten Programmierung ist, dass ein Programm eine
Ansammlung von Objekten ist, die miteinander interagieren. Jedes Objekt gehört zu einer
bestimmten Klasse, und ist mit Datenfeldern und Methoden ausgerüstet. Der Wert der
Datenfelder beschreibt den Zustand des Objektes, die Methoden dienen dazu, diesen Zustand
zu verändern und mit anderen Objekten zu kommunizieren.
7.1 abstrakte Datentypen, Objekte, Klassen
Abstrakte Datentypen (ADT) sind ein Konzept aus der theoretischen Informatik, welches für
die objektorientierte Programmierung eine ähnliche grundlegende Rolle spielt wie der
λ-Kalkül für die funktionale Programmierung oder die Turing-Maschine für die imperative
Programmierung. Formal definieren wir den Begriff der Signatur: Eine Signatur Σ ist ein
Paar, bestehend aus einer Grundmenge Μ und einer Menge von Operationen und Prädikaten
Φ:
Σ = (Μ, Φ)
Zu jedem Element von Φ wird außerdem ihre Stelligkeit angegeben (zur Erinnerung: eine nstellige Operation auf einer Menge Μ ist eine Funktion Μn  Μ, eine n-stellige Operation
auf einer Menge Μ ist eine Funktion Μn  B)
Beispiel für eine Signatur ist also etwa : (N0,0,s,+,*). Dabei sei 0 nullstellig, s
einstellig, und + und * zweistellig.
0 : → N0
s : N0 → N0
+: N0×N0 → N0
*: N0×N0 → N0
Es erhebt sich die Frage, welche Datenobjekte vom Typ N0 es (mindestens) gibt. Unter der
Termalgebra einer Signatur versteht man alle Objekte, die sich als Ergebnis wohlgeformter
Terme auf Grund der Signatur darstellen lassen. Beispiele sind
 0, s(0), s(s(0)), s(s(s(0))), …
 0+0, (0+0)+0, …
 s(0)+s(0), s(s(0))+s(s(s(0))), …
 s(s(0))*(s(s(0))*(s(s(0)))), (0*s(0))+s(0), …
Die Objekte, die sich so darstellen lassen, nennt man die Termalgebra der Signatur.
Nicht alle Terme geben verschiedene Werte: z.B ist s(0)+s(0) gleich zu s(s(0))
(manchmal ist 1+1=2). Daher nimmt man eine Äquivalenzklassenbildung durch Angabe von
(allgemeingültigen) Gesetzen vor:
 x+0=x
 x+s(y)=s(x+y)
 x*0=0
 x*s(y)=x+(x*y)
Damit lässt sich obige Aussage beweisen (1+1=2):
s(0)+s(0) =? s(s(0))
7-93
s(0)+s(0) = s(0 + s(0))
= s(s(0 + 0))
= s(s(0))
Ein abstrakter Datentyp (ADT) Τ besteht aus einer Signatur Σ und einer Menge von
algebraischen Gesetzen (Gleichungen) Γ
Τ = (Σ, Γ)
Wenn die Gesetze ausschließlich aus Gleichungen zwischen Elementen von Μ bestehen,
spricht man auch von einer Varietät (engl.: variety). Oft lässt man auch Bedingungen und
Ungleichungen zu, dies ist aber eine Erweiterung des ursprünglichen Konzepts. Die Gesetze
beschreiben, was für alle Objekte dieses Typs gelten soll.
Beispiel: ADT IntSet „Menge ganzer Zahlen“
Signatur:(IntSet, ∅, ∈, ⊆, ∪, ∩, –)
∅ : → IntSet
∪ : IntSet × IntSet → IntSet
∈ : Z × IntSet → boolean
Gesetze: z.B.
x∪y=y∪x
x∩∅=∅
…
Konkrete Mengen ganzer Zahlen (z.B. {1,2,3} oder {x | x%2=0} sind Instanzen des
abstrakten Typs. Die Gesetze legen fest, was für alle Instanzen gelten soll. Beispiel:
{1,2,3} ∪ {4,5} = {4,5} ∪ {1,2,3}
{x | x%2=0} ∩ ∅ = ∅
Beispiel: Paare ganzer Zahlen Z×Z:
ADT ZZ = (ZZ, first, second, conc)
first, second : ZZ → Z
conc : Z × Z → ZZ
Gesetze:
first(conc(x,y))=x
second(conc(x,y))=y
Instanzen:
⟨3,-5⟩, ⟨0,0⟩, ⟨-12,12⟩, …
first(⟨3,0⟩)=3
conc(1,2)=⟨1,2⟩
Beispiel: Sequenzen (parametrisiert mit Basistyp σ)
ADT seq⟨σ⟩:( seq⟨σ⟩, empty, isEmpty,
prefix, first, rest,
postfix, last, lead):
empty : → seq⟨σ⟩
isEmpty : seq⟨σ⟩ → boolean
7-94
prefix: σ × seq⟨σ⟩ → seq⟨σ⟩
first: seq⟨σ⟩ → σ
rest: seq⟨σ⟩ → seq⟨σ⟩
postfix: seq⟨σ⟩ × σ → seq⟨σ⟩
last: seq⟨σ⟩ → σ
lead: seq⟨σ⟩ → seq⟨σ⟩
isEmpty(empty) = true
isEmpty(prefix(a,x)) = false
isEmpty(postfix(x,a)) = false
first(prefix(a,x))= a
rest(prefix(a,x))= x
last(postfix(x,a))= a
lead(postfix(x,a))= x
Eigenschaften
first(rest(prefix(a, prefix(b, empty)))) = b
rest(rest(prefix(a, prefix(b, empty)))) = empty
last(lead(postfix(a, postfix(b,empty)))) = b
…
prefix(a,empty) =? postfix(empty,a)
Typerweiterung
abgeleitete Operation +:
+: (seq⟨σ⟩ ∪σ) × (seq⟨σ⟩ ∪σ)→ seq⟨σ⟩
prefix(a,x)=a+x
postfix(x,a)=x+a
x+empty=x
empty+x=x
x+(y+z)=(x+y)+z
+ ist also ein “überladener” Operator, der für mehrere verschiedene Eingabetypen eine
Sequenz liefert. Er kann wie folgt rekursiv definiert werden:
x+empty=x
x+postfix(y,a) = postfix(x+y,a)
7-95
empty+x=x
prefix(a,x)+y = prefix(a,x+y)
Stapel und Schlangen
Stapel („stack“): einseitiger Zugriff
empty, isEmpty, first, rest, prefix – oder –
empty, isEmpty, last, lead, postfix
top ≅ first/last, pop ≅ rest/lead, push ≅ prefix/postfix
Schlange („queue“):
empty, isEmpty, first, rest, postfix – oder –
empty, isEmpty, last, lead, prefix
head ≅ first/last, tail ≅ rest/lead, append ≅ postfix/prefix
Multimengen
bag⟨σ⟩ :
empty : → bag⟨σ⟩
isEmpty : bag⟨σ⟩ → boolean
insert: bag⟨σ⟩ × σ → bag⟨σ⟩
delete: bag⟨σ⟩ × σ → bag⟨σ⟩
elem: σ × bag⟨σ⟩ → boolean
any: bag⟨σ⟩ → σ
isEmpty(empty) = true
isEmpty(insert(x,a)) = false
insert(insert(x,a),b)= insert(insert(x,b),a)
delete(empty,a) = empty
delete(insert(x,a),a) = x
delete(insert(x,b),a) = insert(delete(x,a),b)
elem(a, empty) = false
elem(a, insert(x,a)) = true
elem(a,insert(x,b) = elem(a,x) (a≠b)
elem(any(x),x) = true (x≠empty)
Beispiel-Algorithmus für Multimengen
7-96
card: bag⟨σ⟩ × σ → N0
int card (bag⟨σ⟩ x, σ a){
if (isEmpty(x)) return 0;
else {
σ b = any(x);
return card(delete(x,b)) + ((a=b)?1:0);
}
}
7.2 Klassen, Objekte, Methoden, Datenfelder, Konstruktoren
Eine Klasse ist die Java-Realisierung eines abstrakten Datentyps. In der objektorientierten
Programmierung sind Klassen das grundlegende Strukturierungsmittel.
Klasse = Datenfelder + Methoden
Datenfelder sind die Zustandsvariablen des Objekts, Methoden die Operationen, Funktionen
oder Prozeduren zur Zustandsänderung. Objekte sind Instanzen von Klassen, ähnlich wie
Elemente Bestandteile einer Menge sind. Die folgenden Begriffe werden fast synonym
verwendet (mit minimalen, subtilen Unterschiede!)
• Klasse ~ Typ ~ Art
• Objekt ~ Instanz ~ Exemplar
• Datenfeld ~ Objektattribut ~ Instanzvariable ~ Eigenschaft (passives Merkmal)
• Methode ~ Funktion/Prozedur ~ Operation ~ Fähigkeit (aktives Merkmal)
• Parameter ~ Argument ~ Eingabewert
Beispiel für eine Klasse: Philosophen sitzen um einen Tisch, denken, werden hungrig und
essen:
class Philosoph {
boolean hungrig;
void denken() {...};
void essen() {...};
}
Klassen bestehen also im Wesentlichen aus der Deklaration von Daten- und Funktionsteilen
(vgl. Turing/von-Neumann Analogie zwischen Daten und Programmen). Datenfelder
bestimmen durch ihren aktuellen Wert während einer Berechnung den Zustand der
abgeleiteten Objekte, Methoden definieren die Aktionsmöglichkeiten der Objekte.
Beispiel: das Objekt `Sokrates´ ist eine Instanz der Klasse `Mensch´, kann im Zustand
`hungrig´ sein und beherrscht die Methode `denken´ mit Ergebnistyp `Erkenntnis´ (void).
Objekte können von anderen Objekten erzeugt und aufgerufen werden:
public static void main(String[] args) {
Philosoph sokrates;
sokrates = new Philosoph();
sokrates.denken();
if(sokrates.hungrig) sokrates.essen();
7-97
System.out.println(sokrates.hungrig);
}
Objektorientierte Modellierung
Klassen modellieren ein Konzept eines Anwendungsbereiches oder eine Gemeinsamkeit
mehrerer Dinge (platonische „Idee“); Objekte modellieren die konkreten Akteure oder
Gegenstände des betrachteten Bereiches. Berechnungen entstehen dadurch, dass Objekte
miteinander und mit dem Benutzer kommunizieren, das heisst,
• sich gegenseitig aufrufen
• Nachrichten austauschen
• neue Objekte erzeugen
Wurzel der Interaktion ist die public class Main mit der Methode
public static void main(String[] args) {…}
Beispiele
Klasse
Mensch
Auto
Planet
Kreis
Objekt
sokrates
herbie
erde
kreis1
Datenfeld
hungrig
tankinhalt
masse
radius
Methode
denken
fahren
drehen
farbeAendern
Wichtig: Klassen können hierarchisch strukturiert sein!
Lebewesen – Mensch – Berliner
Fahrzeug – Auto – Porsche
usw.
Darüber hinaus gibt es statische Datenfelder (Schlüsselwort static): Diese beschreiben
Attribute, die alle Objekte der Klasse gemeinsam haben
Beispiel: static int anzahl_Finger = 10;
als Attribut von class Philosoph
Solange kein Objekt einer Klasse erzeugt wurde, muss nur der Speicherplatz für statische
Variablen angelegt werden. Der Speicherplatz für Objekte wird erst beim Aufruf von new
reserviert. Mit new wird (dynamisch) ein neues Objekt erzeugt. Man nennt diesen Vorgang
auch “instanziieren” und das Objekt eine „Instanz“ der Klasse. z.B.
Philosoph aristoteles = new Philosoph();
•
•
•
Dadurch werden die objektspezifischen Datenfelder angelegt, d.h., es wird
ausreichend Speicherplatz im Adressraum reserviert.
Dieser Speicherplatz wird automatisch wieder freigegeben, wenn das Objekt nicht
mehr erreichbar ist („garbage collection“)
Primitive Datenobjekte (Zahlen oder Zeichen) müssen nicht erzeugt werden, sie
existieren sowieso.
Vom Instanziieren zu unterscheiden ist das Initialisieren von Objekten: den Datenfeldern
werden Anfangswerte zugewiesen; z.B., int x = 0;
Eine nichtinitialisierte Variable enthält u.U. einen unvorhersagbaren Wert (was
zufällig an dieser Stelle im Speicher stand…)
7-98
Für die Erzeugung von neuen Objekten kann jede Klasse Konstruktor-Methoden zur
Verfügung stellen, die genauso wie die Klasse selbst heißen (im Beispiel Philosoph()), und
in denen die Datenfelder der Klasse initialisiert werden können.
void Philosoph (){
hungrig = true;
}
Der Konstruktor wird bei der Erzeugung eine Instanz der Klasse automatisch aufgerufen und
dient dazu, das betreffende Objekt zu initialisieren. Der Konstruktor hat denselben Namen
wie die Methode selbst und kann auch Eingabeparameter enthalten, liefert jedoch kein
Ergebnis.
class Student { ...
String matrikelnummer;
int scheine;
Student (String matrNr){
matrikelnummer = matrNr; ...
scheine = 0;
}
...
// Konstruktor- Methode
Klassen dienen zur Realisierung von abstrakten Datentypen. Dabei sind nur die
Zugriffsfunktionen (in der Signatur) sichtbar („public“). Beispiel: Klasse „Stack“ zur
Realisierung von Stapeln (nach Bothe):
class Stack {
private char[] stackElements;
private int top;
public Stack(int n) {
stackElements = new char [n];
top = -1; }
public boolean isempty() {
return top == -1; }
public void push(char x) { // Methode
if (top + 1 == stackElements.length){
System.out.println (“Stack ist voll”);
return;
}
top++; stackElements[top] = x; }
public char top() {
if (isempty()) {
System.out.println("Stack leer");
return ' ';
} else
return stackElements [top];
}
public void pop() {
if (isempty())
System.out.println("Stack leer");
else
top--;
}
}
7-99
Anwendung der Klasse: z.B. zum Überprüfen von Klammerstrukturen:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class Klammerstruktur {
public static void main(String[] args) {
System.out.println("Geben Sie eine Klammerstruktur ein");
BufferedReader inputReader =
new BufferedReader(new InputStreamReader(System.in)) ;
String eingabeZeile = "";
try {eingabeZeile = inputReader.readLine();}
catch (IOException e){}
int n = eingabeZeile.length();
Stack s = new Stack(n);
for (int i = 0; i < n; i++) {
char ch = eingabeZeile.charAt(i);
if (ch == '(' || ch == '{' || ch == '[')
s.push(ch);
else if (ch==')' && s.top() =='(' ||
ch=='}' && s.top() =='{' ||
ch==']' && s.top() =='[')
s.pop();
else if (ch==')' || ch=='}' || ch==']' ) {
System.out.println("schließende Klammer passt nicht"); return;
}
}
if (!s.isempty())
System.out.println("schließende Klammer fehlt");
else
System.out.println("Klammerstruktur korrekt");
}
}
(Achtung: Auch hier prüfen wir nicht ob der Keller leer ist.)
Anwendungsbeispiel ist z.B. der Term (()([]{})) oder der Term
if ( x < ( a [ ( i ) ] / 2 )) { x += f () ;}
Natürlich kann man auch mehrere Objekte einer Klasse erzeugen:
Stack s7 = new Stack(7);
Stack s9 = new Stack(9);
Dadurch werden verschiedene Instanzen der Klasse, jeweils mit eigenen Instanzvariablen und
–methoden, erzeugt. Der Zugriff erfolgt über Qualifikatoren:
s7.push('a');
s9.push('b');
7-100
Modifikatoren:
Klassen, Datenfelder und Operationen können durch Modifikatoren näher attributiert werden.
Diese betreffen vorwiegend die Zugriffsmöglichkeiten auf die betreffende Komponente.
Folgende Modifikatoren sind erlaubt:
• final (für ein Datenfeld): Das Datenfeld ist eine Konstante, hat also für alle Objekte
der Klasse stets den gleichen, unveränderlichen Wert.
• final (für eine Operation): Die Operation kann in Unterklassen nicht verändert
(überschrieben) werden.
• static : Die Komponente ist klassenbezogen, d.h. für alle Objekte der Klasse in
gleicher Weise verfügbar und wertgleich. Für eine Operation bedeutet das: Die
betreffende Operation darf nur static-Datenfelder benutzen.
• private : Die Komponente darf nur innerhalb der aktuellen Klasse benutzt werden
(aber von beliebigen Objekten der Klasse).
• public : Die Komponente ist öffentlich, darf also unbeschränkt von außen benutzt
werden.
• protected : Auf die Komponente kann man nur innerhalb des Pakets zugreifen, das
die betreffende Klasse enthält, und zusätzlich von deren Unterklassen.
• package (default-Einstellung): Zugriff wie bei protected, aber auf das aktuelle Paket
beschränkt.
Auch bei der Verwendung von Modifikatoren sollte man die Sichtbarkeitsregel „so lokal wie
möglich, so global wie nötig“ berücksichtigen.
7.3 Vererbung, Polymorphismus, dynamisches Binden
Beim Vergleich von Ingenieurswesen und Software-Engineering fällt auf, dass Ingenieure
ihren Gegenstandsbereich hierarchisch strukturieren. Ein Beispiel aus dem Maschinenbau ist
die hierarchische Strukturierung eines KFZ gemäß dem Aufbau:
Fahrzeug – Getriebe – Zahnrad.
Innerhalb der Betrachtungsebene „Zahnrad“ gibt es eine Typenreihen-Strukturierung gemäß
der Weltsicht („Ontologie“): Es gibt runde und ovale Zahnräder, Zahnräder bei denen die
Zähne innen oder außen sind, usw. Auf der Betrachtungsebene „Fahrzeug“ gibt es die
Spezialisierungen (z.B.) Volkswagen, Nutzfahrzeuge, Caddy, Life, TDI 1.9 DPF.
Die Einteilung entspricht dem, was man in der Informatik „Vererbungsstruktur“ nennt; es gibt
verschiedene Zahnrad-Bautypen, die sich voneinander im Detail unterscheiden; es gibt aber
auch gewisse Gemeinsamkeiten, die uns veranlassen, von einem „Zahnrad“ zu sprechen.
Genauso: es gibt verschiedene KFZ-Marken, aber alle haben 4 Räder und einen Hubraum.
Wenn ein Ingenieur ein Getriebe konstruiert, überlegt er sich eine Dekomposition in
Komponenten (bzw. einen Bauplan zum Zusammensetzen aus Einzelteilen) und schlägt in
Katalogen mit Zahnkranz-Bautypen nach oder modifiziert bestehende Bautypen geeignet.
In der objektorientierten Programmierung besteht Softwaredesign aus der Dekomposition des
Problems in einzelne Klassen (bzw. der Strukturierung in eine Klassenhierarchie) und dem
Zusammenfügen bzw. der Modifikation vorhandener Bibliotheksklassen. Genau wie ein
Fahrzeug aus vielen Einzelteilen besteht, ist objektorientierte Software aus vielen Klassen
zusammengesetzt.
Technisch bedeutet dies, dass Klassendefinitionen auf verschiedene Weise hierarchisch
strukturiert sein können:
• Klassen dürfen lokale Klassen als Deklaration enthalten
• Klassen können zu Paketen zusammengefasst werden
• Zwischen Klassen kann eine Erbschaftsbeziehung (Super/Subklassenhierarchie)
bestehen
7-101
•
Zwischen abstrakten und konkreten Klassen kann eine Implementierungsrelation
bestehen
Vererbung ist also das fundamentale Konzept der oo- (objektorientierten) Programmierung.
Als Beispiel betrachten wir eine Hierarchie von Fahrzeugtypen:
© http://www.javabuch.de/
Diese Hierarchie könnte objektorientiert etwa wie folgt nachempfunden werden:
public class Fahrzeug {
private int tachostand;
public Fahrzeug() {
tachostand = 0;
}
public int gibTachostand() {
return tachostand;
}
public void fahre(int strecke) {
tachostand += strecke;
}
}
Eine Erweiterung dieser Klasse wäre dann etwa:
class LKW extends Fahrzeug {...}
Durch diese Definition sind alle Datenfelder und Methoden von Fahrzeug auch für LKW
verfügbar (Ausnahme: private Datenfelder und Methoden). Wir sagen, dass die Klasse LKW
die Attribute der Klasse Fahrzeug erbt. LKW kann darüber hinaus zusätzliche Variablen oder
Methoden enthalten oder Methoden abändern


z.B. nutzlast oder transportiere
z.B. fahre mit zusätzlichem Argument ladung
Der Zugriff auf die Methoden erfolgt mit Member-Selektor (Punkt), z.B. lkw1.ladung
Polymorphismus
Auf oberster Ebene gilt: Jede Klasse ist von der allgemeinsten Klasse Object abgeleitet und
erbt von dieser gewisse Eigenschaften
7-102




Object clone()
boolean equals(Object)
String toString()
...
In den meisten Fällen muss jedoch die generische („ererbte“) Definition der Methoden
abgeändert werden. In abgeleiteten Klassen dürfen alle ererbten Methoden neu definiert
werden (Ausnahmen: private, final). Dies realisiert den Umstand, dass im Speziellen
zusätzliche Maßnahmen gegenüber dem Allgemeinen durchgeführt werden.
Beispiel: ein LKW kann nicht rückwärts fahren, daher prüft fahre zunächst ob das Argument
positiv ist:
public void fahre(int strecke){
if (strecke > 0) super.fahre(strecke);
}
Die speziellere Methode überlagert die allgemeinere aus der übergeordneten Klasse. Unter
Polymorphie (griechisch: „Vielgestaltigkeit“) versteht man die Tatsache, dass
unterschiedliche Methoden in verschiedenen Klassen oder auch Methoden mit verschiedenen
Parametern innerhalb einer Klasse gleich bezeichnet sein dürfen. Beispiele:
•
•
+ für Int-, Float-Addition, String-Konkatenation
neue Methode fahre in Klasse LKW mit zusätzlichem Argument last prüft zunächst
ob die angegebene Nutzlast überschritten wurde:
public void fahre(int strecke, int last) {
if (last > nutzlast)
System.out.println("überladen!");
else fahre(strecke); }
In jedem Fall muss an Hand der Signatur oder des Objekttyps entscheidbar sein, welche
Methode gemeint ist.
public class LKW extends Fahrzeug {
private int nutzlast;
public LKW(int nutzlast){
this.nutzlast = nutzlast;
}
public void fahre(int strecke){
if (strecke > 0) super.fahre(strecke);
}
public void fahre(int strecke, int last) {
if (last > nutzlast)
System.out.println("überladen!");
else fahre(strecke);
}
public void transportiere (int last, int start, int ziel)
{ /* belade(last); fahre(ziel - start, last); */ }
}
Dynamisches Binden
Es ist nicht immer statisch entscheidbar, welche Methode gerade gemeint ist:
Fahrzeug kfz = new LKW(...);
kfz.fahre (... ladung); ...
7-103
if (...) kfz = new PKW();
kfz.fahre(... personen);
Das bedeutet, dass erst zur Laufzeit entschieden werden kann, welche Methode jetzt
eigentlich gemeint ist. Diesen Effekt nennt man „dynamisches Binden“. Zur Realisierung
erzeugt der Compiler zusätzlichen Code zur Prüfung (Ausnahmen: private, static, final).
public class Fuhrpark {
private static Fahrzeug f;
public static void sampleMethod() {
f = new LKW(20000); // ein 20-Tonner
f.fahre(-100); // welches fahre?
//f.fahre(100, 10000); // nicht erlaubt
f = new LKW(4); // ein 4-Sitzer
((LKW)f).fahre(123,5);
}
}
Bindungsregeln und Objektinitialisierung
•
•
•
7-104
Beim Erzeugen eines Objektes mittels new werden zunächst die Konstruktoren der
vererbenden Klassen (Eltern, Großeltern etc.) aufgerufen.
Die eigenen Merkmale können mit dem Deskriptor this, die der jeweiligen
Elternklasse mit super aufgerufen werden.
Eventuelle Typumwandlung (Casting) durch vorgestelltes (Typ)
Kapitel 8: Modellbasierte Softwareentwicklung
8.1 UML Klassendiagramme und Zustandsmaschinen
– entfällt –
8.2 Codegenerierung und Modelltransformationen
– entfällt –
8-105
Kapitel 9: Spezielle Programmierkonzepte
9.1 Benutzungsschnittstellen, Ereignisbehandlung
Viele der bislang betrachteten Programme bekamen ihre Eingabe als Parameter beim Aufruf,
und lieferten ihre Ausgabe als Ergebnis bei Terminierung ab. Interaktive Programme
kommunizieren mit den Benutzern über Ein- und Ausgabegeräte. Bei textbasierten
Programmen erfolgt die Ausgabe oft in einem Textfeld („Konsole“); in Java etwa durch die
Methode System.out.println(…). Die Komponente „out“ bezeichnet dabei den StandardAusgabestrom des Systems, vom Typ PrintStream. (Ähnlich bezeichnet err die
Standardausgabe für Fehlermeldungen, und in den Standard-Eingabestrom. Über System.in
lassen sich also vom Benutzer auf der Konsolschnittstelle eingegebene Werte einlesen. Beim
Aufruf der Methode System.in.read wartet das Programm, bis der Benutzer Text eingibt
und die Eingabetaste drückt.
Das folgende Programmfragment liest eine Zahl von der Konsole und gibt sie (um 1 erhöht)
wieder aus:
int i=0;
int len = 20; byte[] b = new byte[len];
try {
int l = System.in.read(b, 0, len)-1;
String s = new String(b,0,l-1);
i = Integer.parseInt(s);
} catch (IOException e) {}
System.out.println(i+1);
System.in.read erwartet als Eingabe eine Referenz auf (hinreichend großes) Byte-Array,
liest die Bytes von Konsole und gibt die Anzahl der gelesenen Zeichen (inklusive dem
„Zeilenwechsel-Zeichen“) als Ergebnis zurück. Die tatsächlich eingegebene Zeichenzahl ist
also um eins niedriger. Da Benutzerinteraktionen grundsätzlich Fehler erzeugen können,
müssen sie in einen try–Block eingeschlossen werden, siehe Abschnitt 9.3. Mit der
Anweisung String s = new String(b,0,l-1); werden die Zeichen 0 bis l-1 dieses ByteArrays in eine Zeichenreihe umgewandelt. Integer.parseInt(s) schließlich wandelt diese
Zeichenreihe (sofern sie nach den Java-Regeln eine korrekte Zahl darstellt) in ein Objekt der
Art Integer um. Etwas einfacher wird es, wenn wir aus System.in einen BufferedReader
erstellen, da wir mit diesem direkt Strings einlesen können (Methode readLine()):
BufferedReader konsole = new BufferedReader(
new InputStreamReader (System.in)) ;
try {i = Integer.parseInt(konsole.readLine());}
catch (IOException e){}
Für viele Programme ist allerdings eine rein textbasierte Ein-Ausgabe nicht ausreichend.
Üblicherweise wird ein Programm heute mit dem Benutzer über eine GUI (graphical user
interface) interagieren, d.h. das Programm stellt graphische Elemente wie Knöpfe, Regler,
Textfelder usw. auf dem Bildschirm zur Verfügung, über die die Mensch-MaschineKommunikation erfolgt. Dazu gibt es inzwischen sehr viele verschiedene Möglichkeiten.
Programme, die eine GUI bereitstellen, reagieren auf Ereignisse (events). In der
ablaufgesteuerten Programmierung ist ein Programm eine Folge von Anweisungen (d.h.
Methodenaufrufen); bei der ereignisgesteuerten Programmierung ist ein Programm eine
Sammlung von Behandlungsroutinen (= Methoden) für verschiedene Ereignisse der GUI.
Typische Ereignisse (events) sind:
• Mausklick, Mausziehen, Mausbewegung, Return-Taste, Tastatureingabe, …
• Signale des Zeitgebers, Unterbrechungen, …
• programmerzeugte Ereignisse
9-106
In einem ereignisgesteuerten Programm wird für jedes Fenster ein Prozess gestartet, der auf
die Ereignisse wartet und sie bearbeitet. Für jedes Ereignis wird eine Bearbeitungsroutine
(event handler) registriert, die beim Eintreten des Ereignisses bestimmte Aktionen auslöst.
Zur konkreten Realisierung von GUIs in Java existieren zwei weit verbreitete Bibliotheken:
AWT und Swing.
• AWT (Abstract Window Toolkit) ist eine plattformunabhängige schwergewichtige
Bibliothek (stützt sich auf Betriebssystem-Routinen ab)
• Swing ist Bestandteil der Java-Runtime (leichtgewichtig), hat ein spezifisches „Lookand-Feel“ sowie gegenüber AWT einige zusätzliche Komponenten und ist generell
„moderner“.
Das folgende Programm erzeugt zwei Fenster und zeigt sie auf dem Bildschirm an:
import java.awt.*;
class Fenster extends Frame {
Fenster (String titel) {
super(titel); //Setzen des Textes in der Titelzeile des Fensters
setSize(160,120); // Größe in Pixeln einstellen
setVisible(true); // das Fenster ist anfangs sichtbar
}
}
public class FensterDemo {
public static void main(String[] args) {
Fenster zumHof = new Fenster("zum Hof");
Fenster zurTür = new Fenster("zur Tür");
}
}
Damit diese Fenster z.B. auf das Schaltfeld „Beenden“ (in Windows: das rote Kreuz rechts
oben) reagieren, muss man ihnen einen entsprechenden Fensterbeobachter zuordnen:
import java.awt.*;
import java.awt.event.*;
class FensterBeobachter extends WindowAdapter{
public void windowClosing(WindowEvent e) {// Schließen des Fensters
System.exit(0); // Aktion beim Schließen: z.B. Programmende
}
}
class Fenster extends Frame {
Fenster (String titel) {
FensterBeobachter fb = new FensterBeobachter();
addWindowListener(fb); // hier wird der Beobachter registriert
super(titel); ...); // wie oben
}
}
public class FensterDemo {
public static void main(String[] args) {
Fenster f = new Fenster("PI-1");
}
}
Innerhalb einem Fenster kann man verschiedene Knöpfe definieren. Jedem Knopf wird ein
Knopfbeobachter zugewiesen, der auf das Ereignis „Knopf wird gedrückt“ reagiert:
class KnopfBeobachter implements ActionListener{
public void actionPerformed(ActionEvent e) {
System.out.println("Knopf gedrückt!"); //Aktion beim Klick
}
}
9-107
class Fenster extends Frame {
Fenster (String titel) {
...
Button k = new Button("Knopf!"); // neuen Knopf definieren
KnopfBeobachter kb = new KnopfBeobachter(); // neuer Beobachter kb
k.addActionListener(kb); // Beobachter kb Knopf k zuordnen
add(k); // Knopf zum aktuellen Fensterobjekt hinzufügen
}
}
Zur Verwendung aktiver Komponenten (Knöpfe, Schalter, …) in einer GUI sind also immer
folgende Schritte nötig
• Erzeugen – Button b = new Button ()
• Ereignisbehandlung zuordnen – b.add.ActionListener(...)
• Hinzufügen zum Fenster – add(b)
• Methode zur Ereignisbehandlung schreiben
 actionPerformed, itemStateChanged, adjustmentValueChanged,...
Jedem Schaltelement können beliebig viele Beobachter zugeordnet werden, die auf die
unterschiedlichen Ereignisse reagieren. Beobachter müssen nicht als eigene Variablen
benannt werden, sondern können auch anonym existieren:
setLayout(new FlowLayout());
Button knopf2 = new Button("Knopf2");
knopf2.addActionListener (new ActionListener (){
public void actionPerformed(ActionEvent e) {
System.out.println("Knopf2 gedrückt!"); }});
add(knopf2);
...
Für die graphische Gestaltung der Benutzerinteraktion ist eine Vielzahl von automatischen
Optionen verfügbar; zu Layout-Fragen konsultiert man am besten die entsprechende
Dokumentation. Gängige Layouts sind z.B.
•
•
•
•
•
BorderLayout
FlowLayout
GridLayout
CardLayout
GridBagLayout
Es ist auch eine Schachtelung von Layouts möglich; darüber hinaus lässt sich natürlich jedes
Objekt auch absolut innerhalb des Fensters positionieren.
Als ein etwas größeres Beispiel betrachten wir jetzt eine Stoppuhr, die durch entsprechende
Mausklicks gestartet und gestoppt wird, und bei der die Zeitanzeige im Format (Minuten,
Sekunden, Hunderstelsekunden) in Textfeldern des Fensters erscheint. Die Klasse enthält die
Zeitanzeige mit drei Label (Minuten, Sekunden, Hundertstel) sowie die beiden Knöpfe
StartStop und Reset.
class Stoppuhr extends Frame {
Label zeitAnzeigeMin, zeitAnzeigeSec, zeitAnzeigeCsec;
Button startstop, reset;
long startTime, elapsedTime, elapsedLast = 0;
boolean running = false;
9-108
Stoppuhr(){ // Konstruktor erzeugt Layout
zeitAnzeigeMin = new Label();
zeitAnzeigeMin.setBounds(50, 50, 40, 25);
add(zeitAnzeigeMin);
zeitAnzeigeSec = new Label();
zeitAnzeigeSec.setBounds(100, 50, 40, 25);
add(zeitAnzeigeSec);
zeitAnzeigeCsec = new Label();
zeitAnzeigeCsec.setBounds(150, 50, 40, 25);
add(zeitAnzeigeCsec);
startstop = new Button("Start");
startstop.setBounds(50,100,60,25);
startstop.addActionListener(new ButtonListenerStartStop());
add(startstop);
reset = new Button("Reset");
reset.setBounds(120,100,60,25);
reset.addActionListener(new ButtonListenerReset());
add(reset);
addWindowListener(new WindowAdapter (){
public void windowClosing(WindowEvent e) { System.exit(0); }
});
add(new Button()); // sonst Layoutfehler?!?
}
public static void main(String[] args) {
Stoppuhr uhr = new Stoppuhr(); // erzeuge neues Fenster
uhr.setBounds(0, 0, 250, 180); //250 Pixel breit, 180 Pixel hoch
uhr.setVisible(true);
uhr.new Zeitanzeige ().start(); // starte separaten Thread
}
Die Main-Methode startet die Uhranzeige als parallelen Thread, der ständig die Anzeige
aktualisiert:
class Zeitanzeige extends Thread{
public void run(){
while(true){
if (running){
elapsedTime = (System.currentTimeMillis() - startTime)
+ elapsedLast; // nach stop, weiter- statt neuzählen
int csec = (int) (elapsedTime % 1000 / 10);
int sec = (int) (elapsedTime / 1000) % 60;
int min = (int) (elapsedTime / 60000);
zeitAnzeigeCsec.setText(((csec < 10)? "0" : "") + csec);
zeitAnzeigeSec.setText(((sec < 10)? "0" : "") + sec);
zeitAnzeigeMin.setText(((min < 10)? "0" : "") + min);
}
}
}
}
Schließlich müssen wir noch die Aktionen definieren, die beim Klicken der Knöpfe
ausgeführt werden:
9-109
class ButtonListenerStartStop implements ActionListener{
public void actionPerformed(ActionEvent e){
startTime = System.currentTimeMillis(); // neue Startzeit
if (! running){
running = true;
startstop.setLabel("Stop");
} else {
running = false;
elapsedLast = elapsedTime; // Speichern des gestoppten Stands
startstop.setLabel("Start");
}
}
}
class ButtonListenerReset implements ActionListener{
public void actionPerformed(ActionEvent e){
startTime = System.currentTimeMillis();
elapsedLast = 0;
zeitAnzeigeMin.setText("00");
zeitAnzeigeSec.setText("00");
zeitAnzeigeCsec.setText("00");
startstop.setLabel("Start");
}
}
}
Das entstehende Fenster sieht wie folgt aus:
Graphikprogrammierung
In einem interaktiven Programm möchte man mit der Maus normalerweise noch mehr
machen, als nur Knöpfe anzuklicken. Mausevents (Ziehen, Doppelklick, Rechtsklick usw.)
werden nach dem selben Schema wie Knopfdrücke behandelt
addMouseListener(new MouseAdapter() {
public void mousePressed(MouseEvent e) {
x1 = e.getX(); y1 = e.getY(); } ...
Auf diese Weise ist es möglich, graphische Elemente (Linien, Kreise, Vielecke, Polygone…)
als Objekt der Klasse Graphics auszugeben und mit der Maus zu bearbeiten.
•
•
Verwendete Methoden: drawLine, fillPolygon, drawOval, …
Konstruktor getGraphics()
Als Beispiel implementieren wir ein Malprogramm: Nach Klicken auf die Knöpfe „Rechteck“
oder „Oval“ können entsprechende Figuren durch Ziehen mit der Maus gezeichnet werden.
9-110
import java.awt.*;
import java.awt.event.*;
class GrafikFenster extends Frame{
private static int x1, y1, x2, y2;
private static int modus = 0;
GrafikFenster(String titel, int breite, int höhe){
super(titel);
setLayout(new FlowLayout());
Button rechteckButton = new Button ("Rechteck");
rechteckButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
modus = 1; } });
add(rechteckButton);
Button ovalButton = new Button ("Oval");
ovalButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
modus = 2; } });
add(ovalButton);
addMouseListener(new MouseAdapter() {
public void mousePressed(MouseEvent e) {
x1 = e.getX(); y1 = e.getY();
}
public void mouseReleased(MouseEvent e) {
x2 = e.getX(); y2 = e.getY();
Graphics g = getGraphics();
g.setColor(Color.blue);
int w=Math.abs(x1-x2), h=Math.abs(y1-y2);
switch (modus) {
case 1: g.fillRect(x1,y1,w,h); break;
case 2: g.fillOval(x1,y1,w,h); break;
default: break;
}
}
});
addWindowListener(new WindowAdapter() {
public void windowClosing(WindowEvent e) {
dispose(); }
});
setBackground(Color.lightGray);
setSize(breite, höhe);
setVisible(true);
}
}
public class GrafikDemo {
public static void main(String[] args) {
GrafikFenster f = new GrafikFenster("Künstler",800,600);
}
}
Animationen
Ein Problem des obigen Programms ist es, dass bei Veränderung des Fensters (z.B.
Vergrößern) die Zeichnung verloren geht. Das kann mit der Methode paint (Graphics g)
zur Ausgabe der Zeichnung gelöst werden. paint wird bei Fensterveränderungen
automatisch aufgerufen und muss so programmiert werden, dass es alle Zeichnungsobjekte
neu ausgibt. Eine Möglichkeit dazu besteht darin, alle Zeichnungsobjekte in einer
Collection (z.B. Set) einzutragen und beim Neuzeichnen iterativ alle Elemente auszugeben.
Die selbe Technik kann auch für Animationen benutzt werden, indem paint() in eigenem
Thread (etwa alle 40 ms) aufgerufen wird.
9-111
9.2 Abstrakte Klassen, Interfaces, generische Typen
Abstrakte Klassen
Abstrakte Klassen sind solche, die noch nicht „fertig“ sind: sie enthalten Methoden ohne
Implementierung. Beispiel:
abstract class Figur {
protected int x, y;
public void setzeX(int xNeu) {
x = xNeu;
}
public abstract float berechneFlaeche();
}
Für abstrakte Klassen ist keine Instanzenbildung erlaubt; allerdings können Unterklassen die
abstrakte Klasse erweitern und die abstrakten Methoden konkretisieren.
Interfaces
Ein Interface ist das Java-Äquivalent zur Signatur eines abstrakten Datentyps. Es besteht nur
aus den Köpfen der Methoden, enthält keine Datenfelder oder Algorithmen (aber auch keine
algebraischen Gesetze). Eingeleitet werden Interfaces mit dem Schlüsselwort interface:
Beispiel:
public interface StackInterface {
public boolean isempty();
public void push(char x);
public char top();
public void pop();
}
Die implementierende Klasse referenziert dann das Interface:
class Stack implements StackInterface { ... }
Interfaces sind gut geeignet, um Benutzungsschnittstellen von Klassen noch vor der
eigentlichen Implementierung festzulegen. Von einem Interface können keine Instanzen
gebildet werden (da ja keine Methodenrümpfe und Datenfelder vorliegen). Allerdings kann
der Compiler die Konsistenz einer Benutzung der Klasse sowie die Konsistenz einer
Implementierung des Interfaces mit der Definition überprüfen. Eine Variable, die als Typ das
Interface hat, darf als Wert Objekte aller implementierenden Klassen haben. Ein wesentlicher
Unterschied zwischen abstrakten Klassen und Interfaces besteht darin, dass eine Klasse
mehrere Interfaces implementieren kann (sogenannte Mehrfachvererbung).
Generische Typen
Generische Typen realisieren da Konzept der parametrisierten abstrakten Datentypen. Es ist
möglich, einer Klasse einen Typ als Parameter mitzugeben:
class Tupel<T> {
private T first;
private T second;
public Tupel(T fst, T scd) {
first = fst;
second = scd;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
9-112
Der Zugriff erfolgt dann wie folgt:
pi = new Pair<Integer> (17, 24);
ps = new Pair<String> ("Hallo", "World");
Allerdings ist das Konzept in Java nur eingeschränkt nutzbar, da z.B. keine Felder über
generischen Typen gebildet werden können.
9.3 Fehler, Ausnahmen, Zusicherungen
Ausnahmen
Eine typische Situation beim Programmieren ist der Entwurf von Klassenbibliotheken für
(spätere) Anwendungen. Hier muss man beständig mit unzulässige Eingaben rechnen (z.B.
top(empty()), Division durch Null, usw.). Es erhebt sich die Frage, wie man mit solchen
unzulässigen Eingabewerten umgehen sollte. Eine Möglichkeit besteht darin, die unzulässige
Eingabe zu ignorieren und einen Standardwert zurückgeben. (z.B. x/0 = 0). Dies hat mehrere
Nachteile: Der Definitionsbereich der Funktionen wird unzulässig erweitert, der Anwender
der Klasse bemerkt seinen FDehler nicht und der Fehler wird unkalkulierbar verschleppt.
Besser ist es, das Programm mit einer Fehlermeldung abzubrechen („lieber ein Ende mit
Schrecken als ein Schrecken ohne Ende“). Die dritte Möglichkeit besteht darin, zu jeder
Methode einen zusätzlichen boole’schen Ergebniswert („ok“) einzuführen, der auf true gesetzt
wird wenn der Methodenaufruf fehlerfrei war. Diese Methode erfordert gegebenenfalls
erheblichen zusätzlichen Schreibaufwand, wird aber z.B. in der Programmiersprache C
praktiziert. In Java besteht die Lösung im „Aufwerfen“ (throw) einer Ausnahmesituation. Das
„Abfangen“ (catch) der Ausnahmesituation liegt in der Verantwortung des Aufrufers; wenn er
sich nicht darum kümmert, wird die Ausnahmesituation „nach oben weitergereicht“. Wenn
sich niemand um den Fehler kümmert, bricht das Programm ab. Dadurch ist es möglich,
gewisse Fehler auf der entsprechenden Programmebene kontrolliert zu behandeln,
Debugging-Informationen auszugeben, oder das Programm sogar unter Umständen
fortzusetzen.
public class Ausnahmebehandlung {
static void f() throws Exception {
Exception e = new Exception("Fehler1");
if (1==1) throw e;
}
public static void main(String[] args) {
// …
System.out.println("vor Aufruf");
try {
f();
} catch (Exception x) {
System.out.println (x + " ist aufgetreten");
}
// …
}
}
Achtung: Ausnahmesituationen gehören zur Signatur der Methode! Dies bedeutet, dass
Ausnahmesituationen in Java „Bürger erster Klasse“ sind. Eine Ausnahmesituation ist ein
Objekt der Klasse Exception. Das bedeutet, es gibt Konstruktoren ohne und mit Argument
(String). Das Werfen einer Ausnahmesituation (throw) bewirkt die Beendigung der Methode
und die Rückkehr zur Aufrufstelle. Aufruf einer Methode, die eine Ausnahmesituation werfen
kann, ist nur in einem try-Block möglich; das Auftreten der Ausnahmesituation wird von
nachfolgenden Exception Handlern überwacht. Der erste Exception Handler, dessen
Parameter-Typ mit dem Typ der geworfenen Exception übereinstimmt, wird ausgeführt, und
9-113
nach Beendigung wird des Exception Handlers wird das Programm normal fortgesetzt. Auf
diese Weise ist es möglich, für jede mögliche Ausnahmesituation eine dezidierte
Fehlerbehandlung anzustoßen. Jede Ausnahme muss behandelt oder weitergereicht werden
(Ausnahme folgt), d.h., der Compiler zwingt den Programmierer, sich über die notwendigen
Maßnahmen beim Eintreten der Ausnahmesituation Gedanken zu machen.
f();//geht nicht!
static void g() throws Exception {
f();
}
Exception-Hierarchie
•
•
Basisklasse aller Ausnahmen ist Throwable
Davon abgeleitet sind
Exception und Error
 Konvention: Exception für behebbare,
Error für nicht vom Anwender
behebbare Ursachen
 Von Exception werden u.a. IOException und RuntimeException abgeleitet
 RuntimeException und davon abgeleitete Klassen müssen nicht behandelt
werden
Syntax
try {
// Anweisungen die Ausnahmen werfen könnten
} catch(Typ1 Objekt1) {
// Anweisungen für Ausnahme1
} catch(Typ2 Objekt2) {
// Anweisungen für Ausnahme2
} finally {
// Anweisungen für Endbehandlung
}
Es ist natürlich möglich, eigene Ausnahmen zu definieren:
static class MyException1 extends Exception{}
static class MyException2 extends Exception{}
public class Ausnahmebehandlung {
static void f() throws MyException1, MyException2 {
if (1==1) throw new MyException1();
else throw new MyException2();
}
public static void main(String[] args) {
System.out.println("vor Aufruf");
try {
f();
} catch (MyException1 x) {
System.out.println (x+" - Handler1");
} catch (MyException2 x) {
System.out.println (" Handler2: " + x);
}
finally {System.out.println("Finally!");}
};
}
9-114
Hier noch eine allgemeine Anmerkung zu Ausnahmen: Ausnahmen sollten NICHT zur
Ablaufkontrolle missbraucht werden! Ausnahmen von dieser Regel sind möglich und zulässig
(z.B. bei Benutzereingaben). Als Beispiel verfeinern wir unser Programm zum Einlesen einer
natürlichen Zahl (größer Null).
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ZahlEingabe {
public static void main(String[] args) {
int i = 0;
System.out.println ("Bitte Zahl eingeben!");
while (i <= 0){
BufferedReader inputReader =
new BufferedReader(new InputStreamReader(System.in));
String eingabeZeile = "";
try {
eingabeZeile = inputReader.readLine();
i = Integer.parseInt (eingabeZeile);
if (i<=0) {
System.out.println ("Bitte Zahl größer Null eingeben!");}
}
catch (NumberFormatException e){
System.out.println ("Not a Number");}
catch (IOException e){
System.out.println ("I/O Exception!");}
}
System.out.println ("Eingegeben: " + i);
}
}
Zusicherungen
Zusicherungen (Assertions) sind boole‘sche Ausdrücke, die dazu dienen, die Sicherheit beim
Programmieren zu erhöhen. Sie können bei der Entwicklung von Programmen helfen,
Irrtümer und Missverständnisse zu vermeiden. Eingeleitet werden Zusicherungen durch das
Schlüsselwort
assert expr;
oder auch
assert expr : expr;
Beispiel für eine Zusicherung:
assert (i<liste.length()): "Index " + i + " falsch";
Semantik: Während des Ablaufs des Programms wird geprüft, ob die Zusicherung eingehalten
ist. Falls nicht, wird eine Ausnahme geworfen (bzw. der Ausdruck an die Ausnahme
übergeben). Verwendung finden Zusicherungen unter anderem als Vor- und
Nachbedingungen von Methoden (z.B. beim Aufruf von pop(x) wird zugesichert, daß
!isEmpty(x)). Zusicherungen sind, wenn sie richtig eingesetzt werden, eine effiziente
Methode zur Entwicklung und zum Debuggen von Programmen.
Bei der Ausführung verlangsamt die Auswertung einer Zusicherung natürlich das Programm.
Daher können Zusicherungen in der Java-Laufzeitumgebung ein- und ausgeschaltet werden:
Argument: -enableassertions oder –ea
Wenn die Zusicherungsauswertung ausgeschaltet ist, können Zusicherungen als eine Art
Kommentar betrachtet werden.
9-115
9.4 Parallelität
Im objektorientierten Paradigma bedeutet Berechnung die Interaktion von Objekten. Das
bedeutet, dass mehrere Objekte gleichzeitig existieren. In der sequentiellen Programmierung
ist jedoch immer nur eines dieser Objekte aktiv. Anfangs gibt es nur das statische MainObjekt. Dieses erzeugt andere Objekte und ruft diese auf. Jeder Aufruf ist blockierend, d.h.
der Aufrufer muss warten, bis das aufgerufene Objekt fertig ist und die Kontrolle zurückgibt.
Folglich ist zu einem gegebenen Zeitpunkt immer nur ein Objekt rechnend. Parallelität
bedeutet, dass mehrere Handlungsstränge zur selben Zeit ablaufen. Dies ist
1. dem objektorientierten Paradigma angemessener
2. für die Ausführung auf Mehrkernprozessoren besser geeignet.
Es gibt in Java mehrere Arten der Parallelität:
• schwergewichtige Parallelität: mehrere Prozesse (tasks) gleichzeitig, werden vom
Betriebssystem verwaltet, Kommunikation über spezielle BS-Mechanismen (pipes,
sockets)
• leichtgewichtige Parallelität: mehrere Handlungsfäden (threads) innerhalb einer Task,
Verwaltung vom Laufzeitsystem, Kommunikation über gemeinsame Variable
Threads in Java
In Java gibt es zwei Arten der Definition
1. Erweiterung der Klasse Thread mit Überlagerung der Methode run
2. Implementierung der Schnittstelle Runnable mit neuer Methode run
 Definition eines objektlokalen Datenfelds t vom Typ Thread, Erzeugung eines
zugehörigen Thread-Objektes
 Aufruf von t.start() führt zur Ausführung von run als separater Handlungsfaden
Beispiel (1): Wir erzeugen eine Erweiterungsklasse von Thread mit Methode run. Diese wird
dann vom aufrufenden Programm aus gestartet.
class Toe extends Thread {
public void run() {
System.out.println("Prozess Toe gestartet");
}
public static void main(String[] args) {
Toe toe = new Toe(); toe.start();
System.out.println("Prozess Toe beendet");
}
}
Achtung: Das Ergebnis ist (in dieser Reihenfolge!):
Prozess Toe beendet
Prozess Toe gestartet
Beispiel (2): Wir erzeugen zwei Instanzen einer Implementierungsklasse zum Interface
Runnable. Diese enthalten jeweils einen Thread, der vom Aufrufer gestartet werden kann.
class TicTacToe {
static class TicTac implements Runnable{
Thread faden;
private int wer;
public TicTac(int w) {
faden = new Thread(this);
wer=w;}
9-116
public void run() {
System.out.println("Prozess T"+((wer==1)?"i":"a")+"c
gestartet");
}
}
public static void main(String[] args) {
TicTac tic = new TicTac(1);
TicTac tac = new TicTac(2);
tic.faden.start(); tac.faden.start();
try {tic.faden.join(); tac.faden.join();
} catch (Exception e) {}
System.out.println("alles beendet");
}
}
Wie das Beispiel zeigt, kann der Aufrufer auch auf die Beendigung eines Threads warten.
Beispiel (3) zeigt, dass man bei der parallelen Programmierung vorsichtig sein muss, weil sich
leicht Fehler einschleichen. Wir inkrementieren und dekrementieren eine gemeinsame
Variable in zwei verschiedenen Threads gleich oft.
class TicTac implements Runnable{
static int summe = 0;
Thread faden;
private int wer;
public TicTac(int w) {
faden = new Thread(this);
wer=w;
}
public void run() {
for(int i=1; i<10000; i++) {
if(wer==1) summe = summe + 1;
else summe = summe - 1;
}
}
public static void main(String[] args) {
TicTac tic = new TicTac(1);
TicTac tac = new TicTac(2);
tic.faden.start(); tac.faden.start();
try {tic.faden.join(); tac.faden.join();
} catch (Exception e) {}
System.out.println("Summe=" + summe);
}
}
Der Thread tic zählt die Variable summe hoch, der Thread tac zählt sie runter. Das Ergebnis
ist aber nicht in jedem Fall 0, sondern unvorhersagbar. (Für „kleine“ Schleifen ergibt sich
jedoch immer 0).
Interpretation der Ergebnisse
• Prinzipiell werden die Aktionen in den Threads parallel und unabhängig voneinander
ausgeführt
• Falls mehr Prozesse als Prozessoren existieren, muss die Rechenzeit aufgeteilt werden
• Zeitscheibenzuteilung: Jeder Thread erhält eine bestimmte Zeitspanne, bevor er
unterbrochen wird. Dadurch kann es passieren, dass ein bereits Thread fertig ist, bevor
der zweite überhaupt anfängt
• Interleaving: Durch parallelen Zugriff auf gemeinsame Variable können Werte
verfälscht werden.
9-117
Interleaving
Durch verzahnt Ausführung (wie bei einem Reißverschluss) können mehrere Prozesse auf nur
einem Prozessor ausgeführt werden. Jeder Prozess erhält dabei der Reihe nach eine
Zeitscheibe einer bestimmten Dauer zugewiesen (die Dauer ist nicht notwendigerweise immer
gleich (Bild „Einfädeln“ von Autos bei einer Baustelle). Beim Zugriff auf gemeinsame
Variable kann es dabei zu unerwarteten Ergebnissen kommen:
Der Befehl {s++} wird zur Befehlsfolge {load s; add 1; store s} übersetzt. Analog bedeutet
{s--} in Maschinensprache {load s; sub 1; store s}. Verschiedene Prozesse benutzen dabei
verschiedene Register für die Rechnung. Bei zwei Prozessen, die beide auf dieselbe Variable
zugreifen ergeben sich jedoch bei der quasiparallelen Ausführung von s++ || s-- folgende
Szenarien:
s=5 (z.B.)
Befehl P1
Befehl P2
load s
add 1
store s
load s
sub 1
store s
Werte
(AC1, AC2, s)
(5, -, 5)
(6, -, 5)
(6, -, 6)
(6, 6, 6)
(6, 5, 6)
(6, 5, 5)
aber auch:
Befehl P1
Befehl P2
load s
load s
add 1
sub 1
store s
store s
Werte
(AC1, AC2, s)
(5, -, 5)
(5, 5, 5)
(6, 5, 5)
(6, 4, 5)
(6, 4, 6)
(6, 4, 4)
oder
Befehl P1
Befehl P2
load s
load s
sub 1
store s
add 1
store s
Werte
(AC1, AC2, s)
(5, -, 5)
(5, 5, 5)
(5, 4, 5)
(5, 4, 4)
(6, 4, 4)
(6, 4, 6)
Das bedeutet, wenn man s simultan inkrementiert und dekrementiert, ist das Ergebnis zufällig
s oder s+1 oder s-1! Wenn wir diesen Effekt oft wiederholen (for (int i = 0; i<1000000; i++))
verstärkt sich der Fehler.
9-118
Prozess-Zustände
Prozesse können dem Laufzeit- oder Betriebssystem selbst ankündigen, ob sie nach Ablauf
ihrer Zeitscheibe weiter ausgeführt werden sollen oder nicht. Das nennt man präemptives
Multitasking.
Zur Synchronisation von Prozessen gibt es in Java folgende Möglichkeiten:
• start() – startet den Thread
• sleep(int i) – suspendiert den Prozess i ms
• wait() – Wartet auf den Eintritt eines Ereignisses
• notify() – benachrichtigt einen wartenden Thread
• notifyAll() – benachrichtigt alle wartenden Threads
• join() – wartet auf die Beendigung des Threads
• interrupt() – unterbricht die Ausführung eines Threads
• isInterrupted() – Test ob unterbrochen
• synchronized – exklusiver Ressourcenzugriff-Monitor
Ein Monitor ist eine Klasse, die die Unteilbarkeit auf die von ihr verwalteten Ressourcen
garantiert. Dies geschieht mit dem Schlüsselwort synchronized vor einer Methode.
class Monitor {
private int i = 0;
synchronized void inc() { i++;}
synchronized void dec() { i--;}
int val() { return i; }
class TicTacMon implements Runnable{
static Monitor summe = new Monitor();
Thread faden;
private int wer;
public TicTacMon(int w) {
faden = new Thread(this);
wer=w;}
public void run() {
System.out.println("Prozess " + wer + " gestartet");
for(int i=1;i<10000000;i++) {
if(wer==1) summe.inc();
else summe.dec();
}
System.out.println("Prozess "+ wer + " beendet");
}
public static void main(String[] args) {
TicTacMon tic = new TicTacMon(1);
TicTacMon tac = new TicTacMon(2);
tic.faden.start(); tac.faden.start();
try {tic.faden.join(); tac.faden.join();} catch (Exception e) {}
System.out.println("Summe=" + summe.val());
}
}
}
9-119
synchronisierte Methoden
Für jedes Objekt gibt es ein intrinsisches Schloss, welches den Zugang einschränkt. Während
des Ablaufs einer synchronisierten Methode wird das Schloss verschlossen, daher kann keine
andere synchronisierte Methode dieses Objekts gleichzeitig ablaufen. Diese muss ggf. warten
(Gefahr der Verklemmung (deadlock)!)
Mit einer Synchronisationsanweisung lässt sich der gleiche Effekt wie mit einem Monitor
erzielen:
class Mutex {};
class TicTac implements Runnable{
static Mutex m = new Mutex();
...
public void run() {
for(int i=1;i<10000000;i++) {
synchronized(m) {
if(wer==1) summe++;
else summe--;
}
...
Dinierende Philosophen
Dieses Standardbeispiel von E.W.Dijkstra verdeutlicht die Probleme, die bei der
Koordinierung paralleler Abläufe durch die Konkurrenz um gemeinsame Betriebsmittel
auftreten können. 5 Philosophen sitzen um einen runden Tisch, in der Mitte steht eine
Schüssel Spaghetti. Zwischen je zwei Philosophen ist eine Gabel (bzw. ein Ess-Stäbchen).
Jeder Philosoph betätigt sich zyklisch nur mit den Tätigkeiten denken – essen – denken –
essen – denken – essen – denken –... Zum Essen benötigt ein Philosoph allerdings zwei
Gabeln / Ess-Stäbchen, nämlich die zu seiner linken und zu seiner rechten. Ein einfacher
Algorithmus für jeden Philosophen wäre also:
wiederhole immer wieder:
denke
warte, bis linke Gabel frei, dann nimm sie
warte, bis rechte Gabel frei, dann nimm sie
iss
gib beide Gabeln wieder frei
In Java lässt sich das etwa wie folgt formulieren:
public class Gabel {
private boolean benutzt = false;
private int welche;
Gabel (int i){welche = i;}
synchronized void aufnehmen()
throws InterruptedException {
while (benutzt) wait();
System.out.println("Gabel " + welche + " aufgenommen");
benutzt = true;
}
synchronized void hinlegen(){
benutzt = false;
System.out.println("Gabel " + welche + " hingelegt");
}
}
9-120
public class Philosoph extends Thread {
private int wer;
private static int n = 5;
private static Gabel [] gabel = new Gabel [n];
private static Philosoph [] phil = new Philosoph [n];
Philosoph (int i){wer = i;}
public void run (){
System.out.println("Philosoph " + wer + " gestartet");
try{
while (true){
System.out.println("Philosoph " + wer + " denkt");
sleep((long)(10000*Math.random())); // Denken
System.out.println("Philosoph " + wer + " hungrig");
gabel[(wer==0)?n-1:wer-1].aufnehmen();
gabel[wer].aufnehmen();
System.out.println("Philosoph " + wer + " isst");
sleep((long)(10000*Math.random())); // Essen
gabel[(wer==0)?n-1:wer-1].hinlegen();
gabel[wer].hinlegen();
}
} catch(InterruptedException e){}
}
public static void main(String[] args) {
for (int i=0; i<n; i++){
gabel[i] = new Gabel (i);
phil[i] = new Philosoph(i);
}
for (int i=0; i<n; i++){
phil[i].start();
}
}
}
Diese Lösung ist allerdings verklemmungsbedroht! Es kann passieren, dass jeder Philosoph
individuell seine linke gabel nimmt (damit liegt keine Gabel mehr auf dem Tisch) und dann
wartet, bis er die rechte nehmen kann (was aber nie passieren wird). Allgemein ist eine
Verklemmung (deadlock) ein zyklischer Wartezustand: A wartet auf B, B wartet auf C, …, Y
wartet auf Z und Z wartet auf A. Ein Applet, um diesen Effekt auszuprobieren, findet sich
z.B. unter http://www.doc.ic.ac.uk/~jnm/concurrency/classes/Diners/Diners.html
Ein etwas besserer Algorithmus vermeidet das Verklemmungsproblem. Jeder Philosoph
macht folgendes:
9-121
wiederhole immer wieder
denke
wiederhole solange bis beide Gabeln genommen wurden:
warte, bis linke Gabel frei, dann nimm sie
falls rechte Gabel nicht da, gib linke wieder frei
warte, bis rechte Gabel frei, dann nimm sie
falls linke Gabel nicht da, gib rechte wieder frei
iss
gib beide Gabeln wieder frei
Obwohl diese Lösung nachweislich verklemmungsfrei ist, hat sie einen anderen gravierenden
Nachteil: Es ist möglich, dass sich einzelne oder alle Philosophen endlos in einer sinnlosen
Beschäftigung verlieren, in dem sie abwechselnd die linke und rechte Gabel aufnehmen und
wieder ablegen. Solch eine Situation nennt man manchmal Endlosschleife (livelock); formal
ist das eine Situation, in der intern immer dieselben Handlungen ausgeführt werden, ohne dass
nach außen irgend ein Fortschritt erkennbar wäre.
Ein noch besserer Algorithmus vermeidet dieses Problem, indem durch einen geeigneten
Synchronisationsalgorithmus das Aufnehmen beider Gabeln simultan erfolgt. Jeder Philosoph
macht also folgendes:
wiederhole immer wieder
denke
warte, bis beide Gabeln frei, dann nimm sie (beide gleichzeitig)
iss
gib beide Gabeln wieder frei (und teile dies ggf. den Nachbarn mit)
Das simultane Aufnehmen der beiden Gabeln kann in Java dadurch programmiert werden,
dass ein Monitor den Zugriff auf die Gabeln regelt.
Die Lösung hat jedoch immer noch eine Schwäche: Es kann sein, dass zwei Philosophen sich
zusammentun, um den zwischen ihnen sitzenden „auszuhungern“: Philosoph 2 ist hungrig,
aber erst isst Philosoph 1 (und 2 wartet auf die linke Gabel), und dann Philosoph 3 ( und 2
wartet auf die rechte Gabel). Allgemein ist ein System aushungerungsfrei (starvation free),
wenn garantiert ist, dass jeder kontinuierlich fortsetzungswillige Prozess auch irgendwann
fortgesetzt wird. Aushungerungsfreiheit ist ein spezieller Fall der so genannten Fairness, die
garantiert, dass kein Prozess immer wieder benachteiligt wird. Allgemeine FairnessEigenschaften lassen sich programmtechnisch nur schwer garantieren; pragmatische
Lösungsmöglichkeiten bestehen darin
• vor dem Aufnehmen der Gabel(n) eine zufällig bestimmte Zeitdauer zu warten (keine
garantierte Aushungerungsfreiheit, wird aber in Kommunikationsprotokollen so
gelöst)
• einen unabhängigen Aushungerungs-Erkennungsalgorithmus einzusetzen
(„Wachhund“, watchdog)
• die Symmetrie zwischen den Philosophen zu brechen und z.B. eine feste Reihenfolge
der Zuteilung vorzugeben, oder
• ein Wartenummernverfahren (ähnlich wie auf Behörden-Wartezimmern) einzuführen.
9-122
Kapitel 10: Algorithmen und Datenstrukturen
Algorithmen und Datenstrukturen wurden klassischerweise als „das“ Thema der praktischen
Informatik betrachtet, in praktisch jedem Informatikstudiengang gibt es Spezialvorlesungen
dazu. Andererseits ist die Bedeutung des Themas in der Praxis rückläufig, da es mittlerweise
zu fast jedem Standard-Thema umfangreiche Bibliotheken gibt und man daher viele
Algorithmen und Datenstrukturen nicht selbst implementieren, sondern einfach importieren
wird. Für einen Informatiker ist es jedoch unerlässlich, zu wissen, wie die importierten
Routinen prinzipiell funktionieren, sonst ist das Ergebnis vielleicht nicht optimal. Darüber
hinaus gibt es auf dem Gebiet immer noch interessante Forschungsfragen und einige
überraschende Effekte, besonders was die Komplexität gewisser Probleme betrifft.
10.1 Listen, Bäume, Graphen
Im Kapitel über abstrakte Datentypen waren diese durch ihre Signatur (Methodenköpfe) und
Eigenschaften (algebraische Gesetze) definiert worden. In der Implementierung hatten wir
gesehen, dass Klassen Datenfelder enthalten können. Jedes Objekt gehört zu einer bestimmten
Klasse (Beispiel: int i; Thread f; PKW herbie usw.). Objekte können andere Objekte als
Bestandteile enthalten (Beispiel: Auto enthält Tachostand). Frage ist, ob ein Objekt andere
Objekte derselben Klasse enthalten kann (Beispiel: Schachtel enthält Schachtel)? Falls diese
Möglichkeit in einer Sprache zugelassen ist, sprechen wir von rekursiven Datenstrukturen.
(Sprachen wie C oder Delphi, die dieses Konzept nicht besitzen, greifen zur Realisierung
meist auf maschinennahe Hilfsmittel wie Zeiger zurück.) In rekursiven Datenstrukturen ist es
erlaubt, dass ein Objekt eine Komponente vom selben Typ enthält wie das Objekt selbst. Das
Rekursionsende wird dann durch das „leere Objekt“ null gekennzeichnet.
public class Zelle {
int inhalt;
Zelle next; // Verweis auf die nächste Zelle
Zelle (int i, Zelle n){
inhalt = i;
next = n;
}
}
Mit solchen Zellen lassen sich verkettete Listen von Zellen realisieren. Listen sind endliche
Folgen von Elementen und dienen hier als Beispiel für eine dynamische Datenstruktur: Im
Unterschied zu Arrays, bei denen bereits bei der Erzeugung eine feste Maximallänge
angegeben werden muss, können Listen beliebig wachsen und schrumpfen. Neue Elemente
werden einfach an die Liste angehängt, nicht mehr benötigte Elemente können wieder aus der
Liste entfernt werden. Verkettete Listen kann man sich wie eine Perlenschnur vorstellen: von
jeder Perle gibt es eine Verbindung zur folgenden. In den Perlen befindet sich die
Information. Im konkreten Beispiel besteht die Liste aus einer Zelle, die den Anfang markiert.
Jede Zelle hat einen Inhalt und einen Verweis auf die folgende Zelle. In der Klasse „Liste“
befinden sich darüber hinaus Methoden, um neue Elemente anzufügen, zu suchen, zu löschen
usw. Vorne anfügen kann beispielweise dadurch erfolgen, dass man ein neues Element
10-123
erzeugt, das als Nachfolger den bisherigen Anfang hat, und dieses neue Element als neuen
Anfang der Liste nimmt.
public class Liste {
Zelle anfang;
void prefix(int n){
Zelle z = new Zelle (n,anfang);
anfang = z;
}
void search(int n){...}
}
Zum Ausgeben einer Liste überschreiben wir die Objekt-Methode toString. Die Ausgabe
erfolgt rekursiv gemäß der rekursiven Definition der Datenstruktur.
public class Zelle {
...
public String toString(){
return inhalt+((next==null)?"":
}
}
" -> " + next.toString());
public class Liste {
Zelle anfang;
...
public String toString() { return anfang.toString(); }
}
Hier ist ein kleines Beispiel zum Testen der Klasse „Liste“.
public class ListenAnwendung {
static Liste l = new Liste();
public static void main(String[] args) {
l.prefix(101);
l.prefix(38);
l.prefix(42);
System.out.println(l);
}
}
Dieser Test druckt 42 -> 38 -> 101.
Entfernen von Elementen einer Liste: Um das erste Element zu löschen, setzen wir den
Anfang einfach auf das zweite Element:
void removeFirst () {
anfang = anfang.next;
}
10-124
Der vorherige Anfang ist damit nicht mehr zugreifbar! Natürlich darf diese Methode nur
ausgeführt werden, falls die Liste nicht leer ist. Um ein inneres Element zu löschen, verbinden
wir die Vorgängerzelle mit der Nachfolgerzelle.
Man beachte, daß die Liste dadurch verändert wird! Das abgeklemmte Element ist vom
Programm aus nicht mehr zugreifbar. Das Java-Laufzeitsystem sorgt dafür, dass dies
irgendwann von der Speicherbereinigung entdeckt wird und der Platz wiederverwendet
werden kann. Programmiersprachlich lässt sich das Entfernen des n-ten Elementes etwa wie
folgt definieren:
void removeNth(int n) {
if(anfang !=null) {
Zelle v = anfang;
while (v.next!=null && n>1) {
v = v.next;
n--;
}
if(v.next!=null) v.next = v.next.next;
}
}
Achtung: removeFirst() ist nicht dasselbe wie removeNth(1). Auf ganz ähnlich Art lassen
sich weitere Listenoperationen rekursiv definieren.
int laenge(){
int i = 0; Zelle z = anfang;
while (z!=null) {
i++;
z=z.next;
}
return i;
}
boolean enthaelt(int n) {
boolean gefunden = false;
Zelle z = anfang;
while (z!=null && !gefunden) {
if (z.inhalt==n) gefunden=true;
z=z.next;
}
return gefunden;
}
Zelle suche(int n){
Zelle z = anfang;
while (z!=null) {
if (z.inhalt==n) return z;
z=z.next;
}
return z;
10-125
}
Eine Anwendung der Datenstruktur Liste ist die Realisierung von Kellern durch Listen.
class Stapel extends Liste {
Stapel() {}
Stapel(Zelle z) {
anfang = z;
}
boolean isEmpty(){
return anfang==null;
}
Stapel push(int i) {
Zelle z = new Zelle(i, anfang);
return new Stapel(z);
}
Stapel pop() {
return new Stapel(anfang.next);
}
int top() {
return anfang.inhalt;
}
}
Ein Problem dieser Implementierung ist, dass bei jedem push und pop ein neuer Listenanfang
generiert wird. Dieser wird zwar, wenn er nicht mehr benötigt wird, irgendwann von der
Speicherbereinigung aufgesammelt. Trotzdem ergibt sich ein gewisser Effizienzverlust. Eine
alternative Implementierung wäre etwa
Stapel popSE() {
removeFirst();
return this;
}
Stapel pushSE(int n) {
prefix(n);
return this;
}
Bei der Ausführung wird jetzt allerdings nur noch auf ein und derselben Liste gearbeitet!
Dadurch ergeben sich weitere Seiteneffekte. Beispiel zur Demonstration:
public class StapelDemo {
Stapel s1 = new Stapel();
Stapel s2 = new Stapel();
int sampleMethod() {
s1=s1.push(111).push(222).push(333);
s2=s1.pop();
return s1.top();
}
}
Es ergibt sich die Ausgabe 333, da s1 = 333 -> 222 -> 111. Beim Austausch von pop
durch popSE würde der Stapel s1 überschrieben, d.h. nach Aufruf von s2=s1.popSE(); ist
s1 = 222 -> 111 und die Ausgabe ist 222.
Schlangen
Mit verketteten Listen lassen sich auch Schlangen (queues) realisieren. Dazu braucht man
einen zusätzlichen Zeiger auf den Anfang der Schlange. Um den Aufbau am Schlangenende
und den Abbau am Anfang zu implementieren, wird die Verkettung sozusagen „umgedreht“
10-126
public class Schlange extends Liste {
Zelle ende;
boolean isEmpty(){
return anfang==null;
}
int head() {
return anfang.inhalt;
}
void tail() {
anfang=anfang.next;
}
void append(int n) {
Zelle z = new Zelle(n, null);
if (isEmpty()) { anfang = z; ende = z; }
else { ende.next = z; ende = z; }
}
}
Ein Beispiel mit Schlangen:
class SchlangenDemo{
static Schlange s = new Schlange();
public static void main(String[] args) {
s.append(100);
System.out.println(s);
s.append(200); s.append(300);
System.out.println(s);
System.out.println(s.head());
s.tail();
System.out.println(s.head());
}
}
druckt
100
100 -> 200 -> 300
100
200
Doppelt verkettete Listen („Deque“, double-ended queue)
Für Listen, die auf beiden Seiten zugreifbar sein sollen, bietet sich eine symmetrische Lösung
an. Für jede Zelle wird Nachfolger und Vorgänger in der Liste gespeichert. Beim Einfügen
und Löschen müssen die doppelten Verkettungen beachtet werden.
public class Deque {
class Item{
int inhalt;
Item links, rechts;
public String printLR(){
return inhalt + ((rechts==null)?"": "->" + rechts.printLR());}
public String printRL(){
return ((links==null)? "" : links.printRL() + "<-") + inhalt;}
}
Item erstes, letztes;
Deque() {}
Deque(Item e, Item l) { erstes=e; letztes=l; }
10-127
public void print(){
if (! isEmpty()){
System.out.println(erstes.printLR());
System.out.println(letztes.printRL());
}
}
boolean isEmpty() { return (erstes==null); }
int first (){ return erstes.inhalt; }
void rest() {
erstes.rechts.links = null;
erstes=erstes.rechts;
}
void prefix(int i) {
Item neu = new Item();
neu.inhalt = i;
if (this.isEmpty()) {
erstes = neu; letztes = neu; }
else {
neu.rechts = erstes;
erstes.links = neu;
erstes = neu; }
}
int last (){ return letztes.inhalt; }
void lead() {
letztes.links.rechts = null;
letztes=letztes.links;
}
void postfix(int i) {
Item neu = new Item();
neu.inhalt = i;
if (this.isEmpty()) {
erstes = neu; letztes = neu; }
else {
neu.links = letztes;
letztes.rechts = neu;
letztes = neu; }
}
}
Löschen eines inneren Knotens erfolgt durch Ersetzung zweier verschiedener Zeiger.
Man betrachte z.B. die Liste erna <-> mary <-> hugo, löschen von mary erfolgt durch
erna.rechts = erna.rechts.rechts; hugo.links = hugo.links.links;
oder, alternativ durch
erna.rechts = erna.rechts.rechts; erna.rechts.links = erna;
Programmiersprachlich kann man das wie folgt realisieren:
void removeNth(int n){
if (n==0) rest(); // erstes Element (n==0) ist zu löschen
else {
Item search = erstes;
for (int i=1; i<n; i++) // gehe zu Element vor dem zu löschenden
if (search == null || search.rechts == null){
System.out.println("Liste zu kurz"); return; }
else search = search.rechts;
if (search.rechts == null){//search = letztes
System.out.println("Liste zu kurz"); return; }
else search.rechts = search.rechts.rechts;
if (search.rechts == null) //letztes Element gelöscht
letztes = search;
else
search.rechts.links = search;
}
}
10-128
Bäume in Java
Syntaktisch sehen binäre Bäume genauso wie doppelt verkettete Listen aus.
class Bintree{
Bintree left;
char node;
Bintree right;
...}
+
*
x
*
y
x
z
Die Verallgemeinerung auf n-äre Bäume ist offensichtlich:
class Ntree{
String name;
int children;
Ntree [] child;
Ntree (String s, int n) {
name=s; children=n; child=new Ntree [n];}
...}
Anwendung von Binärbäumen:
• geordnete Binärbäume sind definiert durch die folgende Eigenschft:
alle Knoten des linken Unterbaums < Wurzel < alle Knoten des rechten Unterbaums
• Das Einfügen geschieht daher je nach einzufügendem Inhalt
• Ebenso passiert das Suchen im entsprechenden Unterbaum
• Löschen ist etwas komplizierter
• Mit sortierten Binärbäumen erreicht man logarithmische Durchschnittskomplexität
• Problem: Bäume können entarten (d.h. nur wenige aber sehr lange Zweige haben)
Ein Beispiel für einen geordneten Binärbaum ist nebenstehend angegeben.
Einfügen in geordneten Binärbäumen macht man am besten rekursiv:
void insert (int i){ einfügen(i, this); }
private void einfügen (int i, Bintree b) {
if (i<b.node)
if (b.left==null) b.left=new Bintree(i);
else einfügen(i, b.left);
else if (i>b.node)
if (b.right==null) b.right=new Bintree(i);
else einfügen(i, b.right);
// else i==b.node, d.h. schon enthalten
}
55
11
null
null
77
66
null
null
99
null
null
Suchen in geordneten Binärbäumen ist sehr ähnlich zum Einfügen:
boolean find(int i) {
return finde(i, this); }
private boolean finde(int i, Bintree b) {
if (i<b.node)
if (b.left==null) return false;
else return finde(i, b.left);
else if (i>b.node)
if (b.right==null) return false;
else return finde(i, b.right);
else return true; //i==b.node
}
10-129
Löschen ist dagegen etwas komplizierter.?
• Beim Löschen eines Knotens muss ein Unterbaum angehoben werden
 Wahlfreiheit (linker oder rechter Unterbaum?)
• Durch das Anheben entsteht eine Lücke, die wiederum durch Anheben eines
Unterbaums gefüllt werden muss
 Problem mit der Balance (Ausgewogenheit) des Baumes
 Balancegrad beeinflusst die Komplexität des Suchens!
• Lösungsmöglichkeiten:
 Abspeichern der Baumhöhe oder der Anzahl der Teilbäume für jeden Knoten
 endlich verzweigte Bäume
Endlich verzweigte Bäume
class Ntree{
String name;
int children;
Ntree [] child;
Ntree (String s, int n) {
name=s; children=n; child=new Ntree [n];} }
public class BeispielFamilie {
private Ntree t;
BeispielFamilie() {
Ntree n28=new Ntree("Johanna",3);
Ntree n55=new Ntree("Renate",0);
Ntree n60=new Ntree("Angelika",2);
Ntree n62=new Ntree("Margit",1);
Ntree n89=new Ntree("Laura",0);
Ntree n93=new Ntree("Linda",0);
Ntree n98=new Ntree("Viktoria",0);
n28.child[0]=n55; n28.child[1]=n60; n28.child[2]=n62;
n60.child[0]=n89; n60.child[1]=n93;
n62.child[0]=n98; t=n28;
}
}
Suche in endlich verzweigten Bäumen erfolgt durch Iteration innerhalb einer Rekursion (!):
boolean search (String s) {
return suche(s, this);
}
private boolean suche (String s, Ntree t) {
if (t==null) return false;
if (t.name==s) return true;
for (int i=0; i<t.children; i++)
if (suche(s, t.child[i])) return true;
return false;
}
10.2 Graphalgorithmen
• Wdh.: Definition Graph
 Darstellung einer binären Relationen über einer endlichen Grundmenge
 Tupel (V,E), V endliche Menge von Knoten, E Menge von Kanten, zu jeder
Kante genau ein Anfangs- und ein Endknoten
• Repräsentationsmöglichkeiten
 als Relation (Menge von Paaren)
10-130


Knotendarstellung, Verweise als Kanten
Adjazenzmatrix
Knoten-Kanten-Darstellung
Syntaktisch lassen sich Graphen auch genauso wie endlich verzweigte Bäume darstellen:
class Graph{
char node;
int numberOfEdges;
Graph [] edge;
Graph (char c, int n) {
node=c;
numberOfEdges=n;
edge=new Graph [n];
}
}
A
B
Semantisch können Graphen Zyklen enthalten:
C
BeispielGraph() {
D
Graph nodeA=new Graph('A',1);
Graph nodeB=new Graph('B',2);
Graph nodeC=new Graph('C',3);
Graph nodeD=new Graph('D',0);
nodeA.edge[0]=nodeB;
nodeB.edge[0]=nodeD; nodeB.edge[1]=nodeC;
nodeC.edge[0]=nodeA; nodeC.edge[1]=nodeB; nodeC.edge[2]=nodeD;
}
Anwendung von Graphen
• Beispiel: Verbindungsnetz der Bahn
• Suche Verbindung zwischen zwei Knoten
• Problem: Zyklen führen evtl. zu nicht terminierender Rekursion
• Lösung: Markieren bereits untersuchter Knoten (z.B. Eintragen in einer Menge)
Erreichbarkeit - fehlerhaft
boolean search (char c) {
return suche(c, this); }
private boolean suche (char c, Graph g) {
// Achtung fehlerhaft!!
if (g==null) return false;
if (g.node==c) return true;
for (int i=0; i<g.edges; i++)
if (suche(c, g.edge[i])) return true;
return false;
}
// funktioniert nur falls Graph zyklenfrei
// d.h. wenn Graph n-fach verzweigter Baum ist
Erreichbarkeit – korrigiert
import java.util.*;
Set s;
boolean search (char c) {
s = new HashSet();
return suche(c, this); }
private boolean suche (char c, Graph g) {
if (g==null) return false;
if (s.contains(g)) return false;
if (g.node==c) return true;
s.add(g);
10-131
for (int i=0; i<g.edges; i++)
if (suche(c, g.edge[i])) return true;
return false;
}
Darstellung von Graphen als Adjazenzmatrix
• Angenommen, die Knoten seien k1…kn
• boolesche Matrix m der Größe n×n
 m[i][j] gibt an, ob eine Verbindung
von ki-1 zu kj-1 existiert oder nicht
• Vorbesetzung z.B.:
static void fillMatrixRandom
(boolean matrix[][], float f) {
for (int i = 0; i<n; i++) {
for (int j = 0; j<n; j++) {
matrix[i][j]=(Math.random()<f); } } }
transitive Hülle
Def. transitive Hülle:
xR*y ↔ xRy ∨ ∃z (xR*z ∧ zR*y)
• Algorithmus von Warshall:
 starte mit R*=R
 für jeden möglichen Zwischenknoten z:
- Berechne für alle x und y, ob xR*y ∨ (xR*z ∧ zR*y)
 Reihenfolge der Schleifen ist wichtig!
• Algorithmus von Floyd (kürzeste Wege)
 Entfernungsmatrix, min statt ∨, + statt ∧
static void warshall(boolean m[][], boolean t[][]) {
for (int x = 0; x<n; x++) {
for (int y = 0; y<n; y++) {
t[x][y]=m[x][y]; }
}
for (int z=0; z<n; z++) {
for (int x = 0; x<n; x++) {
for (int y = 0; y<n; y++) {
t[x][y]=t[x][y]||t[x][z]&&t[z][y]; }
}
}
}
10.3 Suchen und Sortieren
• Gegeben eine (irgendwie strukturierte) Sammlung von Daten
 Spezielles Suchproblem: entscheide ob ein gegebenes Element in der
Sammlung enthalten ist
 Allgemeines Suchproblem: finde ein (oder alle) Elemente mit einer
bestimmten Eigenschaft
• Algorithmen hängen stark von der Struktur der Datensammlung ab!
 im Folgenden: als Reihung (Array) organisiert
lineare Suche
• Wenn über den Inhalt der Reihung nichts weiter bekannt ist, muss sie von vorne bis
hinten durchsucht werden
public class Suche {
10-132
public static final int n = 10;
static void printReihung(int reihung[]) {
for (int i = 0; i<n; i++)
System.out.print(reihung[i] + " ");
System.out.println("");
}
static void fillReihungRandom(int reihung[]) {
for (int i = 0; i<n; i++)
reihung[i]=(int) (Math.random()*10);
}
static int sucheLinear(int suchreihung[], int suchinhalt) {
for (int i=0;i<n;i++) {
if (suchreihung[i]==suchinhalt) return(i);
}
return (-1);//oder exception
}
public static void main(String[] args) {
int[]r=new int[n];
fillReihungRandom(r);
printReihung(r);
System.out.println(sucheLinear(r,3));
}
}
•
Komplexität: mindestens 1, höchstens n Schleifendurchläufe  O(n)
binäre Suche
• Wenn der Inhalt der Reihung aufsteigend sortiert ist, können wir es besser machen:
public class Suche {
public static final int n = 10;
static void printReihung(int reihung[]) {
for (int i = 0; i<n; i++)
System.out.print(reihung[i] + " ");
System.out.println("");
}
static void fillReihungSorted(int reihung[]) {
final int inc = 3;
reihung[0]=(int) (Math.random()*inc);
for (int i = 1; i<n; i++)
reihung[i]= reihung[i-1] + (int) (Math.random()*inc);
}
static int sucheBZ(int sr[], int si, int lo, int hi) {
if (lo > hi) return -1;
int mitte = (hi + lo) / 2;
if (si == sr[mitte]) return mitte;
else if (si < sr[mitte])
return sucheBZ(sr, si, lo, mitte - 1);
else /* (si > sr[mitte])*/
return sucheBZ(sr, si, mitte + 1, hi);
}
static int sucheBinaer(int suchreihung[], int suchinhalt) {
return sucheBZ(suchreihung, suchinhalt, 0, n-1);
}
public static void main(String[] args) {
int[]r=new int[n];
fillReihungSorted(r);
printReihung(r);
System.out.println(sucheBinaer(r,7));
}
}
10-133
Hashtabellen
• Wenn über den Inhalt der Reihung mehr bekannt ist, können wir es noch besser
machen
 Annahme: 10*i ≤ r[i] ≤ 10*i + 9,
z.B. r[7] liegt zwischen 70 und 79,
d.h. jedes Datenelement hat höchstens einen möglichen Platz
public class Suche {
public static final int n = 10;
static void printReihung(int reihung[]) {
for (int i = 0; i<n; i++)
System.out.print(reihung[i] + " ");
System.out.println("");
}
static void fillReihungHashable(int reihung[]) {
for (int i = 0; i<n; i++)
reihung[i]= 10*i + (int) (Math.random()*10);
}
static int sucheHash(int suchreihung[], int suchinhalt) {
int idx = suchinhalt / 10;
return (suchreihung[idx]==suchinhalt)?idx:-1;
}
public static void main(String[] args) {
int[]r=new int[n];
fillReihungHashable(r);
printReihung(r);
System.out.println(sucheHash(r,77));
}
}
Sortieren
• Oft lohnt es sich, Daten zu sortieren
 einmalige Aktion, Abfragen häufig
 oft gleich beim Eintrag möglich
• Wie sortiert man eine unsortierte Reihung?
 selection sort: größtes Element an letzte Stelle, zweitgrößtes an zweitletzte
Stelle, usw.
- einfach aber ineffizient!
 insertion sort: In absteigender Reihenfolge: das i-te Element in den sortierten
Bereich [i+1, …, n] einsortieren
- noch ineffizienter!
• Beispiele
Bubblesort
• Ineffizienz vorher: „weites“ Vertauschen mit Informationsverlust
• Idee: Tauschen von Nachbarn
 Falls zwei Nachbarn in verkehrter Reihenfolge, vertausche sie
 nach dem i-ten Durchlauf sind die obersten i Elemente sortiert
public class Sortieren {
public static final int n = 5;
static void printReihung(int reihung[]) {
for (int i = 0; i<n; i++)
System.out.print(reihung[i] + " ");
System.out.println("");
}
static void fillReihungRandom(int reihung[]) {
final int range = 100;
10-134
for (int i = 0; i<n; i++)
reihung[i]=(int) (Math.random()*range);
}
static void swap (int[] a, int i, int j) {
int h = a[i]; a[i] = a[j]; a[j] = h; }
static void bubbleSort (int[] a) {
for (int k=n-1; k>0; k--) {
for(int i=0; i<k; i++) {
if (a[i]>a[i+1]) {
swap(a,i,i+1);
printReihung(a);
}
}
}
}
public static void main(String[] args) {
int[]r=new int[n];
fillReihungRandom(r);
printReihung(r);
bubbleSort(r);
printReihung(r);
}
}
Quicksort
• berühmter, schneller Sortieralgorithmus
• wähle ein „mittelgroßes“ Element w=a[k], alle kleineren nach links, alle größeren
nach rechts
• rekursiv linke und rechte Teile sortieren
public class Sortieren {
public static final int n = 5;
static void printReihung(int reihung[]) {
for (int i = 0; i<n; i++)
System.out.print(reihung[i] + " ");
System.out.println("");
}
static void fillReihungRandom(int reihung[]) {
final int range = 100;
for (int i = 0; i<n; i++)
reihung[i]=(int) (Math.random()*range);
}
static void swap (int[] a, int i, int j) {
int h = a[i]; a[i] = a[j]; a[j] = h; }
private static int partition(int[]a, int lo, int hi) {
swap(a,(lo+hi)/2, hi);
int w = a[hi], k=lo;
for (int i=k; i<hi; i++)
if (a[i]<w) {swap(a,i,k); k++;}
swap(a,k,hi);
return k;
}
public static void qSort(int[]a, int lo, int hi) {
if (lo<hi) {
int pivIndex = partition(a,lo,hi);
qSort(a,lo,pivIndex-1);
qSort(a, pivIndex+1, hi);
}
}
public static void quickSort(int[] a) {
10-135
qSort(a,0,n-1);
}
public static void main(String[] args) {
int[]r=new int[n];
fillReihungRandom(r);
printReihung(r);
quickSort(r);
printReihung(r);
}
}
Partitionierung (Methode partition(int[]a, int lo, int hi))
• Idee: Pivotelement w irgendwo aus der Mitte wählen (eigentlich egal), am rechten
Rand (hi) ablegen
• Dann 3 Bereiche bilden
 lo..k-1 : Elemente kleiner als w
 k..i-1: Elemente größer gleich w
 i..hi-1: unsortierte Elemente
10-136
Herunterladen