Язык, созданный для решения головоломок!
Пролог — довольно интересный язык программирования, который заставит вас мыслить совершенно иначе, чем вы привыкли, независимо от того, исходите ли вы из процедурной, функциональной и/или объектно-ориентированной парадигмы.
Это декларативный язык программирования 1970-х годов, корни которого лежат в логике первого порядка. Это означает, что вы декларируетефакты и правила как программист, запускаете запрос, и компьютер выдает вам действительные результаты из область поиска.
Все это очень расплывчато, если вы на самом деле не знаете, что происходит, поэтому давайте погрузимся и объясним концепции по ходу дела.
Основы
Атомы, или константы, начинаются со строчных букв или представляют собой целые числа: кот, джон, 1, 2, 3, машина. Переменные начинаются с прописных букв или символов подчеркивания: X, Y, Z, Result, _var, _1. До сих пор мы видели термины. У нас также могут быть составные термины. Давайте используем составной термин для объявления факта:
student(saif, nus). student(saif, uoft).
Первый факт гласит, что saif
является student
из nus
. Обратите внимание на точку в конце «предложения». Мы можем вставить больше фактов в программу. Мы называем это предложением, где student
является предикатом.
Теперь мы можем выполнить запрос к этим предложениям, используя переменную. Давайте посмотрим, как это делается.[Строки с ?- — это то, что я печатаю, % — начало комментариев, остальное — то, что говорит Пролог. ]
?- student(saif, X). X = nus ; X = uoft.
Пролог вычислил, что X должен быть либо nus, либо uoft!
Наиболее проницательные из вас могли уже заметить, что Пролог — это нетипизированный язык. Совершенно верно.
Отношения против функций
Прежде чем углубиться, полезно сделать шаг назад и понять, что мы неопределяем функции в Прологе. Мы объявляемотношения. Какая разница?
Допустим, у нас есть функция add(a, b) в *insert_favorite_language*. Эта функция принимает 2 целых числа и дает нам результат. Таким образом, add(1, 2) вернет 3.
В Прологе вы бы запросили add(1, 2, R), и язык сказал бы вам, что R = 3. «Э-э, ладно…» Я слышал, вы говорите. Но это всего лишь один из способов выполнить запрос; мы можем запустить его еще 3 способами:
% checking / assertion ?- add(1, 2, 3). true. ?- add(1, 2, 4). false. % subtracting! ?- add(X, 2, 3). X = 1. % and enumerating all possible answers! ?- add(X, Y, 3). ... (infinitely many combinations of X and Y make 3!)
Сладкий! Хотя кажется, что этот последний шаг занимает ужасно много времени :shrugs: Мы можем изменить его, чтобы он занимал меньше времени, предоставив Prolog больше фактов о Vars.
?- add(X, Y, 3), X > -1, Y > -1. X = 0 Y = 3; X = 1 Y = 2; X = 2 Y = 1; X = 3 Y = 0;
Обратите внимание, здесь я впервые использовал оператор AND: это запятая. Вскоре мы увидим оператор ИЛИ в виде точки с запятой.
Логика, наконец!
% example courtesy, CS2104, NUS father(john, mary). father(john, tom). father(kevin, john). mother(eva, tom). mother(eva, mary). mother(cristina, john). male(john). male(kevin). male(tom). female(eva). female(cristina). female(mary).
И давайте спросим об этих фактах:
% Who's mary's father? ?- father(X, mary). X = john. % Who are eva's children? ?- mother(eva, C). C = tom ; C = mary. % Who is eva's daughter? ?- mother(eva, C), female(C). C = mary.
Давайте научим Пролог еще кое-чему о том, как работают семьи.
daughter(X,Y) :- female(X), parent(Y,X). grandparent(X,Y) :- parent(X,Z), parent(Z,Y). brother(X,Y) :- male(X),sibling(X,Y). % first use of OR and HORN CLAUSE parent(X, Y) :- father(X, Y); mother(X, Y). % prolog has the standard mathematical operators sibling(X,Y) :- parent(Z,X), parent(Z,Y), X\==Y.
:-
обозначает «Оговорку о рогах». Я прочитал это как намек. Например, я прочитал родительское предложение как «ЕслиX является отцом Y ИЛИ X является матерью Y, тоX является родителем Y».
Мы тоже можем делать рекурсии! Далее говорится, что предком Y является родитель Y X или Z (где Z — предок X).
ancestor(X,Y) :- parent(X,Y). ancestor(X,Y) :- parent(X,Z),ancestor(Z,Y).
Мы должны быть осторожны; мы можем застрять в бесконечном цикле с левой рекурсией в следующем примере:
% DO NOT DO THIS ancestor(X,Y) :- parent(X,Y). ancestor(X,Y) :- ancestor(Z,Y),parent(X,Z).
НО КАК? !— Объединение и разрешение
Невероятная способность Пролога отвечать на вопросы (даже на такие простые, как этот) исходит из его алгоритмов Разрешения и Объединения.
Пролог делает это с помощью унификации —по сути, пытаясь заменить константы на места переменных (из известных фактов, предложений и сопоставления с образцом). Пока конечный результат замены непротиворечив, это один из возможных ответов.
Затем Пролог возвращается к квадрату 1 и снова пробует его со всеми другими возможными значениями для каждой переменной, по существу выполняя поиск в глубину, пока не исчерпает все пространство поиска. Этот процесс ответа на запрос называется разрешением. Все, что можно доказать, является правдой; все остальное ложно (предположение о замкнутом мире).
Списки
Нам нужно узнать еще кое-что, прежде чем запачкать руки настоящими программами на Прологе. Во-первых, списки.
[this, [], is, a, random_term(X), lst]. % joining 2 lists together append([], Y, Y). append([X|Xs], Y, [X, Rs]) :- append(Xs, Y, Rs).
Если вы внимательно посмотрите на отношение добавления, то увидите, что оно использует сопоставление с образцом в стиле Haskell и образец-накопитель. Вы обнаружите, что это естественный способ мышления, если вы пришли из функционального языка. [X|Xs] используется для представления списка, состоящего из 2 частей: X — начало списка, а Xs — остальная часть списка. 2 строки в основном говорят:
- Если первый список пуст, результатом будет второй.
- В противном случае результатом будет голова первого списка + результат соединения хвоста первого списка со всем вторым списком.
Давайте запустим несколько запросов с нашей новой функцией добавления:
?- append([1,2,3],[4,5],Z). Z = [1,2,3,4,5]. % computing the difference of 2 lists ?- append([1,2,3],Y,[1,2,3,4,5]). Y = [4,5] % splitting a list ?- append(X,Y,[1,2]). X=[], Y=[1,2]; X=[1], Y=[2]; X=[1,2], Y=[]. % finding prefix of a list (_ means I don't care about this). ?- append(X,_,[1,2,3]). X=[]; X=[1]; X=[1,2]; X=[1,2,3].
Конечно, некоторые из этих основных отношений были определены для вас. Вы можете посмотреть реализацию reverse() из SWI-Prolog здесь. Вот вам небольшая головоломка. Каков результат reverse(X, X).
?
Давайте свяжем это с Резолюцией и Объединением. Рассмотрим отношение sel(elem, […]), которое принимает список и сообщает нам, принадлежит ли элемент этому списку:
Управление возвратом с помощью оператора Cut
Рассмотрим отношение, которое принимает список и удаляет дубликаты:
remDupl([], []). remDupl([H|T], R) :- sel(H, T), remDupl(T, R). remDupl([H|T], [H|R]) :- not(sel(H, T)), remDupl(T, R).
Довольно просто. Первая строка — базовый вариант. Вторая строка говорит, что если H найдено в T, отбросить его. Третья строка говорит, что если H не найдено в T, то добавить H к результату и продолжить рекурсию.
На самом деле мы управляем ветвью дерева поиска, используя это первое предложение sel(). Оказывается, это повторяющийся вариант использования в Прологе, поэтому мы используем оператор «вырезать», обозначаемый !
. Прочитайте это так: если вы выберете эту ветвь, не беспокойтесь о других вариантах на этом уровне.
remDupl([], []). remDupl([H|T], R) :- sel(H, T),!, remDupl(T, R). remDupl([H|T], [H|R]) :- remDupl(T, R).
Более лаконично? Читабельнее? Вам решать. Я просто говорю, что он существует.
Математические операторы и крошечная задачка.
У нас также есть все обычные математические операторы: ‹, ›, ≥, ≤, =\=, =:=, +, -, *. Давайте попробуем написать факториальную функцию:
% declaration fact(0, 1). fact(N, R) :- N>0, M is N-1, fact(M, R1), R is R1*N.
fact(15, R) правильно указывает, что R = 1307674368000. Не верите мне? Я подожду.
К сожалению, `fact(X, 120)` не даст нам 5. Проблема в том, что математические операторы можно использовать только одним способом. Поэтому, когда мы говорим M is N-1
, N должно быть известно, чтобы это работало :(
Мы решим эту проблему, используя мощный модуль Constraint Logic Programming, который делает кодирование на Прологе гораздо более интересным.
Линейное программирование с ограничениями
И последнее, что вы должны знать, чтобы сделать Prolog полезным: решение ограничений. Многие головоломки, которые мы решаем в реальной жизни, основаны на конечной области. Если вы знакомы с линейным программированием (или изучали линейную алгебру), вы знаете, как мы сужаем пространство поиска для набора уравнений (до такой степени, что грубая сила становится практичной).
Пакет довольно прост в использовании: в нем есть операторы, похожие на знакомые вам математические операторы (только с префиксом #), и несколько довольно изящных соотношений. Каноническим примером модуля CLP является головоломка ОТПРАВИТЬ+БОЛЬШЕ=ДЕНЬГИ. Давайте посмотрим на код здесь и поймем, что он делает. Если вы не знакомы, Загадка «отправить больше денег — типичный пример проблемы ограничений. Это равнозначно назначению разных цифр от 0 до 9 переменным [S,E,N,D,M,O,R,Y] таким образом, что SEND+MORE=MONEY”:
use_module(library(clpfd)). sendmoremoney(Vars) :- Vars = [S,E,N,D,M,O,R,Y], Vars ins 0..9, S #\= 0, M #\= 0, all_different(Vars), 1000*S + 100*E + 10*N + D + 1000*M + 100*O + 10*R + E #= 10000*M + 1000*O + 100*N + 10*E + Y.
Мы видим отношение ins
из clp, говорящее, что все переменные в списке Vars
должны принимать значения от 0 до 9. Следующие 2 строки говорят, что первые цифры слов не могут быть 0 (решение становится тривиальным). В следующей строке используется отношение all_different
, чтобы убедиться, что все буквы получают разные назначения. Последняя строка гарантирует, что Пролог понимает, что положение буквы имеет значение и что два слова складываются (+) в (#=) ДЕНЬГИ.
Еще одно преимущество, которое мы получаем от использования clpfd
, заключается в том, что мы можем выполнять наши отношения в обоих направлениях, если мы не используем арифметические операторы, которые мы видели ранее. Вот пример факториальной функции, которую можно запустить в обоих направлениях:
% just note that the recursive call comes last. cfact(0, 1). cfact(N, R) :- N #> 0, M #= N-1, R #= N*R1, cfact(M, R1).
Решатель судоку
Все еще здесь? Крутое печенье, ты. Ладно, пора погрузиться в настоящую проблему. Сделаем решатель Судоку. О, подожди. В SWI-Prolog уже есть один. Не беспокойтесь, решение для судоку 9 * 9 с 3 * 3 блоками. Мы обобщим его на любую N*N судоку с блоками B_R*B_C. Давайте посмотрим на исходный код решателя и посмотрим, что нам нужно изменить:
sudoku(Rows) :- length(Rows, 9), maplist(same_length(Rows), Rows), append(Rows, Vs), Vs ins 1..9, maplist(all_distinct, Rows), transpose(Rows, Columns), maplist(all_distinct, Columns), Rows = [As,Bs,Cs,Ds,Es,Fs,Gs,Hs,Is], blocks(As, Bs, Cs), blocks(Ds, Es, Fs), blocks(Gs, Hs, Is).
- Первое предложение гарантирует, что у нас есть 9 строк.
- Второе предложение имеет отношение maplist(). Он принимает каждую из 9 строк и предоставляет их каррированной функции same_length(Rows). Поскольку мы имеем дело с квадратными матрицами, эта строка в основном говорит: размер каждой строки должен быть равен количеству столбцов.
- Отношение append/2 берет строки и разворачивает их, чтобы создать массив из 81 элемента с именем Vs. Каждый элемент в Vs должен быть от 1 до 9 (только целые числа). Помните, если это так, каждый элемент в Rows (т.е. матрица) также должен быть от 1 до 9.
- В следующей строке снова используется список карт. В нем говорится: «Для каждой строки в строках каждый элемент в строке должен быть отличным».
- Транспонировать строки в столбцы. Мы запускаем тот же список карт. Это гарантирует, что все столбцы также различны! Умная.
На данный момент мы убедились, что все строки и столбцы имеют номера от 1 до 9, и ни один номер не повторяется ни в одном столбце/строке. Последнее, что нам нужно сделать, это убедиться, что ни одно число не повторяется в мини-сетках 3*3. Вот что делает оставшаяся часть отношения:
- Затем мы даем каждой строке имя: As, Bs, Cs и так далее.
- Затем мы говорим, что первые 3 строки составляют блок. Следующие 3 составляют еще один блок. И последние 3 составляют последний блок.
Хорошо, а что такое блок? Рад, что вы спросили.
blocks([], [], []). blocks([N1,N2,N3|Ns1], [N4,N5,N6|Ns2], [N7,N8,N9|Ns3]) :- all_distinct([N1,N2,N3,N4,N5,N6,N7,N8,N9]), blocks(Ns1, Ns2, Ns3).
- Блок состоит из 3 списков по 9 элементов в каждом. Пустые списки на всех 3 действительны.
- Чтобы блок был действительным, первые 3 элемента каждого списка должны составлять список, в котором каждый элемент отличается. И это условие должно выполняться для остальных оставшихся элементов каждого списка. Гений!
Давайте объявим проблемную сетку 9x9, редко заполненную, и посмотрим, какое решение предлагает Пролог:
problem(1, [[_,_,_,_,_,_,_,_,_], [_,_,_,_,_,3,_,8,5], [_,_,1,_,2,_,_,_,_], [_,_,_,5,_,7,_,_,_], [_,_,4,_,_,_,1,_,_], [_,9,_,_,_,_,_,_,_], [5,_,_,_,_,_,_,7,3], [_,_,2,_,1,_,_,_,_], [_,_,_,_,4,_,_,_,9]]). % Rows represent the grid of problem 1, where _ denotes unknown. % The given numbers give additional constraints. % Make sure you fill up the unknowns with the rules described in % sudoku. % After that, print each row. ?- problem(1, Rows), sudoku(Rows), maplist(portray_clause, Rows). [9, 8, 7, 6, 5, 4, 3, 2, 1]. [2, 4, 6, 1, 7, 3, 9, 8, 5]. [3, 5, 1, 9, 2, 8, 7, 4, 6]. [1, 2, 8, 5, 3, 7, 6, 9, 4]. [6, 3, 4, 8, 9, 2, 1, 5, 7]. [7, 9, 5, 4, 6, 1, 8, 3, 2]. [5, 1, 9, 2, 8, 6, 4, 7, 3]. [4, 7, 2, 3, 1, 9, 5, 6, 8]. [8, 6, 3, 7, 4, 5, 2, 1, 9].
Лучший решатель судоку!
Предупреждение: следующее заняло у меня 2 дня размышлений. Учитывая, что это была моя первая программа на Прологе, которую я написал, я очень горжусь ею! Это было настоящим упражнением в обдумывании сложных проблем, сохранении хладнокровия и повторении попыток снова и снова, пока я не найду правильную декомпозицию.
Чтобы обобщить это решение, мы должны найти не жестко закодированные способы сделать по существу одно и то же для любых N, B_R и B_C.
Все, кроме двух последних строк, исправить несложно. Нам просто нужна новая переменная N и заменить 9 на N. Большая часть жесткого кода находится в последних двух строках.
Мы не можем давать произвольные имена строкам, поэтому нам нужно разделить сетку, используя строки B_R на группу.
/* Result = first N elements of Src List, Rest is Suffix. */ take_firstN(0, Src, [], Src) :- !. take_firstN(N, [H|T], [H|P], Suffix) :- N #> 0, M #= N-1, take_firstN(M, T, P, Suffix). /* Partitions our matrix into groups with N rows per group */ part(_, [], []) :- !. part(N, Rows, [Group | RestGroups]) :- take_firstN(N, Rows, Group, RowsLeft), part(N, RowsLeft, RestGroups).
- Мы создали отношение, которое разбивает список на первые X элементов и остальное. Элементы списка не имеют значения — имеет значение только их порядок. Тот факт, что каждый элемент сам по себе является строкой, не имеет значения для отношения take_firstN().
- Затем мы используем это вспомогательное отношение для записи part(), которая создает разделы строк B_R в каждом разделе — учитывая всю сетку.
Теперь нам нужно передать каждый из этих разделов другой функции, которая обеспечивает допустимость мини-блоков.
/* Take first N elements of each of M lists into M lists. RestOf contains suffix of each of those lists (2D) */ take_firstN_fromEach(_, [], [], []) :- !. take_firstN_fromEach(N, [H|T], [Result|RestOfResults], [RestOfH|RestOfT]) :- take_firstN(N, H, Result, RestOfH), /* Take N from first list */ take_firstN_fromEach(N, T, RestOfResults, RestOfT). /* Take N from other lists */ valid_blocks(_, [X|_]) :- X = [], !. valid_blocks(B_C, Partition) :- take_firstN_fromEach(B_C, Partition, Grid, OtherGrids), flatten(Grid, FlatGrid), all_distinct(FlatGrid), valid_blocks(B_C, OtherGrids).
- take_firstN_fromEach берет раздел, извлекает элементы B_C из каждой строки, а затем гарантирует, что все эти элементы B_C*B_R различны. Сладкий!
Все, что осталось, это собрать воедино нашу связь судоку. Очень похоже на оригинал!
/* Generalized Sudoku Solver N - size of entire block of N x N B_R - mini-block column size B_C - mini-block column size */ gen_sudoku(Rows,N,B_R,B_C):- N #>1, B_R #> 1, B_C #> 1, N #= B_R * B_C, length(Rows, N), maplist(same_length(Rows), Rows), append(Rows, Vs), Vs ins 1..N, maplist(all_distinct, Rows), transpose(Rows, Columns), maplist(all_distinct, Columns), part(B_R, Rows, Partitions), maplist(valid_blocks(B_C), Partitions).
Теперь мы можем прийти к решению задачи 1 с помощью `problem(1, Rows), gen_sudoku(Rows, 9, 3, 3), maplist(portray_clause, Rows). Лучше всего то, что он работает с разными размерами!
Вывод
Следующая часть задания была еще более сложной. Постановка задачи была такой: при заданной нижней границе X и верхней границе Y можно ли заполнить сетку N*N размерами блоков B_R*B_C так, чтобы сетка имела только единственное решение? Вы можете сделать это быстро? Можете ли вы сделать это с минимальным количеством используемых спотов?
Излишне говорить, что это заняло некоторое время….
Мое решение можно посмотреть здесь: https://github.com/Psyf/Sudoku/blob/main/sudokuB.pl. Решение выполняется за разумное время с использованием алгоритма, основанного на эвристике — вероятность нахождения уникальной сетки при первом запуске уменьшается с увеличением сложности. Отдельное спасибо моему другу Paras за мозговой штурм со мной.
Я надеюсь, что вы нашли для себя ценность в этой статье и оценили различные мощные способы мышления, которые можно увидеть в Прологе. Я хотел бы поблагодарить Prof Chin Wei Ngan за то, что он привил этому нубу понимание различных парадигм программирования во время CS2104.
Я хотел бы услышать от вас! Как бы вы написали обобщенный решатель судоку на своем любимом языке программирования?