image

В моем школьном детстве одним из развлечений, помню, было заполнить в тетрадке в клеточку ходом коня какой-нибудь прямоугольник m x n. Обычно это был квадрат 10х10 и вот, закусив губу (высунув язык), вписываешь старательно циферки в клеточки. На очередном шаге понимаешь, что зашел в тупик и начинаешь все с начала. Это было давно и неправда, но, изучая язык Haskell, я наткнулся на эту задачу в общем перечне https://wiki.haskell.org/99_questions за номером 91.

Собственно как можно попытаться решить эту задачу. Вспомним заповедь автора книжки «Изучай Хаскель во имя добра!», которая звучит как «думай рекурсивно». То есть берем какую-то клетку из нашего набора клеток, как-то заполняем оставшееся и проверяем, что исходная клетка соединяется с заполненным «ходом коня». Ну а «как-то заполняем оставшееся» и означает уйти в рекурсию с уменьшенным набором клеток. Рано или поздно набор сократится до единственного элемента, это и будет базой нашей рекурсии. Ну а в коде с помощью конструктора списков это можно записать, например, так:

knightComb [x] = [[x]]
knightComb xs = [x:ks | 
    x <- xs, 
    ks <- knightComb $ delete x xs, 
    near x $ head ks]
    where near (x1,y1) (x2,y2) = abs ((x2-x1)*(y2-y1)) == 2

Функция delete в базе языка отсутствует, но ее можно подгрузить из модуля Data.List.

На вход функции комбинирования подается набор клеток в виде списка пар координат (x,y), а на выходе – список всех возможных путей обхода этого набора. Если же все варианты не требуются и достаточно одного произвольного, можно взять первый с помощью функции head и тогда, в силу ленивости Хаскеля, остальные вычисляться и не будут. НО! Гладко было на бумаге. Алгоритм действительно работает и квадрат 3x3 без центральной клетки заполняет «на ура». На прямоугольнике 3x4 первый результат появляется через пару минут, но добавление всего одной клетки увеличивает это время до получаса. Про большие пространства можно даже не заикаться.

Ну и, в принципе, результат объясним. Проверка правильности выбора начальной клетки производится после ухода в рекурсию, клеток изначально много, а подходит далеко не каждая, поэтому и сложность перебора, несмотря на ленивость, оказывается порядка N!.. Справедливости ради, если мы на вход подадим уже упорядоченную последовательность, то тут же на выходе ее и получим, так что ленивость таки работает, просто нужно как-то ограничить лишний перебор.

Как именно? Первое, что приходит в голову – это брать изначально не произвольную клетку, а такую, в которую можно попасть из предыдущего хода. Т.е. на вход рекурсии кроме набора свободных клеток передаем координаты последней занятой, по ней строим список возможных ходов, проверяем, что эти клетки свободны и снова уходим в рекурсию с новым ходом, пока клетки не закончатся.

knightsTo x [] = [[x]]
knightsTo x xs = [x:ks | 
    k  <- next x,  
    elem k xs, 
    ks <- knightsTo k $ delete k xs]
    where next (x,y) = [(x+dx,y+dy) | (dx,dy) <- 
            [(-2,-1),(-2,1),(-1,-2),(-1,2),(1,-2),(1,2),(2,-1),(2,1)] ]

Как-то так. Ну и для удобства напишем интерфейс

knights n = 
    head . knightsTo (1,1) $ tail [(x,y) | x <- [1..n], y <- [1..n]]

И, надо сказать, это уже заметный шаг вперед!

*Main> knights 5
[(1,1),(2,3),(1,5),(3,4),(1,3),(2,1),(4,2),(5,4),(3,5),(1,4),(2,2),(4,1),(3,3),(2,5),(4,4),(5,2),(3,1),(1,2),(2,4),(4,5),(5,3),(3,2),(5,1),(4,3),(5,5)]

*Main> knights 6
[(1,1),(2,3),(1,5),(3,4),(1,3),(2,1),(3,3),(1,2),(2,4),(1,6),(3,5),(5,6),(6,4),(5,2),(3,1),(4,3),(6,2),(4,1),(2,2),(1,4),(2,6),(4,5),(6,6),(5,4),(4,2),(6,1),(5,3),(3,2),(5,1),(6,3),(5,5),(3,6),(4,4),(2,5),(4,6),(6,5)]

*Main> knights 7
[(1,1),(2,3),(1,5),(2,7),(3,5),(1,4),(2,2),(3,4),(1,3),(2,1),(3,3),(1,2),(2,4),(1,6),(3,7),(2,5),(1,7),(3,6),(4,4),(3,2),(5,1),(4,3),(3,1),(5,2),(6,4),(7,2),(5,3),(4,1),(6,2),(7,4),(5,5),(7,6),(5,7),(4,5),(2,6),(4,7),(6,6),(5,4),(4,6),(6,7),(7,5),(5,6),(7,7),(6,5),(7,3),(6,1),(4,2),(6,3),(7,1)]

