Grundlagen der Programmierung HUMBOLDT-UNIVERSITÄT ZU BERLIN

Grundlagen der Programmierung  HUMBOLDT-UNIVERSITÄT  ZU  BERLIN

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 vor- und 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. Springer-

Lehrbuch (+ 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 1) * (n 2) *

* 3 * 2 * 1

n

i

1

i

 n!

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 log(n) 256*n n

2

17*n

3

2 n n!

n n

10

50

1

2

3

256

1000

10000

0,0

0,3

0,5

256

512

768

1,0 2560

1,7 12800

1

4

9

17

136

459

2

4

8

1

2

6

#ZAHL!

#ZAHL!

#ZAHL!

1

4

27

100 17000 1024 3628800 10000000000

2500 2125000 1,1259E+15 3,04141E+64 8,88178E+84

2,4 65536 65536 285212672 1,15792E+77 #ZAHL!

3,0 256000 1000000 1,7E+10 1,0715E+301 #ZAHL!

4,0 2560000 100000000 1,7E+13 #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

10

28

=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, Geo-

Informatik, 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:

ab 1980:

Informatikstudium in Deutschland

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

x

1

,...,

x n

M

steht für

x

1

M

und … und

x n

M

, und {

x

1

,...,

x n

} ist die Menge, die genau die Elemente x ,...,

1

x n

enthält.

Beispiele für Mengen sind:

: Die Menge der natürlichen Zahlen 1,2,3,…

0

: Die Menge der Kardinalzahlen (natürlichen Zahlen einschließlich der Null):

0,1,2,3,…

: Menge der ganzen Zahlen …, -3, -2, -1, 0, 1, 2, 3, …

, : Menge der rationalen bzw. reellen Zahlen

 

oder { }: leere Menge

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 ist eine Teilmenge von

Element von

1

M ist. Die Mengen

2

M und

1

M (

2

M

1

M

2

), wenn jedes Element von

