Перейти к содержанию

Решатель Судоку

В этой работе требуется написать решатель Судоку. Правила игры в Судоку достаточно простые, вот пояснения с Википедии:

Quote

Игровое поле представляет собой квадрат размером 9×9, разделённый на меньшие квадраты со стороной в 3 клетки. Таким образом, всё игровое поле состоит из 81 клетки. В них уже в начале игры стоят некоторые числа от 1 до 9, называемые подсказками. От игрока требуется заполнить свободные клетки цифрами от 1 до 9 так, чтобы в каждой строке, в каждом столбце и в каждом малом квадрате 3×3 каждая цифра встречалась бы только один раз.

Далее приведен пример Судоку и его решения:

Пример решения Судоку Пример решения Судоку

Чтение пазлов

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

53..7....
6..195...
.98....6.
8...6...3
4..8.3..1
7...2...6
.6....28.
...419..5
....8..79

где каждая точка соответствует пустой клетке, которую требуется заполнить числом.

Теперь нужно написать функцию для чтения пазла из файла (шаблон работы можно найти в репозитория курса). Назовем ее read_sudoku() и в качестве аргумента будем передавать путь к файлу, в котором хранится пазл:

def create_grid(puzzle: str) -> tp.List[tp.List[str]]:
    digits = [c for c in puzzle if c in "123456789."]
    grid = group(digits, 9)
    return grid


def read_sudoku(path: tp.Union[str, pathlib.Path]) -> tp.List[tp.List[str]]:
    """ Прочитать Судоку из указанного файла """
    path = pathlib.Path(path)
    with path.open() as f:
        puzzle = f.read()
    return create_grid(puzzle)

На текущий момент вашей задачей является написать функцию group(), которая принимает список значений произвольного типа T и размер группы n, а в качестве результата работы возвращает матрицу размера n*n:

T = tp.TypeVar("T")


def group(values: tp.List[T], n: int) -> tp.List[tp.List[T]]:
    """
    Сгруппировать значения values в список, состоящий из списков по n элементов

    >>> group([1,2,3,4], 2)
    [[1, 2], [3, 4]]
    >>> group([1,2,3,4,5,6,7,8,9], 3)
    [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    """
    # PUT YOUR CODE HERE
    pass

Note

Процесс выполнения работы такой же как и предыдущей, поэтому не забудьте активировать виртуальное окружение, создавать новую ветку homework02, запускать тесты и делать коммиты.

Hint

Для решения ряда задач используйте списковые включения. Например, чтобы создать список из четных элементов в диапазоне от 0 до 10 можно использовать такую конструкцию:

>>> ll = [i for i in range(10) if i % 2 == 0]
>>> ll
[0, 2, 4, 6, 8]

Чтобы убедиться в том, что вы верно написали функцию group() воспользуйтесь юнит-тестами:

(cs102) $ python -m unittest test_sudoku.SudokuTestCase.test_group
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Обратите внимание как мы указали путь к конкретному тесту файл.ТестКейс.метод. Аналогично мы можем протестировать весь тест-кейс, указав файл.ТестКейс. Если ТестКейс является единственным в файле, то достаточно указать только имя файла.

Если вы корректно реализовали функцию group(), то давайте посмотрим как работает функция read_sudoku(). Запустите скрипт с заходом в интерактивный режим (все пазлы также в репозитории):

(cs102) $ python -i sudoku.py
>>> grid = read_sudoku('puzzle1.txt')
>>> from pprint import pprint as pp
>>> pp(grid)
[['5', '3', '.', '.', '7', '.', '.', '.', '.'],
['6', '.', '.', '1', '9', '5', '.', '.', '.'],
['.', '9', '8', '.', '.', '.', '.', '6', '.'],
['8', '.', '.', '.', '6', '.', '.', '.', '3'],
['4', '.', '.', '8', '.', '3', '.', '.', '1'],
['7', '.', '.', '.', '2', '.', '.', '.', '6'],
['.', '6', '.', '.', '.', '.', '2', '8', '.'],
['.', '.', '.', '4', '1', '9', '.', '.', '5'],
['.', '.', '.', '.', '8', '.', '.', '7', '9']]

Вывод содержимого пазла grid не очень нагляден, поэтому для вас написана функция display(), которая выводит пазл в более человеко-наглядной форме:

>>> display(grid)
5 3 . |. 7 . |. . .
6 . . |1 9 5 |. . .
. 9 8 |. . . |. 6 .
------+------+------
8 . . |. 6 . |. . 3
4 . . |8 . 3 |. . 1
7 . . |. 2 . |. . 6
------+------+------
. 6 . |. . . |2 8 .
. . . |4 1 9 |. . 5
. . . |. 8 . |. 7 9

