Текст
                    https://liveinternet.club


Grokking Data Structures Marcello La Rocca Foreword by Daniel Zingaro https://liveinternet.club
грокаем структуры данных Марчелло Ла Рокка Предисловие Даниэля Зингаро Выпущено при поддержке 2025 https://liveinternet.club
ББК 32.973.2-018 УДК 004.422.63 Л12 Ла Рокка Марчелло Л12 Грокаем структуры данных. — СПб.: Питер, 2026. — 288 с.: ил. — (Серия «Биб­лиотека программиста»). ISBN 978-5-4461-4267-5 Каждый разработчик знает, насколько важны структуры данных. Без них не обходится ни один серьезный проект, будь то оптимизация запросов, работа с Big Data или просто написание чистого и эффективного кода. Не зря же на собеседованиях постоянно спрашивают про деревья, хеш-таблицы и сложность алгоритмов! Вы только приступили к изучению структур данных? Хотите освежить знания, полученные в ходе обу­чения? В этой книге нет заумной математики, скучных доказательств и абстрактной теории. Вместо этого — понятные объяснения, рабочие примеры и реальные кейсы, с которыми ежедневно сталкиваются разработчики. Вы узнаете, как с помощью правильных структур данных ускорить поиск, эффективнее управлять очередями задач или, например, оптимизировать хранение данных. Книга построена по принципу «от простого к сложному»: начинается с базовых структур, таких как массивы и связанные списки, и постепенно переходит к более сложным — стекам, очередям, деревьям, хеш-таблицам и графам. Каждая глава содержит практические примеры, упражнения и наглядные иллюстрации, которые помогают закрепить материал. Вся теория подкреплена примерами на Python — одном из главных языков современной разработки. Если вы хотите не просто использовать структуры данных, а понимать их и применять осознанно — эта книга для вас. 16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.) ББК 32.973.2-018 УДК 004.422.63 Права на издание получены по соглашению с Manning Publications. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. В книге возможны упоминания организаций, деятельность которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram и др. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими. ISBN 978-1633436992 англ. ISBN 978-5-4461-4267-5 Authorized translation of the English edition © 2024 Manning Publications. This translation is published and sold by permission of Manning Publications, the owner of all rights to publish and sell the same. © Перевод на русский язык ООО «Прогресс книга», 2025 © Издание на русском языке, оформление ООО «Прогресс книга», 2025 © Серия «Библиотека программиста», 2025 https://liveinternet.club
Оглавление От издательства . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 О научном редакторе русского издания . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 Предисловие . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 Введение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 Благодарности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 О книге . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 Для кого эта книга . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Структура книги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . О коде в книге . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Форум liveBook . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Другие онлайн-ресурсы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 17 19 22 22 Об авторе . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 Глава 1. Знакомство. Почему стоит изучать структуры данных . . . . 24 Знакомство с книгой . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Что такое структуры данных? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Какое мне дело до структур данных? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Как использовать структуры данных в проекте? . . . . . . . . . . . . . . . . . . . . https://liveinternet.club 24 26 27 33
6  Оглавление Структуры данных в действии . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35 Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 Глава 2. Статические массивы. Создаем свою первую структуру данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 Что такое массив? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Когда мне понадобятся массивы? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Значения и индексы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Массивы в Python . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Операции с массивами . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Массивы в действии . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 42 45 47 49 55 59 Глава 3. Отсортированные массивы. Цена ускорения поиска . . . . . 60 Для чего сортировать массивы? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60 Реализация отсортированных массивов . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 Глава 4. Нотация «О-большое». Оценка эффективности алгоритмов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 Как выбрать лучший вариант? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Нотация «О-большое» . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Пример асимптотического анализа . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 73 84 88 Глава 5. Динамические массивы. Работа с наборами данных динамического размера . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89 Ограничения статических массивов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90 Как увеличить размер массива? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93 Стеллаж с трофеями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93 Нужно ли еще и сокращать массивы? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99 Реализация динамического массива . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108 Глава 6. Связанные списки. Гибкая динамическая коллекция . . . . 109 Связанные списки и массивы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110 Односвязные списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112 https://liveinternet.club
Оглавление  7 Отсортированные связанные списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Двусвязные списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Циклические связанные списки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122 124 131 134 Глава 7. Абстрактные типы данных. Проектирование простейшего контейнера — мультимножества . . . . . . . . . . . . . . . 136 Абстрактные типы данных и структуры данных . . . . . . . . . . . . . . . . . . . . Контейнеры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Простейший базовый контейнер: мультимножество . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137 142 145 151 Глава 8. Стеки. Накопление данных перед обработкой . . . . . . . . . . . 153 Стек как АТД . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Стек как структура данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Реализация на базе связанного списка . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Теория и реальность . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Другие применения стека . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154 157 160 163 166 170 Глава 9. Очереди. Сохранение информации в порядке поступления . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172 Очередь как абстрактный тип данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Очередь как структура данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Реализация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Как насчет динамических массивов? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Другие применения очередей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 172 176 183 190 192 193 Глава 10. Приоритетные очереди и кучи. Обработка данных с учетом уровня приоритетности элементов . . . . . . . . . . . . . . . . . . 195 Расширение очередей введением приоритета . . . . . . . . . . . . . . . . . . . . . Приоритетные очереди как структуры данных . . . . . . . . . . . . . . . . . . . . . Реализация кучи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Приоритетные очереди в действии . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . https://liveinternet.club 196 199 206 217 219
8  Оглавление Глава 11. Двоичные деревья поиска. Сбалансированный контейнер . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 221 Что делает дерево деревом? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Двоичные деревья поиска . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Сбалансированные деревья . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222 225 238 241 Глава 12. Словари и хеш-таблицы. Как создавать и использовать ассоциативные массивы . . . . . . . . . . . . . . . . . . . . . 243 Проблема словаря . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Структуры данных, реализующие словари . . . . . . . . . . . . . . . . . . . . . . . . . Хеш-таблицы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Хеширование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Разрешение конфликтов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 244 246 248 252 255 263 Глава 13. Графы. Обучаемся моделировать сложные взаимосвязи данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 265 Что такое граф? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Реализация графов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Поиск в графе . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Что дальше? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . https://liveinternet.club 266 272 276 284 285
Хорошие программисты думают о структурах данных и взаимосвязях между ними. Линус Торвальдс https://liveinternet.club
От издательства Мы выражаем огромную благодарность компании КРОК за помощь в работе над русскоязычным изданием книги и вклад в повышение качества перевод­ ной литературы. Ваши замечания, предложения, вопросы отправляйте по адресу comp@ piter.com (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! На веб-сайте издательства www.piter.com вы найдете подробную инфор­ мацию о наших книгах. О научном редакторе русского издания Юрий Шашкин — руководитель группы разработки и тестирования в ком­ пании КРОК. Опыт в ИТ более 7 лет. За это время успешно реализовал десятки сложных проектов в различных бизнес-областях. https://liveinternet.club
Предисловие В наши дни у программистов есть возможность работать более эффективно, чем когда-либо, полагаясь на высокоуровневые языки программирования, высокоуровневые библиотеки и инструменты, а также генеративный ИИ. И знаете что? Мне это нравится. Важно, что мы формируем свое сообщество и делаем программирование доступным более широкому кругу людей. Если какая-нибудь хитроумная библиотека глубокого обучения, написанная на Python, поможет людям, которые не могут как следует использовать ИИ, — я только «за». Но при этом я все равно считаю, что многим полезно знать фундамен­ тальные структуры данных и алгоритмы, которые лежали в основе инфор­ мационных технологий последние 60 лет и будут сохранять свое значение еще очень долго. Похоже, какой задачей ни займись — всегда всплывает фундаментальный вопрос: а как же организовать данные в интересах эффек­ тивности? Почему мой код так медленно работает? Не воспользоваться ли связанным списком вместо массива? Хеш-таблицы вроде должны работать быстро — что не так с хеш-таблицей в моей программе? И главное, этот материал можно однажды изучить (а в ближайшее время он не изменится), а затем применять во всех своих проектах в области про­ граммирования или на собеседованиях по программированию. Изучать структуры данных так же важно, как и алгоритмы. Марчелло Ла Рокка показывает в главе 1, что структуры данных и алгоритмы сплетены на­ столько тесно, что изучать их лучше вместе. Из этой книги вы узнаете и о са­ мых важных структурах данных, и о работающих с ними алгоритмах. Если вам нужна сопутствующая литература, полностью посвященная алгоритмам, https://liveinternet.club
12  Предисловие и вам нравится стиль серии «Grokking»1, я рекомендую также ознакомиться с книгой Aditya Bhargava (Адитья Бхаргава) «Grokking algorithms»2. Я был рецензентом от издательства книги Марчелло «Грокаем структу­ ры данных», и у нас было немало обсуждений в ходе работы над ней. Могу лично подтвердить, что Марчелло по-настоящему заботится и глубоко ува­ жает и обучающихся, и изучаемый материал. Мои поздравления Марчелло с мастерским представлением структур данных в хорошо узнаваемом, ос­ нованном на примерах и графически насыщенном стиле серии «Грокаем». Также хочу подчеркнуть устремленность Марчелло действительно на­ учить структурам данных. Из оглавления видно, что массивам посвящены целых три главы. Кто-то удивится: «Серьезно, три главы? Неужели массивы настолько сложны?» Вообще говоря, даже у такой «простой» структуры данных за кулисами происходит много всего, а у Марчелло хватает терпения и педагогического мастерства аккуратно изложить все эти детали в доступ­ ной манере. Аплодирую вам за то, что посвятили себя изучению материала фунда­ ментальных основ computer science, что нашли время — посреди последних грандиозных новинок — вооружиться знаниями, которые будете применять снова и снова. Счастливого гроканья! Даниэль Зингаро, Университет Торонто 1 2 Название серии на русском языке — «Грокаем», все книги выходили в издательстве «Питер». Бхаргава А. «Грокаем алгоритмы», 2-е изд. СПб., издательство «Питер». https://liveinternet.club
Введение В 2016 году я регулярно писал в профессиональные блоги, в основном по теме JavaScript и его фреймворков. Мне это нравилось и помогало еще глуб­ же понять язык, которым я постоянно пользовался в своей работе в Twitter. Но в какой-то момент я спросил себя, та ли это тема, в которую я хочу по­ грузиться. Это был своего рода риторический вопрос, потому что еще со второго курса колледжа я знал: то, что меня цепляет, — это алгоритмы, структуры данных и оптимизация. Так началось мое пятилетнее путешествие, результатом которого стала публикация моей первой книги «Advanced Algorithms and Data Structures»1 (Manning, 2021). Весной 2023 года я не планировал писать книги в ближайшем будущем. Но возможность поработать над «Грокаем структуры данных» подвернулась как раз, когда у меня было больше свободного времени, чем обычно, — почти по счастливой случайности. Я понимал, какие трудности меня ожидают, но был готов к ним и решил сосредоточиться и написать черновик в ближайшие шесть месяцев. Задача была непростой, потому что эта книга принципиально отличается от «Про­ двинутых алгоритмов и структур данных». Моя первая книга была глубо­ ким погружением в серьезные темы, а цель серии «Грокаем» — представить вопросы, независимо от степени их сложности, в легкой для понимания манере максимально широкой аудитории. Это означало, что мне нужно пере­ смотреть свой подход и сосредоточиться на других моментах. 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
14  Введение Первым делом надо было выстроить такую структуру книги, которая вела бы читателя за собой и постоянно поддерживала его интерес. Я решил начать с базовой информации о массивах и связанных списках, посвятить какое-то время подробному изложению этих тем, а потом уже выстраивать вокруг них более сложные структуры данных. К чему я стремился на протяжении всей книги — так это представить все описанные здесь структуры данных под разными углами: конечно, теорию каждой, чтобы было понятно, как они все работают, но также и практиче­ ское применение — как они могут облегчить жизнь и повысить надежность кода. Также я объясняю, зачем нужна каждая из структур данных и почему в той или иной ситуации стоит предпочесть одну структуру другой, если есть выбор. Книги серии «Грокаем» отличаются неповторимым форматом, и я сле­ довал этой традиции, особенно по части демонстрационной истории для каждой главы — чтобы побудить читателя проникнуться проблемой и со­ переживать героям, — а также иллюстраций, которых здесь больше, чем в обычных книгах. При написании книги, как и во многих проектах, часто приходится ис­ кать баланс между разными аспектами. Я решил сфокусироваться на осно­ вательном и ясном объяснении каждой структуры данных, предоставляя прочный фундамент для их освоения. Надеюсь, такой подход поможет вам ознакомиться со структурами данных, а если вы уже знакомы с этой увле­ кательной темой — возможно, углубить ее понимание и открыть для себя что-то новое. И что еще важнее, я надеюсь, что эта книга вдохновит вас и побудит увлечься структурами данных и алгоритмами — так же, как меня вдохновляли определенные классические книги по этой теме. https://liveinternet.club
Благодарности Книга появилась в результате совместных усилий многих людей. Это была воистину командная работа, так что у меня длинный список тех, кого бы я хотел поблагодарить. Прежде всего хочу выразить свою благодарность Даниэлю Зингаро (Daniel Zingaro), рецензенту от издательства. Дэн — тот, благодаря кому я написал эту книгу. Он был рядом на каждом шагу: помогал, направлял, перепроверял и планировал. Порядок и структура глав — результат наших многочисленных встреч. Он всегда был на связи, когда мне нужна была по­ мощь, порой выходя за рамки своих должностных обязанностей, — только чтобы упростить мне задачу. Работа с ним была по-настоящему приятной и вдохновляющей. Короче говоря, я бы не справился без помощи Дэна. Благодарю также ведущего редактора Марину Майклз (Marina Michaels), чей вклад оказал огромное влияние на качество рукописи и организацию всего процесса. Спасибо за терпение, за рекомендации и обратную связь, благодаря которым книга стала намного лучше. Также хочу поблагодарить Бо Карнза (Beau Carnes), разработчика про­ граммного обеспечения и преподавателя с freeCodeCamp.org, научного редак­ тора книги: твои отзывы всегда были исключительно ценными и глубокими. Также спасибо редактору-корректору Герману Гонзалесу-Моррису (German Gonzalez-Morris) за терпеливый поиск ошибок и нестыковок. Также хочу выразить признательность всем работникам издательства Manning, благодаря которым стал возможен выход этой книги. Огромное спасибо рецензентам: Ганешу Фалаку (Ganesh Falak), Ганешу Сваминатану (Ganesh Swaminathan), Джонатану Камаре (Jonathan Camara), Джонатану Уомаку (Jonathan Womack), Коллину Трухильо (Kollin Trujillo), Максиму https://liveinternet.club
16  Благодарности Волгину (Maxim Volgin), Наджибу Арифу (Najeeb Arif), Навьоту Сингху (Navjot Singh), Пабло Эррере Дж., (Pablo Herrera J.), Патрику Регану (Patrick Regan), Пурви Шетти (Poorvi Shetty), Рахулу Кавале (Rahul Kavale), Ритобрате Гхошу (Ritobrata Ghosh), Ромеллю Иэну де ла Крусу (Romell Ian De La Cruz), Салли Цун (Sally Tsung), Саше Саньковой (Sasha Sankova), Саймону де Бонису (Simone De Bonis), Саймону Сгуацце (Simone Sguazza), Сорену Шеллхоффу (Sören Schellhoff), Таму Тханю Нгуену (Tam Thanh Nguyen), Татьяне Кома­ ристой (Tatiana Komaristaia), Веронике Берман (Weronika Burman), Уильяму Джамиру Силве (William Jamir Silva) и Илуню Чжану (Yilun Zhang). Все ваши предложения помогли улучшить эту книгу. Также мне хотелось бы поблагодарить всех, кто поддерживал меня в этом приключении. С самого начала целью издательства Manning (как и моей) было донести книгу до читателя в течение года. Это означало, что мне при­ шлось полностью сосредоточиться на рукописи — за счет своей частной жизни и моих невероятно понимающих и поддерживающих меня близких и друзей. Как обычно, хочу особо отметить людей, которые помогли мне стать тем, кто я есть — как человек и как ученый. Конечно же, это преподаватели моих колледжа и школы: мне повезло получить хорошее образование в об­ разовательной системе Италии, основанной на праве на образование, а моя alma mater — Катанский университет — предлагает учебную программу computer science мирового уровня. Я хочу, чтобы эта образовательная си­ стема совершенствовалась и далее. Надеюсь, доступ к образованию станет более справедливым и равноправным в глобальном масштабе и обеспечит наилучшее образование детям, независимо от национальности и матери­ ального положения. А больше всего я хочу поблагодарить свою мать — за то, что поддержива­ ла меня в период учебы ценой многих личных жертв и всегда поощряла меня мечтать о большем. Часть заслуг за все ценное в этой книге по праву принад­ лежит ей: без ее поддержки у меня просто не сложилась бы такая карьера. https://liveinternet.club
О книге Для кого эта книга Эта книга — для начинающих: если вы студент, выпускник или джуниор, который хочет больше узнать о структурах данных, подход книги может показаться вам интересным. Охват вопросов — структуры данных началь­ ного уровня и некоторые более продвинутого; я не вдаюсь в математические детали и доказательства — в книге почти нет математики. Мы рассмотрим основные структуры данных, которые необходимо по­ нимать, прежде чем переходить к изучению более сложных алгоритмов и структур. Обсудим ключевые идеи, лежащие в их основе, поймем, как они работают и как использовать их в повседневной практике. Книга может стать идеальным вариантом, если эти концепции вам пока незнакомы, если вы хотите освежить в памяти темы, которые изучали в учеб­ ном заведении, если хотите подготовиться к собеседованию по программи­ рованию или просто повысить качество и эффективность своего кода. За более подробным и глубоким обсуждением этой темы (после того как прочтете эту книгу) обращайтесь к другой моей книге — «Advanced Algorithms and Data Structures»1. Структура книги Книга разбита на 13 глав. Большинство из них организовано по принципу «одна глава — одна структура данных». Если вы новичок, читайте книгу 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
18  О книге по порядку. Каждая глава строится на материале предыдущих. Например, погружение в тему массивов в главах 2 и 3 или в основы асимптотического анализа в главе 4 поможет разобраться в более сложных структурах данных из последующих глав. Прочитав книгу первый раз, вы дальше можете использовать ее как справочник и переходить прямо к той структуре данных или теме, которые вам нужно освежить в памяти. Впрочем, читателям промежуточного или продвинутого уровня тоже стоит просмотреть главы по порядку, по крайней мере один раз. Давайте подробно пройдемся по главам. zzГлава 1 — постепенное введение в тему структур данных с обсуждением того, когда, как и почему выбирать те или иные структуры. zzГлава 2 знакомит с массивами, демонстрируя, как они работают и какие типичные операции поддерживают. В этой главе мы сосредоточимся на массивах статических размеров. zzГлава 3 — рассматриваем отсортированные массивы: как воспользовать­ ся их преимуществами и справиться с их недостатками. Вы получите представление о двоичном поиске и узнаете, почему он работает лучше линейного. zzГлава 4 представляет нотацию «О-большое» и асимптотический анализ и объясняет, как с их помощью можно сравнить эффективность произ­ вольных алгоритмов при решении той или иной задачи. Затем на основе этих концепций мы продемонстрируем очевидные преимущества двоич­ ного поиска перед линейным. zzГлава 5 завершает тему массивов, описывая, как можно создать иллюзию массива динамических размеров без ущерба для его способности к мас­ штабированию. zzГлава 6 — речь идет о связанных списках, которые можно использовать вместо массивов для последовательного хранения данных. На протяже­ нии всей главы мы сравниваем связанные списки с массивами и при­ меняем то, что узнали в главе 4, чтобы понять, в каких случаях какая из структур имеет преимущество перед другими. zzГлава 7 проясняет различия между структурами данных, абстрактными типами данных и реализациями. Затем теория применяется на конкрет­ ных примерах, после чего вводятся понятия контейнеров как категории и мультимножества как простого контейнера. zzВ главе 8 представлен стек — контейнер, реализующий политику LIFO. Здесь объясняется, как он работает и как может быть реализован. Также описаны некоторые практические применения стеков. https://liveinternet.club
О коде в книге  19 zzГлава 9 представляет очередь — контейнер, реализующий политику FIFO, и показывает ее отличия от стека. Далее обсуждаются разные реализации на базе как массивов (линейных и циклических очередей), так и связан­ ных списков. zzГлава 10: обобщаются концепции очередей и приоритетных очередей. Объясняется концепция приоритета, API абстрактного типа данных, а также представлена двоичная куча — самая распространенная реали­ зация приоритетных очередей. zzГлава 11 — первая глава, выходящая за рамки контейнеров. В ней рас­ сматриваются деревья — класс структур данных, и особое внимание уде­ ляется деревьям двоичного поиска — структуре данных, которая может обеспечить хороший баланс эффективности всех основных операций (вставка, удаление, поиск). zzГлава 12: представлен словарь — абстрактный тип данных; обсуждается возможность его реализации посредством структур данных, описанных в предыдущих главах. Затем речь идет о хеш-таблицах: объясняется, как они работают и почему лучше подходят для реализации словарей. zzГлава 13 завершает книгу знакомством с графами — важнейшей структу­ рой данных. После определения понятия и описания основных свойств графов обсуждаются две возможные стратегии их реализации. Исследу­ ются алгоритмы поиска BFS и DFS. В большинстве глав есть упражнения. В одних случаях предлагается реа­ лизовать что-то из того, что обсуждалось в главе, в других ставятся более абстрактные и неоднозначные вопросы. Оба типа заданий — хорошая возможность проверить свое понимание темы, поэтому я рекомендую по­ святить хотя бы несколько минут попыткам их решить. Хотя в книге нет ответов к упражнениям, я включил обсуждения этих заданий и подсказки к их решениям в репозиторий книги на GitHub. О коде в книге Первым непростым решением, которое мне пришлось принять, был выбор языка программирования для примеров. В своей предыдущей книге о струк­ турах данных я использовал в демонстрационных фрагментах псевдокод и предоставлял сопроводительный код на нескольких языках. Смысл был в том, чтобы не привязывать книгу к какому-то одному языку и подчеркнуть, что алгоритмы — это более высокий уровень, чем реализации, и что они не зависят от языка программирования. У такого решения были свои недостатки. В частности, тестировать псев­ докод — это сложно и чревато ошибками, и для читателей язык псевдокода https://liveinternet.club
20  О книге может стать не меньшим препятствием, чем новый для них язык програм­ мирования. А то и бˆольшим. И для этой книги я решил выбрать Python. Почему? Python — один из самых распространенных языков программирования. Его учат в университетах и на курсах, так что вполне вероятно, что читатели (и особенно новички!) уже в какой-то степени владеют им. Кроме того, у Python свободная типизация и простой синтаксис с ми­ нимальной избыточностью, благодаря чему он компактнее других языков со статической типизацией — это важно при ограниченном пространстве вроде книжной страницы. Python легко использовать и как императивный, и как объектно-ориенти­ рованный язык, и он отлично подходит для быстрого построения прототипов, что стало еще удобнее благодаря таким замечательным инструментам, как блокноты Jupyter. Наконец, для Python создана обширная консолидирован­ ная экосистема библиотек, охватывающая и структуры данных, и машинное обучение, и визуализацию, и многое другое — даже квантовые вычисления. Объектно-ориентированный подход Хотя Python допускает императивный подход к программированию, когда глобальные функции принимают в качестве аргумента данные, предназна­ ченные для обработки, в книге я в основном использую объектно-ориенти­ рованное программирование (ООП). Хотя ООП бывает менее компактным и добавляет сложности по срав­ нению с императивной парадигмой, у него есть несколько ключевых пре­ имуществ: zzабстракция — ООП позволяет абстрагировать сложные системы в более простые, более управляемые объекты; zzинкапсуляция — ООП посредством инкапсуляции скрывает внутренние подробности реализации и раскрывает только необходимые интерфейсы, что четко разделяет внутреннее устройство объекта и его использование извне; zzмодульность и возможность повторного использования кода — прямым следствием инкапсуляции данных и поведения в объектах является то, что ООП поддерживает модульное проектирование: объекты легко ис­ пользовать повторно в различных узлах программы или других проектах; zzсопровождение и масштабируемость кода — и то и другое оптимизиру­ ется посредством ООП. Это далеко не все достоинства ООП. ООП не идеально, и оно не решит всех проблем. Возможно, вы предпочитаете другие успешные подходы или про­ https://liveinternet.club
О коде в книге  21 сто привыкли к ним. Какие-то из них вовсе не исключают ООП и вполне совместимы с ним — например, многие идеи из функционального програм­ мирования (ФП) можно интегрировать в ООП. Именно это я и делаю здесь. Scala — пожалуй, лучший пример языка, в котором эти два подхода со­ существуют и дополняют друг друга. Python не является языком ФП в чистом виде, но поддерживает некоторые концепции ФП и достаточно универсален, чтобы допускать различные стили программирования. Тесты, стиль и упрощения Создание книги требует компромиссов. Прежде всего, нынешний читатель ограничен во времени, поэтому автору надо с умом выбирать, что включать в книгу. Но это еще не все: чтобы прояснить какие-то понятия, автору ино­ гда надо сфокусироваться на важном и не утонуть в деталях, даже если это подразумевает чрезмерное упрощение. Так как я хотел сделать свой код максимально понятным для читателя, я привел в книге простейшую работоспособную версию структур данных. Я опустил такие подробности, как оптимизация производительности, «за­ хламление» памяти (memory loitering) или безопасность потоков (thread safety). Конечно, в реальных приложениях эти детали важны, но они бы только отвлекли читателя от ключевой темы — как работает структура данных. У кода в книге также нет аннотаций типов (type hints). Но их можно найти в версии, размещенной на GitHub. Я сделал это, чтобы код не был слишком громоздким и не перегружал материалом новичков, которые могут быть не­ знакомы с аннотациями типов в Python. Важность тестов трудно переоценить. Тестирование кода крайне необ­ ходимо по многим причинам. Хотя оно не гарантирует полной защиты от ошибок, тесты с высоким коэффициентом покрытия определенно полезны и в настоящем, и в будущем. В текущем моменте с их помощью можно обнаружить ошибки, если таковые имеются, и перепроверить требования и логику программы. В будущем тесты помогут вам и вашей команде со­ провождать код, а при рефакторинге они могут предупредить вас, если вы что-то случайно сломаете. Весь код в книге тщательно протестирован. Тесты часто оказываются более длинными и многословными, чем код: соотношение 3:1 по количеству строк вполне типично. И хотя вы не найдете тестов в книге, они доступны в репозитории GitHub. Наконец, в книге много примеров исходного кода как в отдельных ли­ стингах, так и прямо в тексте. В обоих случаях исходный код набран моноширинным шрифтом, как здесь, — для контраста с остальным текстом. https://liveinternet.club
22  О книге Во многих случаях исходный код переформатирован: я добавил перено­ сы строк и изменил отступы, чтобы адаптировать его к формату книжной страницы. В отдельных случаях даже этого было недостаточно, и в листин­ ги приходилось включать маркеры продолжения строки (➥). Кроме того, я часто удалял из листингов комментарии к исходному коду, когда в тексте приводилось его описание. Многие листинги сопровождаются выносками к коду, в которых подчеркиваются важные моменты. Исполняемые фрагменты кода можно загрузить из электронной версии книги: https://livebook.manning.com/book/grokking-data-structures. Полный код примеров из книги можно загрузить с сайта Manning: https://www.manning. com/books/grokking-data-structures, а также с GitHub: https://github.com/mlarocca/ grokking_data_structures. Форум liveBook Приобретая книгу «Грокаем структуры данных», вы также получаете бес­ платный доступ к платформе для онлайн-чтения liveBook издательства Manning (на английском языке). Эксклюзивные возможности liveBook по­ зволяют оставлять комментарии как к книге в целом, так и к отдельным ее разделам или абзацам. Можно легко делать заметки для себя, задавать технические вопросы и отвечать на них, а также получать помощь от ав­ торов и других пользователей. Чтобы получить доступ к форуму, посетите страницу https://livebook.manning.com/book/grokking-data-structures/discussion. Ин­ формацию о форумах Manning и правилах поведения на них см. на https:// livebook.manning.com/discussion. В рамках своих обязательств перед читателями издательство Manning предоставляет ресурс для содержательного общения читателей и авторов. Эти обязательства не подразумевают конкретную степень участия автора, которое остается добровольным (и неоплачиваемым). Задавайте авторам хорошие вопросы, чтобы им было интересно участвовать в диалоге! Форум и архивы обсуждений доступны на сайте Manning, пока книга продолжает издаваться. Другие онлайн-ресурсы Полный код Python, представленный в книге, а также тесты кода можно найти в репозитории книги на GitHub: https://github.com/mlarocca/grokking_ data_structures. Мы также работаем над тем, чтобы добавить реализации на других языках программирования, включая C# и Java, так что время от времени заглядывайте в репозиторий. https://liveinternet.club
Об авторе Марчелло Ла Рокка — ученый-исследователь и инженер-программист. В качестве главного инженера-программиста он участвовал в разработке крупномасштабных веб-приложений и инфраструктуры машинного об­ учения в таких компаниях, как Twitter, Microsoft и Apple. Марчелло также работал над прикладными исследованиями как в академической сфере, так и в промышленности. В его работе и интересах центральное место занима­ ют графы, алгоритмы оптимизации, генетические алгоритмы и машинное обучение. Он разработал адаптивный алгоритм сортировки Neatsort (см. «NeatSort — A practical adaptive algorithm», M. La Rocca, D. Cantone, 2014, https://mng.bz/aEMx). https://liveinternet.club
1 Знакомство. Почему стоит изучать структуры данных В этой главе Знакомство с книгой 9 Что такое структуры данных 9 Для чего нужны структуры данных 9 Примеры полезных структур данных 9 9 9 9 9 9 9 Пошаговое руководство по применению в проекте структур данных Структуры данных правят миром: информация — валюта интернет-эпохи, а структуры данных необходимы для обработки и осмысления информации. Структуры данных позволяют осознанно формировать данные и запраши­ вать именно то, что нам требуется. Знакомство с книгой Добро пожаловать в «Грокаем структуры данных»! Я с огромным удоволь­ ствием буду сопровождать вас в путешествии по миру структур данных. В этой книге я хочу развеять некоторые заблуждения относительно структур данных: на самом деле они в высшей степени полезны в вашей повседневной работе. Даже если вы не ученый, от них многое зависит, а из­ https://liveinternet.club
Знакомство с книгой  25 учить их не так уж сложно: чтобы понять структуры данных и использовать их, совершенно необязательно быть знатоком математики! Во время нашего путешествия я покажу, что структуры данных — вовсе не сухая и скучная теория. Они настолько глубоко вошли в нашу жизнь, что вы наверняка регулярно пользуетесь ими, даже не подозревая об этом. Помимо программирования, вы использовали или видели в действии некоторые из описанных структур, которые можно найти в обычных жизненных ситуациях. Структуры данных вокруг нас Не верите? Можно было бы добавить интриги, поспорив на что-нибудь, но это было бы нечестно с моей стороны! Давайте разберемся. Вы же бывали в продуктовом или универмаге? Вот вы наполняете тележку товарами — пожалуйста, это контейнер. Но какой именно контейнер? Не хочу спойлерить — вы сможете ответить, прочитав книгу. Я структура данных! 6 1 0 И я тоже структура данных! 9 3 7 4 Итак, набрав все, за чем пришли, вы отправляетесь к кассиру — заплатить. А пока расплачиваются предыдущие покупатели, вы фактически находитесь в другой структуре данных — в очереди! Ну как, мне удалось вас убедить? Если вы разработчик программного обеспечения, то убедить вас будет еще проще, потому что, если вы пишете код, то как минимум пользовались массивами. Не говоря уже о том, что если вы читаете цифровую версию этой книги, то, конечно, ваш ридер ис­ пользует множество структур данных для хранения страниц книги, ее слов, добавленных вами закладок и т. д. Структуры данных для всех С помощью этой книги структуры данных сможет освоить кто угодно, не­ зависимо от подготовки. Для этого не понадобится высшая математика, не понадобится вводный курс программирования да и любой другой учебный курс — необязательно быть ниндзя кодинга, хотя и могут пригодиться неко­ https://liveinternet.club
26  Глава 1. Знакомство торые знания Python. Эта книга, как и любая другая книга серии «Грокаем», поможет вам понять, что к чему: она объяснит, что такое структура данных, познакомит вас с основными структурами и подскажет, как объективно ре­ шить, какая структура данных лучше подойдет для вашей задачи. Эта книга для новичков, то есть не предполагает предварительных знаний и полагается на вашу интуицию и зрительную память. Впрочем, даже если вы уже зна­ комы с предметом, то, возможно, сочтете полезным для себя отточить свои навыки и еще глубже понять какие-то вопросы. Что такое структуры данных? Если вы читаете эту книгу, то, вероятно, уже знаете, что мы живем в так называемую эпоху данных — эру, в которой данные стали неотъемлемой частью на­ шего существования. Нашу жизнь наводнила информация, производство которой растет с беспрецедентной, экспоненциальной скоро­ стью, подпитываемой технологическими до­ стижениями. Этот поток данных меняет наш образ жизни, работу и взаимоотношения. Чтобы не утонуть и сориентироваться в этом огромном объеме информации, надо ее как-то упорядочить. Вот тут-то и выручают структуры данных. Структуры данных — это способ организации и хранения информации на компьютере или в программе. Они помогают эффективно управлять и оперировать данными. Допустим, вы хотите узнать, есть ли ваш школьный друг на Facebook. Вы сможете это сделать — только потому, что есть структура данных, которая упорядочивает информацию таким образом, что поиск среди миллиарда пользователей становится простым и быстрым. Алгоритмы и структуры данных О структурах данных часто упоминают в связи с алгоритмами. Собственно, настолько часто, что вы можете спросить: а не одно ли это и то же. Нет, ал­ горитмы и структуры данных — разные вещи, хотя между ними существует тесная связь. Алгоритм — это набор четко прописанных команд, пошаговая процеду­ ра, предназначенная для решения конкретной проблемы или выполнения определенной задачи. В нашем примере с Facebook используется алгоритм, который перебирает имена всех пользователей и предлагает совпадения с запросом или наиболее близкие результаты. https://liveinternet.club
Какое мне дело до структур данных?  27 Структура данных — это способ организации и хранения данных на компьютере или в языке программирования. Она определяет, как элементы данных соотносятся друг с другом, какие операции с ними можно выпол­ нять, а также правила и ограничения относительно доступа к данным и воз­ можности их изменять. Данные пользователей Facebook хранятся в базе, организованной так, чтобы обеспечить эффективный поиск по именам. ПРИМЕЧАНИЕ Понятие алгоритма используется для описания операций, вы­ полняемых со структурами данных. Используя определенную аналогию, структу­ ры данных подобны существительным, тогда как алгоритмы — скорее глаголы. Структуры данных и алгоритмы взаимосвязаны — так же, как осмысленному предложению в английском языке для описания действия нужны субъект, объект и глагол. 4 2 3 1 0 0 1 2 3 4 Несортированный массив (существительное) 0 1 2 3 4 Быстрая сортировка (глагол) 0 1 2 3 4 Отсортированный массив (существительное) Алгоритмы, преобразующие данные, — как глаголы, оперирующие с существительными1 Структуры данных обеспечивают организацию и представление информа­ ции (данных), а алгоритмы служат командами для преобразования этих дан­ ных. Каждая структура данных неявно определяет алгоритмы для различных операций, включая добавление, выборку и удаление элементов. Некоторые структуры данных специально разработаны с прицелом на эффективное выполнение определенных алгоритмов — например, хештаблицы для поиска по ключу (не беспокойтесь, если вы пока не знаете этих терминов: мы всё это чуть позже разберем). Таким образом, чтобы описать структуру данных, необходимо точно объ­ яснить алгоритмы, лежащие в основе ее методов. Другими словами, в этой книге вы узнаете о множестве алгоритмов. Какое мне дело до структур данных? Структуры данных — это строительные блоки computer science. Они важны, потому что помогают упорядочивать данные, решать сложные задачи, по­ вышать эффективность, оптимизировать использование памяти и избегать 1 В английском языке названия алгоритмов звучат как глаголы (Quicksort — букв. «быстро сортируй»). — Примеч. ред. https://liveinternet.club
28  Глава 1. Знакомство рисков с точки зрения безопасности. В общем, это важнейшие инструменты эффективного управления информацией и ее обработки в компьютерных программах. Последнее время в computer science появились новые тенденции, которые используют преимущества структур данных — например, графовые нейрон­ ные сети: еще более мощная версия строительных блоков, предназначенных для машинного обучения, в моделях глубокого обучения. Сфера баз данных тоже развивается, и недавно возникла концепция гибкого индексирования (flexible indexing). Такая модель индексирования основана на структурах данных, которые могут быть вложены (nested) в лю­ бом сочетании и на любой глубине. Это чрезвычайно мощный инструмент, и использовать его возможности получится, только если вы хорошо владеете структурами данных. Впрочем, я приведу еще более убедительный довод: знание структур данных может повысить вашу квалификацию как разработчика программ­ ного обеспечения. Искушенность в области структур данных — это как дополнительный инструмент в вашем арсенале. Вы когда-нибудь слышали о «молотке Маслоу», также известном под на­ званиями «золотой молоток» и «закон инструмента»? Этот закон гласит, что если в вашем арсенале только молоток, то у вас будет соблазн рассматривать любую задачу как забивание гвоздей. По сути, это означает, что люди склонны применять привычные решения в самых разных ситуациях — даже там, где это неуместно. Как это связано со структурами данных? Если вы умеете пользоваться только одной структурой данных, например хеш-таблицей, у вас появится искушение применять ее повсю­ ду, даже если вам нужно эффективно выпол­ нять такие операции, как переход к следующему и предыдущему элементу, для чего было бы луч­ ше использовать дерево. Не огорчайтесь, если этот пример показался туманным и неочевидным или вам незнако­ мы какие-то термины, — тем больше причин читать дальше, потому что мы эту тему позже разберем. Книга предоставляет в ваше распоряжение дополнительные инструменты для решения подобных задач и учит распознавать возмож­ ности применять эти инструменты для улуч­ Если нужно затянуть болт, от молотка пользы не будет шения кода. https://liveinternet.club
Какое мне дело до структур данных?  29 В каких случаях нужны структуры данных? В теории структуры данных нужны тогда, когда надо организовать данные так, чтобы их можно было легко и эффективно хранить (store) и извлекать (retrieve) в соответствии с определенными заданными правилами. Это очень формальное определение, и при всей своей формальной корректности оно кажется несколько далеким от наших повседневных дел, от мира, каким мы его знаем. Рассмотрим несколько примеров структур данных в действии, чтобы лучше понять, о чем идет речь. Поиск как у профи У Тома большая коллекция — представьте себе тысячи бейсбольных карто­ чек или миллионы товаров на сайте его интернет-магазина. У этих позиций есть атрибуты, часть которых (например, имена) уникальны и однозначно их идентифицируют. Как Тому организовать поиск в своей коллекции? Например, как ему найти карточку Джо Ди Маджио среди всех своих бейсбольных карточек? Конечно, можно перебирать их одну за другой, пока не найдется нужная. Если вы такой же страстный коллекционер, как и я, то знаете, что поиск в коллекции из тысяч предметов может занять много времени. А представь­ те, сколько времени уйдет на поиск товара в онлайн-каталоге с миллионами позиций! Для хранения и поиска Тому нужен способ получше, а заодно неплохо бы ему узнать о компромиссах, необходимых при сбалансированном удовле­ творении разноплановых потребностей. Книга предлагает разные варианты структур данных, обеспечивающих эффективный поиск, и может помочь найти ту, что лучше всего подходит для решения конкретной задачи. Как насчет того, чтобы начать с отсортированных массивов (sorted arrays) и двоичного поиска (binary search)? https://liveinternet.club
30  Глава 1. Знакомство Так много пользователей! Давайте рассмотрим другой сценарий. В своем веб-приложении Кэт необходимо отслеживать залогинившихся пользователей и их IP-адреса. Поначалу она сама реализовывает механизм отслеживания IP-адресов. Локально все рабо­ тает прекрасно. Но когда она запускает свои изменения в рабочую версию, структура данных оказывается слишком медленной для реального трафика веб-приложения, что приводит к сбою сервера. Учитывая срочность вопроса, Кэт передает поиск решения фирме-консуль­ танту в надежде, что там знают лучше. Их ре­ шение и правда обеспечивает скорость. Оно даже в рабочей версии хорошо работает… пока не перестает. Оказывается, хакер вычислил, что определен­ ной последовательностью вызовов можно переполнить структуру данных, предложенную внешней компанией, и запросто обрушить приложение Кэт. Что же произошло? В первый раз проблема была в производительности, по­ тому что была выбрана неподходящая структура данных — она оказалась слишком медленной для работы в масштабе. Во второй раз был выбран вариант получше. К сожалению, новое ре­ шение использовалось слишком неосторожно и оставило уязвимость для вредоносной последовательности (adversary sequence) (последовательность входов, выбранная для данного конкретного случая с целью создать про­ блемы со структурой данных). В свою очередь, эта уязвимость открыла воз­ можность атаки отказа в обслуживании, или DoS-атаки (Denial of Service). В таком сценарии хакер может воспользоваться уязвимостью и замедлить работу приложения настолько, что с ним не смогут взаимодействовать ле­ гитимные пользователи. Как же быть? Мы увидим, что хеш-таблицы, если их правильно использовать, должны решить большинство проблем Кэт. Когда речь пойдет о хеш-таблицах, мы больше узнаем о проблеме, которая сделала возможной DoS-атаку, о том, как ее устранить, и, что еще важнее, на что следует обратить особое внимание. Даже покупая готовый продукт у сторонней компании, необходимо знать, какие вопросы следует задать, чтобы убедиться, что все сделано правильно. Моделирование взаимосвязей Сандра запускает социальную сеть нового поколения, которая навсегда изменит наше общение. По крайней мере, это ее мечта. Она все еще строит минимально жизнеспособный продукт и надеется найти финансирование. https://liveinternet.club
Какое мне дело до структур данных?  31 Она добилась значительного прогресса, но слегка споткнулась на отсле­ живании взаимосвязей между пользователями. Сандра хочет опробовать что-то вроде электронной таблицы (spreadsheet) или табличной структуры (tabular structure), но не уверена в том, как лучше ее хранить и реализовать запросы об отношениях типа «друзья друзей». Для начала Сандра пытается использовать наивное решение, несколько раз перебрав весь список пользователей, но приложение почему-то перестает реагировать, что ее сильно огорчает. Сандра пыталась проделать это только в памяти — но что, если понадобится добавить возможность долгосрочного хранения этих данных? А если даль­ ше понадобится находить еще более отдаленные взаимосвязи — типа «друг друга друзей» или «шесть шагов до Кевина Бейкона»1? К сожалению, база данных SQL, похоже, не поддерживает всего того, что ей нужно. Дальше вы узнаете, что справиться с тесно связанными данными Сандре помогут графы, а чтобы обнаруживать опосредованные дружеские связи, можно воспользоваться алгоритмом поиска в ширину (breadth-first search). С графами подтягиваются и графовые базы данных (graph databases) — другой механизм хранения тесно связанных данных, позволяющий быстро выполнять запросы исходя из характера взаимосвязей между различными компонентами данных. 1 https://ru.wikipedia.org/wiki/Шесть_шагов_до_Кевина_Бейкона. https://liveinternet.club
32  Глава 1. Знакомство Мне когда-нибудь придется писать код для всех этих структур данных? Если не считать должностей, связанных с научными исследованиями, на большинстве позиций инженерам-разработчикам программного обеспече­ ния обычно не приходится писать свои собственные алгоритмы и структуры данных каждый день или неделю. По большей части можно просто брать чужой код. Впрочем, даже в этом случае изучение структур данных поможет делать правильный выбор или дать представление о том, что есть более под­ ходящие варианты. В некоторых ситуациях придется закатать рукава и написать собствен­ ную реализацию — например, если вы используете совершенно новый язык программирования, для которого еще не так много доступных библиотек, или же структуру данных необходимо настроить для решения какой-то специфической задачи. Но даже если вам никогда не придется писать собственные реализации, все равно, только не понаслышке зная структуры данных, вы сможете лучше понять, на какие компромиссы идете в своем коде и как сделать его более эффективным. Как выбрать структуру данных? Из примеров предыдущего раздела очевидно, насколько важно с умом вы­ бирать структуры данных. Но есть и другой, менее очевидный, момент: дело вовсе не в том, чтобы выбрать идеальную структуру. Более того, вам даже не всегда надо брать лучшую из возможных; в большинстве случаев сойдет и просто более-менее близкий к оптимальному вариант. Но что принципи­ ально важно, — так это избежать ошибочного выбора: в пользу структуры данных, которая обрушит ваше приложение или создаст проблемы с точки зрения его безопасности. Самое важное, что (как я надеюсь) вы вынесете из этой книги, — это метод оценки и выбора структуры данных в той или иной ситуации. Как это делается? Искусство выбирать правильную структуру данных — это своего рода мускулатура, которую необходимо тренировать. По ходу из­ ложения мы наделим вас знаниями и разовьем вашу интуицию, показывая опасности, с которы­ ми можно столкнуться, как их системно выявлять через оценку сложности алгоритмов, какие аспекты надо сбалансировать и какие компромиссы учесть. https://liveinternet.club
Как использовать структуры данных в проекте?  33 Как использовать структуры данных в проекте? К этому моменту вы уже примерно представляете, о чем эта книга и почему то, о чем я рассказываю, так важно. Следующим шагом должно стать по­ нимание того, как использовать то, что вы узнали, в повседневной работе. Структуры данных и алгоритмы — не технология, поэтому трудно даже представить себе руководство по их использованию. Они применяются повсю­ ду, и в большинстве случаев вы пользуетесь ими, даже не подозревая об этом. Дело даже не в том, как добавить структуры данных в ваш код, потому что вы так или иначе уже это делаете. Речь скорее о том, чтобы выработать про­ цедуру, которая позволит делать осознанный, обоснованный выбор структур, и о том, чтобы расширить свои представления о них, чтобы, столкнувшись с какой-либо проблемой, вспомнить о возможных вариантах ее решения. Ментальная модель применения структур данных Как уже было сказано, не так просто превратить опыт и экспертные знания о структурах данных и алгоритмах в пошаговый процесс. Пожалуй, это от­ части наука, отчасти искусство, где решающий фактор — подспудное знание, приходящее с опытом. Да, это сложно, но я считаю, что можно предложить некоторые рекомендации, которые помогут вам и повысят качество вашего кода. На высоком уровне процесс перехода от проблемы к решению с использо­ ванием алгоритмов и структур данных можно описать в нескольких этапах, представленных на следующем рисунке. Понять проблему Определить, какие нужны структуры данных Выбрать решение Реализовать решение Нет Да Решение эффективно? Да Нет https://liveinternet.club Решение работает?
34  Глава 1. Знакомство Вот последовательность шагов: 1. 2. 3. 4. 5. 6. Понять проблему. Наметить возможное решение. Определить, какие нужны структуры данных. Реализовать решение. Проверить, работает ли решение, если нет — итерировать. Проверить, достаточно ли удачно (эффективно) решение, если нет — итерировать. Ключевыми являются этапы 3 и 6: zzРазмышляем, какие структуры данных можно здесь использовать (этап 3). zzОцениваем, не слишком ли медленное наше (рабочее) решение, не зани­ мает ли оно слишком много памяти и удовлетворяет ли всем остальным требованиям (этап 6). Если мы реализовываем структуры данных с нуля, то этап реализации также становится значимым. В этом случае надо также тщательно протестировать код структуры данных на корректность и производительность. Но реализа­ ция здесь для нас не главное, поэтому для наглядности упростим и предпо­ ложим, что у нас есть некая сторонняя библиотека. Выбрать нужные структуры данных Итак, допустим, вы поняли требования своей задачи (а этим шагом не сто­ ит пренебрегать!) и набросали эскиз решения, над которым, на ваш взгляд, можно поработать. Теперь пришло время подумать над тем, что может по­ надобиться для его воплощения. Здесь вы переходите от высокоуровневых представлений о том, что может понадобиться для решения, к более конкрет­ ному плану, включая то, какие средства будете использовать. Например, если ваша задача — прийти на встречу завтра к 9 утра, то высокоуровневое решение может выглядеть как «поставить будильник, за­ планировать поездку и обязательно взять с собой материалы презентации». Следующий шаг — определиться с инструментарием: телефон, чтобы поста­ вить будильник, автобус или машина, чтобы добраться до места, и ноутбук для презентации. Проверить, достаточно ли удачно ваше решение Найти действенный способ решения задачи может оказаться недостаточно. Еще надо суметь решить ее за разумное время доступными ресурсами. Для сайта интернет-магазина неприемлемо, чтобы время ожидания составляло 10 минут (или даже 1 минуту!). Для видеоигры неприемлемы требования, превышающие возможности домашних компьютеров. https://liveinternet.club
Структуры данных в действии  35 Но, с другой стороны, чересчур переусложнять и чрезмерно надстраивать приложение тоже не хочется. Для управления школьным сайтом не нужен суперкомпьютер — так же как не нужна слишком сложная структура дан­ ных, когда дело эффективно решается с помощью массива. Чтобы избежать преждевременной оптимизации, обычно лучше начинать с малого и про­ бовать применять более сложные структуры данных, только когда вы уже знаете или выяснили на этом этапе, что у вас здесь «бутылочное горлышко». А как насчет тестов? Взглянув на описанную выше схему, вы, возможно, призадумаетесь и заме­ тите, что протестировать и почистить код, а также убедиться, что, например, имена переменных и методов выбраны разумно, — все это важные этапы разработки программного обеспечения. И будете правы — это действитель­ но так. Эти этапы не упоминаются только потому, что мы рассматриваем процесс на высоком уровне и концентрируемся на абстрактном решении, не отвлекаясь на подробности реализации. Структуры данных в действии Теперь, когда вам известны шаги, необходимые для включения структуры данных в проект, давайте рассмотрим пример, чтобы прояснить процесс и показать, почему так важно выбрать наиболее подходящую структуру данных. Наш сценарий — приемная ветклиники. В этом сценарии наши малень­ кие пушистые (и говорящие!) друзья ждут приема у врача в очереди. Нам надо расставить приоритеты, кого и когда принять, и постараться, чтобы все шло без накладок. Нельзя огорчать пациентов — особенно аллигаторов, известных своим скверным характером. Понимание задачи и набросок решения Возможно, это прозвучит банально, но недооценить задачу было бы большой ошибкой. В любом проекте самое важное — понять требования клиента: чем скорее вы их проясните, тем меньше будет проблем с проектом. В данном случае задача сформулирована достаточно туманно, и ее можно интерпре­ тировать по-разному. Нужно ли беспокоиться о том, кто именно находится в приемной? Могут ли кошки, собаки, аллигаторы и кролики находиться в одном помещении? Ограничена ли вместимость приемной (по количеству мест или по количеству посещений за день)? Эти (и многие другие) вопросы необходимо задать, когда столкнетесь с задачей такого рода. https://liveinternet.club
36  Глава 1. Знакомство В нашем случае давайте сделаем просто: нам лишь надо обеспечить ре­ гистрацию и прием пациентов. Предполагается, что вместимость приемной бесконечна и у нее нет никаких других ограничений. Таким образом, нам нужно устройство и нужен фрагмент программного кода, который регистри­ рует пациентов, а затем приглашает их на прием в определенном порядке. В следующих разделах будет показано, как можно итерировать оставшие­ ся шаги, пока не найдется хорошее решение. Первая попытка: с произвольной очередностью С входными и выходными данными все понятно. Следующий шаг — понять, как нам все это обустроить. Работать над решением означает написать собственный алгоритм преоб­ разования входных данных и получения желаемого результата. Это высоко­ уровневая операция, основанная на знании предметной области. Пришло время продумать некоторые подробности. Нам понадобится контейнер — структура данных, которая сможет хра­ нить регистрационные данные пациентов и отображать по запросу данные очередного пациента. Но какой именно контейнер? Учтите, что все упоминаемые далее структуры еще будут рассмотрены в книге, поэтому вам необязательно понимать их все прямо сейчас. Для пер­ вой пробы мы складываем все бланки в коробку для заявок в регистратуре, и когда доктор освобождается, просто наугад вытаскиваем один из них. Для этого будет использоваться контейнер, называемый мультимножеством (bag), — он идеально подходит, когда вас не интересует порядок чтения хранящихся элементов. Для данного примера предполагаем, что код, написанный на этапе реали­ зации, не содержит ошибок. Тогда можно сразу перейти к вопросу, работает ли это решение и хорошо ли оно работает. Легко реализуется Не дает контроля над очередностью ЬТИМУЛ ТВО ЖЕС О Н М https://liveinternet.club
Структуры данных в действии  37 Случайный выбор следующего пациента имеет свои недостатки. В среднем всех обслужат за приемлемое время, но пациенты могут заметить, что ктото, пришедший после них, попал на прием раньше них — особенно если пациентов не так много. Начнутся конфликты, и когда аллигатор съест кролика, который попытался пройти без очереди, станет ясно, что это не лучшее решение. Обратный порядок Нам надо итерировать наше решение. Высокоуровневое решение работает, но нужно сменить структуру данных. На этот раз бланки сложены в стопку: самый первый внизу, а самый последний — наверху. К сожалению, по недоразумению сотрудники ре­ гистратуры берут очередной бланк с верха стопки, то есть они реализо­ вывают стек (stack), — и последний зарегистрированный пациент идет на прием первым! В конце первого же дня, когда им приходится иметь дело с разгневанным львом, который прождал весь день, чтобы ему вытащили занозу из лапы, все понимают, что решение посредством стека совершенно не работает. Стек хорош, когда сначала надо обработать самые новые элементы, но ужасен применительно к ожидающим в очереди. Вершина next() C òåê Если приоритет для обработки — свежие данные Если данные обрабатываются в порядке очереди Первым пришел, первым обслужен На этот раз исправление будет концептуально проще: следующий бланк берется с низа стопки, и пациент, пришедший первым, первым и попадет к врачу. Для такого подхода используется очередь (queue) — структура данных, которая позволяет перебирать элементы в порядке их поступ­ ления. Такое решение работает неплохо: никаких больше споров, никаких бес­ конечных ожиданий. Пациенты повеселели, и с расстановкой приоритетов никакого напряжения. Наконец-то реализованное решение работает. https://liveinternet.club
38  Глава 1. Знакомство Конец next() Î÷ å ðå ä ü Первым пришел, первым обслужен Начало Неотложным заявкам приходится ждать Остается сделать последний шаг. Решение работает, но достаточно ли хоро­ шо? Через несколько недель работы с новой системой сортировки доктора понимают, что в каких-то случаях можно было бы избежать осложнений, если бы пациенты попали на прием немедленно, не дожидаясь очереди. Понимаете, к чему дело идет? Можно сделать лучше. Пришло время ите­ рировать. Неотложные элементы в первую очередь Нам нужна структура данных, позволяющая учитывать не только время при­ бытия. У специалистов предварительной диагностики есть бланки запи­си анамнезов болезней, по которым можно оценить степень срочности оказа­ ния помощи и упорядочить бланки так, чтобы даже если лев с занозой при­ шел первым, то на прием попал бы после питона, съевшего компьютерную мышь, и черепахи, вывихнувшей лодыжку во время пробежки. К счастью, для таких задач имеется подходящая структура данных: приоритетная очередь (priority queue). Если добавить всех пациентов в при­ оритетную очередь, то можно запросить у нее сначала самый неотложный случай, затем — следующий по степени срочности и т. д. Максимальный Высокий Высокий Средний Низкий Низкий Приоритет next() Ïðè îðè ò å ò í à ÿ î ÷åðå ä ü Сначала обрабатываются неотложные элементы Медленнее работает и сложнее в реализации Такое решение работает — и работает хорошо. Работа закончена? Не факт. Во-первых, это зависит от реальных требований, которым могут понадо­ биться более весомые гарантии. Во-вторых, следует учитывать практическую реализацию системы, чтобы решить, какая разновидность приоритетной https://liveinternet.club
Итоги  39 очереди работает достаточно быстро и управляет памятью достаточно эф­ фективно для наших потребностей. Но суть вы поняли: можно измерить производительность, сопоставить ее с требованиями, а потом принять решение. Поскольку мы понимали, каким образом в нашем примере выбрать правильную структуру данных, то и на­ шлось решение, идеально подходящее для наших целей, но для этого нужно понимать, как выбрать правильную структуру. Итоги zzСтруктура данных — это способ организации и хранения информации на компьютере или в языке программирования, определяющий взаимосвязи между данными, спектр возможных операций с ними, а также правила и ограничения доступа к данным и их изменения. zzСтруктуры данных — фундамент эффективной организации и хранения данных. zzАлгоритм представляет собой набор четко определенных команд; это по­ шаговая процедура, предназначенная для решения конкретной проблемы или выполнения определенной задачи. zzАлгоритмы и структуры данных дополняют друг друга аналогично тому, как существительные и глаголы дополняют друг друга в предложениях. zzНеправильный выбор структуры данных может иметь серьезные по­ следствия — например, обрушение веб-сайта или угрозу с точки зрения его безопасности. zzСуществует пошаговая процедура, которая поможет решить, какие струк­ туры данных следует использовать в проекте. zzЭта процедура итеративна и требует проверки качества решения, пока оно не будет удовлетворять всем требованиям. https://liveinternet.club
2 Статические массивы. Создаем свою первую структуру данных В этой главе 9 9 9 9 9 9 Основные идеи, относящиеся к структурам данных Знакомство с фундаментальной структурой данных — массивом Различия между массивами со статическими и динамическими размерами Типичные операции, которые можно выполнять с массивами 9 Применение массивов для решения задач 9 9 9 В этой главе мы начинаем разговор о том, как работают структуры данных и как их реализовывать. Специфика главы в том, что она постепенно вво­ дит нас в курс дела — лейтмотив всей книги, которого мы будем держаться, рассказывая о технологии, которую представляем. К тому же вы еще позна­ комитесь здесь с некоторыми базовыми понятиями, которые понадобятся нам дальше. https://liveinternet.club
Что такое массив?  41 Что такое массив? Наше путешествие по стране структур данных начнется с массивов (arrays), а именно со статических массивов (static arrays). В массивах хранятся кол­ лекции элементов, доступ к которым предоставляется по их индексу. Но прямо сейчас я хочу, чтобы вы смогли ответить на самый важный во­ прос: почему именно массивы? Позвольте объяснить на примере. Память и ящики Сначала нам нужно отступить на шаг назад и поговорить о том, как орга­ низована память. Если попросту, я предпочитаю рассматривать память как модульный стеллаж с выдвижными ящиками. Если конструкция стеллажа — это память, то ящики — это переменные. Такая концепция из области программирования, я полагаю, вам уже знако­ ма. Если вы хотите использовать какую-либо часть памяти, можно создать переменные — ящики для хранения данных, из которых эти данные можно будет извлекать. Размер стеллажа определяет максимальное количество ящиков. Можно создавать переменные (ящики) разных размеров, лишь бы они помещались в стеллаж. В свою очередь ящики заполняются данными, причем чем больше ящик, тем более крупные типы данных в нем можно разместить. Например, для значения с плавающей точкой потребуется ящик побольше, чем для символов или (коротких) целых. https://liveinternet.club
42  Глава 2. Статические массивы Когда мне понадобятся массивы? Знакомьтесь: это Марио! Он любит сладости, особенно шоколад. У них с родителями есть на кухне специ­ альный ящичек, в котором Марио хранит свои любимые шоколадные трюфели. Сейчас у Марио осталось пять трюфелей. Ящик — это как переменная, вместилище данных. В этом примере целочисленная пе­ ременная с именем drawer (ящик) будет содержать значение 5. Чтобы перейти от целочислен­ ных переменных к массивам, рас­ смотрим другой пример. Наступает декабрь, и семья Марио делает для своих детей адвент-календарь1. Ка­ лендарь выполнен в форме прянич­ ного домика с маленькими ящичка­ ми с цифрами от 1 до 24. Если вы не знаете, что такое адвент-календарь, то это как адвент-код2, разве что вместо задач по кодингу вам каждый день с 1 по 24 декабря до­ стается какое-нибудь сладкое лакомство (забавно, что эта аналогия обычно работает с точностью до наоборот для всех, кроме инженеров-программи­ стов!). В каждом ящичке адвент-календаря таится какая-нибудь вкусняшка: печенька, шоколадка, конфетка — и детям можно открыть ящичек опреде­ ленного номера только соответствующего числа. Возвращаясь к нашей аналогии со стеллажом, предположим, что вы отвели часть большого стеллажа-хранилища под адвент-календарь. Двад­ цать четыре ящичка можно разместить на стеллаже как угодно: им даже необязательно быть рядом друг с другом или располагаться в каком-либо определенном порядке. Но если мы создали пронумерованные ящички, то хотелось бы разместить их рядом друг с другом и в порядке возрастания номеров. В противном случае трудно будет найти нужный. 1 2 Advent calendar — рождественский календарь, традиционный отсчет с 1 по 24 декабря с угощениями, приуроченными к каждому дню. — Примеч. ред. Advent of Code — ежегодный неформальный конкурс программистов, организованный по аналогии с рождественским календарем, только вместо угощений предлагаются задачки по программированию. Последний пример: https://adventofcode.com/2024. — Примеч. ред. https://liveinternet.club
Когда мне понадобятся массивы?  43 По тому же принципу, если мы захотим смоделировать условный ад­ вент-календарь в программе, можно создать 24 переменные и назвать их advent_drawer_1, advent_drawer_2 и т. д. Ничто не помешает нам это сделать (хотя хочется надеяться, что кто-нибудь остановит нас, прежде чем это бе­ зобразие попадет в рабочую версию!). Создавать 24 разные переменные вручную уже неприятно. Еще хуже другое — каждый раз, когда потребуется обратиться к одному из ящиков в коде, нам придется ввести правильное имя переменной, ведь в большин­ стве языков программирования надо знать, какая переменная понадобится, еще на стадии компиляции (то есть когда мы пишем код). Но иногда это становится понятно только во время прогона, то есть ког­ да код исполняется. К примеру, вот программа спрашивает пользователя, какой ящик надо проверить, но мы не знаем заранее нужную переменную, потому что это выяснится только посредством операций ввода/вывода по мере выполнения программы. И если это не первое ваше родео с кодом, то вы, вероятно, уже знакомы с циклами: можете вообразить, какая возникнет неразбериха, если перебирать все ящики без цикла for? Если не получается представить, ничего страшного — скоро будет наглядный пример. Вот где на помощь приходят массивы. Массив — это структура данных со множеством элементов, доступ к которым осуществляется по индексу. Мы дадим исчерпывающее определение массива в следующем разделе, а пока за­ помните, что обычно они используются, чтобы хранить (store), перебирать (iterate over) и обрабатывать (manipulate) коллекции значений примерно одного типа, не особо задумываясь, как соотносятся между собой отдель­ ные значения. В тех случаях, когда у вас больше информации о внутренней структуре данных и о том, как элементы связаны друг с другом, в книге будут представлены другие, более походящие структуры данных. Определения: статические и динамические размеры массивов Что же такое массив? Вот как будет выглядеть наш адвент-календарь в виде массива: 4 1 2 7 2 3 3 5 9 4 23 24 25 Целочисленный массив для адвент-календаря     1 Массивы не ограничиваются хранением целых чисел и чисел вообще — в них также могут храниться дробные числа, строки и другие типы объектов. На­ пример, как насчет массива конфет? 2 3 4 5 6 Целочисленный массив для конфет     1 https://liveinternet.club
44  Глава 2. Статические массивы В простейшем определении массив представляет собой индексированную структуру данных. «Индексированная» означает, что в массиве хранится последовательность единиц (обычно именуемых элементами), доступ к ко­ торым предоставляется только по их позиции (также называемой индексом). Для примера, в адвент-календаре можно обратиться к ящичку с индексом 1, чтобы получить лакомство за 1 декабря, но доступ к ящичкам исходя из их содержимого невозможен — не получится, к примеру, легко найти ящичек с семью трюфелями, а в массиве конфет не удастся просто потребовать: «Получить клубничный леденец». Теперь, когда мы приближаемся к формальным определениям, необхо­ димо провести разграничение, ведь определение массива может рассматри­ ваться под разными углами. Если сосредоточиться на функциональности массивов на высоком, полу­ абстрактном уровне, структура данных массива обладает рядом ключевых характеристик: zzВ ней хранится коллекция данных. zzК ее элементам можно обращаться по индексу. zzК элементам необязательно обращаться последовательно, иначе говоря, если мне понадобится 10-й элемент массива, я смогу обратиться к нему напрямую, мне не придется читать 9 элементов, хранящихся в массиве перед ним. Эти характеристики определяют массив на абстрактном уровне. Формально они определяют массив как абстрактный тип данных. Помните об этом, так как мы еще вернемся к этому термину в главе 7. С другой стороны, массивы — часть базового функционала многих язы­ ков программирования. Здесь ситуация становится более конкретной. Рас­ сматривая массивы с этой точки зрения, придется иметь дело с подробностя­ ми реализации, которые зависят от выбранного языка программирования. Впрочем, многие языки придерживаются ряда общих параметров при реализации массивов как части своего базового функционала (продолжаю предыдущий список): zzМассивы создаются в памяти в виде единого непрерывного блока с по­ следовательным хранением элементов, что экономит память и бережет время. zzМассивы ограничиваются хранением данных одного типа. Такое ограни­ чение также обусловлено необходимостью оптимизации, потому что по­ зволяет группировать друг с другом однотипные элементы, а компилятор/ интерпретатор при этом быстрее определяет адреса элементов. Подробнее об этом — в следующем разделе. https://liveinternet.club
Значения и индексы  45 zzРазмер массива (то есть количество элементов, содержащихся в нем) должен быть задан при создании массива, и этот размер нельзя изменить. Последние три пункта представляют низкоуровневое определение, описываю­ щее статические массивы (или массивы статических размеров) — базовый функционал многих языков программирования: C, C++, Java и т. д. В этой главе мы сосредоточимся именно на статических массивах. Динамические массивы (или массивы динамических размеров), то есть такие, размер которых может меняться во время выполнения, — еще одна разно­ видность этой структуры данных. Динамические массивы более подробно рассматриваются в главе 5. Обратите внимание: требование, упомяну­ тое в предпоследнем пункте списка, можно ослабить и разрешить хране­ ние в массиве разнородного контента, это означает, что в элементах массива можно смешивать разные типы данных. Язык программирования Python, который используется в этой книге, предоставляет встроенные списки (lists) — разновидность массивов динамического размера, допускающий для своих элементов данные любого типа. Значения и индексы В предыдущем разделе мы узнали, что массив — это индексированная струк­ тура данных. Это означает, что каждый элемент массива связан с опреде­ ленным индексом и добраться до какого-либо элемента можно только через соответствующий индекс. Когда мы говорили о статических массивах, я отметил, что во многих языках все элементы массива должны относиться к одному типу данных. Это требование полезно по многим причинам. Во-первых, как показано на следующей иллюстрации, оно позволяет выделить точный объем памяти, необходимый для массива. Во-вторых, так можно быстро вычислить ячейку памяти (memory address) каждого элемента — ведь все они одного размера, а значит, между ними одинаковые интервалы, отчего проще вычислить местоположение какого-либо элемента в памяти. Индекс в массиве Машинное слово Границы массива 0 1 2 3 4 2 0 -1 4 5 Ячейка памяти     0xf0 0xf1 0xf2 0xf3 0xf4 0xf5 0xf6 0xf7 0xf8 https://liveinternet.club Ячейки памяти и реализация массивов
46  Глава 2. Статические массивы Возможно, вы заметили, что в примерах с массивом для адвент-календаря из предыдущего раздела индексы элементов начинаются с 1. Другими словами, каждый индекс соответствует одному из первых 24 дней декабря. Кто-то удивленно вскинет брови, потому что привык, что индексы начинаются с 0. Давайте поговорим об этом. Хотя во многих языках программирования индексы действительно на­ чинаются с 0, это не всегда так. Самые известные примеры языков, где ин­ дексы начинаются с 1, — Julia, MATLAB, R и Fortran. Python — из тех языков, в которых индексирование начинается с 0, по­ этому в книге будет использоваться именно эта схема. Индексирование, начинающееся с 0, как вы можете 1 2 7 представить (и, возможно, уже испытали), заставляет 4 0 1 2 3 разработчиков осторожнее работать с индексами, осо­ бенно если они реализуют алгоритмы с обращениями Версия массива для адвент-календаря к конкретным позициям или когда им нужно оставаться с индексами, в границах допустимых индексов. Например, если у мас­ начинающимися с 0 сива с индексированием от 0 размер n, то индекс послед­ него элемента массива будет n-1, и попытка обратиться к элементу с индексом n приведет к ошибке. Инициализация Как я уже сказал, в оставшейся части главы мы сосредоточим внимание на статических массивах. Также был кратко упомянут один из ключевых мо­ ментов: создавая статический массив, необходимо заранее определить его размер. Например, если в массиве должны храниться пять элементов, то память для всех них нужно выделить при создании массива. Таким образом, объявляя массив, мы создаем структуру для пяти значений определенного типа, который также должен быть указан в этот момент. Мы готовим место для хранения этих элементов, но что проиcходит до того, как мы действительно присваиваем им значения? Для начала необходимо сказать, что массивы можно создавать двумя способами: их можно просто объявить, а можно (в большинстве языков программирования) инициализировать элементы массива непосредственно в момент объявления. Инициализировать массив означает присвоить (допустимые) значения всем его элементам. В таком случае компилятор, преобразуя ваш код в про­ грамму, которая совместима с компьютером, одновременно выделяет память для массива и заполняет его значениями, заданными на стадии компиляции, прежде чем переходить к следующей команде. А что будет, если просто объявить массив, без инициализации? Останутся ли его элементы «пустыми»? https://liveinternet.club
Массивы в Python  47 Понятия «пустой массив» не существует, а значит, когда вы объявляете переменную, компилятор должен присвоить ей какое-то значение. В случае массивов значения нужно присвоить всем элементам. Фактическое значение зависит от языка программиро­ ? ? ? ? вания и типа массива. Например, в Java всем элементам ? 0 1 2 3 4 массива целых чисел, созданного без инициализации, «Пустой» массив: будет присвоено значение 0. В некоторых языках для обо­ какие значения значения «пустоты» есть специальное значение. Напри­ в нем хранятся? мер, в Python используется значение None, а в Java — null. Мы просто Обратите внимание — это специальные значения, которые не знаем! явно присваиваются именно элементам массива. Суть в том, что при создании массива надо быть внимательным, если вы планируете обращаться к его элементам, предварительно не присво­ ив им каких-либо значений. Если у вас возникнут сомнения, проверьте спецификацию языка — в ней будет указано, что произойдет в такой ситуации. Массивы в Python Довольно теории, пора опробовать массивы в действии. Юный Марио любит не только конфеты, но и программи­ рование. Он изучает Python и хочет отслеживать состоя­ ние своего адвент-календаря, поэтому каждое утро, как только он открывает ящичек этого дня, он хочет обновить и цифровую версию календаря. Он также планирует об­ новлять календарь каждый раз, когда съедает очередную шоколадку, — так, чтобы можно было следить за младшим братом Яном, который уже был заподозрен в краже лакомств Марио на Хэллоуин. Поможем Марио создать простое приложение с использова­ нием массивов! Списки Python и класс array.array Я уже упоминал, что Python предоставляет класс list в качестве встроенного аналога массива. Списки Python ближе к динамическим массивам, а также у них нет ограничений по типам данных: можно создать список, в котором и числа, и строки, и другие списки — всё вместе. Списки Python мощнее статических массивов: например, они поддер­ живают динамическое изменение размера, тогда как класс array.array из стандартной библиотеки Python на это не способен. Но вы сами знаете, как оно бывает: с большой силой приходит большая ответственность и цена, https://liveinternet.club
48  Глава 2. Статические массивы которую приходится платить. Обычно ценой поддержки динамического раз­ мера становится снижение эффективности и замедление структуры данных (об этом мы подробнее поговорим в главе 4). На всякий случай оговорюсь: во многих случаях можно нормально использовать списки, не заметив откло­ нений в работе приложения. Но если вы пишете критические секции своего кода — потенциальные «узкие места», эффективность которых критична, — стоит выбрать самый производительный вариант. ПРИМЕЧАНИЕ Помните, что оптимизация также имеет свою цену (с точки зрения времени разработки, сопровождения и ясности), поэтому не оптими­ зируйте слишком рано или без реальных преимуществ. Прежде чем вы решите оптимизировать какой-нибудь код, обязательно запустите его и определите критические участки, где оптимизация принесет наибольшую пользу. Очень важно, чтобы вы понимали, как работают статические массивы, пре­ жде чем мы перейдем в следующей главе к их динамической версии. К со­ жалению, Python не предлагает какого-либо встроенного аналога статиче­ ского массива. Самое похожее на него в Python — модуль массива, который обеспечивает согласованность типов, но при этом остается динамическим массивом. Настоящий статический массив можно найти в NumPy — мате­ матической библиотеке, оптимизированной под эффективные векторные вычисления. С numpy.array можно создавать массивы фиксированного раз­ мера с элементами типа double, то есть числами с плавающей точкой двойной точности (fixed-size arrays of doubles), — впрочем, они все же будут несколько отличаться от массивов Java. Здесь не место углубляться в плюсы и минусы всех возможных решений, хотя важно знать, что они существуют. Вместо этого, чтобы помочь вам поэкспериментировать со статическими массивами, я создал специальный пользовательский класс на базе array.array, моделирующий работу стати­ ческих массивов. (Этот пользовательский класс можно найти в репозитории книги: https://mng.bz/VxpG.) На нынешней стадии не нужно беспокоиться о подробностях реализации статического массива. Важно то, что после им­ портирования класса вы сможете создать новый массив размера n с помощью следующего кода: from arrays.core import Array a = Array(n) После этого можно обращаться ко всем элементам a с индексами от 0 до n-1 и присваивать им значения, как в обычных массивах. С другой стороны, не­ возможно будет расширить или сжать этот массив. https://liveinternet.club
Операции с массивами  49 По умолчанию создается массив целых чисел. Если вы хотите создать массив (из пяти элементов), содержащий числа с плавающей точкой, ис­ пользуйте команду: b = Array(5, 'f') После этого, например, можно выполнить следующие команды: print(b) print(b[2]) b[3] = 3.1415 Обратите внимание: все элементы свежесозданного массива инициализиру­ ются значением 0 (или 0.0 для чисел с плавающей точкой). Индексирование Как упоминалось ранее, в массивах Python индексирование начинается с 0, то есть в массиве из n элементов первый всегда под индексом 0, а послед­ ний — под n-1. Иногда индексирование с 0 создает некоторые неудобства, как в нашем примере с адвент-календарем. День 1 хранится в ячейке с индексом 0, тогда как было бы более естественно искать его по индексу 1. Иногда простыми неудобствами дело не ограничивается — приходится внимательно следить за индексами, чтобы случайно не выйти за границы массива. Для массива размера n последний допустимый индекс — n-1. Даже со списками Python, у которых -1 является действительным индексом (он считается индексом последнего элемента массива), попытка обращения a[n] приведет к сбою приложения. И тут вы можете спросить: а как насчет a[-n]? Или a[n+1]? Только один из этих индексов сработает: сможете угадать, какой именно? Чтобы не сталкиваться с подобными уловками джедаев, мы отключили отрицательные индексы для нашего класса статических массивов. Операции с массивами Итак, теперь вы знаете, как создать массив. Следующий вопрос: что с ним делать? Изначально массив представляет собой пустой контейнер — не в том смысле, что его элементы в самом деле пусты, но скорее значения, присвоенные ячейкам массива, бессмысленны. Наш вспомогательный класс по умолчанию инициализирует все элементы массива значениями 0, как это делается во многих языках программирования. https://liveinternet.club
50  Глава 2. Статические массивы Впрочем, подробности каждого языка программирования пока не важ­ ны. Единственное необходимое допущение — пока вы не инициализируете массив, его данные бессмысленны. Массив можно заполнить так, как вы считаете нужным. Новые значения элементам необязательно присваивать в каком-то определенном порядке, но с одной оговоркой: возможно, вы захотите отслеживать, какие элементы осмысленны для вашего приложения. Я пойду еще дальше: вы определенно этого захотите — невозможно смоделировать ситуацию, чтобы вы не за­ хотели. В большинстве случаев порядок хранения элементов ни на что не вли­ яет. И можно просто добавлять новые элементы по первому свободному индексу массива и поддерживать выравнивание по левому краю. Это означает, что если мы добавим в наш массив k≤n элементов, их индексы будут от 0 до k-1. ? 7 ? ? 3 -1 ? 7 3 -1 ? ? Некоторым элементам массива были присвоены значения, другие остались «пустыми» Массив выровнен по левому краю 0 1 2 3 4 5 0 6 1 2 3 4 С массивами, выровненными по левому краю, весьма удобно следить за тем, какие элементы осмысленны, и достаточно хранить размер лишь заполнен­ ной части массива. ПРИМЕЧАНИЕ Это всего лишь один из возможных способов — собственно, один из многих. Если вы решили работать с массивом, выровненным по левому краю, вам придется постоянно отслеживать, сколько элементов хранится в мас­ сиве на данный момент. А теперь посмотрим, как выполнять некоторые базовые операции с нашим (несортированным) массивом. Класс для несортированных массивов Можно написать набор глобальных функций, которые принимали бы как аргумент объект core.Array и работали бы с ним. Тем не менее я выбрал другой путь. Я знаю, что мы можем получить более чистую реализацию, написав класс UnsortedArray, который бы оборачивал и изолировал (инкапсулировал) наш массив. https://liveinternet.club
Операции с массивами  51 Почему? Есть очень много причин предпочесть объектно-ориентиро­ ванное программирование императивной парадигме. Если эти дебаты для вас внове, то советую уделить какое-то время изучению вопроса, что-то почитать на эту тему. Один из моментов, о которых, возможно, вы уже задумывались, — при­ дется отслеживать размер заполненной части массива. С массивом, выров­ ненным по левому краю, этого достаточно, чтобы отделить часть с данными от пустой части. Если мы реализуем класс для несортированного массива, то сможем хранить его размер в атрибуте и обновлять в процессе работы с массивом. Если бы мы не обернули в класс несортированный массив, то нам пришлось бы хранить его размер в глобальной переменной и передавать это значение каждой функции, работающей с несортированным массивом. В свою очередь, эти методы должны доверять вызывающей стороне, но при этом все равно выполнять какую-то проверку вводимых данных. Каж­ дый, кто использует эти методы, может (случайно или намеренно) передать неправильное значение размера массива. Что еще важнее, владелец массива должен поддерживать синхронизацию переменной размера с объемом дан­ ных, — например, не забывать обновлять массив после добавления и удале­ ния значений. Инкапсуляция — оплот современного программирования Тот факт, что любой желающий может изменить переменную с размером массива, чреват ошибками. Вместо этого необходимо стремиться к инкап­ су­ляции. У каждого элемента массива должно быть связанное с ним значе­ ние, которое, в идеале, изменяется только внутри самого элемента. (Python с этим особо не поможет, так как в нем нет реального ограничения доступа к атрибутам класса.) Поэтому мы собираемся реализовать несортированные массивы в виде класса. Полный код доступен на GitHub (https://mng.bz/x2dX): class UnsortedArray: def __init__(self, max_size, typecode = 'l'): self._array = Array(max_size, typecode) self._max_size = max_size self._size = 0 В конструкторе мы сохраняем ту же сигнатуру, что используется в базовом вспомогательном классе (core helper class) статического массива. Собственно, мы даже используем один из этих статических массивов внутри для разме­ щения данных. https://liveinternet.club
52  Глава 2. Статические массивы Обратите внимание: хотя мы могли применить наследование от core.Array, вместо этого мы создаем экземпляр core.Array и присваиваем его атрибуту объекта — мы используем композицию с экземпляром core.Array. ПРИМЕЧАНИЕ Общее правило гласит, что композиция предпочтительнее на­ следования: она предоставляет больше гибкости при проектировании. Если вы не знакомы с композицией, наследованием, их достоинствами и не­ достатками, то стоит прочесть книгу Дейна Хилларда (Dane Hillard) «Practices of the Python Pro»1 (Manning, 2019). Добавление нового элемента Создадим массив arr = UnsortedArray(n), где 0 7 -1 3 ? ? ? ? ? n — количество элементов, отведенных под 0 1 2 3 4 5 6 7 8 массив (его максимальная емкость). Допу­ добавить(-2) стим, мы уже добавили в массив k элементов. 7 -1 3 -2 ? ? ? ? Мы не можем делать никаких предположений 0 0 1 2 3 4 5 6 7 8 относительно порядка элементов, и нас даже Добавление пятого элемента не интересует этот порядок. При таких допущениях можно добавить в массив размером n=9 в массив следующий элемент под индексом k сразу же за последним элементом, но только если в массиве есть свободное место! Первое, что нужно сделать, — проверить, что k — допустимый ин­ декс. Если да, то можно продолжить присваивание, не забывая увеличивать k — текущий размер. Если массив заполнен, мы генерируем исключение, чтобы предупредить вызывающего о проблеме. ПРИМЕЧАНИЕ Не скрывайте ошибки. Использовать исключения необязательно, но важно сообщить о них клиенту, чтобы он мог обнаружить и исправить этот сбой. Одно из преимуществ исключений (допустим, перед возвращением специ­ ального значения при ошибке) — они вынуждают вызывающего озаботить­ ся и проверить, успешно ли выполнена операция, тогда как возвращаемые значения могут и будут игнорироваться. 1 Хиллард Д. «Секреты Python Pro». СПб., издательство «Питер». https://liveinternet.club
Операции с массивами  53 Вот как будет выглядеть код в качестве метода нашего класса: def insert(self, new_entry): if self._size >= len(self._array): raise ValueError('The array is already full') else: self._array[self._size] = new_entry self._size += 1 Удаление элемента 0 7 -1 3 -2 ? ? ? ? 0 1 2 3 4 5 6 7 8 Добавление новых элементов в несортиро­ remove(-1) ванный массив — довольно простое дело, не так ли? Зато когда мы хотим удалить элемент, 0 7 ? 3 -2 ? ? ? ? 0 1 2 3 4 5 6 7 8 все становится немного интереснее. В самом распространенном сценарии тре­ буется удалить элемент откуда-то из сере­ После удаления элемента остадины массива. К сожалению, если элемент ется дыра, поэтому необходимо с заданным индексом просто «стереть», это сдвинуть влево все элементы, оставит «дыру» (пропуск) посреди блока, расположенные справа от нее в котором хранятся допустимые элементы, и нарушит наше правило, что элементы выровнены по левому краю. Чтобы исправить ситуацию, теоретически можно было бы сдвинуть все элементы справа от пропуска на одну позицию влево. Это решило бы про­ блему, но выполнять лишнюю работу не хочется. Согласитесь, было бы намного проще, если бы нам нужно было удалить последний элемент массива! Тогда можно было бы просто обновить размер массива, проигнорировав последний элемент. Есть особый случай — структура данных, называемая стеком, которая позволяет удалять только последний элемент. Стеки будут рассматриваться в главе 8, но пока выясняется, что нам все же повезло: есть способ воздей­ ствовать на несортированные массивы и по­ remove(-1) пасть в тот сценарий, в котором мы удаляем 0 7 -1 3 -2 ? ? ? ? только последний элемент. 0 1 2 3 4 5 6 7 8 Так как массив не отсортирован и мы swap(2,4) предполагаем, что порядок элементов роли не играет, можно просто поменять местами 0 7 -2 3 -1 ? ? ? ? 0 1 2 3 4 5 6 7 8 последний элемент с удаляемым, а затем уда­ remove_at(4) лить последний элемент! При этом необходимо проверить некото­ 0 7 -2 3 ? ? ? ? ? 0 1 2 3 4 5 6 7 8 рые граничные случаи, прежде всего — не пуст ли массив, но в целом решение полу­ Удаляемый элемент меняется мечается намного проще, чем можно было стами с крайним правым элементом массива, после чего удаляется ожидать: https://liveinternet.club
54  Глава 2. Статические массивы def delete(self, index): if self._size == 0: raise ValueError('Delete from an empty array') elif index < 0 or index >= self._size: raise ValueError(f'Index {index} out of range.') else: self._array[index] = self._array[self._size-1] self._size -= 1 «Умная перестановка» путем Последний элемент будет находиться за пределами перезаписи удаляемого элемента заполненного блока (предупреждение: в массиве накап­ (сохранять значение, которое ливаются избыточные данные (array loitering)) собираемся удалить, не нужно) Поиск значения Есть еще одна важная операция, которую хотелось бы уметь выполнять, — это поиск. Задано определенное значение — присутствует ли оно в массиве, и если да, то под каким индексом? Присмотревшись повнимательнее, пой­ мем, что надо прояснить ряд вопросов. Например: zzА если одно и то же значение встречается многократно? Какое вхождение нужно вернуть: первое, любое или все? zzЕсли целевое значение отсутствует в массиве, что мы вернем? Одно из возможных решений — вернуть -1 — работает во многих языках. Одна­ ко в Python –1 является допустимым индексом для списков, потому что отрицательные числа могут использоваться для индексации элементов справа налево. Таким образом, возврат -1 может, напротив, привести к тому, что ошибка останется незамеченной, если вызывающий не про­ верит выходные данные метода (method output). Сделаем следующие допущения: мы вернем индекс первого найденного вхождения целевого элемента или None (недействительный индекс), если оно не найдется. Как выполнить поиск? К сожалению, элементы хранятся неупорядоченно, поэтому нет ничего лучше, чем перебрать все, пока не найдется совпадение. Такой способ не очень эффективен, но у нас нет никакой информации, ко­ торая позволила бы нам действовать иначе: def find(self, target): for index in range(0, self._size): if self._array[index] == target return index return None Это означает, что искомое значение найти невозможно Метод поиска можно использовать в сочетании с методом delete для уда­ ления элементов по значениям. Сначала находим индекс значения, которое https://liveinternet.club
Массивы в действии  55 хотим удалить, а затем можно вызвать метод delete , которому мы дали определение в предыдущем разделе. Обход Иногда бывает нужно провести какую-либо операцию со всеми элемента­ ми структуры данных, в том числе и с массивом. Что угодно: вывести на экран, возвести в квадрат… Что нам нужно — это обойти (traverse) массив, пройтись по всем его элементам (ровно один раз в определенном порядке, исходя из структуры данных) и применить некий метод, который примем в качестве аргумента. Как мы еще увидим, с более сложными структурами данных (такими как деревья и графы) это дается сложнее. Но в случае с массивами достаточно простого цикла for: def traverse(self, callback): for index in range(self._size): callback(self._array[index]) Предположим, что операция, которую мы хотим выполнить, вызывает какой-то побочный эффект, и нам не нужно собирать ее выходные данные (в противном случае речь шла бы об операции отображения (map ope­ration)). Определив эту операцию в простейшей форме, можно попробовать вы­ звать ее методом print, чтобы понять, как она работает: array.traverse(print) Массивы в действии Итак, вы видели, как работают массивы. Давайте посмотрим, как ими поль­ зоваться. Статистика Выпало 1! Марио и Тони играют в игру, которую сами при­ Тони, ты снова думали: Тони выбирает три младших значения на выиграл. кубике, а Марио — три старших. И если на кубике выпадает 1, 2 или 3, то выигрывает Тони, а если 4, 5 или 6 — Марио. Они по очереди бросают кубик, делая ставки — каждый раз ставят на кон свои бейсбольные карточ­ ки. Кто бросает — решает, сколько карточек ставить, а второй может удвоить ставку. https://liveinternet.club
56  Глава 2. Статические массивы Через какое-то время Марио продул половину своей колоды. Он считает, что Тони слишком часто выигрывает, и не понимает почему. Когда Марио рассказывает об игре отцу, тот предполагает, что, возможно, у Тони «нечест­ ный» кубик: одни числа выпадают чаще других. — У честного кубика, — продолжает он, — если его много раз бросить, каждая цифра должна бы выпадать примерно один раз из шести бросков. И чем больше бросать, тем ближе друг к другу будут результаты. Таким образом, один из способов доказать, что кубик нечестный, — со­ хранить статистику результатов множества бросков, а потом посмотреть распределение результатов. Преодолев первую преграду в освоении про­ граммирования и массивов, Марио на подъеме и хочет с помощью массивов доказать, что Тони жульничает. Отец помог Марио написать приложение для мобильника, которое поможет ему записать результаты бросков. Каждый раз, когда Марио регистрирует на телефоне бросок кубика, прило­ жение фиксирует результат в массиве counters, состоящем из шести элементов. При первом запуске приложения все элементы counters инициализирова­ ны как 0. Когда на кубике выпадает, допустим, 4, приложение увеличивает counters[3]. Не забываем, что допустимые значения — от 1 до 6, но индексы массива — от 0 до 5 (в Python и во многих других языках), так что, если хотите обновить количество выпадений k, необходимо увеличить counters[k-1]. Для этого конкретного приложения не нужно заполнять массив посте­ пенно или отслеживать имеющие значение элементы: мы с самого начала точно знаем, сколько нужно выделить элементов, и все они могут считаться значимыми, как только инициализированы как 0. Иначе говоря, массив за­ полняется при инициализации. Однако в следующем примере мы увидим, как использовать то, что мы уже узнали о постепенном заполнении массивов. Как только Тони и Марио вдоволь наигрались и Марио ввел сотни (и даже тысячи!) бросков кубика, наступает самое интерес­ 0 0 0 0 0 0 ное: как ему проверить, насколько эти результаты 0 1 2 3 4 5 соответствуют значениям честного кубика? Есть не­ 0 0 0 1 0 0 сколько способов, но большинство из них, вероят­ 0 1 0 1 0 0 но, выходит за рамки математики младшеклассника. 0 1 0 1 1 0 Поэтому отец Марио предлагает начать с поиска 0 1 0 2 1 0 максимального значения в массиве для числа, ко­ 0 1 1 2 1 0 торое выпадает чаще всего. Допустим, получилось одно максимальное значение, а в случае равенства 0 1 2 3 4 5 двух или нескольких можно просто вернуть то, у ко­ Массив с шестью счетчиторого наименьший индекс. ками. Каждый раз, когда Значит, Марио надо закодить какую-нибудь раз­ игроки бросают кубик, новидность перебора массива. Надо перебрать все показание счетчика выпавшего числа возрастает https://liveinternet.club
Массивы в действии  57 элементы один за другим и посмотреть, у какого из них самая высокая ча­ стота. Обратите внимание: вместо того чтобы предполагать, что максимальное значение в массиве неотрицательно (что в данном случае было бы верно), можно написать безопасный, чуть более общий метод, который будет ини­ циализировать переменную max_value первым элементом массива, а затем начнет итерацию со второго элемента. Эта разновидность делает код более устойчивым (так мы не зависим от того, чтобы вызывающая сторона передавала массив только с неотрицатель­ ными значениями) и более широко применимым. Мы сравниваем каждый элемент с тем, каково значение max_value в дан­ ный момент, и, если у элемента оно выше, обновляем как значение, так и его индекс. В итоге можем просто вернуть найденное значение и индекс, по которому оно расположено. И в данном случае надо не забыть добавить к ин­ дексу 1, чтобы получить самое частое значение, выпадающее у кубика Тони: def max_in_array(array): if len(array) == 0: raise Exception('Max of an empty array') max_index = 0 for index in range(1, len(array)): if array[index] > array[max_index]: max_index = index return max_index, array[max_index] Затем отец Марио поручает ему вторую задачу: написать функцию, которая покажет, какая грань кубика выпадает реже остальных и как часто это слу­ чается. —  И когда у нас будут эти четыре значения, — говорит отец Марио, — мы сможем определить, честный ли у Тони кубик. max_in_array(counters) > 1, 234 min_in_array(counters) > 5, 107 Они обнаружили, что чаще всего выпадает 2 (помним, что индекс всегда на 1 меньше фактического значения на кубике), а реже всего 6 — с огромной разницей по частоте. —  Странно, — говорит Марио, — и что это означает? —  Это означает, что я собираюсь позвонить родителям Тони. Он должен вернуть твои карточки. https://liveinternet.club
58  Глава 2. Статические массивы УПРАЖНЕНИЯ 2.1 Напишите код функции, возвращающей минимальное значение в массиве и его индекс. Подсказка: нельзя ли адаптировать функцию max_in_array? 2.2 Можно ли написать метод, который возвращает сразу и минимальное, и максимальное значения? В чем преимущество вычисления обоих зна­ чений в одном методе? Коллекции Другой вариант использования масси­ вов — отслеживание событий по мере их появления. Например, Марио любит кол­ лекционировать бейсбольные карточки (или любые другие). Родители подарили ему специальный альбом для самых ценных карточек. Аль­ бом ограниченного формата, поэтому Ма­ рио нужно тщательно отбирать для него карточки. Если мы хотим смоделировать такой «альбом» на компьютере, то массив будет хорошей аналогией. Несортированный массив — как тот, что был представлен в предыдущем разделе, — подойдет еще лучше. Можно сделать массив размером со всю колоду карточек. Сначала он будет пуст, то есть мы будем отслеживать все карточки, которые добавля­ ем, — изначально их нет. По мере купли, продажи и обмена карточек можно добавлять новые элементы в массив: нас не заботит порядок их расположения. Можно рас­ ставлять их в любом порядке. Когда альбом (массив) заполнится, можно удалить какие-то карточки (элементы), чтобы освободить место для новых реликвий, которые мы захотим оставить в коллекции. Если есть представ­ ление о том, какую карточку мы хотим удалить (может быть, Билли Рипкен, 1989, Fleer1), можно провести поиск по всему массиву, чтобы найти индекс, который надо освободить. Наконец, завершая нашу аналогию, если мы хотим внести в карточки какие-то данные, например имя и возраст игрока, то стоит подумать о за­ пуске traverse с функцией записи этой информации. 1 Компания, выпускающая бейсбольные карточки. — Примеч. ред. https://liveinternet.club
Итоги  59 Многомерные массивы Массивы не ограничиваются хранением чисел. Их элементами могут быть символы, строки, объекты и другие массивы. В частности, массив массивов называется многомерным массивом (multidimensional array). Матрицы1 ис­ пользуются во многих областях: теории графов, линейной алгебре, машин­ ном обучении и моделировании физической среды. Чтобы больше узнать о многомерных массивах, обращайтесь к репозиторию книги: https://mng. bz/Adlx. Итоги zzМассивы — это способ хранения коллекций элементов и организация эффективного доступа к ним по позиции. zzПод термином «массив» обычно подразумевается массив со статическим размером (или просто статический массив) — набор элементов с досту­ пом по индексу и фиксированным количеством самих элементов в тече­ ние всего срока существования коллекции. zzСуществуют также массивы с динамическим размером. Они ведут себя как статические массивы, не считая того что количество содержащихся в них элементов может изменяться. zzМногие языки программирования, такие как C или Java, предлагают ста­ тические массивы в качестве встроенной функции. zzМассивы можно инициализировать на стадии компиляции. Если язык позволяет пропустить инициализацию, то исходное значение элементов массива зависит от языка. zzМассивы бывают вложенными, то есть можно создать массив массивов. Что касается статических массивов такого рода, мы называем их много­ мерными массивами или матрицами. zzЕсли порядок элементов нас не интересует, то добавление элементов в массив и их удаление выполняются просто. zzВо всех (типичных) массивах можно провести поиск путем перебора, пока не найдется искомое значение. zzМассивы можно использовать во множестве приложений. Например, подсчет предметов и статистические вычисления — идеальные сценарии для применения массивов. 1 Двумерный массив. — Примеч. ред. https://liveinternet.club
3 Отсортированные массивы. Цена ускорения поиска В этой главе 9 9 9 9 9 9 Для чего поддерживать массив отсортированным Настройка методов вставки и удаления для отсортированных массивов Чем линейный поиск отличается от двоичного В главе 2 были представлены статические массивы. Вы научились пользо­ ваться ими как контейнерами для хранения элементов, не беспокоясь о по­ рядке их следования. В этой главе мы сделаем следующий шаг: отсортируем элементы массива. Для упорядочения массивов есть веские причины — например, таковы требования предметной области либо надо ускорить выполнение каких-то операций с массивом. Рассмотрим пример, который показывает, как под­ держивается баланс между издержками и выгодами и в чем можно получить преимущество, если держать элементы массива упорядоченными. Для чего сортировать массивы? В предыдущей главе массивы рассматривались как контейнеры, где порядок элементов не имеет значения. А если имеет? Что ж, если порядок важен, то меняется все, в том числе и приведенные выше реализации базовых операций. https://liveinternet.club
Для чего сортировать массивы?  61 Но прежде чем перейти к тому, как это делается, давайте попробуем по­ нять, когда отсортированный массив может оказаться полезен. Испытание для ниндзя поиска Наш юный друг Марио увлекается как кодингом, так и бейсбольными кар­ точками. Он стал откладывать свои карманные деньги, чтобы покупать кар­ точки для игр с друзьями. Он купил уже столько карточек, что ему тяжело их носить с собой и в них ориентироваться. Отец подарил ему альбом для хранения, чтобы их было проще везде брать с собой, но среди сотен карто­ чек — даже в альбоме — трудно найти нужные. Видя его затруднения, мама Марио — инженер-программист — предло­ жила отсортировать карточки по командам и именам. Марио настроен скептически: сортировка всех этих карточек кажется ему невыполнимой задачей. Он бы лучше поиграл сейчас, чем терять время. Стало быть, настал момент порассуждать о быстром поиске в отсортиро­ ванных списках. Мама Марио объяснила, что если он сначала отсортирует карточки, то облегчит себе поиск нужных. Марио и это не убедило, поэтому она предло­ жила игру: они разделят все карточки на две равные части и выберут друг для друга по пять карт, которые оба должны будут найти — каждый в своей половине. Искать можно только по одной карточке зараз, так что следующая карточка, которую нужно найти, станет известна только после того, как будет найдена предыдущая. Марио может начинать поиск, пока его мама сортирует свои карточки. Кто закончит первым — тот и победил. Состязание начинается, и мама Марио уделяет пять минут тому, чтобы спокойно отсортировать свою половину, а тем временем Марио нашел свою первую карточку и со смешками дразнит ее, что она так копается. Марио считает себя настоя­ щим ниндзя поиска: он же самый быстрый сре­ ди одноклассников. Но вот мама Марио заканчивает сортировать свои карточки — и тут вдруг оказывается, что Ма­ рио еще не успел найти свою третью карту, а мама уже справилась со всеми пятью. Марио не верит своим глазам: «Как у тебя так быстро получилось?» Хороший вопрос! Но чтобы узнать, почему ее ме­ тод оказался быстрее, вам придется подождать до сле­ дующей главы. А в этой мы лучше сосредоточимся на том, как воплотить то, что применила мама Марио для победы в состязании. https://liveinternet.club
62  Глава 3. Отсортированные массивы Реализация отсортированных массивов В оставшейся части этой главы подробнее рассматриваются основные опе­ рации над отсортированными массивами и их реализация на Python. Как и для несортированных массивов, я создам класс, в котором будут храниться все подробности работы отсортированных массивов, — на этот раз он будет называться SortedArray. Полный код доступен на GitHub (https://mng.bz/x24Y), но самые важные аспекты мы рассмотрим также и здесь. Для отсортированных массивов инкапсуляция играет еще более важную роль, потому что мы должны соблюдать еще одно обязательное заданное ус­ ловие (invariant): элементы массива всегда отсортированы. Как обсуждается в следующем разделе, метод вставки (insert) с отсортированными массивами работает совершенно по-другому, а если применить его к несортированному массиву, он будет работать непредсказуемо и почти наверняка выдаст не­ правильный результат. И тебя отсортировать? А как? 3 7 2 1 ? 0 1 2 3 4 По этой же причине не стоит разрешать клиентам напрямую задавать зна­ чения элементов массива и путать их порядок. И массив можно изменять только методами insert и delete. Начнем с объявления класса и конструктора: class SortedArray(): def __init__(self, max_size, typecode = 'l'): self._array = core.Array(max_size, typecode) self._max_size = max_size self._size = 0 В конструкторе мы сохраняем ту же сигнатуру, что и в нашем основном вспо­ могательном классе для статических массивов. И по аналогии с тем, как мы делали для класса UnsortedArray, мы встроим внутрь экземпляр core.Array. Обратите внимание на различия в поведении и смысле некоторых мето­ дов SortedArray и core.Array. Прежде всего, по сравнению с классом core. Array, который мы предоставили, изменился смысл понятия «размер мас­ сива»: для базового типа (core type) оно означало емкость массива, но здесь подразумевается нечто иное. Емкость массива по-прежнему нужно задавать и сохранять, но как и в случае с несортированными массивами, необходимо отслеживать заполнение этой емкости при добавлении элементов в массив. https://liveinternet.club
Реализация отсортированных массивов  63 Следовательно, поведение, ожидаемое от вызова len(array), в данном случае будет другим. Для базового массива (core array) всегда возвращается его емкость, но в данном случае отслеживается количество элементов, хра­ нящихся в массиве в настоящее время (с учетом того, что максимальное ко­ личество элементов определяется емкостью исходного базового (underlying) массива — постоянным значением, возвращаемым методом max_size). Теперь у нас есть класс для следующей структуры данных — отсорти­ рованного массива. Однако структура данных принесет пользу лишь в том случае, если с ней можно выполнять какие-либо операции. Есть несколько базовых операций, которые обычно выполняются с большинством структур данных: вставка, удаление, поиск и перебор (insert, delete, search, traverse). insert(x) SortedArray delete(x) Состояние: search(x) отсортированная последовательtraverse() ность элементов У некоторых структур данных имеются специализированные версии этих операций (например, некоторые позволяют удалять только определенные элементы, как мы увидим при обсуждении стека), а другие могут поддер­ живать не все операции. Тем не менее в большинстве случаев мы будем реализовывать эти базовые операции. Вставка Начнем со вставки. Когда в отсортированный массив добавляется новый эле­ мент, необходимо действовать осторожнее, чем с несортированной версией. В данном случае порядок важен, и мы не можем просто добавить новый эле­ мент в конец массива. Вместо этого необходимо найти правильную позицию для вставки этого нового элемента, где он не нарушит порядок сортировки, а затем откорректировать массив (вскоре я объясню, как именно). Из-за специфики работы массивов все не так просто, как хотелось бы. Рассмотрим конкретный случай: отсортированный массив с пятью эле­ ментами, в который необходимо добавить новое значение 3 (упрощая для наглядности, мы в этом примере обходимся без дубликатов, но и с ними решение осталось бы тем же). Определив подходящую позицию для нового элемента 3, мы разбиваем старый массив. По сути массив делится на две части: левый подмассив L с элементами, меньшими 3 (а именно 1 и 2), и правый подмассив R с элемен­ тами, большими 3 (а именно 4, 5 и 6). https://liveinternet.club
64  Глава 3. Отсортированные массивы Теоретически старый массив следовало бы раз­ 1 2 4 5 6 бить по точке вставки, а затем соединить все три 0 1 2 3 4 insert(3) части [1,2]–[3]–[4,5,6]. К сожалению, с массивами это не так просто сделать (зато проще со связанны­ 1 2 3 4 5 6 ми списками, о чем мы поговорим дальше). Так как массивы должны храниться в непрерывном блоке памяти, а их элементы должны следовать по порядку, от меньших индексов к большим, все элементы из правой части R необходимо сдвинуть на один элемент вправо. Есть разные способы реализовать вставку, но мы последуем такому плану: 1. Начнем с последнего (крайнего правого) элемента массива — назовем его X (в нашем примере 6) — и сравним его с новым вставляемым значени­ ем K (3). 2. Если новое значение K больше или рав­ 1 2 4 5 6 ? ? ? ? но X, то K следует вставить в точности 0 1 2 3 4 5 6 7 8 справа от этой позиции. В противном insert(3) случае (как в нашем примере, где 3 < 6) мы знаем, что X необходимо сдвинуть вправо, 1 2 4 5 6 > 3 ? ? ? и с таким же успехом это можно сделать 0 1 2 3 4 5 6 7 8 сейчас. Мы выбираем новым X элемент, 2 4 5>3 6 ? ? ? находящийся слева от X, то есть 5, и воз­ 1 0 1 2 3 4 5 6 7 8 вращаемся к предыдущему шагу. Процесс повторяется, пока не будет найден эле­ 1 2 4 > 3 5 6 ? ? ? 0 1 2 3 4 5 6 7 8 мент X, меньший или равный K, или не будет достигнуто начало массива. 1 2< 3 4 5 6 ? ? ? 3. Когда правильная позиция найдена, мы 0 1 2 3 4 5 6 7 8 можем просто назначить на нее K без ка­ Пример вставки в отсортированких-либо дополнительных изменений, по­ ный массив тому что все элементы, которые должны быть сдвинуты справа от этой позиции, мы уже переместили. Эти действия лежат в основе алгоритма сортировки, который называется сортировкой вставками (insertion sort). Он сортирует существующий мас­ сив поэтапно: строит отсортированную подпоследовательность, которая становится отправной точкой, и добавляет элементы в массив слева направо один за другим. Хотя существуют более быстрые алгоритмы сортировки, со­ ртировка вставками остается хорошим вариантом, когда отсортированная последовательность должна строиться поэтапно, как в нашем случае. Если вам захочется больше узнать об алгоритмах сортировки, я рекомендую «Гро­ каем алгоритмы», второе издание, особенно главы со 2-й по 4-ю. https://liveinternet.club
Реализация отсортированных массивов  65 Теперь, когда вы знаете, что нужно реализовать, надо просто написать немного Python-кода: def insert(self, value): if self._size >= self._ max_size: raise ValueError(f'The array is already full, maximum size: {self._max _ size}') for i in range(self._size, 0, -1): if self._array[i-1] <= value: self._ array[i] = value Позиция найдена self._size += 1 в середине массива return else: self._ array[i] = self._ array[i-1] self._array[0] = value self._size += 1 Если выполнение достигло этой точки, правильная позиция находится в начале массива Как видите, осталось добавить только одно — проверку в начале метода, чтобы убедиться, что емкость массива не будет превышена при вставке. Удаление Те же соображения, которые упоминались при рассмотрении вставки, также применимы и к удалению существующих элементов. Допустим, вы хотите удалить четвертый элемент (с индексом 3) из массива с семью элементами. Оставлять «дыру» в массиве нельзя, и мы не можем использовать тот же прием, что и для несортированных массивов, remove(4) то есть заполнить удаленную позицию по­ 0 1 4 7 9 ? ? ? ? следним элементом массива. 0 1 2 3 4 5 6 7 8 Вместо этого необходимо сдвинуть все find(4) 2 элементы с пятой по седьмую позицию. Эти 0 1 4 7 9 ? ? ? ? элементы сдвигаются на одну позицию вле­ 0 1 2 3 4 5 6 7 8 во, чтобы элемент, ранее хранившийся под remove_at(2) индексом 4, переместился в ячейку с индек­ 0 1 ? 7 9 ? ? ? ? сом 3, и т. д. 0 1 2 3 4 5 6 7 8 ПРИМЕЧАНИЕ Общее правило таково: сдвигаются все элементы от индекса удаляе­ мого элемента и до конца массива. Как правило, с отсортированными масси­ вами чаще требуется удалить конкретное значение, а не элемент в заданной позиции. А значит, клиент чаще знает удаляемое зна­ чение, а не его индекс. 0 1 7 9 ? ? ? ? ? 0 1 2 3 4 5 6 7 8 Чтобы удалить элемент из отсор­ тированного массива, сначала находим его индекс, а затем сдвигаем все элементы, находящиеся справа от него, перезаписывая удаленный элемент https://liveinternet.club
66  Глава 3. Отсортированные массивы Как согласовать эту особенность с тем, что говорилось ранее, и предо­ ставить удобный интерфейс? Все, что для этого нужно, — найти позицию удаляемого значения, после чего можно воспользоваться методом поиска, который рассмотрим в следующем разделе. При реализации метода delete (по значению, by value) будем предполагать, что метод search уже определен: для целей этого раздела не нужно вникать в подробности его работы. До­ статочно знать, что он возвращает индекс искомого значения или None, если значение не найдено. Получив искомый индекс, просто сдвигаем все элементы, что справа от него, на одну позицию влево: def delete(self, target): index = self.search(target) if index is None: raise ValueError(f'Unable to delete element {target}: the entry is not in the array') for i in range(index, self._size - 1): self._array[i] = self._array[i + 1] self._size -= 1 УПРАЖНЕНИЕ 3.1 А если бы потребовалось реализовать метод удаления по индексу (deleteby-index method)? Опишите абстрактные действия, которые должен выполнять этот алгоритм, а затем реализуйте его на Python как часть класса SortedArray. Линейный поиск Мы реализовали метод удаления по значению с использованием поиска, так что следующий шаг вполне очевиден: реализация метода search. В нем заключена вся суть раздела, посвященного отсортированным массивам: мы хотим поддерживать массив отсортированным, чтобы ускорить в нем поиск. Одно из очевидных преимуществ поиска в отсортированном массиве заключается в том, что при переборе всех элементов слева направо можно остановить безуспешный поиск (поняв, что нашей цели нет в массиве), как только обнаружится элемент, который больше искомого. Так как элементы отсортированы, то все последующие элементы могут быть только еще боль­ ше, поэтому дальнейший поиск смысла не имеет: def linear_search(self, target): for i in range(self._size): if self._ array[i] == target: return i elif self._ array[i] > target: return None return None https://liveinternet.club
Реализация отсортированных массивов  67 Неплохо, но это ситуации не меняет. Конечно, если мы ищем один из наи­ меньших элементов, поиск заметно уско­ рится, но при поиске одного из самых больших значений все равно придется перебрать практически весь массив. Возникает вопрос: нет ли более быстрого способа найти целевое зна­ чение — без перебора всего массива? Остановитесь на минуту и подумайте, как иначе можно это сделать. А теперь поверите ли вы, если я ска­ жу, что можно действовать намного эф­ фективнее? linear_search(8) 8 > > > > > > > < 1 2 3 4 5 6 7 9 10 0 1 2 3 4 5 6 7 8 Неудачный линейный поиск в отсор­ тированном массиве. Приходится проверять каждый элемент от начала массива, пока не найдем элемент (9), который больше нашей цели — 8. Обратите внимание: чтобы узнать, что искомого значения в массиве нет, нам понадобилось сделать восемь сравнений Двоичный поиск Лучше поверьте, потому что это правда. Хотя мы рассмотрим обоснования более формально в следующей главе, к концу этого раздела вы будете четко представлять, что двоичный поиск — это совсем другое дело по сравнению с линей­ binary_search(8) ным. 8 > А пока я расскажу, как это происходит. 1 2 3 4 5 6 7 9 10 Сначала проверяется средний элемент мас­ сива, и если находим свою цель, то все гото­ 0 1 2 3 4 5 6 7 8 8 во (а нам крупно повезло). > В противном случае, так как массив от­ 1 2 3 4 5 6 7 9 10 сор­тирован, мы все еще можем извлечь из 0 1 2 3 4 5 6 7 8 этого сравнения кое-какую информацию. 8 Если наша цель больше серединного эле­ < мента M, значит, она не может располагать­ 1 2 3 4 5 6 7 9 10 ся в левой половине, верно? Так как массив 0 1 2 3 4 5 6 7 8 отсортирован, все элементы до серединного меньше или равны M. Неудачный двоичный поиск. ПоАналогичным образом, если цель мень­ сле одного сравнения из поиска ше, то она не может быть справа от текущей исключается более половины массива. После двух сравнений позиции. Так или иначе, можно ограничить исключается более 75 %. В данном поиск половиной массива и повторить про­ примере, при третьем сравнении, цесс, только в два раза меньшем по размеру когда остаются всего два элеменмассиве. та, среди которых может оказатьЭтот метод называется двоичным поисся цель нашего поиска, выясняетком (binary search). Реализация выглядит не ся, что ее нет в массиве https://liveinternet.club
68  Глава 3. Отсортированные массивы особо сложно: мы определяем два ограничителя (guards) — левый и правый индекс, — определяющих часть массива, в которой должна быть, как мы знаем, наша цель. Затем на каждом шаге ограничители сближаются, пока мы не найдем свою цель или не выясним, что ее нет в массиве. def binary_search(self, target): left = 0 right = self._size - 1 while left <= right: mid_index = (left + right) // 2 mid_val = self._array[mid_index] if mid_val == target: return mid_index elif mid_val > target: right = mid_index - 1 else: left = mid_index + 1 return None Изначально цель поиска может распо­ лагаться где угодно в массиве Мы обнаружили расположение цели своего поиска Цель поиска может быть только в левой половине Цель поиска может быть только в правой половине Если выполнение достигло этой точки, цели нашего поиска нет в массиве Но поверьте: это один из тех алгоритмов, у которых дьявол скрывается в деталях, и правильно написать его с первого раза довольно трудно. Так что вам лучше тщательно протестировать его, даже если вы пишете его в сотый раз! Почему алгоритм называется двоичным поиском и почему он эффектив­ нее метода linear_search? Об этом вы узнаете в следующей главе. А пока небольшое предостережение: если ваш массив содержит дубликаты, а вам нужно найти первое (или последнее) вхождение целевого значения, этот метод не будет работать в приведенном виде. Его можно (и нужно) адап­ тировать для того, чтобы он находил первое вхождение, но это несколь­ ко усложняет логику метода, а код становится чуть менее эффективным. Он все еще быстрее линейного поиска, но очевидно, что возвращать первое найденное вхождение — еще быстрее. Таким образом, о дубликатах стоит беспокоиться только в том случае, если у вас есть веская причина вернуть именно первое вхождение; в противном случае одно вхождение ничем не отличается от другого. На этом наше рассмотрение статических массивов подходит к концу. Я знаю, что ранее упоминалась и четвертая операция: перебор. Напомню, что перебором называется процесс обращения к каждому элементу масси­ ва ровно один раз. Теперь у вас есть все необходимое для того, чтобы вы­ полнить эту операцию самостоятельно. Просто помните, что в контексте отсор­тированных массивов перебор обычно выполняется по возрастанию, от наименьшего элемента к наибольшему. https://liveinternet.club
Итоги  69 УПРАЖНЕНИЯ 3.2 Реализуйте метод перебора для отсортированных массивов. Используйте его для вывода всех элементов массива по возрастанию. 3.3 Реализуйте версию двоичного поиска, которая, если есть дубликаты, возвращает первое вхождение значения. Будьте внимательны! Надо удо­ стовериться, что новый метод по меньшей мере не медленнее исходной версии. Подсказка: прежде чем выполнять это упражнение, убедитесь, что понимаете разницу в скорости выполнения работы между двоичным и линейным поиском. Знакомство с главой 4 поможет вам справиться с этой задачей. Итоги zzОтсортированный массив — это массив, при изменении элементов кото­ рого автоматически поддерживается определенный порядок. zzЧтобы сохранять порядок элементов такого массива, необходимо иначе (чем для несортированного) выполнять вставку и удаление элементов. Эти методы должны сохранять порядок расположения элементов и по­ этому требуют бˆольших усилий, чем их аналоги для несортированных массивов. zzВ уже отсортированном массиве можно провести двоичный поиск — алгоритм поиска, который может найти совпадение, проверяя меньшее количество элементов, чем при линейном поиске (последний просто про­ веряет все элементы, пока не найдет совпадение). zzВ отсортированных массивах поиск выполняется быстрее, но поддержа­ ние их в отсортированном виде требует дополнительных затрат. Следо­ вательно, предпочтение им следует отдавать при высоком соотношении операций чтения/записи (то есть когда метод binary_search вызывается гораздо чаще, чем insert и delete). https://liveinternet.club
4 Нотация «О-большое». Оценка эффективности алгоритмов В этой главе 9 9 9 9 9 9 9 9 Объективное сравнение разных алгоритмов Использование нотации «О-большое» для понимания структур данных Различия между анализом наихудшего случая, анализом среднего и амортизационным анализом Сравнительный анализ двоичного и линейного поисков В главе 3 мы обсуждали, что двоичный поиск работает быстрее линейного, но у нас не было необходимых средств, чтобы это объяснить. В этой главе представлен прием анализа, который изменит ваш подход к работе — и это еще мягко сказано. После этой главы вы сможете отличать высокоуровне­ вый анализ производительности алгоритмов и структур данных от более конкретного анализа времени выполнения вашего кода. Это поможет вам правильно выбрать структуру данных и распознать ее «узкие места», прежде чем браться за написание кода. Небольшая предварительная работа поможет вам сэкономить много времени и усилий. https://liveinternet.club
Как выбрать лучший вариант?  71 Как выбрать лучший вариант? В главе 3 представлены два метода поиска в отсортированном массиве: линейный и двоичный. Я говорил, что двоичный быстрее линейного, и вы могли убедиться на примере, что двоичный поиск потребовал всего нескольких сравнений, тогда как линейному пришлось просканировать практически весь массив. versus linear_search(8) 8 > > > > > > > < 1 2 3 4 5 6 7 9 10 0 1 2 3 4 5 6 7 8 binary_search(8) 8 > > < 1 2 3 4 5 6 7 9 10 0 1 2 3 4 5 6 7 8 Можно подумать, что это простое совпадение… или же я тщательно по­ добрал пример, чтобы произвести впечатление. Да, конечно, именно так и было, но оказывается, что это справедливо и в целом, и только в отдельных граничных случаях линейный поиск быстрее двоичного. Но чтобы определить, какой алгоритм быстрее, нам понадобится подхо­ дящий метод оценки их производительности. Вероятно, нас заинтересует не только скорость работы каждого из алгоритмов, но и соотношение потреб­ ляемых ресурсов (память, диск, время использования специализированного процессора и т. д.). Как же измерить производительность алгоритма? Есть два основных способа: zzИзмерить характеристики при реализации алгоритма, запуская код на разных входных данных и фиксируя затраты времени и памяти. Этот процесс называется профилированием (profiling). zzОбъяснить алгоритм в более абстрактных категориях, взяв упрощенную модель машины, на которой он будет выполняться, и опустив множество деталей. В этом случае мы сосредоточимся на том, чтобы вывести некий математический закон, описывающий время работы и затраты памяти в терминах размера входных данных. Такой подход называется асимптотическим анализом (asymptotic analysis). Профилирование У профилирования есть одна полезная особенность: существуют готовые инструменты, которые выполнят бˆольшую часть работы за вас, измеряя производительность вашего кода и даже разбивая время выполнения по методам и строкам. https://liveinternet.club
72  Глава 4. Нотация «О-большое» В Python библиотеки cProfile и profile (https://docs.python.org/3/library/ доступны всем желающим. От вас потребуется лишь импорти­ ровать ту, что хотите использовать, и настроить код, вызывающий методы, которые хотите профилировать. Профилирование выглядит хорошо, но закрывает ли оно все наши по­ требности? Не совсем. Мы профилируем конкретную реализацию алгоритма, поэтому результа­ ты сильно зависят от выбранного языка программирования (какие-то языки лучше других справляются с нужными нам в данном случае операциями) и от написанного кода. Следовательно, детали реализации могут повлиять на общий результат и неудачные решения способны замедлить даже хороший алгоритм. Более того, машина, на которой выполняется профилирование, и ее программное обеспечение (операционная система, драйверы и компи­ ляторы) тоже могут повлиять на конечный результат. Иначе говоря, когда мы профилируем линейный и двоичный поиск, то сравниваем две конкретные реализации и получаем данные именно об этих реализациях. Нельзя предполагать, что полученные результаты будут спра­ ведливы для всех реализаций, и если на то пошло, также нельзя их обобщать и использовать для сравнения (только) двух алгоритмов. У профилирования есть и другой существенный недостаток: мы тестиру­ ем эти реализации на конечных входных данных. Конечно, можно запустить профилировщик входных данных разных размеров, но размер этих данных не может превышать возможности машины. В некоторых практических ситуациях может оказаться достаточно тестирования на таких входных данных. Но результат можно будет обоб­ щить с оговорками: некоторые алгоритмы превосходят своих конкурентов, только если размер данных превышает определенный порог. И невоз­ можно распространить на крупные машины результаты, полученные на небольших машинах, как и на распределенную систему результаты с одной машины. profile.html) Асимптотический анализ Основная альтернатива профилированию — асимптотический анализ. Цель асимптотического анализа — найти математические формулы, описывающие поведение алгоритма как функции входных данных. С такими формулами будет проще распространить полученные результаты на любой размер входных данных и проверить, как соотносится производительность двух алгоритмов при росте размера входных данных до бесконечности (отсюда и название метода). Полученные результаты не зависят от конкретной реализации и, в прин­ ципе, будут справедливы для всех языков программирования. https://liveinternet.club
Нотация «О-большое»  73 Как нетрудно догадаться, есть и оборотная сторона медали. Конечно, чтобы получить эти формулы, придется поработать, причем иметь дело с математикой — и довольно плотно. Иногда это настолько сложно, что для некоторых алгоритмов еще не нашли формулу или же неизвестно, лучшая ли формула описывает время их выполнения. Однако у алгоритмов, которые используются структурами данных, рас­ сматриваемыми в этой книге, — как и у многих других — нет трудно­ стей с поиском правильной формулы. Вам даже не придется выводить эти формулы самостоятельно; собственно, мы в книге и не будем углубляться в математику. Вы всего лишь поработаете с результатами, проверенными поколениями ученых в области computer science. Моя цель — показать, как пользоваться этими результатами и что учи­ тывать при выборе алгоритма или структуры данных. Что же выбрать? Как профилирование, так и асимптотический анализ полезны на разных ста­ диях процесса разработки. Асимптотический анализ в основном применя­ ется на этапе проектирования, потому что он помогает выбрать правильные структуры данных и алгоритмы, — по крайней мере, на бумаге. Профилирование полезно после того, как вы написали реализацию, — для проверки «узких мест» в коде. Оно обнаруживает проблемы в реализации, но также поможет понять, не выбрали ли вы неподходящую структуру данных (в случае если вы пропустили асимптотический анализ или пришли к не­ правильным выводам). Нотация «О-большое» В этой книге мы сосредоточимся на асимптотическом анализе, поэтому вкратце опишем нотацию, которая обычно используется для выражения формул, описывающих поведение алгоритмов. Но сначала — вы еще не за­ были, что мы говорили об асимптотическом анализе? В нем используется обобщенное (и упрощенное) представление о компьютере, на котором вы­ полняются наши алгоритмы. Важно начать с описания этой модели, потому что она сильно влияет на проведение анализа. Модель оперативной памяти Дальше в книге, когда мы будем анализировать какой-либо алгоритм, нам понадобится критерий для сравнения алгоритмов, и еще надо бы абстра­ гироваться от как можно большего количества подробностей, касающихся оборудования (тактовой частоты процессора, многопоточности и т. д.). https://liveinternet.club
74  Глава 4. Нотация «О-большое» Будем исходить из того, что у нас имеется одноядерный процессор и оперативное запоминающее устройство, ОЗУ (random-access memory, RAM). Это означает, что не нужно беспокоиться о многозадачности, параллелизме и считывать память последовательно, как с ленты, — к любой ячейке памяти можно обратиться за одну операцию, которая занимает одно и то же время, независимо от фактической позиции ячейки. Отталкиваясь от этого, определим, что такое машина с произвольным доступом (random-access machine, та же аббревиатура, RAM): вычислительная модель для однопроцессорного компьютера и оперативного запоминающего устройства. ПРИМЕЧАНИЕ Далее при упоминании RAM-модели будет подразумеваться машина c произвольным доступом (Random Access Machine), а не оперативное запоминающее устройство1. Это упрощенная модель, в которой память не является иерархической, как в реальных компьютерах (где имеется дисковое пространство, оперативная память, кэш, регистры и т. д.). Считается, что доступна только одна разно­ видность памяти, но ее объем бесконечен. Машина спроизвольным доступом Ввод Базовые операции: O(1) Циклы: O(количество шагов) *O(шаг) Одноядерный процессор Вывод Программа Память Промежуточные результаты Обращение: O(1) на ячейку В этой упрощенной модели одноядерный процессор поддерживает всего лишь минимальный набор команд — в основном для арифметических вы­ числений, перемещения данных и управления потоком. Все команды выпол­ няются за одно и то же время (то есть время выполнения каждой команды абсолютно одинаково). Конечно, некоторые из этих допущений нереалистичны, но в этом кон­ тексте они вполне уместны. Например, не бывает безлимитной доступной памяти и не все операции выполняются с одной и той же скоростью, но эти 1 Random-access memory, RAM, — буквально «память с произвольным доступом». — Примеч. ред. https://liveinternet.club
Нотация «О-большое»  75 допущения соответствуют нашему анализу и до определенного момента целесообразны. Скорость роста Теперь, когда мы описали вычислительную модель для изучения алгоритмов, пора представить фактические метрики, которые будем использовать при анализе. Да, это означает, что пришло время математики! Но не волнуйтесь! Мы ориентируемся на визуальное восприятие и значительно упростим все обозначения — будем их толковать и использовать очень неформально. Как упоминалось ранее, мы хотим описать поведение алгоритмов некото­ рыми формулами, связывающими размер входных данных с используемыми ресурсами. В некоторых ситуациях без математического анализа не обойтись. Нас интересует, как меняется объем необходимых ресурсов, когда растет раз­ мер входных данных. Иначе говоря, нас интересует скорость роста этих соотношений. Для заданного ресурса — например, для времени выполнения нашего алгоритма — мы собираемся определить функцию f(n), где n обычно исполь­ зуется для определения размера входных данных. Чтобы выразить скорость роста функции f, используется нотация «O-большое». ПРИМЕЧАНИЕ Название «О-большое» происходит от символа, используемого в нотации, — заглавной буквы О. Мы пишем f(n) = O(n), чтобы обозначить, что функция f растет со скоростью некоторой линии в декартовой системе координат. Какой линии? Мы не уточняем, потому что знать это необязательно. Это может быть любая линия, проходящая через начало координат, кроме вертикальной оси. n f(n) n/2 3 https://liveinternet.club n
76  Глава 4. Нотация «О-большое» На практике, если ресурс (например, используемая алгоритмом память) растет со скоростью, определяемой функцией f(n) = O(n), это означает, что с ростом размера входных данных нашего алгоритма затраты памяти будут ограничиваться двумя прямыми линиями на графике. Более формально можно сказать, например, что для n > 3 выполняется со­ отношение n/2 < f(n) < n. Или, что то же самое, можно сказать, что для n > 30 выполняется соотношение n/4 < f(n) < 5n. Неважно, выберем мы первую пару прямых, y = n/2 и y = n, или вторую пару, y = n/4 и y = 5n: асимптотический анализ просто предлагает нам найти пару прямых, которые для достаточно больших значений n выступают в качестве границ для f. Собственно, нотация O(n) определяет не одну функцию, а целый класс функций — все функции, растущие со скоростью прямых линий, и запись f(n) = O(n) означает, что f принадлежит этому классу. Впрочем, f(n) = O(n) сообщает еще одну важную вещь: есть как мини­ мум одна линия, которая по скорости роста обгонит f(n) при достаточно большом n. Таким образом, когда мы говорим, что алгоритм выполняется за время O(n) (то есть линейное время), это означает, что если нарисовать график, показывающий, сколько времени занимает выполнение алгоритма для вход­ ных данных разной длины, этот график будет подобен прямой. То тут, то там будут встречаться небольшие отклонения, обусловленные случайными факторами, возникающими в компьютерах при выполнении программ, но если смотреть масштабно, не отвлекаясь на эти детали, то график будет вы­ глядеть как прямая линия. Стандартные функции роста Вы можете спросить, что это будет за линия. Если взглянуть на линии на следующем гра­ 10n фике, то между ними заметны значительные различия: одна растет намного медленнее другой! (Обратите внимание, что на всех графиках мы работаем с первым квадрантом системы координат, ограничиваясь положи­ тельными значениями обеих осей.) С этой нотацией невозможно сказать за­ n/10 ранее, к какой линии будет близка скорость роста в нашем случае. Жаль, конечно, но n ничего не поделаешь. Некоторые функции растут гораздо быстрее прямых. Другие функции растут медленнее (значи­ тельно медленнее!) времени выполнения, что досадно, но по крайней мере мы об этом знаем. https://liveinternet.club
Нотация «О-большое»  77 Логарифмическая Нотация «О-большое» Линейная O(n) O(log(n)) n 10 ~3.32 10 100 ~6.64 100 1000 ~10 1000 1000000 ~20 1000000 Рост Линейно-логарифмическая Квадратичная Кубическая Экспоненциальная Факториальная O(nlog(n)) O(n2) ~33 100 ~664 10000 ~9965 1000000 ~19931568 1012 O(n3) 1000 1000000 109 1018 O(2n) 1024 ~1030 ~10300 ~103000000 O(n!) 3628800 ~10156 >102567 >105565700 Медленный Быстрый Образцы темпов роста, с которыми вы можете столкнуться при изучении алгоритмов. Слева направо функции растут все быстрее Если рассмотреть примеры основных функций, часто встречающихся при анализе алгоритмов, можно заметить, что логарифмические функции растут очень медленно, а линейные функции — с постоянной скоростью. С ли­ нейно-логарифмическими функциями (порядка O(n*log(n))) наблюдается небольшое ускорение, означающее, что с увеличением размеров входных данных функция растет быстрее (например, при переходе от 100 к 200 эле­ ментам рост медленнее, чем при переходе от 200 к 300). Тем не менее ли­ нейно-логарифмические функции растут не очень быстро. Многочленные функции — такие как n3 или 3n2 — 4n + 5, — напротив, быстро растут с уве­ личением размера входных данных. Наконец, экспоненциальные функции (такие как 2n или 5n+2) просто взмывают ввысь. Приведенный здесь набор функций не исчерпывает всех возможных классов функций: перечислить их все невозможно. Тем не менее есть нечто, что стоит добавить: константная функция, значение которой не меняется с изменением размеров входных данных. Класс константных функций обо­ значается O(1). В нашей модели RAM можно сказать, что все базовые команды выпол­ няются за время O(1). Темпы роста в реальном мире В предыдущем разделе мы говорили только о том, как растут функции, но хорошо это или плохо? Логарифмическая функция лучше экспоненциаль­ ной? Конечно, функции сами по себе не хороши и не плохи — все зависит от контекста. Если формула описывает ваш доход, зависящий от количества https://liveinternet.club
78  Глава 4. Нотация «О-большое» проданных единиц, то, готов поспорить, вы бы предпочли, чтобы это был факториал! При асимптотическом анализе обычно измеряются ресурсы, необходи­ мые для выполнения алгоритма, так что нас радует, когда алгоритм связан с медленно растущей функцией. Пора рассмотреть конкретный пример, чтобы дать вам более убедительное представление. Допустим, мы пытаемся понять, можем ли позволить себе включить некоторые алгоритмы в свой код исходя из времени их выполнения. В част­ ности, мы хотим рассмотреть следующие пять алгоритмов для работы с массивами: zzПоиск в отсортированном массиве. Поиск в несортированном массиве. zz zzСортировка кучей (heapsort) — алгоритм сортировки, который рассма­ тривается в главе 10. Cортировка получает массив [3,1,2] и возвращает [1,2,3]. zzГенерирование всех пар элементов в массиве. Например, парами массива [1,2,3] будут [1,2], [1,3] и [2,3]. zzГенерирование всех возможных подмассивов массива. Например, под­ массивами массива [1,2,3] будут [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]. Как выяснить, какой из алгоритмов быстрый, а какой медленный? Может, выполнить все эти алгоритмы на разных входных данных и записать, сколько времени это заняло? Вероятно, не лучшая идея, потому что это окажется слишком долгим процессом — реально долгим, как мы скоро увидим. К счастью, если знать формулу, описывающую асимптотическое по­ ведение алгоритма, можно понять порядок величины и оценить, сколько времени потребуется для обработки входных данных разных размеров (пусть это будет не точное время, которое понадобится для выполне­ ния, но хотя бы общее представление: миллисекунды, секунды или же годы!). На следующем графике приведена оценка производительности пяти алгоритмов, исходя из того что на выполнение каждой из базовых команд в нашей модели RAM уходит 10 наносекунд (нс). Для каждого алгоритма на графике приводится формула асимптотического времени его выполнения. Пока вам придется поверить мне на слово, но вскоре мы увидим пример того, как выводятся эти формулы. https://liveinternet.club
Нотация «О-большое»  79 Двоичный поиск Размер ввода n O(log(n)) 10 33 ns 20 43 ns 30 49 ns 60 60 ns 100 66 ns 1000 100 ns 1000000 200 ns 10 9 298 ns Линейный поиск O(n) 100 ns 200 ns 300 ns 600 ns 1 us 10 us 10 ms 10 s Алгоритм Сортировка кучей Все пары Все подмножества Время выполнения O(2 n) O(nlog(n)) O(n 2) 1 us 10 us 330 ns 864 ns 4 us 10 ms 1.4 us 9 ns 10 s 317 years 3.5 us 36 us 100 us 1018 years 6.6 us 100 us 10 ms 10300 years 2.7 hours 19 ms 5.5 min 3171 years Сколько времени потребуется для выполнения алгоритма? Это зависит от порядка роста времени его выполнения. Все результаты являются приближенными Как видите, логарифмические функции довольно удобны. Можно запустить двоичный поиск на миллиарде элементов, и он все равно займет время, срав­ нимое со временем распада некоторых атомов — достаточно быстро, чтобы можно было успеть заметить. Извините, что расстраиваю, но большинство алгоритмов не столь быстры. В диапазон приемлемых скоростей роста входят линейные функции, которые выполняются в мгновение ока (может, в несколько мгновений — в таком анализе важен порядок величин) даже для больших входных данных. Линейно-логарифмические функции, как и хорошие алгоритмы сорти­ ровки, все еще приемлемы: речь идет о минутах на сортировку миллиарда элементов — как раз чтобы сделать небольшой перерыв и выпить чашку кофе. А вот квадратичные функции уже трудно выполнять на больших входных данных: тут речь о тысячах лет для массива из того же миллиарда элементов, так что если бы такая работа завершилась сегодня, она должна была бы начаться во времена строительства египетских пирамид. Теперь, я надеюсь, вы понимаете, почему важно выбрать линейно-логарифмический алгоритм сортировки (такой как сортировка слиянием или сортировка кучей) вместо квадратичного (например, сортировки выбором). https://liveinternet.club
80  Глава 4. Нотация «О-большое» ПРИМЕЧАНИЕ Если вам захочется узнать больше об алгоритмах сортировки, Адитья Бхаргава прекрасно объясняет их в своей популярной книге «Грокаем алгоритмы». Наконец, поговорим об экспоненциальных функциях: со входными данны­ ми небольших размеров обычно можно справиться, но — сами видите — с 60 элементами потребуются столетия (и огромное количество подмно­ жеств!), а со 100 мы окажемся уже на уровне возраста Вселенной. Арифметика «О-большого» Когда я приводил определение нотации «О-большое», я упоминал, что в ут­ верждении f(n) = O(n) нас не интересует, какая именно прямая может пре­ высить f — главное, чтобы она была. Неважно, какую линию мы выберем, будь то y = n, y = 5n или любую другую. Важно то, что для достаточно больших значений n эта линия всегда будет находиться выше f(n). y=5n y=n f(n) n Это свойство позволяет взглянуть на определение «О-большого» под другим углом: можно сказать, что O(n) — класс всех прямых линий. Это означает, что для асимптотического анализа две линии считаются асимптотически эквивалентными, так что две функции f(n) = n и g(n) = 3n считаются экви­ валентными — у динамики роста любой из них тот же порядок величины, что и у всех остальных. Но очевидно, что 3n растет гораздо быстрее n — в 3 раза. Как же они могут быть эквивалентными? Помимо математики, ключевой момент в том, что при сравнении их с любой функцией из h(n) = O(log(n)) они обе в какой-то момент превзойдут h. И если сравнить f, g или любую функцию c*n = O(n) с z(n) = O(n*log(n)), https://liveinternet.club
Нотация «О-большое»  81 то z в какой-то момент превзойдет их, независимо от того, насколько велико значение константы c. Эти соображения напрямую влияют на то, как мы записываем выражения в нотации «О-большое», а также на вычисления выражений с составляющи­ ми, представленными в нотации «О-большое». Во-первых, как мы видели, постоянными множителями можно пре­ небречь, так что c * O(n) = O(c * n) = O(n) для всех вещественных (положи­ тельных) констант c. Второе важное заключение, которое можно сделать, — в многочлене необходимо учитывать только самый большой множитель. O(c * n + b) упрощается до O(n), ведь O(c * n + b) = c * O(n) + b * O(1) = O(n). С геометрической точки зрения это означает, что нам необязательно про­ водить прямую линию через начало координат, чтобы найти линию с более крутой траекторией роста. Пожалуй, лучше всего на конкретном примере показать, что это означает и почему. Рассмотрим функцию f(n) = 3n + 5. Построив график функции f на декартовой системе координат, мы ви­ дим, что можно найти (как минимум) две линии, g(n) = 5n и h(n) = 2n, кото­ рые ограничивают f для n ≥ 3 — то есть для n ≥ 3 у нас 2n < 3n + 5 < 5n. Но это соответствует требованиям нотации «О-большое», из чего следует, что 3n + + 5 = O(n). 5n 3n+5 2n 5 3 n Самое замечательное, что это правило упрощения не ограничивается ли­ ниями! Оно справедливо для многочленов любого порядка и вообще для выражений, суммирующих любой класс функций: O(6 * n * log(n) + 110 * n + 9999) = O(n * log(n)) + O(n) + O(1) = O(n * log(n)). https://liveinternet.club
82  Глава 4. Нотация «О-большое» Наконец, нужно быть осторожнее, когда есть непостоянные члены, которые умножаются на другие нелинейные функции или объединяются с ними. При O(n)*O(log(n)) нельзя упростить ничего, кроме нотации, сведя все формулы к O(n * log(n)). Наихудший случай, средний случай и амортизационный анализ Итак, с нотацией разобрались; осталось учесть еще ряд соображений, каса­ ющихся асимптотического анализа. При проведении асимптотического анализа обычно рассматривается наихудший из возможных случаев, если не указано иное. Представьте ли­ нейный поиск в отсортированном массиве. В каких-то случаях результат будет найден уже после нескольких сравнений, если искомое значение на­ ходится близко к началу массива, а в других — придется просканировать почти весь массив, когда цель поиска в его конце. Какой из случаев следует рассматривать? Ну, хотелось бы подойти к делу основательно и рассмотреть наихудший из возможных вариантов, поэтому он называется анализом наихудшего случая (worst-case analysis). Есть и другие ситуации, в которых мы можем столкнуться с тем, что алгоритм ведет себя по-разному. Тут уж как повезет, но все же вероятность благоприятного исхода в них будет гораздо выше, чем в случае с линейным поиском. В таких ситуациях наряду с анализом наихудшей производитель­ ности алгоритма можно также рассмотреть анализ среднего случая (averagecase analysis), который, чтобы вычислить ожидаемое значение производи­ тельности, учитывает вероятность использования множества различных входных данных. Наконец, средняя производительность некоторых структур данных будет гарантированно выше, даже с разными входными данными, при многократ­ ном выполнении одной и той же операции по сравнению с однократным прогоном. Например, в главе 12 мы увидим, что если при определенных условиях с хеш-таблицей многократно (скажем, миллион раз) повторить опе­ рации search и insert, то показатели суммарного времени всех выполнений будут лучше, чем время наихудшего варианта однократного выполнения, помноженное на миллион. В таких случаях нельзя полагаться на единичную операцию, которая, если не повезло, может оказаться аномально медленной. Но если много­ кратно повторить операцию, то можно амортизировать стоимость одной неудачной попытки, распределив ее на общее время, ушедшее на все опе­ рации. Собственно, именно это и называется амортизационным анализом (amortized analysis). В то время как анализ среднего случая подсказывает, https://liveinternet.club
Нотация «О-большое»  83 чего ожидать в среднем, но не дает никаких гарантий для отдельного вы­ полнения, амортизированный анализ устанавливает границу наихудшего случая для совокупного показателя производительности большого коли­ чества операций. Например, у вас может быть структура данных D , в которой вставка обычно выполняется за время O(1), но один раз за 100 запусков она заняла время O(n), где n — количество элементов, хранящихся в структуре данных. Мы знаем следующее: zzАнализ наихудшего случая говорит нам, что вставка для D иногда замед­ ляется до O(n). Формально это верно, но может вводить в заблуждение, не так ли? Такая медленная линейная операция будет случаться всего один раз из ста! zzАнализ среднего случая говорит о том, что если D содержит n элементов, среднее время выполнения составляет O(n/100) для одной вставки, это все еще означает линейную границу для больших значений n. Но и это не дает полной картины, потому что только одна операция из ста выполняется за линейное время. Теперь допустим, что мы вставляем m = 1000 элементов в изначально пустую структуру D. Вот какой результат даст каждая разновидность анализа: zzАнализ наихудшего случая: T(m) = O(m2). 2 zzАнализ среднего случая: T(m) = O((m/100)2) = O(m ). zzУпрощая, можно предположить, что ровно 990 из этих операций выпол­ няются за время O(1) и только 10 требуют времени O(m). Таким образом, время, потраченное на все m операций, составит T(m) = (m - 10) * O(1) + 10 * O(m) = O(m). Можно провести аналогичные рассуждения для m = k * 100, где k — константа. Амортизационный анализ соответствует нашим интуитивным представ­ лениям, когда нужно измерить производительность алгоритма на большой группе операций, каждая из которых обычно выполняется быстро и только в редких случаях — медленно. В таких случаях можно воспользоваться амортизационным анализом, чтобы выявить более четкую границу эффек­ тивности выполнения всех операций, вместе взятых. Очень похожий пример рассматривается в главе 5. ПРИМЕЧАНИЕ Чтобы избежать неприятных сюрпризов, при оценке алгоритма важно обращать внимание на то, какой тип анализа применялся. Амортизацион­ ный анализ — это отлично, но иногда, например в системах реального времени, необходимо иметь гарантии производительности при вероятном наихудшем случае. https://liveinternet.club
84  Глава 4. Нотация «О-большое» Оценка ресурсов Есть множество ресурсов, которые, возможно, понадобится измерить в за­ висимости от требований ситуации, но в этой книге мы сосредоточимся на двух. Вы уже видели, что нас интересует время выполнения — мы хотим понять, сколько времени потребуется алгоритму для получения результата. Чтобы заявить, что алгоритм A выполняется за линейное время, мы исполь­ зуем запись TA(n) = O(n). Другой критически важный ресурс — память. В некоторых случаях требуется различать, что используется: оперативная память, дисковое про­ странство или кэш. Но чаще всего для обозначения любой памяти, которая обеспечивает работу алгоритма и структуры данных, мы просто используем термин память (space), не обращая внимания на то, где она размещается. Говоря о структуре данных, нужно отслеживать, сколько дополнительной памяти требует применяемый к ней алгоритм. Под дополнительной памятью подразумевается любая память сверх той, которую уже занимает структура данных. Допустим, вы хотите инвертировать массив A (то есть переставить его элементы в обратном порядке). Для этого можно воспользоваться вторым массивом B того же размера, присвоить последний элемент A первому эле­ менту B и т. д. Таким образом мы занимаем O(n) дополнительной памяти для массива размером n, то есть S(n) = O(n). Как вариант, можно инвертировать массив на месте, с помощью одной переменной меняя местами сначала первый и последний элемент A, затем второй и предпоследний и т. д. Так как мы используем всего одну пере­ менную, размер которой не зависит от n, понадобится постоянный объем свободной памяти, что можно записать в виде S(n) = O(1). Пример асимптотического анализа Теперь, разобравшись со сводом понятий, которыми мы будем оперировать на протяжении всей книги, замкнем круг этой главы — воспользуемся но­ тацией «О-большое», чтобы оценить на упорядоченных массивах произво­ дительность двух поисковых алгоритмов, которым мы дали определение. Как это сделать? Можно рассмотреть все действия, выполняемые алго­ ритмом, на абстрактном уровне. Также можно внимательно просмотреть код и отметить ожидаемое асимптотическое время выполнения каждой ко­ манды. Затем вывести выражение, по которому можно вычислить итоговую формулу, и в большинстве случаев этого будет достаточно. Кстати, главная цель этого анализа — найти верхние границы времени выполнения алгоритма и необходимой ему дополнительной памяти (то есть https://liveinternet.club
Пример асимптотического анализа  85 найти формулу, выявляющую максимальный предел времени выполнения алгоритма). Доказательство того, что эти формулы также являются и нижними гра­ ницами (то есть что невозможно найти функцию, которая растет медленнее них), выходит за рамки данной книги. Линейный поиск Если хорошенько поразмыслить, наша интуиция подскажет, что в худшем случае при линейном поиске придется сканировать весь массив. Но давайте в качестве упражнения рассмотрим следующий код: def linear_search(self, target): for i in range(self._size): if self._array[i] == target: return i elif self._array[i] > target: return None return None # Повторяется F(n) раз # Стоимость: O(1) # Стоимость: O(1) # Стоимость: O(1) # Стоимость: O(1) # Стоимость: O(1) Первая команда — это цикл for: циклы for являются множителями, то есть стоимость команд в теле цикла должна умножаться на количество итераций. Допустим, цикл повторяется F(n) раз (нам все еще нужно определить значе­ ние F) и время выполнения каждой из четырех команд в цикле — величина постоянная. Последняя команда выполняется по завершении цикла только один раз, и время ее выполнения — также постоянная величина. Таким образом, формула времени выполнения для линейного поиска вы­ глядит так: T(n) = F(n) * [O(1) + O(1) + O(1) + O(1)] + O(1) = F(n) * O(1) + O(1) = O(F(n)) + O(1) = O(F(n)). Теперь необходимо найти выражение для F (то есть понять, сколько раз повторяется цикл for). Цикл for настроен так, чтобы повториться n раз, но внутри него есть два оператора возврата (return), которые заставляют по­ ток выйти из цикла. Например, если мы обнаружим искомое в первом же элементе, то выйдем из цикла после первой итерации. С другой стороны, при неудачном поиске (если поиск не находит совпа­ дений) цикл проходит все n итераций. Как согласовать эти противоположные сценарии? Можно сделать одно из двух: сценарий наихудшего случая: если не повезло, потребуются O(n) итераций. Рассмотреть вариант с необходимостью отсканировать среднее арифмеzz тическое от общего количества элементов, прежде чем найдем совпадение со своим запросом. zzРассмотреть https://liveinternet.club
86  Глава 4. Нотация «О-большое» Даст ли вариант со средним арифметическим лучший результат? Необяза­ тельно: в среднем (не зная заранее, как распределены элементы в массиве и какие из них понадобится вызвать) нам потребуется n/2 попыток, чтобы найти совпадение. Но константы в нотации «О-большое» можно упрощать, и O(n/2) = O(n). Следовательно, можно сказать, что для линейного поиска T(n) = O(n): не­ удивительно, что линейный поиск выполняется за линейное время! Два важных момента: zzАнализируя код, мы оцениваем реализацию алгоритма. Наш анализ будет хорош настолько, насколько хороша сама реализация. zzСледите за скрытыми затратами. Для примера возьмем цикл for: с каждой итерацией связаны дополнительные затраты на увеличение переменной цикла и проверку условия выхода. В данном случае все это операции, вы­ полняемые за постоянное время, но на практике это не всегда так. И каж­ дый раз, когда вы вызываете метод внутри цикла, необходимо помнить и о связанных с этим затратах. Наконец, как насчет дополнительной памяти? Легко показать, что этот метод требует лишь постоянного объема памяти. Двоичный поиск С линейным поиском разобрались. Теперь обратимся к двоичному поиску, начиная непосредственно с кода: def binary_search(self, target): left = 0 right = self._size - 1 while left <= right: mid_index = (left + right) // 2 mid_val = self._array[mid_index] if mid_val == target: return mid_index elif mid_val > target: right = mid_index - 1 else: left = mid_index + 1 return None # O(1) # O(1) # O(1), G(n) итераций # O(1) # O(1) # O(1) # O(1) # O(1) # O(1) # O(1) # O(1) Каждая строка кода выполняется за постоянное время, кроме цикла while, — тут снова приходится следить за скрытыми затратами, но, к счастью, их в данном случае нет. Выражение можно приблизительно упростить до T(n) = O(1) + O(1) + G(n) * [O(1) + O(1) + O(1) + O(1) + O(1) + O(1)+ O(1)] + O(1) = O(1) + G(n) * O(1) = O(G(n)) + O(1) = O(G(n)). https://liveinternet.club
Пример асимптотического анализа  87 Таким образом, нужно найти выражение для G(n) — функции, описыва­ ющей количество итераций цикла while. Цикл продолжается, пока левый указатель left не перейдет за пра­ вый указатель right. Сначала left указывает на первый элемент массива, а right — на последний: все элементы массива содержатся между этими двумя указателями. Трудно спрогнозировать, binary_search(6) как будут обновляться left и right, потому 6 что это зависит от конкретных значений эле­ > ментов и искомого значения, а следователь­ 1 2 3 4 5 6 7 9 10 но, тенденции их изменений непредсказуемы. 0 1 2 3 4 5 6 7 8 Однако мы можем сделать некоторые выводы 9 = n left right относительно расстояния между ними. 6 Изначально, как мы знаем, это расстояние < (количество элементов массива в диапазоне от left до right, включая и их самих) равно 1 2 3 4 5 6 7 9 10 0 1 2 3 4 5 6 7 8 n. После одного сравнения, если совпадение 4 < n/2 не найдено, мы отбрасываем более половины left right элементов массива. Иначе говоря, один из 6 = двух указателей перемещается по крайней мере на половину расстояния. И с каждым 1 2 3 4 5 6 7 9 10 шагом расстояние между ними все так же 0 1 2 3 4 5 6 7 8 1 < n/4 будет делиться надвое, пока не найдется сов­ left=right падение или расстояние не сократится до 0. Сколько раз можно сокращать расстояние вдвое? Столько, сколько n мож­ но разделить на 2, пока оно не сведется к 0 (мы исходим из целочисленного деления). Это число равно в точности log2(n), так что главный цикл метода может выполняться не более O(log(n)) итераций. Заменяя G(n) на O(log(n)) в выражении для T(n), мы наконец можем сказать, что для двоичного поис­ ка T(n) = O(log(n)). А это, как вы уже должны понимать, очень хорошо — намного лучше линейного поиска, особенно когда приходится выполнять большое количество операций! Но вспомните: загвоздка в том, что дво­ ичный поиск может выполняться только с отсортированными массивами, тогда как для линейного поиска с точки зрения асимптотического анализа нет различий между отсортированным и несортированным массивом. Что касается памяти, то, как и при линейном поиске, задействуется только O(1) дополнительной памяти. Итак, теперь вы знаете, как в главе 3 мама Марио выиграла состязание по поиску и почему это была хорошая идея — сначала отсортировать карточки! На этом завершается наше знакомство с нотацией «О-большое» и асим­ птотическим анализом: он будет широко использоваться в книге. https://liveinternet.club
88  Глава 4. Нотация «О-большое» УПРАЖНЕНИЕ 4.1 Используя нотацию «О-большое» и асимптотический анализ, выведите время выполнения и дополнительные затраты памяти для операций вставки, удаления и перебора в отсортированных массивах. Каково со­ отношение этих показателей с аналогичными для тех же методов, при­ меняемых в несортированных массивах? Итоги zzДля оценки эффективности алгоритма можно провести асимптотический ƒ ƒ ƒ ƒ ƒ ƒ анализ — то есть найти формулу, выраженную в нотации «О-большое», которая описывает поведение алгоритма в модели машины с произволь­ ным доступом (RAM). zzМодель RAM представляет собой упрощенную вычислительную модель базового компьютера, которая имеет ограниченный набор базовых ко­ манд. Все эти команды выполняются за постоянное время. zzНотация «О-большое» применяется для классификации функций на основании асимптотической скорости их роста. Эти классы функций ис­ пользуются, чтобы выразить, насколько быстро с увеличением размера входных данных растут время выполнения и затраты памяти, использу­ емой алгоритмом. zzСамые распространенные классы функций, которые чаще всего встреча­ ются в этой книге: ƒ O(1)-постоянная — затраты ресурса не зависят от n (например, базо­ вая команда). ƒ O(log(n))-логарифмическая — медленный рост, как при двоичном поиске. ƒ O(n)-линейная — функция, растущая со скоростью роста входных данных, — например, количество сравнений, необходимых при ли­ нейном поиске. ƒ O(n*log(n))-линейно-логарифмическая — этот порядок роста характе­ рен для приоритетных очередей. ƒ O(n2)-квадратичная — функции этого класса растут слишком быстро, чтобы сохранять контроль над ресурсами, превышающими миллион элементов. Как пример — количество пар в массиве. ƒ O(2n)-экспоненциальная — функции с экспоненциальным ростом по­ казывают огромные значения уже для n>30. Таким образом, если вы хотите вычислить все подмножества массива, следует знать, что это можно сделать только с небольшими массивами. https://liveinternet.club
Динамические массивы. Работа с наборами данных динамического размера 5 В этой главе 9 9 9 9 9 9 9 9 9 9 Ограничения статических массивов Динамические массивы как решение проблем статических массивов Достоинства и недостатки динамических массивов; в каких случаях стоит их использовать Создание динамического массива Лучшие стратегии расширения и сжатия динамических массивов В начальных главах мы рассмотрели, насколько массивы функционально адаптивны, а также обсудили некоторые варианты их практического при­ менения. Но заметили ли вы, что во всех рассмотренных примерах макси­ мальное количество элементов, которое можно разместить в массиве (то есть его размер), определяется заранее и впоследствии не подлежит изменению? Это может сработать во множестве ситуаций, но, конечно, не всегда — было бы наивно так думать. Есть множество примеров реальных приложений, в которых надо быть гибкими и изменять размеры структур данных, чтобы удовлетворить расту­ https://liveinternet.club
90  Глава 5. Динамические массивы щий спрос. Когда массивы дополняются возможностью изменять размер, получаются динамические массивы. В этой главе мы рассмотрим примеры, где гибкость предоставляет преимущество, а затем обсудим, как реализовать динамические массивы. Ограничения статических массивов Массивы — классная штука, верно? Наш маленький друг Марио тоже так думает: в них удобно хранить данные и можно быстро получить доступ к элементам, если помнишь их позиции в массиве (то есть индексы). Марио настолько увлечен изучением того, как пользоваться массивами, что не в состоянии перестать говорить о них! У него есть школьная подруга Ким, которая разделяет его страсть к научно-техническим дисциплинам, инженерии и компьютерам — она тоже фанатка этого всего. Ким уже кое-что читала о массивах на занятиях по computer science и пытается немного охладить пыл Марио, выдвигая свои возражения и под­ черкивая присущие массивам ограничения. Они какое-то время спорили, но никто в итоге не победил. Дома после ужина Марио просит маму помочь. Она объясняет, что до сих пор Марио имел дело только со статическими массивами и что у них есть свои недостатки. Фиксированный размер Самая очевидная проблема статических массивов — их размер нельзя из­ менить! Полагаю, все с этим согласны, но что это означает на самом деле? Что из этого следует? У проблемы есть два аспекта. Главное ограничение статических массивов — фиксированный размер. И когда массив уже заполнен, приходится создавать новый, большего раз­ мера, и переносить в него элементы из старого. 7 3 4 1 0 1 2 insert(2) Ошибка, массив заполнен 3 1. Создать массив большего размера ? ? ? ? ? 0 1 2 3 4 2. Скопировать все элементы 7 3 4 1 0 1 2 3. Вставить 7 3 4 1 ? 3 0 1 2 insert(2) 7 3 4 1 2 0 1 2 3 4 https://liveinternet.club 3 4
Ограничения статических массивов  91 Эта проблема связана не с массивом как абстрактной структурой данных, а с низкоуровневым функционалом языков программирования (вспомним, это различие обсуждалось в главе 2), потому что массив реализуется как непрерывный блок памяти. Можно себе представить, что создание ново­ го массива для замены уже существующего требует значительных затрат с точки зрения как переноса данных, так и выделяемой/освобождаемой памяти. Вторая проблема является прямым следствием первой: так как из­ менение размера массива обходится дорого, необходимо заранее зарезерви­ ровать достаточно места, чтобы не пришлось заниматься этим изменением. Границы массива Индекс массива 0 1 2 3 4 2 0 -1 4 5 Пустая ячейка 5 6 7 8 9 0xf2 0xf3 0xf4 0xf5 0xf6 0xf7 0xf8 0xf9 0xfa 0xfb 0xfc 0xfd В главе 3, когда мы давали определение классу SortedArray, мы добавили в его конструктор аргумент, задающий максимальную емкость массива, чтобы мы могли заранее выделить всю память, необходимую для хранения максималь­ ного количества элементов, которые можно внести в массив. Но и такой способ слишком расточителен. Например, если я знаю, что на пиковой нагрузке нужно поддерживать до 10 000 элементов, но бˆольшую часть времени в массиве их будет содержаться всего 100, то остальные 99 % памяти, выделенной массиву, будут оставаться пустыми. В таких ситуациях приходится выбирать: или мы выделяем больший массив и растрачиваем память, или выделяем память по мере необходимости, «точно в срок», но периодически перемещаем элементы из меньшего массива в больший (и об­ ратно, когда удаляем элементы). 0 1 2 0 2 Выделение малого массива; после следующей вставки он заполнен Выделение большого массива; большинство его ячеек не используется 0 1 2 0 2 3 4 5 6 7 8 9 Достоинства и недостатки Было бы здорово, если бы существовала версия массива помощнее, который растет и сжимается по мере надобности и без избыточных затрат. Но, увы, такой нет. В следующей главе рассматриваются связанные списки — струк­ https://liveinternet.club
92  Глава 5. Динамические массивы тура данных более гибкая, чем массивы, с легко изменяемыми размерами. Однако такая гибкость не дается даром. Если вы захотите прочитать 4-й элемент связанного списка, это не удастся сделать напрямую — сначала при­ дется прочитать первые три. Вы можете возмутиться: если нет никакой более мощной и гибкой версии массивов, тогда почему же эта глава называется «Динамические массивы»? Я понимаю, вы ощущаете некоторое замешательство, и на то есть веские причины. Тут дело вот в чем: динамические массивы — это не какая-то иная структура данных и не какой-то особый функционал программирования, чудесным образом позволяющий и съесть пирожок, и сохранить его (то есть и без дополнительных затрат изменять размеры, и не терять всех пре­ имуществ массивов). С точки зрения разработчика динамический массив ведет себя точно так же, как и статический, не считая того что при попытке добавить новый элемент в заполненный динамический массив он не выдаст ошибку. Соб­ ственно, вам вообще не придется беспокоиться о размере динамического массива: структура данных регулирует его размер за вас. Срывая покровы, динамические массивы реализуются посредством ста­ тических, и нам все так же приходится расплачиваться за выделение ново­ го массива и ликвидацию старого каждый раз, когда нужно изменить его размер. Ключевой момент динамических массивов, что делает их хорошим компромиссом, это их стратегия расширения и сокращения статических массивов, на которых они выстроены. Есть и нюанс: динамические массивы немного замедляют некоторые операции. И это естественно — время от времени приходится платить за изменение размера массива. Таким образом, если мы знаем заранее, что массив будет содержать определенное количество объектов или что количество элементов будет слегка колебаться вокруг определенного зна­ чения, нам несомненно стоит предпочесть статический массив. Но если количество элементов постоянно растет или время от времени заметно меняется (и в какие-то моменты даже значительно сокращается), то пред­ почтительнее динамический массив, чтобы не транжирить память и обес­ печить гибкость. В следующем разделе обсудим, почему эти стратегии важны и какие из них работают лучше. Затем, закрепив понимание того, как они работают, можно перейти к их реализации. https://liveinternet.club
Стеллаж с трофеями  93 Как увеличить размер массива? Мы знаем, что простых путей увеличить размер массива нет, и знаем, что каждый раз, когда старый массив заполнится, а нужно больше места, нам предстоит пройти через боль создания нового массива. Но при этом воз­ никает ряд вопросов: zzКогда следует изменять размер массива? zzНасколько больше должен быть новый массив? zzЧто делать, когда мы удаляем элементы? Нужно ли при этом сжимать массив? Пришла пора снова встретиться с нашей юной подругой Ким, которая по­ может разобраться с этими вопросами. Стеллаж с трофеями У Ким страсть к научно-техни­ ческим дисциплинам, но что она по-настоящему любит, так это ро­ бототехнику. Она выиграла ряд конкурсов от школьного до регио­ нального уровня, и каждый раз ставит робота, завоевавшего приз, на специальный стеллаж в их се­ мейной гостиной. Она настолько хороша в робототехнике и столько выигрывает конкурсов, что ресур­ сы стеллажа уже исчерпаны. Родители предлагают расчистить пространство и убрать какую-то часть самых старых роботов — по крайней мере тех, что она собрала в младших классах. Но позиция Ким бескомпромиссна: об этом не может быть и речи. Она не желает ничего выбрасывать, требуя шкаф побольше — для новых призов. После долгих слез и обид родители Ким сдают­ ся и соглашаются на новые стеллажи для новых призов. Но есть один нюанс: старые шкафы не­ возможно нарастить и придется утилизовать их, а Ким должна заплатить за новые (и утилизацию старых) сама — из своей копилки. Если ее денег не хватит на новый шкаф, то ей придется лишиться самых давнишних роботов. https://liveinternet.club
94  Глава 5. Динамические массивы Итак, у Ким нет выбора: она должна найти оптимальную стратегию, что­ бы в долгосрочной перспективе сэкономить как можно больше денег. (Ктото спросит, почему бы не использовать модульную мебель. Резонно, но для нашего сценария будем считать, что этот вариант недоступен.) Стратегия 1: расширить на один элемент Чтобы обозначить отправную точку, Ким оценивает простейшую из воз­ можных стратегий роста. Когда на полках уже не останется места и как только у нее появится новый робот, она выбросит старый стеллаж и соберет новый, который сможет вместить только обновленное количество роботов, но не более того. Места больше нет! Старый размер: 2 места Добавляется: 1 место Допустим, в первый стеллаж вмещается четыре робота, и когда она выигры­ вает пятый приз, то собирает новый стеллаж — для пяти роботов, затем для шести и т. д. Предположим, стоимость стеллажей линейно зависит от общего количества роботов, которые в них помещаются (скажем, из расчета 200 долларов на робота). Таким образом, ей придется заплатить $200 * 5 + $200 * 6 + $200 * 7 и т. д. При третьей замене выйдет $200 * (5 + 6 + 7) = $3600 — и, вероятно, ей при­ дется распрощаться со всеми карманными деньгами, которые ей еженедель­ но выделяют, причем вплоть до старших классов. Такая перспектива Ким не радует, поэтому она продолжает искать вари­ анты получше. https://liveinternet.club
Стеллаж с трофеями  95 Стратегия 2: расширить на X элементов Похоже, увеличение стеллажа за один раз всего на одно дополнительное место — неудачный вариант. Интуитивно понятно, что к моменту сборки он уже заполнен, и когда понадобится добавить нового робота, весь процесс придется запускать заново. Кажется, этот вариант получше. Остается свободное место. Добавляется: 4 места Старый размер: 3 места Может, Ким стоит зарезервировать место на будущее и, собирая новый стел­ лаж, сделать его на четыре позиции больше? Это немного смягчит ситуацию, поможет Ким выиграть время и, собирая призы, поднакопить чуть больше карманных денег, прежде чем ей придется платить за новый стеллаж. Но правильная ли цифра — четыре позиции? Весной будет целый ряд ро­ бототехнических выставок, а Ким обычно участвует во всех, в каких только может. Что если она выиграет медали в 5 из них, в 10, или вообще во всех? Если она выиграет более 4 призов за неделю, прежде чем стеллаж доставят из столярной мастерской, она может даже не успеть собрать новый стеллаж и воспользоваться им. Может, ей стоит заложить на 5 позиций больше — или на 8, или на 10? Где та золотая середина? Стратегия 3: удвоить размер Наконец, Ким рассматривает третью стратегию. Вместо того чтобы увели­ чивать размер на какую-либо заданную величину, она каждый раз удваивает следующий стеллаж. Это гарантирует, что каждый новый стеллаж будет вмещать ее победную коллекцию в течение периода, равного совокупному сроку службы всех предыдущих стеллажей, вместе взятых. https://liveinternet.club
96  Глава 5. Динамические массивы Следующий стеллаж для победной коллекции прослужит 12 недель! 3 недели 6 недель 3 недели 12 недель 6 недель При удвоении размера стеллажа ожидаемый срок службы каждого следующего будет равен суммарному сроку службы всех предыдущих, потому что в нем можно будет разместить еще столько же роботов, сколько было в том, который мы сейчас меняем Сейчас, когда ее стеллаж на 4 позиции заполнен и она получила новую на­ граду, ей предстоит собрать новый — на 8 позиций. Следующий будет на 16 позиций, а тот, что за ним, сможет вместить уже 32 робота. Разумеется, если она собирает более крупные стеллажи, ей приходится вкладывать больше денег на перспективу, но зато потом какое-то время она может не волноваться. И если она быстрее соберет новые призы, то новые стеллажи будут соответствовать этому темпу. Но в итоге она потратит денег больше или меньше? Какова наилучшая стратегия? Сравнение стратегий Есть только один способ это выяснить: математический подсчет. У Ким амбициозная цель — завоевать 60 наград к старшим классам. И ей лишь осталось вычислить, во что обойдется покупка все более вместитель­ ных стеллажей, пока дело не дойдет до такого, в который поместятся как ми­ нимум 60 ее роботов, и сопоставить друг с другом затраты разных вариантов. И нет, родители не намерены покупать ей стеллаж на 60 позиций уже сейчас. Ведь это будет как бы провалом их задачи: вернуть себе свою гостиную, не отдать ее роботам. В таблице 5.1 сравниваются затраты, связанные с каждой из этих трех стратегий. https://liveinternet.club
Стеллаж с трофеями  97 Таблица 5.1. Сравнение затрат, связанных с разными стратегиями постепенного увеличения размера стеллажа достижений Стратегия Общие затраты (выражение) Итоговые затраты Увеличение размера на 1 $200 * (5 + 6 + 7 + 8 + … + 60) $364 000 Увеличение размера на 4 $200 * (8 + 12 + 16 + 20 + … + 56 + 60) $95 200 Удвоение размера $200 * (8 + 16 + 32 + 64) $24 000 Таким образом, на первый вариант решения Ким придется потратить все деньги, оставленные на колледж. Второй получше, но все равно, чтобы столь­ ко выплатить, придется подрабатывать. Третий вариант тоже затратный, но гораздо дешевле первых двух. Можно заметить, что в последней стратегии задействовано меньше всего слагаемых, — возможно, в этом и разгадка! Фактически, ей придется купить всего четыре новых стеллажа за весь период, что гораздо выигрышнее про­ чих вариантов! Правда, тратить 24 000 долларов на шкафы — это все же перебор… Так что в конце концов Ким может прислушаться к доводам родителей, согла­ ситься на стеллаж, где помещается 10 роботов, и выставить только свои лучшие творения. Тем не менее эти же рассуждения будут справедливы и по отношению к массивам — и в этом их ощутимая ценность. Применение стратегий к массивам Итак, из этих примеров и математических расчетов выходит, что лучшая стратегия — удваивать массив каждый раз, когда нам требуется больше места. Пока вы не купились на эту идею, можете спросить: «А что, если вместо прироста на 4 позиции мы будем наращивать стеллаж на 16?» Если посчитать, то $200 * (20 + 36 + 52 + 68) дают $35 200 — ближе к стратегии удвоения, но на 50% дороже. У стратегии постоянного при­ ращения может быть какая-то оптимальная величина, но она подогнана под специфические ситуации (как наш пример с 60 роботами). Но если бы мы точно знали, сколько памяти нам понадобится, мы взяли бы просто статический массив! Как упоминалось ранее, аналогичные рассуждения применимы и к ди­ намическим массивам. Допустим, нам надо реализовать динамический массив, начав с одного элемента, а затем выделяя новый массив большего https://liveinternet.club
98  Глава 5. Динамические массивы размера каждый раз, когда старый заполняется. Давайте также представим, что в итоге мы добавим в массив 100 элементов. Каждый раз, когда мы создаем новый массив, приходится не только выделять память, но и переносить элементы старого массива в новый. Например, для стратегии +1 при первом изменении размера в старом массиве есть только один элемент, который нужно будет перенести в но­ вый (с размером 2). Затем переносим два элемента в новый массив с раз­ мером 3 и т. д. Аналогичным образом это работает и для других стратегий. Вычисления затрат (в отношении элементов, которые мы переносим из старого массива в новый) и итоговые затраты сведены в таблице 5.2. Таблица 5.2. Сравнение количества операций присваивания значений в разных стратегиях вставки 100 элементов в динамический массив Стратегия Количество присваиваний (выражение) Итого Увеличение размера на 1 1 + 2 + 3 + 4 + 5 + 6 + … + 98 + 99 4851 Увеличение размера на 4 1 + 5 + 9 + 13 + … + 93 + 97 1225 Удвоение размера 1 + 2 + 3 + 6 + 12 + 32 + 64 127 Различия в результатах поражают! Мы не будем углубляться в математику, но позвольте дать представление о том, откуда берется этот результат. Вы­ ражение для первой стратегии представляет собой сумму первых 99 целых чисел, и оно распространяется на любое целое число n: существует хорошо известная формула, согласно которой результат суммирования первых n целых чисел равен n * (n + 1) / 2. Другими словами, количество элементов, которые придется копировать, будет расти квадратично. С последней стратегией количество элементов на каждом шаге удваивает­ ся. Это означает, что мы выжидаем вдвое дольше, прежде чем снова меняем размер массива. Перед нами хорошо известная математическая прогрессия, и можно доказать, что если начать с массива с размером 1 и применять эту стратегию для получения массива с размером n, в худшем случае понадо­ бится скопировать O(n) элементов. В главе 4 было показано, что квадратичная функция растет намного быстрее линейной, поэтому не приходится сомневаться, что нам нужна стратегия, обеспечивающая линейно растущие затраты. https://liveinternet.club
Нужно ли еще и сокращать массивы?  99 7 7 0 0 Копирование Копирование 7 3 0 7 3 1 0 1 7 3 4 0 1 2 7 3 4 1 0 1 2 7 3 4 1 3 0 1 2 3 7 3 4 1 2 0 1 2 3 4 7 3 4 1 2 1 0 1 2 3 4 5 7 3 4 1 2 1 3 0 1 2 3 4 5 6 7 3 4 1 2 1 3 5 7 3 4 1 2 1 3 5 Итого: 28 операций копирования Итого: 7 операций копирования 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 Изменение размера массива со стратегией + 1 в сравнении с изменением размера массива со стратегией x 2. Налицо разница в количестве необходимых операций копирования Нужно ли еще и сокращать массивы? Существует еще один аспект, на который мы до сих пор не обращали вни­ мания. Мы согласились, что удваивать размер динамического массива при заполнении — хорошая идея, но ничего не сказали о том, что делать, когда мы удаляем элементы. В примере со стеллажом такая ситуация вообще не возникала, потому что Ким никогда не желала убирать с полок ни роботов, ни призы. Итак, чтобы лучше разобраться, как нам быть с этим, давайте рассмотрим другой пример. Он будет более отвлеченным, ближе к computer science: давайте представим, что нам нужно реализовать динамический массив, чтобы в режиме реаль­ ного времени отслеживать обработку заказов интернет-магазина. Когда по­ ступает новый заказ, он добавляется в массив; когда заказ исполнен и закрыт, то удаляется из массива. Входные данные хранятся в массиве в порядке их поступления (но заказ может быть удален в любой момент). https://liveinternet.club
100  Глава 5. Динамические массивы Проигрыватель HD-фотокамера виниловых Джамп стрит, 21 дисков Эбби Роуд Создан: 26.09.1969 0 Создан: 06.07.1990 1 Плазменный экран 180" Создан: 10.01.2022 2 3 Итак, мы начинаем с небольшого массива и удваиваем его размер каждый раз, когда он заполняется, а точнее, каждый раз, когда в заполненный мас­ сив нужно добавить новый элемент. Массив растет по мере поступления новых заказов, но в какой-то момент мы еще и начинаем удалять закрытые (исполненные) заказы. И как же нужно регулировать массив, когда заказы удаляются? Обязательно ли что-либо предпринимать, когда мы удаляем элементы? Надо ли нам это? Все сильно зависит от интенсивности поступления новых заказов и ско­ рости исполнения старых, но в общем случае мы знаем, что нужно быть го­ товыми к корректировке массива, когда удаляется много элементов. Почему? Рассмотрим следующую ситуацию: на «черную пятницу» магазин получает лавину заказов, поэтому массив приходится многократно расширять для их хранения. Например, на праздники у вас 10 000 открытых заказов одно­ временно, тогда как обычно, в любой другой момент, их всего 100. Если не изменять размер массива после закрытия заказов «черной пятницы», то, получается, вы тратите 99 % памяти на огромный и по большей части пустой контейнер. А если оборот компании вырастет в 100 и более раз, речь пойдет уже о миллионах пустых элементов (и следовательно, гигабайтах памяти). Таким образом, похоже, динамический массив нужно каким-то образом со­ кращать. Но как именно? Сократить вдвое при удалении Одна из возможных стратегий (и, пожалуй, первое, что приходит в голову) — сокращать массив вдвое, как только окажется, что полови­ на элементов в нем уже не используется. Пре­ имущество этой стратегии — сокращение не­ используемой памяти, которая никогда теперь не будет заполнена более чем на 50 % от своего общего объема. Однако есть один граничный случай, который необходимо рассмотреть, что­ бы понять, почему эта стратегия может ока­ заться не такой уж хорошей идеей. https://liveinternet.club 7 3 4 1 0 1 2 3 insert(2) Сработало удвоение 7 3 4 1 2 ? ? ? 0 1 2 3 4 delete(4) 5 6 7 Сработало сокращение вдвое 7 3 1 2 0 1 2 3
Реализация динамического массива  101 Возьмем заполненный динамический массив A с восемью элементами. Добавление девятого элемента X требует удвоения размера массива. Мы создаем новый массив B на 16 элементов, перемещаем элементы и до­ бавляем X . Но если мы вскоре удаляем какой-либо элемент, половина B становится пустой. Согласно действующему подходу массив наполовину сокращается, формируя массив C на 8 элементов. Теперь C снова заполнен, и любое добавление потребует очередного изменения размеров. Это из­ менение размеров туда и обратно, особенно для очень больших массивов, может резко снизить производительность. Нам нужен более эффективный подход. Более разумное сокращение Попробуем другое решение: если удаление эле­ мента оставляет массив полупустым — мы не паникуем. Вместо того чтобы немедленно из­ менять его размер, мы подождем. Как долго? Есть много хороших вариантов, но я соби­ раюсь придерживаться самого безопасного: мы дождемся, пока останется занятой только четверть массива. Это означает, что массив с емкостью 8 мы сократим вдвое только тог­ да, когда у него наберется 6 неиспользуемых элементов. В этом случае после изменения размеров но­ вый массив появится пуст наполовину и какоето время у нас будет возможность вставлять новые элементы, пока он не заполнится. Будет ли такое решение идеальным? 7 3 4 1 0 1 2 3 insert(2) Сработало удвоение 7 3 4 1 2 ? ? ? 0 1 2 3 4 delete(4) 5 6 7 7 3 1 2 ? ? ? ? 0 1 2 3 4 delete(3) 5 6 7 7 1 2 ? ? ? ? ? 0 1 2 3 4 delete(7) 5 6 7 Сработало сокращение вдвое 1 2 ? ? 0 1 2 3 ПРИМЕЧАНИЕ Идеального решения вообще не существует, потому что иде­ альный выбор можно сделать, только если нам заранее известна вся последова­ тельность вставок и удалений. Но это достаточно разумное добротное решение, которое неплохо работает в большинстве случаев. Реализация динамического массива Теперь, когда мы раскрыли закулисные тайны динамических массивов, можем переходить к их реализации. Вспомним, что мы обсуждали в этой главе: динамические массивы можно реализовывать на базе статических, а затем деликатно (незаметно для клиента) изменять их размеры по мере https://liveinternet.club
102  Глава 5. Динамические массивы ­ адобности. Чтобы изменять размеры вспомогательных статических мас­ н сивов с данными, мы применим следующую стратегию: 1. Начинаем с массива размером 1 (если только клиент не указал начальную емкость). 2. Если надо вставить новый элемент, а вся емкость статического массива уже заполнена, удваиваем размер массива. 3. Если после удаления элемента массив остается заполнен не более чем на четверть от максимальной емкости, сокращаем его вдвое. Готово! Остается написать немного кода, чтобы он сотворил для нас это волшебство. Класс DynamicArray В оставшейся части главы мы займемся реализацией несортированного динамического массива. Это означает, что элементы не упорядочены. Предположим, что элементы будут храниться в порядке их добавления. Когда элемент удаляется из массива, следующие за ним элементы сдвига­ ются, чтобы заполнить пропуск, оставшийся после удаленного элемента (это решение еще подробно рассмотрим в разделе, посвященном методу delete). Перейдем к реализации. Как обычно, весь код вместе с документацией и тестами доступен в репозитории книги на GitHub: https://mng.bz/67J6. Нач­ нем с создания класса: class DynamicArray(): def __init__(self, initial_capacity = 1, typecode = 'l'): self._array = core.Array(initial_capacity, typecode) self._capacity = initial_capacity self._size = 0 self._typecode = typecode Как и с классом SortedArray, мы снова будем использовать базовый класс посредством композиции, создавая статический массив как внутренний атрибут, которым будет закулисно распоряжаться класс DynamicArray. Как видите, это простой конструктор, очень похожий на конструктор для SortedArray. Но обратите внимание: на этот раз необходимо сохранить тип элементов массива, то есть аргумент typecode. Дело в том, что при каждом изменении размера нам потребуется создавать новые статические масси­ вы, а для этого нужно будет передавать аргумент typecode конструктору core.Array. core.Array https://liveinternet.club
Реализация динамического массива  103 Вставка Вставка мало чем отличается от того, что мы делали со статическими масси­ вами в главе 2. Единственная разница — перед выполнением вставки надо проверить, остается ли в массиве свободное место. Если статический мас­ сив заполнен, необходимо изменить его размер, создав новый массив вдвое большей емкости и переместить все элементы из старого массива в новый. Начнем с определения вспомогательного метода изменения размеров: Сохранив в локальной переменной ссылку на ста­ рый массив, можно приступить к созданию ново­ го, вдвое большего размера def _double_size(self): old_array = self._array self._array = core.Array(self._capacity * 2, self._typecode) self._capacity *= 2 for i in range(self._size): self._array[i] = old_array[i] Все элементы необходимо перенести из старого массива в новый Ничего особенного. Нужно лишь реализовать то, что мы обсуждали в этой главе и ранее в этом разделе. Вставка в массив с пустыми ячейками 7 3 4 ? 0 1 2 3 Вставка в заполненный массив 7 3 4 1 0 insert(1) Массив полон 7 3 4 1 0 1 2 1 2 3 insert(2) _double_size() 3 7 3 4 1 ? ? ? ? 0 1 2 3 4 5 6 7 _array[4]=2 7 3 4 1 2 ? ? ? 0 1 2 3 4 5 6 7 Теперь, когда у нас есть вспомогательный метод изменения размера базового статического массива, мы сможем реализовать метод insert проще и с более ясным результатом: Когда выполнение доходит до этой точки, ста­ def insert(self, value): новится очевидно, что self._size < len(self._array) if self._size >= self._capacity: self._double_size() self._array[self._size] = value self._size += 1 https://liveinternet.club
104  Глава 5. Динамические массивы За какое время будет выполняться метод insert и сколько дополнительной памяти ему понадобится? Обращаясь к коду, мы видим, что команды в ме­ тоде вставки выполняются за O(1) шагов и требуют O(1) дополнительной памяти, кроме вызова _double_size(). Помните: каждый раз, когда мы вызываем другой метод, время его выпол­ нения вносит свой вклад в общее время выполнения, поэтому необходимо анализировать также и внутренние вызовы. И действительно, здесь кроется маленькая тонкость: при вызове в массиве размером n метод _double_size() создает новый массив (используя O(n) дополнительной памяти) и переме­ щает O(n) элементов. Предупреждение в связи с анализом затрат памяти: пусть вас не смущает тот факт, что часть памяти освобождается после использования. Необходимо учитывать всю выделенную память, даже если она позже освободится. В свою очередь, insert также занимает время O(n) и использует O(n) дополнительной памяти при вызове метода изменения размера массива. Это означает, что потребность в ресурсах, необходимых методу, находится в линейной зависимости от количества элементов n. Тот факт, что в наихудшем случае время выполнения insert и сопутству­ ющие затраты памяти растут линейно, — это плохая новость. У нас больше нет постоянного времени для insert при наихудшем случае, как это было со статическими массивами. Впрочем, при более глубоком анализе можно также найти и светлую сторону. Я упоминал, что эти требования справедливы при вызове вспо­ могательного метода изменения размера, а как насчет случаев, в которых изменять размер не нужно? В сценариях с наилучшим случаем выполняют­ ся команды только с постоянным временем и дополнительной памяти не тратится. Таким образом, если нам повезло и изменять размер массива не нужно, insert выполняется достаточно быстро. Вот почему так важно (если мы примерно представляем, сколько элементов может понадобиться вставить) использовать в конструкторе аргумент initial_capacity и заранее выделить статический массив побольше (это не просто теория — эта идея встречается в стандартной библиотеке Java). Но это еще не все! Нам нужно копнуть еще глубже и задаться вопросом: сколько раз фактически вызывается метод _double_size? Я не буду вдаваться в формальный анализ — просто интуиция. Если начать с массива размером 1, его достаточно удвоить log(n) раз, пока он не станет размером n. И при каждом таком вызове мы переносим лишь часть этих n элементов. https://liveinternet.club
Реализация динамического массива  105 размер=1 0 копируется 1 элемент _double_size() Три удвоения размер=2 0 1 копируется _double_size() 2 элемента размер=4 0 1 копируется 4 элемента 2 3 _double_size() размер=8 0 1 2 3 4 5 6 7 Например, чтобы получить восемь элементов, мы вызовем _double_size три раза и в общей сложности переместим 1 + 2 + 4 = 7 элементов (1 в первый раз, 2 во второй и т. д.). Обычно эта оценка верна, и можно доказать, что для вставки n элементов в динамический массив необходимо скопировать только O(n) элементов и использовать всего O(n) дополнительной памяти. Следовательно, можно сказать, что амортизационное время для n вставок в динамический массив составляет O(n). Как упоминалось в главе 4, при­ меняя амортизационный анализ, мы не можем давать никаких гарантий относительно отдельной вставки, которая может оказаться медленной (если не повезло), и придется изменять размер базового массива. Но если мы вы­ полняем пакет операций, то можем гарантировать, что порядок роста общей стоимости будет тот же, что и у статических массивов. Поиск В методе find в динамических массивах нет ничего особенного. Те же мето­ ды, что мы описали применительно к несортированным и отсортированным массивам соответственно, можно применять как к статическим, так и к ди­ намическим массивам. В нашем случае — с несортированным массивом — нужно попросту стис­ нуть зубы и просканировать весь массив, пока не найдем совпадение (или убедимся, что совпадений нет). Таким образом, мы уже знаем, что при работе с несортированным массивом время выполнения не может быть лучше O(n) (без подключения дополнительной памяти). https://liveinternet.club
106  Глава 5. Динамические массивы Этот метод не отличается от линейного поиска, который уже рассматри­ вался в главе 2: def find(self, target): for index in range(self._size): if self._array[index] == target: return index return None Удаление Для метода delete можно либо реализовать метод удаления по индексу, либо повторно использовать find, чтобы реализовать вариант удаления по значе­ нию. Здесь я приведу второй способ, у которого всего три дополнительные команды, чтобы найти индекс и проверить, есть ли такое значение. Обратите внимание: на случай если есть дубликаты, мы удалим только первое встретившееся соответствующее значение. Удаление без изменения размера Удаление с изменением размера 7 3 4 1 2 ? ? ? 7 3 4 ? ? ? ? ? 0 1 2 3 4 5 6 7 0 delete(1) 1 2 3 4 5 6 delete(3) 7 3 4 2 ? ? ? ? 0 1 2 3 4 5 6 7 Удаление 3 7 7 4 ? ? ? ? ? ? 0 1 2 3 4 5 6 7 _halve_size() 7 4 ? ? 0 1 2 3 Как и с insert, следует проверить, нужно ли изменять размер, но на этот раз такая проверка будет последним выполняемым действием, а изменение размера означает сокращение. Таким образом, необходимо сначала найти элемент, который надо удалить, а затем исключить его из массива и сдвинуть все элементы, стоящие за ним. Но в этот момент пора удостовериться, что массив заполнен больше, чем на четверть от своей максимальной емкости, — а иначе мы решаем сократить его: def delete(self, target): index = self.find(target) if index is None: raise(ValueError(f'Unable to delete element {target}: the entry is not in the array')) https://liveinternet.club
Реализация динамического массива  107 for i in range(index, self._size - 1): self._array[i] = self._array[i + 1] self._size -= 1 if self._capacity > 1 and self._size <= self._capacity/4: self._halve_size() После удаления элементов проверить, не пора ли сократить массив Как и метод insert, метод delete требует больше ресурсов при вызовах, в которых запускается изменение размера. Но в отличие от insert, даже если мы не изменяем размер массива, у этой версии метода время выполнения в наихудшем случае — O(n). Здесь, чтобы найти индекс удаляемого значения, мы применяем линейный поиск, а затем сдвигаем все элементы, стоящие за удаленным (операция, требующая в наихудшем случае линейно растущего количества присваиваний). Если мы решаем не сохранять порядок вставки, то можем реализовать метод удаления по индексу (принимая в качестве аргумента позицию удаля­ емого элемента), у которого такая же амортизационная производительность, как и у метода insert. Какая версия метода лучше? Это зависит от контекста, то есть от требований вашего приложения. Обратите внимание, что эта разница — не просто вопрос реализации. Мы выбираем между разными алгоритмами, у каждого из которых свое специфическое поведение, достоинства и недостатки. На этом описание реализации завершается. Здесь также можно было бы использовать метод traverse, реализованный ранее. УПРАЖНЕНИЯ 5.1 Реализуйте метод удаления по индексу и убедитесь в том, что как амор­ тизированное время выполнения, так и дополнительная память для n удалений составляют O(n). Подсказка: предполагается, что сохранять порядок вставки элементов не нужно. 5.2 Если реализовывать удаление в версии с устранением элементов по ин­ дексу, то какие могут быть минусы, когда мы меняем местами удаляемый элемент с последним вместо того, чтобы сдвигать элементы, стоящие за удаленным? 5.3 Реализуйте класс DynamicSortedArray, моделирующий динамический мас­ сив, элементы которого упорядочены по возрастанию. Каково налучшее из возможных время выполнения для операций insert и delete? https://liveinternet.club
108  Глава 5. Динамические массивы Итоги zzМассивы — отличные контейнеры, когда нам нужен доступ к элементам исходя из их позиции. Они обеспечивают постоянное время обращения к любому элементу, и можно прочитать или записать любой элемент без последовательных обращений ко всем предшествующим. zzС другой стороны, массивы статичны по своей природе. Это означает, что из-за способа их реализации в памяти невозможно изменить размер массива после его создания. zzФиксированный размер контейнера означает, что у нас не получится быть гибкими, если мы обнаружим, что нужно сохранить элементов больше, чем позволяет его емкость. При этом, если изначально выделить большой массив для поддержки наибольшего вероятного количества элементов, это может оказаться пустой тратой памяти. zzДинамические массивы — это способ взять лучшее от массивов и доба­ вить им гибкости. Они не являются отдельным типом структуры данных, но выстроены на тех же массивах фиксированного размера, просто до­ полненных стратегией расширения и сжатия по мере надобности. Каж­ дый раз, когда надо изменить размер, просто заново выделяется базовый статический массив. zzЛучшая стратегия для динамических массивов — удваивать размер базо­ вого статического массива в момент, когда мы пытаемся вставить новый элемент в заполненный массив, и сокращать размер массива вдвое, когда после удаления элемента три четверти ячеек остаются пустыми. zzЭта гибкость не дается даром: вставка и удаление не могут выполняться с постоянным временем, как в статических несортированных массивах. Но для метода вставки (а при некоторых допущениях и для метода удале­ ния) можно гарантировать, что для n операций потребуется O(n) аморти­ зированного времени выполнения и дополнительной памяти. https://liveinternet.club
Связанные списки. Гибкая динамическая коллекция 6 В этой главе 9 9 9 9 9 9 9 9 Преимущества связанных списков по сравнению с массивами Односвязные списки — простейшая разновидность связанных списков Двусвязные списки упрощают чтение списка в обоих направлениях Сильная сторона циклических списков — обработка периодических и циклических данных В главе 5 мы говорили, что слабая сторона статических массивов — от­ сутствие гибкости. Динамические массивы создают иллюзию гибкости, но, к сожалению, они не являются какой-то иной (и гибкой) структурой данных — они всего лишь применяют стратегию изменения размера ста­ тических массивов с максимально возможной эффективностью. Как уже упоминалось, за возможность изменять размеры массивов приходится рас­ плачиваться замедлением вставки и удаления. В этой главе рассматривается способ создания структуры данных, раз­ мер которой может при необходимости изменяться. Речь идет о связанных списках (linked lists). Мы рассмотрим как односвязные списки (простейшую версию), так и двусвязные, которые при выполнении некоторых операций https://liveinternet.club
110  Глава 6. Связанные списки за счет дополнительных затрат памяти повышают производительность. Как и с динамическими массивами, мы обнаружим, что эта гибкость тоже не дается даром, только на этот раз за нее приходится платить другую цену. Связанные списки и массивы Имеет смысл начать наше обсуждение со сравнения связанных списков и массивов. В конце концов, у нас уже есть структура, в которой можно хранить данные и выполнять операции вставки, удаления и поиска. Кроме того, массив можно перебрать (traverse), то есть последовательно прочитать элементы и выполнить какую-либо операцию с любым из них. Но чем связанные списки отличаются от массивов с точки зрения функ­ циональности и эффективности? Под капотом Для начала разберемся, как работают связанные списки. 1 0 1 1 0 1 0 0 0 1 9 4 3 -1 Вероятно, вы помните из главы 2, что массив обычно реализуется как еди­ ный непрерывный блок памяти, разделенный на ячейки равных размеров, каждая из которых содержит один элемент массива. Машинное слово Ячейка памяти Индекс в массиве 0 1 2 3 4 2 0 -1 4 5 0xf0 0xf1 0xf2 0xf3 0xf4 0xf5 0xf6 0xf7 0xf8 В отличие от массивов, связанный спи­ Значение Ссылка на следующий узел сок представляет собой сложную мо­ узла 7 дульную структуру, состоящую из бло­ node .next ков, называемых узлами (nodes): каждый узел содержит элемент, то есть одно значение связанного списка. Но это еще не все! Так как узлы не занимают смежные участки памяти, каждый из них должен также содержать ссылку на следующий узел — то есть у узла есть до­ полнительный фрагмент данных, в котором хранится информация о место­ положении в памяти следующего узла списка. https://liveinternet.club
Связанные списки и массивы  111 В этом главное отличие массивов от связанных списков: в массивах ме­ стоположение каждого элемента однозначно определяется позицией первого элемента и размером элементов. Поэтому, если нам известна ячейка памяти первого элемента массива (хранится в переменной массива), мы можем вы­ числить ячейку каждого элемента, зная его индекс (то есть позицию в по­ следовательности элементов). Массивы и связанные списки: сравнение Давайте сравним, как одни и те же значения хранятся в массиве и в связан­ ном списке. my_array 7 9 4 3 10 x14 x18 x1B 0 0 0 0x my_list 7 9 4 3 20 24 0x 0x 44 48 0x 0x 04 08 0x 0x AB AF 0x 0x Связанный список — это последовательность соединенных узлов, в которой каждый узел сам является маленькой структурой данных: в нем хранится одно значение и ссылка на следующий узел. Два основных отличия связанных списков от массивов: zzУзлы хранятся не смежно, а в любых свободных ячейках памяти. Поскольку узлы располагаются не смежно друг с другом, местонахожде­ zz ние каждого из них необходимо сохранять. Эти адреса записаны в самих узлах, а значит, каждый узел связанного списка занимает больше памяти, чем соответствующий элемент массива. Как видите, у каждого узла на изображении есть дополнительная ячейка прямо рядом со значением — в ней ссылка на следующий узел. У этих различий есть последствия — как положительные, так и отрицатель­ ные. Из плюсов — поскольку узлы в связанных списках выделяются необя­ зательно смежно друг с другом, у нас больше гибкости: нам не приходится заранее выделять весь лист, и можно добавлять столько узлов, сколько по­ надобится, — в любое время, пока хватает памяти выделить новый узел. Но есть и минус — поскольку адреса узлов не предустановлены, то нет формулы, чтобы вычислить, где какому узлу присвоен его индекс в последовательности элементов списка. Это значит, что, по сравнению с массивами, связанные списки не позволяют обращаться к элементам по их индексам — вместо этого приходится читать связанный список с начала, от узла к узлу, получать адрес следующего узла и так далее — пока не доберемся до нужного элемента. https://liveinternet.club
112  Глава 6. Связанные списки Рассмотрим конкретный пример: чем отличается чтение третьего элемен­ та в массиве от чтения в связанном списке? В случае с массивом можно про­ my_array[2] сто обратиться к элементу с индек­ сом 2. Это одна операция с постоян­ 7 9 4 3 ным временем выполнения (то есть она занимает одно и то же время не­ зависимо от того, обращаемся ли мы 7 9 4 3 к третьему, четвертому или сотому .next .next элементу). my_list В связанном списке мы сначала обращаемся к первому элементу, пе­ реходим по хранящемуся в нем указателю ко второму, а затем аналогичным образом к третьему. Количество шагов этой операции находится в линейной зависимости от количества узлов — и это главный недостаток связанного списка. Конечно, в одних ситуациях это слишком дорого, а в других не име­ ет значения (например, если мы знаем, что не так часто нам понадобится перебирать весь список, а, напротив, всегда будем обращаться к элементам в начале списка). Односвязные списки Структура, описанная в предыдущем разделе, называется односвязным списком (singly linked list) — да, отсюда следует, что существуют две разно­ видности связанных списков. Односвязный представляет собой связанный список, где каждый узел содержит одну ссылку, указывающую на следующий элемент списка. Начало Указатель на следующий элемент (next pointer) Конец Указатель 7 9 на начало списка (головной указатель, head’s pointer) 4 3 Первый элемент списка называется началом (или головой, head), а послед­ ний — концом (или хвостом, tail)). В односвязном списке нет узла с указани­ ем на головной узел, поэтому ссылку на начало списка необходимо хранить где-то в переменной. В односвязном списке каждый узел знает только о следующем элементе; он опосредованно связан со всеми последующими узлами, но отделен от своих предшественников. https://liveinternet.club
Односвязные списки  113 Ты знаешь Кэти? Ну, я знаю одного парня, который знает другого парня… Киша Том Ты знаешь Тома? Впервые слышу о таком! Я и тебя-то не знаю! Ито Кэти У конечного узла тоже есть особенность: его указатель не указывает ни на какой узел (в таких языках программирования, как Java или C, ему присва­ ивается null). В этом разделе мы собираемся обсудить применение одно­ связных списков, а затем исследуем характеристики и реализации тех же методов, которые в свое время мы определили для массивов: insert, delete, search и traverse. Управление заказами В главе 5 была представлена ситуация, с которой статические массивы не справлялись, и нам понадобилась гибкость динамических массивов. Это пример из раздела «Нужно ли сокращать массивы?» о специфике работы интернет-магазина и процессе отслеживания заказов. Поступающие заказы добавляются в конец списка, и их очередность поддерживается в порядке поступления. Выполненные заказы удаляются из списка. Если бы мы хранили заказы в связанном списке вместо массива — что бы изменилось? Проигрыватель виниловых дисков Эбби Роуд Создан: 26.09.1969 HD-фотокамера Джамп стрит, 21 Создан: 06.07.1990 Плазменный экран 180" Создан: 10.01.2022 Прежде всего, в отличие от массивов, под связанные списки не выделяется никакой свободной памяти. Узлы создаются только по мере надобности. С новым заказом мы создаем новый узел (прямо в момент выделения), и нет необходимости выделять память заранее. Удаляя узел, мы не оставля­ ем никаких «дыр», то есть в памяти не появляется пустот (более подробно рассмотрим это в следующих разделах). Конечно, каждому узлу нужно не­ много памяти для хранения своего значения (в данном случае это заказ — со списком покупок, адресом и датой оформления), а также немного дополни­ тельной памяти для ссылки на следующий узел. Похоже, связанные списки идеально подходят для управления заказами. Но не слишком ли это хорошо, чтобы быть правдой? https://liveinternet.club
114  Глава 6. Связанные списки Реализация односвязных списков Связанные списки отличаются от всех структур данных, встречавшихся нам до настоящего момента. Массив — это всего лишь непрерывная область па­ мяти, разделенная на ячейки одинаковых размеров, так что его реализация не особо затратна (по крайней мере, для более простых версий). Однако реализация связанных списков не столь прямолинейна. Я пред­ почитаю рассматривать связанные списки как двухуровневые структуры данных. Есть внешняя структура, которая реализует сам связанный список и пре­ доставляет клиентам API, чтобы можно было работать со списком и выпол­ нять обычные операции. Связанный список insert_to_back(x) insert_in_front(x) Состояние: голова (head), ссылка на первый узел head C B delete(x) A Это как оболочка, обертка для связанного списка. Но внутри этой обертки необходимо использовать другую структуру данных — уже описанные в этой главе узлы. Их можно рассматривать как структуры данных, хранящие одно значение (а точнее, два: одно обращено к пользователю — данные, сохранен­ ные клиентом, а другое, внутреннее, — ссылка на следующий узел). Узел append(node) Состояние: данные + ссылка на следующий узел data() next() has_next() Таким образом, связанный список состоит из узлов, последовательно упоря­ доченных при помощи ссылок. У него также есть и вспомогательные атрибу­ ты — например, ссылка на первый узел списка и некоторые сопутствующие методы. Таким образом, для реализации связанного списка сначала необхо­ димо реализовать класс для узлов. Как всегда, полный код этой главы также хранится в репозитории книги на GitHub: https://mng.bz/rVRD. Реализация класса Node на языке Python вы­ глядит так: class Node: def __init__(self, data, next_node = None): self._data = data self._next = next_node https://liveinternet.club
Односвязные списки  115 def data(self): return self._data def next(self): return self._next def has_next(self): return self._next is not None def append(self, next_node): self._next = next_node Это минимальный класс: здесь только атрибуты для данных и ссылка на следующий узел, два открытых метода возвращения значений и два метода присваивания ссылки на следующий узел и проверки наличия ссылки на следующий узел. И это все, что необходимо для внутренней реализации односвязного списка. ПРИМЕЧАНИЕ Класс Node также можно скрыть от клиента, реализовав его как вложенный класс внутри класса списка. Это связано с тем, что пользователи не должны напрямую оперировать узлами списка. У класса-обертки списка — единственного класса, с которым будут взаимо­ действовать клиенты, — также минимальная секция инициализации: class SinglyLinkedList: def __init__(self): self._head = None Да, вот и все! Все, что нужно, — инициализировать внутренний атрибут, указывающий на голову списка, то есть на его первый узел, значением None: когда head установлен в None, это означает, что список пуст. При более внимательном рассмотрении можно заметить два важных от­ личия от конструкторов класса массивов, реализованных в предыдущей главе: zzЗадавать размер списка не нужно, память заранее выделять не нужно — список может динамично расти. zzНет никаких аргументов в пользу ограничения типа данных, хранящихся в узлах. Дело в том, что в языках со свободной типизацией, например Python, нет смысла ограничивать тип значений таких контейнеров, как список (применительно к массивам это может иметь смысл с точки зре­ ния повышения эффективности, как объяснялось в главе 2). Конечно, в языках с сильной типизацией, например Java или C++, имеет смысл ограничить список элементами одного типа. В крайнем случае можно ограничить тип сохраняемых данных при помощи аннотаций типов Python, если для этого есть веские, обусловленные контекстом причины. https://liveinternet.club
116  Глава 6. Связанные списки Впрочем, не стоит заблуждаться. Вся сложность класса SinglyLinkedList кроется в его методах. Вставка Первая операция, которую обычно надо реализовать в структуре данных, — это вставка. Но как это сделать для несортированного односвязного списка? Вставка в конец списка Если вы помните по главе 2, в несортированные массивы мы добавляли новые элементы в конец массива, после последнего сохраненного элемента. То же самое можно делать и с несортированными списками. Нам не важен порядок элементов — можно просто добавлять новые элементы в конец списка. Чтобы добавить новый элемент в конец списка, необходимо перебрать весь список, найти его хвост (tail) и добавить в него новый узел Node (кото­ рый станет новым хвостом). current=head current.next current.next 1. Найти хвост списка head 2. Создать и добавить новый узел 7 9 4 current.next 3 (Старый) хвост 7 9 4 3 Хвост Новый узел 6 Такое решение работает, и элементы сохраняются в порядке их вставки. Но не видите ли вы здесь проблемы? Каждый раз, когда мы вставляем новый элемент, нам приходится перебирать весь список. Это означает, что этот ме­ тод выполняется за линейное время: O(n) для списка из n элементов. Другой способ взглянуть на это — вставка n элементов в пустой список потребует квадратичного времени: Начинаем с головы списка def insert_to_back(self, data): Если список пуст, просто current = self._head создаем новую голову if current is None: self._head = Node(data) Перебираем весь список до по­ else: следнего элемента (у которого while current.next() is not None: по определению нет узла-пре­ current = current.next() емника) current.append(Node(data)) К списку добавляется новый хвост (хвостовой узел) со своим значением https://liveinternet.club
Односвязные списки  117 В массивах можно получить доступ к последнему (и любому другому) элементу за постоянное время, но, к сожалению, в списках это уже не так. ПРИМЕЧАНИЕ В связанных списках теоретически можно хранить ссылку на хвост, что упростит вставку в конец списка; однако в односвязных списках обновление ссылки на хвост при удалении элемента может оказаться слишком затратным. Итак, мы увязли в линейном времени вставки, что далеко от идеала. Можно ли сделать лучше? Конечно, можно! Вставка. Более рациональный вариант: в начало списка Хотя это вполне обоснованно — вставлять элементы в конец списка, мы имеем дело с несортированными списками, и порядок элементов нас не интересует. Что, если, наоборот, добавить новый элемент в начало списка? head 7 9 4 3 7 9 4 3 Новый узел head 6 Это не только возможно, но и прекрасно работает: def insert_in_front(self, data): old_head = self._head self._head = Node(data, old_head) Есть причина, по которой вставка элементов в начало списка происходит быстро, а в конец — нет. Дело в асимметрии узлов: ведь в них сохраняется ссылка только на узел-преемник, и нет ссылки на узел-предшественник. Та­ ким образом, список можно перебирать только в одном направлении, и, как мы вскоре увидим, любые изменения, вставки или удаления затратны, за исключением случаев, когда они выполняются в начале списка. Список обрастает с головы таким образом: мы создаем новый список, состоящий из только что созданного узла и идущего за ним старого списка. И это настолько эффективно, насколько только возможно: занимает посто­ янное время, O(1). https://liveinternet.club
118  Глава 6. Связанные списки Поиск Теперь, когда связанный список заполняется заказами, можно приступать к поиску, чтобы найти любой поступивший заказ. Метод search представляет собой простой линейный поиск: здесь нет ничего лучше, чем перебор всего списка, пока не найдем искомое или не доберемся до конца списка. search(4) current=head head current.next current.next 7 9 4 3 Найдено Вот реализация метода search, которая, конечно, требует линейного време­ ни и постоянной дополнительной памяти. Она возвращает узел, в котором хранятся искомые данные: def _search(self, target): current = self._head while current is not None: if current.data() == target: return current current = current.next() return None Возможно, вы заметили, что перед именем метода стоит символ подчер­ кивания — я реализовал его как приватный метод. Дело в том, что эта ре­ ализация предназначена только для внутреннего использования. Почему? На самом деле причин две: как функциональность она не будет особенно полезной, а с точки зрения проектирования возвращение объектов Node нежелательно. С точки зрения функциональности нет смысла возвращать клиенту найденный узел. В массивах мы возвращаем индекс найденного значения, но в связанных списках пользователь не получит из узла никакой дополни­ тельной информации, потому что у него уже есть данные, хранящиеся в этом узле (в приведенной реализации мы сравниваем все поле data со значением target, переданным в качестве аргумента). Бывают ситуации, когда в узлах хранятся составные данные, а нам может понадобиться провести поиск по некоторым полям. Например, в приложе­ нии для управления заказами сохраняется собственно заказ — составное поле, состоящее (как правило) из идентификационных данных (ID) заказа, списка товаров, даты оформления, того или иного статуса заказа и уточня­ ющих сведений о покупателе и доставке. Искать можно хоть по ID заказа, хоть по адресу доставки и вызвать весь заказ целиком. https://liveinternet.club
Односвязные списки  119 поиск ("все заказы, оформленные до 01.01.2023") current=head ноутбук 15” current.next current.next Пылесос Брюссель Рим Портативный проектор Дублин Создан: 22.02.2022 Создан: 16.07.2022 Создан: 03.03.2023 Плазменный экран 180" Цюрих Создан: 10.05.2023 Даже в этом случае определенно не стоит возвращать ссылку на узел списка: не следует передавать клиентам класс Node. Внутренняя реализация связан­ ного списка должна быть непрозрачной для пользователей: они должны иметь дело только с внешним интерфейсом, которым их обеспечил связан­ ный список. Благодаря тому что вы позаботились, чтобы пользователи имели дело только с внешним интерфейсом, у них получится бесшовно, незаметно для себя переключиться на другую версию, если вы напишете усовершен­ ствованную реализацию связанного списка и код при этом не сломается. Делая метод _search приватным, мы также можем сделать класс Node при­ ватным для SinglyLinkedList. ПРИМЕЧАНИЕ Исходя из принципа минимальных полномочий, не следует предоставлять клиенту ссылку на Node списка: если у третьей стороны будет ссылка на внутренний узел, который легко модифицируется, то она сможет его изменить и тем самым нарушить целостность списка. Это не означает, что искать бессмысленно. В примере с управлением заказа­ ми можно вернуть копию данных заказа, не предоставляя никаких ссылок на узлы списка. Изменяя возвращаемое значение, можно реализовать метод contains, который сообщает вызывающей стороне лишь информацию о том, хранятся ли данные в списке. А метод _search всегда можно использовать во внутренней реализации и вызывать его из других методов того же класса. Удаление Что касается метода delete, здесь не возникает дилеммы, с которой мы стал­ кивались в случае с массивами. Элементы должны удаляться по значению, потому что, как обсуждалось ранее, доступ к элементам списка осущест­ вляется последовательно: например, невозможно обратиться к третьему элементу списка, не обратившись к первым двум. С другой стороны, удалить элемент из списка гораздо проще. Для этого достаточно обойти удаляемый узел, то есть обновить ссылку, хранящуюся https://liveinternet.club
120  Глава 6. Связанные списки в узле-предшественнике, чтобы она указывала на узел, следующий за уда­ ляемым. 6 head 7 9 4 3 delete(9) Просто вроде бы? Но не торопитесь! Прежде всего, необходимо учесть два граничных случая: 1. Если вы удаляете последний узел, то надо сделать предыдущий новым хвостом (в нашей реализации его ссылке присваивается None). 2. Если вы удаляете первый узел (голову списка), то предшественника нет! В таком случае достаточно обновить указатель head списка. Разобравшись с граничными случаями, можно решить, что для обнаружения удаляемого узла следует снова воспользоваться методом поиска и внести соответствующие изменения. Печальная новость заключается в том, что это не сработает. В разделе, посвященном методу вставки, я упоминал о том, что узлы в односвязных списках асимметричны. Так как в каждом узле хранится ука­ затель только на следующий узел, то от любого узла можно перейти к узлупреемнику, но не к предшественнику. По сути, когда у вас есть ссылка на узел N, то это все равно, как если бы вам был виден весь последующий фрагмент списка, начиная с N, в то время как все предыдущие узлы остаются не видны. Ссылка на удаляемый узел (от search) head 6 7 9 4 3 Следовательно, чтобы найти узел со значением, предназначенным для удале­ ния, не получится использовать _search, потому что мы не сможем обновить ссылку next в узле-предшественнике. Но мы все еще можем использовать механизм из реализации метода по­ иска, необходимо лишь перебрать узлы списка и сохранить ссылку на узелпредшественник искомого узла. Найдя цель, можно воспользоваться этой ссылкой для обращения к пред­ шественнику удаляемого узла: https://liveinternet.club
Односвязные списки  121 def delete(self, target): current = self._head previous = None while current is not None: Граничный случай: удаление if current.data() == target: головы списка if previous is None: Стандартный случай: self._head = current.next() узел в середине списка else: (или в хвосте) previous.append(current.next()) return previous = current current = current.next() raise ValueError(f'No element with value {target} was found in the list.') Если выполнение достигло этой точки, целевого значения в списке нет Что насчет времени выполнения метода delete? Сначала необходимо про­ вести поиск по списку — это время O(n). Как уже обсуждалось, наличие указателя на сам удаляемый узел, к сожалению, не поможет, потому что все равно придется перебрать весь список, чтобы добраться до предшественника и обновить его. Дальше в этой главе мы еще увидим, как можно решить эту проблему. Наконец, обратите внимание, что этому методу достаточно по­ стоянного объема дополнительной памяти. Удаление в начале списка Удаление головы списка можно считать особым случаем. Бывают ситуации, когда вообще не нужно удалять элементы в какой-либо еще позиции, кроме как в начале списка, — идеальный пример такого рода будет показан при знакомстве со стеками в главе 8. Как упоминалось при описании вставки, операции, изменяющие только начало списка, обходятся дешево — и дей­ ствительно, удаление первого узла списка является операцией, выполняемой за постоянное время. На этом завершается наше рассмотрение односвязных списков. Теперь у вас есть все необходимое для реализации работоспособной версии списка! Код в нашем репозитории на GitHub (https://mng.bz/Vx00) также включает несколько вспомогательных методов, которые могут пригодиться в самых разных ситуациях. УПРАЖНЕНИЯ 6.1 Реализуйте метод delete_from_front, который удаляет и возвращает голову списка. Подсказка: это граничный случай для метода удаления общего назначения. https://liveinternet.club
122  Глава 6. Связанные списки 6.2 Реализуйте метод перебора односвязных списков. Он должен полу­ чать функцию, которую можно применять к хранящимся в списке данным, и возвращать список Python с результатом применения такой функции. Отсортированные связанные списки Мы реализовали односвязный список, в котором очередность элементов не важна. А что меняется, когда порядок элементов имеет значение? Например, что, если в нашей системе управления заказами нам понадобится хранить элементы не в порядке их поступления, а (допустим) отсортированными по пользователям или по ID (в случае если ID не поступательно возрастают с каждым новым заказом, а присваиваются случайным образом)? Рим Портативный проектор Дублин Создан: 16.07.2022 Создан: 03.03.2023 Пылесос Плазменный экран 180" Цюрих Создан: 10.05.2023 Ноутбук 15" Брюссель Создан: 22.02.2022 Список заказов, отсортированных по названию товара (по убыванию) В этом разделе мы вкратце разберемся, что необходимо изменить, чтобы организовать и поддержать сортировку элементов односвязного списка. Вставка в середину списка Если нам нужен отсортированный список заказов, то мы не можем просто вставлять новые узлы в начало (или конец) списка. Необходимо перебрать список, чтобы найти правильное место для вставки нового значения, а точ­ нее, найти узел, после которого следует вставить новый, а потом обновить ссылки в списке и в только что созданном узле, тем самым включая в список узел с новым значением. head 3 4 insert(6) 7 9 6 Если, упрощая, предположить, что данные узла можно сравнивать напрямую (то есть что с ними можно использовать оператор <), то реализация этого нового метода вставки очень похожа на процедуру перебора из delete: https://liveinternet.club
Отсортированные связанные списки  123 def insert_in_sorted_list(self, new_data): current = self._head previous = None Граничный случай: вставка while current is not None: в начало списка if current.data() >= new_data: if previous is None: self._head = Node(new_data, current) else: previous.append(Node(new_data, current)) return Общий случай: новый узел добавляется previous = current между предыдущим и текущим current = current.next() if previous is None: self._head = Node(new_data) Граничный случай: else: пустой список previous.append(Node(new_data, None)) Граничный случай: вставка в конец списка Как нетрудно предположить, эта версия метода вставки работает только в том случае, если список отсортирован и выполняется за время O(n), — здесь у нас нет возможности выполнить вставку за постоянное время. УПРАЖНЕНИЕ 6.3 Как бы вы написали метод insert_in_sorted_list с повторным исполь­ зованием методов insert_in_front и delete, не внося при этом в узлы никаких других явных изменений? Каково будет время выполнения этого метода? Можно ли повысить эффективность поиска? В главе 3 рассматривались отсортированные массивы, и мы узнали, что ради сортировки элементов приходится пожертвовать вставкой за постоянное время. Мы также узнали, что взамен получаем двоичный поиск — более эффективный метод поиска, которому в наихудшем случае достаточно про­ верить O(log(n)) элементов, что гораздо лучше линейного поиска со временем выполнения O(n). Может, удастся добиться такого же улучшения для связанных списков? Подумайте минутку, прежде чем читать дальше. Главное преимущество двоичного поиска заключается в том, что можно выбрать элемент из середины массива, а затем (если совпадение не найдено) исключить из дальнейшего поиска половину оставшихся элементов. А вот чтобы перейти к элементу, находящемуся в середине связанного списка, необходимо перебрать все предшествующие ему элементы. Затем, чтобы найти элемент, расположенный в середине оставшейся половины списка, https://liveinternet.club
124  Глава 6. Связанные списки необходимо снова перебрать ту же половину этих элементов (даже если у нас осталась левая половина списка, все эти элементы придется перебирать зано­ во). В результате время выполнения будет больше, чем при линейном поиске. Ключевой особенностью двоичного поиска является постоянное время обращения к любому элементу массива по индексу. Так как у списков такой возможности нет, в общем случае двоичный поиск в них не может быть эффективнее линейного. Это означает, что insert — единственный метод, который нам пришлось изменить, чтобы держать элементы списка отсортированными, а также то, что мы не получим никакого выигрыша, если только, например, нам не требуется быстрый доступ к наименьшему элементу, который всегда будет находиться в голове списка. В любом случае в каких-то ситуациях вам может понадо­ биться держать список отсортированным, так что этот вариант пригодится. Двусвязные списки Мы уже знаем, что односвязные списки превосходят массивы по гибкости, но у них есть свои заметные недостатки. Прежде всего — и самое главное, — мы вынуждены читать элементы списка последовательно, тогда как с масси­ вами можно напрямую обратиться по любому индексу за постоянное время. Это ограничение обусловлено природой связанных списков, и с этим ничего не поделать: это цена, которую приходится платить за структуру данных, которая может быть выделена «на заказ» и «точно в срок». В связанных списках невозможно обеспечить стандартный доступ с по­ стоянным временем обращения, но двусвязные списки (doubly linked lists) решают другую проблему, присущую односвязным узлам: их асимметричную природу. В узлах односвязных списков хранится ссылка только на узел-пре­ емник, что замедляет и усложняет некоторые операции со списками. В этом разделе мы поговорим о том, как и какой ценой можно справиться с этим ограничением. Двойные ссылки — двойное веселье? Вероятно, вы уже догадались: двусвязный список представляет собой связанный спи­ сок, в узлах которого хранятся две ссылки — есть еще ссылка на узел-предшественник. Это тривиальное изменение имеет важ­ ные последствия: Ссылка на предыдущий узел Значение узла Ссылка на следующий узел 7 .prev node .next zzДвусвязный список можно перебирать в обоих направлениях — от головы к хвосту и от хвоста к голове. https://liveinternet.club
Двусвязные списки  125 zzЕсли есть ссылка на один узел списка, по ней можно перейти к любому другому узлу — и вперед, и назад. Мы убедились, насколько это важно, при обсуждении метода удаления в односвязных списках. zzЕсли говорить о минусах, каждый узел двусвязного списка занимает больше места, чем аналогичный узел односвязного списка. С большими списками эта разница может повлиять на работу приложения. zzДругое отрицательное последствие: при каждом изменении списка не­ обходимо обновлять две ссылки, из-за чего сопровождение усложняется и становится более затратным. Что касается реализации, то в класс Node наряду с новым атрибутом добав­ ляются новые методы для присваивания, обращения и проверки ссылки на предыдущий узел. Обратите внимание: конструктору Node уже не передается необязательный аргумент для установки указателя на следующий узел. Луч­ ше заставить клиентов создать отсоединенный узел и использовать метод присоединения (append) явно. Кроме того, в двусвязных списках логика добавления нового узла услож­ няется, потому что во имя согласованности также необходимо установить на нем ссылку на узел-предшественник. Аналогично следует связать его с узлом-преемником: class Node: def __init__(self, data): self._data = data self._next = None self._prev = None def data(self): return self._data def next(self): return self._next def has_next(self): return self._next is not None def append(self, next_node): self._next = next_node if next_node is not None: next_node._prev = self def prev(self): return self._prev def has_prev(self): return self._prev is not None def prepend(self, prev_node): self._prev = prev_node if prev_node is not None: prev_node._next = self https://liveinternet.club
126  Глава 6. Связанные списки В класс-обертку списка также вносятся некоторые изменения: class DoublyLinkedList: def __init__(self): self._head = None self._tail = None В частности, мы также устанавливаем ссылку на хвост списка. Это позво­ ляет быстро выполнять удаление из конца списка, но ценой постоянного обновления этой ссылки при внесении любых изменений, как мы увидим в следующих разделах. Даже по этим реализациям видно, что двусвязные списки сложнее в реа­ лизации и сопровождении, чем их односвязные аналоги. Стоят ли двусвяз­ ные списки всех этих хлопот? Вообще-то это зависит от приложения. Прежде чем углубляться в реализацию методов для двусвязных списков, давайте рассмотрим подобное приложение, в котором их применение в корне меняет ситуацию. О важности возвращения назад Тим работает над своей первой видеоигрой: герою нужно переходить между комнатами внутри здания слева направо. Тим тщательно спроектировал комнаты, реализовал каждую по отдель­ ности, и теперь ему нужно смоделировать последовательность переходов между комнатами. «Как бы это сделать?» — размышляет он. Фреймворк, которым он сейчас пользуется, предоставляет стандартный односвязный список, который сэкономит Тиму значительное время при разработке. Но если у него односвязный список, герой игры сможет перейти слева направо, но не сможет вернуться назад. Пустите меня обратно! Нет! Коридор Кухня Гостиная current room https://liveinternet.club Спальня
Двусвязные списки  127 Проблема в том, что по сценарию Тима у игроков должны быть пути отхода, поскольку в каких-то комнатах возможность взаимодействия бу­ дет разблокирована чуть позже. Значит, односвязный список не подойдет, и Тиму придется реализовать двусвязный. Можно идти куда угодно. Коридор Кухня Гостиная Спальня current room Когда вам надо двигаться вперед и назад по списку в обоих направлениях — это повод использовать двусвязный список. Это могут быть слои многослойно­ го документа или действия, которые можно отменять и повторять. В computer science много прецедентов, когда выделить память под дополнительные ссылки узлов двусвязного списка не только оправданно, но и необходимо. Вставка Мы обсудили, как дополнительная ссылка двусвязных списков может созда­ вать новые возможности — и, в конечном счете, дополнительную ценность. Теперь давайте посмотрим, насколько это затратно. Начнем с метода вставки. Как и прежде, можно вставлять элементы в начало, конец и любую точку списка на выбор. Вставка в начале списка Вставка нового узла в начало списка остается столь же быстрой, что и в одно­ связных списках: все так же надо взять голову списка (а у нас есть ссылка на нее) и добавить к ней узел-предшественник. head 7 9 4 3 tail 7 9 4 3 tail Новый узел head 6 https://liveinternet.club
128  Глава 6. Связанные списки В двусвязных списках с этим чуть больше хлопот — надо обновить в старой голове указатель на предшественник и, возможно, следует обновить ссылку на хвост списка в одном граничном случае — при вставке в пустой список: def insert_in_front(self, data): if self._head is None: self._tail = self._head = Node(data) else: old_head = self._head self._head = Node(data) self._head.append(old_head) Вставка в конец списка Вставка в конец списка выглядит поинтереснее. Если помните, мы говорили ранее в этой главе, что такая вставка особенно непродуктивна в односвязных списках. Она требует линейного времени. Впрочем, в двусвязных списках два обстоятельства сильно меняют си­ туацию: zzВозможность сохранить указатель на хвост списка позволяет обращаться к нему за постоянное время. zzИз любого узла можно обратиться к его предшественнику (и обновить его) за постоянное время. Это означает, что мы можем перейти по ссылке к хвосту списка и добавить новый узел как его преемник — и все это за постоянное время. head 7 9 4 3 tail Новый узел head 7 9 4 3 6 tail Двусвязный список позволяет эффективно вставлять элементы как в начало, так и в конец списка. Более того, поскольку список можно перебирать в обо­ их направлениях, к элементам можно обращаться как в порядке вставки, так и в обратном порядке: def insert_to_back(self, data): if self._tail is None: self._tail = self._head = Node(data) else: old_tail = self._tail self._tail = Node(data) self._tail.prepend(old_tail) https://liveinternet.club
Двусвязные списки  129 Вставка в середину Наконец, что, если надо вставить элемент в какую-либо позицию в середине списка (то есть не в начало, не в конец)? Здесь возможны две ситуации. Если у нас есть ссылка на один из узлов, между которыми нужно вставить новый элемент, операция добавления нового узла сводится к обновлению указателей next и prev этих узлов и вы­ полняется за постоянное время. Если сравнить с массивами, то не нужно сдвигать элементы вправо, и это огромная экономия! head 3 4 7 9 tail 7 9 Новый узел head 3 4 6 tail Когда точку вставки еще нужно найти, особенно если список должен остать­ ся отсортированным, то требуется перебор списка. А перебор списка выпол­ няется за линейное время. К счастью, даже в этом случае нам не придется сохранять ссылку на предыдущий узел в процессе перебора, что упрощает операцию по сравнению с односвязными списками. Поиск и перебор Что касается поиска в двусвязном списке, то дополнительные ссылки на узлы-предшественники особой пользы не принесут. Лучшим вариантом остается линейный поиск, то есть перебор списка от начала до конца, пока не найдем нужный элемент или не достигнем конца списка. Конечно, список теперь можно перебирать в обоих направлениях, но обычно никакого вы­ игрыша от этого нет (разве что имеются какие-то особенности конкретной предметной области, в которой вы работаете и где это оправдано). Следова­ тельно, можно повторно использовать метод _search, написанный для одно­ связного списка, без каких-либо изменений — даже нет смысла повторять здесь этот код. Очевидно, те же соображения применимы и к методу traverse. Впрочем, в качестве упражнения вы можете написать метод для перебора списка в об­ ратном порядке, от хвоста к голове. Удаление Концептуально delete в двусвязных списках работает точно так же, как delete в односвязных: перебираем список, пока не найдем удаляемый элемент E, а за­ тем обновляем ссылки в узлах до и после E, отвязывая его. На этом всё. https://liveinternet.club
130  Глава 6. Связанные списки Однако здесь есть существенное отличие: в узлах хранятся ссылки и на предшественники, в связи с чем можно также использовать метод поиска, чтобы найти узел с удаляемым элементом. Другое отличие заключается в том, что нам приходится учитывать граничные случаи и при необходимо­ сти обновлять ссылку _tail связанного списка. Таким образом, реализации этого метода в двусвязных и односвязных списках заметно различаются: def delete(self, target): node = self._search(target) if node is None: raise ValueError(f'{target} not found in the list.') if node.prev() is None: self._head = node.next() Удалить узел в начале списка if self._head is None: self._tail = None else: self._head.prepend(None) В этом случае голова списка — elif node.next() is None: его единственный элемент self._tail = node.prev() Удалить узел в конце списка self._tail.append(None) else: node.prev().append(node.next()) del node Общий случай Время выполнения метода delete остается на уровне O(n), как и для одно­ связных списков, поскольку, чтобы найти узел с удаляемым элементом, нам все так же необходимо перебрать список. Соединение двух списков Представьте, что у вас два списка или больше — например, списки задач, по одному на каждый день, и эти задачи должны завершаться в порядке их следования в списке, а кроме того, все сегодняшние задачи надо выполнить до завтрашних. Допустим, в какой-то момент необходимо уплотнить задачи двух дней в один — на завтра неожиданно образовалась деловая поездка, и вам нужно завершить свои дела за сегодня. Свести два списка воедино, добавив один к другому, проще простого — достаточно присоединить голову второго списка к хвосту первого. today Спортзал Уборка tomorrow Теннис Обед https://liveinternet.club tail Работа tail
Циклические связанные списки  131 В коде это выглядит так: today._tail.append(tomorrow._head). И все! Списки, и особенно двусвязные, обеспечивают соединение (конкатенацию) за по­ стоянное время. Теперь вообразите, что нам нужно объединить два массива! Придется создать новый массив размером, равным сумме двух исходных, а затем пере­ местить все элементы их обоих в свежесозданный массив. Представьте, что у вас большие списки несортированных элементов, ко­ торые приходится часто объединять. Это идеальный пример приложения, в котором связанный список намного эффективнее массива. И это все, что я хотел сказать о двусвязных списках. Как обычно, полный код класса вы найдете в репозитории книги на GitHub: https://mng.bz/x2Re. УПРАЖНЕНИЯ 6.4 Реализуйте метод вставки в список нового элемента после заданного узла. Этот узел должен передаваться как аргумент. Каково время вы­ полнения этого метода? Есть ли здесь какие-то граничные случаи? 6.5 Реализуйте класс SortedDoublyLinkedList, моделирующий двусвязный список с отсортированными элементами. Подсказка: возьмите за об­ разец наши действия с классом SortedSingleLinkedList. Какие методы необходимо переопределить в этом случае? Циклические связанные списки До сих пор в этой главе обсуждались линейные списки, у которых четко различаются начало и конец. Другими словами, предполагалось, что после перебора списка и достижения конца перебор завершен. В жизни это пра­ вило работает не всегда. Иногда вместо того чтобы просто остановиться, необходимо начать сна­ чала. В этом разделе мы рассмотрим некоторые примеры такого рода и кра­ тко обсудим, как можно модифицировать структуры данных связанного списка, чтобы адаптировать их к этой процедуре. Примеры циклических связанных списков Есть множество циклических процессов с многократно повторяющимися этапами в одной и той же последовательности. Например, в сельскохозяй­ ственном цикле каждый сезон повторяются одни и те же этапы, да и сама смена сезонов образует непрекращающийся цикл. https://liveinternet.club
132  Глава 6. Связанные списки Конденсация Осадки Испарение Накопление Есть циклические процессы с постоянной последовательностью одних и тех же фаз — например, процесс создания и запуска стартапа либо продукта или круговорот воды в природе. Какие-то объекты или явления циклически про­ являются в каких-либо процессах, начиная с примеров из сельского хозяй­ ства, таких как урожай, и заканчивая связанными с computer science, вроде узлов кэша и серверов. Другой пример, также связанный с компьютерами, с которым вы наверняка знакомы, — это смена изображений в слайд-шоу, фото- и видеокаруселях. Во всех этих ситуациях вместо обычного связанного списка можно восполь­ зоваться одной из его разновидностей — циклическим связанным списком (circular linked list). Циклические связанные списки можно реализовать и как односвязные, и как двусвязные. Выбор между односвязным и двусвязным списком не за­ https://liveinternet.club
Циклические связанные списки  133 тва Зре лос ть тиК у л ь ия вац Жа Посевная Со рт семиров ян ка висит от того, циклический ли он, и определяется требованиями к перебору, о чем говорилось ранее в этой главе. Например, односвязного списка будет достаточ­ head Пахо но, чтобы смоделировать слайд-шоу, — презента­ та цию, в которой изображения выводятся цикличе­ ски в одной и той же последовательности, без возможности вручную вернуться к предыдущим слайдам. Похожим образом, если надо предста­ вить сельскохозяйственный цикл или маршрути­ зировать входящие вызовы по перечню серверов, также можно воспользоваться односвязным спи­ ском, так как перебирать его мы будем только в одном направлении. head Идея Если же, напротив, необходимо иметь MV P возможность двигаться по списку в обоих направлениях, следует использовать дву­ связный список. В отличие от примера со слайд-шоу, представьте фотокарусель, в которой изображения можно проли­ стывать вперед и назад. Другим примером может служить моделирование процесса создания идеи, разработки и развития про­ дукта (или стартапа), когда в любой момент может потребоваться возврат к предыдущему шагу. Например, можно вернуться от фазы марке­ тинга к фазе анализа рынка, чтобы внести изменения в продукт без необхо­ димости начинать все с самого начала. Раз вит ие Финансирование Ан ал из тинг Марке Советы по реализации Мы не будем углубляться в подробности реализации циклических связанных списков, прежде всего потому, что это требует минимальных изменений в отношении классов, уже реализованных в этой главе. Тем не менее при проектировании циклического связанного списка не­ обходимо учитывать некоторые обстоятельства: zzЕсли в обычных списках у конечного узла нет преемника (а у головы в двусвязных списках нет предшественника), то в циклических пре­ емником хвоста списка мы назначаем его же голову. Это означает, что перебирать такой список необходимо осторожно, а то попадем в бес­ конечный цикл. https://liveinternet.club
134  Глава 6. Связанные списки zzВ циклических двусвязных списках не нужно хранить ссылку на хвост списка: это просто head.prev. zzДля циклических связанных списков обычно реализуется некая разновид­ ность итеративного перебора списка: по одному элементу за раз, как это делается итератором Python. Это означает, что в список нужно добавить еще один атрибут для хранения узла, который мы перебираем в данный момент, а также метод, который возвращает его данные и одновременно переходит к следующему узлу. zzЕсли мы обеспечиваем пошаговый перебор, то при удалении элементов из списка или, наоборот, вставке в список необходимо действовать очень осторожно — надо следить за тем, чтобы обновлять указатель на текущий элемент, когда это необходимо. УПРАЖНЕНИЕ 6.6 Реализуйте циклический связанный список с пошаговым перебором. Можно реализовать как односвязную, так и двусвязную версию. Можно ли использовать какой-либо из классов, описанных в этой главе? Можно ли применить композицию? Можно ли применить наследование и каки­ ми достоинствами и недостатками будет обладать такое решение? Итоги zzСвязанные списки — альтернатива массивам. Они проще расширяются и сокращаются без дополнительного выделения памяти и перемещения элементов, кроме добавляемых и удаляемых. zzСвязанный список представляет собой двухуровневую структуру дан­ ных, которая реализована, по сути, как последовательность экземпляров другой структуры — узлов. Каждый узел содержит определенные данные, а именно элемент списка и как минимум одну ссылку — на следующий узел в списке. zzСвязанный список, в узлах которого ссылка только на узел-преемник, называется односвязным списком. Это простейшая разновидность свя­ занных списков. Односвязные списки можно перебирать только в одном направлении — от начального узла, именуемого головой, к конечному, именуемому хвостом. zzОдносвязные списки обеспечивают быстрые операции с головой списка: вставка узла перед головой списка, а также удаление начального узла и обращение к голове являются операциями с постоянным временем. Остальные операции требуют линейного времени. https://liveinternet.club
Итоги  135 zzВ двусвязных списках каждый узел хранит также и указатель на узел-пред­ шественник. Следовательно, такие списки можно перебирать и от хвоста к голове, что упрощает чтение списка в обоих направлениях. zzДля хранения одного и того же количества элементов двусвязные списки расходуют больше памяти, чем односвязные. zzТакие операции, как insert, delete и search, в большинстве случаев вы­ полняются с односвязными списками с такой же скоростью, как и с дву­ связными. Двусвязные списки работают быстрее, когда нужно вставить элемент в конец списка или удалить конечный элемент. zzГлавная причина предпочесть двусвязный список односвязному — не­ обходимость перемещаться по списку в обоих направлениях. zzЦиклические связанные списки — это списки (двусвязные или одно­ связные), у которых преемником хвоста списка является его голова (для двусвязных списков также и наоборот). Узел-преемник есть у всех узлов, а в двусвязных списках у них есть и узел-предшественник. zzЦиклические связанные списки используются там, где требуется пере­ бирать список многократно (например, для обработки циклических про­ цессов или выполнения циклически повторяющихся задач). https://liveinternet.club
7 Абстрактные типы данных. Проектирование простейшего контейнера — мультимножества В этой главе 9 9 9 9 Различия между абстрактным типом данных и структурой данных Массивы и связанные списки: структуры данных или типы данных? Ключевые свойства контейнера 9 Мультимножество: простейший из возможных контейнеров 9 9 9 К настоящему моменту вы уже должны освоиться с массивами и связанны­ ми списками, которым были посвящены первые шесть глав. Это основные структуры данных, повсеместно встречающиеся в computer science и раз­ работке программного обеспечения. Более того, они являются фундаментальными структурами данных, а это означает, что мы можем — и будем — строить на их основе более сложные структуры. В главе 2 мы обсуждали, что массивы могут рассматриваться как кон­ кретные функции языка либо как абстрактные типы данных. В этой главе будет показано, что эта двойственность не ограничивается массивами. Затем мы рассмотрим важный класс абстрактных типов данных — контейнеры, которым будут посвящены следующие пять глав. https://liveinternet.club
Абстрактные типы данных и структуры данных  137 Эта глава связывает первую половину книги, в которой рассказывалось об основных структурах данных и основополагающих принципах их работы, со второй половиной, где мы сосредоточимся на структурах данных, созда­ ваемых на базе того, что мы уже узнали. Я представлю вам первый из множества примеров класса контейнеров — мультимножество (bag). Абстрактные типы данных и структуры данных Чем структура данных отличается от абстрактного типа данных? Этот во­ прос в самых общих чертах упоминался при обсуждении массивов. Абстрактный тип данных (АТД) фокусируется на том, какие операции можно выполнять с данными, и не вдается в то, как эти операции реализу­ ются. Структура данных, наоборот, более конкретно указывает на то, как представлены данные, а также на алгоритмы выполнения операций с ними. Клиентское приложение Интерфейс Абстрактный тип данных Публичные функции Структура данных Приватные функции Массив Связанный список Память Например, массивы можно рассматривать как конкретное языковое сред­ ство, предоставляемое некоторыми языками программирования, то есть как непрерывные блоки памяти, которые можно разделить на ячейки оди­ наковых размеров, каждая из которых может хранить элемент заданного (и фиксированного) типа. Или же можно рассматривать более высокую абстракцию массивов, сосредоточившись на операциях, которые они под­ держивают, — чтение/запись элементов по индексам за постоянное время, не обращая внимания на то, как они реализованы. В этом разделе я приведу сначала более формальное определение, под­ черкивающее различия между этими двумя представлениями. Затем мы рассмотрим ряд примеров как иллюстрации к полученным знаниям. https://liveinternet.club
138  Глава 7. Абстрактные типы данных Определения Проектирование и построение программных продуктов — сложный процесс, который обычно начинается с абстрактной идеи, затем оттачивает и обога­ щает ее, пока она не реализуется в коде. Что касается структур данных, то для описания процесса их проектирования можно представить трехуровневую иерархию. Абстрактный тип данных (АТД) — теоретическая концепция, которая на высоком уровне описывает возможную организацию данных и операции, которые можно с ними выполнять. В АТД минимум (или вообще нет) под­ робностей относительно внутреннего представления данных, того, как они хранятся, и как используется физическая память. АТД дает возможность осмыслить на высоком уровне способы структурирования данных и опера­ ции, которые позволяет выполнять та или иная структура. В главе 1 я объяснял, что такое структура данных (СД), но позвольте привести здесь альтернативное определение: структура данных — это уточ­ нение спецификаций, предоставляемых абстрактным типом данных. На этом уровне обычно обсуждается вычислительная сложность операций, органи­ зация данных в памяти (или на диске) и внутренние детали СД. Третий уровень иерархии — реализация. На уровне структур данных мы не обращаем внимания на проблемы, относящиеся к специфике языка, и на особенности, связанные с кодингом структур данных. Для связанного списка мы определяем, как структурируется узел и что он содержит, но не беспокоимся о том, как будет выделяться для него память или будет ли связь со следующим узлом организована через указатель или же ссылку. С другой стороны, на уровне реализации надо написать код структуры данных, поэто­ му мы выбираем язык программирования и преобразуем общие инструкции, определяемые структурой данных, в код. Эти три уровня образуют иерархию степеней абстракции подходов к опи­ санию структуры данных в программном обеспечении. Отношения между уровнями в такой иерархии строятся по принципу «один ко многим» в на­ правлении сверху вниз: АТД может дополнительно уточняться многими СД, а одна СД может иметь много реализаций (некоторые из них могут быть функционально эквивалентны друг другу), в том числе на разных языках программирования. Одну и ту же СД можно использовать для реализации нескольких АТД. В этой и нескольких следующих главах будет показано, как динамический массив или связанный список могут реализовывать очень разные АТД. https://liveinternet.club
Абстрактные типы данных и структуры данных  139 Таблица 7.1. Примеры абстракций и реализаций Абстракция (АТД) Реализация (СД) Транспортное средство Автомобиль Грузовик Мотоцикл Сиденье Стул Диван Кресло Кресло-мешок Список Динамический массив Связанный список Стек Динамический массив Связанный список Очередь Динамический массив Связанный список Массивы и связанные списки: АТД или СД? Вот и все определения. Давайте обсудим несколько примеров, чтобы вы смогли составить представление о данной теме, и с чего лучше начать, как не с массивов, которые мы подробно обсуждали в начальных главах книги? Надеюсь, это не станет для вас сюрпризом, но массивы можно подвести под любой из упомянутых трех уровней: zzМассивы как АТД — здесь массив определяется как высокоуровневая аб­ стракция последовательности элементов. Все элементы упорядочены по внутренним правилам той или иной последовательности, и каждому из них соответствует его позиция (индекс). К каждому элементу возможен доступ по его индексу. zzМассивы как СД — кроме того, что задано АТД, мы обеспечиваем доступ к любому элементу массива как операцию с постоянным временем. Об­ ратите внимание: это всего лишь одно из многих возможных определений структуры данных для массивов. Например, в другом определении можно потребовать, чтобы все элементы относились к одному типу. zzРеализация массива — на этом уровне массивы рассматриваются как языковые средства (для языков со встроенной поддержкой массивов). Для массива должен быть выделен один непрерывный блок памяти, все его элементы должны располагать одинаковыми объемами памяти и от­ носиться к одному типу. Для языков, в которых массивы не поддержи­ ваются, можно написать собственную реализацию, как я сделал здесь: https://mng.bz/Ad9K. https://liveinternet.club
140  Глава 7. Абстрактные типы данных Для связанных списков определения, которые я привел в главе 6, уже работают на уровне структур данных. Здесь мы определяем внутреннюю организацию данных с использованием узлов, показываем, как эти узлы проектируются и как работают операции, выполняемые со связанными списками. Также мы продвинулись к уровню реализации кодом Python. Как насчет уровня АТД? Конечно, можно описать АТД, который уточня­ ется структурой данных связанного списка. Можно назвать его списком — последовательностью элементов, которую можно перебирать в определенном порядке (критерий упорядочения на этом уровне неважен). Обращения к элементам происходят последовательно. А вы знаете, какие другие структуры данных являются уточнениями АТД-списка? Если вы сказали «массивы» — в точку! Связанные списки и массивы — два уточнения, две структуры данных, происходящих от одного абстрактного типа данных. Таблица 7.2. Сравнение времени выполнения для массивов и связанных списков Вставка в начало Вставка в конец Вставка в середину Удаление Поиск Массив O(n) O(1) O(n) O(1)* O(n) Односвязный список O(1) O(n) O(n) O(n) O(n) Двусвязный список O(1) O(1) O(n) O(1)** O(n) * Если удаляемый элемент можно поменять местами с последним элементом; в противном случае — O(n). ** Если доступна ссылка на удаляемый узел. Если узел придется сначала найти, то O(n). Запомните, что я рассказал выше, потому что эта тема сыграет важную роль в нескольких следующих главах: мы опишем некоторые абстрактные струк­ туры данных и обсудим их возможные реализации на базе как массивов, так и связанных списков. Еще один пример: выключатель Прежде чем завершать обсуждение, давайте рассмотрим еще один пример с другой точки зрения: выключатель света. Да, вы все правильно поняли! Мы на минуту отойдем в сторону от computer science. Я хочу показать вам, как эту иерархию уровней абстрак­ ции можно применять в более широкой области науки и техники и, надеюсь, лучше смогу прояснить разницу между уровнями. Но данное упражнение полезно еще и потому, что выключатель похож на очень распространенный АТД — булев (логический) АТД. https://liveinternet.club
Абстрактные типы данных и структуры данных  141 Выключатель как абстрактный тип данных На верхнем уровне абстракции выключа­ тель может рассматриваться как устрой­ ство с двумя состояниями — вкл/выкл — и двумя методами: Выключатель turn_on() is_on() turn_off() zzпервый метод включает свет; zzвторой метод выключает свет. Состояние: вкл/выкл Вот и все! Это все, что нам нужно обозначить. Можно смоделировать еще более обобщенный переключатель, абстрагировав его назначение, но для нашего примера давайте оставим его в связке с состоянием освещенности. Цель описания АТД — определить интерфейс, то есть контракт с пользо­ вателями. Пока мы придерживаемся интерфейса, неважно, как мы реализуем наш выключатель, — можно даже переключаться между разными реализаци­ ями, не нарушая работоспособности приложений, использующих наш АТД. Выключатель как структура данных При переходе на уровень структуры данных необходимо подробнее опреде­ лить, как можно взаимодействовать с устройством. Не углубляясь в электро­ технические подробности, можно разработать ряд концепций относительно выключателя. Ситуация напоминает проектирование разных СД, реализующих один АТД: подобно тому как список можно реализовать посредством массивов или связанных списков, абстракцию выключателя можно реализовать по­ средством различных физических конструкций. Первый вариант — классический выключатель с маленьким тумблером, который поднимается и опускается. Включить: поднять рычажок. Выключить: опустить рычажок. Внутреннее состояние: позиция рычажка Другой дизайн с эквивалентным функционалом — две кнопки: одна включа­ ет свет, другая выключает. Нажимая одну кнопку, отключаем другую. ВКЛ ВЫКЛ Включить: нажать кнопку «вкл». Выключить: нажать кнопку «выкл». Внутреннее состояние: какая кнопка нажата https://liveinternet.club
142  Глава 7. Абстрактные типы данных Вариант такой конструкции — единственная кнопка, переключает между двумя состояниями без каких-то видимых изменений положения элементов устройства. Но можно придумать еще много других вариантов — например, цифровой выключатель, почему бы и нет? Tue, 78°F Lights: on OFF ON Включить: нажать кнопку «вкл». Выключить: нажать кнопку «выкл». Внутреннее состояние: булева (логическая) переменная У всех этих конструкций есть нечто общее: мы описываем, пока еще на до­ вольно высоком уровне, как поддерживается внутреннее состояние и как взаимодействовать с устройством, чтобы изменить это состояние. Если на уровне АТД мы указали только интерфейс устройства (должны присутство­ вать два метода: чтобы включить и чтобы выключить), то для СД необходимо также определить, как работают эти методы (то есть какую кнопку следует нажать и что при этом происходит). Реализация выключателя Когда дело доходит до изготовления действующего выключателя, можно взять любую спецификацию уровня структуры данных из предыдущего подраздела и развивать ее. До какой степени? Вплоть до мельчайших под­ робностей. Для примера возьмем двухкнопочный выключатель. На уровне реализа­ ции необходимо определить размеры выключателя и кнопок, материалы, из которых он должен быть изготовлен, решить, будут ли кнопки оставаться нажатыми или нет, разработать механизм замыкания/размыкания цепи и т. д. Необходимо прояснить все, что необходимо для изготовления рабо­ тающего выключателя. Точно так же в программном обеспечении — на уровне реализации не­ обходимо написать код, который будет работать в реальных приложениях. Контейнеры В следующих пяти главах мы сосредоточимся на определенном классе струк­ тур данных — контейнерах, поэтому в этом разделе я представлю их в общих чертах и объясню отличительные особенности этой структуры данных, а также почему она важна для разработчиков. https://liveinternet.club
Контейнеры  143 Что такое контейнер? Контейнер — абстрактная концепция, определение большой группы структур данных с общими харак­ теристиками. По сути, это набор элементов, обычно одного типа (но необязательно, особенно в языках со свободной типизацией, где это ограничение мо­ жет быть ослаблено). Главная особенность контейнеров — они предо­ ставляют способ организовывать и хранить данные Коробка — воплощение структурированно, что позволяет эффективно вы­ контейнера полнять ряд ключевых операций: доступ, вставку, удаление и поиск элементов, содержащихся в контейнере. Назначение кон­ тейнера — хранить множество фрагментов данных как единое целое, делая работу программистов с коллекциями данных более удобной и эффективной. Контейнеры снижают сложность управления данными. Помните, в главе 2 мы обсуждали, как смоделировать адвент-календарь в программе? Я упо­ минал о том, что календарь можно было бы реализовать на базе 25 разных переменных, но это непросто. С массивом все становится гораздо проще: его можно воспринимать как единое целое, а данные будут аккуратно орга­ низованы и легко доступны по индексу. И если у вас возник вопрос, то да, массивы — это контейнеры, как и свя­ занные списки. Они — базовые контейнеры и, возможно, самые важные, поскольку являются основой для более сложных контейнеров, которые мы обсудим в следующих главах. Мы знаем, что массивы и связанные списки сильно различаются и у каж­ дого из них есть свои достоинства и недостатки. Аналогичным образом и контейнеры могут различаться по своей базовой реализации и возможно­ стям, но у всех у них есть общее свойство — группировать элементы данных, а также некоторые другие общие характеристики. Что не является контейнером? Все ли структуры данных — контейнеры? Нет, многие не считаются тако­ выми. Например, в главе 13 рассматриваются графы. Хотя графы, как и контей­ неры, являются коллекциями элементов, они используются прежде всего для представления отношений и связей между этими элементами и предостав­ ляют различные алгоритмы для изучения этих связей. Обычно они не счита­ ются контейнерами, потому что их предназначение не сводится к простому управлению данными, а по степени сложности они превосходят контейнеры. https://liveinternet.club
144  Глава 7. Абстрактные типы данных Другая интересная структура данных, которую можно привести в ка­ честве примера, — k-d-деревья. Основное назначение этих особых дере­ вьев — организовывать многомерные данные и обеспечивать эффективное удовлетворение запросов с учетом меры удаленности. Они выходят далеко за пределы возможностей контейнеров, а также не предназначены для эф­ фективного удаления или поиска элементов по значению. Ключевые характеристики контейнеров Я уже упоминал о том, что контейнеры обладают рядом общих характери­ стик, но что это за характеристики? Давайте назовем некоторые из них. zzКонтейнеры — это коллекции элементов. Они содержат множество эле­ ментов, которые могут относиться к одному или нескольким типам и хра­ ниться в определенном порядке или без какого-либо порядка. zzКонтейнеры обычно предоставляют один и тот же набор базовых опера­ ций: вставки, удаления, чтения, изменения и поиска элементов коллекций. zzКонтейнеры можно перебирать. Все контейнеры предлагают способ по­ следовательно перебрать свои элементы. На уровне реализации общее для контейнеров — это предоставлять итераторы, которые обеспечивают последовательный доступ ко всем элементам коллекции и могут исполь­ зоваться, например, в циклах for. zzКонтейнеры могут поддерживать определенный порядок хранящихся в них элементов. Порядок может определяться последовательностью вставки (как было показано для списков) или следовать особым прави­ лам, как мы увидим в следующих трех главах на примере стеков, очередей и приоритетных очередей. zzКонтейнеры проектируются для эффективных обращений к своим эле­ ментам. Степень сложности общих операций (таких как вставка, удале­ ние, поиск) варьируется в зависимости от типа контейнера. Эти возможности чрезвычайно важны для разработки программного обес­ печения: когда следует сохранить элементы для последующей обработки, нужен контейнер. Большинство алгоритмов требуют перебирать элементы в определенном порядке, поэтому в таких случаях правильный выбор кон­ тейнера становится критичным, так как неправильный порядок может на­ рушить работу алгоритма или снизить его производительность. Контейнеры на самом деле тоже различаются, иначе любой произвольный контейнер нельзя было бы считать отдельной структурой данных. У неко­ торых контейнеров есть особые ограничения или правила, регулирующие добавление элементов, их удаление и обращение к ним. Мы рассмотрим множество примеров в последующих трех главах, но начнем прямо здесь, в следующем разделе, с нашего первого примера. https://liveinternet.club
Простейший базовый контейнер: мультимножество  145 Простейший базовый контейнер: мультимножество Можете ли вы представить, как будет выглядеть простейший из возможных контейнеров? Знакомьтесь: мультимножество (bag). Оно проще массивов и связанных списков, что выглядит немного парадоксально, потому что для реализации мультимножества придется использовать одну из этих структур. R i c e S o u p    C o r n F l a k e s Помните нашу тележку из главы 1? Да, это и контейнер, и мультимножество! Определение мультимножества Как мультимножество может быть проще массива? Начнем с того, что, до­ бавляя элементы в массив, мы поддерживаем порядок вставки. Мы также можем обратиться к любому элементу массива по индексу и удалять элемен­ ты по значению или по индексу. Дело в том, что ни одна из этих функций не является строго обязательной, когда речь идет о контейнерах! Можно вставлять элементы, забыв о порядке вставки. Также нет нужды индексировать. Здесь, не в пример массиву, все это можно упростить. Говоря о мультимножествах, мы перейдем на более формальный способ определения структур данных. То же самое сделаем в оставшейся части книги по отношению ко всем структурам данных. Первое, что я хотел бы сделать, — описать абстрактный тип данных для мультимножества, а это означает уточнить его интерфейс: необходимо четко определить методы, посредством которых клиент может взаимодействовать с мультимножеством. Недостаточно задать имя, аргументы и возвращаемые типы всех публичных методов АТД, также необходимо зафиксировать по­ ведение каждого метода, его побочные эффекты (side effects) — влияние, которое он может оказать на внутреннее состояние мультимножества, и за­ дачи, которые метод должен решать. Не волнуйтесь — по ходу дела, когда мы будем говорить о мультимноже­ ствах, все прояснится. https://liveinternet.club
146  Глава 7. Абстрактные типы данных Мультимножество insert(x) iterate() Состояние: совокупность элементов Мультимножество представляет собой набор объектов, включающий сле­ дующие методы: zzinsert(x) — позволяет клиенту добавлять в мультимножество отдельные элементы. Порядок вставки неважен, поэтому реализация мультимноже­ ства не обязана его поддерживать. zziterate() — позволяет клиенту перебирать все элементы мультимноже­ ства. Порядок итерации элементов не задан и может меняться от случая к случаю. Тут можно также добавить, что в мультимножестве могут храниться дубли­ каты (ограничений по степени уникальности нет, если только они не обу­ словлены контекстом, в котором используется мультимножество). Обратите внимание — здесь нет методов удаления и поиска элементов. Поддержка этих двух операций обычно ожидается от контейнера, так что мультимножества становятся своего рода особым случаем, промежуточным, — контейнером с ограничениями. Приведенные выше определения полностью описывают мультимножество как абстрактный тип данных, и теперь мы можем уточнить эти спецификации, чтобы определить более конкретную структуру данных. Но сначала давайте посмотрим, как использовать мультимножества. В конце концов, как мы уже говорили, когда описывали АТД, этот высокоуровневый интерфейс нужен только для того, чтобы добавить мультимножества в структуру приложения, а определение структуры данных и ее реализацию отложим на потом. Мультимножества в действии Когда понадобится мультимножество? Рассмотрим пример. Андреа — бэкенд-инженер из компании Beanbags1. Она недавно провела презентацию, посвященную тому, как она применила контейнер-мульти­ множество в качестве кэша для сбора ежедневной статистики по заказам. 1 Кресло-мешок, буквально «мешок бобов» — игра слов: bag переводится и как мешок, и как мультимножество. — Примеч. ред. https://liveinternet.club
Простейший базовый контейнер: мультимножество  147 заказ Прием заказов order data order stats Кэш за день Заказ № 2 Плазменный экран $1999.99 HD-фотокамера Заказ № 3 $450 Проигрыватель виниловых дисков $176.00 Заказ № 1 БД Когда Сара попросила Андреа объяснить, как работают мульти­множества, та ответила: «Вы в детстве собирали стеклянные шарики?» Чтобы нагляднее объяснить, как работают мультимножества, она при­ вела аналогию с шариками. Представьте, что в структуре данных — мульти­ множестве — могут храниться шарики — только разных цветов и с разными узорами. Шарики можно добавлять по одному, и они будут находиться в СД-мультимножестве, как в настоящем мешке настоящие шарики, и когда их накопится определенное количество, уже будет трудно определить, что и где в мешке kt;bn, — полный хаос! insert( ) insert( ) Когда я был маленьким (давно, очень давно!), мы с друзьями собирали ша­ рики, и со временем нам захотелось еще и поиграть в них, соорудить для них трассу и устроить гонки. Итак, перед стартом каждый должен был предъ­ явить свои сокровища (это была еще и возможность похвастаться!). Чтобы посчитать, сколько у кого шариков и какого типа, был только один способ: высыпать их на песок и начать подсчет. В computer science эквивалентной процедурой является итерация муль­ тимножества с подсчетом элементов! https://liveinternet.club
148  Глава 7. Абстрактные типы данных iterate() 1 1 1 2 2 3 2 3 4 5 3 4 (подсчет шариков с сортировкой по типам) Чтобы убедиться, что никто не жульничает, иногда мы пересчитывали еще раз, чтобы перепроверить слова других ребят. Это означало снова пере­ брать гору шариков — и, конечно, ко второму пересчету они лежали уже по-другому. Но если никто не жульничал и все считали аккуратно, порядок ни на что не влиял, и общее количество совпадало с разбивкой по типам. То же самое можно сказать о структуре данных — мультимножестве: что­ бы высчитать статистические данные по содержимому мультимножества (по кучке шариков или заказам за день), необходимо выполнить итерацию его элементов. Когда повторяешь итерацию, элементы могут оказаться в другом порядке, но даже так в большинстве случаев расчеты статистических данных совпадут — во всех тех ситуациях, когда порядок неважен, вроде общего количества чего бы то ни было за день или разбивки по типам. iterate() 1 1 1 2 2 3 2 3 4 5 3 4 iterate()    1 1 3 3 1 2 2 3 4 3 4 5 Итерация элементов мультимножества может изменить порядок их расположения, но на статистику, не зависящую от местоположения элементов, например на суммарное их количество или разбивку по типам, это не влияет. Реализация Теперь, рассмотрев несколько примеров использования мультимножества, мы готовы углубиться в описание его структуры данных с последующей реализацией. Важность случайности Позвольте начать с предпосылки: описывая мультимножества как АТД, я го­ ворил, что в мультимножествах можно не обращать внимания на порядок вставки, потому что можно итерировать элементы в любом порядке. И это даже хорошо, когда при повторной итерации их порядок изменится. Но тот факт, что элементы можно итерировать в случайном порядке, не означает, что мы должны так делать. Иначе говоря, когда речь заходит о по­ строении библиотеки, реализующей мультимножество, то хорошо, что мы всегда будем перебирать элементы в одном и том же порядке, — конечно, https://liveinternet.club
Простейший базовый контейнер: мультимножество  149 если контекст не требует добавить случайности (например, когда мы выпол­ няем операцию, итог которой предполагает проверку разных — и, возможно, равномерно распределенных — последовательностей). ПРИМЕЧАНИЕ Здесь проявляется важная асимметрия. В то время как мы, авторы реализации мультимножеств, можем решить всегда использовать опре­ деленный порядок итерации элементов, клиенты не должны на него полагаться, потому что определение мультимножества ясно указывает, что порядок в нем не гарантирован. Существуют и другие структуры данных, в которых случайность имеет решающее значение. В этой книге они не рассматриваются, но вы найдете примеры такого рода в «Продвинутых алгоритмах и структурах данных». Как бы то ни было, в мультимножествах, если нет ограничений со стороны предметной области, можно упростить себе задачу и просто итерировать элементы в порядке их вставки. Еще раз: определение не обязывает нас сле­ довать порядку вставки, но и не запрещает этого, а в данном конкретном случае следование ему упрощает задачу. Мультимножество как структура данных Эта особенность касательно порядка элементов развязывает нам руки, когда подходит момент описать структуру данных, чтобы реализовать мультимножество как АТД. Так как мы не обязаны возвращать случайную перестановку элементов, мультимножество становится особой разновидно­ стью списка, реализующей лишь подмножество его операций. Это означает, что в качестве основы для СД-мультимножества можно использовать лю­ бую реализацию АТД-списка: статические массивы, динамические массивы, связанные списки. На уровне структуры данных мы можем также уточнить свои определе­ ния, методами мультимножества добавляя по мере необходимости ограниче­ ния времени выполнения и затрат памяти, а также дополнительной памяти, необходимой структуре данных для хранения элементов. Итак, давайте рассмотрим имеющиеся варианты. zzСтатические массивы — в наихудшем случае элементы добавляются за постоянное время и итерируются за линейное время. Но проблема со статическими массивами заключается в том, что максимальная емкость мультимножества должна быть задана при его создании. Это станет до­ полнительным ограничением АТД, и это существенный недостаток. zzДинамические массивы — с этим решением не придется определять ем­ кость мультимножества заранее. С другой стороны, в этом случае вставка выполняется за время O(n) в наихудшем случае, хотя и амортизирован- https://liveinternet.club
150  Глава 7. Абстрактные типы данных ное время вставки n элементов остается тем же O(n), что мы обсудили в главе 5. zzСвязанные списки — поскольку допустимо итерировать элементы в поряд­ ке, обратном порядку вставки, а на самом деле в любом порядке, то можно использовать односвязный список и вставлять новые элементы в начало списка. Таким образом мы гарантируем вставку за время O(1) и перебор за O(n), и в нашем распоряжении максимальная гибкость относительно расширения списка по мере необходимости. Хранение ссылок требует некой дополнительной памяти, но об этом мы позаботимся на уровне реализации — асимптотически и массивы, и связанные списки требуют O(n) суммарной памяти для хранения n элементов. Класс Bag Итак, очень похоже на то, что в реализации класса Bag для хранения элементов лучше всего воспользоваться одно­ связным списком — нет нужды в двусвязном, ведь мы не собираемся удалять элементы и нам не нужно перебирать список от хвоста к голове. Можно применить композицию и задать связанный список как атрибут нового класса. Класс Bag представляет собой простую обертку связанного списка с элементами. Обертка необходима, потому что мы хотим, чтобы у класса Bag было только два публичных метода, с которыми могут взаимодействовать клиенты: class Bag: def __init__(self): self._data = SinglyLinkedList() Конструктор минималистичен — он просто инициализирует пустое муль­ тимножество, создавая пустой связанный список. Вставка Главное преимущество повторного использования других структур данных заключается в том, что это делает методы класса Bag чистыми и компактны­ ми. Как мы уже обсуждали, новые элементы определенно стоит вставлять в начало списка, а не в конец, что было бы неэффективно в односвязных списках, потому что, как мы выяснили в главе 6, чтобы найти последний узел, потребуется перебрать весь список. Чем хороша наша реализация, так это тем, что она сводится лишь к передаче нового элемента методу вставки связанного списка: def insert(self, value): self._data.insert_in_front(value) https://liveinternet.club
Итоги  151 Перебор Чтобы клиент мог итерировать элементы мультимножества, можно либо реализовать метод перебора, либо — в языках, которые предоставляют та­ кую возможность, — определить итератор. Подробности работы итераторов в Python для нас особого интереса не представляют, но вы найдете реализа­ цию итератора для Bag в нашем репозитории на GitHub: https://mng.bz/ZEWO. Если вместо этого вы захотите определить метод traverse, который воз­ вращает список Python с элементами мультимножества, то вот он: def traverse(self): return self._data.traverse() Помните: мультимножества не обеспечивают возврата элементов в каком-то определенном порядке, так что клиентский код не должен на это рассчиты­ вать. Это означает, что даже в тестах не следует устанавливать ограничения на порядок обхода элементов. Хороший подход в тестах — сравнивать пол­ ные наборы (множества) элементов полученного и ожидаемого результатов. Ознакомьтесь с тестами, которые я создал: https://mng.bz/RZO0. Например, я реализовал класс Bag на базе связанного списка и итерирую элементы в порядке, обратном порядку вставки. Даже если бы я заранее знал, в каком порядке вернутся элементы в текущей реализации, если бы я тестировал именно этот порядок, я не смог бы переключиться на другую реализацию, которая использует, например, массивы для хранения элемен­ тов мультимножества и считывает их в порядке вставки, потому что невоз­ можно будет успешно пройти эти тесты. Точно так же, если вы пишете код, рассчитанный на порядок итерации этой реализации, то если заменить ее на другую реализацию, это разрушит ваш код. А если использовать объект Bag из сторонней библиотеки, которая вам неподконтрольна, вы же не захотите оказаться в ситуации, когда придется объяснять начальству, почему ваш код вышел из строя (потому что владелец библиотеки вдруг изменил реализацию мультимножества, не нарушив его интерфейс). Итоги zzАбстрактный тип данных (АТД) — концепция, которая на высоком уровне описывает возможную организацию данных и операции, которые можно будет выполнять с этими данными. Он предусматривает мини­ мум — или не предусматривает вообще — подробностей о внутреннем представлении данных. zzСтруктура данных (СД) — это уточнение определения АТД: здесь мы конкретизируем, как данные организованы в памяти и какова заданная АТД вычислительная сложность операций. https://liveinternet.club
152  Глава 7. Абстрактные типы данных zzРеализация ƒ ƒ ƒ ƒ ƒ — это дальнейшее уточнение теперь уже определения СД, связанное со специфическими ограничениями языка программирования, дающее на выходе код на выбранном языке, полностью реализующий СД. zzКонтейнер — структура данных, относящаяся к классу со следующими характеристиками: ƒ Контейнеры — это коллекции элементов. ƒ Контейнеры предоставляют единый набор базовых операций для вставки, удаления, изменения, поиска элементов и доступа к ним. ƒ Все контейнеры предоставляют способ итерации по всем своим эле­ ментам. ƒ Контейнеры могут поддерживать, а могут и не поддерживать опреде­ ленный порядок хранящихся в них элементов. ƒ Контейнеры проектируются так, чтобы обеспечивать эффективное обращение к своим элементам. Степень сложности стандартных опера­ ций (вставка, удаление, поиск) зависит от типа контейнера и задается на уровне проектирования структуры данных. zzМультимножество — простейшая форма контейнера, предлагающая всего два метода: для вставки элементов и для итерации элементов, хранящихся в мультимножестве (поиск и удаление элементов не под­ держиваются). zzМультимножество может быть реализовано на основе базовых структур данных: массивов и связанных списков. Реализация на основе односвяз­ ного списка гарантирует лучшее время выполнения обеих операций, доступных мультимножеству. Также, в зависимости от специфических требований или ограничений, могут использоваться и более сложные структуры данных. https://liveinternet.club
Стеки. Накопление данных перед обработкой 8 В этой главе Знакомство со стеком как абстрактным типом данных 9 Принцип LIFO в реальном мире и в computer science 9 Реализация стека на базе массивов и связанных списков 9 Для чего нужны стеки 9 9 9 9 9 В предыдущей главе вы познакомились с контейнерами — классом структур данных, предназначенных в основном для хранения коллекций объектов, и с простейшим из всех контейнеров — мультимножеством. Мультимноже­ ства — простые структуры данных, не требующие особых затрат ресурсов. Они могут пригодиться, если надо сохранить данные для некоторых стати­ стических вычислений, но в целом они не особенно широко используются. Пора заняться контейнерами, критически важными для computer science. Начнем со стека. Стеки встречаются в computer science повсеместно, от низ­ коуровневых программ, обеспечивающих работу приложений, до новейших графических пакетов. В этой главе мы узнаем, что такое стек, увидим, как он работает, и рас­ смотрим несколько программ, работающих на стеках. https://liveinternet.club
154  Глава 8. Стеки Стек как АТД Как я упоминал при рассмотрении мультимножеств, наше обсуждение каж­ дого контейнера будет начинаться с уровня абстрактного типа данных (АТД). То есть мы определим, что такое стек, как он работает на высоком уровне и какой интерфейс для взаимодействия с ним предоставляет. Стек и LIFO Стек — контейнер, поддерживающий добавление и удаление элементов по точно определенным правилам: здесь невозможно добавить новый элемент куда угодно, как в случае с массивами и списками. Механизм работы стека описывается аббревиатурой LIFO (Last In, First Out, то есть «последним зашел, первым вышел»). Этот принцип широко распространен в реальном мире, за пределами computer science. На ввод­ ных курсах информатики стек часто сравнивают со стопкой тарелок на ресторанной кухне: официанты ставят грязные тарелки на верх стопки, а посудомойка берет их мыть в обратном порядке. Среди прочего, прин­ цип LIFO также используется в бухгалтерии и управлении складскими запасами. D C C B B A A D C C B B A A D LIFO в складировании товаров В контексте контейнеров принцип LIFO требует структуры данных, в кото­ рой элементы вставляются, а затем используются (или удаляются) в порядке, обратном порядку вставки. Операции со стеком Чтобы стек соответствовал парадигме LIFO, его интерфейс сконструирован так, что поддерживает только два метода: zzМетод вставки элемента в стек — в случае со стеками принято присва­ ивать этому методу имя push() вместо традиционного insert(). zzМетод извлечения последнего добавленного элемента из стека и его возвращения — этот метод по традиции называется pop(), иногда top(). https://liveinternet.club
Стек как АТД  155 Стек push(x) Состояние: последовательность элементов + порядок их вставки top() С учетом ограничения LIFO стек должен поддерживать тот порядок эле­ ментов, в котором они были вставлены. Определение АТД не устанавли­ вает ограничений на то, как должен поддерживаться этот порядок или как должны храниться элементы, так что стек можно представить, например, как стопку элементов вроде стопки тарелок. Вершина Вершина 12 1 2 push(1) Стек 1 12 1 2 Стек Вершина pop() Вывод: 1 12 1 2 Стек Вершина pop() Вывод: 12 1 2 Стек Также необходимо отслеживать последний элемент, добавленный в стек, который (по аналогии со стопкой тарелок) именуется вершиной стека. Что нам надо делать на практике, так это каким-то образом поддерживать по­ следовательность элементов в порядке вставки, а вершиной стека будет та сторона последовательности, на которую мы добавляем и с которой удаляем элементы (независимо от того, как сохраняется последовательность). Стек в действии Карло открыл небольшую компанию, занимающуюся поставкой местных деликатесов из Неаполя экспатам по всему миру. Они отправляют посылки только одного размера и веса (20 килограммов), но заказчики могут в какойто степени менять состав своих заказов. Компания Карло еще маленькая, поэтому у них мало места для хранения готовых к отправке посылок. Помещение у Карло такое, что посылки можно складывать только в два штабеля, едва оставив место для работы вилочного погрузчика. Погрузчик тоже небольшой, он поднимает всего одну посылку. Таким образом, с каждого из этих штабелей можно снять погрузчиком зараз только одну верхнюю посылку. Что-то напоминает? Да, каждая стопка посылок — это стек! https://liveinternet.club
156  Глава 8. Стеки Карло решает разделить посылки на две группы. В одной стопке хранят­ ся все стандартные упаковки: в них одинаковые наборы, срок их годности истекает еще не скоро, и после подготовки любая из них подойдет для выполнения но­ вого заказа. В другой стопке хранятся не­ D 3 C стандартные заказы, пока курьер не заберет 2 B их для отправки. 1 A «Стандартная» стопка работает как стек — Стандартные и это не идеальный вариант, потому что Нестандартные правильнее было бы каждый раз первой отправлять ту посылку, которая была первой подготовлена (это мы рассмотрим в следующей главе), но к со­ жалению для Карло, складирование работает именно так. С «нестандартной» стопкой все интереснее. Эта стопка также работает как стек, но проблема в том, что каждый раз из нее нужно извлечь тот или иной конкретный элемент, а интерфейсы стеков такую операцию не поддерживают. Не тревожьтесь, есть способ это сделать — при помощи второго времен­ ного стека. Допустим, в хранилище Карло стопка из шести нестандартных заказов, и по какой-то причине ему нужно достать посылку под номером 3. 6 5 4 3 3 2 2 1 1 4 5 6 Что он может сделать? Он берет сначала верхнюю посылку с номером 6. За­ тем кладет ее рядом с местом разгрузки и берет следующую посылку с но­ мером 5 и так далее. Таким образом Карло организовывает в месте разгруз­ ки временный стек с элементами, лежавшими над нужной посылкой, предназначенной для отправки, — с номером 3. Наконец, когда нужная посылка «3» отправляется в грузовик курьера, Карло должен вернуть посылки из временной стопки обратно, начиная с верхней с номером 4 и так далее, пока все три не вер­ нутся в нестандартную стопку. 6 В терминологии computer science это пример при­ 5 ложения, использующего два стека, чтобы обеспечить 4 функциональность массива. Если интуиция подсказыва­ 2 1 ет вам, что это ужасно неэффективно, то вы правы: для этого требуется вдвое больше памяти (два стека одина­ кового размера, один из которых остается пустым), а удаление/возвращение условного элемента при работе со стеком размером n потребует O(n) шагов. Но иногда у вас может просто не остаться выбора… https://liveinternet.club
Стек как структура данных  157 Стек как структура данных После того как мы закончили обсуждать стек как абстрактный тип данных и высекли в граните его интерфейс, нужно подумать о его реализации. ПРИМЕЧАНИЕ Помните, что интерфейс АТД — единственное, что следует «высечь в граните». Любые изменения в этом интерфейсе или в предполагаемом поведении, заданном на этапе АТД, нарушат совместимость всех структур данных, построенных на основе АТД. Мы обсуждали это в главе 7: на один АТД может приходиться несколько разных описаний СД. На уровне структуры данных мы концентрируемся на подробностях того, как данные хранятся в стеке и на ресурсах, необходи­ мых для операций (которые в свою очередь обычно обусловлены выбором базовых структур данных). Как и в случае мультимножества, для хранения данных стека можно рас­ смотреть три основных варианта: zzстатический массив; zzдинамический массив; zzсвязанный список. Рассмотрим каждый из них поподробнее. Статический массив для хранения данных стека Если для хранения элементов стека мы используем статический массив, можно заносить новые элементы в конец массива и извлекать их оттуда же. Таким образом, статический массив обеспечивает нам очень хорошую про­ изводительность этих двух операций: обе они выполняются за время O(1) и не требуют дополнительной памяти. 0 1 2 2 0 -1 0 1 2 0 Вершина 3 4 5 4 1 6 5 Вершина 2 3 4 -1 4 1 0 1 2 2 0 -1 0 1 2 2 0 -1 Вершина push(5) 3 4 5 4 1 5 Вершина pop() 3 4 5 4 1 5 push(1) Ошибка https://liveinternet.club
158  Глава 8. Стеки Большая проблема со статическими массивами в том, что их размер не­ изменен. Максимальная емкость стека, использующего статические массивы для хранения своих данных, должна быть задана при его создании, и изме­ нить ее не удастся. В каких-то ситуациях это приемлемо, но по большому счету это ограничение нежелательно. Динамический массив С динамическим массивом способ добавления и извлечения элементов не изменяется — эти операции все так же выполняются в конце массива. Тем не менее проблема емкости решена: в стек можно занести столько элементов, сколько потребуется, — по крайней мере, пока RAM хватает, чтобы выделить более крупный массив. Тот факт, что размер динамического массива удваивается при добавлении нового элемента в заполненный массив, может создать некоторые проблемы. Прежде всего при расширении динамического массива необходимо выделять намного больше памяти, чем необходимо в данный момент, — в среднем O(n) дополнительной памяти. И это еще цветочки. Если массив реализуется непрерывной областью памяти, с ростом разме­ ра стека становится все труднее найти доступный блок памяти, достаточно крупный, чтобы выделить нужный размер под новый базовый массив. Кро­ ме того, если выделить память для массива не удастся, происходит ошибка времени выполнения (runtime error) и приложение падает. Следовательно, если ожидается, что стек разрастется — до тысячи элементов и более, — то динамические массивы могут оказаться не лучшим вариантом. Вершина 0 1 2 2 0 -1 0 Вершина 3 push(4) 1 2 3 2 0 -1 4 0 1 2 0 0 push(1) 2 3 4 -1 4 1 4 Вершина 5 6 7 5 6 7 pop() 1 2 3 0 -1 4    2 Вершина Операции со стеком, реализованном на динамическом массиве. При вызове операции push(1) свободных ячеек нет, поэтому размер массива удваивается Впрочем, даже с менее масштабными стеками приходится переплачивать по сравнению со статическими массивами: push и pop выполняются за вре­ https://liveinternet.club
Стек как структура данных  159 мя O(n) в наихудшем случае в стеке с n элементами. Если вы еще не забыли обсуждение динамических массивов из главы 5, здесь есть и положительная сторона: добавление n элементов в пустой стек выполняется за амортизиро­ ванное время O(n) — аналогичным образом очистка стека с n элементами выполняется за амортизированное время O(n). Обе операции требуют по­ стоянной дополнительной памяти. Связанные списки и стеки Как обычно, реализация с использованием связанного списка оказывается самой гибкой. В стеках необходимо вносить изменения только с одного конца списка, и мы можем выполнять операции в начале списка — хранение элементов в порядке, обратном порядку вставки, не создает никаких про­ блем, так как перебирать их не нужно. А вы догадываетесь, почему эти соображения так важны? Потому что это означает, что можно использовать односвязные списки. Перебирать список вовсе не нужно, а операции вставки и удаления в начале списка для одно­ связных списков выполняются за время O(1), так что нет необходимости использовать двусвязные. Но что самое приятное? Как мы уже знаем, связанные списки гибки по своей природе, что дает возможность расширять и сокращать стек по мере надобности без дополнительных затрат. Кроме того, связанные списки устанавливают меньше ограничений на выделение необходимой им па­ мяти: новый узел может быть выделен в любом месте (ему необязательно располагаться вплотную к остальным узлам или где-то поблизости). Это упрощает выделение более крупных стеков по сравнению с реализацией, использующей массивы. Вершина head 4 -1 0 2 4 -1 0 2 1 4 -1 0 4 -1 0 2 push(1) Вершина head 1 push(6) Вершина head 6 Вершина head 1 https://liveinternet.club 2 pop()
160  Глава 8. Стеки Все ли золото, что блестит? Не всегда, как вам наверняка уже известно. Связанные списки требуют дополнительных затрат памяти для хранения в каждом элементе ссылки на следующий узел. А в следующем разделе все возможные недостатки будут рассмотрены более подробно. Короче говоря, реализация со связанными списками будет лучшим вари­ антом, если вам необходима гибкость, и в теории она также обеспечивает са­ мую эффективную реализацию операций со стеком — по крайней мере, если дополнительная память, необходимая для указателей на узлы, не является проблемой. Но если размер стека известен заранее, реализация со статиче­ скими массивами может оказаться более предпочтительной альтернативой. Реализация на базе связанного списка Анализ на уровне структур данных предполагает, что реализация на базе связанного списка — вариант, обеспечивающий лучшую производитель­ ность и оптимальное использование ресурсов при всех операциях со стеком. Таким образом, хотя дальше в этом разделе мы кратко обсудим вариант с динамическими массивами, пока просто сосредоточимся на связанных списках, начиная с определения класса. Вы заметите много общего с классом Bag, рассмотренным в главе 7. Как и мультимножества, класс Stack представ­ ляет собой простую обертку, которая ограничивает интерфейс связанного списка, позволяя использовать только подмножество его методов: class Stack: def __init__(self): self._data = SinglyLinkedList() Как и с мультимножествами, конструктор просто создает пустой связанный список. Полный код для стеков доступен в репозитории книги на GitHub: https://mng.bz/d6lO. Теоретически, кроме этих трех вариантов, приведенных на уровне струк­ туры данных, существует и четвертый: стек можно реализовать с нуля, управляя способом хранения данных без очередного использования какихлибо базовых структур данных. Но зачем нам это? Мы ничего не добива­ емся, а взамен получаем много дублирующегося кода, потому что придется реализовывать подробности всех операций. В оставшейся части этого раздела обсудим операции API стека, push и pop, а также третий метод, peek, — операцию, которая только читает данные и иногда предоставляется в дополнение к стандартному API. Push Зачем же реализовывать все с нуля? Если для хранения данных стека мы вос­ пользуемся связанным списком, то, добавляя в стек новый элемент, сможем https://liveinternet.club
Реализация на базе связанного списка  161 снова использовать существующий метод insert_in_front из связанных списков. Предполагая, что метод уже должным образом протестирован и консолидирован, мы можем написать метод push в одну строку: def push(self, value): self._data.insert_in_front(value) Базовый связанный список берет на себя все технические подробности — нам остается лишь перенаправить вызов связанному списку. Stack 4 top -1 0 2 0 2 push(1) Stack 4 top -1 1 В зависимости от контекста, обертку push можно использовать для выпол­ нения каких-либо проверок перед непосредственной вставкой элемента в список. Например, если есть ограничения на допустимые значения, можно проверить в стеке со строками, что добавляемое значение не является пустой строкой. Pop Как и push, метод pop сильно зависит от интерфейса связанного списка. В простейшей форме он также может состоять из одной строки, которая просто вызывает в базовом списке delete_from_front. Stack top 4 -1 0 2 pop() Stack top 4 -1 0 https://liveinternet.club 2
162  Глава 8. Стеки Однако при попытке удалить из пустого списка будет вызвано ис­ ключение (за подробностями обращайтесь к главе 6). Такое поведение ожидаемо, но точка, в которой возникнет исключение, и сообщение об ошибке могут сбить с толку пользователя стека и раскрыть вызывающей стороне избыточные подробности внутренней реализации. По этой при­ чине я считаю, что здесь будет лучше явно проверять условие ошибки в методе стека и выдавать исключение, если стек пуст. Конечно, за эту на­ глядность приходится платить тем, что при благоприятном случае (когда не возникает ошибок) проверка будет выполняться дважды — стеком и связанным списком: def pop(self): if self.is_empty(): raise ValueError("Cannot pop from an empty stack") return self._data.delete_from_front() Как вариант — можно перехватить исключение, выданное методом связан­ ного списка, и выдать другое исключение. Peek Метод peek должен быть самым простым для реализации, не так ли? В кон­ це концов, нужно всего лишь вернуть элемент с вершины стека, не внося никаких структурных изменений в сам стек. Тем не менее даже в таком простом методе кроются подводные камни! Нам следует учесть некоторые соображения и обсудить ряд аспектов, чтобы предотвратить возможные будущие ошибки. Простейшая версия этого метода также может быть однострочной и про­ сто возвращать данные, хранящиеся в начале списка. Что-то вроде этого: return self._data._head.data() С этим подходом связаны три проблемы. zzМы не проверяем, пуст ли список, прежде чем обращаться к его голове. zzЭто обращение адресовано приватному атрибуту _head связанного списка. zzМы возвращаем ссылку на элемент, хранящийся в начальном узле. Если элементы списка представляют собой изменяемые объекты, то, если у кого-то есть ссылка, он сможет в любой момент изменить объект. Первая проблема легко решается так же, как с pop(), — просто нужно про­ верять, не граничный ли у нас сейчас случай, прежде чем пытаться что-то делать. А что касается последней — можно использовать существующую библиотеку Python (https://docs.python.org/3/library/copy.html) для копирования данных вместо передачи ссылки: https://liveinternet.club
Теория и реальность  163 import copy def peek(self): if self.is_empty(): raise ValueError("Cannot peek at an empty stack") return copy.deepcopy(self._data._head.data()) Уже лучше, но это все еще обращение к приватному атрибуту связанного списка. Единственное приемлемое решение — добавить в класс связанного списка метод, возвращающий элемент, хранящийся в основном корпусе списка, а не в крайних позициях. УПРАЖНЕНИЯ 8.1 Реализуйте для связанных списков метод get(i), возвращающий i-й элемент от головы списка (i≥0). Затем измените реализацию peek, чтобы избежать обращения к приватному атрибуту _head связанного списка. 8.2 После реализации get(i) обязательно ли вызывать deepcopy в peek? Про­ верьте свою реализацию get и убедитесь в том, что это не обязательно. 8.3 Реализуйте отдельный класс Stack, в котором для хранения элементов стека используются динамические массивы. Сравните новую реализа­ цию с той, что выстроена на связанных списках. Теория и реальность В предыдущем разделе, когда мы обсуждали, как перейти от определения АТД стека к более конкретному определению структуры данных, я показал, что реализация стека на базе связанного списка наиболее эффективна. На­ помню, что при использовании односвязного списка операции push и pop мо­ гут выполняться с постоянным временем в наихудшем случае, тогда как при использовании динамического массива оба метода выполняются за линейное время в наихудшем случае, но если выполняется большое количество опе­ раций, то можно считать, что амортизированные затраты составляют O(1). И на этом все? Может, нужно просто реализовать версию со связанным списком и успокоиться? Вообще-то нет. Во-первых, не стоит реализовывать собственную библио­ теку без крайней необходимости: например, вы не можете найти существу­ ющую реализацию, которая заслуживает доверия, эффективна и тщательно протестирована, или же вам необходимо основательно перенастраивать свою реализацию. Во-вторых, если код, в котором надо использовать эту библиотеку, кри­ тически важен для вашего приложения и может стать «узким местом» при­ ложения, его придется профилировать. https://liveinternet.club
164  Глава 8. Стеки ПРИМЕЧАНИЕ Профилировать весь свой код не стоит. Это займет много вре­ мени и будет в основном бесполезно (если только вы не пишете код для ресурса, предназначенного для работы в реальном времени). Весь фокус в том, чтобы сосредоточиться на критических участках, оптимизация которых обеспечит наибольший прирост эффективности. Под профилированием кода понимается измерение работы приложения, чтобы определить, какие методы выполняются чаще всего и какие занимают больше всего времени. В Python для этого можно воспользоваться модулем cProfile: https://docs. python.org/3/library/profile.html. Я реализовал версию стека StackArray, исполь­ зующую список Python list для хранения своих элементов: https://mng.bz/Bdj8. Почему list? Во-первых, наша версия динамических массивов имеет огра­ ничения по типу элементов, что делает ее несовместимой с версией связанных списков, описанной в этом разделе. Во-вторых, не хочу забегать вперед, но это связано с производительностью. Через минуту поговорим об этом. Итак, я написал быстрый скрипт (https://mng.bz/lMB8), выполняющий миллионы операций со стеками обоих типов (два класса называются Stack и StackArray), причем вызовов push вдвое больше, чем pop. Одни и те же операции в одном и том же порядке выполняются с обеими версиями стека, после чего мы измеряем, сколько времени заняло выполнение каждой из версий. Какой результат вы ожидаете увидеть? Сколько бы вы поставили на то, что версия со связанным списком будет быстрее? Вообще-то вас может ожидать сюрприз: Смотреть нужно на столбец с суммарным временем, то есть временем, про­ веденным в функции и во всех ее подвызовах — это особенно важно, потому что методы связанного списка вызываются внутри всех методов в Stack. В реализации на динамических массивах — в виде списка Python list — push работает более чем в четыре раза быстрее, а pop — более чем в три. Как такое возможно? Означает ли это, что про асимптотический анализ можно забыть? Конечно нет. Тут необходимо учитывать ряд факторов. zz В реализации, основанной на динамических массивах, хотя время выполне­ ния push и pop в наихудшем случае линейное, но амортизированное — то же, https://liveinternet.club
Теория и реальность  165 что и у связанных списков: n операций (push или pop) выполняются за время O(n). Мы обсуждали это в главе 5 для динамических массивов. Так как эф­ фективность этих методов измеряется по большому количеству операций, использование связанных списков не дает асимптотических преимуществ. zz Python предоставляет оптимизированную, чрезвычайно эффективную реализацию list. Этот код обычно пишется на C и компилируется для ис­ пользования в Python, чтобы убедиться, что он эффективен настолько, на­ сколько это возможно (https://docs.python.org/3/extending/extending.html). Трудно написать чистый код Python, который был бы столь же эффективен. Таким образом, каждый вызов добавления элемента в экземпляр StackArray выпол­ няется лишь за малую часть того времени, что требуется экземпляру Stack. Со связанными списками необходимо выделять новый узел при каждом zz вызове push, а затем уничтожать объект Node на каждый pop. Выделение памяти и создание объектов требуют времени. Для подтверждения третьей гипотезы можно взглянуть на статистику методов в SinglyLinkedList: Большая часть времени, потраченного на Stack.push, была проведена за вы­ полнением SinglyLinkedList.insert_in_front; то же самое относится к Stack. pop и SinglyLinkedList.delete_from_front. Последняя строка также интересна — половина времени, потраченного на insert_in_front, ушла на создание нового экземпляра Node. Какие же уроки можно извлечь из этого анализа? ПРИМЕЧАНИЕ При проектировании структуры данных выбирайте реализации с наилучшей производительностью по нотации «О-большое». Если два реше­ ния в асимптотическом анализе близки по производительности, рассмотрите возможность профилирования, чтобы сравнить их реализации с точки зрения эффективности. Это хорошая отправная точка, но, к сожалению, этого не всегда достаточно. В некоторых случаях структуры данных смотрятся лучше на бумаге (их вре­ мя выполнения «О-большое» лучше, чем у альтернатив), но их реализации на практике оказываются медленнее — по крайней мере, для входных данных конечного размера. https://liveinternet.club
166  Глава 8. Стеки Классический пример такого рода — фибоначчиева куча, усовершенство­ ванная приоритетная очередь с наилучшей теоретической эффективностью. Кучи будут рассматриваться в главе 10, но здесь важно то, что фибоначчиевы кучи асимптотически лучше обычных (амортизированное время вставки и извлечения минимального значения O(1), тогда как для обычных куч обе операции характеризуются временем O(log(n)), но с любыми реальными входными данными их реализация работает намного медленнее. По мере накопления опыта вам будет все легче выявлять эти граничные случаи. Тем не менее при любых сомнениях профилирование поможет вам определить, где и как можно усовершенствовать структуру данных или при­ ложение. Другие применения стека Мы обсудили некоторые реальные ситуации, которые работают подобно LIFO, но стеки широко применяются в computer science и программирова­ нии. Давайте вкратце рассмотрим несколько применений. Стек вызовов Стек вызовов — специальная разновидность стека, в котором хранится информация об активных функциях (или, в более общем смысле, подпро­ граммах) выполняемой компьютерной программы. Чтобы нагляднее про­ иллюстрировать смысл этого определения, давайте посмотрим, как может выглядеть стек вызовов для метода Stack.push: Указатель стека Указатель кадра Вершина стека Return value Return address next_node Локальные переменные Кадр стека для Node(3) data (3) old_head Return value Return address Кадр стека для insert_in_front(3) data (3) Return value Аргументы Return address value (3) Кадр стека для push(3) ... Глобальный кадр стека Дно стека https://liveinternet.club
Другие применения стека  167 Как обсуждалось в разделе реализации, метод push вызывает метод в свою очередь вызывает кон­ SinglyLinkedList.insert_in_front, который структор класса SinglyLinkedList.Node: push(3) ... [Stack.push] self._data.insert_in_front(3) ... [SinglyLinkedList.insert_in_front] self._head = Node(3, old_head) ... [Node.__init__] self._data = 3 При выполнении надо передавать элемент, который мы добавляем в Stack после каждого вызова. В коде для этого используются аргументы функций. На более низком уровне аргументы функций передаются через стек вызовов: когда мы вызываем push, для этого вызова функции создается кадр стека и выделяется место для аргументов push. То же самое происходит и с вызовом insert_in_front, при котором со­ храняется значение аргумента data (обычно в начале кадра стека). Анало­ гичный механизм существует и для возвращаемых значений: в кадре стека резервируется область памяти для значений, которые будут возвращаться вызывающей стороне. Если вызывающая сторона сохраняет возвращаемое значение в локальной переменной, оно также сохраняется в ее кадре стека. Наконец, каждый кадр стека содержит адрес возврата: это адрес, по кото­ рому в памяти сохраняется команда, выполняющая вызов функции. Он ис­ пользуется, чтобы возобновить выполнение вызывающей функции, когда вызывающая сторона возвращается. Кадры стека размещаются друг над другом, и выполнение точно так же, как и сам стек, раскручивается в обратную сторону: последняя вызванная функция будет первой на возврат, а ее кадр стека извлекается из стека вы­ зовов (вместе с адресом возврата), что позволяет продолжить выполнение вызывающей функции, и т. д. Вычисление выражений Постфиксная запись — способ записи арифметических выражений, при котором оператор всегда следует за операндом. Например, выражение, которое в инфиксной записи имеет вид 3 + 2, в постфиксной записи пре­ вращается в 3 2 +. Одно из преимуществ такой записи заключается в том, что она устраняет все неоднозначности, встречающиеся в инфиксной записи. Например, чтобы вычислить выражение 3 + 2 * 4, необходимо применить концепцию приоритета операторов и договориться о том, что умножение приоритетнее сложения, чтобы выражение было фактически интерпрети­ https://liveinternet.club
168  Глава 8. Стеки ровано как 3 + (2 * 4). Если бы надо было сначала сложить, то пришлось бы добавить скобки и записать выражение в виде (3 + 2) * 4. В постфиксной записи скобки не нужны — две возможные комбинации можно записать в виде 3 2 4 * + и 3 2 + 4 * соответственно. Есть и другое преимущество: значение постфиксного выражения легко вычисляется с помощью стека. Когда мы разбираем операнд, то есть значе­ ние, мы добавляем его в стек, когда же разбираем оператор, то извлекаем из стека два последних значения (для двоичного оператора), применяем оператор и затем уже добавляем результат в стек. Давайте рассмотрим пример. Вот как будет выглядеть разбор выражения 3 2 4 * +: 3 2 4 * + push(3) push(4) push(2) pop()->4 pop()->2 push(2*4) pop()->8 pop()->3 push(3+8) 4 3 2 2 8 3 3 3 11 А вот как разбирается 3 2 + 4 *: 3 2 + 4 * push(3) push(2) pop()->2 pop()->3 push(3+2) 2 3 3 pop()->4 pop()->5 push(5*4) push(4) 4 5 5 20 Отмена/повтор Вы когда-нибудь задумывались над тем, как работает функциональность undo («отменить действие») в вашей IDE или текстовом редакторе? Они выстроены на стеке (а вернее, на двух стеках, если можно возвращать от­ мененные действия). В первом стеке отслеживаются изменения в документах. Размер этого стека обычно ограничен, так что самые старые записи будут удаляться, и ко­ личество изменений, которые можно отменить, ограниченно. https://liveinternet.club
Другие применения стека  169 Когда вы кликаете undo, документ восстанавливается в состоянии, в ко­ тором он был до последнего выполненного действия. Но это еще не все: отмененные изменения добавляются в новый стек — стек повтора, так что, если вы нажмете undo случайно или передумаете отменять действие, то, пре­ жде чем вносить дальнейшие изменения в документ, можно будет вернуться обратно. Вершина стека Выровнять текст по центру Удалить текст «y oh y» Изменить размер шрифта на 13p Заменить начертание на полужирное Добавить текст «xyz» Стек отмены Вершина стека Выровнять текст по правому краю Добавить текст «ZZZ» Стек повтора undo() Удалить текст «y oh y» Изменить размер шрифта на 13p Заменить начертание на полужирное Добавить текст «xyz» Стек отмены Выровнять текст по центру Выровнять текст по правому краю Добавить текст «ZZZ» Стек повтора Обратно по своим же следам Стеки прекрасно работают там, где требуется шаг за шагом вернуться к ис­ ходной точке. Помните, мы в главе 6 говорили о пошаговом возвращении? Мы помогали нашему другу Тиму, который работал над видеоигрой, и ему понадобилось сохранить в игре список комнат и то, как главный персонаж мог бы ходить из комнаты в комнату слева направо и обратно. Как мне вернуться? Двусвязный список идеально подходил для статической ситуации в струк­ туре игры. Но что, если теперь Тиму нужно запомнить путь, пройденный игроком с начала игры до нынешнего момента, и позволить персонажу шаг за шагом вернуться по своим же следам? Вероятно, вы догадались: Тиму понадобится стек! https://liveinternet.club
170  Глава 8. Стеки Когда игрок входит в комнату, эта комната добавляется в стек. Если нуж­ но отследить передвижения игрока, мы извлекаем комнаты из стека. Учтите, что одна комната может встречаться в стеке многократ­ но, если пользователь выходил из нее и снова возвра­ щался. Этот сценарий — специфический случай: здесь ком­ how do I go back? наты расположены линейно, друг за другом. В более распространенной ситуации может понадобиться пере­ мещаться в двумерной или трехмерной среде. В анало­ гии с комнатами/видеоиграми в некоторых комнатах будет больше двух дверей. Среду такого рода не удастся смоделировать посредством списка — нам понадобится граф. В главе 13 мы обсудим графы, как их можно исполь­ зовать, чтобы моделировать карту города, и как алго­ ритм поиска в глубину использует стек для навигации по графу. УПРАЖНЕНИЕ 8.4 Напишите метод, который разворачивает элементы односвязного списка в обратном порядке. Подсказка: как использовать стек для решения этой задачи? За какое время будет выполняться эта операция? Итоги zzСтек представляет собой контейнер, работающий по принципу LIFO: последний элемент стека становится первым извлекаемым элементом. Стек можно представить в виде стопки тарелок: добавлять тарелки можно только на вершину стопки и снимать также только оттуда. zzСтеки широко применяются в computer science и программировании, включая стеки вызовов, вычисление выражений, функциональность от­ мены/повтора, отслеживание отступов и парных скобок в редакторах. Кроме того, многие алгоритмы используют стеки для отслеживания пройденного пути, например алгоритм поиска в глубину. zzСтеки предоставляют две операции: push для добавления элемента на вершину стека и pop для удаления и возврата элемента с вершины. Других способов вставки и удаления элементов не существует, поиск по стеку обычно не поддерживается. https://liveinternet.club
Итоги  171 zzИногда предоставляется третья операция, peek, которая возвращает эле­ мент с вершины стека, не удаляя его. zzМожно реализовать стек, используя для хранения элементов хоть масси­ вы, хоть связанные списки. zzПри использовании динамического массива push и pop выполняются за время O(n) в наихудшем случае, но с амортизированным временем O(1) (при большом количестве операций). zzПри использовании односвязных списков push и pop выполняются за время O(1) в наихудшем случае. zzПоказатели амортизированной производительности двух реализаций достаточно близки друг к другу. Чтобы понять, какая из них более эф­ фективна в том или ином языке программирования, можно обратиться к профилированию. https://liveinternet.club
9 Очереди. Сохранение информации в порядке поступления В этой главе Знакомство с очередью как с абстрактным типом данных 9 Объяснение принципа FIFO 9 Реализация очереди на базе массивов и связанных списков 9 Применение простых очередей 9 9 9 9 9 Контейнеры, которые мы рассмотрим далее, — это очереди, иногда называе­ мые простыми очередями, чтобы их можно было отличить от приоритетных очередей (последние описаны в главе 10). Как и стеки, очереди вдохновлены нашим повседневным опытом и ши­ роко используются в computer science. Они работают аналогично стекам, с похожим базовым механизмом, и их также можно реализовать, используя для хранения данных массивы или связанные списки. Разница в деталях, о которых мы узнаем в этой главе. Очередь как абстрактный тип данных Очередь — контейнер, который, подобно стеку, позволяет вставлять элемен­ ты только в определенную позицию и удалять также только из определенной позиции. Какие операции позволяет осуществлять очередь? Что определяет внутреннее состояние очереди и как она себя ведет? https://liveinternet.club
Очередь как абстрактный тип данных  173 Для начала разберемся, как работают очереди, а затем опишем их интер­ фейс. Первым пришел — первым ушел Стеки используют политику LIFO (last Ненавижу очереди! in, first out): последним пришел — пер­ вым ушел. Очереди работают по сим­ метричному принципу под названием FIFO — сокращение от first in, first out: первым пришел — первым ушел. FIFO 3 2 1 0 означает, что когда мы удаляем из очере­ ди какой-либо элемент, это всегда будет Очередь тот, что хранился дольше всех осталь­ ных, и это единственный элемент, который можно удалить. Очереди встречаются повсеместно, их название самоочевидно: оче­ редь — еще одно слово, означающее линию или ряд, и мы все время от времени стоим в очередях. При этом политика FIFO применяется и при работе со складскими запасами, когда первыми выводятся позиции с наи­ более продолжительным сроком хранения, у которых срок годности уже на исходе. Тот же принцип применяется и при работе над исправлением багов и распределении поручений на виртуальных командных панелях задач (если только среди задач нет приоритетных — в этом случае стоит прочитать следующую главу!). Когда мы применяем политику FIFO к контейнерам, это означает соз­ дание структуры данных, в которой элементы обрабатываются в порядке поступления. Операции с очередью В очередях есть ограничения на то, куда можно добавлять и откуда можно удалять элементы. Новые элементы могут появляться только в одном месте: в конце, или хвосте, очереди. А в обработку они могут поступать только с другой стороны очереди, именуемой ее началом, или головой. Поэтому мы включили в свой интерфейс только эти два метода: zzМетод вставки элемента в очередь — применительно к очередям этот метод традиционно называется enqueue(). zzМетод удаления и возвращения самого давнего элемента очереди — он традиционно называется dequeue(). Каков внутренний механизм очереди? На уровне абстрактного типа данных никаких ограничений на внутреннюю структуру очереди мы не накладыва­ ем, а просто определяем ее поведение. Поскольку элементы нужно обрабаты­ https://liveinternet.club
174  Глава 9. Очереди вать в порядке их вставки, очевидно, что этот порядок надо как-то сохранять во внутреннем состоянии очереди, но как именно — это можно определить только на уровне структур данных. Очередь Состояние: последовательность элементов + порядок их вставки enqueue(x) dequeue() На уровне АТД можно представлять себе очередь любым абстрактным об­ разом, который сочтем удобным. Даже как очередь за мороженым! Конец 2 Начало 1 enqueue( 0 3 Конец Очередь ) 2 Начало dequeue() 1 0 2 Очередь 1 0 Очередь Когда люди стоят в очереди за мороженым или в кассу, они обычно вы­ страиваются (или должны бы выстраиваться) в прямую линию. Когда кто-то встает в очередь, он идет в ее конец и становится за последним. Когда человек в начале очереди получает свое мороженое, он отходит, и тот, кто стоял за ним, занимает его место. Сама эта расстановка, когда стоят в линию, — это структура, хранящая в памяти порядок «вставки» людей в очередь. Можно также привести пример, больше подходящий для сферы computer science, — с коробками и числами. Сравните это с тем, как работают стеки, — как мы показывали в главе 8. Начало 2 -5 3 Начало enqueue(1) Очередь Конец 2 -5 3 1 Начало dequeue() Очередь output: 2 Конец -5 3 1 Начало dequeue() output: -5 Конец Очередь 3 1 Начало enqueue(7) Очередь Конец 3 1 7 Очередь https://liveinternet.club
Очередь как абстрактный тип данных  175 Есть как минимум два заметных различия: zzВ стеках достаточно хранить ссылку на вершину. В очереди понадобятся ссылки как на ее начало, так и на конец. zzСтек и наращивается, и сокращается с одной и той же стороны, тогда как очередь изменяет размеры асимметрично: элементы добавляются в ее конец и удаляются из ее начала. Очереди в действии Bug Bug Что может быть лучше, чем начать утро с обнаружения бага в качестве первой задачи из бэклога (перечня задач)? «Все что угодно», — думает Приянка, про­ сматривая бэклог. Если вы бывали в та­ кой ситуации, то поднимите руку в знак поддержки! Приянка только что приступила к работе в стартапе, который так кру­ Bu g то выглядел со стороны. Их миссия ей близка, а ИИ-технология, которую раз­ рабатывают основатели, восхитительна. Но чего она не знала, так это того, что, если не считать основную технологию, с точки зрения инфраструктуры и организации обнаружит здесь поле непаханое. Она не могла постичь: как так, у них нет даже надлежащих инструментов управления задачами, а перечень багов будет кучей стикеров на ее столе, мониторе и столике на мини-кухне. Таким образом, «просмотр» бэклога означал собирать эти записки-сти­ керы, выискивая их по всему офису, а затем пытаться разобрать почерк или выяснять, кто же их написал. Тут легче прохлопать ошибку или забыть о ней, чем исправить. Через неделю пропущенных неисправленных ошибок и горящих дедлайнов Приянка решила, что с нее хватит. Она попросила готовый продукт для отслеживания задач, но, очевидно, в бюджете эта статья расходов не предусмотрена. И она решает написать такую систему — конечно, очень простую — как свой проект на выходные. Она настраивает почтовый сервер компании и заводит специальный электронный адрес, чтобы, когда понадобится зафиксировать ошибку, мож­ но было просто отправить email. Когда письмо приходит, его подхватывает демон1 и добавляет ошибку в очередь на рассмотрение. 1 Программа, которая загружается в рабочую память и ожидает появления запроса, чтобы начать исполняться. — Примеч. ред. https://liveinternet.club
176  Глава 9. Очереди Начало Демон enqueue( ) Bug Конец Очередь Выбор очереди для упорядочивания потока ошибок был критичен — каждое утро Приянка проверяет очередь, и система выдает ей самую давнишнюю неисправленную ошибку. Если она начнет работать над этой ошибкой, то всё в порядке. Но если она считает, что ошибка может и подождать, то от­ правляет ее обратно, и та ставится в конец очереди. Начало Начало Bug Конец Начало dequeue() enqueue( Bug Bug + Конец Конец Очередь ) Очередь Очередь Очередь отслеживания ошибок позволяет ей оставаться организованной и не пропустить сообщения об ошибках. Кроме того, очередь естественным образом сохраняет ошибки в хронологическом порядке, и нет нужды до­ бавлять к ним метки времени. Это лишь один пример применения очередей в реальном приложении, но существует много других возможных способов их использования. Кроме того, очереди становятся фундаментальной частью рабочего процесса не­ которых алгоритмов. В главе 13 мы обсудим два алгоритма перебора графа: поиск в глубину и поиск в ширину. Они схожи по структуре, но чтобы ре­ шить, какая вершина должна быть следующей при переборе, один алгоритм использует стек, а другой — очередь. Невероятно, как такая мелочь может столь сильно поменять поведение алгоритма! Очередь как структура данных Разобравшись с тем, как должен выглядеть интерфейс очереди, можно сде­ лать следующий шаг и подумать над тем, как реализовать эту структуру дан­ ных. Всегда можно написать новую структуру данных с нуля, не используя https://liveinternet.club
Очередь как структура данных  177 ничего из того, что мы уже создали. Но этот вариант стоит рассматривать, только если ни одна из альтернатив, на базе которых может строиться новая структура, не работает должным образом. Таким образом, первое, что всегда нужно делать, — это определить, нельзя ли использовать какую-нибудь уже готовую структуру, взвесить ее достоинства и недостатки. С учетом того, что обсуждалось в предыдущих главах, на этот момент у нас есть следующие варианты: zzстатический массив; zzдинамический массив; zzсвязанный список; zzстек. Начнем с конца — реализовать очередь на основе стека возможно, но это неэффективно. Вместо того чтобы добавлять элементы на верхушку стека, реализуя политику LIFO, нам надо добавлять их в низ стека. Но со стеком сделать это не так просто! Так что давайте вычеркнем стеки из списка. Затем можно также вычеркнуть динамические массивы. Хотя реализация очереди на основе динамических массивов возможна и у нее даже имеются некоторые преимущества, но из-за сложности и цены производительности дело просто не стоит того. Мы еще вернемся к этой теме после обсуждения статических массивов, и тогда вы лучше поймете, почему мы исключаем динамические массивы. Это оставляет нам только две опции: связанные списки и статические массивы. В оставшейся части раздела мы подробно обсудим эти два вари­ анта. Построение очереди на базе связанного списка Очередь представляет собой структуру данных, элементы которой хранятся в том порядке, в котором они были вставлены, а все операции — то есть вставка и удаление элементов — выполняются с двух концов очереди. Звучит знакомо? Мы добавляем элементы в начало (голову) очереди и удаляем их с ее конца (хвоста). Да, эти операции мы обсуждали в связи со связанными списками. А вы помните, какой тип связанных списков был оптимизирован под удаление элементов с его хвоста? Двусвязный список идеально подходит для этого, потому что с ним можно эффективно добавлять элементы и к голове, и к хвосту, а также удалять элементы с обоих концов. Начало 4 6 3 https://liveinternet.club 7 Конец
178  Глава 9. Очереди Но если элементы добавляются только в конец связанного списка и удаля­ ются только из его начала, можно также использовать и односвязный список. Единственная оговорка — надо будет слегка подправить код, чтобы указатель на хвост списка сохранился и мог обновляться за постоянное время при вы­ полнении этих двух операций. Как бы то ни было, с двусвязными списками существующий код можно использовать без изменений, что делает это решение более чистым: пока мы не в курсе, что пора оптимизировать затраты памяти, все обещает быть в порядке. Быстрые реализации методов enqueue и dequeue можно создать практиче­ ски без каких-либо усилий. Для этого достаточно просто заново использо­ вать методы insert_to_back и delete_from_front связанных списков. Начало head 4 -1 Конец 3 4 -1 3 7 4 -1 -1 enqueue(6) Конец 3 7 1 Начало head tail 1 Начало head enqueue(1) Конец Начало head tail 7 6 tail 6 tail dequeue() Конец 3 7 1 А самое замечательное, что наша очередь может расширяться и сокращаться без каких-либо хлопот или ограничений. Можно положиться на связанный список: он возьмет на себя управление памятью и изменение размеров. Хранение данных в статическом массиве Если мы решаем реализовать очередь посредством статического массива, придется учитывать тот факт, что размер очереди будет зафиксирован при ее создании. Это достаточно жесткое ограничение по сравнению с реализацией на базе связанного списка. Так как мы не собираемся обращаться к элементам в середине очереди, то на уровне структуры данных главное преимущество массивов перед свя­ занными списками — постоянное время обращения к каждому элементу — останется не востребовано. https://liveinternet.club
Очередь как структура данных  179 Также существует другая проблема. С реализацией на базе массива на­ чало очереди остается на стороне начала массива, тогда как конец очереди приходится на конец массива. Очередь растет в сторону растущих ин­ дексов, и мы заполняем неиспользуемые ячейки за последним элементом массива. 0 Начало 2 0 1 2 0 -1 Начало Конец 3 4 5 6 7 4 5 6 7 5 Конец 1 2 3 0 -1 4 1 2 3 4 2 0 -1 4 1 0 1 2 3 4 0 -1 4 1 2 0 0 0 Начало Начало Начало enqueue(4) 6 7 dequeue() Конец 5 6 6 1 2 3 4 5 0 -1 4 1 7 1 2 3 4 5 -1 4 1 7 Начало enqueue(1) Конец 7 Конец Конец 6 enqueue(7) 7 dequeue() 7 Со стеком элементы, хранящиеся в массиве, естественным образом вырав­ ниваются по левому краю. Если стек содержит n элементов, они занимают индексы от 0 до n-1. Но с очередью при извлечении элемента остается дыра между началом массива и первым оставшимся элементом. Пока после ис­ пользуемых элементов остается достаточно неиспользуемых, все хорошо. Но что произойдет, когда конец очереди достигнет конца массива? 0 1 Начало 2 3 4 5 6 7 -1 4 1 7 -2 6 Конец enqueue(3) https://liveinternet.club
180  Глава 9. Очереди Возможны два варианта. Простой — сдаться и считать, что при дости­ жении конца массива очередь заполнена. Это называется линейной очередью (linear queque). Но это также означает, что свободная емкость очереди будет снижаться со временем, и с очередью, для которой была выделена память для n элементов, можно выполнить только n вставок. Тогда после того, как мы начнем извлекать элементы из очереди, фактическая емкость очереди будет последовательно уменьшаться. Как нетрудно догадаться, такое решение не очень-то практично. К счастью, есть и другое! Пространство, освободившееся при извлечении элементов из очереди, можно использовать повторно. Но как это сделать? И снова возможны два варианта: zzМожно переместить все элементы очереди к началу массива, чтобы в на­ чале массива не оставалось пустого места. Это означает O(n) затрат при каждом перемещении элементов, что далеко не идеально и чересчур не­ эффективно. zzМожно проявить немного фантазии с индексами и использовать весь массив без перемещения элементов. Второй вариант, называемый циклической очередью (circular queque), особен­ но интересен. Тут подразумевается, что нет ненужных затрат, — слишком хорошо, чтобы быть правдой! Но я гарантирую, что это правда. Вот что нужно сделать: представьте, что элементы массива образуют круг вместо прямой линии, так что конец масси­ ва соприкасается с его началом. Отсюда и название — циклическая очередь. 8 0 9 1 75 Конец 1 Индекс в массиве Виртуальный индекс 1 31 1 135 4 7 10 2 14 6 -1 -2 6 Начало 12 4 В примере на рисунке у нас массив с 8 элементами и их индексами от 0 до 7. Что нам сейчас нужно сделать, так это продолжить индексацию, как если бы мы могли продолжить ее по окончании массива. С круговым расположением за элементом с индексом 7 следует элемент в начале массива под индексом 0. Можно назначить ему индекс 8 как вторичный индекс поверх 0. Подобным https://liveinternet.club
Очередь как структура данных  181 же образом надписываем индекс 9 над 1, и так далее, вплоть до индекса 15, который соответствует элементу, проиндексированному как 7. Назовем эти индексы от 8 до 15 виртуальными индексами. Это описание охватывает почти все необходимые нам понятия. Оста­ лось прояснить лишь один маленький кусочек математики — модульную арифметику. Оператор деления по модулю для положительных целых чисел вычисляет остаток от деления. Например, результат 8 % 8 (то есть 8 по мо­ дулю 8, остаток от 8 при делении на 8) равен 0, потому что таков остаток от операции деления. Аналогичным образом 9 % 8 == 1, 10 % 8 == 2 и т. д. Оператор модуля — это способ узнать, какой индекс массива соответствует виртуаль­ ному индексу. В нашем примере конец очереди достиг индекса 7, так что у следующей ячейки массива, в которой нужно сохранить новый элемент, виртуальный индекс 8. Попытка обращения по индексу 8 приведет к ошибке выхода ин­ декса за границы массива. Но если вспомнить, что 8 % 8 == 0, мы поймем, что 0 — это индекс массива, соответствующий виртуальному индексу 8. Так мы можем проверить, пуста ли ячейка массива с индексом 0 или в ней уже хранится какой-либо элемент. Нам повезло: она пуста. Откуда мы знаем? На­ пример, можно проверить, где указатель на начало очереди. В данном случае он указывает на индекс 2, так что все в порядке. 8 0 1 75 1 75 6 6 -2 14 6 1 31 7 1 31 135 4 4 7 10 2 10 2 -2 Начало -1 -1 14 6 3 1 1 Начало 8 0 9 9 12 4 1 135 Конец enqueue(3) 1 Конец 12 4 Вот такой фокус! Указатель на конец очереди вышел за конец массива и снова вошел со стороны начала. Теперь он снова может расти в сторону больших индексов — по крайней мере, пока снова не столкнется с началом очереди (надо будет это проверить, чтобы избежать столкновений). Проблема вставки решена, но что произойдет, если мы выведем из оче­ реди шесть элементов и указатель на начало достигнет конца очереди? Как нетрудно догадаться, мы делаем то же самое, что и в конце очереди: исполь­ зуем виртуальные индексы и оператор модуля, чтобы указатель на начало мог зайти на следующий круг по достижении конца массива. https://liveinternet.club
182  Глава 9. Очереди 8 0 10 2 6 1 31 1 31 135 1 75 1 75 14 6 -2 10 2 135 Конец 8 -2 14 6 1 1 8 Конец 8 0 9 9 1 1 Начало dequeue() 12 4 Начало 12 4 Подробности этих операций обсудим в разделе, посвященном реализации. А пока сравним разные решения, упоминавшиеся ранее. Сравнение возможных реализаций В таблице 9.1 приведены данные асимптотического анализа времени вы­ полнения разных реализаций АТД очереди. Из него можно сделать ряд выводов. Таблица 9.1. Сравнение разных реализаций очереди enqueue() Статический массив O(1) dequeue() Динамический размер O(1) Нет Динамический массив O(n) наихудший случай O(n) наихудший случай Да O(1) амортизированный O(1) амортизированный Связанный список O(1) O(1) Да На уровне структуры данных ничто не предполагает, что нам следует отдать предпочтение реализации на базе массива, потому что реализация на базе связанного списка точно так же эффективна и к тому же гибка, позволяя очереди динамично расширяться. Будучи в среднем быстрой, реализация на базе динамических массивов не может дать нам гарантий относительно отдельных операций. Время от времени enqueue и dequeue могут выполняться медленнее, потому что нам приходится изменять размеры базового массива. Тем не менее в главе 8 мы узнали, что реальный мир порой разительно отличается от того, что гласит теория. Давайте обсудим это в следующем разделе. https://liveinternet.club
Реализация  183 УПРАЖНЕНИЕ 9.1 Как упоминалось ранее, для хранения данных очереди также возможно использовать стеки. Впрочем, одного стека не хватит. Сможете ли вы найти способ реализовать очередь на двух стеках? Подсказка: либо за­ несение, либо извлечение должны будут выполняться за O(n). Реализация Главный недостаток реализации очереди на базе статического массива за­ ключается в том, что у очереди будет фиксированный размер, и с этим раз­ мером надо будет определиться в момент создания очереди. В некоторых ситуациях фиксированный размер создает ощутимые про­ блемы. Тем не менее если нужная емкость очереди известна заранее, это перестает быть проблемой. И надо сказать, использование массивов также обладает рядом преиму­ ществ: zzЭффективность использования памяти — для хранения одних и тех же элементов массив расходует меньше памяти, чем связанный список. Дело в том, что при использовании массива помимо памяти, необходимой для хранения фактически имеющихся элементов, непроизводительные из­ держки связаны только с постоянным объемом памяти, независимо от количества элементов в массиве. zzЛокальность памяти — как упоминалось в главе 2, массив — это цель­ ный блок памяти, в котором все элементы хранятся в непосредственном соседстве. Эту особенность можно использовать для оптимизации кэши­ рования на уровне процессора. zzПроизводительность — операции с массивами обычно выполняются быстрее, чем со связанными списками. Эти три обстоятельства могут иметь серьезные последствия на практике. Какую же реализацию стоит выбрать? Если вам необходима гибкость с точки зрения размера очереди и вы цените чистый, компактный код, выбирайте связанные списки. В реали­ зации на базе связанных списков методы enqueue и dequeue представляют собой простые обертки, вызывающие методы базового связанного списка: insert_to_back для enqueue и delete_from_front для dequeue. Поэтому мы не будем подробно рассматривать здесь эту реализацию, но полный код Python доступен в репозитории книги на GitHub: https://mng.bz/ Dd5w. https://liveinternet.club
184  Глава 9. Очереди Однако если вы можете заранее определиться с максимальной емкостью очереди и выделить соответствующий объем памяти или если статический размер не является проблемой, реализация на базе массивов может предо­ ставить значительные преимущества. Код в этом случае будет сложнее, но это компенсируется повышением производительности. В оставшейся части этого раздела рассмотрим реализацию циклической очереди — той, в которой указатели на начало и конец заходят на следующий круг за конец массива. У реализации линейной очереди просто слишком много недостатков, чтобы в большинстве ситуаций можно было считать ее практичным решением. Базовый статический массив Рассмотрим подробнее реализацию на базе массива: как обычно, полный код можно найти в репозитории на GitHub: https://mng.bz/NRa1. Но еще до того, как приступать к определению класса и конструктору, не­ обходимо принять первое решение. В реализации, использующей связанные списки, начало и конец очереди легко определялись — это голова и хвост списка. При переходе к массивам нам уже не настолько везет, и приходится решать, что делать с этими указателями. Сразу подчеркну, что показанный ниже способ не единственный, но вот что мы сделаем: сохраним два индекса, front и rear: — индекс следующего элемента, извлекаемого из очереди; — наоборот, индекс следующей ячейки массива, в которую можно будет добавить новый элемент. zzfront zzrear Изначально как front, так и rear инициализируются значением 0 — индек­ сом первого элемента массива. Когда в очередь ставится новый элемент, указатель rear смещается к сле­ дующему индексу. А при извлечении элемента из очереди смещается на шаг указатель front. Все идет хорошо, пока мы не достигнем максимальной емкости. Напри­ мер, если емкость очереди составляет пять элементов и мы добавим все пять и ни одного не удалим, произойдет циклический возврат указателя rear через весь массив к ячейке с индексом 0. Таким образом, когда массив пуст и когда он заполнен, указатели front и rear будут указывать на один и тот же индекс. Как нам различить эти ситуации? Есть много возможных решений, и вот самое простое: размер очереди сохраняется в переменной, которая обновляется при каждой операции до­ бавления и извлечения. Эта переменная упростит нашу задачу и ускорит многие операции с очередью. https://liveinternet.club
Реализация  185 0 1 2 1 4 enqueue(2) enqueue(4) enqueue(-1) Начало Конец 0 3 2 3 4 0 2 4 -1 Начало 1 Конец 1 2 Начало 4 Конец enqueue(6) enqueue(-3) 3 4 0 1 2 3 4 2 4 -1 6 -3 4 -1 Начало 3 2 4 -1 dequeue() 0 2 Конец Конец Начало Итак, повторю: при инициализации очереди необходимо установить атри­ буты front и rear класса как 0, а также инициализировать размер очереди (то есть количество элементов, в данный момент хранящихся в очереди), установив его равным 0. Тем не менее это еще не все — нам надо инициали­ зировать также и базовый массив. Замечу, что в этих примерах я использую список Python, хотя на самом деле это динамический массив — просто в Python это самый удобный вариант. В нем не только можно хранить любые объекты, но еще его можно инициализировать всего в одну строку: class Queue: def __init__(self): if max_size <= 1: raise ValueError(f'Invalid size for a queue (must have at least 2 elements): {max_size}') self._data = [None] * max_size self._max_size = max_size self._front = 0 Моделируем статический массив размером max_size. self._rear = 0 Значение, которое выбираем для инициализации, self._size = 0 не важно, но чтобы дать понять, что мы считаем эти элементы пустыми, можно использовать None Однако первое, что необходимо сделать, — это сохранить емкость оче­ реди, переданную через аргумент max_size. Прежде чем принять его, не­ обходимо проверить переданное значение и убедиться, что новая очередь https://liveinternet.club
186  Глава 9. Очереди сможет вместить хотя бы два элемента. При такой настройке становится тривиальной проверка размера очереди, а также того, пуста она или за­ полнена: def __len__(self): return self._size def is_empty(self): return len(self) == 0 def is_full(self): return len(self) == self._max_size Enqueue Теперь сосредоточимся на подробностях добавления новых значений в оче­ редь. Когда мы проектируем работу этого метода, нам надо различать три возможные ситуации (предполагается, что емкость очереди составляет n элементов, n>1): и rear < n-1: front предшествует rear, а rear находится не в конце массива. zzfront <= rear, но rear == n-1: front предшествует rear, а rear указывает на последний элемент массива. zzrear < front: front и rear меняются местами после того, как rear выходит за конец и оборачивается вокруг массива. zzfront <= rear Исходная ситуация При создании очереди front и rear инициализируются значением 0. С этого момента rear может увеличиваться только до достижения конца массива. А front также может увеличиваться при извлечении из очереди, но не может зайти за rear. 0 1 2 3 4 -1 Начало 0 4 enqueue(5) Конец 1 2 3 4 4 -1 5 Начало Конец Это самая простая ситуация, в которой нам не нужно беспокоиться о вир­ туальных индексах и о том, что указатель rear оборачивается с выходом за конец массива. Пока rear не достигнет конца массива, очередь не может даже заполниться, так что все, что нам нужно сделать, это сохранить новое значение и увеличить rear. https://liveinternet.club
Реализация  187 Циклический оборот вокруг массива Теперь самое интересное: rear указывает на по­ Виртуальные индексы следний элемент массива. Что ж, можно начать 5 6 7 8 9 с присваивания нового значения пустой ячейке, 0 1 2 3 4 на которую указывает rear, — здесь ничего не ме­ 4 -1 5 няется. В этот момент в нашем примере — rear=4. Если просто увеличить его, он будет указывать на Начало Конец индекс 5, который выходит за границы нашего мас­ сива. Пора вспомнить о виртуальных индексах, рассматриваемых в разделе «Очередь как структура данных». Мы можем расширить обычное пространство индексации массива с по­ мощью этих виртуальных индексов, представив, что индексы, превышающие физический индекс последнего элемента, будут огибать массив, как если бы они были расположены по кругу. Таким образом, индекс 5 будет указывать на ту же самую ячейку массива, что и индекс 0, а указатель rear обернется вокруг массива. 0 1 2 3 4 -1 5 Начало 0 4 Конец 1 2 3 4 4 -1 5 2 enqueue(2) Конец Начало Front и rear меняются местами К этому моменту очередь еще не заполнена (так как front в нашем примере указывает на индекс 1, в какой-то момент должен был быть вызван метод dequeue), но rear указывает на более низкий индекс, чем front. Фактически два указателя поменялись местами. Когда мы ставим в очередь новый элемент, можно просто увеличить rear, как в первом случае. Но вместо проверки на достижение конца массива гра­ ницей для указателя rear становится указатель front. 0 1 2 3 4 -1 5 2 Конец Начало 0 4 enqueue(9) 1 2 3 4 9 4 -1 5 2 Конец Начало В нашем примере после постановки в очередь значения 9 и rear, и front — оба указывают на элемент с индексом 1, а значит, rear не может больше про­ двинуться: очередь заполнена. https://liveinternet.club
188  Глава 9. Очереди Как упоминалось в начале раздела, посвященного реализации, есть не­ сколько способов воплотить в коде проверки и рост указателя, но если для запоминания размера очереди использовать вспомогательную переменную, это невероятно облегчит нам жизнь. Чтобы определить, пуста ли очередь, заполнена или частично заполнена, нам не нужно проверять, где находятся rear и front или в какой из этих трех ситуаций мы оказались. Вместо этого мы просто проверяем, сколько сохранено элементов. ПРИМЕЧАНИЕ Проверку структуры данных на то, каков ее размер, пуста она или заполнена, делегируйте вспомогательным методам, которые можно много­ кратно применять в коде. Код получится более чистым, в нем будет меньше дублирований и, следовательно, снизится риск ошибок. Чтобы увеличить rear, можно также обработать эти три случая по отдель­ ности с помощью условных команд, но я предпочитаю более чистый способ, хотя, возможно, и менее эффективный: чтобы сопоставить виртуальные и физические индексы массива, можно использовать оператор модуля, как объяснялось в разделе «Очередь как структура данных». При таких допущениях код для enqueue предельно упрощается: def enqueue(self, value): if self.is_full(): raise ValueError('The queue is already full!') self._data[self._rear] = value self._rear = (self._rear + 1) % self._max_size self._size += 1 Dequeue Мы уже знаем, как добавлять элементы в очередь. Теперь давайте посмотрим, как удалить элемент. Точно так же, как и с enqueue, конструируя метод dequeue, нам надо учесть несколько моментов: предшествует rear; и rear указывают на один и тот же индекс; zzfront и rear меняются местами после того, как rear вышел за конец мас­ сива, но front находится не в конце массива; zzfront и rear меняются местами, и front указывает на последний элемент массива. zzfront zzfront Если front указывает на меньший индекс, чем rear, для извлечения элемента из очереди можно просто увеличить front. https://liveinternet.club
Реализация  189 0 1 2 3 4 -1 5 Начало 0 4 1 2 3 4 -1 5 dequeue() Конец Начало Конец Если front и rear указывают на один и тот же индекс, очередь может быть либо пустой, либо заполненной, и понять, какой из двух вариантов верный, можно только путем проверки атрибута size. Пустая очередь: size==0 0 1 2 3 4 dequeue() Начало Ошибка Конец Заполненная очередь: size==5 0 1 2 3 2 4 -1 5 3 Конец 0 4 dequeue() 1 2 4 5 3 2 4 Начало 3 Конец Начало Последние два случая обрабатываются так же, как c rear при добавлении но­ вого значения. Мы увеличиваем индекс, на который указывает front, и если front находится прямо в конце массива, проводим трюк с виртуальным индексом и оператором модуля, так что front может сделать циклический обход, выйдя за конец массива. Обратите внимание: когда front совершает циклический обход с выходом за конец массива, мы возвращаемся к исходной конфигурации, в которой front и rear еще не поменялись местами (то есть front <= rear). 0 1 2 3 5 3 2 Конец 0 1 0 4 dequeue() 3 Конец 3 0 4 Начало dequeue() 1 Начало 2 2 7 Начало https://liveinternet.club 4 3 Конец 3 2 7 2 2 Начало 2 1 Конец 3 4
190  Глава 9. Очереди Как и при добавлении элемента в очередь, вместо того чтобы рассматри­ вать каждый случай по отдельности, можно воспользоваться оператором модуля и написать более чистый код, который не будет разбираться с каж­ дым случаем отдельно: def dequeue(self): if self.is_empty(): raise ValueError("Cannot dequeue from an empty queue") value = self._data[self._front] self._front = (self._front + 1) % self._max_size self._size -= 1 return value Как и в случае со стеками, также можно определить метод peek, который возвращает элемент, расположенный в начале очереди, не удаляя его. Но как мы видели в предыдущей главе, этот метод привносит много избыточной сложности, поэтому я не стал бы включать его в интерфейс очереди без крайней необходимости. УПРАЖНЕНИЯ 9.2 Реализуйте метод peek для класса Queue. Какие основные проблемы надо учитывать и как их решать? Подсказка: посмотрите, что мы делали, ра­ ботая со стеками. 9.3 Реализуйте итератор для очереди. Подсказка: когда очередь или стек используются в циклах for, элементы предъявляются в правильном по­ рядке, но при этом они еще и удаляются из контейнера. Как насчет динамических массивов? Ранее в этой главе я упоминал, что динамические массивы редко использу­ ются для создания очередей. Тем не менее стоит обсудить, как работает такое решение. Оно помогает лучше понять, как работают циклические очереди. И хотя это маловероятно, в некоторых ситуациях динамические массивы могут действительно оказаться лучшим вариантом. Если основа очереди — статический массив, то даже с применением виртуальных индексов и циклической конфигурации для повторного ис­ пользования ячеек массива, освобождаемых при извлечении элементов, в какой-то момент очередь может заполниться. Это происходит, когда ука­ затель на конец очереди достигает (виртуального) индекса, непосредственно предшествующего началу очереди. При попытке вставить еще один элемент выдается ошибка, потому что очередь заполнена — указатель на конец оче­ реди выходит за указатель на ее начало. https://liveinternet.club
Как насчет динамических массивов?  191 С динамическим массивом мы в этот момент, наоборот, удвоили бы ем­ кость базового массива: когда мы пытаемся добавить элемент в заполненную очередь, можно просто выделить в памяти новый массив. Проблема в том, что размер нового массива будет 16, тогда как старого — 8. Вроде бы небольшая проблема, но все виртуальные индексы, рас­ считанные для старого массива, перемешаются, и мы не сможем исполь­ зовать модуль 8 для вычисления индексов массива. И нам понадобится модуль 16. Что еще хуже, если скопировать массив как он есть в новый массив, возникает серьезная проблема: указатели на конец и начало теряют смысл, и посреди очереди возникает большая дыра. Однако при попытке добавить в очередь новый элемент указатель на конец очереди все еще будет оста­ ваться прямо перед указателем на ее начало! Из-за этого, в зависимости от того, как мы реализовали is_full, метод может ошибочно решить, что очередь заполнена. Или, в нашей реализации, очередь перезапишет свои элементы и все равно остановится прежде, чем заполнит дополнительное пространство. Конец 0 1 Начало 2 42 -3 -1 Конец 0 1 3 4 5 6 7 4 1 7 -2 6 Начало 2 42 -3 -1 Удвоение размера массива 3 4 5 6 7 4 1 7 -2 6 8 9 10 11 12 13 14 15 У проблемы есть обходное решение. При копировании элементов в новый массив необходимо выровнять начало очереди по индексу 0, как показано на следующем изображении. Конец 0 1 Начало 2 42 -3 -1 0 Начало -1 3 4 5 6 7 4 1 7 -2 6 Удвоение размера массива 1 2 3 4 4 1 7 -2 5 6 7 Конец 8 9 6 42 -3 https://liveinternet.club 10 11 12 13 14 15
192  Глава 9. Очереди Это будет ничуть не медленнее копирования элементов в те же позиции, что они и занимали, и не повлияет на асимптотический анализ. Но код при этом усложняется. Добавьте к этому то, что с динамическими массивами время выполнения insert и delete в наихудшем случае составит O(n), это в свою очередь означает, что enqueue и dequeue в наихудшем случае также будут выполняться за линейное время. В общем и целом, если вам пона­ добится гибкая очередь с изменяемым размером, чаще всего лучше будет выбрать реализацию на базе связанного списка. Другие применения очередей В начале этой главы рассматривались как ситуации из обычной жизни, ра­ ботающие по принципу FIFO, так и программные приложения — например, система управления задачами, которая в своей простейшей форме исполь­ зует очередь. Есть множество других областей, в которых очереди применя­ ются как составная часть больших систем, а также множество алгоритмов, работа которых основана на очередях. Например, в главе 13 мы поговорим о поиске в ширину — алгоритме обхода графа с определением минимального расстояния (измеряемого количеством ребер) между одной вершиной графа и любой другой или всеми остальными. Давайте обсудим еще несколько примеров применения очередей в computer science. Системы передачи сообщений При построении больших приложений — и особенно больших вебприложений — темп поступления запросов иногда становится слишком высоким и непредсказуемым для обработки в нормальном режиме. Когда для запрашивающего допустимо получить отложенный, то есть не в реальном времени, ответ, темп работы веб-сервиса можно регулировать при помощи так называемой стратегии вытягивания (pull strategy) и очереди. Как правило, веб-сервис настраивается на стратегию принудительной отправки (push strategy): когда у другого сервиса или клиента появляется запрос, он напрямую связывается с сервисом, который этот запрос обраба­ тывает. Однако всплеск запросов может превысить пропускную способность сервера. Допустим, ваш сервис способен обработать не более 100 запросов в минуту. Если случится всплеск в 200 запросов за несколько секунд, сервис перегрузится и некоторые — в данном случае многие — запросы потеряются и не дождутся ответа, а сервис может даже упасть. С системой передачи сообщений все наоборот — запросы передаются сервису с высокой пропускной способностью, который просто ставит их в очередь в виде сообщений в буфере типа Kafka. Затем сервис читает (вытя- https://liveinternet.club
Итоги  193 гивает) сообщения из очереди в своем темпе и в том же порядке, в котором они были отправлены. Если буфер не поддерживает учет приоритетности сообщений, то ис­ пользуемая структура данных — это как раз та простая очередь, которую мы рассматриваем в этой главе. Если приоритетность учитывается, используется другая разновидность очереди, которую обсудим в следующей главе. Веб-серверы Веб-серверы могут использовать похожую стратегию для отслеживания за­ просов, полученных от клиентов. В таком случае буфера сервиса передачи сообщений может и не быть, а веб-сервер может просто использовать оче­ редь для хранения входящих запросов перед обработкой их в своем темпе. Операционные системы Когда нужно спланировать загрузку процессора для активных процессов, или использование диска, или вывод данных на печать, операционная си­ стема (ОС) может использовать очередь для циклического переключения процессов, которым нужен доступ к одному и тому же ресурсу. Совре­ менные операционные системы поддерживают концепцию приоритета процессов, чтобы такие ресурсы, как процессор или диск, занимались в первую очередь и чаще всего высокоприоритетными процессами. Однако вывод на печать, скорее всего, будет обрабатываться более справедливо, в соответствии с политикой FIFO, так что в вашей ОС можно найти оче­ редь принтера. Итоги zzОчередь — это контейнер, работающий по принципу FIFO, то есть первый элемент в очереди становится первым извлекаемым элементом. Ее можно сравнить с очередью к кассе в магазине: покупатели становятся в конец очереди, а первым обслуживается покупатель в начале. zzОчереди широко применяются в computer science и программировании, в том числе в системах передачи сообщений, сетевых коммуникациях, веб-серверах и операционных системах. Кроме того, многие алгоритмы используют очереди, чтобы отслеживать порядок обработки элементов, — например, алгоритм поиска в ширину. zzОчереди поддерживают две операции: enqueue для добавления элемента в конец очереди и dequeue для удаления и возвращения элемента из начала очереди. Как и в случае со стеками, здесь нет другого способа вставлять или удалять элементы, а поиск обычно не поддерживается. https://liveinternet.club
194  Глава 9. Очереди zzОчередь можно реализовать на базе массивов или связанных списков, используемых для хранения ее элементов. zzСо связанными списками enqueue и dequeue выполняются за время O(1) в наихудшем случае. zzСо статическими массивами можно реализовать линейную очередь, кото­ рая поддержит только фиксированное количество операций добавления. Также можно реализовать циклическую очередь, в которой массив рас­ сматривается как циклический контейнер. Это требует дополнительных усложнений. zzХотя можно использовать и динамические массивы, реализации такого типа довольно сложны и не очень распространены. https://liveinternet.club
Приоритетные очереди и кучи. Обработка данных с учетом уровня приоритетности элементов 10 В этой главе 9 9 9 9 9 9 9 9 9 9 9 9 Знакомство с приоритетной очередью как абстрактным типом данных Различия между очередями и приоритетными очередями Реализации приоритетной очереди на базе массивов и связанных списков Знакомство с кучей — структурой данных АТД приоритетной очереди Почему кучи реализуются как массивы, а не как деревья Эффективное построение кучи на базе существующего массива В главе 9 рассматривались очереди — контейнеры, которые хранят данные и возвращают их в том же порядке, в котором те были добавлены. Эта идея расширяется введением концепции приоритета, что приводит нас к при­ оритетным очередям и их самой распространенной реализации — куче. В этой главе мы обсудим и то и другое вместе с несколькими примерами их применения. https://liveinternet.club
196  Глава 10. Приоритетные очереди и кучи Расширение очередей введением приоритета В предыдущей главе мы рассмотрели несколько примеров очередей в ре­ альной жизни — таких, как очередь за мороженым. Однако не все очереди разворачиваются так прямолинейно. Например, в неотложке следующим на прием необязательно попадает тот, кто дольше всех ждет, а скорее тот, кому нужна экстренная помощь. И очередность динамична, а не высечена в камне. В этом разделе мы вводим понятие приоритета и выводим из него вариант простой очереди, именуемой приоритетной очередью. Работа над ошибками (обновленная версия) Помните Приянку, нашего инженера-программиста, которая разгребает ошибки юного стартапа? Мы встречались с ней в главе 9. Она перестроила работу над исправлением ошибок так, чтобы больше ни одна не затерялась. Новая система работает хорошо — настолько, что Приянка уже перегру­ жена работой. Чтобы справиться, она решает привлечь небольшую команду, специализирующуюся на поиске и исправлении ошибок. Впрочем, одного этого недостаточно. В протоколе, который она разработала, есть один этап, из-за которого она теряет слишком много времени. Если помните, процесс был по большей части автоматизирован: инже­ неры отправляли сообщения с описанием обнаруженных ошибок, демон извлекал эти описания из сообщений и ставил их в очередь. Однако на этой стадии Приянке приходилось оценивать ошибки и решать, насколько сроч­ но требуется их исправление. Если ошибка была неотложной, к работе над ней приступали немедленно. В противном случае ее снова ставили в конец очереди. Демон Начало Начало enqueue( ) Bug Начало НО? С Р ОЧ enqueue( dequeue() Bug Конец ) Bug + Конец Очередь Очередь https://liveinternet.club Очередь
Расширение очередей введением приоритета  197 Проверка ошибок на срочность (urgency) отнимает у Приянки слишком много времени. Чтобы оценить срочность, ей часто приходится связываться с инженером, который зарегистрировал ошибку, или с командой, ответствен­ ной за область, к которой относится ошибка, чтобы лучше понять контекст и последствия ошибки. На подобную обработку каждой ошибки уходит слишком много времени. Также требуется участие других инженеров, причем эти усилия могут ока­ заться напрасны, если все согласятся, что ошибка не требует немедленного исправления. И тогда, чтобы переломить ситуацию, у Приянки появляется идея: а что, если человек, заполняющий описание ошибки, сам определит степень ее срочности? Возможно, ей и ее команде придется пообщаться с ответствен­ ными за поврежденный код, чтобы исправить ошибку, но это понадобится только после того, как кто-то определит, что ошибка срочная и исправить ее нужно безотлагательно. Это нововведение потребует изменения в очереди. Теперь, когда Приянка запросит следующую ошибку, система возвращает не самую старую, а самую срочную ошибку. Чтобы обеспечить определенную гибкость, Приянка создает систему с че­ тырьмя уровнями срочности: желательный, необходимый, срочный и критиче­ ский. К «критическим» относятся ошибки, которые нужно было исправить еще вчера, потому что они отражаются на конечных пользователях. «Желательные» исправления, находящиеся на другом конце шкалы, могут подождать. Обычно они относятся к «техническому долгу» — это улучшения, которые желательно внести, хотя они и не сказываются на конечных пользователях. Чтобы ошибки обрабатывались исходя из степени их приоритетности, обычной очереди недостаточно. Вместо нее следует использовать приори­ тетную очередь. Cрочная Начало Cрочная Начало Критическая Начало Критическая Начало Cрочная Необходимая dequeue() Необходимая Bug Cрочная enqueue Желательная (Критическая) enqueue Необходимая (Необходимая) Bug Желательная Приоритетная очередь Приоритетная очередь + Cрочная Необходимая Bug Bug Желательная Необходимая Приоритетная очередь Желательная https://liveinternet.club Приоритетная очередь
198  Глава 10. Приоритетные очереди и кучи Как видно из рисунка, в приоритетной очереди не нужно отслеживать ее конец. Дело в том, что когда новый элемент попадает в очередь, он не ставится на последнее место: его позиция определяется степенью его при­ оритетности. Абстрактный тип данных приоритетной очереди Как и с простыми очередями, есть два важных метода, которые необходимо включить в интерфейс приоритетной очереди: один добавляет новый эле­ мент в очередь, а другой получает элемент с наивысшей степенью приори­ тетности. Для обозначения этих методов принята отдельная терминология. Тот, что добавляет новые элементы, — просто insert. Что касается метода извлечения и удаления элемента с наибольшим приоритетом — здесь мень­ ше единодушия. Он называется то pull_highest_priority_element (может сокращаться до pull), то extract_max, то просто top. Я выбираю последний вариант — из личных предпочтений и ради краткости. Приоритетная очередь Состояние: insert(x,p) последователь- top() ность элементов + степень приоритетности каждого из них Договор приоритетной очереди с клиентами заключается в том, что очередь всегда возвращает самый приоритетный элемент. Как именно это делается, не должно определяться на уровне абстрактного типа данных (АТД): как обычно, мы обсудим это при переходе на уровень структуры данных. УПРАЖНЕНИЯ 10.1 Приоритетные очереди основаны на понятии приоритета. При этом они все еще остаются очередями. С какой расстановкой приоритетов приоритетная очередь будет вести себя как простая очередь? 10.2 Какая конфигурация приоритетов заставит приоритетную очередь вести себя как стек? https://liveinternet.club
Приоритетные очереди как структуры данных  199 Приоритетные очереди как структуры данных Как хранить данные приоритетной очереди? У нас два варианта: можно хранить элементы отсортированными по уровням приоритетности или же каждый раз, когда нужно вернуть элемент с наивысшим приоритетом, его искать. Давайте начнем с первого варианта. В этом разделе рассмотрим примеры с целыми числами, когда бˆольшие числа получают более высокий приоритет. Отсортированные связанные списки и отсортированные массивы Если элементы хранятся отсортированными по степени приоритетности, это упрощает метод top. Собственно, в реализации этого метода достаточно вернуть элемент из начала очереди. С другой стороны, методу insert при­ ходится иметь дело с новыми элементами, добавлять их к уже имеющимся и следить за порядком расположения. Для реализации такого поведения хо­ рошо подходят две структуры данных: отсортированные связанные списки и отсортированные массивы. Для связанных списков достаточно односвязного варианта, потому что мы можем просто удалять элементы из головы списка, а вставка все равно за­ нимает линейное время. Элементы хранятся отсортированными от высшего приоритета (голова) до низшего (хвост), и когда мы добавляем элемент, нам надо просканировать список, пока не найдем в нем подходящее место для нового элемента, как мы обсуждали это в главе 6. Однако удаление элемента из начала списка — это операция, занимающая постоянное время, как вы уже должны знать. Приоритетная очередь голова 9 7 4 3 6 4 4 3 insert(6) 6 Приоритетная очередь голова 9 7 Приоритетная очередь голова 7 6 https://liveinternet.club 3 top()
200  Глава 10. Приоритетные очереди и кучи С массивами можно использовать похожую стратегию, но придется по­ размыслить, как сделать так, чтобы время выполнения метода top было как можно меньше. У нас есть две опции, чтобы упорядочить элементы: можно отсортировать их по убыванию степени приоритетности или по ее возрас­ танию. В первом случае, чтобы удалить элемент с наивысшим приоритетом, придется передвинуть все остальные элементы массива. Таким образом, будет правильно хранить элементы с наивысшим приоритетом в конце массива. Приоритетная очередь insert(6) 3 4 7 9 ? ? ? ? 0 1 2 3 4 5 6 7 Приоритетная очередь top() 3 4 6 7 9 ? ? ? 0 1 2 3 4 5 6 7 Приоритетная очередь 3 4 6 7 ? ? ? ? 0 1 2 3 4 5 6 7 Несортированные связанные списки и несортированные массивы Противоположный вариант — использовать несортированные версии упо­ мянутых выше структур данных. Вставка упрощается и выполняется в обоих случаях за постоянное время, потому что можно просто добавить новый эле­ мент туда, куда удобно, то есть в начало связанного списка и в конец массива. И наоборот, извлечение элемента с наивысшим приоритетом усложняет­ ся, потому что нет доступной информации об элементах. Не остается другого выбора, кроме как перебрать весь список, элемент за элементом, в поисках наивысшего приоритета. Я точно знаю, где искать. Отсортированный массив 3 4 6 7 9 0 1 2 3 4 И с чего мне начать? top() Несортированный массив 6 3 9 4 7 0 1 https://liveinternet.club 2 3 4
Приоритетные очереди как структуры данных  201 Обзор производительности В таблице 10.1 подводится итог того, что мы узнали к настоящему моменту. Таблица 10.1. Исходное сравнение различных реализаций приоритетной очереди insert() top() Динамический размер Отсортированный статический массив O(n) O(1) Нет Несортированный статический массив O(1) O(n) Нет Отсортированный связанный список O(n) O(1) Да Несортированный связанный список O(1) O(n) Да Связанные списки и массивы ведут себя схожим образом. Если они отсор­ тированы, то вставка происходит медленно, а извлечение элемента методом top — быстро. Для несортированных версий справедливо обратное. Это две крайности — полностью отсортированные и полностью несор­ тированные последовательности c диаметрально противоположным друг другу поведением. Было бы здорово, если бы существовало промежуточное решение, позволяющее выполнять обе операции быстрее, чем O(n). Частичное упорядочение Для отсортированного массива A при выборе двух индексов, i и j, где i < j, мы сразу узнаём, что A[i] ≤ A[j]. Дело в том, что отсортированный массив полностью упорядочен, и мы сразу определяем, как два произвольных эле­ мента соотносятся друг с другом, исходя из их позиций. Мы знаем, где найти наибольший элемент в массиве, и если мы его удалим, то также знаем, какой элемент займет его место. Что касается противоположной крайности — не­ сортированных массивов, — тут у нас нет вообще никакой информации. Чем больше информации, тем дороже обходится построение и поддерж­ ка структуры данных. То есть, банально, чтобы полностью отсортировать массив, нужно сравнить друг с другом больше элементов. Ключом к повыше­ нию производительности становится распределение нагрузки между этими двумя операциями и поддержание баланса между минимумом необходимой информации и максимальным количеством элементов, доступных за одну операцию. Этот баланс достигается за счет того, что упорядочиваем только часть элементов. Эта идея исходит из соображения, что, как упоминалось ранее, нам не нужно всегда знать точный порядок возвращения элементов — достаточно лишь знать, какой элемент будет следующим. https://liveinternet.club
202  Глава 10. Приоритетные очереди и кучи Куча В предыдущих главах при обсуждении структур данных для реализации АТД я часто упоминал о том, что всегда можно спроектировать новую структуру данных с нуля, но обычно это не лучшее решение. Пора обсудить исключение из правила. Лучшей структурой данных для реализации приоритетной очереди ока­ зывается не массив и не связанный список. Для этой задачи не удастся ис­ пользовать ни стек, ни очередь. Вместо этого мы вводим новый тип структур данных, с которым еще не встречались. В оставшейся части этого раздела рассматривается куча (heap) и то, как ее можно применить, чтобы реализовать приоритетную очередь. Специальное дерево Куча — это особая разновидность дерева (tree). Если вы не знакомы с дре­ вовидными структурами данных, не беспокойтесь. Я объясню все, что нам здесь понадобится, но вы также можете заглянуть в главу 11 за более под­ робной информацией. В этой главе мы ограничимся двоичными кучами (binary heaps), это озна­ чает, что мы будем использовать двоичные деревья. Собственно, свойство № 1 двоичной кучи состоит в том, что каждый узел дерева может иметь не более двух дочерних узлов. Это не строго обязательно для куч — они могут быть троичными (ternary) деревьями, четверичными (quaternary) и т. д. Однако двоичные кучи самые простые, и в большинстве случаев их достаточно для удовлет­ ворения наших потребностей. Если захотите больше узнать о d-way-кучах, узлы которых имеют больше двух дочерних узлов, то можно прочитать под­ робное описание в главе 2 книги «Advanced Algorithms and Data Structures»1. Уровень 0 M B C D A Q 1 Уровень 1 Z F Уровень 2 Уровень 3 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
Приоритетные очереди как структуры данных  203 Возможно, вы заметили, что узлы дерева упорядочены по уровням. В сле­ дующем примере корень дерева — узел, помеченный буквой M, является единственным узлом уровня 0. На уровне 1 находятся два его дочерних узла, они помечены буквами B и Z, и т. д. Однако куча — не просто двоичное дерево. Чтобы дерево могло считаться двоичной кучей, оно должно обладать еще двумя свойствами. Свойство № 2 — структурное. Дерево кучи «почти полное», это озна­ чает, что каждый уровень дерева — кроме, возможно, последнего — за­ полнен; более того, узлы последнего уровня сдвинуты влево, насколько это возможно. Полный M B C Z E D Почти полный Неполный M M B C F Z D E B F Z C A Q E Q Наконец, свойство № 3 относится к данным в куче. В куче каждый узел со­ держит элемент с наивысшим приоритетом в поддереве с корнем в данном узле. Куча 9 7 4 7 5 5 3 Не куча Куча 9 1 1 9 7 3 4 2 5 1 6 Последнее свойство гарантирует, что наиболее приоритетный элемент всегда находится в корне кучи. Конечно, проблема теперь в том, что нужно восста­ навливать эти свойства, когда в кучу добавляется новый элемент или когда из нее извлекается корень. Как это делается, объясним в следующем разделе, когда будем обсуждать уровень реализации куч. Некоторые свойства куч Из фундаментальных свойств куч следуют некоторые другие, очень интерес­ ные. Из свойства 3 можно сделать вывод, что все пути от корня до любого листа дерева отсортированы. https://liveinternet.club
204  Глава 10. Приоритетные очереди и кучи Корень 9 7 6 9→7→6→3 9→7→6→4 9→7→5 9→8→1 9→8→2 8 1 5 3 4 2 Листья Кстати говоря, это и есть один из вариантов частичной сортировки масси­ ва — тот самый компромисс, о котором шла речь в предыдущем подразделе: у нас нет всей информации о том, как соотносятся друг с другом все пары элементов, но какая-то информация все же имеется. Из свойств 1 и 2 также можно вывести некоторые интересные структур­ ные свойства. Прежде всего мы точно знаем, сколько узлов будет на каждом уровне. Уровень 0 9 7 6 3 4 Уровень 1 8 5 1 2 Уровень 2 Уровень 3 1 узел 2 узла 4 узла ≤ 8 узлов На первом уровне, уровне 0, может находиться только корень. Так как у каж­ дого узла может быть не более двух дочерних узлов, на уровне 1, с дочерними узлами корня, их может быть самое большее два. Пошагово продвигаясь дальше, можно утверждать, что на следующем уровне будет четыре узла, а если обобщить, то на каждом уровне i может быть самое большее 2i узлов (а точнее, ровно 2i узлов, если только это не последний уровень). Индекс i уровней — это их высота, то есть расстояние от корня. Из всех этих свойств делаем вывод, что высота, то есть протяженность самого длинного пути от корня до листа, кучи с n элементами составит всего log(n). Не беспокойтесь, я не буду углубляться в математику. Оставляю ее вам в качестве самостоятельного упражнения! https://liveinternet.club
Приоритетные очереди как структуры данных  205 Производительность кучи Причина, по которой так важно ограничить высоту кучи, в том, что опера­ ции insert и top можно реализовать так, чтобы они проходили только от корня до листа и, соответственно, обратно. В свою очередь, это означает, что время выполнения этих операций пропорционально высоте кучи. Таким образом, если insert и top в куче выполняются за время, пропор­ циональное ее высоте (а это действительно так — я покажу в следующем разделе), таблицу 10.1 можно обновить до таблицы 10.2, которая демонстри­ рует, что кучи обеспечивают более сбалансированную производительность операций в приоритетных очередях. Таблица 10.2. Обновленное сравнение различных реализаций приоритетной очереди insert() top() Отсортированный массив/связанный список O(n) O(1) Несортированный массив/связанный список O(1) O(n) Куча O(log(n)) O(log(n)) Невозрастающие и неубывающие кучи Прежде чем мы перейдем к реализации кучи, я хотел бы прояснить один момент. Кучи, которые мы приводили в качестве примеров, часто называ­ ют невозрастающими. Невозрастающая Неубывающая Невозрастающая куча (max-heap) — та, в которой значе­ пирамида пирамида ние каждого родительского элемента не 9 1 меньше дочернего. Следовательно, ко­ рень невозрастающей кучи содержит ее 7 5 3 5 наибольший элемент. 9 1 7 3 4 А что, если нам надо, чтобы в корне 4 находились наименьшие элементы, по­ скольку мы хотим извлечь следующий наименьший элемент последователь­ ности? В этом случае часто можно встретить применение неубывающей кучи (min-heap), у которой значение каждого родительского элемента не превы­ шает значения своих дочерних элементов. Эксплуатация идеи неубывающей кучи может сбивать с толку и усложнять реализацию кучи, поскольку здесь инвертированы соотношения родитель­ ского узла с дочерними и параметры необходимых проверок свойств кучи. Я считаю, что правильная работа с кучами связана с концепцией приоритета. В куче самый приоритетный элемент всегда в корне, и в отношении каждой пары родитель — потомок мы гарантируем, что priority(P) ≥ priority(C). Тог­ да, например, если мы хотим поместить на вершину кучи наименьшее число, то можем определить приоритет числа x как -x — в противоположность x. https://liveinternet.club
206  Глава 10. Приоритетные очереди и кучи Для этого, чтобы выявить степень приоритетности элемента, потребуется определить и применить функцию, но так устраняются все двусмысленности и появляется больше гибкости. УПРАЖНЕНИЕ 10.3 А вы сможете доказать, что высота кучи с количеством элементов n составляет log(n)? Подсказка: помните, что куча представляет собой почти полное дерево. Реализация кучи Теперь, когда мы убедились, что для приоритетных очередей нам понадо­ бится новая структура данных, пришло время посмотреть, как ее реализо­ вать. Я отложил также обсуждение основных операций с кучами, которые обычно относятся к уровню структуры данных. На то есть причина: эти операции сильно зависят от того, как мы реализуем кучу, и тут есть сю­ жетный поворот, который нам нужно раскрыть прежде, чем мы сможем говорить о реализации. Но чтобы все объяснить, нужно следовать опре­ деленному порядку. Как хранить кучу Конечно, кучу можно хранить как дерево — по аналогии с тем, как мы по­ ступаем со связанными списками, об этом еще поговорим в главе 11. Тем не менее обычно так не делается, потому что есть способ получше. Чтобы объ­ яснить почему, нам нужно вернуться ко второму свойству кучи. Поскольку куча — это почти полное двоичное дерево, мы точно знаем, сколько узлов на каждом уровне. Давайте попробуем присвоить каждому узлу пошаговый индекс, начиная с 0 для корня и проходя по всему дереву сверху донизу и слева направо. 0 1 3 7 2 7 6 4 3 4 8 Уровень 0 9 5 5 1 Уровень 1 8 6 2 https://liveinternet.club Уровень 2 Уровень 3
Реализация кучи  207 Из этого рисунка можно сделать несколько выводов. Например, возь­ мем узел под индексом 3. Мы знаем, что индекс его родительского узла 1, а индексы дочерних — 7 и 8. Так же и с узлом под индексом 2: индекс его родительского узла 0, а индексы его дочерних узлов — 5 и 6. Можно вывести правило: индекс родительского узла с индексом i > 0 равен целочисленному делению (i - 1) / 2, а индексы его дочерних узлов 2 * i + 1 и 2 * i + 2. Все это интересно, но что делать с этой информацией? И еще одно соображение, которое поможет с решением: мы присвоили индексы таким образом, что все узлы уровня 1 предшествуют узлам уров­ ня 2, которые в свою очередь предшествуют узлам уровня 3 и т. д. Почти все дерево выравнивается по левому краю, а это означает, что в нашем индек­ сировании нет «дыр», и даже на последнем уровне мы точно знаем, где на дереве узел с индексом 8. Эта идея уже была представлена, когда мы говорили о массивах: элементы статического массива (обычно) выравниваются по левому краю без пропу­ сков между первым и последним элементом. В самом деле, между деревьями и массивами есть определенная параллель. Если мы выстроим элементы дерева в ряд, расположив уровни один к одному, то индексы, которые мы присвоим узлам, будут идеально соот­ ветствовать индексам массива с теми же элементами. Уровень 0 0 1 9 Уровень 1 2 7 Уровень 0 Уровень 1 8 Уровень 2 3 6 4 3 4 8 5 5 1 6 2 Уровень 3 Уровень 2 9 7 8 6 5 1 2 3 4 0 1 2 3 4 5 6 7 8 Уровень 3 7 И вот он, сюжетный поворот: в итоге для хранения данных кучи мы ис­ пользуем массив! Да, это специальный массив с некоторыми ограничениями и полезными свойствами, и все же это массив. В оставшейся части раздела мы будем исходить из того, что в массиве достаточно места для хране­ ния элементов, которые мы добавляем, поэтому будем рассматривать его как статический массив, предназначенный для анализа операций с кучей. К этому моменту вы уже знаете, что если вместо статического понадобится динамический массив, то ограничения времени выполнения надо будет рас­ сматривать не как наихудший случай, а скорее как амортизированное время. https://liveinternet.club
208  Глава 10. Приоритетные очереди и кучи Конструктор, приоритеты и вспомогательные методы С учетом того, что вы узнали в предыдущем подразделе, можете представить, что мы определим класс Heap, использующий массив (список Python) как внутренний атрибут. Как упоминалось ранее, я предпочитаю передавать функцию для выявления уровня приоритетности элементов, чтобы наи­ высший приоритет гарантированно всегда оказывался на вершине кучи: class Heap: def __init__(self, elements=None, element_priority=lambda x: x): self._priority = element_priority if elements is not None and len(elements) > 0: self._heapify(elements) else: self._elements = [] Я также настоятельно рекомендую всегда разрабатывать вспомогательные методы, которые возьмут на себя детали сравнения степени приоритетно­ сти двух элементов, а также поиск родительских и дочерних узлов элемента. Если обособить эти операции в отдельные, их собственные, методы, это не только даст более чистый код, но и поможет поразмыслить о более сложных операциях без необходимости проверять каждый раз, надо ли использовать <or> и правильно ли выведена формула для определения индекса родитель­ ского узла: def _has_lower_priority(self, element_1, element_2): return self._priority(element_1) < self._priority(element_2) def _has_higher_priority(self, element_1, element_2): return self._priority(element_1) > self._priority(element_2) def _left_child_index(self, index): return index * 2 + 1 def _parent_index(self, index): return (index - 1) // 2 Есть еще один вспомогательный метод, _heapify, который строит кучу из существующего массива. Однако о нем мы поговорим в конце этого раздела. Мы охарактеризовали эти методы и теперь готовы обсудить основные операции с кучей. В качестве примеров возьмем в этой главе очередь ошибок, описанную в начале главы, с одной поправкой: приоритеты обозначаются вместо классов десятичными числами. Чем больше число, тем выше прио­ ритет. Insert Что ж, начнем со вставки. Чтобы вам было проще представить, что мы дела­ ем, я буду приводить рядом друг с другом дерево и массив, представляющие кучу. Эта куча/очередь ошибок будет нашей стартовой точкой. https://liveinternet.club
Реализация кучи  209 Куча представлена в виде дерева 0 1 3 7 10 4 6 5 3 5 0 10 Уровень 0 2 7 Куча представлена в виде массива 1 8 6 Утечка памяти Уровень 1 1 7 2 8 3 6 4 5 2 Уровень 2 Уровень 3 В браузере X не отображается пользовательский интерфейс Утечка памяти L0 L1 CSS-стиль нарушает выравнивание 5 1 Загрузка страницы занимает больше двух секунд CSS-стиль вызывает смещение на 1 пиксель 7 3 Дополнительное поле формы заблокировано 6 2 Рефакторинг CSS Незашифрованный пароль в базе данных Рефакторинг CSS с использованием SASS L2 L3 Дополнительное поле формы заблокировано Каждый элемент представляет собой пару из описания ошибки и обозначе­ ния степени ее приоритетности. В древовидном представлении большинство описаний опущено из-за нехватки свободного места. По этой же причине в дальнейшем я буду приводить описания только для представлениия в виде массива. Допустим, нужно добавить новый элемент. Как уже договорились, мы исходим из того, что массив был выделен с достаточным запасом памяти для добавления новых элементов и мы показываем только заполненную его часть, опуская пустые ячейки. Итак, нужно добавить кортеж ("Broken Login", 9). Сначала новый элемент добавляется в конец массива. Но тут мы замечаем, что он нарушает третье свойство куч, потому что его приоритет выше родительского! Куча представлена в виде дерева 0 1 3 10 6 Уровень 0 2 7 4 5 5 1 Перестановка 7 3 9 8 Куча представлена в виде массива 8 6 Родительский элемент 0 10 1 7 2 8 Уровень 1 3 6 4 5 2 Уровень 2 Уровень 3 5 1 Новая запись 6 2 7 3 8 9 Незашифрованный пароль в базе данных В браузере X не отображается пользовательский интерфейс Утечка памяти L0 L1 CSS-стиль нарушает выравнивание Загрузка страницы занимает больше двух секунд CSS-стиль вызывает смещение на 1 пиксель Рефакторинг CSS с использованием SASS Дополнительное поле формы заблокировано L2 L3 Ошибка входа Что же сделать, чтобы это исправить? Есть идея: поменять местами дочерний и родительский узлы! Это исправит нарушенную иерархию приоритетов и вполне устроит сестринский узел (с индексом 7), ведь он уже был не больше старого родительского узла, который, в свою очередь, меньше нового элемента. https://liveinternet.club
210  Глава 10. Приоритетные очереди и кучи Куча представлена в виде дерева 0 1 Перестановка 3 7 10 Уровень 0 2 7 9 4 3 6 8 5 5 Родительский элемент 8 6 1 Новая запись Уровень 1 0 10 1 7 2 8 3 9 4 5 2 5 1 Уровень 2 Уровень 3 6 2 7 3 8 6 Куча представлена в виде массива Незашифрованный пароль в базе данных В браузере X не отображается пользовательский интерфейс Утечка памяти L0 L1 Ошибка входа Загрузка страницы занимает больше двух секунд CSS-стиль вызывает смещение на 1 пиксель Рефакторинг CSS с использованием SASS Дополнительное поле формы заблокировано L2 L3 CSS-стиль нарушает выравнивание Тем не менее работа еще не завершена. Новый элемент, пусть и в новой по­ зиции, может все еще нарушать свойства кучи. И действительно, так и есть: приоритет у него выше, чем у его нового родителя, узла с индексом 1. Чтобы восстановить свойства кучи, мы поднимаем (bubble up) новый элемент, пока он не достигнет корня кучи или не найдется родитель с приоритетом выше, чем у него. В нашем случае это означает всего один прыжок на следующий уровень — и готово. Куча представлена в виде дерева 0 1 3 7 10 7 4 3 6 8 5 5 0 10 Уровень 0 2 9 Родительский элемент 1 8 6 Уровень 1 2 Новая запись Незашифрованный пароль в базе данных 1 9 Ошибка входа 3 7 В браузере X не отображается пользовательский интерфейс Загрузка страницы занимает больше двух секунд CSS-стиль вызывает смещение на 1 пиксель 2 8 4 5 Уровень 2 Уровень 3 Куча представлена в виде массива 5 1 6 2 7 3 8 6 Утечка памяти Рефакторинг CSS с использованием SASS Дополнительное поле формы заблокировано L0 L1 L2 L3 CSS-стиль нарушает выравнивание Что касается кода, я выделил фрагмент с подниманием элемента в отдельный вспомогательный метод, чтобы метод insert смотрелся чистым и компакт­ ным. Просто добавьте новый элемент в конец массива, и он начнет подни­ маться («всплывать»): def insert(self, element): self._elements.append(element) self._bubble_up(len(self._elements) - 1) https://liveinternet.club
Реализация кучи  211 Конечно, вся сложность во вспомогательном методе. Он оптимизиро­ ван. Вместо того чтобы снова и снова переставлять один и тот же элемент с очередным родителем, мы поочередно смещаем вниз тех самых родителей, с которыми нужно было бы меняться местами, и в конце концов сохраняем новый элемент в конечной для него позиции: def _bubble_up(self, index): Здесь нарушаются свойства кучи, element = self._elements[index] и нам надо поменять местами while index > 0: parent_index = self._parent_index(index) новый элемент с родительским parent = self._elements[parent_index] if self._has_higher_priority(element, parent): self._elements[index] = parent index = parent_index else: Новый элемент и его родительский эле­ break мент не нарушают свойств кучи, а зна­ self._elements[index] = element чит, мы нашли итоговую позицию для вставки нового элемента Сколько элементов придется поменять местами? Мы можем поднимать но­ вый элемент только по траектории от листа к корню, поэтому количество перестановок не превышает высоты кучи. Следовательно, как я и обещал, вставка в куче выполняется за O(log(n)) шагов. Top Итак, давайте начнем с кучи из девяти элементов, получившейся после встав­ ки ("Broken Login", 9), и удалим ее элемент с наивысшим приоритетом — корень кучи. Если просто удалить элемент из кучи, получится сломанное дерево. Останется два поддерева, каждое из которых является полноценной кучей, но их древовидная конфигурация нарушена (то есть у них нет общего корня). Куча представлена в виде дерева Уровень 0 1 3 7 2 9 7 4 3 6 8 5 5 1 8 6 Уровень 1 2 Куча представлена в виде массива 0 ? 1 9 2 8 Утечка памяти 5 1 В браузере X не отображается пользовательский интерфейс Загрузка страницы занимает больше двух секунд CSS-стиль вызывает смещение на 1 пиксель 7 3 Дополнительное поле формы заблокировано 3 7 4 5 Уровень 2 Уровень 3 L0 Ошибка входа 6 2 8 6 Рефакторинг CSS с использованием SASS CSS-стиль нарушает выравнивание https://liveinternet.club L1 L2 L3
212  Глава 10. Приоритетные очереди и кучи Сдвиг остальных элементов нарушит индексацию и структуру кучи. Поднять один из дочерних узлов бывшего корня (как в insert) не получится, потому что, во-первых, так мы переместим в одно из поддеревьев дыру, оставшуюся от бывшего корня. А во-вторых, поднимать следует наибольший из дочерних узлов. В зависимости от того, какой из них самый большой, а также исходя из структуры кучи, может появиться дыра на уровне листьев, что нарушит свойство «почти полноты» дерева. Тут есть вариант получше: почему бы не взять последний элемент кучи (в данном случае под индексом 8) и переместить его в корень кучи, заменив им бывший элемент с наивысшим приоритетом? Это навсегда устранит структурную проблему и восстановит второе свойство кучи, превратив ее в почти полное дерево. Куча представлена в виде дерева 0 1 3 7 3 7 6 2 9 4 Уровень 0 5 5 1 8 6 Уровень 1 2 Уровень 2 Уровень 3 Куча представлена в виде массива 0 6 1 9 2 8 3 7 4 5 CSS-стиль нарушает выравнивание Утечка памяти В браузере X не отображается пользовательский интерфейс рф ф Загрузка страницы занимает больше 5 1 CSS-стиль вызывает смещение на 1 пиксель 7 3 Дополнительное поле формы заблокировано 6 2 L0 Ошибка входа Рефакторинг CSS с использованием SASS L1 L2 L3 Впрочем, еще не все свойства кучи восстановлены: третье свойство все еще нарушено, потому что корень кучи меньше по крайней мере одного из своих дочерних узлов (что было весьма вероятно, потому что мы взяли лист — скорее всего, один из наименьших элементов кучи, — и переместили его в корень). Чтобы восстановить все свойства кучи, необходимо новый корень смещать вниз (push down) по направлению к листьям, меняя его местами с наи­ меньшим из дочерних элементов, пока не найдем место, в котором он больше не будет нарушать третье свойство кучи. Здесь, на изображении, конечная позиция, которую мы нашли для элемента 6, с заштрихованной траекторией перестановки элементов. https://liveinternet.club
Реализация кучи  213 Куча представлена в виде дерева 0 9 1 3 7 3 6 Уровень 0 2 4 5 5 1 8 6 Уровень 1 2 Куча представлена в виде массива 0 9 1 7 2 8 3 6 4 5 Уровень 2 Уровень 3 Ошибка входа В браузере X не отображается пользовательский интерфейс рф ф Утечка памяти L1 CSS-стиль нарушает выравнивание Загрузка страницы занимает больше 5 1 CSS-стиль вызывает смещение на 1 пиксель 7 3 Дополнительное поле формы заблокировано 6 2 L0 Рефакторинг CSS с использованием SASS L2 L3 Как и при реализации insert, у нас есть компактное тело метода top, об­ рабатывающее граничные случаи, и первый шаг — когда мы перемещаем последний элемент массива в корень кучи: def top(self): if self.is_empty(): raise ValueError('Method top called on an empty heap.') if len(self) == 1: element = self._elements.pop() Если в куче всего один эле­ else: мент, достаточно просто element = self._elements[0] извлечь его корень self._elements[0] = self._elements.pop() self._push_down(0) return element Бˆольшая часть работы выполняется вспомогательным методом _push_down. Нам понадобится еще один вспомогательный метод, чтобы узнать, нет ли у какого-нибудь из дочерних элементов узла наивысшего приоритета. Метод возвращает None, если данный узел — лист (это поможет нам позднее): def _highest_priority_child_index(self, index): У данного узла нет first_index = self._left_child_index(index) дочерних узлов if first_index >= len(self): return None if first_index + 1 >= len(self): У данного узла только один дочерний узел return first_index if self._has_higher_priority(self._elements[first_index], self._ elements[first_index + 1]): return first_index else: return first_index + 1 https://liveinternet.club
214  Глава 10. Приоритетные очереди и кучи С этим методом реализация _push_down упрощается. Что нам нужно сделать, так это, задав узел, проверить, является ли он листом: при вызове _highest_ priority_child_index возвращается None, — и если да, то дело сделано. В противном случае мы сравниваем элемент с одним из его потомков, тем, у которого наивысший приоритет. Если они не нарушают третье свойство кучи, работа также завершена. Если нарушают, то надо поменять их местами и повторить процесс: def _push_down(self, index): element = self._elements[index] current_index = index while True: child_index = self._highest_priority_child_index(current_index) if child_index is None: break if self._has_lower_priority(element, self._elements[child_ index]): self._elements[current_index] = self._elements[child_index] current_index = child_index else: break self._elements[current_index] = element Как и в случае со вставкой, мы оптимизируем метод, избегая явной переста­ новки. Но сколько перестановок необходимо сделать? Опять же мы можем двигаться только по пути от корня к листу, так что максимально возможное количество равно высоте кучи. На этот раз нам нужно проверить еще один аспект. Смещаемый вниз элемент мы переставляем местами с наименьшим из его дочерних, поэтому сначала надо найти таковой. Сколько сравнений необходимо выполнить, чтобы узнать, какой из потомков наименьший, и нужно ли менять его места­ ми со смещаемым вниз элементом? Для каждой перестановки понадобится не более двух сравнений, что нас устраивает, поскольку у нас O(log(n)) пере­ становок и O(2*log(n)) = O(log(n)) сравнений. Итак, это показывает, что мое предположение относительно логарифми­ ческой границы данного метода оказалось верным. Преобразование в кучу Остается обсудить еще одну операцию с кучей — преобразование набора элементов в кучу (heapify), то есть создание полноценной кучи из исходного на­ бора элементов. Эта операция не является частью интерфейса приоритетной очереди, потому что она относится только к специфике куч. Контекст такой: есть исходный массив из n элементов — без каких-либо предположений от­ https://liveinternet.club
Реализация кучи  215 носительно порядка их расстановки, — и надо построить из них кучу. Есть как минимум два тривиальных способа: zzэлементы можно отсортировать — отсортированный массив является полноценной кучей; zzможно создать пустую кучу и вызвать insert n раз. Обеим операциям потребуется O(n*log(n)) времени и, возможно, какой-то — вплоть до линейного — объем дополнительной памяти. Но кучи позволяют действовать более эффективно. Фактически кучу можно создать из массива элементов за линейное время, O(n). Это еще одно преимущество по сравнению с использованием отсортированных массивов для реализации приоритетных очередей. Начнем с двух соображений: каждое поддерево кучи является полноцен­ ной кучей, а каждый лист дерева является полноценной подкучей высотой 0. Если начать с произвольного массива и представить его в виде двоичного, почти полного дерева, его внутренние узлы могут нарушать третье свойство куч, но его листья бесспорно являются полноценными подкучами. Наша цель — путем многократных повторений строить бˆольшие подкучи, исполь­ зуя меньшие строительные блоки. Куча представлена в виде дерева 0 1 3 7 6 2 1 9 4 3 10 8 Уровень 0 8 5 5 Листья 7 6 Уровень 1 2 Уровень 2 Уровень 3 Куча представлена в виде массива 0 6 1 1 2 7 3 9 4 8 CSS-стиль нарушает выравнивание CSS-стиль вызывает смещение на 1 пиксель В браузере X не отображается пользовательский интерфейс L1 Ошибка входа Утечка памяти 5 5 Загрузка страницы занимает больше двух секунд 7 3 8 10 Дополнительное поле формы заблокировано 6 2 L0 Рефакторинг CSS с использованием SASS L2 L3 Незашифрованный пароль в базе данных В двоичной куче как минимум половина (плюс/минус 1) узлов — это листья, а значит, только другая половина узлов (внутренние узлы) может нарушать свойства кучи. Если взять любой внутренний узел предпоследнего уровня — уровня 2 в данном примере, — у него один или два дочерних узла, оба из которых листья, а следовательно, полноценные кучи. Теперь у нас есть куча с корнем, который может нарушать третье свойство, и две полноценные https://liveinternet.club
216  Глава 10. Приоритетные очереди и кучи подкучи как дочерние. Это именно то, на что мы обращали внимание, когда обсуждали метод top, — то, что происходит после замены корня последним элементом массива. Следовательно, подкучу можно исправить смещением вниз ее текущего корня. В этом примере единственным внутренним узлом уровня 2, который на­ рушает свойства кучи, является узел под индексом 3. После смещения вниз поддерево с корнем в узле под индексом 3 становится полноценной кучей. Затем мы переходим на уровень 1, на котором единственным другим узлом, чьи дочерние узлы — это листья, оказывается узел под индексом 2. Заметим, что если листьев n/2, то внутренних узлов, чьи дочерние узлы — только листья, будет n/4. В данном примере их всего два — на пять листьев (пред­ полагается, что здесь выполняется целочисленное деление). Мы пытаемся сместить вниз корень поддерева, но в данном случае ничего больше и не нужно. Теперь все подкучи высотой 1 действительны. Можно перейти к подкуче высотой 2 (она только одна), а затем к подкучам высо­ той 3 — в данном примере это вся куча. Проще написать код, чем объяснить или понять. Тело _heapify — это просто несколько строк: оно копирует коллекцию на входе в новый массив, затем вычисляет индекс последнего внутреннего узла кучи (с помощью вспомогательной функции, которая возвращает индекс первого листа) и, наконец, выполняет итерацию по внутренним узлам, смещая каждый из https://liveinternet.club
Приоритетные очереди в действии  217 них вниз. Важно перебирать внутренние узлы от последнего (находящегося на максимальной глубине в дереве) к первому (корню), потому что только этот способ гарантирует, что дочерние узлы каждого смещаемого вниз узла являются полноценными кучами: def _heapify(self, elements): self._elements = elements[:] last_inner_node_index = self._first_leaf_index() - 1 for index in range(last_inner_node_index, -1, -1): self._push_down(index) def _first_leaf_index(self): return len(self) // 2 Сколько времени нужно, чтобы сделать из массива кучу? O(n) сравнений и присваиваний. За математическим доказательством обращайтесь к раз­ делу 2.6.7 книги «Advanced Algorithms and Data Structures»1. Здесь же дам некоторые общие идеи. Для n/2 узлов — листьев кучи — не делается ничего. Среди n/4 узлов — родителей листьев — _push_down выпол­ няет максимум одну перестановку. Дальше по той же схеме: максимум две перестановки среди n/8 узлов — родителей n/4 узлов из предыдущего шага, и так далее, с log(n) таких членов. Суммарное количество этих операций составляет O(n). Приоритетные очереди в действии Теперь, когда мы поняли, как работает куча, давайте посмотрим на нее в дей­ ствии. В этом разделе мы рассмотрим нетривиальный пример использования кучи и встретимся с несколькими старыми друзьями! Найти k наибольших элементов Сломав преграды в освоении программирования и массивов, Марио вошел во вкус. Мы видели в главе 2, как он использовал массив для хранения ста­ тистики бросков кубика, чтобы выяснить, честный ли кубик. Теперь он хочет снова воспользоваться этими навыками, чтобы выиграть в лотерею. Идея проста, хотя и статистически несостоятельна, но Марио еще в седьмом классе и пока не может этого знать. Он хочет отследить, какие числа чаще всего оказывались выигрышными, выбрать первые шесть и с ними принять участие в лотерее. Мы знаем — его предположение, что эти числа выпадут с наибольшей вероятностью, ошибочно, но родители поощряют его продолжать проект для развития навыков анализа и про­ граммирования. 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
218  Глава 10. Приоритетные очереди и кучи Итак, родители находят публикации еженедельных розыгрышей лотереи за последние 30 лет и предоставляют Марио компьютер с интерпретатором Python. Они также помогают ему написать фрагмент программы, отвеча­ ющий за ввод/вывод, чтобы он мог исходить из того, что выпавшие числа можно добавлять по одному. Приложение Марио не запоминает, когда то или иное число выпало последний раз, а всего лишь подсчитывает, сколько раз оно попадалось среди выигрышной шестерки. С помощью той же программы, что сам написал для подсчета бросков кубика, он сохраняет данные о количестве выпадений каждого из 90 чисел, которые могут попасться в розыгрыше лотереи. В конце концов, после долгих часов набора и ввода данных за все 30 лет у него получился массив с 90 эле­ ментами, в котором drawn[i] — количество выпадений числа i за последние 30 лет. С целью упрощения для массива выделена память на 91 элемент, а Марио просто игнорирует первую позицию. Чтобы узнать, какие числа выпадают чаще других, Марио намеревает­ ся отсортировать массив и определить первые шесть элементов. Но когда он рассказывает своей подруге Ким о своем плане, она заявляет: «Я могу написать более эффективное решение». Ким действительно хороша в про­ граммировании, и в школе они только что проходили приоритетные очереди. Марио принимает вызов и пробует нащупать решение получше: «Как насчет преобразовать его в кучу — чтобы из кучи в 90 элементов выбрать 6 самых больших?» Ким ухмыляется: «Уже лучше, но и это можно усовершенствовать!» «Вот смотри, — добавляет она, — если нам нужно выбрать k самых больших элементов из n, то сортировка их всех заняла бы O(n*log(n) + k) шагов. Твое решение потребует O(n + k*log(n)) шагов и O(n) дополнительной памяти. А я могу сделать это за O(n*log(k) + k) шагов и всего с O(k) допол­ нительной памяти». https://liveinternet.club
Итоги  219 Марио изумляется, и Ким воодушевленно объясняет, как работает усо­ вершенствованное решение. Она создает кучу и вставляет элементы в виде кортежей (number, frequency). Но это не обычная куча: здесь будет храниться всего k элементов, в данном случае шесть самых больших элементов, найден­ ных к настоящему моменту. И здесь наступает важный момент: куча должна быть неубывающей. Точнее, если говорить в контексте приоритетов, приоритетность элемента должна быть противоположна частотности его появления — так, чтобы в корне кучи хранился элемент с наименьшей частотностью. Затем Ким один за другим просмотрит все выигравшие лотерейные номе­ ра и сравнит их с корнем кучи. Если корень кучи меньше, она извлечет его и вставит новый элемент. В конце концов в очереди останутся только шесть наиболее часто выпадающих номеров. Вот код, который выполняет эту работу: def k_largest_elements(arr, k): heap = Heap(element_priority=lambda x: -x[1]) for i in range(len(arr)): if len(heap) >= k: if heap.peek()[1] < arr[i]: heap.top() heap.insert((i, arr[i])) else: heap.insert((i, arr[i])) Когда мы удаляем наименьший эле­ return [heap.top() for _ in range(k)] мент, его значение нам больше не нуж­ но. Мы просто отбрасываем его print(k_largest_elements(drawn, 6)) Теперь Марио терзает только один вопрос: делиться ли деньгами с Ким, если они выиграют? Итоги zzПриоритетная очередь — абстрактный тип данных, предоставляющий две операции: insert для добавления нового элемента и top для удаления и возвращения элемента с наивысшим приоритетом. zzПриоритетные очереди предоставляют более широкие возможности, чем обычные очереди, позволяя в выборе элемента на извлечение руковод­ ствоваться иными критериями, чем порядок вставки. zzПриоритетные очереди можно реализовать на основе разных структур данных, но наиболее эффективная реализация достигается при исполь­ зовании кучи. https://liveinternet.club
220  Глава 10. Приоритетные очереди и кучи zzДвоичная куча — особый тип дерева. Это двоичное, почти полное дерево, в котором приоритет каждого узла выше или равен приоритету его до­ черних узлов. Корнем кучи является элемент с наивысшим приоритетом. zzУ куч есть еще одно свойство. Они являются деревом, которое лучше всего реализуется в виде массива. Это возможно благодаря тому, что куча является почти полным деревом. zzРеализуя кучу посредством массива, можно построить приоритетную очередь, в которой insert и top выполняются за логарифмическое время. zzКроме того, методом heapify можно преобразовать массив из n элементов в кучу за линейное время. https://liveinternet.club
Двоичные деревья поиска. Сбалансированный контейнер 11 В этой главе 9 9 9 9 9 9 9 9 9 9 Моделирование иерархических отношений при помощи деревьев Двоичные, троичные и n-ричные деревья Введение ограничений на данные в двоичные деревья: двоичные деревья поиска Оценка производительности двоичных деревьев поиска За счет чего сбалансированные деревья предоставляют более высокие гарантии Эта глава отличается от нескольких предыдущих, в которых наше внимание было сосредоточено на контейнерах. Здесь мы обсудим деревья, которые являются структурой данных, а вернее, целым классом структур данных! Деревья можно использовать для реализации нескольких абстрактных типов данных, так что, в отличие от других глав, здесь не будет раздела с описанием АТД. Мы с ходу возьмемся за дело, займемся описанием деревьев, а затем со­ средоточимся на одной особой разновидности деревьев — двоичных деревьях поиска (binary search tree, BST). Мы рассмотрим, с какими задачами деревья справляются особенно хорошо и что можно сделать, чтобы они работали еще лучше. https://liveinternet.club
222  Глава 11. Двоичные деревья поиска Что делает дерево деревом? В главе 6 мы обсудили связанные списки, свою первую составную струк­ туру данных. Элементы связанного списка, то есть его узлы, сами по себе являются простейшими структурами данных. Связанные списки основаны на идее, что у каждого узла есть один преемник и один предшественник — за исключением головы списка, у которой нет предшественника, и хвоста списка, у которого нет преемника. Однако не всегда все так просто, и взаимосвязи, бывает, складываются более замысловато. Иногда вместо линейной последовательности требуется представить некую иерархию с одной, четко определенной отправной точкой и ответвлениями от каждого узла. Определение дерева Типичное дерево представляет собой составную структуру данных, которая состоит из узлов, соединенных ссылками. Каждый узел содержит значение и переменное количество ссылок на другие узлы, от нуля до некоторого числа k (k — это количество разветвлений k-ричного дерева, то есть макси­ мальное количество ссылок, которые могут содержаться в узле). В каждом дереве есть особый узел, называемый корнем. Его особенность в том, что ни в одном другом узле нет ссылки на корень. 0 Корень Внутренний узел 2 6 3 4 Ссылка с родительского узла на дочерний 8 5 1 3 Лист 7 2 Leaf Если у узла P есть ссылка на узел C, то P называется родительским узлом (или родителем) по отношению к C, а C — дочерним по отношению к P. На изоб­ ражении у корня дерева два дочерних узла — со значениями 2 и 8. В неко­ торых деревьях у дочерних элементов также есть непосредственные ссылки на родительские, чтобы упростить обход дерева. Узел, у которого нет ссылок на дочерние узлы, называется листом. Другие узлы, у которых есть дочерние, называются внутренними. На иллюстрациях этого раздела шесть листьев со значениями 5, 1, 2, 3, 4 и 7. https://liveinternet.club
Что делает дерево деревом?  223 Чтобы дерево относилось к категории правильно сформированных, у каждого его узла должен быть ровно один родитель — за исключением кор­ невого, у которого нет родителя. Это означает, что если C — дочерний узел по отношению к P, то единственный путь от корня к C проходит через P. Это также означает, что все пути от корня к листу являются простыми, то есть в дереве нет циклов. Иначе говоря, следуя любым путем от корня к листу, вы никогда не встретите один и тот же узел дважды. Рассмотрим еще несколько определений, прежде чем двигаться дальше. Высота 0 Поддерево с корнем 8 2 6 3 4 8 5 1 Сиблинги Предок узлов (1, 3, 2, 7) 3 2 Потомок узлов (3, 8, 0) 7 Поддерево с корнем 6 Узел N предок узла M, если N находится на пути от корня к M. В таком случае M называется потомком N. Другими словами, потомок узла N является либо до­ черним узлом по отношению к N, либо потомком одного из дочерних узлов N. Конечно, корень является единственным предком, общим для всех осталь­ ных узлов. На иллюстрации также видно, что узел 8 является предком узла 7. Все дочерние узлы одного и того же узла являются сестринскими (сиблин­ гами), или одноуровневыми. Между сиблингами нет прямых ссылок, поэтому перейти от одного к другому можно только через общий родительский узел. Поддерево — часть дерева, состоящая из узла R , именуемого корнем поддерева, и всех потомков R. Каждый дочерний узел любого узла является корнем своего собственного поддерева. Высота дерева — это протяженность самого длинного пути от корня к ли­ сту. Высота дерева на иллюстрации выше равна 3, потому что есть несколько путей, состоящих из трех переходов, например 0→2→6→3. Высота поддерева с корнем в узле со значением 8 равна 2. От связанных списков к деревьям Связанные списки идеально моделируют линейные взаимосвязи, при кото­ рых мы определяем общий порядок элементов списка: первый элемент пред вторым, второй перед третьим и т. д. Однако дерево с легкостью олицетво­ ряет частичные связи — когда есть сопоставимые друг с другом и упорядо­ https://liveinternet.club
224  Глава 11. Двоичные деревья поиска ченные элементы и есть другие, несопоставимые и никак не связанные друг с другом. Чтобы различия были более наглядными, почему бы не представить два разных подхода к завтраку? Конечно, я имею в виду европейский континен­ тальный завтрак, который скорее сладкий, чем питательный. Взять чашку Взять чашку Насыпать хлопьев Насыпать хлопьев Добавить сахара Добавить сахара Налить молока Положить мороженое Налить молока Добавить сиропа Добавить шоколада Налить молока Впрочем, забудем о кулинарных пристрастиях и рассмотрим два подхода. Первый подход методичен. Представьте человека, который всегда ест хлопья на завтрак и повторяет одни и те же действия в неизменном порядке. Такой линейный метод можно смоделировать с помощью связанного списка. Второй вариант — это то, что я бы назвал подходом по настроению. Чаще всего по утрам я придерживаюсь своей диеты и ем хлопья без сахара. Время от времени я чувствую упадок сил и добавляю сахар, чтобы подсластить свой день. А иногда, когда я совсем подавлен, то вообще обхожусь без хло­ пьев и сразу приступаю к мороженому! Такой подход, сочетающий выбор и множество вариантов, невозможно представить связанным списком — для этого понадобится дерево. В таблице 11.1 приведена сводка сходств и различий между связанными списками и деревьями. Таблица 11.1. Сравнение связанных списков с деревьями Связанный список Дерево У одного узла нет предшественника (голова) У одного узла нет родительского узла (корень) У одного узла нет последующего (хвост) У многих узлов нет дочерних (листья) У каждого узла ровно одна исходящая ссылка на узел-преемник У каждого узла ноль, одна или много ссылок на дочерние узлы Двусвязные списки: у каждого узла ровно одна ссылка на узел-предшественник В некоторых деревьях у узлов есть ссылки на их родительские узлы. В таком случае у каждого узла ровно одна ссылка на свой единственный родительский узел https://liveinternet.club
Двоичные деревья поиска  225 Двоичные деревья По определению двоичных деревьев каждый узел может иметь не более двух дочерних узлов. Таким образом, в двоичном дереве у узла может быть ноль, одна или две ссылки на дочерние. Обычно эти две ссылки обозначаются как левая и правая дочерние ссылки, а отсюда и два поддерева — левое и правое. Впрочем, порядок дочерних ссылок и узлов важен не для всех двоичных деревьев. Троичное дерево Двоичное дерево Левая дочерняя ссылка 0 2 6 3 4 Правая дочерняя ссылка 4 -1 8 5 2 3 7 7 9 5 3 0 6 Практическое применение деревьев Количество областей, в которых используются деревья, впечатляет. Каждый раз, когда нам требуется представить иерархические отношения, деревья предоставляют решение. Они применяются в машинном обучении, а деревья принятия решений и случайные леса входят в число лучших инструментов классификации, помимо нейронных сетей. В главе 10 рассматривалась куча — особое дерево, эффективно реализующее приоритетные очереди. Существует множество более специализированных деревьев, например b-деревья, обеспечивающие эффективное хранение данных (как в базах данных), или kd-деревья — для индексирования многомерных данных. Впрочем, это лишь вершина айсберга: деревья образуют очень большой класс исключительно многогранных структур данных. Двоичные деревья поиска Кроме моделирования отношений, деревья также могут служить в качестве контейнеров. Мы уже использовали для этого кучи, но область их при­ менения узка. Однако существует контейнер более широкого назначения, о котором мы поговорим в оставшейся части этой главы: двоичное дерево поиска (BST). Из названия следуют некоторые свойства таких контейнеров: это деревья; они двоичны, то есть у каждого узла — хотя и не обязатель­ но — есть левый и правый дочерний узел и они используются для поиска. https://liveinternet.club
226  Глава 11. Двоичные деревья поиска Эти деревья были разработаны для ускорения поиска, в перспективе до показателей двоичного поиска в отсортированном массиве. И у них есть одно важное преимущество перед отсортированными массивами: вставка и удаление в BST могут выполняться быстрее. В чем тут секрет и чем за это расплачиваться? Как и связанные списки, деревья требуют больше памяти для реализации, и их код сложнее, особенно если мы хотим гарантировать, что упомянутые операции будут выполняться быстрее, чем с массивами. В этом разделе мы опишем двоичные деревья поиска как структуры дан­ ных и обсудим их реализацию. Порядок имеет значение Как и в случае с кучами, имеющими ограничения структуры и данных де­ рева, для перехода от двоичного дерева к двоичному дереву поиска нужно добавить данным, хранящимся в узлах, определенное свойство. ОПРЕДЕЛЕНИЕ Все двоичные деревья поиска обладают BST-свойством: если произвольный узел N хранит значение v, то во всех узлах левого поддерева N будут значения меньшие, чем v, либо равные v, а во всех узлах правого поддерева N — значения большие, чем v. Имейте в виду, что между двумя поддеревьями существует асимметрия: если есть дубликаты, нам нужен способ избежать ничьей, чтобы мы всегда знали, где искать возможные дубликаты значения узла. Выбор левого поддерева совершенно произволен и основан только на договоренности. Левое поддерево 4 2 1 3 Правое поддерево 6 7 5 v<=6 v>6 9 8 Определение класса и конструктор Сейчас самое время рассмотреть реализацию класса для BST. Так как мы име­ ем дело с составной структурой данных, необходимо реализовать внешний класс для публичного интерфейса, доступного клиентам, и внутренний при­ ватный класс для представления узла. Я реализовал минимальную версию https://liveinternet.club
Двоичные деревья поиска  227 класса Node, и бˆольшая часть работы будет выполняться во внешнем классе. Просто знайте, что также возможен и другой вариант: class Node: def __init__(self, value, left=None, right=None): self._value = value self._left = left self._right = right def set_left(self, node): self._left = node def set_right(self, node): self._right = node Я опустил здесь геттеры (getter methods), которые просто возвращают ссылки на приватные поля Node (вы можете найти их в полном коде в репозитории книги). Хотя левый и правый дочерние узлы можно впоследствии изменить при помощи сеттеров (setter methods), приведенных здесь, я не позволяю изменять значения узла. Если вы захотите изменить значение узла в дереве, вам придется создать новый экземпляр Node и задать его дочерние узлы. Конструктор внешнего класса еще проще. Достаточно лишь инициали­ зировать корень пустым узлом: class BinarySearchTree: def __init__(self): self._root = None Поиск Если внимательнее присмотреться к свойству данных BST, можно узнать нечто интересное. Можно связать диапазон возможных значений заданного узла дерева с его поддеревьями и ребрами его левого и правого дочерних узлов. Каждый узел N, содержащий значение v, 6 v>6 v<=6 распределяет возможные значения по сво­ им поддеревьям таким образом, что при 4 7 обходе дерева по левым ссылкам мы обна­ v<=4 v>4&v<=6 v>6 ружим только значения x≤v, а по правым — 2 5 9 только значения x>v. v<=2 v>2&v<=4 v>6&v<=9 Но на самом деле и это еще не все, по­ тому что ограничения предков узла также 1 3 8 действительны и для его дочерних узлов. В примере на иллюстрации при обходе левой от корня ветви мы знаем, что найти можно только значения x≤6. Затем мы находим узел со значением 4. Его правое поддерево может содержать только значения >4, но из-за его родителя во всем поддереве с корнем 4 не может быть никаких значений >6, поэтому для любого x в правом поддереве узла 4 мы знаем, что 4<x≤6. https://liveinternet.club
228  Глава 11. Двоичные деревья поиска Информации много, но как использовать ее? Ответ: при поиске. Если мы ищем в дереве из нашего примера определен­ ное значение — допустим, 100, — то, взглянув на корень, мы понимаем, что искомое значение может находиться только в правом поддереве корня. Это означает, что на левое поддерево можно вообще не смотреть! Аналогичным образом, если значение меньше значения корня либо равно ему, то можно не смотреть на правое поддерево. Что произойдет со следующим узлом — тем, который в нашем примере хранит значение 7? Применяется тот же принцип — мы идем либо налево, либо направо. В данном случае, если мы продолжаем искать значение 100, то идем направо. От любого узла можно идти либо по левому, либо по правому пути. Мы никогда не поднимаемся вверх по дереву по направлению к корню. В какой-то момент, если мы еще не достигли цели, мы попытаемся пройти по нулевой ссылке. Мы либо находимся в листе, либо достигли промежу­ точного узла с одним дочерним узлом (левым, когда нужно идти направо, или наоборот). Тогда мы знаем, что поиск оказался безуспешным, потому что нашей цели не может быть ни на каком другом пути в дереве. Почему? Потому что на каждой развилке, в каждом узле два (возможных) поддерева будут взаимоисключающими, поэтому мы следовали по единственному пути, по ходу которого может содержаться целевое значение. search(5) search(100) v<=6 4 2 1 3 5 6 100>6 7 5<6 100>7 v<=9 8 9 100>9 v<=4 4 6 5>4 v>6 7 2 1 3 9 8 Тот факт, что метод поиска следует по единственному пути от корня до, воз­ можно, листа, означает, что количество шагов не превысит высоту дерева — понадобится O(h) сравнений, где h — высота дерева. Теперь обратимся к реализации. Метод поиска получает значение и воз­ вращает узел дерева, который содержит это значение, или None, если значение отсутствует. По аналогии с тем, как делалось со связанными списками, этот метод предоставляется как приватный, потому что внутренняя структура дерева не должна раскрываться перед клиентом. Мы можем легко предо­ ставить публичный метод contains, который проверяет, вернул ли поиск значение None. https://liveinternet.club
Двоичные деревья поиска  229 Сейчас я покажу разновидность метода поиска, которая возвращает кор­ теж — вместе с найденным узлом возвращается его родитель. Это объясня­ ется тем, что если мы не сохраняем ссылку на родителя узла, то оказываемся в ситуации, сходной с ситуацией с односвязным списком, где приходится запоминать предшественника узла при сканировании списка, иначе впо­ следствии мы не сможем получить этого предшественника: Начинаем с корня, и его родитель равен None def _search(self, value): parent = None node = self._root while node is not None: node_val = node.value() if node_val == value: return node, parent elif value < node_val: parent = node node = node.left() else: parent = node node = node.right() return None, None Цель найдена! Если поиск дошел досюда, — значит, мы вышли из цикла, не достигнув цели Поиск минимума и максимума Прежде чем переходить к методам, модифицирующим BST, мне хотелось бы обсудить особую разновидность поиска: обнаружение наибольшего и наи­ меньшего элемента в дереве. Эта задача проще общего поиска. Собственно, мы точно знаем, где эти два элемента будут в дереве. Например, чтобы получить наибольший элемент, мы начинаем от корня и следуем по ссылкам по правым дочерним узлам, пока не достигнем узла, не имеющего правого дочернего. Этот узел — которым, разумеется, может быть и сам корень — содержит наибольшее значение дерева. Почему? По­ тому что, если на какой-то развилке повернуть налево, то даже найдя узел без правого дочернего узла, значения, найденные в левом поддереве, будут как максимум равны значению, хранящемуся в данном узле. 6 4 2 Наибольшее значение 1 3 7 5 9 6 8 https://liveinternet.club Наименьшее значение
230  Глава 11. Двоичные деревья поиска С минимумом дело обстоит аналогично, с учетом того, что мы всегда выбираем левый путь вместо правого. Теперь давайте рассмотрим реали­ зацию метода поиска наибольшего значения — вскоре мы снова им вос­ пользуемся. Ключевая особенность двоичного поискового дерева — рекурсивная структура: каждое поддерево также является полноценным BST. Это также означает, что мы можем найти наибольшее и наименьшее значение любого поддерева основного дерева. Итак, давайте реализуем в классе Node метод выявления наибольшего значения поддерева с корнем в заданном узле: def find_max_in_subtree(self): parent = None node = self while node.right() is not None: parent = node node = node.right() return node, parent Обратите внимание: как и в случае с _search, также необходимо вернуть родителя найденного узла. Insert Теперь мы знаем, как искать в BST и как создать пустое BST, — осталось на­ учиться заполнять его! Метод вставки очень похож на метод поиска, и это не простое совпадение. Когда мы вставляем новый элемент, мы фактически ищем позицию, которую занимал бы этот новый элемент в дереве, если бы он уже был вставлен. Конечно, есть и некоторые отличия от метода поиска. Мы не можем оста­ новиться, когда находим то же самое значение, которое хотим вставить, — разве что если дубликаты запрещены, но это особый случай. Вместо того чтобы остановиться, если находим уже имеющееся вхождение того значения, которое хотим вставить, мы продолжаем обход дерева, делая поворот налево. Обычно, когда мы добираемся до узла, то сначала проверяем хранящееся в нем значение, чтобы понять, по какой ветви следует идти дальше, — левой или правой. Допустим, мы определили, что идти нужно налево. Если у узла нет левого дочернего, значит, мы обнаружили место, куда нужно добавить новый элемент. Все, что необходимо сделать, — это создать новый узел и при­ соединить его как левый дочерний к данному узлу. Вариант, когда нужно проследовать по правому пути, обрабатывается симметрично. Давайте рассмотрим несколько примеров, чтобы прояснить, как работает вставка. https://liveinternet.club
Двоичные деревья поиска  231 insert(6) 6<=6 v<=4 4 2 1 3 6 v>6 v<=6 7 6>4 5 insert(11) 6 4 9 6>5 8 6 2 7 5 1 3 11>6 11>7 v<=9 6 8 9 11>9 11 В первом примере добавляется дубликат значения корня дерева, 6. В корне мы поворачиваем налево — как всегда делаем, когда находим то же значение, что уже хранится в узле. Мы идем по этой ветви, пока не достигаем листа со значением 5 — и знаем, что можем добавить наш новый узел сюда. Меня всегда удивляло, как в BST два вхождения одного значения могут находиться настолько далеко друг от друга. В отсортированном массиве они были бы смежными, но не в BST. Помните об этом, когда мы вернемся к этой теме. В другом представленном примере добавляется наибольшее значение дерева, поэтому мы идем по крайней правой траектории дерева и там до­ бавляем новый узел для значения 11. Код метода insert не так сильно отличается от кода метода search: def insert(self, value): Дерево пусто Нашли подходящее место node = self._root для нового значения if node is None: self._root = BinarySearchTree.Node(value) else: while node is not None: if value <= node.value(): Необходимо if node.left() is None: продолжить node.set_left(BinarySearchTree.Node(value)) обход левой break Здесь также нашли подходящее ветви под­ else: место для нового значения дерева node = node.left() elif node.right() is None: node.set_right(BinarySearchTree.Node(value)) break Необходимо продолжить обход else: правой ветви поддерева node = node.right() Так как вставка в BST эквивалентна безуспешному поиску, она также вы­ полняется за время O(h), где h — высота дерева. https://liveinternet.club
232  Глава 11. Двоичные деревья поиска Delete В то время как добавление элемента относительно прямолинейно, удаление происходит намного сложнее. Но опять-таки этот метод сильно зависит от search, поскольку мы должны найти значение, которое хотим удалить, и получить ссылку на содержащий его узел. Это предпочтительнее прямой передачи удаляемого узла методу delete. Среди прочего нам также понадо­ бится ссылка на родителя удаляемого узла. При удалении узла необходимо различать три ситуации: zzудаляем лист; zzудаляем узел только с одним дочерним узлом; zzузел, который хотим удалить, имеет оба дочерних узла. 6 Случай 1 4 2 7 5 1 3 9 Случай 3 Случай 2 8 Для каждого из этих случаев у нас есть несколько иная процедура, если удаляемый узел — корень. Во всех ситуациях мы исходим из того, что уже провели поиск и нашли удаляемый узел N и его родителя P. Удаление листа Это простейший случай. Лист по определению не имеет дочерних узлов, так что не надо ничего мудрить. Единственное, что необходимо сделать, — это разорвать связь между родительским узлом и удаляемым. Это объясняет, почему при успешном поиске необходимо возвращать родительский узел. 6 P 4 2 1 3 7 5 6 delete(5) P 9 N 8 4 7 2 1 3 https://liveinternet.club 9 8
Двоичные деревья поиска  233 Что произойдет, если удаляемый узел является корневым и не имеет родителя? Если корень является листом, то удаление оставит нас с пустым деревом. Удаление узла с единственным дочерним узлом Если у удаляемого узла только один дочерний узел, процесс все равно будет проще по сравнению с ситуацией, когда имеются два дочерних узла. Един­ ственный дочерний узел можно напрямую соединить с родителем удален­ ного узла. Здесь у нас четыре случая. У узла N может быть левый или правый дочер­ ний узел, и сам узел N может быть левым или правым дочерним узлом P. Все эти четыре случая можно обработать одним и тем же способом, и разница лишь в том, какими указателями воспользуемся. Чтобы прояснить ситуацию, рассмотрим случай, в котором C — правый дочерний узел узла N . В нашем примере из дерева должно быть удалено значение 7. P 4 2 1 3 delete(7) 6 7 5 8 6 4 N 9 P C S 2 9 5 8 S 1 3 Что нам нужно сделать, так это разорвать связи между P и N, а также N и C и создать новую прямую связь между P и C. Поскольку в этом примере N был правым дочерним узлом P, C становится новым правым дочерним узлом P. При этом S, бывшее правое поддерево N, двигается вверх и становится пра­ вым поддеревом P. А если бы N был корневым узлом дерева? Ну в таком случае все что нуж­ но — это обновить корень, и дело сделано. Удаление узла с обоими дочерними узлами Этот случай оказывается самым сложным. Допустим, мы хотим удалить узел 4 в BST, которое мы использовали в качестве примера на протяжении этого раздела. (На самом деле для простоты возьмем немного другое дерево.) Как только мы найдем узел N, который собираемся удалить, то поймем, что у него есть оба дочерних узла. Следовательно, мы не сможем просто пере­ https://liveinternet.club
234  Глава 11. Двоичные деревья поиска кинуть ссылку от родителя P на один из его дочерних узлов, потому что не знаем, как исправить другое поддерево N. Мы не сможем даже поднять значение, N 4 7 как делали это с кучей! Вместо этого для замены удаляемого узла нам 1 5 9 понадобится значение, которое будет меньше любо­ го элемента правого поддерева N и не меньше любо­ го значения левого поддерева N. Значение v является 3 6 8 предшественником N в поддереве с корнем N . Это значение, которое в случае сортировки всех значе­ ний в поддереве с корнем N будет располагаться не­ 2 посредственно перед value(N). В нашем примере это значение 3, которое ока­ P зывается наибольшим в левом поддереве узла 4! Да, 6 и это не просто совпадение. Искомое значение v всегN 4 7 да является наибольшим в левом поддереве N. И что еще лучше для нас, у узла M, содержащего это наи­ большее значение, не может быть правого поддерева. 1 5 9 На самом деле если бы у M было правое поддере­ во, это бы означало, что в левом поддереве N есть 6 8 узел со значением, превышающим v, что является M 3 противоречием. Таким образом, при удалении M мы оказываемся либо в случае 1, либо в случае 2 метода 2 удаления. Другими словами, узел M удаляется безо всяких сложностей, и это здорово! 6 P P 4 N 1 M value(N)=3 6 7 5 3 2 6 8 3 N PM 1 9 M deleteN(3) 6 7 5 3 N 9 6 8 6 3 1 7 5 2 9 6 8 2 Итак, вот план удаления узла 4: мы заменяем значение узла его предше­ ственником в поддереве с корнем 4, которым является узел 3. Затем удаляем https://liveinternet.club
Двоичные деревья поиска  235 узел 3 в левом поддереве узла 4, зная, что этот узел, являющийся наибольшим в left(4), будет несложно удалить. И на этом дело сделано. Сводя всё воедино Рассмотрев все возможные случаи удаления узлов, давайте свяжем все это воедино и напишем метод для класса BST. Это будет самый сложный метод из всех, что мы написали к настоящему моменту. От методов, рассматривавшихся в предыдущих подразделах, он отличает­ ся прежде всего подробностями реализации, например, тем фактом, что мы не просто заменим значение в удаляемом узле, а скорее заменим весь узел. def delete(self, value): Эта ветвь охватывает случаи 1 и 2 if self._root is None: Эта команда позволяет raise ValueError('Delete on an empty tree') в дальнейшем исполь­ node, parent = self._search(value) зовать один и тот же if node is None: код для обеих разно­ raise ValueError('Value not found') видностей случая 2, if node.left() is None or node.right() is None: maybe_child = node.right() if node.left() is None а также для случая 1, когда оба дочерних узла ➥ else node.left() равны None if parent is None: self._root = maybe_child Если родительский узел равен None, то elif value <= parent.value(): parent.set_left(maybe_child) данный узел является корневым. В против­ ном случае проверяем, левым или правым else: parent.set_right(maybe_child) дочерним узлом является данный узел else: max_node, max_node_parent = node.left().find_max_in_subtree() Поиск наибольшего значения в левом под­ дереве удаляемого узла if max_node_parent is None: new_node = BinarySearchTree.Node(max_node.value(), None, node.right()) else: new_node = BinarySearchTree.Node( В данном случае это означает, что max_node.value(), наибольшее значение левого подде­ node.left(), рева — это как раз левый дочерний node.right()) узел этого узла max_node_parent.set_right(max_node.left()) if parent is None: self._root = new_node elif value <= parent.value(): parent.set_left(new_node) else: parent.set_right(new_node) Ни в одном из этих трех случаев мы ни в коем случае не поднимаемся вверх по дереву, а всегда следуем по пути от корня к листу. Следовательно, delete также требует не более O(h) шагов. https://liveinternet.club
236  Глава 11. Двоичные деревья поиска Обход BST Обход — одна из фундаментальных операций со структурами данных. Способ обхода некоторых структур, которые мы до сих пор обсуждали, был очевидным. В массивах и связанных списках начинаем с самого начала и продвигаемся линейно. В других структурах данных обхода не существует. Элементы стеков, очередей и приоритетных очередей можно перебирать, только удаляя их из контейнера. С BST мы вступаем на неисследованную территорию своего путешествия. Эта структура данных нелинейна по своей природе, так как же совершать ее обход? Есть три способа обхода обычного двоичного дерева: zzпрямой (pre-order), когда мы посещаем каждый узел до его поддеревьев; zzобратный (post-order), когда посещаем поддеревья узла до него самого; zzпоследовательный, или центрированный (in-order), когда сначала об­ ходим левое поддерево заданного узла N, затем сам N, а затем его правое поддерево. Для BST самый подходящий порядок — центрированный. Чтобы понять, почему это так, возьмем мини-BST с кор­ нем и двумя дочерними узлами и посмотрим, в каком по­ рядке будут посещаться узлы: B A C zzпрямой: B A C; zzобратный: A C B; zzцентрированный: A B C. Центрированный порядок — единственный способ получить отсортиро­ ванную последовательность элементов в BST. Код центрированного обхода доступен в репозитории этой книги на GitHub: https://mng.bz/9de1. Предшественники и преемники У BST в качестве исключения мы описываем две дополнительные операции: поиск предшественника и поиск преемника узла. Формально для заданной коллекции C, не содержащей дубликатов, преемником элемента x является элемент s, наименьший среди элементов C, больших, чем x. Аналогичным образом предшественник x определяется как наибольший среди элементов, меньших, чем x. В отсортированном массиве и отсортированном двусвязном списке эти операции выполняются тривиально: предшественник и преемник элемента x (если они существуют) расположены непосредственно по соседству с x — эти элементы стоят в структуре данных буквально до и после x. https://liveinternet.club
Двоичные деревья поиска  237 В несортированных версиях упомянутых структур данных эти операции остаются не особо сложными, но становятся затратными: чтобы найти пре­ емника, придется просканировать весь контейнер. Как насчет BST? Мы знаем, что элементы BST располагаются в опреде­ ленном порядке, но получение предшественника и преемника не является операцией, выполняемой за постоянное время. При обсуждении метода delete мы выяснили, что для заданного узла N предшественником N, ограниченным поддеревом с корнем в N, является наи­ больший элемент левого поддерева N — если оно есть. Тем не менее, когда речь заходит о поиске предшественника узла N во всем дереве, все оказыва­ ется не так просто. zzЕсли у N есть левое поддерево — да, предшественником является макси­ мум этого поддерева. zzЕсли у N нет левого поддерева и он является правым дочерним узлом, то его родитель является также и его предшественником. zzЕсли у N нет левого поддерева и он является левым дочерним узлом, сле­ дует подниматься вверх по дереву, пока не найдем узел M, являющийся правым дочерним узлом, — его родитель и будет предшественником N. zzЕсли мы достигнем корня прежде, чем найдем такой узел, это означает, что N — наименьший узел дерева и у него нет предшественника. 6 Узел 6 4 2 5 1 3 2 9 Предшественник 4 7 5 Предшественник 5 9 8 6 Предшественник 4 2 9 7 Узел 1 3 8 6 2 4 7 7 5 9 Узел 1 3 Узел 8 1 3 https://liveinternet.club 8
238  Глава 11. Двоичные деревья поиска Чтобы реализовать этот метод в BST — а оно не хранит ссылки на родите­ лей узлов, — придется использовать метод поиска с возвратом (backtracking), описание которого выходит за рамки книги. О такого рода методах можно подробнее узнать в книге «Introduction to Algorithms» (Cormen, Leiserson, Rivest, Stein, 2022, MIT Press)1. Я хочу, чтобы вы запомнили две важные вещи: эти две операции сложнее, чем можно подумать, и в BST они выполняются за время O(h). УПРАЖНЕНИЯ 11.1 Реализуйте класс BST, в котором узлы хранят ссылку на своего роди­ теля, а затем добавьте в класс методы поиска предшественника и пре­ емника. 11.2 Сможете ли вы найти пример среди BST, показанных в предыдущих разделах, в котором описанный выше метод predecessor оказался бы неудачным? Подсказка: загляните в следующее упражнение. 11.3 Когда в BST есть дубликаты, получение предшественника несколько усложняется, тогда как метод successor может работать без изменений. Сможете объяснить почему? 11.4 Как исправить метод predecessor, чтобы он справлялся с дубликатами? Сбалансированные деревья Все операции в BST, что мы видели, выполняются за время, пропорциональ­ ное высоте дерева. Это хорошо или плохо? Можно сформулировать вопрос иначе: в заданном BST с n узлами и высотой h будет ли O(h) лучше O(n)? По­ нятно, что хуже не будет, но возможно ли, что O(h) = O(n)? Двоичные деревья поиска в действии Наш юный друг Марио, изучающий computer science, уже однажды обжегся, когда бросил вызов своей маме. Если вы помните, в главе 3 он поспорил, что быстрее найдет бейсбольные карточки в колоде, и проиграл. Марио знает, что перехитрить маму непросто, но хочет применить полученные знания, чтобы одержать легкую победу над каким-нибудь простаком. И он решает опробовать мамин трюк на однокласснице Ким. Однако простого выигрыша Марио недостаточно. Он хочет произвести впечатление на Ким, поэтому решает вместо отсортированного массива применить BST — он только что узнал о нем из учебников своих родителей по computer science. 1 Кормен Т., Лейзерсон Ч., Ривест Р. «Алгоритмы. Построение и анализ». https://liveinternet.club
Сбалансированные деревья  239 Задача та же. Каждому — половину карточек Марио, и каждый готовит список карточек, которые нужно найти как можно быстрее. Есть у Марио только одна проблема: Ким лучше знает BST, и услышав, что он хочет пойти таким путем, она быстро раскладывает половину колоды так, чтобы карточки были почти отсортированы — по убыванию, в обратном порядке. Когда Марио начинает строить свое BST на полу комнаты, он понимает, что Ким его провела. Дерево не помещается в комнате, и Марио приходится проложить длинную-предлинную ветвь в коридор. К тому времени, когда он возвращается в комнату, Ким уже нашла свои пять карточек, и Марио угрюм и разбит. Наконец он ее спрашивает: «Знаю, ты меня перехитрила, но как именно?» Нежелательные последовательности вставки Что сделала Ким? Она тщательно подобрала закономерную последова­ тельность, а это, как известно, создает проблемы для BST. Если вставлять элементы BST от наименьшего к наибольшему или наоборот, мы получим полностью перекошенные деревья, похожие на связанные списки. 1 5 2 4 3 3 4 2 5 1 https://liveinternet.club 6
240  Глава 11. Двоичные деревья поиска В таких особых случаях высота дерева в точности равна n . Подумать только — ирония в том, что при отсортированных последовательностях мы получаем наихудшую производительность! Чтобы доказать, что для обычных BST в наихудшем случае O(h) = O(n), одного примера будет достаточно. К сожалению, он далеко не единственный. Чтобы получилось перекошенное дерево, нам необязательно должно так не повезти, чтобы все элементы оказались вставлены в их финальном порядке. Если мы сможем найти в последовательности вставки пусть и несмежную, но подпоследовательность из половины или, скажем, четверти, или пятой части (и т. д.) отсортированных элементов, то высота дерева будет не менее n/2, n/4 или n/5 соответственно. И мы знаем, что O(n/5) = O(n). Удаления перекашивают дерево Есть еще две причины дисбаланса BST. Первая: когда у нас есть дубликаты, мы, как известно, нарушаем связи, сдвигаясь все время влево. Это означает, что левые ветви в среднем будут чуть больше правых. Вторая, которая приводит к худшему результату: когда мы удаляем зна­ чения из BST, мы всегда забираем наибольшее значение из левой ветви уда­ ляемого узла. Это означает, что левая ветвь будет становиться все меньше, и после многих удалений дерево будет сильно перекошено: правые ветви будут ощутимо больше левых. Балансировка дерева После всего, что было сказано, ситуация выглядит довольно мрачно для BST. Если выбрать неправильный порядок вставки, получим перекошенное дерево. Если выполнить много удалений — снова перекошенное дерево. И что, мы обречены? Есть пара трюков, которые могут помочь. Если мы в какой-то степени контролируем порядок вставки, то можно перетасовать порядок входной последовательности, чтобы снизить вероятность даже частично отсорти­ рованной последовательности. А чтобы справиться с дисбалансом, вызван­ ным delete, можно замещать удаляемые значения то их преемниками, то предшественниками — случайным образом их чередовать, чтобы избежать перекоса. Но часто оказывается, что входная последовательность нам неподконт­ рольна — например, она может оказаться динамичной. Кроме того, ни один из этих трюков не обеспечивает гарантированной верхней границы высоты дерева. В главе 10 рассматривалась куча, гарантировавшая высоту O(log(n)) для n элементов. Нет ли чего-нибудь подробного для BST? Конечно, структура https://liveinternet.club
Итоги  241 кучи другая — в случае с кучей мы не сохраняем полный порядок располо­ жения узлов, и поэтому проще поддерживать ее высоту логарифмической. Но есть способы поддержки баланса и для BST. Двоичное дерево считает­ ся сбалансированным по высоте (height-balanced), если разность высот левого и правого поддеревьев любого его узла не превышает 1 и оба его поддерева также сбалансированы. Есть структуры данных, эволюционное продолжение BST, которые могут гарантировать выполнение этого условия. Одна из таких структур использу­ ет для обеспечения баланса свойства кучи: рандомизированные кучи пред­ ставляют собой недетерминированно сбалансированное двоичное поисковое дерево поиска. О них можно больше узнать в главе 3 моей книги «Advanced Algorithms and Data Structures»1. Из всех сбалансированных деревьев поиска на практике чаще всего при­ меняются красно-черные деревья и 2-3-деревья. О них подробно рассказано в той же главе книги «Advanced Algorithms and Data Structures», или в гла­ ве 13 «Introduction to Algorithms» (Cormen, Leiserson, Rivest, Stein, 2022, MIT Press), или в разделе 3.3 книги «Algorithms, 4th Edition» (Sedgewick, Wayne, 2020, Pearson). Сбалансированное двоичное дерево поиска (balanced binary search tree, BBST) позволяет выполнять такие операции, как поиск, вставка и удаление за время O(log(n)). Вследствие этого BBST становится структурой данных, обес­печивающей наилучшую среднюю производительность по всему диа­ пазону операций: некоторые операции с отсортированным массивом или связанным списком могут выполняться быстрее, но в среднем по всем опе­ рациям все показатели в пользу BBST. И мы также получаем ответ на вопрос, для чего нужны BST. Они по­ зволяют делать то же самое, что и отсортированные массивы, но в целом быстрее. Итоги zzДеревья — рекурсивные структуры данных, состоящие из узлов. Каж­ дый узел содержит значение и определенное количество ссылок на дочерние узлы. Каждый дочерний узел является корнем полноценного поддерева. zzДеревья идеально подходят для моделирования иерархических взаимо­ связей и любых конструкций с разветвлениями. zzВ двоичных деревьях каждый узел может иметь ноль, один или два до­ черних узла. Узлы, не имеющие дочерних узлов, называются листьями. 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
242  Глава 11. Двоичные деревья поиска У других узлов — они называются внутренними — один или два до­ черних узла. zzСсылки на дочерние узлы в двоичном дереве обычно называются левыми или правыми. В некоторых, но не во всех двоичных деревьях это различие может иметь значение. zzВ двоичном дереве поиска, BST, левое поддерево любого узла N может содержать только значения, не превышающие N, а правое поддерево — только значения, большие, чем N. zzBST хорошо подходят для поиска — если дерево сбалансировано, поиск может быть завершен посредством сравнения не более чем O(log(n)) элементов. zzКак правило, в BST с n узлами и высотой h все операции — вставка, уда­ ление, поиск, предшественник и преемник, максимум и минимум — вы­ полняются за время O(h). zzДля сбалансированных двоичных поисковых деревьев (BBST) высота h де­ рева гарантированно равна O(log(n)), поэтому все перечисленные выше операции выполняются за логарифмическое время. https://liveinternet.club
Словари и хеш-таблицы. Как создавать и использовать ассоциативные массивы 12 В этой главе 9 9 9 9 9 9 9 9 9 9 Каким образом АТД словаря оптимизирует индексацию Реализация словаря на основе уже известных нам структур данных Знакомство с новой структурой данных, которая кардинально меняет представление о словарях, — хеш-таблицы Как работает хеширование Сравнение методов цепочек и открытой адресации — двух стратегий разрешения конфликтов До сих пор мы обсуждали структуры данных, позволяющие извлекать хранящиеся в них данные исходя из расположения элементов. В массивах и связанных списках можно обращаться к элементам, ориентируясь на их позиции в структуре. В стеках и очередях следующий элемент, который можно извлечь, находится в конкретной позиции. Сейчас мы познакомимся со структурами данных, основанными на ключах, — иногда они называются ассоциативными массивами (associated array). В этой главе мы также рассмотрим словарь (dictionary) — воплоще­ ние абстрактных типов данных, основанных на ключах, после чего обсудим стратегии эффективной реализации выборки элементов по ключу. https://liveinternet.club
244  Глава 12. Словари и хеш-таблицы Проблема словаря Наш юный друг Марио серьезно подходит к коллекционированию бейсбольных карто­ чек. А знаете, что ему нравится больше всего? Обмениваться карточками с друзьями! У Марио хорошая память, но теперь, с сот­ нями карточек, он уже с трудом помнит, какие карточки у него уже есть, а ка­ ких еще не хватает. Тем более что, когда он меняется карточками с друзьями, у него есть всего несколько секунд, чтобы застолбить карточку, пока она не досталась кому-то другому. Чтобы быть быстрее всех, Марио пригодилось бы мобильное приложение, которое сканирует карточки камерой и за доли секунды определяет, есть ли у него уже такая, и если да, то сколько штук. Итак, ядро этого приложения (помимо UX1 и распознавания объектов) — это как раз то, что делает словарь. Оно сохраняет данные по определенному ключу — с бейсбольными карточками это могут быть имена игроков или даже просто фото карточек — и позволяет искать данные по ключу. В нашем примере ключи можно связать с такими атрибутами, как количество уже имеющихся копий этой карточки, или с ее специфическими особенностями: информация о команде, статистические данные и т. п. Удаление дубликатов Другое распространенное применение словарей — удаление дубликатов из коллекции. Допустим, мы хотим удалить дубликаты из массива. С учетом того, что мы уже узнали, мы, в норме, отсортируем массив и затем, когда проскани­ руем его, обнаружим дубликаты рядом друг с другом. Основные затраты этого метода приходятся на сортировку, которая выполняется за время O(n*log(N)). Представим, что у нас есть волшебный черный ящик — словарь D, кото­ рый может подсказать, видели ли мы уже когда-нибудь тот или иной объект. С его помощью можно отфильтровать дубликаты из коллекции C. Идея в том, что можно начать с пустого словаря, а затем идти по списку элементов и добавлять каждый пункт c одновременно в D и во вспомогатель­ ную коллекцию tmp, если только не окажется, что c уже есть в словаре. Если есть, то мы понимаем — у нас дубликат: tmp = [] for c in C: if not c in D: D.add(c) tmp.append(c) C = tmp 1 User experience — пользовательский опыт. — Примеч. ред. https://liveinternet.club
Проблема словаря  245 В Python для этой цели можно использовать set — особую разновидность словаря, которая хранит только элементы, без привязки к ним какого бы то ни было значения. Аналогичным примером может послужить подсчет количества вхождений каждого элемента в коллекцию: counters = {} for c in C: counters[c] = counters.get(c, 0) + 1 Это сокращенный синтаксис, допустимый в Python, но он — эквивалент проверке, есть ли в словаре ключ c, с последующим извлечением и увеличе­ нием связанного (ассоциированного) с ним значения или инициализацией значения, связанного с новым ключом, числом 1. Вот вопрос, который я ожидаю от вас: что лучше — использовать словарь или сортировать, когда удаляем дубликаты? Ну, в основном это зависит от затрат на проверку словаря на наличие элемента и добавление новых элементов. Если хотя бы одна из операций выполняется за время, большее O(log(n)), версия со словарем будет более затратной. Если обе операции требуют времени менее логарифмического, отлично! Производительность словаря зависит от его реализации, и мы поговорим об этом дальше в этой главе. А пока давайте сосредоточимся на абстрактном типе данных: на том, что позволяет делать словарь, а не на том, как это делается. Словарь как абстрактный тип данных При описании интерфейса словаря необходимо учесть следующие три ме­ тода: zzвставка нового значения; zzполучение значения, связанного с ключом, если оно есть; zzудаление какого-либо значения или значения, связанного с ключом. Словарь insert(value) search(key) Состояние: набор значений и связанных delete(key) с ними ключей В самом распространенном определении интерфейса словаря сохраняются значения, с которыми можно связать ключи. Для некоторых типов значений (например, целых чисел) ключом становится само значение. Ключи могут вычисляться по значениям, для чего к ним применяется свободная ­функция. https://liveinternet.club
246  Глава 12. Словари и хеш-таблицы В Python идеальным кандидатом для этой цели становится встроенная функция hash. Или, если мы работаем с объектами, у объекта будет свой собственный метод возврата ключа. Исходя из этого метод insert получает полное значение, которое должно быть добавлено в словарь, тогда как метод search получает только ключ (он, как предполагается, меньше значения) и извлекает связанное с ним значение. Однако метод delete должен получать либо ключ, либо полное значение, либо ссылку на полное удаляемое значение. Возможен и другой вариант этого API, когда мы явно связываем ключи и значения, передавая два разных значения и храня их отдельно друг от друга. В частности, словарь Python работает по этой схеме. Словари могут также предоставлять дополнительные методы — напри­ мер, методы получения наименьшего и наибольшего хранящихся ключей или метод, который по заданному ключу получает его предшественника и преемника. Однако эти методы не являются частью базового интерфейса словарей, поэтому их не всегда можно найти. Дело в том, что они обычно предоставляются только с определенными реализациями АТД словаря, в ко­ торых они легко реализовываются и быстро выполняются. Структуры данных, реализующие словари Какие из структур данных, о которых мы уже говорили, можно использовать для реализации словаря? Немного подумайте над этим вопросом, а затем давайте вместе обсудим ответ. Итак, нам нужно иметь возможность вставлять новые элементы, а также извлекать и удалять любые элементы, хранящиеся в словаре. Эти требова­ ния исключают стеки, очереди и приоритетные очереди, поскольку то, что они могут извлекать и удалять, зависит от порядка вставки или приоритета. Итак, остаются массивы, связанные списки и двоичные деревья поиска — все они поддерживают эти три операции. Будем исходить из того, что во всех этих вариантах ключи и значения явно сохраняются попарно. Массив Вставка работает с ходу. Мы создаем пару (key, value) и сохраняем ее в мас­ сиве простым методом array.insert. Что произойдет, если вставить две пары с одинаковыми ключами, но разными значениями? Обычно словарь позволяет связать с каждым уникальным ключом только одно значение. Тем не менее, если мы допускаем только одно значение для каждого уникального ключа, необходимо изменить процедуру вставки, чтобы она сначала про­ веряла, нет ли уже такого ключа. https://liveinternet.club
Структуры данных, реализующие словари  247 Чтобы удалить ключ, необходимо сначала провести специальный поиск, чтобы найти пару, ключ которой совпадает с аргументом. Ключ Значение Joe Di Maggio Jackie Robinson 0 1 Barry Bonds Mike Schmidt 2 3 Аналогичным образом, когда речь заходит о search, мы просто передаем ключ как аргумент, и нужно просканировать массив, чтобы найти пару, первое значение в которой совпадает с ключом. Использовать можно как отсортированные, так и несортированные мас­ сивы. Первый вариант ускоряет поиск, но вставка выполняется за линейное время, а второй обеспечивает вставку с постоянным временем — если не нужно проверять дубликаты — при медленном поиске. Связанный список Многие принципы, упоминавшиеся в связи с массивами, также применимы и к связанным спискам. Обычно удобнее использовать двусвязные списки, чтобы delete было более эффективным. И снова можно выбирать между отсортированными и несортированными списками, не считая того, что списки, если вы еще не забыли, не поддерживают двоичный поиск, так что использование отсортированной версии никаких реальных преимуществ не дает. Сбалансированное двоичное дерево поиска Мы только что открыли для себя в предыдущей главе сбалансированные двоичные деревья поиска, и в данном случае это неплохой вариант! Все операции, которые нам нужно выполнять со словарем (включая такие, как max), со сбалансированным деревом могут выполняться за логарифмическое время. Надо быть осторожными насчет дубликатов, как и с двумя другими структурами данных, но этот вариант гарантирует наиболее сбалансирован­ ную производительность за счет некоторого объема дополнительной памяти. Сводка В таблице 12.1 приведено время, которое требуется каждой из рассмотрен­ ных в этой главе реализаций, на основные операции со словарями. Я вклю­ чил столбец для учета времени, необходимого для создания каждой струк­ туры данных на основе коллекции из n элементов. Эти затраты необходимо https://liveinternet.club
248  Глава 12. Словари и хеш-таблицы учитывать, и они не всегда совпадают с затратами на n вставок (помните метод heapify из главы 10?). Таблица 12.1. Время выполнения операций, которое требуется разным реализациям словаря Вставка Удаление Поиск Инициализация с n элементами Несортированный массив O(1) O(n) O(n) O(n) Отсортированный массив O(n) O(n) O(log(n)) O(n*log(n)) Несортированный двусвязный список O(1) O(n) O(n) O(n) Отсортированный двусвязный список O(n) O(n) O(n) O(n2) Сбалансированное двоичное поисковое дерево O(log(n)) O(log(n)) O(log(n)) O(n*log(n)) При анализе для таблицы 12.1 я исходил из того, что добавляемые ключи не проверяются (иначе время выполнения insert никак не может быть меньше времени выполнения search), а delete принимает удаляемый ключ в качестве аргумента и поэтому сначала должен его найти. Как и предполагалось, сбалансированные двоичные деревья поиска об­ ладают наилучшей средней производительностью с учетом всех операций. Хеш-таблицы В предыдущем разделе были перечислены известные варианты и приведе­ ны некоторые ключевые выводы из предшествующих глав. Пришло время сделать следующий шаг и подумать о чем-то совершенно новом, что меняет правила игры. Этот раздел описывает новую структуру данных и обсуждает, как она работает, реализуя АТД словаря. Думаете, O(log(n)) — это хорошо? Подумайте еще раз! Вы не поверите, чего мы сможем добиться. Новый способ индексации Массивы не гарантируют выдающейся производительности при выполнении операций со словарями, потому что с индексированием на основе ключей теряется главное преимущество массива: постоянное время доступа по индексу. Как же использовать это колоссальное преимущество? Давайте вернемся к Марио и его коллекции бейсбольных карточек. https://liveinternet.club
Хеш-таблицы  249 Допустим, его коллекция статична, в ней фиксированное количество кар­ точек, и для упрощения будем считать, что среди них нет дубликатов. Если коллекция состоит из n карточек и ее состав никогда не изменяется, теоре­ тически с каждой карточкой можно связать целое число от 0 до n-1. Звучит знакомо? Это целое число можно использовать в качестве индекса массива. Но как связать этот индекс с каждой карточкой? Пока давайте представим, что у нас есть функция-оракул — некий «черный ящик», который выдает правильный индекс, когда мы скармливаем ему карточку. 0 3 Например, этого оракула можно спросить, какой индекс связан с карточкой Джо Ди Маджо, и она выдаст ответ: 3. И мы знаем, что карточку можно со­ хранить в четвертой ячейке массива (под индексом 3) и тот же индекс можно использовать, чтобы получить эту карточку, когда будем ее искать. Обратите внимание: при этом придется использовать массив немного не так, как было показано в главе 2. Элементы, хранящиеся в массиве, не будут выровнены по левому краю, а их позиции не будут определяться порядком вставки. Mike Schmidt Joe Di Maggio Значение Ключ 0 1 Пустая ячейка 2 3 4 Такая структура данных называется таблицей прямого доступа (directaccess table). Хранящиеся в ней элементы будут разбросаны по всей памяти массива, и между элементами могут оказаться пустые ячейки. По этой при­ чине нам также понадобится способ отслеживать, какие ячейки заполнены, а какие пусты. Ничего сложного: можно просто сохранить None, null или любое другое специальное значение, которое ваш язык программирования предлагает, чтобы закодировать отсутствие значения. При определенных условиях, которые мы опишем далее в этом разделе, такая функция-оракул будет называться функцией индексирования. Конечно, то, что я описываю здесь, это более чем идеальная ситуация, и вскоре мы обозначим все ее ограничения. Но если бы мы могли восполь­ зоваться этим решением, его производительность была бы на порядки выше всего, что мы обсуждали в предыдущем разделе. Как только у нас будет ин­ https://liveinternet.club
250  Глава 12. Словари и хеш-таблицы декс, предоставленный функцией-оракулом, все операции: поиск, вставка и удаление элемента — будут выполняться за постоянное время! Слишком хорошо, чтобы быть правдой? Ну да, к сожалению, так и есть. Затраты на индексирование Прежде всего одна важная деталь, которую мы упустили: каких затрат по­ требует функция индексирования? Чтобы это понять, давайте подробнее разберем, как в массиве проходит операция преобразования данных кар­ точки в ее индекс. Прямое индексирование Извлечь ID Joe Di Maggio Преобразовать в целое число 1119 Ключ С таблицами прямого доступа ключ объекта становится его индексом в мас­ сиве. Тем не менее этот ключ еще нужно вычислить. Начинаем с полного объекта — бейсбольной карточки (или ее цифрово­ го эквивалента) — и извлекаем из него уникальный идентификатор. Этот идентификатор (ID) может быть целым числом — в таком случае задача решена. Впрочем, чаще ID представляет собой некую строку, и нам понадо­ бится дополнительный этап ее преобразования в целое число. Сделать это совсем несложно. Например, можно преобразовать каждую букву в целое число, получив ее значение в ASCII или Юникоде, а затем сложить все зна­ чения. Именно это и показано на рисунке. Однако у этой формулы есть одна большая проблема: все анаграммы предложения генерируют одно и то же значение, потому что при суммировании не учитывается расположение букв. Так что прощайте, уникальные ID! Более эффективная формула умножает значение каждой буквы на число, определяемое ее позицией. Например: id = 0 for c in 'Joe Di Maggio': id = id + ord(c) id *= 256 Этот код интерпретирует ASCII-строку как число 256-ричной системы счис­ ления. Здесь мы просто преобразуем «число» 256-ричной системы, 'Joe Di Maggio', в целое число десятеричной системы. Как видите, для перехода от элемента, который мы хотим сохранить, к его индексу в массиве существуют промежуточные шаги, которые могут https://liveinternet.club
Хеш-таблицы  251 потребовать дополнительных затрат, например перебор строки. Мы можем вынести это за скобки, предположив, что функция индексации занимает время O(k), где k — некоторое значение, зависящее от элементов, которые мы хотим сохранить. Это значение k обычно не зависит от количества эле­ ментов, которые мы храним, и если оно может быть ограничено константой (например, если все имена содержат не более 50 символов), можно считать, что операция выполняется за постоянное время. Но не забывайте, что из­ влечение ключей тоже сопряжено с затратами. Проблемы, связанные с идеальной моделью Затраты на функцию индексирования — всего лишь вершина айсберга. Наше предположение, что коллекция карточек статична и неизменяема, создает еще более серьезную проблему. Как нетрудно представить, такое решение недолговечно: каждый год выпускаются новые карточки. А если вместо бейс­ больных карточек взять книги из интернет-магазина, ситуация становится еще хуже, потому что каталог может измениться в любой момент. Чтобы решить эту проблему, необходимо создать массив, достаточно большой, чтобы разместить все возможные ключи всех возможных по­ зиций. Если вычислять индекс по именам игроков, интерпретируемых как числа 256-ричной системы счисления, то для 'Joe Di Maggio' мы получим индекс порядка 1029. Даже если бы было возможно создать массив такой величины, у нас получился бы огромный массив, который в основном оста­ вался бы пустым. Давайте проиллюстрируем это некоторыми цифрами. Допустим, количество всех возможных комбинаций имен на бейсбольных карточках имеет порядок 264, то есть около 20 миллиардов миллиардов. За все время в бейсбольной высшей лиге играло максимум 20 000 игроков, и предполагая, что карьера игрока может длиться 20 лет, можно сделать вывод, что всего было выпущено менее 400 000 уникальных бейсбольных карточек. Даже если Марио сможет купить по одной копии каждой карточки, ко­ торая когда-либо была напечатана, они заполнят менее 0,000000000002 % массива с емкостью 264 элементов и около 0,009 % более скромного массива, выделенного для 232 элементов. Другими словами, это непомерные траты памяти. Использовать массив в качестве таблицы прямого доступа можно только в очень специфических ситуациях, когда мы можем наложить ограничения на размер и состав на­ бора хранящихся элементов. Однако в этой идее есть и кое-что толковое. Вероятно, не все должно кануть в бездну. https://liveinternet.club
252  Глава 12. Словари и хеш-таблицы Хеширование Чтобы идея сработала, нам нужно привнести нечто новое. Наша самая большая проблема с таблицами прямого доступа в том, что пространство для индексации обычно слишком велико, а мы не можем позволить себе выделять память под столь крупные массивы. По сути, нам нужно предусмотреть массив размером m << |keys| — контейнер, который может вместить то количество элементов, которое мы ожидаем получить, а не все возможные элементы, которые могли бы когда-либо существовать. В нашем примере Марио хочет создать для своих бейсбольных карточек массив примерно в тысячу элементов, а не миллиардный. Но если емкость используемого массива будет меньше количества воз­ можных ключей, то уже не получится использовать ключи как индексы. Нам понадобится переосмыслить процесс вычисления индекса исходя из харак­ теристик объекта и придется добавить промежуточный шаг, который всегда будет наделять объект допустимым (валидным) индексом. Хеширование Извлечь идентификатор Joe Di Maggio Преобразовать в целое число 1119 Хеш Проиндексировать 3 Ключ Этот шаг называется хешированием (hashing), а массив, который мы ис­ пользуем для хранения элементов, проиндексированных посредством хеширования, — хеш-таблицей (hash table). В широком смысле слова термин «хеширование» может описывать весь процесс перехода от объ­ екта к его индексу. Иначе говоря, хеш-функция может принимать весь объект в качестве входных данных и выдавать на выходе его допустимый индекс. Но важнейший шаг, на котором и происходит собственно хеширова­ ние, — это переход от произвольного целочисленного идентификатора к до­ пустимому для нашей хеш-таблицы индексу. Хеш-функции Каковы свойства хеш-функции? И что делает хеш-функцию хорошей? Это ключевые вопросы, на которые необходимо ответить для реализации хеш-таблицы. Требования к хеш-функциям зависят от контекста, в част­ ности от специфики предназначенных для хранения значений и размера хеш-таблицы: https://liveinternet.club
Хеширование  253 zz Областью определения (доменом) хеш-функции должно быть множество всех возможных ключей. Конечно, специфика входных значений зависит от контекста. Однако предназначенные для хранения элементы всегда можно преобразовать в целые числа, так что можно сказать, что областью опре­ деления хеш-функции общего назначения является весь ряд целых чисел. zzХеш-функция должна возвращать допустимый индекс. Если размер хештаблицы равен m, то хеш-функция, связанная с таблицей, должна выдавать на выходе целое число от 0 до m-1. 1 Понять, что делает хеш-функцию хорошей, немного сложнее. Теоретически желательным свойством хеш-функций является равномерность: каждый элемент должен с одной и той же вероятностью хешироваться в любую из m ячеек хеш-таблицы — независимо от того, в какую ячейку был или будет захеширован любой другой элемент. К сожалению, равномерности трудно добиться: элементы часто распределяются не независимо, и трудно ее про­ верить, потому что мы обычно не знаем, как распределяются ключи. Домен Индексы Ключи шХе кция н фу В таких случаях лучшее, что мы можем сделать, — это спроектировать эври­ стики, которые показывают достаточно хорошие результаты, даже если эти результаты далеки от единообразия. При проектировании таких эвристик следует убедиться в том, что их выходные данные никоим образом не зависят от каких бы то ни было закономерностей, которые могут обнаружиться во входных данных. 1 Область определения, или домен (domain), функции — это множество всех допустимых для данной функции входных значений, которые она может принять и обработать. — Примеч. ред. https://liveinternet.club
254  Глава 12. Словари и хеш-таблицы Метод деления В главе 9 при обсуждении циклических очередей мы ввели понятие вирту­ ального адресного пространства и обсудили, как обеспечить циклический переход к началу очереди, когда указатели на ее начало или конец выходят за пределы массива. Метод деления работает аналогичным образом. При заданной хештаблице размера m для любого целого ключа k мы вычисляем индекс, под которым мы сохраним k, при помощи хеш-функции h(k) = k % m, которая является остатком от деления k на m. Этот метод прост, но за внешней простотой кроются некоторые сложно­ сти. Например, если выбрать m равным двойке в степени, m = 2p, где p — не­ которое положительное целое число, мы окажемся в затруднении. Проблема в том, что результат k % 2p в точности равен p младших битов k. Если мы не уверены, что распределение младших p битов ключей является равномерным, то нам следует тщательно выбирать значение m, которое обозначает размер таблицы. Эмпирическое правило: когда мы применяем метод деления, лучшим вы­ бором для m будет простое число, не слишком близкое к степеням двойки. Поиск простых чисел — не самая простая операция, так что мы можем быть открыты для разных альтернатив. Метод умножения Если мы хотим больше свободы в выборе размера хеш-таблицы или у нас нет права голоса при этом выборе, мы можем прибегнуть к другому методу вычисления хеш-функции. Метод умножения — эффективная альтернатива, но он и более сложен в вычислении. Первое, что необходимо сделать, — это выбрать действительное число, константу A, и умножить на нее наш входной ключ k. Второй шаг — взять дробную часть этого произведения, то есть мы вычисляем (k*A) % 1. На этом этапе можно сделать вывод, что не все варианты A одинаково хороши. Например, целые числа — ужасный выбор, потому что итоговое значение всегда будет равно 0. Сложность в том, что оптимальное значение A зависит от характеристик хешируемых данных. Однако всегда можно последовать совету Дональда Кнута и использовать A = (math.sqrt(5)-1)/2, что должно хорошо работать в большинстве ситуаций. Но это еще не все! Теперь нужно умножить полученное действительное число на m, размер таблицы, а затем взять целую часть результата, которая будет целым числом от 0 до m - 1. https://liveinternet.club
Разрешение конфликтов  255 Версия функции h на языке Python: h = lambda k: math.floor(m * ((A * k) % 1)) Как я упоминал, выбор m некритичен для этого метода. В отличие от метода деления, здесь часто используется двойка в степени, потому что это позво­ ляет несколько оптимизировать вычисление h. По сравнению с методом деления у этого метода есть еще одно востребо­ ванное свойство: близкие друг к другу ключи в итоге преобразуются в далеко отстоящие друг от друга индексы. Это важно для равномерного распреде­ ления нагрузки по таблице, и в следующем разделе мы обсудим, почему это критически важно. Конечно, есть и другие способы вычисления хеш-функции, но для наших целей достаточно и этих. А теперь пора заняться «слоном в таблице». Разрешение конфликтов Когда я знакомил вас с хеш-таблицами, то упоминал, что емкость используе­ мого массива меньше количества возможных ключей, и поэтому ключи уже невозможно использовать в качестве индексов, потому что какой-нибудь ключ может оказаться больше самого большого индекса хеш-таблицы. Ключи 0 1000 1001 1 2 3 4 5 1000000 4294967295 ш- я Хе кци н фу Индексы 0 1 2 3 4 5 6 255 У различий в размерах есть еще последствие, о котором я не упомянул, — хеш-функция будет отображать как минимум два ключа на один индекс. Это следует из так называемого принципа голубиных гнезд1. 1 Смысл принципа голубиных гнезд (pigeonhole principle), или принципа Дирихле, — в том, что если объектов больше, чем ячеек для их хранения, то как минимум в одной ячейке окажется более одного объекта. — Примеч. ред. https://liveinternet.club
256  Глава 12. Словари и хеш-таблицы Если вы рассаживаете пять голубей по четырем гнездам, то хотя бы в од­ ном ящике будут два голубя. Также может быть более одного гнезда с двумя голубями или гнезда с более чем двумя голубями. Таким образом, в хеш-таблице в какой-то момент два ключа будут отобра­ жаться на одну ячейку массива. Когда такое случается, мы называем это конфликтом. Что можно сделать в таких ситуациях? Как разрешать такие конфликты? Ой! И что теперь делать? 0 1 2 3 4 Есть две основные стратегии: метод цепочек (chaining) и открытая адресация (open addressing). Это принципиально разные методы, каждый со своими достоинствами и недостатками. Рассмотрим их более подробно. Метод цепочек Первый способ разрешить конфликт — позволить хранить несколько эле­ ментов в одной ячейке. У нас, конечно, не получится просто сделать ячейку массива больше и сохранить в ней более одного значения. Придется действо­ вать более изобретательно. Вместо того чтобы хранить значения прямо в ячейках массива, в каждой ячейке хранится голова связанного списка, называемого цепочкой хеширования (hash chain). Когда новый элемент x хешируется в i-ю ячейку, мы извле­ каем цепочку хеширования, на которую указывает эта ячейка, и вставляем x в ее начало. Если хотим избежать дубликатов, можно, наоборот, добавлять новые элементы в хвост списка после того, как обойдем весь список и удо­ стоверимся, что x в нем отсутствует. Какую разновидность связанного списка следует использовать? Как мы обсуждали в главе 5, если требуется удалять элементы из произвольных https://liveinternet.club
Разрешение конфликтов  257 позиций, лучше всего подходят двусвязные списки. Однако у нас не будет ссылки на удаляемый узел, так что единственным отличием от варианта с односвязными списками будет усложнение кода удаления элемента. Поскольку мы уже реализовали обе разновидности списков, я применил односвязные списки и метод умножения для хеш-функции. Чтобы вычис­ лить индекс значения, применяем метод умножения к ключу, связанному с этим значением. Внутри класса для извлечения ключа из любого объекта по умолчанию используется встроенный hash-метод. Впрочем, ничто не ме­ шает перенастроить способ извлечения ключей при инициализации класса: Константа для метода class HashTable: умножения, определенная __A__ = Decimal((sqrt(5) - 1) / 2) как свойство класса def __init__(self, buckets, extract_key=hash): self._m = buckets self._data = [SinglyLinkedList() for _ in range(buckets)] self._extract_key = extract_key def _hash(self, key): return floor(self._m*((Decimal(key) * HashTable.__A__)%1)) Это бˆольшая часть кода, который необходимо написать для реализации хештаблицы. Дальше я покажу, что каждый из ее методов занимает всего пару строк, потому что для выполнения всей черной работы можно воспользо­ ваться методами класса SinglyLinkedList. https://liveinternet.club
258  Глава 12. Словари и хеш-таблицы Насколько же эффективна хеш-таблица с цепочками? Чтобы понять это, нужен немного иной подход к асимптотическому анализу, не тот, что был у нас до сих пор. Допустим, у нас имеется хеш-таблица с m ячейками, в ко­ торых уже хранятся n элементов. Исходим также из того, что вычисление хеша для ключа выполняется за время O(1) и нас не волнует, есть ли у нас дубликаты. Для нашего анализа ключевым фактором является размер цепочек хе­ ширования. Но если точное распределение ключей заранее не известно, то можно рассуждать только на уровне средних значений. Можно предпо­ ложить, что в среднем с каждой ячейкой массива будет связано n/m ключей. Что касается вставки, то нам повезло: если мы вставляем новые элементы в начало списков, то метод insert особенно эффективен и выполняется за постоянное время, независимо от значений m и n: def insert(self, value): index = self._hash(self._extract_key(value)) self._data[index].insert_in_front(value) Как насчет метода search? Как было сказано выше, средний список содержит элементов, и мы можем использовать только линейный поиск, так что среднее время выполнения search составляет O(1 + n/m). Но если нам очень сильно не повезло (или мы недостаточно осторожны, как вскоре увидим), все ключи могут выпасть на одну ячейку. Следовательно, в наихудшем случае время выполнения поиска составит O(n): n/m def _search(self, value): index = self._hash(key) value_matches_key = lambda v: self._extract_key(v) == key return self._data[index].search(value_matches_key) В коде search используется специальный метод поиска для связанных спи­ сков, который получает предикат как свой единственный аргумент и воз­ вращает первый элемент, относительно которого предикат возвращает True. Помните: чтобы весь этот класс работал, ключи должны быть уникальными идентификаторами значений. Удаление элемента может выполняться за постоянное время для двусвяз­ ных списков, но только если у вас имеется ссылка на удаляемый узел списка. В противном случае оно занимает столько же времени, что и поиск. И хотя в источниках часто говорится, что метод delete получает ссылку на позицию удаляемого значения, я рекомендую избегать этого, поскольку такой вариант нельзя назвать ни чистым, ни безопасным. Таким образом, можно выбирать между удалением по ключу и удалением по значению. Ради экономии места я показываю только версию с удалением по значению, но обе https://liveinternet.club
Разрешение конфликтов  259 они требуют сначала выполнить поиск и в среднем выполняются за время O(1 + n/m): def delete(self, value): index = self._hash(self._extract_key(value)) self._data[index].delete(value) Полный код класса HashTable доступен на GitHub: https://mng.bz/jXRP. В общем случае перебор всех элементов хеш-таблицы выполняется за O(n+m) шагов, потому что нам придется обойти по крайней мере все ячейки массива, даже если связанные списки, на которые они указывают, пусты. А если нас интересует поиск минимума или максимума в таблице? В та­ ком случае необходимо просканировать всю таблицу, так что время выпол­ нения также равно O(n+m). Аналогичные рассуждения справедливы и в том случае, если хотим найти преемника или предшественника элемента. Таблица 12.2 подытоживает данные по времени выполнения основных методов хеш-таблицы с цепочками. Таблица 12.2. Время выполнения операций реализации АТД словаря (с дубликатами) на базе хеш-таблицы Вставка Удаление Поиск Инициализация с n элементами Метод цепочек (в среднем) O(1) O(1+n/m) O(1+n/m) O(m+n) Метод цепочек (наихудший случай) O(1) O(n) O(n) O(m+n) Открытая адресация Метод цепочек — не единственный способ разрешить конфликты хеширо­ вания. Если мы хотим обойтись без составных структур данных и хранить элементы непосредственно в таблице, мы можем выбрать другой подход. При открытой адресации мы можем для каждого ключа прозондировать (probe) в определенном порядке все m ячеек массива, пока либо не найдем искомое (элемент или пустую ячейку), либо не прозондируем все ячейки. В каком-то смысле в результате конфликта нам выпадает повторная попытка, второй шанс (третий шанс и т. д.). Чтобы зондирование было возможно, мы расширяем хэш-функцию, что­ бы она принимала два аргумента: хешируемый ключ и количество уже пред­ принятых попыток. Давайте объясню, как это работает. Допустим, мы хотим вставить новый элемент, целочисленный ключ которого 714. Мы вычисляем p(714,0)=3, затем проверяем ячейку 3 и обнаруживаем, что под индексом 3 уже сохранен другой элемент — с ключом, скажем, 423. https://liveinternet.club
260  Глава 12. Словари и хеш-таблицы Но мы не сдаемся! Вычисляем p(714,1)=1 и зондируем другую ячейку, под индексом 1: к сожалению, она тоже недоступна. Попробуем снова: p(714,2) = 6, и под индексом 6 обнаруживается пустая ячейка. Теперь можно сохранить наш элемент — и готово. h( 0 h( ,1)=1 1 2 ,0)=3 3 h( 4 5 ,2)=6 6 7 Поиск работает точно так же, но с одной важной оговоркой: в момент, когда хеширование приводит нас к пустой ячейке, мы понимаем, что поиск ока­ зался безуспешным. В противном случае проверяем найденное значение, и, если оно совпадает с искомым, нам повезло. В противном случае знаем, что нужно попытаться снова. Конечно, хеш-функция должна быть спроектирована так, чтобы для любого возможного ключа k, [p(k,i) for i in range(0,m)] в ней были все возможные индексы хеш-таблицы. Другими словами, последовательность <p(k,0), p(k,1),…,p(k,m-1)> должна быть перестановкой последовательности <0,…,m-1> для любого k. Для заданной действительной хеш-функции h два наиболее часто ис­ пользуемых варианта функции зондирования — линейное зондирование (linear probing) с p(k,i) = (h(k)+i) % m и квадратичное зондирование (quadratic probing), где p(k,i) = (h(k) + a*i + b*i2) % m для некоторых констант a, b. Проблемы с открытой адресацией По сравнению с методом цепочек у открытой адресации есть одно значитель­ ное преимущество: вы не тратите память на связанные списки, и потребуется лишь минимум расходов на массивы. Тем не менее есть здесь и множество недостатков. zzМетод цепочек позволяет хранить неограниченное количество элементов, тогда как открытая адресация использует статический массив, фиксируя емкость хеш-таблиц при инициализации (n ≤ m). zzЛинейное и квадратичное зондирования часто приводят к появлению скоплений элементов, длинных цепочек, которые приходится перебирать при поиске и вставке, что замедляет эти операции. Квадратичное зонди­ рование работает чуть лучше, но для заданного размера таблицы m не все https://liveinternet.club
Разрешение конфликтов  261 комбинации a и b допустимы (формула должна возвращать допустимую перестановку индексов). zzОткрытая адресация усложняет удаление элементов. Если ячейку просто оставить пустой, это нарушит поиск. Вернемся к нашему примеру: если после вставки элемента x под индекс 6 мы удалим элемент под индексом 1, оставив пустую ячейку, новые поиски x могут преждевременно прекра­ титься, наткнувшись на пустую ячейку под индексом 1. Можно использовать специальное значение для удаленных элементов, но тогда при поиске мы будем посещать больше элементов, чем фактически хра­ нится, замедляя его. А иначе надо отключить удаление элементов, но тогда мы быстро заполним таблицу и нам придется выделять таблицу покрупнее, даже если в этом нет нужды. Короче говоря, если вам нужно удалять элементы, следует использовать метод цепочек. Риски, связанные с хешированием Хеш-таблицы предоставляют значительное усовершенствование по сравне­ нию с другими реализациями АТД словаря. Еще чуть-чуть — и покажется, что это слишком хорошо, чтобы быть правдой. Но следует помнить, что в структурах данных, как и в жизни, не бывает роз без шипов (зато обилие шипов без роз встречается сплошь и рядом). Первое, что необходимо помнить, — хотя реализации insert , search и delete могут наиболее эффективно выполняться с хеш-таблицами, другие операции — maximum, minimum, successor и predecessor — выполняются бы­ стрее, когда вместо них используется BST. Впрочем, если действовать неосторожно, могут возникнуть еще более серьезные проблемы. Скажем, версия метода цепочек, представленная выше, вставляет элементы в начало связанных списков и ради максимальной эффективности не проверяет их на наличие дубликатов. Если мы решили проверить, нет ли в списках дубликатов, или если по какой-то причине хо­ тим хранить связанные списки отсортированными, тогда вставка начинает выполняться за линейное время, как показано в таблице 12.3. Таблица 12.3. Время выполнения для хеш-таблицы, когда дубликаты недопустимы Вставка Удаление Поиск Инициализация с n элементами Метод цепочек (в среднем) O(1+n/m) O(1+n/m) O(1+n/m) O(m+(n/m)2) Метод цепочек (наихудший случай) O(n) O(n) O(n) O(m+n2) https://liveinternet.club
262  Глава 12. Словари и хеш-таблицы Бывают ситуации, когда нельзя допустить дублирования, и это усугубляет проблему, которую я опишу далее. В частности, обратите внимание, как по­ строение таблицы становится квадратичной операцией. Половина проблем с хеш-таблицами связана с тем, что при очень хороших средних показателях производительность наихудшего случая плохая — хуже, чем у реализации на BST. Вторая половина проблем в том, что, если не предпринять контрмер, клиент может намеренно устроить низкую производительность хештаблицы. В частности, если хеш-функция фиксирована и известна (или если ее можно воспроизвести), клиент может найти последовательности ключей, которые сводятся к одной и той же цепочке хеширования. Эта уязвимость была проэксплуатирована при атаке на хеш-таблицу, которую используют серверы для хранения HTTP-параметров, отправляемых с по­ мощью POST-запросов. Отправка миллионов параметров формы, которые, как известно, хеши­ руются в одну ячейку таблицы, замедлила обработку запроса до минуты — минуты, на протяжении которой одно ядро процессора было занято вы­ полнением этой задачи. Можете представить, как отправка сотен или тысяч таких запросов может привести к остановке сервера. Вы можете больше узнать об этой уязвимости по адресу https://lwn.net/ Articles/474912/, а исходная статья с подробным ее описанием доступна по адресу https://mng.bz/WEK1. Заметим, что уязвимость обусловлена не серверным кодом, а изначально присуща таким языкам программирования, как Perl, PHP, Python, Ruby, Java и JavaScript. Как же предотвратить такую атаку? Уязвимость происходит от детерминированной природы хеш-функции. Конечно, функция должна быть детерминированной для заданной таблицы и не может изменяться с каждой операцией. В противном случае таблица повредится. Однако создание хеш-функции, которая инициализируется одновременно с хеш-таблицей случайным элементом, может предотвратить эксплуатацию злоумышленниками предсказуемого сопоставления ключей с ячейками (key bucket mapping) хеш-таблицы. Этого недостаточно, так как атакующий все равно может угадать используемую хеш-функцию, но для устранения подобного риска разработаны более сложные решения. По этой причине важно понимать, как работают хеш-таблицы (и другие структуры данных, описанные в книге). Только понимая их внутреннее устройство, вы сможете осмысленно выбирать библиотеки, которыми бу­ дете пользоваться, проверять их особенности и убеждаться, что в них нет подобных уязвимостей. https://liveinternet.club
Итоги  263 Итоги zzСловарь — абстрактный тип данных контейнера для хранения элемен­ тов, которые можно искать или удалять по ключу. Словари использу­ ются повсеместно, от маршрутизаторов до баз данных типа «ключ — значение». zzДля реализации АТД словаря можно использовать несколько структур данных, описанных в этой книге, но именно сбалансированные двоичные деревья поиска обеспечивают наилучшую производительность по всем операциям. zzРеализация, использующая хеш-таблицы, обеспечивает лучшую среднюю производительность для операций insert, search и delete. zzТаблица прямого доступа представляет собой массив, в котором каждый ключ (целочисленный элемент) k хранится под индексом k, вследствие чего поиск по значению выполняется за постоянное время. Нецелочис­ ленные элементы сначала преобразуются в целые числа извлечением уни­ кального идентификатора. Таблицы прямого доступа слишком велики, чтобы такое решение было практичным. zzХеш-таблица — специальная разновидность массива, когда индекс (цело­ численного) элемента возвращается специальной функцией, которая называется хеш-функцией. Хеш-таблицы могут быть намного меньше диапазона хранящихся в них значений, вследствие чего они гораздо практичнее таблиц прямого доступа. zzТак как диапазон ключей хеш-таблицы может быть больше количества ячеек в таблице, мы не сможем избежать конфликтов, то есть ситуаций, когда два ключа отображаются в одной и той же ячейке массива. zzКонфликты могут разрешаться посредством метода цепочек или открытой адресацией. zzВ методе цепочек каждая ячейка таблицы содержит ссылку на связанный список, в котором хранятся элементы. Такие таблицы могут расти до не­ ограниченных размеров. zzПри открытой адресации каждому ключу соответствует своя комбина­ ция индексов таблицы. Если при вставке обнаруживается, что первый индекс уже занят, проверяется второй индекс и т. д. — точно так же, как при поиске. zzКоличество элементов, хранящихся в хеш-таблицах с открытой адреса­ цией, не может превышать количество ячеек. Удаление элементов в них усложнено, а их производительность снижается с ростом коэффициента заполнения. Поэтому они редко используются. https://liveinternet.club
264  Глава 12. Словари и хеш-таблицы zzСреднее время выполнения вставки, поиска и удаления в хеш-таблицах является постоянным. При этом в наихудшем случае время выполнения будет линейным. zzЕсли используемая хеш-функция детерминирована или ее легко угадать злоумышленникам, то они могут разработать последовательность ключей, которая чрезвычайно снизит производительность хеш-таблицы. Это при­ вело к появлению уязвимостей в серверах, написанных на разных языках программирования, включая Perl, PHP, Python, Ruby, Java и JavaScript. https://liveinternet.club
Графы. Обучаемся моделировать сложные взаимосвязи данных 13 В этой главе Определение графа 9 Обсуждение основных свойств графа 9 9 9 9 9 9 9 Оценка стратегий реализации графов: список смежности и матрица смежности Исследование обхода графа: поиск в ширину и поиск в глубину В последней главе мы рассмотрим граф — еще одну структуру данных, ко­ торая превосходит характеристики контейнера. Графы можно использовать и для хранения элементов, но это было бы сужением их возможностей, по­ скольку у них гораздо более широкий спектр применения. В этой главе мы дадим определение графам и обсудим некоторые из их важнейших свойств. После рассмотрения основ займемся реализацией гра­ фа. Напоследок кратко обсудим два метода обхода графа. https://liveinternet.club
266  Глава 13. Графы Что такое граф? Неудивительно, что первый вопрос, на который мы хотим ответить, звучит так: что такое граф? Есть много способов определения графов, от нефор­ мальных до формулировок из арсенала сухой теории. Я начну с такого: гра­ фы являются обобщением деревьев. Описывая деревья в главе 11, я говорил, что их можно использовать для моделирования иерархических взаимосвя­ зей. Графы позволяют моделировать более широкий спектр взаимосвязей. Например, структуру файловой системы или арифметическое выражение можно представить посредством деревьев, но деревья не подойдут для представления графа дружеских взаимоотношений или потока выполнения компьютерной программы. Для таких взаимосвязей потребуются графы. Определение Позднее в этом разделе мы вернемся к различиям между графами и деревья­ ми. А сейчас пора дать более формальное определение графа. Граф G можно определить как пару множеств: zzМножество вершин (set of vertices) V — это сущности, не зависящие друг от друга и уникальные. Множество вершин может иметь произвольный размер и даже быть пустым. zzМножество ребер (set of edges) E, соединяющих вершины; ребро опреде­ ляется парой вершин. Первая вершина называется начальной (source), а вторая — конечной (destination). Можно использовать запись G = (V,E), чтобы наглядно показать, что граф состоит из множества вершин V и множества ребер E. Исходя из примера на иллюстрации, мы можем записать следующее: G = ([v1, v2, v3, v4], [(v1,v2),(v1,v3),(v2,v4)]) Давайте рассмотрим еще несколько основных определений: zzРебро, начальная вершина которого совпадает с конечной, называется петлей (loop). https://liveinternet.club
Что такое граф?  267 zzПростые графы — графы без петель, в них любые две вершины соедине­ ны не более чем одним ребром. Для любой пары вершин u, v, где u ≠ v, может существовать (максимум) одно ребро — от u к v. zzВ мультиграфе две заданные вершины могут быть соединены любым ко­ личеством ребер. Спецификация как простых графов, так и мультиграфов может быть расширена, чтобы они допускали петли. zz С ребром может быть связано числовое значение. Такое значение называ­ ется весом (weight), а ребро называется взвешенным ребром (weighted edge). zzГраф является разреженным (sparse), если количество ребер в нем относи­ тельно невелико. В качестве ориентира — граф с n вершинами считается разреженным, если количество его ребер составляет O(n) или менее. zzГраф является насыщенным (dense), если количе­ 7.3 ство его ребер близко к максимально возможно­ 12 му, которое для простого графа с n вершинами 3.14 8 может быть не более O(n2). Граф дружеских отношений -0.5 В этом разделе мы вернемся к группе друзей, описанной в первой главе. На звероферме ажиотаж! Недавно появилась новая социальная сеть, и все животные теперь не отрываются от телефонов. Лев и Тигрица давно враж­ дуют, и теперь их соперничество перенеслось в цифровой мир. На ферме грядут выборы, и Тигрица хочет перехватить у Льва должность царя зверей (царицы). Для этого они с командой пытаются использовать социальные сети и построить граф дружеских взаимоотношений, чтобы разобраться во всех взаимосвязях. Они хотят понять, у кого больше фолловеров — у Ти­ грицы или у Льва, и определить, на каких животных стоит ориентироваться в избирательной кампании Тигрицы, чтобы перетянуть их голоса. Вершинами этого графа станут животные фермы. Ребра графа будут ото­ бражать дружеские взаимосвязи в социальных сетях. Так как нужно с чегото начать, в первой версии графа дружеских взаимоотношений вершинами будут Тигрица и советник ее предвыборныой кампании, а также лучший друг — Обезьяна. Направленные и ненаправленные графы После включения в граф Обезьяны следующим этой чести удостаивается Крокодил — IT-директор кампании Тигрицы. Крокодил как разработчик https://liveinternet.club
268  Глава 13. Графы задает хороший технический вопрос: какой граф им использовать — на­ правленный или ненаправленный? В направленном графе (directed graph) у ребер имеется направление: они идут от начальной вершины к конечной. Это означает, что если две вершины u и v соединены (только) ребром (u,v), то можно перейти от u к v, но невоз­ можно перейти от v непосредственно к вершине u. Социальные сети, такие как Twitter или Instagram, где можно подписывать­ ся на других пользователей и без взаимной подписки, проще всего представить посредством направленных графов. Другие приложения, которые лучше мо­ делировать с помощью таких графов — это дорожные карты (дороги бывают односторонними), приложения по организации хода работ и отслеживания каких бы то ни было процессов, — по сути, любые машины состояний. Все участники кампании Тигрицы — ее фолловеры в Twitter, но она не подписана ни на кого из них в ответ. Крокодил подписан на Обезьяну, по­ тому что Обезьяна — руководитель кампании. Подписан Подписан Подписан И наоборот — в ненаправленном графе (undirected graph) можно переходить по ребрам в обоих направлениях. Так, по ребру ненаправленного графа (u,v) можно перейти и от v к u. Связи в LinkedIn и дружба в Facebook — это двусторонние симметричные взаимоотношения, которые осуществляются посредством ненаправленных графов. Друг Друг Друг https://liveinternet.club
Что такое граф?  269 Можно заметить, что временами встречается описательный текст к реб­ рам. Эти надписи не следует путать с весом ребра. В нашем примере они приводятся просто для иллюстрации, но вообще метки, особенно в муль­ тиграфах, могут иметь смысл — например, они могут определять условия, которых следует придерживаться, чтобы перейти по определенному ребру, или действия, которые нужно предпринять при переходе по нему. Можно ли преобразовать направленный граф в ненаправленный и на­ оборот? Ненаправленное ребро (u ,v ) эквивалентно двум направленным ребрам (u,v) и (v,u). Таким образом, ненаправленный граф всегда может быть представлен направленными ребрами. Обратное же неверно: напри­ мер, направленный граф из этого раздела не эквивалентен никакому нена­ правленному графу. По этой причине в компьютерных представлениях графов обычно прак­ тичнее использовать направленные ребра. Независимо от фактического типа графа это дает больше гибкости. Циклические и ациклические графы В штабе избирательной кампании Тигрицы животные усердно работают над расширением графа своих фолловеров. Они только что добавили Зебру и Жирафа и заметили, что последний еще не подписался на Тигрицу (и об этом с ним еще предстоит немного побеседовать!). В графе есть еще кое-что интересное: я выделил три ребра, немного увеличив их толщину. Они проходят от Жирафа к Крокодилу, от Крокодила к Обе­ зьяне и от Обезьяны к Жирафу. Это, как вы, возможно, уже знаете, цикл. Давайте сделаем небольшой шаг назад. Мы определяем путь (path) в гра­ фе как последовательность одного и более ребер (v1, v2), (v2, v3)…(vn-1, vn), где для каждой пары соседних ребер в последовательности конечная вершина первого ребра совпадает с начальной вершиной следующего. Про­ ще говоря, путь представляет собой отсортированную последовательность ребер, которая позволяет обойти граф от вершины v1 к вершине vn. https://liveinternet.club
270  Глава 13. Графы Цикл (cycle) представляет собой путь, который начинается и заканчивает­ ся в одной вершине — в цикле v1 = vn. Внимательно присмотревшись к графу, можно заметить, что Крокодил→Обезьяна→Жираф — не единственный цикл в графе. Также существует меньший цикл между Обезьяной и Жирафом. Граф, в котором нет циклов, называется ациклическим (acyclic). Связные графы и связные компоненты Чтобы получить более полное представление о конкуренции, пора добавить в граф и Льва с друзьями. После включения всего нескольких контактов Льва в соцсетях кое-что стало очевидным: у Льва не тот стиль, что у Тигрицы. Он сам взаимно подписывается на своих фолловеров — видимо, старается создать ощущение более близких, тесных связей. Однако самое интересное в этом графе — это две крупные области, два больших скопления вокруг Тигрицы и Льва, причем безо всякой связи между этими двумя областями. Каждая из двух этих областей является связным компонентом (connected component), то есть подграфом, в котором все вершины соединены между собой. Позвольте привести несколько определений. Для заданного графа G = (V,E): zzПодграф G' = ( V',E') состоит из подмножества V' вершин исходного гра­ фа и подмножества E' ребер, соединяющих вершины подмножества V'. zzДве вершины u и v связаны, если существует путь от u к v. https://liveinternet.club
Что такое граф?  271 zzНенаправленный граф является связным, если все его вершины соедине­ ны между собой. У связного графа есть только один связный компонент. Направленный граф является слабосвязным (weakly connected), если нена­ правленный граф, полученный заменой направленных ребер ненаправлен­ ными, является связным. Но есть и более строгое определение связности. Две вершины u и v являются сильносвязными (strongly connected), когда в графе есть по меньшей мере один путь, ведущий от u к v, и путь, ведущий от v к u. В ненаправленном графе две соединенные вершины также сильно связа­ ны между собой. В направленном, напротив, это уже не так, и обычно важно определить его сильносвязные компоненты (strongly connected components), то есть максимальные подграфы: те, все вершины которых сильно связаны друг с другом. По нашему примеру мы видим, что, когда команда добавляет в граф Цы­ пленка, это делает граф слабосвязным: Цыпленок подписан только на Кроко­ дила, а на него самого подписана только Корова, и это уже не сильносвязный граф. Вместо этого мы можем определить в этом графе пять сильносвязных компонентов. Сильносвязный компонент Обратите внимание: если все ребра вершины исходящие, как у Зебры, или вхо­ дящие, как у Тигрицы, то это сформирует вырожденный (degenerate) сильнос­ вязный компонент, содержащий только одну эту вершину. Впрочем, такое мо­ жет происходить и в других случаях — посмотрите для примера на Цыпленка. https://liveinternet.club
272  Глава 13. Графы Связные и сильносвязные компоненты особенно важны для больших графов, потому что позволяют расчленить большой граф на фрагменты, которые можно обрабатывать по отдельности. Деревья как графы Теперь, зная все эти определения, можно вернуться к различиям между дере­ вьями и графами и привести более формальное определение дерева. По сути, дерево представляет собой простой ненаправленный связный ациклический граф. Как следствие, у дерева с n вершинами — узлами, в терминологии де­ ревьев, — должно быть ровно n-1 ребер. Простой ненаправленный ациклический граф, не являющийся связным, называется лесом (forest). В этом случае каждый связный компонент пред­ ставляет собой дерево этого леса. Не дерево (петля) Не дерево (цикл) Дерево Дерево Лес Можно возразить, что ребра в деревьях идут от родительских узлов к до­ черним, а следовательно, соответствуют модели направленных деревьев. Но в дереве само определение корневого узла развеивает все неясности вза­ имодействия родительских узлов с дочерними, и нет нужды явно задавать направление ребер. Реализация графов Графы, как и деревья, лучше описать как класс структур данных, а не как абстрактный тип данных. Но все так же можно определить API, и есть ряд общих операций, которые поддерживаются большинством графов, — напри­ мер, добавление вершин и ребер. Существует, кроме того, гораздо больше операций, выходящих за рамки этого базового API. При переходе на уровень структур данных ключевой момент — это найти способ хранения вершин и ребер графа таким образом, чтобы было легко и удобно совершать обход и поиск. https://liveinternet.club
Реализация графов  273 В этом разделе мы рассмотрим два способа реализации графов: список смежности (adjacency list) и матрицу смежности (adjacency matrix). Это не единственные варианты, но самые распространенные. Список смежности В варианте со списком смежности ребра группируются по их начальной вершине. Если задана вершина v, то список всех ребер, у которых v — их начальная вершина, образует список смежности для v . Затем строится словарь, в котором каждая вершина графа связывается со своим списком смежности. В Python-реализации списки смежности фактически могут быть свя­ занными списками, а также списками или множествами Python — на са­ мом деле подойдет любой контейнер, предоставляющий операции поиска и перебора. Список смежности Давайте рассмотрим возможную реализацию на Python. Методов реализации столько, что привести их все здесь невозможно, но вы можете проверить полный код в репозитории книги на GitHub: https://mng.bz/8wgw. Создавая словарь, я для простоты и ясности использовал Python-словарь. Чтобы сохранить списки смежности, возьмем односвязные списки, кото­ рые были описаны в главе 6. Каждое ребро можно хранить посредством настроенного Edge-объекта в виде пары (source, destination) или кортежа (source, destination, weight). Но если граф состоит только из невзвешенных и непомеченных ребер, можно просто хранить в списке смежности только конечные вершины, потому что начальная вершина уже известна. https://liveinternet.club
274  Глава 13. Графы Начну с определения внутреннего класса вершин. Каждая вершина бу­ дет представлять собой обертку, идентифицируемую своим уникальным ключом. Она также будет хранить свой список смежности. Таким образом, можно добавлять всевозможные методы, работающие с исходящими ребра­ ми, непосредственно в объект Vertex, который будет единолично отвечать за поддержание порядка: class Vertex: def __init__(self, key): self.id = key self._adj_list = SinglyLinkedList() def has_edge_to(self, destination_vertex): return self._adj_list._search(destination_vertex) is not None def add_edge_to(self, destination_vertex): if self.has_edge_to(destination_vertex): raise ValueError(f'Edge already exists: {self} -> { destination_vertex}') self._adj_list.insert_in_front(destination_vertex) Чтобы не усложнять, я реализовал невзвешенный граф, а в списках смежно­ сти храню только конечные вершины. Таким образом, поиск ребра в списке смежности означает поиск вершины в связанном списке, а эта операция может быть делегирована API связанного списка. Аналогичным образом, чтобы вставить новое ребро, можно воспользоваться методом из арсенала связанных списков, но сначала убедиться, что такого ребра пока еще нет. Теперь можно определить внешний класс Graph с простым конструкто­ ром — мы просто создаем пустой словарь для списка смежности: class Graph: def __init__(self): self._adj = {} Как я уже говорил, вершины будут идентифицироваться по своим ключам, а объект Vertex предназначен исключительно для внутреннего исполь­ зования классом Graph. Например, если клиент запросит вставку верши­ ны с ключом "v" , он ни в коем случае не получит ссылку на экземпляр Vertex("v"), созданный во внутренней реализации. Когда ему потребуется выполнить то или иное действие с этой вершиной, он может идентифици­ ровать ее только по ID "v" — например, клиент может использовать вызов вида graph.add_edge("v", "u"). Следовательно, нам понадобится способ получения объекта Vertex , связанного с заданным ID вершины. Метод _get_vertex реализуется как приватный, потому что не следует позволять клиенту получать ссылки на эти объекты (при том что для нас этот метод https://liveinternet.club
Реализация графов  275 будет очень полезен). Здесь на помощь приходит атрибут _adj. Это словарь, ключами которого являются идентификаторы вершин, а значениями — со­ ответствующие объекты Vertex: def _get_vertex(self, key): if key not in self._adj: raise ValueError(f'Vertex {key} does not exist!') return self._adj[key] Чтобы добавить новую вершину в граф, достаточно задать значение в словаре, а также убедиться в том, что в этом графе еще нет такой вершины: def insert_vertex(self, key): if key in self._adj: raise ValueError(f'Vertex {key} already exists!') self._adj[key] = Graph.Vertex(key) Наконец, давайте посмотрим, как добавить новое ребро. Необходимо задать объекты Vertex для начальной и конечной вершин, после чего операцию можно делегировать начальной вершине, которая также проверит, нет ли уже такого ребра: def insert_edge(self, key1, key2): v1 = self._get_vertex(key1) v2 = self._get_vertex(key2) v1.add_edge_to(v2) Удалить ребро столь же просто: мы движемся той же колеей — делегируем задачу начальной вершине и ей же оставляем проверку ошибок. Я опускаю здесь этот метод ради экономии места, но его можно найти в репозитории на GitHub. Удаление вершины, напротив, — это нечто, над чем нам придется пораз­ мыслить. Удалить запись вершины v из списка смежности недостаточно: ко­ нечно, так мы удалим исходящие ребра v, но если у v есть также и входящие ребра, их это не затронет. Увы, единственный способ решить задачу — это обойти весь список смежности и удалить каждое ребро, конечная вершина которого — v. Как нетрудно догадаться, это затратная операция, которая потребует O(n+m) шагов для графа с n вершинами и m ребрами: def delete_vertex(self, key): v = self._get_vertex(key) for u in self._adj.values(): if u != v and u.has_edge_to(v): u.remove_edge_to(v) del self._adj[key] https://liveinternet.club
276  Глава 13. Графы Матрица смежности В варианте с матрицами смежности мы храним ребра в большой матрице, строки и столбцы которой представляют вершины графа. Ячейка матрицы с координатами (u,v) может хранить двоичное значение (0, если ребра нет, и 1, если есть ребро от u к v), вес ребра (или специальное значение вроде None, если ребра нет) или объект, моделирующий ребро. Матрицы смежности могут работать быстрее списков смежности, когда нужно проверить, есть ли ребро между двумя вершинами. Для этого нужен однократный поиск в двумерном массиве, что составляет O(1). Таким об­ разом, преимущества матриц смежности проявляются в алгоритмах, часто требующих проверки связности. С другой стороны, для матриц смежности требуется объем памяти, пропорциональный квадрату количества вершин (то есть максимальному количеству ребер простого графа), даже если граф является разреженным. По этой причине их используют редко и обычно только там, где мы уверены, что имеем дело с насыщенными графами. По этой же причине мы не будем углубляться в код реализации матрицы смежности. 42 7.8 4 0.1 3.14 -4 -2.7 Нет Нет 42 Нет Нет Нет -2.7 Нет Нет Нет Нет Нет Нет -4 Нет 7.8 Нет 0.1 3.14 2.3 Нет Нет Нет 4 Нет конечная вершина Матрица смежности 2.3 исходная вершина Поиск в графе Поиск в графе означает несколько иное, чем то, с чем мы имели дело до сих пор. Конечно, мы все так же можем искать, есть ли в графе та или иная вер­ шина или ребро, просматривая список смежности или матрицу смежности. Но это было бы недооценкой потенциала графа. Помните, граф — нечто большее, чем просто контейнер: в нем хранится информация о том, как сущ­ ности, то есть вершины, связаны друг с другом. https://liveinternet.club
Поиск в графе  277 В этом разделе приводятся примеры того, как информация такого рода может извлекаться из графа. Изучение дружеских взаимосвязей В этом и следующем разделах мы будем работать с ненаправленным графом. Тем не менее те же рассуждения применимы и к направленным. В избирательном штабе Тигрицы бурная деятельность: IT-команда строит граф друзей на Facebook. Как упоминалось ранее, это симметричные вза­ имосвязи, которые лучше всего моделировать ненаправленными ребрами. Этот граф больше того, что мы видели ранее, и его анализ потребует опре­ деленных усилий. Крокодил со своей командой хочет найти всех непосред­ ственных друзей Тигрицы и сопоставить эту картину с непосредственными друзьями Льва. Друзья Тигрицы первого круга Друзья Льва первого круга Итак, у Тигрицы пять друзей, а у Льва только четыре — хорошие новости! Но все ли это, что мы можем узнать из графа? На следующем шаге IT-команда решает изучить множества «друзей дру­ зей». Множества друзей Тигрицы и Льва не пересекаются, и можно предпо­ ложить, что каждый из этих девятерых животных проголосует за своего бли­ жайшего друга. Было бы разумно предположить, что среди связей второго круга есть неопределившиеся избиратели и на их выбор могут повлиять их друзья. А значит, важно склонить их на свою сторону. Здесь уже ситуация не столь блестящая. На иллюстрации для простоты показана картинка для Тигрицы. Контакты второго круга Тигрицы — толь­ ко Жираф да Цыпленок. У Льва три друга второго круга: помимо Жирафа и Цыпленка, в чем он сходится с Тигрицей, у него еще есть Кошка. https://liveinternet.club
278  Глава 13. Графы Друзья Тигрицы первого круга Друзья второго круга Друзья третьего круга А что, если мы доберемся до друзей третьего круга? Или четвертого и так далее? Поиск в ширину Есть алгоритм поиска, который работает именно таким образом — обследует вершины графа концентрическими кругами, пока не найдет то, что ищет. По информации от руководителя кампании Кролик — настоящая звезда социальных сетей, и, заручившись его поддержкой, можно переломить ход выборов. В штабе Тигрицы хотят понять, насколько далеко от них Кролик в цепочке друзей. Кроме того, каков кратчайший путь от Тигрицы к Кроли­ ку? План такой — начать с кого-то из друзей Тигрицы, чтобы он представил ее кому-то из своих друзей, затем тот — кому-то из своих и так далее, пока не доберемся до Кролика. И чем короче этот путь, тем меньше народу при­ дется задействовать. Алгоритм поиска в ширину (breadth-first search, BFS) делает именно это: он обследует граф от начальной вершины s, Тигрицы, поступательно расширяя область охвата вершин, соединенных — прямо или косвенно — с s, пока не найдется целевая вершина — Кролик. Но что еще важнее, BFS обследует вершины в определенном порядке: начинает с непосредственных соседей исходной вершины, затем переходит к соседям второго круга и т. д. Более точно, это расширение происходит не уровень за уровнем, а верши­ на за вершиной. Чтобы убедиться, что мы обследуем ближестоящие вершины прежде более отдаленных, можно воспользоваться очередью: сначала ставим в очередь всех непосредственных друзей Тигрицы. https://liveinternet.club
Поиск в графе  279 Расстояние Друзья 0 Посещено Текущая вершина Очередь Расстояние 1 1 1 1 1 Затем берем первую вершину очереди — Обезьяну, проходим по всем ее исходящим ребрам и добавляем их конечные точки в конец очереди. Эти вершины — в том случае, если они еще не успели стать объектами этого анализа, — будут на расстоянии двух ребер от Тигрицы. Если добавлять друзей в очередь без проверки, то в ней могут появиться дубликаты. Через них будут проходить дублирующие пути к искомой вер­ шине от начала, но ни один из них не будет более короткой дистанцией до начальной вершины! Таким образом, можно просто игнорировать друзей, которых мы уже добавили в очередь, и не добавлять их второй раз. Друзья Расстояние 1 0 Посещено Текущая вершина Текущая граница (между посещенными вершинами и остальным графом) Очередь Расстояние 1 1 1 1 2 2 2 Посещенные вершины игнорируются, то есть не ставим их в очередь дважды Продолжаем исследовать граф, расширяя область охвата вершин, связанных с исходной вершиной, пока, наконец, не достигнем своей цели или пока не кончатся вершины в очереди. https://liveinternet.club
280  Глава 13. Графы Друзья Расстояние 3 3 2 2 Текущая граница 1 1 1 1 1 0 Текущая граница Очередь Расстояние Посещенные 4 4 Если мы отслеживаем расстояние до вершины v, когда добавляем ее в очередь, устанавливая distance[v] равным 1, плюс расстояние до начальной вершины ребра, которое мы в данный момент обходим, то мы можем доказать, что при проверке вершины это значение является минимальным расстоянием с точки зрения количества пройденных ребер между исходной вершиной и v. А если также отслеживать ребра, которые мы обходили, чтобы добраться до v, то в конце метода можно восстановить кратчайший путь от исходной вершины до любой другой — он выделен на иллюстрации. В нашем примере несколько кратчайших путей длиной в 4 ребра от Тигрицы к Кролику, так что реальный выбор зависит от порядка, в котором вершины добавлялись в очередь. Теперь пора взглянуть на код этого замечательного метода: def bfs(self, start_vertex, target_vertex): distance = {v: float('inf') for v in self._adj} predecessor = {v: None for v in self._adj} queue = Queue(self.vertex_count()) queue.enqueue(start_vertex) distance[start_vertex] = 0 while not queue.is_empty(): Сначала в очередь добавляется u = queue.dequeue() исходная вершина if u == target_vertex: return reconstruct_path(predecessor, target_vertex) for (_, v) in self._get_vertex(u).outgoing_edges(): if distance[v] == float('inf'): В этой точке узнаем, distance[v] = distance[u] + 1 что от исходной вершины predecessor[v] = u до целевой нет пути queue.enqueue(v) return None https://liveinternet.club
Поиск в графе  281 Обратите внимание, что благодаря способу обхода вершин мы автома­ тически находим минимальное расстояние от исходной вершины до любой другой в момент ее первого посещения. Таким образом, необязательно явно отслеживать вершины, через которые мы проходим, — важнее позаботиться о том, что добавляем их в очередь только один раз. В пазле все еще не хватает последней детали — вспомогательного метода, который получает словарь с предшественниками каждой вершины v и ре­ конструирует кратчайший путь от s к v: def reconstruct_path(pred, target): path = [] while target: path.append(target) target = pred[target] return path[::-1] Список следует от целевой вершины к начальной, его необходимо развернуть в обратном направлении В целом, в наихудшем случае метод bfs должен пройти по всем m ребрам и n вершинам, так что время его выполнения составит O(n+m). Этот метод идеально работает, когда нас интересует только расстояние, измеряемое в количестве ребер. Если же ребрам назначены веса и расстоя­ ние между двумя вершинами u и v определяется как сумма весов ребер на пути от u к v, то потребуется усовершенствованная версия алгоритма поиска в ширину — алгоритм Дейкстры. Если вам захочется больше узнать о нем, об­ ращайтесь к главе 15 моей книги «Advanced Algorithms and Data Structures»1. Поиск в глубину BFS — единственный способ исследовать граф? Конечно нет. BFS обходит граф концентрическими кольцами на все большем расстоянии от исходной верши­ ны. Таким образом он расширяет границу вершин, примыкающих к тем, что мы уже посетили, подобно волне распространяясь во все стороны. Противополож­ ный вариант — заходить в граф настолько глубоко, насколько это возможно, и так действует алгоритм поиска в глубину (depth-first search, DFS). Выбирается исходная вершина s, после чего алгоритм следует по одному пути от s до конца. По достижении конца пути он отступает, пока не найдет вершину, в которой он мог бы выбрать другое ребро, и снова следует по этому пути до конца. Текущая вершина Текущая вершина Стек Стек Посещенные Посещенные 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
282  Глава 13. Графы С помощью этого алгоритма невозможно найти кратчайший путь между вершинами. С другой стороны, он может найти связные и сильносвязные компоненты, даст понять, является ли направленный граф ацикличным, и найти топологическую сортировку для направленного ациклического графа (directed acyclic graphs, DAG). В этом разделе мы рассмотрим, как определить, является ли граф ацикли­ ческим, а за информацией о других применениях DFS обращайтесь к моей книге «Advanced Algorithms and Data Structures». Чтобы с помощью DFS проверить, есть ли в графе циклы, необходимо выполнить несколько дополнительных действий при обходе вершин — в частности, пометить вершины цветом. Изначально все вершины окрашены в белый цвет, затем вершина окрашивается в серый цвет при ее посещении и, наконец, в черный, когда мы покидаем ее, то есть удаляем из стека ее первое вхождение после обхода всех ее исходящих ребер. Но в каком порядке исследовать вершины? В BFS для обхода ребер в по­ рядке их обнаружения применялась очередь. В DFS, по симметричному принципу, чтобы обходить ребра в порядке их обнаружения, мы используем стек, продвигаясь по путям настолько, насколько только возможно. Что же должно происходить, когда мы извлекаем вершину из стека и об­ наруживаем, в какой цвет она окрашена? нашли белую вершину, то это вершина, которую мы еще не иссле­ довали, потому ничего не узнаем, но нам надо немного потрудиться: до­ бавить ее соседей в стек, а затем исследовать их. zzДля черной вершины v мы знаем, что уже полностью ее исследовали. Те­ перь мы нашли другую вершину, u, которая соединена ребром с v: таким образом узнаем, что достичь u из v невозможно. zzНо если находим серую вершину w, то перед нами вершина, которая еще не полностью исследована. Следовательно, в графе есть путь, который начинается с w и завершается в w, то есть мы обнаружили цикл. zzЕсли О DFS еще можно долго рассказывать, но сейчас время закругляться и по­ смотреть на код. Я реализовал итеративную версию метода dfs, который явно использует стек, в отличие от BFS, который пользуется очередью. Од­ нако следует знать, что для реализации DFS принято использовать рекурсию, неявно используя стек вызовов, чтобы определить порядок вершин. С явным стеком потребуется небольшой трюк, чтобы узнать, когда мы за­ вершили исследование вершины. При первом посещении вершины v можно поместить v обратно в стек, но одного этого недостаточно, потому что мы ведь можем добавить ее снова как соседку какой-либо другой вершины. Следовательно, нам также понадобится флаг, указывающий на то, что это последнее вхождение v в стеке. По этой причине я помещаю в стек кортеж. https://liveinternet.club
Поиск в графе  283 Стек Текущая вершина Посещенные Текущая вершина Стек Посещенные Стек Текущая вершина Посещенные Первое значение в кортеже сообщает, готовы ли мы пометить вершину как черную: def dfs(self, start_vertex, color=None): if color is None: color = {v: 'white' for v in self._adj} acyclic = True stack = Stack() stack.push((False, start_vertex)) while not stack.is_empty(): (mark_as_black, v) = stack.pop() col = color.get(v, 'white') if mark_as_black: color[v] = 'black' elif col == 'grey': acyclic = False elif col == 'white': color[v] = 'grey' stack.push((True, v)) for (_, w) in self._get_vertex(v).outgoing_edges(): stack.push((False, w)) return acyclic, color https://liveinternet.club
284  Глава 13. Графы Этот метод можно вызывать многократно с разными исходными вершинами. Если граф не является сильносвязным, то маловероятно, что мы обойдем весь граф за один вызов. Вот почему мы возвращаем словарь color вместе с булевым флагом, который говорит нам, нашел ли DFS цикл. Чтобы опре­ делить, до каких вершин можно добраться от исходной, и выявить связные компоненты графа, можно ориентироваться на примененную маркировку цветом (см. тесты для этого метода на GitHub: https://mng.bz/EZ6O). DFS может обойти все ребра и посетить все вершины графа, поэтому время его выполнения составляет O(n+m), как и для BFS. Что дальше? Раздел, посвященный DFS, завершает наше рассмотрение графов, эту главу и всю книгу. Если вы захотите узнать больше о графах и алгоритмах, рабо­ тающих с графами, я рекомендую следующие книги: zz«Advanced Algorithms and Data Structures» (M. La Rocca, 2021, Manning)1 zz«Introduction to Algorithms» (Cormen, Leiserson, Rivest, Stein, 2022, MIT Press)2 zz«Graph-Powered Machine Learning» (A. Negro, 2021, Manning) zz«Graph Databases in Action» (D. Bechberger, J. Perryman, 2020, Manning) На этом наш экскурс в структуры данных закончен, и я надеюсь, что он вам понравился и вам захотелось узнать больше. Ваше путешествие только началось, и замечательная новость в том, что есть множество отличных книг, которые можно прочитать, чтобы больше узнать о структурах дан­ ных. Кроме таких классических учебников, как «Introduction to Algorithms» и «The Algorithms Design Manual», есть ряд превосходных книг издательства Manning, которые заслуживают вашего внимания: zz«Grokking Algorithms» Адитьи Бхаргавы отчасти дополняет книгу, кото­ рую вы только что прочли. В ней автор больше внимания уделяет алго­ ритмам, выполняемым со структурами данных, — таким как сортировка или поиск. 4 zz«Optimization Algorithms» А. Хамиса (A. Khamis) : из этой книги вы узнаете о современных алгоритмах поиска, эволюционных алгоритмах и машинном обучении. 3 1 2 3 4 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». Кормен Т., Лейзерсон Ч., Ривест Р. «Алгоритмы. Построение и анализ». Бхаргава А. «Грокаем алгоритмы», 2-е изд. СПб., издательство «Питер». Хамис А. «Алгоритмы оптимизации». Выходит в издательстве «Питер» в 2026 г. https://liveinternet.club
Итоги  285 zz«Advanced Algorithms and Data Structures»1: кроме более глубокого рас­ смотрения графов, в книге рассматриваются такие нетривиальные струк­ туры данных, как рандомизированные пирамиды, префиксные деревья, k-d-деревья, SS+-деревья и т. д. Итоги zzГрафы — это гораздо больше, чем просто контейнеры: они могут модели­ ровать отношения между сущностями (которые называются вершинами), соединенными ребрами. zzГрафы представляют собой обобщения деревьев. А если говорить более конкретно, деревья — простые связные ациклические графы. zzГраф является простым, если любая пара его вершин соединена не более чем одним ребром и в нем нет петель (когда ребро ведет из вершины к ней же самой). zzГраф является связным, если для любой пары вершин можно найти по­ следовательность ребер, ведущих от одной вершины к другой. Если граф не является связным, его можно разложить на связные компоненты. zzГраф является циклическим, если в нем есть хотя бы один путь, начина­ ющийся и завершающийся в одной вершине; в противном случае граф называется ациклическим. zzВ направленном графе ребра можно пройти только в одном направле­ нии — от начальной вершины к конечной. Подписка в Twitter лучше всего моделируется направленным графом. В ненаправленном графе все ребра можно пройти в обоих направлениях. Примером может послужить взаимное подтверждение дружбы на Facebook. zzГрафы обычно реализуются на базе списка смежности или матрицы смежности. Второй вариант применяется только в специфических си­ туациях. zzПоиск в ширину — это алгоритм для обхода графов, позволяющий нахо­ дить пути с минимальным количеством ребер между исходной вершиной и остальной частью графа. zzПоиск в глубину — это способ поиска в графе, который проходит по каждому пути до его завершения. Он позволяет проверить множество свойств графа, в частности, связный и цикличный ли он. 1 Ла Рокка М. «Продвинутые алгоритмы и структуры данных». СПб., издательство «Питер». https://liveinternet.club
Марчелло Ла Рокка Грокаем структуры данных Перевел с английского Е. Матвеев Научный редактор Ю. Шашкин Руководитель дивизиона Ведущий редактор Научный редактор Литературный редактор Художественный редактор Корректоры Верстка Ю. Сергиенко Е. Строганова Ю. Шашкин М. Львов В. Мостипан С. Беляева, Г. Шкатова Л. Егорова Изготовлено в России. Изготовитель: ООО «Прогресс книга». Место нахождения и фактический адрес: 194044, Россия, г. Санкт-Петербург, Б. Сампсониевский пр., д. 29А, пом. 52. Тел.: +78127037373. Дата изготовления: 10.2025. Наименование: книжная продукция. Срок годности: не ограничен. Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные профессиональные, технические и научные. Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01. Подписано в печать 19.09.25. Формат 70×100/16. Бумага офсетная. Усл. п. л. 23,220. Тираж 1000. Заказ 0000. https://liveinternet.club
Ришал Харбанс ГРОКАЕМ АЛГОРИТМЫ ИСКУСCТВЕННОГО ИНТЕЛЛЕКТА . Искусственный интеллект — часть нашей повседневной жизни Мы встречаемся с его проявлениями, когда занимаемся шопингом в интернет-магазинах, получаем рекомендации «вам может понравиться этот фильм», узнаем медицинские диагнозы… . Чтобы уверенно ориентироваться в новом мире, необходимо понимать алгоритмы, лежащие в основе ИИ . . «Грокаем алгоритмы искусственного интеллекта» объясняет фундаментальные концепции ИИ с помощью иллюстраций и примеров из жизни Все, что вам понадобится, — это знание алгебры на уровне старших классов школы, и вы с легкостью будете решать задачи, позволяющие обнаружить банковских мошенников, создавать шедевры живописи и управлять движением беспилотных автомобилей КУПИТЬ https://liveinternet.club
https://liveinternet.club