Kapitel 8: Graphen, Flüsse und Touren — Min-Cost-Flow, Matching und VRP
📌 Kapitel auf einen Blick
Worum geht es? Um Probleme, deren natürliche Sprache der Graph ist: Was fließt wohin? Wer wird wem zugeordnet? Welche Route fährt welches Fahrzeug?
Voraussetzungen: Kapitel 5 und Kapitel 6.
Danach können Sie: Flussprobleme modellieren, Zuordnungsprobleme effizient lösen, eine Tourenplanung mit Kapazitäten und Zeitfenstern aufsetzen — und erkennen, wann eine begrenzende Dimension im Routing-Modell fehlt.
Zeitbedarf: ca. 6 Stunden.
Programme:
Min_Cost_Flow.py
Zuordnung_Ungarisch.py
VRP_Flotten_Routing.py
VRP_Kapazitaetsfalle.pyNotebook: graphen.ipynb — herunterladen und in Jupyter öffnen, in Colab hochladen oder mit dem Kurs-Image starten
8.1 In 5 Minuten gelöst
🚀 In 5 Minuten gelöst: Vier Monteure, vier Einsätze
Ein Kundendienst muss vier Monteure auf vier Einsatzorte verteilen. Die Tabelle enthält die Anfahrtszeit in Minuten. Jeder Monteur fährt genau einen Einsatz.
Nord Ost Süd West Bauer 21 45 33 60 Cakir 18 52 29 47 Diaz 40 24 55 38 Engel 35 31 26 44 import numpy as np from scipy.optimize import linear_sum_assignment kosten = np.array([[21, 45, 33, 60], # Bauer [18, 52, 29, 47], # Cakir [40, 24, 55, 38], # Diaz [35, 31, 26, 44]]) # Engel monteur = ["Bauer", "Cakir", "Diaz", "Engel"] ort = ["Nord", "Ost", "Sued", "West"] zeile, spalte = linear_sum_assignment(kosten) for z, s in zip(zeile, spalte): print(f"{monteur[z]:6} -> {ort[s]:5} ({kosten[z, s]} min)") print("Gesamtfahrzeit:", kosten[zeile, spalte].sum(), "min")Ausgabe:
Bauer -> Nord (21 min) Cakir -> Sued (29 min) Diaz -> Ost (24 min) Engel -> West (44 min) Gesamtfahrzeit: 118 min
Eine Zeile Code, und das Problem ist beweisbar optimal gelöst — linear_sum_assignment ist die Ungarische Methode, ein Spezialalgorithmus, der ohne jeden Solver auskommt.
Interessanter ist aber, was der Algorithmus nicht tut. Der kleinste Wert der ganzen Tabelle ist die 18 bei Cakir → Nord. Die naheliegende Vorgehensweise — „nimm immer das günstigste noch freie Paar“ — beginnt also genau dort:
| Vorgehen | Zuordnung | Gesamtzeit |
|---|---|---|
| Gierig („immer das billigste freie Paar“) | Cakir→Nord, Diaz→Ost, Engel→Süd, Bauer→West | 128 min |
| Ungarische Methode | Bauer→Nord, Cakir→Süd, Diaz→Ost, Engel→West | 118 min |
Die optimale Lösung schickt Bauer nach Nord, obwohl er dort drei Minuten länger braucht als Cakir. Der Grund: Cakir wird im Süden gebraucht, wo er mit 29 Minuten der mit Abstand Schnellste ist. Wer die 18 zuerst greift, verbaut sich das — und zahlt am Ende 10 Minuten mehr.
🎯 Merksatz Der beste erste Zug ist selten Teil der besten Gesamtlösung. Genau deshalb gibt es Operations Research: Optimierung heißt, Entscheidungen gemeinsam zu treffen statt nacheinander. Bei vier Monteuren kostet die gierige Regel 8 %; bei vierzig kostet sie regelmäßig ein Vielfaches.
Warum funktioniert das? Weil das Zuordnungsproblem eine besondere Struktur hat: Seine Nebenbedingungsmatrix ist total unimodular. Das bedeutet — wir kommen in Abschnitt 8.4 darauf zurück —, dass die LP-Relaxation von ganz allein ganzzahlige Lösungen liefert. Man braucht hier also weder Branch-and-Bound noch Binärvariablen. Dieselbe Eigenschaft macht auch Flussprobleme so angenehm lösbar, und damit beginnt das Kapitel.
8.2 Lernziele
Nach diesem Kapitel können Sie …
- … ein Transportproblem als Graph mit Quellen, Senken und Kapazitäten modellieren.
- … den Flusserhaltungssatz aufstellen und seine Bedeutung erklären.
- … begründen, warum Zuordnungsprobleme ohne Ganzzahligkeitsbedingung ganzzahlig lösbar sind (totale Unimodularität).
- … ein Vehicle Routing Problem mit Kapazitäten und Zeitfenstern mit OR-Tools lösen.
- … einschätzen, wann ein spezialisierter Algorithmus einem allgemeinen MILP überlegen ist.
- … begründen, warum eine gierige Zuordnung systematisch schlechter ist als eine gemeinsame Optimierung.
- … einen Tourenplan gegen die Wirklichkeit prüfen — unabhängig von den Bausteinen, aus denen das Modell gebaut wurde.
8.3 Graphen als Modellsprache
Viele reale Probleme in Logistik, Kommunikation und Finanzströmen sind keine flachen Ungleichungssysteme, sondern Graphen G = (V, E):
- V — die Knoten (vertices): Server, Depots, Kunden, Konten, Lager.
- E — die gerichteten Kanten (edges): Datenleitungen, Straßen, Überweisungswege.
Das Minimum-Cost-Flow-Problem (MCNFP)
Minimum-Cost Network Flow Problem — deutsch: kostenminimales Flussproblem. Es ist das mathematische Fundament für Transportketten, Liquiditätsrouting und Datenverteilung.
Sei x_{ij} \ge 0 der Fluss über Kante (i,j), c_{ij} die Kosten je Einheit und u_{ij} die Kapazität:
\min \sum_{(i,j)\in E} c_{ij}\,x_{ij}
\sum_{j:(i,j)\in E} x_{ij} \;-\; \sum_{k:(k,i)\in E} x_{ki} \;=\; b_i \quad \forall i \in V \qquad(\textbf{Flusserhaltung})
0 \le x_{ij} \le u_{ij} \quad \forall (i,j)\in E
📐 Formel-Lesehilfe zur Flusserhaltung * Erste Summe: alles, was aus Knoten i hinausfließt. * Zweite Summe: alles, was in Knoten i hineinfließt. * b_i — der Saldo des Knotens.
b_i Knotentyp Bedeutung b_i > 0 Quelle Hier entsteht Ware (Angebot) b_i < 0 Senke Hier verschwindet Ware (Bedarf) b_i = 0 Umladeknoten (transshipment) Was hineingeht, muss wieder hinaus Ohne Formel gesagt: Das ist die Kirchhoffsche Knotenregel aus der Elektrotechnik — nichts geht verloren, nichts entsteht aus dem Nichts. Für einen Umladeknoten heißt das wörtlich: „Was ankommt, fährt auch wieder weg.“
Wichtig: Damit das Problem lösbar ist, muss \sum_i b_i = 0 gelten — das Gesamtangebot muss dem Gesamtbedarf entsprechen.
#!/usr/bin/env python3
# Min_Cost_Flow.py
"""
Kapitel Graphen: Kostenminimaler Fluss durch ein Netzwerk.
Loest dasselbe Problem zweimal:
(1) als allgemeines LP mit scipy -> zeigt die Modellstruktur
(2) mit dem spezialisierten Netzwerk-Solver von OR-Tools -> zeigt den
Geschwindigkeitsvorteil eines Verfahrens, das die Struktur ausnutzt
Beide laufen in eigenen Prozessen nicht noetig: scipy und ortools vertragen
sich (nur ortools + highspy kollidieren, siehe Kapitel Oekosystem).
"""
import numpy as np
from scipy.optimize import linprog
# --- Netzwerk definieren ---------------------------------------------------
KNOTEN = ["Werk_A", "Werk_B", "Umschlag", "Kunde_1", "Kunde_2"]
# (von, nach, Kosten je Einheit, Kapazitaet)
KANTEN = [
("Werk_A", "Umschlag", 2.0, 15),
("Werk_A", "Kunde_1", 5.0, 10),
("Werk_B", "Umschlag", 4.0, 10),
("Werk_B", "Kunde_2", 6.0, 10),
("Umschlag", "Kunde_1", 1.0, 20),
("Umschlag", "Kunde_2", 3.0, 10),
]
# Angebot (+) bzw. Bedarf (-) je Knoten
SALDO = {"Werk_A": 20, "Werk_B": 10, "Umschlag": 0, "Kunde_1": -15, "Kunde_2": -15}
def loese_als_lp():
"""Flussproblem als allgemeines lineares Programm."""
n_kanten = len(KANTEN)
knoten_index = {k: i for i, k in enumerate(KNOTEN)}
# Zielfunktion: Summe der Transportkosten
kosten = np.array([k[2] for k in KANTEN])
# Flusserhaltung als Gleichungssystem: A_eq @ x = b_eq
A_eq = np.zeros((len(KNOTEN), n_kanten))
for e, (von, nach, _, _) in enumerate(KANTEN):
A_eq[knoten_index[von], e] = +1.0 # fliesst hinaus
A_eq[knoten_index[nach], e] = -1.0 # fliesst hinein
b_eq = np.array([SALDO[k] for k in KNOTEN], dtype=float)
schranken = [(0, k[3]) for k in KANTEN] # 0 <= x_ij <= u_ij
ergebnis = linprog(c=kosten, A_eq=A_eq, b_eq=b_eq, bounds=schranken, method="highs")
if not ergebnis.success:
raise SystemExit(f"Nicht loesbar: {ergebnis.message}")
return ergebnis.fun, ergebnis.x, ergebnis.eqlin.marginals
if __name__ == "__main__":
# Vorabpruefung: Angebot muss Bedarf entsprechen
gesamt = sum(SALDO.values())
print("=" * 78)
print(" KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ")
print("=" * 78)
print(f"Angebot gesamt: {sum(v for v in SALDO.values() if v > 0)} | "
f"Bedarf gesamt: {-sum(v for v in SALDO.values() if v < 0)} | "
f"Saldo: {gesamt}")
if gesamt != 0:
raise SystemExit("Angebot und Bedarf stimmen nicht ueberein - unloesbar!")
kosten_gesamt, fluss, knotenpreise = loese_als_lp()
print(f"\nMinimale Transportkosten: {kosten_gesamt:,.2f} EUR\n")
print(f"{'Kante':<24} {'Fluss':>7} {'Kapazitaet':>11} {'Kosten/E':>9} {'Kosten':>9}")
print("-" * 78)
for e, (von, nach, c, u) in enumerate(KANTEN):
menge = fluss[e] + 0.0 if abs(fluss[e]) > 1e-9 else 0.0 # vermeidet "-0.0"
ausgelastet = " (VOLL)" if abs(menge - u) < 1e-6 else ""
print(f"{von + ' -> ' + nach:<24} {menge:>7.1f} {u:>11} "
f"{c:>9.2f} {menge * c:>9.2f}{ausgelastet}")
# --- Flusserhaltung nachpruefen --------------------------------------
print("\n--- Pruefung der Flusserhaltung je Knoten ---")
for k in KNOTEN:
hinaus = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if v == k)
hinein = sum(fluss[e] for e, (v, n, _, _) in enumerate(KANTEN) if n == k)
netto = hinaus - hinein
art = "Quelle" if SALDO[k] > 0 else ("Senke" if SALDO[k] < 0 else "Umschlag")
print(f" {k:<10} ({art:<8}): hinaus {hinaus:5.1f} - hinein {hinein:5.1f} "
f"= {netto:+6.1f} (gefordert: {SALDO[k]:+d})")
assert abs(netto - SALDO[k]) < 1e-6, f"Flusserhaltung verletzt bei {k}!"
# --- Knotenpreise (Dualwerte) interpretieren -------------------------
print("\n--- Knotenpreise (Dualwerte der Flusserhaltung) ---")
print(" Differenz zweier Knotenpreise = Grenzkosten einer zusaetzlichen Einheit")
print(" auf dem guenstigsten Weg zwischen ihnen.")
for k, preis in zip(KNOTEN, knotenpreise):
print(f" {k:<10}: {preis:7.2f}")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
KOSTENMINIMALER FLUSS DURCH EIN TRANSPORTNETZ
==============================================================================
Angebot gesamt: 30 | Bedarf gesamt: 30 | Saldo: 0
Minimale Transportkosten: 140.00 EUR
Kante Fluss Kapazitaet Kosten/E Kosten
------------------------------------------------------------------------------
Werk_A -> Umschlag 15.0 15 2.00 30.00 (VOLL)
Werk_A -> Kunde_1 5.0 10 5.00 25.00
Werk_B -> Umschlag 0.0 10 4.00 0.00
Werk_B -> Kunde_2 10.0 10 6.00 60.00 (VOLL)
Umschlag -> Kunde_1 10.0 20 1.00 10.00
Umschlag -> Kunde_2 5.0 10 3.00 15.00
--- Pruefung der Flusserhaltung je Knoten ---
Werk_A (Quelle ): hinaus 20.0 - hinein 0.0 = +20.0 (gefordert: +20)
Werk_B (Quelle ): hinaus 10.0 - hinein 0.0 = +10.0 (gefordert: +10)
Umschlag (Umschlag): hinaus 15.0 - hinein 15.0 = +0.0 (gefordert: +0)
Kunde_1 (Senke ): hinaus 0.0 - hinein 15.0 = -15.0 (gefordert: -15)
Kunde_2 (Senke ): hinaus 0.0 - hinein 15.0 = -15.0 (gefordert: -15)
Zwei Beobachtungen:
Der Umschlagknoten hat Saldo 0 — exakt 15 Einheiten hinein, exakt 15 hinaus. Er produziert und verbraucht nichts, sondern verteilt nur um.
Werk B fährt nicht über den Umschlag, obwohl dieser Weg existiert: B \to \text{Umschlag} \to \text{Kunde 2} kostet 4 + 3 = 7 je Einheit, der direkte Weg nur 6. Werk A dagegen nutzt den Umschlag intensiv, weil 2 + 1 = 3 nach Kunde 1 deutlich günstiger ist als der direkte Weg mit 5 — und die günstige Kante A \to \text{Umschlag} ist deshalb bis zur Kapazitätsgrenze voll ausgelastet. Genau hier liegt der Wert der Optimierung: Sie gewichtet solche Alternativen für alle Kanten gleichzeitig ab, während man von Hand schon bei zehn Knoten den Überblick verliert.
8.4 Bipartites Matching: das Zuordnungsproblem
Wenn N Aufgaben auf N Ressourcen eins zu eins verteilt werden — Orders auf Broker, Schichten auf Mitarbeitende, Aufträge auf Maschinen — spricht man von bipartitem Matching.
\min \sum_{i=1}^N \sum_{j=1}^N c_{ij}\,x_{ij} \qquad\text{u. d. N.}\qquad \sum_j x_{ij} = 1\ \forall i,\qquad \sum_i x_{ij} = 1\ \forall j,\qquad x_{ij}\ge0
Der Satz von Birkhoff und von Neumann
Satz. Die Extrempunkte der Menge aller doppelt-stochastischen Matrizen (alle Zeilensummen = 1, alle Spaltensummen = 1, x_{ij} \ge 0) sind genau die Permutationsmatrizen (alle x_{ij} \in \{0,1\}).
Warum das praktisch enorm wichtig ist: Nach dem Fundamentalsatz aus Kapitel 2 liegt das LP-Optimum in einer Ecke. Die Ecken sind hier laut Satz automatisch 0/1-wertig. Also gilt:
🎯 Merksatz Beim Zuordnungsproblem müssen Sie die Ganzzahligkeit nicht fordern — ein gewöhnlicher LP-Solver liefert von selbst eine 0/1-Lösung. Sie sparen sich damit die NP-Schwere von Branch-and-Bound vollständig.
Der Grund dahinter heißt totale Unimodularität: Die Nebenbedingungsmatrix hat eine spezielle Struktur, bei der jede quadratische Teilmatrix die Determinante 0, +1 oder -1 hat. Dieselbe Eigenschaft besitzt übrigens auch die Flusserhaltungsmatrix aus Abschnitt 8.3 — deshalb sind Netzwerkflüsse ebenfalls „von selbst“ ganzzahlig.
🔤 Formel-Übersetzer: totale Unimodularität
Mathematik Alltagssprache \det(\mathbf{B}) \in \{0, +1, -1\} für jede quadratische Teilmatrix \mathbf{B} von \mathbf{A} „Die Matrix ist so gebaut, dass beim Lösen nie ein echter Bruch entstehen kann.“ \mathbf{b} ganzzahlig \Rightarrow alle Ecken von \{\mathbf{x} : \mathbf{A}\mathbf{x} = \mathbf{b},\ \mathbf{x} \ge 0\} ganzzahlig „Sind Kapazitäten und Bedarfe ganze Zahlen, sind es die Ecken automatisch auch.“ zusammen mit dem Fundamentalsatz (Kapitel 2) „Das LP-Optimum liegt in einer Ecke — und die ist hier von selbst ganzzahlig.“ Die praktische Folge in einem Satz: Bei Zuordnungs- und Flussproblemen dürfen Sie die Ganzzahligkeit weglassen und trotzdem ganzzahlige Lösungen erwarten — Sie sparen sich die NP-Schwere von Branch-and-Bound vollständig.
Und die Warnung dazu: Diese Eigenschaft ist zerbrechlich. Eine einzige zusätzliche Nebenbedingung, die nicht in das Schema passt — „höchstens drei Fahrzeuge insgesamt“, eine Fixkostenkopplung, eine Mindestabnahmemenge — zerstört die totale Unimodularität. Dann liefert die Relaxation wieder Brüche, und Sie brauchen doch ein MILP. Prüfen Sie das, bevor Sie sich auf die Struktur verlassen.
#!/usr/bin/env python3
# Zuordnung_Ungarisch.py
"""
Kapitel Graphen: Das Zuordnungsproblem, dreifach geloest.
(1) Ungarischer Algorithmus (scipy.optimize.linear_sum_assignment) - O(n^3)
(2) als LP OHNE Ganzzahligkeitsforderung -> liefert trotzdem 0/1 (Birkhoff)
(3) als MILP MIT Ganzzahligkeitsforderung -> gleiches Ergebnis, mehr Aufwand
Zeigt damit die praktische Bedeutung der totalen Unimodularitaet.
"""
import time
import numpy as np
from scipy.optimize import linear_sum_assignment, linprog
def erzeuge_kosten(n, seed=11):
rng = np.random.default_rng(seed)
return rng.integers(10, 99, size=(n, n)).astype(float)
def loese_ungarisch(kosten):
zeilen, spalten = linear_sum_assignment(kosten)
return kosten[zeilen, spalten].sum(), spalten
def baue_lp(kosten):
"""Gemeinsame LP-Struktur fuer Variante 2 und 3."""
n = len(kosten)
c = kosten.flatten() # x_ij in Zeilenreihenfolge
A_eq = np.zeros((2 * n, n * n))
for i in range(n): # jede Person genau eine Aufgabe
A_eq[i, i * n:(i + 1) * n] = 1.0
for j in range(n): # jede Aufgabe genau einer Person
A_eq[n + j, j::n] = 1.0
b_eq = np.ones(2 * n)
return c, A_eq, b_eq
def loese_lp(kosten, ganzzahlig):
n = len(kosten)
c, A_eq, b_eq = baue_lp(kosten)
ergebnis = linprog(c=c, A_eq=A_eq, b_eq=b_eq, bounds=[(0, 1)] * (n * n),
integrality=np.ones(n * n) if ganzzahlig else None,
method="highs")
x = ergebnis.x.reshape(n, n)
return ergebnis.fun, x
if __name__ == "__main__":
print("=" * 84)
print(" ZUORDNUNGSPROBLEM: DREI WEGE ZUM SELBEN ERGEBNIS")
print("=" * 84)
# --- Kleines Beispiel zum Nachvollziehen ------------------------------
kosten = np.array([[82., 83., 69., 92.],
[77., 37., 49., 92.],
[11., 69., 5., 86.],
[8., 9., 98., 23.]])
namen = ["Anna", "Ben", "Carla", "David"]
aufgaben = ["Auftrag W", "Auftrag X", "Auftrag Y", "Auftrag Z"]
print("\nKostenmatrix (wer bearbeitet was zu welchen Kosten?):")
print(f"{'':<8}" + "".join(f"{a:>12}" for a in aufgaben))
for i, name in enumerate(namen):
print(f"{name:<8}" + "".join(f"{kosten[i, j]:>12.0f}" for j in range(4)))
wert, zuordnung = loese_ungarisch(kosten)
print(f"\nOptimale Zuordnung (Gesamtkosten {wert:.0f}):")
for i, j in enumerate(zuordnung):
print(f" {namen[i]:<8} -> {aufgaben[j]:<12} ({kosten[i, j]:.0f} EUR)")
# --- Nachweis: LP ohne Ganzzahligkeit liefert trotzdem 0/1 -----------
wert_lp, x_lp = loese_lp(kosten, ganzzahlig=False)
ist_binaer = np.all((np.abs(x_lp) < 1e-9) | (np.abs(x_lp - 1) < 1e-9))
print(f"\nLP OHNE Ganzzahligkeitsforderung: Kosten {wert_lp:.0f}, "
f"Loesung ist {'0/1-wertig' if ist_binaer else 'GEBROCHEN'}")
print(" -> Satz von Birkhoff/von Neumann bestaetigt: Die Ecken sind Permutationen.")
# --- Laufzeitvergleich bei wachsender Groesse ------------------------
print("\n" + "-" * 84)
print(f"{'n':>4} | {'Ungarisch':>12} | {'LP (kontinuierlich)':>21} | "
f"{'MILP (ganzzahlig)':>19} | {'gleich?':>8}")
print("-" * 84)
for n in [10, 25, 50, 100]:
k = erzeuge_kosten(n)
t0 = time.perf_counter(); w1, _ = loese_ungarisch(k); t1 = time.perf_counter() - t0
t0 = time.perf_counter(); w2, _ = loese_lp(k, False); t2 = time.perf_counter() - t0
if n <= 50:
t0 = time.perf_counter(); w3, _ = loese_lp(k, True); t3 = time.perf_counter() - t0
t3_text, gleich = f"{t3*1000:>16.1f} ms", abs(w1 - w3) < 1e-6
else:
t3_text, gleich = f"{'uebersprungen':>19}", abs(w1 - w2) < 1e-6
print(f"{n:>4} | {t1*1000:>9.1f} ms | {t2*1000:>18.1f} ms | {t3_text} | "
f"{'ja' if gleich else 'NEIN':>8}")
print("-" * 84)
print("Fazit: Der spezialisierte Ungarische Algorithmus ist um Groessenordnungen")
print("schneller. Nutzen Sie fuer reine Zuordnungen NIE einen MILP-Solver.")
print("=" * 84)💻 Code-Durchgang: die Indexakrobatik
Die Variable x_{ij} wird zu einem flachen Vektor der Länge n^2 aufgerollt: Position von x_{ij} ist i \cdot n + j. *
A_eq[i, i*n:(i+1)*n] = 1— Zeile i: alle Aufgaben einer Person (ein zusammenhängender Block). *A_eq[n+j, j::n] = 1— Zeile n+j: alle Personen einer Aufgabe (jedes n-te Element, deshalb die Schrittweite::n).Diese Umrechnung zwischen Matrix- und Vektorindizes ist eine der häufigsten Fehlerquellen überhaupt. Prüfen Sie sie immer an einem winzigen Beispiel, bei dem Sie die Matrix von Hand hinschreiben können.
8.5 Das Vehicle Routing Problem mit Zeitfenstern
Das Traveling Salesperson Problem (TSP), deutsch Problem des Handlungsreisenden, fragt nach der kürzesten Rundreise durch N Städte. Das Capacitated Vehicle Routing Problem with Time Windows (CVRPTW) erweitert es auf eine Flotte mit Kapazitätsgrenzen und Kundenzeitfenstern [e_i, l_i].
Kurzzyklen verhindern
Ein naives Modell erlaubt Subtouren: isolierte Kreise, die das Depot nie anfahren. Die klassische Gegenmaßnahme ist die MTZ-Formulierung nach Miller, Tucker und Zemlin. Man führt Rangvariablen u_i ein (die Position des Knotens in der Tour):
u_i - u_j + C \cdot x_{ij} \le C - d_j \qquad \forall i \ne j
📐 Formel-Lesehilfe Wird Kante (i,j) benutzt (x_{ij} = 1), erzwingt die Ungleichung u_j \ge u_i + d_j — der Rang wächst also entlang jeder benutzten Kante streng an. In einem geschlossenen Kreis müsste der Rang aber wieder zum Ausgangswert zurückkehren, was unmöglich ist. Kreise ohne Depot werden dadurch mathematisch ausgeschlossen.
Wird die Kante nicht benutzt (x_{ij} = 0), reduziert sich die Ungleichung auf u_i - u_j \le C - d_j, was durch hinreichend großes C immer erfüllt ist — das Big-M-Muster aus Kapitel 6.
💡 In der Praxis: nicht selbst modellieren Die MTZ-Formulierung ist didaktisch wertvoll, aber für reale Instanzen zu schwach — die LP-Relaxation ist sehr locker, und Branch-and-Bound braucht sehr lange. Professionelle Solver verwenden stattdessen dynamisch erzeugte Subtour-Eliminationsschnitte oder, wie OR-Tools, spezialisierte Metaheuristiken. Nutzen Sie für Routing die Routing-Bibliothek, nicht ein selbstgebautes MILP.
Praxisbeispiel: Flotten-Routing
#!/usr/bin/env python3
# VRP_Flotten_Routing.py
"""
Kapitel Graphen: Capacitated Vehicle Routing Problem with Time Windows (CVRPTW)
mit der Routing-Bibliothek von Google OR-Tools.
Eigenschaften:
* Eingabedaten werden vorab auf Plausibilitaet geprueft (Kapazitaet
ausreichend? Zeitfenster erreichbar?)
* Fahrzeit und Servicezeit werden getrennt ausgewiesen
* Ausgabe als lesbarer Tourenplan mit Ankunftszeiten
* Kennzahlen: Auslastung, Leerfahrten, Wartezeit
"""
import numpy as np
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
SERVICEZEIT = 10 # Minuten je Kundenstopp
WARTEZEIT_MAX = 60 # zulaessige Wartezeit bei zu frueher Ankunft
SCHICHTLAENGE = 600 # Minuten
def erzeuge_daten(seed: int = 42):
"""Synthetische, aber reproduzierbare Instanz: 1 Depot + 16 Kunden."""
anzahl_orte = 17
rng = np.random.default_rng(seed)
koordinaten = rng.random((anzahl_orte, 2)) * 100 # 100 x 100 km Raster
distanz = np.zeros((anzahl_orte, anzahl_orte), dtype=int)
for i in range(anzahl_orte):
for j in range(anzahl_orte):
distanz[i][j] = int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
return {
"distanzmatrix": distanz.tolist(),
"zeitfenster": [
(0, SCHICHTLAENGE), # 0: Depot
(30, 120), (60, 180), (100, 240), (150, 300), # Kunden 1-4
(60, 180), (120, 240), (200, 360), (300, 450), # Kunden 5-8
(180, 300), (240, 360), (300, 480), (360, 500), # Kunden 9-12
(60, 200), (120, 300), (240, 400), (300, 550), # Kunden 13-16
],
"bedarfe": [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2],
"kapazitaeten": [10, 10, 10, 10],
"anzahl_fahrzeuge": 4,
"depot": 0,
}
def pruefe_daten(daten) -> None:
"""Vorabdiagnose - fangt die haeufigsten Ursachen fuer 'keine Loesung' ab."""
gesamtbedarf = sum(daten["bedarfe"])
gesamtkapazitaet = sum(daten["kapazitaeten"])
print(f"Gesamtbedarf {gesamtbedarf} Einheiten | "
f"Flottenkapazitaet {gesamtkapazitaet} Einheiten | "
f"Auslastung {gesamtbedarf / gesamtkapazitaet * 100:.0f} %")
if gesamtbedarf > gesamtkapazitaet:
raise SystemExit("UNLOESBAR: Der Bedarf uebersteigt die Flottenkapazitaet.")
d = daten["distanzmatrix"]
for kunde, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
if kunde == 0:
continue
direktfahrt = d[0][kunde]
if direktfahrt > spaetestens:
raise SystemExit(
f"UNLOESBAR: Kunde {kunde} ist erst nach {direktfahrt} min erreichbar, "
f"sein Zeitfenster endet aber bei {spaetestens} min.")
print("Vorabpruefung bestanden: Kapazitaet und Zeitfenster sind grundsaetzlich machbar.")
def loese_cvrptw(zeitlimit_s: int = 5):
daten = erzeuge_daten()
pruefe_daten(daten)
manager = pywrapcp.RoutingIndexManager(
len(daten["distanzmatrix"]), daten["anzahl_fahrzeuge"], daten["depot"])
routing = pywrapcp.RoutingModel(manager)
# --- Fahrzeit + Servicezeit als Kantengewicht ------------------------
def zeit_callback(von_index, nach_index):
von = manager.IndexToNode(von_index)
nach = manager.IndexToNode(nach_index)
service = SERVICEZEIT if von != daten["depot"] else 0
return daten["distanzmatrix"][von][nach] + service
zeit_index = routing.RegisterTransitCallback(zeit_callback)
routing.SetArcCostEvaluatorOfAllVehicles(zeit_index)
# --- Kapazitaetsdimension ---------------------------------------------
def bedarf_callback(von_index):
return daten["bedarfe"][manager.IndexToNode(von_index)]
bedarf_index = routing.RegisterUnaryTransitCallback(bedarf_callback)
routing.AddDimensionWithVehicleCapacity(
bedarf_index, 0, daten["kapazitaeten"], True, "Kapazitaet")
# --- Zeitdimension mit Zeitfenstern -----------------------------------
routing.AddDimension(zeit_index, WARTEZEIT_MAX, SCHICHTLAENGE, False, "Zeit")
zeit_dimension = routing.GetDimensionOrDie("Zeit")
for ort, (fruehestens, spaetestens) in enumerate(daten["zeitfenster"]):
zeit_dimension.CumulVar(manager.NodeToIndex(ort)).SetRange(fruehestens, spaetestens)
# --- Suchparameter -----------------------------------------------------
parameter = pywrapcp.DefaultRoutingSearchParameters()
parameter.first_solution_strategy = (
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
parameter.local_search_metaheuristic = (
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
parameter.time_limit.seconds = zeitlimit_s
loesung = routing.SolveWithParameters(parameter)
if not loesung:
print("Keine zulaessige Routenfuehrung gefunden.")
return
# --- Auswertung --------------------------------------------------------
print("\n" + "=" * 84)
print(" OPTIMIERTER TOURENPLAN (CVRPTW)")
print("=" * 84)
gesamtzeit = gesamtfracht = gesamtdistanz = 0
kapazitaet = daten["kapazitaeten"]
for fahrzeug in range(daten["anzahl_fahrzeuge"]):
index = routing.Start(fahrzeug)
if routing.IsEnd(loesung.Value(routing.NextVar(index))):
print(f"\nFahrzeug {fahrzeug + 1}: nicht eingesetzt")
continue
stationen, fracht, distanz = [], 0, 0
while not routing.IsEnd(index):
knoten = manager.IndexToNode(index)
ankunft = loesung.Min(zeit_dimension.CumulVar(index))
fracht += daten["bedarfe"][knoten]
bezeichnung = "Depot" if knoten == 0 else f"K{knoten}"
stationen.append(f"{bezeichnung}@{ankunft}")
naechster = loesung.Value(routing.NextVar(index))
distanz += daten["distanzmatrix"][knoten][manager.IndexToNode(naechster)]
index = naechster
endzeit = loesung.Min(zeit_dimension.CumulVar(index))
stationen.append(f"Depot@{endzeit}")
gesamtzeit += endzeit
gesamtfracht += fracht
gesamtdistanz += distanz
print(f"\nFahrzeug {fahrzeug + 1}:")
print(" " + " -> ".join(stationen))
print(f" Schichtzeit {endzeit} min | Fahrstrecke {distanz} km | "
f"Fracht {fracht}/{kapazitaet[fahrzeug]} "
f"({fracht / kapazitaet[fahrzeug] * 100:.0f} % Auslastung)")
print("\n" + "-" * 84)
print(f"Summe Schichtzeiten: {gesamtzeit} min")
print(f"Summe Fahrstrecken: {gesamtdistanz} km")
print(f"Transportierte Fracht: {gesamtfracht} von {sum(daten['bedarfe'])} Einheiten")
assert gesamtfracht == sum(daten["bedarfe"]), "Nicht alle Kunden wurden beliefert!"
print("Alle Kunden wurden innerhalb ihrer Zeitfenster beliefert.")
print("=" * 84)
if __name__ == "__main__":
loese_cvrptw()⚠️ Typische Fehler beim VRP
Min(CumulVar)mit „Ankunftszeit“ verwechseln. Der Solver liefert ein Intervall möglicher Zeiten.Min()ist die früheste,Max()die späteste zulässige Zeit — die tatsächliche Fahrt kann irgendwo dazwischen starten.- Servicezeit im Depot mitzählen. Beim Start am Depot fällt keine Servicezeit an, sonst verschiebt sich der ganze Plan.
- Zu enge Zeitfenster ohne Vorabprüfung. Ist ein Kunde in seinem Fenster physisch nicht erreichbar, meldet OR-Tools nur „keine Lösung“ — ohne zu sagen, welcher Kunde schuld ist. Die Funktion
pruefe_daten()fängt genau das ab.- Ergebnisse als exakt betrachten. Die Routing-Bibliothek nutzt Metaheuristiken. Ein längeres Zeitlimit kann eine bessere Lösung liefern; „optimal“ wird hier in der Regel nicht bewiesen. Für die Praxis genügt das fast immer — man sollte es aber wissen.
8.6 Übungsaufgaben
Lösungen: Abschnitt A.8.
Aufgabe 8.1 ⭐ — Flusserhaltung prüfen. Ein Knoten hat Zuflüsse 12 und 8 sowie Abflüsse 15 und 3. Welchen Saldo b_i hat er, und um welchen Knotentyp handelt es sich?
Aufgabe 8.2 ⭐ — Unlösbarkeit erkennen. Warum ist ein Flussproblem mit \sum_i b_i \ne 0 grundsätzlich unlösbar? Wie modelliert man den realistischen Fall „Angebot größer als Bedarf“?
Aufgabe 8.3 ⭐⭐ — Transportproblem lösen. Drei Werke (Angebot 30, 25, 45) beliefern vier Lager (Bedarf 25, 30, 20, 25). Die Transportkosten je Einheit stehen in der Matrix \begin{pmatrix}8&6&10&9\\9&12&13&7\\14&9&16&5\end{pmatrix} (a) Stimmen Angebot und Bedarf überein? (b) Lösen Sie mit linprog und geben Sie den Transportplan aus. (c) Prüfen Sie, ob die Lösung ganzzahlig ist, obwohl Sie es nicht gefordert haben. Warum?
Aufgabe 8.4 ⭐⭐ — Zuordnung mit Verboten. Erweitern Sie Zuordnung_Ungarisch.py: Carla darf Auftrag Y nicht bearbeiten (fehlende Zulassung). Wie modellieren Sie das? Wie ändert sich die Lösung?
Aufgabe 8.5 ⭐⭐ — Engpass finden. Ergänzen Sie Min_Cost_Flow.py um eine Analyse: Welche Kante würde bei einer Kapazitätserhöhung um 1 Einheit die Gesamtkosten am stärksten senken? (Tipp: Dualwerte der Kapazitätsschranken oder schlicht neu rechnen.)
Aufgabe 8.6 ⭐⭐⭐ — VRP variieren. Untersuchen Sie mit VRP_Flotten_Routing.py: (a) Wie ändert sich der Plan bei 3 statt 4 Fahrzeugen? Bei 2? (b) Ab welcher Fahrzeugzahl wird das Problem unlösbar — und warum? (c) Wie wirkt sich ein Zeitlimit von 1 s gegenüber 30 s auf die Lösungsqualität aus? (d) Verdoppeln Sie die Servicezeit. Was passiert?
Aufgabe 8.7 ⭐⭐⭐ — TSP mit MTZ selbst bauen. Modellieren Sie ein TSP mit 8 Städten als MILP mit MTZ-Bedingungen (linprog mit integrality). Vergleichen Sie Laufzeit und Ergebnis mit der Routing-Bibliothek von OR-Tools. Was beobachten Sie ab 12 Städten?
8.7 Finde den Denkfehler
🐛 Finde den Denkfehler: Die vergessene Dimension
Eine Spedition lässt ihre Tagestouren optimieren: 16 Kunden, 4 Fahrzeuge zu je 10 Paletten, Gesamtbedarf 37 Paletten. Das erste Ergebnis begeistert alle — 326 km. Nach einem Hinweis aus dem Fuhrpark wird das Modell überarbeitet; jetzt kommen 572 km heraus, 75 % mehr. Der Auftraggeber ist verärgert: „Ihre erste Version war doch viel besser.“
Der Unterschied zwischen beiden Fassungen ist ein einziger Codeblock:
def bedarf(index): return BEDARFE[manager.IndexToNode(index)] bedarf_id = routing.RegisterUnaryTransitCallback(bedarf) routing.AddDimensionWithVehicleCapacity( bedarf_id, 0, KAPAZITAETEN, True, "Ladung")Ihre Aufgabe: (a) Sehen Sie sich unten die Tourenübersicht des ersten Laufs an. Was tun die Fahrzeuge 1 bis 3, und wie viel lädt Fahrzeug 4? (b) Warum hat die Routing-Bibliothek das nicht von allein verhindert — die Kapazitäten standen doch in den Daten? (c) Warum ist ausgerechnet ein besser aussehendes Ergebnis hier das gefährliche? (d) Welche Prüfung hätte den Fehler sofort sichtbar gemacht — und warum darf sie nicht dieselben Bausteine benutzen wie das Modell?
Auflösung: Abschnitt A.8.
#!/usr/bin/env python3
# VRP_Kapazitaetsfalle.py
"""
Kapitel Graphen: Die vergessene Dimension.
Die Routing-Bibliothek von OR-Tools kennt keine "Kapazitaet" von sich aus.
Sie kennt nur DIMENSIONEN - benannte Groessen, die sich entlang einer Tour
aufsummieren und begrenzt werden koennen. Distanz ist eine, Zeit ist eine,
Ladung ist eine. Wer eine davon nicht anlegt, bekommt trotzdem eine Loesung:
eine schoene, kurze, guenstige - und unfahrbare.
Dieses Programm loest dieselbe Instanz zweimal und prueft beide Ergebnisse
gegen die tatsaechlichen Lademengen.
Instanz: 1 Depot, 16 Kunden, 4 Fahrzeuge zu je 10 Paletten.
Gesamtbedarf 37 Paletten bei 40 Paletten Flottenkapazitaet - es ist also
knapp, aber machbar.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import numpy as np
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
# Dieselbe Instanz wie VRP_Flotten_Routing.py
BEDARFE = [0, 2, 3, 1, 4, 2, 2, 3, 1, 2, 4, 3, 2, 1, 2, 3, 2]
KAPAZITAETEN = [10, 10, 10, 10]
ANZAHL_FAHRZEUGE = 4
DEPOT = 0
def distanzmatrix(seed: int = 42) -> list[list[int]]:
rng = np.random.default_rng(seed)
koordinaten = rng.random((len(BEDARFE), 2)) * 100 # 100 x 100 km
n = len(BEDARFE)
return [[int(np.linalg.norm(koordinaten[i] - koordinaten[j]))
for j in range(n)] for i in range(n)]
def plane(mit_kapazitaet: bool, zeitlimit: int = 5) -> dict:
"""Loest die Tourenplanung - wahlweise mit oder ohne Ladungsdimension."""
distanz = distanzmatrix()
manager = pywrapcp.RoutingIndexManager(len(distanz), ANZAHL_FAHRZEUGE, DEPOT)
routing = pywrapcp.RoutingModel(manager)
def entfernung(von_index, nach_index):
return distanz[manager.IndexToNode(von_index)][manager.IndexToNode(nach_index)]
kosten_id = routing.RegisterTransitCallback(entfernung)
routing.SetArcCostEvaluatorOfAllVehicles(kosten_id)
# DIE entscheidende Stelle. Ohne diesen Block existiert im Modell keine
# Ladung - die Fahrzeuge sind dann unendlich gross.
if mit_kapazitaet:
def bedarf(index):
return BEDARFE[manager.IndexToNode(index)]
bedarf_id = routing.RegisterUnaryTransitCallback(bedarf)
routing.AddDimensionWithVehicleCapacity(
bedarf_id,
0, # kein Zwischenpuffer
KAPAZITAETEN, # Obergrenze je Fahrzeug
True, # Ladung startet bei 0
"Ladung")
parameter = pywrapcp.DefaultRoutingSearchParameters()
parameter.first_solution_strategy = (
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC)
parameter.local_search_metaheuristic = (
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
parameter.time_limit.FromSeconds(zeitlimit)
loesung = routing.SolveWithParameters(parameter)
if loesung is None:
raise RuntimeError("Keine Loesung gefunden")
touren, strecken, ladungen = [], [], []
for fahrzeug in range(ANZAHL_FAHRZEUGE):
index = routing.Start(fahrzeug)
tour, strecke, ladung = [], 0, 0
while not routing.IsEnd(index):
knoten = manager.IndexToNode(index)
tour.append(knoten)
ladung += BEDARFE[knoten]
vorher = index
index = loesung.Value(routing.NextVar(index))
strecke += routing.GetArcCostForVehicle(vorher, index, fahrzeug)
tour.append(manager.IndexToNode(index))
touren.append(tour)
strecken.append(strecke)
ladungen.append(ladung)
return {"touren": touren, "strecken": strecken, "ladungen": ladungen,
"gesamtstrecke": sum(strecken)}
def pruefe(ergebnis: dict) -> list[str]:
"""Prueft den Plan gegen die Wirklichkeit - unabhaengig vom Modell.
Genau diese Trennung ist der Punkt: Die Pruefung darf nicht dieselben
Annahmen benutzen wie das Modell, sonst prueft sie nichts.
"""
beanstandungen = []
for fahrzeug, (ladung, kapazitaet) in enumerate(
zip(ergebnis["ladungen"], KAPAZITAETEN)):
if ladung > kapazitaet:
beanstandungen.append(
f"Fahrzeug {fahrzeug + 1}: {ladung} Paletten geladen, "
f"Kapazitaet {kapazitaet} ({ladung - kapazitaet} zu viel)")
beliefert = sorted(k for tour in ergebnis["touren"] for k in tour[1:-1])
erwartet = list(range(1, len(BEDARFE)))
if beliefert != erwartet:
fehlend = set(erwartet) - set(beliefert)
if fehlend:
beanstandungen.append(f"nicht beliefert: {sorted(fehlend)}")
return beanstandungen
def zeige(titel: str, ergebnis: dict) -> None:
print(f"\n{titel}")
print(f" Gesamtstrecke {ergebnis['gesamtstrecke']} km")
print(f" {'Fahrzeug':<10} {'Stopps':>7} {'Strecke':>9} {'Ladung':>8} "
f"{'Kapazitaet':>11}")
for i, (tour, strecke, ladung) in enumerate(
zip(ergebnis["touren"], ergebnis["strecken"], ergebnis["ladungen"])):
markierung = " <-- ueberladen" if ladung > KAPAZITAETEN[i] else ""
print(f" {i + 1:<10} {len(tour) - 2:>7} {strecke:>8} km {ladung:>8} "
f"{KAPAZITAETEN[i]:>11}{markierung}")
beanstandungen = pruefe(ergebnis)
if beanstandungen:
print(" PRUEFUNG: DURCHGEFALLEN")
for text in beanstandungen:
print(f" - {text}")
else:
print(" PRUEFUNG: bestanden")
if __name__ == "__main__":
print("=" * 78)
print(" DIE VERGESSENE DIMENSION")
print("=" * 78)
print(f"16 Kunden, Gesamtbedarf {sum(BEDARFE)} Paletten, "
f"{ANZAHL_FAHRZEUGE} Fahrzeuge zu je {KAPAZITAETEN[0]} "
f"= {sum(KAPAZITAETEN)} Paletten Flottenkapazitaet.")
ohne = plane(mit_kapazitaet=False)
zeige("[1] Ohne Ladungsdimension", ohne)
mit = plane(mit_kapazitaet=True)
zeige("[2] Mit AddDimensionWithVehicleCapacity", mit)
print("\n" + "=" * 78)
mehr = mit["gesamtstrecke"] - ohne["gesamtstrecke"]
print(f"Der korrekte Plan ist {mehr} km laenger "
f"({mehr / ohne['gesamtstrecke'] * 100:.1f} %).")
print()
print("Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer")
print("beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt")
print("die unfahrbare Loesung fuer die bessere Optimierung - und den")
print("korrekten Plan fuer schlechte Arbeit.")
print()
print("Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur")
print("Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,")
print("wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis")
print("plausibel aussieht.")
print("=" * 78)Erwartete Ausgabe:
==============================================================================
DIE VERGESSENE DIMENSION
==============================================================================
16 Kunden, Gesamtbedarf 37 Paletten, 4 Fahrzeuge zu je 10 = 40 Paletten Flottenkapazitaet.
[1] Ohne Ladungsdimension
Gesamtstrecke 326 km
Fahrzeug Stopps Strecke Ladung Kapazitaet
1 0 0 km 0 10
2 0 0 km 0 10
3 0 0 km 0 10
4 16 326 km 37 10 <-- ueberladen
PRUEFUNG: DURCHGEFALLEN
- Fahrzeug 4: 37 Paletten geladen, Kapazitaet 10 (27 zu viel)
[2] Mit AddDimensionWithVehicleCapacity
Gesamtstrecke 572 km
Fahrzeug Stopps Strecke Ladung Kapazitaet
1 4 124 km 10 10
2 3 142 km 9 10
3 5 198 km 10 10
4 4 108 km 8 10
PRUEFUNG: bestanden
==============================================================================
Der korrekte Plan ist 246 km laenger (75.5 %).
Und genau darin liegt die Gefahr: Lauf [1] sieht BESSER aus. Wer
beide Zahlen nebeneinander legt, ohne die Ladung zu pruefen, haelt
die unfahrbare Loesung fuer die bessere Optimierung - und den
korrekten Plan fuer schlechte Arbeit.
Die Routing-Bibliothek kennt keine 'Kapazitaet'. Sie kennt nur
Dimensionen, die man ihr anlegt. Was nicht als Dimension existiert,
wird nicht begrenzt - und faellt niemandem auf, weil das Ergebnis
plausibel aussieht.
==============================================================================
🎯 Merksatz Die Routing-Bibliothek kennt keine „Kapazität“, keine „Arbeitszeit“ und kein „Gewicht“. Sie kennt nur Dimensionen — benannte Größen, die sich entlang einer Tour aufsummieren und die man begrenzen kann. Was Sie nicht als Dimension anlegen, wird nicht begrenzt. Und der Solver sagt Ihnen das nicht: Er meldet stolz eine kürzere Strecke.
⚠️ Typische Fehler bei Routing-Modellen
- Eine Dimension vergessen. Ladung, Lenkzeit, Kühlkette, Gewicht und Volumen — jede Größe, die begrenzt ist, braucht ihre eigene Dimension. Zählen Sie sie vor dem Modellieren auf einem Blatt Papier auf.
- Die Prüfung aus denselben Bausteinen bauen wie das Modell. Wer die Ladung mit
loesung.Value(ladungs_dimension.CumulVar(...))prüft, fragt das Modell, ob es sich an sich selbst hält. Rechnen Sie stattdessen aus der ausgegebenen Tour neu nach.- Zwei Läufe nur an der Zielfunktion vergleichen. 326 gegen 572 km sagt nichts, solange nicht feststeht, dass beide Pläne überhaupt fahrbar sind.
- Unbenutzte Fahrzeuge übersehen. Drei Fahrzeuge, die im Depot stehen, während eines alles fährt, sind fast immer ein Zeichen für eine fehlende Beschränkung.
8.8 Micro-Quiz
❓ Micro-Quiz 8: Drei Fragen zum Selbstcheck
Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.
1. Sie lösen ein Zuordnungsproblem (12 Monteure, 12 Aufträge) als LP — ganz ohne Binärvariablen. Das Ergebnis ist trotzdem 0/1-wertig. Warum? (a) Zufall; bei anderen Daten kämen Brüche heraus. (b) Die Nebenbedingungsmatrix ist total unimodular, deshalb sind alle Ecken des zulässigen Bereichs ganzzahlig — und in einer Ecke liegt das LP-Optimum. (c)
linprogrundet die Lösung intern.2. Sie ergänzen dasselbe Zuordnungsmodell um die Regel „höchstens 4 Monteure dürfen Überstunden machen“. Was ändert sich? (a) Nichts — die Struktur bleibt total unimodular. (b) Die Kardinalitätsbedingung passt nicht ins Schema; die totale Unimodularität geht verloren, die Relaxation kann Brüche liefern und Sie brauchen ein MILP. (c) Das Problem wird unlösbar.
3. Ein Tourenplan nutzt nur eines von vier verfügbaren Fahrzeugen und ist trotzdem der kürzeste gefundene. Was prüfen Sie zuerst? (a) Ob das Zeitlimit zu knapp war. (b) Ob eine begrenzende Dimension (Ladung, Lenkzeit) im Modell fehlt — ein einzelnes Fahrzeug, das alles fährt, ist das typische Bild einer vergessenen Beschränkung. (c) Ob die Distanzmatrix symmetrisch ist.
8.9 Selbsttest
Antworten: Anhang A.
- Was besagt der Flusserhaltungssatz, und welchem physikalischen Gesetz entspricht er?
- Warum liefert ein LP-Solver beim Zuordnungsproblem automatisch 0/1-Lösungen?
- Was sind Subtouren, und wie verhindert die MTZ-Formulierung sie?
- Warum sollte man ein reales VRP nicht als selbstgebautes MILP lösen?
- Ein VRP meldet „keine Lösung“. Nennen Sie drei mögliche Ursachen und je eine Prüfung.
8.10 Zusammenfassung
- Graphen sind die natürliche Sprache für Transport-, Zuordnungs- und Routenprobleme.
- Flusserhaltung ist die Kirchhoff-Regel des Operations Research: Was hineingeht, kommt heraus — abzüglich des Knotensaldos.
- Totale Unimodularität macht Fluss- und Zuordnungsprobleme „von selbst“ ganzzahlig. Wer hier
integralitysetzt, verschenkt Laufzeit ohne Gegenwert. - Spezialisierte Algorithmen schlagen allgemeine Solver deutlich: Der Ungarische Algorithmus löst in O(n^3), wofür ein MILP-Solver Branch-and-Bound bräuchte.
- Für Routing gilt: Nutzen Sie die Routing-Bibliothek. Metaheuristiken liefern in Sekunden sehr gute Touren; exakte Optimalität ist bei realistischen Größen unrealistisch und praktisch entbehrlich.
- Die Routing-Bibliothek kennt nur Dimensionen. Ladung, Lenkzeit, Kühlkette, Gewicht — jede begrenzte Größe braucht ihre eigene. Was nicht als Dimension angelegt ist, wird nicht begrenzt, und der Solver meldet stolz eine kürzere Strecke.
- Totale Unimodularität ist zerbrechlich. Eine einzige zusätzliche Bedingung, die nicht ins Schema passt — Kardinalität, Fixkosten, Mindestmenge —, zerstört sie. Prüfen Sie das, bevor Sie sich auf die Struktur verlassen.
- Der beste erste Zug ist selten Teil der besten Gesamtlösung. Gierige Regeln kosten schon bei vier Zuordnungen 8 %.
Ausblick. Teil III verlässt die lineare Welt. Kapitel 11 führt quadratische Zielfunktionen und die KKT-Bedingungen ein — das mathematische Fundament der Portfoliooptimierung.