Разбивка на строки, колонки и блоки

Так как при решении Судоку ставятся условия, что значения не могут повторяться ни в строке, ни в столбце, ни в квадрате, то следовательно нам эти значения нужно получить. Для этого от вас требуется написать три функции get_row(), get_col() и get_block(), каждая из которых принимает два аргумента: пазл (grid) и позицию (pos), для которой мы пытаемся найти верное число:

def get_row(grid: tp.List[tp.List[str]], pos: tp.Tuple[int, int]) -> tp.List[str]:
    """ Возвращает все значения для номера строки, указанной в pos

    >>> get_row([['1', '2', '.'], ['4', '5', '6'], ['7', '8', '9']], (0, 0))
    ['1', '2', '.']
    >>> get_row([['1', '2', '3'], ['4', '.', '6'], ['7', '8', '9']], (1, 0))
    ['4', '.', '6']
    >>> get_row([['1', '2', '3'], ['4', '5', '6'], ['.', '8', '9']], (2, 0))
    ['.', '8', '9']
    """
    # PUT YOUR CODE HERE
    pass


def get_col(grid: tp.List[tp.List[str]], pos: tp.Tuple[int, int]) -> tp.List[str]:
    """ Возвращает все значения для номера столбца, указанного в pos

    >>> get_col([['1', '2', '.'], ['4', '5', '6'], ['7', '8', '9']], (0, 0))
    ['1', '4', '7']
    >>> get_col([['1', '2', '3'], ['4', '.', '6'], ['7', '8', '9']], (0, 1))
    ['2', '.', '8']
    >>> get_col([['1', '2', '3'], ['4', '5', '6'], ['.', '8', '9']], (0, 2))
    ['3', '6', '9']
    """
    # PUT YOUR CODE HERE
    pass


def get_block(grid: tp.List[tp.List[str]], pos: tp.Tuple[int, int]) -> tp.List[str]:
    """ Возвращает все значения из квадрата, в который попадает позиция pos

    >>> grid = read_sudoku('puzzle1.txt')
    >>> get_block(grid, (0, 1))
    ['5', '3', '.', '6', '.', '.', '.', '9', '8']
    >>> get_block(grid, (4, 7))
    ['.', '.', '3', '.', '.', '1', '.', '.', '6']
    >>> get_block(grid, (8, 8))
    ['2', '8', '.', '.', '.', '5', '.', '7', '9']
    """
    # PUT YOUR CODE HERE
    pass

Разберемся с тем, как представлена позиция в программе. Каждая позиция однозначно определяется номером строки и номером столбца, поэтому для ее представления удобно использовать кортеж. Вспомним, что кортеж это неизменяемый список. Позиция создается следующим образом:

>>> pos = (0, 0)
>>> row, col = pos
>>> row
0
>>> col
0

Обратите внимание, что для функций get_row() и get_col() приведены примеры в доктестах для доски размером 3*3. У нас же доска 9*9, но функции должны работать для доски любого размера. Функция get_block() возвращает все значения из квадрата, в который попадает позиция pos (всего 9 квадратов размером 3*3).

Не забудьте запустить тесты и сделать соответствующие коммиты:

(cs102) $ python -m unittest \
    test_sudoku.SudokuTestCase.test_get_col \
    test_sudoku.SudokuTestCase.test_get_row \
    test_sudoku.SudokuTestCase.test_get_block
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Алгоритм решения Судоку

Давайте наконец перейдем к решению самого Судоку. В шаблоне вы найдете функцию solve(), которая принимает один аргумент - пазл, а возвращает заполненную значениями доску:

def solve(grid: tp.List[tp.List[str]]) -> tp.Optional[tp.List[tp.List[str]]]:
    """ Поиск решения для указанного пазла.

    Как решать Судоку?
    1. Найти свободную позицию
    2. Найти все возможные значения, которые могут находиться на этой позиции
    3. Для каждого возможного значения:
        3.1. Поместить это значение на эту позицию
        3.2. Продолжить решать оставшуюся часть пазла

    >>> grid = read_sudoku('puzzle1.txt')
    >>> solve(grid)
    [['5', '3', '4', '6', '7', '8', '9', '1', '2'], ['6', '7', '2', '1', '9', '5', '3', '4', '8'], ['1', '9', '8', '3', '4', '2', '5', '6', '7'], ['8', '5', '9', '7', '6', '1', '4', '2', '3'], ['4', '2', '6', '8', '5', '3', '7', '9', '1'], ['7', '1', '3', '9', '2', '4', '8', '5', '6'], ['9', '6', '1', '5', '3', '7', '2', '8', '4'], ['2', '8', '7', '4', '1', '9', '6', '3', '5'], ['3', '4', '5', '2', '8', '6', '1', '7', '9']]
    """
    # PUT YOUR CODE HERE
    pass

