Damenproblem

Das Damenproblem ist ein einfach zu beschreibendes Problem, das sich elegant durch Backtracking lösen lässt und dabei erlaubt, die beschriebenen Aspekte eines Suchproblems zu verdeutlichen. Es besteht darin, acht Damen so auf einem Schachbrett zu platzieren, dass sie sich weder horizontal noch vertikal noch diagonal schlagen können. Im Folgenden ist eine gültige Platzierung von vier Damen auf einem entsprechend verkleinerten Schachbrett dargestellt.

Vier Damen auf einem 4x4 Schachbrett, die sich nichtschlagen.

Um die Platzierung von Damen auf einem Schachbrett in Python darzustellen, können wir ausnutzen, dass diese, damit sie sich nicht horizontal schlagen können, in unterschiedlichen Zeilen (Reihen) platziert werden müssen. Wir stellen sie deshalb als Array aus Zahlen dar und speichern dabei im ersten Eintrag des Arrays, in welcher Spalte (Linie) die erste Dame steht, im zweiten Eintrag die Spalte der zweiten Dame und so weiter. Wir durchlaufen dabei die Zeilen von oben nach unten und zählen die Spalten von Null beginnend. Zum Beispiel entspricht das Array [2,0,3,1] der oben gezeigten Platzierung von vier Damen auf einem 4x4 Schachbrett.

Die Prozedur print_queens gibt eine so dargestellte Platzierung von Damen im Terminal aus.

def print_queens(queens):
    for i in range(0, len(queens)):
        print("  " * queens[i] + "Q")

Die Ausgabe von print_queens([2,0,3,1]) ähnelt der obigen grafischen Darstellung.

    Q
Q
      Q
  Q

Um das Damenproblem mit Hilfe von Backtracking zu lösen, implementieren wir eine Funktion is_complete, die testet, ob eine so dargestellte Platzierung vollständig ist. Da wir acht Damen auf einem richtigen Schachbrett platzieren wollen, testen wir dazu, ob das Array, die Größe acht hat. Unser Algorithmus soll also dem Array schrittweise Einträge hinzufügen, bis dieses acht Einträge enthält.

BOARD_SIZE = 8

def is_complete(queens):
    return len(queens) == BOARD_SIZE

Wir verwenden eine Konstante1 BOARD_SIZE für die Anzahl der Damen, die auf dem Schachbrett platziert werden sollen. Diese definieren wir global (also außerhalb der Definition von is_complete), um sie auch in anderen Definitionen verwenden zu können.

Um zu testen, ob eine Platzierung gültig ist, müssen wir testen, ob alle Damen vor Angriffen anderer sicher sind. Da wir durch die Darstellung bereits sicher gestellt haben, dass Damen sich nicht horizontal schlagen können, brauchen wir dazu nur noch zu testen, ob sie sich vertikal oder diagonal bedrohen. Dazu durchlaufen wir jede Dame mit Hilfe einer Zählschleife und testen dann in einer weiteren Zählschleife, ob sie vor später platzierten Damen sicher ist.

def is_safe(queens):
    # search all pairs of different queens
    for i in range(0, len(queens)):
        for j in range(i + 1, len(queens)):
            # queens in same column
            if queens[i] == queens[j]:
                return False
            # row distance equals column distance: queens on same diagonal
            if j - i == abs(queens[j] - queens[i]):
                return False
    # found no attack
    return True

Ob sich Damen vertikal bedrohen, erkennen wir daran, ob sich die Spalten zweier verschiedener Damen gleichen. Um zu testen, ob sich Damen diagonal bedrohen, vergleichen wir deren Spaltenabstand mit dem Zeilenabstand. Sind diese gleich, stehen die Damen auf der selben Diagonale und können sich schlagen. Zur Berechnung des Spaltenabstandes verwenden wir den Absolutbetrag. Da wir für jede in der äußeren Schleife durchlaufene Dame nur später platzierte Damen betrachten, ist dies für den Zeilenabstand nicht nötig.

Schließlich implementieren wir noch eine Funktion place_next, die eine Platzierung um eine weitere Dame erweitert. Diese fügt dem Array einen Eintrag zwischen null und sieben hinzu und gibt ein Array aller so erzeugten Arrays zurück.

