Прикладные структуры данных и алгоритмы. Прокачиваем навыки. [Джей Венгроу] (pdf) читать онлайн

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


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

~nnTEP®

А

cOmmon-Sense QUide to Data
Structures and Algorithms.
Second Edition
Level Up Your Core Programming Skills

JayWengrow

Тhе Pragrпatic

Booksl1elf

Raleigh, North Carolina

приклапные

структуры ,данных
и алгоритмы
прокачиваем навыки

Джей Венгроу

Выпущено при поддержке:

Санкт-Петербург

2024

• Москва •Минск

КРОК

Джей Венгроу
Прикладные структуры данных и алгоритмы.

Прокачиваем навыки
Перевел с английского С. Черников

Научный редактор Анна Белых, старший инженер-разработчик компании КРОК
ББК
УДК

32.973.2-018
004.422.63+004.421

Венгроу Джей

829

Прикладные структуры данных и алгоритмы. Прокачиваем навыки.

Питер,

2024. -

512

с.: ил.

-

СПб.:

-

(Серия «Библиотека программиста»).

ISBN 978-5-4461-2068-0
Структуры данных и алгоритмы

это не абстракrные концепции, а турбина, способная превратить

-

ваш софт в болид «Формулы-!». Научитесь использовать нотацию

«0

большое», выбирайте наиболее

подходящие структуры данных, такие как хеш-таблицы, деревья и графы, чтобы повысить эффекrивность
и быстродействие кода, что критически важно для современных мобильных и веб-приложений.
Книга полна реальных прикладных примеров на популярных языках программирования (Pythoп,

JavaScript и Ruby),

которые помогут освоить структуры данных и алгоритмы и начать применять их в по­

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

16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. N11436-ФЗ.)
ISBN 978-1680507225 англ.
ISBN 978-5-4461-2068-0

© 2020 The

Pгagmatic Pгogгammeгs,

LLC.

©Перевод на русский язык ООО «Прогресс книга».

2023

©Издание на русском языке, оформление ООО «Прогресс книга»,
©Серия «Библиотека программиста»,

Права на издание получены по соглашению с

The

2023

2023

Pгagmatic Pгogгammeгs,

Все права защищены.

LLC.

Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного
разрешения владельцев авторских прав.

Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как на­
дежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возмож­
ные ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность
которых запрещена на территории Российской Федерации, таких как Meta Platforms lпс" Facebook, lпstagгam
и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти
в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.

Изготовлено в России. Изготовитель: ООО «Прогресс книга». Место нахождения и фактический адрес:
Россия, г. Санкт-Петербург, Б. Сампсоииевский пр" д. 29А, пом.
Дата изготовления:

Налоговая льгота

-

08.2023.

52.

Тел.:

194044,

+78127037373.

Наименование: книжная продукция. Срок годности: ие ограничен.

общероссийский классификатор продукции ОК

034-2014, 58.11.12 -

Книги печатные

профессиональные, технические и научные.

Импортер в Беларусь: ООО «ПИТЕР М»,
Подписано в печать

05.07.23.

220020,

РБ, г. Минск, ул. Тимирязева, д.

Формат 7Ох100/16. Бумага офсетная. Усл. п. л.

Отпечатано в типографии ООО «Экопейпер»,

420044,

121/3,

41,280.

к.

214,

Тираж

тел./факс:

1500 экз.

208 80 01.

Заказ Ф-1575.

Россия, г. Казань, пр. Ямашева, д. 36Б.

Предисловие""".""".""." ...•. """"" .•"""""" .•.. ""."""•. "."" .•.. " .. """"""."".""" ••""•. """"."" .•""."19

Глава 1. О важности структур данных""""""""""""".""""""""."".""""""""""""""".""""".27

Глава 2. О важности алгоритмов """""""""""""""""""""""""""""""""""""""""""""."".""46
Глава 3. О да! Нотация «О большое» """""""""""""""""""""""""""""""""""""""""""""""61
Глава 4. Оптимизация кода с помощью О-нотации ".""""""""""""""""""""""""""""""". 73

Глава 5. Оптимизация кода с О-нотацией и без нее"""""""""""""""""""""""""""""""""89
Глава б. Повышение эффективности с учетом оптимистичных сценариев"."""""""""1 Об

Глава 7. О-нотация в работе программиста """"""""""""""""""""""""""."""""""""""".123
Глава 8. Молниеносный поиск с помощью хеш-таблиц """"""""""""""""""""""""""""142
Глава 9. Создание чистого кода с помощью стеков и очередей """""""""""""."""""""162

Глава 1О. Рекурсивно рекурсируем с помощью рекурсии """"""""""""""""""""""""".179

Глава 11. Учимся писать рекурсивный код .""""""""""".".""""""""""""""""""""""""""192
Глава 12. Динамическое программирование""".""""""""""""."""."""""""""."""""""".215
Глава 13. Рекурсивные алгоритмы для ускорения выполнения кода""""""""""""""".231

Глава 14. Структуры данных на основе узлов """"""""""""""""""""""""".""""""""""".259

6

Краткое содержание

Глава 15. Тотальное ускорение с помощью двоичных деревьев поиска """""""""""".281

Глава 16. Расстановка приоритетов с помощью куч""""""""""""""""""""""""""""""".312

Глава 17. Префиксные деревья """"""".""""""""""""""".""""""""" ."""""""."""""""."".338
Глава 18. Отражение связей между объектами с помощью графов"""""""""""""""""365

Глава 19. Работа в условиях ограниченного пространства """""""""""""""""""""""".422

Глава 20. Оптимизация кода""."""""""""""""."".""""."""".""""""."".""."."""""".. ""."""433
Приложение. Решения к упражнениям"""".""""""."""".""."""""".""."""""."""""."""""475

...

Оrлавление

Отзывы о втором издании."."."." ..".".".""" .. "" ......................................." ......" ... ".""""" .."."""""" .. ".17

Предисловие""."" .. """"." ... "..".""."".. ".. "... """ ..""."".""""."".""."".. "..".""""."."."".""."".""19

Для кого эта книга """."."""."."""""".""""".".".".".""".".""""""""""""""""""""""""""""""""""""20
Новое во втором издании """""""""""""""""."""""".""".""".".""".""".""""""""""""""""""""".20
Что вы найдете в этой книге """""""""""".""""""""""".""""""""""""""""."".""""""""""""""""21

Как читать эту книгу """""""""""""""""""""""""""""""""""""""""""""""""".""""".".""""""""".22

Примеры кода ".""".""""""""""""""""""""""""""""""""""""""""""""""""""""".""".""""".""""".24
Интернет-ресурсы "."""".""".".""".""".""""""""""""""""""""""""""""""""""""""""""""""".""".24
Благодарности """"""""""".""""""""""".".""".""""""""."."."""."."""""."""""""""""""""""""""".25

Обратная связь """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""".".""""""".".26
От издательства.""""."""""""."."."""."".""""""".""".""""""""""""""""""""""""""""""""."""""."26
Глава

1. О важности структур данных" .. "... "... ".. "... ".. "... "... ".""." ..."".""...... "... "... "......... "... 27

Структуры данных"""""""""""""""""""""""""""""""""""""""""""""""""""""""."""""."""""."."28
Массив: базовая структура данных"""""""""""""""""""""""""""""""".""""."""."""."""".".""29
Операции над структурами данных"""."."""""""""""""""""""""""""""""""""""""""""""""".30
Измерение скорости ."."""."."."."""."."""."""""".""".".""""".".""""""""""""""""""""""""""""".30

Чтение"""""""""""""""""""".""""""""""".""""""."""""".""".".""""""""."""""""".""""""""""""""31
Поиск"""""""""""""""""""""""""""""""""""""""""""""""""""""""""".".""""""""".""""""".""".".34
Вставка"."""""""""".""""."""""""""""""""""""""""""""""""""""""""""""."".""."""""."""."""."""37
Удаление ."."""."."""."""."."."."."""."""."."."""."."""""""".""""."""""""""""""""""""""""""""""".40
Множества: как одно правило может повлиять на эффективность"""".".""""""""""""""41

Выводы """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""".""."""."""""."""."".45
Упражнения """""""""."""""""""""""""""""""""""""""""""""""""""""""""""."""""""""""".""""45

8

Оглавление

Глава 2. О важности алгоритмов ."".".. """".".. "".""""."""".".. """"."""".""".".. "" .. " ... " .... " .. 46
Упорядоченные массивы ...... """.""."" ........... "."""."""""" ........ """.".".""".""." .................................. 47
Поиск в упорядоченном массиве ............................................................................................................50

Бинарный поиск .... ".................. ".........................................."........... "........................................................... 51
Программная реализация ...................................................................................................................54

Сравнение алгоритмов бинарного и линейного поиска

............................................................. 56

Викторина ........................................"." .................. "."..."" ........................................................................ 59
Выводы ................................................................................................................................................................59
Упражнения .......................................................................................................................................................60

Глава 3. О да! Нотация «О большое».""."."""""".""" .•""""""""" ..""""."".•. " ... """" .•. "".""."61

«0 большое»:

количество шагов при наличии

N элементов ......................................................62

Суть О-нотации ................................................................................................................................................63
Погружение в суть О-нотации ...........................................................................................................65

Один алгоритм, разные сценарии ...................................................................................................66
Алгоритм третьего типа ...............................................................................................................................66

Логарифмы

........................................................................................................................................................67

Значение выражения

O(log N) .................................................................................................................68

Практические примеры ...............................................................................................................................69
Выводы ..............................................................................................."...............................................................71
Упражнения .......................................................................................................................................................71

Глава 4. Оптимизация кода с помощью О-нотации """"""" .•""" .•""""""""."""""""""""". 73
Пузырьковая сортировка

...........................................................................................................................73

Пузырьковая сортировка в действии ...................................................................................................75
Программная реализация ...................................................................................................................79

Эффективность пузырьковой сортировки .........................................................................................81
Квадратичная задача

....................................................................................................................................83

Линейное решение ........................................................................................................................................85
Выводы .... "....................... ".................................................................................................................................87
Упражнения .......................................................................................................................................................87

Глава S. Оптимизация кода с О-нотацией и без нее"""""""""""""""."""""""."""".""".""89
Сортировка выбором ..................................................................." .... "........................................................ 89
Сортировка выбором в действии .......................................................................................".................. 90
Программная реализация ...................................................................................................................96

Оглавление

9

Эффективность сортировки выбором ..... " .. "."."." .."."." ......................."."." ... """ .. "" ..." ... "" .. """"97

Игнорирование констант"."""""" .. " ... """"""""""""""""".""""""""""""""""""""""""""""""""".98
Категории алгоритмической сложности в О-нотации"""""""""""""""""""""""""""""""" 100
Практический пример""""" .. """""""""""."""."""."""""""".""".. ".".""""" .." .. """." .. "."""". 102

Значимые шаги"""" .. """" .. ".""""".""""""""""".""""".""""""""""".. " .. "."".""""""".... " .. "." ... 103

Выводы"" .. """"""".".. """"""""""""""""""""""""""""""""""""".. """"""""""""""""""""""""""""103

Упражнения"""""""""""""""".""".""""""""""""""""""""" .. """.. """""""""""""""""".""""""""".104
Глава б. Повышение эффективности с учетом оптимистичных сценариев ................... "1 Об

Сортировка вставками"""" .." .. "."." .. ".""""""""""""""""""""""""""""""""".""""""".""""".".".106
Сортировка вставками вдействии" .. """"""""""""""""".""""""""."."""""""""..".""""""""""108

Программная реализация"""""""."""""""""""""""" .. " .. "."""""""""""" .. """"""""""""""" 112

Эффективность сортировки вставками"""""".""""".""""""""""."""".""" .. """"""""" .. """"".114
Средний случай""""""""""."." .. "."""" .. """"""""""""""".""""""""""""""".. """""""""""""""""" 116
Практический пример"""""""""""""" .. """"""""""""""""... "" .. """"""""""""... """""""""""""". 118

Выводы"""""""""""""""""."".""""""""" .. """" .. " .. """."""""" .. "."""."""." .. """" .. "" .. """." .. """"".".121
Упражнения""""""""""""" .. "."" ... """"""""" .. """.".. """""""""""""." .. " .. """ .. """."""""""""""."""121
Глава

7. О-нотация

в работе программиста ..............................................................................123

Среднее арифметическое четных чисел"".""".""""."."""."".""." .. """""""."""."""""."".".. ". 124

Конструктор слов""""""""""""""""""""""""""""""""""""".""""""""""""""""""""""".""""""" 125
Выборка из массива" .. " .. ".""""""""""""""""""""""".. """"""""""""" .. """."""."".""""""""""".." 127
Среднее значение температуры в градусах Цельсия"""""""""""""""" .. """"""""""""""". 128

Бирки для одежды""""""""""""."""""" .. """"""""""""""""""""""""""""""""".. """""""""""""" 129

Подсчетединиц""""".""""""""""""".""""""""""""""""""."""""" .. """" .."""""""""""""""""""".130
Поиск палиндрома"""""".""""""""""""""""""""""""""""""""""""""""".""""""""""".. """"""" 131
Вычисление произведений всех пар чисел""""""""""""""""""""""""""""""""""""""" .. "" 132
Работа с несколькими наборами данных""""""""""""""""""""""""""." .. """""."""""". 134
Взломщик паролей """""""""""""""""""""""""""""""""".""""""""""""""""""""""".." .. """""" 135

Выводы""""""""""""" .. """""""""""""""""""""""""""".. ".""""""""""""" .. "" .."""""""""""""""""138
Упражнения""""""" .. """""""""""""."."""""""""""""""""""""""""""""""""""""""""""""..""""" 138
Глава

8. Молниеносный поиск с помощью хеш-таблиц ............................................."... ".... 142

Хеш-таблицы"" .. ".""""".""""""."..... " .. "".".""""""."."".. "."".""" .."""""...." .. " .. "."" .. " ... ""..... " .. " .. ".142
Хеширование." .. ".""".. "" .. "."." .. "."...." .. " ..... "." .. "."." .. "."""" ... ".".. "."..... "."." .." .... "."......... " .. "."." ... 143
Создание тезауруса для удовольствия и прибыли, но в основном для прибыли "." ... 145

10

Оглавление

Поиск в хеш-таблице.""""." .. "."""."""."".".""""..""."".""."...".".""."""""."""".".".""".".""".".""147
Однонаправленный поиск."." .. " .. ".".".. ".""."".".".".""".".".".. ".".".".. ".".""""""""".".".""" 148

Разрешение коллизий".""."""""""""""."""."".""""""""."""."""".".""""."."""."".""""."."""""" 148
Создание эффективной хеш-таблицы."."."""""""".".""."""""."""".".".".""".""".""."""".""." 151
Великий компромисс.".""".""".".""".""""" .."."".".. " .."."..".".." ... """.".""".""."."""."."".""" 152

Хеш-таблицы для организации данных."""."".""""""".".""."."""." .. """"."."""."".. "."."""".". 153
Хеш-таблицы для ускорения выполнения кода.".".""""""""."""."."""."."".".""""."""".""" 155
Подмножество массива" .. "."."""."""."""""""."."".".. ".".""".".""."".".. ".""""""".".""".""".. 156

Выводы""""."""""." .. ".""."""".. ".".""".".""".".. "."."""""".".. ".""".""""""."."""... ".".""."".. ".".. ".".160
Упражнения"."."""".""."""".""."""".".""." .. "."".""."""""""".".""."."".. "."."....... " ..""".""""."".".". 160
Глава 9. Создание чистого кода с помощью стеков и очередей """""""""""""""""."""162

Стеки".""" .. " .. "."""."".".".".".. ".""".""".""."".""".".".""""""."""."""""..".""."."" .."""."..""""""".""162

Абстрактные типы данных".".""".".".".""".""""""""."""".".".""".".""""""."""""""".""."."""". 165
Стек в действии ..".".."""".""""."""".".""."""."""".."."."."."..... """""."".. ".".. """."""""".. ".""".".". 166

Программная реализация: линтер на базе стека"""".""".""".""""""."."""""."""."""." 170
О важности ограниченных структур данных".""""""."."""""""".""".""""""""".""".".""".". 173
Очереди """"."."""""".""".. ".""""".""".".. ".".""""".".."."""".. ".""."".".. ".".. ""."".""".""".""""".".. 173

Реализация очереди"."""""."""".""."""""."."."""."."""."""."."."""""."""".".""".""""""."".175
Очередь в действии .""""""".. """"""""".""""""."""."."""""""".".""."" "."""."""""""""."."."""" 176

Выводы""""""."""""."."""...".".".""".""".""""".".".""""""""."."""." ""."."""""""."""""."."."""."".177
Упражнения .. ".""."""."".. ".""".. ".".""".. ".""".".""".""".".".""".""""""."""".""."" .""".".. ".""".""".178
Глава 1О. Рекурсивно рекурсируем с помощью рекурсии """""""""""."""."".""."".".".179
Рекурсия вместо цикла"" .. ".""".""".""""".".""".".".""".".".. "."""."."""."""."".""".""".. "".".".". 179
Базовый случай ."""."""."""."""""."."".".".""".".. "."."."."..."".. ".".""".".. ".".""."""."""".""""""." 181
Чтение рекурсивного кода"""."""."." .. """.".".""""""."""."."""""""""""""."".". ".""".".".""".". 182

Рекурсия глазами компьютера".".""".""".""".".".".""".""" ..""."""."""."."""".".".".".".".. ".".. " 184
Стек вызовов""""." .. ".""".. ".".""".. ".".. ".".. "."."""""".".. ".".""".".. ".".".""""""".".".".".""".". 185

Переполнение стека""."""""."""." .. "."."""."."."""""""""."".".""".""".".""" """""""."."""". 187

Обход файловой системы"."""".""""""""."""."""""".""".""""""""""".""".".".""."."..."".""""" 187
Выводы.".""""" .. ".""".".. ".".""".. ".".""".. ".""".".""".."".. """.""""""""".""""""."".".".""".".. ".".. " .. 190

Упражнения""."""."""."""."""."""."""""."""""""""."""""."."""."""""".".""""""""""""".".""""". 190
Глава 11. Учимся писать рекурсивный код """""""""""""""""""""""""""""""" .""."""".192
Категория рекурсивных задач: многократное выполнение действия .".""".""".""".""" 192

Рекурсивный прием: передача дополнительных параметров".""""".""""""""."""" 193

Оглавление

11

Категория рекурсивных задач: вычисления"".""" ........" .."""."."""""" ......"" ......" .................... 197

Два подхода к вычислениям""""""""""""""""""""""""""""""""""""""""""""""""""""".198
Нисходящая рекурсия: новый способ мышления"""""""""""""""""""""""""""""""""""" 199

Нисходящий способ мышления""""""""""""""""""""""""""""""""""."".""""""""""""" 200
Вычисление суммы элементов массива"""""""""""""""""""" ..""""""""""""".""""""". 200

Обращение строки"""""""""""""""".""""""""""""""""""""""""""""""""""""""""."""."". 202
Подсчет символов «Х» в строке""""""""""""""""""""""""""""""""""""""""""""."."""". 202
Задача с лестницей """"""""""""""""""."""""""""""""""""""""""""""""""""""""""""""""""" 204

Базовый случай для задачи с лестницей""""""""""""""""""""""""""""""""""""""""". 207

Генерация анаграмм""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""". 209
Эффективность алгоритма генерации анаграмм""."""""""""""""""""""""""""""""". 211

Выводы""""""""""""""""."""."""""".""""""""""""""""""""""""""""""""""""""""""""""""""""213
Упражнения""""""""""""""""""""."""""""""""""""."""".""""""""""".""""""""."""""""."""""" 213
Глава

12. Динамическое программирование" .."."."" .. ".. "."" ... "... "." ...... "" .."" ..""." ..... "...215

Бесполезные рекурсивные вызовы" .. "".""".""""."""."""""""."""."""."""... ""."""."."""."""" 215
Пошаговый разбор выполнения рекурсивной функции max """""""""""""""""""" 217

Маленькое исправление для большого «0» """"""""""""""""""""""""".• """"""""""""""". 219

Эффективность рекурсии"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 220
Перекрывающиеся подзадачи""""""""""""""""""""""""""".""""""""""" .. """"""""""""""". 221
Динамическое программирование с помощью мемоизации "."""""""""""""""""""""". 223

Реализация мемоизации """""""""""""""""""""""""""."""""."""""""""""""""""""""""" 225
Восходящее динамическое программирование """""""""""""""""""""""""""""""""""" 227
Восходящий подход для вычисления элементов

последовательности Фибоначчи """""""."""""""""""..""""""""""""""""""""""""""""" 227

Мемоизация и восходящий подход""."""""""""""""""."""""""""""""""""""""""""""". 228

Выводы"""""""""""""""""""""""""""""""""""""""""""""".""""""""""""""""""""""""""""""""229
Упражнения"""""""""""""""""""""""""""""""""""""""""""""."""""""""""""""""""""""""""". 229
Глава

1З. Рекурсивные алгоритмы для ускорения выполнения

кода""" .. "".. "... "... "... "231

Разбиение""""""""""""""""""""".""""""""""""""""""""""""""""""""""""""""."""""""""""""" 232
Программная реализация"""""""".""""""""""""""""""""""""""""""."".""""""".""""""" 235
Алгоритм быстрой сортировки (Quicksort) """""""""""""""""""""""""""""""""""."""""." 238

Программная реализация""""."""""""""""""""""""""""""""""""""""."""""""""""""""" 243
Эффективность быстрой сортировки"""""".""""""""""""""""""""""""""""""""""""""""". 244
Взгляд на быструю сортировку сверху""""""""""""""""""""""""""""""""""""""."""". 245
Вычисление эффективности быстрой сортировки с помощью О-нотации""""""" 247

12

Оглавление

Временная сложность быстрой сортировки в худшем сценарии" .."."."."."."..".".."..".". 250

Быстрая сортировка и сортировка вставками."""."."." .. "" ... ".".".. "."..."."."..".""".""".. 251

Алгоритм быстрого выбора."""."""."""." .. ".".".. ".... """.".".. ".".".".. ".".".. "."."".. ".".""".".. "."... 252
Эффективность алгоритма быстрого выбора """"."."""""""".".""."""."."""""""."""." 254
Программная реализация."." .. ".""".".. ".".".. ".".".. ".".".. ".".. """".".. "."."."""".""".""".. "."" 254

Сортировка как основа для других алгоритмов"""."""."."""."."."""."."""".".""""""""""". 255
Выводы".""" .. ".""".""".. ".""".""".. ".".. ".".. ".".".""".".. ".".""".".. ".. "".""".".".. "."".".. ".".. ".".. ".".. ".257
Упражнения""" .. ".".. ".. """.".".. ".. ".".. ".".""".".".. ".. "".. ".".. ".".".. ".".".. ".".".. "."".. ".".. ".".. ".".. ".. ". 257
Глава

14. Структуры данных на основе узлов ."..""."... ".."....".."."".."..."."""..."..."".""." ..259

Связные списки .".".. ".""".. ".. "."."".... ".".".. ".".".""".".".""".""".. ".".. ".".".. "."... ".. ".""".""".. ".".. " 259
Реализация связного списка"."." .. ".".""".. ".".".. ".".".".. ".".. ".".. ".. ".".".".. ".".".".. ".. ".. "".. ".""" 261

Чтение"."."""".""""." .. ".. ".. "."."" ... ".. ".. ".".".. "." .. "."""".".".".. ".".".. ".. ".".".. ".. "".".. ".".. "."""""."".263
Программная реализация: чтение элементов связного списка"."""".""""".""".""". 264

Поиск." .. "."".".".""".. ".".. ".. ".""".""".. ".".. ".".".. ".".. ".".".".".. "."... "".. ".".. ".".".. "... ".".. ".. ".. ".".. ".. ".265

Программная реализация: поиск по связному списку"""".".""".""""."."""."""""""." 265
Вставка .. ".. ".".. ".. ".".. ".".. ".. ".. ".".. ".".. ".".. ".".. ".".. ".".".".. ".".".. ".".. ".".".. ".""".. "".".".. "..."".. ".".. ". 266
Программная реализация: вставка элемента в связный список" ..""".""."""."."""." 269

Удаление""" .... ".".. ".. ".".. ".. ".".""".. ".. ".".. ".".".. "."..... ".".".. ".".. ".".. ".".".. "."."""".".. ".".. "."..... """. 271
Программная реализация: удаление элемента из связного списка."""""""."."""." 272

Эффективность операций над связными списками""".""""""."""."""""""""."""""."."""" 273
Связные списки в действии" .. ".""".".. ".""".. ".".".. ".".".. ".".".. ".".. ".".. ".".. "."."".. ".".. ".".. ".".. ". 274
Двусвязные списки."" .. "."".".".. ".".""".""".".. ".""".".""." ... """.""".".. ".".".. "."".""""""".. """".. " 275
Программная реализация: добавление элемента в двусвязный список."."""."."." 276

Движение вперед и назад."."""." .. ".".. ".".. """""".".".".""""".. "."."""."... """.""""".".".. """ 277
Очереди на основе двусвязных списков .. ".".".. ".. ".".".. ".".. ".".. ".".. ".".. ".""..... ".. "."."."."."." 277

Программная реализация.".""""".""".".""".""" .. ".".".""".".""".".. ".".. "."".""".. ".".".""".". 278
Выводы"""""".".""".""""" .. ".. ".".. ".".. ".".. "."."" .... """".. ".".. ".".".. ".".".. ".".. "."... ".""".. ".""""".. "."279
Упражнения.""".""""".""" .. ".".""".. ".".. ".""".".. ".".".""".""".. ".".".".. ".""".".".... ".".. "."."."."."""" 280
Глава

1S. Тотальное ускорение с помощью двоичных деревьев поиска """""""""."""281

Деревья"."""."""""""." .. ".".. ".. ".".".. ".".. ".".. "."""..... ".".".. ".".".. ".".".. ".".. ".".".""."."."""".. ".".. ".. 282
Двоичные деревья поиска." .. ".".. ".".""".".".. ".. ".".".".. ".".. ".".".. ".".. "."."."" .. "."."."""".".. "."... 284

Поиск.""""".""" .. ".".""".. ".""""" ..."".".""."""".. ".".".""".".".. ".".".. ".".. ".".".".. ".. """""".".".. "."... ".285
Эффективность поиска в двоичном дереве поиска """"""""""""""".".""""""""""."". 287

Log(N) уровней".".""""" .. ".".""".""".".. ".".."."."""."."" ... "..".".".""".".".. "... ".".".".""".. ".".. ". 288

13

Оглавление

Программная реализация: поиск значения в двоичном дереве ... """" .."."""""." .... " 289
Вставка .............................................................................................................................................................. 290
Программная реализация: вставка значения в двоичное дерево поиска ................ 292

Порядок вставки ................................................................................................................................... 293

Удаление"."" .. """."."""""" .. " .. """.".".. ".""".".""""""."""""""."."""."""""."""".. """""".""""""""" 294
Удаление узла с двумя дочерними элементами
Поиск узла-преемника

.................................................................... 296

...................................................................................................................... 297

Узел-преемник с правым дочерним элементом ...........................".. "................................... 299
Полный алгоритм удаления ............................................................................................................ 300
Программная реализация: удаление значения из двоичного дерева поиска ........ 300

Эффективность удаления значения из двоичного дерева поиска

...... "....................... 305

Двоичные деревья поиска в действии .................. ".......................................................................... 305

Обход двоичного дерева поиска" ........................................................................................................ 306
Выводы

............................................................................................................................................................. 310

Упражнения ............................................................................................".................................".. "............... 310

Глава

16. Расстановка приоритетов с помощью куч""." ........................."" ..."......................312

Приоритетные очереди ......... "............ "................................."...............................".................." ........... 312
Кучи .................................................................................................................................................................... 314

Свойство кучи ................................................................................................"""".".".""."."""""""". 315

Полные деревья"." .... ""."""""""""""""""""""... ".. "" .. """"".""""".".. """""""""""""".""""". 316

Особенности кучи"""""".""".""""""."""""""""""""""""" .. """""""""""""""".. """ .. """"""""""" 317
Вставка в кучу .. """.""""""""""""""""""""".. ".""""".""""".""".".. ".".""."""""".".".""".""".""""." 318
Поиск последнего узла ".".. ".".. "."" .. "".".""".""""""""""""""""""""".""".".""""""""""."""""." 321

Удаление из кучи"""""""" .. ".""".".".. """""""""""""""""""""""""""""""."""""""""""""""""""" 322

Кучи и упорядоченные массивы".".""""""""""".""""""""""""""""""""""""""""""""""""""" 326
Проблема последнего узла ... снова"""."""."""""""""""""".""".""".""""" .... ".".. ".. ".".. "." .. ". 327
Массивы в качестве куч""""."""."""""""."."."" ... """""""""""".".""".".. """" .. ""." ... ".. """" .. ""." 330
Обход кучи на основе массива".""""" ... """.""".".. """"""""" ... ".".. ".. """""""""""""."""". 331
Программная реализация: вставка значения в кучу""""""""""""" .. """"""""""""""". 332
Программная реализация: удаление значения из кучи"""""""""" .. ""."... """""""" .. ". 334

Другие варианты реализации кучи""" .. "."""."""""."."""."""""."""."."."""""""""... """." 336
Кучи в качестве приоритетных очередей """"""".. ".".""."""""""""""""""""""""""""""""" 336

Выводы"."""""""""""""""""."""""".""""""."""""".""""""".""""""""""""""".""."."""""""""""""337
Упражнения""."." .. """"""""""""""""""""""""""""""".""""""""""""""""""""""""""."""""".. """ 337

14

Оглавление

Глава 17. Префиксные деревья """"""""""""""""""""""""""""""""""""""""""""""""""338
Префиксные деревья"""".""""".".""""".""".""""" .............. " .... "." ..................................................... 339

Узел префиксного дерева"".""".""".""""""""."."""""".""".""."""""""."""""""""""""""". 339
Класс Trie """""""".""""""""."."""."""."""."""."""""""""""""""""""""""""""""""""""""""". 340

Хранение слов"""""""""""""""""""""""""""""."""."."""."."""."."""."."""."."""".""".""""""""" 340

Важность звездочки""""""""""""""""""""""""""""""""""""""""."."""""""""."""."""."."" 342
Поиск в префиксном дереве"."."""""""""""""""""""""."""""""""""""""""""""""""""""""". 344
Программная реализация."""""".""""""""""""""""""""""""""""""""""""""""""""""""" 346
Эффективность поиска в префиксном дереве".".".""".""".""""""."""."""""""""""""""""" 348
Вставка значения в префиксное дерево."""."."""."."""."""."""."""""""""""""""""""""""". 349
Программная реализация"""""""""""."""""""".".".""""""""""."."""."".""".""".".""".""" 353
Создание функции автозаполнения """""""""""""""""".""""""""""""""""""""""".""""""". 354

Собираем все слова""""".""".""""""."""""""""""""""""""""""""""""""""""""""""""""". 354
Пошаговый разбор рекурсивных вызовов""".""""""""."""."."""."."".""""".""""".""". 356
Завершение функции автозаполнения """"""""""""""""""""""""""""""""""""".""""""".". 360
Префиксные деревья с дополнительными значениями: улучшенная функция

автозаполнения"""""""".""".""".""""".""""""""""""""""""""""""""""""""""""""""""""""""" 361

Выводы""""""""""""""""""""""""."""."."""."""."""""""".""".""""""""""""""""""""""""""""""362
Упражнения"""""""""""""""""""""""""""""".""""""""."""""".".""".""".""".""""."""."""""""". 363
Глава 18. Отражение связей между объектами с помощью графов."".""".""."""""."."365

Графы""""""".""".""".""""""""""""""""""""""""""""""""""""""""""""""."""".".""""".""""""""366
Графы и деревья"""."""""."."""."."""""""""""""""""""""""""""""""""""""""""."""""""". 366
Терминология графов""""""""".""""".""".""".""""""."""""""""""""""".""""""""""""""" 367
Простейшая реализация графа""""""".".""""""."""""""""".".""".".".".".".".""".""""".". 367

Ориентированные графы".""""""""""""""""""""""""""""".""""""."""""""""."."""""".""".". 368
Объектно-ориентированная реализация графа""""""""""""""""".""""""""""""".""""."" Зб8

Поиск по графу""."""."""."""""".""".""".".".".".""""""""""""".".".""""""""""""."."""."""."""". 371
Поиск в глубину"."" .."""""".".".".""."""".".".""""""""."."""."."."""."""""".".""""".""".""""".".. 373

Пошаговый разбор поиска в глубину"""".""".".""".".".""""""""."."."""".""."""".""".""374

Программная реализация."""."".""".""."."."""""""""".""".""""."""."""""""."."""."""""" 381
Поиск в ширину""."""""."""""".".".".""".""".""".".""".""".".".""".".".""".""""".""".""".""."""" 382
Пошаговый разбор поиска в ширину"""""""."."."."""."""."."""."."."".".""".".""""".""" 383

Программная реализация""".""""""""""""""."".""."".""."""".""""."."".""."."""."""".""" 392
Поиск в глубину и поиск в ширину """"""."""."."""."."."."""."""."."""""""."."""."""."". 393

Оглавление

15

Эффективность поиска по графу." .. "."."."."""."""." .. """""""""""".""""""".. """"""""""""""" 395
Временная сложность O(V + Е) """"".".""""".""".""".""""""""""""""""""""""""""""""". 397

Взвешенные графы.".""""""""""."""""".""".""""""""""""."."""""""""""""""""""""""""""""" 398
Реализация взвешенного графа"".""""""""""""""""""""""""""""""""""""""""".""""""400
Задача о кратчайшем пути""""""""""".""""".".""""".""""""""""""""""""""""""""".""""400

Алгоритм Дейкстры""""""""""""""""""""""""""""""".""""""""""""""".""""""."."""""""""""401
Подготовка алгоритма Дейкстры """.".""""""""""""""""""""""""""""""""""."""""""""402
Этапы алгоритма Дейкстры """"""""""""""""""""""""""""""""."""""""""""""""""""""" 403
Пошаговый разбор алгоритма Дейкстры """""""""""""""""""""."""""".""".""""".""". 404

Поиск кратчайшего пути"".""""""."""."."."""""."""""""""""""""""""""""."""""""""""""411
Программная реализация: алгоритм Дейкстры """".".""""""".""".""""""""""""""""" 412
Эффективность алгоритма Дейкстры """"""""""""""""""""""."""""""""""""""""""""" 418

Выводы""""""""""""""""""""""""""""""""""""""""."""""""""""""""""""."""""""""""""."""""419
Упражнения""".""""""""""""""""""""""""""""""""""""""""""""""""""""".""""""""""."""."""419
Глава 19. Работа в условиях ограниченного пространства """ ..""."""""""""""."".""""422
Выражение пространственной сложности с помощью О-нотации ..." ...... ".""."".".".""." 422

Компромисс между временем выполнения и занимаемой памятью """""""""""""""".425

Скрытые издержки рекурсии"""""""""""""""""""."."""""."""."""""""""""""""".""""""""""428

Выводы"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""."""""""""""""430
Упражнения"""""""""""""""""""""""""""""""""""""""""""""""""""."""""""""""""""""""""".431
Глава 20. Оптимизация кода"""""."""""""""".""""""""""""""""".""""""""."""""""""""433
Предварительное условие: определение текущей эффективности".""""."""""""".""". 433

Определение лучшей эффективности из возможных""""""""""""""."""""""".""""""".". 434

Развитие воображения""."."""."."""."."""""""".""""".""""".""""""""""""""""""""""""". 435

Волшебные поиски""""""""""""""""""""""""""""""""""""""""""""."""""""""""""""""."""". 436
Волшебный поиск авторов книг """"."""""""""""""""""""""""""""""""""""""""""""""436
Дополнительная структура данных""""""""."""""""""""".""""""""""""""""""""""""" 438

Проблема двух сумм""""""""""""""""""""""""""""""""""""""""""""""."."""""."."""""".440
Выявление закономерностей"""""""".""".""""""""."""."."""""""""""""""""""""""""""""". 443

Игра с монетками""""""""""""""""""""""""""""""""""""""""""""""""".""""".".""""""""443
Генерация примеров"""""""."."""""".""".".""".""".""""""""""""""""""""""""""""""""".445
Перестановка чисел для уравнивания сумм (задача о sum swap) ."".""".""""""""". 446

Жадные алгоритмы"""""""""""""""""""""""""""""""""""""""""".""""""""""""""""""""""""451
Максимальный элемент массива"""""""""""""""""""""""""""""""""""""""""""""""""451

16

Оглавление

Наибольшая сумма элементов подраздела массива"".""".".""".""""." .•.. " •••..•. ""."."."452

Жадные предсказания цен на акции"""""""""""""""".""""""""""""".""""""""""""""".459
Замена структуры данных"""."""."""."""."""."."""."."""""".""""""""""""".""""""".""""""""464
Алгоритм для проверки анаграмм"""."""."""""".""".".""".""""""."""".".".""""""""".". 464

Группировка элементов массива

"""""""""""".""""""""".".""""""."""""""""""."""""""467

Выводы""""""".""""""""""""."""."""." •. """"""""""""""""""""""""""""."""""""""""""""""""""470
Заключительные мысли""""""""""""""."""""""".""""""."""."."""."""""".""""""""""""""""". 470
Упражнения".""""""""""."""""""""""""""""""""".""""""""."""."."""""""""""""".""""""""."""471
Приложение. Решения к упражнениям"""".""."""."""""""""."""""""." .. """""."."""""".475

Глава

1 """"""""""""""""""."""."""."""."."""""""""""""""""""""""""""""""""""""""""""""""""475

Глава

2""""""""""""""""".""""""""""""".""".""".""""""""""""""""""""""""""""""""""""""""".476

Глава З """""""""""""""."""""""""""""""""""""""""""""""""""""""""""""""".""""""""""""."""477
Глава 4"""""""""""""""""."""""."""."""""""""""""""""""""""""""""""""""""""""""""""""""""477
Глава 5"."""""."""""""""""""""""""""""""".".""""""""""."""."."."""""""".""".""""""""""""""".478
Глава 6".""""".".""."""""."""""."""""""""""""""""""""""""""""""""""""""""""""".""".""""""".479

