Mathematisches Praktikum - SoSe 2015 Prof. Dr. Wolfgang Dahmen — Felix Gruber, Igor Voulis Aufgabe 3 Bearbeitungszeit: eine Woche (bis Montag, 11. Mai 2015) Mathematischer Hintergrund: Graphen, diskrete Optimierung, Dijkstra-Algorithmus Informatischer Hintergrund: dynamische Programmierung, greedy-Algorithmen Elemente von C++: Klassen, STL-Container, Ein- und Ausgabedateien 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 (STL) zurück. Gerichtete Graphen Sei π = {1, · · · , π } eine Menge von Knoten (“vertices”) und πΈ ⊂ π ×π eine Menge von (gerichteten) Kanten (“edges”). Das Tupel πΊ = (π, πΈ) heißt gerichteter Graph. Eine Kante π = (π£1 , π£2 ) ∈ πΈ verläuft dabei von Knoten π£1 ∈ π zu Knoten π£2 ∈ π . Gibt es zu jeder Kante π = (π£1 , π£2 ) ∈ πΈ auch die entgegengesetzte Kante π¯ = (π£2 , π£1 ) ∈ πΈ, so spricht man von einem ungerichteten Graphen. Ist ein gerichteter Graph (π, πΈ) mit πΈ ∩ {(π£, π£) : π£ ∈ π } = ∅ zusammen mit einer Abbildung π½ : πΈ → R+ (Bewertung der Kanten) gegeben, so heißt das Tripel πΊ = (π, πΈ, π½) ein Distanzgraph. Normalerweise wird π½ auf ganz π × π fortgesetzt und die Fortsetzung mit π : π × π → R+ (“costs”) bezeichnet: β§ βͺ falls (π£1 , π£2 ) ∈ πΈ, β¨π½((π£1 , π£2 )), π((π£1 , π£2 )) := 0, falls π£1 = π£2 , βͺ β© ∞, sonst. 2.0 Beispiel: Der Distanzgraph πΊ = (π, πΈ, π½) mit |π | = 4 Knoten, der Kantenmenge πΈ = {(1, 2), (1, 3), (2, 1), (2, 3), (4, 2)} und π½((π£1 , π£2 )) := π£2 , |π£1 − π£2 | (π£1 , π£2 ) ∈ πΈ kann wie nebenstehend veranschaulicht werden. 1 2 1.0 1.5 1.0 3.0 3 4 Ein Distanzgraph πΊ kann z.B. als Modell für ein Flugliniennetz dienen: Die Knoten π£ ∈ π stehen dann für verschiedene Flughäfen, eine Kante π ∈ πΈ entspricht einer Fluglinie zwischen den betreffenden Flughäfen, wobei die Bewertung π½(π) 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 preiswertesten (bzw. kürzesten) Verbindungen von einem bestimmten Flughafen aus. Dies wird auch 1 als “single-source shortest path problem” bezeichnet und soll im Folgenden in einem graphentheoretischen Rahmen definiert werden. Eine Folge π = (π£0 , π£1 , . . . , π£π ) ∈ π π+1 wird als Weg in πΊ bezeichnet, wenn jeweils (π£π−1 , π£π ) ∈ πΈ für 1 ≤ π ≤ π gilt (bzw. π£0 ∈ π im∑οΈFalle π = 0). Die Kosten eines Weges π ergeben sich aus der Summe der Kosten der Kanten, d.h. π(π) = ππ=1 π((π£π−1 , π£π )). Für zwei Knoten π£, π£ ′ ∈ π ist die Distanz π(π£, π£ ′ ) zwischen π£ und π£ ′ definiert als {οΈ ∞, falls ππΊ (π£, π£ ′ ) = ∅, ′ π(π£, π£ ) := min{π(π) : π ∈ ππΊ (π£, π£ ′ )}, sonst, wobei ππΊ (π£, π£ ′ ) die Menge der Wege von π£ nach π£ ′ in πΊ bezeichne. Das single-source shortest path problem lautet nun: Gegeben ein Knoten π£0 eines Distanzgraphen, wie lauten die Distanzen π(π£0 , π£) für alle π£ ∈ π ? Eine Antwort darauf liefert der Algorithmus von Dijkstra. Algorithmus von Dijkstra Der Algorithmus von Dijkstra [2] 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 π von Knoten, für die ein kürzester Weg und damit die Distanz bereits bekannt ist (am Anfang ist dies π = {π£0 } und π(π£0 , π£0 ) = 0). Nun wählt man unter den restlichen Knoten in π := π β π einen aus, dessen vorläufige Distanz minimal ist unter allen Knoten in π (greedy-Auswahl) und fügt ihn zu π hinzu. Anschließend werden die vorläufigen Distanzen für π aufdatiert. Dies wird solange wiederholt, bis π = π 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. Algorithmus 1 Algorithmus von Dijkstra zur Berechnung von π·[π£] = π(π£0 , π£) für alle π£ ∈ π 1: π := {π£0 }; 2: π·[π£0 ] := 0; 3: for π£ ∈ π β π do 4: π·[π£] := π((π£0 , π£)); β Initialisierung von π· 5: end for 6: while π β π ΜΈ= ∅ do 7: wähle Knoten π£1 in π β π, für den π·[π£1 ] minimal ist; 8: π := π ∪ {π£1 }; 9: for π£ ∈ π β π do 10: π·[π£] := min{π·[π£], π·[π£1 ] + π((π£1 , π£))}; β Anpassen von π· 11: end for 12: end while Folgende Tabelle gibt den Verlauf des Algorithmus für den im vorigen Beispiel skizzierten Distanzgraphen für π£0 = 4 wieder: Iteration 0 1 2 3 π {4} {2, 4} {1, 2, 4} {1, 2, 3, 4} π£1 – 2 1 3 π·[1] ∞ 2.0 2.0 2.0 π·[2] 1.0 1.0 1.0 1.0 π·[3] ∞ 4.0 3.5 3.5 π·[4] 0.0 0.0 0.0 0.0 Für die tatsächliche Implementierung des Algorithmus empfiehlt es sich, mit π statt mit π zu arbeiten, da die for-Schleifen beide über π 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. 2 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 π −1 durch1 , so reicht es, zur Darstellung von π die Zahl π abzuspeichern. Die Darstellung der Kanten πΈ und der Abbildung π½ erfordert dagegen mehr Aufwand. Eine Möglichkeit ist der Einsatz einer std::map oder besser einer std::unordered_map aus der Standard Template Library (STL) [1] (vor C++11 hieß sie std::tr1::unordered_map), die π½ und damit implizit auch πΈ darstellt. 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 eventuell 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 STL zurückzugreifen – so können Sie sich viel Arbeit ersparen. Standard Template Library (STL) Die Standard Template Library (STL) [1] 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 std::list<std::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!=obst.end(); ++it) std::cout << (*it) << std::endl; return 0; } 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 π-ten Eintrag zugegriffen werden kann (z.B. vec[i-1] für einen Vektor vec). Auch hier beginnt die Nummerierung, wie bei einem Feld in C, mit der Null. 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 πͺ(log π) (wobei π =size() die Anzahl der Einträge bezeichnet) gegenüber πͺ(π) im Falle einer unsortierten Speicherung wie z.B. bei vector. Die assoziativen Container besitzen zu diesem Zweck eine Elementfunktion find. 1 Aus programmiertechnischen Gründen ist dies geeigneter als die natürliche Nummerierung von 1 bis π . 3 Weitere Informationen über die Standardcontainer, z.B. die Auflistung und Erklärung der jeweiligen Elementfunktionen sowie Anwendungsbeispiele, können Sie im Internet (z.B. [1]) sowie in entsprechender Literatur (z.B. [3]) finden. Die meisten C++-Bücher (z.B. [4, 5]) enthalten ein Kapitel über die STL. Schnittstellen Im Rahmen dieser Aufgabe soll der Algorithmus von Dijkstra auf fünf verschiedene Distanzgraphen angewendet werden. Diese müssen zu Beginn aus den Dateien Graph1.dat bis Graph5.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 π und die Anzahl der Kanten |πΈ|, anschließend folgen für jede Kante π des Graphen der Start- und der Endknoten sowie die Kosten π½(π). Die Bedeutung der Graphen ist in der Datei Readme.txt erläutert. In der Header-Datei unit.h sind die Datentypen VertexT, EdgeT und CostT definiert. Die Konstante infty enthält eine (im Vergleich zu den Kosten) sehr 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 π£0 nacheinander alle Knoten aus π einsetzen. Die Überprüfung der Ergebnisse erfolgt wie gewohnt mit der Funktion void Ergebnis( int Bsp, VertexT v0, const vector<CostT>& D); 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 π£ den kompletten kürzesten Weg von π£0 nach π£ abzuspeichern, ist es ausreichend, sich nur den Vorgängerknoten von π£ auf diesem Weg zu merken. 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. Literatur [1] C/C++-Referenz. http://www.cppreference.com/index.html. [2] Cormen, T. H., C. E. Leiserson und R. L. Rivest: Introduction to Algorithms. The MIT Press, Cambridge, MA, 1994. [3] Kuhlins, S. und M. Schader: Die C++-Standardbibliothek. Springer Verlag, Heidelberg, 2005. [4] Prata, S.: C++ Primer Plus. SAMS Publishing, Indianapolis, IN, 4. Auflage, 2001. [5] Stroustrup, B.: The C++ Programming Language. Addison-Wesley, Boston, 4. Auflage, 2013. http: //www.stroustrup.com/4th.html. 4