Inhaltsverzeichnis

Graphenalgorithmen

Teil des Talit Programmierkurses.

Was ist der schnellste Weg von der KSR an den Bahnhof Romanshorn? Von Romanshorn nach Rom? Wie kann ein Staubsaugroboter auf dem kürzesten Weg die ganze Wohnung staubsaugen?

Wie können wir die Probleme überhaupt mathematisch formulieren, so dass wir sie mit einem Algorithmus lösen können?

Graphen

Alle diese Probleme lassen sich mit mit Graphen formulieren und mit Graphenalgorithmen lösen. Graphen sind eine wichtige Datenstruktur in der Informatik. In der Talit kümmern wir uns erst einmal darum, wie wir einen Graph effizient beschreiben können; anschliessend schreiben wir Code, um verschiedene Eigenschaften von Graphen zu untersuchen - und vielleicht schneller nach Rom zu gelangen.

Graphen repräsentieren

Wie könnten wir das Weg-Problem (von der KSR an den Bahnhof) abstrakt erfassen? Wir könnten die verschiedenen Wege zum Beispiel so erfassen:

Fig. 1: Graph mit drei Pfaden zum Bahnhof

Aus was besteht ein Graph? Aus Knoten (en. nodes oder vertices) und Kanten (edges). In unserem Fall haben die Kanten Gewichte, die die Länge oder Fahrtdauer der einzelnen Strecken repräsentieren.

Wie könnten wir diesen Graphen in Python repräsentieren?

Adjazenzmatrix

Einen Graphen mit m Knoten speichern wir als m*m Matrix. Eine zweidimensionale Matrix ist nichts anderes als eine Liste von Listen gleicher Länge. In jedem Feld speichern wir True, wenn eine Verbindung zwischen den zwei Knoten besteht, sonst False.

matrix.py
graph = [
    # KSR, Sek, Bahnhofstr, Hafenstrasse, Zelgstrasse, Bahnhof
    [ False, True, False, False, False, False],  # KSR
    [ True, False, True, True, True, False],     # Sek
    [ False, True, False, False, False, True],   # Bahnhofstrasse
    [ False, True, False, False, False, True],   # Hafenstrasse
    [ False, True, False, False, False, True],   # Zelgstrasse
    [ False, False, True, True, True, False],    # Bahnhof
]

Hat der Graph Gewichte, speichern wir das Gewicht, -1 falls es keine Verbindung gibt:

matrix.py
graph = [
    # KSR, Sek, Bahnhofstr, Hafenstrasse, Zelgstrasse, Bahnhof
    [ -1, 1, -1, -1, -1, -1],  # KSR
    [ 1, -1, 3, 5, 7, -1],     # Sek
    [ -1, 3, -1, -1, -1, 7],   # Bahnhofstrasse
    [ -1, 5, -1, -1, -1, 6],   # Hafenstrasse
    [ -1, 7, -1, -1, -1, 5],   # Zelgstrasse
    [ -1, -1, 7, 6, 5, -1],    # Bahnhof
]
Aufgabe 1a

Codiere eine Funktion, die für eine Adjazenzmatrix und zwei Knotenindices zurückgibt, ob es eine direkte Verbindung gibt zwischen den Knoten:

def has_direct_connection(matrix, n1, n2):
    pass

Hinweis:

Adjazenzliste

Die Matrix wird ziemlich gross, sie wächst mit dem Quadrat der Anzahl Knoten m. Meist existieren aber lange nicht alle möglichen Kanten in einem Graphen, dann ist es viel effizienter, nur die Liste der Kanten abzuspeichern. Das geht am besten mit Dictionaries, das sind Listen, die für jeden Eintrag einen Schlüssel (Key) haben, dem ein Wert (Value) zugeordnet ist.

adjacencylist.py
graph = {
    "ksr": {"sek": 1},
    "sek": {"bahnhofstr": 3, "hafenstr": 5, "zelgstr": 7},
    "bahnhofstr": {"bahnhof": 7},
    "hafenstr": {"bahnhof": 6},
    "zelgstr": {"bahnhof": 5},
    "bahnhof": {},
}

Achtung: Die obige Adjazenzliste codiert einen gerichteten Graphen - die Kanten zwischen zwei Knoten haben eine Richtung. Besteht eine Kante von A nach B, bedeutet das nicht automatisch, dass auch eine Kante von B nach A verläuft.

Aufgabe 1b

Schreibe eine Funktion has_direction_connection(list, n1, n2) die für die Adjazenzliste funktioniert.

Hinweise:

Tiefensuche

Bevor wir den schnellsten Weg an den Bahnhof zu finden versuchen, wären wir schon mal nur froh, überhaupt einen Weg zu finden. Es könnte ja auch sein, dass wir uns in die Turnhalle begeben, von wo kein Weg zum Bahnhof führt. Oder dass wir von der Bahnhofstrasse zurück zur KSR gelangen:

Graph
Fig. 2: Detaillierter Graph von der KSR zum Bahnhof

Aufgabe 2

Hinweise:

Lösung:

Obiger Algorithmus heisst Tiefensuche (Depth First Search - DFS): Wenn wir an einen neuen Knoten gelangen, suchen wir bei jedem Nachbar-Knoten bis wir entweder eine Lösung gefunden haben oder gescheitert sind. Erst dann besuchen wir den nächsten Nachbar.

Zyklen finden

Tiefensuche können wir auch verwenden, um Zyklen zu finden. Ein Zyklus ist ein Pfad, bei dem vom letzten Knoten wieder der erste erreichbar ist.

