Во многих программах для ЭВМ, подобных программам поиска в лабиринте, полезно вести информационные списки и просматривать нужный список, когда впоследствии понадобится некоторая информация. Например, если мы решили найти во дворце телефон, нам может понадобиться список уже осмотренных комнат. Чтобы не плутать, снова и снова заходя в те же самые комнаты, нам нужно просто записывать на листке бумаги номера комнат, где мы уже побывали. Перед тем, как войти в комнату, мы проверяем, нет ли ее номера на нашем листке. Если он есть, мы пропускаем эту комнату, потому что уже должны были побывать там раньше. Если номера этой комнаты нет на листке, то мы записываем ее номер и входим в комнату, и так до тех пор, пока не найдем телефон.

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

1. Подойти к двери какой-либо комнаты. Если номер комнаты есть в нашем списке, то перейти к шагу 1.

2. Если в поле зрения нет ни одной комнаты, то «вернуться назад» через ту комнату, через которую мы прошли сюда, и посмотреть, нет ли возле нее каких-либо других комнат.

3. Иначе дописать номер комнаты к нашему списку.

4. Поискать телефон в этой комнате.

5. Если телефона нет, то перейти к шагу 1. Иначе мы останавливаемся, и наш список содержит путь, который мы прошли, чтобы попасть в нужную комнату.

Будем считать, что номера комнат являются константами (безразлично целыми числами или атомами). Сначала мы можем решить, как просматривать номера комнат, записанные на листке бумаги. Для этого можно использовать предикат принадлежит, определенный в разд. 3.3, полагая, что содержимое листка бумаги представлено в виде списка. Теперь мы можем продвинуться в решении задачи поиска в лабиринте. Рассмотрим небольшой пример, где задан план дома, комнаты которого помечены буквами (см. рис. 7.2). Заметим, что просветы в стенах обозначают двери и что комната а– это просто представление пространства вне дома. Имеются двери, ведущие из ав b, из св d, из fв е, и так далее. Сведения о том, где имеются двери, могут быть представлены в виде фактов Пролога:


д(а,b).

д(b,е).

д(b,с).

д(d,c).

д(c,d).

д(e,f).

д(g,e).

Рис. 7.2.

Заметим, что информация о наличии дверей не избыточна. Например, мы сказали, что имеется дверь, ведущая из комнаты  gв комнату е, но не сказали, что имеется дверь, ведущая из комнаты ев комнату g, т. е. мы не зафиксировали утверждение д(e,g). Чтобы обойти эту проблему представления двухсторонних дверей, мы могли бы повторно записать д-факт для каждой двери с перестановкой аргументов. Или мы могли бы устроить программу таким образом, чтобы она понимала, что каждая дверь фактически может рассматриваться как двухсторонняя. Этот вариант мы и выбрали в нижеследующей программе.

Чтобы перейти из одной комнаты в другую, мы должны распознать один из следующих случаев:

• мы находимся в той комнате, которая нам нужна, или

• мы должны войти в дверь и распознать эти случаи снова (рекурсивно).

Рассмотрим целевое утверждение переход(X,Y,T), которое доказуемо (согласуется с базой данных), если можно перейти из комнаты Xв комнату Y. Третий аргумент Т– это наш листок бумаги, который мы носим с собой и на котором записан перечень номеров комнат, в которых мы побывали до сего момента.

Граничное условие перехода из комнаты Xв комнату Yсостоит в том, что, возможно, мы уже находимся в комнате Y(т. е., возможно, Xесть Y). Это условие представлено в виде утверждения:


переход(Х,Х,Т).


В противном случае мы выбираем некоторую смежную комнату, назовем ее Z, и смотрим, были ли мы в ней раньше. Если нет, то «переходим» из Zв Y, дописывая Zв наш список. Все это выражается в виде следующего утверждения:


переход(Х, Y,T,):- Д(Х,Z),not(принадлежит(Z,Т)), переход(Z,Y,[Z|T]).


Словами это может быть выражено так: для того чтобы «перейти» из Xв Y, не проходя через комнаты из списка Т, надо найти дверь из Xкуда-либо (т. е. в Z), убедиться, что Zеще не занесена в список Т, и «перейти» из Zв Y, используя список Тс дописанной в него Z.