Мы будем решать Судоку методом перебора (поиска) с возвратом. Общая схема этого метода заключается в следующем:

def Перебор(Ситуация):
    if Ситуация конечная:
        Завершающая обработка
    else:
        for Действие in Множество всех возможных действий:
            Применить Действие к Ситуация
            Перебор(Ситуация)
            откатить Действие назад

Решение Судоку чем-то похоже на задачу о возможных комбинациях:

def permutations(L: tp.List[tp.Any], result: tp.List[tp.Any]) -> None:
    if len(L) == 0:
        print(result)
    else:
        for i in range(len(L)):
            permutations(L[0:i] + L[i+1:], result + [L[i]])

>>> permutations([1,2,3], [])

Каждый раз мы удерживаем один элемент и перебираем все остальные. В Судоку все происходит точно также, сначала мы подставляем одно из возможных значений (пункт 2) для свободной позиции (пункт 1) и перебираем все остальные (пункт 3.2), то есть решаем более простую задачу. Например, вот простейшее «Судоку» (это не совсем Судоку конечно), которое мы можем заполнять значениями 1 или 2:

1.
.1

Находим первую свободную позицию (это окажется (0, 1)), затем значения которые можем на нее поставить (в данном случае только 2), вставляем это значение на указанную позицию и продолжаем решать уже более простое Судоку:

12
.1

И так продолжаем, пока не заполним все пустые клетки. В конце мы должны получить решение Судоку.

Не забудьте про базовые случаи для выхода из рекурсии, подумайте над вопросами:

  • Всегда ли есть свободная позиция?
  • Всегда ли есть возможные значения?

Поиск возможных решений

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

def find_empty_positions(grid: tp.List[tp.List[str]]) -> tp.Optional[tp.Tuple[int, int]]:
    """ Найти первую свободную позицию в пазле

    >>> find_empty_positions([['1', '2', '.'], ['4', '5', '6'], ['7', '8', '9']])
    (0, 2)
    >>> find_empty_positions([['1', '2', '3'], ['4', '.', '6'], ['7', '8', '9']])
    (1, 1)
    >>> find_empty_positions([['1', '2', '3'], ['4', '5', '6'], ['.', '8', '9']])
    (2, 0)
    """
    # PUT YOUR CODE HERE
    pass

Кроме поиска свободных позиций, также необходимо искать значения, которые на эту позицию можно поставить:

def find_possible_values(grid: tp.List[tp.List[str]], pos: tp.Tuple[int, int]) -> tp.Set[str]:
    """ Вернуть множество всех возможных значения для указанной позиции

    >>> grid = read_sudoku('puzzles/puzzle1.txt')
    >>> values = find_possible_values(grid, (0,2))
    >>> set(values) == {'1', '2', '4'}
    True
    >>> values = find_possible_values(grid, (4,7))
    >>> set(values) == {'2', '5', '9'}
    True
    """
    # PUT YOUR CODE HERE
    pass

Hint

Для решения этого задания используйте множества set.

Помните, что всего значений, которые мы можем поставить на указанную позицию, ровно 9, это числа 1,2,3,4,5,6,7,8,9. Но не каждое из этих чисел мы можем использовать (см. правила Судоку). В этой функции вы можете пользоваться написанными ранее функциями get_row(), get_col(), get_block().

К этому моменту функция solve() уже должна быть полностью рабочей:

(cs102) $ python -i sudoku.py
>>> grid = read_sudoku('puzzle1.txt')
>>> display(grid)
5 3 . |. 7 . |. . .
6 . . |1 9 5 |. . .
. 9 8 |. . . |. 6 .
------+------+------
8 . . |. 6 . |. . 3
4 . . |8 . 3 |. . 1
7 . . |. 2 . |. . 6
------+------+------
. 6 . |. . . |2 8 .
. . . |4 1 9 |. . 5
. . . |. 8 . |. 7 9

>>> solution = solve(grid)
>>> display(solution)
5 3 4 |6 7 8 |9 1 2
6 7 2 |1 9 5 |3 4 8
1 9 8 |3 4 2 |5 6 7
------+------+------
8 5 9 |7 6 1 |4 2 3
4 2 6 |8 5 3 |7 9 1
7 1 3 |9 2 4 |8 5 6
------+------+------
9 6 1 |5 3 7 |2 8 4
2 8 7 |4 1 9 |6 3 5
3 4 5 |2 8 6 |1 7 9