M sind gleich (

2

M auch

1

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

| -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:

Vereinigung:

M

M

1

1

M

M

2

2

{

{

x x

|

|

x x

M

M

1

1

x x

M

M

2

2

}

}

.

.

1-20

-

Beispiele:

Differenz:

M

1

0

=

0

,

M

2

{

x

|

x

M

1

0

= , –

0

=

,

x

M

2

} .

0

=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

M

2 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

0

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 bezeichnet

M

1

M

2

{(

x

,

y

) |

x

M

1

M und

1

M Mengen sind, so

2

y

M

2

} die Menge aller Paare von Elementen, deren erster Bestandteil ein Element aus M und deren zweiter eines aus

1

M ist. Da die

2 runden Klammern für vielerlei Zwecke verwendet werden, verwendet man manchmal zur

Kennzeichnung von Paaren auch spitze oder, besonders in Programmiersprachen, eckige

Klammern.

Beispiele:

={(1,tt),(1,ff),(2,tt),(2,ff), (3,tt),…},



=

,

{0}={(1,0), (2,0), (3,0), …},

0

={(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

{(

x

1

,

x

2

,...,

x n

) |

x

1

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

(

x

1

,

x

2

,...,

x n

)

x i

Falls alle

M i

gleich sind (

M

1

M

M

    

M

auch

n

M

2

    

M

und nennen

M n

M

) , so schreiben wir statt

(

x

1

,

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!

n

M

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

*

M

0

.

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/JN1015-

Collections ). 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 und

1

M ist eine Teilmenge von

2

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

M

M

1 2

, 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 der

x

M

1

, für die es ein

(xRy ) gibt, heißt der Definitionsbereich oder

Urbildbereich (domain) der Abbildung; die Menge der

x

M

1 mit

f

:

M

1

M

2

und

y

M

2 mit

f

(

x

)

(xRy )

y

für

f

M

1

M

2

und (

x

,

y

)

y

M

2

, für die es ein

f

. Die Menge 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 [x

1

:y

1

, x

2

:y

2

,....,x n

:y n

], also z.B.

[ 1 : 5 , 2 : 10 , 3 : 15 ] oder

[ "Name" : "Anton" , "id" : 573328 ] , aber auch

[ 3 .

14 : 'a' , "a" : 2010 , 10 : 10 , 7 e5: 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 n-

Tupel (

x

1

,

x

2

,...,

x n

)

M

1

M

2

    

M n

genau ein

y

M

existiert mit (

x

1

,

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 , , und . Die Differenz – ist auf , und eine Operation, auf 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

: M falls

2

(x

x

M

1

und

(x

x

M

1

. Mit Hilfe der charakteristischen Funktion kann jede Relation zwischen den

Mengen

M

1

,

M

2

von

,

  

,

M

M in

1

n

M definiert durch

2

als Funktion von

M

1

,

M

2

,

 

)

, true falls

M n

) false

nach 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

symmetrisch, wenn für alle

x

,

y

M

mit

xRy

und

yRz

gilt, dass xRz . 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.

i n

1

i

3

i n

1

i

2

Ein wichtiger Gesichtspunkt beim Induktionsaxiom ist, dass die natürlichen Zahlen als

„induktiv aufgebaut“ dargestellt werden gemäß den folgenden Regeln:

1

.

Wenn i

, dann auch i+1

.

Außer den so erzeugten Objekten enthält 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(n1 )) }

def

fac(n){ (n== 1 )? 1 : n*fac(n1 ) }

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

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)

fib

(

n

)

fib

(

n

1 ,

1 )

falls n fib

(

n

2

2 ),

sonst

oder, in der programmiersprachlichen Fassung (in Groovy),

def

fib(n){ (n<= 2 )? 1 : fib(n1 ) + fib(n2 ) }

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:

fib

(

n

)

1

5



1

2

5



n

 



1

2

5



n

.

Als Lemma benötigen wir, dass für

1

2

5 gilt

1

2

, und ebenso für

1

2

5

.

Das sieht man durch einfaches Ausrechnen, ebenso die Gültigkeit der Aussage für n=1,2.

Damit können wir als Induktionsannahme voraussetzen, dass

fib fib

(

n

(

n

)

2 )

1

5

1

5

n

1

n

2

n

2

n

2

n

. Mit

1

n fib

(

2

n

)

, d.h.,

fib

(

n

1 )

fib

(

n

fib

2 )

(

n

1 )

1

5 ergibt sich

n

1

n

1

und

fib

(

n

)

1

5

(

1 )

n

2

(

1 )

n

2

1

5

2

n

2

2

n

2

1

5

n

n

, was zu zeigen 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={a

1

, …, a n

}. 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 der

Wahrheitswerte.

Eine (endliche) Folge w

A

* von Zeichen über einem Alphabet A heißt Wort oder Zeichen-

reihe (string) über A. Normalerweise schreibt man, wenn es sich um Wörter handelt, statt w =

(a

1

,a

2

,…,a n

) kurz w = "a

1 a

2

…a n

", 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 = a

1 a

2

…a n und w = b

1 b

2

…b m

, dann ist v

° w = a

1 a

2

…a n b

1 b

2

…b m

. 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 a

1 a

2

…a n

= b

1 b

2

…b m

, mit (a i

, b i

∈A), so ist n=m und a

1

= b

1

und… und a n

= b n

).

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= x

1 x

2

…x n

und y= y

1 y

2

…y m

. 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

(a < b bedeutet a

b und nicht a = b)

°

x’, y=z

°

y’) und first(x’) <first(y’).

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“)

) inverse(„BC“) rest(„ABC“) first(„ABC“)

+ rest(„BC“) inverse(„C“) first(„BC“)

+ rest(„C“) inverse(„“) first(„C“)

+

(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: fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(2) 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:

fibComp

(

n

)

1

fibComp

(

n

1 ,

1 )

falls

n

2

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

)

2 * (

fib

(

n

1

1 )

fibComp

(

n fib

(

n

2 ))

1

1 )

fibComp

2 *

fib

(

n

)

(

n

1

2 )

I

.

V

.

1

2 *

fib

(

n

1 )

1

2 *

