Почему произвольный выбор следующей ячейки влияет на производительность при поиске решения судоку? - PullRequest
0 голосов
/ 02 января 2019

Я сделал простой класс Судоку (на Python). Его основными атрибутами являются следующие:

def __init__(self, it: iter = None):
    self.grid = [[None for _ in range(9)] for _ in range(9)]
    self.usedInRow = [[False for _ in range(10)] for _ in range(9)]
    self.usedInCol = [[False for _ in range(10)] for _ in range(9)]
    self.usedInBox = [[False for _ in range(10)] for _ in range(9)]

def writeCell(self, pos: tuple, val: int) -> bool: [...]

def eraseCell(self, pos: tuple): [...]

self.grid отслеживает фактические цифры в судоку. self.usedInRow, self.usedInCol и self.usedInBox - это логические массивы, которые отслеживают, какие числа были использованы в каждой строке / столбце / квадрате. writeCell - это метод, который пытается записать число в пустую ячейку и возвращает, успешно ли оно выполнено (согласно правилам судоку). Наконец, eraseCell заменяет содержимое ячейки на None.

Внутри класса я написал метод решателя с рекурсивным отслеживанием. При реализации решателя я пробовал разные подходы для выбора ячейки для заполнения следующей. Первое, что я попробовал, было просто начать с (0, 0) и увеличивать позиции слева направо и сверху вниз (с колонками как «маленькой» единицей и строками как «большой» единицей).

Но это недостаточно обобщенно (если по какой-то причине решатель начал решать с позиции, отличной от (0, 0), каждую пустую ячейку до того, как она будет пропущена). Поэтому я добавил участника, чтобы отслеживать пустые ячейки:

def __init__(self, it: iter = None):
    [...]
    self.emptyCells = [(i, j) for i in range(9) for j in range(9)]

Я изменил методы writeCell и eraseCell для обеспечения правильной записи. На этом этапе решатель выглядит примерно так:

def solve(self) -> bool:
    [...]

    def _solve() -> bool:
        if not self.emptyCells: return True
        pos = self.emptyCells[0]

        for n in range(1, 10):
            if self.writeCell(pos, n):
                if _solve(): return True
                self.eraseCell(pos)
        else: return False

    success = _solve()
    [...]
    return success

В этот момент я подумал, что, возможно, было бы лучше, если бы член self.emptyCells был set вместо list: я не хотел дубликатов, не нуждался в индексированном доступе (я думал, что это не имеет значения порядок, в котором были заполнены ячейки), и требуется быстрое удаление. Поэтому я изменил атрибут self.emptyCells

self.emptyCells = set((i, j) for i in range(9) for j in range(9))

и методы, которые ссылались на него (например, я использовал .discard() вместо .remove() в writeCell). Метод решателя теперь выглядел так:

def solve(self) -> bool:
    [...]

    def _solve() -> bool:
        if not self.emptyCells: return True
        pos = self.emptyCells.pop()  # Get arbitrary position

        for n in range(1, 10):
            if self.writeCell(pos, n):
                if _solve(): return True
                self.eraseCell(pos)
        else: 
            self.emptyCells.add(pos)  # Backtrack
            return False

    success = _solve()
    [...]
    return success

К моему удивлению, второй подход (с использованием набора) занял около одной минуты, чтобы решить пустое судоку, в то время как первый подход (с использованием списка) занял около двух секунд. Это почему? Влияет ли произвольно выбранная позиция, присвоенная set.pop(), на эффективность алгоритма по сравнению с заполнением ячеек по порядку?



Полный код

Использование списка

import queue
import threading
import colorama

colorama.init()  # For ANSI escape codes