Проверка решения

Мы получили решение, но является ли оно верным? Давайте напишем функцию check_solution(), которая проверяет наше решение:

def check_solution(solution: tp.List[tp.List[str]]) -> bool:
    """ Если решение solution верно, то вернуть True, в противном случае False """
    # PUT YOUR CODE HERE
    pass

Решение оказывается верным, если ни в одной строке, ни в одном столбце, ни в квадрате не повторяются значения:

>>> check_solution(solution)
True

Когда вы закончите работать над этой функцией, то запустите программу следующим образом:

(cs102) $ python sudoku.py
...
Solution is correct
...
Solution is correct
...
Solution is correct

Если вы встретили сообщение Ooops, то значит, что одно или все ваши решения оказались не верны. Если же вы уверены в своем решении, то проверьте корректность функции check_solution().

Генерация новых пазлов

Напишите функцию generate_sudoku(N), которая создает новый судоку, заполненный на N элементов:

def generate_sudoku(N: int) -> tp.List[tp.List[str]]:
    """ Генерация судоку заполненного на N элементов
    >>> grid = generate_sudoku(40)
    >>> sum(1 for row in grid for e in row if e == '.')
    41
    >>> solution = solve(grid)
    >>> check_solution(solution)
    True
    >>> grid = generate_sudoku(1000)
    >>> sum(1 for row in grid for e in row if e == '.')
    0
    >>> solution = solve(grid)
    >>> check_solution(solution)
    True
    >>> grid = generate_sudoku(0)
    >>> sum(1 for row in grid for e in row if e == '.')
    81
    >>> solution = solve(grid)
    >>> check_solution(solution)
    True
    """
    # PUT YOUR CODE HERE
    pass

Пример использования функции:

>>> sudoku = generate_sudoku(40)
>>> display(sudoku)
. 3 . |6 . 8 |. . .
6 . 2 |. 9 . |3 4 .
. . . |3 4 . |. 6 7
------+------+------
. 5 . |7 . 1 |. 2 .
. . 6 |8 . 3 |. 9 1
7 . 3 |. 2 . |8 . 6
------+------+------
9 . . |5 . 7 |2 . 4
. . 7 |4 . . |. . .
3 4 5 |. 8 6 |1 7 .

Приложение

Вы заметили, что второй пазл решается дольше остальных?

import time

if __name__ == "__main__":
    for filename in ("puzzle1.txt", "puzzle2.txt", "puzzle3.txt"):
        grid = read_sudoku(filename)
        start = time.time()
        solve(grid)
        end = time.time()
        print(f"{filename}: {end-start}")

На моей машине результат получился таким (от запуска к запуску вы будете получать разные результаты):

puzzle1.txt: 0.05907106399536133
puzzle2.txt: 7.427937984466553
puzzle3.txt: 0.43831491470336914

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

Давайте попробуем воспользоватся модулем threading, чтобы каждый пазл решался в отдельном потоке:

import threading

def run_solve(filename: str) -> None:
    grid = read_sudoku(filename)
    start = time.time()
    solve(grid)
    end = time.time()
    print(f"{filename}: {end-start}")


if __name__ == "__main__":
    for filename in ("puzzle1.txt", "puzzle2.txt", "puzzle3.txt"):
        t = threading.Thread(target=run_solve, args=(filename,))
        t.start()
puzzle1.txt required 0.013156652450561523
puzzle3.txt required 0.7069487571716309
puzzle2.txt required 7.912024021148682

Из результатов видно, что решение для puzzle3 мы получили раньше чем для puzzle2, но тем не менее они не были решены параллельно, как можно было бы подумать, и связано это с таким понятием как GIL.

Чтобы решать пазлы параллельно (за исключением разных если) мы можем воспользоваться модулем multiprocessing:

import multiprocessing

if __name__ == "__main__":
    for filename in ("puzzle1.txt", "puzzle2.txt", "puzzle3.txt"):
        p = multiprocessing.Process(target=run_solve, args=(filename,))
        p.start()
puzzle1.txt: 0.043260812759399414
puzzle3.txt: 0.10617399215698242
puzzle2.txt: 6.155700922012329

Мы получили примерно тот же результат. В чем тогда преимущество multiprocessing перед threading? Чтобы лучше ощутить разницу в работе этих двух модулей попробуйте поэкспериментировать с числом решаемых пазлов и их сложностью. Список сложных пазлов можно найти в репозитории в файле hard_puzzles.txt.


Последнее обновление: 10 сентября 2020 г.

Комментарии