Глава 7 """"""""""""""""""""""""""".""""""""""""""""""""""""""""""""""""""""""""""""""""480
Глава 8""""""""""""""""""""""""""""".""""""""""""""""""""""""""""""""""""""""""""""""""480
Глава 9.""."""."."""""""""""""""""""."""""""""""""""."""""."."."."""""""""""""""""""""""""".482
Глава 10""""""""""""""""""""""""""""""""""""""""."""""""""".".""".""""""""""".".""""""."""48З
Глава

11""".""""""""".""".""""""""""""""""""""""""""""""""."""."""""""""""""".""".""".".".". 484

Глава

12"""""""""""""""""".""""""""""."""."""""""""""""""""""".""".""""""""""""""""""""""487

Глава lЗ.""""""""""""""""""""""""""""""."""."."""."""."""."."""""""""""""""""""""""""""""".488
Глава

14"""".""""".".""""""""""""""""""""""""""""""""""""""."."""."."""""""""""""""".".""""490

Глава 15"".""""""".""".""".""""""""""""""""""""""""""""""""""""""""""""""""""""."""""""""49З
Глава

16"""""""""""""""."""""""".""""".""".""""""""."""""""""""""""""""""""""""""""""""""495

Глава

17"""""""""""""""""""""""""""""."""""."""."""""""""""""""""""""""".""".""""""""•. """496

Глава

