Идеи реализации

Сразу же отвергаем совершенно очевидный и совершенно безнадёжный алгоритм поиска палиндромов, основанный на полном переборе всевозможных комбинаций слов из словаря, и отборе тех из них, которые одинаково читаются в обоих направлениях: таких комбинаций слишком много. Если ищутся n-словные палиндромы, а словарь содержит w слов, комбинаций окажется w n . Для словаря из 1500000 слов двусловных комбинаций будет 2250000000000, а трёхсловных — 3375000000000000000, уже почти астрономическое число.

Мы, как говорится, пойдём другим путём.

Будущий палиндром будем представлять в виде двух списков слов — левого и правого. Изначально оба списка пусты, но в процессе поиска палиндромов они будут заполняться словами. в зависимости от ситуации слова будут добавляться либо в конец левого, либо в начало правого списка. Пока суммарное количество слов в обоих списках меньше n, палиндром ещё не готов, но как только общее количество слов в списках станет равно n, мы объединяем списки и выводим по порядку сначала «левые слова», затем «правые».

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

Будем составлять список кандидатов в палиндромы. Изначально есть единственный кандидат: он состоит из пустых левого и правого списков. Суммарная длина слов в левом списке (она равна нулю) не превосходит суммарной длины слов в правом. В этом случае пытаемся добавлять слова в конец левого списка, иначе — в начало правого.

левый списокправый список

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

левый списокправый список
клопа
лёша
на
нашёл
полке

Теперь вернёмся к началу списка кандидатур. Палиндром клопа … ещё не готов (суммарное количество слов меньше пяти), поэтому пытаемся добавить слово из словаря в правый список (в правый, потому что суммарная длина слов в правом списке меньше, чем в левом). Однако теперь добавляемые слова либо должны заканчиваться на аполк (это зеркальное отражение слова клопа), либо служить окончанием слова аполк, то есть принадлежать списку полк, олк, лк, к. Увы, но таких слов в словаре нет, и поэтому первый кандидат отвергается и удаляется из списка кандидатур:

левый списокправый список
лёша
на
нашёл
полке

Опять возвращаемся к началу списка кандидатур. Вариант лёша … теперь не является безнадёжным, поскольку допускает добавление слова нашёл в правый список. Вариант лёша … заменяется на вариант лёша … нашёл:

левый списокправый список
лёшанашёл
на
нашёл
полке

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

левый списокправый список
лёша нанашёл
лёша нашёлнашёл
на
нашёл
полке

Опять к началу списка. Вариант лёша на … нашёл допускает добавление в правый список слов, заканчивающихся на а:

левый списокправый список
лёша наклопа нашёл
лёша налёша нашёл
лёша нана нашёл
лёша нашёлнашёл
на
нашёл
полке

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

левый списокправый список
лёша налёша нашёл
лёша нана нашёл
лёша нашёлнашёл
на
нашёл
полке

Дальнейшую эволюцию списка кандидатур приводим без пояснений:

левый списокправый список
лёша нана нашёл
лёша нашёлнашёл
на
нашёл
полке

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

Следует признать, что такая задача в полном объёме непосильна для нас. Однако проверить длину и разнообразие слов, составляющих палиндром, вполне возможно. В роли меры качества палиндрома можно было бы взять суммарную длину его слов, однако тогда качественными станут палиндромы, состоящие из многократно повторяющихся зеркально-симметричных слов. Лучшим решением была бы сумма длин слов без учёта их повторений. Но эта величина в среднем пропорциональна количеству слов в палиндроме. Чтобы устранить такую зависимость, поделим сумму длин слов без повторов на количество слов в палиндроме (с учётом их кратности).

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

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

Такое использование словаря приводит к следующей объектной модели:

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

Дадим классу, реализующему словарь, предварительное имя Lexicon. Мы намерены реализовать словарь несколькими способами; имена соответствующих классов будут содержать это имя в своей основе.

Наиболее простым для реализации был бы словарь на основе массива. Добавление слов в массив не должно вызвать никаких затруднений — на то имеется встроенная процедура push. Наиболее трудный и часто вызываемый метод — suitableWords — можно запрограммировать с использованием встроенной процедуры grep.

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

Ещё одна проблема — словарь на основе массива может содержать одно и то же слово в нескольких экземплярах. Можно, конечно, проверять для каждого добавляемого слова, не содержится ли оно уже в словаре. Но тогда по мере роста словаря добавление слов станет всё медленней и медленней, поскольку такая проверка потребует сравнения добавляемого слова с каждым, уже присутствующем в словаре. Мы не станем выполнять такую проверку, полагаясь на то, что источником слов для заполнения словаря будет проверенный файл, не содержащий дубликатов.

Мы реализуем словарь на базе массива — лишь для того, чтобы убедиться в его крайней неэффективности, и чтобы было с чем сравнивать другую, лучшую реализацию. Класс, объектами которого будут словари на базе массива, назовём Lexicon::Array.

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