fib

(

n

2 )

1

Mit der früher bewiesenen Gleichung von Binet erhalten wir

fibComp

(

n

)

2

5



2

5



n

2

5



n

1

.

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*10

6

2*10

8

2.5*10

10

3*10

12

3.8*10

14

4.6*10

16

5.7*10

18

7*10

20

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:

A

B

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 B C D

A X

B X X

C X X X

D

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 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 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 2

n

mögliche Bitfolgen der Länge n gibt (| |=2  |

n

|=2

n

)

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 2

8

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:

2

10

= 1024

1000 = 10

3

Für Größenangaben von Dateien, Disketten, Speicherbausteinen, Festplatten etc. benutzt man daher folgende Präfixe:

k = 1024 = 2

10

10

3

M = 1024

2

= 1048576=2

20

10

6

G = 1024

3

= 2

30

T =

1024

4

= 2

40

P =

1024

5

= 2

50

E =

1024

6

= 2

60

10

10

10

10

9

12

15

18

(k = Kilo)

(M = Mega)

(G = Giga)

(T = Tera)

(P = Peta)

(E = Exa)

Die Ungenauigkeit der obigen Näherungsformel nimmt man dabei in Kauf.

2-32

Mit 1 GByte können also entweder 2

30

= 1024

3

= 1.073.741.824 oder 10

9

= 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

10

6

= 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

(m = Milli)

(µ = Mikro)

n = 1/1000000000 = 10

-9

(n = Nano)

p = ... = 10

-12

f = ... = 10

-15

(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).( http://www.land.salzburg.at/hs-kuchl/hsk-math/Araber.htm

)

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 x n-1

... x

0

repräsentiert, wobei - bezogen auf eine gegebene Basis b - jede Ziffer x i

einen Stellenwert (x i

*b i

) bekommt:

x n

1

x

0

b

i n

1

0

x i

b i

Werden dabei nur Ziffern x

i

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 [x n-1

... x

0

] b

und [ y n'-1

... y

0

] b'

mit 0

x

b und 0

y

i

b' ineinander überführt.

i

2-34

Beispiele:

b = 2:

b = 3:

b = 8:

b = 10:

[10010011]

2

= 1*2

7

+ 1*2

4

+ 1*2

1

+ 1*2

0

= [147]

10

[12110]

3

= 1*3

4

+ 2*3

3

+ 1*3

2

+ 1*3

1

= [147]

10

[223]

8

= 2*8

2

+ 2*8

1

+ 3*8

0

= [147]

10

[147]

10

= 1*10

2

+ 4*10

1

+ 7*10

0

