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