Aufgabe 3: Dijkstra-Algorithmus

Werbung
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
Herunterladen