Программирование: теоремы и задачи [Александр Ханиевич Шень] (pdf) читать онлайн

-  Программирование: теоремы и задачи  1.62 Мб, 321с. скачать: (pdf) - (pdf+fbd)  читать: (полностью) - (постранично) - Александр Ханиевич Шень

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]
  [Оглавление]

А. ШЕНЬ

ðòïçòáííéòï÷áîéå
ÔÅÏÒÅÍÙ É ÚÁÄÁÞÉ
Издание шестое, дополненное

Москва

Издательство МЦНМО

2017

УДК 519.671
ББК 22.18
Ш47

Ш47

Шень А.

Программирование: теоремы и задачи. | 6-е изд., дополненное. | М.: МЦНМО, 2017. | 320 с.: ил.
ISBN 978-5-4439-0685-0
Книга содержит задачи по программированию различной трудности.
Большинство задач приводятся с решениями. Цель книги | научить основным методам построения корректных и быстрых алгоритмов.
Для учителей информатики, старшеклассников, студентов младших курсов высших учебных заведений. Пособие может быть использовано на кружковых и факультативных занятиях в общеобразовательных учреждениях, в
школах с углублённым изучением математики и информатики, а также в
иных целях, не противоречащих законодательству РФ.
Предыдущее издание книги вышло в 2014 г.

ББК 22.18

ISBN 978-5-4439-0685-0

c


А. Шень, 1995, 2017

Несколько замечаний вместо предисловия
Книга написана по материалам занятий программированием со
школьниками математических классов школы Ђ 57 г. Москвы
и студентами младших курсов (Московский государственный
университет, Независимый московский университет, университет
г. Uppsala, Швеция ).
Книга написана в убеждении, что программирование имеет свой предмет,
не сводящийся ни к конкретным языкам и системам, ни к методам
построения быстрых алгоритмов.
Кто-то однажды сказал, что можно убедить в правильности алгоритма,
но не в правильности программы. Одна из целей книги | попытаться
продемонстрировать, что это не так.
В принципе, возможность практического исполнения программ не
является непременным условием изучения программирования. Однако она
является сильнейшим стимулом | без такого стимула вряд ли у кого
хватит интереса и терпения.
Выбранный жанр книги по необходимости ограничивает её
«программированием в малом», оставляя в стороне необходимую часть
программистского образования | работу по модификации больших
программ. Автор продолжает мечтать о наборе учебных программных
систем эталонного качества, доступных для модификации школьниками.
Кажется, Хоар сказал, что эстетическая прелесть программы | это не
архитектурное излишество, а то, что отличает в программировании
успех от неудачи. Если, решая задачи из этой книги, читатель
почувствует прелесть хорошо написанной программы, в которой «ни
убавить, ни прибавить», и сомнения в правильности которой кажутся
нелепыми, то автор будет считать свою цель достигнутой.
Характер глав различен: в одних предлагается набор мало связанных друг
с другом задач с решениями, в других по существу излагается
один-единственный алгоритм. Темы глав во многом пересекаются, и мы
предпочли кое-какие повторения формальным ссылкам.
Уровень трудности задач и глав весьма различен. Мы старались
включить как простые задачи, которые могут быть полезны для
начинающих, так и трудные задачи, которые могут посадить в лужу
сильного школьника. (Хоть и редко, но это бывает полезно.)

В качестве языка для записи программ был выбран паскаль. Он
достаточно прост и естествен, имеет неплохие реализации (например,
старые компиляторы Turbo Pascal фирмы Borland были выложены для
бесплатного скачивания ) и позволяет записать решения всех
рассматриваемых задач. Возможно, Модула-2 или Оберон были бы более
изящным выбором, но они менее доступны.
Практически все задачи и алгоритмы, разумеется, не являются новыми.

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

(и особенно доказательства ) изложены более коротко и отчётливо.

Это не только и не столько учебник для школьника, сколько справочник
и задачник для преподавателя, готовящегося к занятию.
Об «авторских правах»: право формулировать задачу и объяснять её
решение является неотчуждаемым естественным правом всякого, кто на
это способен. В соответствии с этим текст является свободно
распространяемым. Адреса автора: shen@mccme.ru, sasha.shen@gmail.com,
alexander.shen@lirmm.fr. Сказанное относится к русскому тексту; все
права на переводы переданы издательству Springer.
При подготовке текста использовалась (свободно распространяемая )
версия LATEXа, включающая стилевые файлы, составленные
С. М. Львовским (см. ftp://ftp.mccme.ru/pub/tex/).
Я рад случаю поблагодарить всех, с кем имел честь сотрудничать, преподавая программирование, особенно тех, кто был «по другую сторону баррикады», а также всех приславших мне замечания и исправления

(специаль-

ная благодарность | Ю. В. Матиясевичу ). Автор благодарит В. Шувалова
за хлопоты по вёрстке, а также издательство МЦНМО. Благодарю также Институт проблем передачи информации РАН, Американское матема-

(фонд помощи бывшему СССР ), фонд Сороса, универ(Швеция ),
CNRS (Франция ), Ecole Normale (Лион, Франция ), LIF (Марсель, Франция ),
LIRMM (Монпелье, Франция), университет г. Уппсала (Швеция ), агенттическое общество

ситет г. Бордо, фонд «Культурная инициатива», фонды STINT

ство ANR (грант RaCAF, ANR-15-CE40-0016-01), Российский фонд фундаментальных исследований

(гранты 02-01-22001 НЦНИа, 03-01-00475 и дру-

гие ), а также Совет поддержки научных школ при Президенте РФ (грант
НШ-358.2003.1 ) за поддержку. Вместе с тем содержание книги отражает
точку зрения автора, за ошибки которого указанные организации и лица
ответственности не несут

(и наоборот ).

Содержание
1. Переменные, выражения, присваивания

8

1.1. Задачи без массивов . . . . . . . . . . . . . . . . . . . . . . 8
1.2. Массивы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
1.3. Индуктивные функции (по А. Г. Кушниренко) . . . . . . . 38

2. Порождение комбинаторных объектов

2.1.
2.2.
2.3.
2.4.
2.5.
2.6.
2.7.

Размещения с повторениями . . .
Перестановки . . . . . . . . . . . .
Подмножества . . . . . . . . . . .
Разбиения . . . . . . . . . . . . . .
Коды Грея и аналогичные задачи
Несколько замечаний . . . . . . .
Подсчёт количеств . . . . . . . . .

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

.
.
.
.
.
.
.

43

43
44
45
48
49
55
57

3. Обход дерева. Перебор с возвратами

60

4. Сортировка

72

3.1. Ферзи, не бьющие друг друга: обход дерева позиций . . . 60
3.2. Обход дерева в других задачах . . . . . . . . . . . . . . . 70

4.1.
4.2.
4.3.
4.4.
4.5.

Квадратичные алгоритмы . . . . . . . . . . . . . . .
Алгоритмы порядка 𝑛 log 𝑛 . . . . . . . . . . . . . . .
Применения сортировки . . . . . . . . . . . . . . . . .
Нижние оценки для числа сравнений при сортировке
Родственные сортировке задачи . . . . . . . . . . . .

5. Конечные автоматы и обработка текстов

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

72
73
80
82
84
90

5.1. Составные символы, комментарии и т. п. . . . . . . . . . . 90
5.2. Ввод чисел . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92

6

Содержание

6. Типы данных

6.1.
6.2.
6.3.
6.4.

Стеки . . . . .
Очереди . . . .
Множества . .
Разные задачи

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

7. Рекурсия

7.1.
7.2.
7.3.
7.4.

Примеры рекурсивных программ . . . . . . . .
Рекурсивная обработка деревьев . . . . . . . . .
Порождение комбинаторных объектов, перебор
Другие применения рекурсии . . . . . . . . . . .

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

96

. 96
. 103
. 111
. 115
117

. 117
. 120
. 123
. 127

8. Как обойтись без рекурсии

135

9. Разные алгоритмы на графах

146

8.1. Таблица значений (динамическое программирование) . . 135
8.2. Стек отложенных заданий . . . . . . . . . . . . . . . . . . 140
8.3. Более сложные случаи рекурсии . . . . . . . . . . . . . . . 143
9.1. Кратчайшие пути . . . . . . . . . . . . . . . . . . . . . . . 146
9.2. Связные компоненты, поиск в глубину и ширину . . . . . 152
9.3. Сети, потоки и разрезы . . . . . . . . . . . . . . . . . . . . 157

10. Сопоставление с образцом

10.1.
10.2.
10.3.
10.4.
10.5.
10.6.
10.7.
10.8.

Простейший пример . . . . . . . . . . . . . .
Повторения в образце | источник проблем
Вспомогательные утверждения . . . . . . . .
Алгоритм Кнута { Морриса { Пратта . . . .
Алгоритм Бойера { Мура . . . . . . . . . . .
Алгоритм Рабина . . . . . . . . . . . . . . . .
Более сложные образцы и автоматы . . . . .
Суффиксные деревья . . . . . . . . . . . . . .

11. Анализ игр

11.1.
11.2.
11.3.
11.4.
11.5.

Примеры игр . . . . . . . . . . .
Цена игры . . . . . . . . . . . . .
Вычисление цены: полный обход
Альфа-бета-процедура . . . . . .
Ретроспективный анализ . . . .

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

178

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

.
.
.
.
.
.
.
.

. 178
. 181
. 183
. 183
. 186
. 188
. 190
. 197

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

. 210
. 212
. 220
. 223
. 227

210

7

Содержание
12. Оптимальное кодирование

12.1.
12.2.
12.3.
12.4.

Коды . . . . . . . . . . . . . . . . . .
Неравенство Крафта { Макмиллана
Код Хаффмана . . . . . . . . . . . .
Код Шеннона { Фано . . . . . . . . .

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

.
.
.
.

230

. 230
. 231
. 235
. 237

13. Представление множеств. Хеширование

241

14. Деревья. Сбалансированные деревья

250

15. Контекстно-свободные грамматики

269

13.1. Хеширование с открытой адресацией . . . . . . . . . . . . 241
13.2. Хеширование со списками . . . . . . . . . . . . . . . . . . . 244
14.1. Представление множеств с помощью деревьев . . . . . . . 250
14.2. Сбалансированные деревья . . . . . . . . . . . . . . . . . . 258
15.1. Общий алгоритм разбора . . . . . . . . . . . . . . . . . . . 269
15.2. Метод рекурсивного спуска . . . . . . . . . . . . . . . . . 275
15.3. Алгоритм разбора для LL(1)-грамматик . . . . . . . . . . 286

16. Синтаксический разбор слева направо (LR)

16.1.
16.2.
16.3.
16.4.
16.5.

LR-процессы . . . . . . . . . . . . . . . . . . .
LR(0)-грамматики . . . . . . . . . . . . . . . .
SLR(1)-грамматики . . . . . . . . . . . . . . .
LR(1)-грамматики, LALR(1)-грамматики . .
Общие замечания о разных методах разбора .

Книги для чтения
Предметный указатель
Указатель имён

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

.
.
.
.
.

294

. 294
. 300
. 306
. 307
. 310
312
313
319

1. ПЕРЕМЕННЫЕ,
ВЫРАЖЕНИЯ,
ПРИСВАИВАНИЯ
1.1. Задачи без массивов
1.1.1. Даны две целые переменные a, b. Составьте фрагмент программы, после исполнения которого значения переменных поменялись
бы местами (новое значение a равно старому значению b и наоборот).
Решение.

Введём дополнительную целую переменную t.

t := a;
a := b;
b := t;



Попытка обойтись без дополнительной переменной, написав
a := b;
b := a;

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

Начальные значения a и b обозначим a0, b0.

a := a + b; {a = a0 + b0, b = b0}
b := a - b; {a = a0 + b0, b = a0}
a := a - b; {a = b0, b = a0}



1.1. Задачи без массивов

9

1.1.3. Дано целое число а и натуральное (целое неотрицательное)
число n. Вычислите an . Другими словами, необходимо составить программу, при исполнении которой значения переменных а и n не меняются, а значение некоторой другой переменной (например, b) становится
равным an . (При этом разрешается использовать и другие переменные.)
Решение. Введём целую переменную k, которая меняется от 0 до n,
причём поддерживается такое свойство: b = ak ).

k := 0; b := 1;
{b = a в степени k}
while k n do begin
k := k + 1;
b := b * a;
end;



Другое решение той же задачи:
k := n; b := 1;
{a в степени n = b * (a в степени k)}
while k 0 do begin
k := k - 1;
b := b * a;
end;
1.1.4. Решите предыдущую задачу, если требуется, чтобы число
действий (выполняемых операторов присваивания) было порядка log n
(то есть не превосходило бы 𝐶 log n для некоторой константы 𝐶 ; log n |
это степень, в которую нужно возвести 2, чтобы получить n).
Решение. Внесём некоторые изменения во второе из предложенных
решений предыдущей задачи:

k := n; b := 1; c:=a;
{a в степени n = b * (c в степени k)}
while k 0 do begin
if k mod 2 = 0 then begin
k:= k div 2;
c:= c*c;
end else begin
k := k - 1;
b := b * c;
end;
end;

10

1. Переменные, выражения, присваивания

Каждый второй раз (не реже) будет выполняться первый вариант
оператора выбора (если k нечётно, то после вычитания единицы становится чётным), так что за два цикла величина k уменьшается по
крайней мере вдвое.

1.1.5. Даны натуральные числа а, b. Вычислите произведение a · b,
используя в программе лишь операции +, -, =, .
Решение.

k := 0; c := 0;
{инвариант: c = a * k}
while k b do begin
k := k + 1;
c := c + a;
end;
{c = a * k и k = b, следовательно, c = a * b}



1.1.6. Даны натуральные числа а и b. Вычислите их сумму а + b.
Используйте операторы присваивания лишь вида

⟨переменная1⟩ := ⟨переменная2⟩,
⟨переменная⟩ := ⟨число⟩,

⟨переменная1⟩ := ⟨переменная2⟩ + 1.
Решение.

...
{инвариант: c = a + k}
...



1.1.7. Дано натуральное (целое неотрицательное) число а и целое
положительное число d. Вычислите частное q и остаток r при делении а
на d, не используя операций div и mod.
Решение. Согласно определению, a = q · d + r, 0 6 r < d.

{a >= 0; d > 0}
r := a; q := 0;
{инвариант: a = q * d + r, 0 = d}
r := r - d; {r >= 0}
q := q + 1;
end;



1.1. Задачи без массивов

11

1.1.8.

Дано натуральное n, вычислите n! (0! = 1, n! = n · (n − 1)!). 

1.1.9.

Последовательность Фибоначчи определяется так: a0 = 0,

a1 = 1, ak = ak-1 + ak-2 при k > 2. Дано n, вычислите an .



1.1.10. Та же задача, если требуется, чтобы число операций было
пропорционально log n. (Переменные должны быть целочисленными.)
[Указание. Пара соседних чисел Фибоначчи получается из предыдущей умножением на матрицу


⃦ 1

⃦ 1


1 ⃦

0 ⃦

| так что задача сводится к возведению матрицы в степень n. Это
можно сделать за 𝐶 log n действий тем же способом, что и для чисел.]

1.1.11.

Дано натуральное n, вычислите
1
1
1
+ + ... + .
0! 1!
n!



1.1.12. То же, если требуется, чтобы количество операций (выполненных команд присваивания) было бы порядка n (не более 𝐶 n для некоторой константы 𝐶 ).
Решение. Инвариант: sum = 1/1! + . . . + 1/k!, last = 1/k! (важно не
вычислять заново каждый раз k!).

1.1.13. Даны два натуральных числа a и b, не равные нулю одновременно. Вычислите НОД(a,b) | наибольший общий делитель а и b.
Решение. Вариант 1.

if a < b then begin
k := a;
end else begin
k := b;
end;
{k = min (a,b)}
{инвариант: никакое число, большее k, не является
общим делителем (оно больше одного из чисел a,b)}
while not ((a mod k = 0) and (b mod k = 0)) do begin
k := k - 1;
end;
{k - общий делитель, большие - нет}

12

1. Переменные, выражения, присваивания

Вариант 2 (алгоритм Евклида). Будем считать, что НОД(0,0)=0.
Тогда НОД(a,b) = НОД(a-b,b) = НОД(a,b-a); НОД(a,0) = НОД(0,a) = a
для всех a, b > 0.
m := a; n := b;
{инвариант: НОД (a,b) = НОД (m,n); m,n >= 0 }
while not ((m=0) or (n=0)) do begin
if m >= n then begin
m := m - n;
end else begin
n := n - m;
end;
end;
{m = 0 или n = 0}
if m = 0 then begin
k := n;
end else begin {n = 0}
k := m;
end;



1.1.14. Напишите модифицированный вариант алгоритма Евклида,
использующий соотношения НОД(a,b) = НОД(a mod b, b) при a > b,
НОД(a,b) = НОД(a, b mod a) при b > a.

1.1.15. Даны натуральные a и b, не равные 0 одновременно. Найдите
d = НОД(a,b) и такие целые x и y, что d = a · x + b · y.
Решение. Добавим в алгоритм Евклида переменные p, q, r, s и впишем в инвариант условия m = p*a+q*b; n = r*a+s*b.

m:=a; n:=b; p := 1; q := 0; r := 0; s := 1;
{инвариант: НОД (a,b) = НОД (m,n); m,n >= 0
m = p*a + q*b; n = r*a + s*b.}
while not ((m=0) or (n=0)) do begin
if m >= n then begin
m := m - n; p := p - r; q := q - s;
end else begin
n := n - m; r := r - p; s := s - q;
end;
end;
if m = 0 then begin
k :=n; x := r; y := s;
end else begin
k := m; x := p; y := q;
end;



1.1. Задачи без массивов

13

1.1.16. Решите предыдущую задачу, используя в алгоритме Евклида деление с остатком.

1.1.17. (Э. Дейкстра) Добавим в алгоритм Евклида дополнительные
переменные u, v, z:

m := a; n := b; u := b; v := a;
{инвариант: НОД (a,b) = НОД (m,n); m,n >= 0 }
while not ((m=0) or (n=0)) do begin
if m >= n then begin
m := m - n; v := v + u;
end else begin
n := n - m; u := u + v;
end;
end;
if m = 0 then begin
z:= v;
end else begin {n=0}
z:= u;
end;

Докажите, что после исполнения алгоритма значение z равно удвоенному наименьшему общему кратному чисел a, b: z = 2 · НОК(a,b).
Решение. Заметим, что величина m · u + n · v не меняется в ходе выполнения алгоритма. Остаётся воспользоваться тем, что вначале она
равна 2ab и что НОД(a, b) · НОК(a, b) = ab.

1.1.18. Напишите вариант алгоритма Евклида, использующий соотношения
НОД(2a, 2b) = 2 · НОД(a, b),
НОД(2a, b) = НОД(a, b) при нечётном b,
не включающий деления с остатком, а использующий лишь деление на 2
и проверку чётности. (Число действий должно быть порядка log k для
исходных данных, не превосходящих k.)
Решение.

m:= a; n:=b; d:=1;
{НОД(a,b) = d * НОД(m,n)}
while not ((m=0) or (n=0)) do begin
if (m mod 2 = 0) and (n mod 2 = 0) then begin
d:= d*2; m:= m div 2; n:= n div 2;
end else if (m mod 2 = 0) and (n mod 2 = 1) then begin

14

1. Переменные, выражения, присваивания
m:= m div 2;
end else if(m mod 2 = 1) and (n mod 2 = 0) then begin
n:= n div 2;
end else if (m mod 2=1) and (n mod 2=1) and (m>=n) then begin
m:= m-n;
end else if (m mod 2=1) and (n mod 2=1) and (m ответ=d*n; n=0 => ответ=d*m}

Оценка числа действий: каждое второе действие делит хотя бы одно из
чисел m и n пополам.

1.1.19. Дополните алгоритм предыдущей задачи поиском x и y, для
которых ax + by = НОД(a, b).
Решение. (Идея сообщена Д. Звонкиным.) Прежде всего заметим,
что одновременное деление a и b пополам не меняет искомых x и y. Поэтому можно считать, что с самого начала одно из чисел a и b нечётно.
(Это свойство будет сохраняться и далее.)
Теперь попытаемся, как и раньше, хранить такие числа p, q, r, s, что
m = ap + bq,
n = ar + bs.

Проблема в том, что при делении, скажем, m на 2 надо разделить p
и q на 2, и они перестанут быть целыми (а станут двоично-рациональными). Двоично-рациональное число естественно хранить в виде пары
⟨числитель, показатель степени двойки в знаменателе⟩. В итоге мы получаем d в виде комбинации a и b с двоично-рациональными коэффициентами. Иными словами, мы имеем
2i d = ax + by

для некоторых целых x, y и натурального i. Что делать, если i > 1?
Если x и y чётны, то на 2 можно сократить. Если это не так, положение
можно исправить преобразованием
x := x + b,
y := y − a

(оно не меняет ax + by). Убедимся в этом. Напомним, что мы считаем,
что одно из чисел a и b нечётно. Пусть это будет a. Если при этом

1.1. Задачи без массивов

15

y чётно, то и x должно быть чётным (иначе ax + by будет нечётным).
А при нечётном y вычитание из него нечётного a делает y чётным. 
1.1.20. Составьте программу, печатающую квадраты всех натуральных чисел от 0 до заданного натурального n.
Решение.

k:=0;
writeln (k*k);
{инвариант: k 0 (другими словами, требуется печатать только простые числа и произведение напечатанных
чисел должно быть равно n; если n = 1, печатать ничего не надо).

16

1. Переменные, выражения, присваивания
Решение.

Вариант 1.

k := n;
{инвариант: произведение напечатанных чисел и k равно
n, напечатаны только простые числа}
while not (k = 1) do begin
l := 2;
{инвариант: k не имеет делителей в интервале (1,l)}
while k mod l 0 do begin
l := l + 1;
end;
{l - наименьший делитель k, больший 1, следовательно,
простой}
writeln (l);
k:=k div l;
end;

Вариант 2.
k := n; l := 2;
{произведение k и напечатанных чисел равно n; напечатанные
числа просты; k не имеет делителей, меньших l}
while not (k = 1) do begin
if k mod l = 0 then begin
{k делится на l и не имеет делителей,
меньших l, значит, l просто}
k := k div l;
writeln (l);
end else begin
{ k не делится на l }
l := l+1;
end;
end;



1.1.23. Составьте программу решения предыдущей задачи, использующую тот факт, что составное число имеет делитель, не превосходящий квадратного корня из этого числа.
Решение. Во втором варианте решения вместо l:=l+1 можно написать

if l*l > k then begin
l:=k;
end else begin
l:=l+1;
end;



1.1. Задачи без массивов
1.1.24.

простым.

17

Проверьте, является ли заданное натуральное число n > 1


1.1.25. (Для знакомых с основами алгебры) Дано целое гауссово
число n + m 𝑖 (принадлежащее Z[𝑖]).
(a) Проверьте, является ли оно простым (в Z[𝑖]).
(б) Напечатайте его разложение на простые (в Z[𝑖]) множители. 
1.1.26. Разрешим применять команды write(i) лишь при i = 0, 1,
2, . . . , 9. Составьте программу, печатающую десятичную запись заданного натурального числа n > 0. (Случай n = 0 явился бы некоторым исключением, так как обычно нули в начале числа не печатаются, а для
n = 0 | печатаются.)
Решение.

base:=1;
{base - степень 10, не превосходящая n}
while 10 * base =0 (при данном k) }
k := k + 1;
s := s + t;
end;
{k*k >= n, поэтому s = количество всех решений
неравенства}

Здесь ... | пока ещё не написанный кусок программы, который
будет таким:
l := 0; t := 0;
{инвариант: t = число решений
неравенства k*k + y*y < n c 0 0, для которых не принадлежит X;
| число пар натуральных x, y, для которых x < k и принадлежит X.
Обозначим эти условия через (И).
∙ s

k := 0; l := 0;
while принадлежит X do begin
l := l + 1;
end;
{k = 0, l - минимальное среди тех l >= 0,
для которых не принадлежит X}
s := 0;
{инвариант: И}
while not (l = 0) do begin
s := s + l;
{s - число точек в столбцах до k-го включительно}
k := k + 1;
{точка лежит вне X, но, возможно, её надо сдвинуть
вниз, чтобы восстановить И}
while (l 0) and ( не принадлежит X) do begin
l := l - 1;
end;
end;
{И, l = 0, поэтому k-ый столбец и все следующие пусты, а
s равно искомому числу}

20

1. Переменные, выражения, присваивания

Оценка√числа действий очевидна: сначала мы движемся вверх не более
чем на √n шагов, а затем вниз и вправо | в каждую сторону не более
чем на n шагов.

1.1.30. Даны натуральные числа n и k, n > 1. Напечатайте k десятичных знаков числа 1/n. (При наличии двух десятичных разложений
выбирается то из них, которое не содержит девятки в периоде.) Программа должна использовать только целые переменные.
Решение. Сдвинув в десятичной записи числа 1/n запятую на k мест
вправо, получим число 10k /n. Нам надо напечатать его целую часть,
то есть разделить 10k на n нацело. Стандартный способ требует использования больших по величине чисел, которые могут выйти за
границы диапазона представимых чисел. Поэтому мы сделаем иначе
(следуя обычному методу «деления уголком») и будем хранить «остаток» r:
l := 0; r := 1;
{инв.: напечатано l разрядов 1/n, осталось напечатать
k - l разрядов дроби r/n}
while l k do begin
write ( (10 * r) div n);
r := (10 * r) mod n;
l := l + 1;
end;



1.1.31. Дано натуральное число n > 1. Определите длину периода
десятичной записи дроби 1/n.
Решение. Период дроби равен периоду в последовательности остатков (докажите это; в частности, надо доказать, что он не может быть
меньше). Кроме того, в этой последовательности все периодически повторяющиеся члены различны, а предпериод имеет длину не более n.
Поэтому достаточно найти (n + 1)-й член последовательности остатков и затем минимальное k, при котором (n + 1 + k)-й член совпадает
с (n + 1)-м.

l := 0; r := 1;
{инвариант: r/n = результат отбрасывания l знаков в 1/n}
while l n+1 do begin
r := (10 * r) mod n;
l := l + 1;
end;
c := r;

1.1. Задачи без массивов
{c = (n+1)-ый член последовательности остатков}
r := (10 * r) mod n;
k := 1;
{r = (n+k+1)-ый член последовательности остатков}
while r c do begin
r := (10 * r) mod n;
k := k + 1;
end;

21



(Сообщил Ю. В. Матиясевич) Дана функция f : {1 . . . N} →
Найдите период последовательности 1, f(1), f(f(1)), . . .
Количество действий должно быть пропорционально суммарной длине предпериода и периода (эта сумма может быть существенно меньше N).
Решение. Если отбросить начальный кусок, последовательность периодична, причём все члены периода различны.
1.1.32.

→ {1 . . . N}.

{Обозначение: f[n,1]=f(f(...f(1)...)) (n раз)}
k:=1; a:=f(1); b:=f(f(1));
{a=f[k,1]; b=f[2k,1]}
while a b do begin
k:=k+1; a:=f(a); b:=f(f(b));
end;
{a=f[k,1]=f[2k,1]; f[k,1] входит в периодическую часть}
l:=1; b:=f(a);
{b=f[k+l,1]; f[k,1],...,f[k+l-1,1] различны}
while a b do begin
l:=l+1; b:=f(b);
end;
{период равен l}



1.1.33. (Э. Дейкстра) Функция f с натуральными аргументами и
значениями определена так: f(0) = 0, f(1) = 1, f(2n) = f(n), f(2n + 1) =
= f(n) + f(n + 1). Составьте программу вычисления f(n) по заданному n, требующую порядка log n операций.
Решение.

k := n; a := 1; b := 0;
{инвариант: 0 2.
[Указание. Храните коэффициенты в выражении f(n) через три соседних числа.]

1.1.35. Даны натуральные числа а и b, причём b > 0. Найдите частное и остаток при делении a на b, оперируя лишь с целыми числами
и не используя операции div и mod, за исключением деления на 2 чётных чисел; число шагов не должно превосходить 𝐶1 log(a/b) + 𝐶2 для
некоторых констант 𝐶1 , 𝐶2 .
Решение.

b1 := b;
while b1 a, b1 = b * (некоторая степень 2)}
q:=0; r:=a;
{инвариант: q, r - частное и остаток при делении a на b1,
b1 = b * (некоторая степень 2)}
while b1 b do begin
b1 := b1 div 2 ; q := q * 2;
{ a = b1 * q + r, 0 = b1 then begin
r := r - b1;
q := q + 1;
end;
end;
{q, r - частное и остаток при делении a на b}



1.2. Массивы

23

1.2. Массивы

В следующих задачах переменные x, y, z предполагаются описанными как array[1..n] of integer (где n | некоторое натуральное число,
большее 0), если иное не оговорено явно.
1.2.1. Заполните массив x нулями. (Это означает, что нужно составить фрагмент программы, после выполнения которого все значения
x[1]..x[n] равнялись бы нулю, независимо от начального значения переменной x.)
Решение.

i := 0;
{инвариант: первые i значений x[1]..x[i] равны 0}
while i n do begin
i := i + 1;
{x[1]..x[i-1] = 0}
x[i] := 0;
end;



1.2.2. Подсчитайте количество нулей в массиве x. (Составьте фрагмент программы, не меняющий значения x, после исполнения которого
значение некоторой целой переменной k равнялось бы числу нулей среди
компонент массива x.)
Решение.

...
{инвариант: k = число нулей среди x[1]..x[i] }
...



1.2.3. Не используя оператора присваивания для массивов, составьте фрагмент программы, эквивалентный оператору x:=y.
Решение.

i := 0;
{инвариант: значение y не изменилось, x[l]=y[l] при l max then begin
max := x[i];
end;
end;



1.2.5. Дан массив x: array[1..n] of integer, причём известно, что
x[1] 6 x[2] 6 . . . 6 x[n]. Найдите количество различных чисел среди

элементов этого массива.
Решение. Вариант 1.

i := 1; k := 1;
{инвариант: k - количество различных среди x[1]..x[i]}
while i n do begin
i := i + 1;
if x[i] x[i-1] then begin
k := k + 1;
end;
end;

Вариант 2. Искомое число на 1 больше количества тех чисел i из
1..n-1, для которых x[i] не равно x[i+1].
k := 1;
for i := 1 to n-1 do begin
if x[i] x[i+1] then begin
k := k + 1;
end;
end;



1.2.6. Дан массив x: array[1..n] of integer. Найдите количество
различных чисел среди элементов этого массива. (Число действий должно быть порядка n2 .)

1.2.7. Та же задача, если требуется, чтобы количество действий
было порядка n log n.
[Указание. См. главу 4 (Сортировка).]


1.2. Массивы

25

1.2.8. Та же задача, если известно, что все элементы массива |
числа от 1 до k и число действий должно быть порядка n + k.

1.2.9. (Сообщил А. Л. Брудно) Прямоугольное поле m × n разбито на
mn квадратных клеток. Некоторые клетки покрашены в чёрный цвет.
Известно, что все чёрные клетки могут быть разбиты на несколько
непересекающихся и не имеющих общих вершин чёрных прямоугольников. Считая, что цвета клеток даны в виде массива типа

array [1..m] of array [1..n] of boolean;

подсчитайте число чёрных прямоугольников, о которых шла речь. Число действий должно быть порядка mn.
Решение. Число прямоугольников равно числу их левых верхних
углов. Является ли клетка верхним углом, можно узнать, посмотрев
на её цвет, а также цвет верхнего и левого соседей. (Не забудьте, что
их может не быть, если клетка с краю.)

1.2.10. Дан массив x[1]..x[n] целых чисел. Не используя других
массивов, переставьте элементы массива в обратном порядке.
Решение. Элементы x[i] и x[n+1-i] нужно поменять местами для
всех i, для которых i < n + 1 − i, то есть 2i < n + 1 ⇔ 2i 6 n ⇔
⇔ i 6 n div 2:
for i := 1 to n div 2 do begin
...поменять местами x[i] и x[n+1-i];
end;



1.2.11. (Из книги Д. Гриса) Дан массив целых чисел x[1]..x[m+n],
рассматриваемый как соединение двух его отрезков: начала x[1]..x[m]
длины m и конца x[m+1]..x[m+n] длины n. Не используя дополнительных массивов, переставьте начало и конец. (Число действий порядка
m + n.)
Решение. Вариант 1. Перевернём (расположим в обратном порядке)
отдельно начало и конец массива, а затем перевернём весь массив как
единое целое.
Вариант 2. (А. Г. Кушниренко) Рассматривая массив записанным по
кругу, видим, что требуемое действие | поворот круга. Как известно,
поворот есть композиция двух осевых симметрий.
Вариант 3. Рассмотрим более общую задачу | обмен двух участков массива x[p+1]..x[q] и x[q+1]..x[r]. Предположим, что длина
левого участка (назовём его 𝐴) не больше длины правого (назовём

26

1. Переменные, выражения, присваивания

его 𝐵 ). Выделим в 𝐵 начало той же длины, что и 𝐴, назовём его 𝐵1 ,
а остаток 𝐵2 . (Так что 𝐵 = 𝐵1 + 𝐵2 , если обозначать плюсом приписывание массивов друг к другу.) Нам надо из 𝐴 + 𝐵1 + 𝐵2 получить
𝐵1 + 𝐵2 + 𝐴. Меняя местами участки 𝐴 и 𝐵1 | они имеют одинаковую
длину, и сделать это легко, | получаем 𝐵1 + 𝐴 + 𝐵2 , и осталось поменять местами 𝐴 и 𝐵2 . Тем самым мы свели дело к перестановке двух
отрезков меньшей длины. Итак, получаем такую схему программы:
p := 0; q := m; r := m + n;
{инвариант: осталось переставить x[p+1..q], x[q+1..r]}
while (p q) and (q r) do begin
{оба участка непусты}
if (q - p) x[n]}
t:=k+1;
{t ... > x[t] больше x[k]}
while (t < n) and (x[t+1] > x[k]) do begin
t:=t+1;
end;
{x[k+1] > ... > x[t] > x[k] > x[t+1] > ... > x[n]}
...обменять x[k] и x[t]
{x[k+1] > ... > x[n]}
...переставить участок x[k+1]..x[n] в обратном порядке
Замечание.

не определено.



Программа имеет знакомый дефект: если t=n, то x[t+1]

2.3. Подмножества
2.3.1. Для заданных n и k (k 6 n) перечислите все k-элементные подмножества множества {1..n}.
Решение. Будем представлять каждое подмножество последовательностью x[1]..x[n] нулей и единиц длины n, в которой ровно k единиц. (Другой способ представления разберём позже.) Такие последовательности упорядочим лексикографически (см. выше). Очевидный способ решения задачи | перебирать все последовательности как раньше,

46

2. Порождение комбинаторных объектов

а затем отбирать среди них те, у которых k единиц | мы отбросим,
считая его неэкономичным (число последовательностей с k единицами
может быть много меньше числа всех последовательностей). Будем искать такой алгоритм, чтобы получение очередной последовательности
требовало не более C·n действий.
В каком случае s-й член последовательности можно увеличить, не
меняя предыдущие? Если x[s] меняется с 0 на 1, то для сохранения
общего числа единиц нужно справа от х[s] заменить 1 на 0. Для этого надо, чтобы справа от x[s] единицы были. Если мы хотим перейти
к непосредственно следующему, то x[s] должен быть первым справа
нулём, за которым стоят единицы. Легко видеть, что х[s+1]=1 (иначе
х[s] не первый). Таким образом надо искать наибольшее s, для которого х[s]=0, x[s+1]=1:
x

0 1..1 0..0

s

За х[s+1] могут идти ещё несколько единиц, а после них несколько нулей. Заменив х[s] на 1, надо выбрать идущие за ним члены так, чтобы
последовательность была бы минимальна с точки зрения нашего порядка, т. е. чтобы сначала шли нули, а потом единицы. Вот что получается:
первая последовательность: 0..01..1 (n-k нулей, k единиц);
последняя последовательность: 1..10..0 (k единиц, n-k нулей);
алгоритм перехода к следующей за х[1]..x[n] последовательности
(предполагаем, что она есть):
s := n - 1;
while not ((x[s]=0) and (x[s+1]=1)) do begin
s := s - 1;
end;
{s - член, подлежащий изменению с 0 на 1}
num:=0;
for k := s to n do begin
num := num + x[k];
end;
{num - число единиц на участке x[s]..x[n], число нулей
равно (длина - число единиц), т.е. (n-s+1) - num}
x[s]:=1;
for k := s+1 to n-num+1 do begin
x[k] := 0;
end;

2.3. Подмножества
{осталось поместить num-1 единиц в конце}
for k := n-num+2 to n do begin
x[k]:=1;
end;

47



Другой способ представления подмножеств | это перечисление их
элементов. Чтобы каждое подмножество имело ровно одно представление, договоримся перечислять элементы в возрастающем порядке. Приходим к такой задаче.
2.3.2. Перечислите все возрастающие последовательности длины k
из чисел 1..n в лексикографическом порядке. (Пример: при n=5, k=2
получаем: 12 13 14 15 23 24 25 34 35 45.)
Решение. Минимальной будет последовательность ⟨1 2 . . . k⟩; максимальной | ⟨(n-k+1) . . . (n-1) n⟩. В каком случае s-й член последовательности можно увеличить? Ответ: если он меньше n-k+s. После увеличения s-го элемента все следующие должны возрастать с шагом 1.
Получаем такой алгоритм перехода к следующему:
s:=n;
while not (x[s] < n-k+s) do begin
s:=s-1;
end;
{s - номер элемента, подлежащего увеличению};
x[s] := x[s]+1;
for i := s+1 to n do begin
x[i] := x[i-1]+1;
end;



2.3.3. Пусть мы решили представлять k-элементные подмножества
множества {1..n} убывающими последовательностями длины k, упорядоченными по-прежнему лексикографически. (Пример: 21 31 32 41
42 43 51 52 53 54.) Как выглядит тогда алгоритм перехода к следующей?
Ответ. Ищем наибольшее s, для которого х[s+1]+1 < x[s]. (Если
такого s нет, полагаем s=0.) Увеличив x[s+1] на 1, кладём остальные
минимально возможными (x[t]=k+1-t для t>s).

2.3.4. Решите две предыдущие задачи, заменив лексикографический порядок на обратный (раньше идут те, которые больше в лексикографическом порядке).

2.3.5. Перечислите все вложения (функции, переводящие разные
элементы в разные) множества {1..k} в {1..n} (предполагается, что

48

2. Порождение комбинаторных объектов

k 6 n). Порождение очередного элемента должно требовать не более
C · k действий.

[Указание. Эта задача может быть сведена к перечислению подмножеств и перестановок элементов каждого подмножества.]

2.4. Разбиения
2.4.1. Перечислите все разбиения целого положительного числа n
на целые положительные слагаемые (разбиения, отличающиеся лишь
порядком слагаемых, считаются за одно). (Пример: n=4, разбиения
1+1+1+1, 2+1+1, 2+2, 3+1, 4.)
Решение. Договоримся, что (1) в разбиениях слагаемые идут в невозрастающем порядке, (2) сами разбиения мы перечисляем в лексикографическом порядке. Разбиение храним в начале массива x[1]..x[n],
при этом количество входящих в него чисел обозначим k. В начале
x[1]=...=x[n]=1, k=n, в конце x[1]=n, k=1.
В каком случае x[s] можно увеличить, не меняя предыдущих?
Во-первых, должно быть x[s-1]>x[s] или s=1. Во-вторых, s должно быть не последним элементом (увеличение s надо компенсировать
уменьшением следующих). Увеличив s, все следующие элементы надо
взять минимально возможными.

s := k - 1;
while not ((s=1) or (x[s-1] > x[s])) do begin
s := s-1;
end;
{s - подлежащее увеличению слагаемое}
x [s] := x[s] + 1;
sum := 0;
for i := s+1 to k do begin
sum := sum + x[i];
end;
{sum - сумма членов, стоявших после x[s]}
for i := 1 to sum-1 do begin
x [s+i] := 1;
end;
k := s+sum-1;



2.4.2. Представляя по-прежнему разбиения как невозрастающие последовательности, перечислите их в порядке, обратном лексикографическому (для n=4, например, должно быть 4, 3+1, 2+2, 2+1+1, 1+1+1+1).

2.5. Коды Грея и аналогичные задачи

49

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

2.4.3. Представляя разбиения как неубывающие последовательности, перечислите их в лексикографическом порядке. Пример для n=4:
1+1+1+1, 1+1+2, 1+3, 2+2, 4.
[Указание. Последний член увеличить нельзя, а предпоследний |
можно; если после увеличения на 1 предпоследнего члена за счёт последнего нарушится возрастание, то из двух членов надо сделать один,
если нет, то последний член надо разбить на слагаемые, равные предыдущему, и остаток, не меньший его.]

2.4.4. Представляя разбиения как неубывающие последовательности, перечислите их в порядке, обратном лексикографическому. Пример для n=4: 4, 2+2, 1+3, 1+1+2, 1+1+1+1.
[Указание. Чтобы элемент x[s] можно было уменьшить, необходимо, чтобы s=1 или x[s-1]1. Все ходы поделим на те, где двигается последняя шашка,
и те, где двигается не последняя. Во втором случае последняя шашка
стоит у стены, и мы её поворачиваем, так что за каждым ходом второго типа следует k-1 ходов первого типа, за время которых последняя
шашка побывает во всех клетках. Если мы теперь забудем о последней шашке, то движения первых n-1 по предположению индукции пробегают все последовательности длины n-1 по одному разу; движения
же последней шашки из каждой последовательности длины n-1 делают
k последовательностей длины n.
В программе, помимо последовательности x[1]..x[n], будем хранить массив d[1]..d[n] из чисел +1 и -1 (+1 соответствует стрелке
вверх, -1 | стрелке вниз).
Начальное состояние: x[1]=...=x[n]=1; d[1]=...=d[n]=1.
Приведём алгоритм перехода к следующей последовательности (одновременно выясняется, возможен ли переход | ответ становится значением булевской переменной p).
{если можно, сделать шаг и положить p := true, если нет,
положить p := false }
i := n;
while (i > 1) and
(((d[i]=1) and (x[i]=n)) or ((d[i]=-1) and (x[i]=1)))
do begin
i:=i-1;
end;
if (d[i]=1 and x[i]=n) or (d[i]=-1 and x[i]=1) then begin
p:=false;
end else begin
p:=true;
x[i] := x[i] + d[i];
for j := i+1 to n do begin
d[j] := - d[j];
end;
end;



Замечание. Для последовательностей нулей и единиц возможно другое решение, использующее двоичную систему. (Именно оно связывается обычно с названием «коды Грея».)

2.5. Коды Грея и аналогичные задачи

51

Запишем подряд все числа от 0 до 2 − 1 в двоичной системе. Например, для 𝑛 = 3 напишем:
𝑛

000 001 010 011 100 101 110 111
Затем каждое из чисел подвергнем преобразованию, заменив каждую
цифру, кроме первой, на её сумму с предыдущей цифрой (по модулю 2).
Иными словами, число 𝑎1 , 𝑎2 , . . . , 𝑎 преобразуем в 𝑎1 ,𝑎1 + 𝑎2 ,𝑎2 + 𝑎3 ,. . .
. . . , 𝑎 −1 + 𝑎 (сумма по модулю 2). Для 𝑛 = 3 получим:
𝑛

𝑛

𝑛

000 001 011 010 110 111 101 100
Легко проверить, что описанное преобразование чисел обратимо
(и тем самым даёт все последовательности по одному разу). Кроме того, двоичные записи соседних чисел отличаются заменой конца 011 . . . 1
на конец 100 . . . 0, что | после преобразования | приводит к изменению единственной цифры.
Применение кода Грея. Пусть есть вращающаяся ось, и мы хотим
поставить датчик угла поворота этой оси. Насадим на ось барабан,
выкрасим половину барабана в чёрный цвет, половину в белый и установим фотоэлемент. На его выходе будет в половине случаев 0, а в
половине 1 (т. е. мы измеряем угол «с точностью до 180»).
Развёртка барабана:
0
1
← склеить бока
Сделав рядом другую дорожку из двух чёрных и белых частей и поставив второй фотоэлемент, получаем возможность измерить угол с
точностью до 90∘ :
0 0 1 1
0 1 0 1
Сделав третью,

0 0 0 0 1 1 1 1
0 0 1 1 0 0 1 1
0 1 0 1 0 1 0 1

52

2. Порождение комбинаторных объектов

мы измерим угол с точностью до 45∘ и т. д. Эта идея имеет, однако, недостаток: в момент пересечения границ сразу несколько фотоэлементов
меняют сигнал, и если эти изменения произойдут не совсем одновременно, на какое-то время показания фотоэлементов будут бессмысленными. Коды Грея позволяют избежать этой опасности. Сделаем так,
чтобы на каждом шаге менялось показание лишь одного фотоэлемента
(в том числе и на последнем, после целого оборота).
0 0 0 0 1 1 1 1
0 0 1 1 1 1 0 0
0 1 1 0 0 1 1 0

Написанная нами формула позволяет легко преобразовать данные
от фотоэлементов в двоичный код угла поворота.
Заметим также, что геометрически существование кода Грея означает наличие «гамильтонова цикла» в 𝑛-мерном кубе (возможность
обойти все вершины куба по разу, двигаясь по рёбрам, и вернуться
в исходную вершину).
2.5.2. Напечатайте все перестановки чисел 1..n так, чтобы каждая
следующая получалась из предыдущей перестановкой (транспозицией)
двух соседних чисел. Например, при n=3 допустим такой порядок:
3.2 1 → 2 3.1 → 2.1 3 → 1 2.3 → 1.3 2 → 3 1 2

(между переставляемыми числами вставлены точки).
Решение. Наряду с множеством перестановок рассмотрим множество последовательностей y[1]..y[n] целых неотрицательных чисел,
для которых y[1] 6 0, . . . , y[n] 6 n-1. В нём столько же элементов,
сколько в множестве всех перестановок, и мы сейчас установим между
ними взаимно однозначное соответствие. Именно, каждой перестановке
поставим в соответствие последовательность y[1]..y[n], где y[i] |
количество чисел, меньших i и стоящих левее i в этой перестановке.
Взаимная однозначность вытекает из такого замечания. Перестановка чисел 1..n получается из перестановки чисел 1..n-1 добавлением
числа n, которое можно вставить на любое из n мест. При этом к сопоставляемой с ней последовательности добавляется ещё один член, принимающий значения от 0 до n-1, а предыдущие члены не меняются.
При этом оказывается, что изменение на единицу одного из членов последовательности y соответствует транспозиции двух соседних чисел,

2.5. Коды Грея и аналогичные задачи

53

если все следующие числа последовательности y принимают максимально или минимально возможные для них значения. Именно, увеличение
y[i] на 1 соответствует транспозиции числа i с его правым соседом,
а уменьшение | с левым.
Теперь вспомним решение задачи о перечислении всех последовательностей, на каждом шаге которого один член меняется на единицу.
Заменив прямоугольную доску доской в форме лестницы (высота i-й
вертикали равна i) и двигая шашки по тем же правилам, мы перечислим все последовательности y, причём i-й член будет меняться как
раз только если все следующие шашки стоят у края. Надо ещё уметь
параллельно с изменением y корректировать перестановку. Очевидный
способ требует отыскания в ней числа i; это можно облегчить, если
помимо самой перестановки хранить функцию
i ↦→ позиция числа i в перестановке,

т. е. обратное к перестановке отображение, и соответствующим образом её корректировать. Вот какая получается программа:
program test;
const n=...;
var
x: array [1..n] of 1..n; {перестановка}
inv_x: array [1..n] of 1..n; {обратная перестановка}
y: array [1..n] of integer; {y[i] < i}
d: array [1..n] of -1..1; {направления}
b: boolean;
procedure print_x;
var i: integer;
begin
for i:=1 to n do begin
write (x[i], ’ ’);
end;
writeln;
end;
procedure set_first;{первая: y[i]=0 при всех i}
var i : integer;
begin
for i := 1 to n do begin
x[i] := n + 1 - i;
inv_x[i] := n + 1 - i;
y[i]:=0;

54

2. Порождение комбинаторных объектов
d[i]:=1;
end;
end;
procedure move (var done : boolean);
var i, j, pos1, pos2, val1, val2, tmp : integer;
begin
i := n;
while (i > 1) and (((d[i]=1) and (y[i]=i-1)) or
((d[i]=-1) and (y[i]=0))) do begin
i := i-1;
end;
done := (i>1); {упрощение: первый член нельзя менять}
if done then begin
y[i] := y[i]+d[i];
for j := i+1 to n do begin
d[j] := -d[j];
end;
pos1 := inv_x[i];
val1 := i;
pos2 := pos1 + d[i];
val2 := x[pos2];
{pos1, pos2 - номера переставляемых элементов;
val1, val2 - их значения; val2 < val1}
tmp := x[pos1];
x[pos1] := x[pos2];
x[pos2] := tmp;
tmp := inv_x[val1];
inv_x[val1] := inv_x[val2];
inv_x[val2] := tmp;
end;
end;
begin
set_first;
print_x;
b := true;
{напечатаны все перестановки до текущей включительно;
если b ложно, тотекущая - последняя}
while b do begin
move (b);
if b then print_x;
end;
end.



2.6. Несколько замечаний

55

2.6. Несколько замечаний

Посмотрим ещё раз на использованные нами приёмы. Вначале удавалось решить задачу по такой схеме: определяем порядок на подлежащих перечислению объектах и явно описываем процедуру перехода
от данного объекта к следующему (в смысле этого порядка). В задаче
о кодах Грея потребовалось хранить, помимо текущего объекта, и некоторую дополнительную информацию (направления стрелок). Наконец,
в задаче о перечислении перестановок (на каждом шаге допустима одна
транспозиция) мы применили такой приём: установили взаимно однозначное соответствие между перечисляемым множеством и другим, более просто устроенным. Таких соответствий в комбинаторике известно
много. Мы приведём несколько задач, связанных с так называемыми
«числами Каталана».
2.6.1. Перечислите все последовательности длины 2n, составленные
из n единиц и n минус единиц, у которых сумма любого начального отрезка неотрицательна, т. е. число минус единиц в нём не превосходит
числа единиц. (Число таких последовательностей называют числом Каталана ; формулу для чисел Каталана см. в следующем разделе.)
Решение. Изображая единицу вектором (1,1), а минус единицу вектором (1,-1), можно сказать, что мы ищем пути из точки (0,0) в точку (n,0), не опускающиеся ниже оси абсцисс.
Будем перечислять последовательности в лексикографическом порядке, считая, что -1 предшествует 1. Первой последовательностью
будет «пила»

1, -1, 1, -1, ...

а последней | «горка»
1, 1, 1,..., 1, -1, -1,..., -1.

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

56

2. Порождение комбинаторных объектов

такую программу:
...
type array2n = array [1..2n] of integer;
...
procedure get_next (var a: array2n; var last: Boolean);
{в a помещается следующая последовательность, если}
{она есть (при этом last:=false), иначе last:=true}
var k, i, sum: integer;
begin
k:=2*n;
{инвариант: в a[k+1..2n] только минус единицы}
while a[k] = -1 do begin k:=k-1; end;
{k - максимальное среди тех, для которых a[k]=1}
while (k>0) and (a[k] = 1) do begin k:=k-1; end;
{a[k] - самая правая -1, за которой есть 1;
если таких нет, то k=0}
if k = 0 then begin
last := true;
end else begin
last := false;
i:=0; sum:=0;
{sum = a[1]+...+a[i]}
while ik do begin
i:=i+1; sum:= sum+a[i];
end;
{sum = a[1]+...+a[k], a[k]=-1}
a[k]:= 1; sum:= sum+2;
{вплоть до a[k] всё изменено, sum=a[1]+...+a[k]}
while k 2*n do begin
k:=k+1;
if sum > 0 then begin
a[k]:=-1
end else begin
a[k]:=1;
end;
sum:= sum+a[k];
end;
{k=2n, sum=a[1]+...+a[2n]=0}
end;
end;



2.6.2. Перечислите все расстановки скобок в произведении n сомножителей. Порядок сомножителей не меняется, скобки полностью опре-

57

2.7. Подсчёт количеств

деляют порядок действий. Например, для n=4 есть 5 расстановок:
((ab)c)d, (a(bc))d, (ab)(cd), a((bc)d), a(b(cd)).

[Указание. Каждому порядку действий соответствует последовательность команд стекового калькулятора, описанного на с. 144.] 
2.6.3. На окружности задано 2n точек, пронумерованных от 1 до 2n.
Перечислите все способы провести n непересекающихся хорд с вершинами в этих точках.

2.6.4. Перечислите все способы разрезать n-угольник на треугольники, проведя n-2 его диагонали.

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

Иногда можно найти количество объектов с тем или иным свойством, не перечисляя их. Классический пример: 𝐶 | число всех 𝑘-элементных подмножеств 𝑛-элементного множества | можно найти, заполняя таблицу по формулам
𝑘
𝑛

𝐶0 = 𝐶 = 1
𝐶 = 𝐶 −−11 + 𝐶
𝑛

𝑛
𝑛

𝑘
𝑛

𝑘
𝑛

𝑘
𝑛 −1

(𝑛 > 1)
(𝑛 > 1, 0 < 𝑘 < 𝑛)

или по формуле

𝑛!
.
𝑘! · (𝑛 − 𝑘)!
(Первый способ эффективнее, если нужно сразу много значений 𝐶 .)
Приведём другие примеры.
2.7.1. (Число разбиений; предлагалась на Всесоюзной олимпиаде по
программированию 1988 года) Пусть 𝑃 (𝑛) | число разбиений целого
положительного 𝑛 на целые положительные слагаемые (без учёта порядка, 1 + 2 и 2 + 1 | одно и то же разбиение). При 𝑛 = 0 положим
𝑃 (𝑛) = 1 (единственное разбиение не содержит слагаемых). Постройте
алгоритм вычисления 𝑃 (𝑛) для заданного 𝑛.
𝐶 =
𝑘
𝑛

𝑘
𝑛

58

2. Порождение комбинаторных объектов

Решение. Можно доказать (это нетривиально) такую формулу для
𝑃 (𝑛):
𝑃 (𝑛)= 𝑃 (𝑛 − 1)+ 𝑃 (𝑛 − 2) − 𝑃 (𝑛 − 5) − 𝑃 (𝑛 − 7)+ 𝑃 (𝑛 − 12)+ 𝑃 (𝑛 − 15)+ . . .
(знаки у пар членов чередуются, вычитаемые в одной паре равны
(3𝑞2 − 𝑞)/2 и (3𝑞2 + 𝑞)/2; сумма конечна | мы считаем, что 𝑃 (𝑘) = 0
при 𝑘 < 0).
Однако и без её использования можно придумать способ вычисления 𝑃 (𝑛), который существенно эффективнее перебора и подсчёта всех
разбиений.
Обозначим через 𝑅(𝑛, 𝑘) (для 𝑛 > 0, 𝑘 > 0) число разбиений 𝑛 на целые положительные слагаемые, не превосходящие 𝑘. (При этом 𝑅(0, 𝑘)
считаем равным 1 для всех 𝑘 > 0.) Очевидно, 𝑃 (𝑛) = 𝑅(𝑛, 𝑛). Все разбиения 𝑛 на слагаемые, не превосходящие 𝑘, разобьём на группы в зависимости от максимального слагаемого (обозначим его 𝑖). Число 𝑅(𝑛, 𝑘)
равно сумме (по всем 𝑖 от 1 до 𝑘) количеств разбиений со слагаемыми
не больше 𝑘 и максимальным слагаемым, равным 𝑖. А разбиения 𝑛 на
слагаемые не более 𝑘 с первым слагаемым, равным 𝑖, по существу представляют собой разбиения 𝑛 − 𝑖 на слагаемые, не превосходящие 𝑖 (при
𝑖 6 𝑘). Так что

𝑅(𝑛, 𝑘) =

𝑘
∑︁
𝑖=1

𝑅(𝑛 − 𝑖, 𝑖)

при 𝑘 6 𝑛,

𝑅(𝑛, 𝑘) = 𝑅(𝑛, 𝑛)
при 𝑘 > 𝑛,
что позволяет заполнять таблицу значений функции 𝑅.

2.7.2. (Счастливые билеты; предлагалась на Всесоюзной олимпиаде по программированию 1989 года.) Последовательность из 2𝑛 цифр
(каждая цифра от 0 до 9) называется счастливым билетом, если сумма
первых 𝑛 цифр равна сумме последних 𝑛 цифр. Найдите число счастливых последовательностей данной длины.
Решение. (Сообщено одним из участников олимпиады; к сожалению,
не могу указать фамилию, так как работы проверялись зашифрованными.) Рассмотрим более общую задачу: найти число последовательностей, где разница между суммой первых 𝑛 цифр и суммой последних 𝑛 цифр равна 𝑘 (𝑘 = −9𝑛, . . . , 9𝑛). Пусть 𝑇 (𝑛, 𝑘) | число таких
последовательностей.
Разобьём множество таких последовательностей на классы в зависимости от разницы между первой и последней цифрами. Если эта разница равна 𝑡, то разница между суммами групп из оставшихся 𝑛 − 1 цифр

59

2.7. Подсчёт количеств

равна 𝑘 − 𝑡. Учитывая, что пар цифр с разностью 𝑡 бывает 10 − |𝑡|, получаем формулу

𝑇 (𝑛, 𝑘) =

9
∑︁

(10 − |𝑡|)𝑇 (𝑛 − 1, 𝑘 − 𝑡).

𝑡=−9

(Некоторые слагаемые могут отсутствовать, так как 𝑘 − 𝑡 может быть
слишком велико.)

В некоторых случаях ответ удаётся получить в виде явной формулы.
2.7.3. Докажите, что число Каталана (количество последовательностей длины 2𝑛 из 𝑛 единиц и 𝑛 минус единиц, в любом начальном отрезке которых не меньше единиц, чем минус единиц) равно 𝐶2 /(𝑛 + 1).
[Указание. Число Каталана есть число ломаных, идущих из (0, 0)
в (2𝑛, 0) шагами (1, 1) и (1, −1), не опускающихся в нижнюю полуплоскость, т. е. разность числа всех ломаных (которое есть 𝐶2 ) и числа ломаных, опускающихся в нижнюю полуплоскость. Последние можно описать также как ломаные, пересекающие прямую 𝑦 = −1. Отразив их кусок справа от самой правой точки пересечения относительно указанной
прямой, мы установим взаимно однозначное соответствие между ними
и ломаными из (0, 0) в (2𝑛, −2). Остаётся проверить, что 𝐶2 − 𝐶2 +1 =
= 𝐶2 /(𝑛 + 1).]

𝑛
𝑛

𝑛
𝑛

𝑛
𝑛

𝑛
𝑛

𝑛
𝑛

3. ОБХОД ДЕРЕВА.
ПЕРЕБОР С ВОЗВРАТАМИ
3.1. Ферзи, не бьющие друг друга:
обход дерева позиций

В предыдущей главе мы рассматривали несколько задач одного и того же типа: «перечислить все элементы некоторого множества 𝐴». Схема решения была такова: на множестве 𝐴 вводился порядок и описывалась процедура перехода от произвольного элемента множества 𝐴
к следующему за ним (в этом порядке). Такую схему не всегда удаётся
реализовать непосредственно, и в этой главе мы рассмотрим другой
полезный приём перечисления всех элементов некоторого множества.
Его называют «поиск с возвратами», «метод ветвей и границ», «backtracking». На наш взгляд, наиболее точное название этого метода |
обход дерева.
3.1.1. Перечислите все способы расстановки 𝑛 ферзей на шахматной доске 𝑛 × 𝑛, при которых они не бьют друг друга.
Решение. Очевидно, на каждой из 𝑛 горизонталей должно стоять
по ферзю. Будем называть 𝑘-позицией (для 𝑘 = 0, 1, . . . , 𝑛) произвольную расстановку 𝑘 ферзей на 𝑘 нижних горизонталях (ферзи могут
бить друг друга). Нарисуем «дерево позиций»: его корнем будет единственная 0-позиция, а из каждой 𝑘-позиции выходит 𝑛 стрелок вверх
в (𝑘 + 1)-позиции. Эти 𝑛 позиций отличаются положением ферзя на
(𝑘 + 1)-й горизонтали. Будем считать, что расположение их на рисунке соответствует положению этого ферзя: левее та позиция, в которой
ферзь расположен левее.
Среди позиций этого дерева нам надо отобрать те 𝑛-позиции, в которых ферзи не бьют друг друга. Программа будет «обходить дерево»
и искать их. Чтобы не делать лишней работы, заметим вот что: если
в какой-то 𝑘-позиции ферзи бьют друг друга, то ставить дальнейших

61

3.1. Ферзи, не бьющие друг друга: обход дерева позиций














I
@
@

i
P
PP
PP

1




I
@
@







Дерево позиций для 𝑛 = 2
ферзей смысла нет. Поэтому, обнаружив это, мы будем прекращать
построение дерева в этом направлении.
Точнее, назовём 𝑘-позицию допустимой, если после удаления верхнего ферзя оставшиеся не бьют друг друга. Наша программа будет
рассматривать только допустимые позиции.









BMB
B





BMB















BMB
B







BMB
6 
B



i
P
PP
P





6 


6 


B



PP
P


6
P





6 











BMB
B


6 






1






Дерево допустимых позиций для 𝑛 = 3
Разобьём задачу на две части: (1) обход произвольного дерева и (2)
реализацию дерева допустимых позиций.

62

3. Обход дерева. Перебор с возвратами

Сформулируем задачу обхода произвольного дерева. Будем считать, что у нас имеется Робот, который в каждый момент находится
в одной из вершин дерева (вершины изображены на рисунке кружочками). Он умеет выполнять команды:
∙ вверх налево (идти по самой левой из выходящих вверх стрелок)
∙ вправо

(перейти в соседнюю справа вершину)

(спуститься вниз на один уровень)
(На рисунках стрелками показано, какие перемещения соответствуют
этим командам.)
∙ вниз

q q q q
@
I
@AA 
q q q@
Aq q
KA  6
A

q
q
Aq
I
@
@
@q
вверх налево

q

q-q-q-q
@
@AA 
q-q-q@
Aq-q-q
AA 

q Aq -q
@
@
@q
вправо

q q q q
@A 
AUAq?
 q q
R
@
q q @
q@
A 

A
U


?
?
q
Aq
q
@
@
@
R?
q
вниз

Кроме того, в репертуар Робота входят проверки (соответствующие
возможности выполнить каждую из команд):
∙ есть сверху;
∙ есть справа;
∙ есть снизу;

(последняя проверка истинна всюду, кроме корня). Обратите внимание,
что команда вправо позволяет перейти лишь к «родному брату», но не
к «двоюродному».
q
q @-q
q
AA 
K
 @ K
AA 

Aq
Aq
I
@

@
@q

Так команда
вправо

не

действует!

Будем считать, что у Робота есть команда обработать и что его
задача | обработать все листья (вершины, из которых нет стрелок

3.1. Ферзи, не бьющие друг друга: обход дерева позиций

63

вверх, то есть где условие есть сверху ложно). Для нашей шахматной
задачи команде обработать будет соответствовать проверка и печать
позиции ферзей.
Доказательство правильности приводимой далее программы использует такие определения. Пусть фиксировано положение Робота
в одной из вершин дерева. Тогда все листья дерева разбиваются на
три категории: над Роботом, левее Робота и правее Робота. (Путь из
корня в лист может проходить через вершину с Роботом, сворачивать
влево, не доходя до неё и сворачивать вправо, не доходя до неё.) Через
(ОЛ) обозначим условие «обработаны все листья левее Робота», а через
(ОЛН) | условие «обработаны все листья левее и над Роботом».
Левее
@
@

Над Правее
@
@

@q
@

@

@ 
@q



Нам понадобится такая процедура:
procedure вверх_до_упора_и_обработать;
{дано: (ОЛ), надо: (ОЛН)}
begin
{инвариант: ОЛ}
while есть_сверху do begin
вверх_налево;
end
{ОЛ, Робот в листе}
обработать;
{ОЛН}
end;

Основной алгоритм:
дано: Робот в корне, листья не обработаны
надо: Робот в корне, листья обработаны
{ОЛ}
вверх_до_упора_и_обработать;
{инвариант: ОЛН}

64

3. Обход дерева. Перебор с возвратами
while есть_снизу do begin
if есть_справа then begin {ОЛН, есть справа}
вправо;
{ОЛ}
вверх_до_упора_и_обработать;
end else begin
{ОЛН, не есть_справа, есть_снизу}
вниз;
end;
end;
{ОЛН, Робот в корне => все листья обработаны}

Осталось воспользоваться следующими свойствами команд Робота
(в каждой строке в первой фигурной скобке записаны условия, в которых выполняется команда, во второй | утверждения о результате её
выполнения):
(1) {ОЛ, не есть сверху} обработать {ОЛН}
(2) {ОЛ, есть сверху} вверх налево {ОЛ}
(3) {есть справа, ОЛН} вправо {ОЛ}
(4) {не есть справа, есть снизу, ОЛН} вниз {ОЛН}
3.1.2. Докажите, что приведённая программа завершает работу (на
любом конечном дереве).
Решение. Процедура вверх до упора и обработать завершает работу (высота Робота не может увеличиваться бесконечно). Если программа работает бесконечно, то, поскольку листья не обрабатываются повторно, начиная с некоторого момента ни один лист не обрабатывается.
А это возможно, только если Робот всё время спускается вниз. Противоречие. (Об оценке числа действий см. далее.)

3.1.3. Докажите правильность такой программы обхода дерева:
var state: (WL, WLU);
state := WL;
while есть_снизу or (state WLU) do begin
if (state = WL) and есть_сверху then begin
вверх_налево;
end else if (state = WL) and not есть_сверху then begin
обработать; state := WLU;
end else if (state = WLU) and есть_справа then begin
вправо; state := WL;
end else begin {state = WLU, not есть_справа, есть_снизу}
вниз;
end;
end;

3.1. Ферзи, не бьющие друг друга: обход дерева позиций
Решение.

65

Инвариант цикла:
state = WL ⇒ ОЛ
state = WLU ⇒ ОЛН

Доказательство завершения работы: переход из состояния ОЛ в ОЛН
возможен только при обработке вершины, поэтому если программа работает бесконечно, то с некоторого момента значение state не меняется, что невозможно.

3.1.4. Напишите программу обхода дерева, использующую процедуру перехода в следующий лист (с выходным параметром, сообщающим,
удалось ли это сделать или лист оказался последним).

3.1.5. Решите задачу об обходе дерева, если мы хотим, чтобы обрабатывались все вершины (не только листья).
Решение. Пусть 𝑥 | некоторая вершина. Тогда любая вершина 𝑦
относится к одной из четырёх категорий. Рассмотрим путь из корня
в 𝑦. Он может:
(а) быть частью пути из корня в 𝑥 (𝑦 ниже 𝑥);
(б) свернуть налево с пути в 𝑥 (𝑦 левее 𝑥);
(в) пройти через 𝑥 (𝑦 над 𝑥);
(г) свернуть направо с пути в 𝑥 (𝑦 правее 𝑥);
В частности, сама вершина 𝑥 относится к категории (в). Условия теперь
будут такими:
(ОНЛ) обработаны все вершины ниже и левее;
(ОНЛН) обработаны все вершины ниже, левее и над.
Вот как будет выглядеть программа:

procedure вверх_до_упора_и_обработать;
{дано: (ОНЛ), надо: (ОНЛН)}
begin
{инвариант: ОНЛ}
while есть_сверху do begin
обработать;
вверх_налево;
end
{ОНЛ, Робот в листе}
обработать;
{ОНЛН}
end;

66

3. Обход дерева. Перебор с возвратами

Основной алгоритм:
дано: Робот в корне, ничего не обработано
надо: Робот в корне, все вершины обработаны
{ОНЛ}
вверх_до_упора_и_обработать;
{инвариант: ОНЛН}
while есть_снизу do begin
if есть_справа then begin {ОНЛН, есть справа}
вправо;
{ОНЛ}
вверх_до_упора_и_обработать;
end else begin
{ОЛН, не есть_справа, есть_снизу}
вниз;
end;
end;
{ОНЛН, Робот в корне => все вершины обработаны}



3.1.6. Приведённая только что программа обрабатывает вершину
до того, как обработан любой из её потомков. Как изменить программу, чтобы каждая вершина, не являющаяся листом, обрабатывалась
дважды: один раз до, а другой раз после всех своих потомков? (Листья по-прежнему обрабатываются по разу.)
Решение. Под «обработано ниже и левее» будем понимать «ниже обработано по разу, слева обработано полностью (листья по разу, остальные по два)». Под «обработано ниже, левее и над» будем понимать «ниже
обработано по разу, левее и над | полностью».
Программа будет такой:

procedure вверх_до_упора_и_обработать;
{дано: (ОНЛ), надо: (ОНЛН)}
begin
{инвариант: ОНЛ}
while есть_сверху do begin
обработать;
вверх_налево;
end
{ОНЛ, Робот в листе}
обработать;
{ОНЛН}
end;

3.1. Ферзи, не бьющие друг друга: обход дерева позиций

67

Основной алгоритм:
дано: Робот в корне, ничего не обработано
надо: Робот в корне, все вершины обработаны
{ОНЛ}
вверх_до_упора_и_обработать;
{инвариант: ОНЛН}
while есть_снизу do begin
if есть_справа then begin {ОНЛН, есть справа}
вправо;
{ОНЛ}
вверх_до_упора_и_обработать;
end else begin
{ОЛН, не есть_справа, есть_снизу}
вниз;
обработать;
end;
end;
{ОНЛН, Робот в корне => все вершины обработаны полностью}



3.1.7. Докажите, что число операций в этой программе по порядку равно числу вершин дерева. (Как и в других программах, которые
отличаются от этой лишь пропуском некоторых команд обработать.)
[Указание. Примерно каждое второе действие при исполнении этой
программы | обработка вершины, а каждая вершина обрабатывается
максимум дважды.]

Вернёмся теперь к нашей задаче о ферзях (где из всех программ
обработки дерева понадобится лишь первая, самая простая). Реализуем операции с деревом позиций. Позицию будем представлять с помощью переменной k: 0..n (число ферзей) и массива c: array[1..n]
of 1..n (c[i] | координаты ферзя на i-й горизонтали; при i > k
значение c[i] роли не играет). Предполагается, что все позиции
допустимы (если убрать верхнего ферзя, остальные не бьют друг
друга).

program queens;
const n = ...;
var
k: 0..n;
c: array [1..n] of 1..n;

68

3. Обход дерева. Перебор с возвратами
procedure begin_work; {начать работу}
begin
k := 0;
end;
function danger: boolean; {верхний ферзь под боем}
var b: boolean; i: integer;
begin
if k 0) and (c[k] < n);
end;
{возможна ошибка: при k=0 не определено c[k]}
function is_down: boolean; {есть_снизу}
begin
is_down := (k > 0);
end;
procedure up; {вверх_налево}
begin {k < n, not danger}
k := k + 1;
c [k] := 1;
end;

3.1. Ферзи, не бьющие друг друга: обход дерева позиций

69

procedure right; {вправо}
begin {k > 0, c[k] < n}
c [k] := c [k] + 1;
end;
procedure down; {вниз}
begin {k > 0}
k := k - 1;
end;
procedure work; {обработать}
var i: integer;
begin
if (k = n) and not danger then begin
for i := 1 to n do begin
write (’ ’);
end;
writeln;
end;
end;
procedure UW; {вверх_до_упора_и_обработать}
begin
while is_up do begin
up;
end;
work;
end;
begin
begin_work;
UW;
while is_down do begin
if is_right then begin
right;
UW;
end else begin
down;
end;
end;
end.



70

3. Обход дерева. Перебор с возвратами

3.1.8. Приведённая программа тратит довольно много времени на
выполнение проверки есть сверху (проверка, находится ли верхний
ферзь под боем, требует числа действий порядка n). Измените реализацию операций с деревом позиций так, чтобы все три проверки есть сверху/справа/снизу и соответствующие команды требовали бы количества действий, ограниченного не зависящей от n константой.
Решение. Для каждой вертикали, каждой восходящей и каждой нисходящей диагонали будем хранить булевское значение | сведения о
том, находится ли на этой линии ферзь (верхний ферзь не учитывается). (Заметим, что в силу допустимости позиции на каждой из линий
может быть не более одного ферзя.)


3.2. Обход дерева в других задачах
3.2.1. Используйте метод обхода дерева для решения следующей задачи: дан массив из n целых положительных чисел a[1] . . . a[n] и число s; требуется узнать, может ли число s быть представлено как сумма
некоторых из чисел массива a. (Каждое число можно использовать не
более чем по одному разу.)
Решение. Будем задавать k-позицию последовательностью из k булевских значений, определяющих, входят ли в сумму числа a[1] . . . a[k]
или не входят. Позиция допустима, если её сумма не превосходит s. 

По сравнению с полным перебором всех 2n подмножеств
тут есть некоторый выигрыш. Можно также предварительно отсортировать массив a в убывающем порядке, а также считать недопустимыми те позиции, в которых сумма отброшенных членов больше, чем
разность суммы всех членов и s. Последний приём называют «методом
ветвей и границ». Но принципиального улучшения по сравнению с полным перебором тут не получается (эта задача, как говорят, 𝑁𝑃 -полна, подробности см. в книге Ахо, Хопкрофта и Ульмана «Построение
и анализ вычислительных алгоритмов», Мир, 1979, а также в книге Гэри и Джонсона «Вычислительные машины и труднорешаемые задачи»,
Мир, 1982). Традиционное название этой задачи | «задача о рюкзаке» (рюкзак общей грузоподъёмностью s нужно упаковать под завязку, располагая предметами веса a[1] . . . a[n]). См. также в главе 8 (Как
обойтись без рекурсии) алгоритм её решения, полиномиальный по n + s
(использующий «динамическое программирование»).
Замечание.

3.2. Обход дерева в других задачах

71

3.2.2. Перечислите все последовательности из 𝑛 нулей, единиц и
двоек, в которых никакая группа цифр не повторяется два раза подряд
(нет куска вида 𝑋𝑋 ).

3.2.3. Аналогичная задача для последовательностей нулей и единиц,
в которых никакая группа цифр не повторяется три раза подряд (нет
куска вида 𝑋𝑋𝑋 ).

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

4. СОРТИРОВКА
4.1. Квадратичные алгоритмы
4.1.1. Пусть a[1], . . . , a[n] | целые числа. Требуется построить
массив b[1], . . . , b[n], содержащий те же числа, для которого b[1] 6 . . .
. . . 6 b[n].
Замечание. Среди чисел a[1] . . . a[n] могут быть равные. Требуется, чтобы каждое целое число входило в b[1] . . . b[n] столько же раз,
сколько и в a[1] . . . a[n].
Решение. Удобно считать, что числа a[1] . . . a[n] и b[1] . . . b[n]
представляют собой начальное и конечное значения массива x. Требование «a и b содержат одни и те же числа» будет заведомо выполнено,
если в процессе работы мы ограничимся перестановками элементов x.

k := 0;
{k наименьших элементов массива установлены на свои места}
while k n do begin
s := k + 1; t := k + 1;
{x[s] - наименьший среди x[k+1]..x[t] }
while tn do begin
t := t + 1;
if x[t] < x[s] then begin
s := t;
end;
end;
{x[s] - наименьший среди x[k+1]..x[n] }
...переставить x[s] и x[k+1];
k := k + 1;
end;

4.1.2. Дайте другое решение задачи сортировки, использующее инвариант «первые k элементов упорядочены» (x[1] 6 . . . 6 x[k]).

4.2. Алгоритмы порядка

𝑛 log 𝑛

73

Решение.

k:=1;
{первые k элементов упорядочены}
while k n do begin
t := k+1;
{k+1-ый элемент продвигается к началу, пока не займёт
надлежащего места, t - его текущий номер}
while (t > 1) and (x[t] < x[t-1]) do begin
...поменять x[t-1] и x[t];
t := t - 1;
end;
end;



Замечание. Дефект программы: при ложном выражении (t>1) проверка x[t] < x[t-1] требует несуществующего значения x[0].
Оба предложенных решения требуют числа действий, пропорционального n2 . Существуют более эффективные алгоритмы.

4.2. Алгоритмы порядка

𝑛 log 𝑛

4.2.1. Предложите алгоритм сортировки за время 𝑛 log 𝑛 (число операций при сортировке 𝑛 элементов не больше 𝐶𝑛 log 𝑛 для некоторого 𝐶
и для всех 𝑛).
Мы приведём два решения.
Решение 1 (сортировка слиянием).
Пусть k | положительное целое число. Разобьём массив x[1] . . . x[n]
на отрезки длины k. (Первый | x[1] . . . x[k], затем x[k+1] . . . x[2k]
и так далее.) Последний отрезок будет неполным, если n не делится
на k. Назовём массив k-упорядоченным, если каждый из этих отрезков
в отдельности упорядочен. Любой массив 1-упорядочен. Если массив
k-упорядочен и n 6 k, то он упорядочен.
Мы опишем, как преобразовать k-упорядоченный массив в 2k-упорядоченный (из тех же элементов). С помощью этого преобразования
алгоритм записывается так:

k:=1;
{массив x является k-упорядоченным}
while k < n do begin
...преобразовать k-упорядоченный массив в 2k-упорядоченный;
k := 2 * k;
end;

74

4. Сортировка

Требуемое преобразование состоит в том,что мы многократно «сливаем» два упорядоченных отрезка длины не больше k в один упорядоченный отрезок. Пусть процедура
слияние (p,q,r: integer)

при p 6 q 6 r сливает отрезки x[p+1] . . . x[q] и x[q+1] . . . x[r] в упорядоченный отрезок x[p+1] . . . x[r] (не затрагивая других частей массива x).
p

упорядоченный

q

упорядоченный

r



упорядоченный
Тогда преобразование k-упорядоченного массива в 2k-упорядоченный
осуществляется так:
t:=0;
{t кратно 2k или t = n, x[1]..x[t] является
2k-упорядоченным; остаток массива x не изменился}
while t + k < n do begin
p := t;
q := t+k;
r := min (t+2*k, n);
{min(a,b) - минимум из a и b}
слияние (p,q,r);
t := r;
end;

Слияние требует вспомогательного массива для записи результатов
слияния | обозначим его b. Через p0 и q0 обозначим номера последних
элементов участков, подвергшихся слиянию, s0 | последний записанный в массив b элемент. На каждом шаге слияния производится одно
из двух действий:
b[s0+1]:=x[p0+1];
p0:=p0+1;
s0:=s0+1;

4.2. Алгоритмы порядка

𝑛 log 𝑛

75

или
b[s0+1]:=x[q0+1];
q0:=q0+1;
s0:=s0+1;

(Любители языка C написали бы в этом случае b[++s0]=x[++p0] и
b[++s0]=x[++q0].)
Первое действие (взятие элемента из первого отрезка) может производиться при одновременном выполнении двух условий:
(1) первый отрезок не кончился (p0 < q);
(2) второй отрезок кончился (q0 = r) или не кончился, но элемент в нём не меньше очередного элемента первого отрезка [(q0 < r)
и(x[p0+1] 6 x[q0+1])].
Аналогично для второго действия. Итак, получаем
p0 := p; q0 := q; s0 := p;
while (p0 q) or (q0 r) do begin
if (p0 < q) and ((q0 = r) or ((q0 < r) and
(x[p0+1] 𝑛 − 1 (все игроки, кроме одного, кому-то проиграли). Объясним, как надо выбирать результаты матчей, чтобы добиться
неравенства 𝑘2 > ⌈log2 𝑛⌉ − 1. Результат встречи двух не-лидеров может быть выбран любым. Если лидер встречается с не-лидером, то выигрывает лидер. При встрече двух лидеров выигрывает более опытный,
то есть тот, кто выиграл к этому моменту больше игр (при равенстве |
любой).
Чтобы доказать, что в этом случае выполнено искомое неравенство
на 𝑘2 , введём отношения подчинения, считая при этом, что каждый
игрок в любой момент игры подчинён ровно одному лидеру. В начале каждый сам себе лидер и подчинён только себе. При встрече лидера
с не-лидером (или двух не-лидеров) подчинение не меняется; при встрече двух лидеров проигравший и все его подчинённые переподчиняются
выигравшему.
Легко доказать по индукции, что если лидер выиграл 𝑘 игр, то группа его подчинённых (включая его самого) содержит не более 2 человек.
Вначале 𝑘 = 0 и в его группе только он сам. Если лидер выиграл 𝑘 игр
и побеждает лидера, выигравшего не более 𝑘 игр, то в каждой из групп
не более 2 игроков, а в объединении не более 2 +1 игроков.
Следовательно, по окончании турнира лидер выиграл не менее
⌈log2 𝑛⌉ игр, поскольку в его группе все 𝑛 игроков. Все побеждённые
им, кроме второго по силе игрока, проиграли ещё кому-то (иначе почему мы уверены, что они не вторые по силе?). Отсюда и получается
требуемая оценка на 𝑘2 .

𝑖

𝑖

𝑖

𝑘

𝑘

𝑘

88

4. Сортировка

4.5.7. Докажите, что оценка предыдущей задачи остаётся в силе,
если требуется найти лишь второй по весу камень, а самый тяжёлый
искать не обязательно.
[Указание. Если по окончанию турнира определился второй по силе игрок, то он кому-то проиграл (откуда мы знаем иначе, что он не
первый?), и тем самым известен и победитель.]

4.5.8. Дано 𝑛 различных по весу камней и число 𝑘 (от 1 до 𝑛). Требуется найти 𝑘-й по весу камень, сделав не более 𝐶𝑛 взвешиваний, где 𝐶 |
некоторая константа, не зависящая от 𝑘 и 𝑛.
Замечание. Сортировка позволяет сделать это за 𝐶𝑛 log 𝑛 взвешиваний. Указание к этой (трудной) задаче приведено в главе про рекурсию.

Следующая задача имеет неожиданно простое решение.
4.5.9. Имеется 𝑛 одинаковых на вид камней, некоторые из которых
на самом деле различны по весу. Имеется прибор, позволяющий по двум
камням определить, одинаковы они или различны (но не говорящий,
какой тяжелее). Известно, что среди этих камней большинство (более
𝑛/2) одинаковых. Сделав не более 𝑛 взвешиваний, найдите хотя бы один
камень из этого большинства. (Предостережение. Если два камня одинаковые, это не гарантирует их принадлежности к большинству.)
[Указание. Если найдены два различных камня, то их оба можно
выбросить | хотя бы один из них плохой и большинство останется
большинством.]
Решение. Программа просматривает камни по очереди, храня в переменной i число просмотренных камней. (Считаем камни пронумерованными от 1 до n.) Помимо этого программа хранит номер «текущего
кандидата» c и его «кратность» k. Смысл этих названий объясняется
инвариантом (И):

если к непросмотренным камням (с номерами i+1 . . . n) добавили бы k копий c-го камня, то наиболее частым среди них
был бы такой же камень, что и для исходного массива.
Получаем такую программу:
k:=0; i:=0;
{(И)}
while in do begin
if k=0 then begin

4.5. Родственные сортировке задачи

89

k:=1; c:=i+1; i:=i+1;
end else if (i+1-ый камень одинаков с c-ым) then begin
i:=i+1; k:=k+1;
{заменяем материальный камень идеальным}
end else begin
i:=i+1; k:=k-1;
{выкидываем один материальный и один идеальный камень}
end;
end;
искомым является c-ый камень
Замечание.

Поскольку во всех трёх вариантах выбора стоит команда

i:=i+1, её можно вынести наружу.



Заметим также, что эта программа гарантирует отыскание наиболее частого камня, лишь если он составляет большинство.
Следующая задача не имеет на первый взгляд никакого отношения
к сортировке.
4.5.10. Имеется квадратная таблица a[1..n,1..n]. Известно, что
для некоторого i строка с номером i заполнена одними нулями, а столбец с номером i | одними единицами (за исключением их пересечения
на диагонали, где стоит неизвестно что). Найдите такое i (оно, очевидно, единственно). Число действий порядка n. (Заметим, что это существенно меньше числа элементов в таблице.)
[Указание. Рассмотрите a[i][j] как результат «сравнения» i с j
и вспомните, что самый тяжёлый из n камней может быть найден
за n сравнений. (Заметим, что таблица может не быть «транзитивной», но всё равно при «сравнении» двух элементов один из них отпадает.)]


5. КОНЕЧНЫЕ АВТОМАТЫ
И ОБРАБОТКА ТЕКСТОВ
5.1. Составные символы, комментарии и т. п.
5.1.1. В тексте возведение в степень обозначалось двумя идущими
подряд звёздочками. Решено заменить это обозначение на ^ (так что,
к примеру, x**y заменится на x^y). Как это проще всего сделать? Исходный текст читается символ за символом, получающийся текст требуется печатать символ за символом.
Решение. В каждый момент программа находится в одном из двух
состояний: «основное» и «после» (звёздочки):

Состояние Очередной
входной символ
основное
*
основное
𝑥=
̸ *
после
*
после
𝑥=
̸ *

Новое
состояние
после
основное
основное
основное

Действие
нет
печатать 𝑥
печатать ^
печатать *, 𝑥

Если в конце текста программа оказывается в состоянии «после», то
следует напечатать звёздочку (и кончить работу).

Замечание. Наша программа заменяет *** на ^* (но не на *^). В условии задачи мы не оговаривали деталей, как это часто делается |
предполагается, что программа «должна действовать разумно». В данном случае, пожалуй, самый простой способ объяснить, как программа
действует | это описать её состояния и действия в них.
5.1.2.

да abc.

Напишите программу, удаляющую из текста все подслова ви

5.1. Составные символы, комментарии и т. п.
5.1.3.

91

В паскале комментарии заключаются в фигурные скобки:
begin {начало цикла}
i:=i+1; {увеличиваем i на 1}

Напишите программу, которая удаляла бы комментарии и вставляла бы
вместо исключённого комментария пробел (чтобы 1{один}2 превратилось не в 12, а в 1 2).
Решение. Программа имеет два состояния: «основное» и «внутри»
(комментария).
Состояние Очередной
входной символ
основное
{
основное
𝑥=
̸ {
внутри
}
внутри
𝑥=
̸ }

Новое
состояние
внутри
основное
основное
внутри

Действие
нет
печатать 𝑥
печатать пробел
нет



Замечание. Эта программа не воспринимает вложенные комментарии: строка вроде

{{комментарий внутри} комментария}

превратится в
комментария}

(в начале стоят два пробела). Обработка вложенных комментариев
конечным автоматом невозможна (нужно «помнить число скобок» |
а произвольное натуральное число не помещается в конечную память).
5.1.4. В паскалевских программах бывают также строки, заключённые в кавычки. Если фигурная скобка встречается внутри строки, то
она не означает начала или конца комментария. В свою очередь, кавычка в комментарии не означает начала или конца строки. Как изменить
программу, чтобы это учесть?
[Указание. Состояний будет три: основное, внутри комментария,
внутри строки.]

5.1.5. Ещё одна возможность многих реализаций паскаля | это
комментарии вида
i:=i+1;

(*

here i is increased by 1 *)

при этом закрывающая скобка должна соответствовать открывающей
(то есть {. . . *) не разрешается). Как удалять такие комментарии? 

92

5. Конечные автоматы и обработка текстов

5.2. Ввод чисел

Пусть десятичная запись числа подаётся на вход программы символ
за символом. Мы хотим «прочесть» это число (поместить в переменную
типа real его значение). Кроме того, надо сообщить об ошибке, если
число записано неверно.
Более конкретно, представим себе такую ситуацию. Последовательность символов на входе делится на прочитанную и оставшуюся части.
Мы можем пользоваться функцией Next:char, которая даёт первый
символ оставшейся части, а также процедурой Move, которая забирает
первый символ из оставшейся части, переводя его в категорию прочитанных.
прочитанная часть

Next

?

?

Будем называть десятичной записью такую последовательность
символов:
⟨0 или более пробелов⟩ ⟨1 или более цифр⟩,
а также такую:
⟨0

или более пробелов⟩ ⟨1 или более цифр⟩.⟨1 или более цифр⟩.

Заметим, что согласно этому определению
1.

.1

1.␣1

-1.1

не являются десятичными записями. Сформулируем теперь задачу
точно:
5.2.1. Прочтите из входной строки максимальную часть, которая
может быть началом десятичной записи. Определите, является ли эта
часть десятичной записью или нет.
Решение. Запишем программу на паскале (используя «перечислимый
тип» для наглядности записи: переменная state может принимать одно
из значений, указанных в скобках).
var state:
(Accept, Error, Initial, IntPart, DecPoint, FracPart);
state := Initial;

5.2. Ввод чисел

93

while (state Accept) or (state Error) do begin
if state = Initial then begin
if Next = ’ ’ then begin
state := Initial; Move;
end else if Digit(Next) then begin
state := IntPart; {после начала целой части}
Move;
end else begin
state := Error;
end;
end else if state = IntPart then begin
if Digit (Next) then begin
state := IntPart; Move;
end else if Next = ’.’ then begin
state := DecPoint; {после десятичной точки}
Move;
end else begin
state := Accept;
end;
end else if state = DecPoint then begin
if Digit (Next) then begin
state := FracPart; Move;
end else begin
state := Error; {должна быть хоть одна цифра}
end;
end else if state = FracPart then begin
if Digit (Next) then begin
state := FracPart; Move;
end else begin
state := Accept;
end;
end else if
{такого быть не может}
end;
end;

Заметьте, что присваивания state:=Accept и state:=Error не сопровождаются сдвигом (символ, который не может быть частью числа, не
забирается).

Приведённая программа не запоминает значение прочитанного числа.
5.2.2. Решите предыдущую задачу с дополнительным требованием:
если прочитанный кусок является десятичной записью, то в переменную val:real следует поместить её значение.

94

5. Конечные автоматы и обработка текстов

Решение. При чтении дробной части переменная step хранит множитель при следующей десятичной цифре.

state := Initial; val:= 0;
while (state Accept) or (state Error) do begin
if state = Initial then begin
if Next = ’ ’ then begin
state := Initial; Move;
end else if Digit(Next) then begin
state := IntPart; {после начала целой части}
val := DigitValue (Next); Move;
end else begin
state := Error;
end;
end else if state = IntPart then begin
if Digit (Next) then begin
state := IntPart; val := 10*val + DigitVal(Next);
Move;
end else if Next = ’.’ then begin
state := DecPoint; {после десятичной точки}
step := 0.1;
Move;
end else begin
state := Accept;
end;
end else if state = DecPoint then begin
if Digit (Next) then begin
state := FracPart;
val := val + DigitVal(Next)*step; step := step/10;
Move;
end else begin
state := Error; {должна быть хоть одна цифра}
end;
end else if state = FracPart then begin
if Digit (Next) then begin
state := FracPart;
val := val + DigitVal(Next)*step; step := step/10;
Move;
end else begin
state := Accept;
end;
end else if
{такого быть не может}
end;
end;



95

5.2. Ввод чисел

5.2.3. Та же задача, если перед числом может стоять знак - или
знак + (а может ничего не стоять).
Формат чисел в этой задаче обычно иллюстрируют такой картинкой:

+
-

-

⟨цифра⟩


-

.

⟨цифра⟩




5.2.4. Та же задача, если к тому же после числа может стоять
показатель степени десяти, как в 254E-4 (= 0.0254) или в 0.123E+9
(= 123 000 000). Нарисуйте соответствующую картинку.

5.2.5. Что надо изменить в приведённой выше программе, чтобы
разрешить пустые целую и дробную части (как в «1.», «.1» или даже «.» | последнее число считаем равным нулю)?

Мы вернёмся к конечным автоматам в главе 10 (Сравнение с образцом).

6. ТИПЫ ДАННЫХ
6.1. Стеки

Пусть T | некоторый тип. Рассмотрим (отсутствующий в паскале)
тип «стек элементов типа T». Его значениями являются последовательности значений типа T.
Операции:


Сделать пустым (var s: стек элементов типа T)



Добавить (t:T; var s: стек элементов типа T)



Взять (var t:T; var s: стек элементов типа T)



Пуст (s: стек элементов типа T): boolean



Вершина (s: стек элементов типа T): T

(Мы пользуемся обозначениями, напоминающими паскаль, хотя в
паскале типа «стек» нет.) Процедура «Сделать пустым» делает стек s
пустым. Процедура «Добавить» добавляет t в конец последовательности s. Процедура «Взять» применима, если последовательность s непуста; она забирает из неё последний элемент, который становится значением переменной t. Выражение «Пуст(s)» истинно, если последовательность s пуста. Выражение «Вершина(s)» определено, если последовательность s непуста, и равно последнему элементу последовательности s.
Мы покажем, как моделировать стек в паскале и для чего он может
быть нужен.
Моделирование ограниченного стека в массиве

Будем считать, что количество элементов в стеке не превосходит
некоторого числа n. Тогда стек можно моделировать с помощью двух

6.1. Стеки

97

переменных:
Содержание: array [1..n] of T;
Длина: integer;

считая, что в стеке находятся элементы
Содержание [1],...,Содержание [Длина].



Чтобы сделать стек пустым, достаточно положить
Длина := 0



Добавить элемент t:
{Длина < n}
Длина := Длина+1;
Содержание [Длина] :=t;



Взять элемент в переменную t:
{Длина > 0}
t := Содержание [Длина];
Длина := Длина - 1;

Стек пуст, если Длина = 0.
∙ Вершина стека равна Содержание [Длина].
Таким образом, вместо переменной типа стек в программе на паскале можно использовать две переменные Содержание и Длина. Можно
также определить тип stack, записав


const N = ...
type
stack = record
Содержание: array [1..N] of T;
Длина: integer;
end;

(Мы позволяем себе использовать имена переменных из русских букв,
хотя обычно паскаль этого не любит.) После этого могут быть | в соответствии с правилами паскаля | описаны процедуры работы со стеком. Например, можно написать
procedure Добавить (t: T; var s: stack);
begin
{s.Длина < N}
s.Длина := s.Длина + 1;
s.Содержание [s.Длина] := t;
end;

98

6. Типы данных
Использование стека

Будем рассматривать последовательности открывающихся и закрывающихся круглых и квадратных скобок ( ) [ ]. Среди всех таких
последовательностей выделим правильные | те, которые могут быть
получены по таким правилам:


пустая последовательность правильна.



если 𝐴 и 𝐵 правильны, то и 𝐴𝐵 правильна.



если 𝐴 правильна, то [𝐴] и (𝐴) правильны.

Пример. Последовательности (), [[ ]], [()[ ]()][ ] правильны, а
последовательности ], )(, (], ([)] | нет.
6.1.1. Проверьте правильность последовательности за время, не
превосходящее константы, умноженной на её длину. Предполагается,
что члены последовательности закодированы числами:

(
[
)
]

1
2
−1
−2

Решение. Пусть a[1]. . . a[n] | проверяемая последовательность.
Разрешим хранить в стеке открывающиеся круглые и квадратные скобки (т. е. 1 и 2).
Вначале стек делаем пустым. Далее просматриваем члены последовательности слева направо. Встретив открывающуюся скобку (круглую
или квадратную), помещаем её в стек. Встретив закрывающуюся, проверяем, что вершина стека | парная ей скобка; если это не так, то
можно утверждать, что последовательность неправильна, если скобка
парная, то заберём её (вершину) из стека. Последовательность правильна, если в конце стек оказывается пуст.

Сделать_пустым (s);
i := 0; Обнаружена_ошибка := false;
{прочитано i символов последовательности}
while (i < n) and not Обнаружена_ошибка do begin
i := i + 1;
if (a[i] = 1) or (a[i] = 2) then begin
Добавить (a[i], s);
end else begin {a[i] равно -1 или -2}

6.1. Стеки

99

if Пуст (s) then begin
Обнаружена_ошибка := true;
end else begin
Взять (t, s);
Обнаружена_ошибка := (t - a[i]);
end;
end;
end;
Правильно := (not Обнаружена_ошибка) and Пуст (s);

Убедимся в правильности программы.
(1) Если последовательность построена по правилам, то программа
даст ответ «да». Это легко доказать индукцией по построению правильной последовательности. Надо проверить для пустой, для последовательности 𝐴𝐵 в предположении, что для 𝐴 и 𝐵 уже проверено,
и, наконец, для последовательностей [𝐴] и (𝐴) | в предположении,
что для 𝐴 уже проверено. Для пустой очевидно. Для 𝐴𝐵 действия программы происходят как для 𝐴 и кончаются с пустым стеком; затем всё
происходит как для 𝐵 . Для [𝐴] сначала помещается в стек открывающая квадратная скобка и затем всё идёт как для 𝐴 | с тойразницей,
что в глубине стека лежит лишняя скобка. По окончании 𝐴 стек становится пустым | если не считать этой скобки | а затем и совсем
пустым. Аналогично для (𝐴).
(2) Покажем, что если программа завершает работу с ответом «да»,
то последовательность правильна. Рассуждаем индукцией по длине последовательности. Проследим за состоянием стека в процессе работы
программы. Если он в некоторый промежуточный момент пуст, то последовательность разбивается на две части, для каждой из которых
программа даёт ответ «да»; остаётся воспользоваться предположением
индукции и определением правильности. Пусть стек всё время непуст.
Это значит, что положенная в него на первом шаге скобка будет вынута лишь на последнем шаге. Тем самым, первый и последний символы
последовательности | это парные скобки, и последовательность имеет
вид (𝐴) или [𝐴], а работа программы (кроме первого и последнего
шагов) отличается от её работы на 𝐴 лишь наличием лишней скобки
на дне стека (раз её не вынимают, она никак не влияет на работу программы). Снова ссылаемся на предположение индукции и определение
правильности.

6.1.2. Как упростится программа, если известно, что в последовательности могут быть только круглые скобки?
Решение. В этом случае от стека остаётся лишь его длина, и мы фак-

100

6. Типы данных

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

6.1.3. Реализуйте с помощью одного массива два стека, суммарное количество элементов в которых ограничено длиной массива; все
действия со стеками должны выполняться за время, ограниченное константой, не зависящей от длины стеков.
Решение. Стеки должны расти с концов массива навстречу друг
другу: первый должен занимать места
Содержание[1] . . . Содержание[Длина1],

а второй |
Содержание[n] . . . Содержание[n-Длина2+1]

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

6.1.4. Реализуйте k стеков с элементами типа T, общее количество
элементов в которых не превосходит n, с использованием массивов суммарной длины 𝐶 (n + k), затрачивая на каждое действие со стеками
(кроме начальных действий, делающих все стеки пустыми) время не
более некоторой константы 𝐶 . (Как говорят, общая длина массивов
должна быть 𝑂(m + n), a время на каждую операцию | 𝑂(1).)
Решение. Применяемый метод называется «ссылочной реализацией».
Он использует три массива:
Содержание: array [1..n] of T;
Следующий: array [1..n] of 0..n;
Вершина: array [1..k] of 0..n.

Удобно изображать массив Содержание как n ячеек с номерами 1 . . . n, каждая из которых содержит элемент типа T. Массив
Следующий изобразим в виде стрелок, проведя стрелку из i в j, если Следующий[i]=j. (Если Следующий[i]=0, стрелок из i не проводим.) Содержимое s-го стека (s ∈ 1 . . . k) хранится так: вершина равна
Содержание[Вершина[s]], остальные элементы s-го стека можно найти,
идя по стрелкам | до тех пор, пока они не кончатся. При этом
(s-й стек пуст) ⇔ Вершина[s]=0.

101

6.1. Стеки

Стрелочные траектории, выходящие из
Вершина[1], . . . , Вершина[k]

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

Содержание

a

p

q

d

s

Вершина

Содержание
Следующий
Вершина

t

v

w

Свободная

a
3
1

p
0
7

q
6

d
0

s
0

t
2

v
5

w
4

Свободная = 8

Стеки: первый содержит p, t, q, a (a | вершина); второй содержит s, v
(v | вершина).
procedure Начать_работу; {Делает все стеки пустыми}
var i: integer;
begin
for i := 1 to k do begin
Вершина [i]:=0;
end;
for i := 1 to n-1 do begin
Следующий [i] := i+1;
end;
Следующий [n] := 0;

102

6. Типы данных
Свободная:=1;
end;
function Есть_место: boolean;
begin
Есть_место := (Свободная 0);
end;
procedure Добавить (t: T; s: integer);
{Добавить t к s-му стеку}
var i: 1..n;
begin
{Есть_место}
i := Свободная;
Свободная := Следующий [i];
Следующий [i] := Вершина [s];
Вершина [s] :=i;
Содержание [i] := t;
end;
function Пуст (s: integer): boolean;
{s-ый стек пуст}
begin
Пуст := (Вершина [s] = 0);
end;
procedure Взять (var t: T; s: integer);
{взять из s-го стека в t}
var i: 1..n;
begin
{not Пуст (s)}
i := Вершина [s];
t := Содержание [i];
Вершина [s] := Следующий [i];
Следующий [i] := Свободная;
Свободная := i;
end;
function Вершина_стека (s: integer): T;
{вершина s-го стека}
begin
Вершина_стека := Содержание[Вершина[s]];
end;



6.2. Очереди

103

6.2. Очереди

Значениями типа «очередь элементов типа T», как и для стеков, являются последовательности значений типа T. Разница состоит в том, что
берутся элементы не с конца, а с начала (а добавляются по-прежнему
в конец).
Операции с очередями:






Сделать пустой (var x: очередь элементов типа T);
Добавить (t:T, var x: очередь элементов типа T);
Взять (var t:T, var x: очередь элементов типа T);
Пуста (x: очередь элементов типа T): boolean;
Очередной (x: очередь элементов типа T): T.

При выполнении команды «Добавить» указанный элемент добавляется в конец очереди. Команда «Взять» выполнима, лишь если очередь
непуста, и забирает из неё первый (положенный туда раньше всех) элемент, помещая его в t. Значением функции «Очередной» (определённой
для непустой очереди) является первый элемент очереди.
Английские названия стеков | Last In First Out (последним вошёл | первым вышел), а очередей | First In First Out (первым вошёл |
первым вышел). Сокращения: LIFO, FIFO.
Реализация очередей в массиве

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

Содержание: array [0..n-1] of T

и переменные
Первый: 0..n-1,
Длина : 0..n.

104

6. Типы данных

При этом элементами очереди будут
Содержание [Первый], Содержание [Первый+1], . . . ,
Содержание [Первый+Длина-1],

где сложение выполняется по модулю n. (Предупреждение. Если вместо
этого ввести переменные Первый и Последний, значения которых | вычеты по модулю n, то пустая очередь может быть спутана с очередью
из n элементов.)
Операции выполняются так.
Сделать пустой:
Длина := 0;
Первый := 0;

Добавить элемент:
{Длина < n}
Содержание [(Первый + Длина) mod n] := элемент;
Длина := Длина + 1;

Взять элемент:
{Длина > 0}
элемент := Содержание [Первый];
Первый := (Первый + 1) mod n;
Длина := Длина - 1;

Пуста:
Длина = 0

Очередной:
Содержание [Первый]



6.2.2. (Сообщил А. Г. Кушниренко) Придумайте способ моделирования очереди с помощью двух стеков (и фиксированного числа переменных типа T). При этом отработка n операций с очередью (начатых,
когда очередь была пуста) должна требовать порядка n действий.
Решение. Инвариант: стеки, составленные концами, образуют очередь. (Перечисляя элементы одного стека вглубь и затем элементы второго наружу, мы перечисляем все элементы очереди от первого до последнего.) Ясно, что добавление сводится к добавлению к одному из

6.2. Очереди

105

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

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

6.2.4. (Сообщил А. Г. Кушниренко.) Имеется дек элементов типа T
и конечное число переменных типа T и целого типа. В начальном состоянии в деке некоторое число элементов. Составьте программу, после
исполнения которой в деке остались бы те же самые элементы, а их число было бы в одной из целых переменных.
[Указание. (1) Элементы дека можно циклически переставлять, забирая с одного конца и помещая в другой. После этого, сделав столько
же шагов в обратном направлении, можно вернуть всё на место. (2) Как
понять, прошли мы полный круг или не прошли? Если бы какой-то элемент заведомо отсутствовал в деке, то можно было бы его подсунуть
и ждать вторичного появления. Но таких элементов нет. Вместо этого
можно для данного n выполнить циклический сдвиг на n дважды, подсунув разные элементы, и посмотреть, появятся ли разные элементы
через n шагов.]

Применение очередей

6.2.5. Напечатайте в порядке возрастания первые n натуральных
чисел, в разложение которых на простые множители входят только числа 2, 3, 5.
Решение. Введём три очереди x2, x3, x5, в которых будем хранить
элементы, которые в 2 (3, 5) раз больше напечатанных, но ещё не напечатаны. Определим процедуру

procedure напечатать_и_добавить (t: integer);
begin

106

6. Типы данных
writeln (t);
Добавить (2*t, x2);
Добавить (3*t, x3);
Добавить (5*t, x5);
end;

Вот схема программы:
...сделать x2, x3, x5 пустыми
напечатать_и_добавить (1);
k := 1; { k - число напечатанных }
{инвариант: напечатано в порядке возрастания k минимальных
членов нужного множества; в очередях элементы, вдвое,
втрое и впятеро большие напечатанных, но не напечатанные,
расположенные в возрастающем порядке}
while k n do begin
x := min (очередной(x2), очередной(x3), очередной(x5));
напечатать_и_добавить (x);
k := k+1;
...взять x из тех очередей, где он был очередным;
end;

Пусть инвариант выполняется. Рассмотрим наименьший из ненапечатанных элементов множества; пусть это x. Тогда он делится нацело
на одно из чисел 2, 3, 5, и частное также принадлежит множеству. Значит, оно напечатано. Значит, x находится в одной из очередей и, следовательно, является в ней первым (меньшие напечатаны, а элементы
очередей не напечатаны). Напечатав x, мы должны его изъять и добавить его кратные.
Длины очередей не превосходят числа напечатанных элементов. 
Следующая задача связана с графами (к которым мы вернёмся в главе 9).
Пусть задано конечное множество, элементы которого называют
вершинами, а также некоторое множество упорядоченных пар вершин,
называемых рёбрами. В этом случае говорят, что задан ориентированный граф. Пару ⟨𝑝, 𝑞 ⟩ называют ребром с началом 𝑝 и концом 𝑞 ; говорят
также, что оно выходит из вершины 𝑝 и входит в вершину 𝑞. Обычно
вершины графа изображают точками, а рёбра | стрелками, ведущими
из начала в конец. (В соответствии с определением из данной вершины в данную ведёт не более одного ребра; возможны рёбра, у которых
начало совпадает с концом.)
6.2.6. Известно, что ориентированный граф связен, т. е. из любой
вершины можно пройти в любую по рёбрам. Кроме того, из каждой

6.2. Очереди

107

вершины выходит столько же рёбер, сколько входит. Докажите, что существует замкнутый цикл, проходящий по каждому ребру ровно один
раз. Составьте алгоритм отыскания такого цикла.
Решение. Змеёй будем называть непустую очередь из вершин, в которой любые две вершины соединены ребром графа (началом является
та вершина, которая ближе к началу очереди). Стоящая в начале очереди вершина будет хвостом змеи, последняя | головой. На рисунке змея
изобразится в виде цепи рёбер графа, стрелки ведут от хвоста к голове. Добавление вершины в очередь соответствует росту змеи с головы,
взятие вершины | отрезанию кончика хвоста.
Вначале змея состоит из единственной вершины. Далее мы следуем
такому правилу:

while змея включает не все рёбра do begin
if из головы выходит не входящее в змею ребро then begin
удлинить змею этим ребром
end else begin
{голова змеи в той же вершине, что и хвост}
отрезать конец хвоста и добавить его к голове
{"змея откусывает конец хвоста"}
end;
end;

Докажем, что мы достигнем цели.
(1) Идя по змее от хвоста к голове, мы входим в каждую вершину
столько же раз, сколько выходим. Так как в любую вершину входит
столько же рёбер, сколько выходит, то невозможность выйти означает,
что голова змеи в той же точке, что и хвост.
(2) Змея не укорачивается, поэтому либо она охватит все рёбра,
либо, начиная с некоторого момента, будет иметь постоянную длину.
Во втором случае змея будет бесконечно «скользить по себе». Это возможно, только если из всех вершин змеи не выходит неиспользованных
рёбер. В этом случае из связности следует, что змея проходит по всем
рёбрам.
Замечание по реализации на паскале. Вершинами графа будем считать числа 1 . . . n. Для каждой вершины i будем хранить число Out[i]
выходящих из неё рёбер, а также номера Num[i][1],. . . ,Num[i][Out[i]]
тех вершин, куда эти рёбра ведут. В процессе построения змеи будем
выбирать первое свободное ребро. Тогда достаточно хранить для каждой вершины число выходящих из неё использованных рёбер | это
будут рёбра, идущие в начале списка.


108

6. Типы данных

6.2.7. Докажите, что для всякого 𝑛 существует последовательность
нулей и единиц длины 2 со следующим свойством: если «свернуть её
в кольцо» и рассмотреть все фрагменты длины 𝑛 (их число равно 2 ),
то мы получим все возможные последовательности нулей и единиц длины 𝑛. Постройте алгоритм отыскания такой последовательности, требующий не более 𝐶 действий для некоторой константы 𝐶 .
[Указание. Рассмотрим граф, вершинами которого являются последовательности нулей и единиц длины 𝑛 − 1. Будем считать, что из вершины 𝑥 ведёт ребро в вершину 𝑦, если 𝑥 может быть началом, а 𝑦 |
концом некоторой последовательности длины 𝑛. Тогда из каждой вершины входит и выходит два ребра. Цикл, проходящий по всем рёбрам,
и даст требуемую последовательность.]

6.2.8. Реализуйте 𝑘 очередей с ограниченной суммарной длиной 𝑛,
используя память 𝑂(𝑛 + 𝑘) [= не более 𝐶 (𝑛 + 𝑘) для некоторой константы 𝐶 ], причём каждая операция (кроме начальной, делающей все
очереди пустыми) должна требовать ограниченного константой числа
действий.
Решение. Действуем аналогично ссылочной реализации стеков: мы
помним (для каждой очереди) первого, каждый участник очереди помнит следующего за ним (для последнего считается, что за ним стоит
фиктивный элемент с номером 0). Кроме того, мы должны для каждой
очереди знать последнего (если он есть) | иначе не удастся добавлять.
Как и для стеков, отдельно есть цепь свободных ячеек. Заметим, что
для пустой очереди информация о последнем элементе теряет смысл |
но она и не используется при добавлении.
𝑛

𝑛

𝑛

Содержание: array [1..n] of T;
Следующий: array [1..n] of 0..n;
Первый: array [1..k] of 0..n;
Последний: array [1..k] of 0..n;
Свободная : 0..n;
procedure Сделать_пустым;
var i: integer;
begin
for i := 1 to n-1 do begin
Следующий [i] := i + 1;
end;
Следующий [n] := 0;
Свободная := 1;
for i := 1 to k do begin

6.2. Очереди
Первый [i]:=0;
end;
end;
function Есть_место : boolean;
begin
Есть_место := Свободная 0;
end;
function Пуста (номер_очереди: integer): boolean;
begin
Пуста := Первый [номер_очереди] = 0;
end;
procedure Взять (var t: T; номер_очереди: integer);
var перв: integer;
begin
{not Пуста (номер_очереди)}
перв := Первый [номер_очереди];
t := Содержание [перв]
Первый [номер_очереди] := Следующий [перв];
Следующий [перв] := Свободная;
Свободная := перв;
end;
procedure Добавить (t: T; номер_очереди: integer);
var нов, посл: 1..n;
begin
{Есть_место }
нов := Свободная; Свободная := Следующий [Свободная];
{из списка свободного места изъят номер нов}
if Пуста (номер_очереди) then begin
Первый [номер_очереди] := нов;
Последний [номер_очереди] := нов;
Следующий [нов] := 0;
Содержание [нов] := t;
end else begin
посл := Последний [номер_очереди];
{Следующий [посл] = 0 }
Следующий [посл] := нов;
Следующий [нов] := 0;
Содержание [нов] := t
Последний [номер_очереди] := нов;
end;
end;

109

110

6. Типы данных
function Очередной (номер_очереди: integer): T;
begin
Очередной := Содержание [Первый [номер_очереди]];
end;



Та же задача для деков вместо очередей.
[Указание. Дек | структура симметричная, поэтому надо хранить
ссылки в обе стороны (вперёд и назад). При этом удобно к каждому
деку добавить фиктивный элемент, замкнув его в кольцо, и точно такое
же кольцо образовать из свободных позиций.]

В следующей задаче дек используется для хранения вершин выпуклого многоугольника.
6.2.10. На плоскости задано 𝑛 точек, пронумерованных слева направо (а при равных абсциссах | снизу вверх). Составьте программу,
которая строит многоугольник, являющийся их выпуклой оболочкой,
за 𝑂(𝑛) [= не более чем 𝐶𝑛] действий.
Решение. Будем присоединять точки к выпуклой оболочке одна за
другой. Легко показать, что последняя присоединённая точка будет
одной из вершин выпуклой оболочки. Эту вершину мы будем называть выделенной. Очередная присоединяемая точка видна из выделенной (почему?). Дополним наш многоугольник, выпустив из выделенной
вершины «иглу», ведущую в присоединяемую точку. Получится вырожденный многоугольник, и остаётся ликвидировать в нём «впуклости».
6.2.9.

r
P
PP
P
r
B
B
B
B

Br

P
Pr
H
A HH
A
HH
AAr
HHr





r 



r


Будем хранить вершины многоугольника в деке в порядке обхода
его периметра по часовой стрелке. При этом выделенная вершина является началом и концом (головой и хвостом) дека. Присоединение «иглы»
теперь состоит в добавлении присоединяемой вершины в голову и в

6.3. Множества

111

хвост дека. Устранение впуклостей несколько более сложно. Назовём
подхвостом и подподхвостом элементы дека, стоящие за его хвостом.
Устранение впуклости у хвоста делается так:
while по дороге из хвоста в подподхвост мы поворачиваем
у подхвоста влево ("впуклость") do begin
выкинуть подхвост из дека
end

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

6.3. Множества

Пусть T | некоторый тип. Существует много способов хранить (конечные) множества элементов типа T; выбор между ними определяется
типом T и набором требуемых операций.
Подмножества множества

{1 . . . 𝑛}

6.3.1. Как, используя память размера 𝑂 (𝑛) [пропорциональную 𝑛],
хранить подмножества множества {1 . . . 𝑛}?

Операции
Число действий
Сделать пустым
𝐶𝑛
Проверить принадлежность
𝐶
Добавить
𝐶
Удалить
𝐶
Минимальный элемент
𝐶𝑛
Проверка пустоты
𝐶𝑛
Решение.

Храним множество как array [1..n] of Boolean.



112

6. Типы данных

То же, но проверка пустоты должна выполняться за время 𝐶 .
Решение. Храним дополнительно количество элементов.

6.3.3. То же при следующих ограничениях на число действий:
Операции
Число действий
Сделать пустым
𝐶𝑛
Проверить принадлежность
𝐶
Добавить
𝐶
Удалить
𝐶𝑛
Минимальный элемент
𝐶
Проверка пустоты
𝐶
6.3.2.

Решение.

ства.

6.3.4.

Дополнительно храним минимальный элемент множе

То же при следующих ограничениях на число действий:
Операции
Число действий
Сделать пустым
𝐶𝑛
Проверить принадлежность
𝐶
Добавить
𝐶𝑛
Удалить
𝐶
Минимальный элемент
𝐶
Проверка пустоты
𝐶

Решение. Храним минимальный, а для каждого | следующий и предыдущий по величине.

Множества целых чисел

В следующих задачах величина элементов множества не ограничена,
но их количество не превосходит 𝑛.
6.3.5. Память 𝐶𝑛.
Операции
Число действий
Сделать пустым
𝐶
Число элементов
𝐶
Проверить принадлежность
𝐶𝑛
Добавить новый (заведомо отсутствующий)
𝐶
Удалить
𝐶𝑛
Минимальный элемент
𝐶𝑛
Взять какой-то элемент
𝐶

6.3. Множества
Решение.

113

Множество представляем с помощью переменных

a:array [1..n] of integer, k: 0..n;

множество содержит k элементов a[1], . . . a[k]; все они различны. По
существу мы храним элементы множества в стеке (без повторений).
С тем же успехом можно было бы воспользоваться очередью вместо
стека.

6.3.6. Память 𝐶𝑛.
Операции
Число действий
Сделать пустым
𝐶
Проверить пустоту
𝐶
Проверить принадлежность
𝐶 log 𝑛
Добавить
𝐶𝑛
Удалить
𝐶𝑛
Минимальный элемент
𝐶
Решение. См. решение предыдущей задачи с дополнительным условием a[1] < . . . < a[k]. При проверке принадлежности используем двоичный поиск.

В следующей задаче полезно комбинировать разные способы.
6.3.7. Используя описанные способы представления множеств, найдите все вершины ориентированного графа, доступные из данной по
рёбрам. (Вершины считаем числами 1 . . . n.) Время не больше 𝐶 · (общее
число рёбер, выходящих из доступных вершин).
Решение. (Другое решение см. в главе о рекурсии, задача 7.4.6)
Пусть num[i] | число рёбер, выходящих из i, а out[i][1], . . .
. . . , out[i][num[i]] | вершины, куда ведут рёбра из вершины i.

procedure Доступные (i: integer);
{напечатать все вершины, доступные из i, включая i}
var X: подмножество 1..n;
P: подмножество 1..n;
q, v, w: 1..n;
k: integer;
begin
...сделать X, P пустыми;
writeln (i);
...добавить i к X, P;

114

6. Типы данных
{(1)
(2)
(3)
(4)

P = множество напечатанных вершин; P содержит i;
напечатаны только доступные из i вершины;
X - подмножество P;
все напечатанные вершины, из которых выходит
ребро в ненапечатанную вершину, принадлежат X}
while X непусто do begin
...взять какой-нибудь элемент X в v;
for k := 1 to num [v] do begin
w := out [v][k];
if w не принадлежит P then begin
writeln (w);
добавить w в P;
добавить w в X;
end;
end;
end;
end;

Свойство (1) не нарушается, так как печать происходит одновременно с добавлением в P. Свойство (2): раз v было в X, то v доступно,
поэтому w доступно. Свойство (3) очевидно. Свойство (4): мы удалили из X элемент v, но все вершины, куда из v идут рёбра, перед этим
напечатаны.

6.3.8. Покажите, что можно использовать и другой инвариант: P |
напечатанные вершины; X ⊂ P; осталось напечатать вершины, доступные из X по ненапечатанным вершинам.

Оценка времени работы. Заметим, что изъятые из X элементы больше туда не добавляются, так как они в момент изъятия (и, следовательно, всегда позже) принадлежат P, а добавляются только элементы не
из P. Поэтому тело цикла while для каждой доступной вершины выполняется не более, чем по разу, при этом тело цикла for выполняется
столько раз, сколько из вершины выходит рёбер.
Для X надо использовать представление со стеком или очередью
(см. выше), для P | булевский массив.

6.3.9. Решите предыдущую задачу, если требуется, чтобы доступные вершины печатались в таком порядке: сначала заданная вершина,
потом её соседи, потом соседи соседей (ещё не напечатанные) и т. д.
[Указание. Так получится, если использовать очередь для хранения X
в приведённом выше решении: докажите индукцией по 𝑘, что существует момент, в который напечатаны все вершины на расстоянии не больше 𝑘, а в очереди находятся все вершины, удалённые ровно на 𝑘.] 

6.4. Разные задачи

115

Более сложные способы представления множеств будут разобраны
в главах 13 (хеширование) и 14 (деревья).
6.4. Разные задачи
6.4.1. Реализуйте структуру данных, которая имеет все те же операции, что массив длины n, а именно
∙ начать работу;
∙ положить в i-ю ячейку число x;
∙ узнать, что лежит в i-й ячейке;
а также операцию
∙ указать номер минимального элемента
(точнее, одного из минимальных элементов). Количество действий для
всех операций должно быть не более 𝐶 log n, не считая операции «начать
работу» (которая требует не более 𝐶 n действий).
Решение. Используется приём, изложенный в разделе о сортировке
деревом. Именно, надстроим над элементами массива как над листьями
двоичное дерево, в каждой вершине которого храним минимум элементов соответствующего поддерева. Корректировка этой информации,
а также прослеживание пути из корня к минимальному элементу требуют логарифмического числа действий.

6.4.2. Приоритетная очередь | это очередь, в которой важно не то,
кто встал последним (порядок помещения в неё не играет роли), а кто
главнее. Более точно, при помещении в очередь указывается приоритет
помещаемого объекта (будем считать приоритеты целыми числами),
а при взятии из очереди выбирается элемент с наибольшим приоритетом (или один из таких элементов). Реализуйте приоритетную очередь
так, чтобы помещение и взятие элемента требовали логарифмического
числа действий (от размера очереди).
Решение. Следуя алгоритму сортировки деревом (в его окончательном варианте), будем размещать элементы очереди в массиве x[1..k],
поддерживая такое свойство: x[i] старше (имеет больший приоритет)
своих сыновей x[2i] и x[2i+1], если таковые существуют | и, следовательно, всякий элемент старше своих потомков. (Сведения о приоритетах также хранятся в массиве, так что мы имеем дело с массивом пар ⟨элемент, приоритет⟩.) Удаление элемента с сохранением этого

116

6. Типы данных

свойства описано в алгоритме сортировки. Надо ещё уметь восстанавливать свойство после добавления элемента в конец. Это делается так:
t:= номер добавленного элемента
{инвариант: в дереве любой предок приоритетнее потомка,
если этот потомок - не t}
while t - не корень и t старше своего отца do begin
поменять t с его отцом
end;

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

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

7. РЕКУРСИЯ
7.1. Примеры рекурсивных программ

При анализе рекурсивной программы возникает, как обычно, два
вопроса:
(а)

почему программа заканчивает работу?

(б)

почему она работает правильно, если заканчивает работу?

Для (б) достаточно проверить, что (содержащая рекурсивный вызов) программа работает правильно, предположив, что вызываемая ею
одноимённая программа работает правильно. В самом деле, в этом случае в цепочке рекурсивно вызываемых программ все программы работают правильно (убеждаемся в этом, идя от конца цепочки к началу).
Чтобы доказать (а), обычно проверяют, что с каждым рекурсивным
вызовом значение какого-то параметра уменьшается, и это не может
продолжаться бесконечно.
7.1.1. Напишите рекурсивную процедуру вычисления факториала
целого положительного числа 𝑛 (т. е. произведения 1 · 2 · · · 𝑛, обозначаемого 𝑛!).
Решение. Используем равенства 1! = 1, 𝑛! = (𝑛 − 1)! · 𝑛.
procedure factorial (n: integer; var fact: integer);
{положить fact равным факториалу числа n}
begin
if n=1 then begin
fact:=1;
end else begin {n>1}
factorial (n-1, fact);
{fact = (n-1)!}
fact:= fact*n;
end;
end;



118

7. Рекурсия

С использованием процедур-функций можно написать так:
function factorial (n: integer): integer;
begin
if n=1 then begin
factorial:=1;
end else begin {n>1}
factorial:= factorial (n-1)*n;
end;
end;

Обратите внимание на некоторую двойственность использования имени factorial внутри описания функции: оно обозначает как переменную, так и вызываемую рекурсивно функцию. К счастью, в нашем случае они различаются по скобкам после имени, но если бы функция была
без параметров, то дело было бы плохо. (Стандартная, но трудно находимая ошибка возникает, если автор программы на паскале полагает,
что он использует значение переменной, а компилятор в этом месте
видит рекурсивный вызов.)
7.1.2. Обычно факториал определяют и для нуля, считая, что 0! = 1.
Измените программы соответственно.

7.1.3. Напишите рекурсивную программу возведения в целую неотрицательную степень.

7.1.4. То же, если требуется, чтобы глубина рекурсии не превосходила 𝐶 log 𝑛, где 𝑛 | показатель степени.
Решение.

function power (a,n: integer): integer;
begin
if n = 0 then begin
power:= 1;
end else if n mod 2 = 0 then begin
power:= power(a*a, n div 2);
end else begin
power:= power(a, n-1)*a;
end;
end;



7.1. Примеры рекурсивных программ

119

7.1.5. Что будет, если изменить программу, приведённую в решении
предыдущей задачи, заменив строку

power:= power(a*a, n div 2)

на
power:= power(a, n div 2)* power(a, n div 2)?
Решение. Программа останется правильной. Однако она станет работать медленнее. Дело в том, что теперь вызов может породить два
вызова (хотя и одинаковых) вместо одного | и число вызовов быстро
растёт с глубиной рекурсии. Программа по-прежнему имеет логарифмическую глубину рекурсии, но число шагов работы становится линейным вместо логарифмического.
Этот недостаток можно устранить, написав

t:= power(a, n div 2);
power:= t*t;

или воспользовавшись функцией возведения в квадрат (sqr).

7.1.6. Используя команды write(x) лишь при x = 0 . . . 9, напишите
рекурсивную программу печати десятичной записи целого положительного числа 𝑛.
Решение. Здесь использование рекурсии облегчает жизнь (проблема была в том, что цифры легче получать с конца, а печатать надо
с начала).
procedure print (n:integer); {n>0}
begin
if n’, n);
end else begin
s:=6-m-n; {s - третий стержень: сумма номеров равна 6}
move (i-1, m, s);
writeln (’сделать ход ’, m, ’->’, n);
move (i-1, s, n);
end;
end;

(Сначала переносится пирамидка из i-1 колец на третью палочку. После этого i-е кольцо освобождается, и его можно перенести куда следует. Остаётся положить на него пирамидку.)

7.1.8. Напишите рекурсивную программу суммирования массива
a: array [1..n] of integer.
[Указание. Рекурсивно определяемая функция должна иметь дополнительный параметр | число складываемых элементов.]

7.2. Рекурсивная обработка деревьев

Двоичным деревом называется картинка вроде такой:
k
A
A

k

k

A 
A 
k

k

A 
A 
k

Нижняя вершина называется корнем. Из каждой вершины могут идти две линии: влево вверх и вправо вверх. Вершины, куда они ведут,

7.2. Рекурсивная обработка деревьев

121

называются левым и правым сыновьями исходной вершины. Вершина
может иметь двух сыновей, а может иметь только одного сына (левого
или правого). Она может и вовсе не иметь сыновей, и в этом случае
называется листом.
Пусть 𝑥 | какая-то вершина двоичного дерева. Она сама вместе
с сыновьями, внуками, правнуками и т. д. образует поддерево с корнем
в 𝑥 | поддерево потомков 𝑥.
В следующих задачах мы предполагаем, что вершины дерева пронумерованы целыми положительными числами, причём номера всех вершин различны. Мы считаем, что номер корня хранится в переменной
root. Мы считаем, что имеются два массива
l,r: array [1..N] of integer

и левый и правый сын вершины с номером i имеют соответственно
номера l[i] и r[i]. Если вершина с номером i не имеет левого (или
правого) сына, то l[i] (соответственно r[i]) равно 0. (По традиции
при записи программ мы используем вместо нуля константу nil, равную нулю.)
Здесь N | достаточно большое натуральное число (номера всех вершин не превосходят N). Отметим, что номер вершины никак не связан
с её положением в дереве и что не все числа от 1 до N обязаны быть
номерами вершин (и, следовательно, часть данных в массивах l и r |
это мусор).
7.2.1. Пусть N = 7, root = 3, массивы l и r таковы:
i 1 2 3 4 5 6 7
l[i] 0 0 1 0 6 0 7
r[i] 0 0 5 3 2 0 7
Нарисуйте соответствующее дерево.
Ответ.





6


2


AA

A



1

5



AA

A

3





122

7. Рекурсия

Напишите программу подсчёта числа вершин в дереве.
Решение. Рассмотрим функцию n(x), равную числу вершин в поддереве с корнем в вершине номер x. Считаем, что n(nil) = 0 (полагая соответствующее поддерево пустым), и не заботимся о значениях nil(s)
для чисел s, не являющихся номерами вершин. Рекурсивная программа
для n такова:
7.2.2.

function n(x:integer):integer;
begin
if x = nil then begin
n:= 0;
end else begin
n:= n(l[x]) + n(r[x]) + 1;
end;
end;

(Число вершин в поддереве над вершиной x равно сумме чисел вершин над её сыновьями плюс она сама.) Глубина рекурсии конечна, так
как с каждым шагом высота соответствующего поддерева уменьшается.

7.2.3. Напишите программу подсчёта числа листьев в дереве.
Ответ.

function n (x:integer):integer;
begin
if x = nil then begin
n:= 0;
end else if (l[x]=nil) and (r[x]=nil) then begin {лист}
n:= 1;
end else begin
n:= n(l[x]) + n(r[x]);
end;
end;



7.2.4. Напишите программу подсчёта высоты дерева (корень имеет
высоту 0, его сыновья | высоту 1, внуки | 2 и т. п.; высота дерева |
это максимум высот его вершин).
[Указание. Рекурсивно определяется функция f(x) = высота поддерева с корнем в x.]

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


7.3. Порождение комбинаторных объектов, перебор

123

Вместо подсчёта количества вершин того или иного рода можно
просить напечатать список этих вершин (в том или ином порядке).
7.2.6. Напишите программу, которая печатает (по одному разу) все
вершины дерева.
Решение. Процедура print subtree(x) печатает все вершины поддерева с корнем в x по одному разу; главная программа содержит вызов
print subtree(root).
procedure print_subtree (x:integer);
begin
if x = nil then begin
{ничего не делать}
end else begin
writeln (x);
print_subtree (l[x]);
print_subtree (r[x]);
end;
end;

Данная программа печатает сначала корень поддерева, затем поддерево над левым сыном, а затем над правым. Три строки в else-части
могут быть переставлены 6 способами, и каждый из этих способов даёт
свой порядок печати вершин.

7.3. Порождение комбинаторных объектов,
перебор

Рекурсивные программы являются удобным способом порождения
комбинаторных объектов заданного вида. Мы решим заново несколько
задач соответствующей главы.
7.3.1. Напишите программу, которая печатает по одному разу все
последовательности длины n, составленные из чисел 1 . . . k (их количество равно kn ).
Решение. Программа будет оперировать с массивом a[1] . . . a[n]
и числом t. Рекурсивная процедура generate печатает все последовательности, начинающиеся на a[1] . . . a[t]; после её окончания t и
a[1] . . . a[t] имеют то же значение, что и в начале:
procedure generate;
var i,j : integer;

124

7. Рекурсия
begin
if t = n then begin
for i:=1 to n do begin
write(a[i]);
end;
writeln;
end else begin {t < n}
for j:=1 to k do begin
t:=t+1;
a[t]:=j;
generate;
t:=t-1;
end;
end;
end;

Основная программа теперь состоит из двух операторов:
t:=0; generate;



Замечание. Команды t:=t+1 и t:=t-1 для экономии можно вынести
из цикла for.
7.3.2. Напишите программу, которая печатала бы все перестановки
чисел 1 . . . n по одному разу.
Решение. Программа оперирует с массивом a[1] . . . a[n], в котором
хранится перестановка чисел 1 . . . n. Рекурсивная процедура generate
в такой ситуации печатает все перестановки, которые на первых t позициях совпадают с перестановкой a; по выходе из неё переменные t
и a имеют те же значения, что и до входа. Основная программа такова:

for i:=1 to n do begin a[i]:=i; end;
t:=0;
generate;

Вот описание процедуры:
procedure generate;
var i,j : integer;
begin
if t = n then begin
for i:=1 to n do begin
write(a[i]);
end;
writeln;

7.3. Порождение комбинаторных объектов, перебор
end else begin {t < n}
for j:=t+1 to n do begin
поменять местами a[t+1] и a[j]
t:=t+1;
generate;
t:=t-1;
поменять местами a[t+1] и a[j]
end;
end;
end;

125



7.3.3. Напечатайте все последовательности из n нулей и единиц, содержащие ровно k единиц (по одному разу каждую).

7.3.4. Напечатайте все возрастающие последовательности длины k,
элементами которых являются натуральные числа от 1 до n. (Предполагается, что k 6 n, иначе таких последовательностей не существует.)
Решение. Программа оперирует с массивом a[1] . . . a[k] и целой переменной t. Предполагая, что a[1] . . . a[t] | возрастающая последовательность натуральных чисел из отрезка 1 . . . n, рекурсивно определённая процедура generate печатает все её возрастающие продолжения
длины k. (При этом t и a[1] . . . a[t] в конце такие же, как в начале.)

procedure generate;
var i: integer;
begin
if t = k then begin
печатать a[1]..a[k]
end else begin
t:=t+1;
for i:=a[t-1]+1 to t-k+n do begin
a[t]:=i;
generate;
end;
t:=t-1;
end;
end;
Замечание. Цикл for мог бы иметь верхней границей n (вместо
t − k + n). Наш вариант экономит часть работы, учитывая тот факт,
что предпоследний ((k-1)-й) член не может превосходить n-1, (k-2)-й
член не может превосходить n-2 и т. п.

126

7. Рекурсия

Основная программа теперь выглядит так:
t:=1;
for j:=1 to 1-k+n do begin
a[1]:=j;
generate;
end;

Можно было бы добавить к массиву a слева фиктивный элемент a[0] =
= 0, положить t = 0 и ограничиться единственным вызовом процедуры
generate.

7.3.5. Перечислите все представления положительного целого числа n в виде суммы последовательности невозрастающих целых положительных слагаемых.
Решение. Программа оперирует с массивом a[1..n] (максимальное
число слагаемых равно n) и с целой переменной t. Предполагая, что
a[1] . . . a[t] | невозрастающая последовательность целых чисел, сумма которых не превосходит n, процедура generate печатает все представления требуемого вида, продолжающие эту последовательность.
Для экономии вычислений сумма a[1] + . . . + a[t] хранится в специальной переменной s.
procedure generate;
var i: integer;
begin
if s = n then begin
печатать последовательность a[1]..a[t]
end else begin
for i:=1 to min(a[t], n-s) do begin
t:=t+1;
a[t]:=i;
s:=s+i;
generate;
s:=s-i;
t:=t-1;
end;
end;
end;

Основная программа при этом может быть такой:
t:=1;
for j:=1 to n do begin
a[1]:=j

7.4. Другие применения рекурсии

127

s:=j;
generate;
end;
Замечание. Можно немного сэкономить, вынеся операции увеличения и уменьшения t из цикла, а также не возвращая s каждый раз
к исходному значению (увеличивая его на 1 и возвращая к исходному
значению в конце). Кроме того, добавив фиктивный элемент a[0] = n,
можно упростить основную программу:

t:=0; s:=0; a[0]:=n; generate;



7.3.6. Напишите рекурсивную программу обхода дерева (используя
те же команды и проверки, что и в главе 3 (Обход дерева).
Решение. Процедура обработать над обрабатывает все листья над
текущей вершиной и заканчивает работу в той же вершине, что и начала. Вот её рекурсивное описание:

procedure обработать_над;
begin
if есть_сверху then begin
вверх_налево;
обработать_над;
while есть_справа do begin
вправо;
обработать_над;
end;
вниз;
end else begin
обработать;
end;
end;



7.4. Другие применения рекурсии
Топологическая сортировка. Представим себе 𝑛 чиновников, каждый из которых выдаёт справки определённого вида. Мы хотим получить все эти справки, соблюдая установленные ограничения: у каждого
чиновника есть список справок, которые нужно собрать перед обращением к нему. Дело безнадёжно, если схема зависимостей имеет цикл
(справку 𝐴 нельзя получить без 𝐵 , 𝐵 без 𝐶 ,. . . , 𝑌 без 𝑍 и 𝑍 без 𝐴).

128

7. Рекурсия

Предполагая, что такого цикла нет, требуется составить план, указывающий один из возможных порядков получения справок.
Изображая чиновников точками, а зависимости | стрелками, приходим к такой формулировке. Имеется 𝑛 точек, пронумерованных от 1
до 𝑛. Из каждой точки ведёт несколько (возможно, 0) стрелок в другие
точки. (Такая картинка называется ориентированным графом.) Циклов
нет. Требуется расположить вершины графа (точки) в таком порядке,
чтобы конец любой стрелки предшествовал её началу. Эта задача называется топологической сортировкой.
7.4.1. Докажите, что это всегда возможно.
Решение. Из условия отсутствия циклов вытекает, что есть вершина, из которой вообще не выходит стрелок (иначе можно двигаться по
стрелкам, пока не зациклимся). Её будем считать первой. Выкидывая
эту вершину и все соседние стрелки, мы сводим задачу к графу с меньшим числом вершин и продолжаем рассуждение по индукции.

7.4.2. Предположим, что ориентированный граф без циклов хранится в такой форме: для каждого i от 1 до n в num[i] хранится число выходящих из i стрелок, в adr[i][1], . . . , adr[i][num[i]] | номера вершин, куда эти стрелки ведут. Составьте (рекурсивный) алгоритм, который производит топологическую сортировку не более чем за 𝐶 · (n + m)
действий, где m | число рёбер графа (стрелок).
Замечание. Непосредственная реализация приведённого выше доказательства существования не даёт требуемой оценки; её приходится
немного подправить.
Решение. Наша программа будет печатать номера вершин. В массиве
printed: array[1..n] of boolean

мы будем хранить сведения о том, какие вершины напечатаны (и корректировать их одновременно с печатью вершины). Будем говорить,
что напечатанная последовательность вершин корректна, если никакая вершина не напечатана дважды и для любого номера i, входящего
в эту последовательность, все вершины, в которые ведут стрелки из i,
напечатаны, и притом до i.
procedure add (i: 1..n);
{дано: напечатанное корректно;}
{надо: напечатанное корректно и включает вершину i}

7.4. Другие применения рекурсии

129

begin
if printed [i] then begin {вершина i уже напечатана}
{ничего делать не надо}
end else begin
{напечатанное корректно}
for j:=1 to num[i] do begin
add(adr[i][j]);
end;
{напечатанное корректно, все вершины, в которые из
i ведут стрелки, уже напечатаны - так что можно
печатать i, не нарушая корректности}
if not printed[i] then begin
writeln(i); printed [i]:= TRUE;
end;
end;
end;

Основная программа:
for i:=1 to n do begin
printed[i]:= FALSE;
end;
for i:=1 to n do begin
add(i)
end;

К оценке времени работы мы вскоре вернёмся.
7.4.3. В приведённой программе можно выбросить проверку, заменив
if not printed[i] then begin
writeln(i); printed [i]:= TRUE;
end;

на
writeln(i); printed [i]:= TRUE;

Почему? Как изменится спецификация процедуры?
Решение. Спецификацию можно выбрать такой:
дано: напечатанное корректно
надо: напечатанное корректно и включает вершину i;
все вновь напечатанные вершины доступны из i.



130

7. Рекурсия

Где использован тот факт, что граф не имеет циклов?
Решение. Мы опустили доказательство конечности глубины рекурсии. Для каждой вершины рассмотрим её «глубину» | максимальную
длину пути по стрелкам, из неё выходящего. Условие отсутствия циклов
гарантирует, что эта величина конечна. Из вершины нулевой глубины
стрелок не выходит. Глубина конца стрелки по крайней мере на 1 меньше, чем глубина начала. При работе процедуры add(i) все рекурсивные
вызовы add(j) относятся к вершинам меньшейглубины.

Вернёмся к оценке времени работы. Сколько вызовов add(i) возможно для какого-то фиксированного i? Прежде всего ясно, что первый из них печатает i, остальные сведутся к проверке того, что i уже
напечатано. Ясно также, что вызовы add(i) индуцируются «печатающими» (первыми) вызовами add(j) для тех j, из которых в i ведёт
ребро. Следовательно, число вызовов add(i) равно числу входящих в i
рёбер (стрелок). При этом все вызовы, кроме первого, требуют 𝑂(1)
операций, а первый требует времени, пропорционального числу исходящих из i стрелок. (Не считая времени, уходящего на выполнение add(j)
для концов j выходящих рёбер.) Отсюда видно, что общее время пропорционально числу рёбер (плюс число вершин).

Связная компонента графа. Неориентированный граф | набор точек (вершин), некоторые из которых соединены линиями (рёбрами).
Неориентированный граф можно считать частным случаем ориентированного графа, в котором для каждой стрелки есть обратная.
Связной компонентой вершины i называется множество всех тех
вершин, в которые можно попасть из i, идя по рёбрам графа. (Поскольку граф неориентированный, отношение «j принадлежит связной
компоненте i» является отношением эквивалентности.)
7.4.5. Дан неориентированный граф (для каждой вершины указано
число соседей и массив номеров соседей, как в задаче о топологической
сортировке). Составьте алгоритм, который по заданному i печатает
все вершины связной компоненты i по одному разу (и только их). Число действий не должно превосходить 𝐶 · (общее число вершин и рёбер
в связной компоненте).
Решение. Программа в процессе работы будет «закрашивать» некоторые вершины графа. Незакрашенной частью графа будем называть
то, что останется, если выбросить все закрашенные вершины и ведущие в них рёбра. Процедура add(i) закрашивает связную компоненту i
в незакрашенной части графа (и не делает ничего, если вершина i уже
закрашена).
7.4.4.

7.4. Другие применения рекурсии

131

procedure add (i:1..n);
begin
if вершина i закрашена then begin
ничего делать не надо
end else begin
закрасить i (напечатать и пометить как закрашенную)
для всех j, соседних с i
add(j);
end;
end;
end;

Докажем, что эта процедура действует правильно (в предположении,
что рекурсивные вызовы работают правильно). В самом деле, ничего, кроме связной компоненты незакрашенного графа, она закрасить
не может. Проверим, что вся она будет закрашена. Пусть k | вершина, доступная из вершины i по пути i → j → . . . → k, проходящему
только по незакрашенным вершинам. Будем рассматривать только пути, не возвращающиеся снова в i. Из всех таких путей выберем путь
с наименьшим j (в порядке просмотра соседей в процедуре). Тогда при
рассмотрении предыдущих соседей ни одна из вершин пути j → . . . → k
не будет закрашена (иначе j не было бы минимальным) и потому k окажется в связной компоненте незакрашенного графа к моменту вызова
add(j). Что и требовалось.
Чтобы установить конечность глубины рекурсии, заметим, что на
каждом уровне рекурсии число незакрашенных вершин уменьшается
хотя бы на 1.
Оценим число действий. Каждая вершина закрашивается не более
одного раза | при первым вызове add(i) с данным i. Все последующие вызовы происходят при закрашивании соседей | количество таких вызовов не больше числа соседей | и сводятся к проверке того,
что вершина i уже закрашена. Первый же вызов состоит в просмотре всех соседей и рекурсивных вызовах add(j) для всех них. Таким
образом, общее число действий, связанных с вершиной i, не превосходит константы, умноженной на число её соседей. Отсюда и вытекает
требуемая оценка.

7.4.6.
Решите ту же задачу для ориентированного графа (напечатать все вершины, доступные из данной по стрелкам; граф может
содержать циклы).
Ответ. Годится по существу та же программа (строку «для всех
соседей» надо заменить на «для всех вершин, куда ведут стрелки»). 

132

7. Рекурсия

Следующий вариант задачи о связной компоненте имеет скорее теоретическое значение (и называется теоремой Сэвича ).
7.4.7. Ориентированный граф имеет 2 вершин (двоичные слова
длины 𝑛) и задан в виде функции есть ребро, которая по двум вершинам 𝑥 и 𝑦 сообщает, есть ли в графе ребро из 𝑥 в 𝑦. Составьте алгоритм,
который для данной пары вершин 𝑢 и 𝑣 определяет, есть ли путь (по
рёбрам) из 𝑢 в 𝑣, используя память, ограниченную многочленом от 𝑛.
(Время при этом может быть | и будет | очень большим.)
[Указание. Используйте рекурсивную процедуру, выясняющую, существует ли путь из 𝑥 в 𝑦 длины не более 2 (и вызывающую себя
с уменьшенным на единицу значением 𝑘).]

𝑛

𝑘

Быстрая сортировка Хоара. В заключение приведём рекурсивный
алгоритм сортировки массива, который на практике является одним
из самых быстрых. Пусть дан массив a[1] . . . a[n]. Рекурсивная процедура sort(l,r:integer) сортирует участок массива с индексами из
полуинтервала (l, r], то есть a[l+1] . . . a[r], не затрагивая остального
массива.

procedure sort (l,r: integer);
begin
if l = r then begin
ничего делать не надо - участок пуст
end else begin
выбрать случайное число s в полуинтервале (l,r]
b := a[s]
переставить элементы сортируемого участка так, чтобы
сначала шли элементы, меньшие b - участок (l,ll]
затем элементы, равные b
- участок (ll,rr]
затем элементы, большие b
- участок (rr,r]
sort (l,ll);
sort (rr,r);
end;
end;

Разделение элементов сортируемого участка на три категории (меньшие, равные, больше) рассматривалась в главе 1, с. 36 (это можно сделать за время, пропорциональное длине участка). Конечность глубины
рекурсии гарантируется тем, что длина сортируемого участка на каждом уровне рекурсии уменьшается хотя бы на 1.
7.4.8. (Для знакомых с основами теории вероятностей). Докажите,
что математическое ожидание числа операций при работе этого ал-

133

7.4. Другие применения рекурсии

горитма не превосходит 𝐶𝑛 log 𝑛, причём константа 𝐶 не зависит от
сортируемого массива.
[Указание. Пусть 𝑇 (𝑛) | максимум математического ожидания числа операций для всех входов длины 𝑛. Из текста процедуры вытекает
такое неравенство:

𝑇 (𝑛) 6 𝐶𝑛 +

1
𝑛

∑︁

(︀

𝑇 (𝑘) + 𝑇 (𝑙)

)︀

𝑘 +𝑙=𝑛−1

Первый член соответствует распределению элементов на меньшие, равные и большие. Второй член | это среднее математическое ожидание
для всех вариантов случайного выбора. (Строго говоря, поскольку среди элементов могут быть равные, в правой части вместо 𝑇 (𝑘) и 𝑇 (𝑙)
должны стоять максимумы 𝑇 (𝑥) по всем 𝑥, не превосходящим 𝑘 или 𝑙, но
это не мешает дальнейшим рассуждениям.) Далее индукцией по 𝑛 нужно доказывать оценку 𝑇 (𝑛) 6 𝐶 ′ 𝑛 ln 𝑛. При этом для вычисления
∫︀ среднего значения 𝑥 ln∫︀ 𝑥 по всем 𝑥 = 1, . . . , 𝑛 − 1 нужно вычислять 1 𝑥 ln 𝑥 𝑑𝑥
по частям как ln 𝑥 𝑑(𝑥2 ). При достаточно большом
𝐶 ′ член 𝐶𝑛 в пра∫︀ 2
вой части перевешивается за счёт интеграла 𝑥 𝑑 ln 𝑥, и индуктивный
шаг проходит.]

𝑛

7.4.9. Имеется массив из 𝑛 различных целых чисел и число 𝑘 . Требуется найти 𝑘-е по величине число в этом массиве, сделав не более 𝐶𝑛 действий, где 𝐶 | некоторая константа, не зависящая от 𝑘 и 𝑛.
Замечание. Сортировка позволяет очевидным образом сделать это
за 𝐶𝑛 log 𝑛 действий. Очевидный способ: найти наименьший элемент,
затем найти второй, затем третий, . . . , 𝑘-й требует порядка 𝑘𝑛 действий, то есть не годится (константа при 𝑛 зависит от 𝑘).
[Указание. Изящный (хотя практически и бесполезный | константы
слишком велики) способ сделать это таков:
А. Разобьём наш массив на 𝑛/5 групп, в каждой из которых по 5 элементов. Каждую группу упорядочим.
Б. Рассмотрим средние элементы всех групп и перепишем их в массив из 𝑛/5 элементов. С помощью рекурсивного вызова найдём средний
по величине элемент этого массива.
В. Сравним этот элемент со всеми элементами исходного массива:
они разделятся на большие его и меньшие его (и один равный ему).
Подсчитав количество тех и других, мы узнаем, в какой из этих частей
должен находится искомый (𝑘-й) элемент и каков он там по порядку.
Г. Применим рекурсивно наш алгоритм к выбранной части.

134

7. Рекурсия

Пусть 𝑇 (𝑛) | максимально возможное число действий, если этот
способ применять к массивам из не более чем 𝑛 элементов (𝑘 может
быть каким угодно). Имеем оценку:

𝑇 (𝑛) 6 𝐶𝑛 + 𝑇 (𝑛/5) + 𝑇 (примерно 0,7𝑛).
Последнее слагаемое объясняется так: при разбиении на части каждая
часть содержит не менее 0,3𝑛 элементов. В самом деле, если 𝑥 | средний из средних, то примерно половина всех средних меньше 𝑥. А если
в пятёрке средний элемент меньше 𝑥, то ещё два заведомо меньше 𝑥.
Тем самым по крайней мере 3/5 от половины элементов меньше 𝑥.
Теперь по индукции можно доказать оценку 𝑇 (𝑛) 6 𝐶𝑛 (решающую
роль при этом играет то обстоятельство, что 1/5 + 0,7 < 1).]


8. КАК ОБОЙТИСЬ
БЕЗ РЕКУРСИИ
Для универсальных языков программирования (каковым является
паскаль) рекурсия не даёт ничего нового: для всякой рекурсивной программы можно написать эквивалентную программу без рекурсии. Мы
не будем доказывать этого, а продемонстрируем некоторые приёмы,
позволяющие избавиться от рекурсии в конкретных ситуациях.
Зачем это нужно? Ответ прагматика мог бы быть таким: во многих компьютерах (в том числе, к сожалению, и в современных, использующих так называемые RISC-процессоры), рекурсивные программы
в несколько раз медленнее соответствующих нерекурсивных программ.
Ещё один возможный ответ: в некоторых языках программирования рекурсивные программы запрещены. А главное, при удалении рекурсии
возникают изящные и поучительные конструкции.
8.1. Таблица значений (динамическое
программирование)
8.1.1. Следующая рекурсивная процедура вычисляет числа сочетаний (биномиальные коэффициенты). Напишите эквивалентную нерекурсивную программу.

function C(n,k: integer):integer;
{n >= 0; 0 ’, n);
end else begin
s:=6-m-n; {s - третий стержень: сумма номеров равна 6}
move (i-1, m, s);
writeln (’сделать ход ’, m, ’->’, n);
move (i-1, s, n);
end;
end;

Видно, что задача «переложить i верхних дисков с m-го стержня на n-й»
сводится к трём задачам того же типа: двум задачам с i-1 дисками

8.2. Стек отложенных заданий

141

и к одной задаче с единственным диском. Занимаясь этими задачами,
важно не позабыть, что ещё осталось сделать.
Для этой цели заведём стек отложенных заданий, элементами которого будут тройки ⟨i, m, n⟩. Каждая такая тройка интерпретируется
как заказ «переложить i верхних дисков с m-го стержня на n-й». Заказы упорядочены в соответствии с требуемым порядком их выполнения:
самый срочный | вершина стека. Получаем такую программу:
procedure move(i,m,n: integer);
begin
сделать стек заказов пустым
положить в стек тройку
{инвариант: осталось выполнить заказы в стеке}
while стек непуст do begin
удалить верхний элемент, переложив его в
if j = 1 then begin
writeln (’сделать ход’, p, ’->’, q);
end else begin
s:=6-p-q;
{s - третий стержень: сумма номеров равна 6}
положить в стек тройки , ,
end;
end;
end;

(Заметим, что первой в стек кладётся тройка, которую надо выполнять
последней.) Стек троек может быть реализован как три отдельных стека. (Кроме того, в паскале есть специальный тип, называемый «запись»
(record), который может быть применён.)

8.2.2. (Сообщил А. К. Звонкин со ссылкой на Анджея Лисовского.)
Для задачи о ханойских башнях есть и другие нерекурсивные алгоритмы. Вот один из них: простаивающим стержнем (не тем, с которого
переносят, и не тем, на который переносят) должны быть все стержни по очереди. Другое правило: поочерёдно перемещать наименьшее
кольцо и не наименьшее кольцо, причём наименьшее | по кругу. 
8.2.3. Используйте замену рекурсии стеком отложенных заданий
в рекурсивной программе печати десятичной записи целого числа.
Решение. Цифры добываются с конца и закладываются в стек, а затем печатаются в обратном порядке.

8.2.4. Напишите нерекурсивную программу, печатающую все вершины двоичного дерева.

142

8. Как обойтись без рекурсии

Решение. В этом случае стек отложенных заданий будет содержать
заказы двух сортов: «напечатать данную вершину» и «напечатать все
вершины поддерева с данным корнем» (при этом nil считается корнем
пустого дерева). Таким образом, элемент стека есть пара: ⟨тип заказа,
номер вершины⟩.
Вынимая элемент из стека, мы либо сразу исполняем его (если это
заказ первого типа), либо помещаем в стек три порождённых им заказа | в одном из шести возможных порядков.

8.2.5. Что изменится, если требуется не печатать вершины двоичного дерева, а подсчитать их количество?
Решение. Печатание вершины следует заменить прибавлением единицы к счётчику. Другими словами, инвариант таков: (общее число
вершин) = (счётчик) + (сумма чисел вершин в поддеревьях, корни которых лежат в стеке).

8.2.6. Для некоторых из шести возможных порядков возможны упрощения, делающие ненужным хранение в стеке элементов двух видов.
Укажите некоторые из них.
Решение. Если требуемый порядок таков:
корень, левое поддерево, правое поддерево,
то заказ на печатание корня можно не закладывать в стек, а выполнять
сразу.
Несколько более сложная конструкция применима для порядка
левое поддерево, корень, правое поддерево.
В этом случае все заказы в стеке, кроме самого первого (напечатать
поддерево) делятся на пары:
напечатать вершину x, напечатать «правое поддерево» x
(= поддерево с корнем в правом сыне x). Объединив эти пары в заказы специального вида и введя переменную для отдельного хранения
первого заказа, мы обойдёмся стеком однотипных заказов.
То же самое, разумеется, верно, если поменять местами левое и правое | получается ещё два порядка.

Замечание. Другую программу печати всех вершин дерева можно
построить на основе программы обхода дерева, разобранной в главе 3.
Там используется команда «вниз». Поскольку теперешнее представление дерева с помощью массивов l и r не позволяет найти предка заданной вершины, придётся хранить список всех вершин на пути от корня
к текущей вершине. См. также главу 9.

8.3. Более сложные случаи рекурсии

143

8.2.7. Напишите нерекурсивный вариант программы быстрой сортировки (см. с. 132). Как обойтись стеком, глубина которого ограничена 𝐶 log 𝑛, где 𝑛 | число сортируемых элементов?
Решение. В стек кладутся пары ⟨𝑖, 𝑗 ⟩, интерпретируемые как отложенные задания на сортировку соответствующих участков массива.
Все эти заказы не пересекаются, поэтому размер стека не может превысить 𝑛. Чтобы ограничиться стеком логарифмической глубины, будем
придерживаться такого правила: глубже в стек помещать больший из
возникающих двух заказов. Пусть 𝑓 (𝑛) | максимальная глубина стека, которая может встретиться при сортировке массива из не более
чем 𝑛 элементов таким способом. Оценим 𝑓 (𝑛) сверху таким способом:
после разбиения массива на два участка мы сначала сортируем более
короткий (храня в стеке более длинный про запас), при этом глубина
стека не больше 𝑓 (𝑛/2) + 1, затем сортируем более длинный, так что

𝑓 (𝑛) 6 max(𝑓 (𝑛/2) + 1, 𝑓 (𝑛 − 1)),
откуда очевидной индукцией получаем 𝑓 (𝑛) = 𝑂(log 𝑛).



8.3. Более сложные случаи рекурсии

Пусть функция 𝑓 с натуральными аргументами и значениями определена рекурсивно условиями

𝑓 (0) = 𝑎,
𝑓 (𝑥) = ℎ(𝑥, 𝑓 (𝑙(𝑥))) (𝑥 > 0)
где 𝑎 | некоторое число, а ℎ и 𝑙 | известные функции. Другими словами, значение функции 𝑓 в точке 𝑥 выражается через значение 𝑓 в точке
𝑙(𝑥). При этом предполагается, что для любого 𝑥 в последовательности
𝑥, 𝑙(𝑥), 𝑙(𝑙(𝑥)), . . .
рано или поздно встретится 0.
Если дополнительно известно, что 𝑙(𝑥) < 𝑥 для всех 𝑥, то вычисление 𝑓 не представляет труда: вычисляем последовательно 𝑓 (0), 𝑓 (1),
𝑓 (2), . . .
8.3.1. Напишите нерекурсивную программу вычисления 𝑓 для общего случая.
Решение. Для вычисления 𝑓 (𝑥) вычисляем последовательность
𝑙(𝑥), 𝑙(𝑙(𝑥)), 𝑙(𝑙(𝑙(𝑥))), . . .

144

8. Как обойтись без рекурсии

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

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

𝑓 (0) = 𝑎,
𝑓 (𝑥) = ℎ(𝑥, 𝑓 (𝑙(𝑥)), 𝑓 (𝑟(𝑥))) (𝑥 > 0),
где 𝑎 | некоторое число, а 𝑙, 𝑟 и ℎ | известные функции. Предполагается, что если взять произвольное число и начать применять к нему
функции 𝑙 и 𝑟 в произвольном порядке, то рано или поздно получится 0.
8.3.2. Напишите нерекурсивную программу вычисления 𝑓 .
Решение. Можно было бы сначала построить дерево, у которого
в корне находится 𝑥, а в сыновьях вершины 𝑖 стоят 𝑙(𝑖) и 𝑟(𝑖) | если
только 𝑖 не равно нулю. Затем вычислять значения функции, идя от
листьев к корню. Однако есть и другой способ.
Обратной польской записью (или постфиксной записью ) выражения называют запись, где знак функции стоит после всех её аргументов,
а скобки не используются. Вот несколько примеров:

𝑓 (2)
𝑓 (𝑔(2))
𝑠(2, 𝑡(7))
𝑠(2, 𝑢(2, 𝑠(5, 3))

2
2
2
2

𝑓
𝑔 𝑓
7 𝑡 𝑠
2 5 3 𝑠 𝑢 𝑠

Постфиксная запись выражения позволяет удобно вычислять его с помощью стекового калькулятора. Этот калькулятор имеет стек, который мы будем представлять себе расположенным горизонтально (числа
вынимаются и кладутся справа), и клавиши | числовые и функциональные. При нажатии на клавишу с числом это число кладётся в стек.
При нажатии на функциональную клавишу соответствующая функция
применяется к нескольким аргументам у вершины стека. Например,
если в стеке были числа
23456
и нажата функциональная клавиша 𝑠, соответствующая функции от
двух аргументов, то в стеке окажутся числа
2 3 4 𝑠(5, 6).

8.3. Более сложные случаи рекурсии

145

Перейдём теперь к нашей задаче. В процессе вычисления значения
функции 𝑓 мы будем работать со стеком чисел, а также с последовательностью чисел и символов f, l, r, h, которую мы будем интерпретировать как последовательность нажатий клавиш на стековом калькуляторе. Инвариант такой:
если стек чисел представляет собой текущее состояние стекового калькулятора, то после нажатия всех клавиш последовательности в стеке останется единственное число, и оно
будет искомым ответом.
Пусть нам требуется вычислить значение 𝑓 (𝑥). Тогда вначале мы помещаем в стек число 𝑥, а последовательность содержит единственный
символ f. (При этом инвариант соблюдается.) Далее с последовательностью и стеком выполняются такие преобразования:
старый
стек
𝑋
𝑋𝑥
𝑋𝑥
𝑋𝑥𝑦𝑧
𝑋0
𝑋𝑥

старая
последовательность
𝑥𝑃
l𝑃
r𝑃
h𝑃
f𝑃
f𝑃

новый
стек
𝑋𝑥
𝑋 𝑙(𝑥)
𝑋 𝑟(𝑥)
𝑋 ℎ(𝑥, 𝑦, 𝑧 )
𝑋𝑎
𝑋

новая
последовательность
𝑃
𝑃
𝑃
𝑃
𝑃
𝑥𝑥lf𝑥rfh𝑃

Здесь 𝑥, 𝑦, 𝑧 | числа, 𝑋 | последовательность чисел, 𝑃 | последовательность чисел и символов f, l, r, h. В последней строке предполагается, что 𝑥 ̸= 0. Эта строка соответствует равенству

𝑓 (𝑥) = ℎ(𝑥, 𝑓 (𝑙(𝑥)), 𝑓 (𝑟(𝑥))).
Преобразования выполняются, пока последовательность не станет пуста. В этот момент в стеке окажется единственное число, которое и будет ответом.

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

9. РАЗНЫЕ АЛГОРИТМЫ
НА ГРАФАХ
9.1. Кратчайшие пути

В этом разделе рассматриваются различные варианты одной задач.
Пусть имеется n городов, пронумерованных числами от 1 до n. Для каждой пары городов с номерами i, j в таблице a[i][j] хранится целое
число | цена прямого авиабилета из города i в город j. Считается, что
рейсы существуют между любыми городами, a[i][i] = 0 при всех i,
a[i][j] может отличаться от a[j][i]. Наименьшей стоимостью проезда из i в j считается минимально возможная сумма цен билетов для
маршрутов (в том числе с пересадками), ведущих из i в j. (Она не
превосходит a[i][j], но может быть меньше.)
В предлагаемых ниже задачах требуется найти наименьшую стоимость проезда для некоторых пар городов при тех или иных ограничениях на массив a и на время работы алгоритма.
9.1.1. Предположим, что не существует замкнутых маршрутов, для
которых сумма цен отрицательна. Докажите, что в этом случае маршрут с наименьшей стоимостью существует.
Решение. Маршрут длиной больше n всегда содержит цикл, поэтому минимум можно искать среди маршрутов длиной не более n, а их
конечное число.


Во всех следующих задачах предполагается, что это условие (отсутствие циклов с отрицательной суммой) выполнено.
9.1.2. Найдите наименьшую стоимость проезда из 1-го города во
все остальные за время 𝑂(n3 ).
Решение. Обозначим через МинСт(1,s,k) наименьшую стоимость
проезда из 1 в s менее чем с k пересадками. Тогда выполняется та-

9.1. Кратчайшие пути

147

кое соотношение:
(︁
)︁
МинСт(1, s, k+1) = min МинСт(1,s,k), min МинСт(1,i,k) + a[i][s])
i=1..n

Как отмечалось выше, искомым ответом является МинСт(1,i,n) для
всех i = 1 . . . n.
k:= 1;
for i := 1 to n do begin x[i] := a[1][i]; end;
{инвариант: x[i] = МинСт(1,i,k)}
while k n do begin
for s := 1 to n do begin
y[s] := x[s];
for i := 1 to n do begin
if y[s] > x[i]+a[i][s] then begin
y[s] := x[i]+a[i][s];
end;
end
{y[s] = МинСт(1,s,k+1)}
end;
for i := 1 to n do begin x[s] := y[s]; end;
k := k + 1;
end;



Приведённый алгоритм называют алгоритмом динамического программирования, или алгоритмом Форда { Беллмана.
9.1.3. Докажите, что программа останется правильной, если не заводить массива y, а производить изменения в самом массиве x (заменив
в программе все вхождения буквы y на x и затем удалить ставшие лишними строки).
Решение.

Инвариант будет таков:
МинСт(1,i,n) 6 x[i] 6 МинСт(1,i,k).



Этот алгоритм может быть улучшен в двух отношениях: можно за
то же время 𝑂(n3 ) найти наименьшую стоимость проезда i → j для
всех пар i, j (а не только при i = 1), а можно сократить время работы
до 𝑂(n2 ). Правда, в последнем случае нам потребуется, чтобы все цены
a[i][j] были неотрицательны.
9.1.4. Найдите наименьшую стоимость проезда i → j для всех i, j
за время 𝑂(n3 ).

148

9. Разные алгоритмы на графах

Решение. Для k = 0 . . . n через A(i,j,k) обозначим наименьшую стоимость маршрута из i в j, если в качестве пересадочных разрешено
использовать только пункты с номерами не больше k. Тогда

A(i,j,0) = a[i][j],
(︀
)︀
A(i,j,k+1) = min A(i,j,k), A(i,k+1,k) + A(k+1,j,k)

(два варианта соответствуют неиспользованию и использованию пункта k+1 в качестве пересадочного; отметим, что в нём незачем бывать
более одного раза).

Этот алгоритм называют алгоритмом Флойда.
9.1.5. Как проверить за 𝑂 (n3 ) действий, имеет ли граф с n вершинами циклы с отрицательной суммой?
[Указание. Можно применять алгоритм Флойда, причём разрешать
i = j в A(i,j,k), пока не появится первый отрицательный цикл.]

9.1.6. Имеется 𝑛 валют и таблица обменных курсов (сколько флоринов дают за талер и т.п.). Коммерсант хочет неограниченно обогатиться, обменивая свой начальный капитал туда-сюда по этим курсам.
Как проверить, возможно ли это?
[Указание. После логарифмирования деньги уподобляются расстояниям.]

9.1.7. Известно, что все цены неотрицательны. Найдите наименьшую стоимость проезда 1 → i для всех i = 1 . . . n за время 𝑂(n2 ).
Решение. В процессе работы алгоритма некоторые города будут
выделенными (в начале | только город 1, в конце | все). При этом:



для каждого выделенного города i хранится наименьшая стоимость пути 1 → i; при этом известно, что минимум достигается
на пути, проходящем только через выделенные города;



для каждого невыделенного города i хранится наименьшая стоимость пути 1 → i, в котором в качестве промежуточных используются только выделенные города.

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

9.1. Кратчайшие пути

149

путь. Рассмотрим первый невыделенный город на этом пути | уже до
него путь длиннее! (Здесь существенна неотрицательность цен.)
Добавив выбранный город к выделенным, мы должны скорректировать информацию, хранимую для невыделенных городов. При этом
достаточно учесть лишь пути, в которых новый город является последним пунктом пересадки, а это легко сделать, так как минимальную
стоимость проезда в новый город мы уже знаем.
При самом бесхитростном способе хранения множества выделенных
городов (в булевском векторе) добавление одного города к числу выделенных требует времени 𝑂(n).

Этот алгоритм называют алгоритмом Дейкстры.
9.1.8. Имеется 𝑛 городов, соединённых дорогами (с односторонним
движением). Для любых городов 𝑖, 𝑗 известен максимальный вес груза,
который можно везти из 𝑖 в 𝑗 (грузоподъёмность дороги). Найдите за
время 𝑂(𝑛2 ) для всех городов максимальный вес груза, который в них
можно привезти из столицы.
[Указание. Действуйте аналогично алгоритму Дейкстры, заменив
сумму на максимум.]

Отыскание кратчайшего пути имеет естественную интерпретацию
в терминах матриц. Пусть 𝐴 | матрица цен одной авиакомпании,
а 𝐵 | матрица цен другой. Пусть мы хотим лететь с одной пересадкой, причём сначала самолётом компании 𝐴, а затем | компании 𝐵 .
Сколько нам придётся заплатить, чтобы попасть из города i в город j?
9.1.9. Докажите, что эта матрица вычисляется по обычной формуле
для произведения матриц, только вместо суммы надо брать минимум,
а вместо умножения | сумму.

9.1.10. Докажите, что таким образом определённое произведение
матриц ассоциативно.

9.1.11. Докажите, что задача о кратчайших путях эквивалентна вычислению 𝐴∞ для матрицы цен 𝐴: в последовательности 𝐴, 𝐴2 , 𝐴3 , . . .
все элементы, начиная с некоторого, равны искомой матрице стоимостей кратчайших путей. (Если нет отрицательных циклов!)

9.1.12. Начиная с какого элемента можно гарантировать равенство
в предыдущей задаче?

Обычное (не модифицированное) умножение матриц тоже может
оказаться полезным, только матрицы должны быть другие. Пусть есть

150

9. Разные алгоритмы на графах

не все рейсы (как раньше), а только некоторые, a[i][j] равно 1, если
рейс есть, и 0, если рейса нет. Возведём матрицу a (обычным образом)
в степень k и посмотрим на её (i-j)-й элемент.
9.1.13. Чему он равен?
Ответ. Числу различных способов попасть из i в j за k рейсов
(с k-1 пересадками).

При описании кратчайших путей случай, когда есть не все рейсы,
можно свести к исходному, введя фиктивные рейсы с бесконечно большой (или достаточно большой) стоимостью. Тем не менее возникает
такой вопрос. Число реальных рейсов может быть существенно меньше 𝑛2 , поэтому интересны алгоритмы, которые работают эффективно
в такой ситуации. Исходные данные естественно представлять тогда
в такой форме: для каждого города известно число выходящих из него
рейсов, их пункты назначения и цены.
9.1.14. Докажите, что алгоритм Дейкстры можно модифицировать
так, чтобы для 𝑛 городов и 𝑚 рейсов (всего) он требовал не более
𝐶 (𝑛 + 𝑚) log 𝑛 операций.
[Указание. Что надо сделать на каждом шаге? Выбрать невыделенный город с минимальной стоимостью и скорректировать цены для всех
городов, в которые из него есть маршруты. Если бы кто-то сообщал
нам, для какого города стоимость минимальна, то хватило бы 𝐶 (𝑛 + 𝑚)
действий. А поддержание сведений о том, какой элемент в массиве минимален (см. задачу на с. 115) обходится ещё в множитель log 𝑛.]

Иногда нам нужны не все города. Пусть, скажем, в графе дорог
нужно найти кратчайший путь между двумя близкими посёлками |
тогда города и дороги в другой части страны нас явно не интересуют.
С точки зрения алгоритма, если нас интересует расстояние от i до j,
то алгоритм Дейкстры, вычисляющий расстояния от i до всех городов,
можно остановить, как только город j станет выделенным.
9.1.15. Покажите, что в этот момент выделенными могут быть только города, которые не дальше от i, чем j.
[Указание. Для невыделенного города с минимальной стоимостью
расстояние до него равно его стоимости, а для любого другого невыделенного города 𝑢 расстояние не меньше (сравним с расстоянием до
первого невыделенного города на кратчайшем пути в 𝑢).]
Если мы (как это часто делают на практике) храним отдельно выделенные города (как множество, см. главы 13 и 14), а также невыделенные города конечной стоимости (множество плюс приоритетная

9.1. Кратчайшие пути

151

очередь, с. 115), предыдущая задача ограничивает количество хранимых городов: это города, отстоящие от i не больше, чем город j, а
также все их соседи. Такой способ хранения полезен, если граф задан
неявно | скажем, если мы ищем кратчайший путь из одной позиции
какой-то головоломки в другую. (При реализации алгоритма Дейкстры
от приоритетной очереди, помимо взятия наименьшего элемента, понадобится операция увеличения приоритета элемента, но её тоже легко
реализовать при способе хранения, описанном в задаче 6.4.2.)
Предыдущие замечания особенно полезны в сочетании с методом
некоторая функция 𝜙 на вершинах графа,
Изменим длины всех рёбер графа, прибавив
в длине ребра из 𝑢 в 𝑣 величину 𝜙(𝑣) − 𝜙(𝑢).
потенциалов. Пусть есть
называемая потенциалом.

9.1.16. Докажите, что при таком изменении графа кратчайшие пути сохранятся (кратчайший путь из одной вершины в другую останется
кратчайшим), но их длины изменятся: к длине кратчайшего пути из 𝑝
в 𝑞 прибавится 𝜙(𝑞) − 𝜙(𝑝).

[Указание. К длине любого пути из 𝑝 в 𝑞 прибавится как раз та же
самая разность 𝜙(𝑞) − 𝜙(𝑝) (при сложении добавок к длинам рёбер все
промежуточные члены сократятся).]
Эта задача вместе с предыдущими используется в алгоритме, называемом 𝐴* -поиском.. Пусть нам надо найти кратчайшее расстояние
от i до j. Возьмём в качестве потенциала некоторую эвристическую
оценку расстояния до j. Тогда рёбра, идущие в сторону уменьшения
этой оценки, станут короче, и поэтому алгоритм будет рассматривать
их в первую очередь. Нужно только, чтобы длина всех рёбер оставалась неотрицательной, то есть чтобы длина ребра 𝑢𝑣 была не меньше
𝜙(𝑢) − 𝜙(𝑣).
9.1.17. Покажите, что если это свойство выполнено, то эвристическая оценка расстояния будет (гарантированно) нижней оценкой.

[Указание. Как она меняется вдоль кратчайшего пути?]
9.1.18. Пусть рёбра в графе | это дороги, а их длина измеряется вдоль дороги. Что тогда можно выбрать в качестве такой нижней
оценки?
Ответ.

Геометрическое расстояние (по поверхности Земли) до j.

152

9. Разные алгоритмы на графах

9.2. Связные компоненты, поиск в глубину
и ширину

Наиболее простой случай задачи о кратчайших путях | если все
цены равны 0 или +∞. Другими словами, мы интересуемся возможностью попасть из 𝑖 в 𝑗 , но за ценой не постоим. В других терминах: мы
имеем ориентированный граф (картинку из точек, некоторые из которых соединены стрелками) и нас интересуют вершины, доступные из
данной.
Для этого случая задачи о кратчайших путях приведённые в предыдущем разделе алгоритмы | не наилучшие. В самом деле, более быстрая рекурсивная программа решения этой задачи приведена в главе 7,
а нерекурсивная | в главе 6. Сейчас нас интересует такая задача: не
просто перечислить все вершины, доступные из данной, но перечислить
их в определённом порядке. Два популярных случая | поиск в ширину
и в глубину.
Поиск в ширину.

Надо перечислить все вершины ориентированного графа, доступные из данной, в порядке увеличения длины пути от неё. (Тем самым мы
решим задачу о кратчайших путях, когда цены рёбер равны 1 или +∞.)
9.2.1. Придумайте алгоритм решения этой задачи с числом действий не более 𝐶 · (число рёбер, выходящих из интересующих нас вершин).
Решение. Эта задача рассматривалась в главе 6, с. 114. Здесь мы
приведём подробное решение. Пусть num[i] | количество рёбер, выходящих из i, out[i][1], . . . , out[i][num[i]] | вершины, куда ведут
рёбра. Вот программа, приведённая ранее:

procedure Доступные (i: integer);
{напечатать все вершины, доступные из i, включая i}
var X: подмножество 1..n;
P: подмножество 1..n;
q, v, w: 1..n;
k: integer;
begin
...сделать X, P пустыми;
writeln (i);
...добавить i к X, P;
{(1) P = множество напечатанных вершин; P содержит i;
(2) напечатаны только доступные из i вершины;

9.2. Связные компоненты, поиск в глубину и ширину

153

(3) X - подмножество P;
(4) все напечатанные вершины, из которых выходит
ребро в ненапечатанную вершину, принадлежат X}
while X непусто do begin
...взять какой-нибудь элемент X в v;
for k := 1 to num [v] do begin
w := out [v][k];
if w не принадлежит P then begin
writeln (w);
добавить w в P;
добавить w в X;
end;
end;
end;
end;

Тогда нам было безразлично, какой именно элемент множества X выбирается. Если мы будем считать X очередью (первым пришёл | первым ушёл), то эта программа напечатает все вершины, доступные из i,
в порядке возрастания их расстояния от i (числа рёбер на кратчайшем
пути из i). Докажем это.
Обозначим через 𝑉 (𝑘) множество всех вершин, расстояние которых
от i (в описанном смысле) равно 𝑘. Имеет место такое соотношение:

𝑉 (𝑘 + 1) = (концы рёбер с началами в 𝑉 (𝑘)) ∖ (𝑉 (0) ∪ . . . ∪ 𝑉 (𝑘))
Докажем, что для любого 𝑘 = 0, 1, 2 . . . в ходе работы программы будет
такой момент (после очередной итерации цикла while), когда
в очереди стоят все элементы 𝑉 (𝑘) и только они;
напечатаны все элементы 𝑉 (0), . . . , 𝑉 (𝑘).
(Для 𝑘 = 0 это будет состояние перед циклом.) Рассуждая по индукции,
предположим, что в очереди скопились все элементы 𝑉 (𝑘). Они будут
просматриваться в цикле, пока не кончатся (поскольку новые элементы добавляются в конец, они не перемешаются со старыми). Концы
ведущих из них рёбер, если они уже не напечатаны, печатаются и ставятся в очередь | то есть всё как в записанном выше соотношении
для 𝑉 (𝑘 + 1). Так что когда все старые элементы кончатся, в очереди
будут стоять все элементы 𝑉 (𝑘 + 1).

Поиск в глубину.

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

154

9. Разные алгоритмы на графах

что все вершины доступны из выделенной по ориентированным путям.
Построим дерево, которое можно было бы назвать «универсальным накрытием» нашего графа. Его корнем будет выделенная вершина графа.
Из корня выходят те же стрелки, что и в графе | их концы будут сыновьями корня. Из них в дереве выходят те же стрелки, что и в графе
и так далее. Разница между графом и деревом в том, что пути в графе, ведущие в одну и ту же вершину, в дереве «расклеены». В других
терминах: вершина дерева | это путь в графе, выходящий из корня.
Её сыновья | это пути, продолженные на одно ребро. Заметим, что
дерево бесконечно, если в графе есть ориентированные циклы.
Имеется естественное отображение дерева в граф (вершин в вершины). При этом каждая вершина графа имеет столько прообразов,
сколько путей в неё ведёт. Поэтому обход дерева (посещение его вершин
в том или ином порядке) одновременно является и обходом графа |
только каждая вершина посещается многократно.
Будем предполагать, что для каждой вершины графа выходящие
из неё рёбра упорядочены (например, пронумерованы). Тем самым для
каждой вершины дерева выходящие из неё рёбра также упорядочены.
Будем обходить дерево так: сначала корень, а потом поддеревья (в порядке ведущих в них рёбер). Такой обход дерева рассматривался нами
в главе 7. Ему соответствует обход графа. Если выкинуть из этого обхода повторные посещения уже посещённых вершин, то получится то,
что называется «поиск в глубину».
Другими словами, на путях, выходящих из выделенной вершины,
введём порядок: путь предшествует своему продолжению; если два пути расходятся в некоторой вершине, то меньшим считается тот, который выходит из неё по меньшему ребру. Вершины теперь упорядочиваются в соответствии с минимальными путями, в них ведущими. Обход
вершин графа в указанном порядке называется поиском в глубину.
9.2.2. Напишите программу поиска в глубину.
[Указание. Возьмём программу обхода дерева (корень → левое поддерево → правое поддерево) из главы 7 или из главы 8 и используем
её применительно к обстоятельствам. Главное изменение: не надо посещать вершины повторно. Так что если мы попали в уже посещённую
вершину, то можно с ней ничего не делать. (Если путь не минимален
среди ведущих в данную вершину, то и все его продолжения не минимальны | их просматривать не надо).]

Замечание. Напомним, что в главе 8 упоминались две возможности
устранения рекурсии в программе обхода дерева (с. 142). Оба варианта
можно использовать для поиска в глубину.

9.2. Связные компоненты, поиск в глубину и ширину

155

Поиск в глубину лежит в основе многих алгоритмов на графах, порой в несколько модифицированном виде.
9.2.3. Неориентированный граф называется двудольным, если его
вершины можно раскрасить в два цвета так, что концы любого ребра |
разного цвета. Составьте алгоритм проверки, является ли заданный
граф двудольным, в котором число действий не превосходит 𝐶 · (число
рёбер + число вершин).
[Указание. (а) Каждую связную компоненту можно раскрашивать
отдельно. (б) Выбрав цвет одной вершины и обходя её связную компоненту, мы определяем единственно возможный цвет остальных.]

Замечание. В этой задаче безразлично, производить поиск в ширину
или в глубину.
9.2.4. Составьте нерекурсивный алгоритм топологической сортировки ориентированного графа без циклов. (Рекурсивный алгоритм
см. на с. 128.)
Решение. Предположим, что граф имеет вершины с номерами 1 . . . n,
для каждой вершины i известно число num[i] выходящих из неё рёбер
и номера вершин dest[i][1], . . . , dest[i][num[i]], в которые эти рёбра ведут. Будем условно считать, что рёбра перечислены «слева направо»: левее то ребро, у которого номер меньше. Нам надо напечатать все
вершины в таком порядке, чтобы конец любого ребра был напечатан
перед его началом. Мы предполагаем, что в графе нет ориентированных циклов | иначе такое невозможно.
Для начала добавим к графу вершину 0, из которой рёбра ведут
в вершины 1, . . . , n. Если её удастся напечатать с соблюдением правил,
то тем самым все вершины будут напечатаны.
Алгоритм хранит путь, выходящий из нулевой вершины и идущий
по рёбрам графа. Переменная l отводится для длины этого пути. Путь
образован вершинами vert[1] . . . vert[l] и рёбрами, имеющими номера edge[1] . . . edge[l]. Номер edge[s] относится к нумерации рёбер,
выходящих из вершины vert[s]. Тем самым для всех s должны выполняться неравенство
edge[s] 6 num[vert[s]]

и равенство
vert[s+1] = dest [vert[s]] [edge[s]].

Заметим, что конец последнего ребра нашего пути (то есть вершина
dest[vert[l]][edge[l]], не включается в массив vert. Кроме того,

156

9. Разные алгоритмы на графах

для последнего ребра мы делаем исключение, разрешая ему указывать
«в пустоту», т. е. разрешаем edge[l] равняться num[vert[l]]+1.
В процессе работы алгоритм будет печатать номера вершин, при
этом соблюдая требование «вершина напечатана только после тех вершин, в которые из неё ведут рёбра». Кроме того, будет выполняться
такое требование (И):
вершины пути, кроме последней (vert[1] . . . vert[l]) не напечатаны, но свернув с пути налево, мы немедленно упираемся в напечатанную вершину.
Вот что получается:
l:=1; vert[1]:=0; edge[1]:=1;
while not( (l=1) and (edge[1]=n+1)) do begin
if edge[l]=num[vert[l]]+1 then begin
{путь кончается в пустоте, поэтому все вершины,
следующие за vert[l], напечатаны - можно
печатать vert[l]}
writeln (vert[l]);
l:=l-1; edge[l]:=edge[l]+1;
end else begin
{edge[l] 2. Таким образом, в этой сети требуется 𝑓 [𝑖, 𝑗 ] = 2, не больше
и не меньше. (Можно представить себе трубу, в которой по каким-то
технологическим причинам нельзя останавливать или даже уменьшать
перекачку ниже максимального уровня.)

9.3.4. Как отразить в наших обозначениях одностороннюю трубу,
по которой можно перекачивать из 𝑖 в 𝑗 не более 2 единиц, а обратно
ничего перекачивать нельзя?
Ответ. 𝑐[𝑖, 𝑗 ] = 2, 𝑐[𝑗, 𝑖] = 0.

9.3.5. Как записать в терминах 𝑐[𝑖, 𝑗 ] условие «поток из 𝑖 в 𝑗 должен
быть не меньше 2 и не больше 3»?
Ответ. 𝑐[𝑖, 𝑗 ] = 3, 𝑐[𝑗, 𝑖] = −2.

9.3.6. Что можно сказать о потоке в сети, если 𝑐[𝑖, 𝑗 ] = 2, 𝑐[𝑗, 𝑖] = −3?
Ответ. Такого потока не существует.

Если отрицательные значения 𝑐[𝑖, 𝑗 ] вас пугают, то можно договориться рассматривать только сети, где 𝑐[𝑖, 𝑗 ] > 0 при всех 𝑖, 𝑗 . В такой
сети всегда есть поток (нулевой | все требования выполнены). Так
мы и считаем в дальнейшем, если обратное не оговорено явно. Заметьте, что среди 𝑓 [𝑖, 𝑗 ] отрицательные числа всё равно будут (если поток
ненулевой) | в силу кососимметричности.
В следующей задаче требуется перевести очевидный факт на математический язык и доказать его.
9.3.7. Покажите, что для любого потока выполнено равенство

∑︁
𝑖

𝑓 [𝑠, 𝑖] =

∑︁

𝑓 [𝑗, 𝑡].

𝑗

Что означает это равенство на человеческом языке?
Решение. Левая часть | это суммарный поток из истока (в принципе туда может что-то и втекать, тогда в этой сумме есть отрицательные члены), а правая часть | суммарный поток в сток. Равенство, таким образом, можно назвать «законом сохранения материи»; оно верно,
если в сети нет утечек и дополнительных источников (одно из наших
условий).
Общее значение левой и правой части называют суммарной величиной потока 𝑓 .
Как это (очевидное)
утверждение доказать формально? Вот как:
∑︀
рассмотрим сумму
=1 𝑓 [𝑖, 𝑗 ]. Эта сумма равна нулю, так как члены
𝑛

𝑖,𝑗

162

9. Разные алгоритмы на графах

𝑓 [𝑖, 𝑗 ] и 𝑓 [𝑗, 𝑖] сокращаются, а все 𝑓 [𝑖, 𝑖] равны нулю. С другой стороны,
её можно переписать как
𝑛
𝑛
∑︁
∑︁

𝑓 [𝑖, 𝑗 ].

𝑖=1 𝑗 =1

По определению потока внутренняя сумма равна нулю, кроме случаев
𝑖 = 𝑠 и 𝑖 = 𝑡, так что от всей суммы (равной нулю, напомним) остаётся
0=

𝑛
∑︁

𝑓 [𝑠, 𝑗 ] +

𝑗 =1

𝑛
∑︁

𝑓 [𝑡, 𝑗 ].

𝑗 =1

Поскольку 𝑓 [𝑡, 𝑗 ] = −𝑓 [𝑗, 𝑡], изменяем знаки во втором слагаемом и получаем искомое равенство.

Теперь мы должны перевести на тот же математический язык утверждение о разрезах. Будем называть разрезом разбиение всех чисел
от 1 до 𝑛 (узлов сети) на два непересекающихся класса 𝑆 и 𝑇 , в котором
исток 𝑠 попадает в 𝑆 , а сток 𝑡 попадает в 𝑇 . Будем обозначать такой
разрез (𝑆, 𝑇 ). Пусть, помимо этого, у нас есть поток 𝑓 . Тогда можно
посчитать поток через разрез, то есть сумму
∑︁

𝑓 [𝑢, 𝑣],

𝑢∈𝑆,𝑣 ∈𝑇

которую мы будем обозначать через 𝑓 (𝑆, 𝑇 ).
Подсчёт потока через разрез | это именно то, что делает таможенная статистика с потоком товаров через границу. И если товары
производятся в одной стране, а потребляются в другой (и только), то
эта сумма равнапроизводству в истоке и потреблению в стоке. Следующая задача предлагает доказать это формально.
9.3.8. Пусть (𝑆, 𝑇 ) | произвольный разрез сети, а 𝑓 | поток в этой
сети. Докажите, что равенство предыдущей задачи можно дополнить:
∑︁

𝑓 [𝑠, 𝑖] = 𝑓 (𝑆, 𝑇 ) =

𝑖

∑︁

𝑓 [𝑗, 𝑡].

𝑗

Решение. Будем действовать по аналогии с предыдущей задачей:
рассмотрим сумму
∑︁
𝑓 [𝑖, 𝑗 ],
𝑖∈𝑆,𝑗 ∈{1...,𝑛}

9.3. Сети, потоки и разрезы

163

но ограничим суммирование только парами (𝑖, 𝑗 ), для которых 𝑖 ∈ 𝑆 .
Теперь уже сокращения не получится: хотя рёбра, у которых оба конца
в 𝑆 , появляются с плюсом и с минусом и по-прежнему сокращаются,
останутся рёбра, у которых 𝑖 ∈ 𝑆 , а 𝑗 ∈ 𝑇 , им сократиться не с кем,
обратного ребра в сумме нет. Так что от всей суммы останется 𝑓 (𝑆, 𝑇 ).
С другой стороны, если переписать сумму как
𝑛
∑︁ ∑︁

𝑓 [𝑖, 𝑗 ],

𝑖∈𝑆 𝑗 =1

то все внутренние суммы равны нулю при 𝑖 ̸= 𝑠, так что останется как
раз поток, выходящий из истока 𝑠.

9.3.9. Повторите рассуждение для потока, входящего в сток.

Заметим, что при подсчёте потока через разрез в сумме могут быть
слагаемые разных знаков. Даже если какой-то товар производится только в одной стране, и потребляется только в другой, всё равно потоки
через границу могут быть в обе стороны (представьте себе нефтепровод из трёх частей, который идёт зигзагом, сначала пересекая границу,
потом обратно, и потом снова туда). Естественно, что при подсчёте потока слагаемые разного знака полностью или частично сокращаются.
9.3.10. Как изменится поток через разрех, если мы поменяем 𝑆 и 𝑇
(а также исток 𝑠 и сток 𝑡) местами, не меняя массива 𝑓 ?
Ответ. Изменит знак.

Зная пропускные способности рёбер через разрез, можно ограничить суммарный поток через него:
9.3.11. Пусть дана сеть с пропускными способностями 𝑐[𝑖, 𝑗 ] и некоторый поток 𝑓 [𝑖, 𝑗 ] в ней. Пусть, с другой стороны, в этой сети есть
некоторый разрез (𝑆, 𝑇 ). Докажите, что суммарная величина потока не
превосходит пропускной способности разреза, определяемой как
∑︁

𝑐[𝑢, 𝑣].

𝑢∈𝑆,𝑣 ∈𝑇

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

Заметим, что в сумме, определяющей пропускную способность разреза, все слагаемые неотрицательны (напомним, что мы ограничиваемся случаем неотрицательных чисел 𝑐[𝑖, 𝑗 ]) | для потоков это было не
так.

164

9. Разные алгоритмы на графах

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

Фиксируем некоторую сеть, и будем рассматривать в ней разные
потоки (напомним, что мы считаем 𝑐[𝑖, 𝑗 ] неотрицательными, так что
нулевой поток всегда существует) и разные разрезы. У каждого потока
есть суммарная величина, а у каждого разреза | пропускная способность. Задача 9.3.11 утверждает, что все первые не превосходят всех
вторых | по очевидным причинам. Оказывается | и это утверждение
называют теоремой Форда { Фалкерсона, | что это неравенство обращается в равенство для некоторого потока (максимального потока )
и некоторого разреза (минимального разреза ). Другими словами, есть
число, которое является одновременно суммарной величиной некоторого потока и пропускной способностью некоторого разреза. Это число
ограничивает сверху любой поток (потому что любой поток ограничен сверху минимальным разрезом) и снизу любой разрез (потому что
любой разрез ограничен снизу максимальным потоком). Так что действительно названия «максимальный» и «минимальный» для потоков и
разрезов оправданы.
Мы докажем теорему Форда { Фалкерсона, но не сразу. Начнём с
более слабого утверждения.
9.3.13. Пусть имеется сеть 𝑐 и некоторый поток 𝑓 в ней. Тогда
выполнено одно из двух: либо в сети 𝑐 существует поток большей величины, либо в ней есть разрез, у которого пропускная способность
равна величине потока 𝑓 .

(Как мы видели, эти случаи исключают друг друга: если поток равен некоторому разрезу, то большего потока уже быть не может.)
Это утверждение очевидно следует из теоремы Форда { Фалкерсона:
сравнивая поток с максимальным (который существует по этой теореме), мы либо можем его увеличить, либо замечаем, что он уже равен
минимальному разрезу. Но обратное рассуждение так просто не проходит (см. обсуждение после решения задачи).
Решение. Основная идея состоит в рассмотрении остаточной сети
(residual network). Представим себе, что по трубе с пропускной способностью 3 в каком-то направлении идёт поток величины 2. В этом случае
при необходимости можно увеличить этот поток ещё на 1; как говорят,

9.3. Сети, потоки и разрезы

165

этой трубы (в том же направлении) равна 1. Более хитрый пример: пусть есть односторонняя труба
пропускной способности 2, и она полностью использована. Тогда возникает «виртуальная» труба1 в обратную сторону с той же пропускной
способностью 2: если мы захотим добавить поток в обратном направлении, нужно просто уменьшить исходный поток на соответствующую
величину. Формально говоря, мы рассматриваем остаточную сеть 𝑐′ ,
положив 𝑐′ [𝑖, 𝑗 ] = 𝑐[𝑖, 𝑗 ] − 𝑓 [𝑖, 𝑗 ]. Заметим, что по определению потока
все значения 𝑐′ [𝑖, 𝑗 ] неотрицательны (и нулевой поток в остаточной сети соответствует неизменённому потоку исходной).
9.3.14. Если в двусторонней трубе с пропускной способностью 3
сейчас поток 2 в одну сторону, то каковы пропускные способности
остаточной сети в ту и другую сторону?

Продолжим решение задачи 9.3.13. Для данной сети 𝑐 и потока 𝑓
в ней рассмотрим остаточную сеть. Построим ориентированный граф,
проведя рёбро из 𝑢 в 𝑣 в тех случаях, когда в остаточной сети ненулевая (положительная) пропускная способность 𝑐[𝑢, 𝑣]. Этот остаточный
граф отражает возможности дополнительных поставок. (Заметьте, что
в нём вполне могут быть одновременно рёбра из 𝑢 в 𝑣 и обратно.) Теперь есть два случая:
остаточная пропускная способность

∙ В остаточном графе есть путь из истока 𝑠 в сток 𝑡. Такой
путь называют дополняющим путём (augmenting path), посколь-

ку вдоль него можно пропустить дополнительный поток из 𝑠 в
𝑡. Почему? Посмотрим на положительные остаточные пропускные способности вдоль этого пути. Возьмём наименьшую из них,
пусть это какое-то 𝛿 > 0. Добавим к исходному потоку дополнительный поток величины 𝛿 , то есть увеличим исходный поток по
рёбрам дополняющего пути на 𝛿 , а по другим рёбрам оставим
как было. (Педантичный читатель или редактор справедливо укажет на неточность в последнем предложении: если в дополняющий
путь входит ребро из 𝑢 в 𝑣, то мы увеличиваем 𝑓 [𝑢, 𝑣] на 𝛿 и уменьшаем 𝑓 [𝑣, 𝑢] на 𝛿 в соответствии с нашим соглашением о знаках.)
Легко проверить, что это будет потоком (добавка 𝛿 как вошла,
так и выйдет), и суммарная величина потока увеличится на 𝛿 .

∙ В остаточном графе нет пути из 𝑠 в 𝑡. Другими словами, верши-

на 𝑡 не входит в число вершин, доступных в этом графе из 𝑠. Тогда

1 В газотранспортной отрасли это называют «виртуальным реверсом» и много
спорят о законности этого.

166

9. Разные алгоритмы на графах

понятно, где «узкое место», в котором надо провести разрез: пусть
𝑆 | вершины, доступные из 𝑠 в остаточном графе (включая сам
исток 𝑠), а 𝑇 | все остальные (включая сток 𝑡). Из 𝑆 в 𝑇 не
ведёт рёбер в остаточном графе (из доступной вершины нельзя
попасть в недоступную, иначе она была бы доступной). Значит,
для рёбер из 𝑆 в 𝑇 их пропускная способность 𝑐[𝑢, 𝑣] используется потоком 𝑓 [𝑢, 𝑣] полностью, и потому пропускная способность
разреза равна потоку 𝑓 (𝑆, 𝑇 ) через разрез.
Эти два случая и соответствуют двум вариантам из условия задачи. 
9.3.15. В решении есть небольшая неточность: нужно было брать
не просто путь в остаточном графе, а путь, не проходящий дважды по
одному ребру (например, кратчайший). Почему это важно?
Решение. Если путь дважды проходит по ребру, то поток по этому
ребру придётся увеличивать на 2𝛿 , а это может превзойти остаточную
пропускную способность.

9.3.16. Докажите, что во втором из рассмотренных случаев (когда
пропускная способность равна величине потока) поток по всем рёбрам
пересекает разрез в одну и ту же сторону, то есть 𝑓 [𝑢, 𝑣] > 0 для всех
𝑢 ∈ 𝑆, 𝑣 ∈ 𝑇 .
Решение. Неравенство между потоком и пропускной способностью
получается сложением неравенств 𝑓 [𝑢, 𝑣] 6 𝑐[𝑢, 𝑣]; оно обращается в равенство, лишь если все складываемые неравенства обращаются в равенства, и в этом случае 𝑓 [𝑢, 𝑣] = 𝑐[𝑢, 𝑣] > 0 по предположению.
Теперь попробуем доказать теорему Форда { Фалкерсона, то есть
построить поток, равный разрезу (точнее: поток, суммарная величина
которого равна пропускной способности некоторого разреза). Будем
рассуждать так. Начнём с произвольного потока (например, нулевого).
Мы только что доказали, что либо его можно увеличить, либо он уже
равен некоторому разрезу. Во втором случае всё хорошо. В первом увеличим поток, и снова применим то же рассуждение. Либо поток можно
ещё увеличить, либо есть равный ему разрез | если первое, увеличим
ещё и так далее, пока это не станет невозможным. Когда это случится,
мы получим максимальный поток, равный минимальному разрезу.
9.3.17. В чём ошибка в этом рассуждении?
Решение. Ниоткуда не следует, что этот процесс увеличения когдато остановится, так что слова «когда это случится» неоправданно оптимистичны.


9.3. Сети, потоки и разрезы

167

И это не просто теоретическая возможность, такое реально бывает, если никак не ограничивать выбор дополняющего пути. Начальный
пример Форда { Фалкерсона был довольно сложный, но потом были придуманы более простые (Цвик в статье 1995 года построил пример с
6 вершинами и 8 рёбрами и показал, что ни один из параметров нельзя
уменьшить, Theoretical Computer Science, 148(1), 165{170, 1995). Идею
таких примеров понять не так сложно. Представим себе сеть, в которой есть три ребра небольшой пропускной способности, а кроме этого
много рёбер большой пропускной способности, которые связывают все
остальные пары вершин.

𝑠

𝑡
Конечно, тогда эти рёбра малой пропускной способности не очень-то
и нужны, но мы хотим показать лишь, что при некотором способе выбирать дополняющий путь процесс продолжается бесконечно. Так что
мы имеем право выбирать дополняющий путь как хотим. А именно,
рассмотрим путь, в котором два ребра из трёх проходятся в прямом
направлении, а третье в обратном. Таких путей возьмём три (в зависимости от того, какое ребро проходится в обратном направлении), на
рисунке показан один из трёх.

𝑠

𝑡
Что происходит при дополнении потока по таким путям? Если до
этого рёбра были недогружены на 𝑎, 𝑏, 𝑐, то после этого в двух из них

168

9. Разные алгоритмы на графах

недогруз уменьшится, а в одном увеличится:
(𝑎, 𝑏, 𝑐)

(𝑎−𝛿, 𝑏−𝛿, 𝑐+𝛿 ) или (𝑎−𝛿, 𝑏+𝛿, 𝑐−𝛿 ) или (𝑎+𝛿, 𝑏−𝛿, 𝑐−𝛿 )

При этом 𝛿 выбирается максимально возможным, чтобы при дальнейшем увеличении недогруз уже стал бы отрицательным | то есть одно из чисел станет равным нулю. Следующий шаг (указанного вида)
определяется однозначно: надо уменьшать два положительных числа,
увеличивая нулевое, до тех пор, пока наименьшее из двух не обратится
в нуль, и так далее. Легко сообразить, что если после первого шага
недогруз будет (0, 𝛼, 𝛽 ), где 𝛼 и 𝛽 несоизмеримы (их отношение иррационально), то процесс будет продолжаться неограниченно.
9.3.18. Покажите, что если 𝛾 обратно золотому сечению, то есть
является (положительным) решением уравнения 𝛾 + 𝛾 2 = 1, то начав с
тройки (0, 1, 𝛾 ), мы получим тройки (𝛾, 𝛾 2 , 0), (𝛾 3 , 0, 𝛾 2 ) и так далее |
так что процесс будет продолжаться бесконечно.

В приведённом выше рассуждении пропущен один момент: почему
остальные рёбра (большой пропускной способности, как мы сказали)
никогда не насытятся? Но видно (особенно в последнем примере), что
увеличение общего потока ограничено, так что если пропускные способности остальных рёбер достаточно велики, то до их насыщения дело
никогда не дойдёт.
Построенный пример существенным образом использует иррациональные числа.
9.3.19. Покажите, что в сети с целыми (или хотя бы рациональными) пропускными способностями аналогичная ситуация невозможна: увеличение потока вдоль дополняющего пути, как его ни выбирать,
рано или поздно даст поток, в котором дополняющего пути не будет.
Из этого утверждения (и ранее доказанного) следует теорема Форда { Фалкерсона для частного случая графов с целыми пропускными
способностями.
Решение. Если все пропускные способности целые, и мы начинаем
с нулевого потока, то дробные числа не могут возникнуть (и остаточные пропускные способности, и минимум их вдоль дополняющего пути
будут целыми, и при вычитании тоже будут только целые числа. Значит, каждый раз поток увеличивается по крайней мере на 1, и рано
или поздно это кончится (поток ограничен любым разрезом). Для рациональных чисел можно перейти к другой единице измерения потока,
чтобы все числа стали целыми.


9.3. Сети, потоки и разрезы

169

В этом рассуждении число шагов может быть большим даже для
маленького графа. Это видно на совсем простом примере.
𝑝
𝑁
𝑁
𝑠
𝑡
1

𝑁

𝑞

𝑁

9.3.20. Покажите, что на графе на рисунке (где 𝑁 | большое целое число) при неудачном выборе дополняющих путей число итераций
будет не меньше 𝑁 .
Решение. Будем поочерёдно использовать 𝑠{𝑝{𝑞 {𝑡 и 𝑠{𝑞 {𝑝{𝑡 в качестве дополняющих путей, начав с нулевого потока. Тогда на первом шаге поток увеличится на 1, а на каждом следующем будет увеличиваться
на 2. Максимальный поток (который вообще не использует ребро 𝑝{𝑞)
равен 2𝑁 , так что до него надо сделать как минимум 𝑁 итераций.
(Видно, как неудачный выбор дополняющего пути приводит к долгой
работе в тривиальном примере.)

9.3.21. Рассмотрим сеть с целыми пропускными способностями. Докажите, что максимальный поток в ней всегда можно выбрать целочисленным (нецелые потоки могут давать тот же результат, но не могут
давать большего).
Решение. Описанный процесс увеличения даёт целые потоки | и
когда он закончится, поток равен разрезу, а этого не может превзойти
никакой поток, целый или нет.

Как же найти максимальный поток и минимальный разрез для произвольной сети? Самый простой алгоритм для этого, называемый алгоритмом Эдмондса { Карпа в честь его изобретателей, следует описанной схеме с единственным добавлением: в качестве дополняющего
пути всегда берётся кратчайший путь из 𝑠 в 𝑡 в остаточном графе.
Мы уже знаем, как быстро искать кратчайший путь, так что это не
проблема. Осталось только доказать, что при этом процесс сойдётся,
и оценить число итераций. Начнём с первого. Вот простое (но неверное!) рассуждение: на каждом шаге одно из рёбер дополняющего пути
используется полностью, и тем самым больше по нему ничего пропустить нельзя. Значит, количество рёбер в остаточном графе каждый
раз уменьшается, и бесконечно так продолжаться не может.

170

9. Разные алгоритмы на графах

9.3.22. Что неверно в этом рассуждении? (Оно не может быть верным, так как мы не используем, что дополняющий путь кратчайший.)
Решение. Хотя одно из рёбер дополняющего пути действительно
пропадает, но общее число рёбер может не уменьшиться или даже увеличиться, поскольку возникают реверсные рёбра. (Мы видели на примерах, что одно и то же ребро может входить в дополняющий путь то
в одну сторону, то в другую.)
Но тем не менее что-то в таком роде (что остаточный граф постепенно «прореживается») сказать можно.
9.3.23. Выберем какую-то вершину 𝑢 графа и будем следить за расстоянием в остаточном графе от 𝑠 до 𝑢. Докажите, что в ходе алгоритма это расстояние не может уменьшаться (а может оставаться неизменным или увеличиваться).
Аналогичное утверждение верно и для расстояния от 𝑢 до 𝑡 (симметрия).
Решение. Пусть мы сделали несколько шагов алгоритма, и имеется
остаточная сеть (и соответствующий остаточный граф). Расклассифицируем все вершины по длине кратчайшего пути из 𝑠 в этом графе: в
𝑆0 входит сама вершина 𝑠, в 𝑆1 | вершины, куда из 𝑠 ведёт ребро, в
𝑆2 | вершины на расстоянии 2, и так далее. (Эта классификация не
зависит от выбора вершины 𝑢.) Если расстояние от 𝑠 до 𝑢 было равно
какому-то 𝑖, то вершина 𝑢 попадёт в класс 𝑆 .
Заметим, что кратчайший путь из 𝑠 в 𝑢 должен проходить по очереди через вершины в классах 𝑆0 , 𝑆1 , . . . , 𝑆 : пропустить класс он не может, так как на каждом шаге расстояние меняется на 1, а оставаться
в том же классе или идти назад нельзя, тогда путь не будет кратчайшим. После увеличения потока добавятся обратные рёбра этого пути
(если их не было) | и все эти рёбра будут идти из класса с большим
номером в класс с меньшим номером. И потому их добавление не сократит путь: даже если вообще добавить все рёбра, ведущие из класса
с большим номером в класс с меньшим номером, путь не сократится |
какой смысл идти в точку, которая ближе к началу пути, чем текущая?
Так что расстояние или останется прежним, или увеличится (ведь
одно из рёбер дополняющего пути пропало, и другого пути той же
длины может не быть).
Формально говоря, мы не рассмотрели случай, когда расстояние
уже было бесконечным (пути из 𝑠 в 𝑢 не было) и не доказали, что оно
останется бесконечным. Тут надо сказать так: новые рёбра могут вести
только в вершины, доступные из 𝑠, потому новых доступных вершин
не появится.

𝑖

𝑖

9.3. Сети, потоки и разрезы

171

Как мы уже говорили, на каждом шаге работы алгоритма (увеличение потока вдоль дополняющего пути) одно из рёбер дополняющего
пути (то, где пропускная способность минимальна | его ещё называют критическим ребром) исчезает, заменяясь на противоположное.
Если мы оценим сверху, сколько раз это может происходить с каждым
ребром, то получим и верхнюю оценку для числа шагов (умножив на
общее число рёбер).
9.3.24. Докажите, что одно и то же ребро не может быть критическим (в ту и в другую сторону) более чем 𝑂(𝑛) раз. (Напомним, что
𝑛 | число вершин.)
Решение. Пусть нас интересует ребро 𝑢{𝑣 . Оно может быть критическим лишь поочерёдно в ту и в другую сторону. Будем следить, как
меняются расстояния от истока до 𝑢 и до 𝑣. Мы уже знаем, что они могут только возрастать (и не превосходят 𝑛, раз у нас всего 𝑛 вершин).
При этом, когда ребро критическое, одно из расстояний превышает
другое ровно на 1, как мы видели. Остаётся заметить, что если есть
два пешехода, которые идут по дороге в 𝑛 километров, не поворачивая
назад, и в какой-то момент первый обгонял второго на километр, потом
второй первого, потом снова первый второго и так далее, общее число
таких моментов не больше 𝑛 (потому что между соседними моментами
один из пешеходов должен пройти как минимум два километра, а всего
они проходят 2𝑛).

Отсюда следует, что алгоритм Эдмондса { Карпа всегда завершает
работу, и тем самым мы доказали теорему Форда { Фалкерсона в общем
случае (не только для целых пропускных способностей).
Можно попробовать оценить время работы этого алгоритма. Сразу
видно, что он, как говорят, полиномиальный (время работы ограничено
полиномом от 𝑛), никакого перебора экспоненциального числа вариантов в нём нет. При практической реализации полезно хранить данные
(значения 𝑐 и 𝑓 ) не про все пары вершин, а только для тех пар, которые
в изначальной сети соединены ребром (и у каждой вершины иметь список соседей в исходном графе) | для всех остальных пар и в массиве
𝑐, и в массиве 𝑓 стоят только нули, которые так и остаются нулями.
Если, следуя традиции, обозначать число вершин за 𝑉 , а число рёбер
за 𝐸 , то при таком способе хранения данных


поиск кратчайшего пути (поиск в ширину) требует 𝑂(𝐸 ) шагов;



коррекция вдоль дополняющего пути требует 𝑂(𝑉 ) шагов (что
поглощается 𝑂(𝐸 ) шагами поиска);

172


9. Разные алгоритмы на графах

число итераций есть 𝑂(𝐸𝑉 ), потому что каждое из 𝐸 рёбер является критическим 𝑂(𝑉 ) раз.

Какое общее число шагов дают эти оценки?
Ответ. Всего получается 𝑂 (𝐸 2 𝑉 ).

Есть много более эффективных алгоритмов (построенных на разных идеях), это большой раздел информатики, но мы ограничимся приведённым простым алгоритмом, и в заключение приведём два примера,
когда этот алгоритм можно использовать для решения других задач.
Первый пример совсем простой.
9.3.26. Рассмотрим сеть без стока и истока, и будем искать в ней
∑︀
циркуляции (жидкость циркулирует по трубам,
𝑓 [𝑖,𝑗 ] = 0 при всех 𝑖).
Будем разрешать отрицательные значения функции 𝑐[𝑖, 𝑗 ] (что означает, что в некоторых трубах указано минимальное допустимое значение
потока). Как проверить, возможна ли в данной сети циркуляция?
Решение. Добавим к сети отдельные вершины стока и истока, и
соединим их со всеми вершинами исходной сети трубами пропускной
способности 𝑐, где 𝑐 | достаточно большая константа.
9.3.25.

𝑗

𝑠

𝑡

Построим в полученной сети некоторый поток. Для этого пустим по
каждой трубе какое-то разрешённое количество жидкости (предполагаем, что для каждой трубы условия на 𝑓 [𝑖, 𝑗 ] и 𝑓 [𝑗, 𝑖] не противоречат
друг другу, иначе задача тривиальна), беря эту жидкость при необходимости по трубе из истока и сливая её по трубе в сток. Проблем не
возникнет, поскольку мы предположили, что 𝑐 достаточно велико.
Итак, у нас есть некоторый поток в результирующей сети. Теперь
используем алгоритм Эдмондса { Карпа для его максимизации. Ясно,
что не может получиться больше 𝑐𝑉 , где 𝑉 | число вершин, и что

9.3. Сети, потоки и разрезы

173

ровно 𝑐𝑉 получится в том и только том случае, когда в исходной сети
существует циркуляция.

Второй пример | эта задача о максимальном паросочетании в двудольном графе. В адаптированном для школьников варианте её можно
сформулировать так. Есть несколько школьников, каждый из них решил несколько задач. Надо организовать разбор максимального числа
задач, при этом один и тот же школьник не может выступать дважды,
и нельзя дважды рассказывать одну и ту же задачу.
9.3.27. Схема «кто что решил» показана на рисунке (слева школьники 𝑎, 𝑏, 𝑐, 𝑑, 𝑒, справа задачи 1, 2, 3, 4, 5). Какое максимальное число
задач можно разобрать, соблюдая ограничения?
школьники
задачи

𝑎
𝑏
𝑐
𝑑
𝑒

1
2
3
4
5

Решение. Можно организовать разбор трёх задач (паросочетание
𝑎{2, 𝑏{1, 𝑒{5, см. рисунок).

Как убедиться, что больше нельзя, не перебирая варианты? Заметим, что школьники 𝑎, 𝑐, 𝑑, 𝑒 не решили ничего, кроме задач 2 и 5 (а
школьник 𝑑 даже и задачу 2 не решил, но это не важно). Поэтому из них
могут выступить только двое | а двое других останутся не у дел. 
Терминология: двудольный граф на рисунках состоит из левой доли
(школьники) и правой доли (задачи). Паросочетание (способ разбора) | набор рёбер, у которых все левые концы разные (ни один школьник не выступает дважды) и все правые концы разные (ни одну задачу

174

9. Разные алгоритмы на графах

не рассказывают дважды). Аналогичную задачу о максимальном паросочетании можно поставить для произвольного двудольного графа
(произвольной схемы соединения вершин слева и справа).
9.3.28. Найдите максимальное паросочетание в произвольном двудольном графе, сведя эту задачу к отысканию максимального потока.
Решение. Будем считать рёбра графа идущими слева направо и имеющими неограниченную пропускную способность, и добавим исток слева и сток справа, соединив их с вершинами односторонними рёбрами с
пропускной способностью 1.

1

1

𝑠

𝑡
1

1

Целочисленный поток в таком графе | это в точности паросочетание. В самом деле, приходящая из истока единица потока должна куда-то направиться. Поскольку поток целый, то она может пойти только
по одному ребру. И если два таких использованных ребра приведут в
одну вершину справа, то из неё уже поток не сможет выйти. А как
искать максимальный целочисленный поток, мы знаем, для этого даже алгоритм Эдмондса { Карпа не нужен, можно использовать любые
дополняющие пути.

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

9.3. Сети, потоки и разрезы

175

Мы поняли, что (целочисленные) потоки в построенной сети соответствуют паросочетаниям. А чему соответствуют разрезы и что
можно извлечь из совпадения максимального потока и минимального
разреза? Вспомним объяснение про школьников: поток не может быть
большим, потому что есть четыре школьника, которые решили одни и
те же две задачи. Оказывается, что именно такого рода препятствие
есть всегда, и это как раз соответствует разрезу в сети.
Представим себе, что в левой доле, где всего 𝑚 вершин, есть набор
𝑈 из 𝑢 вершин, у которых все их соседи входят в какой-то набор 𝑉 из
меньшего числа вершин 𝑣 в правой доле.
𝑚
𝑛

𝑈
𝑢

𝑉
𝑣

Это означает, что как минимум 𝑢 − 𝑣 вершин слева останутся без дела,
так что максимальное паросочетание не превосходит

𝑚 − (𝑢 − 𝑣) = 𝑚 − 𝑢 + 𝑣.
Оказывается, что такую оценку всегда можно сделать точной.
9.3.29. Пусть в двудольном графе (слева 𝑚 вершин, справа 𝑛 вершин) не существует паросочетания размера 𝑘 > 0. Тогда в левой доле
есть набор 𝑈 из 𝑢 вершин, а в правой доле есть набор 𝑉 из 𝑣 вершин, для
которых (1) все соседи вершин из 𝑈 попадают в 𝑉 ; (2) 𝑚 − 𝑢 + 𝑣 < 𝑘.
(Поскольку исходная задача симметрична, можно сформулировать
и симметричное утверждение о вершинах в правой доле и их соседях в
левой доле; это эквивалентно переходу от множеств 𝑈 и 𝑉 к их дополнениям.)
Решение. Рассмотрим максимальный разрез | по теореме Форда { Фалкерсона он меньше 𝑘. Левая его компонента 𝑆 содержит исток,
какие-то вершины левой доли (назовём их 𝑈 ), и какие-то вершины правой доли (назовём их 𝑉 ).
Заметим, что вершина в 𝑈 не может быть соединена ребром с вершиной не из 𝑉 , потому что тогда это ребро попадает в разрез, а такие рёбра имеют неограниченную (очень большую) пропускную способность. Пусть в 𝑈 попало 𝑢 вершин, а в 𝑉 попало 𝑣 вершин. Какие

176

9. Разные алгоритмы на графах

𝑚
𝑈
𝑠

𝑛
𝑉
𝑡

рёбра пересекают разрез? Это 𝑚 − 𝑢 рёбер, которые из истока ведут в
вершины не из 𝑈 , а также 𝑣 рёбер, которые из 𝑉 ведут в сток. Все они
имеют пропускную способность 1, поэтому минимальный разрез равен
𝑚 − 𝑢 + 𝑣 и меньше 𝑘 (мы с этого начали).

То же самое утверждение в чуть другой форме:
9.3.30. В школе работает несколько кружков. Требуется в каждом
кружке выбрать старосту из числа участников этого кружка, причём
один человек не может быть старостой двух кружков. Докажите, что
это невозможно в том и только том случае, когда при некотором 𝑢
найдётся 𝑢 кружков, в которых вместе занимается меньше 𝑢 человек.
Решение. Кружки | точки в левой доле, школьники | в правой, а
рёбра ведут от кружка ко всем его участникам.

В такой форме это утверждение называют теоремой Холла о представителях (староста представляет свой кружок). В одну сторону оно
очевидно: если в 𝑢 кружках всего занимаются меньше 𝑢 человек, то
старост выбрать не удастся. В обратную сторону оно, как мы видели,
следует из теоремы Форда { Фалкерсона. Но можно доказать его и без
неё.
9.3.31. Докажите это обратное утверждение индукцией по числу
кружков, рассмотрев два случая: (1) есть набор из некоторого числа 𝑢
кружков, в которых вместе занимаются ровно 𝑢 школьников, и (2) всегда есть запас: для любого семейства кружков общее число школьников
в них больше числа кружков в семействе.
Вот ещё одна переформулировка того же утверждения, называемая
теоремой Кёнига ; её преимущество в том, что обе доли графа входят
в неё симметричным образом.
9.3.32. Покажите, что размер максимального паросочетания в двудольном графе равен размеру минимального вершинного покрытия.

9.3. Сети, потоки и разрезы

177

(Множество вершин называется вершинным покрытием, если у любого ребра хотя бы один конец принадлежит этому множеству. Вершины
можно выбирать из обеих долей.)
Следующую задачу можно найти в сборниках олимпиадных задач
для школьников, но, по-видимому, её решение по существу требует теоремы Холла в том или ином виде (а особенно просто её решить, зная
о потоках и разрезах).
9.3.33. На контрольной каждый школьник решил 5 задач, и каждую
задачу решило 5 школьников. Докажите, что можно организовать разбор так, чтобы каждый школьник рассказал одну из решённых им задач
и все задачи были бы разобраны.
[Указание. Число задач равно числу школьников (посчитаем решения двумя способами), обозначим его за 𝑛. В соответствующем графе
есть дробный поток величины 𝑛, в котором каждая задача рассказывается с весом 1/5, значит, есть и целый поток такой величины. (Если
избегать упоминания потоков, то можно заметить, что препятствие к
разбору всех задач из теоремы Холла невозможно, потому что в этом
случае из 𝑈 выходит больше рёбер, чем может зайти в 𝑉 .)]

10. СОПОСТАВЛЕНИЕ
С ОБРАЗЦОМ
10.1. Простейший пример
10.1.1. Имеется последовательность символов x[1] . . . x[n]. Определите, имеются ли в ней идущие друг за другом символы abcd. (Другими словами, требуется выяснить, есть ли в слове x[1] . . . x[n] подслово
abcd.)
Решение. Имеется примерно n (если быть точным, n-3) позиций,
на которых может находиться искомое подслово в исходном слове. Для
каждой из позиций можно проверить, действительно ли там оно находится, сравнив четыре символа. Однако есть более эффективный способ. Читая слово x[1] . . . x[n] слева направо, мы ожидаем появления буквы a. Как только она появилась, мы ищем за ней букву b, затем c, и,
наконец, d. Если наши ожидания оправдываются, то слово abcd обнаружено. Если же какая-то из нужных букв не появляется, мы оказываемся
у разбитого корыта и начинаем всё сначала.

Этот простой алгоритм можно описать в разных терминах. Используя терминологию так называемых конечных автоматов, можно сказать, что при чтении слова x слева направо мы в каждый момент находимся в одном из следующих состояний: «начальное» (0),
«сразу после a» (1), «сразу после ab» (2), «сразу после abc» (3) и «сразу после abcd» (4). Читая очередную букву, мы переходим в следующее состояние по правилу, указанному в таблице (см. следующую
страницу). Как только мы попадём в состояние 4, работа заканчивается.
Наглядно выполнение алгоритма можно представить себе так: фишка двигается из кружка в кружок по стрелкам; стрелка выбирается так,
чтобы надпись на ней соответствовала очередной букве входного слова.
Чтобы этот процесс был успешным, нужно, чтобы для каждой буквы

179

10.1. Простейший пример

Текущее Очередная
Новое
состояние
буква
состояние
0
a
1
0
кроме a
0
1
b
2
1
a
1
1
кроме a,b
0
2
c
3
2
a
1
2
кроме a,c
0
3
d
4
3
a
1
3
кроме a,d
0
Правила перехода для конечного автомата
была ровно одна подходящая стрелка из любого кружка.
начало
0
̸= 𝑎

𝑎

𝑎
1
̸= 𝑎, 𝑏

𝑏

𝑎
2
̸= 𝑎, 𝑐

𝑐

𝑎
3

𝑑

4

̸= 𝑎, 𝑑

Соответствующая программа очевидна (мы указываем новое состояние, даже если оно совпадает со старым; эти строки можно опустить):
i:=1; state:=0;
{i - первая непрочитанная буква, state - состояние}
while (i n+1) and (state 4) do begin
if state = 0 then begin
if x[i] = a then begin
state:= 1;
end else begin
state:= 0;
end;
end else if state = 1 then begin
if x[i] = b then begin
state:= 2;
end else if x[i] = a then begin
state:= 1;

180

10. Сопоставление с образцом
end else begin
state:= 0;
end;
end else if state = 2 then begin
if x[i] = c then begin
state:= 3;
end else if x[i] = a then begin
state:= 1;
end else begin
state:= 0;
end;
end else if state = 3 then begin
if x[i] = d then begin
state:= 4;
end else if x[i] = a then begin
state:= 1;
end else begin
state:= 0;
end;
end;
end;
answer := (state = 4);

Иными словами, мы в каждый момент храним информацию о том,
какое максимальное начало нашего образца abcd является концом прочитанной части. (Его длина и есть то «состояние», о котором шла речь.)
Терминология, нами используемая, такова. Слово | это любая последовательность символов из некоторого фиксированного конечного
множества. Это множество называется алфавитом, его элементы |
буквами. Если отбросить несколько букв с конца слова, останется другое слово, называемое началом первого. Любое слово также считается своим началом. Конец слова | то, что останется, если отбросить
несколько первых букв. Любое слово считается своим концом. Подслово | то, что останется, если отбросить буквы и с начала, и с конца.
(Другими словами, подслова | это концы начал, или, что то же, начала
концов.)
В терминах индуктивных функций (см. раздел 1.3) ситуацию можно
описать так: рассмотрим функцию на словах, которая принимает два
значения «истина» и «ложь» и истинна на словах, имеющих abcd своим
подсловом. Эта функция не является индуктивной, но имеет индуктивное расширение

𝑥 ↦→ длина максимального начала слова abcd, являющегося концом 𝑥.

10.2. Повторения в образце | источник проблем

181

10.2. Повторения в образце | источник
проблем
10.2.1. Можно ли в предыдущих рассуждениях заменить слово abcd
на произвольное слово?
Решение. Нет, и проблемы связаны с тем, что в образце могут быть
повторяющиеся буквы. Пусть, например, мы ищем вхождения слова
ababc. Вот появилась буква a, за ней идёт b, за ней идёт a, затем снова b.
В этот момент мы с нетерпением ждём буквы c. Однако | к нашему
разочарованию | вместо неё появляется другая буква, и наш образец
ababc не обнаружен. Однако нас может ожидать утешительный приз:
если вместо c появилась буква a, то не всё потеряно: за ней могут последовать буквы b и c, и образец-таки будет найден.


Вот картинка, поясняющая сказанное:
x y z a b a b a b c ...
a b a b c
a b a b c





входное слово
мы ждали образца здесь
а он оказался здесь

Таким образом, к моменту
x y z a b a b
a b a b c
a b a b c





входное слово
мы ждали образца здесь
а он оказался здесь

есть два возможных положения образца, каждое из которых подлежит
проверке. Тем не менее по-прежнему возможен конечный автомат, читающий входное слово буква за буквой и переходящий из состояния
в состояние в зависимости от прочитанных букв.
10.2.2. Укажите состояния соответствующего автомата и таблицу перехода (новое состояние в зависимости от старого и читаемой
буквы).
Решение. По-прежнему состояния будут соответствовать наибольшему началу образца, являющемуся концом прочитанной части слова.
Их будет шесть: 0, 1 (a), 2 (ab), 3 (aba), 4 (abab), 5 (ababc). Таблица
перехода показана на следующей странице.

182

10. Сопоставление с образцом

Текущее
состояние
0
0
1 ( a)
1 ( a)
1 ( a)
2 (ab)
2 (ab)
3 (aba)
3 (aba)
3 (aba)
4 (abab)
4 (abab)
4 (abab)

Очередная Новое
буква
состояние
a
1 ( a)
кроме a
0
b
2 (ab)
a
1 ( a)
кроме a,b 0
a
3 (aba)
кроме a
0
b
4 (abab)
a
1 ( a)
кроме a,b 0
c
5 (ababc)
a
3 (aba)
кроме a,c 0

Для проверки посмотрим, к примеру, на вторую снизу строку. Если прочитанная часть кончалась на abab, а затем появилась буква a,
то теперь прочитанная часть кончается на ababa. Наибольшее начало
образца (ababc), являющееся её концом | это aba.

Философский вопрос : мы говорили, что трудность состоит в том,
что есть несколько возможных положений образца, каждое из которых
может оказаться истинным. Им соответствуют несколько начал образца, являющихся концами входного слова. Но конечный автомат помнит
лишь самое длинное из них. Как же остальные?
Философский ответ. Дело в том, что самое длинное из них определяет все остальные | это его концы, одновременно являющиеся его
началами.
Не составляет труда для любого конкретного образца написать программу, осуществляющую поиск этого образца описанным способом.
Однако хотелось бы написать программу, которая ищет произвольный образец в произвольном слове. Это можно делать в два этапа:
сначала по образцу строится таблица переходов конечного автомата,
а затем читается входное слово и состояние преобразуется в соответствии с этой таблицей. Подобный метод часто используется для более сложных задач поиска (см. далее), но для поиска подслова существует более простой и эффективный алгоритм, называемый алгоритмом Кнута { Морриса { Пратта. (Ранее сходные идеи были предложены
Ю. В. Матиясевичем.) Но прежде нам понадобятся некоторые вспомогательные утверждения.

10.3. Вспомогательные утверждения

183

10.3. Вспомогательные утверждения

Для произвольного слова 𝑋 рассмотрим все его начала, одновременно являющиеся его концами, и выберем из них самое длинное. (Не
считая, конечно, самого слова 𝑋 .) Будем обозначать его 𝑙(𝑋 ).
Примеры: 𝑙(aba) = a, 𝑙(abab) = ab, 𝑙(ababa) = aba, 𝑙(abc) = пустое
слово.
10.3.1. Докажите, что все слова 𝑙(𝑋 ), 𝑙(𝑙(𝑋 )), 𝑙(𝑙(𝑙(𝑋 ))) и т. д. являются началами слова 𝑋 .
Решение. Каждое из них (согласно определению) является началом
предыдущего.

По той же причине все они являются концами слова 𝑋 .
10.3.2. Докажите, что последовательность предыдущей задачи обрывается (на пустом слове).
Решение. Каждое слово короче предыдущего.

10.3.3. Докажите, что любое слово, одновременно являющееся началом и концом слова 𝑋 (кроме самого 𝑋 ) входит в последовательность
𝑙(𝑋 ), 𝑙(𝑙(𝑋 )), . . .
Решение. Пусть слово 𝑌 есть одновременно начало и конец 𝑋 . Слово
𝑙(𝑋 ) | самое длинное из таких слов, так что 𝑌 не длиннее 𝑙(𝑋 ). Оба эти
слова являются началами 𝑋 , поэтому более короткое из них является
началом более длинного: 𝑌 есть начало 𝑙(𝑋 ). Аналогично, 𝑌 есть конец
𝑙(𝑋 ). Рассуждая по индукции, можно предполагать, что утверждение
задачи верно для всех слов короче 𝑋 , в частности, для слова 𝑙(𝑋 ). Так
что слово 𝑌 , являющееся концом и началом 𝑙(𝑋 ), либо равно 𝑙(𝑋 ), либо
входит в последовательность 𝑙(𝑙(𝑋 )), 𝑙(𝑙(𝑙(𝑋 ))),. . . , что и требовалось
доказать.

10.4. Алгоритм Кнута { Морриса { Пратта

Алгоритм Кнута { Морриса { Пратта (КМП) получает на вход слово
X = x[1]x[2] . . . x[n]

и просматривает его слева направо буква за буквой, заполняя при этом
массив натуральных чисел l[1] . . . l[n], где
l[i] = длина слова 𝑙(x[1] . . . x[i])

184

10. Сопоставление с образцом

(функция 𝑙 определена в предыдущем пункте). Словами: l[i] есть длина наибольшего начала слова x[1] . . . x[i], одновременно являющегося
его концом.
10.4.1. Какое отношение всё это имеет к поиску подслова? Другими словами, как использовать алгоритм КМП для определения того,
является ли слово A подсловом слова B?
Решение. Применим алгоритм КМП к слову A#B, где # | специальная буква, не встречающаяся ни в A, ни в B. Слово A является подсловом
слова B тогда и только тогда, когда среди чисел в массиве l будет число,
равное длине слова A.

10.4.2. Опишите алгоритм заполнения таблицы l[1] . . . l[n].
Решение. Предположим, что первые i значений l[1] . . . l[i] уже
найдены. Мы читаем очередную букву слова (т. е. x[i+1]) и должны
вычислить l[i+1].
1

i i+1

уже прочитанная часть x

𝑍
𝑍
Другими словами, нас интересуют начала 𝑍 слова x[1] . . . x[i+1],
одновременно являющиеся его концами | из них нам надо выбрать
самое длинное. Откуда берутся эти начала? Каждое из них (не считая пустого) получается из некоторого слова 𝑍 ′ приписыванием буквы
x[i+1]. Слово 𝑍 ′ является началом и концом слова x[1] . . . x[i]. Однако не любое слово, являющееся началом и концом слова x[1] . . . x[i],
годится | надо, чтобы за ним следовала буква x[i+1].
Получаем такой рецепт отыскания слова 𝑍 . Рассмотрим все начала слова x[1] . . . x[i], являющиеся одновременно его концами. Из них
выберем подходящие | те, за которыми идёт буква x[i+1]. Из подходящих выберем самое длинное. Приписав в его конец x[i+1], получим
искомое слово 𝑍 .
Теперь пора воспользоваться сделанными нами приготовлениями
и вспомнить, что все слова, являющиеся одновременно началами и концами данного слова, можно получить повторными применениями к нему
функции 𝑙 из предыдущего раздела. Вот что получается:






i:=1; l[1]:= 0;
{таблица l[1]..l[i] заполнена правильно}
while i n do begin
len := l[i]



10.4. Алгоритм Кнута { Морриса { Пратта
{len - длина начала слова x[1]..x[i], которое является
его концом; все более длинные начала оказались
неподходящими}
while (x[len+1] x[i+1]) and (len > 0) do begin
{начало не подходит, применяем к нему функцию l}
len := l[len];
end;
{нашли подходящее или убедились в отсутствии}
if x[len+1] = x[i+1] do begin
{x[1]..x[len] - самое длинное подходящее начало}
l[i+1] := len+1;
end else begin
{подходящих нет}
l[i+1] := 0;
end;
i := i+1;
end;

185



10.4.3. Докажите, что число действий в приведённом только что
алгоритме не превосходит 𝐶 n для некоторой константы 𝐶 .
Решение. Это не вполне очевидно: обработка каждой очередной буквы может потребовать многих итераций во внутреннем цикле. Однако
каждая такая итерация уменьшает len по крайней мере на 1, и в этом
случае l[i+1] окажется заметно меньше l[i]. С другой стороны, при
увеличении i на единицу величина l[i] может возрасти не более чем
на 1, так что часто и сильно убывать она не может | иначе убывание
не будет скомпенсировано возрастанием.
Более точно, можно записать неравенство

l[i+1] 6 l[i] − (число итераций на i-м шаге) + 1

или

(число итераций на i-м шаге) 6 l[i] − l[i+1] + 1.
Остаётся сложить эти неравенства по всем i и получить оценку сверху
для общего числа итераций.

10.4.4. Будем использовать этот алгоритм, чтобы выяснить, является ли слово X длины n подсловом слова Y длины m. (Как это делать
с помощью специального разделителя #, описано выше.) При этом число действий будет не более 𝐶 (n + m), и используемая память тоже.
Придумайте, как обойтись памятью не более 𝐶 n (что может быть существенно меньше, если искомый образец короткий, а слово, в котором
его ищут | длинное).

186

10. Сопоставление с образцом

Решение. Применяем алгоритм КМП к слову A#B. При этом вычисление значений l[1], . . . , l[n] проводим для слова X длины n и запоминаем эти значения. Дальше мы помним только значение l[i] для
текущего i | кроме него и кроме таблицы l[1] . . . l[n], нам для вычислений ничего не нужно.

На практике слова X и Y могут не находиться подряд,поэтому просмотр слова X и затем слова Y удобно оформить в виде разных циклов.
Это избавляет также от хлопот с разделителем.
10.4.5. Напишите соответствующий алгоритм (проверяющий, является ли слово X = x[1] . . . x[n] подсловом слова Y = y[1] . . . y[m]).
Решение. Сначала вычисляем таблицу l[1] . . . l[n] как раньше. Затем пишем такую программу:

j:=0; len:=0;
{len - длина максимального начала слова X, одновременно
являющегося концом слова y[1]..y[j]}
while (len n) and (j m) do begin
while (x[len+1] y[j+1]) and (len > 0) do begin
{начало не подходит, применяем к нему функцию l}
len := l[len];
end;
{нашли подходящее или убедились в отсутствии}
if x[len+1] = y[j+1] do begin
{x[1]..x[len] - самое длинное подходящее начало}
len := len+1;
end else begin
{подходящих нет}
len := 0;
end;
j := j+1;
end;
{если len=n, слово X встретилось; иначе мы дошли до конца
слова Y, так и не встретив X}



10.5. Алгоритм Бойера { Мура

Этот алгоритм делает то, что на первый взгляд кажется невозможным: в типичной ситуации он читает лишь небольшую часть всех букв
слова, в котором ищется заданный образец. Как так может быть? Идея
проста. Пусть, например, мы ищем образец abcd. Посмотрим на четвёртую букву слова: если, к примеру, это буква e, то нет никакой необхо-

10.5. Алгоритм Бойера { Мура

187

димости читать первые три буквы. (В самом деле, в образце буквы e
нет, поэтому он может начаться не раньше пятой буквы.)
Мы приведём самый простой вариант этого алгоритма, который не
гарантирует быстрой работы во всех случаях. Пусть x[1] . . . x[n] |
образец, который надо искать. Для каждого символа s найдём самое
правое его вхождение в слово X, то есть наибольшее k, при котором
x[k] = s. Эти сведения будем хранить в массиве pos[s]; если символ s
вовсе не встречается, то нам будет удобно положить pos[s] = 0 (мы
увидим дальше, почему).
10.5.1. Как заполнить массив pos?
Решение.

положить все pos[s] равными 0
for i:=1 to n do begin
pos[x[i]]:=i;
end;



В процессе поиска мы будем хранить в переменной last номер буквы в слове, против которой стоит последняя буква образца. Вначале
last = n (длина образца), затем last постепенно увеличивается.
last:=n;
{все предыдущие положения образца уже проверены}
while last 𝑏 и мы обходим
вершины дерева слева направо. Тогда после просмотра вершины 𝑎 мы

𝑏
𝑎

min
max

Рис. 11.6. Оптимизация возможна при 𝑎 > 𝑏.
знаем, что цена корневой вершины не меньше 𝑎. Перейдя к min-вершине
и просмотрев её первого сына 𝑏, мы определяем, что цена min-вершины
не больше 𝑏 и (при 𝑏 6 𝑎) она не может повлиять на цену корня. Поэтому
следующие вершины (серая область на рисунке) и их поддеревья нам
просматривать не нужно.
Применённую в обоих случаях оптимизацию можно описать так.
Приступая к оценке некоторой вершины, мы знаем некоторый промежуток [𝑚, 𝑀 ], в пределах которого нас интересует цена этой вершины | либо потому, что она заведомо не может выйти за пределы

224

11. Анализ игр

промежутка (как в первом случае, когда лучше выигрыша ничего не
бывает), либо потому, что это нам ничего не даёт (как во втором случае, когда все цены меньше 𝑎 для нас неотличимы от 𝑎).
Более формально, введём обозначение 𝑥[ ] , где 𝑥 | число, а [𝑎, 𝑏] |
промежуток:


⎨𝑎, если 𝑥 6 𝑎;
𝑥[ ] = 𝑥, если 𝑎 6 𝑥 6 𝑏;


𝑏, если 𝑏 6 𝑥.
Другими словами, 𝑥[ ] | ближайшая к 𝑥 точка промежутка [𝑎, 𝑏], которую можно назвать «приведённым к [𝑎, 𝑏] значением 𝑥». Теперь можно
сказать, что после просмотра вершины 𝑎 на рисунке 11.6 нас интересует приведённая к [𝑎, +∞] цена min-вершины (все значения, меньшие 𝑎,
безразличны), а после просмотра вершины 𝑏 эта приведённая цена уже
известна (равна 𝑎). Аналогичным образом цена игры с двумя исходами
±1 равна её приведённой к отрезку [−1, +1] цене, и после обнаружения
выигрышного хода становится ясным, что эта цена равна +1.
Используя это соображение, напишем оптимизированный алгоритм,
в котором рекурсивно определяется приведённая к промежутку [𝑎, 𝑏]
цена игры в текущей вершине:
𝑎,𝑏

𝑎,𝑏

𝑎,𝑏

procedure find_reduced_cost (a,b: integer; var c: integer)
var x: integer;
begin
if тип = final then begin
c:= стоимость, приведённая к [a,b]
end else if тип = max then begin
вверх_налево;
find_reduced_cost (a,b,c);
{c = максимум цены вершины и братьев слева,
приведённый к [a,b]
while есть_справа and (c
> 𝑝2 > . . . > 𝑝 . Докажите, что тогда длины слов оптимального кода
идут в неубывающем порядке: 𝑛1 6 𝑛2 6 . . . 6 𝑛 .
𝑘

𝑘

Решение. Если бы более редкая буква имела бы более короткое кодовое слово, то, обменяв кодовые слова, мы сократили бы среднюю длину
кода.

12.3.3. Останется ли утверждение предыдущей задачи в силе, если
частоты расположены в невозрастающем порядке (возможны равные)?
Решение. Нет: если, скажем, имеются три буквы с частотой 1/3, то
оптимальный код будет иметь длины слов 1, 2, 2 (если бы два кодовых
слова имели длину 1, то на третье уже не осталось бы места), и они
могут идти в любом порядке.


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

236

12. Оптимальное кодирование

12.3.4. Пусть частоты расположены в невозрастающем порядке
(𝑝1 > 𝑝2 > . . . > 𝑝 ), а длины слов в оптимальном коде расположены в неубывающем порядке 𝑛1 6 𝑛2 6 . . . 6 𝑛 . Докажите, что 𝑛 −1 = 𝑛 (при
𝑘 > 2).
Решение. Предположим, что это не так, и что есть единственное самое длинное кодовое слово длины 𝑛 . Тогда неравенство Крафта { Макмиллана не может обращаться в равенство, поскольку все слагаемые,
кроме наименьшего (последнего), кратны удвоенному последнему слагаемому. Значит, в этом неравенстве есть запас, причём не меньший
последнего слагаемого. А тогда можно уменьшить 𝑛 на единицу, не
нарушая неравенства, что противоречит предположению об оптимальности исходного кода.

Эта задача показывает, что при поиске оптимального кода можно
рассматривать лишь коды, в которых две самые редкие буквы имеют
коды одинаковой длины.
12.3.5. Как свести задачу отыскания длин кодовых слов оптимального кода для 𝑘 частот
𝑝1 > 𝑝2 > . . . > 𝑝 −2 > 𝑝 −1 > 𝑝
к задаче поиска длин оптимального кода для 𝑘 − 1 частот
𝑝1 , 𝑝2 , . . . , 𝑝 −2 , 𝑝 −1 + 𝑝
(частоты двух самых редких букв объединены)?
Решение. Мы уже знаем, что можно рассматривать лишь коды с
𝑛 −1 = 𝑛 . Неравенство Крафта { Макмиллана тогда запишется как
𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

2

𝑘

𝑘

𝑘

𝑘

+2

+ . . . + 2−

+ 2− 𝑘 =
= 2− 1 + 2− 2 + . . . + 2− 𝑘−2 + 2− 6 1,
если положить 𝑛 −1 = 𝑛 = 𝑛 + 1. Таким образом, числа 𝑛1 , . . . , 𝑛 −2 , 𝑛
должны удовлетворять неравенству Крафта { Макмиллана для 𝑘 − 1
букв. Средняя длины этих двух кодов будут связаны:
−𝑛 1

−𝑛 2

𝑛𝑘−2

+ 2−

𝑛𝑘 −1

𝑛

𝑛

𝑘

𝑝1 𝑛1 + . . . + 𝑝

𝑛

𝑛

𝑘

𝑘

+ 𝑝 −1 𝑛 −1 + 𝑝 𝑛 =
= 𝑝1 𝑛1 + . . . + 𝑝 −2 𝑛 −2 + (𝑝 −1 + 𝑝 )𝑛 + [𝑝 −1 + 𝑝 ].
Последнее слагаемое (квадратная скобка) не зависит от выбираемого
кода, поэтому минимизировать надо остальное, то есть как раз среднюю длину кода с длинами слов 𝑛1 , . . . , 𝑛 −2 , 𝑛 для частот 𝑝1 , 𝑝2 , . . .
. . . , 𝑝 −2 , 𝑝 −1 + 𝑝 . После этого надо положить 𝑛 −1 = 𝑛 = 𝑛 + 1, и это
даст оптимальный код для исходной задачи.

𝑘 −2

𝑛

𝑛

𝑘 −2

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

𝑘

237

12.4. Код Шеннона { Фано

Используя эту задачу, несложно составить рекурсивную программу
для отыскания длин кодовых слов. С каждым вызовом число букв будет уменьшаться, пока мы не сведём задачу к случаю двух букв, когда
оптимальный код состоит из слов 0 и 1. Затем можно найти и сами
кодовые слова (согласно задаче 12.2.3). Но проще объединить эти действия и сразу искать кодовые слова: ведь замена числа 𝑛 на два числа
𝑛 + 1 соответствует замене кодового слова 𝑃 на два слова 𝑃 0 и 𝑃 1 на
единицу большей длины (и эта последняя замена сохраняет префиксность кода).
Код, построенный таким методом, называется кодом Хаффмана.
Мы доказали, что он имеет минимальную среднюю длину среди всех
кодов (для данных частот букв). В следующей задаче оценивается число операций, необходимых для построения кода Хаффмана.
12.3.6. Покажите, что можно обработать частоты 𝑝1 , . . . , 𝑝 , сделав 𝑂(𝑘 log 𝑘) операций, после чего 𝑖-е кодовое слово можно указать за
время, пропорциональное его длине.
𝑘

[Указание. Заметим, что оценка времени довольно сильная: только на сортировку чисел 𝑝 уже уходит 𝑂(𝑘 log 𝑘) действий. Поэтому,
применяя предыдущую задачу, нужно использовать результаты сортировки 𝑘 чисел при сортировке меньшего количества чисел. Это можно
сделать с помощью очереди с приоритетами, вынимая два минимальных числа и добавляя их сумму за 𝑂(log 𝑘) действий. Это позволяет
определить, какие две буквы надо соединять в одну на каждом шаге.
Параллельно с соединением букв можно строить дерево кодов, проводя
рёбра (помеченные 0 и 1) от соединённой буквы к каждой из её половинок. При этом требуется 𝑂(1) действий на каждом шаге. После
завершения построение прослеживать код любой буквы можно символ
за символом.]

𝑖

12.4. Код Шеннона { Фано

Мы видели, как можно построить оптимальный код (имеющий минимальную среднюю длину) для данного набора частот. Однако эта
конструкция не даёт никакой оценки для средней длины оптимального
кода (как функции от частот 𝑝 ). Следующие задачи указывает такую
оценку (с абсолютной погрешностью не более 1).
𝑖

12.4.1. Покажите, что для любых положительных частот 𝑝1 , . . . , 𝑝
(в сумме равных единице) существует код средней длиной не более
𝑘

238

12. Оптимальное кодирование

𝐻 (𝑝1 , . . . , 𝑝 ) + 1, где функция 𝐻 (называемая
определяется формулой

энтропией Шеннона )

𝑘

𝐻 (𝑝1 , . . . , 𝑝 ) = 𝑝1 (− log2 𝑝1 ) + . . . + 𝑝 (− log2 𝑝 )
𝑛

𝑘

𝑘

Решение. Если частоты 𝑝 представляют собой целые (отрицательные) степени двойки, то это утверждение почти очевидно. Положим
𝑛 = − log 𝑝 (здесь и далее все логарифмы двоичные). Тогда 2− 𝑖 = 𝑝
и потому для чисел 𝑛 выполнено неравенство Крафта { Макмиллана.
По задаче 12.2.3 можно построить префиксный код с длинами кодовых
слов 𝑛1 , . . . , 𝑛 , и средняя длина этого кода будет равна 𝐻 (𝑝1 , . . . , 𝑝 )
(и даже единицу добавлять не надо).
Эта единица пригодится, если log 𝑝 не целые. В этом случае надо
взять наименьшее 𝑛 , при котором 2− 𝑖 6 𝑝 . Для таких 𝑛 выполняется
неравенство Крафта { Макмиллана, и они больше − log 𝑝 не более чем
на единицу (потому и после усреднения ухудшение будет не более чем
на единицу).

Построенный на основе этой задачи код называется кодом Шеннона { Фано. Это построение легко извлекается из решения задачи 12.2.3:
рассматривая числа 𝑛 = −⌊log 𝑝 ⌋ (наименьшие целые числа, для которых 2− 𝑖 6 𝑝 ) в порядке убывания, мы отводим для каждого из
них кодовое слово и соответствующий участок отрезка [0, 1] слева направо.
При этом мы проигрываем в длине кода (по сравнению с оптимальным кодом) не более единицы: как мы сейчас увидим, средняя длина
любого (в том числе и оптимального) кода не меньше 𝐻 (𝑝1 , . . . , 𝑝 ).
12.4.2. (Для знакомых с математическим анализом) Докажите, что
(при данных положительных частотах, в сумме дающих единицу) средняя длина любого (однозначного) кода не меньше 𝐻 (𝑝1 , . . . , 𝑝 ).
Решение. Имея в виду неравенство Крафта { Макмиллана, мы должны доказать такой факт: если
𝑖

𝑖

𝑛

𝑖

𝑖

𝑖

𝑘

𝑘

𝑖

𝑛

𝑖

𝑖

𝑖

𝑖

𝑖

𝑛

𝑖

𝑖

𝑘

𝑘

2− 1 + . . . 2− 𝑘 6 1,
𝑛

𝑛

то

𝑝1 𝑛1 + . . . + 𝑝 𝑛 > 𝐻 (𝑝1 , . . . , 𝑝 ).
Это верно для любых 𝑛 , не обязательно целых. Удобно перейти от 𝑛
к величинам 𝑞 = 2− 𝑖 ; интересующее нас утверждение тогда гласит,
что если 𝑝1 , . . . , 𝑝 и 𝑞1 , . . . , 𝑞 | два набора положительных чисел,
𝑘

𝑖

𝑘

𝑘

𝑖

𝑛

𝑖

𝑘

𝑘

239

12.4. Код Шеннона { Фано

и сумма чисел в каждом равна единице, то

𝑝1 (− log 𝑞1 ) + . . . + 𝑝 (− log 𝑞 ) > 𝑝1 (− log 𝑝1 ) + . . . + 𝑝 (− log 𝑝 ).
𝑘

𝑘

𝑘

𝑘

Другими словами, выражение

𝑝1 (− log 𝑞1 ) + . . . + 𝑝 (− log 𝑞 )
𝑘

𝑘

(рассматриваемое при фиксированных 𝑝 как функция на множестве
всех положительных 𝑞1 , . . . , 𝑞 , в сумме равных единице) достигает минимума при 𝑞 = 𝑝 . Область определения этой функции есть внутренность симплекса (треугольника при 𝑛 = 3, тетраэдра при 𝑛 = 4 и т. д.)
и при приближении к границе одно из 𝑞 становится малым, а его минус
логарифм уходит в бесконечность. Значит, минимум функции достигается внутри области. В точке минимума градиент (−𝑝1 /𝑞1 , . . . , −𝑝 /𝑞 )
должен быть перпендикулярен плоскости, на которой функция определена (иначе сдвиг вдоль этой плоскости
∑︀
∑︀ уменьшал бы функцию), то
есть все 𝑝 /𝑞 равны. Поскольку 𝑝 = 𝑞 = 1, то это означает, что
𝑝 =𝑞 .
Другое объяснение: функция log выпукла вверх, поэтому для любых
неотрицательных коэффициентов 𝛼 , в сумме равных единице, и для
любых точек 𝑥 из области определения логарифма выполняется неравенство
(︁∑︁
)︁ ∑︁
log
𝛼 𝑥 > 𝛼 log 𝑥 .
𝑖

𝑘

𝑖

𝑖

𝑖

𝑛

𝑖

𝑖

𝑖

𝑖

𝑛

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

Остаётся положить 𝛼 = 𝑝∑︀, 𝑥 = 𝑞 /𝑝 ; в левой части будет логарифм
единицы, то есть нуль, а 𝑝 log(𝑞 /𝑝 ) есть как раз разность между
левой и правой частями доказываемого неравенства.

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

𝑖

Велика ли экономия от использования кодов, описанных в этом разделе? Это, конечно, зависит от частот букв: если они все одинаковые, то никакой экономии не будет. Легко заметить, что в русском
языке разные буквы имеют разную частоту. Если, скажем, в текстах
(TEX-файлах) этой книжки (на момент эксперимента) оставить только 33 строчные русские буквы от «а» до «я», а все остальные символы
не учитывать, то самой частой буквой будет буква «о» (частота 0,105),
а самой редкой | твёрдый знак (частота 0,00019). Значение энтропии
Шеннона при этом будет равно 4,454 (сравните с 5 битами, необходимыми для кодирования 32 букв). Выигрыш не так велик. Он будет
больше, если учитывать также и другие символы (прописные буквы,
знаки препинания и др.), которые встречаются в тексте гораздо реже.
Наконец, можно кодировать не буквы, а двухбуквенные комбинации или

240

12. Оптимальное кодирование

ещё что-нибудь. Именно так поступают популярные программы сжатия
информации (типа zip), которые позволяют сократить многие тексты
в полтора-два раза (а некоторые другие файлы данных | и в большее
число раз).
12.4.3. Компания M. утверждает, что её новая программа суперсжатия файлов позволяет сжать любой файл длиной больше 100 000 байтов по крайней мере на 10% без потери информации (можно восстановить исходный файл по его сжатому варианту). Докажите, что она
врёт.


13. ПРЕДСТАВЛЕНИЕ
МНОЖЕСТВ.
ХЕШИРОВАНИЕ
13.1. Хеширование с открытой адресацией

В главе 6 было указано несколько представлений для множеств, элементами которых являются целые числа произвольной величины. Однако в любом из них хотя бы одна из операций проверки принадлежности,
добавления и удаления элемента требовала количества действий, пропорционального числу элементов множества. На практике это бывает
слишком много. Существуют способы, позволяющие получить для всех
трёх упомянутых операций оценку 𝐶 log 𝑛. Один из таких способов мы
рассмотрим в следующей главе. В этой главе мы разберём способ, которые хотя и приводит к 𝐶𝑛 действиям в худшем случае, но зато «в среднем» требует значительно меньшего их числа. (Мы не будем уточнять
слов «в среднем», хотя это и можно сделать.) Этот способ называется
хешированием.
Пусть нам необходимо представлять множества элементов типа T,
причём число элементов в них заведомо меньше n. Выберем некоторую функцию h, определённую на значениях типа T и принимающую
значения 0 . . . n-1. Было бы хорошо, чтобы эта функция принимала на
элементах будущего множества по возможности более разнообразные
значения. (Худший случай | это когда её значения на всех элементах хранимого множества одинаковы.) Эту функцию будем называть
хеш-функцией, или, как ещё говорят, функцией расстановки.
Введём два массива
val: array [0..n-1] of T;
used: array [0..n-1] of boolean;

(мы позволяем себе писать n-1 в качестве границы в определении типа,

242

13. Представление множеств. Хеширование

хотя в паскале это не разрешается). В этих массивах будут храниться
элементы множества: оно равно множеству всех val [i] для тех i, для
которых used [i], причём все эти val [i] различны. По возможности
мы будем хранить элемент t на месте h(t), считая это место «исконным» для элемента t. Однако может случиться так, что новый элемент,
который мы хотим добавить, претендует на уже занятое место (для
которого used истинно). В этом случае мы отыщем ближайшее справа
свободное место и запишем элемент туда. («Справа» значит «в сторону увеличения индексов»; дойдя до края, мы перескакиваем в начало.)
По предположению, число элементов всегда меньше n, так что пустые
места заведомо будут.
Формально говоря, в любой момент должно соблюдаться такое требование: для любого элемента множества участок справа от его исконного места до его фактического места полностью заполнен.
Благодаря этому проверка принадлежности заданного элемента t
осуществляется легко: встав на h(t), двигаемся направо, пока не дойдём до пустого места или до элемента t. В первом случае элемент t
отсутствует в множестве, во втором | присутствует. Если элемент
отсутствует, то его можно добавить на найденное пустое место. Если
присутствует, то можно его удалить (положив used = false).
13.1.1. В предыдущем абзаце есть ошибка. Найдите её и исправьте.
Решение. Дело в том, что при удалении требуемое свойство «отсутствия пустот» может нарушиться. Поэтому будем делать так. Создав
дыру, будем двигаться направо, пока не натолкнёмся на элемент, стоящий не на исконном месте, или на ещё одно пустое место. Во втором
случае на этом можно успокоиться. В первом случае посмотрим, не
нужно ли найденный элемент поставить на место дыры. Если нет, то
продолжаем поиск, если да, то затыкаем им старую дыру. При этом
образуется новая дыра, с которой делаем всё то же самое.

13.1.2. Напишите программы проверки принадлежности, добавления и удаления.
Решение.

function принадлежит
var i: integer;
begin
i := h (t);
while used [i] and
i := (i + 1) mod
end; {not used [i]

(t: T): boolean;

(val [i] t) do begin
n;
or (val [i] = t)}

13.1. Хеширование с открытой адресацией

243

принадлежит := used [i] and (val [i] = t);
end;
procedure добавить (t: T);
var i: integer;
begin
i := h (t);
while used [i] and (val [i] t) do begin
i := (i + 1) mod n;
end; {not used [i] or (val [i] = t)}
if not used [i] then begin
used [i] := true;
val [i] := t;
end;
end;
procedure исключить (t: T);
var i, gap: integer;
begin
i := h (t);
while used [i] and (val [i] t) do begin
i := (i + 1) mod n;
end; {not used [i] or (val [i] = t)}
if used [i] and (val [i] = t) then begin
used [i] := false;
gap := i;
i := (i + 1) mod n;
{gap - дыра, которая может закрыться одним из i,i+1,...}
while used [i] do begin
if i = h (val[i]) then begin
{на своём месте, ничего не делать}
end else if dist(h(val[i]),i) < dist(gap,i) then begin
{gap...h(val[i])...i, ничего не делать}
end else begin
used [gap] := true;
val [gap] := val [i];
used [i] := false;
gap := i;
end;
i := (i + 1) mod n;
end;
end;
end;

Здесь dist (a,b) | измеренное по часовой стрелке (слева направо)

244

13. Представление множеств. Хеширование

расстояние от a до b, то есть
dist (a,b) = (b - a + n) mod n.

(Мы прибавили n, так как функция mod правильно работает при положительном делимом.)

13.1.3. Существует много вариантов хеширования. Один из них таков: обнаружив, что исконное место (обозначим его 𝑖) занято, будем искать свободное не среди 𝑖 + 1, 𝑖 + 2, . . ., а среди 𝑟(𝑖), 𝑟(𝑟(𝑖)), 𝑟(𝑟(𝑟(𝑖))), . . .,
где 𝑟 | некоторое отображение {0, . . . , 𝑛 − 1} в себя. Какие при этом
будут трудности?
Ответ. (1) Не гарантируется, что если пустые места есть, то мы
их найдём. (2) При удалении неясно, как заполнять дыры. (На практике во многих случаях удаление не нужно, так что такой способ также
применяется. Считается, что удачный подбор функции 𝑟 может предотвратить образование «скоплений» занятых ячеек.)

13.1.4. Пусть для хранения множества всех правильных русских
слов в программе проверки орфографии используется хеширование.
Что нужно добавить, чтобы к тому же уметь находить английский
перевод любого правильного слова?
Решение. Помимо массива val, элементы которого являются русскими словами, нужен параллельный массив их английских переводов. 
13.2. Хеширование со списками

На хеш-функцию с 𝑘 значениями можно смотреть как на способ свести вопрос о хранении одного большого множества к вопросу о хранении нескольких меньших. Именно, если у нас есть хеш-функция с 𝑘 значениями, то любое множество разбивается на 𝑘 подмножеств (возможно, пустых), соответствующих возможным значениям хеш-функции.
Вопрос о проверке принадлежности, добавлении или удалении для большого множества сводится к такому же вопросу для одного из меньших
(чтобы узнать, для какого, надо посмотреть на значение хеш-функции).
Эти меньшие множества удобно хранить с помощью ссылок; их суммарный размер равен числу элементов хешируемого множества. Следующая задача предлагает реализовать этот план.
13.2.1. Пусть хеш-функция принимает значения 1 . . . k. Для каждого значения хеш-функции рассмотрим список всех элементов множе-

13.2. Хеширование со списками

245

ства с данным значением хеш-функции. Будем хранить эти k списков
с помощью переменных
Содержание: array [1..n] of T;
Следующий: array [1..n] of 1..n;
ПервСвоб: 1..n;
Вершина: array [1..k] of 1..n;

так же, как мы это делали для k стеков ограниченной суммарной длины. Напишите соответствующие программы. (Удаление по сравнению
с открытой адресацией упрощается.)
Решение. Перед началом работы надо положить Вершина[i] = 0 для
всех i = 1 . . . k, и связать все места в список свободного пространства,
положив ПервСвоб = 1 и Следующий[i] = i+1 для i = 1 . . . n-1, а также
Следующий[n] = 0.
function принадлежит (t: T): boolean;
var i: integer;
begin
i := Вершина[h(t)];
{осталось искать в списке, начиная с i}
while (i 0) and (Содержание[i] t) do begin
i := Следующий[i];
end; {(i=0) or (Содержание [i] = t)}
принадлежит := (i0) and (Содержание[i]=t);
end;
procedure добавить (t: T);
var i: integer;
begin
if not принадлежит(t) then begin
i := ПервСвоб;
{ПервСвоб 0 - считаем, что не переполняется}
ПервСвоб := Следующий[ПервСвоб]
Содержание[i]:=t;
Следующий[i]:=Вершина[h(t)];
Вершина[h(t)]:=i;
end;
end;
procedure исключить (t: T);
var i, pred: integer;
begin
i := Вершина[h(t)]; pred := 0;

246

13. Представление множеств. Хеширование
{осталось искать в списке, начиная с i; pred предыдущий, если он есть, и 0, если нет}
while (i 0) and (Содержание[i] t) do begin
pred := i; i := Следующий[i];
end; {(i=0) or (Содержание [i] = t)}
if i 0 then begin
{Содержание[i]=t, элемент есть, надо удалить}
if pred = 0 then begin
{элемент оказался первым в списке}
Вершина[h(t)] := Следующий[i];
end else begin
Следующий[pred] := Следующий[i]
end;
{осталось вернуть i в список свободных}
Следующий[i] := ПервСвоб;
ПервСвоб:=i;
end;
end;



13.2.2. (Для знакомых с теорией вероятностей.) Пусть хеш-функция с 𝑘 значениями используется для хранения множества, в котором
в данный момент 𝑛 элементов. Докажите, что математическое ожидание числа действий в предыдущей задаче не превосходит 𝐶 (1 + 𝑛/𝑘),
если добавляемый (удаляемый, искомый) элемент 𝑡 выбран случайно,
причём все значения ℎ(𝑡) имеют равные вероятности (равные 1/𝑘).
Решение. Если 𝑙(𝑖) | длина списка, соответствующего хеш-значению 𝑖, то число операций не превосходит
𝐶 (1 + 𝑙(ℎ(𝑡))); усредняя, по∑︀
лучаем искомый ответ, так как
𝑙(𝑖) = 𝑛.

𝑖

Эта оценка основана на предположении о равных вероятностях. Однако в конкретной ситуации всё может быть совсем не так, и значения
хеш-функции могут «скучиваться»: для каждой конкретной хеш-функции есть «неудачные» ситуации, когда число действий оказывается
большим. Приём, называемый универсальным хешированием, позволяет обойти эту проблему. Идея состоит в том, что берётся семейство
хеш-функций, причём любая ситуация оказывается неудачной лишь для
небольшой части этого семейства.
Пусть 𝐻 | семейство функций, каждая из которых отображает
множество 𝑇 в множество из 𝑘 элементов (например, 0 . . . 𝑘 − 1). Говорят, что 𝐻 | универсальное семейство хеш-функций, если для любых
двух различных значений 𝑠 и 𝑡 из множества 𝑇 вероятность события
ℎ(𝑠) = ℎ(𝑡) для случайной функции ℎ из семейства 𝐻 равна 1/𝑘. (Дру-

247

13.2. Хеширование со списками

гими словами, те функции из 𝐻 , для которых ℎ(𝑠) = ℎ(𝑡), составляют
1/𝑘-ю часть всех функций в 𝐻 .)
Замечание. Более сильное требование к семейству 𝐻 могло бы состоять в том, чтобы для любых двух различных элементов 𝑠 и 𝑡 множества 𝑇 значения ℎ(𝑠) и ℎ(𝑡) случайной функции ℎ являются независимыми случайными величинами, равномерно распределёнными на
0 . . . 𝑘 − 1.
13.2.3. Пусть 𝑡1 , . . . , 𝑡 | произвольная последовательность различных элементов множества 𝑇 . Рассмотрим количество действий, происходящих при помещении элементов 𝑡1 , . . . , 𝑡 в множество, хешируемое
с помощью функции ℎ из универсального семейства 𝐻 . Докажите, что
среднее количество действий (усреднение | по всем ℎ из 𝐻 ) не превосходит 𝐶𝑛(1 + 𝑛/𝑘).
Решение. Обозначим через 𝑚 количество элементов последовательности, для которых хеш-функция равна 𝑖. (Числа 𝑚0 , . . . , 𝑚 −1 зависят, конечно, от выбора хеш-функции.) Количество действий, которое мы хотим оценить, с точностью до постоянного множителя равно 𝑚20 + 𝑚21 + . . . + 𝑚2 −1 . (Если 𝑠 чисел попадают в одну хеш-ячейку,
то для этого требуется примерно 1 + 2 + . . . + 𝑠 действий.) Эту же
сумму квадратов можно записать как число пар ⟨𝑝, 𝑞⟩, для которых
ℎ(𝑡 ) = ℎ(𝑡 ). Последнее равенство, если его рассматривать как событие при фиксированных 𝑝 и 𝑞, имеет вероятность 1/𝑘 при 𝑝 ̸= 𝑞, поэтому
среднее значение соответствующего члена суммы равно 1/𝑘, а для всей
суммы получаем оценку порядка 𝑛2 /𝑘, а точнее 𝑛 + 𝑛2 /𝑘, если учесть
члены с 𝑝 = 𝑞.

Эта задача показывает, что на каждый добавляемый элемент приходится в среднем 𝐶 (1 + 𝑛/𝑘) операций. В этой оценке дробь 𝑛/𝑘 имеет
смысл «коэффициента заполнения» хеш-таблицы.
13.2.4. Докажите аналогичное утверждение для произвольной последовательности операций добавления, поиска и удаления (а не только
для добавления, как в предыдущей задаче).
[Указание. Будем представлять себе, что в ходе поиска, добавления и удаления элемент проталкивается по списку своих коллег с тем
же хеш-значением, пока не найдёт своего двойника или не дойдёт до
конца списка. Будем называть 𝑖-𝑗 -столкновением столкновение 𝑡 с 𝑡 .
(Оно либо произойдёт, либо нет | в зависимости от ℎ.) Общее число действий примерно равно числу всех происшедших столкновений
плюс число элементов. При 𝑡 ̸= 𝑡 вероятность 𝑖-𝑗 -столкновения не превосходит 1/𝑘. Осталось проследить за столкновениями между равными
𝑛

𝑛

𝑖

𝑘

𝑘

𝑝

𝑞

𝑖

𝑖

𝑗

𝑗

248

13. Представление множеств. Хеширование

элементами. Фиксируем некоторое значение 𝑥 из множества 𝑇 и посмотрим на связанные с ним операции. Они идут по циклу: добавление |
проверки | удаление | добавление | проверки | удаление | . . .
Столкновения происходят между добавляемым элементом и следующими за ним проверками (до удаления включительно), поэтому общее их
число не превосходит числа элементов, равных 𝑥.]

Теперь приведём примеры универсальных семейств. Очевидно, для
любых конечных множеств 𝐴 и 𝐵 семейство всех функций, отображающих 𝐴 в 𝐵 , является универсальным. Однако этот пример с практической точки зрения бесполезен: для запоминания случайной функции
из этого семейства нужен массив, число элементов в котором равно числу элементов в множестве 𝐴. (А если мы можем себе позволить такой
массив, то никакого хеширования не требуется!)
Более практичные примеры универсальных семейств могут быть
построены с помощью несложных алгебраических конструкций. Через Z мы обозначаем множество вычетов по простому модулю 𝑝, т. е.
{0, 1, . . . , 𝑝 − 1}; арифметические операции в этом множестве выполняются по модулю 𝑝. Универсальное семейство образуют все линейные функционалы на Z со значениями в Z . Более подробно, пусть
𝑎1 , . . . , 𝑎 | произвольные элементы Z ; рассмотрим отображение
𝑝

𝑛
𝑝

𝑝

𝑛

𝑝

ℎ : ⟨𝑥1 . . . 𝑥

𝑛

⟩ ↦→ 𝑎1 𝑥1 + . . . + 𝑎𝑛 𝑥𝑛 .

Мы получаем семейство из 𝑝 отображений Z → Z параметризованное наборами ⟨𝑎1 . . . 𝑎 ⟩.
13.2.5. Докажите, что это семейство является универсальным.
[Указание. Пусть 𝑥 и 𝑦 | различные точки пространства Z . Какова
вероятность того, что случайный функционал принимает на них одинаковые значения? Другими словами, какова вероятность того, что он
равен нулю на их разности 𝑥 − 𝑦? Ответ даётся таким утверждением:
пусть 𝑢 | ненулевой вектор; тогда все значения случайного функционала на нём равновероятны.]

𝑛

𝑛
𝑝

𝑝

𝑛

𝑛
𝑝

В следующей задаче множество B = {0, 1} рассматривается как множество вычетов по модулю 2.
13.2.6. Семейство всех линейных отображений из B в B является
универсальным.

𝑛

𝑚

Родственные хешированию идеи неожиданно оказываются полезными в следующей ситуации (рассказал Д. Варсанофьев). Пусть мы хо-

13.2. Хеширование со списками

249

тим написать программу, которая обнаруживала (большинство) опечаток в тексте, но не хотим хранить список всех правильных словоформ.
Предлагается поступить так: выбрать некоторое 𝑁 и набор функций
𝑓1 , . . . , 𝑓 , отображающих русские слова в 1, . . . , 𝑁 . В массиве из 𝑁 битов положим все биты равными нулю, кроме тех, которые являются значением какой-то функции набора на какой-то правильной словоформе.
Теперь приближённый тест на правильность словоформы таков: проверить, что значения всех функций набора на этой словоформе попадают
на места, занятые единицами. (Этот тест может не заметить некоторых ошибок, но все правильные словоформы будут одобрены.)
𝑘

14. ПРЕДСТАВЛЕНИЕ
МНОЖЕСТВ. ДЕРЕВЬЯ.
СБАЛАНСИРОВАННЫЕ
ДЕРЕВЬЯ
14.1. Представление множеств
с помощью деревьев

𝑇 -деревья
Нарисуем точку. Из неё проведём две стрелки (влево вверх и вправо вверх) в две другие точки. Из каждой из этих точек проведём по
две стрелки и так далее. Полученную картинку (в 𝑛-м слое будет 2 −1
точек) называют полным двоичным деревом. Нижнюю точку называют корнем. У каждой вершины есть два сына (две вершины, в которые
идут стрелки) | левый и правый. У всякой вершины, кроме корня, есть
единственный отец.
Пусть выбрано некоторое конечное множество вершин полного двоичного дерева, содержащее вместе с каждой вершиной и всех её предков. Пусть на каждой вершине этого множества написано значение фиксированного типа 𝑇 (то есть задано отображение множества вершин
в множество значений типа 𝑇 ). То, что получится, будем называть 𝑇 -деревом. Множество всех 𝑇 -деревьев обозначим Tree(𝑇 ).
Рекурсивное определение. Всякое непустое 𝑇 -дерево разбивается на
три части: корень (несущий пометку из 𝑇 ), левое и правое поддеревья
(которые могут быть пустыми). Это разбиение устанавливает взаимно однозначное соответствие между множеством непустых 𝑇 -деревьев
и произведением 𝑇 × Tree(𝑇 ) × Tree(𝑇 ). Обозначив через empty пустое
дерево, можно написать
Tree(𝑇 ) = {empty} + 𝑇 × Tree(𝑇 ) × Tree(𝑇 ).
Полное двоичное дерево.

𝑛

251

14.1. Представление множеств с помощью деревьев
Поддеревья. Высота

Фиксируем некоторое 𝑇 -дерево. Для каждой его вершины 𝑥 определено её левое поддерево (левый сын вершины 𝑥 и все его потомки),
правое поддерево (правый сын вершины 𝑥 и все его потомки) и поддерево с корнем в 𝑥 (вершина 𝑥 и все её потомки).
правое
левое

@ B
@ B 
@B 
@Bq

корень

Левое и правое поддеревья вершины 𝑥 могут быть пустыми, а поддерево с корнем в 𝑥 всегда непусто (содержит по крайней мере 𝑥). Высотой поддерева будем считать максимальную длину цепи 𝑦1 . . . 𝑦 его
вершин, в которой 𝑦 +1 | сын 𝑦 для всех 𝑖. (Высота дерева из одного
корня равна единице, высота пустого дерева | нулю.)
𝑛

𝑖

𝑖

𝑇 -деревья
Пусть на множестве значений типа 𝑇 фиксирован порядок. Назовём
𝑇 -дерево упорядоченным, если выполнено такое свойство: для любой
вершины 𝑥 все пометки в её левом поддереве меньше пометки в 𝑥, а все
пометки в её правом поддереве больше пометки в 𝑥.
Упорядоченные

@ 𝑥
@

B
@ B 
@B 
@Br

𝑥

14.1.1. Докажите, что в упорядоченном дереве все пометки различны.
[Указание. Индукция по высоте дерева.]

Представление множеств с помощью деревьев

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

252

14. Деревья. Сбалансированные деревья

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

𝑦𝑥


𝑥
6

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

Можно было бы сопоставить вершины полного двоичного дерева
с числами 1, 2, 3, . . . (считая, что левый сын 𝑘 есть 2𝑘, правый сын 𝑘 есть
2𝑘 + 1) и хранить пометки в массиве val[1..n]. Однако этот способ
неэкономен, поскольку тратится место на хранение пустых вакансий
в полном двоичном дереве.
Более экономен такой способ. Введём три массива
val: array [1..n] of T;
left, right: array [1..n] of 0..n;

(n | максимальное возможное число вершин дерева) и переменную
root:0..n. Каждая вершина хранимого 𝑇 -дерева будет иметь номер |
число от 1 до n. Разные вершины будут иметь разные номера. Пометка
в вершине с номером x равна val[x]. Корень имеет номер root. Если вершина с номером i имеет сыновей, то их номера равны left[i]
и right[i]. Отсутствующим сыновьям соответствует число 0. Аналогичным образом значение root=0 соответствует пустому дереву.
Для хранения дерева используется лишь часть массива; для тех i,
которые свободны (не являются номерами вершин), значения val[i]
безразличны. Нам будет удобно, чтобы все свободные числа были «связаны в список»: первое хранится в специальной переменной free:0..n,
а следующее за i свободное число хранится в left[i], так что свободны числа
free, left[free], left[left[free]], ...

14.1. Представление множеств с помощью деревьев

253

Для последнего свободного числа i значение left[i] равно 0. Равенство free=0 означает, что свободных чисел больше нет.
Замечание. Мы использовали для связывания свободных вершин в
список массив left, но, конечно, с тем же успехом можно было использовать массив right.
Вместо значения 0 (обозначающего отсутствие вершины) можно было бы воспользоваться любым другим числом вне 1..n. Чтобы подчеркнуть это, будем вместо 0 использовать константу null=0.
14.1.2. Составьте программу, определяющую, содержится ли элемент t:T в упорядоченном дереве (хранимом так, как только что описано).
Решение.

if root = null then begin
..не принадлежит
end else begin
x := root;
{инвариант: остаётся проверить наличие t в непустом
поддереве с корнем x}
while ((t < val [x]) and (left [x] null)) or
((t > val [x]) and (right [x] null)) do begin
if t < val [x] then begin {left [x] null}
x := left [x];
end else begin {t > val [x], right [x] null}
x := right [x];
end;
end;
{либо t = val [x], либо t отсутствует в дереве}
..ответ = (t = val [x])
end;



14.1.3. Упростите решение, используя следующий трюк. Расширим
область определения массива val, добавив ячейку с номером null и положим val[null]=t.
Решение.

val [null] := t;
x := root;
while t val [x] do begin
if t < val [x] then begin
x := left [x];
end else begin

254

14. Деревья. Сбалансированные деревья
x := right [x];
end;
end;
..ответ: (x null).



14.1.4. Составьте программу добавления элемента t в множество,
представленное упорядоченным деревом (если элемент t уже есть, ничего делать не надо).
Решение. Определим процедуру get free (var i:integer), дающую свободное (не являющееся номером) число i и соответствующим
образом корректирующую список свободных чисел.

procedure get_free (var i: integer);
begin
{free null}
i := free;
free := left [free];
end;

С её использованием программа приобретает такой вид:
if root = null then begin
get_free (root);
left [root] := null; right [root] := null;
val [root] := t;
end else begin
x := root;
{инвариант: осталось добавить t к непустому поддереву с
корнем в x}
while ((t < val [x]) and (left [x] null)) or
((t > val [x]) and (right [x] null)) do begin
if t < val [x] then begin
x := left [x];
end else begin {t > val [x]}
x := right [x];
end;
end;
if t val [x] then begin {t нет в дереве}
get_free (i);
left [i] := null; right [i] := null;
val [i] := t;
if t < val [x] then begin
left [x] := i;
end else begin {t > val [x]}

14.1. Представление множеств с помощью деревьев
right [x] := i;
end;
end;
end;

255



14.1.5. Составьте программу удаления элемента t из множества,
представленного упорядоченным деревом (если его там нет, ничего делать не надо).
Решение.

if root = null then begin
{дерево пусто, ничего делать не надо}
end else begin
x := root;
{осталось удалить t из поддерева с корнем в x; поскольку
это может потребовать изменений в отце x, введём
переменные father: 1..n и direction: (l, r);
поддерживаем такой инвариант: если x не корень, то father
- его отец, а direction равно l или r в зависимости от
того, левым или правым сыном является x}
while ((t < val [x]) and (left [x] null)) or
((t > val [x]) and (right [x] null)) do begin
if t < val [x] then begin
father := x; direction := l;
x := left [x];
end else begin {t > val [x]}
father := x; direction := r;
x := right [x];
end;
end;
{t = val [x] или t нет в дереве}
if t = val [x] then begin
..удаление вершины x с отцом father и
направлением direction
end;
end;

Удаление вершины использует процедуру
procedure make_free (i: integer);
begin
left [i] := free;
free := i;
end;

256

14. Деревья. Сбалансированные деревья

Она включает число i в список свободных. При удалении различаются
4 случая в зависимости от наличия или отсутствия сыновей у удаляемой
вершины.
if (left [x] = null) and (right [x] = null) then begin
{x - лист, т.е. не имеет сыновей}
make_free (x);
if x = root then begin
root := null;
end else if direction = l then begin
left [father] := null;
end else begin {direction = r}
right [father] := null;
end;
end else if (left[x]=null) and (right[x] null) then begin
{x удаляется, а right [x] занимает место x}
make_free (x);
if x = root then begin
root := right [x];
end else if direction = l then begin
left [father] := right [x];
end else begin {direction = r}
right [father] := right [x];
end;
end else if (left[x] null) and (right[x]=null) then begin
..симметрично
end else begin {left [x] null, right [x] null}
..удалить вершину с двумя сыновьями
end;

Удаление вершины с двумя сыновьями нельзя сделать просто так, но её
можно предварительно поменять с вершиной, пометка на которой является непосредственно следующим (в порядке возрастания) элементом за
пометкой на x.
y := right [x];
father := x; direction := r;
{теперь father и direction относятся к вершине y}
while left [y] null do begin
father := y; direction := l;
y := left [y];
end;
{val [y] - минимальная из пометок, больших val [x],
y не имеет левого сына}

14.1. Представление множеств с помощью деревьев
val [x] := val [y];
..удалить вершину y (как удалять вершину, у которой нет
левого сына, мы уже знаем)

257



14.1.6. Упростите эту программу, заметив, что некоторые случаи
(например, первые два из четырёх) можно объединить.

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

func_val: array [1..n] of U;

если val[x] = t, func val[x] = u, то значение хранимой функции на t
равно u.

14.1.8. Предположим, что необходимо уметь также отыскивать k-й
элемент множества (в порядке возрастания), причём количество действий должно быть не более 𝐶 · (высота дерева). Какую дополнительную информацию надо хранить в вершинах дерева?
Решение. В каждой вершине будем хранить число всех её потомков.
Добавление и исключение вершины требует коррекции лишь на пути от
корня к этой вершине. В процессе поиска k-й вершины поддерживается
такой инвариант: искомая вершина является s-й вершиной поддерева
с корнем в x (здесь s и x | переменные).

Оценка количества действий

Для каждой из операций (проверки, добавления и исключения) количество действий не превосходит 𝐶 · (высота дерева). Для «ровно подстриженного» дерева (когда все листья на одной высоте) высота по
порядку величины равна логарифму числа вершин. Однако для кривобокого дерева всё может быть гораздо хуже: в наихудшем случае все
вершины образуют цепь и высота равна числу вершин. Так случится, если элементы множества добавляются в возрастающем или убывающем порядке. Можно доказать, однако, что при добавлении элементов «в случайном порядке» средняя высота дерева будет не больше
𝐶 log(число вершин). Если этой оценки «в среднем» мало, необходимы
дополнительные действия по поддержанию «сбалансированности» дерева. Об этом см. в следующем пункте.

258

14. Деревья. Сбалансированные деревья

14.2. Сбалансированные деревья

Дерево называется сбалансированным (или АВЛ-деревом в честь
изобретателей этого метода Г. М. Адельсона-Вельского и E. М. Ландиса), если для любой его вершины высоты левого и правого поддеревьев
этой вершины отличаются не более чем на 1. (В частности, когда одного из сыновей нет, другой | если он есть | обязан быть листом.)
14.2.1. Найдите минимальное и максимальное возможное количество вершин в сбалансированном дереве высоты 𝑛.
Решение. Максимальное число вершин равно 2 − 1. Если 𝑚 | минимальное число вершин, то, как легко видеть, 𝑚 +2 = 1 + 𝑚 + 𝑚 +1 ,
откуда 𝑚 = ˘ +2 − 1 (˘ | 𝑛-е число Фибоначчи, ˘1 = 1, ˘2 = 1,
˘ +2 = ˘ + ˘ +1 ).

14.2.2. Докажите, что сбалансированное дерево с 𝑛 вершинами имеет высоту не больше 𝐶 log 𝑛 для некоторой константы 𝐶 , не зависящей
от 𝑛.
Решение. Индукцией по 𝑛 легко доказать, что ˘ +2 > 𝑎 , где
𝑎 |√ больший корень квадратного уравнения 𝑎2 = 1 + 𝑎, то есть 𝑎 =
= ( 5 + 1)/2. Остаётся воспользоваться предыдущей задачей.

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

𝑛

Вращения

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

𝑄B
@
B

@ 𝑃 B
@ B 
@
B
@B 
@ B
@Bt

@B


𝑏
@Bt

@

𝑎

𝑅


@ 𝑃 B
 𝑄
@
B


@ B 

@B 

@Bt

𝑎 HHH
Ht

𝑅

𝑏

Пусть вершина 𝑎 имеет правого сына 𝑏. Обозначим через 𝑃 левое поддерево вершины 𝑎, через 𝑄 и 𝑅 | левое и правое поддеревья вершины 𝑏.
Упорядоченность дерева требует, чтобы 𝑃 < 𝑎 < 𝑄 < 𝑏 < 𝑅 (точнее сле-

259

14.2. Сбалансированные деревья

довало бы сказать «любая пометка на 𝑃 меньше пометки на 𝑎», «пометка
на 𝑎 меньше любой пометки на 𝑄» и т. д., но мы позволим себе этого
не делать). Точно того же требует упорядоченность дерева с корнем 𝑏,
его левым сыном 𝑎, в котором 𝑃 и 𝑄 | левое и правое поддеревья 𝑎,
𝑅 | правое поддерево 𝑏. Поэтому первое дерево можно преобразовать во второе, не нарушая упорядоченности. Такое преобразование
назовём малым правым вращением (правым | поскольку существует
симметричное, левое, малым | поскольку есть и большое, которое мы
сейчас опишем).
Пусть 𝑏 | правый сын 𝑎, 𝑐 | левый сын 𝑏, 𝑃 | левое поддерево 𝑎,
𝑄 и 𝑅 | левое и правое поддеревья 𝑐, 𝑆 | правое поддерево 𝑏. Тогда
𝑃 < 𝑎 < 𝑄 < 𝑐 < 𝑅 < 𝑏 < 𝑆.
@ 𝑄B
𝑅
@ B 

@B 

@Bs

H
@ 𝑃 B 𝑐 HHs
@ B
 𝑏
@ B 
@Bs

𝑎

𝑆

@ 𝑃 B 𝑄
@ B

@B
@Bs
𝑎 QQ
Q

J 𝑅 
J

J 
Js

 𝑏

Q
s

𝑆

𝑐

Такой же порядок соответствует дереву с корнем 𝑐, имеющим левого
сына 𝑎 и правого сына 𝑏, для которого 𝑃 и 𝑄 | поддеревья вершины 𝑎,
а 𝑅 и 𝑆 | поддеревья вершины 𝑏. Соответствующее преобразование
будем называть большим правым вращением. (Аналогично определяется симметричное ему большое левое вращение.)
14.2.3. Дано дерево, сбалансированное всюду, кроме корня, в котором разница высот равна 2 (т. е. левое и правое поддеревья корня
сбалансированы и их высоты отличаются на 2). Докажите, что оно может быть превращено в сбалансированное одним из четырёх описанных
преобразований, причём высота его останется прежней или уменьшится на 1.
Решение. Пусть более низким является, например, левое поддерево,
и его высота равна 𝑘. Тогда высота правого поддерева равна 𝑘 + 2.
Обозначим корень через 𝑎, а его правого сына (он обязательно есть)
через 𝑏. Рассмотрим левое и правое поддеревья вершины 𝑏. Одно из них
обязательно имеет высоту 𝑘 + 1, а другое может иметь высоту 𝑘 или
𝑘 + 1 (меньше 𝑘 быть не может, так как поддеревья сбалансированы).

260

14. Деревья. Сбалансированные деревья

Если высота левого поддерева равна 𝑘 + 1, а правого | 𝑘, то потребуется большое правое вращение; в остальных случаях помогает малое.
Вот как выглядят три случая балансировки дерева:

@A 
@A 
@A @
@A As 𝑏
@
As 𝑎



J B

JB 
JB 
@ A JBs
@A
𝑏
@
As 𝑎



@ ?A
?
@
@ AA

@A 
@J @
As
@J @
@J @s 𝑏
@
Js 𝑎



@A

@A 
@
As
@
@s


@A 
@A 
@
As
@
@s

@ JA ?
? 
@JAA

@JA

s
@
J
As
@
@s



14.2.4. В сбалансированное дерево добавили или из него удалили
лист. Докажите, что можно восстановить сбалансированность с помощью нескольких вращений, причём их число не больше высоты дерева.
Решение. Будем доказывать более общий факт:
Лемма. Если в сбалансированном дереве 𝑋 одно из его поддеревьев 𝑌 заменили на сбалансированное дерево 𝑍 , причём высота 𝑍 отличается от высоты 𝑌 не более чем на 1, то полученное такой «прививкой» дерево можно превратить в сбалансированное вращениями (причём количество вращений не превосходит высоты, на которой делается
прививка).
Частным случаем прививки является замена пустого поддерева на
лист или наоборот, так что достаточно доказать эту лемму.
Доказательство леммы. Индукция по высоте, на которой делается
прививка. Если она происходит в корне (заменяется всё дерево цели-

261

14.2. Сбалансированные деревья

ком), то всё очевидно («привой» сбалансирован по условию). Пусть заменяется некоторое поддерево, например, левое поддерево некоторой
вершины 𝑥. Возможны два случая.
1) После прививки сбалансированность в вершине 𝑥 не нарушилась
(хотя, возможно, нарушилась сбалансированность в предках 𝑥:
высота поддерева с корнем в 𝑥 могла измениться). Тогда можно
сослаться на предположение индукции, считая, что мы прививали
целиком поддерево с корнем в 𝑥.
2) Сбалансированность в 𝑥 нарушилась. При этом разница высот
равна 2 (больше она быть не может, так как высота 𝑍 отличается
от высоты 𝑌 не более чем на 1). Разберём два варианта.

𝑍
𝑌

𝑍

@ A
@

@ AA
@A 
@
As




𝑘

AA
A 𝑌
A

A 
As

𝑘

(а)
(б)
а) Выше правое (не заменявшееся) поддерево вершины 𝑥. Пусть
высота левого (т. е. 𝑍 ) равна 𝑘, правого | 𝑘 + 2. Высота старого левого поддерева вершины 𝑥 (т. е. 𝑌 ) была равна 𝑘 + 1.
Поддерево с корнем 𝑥 имело в исходном дереве высоту 𝑘 + 3,
и эта высота не изменилась после прививки.
По предыдущей задаче вращение преобразует поддерево с
корнем в 𝑥 в сбалансированное поддерево высоты 𝑘 + 2 или
𝑘 + 3. То есть высота поддерева с корнем 𝑥 | в сравнении
с его прежней высотой | не изменилась или уменьшилась
на 1, и мы можем воспользоваться предположением индукции.
б) Выше левое поддерево вершины 𝑥. Пусть высота левого
(т. е. 𝑍 ) равна 𝑘 + 2, правого | 𝑘. Высота старого левого
поддерева (т. е. 𝑌 ) была равна 𝑘 + 1. Поддерево с корнем 𝑥
в исходном дереве 𝑋 имело высоту 𝑘 + 2, после прививки она
стала равна 𝑘 + 3. После подходящего вращения (см. предыдущую задачу) поддерево с корнем в 𝑥 станет сбалансированным, его высота будет равна 𝑘 + 2 или 𝑘 + 3, так что
изменение высоты по сравнению с высотой поддерева с корнем 𝑥 в дереве 𝑋 не превосходит 1 и можно сослаться на
предположение индукции.


262

14. Деревья. Сбалансированные деревья

14.2.5. Составьте программы добавления и удаления элементов, сохраняющие сбалансированность. Число действий не должно превосходить 𝐶 · (высота дерева). Разрешается хранить в вершинах дерева дополнительную информацию, необходимую при балансировке.
Решение. Будем хранить для каждой вершины разницу между высотой её правого и левого поддеревьев:

diff [i] = (высота правого поддерева вершины i) −
− (высота левого поддерева вершины i).

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

@ 𝑃
@
@

@ 𝑄
@
@
@ 𝑃
@
@

@ 𝑄
@
@

𝑅
𝑏 𝛽



@ 𝑃
@
@

𝑄
𝑎 𝛽



𝑅

@
I
@

𝑎 𝛼

𝑏 𝛼

𝑅
𝑐 𝛾
I
@
@


𝑎 𝛼

𝑆
𝑏 𝛽



@ 𝑃
@
@

𝑄

@ 𝑅
@
@

𝑎 𝛾
HH
Y

H

𝑆
𝑐 𝛽


*


𝑏 𝛼

(2) После преобразований мы должны также изменить соответственно
значения в массиве diff. Для этого достаточно знать высоты деревьев
𝑃, 𝑄, . . . с точностью до константы, поэтому можно предполагать, что
одна из высот равна нулю.

14.2. Сбалансированные деревья

263

Вот процедуры вращений:
procedure SR (a:integer); {малое правое вращение с корнем a}
var b: 1..n; val_a,val_b: T; h_P,h_Q,h_R: integer;
begin
b := right [a]; {b null}
val_a := val [a]; val_b := val [b];
h_Q := 0; h_R := diff[b]; h_P := (max(h_Q,h_R)+1)-diff[a];
val [a] := val_b; val [b] := val_a;
right [a] := right [b] {поддерево R}
right [b] := left [b] {поддерево Q}
left [b] := left [a] {поддерево P}
left [a] := b;
diff [b] := h_Q - h_P;
diff [a] := h_R - (max (h_P, h_Q) + 1);
end;
procedure BR(a:integer);{большое правое вращение с корнем a}
var b,c: 1..n; val_a,val_b,val_c: T;
h_P,h_Q,h_R,h_S: integer;
begin
b := right [a]; c := left [b]; {b,c null}
val_a := val [a]; val_b := val [b]; val_c := val [c];
h_Q := 0; h_R := diff[c]; h_S := (max(h_Q,h_R)+1)+diff[b];
h_P := 1 + max (h_S, h_S-diff[b]) - diff [a];
val [a] := val_c; val [c] := val_a;
left [b] := right [c] {поддерево R}
right [c] := left [c] {поддерево Q}
left [c] := left [a] {поддерево P}
left [a] := c;
diff [b] := h_S - h_R;
diff [c] := h_Q - h_P;
diff [a] := max (h_S, h_R) - max (h_P, h_Q);
end;

Левые вращения (большое и малое) записываются симметрично.
Процедуры добавления и удаления элементов пишутся как раньше,
но только добавление и удаление должно сопровождаться коррекцией
массива diff и восстановлением сбалансированности.
При этом используется процедура с такими свойствами:
дано: левое и правое поддеревья вершины с номером a
сбалансированы, в самой вершине разница высот не больше 2, в поддереве с корнем a массив diff заполнен правильно;

264

14. Деревья. Сбалансированные деревья
надо: поддерево с корнем a сбалансировано и массив diff
соответственно изменён, d | изменение его высоты (равно 0
или -1); в остальной части всё осталось как было | в частности, значения diff.

procedure balance (a: integer; var d: integer);
begin {-2 val [x]}
..добавить в стек пару
x := right [x];
end;
end;
if t val [x] then begin {t нет в дереве}
get_free (i); val [i] := t;
left [i] := null; right [i] := null; diff [i] := 0;
if t < val [x] then begin
..добавить в стек пару
left [x] := i;
end else begin {t > val [x]}
..добавить в стек пару
right [x] := i;
end;
d := 1;
{инвариант: стек содержит путь к изменившемуся
поддереву, высота которого увеличилась по сравнению
с высотой в исходном дереве на d (=0 или 1); это
поддерево сбалансировано; значения diff для его
вершин правильны; в остальном дереве всё осталось
как было - в частности, значения diff}
while (d 0) and ..стек непуст do begin {d = 1}
..взять из стека пару в
if direct = l then begin
if diff [v] = 1 then begin
c := 0;
end else begin
c := 1;
end;
diff [v] := diff [v] - 1;
end else begin
{direct = r}

265

266

14. Деревья. Сбалансированные деревья
if diff [v] = -1 then begin
c := 0;
end else begin
c := 1;
end;
diff [v] := diff [v] + 1;
end;
{c = изменение высоты поддерева с корнем в v по
сравнению с исходным деревом; массив diff
содержит правильные значения для этого поддерева;
возможно нарушение сбалансированности в v}
balance (v, d1); d := c + d1;
end;
end;
end;

Легко проверить, что значение d может быть равно только 0 или 1 (но
не -1): если c=0, то diff[v]=0 и балансировка не производится.
Программа удаления строится аналогично. Её основной фрагмент
таков:
{инвариант: стек содержит путь к изменившемуся поддереву,
высота которого изменилась по сравнению с высотой в
исходном дереве на d (=0 или -1); это поддерево
сбалансировано; значения diff для его вершин правильны;
в остальном дереве всё осталось как было в частности, значения diff}
while (d 0) and ..стек непуст do begin
{d = -1}
..взять из стека пару в
if direct = l then begin
if diff [v] = -1 then begin
c := -1;
end else begin
c := 0;
end;
diff [v] := diff [v] + 1;
end else begin {direct = r}
if diff [v] = 1 then begin
c := -1;
end else begin
c := 0;
end;
diff [v] := diff [v] - 1;
end;

14.2. Сбалансированные деревья

267

{c = изменение высоты поддерева с корнем в v по
сравнению с исходным деревом; массив diff содержит
правильные значения для этого поддерева;
возможно нарушение сбалансированности в v}
balance (v, d1);
d := c + d1;
end;

Легко проверить, что значение d может быть равно только 0 или -1 (но
не -2): если c=-1, то diff[v]=0 и балансировка не производится.
Отметим также, что наличие стека делает излишними переменные
father и direction (их роль теперь играет вершина стека).

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

Замечание. Мы старались записать программы добавления и удаления так, чтобы они были как можно более похожими друг на друга.
Используя специфику каждой из них, можно многое упростить.
Существуют и другие способы представления множеств, гарантирующие число действий порядка log 𝑛 на каждую операцию. Опишем
один из них (называемый Б-деревьями ).
До сих пор каждая вершина содержала один элемент хранимого множества. Этот элемент служил границей между левым и правым поддеревом. Будем теперь хранить в вершине 𝑘 > 1 элементов множества
(число 𝑘 может меняться от вершины к вершине, а также при добавлении и удалении новых элементов, см. далее). Эти 𝑘 элементов служат
разделителями для 𝑘 + 1 поддеревьев. Пусть фиксировано некоторое
число 𝑡 > 1. Будем рассматривать деревья, обладающие такими свойствами:
1) Каждая вершина содержит от 𝑡 до 2𝑡 элементов (за исключением корня, который может содержать любое число элементов от 0
до 2𝑡).
2) Вершина с 𝑘 элементами либо имеет 𝑘 + 1 сыновей, либо не имеет
сыновей вообще (является листом ).
3) Все листья находятся на одной и той же высоте.

268

14. Деревья. Сбалансированные деревья

Добавление элемента происходит так. Если лист, в который он попадает, неполон (т. е. содержит менее 2𝑡 элементов), то нет проблем.
Если он полон, то 2𝑡 + 1 элемент (все элементы листа и новый элемент)
разбиваем на два листа по 𝑡 элементов и разделяющий их серединный
элемент. Этот серединный элемент надо добавить в вершину предыдущего уровня. Это возможно, если в ней менее 2𝑡 элементов. Если и она
полна, то её разбивают на две, выделяют серединный элемент и т. д. Если в конце концов мы захотим добавить элемент в корень, а он окажется полным, то корень расщепляется на две вершины, а высота дерева
увеличивается на 1.
Удаление элемента, находящегося не в листе, сводится к удалению
непосредственно следующего за ним, который находится в листе. Поэтому достаточно научиться удалять элемент из листа. Если лист при
этом становится слишком маленьким, то его можно пополнить за счёт
соседнего листа | если только и он не имеет минимально возможный
размер 𝑡. Если же оба листа имеют размер 𝑡, то на них вместе 2𝑡 элементов, вместе с разделителем | 2𝑡 + 1. После удаления одного элемента остаётся 2𝑡 элементов | как раз на один лист. Если при этом
вершина предыдущего уровня становится меньше нормы, процесс повторяется и т. д.
14.2.7. Реализуйте описанную схему хранения множеств, убедившись, что она также позволяет обойтись 𝐶 log 𝑛 действий для операций
включения, исключения и проверки принадлежности.


14.2.8. Можно определять сбалансированность дерева иначе: требовать, чтобы для каждой вершины её левое и правое поддеревья имели
не слишком сильно отличающиеся количества вершин. (Преимущество
такого определения состоит в том, что при вращениях не нарушается
сбалансированность в вершинах, находящихся ниже точки вращения.)
Реализуйте на основе этой идеи способ хранения множеств, гарантирующий оценку в 𝐶 log 𝑛 действий для включения, удаления и проверки
принадлежности.
[Указание. Он также использует большие и малые вращения. Подробности см. в книге Рейнгольда, Нивергельта и Део «Комбинаторные
алгоритмы».]


15. КОНТЕКСТНО-СВОБОДНЫЕ
ГРАММАТИКИ
15.1. Общий алгоритм разбора

Чтобы определить то, что называют контекстно-свободной грам(КС-грамматикой), надо:

матикой


указать конечное множество 𝐴, называемое алфавитом ; его элементы называют символами ; конечные последовательности символов называют словами (в данном алфавите);



разделить все символы алфавита 𝐴 на две группы: терминальные
(«окончательные») и нетерминальные («промежуточные»);



выбрать среди нетерминальных символов один, называемый начальным ;



указать конечное число правил грамматики, каждое из которых
должно иметь вид 𝐾 → 𝑋 , где 𝐾 | некоторый нетерминальный
символ, а 𝑋 | слово (в него могут входить и терминальные, и нетерминальные символы).

Пусть фиксирована КС-грамматика (мы часто будем опускать префикс «КС-», так как других грамматик у нас не будет). Выводом в этой
грамматике называется последовательность слов 𝑋0 , 𝑋1 , . . . , 𝑋 , в которой 𝑋0 состоит из одного символа, и этот символ | начальный,
а 𝑋 +1 получается из 𝑋 заменой некоторого нетерминального символа 𝐾 на слово 𝑋 по одному из правил грамматики. Слово, составленное из терминальных символов, называется выводимым, если существует вывод, который им кончается. Множество всех выводимых слов
(из терминальных символов) называется языком, порождаемым данной
грамматикой.
𝑛

𝑖

𝑖

270

15. Контекстно-свободные грамматики

В этой и следующей главе нас будет интересовать такой вопрос:
дана КС-грамматика; построить алгоритм, который по любому слову
проверяет, выводимо ли оно в этой грамматике.
Пример 1. Алфавит:
( ) [ ] E

(четыре терминальных символа и один нетерминальный символ E). Начальный символ: E. Правила:
E → (E)
E → [E]
E → EE
E→

(в последнем правиле справа стоит пустое слово).
Примеры выводимых слов:
(пустое слово)

()
([ ])
()[([ ])]
[()[ ]()[ ]]

Примеры невыводимых слов:
(
)(
(]
([)]

Эта грамматика встречалась в разделе 6.1 (где выводимость в ней проверялась с помощью стека).
Пример 2. Другая грамматика, порождающая тот же язык:
Алфавит: ( ) [ ] T E
Правила:
E→
E → TE
T → (E)
T → [E]

15.1. Общий алгоритм разбора

271

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

𝐸 содержит пустое слово
если слово 𝐴 принадлежит 𝑇 ,
а слово 𝐵 принадлежит 𝐸 , то
слово 𝐴𝐵 принадлежит 𝐸
T → [E] если 𝐴 принадлежит 𝐸 , то слово
[𝐴] принадлежит 𝑇
T → (E) если 𝐴 принадлежит 𝐸 , то слово
(𝐴) принадлежит 𝑇
E→
E → TE

Сформулированные свойства множеств 𝐸 , 𝑇 не определяют эти множества однозначно (например, они остаются верными, если в качестве 𝐸 и 𝑇 взять множество всех слов). Однако можно доказать, что
множества, задаваемые грамматикой, являются минимальными среди
удовлетворяющих этим условиям.
15.1.1. Сформулируйте точно и докажите это утверждение для произвольной контекстно-свободной грамматики.

15.1.2. Постройте грамматику, в которой выводимы слова
(а) 00..0011..11 (число нулей равно числу единиц);
(б) 00..0011..11 (число нулей вдвое больше числа единиц);
(в) 00..0011..11 (число нулей больше числа единиц);
(и только они).

15.1.3. Докажите, что не существует КС-грамматики, в которой
были бы выводимы слова вида 00..0011..1122..22, в которых числа
нулей, единиц и двоек равны, и только они.
[Указание. Докажите следующую лемму о произвольной КС-грамматике: для любого достаточно длинного слова 𝐹 , выводимого в этой
грамматике, существует такое его представление в виде 𝐴𝐵𝐶𝐷𝐸 , что

272

15. Контекстно-свободные грамматики

любое слово вида 𝐴𝐵 . . . 𝐵𝐶𝐷 . . . 𝐷𝐸 , где 𝐵 и 𝐷 повторены одинаковое
число раз, также выводимо в этой грамматике. (Это можно установить, найдя нетерминальный символ, оказывающийся своим собственным «наследником» в процессе вывода.)]

Нетерминальный символ можно рассматривать как «родовое имя»
для выводимых из него слов. В следующем примере для наглядности
в качестве нетерминальных символов использованы фрагменты русских слов, заключённые в угловые скобки. (С точки зрения грамматики
каждый такой фрагмент | один символ!)
Пример 3. Алфавит:
терминалы: + * ( ) x
нетерминалы: ⟨выр⟩ ⟨оствыр⟩ ⟨слаг⟩ ⟨остслаг⟩ ⟨множ⟩
Правила:
⟨выр⟩ → ⟨слаг⟩ ⟨оствыр⟩
⟨оствыр⟩ → + ⟨выр⟩
⟨оствыр⟩ →
⟨слаг⟩ → ⟨множ⟩ ⟨остслаг⟩
⟨остслаг⟩ → * ⟨слаг⟩
⟨остслаг⟩ →
⟨множ⟩ → x
⟨множ⟩ → ( ⟨выр⟩ )
Согласно этой грамматике, выражение ⟨выр⟩ | это последовательность слагаемых ⟨слаг⟩, разделённых плюсами, слагаемое | это последовательность множителей ⟨множ⟩, разделённых звёздочками (знаками
умножения), а множитель | это либо буква x, либо выражение в скобках.
15.1.4. Приведите пример другой грамматики, задающей тот же
язык.
Ответ. Вот один из вариантов:
⟨выр⟩ → ⟨выр⟩ + ⟨выр⟩
⟨выр⟩ → ⟨выр⟩ * ⟨выр⟩
⟨выр⟩ → x
⟨выр⟩ → ( ⟨выр⟩ )

Эта грамматика хоть и проще, но в некоторых отношениях хуже,
о чём мы ещё будем говорить.

273

15.1. Общий алгоритм разбора

15.1.5. Дана произвольная КС-грамматика. Постройте алгоритм
проверки принадлежности задаваемому ей языку, работающий полиномиальное время (т. е. число действий не превосходит полинома от
длины проверяемого слова; полином может зависеть от грамматики).
Решение. Заметим, что требование полиномиальности исключает
возможность решения, основанном на переборе всех возможных выводов. Тем не менее полиномиальный алгоритм существует. Поскольку практического значения он не имеет (используемые на практике
КС-грамматики обладают дополнительными свойствами, позволяющими строить более эффективные алгоритмы), мы изложим лишь общую
схему решения.
(1) Пусть в грамматике есть нетерминалы 𝐾1 , . . . , 𝐾 . Построим новую грамматику с нетерминалами 𝐾1′ , . . . , 𝐾 ′ так, чтобы выполнялось
такое свойство: из 𝐾 ′ выводятся (в новой грамматике) те же слова, что
из 𝐾 в старой, за исключением пустого слова, которое не выводится.
Чтобы выполнить такое преобразование грамматики, надо выяснить, из каких нетерминалов исходной грамматики выводится пустое
слово, а затем каждое правило заменить на совокупность правил, получающихся, если в правой части опустить какие-либо из нетерминалов,
из которых выводится пустое слово, а у остальных поставить штрихи.
Например, если в исходной грамматике было правило
𝑛

𝑛

𝑖

𝑖

K → L M N,

причём из L и N выводится пустое слово, а из M нет, то это правило надо
заменить на правила
K ′ → L′ M ′ N′
K ′ → M′ N ′
K ′ → L′ M ′
K ′ → M′

(2) Итак, мы свели дело к грамматике, где ни из одного нетерминала
не выводится пустое слово. Теперь устраним «циклы» вида
K→L
L→M
M→N
N→K

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

274

15. Контекстно-свободные грамматики

(3) Теперь проверка принадлежности какого-либо слова языку, порождённому грамматикой, может выполняться так: для каждого подслова проверяемого слова и для каждого нетерминала выясняем, порождается ли это подслово этим нетерминалом. При этом подслова проверяются в порядке возрастания длин, а нетерминалы | в таком порядке,
чтобы при наличии правила 𝐾 → 𝐿 нетерминал 𝐿 проверялся раньше
нетерминала 𝐾 . (Это возможно в силу отсутствия циклов.) Поясним
этот процесс на примере.
Пусть в грамматике есть правила
K→L
K→M N L

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


если 𝐴 выводится из L;



если 𝐴 можно разбить на непустые слова 𝐵 , 𝐶 , 𝐷, для которых
𝐵 выводится из M, 𝐶 выводится из N, а 𝐷 выводится из L.

Вся эта информация уже есть (слова 𝐵 , 𝐶 , 𝐷 короче 𝐴, а L рассмотрен
до K).
Легко видеть, что число действий этого алгоритма полиномиально.
Степень полинома зависит от числа нетерминалов в правых частях правил и может быть понижена, если грамматику преобразовать к форме,
в которой правая часть каждого правила не более 2 нетерминалов (это
легко сделать, вводя новые нетерминалы: например, правило K → LMK
можно заменить на K → LN и N → MK, где N | новый нетерминал). 
15.1.6. Рассмотрим грамматику с единственным нетерминалом K,
нетерминалами 0, 1, 2, 3 и правилами
K→0
K→1 K
K→2 K K
K→3 K K K

Как проверить выводимость слова в этой грамматике, читая слово слева направо? (Число действий при прочтении одной буквы должно быть
ограничено.)

15.2. Метод рекурсивного спуска

275

Решение. Хранится целая переменная n, инвариант: слово выводимо ⇔ непрочитанная часть представляет собой конкатенацию (соединение) n выводимых слов.

15.1.7. Тот же вопрос для грамматики

K→0
K→K 1
K→K K 2
K→K K K 3



15.2. Метод рекурсивного спуска

В отличие от алгоритма предыдущего раздела (представляющего чисто теоретический интерес), алгоритмы на основе рекурсивного
спуска часто используются на практике. Этот метод применим, однако,
далеко не ко всем грамматикам. Мы обсудим необходимые ограничения
позднее.
Идея метода рекурсивного спуска такова. Для каждого нетерминала K мы строим процедуру ReadK, которая | в применении к любому
входному слову 𝑥 | делает две вещи:
∙ находит наибольшее начало 𝑧 слова 𝑥, которое может быть началом выводимого из K слова;
∙ сообщает, является ли найденное слово 𝑧 выводимым из K.
Прежде чем описывать этот метод более подробно, договоримся
о том, как процедуры получают сведения о входном слове и как сообщают о результатах своей работы. Мы предполагаем, что буквы входного слова поступают к ним по одной, т. е. имеется граница, отделяющая «прочитанную» часть от «непрочитанной». Будем считать, что
есть функция (без параметров)
Next: Symbol

дающая первый непрочитанный символ. Её значениями могут быть терминальные символы, а также специальный символ EOI (End Of Input |
конец входа), означающий, что всё слово уже прочитано. Вызов этой
функции, разумеется, не сдвигает границы между прочитанной и непрочитанной частью | для этого есть процедура Move, которая сдвигает границу на один символ. (Она применима, если NextEOI.) Пусть,
наконец, имеется булевская переменная b.

276

15. Контекстно-свободные грамматики

Теперь мы можем сформулировать наши требования к процедуре

ReadK. Они состоят в следующем:

прочитывает из оставшейся части слова максимальное начало 𝐴, являющееся началом некоторого слова, выводимого из K;

∙ ReadK


значение b становится истинным или ложным в зависимости от
того, является ли 𝐴 выводимым из K или лишь невыводимым началом выводимого (из K) слова.

Для удобства введём такую терминологию: выводимое из K слово будем называть K-словом, а любое начало любого выводимого из K слова |
K-началом. Два сформулированных требования вместе будем выражать
словами «ReadK корректна для K».
Начнём с примера. Пусть правило
K → L M

является единственным правилом грамматики, содержащим K в левой
части, пусть L, M | нетерминалы и ReadL, ReadM | корректные (для
них) процедуры.
Рассмотрим такую процедуру:
procedure ReadK;
begin
ReadL;
if b then begin
ReadM;
end;
end;
15.2.1.

для K.

Приведите пример, когда эта процедура будет некорректной

Ответ. Пусть из L выводится любое слово вида 00..00, а из M выводится лишь слово 01. Тогда из K выводится слово 00001, но процедура
ReadK этого не заметит.


Укажем достаточные условия корректности процедуры ReadK. Для
этого нам понадобятся некоторые обозначения. Пусть фиксированы
КС-грамматика и некоторый нетерминал 𝑁 этой грамматики. Рассмотрим 𝑁 -слово 𝐴, которое имеет собственное начало 𝐵 , также являю-

15.2. Метод рекурсивного спуска

277

щееся 𝑁 -словом (если такие есть). Для любой пары таких слов 𝐴 и 𝐵
рассмотрим терминальный символ, идущий в 𝐴 непосредственно за 𝐵 .
Множество всех таких терминалов обозначим Посл(𝑁 ). (Если никакое
𝑁 -слово не является собственным началом другого 𝑁 -слова, то множество Посл(𝑁 ) пусто.)
15.2.2. Укажите (а) Посл(E) для примера 1 (с. 270); (б) Посл(E) и
Посл(T) для примера 2 (с. 270); (в) Посл(⟨слаг⟩) и Посл(⟨множ⟩) для
примера 3 (с. 272);
Ответ. (а) Посл(E) = {[, (}. (б) Посл(E) = {[, (}; Посл(T) пусто (никакое T-слово не является началом другого). (в) Посл(⟨слаг⟩) = {*};
Посл(⟨множ⟩) пусто.

Кроме того, для каждого нетерминала 𝑁 обозначим через Нач(𝑁 )
множество всех терминалов, являющихся первыми буквами непустых
𝑁 -слов. Это обозначение | вместе с предыдущим | позволит дать
достаточное условие корректности процедуры ReadK в описанной выше
ситуации.
15.2.3. Докажите, что если Посл(L) не пересекается с Нач(M) и множество всех M-слов непусто, то ReadK корректна.
Решение. Рассмотрим два случая.
(1) Пусть после ReadL значение переменной b ложно. В этом случае ReadL читает со входа максимальное L-начало 𝐴, не являющееся
L-словом. Оно является K-началом (здесь важно, что множество M-слов
непусто.). Будет ли оно максимальным K-началом среди начал входа?
Если нет, то 𝐴 является началом слова 𝐵𝐶 , где 𝐵 есть L-слово, 𝐶 есть
M-начало и 𝐵𝐶 | более длинное начало входа, чем 𝐴. Если 𝐵 длиннее 𝐴,
то 𝐴 | не максимальное начало входа, являющееся L-началом, что противоречит корректности ReadL. Если 𝐵 = 𝐴, то 𝐴 было бы L-словом,
а это не так. Значит, 𝐵 короче 𝐴, 𝐶 непусто и первый символ слова 𝐶
следует в 𝐴 за последним символом слова 𝐵 , т. е. Посл(L) пересекается
с Нач(M). Противоречие. Итак, 𝐴 максимально. Из сказанного следует также, что 𝐴 не является K-словом. Корректность процедуры ReadK
в этом случае проверена.
(2) Пусть после ReadL значение переменной b истинно. Тогда прочитанное процедурой ReadK начало входа имеет вид 𝐴𝐵 , где 𝐴 есть L-слово, а 𝐵 есть M-начало. Тем самым 𝐴𝐵 есть K-начало. Проверим его максимальность. Пусть 𝐶 есть большее K-начало. Тогда либо 𝐶 есть L-начало (что невозможно, так как 𝐴 было максимальным L-началом), либо
𝐶 = 𝐴′ 𝐵 ′ , где 𝐴′ | L-слово, 𝐵 ′ | M-начало. Если 𝐴′ короче 𝐴, то 𝐵 ′ непусто и начинается с символа, принадлежащего и Нач(M), и Посл(L),

278

15. Контекстно-свободные грамматики

что невозможно. Если 𝐴′ длиннее 𝐴, то 𝐴 | не максимальное L-начало.
Итак, 𝐴′ = 𝐴. Но в этом случае 𝐵 ′ есть продолжение 𝐵 , что противоречит корректности ReadM. Таким образом, 𝐴𝐵 | максимальное K-начало. Остаётся проверить правильность выдаваемого процедурой ReadK
значения переменной b. Если оно истинно, то это очевидно. Если оно
ложно, то 𝐵 не есть M-слово, и надо проверить, что 𝐴𝐵 | не K-слово.
В самом деле, если бы выполнялось 𝐴𝐵 = 𝐴′ 𝐵 ′ , где 𝐴′ | L-слово, 𝐵 ′ |
M-слово, то 𝐴′ не может быть длиннее 𝐴 (ReadL читает максимальное
слово), 𝐴′ не может быть равно 𝐴 (тогда 𝐵 ′ равно 𝐵 и не является
M-словом) и 𝐴′ не может быть короче 𝐴 (тогда первый символ 𝐵 ′ принадлежит и Нач(M), и Посл(L)). Задача решена.

Перейдём теперь к другому частному случаю. Пусть в КС-грамматике есть правила
K→L
K→M
K→N

и других правил с левой частью K нет.
15.2.4. Считая, что ReadL, ReadM и ReadN корректны (для L, M и N)
и что множества Нач(L), Нач(M) и Нач(N) не пересекаются, напишите
процедуру, корректную для K.
Решение.

Схема процедуры такова:

procedure ReadK;
begin
if (Next принадлежит Нач(L)) then begin
ReadL;
end else if (Next принадлежит Нач(M)) then begin
ReadM;
end else if (Next принадлежит Нач(N)) then begin
ReadN;
end else begin
b := true или false в зависимости от того,
выводимо ли пустое слово из K или нет
end;
end;

Докажем, что ReadK корректно реализует K. Если Next не принадлежит
ни одному из множеств Нач(L), Нач(M), Нач(N), то пустое слово является

15.2. Метод рекурсивного спуска

279

наибольшим началом входа, являющимся K-началом. Если Next принадлежит одному (и, следовательно, только одному) из этих множеств, то
максимальное начало входа, являющееся K-началом, непусто и читается
соответствующей процедурой.

15.2.5. Используя сказанное, составьте процедуру распознавания
выражений для грамматики (пример 3, с. 272):

⟨выр⟩ → ⟨слаг⟩ ⟨оствыр⟩

⟨оствыр⟩ → + ⟨выр⟩
⟨оствыр⟩ →

⟨слаг⟩ → ⟨множ⟩ ⟨остслаг⟩

⟨остслаг⟩ → * ⟨слаг⟩

⟨остслаг⟩ →

⟨множ⟩ → x

⟨множ⟩ → ( ⟨выр⟩ )
Решение. Эта грамматика не полностью подпадает под рассмотренные частные случаи: в правых частях есть комбинации терминалов и нетерминалов

+ ⟨выр⟩

и группы из трёх символов
( ⟨выр⟩ )

В грамматике есть также несколько правил с одной левой частью и с
правыми частями разного рода, например
⟨оствыр⟩ → + ⟨выр⟩
⟨оствыр⟩ →

Эти ограничения не являются принципиальными. Так, правило типа
K → L M N можно было бы заменить на два правила K → L Q и Q → M N,
терминальные символы в правой части | на нетерминалы (с единственным правилом замены на соответствующие терминалы). Несколько правил с одной левой частью и разнородными правыми также можно

280

15. Контекстно-свободные грамматики

свести к уже разобранному случаю: например,
K→L M N
K→P Q
K→

можно заменить на правила
K → K1
K → K2
K → K3
K1 → L M N
K2 → P Q
K3 →

Но мы не будем этого делать | а сразу же запишем то, что получится,
если подставить описания процедур для новых терминальных символов
в места их использования. Например, для правила
K→L M N

это даёт процедуру
procedure ReadK;
begin
ReadL;
if b then begin
ReadM;
end;
if b then begin
ReadN;
end;
end;

Для её корректности надо, чтобы Посл(L) не пересекалось с Нач(MN)
(которое равно Нач(M), если из M не выводится пустое слово, и равно
объединению Нач(M) и Нач(N), если выводится), а также чтобы Посл(M)
не пересекалось с Нач(N).
Аналогичным образом правила
K→L M N
K→P Q
K→

15.2. Метод рекурсивного спуска

281

приводят к процедуре
procedure ReadK;
begin
if (Next принадлежит Нач(LMN)) then begin
ReadL;
if b then begin ReadM; end;
if b then begin ReadN; end;
end else if (Next принадлежит Нач(PQ)) then begin
ReadP;
if b then begin ReadQ; end;
end else begin
b := true;
end;
end;

корректность которой требует, чтобы Нач(LMN) не пересекалось с
Нач(PQ).
Читая приведённую далее программу, полезно иметь в виду соответствие между русскими и английскими словами:

ВЫРажение
ОСТаток ВЫРажения
СЛАГаемое
ОСТаток СЛАГаемого
МНОЖитель

EXPRession
REST of EXPRession
ADDitive term
REST of ADDitive term
FACTor

procedure ReadSymb (c: Symbol);
b := (Next = c);
if b then begin
Move;
end;
end;
procedure ReadExpr;
ReadAdd;
if b then begin ReadRestExpr; end;
end;
procedure ReadRestExpr;
if Next = ’+’ then begin
ReadSymb (’+’);
if b then begin ReadExpr; end;
end else begin

282

15. Контекстно-свободные грамматики
b := true;
end;
end;
procedure ReadAdd;
ReadFact;
if b then begin ReadRestAdd; end;
end;
procedure ReadRestAdd;
if Next = ’*’ then begin
ReadSymb (’*’);
if b then begin ReadAdd; end;
end else begin
b := true;
end;
end;
procedure ReadFact;
if Next = ’x’ then begin
ReadSymb (’x’);
end else if Next = ’(’ then begin
ReadSymb (’(’);
if b then begin ReadExpr; end;
if b then begin ReadSymb (’)’); end;
end else begin
b := false;
end;
end;

Осталось обсудить проблемы, связанные с взаимной рекурсивностью
этих процедур (одна использует другую и наоборот). В паскале это
допускается, только требуется дать предварительное описание процедур («forward»). Как всегда для рекурсивных процедур, помимо доказательства того, что каждая процедура работает правильно в предположении, что используемые в ней вызовы процедур работают правильно,
надо доказать отдельно, что работа завершается. (Это не очевидно:
если в грамматике есть правило K → KK, то из K ничего не выводится,
Посл(K) и Нач(K) пусты, но написанная по нашим канонам процедура
procedure ReadK;
begin
ReadK;
if b then begin

15.2. Метод рекурсивного спуска

283

ReadK;
end;
end;

не заканчивает работы.)
В данном случае процедуры ReadRestExpr, ReadRestAdd, ReadFact
либо завершаются, либо уменьшают длину непрочитанной части входа.
Поскольку любой цикл вызовов включает одну из них, то зацикливание
невозможно.

15.2.6. Пусть в грамматике имеются два правила с нетерминалом K
в левой части, имеющих вид
K→L K
K→

по которым K-слово представляет собой конечную последовательность
L-слов, причём множества Посл(L) и Нач(K) (в данном случае равное Нач(L)) не пересекаются. Используя корректную для L процедуру ReadL, напишите корректную для K процедуру ReadK, не используя
рекурсии.

Решение.

По нашим правилам следовало бы написать

procedure ReadK;
begin
if (Next принадлежит Нач(L)) then begin
ReadL;
if b then begin ReadK; end;
end else begin
b := true;
end;
end;

завершение работы гарантируется тем, что перед рекурсивным вызовом длина непрочитанной части уменьшается.
Эта рекурсивная процедура эквивалентна нерекурсивной:
procedure ReadK;
begin
b := true;
while b and (Next принадлежит Нач(L)) do begin
ReadL;
end;
end;

284

15. Контекстно-свободные грамматики

Формально можно проверить эту эквивалентность так. Завершаемость
в обоих случаях ясна. Достаточно проверить поэтому, что тело рекурсивной процедуры эквивалентно нерекурсивной в предположении, что
её рекурсивный вызов эквивалентен вызову нерекурсивной процедуры.
Подставим:
if (Next принадлежит Нач(L)) then begin
ReadL;
if b then begin
b := true;
while b and (Next принадлежит Нач(L)) do begin
ReadL;
end;
end;
end else begin
b := true;
end;

Первую команду b:=true можно выкинуть (в этом месте и так b истинно). Вторую команду можно перенести в начало:
b := true;
if (Next принадлежит Нач(L) then begin
ReadL;
if b then begin
while b and (Next принадлежит Нач(L)) do begin
ReadL;
end;
end;
end;

Теперь внутренний if можно выкинуть (если b ложно, цикл while всё
равно не выполняется) и добавить в условие внешнего if условие b
(которое всё равно истинно).
b := true;
if b and (Next принадлежит Нач(L)) then begin
ReadL;
while b and (Next принадлежит Нач(L)) do begin
ReadL;
end;
end;

что эквивалентно приведённой выше нерекурсивной процедуре (из которой вынесена первая итерация цикла).


15.2. Метод рекурсивного спуска

285

15.2.7. Докажите корректность приведённой выше нерекурсивной
программы непосредственно, без ссылок на рекурсивную.
Решение. Рассмотрим наибольшее начало входа, являющееся K-началом. Оно представляется в виде конкатенации (последовательного
приписывания) нескольких непустых L-слов и, возможно, одного непустого L-начала, не являющегося L-словом. Инвариант цикла: прочитано
несколько из них; b ⇔ (последнее прочитанное является L-словом).
Сохранение инварианта: если осталось последнее слово, это очевидно; если осталось несколько, то за первым L-словом (из числа оставшихся) идёт символ из Нач(L), и потому это слово | максимальное начало
входа, являющееся L-началом.

На практике при записи грамматики используют сокращения. Если
правила для какого-то нетерминала K имеют вид

K→L K
K→

(т. е. K-слова | это последовательности L-слов), то этих правил не пишут, а вместо K пишут L в фигурных скобках. Несколько правил с одной
левой частью и разными правыми записывают как одно правило, разделяя альтернативные правые части вертикальной чертой.
Например, рассмотренная выше грамматика для ⟨выр⟩ может быть
записана так:
⟨выр⟩ → ⟨слаг⟩ { + ⟨слаг⟩ }

⟨слаг⟩ → ⟨множ⟩ { * ⟨множ⟩ }

⟨множ⟩ → x |

( ⟨выр⟩ )

15.2.8. Напишите процедуру, корректную для ⟨выр⟩, следуя этой
грамматике и используя цикл вместо рекурсии, где можно.
Решение.

procedure ReadSymb (c: Symbol);
b := (Next = c);
if b then begin Move; end;
end;
procedure ReadExpr;
begin
ReadAdd;

286

15. Контекстно-свободные грамматики
while b and (Next = ’+’) do begin
Move; ReadAdd;
end;
end;
procedure ReadAdd;
begin
ReadFact;
while b and (Next = ’*’) do begin
Move; ReadFact;
end;
end;
procedure ReadFact;
begin
if Next = ’x’ do begin
Move; b := true;
end else if Next = ’(’ then begin
Move; ReadExpr;
if b then begin ReadSymb (’)’); end;
end else begin
b := false;
end;
end;

15.2.9.

Почему?

В последней процедуре команду b:=true можно опустить.

Решение.

b=true.



Можно предполагать, что все процедуры вызываются при


15.3. Алгоритм разбора для LL(1)-грамматик

В этом разделе мы рассмотрим ещё один метод проверки выводимости в КС-грамматике, называемый по традиции LL(1)-разбором. Вот
его идея в одной фразе: можно считать, что в процессе вывода мы
всегда заменяем самый левый нетерминал и нужно лишь выбрать одно
из правил; если нам повезёт с грамматикой, то выбрать правило можно, глядя на первый символ выводимого из этого нетерминала слова.
Говоря более формально, дадим такое
Определение. Левым выводом (слова в грамматике) называется вывод, в котором на каждом шаге замене подвергается самый левый из
нетерминалов.

15.3. Алгоритм разбора для LL(1)-грамматик

287

15.3.1. Для каждого выводимого слова (из терминалов) существует
его левый вывод.
Решение. Различные нетерминалы заменяются независимо; если в
процессе вывода появилось слово . . . 𝐾 . . . 𝐿 . . ., где 𝐾 , 𝐿 | нетерминалы, то замены 𝐾 и 𝐿 можно производить в любом порядке. Поэтому
можно перестроить вывод так, чтобы стоящий левее нетерминал заменялся раньше. (Формально говоря, надо доказывать индукцией по длине вывода такой факт: если из некоторого нетерминала 𝐾 выводится
некоторое слово 𝐴, то существует левый вывод 𝐴 из 𝐾 .)

15.3.2. В грамматике с 4 правилами
(1) E →
(2) E → TE
(3) T → (E)
(4) T → [E]
найдите левый вывод слова 𝐴 = [()([ ])] и докажите, что он единствен.
Решение. На первом шаге можно применить только правило (2):

E → TE

Что будет дальше с T? Так как слово 𝐴 начинается на [, то может
примениться только правило (4):
E → TE → [E]E

Первое E должно замениться на TE (иначе вторым символом была бы
скобка ]):
E → TE → [E]E → [TE]E

и T должно заменяться по (3):
E → TE → [E]E → [TE]E → [(E)E]E

Далее первое E должно замениться на пустое слово (иначе третьей буквой слова будет ( или [ | только на эти символы может начинаться
слово, выводимое из T):
E → TE → [E]E → [TE]E → [(E)E]E → [()E]E

и далее

. . . → [()TE]E → [()(E)E]E → [()(TE)E]E → [()([E]E)E]E →
→ [()([ ]E)E]E → [()([ ])E]E → [()([ ])]E → [()([ ])]



288

15. Контекстно-свободные грамматики

Что требуется от грамматики, чтобы такой метод поиска левого
вывода был применим? Пусть, например, на очередном шаге самым
левым нетерминалом оказался нетерминал 𝐾 , т. е. мы имеем слово вида 𝐴𝐾𝑈 , где 𝐴 | слово из терминалов, а 𝑈 | слово из терминалов
и нетерминалов. Пусть в грамматике есть правила

𝐾 → 𝐿𝑀𝑁
𝐾 → 𝑃𝑄
𝐾 →𝑅
Нам надо выбрать одно из них. Мы будем пытаться сделать этот выбор,
глядя на первый символ той части входного слова, которая выводится
из 𝐾𝑈 .
Рассмотрим множество Нач(𝐿𝑀𝑁 ) тех терминалов, с которых начинаются непустые слова, выводимые из 𝐿𝑀𝑁 . (Это множество равно
Нач(𝐿), объединённому с Нач(𝑀 ), если из 𝐿 выводится пустое слово,
а также с Нач(𝑁 ), если из 𝐿 и из 𝑀 выводится пустое слово.) Чтобы описанный метод был применим, надо, чтобы Нач(𝐿𝑀𝑁 ), Нач(𝑃 𝑄)
и Нач(𝑅) не пересекались. Но этого мало. Ведь может быть так, например, что из 𝐿𝑀𝑁 будет выведено пустое слово, а из слова 𝑈 будет
выведено слово, начинающееся на букву из Нач(𝑃 𝑄). Следующие определения учитывают эту проблему.
Напомним, что определение выводимости в КС-грамматике было дано только для слова из терминалов. Оно очевидным образом обобщается
на случай слов из терминалов и нетерминалов. Можно также говорить
о выводимости одного слова (содержащего терминалы и нетерминалы)
из другого. (Если говорится о выводимости слова без указания того,
откуда оно выводится, то всегда подразумевается выводимость в грамматике, т. е. выводимость из начального нетерминала.)
Для каждого слова 𝑋 из терминалов и нетерминалов через Нач(𝑋 )
обозначаем множество всех терминалов, с которых начинаются непустые слова из терминалов,выводимые из 𝑋 . (В случае, если из любого
нетерминала выводится хоть одно слово из терминалов, не играет роли,
рассматриваем ли мы при определении Нач(𝑋 ) слова только из терминалов или любые слова. Мы будем предполагать далее, что это условие
выполнено.)
Для каждого нетерминала 𝐾 через Послед(𝐾 ) обозначим множество
терминалов, которые встречаются в выводимых (в грамматике) словах
сразу же за 𝐾 . (Не смешивать с Посл(𝐾 ) предыдущего раздела!) Кроме
того, в Послед(𝐾 ) включается символ EOI, если существует выводимое
слово, оканчивающееся на 𝐾 .

15.3. Алгоритм разбора для LL(1)-грамматик

289

Для каждого правила

𝐾 →𝑉
(где 𝐾 | нетерминал, 𝑉 | слово, содержащее терминалы и нетерминалы) определим множество направляющих терминалов, обозначаемое
Напр(𝐾 → 𝑉 ). По определению оно равно Нач(𝑉 ), к которому добавлено Послед(𝐾 ), если из 𝑉 выводится пустое слово.
Определение. Грамматика называется LL(1)-грамматикой, если для
любых правил 𝐾 → 𝑉 и 𝐾 → 𝑊 с одинаковыми левыми частями множества Напр(𝐾 → 𝑉 ) и Напр(𝐾 → 𝑊 ) не пересекаются.
15.3.3. Является ли грамматика
K→K #
K→

(выводимыми словами являются последовательности диезов) LL(1)грамматикой?
Решение. Нет: символ # принадлежит множествам направляющих
символов для обоих правил (для второго | поскольку # принадлежит
Послед(𝐾 )).

15.3.4. Напишите LL(1)-грамматику для того же языка.
Решение.

K→# K
K→



Как говорят, «леворекурсивное» правило заменено на «праворекурсивное».
Следующая задача показывает, что для LL(1)-грамматики существует не более одного возможного продолжения левого вывода.
15.3.5. Пусть дано выводимое в LL(1)-грамматике слово 𝑋 , в котором выделен самый левый нетерминал 𝐾 : 𝑋 = 𝐴𝐾𝑆 , где 𝐴 | слово
из терминалов, 𝑆 | слово из терминалов и нетерминалов. Пусть существуют два различных правила грамматики с нетерминалом 𝐾 в левой
части, и мы применили их к выделенному в 𝑋 нетерминалу 𝐾 , затем
продолжили вывод и в конце концов получили два слова из терминалов,
начинающихся на 𝐴. Докажите, что в этих словах за началом 𝐴 идут
разные буквы. (Здесь к числу букв мы относим EOI.)

290

15. Контекстно-свободные грамматики

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

15.3.6. Докажите, что если слово выводимо в LL(1)-грамматике, то
его левый вывод единствен.
Решение. Предыдущая задача показывает, что на каждом шаге левый вывод продолжается однозначно.

15.3.7. Грамматика называется леворекурсивной, если из некоторого нетерминала 𝐾 выводится слово, начинающееся с 𝐾 , но не совпадающее с ним. Докажите, что леворекурсивная грамматика, в которой из
каждого нетерминала выводится хотя бы одно непустое слово из терминалов и для каждого нетерминала существует вывод (начинающийся с начального нетерминала), в котором он встречается, не является
LL(1)-грамматикой.
Решение. Пусть из 𝐾 выводится 𝐾𝑈 , где 𝐾 | нетерминал, а 𝑈 |
непустое слово. Можно считать, что это левый вывод (другие нетерминалы можно не заменять). Рассмотрим вывод

𝐾

𝐾𝑈

𝐾𝑈𝑈

...

(знак обозначает несколько шагов вывода) и левый вывод 𝐾 𝐴,
где 𝐴 | непустое слово из терминалов. На каком-то шаге второй вывод отклоняется от первого, а между тем по обоим путям может быть
получено слово, начинающееся на 𝐴 (в первом случае это возможно, так
как сохраняется нетерминал 𝐾 , который может впоследствии быть заменён на 𝐴). Это противоречит возможности однозначного определения правила, применяемого на очередном шаге поиска левого вывода.
(Однозначность выполняется для выводов из начального нетерминала, и надо воспользоваться тем, что 𝐾 по предположению встречается
в таком выводе.)

Таким образом, к леворекурсивным грамматикам (кроме тривиальных случаев) LL(1)-метод неприменим. Их приходится преобразовывать в эквивалентные LL(1)-грамматики | или пользоваться другими
методами распознавания.
15.3.8. Используя сказанное, постройте алгоритм проверки выводимости слова из терминалов в LL(1)-грамматике.
Решение. Мы следуем описанному выше методу поиска левого вывода, храня лишь часть слова, находящуюся правее уже прочитанной

15.3. Алгоритм разбора для LL(1)-грамматик

291

части входного слова. Другими словами, мы храним слово S из терминалов и нетерминалов, обладающее такими свойствами (прочитанную
часть входа обозначаем через A):
1) слово AS выводимо в грамматике;
2) любой левый вывод входного слова проходит через стадию AS
Эти свойства вместе будем обозначать «(И)».
Вначале A пусто, а S состоит из единственного символа | начального нетерминала.
Если в некоторый момент S начинается на терминал t и t = Next,
то можно выполнить команду Move и удалить символ t, являющийся
начальным в S, поскольку при этом AS не меняется.
Если S начинается на терминал t и t ̸= Next, то входное слово невыводимо | ибо по условию любой его вывод должен проходить через
AS. (Это же справедливо и в случае Next = EOI.)
Если S пусто, то из условия (И) следует, что входное слово выводимо
тогда и только тогда, когда Next = EOI.
Остаётся случай, когда S начинается с некоторого нетерминала K.
По доказанному выше все левые выводы из S слов, начинающихся на
символ Next, начинаются с применения к S одного и того же правила |
того, для которого Next принадлежит направляющему множеству. Если
таких правил нет, то входное слово невыводимо. Если такое правило
есть, то нужно применить его к первому символу слова S | при этом
свойство (И) не нарушится. Приходим к такому алгоритму:
s := пустое слово;
error := false;
{error => входное слово невыводимо;}
{not error => (И)}
while (not error) and not ((Next=EOI) and (S пусто))
do begin
if (S начинается на терминал, равный Next) then begin
Move; удалить из S первый символ;
end else if (S начинается на терминал, не равный Next)
then begin
error := true;
end else if (S пусто) and (Next EOI) then begin
error := true;
end else if (S начинается на нетерминал и Next входит в
направляющее множество одного из правил для этого
нетерминала) then begin
применить это правило

292

15. Контекстно-свободные грамматики
end else if (S начинается на нетерминал и Next не входит
в направляющее множество ни одного из правил для этого
нетерминала) then begin
error := true;
end else begin
{так не бывает}
end;
end;
{входное слово выводимо not error}

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

Замечания.



Приведённый алгоритм использует S как стек (все действия производятся с левого конца).



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



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

15.3.9. При проверке того, относится ли данная грамматика к типу
LL(1), необходимо вычислить Послед(𝑇 ) и Нач(𝑇 ) для всех нетерминалов 𝑇 . Как это сделать?
Решение. Пусть, например, в грамматике есть правило 𝐾 → 𝐿 𝑀 𝑁 .
Тогда

Нач (𝐿) ⊂ Нач (𝐾 ),
Нач (𝑀 ) ⊂ Нач (𝐾 ),
Нач (𝑁 ) ⊂ Нач (𝐾 ),

если из 𝐿 выводимо пустое слово,
если из 𝐿 и 𝑀 выводимо пустое слово,

15.3. Алгоритм разбора для LL(1)-грамматик

293

Послед (𝐾 ) ⊂ Послед (𝑁 ),
Послед (𝐾 ) ⊂ Послед (𝑀 ), если из 𝑁 выводимо пустое слово,
Послед (𝐾 ) ⊂ Послед (𝐿), если из 𝑀 и 𝑁 выводимо пустое слово,
Нач (𝑁 ) ⊂ Послед (𝑀 ),
Нач (𝑀 ) ⊂ Послед (𝐿),
Нач (𝑁 ) ⊂ Послед (𝐿), если из 𝑀 выводимо пустое слово.
Подобные правила позволяют шаг за шагом порождать множества
Нач(𝑇 ), а затем и Послед(𝑇 ), для всех терминалов и нетерминалов 𝑇 .
При этом началом служит
EOI ∈ Послед (𝐾 )

для начального нетерминала 𝐾 и

𝑧 ∈ Нач (𝑧 )
для любого терминала 𝑧 . Порождение заканчивается, когда применение правил перестаёт давать новые элементы множеств Нач(𝑇 )
и Послед(𝑇 ).


16. СИНТАКСИЧЕСКИЙ РАЗБОР
СЛЕВА НАПРАВО (LR)
Сейчас мы рассмотрим ещё один метод синтаксического разбора,
называемый LR(1)-разбором, а также некоторые упрощённые его варианты.
16.1. LR-процессы

Два отличия LR(1)-разбора от LL(1)-разбора: во-первых, строится
не левый вывод, а правый, во-вторых, он строится не с начала, а с
конца. (Вывод в КС-грамматике называется правым, если на каждом
шаге замене подвергается самый правый нетерминал.)
16.1.1. Докажите, что если слово, состоящее из терминалов, выводимо, то оно имеет правый вывод.

Нам будет удобно смотреть на правый вывод «задом наперёд». Определим понятие LR-процесса над словом 𝐴. В этом процессе, помимо 𝐴,
будет участвовать и другое слово 𝑆 , которое может содержать как терминалы, так и нетерминалы. Вначале слово 𝑆 пусто. В ходе LR-процесса
разрешены два вида действий:
(1)

(2)

можно перенести первый символ слова 𝐴 (его называют очередным
символом и обозначают Next) в конец слова 𝑆 , удалив его из 𝐴
(это действие называют сдвигом );
если правая часть одного из правил грамматики оказалась концом
слова 𝑆 , то разрешается заменить её на нетерминал, стоящий в левой части этого правила; при этом слово 𝐴 не меняется. (Это действие называют свёрткой, или приведением.)

Отметим, что LR-процесс не является детерминированным: в одной
и той же ситуации могут быть разрешены разные действия.

16.1. LR-процессы

295

Говорят, что LR-процесс на слове 𝐴 успешно завершается, если слово 𝐴 становится пустым, а в слове 𝑆 остаётся единственный нетерминал | начальный нетерминал грамматики.
16.1.2. Докажите, что для любого слова 𝐴 (из терминалов) успешно завершающийся LR-процесс существует тогда и только тогда, когда
слово 𝐴 выводимо в грамматике. В ходе доказательства установите взаимно однозначное соответствие между правыми выводами и успешно
завершающимися LR-процессами.
Решение. При сдвиге слово 𝑆𝐴 не меняется, при свёртке слово 𝑆𝐴
подвергается преобразованию, обратному шагу вывода. Этот вывод
будет правым, так как сворачивается конец 𝑆 , а в 𝐴 все символы |
терминальные. Таким образом, каждому LR-процессу соответствует
правый вывод. Обратное соответствие: пусть дан правый вывод. Представим себе, что за последним нетерминалом в слове стоит перегородка. Применив к этому нетерминалу правило грамматики, мы должны
сдвинуть перегородку влево (если правая часть правила кончается на
терминал). Разбивая этот сдвиг на отдельные шаги, получим процесс,
в точности обратный LR-процессу.

Поскольку в ходе LR-процесса все изменения в слове 𝑆 происходят
с правого конца, слово 𝑆 называют стеком LR-процесса.
Задача построения правого вывода для данного слова сводится, таким образом, к правильному выбору очередного шага LR-процесса.
Нам нужно решить, будем ли мы делать сдвиг или свёртку, и если
свёртку, то по какому правилу | ведь подходящих правил может быть
несколько. В LR(1)-алгоритме это решение принимается на основе 𝑆
и первого символа слова 𝐴; если используется только 𝑆 , то говорят
о LR(0)-алгоритме. (Точные определения см. ниже.)
Пусть фиксирована грамматика, в которой из любого нетерминала
можно вывести какое-либо слово из терминалов. (Это ограничение мы
будет всегда предполагать выполненным.)
Пусть K → U | одно из правил грамматики (K | нетерминал, U |
слово из терминалов и нетерминалов). Определим множество слов (из
терминалов и нетерминалов), называемое левым контекстом правила
K → U. (Обозначение: ЛевКонт(K → U).) По определению в него входят
все слова, которые являются содержимым стека непосредственно перед
свёрткой U в K в ходе некоторого успешно завершающегося LR-процесса.
16.1.3. Переформулируйте это определение на языке правых выводов.

296

16. Синтаксический разбор слева направо (LR)

Решение.

Рассмотрим все правые выводы вида
⟨начальный

нетерминал⟩

XKA → XUA,

где A | слово из терминалов, X | слово из терминалов и нетерминалов.
Все возникающие при этом слова XU и образуют левый контекст правила K → U. Чтобы убедиться в этом, следует вспомнить, что мы предполагаем, что из любого нетерминала можно вывести какое-то слово из
терминалов, так что правый вывод слова XUA может быть продолжен
до правого вывода какого-то слова из терминалов.

16.1.4. Все слова из ЛевКонт(K → U) кончаются, очевидно, на U. Докажите, что если у всех них этот конец U отбросить, то полученное
множество слов не зависит от того, какое из правил для нетерминала K
выбрано. (Это множество обозначается Лев(K).)
Решение. Из предыдущей задачи ясно, что Лев(K) | это всё, что
может появиться в правых выводах левее самого правого нетерминала K.

16.1.5. Докажите, что в предыдущей фразе можно отбросить слова
«самого правого»: Лев(K) | это всё то, что может появляться в правых
выводах левее любого вхождения нетерминала K.
Решение. Продолжив построение правого вывода, все нетерминалы
справа от K можно заменить на терминалы (а слева от K при этом ничего
не изменится).

16.1.6. Постройте грамматику, содержащую для каждого нетерминала K исходной грамматики нетерминал ⟨ЛевK⟩, причём следующее свойство должно выполняться для любого нетерминала K исходной
грамматики: в новой грамматике из ⟨ЛевK⟩ выводимы все элементы
Лев(K) и только они. (При этом терминалы и нетерминалы исходной
грамматики являются терминалами новой.)
Решение. Пусть P | начальный нетерминал грамматики. Тогда в
новой грамматике будет правило

⟨ЛевP⟩ →

(пустое слово)

Для каждого правила исходной грамматики, например, правила
K → LtMN

(L, M, N | нетерминалы, t | терминал),

297

16.1. LR-процессы

в новую грамматику мы добавим правила
⟨ЛевL⟩ → ⟨ЛевK⟩

⟨ЛевM⟩ → ⟨ЛевK⟩ L t

⟨ЛевN⟩ → ⟨ЛевK⟩ L t M

и аналогично поступим с другими правилами. Смысл новых правил
таков: пустое слово может появиться слева от P; если слово X может
появиться слева от K, то X может появиться слева от L, XLt может появиться слева от M, XLtM | слева от N. Индукцией по длине правого
вывода легко проверить, что всё, что может появиться слева от какого-то нетерминала, появляется в соответствии с этими правилами. 
16.1.7. Почему в предыдущей задаче важно, что мы рассматриваем
только правые выводы?
Ответ. В противном случае следовало бы учитывать преобразования, происходящие внутри слова, стоящего слева от K.

16.1.8. Для данной грамматики постройте алгоритм, который по
любому слову выясняет, каким из множеств Лев(K) оно принадлежит.

(Замечание для знатоков. Существование такого алгоритма | и даже конечного автомата, то есть индуктивного расширения с конечным
числом значений, см. раздел 1.3, | вытекает из предыдущей задачи,
так как построенная в ней грамматика имеет специальный вид: в правых частях всего один нетерминал, причём он стоит у левого края. Тем
не менее мы приведём явное построение.)
Решение. Будем называть ситуацией данной грамматики одно из её
правил, в правой части которого отмечена одна из позиций (до первой
буквы, между первой и второй буквой, . . . , после последней буквы).
Например, правило

K → LtMN

(K, L, M, N | нетерминалы, t | терминал) порождает пять ситуаций
K → LtMN

K → L tMN

K → Lt MN

K → LtM N

K → LtMN

(позиция указывается знаком подчёркивания).
Будем говорить, что слово S согласовано с ситуацией K → U V, если
S кончается на U, то есть S = TU при некотором T, и, кроме того, T принадлежит Лев(K). (Смысл этого определения примерно таков: в стеке S

298

16. Синтаксический разбор слева направо (LR)

подготовлена часть U для будущей свёртки UV в K.) В этих терминах
ЛевКонт(K → X) | это множество всех слов, согласованных с ситуацией
K → X , а Лев(K) | это множество всех слов, согласованных с ситуацией
K → X (где K → X | любое правило для нетерминала K).
Эквивалентное определение в терминах LR-процесса: S согласовано
с ситуацией K → U V, если существует успешный LR-процесс, в котором
события развиваются так:


в ходе процесса в стеке появляется слово S, и оно оканчивается
на U;



некоторое время S не затрагивается, а справа от него появляется V;

∙ UV


сворачивается в K;

процесс продолжается и успешно завершается.

16.1.9.

Докажите эквивалентность этих определений.

[Указание. Если S = TU и T принадлежит Лев(K), то можно получить
в стеке сначала T, потом U, потом V, потом свернуть UV в K и затем
успешно завершить процесс. (Мы используем несколько раз тот факт,
что из любого нетерминала что-то да выводится: благодаря этому мы
можем добавить в стек любое слово.)]

Наша цель | построение алгоритма, распознающего принадлежность произвольного слова к Лев(K). Рассмотрим функцию, сопоставляющую с каждым словом S (из терминалов и нетерминалов) множество всех согласованных с ним ситуаций. Это множество назовём состоянием, соответствующим слову S. Будем обозначать его Сост(S).
Достаточно показать, что функция Сост(S) индуктивна, то есть что
значение Сост(SJ), где J | терминал или нетерминал, может быть вычислено, если известно Сост(S) и символ J. (Мы видели ранее, как принадлежность к Лев(K) выражается в терминах этой функции.) Значение
Сост(SJ) вычисляется по таким правилам:
(1) Если слово S согласовано с ситуацией K → U V, причём
слово V начинается на букву J, то есть V = JW, то SJ согласовано с ситуацией K → UJ W.

16.1. LR-процессы

299

Это правило полностью определяет все ситуации с непустой левой
половиной (то есть не начинающиеся с подчёркивания), согласованные
с SJ. Осталось определить, для каких нетерминалов K слово SJ принадлежит Лев(K). Это делается по двум правилам:
(2) Если ситуация L → U V согласована с SJ (согласно правилу (1)), а V начинается на нетерминал K, то SJ принадлежит
Лев(K).
(3) Если SJ входит в Лев(L) для некоторого L, причём L →
→ V | правило грамматики и V начинается на нетерминал K,
то SJ принадлежит Лев(K).
Заметим, что правило (3) можно рассматривать как аналог правила
(2): в указанных в (3) предположениях ситуация L → V согласована
с SJ, а V начинается на нетерминал K.
Корректность этих правил в общем-то очевидна, если хорошенько подумать. Единственное, что требует некоторых пояснений | это
то, почему с помощью правил (2) и (3) обнаружатся все терминалы K,
для которых SJ принадлежит Лев(K). Попытаемся это объяснить. Рассмотрим правый вывод, в котором SJ стоит слева от K. Откуда мог
взяться в нём нетерминал K? Если правило, которое его породило, породило также и конец слова SJ, то принадлежность SJ к Лев(K) будет
обнаружена по правилу (2). Если же K было первой буквой слова, порождённого каким-то другим нетерминалом L, то | благодаря правилу
(3) | достаточно установить принадлежность SJ к Лев(L). Осталось
применить те же рассуждения к L и так далее.
В терминах LR-процесса то же самое можно сказать так. Сначала
нетерминал K может участвовать в нескольких свёртках, не затрагивающих SJ (они соответствуют применению правила (3) ), но затем он
обязан подвергнуться свёртке, затрагивающей SJ (что соответствует
применению правила (2) ).
Осталось выяснить, какие ситуации согласованы с пустым словом,
то есть для каких нетерминалов K пустое слово принадлежит Лев(K).
Это определяется по следующим правилам:
(1) начальный нетерминал таков;
(2) если K таков и K → V | правило грамматики, причём
слово V начинается с нетерминала L, то и L таков.



300

16. Синтаксический разбор слева направо (LR)

16.1.10.

Проделайте описанный анализ для грамматики
E→E+T
E→T
T→T*F
T→F
F→x
F→(E)

(задающей тот же язык, что и грамматика примера 3, с. 272).
Решение. Множества Сост(S) для различных S приведены в таблице
на с. 301. Знак равенства означает, что множества ситуаций, являющиеся значениями функции Сост(S) на словах, стоящих слева и справа от
знака равенства, одинаковы.
Правило определения Сост(SJ), если известны Сост(S) и J (здесь S |
слово из терминалов и нетерминалов, J | терминал или нетерминал),
таково:
надо найти Сост(S) в правой колонке, взять соответствующее ему слово T в левой колонке, приписать к нему J и взять
множество, стоящее напротив слова TJ (если слово TJ в таблице отсутствует, то Сост(SJ) пусто).



16.2. LR(0)-грамматики

Напомним, что наша основная цель | это поиск вывода заданного
слова, или, другими словами, поиск успешного LR-процесса над ним. Во
всех рассматриваемых нами грамматиках успешный LR-процесс (над
данным словом) единствен. Искать этот единственный успешный процесс мы будем постепенно: в каждый момент мы смотрим, какой шаг
возможен следующим. Для этого на грамматику надо наложить дополнительные требования, и сейчас мы рассмотрим простейший случай
так называемых LR(0)-грамматик. Мы уже знаем:
(1) В успешном LR-процессе возможна свёртка по правилу
K → U при содержимом стека S тогда и только тогда, когда
S принадлежит ЛевКонт(K → U) или, другими словами, когда
слово S согласовано с ситуацией K → U .

16.2. LR(0)-грамматики

Слово S Сост(S)
пустое E → E+T E → T T → T*F
E
T
F
x
(
E+
T*
(E
(T
(F
(x
((
E+T
E+F
E+x
E+(
T*F
T*x
T*(
(E)
(E+
E+T*

T → F F → x F → (E)
E → E +T
E → T T → T *F
T→F
F→x
F → ( E) E → E+T E → T
T → T*F T → F F → x F → (E)
E → E+ T T → T*F T → F
F → x F → (E)
T → T* F F → x F → (E)
F → (E ) E → E +T
=T
=F
=x
=(
E → E+T T → T *F
=F
=x
=(
T → T*F
=x
=(
F → (E)
= E+
= T*

К задаче 16.1.10

301

302

16. Синтаксический разбор слева направо (LR)

Аналогичное утверждение про сдвиг гласит:
(2) В успешном LR-процессе при содержимом стека S возможен сдвиг с очередным символом a тогда и только тогда,
когда S согласовано с некоторой ситуацией K → U aV.
Докажите это.
[Указание. Пусть произошёл сдвиг и к стеку S добавилась буква a.
Рассмотрите первую свёртку, затрагивающую эту букву.]

Рассмотрим некоторую грамматику и произвольное слово S из терминалов и нетерминалов. Если множество Сост(S) содержит ситуацию,
в которой справа от подчёркивания стоит терминал, то говорят, что
для слова S возможен сдвиг. Если в Сост(S) есть ситуация, в которой
справа от подчёркивания ничего нет, то говорят, что для слова S возможна свёртка (по соответствующему правилу). Говорят, что для слова S возникает конфликт типа сдвиг/свёртка, если возможны и сдвиг,
и свёртка. Говорят, что для слова S возникает конфликт типа свёртка/свёртка, если есть несколько правил, по которым возможна свёртка.
Грамматика называется LR(0)-грамматикой, если в ней нет конфликтов типа сдвиг/свёртка и свёртка/свёртка ни для одного слова S.
16.2.2. Является ли приведённая выше грамматика LR(0)-грамматикой?
Решение. Нет, не является. Для слов T и E+T имеются конфликты
типа сдвиг/свёртка.

16.2.3. Являются ли LR(0)-грамматиками такие:
16.2.1.

(а) T → 0
T → T1
T → TT2
T → TTT3

(б) T → 0
T → 1T
T → 2TT
T → 3TTT

Являются, см. таблицы на с. 303 { 304 (конфликтов нет). 
Эта задача показывает, что LR(0)-грамматики могут быть как леворекурсивными, так и праворекурсивными.
16.2.4. Пусть дана LR(0)-грамматика. Докажите, что у любого слова существует не более одного правого вывода. Постройте алгоритм
проверки выводимости в LR(0)-грамматике.
Решение.

16.2. LR(0)-грамматики

Слово S Сост(S)
пустое Т → 0 T → T1 T → TT2 T → TTT3
0
Т

T1
TT
TT2
TTT
TT0
TTT3
TTT2
TTTT
TTT0

Т→0
Т → Т 1 T → T T2 T → T TT3
Т → 0 T → T1 T → TT2 T → TTT3
T → T1
T → TT 2 T → TT T3
T → T 1 T → T T2 T → T TT3
T → 0 T → T1 T → TT2 T → TTT3
T → TT2
T → TTT 3 T → TT 2 T → TT T3
T → T 1 T → T T2 T → T TT3
Т → 0 T → T1 T → TT2 T → TTT3
=0
T → TTT3
= TT2
= TTT
=0

(а)

Слово S Сост(S)
пустое T → 0 T → 1Т T → 2ТТ T → 3ТТТ
0
1
2
3

Т→0
Т→1 T
T → 0 T → 1Т T → 2ТТ T → 3ТТТ
T → 2 TT
T → 0 T → 1Т T → 2ТТ T → 3ТТТ
T → 3 TTT
T → 0 T → 1Т T → 2ТТ T → 3ТТТ

(б), начало
К задаче 16.2.3.

303

304

16. Синтаксический разбор слева направо (LR)

Слово S Сост(S)
1T
10
11
12
13
2T
20
21
22
23
3T
30
31
32
33
2TT
2T0
2T1
2T2
2T3
3TT
3T0
3T1
3T2
3T3
3TTT
3TT0
3TT1
3TT2
3TT3

T → 1T
=0
=1
=2
=3
T → 2T T
T → 0 T → 1Т T → 2ТТ T → 3ТТТ
=0
=1
=2
=3
T → 3T TT
T → 0 T → 1Т T → 2ТТ T → 3ТТТ
=0
=1
=2
=3
T → 2TT
=0
=1
=2
=3
T → 3TT T
T → 0 T → 1Т T → 2ТТ T → 3ТТТ
=0
=1
=2
=3
T → 3TTT
=0
=1
=2
=3

(б), окончание

16.2. LR(0)-грамматики

305

Решение. Пусть дано произвольное слово. Будем строить LR-процесс над ним по шагам. Пусть текущее состояние стека LR-процесса
равно S. Нам надо решить, делать сдвиг или свёртку (и если свёртку, то по какому правилу). Согласно определению LR(0)-грамматики, в нашем состоянии S возможен либо только сдвиг, либо только свёртка (причём лишь по одному правилу). Таким образом, поиск
возможных продолжений LR-процесса происходит детерминированно
(на каждом шаге можно определить, какое действие только и возможно).

16.2.5. Что произойдёт, если анализируемое слово не имеет вывода
в данной грамматике?
Ответ. Либо на некотором шаге не будет возможен ни сдвиг, ни
свёртка, либо все возможные сдвиги будет иметь неподходящий очередной символ.

Замечания. 1. При реализации этого алгоритма нет необходимости
каждый раз заново вычислять множество Сост(S) для текущего значения S. Эти множества можно также хранить в стеке (в каждый момент
хранятся множества Сост(T) для всех начал T текущего слова S).
2. На самом деле само слово S можно не хранить | достаточно
хранить множества ситуаций Сост(T) для всех его начал T (включая
само S).

В алгоритме проверки выводимости в LR(0)-грамматике мы используем не всю информацию, которую могли бы. В этом алгоритме для каждого состояния известно заранее, что в нём возможен только сдвиг или только свёртка (причём в последнем случае известно, по какому правилу). Более изощрённый алгоритм мог бы принимать решение о выборе между сдвигом и свёрткой, посмотрев
на очередной символ (Next). Глядя на состояние, можно сказать,
при каких значениях Next возможен сдвиг (это те терминалы, которые в ситуациях этого состояния стоят непосредственно за подчёркиванием). Сложнее воспользоваться информацией о символе Next
для решения вопроса о том, возможна ли свёртка. Для этого есть
упрощённый метод (грамматики, к которым он применим, называют SLR(1)-грамматиками [сокращение от Simple LR(1)]) и полный
метод (более сложный, но использующий всю возможную информацию; грамматики, к которым он применим, называют LR(1)-грамматиками). Есть и промежуточный класс грамматик, называемый
LALR(1).

306

16. Синтаксический разбор слева направо (LR)

16.3. SLR(1)-грамматики

Напомним, что для любого нетерминала K мы определяли (с. 288)
множество Послед(K) тех терминалов, которые могут стоять непосредственно за K в выводимом (из начального нетерминала) слове; в это множество добавляют также символ EOI, если нетерминал K может стоять
в конце выводимого слова.
16.3.1. Докажите, что если в данный момент LR-процесса последний символ стека S равен K, причём процесс этот может в дальнейшем
успешно завершиться, то Next принадлежит Послед(K).
Решение. Этот факт является непосредственным следствием определения (вспомним соответствие между правыми выводами и LR-процессами).

Рассмотрим некоторую грамматику, произвольное слово S из терминалов и нетерминалов и терминал x. Если множество Сост(S) содержит
ситуацию, в которой справа от подчёркивания стоит терминал x, то
говорят, что для пары ⟨S, x⟩ возможен сдвиг. Если в Сост(S) есть ситуация K → U , причём x принадлежит Послед(K), то говорят, что для
пары ⟨S, x⟩ SLR(1)-возможна свёртка (по правилу K → U). Говорят, что
для пары ⟨S, x⟩ возникает SLR(1)-конфликт типа сдвиг/свёртка, если
возможны и сдвиг, и свёртка. Говорят, что для пары ⟨S, x⟩ возникает
SLR(1)-конфликт типа свёртка/свёртка, если есть несколько правил,
по которым возможна свёртка.
Грамматика называется SLR(1)-грамматикой, если в ней нет
SLR(1)-конфликтов типа сдвиг/свёртка и свёртка/свёртка ни для одной пары ⟨S, x⟩.
16.3.2. Пусть дана SLR(1)-грамматика. Докажите, что у любого
слова существует не более одного правого вывода. Постройте алгоритм
проверки выводимости в SLR(1)-грамматике.
Решение. Аналогично случаю LR(0)-грамматик, только при выборе
между сдвигом и свёрткой учитывается очередной символ (Next). 
16.3.3. Проверьте, является ли приведённая выше на с. 300 грамматика (с нетерминалами E, T и F) SLR(1)-грамматикой.
Решение. Да, является, так как оба конфликта, мешающие ей быть
LR(0)-грамматикой, разрешаются с учётом очередного символа: и для
слова T, и для слова E+T сдвиг возможен только при Next = *, а символ *
не принадлежит ни Послед(E) = {EOI, +, )}, ни Послед(T) = {EOI, +, *, )},
и поэтому при Next = * свёртка невозможна.


16.4. LR(1)-грамматики, LALR(1)-грамматики

307

16.4. LR(1)-грамматики, LALR(1)-грамматики

Описанный выше SLR(1)-подход используют не всю возможную информацию при выяснении того, возможна ли свёртка. Именно, он отдельно проверяет, возможна ли свёртка при данном состоянии стека S
и отдельно | возможна ли свёртка по данному правилу при данном
символе Next. Между тем эти проверки не являются независимыми:
обе могут дать положительный ответ, но тем не менее свёртка при
стеке S и очередном символе Next невозможна. В LR(1)-подходе этот
недостаток устраняется.
LR(1)-подход состоит вот в чём: все наши определения и утверждения модифицируются так, чтобы учесть, какой символ стоит справа
от разворачиваемого нетерминала (другими словами, чему равен Next
при свёртке).
Пусть K → U | одно из правил грамматики, а t | некоторый терминал или спецсимвол EOI (который мы домысливаем в конце входного слова). Определим множество ЛевКонт(K → U, t) как множество
всех слов, которые являются содержимым стека непосредственно перед свёрткой U в K в ходе успешного LR-процесса, при условии Next = t
(в момент свёртки).
Если отбросить у всех слов из ЛевКонт(K → U) их конец U, то получится множество всех слов, которые могут появиться в правых выводах
перед нетерминалом K, за которым стоит символ t. Это множество (не
зависящее от того, какое из правил K → U для нетерминала K выбрано)
мы будем обозначать Лев(K, t).
16.4.1. Напишите грамматику для порождения множеств Лев(K, t).
Решение. Её нетерминалами будут символы ⟨ЛевK t⟩ для каждого
нетерминала K и для каждого терминала t (а также для t = EOI). Её
правила таковы. Пусть P | начальный нетерминал исходной грамматики. Тогда в новой грамматике будет правило
⟨ЛевP EOI⟩ →

(пустое слово).

Каждое правило исходной грамматики порождает несколько правил новой. Например, для правила
K → LuMN

(L, M, N | нетерминалы, u | терминал) в новую грамматику мы добавим правила
⟨ЛевL u⟩ → ⟨ЛевK x⟩

308

16. Синтаксический разбор слева направо (LR)

(для всех терминалов x);
⟨ЛевM s⟩ → ⟨ЛевK y⟩ L u

(для всех s, которые могут начинать слова, выводимые из N, и для
всех y, а также для всех пар s = y, если из N выводимо пустое слово);
⟨ЛевN s⟩ → ⟨ЛевK s⟩ L u M

(для всех терминалов s).
16.4.2. Как меняется определение ситуации?
Решение. Ситуацией называется пара
[ситуация в старом смысле, терминал или EOI]





Как изменится определение согласованности?
Решение. Слово S из терминалов и нетерминалов согласовано с ситуацией [K → U V, t] (здесь t | терминал или EOI), если S кончается
на U, то есть S = TU, и, кроме того, T принадлежит Лев(K, t).

16.4.4. Каковы правила для индуктивного вычисления множества
Сост(S) ситуаций, согласованных с данным словом S?
16.4.3.

Ответ.

(1) Если слово S согласовано с ситуацией [K → U V, t], причём
слово V начинается на букву J, то есть V = JW, то слово SJ
согласовано с ситуацией [K → UJ W, t].
Это правило полностью определяет все ситуации с непустой левой
половиной (то есть не начинающиеся с подчёркивания), согласованные
с SJ. Осталось определить, для каких нетерминалов K и терминалов t
слово SJ принадлежит Лев(K, t). Это делается по двум правилам:
(2) Если ситуация [L → U V, t] согласована с SJ (согласно правилу (1)), а V начинается на нетерминал K, то SJ принадлежит
Лев(K, s) для всех терминалов s, которые могут начинать
слова, выводимые из слова V ∖ K (слово V без первой буквы K),
а также для s = t, если из V ∖ K выводится пустое слово.
(3) Если SJ входит в Лев(L, t) для некоторых L и t, причём
L → V | правило грамматики и V начинается на нетерминал K, то SJ принадлежит Лев(K, s) для всех терминалов s,
которые могут начинать слова, выводимые из V ∖ K, а также
для s = t, если из V ∖ K выводится пустое слово.



16.4. LR(1)-грамматики, LALR(1)-грамматики

309

16.4.5. Дайте определение LR(1)-конфликтов типа сдвиг/свёртка и
свёртка/свёртка.
Решение. Пусть дана некоторая грамматика. Пусть S | произвольное слово из терминалов и нетерминалов. Если множество Сост(S) содержит ситуацию, в которой справа от подчёркивания стоит терминал t, то говорят, что для пары ⟨S, t⟩ возможен сдвиг. (Это определение
не изменилось по сравнению с SLR(1)-случаем | вторые компоненты
пар из Сост(S) не учитываются.)
Если в Сост(S) есть ситуация, в которой справа от подчёркивания
ничего нет, а вторым членом пары является терминал t, то говорят,
что для пары ⟨S, t⟩ LR(1)-возможна свёртка (по соответствующему
правилу). Говорят, что для пары ⟨S, t⟩ возникает LR(1)-конфликт типа
сдвиг/свёртка, если возможны и сдвиг, и свёртка. Говорят, что для пары ⟨S, t⟩ возникает LR(1)-конфликт типа свёртка/свёртка, если есть
несколько правил, по которым возможна свёртка.

Грамматика называется LR(1)-грамматикой, если в ней нет LR(1)конфликтов типа сдвиг/свёртка и свёртка/свёртка ни для одной пары
⟨S, t⟩.
16.4.6. Постройте алгоритм проверки выводимости слова в LR(1)грамматике.
Решение. Как и раньше, на каждом шаге LR-процесса можно однозначно определить, какой шаг только и может быть следующим.

Полезно (в частности, для LALR(1)-разбора, см. ниже) понять, как
связаны понятия LR(0) и LR(1)-согласованности.
16.4.7. Сформулируйте и докажите соответствующее утверждение.
Ответ. Пусть фиксирована некоторая грамматика. Слово S из терминалов и нетерминалов является LR(0)-согласованным с ситуацией
K → U V тогда и только тогда, когда оно LR(1)-согласовано с парой
[K → U V, t] для некоторого терминала t (или для t = EOI). То же самое
другими словами: Лев(K) есть объединение Лев(K, t) по всем t. В последней форме это совсем ясно.

Замечание. Таким образом, функция Сост(S) в LR(1)-смысле является расширением функции Сост(S) в LR(0)-смысле: СостLR(0) (S) получается из СостLR(1) (S), если во всех парах выбросить вторые члены.
Теперь мы можем дать определение LALR(1)-грамматики. Пусть
фиксирована некоторая грамматика, S | слово из нетерминалов и терминалов, t | некоторый терминал (или EOI). Будем говорить, что для

310

16. Синтаксический разбор слева направо (LR)

пары ⟨S, t⟩ LALR(1)-возможна свёртка по некоторому правилу, если
существует другое слово S1 с СостLR(0) (S0 ) = СостLR(0) (S1 ), причём для
пары ⟨S1 , t⟩ LR(1)-возможна свёртка по рассматриваемому правилу.
Далее определяются конфликты (естественным образом), и грамматика называется LALR(1)-грамматикой, если конфликтов нет.
16.4.8. Докажите, что любая SLR(1)-грамматика является также и
LALR(1)-грамматикой, а любая LALR(1)-грамматика является также
и LR(1)-грамматикой.

[Указание. Это | простое следствие определений.]



16.4.9. Постройте алгоритм проверки выводимости в LALR(1)грамматике, который хранит в стеке меньше информации, чем соответствующий LR(1)-алгоритм.

[Указание. Достаточно хранить в стеке множества СостLR(0) (S),
поскольку согласно определению LALR(1)-возможность свёртки ими
определяется. (Так что сам алгоритм ничем не отличается от SLR(1)случая, кроме таблицы возможных свёрток.)]

16.4.10. Приведите пример LALR(1)-грамматики, которая не является SLR(1)-грамматикой.

16.4.11. Приведите пример LR(1)-грамматики, которая не является
LALR(1)-грамматикой.


16.5. Общие замечания о разных методах разбора

Применение этих методов на практике имеет свои хитрости и тонкости, которых мы не касались. (Например, таблицы следует хранить
по возможности экономно.) Часто оказывается также, что для некоторого входного языка наиболее естественная грамматика не является
LL(1)-грамматикой, но является LR(1)-грамматикой, а также может
быть заменена на LL(1)-грамматику без изменения языка. Какой из
этих вариантов выбрать, не всегда ясно. Дилетантский совет: если Вы
сами проектируете входной язык, то не следует выпендриваться и употреблять одни и те же символы для разных целей | и тогда обычно несложно написать LL(1)-грамматику или рекурсивный анализатор. Если
же входной язык задан заранее с помощью LR(1)-грамматики, не являющейся LL(1)-грамматикой, то лучше её не трогать, а разбирать как
есть. При этом могут оказаться полезные средства автоматического

16.5. Общие замечания о разных методах разбора

311

порождения анализаторов, наиболее известными из которых являются
yacc (UNIX) и bison (GNU).
Большое количество полезной и хорошо изложенной информации
о теории и практике синтаксического разбора имеется в книге Ахо,
Сети и Ульмана (см. список книг для чтения).

Книги для чтения
А. Ахо, Р. Сети, Дж. Ульман.

Компиляторы: принципы, технологии и
инструменты. М.: Вильямс, 2001.
А. Ахо, Дж. Хопкрофт, Дж. Ульман. Построение и анализ вычислительных алгоритмов. М.: Мир, 1979.
Н. Вирт. Систематическое программирование. Введение. М.: Мир, 1977.
Н. Вирт. Алгоритмы + структуры данных = программы. М.: Мир,
1985.
Д. Гасфилд. Строки, деревья и последовательности в алгоритмах. СПб.:
Невский диалект, 2003.
Д. Грис. Наука программирования. М.: Мир, 1984.
М. Гэри, Д. Джонсон. Вычислительные машины и труднорешаемые задачи. М.: Мир, 1982.
Э. Дейкстра. Дисциплина программирования. М.: Мир, 1978.
С. Дасгупта, Х. Пападимитриу, У. Вазирани. Алгоритмы. М.: МЦНМО,
2014.
Т. Кормен, Ч. Лейзерсон, Р. Ривест. Алгоритмы: построение и анализ.
М.: МЦНМО, 2000.
А. Г. Кушниренко, Г. В. Лебедев. Программирование для математиков.
М.: Наука, 1988.
В. Липский. Комбинаторика для программистов. М.: Мир, 1988.
Э. Рейнгольд, Ю. Нивергельт, Н. Део. Комбинаторные алгоритмы. Теория и практика. М.: Мир, 1980.
Дж. Хопкрофт, Р. Мотвани, Дж. Ульман. Введение в теорию автоматов, языков и вычислений. М.: Вильямс, 2002.

Предметный указатель
𝐴* -алгоритм поиска 151
AND-OR-дерево 225
backtracking 60
Borland 4
LALR(1)-грамматика 310
LL(1)-грамматика 289
LL(1)-разбор 286
LR(0)-грамматика 302
LR(1)-грамматика 309
LR-процесс 294
NP-полнота 70
SLR(1)-грамматика 306
Turbo Pascal 4, 29
АВЛ-дерево 258
автомат конечный 90, 178, 190
| | недетерминированный 195
азбука Морзе 231
алгоритм Маккрейта 203
| чередующихся цепей 174
алфавит 180, 230
альфа-бета-процедура 223, 224
Б-дерево 267
билеты счастливые, число 58
биномиальный коэффициент 136

ближайшая сумма 31
Бойера { Мура алгоритм 186
бридж-ит, игра 217
буква 230
|, частота 231
быстрое умножение 28
величина потока 161{164, 166
вершина графа 106, 130
вершинное покрытие 176
ветвей и границ метод 60
вращение левое, правое 259
| малое, большое 259
вывод в грамматике 269
| левый 286
| правый 294
выводимость в КС-грамматике,
полиномиальный алгоритм 273
выигрышные позиции 212
выпуклая оболочка 81, 110
выражение 272
| регулярное 193
высота 251
гауссовы числа 17
Гейла игра 217
голландский флаг 36
Горнера схема 26
грамматика LALR(1) 310
| LL(1) 289
| LR(0) 302

314

Предметный указатель

| LR(1) 309
| SLR(1) 306
| выражений 272
| контекстно-свободная 269
| леворекурсивная 290
граф, вершина 106, 130
| двудольный 155, 173
|, кратчайшие пути 146
| неориентированный 130
| ориентированный 106
|, ребро 130
|, связная компонента 113, 130,
152
| связный 106
Грея коды 49
датчик поворота 51
двоичный поиск 33
двудольный граф 155, 173
Дейкстры алгоритм (кратчайшие
пути) 148, 150
дек, реализация в массиве 105
|, ссылочная реализация 110
деление с остатком 10
| | быстрое 22
дерево 60
|, AND-OR- 225
|, Б-дерево 267
|, вершина 121
|, высота 122
| двоичное 75
|, корень 121
|, обход 62, 123, 127, 142
| | нерекурсивный 141
| подслов 197
| позиций 60
| |, реализация 67
| полное двоичное 250
|, рекурсивная обработка 122
| сбалансированное 258
| сжатое суффиксное 199

|, ссылочная реализация 121, 252
| суффиксное 197
| упорядоченное 251
|, число вершин 122, 142
| | листьев 122
десятичная дробь, период 20
| запись, печать 17, 20, 141
| | | рекурсивная 119
| |, чтение 92
детерминизация конечного
автомата 196
динамическое программирование
135, 137
| |, кратчайшие пути 146
диофантово уравнение 12, 14
дополнение регулярного
множества 197
дополняющий путь 165, 167, 169
Евклида алгоритм 12, 13
| | двоичный 13
жулик на пособии 31
задача NP-полная 70
| о рюкзаке 70, 140
игра Гейла 217
| крестики-нолики 216
| мат с неподвижным королём
228
| ним 211
|, ретроспективный анализ 227
| с нулевой суммой 213
| с полной информацией 210
|, цена 212
индуктивная функция 38
индуктивное расширение 39
исток 159
источник 195
калькулятор стековый 144

Предметный указатель

Каталана число 55, 59, 137
Кёнига теорема 176
Кнута { Морриса { Пратта
алгоритм 182
код 230
| Грея 49
| однозначный 230
| префиксный 231
|, средняя длина 232
| Хаффмана 235{237
| Шеннона { Фано 237{239
кодовое слово 230
количество различных 24, 80
комментарии вложенные 91
|, удаление 91
конец слова 180
конечный автомат 90, 178, 190
| | недетерминированный 195
контекстно-свободная
грамматика 269
конфликт свёртка/свёртка 302,
306, 309
| сдвиг/свёртка 302, 306, 309
коэффициент биномиальный 136
Крафта { Макмиллана
неравенство 231{234
крестики-нолики, игра 216
критическое ребро 171
КС-грамматика 269
Лев(𝐾 ) 296
Лев(𝐾 , 𝑡) 307
ЛевКонт(𝐾 → 𝑈 ) 295
ЛевКонт(𝐾 → 𝑈 , 𝑡) 307
левый контекст правила 295
Маккрейта алгоритм 199, 203
максимальное паросочетание
173{176
максимальный поток 164, 169
массив 23

315

| с минимальным элементом 115
| суффиксов 208
матриц произведение 149
матрица цен 149
матрицы, порядок умножения 138
медиана, поиск 88, 133
метод потенциалов 151
минимальный разрез 164, 169
минимум, поиск 84
многоугольника триангуляция 57
многочлен, значение 26
|, производная 27
|, умножение 27, 28
множество, представление 241,
244
| | деревом 250
|, реализация в битовом массиве
111
| | перечнем 112
| регулярное 194
|, тип данных 111
множитель 272
моделирование, очередь событий
116
монотонных последовательностей
перечисление 47
Морзе азбука 231
наибольший общий делитель 11
наименьшее общее кратное 13
Напр(𝐾 → 𝑉 ) 289
Нач(𝑋 ) 277, 288
начало слова 180
неассоциативное произведение
139
недетерминированный конечный
автомат 195
неориентированный граф 130
неравенство Крафта {
Макмиллана 231{234
нетерминал 269

316

Предметный указатель

нижние оценки числа сравнений
84
ним, игра 211
НОД 11
НОК 13
обмен значений 8
образец, поиск 178
обратная перестановка 35
|польская запись 144
обход дерева 60, 220
| | рекурсивный 127
общий элемент (в упорядоченных
массивах) 31
опечатки, поиск 249
ориентированный граф 106
орфография, проверка 249
остаточная сеть 164, 165
остаточный граф 165, 170
открытая адресация 242
очередь 103
| из двух стеков 104
| приоритетная 115
|, реализация в массиве 103
|, ссылочная реализация 108
ошибка: индекс за границей 29,
34, 35, 73, 75
паросочетание 173{176
паскаль, язык 4, 29
Паскаля треугольник 57, 136
перебор с возвратами 60
|, сокращение 223
пересечение регулярных
множеств 197
| упорядоченных массивов 31
перестановка обратная 35, 53
| частей массива 25
|, чётность 35
перестановок перечисление 44,
52, 124

период десятичной дроби 20
поддерево 251
подмножеств перечисление 44, 45
подпоследовательность
максимальная возрастающая 41
| общая 40
|, проверка 40
подслово 180
|, поиск 182, 185, 186, 188
позиции в суффиксном дереве 200
| проигрышные и выигрышные
212
поиск 𝐴* -алгоритм 151
| 𝑘-го по порядку 88, 133, 257
| в глубину 153
| в ширину 114, 152
| двоичный 33
| кратчайшего пути 146
| минимума 84
| образца 186, 188, 190
| одного из слов 191
| подслова 178, 182, 185, 186, 188
| представителя большинства 88
Посл(𝑋 ) 277
Послед(𝑋 ) 288
последовательности монотонные,
перечисление 125
последовательность, содержащая
все слова длины 𝑛 108
постфиксная запись 144
потенциал 151
поток 157, 159
| через разрез 162
потомок вершины 121
права авторские 4
предпоток 164
приведение 294
приоритетная очередь 115
программа сжатия информации
240

Предметный указатель

программирование динамическое
135, 137, 273
проигрышные позиции 212
произведение многочленов 27
| неассоциативное 56, 139
пропускная способность 159
| | разреза 163, 164, 166
простые множители 15
путей число 150
Рабина алгоритм 188
разбиений на слагаемые
перечисление 48, 126
| | число 57
разбор LL(1) 286
| LR(1) 294
|, общий КС-алгоритм 273
|, рекурсивный спуск 275
разложение на множители 15
размещения с повторениями 43,
123
разрез 157, 159, 162
расстановки функция 241
расширение индуктивное 39
ребро графа 130
регулярное выражение 193
| множество 194
| |, дополнение 197
| |, пересечение 197
рекурсивная программа 117
рекурсивный спуск 275
рекурсия 117
|, устранение 135
ретроспективный анализ игры
227
рюкзак, заполнение 70, 140
свёртка 294
связная компонента
неориентированного графа 130

317

| | ориентированного графа
113, 131, 152
связный граф 106
сдвиг 294
сеть 157, 159, 162
сжатие информации 240
сжатое суффиксное дерево 199
символ 230
|, код 230
| начальный 269
| нетерминальный 269
| терминальный 269
ситуация грамматики 297
скобки, правильность 98, 270
скобок расстановка 56
слагаемое 272
слияние упорядоченных массивов
30
слово 180
| выводимое 269
сортировка 𝑛 log 𝑛 73
| деревом 75, 115
| квадратичная 72
|, нижняя оценка сложности 82
| слиянием 73, 80
| топологическая 128, 155
| Хоара (быстрая) 80, 132
| | нерекурсивная 143
| цифровая 83
|, число сравнений 82
Сост(𝑆 ) 298
составные символы, замена 90
сочетаний число 57, 136
ссылки суффиксные 201
стек 96
|, два в массиве 100
| отложенных заданий 141
|, реализация в массиве 97
|, ссылочная реализация 100
стековый калькулятор 144
степень, быстрое вычисление 9

318

Предметный указатель

|, вычисление 9
|, рекурсивная программа 118
сток 160
стратегия в игре 214
| позиционная 214
суммарная величина потока
161{164, 166
суммирование массива
рекурсивное 120
суффикс 199
суффиксное дерево 197, 199
суффиксные ссылки 201
суффиксный массив 208
счастливые билеты, число 58
теорема Кёнига 176
| Форда { Фалкерсона 164
| Холла о представителях 176,
177
| Цермело 214
терминал 269
| направляющий 289
топологическая сортировка 128,
155
треугольник Паскаля 136
триангуляция многоугольника
57, 137
упорядоченное дерево 251
факториал 11
|, рекурсивная программа 117
ферзей расстановка 60
Фибоначчи последовательность
11, 137, 258
| |, быстрое вычисление 11
Флойда алгоритм 147, 197
Форда { Беллмана алгоритм 146
функция индуктивная 38
| расстановки 241

ханойские башни, нерекурсивная
программа 140
| |, рекурсивная программа
119
Хаффмана код 235{237
хеш-функция 241
|, универсальное семейство 246,
248
хеширование 241
|, оценка числа действий 246
| с открытой адресацией 242
| со списками 244
| универсальное 246
Хоара сортировка 80, 132
| | нерекурсивная 143
хорды окружности 57
целые точки в круге 18
цена игры 212, 214
| |, вычисление 220
Цермело теорема 214
цикл эйлеров (по всем рёбрам
графа) 106
циркуляция 172
частота буквы 231
чётность перестановки 35
число Каталана 55, 59, 137
| общих элементов 28
| разбиений 57
| сочетаний 57
| счастливых билетов 58
Шеннона { Фано код 237{239
энтропия Шеннона 238
язык контекстно-свободный 269
| не контекстно-свободный 271

Указатель имён
Адельсон-Вельский, Г. М. 258
Ахо (Aho, A. V.) 70, 197, 311, 312
Баур (Baur, W.) 27
Брудно, А. Л. 25, 228
Вазирани (Vasirani, U.) 312
Вайнцвайг, М. Н. 40
Варсанофьев, Д. В. 40, 248
Вирт (Wirth, N.) 312
Вьюгин, М. В. 42
Гарднер (Gardner, M.) 217
Гасфилд (Gus˛eld, D.) 312
Гейл (Gale, D.) 217, 219
Грис (Gries, D.) 25, 31, 34, 41, 312
Гросс (Gross, Oliver) 218
Гэри (Garey, M. R.) 70, 312
Дасгупта (Dasgupta, S.) 312
Дейкстра (Dijkstra, E. W.) 13, 21,
36, 312
Део (Deo, N.) 268, 312
Джонсон (Johnson, D. S.) 70, 312
Диментман, А. М. 40
Звонкин, А. К. 141
Звонкин, Д. 14
Карп (Karp, R. M.) 169, 171, 172,
174
Каталан (Catalan, C. E.) 55, 59,
137
Кёниг (K}onig, D.) 176
Кнут (Knuth, D. E.) 182
Коган, А. Г. 37
Кормен (Cormen, T.) 312

Крафт (Kraft, L. C.) 231, 233
Кушниренко, А. Г. 25, 27, 38, 104,
105, 312
Ландис, Е. М. 258
Лебедев, Г. В. 312
Лейзерсон (Leiserson, C.) 312
Липский (Lipski, W.) 312
Лисовский, Анджей 141
Макмиллан (McMillan, B.) 231,
233
Матиясевич, Ю. В. 4, 21, 182
Мотвани (Motvani, R.) 312
Нивергельт (Nievergelt, J.) 268,
312
Пападимитриу (Papadimitriou,
C.) 312
Паскаль (Pascal, B.) 4, 136
Рейнгольд (Reingold, E. M.) 268,
312
Ривест (Rivest, R.) 312
Сети (Sethi, R.) 197, 311, 312
Сэвич (Walter Savitch) 132
Торхов, Ю. Н. 4
Ульман (Ullman, J. D.) 70, 197, 311,
312
Фалкерсон (Fulkerson, D. R.) 164,
166{168, 171, 175, 176
Фано (Fano, R. M.) 237
Форд (Ford, L. R., Jr.) 164, 166{
168, 171, 175, 176
Хоар (Hoare, C. A. R.) 3, 132, 143

320

Предметный указатель

Холл (Hall, P.) 176, 177
Хопкрофт (Hopcroft, J.) 70, 312
Цвик (Zwick, U.) 167
Шеннон (Shannon, C.) 218, 237,

238
Штрассен (Strassen, V.) 27
Эдмондс (Edmonds, J. R.) 169, 171,
172, 174

Научно-популярное издание
Александр Шень

ПРОГРАММИРОВАНИЕ: ТЕОРЕМЫ И ЗАДАЧИ
Оригинал-макет: В. Шувалов
Дизайн обложки: У. Сопова
Подписано в печать 25.08.2016 г. Формат 60 × 90 1/16. Бумага офсетная.
Печать офсетная. Печ. л. 20. Тираж 2000 экз. Заказ Ђ
Издательство Московского центра
непрерывного математического образования.
119002, Москва, Большой Власьевский пер., 11. Тел. (499) 241-08-04.
Отпечатано в ООО «Типография Миттель Пресс\».
"
г. Москва, ул. Руставели, д. 14, стр. 6.
Тел./факс +7 (495) 619-08-30, 647-01-89.
E-mail: mittelpress@mail.ru
Книги издательства МЦНМО можно приобрести в магазине
«Математическая книга», Москва, Большой Власьевский пер., д. 11.
Тел. (495) 745-80-31. E-mail: biblio@mccme.ru