Aufgabenblatt 5: Graphen, diskrete Optimierung

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