Aufgabe 3a

Schreibe Python-Code, der herausfindet, ob ein Graph zyklenfrei ist. D.h. der Code versucht einen Zyklus zu finden und gibt False genau dann zurück, wenn er mindestens einen findet.

Aufgabe 3b

Schreibe rekursiven Python-Code, der alle Zyklen in einem Graphen findet.

Lösung:

Aufgabe 4 - Graph Walk

Wir haben die Tiefensuche nun auf verschiedene Probleme angewendet. Im Allgemeinen haben wir eine Traversierung implementiert, die einen Graphen linearisiert, das heisst, jeden Knoten in einer bestimmten Reihenfolge genau einmal besucht.

Aufgabe: Schreibe eine Funktion dfs(graph), die die Knoten eines Graphen mittels Tiefensuche ausgibt.

Lösung:

Breitensuche

Mit der Tiefensuche finden wir Pfade von A nach B in einem Graphen, aber vielleicht nehmen wir dabei grosse Umwege in Kauf, weil wir zuerst weit von A entfernte Knoten besuchen, bevor wir alle Nachbarn von A untersuchen. Wir möchten aber eine Möglichkeit haben, den Graphen der Nähe nach zu besuchen, also die Knoten in aufsteigender Distanz von A zu besuchen. Dieses Verfahren heisst Breitensuche (Breadth First Search - BFS).

Aufgabe 5

Überlege dir, wie du vorgehen könntest, um zuerst die unmittelbaren Knoten des Starts zu besuchen, und nur allmählich weitere Kanten zu traversieren. Ignoriere die Kantengewichte dabei erst mal - du kannst von einem ungewichteten Graphen ausgehen.

Hinweise:

Lösung:

Aufgabe 6: Pfadsuche mit BFS

Implementiere die Pfadsuche auch mit BFS. Wie zeichnen sich die gefundenen Pfade aus?

Hinweise:

Lösung:

Scale it up - Pfadsuche im öffentlichen Verkehr

Wir wollen unsere Algorithmen an einem etwas grösseren Beispiel testen. Der Schweizer ÖV-Fahrplan listet über 34'000 Haltepunkte auf, und wir wollen Verbindungen zwischen allen möglichen Stationen herausfinden und die Resultate vergleichen.

Der Datenbestand enthält als Adjazenz-Liste alle Haltepunkte, die mit einer Non-Stop-Verbindung zu erreichen sind. Die Nachbarknoten von Romanshorn sind also beispielsweise Amriswil, Egnach und Konstanz, aber nicht Frauenfeld. Zusätzlich enthält die Liste die reine Fahrtdauer in Minuten. Bei Transfer-Verbindungen (Romanshorn - Romanshorn, Bahnhof) ist statt der Fahrzeit die Umsteigezeit angegeben.

>>> import fahrplan
>>> sbb = fahrplan.latest
>>> sbb['Romanshorn']
{'Amriswil': 5, 'Egnach': 1, 'Konstanz': 17, 'Kreuzlingen Hafen': 14, 'Neukirch-Egnach': 2, 'Romanshorn': 3, 'Romanshorn (See)': 5, 'Romanshorn Autoquai': 6, 'Romanshorn, Bahnhof': 3, 'St. Gallen': 18, 'Uttwil': 3, 'Weinfelden': 13, 'Wittenbach': 11}

Aufgabe 7: Real-World Beispiel

import fahrplan
 
# Read schedule
sbb = fahrplan.latest
 
# Find path - depth-first.
# We may need to increase python's recursion depth...
import sys
sys.setrecursionlimit(10000)
 
dfs_path = find_path_dfs(sbb, "Romanshorn", "Lugano")
 
# Find path - breadth-first.
bfs_path = find_path_bfs(sbb, "Romanshorn", "Lugano")
Fragen

Dijkstra - Kürzeste Pfade

In einem ungewichteten Graph finden wir mit der Breitensuche den kürzesten Weg, also den Pfad mit den wenigsten Segmenten. Ist der Pfad aber gewichtet (wie der Haltestellen-Graph mit Minuten-Angaben zu den Reisezeiten), so ist der Weg mit den wenigsten Segmenten nicht unbedingt der schnellste.

Unser Ziel ist es, die Knoten in aufsteigender Distanz von start zu besuchen. Wir teilen die Knoten in drei Gruppen:

  1. die bereits besuchten Knoten
  2. die entdeckten (aber noch nicht besuchten Knoten): candidates
  3. alle anderen, noch unentdeckten Knoten

Der Algorithmus basiert auf dem folgenden Prinzip:

import queue.PriorityQueue
# Create the queue.
candidates = PriorityQueue()
# Add a tuple (distance, node) to the queue.
candidates.put((0, start))
 
# ...
 
while candidates:
    # Pop the next candidate off the queue - it is the nearest
    # of all known candidates.
    distance, node = candidates.get()
    for neighbor, edge in graph[node].items():
        new_distance = distance + edge
        old_distance = distances.get(neighbor, inf)

Der Aufbau des Pfades mithilfe der Elternknoten erfolgt gleich wie bei BFS.

Lösung

Visualisierung

Mit Folium lassen sich die gefundenen Pfade hübsch visualiseren. Der Code für untenstehende Grafik findet sich auf github.

Fig. 3: Pfad von Romanshorn nach Bern — Rot: BFS, Grün: DFS, Blau: Dijkstra