Aufgabenblock 4 - Institut für Wissenschaftliches Rechnen

Werbung
Institut für Wissenschaftliches Rechnen
Technische Universität Braunschweig
Prof. Hermann G. Matthies, Ph. D.
Rainer Niekamp
Sommersemester 2014
15. April 2014
Weiterführendes Programmieren
Zeichenketten-Interpretation und Graphenalgorithmen
Aufgabenblatt 4
Interpretation von Sprachen
Bei der Entwicklung von Berechnungsprogrammen und interaktiver Software ist es häufig notwendig
komplexe Benutzereingaben zu interpretieren und in eine Systemantwort des Softwaresystems zu überführen. So stellen Eingaben auf der Ihnen bereits bekannten Kommandozeile von UNIX eine Interaktion
zwischen Benutzer und einem Sprachinterpreter dar.
Beispiele wie das Einlesen komplexer Datensätze aus Dateien oder die Entwicklung von Anwendungssoftware für das Internet erfordern den Umgang mit Zeichenketten, deren Manipulation oder Interpretation.
In dieser Aufgabe soll der Umgang mit Zeichenketten und insbesondere deren Interpretation durchgenommen werden. Für die Interpretation einfacher Ausdrücke kann man sehr leicht einfache Interpreter
schreiben. Erst bei komplizierteren Ausdrücken, wie sie z.B. bei der Interpretation einer Programmiersprache aufteten, sollte man für die Implementierung eines Interpreters unbedingt Werkzeuge wie etwa
JavaCC verwenden.
In dieser Aufgabe wollen wir nicht so weit gehen und auf eine sehr einfache Sprache interpretieren.
Bei der Beschreibung solcher formalen Sprachen ist es üblich die sogenannte Backus-Naur-Form (BNF)
(http://de.wikipedia.org/wiki/Backus-Naur-Form) zu verwenden.
Sprache zur Beschreibung von Formalen Sprachen
In der Metasprache BNF (Metasprache bedeutet soviel wie “Sprache zur Beschreibung von Sprache”)
kann man sogenannte Produktionen einsetzen, welche definieren, welche Form ein bestimmter Ausdruck
annehmen kann. Beispiel:
<Ziffer ausser Null> ::= 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Eine Ziffer außer der Null ist eine 1, oder eine 2, oder eine 3,... Diese sogenannte Produktionsregel
beschreibt, was eine Ziffer außer der Null ist. Es lassen sich auch Sequenzen definieren, bei denen bestimmte Symbole in einer bestimmten Reihenfolge auftreten:
<Ziffer>
<Zweistellige Zahl>
<Zehn bis Neunzehn>
<Zweiundvierzig>
::=
::=
::=
::=
0 | <Ziffer ausser Null>
<Ziffer ausser Null> <Ziffer>
1 <Ziffer>
42
Eine Ziffer ist also eine 0 oder eine “Ziffer außer Null”. Eine zweistellige Zahl ist eine “Ziffer außer
Null” gefolgt von einer Ziffer. Zweiundvierzig ist eine 4 gefolgt von einer 2.
Wiederholungen müssen in BNF über Rekursionen definiert werden. Eine Ableitungsregel kann dazu auf
der rechten Seite das Symbol der linken Seite enthalten, etwa:
<Ziffernfolge> ::= <Ziffer> | <Ziffer> <Ziffernfolge>
Lies: Eine Ziffernfolge ist eine “Ziffer” oder eine “Ziffer gefolgt von einer Ziffernfolge”.
Mit Hilfe dieser Schreibweise kann man auf sehr einfache Weise die Struktur von Ausdrücken beschreiben. Wir wollen die BNF auf diesem Aufgabenblatt dazu benutzen eine sehr einfache Sprache zur Beschreibung von Graphen zu definieren. Doch nun wollen wir zunächst Graphen vorstellen, die wir später
mit einer Sprache beschreiben wollen.
Graphen zur Modellierung von Wegen
Ein Graph im Sinne der mathematischen Graphentheorie besteht aus Elementen einer Menge (die sogenannten Knoten des Graphs), zwischen denen direkte Verbindungen bestehen können (die sogenannten
Kanten des Graphs). Mathematisch ausgedrückt ist ein Graph G ein Tupel (V, E), wobei V eine Menge von Knoten und E eine Menge von Kanten bezeichnet. Dieser Definition genügt folgendes einfache
Beispiel:
G1 = ({1, 2, 3}, {(1, 2), (3, 1), (3, 2)})
Dieser Graph G1 hat drei Knoten, die Zahlen 1, 2 und 3. Er besitzt drei
Kanten von der Zahl 1 zur Zahl 2 und von der Zahl 3 jeweils zu den Zah3
len 1 und 2. Man kann Graphen auch graphisch darstellen, der Graph G1 ist
in Abbildung 1 visualisiert. Graphen wie diese haben in vielen Bereichen
2
der Wissenschaft und Technik herausragende Bedeutung. Sie werden in der
1
Elektrotechnik z.B. genutzt um Schaltungsnetze zu modellieren, in der Informatik um Prozesse und Datenstrukturen zu modellieren, in der Mathematik zur Modellierung diskreter Topologien; die Liste der Anwendungen kann Abbildung 1: Grafische
beliebig weitergeführt werden. Praktische Relevanz im Alltag findet sich in Darstellung des Graphen
der Straßennavigation, wo Orte durch Knoten und Verbindungswege durch G1
Kanten modelliert werden können.
Bleiben wir kurz in der Straßennavigation, denn an dieser Anwendung lassen sich alle wichtigen Eigenschaften von Graphen gut erklären. Der in Abbildung 2 dargestellte Graph zeigt ein Modell der
Verbindungswege zwischen den “Verkehrsknotenpunkten” Braunschweig, Hannover und Hamburg. Die
Verbindungen zwischen den Knoten sind mit dem Namen der Autobahn beschriftet, welche die Knoten
direkt verbindet. Mit diesem Graphen kann man “berechnen”, welchen Weg man nehmen kann, um von
Braunschweig nach Hamburg zu gelangen. Wären in dem Graphen alle Stätte Deutschlands mit allen
Straßen und ihren Namen eingetragen, so könnte man eine ganze Reihe von Wegen zwischen beliebigen
Orten finden.
Allerdings kann es sein, dass der berechnete Weg nicht der kürzeste ist, denn in dem Graphen gibt es
keine Daten über Distanzen zwischen den Knoten, welche allerdings erforderlich sind, um eine Minimierung der Gesamtwegstrecke durchzuführen. Diese Funktionalität kennen Sie von modernen Navigationsgeräten. Um den Informationsgehalt des Graphen zu erhöhen, kann man ihn mit weiteren Daten
„A2“
dist=62.32
„A2“
Braunschweig
Braunschweig
Hannover
„A2“
dist=61.31
„A2“
„A7“
Hannover
„A7“
dist=148.2
„A7“
Hamburg
„A7“
dist=149.3
Hamburg
Abbildung 3: Eine vereinfachte Graphenrepräsentation der Verbindungswege zwischen
Hamburg, Hannover und Braunschweig mit
annotierten Distanzen.
Abbildung 2: Eine vereinfachte Graphenrepräsentation der Verbindungswege zwischen
Hamburg, Hannover und Braunschweig.
anreichern, welche z.B. die Entfernung in Autobahnkilometern auf einer Kante oder die Stauwahrscheinlichkeiten beschreiben. Dies ist beispielhaft in Abbildung 3 geschehen. Wenn die Daten mit der realen
Situation übereinstimmen, kann man mit einem solchen Graphen tatsächlich denen kürzesten Verkehrsweg zwischen zwei Verkehrsknotenpunkten ermitteln. Reichert man den Graphen dann weiter an, so
dass die Koordinaten der Stätte zu den Datensätzen gehören (also weitere Informationen zu den Knoten
einfließen), so kann man z.B. auch die geometrische Entfernung der Knotenpunkte berechnen. Sie sehen,
dass die Menge der Anwendungen nur von der Fantasie begrenzt ist.
Eine Sprache zur Beschreibung von Graphen
Im letzten Abschnitt ist definiert worden, dass ein Graph formal aus einer Menge von Knoten (engl.
Nodes) und einer Menge von Kanten (engl. Edges) besteht.
G = (V, E)
Um also eine Sprache zur Beschreibung von Graphen beschreiben zu können, muss man genau diese
Elemente abbilden, also Kanten und Knoten definieren können. Ein Beispiel für eine Sprachdefinition in
BNF ist:
<NAME>
<REAL>
::= <STRING ’a-z 0-9 _’>
::= <FLOAT>
<NODEDEFINITION>
<EDGEDEFINITION>
<HELPCALL>
<QUITCALL>
::=
::=
::=
::=
NODE ( <NAME> )
EDGE ( <NAME> , <NAME> , <REAL> )
HELP
QUIT
Wie Sie sehen, wurde in den ersten beiden Ausdruckstypen auf eine formal vollständige Definition verzichtet.
Die hier definierte Sprachen nennt man aus formaler Sicht regulär, das heißt, sie ist verglichen mit
anderen in BNF definierbaren Sprachen einfach aufgebaut. Daher können wir sie mittels sogenannter
regulärer Ausdrücke in Java (Klasse java.util.regex.Pattern) auswerten. Die Ausdrücke ergeben sich unmittelbar aus der Sprachdefinition, und wir können über Platzhalter einzelne Werte, wie
zum Beispiel Knotennamen und Kantengewichte, aus einem String, der obiger Sprachdefinition genügt,
auslesen.
Übung 1: Kürzeste Wege Algorithmen – Floyd-Warshall
Zunächst sollten Sie sich intensiv mit dem von uns vorgegebenen Code auseinandersetzen, den Sie unter
http://www.wire.tu-bs.de/ADV/files/exercise4/graph.zip herunterladen können.
Wenn Sie die Struktur verstanden haben, werden Sie bemerken, dass das Programm einfach erweitert
werden kann. Daher lassen Sie sich auf keinen Fall abschrecken und lassen Sie sich Zeit damit, den
Code genau zu verstehen, dann fallen Ihnen die folgenden Aufgaben leichter.
Programme, die formal definierte Sprachen auswerten, nennt man Parser. So besitzt auch das von uns
vorgegebene Programm einen Parser, der in der Datei Parser.java implementiert ist. Sehen Sie
sich daher zunächst diese Datei an. In der Methode parseCommmand(String command) versucht
der Parser, jeweils eines der definierten Sprachelemente (NODE( ), HELP etc.) im übergebenen String
command zu erkennen, und ggf. die einzelnen Parameter (Knotennamen etc.) zu extrahieren. Die Elemente werden dann vom Programm als Konstruktionsanweisungen für einen Graphen verwendet.
Bei Elementen, die keine Parameter enthalten, reicht ein normaler String-Vergleich zur Erkennung (z.B.
command.equalsIgnoreCase("quit")), bei allen anderen nutzen wir die erwähnten regulären
Ausdrücke, um die übergebene Anweisung auf ihre Struktur (NODE( ) oder EDGE( , , ) zu prüfen.
Dazu wird aus jedem regulären Ausdruck zusammen mit dem Eingabe-String ein Matcher-Objekt erzeugt, das prüft, ob der jeweilige Ausdruck zu der Eingabe passt. Falls ja, werden die einzelnen Werte
ausgelesen. Auslesbare Werte werden in regulären Ausdrücken mit runden Klammern markiert. Diese
Markierungen nennt man Gruppen. Gruppen werden von links nach rechts von 1 ausgehend durchnummeriert und können über die Methode Matcher.group(int position) ausgelesen werden.
1
2
3
4
else if( command . equalsIgnoreCase ("quit")) {
interpreter . quit ();
}
else if( EDGE_REGEX . matcher ( command ). matches ()) {
5
// regex matches , extract given values
6
Matcher matcher = EDGE_REGEX . matcher ( command );
7
8
// must be called again to make group access available
9
matcher . matches ();
10
11
// extract values
12
String sourceName = matcher . group (1);
13
String targetName = matcher . group (2);
14
// convert to double value
15
double weight = Double . parseDouble ( matcher . group (3));
16
17
// invoke corresponding interpreter method
18
interpreter . addEdge ( sourceName , targetName , weight );
19
}
Listing 1: Auszug aus der Methode Parser.parseCommand mit eingesetzten Gruppenpositionen
Die Entwicklung von regulären Ausdrücken erfordert aufgrund der komplexen Syntax ausführliche Erklärungen und ist daher nicht Gegenstand dieses Aufgabenblattes. Soweit Ausdrücke benötigt werden,
geben wir diese vor und verweisen für genauere Informationen auf Kapitel 4.8 im Buch “Java ist auch
eine Insel” [Ullenboom: 10. Auflage] .
Erkennt der Parser eine Anweisung, so gibt er diese zusammen mit etwaigen Parametern an das InterpreterObjekt weiter (Interpreter.java), das dem Parser im Konstruktor übergeben worden ist. Der Interpreter realisiert die eigentliche Konstruktion des Graphen und Steuerung des restlichen Programms.
Wir könnten den Graphen auch direkt im Parser konstruieren, jedoch hat diese Aufteilung auf mehrere
Klassen Vorteile, und ist bei der Entwicklung von komplexeren Programmen üblich. So können wir etwa
den Parser einfach gegen einen anderen austauschen, ohne die Konstruktion des Graphen neu implementieren zu müssen.
Falls der Parser keine zulässige Anweisung erkennen kann, wirft er eine ParseException, die in der
Hauptklasse des Programms (Main.java) aufgefangen wird. Die Hauptklasse liest lediglich zeilenweise Strings von der Konsole ein und übergibt diese jeweils an den Parser.
1
2
3
void addEdge ( String sourceName , String targetName , double weight ) {
graph . addEdge ( sourceName , targetName , weight );
}
4
5
6
7
void addNode ( String nodeName ) {
graph . addNode ( nodeName );
}
Listing 2: Auszug aus der Klasse Interpreter
Das Interpreter-Objekt (Datei Interpreter.java) vervollständigt ahnhand der durch den Parser
aufgerufenen Methoden nach und nach den Graphen. Der Graph ist mittels der Klassen Graph, Node
und Edge implementiert. Schauen Sie sich insbesondere die Kommentare zu den einzelnen Methoden
dieser Klassen an, sie werden diese im Verlauf dieser Aufgabe benötigen, um die Algorithmen zu implementieren.
Mit dem gerade vorgestellten Programm ist es möglich, einen Graphen aus einer Datei einzulesen um
auf ihm Algorithmen auszuführen. Ein wichtiges Problem vor das einen ein komplexer Graph stellt
ist das finden des kürzesten Weges zwischen zwei beliebigen Knoten. Bei dem Beispiel in Abbildung
3 ist es noch relativ einfach den kürzesten Weg zwischen zwei Knoten zu finden, denn es gibt keine
Alternativwege. Ist der Graph allerdings größer und gibt es mehrere verschiedene Wege, so ist dieses
Problem nicht mehr so leicht zu lösen. Im schlimmsten Fall muss man systematisch jeden denkbaren
Weg durchgehen und den kürzesten suchen.
Dieses Vorgehen setzt der sogenannte Floyd-Warshall Algorithmus um, den Sie in dieser Aufgabe implementieren sollen. Dieser Algorithmus würde in einem modernen Navigationssystem zwar keine Anwendung mehr finden, dennoch ist er gerade für den Anfang und für kleine Graphen gut geeignet. Das
große Problem dieses Verfahrens ist, dass seine Rechenkomplexität kubisch von der Anzahl der Knoten
abhängt. Das ist mit einem schnellen Rechner und relativ wenigen Knoten natürlich nicht das größte
Problem, aber zusätzlich benötigt der Algorithmus eine n × n Matrix, welche bei einer Knotenzahl von
1.000 bereits eine größe von 1.000.000 annimmt und somit bei Benutzung von double Variablen (die
8 Byte Speicher einnehmen), bereits 8 MegaByte Speicher und mit 10.000 Knoten schon 100.000.000
schon 800 MegaByte einnimmt. Was nützt ein Navigationssystem, das mit gerade mal 10.000 Kreuzungen schon einen relativ großen Computer benötigt?
Lässt man dieses Problem außer Acht, so ist der Floyd-Warshall Algorithmus ein relativ einfacher und
damit gut verständlicher Algorithmus zur Lösung des Problems der kürzesten Wege.
Bemerkung: Der Floyd-Warshall Algorithmus wird hier als Pseudocode präsentiert. Er berechnet die
kürzeste Distanz vom Knoten source zum Knoten target im Graphen graph.
F LOYDWARSHALL(source, target, graph)
1
n = number of nodes in graph
2
//diagonal entries initially zero, all other entries infinity (Double.MAX VALUE)
costs = n × n matrix
3
4
for each edge in graph
do costs[edge.source.id][edge.target.id] = edge.weight
5
6
7
8
//floyd-warshall phase
for k ← 1 to n
do for i ← 1 to n
do for j ← 1 to n
do costs[i][j] = MIN(costs[i][j], costs[i][k] + costs[k][j])
9
return ←costs[source.id][target.id]
Bemerkung: Beachten Sie bitte bei der Implementierung des Pseudocodes, dass Java-Arrays stets ab
dem Index 0 gezählt werden.
Aufgabe a) Implementieren Sie den Floyd-Warshall-Algorithmus in der Datei Graph.java, welche im Paket http://www.wire.tu-bs.de/ADV/files/exercise4/graph.zip enthalten
ist (das Sie bereits heruntergeladen haben sollten). Der Methodenkopf ist durch public double
getShortestDistance(String sourceName, String targetName) bereits vorgegeben,
Sie müssen lediglich den Rumpf implementieren. Nutzen Sie dabei die bereits vorhandenen Objekte und
Methoden der Graph-Datenstruktur. 2
Aufgabe b) Bauen Sie einen neuen Befehl SHORTESTDISTANCE(Source,Target) in den Sprachinterpreter ein, um die kürzeste Distanz zwischen den zwei Knoten Source und Target mittels des
in der vorherigen Aufgabe implementierten Floyd-Warshall-Algorithmus zu berechnen und auf der Konsole auszugeben. Sie müssen dazu lediglich die Dateien Parser.java und Interpreter.java an
jeweils geeigneten Stellen erweitern. Die BNF zu dem neuen Befehl lautet:
<SHORTESTDISTANCECALL>
::= SHORTESTDISTANCE ( <NAME> , <NAME> )
Sie können den folgenden regulären Ausdruck verwenden:
shortestdistance\\(([\\w]+),([\\w]+)\\)
Halten Sie sich zum Erkennen des Befehls und zum Auslesen der Werte an die Vorgehensweise der bereits implementierten regulären Ausdrücke. Der Name des ersten Knotens befindet sich in Gruppe 1, der
zweite in Gruppe 2. 2
Aufgabe c) Starten Sie das Programm mittels java Main und verwenden Sie die Datei graph.in
mittels Ein-/Ausgabeumleitung als Eingabe. In dieser Datei haben wir einen einfachen Test-Graphen
vordefiniert und lassen beispielhaft drei kürzeste Routen berechnen. Sie können Ihre Implementierung
anhand folgender Ergebnisse testen:
hamburg ---(210,52)---> braunschweig
hamburg ---(328,30)---> muenster
muenster ---(494,52)---> berlin
Testen Sie ihr Programm außerdem mit weiteren Eingaben. 2
Aufgabe d) Geben Sie weitere Knoten und Kanten in die Datei graph.in ein und testen Sie den Algorithmus weiter. 2
Bis jetzt haben wir uns damit beschäftigt, wie man komplexe Datenstrukturen abspeichern und wieder einlesen kann. Diese Aufgabe muss in der Praxis der Programmentwicklung sehr häufig bewältigt
werden. Auch dynamische Datenstrukturen sind in der Praxis ausgesprochen wichtig, da man ohne Sie
nur sehr unflexible Programme schreiben kann, und bestimmte Algorithmen nicht implementiert werden
können. Wir haben auf den letzten Aufgabenblättern bereits Such- und Sortierverfahren betrachtet, welche ein Array als Basisstruktur benutzen. Viele Algorithmen benötigen allerdings Strukturen wie Listen,
bei denen Einträge auch in der Mitte eingefügt werden können (das geht bei Arrays nicht) oder Bäume,
auf denen man noch schneller als in log2 n Schritten suchen kann1 . Da dynamische Datenstrukturen also
eine äußerst wichtige Rolle spielen, werden wir in dieser Aufgabe auf diese Technik eingehen und eine
doppelt verkettete Liste umsetzen.
Die in Abbildung 4 dargestellte Liste besteht technisch aus zwei Klassen.
Klasse CircularList Die Hauptklasse CircularList, die im linken oberen Bereich des Bildes
4 dargestellt ist, stellt die Operationen zur Manipulation der Liste (Einfügen, Entfernen, Bestimmung der
Länge) zur Verfügung. Dazu enthält die Klasse eine Referenz auf den ersten Eintrag der Liste (Listenkopf, head).
Klasse Entry Die eigentliche Liste besteht aus Objekten der Klasse Entry. Für jeden in der Liste
gespeicherten Wert existiert ein Entry-Objekt. Diese Objekte besitzen zusätzlich zu dem eigentlichen
Wert (value) Referenzen auf ihren Vorgänger- und Nachfolgereintrag (next bzw. prev). Daher nennt
man diese Liste doppelt verkettet. Um beispielsweise auf den zweiten Eintrag einer Liste list direkt
zuzugreifen, könnte man den Ausdruck list.head.next.value verwenden.
1
Nehmen Sie die Datenbank aller Autos die in Deutschland gemeldet sind. Nehmen Sie an, die Identifikationsnummern
sind sortiert, so dass man in log2 n Schritten jeden Datensatz findet. Jetzt sollen aber alle Autos gefunden werden, die Weiß
sind, da hilft diese Suche nicht mehr und die Datenbank muss linear in O(n) Schritten durchsucht werden. Nehmen wir an ein
Datensatz hat 1.000 Byte, bei 50.000.000 Fahrzeugen in Deutschland hat die Datenbank also ca. 50GB an Größe. Auf einer
handelsüblichen Festplatte dauert die Suche (bei maximal 50MB/s) somit noch mindestens eine 14 Stunde. Datenbanken sind
ein wichtiges Anwendungsgebiet von dynamischen Datenstrukturen.
Prinzipiell wäre eine einfache Verkettung über Nachfolger-Referenzen ausreichend, um eine Listenstruktur zu speichern. Die zusätzliche Speicherung des Vorgängers vereinfacht aber einige Operationen, die
ansonsten erst umständlich den Vorgänger eines Eintrages in der Liste suchen müssten. Darüber hinaus
lässt sich eine doppelt verkettete Liste auch auf einfache Weise rückwärts durchlaufen.
Da der letze Eintrag der Liste mit dem ersten verknüpft ist, ist die abgebildete Liste außerdem zirkulär.
Dies vereinfacht zum Beispiel die Sonderfälle des Einfügens am Anfang oder Ende der Liste. Insbesondere treten keine Nullzeiger auf, da jeder Eintrag immer einen Vorgänger und Nachfolger besitzt. Ferner
erhält man einen einfachen Zugriff auf den letzten Eintrag der Liste, da dieser eben der Vorgänger des
Listenanfangs ist.
In Listing 3 zeigen wir einen Teil der Implementierung des diskutierten Listenprinzips in Java. In dem
Listing sind bereits alle Datenfelder enthalten und besitzen die gleichen Namen wie in der Darstellung
aus Abbildung 4. Ferner ist die Liste (wie bereits in Aufgabenblatt 3 eingeführt) mittels des Typparameters T generisch gehalten, um Nutzdaten beliebigen Typs aufnehmen zu können.
Bemerkung: Die Klasse Entry ist hier als innere Klasse ausgeführt, das heißt, sie ist innerhalb
der Klasse CircularList definiert. Innere Klassen verwendet man, um eine besonders starke Zugehörigkeit oder Lokalität zu der umgebenden Klasse auszudrücken, was hier der Fall ist: Die Listeneinträge werden ausschließlich zur Verwaltung der Nutzdaten innerhalb der Klasse CircularList
verwendet und haben für andere Klassen keine unmittelbare Bedeutung. Daher ist die Klasse Entry
zusätzlich als private markiert, sodass andere Klassen (mit Ausnahme von CircularList) nicht
auf diese zugreifen können.
Die Handhabung von statischen inneren Klassen unterscheidet sich nicht von der gewöhnlicher Klassen, mit einer Ausnahme: Sofern eine fremde Klasse eine innere Klasse verwenden soll, muss deren
Name dort um den Namen der definierenden Klasse ergänzt werden, im Fall unserer Listeneinträge also
CircularList.Entry anstatt nur Entry.
1
public class CircularList <T >
implements Iterable <T > {
2
private static class Entry <T > {
Entry <T > next ;
Entry <T > prev ;
T value ;
}
3
4
5
6
7
8
// first element
9
12
private Entry <T > head ;
public int size ();
public void append (T value );
13
// ... etc
10
11
Abbildung 4: Graphische Darstellung einer Liste,
wie Sie in Java implementiert werden kann
14
}
Listing 3: Technische Umsetzung der in Abbildung
4 dargestellten Liste
Iteratoren Ein sogenannter Iterator dient dazu, eine Datenstruktur Element für Element zu durchlaufen, zum Beispiel, um jedes Element einer Liste an der Konsole auszugeben oder auf eine bestimmte
Eigenschaft zu prüfen. Konzeptionell ist ein Iterator ein Zeiger, der Operationen besitzt, um diesen Zeiger
vom jeweils aktuellen Element auf dessen Nachfolger zu setzen beziehungsweise zu prüfen, ob überhaupt
weitere, noch nicht erreichte Elemente in der Datenstruktur existieren. Dies soll die dritte Komponente
in Abbildung 4, “Iterator” darstellen: Soll die Liste mittels des Iterators durchlaufen werden, so zeigt der
gestrichelte Pfeil nach und nach einmal auf jeden Listeneintrag. Der den Iterator benutzende Quellcode
erhält so sukzessive Zugriff auf jedes Element der Liste.
In Java werden Iteratoren üblicherweise mittels Objekten implementiert, die von dem jeweiligen Datenstruktur-Objekt (Liste etc.) erzeugt werden. Java sieht für Iteratoren eine standardisierte Schnittstelle
java.util.Iterator vor, die die elementaren Iterator-Operationen als next() und hasNext()
spezifiziert (http://docs.oracle.com/javase/7/docs/api/java/util/Iterator.html).
Für diese Schnittstelle sollen Sie weiter unten auch eine Implementierung entwickeln. Lesen Sie daher
das Kapitel 13.5 im Buch “Java ist auch eine Insel” [Ullenboom: 10. Auflage] zur Verwendung von
Iteratoren in Java.
1
class List1 {
2
Iterator iterator ();
3
// ...
4
}
5
6
List1 list1 = new List1 ();
7
8
// Iterable not implemented , enhanced for loop cannot be used to
traverse list1
9
10
for( Iterator it = list1 . iterator (); it . hasNext ();) {
Object next = it . next ();
// ...
11
12
}
13
14
15
class List2 implements Iterable {
Iterator iterator ();
// ...
16
17
}
18
19
List2 list2 = new List2 ();
20
21
// enhanced for loop simplifies list traversal by hiding the iterator
22
for( Object next : list2 ) {
// ...
23
24
}
Listing 4: Schnittstelle Iterable und erweiterte for-Schleife
Mit der Iterator-Schnittstelle verwandt ist die Schnittstelle java.lang.Iterable:
http://docs.oracle.com/javase/7/docs/api/java/lang/Iterable.html. Diese spe-
zifiziert lediglich eine parameterlose Methode iterator(), die einen Iterator zurückliefert. Es empfiehlt sich, diese Schnittstelle zu allen Klassen hinzuzufügen, die einen Iterator anbieten. Dadurch kann
man diese Klassen etwa in der erweiterten for-Schleife verwenden (vgl. Listing 4 und Kapitel 13.1.9 im
Buch “Java ist auch eine Insel” [Ullenboom: 10. Auflage] ). Aus diesem Grund haben wir die IterableSchnittstelle bereits zu unserer Implementierung in Listing 3 hinzugefügt.
Prinzip der Trennung von Algorithmen und Datenstrukturen
Wenn man ein Programm schreiben möchte, das eine Liste benutzt, hat man zwei Möglichkeiten dies zu
tun:
Eingebettet die Operationen zur Manipulation einer Liste werden direkt in den Code integriert, ein
neuer Eintrag erfordert dann speziellen Code innerhalb eines Algorithmus der den neuen Eintrag
in die Liste einfügt, also die Referenzen (next,prev, ...) entsprechend verändert.
Gekapselt die Operationen zur Manipulation einer Liste werden separat in Form von Klassen und Methoden definiert. Es gibt also Methoden wie insert, append, ... welche eine gegebene
Liste manipulieren.
Die Einbettung der Manipulationsoperatoren führt dazu, dass technischer Programmcode und Anwendungscode miteinander stark verwoben sind. Diese Verwebung ergibt, dass das resultierende Programm
nicht mehr so leicht verändert werden kann. Stellen Sie sich vor, dass die Listenimplementierung, die ein
Programm benutzt, verändert werden soll und Elemente beispielsweise auf eine andere Art in die Liste
eingebunden werden sollen. In der eingebetteten Variante muss der gesamte Programmcode durchsucht
werden und an vielen Stellen angepasst werden. In der gekapselten Variante beschränkt sich die Codeänderung auf einen kleinen Satz von Funktionen. Damit ist klar, eine Verwebung von Datenstrukturen
und Anwendungsalgorithmen hat schwerwiegende Nachteile.
In dieser Aufgabe soll die vorteilhafte Separierung von Algorithmen und Datenstrukturen am Beispiel
einer Liste geübt werden. Dazu werden wir Ihnen das Grundgerüst der bereits beschriebenen ListenKlasse in der Datei CircularList.java vorgeben. Die Liste soll in einer späteren Aufgabe dazu
benutzt werden, Routen zwischen Städten zu beschreiben.
Da in der Klasse CircularList bereits alle Methoden deklariert sind, die für die Umsetzung einer
Liste erforderlich sind, können wir Ihnen wieder einen Test vorgeben, der das korrekte Verhalten der
Liste überprüft. Sie finden den Test in der Datei CircularListTest.java.
Übung 2: Listenimplementierung
Aufgabe a) Laden Sie die vorgegebenen Dateien unter
http://www.wire.tu-bs.de/ADV/files/exercise4/list.zip herunter. 2
Aufgabe b) Implementieren Sie die in der Datei CircularList.java deklarierten Methoden. Beachten Sie dabei genau die in den Kommentaren festgelegte Arbeitsweise jeder Methode.
Hinweis: Um die Methode iterator() zu vervollständigen, müssen Sie ggf. eine weitere (innere)
Klasse definieren, die den Iterator implementiert. Testen Sie anschließend ihre Implementierung mittels
java CircularListTest. Damit Sie in den folgenden Aufgaben keine Probleme bekommen, sollte der Test ohne Fehler durchlaufen. 2
Aufgabe c) Konfigurieren Sie Ant so, dass Ihre Listen-Implementierung in einer eigenen Jar-Datei verpackt wird, die Sie bei den folgenden Aufgaben als Bibliothek einbinden. 2
Anwendung der CircularList bei der Auswertung von kürzesten Routen
In der letzten Aufgabe wurde der Floyd-Warschall Algorithmus benutzt, um die kürzeste Entfernung
zwischen zwei Verkehrsknotenpunkten zu berechnen. Hier werden wir den Algorithmus etwas erweitern
und zusätzlich den Weg selbst, also eine Route, berechnen.
In dieser Anwendung berechnet der Floyd-Warschall Algorithmus eine Matrix A ∈ Nn×n (mit n gleich
der Anzahl an Knoten im Graphen), in der für je zwei Knoten i und j ein Eintrag Ai,j = k berechnet
wird, der einen Zwischenknoten angibt, über den der kürzeste Weg zwischen i und j führt. Da Knoten in
unserem Programm über ganzzahlige Werte (int) identifiziert werden, benötigen Sie für diese Aufgabe
zusätzlich eine quadratische Integer-Matrix, die Sie völlig analog mittels int[n][n] erzeugen können.
Bemerkung: Den Floyd-Warshall-Algorithmus zur Berechnung des kürzesten Pfades und der kürzesten
Distanz zwischen source und target in graph geben wir hier im Pseudocode an:
F LOYDWARSHALL ROUTE(source, target, graph)
1
n = number of nodes in graph
2
//diagonal entries initially zero, all other entries infinity (Double.MAX VALUE)
costs = n × n matrix
//all entries initially -1
3 route = n × n matrix
4
5
6
for each edge in graph
do costs[edge.source.id][edge.target.id] = edge.weight
route[edge.source.id][edge.target.id] = edge.target.id
//floyd-warshall phase
7 for k ← 1 to n
8
do for i ← 1 to n
9
do for j ← 1 to n
10
do if (costs[i][j] > costs[i][k] + costs[k][j])
11
do costs[i][j] = costs[i][k] + costs[k][j]
12
route[i][j] = k
13 waypoints ← E VALUATE O PTIMAL ROUTE(route, source.id, target.id)
14 return ←costs[source.id][target.id]
Die im Pseudocode gegebenen Zeilen 13 und 14 sollen beide als Ergebnisse des Algorithmus interpretiert
werden.
Bemerkung: Im Folgenden geben wir den Pseudocode für die Funktion E VALUATE O PTIMAL ROUTE
an.
E VALUATE O PTIMAL ROUTE(route, source id, target id)
1
2
result = list of integers
waypoint = route[source id][target id]
3 if (source id != target id and target id 6= waypoint)
4
do subroute1 = E VALUATE O PTIMAL ROUTE(route, source id, waypoint)
5
result.appendList(subroute1)
6
subroute2 = E VALUATE O PTIMAL ROUTE(route, waypoint, target id)
7
result.appendList(subroute2)
8
else
9
result.append(waypoint)
10 return ← result
Übung 3: Der kürzeste Weg
Nun haben wir endgültig alle Werkzeuge zusammen, um den kürzesten Weg zwischen zwei Knoten in
einem Graphen zu berechnen.
Aufgabe a) Implementieren Sie den im obigen Pseudocode angegebenen Algorithmus in der Datei
Graph.java. Für die Funktion F LOYDWARSHALL ROUTE verwenden Sie bitte den bereits vorhandenen Methodenkopf
public double getShortestPath(String sourceName, String targetName,
CircularList<Node> path),
und für E VALUATE O PTIMAL ROUTE die Methode
private CircularList<Integer> evaluateOptimalRoute(int[][] route,
int sourceId, int targetId).
Verwenden Sie hier unbedingt die von Ihnen auf diesem Aufgabenblatt entwickelte Listenimplementierung CircularList.
Bemerkung: Die im Parameter path der Methode getShortestPath(...) übergebene Liste ist
zur Aufnahme der in der Pseudocode-Variable waypoints enthalteten Route vorgesehen. Beachten Sie
jedoch, dass diese Liste aus Knotenobjekten besteht, der Algorithmus intern aber nur die IDs der Knoten
verwendet. Sie müssen daher die Liste der Knoten-IDs in eine Liste von Knotenobjekten umwandeln.
Wir haben zur Unterstützung bereits die Methode getNode(int id) implementiert.
2
Aufgabe b) Bauen Sie analog zu Übung 1b) einen neuen Befehl SHORTESTPATH(Source,Target)
in den vorhandenen Sprachinterpreter ein. Die BNF zu dem neuen Befehl lautet:
<SHORTESTPATHCALL>
::= SHORTESTPATH ( <NAME> , <NAME> )
Nutzen Sie folgenden regulären Ausdruck, wiederum mit den Gruppen 1 und 2.
shortestpath\\(([\\w]+),([\\w]+)\\)
Achten Sie darauf, dass in der Ausgabe zu dem SHORTESTPATH-Befehl der Weg zwischen den beiden
Knoten sinnvoll ausgegeben wird. Ein Beispiel für eine sinnvolle Ausgabe sehen Sie in Listing 5. Die
Ausgabe basiert wiederum auf unserem Test-Graphen, sodass Sie diese zur Überprüfung Ihrer Implementierung nutzen können.
1
hamburg ---(210,52)---> braunschweig
via :
2
hannover
3
4
hamburg ---(328,30)---> muenster
via :
5
hannover
6
7
muenster ---(494,52)---> berlin
via :
8
hannover
9
10
braunschweig
11
magdeburg
Listing 5: Beispielhafte Ausgabe des Programms
2
Herunterladen