Напомним, что слово из словаря подходит слева к данной строке, если

Подходящие слова первого рода следует искать среди конкретного перечня кандидатур. Например, для строки стол кандидатами будут с (предлог, наверняка есть в словаре!), ст, сто (числительное, тоже, наверное, есть в словаре!).

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

Поиск слов, подходящих слева (обоих родов), можно заметно ускорить, если массив со словами упорядочен по алфавиту. В этом случае хорошо себя проявляет двоичный поиск, который мы уже обсуждали в главе 11. «Угадывание чисел». Двоичный поиск включает несколько этапов, где в самом начале областью поиска служит весь упорядоченный список, а на каждом следующем этапе эта область сужается примерно вдвое, пока она не станет содержать лишь одно слово. На каждом этапе искомая строка однократно сравнивается с некоторым словом из словаря.

Для словаря, состоящего из n слов, двоичный поиск даст примерно log 2 n этапов. Для словаря из полутора миллиона слов это 21 этап, или 21 сравнение. Это совсем немного. При сплошном поиске в худшем случае (когда искомое слово находится в самом конце списка) пришлось бы выполнить все n cравнений (полтора миллиона), а в среднестатистическом случае — вдвое меньше.

Между прочим, двоичный поиск пригодится и для расположения добавляемых в словарь слов по алфавиту. В этом случае результатом поиска будет место (индекс в массиве), куда новое слово должно быть вставлено. Для вставки слово в произвольное место в массиве удобно применить команду splice. Впрочем, если слово в массиве уже присутствует, то вставлять его повторно не нужно. Мы запрограммируем процедуру поиска слова таким образом, чтобы она всегда возвращала бы пару значений. Первое, логическое, показывает, нашлось ли нужное слово. Второе значение — индекс слова в массиве, если оно найдено, или индекс, который будет у отсутствующего слова после его вставки. Если слово должно быть вставлено в самый конец массива, возвращаемый индекс должен быть равен размеру массива.

Подсчитаем, сколько сравнений слов потребуется при составлении упорядоченного списка из n слов. Вставка второго слова протребует одного сравнения ( 1 = log 2 2 ), вставка последующих слов приведёт к log 2 3 , log 2 4 , log 2 5 , , log 2 n сравнений. Всего получится i = 2 n log 2 i = log 2 i = 2 n i = log 2 n ! .

Чтобы оценить полученное число для n = 1500000 , мы, конечно, не будем вычислять 1500000 ! (страшно даже подумать о том, насколько велико это число). Вместо этого применим уже известную асимптотическую формулу Стирлинга n ! 2 π n n e n : log 2 n ! log 2 2 π n + n log 2 n e . При n = 1500000 эта формула даёт приближённое значение 28610777,… (точное значение, полученное сложением двоичных логарифмов чисел от 2 до 1500000, равно 28610765,…, что позволяет судить о точности формулы Стирлинга при больших n).

А теперь разберёмся с подходящими слева словами второго рода. Заметим, что в упорядоченном массиве они встречаются подряд, непрерывным блоком. Каждое из них, w, удовлетворяет системе лексикографических неравенств s w s , где s — строка, к которой подходит слово w, а s  — строка, получаемая из s заменой последнего символа на следующий за ним по алфавиту. К примеру, если s = стол, то s = стом, поскольку буква м следует по алфавиту сразу за л.

Осталось найти в упорядоченном массиве индексы строк s и s (или мест для их вставки). Индексы подходящих слов второго рода будут удовлетворять неравенствам index s index w < index s .

Вспомним, как происходит поиск нужной книги в библиотеке. Начинается он с каталога. Карточка в каталоге содержит, помимо прочей информации, координаты книги — номер стеллажа, полки в нём, и места на полке. Так что найти книгу — это то же самое, что и найти её карточку. Если бы поиск карточки производился путём полного перебора, поиск в большой библиотеке затягивался бы на годы. Но карточки сгруппированы по первым буквам (названия или фамилии автора): разные буквы — разные ящики с карточками.

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

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

Удобно организовать словарь на основе ассоциативного массива. Ключами в нём служат первые буквы. Значением, соответствующим ключу (первой букве) будут ссылки на словари, содержащие слова, получаемые удалением этой первой буквы. Поясним на примере словаря клопа, лёша, на, нашёл, полке. Такой словарь содержит слова, начинающиеся с букв к, л, н (два слова), п. Ключу к будет отвечать ссылка на словарь из одного слова — лопа. В этом словаре единственному ключу л будет отвечать словарь с ключом о, указывающим на словарь с ключом п, указывающим на словарь с ключом а, указывающим на… На что же будет указывать этот ключ? На словарь, состоящий из единственного пустого слова. Но в пустом слове нет первой буквы. Договоримся представлять словарь, состоящий из пустого слова, как ассоциативный массив, единственным ключом в котором служит пустая строка, а соответствующим значением — неважно что… Пусть это значение будет неопределённым. Во всех остальных случаях ключами в словаре будут односимвольные строки, и лишь для словаря, включающего одну-единственную пустую строку, ключом станет эта самая пустая строка в знак того, что этот словарь последний в древовидной иерархии словарей. Итак, слову клопа (единственному, начинающемуся с буквы к), будет соответствовать такая ветвь дерева:

'к'=>{ 'л'=>{ 'о'=>{ 'п'=>{ 'а'=>{ ''=>undef } } } } }

Подобным же образом будут устроены и ветви для слов лёша, полке:

'л'=>{ 'ё'=>{ 'ш'=>{ 'а'=>{ ''=>undef } } } },
'п'=>{ 'о'=>{ 'л'=>{ 'к'=>{ 'е'=>{ ''=>undef } } } } }

Для слов на, нашёл (у них общая начальная буква, и вторая, кстати, тоже) ветвь устроена интересней:

'н'=>{ 'а'=>{ ''=>undef, 'ш'=>{ 'ё'=>{ 'л'=>{ ''=>undef } } } } }

А вот и полная структура словаря:

{
	'к'=>{ 'л'=>{ 'о'=>{ 'п'=>{ 'а'=>{ ''=>undef } } } } },
	'л'=>{ 'ё'=>{ 'ш'=>{ 'а'=>{ ''=>undef } } } },
	'н'=>
	{
		'а'=>
		{
			''=>undef,
			'ш'=>{ 'ё'=>{ 'л'=>{ ''=>undef } } }
		}
	},
	'п'=>{ 'о'=>{ 'л'=>{ 'к'=>{ 'е'=>{ ''=>undef } } } } }
}

Для словаря бодро, бредил, гордо, лидер получится следующее:

{
	'б'=>
	{
		'о'=>{ 'д'=>{ 'р'=>{ 'о'=>{ ''=>undef } } } },
		'р'=>{ 'е'=>{ 'д'=>{ 'и'=>{ 'л'=>{ ''=>undef } } } } }
	},
	'г'=>{ 'о'=>{ 'р'=>{ 'д'=>{ 'о'=>{ ''=>undef } } } } },
	'л'=>{ 'и'=>{ 'д'=>{ 'е'=>{ 'р'=>{ ''=>undef } } } } }
}

Не зря в описании конструкции словаря мы использовали слово «древовидный». Такую структуру можно наглядно представить в виде дерева, в котором буквы служат узлами, а ссылки — нисходящими ветвями. Особые, терминальные узлы, то есть такие, из которых ветви уже не выходят, обозначены на рисунке знаком заземления. Их ровно столько, сколько слов содержит словарь. Терминальным узлам соответствуют фрагменты ''=>undef. На вершине этой иерархии находится особый узел (зелёный) — сам словарь.

Дерево с символьными узлами является хотя и очень простой, но всё же весьма расточительной (в смысле использования памяти) структурой. Каждый символ каждого размещённого в дереве слова представлен как символьный узел в дереве. Из каждого символьного узла ведёт как минимум одна ссылка — либо в другой символьный узел, либо в терминальный.

Между тем значительная часть узлов обычно содержит только одну исходящую ветвь дерева. Можно существенно сэкономить память, если объединить в одну строку неветвящиеся цепочки символьных узлов. Во-первых, строковое значение в Perl содержит, помимо цепочки символов, также дополнительную информацию (длину строки и набор дополнительных атрибутов), так что одна строка требует намного меньше памяти, чем все вместе взятые односимвольные строки для каждого символа этой строки. Накладные расходы памяти в односимвольной строке значительно превышают память, занятую самим символом. Во-вторых, сжатие символьных цепочек позволит сэкономить на ссылках, соединяющих символы в таких цепочках.

На рисунке показан результат сжатия для уже рассматривавшихся деревьев:

Сжатие дерева достигается небольшим усложнением алгоритмов добавления нового слова и поиска слов, подходящих слева.

Как уже говорилось, палиндром на этапе его построения удобно представить в виде пары списков слов — левого и правого. Запрограммируем класс Palindrome, объектами которого будут соответствующие структуры данных. Вот перечень методов класса Palindrome:

new()

Конструктор.

copy()

Конструктор копий. Создаёт и возвращает новый объект — точную копию данного объекта.

totalWords()

Возвращает количество слов в палиндроме (сумму длин левого и правого списков слов).

isPalindrome()

Возвращает истинное значение, если слова в левом и правом списках образуют палиндром, и ложное значение в противном случае.

toString()

Возвращает строковое представление палиндрома (слова из обоих списков, соединённые пробелами).

quality()

Возвращает числовую меру качества палиндрома.

whatToDo()

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

К примеру, для недоделанного палиндрома гни … смокинг будет возвращаться список (0, 'комс'), а для палиндрома ася молоко … мяса — (1, 'около').

add($side, $word)

Дополняет палиндром слева или справа (в зависимости от параметра $side, принимающего значение 0 или 1) словом $word.

Информатика-54© А. Н. Швец