=[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

[16]

[17]

10

=[21]

8

=[11]

16

10

=[20]

8

=[10]

16

[256]

10

=[400]

8

=[100]

[1000]

10

=[3E8]

16

16

[8]

10

=[10]

8

=[8]

16

=[1000]

2

[32]

10

=[40]

8

=[20]

16

[4096]

10

=[10000]

8

=[1000]

16

[9]

10

=[11]

8

=[9]

16

=[1001]

2

[33]

10

=[41]

8

=[21]

16

[10]

10

=[12]

8

=[A]

16

=[1010]

2

[10000]

10

=[2710]

16

[11]

10

=[13]

8

=[B]

16

=[1011]

2

[80]

10

=[100]

8

=[50]

16

[12]

10

=[14]

8

=[C]

16

=[1100]

2

[45054]

10

=[AFFE]

16

[13]

10

=[15]

8

=[D]

16

=[1101]

2

[160]

10

=[240]

8

=[A0]

16

[65535]

10

=[177777]

8

=[FFFF]

16

[14]

10

=[16]

8

=[E]

16

=[1110]

2

[10

6

]

10

=[F4240]

16

[15]

10

=[17]

8

=[F]

16

=[1111]

2

[255]

10

=[377]

8

=[FF]

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 2

n

Zahlenwerte darstellen:

die natürlichen Zahlen von 0 bis 2 n

- 1 oder

die ganzen Zahlen zwischen -2 n-1

und 2 n-1

- 1 oder

ein Intervall der reellen Zahlen (mit begrenzter Genauigkeit)

2-35

Beispiel:

Länge darstellbare Zahlen

4 0 .. 15

8

16

32

0 .. 255

0 .. 65535

0 .. 4 294 967 295

Darstellung ganzer Zahlen

Für die Darstellung ganzer Zahlen wird ein zusätzliches Bit (das "Vorzeichen-Bit") benötigt. Mit Bitfolgen der Länge n kann also (ungefähr) der Bereich [-2

n-1

.. 2

n-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:

[-x n-1

... x

0

]

2c

+ [x n-1

...x

0

]

2c

= [10...0]

2c

= 2 n+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 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 meist wie reelle Zahlen behandelt.

Für reelle Zahlen gilt:

Es gibt überabzählbar viele reelle Zahlen . 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 gibt es die Festkomma- und die Gleitkommadarstellung. Bei der

Festkommadarstellung steht das Komma an einer beliebigen, festen Stelle. Für

x=[x

n-1 x n-2

…x

1 x

0 x

-1 x

-2

…x

-m

]

2

ist

x

n

1

i

 

m x 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 m

1

....m

n

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 * b

e-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 * 2

6-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’=2

L-1

-1.

Damit ist 0

E

(2

L

-1) positiv, und (v, m, E) wird als (-1)

v

* m * b

E -(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 * 2

3-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*10

38

.(7 relevante

Dezimalstellen: 23 Bit Mantisse, 8 Bit Exponent)

Datentyp double (64 Bit) mit dem Wertebereich (+/-)4.9*10

-324

..1.79*10

308

.(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 ASCII-

Erweiterungen 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 10646-

1: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-2038-

2-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 Beifahrer-

Aussage „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 = w

1° u

° w

2

, w’ = w

1° v

° w

2

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

L

G

ist definiert durch

L

G

= { 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>

<subjekt>

<subjekt>

<verb>

<attribut> <substantiv> “.”

<objekt>

<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 {a

n

b

n

c

n

| 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 j

mit der Bedeutung

„…mindestens i und höchstens j mal“, wobei j auch durch einen Stern ersetzt werden kann

(für „beliebig mal“). Es gilt

{

x

}

 

*

0

und

   

1

0

.

Beispiele:

 

3

0

{

,

a

,

aa

,

aaa

}

2-44

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.

Beispiele: a 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:

{}, falls p=5

0,50+Wechselgeld (p+0.50), falls p

5 und 5-p

0.50

Wechselgeld (p) =

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 w={}

Int(Fract(p)*10)

{3, 8}? y w+={20c} p +=0.2

Int(Fract(p)*10)

{2,4,7,9}? y w+={10c} p +=0.1 n p < 5 ? y w+={50c} p +=0.5

Int(Fract(p)*10)

{1, 6}? y w+={20c} p +=0.2 n

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} n n

2-49

5. Darstellung als Groovy- oder Java-Programm (mit heftigem Gebrauch von Mathematik)

Groovy: int

fuenfzigCentStuecke(

float

p) {

(( 5 - p) * 1 0 / 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 („von-

Neumann-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“

(http://www.henrykautz.org/Computers/index.htm) 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 mit

3-53

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: I

1

und I

1

´, I

2

und I

2

´) 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 I

1

)

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

]

3

1

_ [“+|“-][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

]

3

1

[“´”]

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 I

2

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) = x

2

+ 6x + 6, x=1..10

V1 = x

V2 = x

2

V3 = 6

V4 = x

2

, x

2

+6x, x

2

+6x+6

V5 = 6x

N1 10

N3 6

*

L1

L1

S4

L1

3-55

L3

S5

+

L4

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

3-56

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.“ …

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>}

3-57

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 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 any P0,R,R s1 s1 any 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)

Steuerwerk

(control unit)

3-61

Peripherie

Konsole

Tastatur

(+Maus)

Bildschirm

Drucker Plattenspeicher

CD-

Laufwerk

Prozessor

(CPU - central processing unit)

Steuerwer k

(Programmablauf)

Befehle

Rechenwerk

(einzelne Operationen)

Register:

Operanden

Bus (Verbindung)

E/A

Prozessor

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 http://www.kreissl.info/diggs/ra_01.php):

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-Neumann-

Flaschenhals“)

- 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-Neumann-

Architektur 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, Transistor-

Ebene, 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, 2-

50 ns für einen schnellen Cache, 100-300 für einen Hauptspeicherzugriff, 10-

50ms 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

-8

Größe: gemessen in cm

2

oder cm

3 pro Bit c/B=35c/GB) o

Papier: 6000 Zeichen/630cm²=10B/cm²; Floppy: 1.44MB/ 80cm

2

=18 KB/cm

2

;

Festplatte: 10-20 GB/cm

2

, physikalisch machbar 1000GB/cm

2

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 CACHE

3786:

3787:

3788:

3789:

3790:

17

"c"

3.1415

3786

"x" c1: 0 1 0 3787 c2: 1 1 0 3788 c3: 0 1 0 3792

"c"

3.1415

3790

3791: 123456

3792: 3790

3793: NIL c1: 0 1 0 3790 c2: 1 1 0 3788 c3: 0 1 0 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 Diagramm-

Erstellers.

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

44

45

47

30

25

20

15

10

5

50

45

40

35

6

0

Fortran

10

13

Delphi

Pascal

14

19

Java

22

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 Scala

Sun-Ray Sun-Ray

Java Java

JVM

Groovy Groovy

PC PC

C C

.NET

C++ C++

Android Android

C# C#

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,

1993

1994

Erweiterbarkeit der Systeme und Austauschbarkeit von Komponenten.

Oak wird im Green-Projekt zum Experimentieren mit graphischen Benutzer-

Schnittstellen eingesetzt und später (wegen rechtlicher Probleme) in Java umbenannt. Zu diesem Namen wurden die Entwickler beim Kaffeetrinken inspiriert.

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 Java-

Programme 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={x

1

,…,x n

}. Ein λ–Term ist definiert durch folgende Grammatik:

λ–Term ::= Variable | (λ–Term λ–Term) | λ Variable . λ–Term

Variable ::= x

1

| … | x n

λ–Terme der Art (t

1

t

2

) 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 (t

1

t

2

) gelesen als „die Funktion t

1 angewendet auf Argument t

2

“ 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. t

1

) t