При использовании этого правила существуют три возможности возникновения ошибки: во-первых, если в Xвообще нет двери. Во-вторых, если дверь, которую мы выбрали, уже есть в списке. В-третьих, если «переход» в комнату Zприведет в тупик на следующих уровнях. Если первое целевое утверждение д(X, Z)не согласуется с базой данных, то и данное целевое утверждение переходтакже недоказуемо. На «самом верхнем» уровне (не рекурсивный вызов) это означает, что из Xв Yнет пути; на более глубоких уровнях это означает, что мы должны сделать «шаг назад» и поискать другую дверь.

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


переход(Х,X,T).

переход(X,Y,T):- д(X,Z), not(принадлежит(Z,Т)),переход(Z,Y[Z|T]). переход(Х,Y,T):- д(Z,Х), not(принадлежит(Z,Т)),пeреход(Z,Y,[Z|T]).


Или, используя предикат ';' (обозначающий дизъюнкцию), можно записать:


переход(Х,Х,Т).

переход(Х,Y,T):- (д(Х,Z); д(Z,Х)), not(принадлежит (Z,T)),пepexод(Z,Y,[Z|T]).


Теперь о том, как найти телефон. Рассмотрим целевое утверждение есть_телефон(X), которое согласуется с базой данных, если в комнате Xесть телефон. Если мы хотим сказать, что в комнате gесть телефон, то мы просто записываем в нашу базу данных факт есть_телефон(g). Предположим, мы начали поиск с комнаты а. Один из способов узнать дорогу к телефону – это задать вопрос:


?- переход(а,Х,[]), есть_телефон(X).


Это – вопрос типа «создать и проверить», который находит достижимые комнаты и затем проверяет наличие в них телефона. Другой способ – это найти сопоставление сначала для предиката есть_телефон(Х), а затем попробовать перейти из комнаты ав X:


?- есть_телефон(Х), переход(а,Х,[]).


Последний метод более эффективен, однако он подразумевает что мы «знаем», где телефон, еще до того, как начали поиск.

Начальная установка третьего аргумента пустым списком означает, что мы начинаем поиск, имея чистый лист бумаги. Изменяя эту начальную установку, можно получить разные варианты поиска. Вопрос «найти телефону не заходя в комнаты d и f» можно выразить на Прологе так:


?- есть_телефон(X), переход (a,X,[d,f]).


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

Упражнение 7.2.Допишите вышеприведенную программу так, чтобы она печатала такие сообщения, как «входим в комнату X» и «телефон найден в комнате Y», подставляя в них соответствующие номера комнат.

Упражнение 7.3.Может ли эта программа находить альтернативные пути? Если да, то где нужно «отсечь», чтобы избежать нахождения более чем одного пути?

Упражнение7.4. Чем определяется порядок, в котором просматриваются комнаты?

7.3. Ханойские башни

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

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

• Граничное условие выполняется в случае, когда на исходном (левом) штыре нет дисков.

• Переместить N-1 дисков с исходного штыря на запасной (правый) штырь, используя итоговый штырь как запасной; отметим, что это перемещение осуществляется рекурсивно.

• Переместить один диск с исходного штыря на итоговый штырь. В этом месте наша программа будет выдавать сообщение об этом перемещении.

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

Пролог-программа, реализующая данную стратегию, определяется следующим образом. Определяется предикат ханойс одним аргументом, такой, что xaной(N)означает выдачу сообщений о последовательности перемещений, когда на исходном штыре находится Nдисков. Из двух утверждений предиката переместитьодин задает граничное условие, которое сформулировано выше, а второй – реализует рекурсивные случаи. Предикат переместитьимеет четыре аргумента. Первый аргумент – это число дисков, которые нужно переместить. Три другие представляют исходный, итоговый и запасной штыри для перемещения дисков. Предикат сообщитьиспользует предикат writeдля печати названий штырей, участвующих в перемещении диска.


xaной(N):- переместить(N, левый,средний,правый).

переместить(О,_,_,_):-!.

переместить(N, А,В,С):-М is N-1,переместить(М,А,С,В),сообщить(А,В), переместить(М,С,В,А).