18""""""."""""""""""""""""""""""""""".""".""".""""""."""""""""""""""".""""""""""""."497

Глава

19".""".""""""".""".""""""""""""""""""""""""""""""""""""""""""""."".""""""""""""""".501

Глава 20""""".""""""""""."""."""""."."""""""""""""""""""".""""""""""""""""""""""""""""""". 502

18

Отзывы о втором издании

Во втором издании «Прикладных структур данных и алгоритмов1> вы найдете

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

главы делают эту книгу бесценной для всех разработчиков ПО, вне зависимости

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

старший инженер-программист,

KEYSYS

Consиltiпg

Идеальное введение в тему алгоритмов и структур данных для начинающих.

Настоятельно рекомендую к прочтению!
БрайанШо,

ведущий программист, Schaи Coпsиltiпg

Структуры данных и алгоритмы

-

это не просто абстрактные концепции. Освоив

их, вы сможете писать эффектив'НЫЙ код, благодаря которому программное

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

математики, бывает трудно понять суть. Даже книги, авторы которых обещают
«легкое» знакомство с алгоритмами, предполагают наличие у читателя матема­

тического образования. Из-за этого многие избегают изучения этих концепций,
считая себя недостаточно «умными» для их понимания.
По правде говоря, все, что касается структур данных и алгоритмов, опирается

на здравый смысл. Математика

-

это просто особый язык, и все ее концепции

можно объяснить в доступной для любого читателя форме. Чтобы вам было
несложно и интересно изучать весь изложенный материал, я использовал толь­

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

эффективный и быстрый код. Взвесив все плюсы и минусы вариантов кода, вы

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

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

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

20

Предисловие

практике. Если вы хотите писать более качественный код и создавать более

быстрое ПО, то обратились по адресу.

Для кого эта книга
Эта книга подойдет для вас, если вы:



студент-информатик, который нуждается в простом и понятном ресур­

се для изучения структур данных и алгоритмов. Эта книга станет хоро­

шим дополнением к любому классическому учебнику, которым вы
пользуетесь;



начинающий разработчик, который знаком с азами программирования,

но хочет изучить основы компьютерных наук, чтобы писать более каче­
ственный код и расширить свои знания в этой области;



программист-самоучка, который никогда не изучал информатику фор­

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

Новое во втором издании
Почему второе? С момента публикации первого издания прошли годы, за кото­
рые я успел поделиться этим материалом с разными аудиториями. Я немного

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

1.

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

Я полностью переписал многие разделы исходных глав и добавил ряд со­

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

21

Что вы найдете в этой книге

2.

Новые lJlaвы и темы. Во второе издание добавлены шесть новых глав, по­

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

я добавил еще больше материала, который вы можете сразу же применить.
Главы

7 и 20 посвящены только отчетам об ошибках, повседневному коди­

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

в написании более эффективного ПО.
В этой книге я постарался максимально доходчиво объяснить тему рекур­

сии. Я уже посвящал ей одну из глав в прошлом издании, но в это добавил
новую

-

11-ю, где изложил процесс написания рекурсивного кода, так как

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

и ценным. Кроме того, я добавил главу

12,

где рассказал о динамическом

программировании, довольно популярной теме, важной для повышения

эффективности рекурсивного кода.

Существующих структур данных довольно много, и бывает трудно выбрать,
какие из них добавить в книгу, а какие

-

нет. Но в последнее время я на­

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

3.

16 и 17.

Упражнения и решения. Теперь в каждой главе вы найдете ряд упражнений
для практического закрепления изученных концепций (все подробные
решения вы найдете в приложении в конце книги). Благодаря этому улуч­

шению книга превратилась в более полное учебное пособие.

Что вы найдете в этой книге
Как вы уже могли догадаться, большая часть этой книги посвящена структурам

данных и алгоритмам. Пособие состоит из следующих глав.
В главах

1 и 2 мы

поговорим о том, что такое структуры данных и алгоритмы,

и рассмотрим концепцию временной сложности, которая используется для

определения скорости работы алгоритма. По ходу дела мы обсудим массивы,
множества и двоичный (бинарный) поиск.
Глава 3 очень важна. В ней я постараюсь максимально доступно объяснить суть
нотации

«0 большое~,

которая упоминается на протяжении всей книги.

В главах 4, 5 и 6 мы углубимся в эту тему и используем О-нотацию, чтобы за­
ставить код работать быстрее. Попутно я расскажу о разных алгоритмах сорти­
ровки, в том числе о сортировке пузырьком, выбором и вставками.

22

Предисловие

В главе

7 вы примените все полученные ранее знания и оцените эффективность

реального фрагмента кода.

В главах

8 и 9 мы обсудим несколько дополнительных структур данных,

вклю­

чая хеш-таблицы, стеки и очереди. Я покажу, как они влияют на скорость рабо­
ты и простоту кода и как их использовать для решения реальных задач.

Глава

10

посвящена рекурсии

-

важнейшей концепции в мире информатики.

Мы подробно разберем ее и посмотрим, когда она может пригодиться. Глава

11

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

В главе

12 я покажу, как оптимизировать рекурсивный код и не допустить того,
13 вы узнаете, как использовать ре­

чтобы он вышел из-под контроля. В главе

курсию в качестве основы для сверхбыстрых алгоритмов сортировки и выбора,
и усовершенствуете свои навыки разработки алгоритмов.
В главах

14, 15, 16, 17

и

18

мы исследуем структуры данных на основе узлов,

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

В главе

19 мы

поговорим о пространственной сложности алгоритма, которая

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

эффективности кода и предлагаются новые идеи по улучшению кода, который
вы пишете каждый день.

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

основана на информации из предыдущей. Структура книги выстроена так, что­

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

Например, при желании после чтения главы

ве

13

1О вы можете перейти сразу к гла­

(кстати, эта диаграмма основана на структуре данных «дерево», с которой

вы познакомитесь в главе

15).

23

Как читать эту книгу

Еще одно важное замечание: чтобы облегчить понимание материала, в книге
я не всегда даю всю информацию о каком-то понятии при его первом упомина­

нии. Иногда лучший способ раскрыть суть сложной концепции

-

сначала объ­

яснить небольшую ее часть и лишь после ее усвоения переходить к следующей.

24

Предисловие

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

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

Примеры кода
Концепции в этой книге не относятся к одному конкретному языку программи­
рования. Поэтому примеры кода, которые вы здесь найдете, написаны на разных

языках, в частности

Ruby, Python

и JavaScгipt, знакомство с которыми будет

весьма кстати.

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

чине я не использую идиомы, если чувствую, что они могут сбить с толку не­
знакомого с ними читателя.

Несмотря на то что частое переключение с одного языка программирования на

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

В разделе «Программная реализация» вы найдете несколько длинных фрагмен­

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

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

-

не стесняйтесь!

Интернет-ресурсы
У этой книги есть своя веб-страница (https://pragprog.com/titles/jwdsal2), где вы
найдете всю дополнительную информацию. Вы также можете помочь нам, со­

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

содержания пособия.

25

Благодарности

Благодарности
Может показаться, что готовая книга

-

это плод труда одного человека. Нолю­

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

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

и эмоциональную поддержку. За то, что заботилась обо всем, пока я писал, за­
першись в кабинете, как затворник. Спасибо моим очаровательным детям Туви,
Лии, Шайе и Рами за терпение, которое они проявляли, пока я работал над
книгой об .

Сколько шагов нужно для выполнения линейного поиска числа

8 в упоря­

доченном массиве [2, 4, б, 8, 10, 12, 13]?

2.
3.

Сколько шагов нужно для выполнения двоичного поиска в примере выше?

Какое максимальное число шагов потребуется для выполнения двоичного
поиска в массиве из

100 ООО элементов?

ГЛАВА3

Ранее мы с вами говорили о том, что основной фактор, определяющий эффек­
тив1юсть алгоритма,

-

это количество выполняемых им шагов.

Но мы не можем просто сказать, что один алгоритм состоит из
гой

из

-

22 шагов, а дру­
400, потому что количество выполняемых алгоритмом шагов не может

быть сведено к конкретному числу. Возьмем, к примеру, линейный поиск. Ко­
личество шагов этого алгоритма зависит от числа элементов в массиве. Если

массив содержит
а если

400 -

то

22 элемента, алгоритм линейного поиска состоит из 22 шагов,
из 400 шагов.

Поэтому, чтобы количественно оценить эффективность этого алгоритма, мы
можем сказать, что для выполнения линейного поиска в массиве из N элемеNmов
нужно

N шагов.

То есть если в массиве содержится

иск будет состоять из

N

N элементов, линейный

по­

шагов. Но такой способ выражения эффективности

довольно громоздкий.

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

Формальный способ выражения этих концепций называется Nоmацией
шое»

(Big

«0 боль­

О) или 0-Nоmацией и позволяет легко оценивать эффективность

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

Хотя О-нотация пришла из мира математики, я собираюсь обойтись без сложной

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

62

Глава

3. О да!

Нотация «О большое»

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

но еще проще ее будет усвоить по частям.

«О большое»: количество шагов при наличии

N элементов
Согласованность О-нотации обусловлена особым способом подсчета количества
шагов алгоритма. Сначала давайте попробуем оценить с ее помощью эффектив­
ность алгоритма линейного поиска.

В худшем случае количество шагов линейного поиска будет равно количеству
элементов в массиве. Как мы уже говорили, линейный поиск в массиве из N эле­

ментов может потребовать до
разить так:

N

шагов. С помощью О-нотации это можно вы­

O(N).

Некоторые произносят это как
Но я предпочитаю

«0 большое от эн»
говорить просто: «0 от эн».

или «сложность порядка №.

Эта запись выражает ответ на ключевой вопрос: сколько шагов будет выполнять
алгоритм при наличии N элементов данных? Выучите это предложение наизусть,
потому что именно это определение О-нотации мы будем использовать на про­
тяжении всей оставшейся части книги.

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

O(N)

говорит о том, что алгоритм будет выполнять

N шагов.

Теперь давайте рассмотрим выражение временной сложности с помощью

О-нотации на примере того же линейного поиска. Сначала мы задаем ключевой

N элементов данных, сколько шагов нужно для выполнения
N шагов,
поэтому выражаем ответ так: O(N). Для справки, O(N) еще называют алгоритмом
вопрос: если вмассиве

линейного поиска? Мы знаем, что на выполнение такого поиска уйдет

с линейной временной сложностью или выполняемым за линейное время.
Сравним все это с выражением эффективности чтения из стандартного масси­
ва через О-нотацию. Как вы узнали из главы

1, на чтение из

массива, вне зави­

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

N элементов данных, сколько шагов нужно для чтения из него?
поэтому мы можем выразить сложность этого алгоритма так:
сится как

«0

Ответ: один шаг,

0(1)

(произно­

от единицы»).

Случай с О( 1) довольно интересен: несмотря на то что ключевой вопрос основан

на терминах

N

(«Сколько шагов будет выполнять алгоритм при

N

элементах

63

Суть О-нотации

данных?»), ответ никак не относится к

N.

В этом и суть: сколько бы элементов

ни было в массиве, чтение из него всегда требует только одного шага.

И именно поэтому алгоритм со сложностью

0(1) считается

«самым быстрым».

Даже по мере увеличения объема данных он не выполняет никаких дополни­
тельных шагов, то есть всегда выполняет одно и то же их количество вне за­

висимости от значения

N.

Алгоритм О( 1) еще называют алгоритмом с посто­

янной временной сложностью или выполняемым за постоянное (константное)
время.

А где же математика?
Как я уже говорил, в этой книге я постарался объяснить тему О-нотации

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

концепцией с математической точки зрения. О-нотация была позаимство­
вана из мира математики, поэтому для ее описания часто используют

математические термины. И тогда можно услышать такие выражения, как

«"0" большое описывает верхнюю границу скорости роста функции» или
« если функция g(x) растет не быстрее функции /(х), то g является эле­
ментом O(f)». Все эти фразы либо имеют смысл, либо нет - все зависит
от вашего уровня подготовки. В своей книге я постарался объяснить эту
тему так, чтобы ее могли понять даже те, кто далек от математики.
Если хотите углубиться в математические основы О-нотации , прочтите
книгу Iпtroductioп

to Algorithms 1 Томаса Х.

Кормена, Чарльза И. Лейзер­

сона, Рональда Л . Ривеста и Клиффорда Штайна или статью Джастина

Абрамса: https: //j ustin.abrah.ms/computer-science/understanding - Ьig -o-formal ­

definition.html.

Суть О-нотации
Теперь, когда мы познакомились с

O(N)

и

0(1),

мы начинаем понимать, что

О-нотация не просто описывает количество шагов, которые выполняет алгоритм,
например, с жестким числом, таким как

22 или 400.

Это скорее ответ на постав­

ленный ранее ключевой вопрос: сколько шагов будет выполнять алгоритм при
наличии

N элементов данных?

Но суть О-нотации не только в этом.

1

Кормен Т. Х. , Лейзерсон Ч. И. , Ривест Р. Л., Штайн К. Алгоритмы: построение и анализ .

64

Глава

3. О да!

Нотация «О большое»

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

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

Но правильный ответ

0(3).

О( 1). Сейчас я объясню, почему.

-

Хотя с помощью О-нотации действительно можно выразить количество шагов

алгоритма при наличии

N элементов данных,

это определение не затрагивает

нечто более глубокое, «суть» этой нотации.
О-нотация может ответить на вопрос: «Как изменяется производительность

алгоритма по мере увеличения о6ьема данных?»
Она не просто говорит нам, сколько шагов выполняет алгоритм, но и показы­

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

или

0(3),

-

О( 1)

ведь она никак не зависит от объема данных, так как число шагов

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

личества данных. Поэтому для нас они одинаковы.

В свою очередь, алгоритм со сложностью

O(N)

относится к другому типу, так

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

O(N).

Она отражает пропорцио­

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

10
9
00

8

rn

7

о
L...

3

о

00
....
(,)
ф

:r

6

5

:s:

4

о

3

= 0:
if array[position] > temp_value:
array[position + 1]
array[position]
position = position - 1
else:
break

Python:

113

Сортировка вставками в действии

array[position + 1]

temp_value

return array
Разберем код пошагово.
Начиная с индекса

1,

Каждая итерация

это проход по массиву:

-

мы запускаем цикл, который обрабатывает весь массив.

for index in range(l, len(array)):
В рамках каждого прохода мы сохраняем «удаленное• значение в переменной

temp_value:
temp_value

=

array[index]

Создаем переменную

temp_value.

из них со значением

position

posi tion,

которая начинается сразу слева от индекса

С помощью нее мы будем обрабатывать значения, сравнивая каждое

=

temp_value:

index - 1

Выполняя проход, мы будем перебирать эти позиции справа налево, сравнивая
значения в них с

temp_value.

Теперь запускаем внутренний цикл while, который будет выполняться, пока
значение

posi tion

больше или равно О:

while position >= 0:
Выполняем сравнение

-

проверяем, превышает ли значение

position значение

temp_value:
if array[position] > temp_value:
Если да, то сдвигаем соответствующее левое значение вправо:

array[position + 1] = array[position]
Уменьшаем значение

posi tion на 1, чтобы сравнить значение левее с temp_value
while:

в рамках следующей итерации цикла

position

=

position - 1

Если в какой-то момент значение

position будет меньше или равно temp_value,

мы должны быть готовы завершить проход и заполнить пробел значением

temp_value:
else:
break

Глава 6. Повышение эффективности с учетом оптимистичных сценариев

114

Последний шаг каждого прохода

array[position + 1]

=

-

заполнение пробела значением

temp_value:

temp_value

После выполнения всех проходов мы возвращаем отсортированный массив:

return array

Эффективность сортировки вставками
В сортировке вставками выполняется четыре типа шагов: удаление, сравнение,

сдвиг и вставка. Чтобы узнать, насколько алгоритм эффективен, нужно под­
считать количество этих шагов.

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

значение слева от пробела с

temp_value. В худшем случае, когда массив отсор­

тирован в обратном порядке, мы должны сравнивать каждое число слева от

temp_value

с этой же переменной при каждом проходе. Это связано с тем, что

все значения слева от

temp_value будут превышать ее значение

и проход завер­

шится, только когда пробел достигнет левого конца массива.
Во время первого прохода, когда в

temp_value

хранится значение с индексом

1,

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

temp_value

с каждым значением в мас­

N
N - 1 сравнений.

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

Поэтому общее количество сравнений составляет:

1 +2+3+ ... +(N-1).
В нашем примере с массивом из пяти элементов максимальное количество
сравнений составляет:

1 + 2 + 3 + 4 = 10.
Для массива из десяти элементов:

1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 = 45.
Для массива из

20 элементов - 190 сравнений

и т. д.

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

равно

50, а 20 2/2 - 200.

N

элементов

-

примерно №/2

(10 2/2

Подробнее об этом мы поговорим в следующей главе).

115

Эффективность сортировки вставками

Теперь проанализируем другие типы шагов. Сдвиг происходит всякий раз,
когда мы перемещаем значение на одну ячейку вправо. Когда массив отсорти­

рован в обратном порядке, число сдвигов будет совпадать с количеством срав­
нений, так как нам придется перемещать значение вправо после каждого.

Сложив количество сравнений и сдвигов в худшем сценарии, мы получим:

№ /2 сравнений

+ №/2

СДВИГОВ.

Итого: № шагов.
Удаление и вставка

temp_value происходит один раз за проход. Поскольку чис­
N - 1, количество удалений и вставок составляет N - 1.

ло проходов всегда равно
И так, мы имеем:

№ сравнений и сдвигов, вместе взятых;

N- 1 удалений;

+ N - 1 вставок.
Итого: №

+ 2N- 2 шагов.

Как вы помните, О-нотация игнорирует константы. Опираясь на это правило,

мы могли бы упростить полученное выражение до О(№

+ N).

Но у О-нотации есть еще одно важное правило:

Она учитывает только слагаемое самого высокого порядка.
То есть если алгоритм выполняет №

+ № + № + N шагов, мы считаем значимым

только № и заключаем, что временная сложность алгоритма равна О(№). По­
чему?

Взгляните на следующую таблицу:

N







2

4

8

16

5

25

125

625
10 ООО

10

100

1000

100

10 ООО

1 ООО ООО

100 ООО

ООО

По мере роста N значение № становится настолько более значимым, чем осталь­
ные порядки

N,

что ими можно пренебречь. Например, если мы сложим

116

Глава 6. Повышение эффективности с учетом оптимистичных сценариев

+ № + № + N, подставив значения из нижней строки таблицы, то в сумме
получим 101010100. Но мы вполне можем округлить это число до 100 ООО ООО,



проигнорировав более низкие порядки

N.

Такой же подход мы можем применить и к сортировке вставками. Несмотря на

то что мы уже упростили ее до №

+ N, мы

можем отбросить слагаемое более

низкого порядка и сократить выражение до О(№).
Итак, в худшем случае временная сложность сортировки вставками такая же,

как и у пузырьковой и сортировки выбором

-

О(№) .

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

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

№/2 шагов, а вторая -№. Может показаться, что сортировка вставками рабо­
тает так же медленно, как и пузырьком, поскольку на ее выполнение тоже нуж­

но примерно № шагов.

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

раза быстрее. На самом деле все не так просто.

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

средний случай.
Почему?

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

Худший

Средний

Лучший

случай

случай

случай

117

Средний случай

Лучшие и худшие сценарии реализуются редко. Большинство же случаев

-

средние.

Возьмем, к примеру, массив, отсортированный произвольно. Какова вероятность

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

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

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

Но когда данные отсортированы произвольно, при выполнении одних проходов

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

-

толь­

ко их часть, а при выполнении третьих нам вообще не придется ничего трогать.
Если вы вернетесь к разделу «Сортировка вставками в действии~., то заметите,
что при первом и третьем проходах мы сравниваем и сдвигаем все встречающи­
еся значения, при четвертом

-

только некоторые, а при втором выполняем

только одно сравнение и ни одного сдвига.

Разница в том, что одни проходы требуют сравнения всех значений слева от

temp_value, а другие завершаются раньше из-за обнаружения значения, мень­
шего, чем temp_value.
Итак, в худшем случае мы сравниваем и сдвигаем все данные, а в лучшем

-

ни­

чего не сдвигаем (выполняя только одно сравнение за проход). Можно предпо­
ложить, что в среднем случае придется сдвинуть около половины данных. Так,

если в худшем случае сортировка вставками требует выполнения № шагов, то

в среднем

- около №/2 (но с точки зрения О-нотации сложность обоих сцена­

риев будет О(№)).
Рассмотрим конкретные примеры.

Массив

[1, 2,

з,

4] уже отсортирован - это лучший сценарий. Худший сцена­
- (4, з, 2, 1],асредний,допустим, [1, з, 4, 2].

рий для тех же данных

Худший случай требует выполнения шести сравнений и сдвигов
гов, средний

-

четыре сравнения и два сдвига, всего

- всего 12 ша­
6 шагов. А в лучшем случае

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

118

Глава 6. Повышение эффективности с учетом оптимистичных сценариев

Теперь мы видим, что производительность алгоритма сортировки вставками сw~ъ­
но отличается в зависимости от сценария. В худшем случае сортировка вставками

требует выполнения № шагов, в среднем

- №/2, а в лучшем - около N шагов.

Эту разницу в производительности графически можно представить так:
1

1

!/

1

О( n•)

1

"'

е
3

J

rn

j

01 'n27'l)

11
11
1
1/
j/

о

"'
t;
ф


:s:

а

~

1 {/

v

v

о

/

/

/

/

n)

и

Количество элементов

Сравните эти показатели с эффективностью сортировки выбором, которая
требует выполнения №/2 шагов во всех случаях. Это связано с тем, что у со­
ртировки выбором нет механизма для досрочного завершения прохода. Каждый

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

Средний

Худший

сценарий

сценарий

сценарий

№/2

№/2

№/2

N

№/2



Сортировка выбором
Сортировка вставками

Итак, какой же из алгоритмов лучше? На этот вопрос нет однозначного ответа.

В среднем случае, когда массив отсортирован произвольно, они работают оди­

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

какими будут данные, оба алгоритма будут одинаково эффективны.

Практический пример
Допустим, вы пишете приложение нajavaScript и где-то в коде вам нужно най­
ти пересечение двух массивов

-

получить список значений, которые есть в них

119

Практический пример

обоих. Например, для массивов [З, 1, 4, 2] и [4, 5, 3, б] пересечением будет

[ з, 4], так как эти значения

массив

есть и в том, и в другом.

Вот одна из возможных реализаций этого кода:

function intersection(firstArray, secondArray){
let result
[];
for (let i
0; i < firstArray.length; i++) {
for (let j = 0; j < secondArray.length; j++) {
if (firstArray[i] == secondArray[j]) {
result.push(firstArray[i]);
}
}
}

return result;
}
Здесь мы запускаем вложенные циклы. Во внешнем мы последовательно пере­

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

Алгоритм выполняет два типа шагов: сравнение и вставку. Мы сравниваем все
значения двух массивов и помещаем совпадающие в массив

resul t.

Начнем

с подсчета сравнений.

Если у двух массивов одинаковый размер

N, то число выполняемых сравнений -

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

тов, в итоге мы выполним
поиска пересечения

-

25 сравнений.

Итак, эффективность этого алгоритма

О(№).

Вставки потребовали бы максимум

N шагов (если бы два массива оказались

идентичными). Это слагаемое более низкого порядка по сравнению с №, поэто­

му мы по-прежнему считаем сложность алгоритма равной О(№). Если бы
массивы были разного размера, скажем N и М, то эффективность этой функции
была бы равна

O(N х

М) (подробнее об этом мы поговорим в главе

7).

Можно ли как-то улучшить этот алгоритм?
Чтобы ответить на этот вопрос, рассмотрим остальные сценарии. В текущей

реализации функции

intersection

мы выполняем № сравнений во всех сцена­

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

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

Глава 6. Повышение эффективности с учетом оптимистичных сценариев

120

1218111 1218111 1218111
1518131 1518131 1518131
t

t

t

...

...

1218111 12(§)11 1218111
1518131 •
15181~1
t

(

Ненужный
шаг!

В этом примере бессмысленно продолжать выполнение второго цикла после
нахождения общего значения
второго массива

8,

-

(8).

Нам незачем проверять остальные элементы

мы уже выяснили, что он, как и первый, содержит значение

и можем сразу добавить обнаруженное совпадение в массив resul t. Полу­

чается, в этой реализации мы выполняем ненужный шаг.

Чтобы это исправить, достаточно добавить в реализацию всего одно слово:

function intersection(firstArray, secondArray){
let result = [];
for (let i = 0; i < firstArray.length; i++) {
for (let j = 0; j < secondArray.length; j++) {
if (firstArray[i] == secondArray[j]) {
result.push(firstArray[i]);
break;
}
}
}

return result;
}

Добавление

break позволяет досрочно завершить выполнение внутреннего

цикла и сократить число шагов (а значит, сэкономить время).

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

N

сравнений. В среднем, когда массивы разные, но содержат несколько

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

121

Упражнения

Эта оптимизация функции

intersection

довольно существенная, поскольку

наша первая реализация предполагала выполнение № сравнений во всех слу­
чаях.

Выводы
Способность различать лучшие, средние и худшие сценарии

-

важный навык,

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

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

базах, и определим временную сложность каждого из них с помощью О-нотации.

Упражнения
Выполните следующие упражнения, чтобы закрепить знания, полученные из
этой главы. Решения вы найдете в приложении в разделе «Глава б~>.

1.

С помощью О-нотации определите эффективность алгоритма, который

выполняет 3№

2.

С помощью О-нотации определите эффективность алгоритма, который

выполняет

3.

+ 2N + 1 шагов.

N + log N шагов.

Следующая функция проверяет, содержит ли массив пару чисел, сумма
которых равна

10.

function twoSum(array) {
for (let i = 0; i < array.length; i++) {
for (let j = 0; j < array.length; j++) {
if (i !== j && array[i] + array[j] === 10) {
return true;
}
}
}

return false;
}
Проанализируйте лучший, средний и худший случаи, а затем выразите

эффективность алгоритма в самом плохом сценарии с помощью О-нотации.

4.

Следующая функция проверяет, содержится ли в строке заглавная бук­
ва

«Xi>.

Глава 6. Повышение эффективности с учетом оптимистичных сценариев

122

function containsX(string) {
foundX = false;
for(let i = 0; i < string.length; i++) {
if (string[i] === "Х") {
foundX = true;
}
}

return foundX;
}
Выразите временную сложность этой функции с помощью О-нотации.

Измените код так, чтобы в лучших и средних случаях алгоритм работал
более эффективно.

ГЛАВА

7

О-нотация в работе
~--·-·-----~·-&~~--~-D роrрамм i!lCia

Вы уже научились использовать О-нотацию для выражения временной слож­
ности алгоритмов и узнали о множестве аспектов, которые нужно учитывать

в ходе проведения анализа. В этой главе вы примените все полученные знания

для определения эффективности примеров кода из реальных кодовых баз.
Оценка эффективности кода

-

это первый этап его оптимизации. В конце кон­

цов, если скорость его работы неизвестна, откуда нам знать, как именно его
улучшить?
Определив, к какой категории алгоритмической сложности относится наш код,

мы сможем решить, нужно ли его вообще улучшать. Например, алгоритм О(№)

обычно считается «медленным~>. Если наш алгоритм попадает в эту категорию,
придется поискать способы его оптимизации.
Конечно, иногда эффективность О(№) может оказаться предельной. Но, зная,

что наш алгоритм считается медленным, мы можем попытаться найти более

быстрые альтернативы.
В следующих главах вы познакомитесь со множеством приемов оптимизации

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

Итак, начнем.

124

Глава

7. О-нотация

в работе программиста

Среднее арифметическое четных чисел
Следующий метод языка

Ruby принимает

массив чисел и возвращает среднее

значение всех четных из этого массива. Как выразить его эффективность с по­
мощью О-нотации?

def
#
#
#

average_of_eveп_пumbers(array)
Среднее значение четных чисел будет определяться как сумма четных чисел,
деленная

как

sum

на

сумму,

их

количество,

так и

0.0

=

couпt_of_eveп_пumbers

#
#

поэтому мы отслеживаем

количество:

=

0

Перебираем все числа в массиве и,

когда нам встречается

четное,

количества:

изменяем значение

суммы и

lпumberl

array.each do

пumber.eveп?

if

sum +=

пumber

couпt_of_eveп_пumbers

+= 1

епd
епd

#

Возвращаем среднее значение:

returп

sum /

couпt_of_eveп_пumbers

епd

Чтобы определить эффективность кода, разберемся в том, что он делает.
Как вы помните, О-нотация помогает определить, сколько шагов будет выпол­
нять алгоритм при наличии

N элементов данных.
N.

Поэтому сначала нам нужно

определить, что именно подразумевается под

В нашем случае алгоритм обрабатывает массив чисел, переданный функции,
поэтому

N здесь -

это размер массива, количество содержащихся в нем зна­

чений.

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

N значений.

Мы видим, что самая важная часть алгоритма

- цикл, перебирающий все

числа внутри массива, поэтому сначала проанализируем его. Поскольку цикл

перебирает все

N

элементов, мы знаем, что алгоритм выполняет не менее

N

шагов.

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

125

Конструктор слов

число четное, выполняем еще два шага: изменяем значения переменных

sum

и count_of_even_numbers. Получается, что при обнаружении четных чисел мы

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

-

это когда все числа в массиве четные

и мы вынуждены выполнять по три шага в рамках каждой итерации цикла.

Получается, что при наличии

3N шагов -

N

элементов данных наш алгоритм выполняет

по три для каждого из

N чисел.

Вне цикла наш метод выполняет еще несколько шагов: перед запуском цикла
мы инициализируем две переменные нулем. Технически это два шага. По за­
вершении цикла мы выполняем еще один шаг: делим значение

sum

на значение

count_of_even_numbers. По сути, наш алгоритм выполняет еще три шага в до­
полнение к

3N шагам,

поэтому общее количество шагов равно

3N + 3.

Но, как мы помним, О-нотация игнорирует константы, поэтому с ее точки зре­

ния сложность нашего алгоритма будет

O(N), а не 0(3N + 3).

Конструктор слов
Следующий пример

-

алгоритм, который составляет двухсимвольные стро­

ки из отдельных символов в массиве. Например, если мы передадим ему
массив ["а", "Ь", "с",

"d" ] , он вернет нам новый, содержащий следующие

строки:

'аЬ',

'ас',

'са',

'сЬ',

'ad', 'Ьа', 'Ьс', 'bd',
'cd', 'da', 'db', 'dc'

Ниже приведена реализация этого алгоритма на языке JavaScript. Попробуем
оценить его эффективность с помощью О-нотации:

function wordBuilder(array) {
[];
let collection
for(let i = 0; i < array.length; i++) {
for(let j = 0; j < array.length; j++) {
i f (i ! == j) {
collection.push(array[i] + array[j]);
}
}
}

return collection;
}

126

Глава

7. О-нотация

в работе программиста

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

i.

Для каждого ин­

декса i выполняется внутренний цикл, который снова перебирает все символы
в том же массиве, отслеживая их индексы с помощью переменной

j. В этом
i и j, за

внутреннем цикле выполняется конкатенация символов с индексами

исключением случаев, когда

i

и

j

указывают на один и тот же индекс.

Чтобы определить эффективность этого алгоритма, нужно выяснить, что по­
нимается под

N.

В этом примере, как и в прошлом,

N соответствует количеству

элементов внутри массива, передаваемого в функцию.

Следующий этап

определение количества шагов, которые алгоритм выпол­

-

няет при наличии

N

элементов данных. В нашем случае внешний цикл пере­

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

N элементов, что в итоге составляет N х N шагов. Это

классический пример алгоритма со сложностью О(№), и именно в эту категорию
обычно попадают алгоритмы с вложенными циклами.
Теперь посмотрим, что произойдет, если мы изменим этот алгоритм так, чтобы
он создавал массив трехсимволъных строк: то есть при передаче массива ["а",

"Ь", "с",

"d"] возвращал следующий:

•аЬс', 'abd',
'acd', 'adb',
'Ьас', 'bad',
'bcd', 'bda',
'саЬ', 'cad',
'cbd', 'cda',
'dab', 'dac',
'dbc', 'dca',

1

ась 1 ,

'adc',
'Ьса',

'bdc ',
'сЬа',

'cdb',
'dba',
'dcb'

Эта реализация использует три вложенных цикла. Какова ее временная слож­

ность?

function wordBuilder(array) {
let collection = [];
for(let i = 0; i < array.length; i++) {
for(let j = 0; j < array.length; j++) {
for(let k = 0; k < array.length; k++) {
if (i !== j && j !== k && i !== k) {
collection.push(array[i] + array[j] + array[k]);
}
}
}
}

return collection;
}

127

Выборка из массива

В этом алгоритме при наличии
в цикле

N

элементов данных мы выполняем

N

шагов

i, N в цикле j и N в цикле k, то есть всего N х N х N, или № шагов. Полу­

чается, сложность этого алгоритма

-

О(№).

При использовании четырех или пяти вложенных циклов сложность алгоритма

была бы О(№) и О(№) соответственно. Посмотрим, как все это выглядит на
графике.

Было бы здорово, если бы мы смогли оптимизировать код, чтобы перевести
алгоритм из категории 0(№) в О(№), поскольку в этом случае код стал бы на
порядок быстрее.

Выборка из массива
В следующем примере мы создаем функцию, которая отбирает несколько эле­

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

Вот реализация этой функции на

Python. Попробуйте оценить ее эффективность

через О-нотацию:

def sample(array):
first = array[0]
middle = array[int(len(array) / 2)]

128

Глава

7. О-нотация

в работе программиста

last = array[ -1]
returп

Здесь N

-

[first, middle, last]

это снова количество элементов в массиве, передаваемом в функцию.

Но эта функция выполняет одинаковое количество шагов вне зависимости от

значения

N.

Чтение с начала, середины и последнего индекса массива занимает

один шаг независимо от размера массива. Вычисление длины массива и ее де­
ление пополам тоже выполняется за шаг.

Поскольку количество шагов постоянное, то есть остается неизменным вне за­

висимости от значения

N,

сложность этого алгоритма считается равной О( 1).

Среднее значение температуры
в градусах Цельсия
Рассмотрим еще один пример с вычислением среднего значения. Допустим, мы

создаем ПО для прогнозирования погоды. Чтобы определить температуру в го­
роде, мы собираем показания множества термометров по всему населенному
пункту и находим среднюю температуру.

Мы хотим показывать данные в градусах Фаренгейта и Цельсия, но изначально
показания получаем только в градусах Фаренгейта.
Наш алгоритм вычисляет среднюю температуру по Цельсию в два этапа: снача­

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

Ниже приведен код на языке

Ruby,

который выполняет эти действия. Какова

его эффективность с точки зрения О-нотации?

def
#

average_celsius(fahreпheit_readiпgs)
Собираем значения температуры в

celsius_пumbers

градусах Цельсия:

= []

# Преобразуем каждое значение в градусы Цельсия и добавляем в массив:
fahreпheit_readiпgs.each
celsius_coпversioп

=

do

lfahreпheit_readiпgl

(fahreпheit_readiпg

- 32) / 1.8

celsius_пumbers.push(celsius_coпversioп)
епd

# Вычисляем сумму всех значений температуры в градусах Цельсия:
sum = 0.0
celsius_пumbers.each

do

lcelsius_пumberl

129

Бирки для одежды

sum += celsius_number
end
Возвращаем среднее значение:

#

return sum / celsius_numbers.length
end
Во-первых, из примера видно, что под

температуры в градусах Фаренгейта

N подразумевается количество значений
( fahrenhei t_readings ), переданных на­

шему методу.

Внутри метода мы запускаем два цикла: первый преобразует показания в гра­
дусы Цельсия, а второй суммирует все полученные в результате данные. По­

скольку у нас есть два цикла, каждый из которых перебирает все

N элементов,
N + N, то есть 2N шагов (и еще несколько шагов,
которых не зависит от N). Поскольку О-нотация игнорирует константы,

наш алгоритм выполняет
число

сложность этого алгоритма будет

O(N).

Пусть вас не смущает, что в более раннем примере с конструктором слов ис­
пользование двух циклов привело к эффективности О(№). Там мы имели дело
с вложенными циклами, поэтому при подсчете количества шагов мы умножали

N на N.

Но здесь мы используем два цикла, следующих друг за другом, поэтому

просто с1 1402021, "Candidate

-

В"

=> 2321443, "Candidate

С"

=> 432}

система управления запасами, которая отслеживает коли­

чество товаров в наличии:

{"Yellow Shirt" => 1203, "Blue Jeans" => 598, "Green Felt Hat" => 65}
Хеш-таблицы настолько хорошо подходят для хранения парных данных, что
иногда мы даже можем использовать их для упрощения условной логики.

Возьмем, к примеру, функцию, которая возвращает значения распространенных
кодов состояния НТТР:

def status_code_meaning(number)
if number == 200
return "ОК"
elsif number == 301
return "Moved Permanently"
elsif number == 401
return "Unauthorized"
elsif number == 404
return "Not Found"
elsif number == 500
return "Internal Server Error"
end
end
Если мы внимательно взглянем на этот код, то поймем, что условная логика
вращается здесь вокруг парных данных: номеров кодов состояния и их соот­

ветствующих значений.

С помощью хеш-таблицы мы можем полностью исключить эту условную логику:

STATUS_CODES

{200 => "ОК", 301 => "Moved Permanently",
401 => "Unauthorized", 404 => "Not Found",
500 => "Internal Server Error"}

def status_code_meaning(number)
return STATUS_CODES[number]
end

155

Хеш-таблицы для ускорения выполнения кода

Еще один распространенный способ использования хеш-таблиц

-

представле­

ние объектов с разными атрибутами. Например, так выглядит представление
собаки:

{"Name" => "Fido", "Breed" => "Pug", "Age" => 3, "Gender" => "Male"}
Как видите, атрибуты
та

-

-

это ключ, а сам он

это своего рода парные данные, поскольку имя атрибу­

-

значение. Поместив несколько хеш-таблиц в массив,

мы можем создать целый список собак:

{"Name" => "Fido", "Breed" => "Pug", "Age" => 3, "Gender" => "Male"},
{"Name" => "Lady", "Breed" => "Poodle", "Age" => 6, "Gender" => "Female"},
{"Name" => "Spot", "Breed" => "Dalmatian", "Age" => 2, "Gender" => "Male"}

Хеш-таблицы для ускорения выполнения кода
Хотя хеш-таблицы идеально подходят для хранения парных данных, их можно
использовать и для ускорения выполнения кода, даже если эти данные не парные.

Именно здесь начинается все самое интересное.

Вот простой массив:

array = [61, 30, 91, 11, 54, 38, 72]
Сколько шагов нужно выполнить, чтобы найти в нем определенное число?
Поскольку массив неупорядоченный, вам придется осуществить линейный по­

иск, который потребует выполнения

N шагов.

Мы уже говорили об этом в самом

начале книги.

Но что будет, если мы используем некоторый код для преобразования этих
чисел в вот такую хеш-таблицу:
hash_taЫe = {61 => true, 30 => true, 91 => true, 11 => true, 54 => true,
38 => true, 72 => true}

Здесь мы сохранили каждое число в виде ключа вместе со связанным логическим
значением

true.

Как вы думаете, сколько теперь нужно шагов для нахождения определенного

числа, которое хранится в этой хеш-таблице в качестве ключа?
С помощью следующего простого фрагмента кода:
hash_taЫe[72]

156

Глава

я могу найти число

8. Молниеносный поиск с помощью хеш-таблиц

72 всего за один

шаг.

То есть при выполнении поиска в хеш-таблице с числом

72

в качестве ключа

я могу за один шаг определить, есть ли в этой таблице значение
просто: если число

72 -

72.

Здесь все

это ключ, который есть в хеш-таблице, код возвратит

значение

true, связанное с этим ключом. Если же 72 не хранится в хеш-таблице
nil (разные языки возвращают разные значения
отсутствии заданного ключа. Ruby возвращает nil).

в качестве ключа, код возвратит

при

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

O(N)

в

0(1).

Самое интересное здесь в том, что мы имеем дело не с парными данными, для

организации которых лучше всего подходят хеш-таблицы, а со списком отдель­
ных чисел.

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

true,

но с тем

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

какое-либо значение, мы точно знаем, что ключ есть в хеш-таблице, если же он
возвращает

nil,

значит, искомого ключа нет.

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

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

Подмножество массива
Допустим, нам нужно определить, служит ли один массив подмножеством дру­
гого. Для примера возьмем следующие два массива:
["а",

··ь··,

"с",

[ "Ь", "d", "f"]

"d

1

',

"е 1 ',

'

1

f

11

]

157

Хеш-таблицы для ускорения выполнения кода

Второй массив

"f'']

-

подмножество первого, потому что каждое значение

содержится в [

11

11

а··,

Ь 11 , ··с··,

"d'',

11

е",

[ "Ь", "d",

"f''].

Но в случае с массивами:
"с",

"d",

["а",

"Ь",

["Ь",

"d", "f", "h"]

"е",

1

'f

11

]

второй массив 1te будет подмножеством первого, так как содержит значение "h",
которого в первом массиве нет.

Как написать функцию, которая сравнивает два массива и сообщает о том, слу­
жит ли один из них подмножеством другого?

Один из способов сделать это

-

использовать вложенные циклы. Мы можем

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

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

false. Если все циклы будут выполнены, значит, она не обнаружила таких эле­
ментов и код должен возвратить true.
Так выглядит реализация этого подхода на языкejavaScript:
fuпctioп

isSubset(arrayl, array2) {

let largerArray;
let smallerArray;

11

Определяем,

какой массив меньше:

if(arrayl.leпgth

>

array2.leпgth)

{

largerArray = arrayl;
smallerArray = array2;
} else {
largerArray = array2;
smallerArray = arrayl;
}

11

Перебираем значения

for(let i

11
11

0; i <

в меньшем массиве:

smallerArray.leпgth;

На время допускаем,

i++) {

что текущего значения

меньшего массива нет в большем

let

11
11

=

fouпdMatch

=

false;

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

for(let j = 0; j <

11
11

largerArray.leпgth;

Если два значения

равны,

значит,

j++) {
текущее значение

меньшего массива есть в большем:

if(smallerArray[i] === largerArray[j]) {
foundMatch = true;

158

Глава

8.

Молниеносный поиск с помощью хеш-таблиц

break;
}
}

11
11

Если текущего значения меньшего массива нет

в большем, возвращаем false
if(foundMatch === false) { return false; }

}

11
11

Если

все циклы выполнены,

значит,

все значения

меньшего массива есть в большем:

return true;
}
Если мы проанализируем эффективность этого алгоритма, то выясним, что она
равна

O(N х М), поскольку число выполняемых им шагов равно произведению

количества элементов в первом и во втором массивах.

Теперь воспользуемся мощью хеш-таблицы, чтобы оптимизировать наш алго­

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

запустим один цикл, в котором переберем все значения в большем массиве и со­
храним их в хеш-таблице:

let

hashTaЬle

= {};

for(const value of largerArray) {
hashTaЬle[value] = true;
}
Здесь мы создаем пустую хеш-таблицу внутри переменной hashTaЫe, а затем

перебираем все значения в большем массиве largerArray, добавляя их в хеш­
таблицу в качестве ключа. Значением всех этих ключей будет

true.

Например, массив ["а", "Ь", "с", "d", "е", "f"] будет преобразован в следую­
щую хеш-таблицу:
{"а":

true,

"Ь":

true,

"с":

true, "d": true,

"е":

true, "f": true}

Она будет нашим «индексомi>, позволяющим в дальнейшем выполнять поиск
конкретных элементов за один шаг.

А теперь самое интересное. Как только первый цикл завершится и у нас появит­

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

for(const value of smallerArray) {
if(!hashTaЬle[value]) { return false; }
}

159

Хеш-таблицы для ускорения выполнения кода

Этот цикл просматривает каждый элемент меньшего массива

smallerArray

и проверяет, есть ли он в качестве ключа внутри хеш-таблицы hashTaЬle. Как
вы помните, эта таблица хранит в качестве ключей все элементы большего мас­
сива

largerArray. И если мы обнаруживаем определенный элемент в hashTaЫe,

значит, он будет и в largerArray. А если мы не находим элемент в хеш-таблице,
значит, его нет и в большем массиве.
Итак, мы проверяем, является ли каждый элемент меньшего массива

smallerArray

ключом в hashTaЬle. Если нет, то его нет в большем массиве, а значит, меньший
массив не служит подмножеством большего, и мы возвращаем

false (но если

этот цикл будет полностью выполнен, значит, меньший массив является под­

множеством большего).
Объединим все это в одной функции:

function isSubset(arrayl, array2) {
let largerArray;
let smallerArray;
let hashTaЬle = {};

11

Определяем,

какой массив меньше:

if(arrayl.length > array2.length) {
largerArray = arrayl;
smallerArray = array2;
} else {
largerArray = array2;
smallerArray = arrayl;
}

11

Сохраняем все элементы большего массива

largerArray

внутри хеш-таблицы:

for(const value of largerArray) {
hashTaЬle[value] = true;
}

11 Перебираем все элементы меньшего массива smallerArray и
11 false, если находим элемент, которого нет вhashTaЬle:

возвращаем

for(const value of smallerArray) {
if(!hashTaЬle[value]) { return false; }
}

11
11

Если до этого момента код не возвратил значение
значит,

false,

все элементы меньшего массива есть в большем:

return true;
}

Итак, сколько же шагов потребовал этот алгоритм? При создании хеш-таблицы
мы перебрали все элементы болъшеzо массива один раз.
Мы перебрали и все элементы менъшеzо массива, выполняя всего один шаг при

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

160

Глава

Если

N-

8.

Молниеносный поиск с помощью хеш-таблиц

это общее количество элементов в обоих массивах, то сложность на­

шего алгоритма

- O(N),

поскольку мы выполнили по одному шагу при обра­

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

O(Nx

М).

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

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

Как я уже отмечал, этот метод особенно интересен тем, что при его использова­
нии мы даже не имеем дело с парными данными: мы просто выясняем, есть ли

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

Выводы
Хеш-таблицы

-

незаменимый инструмент для создания эффективного ПО. Эта

структура данных позволяет производить чтение и вставку значений всего за
шаг.

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

мость.

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

1.

8».

Напишите функцию, которая возвращает пересечение двух массивов

-

третий массив со всеми значениями из обоих исходных. Например, пере­
сечение массивов

[1, 2,

з,

4, 5]

вашей функции должна быть

и

[0, 2, 4, 6, 8] -

O(N).

это

[2, 4].

Сложность

Если ваш язык программирования

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

161

Упражнения

2.

Напишите функцию, которая принимает массив строк и возвращает первое
повторяющееся значение. Например, в случае с массивом ["а", "Ь", "с",

"d", "с", "е", "f"] эта функция должна возвратить "с", так как оно встре­
чается в массиве более одного раза. Можете предположить, что массив со­
держит одну пару дубликатов. Убедитесь, что сложность этой функции

-

O(N).
3.

Напишите функцию, которая принимает строку со всеми буквами алфави­

та, кроме одной, и возвращает эту недостающую букву. Например, строка

"the quick brown Ьох jumps over а lazy dog" содержит все буквы латинского
алфавита, кроме "f". Временная сложность вашей функции должна быть
равна

4.

O(N).

Напишите функцию, которая возвращает первый неповторяющийся символ

в строке. Например, в строке
ла

-

"п" и

"u ",

"minimum" есть два неповторяющихся симво­

поэтому ваша функция должна возвратить "п", так как он

встречается первым. Временная сложность вашей функции должна быть
равна

O(N).

ГЛАВА9

Создание чистого кода
v

с помо111.ью стеков и очередеи.

.......,

До сих пор при обсуждении структур данных мы сосредоточивались на том, как

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

и очередями. Но, по правде говоря, это не будет для вас чем-то новым. Это про­
сто массивы с некоторыми ограничениями. Но именно эти ограничения и дела­
ют их столь эффективными.

Стеки и очереди

-

отличные инструменты для качественной обработки времен­

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

-

от создания архитектуры

операционной системы (ОС) до обработки заданий печати и обхода данных.
Временные данные как заказы еды в закусочной: запрос каждого клиента важен

до тех пор, пока еда не будет приготовлена и подана

-

потом бланк заказа мож­

но выбросить. Хранить эту информацию вовсе не обязательно. Временные
данные

-

это информация, которая становится бесполезной после обработки,

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

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

163

Стеки



данные можно вставлять только в конец стека;



удалить из стека можно только последний элемент;



прочитать можно только последний элемент стека.

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

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

-

только самую верхнюю (пытаясь посту­

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

-

дном или низом.

Мы будем придерживаться этой терминологии и изображать стеки на диаграм­
мах в виде вертикальных массивов:

8
4
1

9
6
2

112161911141g1
1 1 1 1 1 1

Как видите, первый элемент массива становится дном стека, в последний

-

его

вершиной.
Хотя свойственные стеку ограничения могут поначалу показаться, скажем так,

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

Вставка нового значения в стек называется проталкиванием

(push). Это очень

похоже на добавление тарелки на вершину стопки блюд.
Давайте втолкнем в стек значение

5:

Пока не происходит ничего необычного. Мы просто вставляем элемент данных
в конец массива.

164

Глава

9. Создание чистого кода с помощью стеков и очередей

Теперь втолкнем в стек значение

3:

Затем добавим в него О:

i
0
3
5
Обратите внимание, что мы всегда помещаем данные на вершину (то есть в ко­
нец) стека. Поместить О в самый низ или в середину стека мы бы не смогли,

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

Удаление элемента из стека называется выталкиванием (рор ). Из-за свойствен­
ных стеку ограничений мы можем удалять данные только с его вершины.
Вытолкнем несколько элементов из нашего стека.

Сначала выталкиваем О:

Теперь в нашем стеке есть только два элемента:

5 и 3.

165

Абстрактные типы данных

Затем выталкиваем

3:

В нашем стеке осталось только

5:

Для описания операций со стеком используется аббревиатура
расшифровывается как Last In, First Out ( @оследним пришел -

LIFO,

которая

первым ушел»).

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

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

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

структур данных для фактического хранения значений. Вот один из способов
реализации стека на языке

class Stack
def initialize
@data = []
end
def push(element)
@data ")", "[" => "]", "{" => "}"}[opening_brace]
end
end
Метод

lint

принимает строку с кодом JavaScript и перебирает все ее символы

с помощью фрагмента:

text.each_char do icharl
Если мы обнаруживаем открывающую скобку, то вталкиваем ее в стек:

if is_opening_brace?(char)
@stack.push(char)
Обратите внимание, что мы используем вспомогательный метод is_opening_

brace?, который проверяет, является ли символ открывающей скобкой. Он опре­
делен так:

["(", "[", "{"].include?(char)
Обнаружив закрывающую скобку, мы выталкиваем верхний элемент из стека
и сохраняем его в переменной

popped_opening_brace

=

popped_opening_brace:

@stack.pop

172

Глава

9. Создание чистого кода с помощью стеков и очередей

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

Иногда стек может быть пуст. Тогда при попытке извлечь из него элемент код
вернет значение

nil.

Это будет означать, что мы столкнулись с синтаксической

ошибкой второго типа:

if !popped_opening_brace
return "#{char} doesn't have opening brace"
end
При обнаружении ошибки в процессе анализа мы возвращаем простую строку
с сообщением о ней.
После удачного извлечения открывающей скобки из стека мы проверяем ее на
предмет соответствия текущей закрывающей. Если их тип не совпадает, значит,

мы столкнулись с синтаксической ошибкой третьего типа:

if is_not_a_match(popped_opening_brace, char)
return "#{char} has mismatched opening brace"
end
(Вспомогательный метод

is_not_a_match

будет определен в коде позже.)

Наконец, по завершении анализа строки мы проверяем с помощью фрагмента

@stack. read,

не остались ли в стеке открывающие скобки. Если да, значит,

в строке есть незакрытая скобка

и мы выдаем сообщение о синтаксической

-

ошибке первого типа:

if @stack.read
return "#{@stack.read} does not have closing brace"
end
Если в кoдejavaScript нет ошибок, возвращаем
Мы можем использовать наш класс

linter = Linter.new
puts linter.lint("( var

х

= { у:

true.

Linter так:
[1, 2,

З]

} )")

Здесь кoдjavaScript верный, поэтому программа возвращает

true.

Но если мы введем строку с ошибкой, например, без открывающей скобки:

"var

х = { у:

[1, 2,

З]

})"

то получим сообщение об ошибке:

) doesn 't have opening brace.

173

Очереди

В этом примере мы использовали стек для реализации линтера с очень изящным

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

О важности ограниченных структур данных
Если стек

это просто ограниченная версия массива, значит, массив может

-

делать все то же, что и стек. А раз так, то в чем же преимущество стека?
Ограниченные структуры данных, такие как стек (и очередь, о которой мы по­
говорим далее), важны по нескольким причинам.
Во-первых, используя ограниченные структуры данных, мы можем предотвра­

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

перестанет эффективно работать. Используя стек, мы можем извлекать только
элементы, которые находятся на вершине.

Во-вторых, такие структуры данных предоставляют нам новую мысленную мо­

дель решения задач. К примеру, стек дает нам представление о принципе
(«последним пришел

-

LIFO

первым ушел»), который мы потом можем применить

для выполнения разных задач, вроде создания вышеописанного линтера.

А после знакомства с принципом работы стека мы начнем писать качественный
и понятный другим разработчикам код. Увидев в алгоритме стек, они сразу
поймут, что работа алгоритма основана на принципе
Итак, стеки
пришел

-

-

LIFO.

идеальный выбор для обработки данных по принципу «последним

первым ушел». Например, они подойдут для реализации функции

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

стека оказывается предпоследнее нажатие клавиши, которое при необходимости
тоже можно отменить.

Очереди
Очередь

-

это еще одна структура для обработки временных данных. Она по­

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

относится к абстрактным типам данных.

174

Глава

9. Создание

чистого кода с помощью стеков и очередей

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

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

граммисты говорят, что очереди работают по принципу FIFO ( «Fiгst
Out~, «первым пришел

-

In,

Fiгst

первым ушел~).

Как и обычная вереница из людей, очередь изображается горизонтально. На­
чало очереди принято называть головой, а конец

Как и стеки, очереди



-

-

хвостом.

это массивы с тремя ограничениями:

данные могут вставляться только в конец очереди (как и в случае со
стеком);



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



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

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

Сначала вставляем в очередь значение

зывают постановкой в очередь

5 (вставку нового значения обычно на­
(enqueue), но мы будем использовать эти терми­

ны взаимозаменяемо):

Затем мы вставляем

Потом

9:

- 100:

До сих пор очередь вела себя так же, как стек. Но удаление элементов из очере­
ди

( dequeue)

происходит в обратном порядке

-

начиная с головы.

175

Очереди

Чтобы удалить данные, мы должны начать с

5, так как это значение

находится

в начале очереди:

rl 911001
Затем мы удаляем

9:

Теперь в очереди остался только один элемент

-

значение

100.

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

Так выглядит реализация очереди на языке

Ruby:

class Queue
def initialize
@data = []
end
def enqueue(element)
@data = len(array):
return
array[index] *= 2
index + 1)

douЫe_array(array,

Протестируем эту функцию с помощью следующего кода:

array = [1, 2, 3, 4]
0)

douЫe_array(array,

print(array)
Теперь наша рекурсивная функция завершена. Но если ваш язык программи­
рования поддерживает аргументы по умолчанию, мы можем сделать ее код еще
проще.

Сейчас вызов функции выглядит так:
douЫe_array([l,

2, 3, 4, 5], 0)

Мы признаем, что передача значения О в качестве второго параметра выглядит

не очень. Это нужно нам для реализации трюка с отслеживанием индекса, ко­

торое всегда начинается с нуля.
Но использование параметров по умолчанию позволяет обойтись без этого
и вызывать функцию так же, как раньше:
douЫe_array([l,

2, 3, 4, 5])

Так выглядит наш обновленный код:

def
#

douЫe_array(array,

Базовый случай:

index=0):

индекс выходит за границу массива

if (index >= len(array)):
return
array[index] *= 2
douЫe_array(array,

Все, что мы сделали,

-

index + 1)
это задали аргумент по умолчанию

index

= 0.

Так, при

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

Прием с использованием дополнительных параметров функции очень удобен
и широко применяется в написании рекурсивных функций.

197

Категория рекурсивных задач: вычисления

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

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

Есть много функций для выполнения вычислений. Например, те, что возвра­

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

В главе 1О мы выяснили, что рекурсия бывает особенно полезна для выполнения
задач с произвольным количеством уровней. Вторая область, где рекурсия осо­
бенно хорошо себя проявляет,

-

вычисления на основе результата выполнения

подзадачи основной задачи.
Прежде чем познакомиться с подзадачами, вернемся к примеру с вычислением
факториала. Как вы уже знаете, факториал

6 равен:

6 х 5 х 4 х 3 х 2 х 1.
Для написания функции, которая находит факториал числа, мы могли бы ис­
пользовать классический цикл, который начинается с
жает

1 на 2, затем

На языке

полученный результат умножает на

Ruby такая

1, то есть сначала умно­
3 и т. д., вплоть до 6.

функция может быть реализована так:

def factorial(n)
product = 1
(1 .. n).each do lnuml
product *= num
end
return product
end
Но мы можем применить другой подход: вычислить факториал на основе ре­

зультатов выполнения подзадачи.

Подзадача

- это версия основной задачи меньшего размера. Применим этот

подход к нашему случаю.

Если подумать, то factorial ( 6) будет равен числу 6, умноженному на результат
выполнения функции

factorial(S).

198

Глава

Поскольку

factorial ( 6)

писать рекурсивный код

равен:

6
а

11. Учимся

х

5

х

4

х

3

х

2

х

5

х

4

х

3

х

2

х

1.

1,

factorial(S):

Результат выполнения функции factorial(б) равнозначен:

6 х factorial ( 5).
То есть, чтобы получить результат вызова
умножить на

6 полученный результат

factorial ( 6), мы можем просто
вызова factorial ( 5).

factorial(S) - это менее масштабная задача, результат выполнения которой
можно использовать для вычисления результата основной, поэтому она будет
подзадачей

factorial ( 6).

Вот реализация этого подхода из предыдущей главы:

def factorial(number)
i f number == 1
return 1
else
return number * factorial(number - 1)
end
end
Опять же, ключевая строка здесь

- return number * factorial(number - 1), в ко­

торой мы вычисляем результат, умножая
задачи

number

на результат выполнения под­

factorial(number - 1).

Два подхода к вычислениям
Итак, при написании функции для вычислений можно использовать два под­
хода: выполнять задачу «снизу вверх:1> или атаковать ее «сверху вниз:1>, опи­

раясь на результаты подзадач. Такие восходящие и нисходящие подходы ча­
сто описываются в литературе по информатике в разделах, посвященных
рекурсии.

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

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

199

Нисходящая рекурсия: новый способ мышления

def factorial(n, i=l, product=l)
return product if i > n
return factorial(n, i + 1, product * i)
end
Здесь мы используем три параметра. Как и прежде, п которого мы вычисляем. i -

это число, факториал

это простая переменная, которая начинается с

1

и увеличивается на единицу при каждом последующем вызове до достижения

значения п. Наконец,

product -

это переменная, где хранится текущий резуль­

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

Для такой реализации восходящего подхода мы вполне можем использовать

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

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

Но двигаться сверху вниз позволяет только рекурсия. И эта уникальная способ­
ность реализовать нисходящую стратегию

-

одна из ее сильнейших сторон.

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

реализации нисходящего подхода. Этот подход предоставляет нам новую мыс­

ленную стратегию выполнения задачи, позволяя взглянуть на нее под другим
углом.

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

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

factorial:

return number * factorial(number - 1)
Она производит вычисление на основе результата

factorial{number - 1). Когда

мы пишем эту строку, обязательно ли нам понимать, как работает функция

factorial,

которую она вызывает? Нет. При написании кода, который вызыва­

ет другую функцию, мы предполагаем, что она возвратит правильное значение,

и нам вовсе не обязательно понимать принцип ее работы.

200

Глава

11. Учимся

При вычислении ответа на основе результата вызова

писать рекурсивный код

factorial

нам не обяза­

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

factorial!

Вышеупомянутая строка кода находится внутри этой функции.

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

Используя рекурсию для реализации нисходящей стратегии, мы можем немно­

го разгрузить свой мозг и даже проигнорировать некоторые подробности про­
цесса вычисления, просто положившись на то, что результат решения подзадач

будет правильным.

Нисходящий способ мышления
Если вы никогда не применяли нисходящую рекурсию, у вас уйдет некоторое

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

1.

Представьте, что функция, которую вы пишете, уже реализована кем-то
другим.

2.
3.

Определите подзадачу основной задачи.

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

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

Вычисление суммы элементов массива
Допустим, нам нужно написать функцию

sum,

которая суммирует все числа

в заданном массиве. Например, при передаче этой функции массива

[1, 2,

З,

4, 5] она должна возвратить значение 15 - сумму чисел в нем.
Для начала представьте, что

sum уже реализована.

Это может быть нелегко, ведь

мы знаем, что только пишем ее! Но давайте хотя бы попытаемся допустить, что
функция

sum уже существует и работает как положено.

Теперь определим подзадачу. Этот процесс

-

Он требует практики. В нашем случае подзадача
массива

[2,

з,

4, 5],

скорее, искусство, чем наука.

-

это суммирование элементов

всех чисел из исходного массива, кроме первого.

201

Нисходящая рекурсия: новый способ мышления

Теперь посмотрим, что произойдет, если мы используем sum для выполнения

нашей подзадачи. Если эта функция «уже работает как положено~, а подзадача
сводится к суммированию элементов массива

зова sum( [ 2, 3, 4, 5]) будет значение

14 -

[2, 3, 4, 5],

сумма чисел

то результатом вы­

2, 3, 4 и 5.

Получается, что для нахождения суммы элементов массива

можем просто прибавить первое число,

[1, 2, 3, 4, 5] мы

1, к результату вызова sum( [2, 3, 4, 5] ).

В псевдокоде мы бы написали что-то вроде этого:

return array[0) + sum(the remainder of the array)
На языке

Ruby мы можем реализовать это так:

return array[0) + sum(array[l, array.length - 1])
(Во многих языках синтаксис

array[x,

у] возвращает массив элементов с ин­

дексами от х до у.)

Хотите верьте, хотите нет, но мы закончили! Если не считать базового случая,

к которому мы перейдем прямо сейчас, наша функция sum может быть реализо­
вана так:

def sum(array)
return array[0] + sum(array[l, array.length - 1])
end
Обратите внимание, что мы не думали о том, как будем складывать все числа
массива. Мы просто представили, что кто-то другой уже написал за нас функ­
цию

sum,

и применили ее к подзадаче

-

отложили выполнение задачи на потом

и тем самым выполнили ее.

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

s um ( [ 5] ) .

При ее выполнении функция попытается сложить

элементами массива, которых в нем нет.

Решить эту проблему можно, предусмотрев базовый случай:

def sum(array)
#

Базовый

случай:

массив содержит только один элемент:

return array[0] if array.length == 1
return array[0] + sum(array[l, array.length - 1])
end
Вот и все.

5

с остальными

202

Глава

11. Учимся

писать рекурсивный код

Обращение строки
Представьте, что нам нужно написать функцию
строку, то есть при передаче аргумента

"abcde"

reverse, которая обращает
"edcba".

возвращает

Сначала определим подзадачу. Опять же, для этого нужна практика, но обычно

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

"abcde" подзадачей будет строка "bcde", то есть исходная строка без

первого символа.

Теперь представим, что кто-то оказал нам большую услугу, реализовав функцию

reverse.

Как мило с его стороны!

Итак, если функция
строки

reverse уже есть, а наша подзадача сводится к обращению
"bcde", то мы можем вызвать reverse("bcde" ), которая возвратит "edcb".

После этого разобраться с символом "а" будет проще простого: достаточно по­
местить его в конец строки.

И так, мы можем написать:

def reverse(string)
return reverse(string[l, string.length - 1]) + string[0]
end
Наш процесс вычисления сводится к получению результата вызова функции

reverse для выполнения подзадачи и добавлению первого символа в конец
полученной строки.

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

-

это строка из одного символа, поэтому для его обработки мы

можем добавить в код следующий фрагмент:

def reverse(string)
#

Базовый случай:

строка

из одного символа

return string[0] if string.length == 1
return reverse(string[l, string.length - 1]) + string[0]
end
Дело сделано.

Подсчет символов «Х» в строке
Пока мы в ударе, рассмотрим еще один пример и напишем функцию

count_x,

которая возвращает количество символов "х" в заданной строке. Получив стро-

203

Нисходящая рекурсия: новый способ мышления

ку" axbxcxd", она должна возвратить значение

3, так как в строке три экземпля­

ра символа "х".

Сначала определим подзадачу. Как и в примере выше, подзадачей будет исход­
ная строка без первого символа. Итак, подзадача для строки "axbxcxd"

"xbxcxd"i,.,
Теперь представим, что функция count_x уже реализована. Если мы вызовем

count_x для
чим

3.

выполнения подзадачи с помощью кода

count_x( "xbxcxd" ), то полу­

К этому результату нам достаточно прибавить

1,

если первый символ

исходной строки тоже "х" (если нет, прибавлять ничего не нужно).
И так, мы можем написать:

def count_x(string)
if string[0] == "х"
return 1 + count_x(string[l, string.length - 1])
else
return count_x(string[l, string.length - 1])
end
end
Это довольно простое условное утверждение. Если первый символ строки

мы прибавляем

1 к результату подзадачи, если нет -

-

"х",

возвращаем результат этой

подзадачи как есть.

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

-

строка из одного симво­

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

def count_x(string)
#

Базовый случай:

if string.length == 1
if string[0] == "х"
return 1
else
return 0
end
end
if string[0] == "х"
return 1 + count_x(string[l, string.length - 1])
else
return count_x(string[l, string.length - 1])
end
end

204

Глава

11. Учимся писать рекурсивный код

К счастью, для решения этой проблемы мы можем применить еще один простой
прием.

Во многих языках вызов

string[l, 0]

возвращает пустую строку. Благодаря

этому мы можем упростить наш код:

def count_x(string)
#

Базовый случай:

пустая строка

return 0 if string.length == 0
if string[0] == "х"
return 1 + count_x(string[l, string.length - 1))
else
return count_x(string[l, string.length - 1))
end
end
Базовый случай в этой версии

-

пустая строка

(string. length

==

0).

Мы воз­

вращаем О, потому что в пустой строке никогда не будет "х".

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

string.length - 1 равно

О. Код

1 или О к результату
- count_x(string[l, 0] ), так как

string[l, 0]

возвращает пустую стро­

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

Для справки: вызов

array[l, 0]

также возвращает пустой массив во многих

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

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

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

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

для меня это так!

Приведу один из моих любимых примеров

-

задачу с лестницей.

205

Задача с лестницей

Допустим, у нас есть лестница из

N ступенек, а человек может преодолеть одну,

две или три ступеньки за раз. Сколько есть возможных «способов~ подъема по
такой лестнице? Напишите функцию, которая вычисляет число таких способов
для

N ступенек.

На следующем изображении показаны три возможных способа подъема по
пятиступенчатой лестнице.

Это всего лишь три варианта из множества.

Используем для выполнения этой задачи восходящий подход. Мы будем дви­
гаться от простых случаев к более сложным.
Очевидно, что при наличии всего одной ступеньки возможный способ подъема
ТОЛЬКО ОДИН.

206

Глава

11. Учимся

писать рекурсивный код

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

Запишем это так:

1, 1
2

Для подъема по трехступенчатой лестнице есть четыре возможных способа:
1, 1, 1

1, 2
2, 1
3

При наличии четырех ступенек число способов подъема достигает семи:
1,
1,
1,
1,
2,
2,
3,

1,
1,
2,
3
1,
2
1

1, 1
2
1
1

Попробуйте составить остальные комбинации для пятиступенчатой лестницы.
Будет непросто! А это всего пять ступенек. Представьте, сколько комбинаций
будет для

11.

Теперь перейдем к главному вопросу: как написать код для подсчета всех воз­
можных способов подъема?
Без рекурсивного мышления понять алгоритм выполнения этого вычисления
очень трудно. Но при использовании соответствующего нисходящего подхода
эта задача может оказаться на удивление легкой.

Допустим, первая подзадача для 11-ступенчатой лестницы

-

это

1О-ступенчатая

лестница. Как думаете, если бы мы знали число всех способов подъема по 1О-сту­
пенчатой лестнице, могли бы мы использовать его при подсчете возможных

способов подъема по 11-ступенчатой?
Во-первых, мы точно знаем, что для подъема по 11-ступенчатой лестнице нуж­
но не меньше шагов, чем для подъема по 10-ступенчатой. То есть у нас есть все

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

9 и 8.

207

Задача с лестницей

Если задуматься, становится ясно, что при выборе любого способа подъема
с 10-й ступеньки на 11-ю мы не учитываем ни один из способов, предполагающих
прыжок с 9-й на 11-ю. И наоборот, если мы прыгаем с 9-й ступеньки на 11-ю, то
исключаем все способы, предполагающие попадание на 10-ю.
Итак, мы точно знаем, что количество способов подъема будет включать как
минимум число путей до 10-й и до 9-й ступенек.
А поскольку человек может перепрыгнуть и с 8-й ступеньки на 11-ю, то есть
преодолеть три ступеньки за раз, мы должны учесть соответствующее число

способов подъема.
Итак, мы выяснили, что число способов подъема на вершину равно, по крайней
мере, сумме всех способов достижения ступенек

10, 9 и 8.

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

11-ю. Поэтому количество способов подъема для

N ступенек равно:

number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)
Если не считать базового случая, можно сказать, что код функции уже готов!

def number_of_paths(n)
number_of_paths(n - 1) + number_of_paths(n - 2) + number_of_paths(n - 3)
end
Это кажется невероятным, но перед нами почти весь код, который нам нужен.

Осталось разобраться с базовым случаем.

Базовый случай для задачи с лестницей
Определить базовый случай для этой задачи проблематично, так как при дости­

жении

n,

равного

3, 2 или 1,

эта функция продолжает вызывать саму себя с по­

мощью нулевого или отрицательного значения n. Например, number_of _paths ( 2)
вызывает

number _of_paths(l), number _of_paths(0)

Один из способов решения этой проблемы

-

и

number _of_paths(-1).

«жестко запрограммировать» все

базовые случаи:

def number_of_paths(n)
return 0 if n ->

(Финиш)

s
F
Под «кратчайшим>-> путем подразумевается то, что каждый раз вы пере­
двигаетесь на один шаг вправо:

s ....
F
или на один шаг вниз:

s
1

"
F
Ваша функция должна вычислять количество кратчайших путей.

ГЛАВА

12

Динамическое
прQграммирование

В прошлой главе вы научились писать рекурсивный код и использовать рекур­
сию для решения некоторых сложных задач.

Но, несмотря на то что рекурсия может решить некоторые проблемы, при не­
правильном использовании она способна и породить новые. На самом деле

именно рекурсия часто становится виновницей медленной работы некоторых
алгоритмов

-

например, относящихся к категории сложности

0(2N).

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

скорость работы рекурсивного кода, и научитесь описывать соответствующие
алгоритмы с помощью О-нотации. Что еще важнее

-

вы узнаете, как решать все

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

в рекурсивное блаженство.

Бесполезные рекурсивные вызовы
Ниже представлена рекурсивная функция, которая находит наибольшее число
в массиве:

def max(array)
#
#

Базовый

случай:

если массив содержит

всего один

элемент,

то этот элемент по определению будет наибольшим числом:

returп

array[0] if

array.leпgth

== 1

216

Глава

12. Динамическое

программирование

# Сравниваем первый элемент с наибольшим числом из оставшихся в массиве.
# Если он больше, возвращаем его в качестве наибольшего числа:

if array[0) > max(array[l, array.length - 1))
return array[0)
# Если нет, возвращаем наибольшее число из оставшихся в массиве:

else
return max(array[l, array.length - 1))
end
end
Каждый рекурсивный вызов сравнивает одно число

( array [ 0]) с максимальным

числом из оставшихся в массиве (чтобы найти максимальное число среди остав­
шихся, мы вызываем ту самую функцию

max,

внутри которой находимся. Имен­

но это и делает ее рекурсивной).

Выполняем сравнение с помощью условного оператора, первая половина кото­
рого выглядит так:

if array[0] > max(array[l, array.length - 1))
return array[0)
Исходя из этого фрагмента кода становится ясно, что если одиночное значение

(array[0]) превышает текущее максимальное число из оставшихся в массиве
(max(array[l, array. length - 1]) ), array[0] по определению будет наибольшим
числом. Поэтому мы возвращаем именно его.

Вторая половина условного оператора выглядит так:

else
return max(array[l, array.length - 1))
Глядя на второй фрагмент, мы понимаем, что если

array[0] не превышает теку­

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

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

-

max(array[l, array. length - 1])

встре­

по одному разу в каждой половине условного оператора.

Проблема в том, что каждое упоминание

max(array[l, array.length - 1]) за­

пускает лавину рекурсивных вызовов.

Разберем этот вопрос на примере массива [1, 2, з, 4].
Мы знаем, что сначала функция сравнит

1 с максимальным числом из оставших­
2 с максимальным значе­
нием из оставшихся [ з, 4], а затем и к сравнению 3 с [ 4]. Еще один рекурсивный
вызов будет выполнен для значения [ 4], которое служит базовым случаем.
ся в массиве

[ 2,

з,

4].

Это приведет к сравнению числа

217

Бесполезные рекурсивные вызовы

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

Приступим.

Пошаговый разбор выполнения рекурсивной функции
При вызове

max( [ 4])

с одним элементом

-

функция просто возвращает число

4,

max

потому что массив

это базовый случай, как видно в следующей строке кода:

return array[0] if array.length == 1
Здесь все понятно

-

это просто единичный вызов функции:

max([4])
Пройдем вверх по цепочке вызовов и посмотрим, что произойдет при вызове

max( [З, 4] ). В первой половине условного оператора (if array[0] > max(array[l,
array.length - 1] )) мы сравниваем 3 с max( [4] ). Но вызов max( [4]) сам по себе
рекурсивен. На следующей схеме показано, как вызов функции max( [ 4]) вы­
полняется в рамках вызова max ( [ з, 4] ) :
mах([З,4])

1-й!
max([4])
Обратите внимание на метку «1-й» рядом со стрелкой, указывающей на то, что
этот рекурсивный вызов был выполнен первой половиной условного оператора
в рамках вызова

max( (3, 4] ).

После выполнения этого шага код может сравнить

3

с результатом вызова

max( [ 4] ), которым будет 4. Поскольку 3 меньше 4, выполняется вторая полови­
на условного оператора (return max(array[l, array. length - 1]) ). В результате
код возвращает max ( [ 4] ) .
Но это приводит к фактическому запуску второго вызова функции
mах([З,4])

1-й! 2-~
max([4]) max([4])

max ( [ 4]):

218

Как видите,

Глава

max ( [ з, 4] )

12. Динамическое

предусматривает два вызова

программирование

max ( [ 4] ) . Конечно, мы
max ( [ 4]), зачем

хотели бы этого избежать. Если мы уже вычислили результат
нам снова вызывать ту же функцию?

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

Вот что происходит при вызове

max( [2,

з,

4] ).

Первая половина условного оператора сравнивает число

2 с max ( [ з, 4] ) . Про­

цесс, как мы уже выяснили, выглядит так:

mах([З,4])

1-й! 2-~
max([4])
Получается, что вызов

max([4])

max ( [ з, 4] ) в рамках вызова max ( [ 2, з, 4] ) будет вы­

глядеть так:

mах([2,З,4])

1-й!
mах([З,4])

1-й! 2-й~
max([4])

max([4])

Но подвох в том, что мы разобрали только первую половину условного опера­

тора, обрабатывающего вызов

max ( [ 2, З, 4] ) . Во второй мы снова вызываем

max( [З, 4] ):
mах([2,З,4])

!

2-~

1-й

mах([З,4])

1-й! 2-~
max([4])

max([4])

mах([З,4])

1-й! 2-~
max([4])

max([4])

Маленькое исправление для большого

219

«0»

Ой!
Если мы осмелимся подняться на самую вершину цепочки и вызовем

max( [1,

2, з, 4] ) , то после вызова функции max в обеих половинах условного оператора
мы получим вот это:

mах([l,2,З,4])-

1-й

i

....
mах([2,З,4])

mах([2,З,4])

1-й~
mах([З,4])

1-й

1-й~

2-й~

max( [3 ,4])
1-й 2-й~

mах([З,4])

i 2-й~

1-й

2-й~

i

i 2-й~

mах([З,4])

1-й

i 2-й~

max([4]) max([4]) max([4]) max([4]) max{[4]) max([4]) max{[4]) max{[4])
Итак, при вызове

max( [1, 2,

з,

4])

функция

max запускается целых 15 раз.

Мы можем убедиться в этом, добавив в начало функции фрагмент кода

puts

"RECURSION":

def max(array)
puts "RECURSION"
#

оставшаяся часть

кода опущена для

Теперь при запуске кода в консоли будет

краткости

15 раз отображаться слово RECURSI ON.

Конечно, некоторые из этих вызовов очень важны. Например, нам нужно вы­
числить результат

max( [4] ). Для этого вполне достаточно одного вызова такой

функции, но в примере выше мы вызываем ее восемь раз.

Маленькое исправление для большого

«0»

К счастью, есть простой способ избавиться от лишних рекурсивных вызовов:
вызов функции

max

только один раз и сохранениеполученного результата

в переменной:

def max(array)
returп

#
#

array[0] if

array.leпgth

== 1

Вычисляем максимальное значение

и сохраняем его в переменной:

среди оставшихся в массиве

220

Глава

max(array[l,

max_of_remaiпder =

#

Сравниваем первое

12. Динамическое

array.leпgth

число со значением этой

программирование

- 1])
переменной:

if array[0] > max_of_remaiпder
returп array[0]
else
returп max_of_remaiпder

епd
епd

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

ции

max до четырех. Убедитесь в этом сами, добавив в код строку puts "RECURSION"

и запустив его.

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

Разница в эффективности между исходной функцией и ее слегка измененной
версией весьма существенная.

Эффективность рекурсии
Во второй, улучшенной версии функции

max число рекурсивных вызовов соот­

ветствует количеству значений в массиве. Поэтому мы можем отнести ее к ка­
тегории

O(N).

Алгоритмы

O(N), с которыми мы работали до сих пор, предполагали использо­
N раз. Но мы можем оценить с помощью О-нотации

вание циклов, выполняемых
и рекурсивные функции.

Как вы помните, О-нотация помогает узнать, сколько шагов будет выполнять
алгоритм при наличии

N элементов данных.

Поскольку улучшенная функция

max выполняется N раз при N значений в мас­
O(N). Даже если сама функция предусма­

сиве, ее временная сложность равна

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

O(SN), что опять же сводится

к

O(N).

Но в первой версии функция вызывала саму себя дважды при каждом запуске

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

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

max для

мас­

221

Перекрывающиеся подзадачи

Количество вызовов

Nэлементов

1
3
7
15
31

2
3
4

5

Видите закономерность? Увеличение числа элементов массива на единицу при­

водит почти к удвоению числа шагов алгоритма. Как было сказано в главе

7,

такие алгоритмы относятся к категории 0(2Л') и работают очень медленно.
Но в улучшенной версии число рекурсивных вызовов функции

ет количеству элементов в массиве. Значит, она относится к

max соответству­
категории O(N).

Исходя из этого, можно сделать важный вывод: предотвращение ненужных

рекурсивных вызовов

- ключ к поддержанию высокой скорости работы рекур­

сивной функции. То, что казалось совсем незначительным изменением кода,

простое сохранение результатов вычислений в переменной, в итоге позволило

перевести функцию из категории 0(2Л') в

O(N).

Перекрывающиеся подзадачи
Последовательность Фибоначчи

- это математическая числовая последователь­

ность, которая начинается так:

О,

1, 1, 2, 3, 5, 8, 13, 21, 34, 55 ...

и продолжается до бесконечности.
Первые числа Фибоначчи
дущих. Например,

55 -

- О и 1, а каждое последующее - сумма двух преды­
- 21 и 34.

это сумма двух предыдущих чисел

Следующая функция на языке

Python

возвращает

N-e

число последователь­

ности Фибоначчи. Например, если мы передадим ей число

10, она возвратит 55,

так как это десятый элемент этой последовательности (О считается нулевым
элементом).

def
#

if

fib(п):
Первые два числа

п

== 0 or

returп

п

последовательности

-

базовые случаи:

== 1:

п

# Возвращаем сумму двух предыдущих чисел Фибоначчи:
- 2) + fib(п - 1)

returп fib(п

222

Глава

12. Динамическое

программирование

Ключевая строка этой функции:

return fib(n - 2) + fib(n - 1)
суммирует два предыдущих числа Фибоначчи. Это прекрасная рекурсивная
функция.

Но прямо сейчас вы должны насторожиться, ведь функция вызывает сама себя

дважды.
Для примера рассмотрим вычисление шестого элемента последовательности

Фибоначчи. Как видно на следующей схеме, функция fib(б) вызывает как

fib(4),

так и

fib(S).

/fib(б)"-..
fib(4)

fib(S)

Мы уже знаем, что функция, которая дважды вызывает саму себя, относится
к категории СЛОЖНОСТИ 0(2Л).
Вот все рекурсивные вызовы, которые выполняются в рамках вызова fib(б):

~fib(б)~
fib(4)

fib(S)

/~
fib(2)

fib(З)

fib(З)

/~

1\

1\

1\

fib{0) fib{l)

fib{l) fib(2)

fib(l) fib(2)

fib(4)

1\
fib(2)

~~
fib(0) fib(l)

fib(0) fib(l)

fib(З)

~\,.

fib(0) fib(l) fib(l) fib(2)

fib(0) fib(l)

Согласитесь, что работа алгоритмов категории 0(2Л) выглядит довольно устра­
шающе.

Но оптимизировать функцию для вычисления чисел Фибоначчи не так легко,
как это было в первом примере, где мы просто внесли в код одно простое из­
менение.

223

Динамическое программирование с помощью мемоизации

Мы не можем сохранить в переменной только один фрагмент данных. Нам
нужен результат вычисления как

число Фибоначчи

-

fib(n - 2), так и fib(n - 1) (так как каждое

сумма этих двух чисел), и, сохранив только один из них,

мы не получим второй.
Для описания этого случая программисты используют термин перекрывающи­

еся подзадачи. Что он значит? Давайте разберемся.
Когда основная задача выполняется путем реализации ее уменьшенных версий,

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

-

мы часто сталкивались с ней при обсуждении рекурсии. В случае

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

fib(n - 2)
fib ( n - 1) приводят к многократному выполнению одних и тех же вычислений.
То есть в рамках вызова fib(n - 1) производятся некоторые из действий, кото­
рые уже были выполнены в рамках вызова fib(n - 2). Например, как видно на
прошлой диаграмме, и fib(4), и fib(S) вызывают функцию fib(З) (и осущест­
и

вляют многие другие повторяющиеся вызовы).

Может показаться, что мы в тупике: наша функция для нахождения чисел Фи­
боначчи предполагает выполнение множества перекрывающихся вызовов, из-за
чего алгоритм работает с черепашьей скоростью 0(2Л'). И мы ничего не можем
с этим поделать.

Или можем?

Динамическое программирование
с помощью мемоизации
К счастью, нам на помощь приходит динамическое программирование (ДП)

-

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

Не обращайте внимания на слово «динамическое~. Никто точно не знает, как
появился этот термин, и в методах, которые я вам покажу, тоже нет ничего осо­

бенно динамического.

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

-

это простая, но очень

эффективная техника сокращения рекурсивных вызовов для решения пере­
крывающихся подзадач.

224

Глава

12. Динамическое

программирование

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

В нашем примере с числами Фибоначчи при первом вызове fib(З) функция
выполняет вычисления и возвращает

2.

Но, прежде чем двигаться дальше, она

сохраняет этот результат в хеш-таблице:
{З:

2}

Эта запись означает, что результат вызова функции fib(З)

- значение 2.

Точно так же наш код будет запоминать результаты всех вновь выполненных
вычислений. Например, после вызова функций

fib(4), fib(S) и fib(б) наша

хеш-таблица будет выглядеть так:
{
З:

2,

4: з,
5: 5,
б: 8
}
Благодаря этой хеш-таблице мы можем предотвратить будущие рекурсивные
вызовы. Вот как это работает.
Без мемоизации функция

fib(4) вызывает fib(З) и fib(2), которые выполняют

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

fib(4)

сначала проверяет

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

вызовов. Используя мемоизацию, мы сохраняем результаты каждого нового

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

Все это звучит хорошо, но есть один вопрос: «Как рекурсивные функции полу­

чают доступ к этой хеш-таблице?»
Ответ таков: мы передаем эту хеш-таблицу функции в качестве второго пара­
метра.

Так как хеш-таблица

-

это особый объект в памяти, мы можем передавать ее от

одного рекурсивного вызова к другому, даже если по ходу дела модифицируем
ее. Это подходит даже для раскручивания стека вызовов. Несмотря на то что во

время первого вызова хеш-таблица могла быть пустой, она вполне может за­
полниться данными к моменту завершения выполнения исходного вызова.

225

Динамическое программирование с помощью мемоизации

Реализация мемоизации
Для передачи хеш-таблицы мы изменяем функцию так, чтобы она принимала
два аргумента, вторым из которых и будет наша хеш-таблица с именем

def

fib(п,

memo:

memo):

При первом вызове функции мы передаем ей число и пустую хеш-таблицу:
fib(б,

{})

Таблица передается дальше при каждом рекурсивном вызове функции fib, по­
степенно заполняясь данными.

Остальная часть функции выглядит так:

def

fib(п,

if

п

== 0 or

returп

#

memo):
п

== 1:

п

Проверяем хеш-таблицу

(memo)

на предмет наличия

#результата вычисления fib(п):

if

поt memo.get(п):

#
#

Если п нет в

memo[п]

#
#
#

memo,

вычисляем результат вызова fib(п)

с

помощью рекурсии

и сохраняем его в хеш-таблице:

=

fib(п

- 2, memo) +

fib(п

- 1, memo)

Сейчас результат вызова fib(п) уже точно находится в
(Возможно,

он был там и раньше,

а может быть,

memo.

мы сохранили его

в хеш-таблице при выполнении прошлой строки кода.

Сейчас он там

#точно есть.) Поэтому возвращаем его:
returп memo[п]

Проанализируем этот код построчно.

Итак, теперь наша функция принимает два параметра:

def

fib(п,

n и хеш-таблицу memo:

memo):

Мы могли бы задать для

memo

значение по умолчанию, чтобы не нужно было

явно передавать пустую хеш-таблицу при первом вызове функции:

def

fib(п,

memo={}):

Так или иначе базовые случаи О и

1 остаются

прежними, и мемоизация на них

никак не влияет.

Перед выполнением любых рекурсивных вызовов наш код сначала проверяет
наличие результата вычисления

if

поt memo.get(п):

fib(n)

для заданного

n:

226

Глава

12. Динамическое

программирование

Если он уже есть в хеш-таблице, мы просто возвращаем этот результат с помощью
кода

return memo[n].

Если результата там нет, выполняем вычисление:

memo[n]

=

fib(n - 2, memo) + fib(n - 1, memo)

Сохраняем результат вычисления в хеш-таблице

memo, чтобы не повторять этот

процесс заново.

Обратите внимание на то, как мы передаем

memo в качестве аргумента функции

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

еще сводится к

fib

все

fib(n - 2) + fib(n - 1). Но если этот результат новый, мы со­

храняем его в хеш-таблице, а если он уже там есть

-

просто берем его оттуда,

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

~fib(б)~
fib(4)

fib(S)

/~

tl\
fib(0)

/~

fib(З)

fib(2)

tl\

fib(l)

fib(l) 1fib(2)1

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

был извлечен из хеш-таблицы.
Какая же временная сложность у нашей функции теперь? Посмотрим, сколько
рекурсивных вызовов мы совершаем при разных значениях
Nэлементов

Количество вызовов

1

1
3
5

2
3
4
5
6

7

9
11

N:

227

Восходящее динамическое программирование

Итак, при наличии

N

элементов мы совершаем

2N - 1 вызовов.

О-нотация иг­

норирует константы, поэтому временная сложность этого алгоритма

- O(N).

Это в разы лучше, чем 0(2Л). Да здравствует мемоизация!

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

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

отказ от рекурсии и использование для решения той же задачи

-

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

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

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

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

ления элементов последовательности Фибоначчи.

Восходящий подход для вычисления элементов

последовательности Фибоначчи
Здесь мы начинаем с первых двух чисел Фибоначчи: О и
добрую итерацию для построения последовательности:

def
if

fib(п):
п

== 0:

returп

0

# Начальные значения а и Ь - это первые два числа
# последовательности соответственно:

1, и используем старую

Глава 12. Динамическое программирование

228
а

0

ь

1
Выполняем цикл,

#

for i

iп raпge(l,

перебирая значения от

1

до п:

п):

# а и Ь меняются, становясь следующими числами последовательности.
# При этом новым значением Ь становится Ь + а, а новым значением а
# прежнее значение Ь. Чтобы внести эти изменения, используем
# временную переменную:
temp = а
а

-

Ь

Ь

=

temp +

а

returп Ь

Начальные значения переменных а и Ь здесь

-

Ои

1 соответственно, первые два

числа Фибоначчи.
Чтобы вычислить каждое число последовательности вплоть до

n,

запускаем

цикл:

for i

iп raпge(l,

п):

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

temp
а

-

temp значение пред­

последнего:

= а

= Ь

Новое число последовательности, которое теперь будет храниться в перемен­
ной Ь,
Ь =

-

это сумма двух предыдущих:

temp +

Наш код

-

а

это простой цикл, перебирающий значения от

1 до N, поэтому алго­
O(N), как и тот, где

ритм выполняет N шагов и обладает временной сложностью
использовалась мемоизация.

Мемоизация и восходящий подход
Итак, вы познакомились с двумя основными методами динамического про­
граммирования: мемоизацией и восходящим подходом. Какой из них лучше?

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

229

Упражнения

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

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

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

отслеживать все вызовы с помощью стека, который потребляет память, а мемои­
зация использует хеш-таблицы, которые тоже занимают место на компьютере
(подробнее об этом

-

в главе

19).

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

Выводы
Теперь, когда вы научились писать эффективный рекурсивный код, считайте,

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

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

1.

12».

Следующая функция принимает массив чисел и возвращает сумму, которая

вычисляется, пока не превысит значение

100 при прибавлении какого-то

числа. В таком случае это число игнорируется. Но функция выполняет

лишние рекурсивные вызовы. Исправьте код, чтобы устранить ненужную
рекурсию:

def add_until_100(array)
return 0 if array.length == 0
if array[0] + add_until_100(array[1, array.length - 1]) > 100
return add_until_100(array[1, array.length - 1])
else
return array[0] + add_until_100(array[1, array.length - 1])
end
end

2.

Следующая функция использует рекурсию для определения N-го числа из

последовательности Голомба. Но это ужасно неэффективно! Оптимизи­

руйте функцию с помощью мемоизации (чтобы выполнить это упражнение,

230

Глава

12. Динамическое

программирование

вам вовсе не обязательно понимать, как вычисляется эта последователь­
ность).

def golomb(n)
return 1 if n == 1
return 1 + golomb(n - golomb(golomb(n - 1)));
end

3.

Это реализация задачи, связанной с поиском «уникальных путей~, из
прошлой главы. Используйте мемоизацию для повышения ее эффектив­
ности:

def unique_paths(rows, columns)
return 1 if rows == 1 11 columns == 1
return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1)
end

ГЛАВА

13

Рекурсивные алгоритмы
"дпа ус~орениа аь1оолнения ~ода

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

способные ускорить выполнение кода.
В прошлых главах мы познакомились с рядом алгоритмов сортировки: пузырь­

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

так называемый алгоритм быстрой сортировки

(Quicksort).

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

ципа его работы позволяет использовать рекурсию для ускорения реальных
алгоритмов.

Quicksort -

очень быстрый алгоритм сортировки, который особенно эффекти­

вен в средних случаях. Несмотря на то что в худших сценариях (когда элементы

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

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

232

Глава

13.

Рекурсивные алгоритмы для ускорения выполнения кода

Разбиение
Разбиение

(partitioning)

массива

называется опорным элементом

- это выбор случайного значения, которое
(pivot), и перераспределение остальных эле­

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

Для согласованности мы всегда будем выбирать в качестве опорного элемента

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

-

число

3:

101sl2l1 lб I®
Теперь устанавливаем «указатели», один из которых направлен на крайнее левое
значение массива, а другой

-

на крайнее правое, исключая опорный элемент:

l0lsl2l1lб I®
)*
Левый указатель

'\
Правый указатель

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

-

все прояснится, когда мы

обратимся к конкретному примеру).

1.

Левый указатель последовательно смещается на одну ячейку вправо, вплоть

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

2.

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

останавливается. Правый указатель останавливается и по достижении на­
чала массива.

3.

Остановка правого указателя означает, что мы на развилке. Если левый
указатель достиг правого (или оказался справа от него), мы переходим

233

Разбиение

к выполнению шага

4.

Если нет

-

меняем местами значения, на которые

направлены левый и правый указатели, а затем повторяем шаги

4.

1, 2 и 3.

Наконец меняем местами опорный элемент и значение, на которое сейчас
направлен левый указатель.

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

Рассмотрим этот процесс на примере.

Шаг

1: сравниваем значение, на которое направлен левый указатель (О), с опор­
(3):

ным элементом

l0l sl2l1 lб I®

'

)'

Правый указатель

Левый указатель

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

Шаг

2: перемещаем левый указатель:

l0lsl2l1lбl®

'

)'

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

Шаг 3: сравниваем значение под правым указателем

(6) с опорным.

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

Шаг

4: правый указатель смещается на одну ячейку:

l0lsl2l1lбl®
)'

'

Оно превы­

234

Глава

13. Рекурсивные алгоритмы для ускорения выполнения кода

Сравниваем значение под правым указателем

( 1) с опорным.

Оно меньше опор­

ного элемента, поэтому правый указатель останавливается.

Шаг

5:

оба указателя остановились, поэтому меняем местами значения, на ко­

торые они направлены:

l0 \1 \2 s б I®
l

?

l

'\

На следующем шаге мы снова активируем левый указатель.
Шаг

6: левый указатель смещается на одну ячейку:

101112\slбl®
?

'\

Сравниваем значение, на которое указывает левый маркер

Значение

2

(2),

с опорным.

меньше опорного элемента, поэтому левый указатель движется

дальше.

Шаг

7:

левый указатель смещается на одну ячейку. Обратите внимание, что

сейчас и левый, и правый указатели смотрят на одно значение:

l0

l

1 l 2 l s \б I®
? '\

Сравниваем значение под левым указателем с опорным элементом. Оно превы­
шает опорное, и левый указатель останавливается. Так как левый указатель
достиг правого, процесс перемещения указателей завершен.

235

Разбиение

Шаг

8:

меняем местами опорный элемент и значение, на которое направлен

левый указатель:

Хотя наш массив еще не полностью отсортирован, мы успешно выполнили раз­

биение. Теперь все числа меньше опорного элемента
а больше

-

справа. Это значит, что само значение

(3) находятся слева от него,

3 теперь на месте.

Программная реализация

Ruby, которая пре­
partition ! , разбивающий массив способом, который мы

Ниже приведена реализация класса SortaЬleArray на языке

дусматривает метод
разобрали:

class

SortaЫeArray

attr_reader :array
def iпitialize(array)
@array = array
епd

def

partitioп!(left_poiпter,

right_poiпter)

#В качестве опорного мы всегда выбираем крайний правый элемент.

#

Сохраняем индекс опорного элемента для дальнейшего использования:

pivot_iпdex

#

Считываем значение опорного элемента:

pivot
#

= right_poiпter

= @array[pivot_iпdex]

Помещаем правый указатель слева от опорного элемента

right_poiпter

-= 1

while true
#
#

Перемещаем левый указатель вправо,
не остановится

пока он

на значении меньше опорного:

236

Глава

while

13.

Рекурсивные алгоритмы для ускорения выполнения кода

@array[left_poiпter]

left_poiпter

+=

< pivot do

1

епd

#
#

Перемещаем правый указатель влево до тех пор,
не остановится на

while

@array[right_poiпter]

right_poiпter

-=

пока он

значении больше опорного:

> pivot do

1

епd

#

Левый

и правый указатели остановились.

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

if

left_poiпter

>=

right_poiпter

break
#
#

Если левый указатель все еще слева от правого,
меняем местами значения,

на

которые они

направлены:

else
@array[left_poiпter],

@array[right_poiпter]

@array[right_poiпter],

#
#

=

@array[left_poiпter]

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

left_poiпter

+=

1

епd
епd

# На последнем этапе разбиения меняем местами значение, на которое
# направлен левый указатель, и опорный элемент:
@array[left_poiпter], @array[pivot_iпdex]
@array[pivot_iпdex],

=

@array[left_poiпter]

# Возвращаем значение left_poiпter для использования в методе
# быстрой сортировки, который будет добавлен позднее
returп left_poiпter
епd
епd

Разберем этот код.
Метод

parti tioп !

принимает в качестве параметров исходные позиции левого

и правого указателей:

def

partitioп!(left_poiпter,

right_poiпter)

При его первом вызове эти указатели будут направлены на левый и правый
концы массива соответственно. Но, как мы увидим далее, алгоритм быстрой
сортировки предусматривает применение этого метода и к подразделам массива.

237

Разбиение

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

аргументов. Эта часть станет понятнее, когда мы познакомимся с полным алго­

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

значение обрабатываемого диапазона:

pivot_index = right_pointer
pivot = @array[pivot_index]
После этого наводим правый указатель

right_pointer на элемент слева от опор­

ного:

right_pointer

-= 1

Запускаем цикл, который будет выполняться, пока левый

right_pointer указатели

left_pointer и правый

не встретятся. Внутри этого цикла мы запускаем дру­

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

элемента, который больше опорного или равен ему:

while @array[left_pointer] < pivot do
left_pointer += 1
end
Точно так же перемещаем

right_pointer

влево, пока он не укажет на элемент

меньше опорного или равный ему:

while @array[right_pointer] > pivot do
right_pointer -= 1
end
Как только указатели остановятся, проверяем, не встретились ли они:

if left_pointer >= right_pointer
break
Если встретились, выходим из цикла и готовимся к перемещению опорного
элемента (этот процесс рассмотрим чуть позже). Если же нет, меняем местами
значения, на которые они указывают:

@array[left_pointer], @array[right_pointer]
@array[right_pointer], @array[left_pointer]
Как только два указателя встретятся, мы меняем местами опорный элемент
и значение, на которое указывает

left_pointer:

@array[left_pointer], @array[pivot_index] =
@array[pivot_index], @array[left_pointer]

238

Глава

13. Рекурсивные алгоритмы для ускорения

выполнения кода

Функция возвращает значение left_pointer, которое потребуется для выпол­
нения алгоритма быстрой сортировки (о котором я расскажу далее).

Алгоритм быстрой сортировки

(Quicksort)

Алгоритм быстрой сортировки сочетает в себе разбиение и рекурсию и выпол­
няется в несколько этапов.

1.
2.

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

1 и 2 для подмассивов слева и

справа от опор­

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

3.

Когда остается пустой или одноэлементный подмассив, мы достигаем ба­
зового случая и ничего не делаем.

Вернемся к примеру. Мы начали с массива

[0,

s,

2, 1, 6, З) и выполнили одно

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

Исходным опорным элементом было число

3. Теперь, когда оно на своем месте,

нам нужно отсортировать все значения слева и справа от него. Обратите вни­
мание, что в нашем примере числа слева от опорного элемента уже отсортиро­

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

-

разбиение подмассива

слева от опорного элемента.

Чтобы не отвлекаться, на время скроем остальную часть массива:

Теперь из подмассива (0, 1, 2) выбираем опорным крайний правый элемент.
Им будет число

2:-

CIOIJ@l) Jб l s I

Алгоритм быстрой сортировки

239

(Quicksort)

Устанавливаем левый и правый указатели в нужные положения:

Теперь мы готовы к разбиению этого подмассива. Продолжим с шага

8, на ко­

тором остановились ранее.

Шаг
ным

9: сравниваем значение, на которое смотрит левый указатель (О), с опор­
(2). Поскольку О меньше 2, продолжаем перемещение левого указателя.

Шаг 1О: смещаем левый указатель на одну ячейку вправо

-

теперь он направлен

на то же значение, что и правый:

Сравниваем значение под левым указателем с опорным. Так как

1 меньше опор­

ного, двигаемся дальше .

Шаг

11: смещаем левый указатель на одну ячейку вправо -

теперь он смотрит

на опорный элемент:

ШШ~iЗ"i б 1s 1

'? "'''"

Правый указатель

Левый указатель

Левый маркер указывает на значение, равное опорному (ведь это значение и есть

опорное!), поэтому останавливается.

Обратите внимание, как левый указатель сумел проскользнуть мимо правого.
Но все в порядке. Алгоритм будет работать даже в таком случае.
Шаг

12: активируем

правый указатель. Но, поскольку значение под ним

(1)

меньше опорного, он никуда не двигается.

Так как левый указатель стоит справа от правого, мы прекращаем их перемеще­

ние на этом этапе разбиения.

240
Шаr

Глава

13:

13. Рекурсивные алгоритмы для

ускорения выполнения кода

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

Так получилось, что левый указатель смотрит на опорный элемент, который

«меняется местами~ с самим собой, что не приводит ни к каким изменениям.
На этом процесс разбиения завершен и опорный элемент (2) теперь в правиль­
ной позиции:
\1•llfflll1111

l0l1l2lзlбlsl
, ••• , , . , , •• ,,11

Теперь у нас есть подмассив

[0, 1]

слева от опорного элемента

неrо подмассива нет. Следующий этап

-

(2), а справа от

рекурсивное разбиение подмассива

[ 0, 1] слева от опорноrо элемента. Нам не нужно проделывать то же самое
с подмассивом справа, так как

ero не существует.

Поскольку на следующем шаrе нас будет интересовать только подмассив [ 0, 1],
скроем остальную часть исходного массива:

l 0l 1l 2l з l б l s l
/1 1 1 \ \ \ \ 11 1 \ \ 1 1

Чтобы разбить подмассив [0, 1], сделаем крайний правый элемент (1) опорным.
Куда же мы поместим левый и правый указатели? Они оба будут смотреть на
О, так как мы всеrда располаrаем правый указатель слева от опорноrо элемента.

Дела у нас обстоят так:

ШJФI 21з l б l s I

/'

"' ' ' ''" ' ''' ' '

Теперь мы готовы начать процесс разбиения.
Шаr

14: сравниваем значение под левым указателем

(О) с опорным

ШJФ1 2ТЗ"i б 1s 1

/

'

/1 1 1 \ \ l \ ! • l \ \ l l

(1):

Алгоритм быстрой сортировки

241

(Quicksort)

Оно меньше опорного, поэтому двигаемся дальше.
Шаг

15: сдвигаем левый указатель на одну ячейку вправо -

теперь он направлен

на опорный элемент:

[Ш]фl 2"1·3"1б 1s 1
Правый указатель' /Ле~~;~ ;~~~~~:~ь
Поскольку значение под левым указателем

(1)

не меньше опорного (ведь оно

и естъ опорное) , левый указатель останавливается.
Шаг

16:

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

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

завершаем перемещение указателей на этом этапе разбиения.
Шаг

17: меняем местами значение под левым указателем с опорным элементом.

Поскольку левый указатель сейчас смотрит на опорный элемент, это ничего нам

не дает. Теперь опорный элемент находится на своем месте, и процесс разбиения
завершен.

Его результат выглядит так:

l0l1J2lзJбJsl
l • l \ \ l /1 1 \ \ \ l ll • 1\ \ 1\

Теперь нам нужно разбить подмассив слева от последнего опорного элемента.

У нас это одноэлементный подмассив

[ 0] .

Это базовый случай , поэтому мы

ничего не делаем, предполагая, что элемент уже на своем месте . Итак, вот что
у нас есть:

iT

~'1' ет 2ТЗ" ·161 s 1
,," , 1\\1\1tl\/1\11\\11//1•1/

Мы начали с выбора значения
массив слева от него
массивом

3 в качестве опорного и рекурсивно разбили под­

([0, 1, 2)).
справа ([6, 5)).

Теперь нам нужно сделать то же самое с под­

242

Глава

Скроем часть

13. Рекурсивные алгоритмы для ускорения выполнения кода

[0, 1, 2,

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

З], так как уже отсортировали ее, и сосредоточимся

[ 6, 5]:

(3".l б l sI
~'i"0"1'i"l"2'
", ,, , ,,,,,,. ,,,,,., ,,,,,,, , ,,
При его разбиении выбираем крайний правый элемент

(5) в качестве опорного:

~I0TiT2TЗ"i 6 I®
' ' 11 • 1 \ \ 1 \ 1 1 1 , , , , , , , , , , , , , , 1 1

Левый и правый указатели смотрят на значение

6:

~'!"0TiT2TЗ"l 6 I®
l / 11 1 1 \ \ l \ l l l l / 1 \ l l l \ / l / / I • / '

Шаг

18:

скольку
Шаг

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

19: правый

указатель тоже направлен на

с опорным

6, поэтому мы

(5).

По­

должны были бы

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

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

Шаг

20: меняем местами опорный элемент и значение под левым указателем:

1"3"1 ffiJ\

i
~""'!"0"'T
"'" """" "","" '\____®
'1'