*Main> knights 8
[(1,1),(2,3),(1,5),(2,7),(3,5),(1,4),(2,2),(3,4),(1,3),(2,1),(3,3),(1,2),(2,4),(1,6),(2,8),(3,6),(1,7),(2,5),(3,7),(1,8),(2,6),(3,8),(4,6),(5,4),(4,2),(6,1),(5,3),(3,2),(4,4),(5,2),(3,1),(4,3),(5,1),(7,2),(6,4),(4,5),(5,7),(7,8),(8,6),(6,5),(8,4),(7,6),(8,8),(6,7),(4,8),(5,6),(6,8),(4,7),(5,5),(7,4),(8,2),(6,3),(7,1),(8,3),(7,5),(8,7),(6,6),(5,8),(7,7),(8,5),(7,3),(8,1),(6,2),(4,1)]

А если набраться терпения, то и

*Main> knights 9
[(1,1),(2,3),(1,5),(2,7),(1,9),(3,8),(1,7),(2,5),(1,3),(2,1),(3,3),(1,2),(2,4),(1,6),(2,8),(3,6),(4,4),(3,2),(5,1),(4,3),(2,2),(1,4),(2,6),(1,8),(3,7),(2,9),(4,8),(5,6),(3,5),(4,7),(3,9),(5,8),(4,6),(3,4),(4,2),(5,4),(6,2),(4,1),(5,3),(4,5),(5,7),(4,9),(6,8),(7,6),(5,5),(6,3),(7,1),(9,2),(8,4),(6,5),(8,6),(9,8),(7,9),(6,7),(5,9),(7,8),(9,9),(8,7),(6,6),(7,4),(9,5),(8,3),(9,1),(7,2),(9,3),(8,1),(7,3),(6,1),(8,2),(9,4),(7,5),(9,6),(8,8),(6,9),(7,7),(8,9),(9,7),(8,5),(6,4),(5,2),(3,1)]

На квадрате 10x10 я результата не дождался. Но, тем не менее, на каждом шаге рекурсии количество новых вызовов теперь зависит не от количества свободных клеток, а от количества возможных ходов, которых точно не больше восьми (точнее даже семи, из одного мы только что пришли), чаще гораздо меньше. Т.е. сложность алгоритма стала O(p^N). Тоже не полином, но уже продвинулись.

Функция next может выдавать лишние ходы, которые потом, правда, отсекаются проверкой elem, но восемь раз пробегать список может быть неэффективно. Разумней кажется пробежать список свободных клеток один раз и отфильтровать подходящие.

knightsTo x [] = [[x]]
knightsTo x xs = [x:ks | 
    k  <- filter (near x) xs, 
    ks <- knightsTo k $ delete k xs]
    where near (x1,y1) (x2,y2) = abs ((x2-x1)*(y2-y1)) == 2

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

Как еще можно ограничить лишний перебор? Можно, например, на каждом шаге рекурсии организовать дополнительную проверку входного списка на отсутствие одиноких клеток. Каждая незаполненная клетка во входном наборе должна иметь хотя бы одного свободного соседа, если только весь набор не состоит из одной клетки. Но после этой проверки могут оставаться брошенными пары, тройки и т.д. клеток. И, если обобщать, то необходимо проверять на связность всю оставшуюся область. А общая связность, в свою очередь означает, что из текущей позиции должен существовать путь в любую оставшуюся клетку. Такую проверку можно осуществить, например, алгоритмом обхода «в ширину», для чего напишем вспомогательную функцию.

connected _ [] = True
connected [] _ = False
connected (x:xs) ws = 
    let ns = filter (near x) ws in connected (xs++ns) (ws\\ns)

У функции на входе два списка: проверяемых и еще непроверенных вершин, и если второй опустошается, то граф область является связной.

Добавляем новую проверку в основную функцию

knightsTo x [] = [[x]]
knightsTo x xs = [x:ks | 
    connected [x] xs,
    k <- filter (near x) xs, 
    ks <- knightsTo k $ delete k xs]

А функцию проверки соседства описываем глобально

near (x1,y1) (x2,y2) = abs ((x2-x1)*(y2-y1)) == 2

Оно неплохо работает! Решение для квадрата 9x9 теперь находится быстрее времени, которое раньше требовалось для квадрата 8x8. И это не смотря на то, что сложность дополнительной проверки квадратично зависит от входных данных. Но поскольку общая сложность алгоритма экспоненциальна, отбрасывание лишних ветвей даже такой ценой позволяет заметно сократить вычисления.

Если проанализировать выдаваемые решения, то можно заметить, что алгоритм выбора ходов старается плотно заполнить левую часть области, и, по крайней мере, вначале это у него неплохо получается, что наталкивает на мысль разбивать большие области на более мелкие компакты. Отличная идея! Наш проблемный квадрат 10x10 – это четыре квадрата 5x5, а их мы теперь щелкаем как орешки. Необходимо разве что научиться не только стартовать из определенной точки, но и заканчивать где укажем. Для этого модифицируем интерфейсную функцию

knights5 ((m,n), st, fin) = head . filter (end fin) . 
    knightsTo st $ delete st [(x,y) | x <- [m..m+4], y <- [n..n+4]]
    where end x xs = x == last xs