class Sudoku:
    @staticmethod
    def isSudoku(S: iter):
        return len(S) == 9 \
               and all(len(row) == 9 for row in S) \
               and all(num in range(1, 10) for row in S for num in row)

    def __init__(self, it: iter = None):
        self.grid = [[None for _ in range(9)] for _ in range(9)]
        self.usedInRow = [[False for _ in range(10)] for _ in range(9)]
        self.usedInCol = [[False for _ in range(10)] for _ in range(9)]
        self.usedInBox = [[False for _ in range(10)] for _ in range(9)]
        self.emptyCells = [(i, j) for i in range(9) for j in range(9)]

        if it is not None:
            for i in range(9):
                for j, num in zip(range(9), it):
                    if num is not None:
                        if not self.writeCell((i, j), num): raise ValueError("Invalid Sudoku")

    def __iter__(self):
        return iter(self.grid)

    def __len__(self):
        return 9

    def __str__(self):
        def numsToStr(row):
            for num in row: yield num if num is not None else ' '

        def rowsBlock(i):
            yield ''
            for row in self.grid[3 * i:3 * (i + 1)]:
                yield '|{} {} {}|{} {} {}|{} {} {}|'.format(*numsToStr(row))
            yield ''

        def blocks():
            yield ''
            for i in range(3): yield '\n'.join(rowsBlock(i))
            yield ''

        return ('-' * 19).join(blocks())

    def __getitem__(self, key: tuple):
        if not len(key) == 2: raise TypeError("Two indices needed")
        r, c = key
        return self.grid[r][c]

    def __setitem__(self, key: tuple, value: int):
        if not len(key) == 2: raise TypeError("Two indices needed")
        self.eraseCell(key)
        self.writeCell(key, value)

    def _canWrite(self, r: int, c: int, b: int, val: int) -> bool:
        if self.usedInRow[r][val] or self.usedInCol[c][val] or self.usedInBox[b][val] \
            or self.grid[r][c] is not None: return False
        self.usedInRow[r][val] = self.usedInCol[c][val] = self.usedInBox[b][val] = True
        return True

    def writeCell(self, pos: tuple, val: int) -> bool:
        if val is None: return True  # Ignore writing none
        r, c = pos
        b = 3*(r//3) + c//3

        if not self._canWrite(r, c, b, val): return False
        # noinspection PyTypeChecker
        self.grid[r][c] = val
        self.emptyCells.remove(pos)
        return True

    def eraseCell(self, pos: tuple):
        r, c = pos
        b = 3*(r//3) + c//3

        val = self.grid[r][c]  # Get old value for reference
        if val is None: return  # If there wasn't anything in the first place
        self.grid[r][c] = None  # Actually erase
        self.emptyCells.append(pos)
        # noinspection PyTypeChecker
        self.usedInRow[r][val] = self.usedInCol[c][val] = self.usedInBox[b][val] = False

    def solve(self) -> bool:
        printQueue = queue.PriorityQueue()

        def printer():
            while True:
                priority, item = printQueue.get()
                if item is StopAsyncIteration:
                    break
                print(item)
                print('\x1b[13A', end='')
                printQueue.task_done()

        printThread = threading.Thread(target=printer, name="PrintThread")
        printThread.start()

        def _solve() -> bool:
            if not self.emptyCells: return True
            pos = self.emptyCells[0]

            for n in range(1, 10):
                if self.writeCell(pos, n):
                    if _solve(): return True
                    printQueue.put((1, self))
                    self.eraseCell(pos)
            else:
                return False

        success = _solve()
        printQueue.put((0, StopAsyncIteration))
        printThread.join()
        return success


Использование набора

Меняются только следующие вещи.

_solve() внутренний метод:

    def _solve():
        if not self.emptyCells: return True
        pos = self.emptyCells.pop()

        for n in range(1, 10):
            if self.writeCell(pos, n):
                if _solve(): return True
                printQueue.put((1, self))
                self.eraseCell(pos)
        else:
            self.emptyCells.add(pos)
            return False

eraseCell и writeCell:

def writeCell(self, pos: tuple, val: int):
    if val is None: return True  # Ignore writing none
    r, c = pos
    b = 3*(r//3) + c//3

    if not self._canWrite(r, c, b, val): return False
    # noinspection PyTypeChecker
    self.grid[r][c] = val
    self.emptyCells.discard(pos)
    return True

def eraseCell(self, pos: tuple):
    r, c = pos
    b = 3*(r//3) + c//3

    val = self.grid[r][c]  # Get old value for reference
    if val is None: return  # If there wasn't anything in the first place
    self.grid[r][c] = None  # Actually erase
    self.emptyCells.add(pos)
    # noinspection PyTypeChecker
    self.usedInRow[r][val] = self.usedInCol[c][val] = self.usedInBox[b][val] = False

Примечание

Причина, по которой я не возвращаю pos в версию списка, а также почему я получаю pos как self.emptyCells[0] вместо self.emptyCells.pop(0) - потому что writeCell и eraseCell уже заботятся о что: если writeCell удастся написать (ранее пустую) ячейку, то она вызовет self.emptyCells.remove(pos) - и вот еще одна причина, по которой я не появляюсь в списке: в противном случае я бы remove нашел бы несуществующий элемент в список, и придется поймать ValueError.

После этого, если происходит возврат (т. Е. Рекурсивный вызов _solve() возвращает False), eraseCell вернет pos в список. Таким образом, если цикл for «не работает», т. Е. Введена ветвь else, последний eraseCell уже вернет pos назад.

С другой стороны, при использовании set невозможно получить доступ к элементу, не удалив его из набора (насколько мне известно). Таким образом, я вынужден pop из него и положить его обратно вручную. Вот почему remove используется вместо *1093* в set -версии writeCell, так как pos, возможно, уже был вытолкнут solve_.

Альтернативой может быть сохранение pos = self.emptyCells.pop() и add прямо (self.emptyCells.add(pos)), а затем переход к solve_. Тогда я мог бы изменить discard на remove.




Пример использования

sudoku = Sudoku()
sudoku.solve()
print(sudoku)


Результаты профилирования

Я профилировал обе версии, но не нашел никакого очевидного виновника, то есть пропорции совокупного времени, затраченного каждым методом, были очень похожи, хотя фактическое абсолютное время, которое они взяли, было только увеличено в версии set с уважение к версии списка.


Обновление - pos насчитывает

Я добавилмассив для отслеживания того, сколько раз каждый pos был выбран для исследования, и я распечатал его в конце решения. Вот результаты.

Список версий:

[[   1    1    1    1    1    1    1    1    1]
 [   1    1    1    1  145  671 7169 9869 7606]
 [ 774 1115  645    7 2066 1240  451  463  554]
 [ 511  466  504  424  554  422  358  502  566]
 [ 545  224  507  539  578  404  487  357  574]
 [ 421  421  452  436  465  406  326  621  483]
 [ 436  519  284  462  545  534  410  563  730]
 [ 433  561  311  397  365  254  308  708  384]
 [ 445  559  558  413  513  572  564  511  686]]

Установить версию (насколько я могу судить, результаты согласованы):

[[ 19895      1  91239  66620      1      1  76794      1      1]
 [     1      1  77617  43061  95435      1      1  49617  77772]
 [ 54441      1      1  96081  79660      1      1      1  89575]
 [101233      1      1  85645  55172      1      1      1  54613]
 [     1      1  71512  61978   5531      1  76794  82692  74311]
 [ 96268      1  57136  92052      1      1  86104  91911      1]
 [     1  98119  95706  10680      1  97232  67210      1  39094]
 [ 53447      1      1      1  89846      1      1  88072      1]
 [ 74468  19103      1  39934  75698      1      1  90778  53260]]
...