сообщить(Х,Y):-write([переместили,диск,со,штыря,Х,на, штырь,Y]),nl.

7.4. Справочник комплектующих деталей

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

Организация базы данных справочника сходна с тем, что описано в гл. 3. Сборочный узел представлен в виде списка структур вида чис(X, Y), где X– это имя некоторой детали (простой детали или узла), a Y– необходимое количество таких деталей. Ниже перечислены все предикаты измененной программы с указанием их назначения:

Деталиузла(А):выдает на печать список всех простых деталей, требующихся для сборки узла А, и количество каждой детали.

Деталиузлов(N,X,P): P- это список структур чис(Дет, Кол),где Дет- это название детали, а Кол- это количество таких деталей, требующихся для сборки каждого из экземпляров узлов X. N- целое, а X– атом, представляющий название некоторой детали.

Деталировка(N,S,Р): Р- это, как и выше, список структур чис,требующихся для сборки всех узлов, представленных элементами списка S; Nзадает число экземпляров списка S, N– целое; S– список структур чис.

Собрать(Р, А): Ри А– списки структур чис. А– это список, составленный из тех же элементов, что и Р, но без повторений одной и той же детали. Причем количество каждой детали, указанное в списке А, совпадает с суммой всех повторений этой детали в списке Р. Предикат собратьмы используем для того, чтобы собрать несколько описей наборов одинаковых деталей в одну опись. Например, 3 винта, 4 шайбы и 4 винтасобираются вместе, давая 7 винтов и 4 шайбы.

Дособрать(Х,М, L,O,N): Lи О- это списки структур, чис,О – это список всех элементов списка L, в состав которых не входит деталь X; X – это атом, задающий название некоторой детали; N– это общее количество Xв списке L, сложенное с М; М– это целое число, которое используется для суммирования количеств Xв Lи передается как аргумент в каждом вызове дособрать.При выходе из рекурсии, который обеспечивается выполнением граничного условия, Мвозвращается как N.

Вывдеталейузла(Р): Р– это список структур чис,который выдается на печать по одной структуре на строке вывода. Цель put(9)выводит литеру с кодом ASCII=9, что соответствует горизонтальной табуляции. С предикатом присоединитьмы уже неоднократно встречались ранее.

Полностью Пролог-программа выглядит так:


деталиузла(Т):-деталиузлов(1,Т,Р), co6paть(P,Q), вывдеталейузла(Q).

деталиузлов(N,Х,Р):-узел(Х,S), деталировка(N,S,Р).

деталиузлов(N,Х, [чис(Х,N)]):- деталь(Х).

деталировка(_, [], []).

деталировка(N, [чис(Х, Число) |L],T):-М is N * Число, деталиузлов(М,Х,Хдетали),деталировка (N, L,Остдетали,Т),присоединить(Хдетали,Остдетали,Т).

собрать([],[]).

coбpaть([чис(X,N)|R],[чис(X,Nитог)|R2]):-дособрать(Х,N,R,O,Nитог),собрать(О,R2).

досo6paть(_,N,[],[],N).

дособрать(Х,N,[чис(Х,Число)|Oст],Прочие,Nитог):-!,М is N+Число, дособрать(Х,М,Ост,Прочие,Nитог).

дособрать(Х,N,[Друг|Ост],[Друг|Прочие],Nитог):-дособрать(Х, N, Ост, Прочие, Nитог).

вывдеталейузла([]).

