Der zweite Teil des Praktikums "Angewandte Mathematik am Rechner" dient dazu, die praktische Anwendung von Konzepten aus der diskreten Mathematik und linearen Algebra besser kennenzulernen. Dazu schauen wir uns Anwendungen aus der diskreten Optimierung und Geometrie an. Auf diesem Übungsblatt beginnen wir zunächst mit einem bekannten diskreten Optimierungsproblem: das Finden von kürzesten Routen in einem Straßennetzwerk.
Aufgabe 1: Datenstruktur für Graphen.
Aufgabe 2: Einlesen und darstellen von OpenStreeMap-Karten
Aufgabe 3: Die Datenstruktur einer Prioritätswarteschlange
Aufgabe 4: Der Dijkstra Algorithmus
Aufgabe 5: Preprocessing, geometrische untere Schranke
Tipp: Wie üblich bauen die Aufgaben aufeinander auf, dennoch können die einzelnen Teile weitestgehend unabhängig von einander gelöst werden. Vergessen Sie nicht, dass Sie eine Gruppe von 3 Personen sind!
Deadline: Dieses Übungsblatt muss in den Übungsgruppen am 7. November 2024 vorgeführt werden. Wie im letzten Semester muss dazu die gesamte Gruppe zu ihrem vereinbarten Abgabetermin (20min Slot) erscheinen, die Lösung vorführen und jedes Gruppenmitglied muss die Lösung erklären können.
Hardware: Falls Sie für die Vorführung keinen eigenen Laptop mitbringen können, sprechen Sie bitte vorher (spätestens am Tag vor der Übung) mit Ihrem Übungsgruppenleiter (oder senden Sie eine E-Mail an Frank Fischer).
(20 Punkte)
Einige der grundlegendsten Darstellungsformen zur Modellierung von Beziehungen zwischen Objekten sind Graphen. Mathematisch ist ein gerichteter Graph ein Paar \(G=(V,E)\) bestehend ausführt
Graphen werden zur Beschreibung verschiedener Beziehungsstrukturen verwendet, z.B.
Auf diesem Übungsblatt wollen wir uns insbesondere mit der letzten Anwendung, den Straßennetzwerken beschäftigen.
Wollen wir mit Graphen im Computern arbeiten, so müssen wir Sie geeignet darstellen. Wie so oft gibt es mehrere Möglichkeiten, einen Graphen im Rechner zu repräsentieren. Welche Darstellungsform die geeignetste ist, hängt von der jeweiligen Anwendung ab. Wir müssen uns daher überlegen, welche (grundlegenden) Operationen wir auf dem Graphen durchführen wollen. Ziel dieser Aufgabe ist es daher, eine Datenstruktur zu implementieren, mit der ein Graph im Rechner dargestellt werden kann.
Graph
, welche einen Graphen repräsentiert. Diese soll mindestens die folgenden Operationen ermöglichen: class Graph:
...
def num_nodes(self):
...
def num_edges(self):
...
def from_node(self, e):
...
def to_node(self, e):
...
def out_edges(self, u):
...
def in_edges(self, u):
...
Beachten Sie auch, dass Sie den Graphen erst erstellen müssen (d.h. Sie benötigen vielleicht Funktionen, die neue Knoten bzw. neue Kanten zum Graphen hinzufügen). class Graph:
...
def set_node_value(self, u, value):
...
def node_value(self, u):
return ...
def set_edge_value(self, e, value):
...
def edge_value(self, e):
return ...
Wie effizient sind diese Operationen?(20 Punkte)
In dieser Aufgabe wollen wir die eben erstellten Graphen dazu nutzen, Straßennetzwerke von OpenStreeMap-Daten einzulesen. Mit anderen Worten, die Knoten \(V\) des Graphen entsprechen gewissen Wegpunkten auf den Karten, die Kanten entsprechen Straßen oder Straßenabschnitten.
Die Kartendaten können mit Hilfe des Skripts download_osm.py
runtergeladen werden. Das Skript benötigt vier Parameter in dieser Reihenfolge:
Die notwendigen Angaben können auf der Webseite https://www.openstreetmap.org
(dort den gewünschten Kartenausschnitt wählen und mittels "Export" die Koordinaten ablesen) bestimmt werden. Um einen Ausschnitt von Mainz/Wiesbaden zu erhalten, kann man etwa den folgenden Befehl ausführen:
python download_osm.py 8.1611 49.9468 8.3781 50.1082
Der Graph wird wie folgt gespeichert:
nodes.csv
enthält pro Zeile drei mit Komma getrennte Zahlen, die Knotennummer, den Längengrad und den Breitengrad (in dieser Reihenfolge), z.B.: 1,8.312332131,50.23132
2,8.312531229,50.23132
edges.csv
enthält pro Zeile die beiden durch Komma getrennte Knotennummern, die sie verbindet: 123,42
Ein Beispielgraph von Mainz/Wiesbaden kann hier runtergeladen werden: nodes.csv, edges.csv.
Tipp: Mit dem Python-Modul csv
lassen sich csv-Dateien ganz bequem einlesen.
Tipp: Es ist oft hilfreich, sich kleine Testinstanzen von Hand zu erzeugen, sich also solche Dateien für wenige Knoten und Kanten selbst zu schreiben und daran seine Implementierung zu testen.
Tipp: Die von download_osm.py
erzeugten Instanzen enthalten alle Kanten in jeweils beide Richtungen, es gibt also keine Einbahnstraßen.
(Ein schöne Übersicht ist auch hier zu finden). Ein Problem, das wir haben, wollen wir wirklich kürzeste Wege in Straßennetzen berechnen, ist, dass wir keine Längen für die Kanten (Straßen) haben. Wir kennen nur die Koordinaten der Knoten, genauer ihre Längen- und Breitengrade auf der Erdkugel. Wir müssen aus diesen Informationen also tatsächlichen Längen bestimmen. Je nachdem, wie genau wir das machen wollen bzw. müssen, bieten sich die folgenden Möglichkeiten an:
1. Für kleine Kartenausschnitte kann man vernachlässigen, dass die Erde eine Kugel ist, und sie stattdessen als euklidische Ebene \(\mathbb{R}^2\) auffassen. Jetzt müssen wir nur noch die Gradangaben in Abstände umrechnen.
2. Will man es etwas genauer machen, kann man auch die sphärische Geometrie zu Hilfe nehmen. Die wesentliche Idee dabei ist, dass die beiden Punkte \(x_1\) und \(x_2\) zusammen mit dem Nordpol ein (sphärisches) Dreieck bilden. Die Verbindungslinien zwischen zwei Punkten sind dabei "Strecken", die auf einem sog. Großkreis liegen (ein Großkreis ist der Schnitt einer Kugel mit einer Ebene, die durch den Kugelmittelpunkt geht. Alle Längenkreise sind z.B. Großkreise, aber der Äquator ist der einzige Breitenkreis, der ein Großkreis ist). Der Grund, warum man sich für Großkreise interessiert, ist, dass auf einer Kugel die kürzeste Verbindung zwischen zwei Punkten immer auf einem Großkreis liegt (liegen also zwei Städte genau auf dem Äquator, so ist der schnellste bzw. kürzeste Weg zwischen ihnen genau entlang des Äquators).
Hinweis: Achten Sie darauf, dass die Python-Funktionen math.cos
und math.sin
den Winkel in Bogenmaß benötigen. Ist \(\alpha\) also ein Winkel in Grad, so wird der Kosinus des Winkels in Python über math.cos(alpha * math.pi / 180)
berechnet.
(20 Punkte)
Der Dijkstra-Algorithmus, den wir in der nächsten Aufgabe anschauen wollen, sammelt bereits gesehene Knoten in einer Menge und wählt unter aus dieser stets denjenigen Knoten aus, dessen zugewiesener Distanz-Wert minimal ist. Genauer, sei \(G \subseteq V\) die Menge der "grauen" Knoten (das ist eine Teilmenge, die der Algorithmus verwaltet, siehe unten) und \(d\colon G \to \mathbb{R}\) eine Funktion, die jedem Knoten aus \(V\) einen Wert zuweist, dann müssen wir folgende Operationen durchführen können:
pop_min
: Wähle und entferne denjenigen Knoten \(u\) aus \(G\), für den \(d(u)\) minimal ist. Formal: \[ u \in G\colon d(u) = \min \{d(v) | v \in G\}. \]push
: Füge einen Knoten \(v \in V \setminus G\) zur Menge \(G\) mit Wert \(d(v)\).decrease_key
: Verringere den Wert \(d(v)\) eines Knoten \(v \in G\).Eine Datenstruktur, die eine Menge darstellt und diese Operationen ermöglicht, heißt Heap oder Prioritätswarteschlange (Priority-Queue) (manchmal wird auch Operation 3 weggelassen, aber wir benötigen sie). In dieser Aufgabe sollen Sie eine Klasse implementieren, die diese Datenstruktur realisiert. Da wir auf recht großen Graphen arbeiten werden ist es wichtig, dass die Implementierung einigermaßen effizient ist.
Ziel ist es also, eine Klasse der folgenden Struktur zur implementieren:
class PQueue:
# Konstruktor
def __init__(self):
...
# Füge Knoten `u` mit Wert `value` hinzu.
def push(u, value):
...
# Verringere den Wert von `u` auf `value`.
def decrease_key(u, value):
...
# Entferne und geben den Knoten zurück, dessen zugehöriger Wert minimal ist.
# Ist die Queue leer, gebe `None` zurück.
def pop_min():
...
Einige Möglichkeiten zur Implementierungen sind die folgenden:
self.items = [(u, 5), (v, 42), (w, 23)]
push
: diese Operation ist einfach, man hängt einfach das neue Element an self.items.append((u, value))
)decrease_key
: das ist schwieriger: man muss den Knoten in der Liste suchen und den Wert verändernpop_min
: man muss den Knoten mit dem kleinsten Wert in der Liste suchen und ihn aus der Liste entfernenpush
: das Element wird angehängt und die Liste nach dem zweiten Wert sortiertdecrease_key
: wie push
, nur das man zuvor das Element finden musspop_min
: das kleinste Element ist das erste der Listepush
und decrease_key
: da alle Knoten bis auf den neuen (push
) bzw. den mit dem veränderten Wert (decrease_key
) bereits sortiert sind, kann man den einen Knoten mittels einer Insertion-Sort
Operation an die richtige Stelle bringen:# u ist der einzusortierende Knoten
# value ist sein Wert
# j ist sein Index in der Liste `self.items`
# schiebe solange das vorhergehende Element
# eine Position nach hinten, solange es einen
# größeren Wert als `value` hat
while j > 0 and self.items[j-1][1] > value:
self.items[j] = self.items[j-1] # Element eine Position nach hinten schieben
j -= 1
# Der Knoten `u` gehört nun an Position `j`
self.items[j] = (u, value)
dict
die Information, an welcher Position ein Knoten u
sich befindet: self.positions[u] = i
. Das ermöglicht es, bei decrease_key
die Position des Knotens, dessen Wert man verringern möchte, direkt zu finden.self.positions
immer aktuell ist. Insbesondere muss die Information aktualisiert werden, wenn Knoten bei der "Insertion-Sort" Operation verschoben werden.pop_min
: das Löschen des ersten Elements aus einem Array ist teuer (das gesamte Array muss kopiert werden). Eine Idee, dies zu umgehen, ist, sich die Position des ersten, noch nicht entfernten Elements der Liste zu merken: das erste, noch nicht entfernte Element steht also nicht an Position 0 sondern an Position self.first
. Wird pop_min
ausgeführt, wird einfach self.first += 1
um Eins erhöht.Tipp: Diese Aufgabe ist nicht ganz einfach. Nutzen Sie daher die Möglichkeit, in der Vorlesung Fragen zu stellen!
(20 Punkte)
In dieser Aufgabe wollen wir einen der wahrscheinlich am häufigsten verwendeten kombinatorischen Algorithmen implementieren: den Dijkstra-Algorithmus zur Bestimmung von kürzesten Wegen in einem Graphen. (Den Algorithmus werden Sie in anderen Vorlesungen wahrscheinlich kennenlernen oder haben es bereits. Wir wollen den Algorithmus hier auch gar nicht analysieren, sondern nur anwenden).
Gegeben ist ein (gerichteter und ungerichteter) Graph \(G=(V,E)\) mit nicht-negativen Kantenlängen \(d \in \mathbb{R}^E\) (die Voraussetzung "nicht-negativ" ist für den Dijkstra-Algorithmus wichtig und nicht in jeder Anwendung gegeben. Für Straßennetzwerke aber ist die Annahme, dass alle Längen >= 0 sind natürlich sinnvoll). Der Dijkstra-Algorithmus dient zur Berechnung von kürzesten Wegen von einem Startknoten \(s \in V\) zu allen anderen Knoten, wobei die Länge eines Weges die Summe der Kantenlängen ist (also nicht die Anzahl der Kanten ist entscheidend, wie bei der Breitensuche, sondern ihre Längen). Er funktioniert wie folgt:
Eingabe: Graph \(G=(V,E)\), Kantenlängen \(\ell \colon E \to \mathbb{R}\), Startknoten \(s \in V\), Zielknoten \(t \in V\).
Ausgabe: Folgen von aufeinander folgenden Kanten \(e_1, e_2, \ldots, e_k\), wobei \(e_1\) in \(s\) beginnt und \(e_k\) in \(t\) endet.
Hilfsvariable:
Algorithmus:
# färbe alle Knoten weiß = (nicht schwarz oder grau)
# keine Vorgänger bisher
# kein Weg bisher
# Startknoten grau färben
# wähle grauen Knoten mit d_u minimal
# v ist weiß -> wird zum ersten mal gesehen
# queue.push(v, d_u + l(e))
# es wurde besserer Weg zu v gefunden
# queue.decrease_key(v, d_u + l(e))
# P ist eine Liste der Kanten auf dem Weg von u nach t
# P.append(p_u)
Tipp: Die Mengen \(G\) und \(S\) sowie die Distanzen \(d_u\) und die eingehenden Kanten \(p_u\) lassen sich am besten mittels eines Python Dictionarys dict
umsetzen.
(20 Punkte)
In der Praxis ist die Berechnung von kürzesten Wegen unheimlich schnell, selbst auf nicht-so-sehr-schnellen Computern wie Navigationsgeräten. Wir wollen uns in dieser Aufgabe Techniken anschauen, mit den die Berechnung sehr stark beschleunigt werden kann.
Der Dijkstra-Algorithmus wählt unter den noch nicht besuchten Knoten aber in einem Schritt erreichbaren Knoten (den "grauen") denjenigen aus, der dem Startknoten \(s\) am nächsten liegt. Was dabei nicht berücksichtigt wird ist der Abstand zum Zielknoten \(t\). Genau genommen besucht der Algorithmus alle Knoten in der gleichen Reihenfolge, egal welcher Knoten der Zielknoten ist (der Zielknoten beeinflusst nur, wann der Algorithmus stoppt). Wenn wir eine Karte betrachten, dann "sehen" wir aber, dass es keinen Sinn macht, vom Startknoten \(s\) etwa in die entgegengesetzte Richtung zu laufen. Wir wollen den Dijkstra-Algorithmus so anpassen, dass er auch "die Richtung zum Ziel" bei der Suche nutzt.
Eine sehr wirkungsvolle Idee ist, die Entfernung zum Ziel nach unten abzuschätzen. Nehmen wir an, wir dass wir für jeden Knoten \(u\) eine untere Schranke \(h_u\) für die Entfernung zum Ziel \(t\) kennen, dann können wir die Auswahlregel des nächsten Knoten \(u\) in Schritt 5.1 des Algorithmus so verändern, dass diese vermutete Distanz auch berücksichtigt wird:
# wähle grauen Knoten mit d_u + h_u minimal
Statt die Auswahlregel so zu verändern, kann man sich auch des folgenden Tricks bedienen (dass da wirklich dasselbe Ergebnis rauskommt kann man zeigen, wollen wir hier aber nicht tun): Statt der Kantenlängen \(\ell((u,v))\) verwendet man die Kantenlängen \[\ell^h((u,v)) := \ell((u,v)) + h_v - h_u.\] Wir addieren also auf jede Kante \(e=(u,v)\) zur ihrer Länge den Wert \(h_v\) des Zielknotens \(v\) hinzu und ziehen den Wert \(h_u\) des Startknotens \(u\) ab. Das macht man natürlich nicht für alle Kanten des Graphen (das würde zu lange dauern, weil wir dazu ja jede Kante des Graphen anschauen müssten, was wir gern vermeiden wollen), sondern indem man die Zeilen 5.4.1.2, 5.4.2 und 5.4.2.1 des Algorithmus anpasst, z.B. \[d_v \gets d_u + \ell(e) + h_v - h_u.\]
Eine Frage ist nun, wie kommt man zu diesen unteren Schranken \(h_u\)? Hier können wir ausnutzen, dass wir keinen beliebigen Graphen haben sondern ein Straßennetz.
Tipp: Sie müssen für jeden Landmark-Knoten die Abstände zu allen anderen Knoten bestimmen. Es ist natürlich sehr aufwendig, für jedes Knotenpaar \((u,L)\) bestehend aus einem Knoten \(u\) und einer Landmarke \(L\) einmal den Dijkstra-Algorithmus durchzuführen. Glücklicherweise müssen Sie das aber nicht: wenn man den Dijkstra-Algorithmus ohne Zielknoten mit dem Landmark-Knoten \(m\) als Startknoten startet, dann läuft der Algorithmus so lange, bis er keinen Knoten mehr findet. Am Ende steht dann in \(d_u\) für jeden Knoten \(u\) der Abstand zum Landmark-Knoten. Mit anderen Worten, der Dijkstra-Algorithmus kann also den Abstand von einem festen Startknoten \(m\) zu allen anderen Knoten in einem einzigen Durchlauf berechnen. Das bedeutet, Sie müssen zur Berechnung der Landmark-Distanzen den Dijkstra-Algorithmus nur ein Mal für jeden Landmark-Knoten durchführen.