2

= t

1

[x:=t

2

]

Hierbei bezeichnet t

1

[x:=t

2

] den Term t

1

, wobei jedes freie (d.h. nicht durch ein λ gebundene)

Vorkommnis von x durch t

2 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. f

n

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 )? (n10 ) : f(f(n+ 11 ))} g = {n -> (n> 100 )? (n10 ) : 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-Neumann-

Rechnern 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 (n1 , 1 ) : ack(n1 , ack(n,m1 ))}

Hier sind die Werte der Funktion für verschiedene Eingabeparameter n=0 n=1 n=2 n=3 m=0 1 2 3 5 m=1 2 3 5 13 m=2 3 4 7 29 m=3 4 5 9 61 m=4 5 6 11 125 m=5 6 7 13 253 m=6 7 8 15 509 m=7 8 9 17 1021 m=8 9 10 19 2045 m=9 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. t

1

) t

2

)…) 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 , y1 ) + fun ( 2 , y1 ) } fun( 0 , 2 ) ergibt

Aufruf mit x: 0, y: 2

Aufruf mit x: 1, y: 1

Aufruf mit x: 1, y: 0

Aufruf mit x: 2, y: 0

Aufruf mit x: 2, y: 1

Aufruf mit x: 1, y: 0

Aufruf mit x: 2, y: 0

Result: 0

Das Church-Rosser-Theorem im λ–Kalkül besagt, dass die Relation , mit t

1

 t

2

g.d.w. t

2 durch β-Reduktion aus t

1

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: 

0

, 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 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(x1 );

}

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 „Mini-

Javakurs“ 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 ]);

}

}

6-86

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 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 >>>=

6-87