Размер области фиксируем, на вход подаем координаты левого нижнего угла квадрата 5x5, координаты начала искомого пути и его окончания. И четыре раза применяем эту функцию к нужным параметрам.

knights10 = concatMap knights5 
    [((1,1),(5,3),(5,5)), ((1,6),(3,6),(5,6)),
     ((6,6),(6,8),(6,6)), ((6,1),(8,5),(6,5))]

Вуаля!

*Main> knights10
[(5,3),(4,1),(2,2),(1,4),(3,3),(4,5),(2,4),(1,2),(3,1),(5,2),(4,4),(2,5),(1,3),(2,1),(4,2),(5,4),(3,5),(4,3),(5,1),(3,2),(1,1),(2,3),(1,5),(3,4),(5,5),(3,6),(1,7),(2,9),(4,10),(3,8),(5,7),(4,9),(2,10),(1,8),(2,6),(4,7),(5,9),(3,10),(1,9),(2,7),(4,6),(5,8),(3,7),(1,6),(2,8),(1,10),(3,9),(5,10),(4,8),(5,6),(6,8),(7,6),(8,8),(7,10),(9,9),(10,7),(8,6),(6,7),(7,9),(9,10),(10,8),(9,6),(7,7),(6,9),(8,10),(10,9),(9,7),(7,8),(6,10),(8,9),(10,10),(9,8),(10,6),(8,7),(6,6),(8,5),(6,4),(7,2),(9,1),(8,3),(10,4),(9,2),(7,1),(6,3),(7,5),(9,4),(10,2),(8,1),(6,2),(7,4),(9,5),(10,3),(8,4),(10,5),(9,3),(10,1),(8,2),(6,1),(7,3),(6,5)]

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

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

P.S. В комментариях подсказали интересную эвристику. Из списка ходов в первую очередь выбирать ход с наименьшим количеством продолжений. При этом дополнительная проверка на связность с квадратичной сложностью начинает мешать и тормозит процесс.

Основную функцию изменить несложно

knightsTo x [] = [[x]]
knightsTo x xs = [x:ks | 
    k <- ksort xs $ filter (near x) xs, 
    ks <- knightsTo k $ delete k xs]

Дело за малым, осталось расписать функцию сортировки ksort, которая упорядочивает список соседей по числу «соседей этих соседей». Быстро, в лоб, это можно написать, например, так

ksort xs ks = map snd $ sort $ zip 
    (map ((\ ys y -> length $ filter (near y) ys) xs) ks) ks

Для каждого соседа из ks находим список его возможных ходов и вычисляем его длину. Длины компонуем в пары с координатами соседей. Штатный sort сортирует по первому элементу, т.е. по длине, после чего отбрасываем первые элементы пар, оставляя только вторые.

Возможно несколько криво, и по-хорошему, для наглядности, ее стоит разбить на несколько функций, но главное — результат. Какой там 10x10 — 70x70! И даже 80x80, если чуть подождать. Вот 100x100 уже не дождался.

Комментарии (9)


  1. wormball
    24.09.2017 15:03
    +2

    Помнится, я в 2002 году на трубо-паскале делал 15*15 за несколько секунд на 80486.


    1. voyager-1
      24.09.2017 17:20

      Когда-нибудь эту же задачу на квадрате 5*5 можно будет закодить в одну строку на каком-нибудь ядрёном потомке Haskell-я и Ruby. Но она всё равно будет требовать для выполнения несколько секунд работы квантового компьютера на миллион кубитов.

      Да, и по поводу строки
      Никто не говорил что она будет короче этой)


  1. aamonster
    24.09.2017 21:56
    +2

    Стоп. Вы ищете не все решения, а только одно, я правильно понял?
    Тогда следует использовать эвристику — стараться идти в клетку, из которой доступно меньше всего ходов.


    Если попробуете — расскажите, какой эффект дало.


    1. shale Автор
      25.09.2017 00:28

      Спасибо за подсказку, прекрасно работает. Подредактировал статью.


  1. WinPooh73
    24.09.2017 22:28
    +2

    В зачитанной в детстве до дыр книге Гика "Шахматы и математика" эта задача подробно разбиралась. Запомнилась основная эвристика — правило Варнсдорфа: из возможных клеток очередной ход коня надо делать на ту, откуда есть МЕНЬШЕ возможных продолжений. Несмотря на всю контр-интуитивность, стратегия работала как часы на самых разных досках! Вы не пробовали добавить эту эвристику в свою программу и сравнить эффективность алгоритмов?


    1. shale Автор
      24.09.2017 22:44
      +1

      Интересная идея. В принципе реализовать несложно. Надо отсортировать список возможных ходов по определенному признаку. А сложность сортировки даже меньше квадрата


      1. shale Автор
        24.09.2017 23:17
        +2

        Вау! Невероятно! Работает! 25x25 меньше, чем за минуту. Спасибо, придется подкорректировать статью.


        1. shale Автор
          24.09.2017 23:23
          +2

          А если убрать проверку на связность, то и 50x50.


    1. shale Автор
      25.09.2017 08:12
      +1

      И отдельное спасибо за книжку.