Rheinisch-Westfälische Technische Hochschule Institut für Geometrie und Praktische Mathematik Mathematisches Praktikum (MaPra) — Wintersemester 2007/08 Prof. Dr. Wolfgang Dahmen — Dr. Karl-Heinz Brakhage Aufgabe 5 Bearbeitungszeit: Drei Wochen (Abgabe bis Donnerstag, den 10. Januar 2008) Bearbeitungshinweis: Es sollen auch diesmal wieder eigene Tests erstellt werden. Diese sollen bereits eine Woche vor Abgabefrist vorgeführt werden, um böse Überaschungen zu vermeiden. Algorithmischer Hintergrund: Graphen, diskrete Optimierung, dynamische Programmierung, greedyAlgorithmen Elemente von C++: Klassen, Templates, STL-Container, Ein- und Ausgabedateien 20 Punkte Aufgabenstellung Implementieren Sie den Algorithmus von Dijkstra, um in einem gegebenen Distanzgraphen von einem Knoten aus die (im Sinne einer Kostenfunktion) kürzesten Wege zu den übrigen Knoten zu berechnen. Greifen Sie dazu auf die Standardcontainer aus der Standard Template Library zurück. Gerichtete Graphen Sei V = {1, · · · , N } eine Menge von Knoten (“vertices”) und E ⊂ V ×V eine Menge von (gerichteten) Kanten (“edges”). Das Tupel G = (V, E) heißt gerichteter Graph. Eine Kante e = (v1 , v2 ) ∈ E verläuft dabei von Knoten v1 ∈ V nach Knoten v2 ∈ V . Gibt es zu jeder Kante e = (v1 , v2 ) ∈ E auch die entgegengesetzte Kante ē = (v2 , v1 ) ∈ E, so spricht man von einem ungerichteten Graphen. Ist ein gerichteter Graph (V, E) mit E ∩ {(v, v) : v ∈ V } = ∅ zusammen mit einer Abbildung β : E → R+ (Bewertung der Kanten) gegeben, so heißt das Tripel G = (V, E, β) ein Distanzgraph. Normalerweise wird β auf ganz V × V fortgesetzt und die Fortsetzung mit c : V × V → R+ (“costs”) bezeichnet: falls (v1 , v2 ) ∈ E, β((v1 , v2 )) c((v1 , v2 )) := 0 falls v1 = v2 , ∞ sonst. 2.0 Beispiel: Der Distanzgraph G = (V, E, β) mit |V | = 4 Knoten, der Kantenmenge E = {(1, 2), (1, 3), (2, 1), (2, 3), (4, 2)} und β((v1 , v2 )) := v2 , |v1 − v2 | (v1 , v2 ) ∈ E kann wie nebenstehend veranschaulicht werden. 1 2 1.0 1.5 1.0 3.0 3 4 G kann z.B. als Modell für ein Flugliniennetz dienen: Die Knoten v ∈ V stehen dann für verschiedene Flughäfen, eine Kante e ∈ E entspricht einer Fluglinie zwischen den betreffenden Flughäfen, wobei die 1 Bewertung β(e) beispielsweise als die durch den Flug entstehenden Kosten oder aber die Entfernung zwischen den Flughäfen interpretiert werden kann. Darüberhinaus sind viele weitere Anwendungsfelder denkbar. Eine interessante Problemstellung im Zusammenhang mit dem Fluglinienmodell ergibt sich aus der Frage nach den billigsten (bzw. kürzesten) Verbindungen von einem bestimmten Flughafen aus. Dies wird auch als “single-source shortest path problem” bezeichnet und soll im Folgenden in einem graphentheoretischen Rahmen definiert werden. Eine Folge p = (v0 , v1 , . . . , vn ) ∈ V n+1 wird als Weg in G bezeichnet, wenn jeweils (vi−1 , vi ) ∈ E für 1 ≤ i ≤ n gilt (bzw. v0 ∈ V imPFalle n = 0). Die Kosten eines Weges p ergeben sich aus der Summe der Kosten der Kanten, d.h. c(p) = ni=1 c((vi−1 , vi )). Für zwei Knoten v, v 0 ∈ V ist die Distanz d(v, v 0 ) zwischen v und v 0 definiert als ( ∞ falls PG (v, v 0 ) = ∅ 0 d(v, v ) := min{c(p) : p ∈ PG (v, v 0 )} sonst, wobei PG (v, v 0 ) die Menge der Wege von v nach v 0 in G bezeichne. Das single-source shortest path problem lautet nun: Gegeben ein Knoten v0 eines Distanzgraphen, wie lauten die Distanzen d(v0 , v) für alle v ∈ V ? Eine Antwort darauf liefert der Algorithmus von Dijkstra. Algorithmus von Dijkstra Der Algorithmus von Dijkstra beruht auf dem Prinzip des greedy-Vorgehens, d.h. wenn im Verlauf des Algorithmus zwischen Alternativen gewählt werden muss, so entscheidet man sich für die zu diesem Zeitpunkt günstigste. Da diese Entscheidung im Folgenden nicht mehr revidiert wird, muss sichergestellt sein, dass diese lokale Optimalitätsbedingung am Ende zu einem globalen Optimum führt. Dies ist für den Algorithmus von Dijkstra tatsächlich der Fall, da es keine Kanten mit negativen Kosten gibt. Zu jedem Zeitpunkt des Algorithmus hat man bereits eine Menge S von Knoten, für die ein kürzester Weg und damit die Distanz bereits bekannt ist (am Anfang ist dies S = {v0 } und d(v0 , v0 ) = 0). Nun wählt man unter den restlichen Knoten in R := V \ S einen aus, dessen vorläufige Distanz minimal ist unter allen Knoten in R (greedy-Auswahl) und fügt ihn zu S hinzu. Anschließend werden die vorläufigen Distanzen für R aufdatiert. Dies wird solange wiederholt, bis S = V ist. Diese Technik, für die Lösung des großen Problems auf die zwischengespeicherten Lösungen von kleineren Teilproblemen zurückzugreifen, wird als dynamische Programmierung bezeichnet. Algorithm Dijkstra // zur Berechnung von D[v] = d(v0 , v) für alle v ∈ V S := {v0 }; D[v0 ] := 0; for v ∈ V \ S do // Initialisierung von D D[v] := c((v0 , v)); while V \ S 6= ∅ do wähle Knoten v1 in V \ S, für den D[v1 ] minimal ist; S := S ∪ {v1 }; for v ∈ V \ S do // Anpassen von D D[v] := min{D[v], D[v1 ] + c((v1 , v))}; Folgende Tabelle gibt den Verlauf des Algorithmus für den im vorigen Beispiel skizzierten Distanzgraphen für v0 = 4 wieder: Iteration 0 1 2 3 S {4} {2, 4} {1, 2, 4} {1, 2, 3, 4} v1 – 2 1 3 D[1] ∞ 2.0 2.0 2.0 2 D[2] 1.0 1.0 1.0 1.0 D[3] ∞ 4.0 3.5 3.5 D[4] 0.0 0.0 0.0 0.0 Für die tatsächliche Implementierung des Algorithmus empfiehlt es sich, mit R statt mit S zu arbeiten, da die for-Schleifen beide über R laufen. Der Algorithmus kann leicht so ergänzt werden, dass nicht nur die Distanzen bestimmt werden, sondern auch der jeweils zugehörige kürzeste Weg mitberechnet wird. Datenstruktur Um den Distanzgraphen als Datenstruktur zu repräsentieren, müssen die Knoten, Kanten sowie β geeignet dargestellt werden. Nummeriert man die Knoten des Graphen von 0 bis N − 1 durch1 , so reicht es zur Darstellung von V die Zahl N abzuspeichern. Die Darstellung der Kanten E und der Abbildung β erfordert dagegen mehr Aufwand. Eine Möglichkeit ist der Einsatz einer map, die β und damit implizit auch E darstellt. Ein anderer Ansatz besteht darin, einen Vektor von Listen zu verwenden, wobei jede der Listen die Kanten enthält, die von dem jeweiligen Knoten ausgehen. Als Beispiel diene folgendes Schema, welches dem Distanzgraphen auf Seite 1 entspricht: 1 2 v: 2 beta: 2.0 v: 1 beta: 1.0 v: 3 beta: 1.5 v: 3 beta: 3.0 3 4 v: 2 beta: 1.0 Schreiben Sie eine Klasse Graph, die einen Distanzgraphen repräsentiert. Ein Grundgerüst haben wir Ihnen dafür bereits vorgegeben. Ergänzen Sie die Klasse evtl. um geeignete Elementfunktionen, falls Ihnen dies hilfreich erscheint. Bei der Wahl der Datenstruktur bleibt es Ihnen überlassen, ob Sie sich für eine der beiden obengenannten Möglichkeiten oder einen anderen Ansatz entscheiden. Es empfiehlt sich, dabei auf die Container der Standard Template Library (STL) zurückzugreifen – so können Sie sich viel Arbeit ersparen. Standard Template Library (STL) Die Standard Template Library (STL) ist eine Bibliothek mit bereits vorgefertigten Template-Klassen, den sogenannten Standardcontainern. Dazu zählen Listen (list), Vektoren (vector), Abbildungen (map) und Mengen (set). Ein kleines Beispiel soll die Funktionalität einer Liste verdeutlichen: #include <list> #include <string> #include <iostream> typedef list<string> StrListe; int main() { StrListe obst; obst.push_back( "Apfel"); obst.push_back( "Banane"); obst.push_back( "Kiwi"); obst.pop_front(); for (StrListe::iterator it= obst.begin(); it!=end; ++it) cout << (*it) << endl; return 0; } 1 Aus programmiertechnischen Gründen ist dies geeigneter als die natürliche Nummerierung von 1 bis N . 3 Mit Hilfe eines Iterators kann über den jeweiligen Container, in diesem Falle die Liste, iteriert werden. Der Iterator verhält sich dabei ähnlich wie ein Zeiger, d.h. er kann erhöht (it++) bzw. erniedrigt (it--) werden, der Zugriff auf den Inhalt erfolgt mit *it bzw. mit dem Operator ->. Die Elementfunktion begin() gibt den Iterator auf den ersten Eintrag, end() den Iterator auf die Stelle nach dem letzten Eintrag zurück. Der Container vector verfügt zusätzlich über einen sogenannten Random-Access-Iterator, mit dem über den Operator [] gezielt auf den i-ten Eintrag zugegriffen werden kann (z.B. vec[i] für einen Vektor vec). Im Unterschied zu list und vector sind map und set assoziative Container, die die Einträge in sortierter Reihenfolge abspeichern. Dementsprechend werden neue Einträge nicht mit push_back angehängt, sondern mittels einer Elementfunktion insert an der richtigen Stelle eingefügt. Das ist etwas zeitaufwändiger, dafür ist die Suche nach einem Eintrag von der Komplexität O(log n) (wobei n =size() die Anzahl der Einträge bezeichnet) gegenüber O(n) im Falle einer unsortierten Speicherung wie z.B. bei vector. Die assoziativen Container besitzen zu diesem Zweck eine Elementfunktion find. Weitere Informationen über die Standardcontainer, z.B. die Auflistung und Erklärung der jeweiligen Elementfunktionen sowie Anwendungsbeispiele, können Sie im Internet (z.B. www.sgi.com/tech/stl) sowie in entsprechender Literatur finden. Die meisten C++-Bücher (z.B. Stroustrup: Die C++-Programmiersprache) enthalten ein Kapitel über die STL. Ebenfalls geeignet ist das Skript von Wilhelm Hanrath zur MATAAusbildung an der RWTH. ( http://www.rz.rwth-aachen.de/ca/k/qxm komplett bzw. nur STL) Schnittstellen Im Rahmen dieser Aufgabe soll der Algorithmus von Dijkstra auf n=AnzBsp verschiedene Distanzgraphen angewendet werden. Diese müssen zu Beginn aus den Dateien Graph_1.dat bis Graph_n.dat eingelesen werden. Vergessen Sie aus diesem Grunde nicht, den Eingabeoperator operator>> für die Klasse Graph zu implementieren. Das Eingabeformat ist dabei wie folgt festgelegt: Als erstes kommen die Anzahl der Knoten N und die Anzahl der Kanten |E|, anschließend folgen für jede Kante e des Graphen der Start- und der Endknoten sowie die Kosten β(e). Die Bedeutung der Graphen ist in der Datei Readme.txt erläutert. In der Header-Datei unit5.h sind die Datentypen VertexT, EdgeT und CostT definiert. Die Konstante infty enthält eine (im Vergleich zu den Kosten) große double-Zahl und kann daher im Algorithmus von Dijkstra stellvertretend für ∞ verwendet werden. Wenden Sie den Algorithmus von Dijkstra mehrmals auf jeden Graphen an, wobei Sie für den Anfangsknoten v0 nacheinander alle Knoten aus V einsetzen. Die Überprüfung der Ergebnisse erfolgt wie gewohnt mit der Funktion void Ergebnis( int Bsp, VertexT v0, const vector<CostT>& D); Achtung: Es gehört auch diesmal zur Aufgabenstellung, dass Sie sich selbst zunächst geeignete Tests und Testbeispiele überlegen und diese auch (mit Begründung der Auswahl) dokumentieren. Wegberechnung Erweitern Sie Ihren Algorithmus, so dass zusätzlich zu den Distanzen auch die zugehörigen kürzesten Wege berechnet werden. Ein kleiner Tipp zur Effizienz: Anstatt zu jedem Knoten v den kompletten kürzesten Weg von v0 nach v abzuspeichern, ist es ausreichend, sich nur den Vorgängerknoten von v auf diesem Weg zu merken. Diese Zusatzaufgabe ist freiwillig und zum Bestehen des Testats nicht notwendig. Da sie aber nur geringen zusätzlichen Arbeitsaufwand erfordert, rechnen wir mit Ihren zahlreichen kreativen Ideen! Sie können den von Ihnen bestimmten kürzesten Weg mit der Funktion void PruefeWeg( int Bsp, const list<VertexT>& weg); auf seine Richtigkeit hin untersuchen lassen. 4