Теперь наш опорный элемент

(5)

находится на месте:

~'!"0TiT2TЗTS'l 6
' ' " , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , 11

I

Алгоритм быстрой сортировки

243

(Quicksort)

Далее нам нужно рекурсивно разбить подмассивы слева и справа от опорно­
го элемента

(5).

Слева от него подмассива нет, поэтому остается разбить

только подмассив справа, но он содержит только один элемент

[ 6]. Мы до­
6 уже на

стиг ли базового случая и ничего не делаем, считая что значение
своем месте:

Н 0Т i '12' '!"ЗТSТб 'lj
l 111 ' 1 \ \ 1 11 • ' \ / 1 1' 1 1 \ 1 1 1' 1 ' 1 " 11 1 ' 1 /1 ' ' 1 ' 1\ \ \

Сортировка завершена!

Программная реализация
Ниже приведен код метода

quicksort ! , который мы можем объединить с пре­

дыдущим классом SortaЫeArray для завершения алгоритма быстрой сорти­
ровки:

def

quicksort!(left_iпdex,

right_iпdex)

# Базовый случай: пустой или одноэлементный подмассив:

if

right_iпdex

-





<

Ь)

? -1 : 1);

Перебираем все значения в массиве до последнего:

for(let i

11
11

=

0; i <

array.leпgth

- 1; i++) {

Если значение идентично следующему за ним,
значит,

мы нашли дубликат:

if(array[i] == array[i + 1]) {
returп true;
}
}

11
11

Если мы дошли до конца массива,

не возвратив

true,

значит, дубликатов в нем нет:

returп

false;

}
Итак, в этом алгоритме сортировка используется в качестве одного из компо­

нентов. Какова же его временная сложность?

257

Упражнения

Начнем с сортировки массива. Мы можем предположить, что сложность функ­

J

ции аvаSсriрt

sort() - O(Nlog N). Далее мы выполняем до Nшагов при пере­

боре элементов массива. Выходит, наш алгоритм выполняет

(Nlog N) + N шагов.

Как вы помните, О-нотация учитывает только слагаемое самого высокого по­

рядка, отбрасывая слагаемые более низкого. Здесь слагаемое N отбрасывается,
поэтому мы сокращаем выражение до

O(N log N).

Итак, мы использовали сортировку для реализации алгоритма с временной

сложностью

O(Nlog N).

Это серьезная оптимизация по сравнению с исходным

алгоритмом О(№).
Сортировка используется во множестве разных алгоритмов. Теперь, делая так

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

O(Nlog N).

Конечно, он может работать медленнее по каким-то другим при­

чинам, но мы точно знаем, что временная сложность

O(N log N)

всегда будет

точкой отсчета.

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

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

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

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

1.

13\>.

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

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

258

Глава

13. Рекурсивные

чтобы он выполнялся за

алгоритмы для ускорения выполнения кода

O(Nlog N) (есть и более быстрые реализации, но

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

2.

Следующая функция находит в массиве целых чисел «недостающее». То

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

(5, 2, 4, 1, 0] нет числа 3,

(9, 3, 2, 5, 6, 7, 1, 0, 4] - 8.

Вот реализация с временной сложностью О(№) (работа одного только
метода

include

занимает

O(N)

шагов, поскольку компьютеру нужно про­

извести поиск по всему массиву, чтобы найти

n):

function findMissingNumber(array) {
for(let i = 0; i < array.length; i++) {
if(!array.includes(i)) {
return i;
}
}

11 Если в массиве
return null;

есть все числа:

}
С помощью алгоритма сортировки напишите новую реализацию этой функ­

ции, временная сложность которой

- O(N log N)

(есть и более быстрые

реализации, но здесь нас интересует сортировка как метод ускорения вы­

полнения кода).

3.

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

O(Nlog N),

а третьей

- O(N).

-

ГЛАВА

14

Структуры данных
на основе узлов

• • • g; 11

В следующих нескольких главах мы рассмотрим разные структуры, состоящие

из узлов

( node) -

фрагментов данных, которые могут быть рассредоточены по

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

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

Здесь мы рассмотрим связный список

-

простейшую структуру данных на ос­

нове узлов, знакомство с которой поможет вам усвоить материал следующих

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

Связные списки
Связный список

-

это структура данных, которая, как и массив, представлена

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

Как уже говорилось в главе

1, память компьютера похожа на гигантский набор

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

Как вы помните, компьютер может получить доступ к любому адресу памяти за
один шаг, а значит, мгновенно считать любое значение в массиве по его индексу.
Если бы с помощью кода вы дали компьютеру команду node.value:
#
#

Если

правого дочернего

вставляем значение

в

элемента

качестве

нет,

правого дочернего элемента:

if node.rightChild is None:
node.rightChild = TreeNode(value)
else:
insert(value,node.rightChild)
Функция вставки
вить, и узел

insert принимает значение (value), которое мы хотим
(node), который будет предком для вставляемого узла.

вста­

Сначала сравниваем вставляемое значение со значением текущего узла:

if value < node.value:
Если оно меньше значения узла, то мы понимаем, что оно должно быть встав­
лено в его левое поддерево.

Теперь проверяем, есть ли у текущего узла левый дочерний элемент. Если нет,
делаем вставляемое значение левым дочерним элементом:

if node.leftChild is None:
node.leftChild = TreeNode(value)
Это базовый случай, так как нам не нужно выполнять никаких рекурсивных
вызовов.

Но если у узла уже есть левый дочерний элемент, мы не можем поместить на

его место вставляемое значение. Поэтому мы рекурсивно вызываем функцию

insert

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

для вставки значения места:

else:
insert(value, node.leftChild)
Наконец, мы обнаруживаем дочерний узел без дочернего элемента и делаем
вставляемое значение его дочерним узлом.

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

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

294

Глава

15. Тотальное ускорение с помощью двоичных деревьев поиска

тированные данные, оно может стать несбалансированным и менее эффектив­
ным. Например, если бы мы вставили следующие данные в таком порядке:

3, 4, 5, то получили

1, 2,

бы вот такое дерево:

1

~

3
~

4
~

5
Оно линейное, поэтому поиск значения

5 в нем занимает время O(N).

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

3, 2, 4, 1, 5,

то дерево

будет равномерно сбалансированным:

3
~~

2

4

~

~

1

5

Только в сбалансированном дереве временная сложность поиска будет

O(log N).

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

Получается, что в худшем случае, когда дерево абсолютно не сбалансировано,
поиск занимает время
но,

- O(log N).

O(N), а в лучшем,

когда дерево идеально сбалансирова­

В типичном сценарии, когда данные вставляются в случайном

порядке, дерево оказывается вполне сбалансированным, поэтому временная
сложность поиска в нем

-

примерно

O(log N).

Удаление
Удаление

-

это самая непростая операция над двоичным деревом поиска, тре­

бующая осторожного маневрирования.

295

Удаление

Допустим, мы хотим удалить значение

4

из следующего двоичного дерева по-

иска:

50

/~
75

25

/\

/\

10

33

'~ J:4.

89

'~

J:-

52 61 82 95

4 11 30 40
Сначала мы находим значение

56

Пропустим процесс поиска

-

он уже был

описан выше.

Как только мы найдем нужное число

( 4 ), то сможем удалить его за один

шаг:

50

/~
75

25

/\

10

33

'~

J:-

/\

-1...11 30 40

56

89

'~

J:-

52 61 82 95

Это было несложно. Но давайте посмотрим, что произойдет при попытке удалить
ЧИСЛО

10:

50

/~
25

/\
~

33

~

J:-

11 30 40

75

/\

56

89

'~

J:-

52 61 82 95

296

Глава

В итоге число

11

15. Тотальное ускорение с помощью двоичных деревьев поиска

потеряет связь с деревом. А мы не можем этого допустить, по­

скольку не хотим навсегда потерять это значение.

Чтобы решить эту проблему, нужно вставить значение

11

на место значения

10:

50

/~
25

75

J\
-

/\

l 33

'""'' J\
30 40

56

89

'~

J\

52 61 82 95

На этом этапе наш алгоритм удаления придерживается следующих правил:



если у удаляемого узла нет дочерних элементов, просто удаляем его;



если есть один дочерний узел, удаляем его и помещаем дочерний элемент
на место удаленного узла.

Удаление узла с двумя дочерними элементами
Удаление узла с двумя дочерними элементами

пустим, мы хотим удалить

- самый
56 из следующего дерева:

сложный случай. До­

50

/~
75

25

!\

11

/\

~

33

J\
30 40

'~

89

J\

52 61 82 95

Что будем делать с его дочерними элементами

52

стить оба элемента на прежнее место значения

и 61? Мы не можем переме­
56. И здесь в игру вступает

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

ставим на его место узел-преемник

-

дочерний узел с наименьшим значением из

всех, превышающих значение удаленного узла.

297

Удаление

Это сложно понять, но суть вот в чем: если мы выстроим удаленный узел и всех

его потомков в порядке возрастания, то преемником будет тот, что следует сра­
зу за удаляемым узлом.

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

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

52-56-61

56 будет 61.

Обнаружив преемника, мы помещаем его на место удаленного узла. Итак, мы
меняем значение

56 на 61:

50

/~
25

/\

11

33

J'i.
30 40

52

82 95

Поиск узла-преемника
Чем ближе к корню дерева удаляемый узел, тем сложнее будет найти узел-пре­
емник.

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

левого потомка, который и становится узлом-преемником.

Рассмотрим этот процесс на более сложном примере с удалением корневого
узла:

~
/~
75

25

/\

11

33

J'i.
30 40

/\

61

'

52

89

J'i.
82 95

298

Глава

15. Тотальное ускорение

с помощью двоичных деревьев поиска

Нам нужно поместить узел-преемник на место удаленного значения

50, сделав

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

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

~

~

/

/\

11

1

@

25

33

J~
30 40

/\

@}
-~-3

