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.py

Notebook: 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 min

24 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 …

  1. … erklären, warum eine lokale Suche ohne Kostenänderung in O(1) praktisch wertlos ist.
  2. … Simulated Annealing implementieren und seine Starttemperatur an der Zuggröße kalibrieren statt am Zielfunktionswert.
  3. … den Beitrag der Metaheuristik gegen die einfachere Alternative abgrenzen — reines Bergsteigen — statt ihn ihr gutzuschreiben.
  4. … den Umschlagpunkt bestimmen, ab dem eine Metaheuristik den exakten Solver schlägt, und begründen, warum diese Zahl problemspezifisch ist.
  5. … erklären, warum ein exakter Lauf auch dann dazugehört, wenn Sie am Ende die heuristische Lösung einsetzen.
  6. … 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 gedacht

Das 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.
==============================================================================
Abb. 9.1: Dieselbe Rechnung als Bild. Links der Zielwert während der Suche: Bergsteigen fällt monoton, Annealing bei T_0=1 fast genauso — bei T_0=8 irrt die Suche bis auf das 2,1-fache der Startlösung ab und findet im Zugbudget nicht zurück. Rechts das Ergebnis über die Starttemperatur: Das Optimum ist schmal, und jenseits davon ist der ganze Vorsprung aufgezehrt. Erzeugt von bilder_04/erzeuge_metaheuristik_landschaft.py.

💻 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_kosten die beste je gesehene Lösung wird getrennt mitgeführt. Ohne das gäbe man am Ende den zufälligen Endzustand aus
Verlauf.schlechtester_zustand die 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

  1. Erklären Sie in eigenen Worten, warum eine Faustregel „kurzsichtig“ ist — und warum das nicht dasselbe ist wie „schlecht“.
  2. Warum lässt sich 2-opt bei asymmetrischen Rüstzeiten nicht in O(1) bewerten?
  3. 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?
  4. Ihr LNS verbessert kaum noch. Nennen Sie zwei Ursachen, die man an der Ausgabe unterscheiden kann.
  5. 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.