Выберем наиболее простой способ хранить найденные перестановки в программе:
массив. Если перестановки предстоит помещать в список, для такого списка
подойдёт массив ссылок на анонимные массивы, в каждом из которых будет
размещаться перестановка. Вот как будет выглядеть структура, хранящая все шесть
перестановок из множества
:
([3, 2, 1], [2, 3, 1], [2, 1, 3], [3, 1, 2], [1, 3, 2],
[1, 2, 3])
.
Для реализации рекурсивного алгоритма нужно исходную задачу — поиск
перестановок из
—
свести к решению таких же задач (поиск перестановок из
для некоторых ). Если решение
основной задачи оформить в виде процедуры permutations
,
принимающей параметр $n
, то такая процедура помогала бы сама
себе, рекурсивно вызывая себя для решения вспомогательных задач.
Такое представление есть, и оно удивительно просто. Достаточно знать множество , чтобы найти . Из каждой -перестановки наделаем штук новых -перестановок. Для этого в каждую перестановку нужно вставить число всевозможными способами. Сколько всего таких способов? Конечно, . К примеру, для -перестановки после указанных манипуляций получатся -перестановки , , , — всего штуки.
Кстати, мы получили одно из доказательств того факта, что количество -перестановок равно . Это доказательство по индукции. Вообще, индукция в математике очень близка по смыслу к рекурсии в информатике.
Параметр при вызове процедуры
permutations
— это количество элементов в некотором
множестве, поэтому допустимые значения этого параметра — целые неотрицательные
числа. Поэтому особого внимания требует граничный случай, когда
.
Что должна возвратить процедура permutations
в этом
случае? Список перестановок пустого множества. Сколькими способами можно
упорядочить все (ноль) элементов пустого множества? Только одним — вот таким:
.
Именно так, хоть это и выглядит странно. Таким образом, при
процедура permutations
должна вернуть одноэлементный
список перестановок; единственная перестановка в этом списке — это пустой
список чисел
,
то есть .
Основная часть процедуры permutations
, таким образом,
будет содержать двойной цикл. Во внешнем будут перебираться
-перестановки.
Во внутреннем будут перебираться все способы, которыми можно вставить число
в каждую из них.
Наблюдения за работой рекурсивной программы выявляют существенный недостаток рекурсивного подхода. Программа некоторое время ничего не выводит на экран, накапливая перестановки в списке. Это означает, что к моменту вывода первой строчки найдены и помещены в массив все перестановок. С учётом того, что очень быстро растёт с ростом , мы рискуем очень быстро исчерпать всю доступную память компьютера.
Заметим, что совершенно не обязательно хранить в памяти все перестановки только для того, чтобы вывести их на экран одну за другой. Можно в цикле переходить от текущей перестановки к следующей за ней подобно тому, как в цикле, перебирающем натуральные числа, от текущего числа переходят к следующему, на единицу большему.
Осталось лишь выяснить, какую перестановку из мы выберем в качестве первой, и каким способом мы станем переходить от текущей перестановки к следующей. Иными словами, нужно описать порядок перебора элементов .
Совершенно естественно выбрать в качестве начальной перестановки тождественную , которая всегда содержится в , какое бы мы ни взяли.
Взглянем на -элементное множество как на карточную колоду. Каждая из перестановок этого множества получается в результате некоторого тасования колоды. Если бы удалось перебрать все возможные варианты тасования, можно было бы получить все перестановки. Такой перебор возможен.
Тасование представим как последовательность из транспозиций карт. Сначала последняя карта меняется местами с любой картой из колоды (очень важно не исключить по ошибке случай, когда карта меняется местами сама с собой). На следующем шаге предпоследняя карта меняется с любой, за исключением последней. Потом предпредпоследняя — с любой, кроме двух последних, и так далее. Так мы дойдём до первой карты колоды, которой не останется ничего другого, как обменяться местами самой с собой. Такой подход позволяет представить тасование как -элементную последовательность номеров карт , где для всех . Для того, чтобы выполнить закодированное в виде такой последовательности тасование, нужно менять местами карты с номерами и , последовательно уменьшая от до . Например, для последовательности получается перестановка как результат цепочки транспозиций:
Пока что остаётся открытым вопрос о том, как организовать перебор таких последовательностей. Но прежде следует выяснить, имеется ли взаимно-однозначное соответствие между последовательностями и перестановками множества. Иными словами, любая ли перестановка может быть получена таким способом, и не дадут ли разные тасования одну и ту же перестановку?
На второй из этих вопросов ответ отрицательный: нет, не дадут. Пусть имеется два различных тасования и . Тогда найдётся хотя бы одно такое , что . Выберем наибольшее из таких . Возьмём колоду и начнём тасовать. Тогда при тасованиях и , очевидно, на -м месте окажутся разные карты, так что разным тасованиям соответствуют разные перестановки.
Осталось доказать, что каждой перестановке отвечает некоторое тасование. Проще всего убедиться в этом подсчётом тасований. Для элементов последовательности, задающей тасование, имеем , , , и так далее. Таким образом, количество тасований равно , то есть совпадает с количеством перестановок. С учётом того, что разным тасованиям отвечают разные перестановки, получаем взаимно-однозначное соответствие.
Для перебора тасований мы воспользовались идеями из книги [6]. Начиная с тасования , будем увеличивать элементы этой последовательности на единицу, если это возможно, начиная с самого первого. Если же -й элемент невозможно увеличить, не нарушив неравенства , мы делаем равным единице, после чего переходим к следующему. Сразу скажем, что попытка увеличить первый элемент заведомо обречена на провал, поэтому можно начинать прямо со второго. Если уже не осталось элементов, которые можно увеличить, то есть получено тасование , алгоритм останавливается.
Этот алгоритм очень похож на алгоритм перебора всех -значных натуральных чисел в некоторой системе счисления. Начиная с наименьшего числа, мы стараемся увеличить на единицу первую цифру, а когда это становится невозможным, берёмся за вторую, потом за третью, и так далее. Отличие заключается в том ограничении, которое должно выполняться при увеличении очередной цифры: результат должен быть меньше основания системы счисления. В нашем же случае для каждой «цифры» своё ограничение: нельзя превысить номер «разряда» (нумерация начинается с единицы).
Как отмечается в [6], алгоритм перебора чисел реализован в хорошо знакомом всем устройстве — механическом одометре, который служит для учёта пробега автомобиля, электрической энергии, воды или газа (рисунок 17.1. «Одометр (с сайта http://avto-all.com)»). Одометр состоит из набора колёс с нанесёнными на них цифрами. Колёса связаны таким образом, что полный оборот одного колеса вызывает поворот следующего на одну цифру. Так что когда возможности для увеличения цифр на данном колесе исчерпаны, цифра обнуляется, но зато делается попытка увеличить цифру на следующем колесе. Одометр — ещё одно полезное изобретение Герона Александрийского, сделанное на самой заре автомобилизма — в первом веке нашей эры.
Если бы на первом колесе одометра была лишь одна цифра, на втором — две, на третьем — три, и так далее, мы получили бы прибор для построения тасований. Нам не удалось найти изображение такого усовершенствованного одометра, так что читателю придётся включить воображение.
Проиллюстрируем алгоритм на примере тасований трёхкарточной колоды. Вот полный перечень тасований, получаемых при его помощи: , , , , , .
Обратите внимание на то, что в предыдущих версиях программы мы никак не оговаривали порядок, в котором будут выводиться перестановки. Теперь же мы усложним задачу, потребовав, чтобы перестановки выводились в некотором естественном порядке.
Таким естественным способом упорядочить перестановки будет так называемый лексикографический порядок. При сравнении двух перестановок меньшей будет та, у которой на первом месте будет меньшее число. Если числа в первой позиции у обеих перестановок одинаковы, сравниваются вторые числа. Если же и они равны, сравниваются третьи, и так далее. Например, , если зна́ком мы обозначим отношение лексикографического «меньше». Приведём полное перечисление перестановок из в лексикографическом порядке: Выбор тождественной перестановки в качестве самой первой хорошо согласуется с лексикографическим порядком перебора перестановок, поскольку, очевидно, она является наименьшей в лексикографическом смысле. Между прочим, последней в списке всегда будет перестановка .
Осталось выяснить, как преобразовать некоторую перестановку, чтобы получить из неё следующую в лексикографическом смысле. Введём обозначение для текущей перестановки. Заметим, что для самой последней перестановки в списке выполняются неравенства . Если же найдётся такой номер , что , , это означает, что может быть увеличена при переходе к следующей перестановке за счёт переупорядочения чисел . Так как следующая перестановка является ближайшей в лексикографическом смысле, числа должны остаться без изменений. Число меняется местами с наименьшим из чисел , стоящих правее его (), но при этом бо́льших, чем . Затем числа переупорядочиваются по возрастанию. Это несложно сделать, поскольку как до обмена , так и после него они упорядочены по убыванию — подумайте, почему. Значит, переупорядочения мы добьёмся, обменяв , , …
В результате всех этих манипуляций перестановка превратится в перестановку такую, что , причём наименьшую возможную.
Продемонстрируем превращение в на конкретном примере. Пусть . Ищем : . Теперь среди следующих за найденным числом найдём наименьшее из чисел, бо́льших чем . Это . Меняем местами и : . Числа, следующие за , упорядочены в порядке убывания. Переупорядочим их по возрастанию: . Перестановка готова.