Problem dokładnego pokrycia zbioru

https://en.wikipedia.org/wiki/Exact_cover

https://arxiv.org/abs/cs/0011047
Dancing links, Donald E. Knuth.

WPROWADZENIE

Problem dokładnego pokrycia zbioru (the exact cover problem) może być określony abstrakcyjnie następująco [Knuth, Dancing links]: Jeżeli dana jest macierz zer i jedynek, to czy posiada ona zbiór wierszy zawierający dokładnie jedną 1 w każdej kolumnie? Przykładowo macierz A poniżej posiada taki zbiór (wiersze 1, 4, 5).


+----------+---------------+
| Kolumna  | A B C D E F G |
+----------+---------------+
| Wiersz 1 | 0 0 1 0 1 1 0 | *
| Wiersz 2 | 1 0 0 1 0 0 1 |
| Wiersz 3 | 0 1 1 0 0 1 0 |
| Wiersz 4 | 1 0 0 1 0 0 0 | *
| Wiersz 5 | 0 1 0 0 0 0 1 | *
| Wiersz 6 | 0 0 0 1 1 0 1 |
+----------+---------------+

Można też patrzeć na kolumny jak na przestrzeń, a na wiersze jak na podzbiory tej przestrzeni. Wtedy będziemy chcieli pokryć przestrzeń podzbiorami rozłącznymi. W każdym przypadku problem jest trudny, NP-zupełny. Rozwiązuje się go za pomocą algorytmów z powrotami (backtracking).

PARKIET

Problem układania parkietu identycznymi klockami 2 × 1. Rozważamy planszę 2 × 3.


+----------+ Numeracja pól planszy
| 11 12 13 |
| 21 22 23 |
+----------+

+----------+-------------------+
| Kolumna  | 11 12 13 21 22 23 |
+----------+-------------------+
| Wiersz 1 |  1  1  0  0  0  0 | poziomo
| Wiersz 2 |  0  1  1  0  0  0 | poziomo
| Wiersz 3 |  0  0  0  1  1  0 | poziomo
| Wiersz 4 |  0  0  0  0  1  1 | poziomo
| Wiersz 5 |  1  0  0  1  0  0 | pionowo
| Wiersz 6 |  0  1  0  0  1  0 | pionowo
| Wiersz 7 |  0  0  1  0  0  1 | pionowo
+----------+-------------------+
Przygotowany plik z danymi:
11 12
12 13
21 22
22 23
11 21
12 22
13 23
Są 3 rozwiązania tego problemu.
+-------+  +-------+  +-------+
| A A C |  | A B B |  | A B C |
| B B C |  | A C C |  | A B C |
+-------+  +-------+  +-------+

Ciekawostka. Układanie parkietu klockami 2 × 1 można zapisać jako problem
znajdowania skojarzenia doskonałego w pewnym grafie.
Tutaj dostajemy graf dwudzielny postaci
11---12---13
|    |    |
21---22---23
Istnieją algorytmy działające w czasie wielomianowym, znajdujące skojarzenie
doskonałe w grafach ogólnych i dwudzielnych.

Podobnie możemy analizować różne puzzle i układanki, np. pentomino, sudoku.

OPIS ALGORYTMU

W artykule Knutha mamy podany algorytm postępowania.


Jeżeli A jest pusta, to zakończ.
Wybierz kolumnę c (deterministycznie).
Ustal wiersze r, dla których A[r,c]=1.
Dla każdego wiersza r:
    Dodaj r do częściowego rozwiązania.
    Ustal kolumny j takie, że A[r,j]=1.
    Dla każdej kolumny j:
        Usuń/zaznacz kolumnę j z A.
        Ustal wiersze i takie, że A[i,j]=1.
        Dla każdego wiersza i:
            Usuń wiersz i z A.
    Wykonaj algorytm na zredukowanej macierzy A.
    Przywróć usunięte wiersze i.
    Przywróć/odznacz kolumny j.
    Usuń r z częściowego rozwiązania.

IMPLEMENTACJA ALGORYTMU W PYTHONIE

Krok pierwszy to reprezentacja macierzy A. Macierz jest zwykle rzadka (niewiele jedynek), co sugeruje zapisywanie tylko niezerowych pozycji. Możemy wykorzystać elastyczność Pythona i korzystać z etykiet tekstowych. Szukając rozwiązania będziemy usuwać wiersze z macierzy A aż do jej całkowitego opróżnienia. Z drugiej strony wybrane wiersze będą wchodziły w skład rozwiązania. Pierwszy pomysł na reprezentację macierzy A to wykorzystanie słownika:


A1 = {(1, "C"): 1, (1, "E"): 1, (1, "F"): 1,
(2, "A"): 1, (2, "D"): 1, (2, "G"): 1,
(3, "B"): 1, (3, "C"): 1, (3, "F"): 1,
(4, "A"): 1, (4, "D"): 1,
(5, "B"): 1, (5, "G"): 1,
(6, "D"): 1, (6, "E"): 1, (6, "G"): 1}

Wszystkie niezerowe wartości w macierzy A to jedynki, co sugeruje, że nie musimy ich zapisywać, tylko pamiętać pozycje (węzły). Stąd drugi pomysł przechowywania macierzy jako listy węzłów (para wiersz i kolumna). Będziemy mogli korzystać z bogatej kolekcji metod dla list.