89

J~

'8Ji

82 95

''"''

t

Узел-преемник

Итак, наш узел-преемник

-

значение

52.

Помещаем его на место удаленного узла:

25

/\

11

33
J~

30 40
Мы закончили!

75

/\

61

89

J~
82 95

299

Удаление

Узел-преемник с правым дочерним элементом
Но есть еще один неучтенный случай: когда у узла-преемника есть правый до­

черний элемент. Добавим в наше дерево правый дочерний элемент для узла

75

25

/\

!\

11

89

61

33

J\
30 40

52:

J\

'

82 95

52

\

55 +--

Новый правый
дочерний элемент

Сейчас мы не можем просто превратить узел-преемник

ведь при этом его дочерний узел

(52)

в корень дерева,

останется сам по себе. Для таких случаев

(55)

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

дочерний узел, то после перемещения преемника на место удаленного узла

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

(52) на место корня -

Сначала мы помещаем узел-преемник

в результате узел

остается без родителя:

25

!\

11

33

75

/\

61

89

J\

J\

30 40

82 95
~

+--

«Осиротевший» узел

55

300

Глава

Теперь делаем узел

15. Тотальное ускорение

с помощью двоичных деревьев поиска

55 левым дочерним элементом бывшего родителя узла-пре­
61, поэтому делаем узел 55 его левым дочерним

емника. У нас это был узел
элементом:

52

/~
25

/\

11

33
J~

75

/\

61

30 40

89

J~
82 95

Вот теперь мы действительно закончили.

Полный алгоритм удаления
После объединения всех шагов алгоритм удаления значения из двоичного де­
рева поиска выглядит так:



если у удаляемого узла нет дочерних элементов, просто удаляем его;



если есть один дочерний узел, удаляем его и помещаем дочерний элемент
на место удаленного узла;



при удалении узла с двумя дочерними элементами мы ставим на его место

узел-преемник

-

дочерний узел с наименьшим значением из всех, превы­

шающих значение удаленного узла;



чтобы найти узел-преемник, посещаем правый дочерний элемент удаля­
емого значения, а затем последовательно все левые дочерние элементы

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



если у узла-преемника есть правый дочерний узел, то после перемещения

преемника на место удаленного узла нужно переместить бывший правый

элемент узла-преемника и сделать его левым дочерним элементом бывше­
го родителя узла-преемника.

Программная реализация: удаление значения
из двоичного дерева поиска

Ниже приведена рекурсивная реализация удаления значения из двоичного

дерева поиска на языке

Python:

301

Удаление

def delete(valueToDelete,

поdе):

# Базовый случай: мы достигли последнего
# дерева без дочерних элементов:
if поdе is Nопе:

узла

Nопе

returп

# Если удаляемое значение меньше или больше значения текущего
# задаем левый или правый дочерний элемент соответственно
# в качестве возвращаемого значения рекурсивного вызова
# этого метода для левого или правого поддерева текущего узла
elif valueToDelete < пode.value:
пode.leftChild = delete(valueToDelete, пode.leftChild)
#
#

(и его поддерево,

Возвращаем текущий узел
использования

для

#дочернего

качестве

в

узла,

если оно есть)

нового значения левого

или

правого

родителя:

элемента его

поdе

returп

elif valueToDelete >
пode.rightChild

пode.value:

= delete(valueToDelete,

пode.rightChild)

returп поdе

# Если текущий узел elif valueToDelete ==

тот,

который мы хотим удалить:

пode.value:

# Если у текущего узла нет левого дочернего элемента, удаляем его,
# возвращая его правый дочерний элемент (и его поддерево, если оно
# есть) в качестве нового поддерева его родителя:
if пode.leftChild is Nопе:
returп пode.rightChild

#
#
#
elif

(Если у текущего узла нет левого ИЛИ правого дочернего
элемента,
с

возвращаемым значением будет Nопе,

первой строкой

is

пode.rightChild

в соответствии

кода этой функции)

Nопе:

returп пode.leftChild

# Если у текущего узла есть два дочерних элемента, удаляем
# с помощью функции lift (см. ниже), которая изменяет
# значение текущего узла на значение узла-преемника:
else:
пode.rightChild

= lift(пode.rightChild,

его

поdе)

returп поdе

def

lift(пode,

пodeToDelete):

# Если у текущего узла этой функции есть левый
# рекурсивно вызываем эту функцию для спуска
# по левому поддереву в поисках узла-преемника
if пode.leftChild:
пode.leftChild = lift(пode.leftChild,
returп поdе

дочерний

пodeToDelete)

элемент,

302

Глава

15. Тотальное ускорение

с помощью двоичных деревьев поиска

# Если у текущего узла нет левого дочернего элемента, значит, текущий
# узел этой функции - узел-преемник, и мы помещаем его значение
# на место удаленного узла:

else:

= пode.value

пodeToDelete.value

#
#

Возвращаем значение

используется

правого потомка узла-преемника,

который

теперь

как левый дочерний элемент его родителя:

returп пode.rightChild

Это сложный код, поэтому разберем его подробно.
Функция удаления принимает два аргумента:

def delete(valueToDelete,

valueToDelete -

поdе):

это значение, которое мы удаляем из дерева, а

node -

его корень.

При первом вызове этой функции node будет фактическим корневым узлом, но,
поскольку функция вызывает себя рекурсивно, он может быть ниже в дереве,

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

if node is None:
return None
Так бывает, когда в рамках рекурсивного вызова функции совершается попьп­
ка получить доступ к несуществующему дочернему узлу. Тогда мы возвращаем

None.
Теперь сравниваем удаляемое значение
узла

valueToDelete

со значением текущего

node:

elif valueToDelete < пode.value:
node.leftChild
delete(valueToDelete, пode.leftChild)
return node
elif valueToDelete > node.value:
node.rightChild = delete(valueToDelete, node.rightChild)
return поdе
Этот фрагмент кода может показаться непонятным, но работает он так. Если
удаляемое значение меньше значения текущего узла, то мы знаем, что данное

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

А теперь самая хитрая часть: мы перезаписываем значение левого дочернего
элемента текущего узла

функции

delete

node,

превращая его в результат рекурсивного вызова

для левого дочернего элемента текущего узла. В итоге

delete

возвращает узел, поэтому мы берем результат и делаем его левым дочерним
элементом текущего узла.

303

Удаление

Но обычно эта «перезапись» не приводит к фактическому изменению левого
дочернего элемента, потому что вызов

delete

для него может вернуть его же.

Чтобы понять, о чем речь, представьте, что мы пытаемся удалить значение

4 из

следующего дерева:

50

/~
75

25

!\

10

33



J\

/\

4 11 30 40

56

89

'~

J\

52 61 82 95

node - корневой узел со значением 50. Поскольку удаляемое зна­
4 (valueToDelete) меньше 50, левый дочерний элемент узла 50 должен
быть результатом вызова функции delete для текущего левого дочернего эле­
мента узла 50, то есть для узла 25.
Изначально

чение

Итак, каким же будет левый дочерний элемент узла

50? Давайте посмотрим.

25 мы снова определяем,
что удаляемое значение 4 меньше 25 (текущий узел node), поэтому рекурсив­
но вызываем delete для левого дочернего элемента узла 25 - для узла 10.
Но каким бы ни было значение левого дочернего элемента узла 25, return node
При рекурсивном вызове функции

delete

для узла

свидетельствует о том, что в конце текущего вызова функции мы возвращаем

узел

25.

Я уже говорил, что левый дочерний элемент узла
вызова функции

delete

для узла

25.

50 должен

быть результатом

Но в итоге мы получили сам узел

25,

так

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

node

изменится, если

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

Рассмотрим следующий фрагмент:

elif valueToDelete == node.value:
Это значит, что

node - тот узел, который мы хотим удалить. Чтобы правильно

это сделать, сначала определим, есть ли у него дочерние элементы, так как это
повлияет на алгоритм удаления.

304

Глава

15. Тотальное ускорение с помощью двоичных деревьев поиска

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

if node.leftChild is None:
return node.rightChild
Если их нет, можем вернуть его правый дочерний элемент в качестве результа­

та вызова этой функции. Запомните, какой бы узел мы ни возвратили, он станет

либо левым, либо правым дочерним элементом предыдущего узла в стеке вызовов.
Итак, представим, что у текущего узла есть правый дочерний элемент. Тогда,

возвращая правый дочерний узел, мы делаем его дочерним узлом предыдущего
узла в стеке вызовов, удаляя текущий узел из дерева.
Если у текущего узла нет правого дочернего элемента, ничего страшного. В этом
случае в качестве результата вызова функции мы передадим None, что тоже при­
ведет к удалению из дерева текущего узла.

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

elif node.rightChild is None:
return node.leftChild
Здесь мы удаляем текущий узел, возвращая его левый дочерний элемент, кото­
рый становится дочерним узлом предыдущего узла в стеке вызовов.

Наконец, мы сталкиваемся с самым сложным случаем: два дочерних элемента
у удаляемого узла:

else:
node.rightChild
return node
Вызываем функцию

lift(node.rightChild, node)

l i ft и делаем возвращенный ею узел правым дочерним

элементом текущего узла.

Что делает

l i ft?

При вызове мы передаем ей правый дочерний элемент текущего узла в допол­

нение к самому узлу. Функция l i ft выполняет четыре задачи.

1.
2.

Находит преемника.

Перезаписывает значение удаляемого узла nodeToDelete, заменяя его зна­
чением узла-преемника (так узел-преемник оказывается в нужном месте).

Обратите внимание, что мы не перемещаем фактический объект узла-пре­
емника, а просто копируем его значение в узел, который «удаляем~.

3.

Чтобы ликвидировать исходный объект узла-преемника, функция превра­
щает его правый дочерний элемент в левый дочерний элемент его родителя.

305

Двоичные деревья поиска в действии

После завершения всех рекурсивных вызовов функция возвращает либо

4.

исходный правый дочерний элемент

rightChild,

переданный ей в самом

начале, либо None, если исходный элемент rightChild стал узлом-преем­
ником (что могло случиться при отсутствии у него левых дочерних эле­
ментов).

Теперь делаем значение, возвращенное функцией

lift,

правым дочерним эле­

ментом текущего узла. В результате правый дочерний элемент либо остается
неизменным, либо меняется на None, если он был использован в качестве узла­
преемника.

Не переживайте, если чего-то не поняли: функция

delete -

один из сложнейших

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

Эффективность удаления значения из двоичного
дерева поиска

Подобно поиску и вставке, удаление значения из дерева тоже происходит за

O(log N)

времени. Это связано с тем, что данная операция предполагает вы­

полнение поиска и нескольких дополнительных шагов для обработки «осиро­
тевших~ узлов. Сравните этот процесс с удалением значения из упорядоченно­

го массива, которое занимает время

O(N)

из-за необходимости сдвигать

элементы влево, чтобы закрыть пробел из-за удаления значения.

Двоичные деревья поиска в действии
Как мы знаем, двоичные деревья поиска позволяют выполнять поиск, вставку

и удаление значений за время

O(log N), что делает их эффективной структурой

данных для хранения упорядоченных фрагментов информации и управления

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

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

тели бы, чтобы оно предусматривало следующие функции:



вывод на экран списка названий книг в алфавитном порядке;



возможность внесения изменений в список;



возможность поиска названия книги в списке.

306

Глава

15. Тотальное ускорение

с помощью двоичных деревьев поиска

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

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

Дерево могло бы выглядеть так:
МоЬу

Dick

/~
Robinson
Crusoe

Great
Expectations
Alice in
Wonderland

Lord of
the Flies

Pride and
Prejudice

The Odyssey

Здесь названия книг расположены в алфавитном порядке: то, которое начина­

ется с буквы, встречающейся раньше в алфавите, считается «меньшим» значе­

нием, а с буквы, встречающейся позже,

-

«большим».

Обход двоичного дерева поиска
Итак, вы уже знаете, как искать, вставлять и удалять данные из двоичного де­

рева поиска. Но мы хотим иметь возможность выводить на экран весь список

названий книг в алфавитном порядке. Как же это сделать?
Во-первых, мы должны иметь возможность посещать каждый узел дерева. Под

посещением подразумевается получение доступа к узлам. Процесс посещения

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

обход.
Рекурсия

-

отличный инструмент для выполнения обхода структуры данных.

Мы создадим рекурсивную функцию

traverse, которую можно будет вызывать

для конкретного узла, после чего она будет делать следующее.

1.

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

307

Обход двоичного дерева поиска

2.

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

3.

Рекурсивно вызывать саму себя для правого дочернего элемента узла вплоть

до обнаружения узла без правого дочернего элемента.
Базовый случай для этого алгоритма

-

вызов функции

traverse

для несуще­

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

Ниже представлена функция

traverse_and_print на Python, которая обрабаты­

вает список названий книг. Обратите внимание на лаконичность ее кода:

def traverse_and_print(node):
i f node is None:
return
traverse_and_print(node.leftChild)
print(node.value)
traverse_and_print(node.rightChild)
Рассмотрим процесс центрированного обхода поэтапно.

Сначала мы вызываем функцию
приводит к вызову функции
мента

traverse_and_print для узла МоЬу Dick, что
traverse_and_print для его левого дочернего эле­

Great Expectations:

traverse_and_print(node.leftChild)
Но прежде чем перейти к нему, мы добавляем в стек вызовов информацию о том,
что находимся внутри вызова функции для узла МоЬу Dick и посещаем его левый
дочерний элемент:

МоЬу

Dick: левый дочерний элемент

После этого вызывается

traverse_and_print для левого дочернего элемента узла
Great Expectations -Alice in Wonderland.

Прежде чем двигаться дальше, добавляем соответствующую информацию в стек
вызовов:

Great Expectations: левый дочерний элемент
МоЬу Dick: левый дочерний элемент

308

Глава

15. Тотальное ускорение с

помощью двоичных деревьев поиска

Далее функция traverse_and_print запускается для левого дочернего элемента

узла

Alice in Wonderland,

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

происходит. Следующая строка кода функции

traverse_and_print:

print(node.value)
выводит на экран значение узла

"Alice in Wonder land".

Затем функция пытается посетить правый дочерний элемент узла

Alice in

Wonderland:
traverse_and_print(node.rightChild)

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

traverse_and_print( "Alice in Wonderland")

мы про­

веряем стек вызовов, чтобы выяснить, на каком этапе находимся:
Great Expectations: левый дочерний
МоЬу

Dick: левый дочерний

элемент

элемент

Все в порядке! Мы сейчас внутри вызова

tations")

и только что завершили вызов