Fallunterscheidung (dreistellig) x? y: z

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

Rechtsshift {

static int

i

= 64 ;

static int

j

= i>> 2 ;

static int

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 .

14 f // 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.*.

Zugriff auf Bibliotheksfunktionen erfolgt mit Qualifizierern, z.B.

System.out.

println

(„Hallo“) java.util.Date d =

new

java.util.Date ();

(Grafik: © Bothe 2009)

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

)

Beispiel für eine Signatur ist also etwa : (

0

,0,s,+,*). Dabei sei 0 nullstellig, s einstellig, und + und * zweistellig.

0 :

0

s :

0

0

+:

0

×

0

0

*:

0

×

0

0

Es erhebt sich die Frage, welche Datenobjekte vom Typ

0

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}

{x | x%2=0}   = 

 {1,2,3}

Beispiel: Paare ganzer Zahlen Z

Z:

ADT ZZ = (ZZ, first, second, conc)

first, second : ZZ

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 Objekt Datenfeld Methode

Mensch sokrates hungrig denken

Auto

Planet herbie erde tankinhalt fahren masse drehen

Kreis kreis1 radius 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. nutzlas t 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

• 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)

7-104

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 Standard-

Ausgabestrom 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 Byte-

Arrays 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-Maschine-

Kommunikation 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 Main-

Objekt. 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 Werte

(AC1, AC2, s) load s add 1 store s load s sub 1 store s

(5, -, 5)

(6, -, 5)

(6, -, 6)

(6, 6, 6)

(6, 5, 6)

(6, 5, 5) aber auch:

Befehl P1 Befehl P2 Werte

(AC1, AC2, s) load s load s

(5, -, 5)

(5, 5, 5) add 1 store s sub 1 store s

(6, 5, 5)

(6, 4, 5)

(6, 4, 6)

(6, 4, 4) oder

Befehl P1 Befehl P2 Werte

(AC1, AC2, s) load s load s sub 1 store s

(5, -, 5)

(5, 5, 5)

(5, 4, 5)

(5, 4, 4) add 1 store s

(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 Fairness-

Eigenschaften 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: druckt

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());

}

}

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

}

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

}

55

11

null

null

77

66

null

null

99

null

null

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:

BeispielGraph() {

C

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;

D

}

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.numberOfEdges; i++)

if

(suche(c, g.edge[i]))

return true

;

return false

;

}

Darstellung von Graphen als Adjazenzmatrix

• Angenommen, die Knoten seien k

1

…k n

• boolesche Matrix m der Größe n

 n

 m[i][j] gibt an, ob eine Verbindung von k i-1

zu k j-1

existiert oder nicht

• Vorbesetzung z.B. zu einem bestimmten Prozentsatz zufällig mit true :

public class

Adjazenzmatrix {

static int

n

= 5;

boolean

[][] matrix =

new boolean

[

n

][

n

];

void

fillMatrixRandom (

float

f) {

for

(

int

i = 0; i<

n

; i++)

for

(

int

j = 0; j<

n

; j++) matrix [i][j]=(Math.random()<f); }

void

printMatrix () {

for

(

int

i = 0; i<

n

; i++) {

for

(

int

j = 0; j<

n

; j++)

System.

out

.print( matrix [i][j]?

" t" : " f" );

System.

out

.println();}

System.

out

.println();

}

} 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

 Reihenfolge der Schleifen ist wichtig!

* y

(xR

* z

zR

* y)

• Algorithmus von Floyd (kürzeste Wege)

 Entfernungsmatrix, min statt

, + statt

void

warshall() {

for

(

int

z=0; z<

n

; z++)

for

(

int

x = 0; x<

n

; x++)

for

(

int

y = 0; y<

n

; y++) matrix [x][y]= matrix [x][y]|| matrix [x][z]&& matrix [z][y];

}

Anwendung z.B. wie folgt:

public class

AdjazenzmatrixTest {

public static void

main(String[] args) {

Adjazenzmatrix A=

new

Adjazenzmatrix();

A.fillMatrixRandom(0.2f);

A.printMatrix();

A.warshall();

A.printMatrix();

}

}

