Kürzeste Wege in Graphen Algorithmische Paradigmen In diesem Abschnitt wollen wir nicht nur neue Algorithmen vorstellen, sondern auch den Blick auf Gemeinsamkeiten und prinzipielle Unterschiede zwischen verschiedenen Algorithmen richten. Bei genauerer Betrachtung findet man bestimmte Grundmuster, welche beim Entwurf von Algorithmen sehr häufig auftreten, man spricht dann von algorithmischen Paradigmen. Drei solche Grundmuster werden wir etwas näher kennenlernen: 1. Greedy–Algorithmen (gierige oder gefräßige Algorithmen) 2. Divide and Conquer (das Prinzip vom Teilen und Herrschen) 3. Dynamische Programmierung Zu den weiteren Paradigmen (auf die wir hier nicht näher eingehen werden) gehören u.a. die Randomisierung, Approximationsalgorithmen, lineare Optimierung und genetische Algorithmen. Greedy–Algorithmen sind per Definition eng mit dem mathematischen Begriff des Matroids verknüpft, aber man kann ihre Gemeinsamkeiten auch verbal beschreiben als Algorithmen für Optimierungsprobleme, bei denen eine vorhandene Lösung für ein Teilproblem immer schrittweise erweitert wird bis man eine Lösung des Gesamtproblems erreicht hat. Typische Vertreter sind Breiten- und Tiefensuche sowie der Algorithmus von Prim zur MST-Berechnung. Im Gegensatz dazu wird die Gesamtlösung bei einem Ansatz mit Divide and Conquer aus den Lösungen mehrerer Teilprobleme zusammengesetzt, in der Regel kombiniert mit rekursiven Aufrufen. Um zu einer effizienten Lösung zu kommen, sollte bei jeder Aufspaltung die Anzahl der neuen Teilprobleme konstant sein (im Standardfall zwei) und die Größe der neuen Teilprobleme um einen konstanten Faktor c < 1 abnehmen (z.B. Halbierung der Größe). Bekannte Vertreter sind Merge–Sort und die Binärsuche. Bei der dynamischen Programmierung wird die Lösungsmenge von vielen Teilproblemen einer bestimmten Größe m im nächsten erweitert auf eine Lösungsmenge von Teilprobleme der Größe m + 1. Im Gegensatz zu Greedy–Algorithmen reicht also die Lösung eines Teilproblems nicht aus zur Erweiterung, sondern man muss den Zugriff auf verschiedene Teillösungen haben. Oft kann man die Teillösungen in Form einer Tabelle erfassen und verwalten. Wir haben noch keinen typischen Vertreter kennengelernt, aber die Berechnung der Binomialkoeffizienten mit dem Pascalschen Dreieck gehört im weiteren Sinne auch zu dieser Klasse. Im Folgenden wollen wir uns mit der Bestimmung kürzester Wege in Graphen beschäftigen. Dazu sei G = (V, E) ein gerichteter Graph mit einer Gewichtsfunktion w : E −→ R, die den Kanten nichtnegative Gewichte (‘Länge’ der Kante) zuordnet. Wir definieren das Gewicht eines Weges als die Summe der Gewichte seiner Kanten. Unser erstes Ziel wird es sein, für einen Startknoten s leichteste, also im Sinne der Anwendung kürzeste, Wege zu allen anderen erreichbaren Knoten zu berechnen. Man beachte, dass sich die Weglänge dann nicht auf die ursprüngliche Definition (Anzahl der Kanten) bezieht, sonder auf die Summen der Kantengewichte. Haben alle Kanten das gleiche Gewicht, dann stimmen die beiden Begriffsbildungen überein. In einem zweiten Schritt wird es darum gehen, für alle Knotenpaae jeweils kürzeste Wege zu berechnen. 1 Der Algorithmus von Dijkstra Vorüberlegungen: • Die Eigenschaft ‘kürzester Weg’ vererbt sich auf Teilwege. Ist also p ein kürzester Weg von u nach v über die Zwischenknoten u0 und v0 , dann ist der induzierte Teilweg von u0 nach v0 auch ein kürzester. • Damit kann man kürzeste Wege von s zu allen erreichbaren Knoten so auswählen, dass sie einen Baum bilden, den man kompakt mittels der π–Zeiger verwaltet. • Breitensuche ist ein Spezialfall eines Kürzesten-Wege-Problems, alle Kanten haben Gewicht 1. Idee des Algorithmus • In Analogie zum MST–Algorithmus von Prim vergrößern wir in jedem Schritt eine Menge S von Knoten, für die die Abstände von s und kürzeste Wege bereits bestimmt sind. Der Algorithmus ist ‘greedy’. • Für jeden Knoten v in V \ S halten wir einen Schlüssel d[v] aufrecht, der sagt, wie lang der bisher bekannte kürzeste Weg von s nach v ist und wir merken uns auch, wer der Vorgänger π[v] auf einem solchen Weg ist. Die Schlüssel d[v] werden in einer Prioritäts–Warteschlange Q verwaltet. • Wir nehmen den Kopf u der Warteschlange in S auf und müssen überprüfen, ob man dessen Nachbarn v von s aus über u besser (d.h. auf kürzerem Weg als bisher bekannt) erreichen kann. Dies nennt man Relaxieren und formal ist es folgendes: Falls d[v] > d[u] + w(u, v) dann ersetze d[v] durch d[u] + w(u, v) und π[v] zeigt jetzt auf u. Adj[u] v u s S V\S Obige Abbildung zeigt, welche Knoten des Graphen nach Entfernung eines minimalen Elements u aus der Prioritätswarteschlange betrachtet werden müssen. Hier ist der formale Pseudocode. 2 Dijkstra(G,w,s) for all v ∈ V(G) d[v] = ∞ π[v] = Nil d[s] = 0 S = 0/ for all v ∈ V(G) insert v in Q while Q not empty u = Extract-Min(Q) S = S ∪ {u} for all v ∈ Adj[u] if (d[v] > d[u] + w(u,v)) π[v] = u d[v] = d[u] + w(u,v) Welche Laufzeit erreicht dieser Algorithmus? Die Antwort hängt von der konkreten Realisierung der Prioritätsschlange ab: • Verwendet man unsortierte Arrays, so kostet Extract-Min im worst case Länge des Arrays, während die Update-Operation in konstanter Zeit geht, also insgesamt O(|V |2 ). • Verwendet man binäre Heaps, so kostet Extract-Min O(log |V |) Zeit. Die Updates gehören nicht zu den Basisoperationen einer Prioritätswarteschlange, aber man kann sie analog zum Verhalden eines neuen Elements in O(log |V |) Zeit implementieren. Zu beachten ist dabei, dass in einem Feld die Positionen der Knoten in der Halde gespeichert werden müssen. Insgesamt erhalten wir eine Laufzeit von O(|E| log |V |). Korrektheitsbeweis für den Algorithmus: Bezeichne dG (s, v) die Länge eines kürzesten Weges von s nach v. Wir müssen zeigen, dass in dem Moment, wenn v in S aufgenommen wird, dG (s, v) = d[v] gilt (danach wird d[v] nicht mehr verändert!) Zunächst ist klar, dass im Algorithmus zu jedem Zeitpunkt und für jeden Knoten v gilt d[v] ≥ dG (s, v). Die entgegengesetzte Ungleichung wird indirekt bewiesen. u S s Ein kuerzester Weg von s nach u y ist der erste Knoten ausserhalb von S, x x der Vorgaenger von y auf diesem Weg y 3 Beweis mit Widerspruch: 1. Sei u der zeitlich erste (!!) Knoten, für den bei Aufnahme in S gilt d[u] > dG (s, u), also der Algorithmus einen Fehler macht. 2. Sei y erster Knoten auf einem tatsächlich kürzestem Weg von s nach u, der in V \ S liegt. Für y gilt aber d[y] = dG (s, y). Begründung: d[x] ist korrekt für den Vorgänger x von y (es gilt x ∈ S). Nach Annahme war u ja erster Fehler. Bei Aufnahme von x in S wurde Kante (x, y) relaxiert, also ist auch d[y] korrekt. Wenn y = u, sind wir hier schon fertig. 3. Ansonsten haben wir: d[u] > dG (s, u) = dG (s, y) + dG (y, u) = d[y] + dG (y, u). Aber damit ist d[u] > d[y] bei der Aufnahme von u in S, weil dG (y, u) nichtnegativ ist. 4. Das ist aber der gesuchte Widerspruch, der Algorithmus hat u genommen, hätte aber tatsächlich y mit dem kleineren Schlüssel nehmen müssen. Anmerkung: Für die meisten Anwendungen ist die Voraussetzung von nichtnegativen Kantengewichten keine wirkliche Einschränkung. Der Algorithmus arbeitet aber sogar für Graphen mit negativen Kantengewichten korrekt, wenn man die Existenz von sogenannten negativen Zyklen ausschließen kann. Damit sind gerichtete Kreise gemeint, deren Kantengewichte sich zu einem negativen Wert aufsummieren. Der A∗ -Algorithmus Wird vom Startknoten s nur der kürzeste Weg zu einem bestimmten Zielknoten w gesucht (Dijkstra berechnet die kürzesten Wege zu allen möglichen Zielknoten), dann kann der Algorithmus unter bestimmten Voraussetzungen beschleunigt werden. Das ist insbesondere der Fall, wenn der Graph neben den Kantengewichten auch eine Einbettung in die Ebene hat mit der Eigenschaft, dass für jede Kante (u, v) die Ungleichung wG (u, v) ≥ dist(u, v), wobei dist(u, v) den Euklidischen Abstand zwischen den Punkten u und v angeben soll. Diese Situation liegt z.B. bei Navigationssystemen vor: Die Länge wG (u, v) einer Straßenverbindung von u nach v kann nicht kürzer als die Luftlinie dist(u, v) sein. Das Problem bei der Anwendung des Dijkstra–Algorithmus, um z.B. die kürzeste Route von Berlin nach München zu berechen, besteht darin, dass man erst zum richtigen Ergebnis kommen kann, wenn man vorher auch die kürzesten Routen nach Hamburg, Kiel und Rostock berechnet hat, also von Städten, die näher an Berlin liegen als München, aber für den kürzesten Weg nach München bestimmt nicht relevant sind. Der A∗ –Algorithmus verfolgt die Idee, die Schlüsselwerte in der Prioritätswarteschlange so zu verändern, dass Orte, die dichter am Ziel liegen (und somit als Zwischenknoten in Frage kommen) bevorzugt werden gegen Orte, die weit vom Ziel entfernt sind. Dafür führt man eine Heuristik h : V → R+ ein mit welcher der kürzeste Weg von einem Zwischenknoten u zum Zielknoten w von unten abschätzt. Es muss also immer h(u) ≤ dG (u, w) gelten. Bei Navigationssystemen kann man h(u) = dist(u, w) setzen. An Stelle des Dijkstra-Schlüssels d[u], der die Länge des kürzesten bekannten Wegs repräsentiert, setzt man als neuen Schlüssel den Wert f [u] = d[u] + h(u). Damit bekämen in unserem Beispiel mit s = Berlin und w = München alle Orte nörlich von Berlin schon allein durch den großen Euklidischen Abstand zu München einen hohen Schlüsselwert, Orte auf dem kürzesten Weg nach München würden dagegen bevorzugt aus der Warteschlange entfernt werden. 4 Der Algorithmus von Floyd-Warshall Ziel ist es, für einen gerichteten Graphen G = (V, E) und eine Kantengewichtsfunktion w alle Abstände zwischen Knoten und realisierende kürzeste Wege zu finden. Das heißt dann All-Pair-Shortest-PathProblem. Wir setzen wieder voraus, dass es keine Kreise mit negativem Gesamtgewicht gibt. Natürlich läßt sich dieses Problem durch Anwendung des Dijkstra-Algorithmus für jeden möglichen Startknoten s lösen. Wir werden aber sehen, dass es konzeptionell viel einfacher geht und insbesondere nur elementare Datenstrukturen nötig sind. Das dahinter stehende algorithmische Prinzip ist das des Dynamischen Programmierens. Die Idee: Wir benennen die Knoten mit 1, ...., n. Ziel ist, eine n × n–Matrix D zu berechnen, deren Einträge di, j die Länge eines kürzesten gerichteten Weges von i nach j angibt. Ausgangspunkt ist die Kostenmatrix W welche für vorhandene Kanten das Kantengewicht, Nullen auf der Diagonalen und ∞ für Nicht–Kanten enthält. Zusätzlich werden wir mit π-Verweisen wie bei Dijkstra tatsächlich kürzeste Wege implizit verwalten. Sei also Π die n × n–Matrix, deren Eintrag πi, j der Vorgänger des Knotens j auf einem kürzesten Weg von i nach j ist. Wir werden zunächst starke Einschränkungen auf das Problem legen, die eingeschränkten Problem lösen und dann die Einschränkungen wieder Schritt für Schritt wegnehmen, bis wir wieder beim Ausgangsproblem sind. Wie sehen die Einschränkungen aus? Wir betrachten für die Knoten i, j nicht mehr alle Wege von i nach j und suchen darunter einen kürzesten, sondern wir schränken die Menge der möglichen Zwischenknoten (die diese Wege benutzen können, aber nicht müssen) ein. (k) Wir definieren: Sei di, j die Länge eines kürzesten Weges von i nach j, der nur Zwischenknoten aus der Knotenmenge {1, ..., k} benutzt. Nichtdefinierte Werte werden auf ∞ gesetzt. Für k = 0 erlauben wir gar keine Zwischenknoten! (k) Die πi, j sind analog definiert, NIL falls der Knoten nicht existiert. (k) (k) Zentrale Beobachtung: Die Matrizen D(k) der di, j –Werte und die Matrizen Π(k) der Knoten πi, j lassen sich bottom–up sehr einfach berechnen. • Initialisierung: Die Matrizen D(0) und Π(0) kann man direkt aus der Adjazenzmatrix A = (ai, j ) des Graphen (dies ist hier die geeignete Datenstruktur, ai, j die Länge der Kante) ablesen. i= j 0 (0) ai, j ∃i → j di, j = ∞ sonst i= j i (0) i ∃i → j πi, j = NIL sonst • D(k) gewinnt man aus D(k−1) mittels der folgenden rekursiven Beziehung: 5 (k−1) (k) di, j = min{di, j (k−1) , di,k (k−1) + dk, j } Denn wenn der Knoten k als Zwischenknoten vorkommt dann nur ein Mal!! Und analog: ( (k) πi, j = (k−1) (k−1) falls di, j (k−1) sonst πi, j πk, j (k−1) < di,k (k−1) + dk, j • Es ist offensichtlich D(n) = D und Π(n) = Π. Zwischenknoten aus der Menge {1,2,.... ,k} j k i Zwischenknoten aus Zwischenknoten aus der Menge {1,2,.... ,k−1} der Menge {1,2,.... ,k−1} j i Zwischenknoten aus der Menge {1,2,.... ,k−1}, also ohne k Dies ergibt sofort den folgenden Algorithmus für die Bestimmung von D(n) : Floyd–Warshall(W,n) D(0) = W for k = 1 to n for i = 1 to n for j = 1 to n (k) (k−1) (k−1) (k−1) di, j = min( di, j ,di,k + dk, j ) return D(n) Die Laufzeit dieses Algorithmus ist offensichtlich Θ(n3 ). Eine Variante: Die transitive Hülle eines gerichteten Graphen G = (V, E) ist der Graph G∗ = (V, E ∗ ), wobei es in G∗ eine Kante von i nach j gibt, falls in G ein gerichteter Weg von i nach j existiert. Man kann eine einfache Abwandlung des Floyd–Warshall dazu benutzen, die transitive Hülle zu bestimmen. (k) Dazu sei ti, j eine Boolesche Variable, die 1 ist, falls ein gerichteter Weg mit Zwischenknoten aus 6 {1, ..., k} von i nach j existiert und 0 sonst. Offensichtlich gilt: (k−1) (k−1) (k−1) (k) ti, j = ti, j ∨ (ti,k ∧ tk, j ) mit der Initialisierung 1 i= j (0) 1 ∃i → j ti, j = 0 sonst 7