traverse_and_print("Great Expecфункции traverse_and_print для его

левого дочернего элемента. Вытолкнем соответствующий фрагмент данных из
стека вызовов:

1

МоЬу Dick: левый дочерний элемент

1

И продолжим. На этом этапе функция выводит на экран значение узла

"Great
Expectations" и вызывает саму себя для посещения его правого дочернего эле­
мента - узла Lord о/ the Flies. Прежде чем перейти к нему, мы фиксируем важные
сведения в стеке вызовов :

l
Great Expectations:
МоЬу

правый дочерний элемент

Dick: левый дочерний элемент

309

Обход двоичного дерева поиска

Теперь переходим к выполнению
Сначала мы вызываем функцию

traverse_and_print("Lord of the Flies").
traverse_and_print для левого дочернего эле­

мента, которого нет, а затем выводим значение узла
мы вызываем функцию

Lord о/ the Flies.

Наконец,

traverse_and_print для его правого дочернего элемен­

та, но его тоже не существует, поэтому функция завершает работу.
Проверяя стек вызовов, видим, что находимся внутри вызова функции traverse_
and_print для правого дочернего элемента узла Great Expectations. Выталкиваем
соответствующие данные из стека:

Итак, мы полностью разобрались с вызовом traverse_and_print ( "Great
Expectations"), поэтому можем вернуться к стеку вызовов, чтобы определить­
ся с дальнейшими действиями:
МоЬу

Dick: левый дочерний

элемент

Мы видим, что находимся внутри вызова функции

левого дочернего элемента узла МоЬу

traverse_and_print

для

Dick. Мы можем вытолкнуть его из стека

вызовов (оставив стек пустым на некоторое время) и перейти к следующему

шагу вызова

traverse_and_print( "МоЬу Dick") -

выводу значения узла МоЬу

Dick.
Затем вызываем функцию traverse_and_print для правого дочернего элемента

узла МоЬу

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

!
МоЬу

Dick:

правый дочерний элемент

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

traverse_and_print отсюда.

310

Глава

15. Тотальное

ускорение с помощью двоичных деревьев поиска

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

@
ф Great
Expectations

МоЬу Dick

/~
@ Robinson
Crusoe

®::rd of ~Pride~ (Z)~e Odyssey
фAlic:::
Prejudice
the Flies
Wonderland
Именно так мы можем достичь цели и отобразить на экране названия книг, рас­

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

O(N)
N узлов де­

рева.

Выводы
Двоичное дерево поиска

-

это мощная структура данных на основе узлов, ко­

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

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

позволяет ускорить работу кода в одной весьма распространенной ситуации.

Упражнения
Выполните следующие упражнения, чтобы закрепить знания, полученные из
этой главы. Решения вы найдете в приложении в разделе «Глава 15~.

1.

Представьте, что вам нужно вставить в пустое двоичное дерево поиска по­
следовательность чисел в таком порядке:

[ 1, 5, 9, 2, 4, 10, 6,

з,

8].

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

2.

Если хорошо сбалансированное двоичное дерево поиска содержит 1ООО зна­
чений, сколько максимум шагов нужно для поиска значения внутри него?

311

Упражнения

3.

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

4.

В этой главе я показал способ центрированного обхода дерева для вывода

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

def traverse_and_print(node):
if node is None:
return
print(node.value)
traverse_and_print(node.leftChild)
traverse_and_print(node.rightChild)
На примере дерева из этой главы (см. следующую диаграмму) определи­
те порядок вывода на экран названий книг при использовании прямого

обхода.
МоЬу

Dick

/~
Robinson
Crusoe

Great
Expectations
Alice in
Wonderland

5.

Lord of
the Flies

Pride and
Prejudice

Есть и еще один способ обхода дерева

-

The Odyssey

обратный обход. Вот соответству­

ющий код для нашего приложения:

def traverse_and_print(node):
if node is None:
return
traverse_and_print(node.leftChild)
traverse_and_print(node.rightChild)
print(node.value)
На примере дерева из этой главы (которое используется и в предыдущем
упражнении) определите порядок вывода на экран названий книг при ис­

пользовании обратного обхода.

ГЛАВА

16

Расстановка приоритетов
С.."ПОШI 1tЬJ.О.Щ~"--. "., ", , ". """"".м.-."""'·'""'" ....,~, ...,,."'.,.",.,,,."",

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

-

знать, какую использовать в конкретной

ситуации.

В этой главе мы рассмотрим еще одну древовидную структуру данных

-

кучу.

В некоторых ситуациях она бывает особенно полезна, например когда нужно

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

-

приоритетная очередь.

Приоритетные очереди
Как было сказано в главе
ся по принципу

FIFO.

9, очередь -

это список, где элементы обрабатывают­

Это значит, что данные могут быть вставлены только

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

-

только из ее начала. При использовании

очереди мы получаем доступ к данным по порядку, в котором они были в нее
вставлены.

Приоритетная очередь

-

это список, данные из которого удаляются и считы­

ваются так же, как из классической очереди, а вставляются так же, как в упоря­

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

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

313

Приоритетные очереди

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

-

приложение

для сортировки пациентов в отделении неотложной помощи. В больнице людей

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

человек с гриппом прибыл на несколько часов раньше.
Допустим, наша система сортировки ранжирует степень тяжести состояния
пациентов по шкале от

1 до 10,

где

10 -

это критическое состояние. В этом

случае приоритетная очередь будет выглядеть так:
Пациент С: степень тяжести

- 10

Пациент А: степень тяжести

- 6

Пациент В: степень тяжести

- 4

степень тяжести

- 2

Пациент

D:

i

Начало очереди
с приоритетом

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

-

Пациент С.

Если теперь в больницу поступит новый пациент, Пациент Е, степень тяжести
состояния которого оценивается на

3, мы поместим его в соответствующее место

в очереди:

Пациент С: степень тяжести

- 10

Пациент А: степень тяжести

- 6

Пациент В: степень тяжести

- 4

Пациент Е: степень тяжести

- 3

степень тяжести

- 2

Пациент

Приоритетная очередь

-

D:

+--

пример абстрактного типа данных. Ее можно реали­

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

способов реализации такой очереди

-

использование упорядоченного массива.

Для этого мы применяем к массиву следующие ограничения:



при вставке данных должен поддерживаться определенный порядок;



данные могут быть удалены только с конца массива (который будет со­
ответствовать началу приоритетной очереди).

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

314

Глава

16. Расстановка

приоритетов с помощью куч

Как вы помните из главы

за

1, удаление значения из начала массива выполняется
O(N) времени, поскольку нам приходится сдвигать все данные, чтобы запол­

нить пробел в позиции с индексом О. Но в нашей реализации конец массива
считается началом приоритетной очереди, что позволяет удалять значения

с конца массива за время О( 1).
Итак, мы можем удалять данные за один шаг. Это довольно неплохо, но что
делать со вставкой значений?
Как вы помните, вставка значений в упорядоченный массив занимает

O(N)

времени, так как нам нужно проверить до N элементов массива, чтобы определить
подходящее место для новых данных (и даже если отыскать правильное место

получится быстро, придется сдвинуть все оставшиеся данные вправо).
Получается, наша приоритетная очередь на основе массива позволяет удалять

значения за время

0(1),

а вставлять

-

за

O(N).

Если в нашей очереди будет

много элементов, то вставка значений, выполняемая за время

O(N),

может за­

медлить работу приложения.

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

-

кучу.

Кучи
Есть несколько типов куч, но мы сосредоточимся на рассмотрении двоичной.

Двоичная куча
дерево

-

-

это особый вид двоичного дерева. Как вы помните, двоичное

это дерево, где у каждого узла не более двух дочерних элементов (одно

из таких деревьев

-

двоичное дерево поиска из прошлой главы).

Двоичные кучи бывают двух видов: mах-куча и min-кyчa. Мы будем рассматри­

вать вторую, но позже вы увидите, что особой разницы между ними нет.
Далее я буду называть двоичную mах-кучу просто кучей.
Куча



( heap) -

это двоичное дерево, которое предполагает следующие условия:

значение каждого узла должно превышать значение каждого из его по­

томков. Это правило определяет основное свойство кучи;



дерево должно быть полным (что это значит, я объясню чуть позже).

Разберем оба пункта, начиная со свойства кучи.

315

Кучи

Свойство кучи
Свойство кучи

-

это условие, которое гласит, что значение каждого узла долж­

но превышать значение каждого из его потомков.

Например, дерево на следующей схеме удовлетворяет этому условию, так как

значение каждого его узла превышает значение любого из его потомков.

в\.

,/

0
@
,/ \
,/ \
@@00
100 превышает значения всех его потомков.
значения обоих его дочерних элемен­
превышает
88
Точно так же значение узла
тов. То же верно и для узла 25.
Здесь значение корневого узла

Следующее дерево

-

неправильная куча, так как оно не удовлетворяет ее ос­

новному свойству:

в\.

,/

@ ,/0 \

,/ \

@[8]00
Значение узла

92

превышает значение его родительского элемента

88,

что на­

рушает главное условие.

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

шает значение данного узла. Но в куче у узла не может быть потомка, значение

которого превышает его собственное. Так что не стоит валить эти структуры
данных «В одну кучу~.

316

Глава

16. Расстановка

приоритетов с помощью куч

Мы можем создать кучу с противоположным свойством, сделав так, чтобы
каждый узел содержал меньшее значение, чем любой из его потомков. Это
и есть min-кyчa, о которой я уже говорил. Но мы сосредоточимся на mах-куче,
где значение каждого узла превышает значение всех его потомков. По сути,
разница между этими кучами незначительная, так как в остальном они иден­
тичны.

Полные деревья
Теперь перейдем ко второму условию: дерево должно быть полным.
Полное дерево

-

это дерево, в котором все уровни заполнены узлами. При этом

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

о

cf O

~ ~

~ ~

0000
Следующее дерево не полное, так как на его третьем уровне нет узла:

о

о
~ ~
00

cf

~

о

Это дерево считается полным, так как пустые позиции есть только на нижнем
уровне, а справа от них нет ни одного узла:

о

cf

~

о

о

317

Особенности кучи

Получается, куча

это дерево, которое удовлетворяет свойству кучи и являет­

-

ся полным. Вот еще один пример:

е

~

/

0
1 \

0

@ G

1\

/ \

0
/ \

/

0

ее0е0
Это правильная куча, так как значение каждого узла превышает значения лю­

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

Особенности кучи
Теперь, когда мы поняли, что такое куча, рассмотрим некоторые ее характери­
стики.

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

100,

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

3 в куче выше.

Если мы начнем с корнево­

то в каком поддереве нам искать? В случае с двоичным деревом

100.

3

может находиться только среди левых по­

Но здесь мы знаем лишь то, что искомое значение

быть потомком узла

100 и не может быть его предком.

3 должно

Но мы понятия не имеем,

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

3

100, но в принципе оно могло оказаться

и среди левых.

Из-за этого кучи считаются слабо упорядоченными в сравнении с двоичными
деревьями поиска. Конечно, некоторый порядок в кучах есть: значения потом­
ков не могут превышать значения предков, но этого недостаточно для того,

чтобы сделать поиск в них целесообразным.
У кучи есть еще одна особенность, о которой вы, наверное, уже догадались: ее
корневой узел всегда содержит наибольшее значение (в min-кyчe

-

наименьшее).

318

Глава

16. Расстановка

приоритетов с помощью куч

Именно это делает кучу отличным инструментом для реализации приоритетных
очередей. Мы используем такие очереди для получения доступа к значению

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

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

здесь выполняют нечасто (она может предусматривать необязательную опера­
цию «чтения~

-

получение доступа к значению корневого узла).

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

В куче есть так называемый послед11ий узел

-

крайний правый элемент нижне­

го уровня.

Взгляните на это изображение.

/
@
/ \
@ @

/ \

/ \

@е0
Здесь элемент со значением

3-

8~

0

@
/ \

@

/
@ [0] ._ Последний узел
это последний узел, крайний правый элемент

в нижнем ряду.

Теперь давайте разберемся с основными операциями над кучей.

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

1.

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

319

Вставка в кучу

Сравниваем значение нового узла со значением его родительского эле­

2.

мента.

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

3.

местами.

Повторяем шаг

4.

3,

перемещая новый узел вверх по куче, пока у него не по­

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

Шаг

40:

1: добавляем 40

в качестве последнего узла кучи:

/
@
1 \

е

1\

е

~

@
/ \
0 0

е

'

/

/ \

@8000

''

:,.

..... -,

( 40)
,.....__/

Обратите внимание: такое расположение нового узла было бы неверным:

/

@

1 \

е

/\

е

/\

е

~

0

/

@8000
Добавляя узел

@

/ \

0

/

40 как дочерний элемент узла 12, мы делаем дерево неполным,

так как новый узел находится справа от пустой позиции. Чтобы куча оставалась
кучей, она должна быть полной.

320
Шаг

Глава

16. Расстановка

приоритетов с помощью куч

2: сравниваем значение 40 со значением родительского узла, которым ока­
8. 40 больше 8, поэтому меняем эти узлы местами:

зывается

Шаг 3: сравниваем значение 40 со значением его нового родительского узла 25.
40 больше 25, и мы снова меняем узлы местами:

Шаг 4: сравниваем значение

100 -

40 со значением родительского узла 100. 40 меньше

процесс вставки завершен!

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

не займет правильное положение.

Временная сложность вставки значения в кучу

- O(log N).

Как было сказано

в прошлой главе, любое двоичное дерево из N узлов содержит примерно

log(N)

уровней. Поскольку в худшем случае новому значению придется просачивать­

ся до самого верха, на этот процесс уйдет не более

log(N)

шагов.

321

Поиск последнего узла

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

@

/

1 \

@

1\

G

/ \

8~
0

40:

@

/ \

/

@

@@000
Становится очевидно, что для добавления значения

40

в качестве последнего

узла мы должны сделать его правым дочерним элементом узла

8,

ведь именно

это следующее доступное место в нижнем ряду.

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

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

100,

говорим ли мы

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

потомков корня?
В нашем примере это место действительно в правом поддереве корневого узла

100, но

взгляните на следующую кучу:

322

Глава

16. Расстановка

приоритетов с помощью куч

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

88, который

находится в левом поддереве корня

100.

Получается, как и в случае с поиском значения в куче, для нахождения ее

последнего узла (или подходящего для него места) нужно проверить все
элементы.

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

Удаление из кучи
Главное, что нужно знать об удалении значения из кучи

-

то, что мы всегда

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

Алгоритм удаления корневого узла из кучи следующий.

1.

Перемещение последнего узла на место корневого, которое приводит к фак­
тическому удалению исходного корня.

2.

Постановка (просачивание) корневого узла вверх на подходящее для него

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

/

§
1 \

@

@

/ \

/ \

е

~

0

/

@@0@0

@

/ \

@

323

Удаление из кучи

Корневой узел здесь

- 100.

Чтобы удалить его, мы перезаписываем значение

корня, заменяя его значением последнего узла (у нас это значение
щаем узел

3 на место

исходного корня

3).

Переме­

100:
/

'

' 3 )

/'•"~

@ \ @
1 \
/ \
@ @ \0 @
/ \ / \
@@0@
'

''
''

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

3

вниз на соответствующее

ему место.

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

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

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

1.

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

2.

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

3.

Повторяем шаги

1 и 2,

пока у просачивающегося узла не останется ни од­

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

324
Шаг

Глава

16. Расстановка приоритетов с помощью куч

88 -

1: сейчас у просачивающегося узла 3 два дочерних узла - 88 и 25.
большее из двух, а так как 3 меньше 88, мы меняем их местами:

Шаг

2:

теперь дочерние элементы просачивающегося узла

наибольшего из них

(87)

- 87 и 16.

Значение

Значение

превышает значение просачивающегося узла

(3),

по­

этому меняем их местами:

@
----•Q/



\~

/

(~)

/ \

\
@

/ \

~~

0

/

~

\
@

@@0@
Шаг

3: теперь дочерние элементы просачивающегося узла - 86 и 50. Значение
(86) превышает значение просачивающегося узла (3), по­

наибольшего из них

этому меняем их местами:

325

Удаление из кучи

Теперь у просачивающегося узла нет дочерних элементов, значения которых

больше его собственного (у него их вообще нет). Получается, свойство кучи
восстановлено, а значит, процесс просачивания завершен.

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

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

Пусть значением корня снова будет

3:

" -3 ' J

1

/'-..-~

@
\
@
1 \
' / \
@ @ \0 @

/ \ / \
@@0@

'

(~)

326

Глава

Поменяем местами корень

16. Расстановка

3 и узел 25 -

Значение 25 меньше 88!

@/
/ \

наименьший из его дочерних элементов:

---+

@·-------,\,

~/!,

, 3 J

;'-"\
0 @

@ 0
/ \ / \
@@0@
Теперь узел

25 -

родитель узла

88.

приоритетов с помощью куч

Так как

88

превышает значение своего ро­

дительского узла, свойство кучи нарушено.
Как и в случае вставки, временная сложность удаления значения из кучи равна

O(log N),

так как при выполнении этой операции корневой узел должен про­

сачиваться вниз через все

log(N)

уровней кучи.

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

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

Вставка
Удаление

Упорядоченный массив

Куча

O(N)
0(1)

O(logN)
O(logN)

Кажется, что у обеих структур данных примерно одинаковая эффективность.

Упорядоченный массив работает медленнее кучи при вставке значения, но
быстрее - при удалении.
И все же лучше выбрать кучу, и вот почему.

327

Проблема последнего узла ... снова

О( 1)

-

это чрезвычайно быстро,

O(log N) -

оченъ быстро, а

O(N) -

относитель­

но медленно. Зная это, таблицу выше можно переписать так:
Куча

Упорядоченный массив
Вставка

Медленно

Очень быстро

Удаление

Чрезвычайно быстро

Очень быстро

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

-

медленно, конечно, мы отдадим предпочтение первой.

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

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

тетной очередью

-

вставка и удаление

-

будут выполняться очень быстро.

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

поднимает проблему последнего узла.
Как я уже говорил, на первом этапе удаления мы должны переместить послед­
ний узел на место корня. Но как отыскать этот узел?

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

мы не можем вставить новые значения куда-нибудь еще? И почему при удале­
нии нельзя заменить корень другим узлом вместо последнего?

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

Потому что мы хотим, чтобы наша куча оставалась сбалажированной.

328

Глава

16. Расстановка

приоритетов с помощью куч

Чтобы убедиться в этом, еще раз рассмотрим процесс вставки. Допустим, у нас
есть следующая куча:

@

1 \

@

0~

/

0

Если мы хотим вставить в нее значение

кучу хорошо сбалансированной
элементом узла

-

@

5,

то единственный способ сохранить

сделать 5 последним узлом, здесь

-

дочерним

1О:

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

5 стал

бы дочерним элементом узла

/
@
1 \
@ 0

0~

@

15:

329

Проблема последнего узла ... снова

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

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

(;;;\ /

~

0 ~ (1;;\
~

1 \

@ 0
Если бы в альтернативной вселенной мы всегда перемещали на место корня

нижний правый узел, то

10 стал бы корневым -

и мы получили бы несбаланси­

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

O(log N).

дерева, например этого, мог бы занять

Обход же сильно несбалансированного

O(N)

времени:

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

N узлов.)

330

Глава

16. Расстановка

приоритетов с помощью куч

Массивы в качестве куч
Для проведения операций над кучей нужно найти последний узел, поэтому

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

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

l100l вв l 2s l 87 l 1б 1 в l 12 l sб l s0 I 2 l 1s I з
о

2

3

4

5

6

7

8

9

10

11

Здесь мы присваиваем каждому узлу определенный индекс массива. На прошлой
диаграмме индекс каждого узла указан в квадрате под ним. Если вы посмотри­
те внимательно, то заметите, что мы присваиваем индексы узлам кучи по опре­

деленному шаблону.
Корневой узел всегда хранится в позиции с индексом О. Мы опускаемся на
уровень ниже и двигаемся слева направо, присваивая каждому узлу индекс

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

присваивается индекс

1, а правому (25) - 2.

(88)

По достижении конца уровня пере­

ходим на следующий и продолжаем.

Мы используем массив для реализации кучи, потому что он позволяет решить

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

331

Массивы в качестве куч

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

3 из

прошлого примера

-

это последнее значение массива.

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

Прежде чем продолжить разбираться в принципе работы кучи на основе масси­

ва, создадим ее базовую структуру. Вот начало реализации кучи на языке

Ruby:

class Неар
def initialize
@data = []
end
def root_node
return @data.first
end
def last_node
return @data.last
end
end
Мы инициализируем кучу пустым массивом. У нас есть метод
рый возвращает первый элемент массива, и

last_node,

root_node,

кото­

который возвращает его

последнее значение.

Обход кучи на основе массива
Итак, алгоритмы вставки в кучу и удаления из нее предполагают просачивание

узла, что означает обход кучи

-

получение доступа к родительскому или до­

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

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

Ответ на этот вопрос довольно интересный. Оказывается, присвоив индексы

узлам кучи по описанному ранее шаблону, мы можем:



найти левый дочерний элемент узла по формуле



найти правый дочерний элемент узла по формуле

(index

* 2) + 1;

(index

* 2) + 2.

16 из прошлой диаграммы, который находится в ячейке с ин­
дексом 4. Чтобы найти его левый дочерний элемент, мы умножаем его индекс ( 4)
на 2 и прибавляем 1, получая 9. Значит, узел с индексом 9 - это левый дочерний
элемент узла с индексом 4.
Взгляните на узел

332

Глава

16. Расстановка приоритетов с помощью куч

Аналогично, чтобы найти правый дочерний элемент узла с индексом

мы

умножаем

это

4 на 2 и прибавляем 2, получая 10. Значит, узел
правый дочерний элемент узла с индексом 4.

с индексом

4,
10 -

Эти формулы работают всегда, поэтому мы можем обращаться с массивом как
с деревом.

Добавим эти два метода в класс Неар:

def left_child_index(index)
return (index * 2) + 1
end
def right_child_index(index)
return (index * 2) + 2
end
Каждый из них принимает индекс в массиве, возвращая индекс левого или
правого дочернего элемента соответственно.

Вот еще одно важное свойство кучи на основе массива: возможность нахождения
родителя узла по формуле

(index - 1) / 2.

Обратите внимание, что в этой формуле используется целочисленное деление,
при котором мы отбрасываем все числа после запятой. Например, результатом
операции

3/2

считается

1,

а не

1, 5.

Вернемся к нашему примеру и сосредоточимся на индексе

4. Если мы вычтем
1, а затем разделим результат на 2, то получим 1. И, как вы можете видеть
на диаграмме, родитель узла с индексом 4 находится по индексу 1.
из него

Теперь мы можем добавить в наш класс Неар еще один метод:

def parent_index(index)
return (index - 1) / 2
end
Он принимает индекс узла и вычисляет индекс его родительского элемента.

Программная реализация: вставка значения в кучу
Теперь, когда у нас есть все основные элементы кучи, реализуем алгоритм
вставки:

def insert(value)
#Делаем значение

последним узлом,

вставляя его в

@data « value

#

Отслеживаем индекс

только что вставленного узла:

конец массива:

333

Массивы в качестве куч

пew_пode_iпdex

= @data.leпgth - 1

# Следующий цикл выполняет алгоритм просачивания узла вверх.

#
#

не находится на месте корня,

Если новый узел

а его значение превышает значение его

while

> 0

пew_пode_iпdex

>

@data[пew_пode_iпdex]

#

Меняем местами

родительского узла:

&&

@data[pareпt_iпdex(пew_пode_iпdex)]

новый узел и его родительский элемент:

@data[pareпt_iпdex(пew_пode_iпdex)], @data[пew_пode_iпdex]
@data[пew_пode_iпdex], @data[pareпt_iпdex(пew_пode_iпdex)]

#

Обновляем индекс

пew_пode_iпdex

нового узла:

= pareпt_iпdex(пew_пode_iпdex)

епd
епd

Как обычно, разберем код по частям.
Метод

insert

принимает значение, которое мы вставляем в кучу. Сначала де­

лаем это новое значение последним узлом, добавляя его в конец массива:

@data « value
Отслеживаем индекс нового узла
этот индекс

-

пew_пode_iпdex

он понадобится нам позднее. На этом этапе

= @data.leпgth - 1

С помощью цикла

while

-

последний в массиве:

while перемещаем новый узел вверх до нужного места:

пew_пode_iпdex

> 0

@data[пew_пode_iпdex]

>

&&

@data[pareпt_iпdex(пew_пode_iпdex)]

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

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

татам.

При каждой итерации этого цикла мы меняем местами новый узел и его роди­
тельский элемент, так как значение нового узла сейчас превышает значение его
родителя:

@data[pareпt_iпdex(пew_пode_iпdex)],
@data[пew_пode_iпdex],

@data[пew_пode_iпdex]

@data[pareпt_iпdex(пew_пode_iпdex)]

Затем обновляем индекс нового узла:
пew_пode_iпdex

= pareпt_iпdex(пew_пode_iпdex)

334

Глава

16. Расстановка приоритетов с помощью куч

Этот цикл выполняется, пока значение нового узла превышает значение его

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

Программная реализация: удаление значения из кучи
Это реализация для удаления элемента из кучи на языке
тод здесь

- delete,

has_greater_child

Ruby.

Основной ме­

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

и

calculate_larger_child_index:

def delete
# Из кучи всегда удаляется только корневой узел, поэтому
# извлекаем из массива последний узел и делаем его корневым:
@data[0] = @data.pop
#Отслеживаем текущий

индекс

"просачивающегося узла":

trickle_node_index = 0

#

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

# Продолжаем выполнять этот цикл, пока у просачивающегося узла есть
# дочерний, значение которого превышает его собственное:
while has_greater_child(trickle_node_index)
# Сохраняем индекс большего дочернего элемента в переменной:
larger_child_index = calculate_larger_child_index(trickle_node_index)
# Меняем местами просачивающийся узел и его больший дочерний
@data[trickle_node_index], @data[larger_child_iпdex]
@data[larger_child_index], @data[trickle_node_iпdex]

элемент:

# Обновляем индекс просачивающегося узла:
trickle_node_index = larger_child_index
end
end
def has_greater_child(index)
# Проверяем, есть ли у узла с заданным индексом
# левые и правые дочерние элементы и превышает ли значение
# из них значение узла с заданным индексом:
(@data[left_child_index(index)] &&
@data[left_child_index(index)] > @data[index]) 11
(@data[right_child_iпdex(index)]

любого

&&

@data[right_child_index(index)] > @data[index])
епd

def calculate_larger_child_index(index)
# При отсутствии правого дочернего элемента:
if !@data[right_child_index(index)]
# Возвращаем индекс левого дочернего элемента:
return left_child_index(index)
end

# Если значение правого дочернего узла превышает значение левого:
if @data[right_child_index(iпdex)] > @data[left_child_index(index)]

335

Массивы в качестве куч

#

Возвращаем индекс

правого дочернего элемента:

return right_child_index(index)
else # Если значение левого дочернего
#

правого,возвращаем

узла больше или равно значению

индекс левого дочернего элемента:

return left_child_index(index)
end
Подробно разберем метод delete.

delete

не принимает никаких аргументов, так как мы всегда удаляем только

корневой узел. Вот как работает этот метод.
Сначала мы удаляем последнее значение из массива и делаем его первым:

@data[0] = @data.pop
Эта простая строка кода удаляет исходный корневой узел: мы перезаписываем
значение корневого узла, заменяя его значением последнего.

Далее нужно переместить новый корневой узел в нужное место. Ранее мы на­
звали этот процесс «просачиванием», и наш код вполне отражает его суть.

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

надобится нам позднее. Сейчас его индекс

trickle_node_index

=

-

-

он по­

О:

0

Теперь используем цикл

while для просачивания узла вниз.

Этот цикл продол­

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

значения которых превышают его собственное:

while has_greater_child(trickle_node_index)
Здесь используется метод

has_greater_child, который проверяет наличие у это­

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

larger_child_index = calculate_larger_child_index(trickle_node_index)
Мы используем метод

calculate_larger_child_index, который возвращает

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

larger _child_index.

Теперь меняем местами просачивающийся узел и его наибольший дочерний
элемент:

@data[trickle_node_index], @data[larger_child_index]
@data[larger_child_index], @data[trickle_node_index]

336

Глава

16. Расстановка приоритетов с помощью куч

Наконец, обновляем индекс просачивающегося узла, меняя его на индекс до­
чернего элемента, с которым он только что поменялся местами:

trickle_node_index = larger_child_index

Другие варианты реализации кучи
Итак, реализация кучи завершена. Мы использовали для этого массив, а могли

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

Но реализация на основе массива более распространена, поэтому я представил
в книге именно ее. Еще очень интересно посмотреть, как использовать массив
для реализации дерева.

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

-

это первый случай двоич­

ного дерева, где реализация на основе массива дает преимущество, помогая

легко находить последний узел.

Кучи в качестве приоритетных очередей
Теперь, когда вы понимаете, как работают кучи, вернемся к приоритетным
очередям.

Как вы помните, основная функция такой очереди

-

предоставление немедлен­

ного доступа к элементу с наивысшим приоритетом. В примере с отделением

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

Именно поэтому куча лучше всего подходит для реализации таких очередей,
так как дает немедленный доступ к самому приоритетному элементу, который

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

и удаление, выполняются за время

O(log N).

Сравните это с упорядоченным массивом, вставка в который занимает

O(N)

времени, так как компьютер должен гарантировать, что каждое новое значение
в итоге окажется на своем месте.

Выходит, что слабая упорядоченность кучи

-

ее преимущество. То, что она не

обязана быть идеально упорядоченной, позволяет нам вставлять новые значения
за время

O(log N).

При этом куча достаточно упорядочена, для того чтобы мы

337

Упражнения

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

Выводы
Мы поговорили о том, как разные типы деревьев позволяют оптимизировать
процесс выполнения разных типов задач, и выяснили, что двоичные деревья

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

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

Упражнения
Выполните следующие упражнения, чтобы закрепить знания, полученные из
этой главы. Решения вы найдете в приложении в разделе «Глава 16~.

1.

Изобразите следующую кучу после вставки в нее значения

2.

Изобразите эту же кучу после удаления из нее корневого узла.

3.

11:

Представьте, что создали совершенно новую кучу, вставив в нее следующий

порядок чисел:

55, 22, 34, 10, 2, 99, 68.

Если затем вы извлечете эти числа из

кучи по одному и вставите в новый массив, в каком порядке они будут рас­
полагаться?

ГЛАВА

17

Префикснь1е деревьа

-

Вы когда-нибудь задумывались над тем, как работает функция автозаполнения
в вашем смартфоне? Автозаполнение срабатывает, когда вы начинаете набирать

какое-нибудь слово, например catn, а ваш телефон предлагает на выбор вариан­
ты

catnip или catnap

(да, я постоянно пишу друзьям о кошачьей мяте).

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

бы он был неотсортированным, нам пришлось бы проверить каждое слово в этом
словаре, чтобы найти те, которые начинаются с

catn.

Такая операция выполня­

ется за время О( N), что очень медленно, учитывая, что

N здесь -

это количество

слов в словаре.

Хеш-таблица здесь тоже не поможет

-

она хеширует слово целиком, чтобы опре­

делить, где именно в памяти должно храниться значение. Из-за отсутствия

в хеш-таблице ключа

catn

мы не сможем легко найти в ней слова

catnip

или

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

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

O(log N).

И хотя

O(log N) -

это уже неплохо, мы можем добиться еще больше­

го. На самом деле при использовании специальной древовидной структуры

данных скорость поиска нужных слов может достигать О( 1).

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

339

Префиксные деревья

задач, например для организации таких данных, как IР-адреса или телефонные
номера.

Префиксные деревья
Префиксное дерево

- это древовидная структура данных, которая идеально

подходит для реализации функций вроде автозаполнения.

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

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

Узел префиксного дерева
Как и большинство других деревьев, префиксное дерево состоит из узлов, ука­
зывающих на другие узлы. Но именно эта структура данных не двоична. У узла

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

-

сколько угодно.

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

··ь··:

··с··:

}

Здесь корневой узел содержит хеш-таблицу с ключами "а", "Ь" и "с", значения
которых

-

это дочерние элементы узла; в этих элементах тоже есть хеш-таблицы,

которые, в свою очередь, указывают на их дочерние элементы. Здесь мы оста­

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

класса TrieNode на языке

class TrieNode:
def ~init~(self):
self .children = {}

Python:

340

Глава

17. Префиксные деревья

Как видите, здесь только хеш-таблица.
Если мы выведем (в консоль) данные из корневого узла дерева выше, то полу­
чим что-то вроде этого:

{'а':
'Ь':

'с':

,

,

instance at
.TrieNode instance at
}

это односимвольные строки, а значения

-

экземпляры

других узлов префиксного дерева.

KлaccTrie
Для создания префиксного дерева нам понадобится отдельный класс
торый будет отслеживать корневой узел:

Trie, ко­

class Trie:
def ~init~(self):
self.root = TrieNode()
Он отслеживает переменную
корень

se 1f. root, указывающую на корень дерева. Здесь
(TrieNode) созданного префиксного дерева (Trie) изначально пуст.

По мере продвижения по этой главе мы будем постепенно добавлять в класс

Trie

методы, позволяющие совершать разные операции над префиксным де­

ревом.

Хранение слов
Итак, мы создаем префиксное дерево, чтобы хранить в нем слова. На следую­
щей диаграмме показано, как эта структура данных хранит слова "асе",
и

"bad"

"cat".

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

что он указывает на дочерний узел с ключом "с". Ключ "с", в свою очередь,

указывает на узел с ключом "е". Объединив эти три символа, мы получим сло­
во "асе".

Точно так же в дереве хранятся слова

"bad"

и

"cat".

341

Хранение слов

"с'':

"ь":

{"а":

}

~ ~ ~
~ ~ ~

1{''*" nil}I 1{"*" nн}I

1{''*" nil}I

=

=

=

Как видите, у последних символов в этих словах есть свои дочерние узлы. На­
пример, если вы взглянете на узел "е" слова" асе", то увидите, что он указыва­

ет на дочерний узел, содержащий хеш-таблицу с ключом

самом деле неважно, какое здесь значение

-

"*"

(звездочка) (на

оно может быть и нулевым).

Он указывает на то, что мы достигли конца слова "асе". О функции ключа"*"
мы поговорим позже.

А сейчас перейдем к самому интересному. Допустим, мы хотим сохранить в пре­

фиксном дереве слово

"act". Для этого мы добавляем к уже существующим

ключам "а" и "с" новый узел с ключом "t":
{''а••:

{"е'':

1{''*" nil}I
=

··с":

··ь 11 :

}

~ ~ ~
~ ~
"t'':}

1{''*" nн}I 1{''*" nн}I 1{" *" nн}I
=

=

=

Как видите, в хеш-таблице выделенного узла теперь два дочерних узла: "е" и "t".
Так мы указываем, что "асе" и

"act" -

допустимые слова.

342

Глава

17. Префиксные деревья

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

D

D

D

ci

ai

ai

D

D

D

1 \:

di

ti

ш

ш

ш

ш

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

Важность звездочки
Допустим, мы хотим сохранить в префиксном дереве слова

Это интересный случай, поскольку в

"bat" и "batter".
"batter" есть слово "bat". Эту проблему

можно решить так:

D
ь~

D

ai
D

ti
1{*, "t"H
ti
D

ei
D
г~
ш

343

Хранение слов

Первый ключ "t" указывает на узел с двумя ключами:

"*"

(с ну левым значени­

ем) и "t", значение которого направлено на другой узел. Это говорит о том, что

"bat" - это одновременно и отдельное слово, и префикс более длинного слова
"batter".
Обратите внимание, что в этой диаграмме вместо классического синтаксиса
хеш-таблицы мы используем сокращенный. Это делается для экономии места.
Чтобы указать, что узел содержит хеш-таблицу, мы использовали фигурные
скобки. Но{*, "t"} - это не пара ~ключ

значение>.>, а просто два ключа. Зна­

-

чение ключа"*" нулевое, а значение ключа "t" -

следующий узел.

Бот почему эти звездочки так важны: с их помощью можно указать, что те или

-

иные части слов

это тоже отдельные слова.

Рассмотрим все это на более сложном примере. Бот префиксное дерево со
словами "асе",
и

"act", "bad", "bake", "bat", "batter",

"саЬ",

"cat", "catnap"

"catnip":

D

D

D

ci

ai

ai

D

D

D

е/
ш

\t
ш

d;ki \t
ШDI ["Fred"],
"Fred" => ["Alice", "Diana", "Elise"]
}
Найти друзей Алисы в таком графе мы можем за О( 1) времени, так как чтение

значения любого ключа из хеш-таблицы выполняется за один шаг:

friends["Alice"]

368

Глава 18. Отражение связей между объектами с помощью графов

Этот фрагмент кода немедленно возвращает массив с именами всех друзей
Алисы.

Ориентированные графы
В некоторых соцсетях отношения участников могут быть невзаимны. Например,
социальная сеть может позволить Алисе подписаться на Боба, но Бобу не обя­
зательно при этом подписываться на Алису. Чтобы отразить этот тип связи,
построим новый граф:

Такой граф называется ориентированным. Здесь стрелки указывают направле­

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

на основе хеш-таблицы:

followees = {
"Alice" => ["ВоЬ", "Cynthia"],
"ВоЬ" => ["Cynthia"],
"Cynthia" => ["ВоЬ"]
}
Единственная разница лишь в том, что мы используем массивы для указания
имен людей, на которых подписан тот или иной человек.

Объектно-ориентированная

реализация графа
Выше я показал вариант реализации графа на основе хеш-таблицы, но в даль­

нейшем мы будем использовать для этого объектно-ориентированный подход.

369

Объектно-ориентированная реализация графа

Вот начало объектно-ориентированной реализации графа на языке

Ruby:

class Vertex
attr_accessor :value, :adjacent_vertices
def initialize(value)
@value = value
@adjacent_vertices
end

[]

def add_adjacent_vertex(vertex)
@adjacent_vertices 100, "Chicago" => 200, "Denver" => 160, "El
Paso" => 280}
Обратите внимание, что Атланта включена в эту таблицу со значением О

-

оно

понадобится алгоритму для работы. И это вполне логично, учитывая, что пере­
лет из Атланты в Атланту ничего не стоит, ведь вы уже и так на месте!

В дальнейшем обсуждении будем называть эту таблицу cheapest_prices_taЫe,
так как в ней будут храниться минимальные значения стоимости поездки из
начального города до всех пунктов назначения.

403

Алгоритм Дейкстры

Если бы мы хотели только определить самую дешевую поездку до города, то

в итоге таблица cheapest_prices_taЫe содержала бы все необходимые нам
данные. Но нас интересует фактический маршрут. То есть, если мы хотим до­

браться из Атланты в Эль-Паса, нам мало просто знать, что минимальная сто­
имость поездки

- 280 долларов: мы хотим знать, что она соответствует опреде­
- Денвер - Чикаго - Эль-Паса.

ленному маршруту: Атланта

Поэтому нам понадобится еще одна таблица

- cheapest_previous_stopover _

city_taЫe. Ее назначение станет понятным при обсуждении работы алгоритма,
поэтому пока опустим этот момент. Сейчас мне важно показать, как эта табли­
ца будет выглядеть по окончании работы алгоритма.
Город последней пересадки,
снижающей стоимость

Бостон

Чикаrо

Денвер

Эль-Пасо

Атланта

Денвер

Атланта

Чикаrо

поездки из Атланты в:

Обратите внимание, что в коде она тоже будет реализована в виде хеш-таблицы.

Этапы алгоритма Дейкстры
Итак, у нас все готово. Теперь давайте рассмотрим конкретные этапы алгорит­

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

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

1.

Посещаем начальный город, делая его «текущим».

2.

Проверяем цены на билеты из текущего города в каждый соседний.

3.

Если стоимость поездки в соседний город из начального ниже текущего

значения в таблице cheapest_prices_taЫe (или если соседний город во­
обще не указан в ней):

а)

обновляем таблицу cheapest_prices_taЫe, включая в нее обнаружен­
ную минимальную стоимость поездки;

б)

обновляем таблицу

cheapest_previous_stopover_city_taЫe, указывая
- в качестве значения.

соседний город в качестве ключа, а текущий

4.

Обращаемся к еще не посещенному городу, до которого дешевле всего до­
браться из начального, делая его текущим.

5.

Повторяем шаги со

2 по 4,

пока не посетим все известные города.

Опять же, все станет понятнее, когда мы рассмотрим конкретный пример.

404

Глава

18. Отражение связей между объектами с помощью графов

Пошаговый разбор алгоритма Дейкстры
Разберем алгоритм Дейкстры шаг за шагом.

Изначально в таблице cheapest_prices_taЫe есть только Атланта:
И3 Атланты в:

$0
Сразу после запуска алгоритма Атланта

-

единственный город, к которому у нас

есть доступ: другие мы еще не «обнаружили».
Шаг

1: официально посещаем

Атланту и делаем ее текущим городом

( current_

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

-

ставим галочку:

$100

$160

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

о которых мы раньше не знали, добавляем их на карту.
Шаг

2:

один из соседей Атланты

в Бостон

- 100 долларов.

-

Бостон. Стоимость поездки из Атланты

Мы проверяем таблицу cheapest_prices_taЫe, чтобы

сравнить эту стоимость с минимальной известной ценой на билет из Атланты
в Бостон. Оказывается, что сравнивать ее пока не с чем. Значит, сейчас обнару­
женная цена

-

минимальная из известных, поэтому добавляем ее в таблицу

cheapest_prices_taЫe:
И3 Атланты в:

Бостон

$0

$100

405

Алгоритм Дейкстры

Мы внесли изменения в таблицу с минимальными ценами, поэтому нужно об­

новить таблицу cheapest_previous _stopover _ ci ty _ taЫe, сделав соседний город
(Бостон) ключом, а текущий

-

значением:

Город последней пересадки, снижающей
стоимость поездки из Атланты в:

Бостон
Атланта

Добавление этих данных в таблицу означает, что для снижения стоимости по­
ездки из Атланты в Бостон

($100)

перед Бостоном нужно посетить именно

Атланту. Сейчас это и так понятно, ведь Атланта

единственный известный

-

нам город, из которого можно добраться до Бостона. Но в дальнейшем значи­
мость второй таблицы станет более очевидной.
Шаг

3: мы уже

проверили Бостон, но у Атланты есть еще один соседний город,

Денвер. Проверяем, действительно ли маршрут поездки стоимостью
самый дешевый из известных маршрутов Атланта

-

$160 -

Денвер. Но Денвер еще не

внесен в таблицу cheapest_prices_taЫe, поэтому добавляем в нее обнаружен­
ную стоимость в качестве минимальной на этом этапе:
Из Атланты в:

Бостон

Денвер

$0

$100

$160

Затем добавляем Денвер и Атланту в качестве пары «ключ
лицу

значение1> в таб­

cheapest_previous_stopover_ci ty_taЫe:

Город последней пересадки, снижающей
стоимость поездки из Атланты в:

Шаг

-

4:

Бостон

Денвер

Атланта

Атланта

к этому моменту мы проверили всех соседей Атланты и готовы посетить

следующий город. Но нам нужно как-то его выбрать.
Теперь, согласно шагам алгоритма выше, приступаем к посещению только тех

городов, где еще не были. Среди еще не посещенных городов всегда сначала

выбираем тот, путь до которого из начального города дешевле всего. Эти данные
можно получить из таблицы cheapest_prices_taЫe.
Известные, но еще не посещенные города в нашем примере

-

Бостон и Денвер.

Судя по таблице cheapest_prices_taЫe, добраться из Атланты в Бостон дешев­
ле, чем из Атланты в Денвер, поэтому посетим Бостон.

406

Глава 18. Отражение связей между объектами с помощью графов

Шаг

5: посещаем

Бостон и обозначаем его как текущий город:

$180

-а\\"11.1'...,.._

8 ~-......,Босто~l;
, , ,,,\

$100

$160

Теперь нужно проверить соседние с Бостоном города.
Шаг

6:

у Бостона два соседа

-

Чикаго и Денвер (Атланта не в счет

-

нам не

нужно лететь из Бостона в Атланту).
Какой город мы должны посетить первым

-

Чикаго или Денвер? Опять же,

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

Стоимость перелета из Бостона в Чикаго

- 120 долларов.

Согласно таблице

cheapest_prices_taЫe, минимальная цена на билет из Атланты до Бостона

100 долларов.

-

Выходит, стоимость самого дешевого маршрута от Атланты до

Чикаго с последней пересадкой в Бостоне будет

220 долларов.

Поскольку сейчас этот маршрут между Атлантой и Чикаго

-

единственный из

известных, добавим его стоимость в таблицу cheapest_prices_taЫe:
Из Атланты в:

Бостон

Чикаго

Денвер

$0

$100

$220

$160

Опять же, поскольку мы внесли изменения в таблицу, нам нужно обновить та­
блицу

cheapest_previous_stopover_city_taЫe. При этом соседний город всег­

да становится ключом, а текущий

-

значением:

Город последней пересадки, снижающей

стоимость поездки из Атланты в:

Бостон

Чикаго

Денвер

Атланта

Бостон

Атланта

407

Алгоритм Дейкстры

Итак, мы проверили Чикаго, теперь займемся Денвером.

Шаг 7: если мы посмотрим на ребро графа, соединяющее Бостон с Денвером, то
увидим, что стоимость перелета между ними
рейс из Атланты в Бостон стоит

- 180 долларов. Самый дешевый
100 долларов, поэтому минимальная стоимость

маршрута от Атланты до Денвера с последней пересадкой в Бостоне будет со­
ставлять

280долларов.

Интересно, что в нашей таблице cheapest_prices_taЫe уже есть стоимость

перелета из Атланты в Денвер

- 160 долларов - это гораздо дешевле по сравне­
нию со стоимостью маршрута Атланта - Бостон - Денвер. Поэтому мы не об­
новляем ни одну из таблиц, чтобы оставить значение $160 как минимальную
известную стоимость поездки из Атланты в Денвер.

Итак, мы закончили с Бостоном и проверили все его соседние города. Теперь
посетим следующий город.

Шаг

8:

мы еще не посетили Чикаго и Денвер. Опять же, сначала обратимся

к городу, путь до которого из начальною города (Атланты) обойдется нам де­
шевле всего. Обратите внимание, что речь идет об исходной точке маршрута.
Согласно таблице cheapest_prices_taЫe, добраться из Атланты в Денвер
дешевле, чем из Атланты в Чикаго

($220),

($160)

поэтому посетим Денвер:

$180

Проверяем соседние к Денверу города.

Шаг

9:

у Денвера два соседних города

-

Чикаго и Эль- Пасо. Чтобы решить,

какой из них посетить первым, нужно проанализировать стоимость перелета

в эти города. Начнем с Чикаго.

408

Глава 18. Отражение связей между объектами с помощью графов

Добраться из Денвера в Чикаго можно всего за

40

долларов (отличное пред­

ложение!). Значит, стоимость самого дешевого маршрута от Атланты до Чика­

го с последней пересадкой в Денвере
Атланты в Денвер можно за

долларов, поскольку добраться из

- 200

160.

Согласно таблице cheapest_prices_taЬle, текущая минимальная стоимость
поездки из Атланты в Чикаго

- 220

долларов. Значит, новый маршрут через

Денвер позволяет добраться из Атланты в Чикаго еще дешевле, поэтому мы

обновляем таблицу cheapest_prices_taЫe:
Из Атланты в:

Бостон

Чикаго

Денвер

$0

$100

$200

$160

Каждое обновление таблицы cheapest_prices_taЫe должно сопровождаться
обновлением таблицы

cheapest_previous_stopover_city_taЫe. Итак, мы ука­

зываем соседний город (Чикаго) в качестве ключа, а текущий (Денвер)

-

в ка­

честве значения. Ключ «Чикаго» уже существует, поэтому при обновлении

таблицы перезапишем его значение, заменив его с Бостона на Денвер:
Город последней пересадки, снижающей

Бостон

стоимость поездки из Атланты в:

Денвер

Чикаго

Атланта

Атланта

Денвер

Выходит, для минимизации стоимости поездки из Атланты в Чикаго нам нуж­

но прилететь в Чикаго из Денвера: последняя пересадка должна быть именно
в Денвере. Только тогда мы сэкономим максимальное количество денег.

Эта информация пригодится нам при определении самого дешевого маршрута
из Атланты в пункт назначения. Потерпите, мы почти у цели!

Шаг

1О:

у Денвера есть еще один соседний город

Денвера в Эль-Пасо

- 140 долларов.

-

Эль- Пасо. Цена билета из

Теперь мы можем подсчитать стоимость

первого известного маршрута от Атланты до Эль-Пасо. Согласно таблице

cheapest_

prices_taЫe, добраться из Атланты в Денвер можно минимум за
Значит, если затем мы поедем из Денвера в

160 долларов.
Эль-Пасо, то потратим еще 140 дол­

ларов, в результате чего общая стоимость поездки из Атланты в Эль-Пасо соста­
вит

300 долларов. Добавим эту сумму в таблицу

cheapest_prices_taЫe:

Из Атланты в:

Бостон

Чикаго

Денвер

Эль-Пасо

$0

$100

$200

$160

$300

Добавим пару «ключ

-

значение» «Эль-Пасо

cheapest_previous_stopover_city_taЫe:

-

Денвер» в нашу таблицу

409

Алгоритм Дейкстры

Город последней пересадки,
Бостон

Чикаго

Денвер

Эль-Пасо

Атланта

Денвер

Атланта

Денвер

снижающей СТОИМОСТЬ

поездки из Атланты в:

Значит, чтобы сэкономить больше денег при поездке из Атланты в Эль-Пасо,
нужно сделать последнюю пересадку в Денвере.

Итак, мы проверили всех соседей текущего города и теперь можем посетить
следующий.

Шаг

11: у нас есть два известных, но еще не посещенных города - Чикаго и Эль­
($200) дешевле, чем в Эль-Пасо ($300),

Пасо. Добраться из Атланты в Чикаго
поэтому посещаем Чикаго.

Шаг

12: у Чикаго только один соседний город - 80 долларов (неплохо). С

каго до Эль-Пасо

Эль-Пасо. Цена билета из Чи­
учетом этой информации мы

можем определить минимальную стоимость поездки из Атланты в Эль-Пасо
с последней пересадкой в Чикаго.

Согласно таблице cheapest_prices_taЫe, минимальная стоимость поездки из
Атланты в Чикаго

- 200 долларов.

Прибавим к этой сумме

80 долларов, и полу­

чим общую стоимость поездки из Атланты в Эль-Пасо с последней пересадкой
в Чикаго

- 280 долларов.

Кажется, мы нашли маршрут подешевле! Согласно таблице cheapest_prices_taЫe,
минимальная стоимость поездки из Атланты в Эль- Пасо составляет 300 долларов.
Но если мы полетим через Чикаго, стоимость снизится до

280 долларов.

$180

,,,.",,

8
$140 \

~а,~

-

$~

-------------

/.,;:/

Чикаго/

-

8

Босто~

V/_

' '' ....
1 \

$8О

$100

$160

410

Глава

18. Отражение связей между объектами с помощью графов

Соответственно, нам нужно обновить таблицу cheapest_prices_taЫe, указав
стоимость найденного маршрута до Эль- Пасо:
Из Атланты в:

Бостон

Чикаrо

Денвер

Эль-Пасо

$0

$100

$200

$160

$280

Обновим таблицу

cheapest_previous_stopover_city_taЫe, указав Эль-Пасо

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

Бостон

Чикаrо

Денвер

Эль-Пасо

Атланта

Денвер

Атланта

Чикаго

поездки из Атланты в:

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

Шаг

13:

Эль-Пасо

-

это единственный из известных, но еще не посещенных

городов, поэтому сделаем его текущим.

$180

$~

~-------

7\

,.,,/_

-а'''

~
e ~q
J/
V/

.

$100

$80

$160

~Эль-Па~~
/
, 1' \ ,, ,Шаг

14: из Эль-Пасо можно полететь только в Бостон. Такой перелет обойдет­
100 долларов. Согласно таблице cheapest_prices_taЫe, минимальная
стоимость поездки из Атланты в Эль-Пасо - 280 долларов. Итак, если мы по­
ся в

летим из Атланты в Бостон с последней пересадкой в Элъ-Пасо, то общая стоимость

411

Алгоритм Дейкстры

поездки составит

380

долларов. Это дороже текущей наименьшей стоимости

перелета из Атланты в Бостон

($100),

поэтому таблицы мы не обновляем.

Мы посетили все известные города, и у нас есть вся необходимая информация
для нахождения самого недорогого маршрута из Атланты в Эль-Пасо.

Поиск кратчайшего пути
Чтобы узнать минимальную стоимость поездки из Атланты в Эль-Пасо, мы
можем просто заглянуть в таблицу cheapest_prices_taЫe и выяснить, что эта
стоимость составляет

280 долларов.

Но если нас интересует конкретный марш­

рут, нам нужно сделать еще кое-что.

Помните нашу таблицу

cheapest_previous_stopover _ci ty_taЫe? Пришло вре­

мя использовать данные из нее.

Сейчас она выглядит так:
Город последней пересадки,
снижающей стоимость

Бостон

Чикаго

Денвер

Эль-Пасо

Атланта

Денвер

Атланта

Чикаго

поездки на Атланты в:

Мы можем использовать эту таблицу, чтобы найти кратчайший путь из Атлан­
ты в Эль- Пасо, двигаясь в обратном направлении.
Сначала посмотрим на Эль-Пасо: ему соответствует Чикаго. Значит, самый
дешевый маршрут из Атланты в Эль-Пасо предполагает последнюю пересадку

в Чикаго. Запишем это так: Чикаго --+ Эль- Пасо.

Если мы снова обратимся к таблице

cheapest_previous_stopover_ci ty_taЫe,

то увидим, что ключу Чикаго соответствует значение Денвер. Значит, са­
мый дешевый маршрут из Атланты в Чикаго предполагает последнюю пере­

садку в Денвере. Добавим этот город в наш маршрут: Денвер --+ Чикаго --+
Эль-Пасо.

В той же таблице мы видим, что самый дешевый маршрут из Атланты в Ден­
вер

-

прямой рейс между этими городами: Атланта --+ Денвер --+ Чикаго --+

Эль-Пасо.
Атланта

-

начальный город, поэтому полученный маршрут оказывается самым

дешевым способом добраться из Атланты в Эль-Пасо.
Рассмотрим логику, лежащую в основе составления самого дешевого маршрута.

412

Глава 18. Отражение связей между объектами с помощью графов

Как вы помните, в таблице

cheapest_previous_stopover _city_taЫe для каждо­

го пункта назначения указан город последней пересадки, позволяющий снизить

общую стоимость поездки из Атланты.
Исходя из этой таблицы, для минимизации стоимости поездки из Атланты
в Эль- Пасо нам нужно лететь ...



...

в Эль- Пасо из Чикаго,



... в



... а в Денвер -

Чикаго

-

из Денвера,
из Атланты.

Значит, самый дешевый маршрут: Атланта-+ Денвер---+ Чикаго---+ Эль-Пасо.

Вот и все. Наконец-то!

Программная реализация: алгоритм Дейкстры
Прежде чем реализовать весь алгоритм на языке

Ruby, реализуем класс City,
WeightedGraphVertex, но использует такие понятия, как routes
(маршруты) и price (цена). Это (немного) упростит наш будущий код:
который похож на

class City
attr_accessor :name, :routes
def initialize(name)
@name = name
@routes = {}
end
def add_route(city, price)
@routes[city] = price
end
end
Чтобы использовать данные из примера выше, запустите следующий код:

atlanta = City.new("Atlanta")
boston = City.new("Boston")
chicago = City.new("Chicago")
denver = City.new("Denver")
el_paso = City.new("El Paso")
atlanta.add_route(boston, 100)
atlanta.add_route(denver, 160)
boston.add_route(chicago, 120)
boston.add_route(denver, 180)
chicago.add_route(el_paso, 80)
denver.add_route(chicago, 40)
denver.add_route(el_paso, 140)

413

Алгоритм Дейкстры

Ниже приведен весь код алгоритма Дейкстры

пожалуй, самый сложный в этой

-

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

City,

а вне

его. Этот метод принимает два экземпляра класса Ci ty и возвращает кратчайший
путь между ними:

def

dijkstra_shortest_path(startiпg_city,

cheapest_prices_taЫe

cheapest_previous_stopover_city_taЫe

#

fiпal_destiпatioп)

= {}
= {}

Для упрощения кода мы будем использовать простой массив для отслеживания

#известных

городов,

uпvisited_cities

которые еще, не

посетили:

= []

#Для отслеживания посещенных городов мы используем хеш-таблицу.

# Для этого можно использовать массив, но нам предстоит выполнять
# поэтому лучше выбрать хеш-таблицу, так как она эффективнее:
visited_cities = {}
#
#
#

поиск,

Добавляем название начального города в качестве первого ключа в таблицу
cheapest_prices_taЫe.
так

как

Задаем для этого ключа значение

0,

ехать нам никуда не надо:

cheapest_prices_taЫe[startiпg_city.пame]
curreпt_city

=

=

0

startiпg_city

# Этот цикл - ядро алгоритма. Он выполняется,
# не закончатся города для посещения:
while curreпt_city

пока у нас

# Добавляем название текущего города (curreпt_city) в хеш-таблицу
# visited_cities, чтобы отметить его как посещенный, и удаляем его
# из списка еще не посещенных городов:
visited_cities[current_city.пame] = true
uпvisited_cities.delete(curreпt_city)

#

Перебираем все соседние к текущему города:

curreпt_city.routes.each

#

ladjaceпt_city,

do

При обнаружении нового города добавляем его название

#в список еще не посещенных городов
uпvisited_cities
adjaceпt_city

#
#
#

(uпvisited_cities):

---+

5

6-

~ ф
f-

5

:I:

:I:

115

51

С
о

с:[
о

4L

а о

С{ 1:§

++1

-1-

+-

+

1-т-+

i

3

+-

"'

Q.

о

+-

2

3

4

5

6

~

-+-

7

8

~

l

L

1
9

t
10

11

12

13

14

15

16

Количество элементов

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

O(N).

Отличие лишь в том, что вертикальная

ось теперь отражает потребляемую памятъ, а не время.
Теперь рассмотрим альтернативную версию функции makeUppercase( ), которая

использует память более эффективно:

function makeUppercase(array) {
for(let i = 0; i < array.length; i++)
array[i] = array[i] .toUpperCase();
}

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

чего возвращаем измененный массив.

Это серьезное улучшение в плане потребления памяти, ведь новая функция

вообще не занимает дополнuтелъную памятъ.
Как выразить это с помощью О-нотации?
Как вы помните, скорость алгоритма с временной сложностью О( 1) остается

постоянной вне зависимости от объема данных. Точно так же и пространствен­
ная сложность О( 1) означает, что алгоритм потребляет одинаковый объем па­
мяти вне зависимости от объема данных.

Наша пересмотренная функция makeUppercase() потребляет постоянный объем
дополнительного пространства (равный нулю!) независимо от того, сколько

425

Компромисс между временем выполнения и занимаемой памятью

элементов в исходном массиве

-

четыре или сто. Поэтому пространственная

сложность этой функции равна О( 1).
Важно отметить, что при использовании О-нотации для описания простран­

ственной сложности мы учитываем только новые данные, которые генерирует
алгоритм. Например, вторая версия функции
с

N

makeUppercase() тоже имеет дело

элементами данных в виде переданного ей массива, но при оценке ее про­

странственной сложности с помощью О-нотации мы их не учитываем, так как

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

Но в некоторых источниках могут встречаться и оценки пространственной

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

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

Теперь сравним две версии функции makeUppercase() с точки зрения их вре­
менной и пространственной сложности:
Версия
Версия
Версия

No 1
No 2

Временная сложность

Пространственная сложность

O(N)
O(N)

O(N)
0(1)

Итак, временная сложность обеих функций

- O(N), так как они предполагают

выполнение N шагов при наличии N элементов данных. Но вторая версия более
эффективна с точки зрения потребления памяти: ее пространственная сложность
равна

0(1), а пространственная сложность

Итак, версия

первой версии

- O(N).

No 2 более эффективна по сравнению с первой, так как потребляет

меньше памяти, не жертвуя при этом скоростью, что очень хорошо.

Компромисс между временем выполнения

и занимаемой памятью
Рассмотрим функцию, которая принимает массив и возвращает значение

если находит в нем повторяющиеся значения (мы обсуждали ее в главе

function hasDuplicateValue(array) {
for(let i = 0; i < array.length; i++) {
for{let j = 0; j < array.length; j++) {
if(i !== j && array[i] === array[j]) {

true,

4).

426

Глава 19. Работа в условиях ограниченного пространства

return true;
}
}
}

return false;
}
Этот алгоритм использует вложенные циклы и выполняется за О(№) времени.
Назовем эту реализацию версией №

1.

А вот вторая реализация, версия №

2,

где используется хеш-таблица и всего

ОДИН ЦИКЛ:

function hasDuplicateValue(array) {
let existingValues = {};
for(let i = 0; i < array.length; i++) {
if(!existingValues[array[i]]) {
existingValues[array[i]] = true;
} else {
return true;
}
}

return false;
}

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

true).

Но если мы встречаем элемент, который уже

был ключом хеш-таблицы, мы возвращаем

true, ведь это значит, что мы обна­

ружили дубликат.
Какой же из этих двух алгоритмов эффективнее? Зависит от того, что для вас
важнее

-

время выполнения или занимаемое пространство. С точки зрения

времени выполнения, версия №

за время

O(N), а версия



1-

2 намного

эффективнее, так как выполняется

за О(№).

Но если речь идет о занимаемом пространстве, то версия №
на, чем версия №

2.

1 более эффектив­
- O(N), так

Пространственная сложность второй версии

как эта функция создает хеш-таблицу, где могут быть все

N значений

входного

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

ность

-

О( 1).

Сравним эти две версии функции
Версия
Версия №
Версия №

1
2

hasDuplicateValue( ):

Временная сложность

Пространственная сложность

О(№)

0(1)
O(N)

O(N)

427

Компромисс между временем выполнения и занимаемой памятью

Мы видим, что версия №
а№

1 более

эффективна в плане потребления памяти,

в плане скорости работы. Так какой же из этих алгоритмов следует вы­

2-

брать?

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


2-

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

а скорость у нас не в приоритете, выбор лучше сделать в пользу версии №

1.

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

Сравним третью версию той же функции с первыми двумя:

function hasDuplicateValue(array) {
array.sort((a, Ь) => (а < Ь) ? -1 : 1);
for(let i = 0; i < array.length - 1; i++) {
if (array[i] === array[i + 1]) {
return true;
}
}

return false;
}
Эта реализация, назовем ее версией №

3, начинается с сортировки массива.

За­

тем функция перебирает все значения в массиве, сравнивая каждое из них со

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

Временная сложность этого алгоритма равна

O(N log N).

3.

Допустим, алгоритм

сортировки, реализованный на языке JavaScгipt, выполняется за время

O(N log N)

(самые быстрые из известных алгоритмов сортировки работают

именно с такой скоростью). Дополнительные N шагов для перебора элементов
массива

-

это совсем немного по сравнению с числом шагов для выполнения

сортировки, поэтому в конечном итоге временная сложность может оказаться

O(Nlog N).
С пространственной сложностью все не так просто, так как разные алгоритмы

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

бором, не занимают дополнительное пространство, поскольку вся сортировка

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

428

Глава 19. Работа в условиях ограниченного пространства

позже. Например, пространственная сложность большинства реализаций алго­
ритма

Quicksort равна O(log N).

Итак, сравним версию
Версия

No 3 с двумя

предыдущими:

Временная сложность

Версия

No 1
Версия№ 2
Версия№ 3

Пространственная сложность

О(№)

0(1)

O(N)
O(NlogN)

O(N)
O(logN)

Версия

No 3 позволяет достичь определенного баланса между временем выпол­
No 3 работает
быстрее, чем No 1, но медленнее, чем No 2. В плане занимаемого места она эф­
фективнее, чем No 2, но менее эффективна по сравнению с версией No 1.
нения и занимаемой памятью. С точки зрения времени, версия

Выходит, мы можем выбрать версию

No 3

в случаях, когда время выполнения

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

пространство при создании новых фрагментов данных, например массивов или

хеш-таблиц. Но алгоритм может занимать пространство, даже если не делает
ничего такого. И это нельзя игнорировать.

Скрытые издержки рекурсии
Мы уже много говорили о рекурсивных алгоритмах. Но давайте рассмотрим
еще одну простую рекурсивную функцию:

function recurse(n) {
if (n < 0) { return; }
console.log(n);
recurse(n - 1);
}

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

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

O(N), так

как число рекурсивных вызовов функции соответ-

429

Скрытые издержки рекурсии

ствует величине

n.

Она не создает новых структур данных, поэтому не занима­

ет дополнительного места.

Или все-таки занимает?

О том, как работает рекурсия, мы говорили в главе

10. Там

вы узнали, что каж­

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

нии работы внутренней.
Если мы передадим в нашу рекурсивную функцию число

100, она добавит вызов

recurse(100) перед вызовом recurse(99), а затем добавит recurse(99) в стек
перед вызовом

recurse(98).

На момент вызова recurse(-1) в стеке будет

101

элемент, от recurse(100) до

recurse(0).
Несмотря на то что стек вызовов в итоге будет раскручен, для хранения этих

100 элементов

в нем нам нужен определенный объем памяти. Получается, что

O(N). N здесь 100, нам придется временно

пространственная сложность нашей рекурсивной функции равна

это число, переданное функции. Если мы передаем
хранить

100 элементов

в стеке вызовов.

Из этого вытекает важный принцип: рекурсивная функция занимает единицу
пространства при каждом рекурсивном вызове. Таким хитрым способом рекур­
сия буквально пожирает память компьютера: даже если функция не создает
новые данные, в процессе самой рекурсии данные добавляются в стек вызовов.

Чтобы правильно рассчитать объем памяти, потребляемой рекурсивной функ­
цией, нужно определить максимальный размер стека вызовов.

В нашем случае размер стека будет примерно равен величине переданного
функции числа

n.

Это может показаться незначительным. В конце концов, современные компью­

теры могут обработать несколько элементов стека вызовов, верно? Что ж, по­
смотрим.

20 ООО функции recurse на своем стильном современном
не может его обработать. 20 ООО не кажется большим числом.

Когда я передаю число

ноутбуке, он

Но при запуске

recurse(20000)

происходит следующее.

Мой компьютер выводит на экран числа от

20000 до 5387, а затем отображает

сообщение об ошибке:

RangeError: Maximum call stack size exceeded

430

Глава

19. Работа в условиях ограниченного пространства

Поскольку рекурсия продолжалась с 20000 примерно до 5000 (я округляю 5387
в меньшую сторону), размер стека вызовов предположительно достиг значения

15

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

способен обработать стек вызовов с более чем

15 ООО элементов.

Это CWlЫto ограничивает возможности применения рекурсии, так как я не могу

передать своей замечательной функции

recurse

число, превышающее

15

ООО!

Сравним этот процесс с использованием простого цикла:

function loop(n) {
while (n >= 0) {
console.log(n);
n--;
}
}
Эта функция решает ту же задачу, применяя вместо рекурсии обычный цикл.
Функция не использует рекурсию и не занимает дополнительную память, по­
этому может работать с огромными числами, не приводя к нехватке места. При

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

Теперь понятно, почему пространственная сложность алгоритма быстрой со­
ртировки

- O(log N).

Он выполняет

O(log N)

рекурсивных вызовов, поэтому

максимальный размер стека вызовов достигает значения

log(N).

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

пользовать «волшебный:.> нисходящий способ мышления, описанный в главе

11,

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

20 ООО, рекур­

сия может не справиться.

Опять же, я не призываю вас вовсе отказываться от рекурсии. Я лишь говорю,
что в каждой конкретной ситуации следует взвешивать все плюсы и минусы
того или иного алгоритма.

Выводы
И так, вы знаете, как оценивать эффективность алгоритмов с точки зрения вре­
мени выполнения и занимаемого пространства. Теперь у вас достаточно инфор-

431

Упражнения

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

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

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

1.

19».

Ниже приведен алгоритм составителя слов из одноименного раздела главы

7.

Опишите его пространственную сложность с помощью О-нотации:

function wordBuilder(array) {
[];
let collection
for(let i = 0; i < array.length; i++) {
for(let j = 0; j < array.length; j++) {
i f (i ! == j) {

collection.push(array[i] + array[j]);
}
}
}

return collection;
}

2.

Ниже приведена функция, которая обращает порядок следования элемен­
тов массива. Опишите ее пространственную сложность с помощью
О-нотации:

function reverse(array) {
let newArray = [];
for (let i = array.length - 1; i >= 0; i--) {
newArray.push(array[i]);
}

return newArray;
}

3.

Создайте новую функцию для обращения порядка элементов массива, про­
странственная сложность которой равна О( 1).

432
4.

Глава 19. Работа в условиях ограниченного пространства

Ниже приведены три разные реализации функции, которая принимает
массив чисел и возвращает массив с теми же числами, умноженными на
Например, при передаче массива

2.

(5, 4, з, 2, 1] эта функция возвращает

(10, 8, 6, 4, 2].

function douЬleArrayl(array) {
let newArray = [];
for(let i = 0; i < array.length; i++) {
newArray.push(array[i] * 2);
}

return newArray;
}

function douЬleArray2(array) {
for(let i = 0; i < array.length; i++) {
array[i] *= 2;
}

return array;
}

function douЬleArrayЗ(array, index=0) {
if (index >= array.length) { return; }
array[index] *= 2;
douЬleArrayЗ(array,

index + 1);

return array;
}
Заполните следующую таблицу, описав эффективность этих трех версий с точ­
ки зрения времени выполнения и занимаемого пространства:

Версия

Версия№
Версия№

1
2

Версия№З

Временная СЛОЖНОСТЬ

Пространственная сложность

?
?
?

?
?
?

ГЛАВА20

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

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

В последней главе я поделюсь с вами дополнительными приемами оптимизации

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

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

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

Обязательное условие перед проведением оптимизации

-

определение текущей

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

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

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

434

Глава

20. Оптимизация

кода

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

из них особенно эффективны в одних ситуациях, а некоторые

-

в других.

Тем не менее, метод, о котором я расскажу сейчас, применим ко всем алгоритмам

и должен быть первым шагом в процессе их оптимизации.
Вот в чем его суть.

После определения эффективности алгоритма (после выполнения предвари­

тельного условия) представьте себе то, что я называю «лучшей мыслимой эф­
фективностью» (некоторые называют это «лучшим мыслимым временем вы­

полнения» или «лучшим воображаемым О-большим», говоря о скорости

работы алгоритма).
По сути, лучшая мыслимая эффективность

-

это максимальное значение, вы­

раженное с помощью О-нотации, к которому нужно стремиться при выполнении

поставленной задачи: та степень эффективности, превзойти которую невоз­
можно.

Например, для написания функции, которая выводит все элементы массива,

лучшая мыслимая эффективность алгоритма будет
нужно вывести каждый из

N

O(N).

Учитывая, что нам

элементов массива на экран, у нас нет другого вы­

бора, кроме как обработать все

N элементов. С этим ничего нельзя поделать,

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

O(N) -

лучшая мыслимая эффективность в этом сценарии.

Получается, что при оптимизации алгоритма нужно определить два показателя
с помощью О-нотации: текущую эффективность нашего алгоритма (выполнить
предварительное условие) и его максималъно возможную эффективность при
решении поставленной задачи.
Если эти значения не совпадают, значит, нам есть что оптимизировать. Напри­
мер, если мой текущий алгоритм реализуется за время О(№), а поставленную
задачу в принципе можно выполнить за время

O(N),

то мне еще есть к чему

стремиться. Разрыв между этими двумя показателями отражает выгоду, которую
мы можем получить за счет оптимизации.

Итак, вам нужно сделать следующее.

1.

Определить категорию сложности текущего алгоритма (это предваритель­
ное условие).

435

Определение лучшей эффективности из возможных

2.

Определить лучшую мыслимую эффективность алгоритма, выполняюще­
го поставленную задачу.

3.

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

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

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

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

мизировать алгоритм до

ся хотя бы за

O(N),

O(log N).

- O(log N),

я постараюсь опти­

Если в результате мой код будет выполнять­

это уже будет большим успехом, которого я смог достичь

благодаря определению лучшей мыслимой эффективности.

Развитие воображения
Как вы уже видели, определение лучшей эффективности из возможных дает
нам цель, к которой нужно стремиться при оптимизации. Чтобы извлечь из

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

мыслимую эффективность, которую можно было бы назвать поразительной.
Советую вам ориентироваться на максимальную эффективность из принципи­
ально возможных.

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

ма и спрашиваю себя: «Если бы кто-нибудь сказал мне, что знает, как выпол­
нить эту задачу с такой поразительной эффективностью, поверил бы я ему?»
Если да, то я использую этот показатель как лучшую эффективность из воз­
можных.

После определения текущего и целевого показателя алгоритмической слож­
ности мы можем приступать к оптимизации алгоритма.

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

436

Глава

20. Оптимизация кода

Волшебные поиски
Это один из моих любимых приемов оптимизации. Я просто спрашиваю себя:

«Если бы я мог каким-то волшебным способом находить нужную информацию
за время

0(1),

то смог бы я ускорить свой алгоритм?~> Если да, я использую

структуру данных (как правило, хеш-таблицу), чтобы воплотить это волшебство
в жизнь. Я называю эту технику «волшебными поисками~>.
Посмотрим, как она работает, на конкретном примере.

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

Массив с именами авторов

authors = [
{"author_id"
{"author_id
{"author_id"
{"author_id"
{"author_id"
11

=>
=>
=>
=>
=>

( authors)

1, "name" =>
2, "name" =>
З, ··name" =>
4, ··name" =>
5, "name" =>

выглядит так:

"Virginia Woolf"},
"Leo Tolstoy"},
"Dr. Seuss"},
"J. к. Rowling"},
"Mark Twain"}

Это массив хеш-таблиц, каждая из которых содержит имя и идентификатор
автора.

У нас есть еще один массив

books = [
{"author_id"
{"author_id"
{"author_ id"
{"author_ id"
{"author_id"
{"author_id"
{"author_id"
{"author_id"
{"author_id"
{"author_id"

=>
=>
=>
=>
=>
=>
=>
=>
=>
=>

З,

-

в нем содержатся данные о книгах:

"title" =>

"Нор

on

Рор"},

1, "title" => "Mrs. Dalloway"},

4, "title" =>
1, "title" =>
2, "title" =>
5, "title" =>
З, "title" =>
2, "title" =>
З, "title" =>
5, "title" =>

"Harry Potter and the Sorcerer's Stone"},
"То the Lighthouse"},
"Anna Karenina"},
"The Adventures of Tom Sawyer"},
"The Cat in the Hat"},
"War and Реасе"},
"Green Eggs and Ham"},
"The Adventures of Huckleberry Finn"}

Как и в authors, в массиве books есть несколько хеш-таблиц. Каждая из них со­
держит название книги и

author_id, с помощью которого мы можем определить
authors. Например, книге Нор
оп Рор соответствует значение author _id, равное 3. Значит, автор этой книги
Dr. Seuss (доктор Сьюз), так как в массиве authors его имени присвоен иденти­
имя автора книги, используя данные из массива

фикатор

3.

437

Волшебные поиски

Теперь предположим, что мы хотим написать код, объединяющий эту инфор­
мацию в одном массиве:

books_with_authors = [
{"title" => "Нор оп Рор", "author" => "Dr. Seuss"}
{"title" => "Mrs. Dalloway", "author" => "Virginia Woolf"}
{"title" => "Harry Potter and the Sorcerer's Stone", "author" => "J. К.
Rowling"}
{"title" => "То the Lighthouse", "author" => "Virginia Woolf"}
{"title" => "Anna Karenina", "author" => "Leo Tolstoy"}
{"title" => "The Adventures of Tom Sawyer", "author" => "Mark Twain"}
{"title" => "The Cat in the Hat", "author" => "Dr. Seuss"}
{"title" => "War and Реасе", "author" => "Leo Tolstoy"}
{"title" => "Green Eggs and Ham", "author" => "Dr. Seuss"}
{"title" => "The Adventures of Huckleberry Finn", "author" => "Mark Twain"}

Для этого нам нужно перебрать массив books и связать каждую книгу с соот­
ветствующим автором. Как это реализовать?

Один из возможных подходов

-

использование вложенных циклов. При этом

внешний цикл будет перебирать книги, для каждой из которых будет запускать­

ся внутренний, перебирающий авторов до обнаружения того, у кого будет соот­
ветствующий идентификатор. Так выглядит реализация этого подхода на

языке

Ruby:

def connect_books_with_authors(books, authors)
books_with_authors = []
books.each do lbookl
authors.each do lauthorl
if book["author_id"] == author["author_id"]
books_with_authors "Virginia Woolf", 2 => "Leo Tolstoy", 3 => "Dr. Seuss", 4 => "J.
Rowling", 5 => "Mark Twain"}
Ключ здесь

-

идентификатор автора, а значение

-

к.

его имя.

Итак, оптимизируем алгоритм так, чтобы он сначала перемещал данные из

массива authors в эту хеш-таблицу и только потом перебирал книги в цикле:

439

Волшебные поиски

def connect_books_with_authors(books, authors)
books_with_authors = []
author_hash_taЫe = {}

# Преобразование массива с именами авторов в хеш-таблицу:

authors.each do lauthorl
author["name"]

author_hash_taЫe[author["author_id"]]

end
books.each do lbookl
books_with_authors book["title"], "author" =>
id"]]}
end

author_hash_taЫe[book["author_

return books_with_authors
end

authors и используем данные из него для соз­
author_hash_taЫe. На это уходит М шагов, где М - число

Сначала мы перебираем массив
дания хеш-таблицы
авторов.

Затем перебираем список книг и используем хеш-таблицу

author _hash_taЫe
N шагов, где

для нахождения имени нужного за один шаг. Этот цикл выполняет

N-

количество книг.

Итак, временная сложность оптимизированного алгоритма равна

как он выполняет один цикл для обработки

O(N + М), так

N книг и еще один для обработки

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

O(N х

М).

Важно отметить, что при создании еще одной хеш-таблицы мы используем
дополнительное пространство О(М), тогда как наш первоначальный алгоритм

вообще не занимал дополнительную память. Но эта оптимизация

-

отличный

вариант, если мы готовы пожертвовать местом ради ускорения работы алго­
ритма.

Итак, мы получили волшебную способность, сначала вообразив себе поиск,
выполняемый за время О( 1), а затем реализовав эту идею с помощью хеш­

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

-

мы обсуждали это еще в главе

приемом, суть которого
время

0(1)

-

8.

0(1), нет ниче­

Здесь я просто делюсь конкретным

представить, как именно выполнение поиска за

повлияет на скорость работы кода. Представив потенциальные

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

440

Глава

20. Оптимизация

кода

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

ного поиска. Кстати, это один из моих любимых примеров оптимизации.

Проблема двух сумм

-

известное упражнение по программированию. Задача

в том, чтобы написать функцию, которая принимает массив чисел и возвра­
щает значение

true

или

false

чисел, которые в сумме дают

в зависимости от того, есть ли в массиве пара

10

(или другое заданное число). Чтобы было

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

[2, 0, 4, 1, 7, 9]
Наша функция вернет

true,

так как

1и9в

сумме дают

10.

При передаче массива:

[2, 0, 4, 5, 3, 9]
эта функция вернет

дают

10, нам

false.

Несмотря на то что три числа

- 2, 5

и з

-

в сумме

нужно найти два.

Первое решение, которое приходит на ум, сводится к использованию вложен­
ных циклов для сравнения всех чисел друг с другом и выяснения того, дают

они в сумме

10

или нет. Так выглядит реализация этой функции на языке

JavaScript:
function twoSum(array) {
for(let i = 0; i < array.length; i++) {
for(let j = 0; j < array.length; j++) {
if(i !== j && array[i] + array[j] === 10) {
return true;
}

}
}

return false;
}
Прежде чем пытаться что-то оптимизировать, нужно выполнить предваритель­

ное условие и оценить текущую эффективность нашего кода.

Как и любой алгоритм, использующий вложенный цикл, эта функция выпол­
няется за время О(№).

Чтобы узнать, стоит ли оптимизировать алгоритм, нужно сравнить текущую
эффективность с максимально возможной.

441

Волшебные поиски

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

O(N). И если
O(N), я бы по­

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

O(N)

лучшей мыслимой эффективностью алго­

ритма.

Теперь зададим себе вопрос: «Если бы я мог каким-то волшебным способом
находить нужную информацию за время

0(1), то смог бы я ускорить свой ал­

горитм?»
Иногда полезно задаться этим вопросом в процессе анализа текущей реализации.
Так мы и сделаем.

Представим работу внешнего цикла на примере массива [2, 0, 4, 1, 7, 9]. Сна­
чала цикл обрабатывает число

2.

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

чтобы в сумме получить

10.

Получается, обращаясь к
сива число
сиве

8, за

8.

2,

мы хотели бы узнать, есть ли среди элементов мас­

Если бы у нас был волшебный способ узнать, есть ли в этом мас­

время

0(1), то мы немедленно вернули бы true. Назовем число 8 до­
2, так как в сумме они дают 10.

полняющим к числу

Точно так же при обработке числа О мы хотели бы найти в массиве дополняющее
его значение

10 за время 0(1)

и т. д.

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

выполняя волшебный поиск дополняющих значений за время О( 1). При обна­

true,

ружении в массиве числа, дополняющего текущее, мы возвращаем

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

а если

- false.

Теперь, когда мы оценилипотенциальную выгоду от выполнения волшебных
поисков за время О( 1), попробуем провернуть наш трюк с добавлением новой
структуры данных. Опять же, обычно по умолчанию применяется хеш-таблица
(частота, с которой ее используют для ускорения работы алгоритмов, удиви­
тельна).

Поскольку мы хотим находить любое число в массиве за время

0(1), будем

хранить эти числа в качестве ключей в хеш-таблице, которая может выглядеть
так:

{2: true, 0: true, 4: true, 1: true, 7: true, 9: true}
Значение ключей может быть произвольным, пусть это будет

true.

442

Глава

20. Оптимизация

кода

Теперь мы можем найти любое число за время

0(1). Как нам отыскать допол­
2 дополняющим
его значением должно быть 8, потому что 2 + 8 = 10.
няющее число? Ранее мы заметили, что при обработке числа

По сути, мы можем вычислить значение, дополняющее любое число с помощью
вычитания его из

10.

Поскольку

10 - 2 = 8, 8 -

дополняющее число к

2.

Теперь у нас есть все для создания по-настоящему быстрого алгоритма:
fuпctioп

let

twoSum(array) {

hashTaЫe

= {};

for(let i = 0; i <

11
11

Проверяем,

array.leпgth;

к текущему числу получается

if(hashTaЬle[10
returп

i++) {

есть ли в хеш-таблице ключ,

при добавлении

которого

10:

- array[i]]) {

true;

}

11

Сохраняем каждое число в хеш-таблице в качестве ключа:

hashTaЬle[array[i]]

=

true;

}

11
11

Возвращаем

false,

если доходим до конца массива,

не обнаружив ни одного числа,

returп

дополняющего другие до

10:

false;

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

в хеш-таблице ключ, значение которого дополняет текущее число до

10. Мы вы­
array[i] (например, если значение array[i] будет 7, так как 10 - 3 = 7).

числяем это с помощью кода 10 -

3, то дополняющим его числом

При обнаружении дополняющего числа мы немедленно возвращаем

говорит об обнаружении двух чисел, сумма которых равна

true,

что

10.

В .процессе перебора чисел мы вставляем каждое из них в хеш-таблицу в качестве
ключа. Так по мере обработки массива мы заполняем таблицу значениями.

Этот подход позволил увеличить скорость работы алгоритма до

O(N). Мы до­

бились этого, сохранив все элементы данных в хеш-таблице, чтобы иметь воз­
можность выполнять поиск в цикле за время О( 1).

Станьте волшебником в мире программирования, вооружившись своей волшеб­
ной палочкой

-

хеш-таблицей (ладно, хватит об этом).

443

Выявление закономерностей

Выявление закономерностей
Одна из самых полезных стратегий как для оптимизации кода, так и для раз­

работки алгоритмов

-

выявление закономерностей в задаче. Часто это помога­

ет разработать простой алгоритм для выполнения сложной задачи.

Игра с монетками
В качестве примера рассмотрим так называемую игру с монетками, где два

игрока по очереди убирают из кучи одну или две монетки. Проигрывает тот, кто

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

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

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

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

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

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

беду при заданном количестве монет в исходной куче. Как это сделать? Если

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

Вот реализация этого рекурсивного подхода на языке

Ruby:

def game_winner(number_of_coins, current_player="you")
if number_of_coins 3,

"с"

=> 3, "d" => 3,

"Ь"

=> 3}

Как и в прошлом примере, здесь мы теряем часть данных. У нас не вышло бы

преобразовать эту хеш-таблицу в исходный массив, так как мы не знали бы ис­
ходного порядка строк.

Но для наших целей группировки эта потеря данных не важна. По сути, в хеш­

таблице есть все данные, которые нужны для создания сгруппированного мас­
сива.

Например, мы можем перебрать все пары «ключ

-

значение» в хеш-таблице

и использовать эти данные для заполнения массива нужным количеством эк­

земпляров строк. Код будет выглядеть так:

469

Замена структуры данных

def group_array(array)
hash_taЫe = {}
пew_array = []

#

Сохраняем количество экземпляров каждой строки в хеш-таблице:

array.each do lvaluel
if hash_taЫe[value]
hash_taЫe[value]

1

+=

else
hash_taЫe[value]

1

=

епd
епd

# Перебираем хеш-таблицу и заполняем новый массив
# нужным количеством экземпляров каждой строки:
hash_taЫe.each do lkey, couпtl

do

couпt.times



{"а"

З,

=>

"с"

З,

"d" => 3,

"Ь"

Затем перебираем все пары «ключ

-

=>

З}

значение» и используем эти данные для

заполнения нового массива:

hash_taЫe.each
couпt.times
пew_array

do lkey,

couпtl

do

« key

епd

епd

То есть при обработке пары "а" => 3 мы добавляем в новый массив три буквы
"а", пары "с"=> 3 - три буквы "с" и т. д. К моменту завершения работы функции

новый массив будет содержать все строки, объединенные в группы.

470

Глава

20. Оптимизация кода

Алгоритм выполняется всего за

O(N) времени. Это серьезное улучшение по
O(N log N), которое ушло бы на сортировку. При этом
мы используем дополнительное место O(N) для создания хеш-таблицы и ново­
сравнению с временем

го массива, хотя для экономии могли бы перезаписать исходный массив. Что
касается объема памяти, занимаемого хеш-таблицей, то он не превысит

O(N)

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

Опять же, если у нас в приоритете скорость, то этот вариант просто фантасти­

ческий, так как позволяет добиться наибольшей эффективности из возможных.

Выводы
Методы из этой главы могут пригодиться при оптимизации кода. Начинать этот

процесс всегда следует с определения текущей и лучшей мыслимой эффектив­

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

не стоит отбрасывать ни ОДИН ИЗ ЭТИХ ПОДХОДОВ.
С опытом вы научитесь чувствовать, какие методы и приемы применять в той

или иной ситуации, а возможно, даже разработаете и собственные!

Заключительные мысли
В ходе этого путешествия вы многому научились.

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

Вы научились определять текущую эффективность кода.

Узнали о том, как оптимизировать код, сделав его быстрее, проще и эффектив­
нее с точки зрения занимаемой памяти.

Эта книга дала вам все необходимое для принятия обоснованных технологиче­
ских решений. Создание качественного ПО предполагает оценку преимуществ
и недостатков доступных вариантов и поиск компромисса между ними. Теперь

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

Подходы к оптимизации всегда лучше тестировать с помощью инструментов

сравнительного анализа. Применение таких техник, которые позволяют оценить

471

Упражнения

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

Я надеюсь, при чтении этой книги вы поняли, что темы, которые на первый

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

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

Тема структур данных и алгоритмов очень обширная и глубокая. В этой книге
мы лишь поверхностно прошлись по основным аспектам. Но, опираясь на полу­

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

Желаю удачи!

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

1.

20».

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

basketball_players = [
{first_name: "Jill", last_name: "Huang", team: "Gators"},
{first_name: "Janko", last_name: "Barton", team: "Sharks"},
{first_name: "Wanda", last_name: "Vakulskas", team: "Sharks"},
{first_name: "Jill", last_name: "Moloney", team: "Gators"},
{first_name: "Luuk", last_name: "Watkins", team: "Gators"}
football_players = [
{first_name: "Hanzla", last_name: "Radosti", team: "32ers"},
{first_name: "Tina", last_name: "Watkins", team: "Barleycorns"},
{first_name: "Alex", last_name: "Patel", team: "32ers"},
{first_name: "Jill", last_name: "Huang", team: "Barleycorns"},
{first_name: "Wanda", last_name: "Vakulskas", team: "Barleycorns"}

Если присмотреться, то можно увидеть, что некоторые спортсмены зани­

маются несколькими видами спорта. Например, Джилл Хуанг Uill
и Ванда Вакульскас

(Wanda Vakulskas)

Huang)

играют и в баскетбол, и в футбол.

472

Глава

20.

Оптимизация кода

Напишите функцию, которая принимает два массива с данными о спортс­

менах и возвращает массив с именами тех, кто занимается обоими видами
спорта. У нас это будет:

["Jill Huang", "Wanda Vakulskas"]
Несмотря на то что у некоторых игроков совпадают имена и фамилии,

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

каждого игрока из одного массива с каждым из другого, но это потребует
О( N х М) времени. Оптимизируйте его так, чтобы он выполнялся за время
О(N+М).

2.

Вы пишете функцию, которая принимает массив целых чисел от О,

до

1, 2, 3 ...

N, где нет одного целого числа. Ваша функция должна возвратить это

число.

Например, в следующем массиве есть все целые числа от О до

6,

кроме

4:

[2, 3, 0, 6, 1, 5]
Поэтому функция должна вернуть значение

4.

В следующем массиве есть все целые числа от О до

9, кроме 1:

[8, 2, 3, 9, 4, 7, 5, 0, 6]
Здесь функция должна вернуть значение

1.

Использование подхода с вложенными циклами потребует до О(№) вре­

мени. Оптимизируйте код так, чтобы он выполнялся за

3.

O(N)

времени.

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

[10, 7, 5, 8, 11, 2, 6]

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

1О долларов, во второй -

7ит.д.).

Ваша функция должна рассчитать наибольшую прибыль, которую можно
получить при совершении сделки
одна «продажа~.

-

одной «покупки~, за которой следует

473

Упражнения

В прошлом примере больше всего денег можно было бы заработать при
покупке акций по
в размере

5 долларов и
6 долларов на акцию.

продаже по

11.

Мы получили бы прибыль

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

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

Мы могли бы использовать вложенные циклы, чтобы найти прибыль от
каждой возможной комбинации купли-продажи. Но на такой алгоритм
ушло бы О(№) времени, что очень медленно для нашей популярной тор­

говой платформы. Ваша задача
выполнялся за время

4.

-

оптимизировать код так, чтобы алгоритм

O(N).

Вы пишете функцию, которая принимает массив чисел и вычисляет наи­

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

[5, -10, -6, 9, 4]
Наибольшее произведение здесь
меньших чисел:

-10

и

-

результат перемножения двух самых

-6.

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

5.

O(N).

Вы создаете программу, которая анализирует данные о температуре тела
сотен пациентов. Температура измерялась у здоровых людей, поэтому ре­

зультаты измерений варьируются от

97,0 до 99,0

градусов по Фаренгейту.

Важный момент: значения температуры в этом приложении содержат не

больше одного знака после запятой.
Вот пример массива со значениями температуры:
[98.б,

98.0, 97.1, 99.0, 98.9, 97.8, 98.5, 98.2, 98.0, 97.1]

Напишите функцию, которая сортирует эти значения в порядке возрастания.

При использовании классического алгоритма сортировки вроде Quicksoгt
этот процесс занял бы время

O(NlogN). Но здесь можно реализовать более

быстрый алгоритм сортировки.
Да, все верно. Несмотря на то что самая быстрая сортировка занимает

O(N log N)

времени, здесь все немного иначе. Почему? Потому что в этом

474

Глава

20.

Оптимизация кода

примере диапазон возмоЖ11ых значений ограничен и мы можем отсортировать
их за время

O(N).

Количество шагов при этом может быть равно

N,

умно­

женному на константу, но временная сложность алгоритма все равно будет

O(N).
6.

Вы пишете функцию, которая принимает массив неотсортированных целых

чисел и возвращает размер самой длинной возрастающей последователь­
ности из целых чисел, увеличивающихся на

1.

Например, в массиве:

[10, 5, 12, 3, 55, 30, 4, 11, 2)
самая длинная возрастающая последовательность

- 2-3-4-5,

потому что

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

10-11-12, она состоит лишь из трех целых
Наша функция должна вернуть значение 4, так как оно соответству­

ет размеру самой длинной возрастающей последовательности из чисел
этого массива.

Вот еще пример:

[19, 13, 15, 12, 18, 14, 17, 11)
Самая длинная последовательность здесь

ция вернет значение

- 11-12-13-14-15, поэтому функ­

5.

Если мы отсортируем массив, то сможем найти самую длинную возраста­
ющую последовательность, пройдя по нему всего раз. Но процесс сорти­

ровки займет

O(Nlog N) времени.
O(N).

выполнялась за время

Оптимизируйте функцию так, чтобы она

ПРИЛОЖЕНИЕ

Здесь вы найдете готовые решения заданий из раздела« Упражнения~ для каж­
дой главы.

Глава

1

Это решения упражнений, которые можно найти в разделе «Упражнения~ на

с.

45.

1.

Разберем каждый из случаев:
а)

чтение из массива всегда выполняется за один шаг;

б)

на поиск несуществующего элемента в массиве размером

100 уйдет
100 шагов: компьютеру нужно проверить каждый элемент, чтобы знать,

что искомого ТОЧНО нет;

в)

вставка значения в начало массива потребует 1О1 шага:
ния каждого элемента вправо и один

-

100 для смеще­

для вставки нового;

г)

вставка значения в конец массива всегда выполняется за один шаг;

д)

на удаление первого значения массива уходит

100 шагов: сначала ком­
99 на одну

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

е)

удаление последнего значения массива всегда выполняется за один
шаг.

2.

Разберем каждый из случаев:
а)

как и в случае с массивом, чтение из множества на основе массива вы­
полняется за один шаг;

476

Приложение. Решения к упражнениям

б)

как и в случае с массивом, поиск значения, которого нет в множестве

на основе массива, потребует выполнения

100

шагов: компьютеру

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

в)

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

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

100

шагов. Затем нужно сдвинуть все

100

элементов вправо, чтобы

освободить место для нового значения, и поместить новое значение
в начало множества. В итоге на все у нас уйдет

г)

201

шаг;

для вставки нового значения в начало множества нужно выполнить
101шаг:100 для поиска и один для вставки;

д)

на удаление первого значения множества уйдет

100 шагов, как и в слу­

чае с классическим массивом;

е)

удаление последнего значения множества выполняется за один шаг,
как и в случае с классическим массивом.

3.

Если в массиве
в нем нужно

N элементов, то на поиск всех экземпляров строки "apple"
N шагов. Если мы хотим найти только один экземпляр этой

строки, то можем остановить поиск сразу после его обнаружения. Но если
нас интересуют все экземпляры, придется проверить все элементы мас­

сива.

Глава

2

Это решения упражнений, которые можно найти в разделе «Упражнения~> на
с.

60.

1.

Линейный поиск здесь занимает четыре шага. Мы проверяем каждый эле­
мент слева направо с начала массива. Поскольку

8-

четвертый элемент,

мы обнаружим его за четыре шага.

2.

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

3.

8!

Чтобы выполнить эту задачу, нужно посчитать, сколько раз придется делить

100 ООО пополам, чтобы получить 1. Итак, при последовательном делении
100 ООО на 2 нужно выполнить 16 операций деления, пока результат не
станет равным примерно 1,53.
Значит, в худшем случае для выполнения двоичного поиска в массиве из

100 ООО элементов

нам потребуется выполнить

16 шагов.

Глава

477

4

Глава

3

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

71.

1.

О( 1). Здесь под годом

N мы можем

понимать значение, переданное в функ­

цию. Но оно не влияет на количество выполняемых ею шагов.

Обработка N элементов массива в цикле потребует выполнения

2. O(N).

Nшагов.

3. O(log N). N

соответствует значению numberOfGrains, которое передается

в функцию. Цикл выполняется, пока условие

placedGrains < numberOfGrains
остается истинным, но исходное значение placedGrains - 1, и при каждой
итерации цикла оно удваивается. Например, для достижения numberOfGrains,
равного

256,

нам пришлось бы выполнить операцию удвоения значения

placedGrains девять раз, а это значит, что наш цикл выполнялся бы девять
раз при N, равном 256. Если бы numberOfGrains было равно 512, цикл вы­
полнялся бы

10

раз, а при значении

1024 - 11.

При удвоении значения

N

количество итераций цикла увеличивается на единицу, поэтому временная

сложность алгоритма равна

4. O(N). N -

O(log N).

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

Nшагов.

5.

О( 1).

N-

это размер массива, но алгоритм выполняет фиксированное ко­

личество шагов вне зависимости от величины
числа

Глава

N,

N.

Он учитывает четность

но все равно выполняет одно и то же количество шагов.

4

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

87.

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

Python,

но их можно за­

Ruby со страницы, посвященной этой книге (https://
prag prog. com/titles/jwdsal2/source_code).
грузить на

1.

JavaScript

и

Вот заполненная таблица:
N::Jлементов

O(N)

100

100

2000

2000

O(logN)
Около

Около

7
11

О(№)

10 ООО

4 ООО

ООО

478

2.

Приложение. Решения к упражнениям

В массиве

16

элементов, поскольку

дратный корень из

3.

256

равен

16 2 равно 256

(другими словами, ква­

16).

Временная сложность этого алгоритма

- О(№), где N - это размер масси­
N элементов массива, для каждого из
цикл, который тоже обрабатывает все N

ва. Внешний цикл обрабатывает все
которых запускается внутренний

элементов массива. Получается, общее количество шагов равно №.

4.

Временная сложность следующей функции

- O(N), так как элементы в ней

перебираются только один раз:

def greatestNumber(array):
greatestNumberSoFar = array[0]
for i in array:
if i > greatestNumberSoFar:
greatestNumberSoFar = i
return greatestNumberSoFar

Глава

5

Это решения упражнений, которые можно найти в разделе «Упражнения» на

с.

104.

1.

Отбросив константы, сократим выражение до

2.

Отбросив константу, сократим выражение до О(№).

3.

Временная сложность этого алгоритма

O(N).

- O(N),

где

N-

размер массива.

Хотя он использует два разных цикла, которые обрабатывают

общее число шагов будет
менную сложность

4.

N элементов,
2N, что после отбрасывания константы дает вре­

O(N).

Временная сложность этого алгоритма

- O(N),

где

N-

размер массива.

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

алгоритм выполняет

3N шагов,
O(N).

что после отбрасывания константы дает

временную сложность

5.

Временная сложность этого алгоритма

-

О(№), где

N-

размер массива.

Хотя мы запускаем внутренний цикл только в половине случаев, этот ал­

горитм выполняет №/2 шагов. Но, отбросив константу
разить его временную сложность как О(№).

2,

мы можем вы­

Глава

479

6

Глава

6

Это решения упражнений, которые можно найти в разделе «Упражнения» на

с.

121.

Представленные здесь алгоритмы написаны на языке JavaScript, но их можно

загрузить на

Python

и

Ruby

со страницы, посвященной этой книге

(https://

pragprog .com/titles/jwdsa 12/source_code).

1.

По правилам О-нотации выражение 2№

Избавляясь от констант, мы получаем №

+ 2N + 1 сокращается до О(№).
+ N, но мы отбрасываем и N, как

слагаемое более низкого порядка.

2.

Отбросив
ДО

3.

log N как слагаемое более низкого порядка, сокращаем выражение

O(N).

Важно отметить, что функция завершается, как только мы находим пару,
сумма которой равна

10.

Выходит, лучший сценарий реализуется, когда

сумма первых двух чисел составляет

10, ведь так мы можем завершить вы­

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

Худший предполагает отсутствие двух чисел, в сумме дающих

10.

В этом

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

4.

Временная сложность этого алгоритма

равен

N,

а цикл перебирает все

N-

- O(N),

размер массива.

так как размер массива

N элементов.

Алгоритм продолжит выполнение цикла, даже если символ "Х" будет най­
ден до достижения конца массива. Мы можем оптимизировать код, если

вернем значение

true сразу после обнаружения

function containsX(string) {
for(let i = 0; i < string.length; i++) {
if (string[i] === "Х") {
return true;
}
}

return false;

"Х":

480

Приложение. Решения к упражнениям

Глава

7

Это решения упражнений, которые можно найти в разделе «Упражнения» на

с.

138.

1. N

здесь

-

это размер массива. Наш цикл предусматривает

N/2

итераций,

так как обрабатывает по два значения в рамках каждой из них. Но времен­

ная сложность функции будет

2.

Здесь определить

N

O(N), так как мы отбрасываем константу.

сложнее, так как мы имеем дело с двумя разными мас­

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

O(N). Еще можно обозначить число элементов
N, а другого - М, выразив временную сложность
мы просто складываем N и М, поэтому проще исполь­

одного массива буквой
в виде

зовать

O(N + М). Но
N для обозначения

общего количества элементов данных в обоих

массивах и выразить временную сложность в виде

3.

O(N).

В худшем случае количество шагов алгоритма равно произведению числа

символов в «строке-иголке» и «строке

-

стоге сена». Строки могут быть

разной длины, поэтому временная сложность функции равна

4.

Если

N-

O(N х

М).

это размер массива, а функция содержит три вложенных цикла,

то временная сложность равна О(№). Действительно, средний цикл вы­

полняет
х

N/2 шагов, а внутренний - N/4, так что общее число шагов равно
(N/2) х (N/4), итого №/8. Отбросив константу, мы получаем О(№).

5. N -

это количество резюме в массиве. При каждой итерации цикла мы

N

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

O(logN).

Глава

8

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

160.

Представленные здесь алгоритмы написаны на языкejavaScript, но их можно

загрузить на

Python и Ruby со страницы, посвященной этой книге (https://
pragprog.com/titles/jwdsal2/source_code).
1.

Следующая реализация сначала сохраняет значения из первого массива

в хеш-таблице, а затем сверяет с ней каждое значение второго:

function getintersection(arrayl, array2) {
let intersection = [];

Глава

481

8

let

hashTaЬle = {};

for(let i

=

0; i < arrayl.length; i++) {
true;

hashTaЫe[arrayl[i]] =

}

0; j < array2.length; j++) {
{
intersection.push(array2[j]);

for(let j

=

if(hashTaЬle[array2[j]])

}
}

return intersection;
}
Временная сложность этого алгоритма равна

2.

O(N).

Следующая реализация проверяет каждую строку в массиве. Если прове­

ряемой строки в хеш-таблице нет, она добавляется в нее. Если строка есть,

значит, она была добавлена туда ранее, что говорит о наличии дубликата!
Временная сложность этого алгоритма

- O(N):

function findDuplicate(array) {
let hashTaЬle = {};
0; i < array.length; i++) {
{
return array[i];
} else {
true;
hashTaЬle[array[i]]

for(let i

=

if(hashTaЬle[array[i]])

}
}
}

3.

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

function findMissingletter(string) {
11 Сохраняем все обнаруженные в строке буквы
let hashTaЫe = {};
for(let i = 0; i < string.length; i++) {
true;
hashTaЫe[string[i]]

в хеш-таблице:

}

11 Сообщаем о букве, которой нет в исходной строке:
let alphabet = "abcdefghijklmnopqrstuvwxyz";
for(let i = 0; i < alphabet.length; i++) {
if(!hashTaЫe[alphabet[i]]) {
return alphabet[i];
}
}

482

Приложение. Решения к упражнениям

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

1, которое указывает на то, что символ был обнаружен в стро­

ке один раз. Если символ есть в хеш-таблице, мы просто увеличиваем зна­
чение соответствующего ключа на
"е"

- 3, то символ

1.

Получается, если значение ключа

"е" встречается в строке три раза.

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

- O(N):

function firstNonDuplicate(string) {
let hashTaЫe = {};
for(let i

=

0; i < string.length; i++) {
{

if(hashTaЫe[string[i]])

hashTaЫe[string[i]]++;

} else {
hashTaЬle[string[i]]

= 1;

}
}

for(let j = 0; j < string.length; j++) {
if(hashTaЬle[string[j]] == 1) {
return string[j];
}
}
}

Глава

9

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

178.

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

грузить на

Ruby, но их можно за­
Python иjavaScript со страницы, посвященной этой книге (https://

pragprog.com/titles/jwdsal2/source_code).

1. Мы хотим быть вежливыми со звонящими и отвечать на вызовы в порядке
их получения, поэтому будем использовать очередь, которая обрабатывает
данные по принципу

2.

FIFO

(«первым пришел

Мы сможем прочитать значение

4,

-

первым ушел»).

которое после выталкивания значений

6 и 5 будет верхним элементом стека.
3.

Мы сможем прочитать значение
окажется в начале очереди.

3, которое после удаления

значений

1и2

Глава

4.

483

10

Воспользуемся преимуществом стека, который позволяет выталкивать

элементы в обратном порядке. И так, сначала поместим каждый символ

строки в стек, а затем будем выталкивать их по одному, добавляя в конец
новой строки:

def reverse(string)
stack = Stack.new
string.each_char do lcharl
stack.push(char)
end
new_string

= ""

while stack.read
new_string += stack.pop
end
return new_string
end

Глава

10

Это решения упражнений, которые можно найти в разделе «Упражнения~ на
с.

190.

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

Ruby и JavaScгipt со страницы,
pragprog.com/titles/jwdsal2/source_code).
1.

Базовый случай

- i f low > high,

Python,

но их можно за­

посвященной этой книге

(https://

то есть нам нужно остановить рекурсию,

как только значение low превысит high. Иначе функция будет выводить
числа, превышающие заданное старшее, вплоть до бесконечности.

2.

Здесь мы столкнемся с бесконечной рекурсией! Функция factorial(10)
вызовет

factorial(8), которая вызовет factorial(б), которая вызовет
factorial(4), которая вызовет factorial(2), которая вызовет factorial(0).
При этом мы не достигнем базового случая i f n == 1 и рекурсия продолжит­
ся: функция factorial(0) вызовет factorial(-2) и т. д.

3.

Допустим, значение

low равно 1, а high - 10.

При вызове

возвращает 10 + sum(l, 9) - результат прибавления
1до9. Функция

sum(l, 10) функция

10

к сумме чисел от

sum(l, 9) вызывает sum(l, 8), которая вызывает sum(l, 7)

и т. д.

Нужно, чтобы последним вызовом был sum(l, 1), в результате которого мы

хотим вернуть число

1. Это

и есть базовый случай:

484

Приложение. Решения к упражнениям

def sum(low, high):
#

Базовый

случай:

if high == low:
return low
return high + sum(low, high - 1)

4.

Здесь мы используем похожий подход, как в примере с файловой системой,
описанном в тексте главы:

def print_all_items(array):
for value in array:
#

Если текущий

элемент это "список",

то есть массив:

if isinstance(value, list):
print_all_items(value)
else:
print(value)
Перебираем все значения внешнего массива. Если какое-то из них само
окажется массивом, рекурсивно вызываем функцию для обработки этого

подмассива. Если нет, мы считаем, что достигли базового случая, и просто
выводим значение.

Глава

11

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

213.

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

Ruby, но их можно за­
Python и JavaScript со страницы, посвященной этой книге (https://

pragprog .com/titles/jwdsal2/source_code).
1.

Назовем нашу функцию

character_count.

Первым делом допустим, что она

уже реализована.

Теперь определим подзадачу. Если основная задача

"def", "ghij"],

то подзадача

-

-

массив ["аЬ", "с",

тот же массив, где нет одной из исходных

строк. Пусть подзадачей будет массив без первой строки

- ["с", "def",

"ghij"].
Теперь посмотрим, что произойдет при применении «уже реализованной»
функции к этой подзадаче. При вызове

character _count( ["с", "def",
"ghij "]) функция вернула бы 8, так как общее число символов в строках

равно восьми.

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

( "аЬ")

подзадачи.

к результату вызова функции

character_count для решения

Глава

485

11

Так выглядит одна из возможных реализаций:

def character_count(array)
#
#

Альтернативный базовый

#

Базовый случай:

случай:

return array[0].length if array.length

1

когда массив пуст:

return 0 if array.length == 0
return array[0].length + character_count(array[l, array.length - 1])
end
Обратите внимание, что наш базовый случай

- это пустой массив: количе­

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

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

2.

Представим, что функция

select_even

уже есть, и определим подзадачу.

В случае с массивом [1, 2, з, 4, 5) подзадачей будут все числа, кроме
первого. Итак, допустим, функция
и возвращает

select_even( [2, з, 4, 5)) уже работает

[2, 4).

1, поэтому достаточно вернуть [2, 4 ]. Но если
бы первым числом был О, мы верну ли бы массив [ 2, 4] с добавленным в него
Первое число в массиве равно

значением

0.

Базовый случай здесь

-

пустой массив.

Вот одна из возможных реализаций этой функции:

def select_even(array)
return [] if array.empty?
if array[0].even?
return [array[0]] + select_even(array[l, array.length - 1])
else
return select_even(array[l, array.length - 1])
end
end

3.

Каждое следующее треугольное число

-

это сумма

n (номер числа)

и пре­

дыдущего числа этой последовательности. Если наша функция называется

triangle, то мы выражаем это в виде n + triangle{n - 1).
n, равное 1.
def triangle(n)
return 1 if n == 1
return n + triangle(n - 1)
end

Базовый случай

-

486

4.

Приложение. Решения к упражнениям

Допустим, наша функция

index_of_x

уже реализована. Пусть подзадачей

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

"hex" подзадачей будет "ех".
Результат вызова

index_of_x( "ех") -

значение

задачи нужно прибавить к этому результату
символ

"h"

1.

Для решения исходной

1, так как дополнительный

в начале строки смещает "х" на одну ячейку вправо.

def index_of_x(string)
return 0 if string[0] == 'х'
return index_of_x(string[l, string.length - 1]) + 1
end

5.

Эта задача похожа на «Задачу о лестнице\> . Давайте разберем ее.
Из исходного положения у нас есть только два варианта движения: пере­
меститься на одну клетку вправо или вниз.

Значит, общее число уникальных кратчайших путей равно сумме числа
путей из клетки справа от

S и числа путей из клетки под S.

Количество путей из пространства справа от

S

равно количеству путей

в сетке из шести столбцов и трех строк:

s

f+

F
Число путей из пространства под

S равно количеству путей в сетке из семи

столбцов и двух строк:

F
С помощью рекурсии это можно выразить так:

return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1)

Глава

487

12

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

Вот полная версия кода функции:

def unique_paths(rows, columns)
return 1 if rows == 1 11 columns == 1
return unique_paths(rows - 1, columns) + unique_paths(rows, columns - 1)
end

Глава

12

Это решения упражнений, которые можно найти в разделе «Упражнения>.> на
с.

229.

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

Python

и

JavaScript

Ruby,

но их можно за­

со страницы, посвященной этой книге (https://

pragprog.com/titles/jwdsal2/source_code).

1.

Проблема здесь в двух рекурсивных вызовах функции. Но мы можем легко
сократить их до одного:

def add_until_100(array)
return 0 if array.length == 0
sum_of_remaining_numbers = add_until_100(array[l, array.length - 1))
if array[0] + sum_of_remaining_numbers > 100
return sum_of_remaining_numbers
else
return array[0] + sum_of_remaining_numbers
end
end

2.

Вот версия функции с мемоизацией:

def golomb(n, memo={})
return 1 if n == 1
i f !memo[n]

memo[n]
end

1 + golomb(n - golomb(golomb(n - 1, memo), memo), memo)

return memo[n]
end

3.

Чтобы применить мемоизацию здесь, превратим количество строк и столб­
цов в ключ, который может быть в форме простого массива

def unique_paths(rows, columns, memo={})
return 1 if rows == 1 11 columns == 1

[ rows, columns]:

488

Приложение. Решения к упражнениям

if !memo[[rows, columns]]
memo[[rows, columns]] = unique_paths(rows - 1, columns, memo) +
unique_paths(rows, columns - 1, memo)
end
return memo[[rows, columns]]
end

Глава

13

Это решения упражнений, которые можно найти в разделе «Упражнения>.> на

с.

257.

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

загрузить на

javaScript, но их можно
Python и Ruby со страницы, посвященной этой книге (https://

pragprog .com/titles/jwdsal2/source_code).
1. Мы знаем, что после сортировки самые большие числа будут в конце мас­
сива, и мы сможем просто перемножить их. Сортировка займет время

O(NlogN):
function greatestProductOfЗ(array) {
array.sort((a, Ь) => (а < Ь) ? -1 : 1);
return array[array.length - 1] * array[array.length - 2] *
array[array.length - З];
}
Глядя на этот код, мы понимаем, что в массиве есть как минимум три зна­

чения. Вы можете добавить код для обработки случаев, когда это не так.

2.

Если мы предварительно отсортируем массив, то, скорее всего, значение

каждого числа будет соответствовать его индексу: О будет находиться по
индексу О,

1-

по индексу

1 и т. д.

Затем мы сможем перебрать массив в по­

исках числа, значение которого не соответствует его индексу. Если мы его

найдем, значит, прямо перед ним должно быть искомое значение:

function findMissingNumber(array) {
array.sort((a, Ь) => (а < Ь) ? -1 : 1);
for(let i = 0; i < array.length; i++) {
if(array[i] !== i) {
return i;
}
}

return null;
}

Глава

489

13

Сортировка требует

N log N

- еще N шагов. Но мы
(N log N) + N до O(N log N), отбросив N как

шагов, а работа цикла

можем сократить выражение

слагаемое более низкого порядка.

3.

Эта реализация предполагает использование вложенных циклов и выпол­
няется за О(№) времени:

function max(array) {
for(let i = 0; i < array.length; i++) {
iisGreatestNumber = true;
for(let j = 0; j < array.length; j++) {
if(array[j] > array[i]) {
iisGreatestNumber = false;
}
}

if(iisGreatestNumber) {
return array[i];
}
}
}
Эта реализация просто сортирует массив и возвращает последнее число.

Сортировка занимает время

function max(array) {
array.sort((a, Ь) =>



O(Nlog N):
<

Ь)

? -1

1);

return array[array.length - 1];
}
Временная сложность этой реализации

- O(N),

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

function max(array) {
let greatestNumberSoFar

=

array[0];

for(let i = 0; i < array.length; i++) {
if(array[i] > greatestNumberSoFar) {
greatestNumberSoFar = array[i];
}
}

return greatestNumberSoFar;
}

так как она предполагает

490

Приложение. Решения к упражнениям

Глава

14

Это решения упражнений, которые можно найти в разделе «Упражнения~ на

с.

280.

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

Python

и

JavaScript

Ruby,

но их можно за­

со страницы, посвященной этой книге (https://

pragprog.com/titles/jwdsal2/source_code).

1.

Один из способов это сделать

def print
current_node

=

-

использовать простой цикл while:

first_node

while current_node
puts current_node.data
current_node
current_node.next_node
end
end

2.

При использовании двусвязного списка у нас есть мгновенный доступ к по­

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

-

это просто обратная

версия прошлого фрагмента:

def print_in_reverse
current_node = last_node
while current_node
puts current_node.data
current_node = current_node.previous_node
end
end

3.

Здесь мы используем для посещения узлов цикл

while. Но, прежде чем

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

def last
current_node

=

first_node

while current_node.next_node
current_node
current_node.next_node
end
return current_node.data
end

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

Глава

491

14

Первая переменная

current_node

соответствует текущему узлу, который

обрабатывается в настоящий момент. Мы отслеживаем и узел
который следует сразу после текущего, и

next_node,
previous_node, который идет перед

текущим:

Предыдущий узел

Текущий узел

Следующий узел

i

i

~~l"c"I

null

Обратите внимание, что в самом начале, когда текущий узел
самый первый, значением

current_node
previous_node будет null, так как перед первым

узлом нет других.

После настройки переменных запускаем цикл.

В рамках него мы сначала меняем ссылку текущего узла

чтобы она указывала на предыдущий
Текущий узел

Предыдущий узел

Следующий узел

i -----------, i

i"a",I\

nu{i-

( current_node) так,

(previous_node):

i
~l"c"I

1

Теперь сдвигаем все переменные вправо:
Предыдущий узел

-----------,_J
nu{i

l"a"I\

1

Текущий узел

Следующий узел

i
i
~l"c"I

Снова запускаем цикл и повторяем процесс изменения ссылки

так чтобы она указывала на

current_node,
previous_node, вплоть до конца списка. К это­

му моменту порядок следования его элементов станет обратным исход­
ному.

Реализация этого алгоритма выглядит так:

def reverse!
previous_node
current_node

=

nil
f irst_node

492

Приложение. Решения к упражнениям

while current_node
next_node = current_node.next_node
current_node.next_node

=

previous_node

previous_node = current_node
current_node
next_node
end
self .first_node
end

5.

previous_node

Хотите верьте, хотите нет, но мы можем удалить средний узел без доступа
к какому-либо из предшествующих ему.

Ниже изображены четыре узла, из которых нам доступентолько "Ь". Значит,
у нас нет доступа к "а", так как в классическом связном списке ссылки ука­

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

~~~l"d"I
i
Доступный
узел

Теперь посмотрим, как удалить узел "Ь" (даже без доступа к "а"). Для яс­
ности будем называть его узлом доступа, так как он первый из доступных
для нас.

Сначала копируем данные из узла, следующего за узлом доступа, в этот же

узел, перезаписывая данные в нем. В нашем случае это будет копирование
строки "с" в узел доступа:

Копирование "с"

"
~~~l"d"I
'

'

\

Глава

493

15

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

узла "с":

---

l "d" I
Код для выполнения этой задачи довольно лаконичный:

def delete_middle_node(node)
node.data = node.next_node.data
node.next_node
node.next_node.next_node
end

Глава

15

Это решения упражнений, которые можно найти в разделе «Упражнения~ на
с.

310.

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

JavaScript

Python,

но их можно за­

со страницы, посвященной этой книге (https://

pragprog .com/titles/jwdsal2/source_code).

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

1

~
2

~9

~4

/\

6

1 \

3

8

10

494
2.

Приложение. Решения к упражнениям

Поиск в сбалансированном двоичном дереве требует не более
Выходит, что при

за

3.

N,

равном

log(N) шагов.
1ООО, поиск должен осуществляться максимум

10 шагов.

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

def max(node):
if node.rightChild:
return max(node.rightChild)
else:
return node.value

4.

На этой схеме указан порядок вывода названий книг на экран при исполь­

зовании прямого обхода:
ф МоЬу Dick

ф Great
Expectations
®Alic:::
Wonderland

5.

/~

®

~rd

of
the Flies

Robinson
Crusoe

® Pride~

Prejudice

ф~е

Odyssey

Ниже указан порядок вывода названий книг на экран при использовании

обратного обхода:
ф МоЬу Dick

/~
® Great
Expectations

фдliс::: ~d
Wonderland

of
the Flies

®

Robinson
Crusoe

@Pride~ ф~е
Prejudice

Odyssey

Глава

495

16

Глава

16

Это решения упражнений, которые можно найти в разделе •Упражнения~ на
с.

337.
1. После вставки значения 11 куча будет выглядеть так:

@

~

(,;;;\ /

1 \

IV:J\

0

1\

0

0

1 \

0

0

0000
2. После удаления корневого узла куча будет выглядеть так:

3. Числа будут стоять в порядке убывания (в случае шах-кучи, а случае minкучи - в порядке возрастания).
Понимаете, что это значит? Вы только что открыли еще один алгоритм со­
ртировки!
Сортировка кучей

(Heapsort) -

это алгоритм, который предполагает встав­

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

Как и быстрая сортировка

(Quicksort), сортировка кучей выполняется за

O(NlogN), так как нам нужно вставитьNзначений в кучу, а на каж­
дую операцию вставки уходит log N шагов.

время

496

Приложение. Решения к упражнениям

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

Глава

17

Это решения упражнений, которые можно найти в разделе «Упражнения» на
с.

363.

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

грузить на Ruby и

JavaScript

Python,

но их можно за­

со страницы, посвященной этой книге (https://

pragprog.com/titles/jwdsal2/source_code).

1.

В этом префиксном дереве хранятся слова:

"today", "total", "we", "well"

2.

"tag", "tan", "tank", "tap",

"went".

и

Префиксное дерево, в котором хранятся слова:

"hall", "ham", "hammer", "hill"

и

"zebra",

"get", "go", "got", "gotten",

выглядит так:

~i~
D
D
D

v~
D

~

ti

ti

ш ~

3.

~
D

ei

\J
D

1/ \m 1i

D
ь~

D~D

D

ti

1i mi 1i

г~

D

ш

D

D

ш

ei

ei

ai

D

D

ш

п~

г~

ш

ш

Следующая функция начинает с указанного узла префиксного дерева

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

обработки:

def traverse(self, node=None):
currentNode = node or self .root

Глава

497

18

for key, childNode

iп curreпtNode.childreп.items():

priпt(key)

if key != "*":
self.traverse(childNode)

4.

Наша реализация автозамены сочетает в себе функции поиска
и сбора слов

(search)

( collectAllWords ):

def autocorrect(self, word):
curreпtNode = self.root
# Отслеживаем, какая часть слова пользователя уже обнаружена
# в префиксном дереве. Нам предстоит соединить ее
# с лучшим из найденных в том же дереве суффиксом
wordFouпdSoFar = ""

for char

iп

word:

# Если у текущего узла есть дочерний ключ, соответствующий
# текущему символу:

if

curreпtNode.childreп.get(char):
wordFouпdSoFar

#

Переходим

к

+=

char

этому дочернему узлу:

curreпtNode = curreпtNode.childreп.get(char)

else:
# Если текущий символ не найден среди дочерних элементов
# текущего узла, собираем все суффиксы, происходящие
# от текущего узла, и получаем самый первый из них.
# Объединяем суффикс с найденным префиксом,
# чтобы предложить пользователю вариант слова:
returп wordFouпdSoFar + self.collectA11Words(curreпtNode)[0]
# Если слово пользователя обнаружено в префиксном дереве:
returп

word

Суть в том, что сначала мы находим в префиксном дереве самый длинный

общий префикс со строкой пользователя. Оказавшись в тупике, вместо того

чтобы просто вернуть

None (как это делает функция search), мы вызываем
функцию collectAllWords для обработки текущего узла, чтобы собрать все
происходящие от него суффиксы. Затем мы берем первый суффикс масси­

ва и объединяем его с префиксом, чтобы предложить пользователю новый
вариант слова.

Глава

18

Это решения упражнений, которые можно найти в разделе «Упражнения» на

с.

419.

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

Python

и

Ruby,

но их можно за­

JavaScript со страницы, посвященной этой книге (https://

pragprog.com/titles/jwdsal2/source_code).

498
1.

Приложение. Решения к упражнениям

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

2.

При поиске в глубину мы обойдем вершины графа в следующем порядке:

A-B-E-J- F-0-C-G-K-D-H-L-M-I-N-P:
6

о

3.

При поиске в ширину мы обойдем вершины графа в следующем порядке:

A-B-C-D-E-F-G-H-I-J-K-L-M-N-0-P:
15

о

4.

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

def bfs(starting_vertex, search_value, visited_vertices={})
queue = Queue.new

Глава 18

499
visited_vertices[starting_vertex.value]
queue.enqueue(starting_vertex)
while queue.read
current_vertex

true

= queue.dequeue

return current_vertex if current_vertex.value

== search_value

current_vertex.adjacent_vertices.each do ladjacent_vertexl
if !visited_vertices[adjacent_vertex.value]
visited_vertices[adjacent_vertex.value] = true
queue.enqueue(adjacent_vertex)
end
end
end
return nil
end
Чтобы найти кратчайший путь в невзвешенном графе, мы используем ал­
горитм поиска в ширину. Его главная особенность в том, что сначала мы
обходим все соседние вершины с начальной. Именно это и поможет нам
найти кратчайший путь.
Применим этот подход к социальной сети из нашего примера. Так как поиск

в ширину начинается с обхода вершин, которые находятся рядом с верши­

ной Идриса, мы обнаружим сначала кратчайший путь до вершины Лины,
и только потом

-

более длинный путь до нее. На самом деле мы можем

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

Итак, при первом посещении каждой вершины мы знаем, что текущая

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

обязательно совпадают).

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

(через Камиля), мы можем сохранить в таблице данные о том, что кратчай­

ший путь от Идриса до Лины проходит через вершину Камиля. Эта табли­
ца похожа на

cheapest_previous_stopover_city_taЫe

из пошагового раз­

бора алгоритма Дейкстры.
На самом деле при посещении любой вершины кратчайший путь до нее от
вершины Идриса будет проходить через текущую. Мы будем хранить все
эти данные в таблице previous_vertex_taЫe.

500

Приложение. Решения к упражнениям

В конце мы сможем использовать обратную последовательность шагов от

Лины до Идриса, чтобы выстроить кратчайший путь между этими двумя
вершинами.

Так выглядит наша реализация:

def fiпd_shortest_path(first_vertex,
queue
Queue.пew
#
#

Как и

в случае

вершину,

с алгоритмом Дейкстры,

visited_vertices={})

отслеживаем в таблице

каждую

предшествующую посещаемой

previous_vertex_taЫe

#

secoпd_vertex,

Используем поиск

= {}

в ширину:

visited_vertices[first_vertex.value)

true

queue.eпqueue(first_vertex)

while queue.read
curreпt_vertex

= queue.dequeue

curreпt_vertex.adjaceпt_vertices.each

if

do

ladjaceпt_vertexl

!visited_vertices[adjaceпt_vertex.value]
visited_vertices[adjaceпt_vertex.value]

= true

queue.eпqueue(adjaceпt_vertex)

# Сохраняем в таблице previous_vertex_taЫe соседнюю
# (adjaceпt_vertex) в качестве ключа, а текущую
# (curreпt_vertex) - в качестве значения.
# Это указывает на то, что текущая вершина
# предшествует соседней

вершину

previous_vertex_taЫe[adjaceпt_vertex.value)
curreпt_vertex.value

епd
епd
епd

# Как и в случае с алгоритмом Дейкстры, обращаем порядок шагов,
# опираясь на таблицу previous_vertex_taЫe, чтобы построить
# кратчайший путь между вершинами

shortest_path = []
curreпt_vertex_value

=

secoпd_vertex.value

while curreпt_vertex_value != first_vertex.value
shortest_path Наибольшее

произведение:

20
21
28
30
42

(-5 х -4)
(3 х 7)
(-7 х -4)
(-6 х -5)
(6 х 7)

Здесь видно, что наибольшее произведение может быть получено путем

перемножения двух либо наибольших, либо 1tаиме1tъших (отрицательных)
чисел.

Зная об этом, реализуем наш алгоритм так, чтобы он отслеживал следующие
четыре числа:






наибольшее;
второе по величине;
наименьшее;

второе наименьшее.

Затем сравниваем произведение двух наибольших чисел с произведением

двух наименьших. Большее из этих двух значений и будет искомым наи­

большим произведением двух чисел массива.
Как же найти два наибольших и наименьших числа? Можно отсортировать
массив. Но сортировка выполняется за время
к показателю

O(Nlog N),

а мы стремимся

O(N).

На самом деле мы можем снова пожадничать и найти все четыре числа за

оди1t проход по массиву.

506

Приложение. Решения к упражнениям

Вот код этого алгоритма, за которым следует его объяснение:

def greatest_product(array)
greatest_number = -Float::INFINITY
second_to_greatest_number = -Float::INFINITY
lowest_number = Float::INFINITY
second_to_lowest_number = Float::INFINITY
array.each do lnumberl
if number >= greatest_number
second_to_greatest_number = greatest_number
greatest_number = number
elsif number > second_to_greatest_number
second_to_greatest_number = number
end
if number greatest_product_from_two_lowest
return greatest_product_from_two_highest
else
return greatest_product_from_two_lowest
end
end
Перед запуском цикла приравниваем значения greatest_number и second_
to_greatest_number к отрицателыюй бесконечности - начальные значения

переменных будут ниже остальных чисел в массиве.
Затем перебираем все числа. Если текущее число превышает значение

greatest_number, присваиваем этой переменной его значение.

Если мы уже

нашли второе по величине число, то обновляем значение переменной

second_to_greatest_number, присваивая ей то, которое было в переменной
greatest_number до обнаружения текущего числа. Так значение second_to_
greatest_number действительно будет вторым по величине числом.
Если текущее число меньше значения

to_greatest_number,
to_greatest_number.

greatest_number,

но больше

присваиваем текущее значение переменной

second_
second_

Глава

507

20

Точно так же находим наименьшее
числа

(lowest_number)

и второе наименьшее

( second_to_lowest_number ).

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

5.

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

21

уникаль­

ного показания температуры:

97.0, 97.1, 97.2,

97.З

... 98.7, 98.8, 98.9, 99.0

Вернемся к массиву из описания упражнения:

98.0, 97.1, 99.0, 98.9, 97.8, 98.5, 98.2, 98.0, 97.1]

[98.б,

Мы можем представить его в виде хеш-таблицы, сохранив каждое показа­
ние в качестве ключа, а количество вхождений

-

в качестве значения. Эта

таблица будет выглядеть примерно так:

=> 1, 98.0 => 2, 97.1 => 2, 99.0 => 1,
98.9 => 1, 97.8 => 1, 98.5 => 1, 98.2 => 1}

{98.б

Теперь запускаем цикл, который перебирает значения в диапазоне от
до

99,0

97,0

и проверяет хеш-таблицу, чтобы выяснить число вхождений соот­

ветствующего значения. Каждый такой поиск выполняется за время О( 1).
Теперь используем это число вхождений для заполнения данными нового

массива. Наш цикл настроен на перебор значений от

97,0 до 99,0,

значения в итоговом массиве будут стоять в порядке возрастания.
Вот как выглядит код нашего алгоритма:

def sort_temperatures(array)
hash_taЫe = {}

# Сохраняем в хеш-таблице показания
# их вхождений:
array.each do ltemperaturel
if hash_taЫe[temperature]
hash_taЫe[temperature] += 1
else
hash_taЫe[temperature]

температуры и количество

= 1

епd

end
sorted_array = []

#
#

Сначала умножаем значение температуры на

10,

работы цикла увеличивать его на целое число,

#связанных с использованием чисел с плавающей

чтобы во время
избегая ошибок,
запятой:

поэтому

508

Приложение. Решения к упражнениям

temperature
#

=

970

Перебираем в цикле

970

значения от

до

990

while temperature true, 12 => true, З => true, 55 => true,
30 => true, 4 => true, 11 => true, 2 => true}
Здесь при обнаружении числа

2 мы

можем запустить цикл, который про­

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

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

Возможно, вы еще не поняли, как это поможет нам. Допустим, у нас есть

массив [6, 5, 4, 3, 2, 1]. При обработке числа

6 мы

выясняем, что с него

восходящая последовательность не начинается. Дойдя до
ваем последовательность

вательность

4-5-6.

5-6.

Когда мы достигаем

Когда нам попадается

4,

5, мы обнаружи­

то находим последо­

3, мы выстраиваем последователь­

ность 3-4-5-6 и т. д. При этом для нахождения всех этих последовательностей

мы все равно выполняем примерно № /2 шагов.
Но мы начнем строить последовательность, только если текущее число

-

это наименьший ее элемент. То есть мы не будем строить последовательность

4-5-6,

если в массиве есть

3.

Но как узнать, действительно ли текущее число

-

наименьший элемент

последовательности? С помощью волшебного поиска!
Перед запуском цикла для поиска последовательности мы выполним один

шаг, чтобы проверить, есть ли в хеш-таблице число, которое на
текущего. Итак, если текущее число

сиве

3.

1 меньше
- 4, сначала проверяем, есть ли в мас­

Если есть, мы не начинаем строить последовательность. Чтобы не

совершать лишних шагов, начнем выстраивать последовательность только

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

def longest_sequence_length(array)
hash_taЫe

= {}

greatest_sequence_length
#

Сохраняем числа

=

0

в хеш-таблице в качестве ключей:

array.each do lnumberl
hash_taЫe[number] =

true

end
#

Перебираем все числа в массиве:

510

Приложение. Решения к упражнениям

array.each do lnumberl
#
#

-

Если текущее число

первый

элемент

(то есть при отсутствии числа,

nоследовательности,

которое на единицу меньше

#текущего):

if

!hash_taЫe[number

- 1]

# Начинаем отсчет количества элементов последовательности
# с текущего числа. Так как оно - nервое в nоследовательности,
# ее длина сейчас равна 1:
current_sequence_length = 1
# Подготавливаем текущее число (current_number)
# к использованию в цикле while:
current_number = number
# Заnускаем цикл while, который работает, пока не
# с отсутствием в хеш-таблице очередного элемента
# последовательности:
while hash_taЫe[current_number + 1]
# Переходим к следующему
current_number += 1

элементу

столкнется

последовательности:

# Увеличиваем длину последовательности
current_sequence_length += 1

на

1:

#Жадно отслеживаем наибольшее значение длины последовательности:

if current_sequence_length > greatest_sequence_length
greatest_sequence_length = current_sequence_length
end
end
end
end
return greatest_sequence_length
end
Этот алгоритм выполняет

N

шагов для построения хеш-таблицы, еще

для перебора массива и еще примерно

N

шагов

-

N-

для поиска элементов

последовательности в хеш-таблице. В общей сложности мы выполняем
около ЗN шагов, а значит, временная сложность этого алгоритма равна O(N).

Уважаемый читатель!
Вы прочитали интересную книгу о прикладных структурах данных и алгоритмах.

Для закрепления информации самое время применить полученные знания на
практике. Это можно сделать вместе с компанией КРОК, специалисты которой
приняли решение улучшить качество переводной ИТ-литературы в русско­

язычном сообществе и выполнили научное редактирование переведенного
текста книги. Если вы представитель бизнеса и потенциальный заказчик ИТ­
решений

-

профессионалы из КРОК смогут помочь вам внедрить решения,

о которых вы прочитали в книге. Если вы студент

-

приходите на практику

в КРОК и закрепляйте знания опытом. Если вы опытный специалист

-

при­

сылайте резюме и добро пожаловать в дружную команду профессионалов.
Тимур Напреев

КРОК
СОЗДАЁМ HACTOs:IЩEE,
ИНТЕГРИРУЕМ БУДУЩЕЕ
сгос.гu

КРОК - технологический партнер с комплексной
экспертизой в области построения и развития
инфраструктуры, внедрения информационных
систем, разработки программных решений
и сервисной поддержки.

Центры компетенций КРОК фокусируются
на ключевых отраслевых кластерах

-

промышлен­

ность, финансовый сектор, розничные продажи,
муниципальное управление, спорт и культура.

Ежегодно сотни проектов КРОК становятся
системообразующими для экономики
и социально-культурной сферы.