вывдеталейузла([чис(Х,N)|R):-tab(4),write(N),put(9),write(X),nl, вывдеталейузла(R).

7.5. Обработка списков

В этом разделе мы рассмотрим некоторые основные предикаты, полезные при работе со списками. Поскольку Пролог позволяет работать с произвольными структурами данных, списки не могут играть в нем той незаменимой роли, какая им отводится в других языках программирования, таких, как Лисп и Поп-2. Однако независимо от того, будут или не будут использоваться списки в ваших программах, всегда важно представлять себе, как работают предикаты, определения которых рассматриваются в данном разделе, поскольку они основаны на принципах, которые применимы при работе с любыми структурами данных.

Нахождение последнего элемента списка:Цель последний(X, L)согласуется с базой данных, если элемент Xявляется последним элементом списка L. Граничное условие выполняется, когда список Lсодержит только один элемент. Это условие проверяется первым правилом. Второе правило задает общий рекурсивный случай:


последний(Х,[Х]).

последний(Х,[_,|Y]):- последний(Х,Y).

?- последний(Х,[talk,of,the,town]).

X = town


Проверка порядка следования элементов:Цель следомза(Х, Y, L)согласуется с базой данных, если элементы Xи Yявляются последовательными элементами списка L. Особенности работы переменных допускают, чтобы или X, или Y, или обе переменные были неконкретизированы перед попыткой согласовать цель. В первом утверждении, которое проверяет граничное условие, должно быть также предусмотрено, что после Xи Yв списке могут быть другие элементы. Этим объясняется появление анонимной переменной, в которой сохраняется хвост списка:


следомза(Х,Y,[Х,Y|_]).

следомза(Х,Y,[_|Z]):- следомза(Х,Y,Z).


Объединение списков:С приводимым примером мы уже встречались ранее в разд. 3.6. Цель присоединить(X, Y, Z)согласуется с базой данных в том случае, если Z– это список, построенный путем добавления Yв конец X. Например,


?- присоединить([a,b,с],[d,e,f],Q).

Q=[a,b,c,d,e,f]


Определение предиката присоединитьвыглядит следующим образом:


присоединить([],L,L).

присоединить([Х|L1],L2,[Х|LЗ]):- присоединить(L1,L2,LЗ).


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

Заметим, что любые два аргумента присоединитьмогут быть конкретизированы, и в этом случае присоединитьконкретизирует третий аргумент соответствующим результатом. Этим свойством, которое можно было бы назвать «недетерминированным программированием», обладают многие из определяемых в данной главе предикатов. Указанная гибкость присоединитьпозволяет определить с его помощью ряд других предикатов, что мы и сделаем:


последний(Е1,Список):- присоединить(_,[Е1],Список).

следомза(Е11,Е12,Список):-

присоединить(_,[Е11,Е12|_], Список).

принадлежит(Е1,Список):- присоединить(_,[Е1|_],Список).


Обращение списка:Цель обр(L,M)согласуется с базой данных, если результат перестановки в обратном порядке элементов списка Lесть список М. В программе используется стандартный прием, когда обращенный список получается присоединением его головы к обращенному хвосту. Лучший способ обратить хвост – это использовать сам обр.Граничное условие выполняется тогда, когда первый аргумент сократился до пустого списка, в этом случае результатом также является пустой список:


обр([],[]).

обр([Н|Т],L):- обр(T,Z), присоединить(Z,[Н],L).


Заметим, что на месте второго аргумента присоединитьстоит Нв квадратных скобках. Причина в том, что Н– это голова первого аргумента, а голова списка сама не обязана быть списком. Хвост же списка по определению всегда является списком. Для более эффективной реализации обрмы можем встроить действия по объединению списков непосредственно в утверждения для обр:


o6p2(L1,L2):- обрдоп(L1,[],L2).

обрдоп([X|L],L2 fL3):- обрдоп(L,[Х|L2],LЗ).

обрдоп([],L,L).


Второй аргумент обрдописпользуется для хранения «текущего результата». Каждый раз, когда выявляется новый фрагмент результата (X), передаваемый в остальную часть программы, «текущий результат» представляет из себя старый «текущий результат», дополненный новым фрагментом X. В самом конце последний «текущий результат» возвращается в качестве результата исходного целевого утверждения. Аналогичный прием используется в разд. 7.8 при определении предиката имя_целого.

Исключение одного элемента:Цель исключ1(Х, Y,Z)исключает первое вхождение элемента Xиз списка Y, формируя новый сокращенный список Z. Если в списке Yнет элемента X, то целевое утверждение недоказуемо. Граничное условие выполняется тогда, когда мы находим искомый элемент X, иначе осуществляется рекурсивная обработка хвоста списка Y:


исключ1(А,[А|L],L):-!.

исключ1(А,[В|L],[В|М]):- исключ1(А,L,М).


Легко добавить утверждение, которое обеспечит доказательство предиката, когда второй аргумент сократится до пустого списка. Это утверждение, реализующее новое граничное условие, есть исключ1(_,[],[])-

Исключение всех вхождений некоторого элемента;Цель исключить(Х, L1, L2)создает список L2путем удаления всех элементов Xиз списка L1. Граничное условие выполняется тогда, когда L1является пустым списком. Это означает, что мы рекурсивно исчерпали весь список. Если Xнаходится в голове списка, то результатом является хвост этого списка, из которого Xтоже удаляется. Последний случай возникает, если во втором аргументе обнаружено, что-то отличное от X. Тогда мы просто входим в новую рекурсию.


исключить(_, [],[]).

исключить(Х,[Х|L],М):-!, исключить(Х,L,М).

исключить(Х,[Y|L1],[Y|L2]):- исключить(Х,L1,L2).


Замещение:Этот предикат очень напоминает исключить,с той лишь разницей, что вместо удаления искомого элемента мы заменяем его некоторым другим элементом. Цель заменить(Х, L,A,M)строит новый список Миз элементов списка L, при этом все элементы Xзаменяются на элементы А. Здесь возможны 3 случая. Первый, связанный с граничным условием, в точности совпадает с тем, что было в исключить.Второй случай – когда в голове второго аргумента содержится элемент X, а третий – когда там содержится нечто отличное от X:


заменить(_,[],_,[]).

заменить(Х,[Х|L],А,[А|М]):-!, заменить(Х,L,А,М).

заменить(Х,[Y|L],А,[Y|М]):- заменить(Х,L,А,М).


Подсписки:Список Xявляется подсписком списка Y, если каждый элемент Xсодержится и в Yс сохранением порядка следования и без разрывов. Например, доказуемо следующее:


подсписок[[собрание, членов, клуба],[общее, собрание, членов, клуба, будет, созвано, позже]).


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


подсписок([Х|L],[Х|М]):- совпало(L,M),!.

подсписок(L,[_|М]):- подсписок(L,M).

совпало([],_).

совпало([Х|L],[Х|М]):- совпало(L,М).


Отображение:Это мощный метод, заключающийся в преобразовании одного списка в другой с применением к каждому элементу первого списка некоторой функции и использованием ее результата в качестве очередного элемента второго списка. Программа преобразования одного предложения в другое, которая рассматривалась в гл. 3, является одним из примеров отображения. Мы говорим, что «отображаем одно предложение в другое».

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

7.6. Представление и обработка множеств

Множество- одна из наиболее важных структур данных, используемых как в математике, так и в программировании. Множество – это набор элементов, напоминающий список, но отличающийся тем, что вопрос о том, сколько раз и в каком месте что-либо входит в множество в качестве его элемента, не имеет смысла. Так, множество (1, 2, 3) – это то же самое множество, что и (1, 2, 3, 1), поскольку значение имеет только сам факт, принадлежит данный элемент множеству или нет. Элементами множеств могут также быть другие множества. Самой фундаментальной операцией над множествами является определение того, принадлежитнекоторый элемент данному множеству или нет.

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

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

Принадлежность множеству: XY

Xпринадлежит некоторому множеству Y, если Xявляется одним из элементов Y.

Пример: а{с,а,t}.

Включение: XY

Множество Yвключает в себя множество X, если каждый элемент множества Xявляется также элементом Y. Множество Yможет содержать некоторые элементы, которых нет в X.

Пример: {x,r,u}{p,q,r,f,t,u,v,w,x,y,z}.

Пересечение: X)Y

Пересечением множеств Xи Yявляется множество, содержащее те элементы, которые одновременно принадлежат Xи Y.

Пример: {r,a,p,i,d}{p,i,c,t,u,r,e}= {r,i,p}.

Объединение: XY

Объединением множеств Xи Yявляется множество, содержащее все элементы, принадлежащие Xили Yили одновременно им обоим.

Пример: {a,b,c}{с,d,е} = {a,b,c,d,e}.

Это – основные операции, которые обычно используются при работе с множествами. Теперь мы можем приступить к написанию Пролог-программ, реализующих каждую из них. Первая основная операция 'принадлежность' реализуется тем же самым предикатом