A2 = [(1, "C"), (1, "E"), (1, "F"),
(2, "A"), (2, "D"), (2, "G"),
(3, "B"), (3, "C"), (3, "F"),
(4, "A"), (4, "D"),
(5, "B"), (5, "G"),
(6, "D"), (6, "E"), (6, "G")]

W globalnym słowniku covered_cols zapiszemy stan każdej kolumny, czy jest już pokryta (True), czy nie (False).


covered_cols = dict((c, False) for (r, c) in A)

Rozwiązania będziemy szukali za pomocą rekurencyjnej funkcji search(k), która na każdym poziomie ustala jeden wiersz rozwiązania. Wiersz ten (listę węzłów) zapiszemy w globalnym słowniku B pod kluczem k.


B = {}

def print_solution():
    for k in B:
        print(" ".join(str(node[1]) for node in B[k]))

Na każdym poziomie wywołania funkcji search(k) będziemy wybierać jedną kolumnę jeszcze nie pokrytą, którą chcemy pokryć. Trzeba to robić systematycznie aż do pokrycia wszystkich kolumn. Kod zapiszemy w osobnej funkcji, aby łatwo testować różne sposoby wybierania kolumn.

Definiujemy wyjątek dla sytuacji błędnych danych. Dokładniej: jeżeli są niepokryte kolumny, to funkcja powinna jedną z nich podać, nawet taką, która nie ma pokrywającego ją wiersza. Funkcja generuje wyjątek, jeżeli nie ma niepokrytych kolumn. Sytuację kolumny bez wiersza, który może ją pokryć, obsługujemy w funkcji search() [to jest ślepy zaułek, ale nie generujemy wyjątku, tylko funkcja oddaje sterowanie wyżej].


def choose_col1():
    # Wybieramy pierwszą niepokrytą kolumnę.
    for c in covered_cols:
        if not covered_cols[c]:
            return c
    raise ValueError("Wszystkie kolumny pokryte")

def choose_col2():
    # Wybieramy ostatnią niepokrytą kolumnę.
    cols = [c for c in covered_cols if not covered_cols[c]]
    if not cols:
        raise ValueError("Wszystkie kolumny pokryte")
    #return cols[0]      # można wybrać pierwszą
    return cols[-1]      # wybieram ostatnią

def choose_col3():
    # Wybieramy kolumnę z najmniejszą liczbą jedynek.
    # Najpierw szukamy niepokrytych kolumn.
    cols = [c for c in covered_cols if not covered_cols[c]]
    if not cols:
        raise ValueError("Wszystkie kolumny pokryte")
    # Dla kolumn niepokrytych szukam liczby wierszy.
    # Mogą się trafić kolumny bez wierszy, więc trzeba inicjalizować zerem.
    tmp = dict((c, 0) for c in cols)
    for (r, c) in A:
        if c in cols:
            tmp[c] = tmp[c] + 1
    # Szukamy minimalnej liczby wierszy.
    min_c = cols[0]
    for c in cols:
        if tmp[c] < tmp[min_c]:
            min_c = c
    return min_c

choose_col = choose_col1         # wybrany wariant

Właściwa funkcja szukająca rozwiązania ma postać.


def search(k):                # szukaj kolejnego wiersza nr k w tablicy A
    if not A:             # już nie ma wierszy
        for c in covered_cols:
            if not covered_cols[c]:   # ślepy zaułek
                return
        print_solution()                  # wszystkie pokryte
        return
    c = choose_col()
    # Wybieramy wiersze z 1 w kolumnie c.
    rows = [node[0] for node in A if node[1]==c]
    if not rows:
        # Nie ma wiersza z jedynką w kolumnie c.
        return
    for r in rows:
        box = []         # tymczasowo usuniete wiersze
        # dodaj wiersz r do częściowego rozwiązania
        B[k] = [node for node in A if node[0] == r]
        # Usuwam wiersz r z A.
        for node in B[k]:
            box.append(node)
            A.remove(node)
        # Wybieram kolumny j takie, że A[r, j] == 1 (tu jest c!)
        cols = [node[1] for node in B[k]]
        for j in cols:
            # Zaznaczam kolumnę j.
            covered_cols[j] = True
            # Wybieram wiersze i takie, że A[i, j] == 1.
            # Tu są też wiersze z rows.
            rows2 = [node[0] for node in A if node[1] == j]
            # Przenoszę wiersze z A do box.
            # Nie wolno iterować po A i zmieniać A jednocześnie!
            tmp = [node for node in A if node[0] in rows2]
            for node in tmp:
                box.append(node)
                A.remove(node)
        search(k+1)
        # Przywracam wiersze do A.
        for node in box:
            A.append(node)
        del box
        del B[k]
        # Anuluję zaznaczenie kolumn.
        for j in cols:
            covered_cols[j] = False
    return

Do funkcji search(k) można dodać kod zliczający wybrane operacje, aby lepiej zrozumieć działanie algorytmu.

Dla dużych problemów wygodnie jest wczytać macierz A z pliku tekstowego zawierającego wiersze. Dla przykładowej macierzy A plik będzie miał postać


C E F
A D G
B C F
A D
B G
D E G

Funkcja wczytująca macierz A z pliku może mieć postać:


def read_table(file_name):
    afile = open(file_name, "r")
    table = []
    row = 0
    for line in afile:
        row += 1
        for col in line.split():
            table.append((row, col))
    afile.close()
    return table