/
Tags: программирование на эвм компьютерные программы программирование
ISBN: 978-5-93700-424-6
Text
АЛГОРИТМЫ
и структуры данных
с примерами на Python
Цзинь Юйдун (@krahets)
Цзинь Юйдун
Алгоритмы
и структуры данных
c примерами на Python
Hello 算法
Python 语言版
作者:靳宇栋 (@krahets)
代码审阅:靳宇栋((@krahets)
Алгоритмы
и структуры данных
с примерами на Python
Цзинь Юйдун (Jin Yudong) (@krahets)
Перевод: И. А. Шевкун
Москва, 2025
УДК 004.421
ББК 32.973
Ц55
Цзинь Юйдун
Ц55
Алгоритмы и структуры данных с примерами на Python / пер. с кит.
И. А. Шевкуна. – М.: ДМК Пресс, 2025. – 494 с.: ил.
ISBN 978-5-93700-424-6
Цель этой книги – при помощи наглядных иллюстраций и исполняемых
примеров кода помочь читателю понять ключевые идеи алгоритмов и структур
данных и освоить их воплощение в программном коде. Если вам не хватает
времени на чтение множества учебников, она станет спасательным кругом
в океане знаний.
Книга будет особенно полезна всем, у кого есть начальные навыки программирования, но отсутствует четкое понимание алгоритмов и структур данных.
Более опытным читателям она поможет освежить и систематизировать знания
об алгоритмах.
УДК 004.421
ББК 32.973
Russian translation rights ©2025. 《Hello算法》(ISBN: 978-7-115-63750-5) 作者: 靳宇栋
(@krahets)
Russian translation rights are arranged with Posts & Telecom Press Co., Ltd. through Media
Solutions, Tokyo Japan (info@mediasolutions.jp)
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой
бы то ни было форме и какими бы то ни было средствами без письменного разрешения
владельцев авторских прав.
ISBN (анг.) 978-7-115-63750-5
ISBN (рус.) 978-5-93700-424-6
Copyrights ©2024 Posts and Telecom Press
© Оформление, издание, перевод, ДМК Пресс, 2025
Оглавление
Предисловие......................................................................................... 8
Предисловие от издательства......................................................... 10
Отзывы и пожелания.........................................................................................10
Список опечаток................................................................................................10
Нарушение авторских прав..............................................................................10
Глава 0. Введение............................................................................... 11
0.1. О книге.........................................................................................................12
0.2. Как использовать эту книгу.......................................................................15
0.3. Резюме.........................................................................................................23
Глава 1. Введение в алгоритмы....................................................... 24
1.1. Алгоритмы повсюду...................................................................................25
1.2. Что такое алгоритм.....................................................................................30
1.3. Резюме.........................................................................................................32
Глава 2. Анализ сложности............................................................... 34
2.1. Оценка эффективности алгоритмов.........................................................35
2.2. Итерация и рекурсия..................................................................................37
2.3. Временная сложность.................................................................................46
2.4. Пространственная сложность....................................................................61
2.5. Резюме.........................................................................................................67
Глава 3. Структуры данных............................................................... 70
3.1. Классификация структур данных..............................................................71
3.2. Основные типы данных.............................................................................74
3.3. Кодирование чисел*...................................................................................76
3.4. Кодирование символов*.............................................................................80
3.5. Резюме.........................................................................................................89
Глава 4. Массивы и списки................................................................ 92
4.1. Массивы.......................................................................................................93
4.2. Связные списки...........................................................................................99
4.3. Списки.......................................................................................................105
4.4. Память и кеш*...........................................................................................109
4.5. Резюме.......................................................................................................113
6
Оглавление
Глава 5. Стек и очередь...................................................................118
5.1. Стек............................................................................................................119
5.2. Очередь......................................................................................................126
5.3. Двусторонняя очередь..............................................................................133
5.4. Резюме.......................................................................................................144
Глава 6. Хеш-таблицы......................................................................146
6.1. Хеш-таблицы............................................................................................147
6.2. Хеш-коллизии...........................................................................................152
6.3. Алгоритмы хеширования.........................................................................161
6.4. Резюме.......................................................................................................167
Глава 7. Деревья...............................................................................170
7.1. Двоичные деревья....................................................................................171
7.2. Обход двоичного дерева...........................................................................177
7.3. Представление двоичного дерева с помощью массива.........................184
7.4. Двоичное дерево поиска..........................................................................189
7.5. АВЛ-дерево*..............................................................................................199
7.6. Резюме.......................................................................................................210
Глава 8. Куча.....................................................................................213
8.1. Куча............................................................................................................214
8.2. Построение кучи.......................................................................................226
8.3. Поиск k наибольших элементов..............................................................229
8.4. Резюме.......................................................................................................234
Глава 9. Графы...................................................................................236
9.1. Графы.........................................................................................................237
9.2. Основные операции с графами...............................................................241
9.3. Обход графа...............................................................................................249
9.4. Резюме.......................................................................................................262
Глава 10. Поиск.................................................................................264
10.1. Двоичный поиск.....................................................................................265
10.2. Вставка с использованием
двоичного поиска............................................................................................270
10.3. Двоичный поиск границ.........................................................................276
10.4. Стратегии оптимизации хеширования.................................................279
10.5. Переосмысление алгоритмов поиска....................................................282
10.6. Резюме.....................................................................................................286
Глава 11. Сортировка....................................................................... 287
11.1. Алгоритмы сортировки..........................................................................288
11.2. Сортировка выбором..............................................................................289
11.3. Сортировка пузырьком..........................................................................294
11.4. Сортировка вставками...........................................................................298
Оглавление
7
11.5. Быстрая сортировка................................................................................301
11.6. Сортировка слиянием............................................................................310
11.7. Пирамидальная сортировка...................................................................316
11.8. Блочная сортировка................................................................................324
11.9. Сортировка подсчетом...........................................................................327
11.10. Поразрядная сортировка......................................................................334
11.11. Резюме...................................................................................................337
Глава 12. Разделяй и властвуй.......................................................342
12.1. Стратегия «разделяй и властвуй»..........................................................343
12.2. Применение стратегии «разделяй и властвуй» для поиска.................347
12.3. Задача построения двоичного дерева...................................................350
12.4. Задача о Ханойских башнях...................................................................357
12.5. Резюме.....................................................................................................363
Глава 13. Поиск с возвратом..........................................................365
13.1. Алгоритмы поиска с возвратом.............................................................366
13.2. Задача о перестановках..........................................................................377
13.3. Задача о сумме подмножеств.................................................................383
13.4. Задача об n ферзях..................................................................................391
13.5. Резюме.....................................................................................................395
Глава 14. Динамическое программирование.............................. 397
14.1. Введение в динамическое программирование....................................398
14.2. Особенности задач динамического программирования.....................404
14.3. Подход к решению задач динамического программирования...........410
14.4. Задача о рюкзаке 0-1..............................................................................422
14.5. Задача о полном рюкзаке.......................................................................434
14.6. Задача расстояния редактирования......................................................448
14.7. Резюме.....................................................................................................457
Глава 15. Жадность..........................................................................460
15.1. Жадные алгоритмы................................................................................461
15.2. Задача о дробном рюкзаке.....................................................................466
15.3. Задача о максимальной вместимости...................................................469
15.4. Задача о максимальном произведении разбиения..............................478
15.5. Резюме.....................................................................................................482
Глава 16. Приложение.....................................................................483
16.1. Установка программной среды.............................................................484
16.2. Совместная разработка..........................................................................486
16.3. Глоссарий.................................................................................................488
Предисловие
Два года назад я опубликовал на платформе LeetCode сборник задач по книге
Coding Interviews, который получил хорошие отклики от читателей. В ходе общения с ними мне чаще всего задавали вопрос: «С чего начать изучение алгоритмов?» Постепенно этот вопрос начал все больше меня занимать.
Решение задач наугад, вслепую является, пожалуй, самым распространенным методом: он прост, прямолинеен и результативен. Однако этот процесс
напоминает компьютерную игру «Сапер»: люди с высокой способностью к самообучению могут без особых проблем разминировать все клетки. А те, кому
Предисловие
9
не хватает базовой подготовки, могут легко подорваться и быстро потерять
мотивацию. Другой распространенный подход – читать учебники от корки
до корки. Но у тех, кто готовится к устройству на работу, значительная часть
времени и сил уходит на получение диплома, составление резюме, подготовку
к тестам и собеседованиям. В таких условиях чтение объемных книг превращается в серьезное испытание.
Если вы сталкиваетесь с подобными трудностями, то считайте большой удачей, что эта книга «нашла» вас. Она представляет собой мой ответ на вопрос
«Как войти в мир алгоритмов?». Возможно, это не самый совершенный ответ,
но это точно искренняя попытка. Эта книга не гарантирует вам быстрого получения хорошего офера, но она поможет построить «карту знаний» по структурам данных и алгоритмам, познакомит с различными «минами», их формой,
размером и расположением, а также научит «методам разминирования». Овладев этими знаниями, вы сможете увереннее решать задачи и читать научные статьи, постепенно формируя целостную систему знаний.
Я полностью согласен со словами Ричарда Фейнмана: «Знания не бесплатны. За них нужно платить вниманием». В этом смысле эта книга вовсе не бесплатна. И чтобы оправдать ваше драгоценное внимание, я вложу максимум
усилий в ее создание.
Осознавая ограниченность собственных знаний и опыта, я понимаю,
что, несмотря на многократные доработки, в книге все же могут оставаться
ошибки. Буду искренне признателен за критику и замечания от преподавателей и студентов.
Исходные коды из книги доступны в виде исполняемых файлов в репозитории по адресу github.com/krahets/hello-algo.
Формат PDF не очень хорошо подходит для анимированных изображений,
поэтому для лучшего восприятия можно обратиться к веб-версии по адресу
https://www.hello-algo.com/en (на англ.).
Предисловие от издательства
Отзывы и пожелания
Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете
об этой книге – что понравилось или, может быть, не понравилось. Отзывы
важны для нас, чтобы выпускать книги, которые будут для вас максимально
полезны.
Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также
можно послать письмо главному редактору по адресу dmkpress@gmail.com;
при этом укажите название книги в теме письма.
Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем сайте по адресу http://dmkpress.com/
authors/publish_book/ или напишите в издательство по адресу dmkpress@gmail.com.
Список опечаток
Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое
качество наших текстов, ошибки все равно случаются. Если вы найдете ошибку
в одной из наших книг – возможно, ошибку в основном тексте или программном коде, – мы будем очень благодарны, если вы сообщите нам о ней. Сделав
это, вы избавите других читателей от недопонимания и поможете нам улучшить последующие издания этой книги.
Если вы найдете какие-либо ошибки в коде, пожалуйста, сообщите о них
главному редактору по адресу dmkpress@gmail.com, и мы исправим это в следующих тиражах.
Нарушение авторских прав
Пиратство в интернете по-прежнему остается насущной проблемой. Издательство «ДМК Пресс» очень серьезно относится к вопросам защиты авторских
прав и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы мы могли применить санкции.
Ссылку на подозрительные материалы можно прислать по адресу
dmkpress@gmail.com.
Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы.
Глава 0
Введение
12
Введение
Абстракция
Алгоритм подобен прекрасной симфонии, где каждая строка кода звучит как
ритм.
Пусть эта книга зазвучит в твоем сознании легкой мелодией и оставит особый, глубокий след.
0.1. О книге
Этот проект задуман как открытое, бесплатное и дружелюбное к новичкам
введение в структуры данных и алгоритмы.
1. В книге используются анимационные иллюстрации. Материал изложен
ясно и последовательно, что облегчает освоение и помогает начинающим построить «карту знаний» по структурам данных и алгоритмам.
2. Исходный код можно запустить одним кликом, что позволяет тренироваться, развивать навыки программирования и формировать понимание принципов работы алгоритмов и реализации структур данных на
фундаментальном уровне.
3. Мы призываем к взаимопомощи читателей: задавайте вопросы и делитесь идеями в комментариях. Обсуждения помогают двигаться вперед
всем вместе.
0.1.1. Целевая аудитория
Если вы новичок в алгоритмах, никогда с ними не сталкивались или у вас есть
некоторый опыт решения задач, но еще нет четкого понимания структур данных и алгоритмов, эта книга создана специально для вас!
Если у вас уже есть определенный опыт решения задач и вы знакомы с большинством типов задач, эта книга поможет вам освежить и систематизировать
знания об алгоритмах. Исходный код может служить набором инструментов
для решения задач или алгоритмическим словарем.
Если вы владеете алгоритмами на экспертном уровне, мы будем рады вашим ценным советам или совместному участию в создании книги.
Предварительные требования
Вам необходимо иметь базовые навыки программирования на любом языке,
а также уметь читать и писать простой код.
0.1.2. Структура книги
Основное содержание книги представлено на рис. 0.1.
1. Анализ сложности: критерии и методы оценки структур данных и алгоритмов. Методы расчета временной и пространственной сложности,
распространенные типы, примеры и т. д.
Графы
Куча
Деревья
https://github.com/krahets/hello-algo
Структуры
данных
Хештаблицы
Стек
и очередь
Массивы
и списки
Структуры
данных
Обход в ширину и в глубину
Алгоритмы
Поиск
с возвратом
Разделяй
и властвуй
Сортировка
Поиск
Пространственная
сложность
Жадные
алгоритмы
Динамическое
программирование
Рис. 0.1. Структура книги
Список и матрица смежности, их сравнение
Ориентированный, связный и взвешенный графы
Минимальная и максимальная куча,
очередь с приоритетом
Реализация кучи на основе массива,
операция построения кучи
Задача о k наибольших элементов
АВЛ-дерево
Двоичное дерево поиска
Обход в ширину; прямой, симметричный и обратный порядок
Представление связным списком и массивом, сравнение
Идеальное, совершенное, полное, сбалансированное
двоичное дерево
Назначение и цели хеш-алгоритмов
Хеш-коллизии, цепная и открытая адресация
Принцип работы хеш-таблицы,
реализация на основе массива
Реализация на основе массива и связного списка
Последний пришел – первый вышел, первый
пришел – первый вышел, двусторонняя очередь
Временная
сложность
Перекрывающиеся подзадачи, оптимальная подструктура,
отсутствие последействия
Методы определения задач динамического
программирования, этапы решения
Примеры задач: рюкзак 0-1, полный рюкзак, расстояние
редактирования
Характеристики жадных задач, этапы решения
Примеры задач: дробный рюкзак, максимальная
вместимость, максимальное произведение разбиения
Перебор, рекурсия с мемоизацией, восходящая рекурсия
Примеры задач: перестановки, сумма
подмножеств, n ферзей
Задача поиска с возвратом, каркас кода
Примеры задач: двоичный поиск,
построение дерева, Ханойская башня
Стратегия «разделяй и властвуй»
«Разделяй и властвуй»: быстрая сортировка,
сортировка слиянием, пирамидальная сортировка
Без сравнения: блочная сортировка,
сортировка подсчетом, поразрядная сортировка
Перебор: сортировка выбором, пузырьком, вставками
Местность, устойчивость, адаптивность,
основанность на сравнении
Выбор алгоритма поиска
Перебор: линейный поиск, поиск
в ширину и глубину
Адаптивный поиск: двоичный
поиск, хеш-поиск, поиск по дереву
Компромисс между временем
и пространством
Методы вычисления, основные типы
Наихудшая, наилучшая, средняя
временная сложность
Методы вычисления, основные типы
Асимптотическая верхняя граница
Анализ
сложности
Сравнение итерации и рекурсии
Стек вызовов рекурсии, хвостовая
рекурсия, дерево рекурсии
Циклы for и while, вложенные циклы
Способы непрерывного и распределенного хранения
Методы работы, преимущества
и недостатки двух способов
Реализация списка на основе
динамического массива
Память и кеш в компьютере
Итерация
и рекурсия
Цифровое и символьное кодирование
Базовые типы данных
Логическая и физическая структура
0.1. О книге 13
14
Введение
2. Структуры данных: классификация основных типов данных и структур
данных. Определение, преимущества и недостатки, основные операции,
распространенные типы, типичные приложения и методы реализации массивов, списков, стеков, очередей, хеш-таблиц, деревьев, куч, графов и т. д.
3. Алгоритмы: определение, преимущества и недостатки, эффективность,
области применения, этапы решения и примеры задач для поиска, сортировки, алгоритма «разделяй и властвуй», обратного поиска, динамического программирования, жадных алгоритмов и т. д.
0.1.3. Благодарности
Эта книга постоянно совершенствуется благодаря совместным усилиям множества
участников открытого сообщества. Благодарим каждого автора, вложившего свое
время и силы. Имена перечислены в порядке, автоматически сгенерированном
GitHub: krahets, coderonion, Gonglja, nuomi1, Reanon, justin-tse, hpstory, danielsss,
curtishd, night-cruise, S-N-O-R-L-A-X, msk397, gvenusleo, khoaxuantu, RiverTwilight,
rongyi, gyt95, zhuoqinyue, K3v123, Zuoxun, mingXta, hello-ikun, FangYuan33, GN-Yu,
yuelinxin, longsizhuo, Cathay-Chen, guowei-gong, xBLACKICEx, IsChristina, JoseHung, qualifier1024, QiLOL, pengchzn, Guanngxu, L-Super, WSL0809, Slone123c, lhxsm,
yuan0221, what-is-me, theNefelibatas, longranger2, cy-by-side, xiongsp, JeffersonHuang, Transmigration-zhou, magentaqin, Wonderdch, malone6, xiaomiusa87, gaofer,
bluebean-cloud, a16su, Shyam-Chen, nanlei, hongyun-robot, Phoenix0415, MolDuM,
Nigh, he-weilai, junminhong, mgisr, iron-irax, yd-j, XiaChuerwu, XC-Zero, seven1240,
SamJin98, wodray, reeswell, NI-SW, Horbin-Magician, Enlightenus, xjr7670, YangXuanyi, DullSword, boloboloda, iStig, qq909244296, jiaxianhua, wenjianmin, keshida,
kilikilikid, lclc6, lwbaptx, liuxjerry, lucaswangdev, lyl625760, hts0000, gledfish, fbigm,
echo1937, szu17dmy, dshlstarr, Yucao-cy, coderlef, czruby, bongbongbakudan, beintentional, ZongYangL, ZhongYuuu, luluxia, xb534, bitsmi, ElaBosak233, baagod, zhouLion,
yishangzhang, yi427, yabo083, weibk, wangwang105, th1nk3r-ing, tao363, 4yDX3906,
syd168, steventimes, sslmj2020, smilelsb, siqyka, selear, sdshaoda, Xi-Row, popozhu,
nuquist19, noobcodemaker, XiaoK29, chadyi, ZhongGuanbin, shanghai-Jerry, JackYanghellobobo, Javesun99, lipusheng, BlindTerran, ShiMaRing, FreddieLi, FloranceYeh, iFleey, fanchenggang, gltianwen, goerll, Dr-XYZ, nedchu, curly210102, CuB3y0nd, KraHsu,
CarrotDLaw, youshaoXG, bubble9um, fanenr, eagleanurag, LifeGoesOnionOnionOnion,
52coder, foursevenlove, KorsChen, hezhizhen, linzeyan, ZJKung, GaochaoZhu, hopkings2008, yang-le, Evilrabbit520, Turing-1024-Lee, thomasq0, Suremotoo, Allen-Scai,
Risuntsy, Richard-Zhang1019, qingpeng9802, primexiao, nidhoggfgg, 1ch0, MwumLi,
martinx, ZnYang2018, hugtyftg, logan-qiu, psychelzh, Keynman, KeiichiKasai и 0130w.
Рецензирование кода книги выполнили coderonion, curtishd, Gonglja, gvenusleo, hpstory, justin-tse, khoaxuantu, krahets, night-cruise, nuomi1, Reanon и rongyi (в алфавитном порядке). Благодарим их за потраченное время и усилия, которые обеспечили стандартизацию и единообразие кода на различных языках.
В процессе создания этой книги мне помогало много людей.
1. Благодарю моего наставника в компании, доктора Ли Си (Li Xi), который
в одной из бесед вдохновил меня «действовать быстро», что укрепило
мою решимость написать эту книгу.
2. Благодарю мою девушку Паo Пао (Pao Pao), которая, будучи первым читателем книги, дала множество ценных советов с точки зрения новичка
в алгоритмах, что сделало книгу более понятной и доступной.
0.2. Как использовать эту книгу 15
3. Благодарю Тен Бао (Teng Bao), Ци Бао (Qi Bao) и Фей Бао (Fei Bao) за креативное название книги, которое навевает приятные воспоминания о написании первой строки кода «Hello World!».
4. Благодарю Сяо Цюань (Xiao Quan) за профессиональную помощь в вопросах интеллектуальной собственности, что сыграло важную роль в совершенствовании этой открытой книги.
5. Благодарю Су Тун (Su Tong) за дизайн обложки и логотипа книги, а также
за терпение при многократных исправлениях по моим просьбам.
6. Благодарю @squidfunk за советы по оформлению и за разработку открытой темы документации Material-for-MkDocs.
В процессе написания я ознакомился с множеством учебников и статей по
структурам данных и алгоритмам. Эти работы послужили отличным образцом
для этой книги, обеспечив ее точность и качество. Выражаю благодарность
всем преподавателям и предшественникам за их выдающийся вклад!
Настоящая книга пропагандирует метод обучения, сочетающий умственную
и практическую деятельность, на который меня вдохновила книга Dive into
Deep Learning («Погружение в глубокое обучение», на англ. языке). Настоятельно рекомендую эту замечательную работу всем читателям.
Сердечно благодарю своих родителей, ведь именно ваша постоянная
поддержка и ободрение дали мне возможность заняться этим увлекательным делом.
0.2. Как использовать эту книгу
Совет
Для наилучшего восприятия материала рекомендуется внимательно прочитать данный раздел.
0.2.1. Стиль изложения
Главы, имеющие символ * после заголовка, являются дополнительными
и содержат более сложный материал. Если у вас ограничено время, можно их пропустить.
Профессиональные термины выделяются полужирным шрифтом (в печатной и PDF-версии) или подчеркиванием (в веб-версии), например массив
(array). Рекомендуется запоминать их для удобства чтения литературы.
Важные моменты и обобщающие фразы выделяются полужирным
шрифтом, на такие тексты следует обращать особое внимание.
При упоминании терминов, различающихся в разных языках программирования, в качестве стандарта используется Python, например None
для обозначения «пустого значения».
В некоторых местах книга отходит от стандартов комментирования программного кода ради более компактного оформления. Комментарии делятся на три типа: заголовочные, содержательные и многострочные.
16
Введение
""" Заголовочные комментарии, используются для обозначения функций, классов,
тестовых примеров и т.д. """
# Содержательные комментарии, используются для пояснения кода.
"""
Многострочные
комментарии.
"""
0.2.2. Эффективное обучение с помощью
анимированных иллюстраций
Видео и изображения обладают более высокой плотностью информации
и структурированностью по сравнению с текстом, что облегчает понимание.
В этой книге ключевые и сложные моменты в основном представлены
в виде анимированных иллюстраций, а текстовая информация служит пояснением и дополнением.
Если какой-либо раздел в книге сопровождается анимационной иллюстрацией, как на рис. 0.2, используйте иллюстрацию в качестве основного источника информации, а текст – в качестве вспомогательного.
Рис. 0.2. Пример анимационной иллюстрации. Шаг 1
0.2. Как использовать эту книгу 17
Рис. 0.2. Продолжение. Шаги 2–3
18
Введение
Рис. 0.2. Окончание. Шаги 4–5
0.2. Как использовать эту книгу 19
0.2.3. Углубление понимания через практику написания кода
Сопроводительный код размещен в репозитории GitHub, он содержит тестовые примеры и может быть запущен одним нажатием кнопки, как показано на рис. 0.3.
Рис. 0.3. Запуск примера кода
Если позволяет время, рекомендуется самостоятельно набирать код. Если
время ограничено, по крайней мере просмотрите и выполните весь код.
Процесс написания кода приносит больше пользы, чем его чтение. Настоящее обучение – это обучение на практике.
Предварительная подготовка для запуска кода включает три этапа.
1. Установка локальной среды программирования. Следуйте инструкциям программы установки. Если среда уже установлена, этот шаг можно пропустить.
2. Клонирование или загрузка репозитория кода. Перейдите в репозиторий GitHub. Если у вас установлена утилита Git, можно клонировать
репозиторий с помощью следующей команды:
git clone https://github.com/krahets/hello-algo.git
Либо можно нажать кнопку Download ZIP (Скачать ZIP-архив), как показано на рис. 0.4, загрузить архив с кодом и затем распаковать его на локальном
компьютере.
20
Введение
Клонировать
репозиторий
Скачать
репозиторий
Рис. 0.4. Клонирование репозитория и загрузка кода
3. Запуск исходного кода. Если для блока кода в книге указано имя файла, этот файл можно найти в папке codes репозитория, как показано на
рис. 0.5. Исходный код можно запустить одним нажатием, что поможет
вам сэкономить время на отладку и сосредоточиться на изучении материала.
Имя файла кода
Соответствующий
файл кода
Рис. 0.5. Блоки кода и соответствующие файлы исходного кода
0.2. Как использовать эту книгу 21
Помимо локального запуска, в веб-версии книги код на Python можно
выполнить в визуальной среде (реализовано на основе pythontutor). Для
этого нажмите кнопку Визуализировать выполнение под блоком кода, как
показано на рис. 0.6. Также в раскрывшемся окне можно нажать кнопку Открыть в полноэкранном режиме для более удобного просмотра1.
Рис. 0.6. Визуальное выполнение кода Python
0.2.4. Совместное развитие через вопросы и обсуждения
При чтении книги не оставляйте без внимания непонятные моменты. Мы призываем вас задавать вопросы в разделе комментариев, и я вместе с коллегами постараюсь ответить вам в течение двух дней.
В конце каждой главы веб-версии книги предусмотрено место для комментариев, как показано на рис. 0.7. Рекомендуется уделять внимание содержимому этой области. С одной стороны, это позволит вам понять, с какими проблемами сталкиваются другие читатели, что поможет выявить пробелы в знаниях
и стимулировать более глубокое понимание. С другой стороны, мы надеемся,
что вы будете отвечать на вопросы других участников и делиться своими мнениями.
1
Функция визуального выполнения кода доступна только в китайской веб-версии
книги – Прим. перев.
22
Введение
Рис. 0.7. Пример раздела комментариев
0.2.5. Дорожная карта изучения алгоритмов
Процесс изучения структур данных и алгоритмов можно разделить на три этапа.
1. Введение в алгоритмы. Необходимо ознакомиться с особенностями
и применением различных структур данных, изучить принципы, процессы, назначение и эффективность различных алгоритмов.
2. Решение алгоритмических задач. Рекомендуется начинать с популярных задач и решить не менее 100 из них, чтобы познакомиться с основными алгоритмическими проблемами. При первом решении задач вы
можете столкнуться с так называемым забвением знаний – не беспокойтесь, это нормально. Следуйте при повторении задач кривой забывания
Эббингауза, обычно после 3–5 циклов повторения они хорошо запоминаются. Рекомендуемые списки задач и планы решения можно найти
в этом репозитории GitHub.
3. Построение системы знаний. В процессе обучения можно читать статьи
по алгоритмам, изучать типичные решения и учебники по алгоритмам,
чтобы постоянно обогащать систему знаний. В решении задач можно применять продвинутые стратегии, такие как классификация по темам, множественные решения одной задачи или одно решение для множества задач.
Советы по этим техникам обучения можно найти в различных сообществах.
Эта книга в основном охватывает первый этап и призвана помочь вам эффективно подготовиться ко второму и третьему этапам обучения, как показано на рис. 0.8.
0.3. Резюме 23
Этап 1
Базовые сведения
Книга по алгоритмам
для начинающих
Этап 2
Тренировка навыков
Этап 3
Построение системы знаний
Решение
алгоритмических задач
Изучение типичных решений
Каркас решения задач
Учебник по алгоритмам
Периодическое повторение
Повторное решение одной и той же
задачи через определенное время
для формирования долговременной
памяти и углубления понимания
Активное обобщение
Систематизация содержания, выявление закономерностей для построения
целостной системы знаний
Рис. 0.8. Дорожная карта изучения алгоритмов
0.3. Резюме
Основная аудитория книги – новички в изучении алгоритмов. Если у вас
уже есть определенная база, книга поможет систематизировать имеющиеся знания об алгоритмах, а исходный код послужит инструментальной библиотекой для решения задач.
Содержание книги включает три основные части – анализ сложности,
структуры данных и алгоритмы – и охватывает большинство тем в этой
области.
Для новичков в алгоритмах крайне важно изучить начальные разделы
книги, чтобы избежать множества ошибок в будущем.
Анимированные иллюстрации в книге обычно используются для представления ключевых и сложных аспектов. При чтении книги следует уделять этим материалам большое внимание.
Практика – лучший способ изучения программирования. Настоятельно
рекомендуется запускать исходный код и самостоятельно писать программы.
В веб-версии книги каждая глава имеет область комментариев, где вы
можете задавать вопросы и делиться своим мнением.
Глава 1
Введение в алгоритмы
Абстракция
Юная девушка танцует, окруженная вихрем данных. С подола ее платья струится мелодия алгоритмов.
Она приглашает вас на танец. Следуйте за ней, чтобы войти в мир алгоритмов, полный логики и красоты.
1.1. Алгоритмы повсюду 25
1.1. Алгоритмы повсюду
Говоря об алгоритмах, естественно вспомнить о математике. Однако на самом
деле многие алгоритмы не связаны со сложной математикой, а больше полагаются на базовую логику, которая повсеместно встречается в нашей повседневной жизни.
Прежде чем углубиться в обсуждение алгоритмов, стоит упомянуть интересный факт: вы уже точно освоили множество алгоритмов и привыкли применять их в повседневной жизни. Далее приведем несколько конкретных
примеров, чтобы подтвердить этот факт.
Пример 1: поиск в словаре. В словаре все слова упорядочены по алфавиту.
Предположим, нам нужно найти слово, начинающееся на букву «r». Обычно
для этого нужно выполнить следующие действия (см. рис. 1.1):
1) открыть словарь примерно на половине страниц и посмотреть, какая
буква является первой на этой странице, – предположим, это буква «m»;
2) поскольку в алфавите буква «r» идет после «m», исключаем первую половину словаря, и область поиска сужается до второй половины;
3) продолжаем повторять шаги 1 и 2, пока не найдем страницу, где первой
буквой слов будет «r».
Словарь упорядочен по алфавиту
Цель поиска: найти любое слово на букву «r»
Шаг 1
Рис. 1.1. Этапы поиска в словаре. Шаг 1
26
Введение в алгоритмы
Половина страниц
Этапы поиска в словаре
1. Открыть словарь примерно на половине страниц – первая буква «m».
2. Поскольку «r» находится после «m», исключить первую половину.
Шаг 2
Половина страниц
1. Открыть оставшуюся часть словаря примерно на половине страниц – первая буква «t».
2. Поскольку «r» находится перед «t», исключить вторую половину.
Шаг 3
Рис. 1.1. Продолжение. Шаги 2–3
1.1. Алгоритмы повсюду 27
Половина страниц
1. Открыть оставшуюся часть словаря примерно на половине страниц – первая буква «p».
2. Поскольку «r» находится после «p», исключить первую половину.
Шаг 4
Половина страниц
1. Открыть оставшуюся часть словаря примерно на половине страниц – первая буква «r».
2. Найдено слово на букву «r», задача выполнена.
Шаг 5
Рис. 1.1. Окончание. Шаги 4–5
28
Введение в алгоритмы
Навык поиска в словаре, которым владеет каждый школьник, на самом деле
является известным алгоритмом двоичного поиска. С точки зрения структуры
данных словарь можно рассматривать как отсортированный массив. С точки
зрения алгоритма последовательность операций по поиску в словаре можно
считать двоичным поиском.
Пример 2: упорядочивание карт. Во время игры в карты необходимо каждый раз упорядочивать карты в руке от меньшего к большему. Для этого нужно
выполнить следующие действия (см. рис. 1.2):
1) разделить карты на упорядоченную и неупорядоченную части, предполагая, что изначально самая левая карта уже упорядочена;
2) из неупорядоченной части извлечь одну карту и вставить ее в правильное место в упорядоченной части. После этого две самые левые карты
станут упорядоченными;
3) повторять шаг 2, каждый раз перемещая одну карту из неупорядоченной
части в упорядоченную, пока все карты не станут упорядоченными.
Неупорядоченно
Упорядоченно
Упорядоченно
Неупорядоченно
Упорядоченно
Неупорядоченно
Упорядоченно
Упорядоченно
Неупорядоченно
Неупорядоченно
Рис. 1.2. Этапы упорядочивания карт
Метод упорядочивания карт по своей сути является алгоритмом сортировки вставками, который весьма эффективен при обработке небольших наборов
данных. Многие функции сортировки в библиотеках программирования используют именно этот алгоритм.
Пример 3: сдача. Предположим, что в супермаркете мы купили товар стоимостью 69 руб. и дали кассиру купюру в 100 руб. Кассир должен вернуть нам 31
руб. Для этого ему нужно выполнить действия, показанные на рис. 1.3.
1. Варианты выбора – это купюры номиналом меньше 31 руб. Пусть у нас
имеются номиналы 1, 5, 10 и 20 руб.
2. Взять самую крупную доступную купюру в 20 руб. Остаток сдачи составит 31 − 20 = 11 руб.
3. Взять самую крупную из оставшихся купюр в 10 руб. Остаток составит
11 − 10 = 1 руб.
1.1. Алгоритмы повсюду 29
4. Взять самую крупную из оставшихся купюр в 1 руб. Остаток составит
1 − 1 = 0 руб.
5. Завершить выдачу сдачи, схема: 20 + 10 + 1 = 31 руб.
Целевая сумма
Выбранный номинал
Рис. 1.3. Этапы выдачи сдачи
В этих шагах мы на каждом этапе выбираем наилучший вариант (используя
купюры наибольшего номинала), в итоге получая оптимальную схему сдачи.
С точки зрения структуры данных и алгоритмов этот метод по своей сути является жадным алгоритмом.
От приготовления блюда до межзвездных путешествий − решение практически любой задачи неразрывно связано с алгоритмами. Появление компьютеров позволило нам с помощью программирования хранить структуры данных в памяти, а также писать код для вызовов к центральному и графическому
процессору для выполнения алгоритмов. Таким образом, мы можем переносить задачи из реальной жизни в компьютер, решая различные сложные проблемы более эффективно.
Совет
Если вы все еще испытываете трудности с пониманием таких понятий, как
структуры данных, алгоритмы, массивы и двоичный поиск, продолжайте
чтение. Эта книга проведет вас в мир знаний об этих фундаментальных
понятиях.
30
Введение в алгоритмы
1.2. Что такое алгоритм
1.2.1. Определение алгоритма
Алгоритм – это набор инструкций или шагов, предназначенных для решения
конкретной задачи за ограниченное время, обладающий следующими свойствами:
1) задача четко определена, включает ясные определения входных и выходных данных;
2) обладает осуществимостью, может быть выполнен за ограниченное количество шагов, времени и памяти;
3) каждый шаг имеет определенное значение, при одинаковых входных
данных и условиях выполнения результат всегда будет одинаковым.
1.2.2. Определение структуры данных
Структура данных – это способ организации и хранения данных, включающий
содержимое данных, их взаимосвязи и методы операций с ними. Структура
данных преследует следующие цели:
1) минимизацию занимаемого пространства для экономии памяти компьютера;
2) максимально быструю обработку данных, включая доступ, добавление,
удаление и обновление данных;
3) обеспечение простого представления данных и логической информации
для эффективного выполнения алгоритмов.
Проектирование структуры данных – это процесс, полный компромиссов. Если вы хотите улучшить один аспект, часто приходится идти на уступки
в другом. Приведем два примера.
1. Связный список, по сравнению с массивом, более удобен для добавления
и удаления данных, но имеет проблемы со скоростью доступа к данным.
2. Граф, по сравнению со связным списком, предоставляет более богатую
логическую информацию, но требует большего объема памяти.
1.2.3. Взаимосвязь структур данных и алгоритмов
Структуры данных и алгоритмы тесно взаимосвязаны, что проявляется в следующих трех аспектах (см. рис. 1.4):
1) структуры данных являются основой алгоритмов. Они обеспечивают
структурированное хранение данных и методы их обработки;
2) алгоритмы оживляют структуры данных. Сами по себе структуры данных лишь хранят информацию, но в сочетании с алгоритмами они позволяют решать конкретные задачи;
3) алгоритмы можно реализовать на основе различных структур данных,
однако эффективность их выполнения может значительно различаться.
Поэтому выбор подходящей структуры данных является ключевым фактором.
1.2. Что такое алгоритм 31
Содержит серию инструкций по выполнению операций
для преобразования входных данных в выходные
Входные
данные
Алгоритм
Выходные
данные
Структура данных
Обеспечивают структурированное хранение данных
и методы их обработки
Рис. 1.4. Взаимосвязь структур данных и алгоритмов
Структуры данных и алгоритмы подобны конструктору, как показано на
рис. 1.5. Комплект конструктора, помимо множества деталей, содержит также подробную инструкцию по сборке. Следуя этой инструкции шаг за шагом,
можно собрать красивую модель.
Рис. 1.5. Сборка конструктора
Подробное описание аналогии с конструктором представлено в табл. 1.1.
32
Введение в алгоритмы
Таблица 1.1. Сравнение структур данных и алгоритмов с конструктором
Структуры данных и алгоритмы
Конструктор
Входные данные
Несобранные детали конструктора
Структура данных
Организация деталей конструктора, включая форму, размер, способы соединения и т. д.
Алгоритм
Последовательность действий по сборке деталей
в целевую модель
Выходные данные
Собранная модель конструктора
Стоит отметить, что структуры данных и алгоритмы не зависят от языка
программирования. Именно поэтому данная книга предлагает их реализации
на различных языках.
Условные сокращения
На практике мы часто сокращаем «структуры данных и алгоритмы» до просто
«алгоритмы». Например, известные задачи на платформе LeetCode на самом
деле проверяют знания как структур данных, так и алгоритмов.
1.3. Резюме
Алгоритмы повсеместно присутствуют в нашей повседневной жизни
и не являются недосягаемыми сложными знаниями. На самом деле мы
уже освоили множество алгоритмов, которые помогают решать различные жизненные задачи.
Принцип поиска в словаре соответствует алгоритму двоичного поиска. Бинарный поиск иллюстрирует важную идею алгоритмов «разделяй
и властвуй».
Процесс сортировки карт в колоде очень похож на алгоритм сортировки
вставками, который хорошо подходит для сортировки небольших наборов данных.
Процесс размена валюты по своей сути является жадным алгоритмом,
в котором на каждом этапе принимается наилучшее на данный момент
решение.
Алгоритм представляет собой набор инструкций или шагов, предназначенных для решения конкретной задачи в ограниченное время, а структура данных – это способ организации и хранения данных в компьютере.
Структуры данных и алгоритмы тесно связаны. Структуры данных являются основой для алгоритмов, а алгоритмы оживляют структуры данных.
Структуры данных и алгоритмы можно сравнить с конструктором: детали конструктора представляют данные, их форма и способы соединения – структуры данных, а этапы сборки конструктора соответствуют
алгоритмам.
1.3. Резюме 33
1. Вопросы и ответы
Вопрос. Я программист, и я никогда не использовал алгоритмы для решения
задач в своей повседневной работе, поскольку часто используемые алгоритмы уже встроены в языки программирования и их можно использовать напрямую. Означает ли это, что задачи, с которыми мы сталкиваемся на работе,
не требуют применения алгоритмов?
Ответ. Если сравнить конкретные профессиональные навыки с приемами
в боевых искусствах, то базовые дисциплины скорее напоминают «внутреннюю силу».
Я считаю, что изучение алгоритмов (и других базовых дисциплин) важно
не для того, чтобы реализовывать их с нуля в работе, а для того, чтобы на основе полученных знаний принимать профессиональные решения и оценки при
решении задач, тем самым повышая общее качество работы. Простой пример:
каждый язык программирования имеет встроенные функции сортировки.
Если бы мы не изучали структуры данных и алгоритмы, то, получив любые
данные, мы, возможно, просто передали бы их этой функции сортировки. Все
работает гладко, производительность хорошая, и на первый взгляд проблем нет.
Однако если мы изучили алгоритмы, то знаем, что временная сложность
встроенной функции сортировки составляет O(n log n). Если же данные представлены целыми числами фиксированной разрядности (например, номерами
студентов), то можно использовать более эффективный метод поразрядной
сортировки, снизив временную сложность до O(nk), где k – это количество разрядов. При больших объемах данных экономия времени выполнения может
привести к значительным преимуществам, таким как снижение затрат и улучшение пользовательского опыта.
В инженерной практике множество задач трудно решить оптимальным образом, и многие из них решаются «как-то». Сложность задачи зависит как от ее
природы, так и от уровня знаний и опыта человека, который ее анализирует.
Чем более полными знаниями и большим опытом обладает человек, тем глубже
он может проанализировать проблему и тем изящнее может быть ее решение.
Глава 2
Анализ сложности
Абстракция
Анализ сложности подобен путеводителю в бескрайней вселенной алгоритмов. Он позволяет нам углубиться в измерения времени и пространства, чтобы найти более изящные решения.
2.1. Оценка эффективности алгоритмов 35
2.1. Оценка эффективности алгоритмов
В процессе разработки алгоритмов мы стремимся к достижению следующих
целей:
1) найти решение задачи: алгоритм должен надежно находить правильное решение задачи в заданных пределах входных данных;
2) найти оптимальное решение: для одной и той же задачи может существовать несколько решений, и мы стремимся найти максимально эффективный алгоритм.
Таким образом, при условии возможности решения задачи эффективность
алгоритма становится основным критерием его оценки, который включает
два аспекта:
1) временную эффективность: продолжительность выполнения алгоритма;
2) пространственную эффективность: объем памяти, занимаемой алгоритмом.
В двух словах, наша цель – разработка быстрых и экономных структур
данных и алгоритмов. Эффективная оценка алгоритмов крайне важна, так
как только так можно сравнивать различные алгоритмы и управлять процессом их разработки и оптимизации.
Методы оценки эффективности делятся на два типа: практическое тестирование и теоретическую оценку.
2.1.1. Практическое тестирование
Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же задачу, и необходимо сравнить их эффективность. Самый прямой метод – это запустить оба алгоритма на компьютере и зафиксировать время их выполнения
и объем используемой памяти. Этот метод отражает реальную ситуацию, но
имеет значительные ограничения.
С одной стороны, сложно исключить влияние факторов тестовой среды. Аппаратная конфигурация влияет на производительность алгоритма.
Например, если алгоритм обладает высокой степенью параллелизма, он
будет лучше работать на многоядерных процессорах. Если алгоритм интенсивно использует память, его производительность будет выше на высокопроизводительной памяти. Это означает, что результаты тестирования на
разных машинах могут значительно отличаться, и потребуется тестирование на различных платформах для получения средней эффективности, что
крайне затруднительно.
С другой стороны, проведение полного тестирования требует значительных ресурсов. С изменением объема входных данных алгоритмы демонстрируют разную эффективность. Например, при небольшом объеме данных
алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме
данных результат может быть противоположным. Следовательно, для получения убедительных выводов необходимо тестировать различные масштабы
входных данных, что требует значительных вычислительных ресурсов.
36
Анализ сложности
2.1.2. Теоретическая оценка
Из-за значительных ограничений практического тестирования можно рассмотреть возможность оценки эффективности алгоритмов только с помощью
математических расчетов. Этот метод называется анализом асимптотической
сложности или просто анализом сложности.
Анализ сложности позволяет отразить зависимость между ресурсами времени и пространства, необходимыми для выполнения алгоритма, и размером
входных данных. Он описывает тенденцию роста времени и пространства, необходимых для выполнения алгоритма, по мере увеличения размера входных данных. Это определение может показаться сложным, но его
можно разбить на три ключевых момента.
1. Ресурсы времени и пространства соответствуют временной сложности
и пространственной сложности.
2. «По мере увеличения размера входных данных» означает, что сложность
отражает зависимость эффективности алгоритма от объема входных
данных.
3. Тенденция роста времени и пространства указывает, что анализ сложности фокусируется не на конкретных значениях времени выполнения
или объема занимаемой памяти, а на скорости их роста.
Анализ сложности преодолевает недостатки метода практического тестирования, что выражается в следующих аспектах:
1) он не требует фактического выполнения кода, что делает его более экологичным и энергосберегающим;
2) он независим от тестовой среды, а результаты анализа применимы ко
всем платформам выполнения;
3) он может продемонстрировать эффективность алгоритма при различных объемах данных, особенно при больших объемах.
Совет
Если вы все еще испытываете затруднения с понятием сложности, не беспокойтесь, в последующих главах мы рассмотрим его более подробно.
Анализ сложности предоставляет нам мерило оценки эффективности алгоритмов, позволяя измерять время и ресурсы, необходимые для выполнения конкретного алгоритма, а также сравнивать эффективность различных
алгоритмов.
Сложность – это математическое понятие, которое новичкам может показаться абстрактным и сложным для изучения. С этой точки зрения анализ
сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуждая особенности той или иной структуры данных или алгоритма, невозможно
избежать анализа их скорости выполнения и использования памяти.
2.2. Итерация и рекурсия 37
Таким образом, перед погружением в изучение структур данных и алгоритмов рекомендуется получить базовое представление об анализе сложности, чтобы иметь возможность выполнять хотя бы базовую оценку их эффективности.
2.2. Итерация и рекурсия
В алгоритмах часто требуется повторное выполнение определенной задачи,
что тесно связано с анализом сложности. Поэтому, прежде чем перейти к обсуждению временной и пространственной сложности, рассмотрим, как реализовать повторное выполнение задач в программе, а именно две основные
структуры управления программой: итерацию и рекурсию.
2.2.1. Итерации
Итерация – это структура управления, которая позволяет повторно выполнять
определенную задачу. В итерации программа повторяет выполнение определенного участка кода, пока выполняется определенное условие.
1. Цикл for
Цикл for – одна из наиболее распространенных форм итерации, которая подходит для использования, когда количество итераций известно заранее.
Следующая функция реализует суммирование 1 + 2 + ... + n с использованием цикла for, результат суммирования сохраняется в переменной res.
Следует отметить, что в Python диапазон range(a, b) соответствует левому закрытому, правому открытому интервалу, т. е. перебираются значения
a, a + 1, ... , b − 1:
# === File: iteration.py ===
def for_loop(n: int) -> int:
"""Цикл for."""
res = 0
# Цикл суммирования 1, 2, ..., n-1, n.
for i in range(1, n + 1):
res += i
return res
Количество операций этой функции суммирования пропорционально размеру входных данных n, или, другими словами, линейно зависит от него.
На самом деле временная сложность описывает именно эту линейную
зависимость. Соответствующий материал будет подробно рассмотрен в следующем разделе.
38
Анализ сложности
Начало
Цикл
Инициализация
i=1
i≤n
Ложь
Обновление Истина
i += 1
Задача
Конец
Рис. 2.1. Блок-схема функции суммирования
2. Цикл while
Подобно циклу for, цикл while также представляет собой метод реализации
итерации. В цикле while программа перед каждой итерацией проверяет условие: если условие истинно, то выполнение продолжается, иначе цикл завершается.
Ниже приведен пример реализации суммирования 1 + 2 + ... + n с использованием цикла while:
# === File: iteration.py ===
def while_loop(n: int) -> int:
"""Цикл while."""
res = 0
i = 1 # Инициализация условной переменной.
# Цикл для суммирования 1, 2, ..., n-1, n.
while i <= n:
res += i
i += 1 # Обновление значения условной переменной.
return res
Цикл while обладает большей степенью свободы по сравнению с циклом for. В цикле while можно свободно управлять инициализацией и обновлением условной переменной.
2.2. Итерация и рекурсия 39
Например, в следующем коде условная переменная i обновляется дважды на каждой итерации, что затруднительно сделать с использованием
цикла for:
# === File: iteration.py ===
def while_loop_ii(n: int) -> int:
"""Цикл while (двойное обновление)."""
res = 0
i = 1 # Инициализация условной переменной.
# Цикл для суммирования 1, 4, 10, ...
while i <= n:
res += i
# Обновление значения условной переменной.
i += 1
i *= 2
return res
В целом код с использованием цикла for более компактный, а цикл
while более гибкий. Но они оба могут реализовать итерационную структуру.
Выбор между ними определяется требованиями конкретной задачи.
3. Вложенные циклы
Внутрь одной циклической структуры можно вложить другую, например
используя два цикла for:
# === File: iteration.py ===
def nested_for_loop(n: int) -> str:
"""Двойной цикл for."""
res = ""
# Цикл i = 1, 2, ..., n-1, n.
for i in range(1, n + 1):
# Цикл j = 1, 2, ..., n-1, n
for j in range(1, n + 1):
res += f"({i}, {j}), "
return res
В этом случае количество выполненных действий пропорционально n2, или,
другими словами, время выполнения алгоритма и размер входных данных
n находятся в квадратичной зависимости.
Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет
повышать размерность, увеличивая временную сложность до кубической зависимости, зависимости четвертой степени и т. д.
40
Анализ сложности
Начало
Ложь
Истина
Внутренний цикл
Ложь
Истина
Задача
Внешний
цикл
Конец
Рис. 2.2. Блок-схема вложенного цикла
2.2.2. Рекурсия
Рекурсия – это стратегия алгоритма, при которой функция вызывает саму себя
для решения задачи. Она включает два основных этапа.
1. Вызов: программа постоянно вызывает саму себя, обычно передавая
меньшие или более упрощенные параметры, пока не будет достигнуто
условие завершения.
2. Возврат: после срабатывания условия завершения программа начинает
возвращаться из самой глубокой рекурсивной функции, объединяя результаты каждого уровня.
С точки зрения реализации рекурсивный код включает три основных элемента.
1. Условие завершения: используется для определения момента перехода
от вызова к возврату.
2. Рекурсивный вызов: соответствует вызову, функция вызывает саму
себя, обычно с меньшими или упрощенными параметрами.
3. Возврат результата: соответствует возврату, возвращает результат текущего уровня рекурсии на предыдущий уровень.
Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить
сумму 1 + 2 + ... + n.
2.2. Итерация и рекурсия 41
# === File: recursion.py ===
def recur(n: int) -> int:
""" Рекурсия."""
# Условие завершения.
if n == 1:
return 1
# Вызов: рекурсивный вызов.
res = recur(n - 1)
# Возврат: возврат результата.
return n + res
Нисходящая «вызов»
Восходящая «возврат»
Условие завершения
рекурсии
Рис. 2.3. Рекурсивный вызов функции суммирования
Хотя с точки зрения вычислений итерация и рекурсия могут давать одинаковый результат, они представляют собой совершенно разные парадигмы
мышления и решения задач.
Итерация: решение задачи снизу вверх. Начинаем с самых базовых шагов,
которые затем повторяются или накапливаются до завершения задачи.
Рекурсия: решение задачи сверху вниз. Исходная задача разбивается на
более мелкие подзадачи, которые имеют ту же форму, что и исходная задача. Далее подзадачи продолжают делиться на еще более мелкие, пока
не достигается базовый случай (решение базового случая известно).
Рассмотрим в качестве примера вышеупомянутую функцию суммирования,
где решается задача f(n) = 1 + 2 + ... + n.
Итерация: моделирование процесса суммирования в цикле проходит от
1 до n, выполняя операцию суммирования на каждом шаге, чтобы получить итоговое значение f(n).
Рекурсия: последовательное разбиение задачи на подзадачи вида f(n) =
n + f(n – 1) до достижения базового случая f(1) = 1.
42
Анализ сложности
1. Стек вызовов
Каждый раз, когда рекурсивная функция вызывает саму себя, система выделяет память для нового вызова функции, чтобы хранить локальные переменные,
адрес вызова и другую информацию. Это поведение имеет два последствия.
1. Контекстные данные функции хранятся в области памяти, называемой пространством стекового кадра, и освобождаются только после возврата функции. Поэтому рекурсия обычно требует больше памяти, чем итерация.
2. Рекурсивный вызов функции создает дополнительные накладные расходы.
Поэтому рекурсия обычно менее эффективна по времени, чем цикл.
До срабатывания условия завершения одновременно существует n невозвращенных рекурсивных функций, как показано на рис. 2.4. Число n называется глубиной рекурсии.
Нисходящая «вызов»
Восходящая «возврат»
Глубина
рекурсии n
Условие завершения
рекурсии
Рис. 2.4. Глубина рекурсивного вызова
На практике глубина рекурсии, разрешенная языком программирования,
обычно ограничена, и слишком глубокая рекурсия может привести к ошибке
переполнения стека.
2. Хвостовая рекурсия
Интересно, что если рекурсивный вызов происходит на последнем шаге перед
возвратом функции, то компилятор или интерпретатор может оптимизировать этот вызов, сделав его по эффективности использования памяти сопоставимым с итерацией. Это называется хвостовой рекурсией.
Обычная рекурсия: когда функция возвращается на предыдущий уровень, необходимо продолжить выполнение кода, поэтому системе нужно
сохранить контекст предыдущего вызова.
Хвостовая рекурсия: рекурсивный вызов является последней операцией перед возвратом функции, что означает, что после возврата на предыдущий уровень не требуется выполнять другие операции, поэтому системе не нужно сохранять контекст предыдущей функции.
2.2. Итерация и рекурсия 43
В качестве примера вычисления суммы 1 + 2 + ... + n можно установить переменную результата res в качестве параметра функции, чтобы реализовать хвостовую рекурсию:
# === File: recursion.py ===
def tail_recur(n, res):
""" Хвостовая рекурсия. """
# Условие завершения.
if n == 0:
return res
# Хвостовой рекурсивный вызов.
return tail_recur(n - 1, res + n)
Процесс выполнения хвостовой рекурсии показан на рис. 2.5. Сравнивая
обычную и хвостовую рекурсии, можно заметить, что точка выполнения операции суммирования у них различается.
Обычная рекурсия: операция суммирования выполняется в процессе
возврата, после каждого возврата необходимо снова выполнить операцию суммирования.
Хвостовая рекурсия: операция суммирования выполняется в процессе
вызова, процесс возврата требует только последовательного возврата.
Нисходящая «вызов»
Восходящая «возврат»
Условие
завершения
рекурсии
Рис. 2.5. Процесс выполнения хвостовой рекурсии
Совет
Обратите внимание, что многие компиляторы или интерпретаторы не поддерживают оптимизацию хвостовой рекурсии. Например, Python по умолчанию не поддерживает оптимизацию хвостовой рекурсии, поэтому, даже
если функция имеет форму хвостовой рекурсии, все равно может возникнуть проблема переполнения стека.
44
Анализ сложности
3. Дерево рекурсии
При решении задач, связанных с алгоритмами типа «разделяй и властвуй», рекурсия зачастую оказывается более интуитивной и читабельной, чем итерация. Рассмотрим в качестве примера последовательность Фибоначчи.
Задача
Дана последовательность Фибоначчи 0, 1, 1, 2, 3, 5, 8, 13, ..., требуется найти
n-й член этой последовательности.
Обозначив n-й член последовательности Фибоначчи как f(n), можно сформулировать два утверждения.
1. Первые два числа последовательности: f(1) = 0 и f(2) = 1.
2. Каждое число последовательности является суммой двух предыдущих
чисел, т. е. f(n) = f(n − 1) + f(n − 2).
Используя рекурсивные вызовы в соответствии с рекуррентным соотношением и принимая первые два числа за условия остановки, можно написать рекурсивный код. Вызов fib(n) позволит получить n-й член последовательности
Фибоначчи.
# === File: recursion.py ===
def fib(n: int) -> int:
""" Последовательность Фибоначчи: рекурсия. """
# Условия остановки f(1) = 0, f(2) = 1
if n == 1 or n == 2:
return n - 1
# Рекурсивный вызов f(n) = f(n-1) + f(n-2).
res = fib(n - 1) + fib(n - 2)
# Возврат результата f(n).
return res
Проанализировав приведенный код, можно заметить, что внутри функции осуществляется рекурсивный вызов двух функций, т. е. из одного вызова образуются два ветвления. При последующем выполнении рекурсивных
вызовов в итоге образуется рекурсивное дерево глубиной n, как показано на
рис. 2.6.
По своей сути рекурсия отражает парадигму мышления «разбиение задачи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй»
крайне важной.
С точки зрения алгоритмов многие важные алгоритмические стратегии,
такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое
программирование, прямо или косвенно используют этот подход.
С точки зрения структур данных рекурсия естественно подходит для решения задач, связанных со списками, деревьями и графами, поскольку они очень
хорошо поддаются анализу с использованием идеи «разделяй и властвуй».
2.2. Итерация и рекурсия 45
Рис. 2.6. Рекурсивное дерево последовательности Фибоначчи
2.2.3. Сравнение
Подводя итог, можно сказать, что итерация и рекурсия различаются по реализации, производительности и применимости, как показано в табл. 2.1.
Таблица 2.1. Сравнение итерации и рекурсии
Итерация
Рекурсия
Способ реализации
Циклическая структура
Функция вызывает саму себя
Временная
эффективность
Обычно высокая
Каждый вызов функции создает
эффективность, нет
затраты
затрат на вызов функции
Использование
памяти
Обычно используется
фиксированный объем
памяти
Накопление вызовов функции может
использовать значительное количество
пространства стека
Сфера использования
Подходит для простых
циклических задач, код
интуитивно понятен
и хорошо читаем
Подходит для разбиения на подзадачи;
для структур деревья, графы; алгоритмов «разделяй и властвуй», возврат
и т. д.; структура кода проста и ясна
Совет
Если у вас возникли трудности с пониманием следующего материала,
можно вернуться к нему после изучения главы «Стек».
Какова же внутренняя связь между итерацией и рекурсией? В рассмотренном примере рекурсивной функции операция сложения выполняется на этапе
возврата рекурсии. Это означает, что функция, вызванная первой, фактически
завершает операцию сложения последней, что соответствует принципу стека «первым пришел – последним вышел».
Фактически такие термины рекурсии, как «вызов стека» и «пространство
стекового кадра», уже намекают на тесную связь между рекурсией и стеком.
46
Анализ сложности
1. Вызов: когда вызывается функция, система выделяет новый стековый
кадр в вызове стека» для хранения локальных переменных функции, параметров, адреса возврата и других данных.
2. Возврат: когда функция завершает выполнение и возвращает результат,
соответствующий стековый кадр удаляется из вызова стека, восстанавливая среду выполнения предыдущей функции.
Таким образом, можно использовать явный стек для моделирования поведения вызова стека, чтобы преобразовать рекурсию в итеративную форму.
# === File: recursion.py ===
def for_loop_recur(n: int) -> int:
""" Использование итерации для моделирования рекурсии. """
# Использование явного стека для моделирования системного вызова стека.
stack = []
res = 0
# Вызов: рекурсивный вызов.
for i in range(n, 0, -1):
# Моделирование вызова через операцию добавления в стек.
stack.append(i)
# Возврат: возвращение результата.
while stack:
# Моделирование возврата через операцию удаления из стека.
res += stack.pop()
# res = 1+2+3+...+n
return res
Проанализировав приведенный код, можно заметить, что после преобразования рекурсии в итерацию код становится более сложным. Хотя в большинстве случаев итерацию и рекурсию можно взаимно преобразовать, это не всегда оправдано по следующим двум причинам:
1) преобразованный код может стать более трудным для понимания, с худшей читаемостью;
2) для некоторых сложных задач моделирование поведения системного
вызова стека может быть крайне сложным.
В общем, выбор между итерацией и рекурсией зависит от природы конкретной задачи. В программной практике крайне важно взвешивать преимущества и недостатки обоих подходов и выбирать наиболее подходящий метод
в зависимости от ситуации.
2.3. Временная сложность
Время выполнения является наглядным и точным показателем эффективности
алгоритма. Что же нам нужно сделать, чтобы точно оценить время выполнения кода?
1. Определить платформу выполнения, включая аппаратную конфигурацию, язык программирования, системную среду и т. д., так как эти
факторы влияют на эффективность выполнения кода.
2.3. Временная сложность 47
2. Оценить время выполнения различных вычислительных операций − например, операция сложения + требует 1 нс, операция умножения * требует 10 нс, операция печати print() требует 5 нс и т. д.
3. Подсчитать все вычислительные операции в коде и суммировать
время выполнения всех операций, чтобы получить общее время выполнения.
Например, в следующем коде размер входных данных равен n:
# На некоторой платформе выполнения.
def algorithm(n: int):
a = 2 # 1 нс.
a = a + 1 # 1 нс.
a = a * 2 # 10 нс.
# Цикл n итераций.
for _ in range(n): # 1 нс.
print(0) # 5 нс.
Согласно вышеописанному методу можно определить, что время выполнения алгоритма равно (6n + 12) нс:
1 + 1 + 10 + (1 + 5) × n = 6n + 12.
Однако на практике подсчет времени выполнения алгоритма не является ни
разумным, ни реалистичным. Во-первых, мы не хотим связывать оценочное
время с конкретной платформой выполнения, так как алгоритм должен работать на различных платформах. Во-вторых, очень трудно узнать время выполнения каждой операции, и это значительно усложняет процесс оценки.
2.3.1. Тенденция роста времени
Анализ временной сложности не оценивает время выполнения алгоритма,
а исследует тенденцию роста времени выполнения алгоритма по мере
увеличения объема данных.
Понятие «тенденция роста времени» довольно абстрактно, и для его лучшего понимания рассмотрим пример. Предположим, что размер входных данных
равен n, и имеется три алгоритма A, B и C:
# Временная сложность алгоритма A: константная.
def algorithm_A(n: int):
print(0)
# Временная сложность алгоритма B: линейная.
def algorithm_B(n: int):
for _ in range(n):
print(0)
# Временная сложность алгоритма C: константная.
def algorithm_C(n: int):
for _ in range(1000000):
print(0)
48 Анализ сложности
На рис. 2.7 изображена схема временной сложности функций этих трех алгоритмов.
Алгоритм A содержит только одну операцию печати, и время выполнения алгоритма не увеличивается с ростом n. Временная сложность этого
алгоритма называется константной.
В алгоритме B операция печати выполняется в цикле n раз, и время выполнения алгоритма увеличивается линейно с ростом n. Временная
сложность этого алгоритма называется линейной.
В алгоритме C операция печати выполняется в цикле 1 000 000 раз, и,
хотя время выполнения очень долгое, оно не зависит от размера входных
данных n. Поэтому временная сложность C такая же, как у A, и остается
константной.
Алгоритм C – константная сложность
Время работы
алгоритма
Алгоритм B – линейная сложность
Алгоритм A – константная сложность
Размер входных данных
n
Рис. 2.7. Тенденция роста времени для алгоритмов A, B и C
Какие особенности имеет анализ временной сложности по сравнению с прямым подсчетом времени выполнения алгоритма?
Временная сложность позволяет эффективно оценить эффективность алгоритма. Например, время выполнения алгоритма B увеличивается линейно, и при n > 1 он медленнее алгоритма A, а при n > 1 000 000
медленнее алгоритма C. На самом деле, если размер входных данных n
достаточно велик, алгоритм с константной сложностью всегда будет лучше, чем алгоритм с линейной сложностью, и это и есть суть тенденции
роста времени.
Метод вычисления временной сложности проще. Очевидно, что
платформа выполнения и типы вычислительных операций не связаны
с тенденцией роста времени выполнения алгоритма. Поэтому в анализе временной сложности можно просто считать, что время выполнения
всех вычислительных операций одинаково и равно единичному времени, что позволяет упростить статистику времени выполнения вычислительных операций до статистики количества вычислительных операций,
значительно снижая сложность оценки.
2.3. Временная сложность 49
Однако временная сложность имеет определенные ограничения.
Например, хотя временная сложность алгоритмов A и C одинакова, их фактическое время выполнения значительно отличается. Аналогично, хотя
временная сложность алгоритма B выше, чем у C, при малых значениях
n алгоритм B явно лучше C. В таких случаях часто трудно оценить эффективность алгоритма только по временной сложности. Тем не менее, несмотря на эти проблемы, анализ сложности остается наиболее эффективным и распространенным методом оценки эффективности алгоритмов.
2.3.2. Асимптотическая верхняя граница функции
Пусть дан входной размер n для функции:
def algorithm(n: int):
a = 1
# +1.
a = a + 1 # +1.
a = a * 2 # +1.
# Цикл n итераций.
for i in range(n):
print(0)
# +1.
# +1.
Пусть T(n) – это количество операций алгоритма, являющееся функцией от
размера входных данных n. Тогда количество операций для вышеуказанной
функции равно:
T(n) = 3 + 2n.
T(n) является линейной функцией, что указывает на линейную тенденцию
роста времени выполнения, следовательно, у алгоритма временная сложность
линейного порядка.
Линейная временная сложность обозначается как O(n), этот математический символ называется «О» большое и представляет асимптотическую верхнюю границу функции T(n).
Анализ временной сложности, по сути, является вычислением асимптотической верхней границы количества операций T(n), которая имеет четкое математическое определение.
Асимптотическая верхняя граница функции
Если существуют положительное действительное число c и действительное
число n0, такие, что для всех n > n0 выполняется неравенство T(n) ≤ c × f(n), то
можно считать, что функция f(n) дает асимптотическую верхнюю границу для
T(n), обозначаемую как T(n) = O(f(n)).
Вычисление асимптотической верхней границы заключается в нахождении
функции f(n) − такой, что при стремлении n к бесконечности T(n) и f(n) находятся на одном уровне роста, отличаясь лишь на множитель константы c, как
показано на рис. 2.8.
50
Анализ сложности
Значение
функции
Размер входных данных n
Если при n > n0 всегда выполняется c • f(n) ≥ T(n), то f(n)
является асимптотической верхней границей T(n).
Обозначается T(n) = O(f(n))
Рис. 2.8. Асимптотическая верхняя граница функции
2.3.3. Методы вычисления
Асимптотическая верхняя граница является абстрактным математическим
понятием, и, если вы не полностью понимаете его, не стоит беспокоиться.
Можно сначала освоить методику вывода, и в процессе практики постепенно
осознать его математическое значение.
Согласно определению, если найти функцию f(n), можно получить временную сложность O(f(n)). Как же определить асимптотическую верхнюю границу f(n)? В общем случае это делается в два этапа: сначала подсчитывается количество операций, затем определяется асимптотическая верхняя граница.
1. Подсчет количества операций
Для кода это делается путем построчного подсчета сверху вниз. Однако, поскольку константа c в выражении c × f(n) может принимать любое значение, все
коэффициенты и константы в T(n) можно игнорировать. На основе этого
принципа можно сформулировать следующие упрощенные приемы подсчета:
1) игнорирование констант в T(n). Они не зависят от n и поэтому не влияют на временную сложность;
2) опускание всех коэффициентов. Например, циклы из 2n итераций, 5n
+ 1 итераций и т. д. можно упростить до n итераций, поскольку коэффициенты перед n не влияют на временную сложность;
3) при вложенных циклах используется умножение. Общее количество
операций равно произведению количества операций внешнего и внутреннего циклов, при этом для каждого уровня цикла можно применять
приемы 1 и 2.
2.3. Временная сложность 51
Следующую функцию можно использовать для подсчета количества операций с помощью вышеуказанных приемов:
def algorithm(n: int):
a = 1 # +0 (прием 1).
a = a + n # +0 (прием 1).
# +n (прием 2).
for i in range(5 * n + 1):
print(0)
# +n*n (прием 3).
for i in range(2 * n):
for j in range(n + 1):
print(0)
Следующая формула демонстрирует результаты подсчета до и после применения вышеуказанных приемов, временная сложность в обоих случаях равна O(n²).
T(n) = 2n(n + 1) + (5n + 1) + 2
= 2n2 + 7n + 3
T(n) = n2 + n
Полный подсчет (‑.‑|||)
Упрощенный подсчет (o.O)
2. Определение асимптотической верхней границы
Временная сложность определяется старшей степенью в T(n). Это связано
с тем, что при стремлении n к бесконечности старшая степень будет играть доминирующую роль, а влиянием других членов можно пренебречь.
В табл. 2.2 приведены некоторые гипертрофированные примеры, иллюстрирующие вывод о том, что «коэффициенты не могут изменить порядок». Когда n стремится к бесконечности, эти константы становятся несущественными.
Таблица 2.2. Временная сложность для различных количеств операций
Количество операций T(n)
Временная сложность O(f(n))
100 000
O(1)
3n + 2
O(n)
2n + 3n + 2
O(n2)
n3 + 10000n2
O(n3)
2n + 10000n10000
O(2n)
2
2.3.4. Основные типы
Пусть размер входных данных равен n, основные типы временной сложности
показаны на рис. 2.9 (в порядке от низшего к высшему).
O(1) < O(log n) < O(n) < O(n log n) < O(n2) < O(2n) < O(n!)
Константная < Логарифмическая < Линейная < Линейнологарифмическая < Квадратичная < Экспоненциальная < Факториальная
52
Анализ сложности
Квадратичная O(n2)
Количество
операций T(n)
Линейная O(n)
Логарифмическая
O(log n)
Константная O(1)
От низшего к высшему,
от лучшего к худшему
Экспоненциальная O(2n)
Размер входных данных n
Рис. 2.9. Основные типы временной сложности
1. Константная сложность O(1)
Количество операций константной сложности не зависит от размера входных
данных n, т. е. не изменяется по мере роста n.
В следующей функции временная сложность не зависит от n и равна O(1),
несмотря на то что количество операций size может быть очень большим.
# === File: time_complexity.py ===
def constant(n: int) -> int:
""" Константный порядок."""
count = 0
size = 100000
for _ in range(size):
count += 1
return count
2. Линейная сложность O(n)
Количество операций линейной сложности растет линейно относительно размера входных данных n. Линейная сложность обычно встречается в однопроходных циклах.
# === File: time_complexity.py ===
def linear(n: int) -> int:
""" Линейный порядок."""
count = 0
for _ in range(n):
count += 1
return count
Временная сложность операций, таких как обход массива и обход связного
списка, равна O(n), где n – длина массива или списка.
2.3. Временная сложность 53
# === File: time_complexity.py ===
def array_traversal(nums: list[int]) -> int:
""" Линейный порядок (обход массива)."""
count = 0
# Количество циклов пропорционально длине массива.
for num in nums:
count += 1
return count
Следует отметить, что размер входных данных n необходимо определять в зависимости от типа входных данных. Например, в первом примере переменная n обозначает размер входных данных; во втором примере
размер данных определяется длиной массива n.
3. Квадратичная сложность O(n2)
Количество операций квадратичной сложности растет с квадратом размера
входных данных n. Квадратичная сложность обычно возникает в случае вложенных циклов, когда временная сложность как внешнего, так и внутреннего циклов равна O(n) и, следовательно, общая временная сложность составляет O(n2):
# === File: time_complexity.py ===
def quadratic(n: int) -> int:
""" Квадратичная сложность."""
count = 0
# Количество операций в циклах пропорционально квадрату размера данных n.
for i in range(n):
for j in range(n):
count += 1
return count
На рис. 2.10 приведено сравнение трех видов временной сложности: константной, линейной и квадратичной.
Цикл n раз
1 раз
Цикл n раз
Временная сложность
Временная сложность
Временная сложность
Константная O(1)
Линейная O(n)
Квадратичная O(n2)
Одна вычислительная операция
Рис. 2.10. Временная сложность константного, линейного и квадратичного порядка
54
Анализ сложности
В качестве примера рассмотрим пузырьковую сортировку: внешний цикл
выполняется n – 1 раз, внутренний цикл выполняется n – 1, n – 2, ..., 2, 1 раз,
в среднем n/2 раз, поэтому временная сложность составляет O((n − 1)n/2) = O(n2).
# === File: time_complexity.py ===
def bubble_sort(nums: list[int]) -> int:
""" Квадратичная сложность (пузырьковая сортировка)."""
count = 0 # Счетчик.
# Внешний цикл: неотсортированный диапазон [0, i].
for i in range(len(nums) - 1, 0, -1):
# Внутренний цикл: перестановка максимального элемента
# неотсортированного диапазона [0, i] и элемента на правом конце.
for j in range(i):
if nums[j] > nums[j + 1]:
# Перестановка nums[j] и nums[j + 1].
tmp: int = nums[j]
nums[j] = nums[j + 1]
nums[j + 1] = tmp
count += 3 # Перестановка элементов включает 3 элементарные операции
return count
4. Экспоненциальная сложность O(2n)
Деление клеток в биологии является типичным примером экспоненциального роста: исходное состояние – 1 клетка, после одного цикла деления их становится 2, после двух циклов – 4 и т. д. После n циклов деления получается
2n клеток.
В следующем коде моделируется процесс деления клеток, временная сложность этого алгоритма составляет O(2n), см. рис. 2.11. Обратите внимание, что
входное значение n обозначает количество циклов деления, а возвращаемое
значение count обозначает общее количество делений.
# === File: time_complexity.py ===
def exponential(n: int) -> int:
""" Экспоненциальная сложность (реализация c циклом)."""
count = 0
base = 1
# Каждая клетка делится на две в каждом цикле, формируя последовательность
1, 2, 4, 8, ..., 2^(n-1).
for _ in range(n):
for _ in range(base):
count += 1
base *= 2
# count = 1 + 2 + 4 + 8 + ...+ 2^(n-1) = 2^n – 1.
return count
2.3. Временная сложность 55
Дерево рекурсии
Каждый уровень делится
на две части
Количество узлов
на каждом уровне
1 раз
Всего n
уровней
Количество вычислительных операций
(Количество узлов в дереве рекурсии)
Экспоненциальная
временная
сложность O(2n)
Рис. 2.11. Временная сложность экспоненциального порядка
В реальных алгоритмах экспоненциальная сложность часто встречается
в рекурсивных функциях. Например, в следующем коде функция рекурсивно
делится на две части и останавливается после n циклов деления.
# === File: time_complexity.py ===
def exp_recur(n: int) -> int:
""" Экспоненциальная сложность (реализация с рекурсией)."""
if n == 1:
return 1
return exp_recur(n - 1) + exp_recur(n - 1) + 1
Экспоненциальный рост является очень быстрым и часто встречается в методах полного перебора (грубая сила, возврат и т. д.). Для задач с большим объемом данных экспоненциальная сложность неприемлема, обычно требуется
использование динамического программирования или жадных алгоритмов.
5. Логарифмическая сложность O(log n)
В отличие от экспоненциальной, логарифмическая сложность отражает принцип «каждый цикл сокращается вдвое». Пусть размер входных данных равен
n, поскольку каждый цикл сокращается вдвое, количество циклов равно log2 n,
т. е. обратной функции к 2n.
В следующем коде моделируется принцип «каждый цикл сокращается
вдвое», временная сложность которого составляет O(log2 n), или сокращенно
O(log n), см. рис. 2.12.
56
Анализ сложности
# === File: time_complexity.py ===
def logarithmic(n: int) -> int:
""" Логарифмическая сложность (реализация с циклом.)"""
count = 0
while n > 1:
n = n / 2
count += 1
return count
Длина оставшегося
массива
Длина входного массива
равна n, на каждом уровне
исключается половина
Каждый уровень
содержит 1
вычислительную
операцию
Всего log2n + 1
уровней
Общее количество
вычислительных
операций log2n + 1
Временная сложность
Логарифмическая O(log n)
Рис. 2.12. Временная сложность логарифмического порядка
Подобно экспоненциальной, логарифмическая сложность также часто
встречается в рекурсивных функциях. Следующий код формирует рекурсивное дерево высотой log2 n.
# === File: time_complexity.py ===
def log_recur(n: int) -> int:
""" Логарифмическая сложность (реализация с рекурсией)."""
if n <= 1:
return 0
return log_recur(n / 2) + 1
Логарифмическая сложность часто встречается в алгоритмах, основанных
на стратегии «разделяй и властвуй», отражая идеи разделения на части и упрощения сложного. Она растет медленно и является идеальной временной сложностью после константной.
2.3. Временная сложность 57
Каково основание логарифма в O(log n)?
Строго говоря, временная сложность «разделения на m» соответствует
O(logm n). Используя формулу смены основания логарифма, можно получить
выражения с разными основаниями, но соответствующие одинаковой временной сложности.
O(logm n) = O(logk n / logk m) = O(logk n).
То есть основание m можно изменить на другое без влияния на сложность.
Поэтому обычно основание m опускается, и логарифмическая сложность записывается просто как O(log n).
6. Линейно-логарифмическая сложность O(n log n)
Линейно-логарифмическая сложность часто встречается во вложенных циклах,
когда временные сложности двух уровней циклов составляют O(log n) и O(n)
соответственно. Ниже приведен пример кода.
# === File: time_complexity.py ===
def linear_log_recur(n: int) -> int:
""" Линейно-логарифмическая сложность."""
if n <= 1:
return 1
# Разделение на две части, размер подзадачи уменьшается вдвое.
count = linear_log_recur(n // 2) + linear_log_recur(n // 2)
# Текущая подзадача содержит n операций.
for _ in range(n):
count += 1
return count
На рис. 2.13 изображен способ формирования линейно-логарифмической
сложности. Общее количество операций на каждом уровне двоичного дерева
равно n, всего в дереве log2 n + 1 уровней, поэтому временная сложность составляет O(n log n).
Временная сложность основных алгоритмов сортировки обычно составляет
O(n log n), например быстрой сортировки, сортировки слиянием, пирамидальной сортировки и т. д.
58
Анализ сложности
Операций на
каждом уровне
n раз
n раз
2
n раз
4
1 раз
n раз
2
n раз
4
1 раз
n раз
4
1 раз
n раз
4
1 раз
Всего log2n + 1
уровней
Общее
количество
операций
n (log2n + 1)
Временная
сложность
O(n log n)
Рис. 2.13. Временная сложность линейно-логарифмического порядка
7. Факториальная сложность O(n!)
Факториальная сложность соответствует математической задаче «полной перестановки». Для заданных n различных элементов необходимо определить
все возможные варианты их перестановки, количество которых вычисляется
по следующей формуле:
n! = n × (n – 1) × (n – 2) × ... × 2 × 1.
Факториал обычно реализуется с использованием рекурсии. Как показано
на рис. 2.14 и в коде ниже, на первом уровне происходит разбиение на n частей, на втором – на n – 1 частей и т. д., пока на n-м уровне разбиение не прекращается.
# === File: time_complexity.py ===
def factorial_recur(n: int) -> int:
""" Факториал (реализация с рекурсией)."""
if n == 0:
return 1
count = 0
# Разбиение 1 части на n частей.
for _ in range(n):
count += factorial_recur(n - 1)
return count
2.3. Временная сложность 59
Узлов на каждом
уровне
Ветвлений на
каждом уровне
Всего n
уровней
Вариантов полной перестановки
(т. е. количество узлов на нижнем уровне)
Временная сложность
Факториальная O(n!)
Рис. 2.14. Временная сложность факториала
Следует отметить, что, поскольку при n ≥ 4 всегда выполняется n! > 2n, факториал растет быстрее, чем экспоненциальная функция, и при больших n он
становится неприемлемым для практического использования.
2.3.5. Худшая, лучшая и средняя временная сложность
Эффективность алгоритма во времени часто не является фиксированной, а зависит от распределения входных данных. Предположим, что на
вход подается массив nums длиной n, содержащий числа от 1 до n, каждое из
которых встречается ровно один раз. Однако порядок элементов перемешан
случайным образом, и задача заключается в том, чтобы вернуть индекс элемента 1. Можно сформулировать следующие факты:
1) когда nums = [?, ?, ..., 1], т. е. когда последний элемент равен 1, необходимо полностью пройти массив, достигая худшей временной сложности O(n);
2) когда nums = [1, ?, ?, ...], т. е. когда первый элемент равен 1, независимо от длины массива не требуется продолжать проход, достигая лучшей
временной сложности Ω(1).
Худшая временная сложность соответствует асимптотическому верхнему
пределу функции и обозначается символом O. Соответственно, лучшая временная сложность соответствует асимптотическому нижнему пределу функции и обозначается символом Ω.
# === File: worst_best_time_complexity.py ===
def random_numbers(n: int) -> list[int]:
""" Генерация массива с элементами 1, 2, ..., n в случайном порядке."""
60
Анализ сложности
# Генерация массива nums =: 1, 2, 3, ..., n.
nums = [i for i in range(1, n + 1)]
# Случайное перемешивание элементов массива.
random.shuffle(nums)
return nums
def find_one(nums: list[int]) -> int:
""" Поиск индекса числа 1 в массиве nums."""
for i in range(len(nums)):
# Когда элемент 1 в начале массива, достигается лучшая временная сложность O(1).
# Когда элемент 1 в конце массива, достигается худшая временная сложность O(n).
if nums[i] == 1:
return i
return -1
Стоит отметить, что на практике лучшая временная сложность используется
редко, так как обычно она достигается с малой вероятностью и может вводить
в заблуждение. Худшая временная сложность более полезна, так как она
предоставляет безопасное значение эффективности, позволяя уверенно
использовать алгоритм.
Из приведенного примера видно, что худшая и лучшая временные сложности возникают только при особом распределении данных, вероятность появления которых может быть мала, и они не отражают реальной эффективности
алгоритма. Кроме того, средняя временная сложность может показать
эффективность алгоритма при случайных входных данных, она обозначается символом Θ.
Для некоторых алгоритмов можно просто вычислить средний показатель
при случайном распределении данных. Например, в приведенном примере,
поскольку входной массив перемешан случайным образом, вероятность появления элемента 1 на любом индексе одинакова, и среднее количество циклов
алгоритма составляет половину длины массива n/2, а средняя временная сложность равна Θ(n/2) = Θ(n).
Однако для более сложных алгоритмов вычисление средней временной
сложности часто затруднительно, так как сложно проанализировать общее математическое ожидание при распределении данных. В таких случаях обычно
используется худшая временная сложность в качестве критерия оценки эффективности алгоритма.
Почему символ Θ встречается редко?
Возможно, из-за того, что символ O слишком привычен, мы часто используем
его для обозначения средней временной сложности. Однако с точки зрения
строгости это не является корректным. В данной книге и других материалах,
если встречается выражение «средняя временная сложность O(n)», следует
понимать ее как Θ(n).
2.4. Пространственная сложность 61
2.4. Пространственная сложность
Пространственная сложность используется для измерения тенденции роста
занимаемой алгоритмом памяти по мере увеличения объема данных. Это понятие очень похоже на временную сложность, только вместо времени выполнения рассматривается занимаемая память.
2.4.1. Пространство алгоритма
Память, используемая алгоритмом в процессе выполнения, главным образом
включает следующие виды.
Входное пространство: используется для хранения входных данных
алгоритма.
Временное пространство: используется для хранения переменных,
объектов, контекста функций и других данных в процессе выполнения
алгоритма.
Выходное пространство: используется для хранения выходных данных
алгоритма.
Как правило, пространственная сложность рассчитывается на основе временного пространства и выходного пространства.
Временное пространство можно, в свою очередь, разделить на три части.
Временные данные: используются для сохранения различных констант, переменных, объектов и т. д. в процессе выполнения алгоритма.
Пространство стека: используется для сохранения контекстных данных вызываемой функции. При каждом вызове функции система создает фрейм стека на вершине стека, который освобождается после возврата функции.
Пространство инструкций: используется для хранения скомпилированных инструкций программы, в реальной статистике обычно игнорируется.
При анализе пространственной сложности программы обычно учитываются временные данные, пространство стека и выходные данные, как показано на рис. 2.15.
Область измерения пространственной сложности
Входное
пространство
Временное
пространство
Входные данные
Временные данные
Пространство стека вызовов
Пространство инструкций
Выходное
пространство
Выходные
данные
Рис. 2.15. Пространство, используемое алгоритмом
62
Анализ сложности
Ниже приведен пример кода.
class Node:
""" Класс."""
def __init__(self, x: int):
self.val: int = x # Значение узла.
self.next: Node | None = None # Ссылка на следующий узел.
def function() -> int:
""" Функция."""
# Выполнение операций...
return 0
def algorithm(n) -> int: # Входные данные.
A = 0 # Временные данные (константа, обычно обозначается заглавной буквой).
b = 0 # Временные данные (переменная).
node = Node(0) # Временные данные (объект).
c = function() # Пространство стека (вызов функции).
return A + b + c # Выходные данные.
2.4.2. Методы вычисления
Методы вычисления пространственной сложности аналогичны методам вычисления временной сложности, только объект статистики изменяется с количества операций на размер используемого пространства.
В отличие от временной сложности обычно учитывается только наихудшая пространственная сложность. Это связано с тем, что память является
жестким требованием, и необходимо убедиться, что для всех входных данных
будет зарезервировано достаточное количество памяти.
В следующем примере кода наихудшая пространственная сложность может
иметь два значения.
1. Наихудшие входные данные: когда n < 10, пространственная сложность составляет O(1); но когда n > 10, инициализированный массив
nums занимает пространство O(n), поэтому наихудшая пространственная
сложность составляет O(n).
2. Пиковое использование памяти во время выполнения: например,
до выполнения последней строки программа занимает пространство
O(1). При инициализации массива nums программа занимает пространство O(n), поэтому наихудшая пространственная сложность составляет O(n).
def algorithm(n: int):
a = 0 # O(1)
b = [0] * 10000 # O(1)
if n > 10:
nums = [0] * n # O(n)
В рекурсивных функциях необходимо учитывать статистику пространства стека. Рассмотрим следующий код.
2.4. Пространственная сложность 63
def function() -> int:
# Выполнение некоторых операций.
return 0
def loop(n: int):
""" Пространственная сложность цикла составляет O(1)."""
for _ in range(n):
function()
def recur(n: int):
""" Пространственная сложность рекурсии составляет O(n)."""
if n == 1:
return
return recur(n - 1)
Функции loop() и recur() имеют временную сложность O(n), но различаются
по пространственной сложности.
Функция loop() вызывает function() n раз в цикле, и каждый раз function()
возвращает значение и освобождает пространство стека, поэтому пространственная сложность остается O(1).
Рекурсивная функция recur() во время выполнения одновременно содержит n невозвращенных вызовов recur(), занимая пространство стека O(n).
2.4.3. Основные типы
Пусть размер входных данных равен n, на рис. 2.16 показаны основные типы
пространственной сложности (в порядке возрастания).
O(1) < O(log n) < O(n) < O(n2) < O(2n)
Константная < Логарифмическая < Линейная <
Квадратичная < Экспоненциальная
Квадратичная O(n2)
Размер
памяти
Линейная O(n)
Логарифмическая
O(log n)
Константная O(1)
От низкой к высокой,
от лучшей к худшей
Экспоненциальная
O(2n)
Размер входных данных n
Рис. 2.16. Основные типы пространственной сложности
1. Константная сложность O(1)
Константная сложность обычно встречается у констант, переменных, объектов, количество которых не зависит от размера входных данных n.
64
Анализ сложности
Следует отметить, что память, занимаемая инициализацией переменных или
вызовом функций в цикле, освобождается при переходе к следующему циклу,
поэтому она не накапливается, и пространственная сложность остается O(1).
# === File: space_complexity.py ===
def function() -> int:
""" Функция."""
# Выполнение некоторых операций.
return 0
def constant(n: int):
""" Константная сложность."""
# Константы, переменные; объекты, занимающие пространство O(1).
a = 0
nums = [0] * 10000
node = ListNode(0)
# Переменные в цикле занимают пространство O(1).
for _ in range(n):
c = 0
# Функции в цикле занимают пространство O(1).
for _ in range(n):
function()
2. Линейная сложность O(n)
Линейная сложность часто встречается у массивов, списков, стеков, очередей
и других объектов, количество элементов которых пропорционально n.
# === File: space_complexity.py ===
def linear(n: int):
""" Линейная сложность."""
# Список длиной n занимает пространство O(n).
nums = [0] * n
# Хеш-таблица длиной n занимает пространство O(n).
hmap = dict[int, str]()
for i in range(n):
hmap[i] = str(i)
Глубина рекурсии этой функции равна n, т. е. одновременно существует
n невозвращенных вызовов функции linear_recur(), использующих пространство стека O(n):
# === File: space_complexity.py ===
def linear_recur(n: int):
""" Линейная сложность (реализация с рекурсией)."""
print(" Рекурсия n =", n)
if n == 1:
return
linear_recur(n - 1)
2.4. Пространственная сложность 65
Нисходящая «вызов»
Восходящая «возврат»
Глубина
рекурсии n
Условие
завершения
рекурсии
Глубина рекурсивных вызовов
функции равна n, всего используется
O(n) пространства стека вызовов
Пространственная
сложность
Линейная O(n)
Рис. 2.17. Линейная пространственная сложность, создаваемая рекурсивной функцией
3. Квадратичная сложность O(n2)
Квадратичная сложность часто встречается у матриц и графов, количество
элементов в которых пропорционально квадрату n.
# === File: space_complexity.py ===
def quadratic(n: int):
""" Квадратичная сложность."""
# Двумерный список занимает пространство O(n^2).
num_matrix = [[0] * n for _ in range(n)]
Глубина рекурсии данной функции равна n, в каждом рекурсивном вызове
инициализируется массив, длина которого последовательно уменьшается от
n до 1, средняя длина равна n/2, поэтому в целом используется O(n2) пространства, как показано на рис. 2.18.
# === File: space_complexity.py ===
def quadratic_recur(n: int) -> int:
""" Квадратичная сложность (реализация с рекурсией)."""
if n <= 0:
return 0
# Массив nums длиной n, n-1, ..., 2, 1.
nums = [0] * n
return quadratic_recur(n - 1)
66
Анализ сложности
Длина массива nums
Нисходящая «вызов»
Восходящая «возврат»
Глубина
рекурсии n
Условие
завершения
рекурсии
В каждой рекурсивной функции инициализируется
массив длиной
Пространственная
сложность
Квадратичная O(n2)
Рис. 2.18. Квадратичная пространственная сложность рекурсивной функции
4. Экспоненциальная сложность O(2n)
Экспоненциальная сложность часто встречается в двоичных деревьях. Количество узлов в полном двоичном дереве с n уровнями равно 2n − 1, что занимает
O(2n) пространства, как показано на рис. 2.19.
# === File: space_complexity.py ===
def build_tree(n: int) -> TreeNode | None:
""" Экспоненциальная сложность (создание полного двоичного дерева)."""
if n == 0:
return None
root = TreeNode(0)
root.left = build_tree(n - 1)
root.right = build_tree(n - 1)
return root
Узлов на каждом
уровне
Всего n уровней
Количество узлов
Пространственная
сложность
Экспоненциальная O(2n)
Рис. 2.19. Экспоненциальная пространственная сложность полного двоичного дерева
2.5. Резюме 67
5. Логарифмическая сложность O(log n)
Логарифмическая сложность часто встречается в алгоритмах типа «разделяй
и властвуй». Например, в сортировке слиянием, где входной массив длиной
n на каждой итерации рекурсивно делится пополам и формирует рекурсивное
дерево высотой log n, используется O(log n) пространства для стека вызовов.
Или, например, при преобразовании числа в строку если входное значение
является положительным целым числом n, то количество его цифр равно ⌊log10
n ⌋ + 1, что соответствует длине строки, и поэтому пространственная сложность
равна O(log10 n + 1) = O(log n).
2.4.4 Баланс времени и пространства
В идеальных условиях мы стремимся к тому, чтобы временная и пространственная сложности алгоритма были оптимальными. Однако на практике одновременно оптимизировать обе сложности зачастую очень затруднительно.
Снижение временной сложности обычно требует увеличения пространственной сложности, и наоборот. Будем называть подход, при котором нужно пожертвовать памятью для увеличения скорости выполнения алгоритма, обменом пространства на время. Обратный подход будем называть
обменом времени на пространство.
Выбор подхода зависит от того, какой аспект для нас более важен. В большинстве случаев время ценнее пространства, поэтому обмен пространства на
время является более распространенной стратегией. Конечно, при больших
объемах данных контроль пространственной сложности также очень важен.
2.5. Резюме
1. Ключевые моменты
Оценка эффективности алгоритмов:
временная и пространственная эффективность являются двумя основными критериями для оценки качества алгоритмов;
эффективность алгоритмов можно оценивать с помощью реальных тестов, однако это сложно из-за влияния тестовой среды и значительных
затрат вычислительных ресурсов;
анализ сложности позволяет устранить недостатки реальных тестов, результаты анализа применимы ко всем платформам и могут выявить эффективность алгоритма при различных объемах данных.
Временная сложность:
временная сложность используется для оценки тенденции изменения
времени выполнения алгоритма с увеличением объема данных, что позволяет оценивать его эффективность. Однако в некоторых случаях она
может работать не так хорошо, например когда объем входных данных
мал или временная сложность одинакова, что не позволяет точно сравнить эффективность алгоритмов;
68
Анализ сложности
худшая временная сложность обозначается символом O и соответствует
асимптотической верхней границе, отражая уровень роста количества
операций T(n) при стремлении n к бесконечности;
определение временной сложности включает два этапа: сначала подсчитывается количество операций, затем определяется асимптотическая
верхняя граница;
наиболее распространенные временные сложности от низкой к высокой: O(1), O(log n), O(n), O(n log n), O(n2), O(2n) и O(n!);
временная сложность некоторых алгоритмов не является фиксированной и зависит от распределения входных данных. Временная сложность
делится на худшую, лучшую и среднюю. Лучшая временная сложность
почти не используется, так как для достижения лучшего случая входные
данные должны соответствовать строгим критериям;
средняя временная сложность отражает эффективность алгоритма при
случайных входных данных и наиболее близка к реальной производительности алгоритма. Для расчета средней временной сложности необходимо
учитывать распределение входных данных и математическое ожидание.
Пространственная сложность:
пространственная сложность аналогична временной сложности и используется для оценки тенденции изменения объема памяти, занимаемой алгоритмом, с увеличением объема данных;
память, используемая в процессе выполнения алгоритма, можно разделить на входное пространство, временное пространство и выходное пространство. Обычно при расчете пространственной сложности входное
пространство не учитывается. Временное пространство делится на временные данные, пространство стека и пространство инструкций, причем пространство стека обычно влияет на пространственную сложность
только в рекурсивных функциях;
обычно рассматривается только худшая пространственная сложность,
т. е. пространственная сложность алгоритма при худших входных данных и в худший момент выполнения;
наиболее распространенные пространственные сложности от низкой
к высокой: O(1), O(log n), O(n), O(n2) и O(2n).
2. Вопросы и ответы
Вопрос. Пространственная сложность хвостовой рекурсии равна O(1)?
Ответ. Теоретически пространственную сложность хвостовой рекурсии
можно оптимизировать до O(1). Однако большинство языков программирования (например, Java, Python, C++, Go, C# и др.) не поддерживают автоматическую оптимизацию хвостовой рекурсии, поэтому обычно считается, что ее
пространственная сложность равна O(n).
Вопрос. В чем разница между терминами «функция» и «метод»?
Ответ. Функция может выполняться независимо, все параметры передаются явно. Метод связан с объектом и неявно передается вызывающему его объекту. Он может оперировать данными, содержащимися в экземпляре класса.
2.5. Резюме 69
Ниже приведены примеры из нескольких распространенных языков программирования:
язык C является процедурным языком и не имеет концепции объектно ориентированного программирования, поэтому в нем есть только
функции. Однако можно создать структуры, чтобы имитировать классы. Функции, связанные со структурами, будут эквивалентны методам
в других языках программирования;
Java и C# являются объектно ориентированными языками программирования, и блоки кода (методы) обычно являются частью какого-либо
класса. Статические методы ведут себя как функции, так как они привязаны к классу и не могут обращаться к конкретным свойствам экземпляра;
C++ и Python поддерживают как процедурное программирование (функции), так и объектно ориентированное программирование (методы).
Вопрос. Отражает ли схема «Типы пространственной сложности» абсолютный размер занимаемого пространства?
Ответ. Нет, данная схема демонстрирует пространственную сложность, отражающую тенденцию роста, а не абсолютный размер занимаемого пространства.
Предположим, для n = 8 вы можете заметить, что значения каждой кривой
не соответствуют значениям функции. Это происходит потому, что каждая
кривая содержит постоянную составляющую, которая используется для сжатия диапазона значений до визуально комфортного уровня.
На практике, поскольку обычно постоянная составляющая метода неизвестна, невозможно выбрать оптимальное решение только на основе сложности
при n = 8. Однако при n = 85 выбор становится очевидным, так как тенденция
роста уже доминирует.
Глава 3
Структуры данных
Абстракция
Структуры данных подобны стабильной, но гибкой структуре.
Они предоставляют план упорядоченной организации данных, на основе которого реализуются алгоритмы.
3.1. Классификация структур данных 71
3.1. Классификация структур данных
К распространенным структурам данных относятся массивы, списки, стеки,
очереди, хеш-таблицы, деревья, кучи, графы. Их можно классифицировать по
двум измерениям: логической структуре и физической структуре.
3.1.1. Логическая структура: линейная и нелинейная
Логическая структура раскрывает логические отношения между элементами данных. В массивах и списках данные расположены в определенном порядке,
что отражает линейные отношения между данными. В деревьях данные расположены сверху вниз по уровням, что демонстрирует отношения «предок» и «потомок». Графы состоят из узлов и ребер, отражая сложные сетевые отношения.
Логические структуры делятся на две большие категории: линейные и нелинейные, как показано на рис. 3.1. Линейные структуры более интуитивно
понятны, поскольку в них данные расположены линейно и логически связаны.
Нелинейные структуры, наоборот, представляют собой нелинейное расположение элементов данных.
Линейные структуры данных: массивы, списки, стеки, очереди, хештаблицы, в которых элементы связаны последовательно один к одному.
Нелинейные структуры данных: деревья, кучи, графы, хеш-таблицы.
Нелинейные структуры данных можно дополнительно разделить на древовидные и сетевые.
Древовидные структуры: деревья, кучи, хеш-таблицы, в которых элементы связаны один ко многим.
Сетевые структуры: графы, в которых элементы связаны многие ко
многим.
Линейные структуры данных
Массив
Нелинейные структуры данных
Дерево
Связный список
Граф
Стек
Куча
Очередь
Хеш-таблица
Рис. 3.1. Линейные и нелинейные структуры данных
72
Структуры данных
3.1.2. Физическая структура:
непрерывная и разреженная
Во время выполнения программы обрабатываемые данные в основном
хранятся в памяти. На рис. 3.2 изображен модуль оперативной памяти компьютера, где каждый черный чип содержит определенный участок памяти.
Память можно представить как огромную таблицу Excel, где каждая ячейка
может хранить данные определенного размера.
Система обращается к данным в целевой позиции через адреса памяти. Компьютер присваивает каждой ячейке таблицы номер по определенным
правилам, чтобы обеспечить уникальный адрес памяти для каждого участка,
как показано на рис. 3.2. Благодаря этим адресам программа может обращаться к данным в памяти.
Доступное пространство памяти
Используемое
пространство памяти
Обращение к адресу памяти 0x0012,
получение данных
Обращение к адресу памяти 0x0024,
получение данных
Рис. 3.2. Модуль памяти, пространство памяти, адреса памяти
Совет
Стоит отметить, что сравнение памяти с таблицей Excel является упрощенной аналогией, поскольку реальный механизм работы памяти более сложен
и включает такие понятия, как адресное пространство, управление памятью,
механизмы кеширования, виртуальная и физическая память.
Память является общим ресурсом для всех программ, и когда участок памяти занят одной программой, он обычно не может быть одновременно использован другими программами. Поэтому в процессе проектирования структур данных и алгоритмов память занимает важное место. Например, пиковое использование памяти алгоритмом не должно превышать оставшуюся
свободную память системы. Если не хватает непрерывных больших участков
памяти, выбранная структура данных должна уметь располагаться в разреженных участках памяти.
3.1. Классификация структур данных 73
Физическая структура отражает способ хранения данных в памяти
компьютера и делится на хранение в непрерывном пространстве (массивы)
и хранение в разреженном пространстве (списки), как показано на рис. 3.3.
Физическая структура на низком уровне определяет методы доступа, обновления, добавления и удаления данных. Обе физические структуры демонстрируют взаимодополняющие характеристики в отношении временной и пространственной эффективности.
Непрерывное хранение
Распределенное хранение
Память для хранения массива
является непрерывной
Память для хранения связного списка
является распределенной
Доступное пространство памяти
Память для хранения массива
Хранение
значения узла
Хранение
указателя узла
Память для хранения
узлов связного списка
Рис. 3.3. Хранение в непрерывном и в разреженном пространстве
Следует отметить, что все структуры данных реализуются на основе
массивов, связных списков или их комбинации. Например, стек и очередь
можно реализовать как с использованием массивов, так и с использованием
связных списков. Реализация хеш-таблицы может включать как массивы, так
и связные списки.
На основе массивов можно реализовать: стек, очередь, хеш-таблицу,
дерево, кучу, граф, матрицу, тензор (массив размерностью ≥ 3) и др.
На основе связных списков можно реализовать: стек, очередь, хештаблицу, дерево, кучу, граф и др.
Связный список после инициализации может изменять свою длину в процессе выполнения программы, поэтому его также называют динамической
структурой данных. Массив после инициализации имеет неизменную длину,
поэтому его называют статической структурой данных. Следует отметить, что
массив может изменять свою длину путем перераспределения памяти, что
придает ему определенную динамичность.
Совет
Если вам трудно разобраться с физической структурой, рекомендуется сначала прочитать следующую главу, а затем вернуться к этому разделу.
74
Структуры данных
3.2. Основные типы данных
Когда речь идет о данных в компьютере, мы думаем в первую очередь о тексте,
изображениях, видео, аудио, 3D-моделях и других формах представления информации. Хотя способы организации этих данных различны, они все строятся из основных типов данных.
Основные типы данных – это те, которые могут быть непосредственно обработаны процессором и используются в алгоритмах. Существуют следующие
основные типы данных:
1) целочисленные типы: byte, short, int, long;
2) типы с плавающей запятой: float, double. Используются для представления дробных чисел;
3) символьный тип: char. Используется для представления букв различных
языков, знаков препинания и даже эмодзи;
4) логический тип: bool. Используется для представления концепций «да»
и «нет».
Основные типы данных хранятся в компьютере в двоичной форме.
Один двоичный разряд равен 1 биту. В большинстве современных операционных систем 1 байт состоит из 8 бит.
Диапазон значений основных типов данных зависит от занимаемого ими
объема памяти. Рассмотрим это на примере языка Java.
Целочисленный тип byte занимает 1 байт = 8 бит, может представлять 28
чисел.
Целочисленный тип int занимает 4 байта = 32 бита, может представлять
232 чисел.
В табл. 3.1 приведены объем памяти, диапазон значений и значения по
умолчанию для различных основных типов данных в Java. Эту таблицу не нужно заучивать наизусть, достаточно иметь общее представление и при необходимости обращаться к ней.
Таблица 3.1. Объем памяти и диапазон значений основных типов данных
Тип
Объем
памяти
Минимальное Максимальное
значение
значение
Значение
по умолчанию
byte
1 байт
−27 (−128)
27 − 1 (127)
0
short
2 байта
−2
2 −1
0
int
4 байта
−2
2 −1
0
long
8 байт
−2
2 −1
float
4 байта
1.175 × 10
3.403 × 10
0.0f
double
8 байт
2.225 × 10–308
1.798 × 10308
0.0
Символьный
char
2 байта
0
2 −1
0
Логический
bool
1 байт
false
true
false
Целые
С плавающей
запятой
Обозначение
15
15
31
31
63
0
63
–38
38
16
3.2. Основные типы данных 75
Обратите внимание, что табл. 3.1 относится к основным типам данных
в языке Java. В каждом языке программирования свои определения типов
данных, объем памяти, диапазон значений и значения по умолчанию могут
различаться.
В Python целочисленный тип int может иметь произвольный размер,
ограниченный только доступной памятью. Тип с плавающей запятой
float является 64-битным двойной точности. Отсутствует тип char, отдельный символ фактически является строкой str длиной 1.
В C и C++ размер основных типов данных не определен четко и зависит
от реализации и платформы. Таблица 3.1 следует модели данных LP64,
используемой в Unix-системах на 64-битных операционных системах,
включая Linux и macOS.
Размер символа char в C и C++ составляет 1 байт. В большинстве языков
программирования он зависит от конкретного метода кодирования символов, подробнее см. в разделе «Кодирование символов».
Хотя для представления логического значения требуется всего 1 бит
(0 или 1), в памяти оно обычно занимает 1 байт. Это связано с тем, что
современные процессоры компьютеров обычно используют 1 байт как
минимальную адресуемую единицу памяти.
Какова же связь между основными типами данных и структурами данных? Известно, что структура данных – это способ организации и хранения
данных в компьютере. В этом предложении подлежащее – «структура», а не
«данные».
Если необходимо представить ряд чисел, естественно использовать массив.
Это связано с тем, что линейная структура массива может представлять соседние и последовательные отношения чисел, но что именно хранится (целые числа int, дробные числа float или символы char), не имеет отношения
к структуре данных.
Иными словами, базовые типы данных предоставляют тип содержимого данных, тогда как структуры данных определяют способ организации
данных. Например, в следующем коде мы используем одну и ту же структуру данных (массив) для хранения и представления различных базовых типов
данных, включая int, float, char, bool и др.
# Инициализация массива с использованием различных базовых типов данных.
numbers: list[int] = [0] * 5
decimals: list[float] = [0.0] * 5
# В Python символы фактически являются строками длиной 1.
characters: list[str] = ['0'] * 5
bools: list[bool] = [False] * 5
# Списки в Python могут свободно хранить различные базовые типы данных и ссылки
на объекты.
data = [0, 0.0, 'a', False, ListNode(0)]
76
Структуры данных
3.3. Кодирование чисел*
Совет
В этой книге главы, обозначенные символом *, являются дополнительными.
Если у вас ограничено время или возникают трудности с пониманием, можно
пропустить их и вернуться к ним после изучения обязательных глав.
3.3.1. Прямой, обратный и дополнительный коды
В таблице из предыдущего раздела можно заметить, что во всех целочисленных типах отрицательных чисел на одно больше, чем положительных. Например, диапазон значений byte составляет [–128, 127]. Этот факт кажется не совсем интуитивным, и его внутренняя причина связана с концепциями прямого, обратного и дополнительного кодов.
Прежде всего необходимо отметить, что числа хранятся в компьютере
в виде дополнительного кода. Прежде чем проанализировать причины этого, сначала дадим определения всем трем кодам.
Прямой код: старший бит двоичного представления числа рассматривается как знак, где 0 обозначает положительное число, 1 – отрицательное, остальные биты представляют значение числа.
Обратный код: обратный код положительного числа совпадает с его
прямым кодом, обратный код отрицательного числа получается инверсией всех битов прямого кода, кроме знакового.
Дополнительный код: дополнительный код положительного числа совпадает с его прямым кодом, дополнительный код отрицательного числа
получается добавлением 1 к его обратному коду.
На рис. 3.4 изображены методы преобразования прямого, обратного и дополнительного кодов между собой.
Число
Прямой код
Инвертировать все
биты, кроме знакового
Обратный код
Добавить 1
к обратному коду
Дополнительный
код
Прямой, обратный и дополнительный
коды положительных чисел равны
Прямой, обратный и дополнительный коды
отрицательных чисел требуют преобразования
Рис. 3.4. Преобразования прямого, обратного и дополнительного кодов
3.3. Кодирование чисел* 77
Хотя прямой код наиболее понятен, он имеет некоторые ограничения. С одной стороны, прямой код отрицательных чисел нельзя напрямую использовать в вычислениях. Например, при вычислении 1 + (−2) в прямом коде
результат будет −3, что явно неверно:
1 + (−2)
→ 0000 0001 + 1000 0010
= 1000 0011
→ −3.
Чтобы решить эту проблему, в компьютерах был введен обратный код. Если
сначала преобразовать прямой код в обратный, затем выполнить вычисление
1 + (−2) в обратном коде и, наконец, преобразовать результат обратно в прямой
код, то получится правильный результат −1:
1 + (−2)
→ 0000 0001 (прямой код) + 1000 0010 (прямой код)
= 0000 0001 (обратный код) + 1111 1101 (обратный код)
= 1111 1110 (обратный код)
= 1000 0001 (прямой код)
→ −1.
С другой стороны, у числа ноль в прямом коде есть два представления:
+0 и −0. Это означает, что числу ноль соответствуют два разных двоичных кода,
что может привести к неоднозначности. Например, в условных проверках,
если не различать положительный и отрицательный ноль, это может привести
к ошибкам в результатах. Если же требуется обработать неоднозначность положительного и отрицательного нуля, необходимо вводить дополнительные операции проверки, что может снизить эффективность вычислений компьютера:
+0 → 0000 0000
−0 → 1000 0000.
Как и в случае с прямым кодом, в обратном коде также существует проблема
неоднозначности положительного и отрицательного нуля, поэтому в компьютерах был введен дополнительный код. Рассмотрим процесс преобразования
прямого, обратного и дополнительного кодов для отрицательного нуля:
−0 → 1000 0000 (прямой код)
= 1111 1111 (обратный код)
= 1 0000 0000 (дополнительный код).
При добавлении 1 к обратному коду отрицательного нуля возникает перенос,
но длина типа byte составляет только 8 бит, поэтому единица, перенесенная в 9-й
бит, будет отброшена. Это означает, что дополнительный код отрицательного нуля равен 0000 0000, что совпадает с дополнительным кодом положительного нуля. Таким образом, в дополнительном коде существует только один
ноль, и неоднозначность положительного и отрицательного нуля устраняется.
78
Структуры данных
Остается последний вопрос: как получается дополнительное отрицательное
число −128 в диапазоне значений типа byte [−128, 127]? Заметим, что все целые числа в диапазоне [−127, +127] имеют соответствующие прямой, обратный
и дополнительный коды, а между прямым и дополнительным кодами можно
выполнять взаимные преобразования.
Однако дополнительный код 1000 0000 является исключением, у него
нет соответствующего прямого кода. Согласно методу преобразования прямой код для этого дополнительного кода будет 0000 0000. Это явное противоречие, так как этот прямой код представляет число 0, дополнительный код
которого должен быть равен самому себе. Компьютеры определяют, что этот
особый дополнительный код 1000 0000 представляет значение −128. Фактически результат вычисления (−1) + (−127) в дополнительном коде равен −128:
(−127) + (−1)
→ 1111 1111 (прямой код) + 1000 0001 (прямой код)
= 1000 0000 (обратный код) + 1111 1110 (обратный код)
= 1000 0001 (дополнительный код) + 1111 1111 (дополнительный код)
= 1000 0000 (дополнительный код)
→ −128.
Вы, возможно, уже заметили, что все приведенные выше вычисления являются операциями сложения. Это указывает на важный факт: аппаратные схемы внутри компьютера в основном разрабатываются на основе операций сложения. Это связано с тем, что сложение, по сравнению с другими операциями (такими как умножение, деление и вычитание), проще в аппаратной
реализации, легче поддается параллельной обработке и выполняется быстрее.
Однако это не означает, что компьютер способен выполнять только сложение.
Путем сочетания сложения с некоторыми базовыми логическими операциями компьютер может выполнять и другие математические операции.
Например, вычитание a − b можно преобразовать в сложение a + (−b). Умножение и деление можно преобразовать в многократное сложение или вычитание.
Теперь можно подытожить, почему компьютеры используют дополнительный
код. Благодаря представлению в дополнительном коде компьютер может обрабатывать сложение положительных и отрицательных чисел с помощью одних
и тех же схем и операций без необходимости проектировать специальные аппаратные схемы для вычитания, а также без необходимости специально обрабатывать неоднозначность положительного и отрицательного нуля. Это значительно
упрощает проектирование аппаратуры и повышает эффективность вычислений.
Техническое решение с дополнительным кодом весьма изящно, но из-за ограниченности объема книги мы пока остановимся на этом и рекомендуем заинтересованным читателям углубиться в изучение этого вопроса самостоятельно.
3.3.2. Кодирование чисел с плавающей запятой
Внимательный читатель, возможно, заметил, что типы int и float имеют одинаковую длину в 4 байта. Но почему тогда диапазон значений float значительно больше, чем у int? Этот факт, возможно, противоречит интуиции, так как
float представляет дробные числа, и диапазон значений должен уменьшиться.
3.3. Кодирование чисел* 79
На самом деле это связано с тем, что числа с плавающей запятой float
используют иной способ представления. 32-битное двоичное число записывается следующий образом:
b31b30b29 ... b2b1b0.
Согласно стандарту IEEE 754 32-битное число float состоит из следующих
трех частей:
1) знакового бита S: занимает 1 бит, соответствует b31;
2) показателя E: занимает 8 бит, соответствует b30b29 ... b23;
3) мантиссы N: занимает 23 бита, соответствует b22b21 ... b0.
Метод вычисления значения двоичного числа float:
val ( 1)b31 2( b30 b29 ... b23 )2 127 (1.b22 b21 ...b0 )2 .
Формула для вычисления в десятичной системе:
val ( 1)S 2E127 (1 N ).
где диапазоны значений каждой из частей:
S 0, 1 , E 1, 2, ..., 254
23
(1 N)= 1 b23 i 2 i 1, 2 223 .
i 1
Длина float 4 байта = 32 бита
Знаковый
Показатель E
бит S
Для этого примера легко
получить:
Мантисса N
По стандарту IEEE 754
число, соответствующее
этому примеру:
Рис. 3.5. Пример вычисления значения float по стандарту IEEE 754
Рассмотрим рис. 3.5, где приведены данные со значениями S = 0, E = 124, N =
2−2 + 2−3 = 0.375, тогда:
val ( 1)0 2124 127 (1 0.375) 0.171875.
80
Структуры данных
Теперь можно ответить на первоначальный вопрос: представление типа
float включает показатель степени, поэтому его диапазон значений значительно больше, чем у int. Согласно вышеуказанным вычислениям максимальное положительное число, которое может быть представлено float, равно
2254 − 127 × (2 − 2−23) ≈ 3.4 × 1038, а переключение знакового бита позволяет получить минимальное отрицательное число.
Несмотря на то что числа с плавающей запятой float расширяют диапазон значений, их побочным эффектом является потеря точности. Целочисленный тип int использует все 32 бита для представления чисел, которые
распределены равномерно. А наличие показателя приводит к тому, что чем
больше значение числа с плавающей запятой float, тем больше будет разница
между двумя соседними числами.
Показатели E = 0 и E = 255 имеют особое значение, используемое для представления нуля, бесконечности, NaN и т. д, как показано в табл. 3.2.
Таблица 3.2. Значения показателя
Показатель E
Мантисса N = 0
Мантисса N ≠ 0
Формула вычисления
0
±0
Слабо нормальное число
(−1)S × 2−126 × (0.N)
1, 2, ..., 254
Нормально число
Нормально число
(−1)S × 2(E−127) × (1.N)
255
±∞
NaN
Стоит отметить, что слабо нормальные числа значительно повышают точность чисел с плавающей запятой. Минимальное положительное нормальное
число равно 2−126, минимальное положительное слабо нормальное число равно
2−126 × 2−23.
В числах с двойной точностью double используется метод представления,
аналогичный float, поэтому здесь мы не будем углубляться в детали.
3.4. Кодирование символов*
В компьютере все данные хранятся в виде двоичных чисел, и символьный тип
данных char не является исключением. Для представления символов необходимо создать таблицу символов, которая устанавливает однозначное соответствие между каждым символом и двоичным числом. С помощью этой таблицы
компьютер может выполнять преобразование двоичных чисел в символы.
3.4.1. Таблица символов ASCII
ASCII является первой таблицей символов, ее полное название – American
Standard Code for Information Interchange (Американский стандартный код
для обмена информацией). Для представления символов в ней используются
7-битные двоичные числа (нижние 7 бит одного байта), что позволяет представить до 128 различных символов. Как показано на рис. 3.6, таблица ASCII
включает в себя заглавные и строчные буквы английского алфавита, цифры
от 0 до 9, некоторые знаки препинания, а также некоторые управляющие символы (например, символы новой строки и табуляции).
3.4. Кодирование символов* 81
Десятичная система
Двоичная система
Символ
Значение
0
0000 0000
NUL
Пустой символ (Null)
1
0000 0001
SOH
Начало заголовка
2
0000 0010
STX
Начало текста
3
0000 0011
ETX
Конец текста
4
0000 0100
EOT
Конец передачи
5
0000 0101
ENQ
Запрос
6
0000 0110
ACK
Подтверждение
7
0000 0111
BEL
Звонок
8
0000 1000
BS
Backspace
9
0000 1001
TAB
Горизонтальная табуляция
10
0000 1010
LF
Перевод строки
11
0000 1011
VT
Вертикальная табуляция
12
0000 1100
FF
Новая страница
13
0000 1101
CR
Возврат каретки
14
0000 1110
SO
Включить сдвиг
15
0000 1111
SI
Выключить сдвиг
16
0001 0000
DLE
Экранирование канала данных
17
0001 0001
DC1
Управление устройством 1
18
0001 0010
DC2
Управление устройством 2
19
0001 0011
DC3
Управление устройством 3
20
0001 0100
DC4
Управление устройством 4
21
0001 0101
NAK
Отрицание
22
0001 0110
SYN
Синхронизация
23
0001 0111
ETB
Конец блока передачи
24
0001 1000
CAN
Отмена
25
0001 1001
EM
Конец носителя
26
0001 1010
SUB
Замена
27
0001 1011
ESC
Экранирование
28
0001 1100
FS
Разделитель файлов
29
0001 1101
GS
Разделитель групп
30
0001 1110
RS
Разделитель записей
31
0001 1111
US
Разделитель элементов
32
0010 0000
(пробел)
Пробел
33
0010 0001
!
Восклицательный знак
34
0010 0010
“
Двойная кавычка
35
0010 0011
#
Решетка (октоторп)
36
0010 0100
$
Знак доллара
Рис. 3.6. Таблица символов ASCII
82
Структуры данных
Десятичная система
Двоичная система
Символ
Значение
37
0010 0101
%
Процент
38
0010 0110
&
Амперсанд
39
0010 0111
‘
Апостроф
40
0010 1000
(
Левая круглая скобка
41
0010 1001
)
Правая круглая скобка
42
0010 1010
*
Звёздочка (астериск)
43
0010 1011
+
Знак плюс
44
0010 1100
,
Запятая
45
0010 1101
-
Дефис/минус
46
0010 1110
.
Точка
47
0010 1111
/
Слеш (косая черта)
48
0011 0000
0
Цифра 0
49
0011 0001
1
Цифра 1
50
0011 0010
2
Цифра 2
51
0011 0011
3
Цифра 3
52
0011 0100
4
Цифра 4
53
0011 0101
5
Цифра 5
54
0011 0110
6
Цифра 6
55
0011 0111
7
Цифра 7
56
0011 1000
8
Цифра 8
57
0011 1001
9
Цифра 9
58
0011 1010
:
Двоеточие
59
0011 1011
;
Точка с запятой
60
0011 1100
<
Знак «меньше»
61
0011 1101
=
Знак равенства
62
0011 1110
>
Знак «больше»
63
0011 1111
?
Вопросительный знак
64
0100 0000
@
Собака (at sign)
65
0100 0001
A
Латинская A
66
0100 0010
B
Латинская B
67
0100 0011
C
Латинская C
68
0100 0100
D
Латинская D
69
0100 0101
E
Латинская E
70
0100 0110
F
Латинская F
71
0100 0111
G
Латинская G
72
0100 1000
H
Латинская H
73
0100 1001
I
Латинская I
Рис. 3.6. Продолжение
3.4. Кодирование символов* 83
Десятичная система
Двоичная система
Символ
Значение
74
0100 1010
J
Латинская J
75
0100 1011
K
Латинская K
76
0100 1100
L
Латинская L
77
0100 1101
M
Латинская M
78
0100 1110
N
Латинская N
79
0100 1111
O
Латинская O
80
0101 0000
P
Латинская P
81
0101 0001
Q
Латинская Q
82
0101 0010
R
Латинская R
83
0101 0011
S
Латинская S
84
0101 0100
T
Латинская T
85
0101 0101
U
Латинская U
86
0101 0110
V
Латинская V
87
0101 0111
W
Латинская W
88
0101 1000
X
Латинская X
89
0101 1001
Y
Латинская Y
90
0101 1010
Z
Латинская Z
91
0101 1011
[
Левая квадратная скобка
92
0101 1100
\
Обратный слеш
93
0101 1101
]
Правая квадратная скобка
94
0101 1110
^
Циркумфлекс
95
0101 1111
_
Нижнее подчеркивание
96
0110 0000
`
Гравис
97
0110 0001
a
Латинская a
98
0110 0010
b
Латинская b
99
0110 0011
c
Латинская c
100
0110 0100
d
Латинская d
101
0110 0101
e
Латинская e
102
0110 0110
f
Латинская f
103
0110 0111
g
Латинская g
104
0110 1000
h
Латинская h
105
0110 1001
i
Латинская i
106
0110 1010
j
Латинская j
107
0110 1011
k
Латинская k
108
0110 1100
l
Латинская l
109
0110 1101
m
Латинская m
110
0110 1110
n
Латинская n
Рис. 3.6. Продолжение
84
Структуры данных
Десятичная система
Двоичная система
Символ
Значение
111
0110 1111
o
Латинская o
112
0111 0000
p
Латинская p
113
0111 0001
q
Латинская q
114
0111 0010
r
Латинская r
115
0111 0011
s
Латинская s
116
0111 0100
t
Латинская t
117
0111 0101
u
Латинская u
118
0111 0110
v
Латинская v
119
0111 0111
w
Латинская w
120
0111 1000
x
Латинская x
121
0111 1001
y
Латинская y
122
0111 1010
z
Латинская z
123
0111 1011
{
Левая фигурная скобка
124
0111 1100
|
Вертикальная черта
125
0111 1101
}
Правая фигурная скобка
126
0111 1110
~
Тильда
127
0111 1111
DEL
Удаление
Рис. 3.6. Окончание
Однако ASCII-код способен представлять только английский язык.
С развитием глобализации вычислительной техники появился расширенный
набор символов EASCII, способный представлять больше языков. Он расширяет
7-битный ASCII до 8 бит, что позволяет представлять 256 различных символов.
В разных частях мира постепенно появились различные EASCII-наборы
символов, адаптированные для разных регионов. Первые 128 символов в этих
наборах совпадают с ASCII, а оставшиеся 128 символов определяются поразному, чтобы удовлетворить потребности различных языков.
3.4.2. Таблицы символов GBK
Позднее выяснилось, что EASCII все еще не может удовлетворить требования многих языков по количеству символов. Например, в китайском языке
около ста тысяч иероглифов, из которых несколько тысяч используются в повседневной жизни. В 1980 году Государственное управление по стандартам Китая выпустило таблицу символов GB2312, которая включала 6763 иероглифа,
что в основном удовлетворяет потребности в обработке китайских иероглифов на компьютере.
Однако GB2312 не может обрабатывать некоторые редкие и традиционные иероглифы. Поэтому таблица GBK была расширена и стала включать 21
886 иероглифов. В кодировке GBK символы ASCII представляются одним байтом, а иероглифы – двумя байтами.
3.4. Кодирование символов* 85
3.4.3. Таблица символов Unicode
С бурным развитием компьютерных технологий появилось множество таблиц
символов и кодировок, что привело к большому количеству проблем. С одной
стороны, эти наборы символов обычно определяют только символы конкретного языка и не могут нормально работать в многоязычной среде. С другой
стороны, для одного и того же языка может существовать несколько таблиц
символов, и если два компьютера используют разные кодировки, то при передаче информации может возникнуть искажение символов.
Поэтому появилась идея создать достаточно полный набор символов,
включающий все языки и символы мира, который должен решить проблемы многоязычной среды и искажения символов. Под влиянием этой
идеи и появился большой и универсальный набор символов Unicode.
Название Unicode означает унифицированный код, теоретически эта кодировка может вмещать более 1 млн символов. Ее целью является включение
символов со всего мира в единый набор, предоставляя универсальное средство
для обработки и отображения различных языков, а также уменьшая проблемы
искажения символов из-за различий в стандартах кодирования.
С момента своего выпуска в 1991 году Unicode постоянно расширяется,
добавляя поддержку новых языков и символов. По состоянию на сентябрь
2022 года кодировка Unicode включает 149 186 символов, включая символы
различных языков, знаки и даже эмодзи. В обширном наборе символов Unicode часто используемые символы занимают 2 байта, некоторые редкие символы занимают 3 или даже 4 байта.
Unicode – это универсальный набор символов, который присваивает каждому символу номер (называемый кодовой точкой), но не определяет, как
эти кодовые точки должны храниться в компьютере. Возникает вопрос:
как система интерпретирует символы, когда в тексте одновременно присутствуют кодовые точки Unicode разной длины? Например, как система
определяет, что код длиной 2 байта – это один 2-байтовый символ, а не два
1-байтовых?
Одним из простых решений этой проблемы является хранение всех
символов в виде кодов одинаковой длины. В примере на рис. 3.7 показано, что каждый символ в слове Hello занимает 1 байт, а каждый символ в китайском слове 算法 (алгоритм) – 2 байта. Можно закодировать все символы
в выражении «Hello 算法» 2 байтами, заполнив старшие биты нулями. Таким
образом, система сможет распознавать один символ каждые 2 байта и восстановить содержание этой фразы.
Однако ASCII-код уже доказал, что для кодирования английского языка достаточно 1 байта. Если использовать вышеописанный метод, размер английского текста будет вдвое больше, чем при кодировании с помощью ASCII, что
крайне неэффективно по памяти. Поэтому необходим более эффективный метод использования кодировки Unicode.
86
Структуры данных
Символ
Английские символы длиной 1 байт
(старшие биты заполняются 0)
Китайские символы длиной 2 байта
Рис. 3.7. Пример представления символов в Unicode
3.4.4. Кодирование UTF-8
В настоящее время UTF-8 является наиболее широко используемым методом
кодирования Unicode в мире. Это кодировка переменной длины, использующая от 1 до 4 байт для представления одного символа, в зависимости от его
сложности. ASCII-символы требуют всего 1 байт, латинские и греческие буквы – 2 байта, часто используемые китайские иероглифы – 3 байта, а некоторые
редкие символы – 4 байта.
Правила кодирования UTF-8 несложны и делятся на два случая.
1. Для символов длиной в 1 байт старший бит устанавливается в 0, остальные 7 бит содержат кодовую точку Unicode. Следует отметить, что ASCIIсимволы занимают первые 128 кодовых точек в наборе Unicode. Это
означает, что кодировка UTF-8 обратно совместима с ASCII. Таким образом, можно использовать UTF-8 для обработки старых текстов в кодировке ASCII.
2. Для символов длиной в n байт (для n > 1) в первом байте старшие n бит
устанавливаются в 1, а (n + 1)-й бит – в 0. Начиная со второго байта,
в каждом байте старшие 2 бита устанавливаются в 10, остальные биты
используются для заполнения кодовой точки символа Unicode.
На рис. 3.8 изображено кодирование UTF-8 для фразы «Hello 算法». Можно
заметить, что, поскольку старшие n бит установлены в 1, система может определить длину символа n, считая количество старших единиц в первом байте.
Но почему старшие 2 бита остальных байтов устанавливаются в 10? На самом
деле эти 10 служат в качестве контрольного символа. Если система начнет разбор
текста с ошибочного байта, 10 в начале байта поможет быстро выявить ошибку.
10 используется как контрольный символ, потому что в соответствии с правилами кодирования UTF-8 не может быть символа, у которого старшие два
бита равны 10. Это можно доказать методом от противного: если предположить, что у символа старшие два бита равны 10, значит длина этого символа
равна 1, что соответствует ASCII-коду. Однако в ASCII-коде старший бит должен быть 0, что противоречит предположению.
3.4. Кодирование символов* 87
Символ
Установить три старших бита в 1,
задав длину символа в 3 байта
Установить два старших
бита остальных байтов в 10
Рис. 3.8. Пример кодирования с помощью UTF-8
Кроме UTF-8, существуют еще два распространенных способа кодирования.
1. Кодировка UTF-16: для представления одного символа используется
2 или 4 байта. Все ASCII-символы и часто используемые неанглийские
символы представлены 2 байтами. Некоторые другие символы требуют 4 байта. Для символов длиной в 2 байта кодировка UTF-16 равна
кодовой точке Unicode.
2. Кодировка UTF-32: каждый символ представляется 4 байтами. Это означает, что UTF-32 занимает больше места, чем UTF-8 и UTF-16, особенно для текстов с большим количеством ASCII-символов.
С точки зрения занимаемого пространства использование UTF-8 для представления английских символов очень эффективно, так как требуется всего
1 байт на символ. Использование UTF-16 для кодирования некоторых неанглийских символов (например, китайских) более эффективно, так как требуется
всего 2 байта, тогда как в UTF-8 может потребоваться 3 байта.
С точки зрения совместимости UTF-8 обладает наилучшей универсальностью, и многие инструменты и библиотеки в первую очередь используют
UTF-8.
3.4.5. Кодирование символов в языках программирования
Раньше в большинстве языков программирования строки в процессе выполнения программы кодировались с использованием UTF-16 или UTF-32, т. е.
кодировок фиксированной длины. В таких кодировках строки можно обрабатывать как массивы, что имеет следующие преимущества.
Случайный доступ: в строках в кодировке UTF-16 легко выполнять случайный доступ. UTF-8 является кодировкой переменной длины, поэтому
для нахождения i-го символа необходимо пройти от начала строки до
i-го символа, что требует времени O(n).
88
Структуры данных
Подсчет символов: аналогично случайному доступу вычисление длины
строки в кодировке UTF-16 является операцией O(1). Тогда как для вычисления длины строки в кодировке UTF-8 необходимо пройти всю строку.
Операции со строками: многие операции над строками в кодировке
UTF-16 (такие как разбиение, соединение, вставка, удаление и т. д.) выполняются легче. На строках в кодировке UTF-8 выполнение этих операций часто требует дополнительных вычислений, чтобы избежать создания недопустимых последовательностей байтов.
На самом деле проектирование схем кодирования символов в языках программирования является очень интересной темой, затрагивающей множество факторов.
Тип String в Java использует кодировку UTF-16, в которой каждый символ
занимает 2 байта. Это связано с тем, что при разработке языка Java предполагалось, что 16 бит достаточно для представления всех возможных
символов. Однако суждение оказалось ошибочным. Впоследствии спецификация Unicode расширилась за пределы 16 бит, поэтому в Java символы могут быть представлены парой 16-битных значений, называемых
суррогатной парой.
Строки в JavaScript и TypeScript также используют кодировку UTF-16
по аналогичным причинам. Когда в 1995 году компания Netscape впервые представила язык JavaScript, Unicode находился на ранней стадии
развития, и 16-битная кодировка была достаточной для представления
всех символов Unicode.
В C# используется кодировка UTF-16, главным образом потому что
платформа .NET была разработана Microsoft, а многие технологии Microsoft, включая операционную систему Windows, широко используют
кодировку UTF-16.
Из-за недооценки количества символов в этих языках программирования
пришлось использовать суррогатные пары для представления символов Unicode, превышающих 16 бит. Это было вынужденной мерой. С одной стороны,
в строках, содержащих суррогатные пары, один символ может занимать 2 или
4 байта, что лишает кодировку преимущества равной длины. С другой стороны, обработка суррогатных пар требует дополнительного кода, что увеличивает сложность программирования и затрудняет отладку.
По указанным причинам в некоторых языках программирования предложили различные схемы кодирования.
В Python тип str использует кодировку Unicode и гибкое представление строк, где длина символа зависит от наибольшего кодового пункта
Unicode в строке. Если строка содержит только символы ASCII, каждый
символ занимает 1 байт. Если есть символы за пределами ASCII, но
в пределах базовой многоязычной плоскости (BMP), каждый символ занимает 2 байта. Если есть символы за пределами BMP, каждый символ
занимает 4 байта.
В языке Go тип string внутренне использует кодировку UTF-8. Go также
содержит тип rune для представления одного кодового пункта Unicode.
3.5. Резюме 89
В языке Rust типы str и String внутренне используют кодировку UTF-8.
Rust также предоставляет тип char для представления одного кодового
пункта Unicode.
Следует отметить, что мы обсуждаем способ хранения строк в языках программирования, что отличается от хранения строк в файлах или передачи их
по сети. При хранении в файлах и сетевой передаче строки обычно кодируются в формате UTF-8 для достижения наилучшей совместимости и эффективности использования пространства.
3.5. Резюме
1. Ключевые моменты
Структуры данных можно классифицировать с точки зрения логической
и физической структуры. Логическая структура описывает логические
отношения между элементами данных, а физическая структура описывает способ хранения данных в памяти компьютера.
К распространенным логическим структурам относятся линейные, древовидные и сетевые. Обычно структуры данных делятся на линейные
(массивы, списки, стеки, очереди) и нелинейные (деревья, графы, кучи).
Реализация хеш-таблиц может включать как линейные, так и нелинейные структуры данных.
При выполнении программы данные хранятся в памяти компьютера.
Каждое пространство памяти имеет соответствующий адрес, с помощью
которого программа получает доступ к данным.
Физическая структура делится на хранение в непрерывном пространстве
(массивы) и хранение в разреженном пространстве (списки). Все структуры данных реализуются с помощью массивов, списков или их комбинации.
Основные типы данных в компьютере включают целые числа byte, short,
int, long, числа с плавающей запятой float, double, символы char и логические bool. Их диапазон значений зависит от размера занимаемого пространства и способа представления.
Прямой, обратный и дополнительный коды – это три метода кодирования чисел в компьютере, которые можно взаимно преобразовывать друг
в друга. В прямом коде старший бит целого числа является знаковым,
а остальные биты представляют значение числа.
Целые числа в компьютере хранятся в виде дополнительного кода.
В представлении дополнительного кода компьютер может одинаково
обрабатывать сложение положительных и отрицательных чисел без необходимости в специальной аппаратной схеме для вычитания. Также
при таком подходе отсутствует проблема двусмысленности положительного и отрицательного нуля.
Кодирование чисел с плавающей запятой состоит из 1 бита для знака, 8 бит
для экспоненты и 23 бит для мантиссы. Благодаря наличию экспоненты
диапазон значений чисел с плавающей запятой значительно превышает
диапазон целых чисел, но это достигается за счет потери точности.
90
Структуры данных
ASCII является первой кодировкой символов для английского языка.
Она включает 127 символов, длина кода составляет 1 байт. Набор символов GBK часто используется для китайского языка и включает более
двадцати тысяч иероглифов. Стандарт Unicode стремится предоставить
стандарт полного набора символов, охватывающий символы различных
языков мира, а также решая проблему искажения символов из-за несоответствия методов кодирования.
UTF-8 является самым популярным методом кодирования символов
Unicode и обладает высокой универсальностью. Это метод кодирования
переменной длины, который позволяет эффективно использовать пространство хранилища. UTF-16 и UTF-32 – это методы кодирования фиксированной длины. При кодировании китайских символов UTF-16 занимает меньше места, чем UTF-8. Такие языки программирования, как Java
и C#, по умолчанию используют кодировку UTF-16.
2. Вопросы и ответы
Вопрос. Почему хеш-таблица одновременно содержит линейные и нелинейные структуры данных?
Ответ. Хеш-таблица в своей основе представляет собой массив, и для решения проблемы хеш-коллизий может использоваться адрес цепочки (подробнее
см. в разделе «Хеш-коллизии»): каждая корзина массива указывает на связный
список, который при превышении определенного порога длины может быть
преобразован в дерево (обычно красно-черное дерево). С точки зрения хранения основа хеш-таблицы – это массив, в котором каждая корзина может содержать значение, связный список или дерево. Таким образом, хеш-таблица
может одновременно содержать линейные структуры данных (массивы, связные списки) и нелинейные структуры данных (деревья).
Вопрос. Длина типа char составляет 1 байт?
Ответ. Длина типа char определяется методом кодирования, используемым
языком программирования. Например, в Java, JavaScript, TypeScript, C# используется кодировка UTF-16 (сохраняющая кодовые точки Unicode), поэтому
длина типа char составляет 2 байта.
Вопрос. Можно ли назвать структуры данных на основе массивов статическими структурами данных? Стеки также могут выполнять такие операции, как извлечение и добавление элементов, но эти операции являются динамическими.
Ответ. Стек действительно может выполнять динамические операции
с данными, но структура данных остается статической (неизменной длины).
Хотя структуры данных на основе массивов могут динамически добавлять или
удалять элементы, их емкость фиксирована. Если объем данных превышает заранее выделенный размер, необходимо создать новый, более крупный массив
и скопировать в него содержимое старого массива.
Вопрос. При создании стека (очереди) не указывается его размер, почему
они являются статическими структурами данных?
Ответ. В высокоуровневых языках программирования не требуется вручную задавать начальную емкость стека (очереди), это выполняется автоматически внутри класса. Например, начальная емкость ArrayList в Java обычно
3.5. Резюме 91
составляет 10. Кроме того, операции расширения также выполняются автоматически. Подробнее см. в разделе «Списки».
Вопрос. Метод перевода прямого кода в дополнительный – сначала инверсия, затем прибавление 1, тогда перевод дополнительного кода в прямой должен быть обратной операцией – сначала вычитание 1, затем инверсия. Но перевод дополнительного кода в прямой также можно выполнить через «сначала
инверсия, затем прибавление 1». Почему это работает?
Ответ. Это связано с тем, что преобразование между прямым и дополнительным кодами фактически является процессом вычисления дополнения.
Сначала дадим определение дополнения: предположим, что a + b = c, тогда
a называется дополнением b до c, и наоборот, b называется дополнением a до c.
Рассмотрим двоичное число 0010 длиной n = 4 бита. Если воспринимать это
число как прямой код (без учета знакового бита), то его дополнительный код
получается путем инвертирования и прибавления 1:
0010 → 1101 → 1110.
Заметим, что сумма прямого и дополнительного кодов равна
0010 + 1110 = 10000, т. е. дополнительный код 1110 является дополнением прямого кода 0010 до 10000. Это означает, что вышеупомянутый процесс инвертирования и прибавления 1 фактически является вычислением дополнения до 10000.
Тогда каково же дополнение дополнительного кода 1110 до 10000? Его также
можно получить путем инвертирования и прибавления 1:
1110 → 0001 → 0010.
Другими словами, прямой и дополнительный коды являются взаимными дополнениями до 10000, поэтому преобразование прямого кода в дополнительный и преобразование дополнительного кода в прямой можно осуществить
с помощью одной и той же операции (инвертирование и прибавление 1).
Конечно, можно также использовать обратную операцию для получения
прямого кода из дополнительного 1110, т. е. вычитание 1 и инвертирование:
1110 → 1101 → 0010.
В итоге обе операции «инвертирование и прибавление 1» и «вычитание 1
и инвертирование» являются вычислением дополнения до 10000, и они эквивалентны.
По сути, операция инвертирования является вычислением дополнения до
1111 (поскольку всегда выполняется равенство прямой код + инверсный код = 1111).
А дополнительный код, полученный путем прибавления 1 к инверсному коду,
является дополнением до 10000.
Приведенный пример для n = 4 можно распространить на двоичные числа
любой длины.
Глава 4
Массивы и списки
Абстракция
Кирпичи массива аккуратно разложены, плотно прилегая друг к другу.
Кирпичи связного списка разложены вразнобой, их соединительная лоза
вьется между кирпичами.
4.1. Массивы 93
4.1. Массивы
Массив представляет собой линейную структуру данных, в которой элементы
одного типа хранятся в непрерывной области памяти. Положение элемента
в массиве называется его индексом. На рис. 4.1. изображены основные понятия и способ хранения массивов.
Элемент
Память для хранения массива
является непрерывной
Массив
Индекс
Адрес памяти
Доступная память
Память, выделенная для массива
Рис. 4.1. Определение и способ хранения массива
4.1.1. Основные операции с массивом
1. Инициализация массива
Существует два способа инициализации массива: без начальных значений и с заданными начальными значениями. Если начальные значения не указаны, большинство языков программирования инициализируют элементы массива нулями.
# === File: array.py ===
# Инициализация массива.
arr: list[int] = [0] * 5 # [ 0, 0, 0, 0, 0 ]
nums: list[int] = [1, 3, 2, 5, 4]
2. Доступ к элементам массива
Элементы массива хранятся в непрерывной области памяти, что упрощает вычисление их адресов. Зная адрес массива (адрес первого элемента) и индекс
элемента, можно вычислить адрес этого элемента по формуле, показанной на
рис. 4.2, и получить к нему доступ.
Как видно из рис. 4.2, индекс первого элемента массива равен 0, что может
показаться неочевидным, так как отсчет с 1 кажется более естественным. Однако с точки зрения формулы вычисления адреса индекс является смещением адреса в памяти. Смещение адреса первого элемента равно 0, поэтому
и его индекс равен 0.
94
Массивы и списки
Массив
Индекс элемента
Адрес памяти
Длина элемента = 4
Адрес памяти элемента = Адрес памяти массива + Длина элемента × Индекс элемента
(Адрес первого элемента)
(Смещение адреса)
Пример: нахождение адреса памяти элемента с индексом 3 в массиве
Рис. 4.2. Вычисление адреса элемента массива
Доступ к элементам массива осуществляется очень эффективно, так как позволяет за время O(1) произвольно обращаться к любому элементу.
# === File: array.py ===
def random_access(nums: list[int]) -> int:
""" Случайный доступ к элементу."""
# Случайно выбирается число из диапазона [0, len(nums)-1].
random_index = random.randint(0, len(nums) - 1)
# Получение и возврат случайного элемента.
random_num = nums[random_index]
return random_num
3. Вставка элемента
Элементы массива в памяти расположены вплотную, между ними нет места
для хранения других данных. Для вставки элемента в середину массива необходимо сдвинуть все последующие элементы на одну позицию вправо,
а затем присвоить значение элементу по заданному индексу, как показано
на рис. 4.3.
Следует отметить, что длина массива фиксирована, поэтому вставка элемента неизбежно приведет к потере элемента в конце массива. Решение этой
проблемы будет рассмотрено в разделе «Списки».
4.1. Массивы 95
Количество значимых
элементов в массиве = 4,
т. е. конечный элемент 0
не имеет значения
Пример
Последовательное
смещение элементов
на одну позицию
вправо
Вставка элемента 3 по индексу 1 массива
После вставки конечный
элемент 0 теряется
Рис. 4.3. Пример вставки элемента в массив
# === File: array.py ===
def insert(nums: list[int], num: int, index: int):
""" Вставка элемента num в массив по индексу index."""
# Сдвиг всех элементов, начиная с индекса index, на одну позицию вправо.
for i in range(len(nums) - 1, index, -1):
nums[i] = nums[i - 1]
# Присвоение num элементу по индексу index.
nums[index] = num
4. Удаление элемента
Аналогично для удаления элемента по индексу i необходимо сдвинуть все последующие элементы на одну позицию влево, как показано на рис. 4.4.
Обратите внимание, что после удаления элемента последний элемент становится бессмысленным, поэтому его можно не изменять.
# === File: array.py ===
def remove(nums: list[int], index: int):
""" Удаление элемента по индексу index."""
# Сдвиг всех элементов, начиная с индекса index, на одну позицию влево.
for i in range(index, len(nums) - 1):
nums[i] = nums[i + 1]
96
Массивы и списки
Элемент, подлежащий удалению
Последовательное смещение
элементов на одну позицию влево
Пример
Удаление элемента с индексом 1 из массива
После удаления количество значимых
элементов массива уменьшается на 1,
конечный элемент 4 больше не имеет
значения и не требует изменения
Рис. 4.4. Пример удаления элемента из массива
В целом операции вставки и удаления в массиве имеют следующие недостатки.
Высокая временная сложность: средняя временная сложность операций вставки и удаления в массиве составляет O(n), где n – длина массива.
Потеря элементов: так как длина массива фиксирована, при вставке
элемента элементы, выходящие за пределы длины массива, теряются.
Расточительность памяти: можно инициализировать длинный массив
и использовать только его часть, но это приведет к потере памяти, так
как неиспользуемые элементы в конце массива будут бессмысленными.
5. Обход массива
В большинстве языков программирования массив можно обходить как по индексам, так и непосредственно по элементам.
# === File: array.py ===
def traverse(nums: list[int]):
""" Обход массива."""
count = 0
# Обход массива по индексам.
for i in range(len(nums)):
count += nums[i]
# Прямой обход элементов массива.
for num in nums:
count += num
# Одновременный обход индексов и элементов.
4.1. Массивы 97
for i, num in enumerate(nums):
count += nums[i]
count += num
6. Поиск элемента
Для поиска заданного элемента в массиве необходимо обойти массив и на
каждой итерации проверить, совпадает ли значение элемента с искомым.
Если совпадает, вывести соответствующий индекс. Поскольку массив является линейной структурой данных, этот процесс называется линейным
поиском.
# === File: array.py ===
def find(nums: list[int], target: int) -> int:
""" Поиск заданного элемента в массиве."""
for i in range(len(nums)):
if nums[i] == target:
return i
return -1
7. Расширение массива
В сложных системных средах нельзя гарантировать, что ячейки памяти,
расположенные после массива, являются свободными. Это делает невозможным безопасное расширение его размера. Поэтому в большинстве языков программирования длина массива фиксирована. Если необходимо увеличить массив, нужно создать новый, больший массив и последовательно
скопировать в него элементы исходного массива. Эта операция имеет сложность O(n) и при больших массивах занимает много времени. Пример кода
представлен ниже.
# === File: array.py ===
def extend(nums: list[int], enlarge: int) -> list[int]:
""" Увеличение длины массива."""
# Инициализация массива с увеличенной длиной.
res = [0] * (len(nums) + enlarge)
# Копирование всех элементов исходного массива в новый массив.
for i in range(len(nums)):
res[i] = nums[i]
# Возврат нового массива с увеличенной длиной.
return res
4.1.2. Преимущества и ограничения массивов
Массивы хранятся в непрерывном пространстве памяти, а его элементы имеют одинаковый тип. Этот подход содержит богатую априорную информацию,
которую система может использовать для оптимизации эффективности операций с данной структурой данных.
98
Массивы и списки
Высокая эффективность использования пространства: массивы выделяют непрерывные блоки памяти для данных без дополнительных
структурных затрат.
Поддержка произвольного доступа: массивы позволяют получить доступ к любому элементу за время O(1).
Локальность кеширования: при доступе к элементам массива компьютер загружает не только его, но и кеширует окружающие данные, что позволяет ускорить выполнение последующих операций за счет использования высокоскоростного кеша.
Непрерывное хранение в пространстве – это палка о двух концах, имеющая
следующие ограничения.
Низкая эффективность вставки и удаления: при большом количестве
элементов в массиве операции вставки и удаления требуют перемещения множества элементов.
Неизменная длина: после инициализации длина массива фиксируется,
а увеличение массива требует копирования всех данных в новый массив,
что влечет за собой значительные затраты.
Расточительность пространства: если размер выделенного массива
превышает фактические потребности, избыточное пространство оказывается потраченным впустую.
4.1.3. Типичные сценарии применения массивов
Массивы – это базовая и распространенная структура данных, часто используемая в различных алгоритмах и для реализации сложных структур данных.
Произвольный доступ: если требуется случайный выбор элементов,
можно использовать массив для хранения и генерации случайной последовательности, осуществляя случайную выборку по индексу.
Сортировка и поиск: массивы являются наиболее часто используемой
структурой данных для алгоритмов сортировки и поиска. Быстрая сортировка, сортировка слиянием, двоичный поиск и другие алгоритмы в основном работают с массивами.
Таблица поиска: когда необходимо быстро найти элемент или его соответствие, можно использовать массив в качестве таблицы поиска. Например, для реализации отображения символов в ASCII-коды можно
использовать значение ASCII-кода символа в качестве индекса, а соответствующий элемент хранить в соответствующем месте массива.
Машинное обучение: в нейронных сетях широко используются операции линейной алгебры между векторами, матрицами и тензорами,
которые реализуются в виде массивов. Массивы являются наиболее
часто используемой структурой данных в программировании нейронных сетей.
Реализация структур данных: массивы могут использоваться для реализации стека, очереди, хеш-таблицы, кучи, графа и других структур
данных. Например, представление графа в виде матрицы смежности
фактически является двумерным массивом.
4.2. Связные списки 99
4.2. Связные списки
Память – это общий ресурс всех программ, и в сложной системной среде выполнения участки свободной памяти могут быть разбросаны по всему пространству памяти. Нам уже известно, что память для хранения массива должна
быть непрерывной, и когда массив очень велик, в памяти может не оказаться
столь большого непрерывного участка. В этом случае проявляется преимущество гибкости связного списка.
Связный список – это линейная структура данных, в которой каждый элемент
является объектом-узлом. При этом узлы соединяются друг с другом с помощью ссылок. В ссылке хранится адрес памяти следующего узла, по которому
можно перейти от текущего узла к следующему.
Структура связного списка позволяет узлам храниться в различных местах
памяти, а их адреса памяти не обязаны быть последовательными.
Память для хранения связного списка
является распределенной
Связный список
Значение
Ссылка (указатель)
Указывает на следующий узел
Узел
Доступная память
Хранение
значения узла
Хранение
указателя узла
Память для
хранения узлов
связного списка
Рис. 4.5. Определение и способ хранения связного списка
На рис. 4.5 изображена структура связного списка. Составным элементом
является объект узла. Каждый узел содержит две части данных: значение узла
и ссылку на следующий узел.
Первый узел связного списка называется головным узлом, а последний
узел – хвостовым узлом.
Хвостовой узел указывает на пустое значение, которое в Java, C++ и Python обозначается как null, nullptr и None соответственно.
В языках, поддерживающих указатели, таких как C, C++, Go и Rust, вышеупомянутая ссылка заменена на указатель.
В следующем примере кода показано, что узел связного списка ListNode, помимо
значения, должен дополнительно хранить ссылку (указатель). Поэтому при одинаковом объеме данных связный список занимает больше памяти, чем массив.
100
Массивы и списки
class ListNode:
""" Класс узла связного списка."""
def __init__(self, val: int):
self.val: int = val
# Значение узла.
self.next: ListNode | None = None # Ссылка на следующий узел.
4.2.1. Основные операции со связным списком
1. Инициализация связного списка
Создание связного списка состоит из двух этапов: первый этап – инициализация каждого объекта узла, второй этап – построение ссылочных отношений между узлами. После завершения инициализации можно начать
с головного узла связного списка и последовательно посетить все узлы через ссылку next.
# === File: linked_list.py ===
# Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4.
# Инициализация каждого узла.
n0 = ListNode(1)
n1 = ListNode(3)
n2 = ListNode(2)
n3 = ListNode(5)
n4 = ListNode(4)
# Построение ссылок между узлами.
n0.next = n1
n1.next = n2
n2.next = n3
n3.next = n4
Массив в целом является переменной, например массив nums содержит элементы nums[0] и nums[1] и т. д., в то время как связный список состоит из множества независимых объектов узлов. Обычно головной узел используется как
обозначение связного списка, например связный список в приведенном
выше коде можно обозначить как связный список n0.
2. Вставка узла
Процесс вставки узла в связный список очень прост. Предположим, что необходимо вставить новый узел P между двумя соседними узлами n0 и n1, для
этого достаточно изменить две ссылки (указателя), а время выполнения
составит O(1), см. рис. 4.6.
Напомним, что вставка элемента в массив имеет временную сложность O(n),
что менее эффективно при больших объемах данных.
4.2. Связные списки 101
(Элемент P указывает на n1)
Пример
Вставка узла P между узлами n0
и n1 связного списка
(Элемент n0 указывает на P)
Рис. 4.6. Пример вставки узла в связный список
# === File: linked_list.py ===
def insert(n0: ListNode, P: ListNode):
""" Вставка узла P после узла n0 в связный список."""
n1 = n0.next
P.next = n1
n0.next = P
3. Удаление узла
Удаление узла в связном списке также является очень простой операцией, как
показано на рис. 4.7. Достаточно изменить всего одну ссылку (указатель).
Обратите внимание, что, хотя после завершения операции удаления узел
P все еще указывает на узел n1, фактически при обходе этого связного списка
доступ к P уже невозможен. То есть фактически P больше не принадлежит этому списку.
# === File: linked_list.py ===
def remove(n0: ListNode):
""" Удаление первого узла после узла n0 в связном списке."""
if not n0.next:
return
# n0 -> P -> n1
P = n0.next
n1 = P.next
n0.next = n1
102
Массивы и списки
Пример
Удаление узла P из связного
списка
(Элемент n0 P
указывает на n1 )
После удаления элемент P все еще указывает на n1.
Но P больше не доступен при обходе списка,
поэтому можно считать, что P был удален
Рис. 4.7. Удаление узла в связном списке
4. Доступ к узлу
Эффективность доступа к узлам в связном списке ниже. Как упоминалось
в предыдущем разделе, доступ к любому элементу массива можно получить
за время O(1). В случае связного списка программа должна начать с головного узла и последовательно проходить по узлам, пока не будет найден целевой
узел. Это означает, что для доступа к i-му узлу связного списка необходимо
выполнить i – 1 итераций, что соответствует временной сложности O(n).
# === File: linked_list.py ===
def access(head: ListNode, index: int) -> ListNode | None:
""" Доступ к узлу с индексом index в связном списке."""
for _ in range(index):
if not head:
return None
head = head.next
return head
5. Поиск узла
Поиск узла заключается в обходе связного списка для поиска узла со значением target и выводе его индекса в списке. Этот процесс также является линейным поиском. Ниже приведен пример кода.
# === File: linked_list.py ===
def find(head: ListNode, target: int) -> int:
""" Поиск первого узла со значением target в связном списке."""
index = 0
4.2. Связные списки 103
while head:
if head.val == target:
return index
head = head.next
index += 1
return -1
4.2.2. Сравнение массивов и связных списков
В табл. 4.1 приведены характеристики массивов и связных списков, а также
сравнение эффективности операций с ними. Поскольку они используют две
противоположные стратегии хранения, их свойства и эффективность операций также имеют противоположные характеристики.
Таблица 4.1. Сравнение эффективности массивов и связных списков
Массив
Связный список
Способ хранения
Непрерывное пространство памяти
Распределенное пространство памяти
Расширение емкости
Длина фиксирована
Возможность гибкого
расширения
Эффективность памяти
Элементы занимают меньше памяти,
но могут расходовать пространство
впустую
Элементы занимают
больше памяти
Доступ к элементу
O(1)
O(n)
Добавление элемента
O(n)
O(1)
Удаление элемента
O(n)
O(1)
4.2.3. Основные типы связных списков
Существуют три основных типа связных списков (см. рис. 4.8).
Односвязный список: это обычный связный список, описанный ранее.
Узлы однонаправленного связного списка содержат значение и ссылку
на следующий узел. Первый узел называется головным, а последний –
хвостовым, хвостовой узел указывает на пустое значение None.
Кольцевой (циклический) список: если сделать так, чтобы хвостовой
узел односвязного списка указывал на головной узел (соединение начала
и конца), получится кольцевой список. В кольцевом списке любой узел
можно рассматривать как головной.
Двусвязный список: в отличие от односвязного двусвязный список
хранит ссылки в двух направлениях. Определение узла двусвязного списка включает ссылки (указатели) на следующий и предыдущий узлы.
По сравнению с односвязным списком двусвязный список обладает
большей гибкостью, позволяя обходить список в обоих направлениях,
но требует больше памяти.
104
Массивы и списки
class ListNode:
""" Класс узла двусвязного списка."""
def __init__(self, val: int):
self.val: int = val
# Значение узла.
self.next: ListNode | None = None # Ссылка на следующий узел.
self.prev: ListNode | None = None # Ссылка на предыдущий узел.
Односвязный список
Кольцевой список
3 Двусвязный список
Рис. 4.8. Виды связных списков
4.2.4. Типичные сценарии применения списков
Односвязные списки обычно используются для реализации таких структур
данных, как стек, очередь, хеш-таблица и граф.
Стек и очередь: когда операции вставки и удаления выполняются с одного конца списка, он ведет себя как стек (принцип «последний пришел – первый вышел»). Когда операция вставки выполняется с одного
конца, а операция удаления – с другого, он ведет себя как очередь (принцип «первый пришел – первый вышел»).
Хеш-таблица: метод цепочек является одним из основных способов решения коллизий в хеш-таблицах, при котором все конфликтующие элементы помещаются в один список.
Граф: списки смежности – это распространенный способ представления
графов, где каждая вершина графа связана со списком, элементы которого представляют другие вершины, соединенные с данной.
Двусвязные списки часто используются в ситуациях, где требуется быстрое
нахождение предыдущего и следующего элемента.
Расширенные структуры данных: например, в красно-черных деревьях и B-деревьях необходимо иметь доступ к родительскому узлу, что
можно реализовать, сохраняя ссылку на родительский узел аналогично
двусвязному списку.
4.3. Списки 105
История браузера: в веб-браузере, когда пользователь нажимает кнопки
Вперед или Назад, браузеру необходимо знать предыдущую и следующую страницы. Свойства двусвязного списка упрощают выполнение таких операций.
Алгоритм LRU: в алгоритме вытеснения из кеша (LRU) необходимо быстро находить наименее используемые данные, а также поддерживать
быстрое добавление и удаление узлов. В этом случае идеально подходит
двусвязный список.
Кольцевые списки часто применяются в ситуациях, требующих циклических операций, например в планировании ресурсов операционной системы.
Алгоритм циклического планирования: в операционных системах алгоритм циклического планирования – это распространенный алгоритм
планирования процессорного времени, который требует циклического обхода группы процессов. Каждому процессу назначается временной квант,
и по его истечении процессор переключается на следующий процесс. Такие
циклические операции можно реализовать с помощью кольцевого списка.
Буфер данных: в некоторых реализациях буферов данных также может
использоваться кольцевой список. Например, в аудио- и видеоплеерах поток данных может разделяться на несколько буферных блоков и помещаться в кольцевой список для обеспечения непрерывного воспроизведения.
4.3. Списки
Список – это абстрактное понятие структуры данных, представляющее собой
упорядоченное множество элементов, поддерживающее операции доступа,
изменения, добавления, удаления и обхода элементов без необходимости учитывать ограничения по объему. Списки могут быть реализованы на основе
связных списков или массивов.
Связные списки естественным образом можно рассматривать как списки, поддерживающие операции добавления, удаления, поиска и изменения элементов, а также динамическое расширение.
Массивы также поддерживают операции добавления, удаления, поиска
и изменения элементов, но из-за фиксированной длины их можно рассматривать только как списки с ограничением по длине.
При использовании массива для реализации списка неизменяемая длина
приводит к снижению его практичности. Это связано с тем, что зачастую
невозможно заранее определить, сколько данных потребуется хранить, что затрудняет выбор подходящей длины списка. Если длина слишком мала, это, вероятно, не удовлетворит потребности. Если длина слишком велика, это приведет
к неэффективному использованию памяти. Для решения этой проблемы можно
использовать динамический массив. Он сохраняет все преимущества массива
и может динамически расширяться в процессе выполнения программы.
На самом деле списки, предоставляемые стандартными библиотеками
многих языков программирования, реализованы на основе динамических массивов, например list в Python, ArrayList в Java, vector в C++ и List в C#.
В дальнейшем мы будем рассматривать список и динамический массив как
эквивалентные понятия.
106
Массивы и списки
4.3.1. Основные операции со списком
1. Инициализация списка
Обычно используются два метода инициализации: без начальных значений
и с заданием начальных значений.
# === File: list.py ===
# Инициализация списка.
# Без начальных значений.
nums1: list[int] = []
# С начальными значениями.
nums: list[int] = [1, 3, 2, 5, 4]
2. Доступ к элементам
Список по своей сути является массивом, поэтому доступ и обновление элементов возможны за время O(1), что очень эффективно.
# === File: list.py ===
# Доступ к элементам.
num: int = nums[1] # Доступ к элементу по индексу 1.
# Изменение элемента.
nums[1] = 0 # Изменение значения элемента с индексом 1 на 0.
3. Вставка и удаление элементов
В отличие от массива в списке можно свободно добавлять и удалять элементы. Сложность добавления элемента в конец списка составляет O(1), но вставка
и удаление элементов имеют ту же сложность O(n), что и в массиве.
# === File: list.py ===
# Очистка списка.
nums.clear()
# Добавление элементов в конец.
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
# Вставка элемента в середину.
nums.insert(3, 6) # Вставка числа 6 по индексу 3.
# Удаление элемента
nums.pop(3)
# Удаление элемента по индексу 3.
4. Обход списка
Как и массив, список можно обходить по индексу или напрямую по элементам.
4.3. Списки 107
# === File: list.py ===
# Обход списка по индексу.
count = 0
for i in range(len(nums)):
count += nums[i]
# Прямой обход элементов списка.
for num in nums:
count += num
5. Конкатенация списков
Создав новый список nums1, его можно присоединить в конец исходного списка.
# === File: list.py ===
# Конкатенация двух списков.
nums1: list[int] = [6, 8, 7, 10, 9]
nums += nums1 # Конкатенация списка nums1 с nums.
6. Сортировка списка
После сортировки списка можно использовать такие алгоритмы, как двоичный поиск и два указателя, которые часто встречаются в задачах с массивами.
# === File: list.py ===
# Сортировка списка.
nums.sort() # После сортировки элементы списка расположены по возрастанию.
4.3.2. Реализация списка
Многие языки программирования, такие как Java, C++, Python и др., имеют встроенные списки. Их реализация довольно сложна, а параметры тщательно продуманы, например начальная емкость, коэффициент расширения и т. д. Заинтересованные читатели могут изучить исходный код самостоятельно. Чтобы углубить понимание принципов работы списка, попробуем реализовать его упрощенную версию, включающую следующие три
ключевых аспекта.
Начальная емкость: выбор разумной начальной емкости массива.
В нашем примере выбрано значение 10 в качестве начальной емкости.
Учет количества: объявление переменной size для учета текущего количества элементов в списке. Эта переменная обновляется при вставке
и удалении элементов. На ее основе можно определить конец списка
и необходимость расширения.
Механизм расширения: если при вставке элемента емкость списка
оказывается исчерпанной, необходимо расширение. Сначала создается новый массив большего размера на основе коэффициента расширения, затем все элементы текущего массива последовательно
перемещаются в новый массив. В нашем примере массив каждый раз
расширяется двукратно.
108
Массивы и списки
# === File: my_list.py ===
class MyList:
""" Класс списка."""
def __init__(self):
""" Конструктор."""
self._capacity: int = 10 # Емкость списка.
self._arr: list[int] = [0] * self._capacity # Массив (хранение
№ элементов списка).
self._size: int = 0 # Длина списка (текущее количество элементов).
self._extend_ratio: int = 2 # Коэффициент расширения списка.
def size(self) -> int:
""" Получение длины списка (текущего количества элементов)."""
return self._size
def capacity(self) -> int:
""" Получение емкости списка."""
return self._capacity
def get(self, index: int) -> int:
""" Доступ к элементу."""
# Если индекс выходит за границы, выбрасывается исключение, далее аналогично.
if index < 0 or index >= self._size:
raise IndexError(" Индекс выходит за границы.")
return self._arr[index]
def set(self, num: int, index: int):
""" Обновление элемента."""
if index < 0 or index >= self._size:
raise IndexError(" Индекс выходит за границы.")
self._arr[index] = num
def add(self, num: int):
""" Добавление элемента в конец."""
# При превышении количества элементов емкости срабатывает механизм расширения.
if self.size() == self.capacity():
self.extend_capacity()
self._arr[self._size] = num
self._size += 1
def insert(self, num: int, index: int):
""" Вставка элемента в середину."""
if index < 0 or index >= self._size:
raise IndexError(" Индекс выходит за границы.")
# При превышении количества элементов емкости срабатывает механизм расширения.
if self._size == self.capacity():
self.extend_capacity()
4.4. Память и кеш* 109
# Все элементы начиная с индекса index смещаются на одну позицию вправо.
for j in range(self._size - 1, index - 1, -1):
self._arr[j + 1] = self._arr[j]
self._arr[index] = num
# Обновление количества элементов.
self._size += 1
def remove(self, index: int) -> int:
""" Удаление элемента."""
if index < 0 or index >= self._size:
raise IndexError(" Индекс выходит за границы.")
num = self._arr[index]
# Перемещение всех элементов после индекса index на одну позицию вперед.
for j in range(index, self._size - 1):
self._arr[j] = self._arr[j + 1]
# Обновление количества элементов.
self._size -= 1
# Возвращение удаленного элемента.
return num
def extend_capacity(self):
""" Расширение списка."""
# Создание нового массива длиной в _extend_ratio раз больше исходного
№ и копирование в него исходного массива.
self._arr = self._arr + [0] * self.capacity() * (self._extend_ratio - 1)
# Обновление емкости списка.
self._capacity = len(self._arr)
def to_array(self) -> list[int]:
""" Возвращение списка с фактической длиной."""
return self._arr[: self._size]
4.4. Память и кеш*
В первых двух разделах этой главы были рассмотрены массивы и связные списки – две базовые и важные структуры данных, представляющие собой соответственно непрерывное хранение и распределенное хранение.
Фактически физическая структура в значительной степени определяет
эффективность использования памяти и кеша программой, что, в свою
очередь, влияет на общую производительность алгоритма.
4.4.1. Устройства хранения в компьютере
В компьютере существует три типа устройств хранения: жесткий диск (HDD/
SSD), оперативная память (RAM) и кеш-память (cache). В табл. 4.2 приведены
их различные роли и характеристики.
110
Массивы и списки
Таблица 4.2. Устройства хранения в компьютере
Жесткий диск
Оперативная память
Кеш
Назначение
Долговременное хранение данных, включая
операционную систему,
программы, файлы и т. д.
Временное хранение
запущенных программ
и обрабатываемых
данных
Хранение часто запрашиваемых данных и инструкций, сокращение числа
обращений к оперативной
памяти процессором
Энергозависимость
Данные не теряются после отключения питания
Данные теряются после Данные теряются после
отключения питания
отключения питания
Емкость
Большая, на уровне
терабайтов
Меньше, на уровне
гигабайтов
Скорость
Медленная, от нескольБыстрая, десятки гигаких сотен до нескольких байт в секунду
тысяч мегабайт в секунду
Очень быстрая, от десятков до сотен гигабайт
в секунду
Цена
Низкая, несколько центов за гигабайт
Очень высокая, цена
включена в стоимость
процессора
Высокая, несколько
долларов за гигабайт
Очень малая, на уровне
мегабайтов
Компьютерную систему хранения можно представить в виде пирамидальной структуры, как показано на рис. 4.9. Чем ближе к вершине пирамиды находится устройство хранения, тем выше его скорость, меньше емкость и выше
стоимость. Такой многоуровневый дизайн не случаен, а является результатом
тщательных размышлений компьютерных ученых и инженеров.
Кеш
Оперативная
память
Скорость
Цена
Объем
Постоянство
Жесткий
диск
Рис. 4.9. Система хранения данных в компьютере
Жесткий диск трудно заменить оперативной памятью. Во-первых,
данные в оперативной памяти теряются после отключения питания, поэтому она не подходит для долговременного хранения. Во-вторых, стоимость оперативной памяти в десятки раз выше, чем у жесткого диска,
что затрудняет ее распространение на потребительском рынке.
4.4. Память и кеш* 111
Большая емкость и высокая скорость кеша трудно совместимы.
С увеличением емкости кеша уровней L1, L2, L3 его физические размеры увеличиваются. Вместе с этим растет физическое расстояние до ядра
процессора, что приводит к увеличению времени передачи данных и задержке доступа к элементам. В текущих условиях многоуровневая структура кеша является оптимальным балансом между емкостью, скоростью
и стоимостью.
Совет
Иерархия хранения данных в компьютере отражает изящный баланс между
скоростью, емкостью и стоимостью. На самом деле такой компромисс существует во всех промышленных областях и требует нахождения оптимального
баланса между различными преимуществами и ограничениями.
В целом жесткий диск используется для долговременного хранения
большого объема данных, оперативная память – для временного хранения данных, обрабатываемых во время выполнения программы, а кеш –
для хранения часто запрашиваемых данных и инструкций, чтобы повысить эффективность выполнения программы. Все три компонента работают
совместно, обеспечивая эффективную работу компьютерной системы.
Во время выполнения программы данные считываются с жесткого диска
в оперативную память для обработки процессором, как показано на рис. 4.10.
Кеш-память можно рассматривать как часть процессора, в которую по сложным алгоритмам загружаются данные из оперативной памяти. Это обеспечивает высокоскоростное чтение данных процессором, значительно повышает
эффективность выполнения программы и снижает зависимость от более медленной оперативной памяти.
Процессор
Кеш
Оперативная
память
Жесткий
диск
Рис. 4.10. Поток данных между жестким диском, оперативной памятью и кешем
4.4.2. Эффективность использования
памяти структурами данных
С точки зрения использования памяти массивы и связные списки имеют свои
преимущества и ограничения.
С одной стороны, память ограничена, и одну и ту же область памяти
нельзя разделить между несколькими программами, поэтому желательно,
112
Массивы и списки
чтобы структуры данных максимально эффективно использовали пространство. Элементы массива расположены плотно, также не требуется дополнительное пространство для хранения ссылок (указателей) между узлами, что
делает его более эффективным с точки зрения использования памяти. Однако массивы требуют выделения сразу достаточного количества непрерывного
пространства памяти, что может привести к ее растрате, а расширение массива также требует дополнительных временных и пространственных затрат.
В отличие от этого списки осуществляют динамическое распределение и освобождение памяти на уровне узлов, что обеспечивает большую гибкость.
С другой стороны, во время выполнения программы по мере многократного
выполнения запросов и освобождения памяти степень фрагментации свободной памяти будет увеличиваться, что снижает эффективность ее использования. Благодаря непрерывному способу хранения массивы относительно менее
подвержены фрагментации памяти. Элементы списка, напротив, хранятся
разрозненно, и при частых операциях вставки и удаления они более подвержены фрагментации памяти.
4.4.3. Эффективность кеширования структур данных
Хотя объем кеша значительно меньше объема памяти, он намного быстрее
и играет решающую роль в скорости выполнения программы. Объем кеша
ограничен, и он может хранить лишь небольшую часть часто запрашиваемых
данных. Поэтому при попытке процессора получить доступ к данным, отсутствующим в кеше, происходит промах кеша, и процессор вынужден загружать
необходимые данные из более медленной памяти.
Очевидно, что чем меньше промахов кеша, тем выше эффективность
чтения и записи данных процессором и тем лучше производительность
программы. Доля данных, успешно полученных процессором из кеша, называется коэффициентом попадания в кеш. Этот показатель обычно используется
для оценки эффективности кеша.
Для достижения максимальной эффективности кеш использует следующие
механизмы загрузки данных.
Кеш-линия: кеш не хранит и не загружает данные по байтам, а использует в качестве единицы кеш-линии. По сравнению с передачей по байтам передача кеш-линий более эффективна.
Механизм предвыборки: процессор пытается предсказать шаблоны
доступа к данным (например, последовательный доступ, доступ с фиксированным шагом и т. д.) и загружает данные в кеш в соответствии
с этими шаблонами, чтобы повысить коэффициент попадания.
Пространственная локальность: если к данным был осуществлен доступ,
то, вероятно, в ближайшее время будет осуществлен доступ и к данным, находящимся поблизости. Поэтому при загрузке данных кеш также загружает
данные, находящиеся рядом, чтобы повысить коэффициент попадания.
Временная локальность: если к данным был осуществлен доступ, то
в ближайшем будущем, вероятно, к ним будет осуществлен повторный
доступ. Кеш использует этот принцип, сохраняя недавно запрашиваемые данные, чтобы повысить коэффициент попадания.
4.5. Резюме 113
Фактически эффективность использования кеша массивами и списками различается, что проявляется в следующих аспектах.
Занимаемое пространство: элементы списка занимают больше пространства, чем элементы массива, что приводит к уменьшению объема
полезных данных, которые могут быть помещены в кеш.
Кеш-линии: данные списка распределены по всей памяти, а кеш загружает данные по линиям, поэтому доля загружаемых неэффективных
данных выше.
Механизм предвыборки: шаблоны доступа к данным массива более
предсказуемы, чем у списка, т. е. системе легче угадать, какие данные
могут быть загружены.
Пространственная локальность: массивы хранятся в сконцентрированном пространстве памяти, поэтому данные, находящиеся рядом с загруженными, с большей вероятностью будут запрошены.
В целом массивы обладают более высоким коэффициентом попадания в кеш, поэтому они обычно превосходят списки по эффективности операций. Это делает структуры данных, реализованные на
основе массивов, более предпочтительными при решении алгоритмических задач.
Следует отметить, что высокая эффективность использования кеша
не означает, что массивы всегда предпочтительнее списков. Выбор структуры данных в реальных приложениях должен основываться на конкретных
требованиях. Например, структуру данных «стек» можно реализовать на основе и массивов, и списков (детали этого будут рассмотрены в следующей главе),
но они предназначены для различных сценариев.
При решении алгоритмических задач предпочтение отдается стеку,
реализованному на основе массива, поскольку он обеспечивает более
высокую эффективность операций и возможность случайного доступа.
Но платой за это является необходимость заранее выделить определенное количество памяти для массива.
Если объем данных очень велик, динамичность высока, и размер стека
трудно предсказать, то более предпочтителен стек, реализованный на
основе списка. Список позволяет распределенно хранить большое количество данных в различных частях памяти и избегать дополнительных
затрат на расширение массива.
4.5. Резюме
1. Ключевые моменты
Массивы и списки – это две основные структуры данных, представляющие два способа хранения данных в памяти компьютера: хранение в непрерывном пространстве и хранение в распределенном пространстве.
Их характеристики дополняют друг друга.
Массивы поддерживают случайный доступ и занимают меньше памяти.
Однако эффективность вставки и удаления элементов низкая, а длина
после инициализации фиксированная.
114
Массивы и списки
Списки обеспечивают эффективную вставку и удаление узлов путем изменения ссылок (указателей) и могут гибко изменять свою длину. Однако доступ к узлам менее эффективен, и они занимают больше памяти.
К распространенным типам списков относятся односвязные, кольцевые
и двусвязные списки.
Список – это упорядоченная коллекция элементов, поддерживающая
операции добавления, удаления, поиска и изменения и обычно реализуемая на основе динамического массива. Он сохраняет преимущества
массива, одновременно позволяя гибко изменять длину.
Появление списка значительно повысило практическую ценность массива, но может привести к частичной потере памяти.
Во время выполнения программы данные в основном хранятся в памяти. Массивы могут обеспечить более высокую эффективность использования памяти, тогда как списки более гибки в ее использовании.
Кеш, используя кеш-линии, механизм предвыборки, а также механизмы
пространственной и временной локальности данных, обеспечивает для
процессора быстрый доступ к данным, значительно повышая эффективность выполнения программы.
Поскольку массивы обладают более высокой вероятностью попадания
в кеш, они обычно более эффективны, чем списки. При выборе структуры данных следует принимать во внимание конкретные требования
и сценарии.
2. Вопросы и ответы
Вопрос. Влияет ли хранение массива в стеке или в куче на временную и пространственную эффективность?
Ответ. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном
пространстве памяти, и эффективность операций с данными в основном одинакова. Однако стек и куча имеют свои особенности, что приводит к следующим различиям:
1) эффективность выделения и освобождения: стек – это небольшая область памяти, выделение которой выполняется автоматически компилятором, в то время как память кучи относительно больше и может выделяться динамически в коде, что делает ее более подверженной фрагментации. Поэтому операции выделения и освобождения в куче обычно
медленнее, чем в стеке;
2) ограничение размера: память стека относительно мала, размер кучи
обычно ограничен доступной памятью. Поэтому куча более подходит
для хранения больших массивов;
3) гибкость: размер массива в стеке должен быть определен на этапе компиляции, тогда как размер массива в куче может быть динамически
определен во время выполнения.
Вопрос. Почему для массива требуется, чтобы элементы были одного типа,
а для списка это не обязательно?
Ответ. Список состоит из узлов, которые соединяются между собой с помощью ссылок (указателей), и каждый узел может хранить данные различных
типов, например int, double, string, object и т. д.
4.5. Резюме 115
Элементы массива, напротив, должны быть одного типа, чтобы можно было
вычислить смещение для получения позиции соответствующего элемента. Например, если массив одновременно содержит элементы типов int и long, которые
занимают 4 и 8 байт соответственно, то нельзя использовать следующую формулу для вычисления смещения, поскольку массив содержит две длины элемента.
# Адрес памяти элемента = Адрес памяти массива (адрес памяти первого элемента)
+ Длина элемента * Индекс элемента
Вопрос. После удаления узла P нужно ли устанавливать P.next в значение None?
Ответ. Не обязательно изменять P.next. С точки зрения данного связного
списка при обходе от головного узла до хвостового узла узел P больше не будет
встречен. Это означает, что узел P уже удален из списка, и в этом случае, куда
бы ни указывал узел P, это не повлияет на данный список.
С точки зрения структуры данных и алгоритмов (решение задач) разрыв
связи не имеет значения, главное – чтобы логика программы была правильной. С точки зрения стандартной библиотеки разрыв связи более безопасен
и логически ясен. Если связь не разрывается и память удаленного узла не будет
корректно освобождена, это может повлиять на освобождение памяти последующих узлов.
Вопрос. Временная сложность операций вставки и удаления в связном списке составляет O(1). Однако для поиска элемента перед добавлением или удалением требуется время O(n). Почему же временная сложность не O(n)?
Если сначала производится поиск элемента, а затем его удаление, временная сложность действительно составляет O(n). Однако преимущество O(1)
для операций добавления и удаления в связном списке проявляется в других
ситуациях. Например, двусторонняя очередь подходит для реализации с помощью связного списка, когда поддерживаются указатели, всегда указывающие на головной и хвостовой узлы, и каждая операция вставки и удаления
выполняется за O(1).
Вопрос. На рис. 4.5 «Определение и способ хранения связного списка» голубые указатели на узлы занимают один блок памяти или они делят его со значениями узлов?
Ответ. Данная схема является качественным представлением, количественное представление требует анализа в зависимости от конкретной ситуации.
Различные типы значений узлов занимают разный объем, например int,
long, double и экземпляры объектов.
Размер области памяти, занимаемой переменной-указателем, зависит
от используемой операционной системы и среды компиляции и обычно
составляет 8 или 4 байта.
Вопрос. Всегда ли добавление элемента в конец списка имеет временную
сложность O(1)?
Ответ. Если при добавлении элемента превышается длина списка, то сначала необходимо расширить список, а затем добавить элемент. Система запросит новый блок памяти и перенесет в него все элементы исходного списка,
в этом случае временная сложность будет O(n).
116
Массивы и списки
Вопрос. Фраза «Появление списка значительно повысило практическую
ценность массива, но может привести к частичной потере памяти» означает,
что потеря памяти связана с дополнительными переменными, такими как емкость, длина и коэффициент расширения?
Ответ. Потеря памяти здесь имеет два значения. Во-первых, у списка устанавливается начальная длина, но нам не всегда требуется столько места. Вовторых, чтобы избежать частого расширения, обычно используется определенный коэффициент, например ×1.5. Это приводит к появлению множества
пустых мест, которые обычно не удается полностью заполнить.
Вопрос. После инициализации массива в Python n = [1, 2, 3] адреса этих трех
элементов связаны, но при инициализации m = [2, 1, 3] обнаруживается, что
идентификатор каждого элемента не является последовательным, а совпадает
с таковым в n. Если адреса элементов не последовательны, является ли m массивом?
Ответ. Если заменить элементы списка на узлы связного списка n = [n1, n2,
n3, n4, n5], то в большинстве случаев эти 5 объектов узлов также будут храниться в разных местах памяти. Однако, имея индекс списка, можно за время
O(1) получить адрес памяти узла и получить доступ к соответствующему узлу.
Это происходит потому, что в массиве хранятся ссылки на узлы, а не сами узлы.
В отличие от многих других языков в Python числа также упакованы в объекты, и в списке хранятся не сами числа, а ссылки на них. Поэтому можно обнаружить, что одинаковые числа в двух массивах имеют один и тот же идентификатор, а адреса памяти этих чисел не обязаны быть последовательными.
Вопрос. В C++ в библиотеке STL std::list уже реализован двусторонний
связный список, но в некоторых книгах по алгоритмам его не используют напрямую. Есть ли у него какие-то ограничения?
Ответ. С одной стороны, мы часто предпочитаем использовать массивы для
реализации алгоритмов и прибегаем к связным спискам только при необходимости. Основные причины этого следующие.
пространственные затраты: так как каждый элемент требует двух дополнительных указателей (одного для предыдущего элемента и одного
для следующего элемента), std::list обычно занимает больше места, чем
std::vector;
неоптимально для кеширования: поскольку данные не хранятся непрерывно, std::list имеет низкую эффективность использования кеша.
В общем случае производительность std::vector будет лучше.
С другой стороны, необходимость использования связных списков в основном возникает в случае двоичных деревьев и графов. Стек и очередь часто используют предоставляемые языком программирования типы stack и queue, а не
связные списки.
Вопрос. Операция res = [[0]] * n создает двумерный список, в котором каждый элемент [0] является независимым?
Ответ. Нет, они не являются независимыми. В этом двумерном списке все
элементы [0] фактически являются ссылками на один и тот же объект. Если изменить один из элементов, все соответствующие элементы также изменятся.
Если требуется, чтобы каждый [0] в двумерном списке был независимым,
можно использовать инструкцию res = [[0] for _ in range(n)]. Этот способ основан на инициализации n независимых объектов списка [0].
4.5. Резюме 117
Вопрос. Операция res = [0] * n создает список, в котором все целые 0 являются независимыми?
Ответ. В этом списке все целые 0 являются ссылками на один и тот же объект. Это связано с тем, что в Python используется механизм пула для малых
целых чисел (обычно от –5 до 256), чтобы максимизировать повторное использование объектов и повысить производительность.
Хотя они ссылаются на один и тот же объект, мы все же можем независимо
изменять каждый элемент списка, поскольку целые числа в Python являются
неизменяемыми объектами. При изменении какого-либо элемента фактически происходит переключение на ссылку на другой объект, а не изменение самого исходного объекта.
Однако, когда элементы списка являются изменяемыми объектами (например, списки, словари или экземпляры классов и т. д.), изменение какого-либо
элемента напрямую изменяет и сам объект, и все элементы, ссылающиеся на
этот объект, претерпевают те же изменения.
Глава 5
Стек и очередь
Абстракция
Стек подобен стопке из котят, а очередь – это их шеренга.
Они отражают логические отношения «первый вошел – последний вышел»
и «первый вошел – первый вышел».
5.1. Стек 119
5.1. Стек
Стек – это линейная структура данных, которая следует логике «первый вошел – последний вышел».
Стек можно сравнить со стопкой тарелок на столе: чтобы достать тарелку
снизу, нужно сначала убрать все тарелки сверху. Заменив тарелки на элементы
различных типов (например, целые числа, символы, объекты и т. д.), мы получим структуру данных, называемую стеком.
Верх стопки элементов называется вершиной стека, а низ – основанием стека, как показано на рис. 5.1. Операция добавления элемента на вершину стека
называется вставка, а удаление элемента с вершины – извлечение.
Помещение Помещение
в стек
в стек
Извлечение Извлечение
из стека
из стека
Стек
Вершина стека
Помещение
в стек
Извлечение
из стека
Основание стека
Рис. 5.1. Правило «первый вошел – последний вышел» для стека
5.1.1. Основные операции со стеком
Основные операции со стеком представлены в табл. 5.1, конкретные имена
методов зависят от используемого языка программирования. Здесь в качестве
примера используются распространенные имена push(), pop(), peek().
Таблица 5.1. Эффективность операций со стеком
Метод
Описание
Временная сложность
push()
Вставка элемента (добавление на вершину стека)
O(1)
pop()
Извлечение элемента с вершины стека
O(1)
peek()
Доступ к элементу на вершине стека
O(1)
Обычно достаточно использовать классы стека, встроенные в язык программирования. Однако в некоторых языках может не быть специального класса
для стека. Тогда можно использовать массив или связный список в качестве
стека, игнорируя операции, не связанные со стеком.
120
Стек и очередь
# === File: stack.py ===
# Инициализация стека.
# В Python нет встроенного класса стека, можно использовать list.
stack: list[int] = []
# Вставка элемента.
stack.append(1)
stack.append(3)
stack.append(2)
stack.append(5)
stack.append(4)
# Доступ к элементу на вершине стека.
peek: int = stack[-1]
# Извлечение элемента.
pop: int = stack.pop()
# Получение длины стека.
size: int = len(stack)
# Проверка на пустоту.
is_empty: bool = len(stack) == 0
5.1.2. Реализация стека
Чтобы глубже понять механизм работы стека, попробуем реализовать собственный класс стека.
Стек следует принципу «первый вошел – последний вышел», поэтому добавление и удаление элементов возможно только на вершине стека. Однако
в массивах и связных списках элементы можно добавлять и удалять в любом
месте, поэтому стек можно рассматривать как ограниченный массив
или связный список. Иными словами, можно скрыть часть операций массива или связного списка, чтобы их внешняя логика соответствовала характеристикам стека.
1. Реализация на основе связного списка
При использовании для реализации стека связного списка можно считать
головной узел связного списка вершиной стека, а хвостовой узел – основанием стека.
Для операции вставки элемента достаточно вставить его в начало связного
списка, как показано на рис. 5.2. Этот метод вставки узла называется вставка в голову. Для операции извлечения элемента достаточно удалить головной
узел из связного списка.
5.1. Стек 121
Вершина стека
Помещение
в стек
Извлечение
из стека
Головной узел
Базовая реализация – связный список
в качестве стека
Хвостовой узел
Основание стека
Шаг 1
Помещение узла 4
в вершину
связного списка
Головной узел
Базовая реализация – связный список
в качестве стека
Хвостовой узел
Шаг 2
Удаление
головного узла
Головной узел
Базовая реализация – связный список
в качестве стека
Хвостовой узел
Шаг 3
Рис. 5.2. Операции вставки и извлечения в стеке на основе связного списка
122
Стек и очередь
Ниже приведен пример кода для реализации стека на основе связного списка.
# === File: linkedlist_stack.py ===
class LinkedListStack:
"""Стек на основе связного списка."""
def __init__(self):
"""Конструктор."""
self._peek: ListNode | None = None
self._size: int = 0
def size(self) -> int:
"""Получение длины стека."""
return self._size
def is_empty(self) -> bool:
"""Проверка стека на пустоту."""
return self._size == 0
def push(self, val: int):
"""Вставка элемента."""
node = ListNode(val)
node.next = self._peek
self._peek = node
self._size += 1
def pop(self) -> int:
"""Извлечение элемента."""
num = self.peek()
self._peek = self._peek.next
self._size -= 1
return num
def peek(self) -> int:
"""Доступ к элементу на вершине стека."""
if self.is_empty():
raise IndexError("Стек пуст")
return self._peek.val
def to_list(self) -> list[int]:
"""Преобразование в список для печати."""
arr = []
node = self._peek
while node:
arr.append(node.val)
node = node.next
arr.reverse()
return arr
2. Реализация на основе массива
При использовании для реализации стека массива можно считать конец массива вершиной стека. Операции вставки и извлечения соответствуют добавле-
5.1. Стек 123
нию и удалению элементов в конце массива, как показано на рис. 5.3. Временная сложность этих операций составляет O(1).
Вершина стека
Помещение
в стек
Свободное
место в массиве
Извлечение
из стека
Конечный
элемент
Базовая реализация – массив
в качестве стека
Начальный
элемент
Основание стека
Шаг 1
Конечный
элемент
Добавление
элемента 4
в конец массива
Базовая реализация – массив
в качестве стека
Начальный
элемент
Шаг 2
Конечный
элемент
Базовая реализация – массив
в качестве стека
Удаление конечного
элемента массива
Начальный
элемент
Шаг 3
Рис. 5.3. Операции вставки и извлечения в стеке на основе массива
124
Стек и очередь
Поскольку количество вставляемых элементов может постоянно увеличиваться, можно использовать динамический массив, чтобы не заниматься расширением массива самостоятельно. Ниже приведен пример кода.
# === File: array_stack.py ===
class ArrayStack:
"""Стек на основе массива."""
def __init__(self):
"""Конструктор."""
self._stack: list[int] = []
def size(self) -> int:
"""Получение длины стека."""
return len(self._stack)
def is_empty(self) -> bool:
"""Проверка стека на пустоту."""
return self.size() == 0
def push(self, item: int):
"""Вставка элемента."""
self._stack.append(item)
def pop(self) -> int:
"""Извлечение элемента."""
if self.is_empty():
raise IndexError("Стек пуст")
return self._stack.pop()
def peek(self) -> int:
"""Доступ к элементу на вершине стека."""
if self.is_empty():
raise IndexError("Стек пуст")
return self._stack[-1]
def to_list(self) -> list[int]:
"""Возврат списка для печати."""
return self._stack
5.1.3. Сравнение двух реализаций
Поддерживаемые операции
Обе реализации поддерживают все операции, определенные для стека. Реализация на основе массива дополнительно поддерживает произвольный доступ,
но это выходит за рамки определения стека, поэтому обычно не используется.
5.1. Стек 125
Временная сложность
В реализации на основе массива операции добавления и удаления элемента
выполняются в заранее выделенной непрерывной памяти, что обеспечивает
хорошую локальность кеша и, следовательно, высокую эффективность. Однако, если при добавлении элемента превышается емкость массива, срабатывает механизм расширения, что приводит к увеличению временной сложности
данной операции до O(n).
В реализации на основе связного списка расширение происходит очень гибко, и не возникает проблемы снижения эффективности, как в случае расширения массива. Однако операция добавления элемента требует инициализации
объекта узла и изменения указателя, что делает ее относительно менее эффективной. Тем не менее, если добавляемый элемент уже является объектом узла,
можно избежать шага инициализации, что повысит эффективность.
Таким образом, если элементы операций добавления и удаления являются
примитивными типами данных, такими как int или double, можно сделать следующие выводы:
1) стек, реализованный на основе массива, при срабатывании механизма
расширения теряет в эффективности, но, так как расширение является
редкой операцией, средняя эффективность выше;
2) стек, реализованный на основе связного списка, обеспечивает более стабильную эффективность.
Пространственная сложность
При инициализации массива система выделяет для него начальную емкость,
которая может превышать фактические потребности. Кроме того, механизм
расширения обычно осуществляется с определенным коэффициентом (например, в 2 раза), и емкость после расширения также может превышать фактические потребности. Поэтому стек, реализованный на основе массива,
может приводить к некоторым потерям пространства.
Однако, так как узлы связного списка требуют дополнительного хранения
указателей, занимаемое ими пространство сравнительно больше.
Таким образом, нельзя однозначно определить, какая реализация более экономична в плане памяти, необходимо анализировать конкретные ситуации.
5.1.4. Типичные сценарии применения стека
Возврат и переход вперед в браузере, отмена и повтор в программном
обеспечении. Каждый раз, когда открывается новая веб-страница, браузер выполняет добавление предыдущей страницы в стек, что позволяет вернуться к ней с помощью операции возврата. Операция возврата
фактически является выполнением удаления из стека. Если требуется
поддержка как возврата, так и перехода вперед, необходимо использовать два стека.
Управление памятью программы. Каждый раз при вызове функции система добавляет на вершину стека фрейм для записи контекстной информации функции. В рекурсивных функциях на этапе нисходящей ре-
126
Стек и очередь
курсии постоянно выполняется добавление в стек, а на этапе восходящей
рекурсии – удаление из стека.
5.2. Очередь
Очередь – это линейная структура данных, следующая правилу «первый пришел – первый вышел». Как следует из названия, очередь моделирует реальную очереди, когда новые элементы постоянно добавляются в конец очереди,
а элементы в начале очереди покидают ее последовательно.
Начало очереди называется голова, а конец – хвост, см. рис. 5.4. Операция
добавления элемента в конец очереди называется добавление в очередь, а удаление элемента из начала очереди – удаление из очереди.
Очередь
Голова очереди
(Извлечение
из очереди)
(Добавление
в очередь)
Хвост очереди
Рис. 5.4. Правило очереди «первый пришел – первый вышел»
5.2.1. Основные операции с очередью
Основные операции с очередью представлены в табл. 5.2. Следует отметить,
что имена методов могут различаться в зависимости от языка программирования. Здесь используются те же названия методов, что и для стека.
Таблица 5.2. Эффективность операций с очередью
Метод
Описание
Временная сложность
Добавление элемента в очередь, т. е. добавление
O(1)
pop()
Удаление элемента из головы очереди
O(1)
peek()
Доступ к элементу в голове очереди
O(1)
push()
элемента
в конец очереди
Основание
стека
Можно использовать готовый класс очереди в языке программирования.
5.2. Очередь 127
# === File: queue.py ===
from collections import deque
# Инициализация очереди# В Python обычно используется класс двусторонней очереди deque.
# Хотя queue.Queue() является полноценным классом очереди, он не очень удобен,
поэтому не рекомендуется к использованию.
que: deque[int] = deque()
# Добавление элемента в очередь.
que.append(1)
que.append(3)
que.append(2)
que.append(5)
que.append(4)
# Доступ к элементу в голове очереди.
front: int = que[0]
# Удаление элемента из очереди.
pop: int = que.popleft()
# Получение длины очереди.
size: int = len(que)
# Проверка очереди на пустоту.
is_empty: bool = len(que) == 0
5.2.2. Реализация очереди
Для реализации очереди требуется структура данных, которая позволяет добавлять элементы с одного конца и удалять с другого конца. И связный список,
и массив соответствуют этим требованиям.
1. Реализация на основе связного списка
Можно рассматривать головной узел и хвостовой узел связного списка как начало очереди и конец очереди соответственно. А также установить правило,
что добавление узлов возможно только в конец очереди, а удаление узлов –
только из начала очереди, как показано на рис. 5.5.
128
Стек и очередь
Голова очереди
(Извлечение
из очереди)
Головной узел
Базовая реализация – связный список
в качестве очереди
Хвостовой узел
(Добавление
в очередь)
Хвост очереди
Шаг 1
Помещение
узла 4 в хвост
связного списка
Головной узел
Базовая реализация – связный список
в качестве очереди
Хвостовой узел
Шаг 2
Удаление головного
узла
Головной узел
Базовая реализация – связный список
в качестве очереди
Хвостовой узел
Шаг 3
Рис. 5.5. Операции добавления и удаления в очереди, реализованной на основе связного
списка
5.2. Очередь 129
Ниже приведен код реализации очереди с использованием связного списка.
# === File: linkedlist_queue.py ===
class LinkedListQueue:
"""Очередь на основе связного списка."""
def __init__(self):
"""Конструктор."""
self._front: ListNode | None = None
self._rear: ListNode | None = None
self._size: int = 0
# Головной узел front.
# Хвостовой узел rear.
def size(self) -> int:
"""Получение длины очереди."""
return self._size
def is_empty(self) -> bool:
"""Проверка очереди на пустоту."""
return self._size == 0
def push(self, num: int):
"""Добавление в очередь."""
# Добавление num после хвостового узла.
node = ListNode(num)
# Если очередь пуста, то головной и хвостовой узлы указывают на этот
узел.
if self._front is None:
self._front = node
self._rear = node
# Если очередь не пуста, то узел добавляется после хвостового узла.
else:
self._rear.next = node
self._rear = node
self._size += 1
def pop(self) -> int:
"""Удаление из очереди."""
num = self.peek()
# Удаление головного узла.
self._front = self._front.next
self._size -= 1
return num
def peek(self) -> int:
"""Доступ к элементу в начале очереди."""
if self.is_empty():
raise IndexError("Очередь пуста")
return self._front.val
130
Стек и очередь
def to_list(self) -> list[int]:
"""Преобразование в список для печати."""
queue = []
temp = self._front
while temp:
queue.append(temp.val)
temp = temp.next
return queue
2. Реализация на основе массива
Удаление первого элемента в массиве имеет временную сложность O(n), что
снижает эффективность операции удаления из очереди. Однако можно использовать следующий изящный метод, чтобы избежать этой проблемы.
Можно использовать переменную front для указания на индекс первого элемента очереди и поддерживать переменную size для записи длины очереди.
Определим переменную rear = front + size. Тогда rear будет указывать на следующий элемент после хвоста очереди.
В этой схеме эффективный диапазон элементов в массиве составляет
[front, rear - 1]. Методы реализации различных операций показаны на рис. 5.6.
Добавление в очередь: присвоение нового элемента индексу rear и увеличение size на 1.
Удаление из очереди: достаточно увеличить front на 1 и уменьшить size
на 1.
Можно заметить, что добавление в очередь и удаление из нее требуют только двух операций, временная сложность каждой из которых равна O(1).
front указывает на первый элемент очереди
rear указывает на последний элемент очереди + 1
Голова очереди
Длина очереди
(Извлечение
из очереди)
Базовая реализация – массив
в качестве очереди
(Добавление
в очередь)
Хвост очереди
Шаг 1
1. Вычислить указатель хвоста rear
2. Добавить элемент в позицию rear
3. Увеличить size на 1
10
1. Увеличить front на 1
2. Уменьшить size на 1
Рис. 5.6. Операции добавления и удаления в очереди, реализованной на основе массива
5.2. Очередь 131
1. Вычислить указатель хвоста rear
2. Добавить элемент в позицию rear
3. Увеличить size на 1
Длина очереди
Базовая реализация – массив
в качестве очереди
Шаг 2
1. Увеличить front на 1
2. Уменьшить size на 1
1. Увеличить front на 1
2. Уменьшить size на 1
Длина очереди
Базовая реализация – массив
в качестве очереди
Шаг 3
Рис. 5.6. Окончание
Может возникнуть трудность: в процессе постоянного добавления и удаления положения front и rear перемещаются вправо, и когда они достигают
конца массива, дальнейшее перемещение становится невозможным.
Чтобы решить эту проблему, можно рассматривать массив как кольцевой массив с соединенными концами.
Для кольцевого массива необходимо, чтобы front или rear, пересекая конец
массива, возвращались к его началу для продолжения обхода. Этот циклический процесс можно реализовать с помощью операции взятия остатка, пример кода приведен ниже.
132
Стек и очередь
# === File: array_queue.py ===
class ArrayQueue:
"""Очередь на основе кольцевого массива."""
def __init__(self, size: int):
"""Конструктор."""
self._nums: list[int] = [0] * size
self._front: int = 0
self._size: int = 0
# Массив для хранения
# элементов очереди.
# Указатель на начало очереди,
# указывает на первый элемент.
# Длина очереди.
def capacity(self) -> int:
"""Получение емкости очереди."""
return len(self._nums)
def size(self) -> int:
"""Получение длины очереди."""
return self._size
def is_empty(self) -> bool:
"""Проверка, пуста ли очередь."""
return self._size == 0
def push(self, num: int):
"""Добавление в очередь."""
if self._size == self.capacity():
raise IndexError("Очередь полна")
# Вычисление указателя на конец очереди, указывает на индекс конца + 1.
# Реализация возврата rear к началу массива после пересечения конца
# с помощью операции взятия остатка.
rear: int = (self._front + self._size) % self.capacity()
# Добавление num в конец очереди.
self._nums[rear] = num
self._size += 1
def pop(self) -> int:
"""Удаление из очереди."""
num: int = self.peek()
# Указатель на начало очереди перемещается на одну позицию вперед,
# если пересекает конец, возвращается к началу массива.
self._front = (self._front + 1) % self.capacity()
self._size -= 1
return num
5.3. Двусторонняя очередь 133
def peek(self) -> int:
"""Доступ к элементу в начале очереди."""
if self.is_empty():
raise IndexError("Очередь пуста")
return self._nums[self._front]
def to_list(self) -> list[int]:
"""Возврат списка для печати."""
res = [0] * self.size()
j: int = self._front
for i in range(self.size()):
res[i] = self._nums[(j % self.capacity())]
j += 1
return res
Реализованная выше очередь все еще имеет ограничение: ее длина неизменна. Однако эту проблему несложно решить, заменить массив на динамический с помощью механизма расширения. Заинтересованные читатели могут
попробовать реализовать это самостоятельно.
Выводы о сравнении двух реализаций аналогичны выводам о стеке, поэтому здесь мы не будем повторяться.
5.2.3. Типичные сценарии применения очереди
1. Заказы на маркетплейсах. После оформления заказа покупателем он добавляется в очередь, и система затем обрабатывает заказы в порядке их
поступления. В период распродаж за короткое время создается огромное количество заказов, и высокая нагрузка становится проблемой для
разработчиков программного обеспечения.
2. Различные списки задач. Любая ситуация, требующая реализации принципа «первым пришел – первым обслужен». Например, очередь заданий
в принтере, очередь заказов в ресторане и т. д. Очередь в этих ситуациях
эффективно поддерживает порядок обработки.
5.3. Двусторонняя очередь
В обычной очереди можно удалять только элементы из начала и добавлять
элементы только в конец. Двусторонняя очередь предоставляет большую гибкость, позволяя выполнять операции добавления или удаления элементов как
в начале, так и в конце, см. рис. 5.7.
134
Стек и очередь
Добавление
элемента
в голову
push_first(1)
Двусторонняя очередь
(Deque)
Извлечение
элемента
с головы
pop_first()
Голова очереди
Извлечение
из очереди
Добавление
в очередь
Добавление
в очередь
Извлечение
из очереди
Хвост очереди
Добавление
элемента
в хвост
push_last(4)
Извлечение
элемента
с хвоста
pop_last()
Рис. 5.7. Операции в двусторонней очереди
5.3.1. Основные операции с двусторонней очередью
Обычные операции с двусторонней очередью представлены в табл. 5.3, конкретные имена методов зависят от используемого языка программирования.
Таблица 5.3. Эффективность операций с двусторонней очередью
Метод
Описание
Временная сложность
push_first()
Добавление элемента в начало очереди
O(1)
push_last()
Добавление элемента в конец очереди
O(1)
pop_first()
Удаление элемента из начала очереди
O(1)
pop_last()
Удаление элемента из конца очереди
O(1)
peek_first()
Доступ к элементу в начале очереди
O(1)
peek_last()
Доступ к элементу в конце очереди
O(1)
Аналогично обычной очереди можно использовать уже реализованный
в языке программирования класс двусторонней очереди.
5.3. Двусторонняя очередь 135
# === File: deque.py ===
from collections import deque
# Инициализация двусторонней очереди.
deq: deque[int] = deque()
# Добавление элементов в очередь.
deq.append(2)
# Добавление в конец.
deq.append(5)
deq.append(4)
deq.appendleft(3) # Добавление в начало.
deq.appendleft(1)
# Доступ к элементам.
front: int = deq[0]
# Элемент в начале очереди.
rear: int = deq[-1]
# Элемент в конце очереди.
# Удаление элементов из очереди.
pop_front: int = deq.popleft() # Удаление из начала очереди.
pop_rear: int = deq.pop()
# Удаление из конца очереди.
# Получение длины двусторонней очереди.
size: int = len(deq)
# Проверка на пустоту двусторонней очереди
is_empty: bool = len(deq) == 0
5.3.2. Реализация двусторонней очереди*
Реализация двусторонней очереди схожа с обычной очередью – можно выбрать в качестве базовой структуры данных связный список или массив.
1. Реализация на основе двусвязного списка
В предыдущем разделе для реализации очереди использовался обычный односвязный список, так как он позволяет удобно удалять головной узел (соответствует операции удаления из очереди) и добавлять новый узел после хвостового узла (соответствует операции добавления в очередь).
Для двусторонней очереди операции добавления и удаления можно выполнять как в начале, так и в конце. Иными словами, двусторонняя очередь требует реализации операций в симметричном направлении. Для этого в качестве
базовой структуры данных двусторонней очереди удобно использовать дву
связный список.
Головной и хвостовой узлы двусвязного списка рассматриваются как начало
и конец двусторонней очереди. При этом реализуется возможность добавления и удаления узлов с обеих сторон, см. рис. 5.8.
136
Стек и очередь
Голова очереди
Извлечение
с головы
очереди
Помещение
в голову
очереди
Помещение
в хвост
очереди
Головной узел
Базовая реализация – двусвязный
список в качестве двусторонней
очереди
Извлечение
Хвостовой узел
с хвоста
очереди
Хвост очереди
Шаг 1
Добавление узла 4 в хвост
связного списка
10 Добавление узла 4
в хвост связного списка
Головной узел
11 Добавление узла 1
Базовая
– двусвязный
в головуреализация
связного списка
список в качестве двусторонней
очереди
12 Удаление хвостового узла
13 Удаление головного узла
Хвостовой узел
Шаг 2
Добавление узла 1
в голову связного
списка
Головной узел
Базовая реализация – двусвязный
список в качестве двусторонней
очереди
Хвостовой узел
Шаг 3
Рис. 5.8. Операции добавления и удаления в двусторонней
очереди на основе связного списка
5.3. Двусторонняя очередь 137
Удаление
хвостового узла
Головной узел
Базовая реализация – двусвязный
список в качестве двусторонней
очереди
Хвостовой узел
Шаг 4
Удаление
головного узла
Головной узел
Базовая реализация – двусвязный
список в качестве двусторонней
очереди
Хвостовой узел
Шаг 5
Рис. 5.8. Окончание
Ниже представлен код реализации.
# === File: linkedlist_deque.py ===
class ListNode:
""" Узел двусвязного списка."""
def __init__(self, val: int):
""" Конструктор."""
self.val: int = val
self.next: ListNode | None = None # Ссылка на следующий узел.
self.prev: ListNode | None = None # Ссылка на предыдущий узел.
class LinkedListDeque:
""" Двусторонняя очередь на основе двусвязного списка."""
138
Стек и очередь
def __init__(self):
""" Конструктор."""
self._front: ListNode | None = None # Головной узел front.
self._rear: ListNode | None = None # Хвостовой узел rear.
self._size: int = 0 # Длина двусторонней очереди.
def size(self) -> int:
""" Получение длины двусторонней очереди."""
return self._size
def is_empty(self) -> bool:
""" Проверка на пустоту двусторонней очереди."""
return self._size == 0
def push(self, num: int, is_front: bool):
""" Операция добавления в очередь."""
node = ListNode(num)
# Если список пуст, front и rear указывают на node.
if self.is_empty():
self._front = self._rear = node
# Добавление в начало очереди.
elif is_front:
# Добавление node в начало списка.
self._front.prev = node
node.next = self._front
self._front = node # Обновление головного узла.
# Добавление в конец очереди.
else:
# Добавление node в конец списка.
self._rear.next = node
node.prev = self._rear
self._rear = node # Обновление хвостового узла.
self._size += 1 # Обновление длины очереди.
def push_first(self, num: int):
""" Добавление в начало очереди."""
self.push(num, True)
def push_last(self, num: int):
""" Добавление в конец очереди."""
self.push(num, False)
def pop(self, is_front: bool) -> int:
""" Операция удаления из очереди."""
if self.is_empty():
raise IndexError(" Двусторонняя очередь пуста.")
# Удаление из начала очереди.
if is_front:
val: int = self._front.val # Временное сохранение значения
# головного узла.
5.3. Двусторонняя очередь 139
# Удаление головного узла.
fnext: ListNode | None = self._front.next
if fnext != None:
fnext.prev = None
self._front.next = None
self._front = fnext # Обновление головного узла.
# Удаление из конца очереди.
else:
val: int = self._rear.val # Временное сохранение значения
# хвостового узла.
# Удаление хвостового узла.
rprev: ListNode | None = self._rear.prev
if rprev != None:
rprev.next = None
self._rear.prev = None
self._rear = rprev # Обновление хвостового узла.
self._size -= 1 # Обновление длины очереди.
return val
def pop_first(self) -> int:
""" Удаление из начала очереди."""
return self.pop(True)
def pop_last(self) -> int:
""" Удаление из конца очереди."""
return self.pop(False)
def peek_first(self) -> int:
""" Доступ к элементу в начале очереди."""
if self.is_empty():
raise IndexError(" Двусторонняя очередь пуста.")
return self._front.val
def peek_last(self) -> int:
""" Доступ к элементу в конце очереди."""
if self.is_empty():
raise IndexError(" Двусторонняя очередь пуста.")
return self._rear.val
def to_array(self) -> list[int]:
""" Возврат массива для печати."""
node = self._front
res = [0] * self.size()
for i in range(self.size()):
res[i] = node.val
node = node.next
return res
140
Стек и очередь
2. Реализация на основе массива
Аналогично реализации обычной очереди для двусторонней очереди можно
использовать кольцевой массив, как показано на рис. 5.9.
front указывает на первый элемент очереди
rear указывает на последний элемент очереди + 1
Голова очереди
Извлечение
с головы
очереди
Помещение
в голову
очереди
Помещение
в хвост
очереди
Длина очереди
Базовая реализация – кольцевой
массив в качестве двусторонней
очереди
Извлечение
с хвоста
очереди
Хвост очереди
Шаг 1
1. Вычислить указатель хвоста rear
2. Добавить элемент в позицию rear
3. Увеличить size на 1
Длина очереди
Базовая реализация – кольцевой
массив в качестве двусторонней
очереди
Шаг 2
Рис. 5.9. Операции добавления и удаления в двусторонней очереди на основе массива
5.3. Двусторонняя очередь 141
1. Уменьшить front на 1
2. Добавить элемент в позицию front
3. Увеличить size на 1
Длина очереди
Базовая реализация – кольцевой
массив в качестве двусторонней
очереди
Шаг 3
Уменьшить size на 1
Длина очереди
Базовая реализация – кольцевой
массив в качестве двусторонней
очереди
Шаг 4
Рис. 5.9. Продолжение
142
Стек и очередь
1. Увеличить front на 1
2. Уменьшить size на 1
Длина очереди
Базовая реализация – кольцевой
массив в качестве двусторонней
очереди
Шаг 5
Рис. 5.9. Окончание
По сравнению с реализацией обычной очереди необходимо лишь добавить
методы для добавления в начало очереди и для удаления из конца очереди.
# === File: array_deque.py ===
class ArrayDeque:
""" Двусторонняя очередь на основе кольцевого массива."""
def __init__(self, capacity: int):
""" Конструктор."""
self._nums: list[int] = [0] * capacity
self._front: int = 0
self._size: int = 0
def capacity(self) -> int:
""" Получение емкости двусторонней очереди."""
return len(self._nums)
def size(self) -> int:
""" Получение длины двусторонней очереди."""
return self._size
def is_empty(self) -> bool:
""" Проверка, пуста ли двусторонняя очередь."""
return self._size == 0
5.3. Двусторонняя очередь 143
def index(self, i: int) -> int:
""" Вычисление индекса кольцевого массива."""
# Реализация соединения начала и конца массива с помощью
# операции взятия остатка.
# Когда i превышает конец массива, возвращается к началу.
# Когда i превышает начало массива, возвращается к концу.
return (i + self.capacity()) % self.capacity()
def push_first(self, num: int):
""" Добавление в начало очереди."""
if self._size == self.capacity():
print(" Двусторонняя очередь полна.")
return
# Перемещение указателя начала очереди на одну позицию влево.
# Реализация возврата front к концу массива после превышения начала.
self._front = self.index(self._front - 1)
# Добавление num в начало очереди.
self._nums[self._front] = num
self._size += 1
def push_last(self, num: int):
""" Добавление в конец очереди."""
if self._size == self.capacity():
print(" Двусторонняя очередь полна.")
return
# Вычисление указателя конца очереди, указывает на индекс конца + 1.
rear = self.index(self._front + self._size)
# Добавление num в конец очереди.
self._nums[rear] = num
self._size += 1
def pop_first(self) -> int:
""" Удаление из начала очереди."""
num = self.peek_first()
# Перемещение указателя начала очереди на одну позицию вправо.
self._front = self.index(self._front + 1)
self._size -= 1
return num
def pop_last(self) -> int:
""" Удаление из конца очереди."""
num = self.peek_last()
self._size -= 1
return num
def peek_first(self) -> int:
""" Доступ к элементу в начале очереди."""
144
Стек и очередь
if self.is_empty():
raise IndexError(" Двусторонняя очередь пуста.")
return self._nums[self._front]
def peek_last(self) -> int:
""" Доступ к элементу в конце очереди."""
if self.is_empty():
raise IndexError(" Двусторонняя очередь пуста.")
# Вычисление индекса последнего элемента.
last = self.index(self._front + self._size - 1)
return self._nums[last]
def to_array(self) -> list[int]:
""" Возврат массива для печати."""
# Преобразование только элементов в пределах действительной длины.
res = []
for i in range(self._size):
res.append(self._nums[self.index(self._front + i)])
return res
5.3.3. Сценарии применения двусторонней очереди
Двусторонняя очередь сочетает в себе логику стека и очереди. Поэтому она
применима для всех сценариев этих двух структур, одновременно предоставляя большую степень свободы.
Известно, что функция отмены в программном обеспечении обычно реализуется с помощью стека: система помещает каждое изменение в стек с помощью операции push, а затем выполняет отмену с помощью операции pop.
Однако, учитывая ограничения системных ресурсов, программное обеспечение обычно ограничивает количество шагов отмены (например, позволяет сохранить только 50 шагов). Когда длина стека превышает 50, программе нужно
выполнить удаление внизу стека (в начале очереди). Но стек не может реализовать эту функцию, и в этом случае необходимо использовать двустороннюю очередь вместо стека. Следует отметить, что основная логика
отмены по-прежнему следует принципу стека «первым пришел – последним
вышел», просто двусторонняя очередь позволяет более гибко реализовать некоторые дополнительные логические операции.
5.4. Резюме
1. Ключевые моменты
Стек – это структура данных, которая следует принципу «первым пришел – последним вышел» и может быть реализована с помощью массива
или связного списка.
В плане временной сложности реализация стека с использованием массива обладает более высокой средней эффективностью, но во время расширения сложность времени выполнения одной операции добавления
5.4. Резюме 145
в стек может ухудшиться до O(n). В сравнении с этим реализация стека
с использованием связного списка обладает более стабильной эффективностью.
В плане пространственной сложности реализация стека с использованием массива может привести к определенной степени потери пространства. Однако следует отметить, что память, занимаемая узлами связного
списка, больше, чем у элементов массива.
Очередь – это структура данных, которая следует принципу «первым
пришел – первым вышел» и также может быть реализована с помощью
массива или связного списка. В плане временной и пространственной
сложности выводы по очереди схожи с выводами по стеку.
Двусторонняя очередь – это очередь с большей степенью свободы, она
позволяет добавлять и удалять элементы с обоих концов.
2. Вопросы и ответы
Вопрос. Реализована ли функция «вперед-назад» в браузере с помощью двустороннего связного списка?
Ответ. Функция «вперед-назад» в браузере, по сути, является типичным
проявлением стека. Когда пользователь посещает новую страницу, она добавляется на вершину стека. Когда пользователь нажимает кнопку Назад, страница извлекается с вершины стека. Использование двусторонней очереди позволяет удобно реализовать некоторые дополнительные операции, что упоминается в разделе «Двусторонняя очередь».
Вопрос. Нужно ли освобождать память узла после извлечения из стека?
Ответ. Если в дальнейшем необходимо использовать извлеченный узел, то
освобождать память не нужно. Если узел больше не нужен, в языках Java и Python
имеется автоматический механизм сборки мусора, поэтому ручное освобождение памяти не требуется. В C и C++ необходимо освобождать память вручную.
Вопрос. Двусторонняя очередь похожа на два стека, соединенных вместе.
Каково ее назначение?
Ответ. Двусторонняя очередь подобна комбинации стека и очереди или двум
стекам, соединенным вместе. Она представляет собой логику стека и очереди,
поэтому подходит для всех сценариев их применения, но является более гибкой.
Вопрос. Как конкретно реализуются функции отмены и повтора операций?
Ответ. Используются два стека: стек A для отмены, стек B для повтора.
1. Когда пользователь выполняет операцию, она помещается в стек A, а стек
B очищается.
2. Когда пользователь выполняет отмену, из стека A извлекается последняя
операция и помещается в стек B.
3. Когда пользователь выполняет повтор, из стека B извлекается последняя
операция и помещается в стек A.
Глава 6
Хеш-таблицы
Абстракция
В мире вычислений хеш-таблица подобна мудрому библиотекарю.
Она знает, как вычислить номер книги, чтобы быстро ее найти.
6.1. Хеш-таблицы 147
6.1. Хеш-таблицы
Хеш-таблица реализует эффективный поиск элементов через установление соответствия между ключом key и значением value. Более конкретно,
передав ключ в хеш-таблицу, можно получить соответствующее значение
за время O(1).
Пусть имеется n студентов, у каждого из которых есть имя и номер. Если
нужно реализовать функцию «ввести номер студента и получить соответствующее имя», то можно использовать хеш-таблицу, как показано на рис. 6.1.
Хеш-таблица
Номер
Имя
"Иван"
"Петр"
"Яков"
"Ира"
"Аня"
Введите номер студента key, чтобы найти соответствующее имя value
Временная сложность операции поиска в хеш-таблице равна O(1)
Рис. 6.1. Схематичное представление хеш-таблицы
Помимо хеш-таблиц, функцией поиска также обладают массивы и связные
списки. Сравнение их эффективности приведено в табл. 6.1.
Добавление элемента: достаточно добавить элемент в конец массива
(списка) за время O(1).
Поиск элемента: так как массив (список) не упорядочен, необходимо
просмотреть все элементы за время O(n).
Удаление элемента: сначала нужно найти элемент, а затем удалить его
из массива (списка), понадобится время O(n).
Таблица 6.1. Сравнение эффективности поиска элементов
Массив
Связный список
Хеш-таблица
Поиск элемента
O(n)
O(n)
O(1)
Добавление элемента
O(1)
O(1)
O(1)
Удаление элемента
O(n)
O(n)
O(1)
Мы видим, что в хеш-таблице операции добавления, удаления и поиска имеют временную сложность O(1), что очень эффективно.
6.1.1. Основные операции с хеш-таблицами
К основным операциям с хеш-таблицами относятся: инициализация, поиск,
добавление и удаление пар ключ–значение. Ниже приведен пример кода.
148
Хеш-таблицы
# === File: hash_map.py ===
# Инициализация хеш-таблицы.
hmap: dict = {}
# Операция добавления.
# Добавление пары ключ-значение (key, value) в хеш-таблицу.
hmap[12836] = "Иван"
hmap[15937] = "Петр"
hmap[16750] = "Владимир"
hmap[13276] = "Максим"
hmap[10583] = "Андрей"
# Операция поиска.
# Ввод ключа key в хеш-таблицу для получения значения value.
name: str = hmap[15937]
# Операция удаления.
# Удаление пары ключ-значение (key, value) из хеш-таблицы.
hmap.pop(10583)
Существует три распространенных способа обхода хеш-таблицы: обход пар
ключ–значение, обход ключей и обход значений. Ниже приведен пример кода.
# === File: hash_map.py ===
# Обход хеш-таблицы.
# Обход пар ключ-значение key->value.
for key, value in hmap.items():
print(key, "->", value)
# Обход только ключей key.
for key in hmap.keys():
print(key)
# Обход только значений value.
for value in hmap.values():
print(value)
6.1.2. Простая реализация хеш-таблицы
Рассмотрим самый простой случай, когда хеш-таблица реализуется с помощью одного массива. В хеш-таблице каждый пустой слот массива называется
корзиной, и каждая корзина может хранить одну пару ключ–значение. Таким
образом, операция поиска заключается в нахождении корзины, соответствующей ключу key, и получении значения value из нее.
Как определить корзину, соответствующий ключу? Это осуществляется с помощью хеш-функции. Хеш-функция предназначена для отображения большого входного пространства в меньшее выходное пространство. В хеш-таблице
входное пространство – это все ключи, а выходное пространство – это все корзины (индексы массива). Другими словами, передав ключ в хеш-функцию,
можно определить место хранения пары ключ–значение в массиве.
6.1. Хеш-таблицы 149
Процесс вычисления хеш-функции для ключа включает следующие этапы:
1) вычисление хеш-значения с помощью некоторого хеш-алгоритма hash();
2) взятие остатка от деления хеш-значения на количество корзин capacity
(длину массива) для получения индекса массива index, соответствующего ключу key.
index = hash(key) % capacity
После этого можно использовать значение index для доступа к соответствующей корзине в хеш-таблице и получения значения value.
Предположим, что длина массива capacity = 100, хеш-алгоритм hash(key) = key.
Тогда хеш-функция будет иметь вид key % 100. На рис. 6.2 показан принцип работы хеш-функции на примере ключа «номер» и значения «имя» для студента.
Ввод key
Индекс
Массив
"Иван"
Вывод
value
"Иван"
"Петр"
Хеш-функция
"Яков"
"Яков"
"Ира"
"Ира"
"Аня"
(Каждая ячейка хранит
одну пару ключ–значение)
Рис. 6.2. Принцип работы хеш-функции
В коде ниже реализуется простая хеш-таблица. Здесь key и value заключены
в класс Pair для представления пары ключ–значение.
# === File: array_hash_map.py ===
class Pair:
""" Пара ключ-значение."""
def __init__(self, key: int, val: str):
self.key = key
self.val = val
class ArrayHashMap:
""" Хеш-таблица на основе массива."""
150
Хеш-таблицы
def __init__(self):
""" Конструктор."""
# Инициализация массива, содержащего 100 корзин.
self.buckets: list[Pair | None] = [None] * 100
def hash_func(self, key: int) -> int:
""" Хеш-функция."""
index = key % 100
return index
def get(self, key: int) -> str:
""" Операция поиска."""
index: int = self.hash_func(key)
pair: Pair = self.buckets[index]
if pair is None:
return None
return pair.val
def put(self, key: int, val: str):
""" Операция добавления."""
pair = Pair(key, val)
index: int = self.hash_func(key)
self.buckets[index] = pair
def remove(self, key: int):
""" Операция удаления."""
index: int = self.hash_func(key)
# Установка в None означает удаление.
self.buckets[index] = None
def entry_set(self) -> list[Pair]:
""" Получение всех пар ключ-значение."""
result: list[Pair] = []
for pair in self.buckets:
if pair is not None:
result.append(pair)
return result
def key_set(self) -> list[int]:
""" Получение всех ключей."""
result = []
for pair in self.buckets:
if pair is not None:
result.append(pair.key)
return result
def value_set(self) -> list[str]:
""" Получение всех значений."""
result = []
6.1. Хеш-таблицы 151
for pair in self.buckets:
if pair is not None:
result.append(pair.val)
return result
def print(self):
""" Печать хеш-таблицы."""
for pair in self.buckets:
if pair is not None:
print(pair.key, "->", pair.val)
6.1.3. Хеш-коллизии и расширение
По своей сути хеш-функция выполняет отображение входного пространства,
состоящего из всех ключей, в выходное пространство, состоящее из всех индексов массива. Причем входное пространство зачастую значительно больше
выходного. Следовательно, теоретически неизбежна ситуация, когда несколько входов соответствуют одному выходу.
Для хеш-функции в примере выше, если последние две цифры ключа совпадают, результат хеш-функции также совпадает. Например, при запросе студентов с номерами 12836 и 20336 мы получим:
12836 % 100 = 36
20336 % 100 = 36.
Оба номера указывают на одно и то же имя, что очевидно неверно, см.
рис. 6.3. Такую ситуацию, когда несколько входов соответствуют одному выходу, называют хеш-коллизией.
Ввод key
Индекс
Массив
Вывод
value
Хеш-коллизия
"Иван"
"Петр"
Хеш-функция
"Яков"
"Ира"
"Аня"
(Каждая ячейка хранит
одну пару ключ–значение)
Рис. 6.3. Пример хеш-коллизии
"Иван"
"Иван"
152
Хеш-таблицы
Логично предположить, что чем больше емкость хеш-таблицы n, тем ниже
вероятность распределения нескольких ключей в одну корзину и тем меньше
коллизий. Поэтому можно уменьшить количество хеш-коллизий, увеличивая емкость хеш-таблицы.
Как показано на рис. 6.4, до увеличения емкости пары ключ–значение (136, A)
и (236, D) попадали в одну корзину, а после увеличения емкости коллизия исчезла.
Емкость хеш-таблицы = 100
Хеш-функция key % 100
Увеличение
хеш-таблицы
в 2 раза
Индекс
Емкость хеш-таблицы = 200
Хеш-функция key % 200
Индекс
Хеш-коллизия
Рис. 6.4. Увеличение емкости хеш-таблицы
Подобно увеличению емкости массива, увеличение емкости хеш-таблицы
требует переноса всех пар ключ–значение из старой хеш-таблицы в новую,
что является очень затратной по времени операцией. Кроме того, поскольку
емкость хеш-таблицы изменяется, необходимо заново вычислять местоположение хранения всех пар ключ–значение с помощью хеш-функции, что еще
больше увеличивает вычислительные затраты процесса расширения. Поэтому
в языках программирования обычно резервируется достаточно большая емкость хеш-таблицы, чтобы избежать частого увеличения.
Коэффициент заполнения является важным понятием для хеш-таблицы.
Он определяется как количество элементов в хеш-таблице, деленное на количество корзин, и используется для оценки степени серьезности хеш-коллизий,
а также часто служит условием для увеличения емкости хеш-таблицы.
Например, в Java, когда коэффициент заполнения превышает 0.75, система
увеличивает емкость хеш-таблицы в 2 раза.
6.2. Хеш-коллизии
Как упоминалось в предыдущем разделе, в обычных условиях входное пространство хеш-функции значительно больше выходного пространства,
6.2. Хеш-коллизии 153
поэтому хеш-коллизии теоретически неизбежны. Например, если входное
пространство состоит из всех целых чисел, а выходное пространство соответствует размеру массива, то обязательно несколько целых чисел будут отображаться в один и тот же индекс корзины.
Хеш-коллизии могут привести к ошибкам в результатах запросов, серьезно
влияя на работоспособность хеш-таблицы. Чтобы решить эту проблему, при
возникновении хеш-коллизий выполняется увеличение емкости хеш-таблицы
до тех пор, пока коллизии не исчезнут. Этот метод понятен и прост в реализации, но крайне неэффективен, поскольку увеличение емкости хеш-таблицы
требует значительных затрат на перенос данных и вычисление хеш-значений.
Для повышения эффективности можно использовать следующие стратегии:
1) улучшение структуры данных хеш-таблицы, чтобы она могла нормально функционировать при возникновении хеш-коллизий;
2) выполнение увеличения емкости только при необходимости, т. е. когда
хеш-коллизии становятся достаточно серьезными.
Основные методы улучшения структуры хеш-таблицы включают цепную
адресацию и открытую адресацию.
6.2.1. Цепная адресация
В исходной хеш-таблице каждая корзина может хранить только одну пару
ключ–значение. Цепная адресация преобразует отдельный элемент в связный
список, где пары ключ–значение выступают в качестве узлов списка, и все
пары ключ–значение, вызвавшие коллизии, хранятся в одном и том же списке.
На рис. 6.5 представлен пример хеш-таблицы с цепной адресацией.
Индекс
Массив
Связный список (узлы представляют
собой пары ключ–значение)
Цепная адресация
Каждая ячейка
содержит связный
список со всеми
коллизиями
Хеш-функция key % 100
Ключи с одинаковыми
двумя последними
цифрами вызывают
хеш-коллизию
Рис. 6.5. Хеш-таблица с цепной адресацией
Методы работы с хеш-таблицей, реализованной на основе цепной адресации, изменяются следующим образом.
154
Хеш-таблицы
Поиск элемента: вводится ключ, с помощью хеш-функции определяется индекс корзины, после чего осуществляется доступ к головному узлу
списка. Затем выполняется обход списка и сравнение ключей для поиска
целевой пары ключ–значение.
Добавление элемента: сначала с помощью хеш-функции осуществляется доступ к головному узлу списка, затем узел (пара ключ–значение)
добавляется в список.
Удаление элемента: на основе результата хеш-функции осуществляется
доступ к головному узлу списка, затем выполняется обход списка для поиска целевого узла и его удаления.
Цепная адресация имеет следующие ограничения.
Увеличение занимаемого пространства: связный список содержит
указатели на узлы, что требует больше памяти по сравнению с массивом.
Снижение эффективности поиска: необходимо линейно обходить
связный список для нахождения соответствующего элемента.
Ниже приведен простой пример реализации хеш-таблицы с цепной адресацией. Следует обратить внимание на следующие моменты.
Использование списка (динамического массива) вместо связного списка
для упрощения кода. В данной конфигурации хеш-таблица (массив) содержит несколько корзин, каждая из которых является списком.
В данной реализации предусмотрен метод расширения хеш-таблицы.
Когда коэффициент заполнения превышает 2/3, хеш-таблица расширяется в 2 раза.
# === File: hash_map_chaining.py ===
class HashMapChaining:
""" Хеш-таблица с цепной адресацией."""
def __init__(self):
""" Конструктор."""
self.size = 0 # Количество пар ключ-значение.
self.capacity = 4 # Вместимость хеш-таблицы.
self.load_thres = 2.0 / 3.0 # Порог коэффициента заполнения для расширения.
self.extend_ratio = 2 # Коэффициент расширения.
self.buckets = [[] for _ in range(self.capacity)] # Массив корзин.
def hash_func(self, key: int) -> int:
""" Хеш-функция."""
return key % self.capacity
def load_factor(self) -> float:
""" Коэффициент заполнения."""
return self.size / self.capacity
def get(self, key: int) -> str | None:
""" Операция поиска."""
6.2. Хеш-коллизии 155
index = self.hash_func(key)
bucket = self.buckets[index]
# Обход корзины, если ключ найден, возвращается соответствующее значение.
for pair in bucket:
if pair.key == key:
return pair.val
# Если ключ не найден, возвращается None.
return None
def put(self, key: int, val: str):
""" Операция добавления."""
# При превышении коэффициента заполнения выполняется расширение.
if self.load_factor() > self.load_thres:
self.extend()
index = self.hash_func(key)
bucket = self.buckets[index]
# Обход корзины; если ключ найден, значение обновляется
# и выполняется выход из функции.
for pair in bucket:
if pair.key == key:
pair.val = val
return
# Если ключ отсутствует, пара ключ-значение добавляется в конец.
pair = Pair(key, val)
bucket.append(pair)
self.size += 1
def remove(self, key: int):
""" Операция удаления."""
index = self.hash_func(key)
bucket = self.buckets[index]
# Обход корзины, удаление пары ключ-значение.
for pair in bucket:
if pair.key == key:
bucket.remove(pair)
self.size -= 1
break
def extend(self):
""" Расширение хеш-таблицы."""
# Сохранение исходной хеш-таблицы.
buckets = self.buckets
# Инициализация новой расширенной хеш-таблицы.
self.capacity *= self.extend_ratio
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
# Перенос пар ключ-значение из исходной хеш-таблицы в новую.
for bucket in buckets:
for pair in bucket:
self.put(pair.key, pair.val)
156
Хеш-таблицы
def print(self):
""" Печать хеш-таблицы."""
for bucket in self.buckets:
res = []
for pair in bucket:
res.append(str(pair.key) + " -> " + pair.val)
print(res)
Стоит отметить, что в длинных списках эффективность поиска O(n) весьма
низка. В этом случае можно преобразовать список в АВЛ-дерево или красно-черное дерево, чтобы оптимизировать временную сложность операции
поиска до O(log n).
6.2.2. Открытая адресация
Открытая адресация не вводит дополнительные структуры данных, а использует многократное пробирование для обработки хеш-конфликтов. Основные
методы пробирования включают линейное пробирование, квадратичное пробирование и двойное хеширование.
Далее на примере линейного зондирования рассмотрим механизм работы
хеш-таблицы с открытой адресацией.
1. Линейное зондирование
При линейном зондировании используется линейный поиск с фиксированным шагом. Метод зондирования отличается от обычной хеш-таблицы.
Вставка элемента: с помощью хеш-функции вычисляется индекс корзины. Если корзина уже занята, начинается линейный обход от места
конфликта (обычно с шагом 1) до нахождения пустой корзины, куда
и вставляется элемент.
Поиск элемента: при обнаружении хеш-конфликта используется тот же
шаг для линейного обхода до нахождения соответствующего элемента, после
чего возвращается его значение. Если встречается пустая корзина, это означает, что целевой элемент отсутствует в хеш-таблице, возвращается None.
На рис. 6.6 демонстрируется распределение пар ключ–значение в хештаблице с открытой адресацией (линейное зондирование). Согласно этой хешфункции ключи с одинаковыми последними двумя цифрами отображаются
в одну корзину. Однако благодаря линейному зондированию они последовательно размещаются в этой и следующих корзинах.
Однако линейное зондирование подвержено так называемой кластеризации. Чем длиннее последовательность занятых позиций в массиве, тем
выше вероятность хеш-конфликта в этих позициях. Это способствует дальнейшему росту кластера, создавая порочный круг и в конечном итоге снижая эффективность операций добавления, удаления и поиска.
Стоит отметить, что в хеш-таблице с открытой адресацией нельзя напрямую удалять элементы. Это связано с тем, что удаление элемента создает в массиве пустую корзину None. И затем при поиске элемента линейное
зондирование, достигнув этой пустой корзины, закончит поиск, что приведет
к невозможности доступа к элементам ниже этой корзины. Программа может
ошибочно посчитать, что эти элементы отсутствуют, как показано на рис. 6.7.
6.2. Хеш-коллизии 157
Индекс
Массив
Открытая адресация
Линейное зондирование с шагом 1
Хеш-функция key % 100
Ключи с одинаковыми двумя
последними цифрами вызывают
хеш-коллизию
Рис. 6.6. Распределение пар ключ–значение в хеш-таблице с открытой адресацией
(линейное зондирование)
Индекс
Массив
После удаления элемента
будет невозможно найти элементы ниже него
Рис. 6.7. Проблемы поиска, вызванные удалением элементов при открытой адресации
Для решения этой проблемы можно использовать механизм ленивого
удаления: элементы не удаляются из хеш-таблицы напрямую, вместо этого для пометки этой корзины используется константа TOMBSTONE. При таком подходе None и TOMBSTONE обозначают пустую корзину, в которую можно
поместить пару ключ–значение. Однако при линейном зондировании значение TOMBSTONE не завершает обход, так как ниже все еще могут находиться
пары ключ–значение.
158
Хеш-таблицы
Однако ленивое удаление может ускорить падение производительности хеш-таблицы. Это происходит потому, что каждая операция удаления
оставляет метку удаления, и с увеличением количества значений TOMBSTONE
время поиска также увеличивается, поскольку при линейном зондировании
может потребоваться пропуск нескольких TOMBSTONE.
В качестве решения предлагается при линейном зондировании фиксировать индекс первого встретившегося TOMBSTONE и менять местами найденный
целевой элемент с этим TOMBSTONE. Преимущество такого подхода заключается в том, что при каждом запросе или добавлении элемента он перемещается
в корзину, находящуюся ближе к идеальному положению (начальной точке поиска), что оптимизирует эффективность запросов.
В следующем коде реализована хеш-таблица с открытой адресацией (линейным зондированием), включающей ленивое удаление. Для более полного
использования пространства хеш-таблицы она рассматривается как кольцевой массив, и при переходе за конец массива происходит возврат к началу.
# === File: hash_map_open_addressing.py ===
class HashMapOpenAddressing:
""" Открытая адресация хеш-таблицы."""
def __init__(self):
""" Конструктор."""
self.size = 0 # Количество пар ключ-значение.
self.capacity = 4 # Вместимость хеш-таблицы.
self.load_thres = 2.0 / 3.0 # Порог коэффициента заполнения для расширения.
self.extend_ratio = 2 # Коэффициент расширения.
self.buckets: list[Pair | None] = [None] * self.capacity # Массив корзин.
self.TOMBSTONE = Pair(-1, "-1") # Метка удаления.
def hash_func(self, key: int) -> int:
""" Хеш-функция."""
return key % self.capacity
def load_factor(self) -> float:
""" Коэффициент заполнения."""
return self.size / self.capacity
def find_bucket(self, key: int) -> int:
""" Поиск индекса корзины по ключу."""
index = self.hash_func(key)
first_tombstone = -1
# Линейное зондирование, выход при встрече пустой корзины.
while self.buckets[index] is not None:
# Если найден ключ, возвращается соответствующий индекс корзины.
if self.buckets[index].key == key:
# Если ранее была метка удаления, перемещение пары
6.2. Хеш-коллизии 159
# ключ-значение в этот индекс.
if first_tombstone != -1:
self.buckets[first_tombstone] = self.buckets[index]
self.buckets[index] = self.TOMBSTONE
return first_tombstone # Возврат индекса перемещенной корзины.
return index # Возврат индекса корзины.
# Фиксация первого встретившегося удаления.
if first_tombstone == -1 and self.buckets[index] is self.TOMBSTONE:
first_tombstone = index
# Вычисление индекса корзины, при переходе за конец
# возвращение к началу.
index = (index + 1) % self.capacity
# Если ключ не существует, возвращается индекс для добавления.
return index if first_tombstone == -1 else first_tombstone
def get(self, key: int) -> str:
""" Операция запроса."""
# Поиск индекса корзины по ключу.
index = self.find_bucket(key)
# Если найдена пара ключ-значение, возвращается соответствующее значение.
if self.buckets[index] not in [None, self.TOMBSTONE]:
return self.buckets[index].val
# Если пара ключ-значение не существует, возвращается None.
return None
def put(self, key: int, val: str):
""" Операция добавления."""
# При превышении порога коэффициента заполнения выполняется расширение.
if self.load_factor() > self.load_thres:
self.extend()
# Поиск индекса корзины по ключу.
index = self.find_bucket(key)
# Если найдена пара ключ-значение, значение перезаписывается и возвращается.
if self.buckets[index] not in [None, self.TOMBSTONE]:
self.buckets[index].val = val
return
# Если пара ключ-значение не существует, добавляется новая пара.
self.buckets[index] = Pair(key, val)
self.size += 1
def remove(self, key: int):
""" Операция удаления."""
# Поиск индекса корзины по ключу.
index = self.find_bucket(key)
# Если найдена пара ключ-значение, она заменяется меткой удаления.
if self.buckets[index] not in [None, self.TOMBSTONE]:
self.buckets[index] = self.TOMBSTONE
self.size -= 1
160
Хеш-таблицы
def extend(self):
""" Расширение хеш-таблицы."""
# Временное сохранение оригинальной хеш-таблицы.
buckets_tmp = self.buckets
# Инициализация новой хеш-таблицы после расширения.
self.capacity *= self.extend_ratio
self.buckets = [None] * self.capacity
self.size = 0
# Перенос пар ключ-значение из оригинальной хеш-таблицы в новую.
for pair in buckets_tmp:
if pair not in [None, self.TOMBSTONE]:
self.put(pair.key, pair.val)
def print(self):
""" Печать хеш-таблицы."""
for pair in self.buckets:
if pair is None:
print("None")
elif pair is self.TOMBSTONE:
print("TOMBSTONE")
else:
print(pair.key, "->", pair.val)
2. Квадратичное зондирование
Квадратичное зондирование, как и линейное, является одной из распространенных стратегий открытой адресации. При возникновении конфликта квадратичное зондирование пропускает не просто фиксированное количество
шагов, а количество шагов, равное квадрату числа попыток, т. е. 1, 4, 9, ...
Квадратичное зондирование обладает следующими преимуществами:
квадратичное зондирование, пропуская расстояние, равное квадрату
числа попыток, стремится сгладить эффект кластеризации линейного
зондирования;
квадратичное зондирование пропускает большее расстояние в поисках
пустого места, что способствует более равномерному распределению
данных.
Тем не менее квадратичный поиск не является идеальным и обладает следующими недостатками:
все еще существует эффект кластеризации, т. е. некоторые позиции занимаются более вероятно, чем другие;
из-за быстрого роста квадрата квадратичное зондирование может не охватить всю хеш-таблицу. То есть даже при наличии в хеш-таблице пустых
корзин квадратичное зондирование может никогда не добраться до них.
3. Множественное хеширование
Как следует из названия, метод множественного хеширования использует для
поиска несколько хеш-функций f1(x), f2(x), f3(x), ...
6.3. Алгоритмы хеширования 161
Вставка элемента: если хеш-функция f1(x) вызывает конфликт, вычисляется f2(x) и т. д., пока не будет найдено пустое место для вставки элемента.
Поиск элемента: поиск выполняется в том же порядке хеш-функций,
пока не будет найден целевой элемент. Если встречается пустое место
или были испробованы все хеш-функции, это означает, что элемента
в хеш-таблице нет, и возвращается None.
По сравнению с линейным зондированием метод множественного хеширования менее склонен к кластеризации, но использование нескольких хешфункций увеличивает вычислительную нагрузку.
Совет
Обратите внимание, что хеш-таблицы с открытой адресацией (линейный поиск, квадратичный поиск и множественное хеширование) имеют проблему
«нельзя напрямую удалять элементы».
6.2.3. Выбор языка программирования
Различные языки программирования используют разные стратегии реализации хеш-таблиц, ниже приведено несколько примеров.
В Python используется метод открытой адресации. В словарях dict для
поиска применяется псевдослучайное число.
В Java используется цепная адресация. Начиная с JDK 1.8, когда длина массива в HashMap достигает 64, а длина цепочки достигает 8, цепочка преобразуется в красно-черное дерево для повышения эффективности поиска.
В Go используется цепная адресация. Здесь предусмотрено, что в каждой
корзине может храниться не более 8 пар ключ–значение. При превышении емкости подключается дополнительная корзина. При избыточном
количестве дополнительных корзин выполняется специальная операция расширения для поддержания производительности.
6.3. Алгоритмы хеширования
В предыдущих разделах были рассмотрены принципы работы хеш-таблиц
и методы обработки хеш-конфликтов. Однако ни открытая, ни цепная адресация не могут уменьшить вероятность возникновения хеш-конфликтов,
они лишь обеспечивают корректную работу хеш-таблицы при их возникновении.
Если хеш-конфликты происходят слишком часто, производительность хештаблицы резко снижается. Как показано на рис. 6.8, для хеш-таблицы с цепной
адресацией в идеальном случае пары ключ–значение равномерно распределены по всем корзинам, что обеспечивает наилучшую эффективность поиска.
В худшем случае все пары ключ–значение хранятся в одной корзине, и временная сложность повышается до O(n).
162
Хеш-таблицы
Хеш-таблица с цепной адресацией
Лучший случай:
Пары ключ–значение равномерно распределены
по всем ячейкам
Временная сложность O(1)
Худший случай:
Все пары ключ–значение находятся в одной ячейке
Временная сложность O(n)
Рис. 6.8. Лучший и худший случаи хеш-конфликтов
Распределение пар ключ–значение определяется хеш-функцией.
Вспомним этапы вычисления хеш-функции: сначала вычисляется хешзначение, затем берется остаток от деления на длину массива.
index = hash(key) % capacity
Из этого выражения видно, что при фиксированной емкости хеш-таблицы
capacity алгоритм хеширования hash() определяет выходное значение,
которое, в свою очередь, определяет распределение пар ключ–значение
в хеш-таблице.
Это означает, что для снижения вероятности возникновения хеш-конфликтов
следует сосредоточиться на разработке алгоритма хеширования hash().
6.3.1. Цели алгоритма хеширования
Для создания быстрой и надежной структуры данных хеш-таблицы алгоритм
хеширования должен обладать следующими характеристиками.
Детерминированность: для одинакового ввода алгоритм хеширования
должен всегда давать одинаковый вывод. Это необходимо для обеспечения надежности работы хеш-таблицы.
Высокая эффективность: процесс вычисления хеш-значения должен
быть достаточно быстрым. Чем меньше вычислительные затраты, тем
выше практическая ценность хеш-таблицы.
Равномерное распределение: алгоритм хеширования должен обеспечивать равномерное распределение пар ключ–значение в хештаблице. Чем равномернее распределение, тем ниже вероятность хешконфликтов.
6.3. Алгоритмы хеширования 163
На практике алгоритмы хеширования применяются не только для реализации хеш-таблиц, но и в других областях.
Хранение паролей: для защиты паролей пользователей система обычно не хранит пароли в открытом виде, а сохраняет их хеш-значения.
Когда пользователь вводит пароль, система вычисляет его хеш-значение
и сравнивает с сохраненным. Если они совпадают, пароль считается
правильным.
Проверка целостности данных: отправитель данных может вычислить хеш-значение данных и отправить его вместе с данными. Получатель может заново вычислить хеш-значение полученных данных
и сравнить его с полученным. Если они совпадают, данные считаются
неизмененными.
В криптографических приложениях для предотвращения обратного вычисления исходного пароля из хеш-значения и других видов обратной инженерии
алгоритм хеширования должен обладать дополнительными характеристиками.
Необратимость: невозможность извлечь какую-либо информацию
о входных данных из хеш-значения.
Устойчивость к коллизиям: должно быть крайне сложно найти два различных входа, дающих одинаковое хеш-значение.
Эффект лавины: небольшие изменения на входе должны приводить
к значительным и непредсказуемым изменениям на выходе.
Следует отметить, что «равномерное распределение» и «устойчивость
к коллизиям» – это два независимых понятия, и выполнение одного из
них не обязательно означает выполнение другого. Например, хеш-функция
key % 100 при случайном вводе значения key может давать равномерное
распределение. Однако этот алгоритм хеширования слишком прост, и все
ключи с одинаковыми последними двумя цифрами будут иметь одинаковый вывод, что позволяет легко извлечь пригодные ключи из хеш-значения
и взломать пароль.
6.3.2. Разработка алгоритма хеширования
Создание хеш-алгоритмов представляет собой сложную задачу, требующую
учета множества факторов. Однако для некоторых несложных сценариев можно разработать простые хеш-алгоритмы.
Аддитивный хеш: складываются ASCII-коды каждого символа входных
данных, полученная сумма используется в качестве хеш-значения.
Мультипликативный хеш: используя свойство некоррелированности
умножения, на каждом шаге значение хеша умножается на константу,
и в результат добавляется ASCII-код очередного символа.
Хеш с использованием операции XOR: каждый элемент входных данных накапливается в хеш-значении с помощью операции XOR.
Ротационный хеш: ASCII-коды каждого символа накапливаются в хешзначении, при этом перед каждым накоплением выполняется операция
ротации хеш-значения.
164
Хеш-таблицы
# === File: simple_hash.py ===
def add_hash(key: str) -> int:
""" Аддитивный хеш."""
hash = 0
modulus = 1000000007
for c in key:
hash += ord(c)
return hash % modulus
def mul_hash(key: str) -> int:
""" Мультипликативный хеш."""
hash = 0
modulus = 1000000007
for c in key:
hash = 31 * hash + ord(c)
return hash % modulus
def xor_hash(key: str) -> int:
""" Хеш с использованием операции XOR."""
hash = 0
modulus = 1000000007
for c in key:
hash ^= ord(c)
return hash % modulus
def rot_hash(key: str) -> int:
""" Ротационный хеш."""
hash = 0
modulus = 1000000007
for c in key:
hash = (hash << 4) ^ (hash >> 28) ^ ord(c)
return hash % modulus
Можно заметить, что последним шагом в каждом из хеш-алгоритмов является взятие остатка от деления на большое простое число 1000000007, чтобы
гарантировать, что хеш-значение находится в допустимом диапазоне. Интересно, почему акцент делается на взятии остатка от деления именно на простое число, и какие недостатки могут быть при делении на составное число?
Ответ: использование большого простого числа в качестве модуля
позволяет обеспечить максимально равномерное распределение хешзначений. Поскольку простое число не имеет общих делителей с другими
числами, это позволяет уменьшить периодические закономерности, возникающие из-за операции взятия остатка, и избежать хеш-конфликтов.
Например, если выбрать в качестве модуля составное число 9, которое делится на 3, то все ключи, делящиеся на 3, будут отображаться в хешзначения 0, 3 и 6:
6.3. Алгоритмы хеширования 165
modulus = 9
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, ... }
hash = {0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6, ... }.
Если входные ключи имеют такую арифметическую прогрессию, то хешзначения будут сгруппированы, что умножит хеш-конфликты. Теперь если заменить modulus на простое число 13, то, поскольку между ключами и модулем
нет общих делителей, равномерность распределения хеш-значений значительно улучшится:
modulus = 13
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, ... }
hash = {0, 3, 6, 9, 12, 2, 5, 8, 11, 1, 4, 7, ... }.
Следует отметить, что если ключи распределены случайно и равномерно, то
выбор простого или составного числа в качестве модуля не имеет значения –
оба варианта обеспечат равномерное распределение хеш-значений. Однако
при наличии периодичности в распределении ключей использование составного числа в качестве модуля может привести к кластеризации.
В общем случае выбирается простое число в качестве модуля, и это простое
число должно быть достаточно большим, чтобы максимально устранить периодические закономерности и повысить устойчивость хеш-алгоритма.
6.3.3. Распространенные хеш-алгоритмы
Нетрудно заметить, что описанные выше простые хеш-алгоритмы довольно
хрупкие и далеки от достижения целей создания хеш-алгоритмов. Например,
сложение и операция XOR удовлетворяют коммутативному закону, поэтому
соответствующие хеш-алгоритмы не различают строки с одинаковым содержанием, но разным порядком символов, что может усилить хеш-конфликты
и вызвать некоторые проблемы с безопасностью.
На практике обычно используются стандартные хеш-алгоритмы, такие как
MD5, SHA-1, SHA-2 и SHA-3. Они могут отображать входные данные произвольной длины в хеш-значения фиксированной длины.
На протяжении почти ста лет хеш-алгоритмы постоянно обновляются и оптимизируются. Одни исследователи стремятся повысить производительность,
другие исследователи и хакеры сосредоточены на поиске проблем с безопасностью. В табл. 6.2 представлены распространенные хеш-алгоритмы, используемые в реальных приложениях.
В MD5 и SHA-1 были обнаружены многочисленные уязвимости, поэтому
они не используются в сценариях, в которых требуется высокий уровень
безопасности.
SHA-256 из серии SHA-2 является одним из самых безопасных хешалгоритмов, до сих пор не было обнаружено ни одной уязвимости, поэтому он часто используется в различных приложениях и протоколах
безопасности.
166
Хеш-таблицы
SHA-3 имеет меньшие затраты на реализацию и более высокую вычислительную эффективность по сравнению с SHA-2, но в настоящее время
его использование не так широко распространено, как серии SHA-2.
Таблица 6.2. Распространенные хеш-алгоритмы
MD5
SHA-1
SHA-2
SHA-3
Год появления
1992
1995
2002
2008
Длина вывода
128 бит
160 бит
256/512 бит
224/256/384/512 бит
Хеш-конфликты
Много
Много
Мало
Мало
Уровень безопасности
Низкий, есть известные уязвимости
Низкий, есть
известные
уязвимости
Высокий
Высокий
Применение
Устарел, но еще
используется для
проверки целостности данных
Устарел
Проверка
транзакций
в криптовалюте,
цифровые
подписи и т. д.
Может использоваться в качестве замены
SHA-2
6.3.4. Хеш-значения для структур данных
Ключи в хеш-таблице могут быть представлены в виде целых чисел, дробей
или строк. Языки программирования обычно предоставляют встроенные хешалгоритмы для своих типов данных, чтобы вычислять индексы корзин в хештаблице. Например, в Python можно вызвать функцию hash() для вычисления
хеш-значений для различных типов данных.
Хеш-значение целых чисел и булевых величин совпадает с их значением.
Хеш-значение дробных чисел и строк вычисляется по более сложному алгоритму, заинтересованные читатели могут изучить его самостоятельно.
Хеш-значение кортежа получается путем хеширования каждого элемента и объединения этих хеш-значений в одно.
Хеш-значение объекта генерируется на основе его адреса в памяти. Путем переопределения метода хеширования объекта можно реализовать
генерацию хеша на основе его содержимого.
Совет
Обратите внимание, что в разных языках программирования встроенные
функции вычисления хеш-значений могут отличаться своим определением
и методом вычисления.
# === File: built_in_hash.py ===
num = 3
hash_num = hash(num)
# Хеш-значение целого числа 3 равно 3.
6.4. Резюме 167
bol = True
hash_bol = hash(bol)
# Хеш-значение булевой величины True равно 1.
dec = 3.14159
hash_dec = hash(dec)
# Хеш-значение дробного числа 3.14159 равно 326484311674566659.
str = "Hello 算法"
hash_str = hash(str)
# Хеш-значение строки "Hello 算法" равно 4617003410720528961.
tup = (12836, " 小哈")
hash_tup = hash(tup)
# Хеш-значение кортежа (12836, '小哈') равно 1029005403108185979.
obj = ListNode(0)
hash_obj = hash(obj)
# Хеш-значение объекта <ListNode object at 0x1058fd810> равно 274267521.
Во многих языках программирования только неизменяемые объекты
могут использоваться в качестве ключей в хеш-таблице. Если список (динамический массив) используется в качестве ключа, то при изменении его содержимого хеш-значение также изменится, и мы не сможем найти исходное
значение.
Хотя переменные-члены пользовательских объектов (например, узлов
связного списка) могут быть изменяемыми, сами объекты можно хешировать. Это связано с тем, что хеш-значение объекта обычно генерируется на основе его адреса в памяти, и даже если содержимое объекта изменяется, адрес остается неизменным, а значит, и хеш-значение также остается прежним.
Возможно, вы заметили, что при запуске программы в разных окнах выводимые хеш-значения отличаются. Это связано с тем, что интерпретатор Python при каждом запуске добавляет случайное значение «соли» к функции хеширования строк. Такой подход эффективно предотвращает атаки
типа HashDoS и повышает безопасность хеш-алгоритма.
6.4. Резюме
1. Ключевые моменты
При вводе ключа key хеш-таблица может найти значение value за время
O(1), что очень эффективно.
Основные операции с хеш-таблицами включают поиск, добавление пар
ключ–значение, удаление пар и обход хеш-таблицы.
Хеш-функция отображает ключ в индекс массива, что позволяет получить доступ к соответствующей корзине и извлечь значение.
168
Хеш-таблицы
Два разных ключа могут после хеширования получить одинаковый индекс массива, что приводит к ошибке в результате поиска. Это явление
называется хеш-конфликтом.
Чем больше емкость хеш-таблицы, тем ниже вероятность хешконфликтов. Поэтому расширение хеш-таблицы может уменьшить количество конфликтов, но, как и в случае с массивами, расширение хештаблицы требует значительных затрат.
Коэффициент заполнения определяется как отношение числа элементов в хеш-таблице к числу корзин и отражает степень серьезности хешконфликтов. Он часто используется как условие для расширения хештаблицы.
Метод цепной адресации преобразует отдельные элементы в связный список, храня все конфликтующие элементы в одном списке. Однако слишком
длинные списки снижают эффективность поиска, поэтому для повышения
эффективности можно преобразовать списки в красно-черные деревья.
Метод открытой адресации решает хеш-конфликты с помощью многократного поиска (зондирования). Линейное зондирование использует фиксированный шаг, но его элементы нельзя удалять, и он склонен к кластеризации. Множественное хеширование использует несколько хеш-функций
для поиска, что снижает вероятность кластеризации по сравнению с линейным зондированием, но увеличивает вычислительную нагрузку.
Разные языки программирования реализуют хеш-таблицы по-разному.
Например, класс HashMap в Java использует цепную адресацию, тогда как
Dict в Python применяет метод открытой адресации.
В хеш-таблицах мы стремимся, чтобы хеш-алгоритмы обладали детерминированностью, высокой эффективностью и равномерным распределением. В криптографии хеш-алгоритмы также должны обладать устойчивостью к коллизиям и эффектом лавины.
Хеш-алгоритмы обычно используют большие простые числа в качестве
модуля, чтобы гарантировать максимально равномерное распределение
хеш-значений и уменьшить количество хеш-коллизий.
К распространенным хеш-алгоритмам относятся MD5, SHA-1, SHA-2
и SHA-3. MD5 часто используется для проверки целостности файлов,
а SHA-2 – для приложений и протоколов безопасности.
Языки программирования обычно предоставляют встроенные хешалгоритмы для типов данных, которые используются для вычисления
индексов корзин в хеш-таблицах. Как правило, хешировать можно только неизменяемые объекты.
2. Вопросы и ответы
Вопрос. В каких случаях временная сложность хеш-таблицы составляет O(n)?
Ответ. Когда хеш-коллизий становится достаточно много, временная сложность хеш-таблицы может ухудшиться до O(n). Если хеш-функция хорошо
спроектирована, установлена разумная емкость, а коллизии распределены
равномерно, временная сложность составляет O(1). При использовании встроенных хеш-таблиц языков программирования обычно предполагается, что
временная сложность составляет O(1).
6.4. Резюме 169
Вопрос. Почему бы не использовать хеш-функцию f(x) = x? Тогда не будет
коллизий.
Ответ. При использовании хеш-функции f(x) = x каждому элементу соответствует уникальный индекс корзины, что эквивалентно массиву. Однако
пространство входных данных обычно значительно больше пространства
выходных данных (длины массива), поэтому последним этапом работы хешфункции часто является взятие остатка от деления на длину массива. Другими словами, цель хеш-таблицы – отобразить большее пространство состояний
в меньшее пространство и обеспечить эффективность запросов O(1).
Вопрос. Почему реализация хеш-таблицы на основе массива, связного списка или двоичного дерева может быть более эффективной?
Ответ. Во-первых, временная эффективность хеш-таблицы увеличивается,
но пространственная эффективность уменьшается. Значительная часть памяти хеш-таблицы остается неиспользованной.
Во-вторых, временная эффективность увеличивается только в определенных сценариях использования. Если функцию можно реализовать с использованием массива или связного списка с той же временной сложностью, то это
обычно быстрее, чем хеш-таблица. Это связано с тем, что вычисление хешфункции требует затрат, а константа временной сложности больше.
Наконец, временная сложность хеш-таблицы может ухудшиться. Например,
при использовании цепной адресации поиск выполняется в связном списке или
красно-черном дереве, что все еще может привести к ухудшению до времени O(n).
Вопрос. Есть ли у метода множественного хеширования недостаток невозможности прямого удаления элементов? Можно ли повторно использовать
пространство, помеченное как удаленное?
Ответ. Множественное хеширование является одной из форм открытой
адресации, и все методы открытой адресации имеют недостаток невозможности прямого удаления элементов, что требует пометки удаленных элементов.
Пространство, помеченное как удаленное, можно использовать повторно. Когда новый элемент вставляется в хеш-таблицу и хеш-функция находит позицию, помеченную как удаленная, эту позиция можно использовать для нового
элемента. Это позволяет сохранить последовательность поиска в хеш-таблице
неизменной и обеспечить эффективность использования пространства.
Вопрос. Почему в линейном зондировании при поиске элемента возникают
хеш-коллизии?
Ответ. При поиске с помощью хеш-функции находится соответствующая
корзина и пара ключ–значение. Если ключ не совпадает, это означает наличие
хеш-коллизии. Поэтому метод линейного зондирования будет последовательно искать с заранее заданным шагом, пока не найдет правильное значение
ключа или не достигнет условия конца поиска.
Вопрос. Почему расширение хеш-таблицы может уменьшить количество
хеш-коллизий?
Ответ. Последним шагом хеш-функции часто является взятие остатка от
деления на длину массива n, чтобы выходное значение попадало в диапазон
индексов массива. После расширения длина массива n изменяется, и соответствующий индекс ключа также может измениться. Несколько ключей, которые
ранее попадали в одну корзину, после расширения могут распределиться по
нескольким корзинам, что снижает количество хеш-коллизий.
Глава 7
Деревья
Абстракция
Высокое дерево, полное жизненной силы, может похвастаться глубокими
корнями, пышной листвой и раскидистыми ветвями.
Оно наглядно демонстрирует мощь разделения данных.
7.1. Двоичные деревья 171
7.1. Двоичные деревья
Двоичное (бинарное) дерево – это нелинейная структура данных, представляющая отношения между предками и потомками и отражающая логику «разделяй и властвуй». Подобно спискам, основным элементом двоичного дерева
является узел, который содержит значение, ссылку на левый дочерний узел
и ссылку на правый дочерний узел.
class TreeNode:
""" Класс узла двоичного дерева."""
def __init__(self, val: int):
self.val: int = val
self.left: TreeNode | None = None
self.right: TreeNode | None = None
# Значение узла.
# Ссылка на левый дочерний узел.
# Ссылка на правый дочерний узел.
Каждый узел имеет две ссылки (указателя), указывающие на левый и правый дочерние узлы. Текущий узел называется родительским для этих двух дочерних узлов. Для заданного узла дерево, образованное его левым дочерним
узлом и всеми его подузлами, называется левым поддеревом. Аналогично
определяется правое поддерево.
Узлы, не имеющие дочерних узлов, называются листьями, все остальные узлы содержат дочерние узлы и непустые поддеревья. Если рассматривать узел 2 на рис. 7.1 как родительский, то его левым и правым дочерними
узлами будут узел 4 и узел 5 соответственно. Левое поддерево – это узел 4 и все
узлы ниже него, а правое поддерево – узел 5 и все узлы ниже него.
Двоичное дерево
Родительский узел
Левый дочерний узел
Левое поддерево
Правый дочерний узел
Правое поддерево
Рис. 7.1. Родительский узел, дочерние узлы, поддеревья
172
Деревья
7.1.1. Основные понятия двоичного дерева
Основные понятия двоичного дерева изображены на рис. 7.2.
Корневой узел: узел, находящийся на верхнем уровне дерева и не имеющий родительского узла.
Листовой узел: узел, не имеющий дочерних узлов, оба его указателя
указывают на None.
Ребро: отрезок, соединяющий два узла, т. е. ссылка (указатель) узла.
Уровень узла: увеличивается сверху вниз, уровень корневого узла равен 1.
Степень узла: количество дочерних узлов узла. В двоичном дереве степень может быть 0, 1 или 2.
Высота двоичного дерева: количество ребер от корневого узла до самого удаленного листового узла.
Глубина узла: количество ребер от корневого узла до данного узла.
Высота узла: количество ребер от самого удаленного листового узла до
данного узла.
Корневой узел
Узел
Глубина = 1
Двоичное дерево
Высота = 2
Узел
Глубина = 2
Листовой узел
Рис. 7.2. Основные понятия двоичного дерева
Совет
Обратите внимание, что обычно высота и глубина определяются как количество пройденных ребер, но в некоторых задачах или учебниках они могут
определяться как количество пройденных узлов. В этом случае к высоте и глубине необходимо прибавлять 1.
7.1. Двоичные деревья 173
7.1.2. Основные операции с двоичными деревьями
1. Инициализация двоичного дерева
Подобно спискам, сначала инициализируются узлы, затем строятся ссылки
(указатели).
# === File: binary_tree.py ===
# Инициализация двоичного дерева.
# Инициализация узлов.
n1 = TreeNode(val=1)
n2 = TreeNode(val=2)
n3 = TreeNode(val=3)
n4 = TreeNode(val=4)
n5 = TreeNode(val=5)
# Построение ссылок (указателей) между узлами.
n1.left = n2
n1.right = n3
n2.left = n4
n2.right = n5
2. Вставка и удаление узлов
Подобно спискам, в двоичном дереве вставку и удаление узлов можно выполнять путем изменения указателей. На рис. 7.3 приведен пример.
Вставка узла
Удаление узла
Рис. 7.3. Вставка и удаление узлов в двоичном дереве
# === File: binary_tree.py ===
# Вставка и удаление узлов.
p = TreeNode(0)
# Вставка узла P между n1 и n2.
174
Деревья
n1.left = p
p.left = n2
# Удаление узла P.
n1.left = n2
Совет
Следует учитывать, что вставка узла может изменить исходную логическую
структуру двоичного дерева, а удаление узла обычно означает удаление этого узла и всех его поддеревьев. Поэтому в двоичном дереве вставка и удаление обычно выполняются в комплексе операций для достижения значимых
результатов.
7.1.3. Основные типы двоичных деревьев
1. Идеальное двоичное дерево
В идеальном двоичном дереве все уровни узлов полностью заполнены, см.
рис. 7.4. В таком дереве степень листовых узлов равна 0, а степень всех остальных узлов равна 2. Если высота дерева равна h, то общее количество узлов равно
2h+1 − 1, что представляет собой стандартное экспоненциальное соотношение,
отражающее явление деления клеток, которое часто встречается в природе.
Совет
Оба английских названия complete binary tree и full binary tree можно перевести на русский как «полное двоичное дерево». Из-за этого может возникать
путаница в понятиях. Поэтому при использовании этих терминов лучше всего уточнять, что конкретно вы имеете ввиду.
Идеальное двоичное дерево
Все уровни дерева полностью
заполнены узлами
Рис. 7.4. Идеальное двоичное дерево
7.1. Двоичные деревья 175
2. Совершенное двоичное дерево
В совершенном двоичном дереве (complete binary tree) заполнены не полностью только узлы на самом нижнем уровне, и они заполняются слева направо,
см. рис. 7.5. Обратите внимание, что идеальное двоичное дерево также является полным.
Совершенное двоичное дерево
Узлы последнего уровня заполняются
слева, а все остальные уровни полностью
заполнены узлами
Рис. 7.5. Совершенное двоичное дерево
3. Полное двоичное дерево
В полном двоичном дереве (full binary tree) все узлы, кроме листовых, имеют
два дочерних узла, см. рис. 7.6.
Полное двоичное дерево
Степень всех узлов равна 0 или 2
Рис. 7.6. Полное двоичное дерево
176
Деревья
4. Сбалансированное двоичное дерево
В сбалансированном двоичном дереве абсолютное значение разности высот
левого и правого поддеревьев любого узла не превышает 1, см. рис. 7.7.
Сбалансированное двоичное дерево
Пусть (высота левого поддерева узла) – (высота
правого поддерева узла) = d.
Тогда для всех узлов в сбалансированном
двоичном дереве выполняется условие |d| ≤ 1
Рис. 7.7. Сбалансированное двоичное дерево
7.1.4. Вырождение двоичного дерева
На рис. 7.8 изображена идеальная и вырожденная структура двоичного дерева.
Когда каждый уровень двоичного дерева полностью заполнен узлами, оно становится идеальным. Если все узлы смещены в одну сторону, двоичное дерево
вырождается в связный список.
Идеальное двоичное дерево является оптимальным случаем, позволяющим в полной мере использовать преимущество подхода «разделяй
и властвуй».
Связный список представляет собой другой крайний случай, когда все операции становятся линейными, а временная сложность деградирует до O(n).
Идеальная структура
Идеальное двоичное дерево
Вырожденная структура
Связный список
Рис. 7.8. Идеальная и вырожденная структуры двоичного дерева
7.2. Обход двоичного дерева 177
Как показано в табл. 7.1, в идеальной и вырожденной структурах двоичного
дерева количество листьев, общее количество узлов и высота достигают максимальных или минимальных значений.
Таблица 7.1. Идеальная и вырожденная структуры двоичного дерева
Идеальное двоичное дерево
Связный список
Количество узлов на уровне i
2i−1
1
Количество листьев в дереве высоты h
2
1
Общее количество узлов в дереве
высоты h
2h+1 − 1
h+1
Высота дерева с n узлами
log2(n + 1) − 1
n−1
h
7.2. Обход двоичного дерева
С физической точки зрения дерево является структурой данных, основанной
на связном списке, поэтому его обход осуществляется последовательным доступом к узлам через указатели. Однако, будучи нелинейной структурой данных, обход дерева сложнее, чем обход связного списка, и требует использования алгоритмов поиска.
Наиболее распространенные методы обхода двоичного дерева включают
обход по уровням, прямой, симметричный и обратный обходы.
7.2.1. Обход по уровням
Обход по уровням осуществляется сверху вниз, выполняется последовательный обход двоичного дерева с посещением узлов на каждом уровне слева направо, как показано на рис. 7.9.
Обход в ширину
Обход по уровням
(узлы посещаются по точкам
)
Рис. 7.9. Обход двоичного дерева по уровням
Обход по уровням по своей сути является обходом в ширину, также называемым поиском в ширину, который характеризуется постепенно расширяющимся кольцом от центра к периферии.
178
Деревья
1. Код реализации
Обход в ширину обычно реализуется с использованием очереди. Очередь следует принципу «первый вошел – первый вышел», а обход в ширину – принципу
«поэтапное продвижение», что делает их концептуально схожими. Ниже приведен код реализации.
# === File: binary_tree_bfs.py ===
def level_order(root: TreeNode | None) -> list[int]:
""" Обход по уровням."""
# Инициализация очереди, добавление корневого узла.
queue: deque[TreeNode] = deque()
queue.append(root)
# Инициализация списка для сохранения последовательности обхода.
res = []
while queue:
node: TreeNode = queue.popleft() # Извлечение из очереди.
res.append(node.val) # Сохранение значения узла.
if node.left is not None:
queue.append(node.left) # Добавление левого дочернего узла в очередь.
if node.right is not None:
queue.append(node.right) # Добавление правого дочернего узла в очередь.
return res
2. Анализ сложности
Временная сложность O(n): каждый узел посещается один раз, что занимает
O(n) времени выполнения, где n – количество узлов.
Пространственная сложность O(n): в худшем случае, т. е. в полном двоичном дереве, до достижения самого нижнего уровня в очереди может находиться одновременно (n + 1)/2 узлов, что занимает O(n) пространства.
7.2.2. Прямой, симметричный и обратный обходы
Прямой, симметричный и обратный обходы относятся к обходам в глубину,
также называемым поиск в глубину, который характеризуется подходом «сначала до конца, затем возврат и продолжение».
На рис. 7.10 демонстрируется принцип работы обхода в глубину для двоичного дерева. Обход в глубину можно представить как обход двоичного
дерева по периметру, при этом на каждом узле встречаются три позиции,
соответствующие прямому, симметричному и обратному обходам.
7.2. Обход двоичного дерева 179
"""Обход двоичного дерева в глубину"""
Собираемся посетить левое поддерево
Обход
в глубину
Левое поддерево посещено, собираемся посетить правое поддерево
Оба поддерева посещены, возврат из функции
Прямой порядок (узлы посещаются по точкам
)
Симметричный порядок (узлы посещаются по точкам
Обратный порядок (узлы посещаются по точкам
)
)
Рис. 7.10. Прямой, симметричный и обратный обходы двоичного дерева
1. Код реализации
Поиск в глубину обычно реализуется на основе рекурсии.
# === File: binary_tree_dfs.py ===
def pre_order(root: TreeNode | None):
""" Прямой обход."""
if root is None:
return
# Приоритет посещения: корневой узел -> левое поддерево -> правое поддерево.
res.append(root.val)
pre_order(root=root.left)
pre_order(root=root.right)
def in_order(root: TreeNode | None):
""" Симметричный обход."""
if root is None:
return
# Приоритет посещения: левое поддерево -> корневой узел -> правое поддерево.
in_order(root=root.left)
res.append(root.val)
in_order(root=root.right)
def post_order(root: TreeNode | None):
""" Обратный обход."""
if root is None:
return
# Приоритет посещения: левое поддерево -> правое поддерево -> корневой узел.
post_order(root=root.left)
post_order(root=root.right)
res.append(root.val)
180
Деревья
Совет
Поиск в глубину можно также реализовать на основе итераций, заинтересованные читатели могут изучить этот подход самостоятельно.
На рис. 7.11 демонстрируется рекурсивный процесс прямого обхода двоичного дерева, который можно разделить на два противоположных этапа: рекурсия и возврат.
1. Рекурсия означает начало нового метода, в процессе которого программа посещает следующий узел.
2. Возврат означает возвращение функции, что указывает на завершение
посещения текущего узла.
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 1
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
Шаг 2
Рис. 7.11. Рекурсивный процесс прямого обхода. Шаги 1–2
)
7.2. Обход двоичного дерева 181
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 3
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 4
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
Шаг 5
Рис. 7.11. Продолжение. Шаги 3–5
)
182
Деревья
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 6
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 7
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
Шаг 8
Рис. 7.11. Продолжение. Шаги 6–8
)
7.2. Обход двоичного дерева 183
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 9
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
)
Шаг 10
Вызов: нисходящая рекурсия
Возврат: восходящая рекурсия
Прямой порядок (узлы посещаются по точкам
Шаг 11
Рис. 7.11. Окончание. Шаги 9–11
)
184
Деревья
2. Анализ сложности
Временная сложность O(n): все узлы посещаются один раз, что занимает O(n)
времени.
Пространственная сложность O(n): в худшем случае, когда дерево вырождается в список, глубина рекурсии достигает n, система занимает O(n) пространства стека.
7.3. Представление двоичного
дерева с помощью массива
При представлении в виде списка единицей хранения двоичного дерева является узел TreeNode, а узлы соединяются между собой указателями. В предыдущем разделе были рассмотрены основные операции с двоичным деревом,
представленным в виде списка.
Можно ли представить двоичное дерево с помощью массива? Ответ положительный.
7.3.1. Представление идеального двоичного дерева
Сначала рассмотрим простой пример. Если дано идеальное двоичное дерево
и все его узлы хранятся в массиве в порядке обхода по уровням, то каждому
узлу соответствует уникальный индекс массива.
На основе свойств обхода по уровням можно вывести формулу соответствия между индексами родительского и дочерних узлов: если индекс узла
равен i, то индекс его левого дочернего узла равен 2i + 1, а правого – 2i + 2.
На рис. 7.12 показаны отношения соответствия между индексами узлов.
Узел
Индекс узла
Текущий индекс узла: i
Формулы
отображения
Индекс левого дочернего узла: 2i+1
Индекс правого дочернего узла: 2i+2
Представление двоичного дерева с помощью массива
Индекс узла
Последовательность
обхода по уровням
Рис. 7.12. Представление идеального двоичного дерева с помощью массива
7.3. Представление двоичного дерева с помощью массива 185
Формула соответствия играет роль, аналогичную ссылкам (указателям) в списке. Имея любой узел в массиве, можно с помощью формулы получить доступ к его левому и правому дочерним узлам.
7.3.2. Представление произвольного двоичного дерева
Идеальное двоичное дерево является частным случаем. Обычно на средних
уровнях двоичного дерева присутствует много пустых значений None. Но последовательность обхода по уровням не содержит этих None, поэтому невозможно по этой последовательности определить количество и расположение
пустых значений. Это означает, что существует множество структур двоичных деревьев, соответствующих данной последовательности обхода
по уровням.
Для такого неидеального двоичного дерева вышеописанный метод представления с помощью массива уже не работает, см. рис. 7.13.
Эта последовательность (массив) не может
однозначно представить данное дерево
Индекс массива
Последовательность
обхода по уровням
Рис. 7.13. Для одной последовательности обхода по уровням существует несколько
возможных вариантов двоичного дерева
Для решения этой проблемы можно явно записать все значения None
в последовательности обхода по уровням. После такой обработки последовательность обхода по уровням уже может однозначно представлять двоичное дерево, как показано на рис. 7.14. Ниже приведен пример кода.
# Представление двоичного дерева с помощью массива.
# Использование None для обозначения пустых мест.
tree = [1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15]
186
Деревья
Эта последовательность (массив) может
однозначно представить данное дерево
Индекс массива
Последовательность
обхода по уровням
Пустая позиция
Рис. 7.14. Представление произвольного двоичного дерева с помощью массива
Стоит отметить, что совершенное двоичное дерево очень удобно представлять с помощью массива. Вспоминая определение совершенного двоичного дерева, None появляются только на самом нижнем уровне и в правой
части, поэтому все значения None обязательно находятся в конце последовательности обхода по уровням.
Это означает, что при использовании массива для представления совершенного двоичного дерева можно опустить хранение всех None, что очень удобно.
На рис. 7.15 приведен пример такого представления.
Последовательность обхода по уровням (массив)
Для полного двоичного дерева
пустые позиции не нужны
Последовательность обхода по уровням (массив)
Рис. 7.15. Представление совершенного двоичного дерева с помощью массива
В коде ниже реализуется двоичное дерево, основанное на представлении
с помощью массива, включая следующие операции.
Для заданного узла получение его значения, левого и правого дочернего
узла, родительского узла.
7.3. Представление двоичного дерева с помощью массива 187
Получение последовательностей обхода в прямом, симметричном, обратном порядке и в порядке обхода по уровням.
# === File: array_binary_tree.py ===
class ArrayBinaryTree:
""" Класс двоичного дерева, представленного с помощью массива."""
def __init__(self, arr: list[int | None]):
""" Конструктор."""
self._tree = list(arr)
def size(self):
""" Вместимость списка."""
return len(self._tree)
def val(self, i: int) -> int | None:
""" Получение значения узла с индексом i."""
# Если индекс выходит за границы, возвращается None,
# обозначающее пустое место.
if i < 0 or i >= self.size():
return None
return self._tree[i]
def left(self, i: int) -> int | None:
""" Получение индекса левого дочернего узла для узла с индексом i."""
return 2 * i + 1
def right(self, i: int) -> int | None:
""" Получение индекса правого дочернего узла для узла с индексом i."""
return 2 * i + 2
def parent(self, i: int) -> int | None:
""" Получение индекса родительского узла для узла с индексом i."""
return (i - 1) // 2
def level_order(self) -> list[int]:
""" Обход по уровням."""
self.res = []
# Прямой обход массива.
for i in range(self.size()):
if self.val(i) is not None:
self.res.append(self.val(i))
return self.res
def dfs(self, i: int, order: str):
""" Обход в глубину."""
if self.val(i) is None:
return
188
Деревья
# Прямой обход.
if order == "pre":
self.res.append(self.val(i))
self.dfs(self.left(i), order)
# Симметричный обход.
if order == "in":
self.res.append(self.val(i))
self.dfs(self.right(i), order)
# Обратный обход.
if order == "post":
self.res.append(self.val(i))
def pre_order(self) -> list[int]:
""" Прямой обход."""
self.res = []
self.dfs(0, order="pre")
return self.res
def in_order(self) -> list[int]:
""" Симметричный обход."""
self.res = []
self.dfs(0, order="in")
return self.res
def post_order(self) -> list[int]:
""" Обратный обход."""
self.res = []
self.dfs(0, order="post")
return self.res
7.3.3. Преимущества и ограничения
Представление двоичного дерева с помощью массива имеет следующие преимущества:
массив хранится в непрерывной области памяти, что хорошо для кеширования. Скорость доступа и обхода достаточно высока;
не требуется хранение указателей, что экономит пространство;
позволяет выполнять произвольный доступ к узлам.
Однако представление с помощью массива имеет и некоторые ограничения.
Хранение в массиве требует непрерывной области памяти, поэтому
не подходит для хранения деревьев с очень большим объемом данных.
Добавление и удаление узлов требует выполнения операций вставки
и удаления в массиве, которые менее эффективны.
Когда в двоичном дереве содержится много значений None, доля данных
узлов в массиве низка, что приводит к низкой эффективности использования пространства.
7.4. Двоичное дерево поиска 189
7.4. Двоичное дерево поиска
Двоичное дерево поиска удовлетворяет следующим условиям, см. рис. 7.16:
1) для корневого узла все значения узлов в левом поддереве < значение
корневого узла < все значения узлов в правом поддереве;
2) левое и правое поддеревья любого узла также являются двоичными деревьями поиска, т. е. удовлетворяют условию 1.
Двоичное дерево поиска
Рис. 7.16. Двоичное дерево поиска
7.4.1. Операции с двоичным деревом поиска
Мы инкапсулируем двоичное дерево поиска в класс BinarySearchTree и объявляем переменную-член root, указывающую на корневой узел дерева.
1. Поиск узла
Для заданного значения целевого узла num можно выполнить поиск, основываясь на свойствах двоичного дерева поиска. Мы объявляем текущий узел cur,
начиная с корневого узла дерева root, и в цикле сравниваем значения узлов
cur.val и num, как показано на рис. 7.17.
Если cur.val < num, значит целевой узел находится в правом поддереве
cur, поэтому выполняется переход cur = cur.right.
Если cur.val > num, значит целевой узел находится в левом поддереве cur,
поэтому выполняется переход cur = cur.left.
Если cur.val = num, значит целевой узел найден, выполняется выход из
цикла и возврат этого узла.
190
Деревья
Поиск узла
в двоичном дереве поиска
Поиск узла
в двоичном дереве поиска
Поиск узла
в двоичном дереве поиска
Шаг 1
Шаг 1
Шаг 2
Шаг 2
Шаг 3
Рис. 7.17. Пример поиска узла в двоичном дереве поиска. Шаги 1–3
7.4. Двоичное дерево поиска 191
Поиск узла
в двоичном дереве поиска
Шаг 4
Рис. 7.17. Окончание. Шаг 4
Операция поиска в двоичном дереве поиска аналогична принципу работы алгоритма двоичного поиска, который исключает половину случаев на каждой итерации. Максимальное количество циклов равно высоте двоичного дерева, и при сбалансированном дереве требуется O(log n) времени. Ниже приведен пример кода.
# === File: binary_search_tree.py ===
def search(self, num: int) -> TreeNode | None:
""" Поиск узла."""
cur = self._root
# Циклический поиск, выход после прохождения листового узла.
while cur is not None:
# Целевой узел в правом поддереве cur.
if cur.val < num:
cur = cur.right
# Целевой узел в левом поддереве cur.
elif cur.val > num:
cur = cur.left
# Найден целевой узел, выход из цикла.
else:
break
return cur
2. Вставка узла
Нужно вставить новый элемент num и при этом сохранить свойство двоичного
дерева поиска: левое поддерево < корневой узел < правое поддерево. Процесс
вставки показан на рис. 7.18.
1. Поиск позиции для вставки: аналогично операции поиска, начиная
с корневого узла, в цикле выполняется поиск вниз по дереву в зависимости от соотношения значений текущего узла и num, пока не будет пройден
листовой узел (достигнуто None), после чего цикл завершается.
192
Деревья
2. Вставка узла в найденную позицию: инициализация узла num и размещение его в позиции None.
Вставка узла
в двоичное дерево поиска
1. Найти позицию cur для вставки узла
2. Вставить узел в эту позицию
Выполнить pre.right =
Рис. 7.18. Вставка узла в двоичное дерево поиска
В коде реализации следует обратить внимание на следующие моменты.
В двоичном дереве поиска не допускается наличие дублирующихся узлов, иначе будет нарушено его определение. Поэтому, если узел, который
нужно вставить, уже существует в дереве, вставка не выполняется, и происходит возврат.
Для выполнения вставки узла необходимо использовать узел pre, чтобы
сохранить узел предыдущей итерации цикла. Таким образом, при достижении None можно получить родительский узел и завершить операцию
вставки узла.
# === File: binary_search_tree.py ===
def insert(self, num: int):
""" Вставка узла."""
# Если дерево пусто, инициализация корневого узла.
if self._root is None:
self._root = TreeNode(num)
return
# Циклический поиск, выход после прохождения листового узла.
cur, pre = self._root, None
while cur is not None:
# Найден дублирующий узел, возврат.
if cur.val == num:
return
pre = cur
7.4. Двоичное дерево поиска 193
# Позиция для вставки в правом поддереве cur.
if cur.val < num:
cur = cur.right
# Позиция для вставки в левом поддереве cur.
else:
cur = cur.left
# Вставка узла.
node = TreeNode(num)
if pre.val < num:
pre.right = node
else:
pre.left = node
Как и в случае поиска узла, вставка узла выполняется за время O(log n).
3. Удаление узла
Сначала в двоичном дереве выполняется поиск целевого узла, после чего он
удаляется. Как и при вставке узла, необходимо гарантировать, что после завершения операции удаления сохраняется свойство двоичного дерева поиска:
левое поддерево < корневой узел < правое поддерево. Поэтому, в зависимости
от количества дочерних узлов целевого узла (0, 1 или 2), выполняются соответствующие операции его удаления.
Если степень удаляемого узла равна 0, значит он является листовым, и его
можно удалить напрямую, см. рис. 7.19.
Удаление узла
1. Найти позицию
cur для вставки узла
из двоичного дерева поиска
У узла cur количество
дочерних узлов = 0
2. Можно напрямую удалить cur
Выполнить pre.left = None
Рис. 7.19. Удаление узла в двоичном дереве поиска (степень 0)
Если степень удаляемого узла равна 1, его можно заменить дочерним узлом,
см. рис. 7.20.
194
Деревья
Удаление узла
1. Найти узел cur,
подлежащий удалению
из двоичного дерева поиска
У узла cur количество
дочерних узлов = 1
2. Заменить узел cur его
дочерним узлом
Выполнить pre.left = cur.right
Рис. 7.20. Удаление узла в двоичном дереве поиска (степень 1)
Если степень удаляемого узла равна 2, его нельзя удалить напрямую, и необходимо заменить его другим узлом. Согласно свойству двоичного дерева поиска левое поддерево < корневой узел < правое поддерево, этот узел может
быть минимальным узлом правого поддерева или максимальным узлом
левого поддерева.
Предположим, что мы выбираем минимальный узел правого поддерева
(следующий узел при симметричном обходе), тогда процесс удаления будет
следующим (см. рис. 7.21):
1) найти следующий узел в последовательности симметричного обхода для
узла, который необходимо удалить, и обозначить его как tmp;
2) заменить значение удаляемого узла значением tmp и рекурсивно удалить узел tmp из дерева.
Удаление узла
из двоичного дерева поиска
1. Найти узел cur, подлежащий удалению
Шаг 1
Рис. 7.21. Удаление узла в двоичном дереве поиска (степень 2). Шаг 1
Шаг 1
7.4. Двоичное дерево поиска 195
Удаление узла
из двоичного дерева поиска
1. Найти узел cur, подлежащий удалению
У узла cur количество дочерних узлов = 2
2. Удаление узла cur делится на три шага:
(1) Найти узел-преемник nex для узла cur
при симметричном обходе
Шаг 2
Удаление узла
из двоичного дерева поиска
(2) Рекурсивно удалить узел nex из дерева
(3) Присвоить значение узла nex узлу cur
1. Найти узел cur, подлежащий удалению
У узла cur количество дочерних узлов = 2
2. Удаление узла cur делится на три шага:
(1) Найти узел-преемник nex для узла cur
при симметричном обходе
(2) Рекурсивно удалить узел nex из дерева
Шаг 3
Рис. 7.21. Продолжение.. Шаг 2–3
(3) Присвоить значение узла nex узлу cur
196
Деревья
Удаление узла
из двоичного дерева поиска
1. Найти узел cur, подлежащий удалению
У узла cur количество дочерних узлов = 2
2. Удаление узла cur делится на три шага:
(1) Найти узел-преемник nex для узла cur
при симметричном обходе
(2) Рекурсивно удалить узел nex из дерева
(3) Присвоить значение узла nex узлу cur
Шаг 4
Рис. 7.21. Окончание. Шаг 4
Операция удаления узла также выполняется за время O(log n). При этом поиск удаляемого узла требует времени O(log n) и получение следующего узла
при симметричном обходе также требует времени O(log n). Ниже приведен
пример кода.
# === File: binary_search_tree.py ===
def remove(self, num: int):
""" Удаление узла."""
# Если дерево пусто, немедленный возврат.
if self._root is None:
return
# Циклический поиск, выход после прохождения листового узла.
cur, pre = self._root, None
while cur is not None:
# Найден узел для удаления, выход из цикла.
if cur.val == num:
break
pre = cur
# Узел для удаления находится в правом поддереве cur.
if cur.val < num:
cur = cur.right
# Узел для удаления находится в левом поддереве cur.
else:
cur = cur.left
7.4. Двоичное дерево поиска 197
# Если узел для удаления не найден, возврат.
if cur is None:
return
# Количество дочерних узлов = 0 или 1.
if cur.left is None or cur.right is None:
# Если количество дочерних узлов = 0 / 1, child = null / этот дочерний узел.
child = cur.left or cur.right
# Удаление узла cur.
if cur != self._root:
if pre.left == cur:
pre.left = child
else:
pre.right = child
else:
# Если удаляемый узел - корень, переназначаем корень.
self._root = child
# Количество дочерних узлов = 2.
else:
# Получение следующего узла при симметричном обходе для cur.
tmp: TreeNode = cur.right
while tmp.left is not None:
tmp = tmp.left
# Рекурсивное удаление узла tmp.
self.remove(tmp.val)
# Замена cur на tmp
cur.val = tmp.val
4. Упорядоченность симметричного обхода
Симметричный обход двоичного дерева следует порядку лево → корень → право, а двоичное дерево поиска удовлетворяет соотношению левый узел < корневой узел < правый узел, см. рис. 7.22.
Это означает, что при симметричном обходе двоичного дерева поиска всегда сначала будет посещаться следующий минимальный узел, что приводит
к важному свойству: последовательность симметричного обхода двоичного дерева поиска является возрастающей.
Используя свойство возрастающей последовательности симметричного
обхода, можно получить упорядоченные данные в двоичном дереве поиска
за время O(n) без необходимости в дополнительных операциях сортировки,
что очень эффективно.
198
Деревья
Симметричный обход двоичного дерева поиска
Элементы в возрастающем порядке
При симметричном
обходе узлы посещаются
по точкам
Рис. 7.22. Симметричный обход двоичного дерева поиска
7.4.2. Эффективность двоичного дерева поиска
Для заданного набора данных можно использовать для его хранения массив
или двоичное дерево поиска. Как показано в табл. 7.2, временная сложность
операций в двоичном дереве поиска имеет логарифмический порядок, что
обеспечивает стабильную и высокую производительность. Только в случае частого добавления и редкого поиска и удаления данных массив будет более эффективен, чем двоичное дерево поиска.
Таблица 7.2. Сравнение эффективности массива и дерева поиска
Операция
Неупорядоченный массив
Двоичное дерево поиска
Поиск элемента
O(n)
O(log n)
Вставка элемента
O(1)
O(log n)
Удаление элемента
O(n)
O(log n)
В идеальных условиях двоичное дерево поиска является сбалансированным, что позволяет находить любой узел за log n итераций.
Однако, если в двоичном дереве поиска постоянно добавлять и удалять узлы,
это может привести к его вырождению в список, как показано на рис. 7.23. Тогда временная сложность различных операций также деградирует до O(n).
7.5. АВЛ-дерево* 199
Сбалансированное двоичное дерево поиска
Вырожденное двоичное дерево поиска
Рис. 7.23. Вырождение двоичного дерева поиска
7.4.3. Типичные сценарии применения
двоичного дерева поиска
Используется в качестве многоуровневого индекса в системах для эффективного поиска, вставки и удаления.
Служит базовой структурой данных для некоторых алгоритмов поиска.
Применяется для хранения потока данных для поддержания его упорядоченного состояния.
7.5. АВЛ-дерево*
В разделе «Двоичное дерево поиска» упоминалось, что после многократных операций вставки и удаления двоичное дерево поиска может выродится в список. В таких случаях временная сложность всех операций ухудшается
с O(log n) до O(n).
На рис. 7.24 приведен пример, когда после двух операций удаления узлов
двоичное дерево поиска вырождается в список.
В другом примере после вставки двух узлов в идеальное двоичное дерево,
показанное на рис. 7.25, дерево сильно наклоняется влево, и временная сложность операций поиска также ухудшается.
В 1962 году советские математики Г. М. Адельсон-Вельский и Е. М. Ландис
в статье «Один алгоритм организации информации» предложили структуру АВЛдерева. В статье подробно описывается серия операций, которые гарантируют,
что после постоянного добавления и удаления узлов АВЛ-дерево не деградирует, что позволяет поддерживать временную сложность различных операций на
уровне O(log n). Иными словами, в сценариях, требующих частых операций добавления, удаления, поиска и изменения, АВЛ-дерево обеспечивает высокую эффективность операций с данными и имеет значительную прикладную ценность.
200
Деревья
Удаление узла 5
Удаление узла 3
Вырождение
Сбалансированное
двоичное дерево
Вырождение
Обычное двоичное
дерево
Связный список
Рис. 7.24. Вырождение АВЛ-дерева после удаления узлов
Вставка узла 2
Идеальное
двоичное дерево
Вырождение
Вставка узла 1
Сбалансированное
двоичное дерево
Вырождение
Обычное двоичное
дерево
Рис. 7.25. Вырождение АВЛ-дерева после вставки узлов
7.5.1. Основные понятия АВЛ-дерева
АВЛ-дерево является одновременно и двоичным деревом поиска, и сбалансированным двоичным деревом, удовлетворяя всем свойствам этих двух
типов деревьев. Таким образом, оно представляет собой сбалансированное
двоичное дерево поиска.
1. Высота узла
Поскольку операции с АВЛ-деревом требуют получения высоты узла, необходимо добавить в класс узла переменную height.
class TreeNode:
"""Класс узла AVL-дерева."""
7.5. АВЛ-дерево* 201
def __init__(self, val: int):
self.val: int = val
# Значение узла.
self.height: int = 0 # Высота узла.
self.left: TreeNode | None = None
# Ссылка на левый дочерний узел.
self.right: TreeNode | None = None # Ссылка на правый дочерний узел.
Высота узла определяется как расстояние от данного узла до самого удаленного листа, т. е. количество ребер, через которые проходит этот путь. Следует особо
отметить, что высота листа равна 0, а высота пустого узла равна –1. Нам понадобятся две вспомогательные функции для получения и обновления высоты узла.
# === File: avl_tree.py ===
def height(self, node: TreeNode | None) -> int:
"""Получение высоты узла."""
# Высота пустого узла равна -1, высота листа равна 0.
if node is not None:
return node.height
return -1
def update_height(self, node: TreeNode | None):
"""Обновление высоты узла."""
# Высота узла равна высоте самого высокого поддерева + 1.
node.height = max([self.height(node.left), self.height(node.right)]) + 1
2. Фактор баланса узла
Фактор баланса узла определяется как высота левого поддерева узла минус
высота правого поддерева, при этом фактор баланса пустого узла равен 0.
Мы обернем функцию получения фактора баланса узла в отдельную функцию
для удобства дальнейшего использования.
# === File: avl_tree.py ===
def balance_factor(self, node: TreeNode | None) -> int:
"""Получение балансировочного фактора."""
# Фактор баланса пустого узла равен 0.
if node is None:
return 0
# Фактор баланса узла = высота левого поддерева - высота правого поддерева.
return self.height(node.left) - self.height(node.right)
Совет
Пусть фактор баланса равен f, тогда фактор баланса любого узла в АВЛ-дереве
удовлетворяет условию –1 ≤ f ≤ 1.
7.5.2. Вращение в АВЛ-дереве
Особенностью АВЛ-дерева является операция вращения, которая позволяет
восстановить баланс узла, не влияя на порядок обхода двоичного дерева. Иными словами, вращение поворота сохраняет свойства двоичного дерева
поиска и делает дерево снова сбалансированным двоичным деревом.
202
Деревья
Узлы с абсолютным значением фактора баланса > 1 называются несбалансированными узлами. В зависимости от типа несбалансированности узла операции вращения делятся на четыре типа: правое; левое; сначала правое, затем
левое; сначала левое, затем правое. Рассмотрим их подробнее.
1. Правое вращение
На рис. 7.26 ниже узла указан фактор баланса. Если идти снизу вверх, в двоичном дереве первым несбалансированным узлом является узел 3. Рассмотрим поддерево с этим узлом в качестве корня: обозначим этот узел как node,
а его левый дочерний узел как child и выполним операцию правого вращения.
После завершения операции поддерево восстанавливает баланс и сохраняет
свойства двоичного дерева поиска.
Несбалансированный узел
Шаг 1
2 Сфокусируемся на несбалансированном
поддереве
Обозначим корневой узел как node
Обозначим левый дочерний узел как child
3 Вращение вправо
4 Баланс восстановлен
5 Последовательность правого вращения:
Сфокусируемся на несбалансированном поддереве
1. Взяв child в качестве точки опоры, повернуть
Обозначим корневой узел как node
node вправо
Обозначим левый дочерний узел как child
2. Заменить прежнюю позицию node на child
6 После выполнения правого вращения
поддерево снова становится
сбалансированным
Шаг 2
Рис. 7.26. Этапы правого вращения. Шаги 1–2
3 Вращение вправо
4 Баланс восстановлен
5 Последовательность правого вращения:
1. Взяв child в качестве точки опоры, повернуть
7.5. АВЛ-дерево* 203
Последовательность правого вращения:
1. Взяв child в качестве точки опоры,
повернуть node вправо
Вращение вправо
Шаг 3
3
4 Баланс восстановлен
5
1.
Последовательность правого вращения:
Баланс восстановлен
2. Заменить прежнюю позицию node на child 1. Взяв child в качестве точки опоры,
повернуть node вправо
6 После выполнения правого вращения
поддерево снова становится
2. Заменить прежнюю позицию node
сбалансированным
на child
После выполнения правого вращения
поддерево снова становится
сбалансированным
Шаг 4
Рис. 7.26. Окончание.. Шаги 3–4
Если у узла child есть правый дочерний узел (обозначим его как grand_child),
необходимо добавить в правое вращение еще один шаг: сделать grand_child левым дочерним узлом для node.
Правое вращение – это образное выражение, фактически оно реализуется
путем изменения указателей узлов, как показано в приведенном ниже коде.
204
Деревья
Несбалансированный узел
Баланс
восстановлен
Вращение
вправо
Рис. 7.27. Правое вращение с grand_child
# === File: avl_tree.py ===
def right_rotate(self, node: TreeNode | None) -> TreeNode | None:
"""Правое вращение."""
child = node.left
grand_child = child.right
# С использованием child в качестве опорной точки выполнить правое вращение node.
child.right = node
node.left = grand_child
# Обновление высоты узлов.
self.update_height(node)
self.update_height(child)
# Возврат корневого узла поддерева после вращения.
return child
2. Левое вращение
Соответственно, если рассмотреть зеркальное отражение вышеупомянутого
несбалансированного двоичного дерева, необходимо выполнить операцию
левого вращения, как показано на рис. 7.28.
Баланс
восстановлен
Несбалансированный узел
Вращение
влево
Рис. 7.28. Левое вращение
7.5. АВЛ-дерево* 205
Аналогично, если у узла child есть левый дочерний узел (обозначим его как
grand_child), необходимо добавить в левое вращение еще один шаг: сделать
grand_child правым дочерним узлом для node, как показано на рис. 7.29.
Баланс
восстановлен
Несбалансированный узел
Вращение
влево
Рис. 7.29. Левое вращение с grand_child
Можно заметить, что правое и левое вращение логически являются
зеркально симметричными, и они решают две симметричные ситуации
несбалансированности. Поэтому достаточно заменить в коде реализации
правого вращения все left на right и все right на left, чтобы получить код реализации левого вращения.
# === File: avl_tree.py ===
def left_rotate(self, node: TreeNode | None) -> TreeNode | None:
"""Левый поворот."""
child = node.right
grand_child = child.left
# С использованием child в качестве опорной точки выполнить левый поворот node.
child.left = node
node.right = grand_child
# Обновление высоты узлов.
self.update_height(node)
self.update_height(child)
# Возврат корневого узла поддерева после поворота.
return child
3. Сначала левое, затем правое вращение
Для несбалансированного узла 3 на рис. 7.30 использование только левого или
правого вращения не позволяет восстановить баланс поддерева. В этом случае
необходимо сначала выполнить левое вращение для child, а затем правое вращение для node.
206
Деревья
Несбалансированный узел
Баланс
восстановлен
Выполнить левое
вращение для child
Выполнить правое
вращение для node
Рис. 7.30. Сначала левое, затем правое вращение
4. Сначала правое, затем левое вращение
Для зеркальной ситуации вышеупомянутого разбалансированного двоичного
дерева необходимо сначала выполнить правое вращение для child, а затем левое вращение для node, как показано на рис. 7.31.
Несбалансированный узел
Выполнить правое
вращение для child
Баланс
восстановлен
Выполнить левое
вращение для node
Рис. 7.31. Сначала правое, затем левое вращение
5. Выбор типа вращения
На рис. 7.32 изображено четыре типа несбалансированности, соответствующие вышеописанным случаям, для которых необходимо применять операции:
правого вращения; сначала левого, затем правого вращения; сначала правого,
затем левого вращения; левого вращения соответственно.
Из табл. 7.3 видно, что для определения того, к какому случаю из рис. 7.32
относится несбалансированный узел, используется фактор баланса узла и знак
фактора баланса дочернего узла с большей высотой.
7.5. АВЛ-дерево* 207
Правое
вращение
Сначала левое, затем
правое вращение
Сначала правое, затем
левое вращение
Левое
вращение
Рис. 7.32. Четыре типа вращений в АВЛ-дереве
Таблица 7.3. Условия выбора для четырех типов вращений
Фактор баланса несбалансированного узла
Фактор баланса
дочернего узла
Рекомендуемый метод вращения
> 1 (левостороннее дерево)
≥0
Правое вращение
> 1 (левостороннее дерево)
<0
Сначала левое, затем правое вращение
< -1 (правостороннее дерево)
≤0
Левое вращение
< -1 (правостороннее дерево)
>0
Сначала правое, затем левое вращение
Для удобства использования операции вращения инкапсулированы
в функцию. С помощью этой функции можно выполнять вращения для
различных случаев несбалансированности узла. Ниже приведен код реализации.
# === File: avl_tree.py ===
def rotate(self, node: TreeNode | None) -> TreeNode | None:
""" Выполнение операции вращения для восстановления баланса поддерева."""
# Получение фактора баланса узла node.
balance_factor = self.balance_factor(node)
# Левостороннее дерево.
if balance_factor > 1:
if self.balance_factor(node.left) >= 0:
# Правое вращение.
return self.right_rotate(node)
else:
# Сначала левое, затем правое вращение.
node.left = self.left_rotate(node.left)
return self.right_rotate(node)
208
Деревья
# Правостороннее дерево.
elif balance_factor < -1:
if self.balance_factor(node.right) <= 0:
# Левое вращение.
return self.left_rotate(node)
else:
# Сначала правое, затем левое вращение.
node.right = self.right_rotate(node.right)
return self.left_rotate(node)
# Сбалансированное дерево, вращение не требуется.
return node
7.5.3. Основные операции с АВЛ-деревом
1. Вставка узла
Операция вставки узла в АВЛ-дерево в основном схожа с двоичным деревом
поиска. Единственное отличие заключается в том, что после вставки узла
в АВЛ-дерево на пути от этого узла к корню могут возникнуть несбалансированные узлы. Поэтому, начиная с этого узла, необходимо выполнять вращения снизу вверх, чтобы восстановить баланс всех несбалансированных узлов. Ниже приведен код реализации.
# === File: avl_tree.py ===
def insert(self, val):
""" Вставка узла."""
self._root = self.insert_helper(self._root, val)
def insert_helper(self, node: TreeNode | None, val: int) -> TreeNode:
""" Рекурсивная вставка узла (вспомогательный метод)."""
if node is None:
return TreeNode(val)
# 1. Поиск позиции для вставки и вставка узла.
if val < node.val:
node.left = self.insert_helper(node.left, val)
elif val > node.val:
node.right = self.insert_helper(node.right, val)
else:
# Повторяющийся узел не вставляется, возвращается напрямую.
return node
# Обновление высоты узла.
self.update_height(node)
# 2. Выполнение операции вращения для восстановления баланса поддерева.
return self.rotate(node)
7.5. АВЛ-дерево* 209
2. Удаление узла
Для удаления узла можно также взять метод удаления узла в двоичном дереве
поиска и добавить вращения при движении снизу вверх, чтобы восстановить
баланс всех несбалансированных узлов. Ниже приведен код реализации.
# === File: avl_tree.py ===
def remove(self, val: int):
""" Удаление узла."""
self._root = self.remove_helper(self._root, val)
def remove_helper(self, node: TreeNode | None, val: int) -> TreeNode | None:
""" Рекурсивное удаление узла (вспомогательный метод)."""
if node is None:
return None
# 1. Поиск узла и его удаление.
if val < node.val:
node.left = self.remove_helper(node.left, val)
elif val > node.val:
node.right = self.remove_helper(node.right, val)
else:
if node.left is None or node.right is None:
child = node.left or node.right
# Количество подузлов = 0, узел node удаляется напрямую и выполняется возврат.
if child is None:
return None
# Количество подузлов = 1, узел node удаляется напрямую.
else:
node = child
else:
# Количество подузлов = 2, следующий узел в порядке обхода удаляется, а текущий узел заменяется этим узлом.
temp = node.right
while temp.left is not None:
temp = temp.left
node.right = self.remove_helper(node.right, temp.val)
node.val = temp.val
# Обновление высоты узла.
self.update_height(node)
# 2. Выполнение операции вращения для восстановления баланса поддерева.
return self.rotate(node)
3. Поиск узла
Операция поиска узла в АВЛ-дереве идентична поиску в двоичном дереве поиска, поэтому здесь повторно не рассматривается.
210
Деревья
7.5.4. Типичные сценарии применения АВЛ-дерева
Организация и хранение больших объемов данных подходит для сценариев с частыми поисками и редкими вставками и удалениями.
Используется для построения индексных систем в базах данных.
Красно-черное дерево также является распространенным видом сбалансированного двоичного дерева поиска. По сравнению с АВЛ-деревом условия баланса в красно-черном дереве более мягкие, что требует меньше
вращений при вставке и удалении узлов и обеспечивает более высокую
среднюю эффективность этих операций.
7.6. Резюме
1. Ключевые моменты
Двоичное (бинарное) дерево – это нелинейная структура данных, отражающая логику «разделяй и властвуй». Каждый узел двоичного дерева
содержит значение и два указателя, указывающих на его левый и правый
дочерние узлы соответственно.
Для любого узла в двоичном дереве его левый (правый) дочерний узел и образуемое им дерево называются левым (правым) поддеревом этого узла.
Связанные с двоичным деревом понятия включают корневой узел, листовой узел, уровень, степень, ребро, высоту, глубину и др.
Инициализация двоичного дерева, вставка и удаление узлов аналогичны
методам работы со списками.
К распространенным типам двоичных деревьев относятся идеальное
двоичное дерево, совершенное двоичное дерево, полное двоичное дерево и сбалансированное двоичное дерево. Идеальное двоичное дерево
является наиболее желаемым состоянием, а список – наихудшим состоянием после вырождения.
Двоичное дерево может быть представлено массивом, в котором значения узлов и пустые места располагаются в порядке обхода по уровням,
а указатели реализуются на основе индексации между родительскими
и дочерними узлами.
Обход двоичного дерева по уровням является методом поиска в ширину, который отражает способ обхода по кругам, расширяющимся наружу
и обычно реализуется с помощью очереди.
Прямой, симметричный и обратный обходы относятся к методам поиска
в глубину. Они демонстрируют способ обхода «сначала до конца, затем возврат и продолжение», обычно реализуемый с использованием рекурсии.
Двоичное дерево поиска представляет собой эффективную структуру данных для поиска элементов, где временная сложность операций поиска,
вставки и удаления составляет O(log n). Когда двоичное дерево поиска вырождается в список, временная сложность всех операций ухудшается до O(n).
АВЛ-дерево, также известное как сбалансированное двоичное дерево
поиска, поддерживает выполнение балансировки дерева после вставки
и удаления узлов с помощью операций вращения.
7.6. Резюме 211
Операции вращения в АВЛ-дереве включают: правое вращение; левое
вращение; сначала левое, затем правое вращение; сначала правое, затем
левое вращение. После вставки или удаления узлов АВЛ-дерево выполняет операции вращения снизу вверх, чтобы восстановить баланс.
2. Вопросы и ответы
Вопрос. Для двоичного дерева с единственным узлом высота дерева и глубина
корневого узла равны 0?
Ответ. Да, поскольку высота и глубина обычно определяются как количество пройденных ребер.
Вопрос. Вставка и удаление в двоичном дереве обычно выполняются с помощью набора операций. Что подразумевается под набором операций? Можно ли это понимать как освобождение ресурсов дочерних узлов?
Ответ. Возьмем, к примеру, двоичное дерево поиска: операция удаления
узла требует обработки трех различных случаев, в каждом из которых необходимо выполнить несколько шагов операций с узлами.
Вопрос. Почему для обхода двоичного дерева в глубину существуют три порядка – прямой, симметричный и обратный, и в чем их преимущества?
Ответ. Подобно прямому и обратному обходу массива, прямой, симметричный и обратный обходы являются тремя методами обхода двоичного дерева.
Они позволяют получить результат обхода в определенном порядке. Например, в двоичном дереве поиска, поскольку значения узлов удовлетворяют условию «значение левого дочернего узла < значение корневого узла < значение
правого дочернего узла», обход дерева в порядке «левый → корень → правый»
позволяет получить упорядоченную последовательность узлов.
Вопрос. Операция правого вращения обрабатывает отношения между несбалансированным узлом node, дочерним узлом child и внуком grand_child.
Не нужно ли поддерживать связь node с его родительским узлом?
Ответ. Необходимо рассматривать этот вопрос с рекурсивной точки зрения.
Операция правого вращения right_rotate(root) принимает корневой узел поддерева, и в итоге возвращает child, который после вращения становится корневым узлом. Связь корневого узла поддерева с его родительским узлом устанавливается после завершения функции и не входит в область поддержания
операции правого вращения.
Вопрос. В C++ функции разделяются на private и public. Какие соображения
при этом нужно учитывать? Почему функции height() и updateHeight() размещены в public и private соответственно?
Ответ. Это зависит от области применения метода. Если метод используется только внутри класса, его следует сделать private. Например, вызов updateHeight() пользователем не имеет смысла, так как это лишь этап в операциях вставки и удаления. А height() используется для доступа к высоте узла
аналогично методу vector.size(), поэтому он помечен как public для удобства
использования.
Вопрос. Как построить двоичное дерево поиска из набора входных данных?
Является ли важным способ выбора корневого узла?
Ответ. Да, метод построения дерева описан в методе build_tree() в коде двоичного дерева поиска. Что касается выбора корневого узла, обычно входные
212
Деревья
данные сортируются, затем средний элемент выбирается в качестве корневого
узла, после чего рекурсивно строятся левые и правые поддеревья. Это позволяет максимально сохранить баланс дерева.
Вопрос. Всегда ли в Java для сравнения строк нужно использовать метод
equals()?
Ответ. В Java для базовых типов данных оператор == используется для сравнения значений двух переменных. Для ссылочных типов принцип работы этих
операторов различен.
==: используется для сравнения того, указывают ли две переменные на
один и тот же объект, т. е. совпадают ли их позиции в памяти.
equals(): используется для сравнения значений двух объектов.
Таким образом, если необходимо сравнить значения, следует использовать
метод equals(). Однако строки, инициализированные как String a = "hi"; String
b = "hi";, хранятся в пуле строковых констант и указывают на один и тот же
объект, поэтому для сравнения содержимого этих строк можно использовать
a == b.
Вопрос. До достижения самого нижнего уровня при обходе в ширину количество узлов в очереди равно 2h?
Ответ. Да, например, для полного двоичного дерева высотой h = 2 общее
количество узлов n = 7, тогда количество узлов на нижнем уровне равно
4 = 2h = (n + 1)/2.
Глава 8
Куча
Абстракция
Кучи подобны возвышающимся горным вершинам с разнообразной формой.
Горы различаются по высоте, но самая высокая всегда бросается в глаза первой.
214
Куча
8.1. Куча
Куча – это полное двоичное дерево, удовлетворяющее определенным условиям, и делится на два основных типа, как показано на рис. 8.1.
Минимальная куча: значение любого узла ≤ значений его дочерних узлов.
Максимальная куча: значение любого узла ≥ значений его дочерних
узлов.
Является совершенным двоичным деревом,
и значение любого узла ≤ значения его
дочерних узлов
Является совершенным двоичным деревом,
и значение любого узла ≥ значения его
дочерних узлов
Минимальная куча
Максимальная куча
Рис. 8.1. Минимальная и максимальная кучи
Куча, как частный случай полного двоичного дерева, обладает следующими
свойствами:
узлы на самом нижнем уровне заполняются слева, остальные уровни
полностью заполнены;
корневой узел двоичного дерева называется вершиной кучи, а самый
правый узел на нижнем уровне – основанием кучи;
для максимальной (минимальной) кучи значение элемента на вершине
(т. е. корневом узле) является наибольшим (наименьшим).
8.1.1. Основные операции с кучей
Следует отметить, что многие языки программирования содержат приоритетную очередь, которая является абстрактной структурой данных, определяемой
как очередь с приоритетной сортировкой.
На практике куча часто используется для реализации приоритетной
очереди, где максимальная куча соответствует приоритетной очереди,
из которой элементы извлекаются в порядке убывания. С точки зрения
использования приоритетную очередь и кучу можно считать эквивалентными
структурами данных. Поэтому в данной книге они не различаются и называются просто кучей.
8.1. Куча 215
Основные операции с кучей представлены в табл. 8.1, названия методов
в разных языках программирования могут отличаться.
Таблица 8.1. Эффективность операций с кучей
Метод
Описание
Временная сложность
push()
Вставка элемента в кучу
O(log n)
pop()
Извлечение элемента с вершины кучи
O(log n)
peek()
Доступ к элементу на вершине кучи (макс./мин. значение)
O(1)
size()
Получение количества элементов в куче
O(1)
isEmpty() Проверка кучи на пустоту
O(1)
В реальных приложениях можно напрямую использовать классы кучи (или
приоритетной очереди), предоставляемые языком программирования.
Подобно сортировочным алгоритмам по возрастанию и по убыванию, можно установить флаг или изменить компаратор для преобразования минимальной кучи в максимальную» и наоборот. Ниже приведен пример кода.
# === File: heap.py ===
# Инициализация
min_heap, flag =
# Инициализация
max_heap, flag =
#
#
#
#
минимальной кучи.
[], 1
максимальной кучи.
[], -1
Модуль heapq в Python по умолчанию реализует минимальную кучу.
Рассматривается вариант, при котором элементы инвертируются перед
добавлением в кучу, что позволяет изменить порядок и реализовать максимальную кучу.
В этом примере flag = 1 соответствует минимальной куче, flag = -1 – максимальной.
# Вставка элемента в кучу.
heapq.heappush(max_heap, flag
heapq.heappush(max_heap, flag
heapq.heappush(max_heap, flag
heapq.heappush(max_heap, flag
heapq.heappush(max_heap, flag
*
*
*
*
*
1)
3)
2)
5)
4)
# Доступ к элементу на вершине кучи.
peek: int = flag * max_heap[0] # 5
# Извлечение элемента с вершины кучи.
# Извлеченные элементы образуют последовательность по убыванию.
val = flag * heapq.heappop(max_heap) # 5
val = flag * heapq.heappop(max_heap) # 4
val = flag * heapq.heappop(max_heap) # 3
val = flag * heapq.heappop(max_heap) # 2
val = flag * heapq.heappop(max_heap) # 1
216
Куча
# Получение размера кучи.
size: int = len(max_heap)
# Проверка кучи на пустоту.
is_empty: bool = not max_heap
# Построение кучи из списка.
min_heap: list[int] = [1, 3, 2, 5, 4]
heapq.heapify(min_heap)
8.1.2. Реализация кучи
Ниже приведена реализация максимальной кучи. Для преобразования в минимальную кучу достаточно инвертировать все логические сравнения (например, заменить ≥ на ≤). Заинтересованные читатели могут реализовать это самостоятельно.
1. Хранение и представление кучи
В разделе «Двоичные деревья» упоминалось, что полные двоичные деревья
удобно представлять в виде массива. Поскольку куча является таким деревом, для ее хранения будем использовать массив.
При использовании массива для представления двоичного дерева элементы
представляют значения узлов, а индексы – их положение в дереве. Указатели
на узлы реализуются через формулы индексации.
Как показано на рис. 8.2, для заданного индекса массива i индекс левого дочернего узла равен 2i + 1, правого – 2i + 2, а индекс родительского узла – (i – 1) / 2
(целочисленное деление вниз). Выход за пределы индексации обозначает пустой
узел или его отсутствие.
Логическая структура кучи
Куча– это совершенное
двоичное дерево
Физическая структура кучи (базовое хранение)
Использование массива для представления
совершенного двоичного дерева
Индекс
Элемент
Родительский
узел
Узел
Левый
Правый
дочерний дочерний
узел
узел
Реализация указателей узлов через
отображение индексов
Рис. 8.2. Представление и хранение кучи
8.1. Куча 217
Формулы индексации можно для удобства использования обернуть
в функции.
# === File: my_heap.py ===
def left(self, i: int) -> int:
""" Получение индекса левого дочернего узла/"""
return 2 * i + 1
def right(self, i: int) -> int:
""" Получение индекса правого дочернего узла/"""
return 2 * i + 2
def parent(self, i: int) -> int:
""" Получение индекса родительского узла."""
return (i - 1) // 2 # Целочисленное деление вниз.
2. Доступ к элементу на вершине кучи
Элемент на вершине кучи – это корневой узел двоичного дерева, т. е. первый
элемент списка.
# === File: my_heap.py ===
def peek(self) -> int:
""" Доступ к элементу на вершине кучи."""
return self.max_heap[0]
3. Вставка элемента в кучу
Нам дан элемент val, который сначала добавляется в основание кучи. После
добавления условия корректности кучи могут быть нарушены, поскольку
элемент val может быть больше других элементов кучи. Поэтому необходимо восстановить порядок на пути от вставленного узла до корневого узла. Эта операция называется упорядочиванием кучи.
Рассмотрим выполнение упорядочивания кучи снизу вверх, начиная
с узла, который был добавлен. Как показано на рис. 8.3, необходимо сравнивать значения вставленного узла и его родительского узла. Если вставленный узел больше, они меняются местами. Затем продолжается выполнение этой операции с исправлением каждого узла кучи снизу вверх, пока
не будет достигнут корневой узел или не встретится узел, который не требует обмена.
218
Куча
Вставка узла
Куча
в кучу
Представление в виде двоичного дерева
Представление в виде массива
Шаг 1
5 1. Добавление узла 7 в конец кучи
6 2. Упорядочивание кучи снизу вверх
7 Узел 5 ≤ Узел 7
8 Обмен местами двух узлов
Вставка узла
в кучу
9 Обмен не требуется
1. Добавление узла 7 в конец кучи
10 После завершения упорядочивания
свойства максимальной кучи восстановлены
Шаг 2
5
6 2. Упорядочивание кучи снизу вверх
7 Узел 5 ≤ Узел 7
8 Обмен местами двух узлов
9 Обмен не требуется
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
10 После завершения упорядочивания2. Упорядочивание кучи снизу вверх
свойства максимальной кучи восстановУзел 5 ≤ Узел 7
лены
Шаг 3
Рис. 8.3. Этапы добавления элемента в кучу. Шаги 1–3
8 Обмен местами двух узлов
9 Обмен не требуется
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
8.1. Куча 219
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
Узел 5 ≤ Узел 7
Обмен местами двух узлов
Шаг 4
9 Обмен не требуется
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
Узел 6 ≤ Узел 7
Шаг 5
9 Обмен не требуется
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
Узел 6 ≤ Узел 7
Обмен местами двух узлов
Шаг 6
8.3. Продолжение. Шаги 4–6
9 Обмен неРис.
требуется
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
220
Куча
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
Узел 9 > Узел 7
Шаг 7
9 Обмен не требуется
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
Узел 9 > Узел 7
Обмен не требуется
Шаг 8
10 После завершения упорядочивания свойства
максимальной кучи восстановлены
Вставка узла
в кучу
1. Добавление узла 7 в конец кучи
2. Упорядочивание кучи снизу вверх
После завершения упорядочивания
свойства максимальной кучи
восстановлены
Шаг 9
Рис. 8.3. Окончание. Шаги 7–9
8.1. Куча 221
Пусть общее количество узлов равно n, тогда высота дерева будет
O(log n). Из этого следует, что максимальное количество циклов операции
упорядочивания кучи также будет O(log n). Тогда и временная сложность
операции добавления элемента в кучу составит O(log n). Ниже приведен
код реализации.
# === File: my_heap.py ===
def push(self, val: int):
""" Добавление элемента в кучу."""
# Добавление узла.
self.max_heap.append(val)
# Упорядочивание кучи снизу вверх.
self.sift_up(self.size() - 1)
def sift_up(self, i: int):
""" Упорядочивание кучи снизу вверх, начиная с узла i."""
while True:
# Получение родительского узла узла i.
p = self.parent(i)
# Если достигнут корневой узел или узел не требует исправления, завершение упорядочивания кучи.
if p < 0 or self.max_heap[i] <= self.max_heap[p]:
break
# Обмен двух узлов.
self.swap(i, p)
# Циклическое упорядочивание кучи вверх.
i = p
4. Извлечение элемента с вершины кучи
Элемент на вершине кучи является корневым узлом двоичного дерева, т. е.
первым элементом списка. Если просто удалить первый элемент из списка,
индексы всех узлов в двоичном дереве изменятся, что затруднит дальнейшее исправление с помощью упорядочивания кучи. Чтобы минимизировать изменения индексов элементов, используется следующий порядок
действий:
1) обмен вершины кучи с элементом в основании кучи (обмен корневого
узла с самым правым листовым узлом);
2) после обмена удаляется элемент в основании кучи из списка (обратите внимание, что фактически удаляется исходный элемент на вершине
кучи, так как они были поменяны);
3) упорядочивание кучи сверху вниз, начиная с корневого узла.
Направление операции упорядочивания кучи сверху вниз противоположно операции упорядочивания кучи снизу вверх, как показано на
рис. 8.4. Значение корневого узла сравнивается со значениями его двух дочерних узлов, и самый большой дочерний узел обменивается с корневым узлом.
Затем эта операция выполняется циклически, пока не будет достигнут листовой узел или не встретится узел, который не требует обмена.
222
Куча
Извлечения элемента с вершины кучи
Вершина кучи
Куча
Представление в виде двоичного дерева
Представление в виде массива
Основание кучи
Шаг 1
7 Обмен местами вершины кучи и основания
Извлечения элемента с вершины кучи
8 Извлечение текущего элемента с основания кучи
9 Упорядочивание кучи сверху вниз
1. Обмен местами вершины кучи и основания
10 Среди узлов 5, 8, 7 наибольшим является 8
11 Обмен местами узлов 5 и 8
12 После завершения упорядочивания свойства
максимальной кучи восстановлены
Шаг 2
8 Извлечение текущего элемента с основания кучи
Извлечения элемента с вершины кучи
9 Упорядочивание кучи сверху вниз
10 Среди узлов 5, 8, 7 наибольшим является 8 1. Обмен местами вершины кучи и основания
11 Обмен местами узлов 5 и 8
2. Извлечение текущего элемента
с основания кучи
12 После завершения упорядочивания свойства максимальной кучи восстановлены
Шаг 3
Рис. 8.4. Этапы извлечения элемента с вершины кучи. Шаги 1–3
9 Упорядочивание кучи сверху вниз
8.1. Куча 223
Извлечения элемента с вершины кучи
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 8, 7 наибольшим является 8
Шаг 4
Извлечения элемента с вершины кучи
11 Обмен местами узлов 5 и 8
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
12 После завершения упорядочивания свойства максимальной кучи восстановлены
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 8, 7 наибольшим является 8
Обмен местами узлов 5 и 8
Шаг 5
Извлечения элемента
с вершины кучи
После завершения упорядочивания свойства максимальной
кучи восстановлены
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 6, 7 наибольшим является 7
Шаг 6
Рис. 8.4. Продолжение. Шаги 4–6
После завершения упорядочивания свойства максимальной кучи восстановлены
224
Куча
Извлечения элемента с вершины кучи
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 6, 7 наибольшим является 7
Обмен местами узлов 5 и 7
Шаг 7
После завершения упорядочивания свойства максимальной
кучи восстановлены
Извлечения элемента
с вершины кучи
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 3, 6 наибольшим является 6
Шаг 8
Извлечения элемента с вершины кучи
После завершения упорядочивания свойства максимальной кучи восстановлены
1. Обмен местами вершины кучи и основания
Обмен местами узлов 5 и 7
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
Среди узлов 5, 3, 6 наибольшим является 6
Обмен местами узлов 5 и 6
Шаг 9
Рис. 8.4. Продолжение. Шаги 7–9
После завершения упорядочивания свойства максимальной кучи восстановлены
8.1. Куча 225
Извлечения элемента с вершины кучи
1. Обмен местами вершины кучи и основания
2. Извлечение текущего элемента
с основания кучи
3. Упорядочивание кучи сверху вниз
После завершения упорядочивания
свойства максимальной кучи
восстановлены
Шаг 10
Рис. 8.4. Окончание. Шаг 10
Подобно операции добавления элемента в кучу, временная сложность операции извлечения элемента с вершины кучи также составляет O(log n). Ниже
приведен код реализации.
# === File: my_heap.py ===
def pop(self) -> int:
""" Извлечение элемента из кучи."""
# Обработка пустой кучи.
if self.is_empty():
raise IndexError("Куча пуста")
# Обмен корневого узла с самым правым листовым узлом (обмен первого и последнего элементов).
self.swap(0, self.size() - 1)
# Удаление узла.
val = self.max_heap.pop()
# Упорядочивание кучи сверху вниз.
self.sift_down(0)
# Возврат элемента с вершины кучи.
return val
def sift_down(self, i: int):
""" Упорядочивание кучи сверху вниз, начиная с узла i."""
while True:
# Определение узла с максимальным значением среди узлов i, l, r, обозначенного как ma.
l, r, ma = self.left(i), self.right(i), i
if l < self.size() and self.max_heap[l] > self.max_heap[ma]:
ma = l
if r < self.size() and self.max_heap[r] > self.max_heap[ma]:
ma = r
226
Куча
# Если узел i максимальный или индексы l, r выходят за пределы,
# упорядочивание кучи не требуется, выход из цикла.
if ma == i:
break
# Обмен двух узлов.
self.swap(i, ma)
# Циклическое упорядочивание кучи вниз.
i = ma
8.1.3. Типичные сценарии применения кучи
Очередь с приоритетом: куча обычно используется как структура данных для реализации очереди с приоритетом, где операции добавления
и извлечения имеют временную сложность O(log n), а операция построения кучи – O(n). Эти операции очень эффективны.
Пирамидальная сортировка: для заданного набора данных можно
построить кучу, а затем последовательно выполнять операции извлечения элементов из кучи, чтобы получить отсортированные данные.
Однако обычно используется более изящный способ реализации пирамидальной сортировки, подробнее см. в разделе «Пирамидальная
сортировка».
Получение k наибольших элементов: это классическая задача алгоритмов и типичное применение. Например, выбор 10 самых популярных
новостей для горячих тем в социальных сетях или выбор 10 самых продаваемых товаров.
8.2. Построение кучи
В некоторых случаях требуется использовать все элементы списка для построения кучи, этот процесс называется построением кучи.
8.2.1. Реализация с помощью операции
добавления в кучу
Сначала создается пустая куча, затем обходится список, и для каждого элемента последовательно выполняется операция добавления в кучу. То есть элемент
сначала добавляется в основание кучи, а затем для него выполняется упорядочивание кучи снизу вверх.
При добавлении элемента в кучу ее длина увеличивается на единицу. Поскольку узлы добавляются в двоичное дерево сверху вниз, куча строится сверху вниз.
Пусть количество элементов равно n и каждый элемент добавляется в кучу
за время O(log n). Тогда временная сложность этого метода построения кучи
составляет O(n log n).
8.2.2. Реализация через обход и упорядочивание
На самом деле можно реализовать более эффективный метод построения
кучи, который состоит из двух шагов:
8.2. Построение кучи 227
1) добавить все элементы списка в кучу без изменений, при этом свойства
кучи еще не соблюдаются;
2) обходить кучу в обратном порядке (обратный обход по уровням) и выполнять упорядочивание сверху вниз для каждого нелистового узла.
После упорядочивания узла поддерево с корнем в этом узле становится корректной подкучей. Поскольку обход осуществляется в обратном порядке, куча строится снизу вверх.
Выбор обратного обхода обусловлен тем, что он гарантирует, что поддеревья под текущим узлом уже являются корректными подкучами, что делает
упорядочивание текущего узла эффективным.
Следует отметить, что листовые узлы не имеют дочерних узлов, поэтому они естественным образом являются корректными подкучами и не
требуют упорядочивания. Как показано в следующем коде, последний нелистовой узел является родителем последнего узла, и с него начинается обратный обход и упорядочивание.
# === File: my_heap.py ===
def __init__(self, nums: list[int]):
""" Конструктор, построение кучи на основе входного списка."""
# Добавление элементов списка в кучу без изменений.
self.max_heap = nums
# Упорядочивание всех узлов, кроме листовых.
for i in range(self.parent(self.size() - 1), -1, -1):
self.sift_down(i)
8.2.3. Анализ сложности
Теперь попытаемся оценить временную сложность второго метода построения кучи.
Предположим, что количество узлов в полном двоичном дереве равно
n, тогда количество листовых узлов равно (n + 1)/2, где «/» обозначает
целочисленное деление вниз. Следовательно, количество узлов, которые
необходимо упорядочить, равно (n − 1)/2.
В процессе упорядочивания сверху вниз каждый узел может быть упорядочен до листового узла, поэтому максимальное количество итераций
равно высоте двоичного дерева log n.
Умножив эти два значения, можно получить временную сложность процесса
построения кучи O(n log n). Однако эта оценка не точна, так как не учитывает, что количество узлов на нижних уровнях двоичного дерева значительно больше, чем на верхних.
Проведем более точный расчет. Чтобы упростить вычисления, предположим, что дано идеальное двоичное дерево с количеством узлов n и высотой h.
Это предположение не повлияет на правильность результата.
228
Куча
Высота узла
Количество узлов
на уровне
(т. е. максимальное
количество итераций
при упорядочивании)
(Примечание: высота листового узла равна 0)
Рис. 8.5. Количество узлов на каждом уровне идеального двоичного дерева
Как видно из рис. 8.5, максимальное количество итераций упорядочивания
сверху вниз для узла равно расстоянию от этого узла до листового узла, что
соответствует высоте узла. Таким образом, можно суммировать произведения
количество узлов × высота узла для каждого уровня, чтобы получить общее
количество итераций упорядочивания для всех узлов:
.
Для упрощения этого выражения воспользуемся знаниями из школьного
,
курса о последовательностях и умножим сначала T(h) на 2:
. ,
,
.
.
,
. 2T(h) уравнеИспользуя метод вычитания со сдвигом, вычтем из уравнения
ние T(h):
.
Можно заметить, что T(h) является геометрической прогрессией, и можно
использовать формулу ее суммы, чтобы получить временную сложность:
.
8.3. Поиск k наибольших элементов 229
Далее, количество узлов в идеальном двоичном дереве высоты h равно
n = 2h+1 − 1, отсюда легко получить сложность O(2h) = O(n). Эти вычисления
показывают, что временная сложность построения кучи из входного списка
составляет O(n), что очень эффективно.
8.3. Поиск k наибольших элементов
Задача
Дан неупорядоченный массив nums длиной n. Необходимо вернуть k наибольших элементов массива.
Для решения этой задачи сначала рассмотрим два простых подхода, а затем
представим более эффективный метод с использованием кучи.
8.3.1. Первый метод: выбор через обход
Можно выполнить k раундов обхода, извлекая в каждом раунде 1-й, 2-й, ...,
k-й по величине элемент, как показано на рис. 8.6. Временная сложность
этого алгоритма составляет O(nk). Этот метод подходит только для случаев,
когда k ≪ n, так как при k, близком к n, временная сложность стремится
к O(n2), что очень затратно по времени.
Обходы для поиска наибольших элементов
Первый обход: найден 1-й наибольший элемент
Второй обход: найден 2-й наибольший элемент
Третий обход: найден 3-й наибольший элемент
Рис. 8.6. Поиск k наибольших элементов через обход
Совет
Когда k = n, можно получить полностью упорядоченную последовательность,
что эквивалентно алгоритму сортировки выбором.
230
Куча
8.3.2. Второй метод: сортировка
Можно сначала отсортировать массив nums, а затем вернуть k самых правых
элементов, как показано на рис. 8.7. Временная сложность этого метода составляет O(n log n). Очевидно, что данный метод перевыполняет задачу, так
как необходимо найти лишь k наибольших элементов, а не сортировать все
остальные.
Сортировка массива
Возврат k правых элементов
Рис. 8.7. Поиск k наибольших элементов с помощью сортировки
8.3.3. Третий метод: куча
Задачу поиска k наибольших элементов можно решить более эффективно с помощью кучи (см. рис. 8.8).
1. Инициализация минимальной кучи, в которой корневой элемент является наименьшим.
2. Вначале в кучу помещаются первые k элементов массива.
3. Начиная с элемента k + 1, если текущий элемент больше корневого элемента, то корневой элемент извлекается из кучи, а текущий элемент помещается в кучу.
4. После завершения обхода в куче остаются k наибольших элементов.
8.3. Поиск k наибольших элементов 231
Вершина кучи
1. Поместить первые k элементов в кучу
Куча
Представление в виде
двоичного дерева
Представление
в виде массива
Шаг 1
Вершина кучи
6 2. Начиная с (k + 1)-го элемента:
7 Если Текущий элемент > Вершина кучи
8 Извлечь элемент с вершины кучи
9 Поместить текущий элемент в кучу
10 Если Текущий элемент ≤ Вершина кучи
11 Продолжить
1. Поместить первые k элементов
в кучу
Куча
12 Обход завершен, можно вернуть heap
Представление в виде
двоичного дерева
Представление
в виде массива
Шаг 2
Вершина кучи
6 2. Начиная с (k + 1)-го элемента:
7 Если Текущий элемент > Вершина кучи
8 Извлечь элемент с вершины кучи
9 Поместить текущий элемент в кучу
10 Если Текущий элемент ≤ Вершина кучи
11 Продолжить
1. Поместить первые k элементов
в кучу
12 Обход завершен, можно вернуть heap
Куча
Представление в виде
двоичного дерева
Представление
в виде массива
Шаг 3
Рис. 8.8. Поиск k наибольших элементов с помощью кучи. Шаги 1–3
6 2. Начиная с (k + 1)-го элемента:
7 Если Текущий элемент > Вершина кучи
232
Куча
Вершина кучи
1. Поместить первые k элементов в кучу
Куча
2. Начиная с (k + 1)-го элемента:
Если Текущий элемент > Вершина кучи
Представление в виде
двоичного дерева
Представление
в виде массива
Шаг 4
8 Извлечь элемент с вершины кучи
9 Поместить текущий элемент в кучуВершина кучи
10 Если Текущий элемент ≤ Вершина кучи
11 Продолжить
12 Обход завершен, можно вернуть heap
1. Поместить первые k элементов в кучу
Куча
2. Начиная с (k + 1)-го элемента:
Если Текущий элемент > Вершина кучи
Представление в виде
двоичного дерева
Представление
в виде массива
Извлечь элемент с вершины кучи
Шаг 5
8
9 Поместить текущий элемент в кучу
Вершина кучи
10 Если Текущий элемент ≤ Вершина кучи
11 Продолжить
12 Обход завершен, можно вернуть heap
1. Поместить первые k элементов в кучу
Куча
2. Начиная с (k + 1)-го элемента:
Если Текущий элемент > Вершина кучи
Извлечь элемент с вершины кучи
Поместить текущий элемент в кучу
Шаг 6
Рис. 8.8. Продолжение. Шаги 4–6
10 Если Текущий элемент ≤ Вершина кучи
11 Продолжить
12 Обход завершен, можно вернуть heap
Представление в виде
двоичного дерева
Представление
в виде массива
8.3. Поиск k наибольших элементов 233
Вершина кучи
1. Поместить первые k элементов в кучу
Куча
2. Начиная с (k + 1)-го элемента:
Если Текущий элемент ≤ Вершина кучи
Представление в виде
двоичного дерева
Представление
в виде массива
Шаг 7
ВершинаИзвлечь
кучи элемент с вершины кучи
Поместить текущий элемент в кучу
10
11 Продолжить
Если Текущий элемент > Вершина кучи
12 Обход завершен, можно вернуть heap
1. Поместить первые k элементов в кучу
Куча
2. Начиная с (k + 1)-го элемента:
Если Текущий элемент ≤ Вершина кучи
Представление в виде
двоичного дерева
Представление
в виде массива
Продолжить
Шаг 8
Обход завершен, можно вернуть heap
Вершина кучи
Обход завершен, можно вернуть heap
Куча
Шаг 9
Рис. 8.8. Окончание. Шаги 7–9
Представление в виде
двоичного дерева
Представление
в виде массива
234
Куча
Ниже приведен пример кода.
# === File: top_k.py ===
def top_k_heap(nums: list[int], k: int) -> list[int]:
""" Поиск k наибольших элементов в массиве на основе кучи."""
# Инициализация минимальной кучи.
heap = []
# Помещение первых k элементов массива в кучу.
for i in range(k):
heapq.heappush(heap, nums[i])
# Начиная с элемента k+1, поддержание длины кучи равной k.
for i in range(k, len(nums)):
# Если текущий элемент больше корневого элемента, то извлечение корневого элемента и помещение текущего элемента в кучу.
if nums[i] > heap[0]:
heapq.heappop(heap)
heapq.heappush(heap, nums[i])
return heap
Всего выполняется n операций помещения и извлечения из кучи, максимальная длина кучи равна k, поэтому временная сложность составляет O(n log k).
Этот метод очень эффективен: когда k мало, временная сложность стремится
к O(n). Когда k велико, временная сложность не превышает O(n log n).
Кроме того, этот метод подходит для использования в сценариях с динамическими потоками данных. При постоянном добавлении данных можно постоянно поддерживать элементы в куче, что позволяет динамически обновлять k наибольших элементов.
8.4. Резюме
1. Ключевые моменты
Куча представляет собой полное двоичное дерево и может быть двух типов: максимальной и минимальной. Корневой элемент максимальной
(минимальной) кучи является наибольшим (наименьшим).
Очередь с приоритетом определяется как очередь с приоритетом извлечения, обычно реализуемая с помощью кучи.
Основные операции с кучей и их временная сложность включают: помещение элемента в кучу O(log n), извлечение корневого элемента O(log
n) и доступ к корневому элементу O(1).
Совершенное двоичное дерево удобно представлять в виде массива, поэтому обычно для хранения кучи используется массив.
Операция упорядочивания кучи используется для поддержания свойств
кучи и применяется при операциях помещения и извлечения.
Временную сложность построения кучи из n элементов можно оптимизировать до O(n), что очень эффективно.
Задача поиска k наибольших элементов является классической алгоритмической задачей и может быть эффективно решена с использованием
кучи с временной сложностью O(n log k).
8.4. Резюме 235
2. Вопросы и ответы
Вопрос. Является ли «куча» в структуре данных тем же понятием, что и «куча»
в управлении памятью?
Ответ. Это не одно и то же понятие, хотя они по случайному стечению обстоятельств и имеют одинаковое название. Куча в памяти компьютерной системы
является частью динамического распределения памяти, которую программа
может использовать для хранения данных во время выполнения. Программа
может запросить определенное количество памяти в куче для хранения таких
сложных структур, как объекты и массивы. Когда эти данные больше не нужны, программа должна освободить эту память, чтобы предотвратить утечку
памяти. В отличие от стека управление и использование памяти в куче требует
большей осторожности, неправильное использование может привести к утечкам памяти и проблемам с указателями.
Глава 9
Графы
Абстракция
В путешествии по жизни мы подобны узлам, соединенным множеством невидимых связей.
Каждое знакомство и расставание оставляет уникальный след в этой огромной сетевой структуре.
9.1. Графы 237
9.1. Графы
Граф – это нелинейная структура данных, состоящая из вершин и ребер.
Граф G можно абстрактно представить как множество вершин V и множество
ребер E. Ниже приведен пример графа, содержащего 5 вершин и 7 ребер:
V = {1, 2, 3, 4, 5}
E = {(1, 2), (1, 3), (1, 5), (2, 3), (2, 4), (2, 5), (4, 5)}
G = {V, E}.
Если рассматривать вершины как узлы, а ребра как ссылки (указатели),
соединяющие узлы, то граф можно рассматривать как расширенный список. По сравнению с линейными отношениями (список) и отношениями разделения (дерево), сетевые отношения (граф) обладают большей свободой и, следовательно, являются более сложными, как показано
на рис. 9.1.
Связный список
(Линейные отношения)
Дерево
(Отношения разделения)
Граф
(Сетевые отношения)
Граф имеет наивысшую логическую свободу и, следовательно,
является наиболее сложной структурой
Рис. 9.1. Связь между списком, деревом и графом
9.1.1. Основные типы и понятия графов
В зависимости от наличия направления у ребер графы делятся на неориентированные и ориентированные, как показано на рис. 9.2.
В неориентированном графе ребро представляет собой двустороннюю
связь между двумя вершинами, например дружеские отношения в социальных сетях.
В ориентированном графе ребро имеет направление. То есть ребра A → B
и A ← B независимы друг от друга, например отношения подписки–подписчики.
238
Графы
Неориентированный граф
(Ребра не имеют направления)
Ориентированный граф
(Ребра имеют направление)
Рис. 9.2. Ориентированный и неориентированный графы
Если все вершины связаны, то граф называется связным, иначе – несвязным, как показано на рис. 9.3.
В связном графе из любой вершины можно достичь любой другой вершины.
В несвязном графе существуют по крайней мере две вершины, между которыми нет пути.
Связный граф
(Все вершины достижимы)
Несвязный граф
(Существуют недостижимые вершины)
Рис. 9.3. Связный и несвязный графы
Можно также добавить к ребрам переменную «вес», получив взвешенный граф,
как показано на рис. 9.4. Например, в мобильных играх, таких как Honor of Kings,
система рассчитывает близость между игроками на основе времени совместной
игры. Такую сеть близости можно представить в виде взвешенного графа.
Со структурой данных графа связаны следующие основные понятия.
Смежность: если между двумя вершинами существует ребро, они называются смежными. На рис. 9.4 вершины, смежные с вершиной 1, – это
вершины 2, 3 и 5.
Путь: последовательность ребер от вершины A до вершины B называется путем от A до B. На рис. 9.4 последовательность ребер 1‑5‑2‑4 является
путем от вершины 1 до вершины 4.
Степень: количество ребер, присоединенных к вершине. Для ориентированного графа входящая степень показывает, сколько ребер ведет
к данной вершине, а исходящая степень показывает, сколько ребер выходит из данной вершины.
9.1. Графы 239
Взвешенный граф
(Ребра имеют вес)
Невзвешенный граф
(Все ребра эквивалентны)
Рис. 9.4. Взвешенный и невзвешенный графы
9.1.2. Представление графа
Графы можно представить с помощью матрицы смежности и списка смежности. Рассмотрим пример с неориентированным графом.
1. Матрица смежности
Пусть количество вершин графа равно n, матрица смежности представляет граф в виде матрицы размером n×n, где каждая строка (столбец) соответствует вершине, а элементы матрицы обозначают наличие ребра. Значение 1 соответствует наличию ребра между двумя вершинами, значение
0 – отсутствию.
Обозначим матрицу смежности как M, а список вершин как V. Тогда элемент
матрицы M[i, j] = 1 указывает на наличие ребра между вершинами V[i] и V[j],
в противном случае элемент матрицы M[i, j] = 0, см. рис. 9.5.
Список вершин
Индексы
Главная диагональ
Граф
Матрица смежности
Рис. 9.5. Представление графа с помощью матрицы смежности
240
Графы
Матрица смежности обладает следующими свойствами.
В простом графе вершина не может быть соединена с самой собой, поэтому элементы на главной диагонали матрицы смежности не имеют
значения.
Для неориентированного графа ребра в обоих направлениях эквивалентны, поэтому матрица смежности симметрична относительно главной диагонали.
Заменив элементы матрицы смежности с 1 и 0 на веса ребер, можно
представить взвешенный граф.
Используя матрицу смежности для представления графа, можно напрямую
обращаться к элементам матрицы для получения информации о ребрах, что
делает операции добавления, удаления, поиска и изменения достаточно эффективными с временной сложностью O(1). Однако пространственная сложность матрицы составляет O(n2), что требует значительных затрат памяти.
2. Список смежности
Список смежности представляет граф с помощью n списков, где узлы списка
представляют вершины. i-й список соответствует вершине i и содержит все
смежные вершины (вершины, соединенные с данной вершиной). На рис. 9.6
показан пример графа, представленного с помощью списка смежности.
Вершины
Граф
Все вершины,
смежные с данной
Список смежности
Рис. 9.6. Представление графа с помощью списка смежности
В списке смежности хранятся только существующие ребра, а общее количество ребер обычно значительно меньше n2, что делает его более экономичным
по памяти. Однако для поиска ребра в списке смежности необходимо просматривать список, что делает его менее эффективным по времени по сравнению
с матрицей смежности.
Как видно из рис. 9.6, структура списка смежности очень похожа на
цепную адресацию в хеш-таблицах, поэтому можно использовать ана-
9.2. Основные операции с графами 241
логичные методы для оптимизации эффективности. Например, если
список длинный, его можно преобразовать в АВЛ-дерево или красно-черное
дерево, чтобы повысить временную эффективность с O(n) до O(log n). Также можно преобразовать список в хеш-таблицу, чтобы снизить временную
сложность до O(1).
9.1.3. Типичные сценарии применения графов
Многие реальные системы можно моделировать с помощью графов, а соответствующие задачи могут быть сведены к задачам вычисления на графах, см.
табл. 9.1.
Таблица 9.1. Графы, встречающиеся в реальной жизни
Вершина
Ребро
Задача вычисления
на графе
Социальные сети
Пользователи
Дружеские связи
Рекомендации потенциальных друзей
Линии метро
Станции
Связь между станциями
Рекомендации по кратчайшему маршруту
Солнечная система
Небесные тела
Взаимодействие гравитации между телами
Расчет орбит планет
9.2. Основные операции с графами
Основные операции с графами можно разделить на операции с ребрами и операции с вершинами. В зависимости от способа представления (матрица смежности или список смежности) реализация будет различаться.
9.2.1. Реализация на основе матрицы смежности
Ниже приведены операции для заданного неориентированного графа с количеством вершин n. Способы реализации показаны на рис. 9.7.
Добавление или удаление ребра: достаточно изменить соответствующее ребро в матрице смежности за время O(1). Поскольку граф неориентированный, необходимо обновить ребра в обоих направлениях.
Добавление вершины: в конец матрицы смежности добавляется
строка и столбец, которые заполняются нулями. Временная сложность
равна O(n).
Удаление вершины: удаляется строка и столбец из матрицы смежности. В худшем случае при удалении первой строки и столбца необходимо переместить (n − 1)2 элементов влево вверх, что занимает
время O(n2).
Инициализация: передается n вершин, инициализируется список вершин vertices длиной n за время O(n). Инициализируется матрица смежности adjMat размером n×n за время O(n2).
242
Графы
Список вершин
Индексы
Инициализация
графа
Шаг 1
Матрица смежности
Граф
6 Добавление ребра 1-2Список вершин
7 Удаление ребра 1-3
Индексы
8 Добавление вершины 6
9 Удаление вершины 3
Добавление
ребра
Шаг 2
Матрица смежности
Граф
7 Удаление ребра 1-3
Список вершин
8 Добавление вершины 6
Индексы
9 Удаление вершины 3
Удаление
ребра
Шаг 3
Граф
Матрица смежности
Рис. 9.7. Инициализация матрицы смежности, добавление и удаление ребер и вершин. Шаги 1–3
8 Добавление вершины 6
9 Удаление вершины 3
9.2. Основные операции с графами 243
Список вершин
Индексы
Добавление
вершины
Шаг 4
Граф
Матрица смежности
Удаление вершины
3 вершин
Список
Индексы
Удаление
вершины
Шаг 5
Граф
Матрица смежности
Рис. 9.7. Окончание. Шаги 4–5
Ниже приведен код реализации графа на основе матрицы смежности.
# === File: graph_adjacency_matrix.py ===
class GraphAdjMat:
""" Класс неориентированного графа на основе матрицы смежности."""
def __init__(self, vertices: list[int], edges: list[list[int]]):
""" Конструктор."""
# Список вершин, элемент представляет "значение вершины", индекс представляет "индекс вершины".
self.vertices: list[int] = []
# Матрица смежности, индексы строк и столбцов соответствуют
# "индексу вершины".
self.adj_mat: list[list[int]] = []
244
Графы
# Добавление вершин.
for val in vertices:
self.add_vertex(val)
# Добавление ребер.
# Обратите внимание: элементы edges представляют индексы вершин,
# т. е. соответствуют индексам элементов vertices.
for e in edges:
self.add_edge(e[0], e[1])
def size(self) -> int:
""" Получение количества вершин."""
return len(self.vertices)
def add_vertex(self, val: int):
""" Добавление вершины."""
n = self.size()
# Добавление нового значения вершины в список вершин.
self.vertices.append(val)
# Добавление строки в матрицу смежности.
new_row = [0] * n
self.adj_mat.append(new_row)
# Добавление столбца в матрицу смежности.
for row in self.adj_mat:
row.append(0)
def remove_vertex(self, index: int):
""" Удаление вершины."""
if index >= self.size():
raise IndexError()
# Удаление вершины с индексом index из списка вершин.
self.vertices.pop(index)
# Удаление строки с индексом index из матрицы смежности.
self.adj_mat.pop(index)
# Удаление столбца с индексом index из матрицы смежности.
for row in self.adj_mat:
row.pop(index)
def add_edge(self, i: int, j: int):
""" Добавление ребра."""
# Параметры i и j соответствуют индексам элементов vertices.
# Обработка выхода за границы индексов и равенства.
if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j:
raise IndexError()
# В неориентированном графе матрица смежности симметрична
# относительно главной диагонали, т. е. (i, j) == (j, i).
self.adj_mat[i][j] = 1
self.adj_mat[j][i] = 1
def remove_edge(self, i: int, j: int):
""" Удаление ребра."""
# Параметры i и j соответствуют индексам элементов vertices.
9.2. Основные операции с графами 245
# Обработка выхода за границы индексов и равенства.
if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j:
raise IndexError()
self.adj_mat[i][j] = 0
self.adj_mat[j][i] = 0
def print(self):
""" Печать матрицы смежности."""
print(" Список вершин =", self.vertices)
print(" Матрица смежности =")
print_matrix(self.adj_mat)
9.2.2. Реализация на основе списка смежности
Ниже приведены описания операций для неориентированного графа с общим
количеством вершин n и ребер m. Способы реализации показаны на рис. 9.8.
Добавление ребра: достаточно добавить ребро в конец связного списка,
соответствующего вершине за время O(1). Поскольку граф неориентированный, необходимо добавить ребра в обоих направлениях.
Удаление ребра: необходимо найти и удалить указанное ребро в связном списке, соответствующем вершине, за время O(m). В неориентированном графе необходимо удалить ребра в обоих направлениях.
Добавление вершины: добавляется связный список в список смежности, а новая вершина становится головным узлом списка. Требуется
время O(1).
Удаление вершины: необходимо пройтись по всему списку смежности
и удалить все ребра, содержащие указанную вершину. Требуется время
O(n + m).
Инициализация: в списке смежности создается n вершин и 2m ребер за
время O(n + m).
Вершины
Все вершины, смежные
с данной
Инициализация
графа
Список смежности
Шаг 1
Граф
Рис. 9.8. Инициализация списка смежности, добавление и удаление ребер и вершин.
6 Добавление ребра 1-2Шаг 1
7 Удаление ребра 1-3
8 Добавление вершины 6
9 Удаление вершины 3
246
Графы
Вершины
Все вершины, смежные
с данной
Добавление
ребра
Список смежности
Шаг 2
Граф
7 Удаление ребра 1-3
8 Добавление вершины 6
9 Удаление вершины 3
Вершины
Все вершины, смежные
с данной
Удаление
ребра
Список смежности
Шаг 3
Граф
Добавление вершины 6
9 Удаление вершины 3
Вершины
Все вершины, смежные
с данной
Добавление
вершины
Список смежности
Шаг 4
Граф
Рис. 9.8. Продолжение. Шаг 2–4
6
9 Удаление вершины 3
9.2. Основные операции с графами 247
Вершины
Все вершины, смежные
с данной
Удаление
вершины
Список смежности
Шаг 5
Граф
Рис. 9.8. Окончание. Шаг 5
6
Ниже приведен код
реализации
смежности. По сравнению с рис. 9.8
9 Удаление
вершинысписка
3
код имеет следующие отличия:
для удобства добавления и удаления вершин, а также упрощения кода
вместо связного списка используется список (динамический массив);
для хранения списка смежности используется хеш-таблица, где ключом
является экземпляр вершины, а значением – список смежных вершин
(связный список).
Кроме того, в списке смежности используется класс Vertex для представления вершин. Это сделано потому, что если, как в случае с матрицей смежности,
использовать индексы списка для различения различных вершин, то при удалении вершины с индексом i необходимо пройтись по всему списку смежности и уменьшить на 1 все индексы, большие i, что крайне неэффективно. Если
же каждая вершина является уникальным экземпляром класса Vertex, то после
удаления одной вершины не требуется изменять другие вершины.
# === File: graph_adjacency_list.py ===
class GraphAdjList:
""" Класс неориентированного графа на основе списка смежности."""
def __init__(self, edges: list[list[Vertex]]):
""" Конструктор."""
# Список смежности, ключ: вершина, значение: все смежные вершины данной
вершины.
self.adj_list = dict[Vertex, list[Vertex]]()
# Добавление всех вершин и ребер.
for edge in edges:
self.add_vertex(edge[0])
self.add_vertex(edge[1])
self.add_edge(edge[0], edge[1])
248
Графы
def size(self) -> int:
""" Получение количества вершин."""
return len(self.adj_list)
def add_edge(self, vet1: Vertex, vet2: Vertex):
""" Добавление ребра."""
if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2:
raise ValueError()
# Добавление ребра vet1 - vet2
self.adj_list[vet1].append(vet2)
self.adj_list[vet2].append(vet1)
def remove_edge(self, vet1: Vertex, vet2: Vertex):
""" Удаление ребра."""
if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2:
raise ValueError()
# Удаление ребра vet1 - vet2.
self.adj_list[vet1].remove(vet2)
self.adj_list[vet2].remove(vet1)
def add_vertex(self, vet: Vertex):
""" Добавление вершины."""
if vet in self.adj_list:
return
# В списке смежности добавляется новый список.
self.adj_list[vet] = []
def remove_vertex(self, vet: Vertex):
""" Удаление вершины."""
if vet not in self.adj_list:
raise ValueError()
# В списке смежности удаляется список, соответствующий вершине vet.
self.adj_list.pop(vet)
# Обход списков других вершин, удаление всех ребер, содержащих vet.
for vertex in self.adj_list:
if vet in self.adj_list[vertex]:
self.adj_list[vertex].remove(vet)
def print(self):
""" Печать списка смежности."""
print(" Список смежности =")
for vertex in self.adj_list:
tmp = [v.val for v in self.adj_list[vertex]]
print(f"{vertex.val}: {tmp},")
9.3. Обход графа 249
9.2.3. Сравнение эффективности
Пусть дан граф с n вершинами и m ребрами. В табл. 9.2 приведено сравнение временной и пространственной сложности матрицы смежности и списка
смежности.
Таблица 9.2. Сравнение матрицы и списка смежности
Операция
Матрица
смежности
Список смежности
(связный список)
Список смежности
(хеш-таблица)
Проверка смежности
O(1)
O(m)
O(1)
Добавление ребра
O(1)
O(1)
O(1)
Удаление ребра
O(1)
O(m)
O(1)
Добавление вершины
O(n)
O(1)
O(1)
Удаление вершины
O(n²)
O(n + m)
O(n)
Занимаемое пространство
O(n²)
O(n + m)
O(n + m)
Из табл. 9.2 видно, что временная и пространственная эффективность списка смежности (хеш-таблица) наиболее оптимальна. Однако на практике операции с ребрами в матрице смежности более эффективны, так как требуют
лишь одного доступа или присвоения в массиве. В целом матрица смежности
реализует принцип обмена пространства на время, тогда как список смежности – обмена времени на пространство.
9.3. Обход графа
Дерево представляет собой отношение «один ко многим», тогда как граф обладает большей степенью свободы и может представлять произвольные отношения «многие ко многим». Таким образом, дерево можно рассматривать как
частный случай графа. Очевидно, что операции обхода дерева также являются частным случаем обхода графа.
И графы, и деревья требуют применения алгоритмов поиска для реализации
операций обхода. Способы обхода графа можно разделить на два типа: обход
в ширину и обход в глубину.
9.3.1. Обход в ширину
Обход в ширину (BFS) – это метод обхода от ближнего к дальнему, начиная с определенного узла, с посещением в первую очередь ближайших
вершин с постепенным расширением наружу. Начиная с левого верхнего
угла, сначала обходятся все смежные вершины текущей вершины, затем все
смежные вершины следующей вершины и т. д., пока не будут посещены все
вершины, как показано на рис. 9.9.
250
Графы
Обход графа в ширину (BFS)
Начиная с вершины
последовательно слой за слоем посещать
вершины от ближних к дальним
Последовательность обхода:
Рис. 9.9. Обход графа в ширину
1. Реализация алгоритма
Обход в ширину обычно реализуется с помощью очереди, код реализации приведен ниже. Очередь обладает свойством «первый пришел – первый вышел»,
что соответствует идее обхода в ширину «от ближнего к дальнему». Алгоритм
следующий:
1) добавить начальную вершину обхода startVet в очередь и начать цикл;
2) на каждой итерации цикла извлекать вершину из головы очереди и записывать посещение, затем добавлять все смежные вершины этой вершины в хвост очереди;
3) повторять шаг 2, пока не будут посещены все вершины.
Чтобы избежать повторного обхода вершин, необходимо использовать хешмножество visited для записи посещенных узлов.
Совет
Хеш-множество можно рассматривать как хеш-таблицу, которая хранит
только ключи, но не значения и позволяет выполнять операции добавления, удаления, поиска и изменения ключей за O(1). Благодаря уникальности ключей хеш-множество часто используется для удаления дубликатов
данных.
# === File: graph_bfs.py ===
def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]:
""" Обход в ширину."""
# Использование списка смежности для представления графа, чтобы получить
все смежные вершины текущей вершины.
# Последовательность обхода вершин.
res = []
# Хеш-множество для записи уже посещенных вершин.
visited = set[Vertex]([start_vet])
9.3. Обход графа 251
# Очередь для реализации поиска в ширину.
que = deque[Vertex]([start_vet])
# Начало с вершины vet; цикл до тех пор, пока не будут посещены все вершины.
while len(que) > 0:
vet = que.popleft() # Вершина извлекается из головы очереди.
res.append(vet) # Запись посещенной вершины.
# Обход всех смежных вершин этой вершины.
for adj_vet in graph.adj_list[vet]:
if adj_vet in visited:
continue # Пропуск уже посещенных вершин.
que.append(adj_vet) # В очередь добавляются только
# непосещенные вершины.
visited.add(adj_vet) # Отметка, что вершина была посещена.
# Возврат последовательности обхода вершин.
return res
Код относительно абстрактен, рекомендуется обратиться к рис. 9.10 для
более глубокого понимания.
Является ли последовательность обхода в ширину уникальной?
Нет, не является. Обход в ширину требует только следовать порядку «от ближнего к дальнему», а порядок обхода вершин на одинаковом расстоянии
может быть произвольно изменен. Например, на рис. 9.10 порядок посещения вершин 1 и 3 можно поменять местами, как и порядок посещения вершин 2, 4 и 6.
Последовательность обхода res
Очередь que
Выход
из очереди
Вход
в очередь
Хеш-множество visited
Инициализация res, que, visited
Шаг 1
Рис. 9.10. Этапы обхода графа в ширину. Шаг 1
7 Вершина 0 извлекается из очереди, и выполняются следующие действия
8 1. Добавление этой вершины в последовательность обхода res
9 2. Добавление всех смежных вершин этой вершины в очередь que и в хеш-множество visited
252
Графы
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 2
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
Рис. 9.10. Продолжение. Шаги 2–3
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 3
9.3. Обход графа 253
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 4
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
Рис. 9.10. Продолжение. Шаги 4–5
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 5
254
Графы
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 6
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
Рис. 9.10. Продолжение. Шаги 6–7
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 7
9.3. Обход графа 255
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 8
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
Рис. 9.10. Продолжение. Шаги 8–9
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 9
256
Графы
Последовательность обхода res
Очередь que
Выход
из очереди
Вершина
извлекается из очереди,
Хеш-множество visited
и выполняются следующие действия:
1. Добавление этой вершины
в последовательность обхода res
2. Добавление всех смежных вершин этой
вершины в очередь que
и в хеш-множество visited
10 Если очередь que пуста, завершение обхода
Вход
в очередь
Шаг 10
Последовательность обхода res
Очередь que
Выход
из очереди
Вход
в очередь
Хеш-множество visited
Если очередь que пуста,
завершение обхода
Шаг 11
Рис. 9.10. Окончание. Шаги 10–11
2. Анализ сложности
Временная сложность: все вершины будут добавляются в очередь и удаляются из нее ровно один раз, что требует времени O(|V|). В процессе обхода смежных вершин, поскольку граф неориентированный, все ребра будут посещены
дважды, что занимает время O(2|E|). В целом требуется время O(|V| + |E|).
Пространственная сложность: список res, хеш-множество visited и количество вершин в очереди que максимум равны |V|, что требует пространства O(|V|).
9.3. Обход графа 257
9.3.2. Обход в глубину
Обход в глубину (DFS) – это метод обхода, при котором сначала исследуются все возможные пути до самого конца, а затем происходит
возврат. Начиная с левой верхней вершины, посещается какая-либо смежная вершина текущей вершины, пока не будет достигнут конец пути, после
чего происходит возврат и опять продолжается обход до конца. Продолжаем процесс и так далее, пока все вершины не будут посещены, как показано
на рис. 9.11.
Обход графа в глубину (DFS)
Начиная с вершины
дойти до конца, затем вернуться
и снова дойти до конца, и т. д...
Последовательность обхода:
Рис. 9.11. Обход графа в глубину
1. Реализация алгоритма
Этот алгоритмический подход «до конца и назад» обычно реализуется с помощью рекурсии. Подобно обходу в ширину, в обходе в глубину также необходимо использовать хеш-множество visited для записи уже посещенных вершин,
чтобы избежать их повторного посещения.
# === File: graph_dfs.py ===
def dfs(graph: GraphAdjList, visited: set[Vertex], res: list[Vertex], vet: Vertex):
""" Вспомогательная функция для обхода в глубину."""
res.append(vet) # Запись посещенной вершины.
visited.add(vet) # Пометка вершины как посещенной.
# Обход всех смежных вершин текущей вершины.
for adjVet in graph.adj_list[vet]:
if adjVet in visited:
continue # Пропуск уже посещенной вершины.
# Рекурсивное посещение смежной вершины.
dfs(graph, visited, res, adjVet)
def graph_dfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]:
""" Обход в глубину."""
# Использование списка смежности для представления графа, чтобы получить
все смежные вершины текущей вершины.
258
Графы
# Последовательность обхода вершин.
res = []
# Хеш-множество для записи уже посещенных вершин.
visited = set[Vertex]()
dfs(graph, visited, res, start_vet)
return res
Алгоритм обхода в глубину показан на рис. 9.12.
Прямые пунктирные линии обозначают нисходящую рекурсию, указывая на начало нового рекурсивного метода для посещения новой вершины.
Изогнутые пунктирные линии обозначают восходящую рекурсию,
указывая на возврат данного рекурсивного метода к месту его начала.
Для лучшего понимания рекомендуется на примере рис. 9.12 и кода реализации мысленно (или с помощью рисунка) смоделировать весь процесс обхода
в глубину, включая моменты начала и возврата каждого рекурсивного метода.
Последовательность обхода res
Хеш-множество visited
Шаг 1
Нисходящая
рекурсия:
Посетить
вершину
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 2
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Рис. 9.12. Этапы обхода графа в глубину. Шаги 1–2
9.3. Обход графа 259
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 3
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 4
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 5
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Рис. 9.12. Продолжение. Шаги 3–5
260
Графы
Последовательность обхода res
Хеш-множество visited
Восходящая рекурсия:
Все вершины, смежные с
Шаг 6
Возврат к вершине
, посещены
5
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 7
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Последовательность обхода res
Хеш-множество visited
Восходящая рекурсия:
Все вершины, смежные с
Шаг 8
Возврат к вершине
Рис. 9.12. Продолжение. Шаги 6–8
5
, посещены
9.3. Обход графа 261
Последовательность обхода res
Хеш-множество visited
Восходящая рекурсия:
Все вершины, смежные с
Шаг 9
Возврат к вершине
5
И т. д . до вершины
0
, посещены
Последовательность обхода res
Хеш-множество visited
Нисходящая рекурсия:
Шаг 10
Обойти вершины, смежные с
Рекурсивно перейти
к вершине
Последовательность обхода res
Хеш-множество visited
Восходящая рекурсия:
Все вершины, смежные с
Возврат к вершине
В вершине
Шаг 11
0
, посещены
5
также происходит возврат,
что означает завершение обхода
Рис. 9.12. Окончание. Шаги 9–11
262
Графы
Является ли последовательность обхода в глубину уникальной?
Подобно обходу в ширину, порядок последовательности обхода в глубину также не является уникальным. Для заданной вершины можно сначала исследовать любое направление, т. е. порядок смежных вершин может быть произвольным, и это все равно будет обход в глубину.
Например, обходы дерева корень → левый → правый, левый → корень → правый и левый → правый → корень соответствуют прямому, симметричному
и обратному обходам, демонстрируя три приоритета обхода, однако все они
относятся к обходу в глубину.
2. Анализ сложности
Временная сложность: все вершины будут посещены один раз, что требует
времени O(|V|). Все ребра будут посещены дважды, что требует времени O(2|E|).
В целом требуется время O(|V| + |E|).
Пространственная сложность: список res и хеш-множество visited имеют
максимум |V| вершин, максимальная глубина рекурсии равна |V|, следовательно, требуется пространство O(|V|).
9.4. Резюме
1. Ключевые моменты
Граф состоит из вершин и ребер, его можно задать как множество вершин и множество ребер.
По сравнению с линейными отношениями (список) и отношениями разделения (дерево), сетевые отношения (граф) обладают большей степенью свободы и, следовательно, более сложны.
Ребра ориентированного графа имеют направленность, в связном графе
любые вершины достижимы, во взвешенном графе каждое ребро содержит переменную веса.
Матрица смежности использует матрицу для представления графа, каждая строка (столбец) представляет вершину, элементы матрицы представляют ребра. Значение 1 соответствует наличию ребра между двумя
вершинами, значение 0 – отсутствию. Матрица смежности эффективна в операциях добавления, удаления, поиска и изменения, но требует
больше пространства.
Список смежности использует несколько списков для представления
графа, i-й список соответствует вершине i и хранит все смежные вершины этой вершины. Список смежности экономнее по сравнению с матрицей смежности, но из-за необходимости обхода списка для поиска ребра
его временная эффективность ниже.
Когда списки в списке смежности становятся слишком длинными, их
можно преобразовать в красно-черное дерево или хеш-таблицу для повышения эффективности поиска.
9.4. Резюме 263
С точки зрения алгоритмических подходов матрица смежности реализует принцип обмена пространства на время, а список смежности – обмена
времени на пространство.
Графы используются для моделирования различных реальных систем,
таких как социальные сети, линии метро и т. д.
Дерево является частным случаем графа, а обход дерева – частным случаем обхода графа.
Обход графа в ширину (BFS) представляет собой метод поиска, который
расширяется от ближних к дальним уровням, обычно реализуется с помощью очереди.
Обход графа в глубину (DFS) – это метод поиска, который сначала проходит до конца, а затем отступает назад, когда дальнейшего пути нет, часто
реализуется на основе рекурсии.
2. Вопросы и ответы
Вопрос. Путь – это последовательность вершин или ребер?
Ответ. В разных языковых версиях «Википедии» определения различаются: в английской версии путь – это последовательность ребер, а в русской
версии путь – это последовательность вершин. Приведем оригинальный текст
английской версии: In graph theory, a path in a graph is a finite or infinite sequence of edges which joins a sequence of vertices.
В этой книге путь рассматривается как последовательность ребер, а не вершин. Это связано с тем, что между двумя вершинами может существовать несколько соединяющих ребер, и каждое из них соответствует отдельному пути.
Вопрос. Могут ли существовать в несвязном графе недостижимые вершины?
Ответ. В несвязном графе существует по крайней мере две вершины – такие, что одна не достижима из другой. Для обхода несвязного графа необходимо установить несколько начальных точек, чтобы обойти все связные компоненты графа.
Вопрос. Существует ли в списке смежности требование к выбору порядка
всех вершин, связанных с данной вершиной?
Ответ. Порядок может быть произвольным. Однако на практике может потребоваться сортировка по определенным правилам, например в порядке добавления вершин или в порядке значений вершин, что помогает быстро находить вершины с определенным экстремумом.
Глава 10
Поиск
Абстракция
Поиск – это увлекательное приключение, в котором, возможно, придется
обойти каждый уголок загадочного пространства, а может быть, удастся быстро найти цель.
В этом путешествии каждый шаг может привести к неожиданному ответу.
10.1. Двоичный поиск 265
10.1. Двоичный поиск
Двоичный (бинарный) поиск – это эффективный алгоритм поиска, основанный на стратегии «разделяй и властвуй». Он использует упорядоченность данных, сокращая на каждом шаге область поиска вдвое, пока не будет найден
целевой элемент или область поиска не станет пустой.
Задача
Дан массив nums длиной n, элементы которого упорядочены по возрастанию и не повторяются. Необходимо найти и вернуть индекс элемента target
в этом массиве. Если массив не содержит данного элемента, вернуть −1. Пример приведен на рис. 10.1.
Элемент
Индекс
Гистограмма для представления массива
Ввод отсортированного массива nums, поиск целевого
элемента target = 6
Рис. 10.1. Пример данных для двоичного поиска
Как показано на рис. 10.2, сначала инициализируются указатели i = 0
и j = n − 1, которые указывают на первый и последний элементы массива
и представляют область поиска [0, n − 1]. Обратите внимание, что квадратные
скобки обозначают замкнутый интервал, включающий граничные значения.
Затем в цикле выполняются следующие два шага:
1) вычисляется индекс средней точки m = ⌊(i + j)/2⌋, где ⌊ ⌋ обозначает операцию округления вниз;
2) определяется соотношение между nums[m] и target, выделяются три случая:
если nums[m] < target, то target находится в интервале [m + 1, j], поэтому
выполняется i = m + 1;
если nums[m] > target, то target находится в интервале [i, m − 1], поэтому
выполняется j = m − 1;
если nums[m] = target, то target найден, и возвращается индекс m.
Если массив не содержит целевой элемент, область поиска в конечном итоге
сократится до пустой. В этом случае возвращается −1.
266
Поиск
Элемент
Индекс
Шаг 1
Инициализация индексов i, j, указывающих на первый и последний
элементы массива; они представляют замкнутый интервал [i, j]
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 2
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 3
nums[m] > target
Выполнить j = m – 1 для сужения интервала поиска
Рис. 10.2. Процесс двоичного поиска. Шаги 1–3
10.1. Двоичный поиск 267
Элемент
Индекс
Цикл двоичного поиска:
Шаг 4
1. Вычисление средней точки m = (i + j)/2
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
nums[m] < target
Выполнить i = m + 1 для сужения интервала поиска
Шаг 5
Элемент
Индекс
Цикл двоичного поиска:
Шаг 6
1. Вычисление средней точки m = (i + j)/2
Рис. 10.2. Продолжение. Шаги 4–6
268
Поиск
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 7
nums[m] == target
Возврат индекса m
Рис. 10.2. Окончание. Шаг 7
Следует отметить, что, поскольку i и j имеют тип int, сумма i + j может
превысить допустимый диапазон значений типа int. Чтобы избежать
переполнения, обычно для вычисления средней точки используется формула
m = ⌊i + (j − i)/2⌋.
Ниже приведен пример кода.
# === File: binary_search.py ===
def binary_search(nums: list[int], target: int) -> int:
""" Двоичный поиск (двойной замкнутый интервал)."""
# Инициализация двойного замкнутого интервала [0, n-1], i и j указывают
# на первый и последний элементы массива.
i, j = 0, len(nums) - 1
# Цикл, выход при пустом интервале поиска (когда i > j).
while i <= j:
# Теоретически числа в Python могут быть бесконечно большими (зависит
# от объема памяти), и нет необходимости учитывать переполнение.
m = (i + j) // 2 # Вычисление индекса средней точки m.
if nums[m] < target:
i = m + 1 # В этом случае target находится в интервале [m+1, j].
elif nums[m] > target:
j = m - 1 # В этом случае target находится в интервале [i, m-1].
else:
return m # Найден целевой элемент, возвращается его индекс.
return -1 # Целевой элемент не найден, возвращается -1.
Временная сложность составляет O(log n): в цикле двоичного поиска область поиска сокращается вдвое на каждом шаге, поэтому количество итераций равно log2 n.
Пространственная сложность составляет O(1): указатели i и j занимают
постоянное количество памяти.
10.1. Двоичный поиск 269
10.1.1. Методы представления интервалов
Кроме указанного выше двойного замкнутого интервала, существует также левозамкнутый правооткрытый интервал [0, n), т. е. левая граница включается,
а правая – нет. В этом представлении интервал [i, j) пуст, когда i = j.
На основе этого представления можно реализовать двоичный поиск с аналогичной функциональностью.
# === File: binary_search.py ===
def binary_search_lcro(nums: list[int], target: int) -> int:
""" Двоичный поиск (левозамкнутый правооткрытый интервал)."""
# Инициализация левозамкнутого правооткрытого интервала [0, n), i и j указывают на первый элемент массива и элемент после последнего.
i, j = 0, len(nums)
# Цикл, выход при пустом интервале поиска (когда i = j).
while i < j:
m = (i + j) // 2 # Вычисление индекса средней точки m.
if nums[m] < target:
i = m + 1 # В этом случае target находится в интервале [m+1, j).
elif nums[m] > target:
j = m # В этом случае target находится в интервале [i, m).
else:
return m # Найден целевой элемент, возвращается его индекс.
return -1 # Целевой элемент не найден, возвращается -1.
В двух представлениях интервалов инициализация, условия цикла и операции сокращения интервала в алгоритме двоичного поиска различаются, как
показано на рис. 10.3.
Поскольку в представлении «двойной замкнутый интервал» обе границы
определены как замкнутые, операции сокращения интервала с помощью указателей i и j также симметричны. Это снижает вероятность ошибок, поэтому обычно рекомендуется использовать запись «двойной замкнутый интервал».
Элемент
Индекс
Обе границы включены
в интервал
Интервал поиска: двойной замкнутый [i, j]
Инициализация указателей: i = 0, j = n − 1
Условие завершения цикла: i > j
Сужение интервала: i = m + 1, j = m − 1
Левая граница включена,
правая – не включена
Интервал поиска: левозамкнутый правоооткрытый [i, j)
Инициализация указателей: i = 0, j = n
Условие завершения цикла: i ≥ j
Операция сужения интервала: i = m + 1, j = m
Рис. 10.3. Два определения интервалов
270
Поиск
10.1.2. Преимущества и ограничения
Двоичный поиск обладает хорошей производительностью как по времени, так
и по пространству.
Двоичный поиск отличается высокой эффективностью по времени. При
большом объеме данных логарифмическая временная сложность имеет значительное преимущество. Например, при размере данных n = 220
линейный поиск требует 220 = 1 048 576 итераций, тогда как двоичный
поиск – всего log2 220 = 20 итераций.
Двоичный поиск, в отличие от некоторых других алгоритмов поиска (например, хеш-поиска), не требует дополнительного пространства и поэтому более экономичен в плане использования памяти.
Тем не менее двоичный поиск не подходит для всех случаев по следующим
основным причинам.
Двоичный поиск применим только к упорядоченным данным. Если
входные данные неупорядоченные, то их сортировка специально для
использования двоичного поиска не оправдана. Это связано с тем,
что временная сложность алгоритмов сортировки обычно составляет
O(n log n), что выше, чем у линейного и двоичного поиска. В сценариях с частыми добавлениями элементов для поддержания упорядоченности массива необходимо вставлять элементы в определенные позиции, что имеет временную сложность O(n) и также является весьма
затратным.
Двоичный поиск применим только к массивам, поскольку требует скачкообразного (непрерывного) доступа к элементам. В связных списках
выполнение скачкообразного доступа менее эффективно, поэтому такой
поиск не подходит для применения в связных списках и структурах данных, основанных на них.
При небольших объемах данных линейный поиск более эффективен.
В линейном поиске на каждом этапе требуется только одна операция
сравнения; в двоичном поиске требуется одна операция сложения, одна
операция деления, от одной до трех операций сравнения и одна операция сложения (вычитания), всего от четырех до шести элементарных
операций. Поэтому, когда объем данных n невелик, линейный поиск оказывается быстрее двоичного.
10.2. Вставка с использованием
двоичного поиска
Двоичный поиск можно использовать не только для поиска целевого элемента, но и для решения множества других задач, таких как поиск позиции для
вставки целевого элемента.
10.2. Вставка с использованием двоичного поиска 271
10.2.1. Случай без повторяющихся элементов
Задача
Дан упорядоченный массив nums длины n и элемент target, в массиве отсутствуют повторяющиеся элементы. Необходимо вставить target в массив nums,
сохраняя его упорядоченность. Если элемент target уже существует в массиве,
то вставить его слева от существующего. Вернуть индекс target после вставки.
Пример показан на рис. 10.4.
Место вставки
Вставка target
Ввод отсортированного массива nums, целевой
элемент target = 6
Тогда индекс места вставки для target равен 2
Рис. 10.4. Пример данных для вставки с использованием двоичного поиска
Если требуется повторно использовать код двоичного поиска из предыдущего раздела, необходимо ответить на следующие два вопроса:
1) если массив содержит target, является ли индекс вставки индексом этого
элемента? Условие задачи требует вставить target слева от равного элемента, т. е. новый target заменяет старое положение target. То есть если
массив уже содержит target, индекс вставки совпадает с индексом
этого target;
2) если массив не содержит target, какой элемент будет иметь индекс вставки?
Дальнейший процесс двоичного поиска: когда nums[m] < target, указатель
i перемещается, т. е. приближается к элементу, большему или равному target.
Аналогично указатель j всегда приближается к элементу, меньшему или равному target.
Таким образом, по окончании двоичного поиска указатель i указывает на
первый элемент, больший target, а указатель j – на первый элемент, меньший
target. Легко понять, что, если массив не содержит target, индекс вставки
будет равен i. Ниже приведен пример кода.
# === File: binary_search_insertion.py ===
272
Поиск
def binary_search_insertion_simple(nums: list[int], target: int) -> int:
""" Двоичный поиск точки вставки (без повторяющихся элементов)."""
i, j = 0, len(nums) - 1 # Инициализация двойного закрытого интервала [0, n-1].
while i <= j:
m = (i + j) // 2 # Вычисление среднего индекса m.
if nums[m] < target:
i = m + 1 # target находится в интервале [m+1, j].
elif nums[m] > target:
j = m - 1 # target находится в интервале [i, m-1].
else:
return m # Найден target, возвращается точка вставки m.
# target не найден, возвращается точка вставки i.
return i
10.2.2. Случай с повторяющимися элементами
Задача
Условия сходны с предыдущей задачей, но допускается наличие повторяющихся элементов в массиве, остальные условия остаются неизменными.
Если в массиве существует несколько одинаковых target, то обычный двоичный поиск может вернуть индекс только одного из них, не определяя, сколько
target находится слева и справа от этого элемента.
Задача требует вставить целевой элемент в самое левое положение, поэтому необходимо найти индекс самого левого target в массиве. Первоначально
предполагается реализовать решение следующим образом (см. рис. 10.5):
1) выполнить двоичный поиск и получить индекс любого target, обозначить его как k;
2) начиная с индекса k, выполнить линейный обход влево и вернуть индекс, когда будет найден самый левый target.
1. Сначала найти любой элемент 6 с помощью
двоичного поиска
2. Затем найти самый левый элемент 6
с помощью линейного поиска
Рис. 10.5. Линейный поиск точки вставки для повторяющихся элементов
10.2. Вставка с использованием двоичного поиска 273
Это рабочий метод, но он включает линейный поиск, поэтому его временная сложность составляет O(n). Когда в массиве много повторяющихся target,
эффективность этого метода низка.
Теперь рассмотрим расширение алгоритма двоичного поиска. Общий процесс остается неизменным: на каждом этапе сначала вычисляется средний индекс m, затем определяется отношение между target и nums[m], как показано на
рис. 10.6. Возможны два случая:
1) nums[m] < target или nums[m] > target, тогда target еще не найден, поэтому
используется операция сужения интервала обычного двоичного поиска,
чтобы указатели i и j приближались к target;
2) nums[m] == target, тогда элементы, меньшие target, находятся в интервале
[i, m – 1]. Поэтому используется операция j = m – 1 для сужения интервала, чтобы указатель j приблизился к элементам, меньшим target.
После завершения цикла i будет указывать на самый левый target, а j – на
первый элемент, меньший target. Поэтому индекс i является точкой вставки.
Элемент
Индекс
Шаг 1
Инициализация индексов i, j, указывающих на первый и последний
элементы массива; они представляют замкнутый интервал [i, j]
Элемент
Индекс
Цикл двоичного поиска:
Шаг 2
1. Вычисление средней точки m = (i + j)/2
Рис. 10.6. Этапы двоичного поиска точки вставки для повторяющихся элементов. Шаги 1–2
274
Поиск
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 3
nums[m] == target
Выполнить j = m – 1 для сужения интервала поиска
Элемент
Индекс
Цикл двоичного поиска:
Шаг 4
1. Вычисление средней точки m = (i + j)/2
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 5
nums[m] < target
Выполнить i = m + 1 для сужения интервала поиска
Рис. 10.6. Продолжение. Шаги 3–5
10.2. Вставка с использованием двоичного поиска 275
Элемент
Индекс
Цикл двоичного поиска:
Шаг 6
1. Вычисление средней точки m = (i + j)/2
Элемент
Индекс
Цикл двоичного поиска:
1. Вычисление средней точки m = (i + j)/2
Шаг 7
nums[m] == target
Выполнить j = m – 1 для сужения интервала поиска
Элемент
Индекс
Шаг 8
Условие цикла i ≤ j не выполняется
Выход из цикла двоичного поиска, возврат индекса i
Рис. 10.6. Окончание. Шаги 6–8
276
Поиск
Ниже приведен пример кода. Операции в ветвях nums[m] > target и nums[m] ==
target одинаковы, поэтому их можно объединить. Тем не менее условие можно
оставить развернутым, так как это делает логику более ясной и улучшает читаемость.
# === File: binary_search_insertion.py ===
def binary_search_insertion(nums: list[int], target: int) -> int:
""" Двоичный поиск точки вставки (с повторяющимися элементами)."""
i, j = 0, len(nums) - 1 # Инициализация двойного закрытого интервала [0,
n-1].
while i <= j:
m = (i + j) // 2 # Вычисление индекса середины m.
if nums[m] < target:
i = m + 1 # target в интервале [m+1, j].
elif nums[m] > target:
j = m - 1 # target в интервале [i, m-1].
else:
j = m - 1 # Первый элемент, меньший target, в интервале [i, m-1].
# Возврат точки вставки i.
return i
Совет
Код в этом разделе написан с использованием двойного закрытого интервала. Заинтересованные читатели могут самостоятельно реализовать вариант
с левым закрытым и правым открытым интервалом.
Подводя итоги, можно сказать, что двоичный поиск заключается в установке
целей поиска для указателей i и j. Целью может быть конкретный элемент (например, target) или диапазон элементов (например, элементы, меньшие target).
В процессе повторяющегося двоичного поиска указатели i и j постепенно
приближаются к заранее установленной цели. В конечном итоге они либо
успешно находят ответ, либо останавливаются после выхода за границы.
10.3. Двоичный поиск границ
10.3.1. Поиск левой границы
Задача
Дан отсортированный массив nums длиной n, который может содержать повторяющиеся элементы. Требуется вернуть индекс самого левого элемента
target в массиве. Если массив не содержит этого элемента, вернуть –1.
10.3. Двоичный поиск границ 277
Вспомним метод двоичного поиска точки вставки: после завершения поиска индекс i указывает на самый левый элемент target, поэтому поиск точки
вставки, по сути, является поиском индекса самого левого target.
Рассмотрим реализацию поиска левой границы через функцию поиска точки вставки. Обратите внимание, что массив может не содержать target, что может привести к следующим двум результатам:
1) индекс точки вставки i выходит за границы;
2) элемент nums[i] не равен target.
При возникновении этих двух ситуаций следует сразу вернуть –1. Код реализации приведен ниже.
# === File: binary_search_edge.py ===
def binary_search_left_edge(nums: list[int], target: int) -> int:
""" Двоичный поиск самого левого элемента target."""
# Эквивалентно поиску точки вставки target.
i = binary_search_insertion(nums, target)
# target не найден, возвращается -1.
if i == len(nums) or nums[i] != target:
return -1
# target найден, возвращается индекс i.
return i
10.3.2. Поиск правой границы
Как найти самый правый элемент target? Самый очевидный способ – изменить
код, заменив операцию сужения указателя в случае nums[m] == target. Мы не
будем приводить код для этого случая, заинтересованные читатели могут реализовать его самостоятельно.
Ниже представлены два более изящных подхода.
1. Повторное использование поиска левой границы
На самом деле можно использовать функцию поиска самого левого элемента
для поиска самого правого элемента. Что именно нужно сделать: преобразовать поиск самого правого элемента target в поиск самого левого target + 1.
После завершения поиска указатель i указывает на самый левый элемент
target + 1 (если он существует), а j указывает на самый правый target, поэтому
можно вернуть j, как показано на рис. 10.7.
278
Поиск
Поиск самого
левого target + 1
Равнозначно
Поиск самого
левого target
Поиск самого
правого target
Рис. 10.7. Преобразование поиска правой границы в поиск левой границы
Обратите внимание, что возвращаемая точка вставки – это i, поэтому необходимо вычесть 1, чтобы получить j.
# === File: binary_search_edge.py ===
def binary_search_right_edge(nums: list[int], target: int) -> int:
""" Двоичный поиск самого правого target."""
# Преобразование в поиск самого левого target + 1.
i = binary_search_insertion(nums, target + 1)
# j указывает на самый правый target, i указывает на первый элемент,
# больший target.
j = i - 1
# target не найден, возвращается -1.
if j == -1 or nums[j] != target:
return -1
# target найден, возвращается индекс j.
return j
2. Преобразование в поиск элемента
Известно, что, когда массив не содержит элемент target, индексы i и j в конечном итоге указывают на первый элемент, больший target, и на первый элемент, меньший target, соответственно.
Таким образом, для поиска левой и правой границ можно создать элемент,
отсутствующий в массиве, как показано на рис. 10.8.
Поиск самого левого target: можно преобразовать в поиск target - 0.5
и вернуть указатель i.
Поиск самого правого target: можно преобразовать в поиск target + 0.5
и вернуть указатель j.
10.4. Стратегии оптимизации хеширования 279
Поиск
target – 0.5
Поиск
target + 0.5
Равнозначно
Поиск самого
левого target
Равнозначно
Поиск самого
правого target
Рис. 10.8. Преобразование поиска границ в поиск элемента
Код мы не приводим, но стоит обратить внимание на следующие два момента:
1) данный массив не содержит дробных чисел, т. е. не нужно беспокоиться
об обработке случаев равенства другим элементам массива;
2) поскольку этот метод вводит дробные числа, необходимо изменить тип
переменной target на тип с плавающей запятой (в Python это изменение
не требуется).
10.4. Стратегии оптимизации хеширования
В алгоритмических задачах линейный поиск часто заменяется на хешпоиск, чтобы снизить временную сложность алгоритма. Рассмотрим задачу для углубленного понимания этого приема.
Задача
Дан массив целых чисел nums и целевой элемент target. Необходимо найти
в массиве два элемента, сумма которых равна target, и вернуть их индексы.
Достаточно вернуть любое найденное решение.
10.4.1. Линейный поиск: обмен времени на пространство
Рассмотрим прямой перебор всех возможных комбинаций. Мы запускаем два
вложенных цикла и на каждой итерации проверяем, равна ли сумма двух целых чисел target. Если да, то возвращаем их индексы, см. рис. 10.9.
280
Поиск
Перебрать все комбинации, возврат при
нахождении комбинации с суммой target
Рис. 10.9. Линейный поиск для нахождения двух чисел, сумма которых равна заданному
Ниже приведен код реализации.
# === File: two_sum.py ===
def two_sum_brute_force(nums: list[int], target: int) -> list[int]:
""" Метод 1: Полный перебор."""
# Два вложенных цикла, временная сложность O(n^2).
for i in range(len(nums) - 1):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return []
Временная сложность этого метода составляет O(n2), а пространственная
сложность O(1), что делает его крайне медленным при большом объеме данных.
10.4.2. Хеш-поиск: обмен пространства на время
Рассмотрим использование хеш-таблицы, в которой ключами и значениями
являются элементы массива и их индексы. Циклически обходим массив, выполняя следующие шаги, показанные на рис. 10.10:
1) проверить, содержится ли число target - nums[i] в хеш-таблице. Если да,
то сразу вернуть индексы этих двух элементов;
2) добавить в хеш-таблицу пару ключ–значение: nums[i] и i.
10.4. Стратегии оптимизации хеширования 281
Элемент
Индекс
Обход массива nums
13 – 2 = 11 отсутствует в map
Добавить элемент 2 в map
Шаг 1
Элемент
Индекс
Обход массива nums
13 – 7 = 6 отсутствует в map
Добавить элемент 7 в map
Шаг 2
Рис. 10.10. Использование вспомогательной хеш-таблицы для нахождения двух чисел,
сумма которых равна заданному. Шаги 1–2
282
Поиск
Элемент
Индекс
Обход массива nums
13 – 11 = 2 присутствует в map
Возврат комбинации индексов [0, 2]
Шаг 3
Рис. 10.10. Окончание. Шаг 3
Код реализации представлен ниже, требуется только один цикл.
# === File: two_sum.py ===
def two_sum_hash_table(nums: list[int], target: int) -> list[int]:
""" Метод 2: Вспомогательная хеш-таблица."""
# Вспомогательная хеш-таблица, пространственная сложность O(n).
dic = {}
# Один цикл, временная сложность O(n).
for i in range(len(nums)):
if target - nums[i] in dic:
return [dic[target - nums[i]], i]
dic[nums[i]] = i
return []
Этот метод снижает временную сложность с O(n2) до O(n) благодаря хешпоиску, значительно повышая эффективность выполнения.
Поскольку требуется поддерживать дополнительную хеш-таблицу, пространственная сложность составляет O(n). Тем не менее общая эффективность этого метода более сбалансирована, что делает его оптимальным
решением данной задачи.
10.5. Переосмысление алгоритмов поиска
Алгоритмы поиска используются для нахождения одного или нескольких элементов, удовлетворяющих определенным условиям, в структурах данных, таких как массивы, списки, деревья или графы.
Алгоритмы поиска можно классифицировать по принципу их реализации
на следующие категории.
10.5. Переосмысление алгоритмов поиска 283
Поиск целевого элемента путем обхода структуры данных, например обход массива, списка, дерева и графа.
Эффективный поиск элементов с использованием структуры организации данных или априорной информации, например двоичный
поиск, хеш-поиск и поиск в двоичных деревьях.
Нетрудно заметить, что эти темы уже были рассмотрены в предыдущих главах, поэтому алгоритмы поиска нам уже знакомы. В этом разделе мы систематизируем полученные ранее знания.
10.5.1. Полный перебор
Полный перебор заключается в обходе каждого элемента структуры данных для
нахождения целевого элемента.
Линейный поиск применяется к линейным структурам данных, таким
как массивы и списки. Он начинается с одного конца структуры данных
и последовательно проверяет элементы, пока не будет найден целевой
элемент или не будет достигнут другой конец.
Поиск в ширину и поиск в глубину – это две стратегии обхода графов
и деревьев. Поиск в ширину начинается с начального узла и исследует все узлы на текущем уровне перед переходом на следующий. Поиск
в глубину начинается с начального узла и следует по пути до конца, затем возвращается и пробует другие пути, пока не будет полностью пройдена вся структура данных.
Преимущество полного перебора заключается в его простоте и универсальности, так как он не требует предварительной обработки данных и использования дополнительных структур данных.
Однако временная сложность таких алгоритмов составляет O(n), где n –
количество элементов, что делает их менее эффективными при большом объеме данных.
10.5.2. Адаптивный поиск
Адаптивный поиск использует специфические свойства данных (например,
упорядоченность) для оптимизации процесса поиска, что позволяет более эффективно находить целевой элемент.
Двоичный поиск использует упорядоченность данных для эффективного поиска и применим только к массивам.
Хеш-поиск использует хеш-таблицы для создания отображения между
данными поиска и целевыми данными, что позволяет эффективно выполнять операции поиска.
Поиск в дереве осуществляется в определенной структуре дерева (например, в двоичном дереве поиска) путем сравнения значений узлов для
быстрого исключения узлов и нахождения целевого элемента.
Преимуществом таких алгоритмов является высокая эффективность, временная сложность может достигать O(log n) и даже O(1).
Однако использование этих алгоритмов часто требует предварительной обработки данных. Например, для двоичного поиска необходимо пред-
284
Поиск
варительно отсортировать массив, хеш-поиск и поиск в дереве требуют использования дополнительных структур данных, поддержание которых также
требует дополнительных временных и пространственных затрат.
Совет
Адаптивные алгоритмы поиска часто называют просто поисковыми алгоритмами, они в основном используются для быстрого извлечения целевых
элементов из определенной структуры данных.
10.5.3. Выбор метода поиска
Для поиска целевого элемента в заданном наборе данных размером n можно
использовать различные методы, такие как линейный поиск, двоичный поиск,
поиск в дереве, хеш-поиск и др. Принципы работы каждого метода показаны
на рис. 10.11.
Хеш-поиск O(1)
Линейный поиск O(n)
Ввод key для
запроса value
Линейный обход массива
Двоичный поиск O(log n)
Поиск в дереве O(logn)
Поиск целевого узла
в двоичном дереве
поиска
Сужение интервала поиска благодаря
упорядоченности данных
Рис. 10.11. Различные стратегии поиска
Эффективность и характеристики указанных методов приведены в табл. 10.1.
Таблица 10.1. Сравнение эффективности алгоритмов поиска
Линейный
поиск
Двоичный
поиск
Поиск в дереве
Хеш-поиск
Поиск элемента
O(n)
O(log n)
O(log n)
O(1)
Вставка элемента
O(1)
O(n)
O(log n)
O(1)
Удаление элемента
O(n)
O(n)
O(log n)
O(1)
Дополнительное
пространство
O(1)
O(1)
O(n)
O(n)
10.5. Переосмысление алгоритмов поиска 285
Окончание табл. 10.1
Линейный
поиск
Двоичный
поиск
Поиск в дереве
Хеш-поиск
Предварительная
обработка данных
–
Сортировка
O(n log n)
Построение дерева O(n log n)
Построение хештаблицы O(n)
Упорядоченность
данных
Неупорядоченные
Упорядоченные
Упорядоченные
Неупорядоченные
Выбор алгоритма поиска также зависит от объема данных, требований
к производительности поиска, частоты запросов и обновлений данных.
Линейный поиск
Обладает хорошей универсальностью, не требует предварительной обработки данных. Если необходимо выполнить только один запрос, время предварительной обработки данных для других трех методов будет
дольше, чем время линейного поиска.
Подходит для небольших объемов данных, в этом случае временная
сложность мало влияет на эффективность.
Подходит для сценариев с высокой частотой обновления данных, так как
этот метод не требует дополнительного обслуживания данных.
Двоичный поиск
Подходит для больших объемов данных, демонстрирует стабильную эффективность, худшая временная сложность составляет O(log n).
Объем данных не должен быть слишком большим, так как массив требует
непрерывного пространства в памяти.
Не подходит для сценариев с частыми добавлениями и удалениями данных, так как поддержание упорядоченного массива требует значительных затрат.
Хеш-поиск
Подходит для сценариев с высокими требованиями к производительности поиска, средняя временная сложность составляет O(1).
Не подходит для случаев, когда требуется упорядоченность данных или
поиск по диапазону, так как хеш-таблица не может поддерживать упорядоченность данных.
Сильно зависит от хеш-функции и стратегии обработки коллизий, существует значительный риск ухудшения производительности.
Не подходит для слишком больших объемов данных, так как хеш-таблица
требует дополнительного пространства для минимизации коллизий
и обеспечения хорошей производительности поиска.
Поиск в дереве
Подходит для огромных объемов данных, так как узлы дерева хранятся
в памяти раздельно.
Подходит для случаев, когда требуется поддержание упорядоченности
данных или поиск по диапазону.
286
Поиск
В процессе постоянного добавления и удаления узлов двоичное дерево
поиска может стать несбалансированным, и временная сложность ухудшится до O(n).
Если используется АВЛ-дерево или красно-черное дерево, все операции
могут выполняться со стабильной эффективностью O(log n), но операции
по поддержанию баланса дерева увеличивают дополнительные затраты.
10.6. Резюме
Двоичный поиск требует упорядоченности данных и выполняется путем
циклического сокращения области поиска в два раза. Требует упорядоченности входных данных и подходит только для массивов или структур
данных, основанных на массивах.
Полный перебор осуществляется путем обхода структуры данных для
нахождения целевого значения. Линейный поиск подходит для массивов
и списков, поиск в ширину и поиск в глубину подходят для графов и деревьев. Эти алгоритмы обладают хорошей универсальностью, не требуют предварительной обработки данных, но их временная сложность O(n)
достаточно высока.
Хеш-поиск, поиск в деревьях и двоичный поиск относятся к эффективным методам поиска, которые позволяют быстро находить целевой
элемент в определенных структурах данных. Эти алгоритмы отличаются высокой эффективностью, их временная сложность может достигать
O(log n) или даже O(1), однако обычно они требуют использования дополнительных структур данных.
На практике для выбора подходящего метода необходимо проводить
конкретный анализ таких факторов, как объем данных, требования
к производительности поиска, частота запросов и обновлений данных.
Линейный поиск подходит для небольших или часто обновляемых данных. Двоичный поиск – для больших, отсортированных данных. Хешпоиск – когда важна высокая эффективность запросов и не требуется
поиск диапазонов. Поиск в деревьях – для больших динамических данных, в которых необходимо поддерживать порядок и выполнять запросы
диапазонов.
Замена линейного поиска на хеш-поиск является распространенной
стратегией оптимизации времени выполнения, позволяющей снизить
временную сложность с O(n) до O(1).
Глава 11
Сортировка
Абстракция
Сортировка подобна волшебному ключу, превращающему хаос в порядок,
который позволяет нам более эффективно понимать и обрабатывать данные.
Простая ли сортировка по возрастанию или же сложная классификация –
любая сортировка демонстрирует нам гармонию данных.
288
Сортировка
11.1. Алгоритмы сортировки
Алгоритмы сортировки используются для упорядочивания набора данных
в определенном порядке. Они имеют широкое применение, поскольку упорядоченные данные обычно можно более эффективно анализировать, обрабатывать и выполнять в них поиск.
Типы данных в алгоритмах сортировки могут быть целыми числами, числами с плавающей запятой, символами или строками, как показано на рис. 11.1.
Правила сортировки могут быть установлены в зависимости от потребностей,
например по величине чисел, порядку ASCII-кодов символов или произвольным пользовательским правилам.
Сортировка целых чисел по величине
Сортировка строк в лексикографическом порядке
Сортировка строк по пользовательскому правилу
Рис. 11.1. Пример типов данных и правил сортировки
11.1.1. Критерии оценки
Эффективность выполнения: ожидается, что временная сложность алгоритма сортировки будет как можно ниже, а общее количество операций – минимальным (уменьшение константного множителя во временной сложности).
Для больших объемов данных эффективность выполнения особенно важна.
Местность: как следует из названия, сортировка на месте осуществляется
путем непосредственной работы с исходным массивом без использования дополнительных вспомогательных массивов, что позволяет экономить память.
Обычно операции перемещения данных при сортировке на месте малочисленны, а скорость выполнения выше.
Стабильность: стабильная сортировка сохраняет относительный порядок
равных элементов в массиве после завершения сортировки.
Стабильная сортировка является необходимым условием для многоуровневой сортировки. Предположим, что у нас есть таблица с информацией
о студентах, где 1-й и 2-й столбцы – это имя и возраст соответственно. В этом
случае нестабильная сортировка может привести к потере упорядоченности
входных данных.
11.2. Сортировка выбором 289
# Входные данные отсортированы по имени.
# (name, age)
('A', 19)
('B', 18)
('C', 21)
('D', 19)
('E', 23)
# Предположим, используется нестабильный алгоритм сортировки по возрасту,
# в результате чего изменяется относительное положение ('D', 19) и ('A', 19),
# теряется свойство упорядоченности входных данных по имени.
('B', 18)
('D', 19)
('A', 19)
('C', 21)
('E', 23)
Адаптивность: адаптивная сортировка способна использовать имеющуюся
информацию о порядке входных данных для уменьшения объема вычислений,
достигая более высокой временной эффективности. Лучшая временная сложность адаптивных алгоритмов сортировки обычно превосходит среднюю временную сложность.
Основанность на сравнении: сортировка на основе сравнения использует
операторы сравнения (<, =, >) для определения относительного порядка элементов, что позволяет отсортировать весь массив. Теоретическая оптимальная временная сложность составляет O(n log n). В то время как не основанная
на сравнении сортировка не использует операторы сравнения, ее временная
сложность может достигать O(n), но ее универсальность относительно ниже.
11.1.2. Идеальный алгоритм сортировки
Быстрый, на месте, стабильный, адаптивный, с хорошей универсальностью. Очевидно, что до сих пор нет алгоритма сортировки, сочетающего все
эти характеристики. Поэтому при выборе алгоритма необходимо учитывать
особенности данных и требования задачи.
Далее мы изучим различные алгоритмы сортировки и проанализируем их
достоинства и недостатки на основе вышеуказанных критериев оценки.
11.2. Сортировка выбором
Принцип работы сортировки выбором весьма прост: запускается цикл, в каждой итерации которого из неотсортированной части массива выбирается наименьший элемент и помещается в конец отсортированной части.
Пусть длина массива равна n, алгоритм сортировки выбором заключается
в следующем (см. рис. 11.2):
1) в начальном состоянии все элементы не отсортированы, т. е. неотсортированный (индексный) диапазон равен [0, n – 1];
290
Сортировка
2) выбирается наименьший элемент из диапазона [0, n – 1] и меняется местами с элементом с индексом 0. После этого первый элемент массива
отсортирован;
3) выбирается наименьший элемент из диапазона [1, n – 1] и меняется местами с элементом с индексом 1. После этого первые два элемента массива отсортированы;
4) таким образом, после n – 1 итераций выбора и обмена первые n – 1 элементов массива отсортированы;
5) единственный оставшийся элемент обязательно является наибольшим,
поэтому сортировка массива завершена.
Неотсортированный диапазон
Шаг 1
1-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Неотсортированный диапазон
1-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Шаг 2
Обмен местами минимального элемента nums[k] и первого элемента
неотсортированного диапазона nums[i]
Неотсортированный диапазон
2-й проход выбора:
Шаг 3
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Рис. 11.2. Этапы сортировки выбором. Шаги 1–3
11.2. Сортировка выбором 291
Неотсортированный диапазон
2-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Шаг 4
Обмен местами минимального элемента nums[k] и первого элемента
неотсортированного диапазона nums[i]
Неотсортированный диапазон
3-й проход выбора:
Шаг 5
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Неотсортированный диапазон
3-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Шаг 6
Обмен местами минимального элемента nums[k] и первого элемента
неотсортированного диапазона nums[i]
Рис. 11.2. Продолжение. Шаги 4–6
292
Сортировка
Неотсортированный диапазон
4-й проход выбора:
Шаг 7
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Неотсортированный диапазон
4-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Шаг 8
Обмен местами минимального элемента nums[k] и первого элемента
неотсортированного диапазона nums[i]
Неотсортированный диапазон
5-й проход выбора:
Шаг 9
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Рис. 11.2. Продолжение. Шаги 7–9
11.2. Сортировка выбором 293
Неотсортированный диапазон
5-й проход выбора:
Обход неотсортированного диапазона, выбор минимального
элемента nums[k]
Шаг 10
Обмен местами минимального элемента nums[k] и первого элемента
неотсортированного диапазона nums[i]
Неотсортированный диапазон
Шаг 11
Сортировка завершена (оставшийся один элемент
не требует сортировки)
Рис. 11.2. Окончание. Шаги 10–11
В приведенном ниже коде реализации используется переменная k для записи индекса наименьшего элемента в неотсортированном диапазоне.
# === File: selection_sort.py ===
def selection_sort(nums: list[int]):
""" Сортировка выбором."""
n = len(nums)
# Внешний цикл: неотсортированный диапазон [i, n-1].
for i in range(n - 1):
# Внутренний цикл: нахождение наименьшего элемента
№ в неотсортированном диапазоне.
k = i
for j in range(i + 1, n):
if nums[j] < nums[k]:
k = j # Запись индекса наименьшего элемента.
# Обмен наименьшего элемента с первым элементом неотсортированного диапазона.
nums[i], nums[k] = nums[k], nums[i]
294
Сортировка
11.2.1. Характеристики алгоритма
Временная сложность O(n2), неадаптивная сортировка: внешний
цикл выполняется n – 1 раз, длина неотсортированного диапазона на
первой итерации равна n, на последней – 2, т. е. каждый внешний цикл
включает n, n – 1, ..., 3, 2 итераций внутреннего цикла, сумма которых
равна (n – 1)(n + 2)/2.
Пространственная сложность O(1), сортировка на месте: указатели i и j используют дополнительное пространство постоянного размера.
Нестабильная сортировка: как показано на рис. 11.3, элемент nums[i]
может быть перемещен вправо от равного ему элемента, что изменяет
их относительный порядок.
Неотсортированный диапазон
Первый проход выбора
Неотсортированный диапазон
После первого прохода относительное положение
двух элементов 4 изменилось
Рис. 11.3. Пример нестабильности сортировки выбором
11.3. Сортировка пузырьком
Сортировка пузырьком реализует сортировку путем последовательного сравнения и обмена соседних элементов. Этот процесс напоминает подъем пузырьков со дна на поверхность, отсюда и такое название.
Процесс поднятия пузырька можно смоделировать операцией обмена
элементов: начиная с самого левого конца массива, производится последовательное сравнение соседних элементов, и, если левый элемент > правый
элемент, они меняются местами, как показано на рис. 11.4. После завершения прохода наибольший элемент будет перемещен в самый правый конец
массива.
11.3. Сортировка пузырьком 295
Представим массив как ряд пузырьков,
при этом размер элемента
пропорционален объему пузырька
Шаг 1
Шаг 2
Обход смежных элементов слева направо:
Левый элемент > Правый элемент
Обмен местами этих двух элементов
Шаг 3
Обход смежных элементов слева направо:
Левый элемент > Правый элемент
Обмен местами этих двух элементов
Шаг 4
Обход смежных элементов слева направо:
Левый элемент > Правый элемент
Обмен местами этих двух элементов
Рис. 11.4. Моделирование поднятия пузырька с помощью обмена элементов. Шаги 1–4
296
Сортировка
Шаг 5
Обход смежных элементов слева направо:
Левый элемент ≤ Правый элемент
Оставить без изменений
Шаг 6
Обход смежных элементов слева направо:
Левый элемент > Правый элемент
Обмен местами этих двух элементов
Дно
Поверхность воды
Осталось n – 1 неотсортированных
элементов
Шаг 7
После завершения прохода самый большой
пузырь всплывает на поверхность
Рис. 11.4. Окончание. Шаги 5–7
11.3.1. Алгоритм
Пусть дан массив длиной n, тогда сортировка пузырьком выглядит следующим
образом (см. рис. 11.5):
1) сначала выполняется пузырек для n элементов, перемещая наибольший элемент в правильное положение;
2) затем выполняется пузырек для оставшихся n – 1 элементов, перемещая второй по величине элемент в правильное положение;
3) таким образом, после n – 1 итераций пузырька первые n – 1 наибольших элементов перемещены в правильные положения;
4) единственный оставшийся элемент обязательно является наименьшим,
поэтому сортировка массива завершена.
11.3. Сортировка пузырьком 297
Элементы,
всплывшие
на поверхность
Неотсортированный
диапазон
1-й проход пузырька
2-й проход пузырька
3-й проход пузырька
4-й проход пузырька
5-й проход пузырька
6-й проход пузырька
Рис. 11.5. Процесс сортировки пузырьком
Ниже приведен пример кода.
# === File: bubble_sort.py ===
def bubble_sort(nums: list[int]):
""" Сортировка пузырьком."""
n = len(nums)
# Внешний цикл: неотсортированный диапазон [0, i].
for i in range(n - 1, 0, -1):
# Внутренний цикл: перемещение наибольшего элемента в неотсортированном
# диапазоне [0, i] в его правый конец.
for j in range(i):
if nums[j] > nums[j + 1]:
# Обмен nums[j] и nums[j + 1].
nums[j], nums[j + 1] = nums[j + 1], nums[j]
11.3.2. Оптимизация эффективности
Если в какой-либо итерации пузырька не выполняется ни одной операции обмена, это означает, что массив уже отсортирован, и можно сразу вернуть результат. Поэтому можно добавить флаг flag для отслеживания этой ситуации,
и как только она возникнет, немедленно выйти из цикла.
После оптимизации наихудшая и средняя временные сложности сортировки пузырьком остаются O(n2); однако, если входной массив полностью отсортирован, можно достичь лучшей временной сложности O(n).
298
Сортировка
# === File: bubble_sort.py ===
def bubble_sort_with_flag(nums: list[int]):
""" Сортировка пузырьком (оптимизация с флагом)."""
n = len(nums)
# Внешний цикл: неотсортированный диапазон [0, i].
for i in range(n - 1, 0, -1):
flag = False # Инициализация флага.
# Внутренний цикл: перемещение наибольшего элемента в неотсортированном
# диапазоне [0, i] в его правый конец.
for j in range(i):
if nums[j] > nums[j + 1]:
# Обмен nums[j] и nums[j + 1]
nums[j], nums[j + 1] = nums[j + 1], nums[j]
flag = True # Запись обмена элементов
if not flag:
break # В этой итерации "пузырька" не было обмена, выход из цикла.
11.3.3. Характеристики алгоритма
Временная сложность O(n2), адаптивная сортировка: длина массива, проходящего каждую итерацию пузырька, последовательно равна
n – 1, n – 2, ..., 2, 1. Сумма этих значений равна (n – 1)n/2. После введения оптимизации с флагом лучшая временная сложность может достигать O(n).
Пространственная сложность O(1), сортировка на месте: указатели
i и j используют дополнительную память постоянного размера.
Стабильная сортировка: поскольку при сортировке пузырьком равные
элементы не меняются местами.
11.4. Сортировка вставками
Сортировка вставками – это простой алгоритм сортировки, работа которого
схожа с процессом ручной сортировки карт в колоде.
Более конкретно: в неотсортированном сегменте выбирается опорный элемент, который сравнивается по величине с элементами в отсортированном
сегменте слева и вставляется на правильное место.
На рис. 11.6 иллюстрируется процесс вставки элемента в массив. Пусть
опорный элемент обозначен как base, необходимо сдвинуть все элементы от
целевого индекса до base вправо на одну позицию, затем присвоить base целевому индексу.
11.4. Сортировка вставками 299
Временное хранилище
Вставка элемента 3
в правильную позицию
отсортированного
интервала nums
Сначала сдвиг
элементов на одну
позицию вправо
Затем присвоение
значения элемента
по целевому
индексу
Рис. 11.6. Операция одиночной вставки
11.4.1. Алгоритм
Процесс сортировки вставками выглядит следующим образом (см. рис. 11.7):
1) в начальном состоянии первый элемент массива уже отсортирован;
2) выбирается второй элемент массива в качестве base, после его вставки
на правильное место первые два элемента массива отсортированы;
3) выбирается третий элемент в качестве base, после его вставки на правильное место первые три элемента массива отсортированы;
4) таким образом, в последнем раунде выбирается последний элемент
в качестве base, после его вставки на правильное место все элементы
отсортированы.
Отсортированный
диапазон
1-й проход вставки
2-й проход вставки
3-й проход вставки
4-й проход вставки
5-й проход вставки
Сортировка завершена
Рис. 11.7. Процесс сортировки вставками
300
Сортировка
Ниже приведен пример кода:
# === File: insertion_sort.py ===
def insertion_sort(nums: list[int]):
""" Сортировка вставками."""
# Внешний цикл: отсортированный сегмент [0, i-1].
for i in range(1, len(nums)):
base = nums[i]
j = i - 1
# Внутренний цикл: вставка base в правильное место в отсортированном
# сегменте [0, i-1].
while j >= 0 and nums[j] > base:
nums[j + 1] = nums[j] # Сдвиг nums[j] вправо на одну позицию.
j -= 1
nums[j + 1] = base # Присвоение base правильному месту.
11.4.2. Характеристики алгоритма
Временная сложность O(n2), адаптивная сортировка: в худшем случае каждая операция вставки требует n − 1, n − 2, ..., 2, 1 циклов. Сумма
этих чисел составляет (n − 1)n/2, поэтому временная сложность равна
O(n2). При наличии упорядоченных данных операция вставки завершается досрочно. Когда входной массив полностью упорядочен, сортировка
вставками достигает лучшей временной сложности O(n).
Пространственная сложность O(1), сортировка на месте: указатели
i и j используют дополнительную память постоянного размера.
Стабильная сортировка: в процессе вставки элементы вставляются
справа от равных элементов, не изменяя их порядок.
11.4.3. Преимущества сортировки вставками
Временная сложность сортировки вставками составляет O(n2), тогда как временная сложность быстрой сортировки, которую мы скоро изучим, равна
O(n log n). Несмотря на более высокую временную сложность, сортировка
вставками обычно быстрее при небольших объемах данных.
Этот вывод аналогичен применению линейного поиска и двоичного поиска. Алгоритмы сортировки, такие как быстрая сортировка с временной
сложностью O(n log n), основаны на стратегии «разделяй и властвуй» и часто
содержат больше элементарных вычислительных операций. Однако при небольших объемах данных значения n2 и n log n близки, и сложность не является доминирующей, а количество элементарных операций в каждом раунде
играет решающую роль.
Фактически многие языки программирования (например, Java) используют встроенные функции сортировки, которые применяют сортировку вставками. Основная идея заключается в следующем: для длинных массивов ис-
11.5. Быстрая сортировка 301
пользуется сортировка на основе стратегии «разделяй и властвуй», например
быстрая сортировка. Для коротких массивов – сортировка вставками.
Хотя временная сложность сортировки пузырьком, сортировки выбором
и сортировки вставками одинакова и равна O(n2), на практике сортировка
вставками используется значительно чаще по следующим причинам.
Сортировка пузырьком основана на обмене элементов, требует использования временной переменной и включает три элементарные операции. Сортировка вставками основана на присвоении элементов и требует только одну элементарную операцию. Поэтому вычислительные
затраты сортировки пузырьком обычно выше, чем у сортировки
вставками.
В любом случае временная сложность сортировки выбором равна O(n2).
Если задана частично упорядоченная группа данных, сортировка
вставками обычно эффективнее сортировки выбором.
Сортировка выбором нестабильна и не может быть применена для многоуровневой сортировки.
11.5. Быстрая сортировка
Быстрая сортировка – это алгоритм сортировки, основанный на стратегии
«разделяй и властвуй». Он отличается высокой эффективностью и широким
применением.
Основной операцией быстрой сортировки является разделение с помощью
стража, цель которого заключается в следующем: выбрать один из элементов
массива в качестве опорного и переместить все элементы, меньшие опорного, влево от него, а элементы, большие опорного, вправо. Процесс разделения
с помощью стража выглядит следующим образом (см. рис. 11.8):
1) выбрать элемент на крайнем левом конце массива в качестве опорного, инициализировать два указателя i и j, указывающих на концы
массива;
2) установить цикл, в каждой итерации которого i (j) ищет первый элемент,
больший (меньший) опорного, после чего эти два элемента меняются
местами;
3) продолжать выполнение шага 2 до тех пор, пока i и j не встретятся,
затем переместить опорный элемент на границу между двумя подмассивами.
302
Шаг 1
Сортировка
Опорный элемент
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Шаг 2
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Опорный
элемент
4 После
завершения
разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Рис. 11.8. Этапы разделения
с помощью стража. Шаги 1–2
Правый подмассив
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
11.5. Быстрая сортировка 303
Шаг 3
Опорный элемент
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Правый подмассив
Шаг 4
Опорный
элемент
4 После
завершения
разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Рис. 11.8. Продолжение. Шаги 3–4
Правый подмассив
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
304
Шаг 5
Сортировка
Опорный элемент
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Правый подмассив
Шаг 6
Опорный элемент
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Рис. 11.8.Правый
Продолжение.
Шаги 5–6
подмассив
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
11.5. Быстрая сортировка 305
Шаг 7
Опорный элемент
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Правый подмассив
Шаг 8
Опорный элемент
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
# Опорный элемент – nums[left].
#
#
#
#
#
#
#
#
Поиск первого элемента,
меньшего опорного.
Поиск первого элемента,
больше опорного.
Обмен элементов.
Перемещение опорного элемента на
границу между двумя подмассивами.
Возврат индекса опорного элемента.
Левый подмассив
Рис. 11.8. Продолжение. Шаги 7–8
Правый подмассив
4 После завершения разделения с помощью стража
выполняется условие:
5 Любой элемент левого подмассива ≤ Опорный
элемент ≤ Любой элемент правого подмассива
306
Шаг 9
Сортировка
Опорный элемент
Левый
подмассив
Правый
подмассив
После завершения разделения с помощью стража выполняется условие:
Любой элемент левого подмассива ≤ Опорный элемент ≤
Любой элемент правого подмассива
Рис. 11.8. Окончание. Шаг 9
После завершения разделения с помощью стража исходный массив делится
на три части: левый подмассив, опорный элемент и правый подмассив. При
этом выполняется условие: любой элемент левого подмассива ≤ опорный элемент ≤ любой элемент правого подмассива. Следовательно, далее необходимо
отсортировать только эти два подмассива.
Стратегия «разделяй и властвуй» в быстрой сортировке
Суть разделения с помощью стража заключается в упрощении задачи сортировки длинного массива до задачи сортировки двух более коротких
массивов.
# === File: quick_sort.py ===
def partition(self, nums: list[int], left: int, right: int) -> int:
""" Разделение с помощью стража."""
# Опорный элемент – nums[left].
i, j = left, right
while i < j:
while i < j and nums[j] >= nums[left]:
j -= 1 # Поиск справа налево первого элемента, меньшего опорного.
while i < j and nums[i] <= nums[left]:
i += 1 # Поиск слева направо первого элемента, большего опорного.
# Обмен элементов.
nums[i], nums[j] = nums[j], nums[i]
# Перемещение опорного элемента на границу между двумя подмассивами.
nums[i], nums[left] = nums[left], nums[i]
return i # Возврат индекса опорного элемента.
11.5. Быстрая сортировка 307
11.5.1. Алгоритм
Процесс быстрой сортировки выглядит следующим образом (см. рис. 11.9):
1) сначала выполнить одно разделение с помощью стража для исходного
массива, получив неотсортированные левый и правый подмассивы;
2) затем рекурсивно выполнить разделение с помощью стража для левого
и правого подмассивов;
3) продолжать рекурсию до тех пор, пока длина подмассива не станет равной 1, таким образом завершая сортировку всего массива.
Начало рекурсии
Разделение
с помощью стража (РС)
Рекурсия для
левого подмассива
Рекурсия для
правого подмассива
РС
РС
Рекурсивное выполнение
разделения с помощью
стража для подмассивов,
пока длина подмассива
не станет равной 1.
Это позволит
отсортировать массив
Завершение
рекурсии
Рис. 11.9. Процесс быстрой сортировки
# === File: quick_sort.py ===
def quick_sort(self, nums: list[int], left: int, right: int):
""" Быстрая сортировка."""
# Прекращение рекурсии, если длина подмассива равна 1.
if left >= right:
return
# Разделение с помощью стража.
pivot = self.partition(nums, left, right)
# Рекурсия для левого и правого подмассивов.
self.quick_sort(nums, left, pivot - 1)
self.quick_sort(nums, pivot + 1, right)
11.5.2. Характеристики алгоритма
Временная сложность O(n log n), неадаптивная сортировка: в среднем случае количество рекурсивных уровней разделения с помощью
стража равно log n, общее количество циклов на каждом уровне равно n,
308
Сортировка
что соответствует времени O(n log n). В худшем случае каждая операция
разделения с помощью стража делит массив длиной n на два подмассива
длиной 0 и n – 1, в этом случае количество рекурсивных уровней достигает n, количество циклов на каждом уровне равно n, что соответствует
времени O(n2).
Пространственная сложность O(n), сортировка на месте: в случае полностью обратным порядком входного массива достигается худшая рекурсивная глубина n, используется O(n) кадров стека. Сортировка выполняется на исходном массиве без использования дополнительных массивов.
Нестабильная сортировка: на последнем шаге разделения с помощью
стража опорный элемент может быть перемещен вправо от равных ему
элементов.
11.5.3. Почему быстрая сортировка быстрая
Уже из названия понятно, что быстрая сортировка должна иметь определенные преимущества в плане эффективности. Хотя средняя временная сложность быстрой сортировки такая же, как у сортировки слиянием и пирамидальной сортировки, обычно быстрая сортировка более эффективна, по следующим причинам.
Вероятность возникновения худшего случая очень низка: хотя худшая временная сложность быстрой сортировки составляет O(n2), что
не так стабильно, как у сортировки слиянием, в подавляющем большинстве случаев быстрая сортировка работает со сложностью O(n log n).
Высокая эффективность использования кеша: при выполнении операции разделения с помощью стража система может загрузить весь подмассив в кеш, что повышает эффективность доступа к элементам. Такие
алгоритмы, как пирамидальная сортировка, требуют скачкообразного
доступа к элементам, что лишает их этого преимущества.
Низкий коэффициент постоянной сложности: среди трех упомянутых алгоритмов общее количество операций сравнения, присваивания
и обмена в быстрой сортировке минимально. Это похоже на причину, по
которой сортировка вставками быстрее пузырьковой сортировки.
11.5.4. Оптимизация выбора опорного элемента
Быстрая сортировка может демонстрировать снижение эффективности
на некоторых входных данных. Например, в случае, когда входной массив
полностью отсортирован в обратном порядке, если выбирать самый левый
элемент в качестве опорного, то после завершения разделения по методу стражей опорный элемент перемещается в самый правый конец массива. В этом
случае левый подмассив будет длиной n – 1, а правый – длиной 0. При таком
рекурсивном подходе после каждого разделения один из подмассивов оказывается длиной 0, стратегия «разделяй и властвуй» не работает, и быстрая сортировка вырождается в форму, близкую к сортировке пузырьком.
Чтобы минимизировать вероятность возникновения такой ситуации, можно оптимизировать стратегию выбора опорного элемента в методе раз-
11.5. Быстрая сортировка 309
деления. Например, можно выбрать опорный элемент случайным образом.
Однако, если вам не повезет и каждый раз будет выбран неудачный опорный
элемент, эффективность все равно будет неудовлетворительной.
Следует отметить, что программные языки обычно генерируют псевдослучайные числа. Если создать специальный тестовый пример для последовательности псевдослучайных чисел, эффективность быстрой сортировки все равно
может ухудшиться.
Для дальнейшего улучшения можно выбирать трех кандидатов из массива
(обычно это первый, последний и средний элементы массива) и использовать медиану этих трех кандидатов в качестве опорного элемента. Таким образом, вероятность того, что опорный элемент будет ни слишком маленьким,
ни слишком большим, значительно возрастает. Конечно, можно выбрать
больше кандидатов, чтобы еще больше повысить устойчивость алгоритма.
Применение этого метода значительно снижает вероятность ухудшения временной сложности до O(n2).
Ниже приведен пример кода.
# === File: quick_sort.py ===
def median_three(self, nums: list[int], left: int, mid: int, right: int) ->
int:
""" Выбор медианы из трех кандидатов """
l, m, r = nums[left], nums[mid], nums[right]
if (l <= m <= r) or (r <= m <= l):
return mid # m находится между l и r
if (m <= l <= r) or (r <= l <= m):
return left # l находится между m и r
return right
def partition(self, nums: list[int], left: int, right: int) -> int:
""" Разделение по методу стражей (медиана из трех) """
# Использование nums[left] в качестве опорного элемента
med = self.median_three(nums, left, (left + right) // 2, right)
# Перемещение медианы в начало массива
nums[left], nums[med] = nums[med], nums[left]
# Использование nums[left] в качестве опорного элемента
i, j = left, right
while i < j:
while i < j and nums[j] >= nums[left]:
j -= 1 # Поиск элемента, меньшего опорного, справа налево
while i < j and nums[i] <= nums[left]:
i += 1 # Поиск элемента, большего опорного, слева направо
# Обмен элементов
nums[i], nums[j] = nums[j], nums[i]
# Перемещение опорного элемента на границу подмассивов
nums[i], nums[left] = nums[left], nums[i]
return i # Возврат индекса опорного элемента
310
Сортировка
11.5.5. Оптимизация хвостовой рекурсии
На некоторых входных данных быстрая сортировка может потреблять
много памяти. Например, в случае полностью отсортированного массива
если длина подмассива в рекурсии равна m, то после каждого разделения
по методу стражей образуется левый подмассив длиной 0 и правый подмассив длиной m – 1. Это означает, что уменьшение размера задачи на каждом уровне рекурсии очень незначительно (уменьшается только на один
элемент), и высота рекурсивного дерева достигает n – 1, что требует O(n)
памяти для стека.
Чтобы предотвратить накопление памяти стека, можно после каждого
разделения по методу стражей сравнивать длины двух подмассивов и выполнять рекурсию только для более короткого из них. Поскольку длина
более короткого подмассива не превышает n/2, этот метод гарантирует, что
глубина рекурсии не превысит log n, тем самым оптимизируя наихудшую
пространственную сложность до O(log n). Пример кода приведен ниже.
# === File: quick_sort.py ===
def quick_sort(self, nums: list[int], left: int, right: int):
""" Быстрая сортировка (оптимизация хвостовой рекурсии) """
# Завершение при длине подмассива 1
while left < right:
# Разделение по методу стражей
pivot = self.partition(nums, left, right)
# Рекурсивная сортировка более короткого подмассива
if pivot - left < right - pivot:
self.quick_sort(nums, left, pivot - 1) # Рекурсивная сортировка
# левого подмассива
left = pivot + 1 # Неотсортированный диапазон [pivot + 1, right]
else:
self.quick_sort(nums, pivot + 1, right) # Рекурсивная сортировка
# правого подмассива
right = pivot - 1 # Неотсортированный диапазон [left, pivot - 1]
11.6. Сортировка слиянием
Сортировка слиянием – это алгоритм сортировки, основанный на стратегии
«разделяй и властвуй», включающий этапы разделения и слияния, как показано на рис. 11.10.
1. Этап разделения: массив рекурсивно делится пополам, превращая
задачу сортировки длинного массива в задачу сортировки коротких
массивов.
2. Этап слияния: когда длина подмассива достигает 1, разделение прекращается и начинается слияние, при котором два более коротких упорядоченных массива объединяются в один более длинный упорядоченный массив.
11.6. Сортировка слиянием 311
Этап разделения
Условие завершения
рекурсии
Этап слияния
Рис. 11.10. Этапы разделения и слияния в сортировке слиянием
11.6.1. Алгоритм
Этап разделения рекурсивно делит массив на два подмассива от вершины до
основания, как показано на рис. 11.11.
1. Вычисление средней точки массива mid, рекурсивное разделение левого
подмассива (интервал [left, mid]) и правого подмассива (интервал [mid
+ 1, right]).
2. Рекурсивное выполнение шага 1 до тех пор, пока длина интервала подмассива не станет равной 1.
Этап слияния заключается в объединении левого и правого подмассивов
в один упорядоченный массив снизу вверх. Следует отметить, что слияние начинается с подмассивов длиной 1, при этом каждый подмассив на этапе слияния уже упорядочен.
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 1
Рис. 11.11. Этапы сортировки слиянием. Шаг 1
312
Сортировка
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 2
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 3
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 4
Рис. 11.11. Продолжение. Шаги 2–4
11.6. Сортировка слиянием 313
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 5
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 6
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 7
Рис. 11.11. Продолжение. Шаги 5–7
314
Сортировка
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 8
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 9
Рекурсивное разделение
Условие завершения
рекурсии
Обратное слияние
Шаг 10
Рис. 11.11. Окончание. Шаги 8–10
Можно заметить, что порядок рекурсии в сортировке слиянием совпадает
с порядком обхода в глубину двоичного дерева.
Обход в глубину: сначала рекурсивный обход левого поддерева, затем
правого поддерева и в конце обработка корневого узла.
Сортировка слиянием: сначала рекурсивное разделение левого подмассива, затем правого подмассива и в конце обработка слияния.
11.6. Сортировка слиянием 315
Ниже приведен код реализации сортировки слиянием. Обратите внимание,
что интервал для слияния в массиве nums – это [left, right], а соответствующий
интервал в tmp – это [0, right - left].
# === File: merge_sort.py ===
def merge(nums: list[int], left: int, mid: int, right: int):
""" Слияние левого и правого подмассивов."""
# Левый подмассив: [left, mid], правый подмассив: [mid+1, right].
# Создание временного массива tmp для хранения результата слияния.
tmp = [0] * (right - left + 1)
# Инициализация начальных индексов для левого и правого подмассивов.
i, j, k = left, mid + 1, 0
# Пока в обоих подмассивах есть элементы, сравнивать и копировать меньший
# элемент во временный массив.
while i <= mid and j <= right:
if nums[i] <= nums[j]:
tmp[k] = nums[i]
i += 1
else:
tmp[k] = nums[j]
j += 1
k += 1
# Копирование оставшихся элементов из левого и правого подмассивов
# во временный массив.
while i <= mid:
tmp[k] = nums[i]
i += 1
k += 1
while j <= right:
tmp[k] = nums[j]
j += 1
k += 1
# Копирование элементов из временного массива tmp обратно в соответствующий
#‑ интервал оригинального массива nums.
for k in range(0, len(tmp)):
nums[left + k] = tmp[k]
def merge_sort(nums: list[int], left: int, right: int):
""" Сортировка слиянием."""
# Условие остановки.
if left >= right:
return # Завершение рекурсии, когда длина подмассива равна 1.
# Этап разделения.
mid = (left + right) // 2 # Вычисление средней точки.
merge_sort(nums, left, mid) # Рекурсивное разделение левого подмассива.
merge_sort(nums, mid + 1, right) # Рекурсивное разделение правого подмассива.
# Этап слияния.
merge(nums, left, mid, right)
316
Сортировка
11.6.2. Характеристики алгоритма
Временная сложность O(n log n), неадаптивная сортировка: разделение создает рекурсивное дерево высотой log n, общее количество операций слияния на каждом уровне составляет n, поэтому общая временная
сложность равна O(n log n).
Пространственная сложность O(n), не на месте: глубина рекурсии
равна log n. Используется кадр стека размером O(log n). Операция слияния требует использования вспомогательного массива, что занимает дополнительное пространство O(n).
Стабильная сортировка: в процессе слияния порядок равных элементов сохраняется.
11.6.3. Сортировка связного списка
Для связного списка сортировка слиянием имеет значительное преимущество
перед другими алгоритмами, позволяя оптимизировать пространственную сложность задачи сортировки связного списка до O(1).
Этап разделения: для выполнения разделения связного списка можно
использовать итерацию вместо рекурсии, что позволяет избежать использования стекового кадра рекурсии.
Этап слияния: в связном списке операции добавления и удаления узлов требуют лишь изменения ссылок (указателей), поэтому на этапе
слияния (объединение двух коротких упорядоченных списков в один
длинный упорядоченный список) нет необходимости создавать дополнительный список.
Конкретные детали реализации довольно сложны, заинтересованные читатели могут обратиться к соответствующей литературе для более глубокого изучения этого приема.
11.7. Пирамидальная сортировка
Совет
Перед чтением этого раздела рекомендуется изучить главу «Куча».
Пирамидальная сортировка – это эффективный алгоритм сортировки, основанный на структуре данных «куча». Для реализации пирамидальной сортировки можно использовать уже изученные операции построения кучи и извлечения элемента из кучи.
1. Ввод массива и построение минимальной кучи, при этом минимальный
элемент находится на вершине кучи.
2. Постоянное выполнение операции извлечения из кучи. Последовательная запись извлеченных элементов позволяет получить последовательность, отсортированную по возрастанию.
11.7. Пирамидальная сортировка 317
Хотя этот метод и работает, он требует использования дополнительного
массива для хранения извлеченных элементов, что неэффективно с точки
зрения использования пространства. На практике обычно используется более
элегантный способ реализации.
11.7.1. Алгоритм
Пусть дан массив длины n, процесс пирамидальной сортировки выглядит следующим образом (см. рис. 11.12):
1) ввод массива и построение максимальной кучи. После завершения максимальный элемент находится на вершине кучи;
2) обмен вершины кучи (первого элемента) с элементом в основании
кучи (последним элементом). После завершения обмена длина кучи
уменьшается на 1, а количество отсортированных элементов увеличивается на 1;
3) начать с вершины кучи и выполнить операцию упорядочивания сверху
вниз. После завершения упорядочивания свойства кучи восстанавливаются;
4) циклическое выполнение шагов 2 и 3. После n – 1 итераций сортировка
массива будет завершена.
Совет
На самом деле операция извлечения элемента из кучи также включает
шаги 2 и 3, только добавляется шаг извлечения элемента.
Пирамидальная сортировка
1. Ввод массива и построение кучи
Представление кучи в виде двоичного дерева
Представление кучи в виде массива
Вершина кучи
Основание кучи
Шаг 1
Рис. 11.12. Этапы пирамидальной сортировки. Шаг 1
5 Допустимый диапазон кучи
6 Отсортированный интервал
2. Циклическое извлечение максимального
элемента из кучи
(1) Обмен местами вершины и основания
кучи
(2) Упорядочивание сверху вниз
318
Сортировка
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
Шаг 2
(2) Упорядочивание сверху вниз
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
Шаг 3
Рис. 11.12. Продолжение. Шаги 2–3
3. Оставшийся один элемент не требует
обработки, сортировка завершена
11.7. Пирамидальная сортировка 319
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
Шаг 4
(2) Упорядочивание сверху вниз
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
Шаг 5
Рис. 11.12. Продолжение. Шаг 4–5
3. Оставшийся один элемент не требует
обработки, сортировка завершена
320
Сортировка
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
Шаг 6
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
Шаг 7
Рис. 11.12. Продолжение. Шаг 6–7
3. Оставшийся один элемент не требует
обработки, сортировка завершена
11.7. Пирамидальная сортировка 321
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
Шаг 8
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
Шаг 9
Рис. 11.12. Продолжение. Шаг 8–9
3. Оставшийся один элемент не требует
обработки, сортировка завершена
322
Сортировка
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
Шаг 10
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Допустимый Отсортировандиапазон кучи ный интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
Шаг 11
Рис. 11.12. Продолжение. Шаг 10–11
3. Оставшийся один элемент не требует
обработки, сортировка завершена
11.7. Пирамидальная сортировка 323
Пирамидальная сортировка
1. Ввод массива и построение кучи
2. Циклическое извлечение максимального
элемента из кучи
Отсортированный
интервал
(1) Обмен местами вершины и основания кучи
(2) Упорядочивание сверху вниз
3. Оставшийся один элемент не требует
обработки, сортировка завершена
Шаг 12
Рис. 11.12. Окончание. Шаг 12
В коде для выполнения упорядочивания сверху вниз используется функция
sift_down(), аналогичная той, что была в разделе «Куча». Следует отметить, что
длина кучи уменьшается по мере извлечения максимальных элементов, поэтому необходимо добавить в функцию sift_down() параметр длины n, чтобы
указать текущую действительную длину кучи. Ниже приведен код реализации.
# === File: heap_sort.py ===
def sift_down(nums: list[int], n: int, i: int):
""" Длина кучи равна n, упорядочивание сверху вниз, начиная с узла i."""
while True:
# Определение узла с максимальным значением среди узлов i, l, r,
# обозначенного как ma.
l = 2 * i + 1
r = 2 * i + 2
ma = i
if l < n and nums[l] > nums[ma]:
ma = l
if r < n and nums[r] > nums[ma]:
ma = r
# Если узел i максимальный или индексы l, r выходят за пределы,
# упорядочивание не требуется, выход.
if ma == i:
break
# Обмен двух узлов.
nums[i], nums[ma] = nums[ma], nums[i]
# Циклическое упорядочивание вниз.
i = ma
324
Сортировка
def heap_sort(nums: list[int]):
""" Сортировка кучей."""
# Операция построения кучи: упорядочивание всех узлов, кроме листьев.
for i in range(len(nums) // 2 - 1, -1, -1):
sift_down(nums, len(nums), i)
# Извлечение максимального элемента из кучи, цикл из n-1 итераций.
for i in range(len(nums) - 1, 0, -1):
# Обмен корневого узла и самого правого листа (обмен первого
# и последнего элементов).
nums[0], nums[i] = nums[i], nums[0]
# Упорядочивание сверху вниз, начиная с корневого узла.
sift_down(nums, i, 0)
11.7.2. Характеристики алгоритма
Временная сложность O(n log n), неадаптивная сортировка: операция построения кучи занимает время O(n). Временная сложность извлечения максимального элемента из кучи составляет O(log n), всего n – 1
итераций.
Пространственная сложность O(1), сортировка на месте: несколько
указателей используют пространство O(1). Обмен элементов и операции
упорядочивания выполняются на исходном массиве.
Нестабильная сортировка: при обмене элементов на вершине и внизу
кучи относительное положение равных элементов может измениться.
11.8. Блочная сортировка
Ранее рассмотренные алгоритмы сортировки относятся к алгоритмам сортировки на основе сравнения, которые осуществляют сортировку путем сравнения величин элементов. Временная сложность таких алгоритмов не может
превысить O(n log n). Далее рассмотрим алгоритмы сортировки без сравнения,
временная сложность которых может достигать линейного порядка.
Блочная сортировка является типичным применением стратегии «разделяй
и властвуй». Она создает набор упорядоченных по величине блоков, где каждый блок соответствует определенному диапазону данных, и равномерно распределяет элементы по этим блокам. Затем сортировка выполняется отдельно внутри каждого блока, после чего отсортированные данные объединяются
в соответствии с порядком блоков.
11.8.1. Алгоритм
Пусть дан массив длиной n, элементы которого являются числами с плавающей запятой в диапазоне [0, 1). Процесс блочной сортировки выглядит следующим образом (см. рис. 11.13):
1) инициализация k блоков, распределение n элементов по k блокам;
2) выполнение сортировки отдельно для каждого блока (здесь используется встроенная функция сортировки языка программирования);
3) объединение результатов в порядке от меньшего блока к большему.
11.8. Блочная сортировка 325
Массив для
сортировки
nums
Обход массива,
распределение чисел
по блокам
Блоки
Buckets
Сортировка отдельно
каждого блока
Диапазон
чисел
Объединение блоков
в конечный результат
Результирующий
массив nums
Рис. 11.13. Процесс выполнения алгоритма блочной сортировки
Ниже представлен код реализации.
# === File: bucket_sort.py ===
def bucket_sort(nums: list[float]):
""" Блочная сортировка."""
# Инициализация k = n/2 блоков, предполагается распределение 2 элементов
# на каждый блок.
k = len(nums) // 2
buckets = [[] for _ in range(k)]
# 1. Распределение элементов массива по блокам.
for num in nums:
# Диапазон входных данных [0, 1), использование num * k для отображения
# в индексный диапазон [0, k-1].
i = int(num * k)
# Добавление num в блок i.
buckets[i].append(num)
# 2. Выполнение сортировки для каждого блока.
for bucket in buckets:
# Использование встроенной функции сортировки, можно заменить на другой
# алгоритм сортировки.
bucket.sort()
# 3. Обход блоков и объединение результатов.
i = 0
for bucket in buckets:
for num in bucket:
nums[i] = num
i += 1
11.8.2. Характеристики алгоритма
Блочная сортировка подходит для обработки очень больших объемов данных. Например, если входные данные содержат 1 млн элементов и из-за
326
Сортировка
ограничений по памяти система не может загрузить все данные сразу, то
можно разделить данные на 1000 блоков, затем отсортировать отдельно
каждый блок и в конце объединить результаты.
Временная сложность O(n + k): при условии равномерного распределения элементов по блокам количество элементов в каждом блоке равно
n/k. Если сортировка одного блока занимает время O(n/k log n/k), то сортировка всех блоков занимает время O(n log n/k). Когда количество блоков
k достаточно велико, временная сложность стремится к O(n). При объединении результатов необходимо обойти все блоки и элементы, что занимает время O(n + k). В худшем случае все данные распределяются в один
блок, и сортировка этого блока занимает время O(n2).
Пространственная сложность O(n + k), не на месте: требуется дополнительное пространство для k блоков и всех n элементов.
Стабильность блочной сортировки зависит от стабильности алгоритма
сортировки элементов внутри блоков.
11.8.3. Реализация равномерного распределения
Время выполнения блочной сортировки теоретически может достигать O(n).
Ключевым моментом здесь является равномерное распределение элементов по блокам, так как в реальных данных распределение часто неравномерное. Например, мы хотим распределить все товары на маркетплейсе по
ценовым диапазонам в 10 блоков, но цены товаров распределены неравномерно: очень много товаров дешевле 100 руб. и очень мало дороже 1000 руб.
Если разделить ценовой диапазон на 10 равных частей, количество товаров
в каждом блоке будет значительно различаться.
Для достижения равномерного распределения можно сначала установить
приблизительную границу и грубо распределить данные по 3 блокам. После
этого блоки с большим количеством товаров можно разделить еще на
3 блока, пока количество элементов в каждом блоке не станет примерно
одинаковым.
Этот метод, по сути, создает рекурсивное дерево, цель которого – сделать
значения в листовых узлах как можно более равномерными, см. рис. 11.14.
Конечно, не обязательно каждый раз делить данные на 3 блока, конкретный способ деления можно выбирать гибко в зависимости от особенностей
данных.
Если заранее известна вероятность распределения цен товаров, можно
установить границы цен для каждого блока на основе этого распределения. Стоит отметить, что распределение данных не обязательно подсчитывать точно, можно использовать вероятностную модель для приближенного
определения.
Предположим, что цены товаров подчиняются нормальному распределению, таким образом, можно разумно установить ценовые диапазоны и равномерно распределить товары по блокам, как показано на рис. 11.15.
11.9. Сортировка подсчетом 327
Данные о товарах
Результат разделения данных
Доля данных
65 %
20 %
Ценовой
диапазон
18 %
35 %
12 %
13 %
12 %
10 %
15 %
Ценовой
диапазон
<10
10–20
20–35
35–50
50–100
100–500
>500
Доля данных
18 %
13 %
12 %
10 %
12 %
20 %
15 %
Рис. 11.14. Рекурсивное деление блоков
Плотность вероятности
Цена товара
Блок
Ценовой
диапазон
Рис. 11.15. Деление блоков на основе вероятностного распределения
11.9. Сортировка подсчетом
Сортировка подсчетом реализует сортировку путем подсчета количества элементов и обычно применяется к массивам целых чисел.
11.9.1. Простая реализация
Рассмотрим простой пример. Пусть дан массив nums длиной n, элементы которого – неотрицательные целые числа. Процесс сортировки подсчетом выглядит следующим образом (см. рис. 11.16):
1) обойти массив, найти максимальное число, обозначить его как m, затем
создать вспомогательный массив counter длиной m + 1;
328
Сортировка
2) с помощью counter подсчитать количество вхождений каждого числа
в nums, где counter[num] соответствует количеству вхождений числа num. Метод
подсчета прост: нужно обойти nums (пусть текущее число – num), и на каждой
итерации увеличивать counter[num] на 1;
3) так как индексы в counter естественно упорядочены, значит все числа уже отсортированы. Далее обходим counter и заполняем nums в порядке возрастания количества вхождений каждого числа.
Массив для
сортировки nums
Индекс (число)
Массив счетчиков
counter
Обход counter, заполнение nums
в соответствии с количеством вхождений.
Элемент counter[num] содержит количество
вхождений num
Обход nums и подсчет количества
вхождений каждого числа.
Элемент counter[num] содержит
количество вхождений num
Результирующий
массив nums
Рис. 11.16. Процесс сортировки подсчетом
Ниже приведен код реализации.
# === File: counting_sort.py ===
def counting_sort_naive(nums: list[int]):
""" Сортировка подсчетом."""
# Простая реализация, не подходит для сортировки объектов.
# 1. Статистика максимального элемента массива m.
m = 0
for num in nums:
m = max(m, num)
# 2. Подсчет количества вхождений каждого числа.
# counter[num] представляет количество вхождений num.
counter = [0] * (m + 1)
for num in nums:
counter[num] += 1
# 3. Обход counter, заполнение исходного массива nums.
i = 0
for num in range(m + 1):
for _ in range(counter[num]):
nums[i] = num
i += 1
11.9. Сортировка подсчетом 329
Связь между сортировкой подсчетом и блочной сортировкой
С точки зрения блочной сортировки каждый индекс массива подсчета counter
можно рассматривать как блок, а процесс подсчета количества – как распределение каждого элемента по соответствующему блоку. По сути, сортировка подсчетом является частным случаем блочной сортировки для целочисленных данных.
11.9.2. Полная реализация
Внимательный читатель мог заметить, что если входные данные – объекты,
то шаг 3 в алгоритме выше не будет работать. Предположим, что входные
данные – объекты товаров, и мы хотим отсортировать их по цене (члену класса),
но приведенный алгоритм может сортировать только цены отдельно.
Как же получить результат сортировки исходных данных? Сначала необходимо вычислить префиксную сумму counter. Как следует из названия, префиксная сумма в позиции i, т. е. prefix[i], равна сумме первых i элементов массива:
i
prefix i counter j .
j 0
Префиксная сумма имеет четкий смысл: prefix[num] - 1 представляет индекс последнего вхождения элемента num в результирующем массиве res.
Эта информация очень важна, так как она указывает, где каждый элемент должен находиться в результирующем массиве. Далее, обходим исходный массив
nums в обратном порядке, и на каждой итерации выполняем следующие два шага:
1) вставить num в массив res на позицию prefix[num] - 1;
2) уменьшить префиксную сумму prefix[num] на 1, чтобы получить индекс
для следующего размещения num.
После завершения обхода массив res будет содержать отсортированные данные, и в завершение можно использовать res для замены исходного массива
nums. На рис. 11.17 демонстрируется полный процесс сортировки подсчетом.
Исходный массив
nums
Число num
Массив счетчиков
counter
Шаг 1
Обход nums и подсчет количества вхождений
каждого числа.
Элемент counter[num] содержит количество
вхождений num
Рис. 11.17. Этапы сортировки подсчетом. Шаг 1
330
Сортировка
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 2
Вычисление префиксной суммы counter, обозначить как prefix
prefix[num] – 1 представляет последний индекс появления num в res
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 3
Обратный обход всех элементов num из nums, на каждом
проходе выполнить:
1. Поместить num в массив res по индексу prefix[num] – 1
2. Уменьшить префиксную сумму prefix[num] на 1, чтобы
получить индекс для размещения следующего num
Рис. 11.17. Продолжение. Шаги 2–3
11.9. Сортировка подсчетом 331
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 4
Обратный обход всех элементов num из nums, на каждом
проходе выполнить:
1. Поместить num в массив res по индексу prefix[num] – 1
2. Уменьшить префиксную сумму prefix[num] на 1, чтобы
получить индекс для размещения следующего num
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 5
Обратный обход всех элементов num из nums, на каждом
проходе выполнить:
1. Поместить num в массив res по индексу prefix[num] – 1
2. Уменьшить префиксную сумму prefix[num] на 1, чтобы
получить индекс для размещения следующего num
Рис. 11.17. Продолжение. Шаги 4–5
332
Сортировка
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 6
Обратный обход всех элементов num из nums, на каждом
проходе выполнить:
1. Поместить num в массив res по индексу prefix[num] – 1
2. Уменьшить префиксную сумму prefix[num] на 1, чтобы
получить индекс для размещения следующего num
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 7
Пропуск оставшихся шагов обхода, переход
к завершению обхода.
В этот момент в res уже находится отсортированный
результат
Рис. 11.17. Продолжение. Шаги 6–7
11.9. Сортировка подсчетом 333
Исходный массив
nums
Результирующий
массив res
Число num
Префиксная сумма
prefix (counter)
Шаг 8
Замена исходного массива nums на результат res
Рис. 11.17. Окончание. Шаг 8
Ниже приведена реализация сортировки подсчетом.
# === File: counting_sort.py ===
def counting_sort(nums: list[int]):
""" Сортировка подсчетом."""
# Полная реализация, сортируемые объекты, стабильная сортировка.
# 1. Определение максимального элемента массива m.
m = max(nums)
# 2. Подсчет количества вхождений каждого числа.
# counter[num] представляет количество вхождений num.
counter = [0] * (m + 1)
for num in nums:
counter[num] += 1
# 3. Вычисление префиксной суммы counter, преобразование "количества
# вхождений" в "конечный индекс".
# То есть counter[num]-1 – это индекс последнего вхождения num в res.
for i in range(m):
counter[i + 1] += counter[i]
# 4. Обратный обход nums, заполнение элементов в результирующий массив res.
# Инициализация массива res для записи результата.
n = len(nums)
res = [0] * n
for i in range(n - 1, -1, -1):
num = nums[i]
res[counter[num] - 1] = num # Размещение num на соответствующем индексе.
counter[num] -= 1 # Уменьшение префиксной суммы на 1, получение
# индекса для следующего размещения num.
# Использование результирующего массива res для замены исходного массива nums.
for i in range(n):
nums[i] = res[i]
334
Сортировка
11.9.3. Характеристики алгоритма
Временная сложность O(n + m), неадаптивная сортировка: включает
обход nums и counter, оба обхода выполняются за линейное время. Обычно
n ≫ m, временная сложность стремится к O(n).
Пространственная сложность O(n + m), не на месте: используются
вспомогательные массивы res и counter длиной n и m соответственно.
Стабильная сортировка: поскольку элементы добавляются в res от
конца к началу, обратный обход nums позволяет избежать изменения относительного положения равных элементов, обеспечивая стабильность
сортировки. На самом деле прямой обход nums также дает правильный
результат, но он уже не будет стабильным.
11.9.4. Ограничения
На этом этапе может показаться, что сортировка подсчетом весьма изящна,
так как позволяет эффективно сортировать, просто подсчитывая количество.
Однако условия для применения сортировки подсчетом довольно строгие.
Сортировка подсчетом применима только к неотрицательным целым
числам. Если требуется использовать ее для других типов данных, необходимо убедиться, что их можно преобразовать в неотрицательные целые числа,
не изменяя относительное положение элементов. Например, для массива целых чисел с отрицательными значениями можно сначала добавить ко всем
числам константу, чтобы все числа стали положительными, а после сортировки вернуть их к первоначальным значениям.
Сортировка подсчетом подходит для случаев, когда объем данных
велик, а диапазон данных мал. Например, в приведенном выше примере
m не должно быть слишком велико, иначе потребуется слишком много памяти.
А когда n ≪ m, сортировка подсчетом использует время O(m), что может быть
медленнее, чем алгоритмы сортировки с временной сложностью O(n log n).
11.10. Поразрядная сортировка
В предыдущем разделе была рассмотрена сортировка подсчетом, которая хорошо подходит для случаев, когда объем данных n велик, а диапазон данных
m мал. Предположим, необходимо отсортировать n = 106 номеров студентов, где
номер – это восьмизначное число, т. е. диапазон данных m = 108 очень велик.
Использование сортировки подсчетом потребует выделения большого объема
памяти, тогда как поразрядная сортировка позволяет избежать этой проблемы.
Поразрядная сортировка основывается на той же идее, что и сортировка
подсчетом, и также реализуется путем подсчета количества. Но поразрядная
сортировка использует прогрессивные отношения между разрядами чисел,
выполняя сортировку по каждому разряду.
11.10.1. Алгоритм
Возьмем в качестве примера данные о номерах студентов. Предположим, что
наименьший разряд – это 1-й разряд, а наибольший – 8-й разряд. Процесс поразрядной сортировки выглядит следующим образом (см. рис. 11.18):
11.10. Поразрядная сортировка 335
1) инициализация разряда k = 1;
2) выполнение сортировки подсчетом по k-му разряду номеров студентов.
После завершения данные будут отсортированы по k-му разряду в порядке возрастания;
3) увеличение k на 1 и возврат на шаг 2. Продолжение итераций до завершения сортировки по всем разрядам.
Входной
массив nums.
Инициализация k = 1
Сортировка
1-го разряда
Сортировка
2-го разряда
Аналогично
сортировка
разрядов 3–7
Сортировка 8-го
разряда.
Вывод отсортированного
результата
Рис. 11.18. Процесс алгоритма сортировки по разрядам
Теперь проанализируем код реализации. Пусть дано число x в d-ричной системе исчисления. Чтобы получить его k-й разряд xk, можно использовать следующую формулу:
x
x k k 1 mod d,
d
где ⌊a⌋ обозначает округление числа a вниз, а mod d обозначает взятие остатка
от деления на d. Для нашей задачи о номерах студентов d = 10 и k ∈ [1, 8].
Кроме того, необходимо немного изменить код сортировки подсчетом, чтобы он мог сортировать по k-му разряду числа.
# === File: radix_sort.py ===
def digit(num: int, exp: int) -> int:
""" Получение k-го разряда элемента num, где exp = 10^(k-1)."""
# Передача exp вместо k позволяет избежать повторного выполнения дорогого
# вычисления степени.
return (num // exp) % 10
336
Сортировка
def counting_sort_digit(nums: list[int], exp: int):
""" Сортировка подсчетом (по k-му разряду nums)."""
# Десятичный диапазон цифр составляет от 0 до 9, поэтому требуется массив
# корзин длиной 10.
counter = [0] * 10.
n = len(nums)
# Подсчет количества вхождений каждой цифры от 0 до 9.
for i in range(n):
d = digit(nums[i], exp) # Получение k-й цифры числа nums[i],
#обозначенной как d.
counter[d] += 1 # Подсчет количества вхождений цифры d.
# Вычисление префиксной суммы для преобразования "количества вхождений"
# в "индексы массива".
for i in range(1, 10):
counter[i] += counter[i - 1]
# Обратный обход, заполнение элементов в res на основе результатов подсчета
# в корзинах.
res = [0] * n
for i in range(n - 1, -1, -1):
d = digit(nums[i], exp)
j = counter[d] - 1 # Получение индекса j для d в массиве.
res[j] = nums[i] # Заполнение текущего элемента в индекс j.
counter[d] -= 1 # Уменьшение количества d на 1.
# Перезапись исходного массива nums результатами сортировки.
for i in range(n):
nums[i] = res[i]
def radix_sort(nums: list[int]):
"""Базовая сортировка."""
# Получение максимального элемента массива для определения
# максимальной разрядности.
m = max(nums)
# Обход от младшего разряда к старшему.
exp = 1
while exp <= m:
# Выполнение сортировки подсчетом для k-й цифры элементов массива.
# k = 1 -> exp = 1
# k = 2 -> exp = 10
# То есть exp = 10^(k-1).
counting_sort_digit(nums, exp)
exp *= 10
Почему сортировка начинается с младшего разряда?
В последовательных раундах сортировки результаты последующего раунда перекрывают результаты предыдущего. Например, если в первом раунде сортировки результат a < b, а во втором a > b, то результат второго раунда заменит результат первого. Поскольку старшие разряды имеют более высокий приоритет,
чем младшие, следует сначала сортировать младшие разряды, а затем старшие.
11.11. Резюме 337
11.10.2. Характеристики алгоритма
По сравнению с сортировкой подсчетом поразрядная сортировка подходит
для случаев с большим диапазоном чисел, но при условии, что данные можно представить в формате фиксированной разрядности, и разрядность
не должна быть слишком большой. Например, числа с плавающей запятой
не подходят для поразрядной сортировки, поскольку их разрядность k слишком велика, что может привести к временной сложности O(nk) ≫ O(n2).
Временная сложность O(nk), неадаптивная сортировка: пусть объем данных равен n, данные имеют d-ричную систему счисления, максимальная разрядность равна k. Тогда выполнение сортировки подсчетом
для одной цифры требует времени O(n + d), сортировка всех k цифр требует времени O((n + d)k). Обычно d и k относительно малы, и временная
сложность стремится к O(n).
Пространственная сложность O(n + d), не на месте: как и сортировка
подсчетом, поразрядная сортировка требует использования массивов
res и counter длиной n и d.
Стабильная сортировка: если сортировка подсчетом стабильна, то
и поразрядная сортировка стабильна. Если сортировка подсчетом нестабильна, то поразрядная сортировка не может гарантировать правильный результат сортировки.
11.11. Резюме
1. Ключевые моменты
Сортировка пузырьком реализует сортировку путем обмена соседних
элементов. Добавив флаг для досрочного выхода из цикла, можно оптимизировать лучшую временную сложность пузырьковой сортировки до O(n).
Сортировка вставками в каждом раунде вставляет элемент из неотсортированного диапазона в правильное место в отсортированном
диапазоне. Хотя временная сложность этой сортировки составляет O(n 2), благодаря относительно малому количеству элементарных
операций она хорошо подходит для задач сортировки небольших
объемов данных.
Быстрая сортировка основана на операции разделения с использованием опорного элемента. При разделении возможна ситуация, когда
каждый раз выбирается наихудший опорный элемент, что приводит
к ухудшению временной сложности до O(n2). Введение медианного
или случайного опорного элемента может снизить вероятность такого
ухудшения. Метод хвостовой рекурсии может эффективно уменьшить
глубину рекурсии и оптимизировать пространственную сложность до
O(log n).
Сортировка слиянием включает два этапа – разделение и слияние –
и является типичным представителем стратегии «разделяй и властвуй».
В сортировке слиянием для сортировки массива требуется создание
338
Сортировка
вспомогательного массива, поэтому пространственная сложность составляет O(n). Однако для сортировки связного списка пространственную сложность можно оптимизировать до O(1).
Блочная сортировка включает три этапа: распределение данных по
блокам, сортировку внутри блоков и объединение результатов. Она
также демонстрирует стратегию «разделяй и властвуй» и подходит
для случаев с большими объемами данных. Ключ к эффективной блочной сортировке заключается в равномерном распределении данных
по блокам.
Сортировка подсчетом является частным случаем блочной сортировки,
она реализует сортировку путем подсчета количества вхождений данных. Сортировка подсчетом подходит для случаев с большим объемом
данных, но ограниченным диапазоном и требует, чтобы данные могли
быть преобразованы в положительные целые числа.
Поразрядная сортировка реализует сортировку данных путем последовательной сортировки по разрядам. Для этого требуется, чтобы данные
можно было представить в виде чисел фиксированной разрядности.
В целом мы стремимся найти алгоритм сортировки, обладающий такими
преимуществами, как высокая эффективность, стабильность, выполнение на месте и адаптивность. Однако, как и в случае с другими структурами данных и алгоритмами, не существует алгоритма сортировки, который одновременно удовлетворял бы всем этим условиям. На практике
необходимо выбирать подходящий алгоритм сортировки в зависимости
от характеристик данных.
На рис. 11.19 приведено сравнение таких характеристик основных алгоритмов сортировки, как эффективность, стабильность, выполнение на
месте и адаптивность.
Плохая
Хорошая
O(n k)
Поразрядная
O(n + k)
Блочная
O(n + m)
O(n log n)
Пирамидальная
Подсчетом
O(n log n)
O(n log n)
Слиянием
Быстрая
O(n)
Вставками
O(n k)
O(n + m)
O(n2)
O(n log n)
O(n log n)
O(n2)
O(n2)
O(n2)
O(n2)
Худшая
O(n + b)
O(n + m)
O(n + k)
O(1)
O(n)
O(log n)
O(1)
O(1)
O(1)
Пространственная
сложность
Сравнение
Не сравнение
Не сравнение
Не сравнение
Неадаптивный
Неадаптивный
Неадаптивный
Неадаптивный
Стабильный Не на месте
Стабильный Не на месте
Стабильный Не на месте
На месте
Нестабильный
Сравнение
Неадаптивный
Сравнение
Сравнение
Адаптивный
Неадаптивный
На месте
Нестабильный
Сравнение
Сравнение
Неадаптивный
Адаптивный
Основанность на
сравнении
Адаптивность
Стабильный Не на месте
На месте
Стабильный
На месте
На месте
Нестабильный
Стабильный
Местность
Стабильность
Рис. 11.19. Сравнение алгоритмов сортировки
n – размер данных
В блочной сортировке k – количество блоков
В сортировке подсчетом m – диапазон данных
В поразрядной сортировке k – максимальное количество разрядов, b – основание системы счисления данных
O(n k)
O(n + m)
O(n + k)
O(n log n)
O(n log n)
O(n log n)
O(n2)
O(n2)
O(n2)
O(n2)
O(n)
Средняя
Временная сложность
Лучшая
Пузырьком
Средняя
Линейная сортировка
O(n)
Сортировка
разделением
O(n log n)
Сортировка
обходом
O(n2)
Выбором
Алгоритм
сортировки
11.11. Резюме 339
340
Сортировка
2. Вопросы и ответы
Вопрос. В каких случаях необходима стабильность алгоритма сортировки?
Ответ. В реальной жизни может возникнуть необходимость сортировки
объектов по какому-либо атрибуту. Например, у студентов есть два атрибута:
имя и рост. Мы хотим осуществить многоуровневую сортировку: сначала по
имени, получив (A, 180) (B, 185) (C, 170) (D, 170), затем по росту. Если алгоритм сортировки нестабилен, возможно получение такого результата: (D, 170)
(C, 170) (A, 180) (B, 185).
Можно заметить, что позиции студентов D и C поменялись и порядок по
имени был нарушен, что является нежелательным результатом.
Вопрос. Можно ли поменять порядок выполнения операций поиска справа
налево и поиска слева направо в методе разделения с использованием стража?
Ответ. Нет, если в качестве опорного элемента выбран самый левый элемент, необходимо сначала искать справа налево, а затем искать слева направо.
Этот вывод может показаться неочевидным, разберем его причины.
Последний шаг метода разделения partition() заключается в обмене
nums[left] и nums[i]. После обмена элементы слева от опорного элемента должны быть <= опорного элемента, что требует выполнения условия nums[left]
>= nums[i] перед обменом. Если сначала искать слева направо, то в случае,
если не удастся найти элемент больше опорного, цикл завершится при i ==
j, и возможна ситуация nums[j] == nums[i] > nums[left]. То есть на последнем
шаге обмена элемент, больший опорного, будет перемещен в начало массива,
что приведет к неудаче разделения с использованием стража.
Например, если для массива [0, 0, 0, 0, 1] искать слева направо, после
разделения с использованием стража получится [1, 0, 0, 0, 0], что является
неправильным результатом.
Если выбрать nums[right] в качестве опорного элемента, то порядок будет обратным, и необходимо сначала искать слева направо.
Вопрос. Почему при оптимизации хвостовой рекурсии выбор короткого
массива гарантирует, что глубина рекурсии не превысит log n?
Ответ. Глубина рекурсии – это количество текущих невозвращенных рекурсивных вызовов. На каждом этапе разделения с использованием стража
исходный массив делится на два подмассива. После оптимизации хвостовой
рекурсии длина подмассива, в который продолжается рекурсия, не превышает
половины длины исходного массива. В худшем случае, если длина всегда будет
составлять половину, окончательная глубина рекурсии составит log n.
В оригинальном алгоритме быстрой сортировки возможно последовательное рекурсивное обращение к более длинным массивам, в худшем случае – n,
n − 1, ..., 2, 1, что приводит к глубине рекурсии n. Оптимизация хвостовой рекурсии позволяет избежать такой ситуации.
Вопрос. Если все элементы массива равны, является ли временная сложность быстрой сортировки O(n2)? Как справиться с таким вырождением?
Ответ. Да. В этом случае можно рассмотреть возможность разделения
массива на три части с использованием стража: меньше, равно и больше
опорного элемента. Рекурсия продолжается только для частей, меньших
и больших опорного элемента. При таком подходе массив с одинаковыми
11.11. Резюме 341
элементами будет отсортирован за одну итерацию разделения с использованием стража.
Вопрос. Почему временная сложность сортировки подсчетом в худшем случае составляет O(n2)?
Ответ. В худшем случае все элементы попадут в одну корзину. Если для сортировки этих элементов используется алгоритм с временной сложностью
O(n2), то общая временная сложность составит O(n2).
Глава 12
Разделяй и властвуй
Абстракция
Сложные задачи разбиваются на более простые уровни, и каждое разбиение
делает их более простыми.
Метод «разделяй и властвуй» раскрывает важный факт: начиная с простого,
все перестает быть сложным.
12.1. Стратегия «разделяй и властвуй» 343
12.1. Стратегия «разделяй и властвуй»
«Разделяй и властвуй» – это важная и распространенная стратегия в алгоритмах. Обычно она реализуется с помощью рекурсии и включает два этапа: разделение и объединение.
Разделение (этап разбиения): рекурсивное разбиение исходной задачи на две или более подзадачи до тех пор, пока не будет достигнута наименьшая подзадача.
Объединение (этап слияния): начиная с решения наименьших подзадач, снизу вверх объединяются решения всех других подзадач, чтобы построить решение исходной задачи.
Сортировка слиянием является типичным примером применения стратегии «разделяй и властвуй» (см. рис. 12.1).
Разделение: рекурсивное разбиение исходного массива (исходной задачи) на два подмассива (подзадачи) до тех пор, пока в подмассивах
не останется по одному элементу (наименьшая подзадача).
Объединение: снизу вверх объединяются упорядоченные подмассивы
(решения подзадач), чтобы получить упорядоченный исходный массив
(решение исходной задачи).
Разделение
(этап разбиения)
Наименьшая подзадача
Объединение
(этап слияния)
Рис. 12.1. Стратегия «разделяй и властвуй» в сортировке слиянием
12.1.1. Определение задачи для метода
«разделяй и властвуй»
Чтобы определить, подходит ли задача для решения методом «разделяй и властвуй», можно использовать следующие критерии:
1) задачу можно разбить: исходную задачу можно разбить на более мелкие, аналогичные подзадачи, которые можно рекурсивно разделить аналогичным образом;
344
Разделяй и властвуй
2) подзадачи независимы: подзадачи не пересекаются, не зависят друг от
друга и могут быть решены независимо;
3) решения подзадач можно объединить: решение исходной задачи получается путем объединения решений подзадач.
Очевидно, что сортировка слиянием соответствует этим трем критериям.
Задачу можно разбить: рекурсивное разбиение массива (исходной задачи) на два подмассива (подзадачи).
Подзадачи независимы: каждый подмассив можно отсортировать независимо (подзадачи можно решить независимо).
Решения подзадач можно объединить: два упорядоченных подмассива (решения подзадач) можно объединить в один упорядоченный массив
(решение исходной задачи).
12.1.2. Повышение эффективности с помощью
стратегии «разделяй и властвуй»
Стратегия «разделяй и властвуй» позволяет не только эффективно решать алгоритмические задачи, но и повышать эффективность алгоритмов. Алгоритмы быстрой сортировки, сортировки слиянием и пирамидальной
сортировки быстрее, чем сортировка выбором, пузырьком и вставками, именно благодаря применению стратегии «разделяй и властвуй».
Возникает вопрос: почему метод «разделяй и властвуй» повышает эффективность алгоритма, в чем его основная логика? Иными словами, почему разбиение большой задачи на несколько подзадач, решение этих подзадач
и объединение их решений в решение исходной задачи оказывается более эффективным, чем непосредственное решение исходной задачи? Этот вопрос можно обсудить с точки зрения количества операций и параллельных вычислений.
1. Оптимизация количества операций
Возьмем, к примеру, сортировку пузырьком, которая требует времени O(n2)
для обработки массива длиной n. Предположим, что мы разделили массив на
два подмассива, как показано на рис. 12.2. Тогда разбиение потребует времени
O(n), сортировка каждого подмассива – O((n/2)2), а объединение двух подмассивов – O(n). Общая временная составит:
n 2
n2
O n + 2 n O 2n .
2
2
Далее, решим следующее неравенство, в котором левая и правая части представляют общее количество операций до и после разбиения соответственно:
n2
2n,
2
n2
n2 2n > 0,
2
n(n 4 ) 0.
n2
12.1. Стратегия «разделяй и властвуй» 345
Разделение
O(n)
Сортировка
пузырьком
O(n2)
Сортировка
пузырьком
O((n/2)2)
Сортировка
пузырьком
O((n/2)2)
Объединение
O(n)
Общая сложность
O(n2)
Общая сложность
O(n2/2+2n)
Рис. 12.2. Сортировка пузырьком до и после разбиения массива
Это означает, что при n > 4 количество операций после разбиения меньше, и эффективность сортировки должна быть выше. Обратите внимание,
что временная сложность после разбиения остается квадратичной O(n2), но постоянный коэффициент в сложности уменьшается.
Если продолжить разбиение подмассивов пополам, пока в них не останется по одному элементу, то получится сортировка слиянием, временная
сложность которой составляет O(n log n).
А что, если мы установим несколько дополнительных точек разделения и равномерно разделим исходный массив на k подмассивов? Эта ситуация
очень похожа на блочную сортировку, которая хорошо подходит для сортировки очень больших объемов данных, и теоретически ее временная сложность
может достигать O(n + k).
2. Оптимизация параллельных вычислений
Известно, что подзадачи, созданные методом «разделяй и властвуй», независимы друг от друга, поэтому их обычно можно решать параллельно. Таким
образом, этот метод не только снижает временную сложность алгоритма, но
и способствует параллельной оптимизации операционной системы.
Параллельная оптимизация особенно эффективна в многоядерной или
многопроцессорной среде, поскольку система может одновременно обрабатывать несколько подзадач, более полно используя вычислительные ресурсы,
что значительно сокращает общее время выполнения.
Например, в блочной сортировке, изображенной на рис. 12.3, огромный
объем данных равномерно распределяется по блокам. Задачи сортировки всех
блоков можно распределить по вычислительным единицам, а затем объединить результаты.
346
Разделяй и властвуй
Неупорядоченные данные
Блок
Вычисления
Упорядоченные данные
Рис. 12.3. Параллельные вычисления в блочной сортировке
12.1.3. Типичные сценарии применения
стратегии «разделяй и властвуй»
С одной стороны, стратегию «разделяй и властвуй» можно использовать для
решения многих классических алгоритмических задач.
Поиск ближайшей пары точек: этот алгоритм сначала делит множество точек на две части, затем находит ближайшую пару точек в каждой части, а затем находит ближайшую пару точек, охватывающую
обе части.
Умножение больших чисел: например, алгоритм Карацубы, который
разлагает умножение больших чисел на несколько операций умножения
и сложения меньших чисел.
Умножение матриц: например, алгоритм Штрассена, который разлагает умножение больших матриц на несколько операций умножения и сложения матриц меньшего размера.
Задача о Ханойских башнях: эту задачу можно решить с помощью
рекурсии, что является типичным применением стратегии «разделяй
и властвуй».
Задача о количестве инверсий: если в последовательности предыдущее число больше последующего, то эти два числа образуют инверсию.
Задачу о количестве инверсий можно решить с помощью подхода «разделяй и властвуй» и сортировки слиянием.
С другой стороны, стратегия «разделяй и властвуй» широко применяется
в разработке алгоритмов и структур данных.
Двоичный поиск: такой поиск делит отсортированный массив на
две части по индексу среднего элемента. Затем, в зависимости от
12.2. Применение стратегии «разделяй и властвуй» для поиска 347
результата сравнения целевого значения со средним элементом, решает, какую половину исключить, и выполняет ту же операцию на
оставшейся части.
Сортировка слиянием: уже была рассмотрена в начале этого раздела,
не будем еще раз повторяться.
Быстрая сортировка: эта сортировка выбирает опорное значение, затем
делит массив на два подмассива, элементы одного из которых меньше
опорного значения, а элементы другого – больше. Затем выполняет ту же
операцию с обеими частями, пока в подмассиве не останется один элемент.
Блочная сортировка: основная идея этой сортировки заключается
в распределении данных по нескольким блокам и сортировке элементов в каждом из них. Затем происходит последовательное извлечение
элементов из каждого блока для построения отсортированного массива.
Деревья: например, двоичные деревья поиска, АВЛ-дерево, красно-черное дерево, B-дерево, дерево B+ и т. д. Операции поиска, вставки и удаления в них можно рассматривать как применение стратегии «разделяй
и властвуй».
Кучи: куча – это особый вид полного двоичного дерева, и такие операции, как вставка, удаление и упорядочивание, фактически подразумевают использование метода «разделяй и властвуй».
Хеш-таблицы: хотя хеш-таблицы напрямую не применяют подход
«разделяй и властвуй», некоторые решения для разрешения коллизий
в хешировании косвенно используют эту стратегию. Например, длинные
цепочки в методе цепной адресации преобразуются в красно-черные деревья для повышения эффективности поиска.
Можно сказать, что стратегия «разделяй и властвуй» – это своего рода
«скрытая» алгоритмическая идея, присутствующая в различных алгоритмах и структурах данных.
12.2. Применение стратегии «разделяй
и властвуй» для поиска
Мы уже знаем, что алгоритмы поиска делятся на две большие категории.
Полный перебор: реализуется путем обхода структуры данных, временная сложность составляет O(n).
Адаптивный поиск: использует особую организацию данных или априорную информацию, временная сложность может достигать O(log n) или
даже O(1).
На практике алгоритмы поиска с временной сложностью O(log n) обычно реализуются на основе стратегии «разделяй и властвуй», например
двоичный поиск и деревья.
Двоичный поиск на каждом шаге разбивает задачу (поиск целевого
элемента в массиве) на более мелкую задачу (поиск целевого элемента
в половине массива). Этот процесс продолжается до тех пор, пока массив
не станет пустым или не будет найден целевой элемент.
348
Разделяй и властвуй
Деревья являются представителями стратегии «разделяй и властвуй».
В структурах данных, таких как двоичное дерево поиска, АВЛ-дерево,
куча и др., временная сложность различных операций составляет O(log n).
Стратегия «разделяй и властвуй» для двоичного поиска выглядит следующим образом.
Задачу можно разбить: двоичный поиск рекурсивно разбивает исходную задачу (поиск в массиве) на подзадачи (поиск в половине
массива), что достигается сравнением среднего элемента с целевым
элементом.
Подзадачи независимы: в двоичном поиске на каждом этапе обрабатывается только одна подзадача, которая не зависит от других
подзадач.
Решения подзадач не требуют объединения: двоичный поиск направлен на поиск конкретного элемента, поэтому объединять решения
подзадач не требуется. Когда подзадача решена, исходная задача также
считается решенной.
Стратегия «разделяй и властвуй» повышает эффективность поиска, поскольку при грубом поиске на каждом этапе можно исключить только один
вариант, тогда как при поиске «разделяй и властвуй» на каждом этапе можно
исключить половину вариантов.
1. Реализация двоичного поиска на основе
стратегии «разделяй и властвуй»
В предыдущих главах двоичный поиск был реализован на основе итераций.
Теперь мы реализуем его на основе принципа «разделяй и властвуй» (рекурсии).
Задача
Дан отсортированный массив nums длиной n, в котором все элементы уникальны. Необходимо найти элемент target.
Для применения стратегии «разделяй и властвуй» обозначим подзадачу для
поискового интервала [i, j] как f(i, j).
Начав с исходной задачи f(0, n – 1), выполняем двоичный поиск по следующему алгоритму:
1) вычисление средней точки m поискового интервала [i, j] и исключение
половины интервала на основе сравнения со средним элементом;
2) рекурсивное решение подзадачи с уменьшенным вдвое размером, возможны варианты f(i, m – 1) и f(m + 1, j);
3) повторение шагов 1 и 2 до тех пор, пока не будет найден элемент target
или интервал не станет пустым.
На рис. 12.4 иллюстрируется процесс применения стратегии «разделяй
и властвуй» при двоичном поиске элемента 6 в массиве.
12.2. Применение стратегии «разделяй и властвуй» для поиска 349
В итоге найден индекс 2 элемента 6
Рис. 12.4. Стратегия «разделяй и властвуй» в двоичном поиске
В коде реализации объявляется рекурсивная функция dfs() для решения задачи f(i, j).
# === File: binary_search_recur.py ===
def dfs(nums: list[int], target: int, i: int, j: int) -> int:
""" Двоичный поиск: задача f(i, j)."""
# Если интервал пуст, значит целевой элемент отсутствует, возвращается -1.
if i > j:
return -1
# Вычисление индекса средней точки m.
m = (i + j) // 2
if nums[m] < target:
# Рекурсивная подзадача f(m+1, j).
return dfs(nums, target, m + 1, j)
elif nums[m] > target:
# Рекурсивная подзадача f(i, m-1).
return dfs(nums, target, i, m - 1)
else:
# Найден целевой элемент, возвращается его индекс.
return m
def binary_search(nums: list[int], target: int) -> int:
""" Двоичный поиск."""
n = len(nums)
# Решение задачи f(0, n-1).
return dfs(nums, target, 0, n - 1)
350
Разделяй и властвуй
12.3. Задача построения двоичного дерева
Задача
Даны результаты прямого порядка обхода preorder и симметричного порядка обхода inorder двоичного дерева. Необходимо построить двоичное дерево
и вернуть его корневой узел. Предполагается, что в двоичном дереве нет узлов с повторяющимися значениями, как показано на рис. 12.5.
Построение
двоичного дерева
Прямой обход
preorder
Симметричный обход
inorder
Рис. 12.5. Пример данных для построения двоичного дерева
1. Проверка критериев стратегии «разделяй и властвуй»
Исходная задача, заключающаяся в построении двоичного дерева из обходов
preorder и inorder, является типичной задачей типа «разделяй и властвуй».
Задачу можно разбить: с точки зрения стратегии «разделяй и властвуй» исходную задачу можно разделить на две подзадачи. Построение
левого поддерева и построение правого поддерева плюс один шаг: инициализация корневого узла. Для каждого поддерева (подзадачи) можно
повторно использовать вышеуказанный метод разделения и разделить
его на более мелкие поддеревья (подзадачи), пока не будет достигнута
минимальная подзадача (пустое поддерево).
Подзадачи независимы: левое и правое поддеревья независимы друг
от друга, между ними нет пересечений. При построении левого поддерева необходимо учитывать только части симметричного и прямого порядка обхода, соответствующие левому поддереву. Для правого поддерева аналогично.
Решения подзадач можно объединить: как только получены левое
и правое поддеревья (решения подзадач), их можно связать с корневым
узлом и получить решение исходной задачи.
2. Разделение поддеревьев
Мы определили, что эту задачу можно решить с помощью стратегии «разделяй
и властвуй». Но как именно разделить левое и правое поддеревья с помощью прямого (preorder) и симметричного (inorder) порядков обхода?
12.3. Задача построения двоичного дерева 351
Согласно определению preorder и inorder можно разделить на три части.
Прямой обход: [корневой узел | левое поддерево | правое поддерево],
например для дерева на рис. 12.5 это соответствует [3 | 9 | 2 1 7].
Симметричный обход: [левое поддерево | корневой узел | правое поддерево], например для дерева на рис. 12.5 это соответствует [9 | 3 | 1 2 7].
На примере этих данных можно получить результат разделения, следуя алгоритму на рис. 12.6.
1. Первый элемент прямого обхода 3 является значением корневого узла.
2. Найти индекс корневого узла 3 в inorder – используя этот индекс, можно
разделить inorder на [9 | 3 | 1 2 7].
3. На основании результата разделения inorder легко определить, что количество узлов в левом и правом поддеревьях составляет 1 и 3 соответственно. Таким образом, можно разделить preorder на [3 | 9 | 2 1 7].
Первый элемент является корневым узлом
Прямой обход
preorder
Поиск индекса корневого узла
Симметричный
обход inorder
Левое поддерево
имеет 1 узел
Правое поддерево
имеет 3 узла
Прямой обход
preorder
Рис. 12.6. Разделение поддеревьев в прямом и симметричном обходах
3. Описание интервалов поддеревьев на основе переменных
По вышеописанному методу разделения мы получили интервалы индексов корневого узла, левого и правого поддеревьев в preorder и inorder. Для
описания этих интервалов индексов необходимо использовать несколько
указателей.
Индекс корневого узла текущего дерева в preorder обозначим как i.
Индекс корневого узла текущего дерева в inorder обозначим как m.
Интервал индексов текущего дерева в inorder обозначим как [l, r].
С помощью этих переменных можно описать индекс корневого узла в preorder
и интервал индексов поддеревьев в inorder, как показано в табл. 12.1.
Обратите внимание, что значение (m – l) в индексе корневого узла правого
поддерева означает количество узлов левого поддерева, рекомендуется разобрать эту таблицу вместе с рис. 12.7.
352
Разделяй и властвуй
Таблица 12.1. Индексы корневого узла и поддеревьев в прямом и симметричном обходах
Индекс корневого узла
в preorder
Интервал индексов поддеревьев в inorder
Текущее дерево
i
[l, r]
Левое поддерево
i+1
[l, m – 1]
Правое поддерево
i + 1 + (m – l)
[m + 1, r]
Прямой обход
preorder
Симметричный
обход
inorder
Рис. 12.7. Представление интервалов индексов корневого узла, левого и правого поддеревьев
4. Код реализации
Для повышения эффективности поиска средней точки m используется хештаблица hmap, в которой хранятся отображения элементов массива inorder
в индексы.
# === File: build_tree.py ===
def dfs(
preorder: list[int],
inorder_map: dict[int, int],
i: int,
l: int,
r: int,
) -> TreeNode | None:
""" Построение двоичного дерева: "разделяй и властвуй"."""
# Завершение, если интервал поддерева пуст.
if r - l < 0:
return None
# Инициализация корневого узла.
root = TreeNode(preorder[i])
# Поиск m для разделения на левое и правое поддеревья.
m = inorder_map[preorder[i]]
# Подзадача: построение левого поддерева.
root.left = dfs(preorder, inorder_map, i + 1, l, m - 1)
# Подзадача: построение правого поддерева.
root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r)
12.3. Задача построения двоичного дерева 353
# Возврат корневого узла.
return root
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
""" Построение двоичного дерева."""
# Инициализация хеш-таблицы для хранения отображения элементов inorder
в индексы.
inorder_map = {val: i for i, val in enumerate(inorder)}
root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1)
return root
На рис. 12.8 демонстрируется рекурсивный процесс построения двоичного
дерева, в котором каждый узел создается в процессе спуска, а каждое ребро
(ссылка) создается в процессе подъема.
preorder
inorder
Шаг 1
Корневой
узел
Левое
поддерево
Правое
поддерево
Корневой
узел
Левое
поддерево
Правое
поддерево
preorder
inorder
Шаг 2
Рис. 12.8. Рекурсивный процесс построения двоичного дерева. Шаги 1–2
354
Разделяй и властвуй
preorder
inorder
Корневой
узел
Шаг 3
Левое
поддерево
Правое
поддерево
preorder
inorder
Шаг 4
Корневой
узел
Левое
поддерево
Правое
поддерево
Корневой
узел
Левое
поддерево
Правое
поддерево
preorder
inorder
Шаг 5
Рис. 12.8. Продолжение. Шаги 3–5
12.3. Задача построения двоичного дерева 355
preorder
inorder
Шаг 6
Корневой
узел
Левое
поддерево
Правое
поддерево
preorder
inorder
Шаг 7
Корневой
узел
Левое
поддерево
Правое
поддерево
preorder
inorder
Шаг 8
Корневой
узел
Рис. 12.8. Продолжение. Шаги 6–8
Левое
поддерево
Правое
поддерево
356
Разделяй и властвуй
preorder
inorder
Корневой
узел
Шаг 9
Левое
поддерево
Правое
поддерево
Рис. 12.8. Окончание. Шаг 9
Результаты разделения прямого обхода preorder и симметричного обхода inorder в каждом рекурсивном вызове показаны на рис. 12.9.
preorder
inorder
preorder
preorder
inorder
inorder
preorder
preorder
inorder
inorder
Корневой
узел
Левое
поддерево
Правое
поддерево
Рис. 12.9. Результаты разделения в каждом рекурсивном вызове
Пусть количество узлов в дереве равно n, инициализация каждого узла
(выполнение одного рекурсивного вызова dfs()) занимает время O(1). Следовательно, общая временная сложность составляет O(n).
Хеш-таблица хранит отображение элементов inorder в индексы, пространственная сложность составляет O(n). В худшем случае, когда двоичное
дерево вырождается в список, глубина рекурсии достигает n, что требует
O(n) пространства стека. Поэтому общая пространственная сложность
составляет O(n).
12.4. Задача о Ханойских башнях 357
12.4. Задача о Ханойских башнях
В алгоритмах сортировки слиянием и построения двоичного дерева мы разбивали исходную задачу на две подзадачи, каждая из которых имела половину размера исходной задачи. Однако для задачи о Ханойских башнях используется другая стратегия разбиения.
Задача
Даны три стержня, обозначенные как A, B и C. В начальном состоянии на
стержне A находятся n дисков, расположенных в порядке увеличения размера
сверху вниз. Необходимо переместить эти n дисков на стержень C, сохраняя
их первоначальный порядок, как показано на рис. 12.10. При перемещении
дисков необходимо соблюдать следующие правила:
1) диск можно снять только с вершины одного стержня и положить на вершину другого стержня;
2) за один раз можно перемещать только один диск;
3) меньший диск всегда должен находиться выше большего диска.
Переместить все диски с A на C
Рис. 12.10. Пример задачи о Ханойских башнях
Обозначим задачу о Ханойских башнях с i дисками как f(i). Например, f(3)
соответствует задаче о перемещении 3 дисков с A на C.
1. Базовый случай
Для случая f(1), когда имеется только один диск, можно просто переместить
единственный диск с A на C, как показано на рис. 12.11.
358
Разделяй и властвуй
Шаг 1
Задача f(1)
2 Этапы решения f(1):
3 Напрямую переместить диск с A на C
Шаг 2
Задача f(1)
Этапы решения f(1):
Напрямую переместить диск с A на C
Рис. 12.11. Решение задачи размера 1
Для задачи f(2), когда имеется два диска, уже требуется соблюдать условие,
что меньший диск находится на большем. Поэтому для выполнения перемещения потребуется использовать стержень B.
1. Сначала переместить верхний диск с A на B.
2. Затем переместить большой диск с A на C.
3. Переместить маленький диск с B на C.
Шаг 1
Задача f(2)
Рис. 12.12. Решение задачи размера 2. Шаг 1
12.4. Задача о Ханойских башнях 359
Этапы решения f(2):
Шаг 2
1. Переместить маленький диск с A на B
2. Переместить большой диск с A на C
3. Переместить маленький диск с B на C
Этапы решения f(2):
1. Переместить маленький диск с A на B
Шаг 3
2. Переместить большой диск с A на C
Этапы решения f(2):
1. Переместить маленький диск с A на B
2. Переместить большой диск с A на C
Шаг 4
3. Переместить маленький диск с B на C
Рис. 12.12. Окончание. Шаги 2–4
Процесс решения задачи f(2) можно кратко описать следующим образом:
переместить два диска с A на C с помощью B. Здесь C называется целевым
стержнем, а B – вспомогательным стержнем.
360
Разделяй и властвуй
2. Разделение на подзадачи
Для задачи f(3), когда имеется три диска, ситуация становится несколько сложнее.
Поскольку решения f(1) и f(2) уже известны, можно рассмотреть задачу с точки зрения метода «разделяй и властвуй». Можно считать два верхних диска на
A единым целым и выполнить шаги, показанные на рис. 12.13. Таким образом,
три диска успешно переместятся с A на C.
1. Пусть B будет целевым стержнем, а C – вспомогательным. Переместить
два диска с A на B.
2. Переместить оставшийся диск с A непосредственно на C.
3. Пусть C будет целевым стержнем, а A – вспомогательным стержнем. Переместить два диска с B на C.
Считать верхние
два диска
одним целым
Шаг 1
Задача f(3)
Этапы решения f(3):
Переместить два диска с A на B
Переместить оставшийся диск с A на C
Переместить два диска с B на C
Этапы решения f(3):
Шаг 2
1. Переместить два диска с A на B
Рис. 12.13. Решение задачи размера 3. Шаги 1-2
Переместить оставшийся диск с A на C
Переместить два диска с B на C
12.4. Задача о Ханойских башнях 361
Этапы решения f(3):
Шаг 3
1. Переместить два диска с A на B
2. Переместить оставшийся диск с A на C
3. Переместить два диска с B на C
Этапы решения f(3):
1. Переместить два диска с A на B
2. Переместить оставшийся диск с A на C
Шаг 4
3. Переместить два диска с B на C
Рис. 12.13. Окончание. Шаги 3–4
По сути, задача f(3) делится на две подзадачи f(2) и одну подзадачу f(1).
После последовательного решения этих трех подзадач исходная задача также
решается. Это показывает, что подзадачи независимы, и их решения можно
объединить.
Таким образом, можно обобщить стратегию «разделяй и властвуй» для решения задачи Ханойской башни, как показано на рис. 12.14: разделить исходную задачу f(n) на две подзадачи f(n − 1) и одну подзадачу f(1). Затем решить
эти три подзадачи в следующем порядке:
1) переместить n − 1 дисков с A на B с помощью C;
2) переместить оставшийся 1 диск с A непосредственно на C;
3) переместить n − 1 дисков с B на C с помощью A.
Для двух подзадач f(n − 1) можно использовать тот же метод рекурсивного деления, пока не будет достигнута минимальная подзадача f(1). Решение
f(1) уже известно и требует только одного перемещения.
362
Разделяй и властвуй
Подзадача f(n − 1):
переместить n − 1 дисков с A на B
Исходная задача f(n):
переместить n дисков с A на C
Подзадача f(1):
переместить оставшийся 1 диск с A на C
Подзадача f(n − 1):
переместить n − 1 дисков с B на C
Рис. 12.14. Стратегия «разделяй и властвуй» для решения задачи Ханойской башни
3. Код реализации
В коде объявляется рекурсивная функция dfs(i, src, buf, tar), которая перемещает i дисков с вершины стержня src на целевой стержень tar с помощью
вспомогательного стержня buf.
# === File: hanota.py ===
def move(src: list[int], tar: list[int]):
""" Перемещение одного диска."""
# Извлечение диска с вершины src.
pan = src.pop()
# Помещение диска на вершину tar.
tar.append(pan)
def dfs(i: int, src: list[int], buf: list[int], tar: list[int]):
""" Решение задачи Ханойской башни f(i)."""
# Если в src остался только один диск, то переместить его на tar.
if i == 1:
move(src, tar)
return
# Подзадача f(i-1): переместить i-1 дисков с вершины src на buf с помощью tar.
dfs(i - 1, src, tar, buf)
# Подзадача f(1): переместить оставшийся диск с src на tar.
move(src, tar)
# Подзадача f(i-1): переместить i-1 дисков с вершины buf на tar с помощью src.
dfs(i - 1, buf, src, tar)
12.5. Резюме 363
def solve_hanota(A: list[int], B: list[int], C: list[int]):
""" Решение задачи Ханойской башни."""
n = len(A)
# Переместить n дисков с вершины A на C с помощью B.
dfs(n, A, B, C)
Задача Ханойской башни формирует рекурсивное дерево высотой n, каждый узел которого представляет подзадачу, соответствующую вызову функции
dfs(), как показано на рис. 12.15. Поэтому временная сложность составляет
O(2n), а пространственная сложность – O(n).
Рис. 12.15. Рекурсивное дерево задачи Ханойской башни
Цитата
Задача Ханойской башни берет начало из древней легенды. В древнеиндийском храме у монахов было три высоких алмазных стержня и 64 золотых диска
разного размера. Монахи постоянно перемещали диски, веря, что в момент,
когда будет правильно размещен последний диск, наступит конец света.
Однако, даже если монахи будут перемещать по одному диску в секунду, потребуется около 264 ≈ 1.84 × 1019 с, т. е. примерно 5850 млрд лет, что значительно превышает текущие оценки возраста Вселенной. Поэтому, если эта легенда
правдива, нам не стоит беспокоиться о наступлении конца света.
12.5. Резюме
«Разделяй и властвуй» – это распространенная стратегия разработки алгоритмов, включающая два этапа – разделение (декомпозиция) и объединение (синтез) – и обычно реализуемая с помощью рекурсии.
364
Разделяй и властвуй
Критерии применимости этой стратегии к задаче включают: возможность декомпозиции задачи, независимость подзадач и возможность их
объединения.
Сортировка слиянием – это типичное применение стратегии «разделяй
и властвуй». Эта сортировка рекурсивно разделяет массив на два подмассива равной длины, пока не останется массив из одного элемента.
После чего начинается поэтапное объединение.
Введение стратегии «разделяй и властвуй» часто позволяет повысить
эффективность алгоритма. С одной стороны, стратегия уменьшает количество операций. С другой стороны, после разделения она способствует
оптимизации для параллельного выполнения.
Принцип «разделяй и властвуй» не только позволяет решать множество
алгоритмических задач, но и широко применяется в проектировании
структур данных и алгоритмов, его можно встретить повсюду.
Адаптивный поиск более эффективен по сравнению с полным перебором. Алгоритмы поиска со сложностью O(log n) обычно реализуются на
основе стратегии «разделяй и властвуй».
Двоичный поиск – это еще одно типичное применение стратегии «разделяй и властвуй», которое не содержит этап объединения решений подзадач.
Двоичный поиск можно реализовать с помощью рекурсивного подхода.
Задачу построения двоичного дерева можно разделить на построение
левого и правого поддеревьев (подзадачи), что достигается путем разделения индексов в порядке предварительного и симметричного обхода.
Задачу Ханойской башни размера n можно разделить на две подзадачи
размера n – 1 и одну подзадачу размера 1. После последовательного решения этих трех подзадач исходная задача будет также решена.
Глава 13
Поиск с возвратом
Абстракция
Исследователи в лабиринте могут столкнуться с трудностями на пути к решению.
Сила возврата позволяет им начинать заново, продолжать попытки и в конечном итоге найти выход к свету.
366
Поиск с возвратом
13.1. Алгоритмы поиска с возвратом
Алгоритм поиска с возвратом – это метод решения задач путем перебора. Его
основная идея заключается в том, чтобы, начиная с начального состояния,
осуществлять грубый поиск всех возможных решений, фиксируя правильное
найденное решение. Процесс поиска продолжается до тех пор, пока не будет
найдено решение или не будут исчерпаны все возможные варианты.
Алгоритмы поиска с возвратом обычно используют поиск в глубину для обхода пространства решений. В разделе «Двоичные деревья» упоминалось, что
прямой, симметричный и обратный обходы относятся к поиску в глубину. Далее, используя прямой обход, мы реализуем задачу поиска с возвратом, чтобы
постепенно понять принцип работы этого алгоритма.
Пример 1
Дано двоичное дерево, необходимо найти и зафиксировать все узлы со значением 7 и вернуть список этих узлов.
Для решения этой задачи мы выполняем предварительный обход дерева
и проверяем, равно ли значение текущего узла 7. Если равно, то добавляем значение этого узла в список результатов res. Процесс представлен на рис. 13.1 и в
следующем коде.
# === File: preorder_traversal_i_compact.py ===
def pre_order(root: TreeNode):
""" Предварительный обход: пример 1."""
if root is None:
return
if root.val == 7:
# Запись решения.
res.append(root)
pre_order(root.left)
pre_order(root.right)
Поиск в глубину
Прямой порядок обхода
Посетить узел в
Выполнить прямой обход
двоичного дерева и записать узлы
со значением 7
Рис. 13.1. Поиск узлов в предварительном обходе
13.1. Алгоритмы поиска с возвратом 367
13.1.1. Попытка и возврат
Алгоритм называется поиском с возвратом, потому что при поиске
в пространстве решений он использует стратегию попытки и возврата.
Когда алгоритм сталкивается с состоянием, в котором невозможно продолжать
или невозможно получить удовлетворительное решение, он отменяет предыдущий выбор, возвращается к предыдущему состоянию и пробует другие возможные варианты.
В примере 1 посещение каждого узла представляет собой попытку, а переход через листовой узел или возврат к родительскому узлу через return означает возврат.
Стоит отметить, что откат включает не только возврат функции. Чтобы
объяснить это, мы немного расширим пример 1.
Пример 2
В двоичном дереве необходимо найти все узлы со значением 7 и верните
путь от корня к этим узлам.
Возьмем за основу код для примера 1. Нам потребуется добавить список path
для записи пути посещенных узлов. Когда будет найден узел со значением 7,
скопируем path и добавим его в список результатов res. После завершения обхода res будет содержать все решения. Код реализации представлен ниже.
# === File: preorder_traversal_ii_compact.py ===
def pre_order(root: TreeNode):
""" Предварительный обход: пример 2."""
if root is None:
return
# Попытка.
path.append(root)
if root.val == 7:
# Запись решения.
res.append(list(path))
pre_order(root.left)
pre_order(root.right)
# Возврат.
path.pop()
В каждой попытке мы добавляем текущий узел в path для записи пути. Перед
возвратом необходимо удалить этот узел из path, чтобы восстановить состояние до этой попытки.
Изучив процесс выполнения алгоритма на рис. 13.2, можно представить
попытку и возврат как движение вперед и отмену, как два противоположных действия.
368
Поиск с возвратом
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 1
При встрече узла 7 добавить путь в
результат
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
При встрече узла 7 добавить путь в результат
Шаг 2
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 3
Рис. 13.2. Попытка и возврат. Шаги 1–3
13.1. Алгоритмы поиска с возвратом 369
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 4
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 5
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 6
Рис. 13.2. Продолжение. Шаги 4–6
370
Поиск с возвратом
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 7
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 8
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 9
Рис. 13.2. Продолжение. Шаги 7–9
13.1. Алгоритмы поиска с возвратом 371
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
При встрече узла 7 добавить путь в результат
Шаг 10
Попытка: рекурсивный обход, обновление состояния
Возврат: восстановление состояния, возврат из функции
Шаг 11
Рис. 13.2. Окончание. Шаги 10–11
13.1.2. Обрезка
Сложные задачи поиска с возвратом обычно содержат одно или несколько
ограничений, которые можно использовать для обрезки.
Пример 3
В двоичном дереве необходимо найти все узлы со значением 7 и вернуть пути
от корневого узла до этих узлов, при этом пути не должны содержать узлы
со значением 3.
Для выполнения данного условия требуется добавить операцию обрезки:
в процессе поиска, если встречается узел со значением 3, следует немедленно
вернуться, не продолжая поиск. Код реализации представлен ниже.
372
Поиск с возвратом
# === File: preorder_traversal_iii_compact.py ===
def pre_order(root: TreeNode):
""" Предварительный обход: пример 3."""
# Обрезка.
if root is None or root.val == 3:
return
# Попытка.
path.append(root)
if root.val == 7:
# Запись решения.
res.append(list(path))
pre_order(root.left)
pre_order(root.right)
# Возврат.
path.pop()
Обрезка является очень наглядным термином. В процессе поиска мы обрезаем ветви поиска, не удовлетворяющие заданным условиям, и избегаем
множества бессмысленных попыток, тем самым повышая эффективность поиска, как показано на рис. 13.3.
Обрезка
Обрезка:
при встрече узла со значением 3
вернуться досрочно
Обрезка позволяет избежать обхода
бесперспективного пространства поиска,
повышая эффективность
Рис. 13.3. Обрезка в соответствии с заданными условиями
13.1.3. Каркас кода
Далее мы попытаемся сформировать основной каркас операций «попытка,
возврат, обрезка» для повышения универсальности кода.
В следующем каркасе кода state обозначает текущее состояние задачи,
а choices – возможные выборы в текущем состоянии:
def backtrack(state: State, choices: list[choice], res: list[state]):
""" Каркас алгоритма поиска с возвратом."""
# Проверка, является ли состояние решением.
if is_solution(state):
# Запись решения.
record_solution(state, res)
13.1. Алгоритмы поиска с возвратом 373
# Не продолжать поиск.
return
# Перебор всех вариантов.
for choice in choices:
# Обрезка: проверка легитимности выбора.
if is_valid(state, choice):
# Попытка: сделать выбор, обновить состояние.
make_choice(state, choice)
backtrack(state, choices, res)
# Возврат: отмена выбора, возврат к предыдущему состоянию.
undo_choice(state, choice)
Теперь на основе каркаса кода решим пример 3. Состояние state – это путь
обхода узлов, выбор choices – это левый и правый дочерние узлы текущего
узла, результат res – список путей.
# === File: preorder_traversal_iii_template.py ===
def is_solution(state: list[TreeNode]) -> bool:
""" Проверка, является ли текущее состояние решением."""
return state and state[-1].val == 7
def record_solution(state: list[TreeNode], res: list[list[TreeNode]]):
""" Запись решения."""
res.append(list(state))
def is_valid(state: list[TreeNode], choice: TreeNode) -> bool:
""" Проверка легитимности выбора в текущем состоянии."""
return choice is not None and choice.val != 3
def make_choice(state: list[TreeNode], choice: TreeNode):
""" Обновление состояния."""
state.append(choice)
def undo_choice(state: list[TreeNode], choice: TreeNode):
""" Восстановление состояния."""
state.pop()
def backtrack(
state: list[TreeNode], choices: list[TreeNode], res: list[list[TreeNode]]
):
""" Поиск с возвратом: пример 3."""
# Проверка, является ли состояние решением.
if is_solution(state):
# Запись решения.
record_solution(state, res)
# Перебор всех вариантов.
for choice in choices:
374
Поиск с возвратом
# Обрезка: проверка легитимности выбора.
if is_valid(state, choice):
# Попытка: сделать выбор, обновить состояние.
make_choice(state, choice)
# Переход к следующему выбору.
backtrack(state, [choice.left, choice.right], res)
# Возврат: отмена выбора, возврат к предыдущему состоянию.
undo_choice(state, choice)
Согласно условию задачи после нахождения узла со значением 7 необходимо продолжать поиск, поэтому следует удалить оператор return после записи решения. На рис. 13.4 сравнивается процесс поиска с сохранением и удалением оператора return.
После записи
решения
остановить
поиск
Сохранение return
Возврат после записи решения,
не продолжать поиск
Удаление return
Не возвращаться после записи
решения, продолжить поиск
Рис. 13.4. Сравнение процесса поиска с сохранением и удалением return
По сравнению с реализацией на основе предварительного обхода, реализация на основе каркаса поиска с возвратом выглядит более громоздкой, но
обладает большей универсальностью. На самом деле многие задачи поиска
с возвратом можно решить в рамках этого каркаса. Необходимо лишь определить state и choices в соответствии с конкретной задачей и реализовать методы каркаса.
13.1.4. Основные термины
Для более четкого понимания алгоритмических задач мы систематизируем
значения часто используемых терминов обратного поиска и приведем соответствующие примеры для задачи 3, как показано в табл. 13.1.
13.1. Алгоритмы поиска с возвратом 375
Таблица 13.1. Основные термины обратного поиска
Термин
Решение
Определение
Пример 3
Ответ, удовлетворяющий определенВсе пути от корневого узла до узла 7,
ным условиям задачи, может быть одно удовлетворяющие условиям
или несколько решений
Ограничение Ограничение на допустимость решения, обычно используется для обрезки
Путь не содержит узлов со значением 3
Состояние
Ситуация задачи в определенный момент, включая сделанные выборы
Текущий посещенный путь узлов, т. е.
список узлов path
Попытка
Процесс исследования пространства
решений на основе доступных выборов, включая выбор, обновление
состояния, проверку на решение
Рекурсивный доступ к левому (правому) дочернему узлу, добавление узла
в path, проверка значения узла на
равенство 7
Возврат
Отмена предыдущих выборов и возврат к предыдущему состоянию при
встрече состояния, не удовлетворяющего ограничению
При переходе через листовой узел,
завершении посещения узла, встрече
узла со значением 3 поиск прекращается, происходит выход из функции
Обрезка
Метод избегания бессмысленных путей При встрече узла со значением 3 попоиска на основе условий и ограниче- иск прекращается
ний задачи, повышающий эффективность поиска
Совет
Понятия задачи, решения, состояния и др. являются общими и встречаются
в алгоритмах «разделяй и властвуй», обратного поиска, динамического программирования, жадных алгоритмах и многих других.
13.1.5. Преимущества и ограничения
Алгоритм поиска с возвратом, по сути, является алгоритмом поиска в глубину, который пытается найти все возможные решения до тех пор, пока не будет
найдено решение, удовлетворяющее условиям. Преимущество этого метода
заключается в возможности нахождения всех возможных решений, и при разумной обрезке он обладает высокой эффективностью.
Однако при решении крупных или сложных задач эффективность работы
алгоритма возврата может оказаться неприемлемой.
Время: алгоритм поиска с возвратом обычно требует перебора всех возможных состояний пространства, и временная сложность может достигать экспоненциального или факториального порядка.
Пространство: в рекурсивных вызовах необходимо сохранять текущее
состояние (например, путь, вспомогательные переменные для обрезки
и т. д.), и при большой глубине потребность в пространстве может стать
значительной.
376
Поиск с возвратом
Тем не менее алгоритм поиска с возвратом по-прежнему является наилучшим решением для некоторых задач поиска и задач с ограничениями. В этих задачах невозможно предсказать, какие выборы могут привести
к эффективному решению, поэтому необходимо перебирать все возможные
варианты. В таких случаях ключевым моментом является оптимизация эффективности, и существуют две распространенные стратегии.
Обрезка: избегание поиска по путям, которые заведомо не приведут
к решению, что позволяет сэкономить время и пространство.
Эвристический поиск: введение некоторых стратегий или оценочных
значений в процессе поиска, чтобы в первую очередь исследовать пути,
которые с наибольшей вероятностью могут привести к эффективному
решению.
13.1.6. Типичные задачи поиска с возвратом
Алгоритм поиска с возвратом можно использовать для решения множества задач поиска, задач с ограничениями и задач комбинаторной оптимизации.
Задачи поиска: цель этих задач – найти решение, удовлетворяющее определенным условиям.
Задача о перестановках: дано множество, требуется найти все возможные перестановки элементов.
Задача о сумме подмножеств: дано множество и целевая сумма, необходимо найти все подмножества, сумма которых равна целевой.
Задача о Ханойских башнях: даны три стержня и несколько дисков разного размера, требуется переместить все диски с одного стержня на другой, перемещая по одному диску за раз, при этом нельзя класть больший
диск на меньший.
Задачи с ограничениями: цель этих задач – найти решение, удовлетворяющее всем ограничениям.
Задача об n ферзях: разместить n ферзей на шахматной доске размером
n×n так, чтобы они не рубили друг друга.
Судоку: заполнить числами от 1 до 9 сетку 9×9 так, чтобы в каждой строке, каждом столбце и каждой подгруппе 3×3 числа не повторялись.
Задача о раскраске графа: дан неориентированный граф, требуется раскрасить его вершины минимальным числом цветов так, чтобы соседние
вершины имели разные цвета.
Задачи комбинаторной оптимизации: цель этих задач – найти оптимальное решение в комбинаторном пространстве, удовлетворяющее определенным условиям.
Задача о рюкзаке 0-1: дано множество предметов и рюкзак. Каждый
предмет имеет определенную ценность и вес, требуется выбрать предметы так, чтобы их общая ценность была максимальной при ограниченной вместимости рюкзака.
Задача коммивояжера: начиная с некоторой вершины графа, требуется
посетить все остальные вершины ровно один раз и вернуться в начальную. Найдя при этом кратчайший путь.
13.2. Задача о перестановках 377
Задача о максимальной клике: дан неориентированный граф, требуется
найти максимальный полный подграф, т. е. подграф, в котором любые
две вершины соединены ребром.
Следует отметить, что для многих задач комбинаторной оптимизации алгоритм поиска с возвратом не является оптимальным решением.
Задача о рюкзаке 0-1 обычно решается с помощью динамического
программирования для достижения более высокой временной эффективности.
Задача коммивояжера является известной NP-трудной задачей, для ее
решения часто используются генетические и муравьиные алгоритмы.
Задача о максимальной клике является классической задачей теории
графов и может быть решена с помощью жадных алгоритмов или других
эвристических методов.
13.2. Задача о перестановках
Задача о перестановках является типичным примером применения алгоритма поиска с возвратом. Она определяется как задача нахождения всех возможных перестановок элементов в заданном множестве (например, массиве
или строке).
В табл. 13.2 представлено несколько примеров данных, включая входной
массив и все соответствующие перестановки.
Таблица 13.2. Примеры полных перестановок
Входной массив
Все перестановки
[1]
[1]
[1, 2]
[1, 2], [2, 1]
[1, 2, 3]
[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]
13.2.1. Случай без равных элементов
Задача
Дан массив целых чисел, в котором отсутствуют повторяющиеся элементы.
Необходимо вернуть все возможные перестановки.
С точки зрения алгоритма поиска с возвратом процесс генерации перестановок можно представить как результат серии выборов. Предположим, что входной массив равен [1, 2, 3]. Если сначала выбрать 1, затем 3, а в
конце 2, то получится перестановка [1, 3, 2]. Возврат означает отмену выбора
и продолжение попыток других вариантов.
С точки зрения кода реализации поиска с возвратом множество кандидатов
choices – это все элементы входного массива, а состояние state – это элементы,
378
Поиск с возвратом
выбранные до текущего момента. Следует отметить, что каждый элемент может быть выбран только один раз, поэтому все элементы в state должны
быть уникальными.
Процесс поиска можно развернуть в виде рекурсивного дерева, в котором
каждый узел представляет текущее состояние state, как показано на рис. 13.5.
Начиная с корневого узла, после трех раундов выбора достигается листовой
узел, а каждый листовой узел соответствует одной перестановке.
1-й раунд
выбора
2-й раунд
выбора
3-й раунд
выбора
Рис. 13.5. Рекурсивное дерево полных перестановок
1. Обрезка повторного выбора
Чтобы обеспечить выбор каждого элемента только один раз, вводится булев
массив selected, где selected[i] указывает, был ли выбран элемент choices[i],
и на его основе выполняется следующая обрезка.
После выбора choice[i] значение selected[i] устанавливается в True, т. е.
элемент помечается как выбранный.
При обходе списка выбора choices пропускаются все уже выбранные
узлы, т. е. выполняется обрезка.
Предположим, что в первом раунде выбирается 1, во втором – 3, а в третьем – 2, тогда во втором раунде необходимо обрезать ветвь элемента 1, а в
третьем – ветви элементов 1 и 3, как показано на рис. 13.6.
Из рис. 13.6 видно, что это отсечение уменьшает размер пространства поиска с O(nn) до O(n!).
13.2. Задача о перестановках 379
Допустимые
элементы: 1, 2, 3
1-й раунд
выбора 1
Обрезка
Обрезка выбранного элемента 1
Оставшиеся допустимые элементы: 2, 3
2-й раунд
выбора 3
Обрезка
3-й раунд
выбора 2
Обрезка
Обрезка выбранных
элементов 1, 3.
Оставшийся допустимый
элемент: 2
Рис. 13.6. Пример обрезки в задаче полных перестановок
2. Код реализации
На основе вышеизложенное можно заполнить пробелы в каркасе кода. Чтобы
сократить код, функции каркаса кода не реализуются отдельно, а объединены
в функцию backtrack().
# === File: permutations_i.py ===
def backtrack(
state: list[int], choices: list[int], selected: list[bool], res:
list[list[int]]
):
""" Поиск с возвратом: полные перестановки I."""
# Когда длина состояния равна количеству элементов, фиксируется решение.
if len(state) == len(choices):
res.append(list(state))
return
# Обход всех выборов.
for i, choice in enumerate(choices):
# Обрезка: не допускается повторный выбор элементов.
if not selected[i]:
# Попытка: сделать выбор, обновить состояние.
selected[i] = True
state.append(choice)
# Переход к следующему выбору.
backtrack(state, choices, selected, res)
# Возврат: отмена выбора, восстановление предыдущего состояния.
selected[i] = False
state.pop()
380
Поиск с возвратом
def permutations_i(nums: list[int]) -> list[list[int]]:
""" Полные перестановки."""
res = []
backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res)
return res
13.2.2. Учет равных элементов
Задача
Дан массив целых чисел, в котором могут быть повторяющиеся элементы. Необходимо вернуть все уникальные перестановки.
Предположим, что входной массив равен [1, 1, 2]. Для удобства различения
двух повторяющихся элементов 1 второй 1 обозначим как 1^.
Как видно на рис. 13.7, половина перестановок, сгенерированных вышеуказанным методом, являются одинаковыми.
1-й раунд
выбора
2-й раунд
выбора
3-й раунд
выбора
Нужно удалить повторяющиеся перестановки
Рис. 13.7. Повторяющиеся перестановки
Как же избавиться от повторяющихся перестановок? Самый прямой способ – использовать хеш-набор для удаления дубликатов из результата перестановок. Однако это не самый изящный подход, так как ветви поиска, генерирующие повторяющиеся перестановки, излишни, и их нужно заранее
распознавать и обрезать – это повысит эффективность алгоритма.
1. Обрезка равных элементов
В первом раунде выбор 1 или 1^ эквивалентен, так как все перестановки, сгенерированные под этими двумя выборами, повторяются, как показано на
рис. 13.8. Поэтому элемент 1^ нужно обрезать.
13.2. Задача о перестановках 381
Аналогично после выбора 2 в первом раунде выбор 1 и 1^ во втором раунде также создадут повторяющиеся ветви, поэтому 1^ во втором раунде также
нужно обрезать.
По сути, наша цель – убедиться, что в каждом раунде выбора несколько
равных элементов будут выбраны только один раз.
Обрезка
1-й раунд
выбора
Два повторяющихся элемента 1
нужно выбрать только один раз
2-й раунд
выбора
Обрезка
Два повторяющихся элемента 1
нужно выбрать только один раз
3-й раунд
выбора
Рис. 13.8. Обрезка повторяющихся перестановок
2. Код реализации
Возьмем за основу код из предыдущей задачи. В каждом раунде выбора введем хеш-набор duplicated, который будет использоваться для записи элементов, уже проверенных в этом раунде, и для обрезки повторяющихся элементов.
# === File: permutations_ii.py ===
def backtrack(
state: list[int], choices: list[int], selected: list[bool], res:
list[list[int]]
):
""" Поиск с возвратом: полные перестановки II."""
# Когда длина состояния равна количеству элементов, решение фиксируется.
if len(state) == len(choices):
res.append(list(state))
return
# Обход всех выборов.
duplicated = set[int]()
for i, choice in enumerate(choices):
# Обрезка: не допускается повторный выбор элементов и выбор равных элементов
if not selected[i] and choice not in duplicated:
# Попытка: сделать выбор, обновить состояние.
duplicated.add(choice) # Запись выбранного значения элемента.
382
Поиск с возвратом
selected[i] = True
state.append(choice)
# Переход к следующему выбору.
backtrack(state, choices, selected, res)
# Возврат: отмена выбора, восстановление предыдущего состояния.
selected[i] = False
state.pop()
def permutations_ii(nums: list[int]) -> list[list[int]]:
""" Полные перестановки II."""
res = []
backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res)
return res
Предположим, что элементы попарно различны, тогда n элементов имеют
n! перестановок (факториал). При записи результата необходимо скопировать
список длиной n за время O(n). Таким образом, временная сложность составляет O(n!n).
Максимальная глубина рекурсии равна n, используется O(n) пространства
стека вызовов. Для selected требуется O(n) пространства. В любой момент
времени в duplicated может содержаться максимум n элементов, что соответствует O(n2) пространства. Таким образом, пространственная сложность
составляет O(n2).
3. Сравнение двух видов обрезки
Обратите внимание, что, хотя и selected, и duplicated используются для обрезки, их цели различны.
Обрезка повторного выбора: в процессе всего поиска существует
только один массив selected. В нем фиксируется элементы, включенные в текущее состояние, а его цель – избежать повторного появления
элемента в state.
Обрезка равных элементов: каждый раунд выбора (каждый вызов
функции backtrack) включает один хеш-набор duplicated. Он фиксирует,
какие элементы были выбраны в текущем обходе (цикл for), а его цель –
гарантировать, что равные элементы выбираются только один раз.
На рис. 13.9 демонстрируется область действия двух условий обрезки. Обратите внимание, что каждый узел в дереве представляет собой выбор, а узлы
на пути от корня до листа составляют одну перестановку.
13.3. Задача о сумме подмножеств 383
Условие обрезки 1
Вывод перестановки
Условие обрезки 2
Рис. 13.9. Область действия двух условий обрезки
13.3. Задача о сумме подмножеств
13.3.1. Случай без повторяющихся элементов
Задача
Дан массив положительных целых чисел nums и целевое положительное целое
число target. Необходимо найти все возможные комбинации элементов, сумма
которых равна target. В массиве нет повторяющихся элементов, каждый элемент можно выбрать несколько раз. Вернуть эти комбинации в виде списка,
в котором не должно быть повторяющихся комбинаций.
Например, для входного множества {3, 4, 5} и целевого числа 9 решениями
будут {3, 3, 3}, {4, 5}. Следует обратить внимание на следующие два момента:
элементы входного множества можно выбирать неограниченное количество раз;
порядок элементов в подмножестве не имеет значения, например {4, 5}
и {5, 4} – одно и то же подмножество.
1. Сравнение с решением задачи о полных перестановках
Подобно задаче о полных перестановках, процесс генерации подмножеств
можно представить как серию выборов, а в процессе выбора в реальном времени обновлять сумму элементов. Когда сумма элементов равна target, подмножество записывается в список результатов.
Однако, в отличие от задачи о полных перестановках, в данной задаче
элементы множества можно выбирать неограниченное количество раз,
384
Поиск с возвратом
поэтому нет необходимости использовать булев список selected для записи
выбранных элементов. Для получения начального решения можно просто немного изменить код для полных перестановок.
# === File: subset_sum_i_naive.py ===
def backtrack(
state: list[int],
target: int,
total: int,
choices: list[int],
res: list[list[int]],
):
""" Поиск с возвратом: сумма подмножеств I."""
# Если сумма подмножества равна target, записать решение.
if total == target:
res.append(list(state))
return
# Перебор всех вариантов выбора.
for i in range(len(choices)):
# Обрезка: если сумма подмножества превышает target, пропустить этот выбор.
if total + choices[i] > target:
continue
# Попытка: сделать выбор, обновить сумму элементов total.
state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target, total + choices[i], choices, res)
# Возврат: отмена выбора, восстановление предыдущего состояния.
state.pop()
def subset_sum_i_naive(nums: list[int], target: int) -> list[list[int]]:
""" Решение задачи о сумме подмножеств I (включая повторяющиеся
подмножества)."""
state = [] # Состояние (подмножество).
total = 0 # Сумма подмножества.
res = [] # Список результатов (список подмножеств).
backtrack(state, target, total, nums, res)
return res
При вводе в этот код массива [3, 4, 5] и целевого элемента 9 будет выведено
[3, 3, 3], [4, 5], [5, 4]. Хотя удалось найти все подмножества с суммой 9, среди
них есть повторяющиеся подмножества [4, 5] и [5, 4].
Это происходит потому, что процесс поиска различает порядок выбора, тогда
как в подмножествах порядок элементов не важен. Как показано на рис. 13.10,
сначала выбрать 4, а затем 5 и сначала выбрать 5, а затем 4 – это разные ветви,
но они соответствуют одному и тому же подмножеству.
13.3. Задача о сумме подмножеств 385
Сумма элементов
1-й раунд выбора
Обрезка по выходу
за границы
Сумма элементов > 9
2-й раунд выбора
3-й раунд выбора
Рис. 13.10. Поиск подмножеств и обрезка по превышению целевого значения
Одним из очевидных подходов к устранению повторяющихся подмножеств является удаление дубликатов из списка результатов. Однако этот
метод очень неэффективен по двум причинам.
Когда в массиве много элементов, особенно когда значение target велико, процесс поиска генерирует множество повторяющихся подмножеств.
Сравнение подмножеств (массивов) на различия очень затратная по времени операция. Она требует сначала сортировки массивов, затем сравнения различий каждого элемента в массиве.
2. Обрезка повторяющихся подмножеств
Рассмотрим устранение дубликатов в процессе поиска с помощью обрезки. На рис. 13.11 показано, что повторяющиеся подмножества возникают
при выборе элементов массива в разном порядке, например в следующих
случаях:
1) пусть на первом и втором этапах выбираются 3 и 4 соответственно, создаются все подмножества, содержащие эти два элемента, обозначенные
как [3, 4, …];
2) затем если на первом этапе выбирается 4, то на втором этапе следует
пропустить 3, так как подмножество [4, 3, …] полностью повторяет подмножество, созданное на этапе 1.
В процессе поиска выбор на каждом уровне осуществляется слева направо.
Поэтому чем правее ветвь, тем больше она обрезается.
1. На первых двух этапах выбираются 3 и 5 и создаются подмножества
[3, 5, …].
386
Поиск с возвратом
2. На первых двух этапах выбираются 4 и 5 и создаются подмножества
[4, 5, …].
3. Если на первом этапе выбирается 5, то на втором этапе следует пропустить 3 и 4, так как подмножества [5, 3, …] и [5, 4, …] полностью повторяют подмножества, описанные на этапах 1 и 2.
1-й раунд выбора
Обрезка: пропустить 3,
чтобы избежать повторяющихся подмножеств
Обрезка: пропустить 3, 4,
чтобы избежать повторяющихся подмножеств
2-й раунд выбора
Подмножества
Повторяющиеся подмножества
Рис. 13.11. Повторяющиеся подмножества, полученные в результате различного
порядка выбора
Обобщим эту мысль. Пусть задан входной массив [x1, x2, …, xn]. Тогда в процессе поиска последовательность выбора [xi1, xi2, …, xim] должна удовлетворять условию i1 ≤ i2 ≤ ⋯ ≤ im, в противном случае она приведет к дубликатам,
и ее нужно обрезать.
3. Код реализации
Для реализации этой обрезки мы инициализируем переменную start, которая
указывает начальную точку обхода. После выбора xi следующая итерация
начинается с индекса i. Это позволяет для последовательности выбора соблюдать условие i1 ≤ i2 ≤ ⋯ ≤ im, обеспечивая уникальность подмножеств.
Кроме того, в код были внесены следующие две оптимизации.
Перед началом поиска массив nums сортируется. При обходе всех вариантов, если сумма подмножества превышает target, цикл завершается, так
как последующие элементы больше, и их сумма также превысит target.
Исключение переменной total, подсчет суммы элементов осуществляется с помощью вычитания из target. Решение фиксируется, когда target
равен 0.
13.3. Задача о сумме подмножеств 387
# === File: subset_sum_i.py ===
def backtrack(
state: list[int], target: int, choices: list[int], start: int, res:
list[list[int]]
):
""" Поиск с возвратом: сумма подмножеств I"""
# При равенстве суммы подмножества target фиксируется решение.
if target == 0:
res.append(list(state))
return
# Обход всех вариантов.
# Обрезка 2: обход начинается с start, чтобы избежать создания
# повторяющихся подмножеств.
for i in range(start, len(choices)):
# Обрезка 1: если сумма подмножества превышает target, цикл завершается.
# Это связано с тем, что массив отсортирован, последующие элементы
# больше, и сумма подмножества обязательно превысит target.
if target - choices[i] < 0:
break
# Попытка: выбор, обновление target, start.
state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target - choices[i], choices, i, res)
# Возврат: отмена выбора, восстановление предыдущего состояния.
state.pop()
def subset_sum_i(nums: list[int], target: int) -> list[list[int]]:
""" Решение задачи суммы подмножеств I."""
state = [] # Состояние (подмножество).
nums.sort() # Сортировка nums.
start = 0 # Начальная точка обхода.
res = [] # Список результатов (список подмножеств).
backtrack(state, target, nums, start, res)
return res
На рис. 13.12 показан полный процесс поиска с возвратом для массива [3, 4, 5]
и целевого элемента 9.
388
Поиск с возвратом
Начало вычитания
из target
1-й раунд выбора
2-й раунд выбора
3-й раунд выбора
Обрезка 1
Сумма элементов
не может
превышать target
Обрезка 2
Не допускать
создания
повторяющихся
подмножеств
Рис. 13.12. Процесс поиска с возвратом для реализации задачи о сумме подмножеств I
13.3.2. Случай с повторяющимися элементами
Задача
Дан массив положительных целых чисел nums и целевое положительное целое
число target. Необходимо найти все возможные комбинации, сумма элементов которых равна target. Входной массив может содержать повторяющиеся элементы, каждый из которых можно выбрать только один раз.
Необходимо вернуть эти комбинации в виде списка, который не должен содержать повторяющихся комбинаций.
В отличие от предыдущей задачи входной массив может содержать повторяющиеся элементы, что создает новую проблему. Например, для массива [4, 4, 5] и целевого элемента 9, текущий код выдает результат [4, 5], [4, 5],
что приводит к повторяющимся подмножествам.
Причина этих повторов в том, что равные элементы выбираются несколько раз на одном этапе. На рис. 13.13 показано, что на первом этапе есть
три варианта выбора, два из которых равны 4. Это приводит к двум повторяющимся ветвям поиска и, следовательно, к повторяющимся подмножествам.
Аналогично два элемента 4 на втором этапе также создают повторяющиеся
подмножества.
13.3. Задача о сумме подмножеств 389
1-й раунд выбора
2-й раунд выбора
Повторяющиеся
подмножества
3-й раунд выбора
Повторяющиеся
подмножества
Рис. 13.13. Повторяющиеся подмножества из-за равных элементов
1. Обрезка равных элементов
Для решения этой проблемы необходимо сделать выбор равных элементов
на каждом этапе однократным. Реализация этого подхода довольно изящна: поскольку массив отсортирован, равные элементы находятся рядом друг
с другом. Это означает, что если текущий элемент равен предыдущему, то он
уже был выбран, и его следует пропустить.
В то же время в этой задаче предусмотрено, что каждый элемент массива может быть выбран только один раз. К счастью, можно использовать
переменную start для выполнения этого ограничения: после выбора xi начинаем следующий цикл с индекса i + 1. Это позволяет исключить повторяющиеся подмножества и избежать повторного выбора элементов.
2. Код реализации
# === File: subset_sum_ii.py ===
def backtrack(
state: list[int], target: int, choices: list[int], start: int, res:
list[list[int]]
):
""" Поиск с возвратом: сумма подмножеств II."""
# Когда сумма подмножества равна target, фиксируется решение.
if target == 0:
res.append(list(state))
return
# Перебор всех вариантов выбора.
# Обрезка 2: перебор начинается со start, чтобы избежать создания
# повторяющихся подмножеств.
# Обрезка 3: перебор начинается со start, чтобы избежать повторного выбора
# одного и того же элемента.
390
Поиск с возвратом
for i in range(start, len(choices)):
# Обрезка 1: если сумма подмножества превышает target, цикл завершается.
# Это связано с тем, что массив уже отсортирован, и последующие
# элементы больше, сумма подмножества обязательно превысит target.
if target - choices[i] < 0:
break
# Обрезка 4: если элемент равен левому элементу, значит, эта ветвь
# поиска повторяется, и ее можно пропустить.
if i > start and choices[i] == choices[i - 1]:
continue
# Попытка: сделать выбор, обновить target, start.
state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target - choices[i], choices, i + 1, res)
# Возврат: отмена выбора, восстановление предыдущего состояния.
state.pop()
def subset_sum_ii(nums: list[int], target: int) -> list[list[int]]:
""" Решение задачи суммы подмножеств II."""
state = [] # Состояние (подмножество).
nums.sort() # Сортировка nums.
start = 0 # Начальная точка перебора.
res = [] # Список результатов (список подмножеств).
backtrack(state, target, nums, start, res)
return res
На рис. 13.14 демонстрируется процесс обратного отслеживания для массива [4, 4, 5] и целевого элемента 9, включающий четыре вида обрезки. Проанализируйте рисунок и комментарии в коде, чтобы лучше понять весь процесс
поиска и как работают различные операции обрезки.
1-й раунд выбора
2-й раунд выбора
Обрезка 4
В одном раунде равные
элементы можно выбирать
только один раз
Обрезка 2
Не допускать создания
повторяющихся
подмножеств
Обрезка 1
Сумма элементов
не может превышать
target
Обрезка 3
Запрет повторного выбора
элементов массива
Рис. 13.14. Процесс поиска с возвратом для реализации задачи о сумме подмножеств II
13.4. Задача об n ферзях 391
13.4. Задача об n ферзях
Задача
Согласно правилам шахмат ферзь может атаковать фигуры, находящиеся на
одной строке, одном столбце или одной диагонали. Дано n ферзей и шахматная доска размером n×n. Требуется найти расстановку, при которой ферзи
не могут атаковать друг друга.
Для n = 4 можно найти два решения, которые изображены на рис. 13.15.
С точки зрения алгоритма поиска с возвратом шахматная доска размером n×n
имеет n2 клеток, которые предоставляют собой все варианты выбора. В процессе размещения ферзей состояние доски постоянно меняется, и в каждый
момент времени доска имеет состояние state.
Рис. 13.15. Решения задачи о 4 ферзях
На рис. 13.16 изображено три условия ограничения для данной задачи:
несколько ферзей не могут находиться на одной строке, в одном столбце или
на одной диагонали. Стоит отметить, что диагонали делятся на главную диагональ \ и побочную диагональ /.
Не могут находиться на одной побочной диагонали
Решение должно удовлетворять
всем ограничениям
Не могут
находиться
в одной
строке
Не могут находиться
в одном столбце
Не могут находиться на
одной главной диагонали
Рис. 13.16. Ограничения задачи об n ферзях
392
Поиск с возвратом
1. Стратегия построчного размещения
Количество ферзей и количество строк на доске равно n, поэтому можно сделать
вывод: на каждой строке доски может быть размещен только один ферзь.
Из этого следует, что мы можем использовать стратегию построчного размещения: размещать по одному ферзю на каждой строке, начиная с первой
и заканчивая последней.
На рис. 13.17 изображен процесс построчного размещения для задачи о 4
ферзях. Из-за ограничений на размер изображения, на рис. 13.17 развернута
только одна ветвь поиска первой строки, а все решения, не удовлетворяющие
ограничениям по столбцам и диагоналям, обрезаны.
Размещений
на 1-й строке
Размещений
на 2-й строке
Размещений
на 3-й строке
Размещений
на 4-й строке
Рис. 13.17. Стратегия построчного размещения
По сути, стратегия построчного размещения выполняет функцию обрезки, отсекая все ветви поиска, в которых на одной строке может находиться
более одного ферзя.
2. Обрезка по столбцам и диагоналям
Чтобы выполнить ограничениям по столбцам, можно использовать булев массив cols длиной n, в котором будет фиксироваться наличие ферзя в каждом
столбце. На его основе перед каждым решением о размещении будут обрезаться столбцы, в которых уже есть ферзь. Состояние cols будет динамически
обновляться в процессе возврата.
Совет
Обратите внимание, что начальная точка матрицы находится в левом верхнем
углу, индекс строк увеличивается сверху вниз, а индекс столбцов – слева направо.
13.4. Задача об n ферзях 393
Как теперь отследить ограничения по диагоналям? Пусть индексы строки
и столбца какой-либо клетки на доске равны (row, col). Выбрав определенную
главную диагональ в матрице, можно заметить, что разность индексов строки
и столбца всех клеток на этой диагонали одинакова, т. е. для всех клеток на
главной диагонали значение row − col является постоянной величиной.
Это означает, что если для двух клеток выполняется условие row1 – col2 =
row2 – col2, то они находятся на одной главной диагонали. Пользуясь этим правилом, можно с помощью массива diags1 фиксировать наличие ферзя на каждой главной диагонали, как показано на рис. 13.18.
Аналогично сумма row + col для всех клеток на побочной диагонали является постоянной величиной. Мы можем использовать еще один массив
diags2 для обработки ограничений на побочной диагонали.
Запись, есть ли ферзь
на побочной диагонали
Запись, есть ли ферзь
на главной диагонали
Запись, есть ли ферзь в столбце
Рис. 13.18. Обработка ограничений по столбцам и диагоналям
3. Код реализации
Следует отметить, что в n-мерной матрице диапазон row – col составляет
[−n + 1, n − 1], а диапазон row + col составляет [0, 2n − 2], поэтому количество
главных и побочных диагоналей равно 2n − 1, т. е. длина массивов diags1
и diags2 также равна 2n − 1.
# === File: n_queens.py ===
def backtrack(
row: int,
n: int,
state: list[list[str]],
res: list[list[list[str]]],
cols: list[bool],
394
Поиск с возвратом
diags1: list[bool],
diags2: list[bool],
):
""" Поиск с возвратом: задача об n ферзях."""
# При размещении всех строк фиксируется решение.
if row == n:
res.append([list(row) for row in state])
return
# Перебор всех столбцов.
for col in range(n):
# Вычисление главной и побочной диагоналей для данной клетки.
diag1 = row - col + n - 1
diag2 = row + col
# Обрезка: не допускается наличие ферзя в данном столбце, на главной
# или побочной диагонали.
if not cols[col] and not diags1[diag1] and not diags2[diag2]:
# Попытка: размещение ферзя в данной клетке.
state[row][col] = "Q"
cols[col] = diags1[diag1] = diags2[diag2] = True
# Переход к следующей строке.
backtrack(row + 1, n, state, res, cols, diags1, diags2)
# Возврат: восстановление клетки в пустое состояние.
state[row][col] = "#"
cols[col] = diags1[diag1] = diags2[diag2] = False
def n_queens(n: int) -> list[list[list[str]]]:
""" Решение задачи об n ферзях."""
# Инициализация шахматной доски размером n*n, где 'Q' обозначает ферзя,
# а '#' обозначает пустую клетку.
state = [["#" for _ in range(n)] for _ in range(n)]
cols = [False] * n # Учет наличия ферзя в столбце.
diags1 = [False] * (2 * n - 1) # Учет наличия ферзя на главной диагонали.
diags2 = [False] * (2 * n - 1) # Учет наличия ферзя на побочной диагонали.
res = []
backtrack(0, n, state, res, cols, diags1, diags2)
return res
Размещение n раз по строкам с учетом ограничений по столбцам предполагает, что от первой до последней строки имеется n, n − 1, ..., 2, 1 вариантов
выбора, что требует времени O(n!). При фиксации решения необходимо копировать матрицу state и добавлять результат в res, что требует времени O(n2).
Таким образом, общая временная сложность составляет O(n! ⋅ n2). На практике обрезка по ограничениям диагоналей значительно сокращает пространство поиска, поэтому эффективность поиска часто превосходит указанную
временную сложность.
Массив state использует O(n2) пространства, массивы cols, diags1 и diags2 используют O(n) пространства. Максимальная глубина рекурсии составляет n, что
требует O(n) пространства стека. Следовательно, пространственная сложность равна O(n2).
13.5. Резюме 395
13.5. Резюме
1. Ключевые моменты
Алгоритм поиска с возвратом по сути является методом полного перебора, который ищет подходящие решения путем обхода в глубину пространства решений. В процессе поиска фиксируются удовлетворяющие
условиям решения до тех пор, пока не будут найдены все решения или
обход не будет завершен.
Поиск с возвратом включает в себя попытки и возвраты. Он использует поиск в глубину и выполняет попытки для различных вариантов. При несоответствии заданным условиям отменяет предыдущий выбор, возвращается к предыдущему состоянию и продолжает проверять другие варианты.
Попытки и возвраты – это операции в противоположных направлениях.
Задачи поиска с возвратом обычно содержат несколько ограничений, которые можно использовать для обрезки. Обрезка позволяет заранее завершить
ненужные ветви поиска, что значительно повышает эффективность поиска.
Алгоритм поиска с возвратом в основном применяется для решения поисковых задач и задач с ограничениями. Задачи комбинаторной оптимизации можно решать с помощью поиска с возвратом, но часто существуют более эффективные или более подходящие методы.
Задача о перестановках направлена на поиск всех возможных перестановок элементов заданного множества. В решении используется массив
для учета выбранных элементов и обрезки ветвей поиска с повторным
выбором одного и того же элемента. Это позволяет обеспечить выбор
каждого элемента только один раз.
В задаче о перестановках с повторяющимися элементами нужно отсекать повторяющиеся перестановки в конечном результате. Необходимо
обеспечить однократный выбор равных элементов в каждом раунде, что
обычно реализуется с помощью хеш-множества.
Цель задачи о сумме подмножеств – найти все подмножества с суммой,
равной целевому значению, в заданном множестве. Порядок элементов
в множестве не важен, но процесс поиска выводит результаты во всех
возможных порядках, создавая повторяющиеся подмножества. Перед
выполнением поиска с возвратом данные сортируются, а также устанавливается переменная для указания начальной точки каждого раунда,
чтобы обрезать ветви поиска с повторяющимися подмножествами.
В задаче о сумме подмножеств равные элементы в массиве создают повторяющиеся множества. При наличии предварительно отсортированного массива обрезка осуществляется путем проверки равенства соседних элементов, что гарантирует выбор равных элементов только один
раз в каждом раунде.
Задача об n ферзях заключается в нахождении способа размещения
n ферзей на шахматной доске размером n×n так, чтобы никакие два ферзя не рубили друг друга. Ограничения задачи включают ограничения по
строкам, столбцам, главным и побочным диагоналям. Для соблюдения
ограничения по строкам используется стратегия размещения по строкам, что гарантирует размещение одного ферзя в каждой строке.
396
Поиск с возвратом
Обработка ограничений по столбцам и диагоналям осуществляется аналогично. Для ограничения по столбцам используется массив, который
фиксирует наличие ферзя в каждом столбце. Для ограничения по диагоналям используются два массива, которые фиксируют наличие ферзя на
главной и побочной диагоналях соответственно. Сложность заключается
в нахождении закономерности индексов строк и столбцов для клеток,
находящихся на одной и той же главной (или побочной) диагонали.
2. Вопросы и ответы
Вопрос. Какова связь между возвратом и рекурсией?
Ответ. В общем, возврат – это стратегия алгоритма, тогда как рекурсия скорее является инструментом.
Алгоритмы поиска с возвратом обычно реализуются на основе рекурсии.
Однако поиск с возвратом – это один из вариантов применения рекурсии, а именно применение рекурсии в задачах поиска.
Структура рекурсии отражает парадигму разбиения на подзадачи и часто используется для решения задач, связанных со стратегией «разделяй
и властвуй», поиском с возвратом, динамическим программированием
(мемоизация рекурсии) и др.
Глава 14
Динамическое
программирование
Абстракция
Ручейки вливаются в реки, а реки – в океан.
Динамическое программирование объединяет решения маленьких задач
в ответ на большую задачу, шаг за шагом ведя нас к решению.
398
Динамическое программирование
14.1. Введение в динамическое программирование
Динамическое программирование является важной парадигмой в алгоритмах.
Ее суть заключается в разбиении задачи на серию более мелких подзадач. Сохранение решений подзадач позволяет избежать повторных вычислений, что
значительно повышает временную эффективность.
В этом разделе мы начнем с классического примера и сначала представим
его решение методом перебора. Мы понаблюдаем за наличием перекрывающихся подзадач, а затем постепенно выведем более эффективное решение
с использованием динамического программирования.
Подъем по лестнице
Дана лестница с n ступенями, на каждом шаге можно подниматься на одну
или две ступени. Сколько существует способов добраться до вершины лестницы?
Как показано на рис. 14.1, для лестницы с тремя ступенями существует три
способа добраться до вершины.
Количество ступеней n =
Есть 3 способа подняться на 3-ю ступень:
Рис. 14.1. Количество способов добраться до 3-й ступени
Цель этой задачи – найти количество способов, и можно попробовать
использовать для ее решения метод поиска с возвратом. Более конкретно – можно представить подъем по лестнице как процесс многократного
выбора: начать с пола, на каждом этапе выбирать подъем на одну или две
ступени, при достижении вершины лестницы количество способов увеличивается на 1, а при превышении вершины происходит обрезка. Ниже приведен код реализации.
14.1. Введение в динамическое программирование 399
# === File: climbing_stairs_backtrack.py ===
def backtrack(choices: list[int], state: int, n: int, res: list[int]) -> int:
""" Поиск с возвратом."""
# Когда достигнута n-я ступень, количество способов увеличивается на 1.
if state == n:
res[0] += 1
# Перебор всех вариантов.
for choice in choices:
# Обрезка: не допускается превышение n-й ступени.
if state + choice > n:
continue
# Попытка: сделать выбор, обновить состояние.
backtrack(choices, state + choice, n, res)
# возврат.
def climbing_stairs_backtrack(n: int) -> int:
""" Подъем по лестнице: поиск с возвратом."""
choices = [1, 2] # Можно выбрать подъем на 1 или 2 ступени.
state = 0 # Начало подъема с 0-й ступени.
res = [0] # Используется res[0] для записи количества способов.
backtrack(choices, state, n, res)
return res[0]
14.1.1. Первый метод: полный перебор
Алгоритм поиска с возвратом обычно не разбивает задачу явным образом,
а рассматривает ее решение как серию шагов принятия решений, исследуя
пути обхода и выполняя обрезку.
Можно попытаться проанализировать эту задачу с точки зрения разбиения.
Пусть для достижения i-й ступени существует dp[i] способов, тогда dp[i] является исходной задачей, а ее подзадачи включают следующие:
dp i 1 , dp i 2 , ... , dp 2 , dp 1 .
На каждом этапе можно подниматься только на одну или две ступени, поэтому перед на i-й ступенью мы находились либо на (i – 1)-й, либо на (i – 2)-й ступени. Другими словами, на i-ю ступень можно перейти только с (i – 1)-й или
(i – 2)-й ступени.
Отсюда следует важный вывод: количество способов добраться до
(i – 1)-й ступени плюс количество способов добраться до (i – 2)-й ступени
равно количеству способов добраться до i-й ступени. Формула выглядит следующим образом:
dp i = dp i 1 dp i 1 .
Это означает, что в задаче подъема по лестнице между подзадачами существует рекуррентная зависимость, и решение исходной задачи можно построить из решений подзадач. На рис. 14.2 демонстрируется эта рекуррентная зависимость.
400
Динамическое программирование
Количество ступеней n =
Количество способов dp[n] =
Рис. 14.2. Рекуррентная зависимость количества способов подъема по лестнице
Можно получить решение методом полного перебора на основе рекуррентной формулы. Начиная с dp[n], большая задача рекурсивно разбивается на
сумму двух меньших задач, пока не будут достигнуты минимальные подзадачи dp[1] и dp[2], для которых возвращаются известные решения: dp[1] = 1,
dp[2] = 2. То есть для достижения 1-й и 2-й ступеней существует 1 и 2 способа
соответственно.
Рассмотрим следующий код, который, как и стандартный код поиска с возвратом, относится к поиску в глубину, но является более лаконичным.
# === File: climbing_stairs_dfs.py ===
def dfs(i: int) -> int:
""" Поиск."""
# dp[1] и dp[2] известны, возврат.
if i == 1 or i == 2:
return i
# dp[i] = dp[i-1] + dp[i-2]
count = dfs(i - 1) + dfs(i - 2)
return count
def climbing_stairs_dfs(n: int) -> int:
""" Подъем по лестнице: поиск."""
return dfs(n)
На рис. 14.3 изображено рекурсивное дерево, образованное полным перебором. Для задачи dp[n] глубина рекурсивного дерева равна n, а временная сложность составляет O(2n). Экспоненциальный рост приводит к взрывному увеличению, и при вводе достаточно большого n можно столкнуться с длительной
работой алгоритма.
14.1. Введение в динамическое программирование 401
Сгенерированное деревом рекурсии пространство поиска
содержит множество перекрывающихся подзадач, что
приводит к экспоненциальной временной сложности O(2n)
Рис. 14.3. Рекурсивное дерево для подъема по лестнице
Как видно из рис. 14.3, экспоненциальная временная сложность вызвана перекрывающимися подзадачами. Например, dp[9] разбивается на dp[8]
и dp[7], dp[8] разбивается на dp[7] и dp[6] – обе задачи содержат подзадачу dp[7].
Таким образом, в подзадачах содержатся более мелкие перекрывающиеся
подзадачи, и большая часть вычислительных ресурсов тратится на их обработку.
14.1.2. Второй метод: мемоизация поиска
Для повышения эффективности алгоритма необходимо, чтобы все перекрывающиеся подзадачи вычислялись только один раз. Для этого мы объявим массив mem для записи решений каждой подзадачи и в процессе поиска
устраним необходимость их повторной обработки.
1. При первом вычислении dp[i] мы записываем результат в mem[i] для
дальнейшего использования.
2. Когда требуется повторно вычислить dp[i], мы можем напрямую получить результат из mem[i], избегая повторной обработки.
Код реализации представлен ниже.
# === File: climbing_stairs_dfs_mem.py ===
def dfs(i: int, mem: list[int]) -> int:
""" мемоизация поиска."""
# dp[1] и dp[2] известны, возврат.
if i == 1 or i == 2:
return i
# Если существует запись dp[i], возвращаем ее значение.
if mem[i] != -1:
return mem[i]
402
Динамическое программирование
# dp[i] = dp[i-1] + dp[i-2]
count = dfs(i - 1, mem) + dfs(i - 2, mem)
# Запись dp[i].
mem[i] = count
return count
def climbing_stairs_dfs_mem(n: int) -> int:
""" Подъем по лестнице: мемоизация поиска."""
# В mem[i] хранится количество способов подняться на i-ю ступень,
# -1 означает отсутствие записи.
mem = [-1] * (n + 1)
return dfs(n, mem)
После внедрения запоминания все пересекающиеся подзадачи нужно вычислить только один раз, что оптимизирует временную сложность до O(n),
это является значительным скачком, см рис. 14.4.
Обрезка
Запись найдена,
немедленный возврат
Временная сложность поиска с мемоизацией составляет O(n),
поскольку все перекрывающиеся подзадачи вычисляются
только один раз
Рис. 14.4. Мемоизация поиска и соответствующее дерево рекурсии
14.1.3. Третий метод: динамическое программирование
Мемоизация поиска – это метод «сверху вниз»: мы начинаем с исходной
задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на
более мелкие, пока не достигнем минимальных подзадач с известным решением (листовые узлы). Затем через возврат поэтапно собираем решения подзадач, чтобы построить решение исходной задачи.
В отличие от этого подхода динамическое программирование представляет собой метод «снизу вверх»: начиная с решения минимальных подзадач, итеративно строится решение более крупных подзадач, пока не будет получено решение исходной задачи.
Поскольку динамическое программирование не включает этап возврата,
оно реализуется с использованием циклов и итераций, без необходимости
14.1. Введение в динамическое программирование 403
в рекурсии. В следующем коде мы инициализируем массив dp для хранения решений подзадач, который выполняет ту же функцию запоминания, что и массив mem в мемоизации поиска.
# === File: climbing_stairs_dp.py ===
def climbing_stairs_dp(n: int) -> int:
""" Подъем по лестнице: динамическое программирование."""
if n == 1 or n == 2:
return n
# Инициализация таблицы dp для хранения решений подзадач.
dp = [0] * (n + 1)
# Начальное состояние: предустановка решения минимальных подзадач.
dp[1], dp[2] = 1, 2
# Переход состояния: постепенное решение более крупных подзадач.
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
На рис. 14.5 иллюстрируется процесс выполнения приведенного выше кода.
Начальное состояние:
Уравнение перехода состояния:
Начать с начального состояния, выполнять переходы
состояний, завершать при достижении целевого
состояния
Рис. 14.5. Применение динамического программирования для подъема по лестнице
Как и в алгоритмах поиска с возвратом, в динамическом программировании
используется концепция состояния для обозначения определенной стадии решения задачи. Каждое состояние соответствует подзадаче и соответствующему локальному оптимальному решению. Например, состояние задачи подъема
по лестнице определяется текущей ступенью i.
На основе этого можно обобщить часто используемые термины динамического программирования.
Массив dp называется таблицей dp, dp[i] обозначает решение подзадачи,
соответствующей состоянию i.
404
Динамическое программирование
Состояния, соответствующие минимальным подзадачам (1-я и 2-я ступени лестницы), называются начальными состояниями.
Рекуррентное соотношение dp[i] = dp[i − 1] + dp[i − 2] называется уравнением перехода состояния.
14.1.4. Оптимизация пространства
Внимательный читатель может заметить, что, поскольку dp[i] зависит
только от dp[i − 1] и dp[i − 2], нам не нужно использовать целый массив dp для хранения всех решений подзадач, а достаточно использовать
только две переменные для последовательного продвижения. Ниже приведен пример кода.
# === File: climbing_stairs_dp.py ===
def climbing_stairs_dp_comp(n: int) -> int:
""" Подъем по лестнице: динамическое программирование с оптимизацией пространства."""
if n == 1 or n == 2:
return n
a, b = 1, 2
for _ in range(3, n + 1):
a, b = b, a + b
return b
Как видно из кода, за счет исключения использования массива dp пространственная сложность снижается с O(n) до O(1).
В задачах динамического программирования текущее состояние часто зависит только от ограниченного числа предыдущих состояний. В этом случае
можно сохранить только необходимые состояния, чтобы сэкономить память.
Эта техника оптимизации пространства называется скользящие переменные или скользящий массив.
14.2. Особенности задач динамического программирования
В предыдущем разделе мы изучили, как динамическое программирование решает исходную задачу путем разложения на подзадачи. На самом деле разложение на подзадачи – это универсальный алгоритмический подход, который по-разному применяется в методах «разделяй и властвуй», динамическом
программировании и поиске с возвратом.
Алгоритм «разделяй и властвуй» рекурсивно делит исходную задачу на
несколько независимых подзадач до самых минимальных и в процессе
обратного хода объединяет решения всех подзадач.
Динамическое программирование также осуществляет рекурсивное разбиение задачи. Основное отличие от алгоритмов «разделяй и властвуй»
заключается в том, что подзадачи в динамическом программировании
взаимозависимы, и в процессе разбиения возникает множество перекрывающихся подзадач.
14.2. Особенности задач динамического программирования 405
Алгоритмы поиска с возвратом исчерпывают все возможные решения
методом проб и возвратов, осекая ненужные ветви поиска с помощью
обрезки. Решение исходной задачи состоит из серии шагов принятия решений, каждый шаг можно рассматривать как подзадачу.
На практике динамическое программирование часто используется для решения задач оптимизации, которые не только содержат перекрывающиеся
подзадачи, но и обладают двумя другими важными свойствами: оптимальной
подструктурой и отсутствием последствий.
14.2.1. Оптимальная подструктура
Чтобы лучше продемонстрировать концепцию оптимальной подструктуры,
рассмотрим задачу о подъеме по лестнице с небольшими изменениями.
Минимальная стоимость подъема по лестнице
Дана лестница, по которой можно подниматься на одну или две ступени за
раз. На каждой ступени указана неотрицательная целочисленная стоимость,
которую необходимо заплатить за подъем по этой ступени. Дан неотрицательный целочисленный массив cost, где cost[i] обозначает стоимость i-й ступени, а cost[0] – на полу (начальная точка). Необходимо вычислить минимальную стоимость достижения вершины.
Если стоимость на 1-й, 2-й и 3-й ступенях составляет 1, 10 и 1 соответственно, то минимальная стоимость подъема с пола на 3-ю ступень равна 2, как показано на рис. 14.6.
Количество ступеней n =
Стоимость cost[n] =
Есть 3 способа подняться на 3-ю ступень:
0 → 1 → 2 → 3,0 → 2 → 3,0 → 1 → 3
Среди них способ 0 → 1 → 3 имеет
наименьшую стоимость, равную 2
Рис. 14.6. Минимальная стоимость подъема на 3-ю ступень
406
Динамическое программирование
Пусть dp[i] обозначает накопленную стоимость для подъема на i-ю ступень.
Поскольку на i-ю ступень можно попасть только с (i – 1)-й или (i – 2)-й ступени,
dp[i] может быть равен либо dp[i − 1] + cost[i], либо dp[i − 2] + cost[i]. Чтобы минимизировать расход, следует выбрать меньшее из двух значений:
dp i = min(dp i 1 , dp i 2 cost i .
Этот пример иллюстрирует смысл оптимальной подструктуры: оптимальное решение исходной задачи строится на основе оптимальных решений подзадач.
Очевидно, что данная задача обладает оптимальной подструктурой: из двух
оптимальных решений подзадач dp[i − 1] и dp[i − 2] выбирается лучшее, и на его
основе строится оптимальное решение исходной задачи dp[i].
Итак, имеет ли задача о подъеме по лестнице из предыдущего раздела оптимальную подструктуру? Цель этой задачи – вычислить количество
решений, что на первый взгляд является задачей подсчета. Но если перефразировать вопрос как вычисление максимального количества решений,
то неожиданно обнаруживается, что, хотя модифицированная задача эквивалентна, возникает оптимальная подструктура: максимальное количество решений для n-й ступени равно сумме максимального количества
решений для (n – 1)-й и (n – 2)-й ступеней. Таким образом, интерпретация
оптимальной подструктуры может быть гибкой и иметь различное значение
в зависимости от задачи.
Согласно уравнению перехода состояния и начальному состоянию dp[1] =
cost[1] и dp[2] = cost[2], можно получить код реализации динамического программирования.
# === File: min_cost_climbing_stairs_dp.py ===
def min_cost_climbing_stairs_dp(cost: list[int]) -> int:
""" Минимальная стоимость подъема по лестнице: динамическое программирование."""
n = len(cost) - 1
if n == 1 or n == 2:
return cost[n]
# Инициализация таблицы dp для хранения решений подзадач.
dp = [0] * (n + 1)
# Начальное состояние: предусмотреть решение минимальной подзадачи.
dp[1], dp[2] = cost[1], cost[2]
# Переход состояния: постепенное решение более крупных подзадач.
for i in range(3, n + 1):
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]
return dp[n]
На рис. 14.7 демонстрируется процесс динамического программирования
в данном коде.
14.2. Особенности задач динамического программирования 407
Начальное состояние:
Уравнение перехода состояния:
Рис. 14.7. Процесс динамического программирования для задачи минимальной стоимости
подъема по лестнице
Эту задачу также можно оптимизировать по пространству, сжав одномерное
представление до нулевого, что снижает сложность по пространству с O(n) до O(1).
# === File: min_cost_climbing_stairs_dp.py ===
def min_cost_climbing_stairs_dp_comp(cost: list[int]) -> int:
""" Минимальная стоимость подъема по лестнице: динамическое программирование с оптимизацией по пространству."""
n = len(cost) - 1
if n == 1 or n == 2:
return cost[n]
a, b = cost[1], cost[2]
for i in range(3, n + 1):
a, b = b, min(a, b) + cost[i]
return b
14.2.2. Отсутствие последствий
Отсутствие последствий – одно из важных свойств, позволяющих динамическому программированию эффективно решать задачи. Оно определяется следующим образом: при заданном определенном состоянии его дальнейшее развитие зависит только от текущего состояния и не зависит от всех
предыдущих состояний.
Возьмем, к примеру, задачу о подъеме по лестнице. При заданном состоянии i оно может развиться в состояния i + 1 или i + 2, что соответствует подъему на одну или две ступени. При выборе одного из этих вариантов нет необходимости учитывать состояния, предшествующие i, так как они не влияют
на будущее состояние.
Однако, если добавить к задаче о подъеме по лестнице ограничения, ситуация изменится.
408
Динамическое программирование
Подъем по лестнице с ограничениями
Дана лестница с n ступенями, по которой можно подниматься на одну или
две ступени за раз, но нельзя дважды подряд подняться на одну ступень.
Сколько существует способов подняться на вершину?
Как показано на рис. 14.8, для достижения 3-й ступени остается только два
возможных варианта. Вариант с тремя последовательными подъемами по одной ступени не удовлетворяет условиям и поэтому отбрасывается.
Поскольку нельзя подняться на 1 ступень два раза
подряд, вариант 0 → 1 → 2 → 3 отбрасывается
Количество ступеней n =
Есть 2 способа подняться на 3-ю ступень:
0 → 2 → 3,0 → 1 → 3
Рис. 14.8. Количество вариантов достижения 3-й ступени с учетом ограничений
В этой задаче если на предыдущем шаге был совершен подъем на одну
ступень, то на следующем шаге необходимо обязательно подняться на две
ступени. Это означает, что выбор следующего шага нельзя определить
независимо от текущего состояния (текущей ступени). Но следующий
шаг также зависит и от предыдущего состояния (ступени на предыдущем шаге).
Нетрудно заметить, что данная задача не удовлетворяет условию отсутствия
последствий. Уравнение перехода состояния dp[i] = dp[i – 1] + dp[i – 2] также
не работает, так как dp[i – 1] представляет собой подъем на одну ступень, включая варианты, в которых на предыдущем шаге был подъем на одну ступень.
Чтобы выполнить условия, нельзя напрямую включать dp[i – 1] в dp[i].
Для этого необходимо расширить определение состояния: состояние [i, j]
обозначает нахождение на i-й ступени, при этом на предыдущем шаге
был подъем на j ступеней, где j ∈ {1, 2}. Это определение состояния уже различает, был ли на предыдущем шаге подъем на одну или две ступени.
Если на предыдущем шаге был подъем на одну ступень, то на шаг до этого можно было подняться только на две ступени, т. е. dp[i, 1] можно получить только из dp[i – 1, 2].
14.2. Особенности задач динамического программирования 409
Если на предыдущем шаге был подъем на две ступени, то на шаг до этого
можно было выбрать подъем на одну или две ступени, т. е. dp[i, 2] можно
получить из dp[i – 2, 1] или dp[i – 2, 2].
При таком определении dp[i, j] обозначает количество вариантов для состояния [i, j], как показано на рис. 14.9. В этом случае уравнение перехода состояния будет следующим:
dp i, 1 = dp i 1, 2
dp i, 2 = dp i 2, 1 dp i 2, 2 .
Нельзя подняться на 1 ступень два раза подряд,
поэтому этот вариант отбрасывается
Рис. 14.9. Рекуррентное соотношение с учетом ограничений
В результате возвращается сумма dp[n, 1] + dp[n, 2], которая представляет
общее количество вариантов достижения n-й ступени.
# === File: climbing_stairs_constraint_dp.py ===
def climbing_stairs_constraint_dp(n: int) -> int:
""" Динамическое программирование для подъема по лестнице
с ограничениями."""
if n == 1 or n == 2:
return 1
# Инициализация таблицы dp для хранения решений подзадач.
dp = [[0] * 3 for _ in range(n + 1)]
# Начальное состояние: предустановка решения минимальной подзадачи.
dp[1][1], dp[1][2] = 1, 0
dp[2][1], dp[2][2] = 0, 1
# Переход состояния: постепенное решение более крупных подзадач.
for i in range(3, n + 1):
dp[i][1] = dp[i - 1][2]
dp[i][2] = dp[i - 2][1] + dp[i - 2][2]
return dp[n][1] + dp[n][2]
410
Динамическое программирование
В приведенном выше примере необходимо учитывать только одно предыдущее состояние, поэтому можно расширить определение состояния, и задача
все равно будет удовлетворять условию отсутствия последствий. Однако некоторые задачи обладают серьезными условиями последствий.
Подъем по лестнице с генерацией препятствий
Дана лестница с n ступенями, на каждом шаге можно подняться на одну или две
ступени. При достижении i-й ступени система автоматически устанавливает препятствие на 2i-й ступени, на которую при дальнейшем подъеме
нельзя наступать. Например, если на первых двух шагах были пройдены 2-я
и 3-я ступени, то в дальнейшем нельзя наступать на 4-ю и 6-ю ступени. Сколько существует способов добраться до вершины лестницы?
В этой задаче следующий шаг зависит от всех предыдущих состояний, так
как каждый предыдущий шаг устанавливает препятствие на более высокой
ступени. Для таких задач динамическое программирование часто оказывается
неэффективным.
На самом деле многие сложные задачи комбинаторной оптимизации (например, задача коммивояжера) не удовлетворяют условию отсутствия последствий. Для решения таких задач обычно выбираются другие методы, такие как эвристический поиск, генетические алгоритмы, обучение с подкреплением и т. д., чтобы получить приемлемое локальное оптимальное решение
за ограниченное время.
14.3. Подход к решению задач динамического
программирования
В предыдущих разделах были рассмотрены основные характеристики задач
динамического программирования, теперь исследуем два более практичных
вопроса.
1. Как определить, является ли задача задачей динамического программирования?
2. С чего начать решение задачи динамического программирования, какова полная схема решения?
14.3.1. Определение задачи
В общем случае, если задача содержит перекрывающиеся подзадачи, оптимальную подструктуру и удовлетворяет условию отсутствия последствий,
она обычно подходит для решения методом динамического программирования. Однако трудно извлечь эти характеристики непосредственно из
описания задачи. Поэтому обычно условия смягчаются, и сначала проверяется, подходит ли задача для решения методом поиска с возвратом
(перебора).
14.3. Подход к решению задач динамического программирования 411
Задачи, подходящие для решения методом поиска с возвратом, обычно соответствуют модели дерева решений. Такие задачи можно описать
с помощью древовидной структуры, в которой каждый узел представляет собой решение, а каждый путь – последовательность решений.
Иными словами, если задача включает в себя явную концепцию принятия
решений и решение получается в результате серии решений, то она соответствует модели дерева решений. Обычно такую задачу можно решить с помощью метода обратного поиска.
Задачи динамического программирования, помимо вышеуказанных, должны иметь некоторые дополнительные характеристики.
Задача содержит описание оптимизации, например максимизацию или
минимизацию.
Состояние задачи можно представить с помощью списка, многомерной
матрицы или дерева, и существует рекурсивная связь между состоянием
и его окружением.
Соответственно, существуют маркеры, которые говорят о неприменимости
стратегии динамического программирования.
Цель задачи – найти все возможные решения, а не оптимальное решение.
Описание задачи имеет явные признаки комбинаторики, и требуется
вернуть несколько конкретных решений.
Если задача соответствует модели дерева решений и обладает достаточно
явными дополнительным характеристиками, можно предположить, что это
задача динамического программирования, и подтвердить это в процессе
решения.
14.3.2. Этапы решения задачи
Процесс решения задач динамического программирования может различаться в зависимости от природы и сложности задачи, но обычно следует
следующей схеме: описание решений, определение состояния, построение
таблицы dp, вывод уравнения перехода состояния, определение граничных
условий и т. д.
Для более наглядного представления этапов решения рассмотрим в качестве примера классическую задачу «минимальная стоимость пути».
Задача
Дан двумерный массив grid размером n×m, в котором каждая ячейка содержит неотрицательное число, представляющее стоимость этой ячейки. Робот
начинает движение из левого верхнего угла и может двигаться только вниз
или вправо на одну ячейку за раз, пока не достигнет правого нижнего угла.
Необходимо вернуть минимальную сумму пути от левого верхнего до правого
нижнего угла.
На рис. 14.10 показан пример, в котором минимальная сумма пути для данного массива равна 13.
412
Динамическое программирование
Начальная точка
Конечная точка
Минимальная стоимость пути равна 13
Соответствующая оптимальная схема: 1 → 2 → 2 → 3 → 2 → 1 → 2
Рис. 14.10. Пример данных для задачи минимальной стоимости пути
Шаг 1: обдумывание каждого решения, определение состояния, получение
таблицы dp
В этой задаче решение заключается в выборе следующего шага из текущей
ячейки: вниз или вправо. Обозначим текущий индекс строки и столбца как
[i, j], тогда после шага вниз или вправо индекс изменится на [i + 1, j] или [i, j + 1].
Таким образом, состояние должно включать два переменных индекса: строки
и столбца, обозначаемых как [i, j].
Подзадача, соответствующая состоянию [i, j], заключается в нахождении минимальной стоимости пути от начальной точки [0, 0] до точки [i, j], решение
обозначается как dp[i, j].
Таким образом, мы получаем двумерную матрицу dp, размер которой совпадает с размером входного массива grid, как показано на рис. 14.11.
Таблица dp
Решение: пройти на одну клетку вправо
или вниз
Подзадача: минимальная сумма пути из левого
верхнего угла до [i, j]
Определение состояния: индексы строки
и столбца [i, j]
Таблица dp: матрица того же размера, что и grid
Рис. 14.11. Определение состояния и таблица dp
14.3. Подход к решению задач динамического программирования 413
Примечание
Динамическое программирование и процесс поиска с возвратом можно описать как последовательность решений. Состояние состоит из всех переменных
решений, оно должно включать все переменные, описывающие прогресс решения, и содержать достаточно информации для вывода следующего состояния.
Каждое состояние соответствует подзадаче, для хранения всех решений подзадач создается таблица dp. Каждая независимая переменная состояния является измерением таблицы dp. По сути, таблица dp определяет соответствие
между состоянием и решением подзадачи.
Шаг 2: нахождение оптимальной подструктуры и вывод уравнения
перехода состояния
Переход в состояние [i, j] возможен только из верхней ячейки [i − 1, j] или левой
ячейки [i, j − 1]. Таким образом, оптимальная подструктура определяется тем,
что минимальная сумма пути до [i, j] определяется минимальной суммой пути
из [i, j − 1] и [i − 1, j].
На основе вышеизложенного можно вывести уравнение перехода состояния, показанное на рис. 14.12:
dp i, j = min(dp i 1 , dp i, j 1 grid i, j .
Таблица dp
Уравнение перехода состояния:
Рис. 14.12. Оптимальная подструктура и уравнение перехода состояния
Примечание
На основе определенной таблицы dp следует обдумать связь между исходной задачей и подзадачами и найти способ построения оптимального решения исходной
задачи через оптимальные решения подзадач, т. е. оптимальную подструктуру.
Как только оптимальная подструктура найдена, можно использовать ее для
построения уравнения перехода состояния.
414
Динамическое программирование
Шаг 3: определение граничных условий и порядка перехода состояния
В этой задаче состояния в первой строке можно получить только из левых состояний, а состояния в первом столбце – только из верхних состояний, поэтому первая строка i = 0 и первый столбец j = 0 являются граничными условиями.
Поскольку каждую ячейку можно получить только из ячейки слева или
сверху, мы используем цикл для обхода матрицы: внешний цикл проходит по
строкам, а внутренний – по столбцам, как показано на рис. 14.13.
Граничные условия:
инициализировать первые
строку и столбец
Порядок перехода состояний:
прямой обход матрицы
Рис. 14.13. Граничные условия и порядок перехода состояний
Примечание
Граничные условия в динамическом программировании используются для
инициализации таблицы dp, а в поиске – для отсечения.
Ключевым моментом в порядке перехода состояний является обеспечение
правильного расчета меньших подзадач, от которых зависит текущая задача.
На основе вышеизложенного анализа можно сразу написать код динамического программирования. Однако разбиение подзадач – это подход сверху
вниз, поэтому реализация в порядке полный перебор → мемоизация → динамическое программирование более соответствует привычному мышлению.
1. Первый метод: полный перебор
Поиск начинается с состояния [i, j] и постоянно разбивается на более мелкие состояния [i – 1, j] и [i, j – 1]. Рекурсивная функция включает следующие элементы.
Рекурсивные параметры: состояние [i, j].
Возвращаемое значение: минимальная стоимость пути от [0, 0] до [i, j],
dp[i, j].
Условие завершения: когда i = 0 и j = 0, возвращается стоимость grid[0, 0].
Обрезка: при i < 0 или j < 0 индекс выходит за допустимые пределы,
в этом случае возвращается стоимость +∞, что означает недопустимость.
Ниже приведен код реализации.
14.3. Подход к решению задач динамического программирования 415
# === File: min_path_sum.py ===
def min_path_sum_dfs(grid: list[list[int]], i: int, j: int) -> int:
""" Минимальная стоимость пути: полный перебор."""
# Если это верхний левый элемент, то поиск завершается.
if i == 0 and j == 0:
return grid[0][0]
# Если индексы строки и столбца выходят за пределы, возвращается стоимость +∞.
if i < 0 or j < 0:
return inf
# Вычисление минимальной стоимости пути от верхнего левого угла до (i-1,
j) и (i, j-1).
up = min_path_sum_dfs(grid, i - 1, j)
left = min_path_sum_dfs(grid, i, j - 1)
# Возвращение минимальной стоимости пути от верхнего левого угла до (i, j).
return min(left, up) + grid[i][j]
На рис. 14.14 изображено дерево рекурсии с корневым узлом dp[2, 1], содержащее несколько перекрывающихся подзадач, количество которых резко увеличивается с увеличением размера сетки grid.
По сути, причиной перекрывающихся подзадач является наличие нескольких путей, ведущих из верхнего левого угла к одной ячейке.
Предыдущая строка
Таблица
dp
Предыдущий столбец
Обрезка: индекс
выходит за
границы
Источник перекрывающихся подзадач:
оба значения dp[i + 1, j] и dp[i, j + 1] зависят
от dp[i, j]
Рис. 14.14. Дерево рекурсии полного перебора
Каждое состояние имеет два варианта выбора: вниз и вправо. Чтобы пройти
из верхнего левого угла в нижний правый, требуется m + n – 2 шагов, поэтому
в худшем случае временная сложность составляет O(2m+n). Обратите внимание,
что этот расчет не учитывает случаи, когда путь достигает границы сетки, где
остается только один вариант выбора, поэтому фактическое количество путей
будет меньше.
416
Динамическое программирование
2. Второй метод: мемоизация
Вводится список mem, имеющий те же размеры, что и сетка grid, для записи решений подзадач и отсечения перекрывающихся подзадач.
# === File: min_path_sum.py ===
def min_path_sum_dfs_mem(
grid: list[list[int]], mem: list[list[int]], i: int, j: int
) -> int:
""" Минимальная стоимость пути: мемоизация."""
# Если это верхний левый элемент, то поиск завершается.
if i == 0 and j == 0:
return grid[0][0]
# Если индексы строки и столбца выходят за пределы, возвращается стоимость +∞.
if i < 0 or j < 0:
return inf
# Если уже есть запись, то возвращается она.
if mem[i][j] != -1:
return mem[i][j]
# Минимальная стоимость пути от левого и верхнего элементов.
up = min_path_sum_dfs_mem(grid, mem, i - 1, j)
left = min_path_sum_dfs_mem(grid, mem, i, j - 1)
# Запись и возвращение минимальной стоимости пути от верхнего левого
# угла до (i, j).
mem[i][j] = min(left, up) + grid[i][j]
return mem[i][j]
После введения мемоизации решения всех подзадач вычисляются только
один раз, как показано на рис. 14.15. Поэтому временная сложность зависит от
общего числа состояний, т. е. от размера сетки O(nm).
Предыдущая строка
Предыдущий столбец
Обрезка: запись
уже существует
Рис. 14.15. Дерево рекурсии мемоизации
Обрезка: индекс
выходит за
границы
14.3. Подход к решению задач динамического программирования 417
3. Третий метод: динамическое программирование
Ниже представлена реализация решения с использованием итеративного подхода динамического программирования.
# === File: min_path_sum.py ===
def min_path_sum_dp(grid: list[list[int]]) -> int:
""" Минимальная стоимость пути: динамическое программирование."""
n, m = len(grid), len(grid[0])
# Инициализация таблицы dp.
dp = [[0] * m for _ in range(n)]
dp[0][0] = grid[0][0]
# Переход состояния: первая строка.
for j in range(1, m):
dp[0][j] = dp[0][j - 1] + grid[0][j]
# Переход состояния: первый столбец.
for i in range(1, n):
dp[i][0] = dp[i - 1][0] + grid[i][0]
# Переход состояния: остальные строки и столбцы.
for i in range(1, n):
for j in range(1, m):
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j]
return dp[n - 1][m - 1]
На рис. 14.16 демонстрируется процесс перехода состояний для минимальной стоимости пути, который охватывает всю сетку, поэтому временная
сложность составляет O(nm). Размер массива dp равен n×m, следовательно,
пространственная сложность также составляет O(nm).
Таблица dp
Шаг 1
Инициализация таблицы dp
Рис. 14.16. Динамическое программирование для минимальной стоимости пути. Шаг 1
418
Динамическое программирование
Таблица dp
Инициализация первой строки
Шаг 2
Инициализация первого столбца
Таблица dp
Переход состояния
Шаг 3
Таблица dp
Переход состояния
Шаг 4
Рис. 14.16. Продолжение. Шаги 2–4
14.3. Подход к решению задач динамического программирования 419
Таблица dp
Переход состояния
Шаг 5
Таблица dp
Переход состояния
Шаг 6
Таблица dp
Переход состояния
Шаг 7
Рис. 14.16. Продолжение. Шаги 5–7
420
Динамическое программирование
Таблица dp
Переход состояния
Шаг 8
Таблица dp
Переход состояния
Шаг 9
Таблица dp
Переход состояния
Шаг 10
Рис. 14.16. Продолжение. Шаги 8–10
14.3. Подход к решению задач динамического программирования 421
Таблица dp
Переход состояния
Шаг 11
Таблица dp
Шаг 12
Возврат минимальной стоимости пути 13
Рис. 14.16. Окончание. Шаги 11–12
4. Оптимизация пространства
Поскольку каждая ячейка зависит только от ячеек слева и сверху, для реализации таблицы dp можно использовать одномерный массив. Обратите внимание, что, поскольку массив dp может представлять только одну строку состояния, невозможно заранее инициализировать состояние первого столбца, его
необходимо обновлять при обходе каждой строки.
# === File: min_path_sum.py ===
def min_path_sum_dp_comp(grid: list[list[int]]) -> int:
""" Минимальная стоимость пути: динамическое программирование с оптимизацией
пространства."""
n, m = len(grid), len(grid[0])
# Инициализация таблицы dp.
dp = [0] * m
422
Динамическое программирование
# Переход состояния: первая строка.
dp[0] = grid[0][0]
for j in range(1, m):
dp[j] = dp[j - 1] + grid[0][j]
# Переход состояния: остальные строки.
for i in range(1, n):
# Переход состояния: первый столбец.
dp[0] = dp[0] + grid[i][0]
# Переход состояния: остальные столбцы.
for j in range(1, m):
dp[j] = min(dp[j - 1], dp[j]) + grid[i][j]
return dp[m - 1]
14.4. Задача о рюкзаке 0-1
Задача о рюкзаке является отличным примером для начала изучения динамического программирования и представляет собой одну из наиболее распространенных форм этой задачи. Существует множество ее вариаций, таких как задача
о рюкзаке 0-1, задача о полном рюкзаке, задача о многократном рюкзаке и др.
В этом разделе мы сначала решим наиболее распространенную задачу
о рюкзаке 0-1.
Задача
Дано n предметов, масса i-го предмета равна wgt[i – 1], его стоимость равна val[i – 1], и рюкзак с вместимостью cap. Каждый предмет можно выбрать
только один раз. Требуется определить максимальную стоимость предметов,
которые можно поместить в рюкзак при заданной вместимости.
Обратите внимание на рис. 14.17: поскольку нумерация предметов i начинается с 1, а индексация массива с 0, то предмету i соответствует масса wgt[i – 1]
и стоимость val[i – 1].
Номер Масса Стоимость
Вместимость рюкзака
Максимальная стоимость: 270
Оптимальная схема: положить в рюкзак
которые займут 50 ед. вместимости рюкзака
,
Рис. 14.17. Пример данных для задачи о рюкзаке 0-1
14.4. Задача о рюкзаке 0-1 423
Задачу о рюкзаке 0-1 можно рассматривать как процесс, состоящий из n этапов принятия решений. Для каждого предмета существует два решения: не класть
в рюкзак или класть. Таким образом, задача соответствует модели дерева решений.
Цель задачи – найти максимальную стоимость предметов, которые можно
поместить в рюкзак при заданной вместимости, что с высокой вероятностью
является задачей динамического программирования.
Шаг 1: обдумывание каждого этапа принятия решения,
определение состояния, получение таблицы dp
Для каждого предмета справедливо утверждение: если предмет не класть
в рюкзак, вместимость рюкзака не изменится; если класть, вместимость уменьшится. Отсюда определяется состояние: текущий номер предмета i и вместимость рюкзака c, обозначается как [i, c].
Подзадача, соответствующая состоянию [i, c], заключается в нахождении
максимальной стоимости первых i предметов в рюкзаке вместимостью
c, обозначается как dp[i, c].
Требуется получить dp[n, cap], поэтому необходима двумерная таблица dp
размером (n + 1) × (cap + 1).
Шаг 2: выявление оптимальной подструктуры и вывод уравнения перехода состояния
После принятия решения по предмету i остается подзадача принятия решений
для первых i – 1 предметов, которая делится на следующие два случая:
1) не класть предмет i: вместимость рюкзака не изменяется, состояние
переходит в [i – 1, c];
2) класть предмет i: вместимость рюкзака уменьшается на wgt[i – 1], стоимость увеличивается на val[i – 1], состояние переходит в [i – 1, c – wgt[i – 1]].
Этот анализ показывает оптимальную подструктуру задачи: максимальная
стоимость dp[i, c] равна большей из двух стоимостей: не класть предмет
i и класть предмет i. Отсюда выводится уравнение перехода состояния:
dp[i, c] = max(dp[i – 1, c], dp[i – 1, c – wgt[i – 1]] + val[i – 1]).
Следует отметить, что если текущая масса предмета wgt[i – 1] превышает
оставшуюся вместимость рюкзака c, то можно выбрать только не класть предмет в рюкзак.
Шаг 3: определение граничных условий и порядка перехода состояния
Когда нет предметов или вместимость рюкзака равна 0, максимальная стоимость равна 0, т. е. первый столбец dp[i, 0] и первая строка dp[0, c] равны 0.
Текущее состояние [i, c] исходит из верхнего состояния [i – 1, c] и левого верхнего состояния [i – 1, c – wgt[i – 1]], поэтому достаточно пройтись по всей таблице dp двумя вложенными циклами.
На основе вышеизложенного анализа реализуем методы полного перебора,
мемоизации поиска и динамического программирования.
1. Первый метод: полный перебор
Код поиска включает следующие элементы.
Рекурсивные параметры: состояние [i, c].
Возвращаемое значение: решение подзадачи dp[i, c].
424
Динамическое программирование
Условие завершения: номер предмета выходит за пределы i = 0 или
оставшаяся вместимость рюкзака равна 0, рекурсия завершается и возвращается стоимость 0.
Обрезка: если текущая масса предмета превышает оставшуюся вместимость рюкзака, можно выбрать только не класть предмет в рюкзак.
# === File: knapsack.py ===
def knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int:
""" Рюкзак 0-1: полный перебор."""
# Если все предметы выбраны или рюкзак не имеет оставшейся вместимости,
# возвращается стоимость 0.
if i == 0 or c == 0:
return 0
# Если вес превышает вместимость рюкзака, можно выбрать только не класть
# в рюкзак.
if wgt[i - 1] > c:
return knapsack_dfs(wgt, val, i - 1, c)
# Вычисление максимальной стоимости без предмета i и с ним.
no = knapsack_dfs(wgt, val, i - 1, c)
yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1]
# Возвращение большей из двух стоимостей.
return max(no, yes)
Поскольку каждый предмет создает две ветви поиска – не выбирать и выбирать, временная сложность составляет O(2n), как показано на рис. 14.18.
При наблюдении за деревом рекурсии легко заметить наличие перекрывающихся подзадач, таких как dp[1, 10]. А когда количество предметов и вместимость рюкзака велики, особенно если есть много предметов с одинаковым весом, количество перекрывающихся подзадач значительно увеличивается.
Номер предмета
Вместимость рюкзака
Нет
Да
Положить предмет 3?
Обрезка: Масса предмета >
Вместимость рюкзака
Да
Нет
Нет
Да
Номер предмета
Нет
Да Положить предмет 2?
Нет
Да
Нет
Да
Положить предмет 1?
Вместимость рюкзака
Масса предмета
Стоимость предмета
Рис. 14.18. Дерево рекурсии полного перебора для задачи о рюкзаке 0-1
14.4. Задача о рюкзаке 0-1 425
2. Второй метод: мемоизация
Чтобы вычислять перекрывающиеся подзадачи только один раз, используем
список запоминания mem для записи решений подзадач, в котором mem[i][c]
соответствует dp[i, c].
После введения мемоизации временная сложность будет зависеть от количества подзадач, т. е. O(n × cap). Ниже приведен код реализации.
# === File: knapsack.py ===
def knapsack_dfs_mem(
wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int
) -> int:
""" Рюкзак 0-1: мемоизация"""
# Если все предметы выбраны или в рюкзаке нет оставшейся вместимости,
# возвращается значение 0.
if i == 0 or c == 0:
return 0
# Если запись уже существует, возврат напрямую.
if mem[i][c] != -1:
return mem[i][c]
# Если превышает вместимость рюкзака, выбирается не класть в рюкзак.
if wgt[i - 1] > c:
return knapsack_dfs_mem(wgt, val, mem, i - 1, c)
# Вычисление максимальной стоимости без и с включением предмета i.
no = knapsack_dfs_mem(wgt, val, mem, i - 1, c)
yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]
# Запись и возврат наибольшей стоимости из двух вариантов.
mem[i][c] = max(no, yes)
return mem[i][c]
На рис. 14.19 изображены обрезанные ветви поиска в процессе мемоизации.
Номер предмета
Вместимость рюкзака
Нет
Да
Положить предмет 3?
Обрезка:
Запись найдена
Да
Нет
Нет
Да
Номер предмета
Нет
Обрезка: Масса предмета >
Вместимость рюкзака
Да Положить предмет 2?
Нет
Да
Нет
Да
Положить предмет 1?
Вместимость рюкзака
Масса предмета
Стоимость предмета
Рис. 14.19. Рекурсивное дерево мемоизации для задачи о рюкзаке 0-1
426
Динамическое программирование
3. Третий метод: динамическое программирование
Динамическое программирование представляет собой процесс заполнения
таблицы dp в процессе перехода между состояниями, как показано в коде
ниже.
# === File: knapsack.py ===
def knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
""" Рюкзак 0-1: динамическое программирование."""
n = len(wgt)
# Инициализация таблицы dp.
dp = [[0] * (cap + 1) for _ in range(n + 1)]
# Переход между состояниями.
for i in range(1, n + 1):
for c in range(1, cap + 1):
if wgt[i - 1] > c:
# Если превышается вместимость рюкзака, то предмет i не выбирается.
dp[i][c] = dp[i - 1][c]
else:
# Наибольшее значение из двух вариантов: не выбирать
# и выбирать предмет i.
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1])
return dp[n][cap]
Временная и пространственная сложность определяются размером массива
dp, т. е. O(n × cap), как показано на рис. 14.20.
Шаг 1
Масса
wgt
Стоимость
val
Количество предметов n = 3
Вместимость рюкзака cap = 4
Инициализация таблицы dp размером (n + 1) × (cap + 1)
Рис. 14.20. Динамическое программирование для задачи о рюкзаке 0-1. Шаг 1
6 Переход состояния
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
14.4. Задача о рюкзаке 0-1 427
Шаг 2
Масса
wgt
Стоимость
val
Переход состояния:
Шаг 3
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Шаг 4
Количество
предметов n = 3 Вместимость рюкзака cap = 4
Масса Стоимость
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Рис. 14.20. Продолжение. Шаги 2–4
Количество предметов n = 3
Вместимость рюкзака cap = 4
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
428
Шаг 5
Динамическое программирование
Масса
wgt
Стоимость
val
Переход состояния:
Шаг 6
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Шаг 7
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Рис. 14.20. Продолжение. Шаги 5–7
Количество предметов n = 3
Вместимость рюкзака cap = 4
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
14.4. Задача о рюкзаке 0-1 429
Шаг 8
Масса
wgt
Стоимость
val
Переход состояния:
Шаг 9
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Шаг 10
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Рис. 14.20. Продолжение. Шаги 8–10
Количество предметов n = 3
Вместимость рюкзака cap = 4
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
430
Шаг 11
Динамическое программирование
Масса
wgt
Стоимость
val
Переход состояния:
Шаг 12
Масса Стоимость
Количество
предметов n = 3 Вместимость рюкзака cap = 4
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Шаг 13
Количество
предметов n = 3 Вместимость рюкзака cap = 4
Масса Стоимость
val
wgt
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
Переход состояния:
Рис. 14.20. Продолжение. Шаги 11–13
Количество предметов n = 3
Вместимость рюкзака cap = 4
Инициализация таблицы dp размером (n + 1) × (cap + 1)
6
7 После помещения всех предметов в рюкзак возврат максимальной
стоимости 20
14.4. Задача о рюкзаке 0-1 431
Шаг 14
Масса
wgt
Стоимость
val
После помещения всех предметов в рюкзак возврат
максимальной стоимости 20
Рис. 14.20. Окончание. Шаг 14
4. Оптимизация пространства
Поскольку каждое состояние зависит только от состояния предыдущей строки,
можно использовать два массива для продвижения и снизить пространственную сложность с O(n2) до O(n).
А можно ли реализовать оптимизацию пространства, используя только один
массив? Заметим, что каждое состояние переходит из верхней или левой верхней ячейки. Если используется только один массив, то при начале обхода строки i массив все еще хранит состояние строки i – 1.
Если обход выполняется в прямом порядке, то при достижении dp[i, j]
значения из левой верхней части dp[i – 1, 1] ~ dp[i – 1, j – 1] могут быть уже
перезаписаны, что делает невозможным получение правильного результата перехода состояния.
Если обход выполняется в обратном порядке, то проблема перезаписи
не возникает, и переход состояния можно выполнить корректно.
На рис. 14.21 демонстрируется процесс перехода от строки i = 1 к строке i = 2
с использованием одного массива. Проанализируйте различия при прямом
и обратном обходах.
Шаг 1
Масса
wgt
Стоимость
val
Используется один
одномерный массив dp
Перед обходом i = 2 в списке dp хранятся все решения для i = 1
Рис. 14.21. Динамическое программирование с оптимизацией пространства для задачи
о рюкзаке 0-1. Шаг 1
5 Обратный обход строки i = 2, выполнение
перехода состояния
432
Шаг 2
Динамическое программирование
Масса
wgt
Стоимость
val
Используется один
одномерный массив dp
Обратный обход строки i = 2, выполнение перехода состояния
Шаг 3
Стоимость
Масса После
завершения обхода в массиве dp содержатся
val
wgt
все решения для i = 2
Используется один
одномерный массив dp
Обратный обход строки i = 2, выполнение перехода состояния
Шаг 4
Стоимость
Масса После
завершения обхода в массиве dp содержатся
val
wgt
все решения для i = 2
Используется один
одномерный массив dp
Обратный обход строки i = 2, выполнение перехода состояния
Рис. 14.21. Продолжение. Шаги 2–4
После завершения обхода в массиве dp содержатся
все решения для i = 2
14.4. Задача о рюкзаке 0-1 433
Шаг 5
Масса
wgt
Стоимость
val
Используется один
одномерный массив dp
Обратный обход строки i = 2, выполнение перехода состояния
Шаг 6
Стоимость
Масса После
завершения обхода в массиве dp содержатся
val
wgt
все решения для i = 2
Используется один
одномерный массив dp
После завершения обхода в массиве dp содержатся
все решения для i = 2
Рис. 14.21. Окончание. Шаги 5–6
В коде реализации необходимо просто удалить первую размерность i из
массива dp и изменить внутренний цикл на обратный обход.
# === File: knapsack.py ===
def knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int:
""" Рюкзак 0-1: динамическое программирование с оптимизацией пространства."""
n = len(wgt)
# Инициализация таблицы dp.
dp = [0] * (cap + 1)
# Переход между состояниями.
for i in range(1, n + 1):
# Обратный обход.
for c in range(cap, 0, -1):
if wgt[i - 1] > c:
# Если превышается вместимость рюкзака, то предмет i не выбирается.
dp[c] = dp[c]
434
Динамическое программирование
else:
# Наибольшее значение из двух вариантов: не выбирать и выбирать
# предмет i.
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1])
return dp[cap]
14.5. Задача о полном рюкзаке
В этом разделе мы сначала решим еще одну распространенную задачу о рюкзаке – задачу о полном рюкзаке. А затем рассмотрим ее частный случай – задачу о размене монет.
14.5.1. Задача о полном рюкзаке
Задача
Даны n предметов, масса i-го предмета равна wgt[i – 1], стоимость равна
val[i – 1], и рюкзак вместимостью cap. Каждый предмет можно выбирать
многократно. Определить, какова максимальная стоимость предметов, которые можно поместить в рюкзак при ограниченной вместимости. Пример
показан на рис. 14.22.
Номер Масса Стоимость
Вместимость рюкзака
Максимальная стоимость: 290
Оптимальная схема: положить одно
и два
в рюкзак, которые займут 50 ед. вместимости
рюкзака
Рис. 14.22. Пример данных для задачи о полном рюкзаке
1. Динамическое программирование
Задача о полном рюкзаке очень похожа на задачу о рюкзаке 0-1. Различие
лишь в том, что количество выборов предметов не ограничено.
В задаче о рюкзаке 0-1 каждый предмет существует в единственном экземпляре, поэтому после помещения предмета i в рюкзак можно выбирать только из первых i – 1 предметов.
14.5. Задача о полном рюкзаке 435
В задаче о полном рюкзаке количество предметов не ограничено, поэтому после помещения предмета i в рюкзак можно продолжать выбирать из первых i предметов.
В условиях задачи о полном рюкзаке изменение состояния [i, c] делится на
два случая.
Не помещать предмет i: аналогично задаче о рюкзаке 0-1, переход
к [i – 1, c].
Помещать предмет i: в отличие от задачи о рюкзаке 0-1, переход
к [i, c – wgt[i – 1]].
Таким образом, уравнение перехода состояния меняется на следующее:
dp[i, c] = max(dp[i – 1, c], dp[i, c – wgt[i – 1]] + val[i – 1]).
2. Код реализации
По сравнению с кодом предыдущей задачи есть одно изменение в переходе
состояния с i – 1 на i, остальной код полностью совпадает.
# === File: unbounded_knapsack.py ===
def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
"""Полный рюкзак: динамическое программирование."""
n = len(wgt)
# Инициализация таблицы dp.
dp = [[0] * (cap + 1) for _ in range(n + 1)]
# Переход между состояниями.
for i in range(1, n + 1):
for c in range(1, cap + 1):
if wgt[i - 1] > c:
# Если превышается вместимость рюкзака, то предмет i не выбирается.
dp[i][c] = dp[i - 1][c]
else:
# Наибольшее значение из двух вариантов: не выбирать и выбирать
# предмет i.
dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1])
return dp[n][cap]
3. Оптимизация пространства
Поскольку текущее состояние исходит из состояний слева и сверху, после оптимизации пространства следует выполнять прямой обход каждой строки в таблице dp.
Этот порядок обхода противоположен порядку в задаче о рюкзаке 0-1. Изучите рис. 14.23 для понимания различий между ними.
436
Шаг 1
Динамическое программирование
Масса
wgt
Стоимость
val
Используется один
одномерный массив dp
Перед обходом i = 2 в списке dp хранятся все решения для i = 1
Прямой обход строки i = 2, выполнение перехода
состояния
Шаг 2
Стоимость
Масса После
завершения обхода в массиве dp содержатся
val
wgt все решения
для i = 2
Используется один
одномерный массив dp
Прямой обход строки i = 2, выполнение перехода состояния
Шаг 3
После завершения обхода в массиве dp содержатся
Стоимость
Масса все
решения для i = 2
val
wgt
Используется один
одномерный массив dp
Прямой обход строки i = 2, выполнение перехода состояния
Рис. 14.23. Динамическое программирование для задачи о полном рюкзаке после оптимизации пространства. Шаги 1–3
После завершения обхода в массиве dp содержатся
все решения для i = 2
14.5. Задача о полном рюкзаке 437
Шаг 4
Масса
wgt
Стоимость
val
Используется один
одномерный массив dp
Прямой обход строки i = 2, выполнение перехода состояния
Шаг 5
После завершения обхода в массиве dp содержатся
Стоимость
Масса все
решения для i = 2
val
wgt
Используется один
одномерный массив dp
Прямой обход строки i = 2, выполнение перехода состояния
Шаг 6
После завершения обхода в массиве dp содержатся
решения для i = 2
Стоимость
Масса все
val
wgt
Используется один
одномерный массив dp
После завершения обхода в массиве dp содержатся
все решения для i = 2
Рис. 14.23. Окончание. Шаги 4–6
438
Динамическое программирование
Код реализации достаточно прост, необходимо лишь удалить первую размерность массива dp.
# === File: unbounded_knapsack.py ===
def unbounded_knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) ->
int:
""" Полный рюкзак: динамическое программирование с оптимизацией пространства."""
n = len(wgt)
# Инициализация таблицы dp.
dp = [0] * (cap + 1)
# Переход состояния.
for i in range(1, n + 1):
# Прямой обход.
for c in range(1, cap + 1):
if wgt[i - 1] > c:
# Если превышается вместимость рюкзака, то предмет i не выбирается.
dp[c] = dp[c]
else:
# Наибольшее значение из двух вариантов: не выбирать и выбирать
# предмет i.
dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1])
return dp[cap]
14.5.2. Задача о размене монет
Задача о рюкзаке является представителем большого класса задач динамического программирования, имеющего множество вариаций, таких как задача
о размене монет.
Задача
Даны n видов монет, номинал i-й монеты равен coins[i – 1], и целевая сумма
amt. Каждую монету можно выбирать многократно. Необходимо найти
минимальное количество монет, чтобы составить целевую сумму. Если это
невозможно, вернуть –1. Пример показан на рис. 14.24.
Номер Номинал
Целевая сумма
Минимальное количество монет: 3
Оптимальная комбинация:
Рис. 14.24. Пример данных для задачи о размене монет
14.5. Задача о полном рюкзаке 439
1. Динамическое программирование
Задачу о размене монет можно рассматривать как частный случай задачи о полном рюкзаке со следующими сходствами и различиями.
Обе задачи можно преобразовать друг в друга: предмет соответствует
монете, масса предмета соответствует номиналу монеты, вместимость
рюкзака соответствует целевой сумме.
Цели оптимизации противоположны: задача о полном рюкзаке стремится максимизировать стоимость предметов, задача о размене монет –
минимизировать количество монет.
Задача о полном рюкзаке ищет решение, не превышающее вместимость
рюкзака, задача о размене монет – решение, точно соответствующее целевой сумме.
Шаг 1: определение каждого этапа принятия решения,
определение состояния для получения таблицы dp
Подзадача состояния [i, a] заключается в нахождении минимального количества монет для составления суммы a из первых i видов монет, обозначается как dp[i, a].
Размер двумерной таблицы dp равен (n + 1) × (amt + 1).
Шаг 2: нахождение оптимальной подструктуры
и выведение уравнения перехода состояния
В этой задаче уравнение перехода состояния отличается от задачи о полном
рюкзаке в двух моментах.
В этой задаче требуется найти минимальное значение, поэтому оператор max() заменяется на min().
Оптимизация направлена на количество монет, а не на стоимость товаров, поэтому при выборе монеты выполняется операция +1.
dp[i, a] = min(dp[i – 1, a], dp[i, a – coins[i – 1]] + 1).
Шаг 3: определение граничных условий и порядка перехода состояния
Когда целевая сумма равна 0, минимальное количество монет для ее составления равно 0, т. е. все dp[i, 0] в первом столбце равны 0.
При отсутствии монет невозможно составить любую целевую сумму > 0,
это является недопустимым решением. Чтобы функция min() в уравнении перехода состояния могла распознавать и фильтровать недопустимые решения,
предлагается использовать значение +∞ для их обозначения, т. е. все dp[0, a]
в первой строке равны +∞.
2. Код реализации
В большинстве языков программирования нет представления для значения +∞,
поэтому часто используется максимальное значение типа int. Однако это может
привести к переполнению при выполнении операции +1 в уравнении перехода.
Поэтому для обозначения недопустимого решения будем использовать число amt + 1, поскольку максимальное количество монет для составления amt
равно amt. Перед возвратом проверяется, равно ли dp[n, amt] значению amt + 1.
440
Динамическое программирование
Если равно, возвращается –1, что означает невозможность составления целевой суммы. Ниже приведен код реализации.
# === File: coin_change.py ===
def coin_change_dp(coins: list[int], amt: int) -> int:
""" Размен монет: динамическое программирование."""
n = len(coins)
MAX = amt + 1
# Инициализация таблицы dp.
dp = [[0] * (amt + 1) for _ in range(n + 1)]
# Переход состояния: первая строка и первый столбец.
for a in range(1, amt + 1):
dp[0][a] = MAX
# Переход состояния: остальные строки и столбцы.
for i in range(1, n + 1):
for a in range(1, amt + 1):
if coins[i - 1] > a:
# Если превышается целевая сумма, то монета i не выбирается.
dp[i][a] = dp[i - 1][a]
else:
# Наименьшее значение между не выбирать и выбирать монету i.
dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1)
return dp[n][amt] if dp[n][amt] != MAX else -1
На рис. 14.25 демонстрируется процесс динамического программирования для задачи о размене монет, который очень похож на задачу о полном
рюкзаке.
Номер Номинал
Виды монет
Шаг 1
n=3
Целевая сумма amt = 4
Инициализация массива dp размером (n + 1) × (amt + 1)
Рис. 14.25. Динамическое программирование задачи о размене монет. Шаг 1
Инициализация первой строки значением MAX =
amt + 1, первого столбца – значением 0
7 Переход состояния
8 Возврат минимального количества монет для набора целевой суммы: 2
14.5. Задача о полном рюкзаке 441
Номер Номинал
Виды монет
Шаг 2
n=3
Целевая сумма amt = 4
Инициализация первой строки значением MAX = amt + 1,
первого столбца – значением 0
Номер Номинал
7 Переход состояния
8 Возврат минимального количества монет для набора целевой суммы: 2
Переход состояния:
Шаг 3
Номер Номинал
Переход состояния:
Шаг 4
Рис. 14.25. Продолжение. Шаги 2–4
442
Динамическое программирование
Номер Номинал
Переход состояния:
Шаг 5
Номер Номинал
Переход состояния:
Шаг 6
Номер Номинал
Переход состояния:
Шаг 7
Рис. 14.25. Продолжение. Шаги 5–7
14.5. Задача о полном рюкзаке 443
Номер Номинал
Переход состояния:
Шаг 8
Номер Номинал
Переход состояния:
Шаг 9
Номер Номинал
Переход состояния:
Шаг 10
Рис. 14.25. Продолжение. Шаги 8–10
444
Динамическое программирование
Номер Номинал
Переход состояния:
Шаг 11
Номер Номинал
Переход состояния:
Шаг 12
Номер Номинал
Переход состояния:
Шаг 13
Рис. 14.25. Продолжение. Шаги 11–13
14.5. Задача о полном рюкзаке 445
Номер Номинал
Переход состояния:
Шаг 14
Номер Номинал
Шаг 15
Возврат минимального количества монет
для набора целевой суммы: 2
Рис. 14.25. Окончание. Шаги 14–15
3. Оптимизация пространства
Оптимизация пространства в задаче о размене монет осуществляется аналогично задаче о полном рюкзаке.
# === File: coin_change.py ===
def coin_change_dp_comp(coins: list[int], amt: int) -> int:
""" Размен монет: динамическое программирование с оптимизацией пространства."""
n = len(coins)
MAX = amt + 1
# Инициализация таблицы dp.
dp = [MAX] * (amt + 1)
dp[0] = 0
# Переход состояния.
for i in range(1, n + 1):
# Прямой обход.
446
Динамическое программирование
for a in range(1, amt + 1):
if coins[i - 1] > a:
# Если превышается целевая сумма, то не выбирается монета i.
dp[a] = dp[a]
else:
# Наименьшее значение между не выбирать и выбирать монету i.
dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1)
return dp[amt] if dp[amt] != MAX else -1
14.5.3 Задача о размене монет II
Задача
Даны n видов монет, номинал i-й монеты равен coins[i – 1]. Цель – достичь
суммы amt, при этом каждую монету можно использовать неограниченное
количество раз. Необходимо определить количество комбинаций монет,
составляющих целевую сумму. Пример показан на рис. 14.26.
Номер Номинал
Целевая сумма
Количество комбинаций: 4
Схемы комбинаций:
Рис. 14.26. Пример данных для задачи о размене монет II
1. Динамическое программирование
В отличие от предыдущей задачи здесь целью является определение количества комбинаций, поэтому подзадача формулируется следующим образом:
количество комбинаций, которыми можно составить сумму a, используя
первые i видов монет. Таблица dp по-прежнему представляет собой двумерную матрицу размером (n + 1) × (amt + 1).
Количество комбинаций для текущего состояния равно сумме количества
комбинаций без выбора текущей монеты и с выбором текущей монеты. Уравнение перехода состояния имеет вид:
14.5. Задача о полном рюкзаке 447
dp[i, a] = dp[i – 1, a] + dp[i, a – coins[i – 1]].
Если целевая сумма равна 0, то для достижения этой суммы не требуется выбирать монеты, поэтому все dp[i, 0] в первом столбце нужно инициализировать
значением 1. Если монет нет, невозможно составить любую сумму больше 0,
поэтому все dp[0, a] в первой строке равны 0.
2. Код реализации
# === File: coin_change_ii.py ===
def coin_change_ii_dp(coins: list[int], amt: int) -> int:
""" Задача о размене монет II: динамическое программирование."""
n = len(coins)
# Инициализация таблицы dp.
dp = [[0] * (amt + 1) for _ in range(n + 1)]
# Инициализация первого столбца.
for i in range(n + 1):
dp[i][0] = 1
# Переход состояния.
for i in range(1, n + 1):
for a in range(1, amt + 1):
if coins[i - 1] > a:
# Если превышается целевая сумма, монета i не выбирается.
dp[i][a] = dp[i - 1][a]
else:
# Сумма двух вариантов: без выбора и с выбором монеты i.
dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]]
return dp[n][amt]
3. Оптимизация пространства
Метод оптимизации пространства аналогичен предыдущей задаче, достаточно удалить измерение монет.
# === File: coin_change_ii.py ===
def coin_change_ii_dp_comp(coins: list[int], amt: int) -> int:
""" Задача о размене монет II: динамическое программирование с оптимизацией
пространства."""
n = len(coins)
# Инициализация таблицы dp.
dp = [0] * (amt + 1)
dp[0] = 1
# Переход состояния.
for i in range(1, n + 1):
# Прямой обход.
for a in range(1, amt + 1):
if coins[i - 1] > a:
448
Динамическое программирование
# Если превышает целевую сумму, монета i не выбирается.
dp[a] = dp[a]
else:
# Сумма двух вариантов: без выбора и с выбором монеты i.
dp[a] = dp[a] + dp[a - coins[i - 1]]
return dp[amt]
14.6. Задача расстояния редактирования
Расстояние редактирования, также известное как расстояние Левенштейна, –
это минимальное количество изменений, необходимых для преобразования одной строки в другую. Обычно используется для измерения сходства двух последовательностей в информационном поиске и обработке естественного языка.
Задача
На вход подаются две строки s и t, необходимо вернуть минимальное количество шагов редактирования для преобразования s в t.
В строке можно выполнять три вида операций редактирования: вставку символа, удаление символа, замену символа на любой другой.
Для преобразования kitten в sitting требуется три шага редактирования,
включая две операции замены и одну операцию добавления, как показано на
рис. 14.27. Для преобразования hello в algo требуется три шага, включая две
операции замены и одну операцию удаления.
Замена
Замена Вставка
Удаление
Замена Замена
Рис. 14.27. Пример данных для задачи расстояния редактирования
Задачу расстояния редактирования можно естественным образом
объяснить с помощью модели дерева решений. Строки соответствуют узлам дерева, а один шаг редактирования (одна операция редактирования) соответствует ребру дерева.
При отсутствии ограничений на операции каждый узел может порождать
множество ребер, каждое из которых соответствует одной операции, как показано на рис. 14.28. Это означает, что существует множество возможных путей
для преобразования hello в algo.
14.6. Задача расстояния редактирования 449
С точки зрения дерева решений цель задачи – найти кратчайший путь между узлом hello и узлом algo.
Заменить на g
Заменить e на a
Удалить h
Рис. 14.28. Представление задачи расстояния редактирования на основе модели дерева решений
1. Динамическое программирование
Шаг 1: обдумывание каждого этапа решения, определение
состояния для получения таблицы dp
Каждый шаг решения – это выполнение одной операции редактирования над
строкой s.
Мы стремимся к тому, чтобы в процессе выполнения операций редактирования размер задачи постепенно уменьшался, что позволяет построить подзадачи. Пусть длины строк s и t равны n и m соответственно. Рассмотрим сначала
последние символы этих двух строк s[n – 1] и t[m – 1].
Если s[n – 1] и t[m – 1] одинаковы, их можно пропустить и сразу рассмотреть s[n – 2] и t[m – 2].
Если s[n – 1] и t[m – 1] различны, необходимо выполнить одну операцию
редактирования над s (вставка, удаление, замена), чтобы последние символы двух строк стали одинаковыми. После этого их можно будет пропустить и рассмотреть задачу меньшего размера.
Таким образом, каждый шаг решения (операция редактирования) в строке
s приводит к изменению оставшихся символов, которые необходимо сопоставить в s и t. Поэтому состояние определяется как текущие рассматриваемые i-й
и j-й символы в s и t, обозначим его как [i, j].
Подзадача, соответствующая состоянию [i, j]: минимальное количество
шагов редактирования, необходимых для преобразования первых i символов s в первые j символов t.
Таким образом, получаем двумерную таблицу dp размером (i + 1) × (j + 1).
450
Динамическое программирование
Шаг 2: нахождение оптимальной подструктуры
и вывод уравнения перехода состояния
Рассмотрим подзадачу dp[i, j], в которой последние символы двух соответствующих строк – это s[i – 1] и t[j – 1]. В зависимости от различных операций редактирования можно выделить три случая, представленные на рис. 14.29.
1. Добавление t[j – 1] после s[i – 1], тогда оставшаяся подзадача – dp[i, j – 1].
2. Удаление s[i – 1], тогда оставшаяся подзадача – dp[i – 1, j].
3. Замена s[i – 1] на t[j – 1], тогда оставшаяся подзадача – dp[i – 1, j – 1].
Добавить e в конец
Удалить k
Заменить k на e
Рис. 14.29. Переходы состояний для расстояния редактирования
На основании вышеизложенного анализа можно получить оптимальную
подструктуру: минимальное количество шагов редактирования для dp[i, j]
равно минимальному количеству шагов редактирования среди dp[i, j – 1],
dp[i – 1, j], dp[i – 1, j – 1] плюс 1 шаг за текущее редактирование. Соответствующее уравнение перехода состояния выглядит следующим образом:
dp[i, j] = min(dp[i, j – 1], dp[i – 1, j], dp[i – 1, j – 1]) + 1.
Обратите внимание, что если s[i – 1] и t[j – 1] совпадают, то редактирование текущего символа не требуется, и уравнение перехода состояния в этом
случае будет следующим:
dp[i, j] = dp[i – 1, j – 1].
Шаг 3: определение граничных условий и порядка перехода состояний
Когда обе строки пусты, количество шагов редактирования равно 0, т. е. dp[0, 0] = 0.
Если s пустая, а t непустая, минимальное количество шагов редактирования равно
длине t, т. е. первая строка dp[0, j] = j. Если s непустая, а t пустая, минимальное
количество шагов редактирования равно длине s, т. е. первый столбец dp[i, 0] = i.
14.6. Задача расстояния редактирования 451
Анализируя уравнение перехода состояния, решение dp[i, j] зависит от решения слева, сверху и слева сверху. Поэтому можно обойти всю таблицу dp
в прямом порядке с помощью двух вложенных циклов.
2. Код реализации
# === File: edit_distance.py ===
def edit_distance_dp(s: str, t: str) -> int:
""" Расстояние редактирования: динамическое программирование."""
n, m = len(s), len(t)
dp = [[0] * (m + 1) for _ in range(n + 1)]
# Переход состояния: первая строка и первый столбец.
for i in range(1, n + 1):
dp[i][0] = i
for j in range(1, m + 1):
dp[0][j] = j
# Переход состояния: остальные строки и столбцы.
for i in range(1, n + 1):
for j in range(1, m + 1):
if s[i - 1] == t[j - 1]:
# Если два символа равны, то они пропускаются.
dp[i][j] = dp[i - 1][j - 1]
else:
# Минимальное количество шагов редактирования =
# минимальное количество шагов для вставки, удаления, замены + 1.
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1
return dp[n][m]
Как видно из рис. 14.30, процесс перехода состояния для задачи расстояния
редактирования очень похож на задачу о рюкзаке, и его можно рассматривать
как заполнение двумерной сетки.
Шаг 1
Инициализация массива dp размером (n + 1) × (m + 1)
1 Инициализация
массива dp размером
(n + 1) × редактирования. Шаг 1
Рис. 14.30. Динамическое
программирование
для расстояния
(m + 1)
2 Инициализация первой строки dp[0, j] = j и
первого столбца dp[i, 0] = i
3 Переход состояния
452
Шаг 2
Динамическое программирование
Инициализация первой строки dp[0, j] = j и первого столбца dp[i, 0] = i
3 Переход состояния
4 Возврат минимального количества шагов
редактирования: 3
Инициализация массива dp размером (n + 1) × (m + 1)
Переход состояния:
Шаг 3
Возврат минимального количества шагов
редактирования: 3
Инициализация массива dp размером (n + 1) × (m + 1)
Инициализация первой строки dp[0, j] = j и первого столбца dp[i, 0] = i
Переход состояния:
Шаг 4
Возврат
минимального
количества шагов
Рис.
14.30. Продолжение.
Шаги 2–4
редактирования: 3
Инициализация массива dp размером (n + 1) × (m + 1)
14.6. Задача расстояния редактирования 453
Переход состояния:
Шаг 5
Переход состояния:
Шаг 6
Переход состояния:
Шаг 7
Рис. 14.30. Продолжение. Шаги 5–7
454
Динамическое программирование
Переход состояния:
Шаг 8
Переход состояния:
Шаг 9
Переход состояния:
Шаг 10
Рис. 14.30. Продолжение. Шаги 8–10
14.6. Задача расстояния редактирования 455
Переход состояния:
Шаг 11
Переход состояния:
Шаг 12
Переход состояния:
Шаг 13
Рис. 14.30. Продолжение. Шаги 11–13
456
Динамическое программирование
Переход состояния:
Шаг 14
Переход состояния:
Шаг 15
Возврат минимального количества шагов редактирования: 3
Рис. 14.30. Окончание. Шаги 14–15
3. Оптимизация пространства
Поскольку dp[i, j] зависит от dp[i – 1, j], dp[i, j – 1], dp[i – 1, j – 1], прямой обход теряет dp[i – 1, j – 1], а обратный обход не позволяет заранее построить dp[i, j – 1].
Оба порядка обхода неприемлемы.
Для оптимизации можно использовать переменную leftup, в которой будет
временно хранится решение dp[i – 1, j – 1], что позволит учитывать только решения слева и сверху. В этом случае ситуация аналогична задаче о полном рюкзаке, и можно использовать прямой обход. Код реализации представлен ниже.
# === File: edit_distance.py ===
def edit_distance_dp_comp(s: str, t: str) -> int:
""" Расстояние редактирования: динамическое программирование с оптимизацией
пространства."""
14.7. Резюме 457
n, m = len(s), len(t)
dp = [0] * (m + 1)
# Переход состояния: первая строка.
for j in range(1, m + 1):
dp[j] = j
# Переход состояния: остальные строки.
for i in range(1, n + 1):
# Переход состояния: первый столбец.
leftup = dp[0] # Временное хранение dp[i-1, j-1].
dp[0] += 1
# Переход состояния: остальные столбцы.
for j in range(1, m + 1):
temp = dp[j]
if s[i - 1] == t[j - 1]:
# Если два символа равны, то они пропускаются.
dp[j] = leftup
else:
# Минимальное количество шагов редактирования = минимальное
# количество шагов для вставки, удаления, замены + 1.
dp[j] = min(dp[j - 1], dp[j], leftup) + 1
leftup = temp # Обновление для следующего шага dp[i-1, j-1].
return dp[m]
14.7. Резюме
Динамическое программирование разбивает задачу на подзадачи, сохраняет их решения и избегает повторных вычислений, что повышает
эффективность.
Все задачи динамического программирования можно решить с помощью перебора (поиска в глубину), но в дереве рекурсии много повторяющихся подзадач, что делает его крайне неэффективным. Использование
мемоизации позволяет сохранить решения всех вычисленных подзадач,
гарантируя, что каждая из них будет решена только один раз.
Мемоизация – это рекурсивный подход сверху вниз, тогда как динамическое программирование – это итеративный подход снизу вверх, похожий на заполнение таблицы. Поскольку текущее состояние зависит
только от некоторых локальных состояний, можно устранить одно измерение таблицы dp и уменьшить пространственную сложность.
Разбиение задачи на подзадачи – это общий алгоритмический подход,
который имеет различную реализацию в методах «разделяй и властвуй»,
динамическом программировании и поиске с возвратом.
Задачи динамического программирования обладают тремя основными свойствами: повторяющиеся подзадачи, оптимальная подструктура
и отсутствие последствий.
Если оптимальное решение исходной задачи можно построить из
оптимальных решений подзадач, то оно обладает оптимальной подструктурой.
458
Динамическое программирование
Отсутствие последствий означает, что будущее развитие состояния зависит только от этого состояния и не зависит от всех предыдущих состояний. Многие задачи комбинаторной оптимизации не обладают этим
свойством, и для их быстрого решения нельзя использовать динамическое программирование.
Задача о рюкзаке
Задача о рюкзаке – одна из самых типичных задач динамического программирования, имеющая такие варианты, как рюкзак 0‑1, полный рюкзак и многократный рюкзак.
Состояние задачи о рюкзаке 0-1 определяется как максимальная стоимость первых i предметов в рюкзаке вместимостью c. На основе двух
решений – не класть в рюкзак и класть в рюкзак – можно получить оптимальную подструктуру и построить уравнение перехода состояния.
В оптимизации пространства, поскольку каждое состояние зависит от
состояний «прямо сверху» и «слева сверху», необходимо обходить список
в обратном порядке, чтобы избежать перезаписи состояния слева сверху.
В задаче о полном рюкзаке количество каждого вида предметов не ограничено, поэтому переход состояния при выборе предметов отличается от задачи о рюкзаке 0-1. Поскольку состояние зависит от состояний
«прямо сверху» и «прямо слева», в оптимизации пространства следует
делать обход в прямом порядке.
Задача о размене монет является вариантом задачи о полном рюкзаке.
Она изменяет поиск максимальной стоимости на поиск минимального
количества монет. Поэтому в уравнении перехода состояния max() следует заменить на min(). От условия не превышать вместимость рюкзака
переходят к условию точно достичь целевой суммы. Для обозначения
недопустимого решения, когда невозможно достичь целевой суммы, используется значение amt + 1.
В задаче о размене монет II вместо поиска минимального количества
монет ищется количество комбинаций монет. Уравнение перехода состояния соответственно изменяется с min() на оператор суммы.
Задача расстоянии редактирования
Расстояние редактирования (расстояние Левенштейна) используется
для измерения сходства между двумя строками и определяется как минимальное количество шагов редактирования, необходимых для преобразования одной строки в другую. Операции редактирования включают
добавление, удаление и замену.
Состояние задачи о расстоянии редактирования определяется как
минимальное количество шагов редактирования, необходимых для
изменения первых i символов строки s в первые j символов строки t.
Когда s[i] ≠ t[j], существуют три решения: добавление, удаление и замена, каждое из которых имеет соответствующую оставшуюся подзадачу. На основе этого можно выявить оптимальную подструктуру и построить уравнение перехода состояния. Когда s[i] = t[j], редактирование
текущего символа не требуется.
14.7. Резюме 459
В задаче о расстоянии редактирования состояние зависит от состояний
«прямо сверху», «прямо слева» и «слева сверху». Поэтому после оптимизации пространства ни прямой, ни обратный обход не позволяют
корректно выполнить переход состояния. Для решения этой проблемы
используется переменная для временного хранения состояния слева
сверху. Это позволяет преобразовать задачу в эквивалентную задаче
о полном рюкзаке, и после оптимизации пространства можно выполнять прямой обход.
Глава 15
Жадность
Абстракция
Подсолнухи поворачиваются к солнцу, стремясь к максимальному росту.
А жадные стратегии через последовательность простых выборов постепенно
приводят к оптимальному решению.
15.1. Жадные алгоритмы 461
15.1. Жадные алгоритмы
Жадный алгоритм – это распространенный метод решения задач оптимизации. Его основная идея заключается в том, чтобы на каждом этапе принятия решения выбирать наиболее оптимальный на данный момент вариант,
т. е. с жадностью принимать локально оптимальные решения в надежде получить глобально оптимальное решение. Жадные алгоритмы просты и эффективны, и они находят широкое применение в решении многих практических задач.
Жадные алгоритмы и динамическое программирование часто используются для решения задач оптимизации. Между ними есть некоторые сходства,
например оба метода зависят от свойств оптимальной подструктуры, но их
принципы работы различны.
Динамическое программирование для получения текущего решения
учитывает все предыдущие решения и использует решения предыдущих
подзадач для построения решения текущей подзадачи.
Жадный алгоритм не учитывает предыдущие решения, а просто движется вперед, делая жадные выборы и постепенно сокращая область задачи,
пока она не будет решена.
Чтобы лучше понять принцип работы жадного алгоритма, рассмотрим его
применение к задаче о размене монет. Она уже была рассмотрена в разделе
«Задача о полном рюкзаке», и, вероятно, вы с ней уже знакомы.
Задача
Дано n видов монет, номинал i-й монеты равен coins[i – 1], и целевая сумма amt. Каждую монету можно использовать неограниченное количество
раз. Требуется найти минимальное количество монет, необходимое для
достижения целевой суммы. Если целевую сумму достичь невозможно,
вернуть –1.
Жадная стратегия, применяемая в этой задаче, показана на рис. 15.1. Для
заданной целевой суммы мы жадно выбираем монету, которая не превышает и наиболее близка к этой сумме, и повторяем этот шаг, пока не будет
достигнута целевая сумма.
462
Жадность
Целевая сумма
Жадная стратегия: всегда выбирать монету,
которая не превышает оставшуюся сумму
и наиболее близка к ней
Рис. 15.1. Жадная стратегия для задачи о размене монет
Ниже приведен код реализации.
# === File: coin_change_greedy.py ===
def coin_change_greedy(coins: list[int], amt: int) -> int:
""" Размен монет: жадный алгоритм."""
# Предполагается, что список coins отсортирован.
i = len(coins) - 1
count = 0
# Выполняем цикл жадного выбора, пока не получим целевую сумму.
while amt > 0:
# Найти монету, меньшую и наиболее близкую к оставшейся сумме.
while i > 0 and coins[i] > amt:
i -= 1
# Выбор coins[i].
amt -= coins[i]
count += 1
# Если не найдено решение, вернуть -1.
return count if amt == 0 else -1
Вы можете невольно воскликнуть: «Эврика!» Жадный алгоритм решает задачу размена монет всего за десяток строк кода.
15.1.1. Преимущества и ограничения жадных алгоритмов
Жадные алгоритмы не только просты в реализации, но и обычно очень
эффективны. Если в приведенном выше коде обозначить минимальный номинал монеты как min(coins), то жадный выбор выполняется не более amt /
min(coins) раз. Тогда временная сложность составляет O(amt / min(coins)). Это
на порядок меньше временной сложности решения с использованием динамического программирования O(n × amt).
15.1. Жадные алгоритмы 463
Однако для некоторых комбинаций номиналов монет жадный алгоритм
не сможет найти оптимальное решение. На рис. 15.2 приведены два примера.
Положительный пример coins = [1, 5, 10, 20, 50, 100]: при данной комбинации монет для любого amt жадный алгоритм сможет найти оптимальное решение.
Отрицательный пример coins = [1, 20, 50]: если amt = 60, жадный алгоритм найдет комбинацию 50 + 1 × 10, всего 11 монет. Но динамическое
программирование может найти оптимальное решение 20 + 20 + 20, всего 3 монеты.
Отрицательный пример coins = [1, 49, 50]: если amt = 98, жадный алгоритм
найдет комбинацию 50 + 1 × 48, всего 49 монет. Но динамическое программирование может найти оптимальное решение 49 + 49, всего 2 монеты.
Комбинация монет
Целевая сумма
Оптимальное решение
жадного алгоритма
(локальный оптимум)
Оптимальное решение
динамического
программирования
(глобальный оптимум)
Рис. 15.2. Примеры, когда жадный алгоритм не может найти оптимальное решение
Таким образом, для задачи размена монет жадный алгоритм не гарантирует нахождение глобально оптимального решения и может привести к очень
плохому решению. Для решения этой задачи лучше подходит динамическое
программирование.
В общем случае жадные алгоритмы применимы в следующих двух ситуациях:
1) можно гарантировать нахождение оптимального решения: в этом
случае жадный алгоритм часто является лучшим выбором, так как он
обычно более эффективен, чем методы обратного поиска и динамического программирования;
2) можно найти приближенное оптимальное решение: в этом случае
жадный алгоритм также применим. Для многих сложных задач поиск
глобально оптимального решения очень затруднителен, и возможность
найти субоптимальное решение с высокой эффективностью является
весьма хорошим результатом.
15.1.2. Свойства жадных алгоритмов
Итак, возникает вопрос: какие задачи подходят для решения с помощью жадного алгоритма? Или, иначе говоря, в каких случаях жадный алгоритм может
гарантировать нахождение оптимального решения?
464
Жадность
По сравнению с динамическим программированием условия применения
жадного алгоритма более строгие, и они в основном сосредоточены на двух
свойствах задачи.
1. Свойство жадного выбора: жадный алгоритм может гарантировать
получение оптимального решения только в случае, если локально оптимальный выбор всегда приводит к глобально оптимальному решению.
2. Оптимальная подструктура: оптимальное решение исходной задачи
содержит оптимальное решение подзадачи.
Оптимальная подструктура уже была рассмотрена в главе «Динамическое
программирование», поэтому здесь не будем повторяться. Стоит отметить,
что оптимальная подструктура некоторых задач не всегда очевидна, но их все
же можно решить с помощью жадного алгоритма.
Основное внимание уделяется методам определения свойства жадного выбора. Хотя его описание кажется простым, на практике доказательство этого свойства для многих задач является сложной задачей.
Например, в задаче о размене монет мы можем легко привести контрпример для опровержения свойства жадного выбора. Однако доказательство его
истинности значительно сложнее. На вопрос «При каких условиях можно
использовать жадный алгоритм для решения задачи размена монет?»
обычно мы можем дать лишь интуитивный или примерный ответ, но не можем предоставить строгое математическое доказательство.
Цитата
Существует статья, в которой предложен алгоритм с временной сложностью
O(n3). Он позволяет определить, можно ли комбинацию монет использовать
для нахождения оптимального решения для любой суммы с помощью жадного алгоритма.
Pearson, D. A polynomial-time algorithm for the change-making problem[J]. Operations Research Letters, 2005, 33(3): 231–234.
15.1.3. Этапы решения задач жадным алгоритмом
Процесс решения жадных задач можно разделить на следующие три этапа:
1) анализ задачи: изучение и понимание характеристик задачи, включая
определение состояния, цели оптимизации и ограничения. Этот этап
также присутствует в методах поиска с возвратом и динамического программирования;
2) определение жадной стратегии: определение того, как делать жадный
выбор на каждом шаге. Эта стратегия позволяет уменьшать размер задачи на каждом шаге и в конечном итоге решить всю задачу;
3) доказательство корректности: обычно требуется доказать наличие свойства жадного выбора и оптимальной подструктуры задачи.
Этот этап может потребовать использования математических доказательств, таких как метод математической индукции или доказательство от противного.
15.1. Жадные алгоритмы 465
Определение жадной стратегии является ключевым этапом решения задачи, но его реализация может быть непростой по следующим причинам.
Жадные стратегии для различных задач могут значительно различаться. Для многих задач жадная стратегия очевидна, и ее можно определить с помощью общего размышления и эмпирических проб. Однако
для некоторых сложных задач жадная стратегия может оказаться очень
скрытой, что потребует значительного опыта в решении задач и навыков работы с алгоритмами.
Некоторые жадные стратегии могут быть обманчивыми. Бывает,
жадная стратегия разработана с полной уверенностью в ее правильности,
код написан и отправлен на выполнение. Но оказывается, что некоторые
тестовые примеры не проходят проверку на корректность. Это происходит потому, что разработанная жадная стратегия является лишь частично
правильной, как в случае с задачей о размене монет, описанной выше.
Для обеспечения корректности необходимо провести строгое математическое доказательство жадной стратегии, обычно с использованием метода
доказательства от противного или метода математической индукции.
Тем не менее доказательство корректности может оказаться непростой задачей. Если нет ясности, обычно выбирается отладка кода на основе тестовых
примеров с постепенной модификацией и проверкой жадной стратегии.
15.1.4. Типичные задачи для жадного алгоритма
Жадный алгоритм часто применяется в задачах оптимизации, удовлетворяющих свойству жадного выбора и оптимальной подструктуре. Ниже перечислены некоторые типичные задачи для жадного алгоритма.
Задача о размене монет: при некоторых комбинациях монет жадный
алгоритм всегда может получить оптимальное решение.
Задача о расписании интервалов: пусть у вас есть несколько задач,
каждая из которых выполняется в течение определенного времени,
и ваша цель – выполнить как можно больше задач. Если каждый раз выбирать задачу с наименьшим временем окончания, то жадный алгоритм
может дать оптимальное решение.
Задача о дробном рюкзаке: дана группа предметов и вместимость. Ваша
цель – выбрать группу предметов так, чтобы общая масса не превышала
вместимость, а общая стоимость была максимальной. Если каждый раз
выбирать предмет с наивысшим соотношением стоимости к массе, то
жадный алгоритм в некоторых случаях может дать оптимальное решение.
Задача о покупке и продаже акций: дана группа акций с историей
цены, можно совершать многократные покупки и продажи, но если акции уже куплены, то перед следующей покупкой их необходимо продать.
Цель – получить максимальную прибыль.
Код Хаффмана – это жадный алгоритм, используемый для сжатия данных без потерь. Строится дерево Хаффмана: каждый раз выбираются
два узла с наименьшей частотой появления и объединяются, в результате чего получается дерево с минимальной длиной взвешенного пути
(длиной кодирования).
466
Жадность
Алгоритм Дейкстры – это жадный алгоритм, решающий задачу нахождения кратчайшего пути от заданной исходной вершины до всех остальных вершин.
15.2. Задача о дробном рюкзаке
Задача
Даны n предметов, масса i-го предмета равна wgt[i – 1], стоимость равна
val[i – 1], и рюкзак с вместимостью cap. Каждый предмет можно выбрать только
один раз, но можно выбрать часть предмета. Стоимость рассчитывается
пропорционально выбранной массе. Какова максимальная стоимость предметов в рюкзаке при ограниченной вместимости? Пример показан на рис. 15.3.
Номер Масса Стоимость
Добавление
массы
Вместимость
рюкзака
Максимальная стоимость: 120 + (30/40) × 210 = 277.5
Оптимальная схема: положить весь
и 30 ед.
массы
в рюкзак, которые занимают 50 ед.
вместимости рюкзака
Рис. 15.3. Пример данных для задачи о дробном рюкзаке
Задача о дробном рюкзаке и задача о рюкзаке 0-1 в целом очень похожи:
состояние включает текущий предмет i и вместимость c, цель – найти максимальную стоимость при ограниченной вместимости рюкзака.
Отличие в том, что в данной задаче допускается выбирать часть предмета.
Можно произвольно разделять предметы и рассчитывать соответствующую стоимость пропорционально массе, как показано на рис. 15.4.
1. Для предмета i его стоимость на единицу массы равна val[i – 1]/wgt[i – 1],
сокращенно – удельная стоимость.
2. Предположим, что в рюкзак помещена часть предмета i массой w, тогда
увеличение стоимость рюкзака составит w × val[i – 1]/wgt[i – 1].
15.2. Задача о дробном рюкзаке 467
Номер Масса
Стои- Стоимость
на ед.
мость
массы
Текущий
предмет
Выбранная масса
Текущий предмет
Стоимость на ед.
массы
В рюкзаке
Добавленная
стоимость
Рис. 15.4. Стоимость предметов на единицу массы
1. Определение жадной стратегии
Максимизация общей стоимости предметов в рюкзаке, по сути, является максимизацией стоимости предметов на единицу массы. Из этого можно вывести
жадную стратегию, изображенную на рис. 15.5.
1. Отсортировать предметы по убыванию стоимости на единицу массы.
2. Перебирать все предметы и жадно выбирать на каждом этапе предмет с наивысшей стоимостью на единицу массы.
3. Если оставшейся вместимости рюкзака недостаточно, использовать
часть текущего предмета для заполнения рюкзака.
Номер Масса
Стои- Стоимость
на ед.
мость
массы
Сортировка по
убыванию стоимости
на ед. массы
Жадная стратегия:
В первую очередь выбирать
предметы с более высокой
стоимостью на ед. массы
Рис. 15.5. Жадная стратегия для задачи о дробном рюкзаке
2. Код реализации
Создадим класс предметов Item, чтобы можно было сортировать предметы по
удельной стоимости. Будем циклически выполнять жадный выбор, если рюкзак заполнен, выход из цикла и возврат решения.
468
Жадность
# === File: fractional_knapsack.py ===
class Item:
""" Предмет."""
def __init__(self, w: int, v: int):
self.w = w # Масса предмета.
self.v = v # Стоимость предмета.
def fractional_knapsack(wgt: list[int], val: list[int], cap: int) -> int:
""" Дробный рюкзак: жадный алгоритм."""
# Создание списка предметов, содержащего два свойства: массу, стоимость.
items = [Item(w, v) for w, v in zip(wgt, val)]
# Сортировка по убыванию стоимости за единицу массы item.v / item.w.
items.sort(key=lambda item: item.v / item.w, reverse=True)
# Циклический жадный выбор.
res = 0
for item in items:
if item.w <= cap:
# Если оставшейся вместимости достаточно, текущий предмет полностью
# помещается в рюкзак.
res += item.v
cap -= item.w
else:
# Если оставшейся вместимости недостаточно, в рюкзак помещается
# ‑часть текущего предмета.
res += (item.v / item.w) * cap
# Вместимость исчерпана, выход из цикла.
break
return res
Помимо сортировки, необходимо в худшем случае пройти весь список предметов, поэтому временная сложность составляет O(n), где n – количество
предметов.
Мы инициализируем список объектов Item, поэтому пространственная
сложность составляет O(n).
3. Доказательство корректности
Используем метод доказательства от противного. Предположим, что предмет
x – это предмет с наивысшей удельной стоимостью, и некоторый алгоритм нашел максимальную ценность res, но это решение не включает предмет x.
Извлечем из рюкзака любой предмет с единичной массой и заменим его
на предмет x с той же массой. Поскольку предмет x обладает наибольшей
удельной стоимостью, общая стоимость после замены будет больше, чем res.
Это противоречит тому, что res является оптимальным решением, следовательно, в оптимальном решении обязательно должен присутствовать предмет x.
15.3. Задача о максимальной вместимости 469
Для других предметов в этом решении также можно построить аналогичное
противоречие. В итоге предметы с большей удельной стоимостью всегда
являются более предпочтительным выбором, что подтверждает эффективность жадной стратегии.
Если рассматривать массу предметов и их удельную стоимость как оси
двухмерной диаграммы, то задачу о дробном рюкзаке можно преобразовать в нахождение максимальной площади, ограниченной конечным интервалом по горизонтальной оси, как показано на рис. 15.6. Это сравнение
помогает понять эффективность жадной стратегии с геометрической точки зрения.
Стоимость на
ед. массы
Масса
Вместимость рюкзака
Рис. 15.6. Геометрическое представление задачи о дробном рюкзаке
15.3. Задача о максимальной вместимости
Задача
Дан массив ht, в котором каждый элемент представляет высоту вертикальной
перегородки. Любые две перегородки из массива и пространство между ними
могут образовывать контейнер.
Вместимость контейнера равна произведению высоты и ширины (площади),
где высота определяется более короткой перегородкой, а ширина – разницей
индексов двух перегородок в массиве.
Необходимо выбрать две перегородки из массива так, чтобы вместимость образованного контейнера была максимальной, и вернуть эту максимальную
вместимость. Пример показан на рис. 15.7.
470
Жадность
Короткая
перегородка
Максимальная вместимость: (5 – 1) × 7 = 28
Рис. 15.7. Пример данных для задачи о максимальной вместимости
Контейнер образуется любыми двумя перегородками, поэтому состояние задачи определяется индексами двух перегородок, обозначим ее
как [i, j].
Согласно условию вместимость равна произведению высоты на ширину, где
высота определяется более короткой перегородкой, а ширина – разницей индексов двух перегородок в массиве. Обозначим вместимость как cap[i, j], тогда
формула для расчета будет следующей:
𝑐𝑎𝑝[𝑖, 𝑗] = min(ℎ𝑡[𝑖], ℎ𝑡[𝑗]) × (𝑗 − 𝑖).
Пусть длина массива равна n, тогда количество комбинаций двух перегородок (общее количество состояний) равно Cn2 = n(n – 1)/2. Наиболее очевидный
способ для нахождения максимальной вместимости – перебрать все состояния, что дает временную сложность O(n2).
1. Определение жадной стратегии
Для этой задачи существует более эффективное решение. Выберем состояние
[i, j], которое удовлетворяет условиям i < j и ht[i] < ht[j], т. е. i является короткой
перегородкой, а j – длинной, как показано на рис. 15.8.
Если в этот момент переместить длинную перегородку j ближе к короткой i, вместимость обязательно уменьшится, как показано на рис. 15.9.
Это происходит потому, что после перемещения длинной перегородки
j ширина j – i обязательно уменьшится. Высота же определяется короткой
перегородкой, поэтому высота может остаться прежней (i остается короткой
перегородкой) либо уменьшиться (перемещенная j становится короткой перегородкой).
15.3. Задача о максимальной вместимости 471
i – короткая перегородка, j – длинная перегородка
Текущая вместимость: cap = min(3, 4) × (7 − 0) = 21
Рис. 15.8. Начальное состояние
При перемещении длинной перегородки внутрь
вместимость обязательно уменьшится
Текущая вместимость: cap = min(3, 3) × (6 − 0) = 18
Рис. 15.9. Состояние после перемещения длинной перегородки внутрь
Обратное рассуждение: увеличить вместимость можно только перемещая короткую перегородку i внутрь. Хотя ширина обязательно уменьшится,
высота может увеличиться (перемещенная короткая перегородка i может
стать длиннее). Например, на рис. 15.10 после перемещения короткой перегородки площадь увеличивается.
Таким образом, можно сформулировать жадную стратегию для этой задачи:
инициализировать два указателя, расположив их по краям контейнера, и на
каждом шаге перемещать указатель, соответствующий короткой перегородке,
внутрь, пока указатели не встретятся.
472
Жадность
При перемещении короткой перегородки внутрь
вместимость может увеличиться
Текущая вместимость: cap = min(8, 4) × (7 − 1) = 24
Рис. 15.10. Состояние после перемещения короткой перегородки внутрь
На рис. 15.11 демонстрируется этот процесс выполнения жадной стратегии.
1. В начальном состоянии указатели i и j расположены по краям массива.
2. Вычисление вместимости текущего состояния cap[i, j] и обновление максимальной вместимости.
3. Сравнение высот перегородок i и j и перемещение короткой перегородки на одну позицию внутрь.
4. Повторение шагов 2 и 3 до тех пор, пока i и j не встретятся.
Шаг 1
Инициализация указателей i, j на противоположных концах массива,
максимальная вместимость res = 0
Рис. 15.11. Жадный алгоритм для задачи о максимальной вместимости. Шаг 1
2 Текущая вместимость
3 Максимальная вместимость
15.3. Задача о максимальной вместимости 473
Шаг 2
Короткая
перегородка
Текущая вместимость
Максимальная вместимость
4 Указатели i и j встретились
Шаг 3
Выход из жадного цикла, возврат максимальной вместимости 28
Короткая
Инициализация указателей i, j на противоположных концах массива,
перегородка
максимальная вместимость res = 0
Текущая вместимость
Максимальная вместимость
Рис. 15.11. Продолжение. Шаг 2–3
4 Указатели i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
Инициализация указателей i, j на противоположных концах массива,
максимальная вместимость res = 0
474
Жадность
Шаг 4
Короткая
перегородка
Текущая вместимость
Максимальная вместимость
4 Указатели i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
Шаг 5
Короткая
перегородка
Инициализация указателей i, j на противоположных концах массива,
максимальная вместимость res = 0
Текущая вместимость
Максимальная вместимость
Рис. 415.11.
Продолжение. Шаг 4–5
Указатели i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
15.3. Задача о максимальной вместимости 475
Шаг 6
Короткая
перегородка
Текущая вместимость
Максимальная вместимость
4 Указатели i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
Шаг 7
Короткая
перегородка
Текущая вместимость
Максимальная вместимость
Рис. 415.11.
Продолжение.
Шаг 6–7
Указатели
i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
476
Жадность
Шаг 8
Короткая
перегородка
Текущая вместимость
Максимальная вместимость
4 Указатели i и j встретились
Шаг 9
Выход из жадного цикла, возврат максимальной вместимости 28
Указатели i и j встретились
Выход из жадного цикла, возврат максимальной вместимости 28
Рис. 15.11. Окончание. Шаг 8–9
2. Код реализации
Цикл выполняется не более n раз, поэтому временная сложность составляет O(n).
Переменные i, j, res используют дополнительное пространство постоянного
размера, поэтому пространственная сложность равна O(1).
15.3. Задача о максимальной вместимости 477
# === File: max_capacity.py ===
def max_capacity(ht: list[int]) -> int:
""" Максимальная вместимость: жадный алгоритм."""
# Инициализация i, j с расположением по краям массива.
i, j = 0, len(ht) - 1
# Начальная максимальная вместимость равна 0.
res = 0
# Цикл жадного выбора, пока две перегородки не встретятся.
while i < j:
# Обновление максимальной вместимости.
cap = min(ht[i], ht[j]) * (j - i)
res = max(res, cap)
# Перемещение короткой перегородки внутрь.
if ht[i] < ht[j]:
i += 1
else:
j -= 1
return res
3. Доказательство корректности
Жадный алгоритм быстрее перебора, потому что каждое жадное решение пропускает некоторые состояния.
Например, имеется состояние cap[i, j], в котором i является короткой перегородкой, а j – длинной. Если жадно переместить короткую доску i на одну позицию внутрь, это приведет к тому, что состояние, показанное на рис. 15.12,
будет пропущено. Это означает, что впоследствии невозможно будет проверить размеры емкости всех этих состояний:
cap[i, i + 1], cap[i, i + 2], ..., cap[i, j – 2], cap[i, j – 1].
Наблюдение показывает, что эти пропущенные состояния на самом деле
являются всеми состояниями, при которых длинная доска j перемещается внутрь. Ранее было доказано, что перемещение длинной доски внутрь обязательно приведет к уменьшению емкости. Это означает, что пропущенные
состояния не могут быть оптимальным решением, и их пропуск не приведет
к упущению оптимального решения.
Этот анализ показывает, что операция перемещения короткой перегородки
является безопасной, и жадная стратегия эффективна.
478
Жадность
Переместить
короткую
перегородку
Пропущенные состояния
Рис. 15.12. Перемещение короткой перегородки приводит к пропущенным состояниям
15.4. Задача о максимальном произведении разбиения
Задача
Дано положительное целое число n, необходимо разложить его на сумму как
минимум двух положительных целых чисел и найти максимальное возможное произведение всех чисел разбиения, как показано на рис. 15.13.
Ввод целого числа n,
поиск max(n1 × n2 × n3 ×...× nm−2 × nm−1 × nm)
Рис. 15.13. Определение задачи о максимальном произведении разбиения
15.4. Задача о максимальном произведении разбиения 479
Предположим, что мы разложили n на m целых множителей, где i-й множитель обозначен как ni, т. е.:
m
n
ni .
i 1
Цель данной задачи – найти максимальное произведение всех целых множителей, т. е.:
m
max= ni .
i 1
Необходимо решить вопрос: насколько велико должно быть количество разбиений m и каковы должны быть значения каждого ni?
1. Определение жадной стратегии
Эмпирический факт заключается в том, произведение двух чисел часто больше
их суммы. Предположим, что из n выделяется множитель 2, тогда итоговое произведение равно 2(n − 2). Сравним это произведение с n:
2(n − 2) ≥ n
2n−n−4≥0
n ≥ 4.
Когда n ≥ 4, выделение множителя 2 увеличивает произведение, как показано на рис. 15.14. Это означает, что целые числа, равные или большие 4,
необходимо раскладывать на несколько множителей.
Жадная стратегия 1: если в схеме разбиения присутствует множитель ≥ 4,
то его следует продолжать раскладывать. В окончательной схеме разбиения
должны присутствовать только множители 1, 2, 3.
При n ≥ 4 всегда выполняется 2(n − 2) ≥ n
Поэтому в итоговой схеме разбиения должны присутствовать
только множители 1, 2, 3
Рис. 15.14. Разбиение увеличивает произведение
480
Жадность
Далее следует обдумать, какой множитель является оптимальным. Среди множителей 1, 2, 3 очевидно, что 1 – наихудший, поскольку неравенство
1 × (n − 1) < n всегда верно, т. е. выделение 1 приведет к уменьшению произведения.
Если n = 6, 3 × 3 > 2 × 2 × 2, значит разбиение на тройки предпочтительнее
разбиения на двойки, см рис. 15.15.
Жадная стратегия 2: в схеме разбиения должно быть не более двух значений 2, поскольку три 2 всегда можно заменить двумя 3 и получить большее
произведение.
При наличии трех двоек нужно жадно
преобразовать их в две тройки
Рис. 15.15. Оптимальные множители разбиения
Таким образом, можно вывести общую жадную стратегию.
1. Задать целое число n и выделять из него множитель 3 до тех пор, пока
остаток не станет 0, 1 или 2.
2. Если остаток равен 0, значит n кратно 3, и дальнейшие действия не требуются.
3. Если остаток равен 2, не продолжать разбиение, оставить как есть.
4. Если остаток равен 1, то, поскольку 2 × 2 > 1 × 3, следует заменить последний множитель 3 на 2.
2. Код реализации
Из рис. 15.16 видно, что для разбиения числа нет необходимости использовать
цикл. Можно воспользоваться операцией целочисленного деления вниз для
получения количества троек a, а также операцией взятия остатка для получения остатка b, в этом случае:
n = 3a + b.
Обратите внимание, что для граничных случаев, когда n ≤ 3, необходимо выделить множитель 1, произведение будет равно 1 × (n − 1).
15.4. Задача о максимальном произведении разбиения 481
# === File: max_product_cutting.py ===
def max_product_cutting(n: int) -> int:
""" Максимальное произведение разбиения: жадный алгоритм."""
# Когда n <= 3, необходимо выделить 1.
if n <= 3:
return 1 * (n - 1)
# Жадно выделять 3, a – количество троек, b – остаток.
a, b = n // 3, n % 3
if b == 1:
# Если остаток равен 1, преобразовать пару 1 * 3 в 2 * 2.
return int(math.pow(3, a - 1)) * 2 * 2
if b == 2:
# Если остаток равен 2, ничего не предпринимать.
return int(math.pow(3, a)) * 2
# Если остаток равен 0, ничего не предпринимать.
return int(math.pow(3, a))
Остаток b
a элементов 3
n=3×a+b
Рис. 15.16. Метод вычисления максимального произведения разбиения
Временная сложность зависит от метода реализации операции возведения в степень в языке программирования. Для Python обычно используются три функции для вычисления степени.
Оператор ** и функция pow() имеют временную сложность O(log a).
Функция math.pow() вызывает функцию pow() из библиотеки C, выполняющую
возведение в степень с плавающей точкой c временной сложностью O(1).
Переменные a и b используют дополнительное пространство постоянного
размера, поэтому пространственная сложность составляет O(1).
3. Доказательство корректности
Используем метод от противного и проанализируем только случай n ≥ 3.
1. Все множители ≤ 3: предположим, что в оптимальной схеме разбиения
существует множитель ≥ 4, тогда его можно разложить на 2(x − 2) и получить большее произведение. Это противоречит предположению.
2. Схема разбиения не содержит 1: предположим, что в оптимальной
схеме разбиения существует множитель 1, тогда его можно объединить
482
Жадность
с другим множителем и получить большее произведение. Это противоречит предположению.
3. Максимальное количество двоек в разбиении равно 2: предположим, что в оптимальном разбиении содержатся три двойки, тогда их
можно заменить на две тройки и получить большее произведение. Это
противоречит предположению.
15.5. Резюме
Жадные алгоритмы обычно применяются для решения задач оптимизации. Их принцип заключается в том, чтобы на каждом этапе принятия
решения делать локально оптимальный выбор с целью получения глобально оптимального решения.
В жадных алгоритмах циклически выполняются жадные выборы, каждый раз превращая задачу в меньшую подзадачу, пока задача не будет
решена.
Жадные алгоритмы не только просты в реализации, но и обладают высокой эффективностью решения. По сравнению с динамическим программированием временная сложность жадных алгоритмов обычно ниже.
В задаче о размене монет для некоторых комбинаций монет жадный алгоритм может гарантировать нахождение оптимального решения. Но для
других комбинаций жадный алгоритм может найти очень плохое решение.
Задачи, подходящие для решения жадными алгоритмами, обладают
двумя основными свойствами: свойство жадного выбора и оптимальная
подструктура. Свойство жадного выбора свидетельствует об эффективности жадной стратегии.
Для некоторых сложных задач доказательство свойства жадного выбора является сложной задачей. Относительно проще найти контрпример
и опровергнуть это свойство, например в задаче о размене монет.
Решение жадных задач обычно включает три этапа: анализ задачи, определение жадной стратегии, доказательство корректности. Среди них
ключевым этапом является определение жадной стратегии, а доказательство корректности часто представляет собой сложную задачу.
Задача о дробном рюкзаке, в отличие от задачи о рюкзаке 0‑1, позволяет
выбирать часть предметов, поэтому ее можно решить с помощью жадного алгоритма. Корректность жадной стратегии можно доказать методом
от противного.
Задачу о максимальной вместимости можно решить методом перебора, временная сложность которого составляет O(n2). Разработав жадную
стратегию, в которой на каждом шаге граница перемещается внутрь,
временную сложность можно оптимизировать до O(n).
В задаче о максимальном произведении разбиения мы последовательно формулируем две жадные стратегии. Во-первых, для целых чисел ≥ 4
нужно продолжать разбиение. Во-вторых, оптимальным множителем
разбиения является 3. В коде содержатся операции возведения в степень, временная сложность которых зависит от метода их реализации
и обычно составляет O(1) или O(log n).
Глава 16
Приложение
Содержание главы
16.1. Установка программной среды
16.2. Совместная разработка
16.3. Глоссарий
484
Приложение
16.1. Установка программной среды
16.1.1. Установка IDE
Рекомендуется использовать открытую и быструю интегрированную среду
разработки (IDE) VS Code. Откройте официальный сайт VS Code и выберите
подходящую вашей операционной системе версию для загрузки и установки.
Скачать VS Code
Рис. 16.1. Загрузка VS Code с официального сайта
VS Code обладает мощной экосистемой расширений и поддерживает выполнение и отладку большинства языков программирования. Например, после
установки расширения Python Extension Pack можно выполнять отладку кода
на Python. Этапы установки показаны на рис. 16.2.
16.1. Установка программной среды 485
Панель поиска
Установка
расширения
Магазин
расширений
Рис. 16.2. Установка расширений в VS Code
16.1.2. Установка языковой среды
1. Среда Python
1. Загрузите и установите инструмент Miniconda3, требуется Python 3.10
или более поздняя версия.
2. В магазине расширений VS Code выполните поиск по слову python и установите расширение Python Extension Pack.
3. (Не обязательно) Введите в командной строке pip install black для установки инструмента форматирования кода.
2. Среда C/C++
1. В системе Windows необходимо установить набор инструментов MinGW
(руководство по настройке). В MacOS имеется встроенный компилятор
Clang, дополнительная установка не требуется.
2. В магазине расширений VS Code выполните поиск по слову c++ и установите расширение C/C++ Extension Pack.
3. (Не обязательно) Откройте страницу настроек, найдите параметр форматирования кода Clang_format_fallback Style и установите его в значение { BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }.
3. Среда Java
1. Загрузите и установите OpenJDK (версия > JDK 9).
2. В магазине расширений VS Code выполните поиск по слову java и установите расширение Extension Pack for Java.
486
Приложение
4. Среда C#
1. Загрузите и установите .Net 8.0.
2. В магазине расширений VS Code выполните поиск по фразе C# Dev Kit
и установите расширение C# Dev Kit (руководство по настройке).
3. Также можно использовать интегрированную среду разработки Visual
Studio (руководство по установке).
5. Среда Go
1. Загрузите и установите Go.
2. В магазине расширений VS Code выполните поиск по слову go и установите Go.
3. Нажмите сочетание клавиш Ctrl + Shift + P, чтобы открыть командную
строку. Введите команду go, выберите Go: Install/Update Tools, отметьте
все и установите.
6. Среда Swift
1. Загрузите и установите Swift.
2. В магазине расширений VS Code выполните поиск по слову swift и установите расширение Swift for Visual Studio Code.
7. Среда JavaScript
1. Загрузите и установите Node.js.
2. (Не обязательно) В магазине расширений VS Code выполните поиск по
слову Prettier и установите инструмент форматирования кода.
8. Среда TypeScript
1. Выполните шаги установки для среды JavaScript.
2. Установите TypeScript Execute (tsx).
3. В магазине расширений VS Code выполните поиск по слову typescript
и установите расширение Pretty TypeScript Errors.
9. Среда Dart
1. Загрузите и установите Dart.
2. В магазине расширений VS Code выполните поиск по слову dart и установите расширение Dart.
10. Среда Rust
1. Загрузите и установите Rust.
2. В магазине расширений VS Code выполните поиск по слову rust и установите расширение rust-analyzer.
16.2. Совместная разработка
Ввиду ограниченных возможностей автора в книге неизбежно присутствуют некоторые упущения и ошибки, просим отнестись к этому с пониманием.
Если вы обнаружите опечатки, неработающие ссылки, неполное содержание,
16.2. Совместная разработка 487
двусмысленности в тексте, неясные объяснения или нерациональную структуру изложения, пожалуйста, помогите нам в исправлении, чтобы предоставить
читателям более качественные учебные ресурсы.
Все идентификаторы GitHub авторов будут представлены на странице репозитория книги, в веб-версии и PDF-версии в знак благодарности за их бескорыстный вклад в сообщество с открытым исходным кодом.
Очарование открытой лицензии
Интервалы между выходом двух печатных изданий бумажной книги зачастую довольно продолжительны, что делает обновление содержания весьма
неудобным. Но обновление примеров исходного кода и электронной версии
книги в репозитории занимает всего нескольких дней или даже часов.
1. Небольшая корректировка содержимого
В правом верхнем углу каждой страницы есть значок редактирования, как показано на рис. 16.3. Следуйте следующим шагам для изменения текста или кода.
1. Нажмите на значок редактирования. Если появится сообщение «Необходимо создать ответвление этого репозитория», согласитесь на это действие.
2. Измените содержимое исходного файла Markdown, проверьте правильность содержания и постарайтесь сохранить единый формат оформления.
3. В нижней части страницы заполните описание изменений, затем нажмите кнопку Propose file change (предложить изменение файла). После
перехода на следующую страницу нажмите кнопку Create pull request
(Создать запрос на слияние), чтобы инициировать запрос на слияние.
Кнопка редактирования
страницы
Рис. 16.3. Кнопка редактирования страницы
488
Приложение
Изображения нельзя изменить напрямую, необходимо создать новую Issue
(Задачу) или оставить комментарий для описания проблемы. Мы как можно
быстрее изменим и обновим изображение.
2. Создание содержимого
Если вы заинтересованы в участии в этом проекте с открытым исходным кодом, включая перевод кода на другие языки программирования, расширение
содержания статей и т. д., необходимо выполнить следующий рабочий процесс
Pull Request (Запрос на слияние).
1. Войдите в GitHub, создайте ответвление хранилища кода книги в свой
личный аккаунт.
2. Перейдите на страницу ответвления и используйте команду git clone
для клонирования хранилища на локальный компьютер.
3. На локальном компьютере создайте содержимое и проведите полное тестирование, чтобы проверить правильность кода.
4. Зафиксируйте изменения, сделанные локально, затем отправьте их
в удаленное хранилище.
5. Обновите страницу хранилища и нажмите кнопку Create pull request
(Создать запрос на слияние), чтобы инициировать запрос на слияние.
3. Развертывание Docker
В корневом каталоге hello-algo выполните следующий сценарий Docker, чтобы
настроить доступ к проекту по адресу http://localhost:8000:
docker-compose up -d
Для удаления развертывания выполните следующую команду:
docker-compose down
16.3. Глоссарий
В табл. 16.1 приведен англо-русский словарь важных терминов, встречающихся в книге. Он поможет вам в чтении англоязычной литературы.
Таблица 16.1. Англо-русский словарь терминов
1’s complement
обратный код
2’s complement
дополнительный код
adjacency
смежность
adjacency list
список смежности
adjacency matrix
матрица смежности
algorithm
алгоритм
array
массив
asymptotic complexity analysis
асимптотический анализ сложности
16.3. Глоссарий 489
asymptotic upper bound
асимптотическая верхняя граница
AVL tree
АВЛ-дерево
backtracking algorithm
алгоритм обратного поиска
balance factor
фактор баланса
balanced binary search tree
сбалансированное двоичное дерево поиска
balanced binary tree
сбалансированное двоичное дерево
big‑O notation
обозначение «О» большое
binary search
двоичный поиск
binary search tree
двоичное дерево поиска
binary tree
двоичное дерево
bottom of the stack
основание стека
breadth‑first search
поиск в ширину
breadth‑first traversal
обход в ширину
bubble sort
сортировка пузырьком
bucket
корзина
bucket sort
блочная сортировка
cache hit rate
коэффициент попадания в кеш
cache memory
кеш-память
cache miss
промах кеша
code
код
complete binary tree
совершенное двоичное дерево
connected graph
связный граф
constraint
ограничение
counting sort
сортировка подсчетом
data structure
структура данных
degree
степень
depth
глубина
depth‑first search
поиск в глубину
depth‑first traversal
обход в глубину
directed graph
ориентированный граф
disconnected graph
несвязный граф
divide and conquer
разделяй и властвуй
double‑ended queue
двусторонняя очередь
dynamic array
динамический массив
dynamic programming
динамическое программирование
edge
ребро
edit distance problem
задача расстояния редактирования
490
Приложение
file
файл
front of the queue
голова очереди
full binary tree
полное двоичное дерево
function
функция
graph
граф
greedy algorithm
жадный алгоритм
hanota problem
задача о Ханойских башнях
hard disk
жесткий диск
hash collision
хеш-коллизия
hash function
хеш-функция
hash set
хеш-набор
hash table
хеш-таблица
head node
головной узел
heap
куча
heap sort
пирамидальная сортировка
heapify
упорядочивание кучи
height
высота
in‑degree
входящая степень
index
индекс
initial state
начальное состояние
insertion sort
сортировка вставкой
iteration
итерация
knapsack problem
задача о рюкзаке
lazy deletion
ленивое удаление
leaf node
листовой узел
left subtree
левое поддерево
left‑child node
левый дочерний узел
level
уровень
level‑order traversal
обход по уровням
linear probing
линейное зондирование
linked list
связный список
linked list node, list node
узел связного списка, узел списка
list
список
load factor
коэффициент заполнения
loop
цикл
max heap
максимальная куча
merge sort
сортировка слиянием
16.3. Глоссарий 491
method
метод
min heap
минимальная куча
n‑queens problem
задача об n ферзях
open addressing
открытая адресация
out‑degree
исходящая степень
parent node
родительский узел
path
путь
perfect binary tree
идеальное двоичное дерево
permutations problem
задача о перестановках
priority queue
приоритетная очередь
pruning
обрезка
queue
очередь
quick sort
быстрая сортировка
radix sort
поразрядная сортировка
random‑access memory (RAM)
оперативное запоминающее устройство (ОЗУ), оперативная память
rear of the queue
хвост очереди
recursion
рекурсия
recursion tree
дерево рекурсии
red‑black tree
красно-черное дерево
right subtree
правое поддерево
right‑child node
правый дочерний узел
root node
корневой узел
searching algorithm
алгоритм поиска
selection sort
сортировка выбором
separate chaining
цепная адресация
sign‑magnitude
прямой код
solution
решение
sorting algorithm
алгоритм сортировки
space complexity
пространственная сложность
stack
куча
state
состояние
state‑transition equation
уравнение перехода состояния
subset‑sum problem
задача о сумме подмножеств
tail node
хвостовой узел
tail recursion
хвостовая рекурсия
time complexity
временная сложность
492
Приложение
top of the stack
вершина стека
top‑k problem
поиск k наибольших элементов
tree node
узел дерева
undirected graph
неориентированный граф
variable
переменная
vertex
вершина
weighted graph
взвешенный граф
Книги издательства «ДМК Пресс» можно купить оптом и в розницу
на складе издательства по адресу:
Москва, ул. Электродная, д. 2, стр. 12, офис 7,
тел. +7 (499) 322–19–38,
а также заказать на сайте www.dmkpress.com
с доставкой в любой регион РФ.
Цзинь Юйдун (@krahets)
Алгоритмы и структуры данных с примерами на Python
Главный редактор Мовчан Д. А.
Зам. главного редактора Яценков В. С.
editor@dmkpress.com
Перевод Шевкун И. А.
Корректор Абросимова Л. А.
Верстка Луценко С. В.
Дизайн обложки Трофимова С. В.
Формат 70×100 1/16.
Гарнитура «PT Serif». Печать цифровая.
Усл. печ. л. 40,14. Тираж 100 экз.
Веб-сайт издательства: www.dmkpress.com
Книга будет особенно полезна всем, у кого есть начальные
навыки программирования, но отсутствует четкое понимание алгоритмов и структур данных. Более опытным
читателям она поможет освежить и систематизировать
знания об алгоритмах.
Цель этой книги – при помощи наглядных иллюстраций и исполняемых примеров кода помочь читателю понять ключевые
идеи алгоритмов и структур данных и освоить их воплощение
в программном коде. Если вам необходимо подготовиться
к собеседованию или получить базовые навыки перед сложным курсом по программированию, но не хватает времени
на чтение множества учебников от корки до корки, этот
учебник станет для вас спасательным кругом в океане знаний.
Среди основных тем книги:
• понятие алгоритма и структуры данных;
• критерии и методы оценки структур данных и алгоритмов;
• классификация основных типов и структур данных,
их достоинства и недостатки;
• основные операции со структурами данных;
• классификация алгоритмов, достоинства и недостатки
разных типов;
• распространенные алгоритмы с примерами задач.
ISBN 978-5-93700-424-6
www.дмк.рф
9 785937 004246