10-132

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

EinfacheSuche {

public static final int

n

= 10;

static int

[]

r

=

new int

[

n

];

static void

printReihung() {

for

(

int

i = 0; i<

n

; i++)

System.

out

.print(

r

[i] + " " );

System.

out

.println( "" );

}

static void

fillReihungRandom() {

for

(

int

i = 0; i<

n

; i++)

r

[i]=(

int

) (Math.random()*10);

}

static int

sucheLinear(

int

suchinhalt) {

for

(

int

i=0;i<

n

;i++) {

if

(

r

[i]==suchinhalt)

return

(i);

}

return

(-1); //oder exception

}

public static void

main(String[] args) {

fillReihungRandom();

printReihung();

System.

out

.println(sucheLinear(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

BinaerSuche {

public static final int

n

= 10;

static int

[]

r

=

new int

[

n

];

static void

printReihung() {...}

static void

fillReihungSorted() {

final int

inc = 3;

r

[0]=(

int

) (Math.random()*inc);

for

(

int

i = 1; i<

n

; i++)

r

[i]=

r

[i-1] + (

int

) (Math.random()*inc);

}

static int

sucheBZ(

int

si,

int

lo,

int

hi) {

if

(lo > hi)

return

-1;

int

mitte = (hi + lo) / 2;

if

(si ==

r

[mitte])

return

mitte;

else if

(si <

r

[mitte])

return

sucheBZ(si, lo, mitte - 1);

else

/* (si > r[mitte])*/

return

sucheBZ(si, mitte + 1, hi);

10-133

}

static int

sucheBinaer(

int

suchinhalt) {

return

sucheBZ(suchinhalt, 0,

n

-1);

}

public static void

main(String[] args) {

fillReihungSorted();

printReihung();

System.

out

.println(sucheBinaer(7));

}

}

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

HashSuche {

public static final int

n

= 10;

static int

[]

r

=

new int

[

n

];

static void

printReihung() {...}

static void

fillReihungHashable() {

for

(

int

i = 0; i<

n

; i++)

r

[i]= 10*i + (

int

) (Math.random()*10);

}

static int

sucheHash(

int

suchinhalt) {

int

idx = suchinhalt / 10;

return

(

r

[idx]==suchinhalt)?idx:-1;

}

public static void

main(String[] args) {

fillReihungHashable();

printReihung();

System.

out

.println(sucheHash(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

BubbleSort {

public static final int

n

= 5;

static int

[]r=

new int

[

n

];

10-134

static void

printReihung() {

...

}

static void

fillReihungRandom() {

final int

range = 100;

for

(

int

i = 0; i<

n

; i++)

r

[i]=(

int

) (Math.random()*range);

}

static void

swap (

int

i,

int

j) {

int

h =

r

[i];

r

[i] =

r

[j];

r

[j] = h; }

static void

bubbleSort () {

for

(

int

k=

n

-1; k>0; k--)

for

(

int

i=0; i<k; i++)

if

(

r

[i]>

r

[i+1]) {

swap(i,i+1);

printReihung();

}

}

public static void

main(String[] args) {

fillReihungRandom();

printReihung();

bubbleSort();

printReihung();

}

}

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

Quicksort {

...

private static int

partition(

int

lo,

int

hi) {

swap((lo+hi)/2, hi);

int

w =

r

[hi], k=lo;

for

(

int

i=k; i<hi; i++)

if

(

r

[i]<w) {swap(i,k); k++;}

swap(k,hi);

return

k;

}

public static void

qSort(

int

lo,

int

hi) {

if

(lo<hi) {

int

pivIndex = partition(lo,hi);

qSort(lo,pivIndex-1);

qSort(pivIndex+1, hi);

}

}

public static void

quickSort() {

qSort(0,

n

-1);

}

public static void

main(String[] args) {...}

}

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-135

Was this manual useful for you? yes no
Thank you for your participation!

* Your assessment is very important for improving the work of artificial intelligence, which forms the content of this project

Download PDF

advertisement

Table of contents