Основы программирования на языке Пролог

       

Списки


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

Дадим сначала неформальное определение списка.

Будем называть списком упорядоченную последовательность элементов произвольной длины.

Список задается перечислением элементов списка через запятую в квадратных скобках, так, как показано в приведенных ниже примерах.

[monday, tuesday, wednesday, thursday, friday, saturday, sunday] — список, элементами которого являются английские названия дней недели;

["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] — список, элементами которого являются русские названия дней недели;

[1, 2, 3, 4, 5, 6, 7] — список, элементами которого являются номера дней недели;

['п', 'в', 'с', 'ч', 'п', 'с', 'в'] — список, элементами которого являются первые символы русских названий дней недели;

[] — пустой список, т.е. список, не содержащий элементов (в языке функционального программирования Лисп он обозначается nil).

Элементы списка могут быть любыми, в том числе и составными объектами. В частности, элементы списка сами могут быть списками.

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

DOMAINS <имя спискового домена>=<имя домена элементов списка>*

Звездочка после имени домена указывает на то, что мы описываем список, состоящий из объектов соответствующего типа.

Например:

listI = integer* /* список, элементы которого — целые числа */ listR = real* /* список, состоящий из вещественных чисел */ listC = char* /* список символов */ lists = string* /* список, состоящий из строк */ listL = listI* /* список, элементами которого являются списки целых чисел */

Последнему примеру будут соответствовать списки вида:

[[1,3,7],[],[5,2,94],[–5,13]]

В классическом Прологе элементы списка могут принадлежать разным доменам, например: [monday, 1, "понедельник"]


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

Например, следующее описание:

DOMAINS element = i(integer); c(char); s(string) listE = element*

позволит иметь дело со списками вида

[i(–15), s("Мама"),c('A'),s("мыла"),c('+'),s("раму"), i(48),c('!')]

Дадим рекурсивное определение списка.



список — это структура данных, определяемая следующим образом:

  1. пустой список ([ ]) является списком;
  2. структура вида [H|T] является списком, если H — первый элемент списка (или несколько первых элементов списка, перечисленных через запятую), а T — список, состоящий из оставшихся элементов исходного списка.


Принято называть H головой списка, а T — хвостом списка. Заметим, что выбор переменных для обозначения головы и хвоста не случаен. По-английски голова — Head, а хвост — Tail.

Фактически операция "|" позволяет разделить список на хвост и голову (в Лиспе есть подобные операции car и cdr) или, наоборот, приписать объект (объекты) к началу списка (cons в Лиспе).

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

Например, в списке [1, 2, 3] элемент 1 является головой, а список [2, 3] — хвостом, т.е. [1, 2, 3] = [1|[2, 3]].

Заметим, что хвост этого списка [2, 3], в свою очередь, может быть представлен в виде головы 2 и хвоста [3], а список [3] можно рассматривать в виде головы 3 и хвоста []. Пустой список далее не разделяется.

В итоге получаем, что список [1, 2, 3] эквивалентен списку [1|[2, 3]], который, в свою очередь, эквивалентен списку [1|[2|[3]]]. Последний сопоставим со списком [1|[2|[3|[ ]]]].



В этом же списке можно выделить два первых элемента и хвост из третьего элемента [1,2|[3]]. И, наконец, возможен вариант разбиения на голову из трех первых элементов и пустой хвост: [1, 2, 3|[]].

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

В качестве резюме к нашим рассуждениям запишем еще раз определение списка в нотации Бэкуса–Науэра:

Список ::= [ ]|[Элемент <,Элемент>*]|[Голова|Хвост] Голова ::= Элемент <,Элемент>* Хвост ::= Список

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

Рассмотрим обработку списков.

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

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

length([], 0). /* в пустом списке элементов нет */ length([_|T], L) :– length(T, L_T), /* L_T — количество элементов в хвосте */ L = L_T + 1. /* L — количество элементов исходного списка */

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

Разберем на примере, как это будет работать. Пусть нас интересует количество элементов в списке [1,2,3]. Запишем соответствующий вопрос Пролог-системе:

length([1,2,3],X).

Система попытается вначале сопоставить нашу цель с первым предложением length([], 0), однако ей это не удается сделать, потому что первый аргумент цели является непустым списком.


Система переходит ко второму предложению процедуры. Сопоставление с заголовком правила проходит успешно, переменная X связывается с переменной L, список [1,2,3] будет сопоставлен со списком [_|T], переменная T будет конкретизирована значением [2,3]. Теперь система переходит к попытке достижения подцели length(T,L_T). Как и в предыдущем случае, первое предложение с подцелью не сопоставляется, так как список T не пустой. При сопоставлении заголовка правила с подцелью хвост T конкретизируется одноэлементным списком [3]. На следующем шаге рекурсии переменная T означена пустым списком (хвост одноэлементного списка). И, значит, наша подцель выглядит следующим образом: length([], L_T). Эта цель сопоставляется с фактом, переменная L_T становится равной нулю. Раскручивается обратный ход рекурсии: переменная L_T увеличивается на единицу, результат попадает в переменную L. Получаем, что длина списка [3] равна единице. На следующем обратном шаге происходит еще одно добавление единицы, после чего длина списка [2,3] конкретизируется двойкой. И, наконец, на последнем возвратном шаге получаем означивание переменной L числом 3 (количеством элементов в списке [1,2,3]).

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

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

member(X,[X|_]). /* X — первый элемент списка */ member(X,[_|T]) :– member(X,T). /* X принадлежит хвосту T*/

Заметим, что в первом случае (когда первый элемент списка совпадает с исходным элементом), нам неважно, какой у списка хвост, и можно в качестве хвоста указать анонимную переменную. Аналогично, во втором случае, если X принадлежит хвосту, нам не важно, какой элемент первый.

Отметим, что описанный предикат можно использовать двояко: во-первых, конечно, для того, для чего мы его и создавали, т.е.


для проверки, имеется ли в списке конкретное значение. Мы можем, например, поинтересоваться, принадлежит ли двойка списку [1, 2, 3]:

member(2, [1, 2, 3]).

Получим, естественно, ответ: "Yes".

Подобным образом можно спросить, является ли число 4 элементом списка [1, 2, 3]:

member(4, [1, 2, 3]).

Ответом, конечно, будет "No".

Второй способ использования данного предиката — это получение по списку его элементов. Для этого нужно в качестве первого аргумента предиката указать свободную переменную. Например:

member(X, [1, 2, 3]).

В качестве результата получим список всех элементов списка:

X=1 X=2 X=3

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

member(1, X).

Вначале Пролог-система выдаст предупреждение о том, что переменная X не связана в первом предложении ("708 WARNING: The variable is not bound in this clause. (F10=ok, Esc=abort)").

У нас есть два способа отреагировать на это предупреждение: нажать кнопку Esc, чтобы отказаться от генерации списков, содержащих единицу в качестве элемента; нажать F10 для того, чтобы продолжить выполнение цели. Во втором случае Пролог-система начнет выдавать варианты списков, содержащих единицу:

X=[1|_] /* единица — первый элемент списка */ X=[_,1|_] /* единица — второй элемент списка */ X=[_,_,1|_] /* единица — третий элемент списка */ и т.д.

Этот процесс будет продолжаться до тех пор, пока не будет нажата комбинация клавиш Ctrl+Break.

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

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


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

member2(X,[X|_]). member2(X,[Y|T]):– X<>Y, member2(X,T).

Заметим, что эту модификацию предиката member нельзя использовать для получения всех элементов списка. Если подставить в качестве первого аргумента несвязанную переменную, то при попытке согласования подцели правила неозначенная переменная X будет сравниваться с неозначенной переменной Y. Получим сообщение об ошибке "Free variable in expression".

Второй способ. Добавим в факт отсечение, чтобы в ситуации, когда искомый элемент оказался первым элементом списка, не производился лишний поиск в хвосте исходного списка. Получим:

member3(X,[X|_]):–!. member3(X,[_|T]):– member3(X,T).

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

Пример. Создадим предикат, позволяющий соединить два списка в один. Первые два аргумента предиката будут представлять соединяемые списки, а третий — результат соединения.

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

conc([ ], L, L). /* при присоединении пустого списка к списку L получим список L */ conc([H|T], L, [H|T1]) :– conc(T,L,T1). /* соединяем хвост и список L, получаем хвост результата */



Заметим, что этот предикат также можно применять для решения нескольких задач.

Во-первых, для соединения списков. Например, если задать вопрос

conc([1, 2, 3], [4, 5], X)

то получим в результате

X= [1, 2, 3, 4, 5]

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

conc([1, 2, 3], [4, 5], [1, 2, 5]).

ответом будет, конечно, No.

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

conc([1, 2], Y, [1, 2, 3]).

то ответом будет Y=[3].

Аналогично, на вопрос

conc(X, [3], [1, 2, 3]).

получим ответ X=[1, 2].

И, наконец, можно спросить

conc(X, Y, [1, 2, 3]).

Получим четыре решения:

X=[], Y=[1, 2, 3] X=[1], Y=[2, 3] X=[1, 2], Y=[3] X=[1, 2, 3], Y=[]

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

conc(L, [2|R], [1, 2, 3, 2, 4]).

Получим два решения:

L=[1], R=[3, 2, 4]. L=[1, 2, 3], R=[4]

В-пятых, на основе нашего предиката conc можно создать предикат, находящий последний элемент списка:

last(L,X):– conc(_,[X],L).

Справедливости ради стоит заметить, что этот предикат можно реализовать и "напрямую", без использования предиката conc:

last2([X],X). /* последний элемент одноэлементного списка — этот элемент */ last2([_|L],X):– last2(L,X). /* последний элемент списка совпадает с последним элементом хвоста */

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

member4(X,L):– conc(_,[X|_],L).

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


Предикат будет иметь три параметра: первые два — значения, третий — список.

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

neighbors(X,Y,L):– conc(_,[X,Y|_],L). /* список L получается путем объединения некоторого списка со списком, голову которого составляют элементы X и Y */

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

neighbors2(X,Y,L):– conc(_,[X,Y|_],L); conc(_,[Y,X|_],L). /* список L получается путем объединения некоторого списка со списком, голову которого составляют элементы X и Y или элементы Y и X */

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

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

Для решения этой задачи воспользуемся рекурсией. Базис: если записать элементы пустого списка (которых нет) в обратном порядке — опять получим пустой список. Шаг рекурсии: для того чтобы получить "перевернутый" список, можно "перевернуть" его хвост и "приклеить" к нему первый элемент исходного списка. Запишем эти размышления.

reverse([ ],[ ]). /* обращение пустого списка дает пустой список*/ reverse([X|T],Z):– reverse(T,S), conc(S,[X],Z). /* обращаем хвост и приписываем к нему справа первый элемент исходного списка*/



Обратите внимание, что вторым аргументом в предикате conc должен стоять именно одноэлементный список [X], а не элемент X. Это связано с тем, что аргументами предиката conc должны быть списки.

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

rev([H|T],L1,L2):– rev(T,[H|L1],L2). /* голову первого аргумента дописываем ко второму аргументу*/ rev([ ],L,L). /* если исходный список закончился, то второй аргумент — передаем в третий аргумент в качестве результата*/

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

reverse2(L1,L2):– rev (L1,[ ],L2).

Пример. Создадим предикат, который позволит проверить, является ли список палиндромом. Палиндромом называется список, который совпадает со своим обращением. Соответственно, у данного предиката будет всего один аргумент (список, который проверяем на "палиндромность").

Первое, что приходит в голову: воспользоваться только что написанным предикатом reverse (или reverse2). Перевернуть список и проверить, совпадает ли результат с исходным списком. Выглядеть этот предикат будет следующим образом:

palindrom(L):– reverse (L,L).

Можно решить эту задачу "напрямую", без использования предиката reverse.

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


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

Решение проведем рекурсией по номеру элемента. В качестве базиса возьмем очевидный факт, что первым элементом списка является его голова. Шаг рекурсии позволит нам сделать предположение, что N-й элементом списка является (N–1)-м элементом хвоста. Данному определению будет соответствовать следующее предложение:

n_element([X|_],1,X). n_element([_|L],N,Y):– N1=N–1, n_element(L,N1,Y).

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

Без рекурсии не обойдется и на этот раз. Если первый элемент окажется удаляемым, то нужно перейти к удалению заданного значения из хвоста списка. Результатом в данном случае должен стать список, полученный путем удаления всех вхождений искомого значения из хвоста первоначального списка. Это даст нам базис рекурсии. Шаг рекурсии будет основан на том, что если первый элемент списка не совпадает с тем, который нужно удалять, то он должен остаться первым элементом результата, и нужно переходить к удалению заданного значения из хвоста исходного списка. Полученный в результате этих удалений список должен войти в ответ в качестве хвоста.

delete_all(_,[],[]). delete_all(X,[X|L],L1):– delete_all (X,L,L1). delete_all (X,[Y|L],[Y|L1]):– X<>Y, delete_all (X,L,L1).

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

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

delete_one(_,[],[]). delete_one(X,[X|L],L):–!. delete_one(X,[Y|L],[Y|L1]):– delete_one(X,L,L1).

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

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

Найдем имена всех дочек: findall(N,mother(_,N),L). В список L попадут имена всех дочек.

Найдем имена всех дочек Даши: findall(N,mother("Даша",N),L). В список L попадут имена всех дочек Даши.


Содержание раздела