def place_next(queens):
    extensions = [None] * BOARD_SIZE

    for q in range(0, BOARD_SIZE):
        extensions[q] = queens + [q]

    return extensions

Wir können nun den zuvor umgangssprachlich formulierten Algorithmus als Funktion is_solvable implementieren. Statt im Schleifenrumpf eine return-Anweisung zu verwenden, speichern wir in einer Variablen solvable, ob eine Lösung gefunden wurde. Diese können wir dann in der Bedingung einer bedingten Schleife abfragen, um die Betrachtung überflüssiger Alternativen zu vermeiden.

def is_solvable(queens):
    if is_complete(queens):
        return is_safe(queens)

    exts = place_next(queens)
    for index in range(0, len(exts)):
        if is_solvable(exts[index]):
            return True

    return False

Falls die übergebene Teillösung vollständig ist, wird zurückgegeben, ob diese gültig ist. Falls nicht, werden alle Erweiterungen der übergebenen Teillösung berechnet und in der Variablen qs gespeichert. Die anschließende Schleife durchläuft die Erweiterungen, bis mit Hilfe eines rekursiven Aufrufs eine lösbare Erweiterung gefunden wurde.

Mit den gezeigten Definitionen läuft der Aufruf is_solvable([]) für einige Sekunden und liefert schließlich das Ergebnis True zurück, zeigt also an, dass das Damenproblem für acht Damen lösbar ist. Dabei werden alle Platzierungen von acht Damen auf einem Schachbrett nacheinandner daraufhin getestet, ob sich Damen bedrohen, bis die erste sichere Platzierung gefunden wurde. Da (bis zur ersten Lösung) der komplette Suchraum aller Platzierungen von acht Damen auf einem Schachbrett durchsucht wird, spricht man von einem sogenannten brute force Algorithmus. Der Suchraum wird mit voller Kraft vorraus aber auch blind durchsucht und erst vollständige Platzierungen werden auf Gültigkeit überprüft.

Da wir bereits unvollständige Teillösungen auf Gültigkeit überprüfen können, können wir die Laufzeit des Algorithmus deutlich verbessern. Wenn sich zum Beispiel schon die beiden zuerst platzierten Damen bedrohen, brauchen die restlichen sechs gar nicht mehr platziert zu werden. Dadurch werden große Teile des Suchbaums gar nicht erst durchlaufen.

Wir implementieren diese Idee, indem wir die Abbarbeitung des Rumpfes von is_solvable vorzeitig beenden, wenn die übergebene Teillösung nicht gültig ist. Dazu können wir die folgende optionale Anweisung am Anfang des Funktionsrumpfes notieren.

    if not is_safe(queens):
        return False

Nach dieser Änderung liefert der Aufruf is_solvable([]) das Ergebnis True ohne merkliche Verzögerung. Falls wie hier bereits Teillösungen auf ihre Gültigkeit überprüft werden können, kann auf diese Weise die Laufzeit des Backtracking-Verfahrens oft erheblich verbessert werden.

Dass das Damenproblem lösbar ist, haben wir möglicherweise bereits vorher vermutet. Um die gefundene Platzierung auszugeben, fügen wir in der bedingten Anweisung zu Beginn der Definition von is_solvable einen Aufruf von print_queens hinzu.

    if is_complete(queens):
        print_queens(queens)
        return True

Da is_solvable ungültige Teillösungen vorher verwirft, ist der Test is_safe(queens) für vollständige Teillösungen nun überflüssig und wir ersetzen ihn durch True. Der Aufruf is_solvable([]) gibt nun die folgende Darstellung einer sicheren Platzierung von acht Damen auf einem Schachbrett aus.

Q
        Q
              Q
          Q
    Q
            Q
  Q
      Q

  1. Python bietet keine Möglichkeit, Zuweisungen an Variablen zu verhindern. Per Konvention wird aber Variablen, die nur aus Großbuchstaben und Unterstrichen bestehen, nach der Initialisierung kein neuer Wert zugewiesen. ↩︎