Kapitel 9: Metaheuristiken — wenn der exakte Solver aussteigt
📌 Kapitel auf einen Blick
Worum geht es? Um den Fall, für den die bisherigen Kapitel keine Antwort haben: Das Problem ist zu groß, der Solver kommt im Zeitlimit nicht zu einem brauchbaren Ergebnis — und eine Entscheidung muss trotzdem heute fallen.
Voraussetzungen: Kapitel 6 und Kapitel 7, insbesondere Abschnitt 6.8 (MIP-Gap und Schranke).
Danach können Sie: eine lokale Suche mit ihrer entscheidenden Zutat — der Kostenänderung in O(1) — selbst schreiben, die Temperatur eines Simulated Annealing ausmessen statt raten, den Umschlagpunkt zwischen exakt und heuristisch für Ihr eigenes Problem bestimmen, und mit Large Neighborhood Search beides kombinieren.
Zeitbedarf: ca. 5 Stunden.
Programme:
Simulated_Annealing.py
Metaheuristik_vs_Exakt.py
Large_Neighborhood_Search.pyNotebook: metaheuristiken.ipynb
In Google Colab öffnen
9.1 In 5 Minuten gelöst
🚀 In 5 Minuten gelöst: Sechs Lackaufträge, eine Reihenfolge
Eine Lackieranlage hat sechs Aufträge offen. Zwischen zwei Aufträgen muss gereinigt werden; wie lange das dauert, hängt von beiden ab — von Hell auf Dunkel geht schnell, umgekehrt dauert es. Die Anlage steht gerade auf Weiß.
von \ nach Weiß Elfenbein Rot Dunkelrot Schwarz Beige Weiß — 2 49 49 49 2 Elfenbein 2 — 49 49 49 2 Rot 50 50 — 2 2 50 Dunkelrot 53 53 5 — 2 50 Schwarz 77 77 29 26 — 74 Beige 5 5 52 49 49 — from itertools import permutations > lack = ["Weiss", "Elfenbein", "Rot", "Dunkelrot", "Schwarz", "Beige"] ruest = [[0, 2, 49, 49, 49, 2], # Ruestzeit in Minuten von Zeile nach Spalte [2, 0, 49, 49, 49, 2], [50, 50, 0, 2, 2, 50], [53, 53, 5, 0, 2, 50], [77, 77, 29, 26, 0, 74], [5, 5, 52, 49, 49, 0]] > dauer = lambda p: sum(ruest[p[k]][p[k + 1]] for k in range(len(p) - 1)) > offen, plan = set(range(1, 6)), [0] # Faustregel: billigster naechster while offen: naechster = min(offen, key=lambda j: ruest[plan[-1]][j]) plan.append(naechster); offen.discard(naechster) > best = min(permutations(range(1, 6)), key=lambda p: dauer((0,) + p)) print("Faustregel:", " -> ".join(lack[i] for i in plan), f"= {dauer(plan)} min") print("Optimal :", " -> ".join(lack[i] for i in (0,) + best), f"= {dauer((0,) + best)} min")Ausgabe:
Faustregel: Weiss -> Elfenbein -> Beige -> Dunkelrot -> Schwarz -> Rot = 84 min Optimal : Weiss -> Elfenbein -> Beige -> Rot -> Dunkelrot -> Schwarz = 60 min24 Minuten Unterschied, 40 % — bei sechs Aufträgen. Und die Faustregel ist nicht dumm: Sie nimmt immer den billigsten nächsten Schritt.
Und jetzt der Punkt. Sehen Sie sich an, wo die Faustregel danebengreift. Beide Pläne beginnen gleich: Weiß → Elfenbein → Beige, alles hell, alles billig. Dann muss die Anlage in die dunkle Gruppe wechseln, und das kostet in jedem Fall. Die Faustregel wählt den billigsten Übergang, den sie sieht: Beige → Dunkelrot für 49 Minuten. Der optimale Plan nimmt stattdessen Beige → Rot für 52 Minuten — drei Minuten teurer.
Diese drei Minuten mehr sparen am Ende 27. Denn wer über Dunkelrot einsteigt, lässt Rot übrig, und Rot ist von Schwarz aus nur für 29 Minuten erreichbar. Der optimale Plan betritt die dunkle Gruppe an der richtigen Stelle und arbeitet sie dann von hell nach dunkel ab: Rot → Dunkelrot → Schwarz, zweimal 2 Minuten.
🎯 Merksatz Der Fehler der Faustregel ist nicht Gier, sondern Kurzsichtigkeit. Sie bewertet einen Schritt danach, was er kostet, und nicht danach, was er übrig lässt. Jede Heuristik in diesem Kapitel ist eine Antwort auf genau diese Schwäche.
Warum funktioniert das? Bei sechs Aufträgen konnten wir alle 5! = 120 Reihenfolgen durchprobieren und wissen deshalb sicher, dass 60 Minuten das Minimum sind. Bei 20 Aufträgen sind es schon 19! \approx 1{,}2 \cdot 10^{17} — und damit ist der Weg dieses Schnellstarts versperrt. Das ganze Kapitel handelt davon, was an seine Stelle tritt.
9.2 Lernziele
Nach diesem Kapitel können Sie …
- … erklären, warum eine lokale Suche ohne Kostenänderung in O(1) praktisch wertlos ist.
- … Simulated Annealing implementieren und seine Starttemperatur an der Zuggröße kalibrieren statt am Zielfunktionswert.
- … den Beitrag der Metaheuristik gegen die einfachere Alternative abgrenzen — reines Bergsteigen — statt ihn ihr gutzuschreiben.
- … den Umschlagpunkt bestimmen, ab dem eine Metaheuristik den exakten Solver schlägt, und begründen, warum diese Zahl problemspezifisch ist.
- … erklären, warum ein exakter Lauf auch dann dazugehört, wenn Sie am Ende die heuristische Lösung einsetzen.
- … Large Neighborhood Search aufsetzen: zerstören, exakt reparieren, übernehmen.
9.3 Die Aufgabe: Rüstzeiten an der Lackieranlage
Der Schnellstart war ein Spielzeug. Die Aufgabe dieses Kapitels ist dieselbe, nur in Betriebsgröße: n Aufträge in eine Reihenfolge bringen, so dass die Summe der Rüstzeiten minimal wird.
\min_{\pi} \; \sum_{k=1}^{n-1} s_{\pi(k),\,\pi(k+1)}
🔤 Formel-Übersetzer
Mathematik Alltagssprache \pi „Der Plan: welcher Auftrag an welcher Position läuft.“ \pi(k) „Der Auftrag, der als k-ter drankommt.“ s_{i,j} „Wie lange die Anlage stillsteht, wenn nach Auftrag i der Auftrag j kommt.“ s_{i,j} \neq s_{j,i} „Von Schwarz auf Weiß ist etwas anderes als von Weiß auf Schwarz.“ \sum_{k=1}^{n-1} „Über alle Übergänge summieren — bei n Aufträgen gibt es n-1 davon, nicht n.“ \min_\pi „Unter allen n! möglichen Reihenfolgen die billigste.“ Ohne Formel gesagt: „Sortiere die Aufträge so, dass möglichst wenig geputzt werden muss.“
Wer die Aufgabe als Graph liest, erkennt sie wieder: Es ist ein asymmetrisches Rundreiseproblem (Kapitel 8) mit offenem Ende. Das ist eine gute und eine schlechte Nachricht. Gut, weil damit alles bekannt ist, was man über das Problem wissen kann. Schlecht, weil dazu gehört, dass es NP-schwer ist.
📖 Definition: Metaheuristik
Ein Verfahrensrahmen, der eine vorhandene Lösung schrittweise verändert und dabei steuert, welche Veränderungen übernommen werden. „Meta“, weil der Rahmen nichts über das Problem weiß: Er braucht nur eine Startlösung, einen Zug und eine Bewertung. Dieselbe Mechanik läuft über Tourenplanung, Personaleinsatz und Portfolioauswahl.
Der Preis dafür steht in derselben Zeile: Ein Verfahren, das nichts über das Problem weiß, kann auch nichts über die Güte seines Ergebnisses sagen. Es liefert eine Lösung, keine Schranke.
Die Instanz
Alle Programme dieses Kapitels benutzen dieselbe Aufgabe. Die Rüstzeit setzt sich aus zwei Teilen zusammen: einem unregelmäßigen Reinigungsaufwand zwischen den Produktfamilien und einem Zuschlag für den Wechsel von Dunkel nach Hell.
Eine Feinheit der Instanz ist wichtiger, als sie aussieht: Die Zahl der Produktfamilien wächst mit der Auftragszahl (eine Familie je zehn Aufträge). Bei fester Familienzahl würde das Problem mit wachsendem n nämlich leichter — der Plan bestünde irgendwann aus ein paar großen Blöcken, und jede Faustregel fände ihn. Beim ersten Entwurf dieses Kapitels ist mir genau das passiert: Die Metaheuristik verbesserte bei 60 Aufträgen um 30 %, bei 200 Aufträgen um 0 %. Nicht weil sie versagte, sondern weil es nichts mehr zu verbessern gab.
9.4 Lokale Suche: der Zug und seine Kosten
Eine lokale Suche braucht drei Dinge: eine Startlösung, einen Zug und eine Regel, welche Züge übernommen werden.
Der Zug in diesem Kapitel ist der einfachste, der für asymmetrische Rüstzeiten taugt: einen Auftrag herausnehmen und woanders einsetzen. (Das klassische 2-opt — ein Teilstück umdrehen — scheidet aus: Beim Umdrehen kehren sich alle Übergänge innerhalb des Stücks um, und weil s_{i,j} \neq s_{j,i} ist, müsste man sie alle neu berechnen.)
⚠️ Die Stelle, an der Metaheuristiken scheitern
Es ist verlockend, die Kosten eines Zugs so zu bewerten:
neue_kosten = gesamtruestzeit(neue_reihe, matrix) # falsch gedachtDas ist korrekt und trotzdem der Fehler, der ein Projekt scheitern lässt. Die volle Summe kostet O(n) je Zug. Herausnehmen und Einsetzen verändert aber nur drei Kanten — die Kostenänderung steht in höchstens sechs Matrixeinträgen und ist in O(1) zu haben.
Bei 500 Aufträgen ist das der Unterschied zwischen einigen tausend und einigen hunderttausend geprüften Zügen im selben Zeitbudget. Eine Metaheuristik lebt von der Zahl der Züge; wer sie durch eine bequeme Bewertungsfunktion um zwei Größenordnungen drückt, misst am Ende nicht das Verfahren, sondern seine eigene Implementierung.
Herausgenommen wird der Auftrag aus zwei Kanten, seine Nachbarn rücken zusammen — das ist eine neue Kante. Eingesetzt wird er zwischen zwei andere Nachbarn, deren bisherige Kante dadurch verschwindet. Drei Kanten weniger, drei Kanten mehr:
\Delta = \underbrace{s_{a,x} + s_{x,b} - s_{a,b}}_{\text{einsetzen}} - \underbrace{(s_{u,x} + s_{x,v} - s_{u,v})}_{\text{herausnehmen}}
wobei x der verschobene Auftrag ist, u,v seine alten und a,b seine neuen Nachbarn.
9.5 Simulated Annealing
Reines Bergsteigen — nur Verbesserungen annehmen — bleibt im ersten lokalen Optimum stehen. Simulated Annealing nimmt Verschlechterungen mit einer Wahrscheinlichkeit an, die von der Größe der Verschlechterung und von einer sinkenden Temperatur abhängt:
P(\text{annehmen}) = \begin{cases} 1 & \Delta \le 0\\[2pt] e^{-\Delta / T} & \Delta > 0 \end{cases}
🔤 Formel-Übersetzer
Mathematik Alltagssprache \Delta \le 0 \Rightarrow P = 1 „Was besser ist, wird immer genommen.“ e^{-\Delta/T} „Kleine Verschlechterungen gehen eher durch als große.“ T groß „Am Anfang darf die Suche herumlaufen.“ T \to 0 „Zum Schluss wird nur noch bergab gegangen.“ \Delta = T „Genau diese Verschlechterung geht in 37 % der Fälle durch (e^{-1}).“ Die letzte Zeile ist die praktisch wichtigste: T und \Delta werden in derselben Einheit gemessen. Wer die Temperatur wählt, ohne die typische Zuggröße zu kennen, wählt blind.
#!/usr/bin/env python3
# Simulated_Annealing.py
"""
Kapitel Metaheuristiken: Simulated Annealing an der Lackieranlage.
Aufgabe: n Auftraege in eine Reihenfolge bringen. Zwischen zwei Auftraegen
faellt eine Ruestzeit an - die Anlage muss gereinigt werden. Wie lange das
dauert, haengt von BEIDEN Auftraegen ab: Ein Wechsel innerhalb derselben
Produktfamilie kostet fast nichts, ein Wechsel von Dunkel nach Hell ist teuer,
und zwischen manchen Familien ist der Reinigungsaufwand schlicht hoch. Gesucht
ist die Reihenfolge mit der kleinsten Summe der Ruestzeiten.
Das Programm zeigt drei Dinge:
1. Warum die Faustregel "immer der billigste naechste Auftrag" in eine
Sackgasse laeuft - und wie weit man sie mit lokaler Suche verbessert.
2. Wie gross der Beitrag der Verschlechterungen WIRKLICH ist. Die Antwort
faellt bescheidener aus als das Lehrbuch verspricht, und genau das ist
der Grund, den Vergleich immer mitzurechnen.
3. Dass die Starttemperatur kein Beiwerk ist: zu heiss macht die Suche
nutzlos, und man sieht es an einer einzigen Kennzahl kommen.
Zum Budget: Der Abkuehlplan laeuft ueber eine feste ZUGZAHL, nicht ueber eine
Sekundenzahl. Im Betrieb hat man zwar ein Zeitbudget - fuer einen VERGLEICH ist
die Zugzahl aber das ehrlichere Mass: Sie ist auf jeder Maschine dieselbe, und
die abgedruckten Zahlen unten lassen sich damit nachrechnen. Die gemessene
Laufzeit steht trotzdem dabei.
Benoetigt: numpy
"""
from __future__ import annotations
import math
import time
import numpy as np
ZUEGE = 400_000 # Zugbudget je Lauf (statt Sekunden: reproduzierbar)
SAAT = 11 # feste Saat -> reproduzierbare Instanz
# --- 1. Die Instanz ---------------------------------------------------------
def erzeuge_ruestmatrix(n: int, saat: int = SAAT) -> np.ndarray:
"""Ruestzeiten in Minuten zwischen je zwei Auftraegen.
Die Zahl der Produktfamilien waechst mit der Auftragszahl (eine Familie je
zehn Auftraege). Sonst wuerde das Problem mit wachsendem n LEICHTER: Bei
fester Familienzahl haette der Plan irgendwann nur noch ein paar grosse
Bloecke, und jede Faustregel faende ihn.
"""
rng = np.random.default_rng(saat)
familien = max(6, n // 10)
# Reinigungsaufwand zwischen den Familien - unregelmaessig und asymmetrisch,
# so wie im Betrieb: Von Klarlack auf Rot ist etwas anderes als umgekehrt.
zwischen = rng.integers(8, 60, (familien, familien))
np.fill_diagonal(zwischen, 2)
familie = rng.integers(0, familien, n)
farbe = rng.integers(0, 10, n) # 0 = weiss ... 9 = schwarz
matrix = np.zeros((n, n), dtype=np.int64)
for i in range(n):
for j in range(n):
if i != j:
# Dunkel -> hell kostet zusaetzlich: die Anlage muss heller
# werden, das braucht mehr Spuelgaenge.
matrix[i, j] = (zwischen[familie[i], familie[j]]
+ 3 * max(0, farbe[i] - farbe[j]))
return matrix
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
"""Summe der Ruestzeiten einer Reihenfolge (offene Kette, keine Rundreise)."""
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
def faustregel(matrix: np.ndarray) -> list[int]:
"""Immer der billigste noch offene Auftrag - 'naechster Nachbar'.
So plant ein Meister von Hand, und es ist keine schlechte Regel. Ihr Fehler
ist die Kurzsichtigkeit: Sie spart am Anfang und laesst die teuren Wechsel
fuer das Ende uebrig, wo keine Wahl mehr bleibt.
"""
n = len(matrix)
offen = set(range(1, n))
reihe = [0]
while offen:
naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
reihe.append(naechster)
offen.discard(naechster)
return reihe
# --- 2. Der Zug und seine Kostenaenderung -----------------------------------
def delta_verschieben(reihe: list[int], matrix: np.ndarray,
von: int, nach: int):
"""Auftrag von Position 'von' an Position 'nach' versetzen.
Liefert (Kostenaenderung, Restliste, Einfuegeposition) oder None, wenn der
Zug nichts aendert.
DAS IST DIE WICHTIGSTE FUNKTION DES PROGRAMMS. Sie berechnet die
Kostenaenderung aus HOECHSTENS SECHS Matrixeintraegen, statt die
Reihenfolge neu durchzusummieren. Der Unterschied ist nicht kosmetisch:
Die volle Summe kostet O(n) je Zug, diese Rechnung O(1). Bei 500 Auftraegen
sind das rund 500-mal mehr geprueste Zuege im selben Zeitbudget - und eine
Metaheuristik lebt von der Zahl der Zuege.
Herausgerissen wird der Auftrag aus zwei Kanten, seine Nachbarn ruecken
zusammen (eine neue Kante). Eingefuegt wird er zwischen zwei andere
Nachbarn, deren bisherige Kante dadurch verschwindet.
"""
n = len(reihe)
if von == nach or nach == von + 1:
return None
heraus = 0
if von > 0:
heraus += matrix[reihe[von - 1], reihe[von]]
if von < n - 1:
heraus += matrix[reihe[von], reihe[von + 1]]
if 0 < von < n - 1: # die Nachbarn ruecken zusammen
heraus -= matrix[reihe[von - 1], reihe[von + 1]]
rest = reihe[:von] + reihe[von + 1:]
stelle = nach if nach < von else nach - 1
hinein = 0
if stelle > 0:
hinein += matrix[rest[stelle - 1], reihe[von]]
if stelle < len(rest):
hinein += matrix[reihe[von], rest[stelle]]
if 0 < stelle < len(rest): # die aufgetrennte Kante faellt weg
hinein -= matrix[rest[stelle - 1], rest[stelle]]
return int(hinein - heraus), rest, stelle
# --- 3. Die Suche -----------------------------------------------------------
class Verlauf:
"""Was ein Lauf ausser dem Ergebnis noch verraet."""
def __init__(self) -> None:
self.schritte = 0
self.verschlechterungen_geprueft = 0
self.verschlechterungen_genommen = 0
self.schlechtester_zustand = 0 # wie weit die Suche abgedriftet ist
self.sekunden = 0.0 # nur zur Information, nicht zum Vergleich
@property
def annahmequote(self) -> float:
if not self.verschlechterungen_geprueft:
return 0.0
return self.verschlechterungen_genommen / self.verschlechterungen_geprueft
def suche(matrix: np.ndarray, zuege: int, start_temperatur: float,
end_temperatur: float = 0.05, saat: int = 1,
bergsteigen: bool = False) -> tuple[list[int], int, Verlauf]:
"""Simulated Annealing (oder reines Bergsteigen, wenn bergsteigen=True).
Der einzige Unterschied zwischen beiden steht in der Annahmezeile: Das
Bergsteigen nimmt nur Verbesserungen, SA nimmt Verschlechterungen mit einer
Wahrscheinlichkeit an, die mit der Zeit gegen null geht.
"""
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste, beste_kosten = reihe[:], kosten
verlauf = Verlauf()
verlauf.schlechtester_zustand = kosten
start = time.perf_counter()
for zug in range(zuege):
verlauf.schritte += 1
# Geometrischer Abkuehlplan ueber das Zugbudget.
temperatur = start_temperatur * (end_temperatur / start_temperatur) \
** (zug / zuege)
von = int(rng.integers(0, n))
nach = int(rng.integers(0, n + 1))
ergebnis = delta_verschieben(reihe, matrix, von, nach)
if ergebnis is None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0:
annehmen = True
else:
verlauf.verschlechterungen_geprueft += 1
# Metropolis-Kriterium: je groesser die Verschlechterung und je
# kaelter es ist, desto unwahrscheinlicher.
annehmen = (not bergsteigen
and rng.random() < math.exp(-aenderung / temperatur))
verlauf.verschlechterungen_genommen += annehmen
if annehmen:
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
verlauf.schlechtester_zustand = max(verlauf.schlechtester_zustand,
kosten)
if kosten < beste_kosten:
beste, beste_kosten = reihe[:], kosten
verlauf.sekunden = time.perf_counter() - start
return beste, beste_kosten, verlauf
if __name__ == "__main__":
N = 200
matrix = erzeuge_ruestmatrix(N)
start_reihe = faustregel(matrix)
start_kosten = gesamtruestzeit(start_reihe, matrix)
print("=" * 78)
print(" SIMULATED ANNEALING AN DER LACKIERANLAGE")
print("=" * 78)
print(f"{N} Auftraege, {max(6, N // 10)} Produktfamilien, "
f"Zugbudget {ZUEGE:,} je Lauf.\n")
print(f"Faustregel 'billigster naechster Auftrag': "
f"{start_kosten:,} Minuten Ruestzeit")
# --- Wie gross sind die Zuege ueberhaupt? ---------------------------
# Ohne diese Zahl kann man die Temperatur nicht waehlen: Das
# Metropolis-Kriterium vergleicht die Verschlechterung MIT der Temperatur.
rng = np.random.default_rng(0)
stichprobe = []
for _ in range(5000):
e = delta_verschieben(start_reihe, matrix,
int(rng.integers(0, N)), int(rng.integers(0, N + 1)))
if e:
stichprobe.append(abs(e[0]))
print(f"Typische Zuggroesse |Delta|: Median {np.median(stichprobe):.0f}, "
f"90 %-Quantil {np.quantile(stichprobe, 0.9):.0f} Minuten")
# --- Bergsteigen gegen Annealing -------------------------------------
print("\n" + "-" * 78)
print("Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:\n")
_, berg, berg_verlauf = suche(matrix, ZUEGE, 1.0, bergsteigen=True)
_, gluehen, gluehen_verlauf = suche(matrix, ZUEGE, 1.0)
print(f" {'Faustregel (Start)':<34} {start_kosten:>7,} Minuten")
print(f" {'Bergsteigen':<34} {berg:>7,} Minuten "
f"({(start_kosten - berg) / start_kosten * 100:5.1f} % besser)")
print(f" {'Simulated Annealing':<34} {gluehen:>7,} Minuten "
f"({(start_kosten - gluehen) / start_kosten * 100:5.1f} % besser)")
print(f"\n Beide pruefen genau {berg_verlauf.schritte:,} Zuege "
f"({berg_verlauf.sekunden:.1f} bzw. {gluehen_verlauf.sekunden:.1f} Sekunden).")
print(f" Annealing nimmt davon {gluehen_verlauf.annahmequote * 100:.2f} % der "
f"VERSCHLECHTERUNGEN an, Bergsteigen keine.")
print("\n Bemerkenswert ist, wie klein der Abstand ist: Das simple")
print(f" Bergsteigen holt hier bereits {(start_kosten - berg) / (start_kosten - gluehen) * 100:.0f} % dessen, was Annealing")
print(" schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich")
print(" IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode")
print(" gut, was schon die einfache geliefert haette.")
# --- Die Temperatur ist kein Beiwerk ---------------------------------
print("\n" + "-" * 78)
print("Und warum die Starttemperatur ausgemessen gehoert:\n")
print(f" {'Start-T':>8} {'Ruestzeit':>11} {'gg. Faustregel':>15} "
f"{'angenommene':>12} {'schlechtester':>14}")
print(f" {'':>8} {'':>11} {'':>15} {'Verschlecht.':>12} {'Zwischenwert':>14}")
print(" " + "-" * 64)
ergebnisse, verlaeufe = {}, {}
for temperatur in (0.5, 1.0, 2.0, 4.0, 8.0):
_, wert, verlauf = suche(matrix, ZUEGE, temperatur)
ergebnisse[temperatur] = wert
verlaeufe[temperatur] = verlauf
gewinn = (start_kosten - wert) / start_kosten * 100
print(f" {temperatur:>8.1f} {wert:>11,} {gewinn:>13.1f} % "
f"{verlauf.annahmequote * 100:>11.2f} % "
f"{verlauf.schlechtester_zustand:>14,}")
beste_temperatur = min(ergebnisse, key=ergebnisse.get)
heiss = verlaeufe[8.0]
print(f"\n Bester Wert bei T0 = {beste_temperatur}.")
print("\n Die letzten beiden Spalten erklaeren, warum es nach oben kippt.")
print(f" Bei T0 = 8 gehen nur {heiss.annahmequote * 100:.2f} % der Verschlechterungen")
print(" durch - wenig genug, dass man es fuer harmlos halten koennte. Es")
print(f" genuegt aber: Die Suche driftet bis auf {heiss.schlechtester_zustand:,} Minuten ab, das")
print(f" {heiss.schlechtester_zustand / start_kosten:.1f}-fache der Startloesung, und findet im Zugbudget nicht")
print(" mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat")
print(f" in {ZUEGE:,} Zuegen nie etwas GESEHEN, das mehr als")
print(f" {(start_kosten - ergebnisse[8.0]) / start_kosten * 100:.1f} % unter der Startloesung lag.")
print("\n Bemerkenswert ist die andere Richtung: Die besten Werte entstehen")
print(" bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen")
print(" 20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in")
print(" die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.")
print(" Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.")
print("\n Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren")
print(" und T0 so waehlen, dass er die Startloesung nicht wesentlich")
print(" ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass")
print(" T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.")
print("=" * 78)Erwartete Ausgabe (Laufzeiten hardwareabhängig, die Werte nicht):
==============================================================================
SIMULATED ANNEALING AN DER LACKIERANLAGE
==============================================================================
200 Auftraege, 20 Produktfamilien, Zugbudget 400,000 je Lauf.
Faustregel 'billigster naechster Auftrag': 1,049 Minuten Ruestzeit
Typische Zuggroesse |Delta|: Median 70, 90 %-Quantil 99 Minuten
------------------------------------------------------------------------------
Nur Verbesserungen annehmen (Bergsteigen) gegen Annealing:
Faustregel (Start) 1,049 Minuten
Bergsteigen 917 Minuten ( 12.6 % besser)
Simulated Annealing 907 Minuten ( 13.5 % besser)
Beide pruefen genau 400,000 Zuege (3.0 bzw. 3.3 Sekunden).
Annealing nimmt davon 0.01 % der VERSCHLECHTERUNGEN an, Bergsteigen keine.
Bemerkenswert ist, wie klein der Abstand ist: Das simple
Bergsteigen holt hier bereits 93 % dessen, was Annealing
schafft. Wer eine Metaheuristik einsetzt, sollte diesen Vergleich
IMMER mitrechnen - sonst schreibt man einer aufwendigen Methode
gut, was schon die einfache geliefert haette.
------------------------------------------------------------------------------
Und warum die Starttemperatur ausgemessen gehoert:
Start-T Ruestzeit gg. Faustregel angenommene schlechtester
Verschlecht. Zwischenwert
----------------------------------------------------------------
0.5 919 12.4 % 0.00 % 1,049
1.0 907 13.5 % 0.01 % 1,057
2.0 914 12.9 % 0.02 % 1,076
4.0 939 10.5 % 0.11 % 1,329
8.0 1,041 0.8 % 0.29 % 2,193
Bester Wert bei T0 = 1.0.
Die letzten beiden Spalten erklaeren, warum es nach oben kippt.
Bei T0 = 8 gehen nur 0.29 % der Verschlechterungen
durch - wenig genug, dass man es fuer harmlos halten koennte. Es
genuegt aber: Die Suche driftet bis auf 2,193 Minuten ab, das
2.1-fache der Startloesung, und findet im Zugbudget nicht
mehr zurueck. Vom Ergebnis bleibt fast nichts uebrig: Die Suche hat
in 400,000 Zuegen nie etwas GESEHEN, das mehr als
0.8 % unter der Startloesung lag.
Bemerkenswert ist die andere Richtung: Die besten Werte entstehen
bei Annahmequoten nahe null. Die Lehrbuchregel 'anfangs sollen
20 bis 50 Prozent der Verschlechterungen durchgehen' fuehrt hier in
die Irre - sie stammt aus Problemen mit sehr kleinen Zuggroessen.
Bei Ruestzeiten in Minuten ist ein einziger schlechter Zug teuer.
Was stattdessen traegt: Den SCHLECHTESTEN Zwischenwert protokollieren
und T0 so waehlen, dass er die Startloesung nicht wesentlich
ueberschreitet. Diese eine Zahl haette hier auf Anhieb gezeigt, dass
T0 = 8 unbrauchbar ist - ohne dass man das Endergebnis abwarten muss.
==============================================================================
💻 Code-Durchgang
Stelle Was passiert delta_verschieben(...)die Kostenänderung aus höchstens sechs Matrixeinträgen — der Kern des Programms for zug in range(zuege)Budget in Zügen, nicht in Sekunden. Damit sind die abgedruckten Werte auf jeder Maschine dieselben temperatur = T0 * (T1/T0)**(zug/zuege)geometrischer Abkühlplan: gleiche relative Absenkung je Zug annehmen = rng.random() < math.exp(-aenderung/temperatur)das Metropolis-Kriterium, eine Zeile beste, beste_kostendie beste je gesehene Lösung wird getrennt mitgeführt. Ohne das gäbe man am Ende den zufälligen Endzustand aus Verlauf.schlechtester_zustanddie Diagnosezahl, um die es unten geht
Was die Messung zeigt — und was sie nicht zeigt
Erstens: Der Beitrag des Annealings ist bescheiden. Bergsteigen kommt auf 917 Minuten, Annealing auf 907 — das simple Verfahren holt 93 % des Ertrags. Das ist kein Argument gegen Annealing, aber ein starkes Argument dafür, immer beide zu rechnen. Wer nur die aufwendige Variante misst, schreibt ihr gut, was schon die einfache geliefert hätte.
Zweitens: Die Temperatur ist keine Stellschraube unter vielen. Bei T_0 = 8 liefert das Verfahren praktisch nichts mehr. Die letzten beiden Spalten der Tabelle erklären, warum — und die Erklärung ist nicht die, die man erwartet:
| Start-T_0 | angenommene Verschlechterungen | schlechtester Zwischenwert | Ergebnis |
|---|---|---|---|
| 0,5 | 0,00 % | 1 049 | 919 |
| 1,0 | 0,01 % | 1 057 | 907 |
| 2,0 | 0,02 % | 1 076 | 914 |
| 4,0 | 0,11 % | 1 329 | 939 |
| 8,0 | 0,29 % | 2 193 | 1 041 |
Bei T_0 = 8 gehen nur 0,29 % der Verschlechterungen durch. Das klingt harmlos. Es genügt aber, um die Suche bis auf das 2,1-fache der Startlösung abdriften zu lassen — und aus diesem Zustand findet sie im verbleibenden Budget nicht zurück.
⚠️ Eine Lehrbuchregel, die hier in die Irre führt
In vielen Darstellungen steht: „Wählen Sie T_0 so, dass anfangs 20 bis 50 % der Verschlechterungen angenommen werden.“ Auf dieser Aufgabe wäre das katastrophal — die besten Werte entstehen bei Annahmequoten nahe null.
Der Grund ist die Zuggröße. Die Regel stammt aus Problemen, in denen ein einzelner Zug die Lösung kaum verändert. Hier kostet ein schlechter Zug im Median 70 Minuten bei einer Gesamtlösung von rund 1 000 — jeder einzelne Zug ist eine spürbare Verschlechterung.
Was stattdessen trägt: den schlechtesten Zwischenwert protokollieren und T_0 so wählen, dass er die Startlösung nicht wesentlich überschreitet. Diese eine Zahl zeigt sofort, dass T_0 = 8 unbrauchbar ist — ohne dass man das Endergebnis abwarten muss.
9.6 Ab wann lohnt es sich?
„Kleine Probleme exakt, große mit Metaheuristiken“ ist richtig und nutzlos, solange niemand sagt, wo groß anfängt. Diese Zahl lässt sich messen.
#!/usr/bin/env python3
# Metaheuristik_vs_Exakt.py
"""
Kapitel Metaheuristiken: Ab wann lohnt sich eine Metaheuristik? Die Antwort ist eine Zahl.
Die verbreitete Regel lautet: "Kleine Probleme exakt, grosse mit
Metaheuristiken." Sie ist richtig - aber voellig nutzlos, solange niemand sagt,
wo 'gross' anfaengt. Dieses Programm misst es fuer die Ruestzeitenaufgabe aus
Simulated_Annealing.py nach, und das Ergebnis ueberrascht in beide Richtungen:
* Bei 60 Auftraegen loest CP-SAT BEWEISBAR optimal, in wenigen Sekunden.
Die Metaheuristik ist hier schlicht die schlechtere Wahl - sie liefert ein
schwaecheres Ergebnis und weiss nicht einmal, wie schwach.
* Bei 200 Auftraegen liegt CP-SAT ohne Optimalitaetsbeweis vorne.
* Erst bei 500 Auftraegen kippt es: Dort gibt CP-SAT im Zeitlimit eine
Loesung aus, die SCHLECHTER ist als die Faustregel eines Meisters.
Der Punkt des Kapitels steht in der letzten Spalte: Die Schranke. Nur der
exakte Solver sagt, wie gut die Loesung sein KANN. Eine Metaheuristik allein
liefert eine Zahl ohne Massstab - man weiss nie, ob man 2 % oder 40 % neben dem
Optimum liegt.
ZUR REPRODUZIERBARKEIT: Die Spalte 'Annealing' ist auf jeder Maschine gleich -
sie laeuft ueber ein festes ZUGbudget. Die CP-SAT-Spalten sind es nicht: Ein
Zeitlimit ist ein Wanduhr-Limit, und wie weit der Solver in 30 Sekunden kommt,
haengt von der Maschine ab. CP-SAT kennt zwar ein deterministisches Zeitmass
(max_deterministic_time), das aber in einer Groessenordnung rechnet, die fuer
ein Buchbeispiel unbrauchbar langsam ist. Die Zahlen unten sind also
hardwareabhaengig - die AUSSAGE der Tabelle ist es nicht, sie ist auf
verschiedenen Groessen und in mehreren Laeufen stabil.
WICHTIG: Hier wird nur ortools importiert, nicht highspy (Kapitel Oekosystem).
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import time
import numpy as np
from ortools.sat.python import cp_model
ZUEGE = 400_000 # Zugbudget der Metaheuristik (wie Simulated_Annealing.py)
ZEITLIMIT = 30.0 # Zeitbudget des exakten Solvers
GROESSEN = (60, 200, 500)
# --- Instanz, Faustregel und Suche: identisch zu Simulated_Annealing.py -----
def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
rng = np.random.default_rng(saat)
familien = max(6, n // 10)
zwischen = rng.integers(8, 60, (familien, familien))
np.fill_diagonal(zwischen, 2)
familie = rng.integers(0, familien, n)
farbe = rng.integers(0, 10, n)
matrix = np.zeros((n, n), dtype=np.int64)
for i in range(n):
for j in range(n):
if i != j:
matrix[i, j] = (zwischen[familie[i], familie[j]]
+ 3 * max(0, farbe[i] - farbe[j]))
return matrix
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
def faustregel(matrix: np.ndarray) -> list[int]:
n = len(matrix)
offen = set(range(1, n))
reihe = [0]
while offen:
naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
reihe.append(naechster)
offen.discard(naechster)
return reihe
def delta_verschieben(reihe, matrix, von, nach):
n = len(reihe)
if von == nach or nach == von + 1:
return None
heraus = 0
if von > 0:
heraus += matrix[reihe[von - 1], reihe[von]]
if von < n - 1:
heraus += matrix[reihe[von], reihe[von + 1]]
if 0 < von < n - 1:
heraus -= matrix[reihe[von - 1], reihe[von + 1]]
rest = reihe[:von] + reihe[von + 1:]
stelle = nach if nach < von else nach - 1
hinein = 0
if stelle > 0:
hinein += matrix[rest[stelle - 1], reihe[von]]
if stelle < len(rest):
hinein += matrix[reihe[von], rest[stelle]]
if 0 < stelle < len(rest):
hinein -= matrix[rest[stelle - 1], rest[stelle]]
return int(hinein - heraus), rest, stelle
def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
start_temperatur: float = 1.0, saat: int = 1) -> tuple[int, float]:
"""Dieselbe Suche wie in Simulated_Annealing.py, hier nur als Vergleichswert."""
import math
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste_kosten = kosten
t0 = time.perf_counter()
for zug in range(zuege):
temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
von = int(rng.integers(0, n))
nach = int(rng.integers(0, n + 1))
ergebnis = delta_verschieben(reihe, matrix, von, nach)
if ergebnis is None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
beste_kosten = min(beste_kosten, kosten)
return beste_kosten, time.perf_counter() - t0
# --- Der exakte Weg: CP-SAT mit AddCircuit ---------------------------------
def exakt(matrix: np.ndarray, zeitlimit: float = ZEITLIMIT):
"""Reihenfolge als Rundreise mit einem kostenlosen Hilfsknoten.
AddCircuit verlangt einen geschlossenen Kreis. Unsere Aufgabe ist aber eine
offene Kette: Der erste Auftrag hat keinen Vorgaenger, der letzte keinen
Nachfolger. Der uebliche Kniff ist ein zusaetzlicher Hilfsknoten n, dessen
Kanten nichts kosten. Der Kreis laeuft dann Hilfsknoten -> Kette ->
Hilfsknoten, und die Kosten des Kreises sind genau die der Kette.
AddCircuit ist der Grund, warum CP-SAT hier so lange mithaelt: Die
Nebenbedingung sorgt selbst dafuer, dass keine Teilkreise entstehen. Wer
dasselbe als MILP formuliert, braucht dafuer entweder exponentiell viele
Ungleichungen oder die schwache MTZ-Formulierung (Kapitel Graphen).
"""
n = len(matrix)
modell = cp_model.CpModel()
kanten, ziel = [], []
for i in range(n + 1):
for j in range(n + 1):
if i == j:
continue
aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
kanten.append((i, j, aktiv))
if i < n and j < n: # Hilfsknoten kostet nichts
ziel.append(int(matrix[i, j]) * aktiv)
modell.AddCircuit(kanten)
modell.Minimize(sum(ziel))
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
loeser.parameters.num_workers = 8
loeser.parameters.random_seed = 1
t0 = time.perf_counter()
status = loeser.Solve(modell)
dauer = time.perf_counter() - t0
return (loeser.StatusName(status), int(loeser.ObjectiveValue()),
int(loeser.BestObjectiveBound()), dauer)
if __name__ == "__main__":
print("=" * 88)
print(" AB WANN LOHNT SICH DIE METAHEURISTIK?")
print("=" * 88)
print(f"Dieselbe Aufgabe in drei Groessen. Metaheuristik: {ZUEGE:,} Zuege. "
f"Exakt: {ZEITLIMIT:.0f} s Zeitlimit.\n")
print(f"{'Auftraege':>10} {'Faustregel':>11} {'Annealing':>10} "
f"{'CP-SAT':>9} {'Status':>10} {'Schranke':>9} {'CP-Zeit':>9}")
print("-" * 88)
zeilen = []
for n in GROESSEN:
matrix = erzeuge_ruestmatrix(n)
start = gesamtruestzeit(faustregel(matrix), matrix)
heuristisch, _ = annealing(matrix)
status, wert, schranke, dauer = exakt(matrix)
zeilen.append((n, start, heuristisch, wert, status, schranke, dauer))
print(f"{n:>10} {start:>11,} {heuristisch:>10,} {wert:>9,} "
f"{status:>10} {schranke:>9,} {dauer:>8.1f}s")
print("-" * 88)
print("\nWas in jeder Zeile steht:\n")
for n, start, heur, wert, status, schranke, _ in zeilen:
if status == "OPTIMAL":
urteil = (f"CP-SAT beweist das Optimum. Annealing liegt "
f"{(heur - wert) / wert * 100:.1f} % daneben - und haette")
zweite = " das ohne den exakten Lauf nicht erfahren."
elif wert <= heur:
urteil = (f"CP-SAT liegt {(heur - wert) / heur * 100:.1f} % vor Annealing, "
f"ohne Beweis (Gap {(wert - schranke) / wert * 100:.1f} %).")
zweite = " Auch ohne Optimalitaetsbeweis ist es die bessere Wahl."
else:
urteil = (f"UMSCHLAGPUNKT: CP-SAT ist {(wert - heur) / heur * 100:.1f} % "
f"SCHLECHTER als Annealing")
zweite = (f" und sogar {(wert - start) / start * 100:.1f} % schlechter "
f"als die Faustregel des Meisters.")
print(f" {n:>4} Auftraege: {urteil}")
print(zweite)
n, start, heur, wert, status, schranke, _ = zeilen[-1]
print("\n" + "=" * 88)
print(" DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF")
print("=" * 88)
print("Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,")
print("wenn seine LOESUNG unbrauchbar ist.")
print()
print(f"Bei {n} Auftraegen liefert Annealing {heur:,} Minuten. Klingt gut: "
f"{(start - heur) / start * 100:.1f} %")
print("besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen")
print(f"Loesung wir gerade verworfen haben, sagt aber: Unter {schranke:,} Minuten geht")
print(f"es nicht. Zwischen {schranke:,} und {heur:,} liegen {(heur - schranke) / heur * 100:.0f} % - so viel Luft")
print("kann noch nach oben sein.")
print()
print("Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine")
print("Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein")
print("exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:")
print("nicht wegen seiner Loesung, sondern wegen seiner Schranke.")
print("=" * 88)Erwartete Ausgabe (die CP-SAT-Spalten sind hardwareabhängig, siehe Programmkopf):
========================================================================================
AB WANN LOHNT SICH DIE METAHEURISTIK?
========================================================================================
Dieselbe Aufgabe in drei Groessen. Metaheuristik: 400,000 Zuege. Exakt: 30 s Zeitlimit.
Auftraege Faustregel Annealing CP-SAT Status Schranke CP-Zeit
----------------------------------------------------------------------------------------
60 388 275 265 OPTIMAL 265 2.0s
200 1,049 907 811 FEASIBLE 804 30.1s
500 2,497 2,343 2,628 FEASIBLE 1,768 30.4s
----------------------------------------------------------------------------------------
Was in jeder Zeile steht:
60 Auftraege: CP-SAT beweist das Optimum. Annealing liegt 3.8 % daneben - und haette
das ohne den exakten Lauf nicht erfahren.
200 Auftraege: CP-SAT liegt 10.6 % vor Annealing, ohne Beweis (Gap 0.9 %).
Auch ohne Optimalitaetsbeweis ist es die bessere Wahl.
500 Auftraege: UMSCHLAGPUNKT: CP-SAT ist 12.2 % SCHLECHTER als Annealing
und sogar 5.2 % schlechter als die Faustregel des Meisters.
========================================================================================
DIE SPALTE, DIE MAN NICHT WEGLASSEN DARF
========================================================================================
Die Schranke ist der eigentliche Ertrag des exakten Solvers - auch dann,
wenn seine LOESUNG unbrauchbar ist.
Bei 500 Auftraegen liefert Annealing 2,343 Minuten. Klingt gut: 6.2 %
besser als die Faustregel. Die Schranke aus demselben CP-SAT-Lauf, dessen
Loesung wir gerade verworfen haben, sagt aber: Unter 1,768 Minuten geht
es nicht. Zwischen 1,768 und 2,343 liegen 25 % - so viel Luft
kann noch nach oben sein.
Das ist der Unterschied zwischen 'besser als vorher' und 'gut'. Eine
Metaheuristik allein kann nur das Erste. Deshalb gehoert auch dann ein
exakter Lauf dazu, wenn man am Ende die heuristische Loesung einsetzt:
nicht wegen seiner Loesung, sondern wegen seiner Schranke.
========================================================================================
Drei Größen, drei völlig verschiedene Empfehlungen:
| Aufträge | Empfehlung |
|---|---|
| 60 | Exakt. CP-SAT beweist das Optimum in 2 Sekunden. Die Metaheuristik liegt 3,8 % daneben — und wüsste es nicht. |
| 200 | Exakt. Kein Beweis mehr, aber der Gap liegt unter 1 %, und der Wert ist rund 11 % besser als der heuristische. |
| 500 | Heuristisch. CP-SAT liefert eine Lösung, die 5,2 % schlechter ist als die Faustregel eines Meisters. |
🎯 Merksatz Der Umschlagpunkt ist keine Eigenschaft der Verfahren, sondern des Problems. Für die Standortplanung aus Abschnitt 6.8 liegt er woanders als hier. Wer ihn nicht für seine eigene Aufgabe gemessen hat, rät.
Die Spalte, die man nicht weglassen darf
Bei 500 Aufträgen haben wir die CP-SAT-Lösung verworfen. Seine Schranke ist trotzdem das Wertvollste in der ganzen Tabelle.
Annealing liefert 2 343 Minuten — 6,2 % besser als die Faustregel. Klingt nach einem Erfolg. Die Schranke sagt: unter 1 768 Minuten geht es nicht. Zwischen 1 768 und 2 343 liegen 25 %.
Das ist der Unterschied zwischen besser als vorher und gut. Eine Metaheuristik allein kann nur das Erste behaupten. Deshalb gehört ein exakter Lauf auch dann dazu, wenn man am Ende die heuristische Lösung einsetzt — nicht wegen seiner Lösung, sondern wegen seiner Schranke.
9.7 Large Neighborhood Search
Die Tabelle oben endet mit einem Widerspruch. Bei 500 Aufträgen scheitert der exakte Solver an der Größe; die Metaheuristik scheitert an der Kleinheit ihrer Züge — sie verschiebt jeweils einen einzigen Auftrag und kann eine ganze Passage nicht auf einmal umbauen.
Large Neighborhood Search (LNS) setzt beide ein, jeden für das, was er kann:
| Schritt | Was passiert |
|---|---|
| Zerstören | Ein zusammenhängendes Stück des Plans herausbrechen — hier 20 Aufträge. |
| Reparieren | Genau dieses Stück exakt neu optimieren. 20 Aufträge sind für CP-SAT eine Kleinigkeit, 500 sind es nicht. |
| Übernehmen | Nur behalten, wenn der Gesamtplan besser wurde. |
Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein kleines.
#!/usr/bin/env python3
# Large_Neighborhood_Search.py
"""
Kapitel Metaheuristiken: Zerstoeren und exakt reparieren - der Solver im Dienst der Heuristik.
Metaheuristik_vs_Exakt.py endet mit einem Widerspruch. Bei 500 Auftraegen gilt:
* Der exakte Solver scheitert an der GROESSE - seine Loesung nach 30 Sekunden
ist schlechter als die Faustregel eines Meisters.
* Die Metaheuristik kommt weiter, laesst aber laut Schranke noch rund ein
Viertel liegen. Ihre Zuege sind zu kleinteilig: Sie verschiebt jeweils
EINEN Auftrag und kann eine ganze Passage nicht auf einmal umbauen.
Large Neighborhood Search loest den Widerspruch, indem sie beide einsetzt -
jeden fuer das, was er kann:
ZERSTOEREN Ein Stueck des Plans herausbrechen (hier: ein zusammen-
haengendes Fenster von 20 Auftraegen).
REPARIEREN Genau dieses Stueck EXAKT neu optimieren. 20 Auftraege sind
fuer CP-SAT eine Kleinigkeit, 500 sind es nicht.
UEBERNEHMEN Nur behalten, wenn der Gesamtplan besser wurde.
Der Solver bekommt also nicht mehr das ganze Problem, sondern immer wieder ein
kleines. Das ist der ganze Trick, und er ist in der Praxis der wichtigste
Baustein dieses Kapitels.
ZUR REPRODUZIERBARKEIT: Alle Laeufe haben eine feste RUNDENzahl, nicht ein
Zeitbudget - die Ergebniswerte sind damit auf jeder Maschine gleich. Die
Laufzeiten daneben sind hardwareabhaengig.
Benoetigt: numpy, ortools
"""
from __future__ import annotations
import math
import time
import numpy as np
from ortools.sat.python import cp_model
FENSTER = 20 # so viele Auftraege werden je Runde herausgebrochen
RUNDEN = 120
ZUEGE = 400_000 # Zugbudget des Annealings (wie Simulated_Annealing.py)
N = 500
# --- Instanz und Faustregel (wie in Simulated_Annealing.py) ----------------
def erzeuge_ruestmatrix(n: int, saat: int = 11) -> np.ndarray:
rng = np.random.default_rng(saat)
familien = max(6, n // 10)
zwischen = rng.integers(8, 60, (familien, familien))
np.fill_diagonal(zwischen, 2)
familie = rng.integers(0, familien, n)
farbe = rng.integers(0, 10, n)
matrix = np.zeros((n, n), dtype=np.int64)
for i in range(n):
for j in range(n):
if i != j:
matrix[i, j] = (zwischen[familie[i], familie[j]]
+ 3 * max(0, farbe[i] - farbe[j]))
return matrix
def gesamtruestzeit(reihe: list[int], matrix: np.ndarray) -> int:
return int(sum(matrix[reihe[k], reihe[k + 1]] for k in range(len(reihe) - 1)))
def faustregel(matrix: np.ndarray) -> list[int]:
n = len(matrix)
offen = set(range(1, n))
reihe = [0]
while offen:
naechster = min(offen, key=lambda j: matrix[reihe[-1], j])
reihe.append(naechster)
offen.discard(naechster)
return reihe
def delta_verschieben(reihe, matrix, von, nach):
n = len(reihe)
if von == nach or nach == von + 1:
return None
heraus = 0
if von > 0:
heraus += matrix[reihe[von - 1], reihe[von]]
if von < n - 1:
heraus += matrix[reihe[von], reihe[von + 1]]
if 0 < von < n - 1:
heraus -= matrix[reihe[von - 1], reihe[von + 1]]
rest = reihe[:von] + reihe[von + 1:]
stelle = nach if nach < von else nach - 1
hinein = 0
if stelle > 0:
hinein += matrix[rest[stelle - 1], reihe[von]]
if stelle < len(rest):
hinein += matrix[reihe[von], rest[stelle]]
if 0 < stelle < len(rest):
hinein -= matrix[rest[stelle - 1], rest[stelle]]
return int(hinein - heraus), rest, stelle
def annealing(matrix: np.ndarray, zuege: int = ZUEGE,
start_temperatur: float = 1.0, saat: int = 1) -> list[int]:
rng = np.random.default_rng(saat)
n = len(matrix)
reihe = faustregel(matrix)
kosten = gesamtruestzeit(reihe, matrix)
beste, beste_kosten = reihe[:], kosten
for zug in range(zuege):
temperatur = start_temperatur * (0.05 / start_temperatur) ** (zug / zuege)
von, nach = int(rng.integers(0, n)), int(rng.integers(0, n + 1))
ergebnis = delta_verschieben(reihe, matrix, von, nach)
if ergebnis is None:
continue
aenderung, rest, stelle = ergebnis
if aenderung <= 0 or rng.random() < math.exp(-aenderung / temperatur):
reihe = rest[:stelle] + [reihe[von]] + rest[stelle:]
kosten += aenderung
if kosten < beste_kosten:
beste, beste_kosten = reihe[:], kosten
return beste
# --- Der Reparaturschritt: ein kleines Problem, exakt geloest --------------
def repariere_fenster(reihe: list[int], matrix: np.ndarray,
a: int, b: int) -> list[int] | None:
"""Ordnet reihe[a:b] optimal neu; die beiden Raender bleiben, wo sie sind.
Die Raender festzuhalten ist entscheidend. Ohne sie waere das Teilproblem
ein anderes als der Ausschnitt aus dem Gesamtplan: Der Uebergang vom
Vorgaenger in das Fenster und aus dem Fenster in den Nachfolger gehoert
zu den Kosten dazu. Genau dafuer steht der Hilfsknoten k - er vertritt
beide Raender in einem.
"""
innen = reihe[a:b]
k = len(innen)
vorgaenger = reihe[a - 1] if a > 0 else None
nachfolger_rand = reihe[b] if b < len(reihe) else None
modell = cp_model.CpModel()
kanten, ziel = [], []
for i in range(k + 1):
for j in range(k + 1):
if i == j:
continue
aktiv = modell.NewBoolVar(f"kante_{i}_{j}")
kanten.append((i, j, aktiv))
if i < k and j < k:
ziel.append(int(matrix[innen[i], innen[j]]) * aktiv)
elif i == k and j < k and vorgaenger is not None:
ziel.append(int(matrix[vorgaenger, innen[j]]) * aktiv)
elif j == k and i < k and nachfolger_rand is not None:
ziel.append(int(matrix[innen[i], nachfolger_rand]) * aktiv)
modell.AddCircuit(kanten)
modell.Minimize(sum(ziel))
loeser = cp_model.CpSolver()
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1
loeser.parameters.max_time_in_seconds = 10.0
status = loeser.Solve(modell)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
# Auch das gehoert dazu: Wenn das Teilproblem nicht loest, bleibt der
# Plan, wie er war. Eine LNS-Runde darf scheitern.
return None
nachfolger = {i: j for i, j, aktiv in kanten if loeser.Value(aktiv)}
neue_folge, aktuell = [], nachfolger[k]
while aktuell != k:
neue_folge.append(innen[aktuell])
aktuell = nachfolger[aktuell]
return reihe[:a] + neue_folge + reihe[b:]
def lns(reihe: list[int], matrix: np.ndarray, runden: int = RUNDEN,
fenster: int = FENSTER, saat: int = 3):
"""Zerstoeren, exakt reparieren, uebernehmen - so lange das Budget reicht."""
rng = np.random.default_rng(saat)
kosten = gesamtruestzeit(reihe, matrix)
verbesserungen = 0
start = time.perf_counter()
for _ in range(runden):
a = int(rng.integers(0, len(reihe) - fenster))
neu = repariere_fenster(reihe, matrix, a, a + fenster)
if neu is None:
continue
neue_kosten = gesamtruestzeit(neu, matrix)
if neue_kosten < kosten:
reihe, kosten = neu, neue_kosten
verbesserungen += 1
return reihe, kosten, verbesserungen, time.perf_counter() - start
if __name__ == "__main__":
matrix = erzeuge_ruestmatrix(N)
start_reihe = faustregel(matrix)
start_kosten = gesamtruestzeit(start_reihe, matrix)
SCHRANKE = 1768 # aus dem CP-SAT-Lauf in Metaheuristik_vs_Exakt.py
print("=" * 84)
print(" ZERSTOEREN UND EXAKT REPARIEREN")
print("=" * 84)
print(f"{N} Auftraege. Je Runde werden {FENSTER} aufeinanderfolgende Auftraege")
print(f"herausgebrochen und exakt neu geordnet, {RUNDEN} Runden lang.\n")
print("Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:\n")
sa_reihe = annealing(matrix)
sa_kosten = gesamtruestzeit(sa_reihe, matrix)
lns_ab_faustregel = lns(start_reihe[:], matrix)
lns_ab_annealing = lns(sa_reihe[:], matrix)
print(f" {'Verfahren':<34} {'Ruestzeit':>10} {'ueber Schranke':>15}")
print(" " + "-" * 62)
for name, wert in [
("Faustregel (ohne Solver)", start_kosten),
("CP-SAT allein, 30 s", 2628),
("Simulated Annealing", sa_kosten),
("LNS ab Faustregel", lns_ab_faustregel[1]),
("Annealing, dann LNS", lns_ab_annealing[1])]:
print(f" {name:<34} {wert:>10,} {(wert - SCHRANKE) / SCHRANKE * 100:>13.1f} %")
print(f" {'untere Schranke (CP-SAT)':<34} {SCHRANKE:>10,} {0.0:>13.1f} %")
print(f"\n LNS ab Faustregel: {lns_ab_faustregel[2]} von {RUNDEN} Runden brachten "
f"eine Verbesserung ({lns_ab_faustregel[3]:.0f} s).")
print(f" LNS ab Annealing: {lns_ab_annealing[2]} von {RUNDEN} Runden "
f"({lns_ab_annealing[3]:.0f} s).")
# --- Die Fenstergroesse ----------------------------------------------
# Verglichen wird bei ANNAEHERND GLEICHER ZEIT, nicht bei gleicher
# Rundenzahl: Eine Runde mit Fenster 30 kostet ein Vielfaches einer Runde
# mit Fenster 10. Wer nach Runden vergleicht, misst nur, dass groessere
# Teilprobleme mehr finden - und uebersieht, was sie kosten. Die
# Rundenzahlen unten sind so gewaehlt, dass alle drei Laeufe in derselben
# Groessenordnung liegen.
print("\n" + "-" * 84)
print("Die Fenstergroesse ist die eine Stellschraube - und sie hat ein")
print("Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:\n")
print(f" {'Fenster':>8} {'Runden':>8} {'Ruestzeit':>11} {'Verbesser-':>12} {'Zeit':>8}")
print(f" {'':>8} {'':>8} {'':>11} {'ungen':>12} {'':>8}")
print(" " + "-" * 52)
fenster_ergebnis = {}
for fenster, runden in ((10, 4000), (20, 150), (30, 20)):
_, wert, verbesserungen, dauer = lns(sa_reihe[:], matrix,
runden=runden, fenster=fenster)
fenster_ergebnis[fenster] = wert
print(f" {fenster:>8} {runden:>8,} {wert:>11,} {verbesserungen:>12} "
f"{dauer:>7.0f}s")
print("\n Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:")
print(f" Mit Fenster 10 bleiben von {4000:,} Runden nur eine Handvoll")
print(" Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug")
print(" umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet")
print(" es auch in beliebig vielen Runden nicht.")
print("\n Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde")
print(" mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden")
print(f" und landet bei {fenster_ergebnis[30]:,} statt {fenster_ergebnis[20]:,}. Eine Reparatur mit 30 Auftraegen")
print(" ist eben genau das Problem, an dem der exakte Solver im Grossen")
print(" scheitert - nur eine Nummer kleiner.")
print("\n Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten")
print(" statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.")
print("\n" + "=" * 84)
print(" WAS DAS HEISST")
print("=" * 84)
bester = lns_ab_annealing[1]
print(f"Der beste Plan kommt aus der Kombination: {bester:,} Minuten,")
print(f"{(start_kosten - bester) / start_kosten * 100:.1f} % unter der Faustregel und "
f"{(sa_kosten - bester) / sa_kosten * 100:.1f} % unter dem reinen Annealing.")
print()
print("Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.")
print("Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit")
print("ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er")
print("beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.")
print()
print(f"Und die Ehrlichkeit zum Schluss: Auch {bester:,} liegt noch "
f"{(bester - SCHRANKE) / SCHRANKE * 100:.0f} % ueber der")
print("Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke")
print("nur schwach ist, weiss man nicht - das ist die Lage, in der man mit")
print("einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren")
print("Abstand zum Optimum.")
print("=" * 84)Erwartete Ausgabe (Laufzeiten hardwareabhängig, die Werte nicht):
====================================================================================
ZERSTOEREN UND EXAKT REPARIEREN
====================================================================================
500 Auftraege. Je Runde werden 20 aufeinanderfolgende Auftraege
herausgebrochen und exakt neu geordnet, 120 Runden lang.
Erst die Metaheuristik allein, dann LNS auf ihrem Ergebnis:
Verfahren Ruestzeit ueber Schranke
--------------------------------------------------------------
Faustregel (ohne Solver) 2,497 41.2 %
CP-SAT allein, 30 s 2,628 48.6 %
Simulated Annealing 2,343 32.5 %
LNS ab Faustregel 2,332 31.9 %
Annealing, dann LNS 2,289 29.5 %
untere Schranke (CP-SAT) 1,768 0.0 %
LNS ab Faustregel: 23 von 120 Runden brachten eine Verbesserung (29 s).
LNS ab Annealing: 13 von 120 Runden (45 s).
------------------------------------------------------------------------------------
Die Fenstergroesse ist die eine Stellschraube - und sie hat ein
Optimum in der Mitte. Gleiche Zeit, verschiedene Fenster:
Fenster Runden Ruestzeit Verbesser- Zeit
ungen
----------------------------------------------------
10 4,000 2,319 6 28s
20 150 2,289 13 48s
30 20 2,298 10 82s
Nach unten ist die Grenze nicht die Zeit, sondern die SAETTIGUNG:
Mit Fenster 10 bleiben von 4,000 Runden nur eine Handvoll
Verbesserungen uebrig. Zwanzig Auftraege lassen sich in einem Zug
umbauen, zehn nicht - und was ein Fenster nicht umbauen kann, findet
es auch in beliebig vielen Runden nicht.
Nach oben ist die Grenze die Rechenzeit: Fenster 30 findet pro Runde
mehr, kommt in derselben Zeit aber nur auf einen Bruchteil der Runden
und landet bei 2,298 statt 2,289. Eine Reparatur mit 30 Auftraegen
ist eben genau das Problem, an dem der exakte Solver im Grossen
scheitert - nur eine Nummer kleiner.
Diese Tabelle gehoert an den Anfang jedes LNS-Projekts. Sie zu raten
statt zu messen ist der haeufigste Fehler beim Einsatz des Verfahrens.
====================================================================================
WAS DAS HEISST
====================================================================================
Der beste Plan kommt aus der Kombination: 2,289 Minuten,
8.3 % unter der Faustregel und 2.3 % unter dem reinen Annealing.
Weder der exakte Solver noch die Metaheuristik allein kommen dorthin.
Der Solver scheitert an der Groesse, die Metaheuristik an der Kleinheit
ihrer Zuege. LNS gibt dem Solver Teilprobleme in einer Groesse, die er
beherrscht, und der Metaheuristik die Umbauten, die sie nicht kann.
Und die Ehrlichkeit zum Schluss: Auch 2,289 liegt noch 29 % ueber der
Schranke. Ob dort wirklich noch so viel Luft ist oder ob die Schranke
nur schwach ist, weiss man nicht - das ist die Lage, in der man mit
einer Metaheuristik arbeitet. Man kennt seine Loesung, nicht ihren
Abstand zum Optimum.
====================================================================================
💻 Code-Durchgang: warum die Ränder festgehalten werden
repariere_fenster()hält den Auftrag vor und nach dem Fenster fest. Das ist keine Vereinfachung, sondern notwendig: Der Übergang vom Vorgänger in das Fenster und aus dem Fenster in den Nachfolger gehört zu den Kosten. Ein Teilproblem, das diese beiden Kanten ignoriert, optimiert etwas anderes als den Ausschnitt aus dem Gesamtplan — und liefert Umbauten, die im Ganzen teurer sind.Im Modell erledigt das der Hilfsknoten
k: Er vertritt beide Ränder in einem. Seine eingehende Kante trägt die Kosten letzter Auftrag → Nachfolger, seine ausgehende die Kosten Vorgänger → erster Auftrag.
Das Fenster ist die eine Stellschraube
| Fenster | Runden (gleiche Zeit) | Verbesserungen | Ergebnis |
|---|---|---|---|
| 10 | 4 000 | 6 | 2 319 |
| 20 | 150 | 13 | 2 289 |
| 30 | 20 | 10 | 2 298 |
Das Optimum liegt in der Mitte, und zwar aus zwei verschiedenen Gründen:
- Nach unten ist die Grenze nicht die Zeit, sondern die Sättigung. Mit Fenster 10 bleiben von 4 000 Runden ganze 6 Verbesserungen übrig. Was ein Fenster nicht umbauen kann, findet es auch in beliebig vielen Runden nicht.
- Nach oben ist die Grenze die Rechenzeit. Eine Reparatur mit 30 Aufträgen ist genau das Problem, an dem der exakte Solver im Großen scheitert — nur eine Nummer kleiner.
🎯 Merksatz Diese Tabelle gehört an den Anfang jedes LNS-Projekts, nicht ans Ende. Die Fenstergröße zu raten statt zu messen ist der häufigste Fehler beim Einsatz des Verfahrens.
Das Gesamtbild
| Verfahren | Rüstzeit | über der Schranke |
|---|---|---|
| Faustregel (ohne Solver) | 2 497 | 41,2 % |
| CP-SAT allein, 30 s | 2 628 | 48,6 % |
| Simulated Annealing | 2 343 | 32,5 % |
| LNS ab Faustregel | 2 332 | 31,9 % |
| Annealing, dann LNS | 2 289 | 29,5 % |
| untere Schranke (CP-SAT) | 1 768 | 0 % |
Der beste Plan kommt aus der Kombination. Und die letzte Spalte bleibt ehrlich: Auch 2 289 liegt noch 29,5 % über der Schranke. Ob dort wirklich so viel Luft ist oder ob die Schranke nur schwach ist, weiß man nicht — das ist die Lage, in der man mit einer Metaheuristik arbeitet.
9.8 Was dieses Kapitel nicht behandelt
Damit die Landkarte vollständig ist — drei Verfahren, die in dieselbe Familie gehören und hier bewusst nur benannt werden:
| Verfahren | Idee in einem Satz | Wann anschauen |
|---|---|---|
| Genetische Algorithmen | Eine Population von Lösungen wird gekreuzt und mutiert. | Wenn es eine natürliche „Kreuzung“ zweier Lösungen gibt. Für Reihenfolgen ist genau das der schwierige Teil. Bibliothek: pymoo. |
| Tabu-Suche | Bergsteigen, das zuletzt gemachte Züge eine Zeit lang verbietet. | Wenn die Suche zwischen zwei Zuständen pendelt. Oft mit weniger Kalibrierung als Annealing. |
| Spaltengenerierung | Nicht die Lösung heuristisch machen, sondern das Modell zerlegen: Ein Master-Problem fragt bei einem Teilproblem nach neuen, lohnenden Spalten. | Bei Zuschnitt- und Dienstplanproblemen mit sehr vielen gleichartigen Mustern. Das folgende Kapitel führt es aus: Kapitel 10. |
Die Spaltengenerierung ist die interessanteste davon, weil sie eine andere Antwort auf dieselbe Frage gibt: Statt die Lösung zu approximieren, formuliert sie das Modell so um, dass exaktes Lösen wieder möglich wird. Sie hat eine eigene Voraussetzung — das Problem muss sich in ein Master- und ein Teilproblem zerlegen lassen — und ist deshalb kein Ersatz für LNS, sondern eine Alternative für eine engere Problemklasse.
Genau deshalb bekommt sie ein eigenes Kapitel: Kapitel 10. Es zeigt nebenbei, warum First-Fit bei manchen Zuschnittproblemen bereits optimal ist — eine Frage, die dieses Kapitel offenlässt.
9.9 Übungsaufgaben
Lösungen: Abschnitt A.9.
Aufgabe 9.1 ⭐ — Die Kurzsichtigkeit von Hand. Rechnen Sie im Schnellstart nach, was die Faustregel kostet, wenn sie nicht bei Weiß, sondern bei Schwarz startet. Ist sie dort besser oder schlechter — und warum?
Aufgabe 9.2 ⭐ — Zuggröße und Temperatur. Simulated_Annealing.py misst den Median der Zuggröße mit 70 Minuten. Bei welcher Starttemperatur würde eine Verschlechterung von genau 70 Minuten in 37 % der Fälle angenommen? Vergleichen Sie das mit den Temperaturen in der Tabelle.
Aufgabe 9.3 ⭐⭐ — Die teure Bewertung. Ersetzen Sie in Simulated_Annealing.py die Funktion delta_verschieben durch eine Variante, die den Zug ausführt und gesamtruestzeit() neu berechnet. Wie viele Züge schafft das Programm dann in derselben Zeit, und wie verändert sich das Ergebnis bei gleichem Zeitbudget?
Aufgabe 9.4 ⭐⭐ — Der Umschlagpunkt genauer. Metaheuristik_vs_Exakt.py misst drei Größen. Zwischen 200 und 500 liegt der Umschlagpunkt. Grenzen Sie ihn mit weiteren Größen auf ±50 Aufträge ein. Ist er scharf oder verwaschen?
Aufgabe 9.5 ⭐⭐⭐ — Zerstören nach Maß. Large_Neighborhood_Search.py bricht ein zusammenhängendes Fenster heraus. Bauen Sie eine zweite Zerstörungsstrategie: Entfernen Sie die 20 Aufträge mit den teuersten Übergängen, egal wo sie stehen, und setzen Sie sie exakt neu ein. Welche Strategie ist besser — und liegt es an der Strategie oder an der Zahl der Runden, die sie schafft?
9.10 Finde den Denkfehler
🐛 „Unsere Heuristik ist 6 % besser“
Ein Kollege stellt das Ergebnis seiner Optimierung vor:
„Die Disposition hat bisher nach Faustregel geplant: 2 497 Minuten Rüstzeit. Unser > Simulated Annealing kommt auf 2 343 — 6,2 % Ersparnis, jede Nacht, vollautomatisch. > Bei 250 Produktionstagen sind das über 640 Stunden Maschinenzeit im Jahr.“
Die Rechnung stimmt. Das Programm ist korrekt. Die 2 343 Minuten sind echt, die Ersparnis ist echt, und das Projekt wird genehmigt.
Trotzdem ist die Darstellung irreführend. Woran liegt es?
Ein Hinweis: Die Zahl, die fehlt, steht in der Ausgabe von
Metaheuristik_vs_Exakt.py— in einer Spalte, die mit der Lösung des Problems nichts zu tun hat.
9.11 Micro-Quiz
❓ Drei Fragen
1. Warum ist die Kostenänderung in O(1) so wichtig? a) Weil die volle Summe numerisch ungenau wird. b) Weil eine lokale Suche von der Zahl geprüfter Züge lebt und die volle Summe sie um Größenordnungen senkt. c) Weil Simulated Annealing sonst mathematisch nicht konvergiert.
2. Bei T_0 = 8 werden 0,29 % der Verschlechterungen angenommen, und das Ergebnis ist unbrauchbar. Warum genügen 0,29 %? a) Weil die Annahmewahrscheinlichkeit im Lauf noch steigt. b) Weil die angenommenen Verschlechterungen sich aufsummieren und die Suche weit von der guten Startlösung wegtragen, bevor die Temperatur fällt. c) Weil das Metropolis-Kriterium bei kleinen Quoten instabil wird.
3. Bei 500 Aufträgen liefert CP-SAT eine schlechtere Lösung als die Faustregel. Was ist der Lauf trotzdem wert? a) Nichts — abgebrochene Läufe sind wertlos. b) Er liefert die untere Schranke und damit die einzige Aussage darüber, wie viel Luft noch nach oben ist. c) Er liefert eine gute Startlösung für das Annealing.
9.12 Selbsttest
- Erklären Sie in eigenen Worten, warum eine Faustregel „kurzsichtig“ ist — und warum das nicht dasselbe ist wie „schlecht“.
- Warum lässt sich 2-opt bei asymmetrischen Rüstzeiten nicht in O(1) bewerten?
- Sie setzen eine Metaheuristik ein und berichten „12 % Verbesserung“. Welche zweite Zahl müssen Sie mitliefern, damit die Aussage belastbar ist, und woher bekommen Sie sie?
- Ihr LNS verbessert kaum noch. Nennen Sie zwei Ursachen, die man an der Ausgabe unterscheiden kann.
- Warum ist der Umschlagpunkt zwischen exakt und heuristisch keine allgemeine Zahl?
9.13 Zusammenfassung
- Eine Faustregel ist selten dumm, aber immer kurzsichtig: Sie bewertet einen Schritt danach, was er kostet, nicht danach, was er übrig lässt.
- Lokale Suche steht und fällt mit der Kostenänderung in O(1). Wer den Zug über die volle Zielfunktion bewertet, misst am Ende seine Implementierung statt des Verfahrens.
- Simulated Annealing nimmt Verschlechterungen mit e^{-\Delta/T} an. T und \Delta haben dieselbe Einheit — die Temperatur gehört an der Zuggröße kalibriert, nicht am Zielfunktionswert. Die verbreitete Regel „20 bis 50 % Annahmequote“ war auf dieser Aufgabe falsch.
- Der schlechteste Zwischenwert ist die beste Diagnosezahl eines Annealing-Laufs: Er zeigt eine zu heiße Starttemperatur sofort, ohne dass man das Ergebnis abwarten muss.
- Rechnen Sie immer das simple Bergsteigen mit. Hier lieferte es 93 % des Ertrags.
- Der Umschlagpunkt zwischen exakt und heuristisch ist problemspezifisch und messbar. Hier liegt er zwischen 200 und 500 Aufträgen — bei 60 wäre die Metaheuristik die schlechtere Wahl gewesen.
- Ein exakter Lauf lohnt sich auch dann, wenn Sie seine Lösung verwerfen: Seine Schranke ist die einzige Aussage darüber, wie gut Ihre heuristische Lösung sein könnte.
- LNS kombiniert beide: zerstören, exakt reparieren, übernehmen. Die Fenstergröße hat ein Optimum in der Mitte und gehört gemessen.