From d2da1b4a8375367af596e8cf09a730c754349d4b Mon Sep 17 00:00:00 2001 From: krahets Date: Wed, 25 Mar 2026 16:54:42 +0800 Subject: [PATCH] update russian v2 --- mkdocs.yml | 3 + ru/chapter_index.json | 90 + ru/chapters/chapter_00.md | 337 + ru/chapters/chapter_01.md | 169 + ru/chapters/chapter_02.md | 1081 +++ ru/chapters/chapter_03.md | 797 +++ ru/chapters/chapter_04.md | 775 +++ ru/chapters/chapter_05.md | 797 +++ ru/chapters/chapter_06.md | 807 +++ ru/chapters/chapter_07.md | 1079 +++ ru/chapters/chapter_08.md | 437 ++ ru/chapters/chapter_09.md | 547 ++ ru/chapters/chapter_10.md | 543 ++ ru/chapters/chapter_11.md | 1192 ++++ ru/chapters/chapter_12.md | 511 ++ ru/chapters/chapter_13.md | 861 +++ ru/chapters/chapter_14.md | 1415 ++++ ru/chapters/chapter_15.md | 493 ++ ru/chapters/chapter_16.md | 441 ++ ru/full_russian.md | 12636 ++++++++++++++++++++++++++++++++++++ 20 files changed, 25011 insertions(+) create mode 100644 ru/chapter_index.json create mode 100644 ru/chapters/chapter_00.md create mode 100644 ru/chapters/chapter_01.md create mode 100644 ru/chapters/chapter_02.md create mode 100644 ru/chapters/chapter_03.md create mode 100644 ru/chapters/chapter_04.md create mode 100644 ru/chapters/chapter_05.md create mode 100644 ru/chapters/chapter_06.md create mode 100644 ru/chapters/chapter_07.md create mode 100644 ru/chapters/chapter_08.md create mode 100644 ru/chapters/chapter_09.md create mode 100644 ru/chapters/chapter_10.md create mode 100644 ru/chapters/chapter_11.md create mode 100644 ru/chapters/chapter_12.md create mode 100644 ru/chapters/chapter_13.md create mode 100644 ru/chapters/chapter_14.md create mode 100644 ru/chapters/chapter_15.md create mode 100644 ru/chapters/chapter_16.md create mode 100644 ru/full_russian.md diff --git a/mkdocs.yml b/mkdocs.yml index ccfd7023f..b63dba521 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,9 @@ extra: - name: 日本語 link: /ja/ lang: ja + - name: Русский + link: /ru/ + lang: ru social: - icon: fontawesome/brands/github link: https://github.com/krahets diff --git a/ru/chapter_index.json b/ru/chapter_index.json new file mode 100644 index 000000000..27275b587 --- /dev/null +++ b/ru/chapter_index.json @@ -0,0 +1,90 @@ +{ + "total_lines": 12637, + "chapters": [ + { + "title": "Введение", + "start_line": 338, + "end_line": 675 + }, + { + "title": "Введение в алгоритмы", + "start_line": 676, + "end_line": 845 + }, + { + "title": "Анализ сложности", + "start_line": 846, + "end_line": 1927 + }, + { + "title": "Структуры данных", + "start_line": 1928, + "end_line": 2725 + }, + { + "title": "Массивы и списки", + "start_line": 2726, + "end_line": 3501 + }, + { + "title": "Стек и очередь", + "start_line": 3502, + "end_line": 4299 + }, + { + "title": "Хеш-таблицы", + "start_line": 4300, + "end_line": 5107 + }, + { + "title": "Деревья", + "start_line": 5108, + "end_line": 6187 + }, + { + "title": "Куча", + "start_line": 6188, + "end_line": 6625 + }, + { + "title": "Графы", + "start_line": 6626, + "end_line": 7173 + }, + { + "title": "Поиск", + "start_line": 7174, + "end_line": 7717 + }, + { + "title": "Сортировка", + "start_line": 7718, + "end_line": 8910 + }, + { + "title": "Разделяй и властвуй", + "start_line": 8911, + "end_line": 9422 + }, + { + "title": "Поиск с возвратом", + "start_line": 9423, + "end_line": 10284 + }, + { + "title": "Динамическое программирование", + "start_line": 10285, + "end_line": 11700 + }, + { + "title": "Жадность", + "start_line": 11701, + "end_line": 12194 + }, + { + "title": "Приложение", + "start_line": 12195, + "end_line": 12636 + } + ] +} \ No newline at end of file diff --git a/ru/chapters/chapter_00.md b/ru/chapters/chapter_00.md new file mode 100644 index 000000000..f8cd94d23 --- /dev/null +++ b/ru/chapters/chapter_00.md @@ -0,0 +1,337 @@ +# Введение + +![](ru/docs/assets/media/image31.jpeg){width="4.60417760279965in" height="5.958333333333333in"} + +1. **О книге** + +> Этот проект задуман как открытое, бесплатное и дружелюбное к новичкам введение в структуры данных и алгоритмы. + +1. В книге используются анимационные иллюстрации. Материал изложен ясно и последовательно, что облегчает освоение и помогает начинаю- щим построить «карту знаний» по структурам данных и алгоритмам. + +2. Исходный код можно запустить одним кликом, что позволяет трениро- ваться, развивать навыки программирования и формировать понима- ние принципов работы алгоритмов и реализации структур данных на фундаментальном уровне. + +3. Мы призываем к взаимопомощи читателей: задавайте вопросы и дели- тесь идеями в комментариях. Обсуждения помогают двигаться вперед всем вместе. + +### Целевая аудитория + +> Если вы новичок в алгоритмах, никогда с ними не сталкивались или у вас есть некоторый опыт решения задач, но еще нет четкого понимания структур дан- ных и алгоритмов, эта книга создана специально для вас! +> +> Если у вас уже есть определенный опыт решения задач и вы знакомы с боль- шинством типов задач, эта книга поможет вам освежить и систематизировать знания об алгоритмах. Исходный код может служить набором инструментов для решения задач или алгоритмическим словарем. +> +> Если вы владеете алгоритмами на экспертном уровне, мы будем рады ва- шим ценным советам или совместному участию в создании книги. + +### Структура книги + +> Основное содержание книги представлено на рис. 0.1. + +1. **Анализ сложности**: критерии и методы оценки структур данных и ал- горитмов. Методы расчета временной и пространственной сложности, распространенные типы, примеры и т. д. + +> ![](ru/docs/assets/media/image33.png)**Структуры данных** +> +> **Структуры данных** +> +> **Массивы и списки** +> +> **Стек** +> +> **[и]{.smallcaps} очередь** +> +> **Хеш- таблицы** +> +> Логическая и физическая структура Базовые типы данных +> +> Цифровое и символьное кодирование +> +> Способы непрерывного и распределенного хранения Методы работы, преимущества +> +> [и]{.smallcaps} недостатки двух способов +> +> Реализация списка на основе динамического массива +> +> Память и кеш в компьютере +> +> Последний пришел -- первый вышел, первый пришел -- первый вышел, двусторонняя очередь +> +> Реализация на основе массива и связного списка +> +> Принцип работы хеш-таблицы, реализация на основе массива +> +> Хеш-коллизии, цепная и открытая адресация Назначение и цели хеш-алгоритмов +> +> Идеальное, совершенное, полное, сбалансированное +> +> двоичное дерево +> +> **Анализ сложности** +> +> **Итерация и рекурсия** +> +> **Временная сложность** +> +> **Пространственная сложность** +> +> **Поиск** +> +> Циклы for и while, вложенные циклы +> +> Стек вызовов рекурсии, хвостовая рекурсия, дерево рекурсии +> +> Сравнение итерации и рекурсии +> +> Асимптотическая верхняя граница +> +> Методы вычисления, основные типы +> +> Наихудшая, наилучшая, средняя временная сложность +> +> Методы вычисления, основные типы Компромисс между временем +> +> и пространством +> +> Перебор: линейный поиск, поиск в ширину и глубину +> +> Адаптивный поиск: двоичный поиск, хеш-поиск, поиск по дереву +> +> Выбор алгоритма поиска +> +> Местность, устойчивость, адаптивность, основанность на сравнении +> +> Перебор: сортировка выбором, пузырьком, вставками + +**Деревья** + +**Куча** + +**Графы** + +https://github.com/krahets/hello-algo + +> Представление связным списком и массивом, сравнение Обход в ширину; прямой, симметричный и обратный порядок Двоичное дерево поиска +> +> АВЛ-дерево +> +> ![](ru/docs/assets/media/image35.png)Минимальная и максимальная куча, **Алгоритмы** +> +> очередь с приоритетом +> +> Реализация кучи на основе массива, операция построения кучи +> +> Задача о k наибольших элементов +> +> Ориентированный, связный и взвешенный графы Список и матрица смежности, их сравнение Обход в ширину и в глубину +> +> **Сортировка** +> +> **Разделяй и властвуй** +> +> **Поиск** +> +> **с возвратом** +> +> **Динамическое программирование** +> +> **Жадные алгоритмы** +> +> «Разделяй и властвуй»: быстрая сортировка, сортировка слиянием, пирамидальная сортировка +> +> Без сравнения: блочная сортировка, +> +> сортировка подсчетом, поразрядная сортировка Стратегия «разделяй и властвуй» +> +> Примеры задач: двоичный поиск, построение дерева, Ханойская башня +> +> Задача поиска с возвратом, каркас кода +> +> Примеры задач: перестановки, сумма подмножеств, n ферзей +> +> Перебор, рекурсия с мемоизацией, восходящая рекурсия +> +> Перекрывающиеся подзадачи, оптимальная подструктура, отсутствие последействия +> +> Методы определения задач динамического программирования, этапы решения +> +> Примеры задач: рюкзак 0-1, полный рюкзак, расстояние редактирования +> +> Характеристики жадных задач, этапы решения +> +> Примеры задач: дробный рюкзак, максимальная вместимость, максимальное произведение разбиения +> +> 0.1. О книге ❖ **13** + +2. **Структуры данных**: классификация основных типов данных и структур данных. Определение, преимущества и недостатки, основные операции, распространенные типы, типичные приложения и методы реализации мас- сивов, списков, стеков, очередей, хеш-таблиц, деревьев, куч, графов и т. д. + +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, JoseHu- ng, qualifier1024, QiLOL, pengchzn, Guanngxu, L-Super, WSL0809, Slone123c, lhxsm, yuan0221, what-is-me, theNefelibatas, longranger2, cy-by-side, xiongsp, Jefferson- Huang, 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, YangXu- anyi, DullSword, boloboloda, iStig, qq909244296, jiaxianhua, wenjianmin, keshida, kilikilikid, lclc6, lwbaptx, liuxjerry, lucaswangdev, lyl625760, hts0000, gledfish, fbigm, echo1937, szu17dmy, dshlstarr, Yucao-cy, coderlef, czruby, bongbongbakudan, beinten- tional, 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, JackYang- hellobobo, Javesun99, lipusheng, BlindTerran, ShiMaRing, FreddieLi, FloranceYeh, iF- leey, fanchenggang, gltianwen, goerll, Dr-XYZ, nedchu, curly210102, CuB3y0nd, KraHsu, CarrotDLaw, youshaoXG, bubble9um, fanenr, eagleanurag, LifeGoesOnionOnionOnion, 52coder, foursevenlove, KorsChen, hezhizhen, linzeyan, ZJKung, GaochaoZhu, hopk- ings2008, 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, gve- nusleo, hpstory, justin-tse, khoaxuantu, krahets, night-cruise, nuomi1, Reanon и ron- gyi (в алфавитном порядке). Благодарим их за потраченное время и усилия, ко- + +торые обеспечили стандартизацию и единообразие кода на различных языках. + +> В процессе создания этой книги мне помогало много людей. + +1. Благодарю моего наставника в компании, доктора Ли Си (Li Xi), который в одной из бесед вдохновил меня «действовать быстро», что укрепило мою решимость написать эту книгу. + +2. Благодарю мою девушку Паo Пао (Pao Pao), которая, будучи первым чи- тателем книги, дала множество ценных советов с точки зрения новичка в алгоритмах, что сделало книгу более понятной и доступной. + +3. Благодарю Тен Бао (Teng Bao), Ци Бао (Qi Bao) и Фей Бао (Fei Bao) за креа- тивное название книги, которое навевает приятные воспоминания о на- писании первой строки кода «Hello World!». + +4. Благодарю Сяо Цюань (Xiao Quan) за профессиональную помощь в во- просах интеллектуальной собственности, что сыграло важную роль в со- вершенствовании этой открытой книги. + +5. Благодарю Су Тун (Su Tong) за дизайн обложки и логотипа книги, а также за терпение при многократных исправлениях по моим просьбам. + +6. Благодарю \@squidfunk за советы по оформлению и за разработку от- крытой темы документации Material-for-MkDocs. + +> В процессе написания я ознакомился с множеством учебников и статей по структурам данных и алгоритмам. Эти работы послужили отличным образцом для этой книги, обеспечив ее точность и качество. Выражаю благодарность всем преподавателям и предшественникам за их выдающийся вклад! +> +> Настоящая книга пропагандирует метод обучения, сочетающий умственную и практическую деятельность, на который меня вдохновила книга Dive into Deep Learning («Погружение в глубокое обучение», на англ. языке). Настоятель- но рекомендую эту замечательную работу всем читателям. +> +> **Сердечно благодарю своих родителей, ведь именно ваша постоянная поддержка и ободрение дали мне возможность заняться этим увлека- тельным делом.** + +#### как использовать эту книгу + +### Стиль изложения + +- Главы, имеющие символ \* после заголовка, являются дополнительными и содержат более сложный материал. Если у вас ограничено время, мож- но их пропустить. + +- Профессиональные термины выделяются полужирным шрифтом (в печат- ной и PDF-версии) или подчеркиванием (в веб-версии), например **массив** (array). Рекомендуется запоминать их для удобства чтения литературы. + +- Важные моменты и обобщающие фразы выделяются **полужирным шрифтом**, на такие тексты следует обращать особое внимание. + +- При упоминании терминов, различающихся в разных языках програм- мирования, в качестве стандарта используется Python, например None для обозначения «пустого значения». + +- В некоторых местах книга отходит от стандартов комментирования про- граммного кода ради более компактного оформления. Комментарии де- лятся на три типа: заголовочные, содержательные и многострочные. + +> \"\"\" Заголовочные комментарии, используются для обозначения функций, классов, тестовых примеров и т.д. \"\"\" +> +> \# Содержательные комментарии, используются для пояснения кода. +> +> \"\"\" +> +> Многострочные комментарии. \"\"\" + +### Эффективное обучение с помощью анимированных иллюстраций + +> Видео и изображения обладают более высокой плотностью информации и структурированностью по сравнению с текстом, что облегчает понимание. В этой книге **ключевые и сложные моменты в основном представлены в виде анимированных иллюстраций**, а текстовая информация служит по- яснением и дополнением. +> +> Если какой-либо раздел в книге сопровождается анимационной иллюстра- цией, как на рис. 0.2, **используйте иллюстрацию в качестве основного ис- точника информации, а текст -- в качестве вспомогательного**. + +![](ru/docs/assets/media/image36.jpeg){width="5.06667760279965in" height="3.4239577865266844in"} + +> **Рис. 0.2.** Пример анимационной иллюстрации. Шаг 1 +> +> ![](ru/docs/assets/media/image37.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +![](ru/docs/assets/media/image38.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +> **Рис. 0.2.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image39.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +![](ru/docs/assets/media/image40.jpeg){width="5.066674321959755in" height="3.4239577865266844in"} + +> **Рис. 0.2.** *Окончание*. Шаги 4--5 + +### Углубление понимания через практику написания кода + +> Сопроводительный код размещен в репозитории GitHub, **он содержит тесто- вые примеры и может быть запущен одним нажатием кнопки**, как по- казано на рис. 0.3. + +![](ru/docs/assets/media/image41.jpeg){width="5.0666863517060365in" height="3.4239577865266844in"} + +> **Рис. 0.3.** Запуск примера кода +> +> Если позволяет время, **рекомендуется самостоятельно набирать код**. Если время ограничено, по крайней мере **просмотрите и выполните весь код**. +> +> Процесс написания кода приносит больше пользы, чем его чтение. **Настоя- щее обучение -- это обучение на практике**. +> +> Предварительная подготовка для запуска кода включает три этапа. + +1. **Установка локальной среды программирования**. Следуйте инструк- циям программы установки. Если среда уже установлена, этот шаг мож- но пропустить. + +2. **Клонирование или загрузка репозитория кода**. Перейдите в репози- торий GitHub. Если у вас установлена утилита Git, можно клонировать репозиторий с помощью следующей команды: + +> git clone https://github.com/krahets/hello-algo.git +> +> Либо можно нажать кнопку **Download ZIP** (Скачать ZIP-архив), как показа- но на рис. 0.4, загрузить архив с кодом и затем распаковать его на локальном компьютере. +> +> ![](ru/docs/assets/media/image43.jpeg) +> +> **Рис. 0.4.** Клонирование репозитория и загрузка кода + +3. **Запуск исходного кода**. Если для блока кода в книге указано имя фай- ла, этот файл можно найти в папке codes репозитория, как показано на рис. 0.5. Исходный код можно запустить одним нажатием, что поможет вам сэкономить время на отладку и сосредоточиться на изучении ма- териала. + +![](ru/docs/assets/media/image45.jpeg) + +> **Рис. 0.5.** Блоки кода и соответствующие файлы исходного кода +> +> Помимо локального запуска, **в веб-версии книги код на Python можно выполнить в визуальной среде** (реализовано на основе pythontutor). Для этого нажмите кнопку **Визуализировать выполнение** под блоком кода, как показано на рис. 0.6. Также в раскрывшемся окне можно нажать кнопку **От- крыть в полноэкранном режиме** для более удобного просмотра1. + +![](ru/docs/assets/media/image46.jpeg){width="5.122829177602799in" height="3.12in"} + +> **Рис. 0.6.** Визуальное выполнение кода Python + +### Совместное развитие через вопросы и обсуждения + +> При чтении книги не оставляйте без внимания непонятные моменты. **Мы при- зываем вас задавать вопросы в разделе комментариев**, и я вместе с колле- гами постараюсь ответить вам в течение двух дней. +> +> В конце каждой главы веб-версии книги предусмотрено место для коммен- тариев, как показано на рис. 0.7. Рекомендуется уделять внимание содержимо- му этой области. С одной стороны, это позволит вам понять, с какими пробле- мами сталкиваются другие читатели, что поможет выявить пробелы в знаниях и стимулировать более глубокое понимание. С другой стороны, мы надеемся, что вы будете отвечать на вопросы других участников и делиться своими мне- ниями. +> +> 1 Функция визуального выполнения кода доступна только в китайской веб-версии книги -- *Прим. перев*. +> +> ![](ru/docs/assets/media/image47.jpeg){width="5.066666666666666in" height="3.4239577865266844in"} +> +> **Рис. 0.7.** Пример раздела комментариев + +### Дорожная карта изучения алгоритмов + +> Процесс изучения структур данных и алгоритмов можно разделить на три этапа. + +1. **Введение в алгоритмы**. Необходимо ознакомиться с особенностями и применением различных структур данных, изучить принципы, про- цессы, назначение и эффективность различных алгоритмов. + +2. **Решение алгоритмических задач**. Рекомендуется начинать с популяр- ных задач и решить не менее 100 из них, чтобы познакомиться с основ- ными алгоритмическими проблемами. При первом решении задач вы можете столкнуться с так называемым забвением знаний -- не беспокой- тесь, это нормально. Следуйте при повторении задач кривой забывания Эббингауза, обычно после 3--5 циклов повторения они хорошо запоми- наются. Рекомендуемые списки задач и планы решения можно найти в **этом репозитории GitHub**. + +3. **Построение системы знаний**. В процессе обучения можно читать статьи по алгоритмам, изучать типичные решения и учебники по алгоритмам, чтобы постоянно обогащать систему знаний. В решении задач можно при- менять продвинутые стратегии, такие как классификация по темам, множе- ственные решения одной задачи или одно решение для множества задач. Советы по этим техникам обучения можно найти в различных сообществах. + +> Эта книга в основном охватывает первый этап и призвана помочь вам эф- фективно подготовиться ко второму и третьему этапам обучения, как показа- но на рис. 0.8. + +1. Резюме ❖ **23** + +> ![](ru/docs/assets/media/image49.jpeg)*Этап 1* +> +> **Базовые сведения** +> +> *Этап 2* +> +> **Тренировка навыков** +> +> *Этап 3* +> +> **Построение системы знаний** +> +> **Решение алгоритмических задач** +> +> **Периодическое повторение** *Повторное решение одной и той же задачи через определенное время* +> +> *для формирования долговременной памяти и углубления понимания* +> +> **Активное обобщение** *Систематизация содержа*- *ния, выявление законо*- +> +> *мерностей для построения целостной системы знаний* + +#### резюме + +> **Рис. 0.8.** Дорожная карта изучения алгоритмов + +- Основная аудитория книги -- новички в изучении алгоритмов. Если у вас уже есть определенная база, книга поможет систематизировать имею- щиеся знания об алгоритмах, а исходный код послужит инструменталь- ной библиотекой для решения задач. + +- Содержание книги включает три основные части -- анализ сложности, структуры данных и алгоритмы -- и охватывает большинство тем в этой области. + +- Для новичков в алгоритмах крайне важно изучить начальные разделы книги, чтобы избежать множества ошибок в будущем. + +- Анимированные иллюстрации в книге обычно используются для пред- ставления ключевых и сложных аспектов. При чтении книги следует уде- лять этим материалам большое внимание. + +- Практика -- лучший способ изучения программирования. Настоятельно рекомендуется запускать исходный код и самостоятельно писать про- граммы. + +- В веб-версии книги каждая глава имеет область комментариев, где вы можете задавать вопросы и делиться своим мнением. + +> Глава 1 diff --git a/ru/chapters/chapter_01.md b/ru/chapters/chapter_01.md new file mode 100644 index 000000000..79c077c1c --- /dev/null +++ b/ru/chapters/chapter_01.md @@ -0,0 +1,169 @@ +# Введение в алгоритмы + +![](ru/docs/assets/media/image50.jpeg){width="3.8117727471566054in" height="4.595624453193351in"} + +#### Алгоритмы повсюду + +> Говоря об алгоритмах, естественно вспомнить о математике. Однако на самом деле многие алгоритмы не связаны со сложной математикой, а больше полага- ются на базовую логику, которая повсеместно встречается в нашей повседнев- ной жизни. +> +> Прежде чем углубиться в обсуждение алгоритмов, стоит упомянуть интерес- ный факт: **вы уже точно освоили множество алгоритмов и привыкли при- менять их в повседневной жизни**. Далее приведем несколько конкретных примеров, чтобы подтвердить этот факт. +> +> **Пример 1**: **поиск в словаре**. В словаре все слова упорядочены по алфавиту. Предположим, нам нужно найти слово, начинающееся на букву «r». Обычно для этого нужно выполнить следующие действия (см. рис. 1.1): + +1) открыть словарь примерно на половине страниц и посмотреть, какая буква является первой на этой странице, -- предположим, это буква «m»; + +2) поскольку в алфавите буква «r» идет после «m», исключаем первую по- ловину словаря, и область поиска сужается до второй половины; + +3) продолжаем повторять шаги 1 и 2, пока не найдем страницу, где первой буквой слов будет «r». + +![](ru/docs/assets/media/image52.jpeg) + +> **Рис. 1.1.** Этапы поиска в словаре. Шаг 1 +> +> ![](ru/docs/assets/media/image54.jpeg) + +![](ru/docs/assets/media/image56.jpeg) + +> **Рис. 1.1.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image58.jpeg) + +![](ru/docs/assets/media/image60.jpeg) + +> **Рис. 1.1.** *Окончание*. Шаги 4--5 +> +> Навык поиска в словаре, которым владеет каждый школьник, на самом деле является известным алгоритмом двоичного поиска. С точки зрения структуры данных словарь можно рассматривать как отсортированный массив. С точки зрения алгоритма последовательность операций по поиску в словаре можно считать двоичным поиском. +> +> **Пример 2**: **упорядочивание карт**. Во время игры в карты необходимо каж- дый раз упорядочивать карты в руке от меньшего к большему. Для этого нужно выполнить следующие действия (см. рис. 1.2): + +1) разделить карты на упорядоченную и неупорядоченную части, предпо- лагая, что изначально самая левая карта уже упорядочена; + +2) из неупорядоченной части извлечь одну карту и вставить ее в правиль- ное место в упорядоченной части. После этого две самые левые карты станут упорядоченными; + +3) ![](ru/docs/assets/media/image62.jpeg)повторять шаг 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 руб. + +4. Взять самую крупную из оставшихся купюр в 1 руб. Остаток составит 1 − 1 = 0 руб. + +5. Завершить выдачу сдачи, схема: 20 + 10 + 1 = 31 руб. + +![](ru/docs/assets/media/image64.jpeg) + +> **Рис. 1.3.** Этапы выдачи сдачи +> +> В этих шагах мы на каждом этапе выбираем наилучший вариант (используя купюры наибольшего номинала), в итоге получая оптимальную схему сдачи. С точки зрения структуры данных и алгоритмов этот метод по своей сути яв- ляется жадным алгоритмом. +> +> От приготовления блюда до межзвездных путешествий − решение практи- чески любой задачи неразрывно связано с алгоритмами. Появление компью- теров позволило нам с помощью программирования хранить структуры дан- ных в памяти, а также писать код для вызовов к центральному и графическому процессору для выполнения алгоритмов. Таким образом, мы можем перено- сить задачи из реальной жизни в компьютер, решая различные сложные про- блемы более эффективно. + +#### что такое алгоритм + +1. **Определение алгоритма** + +> *Алгоритм* -- это набор инструкций или шагов, предназначенных для решения конкретной задачи за ограниченное время, обладающий следующими свой- ствами: + +1) задача четко определена, включает ясные определения входных и вы- ходных данных; + +2) обладает осуществимостью, может быть выполнен за ограниченное ко- личество шагов, времени и памяти; + +3) каждый шаг имеет определенное значение, при одинаковых входных данных и условиях выполнения результат всегда будет одинаковым. + +### Определение структуры данных + +> *Структура данных* -- это способ организации и хранения данных, включающий содержимое данных, их взаимосвязи и методы операций с ними. Структура данных преследует следующие цели: + +1) минимизацию занимаемого пространства для экономии памяти ком- пьютера; + +2) максимально быструю обработку данных, включая доступ, добавление, удаление и обновление данных; + +3) обеспечение простого представления данных и логической информации для эффективного выполнения алгоритмов. + +> **Проектирование структуры данных** -- **это процесс, полный компромис- сов**. Если вы хотите улучшить один аспект, часто приходится идти на уступки в другом. Приведем два примера. + +1. Связный список, по сравнению с массивом, более удобен для добавления и удаления данных, но имеет проблемы со скоростью доступа к данным. + +2. Граф, по сравнению со связным списком, предоставляет более богатую логическую информацию, но требует большего объема памяти. + +### Взаимосвязь структур данных и алгоритмов + +> Структуры данных и алгоритмы тесно взаимосвязаны, что проявляется в сле- дующих трех аспектах (см. рис. 1.4): + +1) структуры данных являются основой алгоритмов. Они обеспечивают структурированное хранение данных и методы их обработки; + +2) алгоритмы оживляют структуры данных. Сами по себе структуры дан- ных лишь хранят информацию, но в сочетании с алгоритмами они по- зволяют решать конкретные задачи; + +3) алгоритмы можно реализовать на основе различных структур данных, однако эффективность их выполнения может значительно различаться. Поэтому выбор подходящей структуры данных является ключевым факто- ром. + + + +2. Что такое алгоритм ❖ **31** + +![](ru/docs/assets/media/image66.jpeg) + +> **Рис. 1.4.** Взаимосвязь структур данных и алгоритмов +> +> Структуры данных и алгоритмы подобны конструктору, как показано на рис. 1.5. Комплект конструктора, помимо множества деталей, содержит так- же подробную инструкцию по сборке. Следуя этой инструкции шаг за шагом, можно собрать красивую модель. + +![](ru/docs/assets/media/image67.jpeg){width="5.09092738407699in" height="3.15in"} + +> **Рис. 1.5.** Сборка конструктора +> +> Подробное описание аналогии с конструктором представлено в табл. 1.1. +> +> **Таблица 1.1.** Сравнение структур данных и алгоритмов с конструктором +> +> **Структуры данных и алгоритмы Конструктор** +> +> Входные данные Несобранные детали конструктора +> +> Структура данных Организация деталей конструктора, включая фор- му, размер, способы соединения и т. д. +> +> Алгоритм Последовательность действий по сборке деталей в целевую модель +> +> Выходные данные Собранная модель конструктора +> +> Стоит отметить, что структуры данных и алгоритмы не зависят от языка программирования. Именно поэтому данная книга предлагает их реализации на различных языках. + +#### резюме + +- Алгоритмы повсеместно присутствуют в нашей повседневной жизни и не являются недосягаемыми сложными знаниями. На самом деле мы уже освоили множество алгоритмов, которые помогают решать различ- ные жизненные задачи. + +- Принцип поиска в словаре соответствует алгоритму двоичного поис- ка. Бинарный поиск иллюстрирует важную идею алгоритмов «разделяй и властвуй». + +- Процесс сортировки карт в колоде очень похож на алгоритм сортировки вставками, который хорошо подходит для сортировки небольших набо- ров данных. + +- Процесс размена валюты по своей сути является жадным алгоритмом, в котором на каждом этапе принимается наилучшее на данный момент решение. + +- Алгоритм представляет собой набор инструкций или шагов, предназна- ченных для решения конкретной задачи в ограниченное время, а струк- тура данных -- это способ организации и хранения данных в компьютере. + +- Структуры данных и алгоритмы тесно связаны. Структуры данных явля- ются основой для алгоритмов, а алгоритмы оживляют структуры данных. + +- Структуры данных и алгоритмы можно сравнить с конструктором: де- тали конструктора представляют данные, их форма и способы соедине- ния -- структуры данных, а этапы сборки конструктора соответствуют алгоритмам. + +### Вопросы и ответы + +> 1.3. Резюме ❖ **33** +> +> **Вопрос**. Я программист, и я никогда не использовал алгоритмы для решения задач в своей повседневной работе, поскольку часто используемые алгорит- мы уже встроены в языки программирования и их можно использовать на- прямую. Означает ли это, что задачи, с которыми мы сталкиваемся на работе, не требуют применения алгоритмов? +> +> **Ответ**. Если сравнить конкретные профессиональные навыки с приемами в боевых искусствах, то базовые дисциплины скорее напоминают «внутрен- нюю силу». +> +> Я считаю, что изучение алгоритмов (и других базовых дисциплин) важно не для того, чтобы реализовывать их с нуля в работе, а для того, чтобы на осно- ве полученных знаний принимать профессиональные решения и оценки при решении задач, тем самым повышая общее качество работы. Простой пример: каждый язык программирования имеет встроенные функции сортировки. +> +> Если бы мы не изучали структуры данных и алгоритмы, то, получив любые данные, мы, возможно, просто передали бы их этой функции сортировки. Все работает гладко, производительность хорошая, и на первый взгляд проблем нет. Однако если мы изучили алгоритмы, то знаем, что временная сложность встроенной функции сортировки составляет *O*(*n* log *n*). Если же данные пред- ставлены целыми числами фиксированной разрядности (например, номерами студентов), то можно использовать более эффективный метод поразрядной сортировки, снизив временную сложность до *O*(*nk*), где *k* -- это количество раз- рядов. При больших объемах данных экономия времени выполнения может привести к значительным преимуществам, таким как снижение затрат и улуч- +> +> шение пользовательского опыта. +> +> В инженерной практике множество задач трудно решить оптимальным об- разом, и многие из них решаются «как-то». Сложность задачи зависит как от ее природы, так и от уровня знаний и опыта человека, который ее анализирует. Чем более полными знаниями и большим опытом обладает человек, тем глубже он может проанализировать проблему и тем изящнее может быть ее решение. +> +> Глава 2 diff --git a/ru/chapters/chapter_02.md b/ru/chapters/chapter_02.md new file mode 100644 index 000000000..3eda7d0da --- /dev/null +++ b/ru/chapters/chapter_02.md @@ -0,0 +1,1081 @@ +# Анализ сложности + +![](ru/docs/assets/media/image68.jpeg){width="3.71875656167979in" height="4.8125in"} + +> 2.1. Оценка эффективности алгоритмов ❖ **35** + +#### Оценка эффективности алгоритмов + +> В процессе разработки алгоритмов мы стремимся к достижению следующих целей: + +1) **найти решение задачи**: алгоритм должен надежно находить правиль- ное решение задачи в заданных пределах входных данных; + +2) **найти оптимальное решение**: для одной и той же задачи может суще- ствовать несколько решений, и мы стремимся найти максимально эф- фективный алгоритм. + +> Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта: + +1) **временную эффективность**: продолжительность выполнения алго- ритма; + +2) **пространственную эффективность**: объем памяти, занимаемой алго- ритмом. + +> В двух словах, **наша цель -- разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процес- сом их разработки и оптимизации. +> +> Методы оценки эффективности делятся на два типа: практическое тестиро- вание и теоретическую оценку. + +1. **Практическое тестирование** + +> Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же зада- чу, и необходимо сравнить их эффективность. Самый прямой метод -- это за- пустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения. +> +> С одной стороны, **сложно исключить влияние факторов тестовой сре- ды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных процессорах. Если алгоритм интен- сивно использует память, его производительность будет выше на высоко- производительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, и потребуется тестирова- ние на различных платформах для получения средней эффективности, что крайне затруднительно. +> +> С другой стороны, **проведение полного тестирования требует значи- тельных ресурсов**. С изменением объема входных данных алгоритмы демон- стрируют разную эффективность. Например, при небольшом объеме данных алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме данных результат может быть противоположным. Следовательно, для полу- чения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов. + +### Теоретическая оценка + +> Из-за значительных ограничений практического тестирования можно рас- смотреть возможность оценки эффективности алгоритмов только с помощью математических расчетов. Этот метод называется анализом асимптотической сложности или просто анализом сложности. +> +> Анализ сложности позволяет отразить зависимость между ресурсами вре- мени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и простран- ства, необходимых для выполнения алгоритма, по мере увеличения раз- мера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента. + +1. Ресурсы времени и пространства соответствуют временной сложности и пространственной сложности. + +2. «По мере увеличения размера входных данных» означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных. + +3. Тенденция роста времени и пространства указывает, что анализ слож- ности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста. + +> **Анализ сложности преодолевает недостатки метода практического те- стирования**, что выражается в следующих аспектах: + +1) он не требует фактического выполнения кода, что делает его более эко- логичным и энергосберегающим; + +2) он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения; + +3) он может продемонстрировать эффективность алгоритма при различ- ных объемах данных, особенно при больших объемах. + +> Анализ сложности предоставляет нам мерило оценки эффективности ал- горитмов, позволяя измерять время и ресурсы, необходимые для выполне- ния конкретного алгоритма, а также сравнивать эффективность различных алгоритмов. +> +> Сложность -- это математическое понятие, которое новичкам может по- казаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуж- дая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти. +> +> Таким образом, перед погружением в изучение структур данных и алго- ритмов рекомендуется получить базовое представление об анализе слож- ности, чтобы иметь возможность выполнять хотя бы базовую оценку их эф- фективности. + +#### итерация и рекурсия + +> В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к об- суждению временной и пространственной сложности, рассмотрим, как реа- лизовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию. + +### Итерации + +> *Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение опреде- ленного участка кода, пока выполняется определенное условие. + +##### Цикл 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*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в сле- дующем разделе. +> +> ![](ru/docs/assets/media/image70.png) + +##### Цикл while + +> **Рис. 2.1.** Блок-схема функции суммирования +> +> Подобно циклу 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 можно свободно управлять инициализацией и обнов- лением условной переменной. +> +> Например, в следующем коде условная переменная *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 **более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи. + +##### Вложенные циклы + +> Внутрь одной циклической структуры можно вложить другую, например используя два цикла 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 +> +> В этом случае количество выполненных действий пропорционально *n*2, или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости. +> +> Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической за- висимости, зависимости четвертой степени и т. д. +> +> ![](ru/docs/assets/media/image72.png) + +### Рекурсия + +> **Рис. 2.2.** Блок-схема вложенного цикла +> +> *Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа. + +1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения. + +2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя ре- зультаты каждого уровня. + +> С точки зрения реализации рекурсивный код включает три основных элемента. + +1. **Условие завершения**: используется для определения момента перехода от вызова к возврату. + +2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами. + +3. **Возврат результата**: соответствует возврату, возвращает результат те- кущего уровня рекурсии на предыдущий уровень. + +> Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*. +> +> \# === File: recursion.py === +> +> def recur(n: int) -\> int: \"\"\" Рекурсия.\"\"\" +> +> \# Условие завершения. if n == 1: +> +> return 1 +> +> \# Вызов: рекурсивный вызов. res = recur(n - 1) +> +> \# Возврат: возврат результата. return n + res + +![](ru/docs/assets/media/image74.png) + +> **Рис. 2.3.** Рекурсивный вызов функции суммирования +> +> Хотя с точки зрения вычислений итерация и рекурсия могут давать оди- наковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач. + +- **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи. + +- **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная за- дача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно). + +> Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*. + +- **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы полу- чить итоговое значение *f*(*n*). + +- **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) = + +> *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1. + +##### Стек вызовов + +> Каждый раз, когда рекурсивная функция вызывает саму себя, система выделя- ет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия. + +1. Контекстные данные функции хранятся в области памяти, называемой про- странством стекового кадра, и освобождаются только после возврата функ- ции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**. + +2. Рекурсивный вызов функции создает дополнительные накладные расходы. + +###### Поэтому рекурсия обычно менее эффективна по времени, чем цикл. + +> До срабатывания условия завершения одновременно существует *n* невоз- вращенных рекурсивных функций, как показано на рис. 2.4. Число *n* называет- ся глубиной рекурсии. + +![](ru/docs/assets/media/image76.jpeg) + +> **Рис. 2.4.** Глубина рекурсивного вызова +> +> На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека. + +##### Хвостовая рекурсия + +> Интересно, что если рекурсивный вызов происходит на последнем шаге перед возвратом функции, то компилятор или интерпретатор может оптимизиро- вать этот вызов, сделав его по эффективности использования памяти сопоста- вимым с итерацией. Это называется хвостовой рекурсией. + +- **Обычная рекурсия**: когда функция возвращается на предыдущий уро- вень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова. + +- **Хвостовая рекурсия**: рекурсивный вызов является последней операци- ей перед возвратом функции, что означает, что после возврата на преды- дущий уровень не требуется выполнять другие операции, поэтому систе- ме не нужно сохранять контекст предыдущей функции. + +> В качестве примера вычисления суммы 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. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения опе- рации суммирования у них различается. + +- **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить опера- цию суммирования. + +- **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, процесс возврата требует только последовательного возврата. + +![](ru/docs/assets/media/image78.jpeg) + +> **Рис. 2.5.** Процесс выполнения хвостовой рекурсии + +##### Дерево рекурсии + +> При решении задач, связанных с алгоритмами типа «разделяй и властвуй», ре- курсия зачастую оказывается более интуитивной и читабельной, чем итера- ция. Рассмотрим в качестве примера последовательность Фибоначчи. +> +> Обозначив *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. +> +> По своей сути рекурсия отражает парадигму мышления «разбиение зада- чи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной. +> +> С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое программирование, прямо или косвенно используют этот подход. +> +> С точки зрения **структур данных** рекурсия естественно подходит для реше- ния задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй». +> +> ![](ru/docs/assets/media/image79.jpeg){width="5.095693350831146in" height="2.08in"} +> +> **Рис. 2.6.** Рекурсивное дерево последовательности Фибоначчи + +### Сравнение + +> Подводя итог, можно сказать, что итерация и рекурсия различаются по реали- зации, производительности и применимости, как показано в табл. 2.1. +> +> **Таблица 2.1.** Сравнение итерации и рекурсии + ++-----------------------+---------------------------------+-----------------------------------------+ +| | > **Итерация** | > **Рекурсия** | ++=======================+=================================+=========================================+ +| > Способ реализации | > Циклическая структура | > Функция вызывает саму себя | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Временная | > Обычно высокая | > Каждый вызов функции создает | ++-----------------------+---------------------------------+-----------------------------------------+ +| > эффективность | > эффективность, нет | > затраты | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > затрат на вызов функции | | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Использование | > Обычно используется | > Накопление вызовов функции может | ++-----------------------+---------------------------------+-----------------------------------------+ +| > памяти | > фиксированный объем | > использовать значительное количество | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > памяти | > пространства стека | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Сфера использования | > Подходит для простых | > Подходит для разбиения на подзадачи; | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > циклических задач, код | > для структур деревья, графы; алгорит- | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > интуитивно понятен | > мов «разделяй и властвуй», возврат | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > [и]{.smallcaps} хорошо читаем | > и т. д.; структура кода проста и ясна | ++-----------------------+---------------------------------+-----------------------------------------+ + +> Какова же внутренняя связь между итерацией и рекурсией? В рассмотрен- ном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу сте- ка «первым пришел -- последним вышел»**. +> +> Фактически такие термины рекурсии, как «вызов стека» и «пространство стекового кадра», уже намекают на тесную связь между рекурсией и стеком. + +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) для некоторых сложных задач моделирование поведения системного вызова стека может быть крайне сложным. + +> В общем, выбор между итерацией и рекурсией зависит от природы конкрет- ной задачи. В программной практике крайне важно взвешивать преимуще- ства и недостатки обоих подходов и выбирать наиболее подходящий метод в зависимости от ситуации. + +#### временная сложность + +> *Время выполнения* является наглядным и точным показателем эффективности алгоритма. Что же нам нужно сделать, чтобы точно оценить время выполне- ния кода? + +1. **Определить платформу выполнения**, включая аппаратную конфи- гурацию, язык программирования, системную среду и т. д., так как эти факторы влияют на эффективность выполнения кода. + +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 нс. +> +> Согласно вышеописанному методу можно определить, что время выполне- ния алгоритма равно (6*n* + 12) нс: +> +> 1 + 1 + 10 + (1 + 5) × *n* = 6*n* + 12. +> +> Однако на практике подсчет времени выполнения алгоритма не является ни разумным, ни реалистичным. Во-первых, мы не хотим связывать оценочное время с конкретной платформой выполнения, так как алгоритм должен рабо- тать на различных платформах. Во-вторых, очень трудно узнать время выпол- нения каждой операции, и это значительно усложняет процесс оценки. + +### Тенденция роста времени + +> Анализ временной сложности не оценивает время выполнения алгоритма, **а исследует тенденцию роста времени выполнения алгоритма по мере увеличения объема данных**. +> +> Понятие «тенденция роста времени» довольно абстрактно, и для его лучше- го понимания рассмотрим пример. Предположим, что размер входных данных равен *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) +> +> На рис. 2.7 изображена схема временной сложности функций этих трех ал- горитмов. + +- Алгоритм A содержит только одну операцию печати, и время выполне- ния алгоритма не увеличивается с ростом *n*. Временная сложность этого алгоритма называется константной. + +- В алгоритме B операция печати выполняется в цикле *n* раз, и время вы- полнения алгоритма увеличивается линейно с ростом *n*. Временная сложность этого алгоритма называется линейной. + +- В алгоритме C операция печати выполняется в цикле 1 000 000 раз, и, хотя время выполнения очень долгое, оно не зависит от размера входных данных *n*. Поэтому временная сложность C такая же, как у A, и остается константной. + +![](ru/docs/assets/media/image81.jpeg) + +> **Рис. 2.7.** Тенденция роста времени для алгоритмов A, B и C +> +> Какие особенности имеет анализ временной сложности по сравнению с пря- мым подсчетом времени выполнения алгоритма? + +- **Временная сложность позволяет эффективно оценить эффектив- ность алгоритма**. Например, время выполнения алгоритма B увеличи- вается линейно, и при *n* \> 1 он медленнее алгоритма A, а при *n* \> 1 000 000 медленнее алгоритма C. На самом деле, если размер входных данных *n* достаточно велик, алгоритм с константной сложностью всегда будет луч- ше, чем алгоритм с линейной сложностью, и это и есть суть тенденции роста времени. + +- **Метод вычисления временной сложности проще**. Очевидно, что платформа выполнения и типы вычислительных операций не связаны с тенденцией роста времени выполнения алгоритма. Поэтому в анали- зе временной сложности можно просто считать, что время выполнения всех вычислительных операций одинаково и равно единичному време- ни, что позволяет упростить статистику времени выполнения вычисли- тельных операций до статистики количества вычислительных операций, значительно снижая сложность оценки. + + - **Однако временная сложность имеет определенные ограничения**. Например, хотя временная сложность алгоритмов A и C одинакова, их фак- тическое время выполнения значительно отличается. Аналогично, хотя временная сложность алгоритма B выше, чем у C, при малых значениях *n* алгоритм B явно лучше C. В таких случаях часто трудно оценить эффек- тивность алгоритма только по временной сложности. Тем не менее, не- смотря на эти проблемы, анализ сложности остается наиболее эффектив- ным и распространенным методом оценки эффективности алгоритмов. + +### Асимптотическая верхняя граница функции + +> Пусть дан входной размер *n* для функции: +> +> def algorithm(n: int): a = 1 \# +1. +> +> a = a + 1 \# +1. +> +> a = a \* 2 \# +1. +> +> \# Цикл n итераций. +> +> for i in range(n): \# +1. print(0) \# +1. +> +> Пусть *T*(*n*) -- это количество операций алгоритма, являющееся функцией от размера входных данных *n*. Тогда количество операций для вышеуказанной функции равно: +> +> *T*(*n*) = 3 + 2*n*. +> +> *T*(*n*) является линейной функцией, что указывает на линейную тенденцию роста времени выполнения, следовательно, у алгоритма временная сложность линейного порядка. +> +> Линейная временная сложность обозначается как *O*(*n*), этот математиче- ский символ называется «О» большое и представляет асимптотическую верх- нюю границу функции *T*(*n*). +> +> Анализ временной сложности, по сути, является вычислением асимптоти- ческой верхней границы количества операций *T*(*n*), которая имеет четкое ма- тематическое определение. +> +> Вычисление асимптотической верхней границы заключается в нахождении функции *f*(*n*) − такой, что при стремлении *n* к бесконечности *T*(*n*) и *f*(*n*) нахо- дятся на одном уровне роста, отличаясь лишь на множитель константы *c*, как показано на рис. 2.8. +> +> ![](ru/docs/assets/media/image83.jpeg) +> +> **Рис. 2.8.** Асимптотическая верхняя граница функции + +### Методы вычисления + +> Асимптотическая верхняя граница является абстрактным математическим понятием, и, если вы не полностью понимаете его, не стоит беспокоиться. Можно сначала освоить методику вывода, и в процессе практики постепенно осознать его математическое значение. +> +> Согласно определению, если найти функцию *f*(*n*), можно получить времен- ную сложность *O*(*f*(*n*)). Как же определить асимптотическую верхнюю грани- цу *f*(*n*)? В общем случае это делается в два этапа: сначала подсчитывается ко- личество операций, затем определяется асимптотическая верхняя граница. + +##### Подсчет количества операций + +> Для кода это делается путем построчного подсчета сверху вниз. Однако, по- скольку константа *c* в выражении *c* × *f*(*n*) может принимать любое значение, **все коэффициенты и константы в** *T*(*n*) **можно игнорировать**. На основе этого принципа можно сформулировать следующие упрощенные приемы подсчета: + +1) **игнорирование констант в** *T*(*n*). Они не зависят от *n* и поэтому не вли- яют на временную сложность; + +2) **опускание всех коэффициентов**. Например, циклы из 2*n* итераций, 5*n* + +> \+ 1 итераций и т. д. можно упростить до *n* итераций, поскольку коэффи- циенты перед *n* не влияют на временную сложность; + +3) **при вложенных циклах используется умножение**. Общее количество операций равно произведению количества операций внешнего и вну- треннего циклов, при этом для каждого уровня цикла можно применять приемы 1 и 2. + +> Следующую функцию можно использовать для подсчета количества опера- ций с помощью вышеуказанных приемов: +> +> 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*) = 2*n*(*n* + 1) + (5*n* + 1) + 2 Полный подсчет (-.-\|\|\|) +> +> = 2*n*2 + 7*n* + 3 +> +> *T*(*n*) = *n*2 + *n* Упрощенный подсчет (o.O) + +##### Определение асимптотической верхней границы + +> Временная сложность определяется старшей степенью в *T*(*n*). Это связано с тем, что при стремлении *n* к бесконечности старшая степень будет играть до- минирующую роль, а влиянием других членов можно пренебречь. +> +> В табл. 2.2 приведены некоторые гипертрофированные примеры, иллю- стрирующие вывод о том, что «коэффициенты не могут изменить поря- док». Когда *n* стремится к бесконечности, эти константы становятся несу- щественными. +> +> **Таблица 2.2.** Временная сложность для различных количеств операций +> +> **Количество операций** *T***(***n***) Временная сложность** *O***(***f***(***n***))** + +100 000 *O*(1) + +3*n* + 2 *O*(*n*) + +2*n*2 + 3*n* + 2 *O*(*n*2) + +*n*3 + 10000*n*2 *O*(*n*3) + +2*n* + 10000*n*10000 *O*(2*n*) + +### Основные типы + +> Пусть размер входных данных равен *n*, основные типы временной сложности показаны на рис. 2.9 (в порядке от низшего к высшему). +> +> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n* log *n*) \< *O*(*n*2) \< *O*(2*n*) \< *O*(*n*!) +> +> Константная \< Логарифмическая \< Линейная \< Линейно- логарифмическая \< Квадратичная \< Экспоненциальная \< Факториальная +> +> **От низшего к высшему,** +> +> **от лучшего к худшему** + +##### Константная сложность *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 + +##### Линейная сложность *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* -- длина массива или списка. +> +> \# === File: time_complexity.py === +> +> def array_traversal(nums: list\[int\]) -\> int: \"\"\" Линейный порядок (обход массива).\"\"\" count = 0 +> +> \# Количество циклов пропорционально длине массива. for num in nums: +> +> count += 1 return count +> +> Следует отметить, что **размер входных данных *n* необходимо опреде- лять в зависимости от типа входных данных**. Например, в первом при- мере переменная *n* обозначает размер входных данных; во втором примере размер данных определяется длиной массива *n*. + +##### Квадратичная сложность *O*(*n*2) + +> Количество операций *квадратичной сложности* растет с квадратом размера входных данных *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 приведено сравнение трех видов временной сложности: кон- стантной, линейной и квадратичной. + +![](ru/docs/assets/media/image87.jpeg) + +> **Рис. 2.10.** Временная сложность константного, линейного и квадратичного порядка +> +> В качестве примера рассмотрим пузырьковую сортировку: внешний цикл выполняется *n* -- 1 раз, внутренний цикл выполняется *n* -- 1, *n* -- 2, \..., 2, 1 раз, в среднем *n*/2 раз, поэтому временная сложность составляет O((*n* − 1)*n*/2) = O(*n*2). +> +> \# === 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 + +##### Экспоненциальная сложность *O*(2*n*) + +> Деление клеток в биологии является типичным примером *экспоненциально- го роста*: исходное состояние -- 1 клетка, после одного цикла деления их ста- новится 2, после двух циклов -- 4 и т. д. После *n* циклов деления получается 2*n* клеток. +> +> В следующем коде моделируется процесс деления клеток, временная слож- ность этого алгоритма составляет *O*(2*n*), см. рис. 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 +> +> ![](ru/docs/assets/media/image89.jpeg) +> +> **Рис. 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 +> +> Экспоненциальный рост является очень быстрым и часто встречается в ме- тодах полного перебора (грубая сила, возврат и т. д.). Для задач с большим объ- емом данных экспоненциальная сложность неприемлема, обычно требуется использование динамического программирования или жадных алгоритмов. + +##### Логарифмическая сложность *O*(log *n*) + +> В отличие от экспоненциальной, *логарифмическая сложность* отражает прин- цип «каждый цикл сокращается вдвое». Пусть размер входных данных равен *n*, поскольку каждый цикл сокращается вдвое, количество циклов равно log2 *n*, т. е. обратной функции к 2n. +> +> В следующем коде моделируется принцип «каждый цикл сокращается вдвое», временная сложность которого составляет *O*(log2 *n*), или сокращенно *O*(log *n*), см. рис. 2.12. +> +> \# === 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** +> +> **Временная сложность Логарифмическая O(log n)** +> +> ![](ru/docs/assets/media/image91.png)**Рис. 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 +> +> Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии «разделяй и властвуй», отражая идеи разделения на части и упро- щения сложного. Она растет медленно и является идеальной временной слож- ностью после константной. + +##### Линейно-логарифмическая сложность *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*), например быстрой сортировки, сортировки слиянием, пирамидаль- ной сортировки и т. д. +> +> ![](ru/docs/assets/media/image93.jpeg) +> +> **Рис. 2.13.** Временная сложность линейно-логарифмического порядка + +##### Факториальная сложность *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 +> +> ![](ru/docs/assets/media/image95.jpeg) +> +> **Рис. 2.14.** Временная сложность факториала +> +> Следует отметить, что, поскольку при *n* ≥ 4 всегда выполняется *n*! \> 2*n*, фак- ториал растет быстрее, чем экспоненциальная функция, и при больших *n* он становится неприемлемым для практического использования. + +### Худшая, лучшая и средняя временная сложность + +> **Эффективность алгоритма во времени часто не является фиксирован- ной**, **а зависит от распределения входных данных**. Предположим, что на вход подается массив 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 в случайном порядке.\"\"\" +> +> \# Генерация массива 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*). +> +> Однако для более сложных алгоритмов вычисление средней временной сложности часто затруднительно, так как сложно проанализировать общее ма- тематическое ожидание при распределении данных. В таких случаях обычно используется худшая временная сложность в качестве критерия оценки эф- фективности алгоритма. + +#### пространственная сложность + +> Пространственная сложность используется для измерения тенденции роста занимаемой алгоритмом памяти по мере увеличения объема данных. Это по- нятие очень похоже на временную сложность, только вместо времени выпол- нения рассматривается занимаемая память. + +### Пространство алгоритма + +> Память, используемая алгоритмом в процессе выполнения, главным образом включает следующие виды. + +- **Входное пространство**: используется для хранения входных данных алгоритма. + +- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных в процессе выполнения алгоритма. + +- **Выходное пространство**: используется для хранения выходных данных алгоритма. + +> Как правило, пространственная сложность рассчитывается на основе вре- менного пространства и выходного пространства. +> +> Временное пространство можно, в свою очередь, разделить на три части. + +- **Временные данные**: используются для сохранения различных кон- стант, переменных, объектов и т. д. в процессе выполнения алгоритма. + +- **Пространство стека**: используется для сохранения контекстных дан- ных вызываемой функции. При каждом вызове функции система соз- дает фрейм стека на вершине стека, который освобождается после воз- врата функции. + +- **Пространство инструкций**: используется для хранения скомпилиро- ванных инструкций программы, в реальной статистике обычно игнори- руется. + +> При анализе пространственной сложности программы обычно учитывают- ся **временные данные**, **пространство стека и выходные данные**, как по- казано на рис. 2.15. + +![](ru/docs/assets/media/image97.jpeg) + +> **Рис. 2.15.** Пространство, используемое алгоритмом +> +> Ниже приведен пример кода. +> +> 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 \# Выходные данные. + +### Методы вычисления + +> Методы вычисления пространственной сложности аналогичны методам вы- числения временной сложности, только объект статистики изменяется с коли- чества операций на размер используемого пространства. +> +> В отличие от временной сложности **обычно учитывается только наихуд- шая пространственная сложность**. Это связано с тем, что память является жестким требованием, и необходимо убедиться, что для всех входных данных будет зарезервировано достаточное количество памяти. +> +> В следующем примере кода наихудшая пространственная сложность может иметь два значения. + +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) +> +> **В рекурсивных функциях необходимо учитывать статистику про- странства стека.** Рассмотрим следующий код. +> +> 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*). + +### Основные типы + +> Пусть размер входных данных равен *n*, на рис. 2.16 показаны основные типы пространственной сложности (в порядке возрастания). +> +> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n*2) \< *O*(2*n*) +> +> Константная \< Логарифмическая \< Линейная \< Квадратичная \< Экспоненциальная +> +> **От низкой к высокой,** +> +> **от лучшей к худшей** + +##### Константная сложность *O*(1) + +> *Константная сложность* обычно встречается у констант, переменных, объек- тов, количество которых не зависит от размера входных данных *n*. +> +> Следует отметить, что память, занимаемая инициализацией переменных или вызовом функций в цикле, освобождается при переходе к следующему циклу, поэтому она не накапливается, и пространственная сложность остается *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() + +##### Линейная сложность *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) +> +> ![](ru/docs/assets/media/image101.jpeg) +> +> **Рис. 2.17.** Линейная пространственная сложность, создаваемая рекурсивной функцией + +##### Квадратичная сложность *O*(*n*2) + +> *Квадратичная сложность* часто встречается у матриц и графов, количество элементов в которых пропорционально квадрату *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*(*n*2) простран- ства, как показано на рис. 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) +> +> ![](ru/docs/assets/media/image103.jpeg) +> +> **Рис. 2.18.** Квадратичная пространственная сложность рекурсивной функции + +##### Экспоненциальная сложность *O*(2*n*) + +> *Экспоненциальная сложность* часто встречается в двоичных деревьях. Количе- ство узлов в полном двоичном дереве с *n* уровнями равно 2*n* − 1, что занимает *O*(2*n*) пространства, как показано на рис. 2.19. +> +> \# === File: space_complexity.py === +> +> def build_tree(n: int) -\> TreeNode \| None: +> +> \"\"\" Экспоненциальная сложность (создание полного двоичного дерева).\"\"\" if n == 0: +> +> return None root = TreeNode(0) +> +> ![](ru/docs/assets/media/image105.jpeg)root.left = build_tree(n - 1) root.right = build_tree(n - 1) return root +> +> **Рис. 2.19.** Экспоненциальная пространственная сложность полного двоичного дерева + +##### Логарифмическая сложность *O*(log *n*) + +> *Логарифмическая сложность* часто встречается в алгоритмах типа «разделяй и властвуй». Например, в сортировке слиянием, где входной массив длиной *n* на каждой итерации рекурсивно делится пополам и формирует рекурсивное дерево высотой log *n*, используется *O*(log *n*) пространства для стека вызовов. +> +> Или, например, при преобразовании числа в строку если входное значение является положительным целым числом *n*, то количество его цифр равно ⌊log10 *n* ⌋ + 1, что соответствует длине строки, и поэтому пространственная сложность равна *O*(log10 *n* + 1) = *O*(log *n*). + +### 2.4.4 Баланс времени и пространства + +> В идеальных условиях мы стремимся к тому, чтобы временная и простран- ственная сложности алгоритма были оптимальными. Однако на практике од- новременно оптимизировать обе сложности зачастую очень затруднительно. +> +> **Снижение временной сложности обычно требует увеличения про- странственной сложности, и наоборот**. Будем называть подход, при кото- ром нужно пожертвовать памятью для увеличения скорости выполнения ал- горитма, обменом пространства на время. Обратный подход будем называть обменом времени на пространство. +> +> Выбор подхода зависит от того, какой аспект для нас более важен. В боль- шинстве случаев время ценнее пространства, поэтому обмен пространства на время является более распространенной стратегией. Конечно, при больших объемах данных контроль пространственной сложности также очень важен. + +#### резюме + +##### Ключевые моменты + +###### Оценка эффективности алгоритмов: + +- временная и пространственная эффективность являются двумя основ- ными критериями для оценки качества алгоритмов; + +- эффективность алгоритмов можно оценивать с помощью реальных те- стов, однако это сложно из-за влияния тестовой среды и значительных + +> затрат вычислительных ресурсов; + +- анализ сложности позволяет устранить недостатки реальных тестов, ре- зультаты анализа применимы ко всем платформам и могут выявить эф- фективность алгоритма при различных объемах данных. + +###### Временная сложность: + +- временная сложность используется для оценки тенденции изменения времени выполнения алгоритма с увеличением объема данных, что по- зволяет оценивать его эффективность. Однако в некоторых случаях она может работать не так хорошо, например когда объем входных данных мал или временная сложность одинакова, что не позволяет точно срав- нить эффективность алгоритмов; + + + +- худшая временная сложность обозначается символом *O* и соответствует асимптотической верхней границе, отражая уровень роста количества операций *T*(*n*) при стремлении *n* к бесконечности; + +- определение временной сложности включает два этапа: сначала подсчи- тывается количество операций, затем определяется асимптотическая верхняя граница; + +- наиболее распространенные временные сложности от низкой к высо- + +> кой: *O*(1), *O*(log *n*), *O*(*n*), *O*(*n* log *n*), *O*(*n*2), *O*(2*n*) и *O*(*n*!); + +- временная сложность некоторых алгоритмов не является фиксирован- ной и зависит от распределения входных данных. Временная сложность делится на худшую, лучшую и среднюю. Лучшая временная сложность почти не используется, так как для достижения лучшего случая входные данные должны соответствовать строгим критериям; + +- средняя временная сложность отражает эффективность алгоритма при случайных входных данных и наиболее близка к реальной производитель- ности алгоритма. Для расчета средней временной сложности необходимо учитывать распределение входных данных и математическое ожидание. + +###### Пространственная сложность: + +- пространственная сложность аналогична временной сложности и ис- пользуется для оценки тенденции изменения объема памяти, занимае- мой алгоритмом, с увеличением объема данных; + +- память, используемая в процессе выполнения алгоритма, можно разде- лить на входное пространство, временное пространство и выходное про- странство. Обычно при расчете пространственной сложности входное пространство не учитывается. Временное пространство делится на вре- менные данные, пространство стека и пространство инструкций, при- чем пространство стека обычно влияет на пространственную сложность только в рекурсивных функциях; + +- обычно рассматривается только худшая пространственная сложность, т. е. пространственная сложность алгоритма при худших входных дан- ных и в худший момент выполнения; + +- наиболее распространенные пространственные сложности от низкой + +> к высокой: *O*(1), *O*(log *n*), *O*(*n*), *O*(n2) и *O*(2n). + +##### Вопросы и ответы + +> **Вопрос**. Пространственная сложность хвостовой рекурсии равна *O*(1)? +> +> **Ответ**. Теоретически пространственную сложность хвостовой рекурсии можно оптимизировать до *O*(1). Однако большинство языков программиро- вания (например, Java, Python, C++, Go, C# и др.) не поддерживают автомати- ческую оптимизацию хвостовой рекурсии, поэтому обычно считается, что ее пространственная сложность равна *O*(*n*). +> +> **Вопрос**. В чем разница между терминами «функция» и «метод»? +> +> **Ответ**. Функция может выполняться независимо, все параметры передают- ся явно. Метод связан с объектом и неявно передается вызывающему его объ- екту. Он может оперировать данными, содержащимися в экземпляре класса. +> +> Ниже приведены примеры из нескольких распространенных языков про- граммирования: + +- язык C является процедурным языком и не имеет концепции объек- тно ориентированного программирования, поэтому в нем есть только функции. Однако можно создать структуры, чтобы имитировать клас- сы. Функции, связанные со структурами, будут эквивалентны методам в других языках программирования; + +- Java и C# являются объектно ориентированными языками программи- рования, и блоки кода (методы) обычно являются частью какого-либо класса. Статические методы ведут себя как функции, так как они при- вязаны к классу и не могут обращаться к конкретным свойствам экзем- пляра; + +- C++ и Python поддерживают как процедурное программирование (функ- ции), так и объектно ориентированное программирование (методы). + +> **Вопрос**. Отражает ли схема «Типы пространственной сложности» абсолют- ный размер занимаемого пространства? +> +> **Ответ**. Нет, данная схема демонстрирует пространственную сложность, отра- жающую тенденцию роста, а не абсолютный размер занимаемого пространства. Предположим, для *n* = 8 вы можете заметить, что значения каждой кривой не соответствуют значениям функции. Это происходит потому, что каждая кривая содержит постоянную составляющую, которая используется для сжа- +> +> тия диапазона значений до визуально комфортного уровня. +> +> На практике, поскольку обычно постоянная составляющая метода неизвест- на, невозможно выбрать оптимальное решение только на основе сложности при *n* = 8. Однако при *n* = 85 выбор становится очевидным, так как тенденция роста уже доминирует. +> +> Глава 3 diff --git a/ru/chapters/chapter_03.md b/ru/chapters/chapter_03.md new file mode 100644 index 000000000..b1a48956f --- /dev/null +++ b/ru/chapters/chapter_03.md @@ -0,0 +1,797 @@ +# Структуры данных + +![](ru/docs/assets/media/image106.jpeg){width="3.630197944006999in" height="4.697916666666667in"} + +#### классификация структур данных + +> К распространенным структурам данных относятся массивы, списки, стеки, очереди, хеш-таблицы, деревья, кучи, графы. Их можно классифицировать по двум измерениям: логической структуре и физической структуре. + +1. **Логическая структура: линейная и нелинейная** + +> **Логическая структура раскрывает логические отношения между элемента- ми данных**. В массивах и списках данные расположены в определенном порядке, что отражает линейные отношения между данными. В деревьях данные располо- жены сверху вниз по уровням, что демонстрирует отношения «предок» и «пото- мок». Графы состоят из узлов и ребер, отражая сложные сетевые отношения. +> +> Логические структуры делятся на две большие категории: линейные и не- линейные, как показано на рис. 3.1. Линейные структуры более интуитивно понятны, поскольку в них данные расположены линейно и логически связаны. Нелинейные структуры, наоборот, представляют собой нелинейное располо- жение элементов данных. + +- **Линейные структуры данных**: массивы, списки, стеки, очереди, хеш- таблицы, в которых элементы связаны последовательно один к одному. + +- **Нелинейные структуры данных**: деревья, кучи, графы, хеш-таблицы. + +> Нелинейные структуры данных можно дополнительно разделить на древо- видные и сетевые. + +- **Древовидные структуры**: деревья, кучи, хеш-таблицы, в которых эле- менты связаны один ко многим. + +- **Сетевые структуры**: графы, в которых элементы связаны многие ко многим. + +![](ru/docs/assets/media/image108.jpeg) + +> **Рис. 3.1.** Линейные и нелинейные структуры данных + +### Физическая структура: + +> **непрерывная и разреженная** +> +> **Во время выполнения программы обрабатываемые данные в основном хранятся в памяти**. На рис. 3.2 изображен модуль оперативной памяти ком- пьютера, где каждый черный чип содержит определенный участок памяти. Память можно представить как огромную таблицу Excel, где каждая ячейка может хранить данные определенного размера. +> +> **Система обращается к данным в целевой позиции через адреса памя- ти**. Компьютер присваивает каждой ячейке таблицы номер по определенным правилам, чтобы обеспечить уникальный адрес памяти для каждого участка, как показано на рис. 3.2. Благодаря этим адресам программа может обращать- ся к данным в памяти. +> +> ![](ru/docs/assets/media/image110.jpeg)Доступное простран- ство памяти +> +> Используемое пространство памяти +> +> Обращение к адресу памяти 0x0012, получение данных +> +> Обращение к адресу памяти 0x0024, получение данных +> +> **Рис. 3.2.** Модуль памяти, пространство памяти, адреса памяти +> +> Память является общим ресурсом для всех программ, и когда участок памя- ти занят одной программой, он обычно не может быть одновременно исполь- зован другими программами. **Поэтому в процессе проектирования струк- тур данных и алгоритмов память занимает важное место**. Например, пи- ковое использование памяти алгоритмом не должно превышать оставшуюся свободную память системы. Если не хватает непрерывных больших участков памяти, выбранная структура данных должна уметь располагаться в разре- женных участках памяти. +> +> **Физическая структура отражает способ хранения данных в памяти компьютера** и делится на хранение в непрерывном пространстве (массивы) и хранение в разреженном пространстве (списки), как показано на рис. 3.3. Физическая структура на низком уровне определяет методы доступа, обновле- ния, добавления и удаления данных. Обе физические структуры демонстриру- ют взаимодополняющие характеристики в отношении временной и простран- ственной эффективности. +> +> ![](ru/docs/assets/media/image112.jpeg)**Непрерывное хранение** +> +> Память для хранения **массива** +> +> является непрерывной +> +> **Распределенное хранение** +> +> Память для хранения **связного списка** +> +> является распределенной +> +> Доступное пространство памяти Память для хранения массива +> +> **Хранение значения узла Хранение** +> +> **указателя узла** +> +> Память для хранения +> +> **узлов связного списка** +> +> **Рис. 3.3.** Хранение в непрерывном и в разреженном пространстве +> +> Следует отметить, что **все структуры данных реализуются на основе массивов, связных списков или их комбинации**. Например, стек и очередь можно реализовать как с использованием массивов, так и с использованием связных списков. Реализация хеш-таблицы может включать как массивы, так и связные списки. + +- **На основе массивов можно реализовать**: стек, очередь, хеш-таблицу, дерево, кучу, граф, матрицу, тензор (массив размерностью ≥ 3) и др. + +- **На основе связных списков можно реализовать**: стек, очередь, хеш- таблицу, дерево, кучу, граф и др. + +> Связный список после инициализации может изменять свою длину в про- цессе выполнения программы, поэтому его также называют динамической структурой данных. Массив после инициализации имеет неизменную длину, поэтому его называют статической структурой данных. Следует отметить, что массив может изменять свою длину путем перераспределения памяти, что придает ему определенную динамичность. + +#### Основные типы данных + +> Когда речь идет о данных в компьютере, мы думаем в первую очередь о тексте, изображениях, видео, аудио, 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 байта −215 215 − 1 0 | ++===============+==========+=============+===================+=================+=============+ +| | > int | > 4 байта −231 231 − 1 0 | ++---------------+----------+-----------------------------------------------------------------+ +| | > long | > 8 байт −263 263 − 1 0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > С плавающей | > float | > 4 байта | > 1.175 × 10--38 | > 3.403 × 1038 | > 0.0f | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| | > double | > 8 байт | > 2.225 × 10--308 | > 1.798 × 10308 | > 0.0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > Символьный | > char | > 2 байта | > 0 | > 216 − 1 | > 0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > Логический | > bool | > 1 байт | > false | > true | > false | ++---------------+----------+-------------+-------------------+-----------------+-------------+ + +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)\] + +#### кодирование чисел\* + +### Прямой, обратный и дополнительный коды + +> В таблице из предыдущего раздела можно заметить, что во всех целочислен- ных типах отрицательных чисел на одно больше, чем положительных. Напри- мер, диапазон значений byte составляет \[--128, 127\]. Этот факт кажется не со- всем интуитивным, и его внутренняя причина связана с концепциями прямо- го, обратного и дополнительного кодов. +> +> Прежде всего необходимо отметить, что **числа хранятся в компьютере в виде дополнительного кода**. Прежде чем проанализировать причины это- +> +> го, сначала дадим определения всем трем кодам. + +- **Прямой код**: старший бит двоичного представления числа рассматри- вается как знак, где 0 обозначает положительное число, 1 -- отрицатель- ное, остальные биты представляют значение числа. + +- **Обратный код**: обратный код положительного числа совпадает с его прямым кодом, обратный код отрицательного числа получается инвер- + +> сией всех битов прямого кода, кроме знакового. + +- **Дополнительный код**: дополнительный код положительного числа со- впадает с его прямым кодом, дополнительный код отрицательного числа получается добавлением 1 к его обратному коду. + +> На рис. 3.4 изображены методы преобразования прямого, обратного и до- полнительного кодов между собой. + +![](ru/docs/assets/media/image114.jpeg) **Число** + +> **Прямой код** +> +> **Обратный код** +> +> **Дополнительный код** +> +> **Прямой, обратный и дополнительный коды положительных чисел равны** +> +> **Инвертировать все биты, кроме знакового** +> +> **Добавить 1** +> +> **[к]{.smallcaps} обратному коду** +> +> **Прямой, обратный и дополнительный коды отрицательных чисел требуют преобразования** +> +> **Рис. 3.4.** Преобразования прямого, обратного и дополнительного кодов +> +> Хотя прямой код наиболее понятен, он имеет некоторые ограничения. С од- ной стороны, **прямой код отрицательных чисел нельзя напрямую исполь- зовать в вычислениях**. Например, при вычислении 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, **что совпадает с дополнительным кодом положи- тельного нуля**. Таким образом, в дополнительном коде существует только один ноль, и неоднозначность положительного и отрицательного нуля устраняется. +> +> Остается последний вопрос: как получается дополнительное отрицательное число −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*). Умноже- ние и деление можно преобразовать в многократное сложение или вычитание. +> +> Теперь можно подытожить, почему компьютеры используют дополнительный код. Благодаря представлению в дополнительном коде компьютер может обра- батывать сложение положительных и отрицательных чисел с помощью одних и тех же схем и операций без необходимости проектировать специальные аппа- ратные схемы для вычитания, а также без необходимости специально обрабаты- вать неоднозначность положительного и отрицательного нуля. Это значительно упрощает проектирование аппаратуры и повышает эффективность вычислений. Техническое решение с дополнительным кодом весьма изящно, но из-за огра- ниченности объема книги мы пока остановимся на этом и рекомендуем заин- тересованным читателям углубиться в изучение этого вопроса самостоятельно. + +### Кодирование чисел с плавающей запятой + +> Внимательный читатель, возможно, заметил, что типы int и float имеют оди- наковую длину в 4 байта. Но почему тогда диапазон значений float значитель- но больше, чем у int? Этот факт, возможно, противоречит интуиции, так как float представляет дробные числа, и диапазон значений должен уменьшиться. +> +> На самом деле **это связано с тем, что числа с плавающей запятой** float **используют иной способ представления**. 32-битное двоичное число запи- сывается следующий образом: +> +> *b*31*b*30*b*29 \... *b*2*b*1*b*0. +> +> Согласно стандарту IEEE 754 32-битное число float состоит из следующих трех частей: + +1) знакового бита S: занимает 1 бит, соответствует *b*31; + +2) показателя E: занимает 8 бит, соответствует *b*30*b*29 \... *b*23; + +3) мантиссы N: занимает 23 бита, соответствует *b*22*b*21 \... *b*0. + +> Метод вычисления значения двоичного числа float: +> +> val = (−1)***^b^***31 ⌡ 2^(***b***^30 ***b***29 \... ***b***23 )2 −127 ⌡ (1.*b b* \...*b* ) . +> +> 22 21 0 2 +> +> Формула для вычисления в десятичной системе: +> +> val = (−1)^S^ ⌡ 2^E−127^ ⌡(1 + N). +> +> где диапазоны значений каждой из частей: + +) + +> (1 + N)= (1 + Ι23 *b* +> +> ξ 2^−***i***^ ∴ 〉 ℘1, 2 − 2^−23^ λ . + +Λ + +>   +> +>   ***i***  1 +> +> 23−***i***   ϑ +> +> Знаковый бит **S** +> +> Длина **float** 4 байта = 32 бита +> +> Для этого примера легко получить: +> +> По стандарту IEEE 754 +> +> ![](ru/docs/assets/media/image116.jpeg)**Рис. 3.5.** Пример вычисления значения float по стандарту IEEE 754 +> +> Рассмотрим рис. 3.5, где приведены данные со значениями S = 0, E = 124, N = 2−2 + 2−3 = 0.375, тогда: +> +> val = (−1)^0^ ⌡ 2^124−127^ ⌡(1 + 0.375) = 0.171875. +> +> Теперь можно ответить на первоначальный вопрос: **представление типа** 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, поэтому здесь мы не будем углубляться в детали. + +#### кодирование символов\* + +> В компьютере все данные хранятся в виде двоичных чисел, и символьный тип данных char не является исключением. Для представления символов необхо- димо создать *таблицу символов*, которая устанавливает однозначное соответ- ствие между каждым символом и двоичным числом. С помощью этой таблицы компьютер может выполнять преобразование двоичных чисел в символы. + +### Таблица символов ASCII + +> ASCII является первой таблицей символов, ее полное название -- American Standard Code for Information Interchange (Американский стандартный код для обмена информацией). Для представления символов в ней используются 7-битные двоичные числа (нижние 7 бит одного байта), что позволяет пред- ставить до 128 различных символов. Как показано на рис. 3.6, таблица ASCII включает в себя заглавные и строчные буквы английского алфавита, цифры от 0 до 9, некоторые знаки препинания, а также некоторые управляющие сим- волы (например, символы новой строки и табуляции). + ++--------------------------+------------------------+--------------+-------------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:=============================:+ +| > 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 + ++--------------------------+------------------------+--------------+-------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:=======================:+ +| > 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.** *Продолжение* + ++--------------------------+------------------------+--------------+----------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:==========================:+ +| > 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.** *Продолжение* + ++--------------------------------------------------+-------------------------------+ +| > **Десятичная система Двоичная система Символ** | > **Значение** | ++==================================================+===============================+ +| > 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 символов определяются по- +> +> разному, чтобы удовлетворить потребности различных языков. + +### Таблицы символов GBK + +> Позднее выяснилось, что **EASCII все еще не может удовлетворить требова- ния многих языков по количеству символов**. Например, в китайском языке около ста тысяч иероглифов, из которых несколько тысяч используются в по- вседневной жизни. В 1980 году Государственное управление по стандартам Ки- тая выпустило таблицу символов GB2312, которая включала 6763 иероглифа, что в основном удовлетворяет потребности в обработке китайских иерогли- фов на компьютере. +> +> Однако GB2312 не может обрабатывать некоторые редкие и традицион- ные иероглифы. Поэтому таблица GBK была расширена и стала включать 21 886 иероглифов. В кодировке GBK символы ASCII представляются одним бай- том, а иероглифы -- двумя байтами. + +### Таблица символов Unicode + +> С бурным развитием компьютерных технологий появилось множество таблиц символов и кодировок, что привело к большому количеству проблем. С одной стороны, эти наборы символов обычно определяют только символы конкрет- ного языка и не могут нормально работать в многоязычной среде. С другой стороны, для одного и того же языка может существовать несколько таблиц символов, и если два компьютера используют разные кодировки, то при пере- даче информации может возникнуть искажение символов. +> +> Поэтому появилась идея **создать достаточно полный набор символов, включающий все языки и символы мира, который должен решить про- блемы многоязычной среды и искажения символов**. Под влиянием этой идеи и появился большой и универсальный набор символов Unicode. +> +> Название Unicode означает унифицированный код, теоретически эта ко- дировка может вмещать более 1 млн символов. Ее целью является включение символов со всего мира в единый набор, предоставляя универсальное средство для обработки и отображения различных языков, а также уменьшая проблемы искажения символов из-за различий в стандартах кодирования. +> +> С момента своего выпуска в 1991 году Unicode постоянно расширяется, добавляя поддержку новых языков и символов. По состоянию на сентябрь 2022 года кодировка Unicode включает 149 186 символов, включая символы различных языков, знаки и даже эмодзи. В обширном наборе символов Uni- code часто используемые символы занимают 2 байта, некоторые редкие сим- волы занимают 3 или даже 4 байта. +> +> Unicode -- это универсальный набор символов, который присваивает каж- дому символу номер (называемый кодовой точкой), **но не определяет, как эти кодовые точки должны храниться в компьютере**. Возникает вопрос: как система интерпретирует символы, когда в тексте одновременно при- сутствуют кодовые точки Unicode разной длины? Например, как система определяет, что код длиной 2 байта -- это один 2-байтовый символ, а не два 1-байтовых? +> +> **Одним из простых решений этой проблемы является хранение всех символов в виде кодов одинаковой длины**. В примере на рис. 3.7 показа- но, что каждый символ в слове Hello занимает 1 байт, а каждый символ в ки- тайском слове 算 法 (алгоритм) -- 2 байта. Можно закодировать все символы в выражении «Hello 算法» 2 байтами, заполнив старшие биты нулями. Таким образом, система сможет распознавать один символ каждые 2 байта и восста- новить содержание этой фразы. +> +> Однако ASCII-код уже доказал, что для кодирования английского языка до- статочно 1 байта. Если использовать вышеописанный метод, размер англий- ского текста будет вдвое больше, чем при кодировании с помощью ASCII, что крайне неэффективно по памяти. Поэтому необходим более эффективный ме- тод использования кодировки Unicode. +> +> ![](ru/docs/assets/media/image118.jpeg) +> +> **Рис. 3.7.** Пример представления символов в Unicode + +### Кодирование 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, что противоречит предположению. +> +> ![](ru/docs/assets/media/image120.jpeg) +> +> **Рис. 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. + +### Кодирование символов в языках программирования + +> Раньше в большинстве языков программирования строки в процессе выпол- нения программы кодировались с использованием UTF-16 или UTF-32, т. е. кодировок фиксированной длины. В таких кодировках строки можно обраба- тывать как массивы, что имеет следующие преимущества. + +- **Случайный доступ**: в строках в кодировке UTF-16 легко выполнять слу- чайный доступ. UTF-8 является кодировкой переменной длины, поэтому для нахождения *i*-го символа необходимо пройти от начала строки до *i*-го символа, что требует времени *O*(*n*). + + + +- **Подсчет символов**: аналогично случайному доступу вычисление длины строки в кодировке 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, а многие технологии Mi- crosoft, включая операционную систему Windows, широко используют кодировку UTF-16. + +> Из-за недооценки количества символов в этих языках программирования пришлось использовать суррогатные пары для представления символов Uni- code, превышающих 16 бит. Это было вынужденной мерой. С одной стороны, в строках, содержащих суррогатные пары, один символ может занимать 2 или 4 байта, что лишает кодировку преимущества равной длины. С другой сторо- ны, обработка суррогатных пар требует дополнительного кода, что увеличива- ет сложность программирования и затрудняет отладку. +> +> По указанным причинам в некоторых языках программирования предло- жили различные схемы кодирования. + +- В Python тип str использует кодировку Unicode и гибкое представле- ние строк, где длина символа зависит от наибольшего кодового пункта Unicode в строке. Если строка содержит только символы ASCII, каждый символ занимает 1 байт. Если есть символы за пределами ASCII, но в пределах базовой многоязычной плоскости (BMP), каждый символ за- нимает 2 байта. Если есть символы за пределами BMP, каждый символ занимает 4 байта. + +- В языке Go тип string внутренне использует кодировку UTF-8. Go также содержит тип rune для представления одного кодового пункта Unicode. + + - В языке Rust типы str и String внутренне используют кодировку UTF-8. Rust также предоставляет тип char для представления одного кодового пункта Unicode. + +> Следует отметить, что мы обсуждаем способ хранения строк в языках про- граммирования, что отличается от хранения строк в файлах или передачи их по сети. При хранении в файлах и сетевой передаче строки обычно кодируют- ся в формате UTF-8 для достижения наилучшей совместимости и эффектив- ности использования пространства. + +#### резюме + +##### Ключевые моменты + +- Структуры данных можно классифицировать с точки зрения логической и физической структуры. Логическая структура описывает логические отношения между элементами данных, а физическая структура описы- вает способ хранения данных в памяти компьютера. + +- К распространенным логическим структурам относятся линейные, дре- вовидные и сетевые. Обычно структуры данных делятся на линейные (массивы, списки, стеки, очереди) и нелинейные (деревья, графы, кучи). Реализация хеш-таблиц может включать как линейные, так и нелиней- ные структуры данных. + +- При выполнении программы данные хранятся в памяти компьютера. Каждое пространство памяти имеет соответствующий адрес, с помощью которого программа получает доступ к данным. + +- Физическая структура делится на хранение в непрерывном пространстве (массивы) и хранение в разреженном пространстве (списки). Все структу- ры данных реализуются с помощью массивов, списков или их комбинации. + +- Основные типы данных в компьютере включают целые числа byte, short, int, long, числа с плавающей запятой float, double, символы char и логиче- ские bool. Их диапазон значений зависит от размера занимаемого про- странства и способа представления. + +- Прямой, обратный и дополнительный коды -- это три метода кодирова- ния чисел в компьютере, которые можно взаимно преобразовывать друг в друга. В прямом коде старший бит целого числа является знаковым, а остальные биты представляют значение числа. + +- Целые числа в компьютере хранятся в виде дополнительного кода. В представлении дополнительного кода компьютер может одинаково обрабатывать сложение положительных и отрицательных чисел без не- обходимости в специальной аппаратной схеме для вычитания. Также при таком подходе отсутствует проблема двусмысленности положитель- ного и отрицательного нуля. + +- Кодирование чисел с плавающей запятой состоит из 1 бита для знака, 8 бит для экспоненты и 23 бит для мантиссы. Благодаря наличию экспоненты диапазон значений чисел с плавающей запятой значительно превышает диапазон целых чисел, но это достигается за счет потери точности. + + + +- ASCII является первой кодировкой символов для английского языка. Она включает 127 символов, длина кода составляет 1 байт. Набор сим- волов GBK часто используется для китайского языка и включает более двадцати тысяч иероглифов. Стандарт Unicode стремится предоставить стандарт полного набора символов, охватывающий символы различных языков мира, а также решая проблему искажения символов из-за несо- ответствия методов кодирования. + +- UTF-8 является самым популярным методом кодирования символов Unicode и обладает высокой универсальностью. Это метод кодирования переменной длины, который позволяет эффективно использовать про- странство хранилища. UTF-16 и UTF-32 -- это методы кодирования фик- сированной длины. При кодировании китайских символов UTF-16 зани- мает меньше места, чем UTF-8. Такие языки программирования, как Java и C#, по умолчанию используют кодировку UTF-16. + +##### Вопросы и ответы + +> **Вопрос**. Почему хеш-таблица одновременно содержит линейные и нелиней- ные структуры данных? +> +> **Ответ**. Хеш-таблица в своей основе представляет собой массив, и для реше- ния проблемы хеш-коллизий может использоваться адрес цепочки (подробнее см. в разделе «Хеш-коллизии»): каждая корзина массива указывает на связный список, который при превышении определенного порога длины может быть преобразован в дерево (обычно красно-черное дерево). С точки зрения хра- нения основа хеш-таблицы -- это массив, в котором каждая корзина может со- держать значение, связный список или дерево. Таким образом, хеш-таблица может одновременно содержать линейные структуры данных (массивы, связ- ные списки) и нелинейные структуры данных (деревья). +> +> **Вопрос**. Длина типа char составляет 1 байт? +> +> **Ответ**. Длина типа char определяется методом кодирования, используемым языком программирования. Например, в Java, JavaScript, TypeScript, C# ис- пользуется кодировка UTF-16 (сохраняющая кодовые точки Unicode), поэтому длина типа char составляет 2 байта. +> +> **Вопрос**. Можно ли назвать структуры данных на основе массивов статически- ми структурами данных? Стеки также могут выполнять такие операции, как из- влечение и добавление элементов, но эти операции являются динамическими. +> +> **Ответ**. Стек действительно может выполнять динамические операции с данными, но структура данных остается статической (неизменной длины). Хотя структуры данных на основе массивов могут динамически добавлять или удалять элементы, их емкость фиксирована. Если объем данных превышает за- ранее выделенный размер, необходимо создать новый, более крупный массив и скопировать в него содержимое старого массива. +> +> **Вопрос**. При создании стека (очереди) не указывается его размер, почему они являются статическими структурами данных? +> +> **Ответ**. В высокоуровневых языках программирования не требуется вруч- ную задавать начальную емкость стека (очереди), это выполняется автома- тически внутри класса. Например, начальная емкость ArrayList в Java обычно +> +> составляет 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 diff --git a/ru/chapters/chapter_04.md b/ru/chapters/chapter_04.md new file mode 100644 index 000000000..f29a0279e --- /dev/null +++ b/ru/chapters/chapter_04.md @@ -0,0 +1,775 @@ +# Массивы и списки + +![](ru/docs/assets/media/image121.jpeg){width="3.71875in" height="4.8125in"} + +#### массивы + +> *Массив* представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов. + +**Элемент** + +> Память для хранения **массива** +> +> ![](ru/docs/assets/media/image123.jpeg)является **непрерывной** +> +> **Массив** +> +> Индекс +> +> **Адрес памяти** +> +> Доступная память +> +> Память, выделенная для массива +> +> **Рис. 4.1.** Определение и способ хранения массива + +1. **Основные операции с массивом** + +##### Инициализация массива + +> Существует два способа инициализации массива: без начальных значений и с за- данными начальными значениями. Если начальные значения не указаны, боль- шинство языков программирования инициализируют элементы массива нулями. +> +> \# === File: array.py === \# Инициализация массива. +> +> arr: list\[int\] = \[0\] \* 5 \# \[ 0, 0, 0, 0, 0 \] +> +> nums: list\[int\] = \[1, 3, 2, 5, 4\] + +##### Доступ к элементам массива + +> Элементы массива хранятся в непрерывной области памяти, что упрощает вы- числение их адресов. Зная адрес массива (адрес первого элемента) и индекс элемента, можно вычислить адрес этого элемента по формуле, показанной на рис. 4.2, и получить к нему доступ. +> +> Как видно из рис. 4.2, индекс первого элемента массива равен 0, что может показаться неочевидным, так как отсчет с 1 кажется более естественным. Од- нако с точки зрения формулы вычисления адреса **индекс является смеще- нием адреса в памяти**. Смещение адреса первого элемента равно 0, поэтому и его индекс равен 0. +> +> ![](ru/docs/assets/media/image125.jpeg) +> +> **Рис. 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 + +##### Вставка элемента + +> Элементы массива в памяти расположены вплотную, между ними нет места для хранения других данных. Для вставки элемента в середину массива не- обходимо сдвинуть все последующие элементы на одну позицию вправо, а затем присвоить значение элементу по заданному индексу, как показано на рис. 4.3. +> +> Следует отметить, что длина массива фиксирована, поэтому вставка эле- мента неизбежно приведет к потере элемента в конце массива. Решение этой проблемы будет рассмотрено в разделе «Списки». +> +> ![](ru/docs/assets/media/image127.jpeg) +> +> **Рис. 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 + +##### Удаление элемента + +> Аналогично для удаления элемента по индексу *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\] +> +> ![](ru/docs/assets/media/image129.jpeg) +> +> **Рис. 4.4.** Пример удаления элемента из массива +> +> В целом операции вставки и удаления в массиве имеют следующие недо- статки. + +- **Высокая временная сложность**: средняя временная сложность опера- ций вставки и удаления в массиве составляет *O*(*n*), где *n* -- длина массива. + +- **Потеря элементов**: так как длина массива фиксирована, при вставке элемента элементы, выходящие за пределы длины массива, теряются. + +- **Расточительность памяти**: можно инициализировать длинный массив и использовать только его часть, но это приведет к потере памяти, так как неиспользуемые элементы в конце массива будут бессмысленными. + +##### Обход массива + +> В большинстве языков программирования массив можно обходить как по ин- дексам, так и непосредственно по элементам. +> +> \# === 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 +> +> \# Одновременный обход индексов и элементов. +> +> for i, num in enumerate(nums): count += nums\[i\] +> +> count += num + +##### Поиск элемента + +> Для поиска заданного элемента в массиве необходимо обойти массив и на каждой итерации проверить, совпадает ли значение элемента с искомым. Если совпадает, вывести соответствующий индекс. Поскольку массив яв- ляется линейной структурой данных, этот процесс называется линейным поиском. +> +> \# === 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 + +##### Расширение массива + +> В сложных системных средах нельзя гарантировать, что ячейки памяти, расположенные после массива, являются свободными. Это делает невоз- можным безопасное расширение его размера. Поэтому в большинстве язы- ков программирования длина массива фиксирована. Если необходимо уве- личить массив, нужно создать новый, больший массив и последовательно скопировать в него элементы исходного массива. Эта операция имеет слож- ность *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 + +### Преимущества и ограничения массивов + +> Массивы хранятся в непрерывном пространстве памяти, а его элементы име- ют одинаковый тип. Этот подход содержит богатую априорную информацию, которую система может использовать для оптимизации эффективности опе- раций с данной структурой данных. + +- **Высокая эффективность использования пространства**: массивы вы- деляют непрерывные блоки памяти для данных без дополнительных структурных затрат. + +- **Поддержка произвольного доступа**: массивы позволяют получить до- ступ к любому элементу за время *O*(1). + +- **Локальность кеширования**: при доступе к элементам массива компью- тер загружает не только его, но и кеширует окружающие данные, что по- зволяет ускорить выполнение последующих операций за счет использо- вания высокоскоростного кеша. + +> Непрерывное хранение в пространстве -- это палка о двух концах, имеющая следующие ограничения. + +- **Низкая эффективность вставки и удаления**: при большом количестве элементов в массиве операции вставки и удаления требуют перемеще- ния множества элементов. + +- **Неизменная длина**: после инициализации длина массива фиксируется, а увеличение массива требует копирования всех данных в новый массив, что влечет за собой значительные затраты. + +- **Расточительность пространства**: если размер выделенного массива превышает фактические потребности, избыточное пространство оказы- вается потраченным впустую. + +### Типичные сценарии применения массивов + +> Массивы -- это базовая и распространенная структура данных, часто исполь- зуемая в различных алгоритмах и для реализации сложных структур данных. + +- **Произвольный доступ**: если требуется случайный выбор элементов, можно использовать массив для хранения и генерации случайной по- следовательности, осуществляя случайную выборку по индексу. + +- **Сортировка и поиск**: массивы являются наиболее часто используемой структурой данных для алгоритмов сортировки и поиска. Быстрая сорти- ровка, сортировка слиянием, двоичный поиск и другие алгоритмы в ос- новном работают с массивами. + +- **Таблица поиска**: когда необходимо быстро найти элемент или его со- ответствие, можно использовать массив в качестве таблицы поиска. На- пример, для реализации отображения символов в ASCII-коды можно использовать значение ASCII-кода символа в качестве индекса, а соот- ветствующий элемент хранить в соответствующем месте массива. + +- **Машинное обучение**: в нейронных сетях широко используются опе- рации линейной алгебры между векторами, матрицами и тензорами, которые реализуются в виде массивов. Массивы являются наиболее часто используемой структурой данных в программировании ней- ронных сетей. + +- **Реализация структур данных**: массивы могут использоваться для ре- ализации стека, очереди, хеш-таблицы, кучи, графа и других структур данных. Например, представление графа в виде матрицы смежности фактически является двумерным массивом. + +#### Связные списки + +> Память -- это общий ресурс всех программ, и в сложной системной среде вы- полнения участки свободной памяти могут быть разбросаны по всему про- странству памяти. Нам уже известно, что память для хранения массива должна быть непрерывной, и когда массив очень велик, в памяти может не оказаться столь большого непрерывного участка. В этом случае проявляется преимуще- ство гибкости связного списка. +> +> *Связный список* -- это линейная структура данных, в которой каждый элемент является объектом-узлом. При этом узлы соединяются друг с другом с помо- щью ссылок. В ссылке хранится адрес памяти следующего узла, по которому можно перейти от текущего узла к следующему. +> +> Структура связного списка позволяет узлам храниться в различных местах памяти, а их адреса памяти не обязаны быть последовательными. + +![](ru/docs/assets/media/image131.jpeg) + +> **Рис. 4.5.** Определение и способ хранения связного списка +> +> На рис. 4.5 изображена структура связного списка. Составным элементом является объект узла. Каждый узел содержит две части данных: значение узла и ссылку на следующий узел. + +- Первый узел связного списка называется головным узлом, а последний узел -- хвостовым узлом. + +- Хвостовой узел указывает на пустое значение, которое в Java, C++ и Py- thon обозначается как null, nullptr и None соответственно. + +- В языках, поддерживающих указатели, таких как C, C++, Go и Rust, вы- шеупомянутая ссылка заменена на указатель. + +> В следующем примере кода показано, что узел связного списка ListNode, помимо значения, должен дополнительно хранить ссылку (указатель). Поэтому при одина- ковом объеме данных **связный список занимает больше памяти, чем массив**. +> +> class ListNode: +> +> \"\"\" Класс узла связного списка.\"\"\" def init (self, val: int): +> +> self.val: int = val \# Значение узла. +> +> self.next: ListNode \| None = None \# Ссылка на следующий узел. + +### Основные операции со связным списком + +##### Инициализация связного списка + +> Создание связного списка состоит из двух этапов: первый этап -- иници- ализация каждого объекта узла, второй этап -- построение ссылочных от- ношений между узлами. После завершения инициализации можно начать с головного узла связного списка и последовательно посетить все узлы че- рез ссылку 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. + +##### Вставка узла + +> Процесс вставки узла в связный список очень прост. Предположим, что не- обходимо вставить новый узел P между двумя соседними узлами n0 и n1, **для этого достаточно изменить две ссылки (указателя)**, а время выполнения составит *O*(1), см. рис. 4.6. +> +> Напомним, что вставка элемента в массив имеет временную сложность *O*(*n*), что менее эффективно при больших объемах данных. +> +> ![](ru/docs/assets/media/image133.jpeg) +> +> **Рис. 4.6.** Пример вставки узла в связный список +> +> \# === File: linked_list.py === +> +> def insert(n0: ListNode, P: ListNode): +> +> \"\"\" Вставка узла P после узла n0 в связный список.\"\"\" n1 = n0.next +> +> P.next = n1 n0.next = P + +##### Удаление узла + +> Удаление узла в связном списке также является очень простой операцией, как показано на рис. 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 +> +> **Пример** +> +> Удаление узла **P** из связного списка +> +> (Элемент **n0 P** +> +> ![](ru/docs/assets/media/image135.jpeg)указывает на **n1 )** +> +> После удаления элемент **P** все еще указывает на **n1**. Но **P** больше не доступен при обходе списка, поэтому можно считать, что **P** был удален + +##### Доступ к узлу + +> **Рис. 4.7.** Удаление узла в связном списке +> +> **Эффективность доступа к узлам в связном списке ниже**. Как упоминалось в предыдущем разделе, доступ к любому элементу массива можно получить за время *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 + +##### Поиск узла + +> Поиск узла заключается в обходе связного списка для поиска узла со значени- ем target и выводе его индекса в списке. Этот процесс также является линей- ным поиском. Ниже приведен пример кода. +> +> \# === File: linked_list.py === +> +> def find(head: ListNode, target: int) -\> int: +> +> \"\"\" Поиск первого узла со значением target в связном списке.\"\"\" index = 0 +> +> while head: +> +> if head.val == target: return index +> +> head = head.next index += 1 +> +> return -1 + +### Сравнение массивов и связных списков + +> В табл. 4.1 приведены характеристики массивов и связных списков, а также сравнение эффективности операций с ними. Поскольку они используют две противоположные стратегии хранения, их свойства и эффективность опера- ций также имеют противоположные характеристики. + ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| | > **Массив** | > **Связный список** | ++========================+======================================================================+========================================+ +| > Способ хранения | > Непрерывное пространство памяти | > Распределенное про- странство памяти | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Расширение емкости | > Длина фиксирована | > Возможность гибкого расширения | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Эффективность памяти | > Элементы занимают меньше памяти, но могут расходовать пространство | > Элементы занимают больше памяти | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Доступ к элементу | > *O*(1) | > *O*(*n*) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Добавление элемента | > *O*(*n*) | > *O*(1) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Удаление элемента | > *O*(*n*) | > *O*(1) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ + +> впустую + +### Основные типы связных списков + +> Существуют три основных типа связных списков (см. рис. 4.8). + +- **Односвязный список**: это обычный связный список, описанный ранее. Узлы однонаправленного связного списка содержат значение и ссылку на следующий узел. Первый узел называется головным, а последний -- хвостовым, хвостовой узел указывает на пустое значение None. + +- **Кольцевой (циклический) список**: если сделать так, чтобы хвостовой узел односвязного списка указывал на головной узел (соединение начала и конца), получится кольцевой список. В кольцевом списке любой узел можно рассматривать как головной. + +- **Двусвязный список**: в отличие от односвязного двусвязный список хранит ссылки в двух направлениях. Определение узла двусвязного спи- ска включает ссылки (указатели) на следующий и предыдущий узлы. По сравнению с односвязным списком двусвязный список обладает большей гибкостью, позволяя обходить список в обоих направлениях, но требует больше памяти. + +> class ListNode: +> +> \"\"\" Класс узла двусвязного списка.\"\"\" def init (self, val: int): +> +> self.val: int = val \# Значение узла. +> +> self.next: ListNode \| None = None \# Ссылка на следующий узел. self.prev: ListNode \| None = None \# Ссылка на предыдущий узел. + +![](ru/docs/assets/media/image137.jpeg) + +> **Рис. 4.8.** Виды связных списков + +### Типичные сценарии применения списков + +> Односвязные списки обычно используются для реализации таких структур данных, как стек, очередь, хеш-таблица и граф. + +- **Стек и очередь**: когда операции вставки и удаления выполняются с од- ного конца списка, он ведет себя как стек (принцип «последний при- шел -- первый вышел»). Когда операция вставки выполняется с одного конца, а операция удаления -- с другого, он ведет себя как очередь (прин- цип «первый пришел -- первый вышел»). + +- **Хеш-таблица**: метод цепочек является одним из основных способов ре- шения коллизий в хеш-таблицах, при котором все конфликтующие эле- менты помещаются в один список. + +- **Граф**: списки смежности -- это распространенный способ представления графов, где каждая вершина графа связана со списком, элементы кото- рого представляют другие вершины, соединенные с данной. + +> Двусвязные списки часто используются в ситуациях, где требуется быстрое нахождение предыдущего и следующего элемента. + +- **Расширенные структуры данных**: например, в красно-черных дере- вьях и B-деревьях необходимо иметь доступ к родительскому узлу, что можно реализовать, сохраняя ссылку на родительский узел аналогично двусвязному списку. + + - **История браузера**: в веб-браузере, когда пользователь нажимает кнопки **Вперед** или **Назад**, браузеру необходимо знать предыдущую и следую- щую страницы. Свойства двусвязного списка упрощают выполнение та- ких операций. + + - **Алгоритм LRU**: в алгоритме вытеснения из кеша (LRU) необходимо бы- стро находить наименее используемые данные, а также поддерживать быстрое добавление и удаление узлов. В этом случае идеально подходит двусвязный список. + +> Кольцевые списки часто применяются в ситуациях, требующих цикличе- ских операций, например в планировании ресурсов операционной системы. + +- **Алгоритм циклического планирования**: в операционных системах ал- горитм циклического планирования -- это распространенный алгоритм планирования процессорного времени, который требует циклического об- хода группы процессов. Каждому процессу назначается временной квант, и по его истечении процессор переключается на следующий процесс. Такие циклические операции можно реализовать с помощью кольцевого списка. + +- **Буфер данных**: в некоторых реализациях буферов данных также может использоваться кольцевой список. Например, в аудио- и видеоплеерах по- ток данных может разделяться на несколько буферных блоков и помещать- ся в кольцевой список для обеспечения непрерывного воспроизведения. + +#### Списки + +> *Список* -- это абстрактное понятие структуры данных, представляющее собой упорядоченное множество элементов, поддерживающее операции доступа, изменения, добавления, удаления и обхода элементов без необходимости учи- тывать ограничения по объему. Списки могут быть реализованы на основе связных списков или массивов. + +- Связные списки естественным образом можно рассматривать как спи- ски, поддерживающие операции добавления, удаления, поиска и изме- нения элементов, а также динамическое расширение. + +- Массивы также поддерживают операции добавления, удаления, поиска и изменения элементов, но из-за фиксированной длины их можно рас- сматривать только как списки с ограничением по длине. + +> При использовании массива для реализации списка **неизменяемая длина приводит к снижению его практичности**. Это связано с тем, что зачастую невозможно заранее определить, сколько данных потребуется хранить, что за- трудняет выбор подходящей длины списка. Если длина слишком мала, это, веро- ятно, не удовлетворит потребности. Если длина слишком велика, это приведет к неэффективному использованию памяти. Для решения этой проблемы можно использовать динамический массив. Он сохраняет все преимущества массива и может динамически расширяться в процессе выполнения программы. +> +> На самом деле **списки**, **предоставляемые стандартными библиотеками многих языков программирования**, **реализованы на основе динамиче- ских массивов**, например list в Python, ArrayList в Java, vector в C++ и List в C#. В дальнейшем мы будем рассматривать список и динамический массив как эквивалентные понятия. + +### Основные операции со списком + +##### Инициализация списка + +> Обычно используются два метода инициализации: без начальных значений и с заданием начальных значений. +> +> \# === File: list.py === \# Инициализация списка. +> +> \# Без начальных значений. nums1: list\[int\] = \[\] +> +> \# С начальными значениями. +> +> nums: list\[int\] = \[1, 3, 2, 5, 4\] + +##### Доступ к элементам + +> Список по своей сути является массивом, поэтому доступ и обновление эле- ментов возможны за время *O*(1), что очень эффективно. +> +> \# === File: list.py === \# Доступ к элементам. +> +> num: int = nums\[1\] \# Доступ к элементу по индексу 1. \# Изменение элемента. +> +> nums\[1\] = 0 \# Изменение значения элемента с индексом 1 на 0. + +##### Вставка и удаление элементов + +> В отличие от массива в списке можно свободно добавлять и удалять элемен- ты. Сложность добавления элемента в конец списка составляет *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. + +##### Обход списка + +> Как и массив, список можно обходить по индексу или напрямую по эле- ментам. +> +> \# === File: list.py === +> +> \# Обход списка по индексу. count = 0 +> +> for i in range(len(nums)): count += nums\[i\] +> +> \# Прямой обход элементов списка. for num in nums: +> +> count += num + +##### Конкатенация списков + +> Создав новый список nums1, его можно присоединить в конец исходного списка. +> +> \# === File: list.py === +> +> \# Конкатенация двух списков. +> +> nums1: list\[int\] = \[6, 8, 7, 10, 9\] +> +> nums += nums1 \# Конкатенация списка nums1 с nums. + +##### Сортировка списка + +> После сортировки списка можно использовать такие алгоритмы, как двоич- ный поиск и два указателя, которые часто встречаются в задачах с массивами. +> +> \# === File: list.py === \# Сортировка списка. +> +> nums.sort() \# После сортировки элементы списка расположены по возрастанию. + +### Реализация списка + +> Многие языки программирования, такие как Java, C++, Python и др., име- ют встроенные списки. Их реализация довольно сложна, а параметры тща- тельно продуманы, например начальная емкость, коэффициент расшире- ния и т. д. Заинтересованные читатели могут изучить исходный код само- стоятельно. Чтобы углубить понимание принципов работы списка, попро- буем реализовать его упрощенную версию, включающую следующие три ключевых аспекта. + +- **Начальная емкость**: выбор разумной начальной емкости массива. В нашем примере выбрано значение 10 в качестве начальной емкости. + +- **Учет количества**: объявление переменной size для учета текущего ко- личества элементов в списке. Эта переменная обновляется при вставке и удалении элементов. На ее основе можно определить конец списка и необходимость расширения. + +- **Механизм расширения**: если при вставке элемента емкость списка оказывается исчерпанной, необходимо расширение. Сначала созда- ется новый массив большего размера на основе коэффициента рас- ширения, затем все элементы текущего массива последовательно перемещаются в новый массив. В нашем примере массив каждый раз расширяется двукратно. + +> \# === 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() +> +> \# Все элементы начиная с индекса 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\] + +#### память и кеш\* + +> В первых двух разделах этой главы были рассмотрены массивы и связные спи- ски -- две базовые и важные структуры данных, представляющие собой соот- ветственно непрерывное хранение и распределенное хранение. +> +> Фактически **физическая структура в значительной степени определяет эффективность использования памяти и кеша программой**, что, в свою очередь, влияет на общую производительность алгоритма. + +### Устройства хранения в компьютере + +> В компьютере существует три типа устройств хранения: жесткий диск (HDD/ SSD), оперативная память (RAM) и кеш-память (cache). В табл. 4.2 приведены их различные роли и характеристики. +> +> **Таблица 4.2.** Устройства хранения в компьютере + ++-------------+----------------------------+--------------------------+----------------------------+ +| | > **Жесткий диск** | > **Оперативная память** | > **Кеш** | ++=============+============================+==========================+============================+ +| > Назначе- | > Долговременное хра- | > Временное хранение | > Хранение часто запраши- | ++-------------+----------------------------+--------------------------+----------------------------+ +| > ние | > нение данных, включая | > запущенных программ | > ваемых данных и инструк- | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > операционную систему, | > и обрабатываемых | > ций, сокращение числа | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > программы, файлы и т. д. | > данных | > обращений к оперативной | ++-------------+----------------------------+--------------------------+----------------------------+ +| | | | > памяти процессором | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Энергоза- | > Данные не теряются по- | > Данные теряются после | > Данные теряются после | ++-------------+----------------------------+--------------------------+----------------------------+ +| > висимость | > сле отключения питания | > отключения питания | > отключения питания | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Емкость | > Большая, на уровне | > Меньше, на уровне | > Очень малая, на уровне | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > терабайтов | > гигабайтов | > мегабайтов | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Скорость | > Медленная, от несколь- | > Быстрая, десятки гига- | > Очень быстрая, от десят- | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > ких сотен до нескольких | > байт в секунду | > ков до сотен гигабайт | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > тысяч мегабайт в секунду | | > [в]{.smallcaps} секунду | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Цена | > Низкая, несколько цен- | > Высокая, несколько | > Очень высокая, цена | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > тов за гигабайт | > долларов за гигабайт | > включена в стоимость | ++-------------+----------------------------+--------------------------+----------------------------+ +| | | | > процессора | ++-------------+----------------------------+--------------------------+----------------------------+ + +> Компьютерную систему хранения можно представить в виде пирамидаль- ной структуры, как показано на рис. 4.9. Чем ближе к вершине пирамиды на- ходится устройство хранения, тем выше его скорость, меньше емкость и выше стоимость. Такой многоуровневый дизайн не случаен, а является результатом тщательных размышлений компьютерных ученых и инженеров. + +![](ru/docs/assets/media/image139.jpeg) + +> **Рис. 4.9.** Система хранения данных в компьютере + +- **Жесткий диск трудно заменить оперативной памятью**. Во-первых, данные в оперативной памяти теряются после отключения питания, по- этому она не подходит для долговременного хранения. Во-вторых, сто- имость оперативной памяти в десятки раз выше, чем у жесткого диска, что затрудняет ее распространение на потребительском рынке. + + - **Большая емкость и высокая скорость кеша трудно совместимы**. С увеличением емкости кеша уровней L1, L2, L3 его физические разме- ры увеличиваются. Вместе с этим растет физическое расстояние до ядра процессора, что приводит к увеличению времени передачи данных и за- держке доступа к элементам. В текущих условиях многоуровневая струк- тура кеша является оптимальным балансом между емкостью, скоростью и стоимостью. + +> В целом **жесткий диск используется для долговременного хранения большого объема данных**, **оперативная память** -- **для временного хране- ния данных**, **обрабатываемых во время выполнения программы**, **а кеш -- для хранения часто запрашиваемых данных и инструкций**, чтобы повы- сить эффективность выполнения программы. Все три компонента работают совместно, обеспечивая эффективную работу компьютерной системы. +> +> Во время выполнения программы данные считываются с жесткого диска в оперативную память для обработки процессором, как показано на рис. 4.10. Кеш-память можно рассматривать как часть процессора, в которую по слож- ным алгоритмам загружаются данные из оперативной памяти. Это обеспечи- вает высокоскоростное чтение данных процессором, значительно повышает эффективность выполнения программы и снижает зависимость от более мед- ленной оперативной памяти. + +![](ru/docs/assets/media/image141.jpeg) + +> **Рис. 4.10.** Поток данных между жестким диском, оперативной памятью и кешем + +### Эффективность использования памяти структурами данных + +> С точки зрения использования памяти массивы и связные списки имеют свои преимущества и ограничения. +> +> С одной стороны, **память ограничена**, **и одну и ту же область памяти нельзя разделить между несколькими программами**, поэтому желательно, +> +> чтобы структуры данных максимально эффективно использовали простран- ство. Элементы массива расположены плотно, также не требуется дополни- тельное пространство для хранения ссылок (указателей) между узлами, что делает его более эффективным с точки зрения использования памяти. Одна- ко массивы требуют выделения сразу достаточного количества непрерывного пространства памяти, что может привести к ее растрате, а расширение мас- сива также требует дополнительных временных и пространственных затрат. В отличие от этого списки осуществляют динамическое распределение и осво- бождение памяти на уровне узлов, что обеспечивает большую гибкость. +> +> С другой стороны, во время выполнения программы по мере многократного выполнения запросов и освобождения памяти степень фрагментации свобод- ной памяти будет увеличиваться, что снижает эффективность ее использова- ния. Благодаря непрерывному способу хранения массивы относительно менее подвержены фрагментации памяти. Элементы списка, напротив, хранятся разрозненно, и при частых операциях вставки и удаления они более подвер- жены фрагментации памяти. + +### Эффективность кеширования структур данных + +> Хотя объем кеша значительно меньше объема памяти, он намного быстрее и играет решающую роль в скорости выполнения программы. Объем кеша ограничен, и он может хранить лишь небольшую часть часто запрашиваемых данных. Поэтому при попытке процессора получить доступ к данным, отсут- ствующим в кеше, происходит промах кеша, и процессор вынужден загружать необходимые данные из более медленной памяти. +> +> Очевидно, что **чем меньше промахов кеша, тем выше эффективность чтения и записи данных процессором** и тем лучше производительность программы. Доля данных, успешно полученных процессором из кеша, называ- ется коэффициентом попадания в кеш. Этот показатель обычно используется для оценки эффективности кеша. +> +> Для достижения максимальной эффективности кеш использует следующие механизмы загрузки данных. + +- **Кеш-линия**: кеш не хранит и не загружает данные по байтам, а исполь- зует в качестве единицы кеш-линии. По сравнению с передачей по бай- там передача кеш-линий более эффективна. + +- **Механизм предвыборки**: процессор пытается предсказать шаблоны доступа к данным (например, последовательный доступ, доступ с фик- сированным шагом и т. д.) и загружает данные в кеш в соответствии с этими шаблонами, чтобы повысить коэффициент попадания. + +- **Пространственная локальность**: если к данным был осуществлен доступ, то, вероятно, в ближайшее время будет осуществлен доступ и к данным, на- ходящимся поблизости. Поэтому при загрузке данных кеш также загружает данные, находящиеся рядом, чтобы повысить коэффициент попадания. + +- **Временная локальность**: если к данным был осуществлен доступ, то в ближайшем будущем, вероятно, к ним будет осуществлен повторный доступ. Кеш использует этот принцип, сохраняя недавно запрашивае- мые данные, чтобы повысить коэффициент попадания. + +> Фактически **эффективность использования кеша массивами и списка- ми различается**, что проявляется в следующих аспектах. + +- **Занимаемое пространство**: элементы списка занимают больше про- странства, чем элементы массива, что приводит к уменьшению объема полезных данных, которые могут быть помещены в кеш. + +- **Кеш-линии**: данные списка распределены по всей памяти, а кеш за- гружает данные по линиям, поэтому доля загружаемых неэффективных данных выше. + +- **Механизм предвыборки**: шаблоны доступа к данным массива более предсказуемы, чем у списка, т. е. системе легче угадать, какие данные могут быть загружены. + +- **Пространственная локальность**: массивы хранятся в сконцентриро- ванном пространстве памяти, поэтому данные, находящиеся рядом с за- груженными, с большей вероятностью будут запрошены. + +- В целом **массивы обладают более высоким коэффициентом попа- дания в кеш**, **поэтому они обычно превосходят списки по эффек- тивности операций**. Это делает структуры данных, реализованные на основе массивов, более предпочтительными при решении алгоритми- ческих задач. + +> Следует отметить, **что высокая эффективность использования кеша не означает, что массивы всегда предпочтительнее списков**. Выбор струк- туры данных в реальных приложениях должен основываться на конкретных требованиях. Например, структуру данных «стек» можно реализовать на осно- ве и массивов, и списков (детали этого будут рассмотрены в следующей главе), но они предназначены для различных сценариев. + +- При решении алгоритмических задач предпочтение отдается стеку, реализованному на основе массива, поскольку он обеспечивает более высокую эффективность операций и возможность случайного доступа. Но платой за это является необходимость заранее выделить определен- ное количество памяти для массива. + +- Если объем данных очень велик, динамичность высока, и размер стека трудно предсказать, то более предпочтителен стек, реализованный на основе списка. Список позволяет распределенно хранить большое коли- чество данных в различных частях памяти и избегать дополнительных затрат на расширение массива. + +#### резюме + +##### Ключевые моменты + +- Массивы и списки -- это две основные структуры данных, представляю- щие два способа хранения данных в памяти компьютера: хранение в не- прерывном пространстве и хранение в распределенном пространстве. Их характеристики дополняют друг друга. + +- Массивы поддерживают случайный доступ и занимают меньше памяти. Однако эффективность вставки и удаления элементов низкая, а длина после инициализации фиксированная. + + + +- Списки обеспечивают эффективную вставку и удаление узлов путем из- менения ссылок (указателей) и могут гибко изменять свою длину. Одна- ко доступ к узлам менее эффективен, и они занимают больше памяти. К распространенным типам списков относятся односвязные, кольцевые и двусвязные списки. + +- Список -- это упорядоченная коллекция элементов, поддерживающая операции добавления, удаления, поиска и изменения и обычно реали- зуемая на основе динамического массива. Он сохраняет преимущества массива, одновременно позволяя гибко изменять длину. + +- Появление списка значительно повысило практическую ценность мас- сива, но может привести к частичной потере памяти. + +- Во время выполнения программы данные в основном хранятся в памя- ти. Массивы могут обеспечить более высокую эффективность использо- вания памяти, тогда как списки более гибки в ее использовании. + +- Кеш, используя кеш-линии, механизм предвыборки, а также механизмы пространственной и временной локальности данных, обеспечивает для процессора быстрый доступ к данным, значительно повышая эффектив- ность выполнения программы. + +- Поскольку массивы обладают более высокой вероятностью попадания в кеш, они обычно более эффективны, чем списки. При выборе струк- туры данных следует принимать во внимание конкретные требования и сценарии. + +##### Вопросы и ответы + +> **Вопрос**. Влияет ли хранение массива в стеке или в куче на временную и про- странственную эффективность? +> +> **Ответ**. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном пространстве памяти, и эффективность операций с данными в основном оди- накова. Однако стек и куча имеют свои особенности, что приводит к следую- щим различиям: + +1) эффективность выделения и освобождения: стек -- это небольшая об- ласть памяти, выделение которой выполняется автоматически компи- лятором, в то время как память кучи относительно больше и может вы- деляться динамически в коде, что делает ее более подверженной фраг- ментации. Поэтому операции выделения и освобождения в куче обычно медленнее, чем в стеке; + +2) ограничение размера: память стека относительно мала, размер кучи обычно ограничен доступной памятью. Поэтому куча более подходит для хранения больших массивов; + +3) гибкость: размер массива в стеке должен быть определен на этапе ком- пиляции, тогда как размер массива в куче может быть динамически определен во время выполнения. + +> **Вопрос**. Почему для массива требуется, чтобы элементы были одного типа, а для списка это не обязательно? +> +> **Ответ**. Список состоит из узлов, которые соединяются между собой с помо- щью ссылок (указателей), и каждый узел может хранить данные различных типов, например int, double, string, object и т. д. +> +> Элементы массива, напротив, должны быть одного типа, чтобы можно было вычислить смещение для получения позиции соответствующего элемента. На- пример, если массив одновременно содержит элементы типов 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*). +> +> **Вопрос**. Фраза «Появление списка значительно повысило практическую ценность массива, но может привести к частичной потере памяти» означает, что потеря памяти связана с дополнительными переменными, такими как ем- кость, длина и коэффициент расширения? +> +> **Ответ**. Потеря памяти здесь имеет два значения. Во-первых, у списка уста- навливается начальная длина, но нам не всегда требуется столько места. Во- вторых, чтобы избежать частого расширения, обычно используется опреде- ленный коэффициент, например ×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\]. +> +> **Вопрос**. Операция res = \[0\] \* n создает список, в котором все целые 0 явля- ются независимыми? +> +> **Ответ**. В этом списке все целые 0 являются ссылками на один и тот же объ- ект. Это связано с тем, что в Python используется механизм пула для малых целых чисел (обычно от --5 до 256), чтобы максимизировать повторное исполь- зование объектов и повысить производительность. +> +> Хотя они ссылаются на один и тот же объект, мы все же можем независимо изменять каждый элемент списка, поскольку целые числа в Python являются неизменяемыми объектами. При изменении какого-либо элемента фактиче- ски происходит переключение на ссылку на другой объект, а не изменение са- мого исходного объекта. +> +> Однако, когда элементы списка являются изменяемыми объектами (напри- мер, списки, словари или экземпляры классов и т. д.), изменение какого-либо элемента напрямую изменяет и сам объект, и все элементы, ссылающиеся на этот объект, претерпевают те же изменения. +> +> Глава 5 diff --git a/ru/chapters/chapter_05.md b/ru/chapters/chapter_05.md new file mode 100644 index 000000000..4af9d177f --- /dev/null +++ b/ru/chapters/chapter_05.md @@ -0,0 +1,797 @@ +# Стек и очередь + +![](ru/docs/assets/media/image142.jpeg){width="3.71873687664042in" height="4.8125in"} + +1. **Стек** + +> *Стек* -- это линейная структура данных, которая следует логике «первый во- шел -- последний вышел». +> +> Стек можно сравнить со стопкой тарелок на столе: чтобы достать тарелку снизу, нужно сначала убрать все тарелки сверху. Заменив тарелки на элементы различных типов (например, целые числа, символы, объекты и т. д.), мы полу- чим структуру данных, называемую стеком. +> +> Верх стопки элементов называется вершиной стека, а низ -- основанием сте- ка, как показано на рис. 5.1. Операция добавления элемента на вершину стека называется вставка, а удаление элемента с вершины -- извлечение. + +![](ru/docs/assets/media/image144.jpeg)Помещение + +> Помещение +> +> Извлечение +> +> Извлечение +> +> **Рис. 5.1.** Правило «первый вошел -- последний вышел» для стека + +### Основные операции со стеком + +> Основные операции со стеком представлены в табл. 5.1, конкретные имена методов зависят от используемого языка программирования. Здесь в качестве примера используются распространенные имена push(), pop(), peek(). +> +> **Таблица 5.1.** Эффективность операций со стеком + ++-----------+--------------------------------------------------+---------------------------+ +| **Метод** | > **Описание** | > **Временная сложность** | ++==========:+==================================================+:=========================:+ +| push() | > Вставка элемента (добавление на вершину стека) | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ +| pop() | > Извлечение элемента с вершины стека | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ +| peek() | > Доступ к элементу на вершине стека | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ + +> Обычно достаточно использовать классы стека, встроенные в язык програм- мирования. Однако в некоторых языках может не быть специального класса для стека. Тогда можно использовать массив или связный список в качестве стека, игнорируя операции, не связанные со стеком. +> +> \# === 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.2. Этот метод вставки узла называется встав- ка в голову. Для операции извлечения элемента достаточно удалить головной узел из связного списка. +> +> ![](ru/docs/assets/media/image146.jpeg) + +![](ru/docs/assets/media/image148.jpeg)![](ru/docs/assets/media/image150.jpeg) + +> **Рис. 5.2.** Операции вставки и извлечения в стеке на основе связного списка +> +> Ниже приведен пример кода для реализации стека на основе связного списка. +> +> \# === 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 + +##### Реализация на основе массива + +> При использовании для реализации стека массива можно считать конец мас- сива вершиной стека. Операции вставки и извлечения соответствуют добавле- +> +> нию и удалению элементов в конце массива, как показано на рис. 5.3. Времен- ная сложность этих операций составляет *O*(1). + +![](ru/docs/assets/media/image152.jpeg)![](ru/docs/assets/media/image154.jpeg)![](ru/docs/assets/media/image156.jpeg) + +> **Рис. 5.3.** Операции вставки и извлечения в стеке на основе массива +> +> Поскольку количество вставляемых элементов может постоянно увеличи- ваться, можно использовать динамический массив, чтобы не заниматься рас- ширением массива самостоятельно. Ниже приведен пример кода. +> +> \# === 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 + +### Сравнение двух реализаций + +##### Поддерживаемые операции + +> Обе реализации поддерживают все операции, определенные для стека. Реали- зация на основе массива дополнительно поддерживает произвольный доступ, но это выходит за рамки определения стека, поэтому обычно не используется. + +##### Временная сложность + +> В реализации на основе массива операции добавления и удаления элемента выполняются в заранее выделенной непрерывной памяти, что обеспечивает хорошую локальность кеша и, следовательно, высокую эффективность. Одна- ко, если при добавлении элемента превышается емкость массива, срабатыва- ет механизм расширения, что приводит к увеличению временной сложности данной операции до *O*(*n*). +> +> В реализации на основе связного списка расширение происходит очень гиб- ко, и не возникает проблемы снижения эффективности, как в случае расшире- ния массива. Однако операция добавления элемента требует инициализации объекта узла и изменения указателя, что делает ее относительно менее эффек- тивной. Тем не менее, если добавляемый элемент уже является объектом узла, можно избежать шага инициализации, что повысит эффективность. +> +> Таким образом, если элементы операций добавления и удаления являются примитивными типами данных, такими как int или double, можно сделать сле- дующие выводы: + +1) стек, реализованный на основе массива, при срабатывании механизма расширения теряет в эффективности, но, так как расширение является редкой операцией, средняя эффективность выше; + +2) стек, реализованный на основе связного списка, обеспечивает более ста- бильную эффективность. + +##### Пространственная сложность + +> При инициализации массива система выделяет для него начальную емкость, которая может превышать фактические потребности. Кроме того, механизм расширения обычно осуществляется с определенным коэффициентом (на- пример, в 2 раза), и емкость после расширения также может превышать фак- тические потребности. Поэтому **стек**, **реализованный на основе массива**, **может приводить к некоторым потерям пространства**. +> +> Однако, так как узлы связного списка требуют дополнительного хранения указателей, **занимаемое ими пространство сравнительно больше**. +> +> Таким образом, нельзя однозначно определить, какая реализация более эко- номична в плане памяти, необходимо анализировать конкретные ситуации. + +### Типичные сценарии применения стека + +- Возврат и переход вперед в браузере, отмена и повтор в программном обеспечении. Каждый раз, когда открывается новая веб-страница, бра- узер выполняет добавление предыдущей страницы в стек, что позволя- ет вернуться к ней с помощью операции возврата. Операция возврата фактически является выполнением удаления из стека. Если требуется поддержка как возврата, так и перехода вперед, необходимо использо- вать два стека. + +- Управление памятью программы. Каждый раз при вызове функции си- стема добавляет на вершину стека фрейм для записи контекстной ин- формации функции. В рекурсивных функциях на этапе нисходящей ре- + +> курсии постоянно выполняется добавление в стек, а на этапе восходящей рекурсии -- удаление из стека. + +#### Очередь + +> *Очередь* -- это линейная структура данных, следующая правилу «первый при- шел -- первый вышел». Как следует из названия, очередь моделирует реаль- ную очереди, когда новые элементы постоянно добавляются в конец очереди, а элементы в начале очереди покидают ее последовательно. +> +> Начало очереди называется голова, а конец -- хвост, см. рис. 5.4. Операция добавления элемента в конец очереди называется добавление в очередь, а уда- ление элемента из начала очереди -- удаление из очереди. + +![](ru/docs/assets/media/image158.jpeg) + +> **Рис. 5.4.** Правило очереди «первый пришел -- первый вышел» + +### Основные операции с очередью + +> Основные операции с очередью представлены в табл. 5.2. Следует отметить, что имена методов могут различаться в зависимости от языка программиро- вания. Здесь используются те же названия методов, что и для стека. + ++-------------+---------------------------------------------------+---------------------------+ +| > **Метод** | > **Описание** | > **Временная сложность** | ++=============+===================================================+===========================+ +| > push() | > Добавление элемента в очередь, т. е. добавление | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ +| > pop() | > Удаление элемента из головы очереди | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ +| > peek() | > Доступ к элементу в голове очереди | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ + +> элемента в конец очереди +> +> Можно использовать готовый класс очереди в языке программирования. +> +> \# === 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.5. +> +> ![](ru/docs/assets/media/image160.jpeg) + +![](ru/docs/assets/media/image162.jpeg)![](ru/docs/assets/media/image164.jpeg) + +> **Рис. 5.5.** Операции добавления и удаления в очереди, реализованной на основе связного списка +> +> Ниже приведен код реализации очереди с использованием связного списка. +> +> \# === File: linkedlist_queue.py === class LinkedListQueue: +> +> \"\"\"Очередь на основе связного списка.\"\"\" +> +> 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): \"\"\"Добавление в очередь.\"\"\" +> +> \# Добавление 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 +> +> def to_list(self) -\> list\[int\]: \"\"\"Преобразование в список для печати.\"\"\" queue = \[\] +> +> temp = self.\_front while temp: +> +> queue.append(temp.val) temp = temp.next +> +> return queue + +##### Реализация на основе массива + +> Удаление первого элемента в массиве имеет временную сложность *O*(*n*), что снижает эффективность операции удаления из очереди. Однако можно ис- пользовать следующий изящный метод, чтобы избежать этой проблемы. +> +> Можно использовать переменную front для указания на индекс первого эле- мента очереди и поддерживать переменную size для записи длины очереди. Определим переменную rear = front + size. Тогда rear будет указывать на сле- дующий элемент после хвоста очереди. +> +> В этой схеме **эффективный диапазон элементов в массиве составляет** + +\[front, rear - 1\]. Методы реализации различных операций показаны на рис. 5.6. + +- **Добавление в очередь**: присвоение нового элемента индексу rear и уве- личение size на 1. + +- **Удаление из очереди**: достаточно увеличить front на 1 и уменьшить size + +> на 1. +> +> Можно заметить, что добавление в очередь и удаление из нее требуют толь- ко двух операций, временная сложность каждой из которых равна *O*(1). + +![](ru/docs/assets/media/image166.jpeg) + +> **Рис. 5.6.** Операции добавления и удаления в очереди, реализованной на основе массива +> +> ![](ru/docs/assets/media/image168.jpeg) + +![](ru/docs/assets/media/image170.jpeg) + +> **Рис. 5.6.** *Окончание* +> +> Может возникнуть трудность: в процессе постоянного добавления и удале- ния положения front и rear перемещаются вправо, и **когда они достигают конца массива**, **дальнейшее перемещение становится невозможным**. Чтобы решить эту проблему, можно рассматривать массив как кольцевой мас- сив с соединенными концами. +> +> Для кольцевого массива необходимо, чтобы front или rear, пересекая конец массива, возвращались к его началу для продолжения обхода. Этот цикличе- ский процесс можно реализовать с помощью операции взятия остатка, при- мер кода приведен ниже. +> +> \# === 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 +> +> 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 +> +> Реализованная выше очередь все еще имеет ограничение: ее длина неиз- менна. Однако эту проблему несложно решить, заменить массив на динамиче- ский с помощью механизма расширения. Заинтересованные читатели могут попробовать реализовать это самостоятельно. +> +> Выводы о сравнении двух реализаций аналогичны выводам о стеке, поэто- му здесь мы не будем повторяться. + +### Типичные сценарии применения очереди + +1. Заказы на маркетплейсах. После оформления заказа покупателем он до- бавляется в очередь, и система затем обрабатывает заказы в порядке их поступления. В период распродаж за короткое время создается огром- ное количество заказов, и высокая нагрузка становится проблемой для разработчиков программного обеспечения. + +2. Различные списки задач. Любая ситуация, требующая реализации прин- ципа «первым пришел -- первым обслужен». Например, очередь заданий в принтере, очередь заказов в ресторане и т. д. Очередь в этих ситуациях эффективно поддерживает порядок обработки. + +#### двусторонняя очередь + +> В обычной очереди можно удалять только элементы из начала и добавлять элементы только в конец. *Двусторонняя очередь* предоставляет большую гиб- кость, позволяя выполнять операции добавления или удаления элементов как в начале, так и в конце, см. рис. 5.7. +> +> **Двусторонняя очередь** +> +> **(Deque)** +> +> **Голова очереди** +> +> Добавление элемента в голову +> +> ![](ru/docs/assets/media/image172.jpeg)push_first(**1**) +> +> Извлечение элемента с головы +> +> pop_first() +> +> **Извлечение из очереди** +> +> **Добавление в очередь** +> +> **Добавление в очередь** +> +> **Извлечение из очереди** +> +> **Хвост очереди** + +Добавление Извлечение элемента элемента + +> push_last(**4**) +> +> с хвоста +> +> pop_last() +> +> **Рис. 5.7.** Операции в двусторонней очереди + +### Основные операции с двусторонней очередью + +Обычные операции с двусторонней очередью представлены в табл. 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) | ++----------------+----------------------------------------+---------------------------+ + +> Аналогично обычной очереди можно использовать уже реализованный в языке программирования класс двусторонней очереди. +> +> \# === 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.8. +> +> ![](ru/docs/assets/media/image174.jpeg) + +![](ru/docs/assets/media/image176.jpeg)![](ru/docs/assets/media/image178.jpeg) + +> **Рис. 5.8.** Операции добавления и удаления в двусторонней очереди на основе связного списка +> +> ![](ru/docs/assets/media/image180.jpeg) + +![](ru/docs/assets/media/image182.jpeg) + +> **Рис. 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: +> +> \"\"\" Двусторонняя очередь на основе двусвязного списка.\"\"\" +> +> 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 \# Временное сохранение значения +> +> \# головного узла. +> +> \# Удаление головного узла. +> +> 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 + +##### Реализация на основе массива + +> Аналогично реализации обычной очереди для двусторонней очереди можно использовать кольцевой массив, как показано на рис. 5.9. + +![](ru/docs/assets/media/image184.jpeg)![](ru/docs/assets/media/image186.jpeg) + +> **Рис. 5.9.** Операции добавления и удаления в двусторонней очереди на основе массива +> +> ![](ru/docs/assets/media/image188.jpeg) + +![](ru/docs/assets/media/image190.jpeg) + +> **Рис. 5.9.** *Продолжение* +> +> ![](ru/docs/assets/media/image192.jpeg) +> +> **Рис. 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 +> +> 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: +> +> \"\"\" Доступ к элементу в начале очереди.\"\"\" +> +> 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 + +### Сценарии применения двусторонней очереди + +> Двусторонняя очередь сочетает в себе логику стека и очереди. **Поэтому она применима для всех сценариев этих двух структур**, **одновременно пре- доставляя большую степень свободы**. +> +> Известно, что функция отмены в программном обеспечении обычно реа- лизуется с помощью стека: система помещает каждое изменение в стек с по- мощью операции push, а затем выполняет отмену с помощью операции pop. Однако, учитывая ограничения системных ресурсов, программное обеспече- ние обычно ограничивает количество шагов отмены (например, позволяет со- хранить только 50 шагов). Когда длина стека превышает 50, программе нужно выполнить удаление внизу стека (в начале очереди). **Но стек не может реа- лизовать эту функцию**, **и в этом случае необходимо использовать дву- стороннюю очередь вместо стека**. Следует отметить, что основная логика отмены по-прежнему следует принципу стека «первым пришел -- последним вышел», просто двусторонняя очередь позволяет более гибко реализовать не- которые дополнительные логические операции. + +#### резюме + +##### Ключевые моменты + +- Стек -- это структура данных, которая следует принципу «первым при- шел -- последним вышел» и может быть реализована с помощью массива или связного списка. + +- В плане временной сложности реализация стека с использованием мас- сива обладает более высокой средней эффективностью, но во время рас- ширения сложность времени выполнения одной операции добавления + +> 5.4. Резюме ❖ **145** +> +> [в]{.smallcaps} стек может ухудшиться до *O*(*n*). В сравнении с этим реализация стека с использованием связного списка обладает более стабильной эффек- тивностью. + +- В плане пространственной сложности реализация стека с использовани- ем массива может привести к определенной степени потери простран- ства. Однако следует отметить, что память, занимаемая узлами связного списка, больше, чем у элементов массива. + +- Очередь -- это структура данных, которая следует принципу «первым пришел -- первым вышел» и также может быть реализована с помощью массива или связного списка. В плане временной и пространственной сложности выводы по очереди схожи с выводами по стеку. + +- Двусторонняя очередь -- это очередь с большей степенью свободы, она позволяет добавлять и удалять элементы с обоих концов. + +##### Вопросы и ответы + +> **Вопрос**. Реализована ли функция «вперед-назад» в браузере с помощью дву- стороннего связного списка? +> +> **Ответ**. Функция «вперед-назад» в браузере, по сути, является типичным проявлением стека. Когда пользователь посещает новую страницу, она добав- ляется на вершину стека. Когда пользователь нажимает кнопку **Назад**, страни- ца извлекается с вершины стека. Использование двусторонней очереди позво- ляет удобно реализовать некоторые дополнительные операции, что упомина- ется в разделе «Двусторонняя очередь». +> +> **Вопрос**. Нужно ли освобождать память узла после извлечения из стека? +> +> **Ответ**. Если в дальнейшем необходимо использовать извлеченный узел, то освобождать память не нужно. Если узел больше не нужен, в языках Java и Python имеется автоматический механизм сборки мусора, поэтому ручное освобожде- ние памяти не требуется. В C и C++ необходимо освобождать память вручную. +> +> **Вопрос**. Двусторонняя очередь похожа на два стека, соединенных вместе. +> +> Каково ее назначение? +> +> **Ответ**. Двусторонняя очередь подобна комбинации стека и очереди или двум стекам, соединенным вместе. Она представляет собой логику стека и очереди, поэтому подходит для всех сценариев их применения, но является более гибкой. **Вопрос**. Как конкретно реализуются функции отмены и повтора операций? +> +> **Ответ**. Используются два стека: стек A для отмены, стек B для повтора. + +1. Когда пользователь выполняет операцию, она помещается в стек A, а стек + +> B очищается. + +2. Когда пользователь выполняет отмену, из стека A извлекается последняя операция и помещается в стек B. + +3. Когда пользователь выполняет повтор, из стека B извлекается последняя операция и помещается в стек A. + +> Глава 6 diff --git a/ru/chapters/chapter_06.md b/ru/chapters/chapter_06.md new file mode 100644 index 000000000..6f2c87668 --- /dev/null +++ b/ru/chapters/chapter_06.md @@ -0,0 +1,807 @@ +# Хеш-таблицы + +![](ru/docs/assets/media/image193.jpeg){width="3.8073009623797027in" height="4.927083333333333in"} + +#### хеш-таблицы + +> *Хеш-таблица* реализует эффективный поиск элементов через установле- ние соответствия между ключом key и значением value. Более конкретно, передав ключ в хеш-таблицу, можно получить соответствующее значение за время *O*(1). +> +> Пусть имеется *n* студентов, у каждого из которых есть имя и номер. Если нужно реализовать функцию «ввести номер студента и получить соответству- ющее имя», то можно использовать хеш-таблицу, как показано на рис. 6.1. + +![](ru/docs/assets/media/image195.jpeg) + +> **Рис. 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), что очень эффективно. + +1. **Основные операции с хеш-таблицами** + +> К основным операциям с хеш-таблицами относятся: инициализация, поиск, добавление и удаление пар ключ--значение. Ниже приведен пример кода. +> +> \# === 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) + +### Простая реализация хеш-таблицы + +> Рассмотрим самый простой случай, **когда хеш-таблица реализуется с помо- щью одного массива**. В хеш-таблице каждый пустой слот массива называется корзиной, и каждая корзина может хранить одну пару ключ--значение. Таким образом, операция поиска заключается в нахождении корзины, соответствую- щей ключу key, и получении значения value из нее. +> +> Как определить корзину, соответствующий ключу? Это осуществляется с по- мощью хеш-функции. Хеш-функция предназначена для отображения большо- го входного пространства в меньшее выходное пространство. В хеш-таблице входное пространство -- это все ключи, а выходное пространство -- это все кор- зины (индексы массива). Другими словами, **передав ключ в хеш-функцию, можно определить место хранения пары ключ--значение в массиве**. +> +> Процесс вычисления хеш-функции для ключа включает следующие этапы: + +1) вычисление хеш-значения с помощью некоторого хеш-алгоритма hash(); + +2) взятие остатка от деления хеш-значения на количество корзин capacity (длину массива) для получения индекса массива index, соответствующе- го ключу key. + +> index = hash(key) % capacity +> +> После этого можно использовать значение index для доступа к соответству- ющей корзине в хеш-таблице и получения значения value. +> +> Предположим, что длина массива capacity = 100, хеш-алгоритм hash(key) = key. Тогда хеш-функция будет иметь вид key % 100. На рис. 6.2 показан принцип ра- боты хеш-функции на примере ключа «номер» и значения «имя» для студента. +> +> ![](ru/docs/assets/media/image197.jpeg) **Ввод 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: +> +> \"\"\" Хеш-таблица на основе массива.\"\"\" +> +> 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 = \[\] +> +> 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) + +### Хеш-коллизии и расширение + +> По своей сути хеш-функция выполняет отображение входного пространства, состоящего из всех ключей, в выходное пространство, состоящее из всех ин- дексов массива. Причем входное пространство зачастую значительно больше выходного. Следовательно, **теоретически неизбежна ситуация**, **когда не- сколько входов соответствуют одному выходу**. +> +> Для хеш-функции в примере выше, если последние две цифры ключа совпа- дают, результат хеш-функции также совпадает. Например, при запросе студен- тов с номерами 12836 и 20336 мы получим: +> +> 12836 % 100 = 36 +> +> 20336 % 100 = 36. +> +> Оба номера указывают на одно и то же имя, что очевидно неверно, см. рис. 6.3. Такую ситуацию, когда несколько входов соответствуют одному вы- ходу, называют хеш-коллизией. +> +> ![](ru/docs/assets/media/image199.jpeg) **Ввод key** +> +> **Индекс Массив Хеш-коллизия** +> +> **Вывод value** +> +> \"Иван\" + +Хеш-функция + +\"Петр\" + +\"Яков\" + +> \"Иван\" +> +> \"Иван\" +> +> \"Ира\" +> +> \"Аня\" +> +> (Каждая ячейка хранит одну пару ключ--значение) +> +> **Рис. 6.3.** Пример хеш-коллизии +> +> Логично предположить, что чем больше емкость хеш-таблицы *n*, тем ниже вероятность распределения нескольких ключей в одну корзину и тем меньше коллизий. Поэтому **можно уменьшить количество хеш-коллизий**, **увели- чивая емкость хеш-таблицы**. +> +> Как показано на рис. 6.4, до увеличения емкости пары ключ--значение (136, A) + +и (236, D) попадали в одну корзину, а после увеличения емкости коллизия исчезла. + +> ![](ru/docs/assets/media/image201.jpeg)**Увеличение хеш-таблицы** +> +> **Рис. 6.4.** Увеличение емкости хеш-таблицы +> +> Подобно увеличению емкости массива, увеличение емкости хеш-таблицы требует переноса всех пар ключ--значение из старой хеш-таблицы в новую, что является очень затратной по времени операцией. Кроме того, поскольку емкость хеш-таблицы изменяется, необходимо заново вычислять местополо- жение хранения всех пар ключ--значение с помощью хеш-функции, что еще больше увеличивает вычислительные затраты процесса расширения. Поэтому в языках программирования обычно резервируется достаточно большая ем- кость хеш-таблицы, чтобы избежать частого увеличения. +> +> Коэффициент заполнения является важным понятием для хеш-таблицы. Он определяется как количество элементов в хеш-таблице, деленное на коли- чество корзин, и используется для оценки степени серьезности хеш-коллизий, **а также часто служит условием для увеличения емкости хеш-таблицы**. Например, в Java, когда коэффициент заполнения превышает 0.75, система увеличивает емкость хеш-таблицы в 2 раза. + +#### хеш-коллизии + +> Как упоминалось в предыдущем разделе, **в обычных условиях входное про- странство хеш-функции значительно больше выходного пространства**, +> +> поэтому хеш-коллизии теоретически неизбежны. Например, если входное пространство состоит из всех целых чисел, а выходное пространство соответ- ствует размеру массива, то обязательно несколько целых чисел будут отобра- жаться в один и тот же индекс корзины. +> +> Хеш-коллизии могут привести к ошибкам в результатах запросов, серьезно влияя на работоспособность хеш-таблицы. Чтобы решить эту проблему, при возникновении хеш-коллизий выполняется увеличение емкости хеш-таблицы до тех пор, пока коллизии не исчезнут. Этот метод понятен и прост в реализа- ции, но крайне неэффективен, поскольку увеличение емкости хеш-таблицы требует значительных затрат на перенос данных и вычисление хеш-значений. Для повышения эффективности можно использовать следующие стратегии: + +1) улучшение структуры данных хеш-таблицы, **чтобы она могла нор- мально функционировать при возникновении хеш-коллизий**; + +2) выполнение увеличения емкости только при необходимости, т. е. когда хеш-коллизии становятся достаточно серьезными. + +> Основные методы улучшения структуры хеш-таблицы включают цепную адресацию и открытую адресацию. + +### Цепная адресация + +> В исходной хеш-таблице каждая корзина может хранить только одну пару ключ--значение. Цепная адресация преобразует отдельный элемент в связный список, где пары ключ--значение выступают в качестве узлов списка, и все пары ключ--значение, вызвавшие коллизии, хранятся в одном и том же списке. На рис. 6.5 представлен пример хеш-таблицы с цепной адресацией. + +![](ru/docs/assets/media/image203.jpeg) + +> **Рис. 6.5.** Хеш-таблица с цепной адресацией +> +> Методы работы с хеш-таблицей, реализованной на основе цепной адреса- ции, изменяются следующим образом. + +- **Поиск элемента**: вводится ключ, с помощью хеш-функции определяет- ся индекс корзины, после чего осуществляется доступ к головному узлу списка. Затем выполняется обход списка и сравнение ключей для поиска целевой пары ключ--значение. + +- **Добавление элемента**: сначала с помощью хеш-функции осуществля- ется доступ к головному узлу списка, затем узел (пара ключ--значение) добавляется в список. + +- **Удаление элемента**: на основе результата хеш-функции осуществляется доступ к головному узлу списка, затем выполняется обход списка для по- иска целевого узла и его удаления. + +> Цепная адресация имеет следующие ограничения. + +- **Увеличение занимаемого пространства**: связный список содержит указатели на узлы, что требует больше памяти по сравнению с массивом. + +- **Снижение эффективности поиска**: необходимо линейно обходить связный список для нахождения соответствующего элемента. + +> Ниже приведен простой пример реализации хеш-таблицы с цепной адреса- цией. Следует обратить внимание на следующие моменты. + +- Использование списка (динамического массива) вместо связного списка для упрощения кода. В данной конфигурации хеш-таблица (массив) со- держит несколько корзин, каждая из которых является списком. + +- В данной реализации предусмотрен метод расширения хеш-таблицы. Когда коэффициент заполнения превышает 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: \"\"\" Операция поиска.\"\"\" +> +> 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) +> +> 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*). + +### Открытая адресация + +> Открытая адресация не вводит дополнительные структуры данных, а исполь- зует многократное пробирование для обработки хеш-конфликтов. Основные методы пробирования включают линейное пробирование, квадратичное про- бирование и двойное хеширование. +> +> Далее на примере линейного зондирования рассмотрим механизм работы хеш-таблицы с открытой адресацией. + +##### Линейное зондирование + +> При линейном зондировании используется линейный поиск с фиксирован- ным шагом. Метод зондирования отличается от обычной хеш-таблицы. + +- **Вставка элемента**: с помощью хеш-функции вычисляется индекс кор- зины. Если корзина уже занята, начинается линейный обход от места конфликта (обычно с шагом 1) до нахождения пустой корзины, куда и вставляется элемент. + +- **Поиск элемента**: при обнаружении хеш-конфликта используется тот же шаг для линейного обхода до нахождения соответствующего элемента, после чего возвращается его значение. Если встречается пустая корзина, это озна- чает, что целевой элемент отсутствует в хеш-таблице, возвращается None. + +> На рис. 6.6 демонстрируется распределение пар ключ--значение в хеш- таблице с открытой адресацией (линейное зондирование). Согласно этой хеш- функции ключи с одинаковыми последними двумя цифрами отображаются в одну корзину. Однако благодаря линейному зондированию они последова- тельно размещаются в этой и следующих корзинах. +> +> Однако линейное зондирование **подвержено так называемой класте- ризации**. Чем длиннее последовательность занятых позиций в массиве, тем выше вероятность хеш-конфликта в этих позициях. Это способствует дальней- шему росту кластера, создавая порочный круг и в конечном итоге снижая эф- фективность операций добавления, удаления и поиска. +> +> Стоит отметить, что **в хеш-таблице с открытой адресацией нельзя на- прямую удалять элементы**. Это связано с тем, что удаление элемента соз- дает в массиве пустую корзину None. И затем при поиске элемента линейное зондирование, достигнув этой пустой корзины, закончит поиск, что приведет к невозможности доступа к элементам ниже этой корзины. Программа может ошибочно посчитать, что эти элементы отсутствуют, как показано на рис. 6.7. +> +> ![](ru/docs/assets/media/image205.jpeg) +> +> **Рис. 6.6.** Распределение пар ключ--значение в хеш-таблице с открытой адресацией (линейное зондирование) + +![](ru/docs/assets/media/image207.jpeg) + +> **Рис. 6.7.** Проблемы поиска, вызванные удалением элементов при открытой адресации +> +> Для решения этой проблемы можно использовать механизм ленивого удаления: элементы не удаляются из хеш-таблицы напрямую, **вместо это- го для пометки этой корзины используется константа** TOMBSTONE. При та- ком подходе None и TOMBSTONE обозначают пустую корзину, в которую можно поместить пару ключ--значение. Однако при линейном зондировании зна- чение TOMBSTONE не завершает обход, так как ниже все еще могут находиться пары ключ--значение. +> +> Однако **ленивое удаление может ускорить падение производительно- сти хеш-таблицы**. Это происходит потому, что каждая операция удаления оставляет метку удаления, и с увеличением количества значений 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: +> +> \# Если ранее была метка удаления, перемещение пары +> +> \# ключ-значение в этот индекс. 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 +> +> 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) + +##### Квадратичное зондирование + +> Квадратичное зондирование, как и линейное, является одной из распростра- ненных стратегий открытой адресации. При возникновении конфликта ква- дратичное зондирование пропускает не просто фиксированное количество шагов, а количество шагов, равное квадрату числа попыток, т. е. 1, 4, 9, \... +> +> Квадратичное зондирование обладает следующими преимуществами: + +- квадратичное зондирование, пропуская расстояние, равное квадрату числа попыток, стремится сгладить эффект кластеризации линейного зондирования; + +- квадратичное зондирование пропускает большее расстояние в поисках пустого места, что способствует более равномерному распределению данных. + +> Тем не менее квадратичный поиск не является идеальным и обладает сле- дующими недостатками: + +- все еще существует эффект кластеризации, т. е. некоторые позиции за- нимаются более вероятно, чем другие; + +- из-за быстрого роста квадрата квадратичное зондирование может не ох- ватить всю хеш-таблицу. То есть даже при наличии в хеш-таблице пустых корзин квадратичное зондирование может никогда не добраться до них. + +##### Множественное хеширование + +> Как следует из названия, метод множественного хеширования использует для поиска несколько хеш-функций *f*1(*x*), *f*2(*x*), *f*3(*x*), \... + +- **Вставка элемента**: если хеш-функция *f*1(*x*) вызывает конфликт, вычис- ляется *f*2(*x*) и т. д., пока не будет найдено пустое место для вставки эле- мента. + +- **Поиск элемента**: поиск выполняется в том же порядке хеш-функций, пока не будет найден целевой элемент. Если встречается пустое место или были испробованы все хеш-функции, это означает, что элемента в хеш-таблице нет, и возвращается None. + +> По сравнению с линейным зондированием метод множественного хеши- рования менее склонен к кластеризации, но использование нескольких хеш- функций увеличивает вычислительную нагрузку. + +### Выбор языка программирования + +> Различные языки программирования используют разные стратегии реализа- ции хеш-таблиц, ниже приведено несколько примеров. + +- В Python используется метод открытой адресации. В словарях dict для поиска применяется псевдослучайное число. + +- В Java используется цепная адресация. Начиная с JDK 1.8, когда длина мас- сива в HashMap достигает 64, а длина цепочки достигает 8, цепочка преоб- разуется в красно-черное дерево для повышения эффективности поиска. + +- В Go используется цепная адресация. Здесь предусмотрено, что в каждой корзине может храниться не более 8 пар ключ--значение. При превыше- нии емкости подключается дополнительная корзина. При избыточном количестве дополнительных корзин выполняется специальная опера- ция расширения для поддержания производительности. + +#### Алгоритмы хеширования + +> В предыдущих разделах были рассмотрены принципы работы хеш-таблиц и методы обработки хеш-конфликтов. Однако ни открытая, ни цепная адре- сация не могут уменьшить вероятность возникновения хеш-конфликтов, **они лишь обеспечивают корректную работу хеш-таблицы при их воз- никновении**. +> +> Если хеш-конфликты происходят слишком часто, производительность хеш- таблицы резко снижается. Как показано на рис. 6.8, для хеш-таблицы с цепной адресацией в идеальном случае пары ключ--значение равномерно распреде- лены по всем корзинам, что обеспечивает наилучшую эффективность поиска. В худшем случае все пары ключ--значение хранятся в одной корзине, и вре- менная сложность повышается до *O*(*n*). +> +> ![](ru/docs/assets/media/image209.jpeg) +> +> **Рис. 6.8.** Лучший и худший случаи хеш-конфликтов +> +> **Распределение пар ключ--значение определяется хеш-функцией**. Вспомним этапы вычисления хеш-функции: сначала вычисляется хеш- значение, затем берется остаток от деления на длину массива. +> +> index = hash(key) % capacity +> +> Из этого выражения видно, что при фиксированной емкости хеш-таблицы capacity **алгоритм хеширования** hash() **определяет выходное значение**, которое, в свою очередь, определяет распределение пар ключ--значение в хеш-таблице. +> +> Это означает, что для снижения вероятности возникновения хеш-конфликтов следует сосредоточиться на разработке алгоритма хеширования hash(). + +### Цели алгоритма хеширования + +> Для создания быстрой и надежной структуры данных хеш-таблицы алгоритм хеширования должен обладать следующими характеристиками. + +- **Детерминированность**: для одинакового ввода алгоритм хеширования должен всегда давать одинаковый вывод. Это необходимо для обеспече- ния надежности работы хеш-таблицы. + +- **Высокая эффективность**: процесс вычисления хеш-значения должен быть достаточно быстрым. Чем меньше вычислительные затраты, тем выше практическая ценность хеш-таблицы. + +- **Равномерное распределение**: алгоритм хеширования должен обе- спечивать равномерное распределение пар ключ--значение в хеш- таблице. Чем равномернее распределение, тем ниже вероятность хеш- конфликтов. + +> На практике алгоритмы хеширования применяются не только для реализа- ции хеш-таблиц, но и в других областях. + +- **Хранение паролей**: для защиты паролей пользователей система обыч- но не хранит пароли в открытом виде, а сохраняет их хеш-значения. Когда пользователь вводит пароль, система вычисляет его хеш-значение и сравнивает с сохраненным. Если они совпадают, пароль считается правильным. + +- **Проверка целостности данных**: отправитель данных может вычис- лить хеш-значение данных и отправить его вместе с данными. По- лучатель может заново вычислить хеш-значение полученных данных и сравнить его с полученным. Если они совпадают, данные считаются неизмененными. + +> В криптографических приложениях для предотвращения обратного вычис- ления исходного пароля из хеш-значения и других видов обратной инженерии алгоритм хеширования должен обладать дополнительными характеристиками. + +- **Необратимость**: невозможность извлечь какую-либо информацию о входных данных из хеш-значения. + +- **Устойчивость к коллизиям**: должно быть крайне сложно найти два раз- личных входа, дающих одинаковое хеш-значение. + +- **Эффект лавины**: небольшие изменения на входе должны приводить к значительным и непредсказуемым изменениям на выходе. + +> Следует отметить, что **«равномерное распределение» и «устойчивость к коллизиям»** -- это два независимых понятия, и выполнение одного из них не обязательно означает выполнение другого. Например, хеш-функция key % 100 при случайном вводе значения key может давать равномерное распределение. Однако этот алгоритм хеширования слишком прост, и все ключи с одинаковыми последними двумя цифрами будут иметь одинако- вый вывод, что позволяет легко извлечь пригодные ключи из хеш-значения и взломать пароль. + +### Разработка алгоритма хеширования + +> Создание хеш-алгоритмов представляет собой сложную задачу, требующую учета множества факторов. Однако для некоторых несложных сценариев мож- но разработать простые хеш-алгоритмы. + +- **Аддитивный хеш**: складываются ASCII-коды каждого символа входных данных, полученная сумма используется в качестве хеш-значения. + +- **Мультипликативный хеш**: используя свойство некоррелированности умножения, на каждом шаге значение хеша умножается на константу, и в результат добавляется ASCII-код очередного символа. + +- **Хеш с использованием операции XOR**: каждый элемент входных дан- ных накапливается в хеш-значении с помощью операции XOR. + +- **Ротационный хеш**: ASCII-коды каждого символа накапливаются в хеш- значении, при этом перед каждым накоплением выполняется операция ротации хеш-значения. + +> \# === 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: +> +> 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, \... }. +> +> Следует отметить, что если ключи распределены случайно и равномерно, то выбор простого или составного числа в качестве модуля не имеет значения -- оба варианта обеспечат равномерное распределение хеш-значений. Однако при наличии периодичности в распределении ключей использование состав- ного числа в качестве модуля может привести к кластеризации. +> +> В общем случае выбирается простое число в качестве модуля, и это простое число должно быть достаточно большим, чтобы максимально устранить пери- одические закономерности и повысить устойчивость хеш-алгоритма. + +### Распространенные хеш-алгоритмы + +> Нетрудно заметить, что описанные выше простые хеш-алгоритмы довольно хрупкие и далеки от достижения целей создания хеш-алгоритмов. Например, сложение и операция XOR удовлетворяют коммутативному закону, поэтому соответствующие хеш-алгоритмы не различают строки с одинаковым содер- жанием, но разным порядком символов, что может усилить хеш-конфликты и вызвать некоторые проблемы с безопасностью. +> +> На практике обычно используются стандартные хеш-алгоритмы, такие как MD5, SHA-1, SHA-2 и SHA-3. Они могут отображать входные данные произ- вольной длины в хеш-значения фиксированной длины. +> +> На протяжении почти ста лет хеш-алгоритмы постоянно обновляются и оп- тимизируются. Одни исследователи стремятся повысить производительность, другие исследователи и хакеры сосредоточены на поиске проблем с безопас- ностью. В табл. 6.2 представлены распространенные хеш-алгоритмы, исполь- зуемые в реальных приложениях. + +- В MD5 и SHA-1 были обнаружены многочисленные уязвимости, поэтому они не используются в сценариях, в которых требуется высокий уровень безопасности. + +- SHA-256 из серии SHA-2 является одним из самых безопасных хеш- алгоритмов, до сих пор не было обнаружено ни одной уязвимости, по- этому он часто используется в различных приложениях и протоколах безопасности. + + + +- SHA-3 имеет меньшие затраты на реализацию и более высокую вычис- лительную эффективность по сравнению с SHA-2, но в настоящее время его использование не так широко распространено, как серии SHA-2. + ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| | > **MD5** | > **SHA-1** | > **SHA-2** | > **SHA-3** | ++==========================+==================================================================+================+=======================+==================================================+ +| > Год появления | > 1992 | > 1995 | > 2002 | > 2008 | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Длина вывода | > 128 бит | > 160 бит | > 256/512 бит | > 224/256/384/512 бит | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Хеш-конфликты | > Много | > Много | > Мало | > Мало | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Уровень без- опасности | > Низкий, есть из- вестные уязви- мости | > Низкий, есть | > Высокий | > Высокий | +| | | > | | | +| | | > уязвимости | | | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Применение | > Устарел, но еще используется для проверки целост- ности данных | > Устарел | > Проверка транзакций | > Может использовать- ся в качестве замены SHA-2 | +| | | | > | | +| | | | > в криптовалюте, | | +| | | | > | | +| | | | > подписи и т. д. | | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ + +> известные +> +> цифровые + +### Хеш-значения для структур данных + +> Ключи в хеш-таблице могут быть представлены в виде целых чисел, дробей или строк. Языки программирования обычно предоставляют встроенные хеш- алгоритмы для своих типов данных, чтобы вычислять индексы корзин в хеш- таблице. Например, в Python можно вызвать функцию hash() для вычисления хеш-значений для различных типов данных. + +- Хеш-значение целых чисел и булевых величин совпадает с их значением. + +- Хеш-значение дробных чисел и строк вычисляется по более сложному ал- горитму, заинтересованные читатели могут изучить его самостоятельно. + +- Хеш-значение кортежа получается путем хеширования каждого элемен- та и объединения этих хеш-значений в одно. + +- Хеш-значение объекта генерируется на основе его адреса в памяти. Пу- тем переопределения метода хеширования объекта можно реализовать генерацию хеша на основе его содержимого. + +> \# === File: built_in_hash.py === +> +> num = 3 +> +> hash_num = hash(num) +> +> \# Хеш-значение целого числа 3 равно 3. +> +> 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) +> +> \# Хеш-значение объекта \ равно 274267521. +> +> Во многих языках программирования **только неизменяемые объекты могут использоваться в качестве ключей в хеш-таблице**. Если список (ди- намический массив) используется в качестве ключа, то при изменении его со- держимого хеш-значение также изменится, и мы не сможем найти исходное значение. +> +> Хотя переменные-члены пользовательских объектов (например, узлов связного списка) могут быть изменяемыми, сами объекты можно хеширо- вать. **Это связано с тем**, **что хеш-значение объекта обычно генериру- ется на основе его адреса в памяти**, и даже если содержимое объекта из- меняется, адрес остается неизменным, а значит, и хеш-значение также оста- ется прежним. +> +> Возможно, вы заметили, что при запуске программы в разных окнах выво- димые хеш-значения отличаются. **Это связано с тем, что интерпретатор Py- thon при каждом запуске добавляет случайное значение «соли» к функ- ции хеширования строк**. Такой подход эффективно предотвращает атаки типа HashDoS и повышает безопасность хеш-алгоритма. + +#### резюме + +##### Ключевые моменты + +- При вводе ключа key хеш-таблица может найти значение value за время + +> *O*(1), что очень эффективно. + +- Основные операции с хеш-таблицами включают поиск, добавление пар ключ--значение, удаление пар и обход хеш-таблицы. + +- Хеш-функция отображает ключ в индекс массива, что позволяет полу- чить доступ к соответствующей корзине и извлечь значение. + + - Два разных ключа могут после хеширования получить одинаковый ин- декс массива, что приводит к ошибке в результате поиска. Это явление называется хеш-конфликтом. + + - Чем больше емкость хеш-таблицы, тем ниже вероятность хеш- конфликтов. Поэтому расширение хеш-таблицы может уменьшить ко- личество конфликтов, но, как и в случае с массивами, расширение хеш- таблицы требует значительных затрат. + + - Коэффициент заполнения определяется как отношение числа элемен- тов в хеш-таблице к числу корзин и отражает степень серьезности хеш- конфликтов. Он часто используется как условие для расширения хеш- таблицы. + + - Метод цепной адресации преобразует отдельные элементы в связный спи- сок, храня все конфликтующие элементы в одном списке. Однако слишком длинные списки снижают эффективность поиска, поэтому для повышения эффективности можно преобразовать списки в красно-черные деревья. + + - Метод открытой адресации решает хеш-конфликты с помощью многократ- ного поиска (зондирования). Линейное зондирование использует фикси- рованный шаг, но его элементы нельзя удалять, и он склонен к кластери- зации. Множественное хеширование использует несколько хеш-функций для поиска, что снижает вероятность кластеризации по сравнению с ли- нейным зондированием, но увеличивает вычислительную нагрузку. + + - Разные языки программирования реализуют хеш-таблицы по-разному. Например, класс HashMap в Java использует цепную адресацию, тогда как Dict в Python применяет метод открытой адресации. + + - В хеш-таблицах мы стремимся, чтобы хеш-алгоритмы обладали детер- минированностью, высокой эффективностью и равномерным распреде- лением. В криптографии хеш-алгоритмы также должны обладать устой- чивостью к коллизиям и эффектом лавины. + + - Хеш-алгоритмы обычно используют большие простые числа в качестве модуля, чтобы гарантировать максимально равномерное распределение хеш-значений и уменьшить количество хеш-коллизий. + + - К распространенным хеш-алгоритмам относятся MD5, SHA-1, SHA-2 и SHA-3. MD5 часто используется для проверки целостности файлов, а SHA-2 -- для приложений и протоколов безопасности. + + - Языки программирования обычно предоставляют встроенные хеш- алгоритмы для типов данных, которые используются для вычисления индексов корзин в хеш-таблицах. Как правило, хешировать можно толь- ко неизменяемые объекты. + +##### Вопросы и ответы + +> **Вопрос**. В каких случаях временная сложность хеш-таблицы составляет *O*(*n*)? +> +> **Ответ**. Когда хеш-коллизий становится достаточно много, временная слож- ность хеш-таблицы может ухудшиться до *O*(*n*). Если хеш-функция хорошо спроектирована, установлена разумная емкость, а коллизии распределены равномерно, временная сложность составляет *O*(1). При использовании встро- енных хеш-таблиц языков программирования обычно предполагается, что временная сложность составляет *O*(1). +> +> **Вопрос**. Почему бы не использовать хеш-функцию *f*(*x*) = *x*? Тогда не будет коллизий. +> +> **Ответ**. При использовании хеш-функции *f*(*x*) = *x* каждому элементу соот- ветствует уникальный индекс корзины, что эквивалентно массиву. Однако пространство входных данных обычно значительно больше пространства выходных данных (длины массива), поэтому последним этапом работы хеш- функции часто является взятие остатка от деления на длину массива. Други- ми словами, цель хеш-таблицы -- отобразить большее пространство состояний в меньшее пространство и обеспечить эффективность запросов *O*(1). +> +> **Вопрос**. Почему реализация хеш-таблицы на основе массива, связного спи- ска или двоичного дерева может быть более эффективной? +> +> **Ответ**. Во-первых, временная эффективность хеш-таблицы увеличивается, но пространственная эффективность уменьшается. Значительная часть памя- ти хеш-таблицы остается неиспользованной. +> +> Во-вторых, временная эффективность увеличивается только в определен- ных сценариях использования. Если функцию можно реализовать с использо- ванием массива или связного списка с той же временной сложностью, то это обычно быстрее, чем хеш-таблица. Это связано с тем, что вычисление хеш- функции требует затрат, а константа временной сложности больше. +> +> Наконец, временная сложность хеш-таблицы может ухудшиться. Например, при использовании цепной адресации поиск выполняется в связном списке или красно-черном дереве, что все еще может привести к ухудшению до времени *O*(*n*). **Вопрос**. Есть ли у метода множественного хеширования недостаток невоз- можности прямого удаления элементов? Можно ли повторно использовать +> +> пространство, помеченное как удаленное? +> +> **Ответ**. Множественное хеширование является одной из форм открытой адресации, и все методы открытой адресации имеют недостаток невозможно- сти прямого удаления элементов, что требует пометки удаленных элементов. Пространство, помеченное как удаленное, можно использовать повторно. Ког- да новый элемент вставляется в хеш-таблицу и хеш-функция находит пози- цию, помеченную как удаленная, эту позиция можно использовать для нового элемента. Это позволяет сохранить последовательность поиска в хеш-таблице неизменной и обеспечить эффективность использования пространства. +> +> **Вопрос**. Почему в линейном зондировании при поиске элемента возникают хеш-коллизии? +> +> **Ответ**. При поиске с помощью хеш-функции находится соответствующая корзина и пара ключ--значение. Если ключ не совпадает, это означает наличие хеш-коллизии. Поэтому метод линейного зондирования будет последователь- но искать с заранее заданным шагом, пока не найдет правильное значение ключа или не достигнет условия конца поиска. +> +> **Вопрос**. Почему расширение хеш-таблицы может уменьшить количество хеш-коллизий? +> +> **Ответ**. Последним шагом хеш-функции часто является взятие остатка от деления на длину массива *n*, чтобы выходное значение попадало в диапазон индексов массива. После расширения длина массива *n* изменяется, и соответ- ствующий индекс ключа также может измениться. Несколько ключей, которые ранее попадали в одну корзину, после расширения могут распределиться по нескольким корзинам, что снижает количество хеш-коллизий. +> +> Глава 7 diff --git a/ru/chapters/chapter_07.md b/ru/chapters/chapter_07.md new file mode 100644 index 000000000..491b8af83 --- /dev/null +++ b/ru/chapters/chapter_07.md @@ -0,0 +1,1079 @@ +# Деревья + +![](ru/docs/assets/media/image210.jpeg){width="3.71875in" height="4.8125in"} + +#### двоичные деревья + +> *Двоичное (бинарное) дерево* -- это нелинейная структура данных, представля- ющая отношения между предками и потомками и отражающая логику «раз- деляй и властвуй». Подобно спискам, основным элементом двоичного дерева является узел, который содержит значение, ссылку на левый дочерний узел и ссылку на правый дочерний узел. +> +> 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 и все узлы ниже него. + +![](ru/docs/assets/media/image212.jpeg) + +> **Рис. 7.1.** Родительский узел, дочерние узлы, поддеревья + +1. **Основные понятия двоичного дерева** + +> Основные понятия двоичного дерева изображены на рис. 7.2. + +- **Корневой узел**: узел, находящийся на верхнем уровне дерева и не име- ющий родительского узла. + +- **Листовой узел**: узел, не имеющий дочерних узлов, оба его указателя указывают на None. + +- **Ребро**: отрезок, соединяющий два узла, т. е. ссылка (указатель) узла. + +- **Уровень узла**: увеличивается сверху вниз, уровень корневого узла равен 1. + +- **Степень узла**: количество дочерних узлов узла. В двоичном дереве сте- пень может быть 0, 1 или 2. + +- **Высота двоичного дерева**: количество ребер от корневого узла до са- мого удаленного листового узла. + +- **Глубина узла**: количество ребер от корневого узла до данного узла. + +- **Высота узла**: количество ребер от самого удаленного листового узла до данного узла. + +![](ru/docs/assets/media/image214.jpeg) + +> **Рис. 7.2.** Основные понятия двоичного дерева + +### Основные операции с двоичными деревьями + +##### Инициализация двоичного дерева + +> Подобно спискам, сначала инициализируются узлы, затем строятся ссылки (указатели). +> +> \# === 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 + +##### Вставка и удаление узлов + +> Подобно спискам, в двоичном дереве вставку и удаление узлов можно выпол- нять путем изменения указателей. На рис. 7.3 приведен пример. + +![](ru/docs/assets/media/image216.jpeg) + +> **Рис. 7.3.** Вставка и удаление узлов в двоичном дереве +> +> \# === File: binary_tree.py === \# Вставка и удаление узлов. +> +> p = TreeNode(0) +> +> \# Вставка узла P между n1 и n2. +> +> n1.left = p p.left = n2 +> +> \# Удаление узла P. n1.left = n2 + +### Основные типы двоичных деревьев + +##### Идеальное двоичное дерево + +> В идеальном двоичном дереве все уровни узлов полностью заполнены, см. рис. 7.4. В таком дереве степень листовых узлов равна 0, а степень всех осталь- ных узлов равна 2. Если высота дерева равна *h*, то общее количество узлов равно 2*h*+1 − 1, что представляет собой стандартное экспоненциальное соотношение, отражающее явление деления клеток, которое часто встречается в природе. + +![](ru/docs/assets/media/image218.jpeg) + +> **Рис. 7.4.** Идеальное двоичное дерево + +##### Совершенное двоичное дерево + +> В совершенном двоичном дереве (complete binary tree) заполнены не полно- стью только узлы на самом нижнем уровне, и они заполняются слева направо, см. рис. 7.5. Обратите внимание, что идеальное двоичное дерево также явля- ется полным. + +![](ru/docs/assets/media/image220.jpeg) + +> **Рис. 7.5.** Совершенное двоичное дерево + +##### Полное двоичное дерево + +> В полном двоичном дереве (full binary tree) все узлы, кроме листовых, имеют два дочерних узла, см. рис. 7.6. + +![](ru/docs/assets/media/image222.jpeg) + +> **Рис. 7.6.** Полное двоичное дерево + +##### Сбалансированное двоичное дерево + +> В сбалансированном двоичном дереве абсолютное значение разности высот левого и правого поддеревьев любого узла не превышает 1, см. рис. 7.7. + +![](ru/docs/assets/media/image224.jpeg) + +> **Рис. 7.7.** Сбалансированное двоичное дерево + +### Вырождение двоичного дерева + +> На рис. 7.8 изображена идеальная и вырожденная структура двоичного дерева. Когда каждый уровень двоичного дерева полностью заполнен узлами, оно ста- новится идеальным. Если все узлы смещены в одну сторону, двоичное дерево вырождается в связный список. + +- Идеальное двоичное дерево является оптимальным случаем, позволя- ющим в полной мере использовать преимущество подхода «разделяй и властвуй». + +- Связный список представляет собой другой крайний случай, когда все опе- рации становятся линейными, а временная сложность деградирует до *O*(*n*). + +![](ru/docs/assets/media/image226.jpeg) + +> **Рис. 7.8.** Идеальная и вырожденная структуры двоичного дерева +> +> Как показано в табл. 7.1, в идеальной и вырожденной структурах двоичного дерева количество листьев, общее количество узлов и высота достигают мак- симальных или минимальных значений. +> +> **Таблица 7.1.** Идеальная и вырожденная структуры двоичного дерева +> +> **Идеальное двоичное дерево Связный список** +> +> Количество узлов на уровне *i* 2*i*−1 1 +> +> Количество листьев в дереве высоты *h* 2*h* 1 +> +> Общее количество узлов в дереве высоты *h* +> +> 2*h*+1 − 1 *h* + 1 +> +> Высота дерева с *n* узлами log2(*n* + 1) − 1 *n* − 1 + +#### Обход двоичного дерева + +> С физической точки зрения дерево является структурой данных, основанной на связном списке, поэтому его обход осуществляется последовательным до- ступом к узлам через указатели. Однако, будучи нелинейной структурой дан- ных, обход дерева сложнее, чем обход связного списка, и требует использова- ния алгоритмов поиска. +> +> Наиболее распространенные методы обхода двоичного дерева включают обход по уровням, прямой, симметричный и обратный обходы. + +### Обход по уровням + +> Обход по уровням осуществляется сверху вниз, выполняется последователь- ный обход двоичного дерева с посещением узлов на каждом уровне слева на- право, как показано на рис. 7.9. +> +> ![](ru/docs/assets/media/image228.jpeg)**Обход в ширину** +> +> **Обход по уровням** +> +> ![](ru/docs/assets/media/image229.png){width="8.0582895888014e-2in" height="8.0582895888014e-2in"}(узлы посещаются по точкам ) +> +> **Рис. 7.9.** Обход двоичного дерева по уровням +> +> Обход по уровням по своей сути является обходом в ширину, также назы- ваемым поиском в ширину, который характеризуется постепенно расширяю- щимся кольцом от центра к периферии. + +##### Код реализации + +> Обход в ширину обычно реализуется с использованием очереди. Очередь сле- дует принципу «первый вошел -- первый вышел», а обход в ширину -- принципу +> +> «поэтапное продвижение», что делает их концептуально схожими. Ниже при- веден код реализации. +> +> \# === 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 + +##### Анализ сложности + +> **Временная сложность** *O*(*n*): каждый узел посещается один раз, что занимает +> +> *O*(*n*) времени выполнения, где *n* -- количество узлов. +> +> **Пространственная сложность** *O*(*n*): в худшем случае, т. е. в полном двоич- ном дереве, до достижения самого нижнего уровня в очереди может находить- ся одновременно (*n* + 1)/2 узлов, что занимает *O*(*n*) пространства. + +### Прямой, симметричный и обратный обходы + +> Прямой, симметричный и обратный обходы относятся к обходам в глубину, также называемым поиск в глубину, который характеризуется подходом «сна- чала до конца, затем возврат и продолжение». +> +> На рис. 7.10 демонстрируется принцип работы обхода в глубину для дво- ичного дерева. **Обход в глубину можно представить как обход двоичного дерева по периметру**, при этом на каждом узле встречаются три позиции, соответствующие прямому, симметричному и обратному обходам. +> +> ![](ru/docs/assets/media/image237.jpeg) +> +> **Рис. 7.10.** Прямой, симметричный и обратный обходы двоичного дерева + +##### Код реализации + +> Поиск в глубину обычно реализуется на основе рекурсии. +> +> \# === 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) +> +> На рис. 7.11 демонстрируется рекурсивный процесс прямого обхода двоич- ного дерева, который можно разделить на два противоположных этапа: рекур- сия и возврат. + +1. Рекурсия означает начало нового метода, в процессе которого програм- ма посещает следующий узел. + +2. Возврат означает возвращение функции, что указывает на завершение посещения текущего узла. + +![](ru/docs/assets/media/image246.jpeg)![](ru/docs/assets/media/image249.jpeg) + +> **Рис. 7.11.** Рекурсивный процесс прямого обхода. Шаги 1--2 +> +> ![](ru/docs/assets/media/image251.jpeg) + +![](ru/docs/assets/media/image253.jpeg)![](ru/docs/assets/media/image256.jpeg) + +> **Рис. 7.11.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image259.jpeg) + +![](ru/docs/assets/media/image261.jpeg)![](ru/docs/assets/media/image263.jpeg) + +> **Рис. 7.11.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image266.jpeg) + +![](ru/docs/assets/media/image269.jpeg)![](ru/docs/assets/media/image271.jpeg) + +> **Рис. 7.11.** *Окончание*. Шаги 9--11 + +##### 2. Анализ сложности + +> **Временная сложность** *O*(*n*): все узлы посещаются один раз, что занимает *O*(*n*) времени. +> +> **Пространственная сложность** *O*(*n*): в худшем случае, когда дерево вырож- дается в список, глубина рекурсии достигает *n*, система занимает *O*(*n*) про- странства стека. + +#### представление двоичного дерева с помощью массива + +> При представлении в виде списка единицей хранения двоичного дерева яв- ляется узел TreeNode, а узлы соединяются между собой указателями. В преды- дущем разделе были рассмотрены основные операции с двоичным деревом, представленным в виде списка. +> +> Можно ли представить двоичное дерево с помощью массива? Ответ поло- жительный. + +### Представление идеального двоичного дерева + +> Сначала рассмотрим простой пример. Если дано идеальное двоичное дерево и все его узлы хранятся в массиве в порядке обхода по уровням, то каждому узлу соответствует уникальный индекс массива. +> +> На основе свойств обхода по уровням можно вывести формулу соответ- ствия между индексами родительского и дочерних узлов: **если индекс узла равен** *i*, **то индекс его левого дочернего узла равен** 2*i* + 1, **а правого** -- 2*i* + 2. На рис. 7.12 показаны отношения соответствия между индексами узлов. + +![](ru/docs/assets/media/image273.jpeg) + +> **Рис. 7.12.** Представление идеального двоичного дерева с помощью массива +> +> **Формула соответствия играет роль**, **аналогичную ссылкам (указате- лям) в списке**. Имея любой узел в массиве, можно с помощью формулы полу- чить доступ к его левому и правому дочерним узлам. + +### Представление произвольного двоичного дерева + +> Идеальное двоичное дерево является частным случаем. Обычно на средних уровнях двоичного дерева присутствует много пустых значений None. Но по- следовательность обхода по уровням не содержит этих None, поэтому невоз- можно по этой последовательности определить количество и расположение пустых значений. **Это означает**, **что существует множество структур дво- ичных деревьев**, **соответствующих данной последовательности обхода по уровням**. +> +> Для такого неидеального двоичного дерева вышеописанный метод пред- ставления с помощью массива уже не работает, см. рис. 7.13. + +![](ru/docs/assets/media/image275.jpeg) + +> **Рис. 7.13.** Для одной последовательности обхода по уровням существует несколько возможных вариантов двоичного дерева +> +> Для решения этой проблемы **можно явно записать все значения** None **в последовательности обхода по уровням**. После такой обработки последо- вательность обхода по уровням уже может однозначно представлять двоич- ное дерево, как показано на рис. 7.14. Ниже приведен пример кода. +> +> \# Представление двоичного дерева с помощью массива. \# Использование None для обозначения пустых мест. +> +> tree = \[1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15\] +> +> ![](ru/docs/assets/media/image277.jpeg) +> +> **Рис. 7.14.** Представление произвольного двоичного дерева с помощью массива +> +> Стоит отметить, что **совершенное двоичное дерево очень удобно пред- ставлять с помощью массива**. Вспоминая определение совершенного дво- ичного дерева, None появляются только на самом нижнем уровне и в правой части, поэтому **все значения** None **обязательно находятся в конце последо- вательности обхода по уровням**. +> +> Это означает, что при использовании массива для представления совершен- ного двоичного дерева можно опустить хранение всех None, что очень удобно. На рис. 7.15 приведен пример такого представления. + +![](ru/docs/assets/media/image279.jpeg) + +> **Рис. 7.15.** Представление совершенного двоичного дерева с помощью массива +> +> В коде ниже реализуется двоичное дерево, основанное на представлении с помощью массива, включая следующие операции. + +- Для заданного узла получение его значения, левого и правого дочернего узла, родительского узла. + + - Получение последовательностей обхода в прямом, симметричном, об- ратном порядке и в порядке обхода по уровням. + +> \# === 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 +> +> \# Прямой обход. +> +> 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 + +### Преимущества и ограничения + +> Представление двоичного дерева с помощью массива имеет следующие пре- имущества: + +- массив хранится в непрерывной области памяти, что хорошо для кеши- рования. Скорость доступа и обхода достаточно высока; + +- не требуется хранение указателей, что экономит пространство; + +- позволяет выполнять произвольный доступ к узлам. + +> Однако представление с помощью массива имеет и некоторые ограничения. + +- Хранение в массиве требует непрерывной области памяти, поэтому не подходит для хранения деревьев с очень большим объемом данных. + +- Добавление и удаление узлов требует выполнения операций вставки и удаления в массиве, которые менее эффективны. + +- Когда в двоичном дереве содержится много значений None, доля данных узлов в массиве низка, что приводит к низкой эффективности использо- вания пространства. + +#### двоичное дерево поиска + +> *Двоичное дерево поиска* удовлетворяет следующим условиям, см. рис. 7.16: + +1) для корневого узла все значения узлов в левом поддереве \< значение корневого узла \< все значения узлов в правом поддереве; + +2) левое и правое поддеревья любого узла также являются двоичными де- ревьями поиска, т. е. удовлетворяют условию 1. + +![](ru/docs/assets/media/image281.jpeg) + +> **Рис. 7.16.** Двоичное дерево поиска + +### Операции с двоичным деревом поиска + +> Мы инкапсулируем двоичное дерево поиска в класс BinarySearchTree и объявля- ем переменную-член root, указывающую на корневой узел дерева. + +##### Поиск узла + +> Для заданного значения целевого узла 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, значит целевой узел найден, выполняется выход из цикла и возврат этого узла. + +> ![](ru/docs/assets/media/image283.jpeg) + +![](ru/docs/assets/media/image285.jpeg)![](ru/docs/assets/media/image287.jpeg) + +> **Рис. 7.17.** Пример поиска узла в двоичном дереве поиска. Шаги 1--3 +> +> ![](ru/docs/assets/media/image289.jpeg) +> +> **Рис. 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 + +##### Вставка узла + +> Нужно вставить новый элемент num и при этом сохранить свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Процесс вставки показан на рис. 7.18. + +1. **Поиск позиции для вставки**: аналогично операции поиска, начиная с корневого узла, в цикле выполняется поиск вниз по дереву в зависимо- сти от соотношения значений текущего узла и num, пока не будет пройден листовой узел (достигнуто None), после чего цикл завершается. + +2. **Вставка узла в найденную позицию**: инициализация узла num и раз- мещение его в позиции None. + +![](ru/docs/assets/media/image291.jpeg) + +> **Рис. 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 +> +> \# Позиция для вставки в правом поддереве 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*). + +##### Удаление узла + +> Сначала в двоичном дереве выполняется поиск целевого узла, после чего он удаляется. Как и при вставке узла, необходимо гарантировать, что после за- вершения операции удаления сохраняется свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Поэтому, в зависимости от количества дочерних узлов целевого узла (0, 1 или 2), выполняются соответ- ствующие операции его удаления. +> +> Если степень удаляемого узла равна 0, значит он является листовым, и его можно удалить напрямую, см. рис. 7.19. + +![](ru/docs/assets/media/image293.jpeg) + +> **Рис. 7.19.** Удаление узла в двоичном дереве поиска (степень 0) +> +> Если степень удаляемого узла равна 1, его можно заменить дочерним узлом, см. рис. 7.20. + +![](ru/docs/assets/media/image295.jpeg) **Удаление узла из двоичного дерева поиска** + +1. *Найти узел* **cur**, + +> *подлежащий удалению* +> +> *У узла* **cur** *количество дочерних узлов = 1* + +2. *Заменить узел* **cur** *его дочерним узлом* + +> *Выполнить* **pre**.left = **cur**.right +> +> **Рис. 7.20.** Удаление узла в двоичном дереве поиска (степень 1) +> +> Если степень удаляемого узла равна 2, его нельзя удалить напрямую, и не- обходимо заменить его другим узлом. Согласно свойству двоичного дерева по- иска левое поддерево \< корневой узел \< правое поддерево, **этот узел может быть минимальным узлом правого поддерева или максимальным узлом левого поддерева**. +> +> Предположим, что мы выбираем минимальный узел правого поддерева (следующий узел при симметричном обходе), тогда процесс удаления будет следующим (см. рис. 7.21): + +1) найти следующий узел в последовательности симметричного обхода для узла, который необходимо удалить, и обозначить его как tmp; + +2) заменить значение удаляемого узла значением tmp и рекурсивно уда- лить узел tmp из дерева. + +![](ru/docs/assets/media/image297.jpeg) + +> **Рис. 7.21.** Удаление узла в двоичном дереве поиска (степень 2). Шаг 1 +> +> ![](ru/docs/assets/media/image299.jpeg) + +![](ru/docs/assets/media/image301.jpeg) + +> **Рис. 7.21.** *Продолжение*.. Шаг 2--3 +> +> ![](ru/docs/assets/media/image303.jpeg) +> +> **Рис. 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 +> +> \# Если узел для удаления не найден, возврат. 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*) без необходимости в дополнительных операциях сортировки, что очень эффективно. +> +> ![](ru/docs/assets/media/image306.jpeg) +> +> **Рис. 7.22.** Симметричный обход двоичного дерева поиска + +### Эффективность двоичного дерева поиска + +> Для заданного набора данных можно использовать для его хранения массив или двоичное дерево поиска. Как показано в табл. 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*). +> +> ![](ru/docs/assets/media/image309.jpeg) +> +> **Рис. 7.23.** Вырождение двоичного дерева поиска + +### Типичные сценарии применения двоичного дерева поиска + +- Используется в качестве многоуровневого индекса в системах для эф- фективного поиска, вставки и удаления. + +- Служит базовой структурой данных для некоторых алгоритмов поиска. + +- Применяется для хранения потока данных для поддержания его упоря- доченного состояния. + + 1. **АВЛ-дерево\*** + +> В разделе «Двоичное дерево поиска» упоминалось, что после многократ- ных операций вставки и удаления двоичное дерево поиска может выродит- ся в список. В таких случаях временная сложность всех операций ухудшается с *O*(log *n*) до *O*(*n*). +> +> На рис. 7.24 приведен пример, когда после двух операций удаления узлов двоичное дерево поиска вырождается в список. +> +> В другом примере после вставки двух узлов в идеальное двоичное дерево, показанное на рис. 7.25, дерево сильно наклоняется влево, и временная слож- ность операций поиска также ухудшается. +> +> В 1962 году советские математики Г. М. Адельсон-Вельский и Е. М. Ландис в статье «Один алгоритм организации информации» предложили структуру АВЛ- дерева. В статье подробно описывается серия операций, которые гарантируют, что после постоянного добавления и удаления узлов АВЛ-дерево не деградиру- ет, что позволяет поддерживать временную сложность различных операций на уровне *O*(log *n*). Иными словами, в сценариях, требующих частых операций до- бавления, удаления, поиска и изменения, АВЛ-дерево обеспечивает высокую эф- фективность операций с данными и имеет значительную прикладную ценность. +> +> ![](ru/docs/assets/media/image311.jpeg) +> +> **Рис. 7.24.** Вырождение АВЛ-дерева после удаления узлов + +![](ru/docs/assets/media/image313.jpeg) + +> **Рис. 7.25.** Вырождение АВЛ-дерева после вставки узлов + +### Основные понятия АВЛ-дерева + +> *АВЛ-дерево* является одновременно и двоичным деревом поиска, и сбалан- сированным двоичным деревом, удовлетворяя всем свойствам этих двух типов деревьев. Таким образом, оно представляет собой сбалансированное двоичное дерево поиска. + +##### Высота узла + +> Поскольку операции с АВЛ-деревом требуют получения высоты узла, необхо- димо добавить в класс узла переменную height. +> +> class TreeNode: +> +> \"\"\"Класс узла AVL-дерева.\"\"\" +> +> 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 + +##### Фактор баланса узла + +> Фактор баланса узла определяется как высота левого поддерева узла минус высота правого поддерева, при этом фактор баланса пустого узла равен 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) + +### Вращение в АВЛ-дереве + +> Особенностью АВЛ-дерева является операция вращения, которая позволяет восстановить баланс узла, не влияя на порядок обхода двоичного дерева. Ины- ми словами, **вращение поворота сохраняет свойства двоичного дерева поиска и делает дерево снова сбалансированным двоичным деревом**. +> +> Узлы с абсолютным значением фактора баланса \> 1 называются несбалан- сированными узлами. В зависимости от типа несбалансированности узла опе- рации вращения делятся на четыре типа: правое; левое; сначала правое, затем левое; сначала левое, затем правое. Рассмотрим их подробнее. + +##### Правое вращение + +> На рис. 7.26 ниже узла указан фактор баланса. Если идти снизу вверх, в дво- ичном дереве первым несбалансированным узлом является узел 3. Рассмо- трим поддерево с этим узлом в качестве корня: обозначим этот узел как node, а его левый дочерний узел как child и выполним операцию правого вращения. После завершения операции поддерево восстанавливает баланс и сохраняет свойства двоичного дерева поиска. + +![](ru/docs/assets/media/image315.jpeg)![](ru/docs/assets/media/image317.jpeg) + +> **Рис. 7.26.** Этапы правого вращения. Шаги 1--2 +> +> ![](ru/docs/assets/media/image319.jpeg) + +![](ru/docs/assets/media/image321.jpeg) + +> **Рис. 7.26.** *Окончание*.. Шаги 3--4 +> +> Если у узла child есть правый дочерний узел (обозначим его как grand_child), необходимо добавить в правое вращение еще один шаг: сделать grand_child ле- вым дочерним узлом для node. +> +> Правое вращение -- это образное выражение, фактически оно реализуется путем изменения указателей узлов, как показано в приведенном ниже коде. +> +> ![](ru/docs/assets/media/image323.jpeg) +> +> **Рис. 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 + +##### Левое вращение + +> Соответственно, если рассмотреть зеркальное отражение вышеупомянутого несбалансированного двоичного дерева, необходимо выполнить операцию левого вращения, как показано на рис. 7.28. + +![](ru/docs/assets/media/image325.jpeg) + +> **Рис. 7.28.** Левое вращение +> +> Аналогично, если у узла child есть левый дочерний узел (обозначим его как grand_child), необходимо добавить в левое вращение еще один шаг: сделать grand_child правым дочерним узлом для node, как показано на рис. 7.29. + +![](ru/docs/assets/media/image327.jpeg) + +> **Рис. 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 на рис. 7.30 использование только левого или правого вращения не позволяет восстановить баланс поддерева. В этом случае необходимо сначала выполнить левое вращение для child, а затем правое вра- щение для node. +> +> ![](ru/docs/assets/media/image329.jpeg) +> +> **Рис. 7.30.** Сначала левое, затем правое вращение + +##### Сначала правое, затем левое вращение + +> Для зеркальной ситуации вышеупомянутого разбалансированного двоичного дерева необходимо сначала выполнить правое вращение для child, а затем ле- вое вращение для node, как показано на рис. 7.31. + +![](ru/docs/assets/media/image331.jpeg) + +> **Рис. 7.31.** Сначала правое, затем левое вращение + +##### Выбор типа вращения + +> На рис. 7.32 изображено четыре типа несбалансированности, соответствую- щие вышеописанным случаям, для которых необходимо применять операции: правого вращения; сначала левого, затем правого вращения; сначала правого, затем левого вращения; левого вращения соответственно. +> +> Из табл. 7.3 видно, что для определения того, к какому случаю из рис. 7.32 относится несбалансированный узел, используется фактор баланса узла и знак фактора баланса дочернего узла с большей высотой. +> +> ![](ru/docs/assets/media/image333.jpeg) +> +> **Рис. 7.32.** Четыре типа вращений в АВЛ-дереве + ++-------------------------------------------------+----------------------+----------------------------------------+ +| > **Фактор баланса несбаланси- рованного узла** | > **Фактор баланса** | > **Рекомендуемый метод вращения** | ++=================================================+======================+========================================+ +| > \> 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) +> +> \# Правостороннее дерево. 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 + +### Основные операции с АВЛ-деревом + +##### Вставка узла + +> Операция вставки узла в АВЛ-дерево в основном схожа с двоичным деревом поиска. Единственное отличие заключается в том, что после вставки узла в АВЛ-дерево на пути от этого узла к корню могут возникнуть несбалансиро- ванные узлы. Поэтому*,* **начиная с этого узла**, **необходимо выполнять вра- щения снизу вверх**, **чтобы восстановить баланс всех несбалансирован- ных узлов**. Ниже приведен код реализации. +> +> \# === 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) + +##### Удаление узла + +> Для удаления узла можно также взять метод удаления узла в двоичном дереве поиска и добавить вращения при движении снизу вверх, чтобы восстановить баланс всех несбалансированных узлов. Ниже приведен код реализации. +> +> \# === 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) + +##### Поиск узла + +> Операция поиска узла в АВЛ-дереве идентична поиску в двоичном дереве по- иска, поэтому здесь повторно не рассматривается. + +### Типичные сценарии применения АВЛ-дерева + +- Организация и хранение больших объемов данных подходит для сцена- риев с частыми поисками и редкими вставками и удалениями. + +- Используется для построения индексных систем в базах данных. + +- Красно-черное дерево также является распространенным видом сбалан- сированного двоичного дерева поиска. По сравнению с АВЛ-деревом ус- ловия баланса в красно-черном дереве более мягкие, что требует меньше вращений при вставке и удалении узлов и обеспечивает более высокую среднюю эффективность этих операций. + +#### резюме + +##### Ключевые моменты + +- Двоичное (бинарное) дерево -- это нелинейная структура данных, отра- жающая логику «разделяй и властвуй». Каждый узел двоичного дерева содержит значение и два указателя, указывающих на его левый и правый дочерние узлы соответственно. + +- Для любого узла в двоичном дереве его левый (правый) дочерний узел и об- разуемое им дерево называются левым (правым) поддеревом этого узла. + +- Связанные с двоичным деревом понятия включают корневой узел, ли- стовой узел, уровень, степень, ребро, высоту, глубину и др. + +- Инициализация двоичного дерева, вставка и удаление узлов аналогичны методам работы со списками. + +- К распространенным типам двоичных деревьев относятся идеальное двоичное дерево, совершенное двоичное дерево, полное двоичное де- рево и сбалансированное двоичное дерево. Идеальное двоичное дерево является наиболее желаемым состоянием, а список -- наихудшим состо- янием после вырождения. + +- Двоичное дерево может быть представлено массивом, в котором значе- ния узлов и пустые места располагаются в порядке обхода по уровням, а указатели реализуются на основе индексации между родительскими и дочерними узлами. + +- Обход двоичного дерева по уровням является методом поиска в шири- ну, который отражает способ обхода по кругам, расширяющимся наружу и обычно реализуется с помощью очереди. + +- Прямой, симметричный и обратный обходы относятся к методам поиска в глубину. Они демонстрируют способ обхода «сначала до конца, затем воз- врат и продолжение», обычно реализуемый с использованием рекурсии. + +- Двоичное дерево поиска представляет собой эффективную структуру дан- ных для поиска элементов, где временная сложность операций поиска, вставки и удаления составляет *O*(log *n*). Когда двоичное дерево поиска вы- рождается в список, временная сложность всех операций ухудшается до *O*(*n*). + +- АВЛ-дерево, также известное как сбалансированное двоичное дерево поиска, поддерживает выполнение балансировки дерева после вставки и удаления узлов с помощью операций вращения. + +> 7.6. Резюме ❖ **211** + +- Операции вращения в АВЛ-дереве включают: правое вращение; левое вращение; сначала левое, затем правое вращение; сначала правое, затем левое вращение. После вставки или удаления узлов АВЛ-дерево выпол- няет операции вращения снизу вверх, чтобы восстановить баланс. + +##### Вопросы и ответы + +> **Вопрос**. Для двоичного дерева с единственным узлом высота дерева и глубина корневого узла равны 0? +> +> **Ответ**. Да, поскольку высота и глубина обычно определяются как количе- ство пройденных ребер. +> +> **Вопрос**. Вставка и удаление в двоичном дереве обычно выполняются с по- мощью набора операций. Что подразумевается под набором операций? Мож- но ли это понимать как освобождение ресурсов дочерних узлов? +> +> **Ответ**. Возьмем, к примеру, двоичное дерево поиска: операция удаления узла требует обработки трех различных случаев, в каждом из которых необхо- димо выполнить несколько шагов операций с узлами. +> +> **Вопрос**. Почему для обхода двоичного дерева в глубину существуют три по- рядка -- прямой, симметричный и обратный, и в чем их преимущества? +> +> **Ответ**. Подобно прямому и обратному обходу массива, прямой, симметрич- ный и обратный обходы являются тремя методами обхода двоичного дерева. Они позволяют получить результат обхода в определенном порядке. Напри- мер, в двоичном дереве поиска, поскольку значения узлов удовлетворяют ус- ловию «значение левого дочернего узла \< значение корневого узла \< значение правого дочернего узла», обход дерева в порядке «левый → корень → правый» позволяет получить упорядоченную последовательность узлов. +> +> **Вопрос**. Операция правого вращения обрабатывает отношения между не- сбалансированным узлом node, дочерним узлом child и внуком grand_child. Не нужно ли поддерживать связь node с его родительским узлом? +> +> **Ответ**. Необходимо рассматривать этот вопрос с рекурсивной точки зрения. Операция правого вращения right_rotate(root) принимает корневой узел под- дерева, и в итоге возвращает child, который после вращения становится кор- невым узлом. Связь корневого узла поддерева с его родительским узлом уста- навливается после завершения функции и не входит в область поддержания операции правого вращения. +> +> **Вопрос**. В C++ функции разделяются на private и public. Какие соображения при этом нужно учитывать? Почему функции height() и updateHeight() разме- щены в public и private соответственно? +> +> **Ответ**. Это зависит от области применения метода. Если метод использу- ется только внутри класса, его следует сделать private. Например, вызов up- dateHeight() пользователем не имеет смысла, так как это лишь этап в опера- циях вставки и удаления. А height() используется для доступа к высоте узла аналогично методу vector.size(), поэтому он помечен как public для удобства использования. +> +> **Вопрос**. Как построить двоичное дерево поиска из набора входных данных? +> +> Является ли важным способ выбора корневого узла? +> +> **Ответ**. Да, метод построения дерева описан в методе build_tree() в коде дво- ичного дерева поиска. Что касается выбора корневого узла, обычно входные +> +> данные сортируются, затем средний элемент выбирается в качестве корневого узла, после чего рекурсивно строятся левые и правые поддеревья. Это позво- ляет максимально сохранить баланс дерева. +> +> **Вопрос**. Всегда ли в Java для сравнения строк нужно использовать метод +> +> equals()? +> +> **Ответ**. В Java для базовых типов данных оператор == используется для срав- нения значений двух переменных. Для ссылочных типов принцип работы этих операторов различен. +> +> ① ==: используется для сравнения того, указывают ли две переменные на один и тот же объект, т. е. совпадают ли их позиции в памяти. + +- equals(): используется для сравнения значений двух объектов. + +> Таким образом, если необходимо сравнить значения, следует использовать метод equals(). Однако строки, инициализированные как String a = \"hi\"; String b = \"hi\";, хранятся в пуле строковых констант и указывают на один и тот же объект, поэтому для сравнения содержимого этих строк можно использовать a == b. +> +> **Вопрос**. До достижения самого нижнего уровня при обходе в ширину коли- чество узлов в очереди равно 2*h*? +> +> **Ответ**. Да, например, для полного двоичного дерева высотой *h* = 2 общее количество узлов *n* = 7, тогда количество узлов на нижнем уровне равно 4 = 2*h* = (*n* + 1)/2. +> +> Глава 8 diff --git a/ru/chapters/chapter_08.md b/ru/chapters/chapter_08.md new file mode 100644 index 000000000..8660803b5 --- /dev/null +++ b/ru/chapters/chapter_08.md @@ -0,0 +1,437 @@ +# Куча + +![](ru/docs/assets/media/image334.jpeg){width="3.8072911198600177in" height="4.927083333333333in"} + +1. **куча** + +> Куча -- это полное двоичное дерево, удовлетворяющее определенным услови- ям, и делится на два основных типа, как показано на рис. 8.1. + +- **Минимальная куча**: значение любого узла ≤ значений его дочерних узлов. + +- **Максимальная куча**: значение любого узла ≥ значений его дочерних узлов. + +> ![](ru/docs/assets/media/image336.jpeg)Является совершенным двоичным деревом, и значение любого узла **≤** значения его дочерних узлов +> +> Является совершенным двоичным деревом, и значение любого узла **≥** значения его дочерних узлов + +**Минимальная куча Максимальная куча** + +> **Рис. 8.1.** Минимальная и максимальная кучи +> +> Куча, как частный случай полного двоичного дерева, обладает следующими свойствами: + +- узлы на самом нижнем уровне заполняются слева, остальные уровни полностью заполнены; + +- корневой узел двоичного дерева называется вершиной кучи, а самый правый узел на нижнем уровне -- основанием кучи; + +- для максимальной (минимальной) кучи значение элемента на вершине (т. е. корневом узле) является наибольшим (наименьшим). + +### Основные операции с кучей + +> Следует отметить, что многие языки программирования содержат приоритет- ную очередь, которая является абстрактной структурой данных, определяемой как очередь с приоритетной сортировкой. +> +> На практике **куча часто используется для реализации приоритетной очереди**, **где максимальная куча соответствует приоритетной очереди**, **из которой элементы извлекаются в порядке убывания**. С точки зрения использования приоритетную очередь и кучу можно считать эквивалентными структурами данных. Поэтому в данной книге они не различаются и называ- ются просто кучей. +> +> Основные операции с кучей представлены в табл. 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 = \[\], 1 +> +> \# Инициализация максимальной кучи. +> +> max_heap, flag = \[\], -1 +> +> \# Модуль heapq в Python по умолчанию реализует минимальную кучу. +> +> \# Рассматривается вариант, при котором элементы инвертируются перед +> +> \# добавлением в кучу, что позволяет изменить порядок и реализовать максимальную кучу. \# В этом примере flag = 1 соответствует минимальной куче, flag = -1 -- максимальной. +> +> \# Вставка элемента в кучу. heapq.heappush(max_heap, flag \* 1) heapq.heappush(max_heap, flag \* 3) heapq.heappush(max_heap, flag \* 2) heapq.heappush(max_heap, flag \* 5) heapq.heappush(max_heap, flag \* 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 +> +> \# Получение размера кучи. 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.2, для заданного индекса массива *i* индекс левого до- чернего узла равен 2*i* + 1, правого -- 2*i* + 2, а индекс родительского узла -- (*i* -- 1) / 2 (целочисленное деление вниз). Выход за пределы индексации обозначает пустой узел или его отсутствие. + +![](ru/docs/assets/media/image338.jpeg) + +> **Рис. 8.2.** Представление и хранение кучи +> +> Формулы индексации можно для удобства использования обернуть в функции. +> +> \# === 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 \# Целочисленное деление вниз. + +##### Доступ к элементу на вершине кучи + +> Элемент на вершине кучи -- это корневой узел двоичного дерева, т. е. первый элемент списка. +> +> \# === File: my_heap.py === def peek(self) -\> int: +> +> \"\"\" Доступ к элементу на вершине кучи.\"\"\" +> +> return self.max_heap\[0\] + +##### Вставка элемента в кучу + +> Нам дан элемент val, который сначала добавляется в основание кучи. После добавления условия корректности кучи могут быть нарушены, поскольку элемент val может быть больше других элементов кучи. **Поэтому необхо- димо восстановить порядок на пути от вставленного узла до корнево- го узла**. Эта операция называется упорядочиванием кучи. +> +> Рассмотрим **выполнение упорядочивания кучи снизу вверх**, начиная с узла, который был добавлен. Как показано на рис. 8.3, необходимо срав- нивать значения вставленного узла и его родительского узла. Если встав- ленный узел больше, они меняются местами. Затем продолжается выпол- нение этой операции с исправлением каждого узла кучи снизу вверх, пока не будет достигнут корневой узел или не встретится узел, который не тре- бует обмена. +> +> ![](ru/docs/assets/media/image340.jpeg) + +![](ru/docs/assets/media/image342.jpeg)![](ru/docs/assets/media/image344.jpeg) + +> **Рис. 8.3.** Этапы добавления элемента в кучу. Шаги 1--3 +> +> ![](ru/docs/assets/media/image346.jpeg) + +![](ru/docs/assets/media/image348.jpeg)![](ru/docs/assets/media/image350.jpeg) + +> **Рис. 8.3.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image352.jpeg) + +![](ru/docs/assets/media/image354.jpeg)![](ru/docs/assets/media/image356.jpeg) + +> **Рис. 8.3.** *Окончание*. Шаги 7--9 +> +> Пусть общее количество узлов равно *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 + +##### Извлечение элемента с вершины кучи + +> Элемент на вершине кучи является корневым узлом двоичного дерева, т. е. первым элементом списка. Если просто удалить первый элемент из списка, индексы всех узлов в двоичном дереве изменятся, что затруднит дальней- шее исправление с помощью упорядочивания кучи. Чтобы минимизиро- вать изменения индексов элементов, используется следующий порядок действий: + +1) обмен вершины кучи с элементом в основании кучи (обмен корневого узла с самым правым листовым узлом); + +2) после обмена удаляется элемент в основании кучи из списка (обрати- те внимание, что фактически удаляется исходный элемент на вершине кучи, так как они были поменяны); + +3) **упорядочивание кучи сверху вниз**, начиная с корневого узла. + +> **Направление операции упорядочивания кучи сверху вниз противо- положно операции упорядочивания кучи снизу вверх**, как показано на рис. 8.4. Значение корневого узла сравнивается со значениями его двух дочер- них узлов, и самый большой дочерний узел обменивается с корневым узлом. Затем эта операция выполняется циклически, пока не будет достигнут листо- вой узел или не встретится узел, который не требует обмена. +> +> ![](ru/docs/assets/media/image358.jpeg) + +![](ru/docs/assets/media/image360.jpeg)![](ru/docs/assets/media/image362.jpeg) + +> **Рис. 8.4.** Этапы извлечения элемента с вершины кучи. Шаги 1--3 +> +> ![](ru/docs/assets/media/image364.jpeg) + +![](ru/docs/assets/media/image366.jpeg)![](ru/docs/assets/media/image368.jpeg) + +> **Рис. 8.4.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image370.jpeg) + +![](ru/docs/assets/media/image372.jpeg)![](ru/docs/assets/media/image374.jpeg) + +> **Рис. 8.4.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image376.jpeg) +> +> **Рис. 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 +> +> \# Если узел i максимальный или индексы l, r выходят за пределы, \# упорядочивание кучи не требуется, выход из цикла. +> +> if ma == i: break +> +> \# Обмен двух узлов. self.swap(i, ma) +> +> \# Циклическое упорядочивание кучи вниз. i = ma + +### Типичные сценарии применения кучи + +- **Очередь с приоритетом**: куча обычно используется как структура дан- ных для реализации очереди с приоритетом, где операции добавления и извлечения имеют временную сложность *O*(log *n*), а операция постро- ения кучи -- *O*(*n*). Эти операции очень эффективны. + +- **Пирамидальная сортировка**: для заданного набора данных можно построить кучу, а затем последовательно выполнять операции извле- чения элементов из кучи, чтобы получить отсортированные данные. Однако обычно используется более изящный способ реализации пи- рамидальной сортировки, подробнее см. в разделе «Пирамидальная сортировка». + +- **Получение** *k* **наибольших элементов**: это классическая задача алго- ритмов и типичное применение. Например, выбор 10 самых популярных новостей для горячих тем в социальных сетях или выбор 10 самых про- даваемых товаров. + +#### пОСтрОение кучи + +> В некоторых случаях требуется использовать все элементы списка для постро- ения кучи, этот процесс называется построением кучи. + +### Реализация с помощью операции добавления в кучу + +> Сначала создается пустая куча, затем обходится список, и для каждого элемен- та последовательно выполняется операция добавления в кучу. То есть элемент сначала добавляется в основание кучи, а затем для него выполняется упорядо- чивание кучи снизу вверх. +> +> При добавлении элемента в кучу ее длина увеличивается на единицу. Посколь- ку узлы добавляются в двоичное дерево сверху вниз, куча строится сверху вниз. +> +> Пусть количество элементов равно *n* и каждый элемент добавляется в кучу за время *O*(log *n*). Тогда временная сложность этого метода построения кучи составляет *O*(*n* log *n*). + +### Реализация через обход и упорядочивание + +> На самом деле можно реализовать более эффективный метод построения кучи, который состоит из двух шагов: + +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) + +### Анализ сложности + +> Теперь попытаемся оценить временную сложность второго метода построе- ния кучи. + +- Предположим, что количество узлов в полном двоичном дереве равно *n*, тогда количество листовых узлов равно (*n* + 1)/2, где «/» обозначает целочисленное деление вниз. Следовательно, количество узлов, которые необходимо упорядочить, равно (*n* − 1)/2. + +- В процессе упорядочивания сверху вниз каждый узел может быть упо- рядочен до листового узла, поэтому максимальное количество итераций равно высоте двоичного дерева log *n*. + +> Умножив эти два значения, можно получить временную сложность процесса построения кучи *O*(*n* log *n*). **Однако эта оценка не точна**, **так как не учиты- вает**, **что количество узлов на нижних уровнях двоичного дерева значи- тельно больше**, **чем на верхних**. +> +> Проведем более точный расчет. Чтобы упростить вычисления, предполо- жим, что дано идеальное двоичное дерево с количеством узлов *n* и высотой *h*. Это предположение не повлияет на правильность результата. +> +> ![](ru/docs/assets/media/image378.jpeg) +> +> **Рис. 8.5.** Количество узлов на каждом уровне идеального двоичного дерева +> +> Как видно из рис. 8.5, максимальное количество итераций упорядочивания сверху вниз для узла равно расстоянию от этого узла до листового узла, что соответствует высоте узла. Таким образом, можно суммировать произведения количество узлов × высота узла для каждого уровня, чтобы **получить общее количество итераций упорядочивания для всех узлов**: +> +> ![](ru/docs/assets/media/image380.png)![](ru/docs/assets/media/image383.png)![](ru/docs/assets/media/image385.png){width="9.519356955380577e-2in" height="9.505468066491689e-2in"}![](ru/docs/assets/media/image383.png)![](ru/docs/assets/media/image388.png){width="0.10160761154855644in" height="0.14292979002624673in"} ![](ru/docs/assets/media/image389.png){width="0.10633092738407698in" height="0.14292979002624673in"} . +> +> Для упрощения этого выражения воспользуемся знаниями из школьного курса о последовательностях и умножим сначала *T*(*h*) на 2: +> +> ![](ru/docs/assets/media/image393.png), +> +> . +> +> Используя метод вычитания со сдвигом, вычтем из уравнения 2*T*(*h*) уравне- ние *T*(*h*): + +![](ru/docs/assets/media/image398.png)![](ru/docs/assets/media/image400.png){width="0.3446259842519685in" height="0.1429407261592301in"} ![](ru/docs/assets/media/image401.png){width="1.0516010498687665in" height="0.1429407261592301in"} . + +> ![](ru/docs/assets/media/image404.png)![](ru/docs/assets/media/image406.png){width="7.259623797025372e-2in" height="0.10093722659667542in"}Можно заметить, что *T*(*h*) является геометрической прогрессией, и можно использовать формулу ее суммы, чтобы получить временную сложность: + +![](ru/docs/assets/media/image407.png){width="0.3288571741032371in" height="0.16171587926509187in"}. + +> Далее, количество узлов в идеальном двоичном дереве высоты *h* равно *n* = 2*h*+1 − 1, отсюда легко получить сложность *O*(2*h*) = *O*(*n*). Эти вычисления показывают, что временная сложность построения кучи из входного списка составляет *O*(*n*), что очень эффективно. + +#### поиск k наибольших элементов + +> Для решения этой задачи сначала рассмотрим два простых подхода, а затем представим более эффективный метод с использованием кучи. + +### Первый метод: выбор через обход + +> Можно выполнить *k* раундов обхода, извлекая в каждом раунде 1-й, 2-й, \..., *k*-й по величине элемент, как показано на рис. 8.6. Временная сложность этого алгоритма составляет *O*(*nk*). Этот метод подходит только для случаев, когда *k* ≪ *n*, так как при *k*, близком к *n*, временная сложность стремится к *O*(*n*2), что очень затратно по времени. + +![](ru/docs/assets/media/image409.jpeg) + +> **Рис. 8.6.** Поиск k наибольших элементов через обход + +### Второй метод: сортировка + +> Можно сначала отсортировать массив nums, а затем вернуть *k* самых правых элементов, как показано на рис. 8.7. Временная сложность этого метода со- ставляет *O*(*n* log *n*). Очевидно, что данный метод перевыполняет задачу, так как необходимо найти лишь *k* наибольших элементов, а не сортировать все остальные. + +![](ru/docs/assets/media/image411.jpeg) + +> **Рис. 8.7.** Поиск k наибольших элементов с помощью сортировки + +### Третий метод: куча + +> Задачу поиска *k* наибольших элементов можно решить более эффективно с по- мощью кучи (см. рис. 8.8). + +1. Инициализация минимальной кучи, в которой корневой элемент явля- ется наименьшим. + +2. Вначале в кучу помещаются первые *k* элементов массива. + +3. Начиная с элемента *k* + 1, если текущий элемент больше корневого эле- мента, то корневой элемент извлекается из кучи, а текущий элемент по- мещается в кучу. + +4. После завершения обхода в куче остаются *k* наибольших элементов. + +> ![](ru/docs/assets/media/image413.jpeg) + +![](ru/docs/assets/media/image415.jpeg)![](ru/docs/assets/media/image417.jpeg) + +> **Рис. 8.8.** Поиск k наибольших элементов с помощью кучи. Шаги 1--3 +> +> ![](ru/docs/assets/media/image419.jpeg) + +![](ru/docs/assets/media/image421.jpeg)![](ru/docs/assets/media/image423.jpeg) + +> **Рис. 8.8.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image425.jpeg) + +![](ru/docs/assets/media/image427.jpeg)![](ru/docs/assets/media/image429.jpeg) + +> **Рис. 8.8.** *Окончание*. Шаги 7--9 +> +> Ниже приведен пример кода. +> +> \# === 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* наибольших элементов. + +#### резюме + +##### Ключевые моменты + +- Куча представляет собой полное двоичное дерево и может быть двух ти- пов: максимальной и минимальной. Корневой элемент максимальной (минимальной) кучи является наибольшим (наименьшим). + +- Очередь с приоритетом определяется как очередь с приоритетом извле- чения, обычно реализуемая с помощью кучи. + +- Основные операции с кучей и их временная сложность включают: по- мещение элемента в кучу *O*(log *n*), извлечение корневого элемента *O*(log *n*) и доступ к корневому элементу *O*(1). + +- Совершенное двоичное дерево удобно представлять в виде массива, по- этому обычно для хранения кучи используется массив. + +- Операция упорядочивания кучи используется для поддержания свойств кучи и применяется при операциях помещения и извлечения. + +- Временную сложность построения кучи из *n* элементов можно оптими- зировать до *O*(*n*), что очень эффективно. + +- Задача поиска *k* наибольших элементов является классической алгорит- мической задачей и может быть эффективно решена с использованием кучи с временной сложностью *O*(*n* log *k*). + +> 8.4. Резюме ❖ **235** + +##### Вопросы и ответы + +> **Вопрос**. Является ли «куча» в структуре данных тем же понятием, что и «куча» в управлении памятью? +> +> **Ответ**. Это не одно и то же понятие, хотя они по случайному стечению обсто- ятельств и имеют одинаковое название. Куча в памяти компьютерной системы является частью динамического распределения памяти, которую программа может использовать для хранения данных во время выполнения. Программа может запросить определенное количество памяти в куче для хранения таких сложных структур, как объекты и массивы. Когда эти данные больше не нуж- ны, программа должна освободить эту память, чтобы предотвратить утечку памяти. В отличие от стека управление и использование памяти в куче требует большей осторожности, неправильное использование может привести к утеч- кам памяти и проблемам с указателями. +> +> Глава 9 diff --git a/ru/chapters/chapter_09.md b/ru/chapters/chapter_09.md new file mode 100644 index 000000000..537c37d28 --- /dev/null +++ b/ru/chapters/chapter_09.md @@ -0,0 +1,547 @@ +# Графы + +![](ru/docs/assets/media/image430.jpeg){width="3.5416732283464567in" height="4.583333333333333in"} + +#### графы + +> *Граф --* это нелинейная структура данных, состоящая из вершин и ребер. Граф *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. + +![](ru/docs/assets/media/image432.jpeg) + +> **Рис. 9.1.** Связь между списком, деревом и графом + +1. **Основные типы и понятия графов** + +> В зависимости от наличия направления у ребер графы делятся на неориенти- рованные и ориентированные, как показано на рис. 9.2. + +- В неориентированном графе ребро представляет собой двустороннюю связь между двумя вершинами, например дружеские отношения в соци- альных сетях. + +- В ориентированном графе ребро имеет направление. То есть ребра *A* → *B* и *A* ← *B* независимы друг от друга, например отношения подписки--под- писчики. + +> ![](ru/docs/assets/media/image434.jpeg) +> +> **Рис. 9.2.** Ориентированный и неориентированный графы +> +> Если все вершины связаны, то граф называется связным, иначе -- несвяз- ным, как показано на рис. 9.3. +> +> В связном графе из любой вершины можно достичь любой другой вершины. В несвязном графе существуют по крайней мере две вершины, между кото- +> +> рыми нет пути. + +![](ru/docs/assets/media/image436.jpeg) + +> **Рис. 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. + +- **Степень**: количество ребер, присоединенных к вершине. Для ориен- тированного графа входящая степень показывает, сколько ребер ведет к данной вершине, а исходящая степень показывает, сколько ребер вы- ходит из данной вершины. + +> ![](ru/docs/assets/media/image438.jpeg) +> +> **Рис. 9.4.** Взвешенный и невзвешенный графы + +### Представление графа + +> Графы можно представить с помощью матрицы смежности и списка смежно- сти. Рассмотрим пример с неориентированным графом. + +##### Матрица смежности + +> Пусть количество вершин графа равно *n*, *матрица смежности* представля- ет граф в виде матрицы размером *n*×*n*, где каждая строка (столбец) соот- ветствует вершине, а элементы матрицы обозначают наличие ребра. Зна- чение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию. +> +> Обозначим матрицу смежности как *M*, а список вершин как *V*. Тогда элемент матрицы *M*\[*i*, *j*\] = 1 указывает на наличие ребра между вершинами *V*\[*i*\] и *V*\[*j*\], в противном случае элемент матрицы *M*\[*i*, *j*\] = 0, см. рис. 9.5. + +![](ru/docs/assets/media/image440.jpeg) + +> **Рис. 9.5.** Представление графа с помощью матрицы смежности +> +> Матрица смежности обладает следующими свойствами. + +- В простом графе вершина не может быть соединена с самой собой, по- этому элементы на главной диагонали матрицы смежности не имеют значения. + +- Для неориентированного графа ребра в обоих направлениях эквива- лентны, поэтому матрица смежности симметрична относительно глав- ной диагонали. + +- Заменив элементы матрицы смежности с 1 и 0 на веса ребер, можно представить взвешенный граф. + +> Используя матрицу смежности для представления графа, можно напрямую обращаться к элементам матрицы для получения информации о ребрах, что делает операции добавления, удаления, поиска и изменения достаточно эф- фективными с временной сложностью *O*(1). Однако пространственная слож- ность матрицы составляет *O*(*n*2), что требует значительных затрат памяти. + +##### Список смежности + +> *Список смежности* представляет граф с помощью *n* списков, где узлы списка представляют вершины. *i*-й список соответствует вершине *i* и содержит все смежные вершины (вершины, соединенные с данной вершиной). На рис. 9.6 показан пример графа, представленного с помощью списка смежности. + +![](ru/docs/assets/media/image442.jpeg) + +> **Рис. 9.6.** Представление графа с помощью списка смежности +> +> В списке смежности хранятся только существующие ребра, а общее количе- ство ребер обычно значительно меньше *n*2, что делает его более экономичным по памяти. Однако для поиска ребра в списке смежности необходимо просма- тривать список, что делает его менее эффективным по времени по сравнению с матрицей смежности. + +###### Как видно из рис. 9.6, структура списка смежности очень похожа на цепную адресацию в хеш-таблицах, поэтому можно использовать ана- + +> **логичные методы для оптимизации эффективности**. Например, если список длинный, его можно преобразовать в АВЛ-дерево или красно-черное дерево, чтобы повысить временную эффективность с *O*(*n*) до *O*(log *n*). Так- же можно преобразовать список в хеш-таблицу, чтобы снизить временную сложность до *O*(1). + +### Типичные сценарии применения графов + +> Многие реальные системы можно моделировать с помощью графов, а соответ- ствующие задачи могут быть сведены к задачам вычисления на графах, см. табл. 9.1. + ++---------------------+-----------------+-------------------------+------------------------------------------+ +| | > **Вершина** | > **Ребро** | > **Задача вычисления на графе** | ++=====================+=================+=========================+==========================================+ +| > Социальные сети | > Пользователи | > Дружеские связи | > Рекомендации потенци- альных друзей | ++---------------------+-----------------+-------------------------+------------------------------------------+ +| > Линии метро | > Станции | > Связь между станциями | > Рекомендации по крат- чайшему маршруту | ++---------------------+-----------------+-------------------------+------------------------------------------+ +| > Солнечная система | > Небесные тела | > Взаимодействие грави- | > Расчет орбит планет | ++---------------------+-----------------+-------------------------+------------------------------------------+ + +> тации между телами + +#### ОСнОвные Операции С графами + +> Основные операции с графами можно разделить на операции с ребрами и опе- рации с вершинами. В зависимости от способа представления (матрица смеж- ности или список смежности) реализация будет различаться. + +### Реализация на основе матрицы смежности + +> Ниже приведены операции для заданного неориентированного графа с коли- чеством вершин *n*. Способы реализации показаны на рис. 9.7. + +- **Добавление или удаление ребра**: достаточно изменить соответствую- щее ребро в матрице смежности за время *O*(1). Поскольку граф неориен- тированный, необходимо обновить ребра в обоих направлениях. + +- **Добавление вершины**: в конец матрицы смежности добавляется строка и столбец, которые заполняются нулями. Временная сложность равна *O*(*n*). + +- **Удаление вершины**: удаляется строка и столбец из матрицы смеж- ности. В худшем случае при удалении первой строки и столбца не- обходимо переместить (*n* − 1)2 элементов влево вверх, что занимает время *O*(*n*2). + +- **Инициализация**: передается *n* вершин, инициализируется список вер- шин vertices длиной *n* за время *O*(*n*). Инициализируется матрица смеж- ности adjMat размером *n*×*n* за время *O*(*n*2). + +> ![](ru/docs/assets/media/image444.jpeg) + +![](ru/docs/assets/media/image446.jpeg)![](ru/docs/assets/media/image448.jpeg) + +> **Рис. 9.7.** Инициализация матрицы смежности, добавление и удаление ребер и вершин. Шаги 1--3 +> +> ![](ru/docs/assets/media/image450.jpeg) + +![](ru/docs/assets/media/image452.jpeg) + +> **Рис. 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\]\] = \[\] +> +> \# Добавление вершин. 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. +> +> \# Обработка выхода за границы индексов и равенства. +> +> 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) + +### Реализация на основе списка смежности + +> Ниже приведены описания операций для неориентированного графа с общим количеством вершин *n* и ребер *m*. Способы реализации показаны на рис. 9.8. + +- **Добавление ребра**: достаточно добавить ребро в конец связного списка, соответствующего вершине за время *O*(1). Поскольку граф неориентиро- ванный, необходимо добавить ребра в обоих направлениях. + +- **Удаление ребра**: необходимо найти и удалить указанное ребро в связ- ном списке, соответствующем вершине, за время *O*(*m*). В неориентиро- ванном графе необходимо удалить ребра в обоих направлениях. + +- **Добавление вершины**: добавляется связный список в список смеж- ности, а новая вершина становится головным узлом списка. Требуется время *O*(1). + +- **Удаление вершины**: необходимо пройтись по всему списку смежности и удалить все ребра, содержащие указанную вершину. Требуется время *O*(*n* + *m*). + +- **Инициализация**: в списке смежности создается *n* вершин и 2*m* ребер за время O(*n* + *m*). + +![](ru/docs/assets/media/image454.jpeg) + +> **Рис. 9.8.** Инициализация списка смежности, добавление и удаление ребер и вершин. +> +> Шаг 1 +> +> ![](ru/docs/assets/media/image456.jpeg) + +![](ru/docs/assets/media/image458.jpeg)![](ru/docs/assets/media/image460.jpeg) + +> **Рис. 9.8.** *Продолжение*. Шаг 2--4 +> +> ![](ru/docs/assets/media/image462.jpeg) +> +> **Рис. 9.8.** *Окончание*. Шаг 5 +> +> Ниже приведен код реализации списка смежности. По сравнению с рис. 9.8 код имеет следующие отличия: + +- для удобства добавления и удаления вершин, а также упрощения кода вместо связного списка используется список (динамический массив); + +- для хранения списка смежности используется хеш-таблица, где ключом является экземпляр вершины, а значением -- список смежных вершин (связный список). + +> Кроме того, в списке смежности используется класс 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\]) +> +> 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},\") + +### Сравнение эффективности + +> Пусть дан граф с *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 видно, что временная и пространственная эффективность спи- ска смежности (хеш-таблица) наиболее оптимальна. Однако на практике опе- рации с ребрами в матрице смежности более эффективны, так как требуют лишь одного доступа или присвоения в массиве. В целом матрица смежности реализует принцип обмена пространства на время, тогда как список смежно- сти -- обмена времени на пространство. + +#### Обход графа + +> Дерево представляет собой отношение «один ко многим», тогда как граф обла- дает большей степенью свободы и может представлять произвольные отноше- ния «многие ко многим». Таким образом, **дерево можно рассматривать как частный случай графа**. Очевидно, что операции обхода дерева также являют- ся частным случаем обхода графа. +> +> И графы, и деревья требуют применения алгоритмов поиска для реализации операций обхода. Способы обхода графа можно разделить на два типа: обход в ширину и обход в глубину. + +### Обход в ширину + +> **Обход в ширину (BFS)** -- **это метод обхода от ближнего к дальнему**, **начи- ная с определенного узла**, **с посещением в первую очередь ближайших вершин с постепенным расширением наружу**. Начиная с левого верхнего угла, сначала обходятся все смежные вершины текущей вершины, затем все смежные вершины следующей вершины и т. д., пока не будут посещены все вершины, как показано на рис. 9.9. +> +> ![](ru/docs/assets/media/image464.jpeg) +> +> **Рис. 9.9.** Обход графа в ширину + +##### Реализация алгоритма + +> Обход в ширину обычно реализуется с помощью очереди, код реализации при- веден ниже. Очередь обладает свойством «первый пришел -- первый вышел», что соответствует идее обхода в ширину «от ближнего к дальнему». Алгоритм следующий: + +1) добавить начальную вершину обхода startVet в очередь и начать цикл; + +2) на каждой итерации цикла извлекать вершину из головы очереди и за- писывать посещение, затем добавлять все смежные вершины этой вер- шины в хвост очереди; + +3) повторять шаг 2, пока не будут посещены все вершины. + +> Чтобы избежать повторного обхода вершин, необходимо использовать хеш- множество visited для записи посещенных узлов. +> +> \# === File: graph_bfs.py === +> +> def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -\> list\[Vertex\]: \"\"\" Обход в ширину.\"\"\" +> +> \# Использование списка смежности для представления графа, чтобы получить +> +> все смежные вершины текущей вершины. +> +> \# Последовательность обхода вершин. res = \[\] +> +> \# Хеш-множество для записи уже посещенных вершин. visited = set\[Vertex\](\[start_vet\]) +> +> \# Очередь для реализации поиска в ширину. 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 для более глубокого понимания. + +![](ru/docs/assets/media/image466.jpeg) + +> **Рис. 9.10.** Этапы обхода графа в ширину. Шаг 1 +> +> ![](ru/docs/assets/media/image468.jpeg) + +![](ru/docs/assets/media/image470.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image472.jpeg) + +![](ru/docs/assets/media/image474.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 4--5 +> +> ![](ru/docs/assets/media/image476.jpeg) + +![](ru/docs/assets/media/image478.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 6--7 +> +> ![](ru/docs/assets/media/image480.jpeg) + +![](ru/docs/assets/media/image482.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 8--9 +> +> ![](ru/docs/assets/media/image484.jpeg) + +![](ru/docs/assets/media/image486.jpeg) + +##### Анализ сложности + +> **Рис. 9.10.** *Окончание*. Шаги 10--11 +> +> **Временная сложность**: все вершины будут добавляются в очередь и удаляют- ся из нее ровно один раз, что требует времени *O*(\|*V*\|). В процессе обхода смеж- ных вершин, поскольку граф неориентированный, все ребра будут посещены дважды, что занимает время *O*(2\|*E*\|). В целом требуется время *O*(\|*V*\| + \|*E*\|). +> +> **Пространственная сложность**: список res, хеш-множество visited и количе- ство вершин в очереди que максимум равны \|*V*\|, что требует пространства *O*(\|*V*\|). + +### Обход в глубину + +> **Обход в глубину (DFS)** -- **это метод обхода, при котором сначала ис- следуются все возможные пути до самого конца**, **а затем происходит возврат**. Начиная с левой верхней вершины, посещается какая-либо смеж- ная вершина текущей вершины, пока не будет достигнут конец пути, после чего происходит возврат и опять продолжается обход до конца. Продолжа- ем процесс и так далее, пока все вершины не будут посещены, как показано на рис. 9.11. + +![](ru/docs/assets/media/image488.jpeg) + +> **Рис. 9.11.** Обход графа в глубину + +##### Реализация алгоритма + +> Этот алгоритмический подход «до конца и назад» обычно реализуется с помо- щью рекурсии. Подобно обходу в ширину, в обходе в глубину также необходи- мо использовать хеш-множество visited для записи уже посещенных вершин, чтобы избежать их повторного посещения. +> +> \# === File: graph_dfs.py === +> +> def dfs(graph: GraphAdjList, visited: set\[Vertex\], res: list\[Vertex\], vet: Ver- tex): +> +> \"\"\" Вспомогательная функция для обхода в глубину.\"\"\" 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\]: \"\"\" Обход в глубину.\"\"\" +> +> \# Использование списка смежности для представления графа, чтобы получить все смежные вершины текущей вершины. +> +> \# Последовательность обхода вершин. res = \[\] +> +> \# Хеш-множество для записи уже посещенных вершин. +> +> visited = set\[Vertex\]() +> +> dfs(graph, visited, res, start_vet) return res +> +> Алгоритм обхода в глубину показан на рис. 9.12. + +- **Прямые пунктирные линии** обозначают нисходящую рекурсию, ука- зывая на начало нового рекурсивного метода для посещения новой вер- шины. + +- **Изогнутые пунктирные линии** обозначают восходящую рекурсию, указывая на возврат данного рекурсивного метода к месту его начала. + +> Для лучшего понимания рекомендуется на примере рис. 9.12 и кода реали- зации мысленно (или с помощью рисунка) смоделировать весь процесс обхода в глубину, включая моменты начала и возврата каждого рекурсивного метода. + +![](ru/docs/assets/media/image490.jpeg)![](ru/docs/assets/media/image492.jpeg) + +> **Рис. 9.12.** Этапы обхода графа в глубину. Шаги 1--2 +> +> ![](ru/docs/assets/media/image494.jpeg) + +![](ru/docs/assets/media/image496.jpeg)![](ru/docs/assets/media/image498.jpeg) + +> **Рис. 9.12.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image500.jpeg) + +![](ru/docs/assets/media/image502.jpeg)![](ru/docs/assets/media/image504.jpeg) + +> **Рис. 9.12.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image506.jpeg) + +![](ru/docs/assets/media/image508.jpeg)![](ru/docs/assets/media/image510.jpeg) + +> **Рис. 9.12.** *Окончание*. Шаги 9--11 + +##### Анализ сложности + +> **Временная сложность**: все вершины будут посещены один раз, что требует времени *O*(\|*V*\|). Все ребра будут посещены дважды, что требует времени *O*(2\|*E*\|). В целом требуется время *O*(\|*V*\| + \|*E*\|). +> +> **Пространственная сложность**: список res и хеш-множество visited имеют максимум \|*V*\| вершин, максимальная глубина рекурсии равна \|*V*\|, следователь- но, требуется пространство *O*(\|*V*\|). + +#### резюме + +##### Ключевые моменты + +- Граф состоит из вершин и ребер, его можно задать как множество вер- шин и множество ребер. + +- По сравнению с линейными отношениями (список) и отношениями раз- деления (дерево), сетевые отношения (граф) обладают большей степе- нью свободы и, следовательно, более сложны. + +- Ребра ориентированного графа имеют направленность, в связном графе любые вершины достижимы, во взвешенном графе каждое ребро содер- жит переменную веса. + +- Матрица смежности использует матрицу для представления графа, каж- дая строка (столбец) представляет вершину, элементы матрицы пред- ставляют ребра. Значение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию. Матрица смежности эффектив- на в операциях добавления, удаления, поиска и изменения, но требует больше пространства. + +- Список смежности использует несколько списков для представления графа, *i*-й список соответствует вершине *i* и хранит все смежные верши- ны этой вершины. Список смежности экономнее по сравнению с матри- цей смежности, но из-за необходимости обхода списка для поиска ребра его временная эффективность ниже. + +- Когда списки в списке смежности становятся слишком длинными, их можно преобразовать в красно-черное дерево или хеш-таблицу для по- вышения эффективности поиска. + +> 9.4. Резюме ❖ **263** + +- С точки зрения алгоритмических подходов матрица смежности реализу- ет принцип обмена пространства на время, а список смежности -- обмена времени на пространство. + +- Графы используются для моделирования различных реальных систем, таких как социальные сети, линии метро и т. д. + +- Дерево является частным случаем графа, а обход дерева -- частным слу- чаем обхода графа. + +- Обход графа в ширину (BFS) представляет собой метод поиска, который расширяется от ближних к дальним уровням, обычно реализуется с по- мощью очереди. + +- Обход графа в глубину (DFS) -- это метод поиска, который сначала прохо- дит до конца, а затем отступает назад, когда дальнейшего пути нет, часто реализуется на основе рекурсии. + +##### Вопросы и ответы + +> **Вопрос**. Путь -- это последовательность вершин или ребер? +> +> **Ответ**. В разных языковых версиях «Википедии» определения различа- ются: в английской версии путь -- это последовательность ребер, а в русской версии путь -- это последовательность вершин. Приведем оригинальный текст английской версии: In graph theory, a path in a graph is a finite or infinite se- quence of edges which joins a sequence of vertices. +> +> В этой книге путь рассматривается как последовательность ребер, а не вер- шин. Это связано с тем, что между двумя вершинами может существовать не- сколько соединяющих ребер, и каждое из них соответствует отдельному пути. **Вопрос**. Могут ли существовать в несвязном графе недостижимые вершины? +> +> **Ответ**. В несвязном графе существует по крайней мере две вершины -- та- кие, что одна не достижима из другой. Для обхода несвязного графа необхо- димо установить несколько начальных точек, чтобы обойти все связные ком- поненты графа. +> +> **Вопрос**. Существует ли в списке смежности требование к выбору порядка всех вершин, связанных с данной вершиной? +> +> **Ответ**. Порядок может быть произвольным. Однако на практике может по- требоваться сортировка по определенным правилам, например в порядке до- бавления вершин или в порядке значений вершин, что помогает быстро на- ходить вершины с определенным экстремумом. +> +> Глава 10 diff --git a/ru/chapters/chapter_10.md b/ru/chapters/chapter_10.md new file mode 100644 index 000000000..fedf090a4 --- /dev/null +++ b/ru/chapters/chapter_10.md @@ -0,0 +1,543 @@ +# Поиск + +![](ru/docs/assets/media/image511.jpeg){width="3.5416633858267716in" height="4.583333333333333in"} + +#### двоичный поиск + +> Двоичный (бинарный) поиск -- это эффективный алгоритм поиска, основан- ный на стратегии «разделяй и властвуй». Он использует упорядоченность дан- ных, сокращая на каждом шаге область поиска вдвое, пока не будет найден целевой элемент или область поиска не станет пустой. + +![](ru/docs/assets/media/image513.jpeg) + +> **Рис. 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. +> +> ![](ru/docs/assets/media/image515.jpeg) + +![](ru/docs/assets/media/image517.jpeg)![](ru/docs/assets/media/image519.jpeg) + +> **Рис. 10.2.** Процесс двоичного поиска. Шаги 1--3 + +![](ru/docs/assets/media/image521.jpeg) + +> ![](ru/docs/assets/media/image523.jpeg) + +![](ru/docs/assets/media/image525.jpeg) + +> **Рис. 10.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image527.jpeg) +> +> **Рис. 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* занимают +> +> постоянное количество памяти. + +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* также симметричны. Это снижает вероятность ошибок, поэтому обыч- но **рекомендуется использовать запись «двойной замкнутый интервал»**. +> +> ![](ru/docs/assets/media/image529.jpeg)**Элемент** +> +> Индекс +> +> Обе границы включены в интервал +> +> Интервал поиска: двойной замкнутый \[**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.** Два определения интервалов + +### Преимущества и ограничения + +> Двоичный поиск обладает хорошей производительностью как по времени, так и по пространству. + +- Двоичный поиск отличается высокой эффективностью по времени. При большом объеме данных логарифмическая временная сложность име- ет значительное преимущество. Например, при размере данных *n* = 220 линейный поиск требует 220 = 1 048 576 итераций, тогда как двоичный поиск -- всего log2 220 = 20 итераций. + +- Двоичный поиск, в отличие от некоторых других алгоритмов поиска (на- + +> пример, хеш-поиска), не требует дополнительного пространства и по- этому более экономичен в плане использования памяти. +> +> Тем не менее двоичный поиск не подходит для всех случаев по следующим основным причинам. + +- Двоичный поиск применим только к упорядоченным данным. Если входные данные неупорядоченные, то их сортировка специально для использования двоичного поиска не оправдана. Это связано с тем, что временная сложность алгоритмов сортировки обычно составляет *O*(*n* log *n*), что выше, чем у линейного и двоичного поиска. В сценари- ях с частыми добавлениями элементов для поддержания упорядочен- ности массива необходимо вставлять элементы в определенные по- зиции, что имеет временную сложность *O*(*n*) и также является весьма затратным. + +- Двоичный поиск применим только к массивам, поскольку требует скач- кообразного (непрерывного) доступа к элементам. В связных списках выполнение скачкообразного доступа менее эффективно, поэтому такой поиск не подходит для применения в связных списках и структурах дан- ных, основанных на них. + +- При небольших объемах данных линейный поиск более эффективен. В линейном поиске на каждом этапе требуется только одна операция сравнения; в двоичном поиске требуется одна операция сложения, одна операция деления, от одной до трех операций сравнения и одна опера- ция сложения (вычитания), всего от четырех до шести элементарных операций. Поэтому, когда объем данных *n* невелик, линейный поиск ока- зывается быстрее двоичного. + +#### вставка с использованием двоичного поиска + +> Двоичный поиск можно использовать не только для поиска целевого элемен- та, но и для решения множества других задач, таких как поиск позиции для вставки целевого элемента. + +### Случай без повторяющихся элементов + +![](ru/docs/assets/media/image531.jpeg) + +> **Рис. 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 === +> +> 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 + +### Случай с повторяющимися элементами + +> Если в массиве существует несколько одинаковых target, то обычный двоич- ный поиск может вернуть индекс только одного из них, не определяя, сколько target находится слева и справа от этого элемента. +> +> Задача требует вставить целевой элемент в самое левое положение, поэто- му необходимо найти индекс самого левого target в массиве. Первоначально предполагается реализовать решение следующим образом (см. рис. 10.5): + +1) выполнить двоичный поиск и получить индекс любого target, обозна- чить его как *k*; + +2) начиная с индекса *k*, выполнить линейный обход влево и вернуть ин- декс, когда будет найден самый левый target. + +![](ru/docs/assets/media/image533.jpeg) + +> **Рис. 10.5.** Линейный поиск точки вставки для повторяющихся элементов +> +> Это рабочий метод, но он включает линейный поиск, поэтому его времен- ная сложность составляет *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* **является точкой вставки**. + +![](ru/docs/assets/media/image535.jpeg)![](ru/docs/assets/media/image537.jpeg) + +> **Рис. 10.6.** Этапы двоичного поиска точки вставки для повторяющихся элементов. Шаги 1--2 + +![](ru/docs/assets/media/image523.jpeg) + +> ![](ru/docs/assets/media/image539.jpeg) + +![](ru/docs/assets/media/image541.jpeg) + +> **Рис. 10.6.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image543.jpeg) + +![](ru/docs/assets/media/image545.jpeg)![](ru/docs/assets/media/image547.jpeg) + +> **Рис. 10.6.** *Окончание*. Шаги 6--8 +> +> Ниже приведен пример кода. Операции в ветвях 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* постепенно приближаются к заранее установленной цели. В конечном итоге они либо +> +> успешно находят ответ, либо останавливаются после выхода за границы. + +#### двоичный поиск границ + +### Поиск левой границы + +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 + +### Поиск правой границы + +> Как найти самый правый элемент target? Самый очевидный способ -- изменить код, заменив операцию сужения указателя в случае nums\[m\] == target. Мы не будем приводить код для этого случая, заинтересованные читатели могут реа- лизовать его самостоятельно. +> +> Ниже представлены два более изящных подхода. + +##### Повторное использование поиска левой границы + +> На самом деле можно использовать функцию поиска самого левого элемента для поиска самого правого элемента. Что именно нужно сделать: преобразо- вать поиск самого правого элемента target в поиск самого левого target + 1. +> +> После завершения поиска указатель *i* указывает на самый левый элемент target + 1 (если он существует), а *j* указывает на самый правый target, поэтому можно вернуть *j*, как показано на рис. 10.7. +> +> ![](ru/docs/assets/media/image549.jpeg) +> +> **Рис. 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 + +##### Преобразование в поиск элемента + +> Известно, что, когда массив не содержит элемент target, индексы *i* и *j* в конеч- ном итоге указывают на первый элемент, больший target, и на первый эле- мент, меньший target, соответственно. +> +> Таким образом, для поиска левой и правой границ можно создать элемент, отсутствующий в массиве, как показано на рис. 10.8. + +- **Поиск самого левого** target: можно преобразовать в поиск target - 0.5 + +> и вернуть указатель *i*. + +- **Поиск самого правого** target: можно преобразовать в поиск target + 0.5 + +> и вернуть указатель *j*. +> +> ![](ru/docs/assets/media/image551.jpeg) +> +> **Рис. 10.8.** Преобразование поиска границ в поиск элемента +> +> Код мы не приводим, но стоит обратить внимание на следующие два мо- мента: + +1) данный массив не содержит дробных чисел, т. е. не нужно беспокоиться об обработке случаев равенства другим элементам массива; + +2) поскольку этот метод вводит дробные числа, необходимо изменить тип переменной target на тип с плавающей запятой (в Python это изменение не требуется). + +#### Стратегии оптимизации хеширования + +> В алгоритмических задачах **линейный поиск часто заменяется на хеш- поиск**, **чтобы снизить временную сложность алгоритма**. Рассмотрим за- дачу для углубленного понимания этого приема. + +### Линейный поиск: обмен времени на пространство + +> Рассмотрим *прямой перебор* всех возможных комбинаций. Мы запускаем два вложенных цикла и на каждой итерации проверяем, равна ли сумма двух це- лых чисел target. Если да, то возвращаем их индексы, см. рис. 10.9. +> +> ![](ru/docs/assets/media/image553.jpeg) +> +> **Рис. 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(*n*2), а пространственная сложность *O*(1), что делает его крайне медленным при большом объеме данных. + +### Хеш-поиск: обмен пространства на время + +> Рассмотрим использование *хеш-таблицы*, в которой ключами и значениями являются элементы массива и их индексы. Циклически обходим массив, вы- полняя следующие шаги, показанные на рис. 10.10: + +1) проверить, содержится ли число target - nums\[i\] в хеш-таблице. Если да, то сразу вернуть индексы этих двух элементов; + +2) добавить в хеш-таблицу пару ключ--значение: nums\[i\] и *i*. + +> ![](ru/docs/assets/media/image555.jpeg) + +![](ru/docs/assets/media/image557.jpeg) + +> **Рис. 10.10.** Использование вспомогательной хеш-таблицы для нахождения двух чисел, сумма которых равна заданному. Шаги 1--2 +> +> ![](ru/docs/assets/media/image559.jpeg) +> +> **Рис. 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*(*n*2) до *O*(*n*) благодаря хеш- поиску, значительно повышая эффективность выполнения. +> +> Поскольку требуется поддерживать дополнительную хеш-таблицу, про- странственная сложность составляет *O*(*n*). **Тем не менее общая эффектив- ность этого метода более сбалансирована**, **что делает его оптимальным решением данной задачи**. + +#### переосмысление алгоритмов поиска + +> Алгоритмы поиска используются для нахождения одного или нескольких эле- ментов, удовлетворяющих определенным условиям, в структурах данных, та- ких как массивы, списки, деревья или графы. +> +> Алгоритмы поиска можно классифицировать по принципу их реализации на следующие категории. + +- **Поиск целевого элемента путем обхода структуры данных**, напри- мер обход массива, списка, дерева и графа. + +- **Эффективный поиск элементов с использованием структуры орга- низации данных или априорной информации**, например двоичный поиск, хеш-поиск и поиск в двоичных деревьях. + +> Нетрудно заметить, что эти темы уже были рассмотрены в предыдущих гла- вах, поэтому алгоритмы поиска нам уже знакомы. В этом разделе мы система- тизируем полученные ранее знания. + +### Полный перебор + +> *Полный перебор* заключается в обходе каждого элемента структуры данных для нахождения целевого элемента. + +- Линейный поиск применяется к линейным структурам данных, таким как массивы и списки. Он начинается с одного конца структуры данных и последовательно проверяет элементы, пока не будет найден целевой элемент или не будет достигнут другой конец. + +- Поиск в ширину и поиск в глубину -- это две стратегии обхода графов и деревьев. Поиск в ширину начинается с начального узла и исследу- ет все узлы на текущем уровне перед переходом на следующий. Поиск в глубину начинается с начального узла и следует по пути до конца, за- тем возвращается и пробует другие пути, пока не будет полностью прой- дена вся структура данных. + +> Преимущество полного перебора заключается в его простоте и универсаль- ности, **так как он не требует предварительной обработки данных и ис- пользования дополнительных структур данных**. +> +> Однако **временная сложность таких алгоритмов составляет** *O*(*n*), где *n* -- количество элементов, что делает их менее эффективными при большом объ- еме данных. + +### Адаптивный поиск + +> *Адаптивный поиск* использует специфические свойства данных (например, упорядоченность) для оптимизации процесса поиска, что позволяет более эф- фективно находить целевой элемент. + +- Двоичный поиск использует упорядоченность данных для эффективно- го поиска и применим только к массивам. + +- Хеш-поиск использует хеш-таблицы для создания отображения между данными поиска и целевыми данными, что позволяет эффективно вы- полнять операции поиска. + +- Поиск в дереве осуществляется в определенной структуре дерева (на- пример, в двоичном дереве поиска) путем сравнения значений узлов для быстрого исключения узлов и нахождения целевого элемента. + +> Преимуществом таких алгоритмов является высокая эффективность, **вре- менная сложность может достигать** *O*(log *n*) **и даже** *O*(1). +> +> Однако **использование этих алгоритмов часто требует предваритель- ной обработки данных**. Например, для двоичного поиска необходимо пред- +> +> варительно отсортировать массив, хеш-поиск и поиск в дереве требуют ис- пользования дополнительных структур данных, поддержание которых также требует дополнительных временных и пространственных затрат. + +### Выбор метода поиска + +> Для поиска целевого элемента в заданном наборе данных размером *n* можно использовать различные методы, такие как линейный поиск, двоичный поиск, поиск в дереве, хеш-поиск и др. Принципы работы каждого метода показаны на рис. 10.11. + +![](ru/docs/assets/media/image561.jpeg) + +> **Рис. 10.11.** Различные стратегии поиска +> +> Эффективность и характеристики указанных методов приведены в табл. 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.1* | ++====================================+======================+:==================:+========================================+:==================================:+ +| | > **Линейный поиск** | > **Двоичный** | > **Поиск в дереве** | > **Хеш-поиск** | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ +| > Предварительная обработка данных | > -- | > Сортировка | > Построение де- рева *O*(*n* log *n*) | > Построение хеш- таблицы *O*(*n*) | +| | | > | | | +| | | > *O*(*n* log *n*) | | | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ +| > Упорядоченность данных | > Неупорядо- ченные | > Упорядочен- | > Упорядоченные | > Неупорядоченные | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ + +> ные +> +> Выбор алгоритма поиска также зависит от объема данных, требований к производительности поиска, частоты запросов и обновлений данных. + +##### Линейный поиск + +- Обладает хорошей универсальностью, не требует предварительной об- работки данных. Если необходимо выполнить только один запрос, вре- мя предварительной обработки данных для других трех методов будет дольше, чем время линейного поиска. + +- Подходит для небольших объемов данных, в этом случае временная сложность мало влияет на эффективность. + +- Подходит для сценариев с высокой частотой обновления данных, так как этот метод не требует дополнительного обслуживания данных. + +##### Двоичный поиск + +- Подходит для больших объемов данных, демонстрирует стабильную эф- фективность, худшая временная сложность составляет *O*(log *n*). + +- Объем данных не должен быть слишком большим, так как массив требует непрерывного пространства в памяти. + +- Не подходит для сценариев с частыми добавлениями и удалениями дан- ных, так как поддержание упорядоченного массива требует значитель- ных затрат. + +##### Хеш-поиск + +- Подходит для сценариев с высокими требованиями к производительно- сти поиска, средняя временная сложность составляет *O*(1). + +- Не подходит для случаев, когда требуется упорядоченность данных или поиск по диапазону, так как хеш-таблица не может поддерживать упоря- доченность данных. + +- Сильно зависит от хеш-функции и стратегии обработки коллизий, суще- ствует значительный риск ухудшения производительности. + +- Не подходит для слишком больших объемов данных, так как хеш-таблица требует дополнительного пространства для минимизации коллизий и обеспечения хорошей производительности поиска. + +##### Поиск в дереве + +- Подходит для огромных объемов данных, так как узлы дерева хранятся в памяти раздельно. + +- Подходит для случаев, когда требуется поддержание упорядоченности данных или поиск по диапазону. + + + +- В процессе постоянного добавления и удаления узлов двоичное дерево поиска может стать несбалансированным, и временная сложность ухуд- шится до *O*(*n*). + +- Если используется АВЛ-дерево или красно-черное дерево, все операции могут выполняться со стабильной эффективностью *O*(log *n*), но операции по поддержанию баланса дерева увеличивают дополнительные затраты. + +#### резюме + +- Двоичный поиск требует упорядоченности данных и выполняется путем циклического сокращения области поиска в два раза. Требует упорядо- ченности входных данных и подходит только для массивов или структур данных, основанных на массивах. + +- Полный перебор осуществляется путем обхода структуры данных для нахождения целевого значения. Линейный поиск подходит для массивов и списков, поиск в ширину и поиск в глубину подходят для графов и де- ревьев. Эти алгоритмы обладают хорошей универсальностью, не требу- ют предварительной обработки данных, но их временная сложность *O*(*n*) достаточно высока. + +- Хеш-поиск, поиск в деревьях и двоичный поиск относятся к эффек- тивным методам поиска, которые позволяют быстро находить целевой элемент в определенных структурах данных. Эти алгоритмы отличают- ся высокой эффективностью, их временная сложность может достигать *O*(log *n*) или даже *O*(1), однако обычно они требуют использования до- полнительных структур данных. + +- На практике для выбора подходящего метода необходимо проводить конкретный анализ таких факторов, как объем данных, требования к производительности поиска, частота запросов и обновлений данных. + +- Линейный поиск подходит для небольших или часто обновляемых дан- ных. Двоичный поиск -- для больших, отсортированных данных. Хеш- поиск -- когда важна высокая эффективность запросов и не требуется поиск диапазонов. Поиск в деревьях -- для больших динамических дан- ных, в которых необходимо поддерживать порядок и выполнять запросы диапазонов. + +- Замена линейного поиска на хеш-поиск является распространенной стратегией оптимизации времени выполнения, позволяющей снизить временную сложность с *O*(*n*) до *O*(*1*). + +> Глава 11 diff --git a/ru/chapters/chapter_11.md b/ru/chapters/chapter_11.md new file mode 100644 index 000000000..12748e7de --- /dev/null +++ b/ru/chapters/chapter_11.md @@ -0,0 +1,1192 @@ +# Сортировка + +![](ru/docs/assets/media/image562.jpeg){width="3.5416655730533684in" height="4.583333333333333in"} + +#### Алгоритмы сортировки + +> *Алгоритмы сортировки* используются для упорядочивания набора данных в определенном порядке. Они имеют широкое применение, поскольку упоря- доченные данные обычно можно более эффективно анализировать, обрабаты- вать и выполнять в них поиск. +> +> Типы данных в алгоритмах сортировки могут быть целыми числами, числа- ми с плавающей запятой, символами или строками, как показано на рис. 11.1. Правила сортировки могут быть установлены в зависимости от потребностей, например по величине чисел, порядку ASCII-кодов символов или произволь- ным пользовательским правилам. + +![](ru/docs/assets/media/image564.jpeg) + +> **Рис. 11.1.** Пример типов данных и правил сортировки + +1. **Критерии оценки** + +> **Эффективность выполнения**: ожидается, что временная сложность алгорит- ма сортировки будет как можно ниже, а общее количество операций -- мини- мальным (уменьшение константного множителя во временной сложности). Для больших объемов данных эффективность выполнения особенно важна. +> +> **Местность**: как следует из названия, сортировка на месте осуществляется путем непосредственной работы с исходным массивом без использования до- полнительных вспомогательных массивов, что позволяет экономить память. Обычно операции перемещения данных при сортировке на месте малочис- ленны, а скорость выполнения выше. +> +> **Стабильность**: стабильная сортировка сохраняет относительный порядок равных элементов в массиве после завершения сортировки. +> +> Стабильная сортировка является необходимым условием для многоуров- невой сортировки. Предположим, что у нас есть таблица с информацией о студентах, где 1-й и 2-й столбцы -- это имя и возраст соответственно. В этом случае нестабильная сортировка может привести к потере упорядоченности входных данных. +> +> \# Входные данные отсортированы по имени. \# (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*), но ее универсальность относительно ниже. + +### Идеальный алгоритм сортировки + +> **Быстрый**, **на месте**, **стабильный**, **адаптивный**, **с хорошей универсально- стью**. Очевидно, что до сих пор нет алгоритма сортировки, сочетающего все эти характеристики. Поэтому при выборе алгоритма необходимо учитывать особенности данных и требования задачи. +> +> Далее мы изучим различные алгоритмы сортировки и проанализируем их достоинства и недостатки на основе вышеуказанных критериев оценки. + +#### Сортировка выбором + +> Принцип работы *сортировки выбором* весьма прост: запускается цикл, в каж- дой итерации которого из неотсортированной части массива выбирается наи- меньший элемент и помещается в конец отсортированной части. +> +> Пусть длина массива равна *n*, алгоритм сортировки выбором заключается в следующем (см. рис. 11.2): + +1) в начальном состоянии все элементы не отсортированы, т. е. неотсорти- рованный (индексный) диапазон равен \[0, *n* -- 1\]; + +2) выбирается наименьший элемент из диапазона \[0, *n* -- 1\] и меняется ме- стами с элементом с индексом 0. После этого первый элемент массива отсортирован; + +3) выбирается наименьший элемент из диапазона \[1, *n* -- 1\] и меняется ме- стами с элементом с индексом 1. После этого первые два элемента мас- сива отсортированы; + +4) таким образом, после *n* -- 1 итераций выбора и обмена первые *n* -- 1 эле- ментов массива отсортированы; + +5) единственный оставшийся элемент обязательно является наибольшим, поэтому сортировка массива завершена. + +![](ru/docs/assets/media/image566.jpeg)![](ru/docs/assets/media/image568.jpeg)![](ru/docs/assets/media/image570.jpeg) + +> **Рис. 11.2.** Этапы сортировки выбором. Шаги 1--3 +> +> ![](ru/docs/assets/media/image572.jpeg) + +![](ru/docs/assets/media/image574.jpeg)![](ru/docs/assets/media/image576.jpeg) + +> **Рис. 11.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image578.jpeg) + +![](ru/docs/assets/media/image580.jpeg)![](ru/docs/assets/media/image582.jpeg) + +> **Рис. 11.2.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image584.jpeg) + +![](ru/docs/assets/media/image586.jpeg) + +> **Рис. 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\] + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **неадаптивная сортировка**: внешний цикл выполняется *n* -- 1 раз, длина неотсортированного диапазона на первой итерации равна *n*, на последней -- 2, т. е. каждый внешний цикл включает *n*, *n* -- 1, \..., 3, 2 итераций внутреннего цикла, сумма которых равна (*n* -- 1)(*n* + 2)/2. + +- **Пространственная сложность** *O*(1), **сортировка на месте**: указате- ли *i* и *j* используют дополнительное пространство постоянного раз- мера. + +- **Нестабильная сортировка**: как показано на рис. 11.3, элемент nums\[i\] может быть перемещен вправо от равного ему элемента, что изменяет их относительный порядок. + +![](ru/docs/assets/media/image588.jpeg) + +> **Рис. 11.3.** Пример нестабильности сортировки выбором + +#### Сортировка пузырьком + +> *Сортировка пузырьком* реализует сортировку путем последовательного срав- нения и обмена соседних элементов. Этот процесс напоминает подъем пу- зырьков со дна на поверхность, отсюда и такое название. +> +> Процесс поднятия пузырька можно смоделировать операцией обмена элементов: начиная с самого левого конца массива, производится последо- вательное сравнение соседних элементов, и, если левый элемент \> правый элемент, они меняются местами, как показано на рис. 11.4. После заверше- ния прохода наибольший элемент будет перемещен в самый правый конец массива. + +![](ru/docs/assets/media/image590.jpeg) + +![](ru/docs/assets/media/image592.jpeg)![](ru/docs/assets/media/image594.jpeg)![](ru/docs/assets/media/image596.jpeg) + +> **Рис. 11.4.** Моделирование поднятия пузырька с помощью обмена элементов. Шаги 1--4 +> +> ![](ru/docs/assets/media/image598.jpeg) + +![](ru/docs/assets/media/image600.jpeg)![](ru/docs/assets/media/image602.jpeg) + +### Алгоритм + +> **Рис. 11.4.** *Окончание*. Шаги 5--7 +> +> Пусть дан массив длиной *n*, тогда сортировка пузырьком выглядит следующим образом (см. рис. 11.5): + +1) сначала выполняется пузырек для *n* элементов, **перемещая наиболь- ший элемент в правильное положение**; + +2) затем выполняется пузырек для оставшихся *n* -- 1 элементов, **переме- щая второй по величине элемент в правильное положение**; + +3) таким образом, после *n* -- 1 итераций пузырька первые *n* -- 1 **наиболь- ших элементов перемещены в правильные положения**; + +4) единственный оставшийся элемент обязательно является наименьшим, поэтому сортировка массива завершена. + +> ![](ru/docs/assets/media/image604.jpeg) +> +> **Рис. 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\] + +### Оптимизация эффективности + +> Если в какой-либо итерации пузырька не выполняется ни одной операции об- мена, это означает, что массив уже отсортирован, и можно сразу вернуть ре- зультат. Поэтому можно добавить флаг flag для отслеживания этой ситуации, и как только она возникнет, немедленно выйти из цикла. +> +> После оптимизации наихудшая и средняя временные сложности сортиров- ки пузырьком остаются *O*(*n*2); однако, если входной массив полностью отсо- ртирован, можно достичь лучшей временной сложности *O*(*n*). +> +> \# === 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 \# В этой итерации \"пузырька\" не было обмена, выход из цикла. + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **адаптивная сортировка**: длина масси- ва, проходящего каждую итерацию пузырька, последовательно равна *n* -- 1, *n* -- 2, \..., 2, 1. Сумма этих значений равна (*n* -- 1)*n*/2. После вве- дения оптимизации с флагом лучшая временная сложность может до- стигать *O*(*n*). + +- **Пространственная сложность** *O*(1), **сортировка на месте**: указатели + +> *i* и *j* используют дополнительную память постоянного размера. + +- **Стабильная сортировка**: поскольку при сортировке пузырьком равные элементы не меняются местами. + +#### Сортировка вставками + +> *Сортировка вставками* -- это простой алгоритм сортировки, работа которого схожа с процессом ручной сортировки карт в колоде. +> +> Более конкретно: в неотсортированном сегменте выбирается опорный эле- мент, который сравнивается по величине с элементами в отсортированном сегменте слева и вставляется на правильное место. +> +> На рис. 11.6 иллюстрируется процесс вставки элемента в массив. Пусть опорный элемент обозначен как base, необходимо сдвинуть все элементы от целевого индекса до base вправо на одну позицию, затем присвоить base целе- вому индексу. + +4. ![](ru/docs/assets/media/image606.jpeg)Сортировка вставками ❖ **299** + +> **Рис. 11.6.** Операция одиночной вставки + +### Алгоритм + +> Процесс сортировки вставками выглядит следующим образом (см. рис. 11.7): + +1) в начальном состоянии первый элемент массива уже отсортирован; + +2) выбирается второй элемент массива в качестве base, **после его вставки на правильное место первые два элемента массива отсортированы**; + +3) выбирается третий элемент в качестве base, **после его вставки на пра- вильное место первые три элемента массива отсортированы**; + +4) таким образом, в последнем раунде выбирается последний элемент в качестве base, **после его вставки на правильное место все элементы отсортированы**. + +> ![](ru/docs/assets/media/image608.jpeg)**Отсортированный** +> +> **Рис. 11.7.** Процесс сортировки вставками +> +> Ниже приведен пример кода: +> +> \# === 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 правильному месту. + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **адаптивная сортировка**: в худшем слу- чае каждая операция вставки требует *n* − 1, *n* − 2, \..., 2, 1 циклов. Сумма этих чисел составляет (*n* − 1)*n*/2, поэтому временная сложность равна *O*(*n*2). При наличии упорядоченных данных операция вставки заверша- ется досрочно. Когда входной массив полностью упорядочен, сортировка вставками достигает лучшей временной сложности *O*(*n*). + +- **Пространственная сложность** O(1), **сортировка на месте**: указатели + +> *i* и *j* используют дополнительную память постоянного размера. + +- **Стабильная сортировка**: в процессе вставки элементы вставляются справа от равных элементов, не изменяя их порядок. + +### Преимущества сортировки вставками + +> Временная сложность сортировки вставками составляет *O*(*n*2), тогда как вре- менная сложность быстрой сортировки, которую мы скоро изучим, равна *O*(*n* log *n*). Несмотря на более высокую временную сложность, **сортировка вставками обычно быстрее при небольших объемах данных**. +> +> Этот вывод аналогичен применению линейного поиска и двоичного по- иска. Алгоритмы сортировки, такие как быстрая сортировка с временной сложностью *O*(*n* log *n*), основаны на стратегии «разделяй и властвуй» и часто содержат больше элементарных вычислительных операций. Однако при не- больших объемах данных значения *n*2 и *n* log *n* близки, и сложность не явля- ется доминирующей, а количество элементарных операций в каждом раунде играет решающую роль. +> +> Фактически многие языки программирования (например, Java) использу- ют встроенные функции сортировки, которые применяют сортировку встав- ками. Основная идея заключается в следующем: для длинных массивов ис- +> +> пользуется сортировка на основе стратегии «разделяй и властвуй», например быстрая сортировка. Для коротких массивов -- сортировка вставками. +> +> Хотя временная сложность сортировки пузырьком, сортировки выбором и сортировки вставками одинакова и равна *O*(*n*2), **на практике сортировка вставками используется значительно чаще** по следующим причинам. + +- Сортировка пузырьком основана на обмене элементов, требует исполь- зования временной переменной и включает три элементарные опера- ции. Сортировка вставками основана на присвоении элементов и тре- бует только одну элементарную операцию. Поэтому **вычислительные затраты сортировки пузырьком обычно выше, чем у сортировки вставками**. + +- В любом случае временная сложность сортировки выбором равна *O*(*n*2). **Если задана частично упорядоченная группа данных**, **сортировка вставками обычно эффективнее сортировки выбором**. + +- Сортировка выбором нестабильна и не может быть применена для мно- гоуровневой сортировки. + +#### Быстрая сортировка + +> *Быстрая сортировка* -- это алгоритм сортировки, основанный на стратегии +> +> «разделяй и властвуй». Он отличается высокой эффективностью и широким применением. +> +> Основной операцией быстрой сортировки является разделение с помощью стража, цель которого заключается в следующем: выбрать один из элементов массива в качестве опорного и переместить все элементы, меньшие опорно- го, влево от него, а элементы, большие опорного, вправо. Процесс разделения с помощью стража выглядит следующим образом (см. рис. 11.8): + +1) выбрать элемент на крайнем левом конце массива в качестве опор- ного, инициализировать два указателя *i* и *j*, указывающих на концы массива; + +2) установить цикл, в каждой итерации которого *i* (*j*) ищет первый элемент, больший (меньший) опорного, после чего эти два элемента меняются местами; + +3) продолжать выполнение шага 2 до тех пор, пока *i* и *j* не встретятся, затем переместить опорный элемент на границу между двумя под- массивами. + +> ![](ru/docs/assets/media/image611.jpeg) + +![](ru/docs/assets/media/image614.jpeg) + +> **Рис. 11.8.** Этапы разделения с помощью стража. Шаги 1--2 +> +> ![](ru/docs/assets/media/image616.jpeg) + +![](ru/docs/assets/media/image618.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 3--4 +> +> ![](ru/docs/assets/media/image620.jpeg) + +![](ru/docs/assets/media/image622.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 5--6 +> +> ![](ru/docs/assets/media/image624.jpeg) + +![](ru/docs/assets/media/image626.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 7--8 +> +> ![](ru/docs/assets/media/image628.jpeg) +> +> **Рис. 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.9): + +1) сначала выполнить одно разделение с помощью стража для исходного массива, получив неотсортированные левый и правый подмассивы; + +2) затем рекурсивно выполнить разделение с помощью стража для левого и правого подмассивов; + +3) продолжать рекурсию до тех пор, пока длина подмассива не станет рав- ной 1, таким образом завершая сортировку всего массива. + +![](ru/docs/assets/media/image630.jpeg) + +> **Рис. 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: в сред- нем случае количество рекурсивных уровней разделения с помощью стража равно log *n*, общее количество циклов на каждом уровне равно *n*, + +> что соответствует времени *O*(*n* log *n*). В худшем случае каждая операция разделения с помощью стража делит массив длиной *n* на два подмассива длиной 0 и *n* -- 1, в этом случае количество рекурсивных уровней дости- гает *n*, количество циклов на каждом уровне равно *n*, что соответствует времени O(*n*2). + +- **Пространственная сложность** *O*(*n*), **сортировка на месте**: в случае пол- ностью обратным порядком входного массива достигается худшая рекур- сивная глубина *n*, используется *O*(*n*) кадров стека. Сортировка выполня- ется на исходном массиве без использования дополнительных массивов. + +- **Нестабильная сортировка**: на последнем шаге разделения с помощью стража опорный элемент может быть перемещен вправо от равных ему элементов. + +### Почему быстрая сортировка быстрая + +> Уже из названия понятно, что быстрая сортировка должна иметь определен- ные преимущества в плане эффективности. Хотя средняя временная слож- ность быстрой сортировки такая же, как у сортировки слиянием и пирами- дальной сортировки, обычно быстрая сортировка более эффективна, по сле- дующим причинам. + +- **Вероятность возникновения худшего случая очень низка**: хотя худ- шая временная сложность быстрой сортировки составляет *O*(*n*2), что не так стабильно, как у сортировки слиянием, в подавляющем большин- стве случаев быстрая сортировка работает со сложностью *O*(*n* log *n*). + +- **Высокая эффективность использования кеша**: при выполнении опе- рации разделения с помощью стража система может загрузить весь под- массив в кеш, что повышает эффективность доступа к элементам. Такие алгоритмы, как пирамидальная сортировка, требуют скачкообразного доступа к элементам, что лишает их этого преимущества. + +- **Низкий коэффициент постоянной сложности**: среди трех упомяну- тых алгоритмов общее количество операций сравнения, присваивания и обмена в быстрой сортировке минимально. Это похоже на причину, по которой сортировка вставками быстрее пузырьковой сортировки. + +### Оптимизация выбора опорного элемента + +> **Быстрая сортировка может демонстрировать снижение эффективности на некоторых входных данных**. Например, в случае, когда входной массив полностью отсортирован в обратном порядке, если выбирать самый левый элемент в качестве опорного, то после завершения разделения по методу стра- жей опорный элемент перемещается в самый правый конец массива. В этом случае левый подмассив будет длиной *n* -- 1, а правый -- длиной 0. При таком рекурсивном подходе после каждого разделения один из подмассивов оказы- вается длиной 0, стратегия «разделяй и властвуй» не работает, и быстрая со- ртировка вырождается в форму, близкую к сортировке пузырьком. +> +> Чтобы минимизировать вероятность возникновения такой ситуации, **мож- но оптимизировать стратегию выбора опорного элемента в методе раз-** +> +> **деления**. Например, можно выбрать опорный элемент случайным образом. Однако, если вам не повезет и каждый раз будет выбран неудачный опорный элемент, эффективность все равно будет неудовлетворительной. +> +> Следует отметить, что программные языки обычно генерируют псевдослу- чайные числа. Если создать специальный тестовый пример для последователь- ности псевдослучайных чисел, эффективность быстрой сортировки все равно может ухудшиться. +> +> Для дальнейшего улучшения можно выбирать трех кандидатов из массива (обычно это первый, последний и средний элементы массива) и использо- вать медиану этих трех кандидатов в качестве опорного элемента. Таким об- разом, вероятность того, что опорный элемент будет ни слишком маленьким, ни слишком большим, значительно возрастает. Конечно, можно выбрать больше кандидатов, чтобы еще больше повысить устойчивость алгоритма. Применение этого метода значительно снижает вероятность ухудшения вре- менной сложности до *O*(*n*2). +> +> Ниже приведен пример кода. +> +> \# === 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 \# Возврат индекса опорного элемента + +### Оптимизация хвостовой рекурсии + +> **На некоторых входных данных быстрая сортировка может потреблять много памяти**. Например, в случае полностью отсортированного массива если длина подмассива в рекурсии равна *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.10. + +1. **Этап разделения**: массив рекурсивно делится пополам, превращая задачу сортировки длинного массива в задачу сортировки коротких массивов. + +2. **Этап слияния**: когда длина подмассива достигает 1, разделение пре- кращается и начинается слияние, при котором два более коротких упо- рядоченных массива объединяются в один более длинный упорядочен- ный массив. + +> ![](ru/docs/assets/media/image632.jpeg) +> +> **Рис. 11.10.** Этапы разделения и слияния в сортировке слиянием + +### Алгоритм + +> Этап разделения рекурсивно делит массив на два подмассива от вершины до основания, как показано на рис. 11.11. + +1. Вычисление средней точки массива mid, рекурсивное разделение левого подмассива (интервал \[left, mid\]) и правого подмассива (интервал \[mid + +> \+ 1, right\]). + +2. Рекурсивное выполнение шага 1 до тех пор, пока длина интервала под- массива не станет равной 1. + +> Этап слияния заключается в объединении левого и правого подмассивов в один упорядоченный массив снизу вверх. Следует отметить, что слияние на- чинается с подмассивов длиной 1, при этом каждый подмассив на этапе слия- ния уже упорядочен. + +![](ru/docs/assets/media/image634.jpeg) + +> **Рис. 11.11.** Этапы сортировки слиянием. Шаг 1 +> +> ![](ru/docs/assets/media/image636.jpeg) + +![](ru/docs/assets/media/image638.jpeg)![](ru/docs/assets/media/image640.jpeg) + +> **Рис. 11.11.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image642.jpeg) + +![](ru/docs/assets/media/image644.jpeg)![](ru/docs/assets/media/image646.jpeg) + +> **Рис. 11.11.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image648.jpeg) + +![](ru/docs/assets/media/image650.jpeg)![](ru/docs/assets/media/image652.jpeg) + +> **Рис. 11.11.** *Окончание*. Шаги 8--10 +> +> Можно заметить, что порядок рекурсии в сортировке слиянием совпадает с порядком обхода в глубину двоичного дерева. + +- **Обход в глубину**: сначала рекурсивный обход левого поддерева, затем правого поддерева и в конце обработка корневого узла. + +- **Сортировка слиянием**: сначала рекурсивное разделение левого под- массива, затем правого подмассива и в конце обработка слияния. + +> Ниже приведен код реализации сортировки слиянием. Обратите внимание, что интервал для слияния в массиве 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: разделе- ние создает рекурсивное дерево высотой log *n*, общее количество опера- ций слияния на каждом уровне составляет *n*, поэтому общая временная сложность равна *O*(*n* log *n*). + +- **Пространственная сложность** *O*(*n*), **не на месте**: глубина рекурсии равна log *n*. Используется кадр стека размером *O*(log *n*). Операция слия- ния требует использования вспомогательного массива, что занимает до- полнительное пространство *O*(*n*). + +- **Стабильная сортировка**: в процессе слияния порядок равных элемен- тов сохраняется. + +### Сортировка связного списка + +> Для связного списка сортировка слиянием имеет значительное преимущество перед другими алгоритмами, **позволяя оптимизировать пространствен- ную сложность задачи сортировки связного списка до** *O*(1). + +- **Этап разделения**: для выполнения разделения связного списка можно использовать итерацию вместо рекурсии, что позволяет избежать ис- пользования стекового кадра рекурсии. + +- **Этап слияния**: в связном списке операции добавления и удаления уз- лов требуют лишь изменения ссылок (указателей), поэтому на этапе слияния (объединение двух коротких упорядоченных списков в один длинный упорядоченный список) нет необходимости создавать допол- нительный список. + +> Конкретные детали реализации довольно сложны, заинтересованные чита- тели могут обратиться к соответствующей литературе для более глубокого из- учения этого приема. + +#### пирамидальная сортировка + +> *Пирамидальная сортировка* -- это эффективный алгоритм сортировки, осно- ванный на структуре данных «куча». Для реализации пирамидальной сорти- ровки можно использовать уже изученные операции построения кучи и из- влечения элемента из кучи. + +1. Ввод массива и построение минимальной кучи, при этом минимальный элемент находится на вершине кучи. + +2. Постоянное выполнение операции извлечения из кучи. Последователь- ная запись извлеченных элементов позволяет получить последователь- ность, отсортированную по возрастанию. + +> Хотя этот метод и работает, он требует использования дополнительного массива для хранения извлеченных элементов, что неэффективно с точки зрения использования пространства. На практике обычно используется более элегантный способ реализации. + +### Алгоритм + +> Пусть дан массив длины *n*, процесс пирамидальной сортировки выглядит сле- дующим образом (см. рис. 11.12): + +1) ввод массива и построение максимальной кучи. После завершения мак- симальный элемент находится на вершине кучи; + +2) обмен вершины кучи (первого элемента) с элементом в основании кучи (последним элементом). После завершения обмена длина кучи уменьшается на 1, а количество отсортированных элементов увеличи- вается на 1; + +3) начать с вершины кучи и выполнить операцию упорядочивания сверху вниз. После завершения упорядочивания свойства кучи восстанавли- ваются; + +4) циклическое выполнение шагов 2 и 3. После *n* -- 1 итераций сортировка массива будет завершена. + +![](ru/docs/assets/media/image654.jpeg) + +> **Рис. 11.12.** Этапы пирамидальной сортировки. Шаг 1 +> +> ![](ru/docs/assets/media/image657.jpeg) + +![](ru/docs/assets/media/image661.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image665.jpeg) + +![](ru/docs/assets/media/image669.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 4--5 +> +> ![](ru/docs/assets/media/image672.jpeg) + +![](ru/docs/assets/media/image674.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 6--7 +> +> ![](ru/docs/assets/media/image677.jpeg) + +![](ru/docs/assets/media/image680.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 8--9 +> +> ![](ru/docs/assets/media/image683.jpeg) + +![](ru/docs/assets/media/image686.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 10--11 +> +> ![](ru/docs/assets/media/image688.jpeg) +> +> **Рис. 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 +> +> 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: опера- ция построения кучи занимает время *O*(*n*). Временная сложность извле- чения максимального элемента из кучи составляет *O*(log *n*), всего *n* -- 1 итераций. + +- **Пространственная сложность** *O*(1), **сортировка на месте**: несколько указателей используют пространство *O*(1). Обмен элементов и операции упорядочивания выполняются на исходном массиве. + +- **Нестабильная сортировка**: при обмене элементов на вершине и внизу кучи относительное положение равных элементов может измениться. + +#### Блочная сортировка + +> Ранее рассмотренные алгоритмы сортировки относятся к алгоритмам сорти- ровки на основе сравнения, которые осуществляют сортировку путем срав- нения величин элементов. Временная сложность таких алгоритмов не может превысить *O*(*n* log *n*). Далее рассмотрим алгоритмы сортировки без сравнения, временная сложность которых может достигать линейного порядка. +> +> *Блочная сортировка* является типичным применением стратегии «разделяй и властвуй». Она создает набор упорядоченных по величине блоков, где каж- дый блок соответствует определенному диапазону данных, и равномерно рас- пределяет элементы по этим блокам. Затем сортировка выполняется отдель- но внутри каждого блока, после чего отсортированные данные объединяются в соответствии с порядком блоков. + +### Алгоритм + +> Пусть дан массив длиной *n*, элементы которого являются числами с плаваю- щей запятой в диапазоне \[0, 1). Процесс блочной сортировки выглядит следу- ющим образом (см. рис. 11.13): + +1) инициализация *k* блоков, распределение *n* элементов по *k* блокам; + +2) выполнение сортировки отдельно для каждого блока (здесь использует- ся встроенная функция сортировки языка программирования); + +3) объединение результатов в порядке от меньшего блока к большему. + + + +8. Блочная сортировка ❖ **325** + +> ![](ru/docs/assets/media/image690.jpeg)Массив для сортировки **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 + +### Характеристики алгоритма + +> Блочная сортировка подходит для обработки очень больших объемов дан- ных. Например, если входные данные содержат 1 млн элементов и из-за +> +> ограничений по памяти система не может загрузить все данные сразу, то можно разделить данные на 1000 блоков, затем отсортировать отдельно каждый блок и в конце объединить результаты. + +- **Временная сложность** *O*(*n* + *k*): при условии равномерного распределе- ния элементов по блокам количество элементов в каждом блоке равно *n*/*k*. Если сортировка одного блока занимает время *O*(*n*/*k* log *n*/*k*), то сорти- ровка всех блоков занимает время *O*(*n* log *n*/*k*). Когда количество блоков *k* достаточно велико, временная сложность стремится к *O*(*n*). При объеди- нении результатов необходимо обойти все блоки и элементы, что зани- мает время *O*(*n* + *k*). В худшем случае все данные распределяются в один блок, и сортировка этого блока занимает время *O*(*n*2). + +- **Пространственная сложность** *O*(*n* + *k*), **не на месте**: требуется допол- нительное пространство для *k* блоков и всех *n* элементов. + +- Стабильность блочной сортировки зависит от стабильности алгоритма сортировки элементов внутри блоков. + +### Реализация равномерного распределения + +> Время выполнения блочной сортировки теоретически может достигать *O*(*n*). **Ключевым моментом здесь является равномерное распределение эле- ментов по блокам**, так как в реальных данных распределение часто нерав- номерное. Например, мы хотим распределить все товары на маркетплейсе по ценовым диапазонам в 10 блоков, но цены товаров распределены неравно- мерно: очень много товаров дешевле 100 руб. и очень мало дороже 1000 руб. Если разделить ценовой диапазон на 10 равных частей, количество товаров в каждом блоке будет значительно различаться. +> +> Для достижения равномерного распределения можно сначала установить приблизительную границу и грубо распределить данные по 3 блокам. **После этого блоки с большим количеством товаров можно разделить еще на 3 блока**, **пока количество элементов в каждом блоке не станет примерно одинаковым**. +> +> Этот метод, по сути, создает рекурсивное дерево, цель которого -- сделать значения в листовых узлах как можно более равномерными, см. рис. 11.14. Конечно, не обязательно каждый раз делить данные на 3 блока, конкрет- ный способ деления можно выбирать гибко в зависимости от особенностей данных. +> +> Если заранее известна вероятность распределения цен товаров, **можно установить границы цен для каждого блока на основе этого распреде- ления**. Стоит отметить, что распределение данных не обязательно подсчиты- вать точно, можно использовать вероятностную модель для приближенного определения. +> +> Предположим, что цены товаров подчиняются нормальному распределе- нию, таким образом, можно разумно установить ценовые диапазоны и равно- мерно распределить товары по блокам, как показано на рис. 11.15. +> +> ![](ru/docs/assets/media/image692.jpeg) +> +> **Рис. 11.14.** Рекурсивное деление блоков + +![](ru/docs/assets/media/image694.jpeg) + +> **Рис. 11.15.** Деление блоков на основе вероятностного распределения + +#### Сортировка подсчетом + +> *Сортировка подсчетом* реализует сортировку путем подсчета количества эле- ментов и обычно применяется к массивам целых чисел. + +### Простая реализация + +> Рассмотрим простой пример. Пусть дан массив nums длиной *n*, элементы кото- рого -- неотрицательные целые числа. Процесс сортировки подсчетом выгля- дит следующим образом (см. рис. 11.16): + +1) обойти массив, найти максимальное число, обозначить его как *m*, затем создать вспомогательный массив counter длиной *m* + 1; + +2) **с помощью** counter **подсчитать количество вхождений каждого числа в** nums, где counter\[num\] соответствует количеству вхождений числа num. Метод подсчета прост: нужно обойти nums (пусть текущее число -- num), и на каждой итерации увеличивать counter\[num\] на 1; + +3) **так как индексы в** counter **естественно упорядочены**, **значит все чис- ла уже отсортированы**. Далее обходим counter и заполняем nums в по- рядке возрастания количества вхождений каждого числа. + +> ![](ru/docs/assets/media/image697.jpeg)Массив для сортировки **nums** +> +> Индекс **(число)** +> +> Массив счетчиков +> +> **counter** +> +> Результирующий массив **nums** +> +> Обход **counter**, заполнение **nums** +> +> [в]{.smallcaps} соответствии с количеством вхождений. Элемент **counter**\[**num**\] содержит количество вхождений **num** +> +> Обход **nums** и подсчет количества вхождений каждого числа. +> +> Элемент **counter**\[**num**\] содержит количество вхождений **num** +> +> **Рис. 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 + +### Полная реализация + +> Внимательный читатель мог заметить, что **если входные данные** -- **объекты**, **то шаг 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 демонстрируется полный процесс сортировки подсчетом. + +![](ru/docs/assets/media/image700.jpeg) + +> **Рис. 11.17.** Этапы сортировки подсчетом. Шаг 1 +> +> ![](ru/docs/assets/media/image702.jpeg) + +![](ru/docs/assets/media/image704.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image706.jpeg) + +![](ru/docs/assets/media/image708.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 4--5 +> +> ![](ru/docs/assets/media/image710.jpeg) + +![](ru/docs/assets/media/image712.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 6--7 +> +> ![](ru/docs/assets/media/image714.jpeg) +> +> **Рис. 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\] + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* + *m*), **неадаптивная сортировка**: включает обход nums и counter, оба обхода выполняются за линейное время. Обычно *n* ≫ *m*, временная сложность стремится к *O*(*n*). + +- **Пространственная сложность** *O*(*n* + *m*), **не на месте**: используются вспомогательные массивы res и counter длиной *n* и *m* соответственно. + +- **Стабильная сортировка**: поскольку элементы добавляются в res от конца к началу, обратный обход nums позволяет избежать изменения от- носительного положения равных элементов, обеспечивая стабильность сортировки. На самом деле прямой обход nums также дает правильный результат, но он уже не будет стабильным. + +### Ограничения + +> На этом этапе может показаться, что сортировка подсчетом весьма изящна, так как позволяет эффективно сортировать, просто подсчитывая количество. Однако условия для применения сортировки подсчетом довольно строгие. +> +> **Сортировка подсчетом применима только к неотрицательным целым числам**. Если требуется использовать ее для других типов данных, необходи- мо убедиться, что их можно преобразовать в неотрицательные целые числа, не изменяя относительное положение элементов. Например, для массива це- лых чисел с отрицательными значениями можно сначала добавить ко всем числам константу, чтобы все числа стали положительными, а после сортиров- ки вернуть их к первоначальным значениям. +> +> **Сортировка подсчетом подходит для случаев*,* когда объем данных велик**, **а диапазон данных мал**. Например, в приведенном выше примере *m* не должно быть слишком велико, иначе потребуется слишком много памяти. А когда *n* ≪ *m*, сортировка подсчетом использует время *O*(*m*), что может быть медленнее, чем алгоритмы сортировки с временной сложностью *O*(*n* log *n*). + +#### поразрядная сортировка + +> В предыдущем разделе была рассмотрена сортировка подсчетом, которая хо- рошо подходит для случаев, когда объем данных *n* велик, а диапазон данных *m* мал. Предположим, необходимо отсортировать *n* = 106 номеров студентов, где номер -- это восьмизначное число, т. е. диапазон данных *m* = 108 очень велик. Использование сортировки подсчетом потребует выделения большого объема памяти, тогда как поразрядная сортировка позволяет избежать этой проблемы. *Поразрядная сортировка* основывается на той же идее, что и сортировка подсчетом, и также реализуется путем подсчета количества. Но поразрядная сортировка использует прогрессивные отношения между разрядами чисел, +> +> выполняя сортировку по каждому разряду. + +### Алгоритм + +> Возьмем в качестве примера данные о номерах студентов. Предположим, что наименьший разряд -- это 1-й разряд, а наибольший -- 8-й разряд. Процесс по- разрядной сортировки выглядит следующим образом (см. рис. 11.18): + +10. Поразрядная сортировка ❖ **335** + + 1) инициализация разряда *k* = 1; + + 2) выполнение сортировки подсчетом по *k*-му разряду номеров студентов. После завершения данные будут отсортированы по *k*-му разряду в по- рядке возрастания; + + 3) увеличение *k* на 1 и возврат на шаг 2. Продолжение итераций до завер- шения сортировки по всем разрядам. + +![](ru/docs/assets/media/image716.jpeg) + +> **Рис. 11.18.** Процесс алгоритма сортировки по разрядам +> +> Теперь проанализируем код реализации. Пусть дано число *x* в *d*-ричной си- стеме исчисления. Чтобы получить его *k*-й разряд *xk*, можно использовать сле- дующую формулу: + +Ι 1 + +> ***k*** Λ *d **k*** −1 Υ +> +> где ⌊*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 +> +> 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 + +### Характеристики алгоритма + +11. Резюме ❖ **337** + +> По сравнению с сортировкой подсчетом поразрядная сортировка подходит для случаев с большим диапазоном чисел, **но при условии**, **что данные мож- но представить в формате фиксированной разрядности**, **и разрядность не должна быть слишком большой**. Например, числа с плавающей запятой не подходят для поразрядной сортировки, поскольку их разрядность *k* слиш- ком велика, что может привести к временной сложности *O*(*nk*) ≫ *O*(*n*2). + +- **Временная сложность** *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. резюме** + +##### Ключевые моменты + +- Сортировка пузырьком реализует сортировку путем обмена соседних элементов. Добавив флаг для досрочного выхода из цикла, можно оп- тимизировать лучшую временную сложность пузырьковой сортиров- ки до *O*(*n*). + +- Сортировка вставками в каждом раунде вставляет элемент из неот- сортированного диапазона в правильное место в отсортированном диапазоне. Хотя временная сложность этой сортировки составля- ет *O*(*n*2), благодаря относительно малому количеству элементарных операций она хорошо подходит для задач сортировки небольших объемов данных. + +- Быстрая сортировка основана на операции разделения с использова- нием опорного элемента. При разделении возможна ситуация, когда каждый раз выбирается наихудший опорный элемент, что приводит к ухудшению временной сложности до *O*(*n*2). Введение медианного или случайного опорного элемента может снизить вероятность такого ухудшения. Метод хвостовой рекурсии может эффективно уменьшить глубину рекурсии и оптимизировать пространственную сложность до *O*(log *n*). + +- Сортировка слиянием включает два этапа -- разделение и слияние -- и является типичным представителем стратегии «разделяй и властвуй». В сортировке слиянием для сортировки массива требуется создание + +> вспомогательного массива, поэтому пространственная сложность со- ставляет *O*(*n*). Однако для сортировки связного списка пространствен- ную сложность можно оптимизировать до *O*(1). + +- Блочная сортировка включает три этапа: распределение данных по блокам, сортировку внутри блоков и объединение результатов. Она также демонстрирует стратегию «разделяй и властвуй» и подходит для случаев с большими объемами данных. Ключ к эффективной блоч- ной сортировке заключается в равномерном распределении данных по блокам. + +- Сортировка подсчетом является частным случаем блочной сортировки, она реализует сортировку путем подсчета количества вхождений дан- ных. Сортировка подсчетом подходит для случаев с большим объемом данных, но ограниченным диапазоном и требует, чтобы данные могли быть преобразованы в положительные целые числа. + +- Поразрядная сортировка реализует сортировку данных путем последо- вательной сортировки по разрядам. Для этого требуется, чтобы данные можно было представить в виде чисел фиксированной разрядности. + +- В целом мы стремимся найти алгоритм сортировки, обладающий такими преимуществами, как высокая эффективность, стабильность, выполне- ние на месте и адаптивность. Однако, как и в случае с другими структу- рами данных и алгоритмами, не существует алгоритма сортировки, ко- торый одновременно удовлетворял бы всем этим условиям. На практике необходимо выбирать подходящий алгоритм сортировки в зависимости от характеристик данных. + +- На рис. 11.19 приведено сравнение таких характеристик основных ал- горитмов сортировки, как эффективность, стабильность, выполнение на месте и адаптивность. + ++-------------------------------------+---------------------------+-------------------------------------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > **Алгоритм сортировки** | > **Временная сложность** | > **Простран- ственная сложность** | > **Стабиль- ность** | > **Местность** | > **Адаптив- ность** | > **Основан- ность на сравнении** | +| | +--------------+---------------+------------+ | | | | | +| | | > **Лучшая** | > **Средняя** | **Худшая** | | | | | | ++=====================================+===========================+:============:+===============+===========:+:==================================:+======================+:===============:+======================+:=================================:+ +| > Сортировка обходом O(n2) | > Выбором | > O(n2) | > O(n2) | > O(n2) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Пузырьком | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Вставками | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| > Сортировка разделением O(n log n) | > Быстрая | > O(n log n) | > O(n log n) | > O(n2) | > O(log n) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Слиянием | > O(n log n) | > O(n log n) | O(n log n) | > O(n) | > Стабильный | > Не на месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Пирами- дальная | > O(n log n) | > O(n log n) | O(n log n) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| > Линейная со- ртировка O(n) | > Блочная | > O(n + k) | > O(n + k) | > O(n2) | > O(n + k) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Подсчетом | > O(n + m) | > O(n + m) | O(n + m) | > O(n + m) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Поразряд- ная | > O(n k) | > O(n k) | > O(n k) | > O(n + b) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ + +> 11.11. Резюме ❖ **339** +> +> Плохая Средняя Хорошая + +- **\ + n** -- размер данных + +- В блочной сортировке **k** -- количество блоков + +- В сортировке подсчетом **m** -- диапазон данных + +- В поразрядной сортировке **k** -- максимальное количество разрядов, **b** -- основание системы счисления данных + +> **Рис. 11.19.** Сравнение алгоритмов сортировки + +##### Вопросы и ответы + +> **Вопрос**. В каких случаях необходима стабильность алгоритма сортировки? +> +> **Ответ**. В реальной жизни может возникнуть необходимость сортировки объектов по какому-либо атрибуту. Например, у студентов есть два атрибута: имя и рост. Мы хотим осуществить многоуровневую сортировку: сначала по имени, получив (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*(*n*2)? Как справиться с таким вырождением? +> +> **Ответ**. Да. В этом случае можно рассмотреть возможность разделения массива на три части с использованием стража: меньше, равно и больше опорного элемента. Рекурсия продолжается только для частей, меньших и больших опорного элемента. При таком подходе массив с одинаковыми +> +> 11.11. Резюме ❖ **341** +> +> элементами будет отсортирован за одну итерацию разделения с использо- ванием стража. +> +> **Вопрос**. Почему временная сложность сортировки подсчетом в худшем слу- чае составляет *O*(*n*2)? +> +> **Ответ**. В худшем случае все элементы попадут в одну корзину. Если для со- ртировки этих элементов используется алгоритм с временной сложностью *O*(*n*2), то общая временная сложность составит *O*(*n*2). +> +> Глава 12 diff --git a/ru/chapters/chapter_12.md b/ru/chapters/chapter_12.md new file mode 100644 index 000000000..2e6316422 --- /dev/null +++ b/ru/chapters/chapter_12.md @@ -0,0 +1,511 @@ +# Разделяй и властвуй + +![](ru/docs/assets/media/image717.jpeg){width="3.5416655730533684in" height="4.583333333333333in"} + +#### Стратегия «разделяй и властвуй» + +> «*Разделяй и властвуй*» -- это важная и распространенная стратегия в алгорит- мах. Обычно она реализуется с помощью рекурсии и включает два этапа: раз- деление и объединение. + +- **Разделение (этап разбиения)**: рекурсивное разбиение исходной зада- чи на две или более подзадачи до тех пор, пока не будет достигнута наи- меньшая подзадача. + +- **Объединение (этап слияния)**: начиная с решения наименьших подза- дач, снизу вверх объединяются решения всех других подзадач, чтобы по- строить решение исходной задачи. + +> Сортировка слиянием является типичным примером применения страте- гии «разделяй и властвуй» (см. рис. 12.1). + +- **Разделение**: рекурсивное разбиение исходного массива (исходной за- дачи) на два подмассива (подзадачи) до тех пор, пока в подмассивах не останется по одному элементу (наименьшая подзадача). + +- **Объединение**: снизу вверх объединяются упорядоченные подмассивы (решения подзадач), чтобы получить упорядоченный исходный массив (решение исходной задачи). + +![](ru/docs/assets/media/image719.jpeg) + +> **Рис. 12.1.** Стратегия «разделяй и властвуй» в сортировке слиянием + +1. **Определение задачи для метода** + +> **«разделяй и властвуй»** +> +> Чтобы определить, подходит ли задача для решения методом «разделяй и вла- ствуй», можно использовать следующие критерии: + +1) **задачу можно разбить**: исходную задачу можно разбить на более мел- кие, аналогичные подзадачи, которые можно рекурсивно разделить ана- логичным образом; + +2) **подзадачи независимы**: подзадачи не пересекаются, не зависят друг от друга и могут быть решены независимо; + +3) **решения подзадач можно объединить**: решение исходной задачи по- лучается путем объединения решений подзадач. + +> Очевидно, что сортировка слиянием соответствует этим трем критериям. + +- **Задачу можно разбить**: рекурсивное разбиение массива (исходной за- дачи) на два подмассива (подзадачи). + +- **Подзадачи независимы**: каждый подмассив можно отсортировать не- зависимо (подзадачи можно решить независимо). + +- **Решения подзадач можно объединить**: два упорядоченных подмасси- ва (решения подзадач) можно объединить в один упорядоченный массив (решение исходной задачи). + +### Повышение эффективности с помощью стратегии «разделяй и властвуй» + +> **Стратегия «разделяй и властвуй» позволяет не только эффективно ре- шать алгоритмические задачи**, **но и повышать эффективность алгорит- мов**. Алгоритмы быстрой сортировки, сортировки слиянием и пирамидальной сортировки быстрее, чем сортировка выбором, пузырьком и вставками, имен- но благодаря применению стратегии «разделяй и властвуй». +> +> Возникает вопрос: **почему метод «разделяй и властвуй» повышает эф- фективность алгоритма**, **в чем его основная логика**? Иными словами, поче- му разбиение большой задачи на несколько подзадач, решение этих подзадач и объединение их решений в решение исходной задачи оказывается более эф- фективным, чем непосредственное решение исходной задачи? Этот вопрос мож- но обсудить с точки зрения количества операций и параллельных вычислений. + +##### Оптимизация количества операций + +> Возьмем, к примеру, сортировку пузырьком, которая требует времени *O*(*n*2) для обработки массива длиной *n*. Предположим, что мы разделили массив на два подмассива, как показано на рис. 12.2. Тогда разбиение потребует времени *O*(*n*), сортировка каждого подмассива -- *O*((*n*/2)2), а объединение двух подмас- сивов -- *O*(*n*). Общая временная составит: + +( ( *[n]{.underline}*  2   ( *n*^2^   + +*O* ∴ *n+* ∴   ξ 2 + *n*   = *O* ∴ + 2*n*  . + +  + +  + +>     2   +> +> Далее, решим следующее неравенство, в котором левая и правая части пред- ставляют общее количество операций до и после разбиения соответственно: +> +> *n*^2^ \> *n*2 + 2*n,* + +2 + +> *n*^2^ − *n*2 − 2*n \>* 0, + +2 + +> *n*(*n* − *4*) \> 0. +> +> ![](ru/docs/assets/media/image721.jpeg) +> +> **Рис. 12.2.** Сортировка пузырьком до и после разбиения массива +> +> **Это означает**, **что при** *n* \> 4 **количество операций после разбиения мень- ше**, **и эффективность сортировки должна быть выше**. Обратите внимание, что временная сложность после разбиения остается квадратичной *O*(*n*2), но по- стоянный коэффициент в сложности уменьшается. +> +> **Если продолжить разбиение подмассивов пополам**, **пока в них не оста- нется по одному элементу**, то получится сортировка слиянием, временная сложность которой составляет *O*(*n* log *n*). +> +> А что, **если мы установим несколько дополнительных точек разделе- ния** и равномерно разделим исходный массив на *k* подмассивов? Эта ситуация очень похожа на блочную сортировку, которая хорошо подходит для сортиров- ки очень больших объемов данных, и теоретически ее временная сложность может достигать *O*(*n* + *k*). + +##### Оптимизация параллельных вычислений + +> Известно, что подзадачи, созданные методом «разделяй и властвуй», незави- симы друг от друга, **поэтому их обычно можно решать параллельно**. Таким образом, этот метод не только снижает временную сложность алгоритма, **но и способствует параллельной оптимизации операционной системы**. +> +> Параллельная оптимизация особенно эффективна в многоядерной или многопроцессорной среде, поскольку система может одновременно обраба- тывать несколько подзадач, более полно используя вычислительные ресурсы, что значительно сокращает общее время выполнения. +> +> Например, в блочной сортировке, изображенной на рис. 12.3, огромный объем данных равномерно распределяется по блокам. Задачи сортировки всех блоков можно распределить по вычислительным единицам, а затем объеди- нить результаты. +> +> ![](ru/docs/assets/media/image723.jpeg) +> +> **Рис. 12.3.** Параллельные вычисления в блочной сортировке + +### Типичные сценарии применения стратегии «разделяй и властвуй» + +> С одной стороны, стратегию «разделяй и властвуй» можно использовать для решения многих классических алгоритмических задач. + +- **Поиск ближайшей пары точек**: этот алгоритм сначала делит множе- ство точек на две части, затем находит ближайшую пару точек в каж- дой части, а затем находит ближайшую пару точек, охватывающую обе части. + +- **Умножение больших чисел**: например, алгоритм Карацубы, который разлагает умножение больших чисел на несколько операций умножения и сложения меньших чисел. + +- **Умножение матриц**: например, алгоритм Штрассена, который разлага- ет умножение больших матриц на несколько операций умножения и сло- жения матриц меньшего размера. + +- **Задача о Ханойских башнях**: эту задачу можно решить с помощью рекурсии, что является типичным применением стратегии «разделяй и властвуй». + +- **Задача о количестве инверсий**: если в последовательности предыду- щее число больше последующего, то эти два числа образуют инверсию. Задачу о количестве инверсий можно решить с помощью подхода «раз- деляй и властвуй» и сортировки слиянием. + +> С другой стороны, стратегия «разделяй и властвуй» широко применяется в разработке алгоритмов и структур данных. + +- **Двоичный поиск**: такой поиск делит отсортированный массив на две части по индексу среднего элемента. Затем, в зависимости от + +> результата сравнения целевого значения со средним элементом, ре- шает, какую половину исключить, и выполняет ту же операцию на оставшейся части. + +- **Сортировка слиянием**: уже была рассмотрена в начале этого раздела, не будем еще раз повторяться. + +- **Быстрая сортировка**: эта сортировка выбирает опорное значение, затем делит массив на два подмассива, элементы одного из которых меньше опорного значения, а элементы другого -- больше. Затем выполняет ту же операцию с обеими частями, пока в подмассиве не останется один элемент. + +- **Блочная сортировка**: основная идея этой сортировки заключается в распределении данных по нескольким блокам и сортировке элемен- тов в каждом из них. Затем происходит последовательное извлечение элементов из каждого блока для построения отсортированного массива. + +- **Деревья**: например, двоичные деревья поиска, АВЛ-дерево, красно-чер- ное дерево, B-дерево, дерево B+ и т. д. Операции поиска, вставки и уда- ления в них можно рассматривать как применение стратегии «разделяй и властвуй». + +- **Кучи**: куча -- это особый вид полного двоичного дерева, и такие опера- ции, как вставка, удаление и упорядочивание, фактически подразумева- ют использование метода «разделяй и властвуй». + +- **Хеш-таблицы**: хотя хеш-таблицы напрямую не применяют подход + +> «разделяй и властвуй», некоторые решения для разрешения коллизий в хешировании косвенно используют эту стратегию. Например, длинные цепочки в методе цепной адресации преобразуются в красно-черные де- ревья для повышения эффективности поиска. +> +> Можно сказать, **что стратегия «разделяй и властвуй»** -- **это своего рода** +> +> **«скрытая» алгоритмическая идея**, присутствующая в различных алгорит- мах и структурах данных. + +#### применение стратегии «разделяй + +> **и властвуй» для поиска** +> +> Мы уже знаем, что алгоритмы поиска делятся на две большие категории. + +- **Полный перебор**: реализуется путем обхода структуры данных, вре- менная сложность составляет *O*(*n*). + +- **Адаптивный поиск**: использует особую организацию данных или апри- орную информацию, временная сложность может достигать *O*(log *n*) или даже *O*(1). + +> На практике **алгоритмы поиска с временной сложностью** *O*(log *n*) **обыч- но реализуются на основе стратегии «разделяй и властвуй»**, например двоичный поиск и деревья. + +- **Двоичный поиск** на каждом шаге разбивает задачу (поиск целевого элемента в массиве) на более мелкую задачу (поиск целевого элемента в половине массива). Этот процесс продолжается до тех пор, пока массив не станет пустым или не будет найден целевой элемент. + + + +- **Деревья** являются представителями стратегии «разделяй и властвуй». В структурах данных, таких как двоичное дерево поиска, АВЛ-дерево, куча и др., временная сложность различных операций составляет *O*(log *n*). + +> Стратегия «разделяй и властвуй» для двоичного поиска выглядит следую- щим образом. + +- **Задачу можно разбить**: двоичный поиск рекурсивно разбивает ис- ходную задачу (поиск в массиве) на подзадачи (поиск в половине массива), что достигается сравнением среднего элемента с целевым элементом. + +- **Подзадачи независимы**: в двоичном поиске на каждом этапе об- рабатывается только одна подзадача, которая не зависит от других подзадач. + +- **Решения подзадач не требуют объединения**: двоичный поиск на- правлен на поиск конкретного элемента, поэтому объединять решения подзадач не требуется. Когда подзадача решена, исходная задача также считается решенной. + +> Стратегия «разделяй и властвуй» повышает эффективность поиска, по- скольку при грубом поиске на каждом этапе можно исключить только один вариант, тогда как при поиске «разделяй и властвуй» на каждом этапе можно исключить половину вариантов. + +##### Реализация двоичного поиска на основе стратегии «разделяй и властвуй» + +> В предыдущих главах двоичный поиск был реализован на основе итераций. Теперь мы реализуем его на основе принципа «разделяй и властвуй» (ре- курсии). +> +> Для применения стратегии «разделяй и властвуй» обозначим подзадачу для поискового интервала \[*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 в массиве. +> +> ![](ru/docs/assets/media/image725.jpeg) +> +> **Рис. 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) + +#### задача построения двоичного дерева + +![](ru/docs/assets/media/image727.jpeg) + +> **Рис. 12.5.** Пример данных для построения двоичного дерева + +##### Проверка критериев стратегии «разделяй и властвуй» + +> Исходная задача, заключающаяся в построении двоичного дерева из обходов +> +> preorder и inorder, является типичной задачей типа «разделяй и властвуй». + +- **Задачу можно разбить**: с точки зрения стратегии «разделяй и вла- ствуй» исходную задачу можно разделить на две подзадачи. Построение левого поддерева и построение правого поддерева плюс один шаг: ини- циализация корневого узла. Для каждого поддерева (подзадачи) можно повторно использовать вышеуказанный метод разделения и разделить его на более мелкие поддеревья (подзадачи), пока не будет достигнута минимальная подзадача (пустое поддерево). + +- **Подзадачи независимы**: левое и правое поддеревья независимы друг от друга, между ними нет пересечений. При построении левого подде- рева необходимо учитывать только части симметричного и прямого по- рядка обхода, соответствующие левому поддереву. Для правого подде- рева аналогично. + +- **Решения подзадач можно объединить**: как только получены левое и правое поддеревья (решения подзадач), их можно связать с корневым узлом и получить решение исходной задачи. + +##### Разделение поддеревьев + +> Мы определили, что эту задачу можно решить с помощью стратегии «разделяй и властвуй». **Но как именно разделить левое и правое поддеревья с помо- щью прямого** (preorder) **и симметричного** (inorder) **порядков обхода**? +> +> Согласно определению 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\]. + +> ![](ru/docs/assets/media/image729.jpeg)**Первый элемент является корневым узлом** +> +> Прямой обход +> +> preorder +> +> **Поиск индекса корневого узла** +> +> Симметричный обход inorder +> +> Левое поддерево имеет **1** узел + +Прямой обход + +> Правое поддерево имеет **3** узла +> +> **Рис. 12.6.** Разделение поддеревьев в прямом и симметричном обходах + +##### Описание интервалов поддеревьев на основе переменных + +> По вышеописанному методу разделения **мы получили интервалы индек- сов корневого узла**, **левого и правого поддеревьев в** preorder и inorder. Для описания этих интервалов индексов необходимо использовать несколько указателей. + +- Индекс корневого узла текущего дерева в preorder обозначим как *i*. + +- Индекс корневого узла текущего дерева в inorder обозначим как *m*. + +- Интервал индексов текущего дерева в inorder обозначим как \[*l*, *r*\]. + +> С помощью этих переменных можно описать индекс корневого узла в preorder +> +> и интервал индексов поддеревьев в inorder, как показано в табл. 12.1. +> +> Обратите внимание, что значение (*m* -- l) в индексе корневого узла правого поддерева означает количество узлов левого поддерева, рекомендуется разо- брать эту таблицу вместе с рис. 12.7. +> +> **Таблица 12.1.** Индексы корневого узла и поддеревьев в прямом и симметричном обходах + ++--------------------+----------------------------------------+-------------------------------------------------+ +| | > **Индекс корневого узла в** preorder | > **Интервал индексов подде- ревьев в** inorder | ++====================+========================================+=================================================+ +| > Текущее дерево | > i | > \[*l*, *r*\] | ++--------------------+----------------------------------------+-------------------------------------------------+ +| > Левое поддерево | > *i* + 1 | > \[*l*, *m* -- 1\] | ++--------------------+----------------------------------------+-------------------------------------------------+ +| > Правое поддерево | > *i* + 1 + (*m* -- l) | > \[*m* + 1, r\] | ++--------------------+----------------------------------------+-------------------------------------------------+ + +![](ru/docs/assets/media/image731.jpeg) + +> **Рис. 12.7.** Представление интервалов индексов корневого узла, левого и правого поддеревьев + +##### Код реализации + +> Для повышения эффективности поиска средней точки *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) +> +> \# Возврат корневого узла. return root +> +> def build_tree(preorder: list\[int\], inorder: list\[int\]) -\> TreeNode \| None: \"\"\" Построение двоичного дерева.\"\"\" +> +> \# Инициализация хеш-таблицы для хранения отображения элементов inorder +> +> [в]{.smallcaps} индексы. +> +> inorder_map = {val: i for i, val in enumerate(inorder)} root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1) return root +> +> На рис. 12.8 демонстрируется рекурсивный процесс построения двоичного дерева, в котором каждый узел создается в процессе спуска, а каждое ребро (ссылка) создается в процессе подъема. + +![](ru/docs/assets/media/image733.jpeg)![](ru/docs/assets/media/image735.jpeg) + +> **Рис. 12.8.** Рекурсивный процесс построения двоичного дерева. Шаги 1--2 +> +> ![](ru/docs/assets/media/image737.jpeg) + +![](ru/docs/assets/media/image739.jpeg)![](ru/docs/assets/media/image741.jpeg) + +> **Рис. 12.8.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image743.jpeg) + +![](ru/docs/assets/media/image745.jpeg)![](ru/docs/assets/media/image747.jpeg) + +> **Рис. 12.8.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image749.jpeg) +> +> **Рис. 12.8.** *Окончание*. Шаг 9 +> +> Результаты разделения прямого обхода preorder и симметричного обхода in- order в каждом рекурсивном вызове показаны на рис. 12.9. + +![](ru/docs/assets/media/image751.jpeg) + +> **Рис. 12.9.** Результаты разделения в каждом рекурсивном вызове +> +> Пусть количество узлов в дереве равно *n*, инициализация каждого узла (выполнение одного рекурсивного вызова dfs()) занимает время *O*(1). **Сле- довательно**, **общая временная сложность составляет** *O*(*n*). +> +> Хеш-таблица хранит отображение элементов inorder в индексы, про- странственная сложность составляет *O*(*n*). В худшем случае, когда двоичное дерево вырождается в список, глубина рекурсии достигает *n*, что требует *O*(*n*) пространства стека. **Поэтому общая пространственная сложность составляет** *O*(*n*). + +#### задача о ханойских башнях + +> В алгоритмах сортировки слиянием и построения двоичного дерева мы раз- бивали исходную задачу на две подзадачи, каждая из которых имела полови- ну размера исходной задачи. Однако для задачи о Ханойских башнях исполь- зуется другая стратегия разбиения. + +![](ru/docs/assets/media/image753.jpeg) + +> **Рис. 12.10.** Пример задачи о Ханойских башнях +> +> Обозначим задачу о Ханойских башнях с *i* дисками как *f*(*i*). Например, *f*(3) соответствует задаче о перемещении 3 дисков с A на C. + +##### 1. Базовый случай + +> Для случая *f*(1), когда имеется только один диск, можно просто переместить единственный диск с A на C, как показано на рис. 12.11. + +![](ru/docs/assets/media/image755.png) + +![](ru/docs/assets/media/image757.jpeg) + +> **Рис. 12.11.** Решение задачи размера 1 +> +> Для задачи *f*(2), когда имеется два диска, уже требуется соблюдать условие, что меньший диск находится на большем. **Поэтому для выполнения пере- мещения потребуется использовать стержень** B. + +1. Сначала переместить верхний диск с A на B. + +2. Затем переместить большой диск с A на C. + +3. ![](ru/docs/assets/media/image759.jpeg)Переместить маленький диск с B на C. + +> **Рис. 12.12.** Решение задачи размера 2. Шаг 1 +> +> ![](ru/docs/assets/media/image761.jpeg) + +![](ru/docs/assets/media/image763.jpeg)![](ru/docs/assets/media/image765.jpeg) + +> **Рис. 12.12.** *Окончание*. Шаги 2--4 +> +> Процесс решения задачи *f*(2) можно кратко описать следующим образом: **переместить два диска с** A **на** C **с помощью** B. Здесь C называется целевым стержнем, а B -- вспомогательным стержнем. + +##### Разделение на подзадачи + +> Для задачи *f*(3), когда имеется три диска, ситуация становится несколько сложнее. Поскольку решения *f*(1) и *f*(2) уже известны, можно рассмотреть задачу с точ- ки зрения метода «разделяй и властвуй». Можно считать два верхних диска на A единым целым и выполнить шаги, показанные на рис. 12.13. Таким образом, +> +> три диска успешно переместятся с A на C. + +1. Пусть B будет целевым стержнем, а C -- вспомогательным. Переместить два диска с A на B. + +2. Переместить оставшийся диск с A непосредственно на C. + +3. Пусть C будет целевым стержнем, а A -- вспомогательным стержнем. Пе- реместить два диска с B на C. + +![](ru/docs/assets/media/image767.jpeg)![](ru/docs/assets/media/image769.jpeg) + +> **Рис. 12.13.** Решение задачи размера 3. Шаги 1-2 +> +> ![](ru/docs/assets/media/image771.jpeg) + +![](ru/docs/assets/media/image773.jpeg) + +> **Рис. 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) уже известно и требует только одного перемещения. +> +> ![](ru/docs/assets/media/image775.jpeg) +> +> **Рис. 12.14.** Стратегия «разделяй и властвуй» для решения задачи Ханойской башни + +##### Код реализации + +> В коде объявляется рекурсивная функция 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) + +1. Резюме ❖ **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*(2*n*), **а пространственная сложность** -- *O*(*n*). + +![](ru/docs/assets/media/image777.jpeg) + +> **Рис. 12.15.** Рекурсивное дерево задачи Ханойской башни +> +> **12.5. резюме** + +- «Разделяй и властвуй» -- это распространенная стратегия разработки ал- горитмов, включающая два этапа -- разделение (декомпозиция) и объ- единение (синтез) -- и обычно реализуемая с помощью рекурсии. + + + +- Критерии применимости этой стратегии к задаче включают: возмож- ность декомпозиции задачи, независимость подзадач и возможность их объединения. + +- Сортировка слиянием -- это типичное применение стратегии «разделяй и властвуй». Эта сортировка рекурсивно разделяет массив на два под- массива равной длины, пока не останется массив из одного элемента. После чего начинается поэтапное объединение. + +- Введение стратегии «разделяй и властвуй» часто позволяет повысить эффективность алгоритма. С одной стороны, стратегия уменьшает коли- чество операций. С другой стороны, после разделения она способствует оптимизации для параллельного выполнения. + +- Принцип «разделяй и властвуй» не только позволяет решать множество алгоритмических задач, но и широко применяется в проектировании структур данных и алгоритмов, его можно встретить повсюду. + +- Адаптивный поиск более эффективен по сравнению с полным перебо- ром. Алгоритмы поиска со сложностью *O*(log *n*) обычно реализуются на основе стратегии «разделяй и властвуй». + +- Двоичный поиск -- это еще одно типичное применение стратегии «разде- ляй и властвуй», которое не содержит этап объединения решений подзадач. Двоичный поиск можно реализовать с помощью рекурсивного подхода. + +- Задачу построения двоичного дерева можно разделить на построение левого и правого поддеревьев (подзадачи), что достигается путем раз- деления индексов в порядке предварительного и симметричного обхода. + +- Задачу Ханойской башни размера *n* можно разделить на две подзадачи размера *n* -- 1 и одну подзадачу размера 1. После последовательного ре- шения этих трех подзадач исходная задача будет также решена. + +> Глава 13 diff --git a/ru/chapters/chapter_13.md b/ru/chapters/chapter_13.md new file mode 100644 index 000000000..b416f5e97 --- /dev/null +++ b/ru/chapters/chapter_13.md @@ -0,0 +1,861 @@ +# Поиск с возвратом + +![](ru/docs/assets/media/image778.jpeg){width="3.630207786526684in" height="4.697916666666667in"} + +#### Алгоритмы поиска с возвратом + +> *Алгоритм поиска с возвратом* -- это метод решения задач путем перебора. Его основная идея заключается в том, чтобы, начиная с начального состояния, осуществлять грубый поиск всех возможных решений, фиксируя правильное найденное решение. Процесс поиска продолжается до тех пор, пока не будет найдено решение или не будут исчерпаны все возможные варианты. +> +> Алгоритмы поиска с возвратом обычно используют поиск в глубину для об- хода пространства решений. В разделе «Двоичные деревья» упоминалось, что прямой, симметричный и обратный обходы относятся к поиску в глубину. Да- лее, используя прямой обход, мы реализуем задачу поиска с возвратом, чтобы постепенно понять принцип работы этого алгоритма. +> +> Для решения этой задачи мы выполняем предварительный обход дерева и проверяем, равно ли значение текущего узла 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) +> +> ![](ru/docs/assets/media/image781.jpeg)Поиск в глубину +> +> Прямой порядок обхода Посетить узел в +> +> **Выполнить прямой обход двоичного дерева и записать узлы со значением 7** +> +> **Рис. 13.1.** Поиск узлов в предварительном обходе + +1. **Попытка и возврат** + +> **Алгоритм называется поиском с возвратом, потому что при поиске в пространстве решений он использует стратегию попытки и возврата**. Когда алгоритм сталкивается с состоянием, в котором невозможно продолжать или невозможно получить удовлетворительное решение, он отменяет преды- дущий выбор, возвращается к предыдущему состоянию и пробует другие воз- можные варианты. +> +> В примере 1 посещение каждого узла представляет собой попытку, а пере- ход через листовой узел или возврат к родительскому узлу через return озна- чает возврат. +> +> Стоит отметить, что **откат включает не только возврат функции**. Чтобы объяснить это, мы немного расширим пример 1. +> +> Возьмем за основу код для примера 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, **можно представить попытку и возврат как движение вперед и отмену**, как два противополож- ных действия. +> +> ![](ru/docs/assets/media/image784.jpeg) + +![](ru/docs/assets/media/image786.jpeg)![](ru/docs/assets/media/image788.jpeg) + +> **Рис. 13.2.** Попытка и возврат. Шаги 1--3 +> +> ![](ru/docs/assets/media/image790.jpeg) + +![](ru/docs/assets/media/image792.jpeg)![](ru/docs/assets/media/image794.jpeg) + +> **Рис. 13.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image796.jpeg) + +![](ru/docs/assets/media/image798.jpeg)![](ru/docs/assets/media/image800.jpeg) + +> **Рис. 13.2.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image802.jpeg) + +![](ru/docs/assets/media/image804.jpeg) + +### Обрезка + +> **Рис. 13.2.** *Окончание*. Шаги 10--11 +> +> Сложные задачи поиска с возвратом обычно содержат одно или несколько ограничений, **которые можно использовать для обрезки**. +> +> Для выполнения данного условия **требуется добавить операцию обрезки**: в процессе поиска, если встречается узел со значением 3, следует немедленно вернуться, не продолжая поиск. Код реализации представлен ниже. +> +> \# === 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. + +![](ru/docs/assets/media/image806.jpeg) + +> **Рис. 13.3.** Обрезка в соответствии с заданными условиями + +### Каркас кода + +> Далее мы попытаемся сформировать основной каркас операций «попытка, возврат, обрезка» для повышения универсальности кода. +> +> В следующем каркасе кода state обозначает текущее состояние задачи, а choices -- возможные выборы в текущем состоянии: +> +> def backtrack(state: State, choices: list\[choice\], res: list\[state\]): \"\"\" Каркас алгоритма поиска с возвратом.\"\"\" +> +> \# Проверка, является ли состояние решением. if is_solution(state): +> +> \# Запись решения. record_solution(state, res) +> +> \# Не продолжать поиск. 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: +> +> \# Обрезка: проверка легитимности выбора. 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. +> +> ![](ru/docs/assets/media/image808.jpeg)После записи решения остановить поиск +> +> **Сохранение return** +> +> Возврат после записи решения, не продолжать поиск +> +> **Удаление return** +> +> Не возвращаться после записи решения, продолжить поиск +> +> **Рис. 13.4.** Сравнение процесса поиска с сохранением и удалением return +> +> По сравнению с реализацией на основе предварительного обхода, реали- зация на основе каркаса поиска с возвратом выглядит более громоздкой, но обладает большей универсальностью. На самом деле многие задачи поиска с возвратом можно решить в рамках этого каркаса. Необходимо лишь опре- делить state и choices в соответствии с конкретной задачей и реализовать ме- тоды каркаса. + +### Основные термины + +> Для более четкого понимания алгоритмических задач мы систематизируем значения часто используемых терминов обратного поиска и приведем соот- ветствующие примеры для задачи 3, как показано в табл. 13.1. +> +> **Таблица 13.1.** Основные термины обратного поиска + ++---------------+----------------------------------------+-----------------------------------------+ +| > **Термин** | > **Определение** | > **Пример 3** | ++===============+========================================+=========================================+ +| > Решение | > Ответ, удовлетворяющий определен- | > Все пути от корневого узла до узла 7, | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ным условиям задачи, может быть одно | > удовлетворяющие условиям | ++---------------+----------------------------------------+-----------------------------------------+ +| | > или несколько решений | | ++---------------+----------------------------------------+-----------------------------------------+ +| > Ограничение | > Ограничение на допустимость реше- | > Путь не содержит узлов со значением 3 | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ния, обычно используется для обрезки | | ++---------------+----------------------------------------+-----------------------------------------+ +| > Состояние | > Ситуация задачи в определенный мо- | > Текущий посещенный путь узлов, т. е. | ++---------------+----------------------------------------+-----------------------------------------+ +| | > мент, включая сделанные выборы | > список узлов path | ++---------------+----------------------------------------+-----------------------------------------+ +| > Попытка | > Процесс исследования пространства | > Рекурсивный доступ к левому (право- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > решений на основе доступных вы- | > му) дочернему узлу, добавление узла | ++---------------+----------------------------------------+-----------------------------------------+ +| | > боров, включая выбор, обновление | > в path, проверка значения узла на | ++---------------+----------------------------------------+-----------------------------------------+ +| | > состояния, проверку на решение | > равенство 7 | ++---------------+----------------------------------------+-----------------------------------------+ +| > Возврат | > Отмена предыдущих выборов и воз- | > При переходе через листовой узел, | ++---------------+----------------------------------------+-----------------------------------------+ +| | > врат к предыдущему состоянию при | > завершении посещения узла, встрече | ++---------------+----------------------------------------+-----------------------------------------+ +| | > встрече состояния, не удовлетворяю- | > узла со значением 3 поиск прекраща- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > щего ограничению | > ется, происходит выход из функции | ++---------------+----------------------------------------+-----------------------------------------+ +| > Обрезка | > Метод избегания бессмысленных путей | > При встрече узла со значением 3 по- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > поиска на основе условий и ограниче- | > иск прекращается | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ний задачи, повышающий эффектив- | | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ность поиска | | ++---------------+----------------------------------------+-----------------------------------------+ + +### Преимущества и ограничения + +> Алгоритм поиска с возвратом, по сути, является алгоритмом поиска в глуби- ну, который пытается найти все возможные решения до тех пор, пока не будет найдено решение, удовлетворяющее условиям. Преимущество этого метода заключается в возможности нахождения всех возможных решений, и при раз- умной обрезке он обладает высокой эффективностью. +> +> Однако при решении крупных или сложных задач **эффективность работы алгоритма возврата может оказаться неприемлемой.** + +- **Время**: алгоритм поиска с возвратом обычно требует перебора всех воз- можных состояний пространства, и временная сложность может дости- гать экспоненциального или факториального порядка. + +- **Пространство**: в рекурсивных вызовах необходимо сохранять текущее состояние (например, путь, вспомогательные переменные для обрезки и т. д.), и при большой глубине потребность в пространстве может стать значительной. + +> Тем не менее **алгоритм поиска с возвратом по-прежнему является наи- лучшим решением для некоторых задач поиска и задач с ограничени- ями**. В этих задачах невозможно предсказать, какие выборы могут привести к эффективному решению, поэтому необходимо перебирать все возможные варианты. В таких случаях ключевым моментом является оптимизация эф- фективности, и существуют две распространенные стратегии. + +- **Обрезка**: избегание поиска по путям, которые заведомо не приведут к решению, что позволяет сэкономить время и пространство. + +- **Эвристический поиск**: введение некоторых стратегий или оценочных значений в процессе поиска, чтобы в первую очередь исследовать пути, которые с наибольшей вероятностью могут привести к эффективному решению. + +### Типичные задачи поиска с возвратом + +> Алгоритм поиска с возвратом можно использовать для решения множества за- дач поиска, задач с ограничениями и задач комбинаторной оптимизации. +> +> **Задачи поиска**: цель этих задач -- найти решение, удовлетворяющее опре- деленным условиям. + +- Задача о перестановках: дано множество, требуется найти все возмож- ные перестановки элементов. + +- Задача о сумме подмножеств: дано множество и целевая сумма, необхо- димо найти все подмножества, сумма которых равна целевой. + +- Задача о Ханойских башнях: даны три стержня и несколько дисков раз- ного размера, требуется переместить все диски с одного стержня на дру- гой, перемещая по одному диску за раз, при этом нельзя класть больший диск на меньший. + +> **Задачи с ограничениями**: цель этих задач -- найти решение, удовлетворя- ющее всем ограничениям. + +- Задача об *n* ферзях: разместить *n* ферзей на шахматной доске размером + +> *n*×*n* так, чтобы они не рубили друг друга. + +- Судоку: заполнить числами от 1 до 9 сетку 9×9 так, чтобы в каждой стро- ке, каждом столбце и каждой подгруппе 3×3 числа не повторялись. + +- Задача о раскраске графа: дан неориентированный граф, требуется рас- красить его вершины минимальным числом цветов так, чтобы соседние вершины имели разные цвета. + +> **Задачи комбинаторной оптимизации**: цель этих задач -- найти опти- мальное решение в комбинаторном пространстве, удовлетворяющее опреде- ленным условиям. + +- Задача о рюкзаке 0-1: дано множество предметов и рюкзак. Каждый предмет имеет определенную ценность и вес, требуется выбрать пред- меты так, чтобы их общая ценность была максимальной при ограничен- ной вместимости рюкзака. + +- Задача коммивояжера: начиная с некоторой вершины графа, требуется посетить все остальные вершины ровно один раз и вернуться в началь- ную. Найдя при этом кратчайший путь. + + - Задача о максимальной клике: дан неориентированный граф, требуется найти максимальный полный подграф, т. е. подграф, в котором любые две вершины соединены ребром. + +> Следует отметить, что для многих задач комбинаторной оптимизации алго- ритм поиска с возвратом не является оптимальным решением. + +- Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования для достижения более высокой временной эффек- тивности. + +- Задача коммивояжера является известной NP-трудной задачей, для ее решения часто используются генетические и муравьиные алгоритмы. + +- Задача о максимальной клике является классической задачей теории графов и может быть решена с помощью жадных алгоритмов или других эвристических методов. + +#### задача о перестановках + +> Задача о перестановках является типичным примером применения алгорит- ма поиска с возвратом. Она определяется как задача нахождения всех воз- можных перестановок элементов в заданном множестве (например, массиве или строке). +> +> В табл. 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\] | ++----------------------+--------------------------------------------------------------------------------+ + +### Случай без равных элементов + +> С точки зрения алгоритма поиска с возвратом **процесс генерации пере- становок можно представить как результат серии выборов**. Предполо- жим, что входной массив равен \[1, 2, 3\]. Если сначала выбрать 1, затем 3, а в +> +> конце 2, то получится перестановка \[1, 3, 2\]. Возврат означает отмену выбора и продолжение попыток других вариантов. +> +> С точки зрения кода реализации поиска с возвратом множество кандидатов +> +> choices -- это все элементы входного массива, а состояние state -- это элементы, +> +> выбранные до текущего момента. Следует отметить, что каждый элемент мо- жет быть выбран только один раз, **поэтому все элементы в** state **должны быть уникальными**. +> +> Процесс поиска можно развернуть в виде рекурсивного дерева, в котором каждый узел представляет текущее состояние state, как показано на рис. 13.5. Начиная с корневого узла, после трех раундов выбора достигается листовой узел, а каждый листовой узел соответствует одной перестановке. + +![](ru/docs/assets/media/image810.jpeg) + +> **Рис. 13.5.** Рекурсивное дерево полных перестановок + +##### Обрезка повторного выбора + +> Чтобы обеспечить выбор каждого элемента только один раз, вводится булев массив 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*!). +> +> ![](ru/docs/assets/media/image812.jpeg) +> +> **Рис. 13.6.** Пример обрезки в задаче полных перестановок + +##### Код реализации + +> На основе вышеизложенное можно заполнить пробелы в каркасе кода. Чтобы сократить код, функции каркаса кода не реализуются отдельно, а объединены в функцию 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() +> +> def permutations_i(nums: list\[int\]) -\> list\[list\[int\]\]: \"\"\" Полные перестановки.\"\"\" +> +> res = \[\] +> +> backtrack(state=\[\], choices=nums, selected=\[False\] \* len(nums), res=res) return res + +### Учет равных элементов + +> Предположим, что входной массив равен \[1, 1, 2\]. Для удобства различения двух повторяющихся элементов 1 второй 1 обозначим как 1\^. +> +> Как видно на рис. 13.7, половина перестановок, сгенерированных вышеука- занным методом, являются одинаковыми. + +![](ru/docs/assets/media/image814.jpeg) + +> **Рис. 13.7.** Повторяющиеся перестановки +> +> Как же избавиться от повторяющихся перестановок? Самый прямой спо- соб -- использовать хеш-набор для удаления дубликатов из результата пере- становок. Однако это не самый изящный подход, **так как ветви поиска, гене- рирующие повторяющиеся перестановки**, **излишни**, **и их нужно заранее распознавать и обрезать** -- это повысит эффективность алгоритма. + +##### Обрезка равных элементов + +> В первом раунде выбор 1 или 1\^ эквивалентен, так как все перестановки, сге- нерированные под этими двумя выборами, повторяются, как показано на рис. 13.8. Поэтому элемент 1\^ нужно обрезать. +> +> Аналогично после выбора 2 в первом раунде выбор 1 и 1\^ во втором раун- де также создадут повторяющиеся ветви, поэтому 1\^ во втором раунде также нужно обрезать. + +###### По сути, наша цель -- убедиться, что в каждом раунде выбора несколько равных элементов будут выбраны только один раз. + +![](ru/docs/assets/media/image816.jpeg) + +> **Рис. 13.8.** Обрезка повторяющихся перестановок + +##### Код реализации + +> Возьмем за основу код из предыдущей задачи. В каждом раунде выбора вве- дем хеш-набор 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) \# Запись выбранного значения элемента. +> +> 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*(*n*2) пространства. Таким образом, **пространственная сложность составляет** *O*(*n*2). + +##### Сравнение двух видов обрезки + +> Обратите внимание, что, хотя и selected, и duplicated используются для обрез- ки, их цели различны. + +- **Обрезка повторного выбора**: в процессе всего поиска существует только один массив selected. В нем фиксируется элементы, включен- ные в текущее состояние, а его цель -- избежать повторного появления элемента в state. + +- **Обрезка равных элементов**: каждый раунд выбора (каждый вызов функции backtrack) включает один хеш-набор duplicated. Он фиксирует, какие элементы были выбраны в текущем обходе (цикл for), а его цель -- гарантировать, что равные элементы выбираются только один раз. + +> На рис. 13.9 демонстрируется область действия двух условий обрезки. Об- ратите внимание, что каждый узел в дереве представляет собой выбор, а узлы на пути от корня до листа составляют одну перестановку. +> +> ![](ru/docs/assets/media/image818.jpeg) +> +> **Рис. 13.9.** Область действия двух условий обрезки + +1. **задача о сумме подмножеств** + +### Случай без повторяющихся элементов + +> Например, для входного множества {3, 4, 5} и целевого числа 9 решениями +> +> будут {3, 3, 3}, {4, 5}. Следует обратить внимание на следующие два момента: + +- элементы входного множества можно выбирать неограниченное коли- чество раз; + +- порядок элементов в подмножестве не имеет значения, например {4, 5} и {5, 4} -- одно и то же подмножество. + +##### Сравнение с решением задачи о полных перестановках + +> Подобно задаче о полных перестановках, процесс генерации подмножеств можно представить как серию выборов, а в процессе выбора в реальном вре- мени обновлять сумму элементов. Когда сумма элементов равна target, под- множество записывается в список результатов. +> +> Однако, в отличие от задачи о полных перестановках, **в данной задаче элементы множества можно выбирать неограниченное количество раз**, +> +> поэтому нет необходимости использовать булев список 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 -- это разные ветви, но они соответствуют одному и тому же подмножеству. +> +> ![](ru/docs/assets/media/image820.jpeg) +> +> **Рис. 13.10.** Поиск подмножеств и обрезка по превышению целевого значения +> +> **Одним из очевидных подходов к устранению повторяющихся подмно- жеств является удаление дубликатов из списка результатов**. Однако этот метод очень неэффективен по двум причинам. + +- Когда в массиве много элементов, особенно когда значение target ве- лико, процесс поиска генерирует множество повторяющихся подмно- жеств. + +- Сравнение подмножеств (массивов) на различия очень затратная по вре- мени операция. Она требует сначала сортировки массивов, затем срав- нения различий каждого элемента в массиве. + +##### Обрезка повторяющихся подмножеств + +> Рассмотрим устранение дубликатов в процессе поиска с помощью обрез- ки. На рис. 13.11 показано, что повторяющиеся подмножества возникают при выборе элементов массива в разном порядке, например в следующих случаях: + +1) пусть на первом и втором этапах выбираются 3 и 4 соответственно, соз- даются все подмножества, содержащие эти два элемента, обозначенные как \[3, 4, ...\]; + +2) затем если на первом этапе выбирается 4, **то на втором этапе следует пропустить** 3, так как подмножество \[4, 3, ...\] полностью повторяет под- множество, созданное на этапе 1. + +> В процессе поиска выбор на каждом уровне осуществляется слева направо. +> +> Поэтому чем правее ветвь, тем больше она обрезается. + +1. На первых двух этапах выбираются 3 и 5 и создаются подмножества \[3, 5, ...\]. + +2. На первых двух этапах выбираются 4 и 5 и создаются подмножества \[4, 5, ...\]. + +3. Если на первом этапе выбирается 5, **то на втором этапе следует про- пустить** 3 **и** 4, так как подмножества \[5, 3, ...\] и \[5, 4, ...\] полностью повто- ряют подмножества, описанные на этапах 1 и 2. + +![](ru/docs/assets/media/image822.jpeg) + +> **Рис. 13.11.** Повторяющиеся подмножества, полученные в результате различного порядка выбора +> +> Обобщим эту мысль. Пусть задан входной массив \[*x*1, *x*2, ..., *xn*\]. Тогда в про- цессе поиска последовательность выбора \[*xi*1, *xi*2, ..., *xim*\] должна удовлетво- рять условию *i*1 ≤ *i*2 ≤ ⋯ ≤ *im*, **в противном случае она приведет к дубликатам, и ее нужно обрезать**. + +##### Код реализации + +> Для реализации этой обрезки мы инициализируем переменную start, которая указывает начальную точку обхода. **После выбора** *xi* **следующая итерация начинается с индекса** *i*. Это позволяет для последовательности выбора со- блюдать условие *i*1 ≤ *i*2 ≤ ⋯ ≤ *im*, обеспечивая уникальность подмножеств. +> +> Кроме того, в код были внесены следующие две оптимизации. + +- Перед началом поиска массив nums сортируется. При обходе всех вариан- тов, если сумма подмножества превышает target, цикл завершается, так как последующие элементы больше, и их сумма также превысит target. + +- Исключение переменной total, подсчет суммы элементов осуществля- ется с помощью вычитания из target. Решение фиксируется, когда target равен 0. + +> \# === 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. +> +> ![](ru/docs/assets/media/image824.jpeg) +> +> **Рис. 13.12.** Процесс поиска с возвратом для реализации задачи о сумме подмножеств I + +### Случай с повторяющимися элементами + +> В отличие от предыдущей задачи **входной массив может содержать по- вторяющиеся элементы**, что создает новую проблему. Например, для мас- сива \[4, 4, 5\] и целевого элемента 9, текущий код выдает результат \[4, 5\], \[4, 5\], что приводит к повторяющимся подмножествам. +> +> **Причина этих повторов в том**, **что равные элементы выбираются не- сколько раз на одном этапе**. На рис. 13.13 показано, что на первом этапе есть три варианта выбора, два из которых равны 4. Это приводит к двум повторя- ющимся ветвям поиска и, следовательно, к повторяющимся подмножествам. Аналогично два элемента 4 на втором этапе также создают повторяющиеся подмножества. +> +> ![](ru/docs/assets/media/image826.jpeg) +> +> **Рис. 13.13.** Повторяющиеся подмножества из-за равных элементов + +##### Обрезка равных элементов + +> Для решения этой проблемы **необходимо сделать выбор равных элементов на каждом этапе однократным**. Реализация этого подхода довольно изящ- на: поскольку массив отсортирован, равные элементы находятся рядом друг с другом. Это означает, что если текущий элемент равен предыдущему, то он уже был выбран, и его следует пропустить. +> +> В то же время **в этой задаче предусмотрено**, **что каждый элемент мас- сива может быть выбран только один раз**. К счастью, можно использовать переменную start для выполнения этого ограничения: после выбора *xi* начи- наем следующий цикл с индекса *i* + 1. Это позволяет исключить повторяющие- ся подмножества и избежать повторного выбора элементов. + +##### Код реализации + +> \# === 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, чтобы избежать повторного выбора \# одного и того же элемента. +> +> 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, включающий четыре вида обрезки. Проана- лизируйте рисунок и комментарии в коде, чтобы лучше понять весь процесс поиска и как работают различные операции обрезки. +> +> ![](ru/docs/assets/media/image828.jpeg)*1-й раунд выбора* +> +> *2-й раунд выбора* +> +> **Обрезка 4** +> +> *В одном раунде равные элементы можно выбирать только один раз* +> +> **Обрезка 2** +> +> *Не допускать создания повторяющихся подмножеств* +> +> **Обрезка 1** +> +> *Сумма элементов* +> +> *не может превышать* +> +> **target** +> +> **Рис. 13.14.** Процесс поиска с возвратом для реализации задачи о сумме подмножеств II + +1. **задача об n ферзях** + +> Для *n* = 4 можно найти два решения, которые изображены на рис. 13.15. С точки зрения алгоритма поиска с возвратом шахматная доска размером *n*×*n* имеет *n*2 клеток, которые предоставляют собой все варианты выбора. В про- цессе размещения ферзей состояние доски постоянно меняется, и в каждый момент времени доска имеет состояние state. + +![](ru/docs/assets/media/image829.jpeg){width="3.312998687664042in" height="1.2706244531933508in"} + +> **Рис. 13.15.** Решения задачи о 4 ферзях +> +> На рис. 13.16 изображено три условия ограничения для данной задачи: несколько ферзей не могут находиться на одной строке, в одном столбце или на одной диагонали. Стоит отметить, что диагонали делятся на главную диа- гональ \\ и побочную диагональ /. + +![](ru/docs/assets/media/image831.jpeg) + +> **Рис. 13.16.** Ограничения задачи об n ферзях + +##### Стратегия построчного размещения + +> Количество ферзей и количество строк на доске равно *n*, поэтому можно сделать вывод: **на каждой строке доски может быть размещен только один ферзь**. Из этого следует, что мы можем использовать стратегию построчного раз- мещения: размещать по одному ферзю на каждой строке, начиная с первой +> +> и заканчивая последней. +> +> На рис. 13.17 изображен процесс построчного размещения для задачи о 4 ферзях. Из-за ограничений на размер изображения, на рис. 13.17 развернута только одна ветвь поиска первой строки, а все решения, не удовлетворяющие ограничениям по столбцам и диагоналям, обрезаны. + +![](ru/docs/assets/media/image833.jpeg) + +> **Рис. 13.17.** Стратегия построчного размещения +> +> По сути**, стратегия построчного размещения выполняет функцию об- резки**, отсекая все ветви поиска, в которых на одной строке может находиться более одного ферзя. + +##### Обрезка по столбцам и диагоналям + +> Чтобы выполнить ограничениям по столбцам, можно использовать булев мас- сив cols длиной *n*, в котором будет фиксироваться наличие ферзя в каждом столбце. На его основе перед каждым решением о размещении будут обре- заться столбцы, в которых уже есть ферзь. Состояние cols будет динамически обновляться в процессе возврата. +> +> Как теперь отследить ограничения по диагоналям? Пусть индексы строки и столбца какой-либо клетки на доске равны (row, col). Выбрав определенную главную диагональ в матрице, можно заметить, что разность индексов строки и столбца всех клеток на этой диагонали одинакова, т. е. **для всех клеток на главной диагонали значение** row − col **является постоянной величиной**. +> +> Это означает, что если для двух клеток выполняется условие row1 -- col2 = row2 -- col2, то они находятся на одной главной диагонали. Пользуясь этим пра- вилом, можно с помощью массива diags1 фиксировать наличие ферзя на каж- дой главной диагонали, как показано на рис. 13.18. +> +> Аналогично **сумма** row + col **для всех клеток на побочной диагонали яв- ляется постоянной величиной**. Мы можем использовать еще один массив diags2 для обработки ограничений на побочной диагонали. + +![](ru/docs/assets/media/image835.jpeg) + +> **Рис. 13.18.** Обработка ограничений по столбцам и диагоналям + +##### Код реализации + +> Следует отметить, что в *n*-мерной матрице диапазон row -- col составляет \[−*n* + 1, *n* − 1\], а диапазон row + col составляет \[0, 2*n* − 2\], поэтому количество главных и побочных диагоналей равно 2*n* − 1, т. е. длина массивов diags1 и diags2 также равна 2*n* − 1. +> +> \# === File: n_queens.py === def backtrack( +> +> row: int, n: int, +> +> state: list\[list\[str\]\], res: list\[list\[list\[str\]\]\], cols: list\[bool\], +> +> 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*(*n*2). Таким образом, **общая временная сложность составляет** *O*(*n*! ⋅ *n*2). На прак- тике обрезка по ограничениям диагоналей значительно сокращает простран- ство поиска, поэтому эффективность поиска часто превосходит указанную временную сложность. +> +> Массив state использует *O*(*n*2) пространства, массивы cols, diags1 и diags2 ис- пользуют *O*(*n*) пространства. Максимальная глубина рекурсии составляет *n*, что требует *O*(*n*) пространства стека. Следовательно, **пространственная слож- ность равна** *O*(*n*2). + +1. Резюме ❖ **395** + +> **13.5. резюме** + +##### Ключевые моменты + +- Алгоритм поиска с возвратом по сути является методом полного пере- бора, который ищет подходящие решения путем обхода в глубину про- странства решений. В процессе поиска фиксируются удовлетворяющие условиям решения до тех пор, пока не будут найдены все решения или обход не будет завершен. + +- Поиск с возвратом включает в себя попытки и возвраты. Он использует по- иск в глубину и выполняет попытки для различных вариантов. При несо- ответствии заданным условиям отменяет предыдущий выбор, возвраща- ется к предыдущему состоянию и продолжает проверять другие варианты. Попытки и возвраты -- это операции в противоположных направлениях. + +- Задачи поиска с возвратом обычно содержат несколько ограничений, кото- рые можно использовать для обрезки. Обрезка позволяет заранее завершить ненужные ветви поиска, что значительно повышает эффективность поиска. + +- Алгоритм поиска с возвратом в основном применяется для решения по- исковых задач и задач с ограничениями. Задачи комбинаторной опти- мизации можно решать с помощью поиска с возвратом, но часто суще- ствуют более эффективные или более подходящие методы. + +- Задача о перестановках направлена на поиск всех возможных переста- новок элементов заданного множества. В решении используется массив для учета выбранных элементов и обрезки ветвей поиска с повторным выбором одного и того же элемента. Это позволяет обеспечить выбор каждого элемента только один раз. + +- В задаче о перестановках с повторяющимися элементами нужно отсе- кать повторяющиеся перестановки в конечном результате. Необходимо обеспечить однократный выбор равных элементов в каждом раунде, что обычно реализуется с помощью хеш-множества. + +- Цель задачи о сумме подмножеств -- найти все подмножества с суммой, равной целевому значению, в заданном множестве. Порядок элементов в множестве не важен, но процесс поиска выводит результаты во всех возможных порядках, создавая повторяющиеся подмножества. Перед выполнением поиска с возвратом данные сортируются, а также уста- навливается переменная для указания начальной точки каждого раунда, чтобы обрезать ветви поиска с повторяющимися подмножествами. + +- В задаче о сумме подмножеств равные элементы в массиве создают по- вторяющиеся множества. При наличии предварительно отсортирован- ного массива обрезка осуществляется путем проверки равенства сосед- них элементов, что гарантирует выбор равных элементов только один раз в каждом раунде. + +- Задача об *n* ферзях заключается в нахождении способа размещения *n* ферзей на шахматной доске размером *n*×*n* так, чтобы никакие два фер- зя не рубили друг друга. Ограничения задачи включают ограничения по строкам, столбцам, главным и побочным диагоналям. Для соблюдения ограничения по строкам используется стратегия размещения по стро- кам, что гарантирует размещение одного ферзя в каждой строке. + + + +- Обработка ограничений по столбцам и диагоналям осуществляется ана- логично. Для ограничения по столбцам используется массив, который фиксирует наличие ферзя в каждом столбце. Для ограничения по диаго- налям используются два массива, которые фиксируют наличие ферзя на главной и побочной диагоналях соответственно. Сложность заключается в нахождении закономерности индексов строк и столбцов для клеток, находящихся на одной и той же главной (или побочной) диагонали. + +##### Вопросы и ответы + +> **Вопрос**. Какова связь между возвратом и рекурсией? +> +> **Ответ**. В общем, возврат -- это стратегия алгоритма, тогда как рекурсия ско- рее является инструментом. + +- Алгоритмы поиска с возвратом обычно реализуются на основе рекурсии. Однако поиск с возвратом -- это один из вариантов применения рекур- сии, а именно применение рекурсии в задачах поиска. + +- Структура рекурсии отражает парадигму разбиения на подзадачи и ча- сто используется для решения задач, связанных со стратегией «разделяй и властвуй», поиском с возвратом, динамическим программированием (мемоизация рекурсии) и др. + +> Глава 14 diff --git a/ru/chapters/chapter_14.md b/ru/chapters/chapter_14.md new file mode 100644 index 000000000..0ee3ab8c9 --- /dev/null +++ b/ru/chapters/chapter_14.md @@ -0,0 +1,1415 @@ +# Динамическое программирование + +![](ru/docs/assets/media/image836.jpeg){width="3.2760411198600177in" height="4.239583333333333in"} + +1. **введение в динамичеСкОе прОграммирОвание** *Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Со- + +> хранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность. +> +> В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрыва- ющихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования. +> +> Как показано на рис. 14.1, для лестницы с тремя ступенями существует три способа добраться до вершины. +> +> ![](ru/docs/assets/media/image838.jpeg)Количество ступеней **n** = + +Есть 3 способа подняться на **3-ю** ступень: + +> **Рис. 14.1.** Количество способов добраться до 3-й ступени +> +> Цель этой задачи -- найти количество способов, **и можно попробовать использовать для ее решения метод поиска с возвратом**. Более конкрет- но -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увели- чивается на 1, а при превышении вершины происходит обрезка. Ниже при- веден код реализации. +> +> \# === 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\] + +### Первый метод: полный перебор + +> Алгоритм поиска с возвратом обычно не разбивает задачу явным образом, а рассматривает ее решение как серию шагов принятия решений, исследуя пути обхода и выполняя обрезку. +> +> Можно попытаться проанализировать эту задачу с точки зрения разбиения. Пусть для достижения *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 демонстрируется эта рекуррент- ная зависимость. +> +> ![](ru/docs/assets/media/image840.jpeg) +> +> **Рис. 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*(2*n*). Экспоненциальный рост приводит к взрывному увели- чению, и при вводе достаточно большого *n* можно столкнуться с длительной работой алгоритма. +> +> ![](ru/docs/assets/media/image842.jpeg) +> +> **Рис. 14.3.** Рекурсивное дерево для подъема по лестнице +> +> Как видно из рис. 14.3, **экспоненциальная временная сложность вызва- на перекрывающимися подзадачами**. Например, *dp*\[9\] разбивается на *dp*\[8\] и *dp*\[7\], *dp*\[8\] разбивается на *dp*\[7\] и *dp*\[6\] -- обе задачи содержат подзадачу *dp*\[7\]. Таким образом, в подзадачах содержатся более мелкие перекрывающиеся подзадачи, и большая часть вычислительных ресурсов тратится на их обработку. + +### Второй метод: мемоизация поиска + +> Для повышения эффективности алгоритма **необходимо**, **чтобы все пере- крывающиеся подзадачи вычислялись только один раз**. Для этого мы объ- явим массив 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\] +> +> \# 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. + +![](ru/docs/assets/media/image845.jpeg) + +> **Рис. 14.4.** Мемоизация поиска и соответствующее дерево рекурсии + +### Третий метод: динамическое программирование + +> **Мемоизация поиска -- это метод «сверху вниз»**: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным реше- нием (листовые узлы). Затем через возврат поэтапно собираем решения под- задач, чтобы построить решение исходной задачи. +> +> В отличие от этого подхода **динамическое программирование представ- ляет собой метод «снизу вверх»**: начиная с решения минимальных подза- дач, итеративно строится решение более крупных подзадач, пока не будет по- лучено решение исходной задачи. +> +> Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости +> +> в рекурсии. В следующем коде мы инициализируем массив 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 иллюстрируется процесс выполнения приведенного выше кода. + +![](ru/docs/assets/media/image848.jpeg) + +> **Рис. 14.5.** Применение динамического программирования для подъема по лестнице +> +> Как и в алгоритмах поиска с возвратом, в динамическом программировании используется концепция состояния для обозначения определенной стадии ре- шения задачи. Каждое состояние соответствует подзадаче и соответствующе- му локальному оптимальному решению. Например, состояние задачи подъема по лестнице определяется текущей ступенью *i*. +> +> На основе этого можно обобщить часто используемые термины динамиче- ского программирования. + +- Массив dp называется таблицей dp, *dp*\[*i*\] обозначает решение подзадачи, соответствующей состоянию *i*. + + + +- Состояния, соответствующие минимальным подзадачам (1-я и 2-я сту- пени лестницы), называются начальными состояниями. + +- Рекуррентное соотношение *dp*\[*i*\] = *dp*\[*i* − 1\] + *dp*\[*i* − 2\] называется уравне- нием перехода состояния. + +### Оптимизация пространства + +> Внимательный читатель может заметить, что, **поскольку** *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*). +> +> В задачах динамического программирования текущее состояние часто за- висит только от ограниченного числа предыдущих состояний. В этом случае можно сохранить только необходимые состояния, чтобы сэкономить память. **Эта техника оптимизации пространства называется скользящие пере- менные или скользящий массив**. + +1. **Особенности задач динамического программирования** + +В предыдущем разделе мы изучили, как динамическое программирование ре- шает исходную задачу путем разложения на подзадачи. На самом деле раз- + +> ложение на подзадачи -- это универсальный алгоритмический подход, кото- рый по-разному применяется в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом. + +- Алгоритм «разделяй и властвуй» рекурсивно делит исходную задачу на несколько независимых подзадач до самых минимальных и в процессе обратного хода объединяет решения всех подзадач. + +- Динамическое программирование также осуществляет рекурсивное раз- биение задачи. Основное отличие от алгоритмов «разделяй и властвуй» заключается в том, что подзадачи в динамическом программировании взаимозависимы, и в процессе разбиения возникает множество пере- крывающихся подзадач. + + - Алгоритмы поиска с возвратом исчерпывают все возможные решения методом проб и возвратов, осекая ненужные ветви поиска с помощью обрезки. Решение исходной задачи состоит из серии шагов принятия ре- шений, каждый шаг можно рассматривать как подзадачу. + +> На практике динамическое программирование часто используется для ре- шения задач оптимизации, которые не только содержат перекрывающиеся подзадачи, но и обладают двумя другими важными свойствами: оптимальной подструктурой и отсутствием последствий. + +### Оптимальная подструктура + +> Чтобы лучше продемонстрировать концепцию оптимальной подструктуры, рассмотрим задачу о подъеме по лестнице с небольшими изменениями. +> +> Если стоимость на 1-й, 2-й и 3-й ступенях составляет 1, 10 и 1 соответствен- но, то минимальная стоимость подъема с пола на 3-ю ступень равна 2, как по- казано на рис. 14.6. + +![](ru/docs/assets/media/image850.jpeg) + +> **Рис. 14.6.** Минимальная стоимость подъема на 3-ю ступень +> +> Пусть *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 демонстрируется процесс динамического программирования в данном коде. +> +> ![](ru/docs/assets/media/image852.jpeg) +> +> **Рис. 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 + +### Отсутствие последствий + +> *Отсутствие последствий* -- одно из важных свойств, позволяющих динамиче- скому программированию эффективно решать задачи. Оно определяется сле- дующим образом: **при заданном определенном состоянии его дальней- шее развитие зависит только от текущего состояния и не зависит от всех предыдущих состояний**. +> +> Возьмем, к примеру, задачу о подъеме по лестнице. При заданном состоя- нии *i* оно может развиться в состояния *i* + 1 или *i* + 2, что соответствует подъ- ему на одну или две ступени. При выборе одного из этих вариантов нет не- обходимости учитывать состояния, предшествующие *i*, так как они не влияют на будущее состояние. +> +> Однако, если добавить к задаче о подъеме по лестнице ограничения, ситуа- ция изменится. +> +> Как показано на рис. 14.8, для достижения 3-й ступени остается только два возможных варианта. Вариант с тремя последовательными подъемами по од- ной ступени не удовлетворяет условиям и поэтому отбрасывается. + +![](ru/docs/assets/media/image854.jpeg) + +> **Рис. 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\]. + + - Если на предыдущем шаге был подъем на две ступени, то на шаг до этого можно было выбрать подъем на одну или две ступени, т. е. *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λϑ. + +![](ru/docs/assets/media/image856.jpeg) + +> **Рис. 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\] +> +> В приведенном выше примере необходимо учитывать только одно преды- дущее состояние, поэтому можно расширить определение состояния, и задача все равно будет удовлетворять условию отсутствия последствий. Однако неко- торые задачи обладают серьезными условиями последствий. +> +> В этой задаче следующий шаг зависит от всех предыдущих состояний, так как каждый предыдущий шаг устанавливает препятствие на более высокой ступени. Для таких задач динамическое программирование часто оказывается неэффективным. +> +> На самом деле многие сложные задачи комбинаторной оптимизации (на- пример, задача коммивояжера) не удовлетворяют условию отсутствия по- следствий. Для решения таких задач обычно выбираются другие методы, та- кие как эвристический поиск, генетические алгоритмы, обучение с подкре- плением и т. д., чтобы получить приемлемое локальное оптимальное решение за ограниченное время. + +#### подход к решению задач динамического программирования + +> В предыдущих разделах были рассмотрены основные характеристики задач динамического программирования, теперь исследуем два более практичных вопроса. + +1. Как определить, является ли задача задачей динамического программи- рования? + +2. С чего начать решение задачи динамического программирования, како- ва полная схема решения? + +### Определение задачи + +> В общем случае, если задача содержит перекрывающиеся подзадачи, опти- мальную подструктуру и удовлетворяет условию отсутствия последствий, она обычно подходит для решения методом динамического программи- рования. Однако трудно извлечь эти характеристики непосредственно из описания задачи. Поэтому обычно условия смягчаются, и **сначала прове- ряется, подходит ли задача для решения методом поиска с возвратом (перебора)**. +> +> **Задачи, подходящие для решения методом поиска с возвратом, обыч- но соответствуют модели дерева решений**. Такие задачи можно описать с помощью древовидной структуры, в которой каждый узел представляет со- бой решение, а каждый путь -- последовательность решений. +> +> Иными словами, если задача включает в себя явную концепцию принятия решений и решение получается в результате серии решений, то она соответ- ствует модели дерева решений. Обычно такую задачу можно решить с помо- щью метода обратного поиска. +> +> Задачи динамического программирования, помимо вышеуказанных, долж- ны иметь некоторые дополнительные характеристики. + +- Задача содержит описание оптимизации, например максимизацию или минимизацию. + +- Состояние задачи можно представить с помощью списка, многомерной матрицы или дерева, и существует рекурсивная связь между состоянием и его окружением. + +> Соответственно, существуют маркеры, которые говорят о неприменимости стратегии динамического программирования. + +- Цель задачи -- найти все возможные решения, а не оптимальное решение. + +- Описание задачи имеет явные признаки комбинаторики, и требуется вернуть несколько конкретных решений. + +> Если задача соответствует модели дерева решений и обладает достаточно явными дополнительным характеристиками, можно предположить, что это задача динамического программирования, и подтвердить это в процессе решения. + +### Этапы решения задачи + +> Процесс решения задач динамического программирования может разли- чаться в зависимости от природы и сложности задачи, но обычно следует следующей схеме: описание решений, определение состояния, построение таблицы *dp*, вывод уравнения перехода состояния, определение граничных условий и т. д. +> +> Для более наглядного представления этапов решения рассмотрим в каче- стве примера классическую задачу «минимальная стоимость пути». +> +> На рис. 14.10 показан пример, в котором минимальная сумма пути для дан- ного массива равна 13. +> +> ![](ru/docs/assets/media/image858.jpeg) +> +> **Рис. 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. +> +> **Решение:** пройти на одну клетку вправо или вниз +> +> **Определение состояния:** индексы строки и столбца **\[i, j\]** +> +> **Таблица dp** +> +> ![](ru/docs/assets/media/image860.jpeg)**Подзадача:** минимальная сумма пути из левого верхнего угла до **\[i, j\]** +> +> **Таблица dp:** матрица того же размера, что и grid +> +> **Рис. 14.11.** Определение состояния и таблица 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*λϑ . + +![](ru/docs/assets/media/image862.jpeg) + +> **Рис. 14.12.** Оптимальная подструктура и уравнение перехода состояния + +##### Шаг 3: определение граничных условий и порядка перехода состояния + +> В этой задаче состояния в первой строке можно получить только из левых со- стояний, а состояния в первом столбце -- только из верхних состояний, поэто- му первая строка *i* = 0 и первый столбец *j* = 0 являются граничными условиями. Поскольку каждую ячейку можно получить только из ячейки слева или сверху, мы используем цикл для обхода матрицы: внешний цикл проходит по +> +> строкам, а внутренний -- по столбцам, как показано на рис. 14.13. +> +> ![](ru/docs/assets/media/image864.jpeg)**Граничные условия:** инициализировать первые строку и столбец +> +> **Порядок перехода состояний:** +> +> прямой обход матрицы +> +> **Рис. 14.13.** Граничные условия и порядок перехода состояний +> +> На основе вышеизложенного анализа можно сразу написать код динами- ческого программирования. Однако разбиение подзадач -- это подход сверху вниз, поэтому реализация в порядке полный перебор → мемоизация → дина- мическое программирование более соответствует привычному мышлению. + +##### Первый метод: полный перебор + +> Поиск начинается с состояния \[*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 индекс выходит за допустимые пределы, в этом случае возвращается стоимость +∞, что означает недопустимость. + +> Ниже приведен код реализации. +> +> \# === 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. +> +> По сути, причиной перекрывающихся подзадач является **наличие несколь- ких путей**, **ведущих из верхнего левого угла к одной ячейке**. + +![](ru/docs/assets/media/image866.jpeg) + +> **Рис. 14.14.** Дерево рекурсии полного перебора +> +> Каждое состояние имеет два варианта выбора: вниз и вправо. Чтобы пройти из верхнего левого угла в нижний правый, требуется *m* + *n* -- 2 шагов, поэтому в худшем случае временная сложность составляет *O*(2*m*+*n*). Обратите внимание, что этот расчет не учитывает случаи, когда путь достигает границы сетки, где остается только один вариант выбора, поэтому фактическое количество путей будет меньше. + +##### Второй метод: мемоизация + +> Вводится список 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*). + +![](ru/docs/assets/media/image868.jpeg) + +> **Рис. 14.15.** Дерево рекурсии мемоизации + +##### Третий метод: динамическое программирование + +> Ниже представлена реализация решения с использованием итеративного под- хода динамического программирования. +> +> \# === 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*). + +![](ru/docs/assets/media/image870.jpeg) + +> **Рис. 14.16.** Динамическое программирование для минимальной стоимости пути. Шаг 1 +> +> ![](ru/docs/assets/media/image873.jpeg) + +![](ru/docs/assets/media/image876.jpeg)![](ru/docs/assets/media/image878.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image880.jpeg) + +![](ru/docs/assets/media/image882.jpeg)![](ru/docs/assets/media/image884.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image886.jpeg) + +![](ru/docs/assets/media/image888.jpeg)![](ru/docs/assets/media/image890.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image892.jpeg) + +![](ru/docs/assets/media/image894.jpeg) + +> **Рис. 14.16.** *Окончание*. Шаги 11--12 + +##### Оптимизация пространства + +> Поскольку каждая ячейка зависит только от ячеек слева и сверху, для реали- зации таблицы *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 +> +> \# Переход состояния: первая строка. 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\] + +#### задача о рюкзаке 0-1 + +> Задача о рюкзаке является отличным примером для начала изучения динами- ческого программирования и представляет собой одну из наиболее распростра- ненных форм этой задачи. Существует множество ее вариаций, таких как задача о рюкзаке 0-1, задача о полном рюкзаке, задача о многократном рюкзаке и др. +> +> В этом разделе мы сначала решим наиболее распространенную задачу о рюкзаке 0-1. +> +> Обратите внимание на рис. 14.17: поскольку нумерация предметов *i* начи- нается с 1, а индексация массива с 0, то предмету *i* соответствует масса *wgt*\[*i* -- 1\] и стоимость *val*\[*i* -- 1\]. + +![](ru/docs/assets/media/image897.jpeg) + +> **Рис. 14.17.** Пример данных для задачи о рюкзаке 0-1 +> +> Задачу о рюкзаке 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*\]. + + + +- **Условие завершения**: номер предмета выходит за пределы *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*(2*n*), как показано на рис. 14.18. +> +> ![](ru/docs/assets/media/image900.jpeg)При наблюдении за деревом рекурсии легко заметить наличие перекрыва- ющихся подзадач, таких как *dp*\[1, 10\]. А когда количество предметов и вмести- мость рюкзака велики, особенно если есть много предметов с одинаковым ве- сом, количество перекрывающихся подзадач значительно увеличивается. + ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ +| *Нет* | | | | > *Да* | | > *Положить предмет 3?* | ++===========+========+==============+=====+========+=====+========================================================+ +| | | | | | | > **Обрезка:** *Масса предмета \> Вместимость рюкзака* | ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ +| > *Нет* | > *Да* | *Не* | *т* | | | > *Да Положить предмет* **2***?* | ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ + +> **Рис. 14.18.** Дерево рекурсии полного перебора для задачи о рюкзаке 0-1 + +##### Второй метод: мемоизация + +> Чтобы вычислять перекрывающиеся подзадачи только один раз, используем список запоминания 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 изображены обрезанные ветви поиска в процессе мемоизации. + +![](ru/docs/assets/media/image902.jpeg) + +> **Рис. 14.19.** Рекурсивное дерево мемоизации для задачи о рюкзаке 0-1 + +##### Третий метод: динамическое программирование + +> Динамическое программирование представляет собой процесс заполнения таблицы *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. + +![](ru/docs/assets/media/image904.jpeg) + +> **Рис. 14.20.** Динамическое программирование для задачи о рюкзаке 0-1. Шаг 1 +> +> ![](ru/docs/assets/media/image906.jpeg) + +![](ru/docs/assets/media/image908.jpeg)![](ru/docs/assets/media/image910.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image912.jpeg) + +![](ru/docs/assets/media/image914.jpeg)![](ru/docs/assets/media/image916.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image918.jpeg) + +![](ru/docs/assets/media/image920.jpeg)![](ru/docs/assets/media/image922.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image924.jpeg) + +![](ru/docs/assets/media/image926.jpeg)![](ru/docs/assets/media/image928.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image930.jpeg) +> +> **Рис. 14.20.** *Окончание*. Шаг 14 + +##### Оптимизация пространства + +> Поскольку каждое состояние зависит только от состояния предыдущей строки, можно использовать два массива для продвижения и снизить пространствен- ную сложность с *O*(*n*2) до *O*(*n*). +> +> А можно ли реализовать оптимизацию пространства, используя только один массив? Заметим, что каждое состояние переходит из верхней или левой верх- ней ячейки. Если используется только один массив, то при начале обхода стро- ки *i* массив все еще хранит состояние строки *i* -- 1. + +- Если обход выполняется в прямом порядке, то при достижении *dp*\[*i*, *j*\] значения из левой верхней части *dp*\[*i* -- 1, 1\] \~ *dp*\[*i* -- 1, *j* -- 1\] могут быть уже перезаписаны, что делает невозможным получение правильного резуль- тата перехода состояния. + +- Если обход выполняется в обратном порядке, то проблема перезаписи не возникает, и переход состояния можно выполнить корректно. + +> На рис. 14.21 демонстрируется процесс перехода от строки *i* = 1 к строке *i* = 2 с использованием одного массива. Проанализируйте различия при прямом и обратном обходах. + +![](ru/docs/assets/media/image932.jpeg) + +> **Рис. 14.21.** Динамическое программирование с оптимизацией пространства для задачи о рюкзаке 0-1. Шаг 1 +> +> ![](ru/docs/assets/media/image934.jpeg) + +![](ru/docs/assets/media/image936.jpeg)![](ru/docs/assets/media/image938.jpeg) + +> **Рис. 14.21.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image940.jpeg) + ++---------------------------------------------------------------------------------------------------------------+ +| > Шаг 6 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > После завершения обхода в массиве **dp** содержатся все решения для i = 2 | ++=======================================================+===========================+===========================+ +| | | | ++-------------------------------------------------------+---------------------------+---------------------------+ + +> ![](ru/docs/assets/media/image942.jpeg)**Рис. 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\] +> +> else: +> +> \# Наибольшее значение из двух вариантов: не выбирать и выбирать \# предмет i. +> +> dp\[c\] = max(dp\[c\], dp\[c - wgt\[i - 1\]\] + val\[i - 1\]) return dp\[cap\] + +#### задача о полном рюкзаке + +> В этом разделе мы сначала решим еще одну распространенную задачу о рюк- заке -- задачу о полном рюкзаке. А затем рассмотрим ее частный случай -- за- дачу о размене монет. + +### Задача о полном рюкзаке + +![](ru/docs/assets/media/image946.png) + +> **Рис. 14.22.** Пример данных для задачи о полном рюкзаке + +##### Динамическое программирование + +> Задача о полном рюкзаке очень похожа на задачу о рюкзаке 0-1. **Различие лишь в том**, **что количество выборов предметов не ограничено**. + +- В задаче о рюкзаке 0-1 каждый предмет существует в единственном эк- земпляре, поэтому после помещения предмета *i* в рюкзак можно выби- рать только из первых *i* -- 1 предметов. + + - В задаче о полном рюкзаке количество предметов не ограничено, поэто- му **после помещения предмета** *i* **в рюкзак можно продолжать вы- бирать из первых** *i* **предметов**. + +> В условиях задачи о полном рюкзаке изменение состояния \[*i*, *c*\] делится на два случая. + +- **Не помещать предмет** *i*: аналогично задаче о рюкзаке 0-1, переход к \[*i* -- 1, *[c]{.underline}*\]. + +- **Помещать предмет** *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\]). + +##### Код реализации + +> По сравнению с кодом предыдущей задачи есть одно изменение в переходе состояния с *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\] + +##### Оптимизация пространства + +> Поскольку текущее состояние исходит из состояний слева и сверху, **после оп- тимизации пространства следует выполнять прямой обход каждой стро- ки в таблице** *dp*. +> +> Этот порядок обхода противоположен порядку в задаче о рюкзаке 0-1. Из- учите рис. 14.23 для понимания различий между ними. +> +> ![](ru/docs/assets/media/image950.jpeg) + +![](ru/docs/assets/media/image952.jpeg)![](ru/docs/assets/media/image954.jpeg) + +> **Рис. 14.23.** Динамическое программирование для задачи о полном рюкзаке после оптими- зации пространства. Шаги 1--3 + +![](ru/docs/assets/media/image956.jpeg)![](ru/docs/assets/media/image958.jpeg) + ++---------------------------------------------------------------------------------------------+ +| > Шаг 4 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > Прямой обход строки i = 2, выполнение перехода состояния | ++========================+===========================================+========================+ +| | | | ++------------------------+-------------------------------------------+------------------------+ + ++---------------------------------------------------------------------------------------------+ +| > Шаг 5 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > Прямой обход строки i = 2, выполнение перехода состояния | ++========================+===========================================+========================+ +| | | | ++------------------------+-------------------------------------------+------------------------+ + +![](ru/docs/assets/media/image960.jpeg) + +> **Рис. 14.23.** *Окончание*. Шаги 4--6 +> +> Код реализации достаточно прост, необходимо лишь удалить первую раз- мерность массива 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\] + +### Задача о размене монет + +> Задача о рюкзаке является представителем большого класса задач динамиче- ского программирования, имеющего множество вариаций, таких как задача о размене монет. + +![](ru/docs/assets/media/image962.jpeg) + +> **Рис. 14.24.** Пример данных для задачи о размене монет + +##### Динамическое программирование + +> **Задачу о размене монет можно рассматривать как частный случай зада- чи о полном рюкзаке** со следующими сходствами и различиями. + +- Обе задачи можно преобразовать друг в друга: предмет соответствует монете, масса предмета соответствует номиналу монеты, вместимость рюкзака соответствует целевой сумме. + +- Цели оптимизации противоположны: задача о полном рюкзаке стре- мится максимизировать стоимость предметов, задача о размене монет -- минимизировать количество монет. + +- Задача о полном рюкзаке ищет решение, не превышающее вместимость рюкзака, задача о размене монет -- решение, точно соответствующее це- левой сумме. + +####### Шаг 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*\] в первой строке равны +∞. + +##### Код реализации + +> В большинстве языков программирования нет представления для значения +∞, поэтому часто используется максимальное значение типа int. Однако это может привести к переполнению при выполнении операции +1 в уравнении перехода. Поэтому для обозначения недопустимого решения будем использовать чис- ло *amt* + 1, поскольку максимальное количество монет для составления *amt* равно *amt*. Перед возвратом проверяется, равно ли *dp*\[*n*, *amt*\] значению *amt* + 1. +> +> Если равно, возвращается --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 демонстрируется процесс динамического программирова- ния для задачи о размене монет, который очень похож на задачу о полном рюкзаке. + +![](ru/docs/assets/media/image964.jpeg) + +> **Рис. 14.25.** Динамическое программирование задачи о размене монет. Шаг 1 + +![](ru/docs/assets/media/image965.jpeg){width="3.3324004811898513in" height="1.8960411198600176in"} + +> ![](ru/docs/assets/media/image967.jpeg) + +![](ru/docs/assets/media/image969.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image971.jpeg) + +![](ru/docs/assets/media/image973.jpeg)![](ru/docs/assets/media/image975.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image977.jpeg) + +![](ru/docs/assets/media/image979.jpeg)![](ru/docs/assets/media/image981.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image983.jpeg) + +![](ru/docs/assets/media/image985.jpeg)![](ru/docs/assets/media/image987.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image989.jpeg) + +![](ru/docs/assets/media/image991.jpeg) + +> **Рис. 14.25.** *Окончание*. Шаги 14--15 + +##### Оптимизация пространства + +> Оптимизация пространства в задаче о размене монет осуществляется анало- гично задаче о полном рюкзаке. +> +> \# === 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): \# Прямой обход. +> +> 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 + +![](ru/docs/assets/media/image993.jpeg) + +> **Рис. 14.26.** Пример данных для задачи о размене монет II + +##### Динамическое программирование + +> В отличие от предыдущей задачи здесь целью является определение количе- ства комбинаций, поэтому подзадача формулируется следующим образом: **количество комбинаций, которыми можно составить сумму** *a*, **используя первые** *i* **видов монет**. Таблица *dp* по-прежнему представляет собой двумер- ную матрицу размером (*n* + 1) × (*amt* + 1). +> +> Количество комбинаций для текущего состояния равно сумме количества комбинаций без выбора текущей монеты и с выбором текущей монеты. Урав- нение перехода состояния имеет вид: +> +> *dp*\[*i*, *a*\] = *dp*\[*i* -- 1, *a*\] + *dp*\[*i*, *a* -- *coins*\[*i* -- 1\]\]. +> +> Если целевая сумма равна 0, то для достижения этой суммы не требуется вы- бирать монеты, поэтому все *dp*\[*i*, 0\] в первом столбце нужно инициализировать значением 1. Если монет нет, невозможно составить любую сумму больше 0, поэтому все *dp*\[0, *a*\] в первой строке равны 0. + +##### Код реализации + +> \# === 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\] + +##### Оптимизация пространства + +> Метод оптимизации пространства аналогичен предыдущей задаче, достаточ- но удалить измерение монет. +> +> \# === 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: +> +> \# Если превышает целевую сумму, монета i не выбирается. dp\[a\] = dp\[a\] +> +> else: +> +> \# Сумма двух вариантов: без выбора и с выбором монеты i. dp\[a\] = dp\[a\] + dp\[a - coins\[i - 1\]\] +> +> return dp\[amt\] + +#### задача расстояния редактирования + +> Расстояние редактирования, также известное как расстояние Левенштейна, -- это минимальное количество изменений, необходимых для преобразования од- ной строки в другую. Обычно используется для измерения сходства двух после- довательностей в информационном поиске и обработке естественного языка. +> +> Для преобразования kitten в sitting требуется три шага редактирования, включая две операции замены и одну операцию добавления, как показано на рис. 14.27. Для преобразования hello в algo требуется три шага, включая две операции замены и одну операцию удаления. + +![](ru/docs/assets/media/image995.jpeg) + +> **Рис. 14.27.** Пример данных для задачи расстояния редактирования +> +> **Задачу расстояния редактирования можно естественным образом объяснить с помощью модели дерева решений**. Строки соответствуют уз- лам дерева, а один шаг редактирования (одна операция редактирования) соот- ветствует ребру дерева. +> +> При отсутствии ограничений на операции каждый узел может порождать множество ребер, каждое из которых соответствует одной операции, как пока- зано на рис. 14.28. Это означает, что существует множество возможных путей для преобразования hello в algo. +> +> С точки зрения дерева решений цель задачи -- найти кратчайший путь меж- ду узлом hello и узлом algo. + +![](ru/docs/assets/media/image997.jpeg) + +> **Рис. 14.28.** Представление задачи расстояния редакти- рования на основе модели дерева решений + +##### Динамическое программирование + +####### Шаг 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). + +####### Шаг 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\]. + +![](ru/docs/assets/media/image999.jpeg) + +> **Рис. 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*. +> +> Анализируя уравнение перехода состояния, решение *dp*\[*i*, *j*\] зависит от ре- шения слева, сверху и слева сверху. Поэтому можно обойти всю таблицу *dp* в прямом порядке с помощью двух вложенных циклов. + +##### Код реализации + +> \# === 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, процесс перехода состояния для задачи расстояния редактирования очень похож на задачу о рюкзаке, и его можно рассматривать как заполнение двумерной сетки. + +![](ru/docs/assets/media/image1001.jpeg) + +> **Рис. 14.30.** Динамическое программирование для расстояния редактирования. Шаг 1 + +![](ru/docs/assets/media/image1002.jpeg){width="3.4433311461067366in" height="2.0345833333333334in"} + +> ![](ru/docs/assets/media/image1004.jpeg) + +![](ru/docs/assets/media/image1006.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 2--4 + +![](ru/docs/assets/media/image1007.jpeg){width="3.4471598862642168in" height="2.0345833333333334in"} + +![](ru/docs/assets/media/image1009.jpeg)![](ru/docs/assets/media/image1011.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image1013.jpeg) + +![](ru/docs/assets/media/image1015.jpeg)![](ru/docs/assets/media/image1017.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image1019.jpeg) + +![](ru/docs/assets/media/image1021.jpeg)![](ru/docs/assets/media/image1023.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image1025.jpeg) + +![](ru/docs/assets/media/image1027.jpeg) + +> **Рис. 14.30.** *Окончание*. Шаги 14--15 + +##### Оптимизация пространства + +> Поскольку *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: +> +> \"\"\" Расстояние редактирования: динамическое программирование с оптимизацией пространства.\"\"\" +> +> 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\] + +#### резюме + +- Динамическое программирование разбивает задачу на подзадачи, со- храняет их решения и избегает повторных вычислений, что повышает эффективность. + +- Все задачи динамического программирования можно решить с помо- щью перебора (поиска в глубину), но в дереве рекурсии много повторяю- щихся подзадач, что делает его крайне неэффективным. Использование мемоизации позволяет сохранить решения всех вычисленных подзадач, гарантируя, что каждая из них будет решена только один раз. + +- Мемоизация -- это рекурсивный подход сверху вниз, тогда как динами- ческое программирование -- это итеративный подход снизу вверх, по- хожий на заполнение таблицы. Поскольку текущее состояние зависит только от некоторых локальных состояний, можно устранить одно из- мерение таблицы *dp* и уменьшить пространственную сложность. + +- Разбиение задачи на подзадачи -- это общий алгоритмический подход, который имеет различную реализацию в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом. + +- Задачи динамического программирования обладают тремя основны- ми свойствами: повторяющиеся подзадачи, оптимальная подструктура и отсутствие последствий. + +- Если оптимальное решение исходной задачи можно построить из оптимальных решений подзадач, то оно обладает оптимальной под- структурой. + + + +- Отсутствие последствий означает, что будущее развитие состояния за- висит только от этого состояния и не зависит от всех предыдущих со- стояний. Многие задачи комбинаторной оптимизации не обладают этим свойством, и для их быстрого решения нельзя использовать динамиче- ское программирование. + +##### Задача о рюкзаке + +- Задача о рюкзаке -- одна из самых типичных задач динамического про- граммирования, имеющая такие варианты, как рюкзак 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*\], редактирование текущего символа не требуется. + + - В задаче о расстоянии редактирования состояние зависит от состояний + +> «прямо сверху», «прямо слева» и «слева сверху». Поэтому после опти- мизации пространства ни прямой, ни обратный обход не позволяют корректно выполнить переход состояния. Для решения этой проблемы используется переменная для временного хранения состояния слева сверху. Это позволяет преобразовать задачу в эквивалентную задаче о полном рюкзаке, и после оптимизации пространства можно выпол- нять прямой обход. +> +> Глава 15 diff --git a/ru/chapters/chapter_15.md b/ru/chapters/chapter_15.md new file mode 100644 index 000000000..290d84021 --- /dev/null +++ b/ru/chapters/chapter_15.md @@ -0,0 +1,493 @@ +# Жадность + +![](ru/docs/assets/media/image1028.jpeg){width="3.71875in" height="4.8125in"} + +#### жадные алгоритмы + +> *Жадный алгоритм* -- это распространенный метод решения задач оптими- зации. Его основная идея заключается в том, чтобы на каждом этапе приня- тия решения выбирать наиболее оптимальный на данный момент вариант, т. е. с жадностью принимать локально оптимальные решения в надежде по- лучить глобально оптимальное решение. Жадные алгоритмы просты и эф- фективны, и они находят широкое применение в решении многих практи- ческих задач. +> +> Жадные алгоритмы и динамическое программирование часто использу- ются для решения задач оптимизации. Между ними есть некоторые сходства, например оба метода зависят от свойств оптимальной подструктуры, но их принципы работы различны. + +- Динамическое программирование для получения текущего решения учитывает все предыдущие решения и использует решения предыдущих подзадач для построения решения текущей подзадачи. + +- Жадный алгоритм не учитывает предыдущие решения, а просто движет- ся вперед, делая жадные выборы и постепенно сокращая область задачи, пока она не будет решена. + +> Чтобы лучше понять принцип работы жадного алгоритма, рассмотрим его применение к задаче о размене монет. Она уже была рассмотрена в разделе +> +> «Задача о полном рюкзаке», и, вероятно, вы с ней уже знакомы. +> +> Жадная стратегия, применяемая в этой задаче, показана на рис. 15.1. Для заданной целевой суммы **мы жадно выбираем монету, которая не превы- шает и наиболее близка к этой сумме**, и повторяем этот шаг, пока не будет достигнута целевая сумма. +> +> ![](ru/docs/assets/media/image1030.jpeg) +> +> **Рис. 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 +> +> Вы можете невольно воскликнуть: «Эврика!» Жадный алгоритм решает за- дачу размена монет всего за десяток строк кода. + +1. **Преимущества и ограничения жадных алгоритмов** + +> **Жадные алгоритмы не только просты в реализации, но и обычно очень эффективны**. Если в приведенном выше коде обозначить минимальный но- минал монеты как *min*(*coins*), то жадный выбор выполняется не более *amt* / *min*(*coins*) раз. Тогда временная сложность составляет *O*(*amt* / *min*(*coins*)). Это на порядок меньше временной сложности решения с использованием дина- мического программирования *O*(*n* × *amt*). +> +> Однако **для некоторых комбинаций номиналов монет жадный алгоритм не сможет найти оптимальное решение**. На рис. 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 монеты. + +> ![](ru/docs/assets/media/image1032.jpeg)**Комбинация монет Целевая сумма Оптимальное решение** +> +> **жадного алгоритма (локальный оптимум)** +> +> **Оптимальное решение динамического программирования (глобальный оптимум)** +> +> **Рис. 15.2.** Примеры, когда жадный алгоритм не может найти оптимальное решение +> +> Таким образом, для задачи размена монет жадный алгоритм не гарантиру- ет нахождение глобально оптимального решения и может привести к очень плохому решению. Для решения этой задачи лучше подходит динамическое программирование. +> +> В общем случае жадные алгоритмы применимы в следующих двух ситуациях: + +1) **можно гарантировать нахождение оптимального решения**: в этом случае жадный алгоритм часто является лучшим выбором, так как он обычно более эффективен, чем методы обратного поиска и динамиче- ского программирования; + +2) **можно найти приближенное оптимальное решение**: в этом случае жадный алгоритм также применим. Для многих сложных задач поиск глобально оптимального решения очень затруднителен, и возможность найти субоптимальное решение с высокой эффективностью является весьма хорошим результатом. + +### Свойства жадных алгоритмов + +> Итак, возникает вопрос: какие задачи подходят для решения с помощью жад- ного алгоритма? Или, иначе говоря, в каких случаях жадный алгоритм может гарантировать нахождение оптимального решения? +> +> По сравнению с динамическим программированием условия применения жадного алгоритма более строгие, и они в основном сосредоточены на двух свойствах задачи. + +1. **Свойство жадного выбора**: жадный алгоритм может гарантировать получение оптимального решения только в случае, если локально опти- мальный выбор всегда приводит к глобально оптимальному решению. + +2. **Оптимальная подструктура**: оптимальное решение исходной задачи содержит оптимальное решение подзадачи. + +> Оптимальная подструктура уже была рассмотрена в главе «Динамическое программирование», поэтому здесь не будем повторяться. Стоит отметить, что оптимальная подструктура некоторых задач не всегда очевидна, но их все же можно решить с помощью жадного алгоритма. +> +> Основное внимание уделяется методам определения свойства жадного вы- бора. Хотя его описание кажется простым, **на практике доказательство это- го свойства для многих задач является сложной задачей**. +> +> Например, в задаче о размене монет мы можем легко привести контрпри- мер для опровержения свойства жадного выбора. Однако доказательство его истинности значительно сложнее. На вопрос «**При каких условиях можно использовать жадный алгоритм для решения задачи размена монет**?» обычно мы можем дать лишь интуитивный или примерный ответ, но не мо- жем предоставить строгое математическое доказательство. + +### Этапы решения задач жадным алгоритмом + +> Процесс решения жадных задач можно разделить на следующие три этапа: + +1) **анализ задачи**: изучение и понимание характеристик задачи, включая определение состояния, цели оптимизации и ограничения. Этот этап также присутствует в методах поиска с возвратом и динамического про- граммирования; + +2) **определение жадной стратегии**: определение того, как делать жадный выбор на каждом шаге. Эта стратегия позволяет уменьшать размер за- дачи на каждом шаге и в конечном итоге решить всю задачу; + +3) **доказательство корректности**: обычно требуется доказать нали- чие свойства жадного выбора и оптимальной подструктуры задачи. Этот этап может потребовать использования математических доказа- тельств, таких как метод математической индукции или доказатель- ство от противного. + +> Определение жадной стратегии является ключевым этапом решения зада- чи, но его реализация может быть непростой по следующим причинам. + +- **Жадные стратегии для различных задач могут значительно разли- чаться**. Для многих задач жадная стратегия очевидна, и ее можно опре- делить с помощью общего размышления и эмпирических проб. Однако для некоторых сложных задач жадная стратегия может оказаться очень скрытой, что потребует значительного опыта в решении задач и навы- ков работы с алгоритмами. + +- **Некоторые жадные стратегии могут быть обманчивыми**. Бывает, жадная стратегия разработана с полной уверенностью в ее правильности, код написан и отправлен на выполнение. Но оказывается, что некоторые тестовые примеры не проходят проверку на корректность. Это происхо- дит потому, что разработанная жадная стратегия является лишь частично правильной, как в случае с задачей о размене монет, описанной выше. + +> Для обеспечения корректности необходимо провести строгое математиче- ское доказательство жадной стратегии, **обычно с использованием метода доказательства от противного или метода математической индукции**. +> +> Тем не менее доказательство корректности может оказаться непростой за- дачей. Если нет ясности, обычно выбирается отладка кода на основе тестовых примеров с постепенной модификацией и проверкой жадной стратегии. + +### Типичные задачи для жадного алгоритма + +> Жадный алгоритм часто применяется в задачах оптимизации, удовлетворяю- щих свойству жадного выбора и оптимальной подструктуре. Ниже перечисле- ны некоторые типичные задачи для жадного алгоритма. + +- **Задача о размене монет**: при некоторых комбинациях монет жадный алгоритм всегда может получить оптимальное решение. + +- **Задача о расписании интервалов**: пусть у вас есть несколько задач, каждая из которых выполняется в течение определенного времени, и ваша цель -- выполнить как можно больше задач. Если каждый раз вы- бирать задачу с наименьшим временем окончания, то жадный алгоритм может дать оптимальное решение. + +- **Задача о дробном рюкзаке**: дана группа предметов и вместимость. Ваша цель -- выбрать группу предметов так, чтобы общая масса не превышала вместимость, а общая стоимость была максимальной. Если каждый раз выбирать предмет с наивысшим соотношением стоимости к массе, то жадный алгоритм в некоторых случаях может дать оптимальное решение. + +- **Задача о покупке и продаже акций**: дана группа акций с историей цены, можно совершать многократные покупки и продажи, но если ак- ции уже куплены, то перед следующей покупкой их необходимо продать. Цель -- получить максимальную прибыль. + +- **Код Хаффмана** -- это жадный алгоритм, используемый для сжатия дан- ных без потерь. Строится дерево Хаффмана: каждый раз выбираются два узла с наименьшей частотой появления и объединяются, в резуль- тате чего получается дерево с минимальной длиной взвешенного пути (длиной кодирования). + + + +- **Алгоритм Дейкстры** -- это жадный алгоритм, решающий задачу нахож- дения кратчайшего пути от заданной исходной вершины до всех осталь- ных вершин. + +#### задача о дробном рюкзаке + +![](ru/docs/assets/media/image1036.jpeg) + +> **Рис. 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\]. + + 2. Задача о дробном рюкзаке ❖ **467** + +![](ru/docs/assets/media/image1040.jpeg) + +> **Рис. 15.4.** Стоимость предметов на единицу массы + +##### Определение жадной стратегии + +> Максимизация общей стоимости предметов в рюкзаке, по сути, является мак- симизацией стоимости предметов на единицу массы. Из этого можно вывести жадную стратегию, изображенную на рис. 15.5. + +1. Отсортировать предметы по убыванию стоимости на единицу массы. + +2. Перебирать все предметы и **жадно выбирать на каждом этапе пред- мет с наивысшей стоимостью на единицу массы**. + +3. Если оставшейся вместимости рюкзака недостаточно, использовать часть текущего предмета для заполнения рюкзака. + +Номер Масса Стои- + +![](ru/docs/assets/media/image1042.jpeg)мость + +> Стоимость на ед. +> +> массы Сортировка по +> +> убыванию стоимости на ед. массы +> +> **Жадная стратегия:** +> +> В первую очередь выбирать предметы с более высокой стоимостью на ед. массы +> +> **Рис. 15.5.** Жадная стратегия для задачи о дробном рюкзаке + +##### Код реализации + +> Создадим класс предметов Item, чтобы можно было сортировать предметы по удельной стоимости. Будем циклически выполнять жадный выбор, если рюк- зак заполнен, выход из цикла и возврат решения. +> +> \# === 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*). + +##### Доказательство корректности + +> Используем метод доказательства от противного. Предположим, что предмет *x* -- это предмет с наивысшей удельной стоимостью, и некоторый алгоритм на- шел максимальную ценность res, но это решение не включает предмет *x*. +> +> Извлечем из рюкзака любой предмет с единичной массой и заменим его на предмет *x* с той же массой. Поскольку предмет *x* обладает наибольшей удельной стоимостью, общая стоимость после замены будет больше, чем res. **Это противоречит тому**, **что** res **является оптимальным решением**, **сле- довательно**, **в оптимальном решении обязательно должен присутство- вать предмет** *x*. +> +> Для других предметов в этом решении также можно построить аналогичное противоречие. В итоге **предметы с большей удельной стоимостью всегда являются более предпочтительным выбором**, что подтверждает эффектив- ность жадной стратегии. +> +> Если рассматривать массу предметов и их удельную стоимость как оси двухмерной диаграммы, то задачу о дробном рюкзаке можно преобразо- вать в нахождение максимальной площади, ограниченной конечным ин- тервалом по горизонтальной оси, как показано на рис. 15.6. Это сравнение помогает понять эффективность жадной стратегии с геометрической точ- ки зрения. + +![](ru/docs/assets/media/image1044.jpeg) + +> **Рис. 15.6.** Геометрическое представление задачи о дробном рюкзаке + +#### задача о максимальной вместимости + +> ![](ru/docs/assets/media/image1046.jpeg) +> +> **Рис. 15.7.** Пример данных для задачи о максимальной вместимости +> +> Контейнер образуется любыми двумя перегородками, **поэтому состоя- ние задачи определяется индексами двух перегородок, обозначим ее как** \[*i*, *j*\]. +> +> Согласно условию вместимость равна произведению высоты на ширину, где высота определяется более короткой перегородкой, а ширина -- разницей ин- дексов двух перегородок в массиве. Обозначим вместимость как *cap*\[*i*, *j*\], тогда формула для расчета будет следующей: +> +> 𝑐𝑎𝑝\[𝑖, 𝑗\] = min(ℎ𝑡\[𝑖\] , ℎ𝑡\[𝑗\] ) × (𝑗 − 𝑖). + +n + +##### Определение жадной стратегии + +> Для этой задачи существует более эффективное решение. Выберем состояние \[*i*, *j*\], которое удовлетворяет условиям *i* \< *j* и *ht*\[*i*\] \< *ht*\[*j*\], т. е. *i* является короткой перегородкой, а *j* -- длинной, как показано на рис. 15.8. +> +> **Если в этот момент переместить длинную перегородку** *j* **ближе к ко- роткой** *i*, **вместимость обязательно уменьшится**, как показано на рис. 15.9. +> +> Это происходит потому, что после перемещения длинной перегородки *j* ширина *j* -- *i* обязательно уменьшится. Высота же определяется короткой перегородкой, поэтому высота может остаться прежней (*i* остается короткой перегородкой) либо уменьшиться (перемещенная *j* становится короткой пе- регородкой). +> +> ![](ru/docs/assets/media/image1048.jpeg) +> +> **Рис. 15.8.** Начальное состояние + +![](ru/docs/assets/media/image1050.jpeg) + +> **Рис. 15.9.** Состояние после перемещения длинной перегородки внутрь +> +> Обратное рассуждение: **увеличить вместимость можно только переме- щая короткую перегородку** *i* **внутрь**. Хотя ширина обязательно уменьшится, **высота может увеличиться** (перемещенная короткая перегородка *i* может стать длиннее). Например, на рис. 15.10 после перемещения короткой пере- городки площадь увеличивается. +> +> Таким образом, можно сформулировать жадную стратегию для этой задачи: инициализировать два указателя, расположив их по краям контейнера, и на каждом шаге перемещать указатель, соответствующий короткой перегородке, внутрь, пока указатели не встретятся. +> +> ![](ru/docs/assets/media/image1052.jpeg) +> +> **Рис. 15.10.** Состояние после перемещения короткой перегородки внутрь +> +> На рис. 15.11 демонстрируется этот процесс выполнения жадной стратегии. + +1. В начальном состоянии указатели *i* и *j* расположены по краям массива. + +2. Вычисление вместимости текущего состояния *cap*\[*i*, *j*\] и обновление мак- симальной вместимости. + +3. Сравнение высот перегородок *i* и *j* и перемещение короткой перегород- ки на одну позицию внутрь. + +4. Повторение шагов 2 и 3 до тех пор, пока *i* и *j* не встретятся. + +![](ru/docs/assets/media/image1054.jpeg) + +> **Рис. 15.11.** Жадный алгоритм для задачи о максимальной вместимости. Шаг 1 +> +> ![](ru/docs/assets/media/image1056.jpeg) + +![](ru/docs/assets/media/image1058.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 2--3 +> +> ![](ru/docs/assets/media/image1060.jpeg) + +![](ru/docs/assets/media/image1062.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 4--5 +> +> ![](ru/docs/assets/media/image1064.jpeg) + +![](ru/docs/assets/media/image1066.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 6--7 +> +> ![](ru/docs/assets/media/image1068.jpeg) + +![](ru/docs/assets/media/image1070.jpeg) + +> **Рис. 15.11.** *Окончание*. Шаг 8--9 + +##### Код реализации + +> Цикл выполняется не более *n* раз, поэтому **временная сложность состав- ляет** *O*(*n*). +> +> Переменные *i*, *j*, *res* используют дополнительное пространство постоянного размера, поэтому **пространственная сложность равна** *O*(1). +> +> \# === 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 + +##### Доказательство корректности + +> Жадный алгоритм быстрее перебора, потому что каждое жадное решение про- пускает некоторые состояния. +> +> Например, имеется состояние *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* **перемещает- ся внутрь**. Ранее было доказано, что перемещение длинной доски внутрь обя- зательно приведет к уменьшению емкости. Это означает, что пропущенные состояния не могут быть оптимальным решением, и **их пропуск не приведет к упущению оптимального решения**. +> +> Этот анализ показывает, что операция перемещения короткой перегородки является безопасной, и жадная стратегия эффективна. +> +> ![](ru/docs/assets/media/image1072.jpeg) +> +> **Рис. 15.12.** Перемещение короткой перегородки приводит к пропущенным состояниям + +#### задача о максимальном произведении разбиения + +![](ru/docs/assets/media/image1074.jpeg) + +> **Рис. 15.13.** Определение задачи о максимальном произведении разбиения +> +> Предположим, что мы разложили *n* на *m* целых множителей, где *i*-й множи- тель обозначен как *ni*, т. е.: + +***m*** + +> ***n*** = Ι*ni* . +> +> ***i*** =1 +> +> Цель данной задачи -- найти максимальное произведение всех целых мно- жителей, т. е.: +> +> max= ( ∉***m** n* 1. +> +>   ***i***   +> +>   ***i***  1   +> +> Необходимо решить вопрос: насколько велико должно быть количество раз- биений *m* и каковы должны быть значения каждого *ni*? + +##### Определение жадной стратегии + +> Эмпирический факт заключается в том, произведение двух чисел часто больше их суммы. Предположим, что из *n* выделяется множитель 2, тогда итоговое про- изведение равно 2(*n* − 2). Сравним это произведение с *n*: +> +> 2(*n* − 2) ≥ *n* +> +> 2 *n* − *n* − 4 ≥ 0 +> +> *n* ≥ 4. +> +> Когда *n* ≥ 4, выделение множителя 2 увеличивает произведение, как пока- зано на рис. 15.14. Это означает, что **целые числа, равные или большие** 4, **необходимо раскладывать на несколько множителей**. +> +> **Жадная стратегия 1**: если в схеме разбиения присутствует множитель ≥ 4, то его следует продолжать раскладывать. В окончательной схеме разбиения должны присутствовать только множители 1, 2, 3. + +![](ru/docs/assets/media/image1076.jpeg) + +> **Рис. 15.14.** Разбиение увеличивает произведение +> +> Далее следует обдумать, какой множитель является оптимальным. Сре- ди множителей 1, 2, 3 очевидно, что 1 -- наихудший, поскольку неравенство 1 × (*n* − 1) \< *n* всегда верно, т. е. выделение 1 приведет к уменьшению произ- ведения. +> +> Если *n* = 6, 3 × 3 \> 2 × 2 × 2, **значит разбиение на тройки предпочтительнее разбиения на двойки**, см рис. 15.15. +> +> **Жадная стратегия 2**: в схеме разбиения должно быть не более двух зна- чений 2, поскольку три 2 всегда можно заменить двумя 3 и получить большее произведение. + +![](ru/docs/assets/media/image1078.jpeg) + +> **Рис. 15.15.** Оптимальные множители разбиения +> +> Таким образом, можно вывести общую жадную стратегию. + +1. Задать целое число *n* и выделять из него множитель 3 до тех пор, пока остаток не станет 0, 1 или 2. + +2. Если остаток равен 0, значит *n* кратно 3, и дальнейшие действия не тре- буются. + +3. Если остаток равен 2, не продолжать разбиение, оставить как есть. + +4. Если остаток равен 1, то, поскольку 2 × 2 \> 1 × 3, следует заменить послед- ний множитель 3 на 2. + +##### Код реализации + +> Из рис. 15.16 видно, что для разбиения числа нет необходимости использовать цикл. Можно воспользоваться операцией целочисленного деления вниз для получения количества троек *a*, а также операцией взятия остатка для получе- ния остатка *b*, в этом случае: +> +> *n* = 3*a* + *b*. +> +> Обратите внимание, что для граничных случаев, когда *n* ≤ 3, необходимо вы- делить множитель 1, произведение будет равно 1 × (*n* − 1). +> +> \# === 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)) + +![](ru/docs/assets/media/image1080.png) + +> **Рис. 15.16.** Метод вычисления максимального произведения разбиения +> +> **Временная сложность зависит от метода реализации операции возве- дения в степень в языке программирования**. Для Python обычно использу- ются три функции для вычисления степени. + +- Оператор \*\* и функция pow() имеют временную сложность *O*(log *a*). + +- Функция math.pow() вызывает функцию pow() из библиотеки C, выполняющую возведение в степень с плавающей точкой c временной сложностью O(1). + +> Переменные *a* и *b* используют дополнительное пространство постоянного размера, поэтому **пространственная сложность составляет** *O*(1). + +##### Доказательство корректности + +> Используем метод от противного и проанализируем только случай *n* ≥ 3. + +1. **Все множители** ≤ 3: предположим, что в оптимальной схеме разбиения существует множитель ≥ 4, тогда его можно разложить на 2(*x* − 2) и полу- чить большее произведение. Это противоречит предположению. + +2. **Схема разбиения не содержит** 1: предположим, что в оптимальной схеме разбиения существует множитель 1, тогда его можно объединить + +> с другим множителем и получить большее произведение. Это противо- речит предположению. + +3. **Максимальное количество двоек в разбиении равно** 2: предполо- жим, что в оптимальном разбиении содержатся три двойки, тогда их можно заменить на две тройки и получить большее произведение. Это противоречит предположению. + +#### резюме + +- Жадные алгоритмы обычно применяются для решения задач оптимиза- ции. Их принцип заключается в том, чтобы на каждом этапе принятия решения делать локально оптимальный выбор с целью получения гло- бально оптимального решения. + +- В жадных алгоритмах циклически выполняются жадные выборы, каж- дый раз превращая задачу в меньшую подзадачу, пока задача не будет решена. + +- Жадные алгоритмы не только просты в реализации, но и обладают высо- кой эффективностью решения. По сравнению с динамическим програм- мированием временная сложность жадных алгоритмов обычно ниже. + +- В задаче о размене монет для некоторых комбинаций монет жадный ал- горитм может гарантировать нахождение оптимального решения. Но для других комбинаций жадный алгоритм может найти очень плохое решение. + +- Задачи, подходящие для решения жадными алгоритмами, обладают двумя основными свойствами: свойство жадного выбора и оптимальная подструктура. Свойство жадного выбора свидетельствует об эффектив- ности жадной стратегии. + +- Для некоторых сложных задач доказательство свойства жадного выбо- ра является сложной задачей. Относительно проще найти контрпример и опровергнуть это свойство, например в задаче о размене монет. + +- Решение жадных задач обычно включает три этапа: анализ задачи, опре- деление жадной стратегии, доказательство корректности. Среди них ключевым этапом является определение жадной стратегии, а доказа- тельство корректности часто представляет собой сложную задачу. + +- Задача о дробном рюкзаке, в отличие от задачи о рюкзаке 0-1, позволяет выбирать часть предметов, поэтому ее можно решить с помощью жадно- го алгоритма. Корректность жадной стратегии можно доказать методом от противного. + +- Задачу о максимальной вместимости можно решить методом перебо- ра, временная сложность которого составляет *O*(*n*2). Разработав жадную стратегию, в которой на каждом шаге граница перемещается внутрь, временную сложность можно оптимизировать до *O*(*n*). + +- В задаче о максимальном произведении разбиения мы последователь- но формулируем две жадные стратегии. Во-первых, для целых чисел ≥ 4 нужно продолжать разбиение. Во-вторых, оптимальным множителем разбиения является 3. В коде содержатся операции возведения в сте- пень, временная сложность которых зависит от метода их реализации и обычно составляет *O*(1) или *O*(log *n*). + +> Глава 16 diff --git a/ru/chapters/chapter_16.md b/ru/chapters/chapter_16.md new file mode 100644 index 000000000..a8a304204 --- /dev/null +++ b/ru/chapters/chapter_16.md @@ -0,0 +1,441 @@ +# Приложение + +![](ru/docs/assets/media/image1081.jpeg){width="4.072915573053368in" height="5.270833333333333in"} + +###### Содержание главы + +1. Установка программной среды + +2. Совместная разработка + +3. Глоссарий + +#### Установка программной среды + +1. **Установка IDE** + +> Рекомендуется использовать открытую и быструю интегрированную среду разработки (IDE) VS Code. Откройте официальный сайт VS Code и выберите подходящую вашей операционной системе версию для загрузки и установки. + +![](ru/docs/assets/media/image1083.jpeg) + +> **Рис. 16.1.** Загрузка VS Code с официального сайта +> +> VS Code обладает мощной экосистемой расширений и поддерживает выпол- нение и отладку большинства языков программирования. Например, после установки расширения Python Extension Pack можно выполнять отладку кода на Python. Этапы установки показаны на рис. 16.2. + +1. ![](ru/docs/assets/media/image1085.jpeg)Установка программной среды ❖ **485** + +> **Рис. 16.2.** Установка расширений в VS Code + +### Установка языковой среды + +##### Среда Python + +1. Загрузите и установите инструмент Miniconda3, требуется Python 3.10 или более поздняя версия. + +2. В магазине расширений VS Code выполните поиск по слову python и уста- новите расширение Python Extension Pack. + +3. (Не обязательно) Введите в командной строке pip install black для уста- новки инструмента форматирования кода. + +##### Среда C/C++ + +1. В системе Windows необходимо установить набор инструментов MinGW (руководство по настройке). В MacOS имеется встроенный компилятор Clang, дополнительная установка не требуется. + +2. В магазине расширений VS Code выполните поиск по слову c++ и устано- вите расширение C/C++ Extension Pack. + +3. (Не обязательно) Откройте страницу настроек, найдите параметр фор- матирования кода Clang_format_fallback Style и установите его в значе- ние { BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }. + +##### Среда Java + +1. Загрузите и установите OpenJDK (версия \> JDK 9). + +2. В магазине расширений VS Code выполните поиск по слову java и уста- новите расширение Extension Pack for Java. + +##### Среда C# + +1. Загрузите и установите .Net 8.0. + +2. В магазине расширений VS Code выполните поиск по фразе C# Dev Kit + +> и установите расширение C# Dev Kit (руководство по настройке). + +3. Также можно использовать интегрированную среду разработки Visual Studio (руководство по установке). + +##### Среда Go + +1. Загрузите и установите Go. + +2. В магазине расширений VS Code выполните поиск по слову go и устано- вите Go. + +3. Нажмите сочетание клавиш Ctrl + Shift + P, чтобы открыть командную строку. Введите команду go, выберите Go: Install/Update Tools, отметьте все и установите. + +##### Среда Swift + +1. Загрузите и установите Swift. + +2. В магазине расширений VS Code выполните поиск по слову swift и уста- новите расширение Swift for Visual Studio Code. + +##### Среда JavaScript + +1. Загрузите и установите Node.js. + +2. (Не обязательно) В магазине расширений VS Code выполните поиск по слову Prettier и установите инструмент форматирования кода. + +##### Среда TypeScript + +1. Выполните шаги установки для среды JavaScript. + +2. Установите TypeScript Execute (tsx). + +3. В магазине расширений VS Code выполните поиск по слову typescript + +> и установите расширение Pretty TypeScript Errors. + +##### Среда Dart + +1. Загрузите и установите Dart. + +2. В магазине расширений VS Code выполните поиск по слову dart и уста- новите расширение Dart. + +##### Среда Rust + +1. Загрузите и установите Rust. + +2. В магазине расширений VS Code выполните поиск по слову rust и уста- новите расширение rust-analyzer. + +#### Совместная разработка + +> Ввиду ограниченных возможностей автора в книге неизбежно присутству- ют некоторые упущения и ошибки, просим отнестись к этому с пониманием. Если вы обнаружите опечатки, неработающие ссылки, неполное содержание, + +2. Совместная разработка ❖ **487** + +> двусмысленности в тексте, неясные объяснения или нерациональную структу- ру изложения, пожалуйста, помогите нам в исправлении, чтобы предоставить читателям более качественные учебные ресурсы. +> +> Все идентификаторы GitHub авторов будут представлены на странице ре- позитория книги, в веб-версии и PDF-версии в знак благодарности за их бес- корыстный вклад в сообщество с открытым исходным кодом. + +##### Небольшая корректировка содержимого + +> В правом верхнем углу каждой страницы есть значок редактирования, как по- казано на рис. 16.3. Следуйте следующим шагам для изменения текста или кода. + +1. Нажмите на значок редактирования. Если появится сообщение «Необходи- мо создать ответвление этого репозитория», согласитесь на это действие. + +2. Измените содержимое исходного файла Markdown, проверьте правиль- ность содержания и постарайтесь сохранить единый формат оформления. + +3. В нижней части страницы заполните описание изменений, затем на- жмите кнопку **Propose file change** (предложить изменение файла). После перехода на следующую страницу нажмите кнопку **Create pull request** (Создать запрос на слияние), чтобы инициировать запрос на слияние. + +![](ru/docs/assets/media/image1087.jpeg) + +> **Рис. 16.3.** Кнопка редактирования страницы +> +> Изображения нельзя изменить напрямую, необходимо создать новую Issue (Задачу) или оставить комментарий для описания проблемы. Мы как можно быстрее изменим и обновим изображение. + +##### Создание содержимого + +> Если вы заинтересованы в участии в этом проекте с открытым исходным ко- дом, включая перевод кода на другие языки программирования, расширение содержания статей и т. д., необходимо выполнить следующий рабочий процесс Pull Request (Запрос на слияние). + +1. Войдите в GitHub, создайте ответвление хранилища кода книги в свой личный аккаунт. + +2. Перейдите на страницу ответвления и используйте команду git clone + +> для клонирования хранилища на локальный компьютер. + +3. На локальном компьютере создайте содержимое и проведите полное те- стирование, чтобы проверить правильность кода. + +4. Зафиксируйте изменения, сделанные локально, затем отправьте их в удаленное хранилище. + +5. Обновите страницу хранилища и нажмите кнопку **Create pull request** + +> (Создать запрос на слияние), чтобы инициировать запрос на слияние. + +##### Развертывание Docker + +> В корневом каталоге hello-algo выполните следующий сценарий Docker, чтобы настроить доступ к проекту по адресу http://localhost:8000: +> +> docker-compose up -d +> +> Для удаления развертывания выполните следующую команду: +> +> docker-compose down + +#### Глоссарий + +> В табл. 16.1 приведен англо-русский словарь важных терминов, встречающих- ся в книге. Он поможет вам в чтении англоязычной литературы. +> +> **Таблица 16.1.** Англо-русский словарь терминов 1's complement обратный код +> +> 2's complement дополнительный код +> +> adjacency смежность +> +> adjacency list список смежности +> +> adjacency matrix матрица смежности +> +> algorithm алгоритм +> +> array массив +> +> asymptotic complexity analysis асимптотический анализ сложности +> +> 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 задача расстояния редактирования +> +> 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 сортировка слиянием +> +> 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 временная сложность +> +> 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**](http://www.dmkpress.com/) +> +> с доставкой в любой регион РФ. + +##### Цзинь Юйдун (@krahets) + +> **Алгоритмы и структуры данных с примерами на Python** +> +> Главный редактор *Мовчан Д. А.* +> +> Зам. главного редактора *Яценков В. С.* +> +> +> +> Перевод *Шевкун И. А.* +> +> Корректор *Абросимова Л. А.* +> +> Верстка *Луценко С. В.* +> +> Дизайн обложки *Трофимова С. В.* +> +> Формат 70×100 1/16. +> +> Гарнитура «PT Serif». Печать цифровая. +> +> Усл. печ. л. 40,14. Тираж 100 экз. +> +> Веб-сайт издательства: [www.dmkpress.com](http://www.dmkpress.com/) diff --git a/ru/full_russian.md b/ru/full_russian.md new file mode 100644 index 000000000..149ccc527 --- /dev/null +++ b/ru/full_russian.md @@ -0,0 +1,12636 @@ +> Цзинь Юйдун +> +> **Алгоритмы** +> +> **[и]{.smallcaps} структуры данных c примерами на Python** +> +> **Hello** 算法 +> +> **Python** 语言版 +> +> 作者:靳宇栋 (@krahets) +> +> 代码审阅:靳宇栋((@krahets) + +![](ru/docs/assets/media/image1.png){width="0.8872692475940508in" height="0.4294783464566929in"} + +> **Алгоритмы** +> +> **и структуры данных с примерами на Python** +> +> Цзинь Юйдун (Jin Yudong) (@krahets) Перевод: И. А. Шевкун + +![](ru/docs/assets/media/image16.png) + +> Москва, 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 + +1. О книге 12 + +2. [Как использовать эту книгу 15](#как-использовать-эту-книгу) + +3. [Резюме 23](#резюме) + +Глава 1. Введение в алгоритмы 24 + +1. [Алгоритмы повсюду 25](#алгоритмы-повсюду) + +2. [Что такое алгоритм 30](#что-такое-алгоритм) + +3. [Резюме 32](#резюме-1) + +Глава 2. Анализ сложности 34 + +1. [Оценка эффективности алгоритмов 35](#оценка-эффективности-алгоритмов) + +2. [Итерация и рекурсия 37](#итерация-и-рекурсия) + +3. [Временная сложность 46](#временная-сложность) + +4. [Пространственная сложность 61](#пространственная-сложность) + +5. [Резюме 67](#резюме-2) + +Глава 3. Структуры данных 70 + +1. [Классификация структур данных 71](#классификация-структур-данных) + +2. [Основные типы данных 74](#основные-типы-данных) + +3. [Кодирование чисел\* 76](#кодирование-чисел) + +4. [Кодирование символов\* 80](#кодирование-символов) + +5. [Резюме 89](#резюме-3) + +Глава 4. Массивы и списки 92 + +1. [Массивы 93](#массивы) + +2. [Связные списки 99](#связные-списки) + +3. [Списки 105](#списки) + +4. [Память и кеш\* 109](#память-и-кеш) + +5. [Резюме 113](#резюме-4) + +6 ❖ Оглавление + +Глава 5. Стек и очередь 118 + +1. Стек 119 + +2. [Очередь 126](#очередь) + +3. [Двусторонняя очередь 133](#двусторонняя-очередь) + +4. [Резюме 144](#резюме-5) + +Глава 6. Хеш-таблицы 146 + +1. [Хеш-таблицы 147](#хеш-таблицы-1) + +2. [Хеш-коллизии 152](#хеш-коллизии) + +3. [Алгоритмы хеширования 161](#алгоритмы-хеширования) + +4. [Резюме 167](#резюме-6) + +Глава 7. Деревья 170 + +1. [Двоичные деревья 171](#двоичные-деревья) + +2. [Обход двоичного дерева 177](#обход-двоичного-дерева) + +3. [Представление двоичного дерева с помощью массива 184](#представление-двоичного-дерева-с-помощью-массива) + +4. [Двоичное дерево поиска 189](#двоичное-дерево-поиска) + +5. АВЛ-дерево\* 199 + +6. [Резюме 210](#резюме-7) + +Глава 8. Куча 213 + +1. Куча 214 + +2. [Построение кучи 226](#построение-кучи) + +3. [Поиск k наибольших элементов 229](#поиск-k-наибольших-элементов) + +4. [Резюме 234](#резюме-8) + +Глава 9. Графы 236 + +1. [Графы 237](#графы-1) + +2. [Основные операции с графами 241](#основные-операции-с-графами) + +3. [Обход графа 249](#обход-графа) + +4. [Резюме 262](#резюме-9) + +Глава 10. Поиск 264 + +1. [Двоичный поиск 265](#двоичный-поиск) + +2. Вставка с использованием + +двоичного поиска 270 + +3. [Двоичный поиск границ 276](#двоичный-поиск-границ) + +4. [Стратегии оптимизации хеширования 279](#стратегии-оптимизации-хеширования) + +5. [Переосмысление алгоритмов поиска 282](#переосмысление-алгоритмов-поиска) + +6. [Резюме 286](#резюме-10) + +Глава 11. Сортировка 287 + +1. [Алгоритмы сортировки 288](#алгоритмы-сортировки) + +2. [Сортировка выбором 289](#сортировка-выбором) + +3. [Сортировка пузырьком 294](#сортировка-пузырьком) + +4. Сортировка вставками 298 + +Оглавление ❖ 7 + +5. [Быстрая сортировка 301](#быстрая-сортировка) + +6. [Сортировка слиянием 310](#сортировка-слиянием) + +7. [Пирамидальная сортировка 316](#пирамидальная-сортировка) + +8. [Блочная сортировка 324](#блочная-сортировка) + +9. [Сортировка подсчетом 327](#сортировка-подсчетом) + +10. [Поразрядная сортировка 334](#поразрядная-сортировка) + +11. Резюме 337 + +Глава 12. Разделяй и властвуй 342 + +1. [Стратегия «разделяй и властвуй» 343](#стратегия-разделяй-и-властвуй) + +2. Применение стратегии «разделяй и властвуй» для поиска 347 + +3. [Задача построения двоичного дерева 350](#задача-построения-двоичного-дерева) + +4. [Задача о Ханойских башнях 357](#задача-о-ханойских-башнях) + +5. Резюме 363 + +Глава 13. Поиск с возвратом 365 + +1. [Алгоритмы поиска с возвратом 366](#алгоритмы-поиска-с-возвратом) + +2. Задача о перестановках 377 + +3. Задача о сумме подмножеств 383 + +4. Задача об n ферзях 391 + +5. Резюме 395 + +Глава 14. Динамическое программирование 397 + +1. Введение в динамическое программирование 398 + +2. Особенности задач динамического программирования 404 + +3. [Подход к решению задач динамического программирования 410](#подход-к-решению-задач-динамического-программирования) + +4. [Задача о рюкзаке 0-1 422](#задача-о-рюкзаке-0-1) + +5. [Задача о полном рюкзаке 434](#задача-о-полном-рюкзаке) + +6. [Задача расстояния редактирования 448](#задача-расстояния-редактирования) + +7. [Резюме 457](#резюме-11) + +Глава 15. Жадность 460 + +1. [Жадные алгоритмы 461](#жадные-алгоритмы) + +2. [Задача о дробном рюкзаке 466](#задача-о-дробном-рюкзаке) + +3. [Задача о максимальной вместимости 469](#задача-о-максимальной-вместимости) + +4. [Задача о максимальном произведении разбиения 478](#задача-о-максимальном-произведении-разбиения) + +5. [Резюме 482](#резюме-12) + +Глава 16. Приложение 483 + +1. [Установка программной среды 484](#установка-программной-среды) + +2. [Совместная разработка 486](#совместная-разработка) + +3. [Глоссарий 488](#_TOC_250000) + +## Предисловие + +![](ru/docs/assets/media/image30.jpeg){width="4.07292760279965in" height="5.270833333333333in"} + +> Два года назад я опубликовал на платформе LeetCode сборник задач по книге Coding Interviews, который получил хорошие отклики от читателей. В ходе об- щения с ними мне чаще всего задавали вопрос: «С чего начать изучение алго- ритмов?» Постепенно этот вопрос начал все больше меня занимать. +> +> Решение задач наугад, вслепую является, пожалуй, самым распространен- ным методом: он прост, прямолинеен и результативен. Однако этот процесс напоминает компьютерную игру «Сапер»: люди с высокой способностью к са- мообучению могут без особых проблем разминировать все клетки. А те, кому +> +> Предисловие ❖ **9** +> +> не хватает базовой подготовки, могут легко подорваться и быстро потерять мотивацию. Другой распространенный подход -- читать учебники от корки до корки. Но у тех, кто готовится к устройству на работу, значительная часть времени и сил уходит на получение диплома, составление резюме, подготовку к тестам и собеседованиям. В таких условиях чтение объемных книг превра- щается в серьезное испытание. +> +> Если вы сталкиваетесь с подобными трудностями, то считайте большой уда- чей, что эта книга «нашла» вас. Она представляет собой мой ответ на вопрос +> +> «Как войти в мир алгоритмов?». Возможно, это не самый совершенный ответ, но это точно искренняя попытка. Эта книга не гарантирует вам быстрого полу- чения хорошего офера, но она поможет построить «карту знаний» по структу- рам данных и алгоритмам, познакомит с различными «минами», их формой, размером и расположением, а также научит «методам разминирования». Ов- ладев этими знаниями, вы сможете увереннее решать задачи и читать науч- ные статьи, постепенно формируя целостную систему знаний. +> +> Я полностью согласен со словами Ричарда Фейнмана: «Знания не бесплат- ны. За них нужно платить вниманием». В этом смысле эта книга вовсе не бес- платна. И чтобы оправдать ваше драгоценное внимание, я вложу максимум усилий в ее создание. +> +> Осознавая ограниченность собственных знаний и опыта, я понимаю, что, несмотря на многократные доработки, в книге все же могут оставаться ошибки. Буду искренне признателен за критику и замечания от преподава- телей и студентов. +> +> Исходные коды из книги доступны в виде исполняемых файлов в репозито- рии по адресу github.com/krahets/hello-algo. +> +> Формат PDF не очень хорошо подходит для анимированных изображений, поэтому для лучшего восприятия можно обратиться к веб-версии по адресу https:[//www.hello-algo](http://www.hello-algo.com/en).c[om/en](http://www.hello-algo.com/en) (на англ.). + +## Предисловие от издательства + +#### Отзывы и пожелания + +> Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете об этой книге -- что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны. +> +> Вы можете написать отзыв на нашем сайте [www.dmkpress.com,](http://www.dmkpress.com/) зайдя на стра- ницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу dmkpress@gmail.com; при этом укажите название книги в теме письма. +> +> Если вы являетесь экспертом в какой-либо области и заинтересованы в написа- нии новой книги, заполните форму на нашем сайте по адресу authors/publish_book/ или напишите в издательство по адресу [dmkpress@gmail.com.](mailto:dmkpress@gmail.com) + +#### Список опечаток + +> Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое качество наших текстов, ошибки все равно случаются. Если вы найдете ошибку в одной из наших книг -- возможно, ошибку в основном тексте или программ- ном коде, -- мы будем очень благодарны, если вы сообщите нам о ней. Сделав это, вы избавите других читателей от недопонимания и поможете нам улуч- шить последующие издания этой книги. +> +> Если вы найдете какие-либо ошибки в коде, пожалуйста, сообщите о них главному редактору по адресу [dmkpress@gmail.com,](mailto:dmkpress@gmail.com) и мы исправим это в сле- дующих тиражах. + +#### нарушение авторских прав + +> Пиратство в интернете по-прежнему остается насущной проблемой. Изда- тельство «ДМК Пресс» очень серьезно относится к вопросам защиты авторских прав и лицензирования. Если вы столкнетесь в интернете с незаконной публи- кацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на ин- тернет-ресурс, чтобы мы могли применить санкции. +> +> Ссылку на подозрительные материалы можно прислать по адресу +> +> [dmkpress@gmail.com.](mailto:dmkpress@gmail.com) +> +> Мы высоко ценим любую помощь по защите наших авторов, благодаря кото- рой мы можем предоставлять вам качественные материалы. +> +> Глава 0 + +# Введение + +![](ru/docs/assets/media/image31.jpeg){width="4.60417760279965in" height="5.958333333333333in"} + +1. **О книге** + +> Этот проект задуман как открытое, бесплатное и дружелюбное к новичкам введение в структуры данных и алгоритмы. + +1. В книге используются анимационные иллюстрации. Материал изложен ясно и последовательно, что облегчает освоение и помогает начинаю- щим построить «карту знаний» по структурам данных и алгоритмам. + +2. Исходный код можно запустить одним кликом, что позволяет трениро- ваться, развивать навыки программирования и формировать понима- ние принципов работы алгоритмов и реализации структур данных на фундаментальном уровне. + +3. Мы призываем к взаимопомощи читателей: задавайте вопросы и дели- тесь идеями в комментариях. Обсуждения помогают двигаться вперед всем вместе. + +### Целевая аудитория + +> Если вы новичок в алгоритмах, никогда с ними не сталкивались или у вас есть некоторый опыт решения задач, но еще нет четкого понимания структур дан- ных и алгоритмов, эта книга создана специально для вас! +> +> Если у вас уже есть определенный опыт решения задач и вы знакомы с боль- шинством типов задач, эта книга поможет вам освежить и систематизировать знания об алгоритмах. Исходный код может служить набором инструментов для решения задач или алгоритмическим словарем. +> +> Если вы владеете алгоритмами на экспертном уровне, мы будем рады ва- шим ценным советам или совместному участию в создании книги. + +### Структура книги + +> Основное содержание книги представлено на рис. 0.1. + +1. **Анализ сложности**: критерии и методы оценки структур данных и ал- горитмов. Методы расчета временной и пространственной сложности, распространенные типы, примеры и т. д. + +> ![](ru/docs/assets/media/image33.png)**Структуры данных** +> +> **Структуры данных** +> +> **Массивы и списки** +> +> **Стек** +> +> **[и]{.smallcaps} очередь** +> +> **Хеш- таблицы** +> +> Логическая и физическая структура Базовые типы данных +> +> Цифровое и символьное кодирование +> +> Способы непрерывного и распределенного хранения Методы работы, преимущества +> +> [и]{.smallcaps} недостатки двух способов +> +> Реализация списка на основе динамического массива +> +> Память и кеш в компьютере +> +> Последний пришел -- первый вышел, первый пришел -- первый вышел, двусторонняя очередь +> +> Реализация на основе массива и связного списка +> +> Принцип работы хеш-таблицы, реализация на основе массива +> +> Хеш-коллизии, цепная и открытая адресация Назначение и цели хеш-алгоритмов +> +> Идеальное, совершенное, полное, сбалансированное +> +> двоичное дерево +> +> **Анализ сложности** +> +> **Итерация и рекурсия** +> +> **Временная сложность** +> +> **Пространственная сложность** +> +> **Поиск** +> +> Циклы for и while, вложенные циклы +> +> Стек вызовов рекурсии, хвостовая рекурсия, дерево рекурсии +> +> Сравнение итерации и рекурсии +> +> Асимптотическая верхняя граница +> +> Методы вычисления, основные типы +> +> Наихудшая, наилучшая, средняя временная сложность +> +> Методы вычисления, основные типы Компромисс между временем +> +> и пространством +> +> Перебор: линейный поиск, поиск в ширину и глубину +> +> Адаптивный поиск: двоичный поиск, хеш-поиск, поиск по дереву +> +> Выбор алгоритма поиска +> +> Местность, устойчивость, адаптивность, основанность на сравнении +> +> Перебор: сортировка выбором, пузырьком, вставками + +**Деревья** + +**Куча** + +**Графы** + +https://github.com/krahets/hello-algo + +> Представление связным списком и массивом, сравнение Обход в ширину; прямой, симметричный и обратный порядок Двоичное дерево поиска +> +> АВЛ-дерево +> +> ![](ru/docs/assets/media/image35.png)Минимальная и максимальная куча, **Алгоритмы** +> +> очередь с приоритетом +> +> Реализация кучи на основе массива, операция построения кучи +> +> Задача о k наибольших элементов +> +> Ориентированный, связный и взвешенный графы Список и матрица смежности, их сравнение Обход в ширину и в глубину +> +> **Сортировка** +> +> **Разделяй и властвуй** +> +> **Поиск** +> +> **с возвратом** +> +> **Динамическое программирование** +> +> **Жадные алгоритмы** +> +> «Разделяй и властвуй»: быстрая сортировка, сортировка слиянием, пирамидальная сортировка +> +> Без сравнения: блочная сортировка, +> +> сортировка подсчетом, поразрядная сортировка Стратегия «разделяй и властвуй» +> +> Примеры задач: двоичный поиск, построение дерева, Ханойская башня +> +> Задача поиска с возвратом, каркас кода +> +> Примеры задач: перестановки, сумма подмножеств, n ферзей +> +> Перебор, рекурсия с мемоизацией, восходящая рекурсия +> +> Перекрывающиеся подзадачи, оптимальная подструктура, отсутствие последействия +> +> Методы определения задач динамического программирования, этапы решения +> +> Примеры задач: рюкзак 0-1, полный рюкзак, расстояние редактирования +> +> Характеристики жадных задач, этапы решения +> +> Примеры задач: дробный рюкзак, максимальная вместимость, максимальное произведение разбиения +> +> 0.1. О книге ❖ **13** + +2. **Структуры данных**: классификация основных типов данных и структур данных. Определение, преимущества и недостатки, основные операции, распространенные типы, типичные приложения и методы реализации мас- сивов, списков, стеков, очередей, хеш-таблиц, деревьев, куч, графов и т. д. + +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, JoseHu- ng, qualifier1024, QiLOL, pengchzn, Guanngxu, L-Super, WSL0809, Slone123c, lhxsm, yuan0221, what-is-me, theNefelibatas, longranger2, cy-by-side, xiongsp, Jefferson- Huang, 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, YangXu- anyi, DullSword, boloboloda, iStig, qq909244296, jiaxianhua, wenjianmin, keshida, kilikilikid, lclc6, lwbaptx, liuxjerry, lucaswangdev, lyl625760, hts0000, gledfish, fbigm, echo1937, szu17dmy, dshlstarr, Yucao-cy, coderlef, czruby, bongbongbakudan, beinten- tional, 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, JackYang- hellobobo, Javesun99, lipusheng, BlindTerran, ShiMaRing, FreddieLi, FloranceYeh, iF- leey, fanchenggang, gltianwen, goerll, Dr-XYZ, nedchu, curly210102, CuB3y0nd, KraHsu, CarrotDLaw, youshaoXG, bubble9um, fanenr, eagleanurag, LifeGoesOnionOnionOnion, 52coder, foursevenlove, KorsChen, hezhizhen, linzeyan, ZJKung, GaochaoZhu, hopk- ings2008, 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, gve- nusleo, hpstory, justin-tse, khoaxuantu, krahets, night-cruise, nuomi1, Reanon и ron- gyi (в алфавитном порядке). Благодарим их за потраченное время и усилия, ко- + +торые обеспечили стандартизацию и единообразие кода на различных языках. + +> В процессе создания этой книги мне помогало много людей. + +1. Благодарю моего наставника в компании, доктора Ли Си (Li Xi), который в одной из бесед вдохновил меня «действовать быстро», что укрепило мою решимость написать эту книгу. + +2. Благодарю мою девушку Паo Пао (Pao Pao), которая, будучи первым чи- тателем книги, дала множество ценных советов с точки зрения новичка в алгоритмах, что сделало книгу более понятной и доступной. + +3. Благодарю Тен Бао (Teng Bao), Ци Бао (Qi Bao) и Фей Бао (Fei Bao) за креа- тивное название книги, которое навевает приятные воспоминания о на- писании первой строки кода «Hello World!». + +4. Благодарю Сяо Цюань (Xiao Quan) за профессиональную помощь в во- просах интеллектуальной собственности, что сыграло важную роль в со- вершенствовании этой открытой книги. + +5. Благодарю Су Тун (Su Tong) за дизайн обложки и логотипа книги, а также за терпение при многократных исправлениях по моим просьбам. + +6. Благодарю \@squidfunk за советы по оформлению и за разработку от- крытой темы документации Material-for-MkDocs. + +> В процессе написания я ознакомился с множеством учебников и статей по структурам данных и алгоритмам. Эти работы послужили отличным образцом для этой книги, обеспечив ее точность и качество. Выражаю благодарность всем преподавателям и предшественникам за их выдающийся вклад! +> +> Настоящая книга пропагандирует метод обучения, сочетающий умственную и практическую деятельность, на который меня вдохновила книга Dive into Deep Learning («Погружение в глубокое обучение», на англ. языке). Настоятель- но рекомендую эту замечательную работу всем читателям. +> +> **Сердечно благодарю своих родителей, ведь именно ваша постоянная поддержка и ободрение дали мне возможность заняться этим увлека- тельным делом.** + +#### как использовать эту книгу + +### Стиль изложения + +- Главы, имеющие символ \* после заголовка, являются дополнительными и содержат более сложный материал. Если у вас ограничено время, мож- но их пропустить. + +- Профессиональные термины выделяются полужирным шрифтом (в печат- ной и PDF-версии) или подчеркиванием (в веб-версии), например **массив** (array). Рекомендуется запоминать их для удобства чтения литературы. + +- Важные моменты и обобщающие фразы выделяются **полужирным шрифтом**, на такие тексты следует обращать особое внимание. + +- При упоминании терминов, различающихся в разных языках програм- мирования, в качестве стандарта используется Python, например None для обозначения «пустого значения». + +- В некоторых местах книга отходит от стандартов комментирования про- граммного кода ради более компактного оформления. Комментарии де- лятся на три типа: заголовочные, содержательные и многострочные. + +> \"\"\" Заголовочные комментарии, используются для обозначения функций, классов, тестовых примеров и т.д. \"\"\" +> +> \# Содержательные комментарии, используются для пояснения кода. +> +> \"\"\" +> +> Многострочные комментарии. \"\"\" + +### Эффективное обучение с помощью анимированных иллюстраций + +> Видео и изображения обладают более высокой плотностью информации и структурированностью по сравнению с текстом, что облегчает понимание. В этой книге **ключевые и сложные моменты в основном представлены в виде анимированных иллюстраций**, а текстовая информация служит по- яснением и дополнением. +> +> Если какой-либо раздел в книге сопровождается анимационной иллюстра- цией, как на рис. 0.2, **используйте иллюстрацию в качестве основного ис- точника информации, а текст -- в качестве вспомогательного**. + +![](ru/docs/assets/media/image36.jpeg){width="5.06667760279965in" height="3.4239577865266844in"} + +> **Рис. 0.2.** Пример анимационной иллюстрации. Шаг 1 +> +> ![](ru/docs/assets/media/image37.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +![](ru/docs/assets/media/image38.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +> **Рис. 0.2.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image39.jpeg){width="5.066665573053369in" height="3.4239577865266844in"} + +![](ru/docs/assets/media/image40.jpeg){width="5.066674321959755in" height="3.4239577865266844in"} + +> **Рис. 0.2.** *Окончание*. Шаги 4--5 + +### Углубление понимания через практику написания кода + +> Сопроводительный код размещен в репозитории GitHub, **он содержит тесто- вые примеры и может быть запущен одним нажатием кнопки**, как по- казано на рис. 0.3. + +![](ru/docs/assets/media/image41.jpeg){width="5.0666863517060365in" height="3.4239577865266844in"} + +> **Рис. 0.3.** Запуск примера кода +> +> Если позволяет время, **рекомендуется самостоятельно набирать код**. Если время ограничено, по крайней мере **просмотрите и выполните весь код**. +> +> Процесс написания кода приносит больше пользы, чем его чтение. **Настоя- щее обучение -- это обучение на практике**. +> +> Предварительная подготовка для запуска кода включает три этапа. + +1. **Установка локальной среды программирования**. Следуйте инструк- циям программы установки. Если среда уже установлена, этот шаг мож- но пропустить. + +2. **Клонирование или загрузка репозитория кода**. Перейдите в репози- торий GitHub. Если у вас установлена утилита Git, можно клонировать репозиторий с помощью следующей команды: + +> git clone https://github.com/krahets/hello-algo.git +> +> Либо можно нажать кнопку **Download ZIP** (Скачать ZIP-архив), как показа- но на рис. 0.4, загрузить архив с кодом и затем распаковать его на локальном компьютере. +> +> ![](ru/docs/assets/media/image43.jpeg) +> +> **Рис. 0.4.** Клонирование репозитория и загрузка кода + +3. **Запуск исходного кода**. Если для блока кода в книге указано имя фай- ла, этот файл можно найти в папке codes репозитория, как показано на рис. 0.5. Исходный код можно запустить одним нажатием, что поможет вам сэкономить время на отладку и сосредоточиться на изучении ма- териала. + +![](ru/docs/assets/media/image45.jpeg) + +> **Рис. 0.5.** Блоки кода и соответствующие файлы исходного кода +> +> Помимо локального запуска, **в веб-версии книги код на Python можно выполнить в визуальной среде** (реализовано на основе pythontutor). Для этого нажмите кнопку **Визуализировать выполнение** под блоком кода, как показано на рис. 0.6. Также в раскрывшемся окне можно нажать кнопку **От- крыть в полноэкранном режиме** для более удобного просмотра1. + +![](ru/docs/assets/media/image46.jpeg){width="5.122829177602799in" height="3.12in"} + +> **Рис. 0.6.** Визуальное выполнение кода Python + +### Совместное развитие через вопросы и обсуждения + +> При чтении книги не оставляйте без внимания непонятные моменты. **Мы при- зываем вас задавать вопросы в разделе комментариев**, и я вместе с колле- гами постараюсь ответить вам в течение двух дней. +> +> В конце каждой главы веб-версии книги предусмотрено место для коммен- тариев, как показано на рис. 0.7. Рекомендуется уделять внимание содержимо- му этой области. С одной стороны, это позволит вам понять, с какими пробле- мами сталкиваются другие читатели, что поможет выявить пробелы в знаниях и стимулировать более глубокое понимание. С другой стороны, мы надеемся, что вы будете отвечать на вопросы других участников и делиться своими мне- ниями. +> +> 1 Функция визуального выполнения кода доступна только в китайской веб-версии книги -- *Прим. перев*. +> +> ![](ru/docs/assets/media/image47.jpeg){width="5.066666666666666in" height="3.4239577865266844in"} +> +> **Рис. 0.7.** Пример раздела комментариев + +### Дорожная карта изучения алгоритмов + +> Процесс изучения структур данных и алгоритмов можно разделить на три этапа. + +1. **Введение в алгоритмы**. Необходимо ознакомиться с особенностями и применением различных структур данных, изучить принципы, про- цессы, назначение и эффективность различных алгоритмов. + +2. **Решение алгоритмических задач**. Рекомендуется начинать с популяр- ных задач и решить не менее 100 из них, чтобы познакомиться с основ- ными алгоритмическими проблемами. При первом решении задач вы можете столкнуться с так называемым забвением знаний -- не беспокой- тесь, это нормально. Следуйте при повторении задач кривой забывания Эббингауза, обычно после 3--5 циклов повторения они хорошо запоми- наются. Рекомендуемые списки задач и планы решения можно найти в **этом репозитории GitHub**. + +3. **Построение системы знаний**. В процессе обучения можно читать статьи по алгоритмам, изучать типичные решения и учебники по алгоритмам, чтобы постоянно обогащать систему знаний. В решении задач можно при- менять продвинутые стратегии, такие как классификация по темам, множе- ственные решения одной задачи или одно решение для множества задач. Советы по этим техникам обучения можно найти в различных сообществах. + +> Эта книга в основном охватывает первый этап и призвана помочь вам эф- фективно подготовиться ко второму и третьему этапам обучения, как показа- но на рис. 0.8. + +1. Резюме ❖ **23** + +> ![](ru/docs/assets/media/image49.jpeg)*Этап 1* +> +> **Базовые сведения** +> +> *Этап 2* +> +> **Тренировка навыков** +> +> *Этап 3* +> +> **Построение системы знаний** +> +> **Решение алгоритмических задач** +> +> **Периодическое повторение** *Повторное решение одной и той же задачи через определенное время* +> +> *для формирования долговременной памяти и углубления понимания* +> +> **Активное обобщение** *Систематизация содержа*- *ния, выявление законо*- +> +> *мерностей для построения целостной системы знаний* + +#### резюме + +> **Рис. 0.8.** Дорожная карта изучения алгоритмов + +- Основная аудитория книги -- новички в изучении алгоритмов. Если у вас уже есть определенная база, книга поможет систематизировать имею- щиеся знания об алгоритмах, а исходный код послужит инструменталь- ной библиотекой для решения задач. + +- Содержание книги включает три основные части -- анализ сложности, структуры данных и алгоритмы -- и охватывает большинство тем в этой области. + +- Для новичков в алгоритмах крайне важно изучить начальные разделы книги, чтобы избежать множества ошибок в будущем. + +- Анимированные иллюстрации в книге обычно используются для пред- ставления ключевых и сложных аспектов. При чтении книги следует уде- лять этим материалам большое внимание. + +- Практика -- лучший способ изучения программирования. Настоятельно рекомендуется запускать исходный код и самостоятельно писать про- граммы. + +- В веб-версии книги каждая глава имеет область комментариев, где вы можете задавать вопросы и делиться своим мнением. + +> Глава 1 + +# Введение в алгоритмы + +![](ru/docs/assets/media/image50.jpeg){width="3.8117727471566054in" height="4.595624453193351in"} + +#### Алгоритмы повсюду + +> Говоря об алгоритмах, естественно вспомнить о математике. Однако на самом деле многие алгоритмы не связаны со сложной математикой, а больше полага- ются на базовую логику, которая повсеместно встречается в нашей повседнев- ной жизни. +> +> Прежде чем углубиться в обсуждение алгоритмов, стоит упомянуть интерес- ный факт: **вы уже точно освоили множество алгоритмов и привыкли при- менять их в повседневной жизни**. Далее приведем несколько конкретных примеров, чтобы подтвердить этот факт. +> +> **Пример 1**: **поиск в словаре**. В словаре все слова упорядочены по алфавиту. Предположим, нам нужно найти слово, начинающееся на букву «r». Обычно для этого нужно выполнить следующие действия (см. рис. 1.1): + +1) открыть словарь примерно на половине страниц и посмотреть, какая буква является первой на этой странице, -- предположим, это буква «m»; + +2) поскольку в алфавите буква «r» идет после «m», исключаем первую по- ловину словаря, и область поиска сужается до второй половины; + +3) продолжаем повторять шаги 1 и 2, пока не найдем страницу, где первой буквой слов будет «r». + +![](ru/docs/assets/media/image52.jpeg) + +> **Рис. 1.1.** Этапы поиска в словаре. Шаг 1 +> +> ![](ru/docs/assets/media/image54.jpeg) + +![](ru/docs/assets/media/image56.jpeg) + +> **Рис. 1.1.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image58.jpeg) + +![](ru/docs/assets/media/image60.jpeg) + +> **Рис. 1.1.** *Окончание*. Шаги 4--5 +> +> Навык поиска в словаре, которым владеет каждый школьник, на самом деле является известным алгоритмом двоичного поиска. С точки зрения структуры данных словарь можно рассматривать как отсортированный массив. С точки зрения алгоритма последовательность операций по поиску в словаре можно считать двоичным поиском. +> +> **Пример 2**: **упорядочивание карт**. Во время игры в карты необходимо каж- дый раз упорядочивать карты в руке от меньшего к большему. Для этого нужно выполнить следующие действия (см. рис. 1.2): + +1) разделить карты на упорядоченную и неупорядоченную части, предпо- лагая, что изначально самая левая карта уже упорядочена; + +2) из неупорядоченной части извлечь одну карту и вставить ее в правиль- ное место в упорядоченной части. После этого две самые левые карты станут упорядоченными; + +3) ![](ru/docs/assets/media/image62.jpeg)повторять шаг 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 руб. + +4. Взять самую крупную из оставшихся купюр в 1 руб. Остаток составит 1 − 1 = 0 руб. + +5. Завершить выдачу сдачи, схема: 20 + 10 + 1 = 31 руб. + +![](ru/docs/assets/media/image64.jpeg) + +> **Рис. 1.3.** Этапы выдачи сдачи +> +> В этих шагах мы на каждом этапе выбираем наилучший вариант (используя купюры наибольшего номинала), в итоге получая оптимальную схему сдачи. С точки зрения структуры данных и алгоритмов этот метод по своей сути яв- ляется жадным алгоритмом. +> +> От приготовления блюда до межзвездных путешествий − решение практи- чески любой задачи неразрывно связано с алгоритмами. Появление компью- теров позволило нам с помощью программирования хранить структуры дан- ных в памяти, а также писать код для вызовов к центральному и графическому процессору для выполнения алгоритмов. Таким образом, мы можем перено- сить задачи из реальной жизни в компьютер, решая различные сложные про- блемы более эффективно. + +#### что такое алгоритм + +1. **Определение алгоритма** + +> *Алгоритм* -- это набор инструкций или шагов, предназначенных для решения конкретной задачи за ограниченное время, обладающий следующими свой- ствами: + +1) задача четко определена, включает ясные определения входных и вы- ходных данных; + +2) обладает осуществимостью, может быть выполнен за ограниченное ко- личество шагов, времени и памяти; + +3) каждый шаг имеет определенное значение, при одинаковых входных данных и условиях выполнения результат всегда будет одинаковым. + +### Определение структуры данных + +> *Структура данных* -- это способ организации и хранения данных, включающий содержимое данных, их взаимосвязи и методы операций с ними. Структура данных преследует следующие цели: + +1) минимизацию занимаемого пространства для экономии памяти ком- пьютера; + +2) максимально быструю обработку данных, включая доступ, добавление, удаление и обновление данных; + +3) обеспечение простого представления данных и логической информации для эффективного выполнения алгоритмов. + +> **Проектирование структуры данных** -- **это процесс, полный компромис- сов**. Если вы хотите улучшить один аспект, часто приходится идти на уступки в другом. Приведем два примера. + +1. Связный список, по сравнению с массивом, более удобен для добавления и удаления данных, но имеет проблемы со скоростью доступа к данным. + +2. Граф, по сравнению со связным списком, предоставляет более богатую логическую информацию, но требует большего объема памяти. + +### Взаимосвязь структур данных и алгоритмов + +> Структуры данных и алгоритмы тесно взаимосвязаны, что проявляется в сле- дующих трех аспектах (см. рис. 1.4): + +1) структуры данных являются основой алгоритмов. Они обеспечивают структурированное хранение данных и методы их обработки; + +2) алгоритмы оживляют структуры данных. Сами по себе структуры дан- ных лишь хранят информацию, но в сочетании с алгоритмами они по- зволяют решать конкретные задачи; + +3) алгоритмы можно реализовать на основе различных структур данных, однако эффективность их выполнения может значительно различаться. Поэтому выбор подходящей структуры данных является ключевым факто- ром. + + + +2. Что такое алгоритм ❖ **31** + +![](ru/docs/assets/media/image66.jpeg) + +> **Рис. 1.4.** Взаимосвязь структур данных и алгоритмов +> +> Структуры данных и алгоритмы подобны конструктору, как показано на рис. 1.5. Комплект конструктора, помимо множества деталей, содержит так- же подробную инструкцию по сборке. Следуя этой инструкции шаг за шагом, можно собрать красивую модель. + +![](ru/docs/assets/media/image67.jpeg){width="5.09092738407699in" height="3.15in"} + +> **Рис. 1.5.** Сборка конструктора +> +> Подробное описание аналогии с конструктором представлено в табл. 1.1. +> +> **Таблица 1.1.** Сравнение структур данных и алгоритмов с конструктором +> +> **Структуры данных и алгоритмы Конструктор** +> +> Входные данные Несобранные детали конструктора +> +> Структура данных Организация деталей конструктора, включая фор- му, размер, способы соединения и т. д. +> +> Алгоритм Последовательность действий по сборке деталей в целевую модель +> +> Выходные данные Собранная модель конструктора +> +> Стоит отметить, что структуры данных и алгоритмы не зависят от языка программирования. Именно поэтому данная книга предлагает их реализации на различных языках. + +#### резюме + +- Алгоритмы повсеместно присутствуют в нашей повседневной жизни и не являются недосягаемыми сложными знаниями. На самом деле мы уже освоили множество алгоритмов, которые помогают решать различ- ные жизненные задачи. + +- Принцип поиска в словаре соответствует алгоритму двоичного поис- ка. Бинарный поиск иллюстрирует важную идею алгоритмов «разделяй и властвуй». + +- Процесс сортировки карт в колоде очень похож на алгоритм сортировки вставками, который хорошо подходит для сортировки небольших набо- ров данных. + +- Процесс размена валюты по своей сути является жадным алгоритмом, в котором на каждом этапе принимается наилучшее на данный момент решение. + +- Алгоритм представляет собой набор инструкций или шагов, предназна- ченных для решения конкретной задачи в ограниченное время, а струк- тура данных -- это способ организации и хранения данных в компьютере. + +- Структуры данных и алгоритмы тесно связаны. Структуры данных явля- ются основой для алгоритмов, а алгоритмы оживляют структуры данных. + +- Структуры данных и алгоритмы можно сравнить с конструктором: де- тали конструктора представляют данные, их форма и способы соедине- ния -- структуры данных, а этапы сборки конструктора соответствуют алгоритмам. + +### Вопросы и ответы + +> 1.3. Резюме ❖ **33** +> +> **Вопрос**. Я программист, и я никогда не использовал алгоритмы для решения задач в своей повседневной работе, поскольку часто используемые алгорит- мы уже встроены в языки программирования и их можно использовать на- прямую. Означает ли это, что задачи, с которыми мы сталкиваемся на работе, не требуют применения алгоритмов? +> +> **Ответ**. Если сравнить конкретные профессиональные навыки с приемами в боевых искусствах, то базовые дисциплины скорее напоминают «внутрен- нюю силу». +> +> Я считаю, что изучение алгоритмов (и других базовых дисциплин) важно не для того, чтобы реализовывать их с нуля в работе, а для того, чтобы на осно- ве полученных знаний принимать профессиональные решения и оценки при решении задач, тем самым повышая общее качество работы. Простой пример: каждый язык программирования имеет встроенные функции сортировки. +> +> Если бы мы не изучали структуры данных и алгоритмы, то, получив любые данные, мы, возможно, просто передали бы их этой функции сортировки. Все работает гладко, производительность хорошая, и на первый взгляд проблем нет. Однако если мы изучили алгоритмы, то знаем, что временная сложность встроенной функции сортировки составляет *O*(*n* log *n*). Если же данные пред- ставлены целыми числами фиксированной разрядности (например, номерами студентов), то можно использовать более эффективный метод поразрядной сортировки, снизив временную сложность до *O*(*nk*), где *k* -- это количество раз- рядов. При больших объемах данных экономия времени выполнения может привести к значительным преимуществам, таким как снижение затрат и улуч- +> +> шение пользовательского опыта. +> +> В инженерной практике множество задач трудно решить оптимальным об- разом, и многие из них решаются «как-то». Сложность задачи зависит как от ее природы, так и от уровня знаний и опыта человека, который ее анализирует. Чем более полными знаниями и большим опытом обладает человек, тем глубже он может проанализировать проблему и тем изящнее может быть ее решение. +> +> Глава 2 + +# Анализ сложности + +![](ru/docs/assets/media/image68.jpeg){width="3.71875656167979in" height="4.8125in"} + +> 2.1. Оценка эффективности алгоритмов ❖ **35** + +#### Оценка эффективности алгоритмов + +> В процессе разработки алгоритмов мы стремимся к достижению следующих целей: + +1) **найти решение задачи**: алгоритм должен надежно находить правиль- ное решение задачи в заданных пределах входных данных; + +2) **найти оптимальное решение**: для одной и той же задачи может суще- ствовать несколько решений, и мы стремимся найти максимально эф- фективный алгоритм. + +> Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта: + +1) **временную эффективность**: продолжительность выполнения алго- ритма; + +2) **пространственную эффективность**: объем памяти, занимаемой алго- ритмом. + +> В двух словах, **наша цель -- разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процес- сом их разработки и оптимизации. +> +> Методы оценки эффективности делятся на два типа: практическое тестиро- вание и теоретическую оценку. + +1. **Практическое тестирование** + +> Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же зада- чу, и необходимо сравнить их эффективность. Самый прямой метод -- это за- пустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения. +> +> С одной стороны, **сложно исключить влияние факторов тестовой сре- ды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных процессорах. Если алгоритм интен- сивно использует память, его производительность будет выше на высоко- производительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, и потребуется тестирова- ние на различных платформах для получения средней эффективности, что крайне затруднительно. +> +> С другой стороны, **проведение полного тестирования требует значи- тельных ресурсов**. С изменением объема входных данных алгоритмы демон- стрируют разную эффективность. Например, при небольшом объеме данных алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме данных результат может быть противоположным. Следовательно, для полу- чения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов. + +### Теоретическая оценка + +> Из-за значительных ограничений практического тестирования можно рас- смотреть возможность оценки эффективности алгоритмов только с помощью математических расчетов. Этот метод называется анализом асимптотической сложности или просто анализом сложности. +> +> Анализ сложности позволяет отразить зависимость между ресурсами вре- мени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и простран- ства, необходимых для выполнения алгоритма, по мере увеличения раз- мера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента. + +1. Ресурсы времени и пространства соответствуют временной сложности и пространственной сложности. + +2. «По мере увеличения размера входных данных» означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных. + +3. Тенденция роста времени и пространства указывает, что анализ слож- ности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста. + +> **Анализ сложности преодолевает недостатки метода практического те- стирования**, что выражается в следующих аспектах: + +1) он не требует фактического выполнения кода, что делает его более эко- логичным и энергосберегающим; + +2) он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения; + +3) он может продемонстрировать эффективность алгоритма при различ- ных объемах данных, особенно при больших объемах. + +> Анализ сложности предоставляет нам мерило оценки эффективности ал- горитмов, позволяя измерять время и ресурсы, необходимые для выполне- ния конкретного алгоритма, а также сравнивать эффективность различных алгоритмов. +> +> Сложность -- это математическое понятие, которое новичкам может по- казаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуж- дая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти. +> +> Таким образом, перед погружением в изучение структур данных и алго- ритмов рекомендуется получить базовое представление об анализе слож- ности, чтобы иметь возможность выполнять хотя бы базовую оценку их эф- фективности. + +#### итерация и рекурсия + +> В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к об- суждению временной и пространственной сложности, рассмотрим, как реа- лизовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию. + +### Итерации + +> *Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение опреде- ленного участка кода, пока выполняется определенное условие. + +##### Цикл 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*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в сле- дующем разделе. +> +> ![](ru/docs/assets/media/image70.png) + +##### Цикл while + +> **Рис. 2.1.** Блок-схема функции суммирования +> +> Подобно циклу 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 можно свободно управлять инициализацией и обнов- лением условной переменной. +> +> Например, в следующем коде условная переменная *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 **более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи. + +##### Вложенные циклы + +> Внутрь одной циклической структуры можно вложить другую, например используя два цикла 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 +> +> В этом случае количество выполненных действий пропорционально *n*2, или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости. +> +> Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической за- висимости, зависимости четвертой степени и т. д. +> +> ![](ru/docs/assets/media/image72.png) + +### Рекурсия + +> **Рис. 2.2.** Блок-схема вложенного цикла +> +> *Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа. + +1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения. + +2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя ре- зультаты каждого уровня. + +> С точки зрения реализации рекурсивный код включает три основных элемента. + +1. **Условие завершения**: используется для определения момента перехода от вызова к возврату. + +2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами. + +3. **Возврат результата**: соответствует возврату, возвращает результат те- кущего уровня рекурсии на предыдущий уровень. + +> Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*. +> +> \# === File: recursion.py === +> +> def recur(n: int) -\> int: \"\"\" Рекурсия.\"\"\" +> +> \# Условие завершения. if n == 1: +> +> return 1 +> +> \# Вызов: рекурсивный вызов. res = recur(n - 1) +> +> \# Возврат: возврат результата. return n + res + +![](ru/docs/assets/media/image74.png) + +> **Рис. 2.3.** Рекурсивный вызов функции суммирования +> +> Хотя с точки зрения вычислений итерация и рекурсия могут давать оди- наковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач. + +- **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи. + +- **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная за- дача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно). + +> Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*. + +- **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы полу- чить итоговое значение *f*(*n*). + +- **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) = + +> *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1. + +##### Стек вызовов + +> Каждый раз, когда рекурсивная функция вызывает саму себя, система выделя- ет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия. + +1. Контекстные данные функции хранятся в области памяти, называемой про- странством стекового кадра, и освобождаются только после возврата функ- ции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**. + +2. Рекурсивный вызов функции создает дополнительные накладные расходы. + +###### Поэтому рекурсия обычно менее эффективна по времени, чем цикл. + +> До срабатывания условия завершения одновременно существует *n* невоз- вращенных рекурсивных функций, как показано на рис. 2.4. Число *n* называет- ся глубиной рекурсии. + +![](ru/docs/assets/media/image76.jpeg) + +> **Рис. 2.4.** Глубина рекурсивного вызова +> +> На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека. + +##### Хвостовая рекурсия + +> Интересно, что если рекурсивный вызов происходит на последнем шаге перед возвратом функции, то компилятор или интерпретатор может оптимизиро- вать этот вызов, сделав его по эффективности использования памяти сопоста- вимым с итерацией. Это называется хвостовой рекурсией. + +- **Обычная рекурсия**: когда функция возвращается на предыдущий уро- вень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова. + +- **Хвостовая рекурсия**: рекурсивный вызов является последней операци- ей перед возвратом функции, что означает, что после возврата на преды- дущий уровень не требуется выполнять другие операции, поэтому систе- ме не нужно сохранять контекст предыдущей функции. + +> В качестве примера вычисления суммы 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. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения опе- рации суммирования у них различается. + +- **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить опера- цию суммирования. + +- **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, процесс возврата требует только последовательного возврата. + +![](ru/docs/assets/media/image78.jpeg) + +> **Рис. 2.5.** Процесс выполнения хвостовой рекурсии + +##### Дерево рекурсии + +> При решении задач, связанных с алгоритмами типа «разделяй и властвуй», ре- курсия зачастую оказывается более интуитивной и читабельной, чем итера- ция. Рассмотрим в качестве примера последовательность Фибоначчи. +> +> Обозначив *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. +> +> По своей сути рекурсия отражает парадигму мышления «разбиение зада- чи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной. +> +> С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое программирование, прямо или косвенно используют этот подход. +> +> С точки зрения **структур данных** рекурсия естественно подходит для реше- ния задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй». +> +> ![](ru/docs/assets/media/image79.jpeg){width="5.095693350831146in" height="2.08in"} +> +> **Рис. 2.6.** Рекурсивное дерево последовательности Фибоначчи + +### Сравнение + +> Подводя итог, можно сказать, что итерация и рекурсия различаются по реали- зации, производительности и применимости, как показано в табл. 2.1. +> +> **Таблица 2.1.** Сравнение итерации и рекурсии + ++-----------------------+---------------------------------+-----------------------------------------+ +| | > **Итерация** | > **Рекурсия** | ++=======================+=================================+=========================================+ +| > Способ реализации | > Циклическая структура | > Функция вызывает саму себя | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Временная | > Обычно высокая | > Каждый вызов функции создает | ++-----------------------+---------------------------------+-----------------------------------------+ +| > эффективность | > эффективность, нет | > затраты | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > затрат на вызов функции | | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Использование | > Обычно используется | > Накопление вызовов функции может | ++-----------------------+---------------------------------+-----------------------------------------+ +| > памяти | > фиксированный объем | > использовать значительное количество | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > памяти | > пространства стека | ++-----------------------+---------------------------------+-----------------------------------------+ +| > Сфера использования | > Подходит для простых | > Подходит для разбиения на подзадачи; | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > циклических задач, код | > для структур деревья, графы; алгорит- | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > интуитивно понятен | > мов «разделяй и властвуй», возврат | ++-----------------------+---------------------------------+-----------------------------------------+ +| | > [и]{.smallcaps} хорошо читаем | > и т. д.; структура кода проста и ясна | ++-----------------------+---------------------------------+-----------------------------------------+ + +> Какова же внутренняя связь между итерацией и рекурсией? В рассмотрен- ном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу сте- ка «первым пришел -- последним вышел»**. +> +> Фактически такие термины рекурсии, как «вызов стека» и «пространство стекового кадра», уже намекают на тесную связь между рекурсией и стеком. + +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) для некоторых сложных задач моделирование поведения системного вызова стека может быть крайне сложным. + +> В общем, выбор между итерацией и рекурсией зависит от природы конкрет- ной задачи. В программной практике крайне важно взвешивать преимуще- ства и недостатки обоих подходов и выбирать наиболее подходящий метод в зависимости от ситуации. + +#### временная сложность + +> *Время выполнения* является наглядным и точным показателем эффективности алгоритма. Что же нам нужно сделать, чтобы точно оценить время выполне- ния кода? + +1. **Определить платформу выполнения**, включая аппаратную конфи- гурацию, язык программирования, системную среду и т. д., так как эти факторы влияют на эффективность выполнения кода. + +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 нс. +> +> Согласно вышеописанному методу можно определить, что время выполне- ния алгоритма равно (6*n* + 12) нс: +> +> 1 + 1 + 10 + (1 + 5) × *n* = 6*n* + 12. +> +> Однако на практике подсчет времени выполнения алгоритма не является ни разумным, ни реалистичным. Во-первых, мы не хотим связывать оценочное время с конкретной платформой выполнения, так как алгоритм должен рабо- тать на различных платформах. Во-вторых, очень трудно узнать время выпол- нения каждой операции, и это значительно усложняет процесс оценки. + +### Тенденция роста времени + +> Анализ временной сложности не оценивает время выполнения алгоритма, **а исследует тенденцию роста времени выполнения алгоритма по мере увеличения объема данных**. +> +> Понятие «тенденция роста времени» довольно абстрактно, и для его лучше- го понимания рассмотрим пример. Предположим, что размер входных данных равен *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) +> +> На рис. 2.7 изображена схема временной сложности функций этих трех ал- горитмов. + +- Алгоритм A содержит только одну операцию печати, и время выполне- ния алгоритма не увеличивается с ростом *n*. Временная сложность этого алгоритма называется константной. + +- В алгоритме B операция печати выполняется в цикле *n* раз, и время вы- полнения алгоритма увеличивается линейно с ростом *n*. Временная сложность этого алгоритма называется линейной. + +- В алгоритме C операция печати выполняется в цикле 1 000 000 раз, и, хотя время выполнения очень долгое, оно не зависит от размера входных данных *n*. Поэтому временная сложность C такая же, как у A, и остается константной. + +![](ru/docs/assets/media/image81.jpeg) + +> **Рис. 2.7.** Тенденция роста времени для алгоритмов A, B и C +> +> Какие особенности имеет анализ временной сложности по сравнению с пря- мым подсчетом времени выполнения алгоритма? + +- **Временная сложность позволяет эффективно оценить эффектив- ность алгоритма**. Например, время выполнения алгоритма B увеличи- вается линейно, и при *n* \> 1 он медленнее алгоритма A, а при *n* \> 1 000 000 медленнее алгоритма C. На самом деле, если размер входных данных *n* достаточно велик, алгоритм с константной сложностью всегда будет луч- ше, чем алгоритм с линейной сложностью, и это и есть суть тенденции роста времени. + +- **Метод вычисления временной сложности проще**. Очевидно, что платформа выполнения и типы вычислительных операций не связаны с тенденцией роста времени выполнения алгоритма. Поэтому в анали- зе временной сложности можно просто считать, что время выполнения всех вычислительных операций одинаково и равно единичному време- ни, что позволяет упростить статистику времени выполнения вычисли- тельных операций до статистики количества вычислительных операций, значительно снижая сложность оценки. + + - **Однако временная сложность имеет определенные ограничения**. Например, хотя временная сложность алгоритмов A и C одинакова, их фак- тическое время выполнения значительно отличается. Аналогично, хотя временная сложность алгоритма B выше, чем у C, при малых значениях *n* алгоритм B явно лучше C. В таких случаях часто трудно оценить эффек- тивность алгоритма только по временной сложности. Тем не менее, не- смотря на эти проблемы, анализ сложности остается наиболее эффектив- ным и распространенным методом оценки эффективности алгоритмов. + +### Асимптотическая верхняя граница функции + +> Пусть дан входной размер *n* для функции: +> +> def algorithm(n: int): a = 1 \# +1. +> +> a = a + 1 \# +1. +> +> a = a \* 2 \# +1. +> +> \# Цикл n итераций. +> +> for i in range(n): \# +1. print(0) \# +1. +> +> Пусть *T*(*n*) -- это количество операций алгоритма, являющееся функцией от размера входных данных *n*. Тогда количество операций для вышеуказанной функции равно: +> +> *T*(*n*) = 3 + 2*n*. +> +> *T*(*n*) является линейной функцией, что указывает на линейную тенденцию роста времени выполнения, следовательно, у алгоритма временная сложность линейного порядка. +> +> Линейная временная сложность обозначается как *O*(*n*), этот математиче- ский символ называется «О» большое и представляет асимптотическую верх- нюю границу функции *T*(*n*). +> +> Анализ временной сложности, по сути, является вычислением асимптоти- ческой верхней границы количества операций *T*(*n*), которая имеет четкое ма- тематическое определение. +> +> Вычисление асимптотической верхней границы заключается в нахождении функции *f*(*n*) − такой, что при стремлении *n* к бесконечности *T*(*n*) и *f*(*n*) нахо- дятся на одном уровне роста, отличаясь лишь на множитель константы *c*, как показано на рис. 2.8. +> +> ![](ru/docs/assets/media/image83.jpeg) +> +> **Рис. 2.8.** Асимптотическая верхняя граница функции + +### Методы вычисления + +> Асимптотическая верхняя граница является абстрактным математическим понятием, и, если вы не полностью понимаете его, не стоит беспокоиться. Можно сначала освоить методику вывода, и в процессе практики постепенно осознать его математическое значение. +> +> Согласно определению, если найти функцию *f*(*n*), можно получить времен- ную сложность *O*(*f*(*n*)). Как же определить асимптотическую верхнюю грани- цу *f*(*n*)? В общем случае это делается в два этапа: сначала подсчитывается ко- личество операций, затем определяется асимптотическая верхняя граница. + +##### Подсчет количества операций + +> Для кода это делается путем построчного подсчета сверху вниз. Однако, по- скольку константа *c* в выражении *c* × *f*(*n*) может принимать любое значение, **все коэффициенты и константы в** *T*(*n*) **можно игнорировать**. На основе этого принципа можно сформулировать следующие упрощенные приемы подсчета: + +1) **игнорирование констант в** *T*(*n*). Они не зависят от *n* и поэтому не вли- яют на временную сложность; + +2) **опускание всех коэффициентов**. Например, циклы из 2*n* итераций, 5*n* + +> \+ 1 итераций и т. д. можно упростить до *n* итераций, поскольку коэффи- циенты перед *n* не влияют на временную сложность; + +3) **при вложенных циклах используется умножение**. Общее количество операций равно произведению количества операций внешнего и вну- треннего циклов, при этом для каждого уровня цикла можно применять приемы 1 и 2. + +> Следующую функцию можно использовать для подсчета количества опера- ций с помощью вышеуказанных приемов: +> +> 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*) = 2*n*(*n* + 1) + (5*n* + 1) + 2 Полный подсчет (-.-\|\|\|) +> +> = 2*n*2 + 7*n* + 3 +> +> *T*(*n*) = *n*2 + *n* Упрощенный подсчет (o.O) + +##### Определение асимптотической верхней границы + +> Временная сложность определяется старшей степенью в *T*(*n*). Это связано с тем, что при стремлении *n* к бесконечности старшая степень будет играть до- минирующую роль, а влиянием других членов можно пренебречь. +> +> В табл. 2.2 приведены некоторые гипертрофированные примеры, иллю- стрирующие вывод о том, что «коэффициенты не могут изменить поря- док». Когда *n* стремится к бесконечности, эти константы становятся несу- щественными. +> +> **Таблица 2.2.** Временная сложность для различных количеств операций +> +> **Количество операций** *T***(***n***) Временная сложность** *O***(***f***(***n***))** + +100 000 *O*(1) + +3*n* + 2 *O*(*n*) + +2*n*2 + 3*n* + 2 *O*(*n*2) + +*n*3 + 10000*n*2 *O*(*n*3) + +2*n* + 10000*n*10000 *O*(2*n*) + +### Основные типы + +> Пусть размер входных данных равен *n*, основные типы временной сложности показаны на рис. 2.9 (в порядке от низшего к высшему). +> +> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n* log *n*) \< *O*(*n*2) \< *O*(2*n*) \< *O*(*n*!) +> +> Константная \< Логарифмическая \< Линейная \< Линейно- логарифмическая \< Квадратичная \< Экспоненциальная \< Факториальная +> +> **От низшего к высшему,** +> +> **от лучшего к худшему** + +##### Константная сложность *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 + +##### Линейная сложность *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* -- длина массива или списка. +> +> \# === File: time_complexity.py === +> +> def array_traversal(nums: list\[int\]) -\> int: \"\"\" Линейный порядок (обход массива).\"\"\" count = 0 +> +> \# Количество циклов пропорционально длине массива. for num in nums: +> +> count += 1 return count +> +> Следует отметить, что **размер входных данных *n* необходимо опреде- лять в зависимости от типа входных данных**. Например, в первом при- мере переменная *n* обозначает размер входных данных; во втором примере размер данных определяется длиной массива *n*. + +##### Квадратичная сложность *O*(*n*2) + +> Количество операций *квадратичной сложности* растет с квадратом размера входных данных *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 приведено сравнение трех видов временной сложности: кон- стантной, линейной и квадратичной. + +![](ru/docs/assets/media/image87.jpeg) + +> **Рис. 2.10.** Временная сложность константного, линейного и квадратичного порядка +> +> В качестве примера рассмотрим пузырьковую сортировку: внешний цикл выполняется *n* -- 1 раз, внутренний цикл выполняется *n* -- 1, *n* -- 2, \..., 2, 1 раз, в среднем *n*/2 раз, поэтому временная сложность составляет O((*n* − 1)*n*/2) = O(*n*2). +> +> \# === 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 + +##### Экспоненциальная сложность *O*(2*n*) + +> Деление клеток в биологии является типичным примером *экспоненциально- го роста*: исходное состояние -- 1 клетка, после одного цикла деления их ста- новится 2, после двух циклов -- 4 и т. д. После *n* циклов деления получается 2*n* клеток. +> +> В следующем коде моделируется процесс деления клеток, временная слож- ность этого алгоритма составляет *O*(2*n*), см. рис. 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 +> +> ![](ru/docs/assets/media/image89.jpeg) +> +> **Рис. 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 +> +> Экспоненциальный рост является очень быстрым и часто встречается в ме- тодах полного перебора (грубая сила, возврат и т. д.). Для задач с большим объ- емом данных экспоненциальная сложность неприемлема, обычно требуется использование динамического программирования или жадных алгоритмов. + +##### Логарифмическая сложность *O*(log *n*) + +> В отличие от экспоненциальной, *логарифмическая сложность* отражает прин- цип «каждый цикл сокращается вдвое». Пусть размер входных данных равен *n*, поскольку каждый цикл сокращается вдвое, количество циклов равно log2 *n*, т. е. обратной функции к 2n. +> +> В следующем коде моделируется принцип «каждый цикл сокращается вдвое», временная сложность которого составляет *O*(log2 *n*), или сокращенно *O*(log *n*), см. рис. 2.12. +> +> \# === 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** +> +> **Временная сложность Логарифмическая O(log n)** +> +> ![](ru/docs/assets/media/image91.png)**Рис. 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 +> +> Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии «разделяй и властвуй», отражая идеи разделения на части и упро- щения сложного. Она растет медленно и является идеальной временной слож- ностью после константной. + +##### Линейно-логарифмическая сложность *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*), например быстрой сортировки, сортировки слиянием, пирамидаль- ной сортировки и т. д. +> +> ![](ru/docs/assets/media/image93.jpeg) +> +> **Рис. 2.13.** Временная сложность линейно-логарифмического порядка + +##### Факториальная сложность *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 +> +> ![](ru/docs/assets/media/image95.jpeg) +> +> **Рис. 2.14.** Временная сложность факториала +> +> Следует отметить, что, поскольку при *n* ≥ 4 всегда выполняется *n*! \> 2*n*, фак- ториал растет быстрее, чем экспоненциальная функция, и при больших *n* он становится неприемлемым для практического использования. + +### Худшая, лучшая и средняя временная сложность + +> **Эффективность алгоритма во времени часто не является фиксирован- ной**, **а зависит от распределения входных данных**. Предположим, что на вход подается массив 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 в случайном порядке.\"\"\" +> +> \# Генерация массива 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*). +> +> Однако для более сложных алгоритмов вычисление средней временной сложности часто затруднительно, так как сложно проанализировать общее ма- тематическое ожидание при распределении данных. В таких случаях обычно используется худшая временная сложность в качестве критерия оценки эф- фективности алгоритма. + +#### пространственная сложность + +> Пространственная сложность используется для измерения тенденции роста занимаемой алгоритмом памяти по мере увеличения объема данных. Это по- нятие очень похоже на временную сложность, только вместо времени выпол- нения рассматривается занимаемая память. + +### Пространство алгоритма + +> Память, используемая алгоритмом в процессе выполнения, главным образом включает следующие виды. + +- **Входное пространство**: используется для хранения входных данных алгоритма. + +- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных в процессе выполнения алгоритма. + +- **Выходное пространство**: используется для хранения выходных данных алгоритма. + +> Как правило, пространственная сложность рассчитывается на основе вре- менного пространства и выходного пространства. +> +> Временное пространство можно, в свою очередь, разделить на три части. + +- **Временные данные**: используются для сохранения различных кон- стант, переменных, объектов и т. д. в процессе выполнения алгоритма. + +- **Пространство стека**: используется для сохранения контекстных дан- ных вызываемой функции. При каждом вызове функции система соз- дает фрейм стека на вершине стека, который освобождается после воз- врата функции. + +- **Пространство инструкций**: используется для хранения скомпилиро- ванных инструкций программы, в реальной статистике обычно игнори- руется. + +> При анализе пространственной сложности программы обычно учитывают- ся **временные данные**, **пространство стека и выходные данные**, как по- казано на рис. 2.15. + +![](ru/docs/assets/media/image97.jpeg) + +> **Рис. 2.15.** Пространство, используемое алгоритмом +> +> Ниже приведен пример кода. +> +> 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 \# Выходные данные. + +### Методы вычисления + +> Методы вычисления пространственной сложности аналогичны методам вы- числения временной сложности, только объект статистики изменяется с коли- чества операций на размер используемого пространства. +> +> В отличие от временной сложности **обычно учитывается только наихуд- шая пространственная сложность**. Это связано с тем, что память является жестким требованием, и необходимо убедиться, что для всех входных данных будет зарезервировано достаточное количество памяти. +> +> В следующем примере кода наихудшая пространственная сложность может иметь два значения. + +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) +> +> **В рекурсивных функциях необходимо учитывать статистику про- странства стека.** Рассмотрим следующий код. +> +> 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*). + +### Основные типы + +> Пусть размер входных данных равен *n*, на рис. 2.16 показаны основные типы пространственной сложности (в порядке возрастания). +> +> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n*2) \< *O*(2*n*) +> +> Константная \< Логарифмическая \< Линейная \< Квадратичная \< Экспоненциальная +> +> **От низкой к высокой,** +> +> **от лучшей к худшей** + +##### Константная сложность *O*(1) + +> *Константная сложность* обычно встречается у констант, переменных, объек- тов, количество которых не зависит от размера входных данных *n*. +> +> Следует отметить, что память, занимаемая инициализацией переменных или вызовом функций в цикле, освобождается при переходе к следующему циклу, поэтому она не накапливается, и пространственная сложность остается *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() + +##### Линейная сложность *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) +> +> ![](ru/docs/assets/media/image101.jpeg) +> +> **Рис. 2.17.** Линейная пространственная сложность, создаваемая рекурсивной функцией + +##### Квадратичная сложность *O*(*n*2) + +> *Квадратичная сложность* часто встречается у матриц и графов, количество элементов в которых пропорционально квадрату *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*(*n*2) простран- ства, как показано на рис. 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) +> +> ![](ru/docs/assets/media/image103.jpeg) +> +> **Рис. 2.18.** Квадратичная пространственная сложность рекурсивной функции + +##### Экспоненциальная сложность *O*(2*n*) + +> *Экспоненциальная сложность* часто встречается в двоичных деревьях. Количе- ство узлов в полном двоичном дереве с *n* уровнями равно 2*n* − 1, что занимает *O*(2*n*) пространства, как показано на рис. 2.19. +> +> \# === File: space_complexity.py === +> +> def build_tree(n: int) -\> TreeNode \| None: +> +> \"\"\" Экспоненциальная сложность (создание полного двоичного дерева).\"\"\" if n == 0: +> +> return None root = TreeNode(0) +> +> ![](ru/docs/assets/media/image105.jpeg)root.left = build_tree(n - 1) root.right = build_tree(n - 1) return root +> +> **Рис. 2.19.** Экспоненциальная пространственная сложность полного двоичного дерева + +##### Логарифмическая сложность *O*(log *n*) + +> *Логарифмическая сложность* часто встречается в алгоритмах типа «разделяй и властвуй». Например, в сортировке слиянием, где входной массив длиной *n* на каждой итерации рекурсивно делится пополам и формирует рекурсивное дерево высотой log *n*, используется *O*(log *n*) пространства для стека вызовов. +> +> Или, например, при преобразовании числа в строку если входное значение является положительным целым числом *n*, то количество его цифр равно ⌊log10 *n* ⌋ + 1, что соответствует длине строки, и поэтому пространственная сложность равна *O*(log10 *n* + 1) = *O*(log *n*). + +### 2.4.4 Баланс времени и пространства + +> В идеальных условиях мы стремимся к тому, чтобы временная и простран- ственная сложности алгоритма были оптимальными. Однако на практике од- новременно оптимизировать обе сложности зачастую очень затруднительно. +> +> **Снижение временной сложности обычно требует увеличения про- странственной сложности, и наоборот**. Будем называть подход, при кото- ром нужно пожертвовать памятью для увеличения скорости выполнения ал- горитма, обменом пространства на время. Обратный подход будем называть обменом времени на пространство. +> +> Выбор подхода зависит от того, какой аспект для нас более важен. В боль- шинстве случаев время ценнее пространства, поэтому обмен пространства на время является более распространенной стратегией. Конечно, при больших объемах данных контроль пространственной сложности также очень важен. + +#### резюме + +##### Ключевые моменты + +###### Оценка эффективности алгоритмов: + +- временная и пространственная эффективность являются двумя основ- ными критериями для оценки качества алгоритмов; + +- эффективность алгоритмов можно оценивать с помощью реальных те- стов, однако это сложно из-за влияния тестовой среды и значительных + +> затрат вычислительных ресурсов; + +- анализ сложности позволяет устранить недостатки реальных тестов, ре- зультаты анализа применимы ко всем платформам и могут выявить эф- фективность алгоритма при различных объемах данных. + +###### Временная сложность: + +- временная сложность используется для оценки тенденции изменения времени выполнения алгоритма с увеличением объема данных, что по- зволяет оценивать его эффективность. Однако в некоторых случаях она может работать не так хорошо, например когда объем входных данных мал или временная сложность одинакова, что не позволяет точно срав- нить эффективность алгоритмов; + + + +- худшая временная сложность обозначается символом *O* и соответствует асимптотической верхней границе, отражая уровень роста количества операций *T*(*n*) при стремлении *n* к бесконечности; + +- определение временной сложности включает два этапа: сначала подсчи- тывается количество операций, затем определяется асимптотическая верхняя граница; + +- наиболее распространенные временные сложности от низкой к высо- + +> кой: *O*(1), *O*(log *n*), *O*(*n*), *O*(*n* log *n*), *O*(*n*2), *O*(2*n*) и *O*(*n*!); + +- временная сложность некоторых алгоритмов не является фиксирован- ной и зависит от распределения входных данных. Временная сложность делится на худшую, лучшую и среднюю. Лучшая временная сложность почти не используется, так как для достижения лучшего случая входные данные должны соответствовать строгим критериям; + +- средняя временная сложность отражает эффективность алгоритма при случайных входных данных и наиболее близка к реальной производитель- ности алгоритма. Для расчета средней временной сложности необходимо учитывать распределение входных данных и математическое ожидание. + +###### Пространственная сложность: + +- пространственная сложность аналогична временной сложности и ис- пользуется для оценки тенденции изменения объема памяти, занимае- мой алгоритмом, с увеличением объема данных; + +- память, используемая в процессе выполнения алгоритма, можно разде- лить на входное пространство, временное пространство и выходное про- странство. Обычно при расчете пространственной сложности входное пространство не учитывается. Временное пространство делится на вре- менные данные, пространство стека и пространство инструкций, при- чем пространство стека обычно влияет на пространственную сложность только в рекурсивных функциях; + +- обычно рассматривается только худшая пространственная сложность, т. е. пространственная сложность алгоритма при худших входных дан- ных и в худший момент выполнения; + +- наиболее распространенные пространственные сложности от низкой + +> к высокой: *O*(1), *O*(log *n*), *O*(*n*), *O*(n2) и *O*(2n). + +##### Вопросы и ответы + +> **Вопрос**. Пространственная сложность хвостовой рекурсии равна *O*(1)? +> +> **Ответ**. Теоретически пространственную сложность хвостовой рекурсии можно оптимизировать до *O*(1). Однако большинство языков программиро- вания (например, Java, Python, C++, Go, C# и др.) не поддерживают автомати- ческую оптимизацию хвостовой рекурсии, поэтому обычно считается, что ее пространственная сложность равна *O*(*n*). +> +> **Вопрос**. В чем разница между терминами «функция» и «метод»? +> +> **Ответ**. Функция может выполняться независимо, все параметры передают- ся явно. Метод связан с объектом и неявно передается вызывающему его объ- екту. Он может оперировать данными, содержащимися в экземпляре класса. +> +> Ниже приведены примеры из нескольких распространенных языков про- граммирования: + +- язык C является процедурным языком и не имеет концепции объек- тно ориентированного программирования, поэтому в нем есть только функции. Однако можно создать структуры, чтобы имитировать клас- сы. Функции, связанные со структурами, будут эквивалентны методам в других языках программирования; + +- Java и C# являются объектно ориентированными языками программи- рования, и блоки кода (методы) обычно являются частью какого-либо класса. Статические методы ведут себя как функции, так как они при- вязаны к классу и не могут обращаться к конкретным свойствам экзем- пляра; + +- C++ и Python поддерживают как процедурное программирование (функ- ции), так и объектно ориентированное программирование (методы). + +> **Вопрос**. Отражает ли схема «Типы пространственной сложности» абсолют- ный размер занимаемого пространства? +> +> **Ответ**. Нет, данная схема демонстрирует пространственную сложность, отра- жающую тенденцию роста, а не абсолютный размер занимаемого пространства. Предположим, для *n* = 8 вы можете заметить, что значения каждой кривой не соответствуют значениям функции. Это происходит потому, что каждая кривая содержит постоянную составляющую, которая используется для сжа- +> +> тия диапазона значений до визуально комфортного уровня. +> +> На практике, поскольку обычно постоянная составляющая метода неизвест- на, невозможно выбрать оптимальное решение только на основе сложности при *n* = 8. Однако при *n* = 85 выбор становится очевидным, так как тенденция роста уже доминирует. +> +> Глава 3 + +# Структуры данных + +![](ru/docs/assets/media/image106.jpeg){width="3.630197944006999in" height="4.697916666666667in"} + +#### классификация структур данных + +> К распространенным структурам данных относятся массивы, списки, стеки, очереди, хеш-таблицы, деревья, кучи, графы. Их можно классифицировать по двум измерениям: логической структуре и физической структуре. + +1. **Логическая структура: линейная и нелинейная** + +> **Логическая структура раскрывает логические отношения между элемента- ми данных**. В массивах и списках данные расположены в определенном порядке, что отражает линейные отношения между данными. В деревьях данные располо- жены сверху вниз по уровням, что демонстрирует отношения «предок» и «пото- мок». Графы состоят из узлов и ребер, отражая сложные сетевые отношения. +> +> Логические структуры делятся на две большие категории: линейные и не- линейные, как показано на рис. 3.1. Линейные структуры более интуитивно понятны, поскольку в них данные расположены линейно и логически связаны. Нелинейные структуры, наоборот, представляют собой нелинейное располо- жение элементов данных. + +- **Линейные структуры данных**: массивы, списки, стеки, очереди, хеш- таблицы, в которых элементы связаны последовательно один к одному. + +- **Нелинейные структуры данных**: деревья, кучи, графы, хеш-таблицы. + +> Нелинейные структуры данных можно дополнительно разделить на древо- видные и сетевые. + +- **Древовидные структуры**: деревья, кучи, хеш-таблицы, в которых эле- менты связаны один ко многим. + +- **Сетевые структуры**: графы, в которых элементы связаны многие ко многим. + +![](ru/docs/assets/media/image108.jpeg) + +> **Рис. 3.1.** Линейные и нелинейные структуры данных + +### Физическая структура: + +> **непрерывная и разреженная** +> +> **Во время выполнения программы обрабатываемые данные в основном хранятся в памяти**. На рис. 3.2 изображен модуль оперативной памяти ком- пьютера, где каждый черный чип содержит определенный участок памяти. Память можно представить как огромную таблицу Excel, где каждая ячейка может хранить данные определенного размера. +> +> **Система обращается к данным в целевой позиции через адреса памя- ти**. Компьютер присваивает каждой ячейке таблицы номер по определенным правилам, чтобы обеспечить уникальный адрес памяти для каждого участка, как показано на рис. 3.2. Благодаря этим адресам программа может обращать- ся к данным в памяти. +> +> ![](ru/docs/assets/media/image110.jpeg)Доступное простран- ство памяти +> +> Используемое пространство памяти +> +> Обращение к адресу памяти 0x0012, получение данных +> +> Обращение к адресу памяти 0x0024, получение данных +> +> **Рис. 3.2.** Модуль памяти, пространство памяти, адреса памяти +> +> Память является общим ресурсом для всех программ, и когда участок памя- ти занят одной программой, он обычно не может быть одновременно исполь- зован другими программами. **Поэтому в процессе проектирования струк- тур данных и алгоритмов память занимает важное место**. Например, пи- ковое использование памяти алгоритмом не должно превышать оставшуюся свободную память системы. Если не хватает непрерывных больших участков памяти, выбранная структура данных должна уметь располагаться в разре- женных участках памяти. +> +> **Физическая структура отражает способ хранения данных в памяти компьютера** и делится на хранение в непрерывном пространстве (массивы) и хранение в разреженном пространстве (списки), как показано на рис. 3.3. Физическая структура на низком уровне определяет методы доступа, обновле- ния, добавления и удаления данных. Обе физические структуры демонстриру- ют взаимодополняющие характеристики в отношении временной и простран- ственной эффективности. +> +> ![](ru/docs/assets/media/image112.jpeg)**Непрерывное хранение** +> +> Память для хранения **массива** +> +> является непрерывной +> +> **Распределенное хранение** +> +> Память для хранения **связного списка** +> +> является распределенной +> +> Доступное пространство памяти Память для хранения массива +> +> **Хранение значения узла Хранение** +> +> **указателя узла** +> +> Память для хранения +> +> **узлов связного списка** +> +> **Рис. 3.3.** Хранение в непрерывном и в разреженном пространстве +> +> Следует отметить, что **все структуры данных реализуются на основе массивов, связных списков или их комбинации**. Например, стек и очередь можно реализовать как с использованием массивов, так и с использованием связных списков. Реализация хеш-таблицы может включать как массивы, так и связные списки. + +- **На основе массивов можно реализовать**: стек, очередь, хеш-таблицу, дерево, кучу, граф, матрицу, тензор (массив размерностью ≥ 3) и др. + +- **На основе связных списков можно реализовать**: стек, очередь, хеш- таблицу, дерево, кучу, граф и др. + +> Связный список после инициализации может изменять свою длину в про- цессе выполнения программы, поэтому его также называют динамической структурой данных. Массив после инициализации имеет неизменную длину, поэтому его называют статической структурой данных. Следует отметить, что массив может изменять свою длину путем перераспределения памяти, что придает ему определенную динамичность. + +#### Основные типы данных + +> Когда речь идет о данных в компьютере, мы думаем в первую очередь о тексте, изображениях, видео, аудио, 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 байта −215 215 − 1 0 | ++===============+==========+=============+===================+=================+=============+ +| | > int | > 4 байта −231 231 − 1 0 | ++---------------+----------+-----------------------------------------------------------------+ +| | > long | > 8 байт −263 263 − 1 0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > С плавающей | > float | > 4 байта | > 1.175 × 10--38 | > 3.403 × 1038 | > 0.0f | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| | > double | > 8 байт | > 2.225 × 10--308 | > 1.798 × 10308 | > 0.0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > Символьный | > char | > 2 байта | > 0 | > 216 − 1 | > 0 | ++---------------+----------+-------------+-------------------+-----------------+-------------+ +| > Логический | > bool | > 1 байт | > false | > true | > false | ++---------------+----------+-------------+-------------------+-----------------+-------------+ + +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)\] + +#### кодирование чисел\* + +### Прямой, обратный и дополнительный коды + +> В таблице из предыдущего раздела можно заметить, что во всех целочислен- ных типах отрицательных чисел на одно больше, чем положительных. Напри- мер, диапазон значений byte составляет \[--128, 127\]. Этот факт кажется не со- всем интуитивным, и его внутренняя причина связана с концепциями прямо- го, обратного и дополнительного кодов. +> +> Прежде всего необходимо отметить, что **числа хранятся в компьютере в виде дополнительного кода**. Прежде чем проанализировать причины это- +> +> го, сначала дадим определения всем трем кодам. + +- **Прямой код**: старший бит двоичного представления числа рассматри- вается как знак, где 0 обозначает положительное число, 1 -- отрицатель- ное, остальные биты представляют значение числа. + +- **Обратный код**: обратный код положительного числа совпадает с его прямым кодом, обратный код отрицательного числа получается инвер- + +> сией всех битов прямого кода, кроме знакового. + +- **Дополнительный код**: дополнительный код положительного числа со- впадает с его прямым кодом, дополнительный код отрицательного числа получается добавлением 1 к его обратному коду. + +> На рис. 3.4 изображены методы преобразования прямого, обратного и до- полнительного кодов между собой. + +![](ru/docs/assets/media/image114.jpeg) **Число** + +> **Прямой код** +> +> **Обратный код** +> +> **Дополнительный код** +> +> **Прямой, обратный и дополнительный коды положительных чисел равны** +> +> **Инвертировать все биты, кроме знакового** +> +> **Добавить 1** +> +> **[к]{.smallcaps} обратному коду** +> +> **Прямой, обратный и дополнительный коды отрицательных чисел требуют преобразования** +> +> **Рис. 3.4.** Преобразования прямого, обратного и дополнительного кодов +> +> Хотя прямой код наиболее понятен, он имеет некоторые ограничения. С од- ной стороны, **прямой код отрицательных чисел нельзя напрямую исполь- зовать в вычислениях**. Например, при вычислении 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, **что совпадает с дополнительным кодом положи- тельного нуля**. Таким образом, в дополнительном коде существует только один ноль, и неоднозначность положительного и отрицательного нуля устраняется. +> +> Остается последний вопрос: как получается дополнительное отрицательное число −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*). Умноже- ние и деление можно преобразовать в многократное сложение или вычитание. +> +> Теперь можно подытожить, почему компьютеры используют дополнительный код. Благодаря представлению в дополнительном коде компьютер может обра- батывать сложение положительных и отрицательных чисел с помощью одних и тех же схем и операций без необходимости проектировать специальные аппа- ратные схемы для вычитания, а также без необходимости специально обрабаты- вать неоднозначность положительного и отрицательного нуля. Это значительно упрощает проектирование аппаратуры и повышает эффективность вычислений. Техническое решение с дополнительным кодом весьма изящно, но из-за огра- ниченности объема книги мы пока остановимся на этом и рекомендуем заин- тересованным читателям углубиться в изучение этого вопроса самостоятельно. + +### Кодирование чисел с плавающей запятой + +> Внимательный читатель, возможно, заметил, что типы int и float имеют оди- наковую длину в 4 байта. Но почему тогда диапазон значений float значитель- но больше, чем у int? Этот факт, возможно, противоречит интуиции, так как float представляет дробные числа, и диапазон значений должен уменьшиться. +> +> На самом деле **это связано с тем, что числа с плавающей запятой** float **используют иной способ представления**. 32-битное двоичное число запи- сывается следующий образом: +> +> *b*31*b*30*b*29 \... *b*2*b*1*b*0. +> +> Согласно стандарту IEEE 754 32-битное число float состоит из следующих трех частей: + +1) знакового бита S: занимает 1 бит, соответствует *b*31; + +2) показателя E: занимает 8 бит, соответствует *b*30*b*29 \... *b*23; + +3) мантиссы N: занимает 23 бита, соответствует *b*22*b*21 \... *b*0. + +> Метод вычисления значения двоичного числа float: +> +> val = (−1)***^b^***31 ⌡ 2^(***b***^30 ***b***29 \... ***b***23 )2 −127 ⌡ (1.*b b* \...*b* ) . +> +> 22 21 0 2 +> +> Формула для вычисления в десятичной системе: +> +> val = (−1)^S^ ⌡ 2^E−127^ ⌡(1 + N). +> +> где диапазоны значений каждой из частей: + +) + +> (1 + N)= (1 + Ι23 *b* +> +> ξ 2^−***i***^ ∴ 〉 ℘1, 2 − 2^−23^ λ . + +Λ + +>   +> +>   ***i***  1 +> +> 23−***i***   ϑ +> +> Знаковый бит **S** +> +> Длина **float** 4 байта = 32 бита +> +> Для этого примера легко получить: +> +> По стандарту IEEE 754 +> +> ![](ru/docs/assets/media/image116.jpeg)**Рис. 3.5.** Пример вычисления значения float по стандарту IEEE 754 +> +> Рассмотрим рис. 3.5, где приведены данные со значениями S = 0, E = 124, N = 2−2 + 2−3 = 0.375, тогда: +> +> val = (−1)^0^ ⌡ 2^124−127^ ⌡(1 + 0.375) = 0.171875. +> +> Теперь можно ответить на первоначальный вопрос: **представление типа** 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, поэтому здесь мы не будем углубляться в детали. + +#### кодирование символов\* + +> В компьютере все данные хранятся в виде двоичных чисел, и символьный тип данных char не является исключением. Для представления символов необхо- димо создать *таблицу символов*, которая устанавливает однозначное соответ- ствие между каждым символом и двоичным числом. С помощью этой таблицы компьютер может выполнять преобразование двоичных чисел в символы. + +### Таблица символов ASCII + +> ASCII является первой таблицей символов, ее полное название -- American Standard Code for Information Interchange (Американский стандартный код для обмена информацией). Для представления символов в ней используются 7-битные двоичные числа (нижние 7 бит одного байта), что позволяет пред- ставить до 128 различных символов. Как показано на рис. 3.6, таблица ASCII включает в себя заглавные и строчные буквы английского алфавита, цифры от 0 до 9, некоторые знаки препинания, а также некоторые управляющие сим- волы (например, символы новой строки и табуляции). + ++--------------------------+------------------------+--------------+-------------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:=============================:+ +| > 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 + ++--------------------------+------------------------+--------------+-------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:=======================:+ +| > 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.** *Продолжение* + ++--------------------------+------------------------+--------------+----------------------------+ +| > **Десятичная система** | > **Двоичная система** | > **Символ** | > **Значение** | ++:========================:+:======================:+:============:+:==========================:+ +| > 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.** *Продолжение* + ++--------------------------------------------------+-------------------------------+ +| > **Десятичная система Двоичная система Символ** | > **Значение** | ++==================================================+===============================+ +| > 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 символов определяются по- +> +> разному, чтобы удовлетворить потребности различных языков. + +### Таблицы символов GBK + +> Позднее выяснилось, что **EASCII все еще не может удовлетворить требова- ния многих языков по количеству символов**. Например, в китайском языке около ста тысяч иероглифов, из которых несколько тысяч используются в по- вседневной жизни. В 1980 году Государственное управление по стандартам Ки- тая выпустило таблицу символов GB2312, которая включала 6763 иероглифа, что в основном удовлетворяет потребности в обработке китайских иерогли- фов на компьютере. +> +> Однако GB2312 не может обрабатывать некоторые редкие и традицион- ные иероглифы. Поэтому таблица GBK была расширена и стала включать 21 886 иероглифов. В кодировке GBK символы ASCII представляются одним бай- том, а иероглифы -- двумя байтами. + +### Таблица символов Unicode + +> С бурным развитием компьютерных технологий появилось множество таблиц символов и кодировок, что привело к большому количеству проблем. С одной стороны, эти наборы символов обычно определяют только символы конкрет- ного языка и не могут нормально работать в многоязычной среде. С другой стороны, для одного и того же языка может существовать несколько таблиц символов, и если два компьютера используют разные кодировки, то при пере- даче информации может возникнуть искажение символов. +> +> Поэтому появилась идея **создать достаточно полный набор символов, включающий все языки и символы мира, который должен решить про- блемы многоязычной среды и искажения символов**. Под влиянием этой идеи и появился большой и универсальный набор символов Unicode. +> +> Название Unicode означает унифицированный код, теоретически эта ко- дировка может вмещать более 1 млн символов. Ее целью является включение символов со всего мира в единый набор, предоставляя универсальное средство для обработки и отображения различных языков, а также уменьшая проблемы искажения символов из-за различий в стандартах кодирования. +> +> С момента своего выпуска в 1991 году Unicode постоянно расширяется, добавляя поддержку новых языков и символов. По состоянию на сентябрь 2022 года кодировка Unicode включает 149 186 символов, включая символы различных языков, знаки и даже эмодзи. В обширном наборе символов Uni- code часто используемые символы занимают 2 байта, некоторые редкие сим- волы занимают 3 или даже 4 байта. +> +> Unicode -- это универсальный набор символов, который присваивает каж- дому символу номер (называемый кодовой точкой), **но не определяет, как эти кодовые точки должны храниться в компьютере**. Возникает вопрос: как система интерпретирует символы, когда в тексте одновременно при- сутствуют кодовые точки Unicode разной длины? Например, как система определяет, что код длиной 2 байта -- это один 2-байтовый символ, а не два 1-байтовых? +> +> **Одним из простых решений этой проблемы является хранение всех символов в виде кодов одинаковой длины**. В примере на рис. 3.7 показа- но, что каждый символ в слове Hello занимает 1 байт, а каждый символ в ки- тайском слове 算 法 (алгоритм) -- 2 байта. Можно закодировать все символы в выражении «Hello 算法» 2 байтами, заполнив старшие биты нулями. Таким образом, система сможет распознавать один символ каждые 2 байта и восста- новить содержание этой фразы. +> +> Однако ASCII-код уже доказал, что для кодирования английского языка до- статочно 1 байта. Если использовать вышеописанный метод, размер англий- ского текста будет вдвое больше, чем при кодировании с помощью ASCII, что крайне неэффективно по памяти. Поэтому необходим более эффективный ме- тод использования кодировки Unicode. +> +> ![](ru/docs/assets/media/image118.jpeg) +> +> **Рис. 3.7.** Пример представления символов в Unicode + +### Кодирование 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, что противоречит предположению. +> +> ![](ru/docs/assets/media/image120.jpeg) +> +> **Рис. 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. + +### Кодирование символов в языках программирования + +> Раньше в большинстве языков программирования строки в процессе выпол- нения программы кодировались с использованием UTF-16 или UTF-32, т. е. кодировок фиксированной длины. В таких кодировках строки можно обраба- тывать как массивы, что имеет следующие преимущества. + +- **Случайный доступ**: в строках в кодировке UTF-16 легко выполнять слу- чайный доступ. UTF-8 является кодировкой переменной длины, поэтому для нахождения *i*-го символа необходимо пройти от начала строки до *i*-го символа, что требует времени *O*(*n*). + + + +- **Подсчет символов**: аналогично случайному доступу вычисление длины строки в кодировке 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, а многие технологии Mi- crosoft, включая операционную систему Windows, широко используют кодировку UTF-16. + +> Из-за недооценки количества символов в этих языках программирования пришлось использовать суррогатные пары для представления символов Uni- code, превышающих 16 бит. Это было вынужденной мерой. С одной стороны, в строках, содержащих суррогатные пары, один символ может занимать 2 или 4 байта, что лишает кодировку преимущества равной длины. С другой сторо- ны, обработка суррогатных пар требует дополнительного кода, что увеличива- ет сложность программирования и затрудняет отладку. +> +> По указанным причинам в некоторых языках программирования предло- жили различные схемы кодирования. + +- В Python тип str использует кодировку Unicode и гибкое представле- ние строк, где длина символа зависит от наибольшего кодового пункта Unicode в строке. Если строка содержит только символы ASCII, каждый символ занимает 1 байт. Если есть символы за пределами ASCII, но в пределах базовой многоязычной плоскости (BMP), каждый символ за- нимает 2 байта. Если есть символы за пределами BMP, каждый символ занимает 4 байта. + +- В языке Go тип string внутренне использует кодировку UTF-8. Go также содержит тип rune для представления одного кодового пункта Unicode. + + - В языке Rust типы str и String внутренне используют кодировку UTF-8. Rust также предоставляет тип char для представления одного кодового пункта Unicode. + +> Следует отметить, что мы обсуждаем способ хранения строк в языках про- граммирования, что отличается от хранения строк в файлах или передачи их по сети. При хранении в файлах и сетевой передаче строки обычно кодируют- ся в формате UTF-8 для достижения наилучшей совместимости и эффектив- ности использования пространства. + +#### резюме + +##### Ключевые моменты + +- Структуры данных можно классифицировать с точки зрения логической и физической структуры. Логическая структура описывает логические отношения между элементами данных, а физическая структура описы- вает способ хранения данных в памяти компьютера. + +- К распространенным логическим структурам относятся линейные, дре- вовидные и сетевые. Обычно структуры данных делятся на линейные (массивы, списки, стеки, очереди) и нелинейные (деревья, графы, кучи). Реализация хеш-таблиц может включать как линейные, так и нелиней- ные структуры данных. + +- При выполнении программы данные хранятся в памяти компьютера. Каждое пространство памяти имеет соответствующий адрес, с помощью которого программа получает доступ к данным. + +- Физическая структура делится на хранение в непрерывном пространстве (массивы) и хранение в разреженном пространстве (списки). Все структу- ры данных реализуются с помощью массивов, списков или их комбинации. + +- Основные типы данных в компьютере включают целые числа byte, short, int, long, числа с плавающей запятой float, double, символы char и логиче- ские bool. Их диапазон значений зависит от размера занимаемого про- странства и способа представления. + +- Прямой, обратный и дополнительный коды -- это три метода кодирова- ния чисел в компьютере, которые можно взаимно преобразовывать друг в друга. В прямом коде старший бит целого числа является знаковым, а остальные биты представляют значение числа. + +- Целые числа в компьютере хранятся в виде дополнительного кода. В представлении дополнительного кода компьютер может одинаково обрабатывать сложение положительных и отрицательных чисел без не- обходимости в специальной аппаратной схеме для вычитания. Также при таком подходе отсутствует проблема двусмысленности положитель- ного и отрицательного нуля. + +- Кодирование чисел с плавающей запятой состоит из 1 бита для знака, 8 бит для экспоненты и 23 бит для мантиссы. Благодаря наличию экспоненты диапазон значений чисел с плавающей запятой значительно превышает диапазон целых чисел, но это достигается за счет потери точности. + + + +- ASCII является первой кодировкой символов для английского языка. Она включает 127 символов, длина кода составляет 1 байт. Набор сим- волов GBK часто используется для китайского языка и включает более двадцати тысяч иероглифов. Стандарт Unicode стремится предоставить стандарт полного набора символов, охватывающий символы различных языков мира, а также решая проблему искажения символов из-за несо- ответствия методов кодирования. + +- UTF-8 является самым популярным методом кодирования символов Unicode и обладает высокой универсальностью. Это метод кодирования переменной длины, который позволяет эффективно использовать про- странство хранилища. UTF-16 и UTF-32 -- это методы кодирования фик- сированной длины. При кодировании китайских символов UTF-16 зани- мает меньше места, чем UTF-8. Такие языки программирования, как Java и C#, по умолчанию используют кодировку UTF-16. + +##### Вопросы и ответы + +> **Вопрос**. Почему хеш-таблица одновременно содержит линейные и нелиней- ные структуры данных? +> +> **Ответ**. Хеш-таблица в своей основе представляет собой массив, и для реше- ния проблемы хеш-коллизий может использоваться адрес цепочки (подробнее см. в разделе «Хеш-коллизии»): каждая корзина массива указывает на связный список, который при превышении определенного порога длины может быть преобразован в дерево (обычно красно-черное дерево). С точки зрения хра- нения основа хеш-таблицы -- это массив, в котором каждая корзина может со- держать значение, связный список или дерево. Таким образом, хеш-таблица может одновременно содержать линейные структуры данных (массивы, связ- ные списки) и нелинейные структуры данных (деревья). +> +> **Вопрос**. Длина типа char составляет 1 байт? +> +> **Ответ**. Длина типа char определяется методом кодирования, используемым языком программирования. Например, в Java, JavaScript, TypeScript, C# ис- пользуется кодировка UTF-16 (сохраняющая кодовые точки Unicode), поэтому длина типа char составляет 2 байта. +> +> **Вопрос**. Можно ли назвать структуры данных на основе массивов статически- ми структурами данных? Стеки также могут выполнять такие операции, как из- влечение и добавление элементов, но эти операции являются динамическими. +> +> **Ответ**. Стек действительно может выполнять динамические операции с данными, но структура данных остается статической (неизменной длины). Хотя структуры данных на основе массивов могут динамически добавлять или удалять элементы, их емкость фиксирована. Если объем данных превышает за- ранее выделенный размер, необходимо создать новый, более крупный массив и скопировать в него содержимое старого массива. +> +> **Вопрос**. При создании стека (очереди) не указывается его размер, почему они являются статическими структурами данных? +> +> **Ответ**. В высокоуровневых языках программирования не требуется вруч- ную задавать начальную емкость стека (очереди), это выполняется автома- тически внутри класса. Например, начальная емкость ArrayList в Java обычно +> +> составляет 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 + +# Массивы и списки + +![](ru/docs/assets/media/image121.jpeg){width="3.71875in" height="4.8125in"} + +#### массивы + +> *Массив* представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов. + +**Элемент** + +> Память для хранения **массива** +> +> ![](ru/docs/assets/media/image123.jpeg)является **непрерывной** +> +> **Массив** +> +> Индекс +> +> **Адрес памяти** +> +> Доступная память +> +> Память, выделенная для массива +> +> **Рис. 4.1.** Определение и способ хранения массива + +1. **Основные операции с массивом** + +##### Инициализация массива + +> Существует два способа инициализации массива: без начальных значений и с за- данными начальными значениями. Если начальные значения не указаны, боль- шинство языков программирования инициализируют элементы массива нулями. +> +> \# === File: array.py === \# Инициализация массива. +> +> arr: list\[int\] = \[0\] \* 5 \# \[ 0, 0, 0, 0, 0 \] +> +> nums: list\[int\] = \[1, 3, 2, 5, 4\] + +##### Доступ к элементам массива + +> Элементы массива хранятся в непрерывной области памяти, что упрощает вы- числение их адресов. Зная адрес массива (адрес первого элемента) и индекс элемента, можно вычислить адрес этого элемента по формуле, показанной на рис. 4.2, и получить к нему доступ. +> +> Как видно из рис. 4.2, индекс первого элемента массива равен 0, что может показаться неочевидным, так как отсчет с 1 кажется более естественным. Од- нако с точки зрения формулы вычисления адреса **индекс является смеще- нием адреса в памяти**. Смещение адреса первого элемента равно 0, поэтому и его индекс равен 0. +> +> ![](ru/docs/assets/media/image125.jpeg) +> +> **Рис. 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 + +##### Вставка элемента + +> Элементы массива в памяти расположены вплотную, между ними нет места для хранения других данных. Для вставки элемента в середину массива не- обходимо сдвинуть все последующие элементы на одну позицию вправо, а затем присвоить значение элементу по заданному индексу, как показано на рис. 4.3. +> +> Следует отметить, что длина массива фиксирована, поэтому вставка эле- мента неизбежно приведет к потере элемента в конце массива. Решение этой проблемы будет рассмотрено в разделе «Списки». +> +> ![](ru/docs/assets/media/image127.jpeg) +> +> **Рис. 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 + +##### Удаление элемента + +> Аналогично для удаления элемента по индексу *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\] +> +> ![](ru/docs/assets/media/image129.jpeg) +> +> **Рис. 4.4.** Пример удаления элемента из массива +> +> В целом операции вставки и удаления в массиве имеют следующие недо- статки. + +- **Высокая временная сложность**: средняя временная сложность опера- ций вставки и удаления в массиве составляет *O*(*n*), где *n* -- длина массива. + +- **Потеря элементов**: так как длина массива фиксирована, при вставке элемента элементы, выходящие за пределы длины массива, теряются. + +- **Расточительность памяти**: можно инициализировать длинный массив и использовать только его часть, но это приведет к потере памяти, так как неиспользуемые элементы в конце массива будут бессмысленными. + +##### Обход массива + +> В большинстве языков программирования массив можно обходить как по ин- дексам, так и непосредственно по элементам. +> +> \# === 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 +> +> \# Одновременный обход индексов и элементов. +> +> for i, num in enumerate(nums): count += nums\[i\] +> +> count += num + +##### Поиск элемента + +> Для поиска заданного элемента в массиве необходимо обойти массив и на каждой итерации проверить, совпадает ли значение элемента с искомым. Если совпадает, вывести соответствующий индекс. Поскольку массив яв- ляется линейной структурой данных, этот процесс называется линейным поиском. +> +> \# === 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 + +##### Расширение массива + +> В сложных системных средах нельзя гарантировать, что ячейки памяти, расположенные после массива, являются свободными. Это делает невоз- можным безопасное расширение его размера. Поэтому в большинстве язы- ков программирования длина массива фиксирована. Если необходимо уве- личить массив, нужно создать новый, больший массив и последовательно скопировать в него элементы исходного массива. Эта операция имеет слож- ность *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 + +### Преимущества и ограничения массивов + +> Массивы хранятся в непрерывном пространстве памяти, а его элементы име- ют одинаковый тип. Этот подход содержит богатую априорную информацию, которую система может использовать для оптимизации эффективности опе- раций с данной структурой данных. + +- **Высокая эффективность использования пространства**: массивы вы- деляют непрерывные блоки памяти для данных без дополнительных структурных затрат. + +- **Поддержка произвольного доступа**: массивы позволяют получить до- ступ к любому элементу за время *O*(1). + +- **Локальность кеширования**: при доступе к элементам массива компью- тер загружает не только его, но и кеширует окружающие данные, что по- зволяет ускорить выполнение последующих операций за счет использо- вания высокоскоростного кеша. + +> Непрерывное хранение в пространстве -- это палка о двух концах, имеющая следующие ограничения. + +- **Низкая эффективность вставки и удаления**: при большом количестве элементов в массиве операции вставки и удаления требуют перемеще- ния множества элементов. + +- **Неизменная длина**: после инициализации длина массива фиксируется, а увеличение массива требует копирования всех данных в новый массив, что влечет за собой значительные затраты. + +- **Расточительность пространства**: если размер выделенного массива превышает фактические потребности, избыточное пространство оказы- вается потраченным впустую. + +### Типичные сценарии применения массивов + +> Массивы -- это базовая и распространенная структура данных, часто исполь- зуемая в различных алгоритмах и для реализации сложных структур данных. + +- **Произвольный доступ**: если требуется случайный выбор элементов, можно использовать массив для хранения и генерации случайной по- следовательности, осуществляя случайную выборку по индексу. + +- **Сортировка и поиск**: массивы являются наиболее часто используемой структурой данных для алгоритмов сортировки и поиска. Быстрая сорти- ровка, сортировка слиянием, двоичный поиск и другие алгоритмы в ос- новном работают с массивами. + +- **Таблица поиска**: когда необходимо быстро найти элемент или его со- ответствие, можно использовать массив в качестве таблицы поиска. На- пример, для реализации отображения символов в ASCII-коды можно использовать значение ASCII-кода символа в качестве индекса, а соот- ветствующий элемент хранить в соответствующем месте массива. + +- **Машинное обучение**: в нейронных сетях широко используются опе- рации линейной алгебры между векторами, матрицами и тензорами, которые реализуются в виде массивов. Массивы являются наиболее часто используемой структурой данных в программировании ней- ронных сетей. + +- **Реализация структур данных**: массивы могут использоваться для ре- ализации стека, очереди, хеш-таблицы, кучи, графа и других структур данных. Например, представление графа в виде матрицы смежности фактически является двумерным массивом. + +#### Связные списки + +> Память -- это общий ресурс всех программ, и в сложной системной среде вы- полнения участки свободной памяти могут быть разбросаны по всему про- странству памяти. Нам уже известно, что память для хранения массива должна быть непрерывной, и когда массив очень велик, в памяти может не оказаться столь большого непрерывного участка. В этом случае проявляется преимуще- ство гибкости связного списка. +> +> *Связный список* -- это линейная структура данных, в которой каждый элемент является объектом-узлом. При этом узлы соединяются друг с другом с помо- щью ссылок. В ссылке хранится адрес памяти следующего узла, по которому можно перейти от текущего узла к следующему. +> +> Структура связного списка позволяет узлам храниться в различных местах памяти, а их адреса памяти не обязаны быть последовательными. + +![](ru/docs/assets/media/image131.jpeg) + +> **Рис. 4.5.** Определение и способ хранения связного списка +> +> На рис. 4.5 изображена структура связного списка. Составным элементом является объект узла. Каждый узел содержит две части данных: значение узла и ссылку на следующий узел. + +- Первый узел связного списка называется головным узлом, а последний узел -- хвостовым узлом. + +- Хвостовой узел указывает на пустое значение, которое в Java, C++ и Py- thon обозначается как null, nullptr и None соответственно. + +- В языках, поддерживающих указатели, таких как C, C++, Go и Rust, вы- шеупомянутая ссылка заменена на указатель. + +> В следующем примере кода показано, что узел связного списка ListNode, помимо значения, должен дополнительно хранить ссылку (указатель). Поэтому при одина- ковом объеме данных **связный список занимает больше памяти, чем массив**. +> +> class ListNode: +> +> \"\"\" Класс узла связного списка.\"\"\" def init (self, val: int): +> +> self.val: int = val \# Значение узла. +> +> self.next: ListNode \| None = None \# Ссылка на следующий узел. + +### Основные операции со связным списком + +##### Инициализация связного списка + +> Создание связного списка состоит из двух этапов: первый этап -- иници- ализация каждого объекта узла, второй этап -- построение ссылочных от- ношений между узлами. После завершения инициализации можно начать с головного узла связного списка и последовательно посетить все узлы че- рез ссылку 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. + +##### Вставка узла + +> Процесс вставки узла в связный список очень прост. Предположим, что не- обходимо вставить новый узел P между двумя соседними узлами n0 и n1, **для этого достаточно изменить две ссылки (указателя)**, а время выполнения составит *O*(1), см. рис. 4.6. +> +> Напомним, что вставка элемента в массив имеет временную сложность *O*(*n*), что менее эффективно при больших объемах данных. +> +> ![](ru/docs/assets/media/image133.jpeg) +> +> **Рис. 4.6.** Пример вставки узла в связный список +> +> \# === File: linked_list.py === +> +> def insert(n0: ListNode, P: ListNode): +> +> \"\"\" Вставка узла P после узла n0 в связный список.\"\"\" n1 = n0.next +> +> P.next = n1 n0.next = P + +##### Удаление узла + +> Удаление узла в связном списке также является очень простой операцией, как показано на рис. 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 +> +> **Пример** +> +> Удаление узла **P** из связного списка +> +> (Элемент **n0 P** +> +> ![](ru/docs/assets/media/image135.jpeg)указывает на **n1 )** +> +> После удаления элемент **P** все еще указывает на **n1**. Но **P** больше не доступен при обходе списка, поэтому можно считать, что **P** был удален + +##### Доступ к узлу + +> **Рис. 4.7.** Удаление узла в связном списке +> +> **Эффективность доступа к узлам в связном списке ниже**. Как упоминалось в предыдущем разделе, доступ к любому элементу массива можно получить за время *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 + +##### Поиск узла + +> Поиск узла заключается в обходе связного списка для поиска узла со значени- ем target и выводе его индекса в списке. Этот процесс также является линей- ным поиском. Ниже приведен пример кода. +> +> \# === File: linked_list.py === +> +> def find(head: ListNode, target: int) -\> int: +> +> \"\"\" Поиск первого узла со значением target в связном списке.\"\"\" index = 0 +> +> while head: +> +> if head.val == target: return index +> +> head = head.next index += 1 +> +> return -1 + +### Сравнение массивов и связных списков + +> В табл. 4.1 приведены характеристики массивов и связных списков, а также сравнение эффективности операций с ними. Поскольку они используют две противоположные стратегии хранения, их свойства и эффективность опера- ций также имеют противоположные характеристики. + ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| | > **Массив** | > **Связный список** | ++========================+======================================================================+========================================+ +| > Способ хранения | > Непрерывное пространство памяти | > Распределенное про- странство памяти | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Расширение емкости | > Длина фиксирована | > Возможность гибкого расширения | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Эффективность памяти | > Элементы занимают меньше памяти, но могут расходовать пространство | > Элементы занимают больше памяти | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Доступ к элементу | > *O*(1) | > *O*(*n*) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Добавление элемента | > *O*(*n*) | > *O*(1) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ +| > Удаление элемента | > *O*(*n*) | > *O*(1) | ++------------------------+----------------------------------------------------------------------+----------------------------------------+ + +> впустую + +### Основные типы связных списков + +> Существуют три основных типа связных списков (см. рис. 4.8). + +- **Односвязный список**: это обычный связный список, описанный ранее. Узлы однонаправленного связного списка содержат значение и ссылку на следующий узел. Первый узел называется головным, а последний -- хвостовым, хвостовой узел указывает на пустое значение None. + +- **Кольцевой (циклический) список**: если сделать так, чтобы хвостовой узел односвязного списка указывал на головной узел (соединение начала и конца), получится кольцевой список. В кольцевом списке любой узел можно рассматривать как головной. + +- **Двусвязный список**: в отличие от односвязного двусвязный список хранит ссылки в двух направлениях. Определение узла двусвязного спи- ска включает ссылки (указатели) на следующий и предыдущий узлы. По сравнению с односвязным списком двусвязный список обладает большей гибкостью, позволяя обходить список в обоих направлениях, но требует больше памяти. + +> class ListNode: +> +> \"\"\" Класс узла двусвязного списка.\"\"\" def init (self, val: int): +> +> self.val: int = val \# Значение узла. +> +> self.next: ListNode \| None = None \# Ссылка на следующий узел. self.prev: ListNode \| None = None \# Ссылка на предыдущий узел. + +![](ru/docs/assets/media/image137.jpeg) + +> **Рис. 4.8.** Виды связных списков + +### Типичные сценарии применения списков + +> Односвязные списки обычно используются для реализации таких структур данных, как стек, очередь, хеш-таблица и граф. + +- **Стек и очередь**: когда операции вставки и удаления выполняются с од- ного конца списка, он ведет себя как стек (принцип «последний при- шел -- первый вышел»). Когда операция вставки выполняется с одного конца, а операция удаления -- с другого, он ведет себя как очередь (прин- цип «первый пришел -- первый вышел»). + +- **Хеш-таблица**: метод цепочек является одним из основных способов ре- шения коллизий в хеш-таблицах, при котором все конфликтующие эле- менты помещаются в один список. + +- **Граф**: списки смежности -- это распространенный способ представления графов, где каждая вершина графа связана со списком, элементы кото- рого представляют другие вершины, соединенные с данной. + +> Двусвязные списки часто используются в ситуациях, где требуется быстрое нахождение предыдущего и следующего элемента. + +- **Расширенные структуры данных**: например, в красно-черных дере- вьях и B-деревьях необходимо иметь доступ к родительскому узлу, что можно реализовать, сохраняя ссылку на родительский узел аналогично двусвязному списку. + + - **История браузера**: в веб-браузере, когда пользователь нажимает кнопки **Вперед** или **Назад**, браузеру необходимо знать предыдущую и следую- щую страницы. Свойства двусвязного списка упрощают выполнение та- ких операций. + + - **Алгоритм LRU**: в алгоритме вытеснения из кеша (LRU) необходимо бы- стро находить наименее используемые данные, а также поддерживать быстрое добавление и удаление узлов. В этом случае идеально подходит двусвязный список. + +> Кольцевые списки часто применяются в ситуациях, требующих цикличе- ских операций, например в планировании ресурсов операционной системы. + +- **Алгоритм циклического планирования**: в операционных системах ал- горитм циклического планирования -- это распространенный алгоритм планирования процессорного времени, который требует циклического об- хода группы процессов. Каждому процессу назначается временной квант, и по его истечении процессор переключается на следующий процесс. Такие циклические операции можно реализовать с помощью кольцевого списка. + +- **Буфер данных**: в некоторых реализациях буферов данных также может использоваться кольцевой список. Например, в аудио- и видеоплеерах по- ток данных может разделяться на несколько буферных блоков и помещать- ся в кольцевой список для обеспечения непрерывного воспроизведения. + +#### Списки + +> *Список* -- это абстрактное понятие структуры данных, представляющее собой упорядоченное множество элементов, поддерживающее операции доступа, изменения, добавления, удаления и обхода элементов без необходимости учи- тывать ограничения по объему. Списки могут быть реализованы на основе связных списков или массивов. + +- Связные списки естественным образом можно рассматривать как спи- ски, поддерживающие операции добавления, удаления, поиска и изме- нения элементов, а также динамическое расширение. + +- Массивы также поддерживают операции добавления, удаления, поиска и изменения элементов, но из-за фиксированной длины их можно рас- сматривать только как списки с ограничением по длине. + +> При использовании массива для реализации списка **неизменяемая длина приводит к снижению его практичности**. Это связано с тем, что зачастую невозможно заранее определить, сколько данных потребуется хранить, что за- трудняет выбор подходящей длины списка. Если длина слишком мала, это, веро- ятно, не удовлетворит потребности. Если длина слишком велика, это приведет к неэффективному использованию памяти. Для решения этой проблемы можно использовать динамический массив. Он сохраняет все преимущества массива и может динамически расширяться в процессе выполнения программы. +> +> На самом деле **списки**, **предоставляемые стандартными библиотеками многих языков программирования**, **реализованы на основе динамиче- ских массивов**, например list в Python, ArrayList в Java, vector в C++ и List в C#. В дальнейшем мы будем рассматривать список и динамический массив как эквивалентные понятия. + +### Основные операции со списком + +##### Инициализация списка + +> Обычно используются два метода инициализации: без начальных значений и с заданием начальных значений. +> +> \# === File: list.py === \# Инициализация списка. +> +> \# Без начальных значений. nums1: list\[int\] = \[\] +> +> \# С начальными значениями. +> +> nums: list\[int\] = \[1, 3, 2, 5, 4\] + +##### Доступ к элементам + +> Список по своей сути является массивом, поэтому доступ и обновление эле- ментов возможны за время *O*(1), что очень эффективно. +> +> \# === File: list.py === \# Доступ к элементам. +> +> num: int = nums\[1\] \# Доступ к элементу по индексу 1. \# Изменение элемента. +> +> nums\[1\] = 0 \# Изменение значения элемента с индексом 1 на 0. + +##### Вставка и удаление элементов + +> В отличие от массива в списке можно свободно добавлять и удалять элемен- ты. Сложность добавления элемента в конец списка составляет *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. + +##### Обход списка + +> Как и массив, список можно обходить по индексу или напрямую по эле- ментам. +> +> \# === File: list.py === +> +> \# Обход списка по индексу. count = 0 +> +> for i in range(len(nums)): count += nums\[i\] +> +> \# Прямой обход элементов списка. for num in nums: +> +> count += num + +##### Конкатенация списков + +> Создав новый список nums1, его можно присоединить в конец исходного списка. +> +> \# === File: list.py === +> +> \# Конкатенация двух списков. +> +> nums1: list\[int\] = \[6, 8, 7, 10, 9\] +> +> nums += nums1 \# Конкатенация списка nums1 с nums. + +##### Сортировка списка + +> После сортировки списка можно использовать такие алгоритмы, как двоич- ный поиск и два указателя, которые часто встречаются в задачах с массивами. +> +> \# === File: list.py === \# Сортировка списка. +> +> nums.sort() \# После сортировки элементы списка расположены по возрастанию. + +### Реализация списка + +> Многие языки программирования, такие как Java, C++, Python и др., име- ют встроенные списки. Их реализация довольно сложна, а параметры тща- тельно продуманы, например начальная емкость, коэффициент расшире- ния и т. д. Заинтересованные читатели могут изучить исходный код само- стоятельно. Чтобы углубить понимание принципов работы списка, попро- буем реализовать его упрощенную версию, включающую следующие три ключевых аспекта. + +- **Начальная емкость**: выбор разумной начальной емкости массива. В нашем примере выбрано значение 10 в качестве начальной емкости. + +- **Учет количества**: объявление переменной size для учета текущего ко- личества элементов в списке. Эта переменная обновляется при вставке и удалении элементов. На ее основе можно определить конец списка и необходимость расширения. + +- **Механизм расширения**: если при вставке элемента емкость списка оказывается исчерпанной, необходимо расширение. Сначала созда- ется новый массив большего размера на основе коэффициента рас- ширения, затем все элементы текущего массива последовательно перемещаются в новый массив. В нашем примере массив каждый раз расширяется двукратно. + +> \# === 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() +> +> \# Все элементы начиная с индекса 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\] + +#### память и кеш\* + +> В первых двух разделах этой главы были рассмотрены массивы и связные спи- ски -- две базовые и важные структуры данных, представляющие собой соот- ветственно непрерывное хранение и распределенное хранение. +> +> Фактически **физическая структура в значительной степени определяет эффективность использования памяти и кеша программой**, что, в свою очередь, влияет на общую производительность алгоритма. + +### Устройства хранения в компьютере + +> В компьютере существует три типа устройств хранения: жесткий диск (HDD/ SSD), оперативная память (RAM) и кеш-память (cache). В табл. 4.2 приведены их различные роли и характеристики. +> +> **Таблица 4.2.** Устройства хранения в компьютере + ++-------------+----------------------------+--------------------------+----------------------------+ +| | > **Жесткий диск** | > **Оперативная память** | > **Кеш** | ++=============+============================+==========================+============================+ +| > Назначе- | > Долговременное хра- | > Временное хранение | > Хранение часто запраши- | ++-------------+----------------------------+--------------------------+----------------------------+ +| > ние | > нение данных, включая | > запущенных программ | > ваемых данных и инструк- | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > операционную систему, | > и обрабатываемых | > ций, сокращение числа | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > программы, файлы и т. д. | > данных | > обращений к оперативной | ++-------------+----------------------------+--------------------------+----------------------------+ +| | | | > памяти процессором | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Энергоза- | > Данные не теряются по- | > Данные теряются после | > Данные теряются после | ++-------------+----------------------------+--------------------------+----------------------------+ +| > висимость | > сле отключения питания | > отключения питания | > отключения питания | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Емкость | > Большая, на уровне | > Меньше, на уровне | > Очень малая, на уровне | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > терабайтов | > гигабайтов | > мегабайтов | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Скорость | > Медленная, от несколь- | > Быстрая, десятки гига- | > Очень быстрая, от десят- | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > ких сотен до нескольких | > байт в секунду | > ков до сотен гигабайт | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > тысяч мегабайт в секунду | | > [в]{.smallcaps} секунду | ++-------------+----------------------------+--------------------------+----------------------------+ +| > Цена | > Низкая, несколько цен- | > Высокая, несколько | > Очень высокая, цена | ++-------------+----------------------------+--------------------------+----------------------------+ +| | > тов за гигабайт | > долларов за гигабайт | > включена в стоимость | ++-------------+----------------------------+--------------------------+----------------------------+ +| | | | > процессора | ++-------------+----------------------------+--------------------------+----------------------------+ + +> Компьютерную систему хранения можно представить в виде пирамидаль- ной структуры, как показано на рис. 4.9. Чем ближе к вершине пирамиды на- ходится устройство хранения, тем выше его скорость, меньше емкость и выше стоимость. Такой многоуровневый дизайн не случаен, а является результатом тщательных размышлений компьютерных ученых и инженеров. + +![](ru/docs/assets/media/image139.jpeg) + +> **Рис. 4.9.** Система хранения данных в компьютере + +- **Жесткий диск трудно заменить оперативной памятью**. Во-первых, данные в оперативной памяти теряются после отключения питания, по- этому она не подходит для долговременного хранения. Во-вторых, сто- имость оперативной памяти в десятки раз выше, чем у жесткого диска, что затрудняет ее распространение на потребительском рынке. + + - **Большая емкость и высокая скорость кеша трудно совместимы**. С увеличением емкости кеша уровней L1, L2, L3 его физические разме- ры увеличиваются. Вместе с этим растет физическое расстояние до ядра процессора, что приводит к увеличению времени передачи данных и за- держке доступа к элементам. В текущих условиях многоуровневая струк- тура кеша является оптимальным балансом между емкостью, скоростью и стоимостью. + +> В целом **жесткий диск используется для долговременного хранения большого объема данных**, **оперативная память** -- **для временного хране- ния данных**, **обрабатываемых во время выполнения программы**, **а кеш -- для хранения часто запрашиваемых данных и инструкций**, чтобы повы- сить эффективность выполнения программы. Все три компонента работают совместно, обеспечивая эффективную работу компьютерной системы. +> +> Во время выполнения программы данные считываются с жесткого диска в оперативную память для обработки процессором, как показано на рис. 4.10. Кеш-память можно рассматривать как часть процессора, в которую по слож- ным алгоритмам загружаются данные из оперативной памяти. Это обеспечи- вает высокоскоростное чтение данных процессором, значительно повышает эффективность выполнения программы и снижает зависимость от более мед- ленной оперативной памяти. + +![](ru/docs/assets/media/image141.jpeg) + +> **Рис. 4.10.** Поток данных между жестким диском, оперативной памятью и кешем + +### Эффективность использования памяти структурами данных + +> С точки зрения использования памяти массивы и связные списки имеют свои преимущества и ограничения. +> +> С одной стороны, **память ограничена**, **и одну и ту же область памяти нельзя разделить между несколькими программами**, поэтому желательно, +> +> чтобы структуры данных максимально эффективно использовали простран- ство. Элементы массива расположены плотно, также не требуется дополни- тельное пространство для хранения ссылок (указателей) между узлами, что делает его более эффективным с точки зрения использования памяти. Одна- ко массивы требуют выделения сразу достаточного количества непрерывного пространства памяти, что может привести к ее растрате, а расширение мас- сива также требует дополнительных временных и пространственных затрат. В отличие от этого списки осуществляют динамическое распределение и осво- бождение памяти на уровне узлов, что обеспечивает большую гибкость. +> +> С другой стороны, во время выполнения программы по мере многократного выполнения запросов и освобождения памяти степень фрагментации свобод- ной памяти будет увеличиваться, что снижает эффективность ее использова- ния. Благодаря непрерывному способу хранения массивы относительно менее подвержены фрагментации памяти. Элементы списка, напротив, хранятся разрозненно, и при частых операциях вставки и удаления они более подвер- жены фрагментации памяти. + +### Эффективность кеширования структур данных + +> Хотя объем кеша значительно меньше объема памяти, он намного быстрее и играет решающую роль в скорости выполнения программы. Объем кеша ограничен, и он может хранить лишь небольшую часть часто запрашиваемых данных. Поэтому при попытке процессора получить доступ к данным, отсут- ствующим в кеше, происходит промах кеша, и процессор вынужден загружать необходимые данные из более медленной памяти. +> +> Очевидно, что **чем меньше промахов кеша, тем выше эффективность чтения и записи данных процессором** и тем лучше производительность программы. Доля данных, успешно полученных процессором из кеша, называ- ется коэффициентом попадания в кеш. Этот показатель обычно используется для оценки эффективности кеша. +> +> Для достижения максимальной эффективности кеш использует следующие механизмы загрузки данных. + +- **Кеш-линия**: кеш не хранит и не загружает данные по байтам, а исполь- зует в качестве единицы кеш-линии. По сравнению с передачей по бай- там передача кеш-линий более эффективна. + +- **Механизм предвыборки**: процессор пытается предсказать шаблоны доступа к данным (например, последовательный доступ, доступ с фик- сированным шагом и т. д.) и загружает данные в кеш в соответствии с этими шаблонами, чтобы повысить коэффициент попадания. + +- **Пространственная локальность**: если к данным был осуществлен доступ, то, вероятно, в ближайшее время будет осуществлен доступ и к данным, на- ходящимся поблизости. Поэтому при загрузке данных кеш также загружает данные, находящиеся рядом, чтобы повысить коэффициент попадания. + +- **Временная локальность**: если к данным был осуществлен доступ, то в ближайшем будущем, вероятно, к ним будет осуществлен повторный доступ. Кеш использует этот принцип, сохраняя недавно запрашивае- мые данные, чтобы повысить коэффициент попадания. + +> Фактически **эффективность использования кеша массивами и списка- ми различается**, что проявляется в следующих аспектах. + +- **Занимаемое пространство**: элементы списка занимают больше про- странства, чем элементы массива, что приводит к уменьшению объема полезных данных, которые могут быть помещены в кеш. + +- **Кеш-линии**: данные списка распределены по всей памяти, а кеш за- гружает данные по линиям, поэтому доля загружаемых неэффективных данных выше. + +- **Механизм предвыборки**: шаблоны доступа к данным массива более предсказуемы, чем у списка, т. е. системе легче угадать, какие данные могут быть загружены. + +- **Пространственная локальность**: массивы хранятся в сконцентриро- ванном пространстве памяти, поэтому данные, находящиеся рядом с за- груженными, с большей вероятностью будут запрошены. + +- В целом **массивы обладают более высоким коэффициентом попа- дания в кеш**, **поэтому они обычно превосходят списки по эффек- тивности операций**. Это делает структуры данных, реализованные на основе массивов, более предпочтительными при решении алгоритми- ческих задач. + +> Следует отметить, **что высокая эффективность использования кеша не означает, что массивы всегда предпочтительнее списков**. Выбор струк- туры данных в реальных приложениях должен основываться на конкретных требованиях. Например, структуру данных «стек» можно реализовать на осно- ве и массивов, и списков (детали этого будут рассмотрены в следующей главе), но они предназначены для различных сценариев. + +- При решении алгоритмических задач предпочтение отдается стеку, реализованному на основе массива, поскольку он обеспечивает более высокую эффективность операций и возможность случайного доступа. Но платой за это является необходимость заранее выделить определен- ное количество памяти для массива. + +- Если объем данных очень велик, динамичность высока, и размер стека трудно предсказать, то более предпочтителен стек, реализованный на основе списка. Список позволяет распределенно хранить большое коли- чество данных в различных частях памяти и избегать дополнительных затрат на расширение массива. + +#### резюме + +##### Ключевые моменты + +- Массивы и списки -- это две основные структуры данных, представляю- щие два способа хранения данных в памяти компьютера: хранение в не- прерывном пространстве и хранение в распределенном пространстве. Их характеристики дополняют друг друга. + +- Массивы поддерживают случайный доступ и занимают меньше памяти. Однако эффективность вставки и удаления элементов низкая, а длина после инициализации фиксированная. + + + +- Списки обеспечивают эффективную вставку и удаление узлов путем из- менения ссылок (указателей) и могут гибко изменять свою длину. Одна- ко доступ к узлам менее эффективен, и они занимают больше памяти. К распространенным типам списков относятся односвязные, кольцевые и двусвязные списки. + +- Список -- это упорядоченная коллекция элементов, поддерживающая операции добавления, удаления, поиска и изменения и обычно реали- зуемая на основе динамического массива. Он сохраняет преимущества массива, одновременно позволяя гибко изменять длину. + +- Появление списка значительно повысило практическую ценность мас- сива, но может привести к частичной потере памяти. + +- Во время выполнения программы данные в основном хранятся в памя- ти. Массивы могут обеспечить более высокую эффективность использо- вания памяти, тогда как списки более гибки в ее использовании. + +- Кеш, используя кеш-линии, механизм предвыборки, а также механизмы пространственной и временной локальности данных, обеспечивает для процессора быстрый доступ к данным, значительно повышая эффектив- ность выполнения программы. + +- Поскольку массивы обладают более высокой вероятностью попадания в кеш, они обычно более эффективны, чем списки. При выборе струк- туры данных следует принимать во внимание конкретные требования и сценарии. + +##### Вопросы и ответы + +> **Вопрос**. Влияет ли хранение массива в стеке или в куче на временную и про- странственную эффективность? +> +> **Ответ**. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном пространстве памяти, и эффективность операций с данными в основном оди- накова. Однако стек и куча имеют свои особенности, что приводит к следую- щим различиям: + +1) эффективность выделения и освобождения: стек -- это небольшая об- ласть памяти, выделение которой выполняется автоматически компи- лятором, в то время как память кучи относительно больше и может вы- деляться динамически в коде, что делает ее более подверженной фраг- ментации. Поэтому операции выделения и освобождения в куче обычно медленнее, чем в стеке; + +2) ограничение размера: память стека относительно мала, размер кучи обычно ограничен доступной памятью. Поэтому куча более подходит для хранения больших массивов; + +3) гибкость: размер массива в стеке должен быть определен на этапе ком- пиляции, тогда как размер массива в куче может быть динамически определен во время выполнения. + +> **Вопрос**. Почему для массива требуется, чтобы элементы были одного типа, а для списка это не обязательно? +> +> **Ответ**. Список состоит из узлов, которые соединяются между собой с помо- щью ссылок (указателей), и каждый узел может хранить данные различных типов, например int, double, string, object и т. д. +> +> Элементы массива, напротив, должны быть одного типа, чтобы можно было вычислить смещение для получения позиции соответствующего элемента. На- пример, если массив одновременно содержит элементы типов 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*). +> +> **Вопрос**. Фраза «Появление списка значительно повысило практическую ценность массива, но может привести к частичной потере памяти» означает, что потеря памяти связана с дополнительными переменными, такими как ем- кость, длина и коэффициент расширения? +> +> **Ответ**. Потеря памяти здесь имеет два значения. Во-первых, у списка уста- навливается начальная длина, но нам не всегда требуется столько места. Во- вторых, чтобы избежать частого расширения, обычно используется опреде- ленный коэффициент, например ×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\]. +> +> **Вопрос**. Операция res = \[0\] \* n создает список, в котором все целые 0 явля- ются независимыми? +> +> **Ответ**. В этом списке все целые 0 являются ссылками на один и тот же объ- ект. Это связано с тем, что в Python используется механизм пула для малых целых чисел (обычно от --5 до 256), чтобы максимизировать повторное исполь- зование объектов и повысить производительность. +> +> Хотя они ссылаются на один и тот же объект, мы все же можем независимо изменять каждый элемент списка, поскольку целые числа в Python являются неизменяемыми объектами. При изменении какого-либо элемента фактиче- ски происходит переключение на ссылку на другой объект, а не изменение са- мого исходного объекта. +> +> Однако, когда элементы списка являются изменяемыми объектами (напри- мер, списки, словари или экземпляры классов и т. д.), изменение какого-либо элемента напрямую изменяет и сам объект, и все элементы, ссылающиеся на этот объект, претерпевают те же изменения. +> +> Глава 5 + +# Стек и очередь + +![](ru/docs/assets/media/image142.jpeg){width="3.71873687664042in" height="4.8125in"} + +1. **Стек** + +> *Стек* -- это линейная структура данных, которая следует логике «первый во- шел -- последний вышел». +> +> Стек можно сравнить со стопкой тарелок на столе: чтобы достать тарелку снизу, нужно сначала убрать все тарелки сверху. Заменив тарелки на элементы различных типов (например, целые числа, символы, объекты и т. д.), мы полу- чим структуру данных, называемую стеком. +> +> Верх стопки элементов называется вершиной стека, а низ -- основанием сте- ка, как показано на рис. 5.1. Операция добавления элемента на вершину стека называется вставка, а удаление элемента с вершины -- извлечение. + +![](ru/docs/assets/media/image144.jpeg)Помещение + +> Помещение +> +> Извлечение +> +> Извлечение +> +> **Рис. 5.1.** Правило «первый вошел -- последний вышел» для стека + +### Основные операции со стеком + +> Основные операции со стеком представлены в табл. 5.1, конкретные имена методов зависят от используемого языка программирования. Здесь в качестве примера используются распространенные имена push(), pop(), peek(). +> +> **Таблица 5.1.** Эффективность операций со стеком + ++-----------+--------------------------------------------------+---------------------------+ +| **Метод** | > **Описание** | > **Временная сложность** | ++==========:+==================================================+:=========================:+ +| push() | > Вставка элемента (добавление на вершину стека) | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ +| pop() | > Извлечение элемента с вершины стека | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ +| peek() | > Доступ к элементу на вершине стека | > *O*(1) | ++-----------+--------------------------------------------------+---------------------------+ + +> Обычно достаточно использовать классы стека, встроенные в язык програм- мирования. Однако в некоторых языках может не быть специального класса для стека. Тогда можно использовать массив или связный список в качестве стека, игнорируя операции, не связанные со стеком. +> +> \# === 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.2. Этот метод вставки узла называется встав- ка в голову. Для операции извлечения элемента достаточно удалить головной узел из связного списка. +> +> ![](ru/docs/assets/media/image146.jpeg) + +![](ru/docs/assets/media/image148.jpeg)![](ru/docs/assets/media/image150.jpeg) + +> **Рис. 5.2.** Операции вставки и извлечения в стеке на основе связного списка +> +> Ниже приведен пример кода для реализации стека на основе связного списка. +> +> \# === 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 + +##### Реализация на основе массива + +> При использовании для реализации стека массива можно считать конец мас- сива вершиной стека. Операции вставки и извлечения соответствуют добавле- +> +> нию и удалению элементов в конце массива, как показано на рис. 5.3. Времен- ная сложность этих операций составляет *O*(1). + +![](ru/docs/assets/media/image152.jpeg)![](ru/docs/assets/media/image154.jpeg)![](ru/docs/assets/media/image156.jpeg) + +> **Рис. 5.3.** Операции вставки и извлечения в стеке на основе массива +> +> Поскольку количество вставляемых элементов может постоянно увеличи- ваться, можно использовать динамический массив, чтобы не заниматься рас- ширением массива самостоятельно. Ниже приведен пример кода. +> +> \# === 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 + +### Сравнение двух реализаций + +##### Поддерживаемые операции + +> Обе реализации поддерживают все операции, определенные для стека. Реали- зация на основе массива дополнительно поддерживает произвольный доступ, но это выходит за рамки определения стека, поэтому обычно не используется. + +##### Временная сложность + +> В реализации на основе массива операции добавления и удаления элемента выполняются в заранее выделенной непрерывной памяти, что обеспечивает хорошую локальность кеша и, следовательно, высокую эффективность. Одна- ко, если при добавлении элемента превышается емкость массива, срабатыва- ет механизм расширения, что приводит к увеличению временной сложности данной операции до *O*(*n*). +> +> В реализации на основе связного списка расширение происходит очень гиб- ко, и не возникает проблемы снижения эффективности, как в случае расшире- ния массива. Однако операция добавления элемента требует инициализации объекта узла и изменения указателя, что делает ее относительно менее эффек- тивной. Тем не менее, если добавляемый элемент уже является объектом узла, можно избежать шага инициализации, что повысит эффективность. +> +> Таким образом, если элементы операций добавления и удаления являются примитивными типами данных, такими как int или double, можно сделать сле- дующие выводы: + +1) стек, реализованный на основе массива, при срабатывании механизма расширения теряет в эффективности, но, так как расширение является редкой операцией, средняя эффективность выше; + +2) стек, реализованный на основе связного списка, обеспечивает более ста- бильную эффективность. + +##### Пространственная сложность + +> При инициализации массива система выделяет для него начальную емкость, которая может превышать фактические потребности. Кроме того, механизм расширения обычно осуществляется с определенным коэффициентом (на- пример, в 2 раза), и емкость после расширения также может превышать фак- тические потребности. Поэтому **стек**, **реализованный на основе массива**, **может приводить к некоторым потерям пространства**. +> +> Однако, так как узлы связного списка требуют дополнительного хранения указателей, **занимаемое ими пространство сравнительно больше**. +> +> Таким образом, нельзя однозначно определить, какая реализация более эко- номична в плане памяти, необходимо анализировать конкретные ситуации. + +### Типичные сценарии применения стека + +- Возврат и переход вперед в браузере, отмена и повтор в программном обеспечении. Каждый раз, когда открывается новая веб-страница, бра- узер выполняет добавление предыдущей страницы в стек, что позволя- ет вернуться к ней с помощью операции возврата. Операция возврата фактически является выполнением удаления из стека. Если требуется поддержка как возврата, так и перехода вперед, необходимо использо- вать два стека. + +- Управление памятью программы. Каждый раз при вызове функции си- стема добавляет на вершину стека фрейм для записи контекстной ин- формации функции. В рекурсивных функциях на этапе нисходящей ре- + +> курсии постоянно выполняется добавление в стек, а на этапе восходящей рекурсии -- удаление из стека. + +#### Очередь + +> *Очередь* -- это линейная структура данных, следующая правилу «первый при- шел -- первый вышел». Как следует из названия, очередь моделирует реаль- ную очереди, когда новые элементы постоянно добавляются в конец очереди, а элементы в начале очереди покидают ее последовательно. +> +> Начало очереди называется голова, а конец -- хвост, см. рис. 5.4. Операция добавления элемента в конец очереди называется добавление в очередь, а уда- ление элемента из начала очереди -- удаление из очереди. + +![](ru/docs/assets/media/image158.jpeg) + +> **Рис. 5.4.** Правило очереди «первый пришел -- первый вышел» + +### Основные операции с очередью + +> Основные операции с очередью представлены в табл. 5.2. Следует отметить, что имена методов могут различаться в зависимости от языка программиро- вания. Здесь используются те же названия методов, что и для стека. + ++-------------+---------------------------------------------------+---------------------------+ +| > **Метод** | > **Описание** | > **Временная сложность** | ++=============+===================================================+===========================+ +| > push() | > Добавление элемента в очередь, т. е. добавление | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ +| > pop() | > Удаление элемента из головы очереди | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ +| > peek() | > Доступ к элементу в голове очереди | > *O*(1) | ++-------------+---------------------------------------------------+---------------------------+ + +> элемента в конец очереди +> +> Можно использовать готовый класс очереди в языке программирования. +> +> \# === 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.5. +> +> ![](ru/docs/assets/media/image160.jpeg) + +![](ru/docs/assets/media/image162.jpeg)![](ru/docs/assets/media/image164.jpeg) + +> **Рис. 5.5.** Операции добавления и удаления в очереди, реализованной на основе связного списка +> +> Ниже приведен код реализации очереди с использованием связного списка. +> +> \# === File: linkedlist_queue.py === class LinkedListQueue: +> +> \"\"\"Очередь на основе связного списка.\"\"\" +> +> 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): \"\"\"Добавление в очередь.\"\"\" +> +> \# Добавление 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 +> +> def to_list(self) -\> list\[int\]: \"\"\"Преобразование в список для печати.\"\"\" queue = \[\] +> +> temp = self.\_front while temp: +> +> queue.append(temp.val) temp = temp.next +> +> return queue + +##### Реализация на основе массива + +> Удаление первого элемента в массиве имеет временную сложность *O*(*n*), что снижает эффективность операции удаления из очереди. Однако можно ис- пользовать следующий изящный метод, чтобы избежать этой проблемы. +> +> Можно использовать переменную front для указания на индекс первого эле- мента очереди и поддерживать переменную size для записи длины очереди. Определим переменную rear = front + size. Тогда rear будет указывать на сле- дующий элемент после хвоста очереди. +> +> В этой схеме **эффективный диапазон элементов в массиве составляет** + +\[front, rear - 1\]. Методы реализации различных операций показаны на рис. 5.6. + +- **Добавление в очередь**: присвоение нового элемента индексу rear и уве- личение size на 1. + +- **Удаление из очереди**: достаточно увеличить front на 1 и уменьшить size + +> на 1. +> +> Можно заметить, что добавление в очередь и удаление из нее требуют толь- ко двух операций, временная сложность каждой из которых равна *O*(1). + +![](ru/docs/assets/media/image166.jpeg) + +> **Рис. 5.6.** Операции добавления и удаления в очереди, реализованной на основе массива +> +> ![](ru/docs/assets/media/image168.jpeg) + +![](ru/docs/assets/media/image170.jpeg) + +> **Рис. 5.6.** *Окончание* +> +> Может возникнуть трудность: в процессе постоянного добавления и удале- ния положения front и rear перемещаются вправо, и **когда они достигают конца массива**, **дальнейшее перемещение становится невозможным**. Чтобы решить эту проблему, можно рассматривать массив как кольцевой мас- сив с соединенными концами. +> +> Для кольцевого массива необходимо, чтобы front или rear, пересекая конец массива, возвращались к его началу для продолжения обхода. Этот цикличе- ский процесс можно реализовать с помощью операции взятия остатка, при- мер кода приведен ниже. +> +> \# === 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 +> +> 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 +> +> Реализованная выше очередь все еще имеет ограничение: ее длина неиз- менна. Однако эту проблему несложно решить, заменить массив на динамиче- ский с помощью механизма расширения. Заинтересованные читатели могут попробовать реализовать это самостоятельно. +> +> Выводы о сравнении двух реализаций аналогичны выводам о стеке, поэто- му здесь мы не будем повторяться. + +### Типичные сценарии применения очереди + +1. Заказы на маркетплейсах. После оформления заказа покупателем он до- бавляется в очередь, и система затем обрабатывает заказы в порядке их поступления. В период распродаж за короткое время создается огром- ное количество заказов, и высокая нагрузка становится проблемой для разработчиков программного обеспечения. + +2. Различные списки задач. Любая ситуация, требующая реализации прин- ципа «первым пришел -- первым обслужен». Например, очередь заданий в принтере, очередь заказов в ресторане и т. д. Очередь в этих ситуациях эффективно поддерживает порядок обработки. + +#### двусторонняя очередь + +> В обычной очереди можно удалять только элементы из начала и добавлять элементы только в конец. *Двусторонняя очередь* предоставляет большую гиб- кость, позволяя выполнять операции добавления или удаления элементов как в начале, так и в конце, см. рис. 5.7. +> +> **Двусторонняя очередь** +> +> **(Deque)** +> +> **Голова очереди** +> +> Добавление элемента в голову +> +> ![](ru/docs/assets/media/image172.jpeg)push_first(**1**) +> +> Извлечение элемента с головы +> +> pop_first() +> +> **Извлечение из очереди** +> +> **Добавление в очередь** +> +> **Добавление в очередь** +> +> **Извлечение из очереди** +> +> **Хвост очереди** + +Добавление Извлечение элемента элемента + +> push_last(**4**) +> +> с хвоста +> +> pop_last() +> +> **Рис. 5.7.** Операции в двусторонней очереди + +### Основные операции с двусторонней очередью + +Обычные операции с двусторонней очередью представлены в табл. 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) | ++----------------+----------------------------------------+---------------------------+ + +> Аналогично обычной очереди можно использовать уже реализованный в языке программирования класс двусторонней очереди. +> +> \# === 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.8. +> +> ![](ru/docs/assets/media/image174.jpeg) + +![](ru/docs/assets/media/image176.jpeg)![](ru/docs/assets/media/image178.jpeg) + +> **Рис. 5.8.** Операции добавления и удаления в двусторонней очереди на основе связного списка +> +> ![](ru/docs/assets/media/image180.jpeg) + +![](ru/docs/assets/media/image182.jpeg) + +> **Рис. 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: +> +> \"\"\" Двусторонняя очередь на основе двусвязного списка.\"\"\" +> +> 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 \# Временное сохранение значения +> +> \# головного узла. +> +> \# Удаление головного узла. +> +> 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 + +##### Реализация на основе массива + +> Аналогично реализации обычной очереди для двусторонней очереди можно использовать кольцевой массив, как показано на рис. 5.9. + +![](ru/docs/assets/media/image184.jpeg)![](ru/docs/assets/media/image186.jpeg) + +> **Рис. 5.9.** Операции добавления и удаления в двусторонней очереди на основе массива +> +> ![](ru/docs/assets/media/image188.jpeg) + +![](ru/docs/assets/media/image190.jpeg) + +> **Рис. 5.9.** *Продолжение* +> +> ![](ru/docs/assets/media/image192.jpeg) +> +> **Рис. 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 +> +> 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: +> +> \"\"\" Доступ к элементу в начале очереди.\"\"\" +> +> 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 + +### Сценарии применения двусторонней очереди + +> Двусторонняя очередь сочетает в себе логику стека и очереди. **Поэтому она применима для всех сценариев этих двух структур**, **одновременно пре- доставляя большую степень свободы**. +> +> Известно, что функция отмены в программном обеспечении обычно реа- лизуется с помощью стека: система помещает каждое изменение в стек с по- мощью операции push, а затем выполняет отмену с помощью операции pop. Однако, учитывая ограничения системных ресурсов, программное обеспече- ние обычно ограничивает количество шагов отмены (например, позволяет со- хранить только 50 шагов). Когда длина стека превышает 50, программе нужно выполнить удаление внизу стека (в начале очереди). **Но стек не может реа- лизовать эту функцию**, **и в этом случае необходимо использовать дву- стороннюю очередь вместо стека**. Следует отметить, что основная логика отмены по-прежнему следует принципу стека «первым пришел -- последним вышел», просто двусторонняя очередь позволяет более гибко реализовать не- которые дополнительные логические операции. + +#### резюме + +##### Ключевые моменты + +- Стек -- это структура данных, которая следует принципу «первым при- шел -- последним вышел» и может быть реализована с помощью массива или связного списка. + +- В плане временной сложности реализация стека с использованием мас- сива обладает более высокой средней эффективностью, но во время рас- ширения сложность времени выполнения одной операции добавления + +> 5.4. Резюме ❖ **145** +> +> [в]{.smallcaps} стек может ухудшиться до *O*(*n*). В сравнении с этим реализация стека с использованием связного списка обладает более стабильной эффек- тивностью. + +- В плане пространственной сложности реализация стека с использовани- ем массива может привести к определенной степени потери простран- ства. Однако следует отметить, что память, занимаемая узлами связного списка, больше, чем у элементов массива. + +- Очередь -- это структура данных, которая следует принципу «первым пришел -- первым вышел» и также может быть реализована с помощью массива или связного списка. В плане временной и пространственной сложности выводы по очереди схожи с выводами по стеку. + +- Двусторонняя очередь -- это очередь с большей степенью свободы, она позволяет добавлять и удалять элементы с обоих концов. + +##### Вопросы и ответы + +> **Вопрос**. Реализована ли функция «вперед-назад» в браузере с помощью дву- стороннего связного списка? +> +> **Ответ**. Функция «вперед-назад» в браузере, по сути, является типичным проявлением стека. Когда пользователь посещает новую страницу, она добав- ляется на вершину стека. Когда пользователь нажимает кнопку **Назад**, страни- ца извлекается с вершины стека. Использование двусторонней очереди позво- ляет удобно реализовать некоторые дополнительные операции, что упомина- ется в разделе «Двусторонняя очередь». +> +> **Вопрос**. Нужно ли освобождать память узла после извлечения из стека? +> +> **Ответ**. Если в дальнейшем необходимо использовать извлеченный узел, то освобождать память не нужно. Если узел больше не нужен, в языках Java и Python имеется автоматический механизм сборки мусора, поэтому ручное освобожде- ние памяти не требуется. В C и C++ необходимо освобождать память вручную. +> +> **Вопрос**. Двусторонняя очередь похожа на два стека, соединенных вместе. +> +> Каково ее назначение? +> +> **Ответ**. Двусторонняя очередь подобна комбинации стека и очереди или двум стекам, соединенным вместе. Она представляет собой логику стека и очереди, поэтому подходит для всех сценариев их применения, но является более гибкой. **Вопрос**. Как конкретно реализуются функции отмены и повтора операций? +> +> **Ответ**. Используются два стека: стек A для отмены, стек B для повтора. + +1. Когда пользователь выполняет операцию, она помещается в стек A, а стек + +> B очищается. + +2. Когда пользователь выполняет отмену, из стека A извлекается последняя операция и помещается в стек B. + +3. Когда пользователь выполняет повтор, из стека B извлекается последняя операция и помещается в стек A. + +> Глава 6 + +# Хеш-таблицы + +![](ru/docs/assets/media/image193.jpeg){width="3.8073009623797027in" height="4.927083333333333in"} + +#### хеш-таблицы + +> *Хеш-таблица* реализует эффективный поиск элементов через установле- ние соответствия между ключом key и значением value. Более конкретно, передав ключ в хеш-таблицу, можно получить соответствующее значение за время *O*(1). +> +> Пусть имеется *n* студентов, у каждого из которых есть имя и номер. Если нужно реализовать функцию «ввести номер студента и получить соответству- ющее имя», то можно использовать хеш-таблицу, как показано на рис. 6.1. + +![](ru/docs/assets/media/image195.jpeg) + +> **Рис. 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), что очень эффективно. + +1. **Основные операции с хеш-таблицами** + +> К основным операциям с хеш-таблицами относятся: инициализация, поиск, добавление и удаление пар ключ--значение. Ниже приведен пример кода. +> +> \# === 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) + +### Простая реализация хеш-таблицы + +> Рассмотрим самый простой случай, **когда хеш-таблица реализуется с помо- щью одного массива**. В хеш-таблице каждый пустой слот массива называется корзиной, и каждая корзина может хранить одну пару ключ--значение. Таким образом, операция поиска заключается в нахождении корзины, соответствую- щей ключу key, и получении значения value из нее. +> +> Как определить корзину, соответствующий ключу? Это осуществляется с по- мощью хеш-функции. Хеш-функция предназначена для отображения большо- го входного пространства в меньшее выходное пространство. В хеш-таблице входное пространство -- это все ключи, а выходное пространство -- это все кор- зины (индексы массива). Другими словами, **передав ключ в хеш-функцию, можно определить место хранения пары ключ--значение в массиве**. +> +> Процесс вычисления хеш-функции для ключа включает следующие этапы: + +1) вычисление хеш-значения с помощью некоторого хеш-алгоритма hash(); + +2) взятие остатка от деления хеш-значения на количество корзин capacity (длину массива) для получения индекса массива index, соответствующе- го ключу key. + +> index = hash(key) % capacity +> +> После этого можно использовать значение index для доступа к соответству- ющей корзине в хеш-таблице и получения значения value. +> +> Предположим, что длина массива capacity = 100, хеш-алгоритм hash(key) = key. Тогда хеш-функция будет иметь вид key % 100. На рис. 6.2 показан принцип ра- боты хеш-функции на примере ключа «номер» и значения «имя» для студента. +> +> ![](ru/docs/assets/media/image197.jpeg) **Ввод 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: +> +> \"\"\" Хеш-таблица на основе массива.\"\"\" +> +> 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 = \[\] +> +> 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) + +### Хеш-коллизии и расширение + +> По своей сути хеш-функция выполняет отображение входного пространства, состоящего из всех ключей, в выходное пространство, состоящее из всех ин- дексов массива. Причем входное пространство зачастую значительно больше выходного. Следовательно, **теоретически неизбежна ситуация**, **когда не- сколько входов соответствуют одному выходу**. +> +> Для хеш-функции в примере выше, если последние две цифры ключа совпа- дают, результат хеш-функции также совпадает. Например, при запросе студен- тов с номерами 12836 и 20336 мы получим: +> +> 12836 % 100 = 36 +> +> 20336 % 100 = 36. +> +> Оба номера указывают на одно и то же имя, что очевидно неверно, см. рис. 6.3. Такую ситуацию, когда несколько входов соответствуют одному вы- ходу, называют хеш-коллизией. +> +> ![](ru/docs/assets/media/image199.jpeg) **Ввод key** +> +> **Индекс Массив Хеш-коллизия** +> +> **Вывод value** +> +> \"Иван\" + +Хеш-функция + +\"Петр\" + +\"Яков\" + +> \"Иван\" +> +> \"Иван\" +> +> \"Ира\" +> +> \"Аня\" +> +> (Каждая ячейка хранит одну пару ключ--значение) +> +> **Рис. 6.3.** Пример хеш-коллизии +> +> Логично предположить, что чем больше емкость хеш-таблицы *n*, тем ниже вероятность распределения нескольких ключей в одну корзину и тем меньше коллизий. Поэтому **можно уменьшить количество хеш-коллизий**, **увели- чивая емкость хеш-таблицы**. +> +> Как показано на рис. 6.4, до увеличения емкости пары ключ--значение (136, A) + +и (236, D) попадали в одну корзину, а после увеличения емкости коллизия исчезла. + +> ![](ru/docs/assets/media/image201.jpeg)**Увеличение хеш-таблицы** +> +> **Рис. 6.4.** Увеличение емкости хеш-таблицы +> +> Подобно увеличению емкости массива, увеличение емкости хеш-таблицы требует переноса всех пар ключ--значение из старой хеш-таблицы в новую, что является очень затратной по времени операцией. Кроме того, поскольку емкость хеш-таблицы изменяется, необходимо заново вычислять местополо- жение хранения всех пар ключ--значение с помощью хеш-функции, что еще больше увеличивает вычислительные затраты процесса расширения. Поэтому в языках программирования обычно резервируется достаточно большая ем- кость хеш-таблицы, чтобы избежать частого увеличения. +> +> Коэффициент заполнения является важным понятием для хеш-таблицы. Он определяется как количество элементов в хеш-таблице, деленное на коли- чество корзин, и используется для оценки степени серьезности хеш-коллизий, **а также часто служит условием для увеличения емкости хеш-таблицы**. Например, в Java, когда коэффициент заполнения превышает 0.75, система увеличивает емкость хеш-таблицы в 2 раза. + +#### хеш-коллизии + +> Как упоминалось в предыдущем разделе, **в обычных условиях входное про- странство хеш-функции значительно больше выходного пространства**, +> +> поэтому хеш-коллизии теоретически неизбежны. Например, если входное пространство состоит из всех целых чисел, а выходное пространство соответ- ствует размеру массива, то обязательно несколько целых чисел будут отобра- жаться в один и тот же индекс корзины. +> +> Хеш-коллизии могут привести к ошибкам в результатах запросов, серьезно влияя на работоспособность хеш-таблицы. Чтобы решить эту проблему, при возникновении хеш-коллизий выполняется увеличение емкости хеш-таблицы до тех пор, пока коллизии не исчезнут. Этот метод понятен и прост в реализа- ции, но крайне неэффективен, поскольку увеличение емкости хеш-таблицы требует значительных затрат на перенос данных и вычисление хеш-значений. Для повышения эффективности можно использовать следующие стратегии: + +1) улучшение структуры данных хеш-таблицы, **чтобы она могла нор- мально функционировать при возникновении хеш-коллизий**; + +2) выполнение увеличения емкости только при необходимости, т. е. когда хеш-коллизии становятся достаточно серьезными. + +> Основные методы улучшения структуры хеш-таблицы включают цепную адресацию и открытую адресацию. + +### Цепная адресация + +> В исходной хеш-таблице каждая корзина может хранить только одну пару ключ--значение. Цепная адресация преобразует отдельный элемент в связный список, где пары ключ--значение выступают в качестве узлов списка, и все пары ключ--значение, вызвавшие коллизии, хранятся в одном и том же списке. На рис. 6.5 представлен пример хеш-таблицы с цепной адресацией. + +![](ru/docs/assets/media/image203.jpeg) + +> **Рис. 6.5.** Хеш-таблица с цепной адресацией +> +> Методы работы с хеш-таблицей, реализованной на основе цепной адреса- ции, изменяются следующим образом. + +- **Поиск элемента**: вводится ключ, с помощью хеш-функции определяет- ся индекс корзины, после чего осуществляется доступ к головному узлу списка. Затем выполняется обход списка и сравнение ключей для поиска целевой пары ключ--значение. + +- **Добавление элемента**: сначала с помощью хеш-функции осуществля- ется доступ к головному узлу списка, затем узел (пара ключ--значение) добавляется в список. + +- **Удаление элемента**: на основе результата хеш-функции осуществляется доступ к головному узлу списка, затем выполняется обход списка для по- иска целевого узла и его удаления. + +> Цепная адресация имеет следующие ограничения. + +- **Увеличение занимаемого пространства**: связный список содержит указатели на узлы, что требует больше памяти по сравнению с массивом. + +- **Снижение эффективности поиска**: необходимо линейно обходить связный список для нахождения соответствующего элемента. + +> Ниже приведен простой пример реализации хеш-таблицы с цепной адреса- цией. Следует обратить внимание на следующие моменты. + +- Использование списка (динамического массива) вместо связного списка для упрощения кода. В данной конфигурации хеш-таблица (массив) со- держит несколько корзин, каждая из которых является списком. + +- В данной реализации предусмотрен метод расширения хеш-таблицы. Когда коэффициент заполнения превышает 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: \"\"\" Операция поиска.\"\"\" +> +> 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) +> +> 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*). + +### Открытая адресация + +> Открытая адресация не вводит дополнительные структуры данных, а исполь- зует многократное пробирование для обработки хеш-конфликтов. Основные методы пробирования включают линейное пробирование, квадратичное про- бирование и двойное хеширование. +> +> Далее на примере линейного зондирования рассмотрим механизм работы хеш-таблицы с открытой адресацией. + +##### Линейное зондирование + +> При линейном зондировании используется линейный поиск с фиксирован- ным шагом. Метод зондирования отличается от обычной хеш-таблицы. + +- **Вставка элемента**: с помощью хеш-функции вычисляется индекс кор- зины. Если корзина уже занята, начинается линейный обход от места конфликта (обычно с шагом 1) до нахождения пустой корзины, куда и вставляется элемент. + +- **Поиск элемента**: при обнаружении хеш-конфликта используется тот же шаг для линейного обхода до нахождения соответствующего элемента, после чего возвращается его значение. Если встречается пустая корзина, это озна- чает, что целевой элемент отсутствует в хеш-таблице, возвращается None. + +> На рис. 6.6 демонстрируется распределение пар ключ--значение в хеш- таблице с открытой адресацией (линейное зондирование). Согласно этой хеш- функции ключи с одинаковыми последними двумя цифрами отображаются в одну корзину. Однако благодаря линейному зондированию они последова- тельно размещаются в этой и следующих корзинах. +> +> Однако линейное зондирование **подвержено так называемой класте- ризации**. Чем длиннее последовательность занятых позиций в массиве, тем выше вероятность хеш-конфликта в этих позициях. Это способствует дальней- шему росту кластера, создавая порочный круг и в конечном итоге снижая эф- фективность операций добавления, удаления и поиска. +> +> Стоит отметить, что **в хеш-таблице с открытой адресацией нельзя на- прямую удалять элементы**. Это связано с тем, что удаление элемента соз- дает в массиве пустую корзину None. И затем при поиске элемента линейное зондирование, достигнув этой пустой корзины, закончит поиск, что приведет к невозможности доступа к элементам ниже этой корзины. Программа может ошибочно посчитать, что эти элементы отсутствуют, как показано на рис. 6.7. +> +> ![](ru/docs/assets/media/image205.jpeg) +> +> **Рис. 6.6.** Распределение пар ключ--значение в хеш-таблице с открытой адресацией (линейное зондирование) + +![](ru/docs/assets/media/image207.jpeg) + +> **Рис. 6.7.** Проблемы поиска, вызванные удалением элементов при открытой адресации +> +> Для решения этой проблемы можно использовать механизм ленивого удаления: элементы не удаляются из хеш-таблицы напрямую, **вместо это- го для пометки этой корзины используется константа** TOMBSTONE. При та- ком подходе None и TOMBSTONE обозначают пустую корзину, в которую можно поместить пару ключ--значение. Однако при линейном зондировании зна- чение TOMBSTONE не завершает обход, так как ниже все еще могут находиться пары ключ--значение. +> +> Однако **ленивое удаление может ускорить падение производительно- сти хеш-таблицы**. Это происходит потому, что каждая операция удаления оставляет метку удаления, и с увеличением количества значений 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: +> +> \# Если ранее была метка удаления, перемещение пары +> +> \# ключ-значение в этот индекс. 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 +> +> 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) + +##### Квадратичное зондирование + +> Квадратичное зондирование, как и линейное, является одной из распростра- ненных стратегий открытой адресации. При возникновении конфликта ква- дратичное зондирование пропускает не просто фиксированное количество шагов, а количество шагов, равное квадрату числа попыток, т. е. 1, 4, 9, \... +> +> Квадратичное зондирование обладает следующими преимуществами: + +- квадратичное зондирование, пропуская расстояние, равное квадрату числа попыток, стремится сгладить эффект кластеризации линейного зондирования; + +- квадратичное зондирование пропускает большее расстояние в поисках пустого места, что способствует более равномерному распределению данных. + +> Тем не менее квадратичный поиск не является идеальным и обладает сле- дующими недостатками: + +- все еще существует эффект кластеризации, т. е. некоторые позиции за- нимаются более вероятно, чем другие; + +- из-за быстрого роста квадрата квадратичное зондирование может не ох- ватить всю хеш-таблицу. То есть даже при наличии в хеш-таблице пустых корзин квадратичное зондирование может никогда не добраться до них. + +##### Множественное хеширование + +> Как следует из названия, метод множественного хеширования использует для поиска несколько хеш-функций *f*1(*x*), *f*2(*x*), *f*3(*x*), \... + +- **Вставка элемента**: если хеш-функция *f*1(*x*) вызывает конфликт, вычис- ляется *f*2(*x*) и т. д., пока не будет найдено пустое место для вставки эле- мента. + +- **Поиск элемента**: поиск выполняется в том же порядке хеш-функций, пока не будет найден целевой элемент. Если встречается пустое место или были испробованы все хеш-функции, это означает, что элемента в хеш-таблице нет, и возвращается None. + +> По сравнению с линейным зондированием метод множественного хеши- рования менее склонен к кластеризации, но использование нескольких хеш- функций увеличивает вычислительную нагрузку. + +### Выбор языка программирования + +> Различные языки программирования используют разные стратегии реализа- ции хеш-таблиц, ниже приведено несколько примеров. + +- В Python используется метод открытой адресации. В словарях dict для поиска применяется псевдослучайное число. + +- В Java используется цепная адресация. Начиная с JDK 1.8, когда длина мас- сива в HashMap достигает 64, а длина цепочки достигает 8, цепочка преоб- разуется в красно-черное дерево для повышения эффективности поиска. + +- В Go используется цепная адресация. Здесь предусмотрено, что в каждой корзине может храниться не более 8 пар ключ--значение. При превыше- нии емкости подключается дополнительная корзина. При избыточном количестве дополнительных корзин выполняется специальная опера- ция расширения для поддержания производительности. + +#### Алгоритмы хеширования + +> В предыдущих разделах были рассмотрены принципы работы хеш-таблиц и методы обработки хеш-конфликтов. Однако ни открытая, ни цепная адре- сация не могут уменьшить вероятность возникновения хеш-конфликтов, **они лишь обеспечивают корректную работу хеш-таблицы при их воз- никновении**. +> +> Если хеш-конфликты происходят слишком часто, производительность хеш- таблицы резко снижается. Как показано на рис. 6.8, для хеш-таблицы с цепной адресацией в идеальном случае пары ключ--значение равномерно распреде- лены по всем корзинам, что обеспечивает наилучшую эффективность поиска. В худшем случае все пары ключ--значение хранятся в одной корзине, и вре- менная сложность повышается до *O*(*n*). +> +> ![](ru/docs/assets/media/image209.jpeg) +> +> **Рис. 6.8.** Лучший и худший случаи хеш-конфликтов +> +> **Распределение пар ключ--значение определяется хеш-функцией**. Вспомним этапы вычисления хеш-функции: сначала вычисляется хеш- значение, затем берется остаток от деления на длину массива. +> +> index = hash(key) % capacity +> +> Из этого выражения видно, что при фиксированной емкости хеш-таблицы capacity **алгоритм хеширования** hash() **определяет выходное значение**, которое, в свою очередь, определяет распределение пар ключ--значение в хеш-таблице. +> +> Это означает, что для снижения вероятности возникновения хеш-конфликтов следует сосредоточиться на разработке алгоритма хеширования hash(). + +### Цели алгоритма хеширования + +> Для создания быстрой и надежной структуры данных хеш-таблицы алгоритм хеширования должен обладать следующими характеристиками. + +- **Детерминированность**: для одинакового ввода алгоритм хеширования должен всегда давать одинаковый вывод. Это необходимо для обеспече- ния надежности работы хеш-таблицы. + +- **Высокая эффективность**: процесс вычисления хеш-значения должен быть достаточно быстрым. Чем меньше вычислительные затраты, тем выше практическая ценность хеш-таблицы. + +- **Равномерное распределение**: алгоритм хеширования должен обе- спечивать равномерное распределение пар ключ--значение в хеш- таблице. Чем равномернее распределение, тем ниже вероятность хеш- конфликтов. + +> На практике алгоритмы хеширования применяются не только для реализа- ции хеш-таблиц, но и в других областях. + +- **Хранение паролей**: для защиты паролей пользователей система обыч- но не хранит пароли в открытом виде, а сохраняет их хеш-значения. Когда пользователь вводит пароль, система вычисляет его хеш-значение и сравнивает с сохраненным. Если они совпадают, пароль считается правильным. + +- **Проверка целостности данных**: отправитель данных может вычис- лить хеш-значение данных и отправить его вместе с данными. По- лучатель может заново вычислить хеш-значение полученных данных и сравнить его с полученным. Если они совпадают, данные считаются неизмененными. + +> В криптографических приложениях для предотвращения обратного вычис- ления исходного пароля из хеш-значения и других видов обратной инженерии алгоритм хеширования должен обладать дополнительными характеристиками. + +- **Необратимость**: невозможность извлечь какую-либо информацию о входных данных из хеш-значения. + +- **Устойчивость к коллизиям**: должно быть крайне сложно найти два раз- личных входа, дающих одинаковое хеш-значение. + +- **Эффект лавины**: небольшие изменения на входе должны приводить к значительным и непредсказуемым изменениям на выходе. + +> Следует отметить, что **«равномерное распределение» и «устойчивость к коллизиям»** -- это два независимых понятия, и выполнение одного из них не обязательно означает выполнение другого. Например, хеш-функция key % 100 при случайном вводе значения key может давать равномерное распределение. Однако этот алгоритм хеширования слишком прост, и все ключи с одинаковыми последними двумя цифрами будут иметь одинако- вый вывод, что позволяет легко извлечь пригодные ключи из хеш-значения и взломать пароль. + +### Разработка алгоритма хеширования + +> Создание хеш-алгоритмов представляет собой сложную задачу, требующую учета множества факторов. Однако для некоторых несложных сценариев мож- но разработать простые хеш-алгоритмы. + +- **Аддитивный хеш**: складываются ASCII-коды каждого символа входных данных, полученная сумма используется в качестве хеш-значения. + +- **Мультипликативный хеш**: используя свойство некоррелированности умножения, на каждом шаге значение хеша умножается на константу, и в результат добавляется ASCII-код очередного символа. + +- **Хеш с использованием операции XOR**: каждый элемент входных дан- ных накапливается в хеш-значении с помощью операции XOR. + +- **Ротационный хеш**: ASCII-коды каждого символа накапливаются в хеш- значении, при этом перед каждым накоплением выполняется операция ротации хеш-значения. + +> \# === 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: +> +> 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, \... }. +> +> Следует отметить, что если ключи распределены случайно и равномерно, то выбор простого или составного числа в качестве модуля не имеет значения -- оба варианта обеспечат равномерное распределение хеш-значений. Однако при наличии периодичности в распределении ключей использование состав- ного числа в качестве модуля может привести к кластеризации. +> +> В общем случае выбирается простое число в качестве модуля, и это простое число должно быть достаточно большим, чтобы максимально устранить пери- одические закономерности и повысить устойчивость хеш-алгоритма. + +### Распространенные хеш-алгоритмы + +> Нетрудно заметить, что описанные выше простые хеш-алгоритмы довольно хрупкие и далеки от достижения целей создания хеш-алгоритмов. Например, сложение и операция XOR удовлетворяют коммутативному закону, поэтому соответствующие хеш-алгоритмы не различают строки с одинаковым содер- жанием, но разным порядком символов, что может усилить хеш-конфликты и вызвать некоторые проблемы с безопасностью. +> +> На практике обычно используются стандартные хеш-алгоритмы, такие как MD5, SHA-1, SHA-2 и SHA-3. Они могут отображать входные данные произ- вольной длины в хеш-значения фиксированной длины. +> +> На протяжении почти ста лет хеш-алгоритмы постоянно обновляются и оп- тимизируются. Одни исследователи стремятся повысить производительность, другие исследователи и хакеры сосредоточены на поиске проблем с безопас- ностью. В табл. 6.2 представлены распространенные хеш-алгоритмы, исполь- зуемые в реальных приложениях. + +- В MD5 и SHA-1 были обнаружены многочисленные уязвимости, поэтому они не используются в сценариях, в которых требуется высокий уровень безопасности. + +- SHA-256 из серии SHA-2 является одним из самых безопасных хеш- алгоритмов, до сих пор не было обнаружено ни одной уязвимости, по- этому он часто используется в различных приложениях и протоколах безопасности. + + + +- SHA-3 имеет меньшие затраты на реализацию и более высокую вычис- лительную эффективность по сравнению с SHA-2, но в настоящее время его использование не так широко распространено, как серии SHA-2. + ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| | > **MD5** | > **SHA-1** | > **SHA-2** | > **SHA-3** | ++==========================+==================================================================+================+=======================+==================================================+ +| > Год появления | > 1992 | > 1995 | > 2002 | > 2008 | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Длина вывода | > 128 бит | > 160 бит | > 256/512 бит | > 224/256/384/512 бит | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Хеш-конфликты | > Много | > Много | > Мало | > Мало | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Уровень без- опасности | > Низкий, есть из- вестные уязви- мости | > Низкий, есть | > Высокий | > Высокий | +| | | > | | | +| | | > уязвимости | | | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ +| > Применение | > Устарел, но еще используется для проверки целост- ности данных | > Устарел | > Проверка транзакций | > Может использовать- ся в качестве замены SHA-2 | +| | | | > | | +| | | | > в криптовалюте, | | +| | | | > | | +| | | | > подписи и т. д. | | ++--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ + +> известные +> +> цифровые + +### Хеш-значения для структур данных + +> Ключи в хеш-таблице могут быть представлены в виде целых чисел, дробей или строк. Языки программирования обычно предоставляют встроенные хеш- алгоритмы для своих типов данных, чтобы вычислять индексы корзин в хеш- таблице. Например, в Python можно вызвать функцию hash() для вычисления хеш-значений для различных типов данных. + +- Хеш-значение целых чисел и булевых величин совпадает с их значением. + +- Хеш-значение дробных чисел и строк вычисляется по более сложному ал- горитму, заинтересованные читатели могут изучить его самостоятельно. + +- Хеш-значение кортежа получается путем хеширования каждого элемен- та и объединения этих хеш-значений в одно. + +- Хеш-значение объекта генерируется на основе его адреса в памяти. Пу- тем переопределения метода хеширования объекта можно реализовать генерацию хеша на основе его содержимого. + +> \# === File: built_in_hash.py === +> +> num = 3 +> +> hash_num = hash(num) +> +> \# Хеш-значение целого числа 3 равно 3. +> +> 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) +> +> \# Хеш-значение объекта \ равно 274267521. +> +> Во многих языках программирования **только неизменяемые объекты могут использоваться в качестве ключей в хеш-таблице**. Если список (ди- намический массив) используется в качестве ключа, то при изменении его со- держимого хеш-значение также изменится, и мы не сможем найти исходное значение. +> +> Хотя переменные-члены пользовательских объектов (например, узлов связного списка) могут быть изменяемыми, сами объекты можно хеширо- вать. **Это связано с тем**, **что хеш-значение объекта обычно генериру- ется на основе его адреса в памяти**, и даже если содержимое объекта из- меняется, адрес остается неизменным, а значит, и хеш-значение также оста- ется прежним. +> +> Возможно, вы заметили, что при запуске программы в разных окнах выво- димые хеш-значения отличаются. **Это связано с тем, что интерпретатор Py- thon при каждом запуске добавляет случайное значение «соли» к функ- ции хеширования строк**. Такой подход эффективно предотвращает атаки типа HashDoS и повышает безопасность хеш-алгоритма. + +#### резюме + +##### Ключевые моменты + +- При вводе ключа key хеш-таблица может найти значение value за время + +> *O*(1), что очень эффективно. + +- Основные операции с хеш-таблицами включают поиск, добавление пар ключ--значение, удаление пар и обход хеш-таблицы. + +- Хеш-функция отображает ключ в индекс массива, что позволяет полу- чить доступ к соответствующей корзине и извлечь значение. + + - Два разных ключа могут после хеширования получить одинаковый ин- декс массива, что приводит к ошибке в результате поиска. Это явление называется хеш-конфликтом. + + - Чем больше емкость хеш-таблицы, тем ниже вероятность хеш- конфликтов. Поэтому расширение хеш-таблицы может уменьшить ко- личество конфликтов, но, как и в случае с массивами, расширение хеш- таблицы требует значительных затрат. + + - Коэффициент заполнения определяется как отношение числа элемен- тов в хеш-таблице к числу корзин и отражает степень серьезности хеш- конфликтов. Он часто используется как условие для расширения хеш- таблицы. + + - Метод цепной адресации преобразует отдельные элементы в связный спи- сок, храня все конфликтующие элементы в одном списке. Однако слишком длинные списки снижают эффективность поиска, поэтому для повышения эффективности можно преобразовать списки в красно-черные деревья. + + - Метод открытой адресации решает хеш-конфликты с помощью многократ- ного поиска (зондирования). Линейное зондирование использует фикси- рованный шаг, но его элементы нельзя удалять, и он склонен к кластери- зации. Множественное хеширование использует несколько хеш-функций для поиска, что снижает вероятность кластеризации по сравнению с ли- нейным зондированием, но увеличивает вычислительную нагрузку. + + - Разные языки программирования реализуют хеш-таблицы по-разному. Например, класс HashMap в Java использует цепную адресацию, тогда как Dict в Python применяет метод открытой адресации. + + - В хеш-таблицах мы стремимся, чтобы хеш-алгоритмы обладали детер- минированностью, высокой эффективностью и равномерным распреде- лением. В криптографии хеш-алгоритмы также должны обладать устой- чивостью к коллизиям и эффектом лавины. + + - Хеш-алгоритмы обычно используют большие простые числа в качестве модуля, чтобы гарантировать максимально равномерное распределение хеш-значений и уменьшить количество хеш-коллизий. + + - К распространенным хеш-алгоритмам относятся MD5, SHA-1, SHA-2 и SHA-3. MD5 часто используется для проверки целостности файлов, а SHA-2 -- для приложений и протоколов безопасности. + + - Языки программирования обычно предоставляют встроенные хеш- алгоритмы для типов данных, которые используются для вычисления индексов корзин в хеш-таблицах. Как правило, хешировать можно толь- ко неизменяемые объекты. + +##### Вопросы и ответы + +> **Вопрос**. В каких случаях временная сложность хеш-таблицы составляет *O*(*n*)? +> +> **Ответ**. Когда хеш-коллизий становится достаточно много, временная слож- ность хеш-таблицы может ухудшиться до *O*(*n*). Если хеш-функция хорошо спроектирована, установлена разумная емкость, а коллизии распределены равномерно, временная сложность составляет *O*(1). При использовании встро- енных хеш-таблиц языков программирования обычно предполагается, что временная сложность составляет *O*(1). +> +> **Вопрос**. Почему бы не использовать хеш-функцию *f*(*x*) = *x*? Тогда не будет коллизий. +> +> **Ответ**. При использовании хеш-функции *f*(*x*) = *x* каждому элементу соот- ветствует уникальный индекс корзины, что эквивалентно массиву. Однако пространство входных данных обычно значительно больше пространства выходных данных (длины массива), поэтому последним этапом работы хеш- функции часто является взятие остатка от деления на длину массива. Други- ми словами, цель хеш-таблицы -- отобразить большее пространство состояний в меньшее пространство и обеспечить эффективность запросов *O*(1). +> +> **Вопрос**. Почему реализация хеш-таблицы на основе массива, связного спи- ска или двоичного дерева может быть более эффективной? +> +> **Ответ**. Во-первых, временная эффективность хеш-таблицы увеличивается, но пространственная эффективность уменьшается. Значительная часть памя- ти хеш-таблицы остается неиспользованной. +> +> Во-вторых, временная эффективность увеличивается только в определен- ных сценариях использования. Если функцию можно реализовать с использо- ванием массива или связного списка с той же временной сложностью, то это обычно быстрее, чем хеш-таблица. Это связано с тем, что вычисление хеш- функции требует затрат, а константа временной сложности больше. +> +> Наконец, временная сложность хеш-таблицы может ухудшиться. Например, при использовании цепной адресации поиск выполняется в связном списке или красно-черном дереве, что все еще может привести к ухудшению до времени *O*(*n*). **Вопрос**. Есть ли у метода множественного хеширования недостаток невоз- можности прямого удаления элементов? Можно ли повторно использовать +> +> пространство, помеченное как удаленное? +> +> **Ответ**. Множественное хеширование является одной из форм открытой адресации, и все методы открытой адресации имеют недостаток невозможно- сти прямого удаления элементов, что требует пометки удаленных элементов. Пространство, помеченное как удаленное, можно использовать повторно. Ког- да новый элемент вставляется в хеш-таблицу и хеш-функция находит пози- цию, помеченную как удаленная, эту позиция можно использовать для нового элемента. Это позволяет сохранить последовательность поиска в хеш-таблице неизменной и обеспечить эффективность использования пространства. +> +> **Вопрос**. Почему в линейном зондировании при поиске элемента возникают хеш-коллизии? +> +> **Ответ**. При поиске с помощью хеш-функции находится соответствующая корзина и пара ключ--значение. Если ключ не совпадает, это означает наличие хеш-коллизии. Поэтому метод линейного зондирования будет последователь- но искать с заранее заданным шагом, пока не найдет правильное значение ключа или не достигнет условия конца поиска. +> +> **Вопрос**. Почему расширение хеш-таблицы может уменьшить количество хеш-коллизий? +> +> **Ответ**. Последним шагом хеш-функции часто является взятие остатка от деления на длину массива *n*, чтобы выходное значение попадало в диапазон индексов массива. После расширения длина массива *n* изменяется, и соответ- ствующий индекс ключа также может измениться. Несколько ключей, которые ранее попадали в одну корзину, после расширения могут распределиться по нескольким корзинам, что снижает количество хеш-коллизий. +> +> Глава 7 + +# Деревья + +![](ru/docs/assets/media/image210.jpeg){width="3.71875in" height="4.8125in"} + +#### двоичные деревья + +> *Двоичное (бинарное) дерево* -- это нелинейная структура данных, представля- ющая отношения между предками и потомками и отражающая логику «раз- деляй и властвуй». Подобно спискам, основным элементом двоичного дерева является узел, который содержит значение, ссылку на левый дочерний узел и ссылку на правый дочерний узел. +> +> 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 и все узлы ниже него. + +![](ru/docs/assets/media/image212.jpeg) + +> **Рис. 7.1.** Родительский узел, дочерние узлы, поддеревья + +1. **Основные понятия двоичного дерева** + +> Основные понятия двоичного дерева изображены на рис. 7.2. + +- **Корневой узел**: узел, находящийся на верхнем уровне дерева и не име- ющий родительского узла. + +- **Листовой узел**: узел, не имеющий дочерних узлов, оба его указателя указывают на None. + +- **Ребро**: отрезок, соединяющий два узла, т. е. ссылка (указатель) узла. + +- **Уровень узла**: увеличивается сверху вниз, уровень корневого узла равен 1. + +- **Степень узла**: количество дочерних узлов узла. В двоичном дереве сте- пень может быть 0, 1 или 2. + +- **Высота двоичного дерева**: количество ребер от корневого узла до са- мого удаленного листового узла. + +- **Глубина узла**: количество ребер от корневого узла до данного узла. + +- **Высота узла**: количество ребер от самого удаленного листового узла до данного узла. + +![](ru/docs/assets/media/image214.jpeg) + +> **Рис. 7.2.** Основные понятия двоичного дерева + +### Основные операции с двоичными деревьями + +##### Инициализация двоичного дерева + +> Подобно спискам, сначала инициализируются узлы, затем строятся ссылки (указатели). +> +> \# === 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 + +##### Вставка и удаление узлов + +> Подобно спискам, в двоичном дереве вставку и удаление узлов можно выпол- нять путем изменения указателей. На рис. 7.3 приведен пример. + +![](ru/docs/assets/media/image216.jpeg) + +> **Рис. 7.3.** Вставка и удаление узлов в двоичном дереве +> +> \# === File: binary_tree.py === \# Вставка и удаление узлов. +> +> p = TreeNode(0) +> +> \# Вставка узла P между n1 и n2. +> +> n1.left = p p.left = n2 +> +> \# Удаление узла P. n1.left = n2 + +### Основные типы двоичных деревьев + +##### Идеальное двоичное дерево + +> В идеальном двоичном дереве все уровни узлов полностью заполнены, см. рис. 7.4. В таком дереве степень листовых узлов равна 0, а степень всех осталь- ных узлов равна 2. Если высота дерева равна *h*, то общее количество узлов равно 2*h*+1 − 1, что представляет собой стандартное экспоненциальное соотношение, отражающее явление деления клеток, которое часто встречается в природе. + +![](ru/docs/assets/media/image218.jpeg) + +> **Рис. 7.4.** Идеальное двоичное дерево + +##### Совершенное двоичное дерево + +> В совершенном двоичном дереве (complete binary tree) заполнены не полно- стью только узлы на самом нижнем уровне, и они заполняются слева направо, см. рис. 7.5. Обратите внимание, что идеальное двоичное дерево также явля- ется полным. + +![](ru/docs/assets/media/image220.jpeg) + +> **Рис. 7.5.** Совершенное двоичное дерево + +##### Полное двоичное дерево + +> В полном двоичном дереве (full binary tree) все узлы, кроме листовых, имеют два дочерних узла, см. рис. 7.6. + +![](ru/docs/assets/media/image222.jpeg) + +> **Рис. 7.6.** Полное двоичное дерево + +##### Сбалансированное двоичное дерево + +> В сбалансированном двоичном дереве абсолютное значение разности высот левого и правого поддеревьев любого узла не превышает 1, см. рис. 7.7. + +![](ru/docs/assets/media/image224.jpeg) + +> **Рис. 7.7.** Сбалансированное двоичное дерево + +### Вырождение двоичного дерева + +> На рис. 7.8 изображена идеальная и вырожденная структура двоичного дерева. Когда каждый уровень двоичного дерева полностью заполнен узлами, оно ста- новится идеальным. Если все узлы смещены в одну сторону, двоичное дерево вырождается в связный список. + +- Идеальное двоичное дерево является оптимальным случаем, позволя- ющим в полной мере использовать преимущество подхода «разделяй и властвуй». + +- Связный список представляет собой другой крайний случай, когда все опе- рации становятся линейными, а временная сложность деградирует до *O*(*n*). + +![](ru/docs/assets/media/image226.jpeg) + +> **Рис. 7.8.** Идеальная и вырожденная структуры двоичного дерева +> +> Как показано в табл. 7.1, в идеальной и вырожденной структурах двоичного дерева количество листьев, общее количество узлов и высота достигают мак- симальных или минимальных значений. +> +> **Таблица 7.1.** Идеальная и вырожденная структуры двоичного дерева +> +> **Идеальное двоичное дерево Связный список** +> +> Количество узлов на уровне *i* 2*i*−1 1 +> +> Количество листьев в дереве высоты *h* 2*h* 1 +> +> Общее количество узлов в дереве высоты *h* +> +> 2*h*+1 − 1 *h* + 1 +> +> Высота дерева с *n* узлами log2(*n* + 1) − 1 *n* − 1 + +#### Обход двоичного дерева + +> С физической точки зрения дерево является структурой данных, основанной на связном списке, поэтому его обход осуществляется последовательным до- ступом к узлам через указатели. Однако, будучи нелинейной структурой дан- ных, обход дерева сложнее, чем обход связного списка, и требует использова- ния алгоритмов поиска. +> +> Наиболее распространенные методы обхода двоичного дерева включают обход по уровням, прямой, симметричный и обратный обходы. + +### Обход по уровням + +> Обход по уровням осуществляется сверху вниз, выполняется последователь- ный обход двоичного дерева с посещением узлов на каждом уровне слева на- право, как показано на рис. 7.9. +> +> ![](ru/docs/assets/media/image228.jpeg)**Обход в ширину** +> +> **Обход по уровням** +> +> ![](ru/docs/assets/media/image229.png){width="8.0582895888014e-2in" height="8.0582895888014e-2in"}(узлы посещаются по точкам ) +> +> **Рис. 7.9.** Обход двоичного дерева по уровням +> +> Обход по уровням по своей сути является обходом в ширину, также назы- ваемым поиском в ширину, который характеризуется постепенно расширяю- щимся кольцом от центра к периферии. + +##### Код реализации + +> Обход в ширину обычно реализуется с использованием очереди. Очередь сле- дует принципу «первый вошел -- первый вышел», а обход в ширину -- принципу +> +> «поэтапное продвижение», что делает их концептуально схожими. Ниже при- веден код реализации. +> +> \# === 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 + +##### Анализ сложности + +> **Временная сложность** *O*(*n*): каждый узел посещается один раз, что занимает +> +> *O*(*n*) времени выполнения, где *n* -- количество узлов. +> +> **Пространственная сложность** *O*(*n*): в худшем случае, т. е. в полном двоич- ном дереве, до достижения самого нижнего уровня в очереди может находить- ся одновременно (*n* + 1)/2 узлов, что занимает *O*(*n*) пространства. + +### Прямой, симметричный и обратный обходы + +> Прямой, симметричный и обратный обходы относятся к обходам в глубину, также называемым поиск в глубину, который характеризуется подходом «сна- чала до конца, затем возврат и продолжение». +> +> На рис. 7.10 демонстрируется принцип работы обхода в глубину для дво- ичного дерева. **Обход в глубину можно представить как обход двоичного дерева по периметру**, при этом на каждом узле встречаются три позиции, соответствующие прямому, симметричному и обратному обходам. +> +> ![](ru/docs/assets/media/image237.jpeg) +> +> **Рис. 7.10.** Прямой, симметричный и обратный обходы двоичного дерева + +##### Код реализации + +> Поиск в глубину обычно реализуется на основе рекурсии. +> +> \# === 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) +> +> На рис. 7.11 демонстрируется рекурсивный процесс прямого обхода двоич- ного дерева, который можно разделить на два противоположных этапа: рекур- сия и возврат. + +1. Рекурсия означает начало нового метода, в процессе которого програм- ма посещает следующий узел. + +2. Возврат означает возвращение функции, что указывает на завершение посещения текущего узла. + +![](ru/docs/assets/media/image246.jpeg)![](ru/docs/assets/media/image249.jpeg) + +> **Рис. 7.11.** Рекурсивный процесс прямого обхода. Шаги 1--2 +> +> ![](ru/docs/assets/media/image251.jpeg) + +![](ru/docs/assets/media/image253.jpeg)![](ru/docs/assets/media/image256.jpeg) + +> **Рис. 7.11.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image259.jpeg) + +![](ru/docs/assets/media/image261.jpeg)![](ru/docs/assets/media/image263.jpeg) + +> **Рис. 7.11.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image266.jpeg) + +![](ru/docs/assets/media/image269.jpeg)![](ru/docs/assets/media/image271.jpeg) + +> **Рис. 7.11.** *Окончание*. Шаги 9--11 + +##### 2. Анализ сложности + +> **Временная сложность** *O*(*n*): все узлы посещаются один раз, что занимает *O*(*n*) времени. +> +> **Пространственная сложность** *O*(*n*): в худшем случае, когда дерево вырож- дается в список, глубина рекурсии достигает *n*, система занимает *O*(*n*) про- странства стека. + +#### представление двоичного дерева с помощью массива + +> При представлении в виде списка единицей хранения двоичного дерева яв- ляется узел TreeNode, а узлы соединяются между собой указателями. В преды- дущем разделе были рассмотрены основные операции с двоичным деревом, представленным в виде списка. +> +> Можно ли представить двоичное дерево с помощью массива? Ответ поло- жительный. + +### Представление идеального двоичного дерева + +> Сначала рассмотрим простой пример. Если дано идеальное двоичное дерево и все его узлы хранятся в массиве в порядке обхода по уровням, то каждому узлу соответствует уникальный индекс массива. +> +> На основе свойств обхода по уровням можно вывести формулу соответ- ствия между индексами родительского и дочерних узлов: **если индекс узла равен** *i*, **то индекс его левого дочернего узла равен** 2*i* + 1, **а правого** -- 2*i* + 2. На рис. 7.12 показаны отношения соответствия между индексами узлов. + +![](ru/docs/assets/media/image273.jpeg) + +> **Рис. 7.12.** Представление идеального двоичного дерева с помощью массива +> +> **Формула соответствия играет роль**, **аналогичную ссылкам (указате- лям) в списке**. Имея любой узел в массиве, можно с помощью формулы полу- чить доступ к его левому и правому дочерним узлам. + +### Представление произвольного двоичного дерева + +> Идеальное двоичное дерево является частным случаем. Обычно на средних уровнях двоичного дерева присутствует много пустых значений None. Но по- следовательность обхода по уровням не содержит этих None, поэтому невоз- можно по этой последовательности определить количество и расположение пустых значений. **Это означает**, **что существует множество структур дво- ичных деревьев**, **соответствующих данной последовательности обхода по уровням**. +> +> Для такого неидеального двоичного дерева вышеописанный метод пред- ставления с помощью массива уже не работает, см. рис. 7.13. + +![](ru/docs/assets/media/image275.jpeg) + +> **Рис. 7.13.** Для одной последовательности обхода по уровням существует несколько возможных вариантов двоичного дерева +> +> Для решения этой проблемы **можно явно записать все значения** None **в последовательности обхода по уровням**. После такой обработки последо- вательность обхода по уровням уже может однозначно представлять двоич- ное дерево, как показано на рис. 7.14. Ниже приведен пример кода. +> +> \# Представление двоичного дерева с помощью массива. \# Использование None для обозначения пустых мест. +> +> tree = \[1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15\] +> +> ![](ru/docs/assets/media/image277.jpeg) +> +> **Рис. 7.14.** Представление произвольного двоичного дерева с помощью массива +> +> Стоит отметить, что **совершенное двоичное дерево очень удобно пред- ставлять с помощью массива**. Вспоминая определение совершенного дво- ичного дерева, None появляются только на самом нижнем уровне и в правой части, поэтому **все значения** None **обязательно находятся в конце последо- вательности обхода по уровням**. +> +> Это означает, что при использовании массива для представления совершен- ного двоичного дерева можно опустить хранение всех None, что очень удобно. На рис. 7.15 приведен пример такого представления. + +![](ru/docs/assets/media/image279.jpeg) + +> **Рис. 7.15.** Представление совершенного двоичного дерева с помощью массива +> +> В коде ниже реализуется двоичное дерево, основанное на представлении с помощью массива, включая следующие операции. + +- Для заданного узла получение его значения, левого и правого дочернего узла, родительского узла. + + - Получение последовательностей обхода в прямом, симметричном, об- ратном порядке и в порядке обхода по уровням. + +> \# === 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 +> +> \# Прямой обход. +> +> 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 + +### Преимущества и ограничения + +> Представление двоичного дерева с помощью массива имеет следующие пре- имущества: + +- массив хранится в непрерывной области памяти, что хорошо для кеши- рования. Скорость доступа и обхода достаточно высока; + +- не требуется хранение указателей, что экономит пространство; + +- позволяет выполнять произвольный доступ к узлам. + +> Однако представление с помощью массива имеет и некоторые ограничения. + +- Хранение в массиве требует непрерывной области памяти, поэтому не подходит для хранения деревьев с очень большим объемом данных. + +- Добавление и удаление узлов требует выполнения операций вставки и удаления в массиве, которые менее эффективны. + +- Когда в двоичном дереве содержится много значений None, доля данных узлов в массиве низка, что приводит к низкой эффективности использо- вания пространства. + +#### двоичное дерево поиска + +> *Двоичное дерево поиска* удовлетворяет следующим условиям, см. рис. 7.16: + +1) для корневого узла все значения узлов в левом поддереве \< значение корневого узла \< все значения узлов в правом поддереве; + +2) левое и правое поддеревья любого узла также являются двоичными де- ревьями поиска, т. е. удовлетворяют условию 1. + +![](ru/docs/assets/media/image281.jpeg) + +> **Рис. 7.16.** Двоичное дерево поиска + +### Операции с двоичным деревом поиска + +> Мы инкапсулируем двоичное дерево поиска в класс BinarySearchTree и объявля- ем переменную-член root, указывающую на корневой узел дерева. + +##### Поиск узла + +> Для заданного значения целевого узла 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, значит целевой узел найден, выполняется выход из цикла и возврат этого узла. + +> ![](ru/docs/assets/media/image283.jpeg) + +![](ru/docs/assets/media/image285.jpeg)![](ru/docs/assets/media/image287.jpeg) + +> **Рис. 7.17.** Пример поиска узла в двоичном дереве поиска. Шаги 1--3 +> +> ![](ru/docs/assets/media/image289.jpeg) +> +> **Рис. 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 + +##### Вставка узла + +> Нужно вставить новый элемент num и при этом сохранить свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Процесс вставки показан на рис. 7.18. + +1. **Поиск позиции для вставки**: аналогично операции поиска, начиная с корневого узла, в цикле выполняется поиск вниз по дереву в зависимо- сти от соотношения значений текущего узла и num, пока не будет пройден листовой узел (достигнуто None), после чего цикл завершается. + +2. **Вставка узла в найденную позицию**: инициализация узла num и раз- мещение его в позиции None. + +![](ru/docs/assets/media/image291.jpeg) + +> **Рис. 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 +> +> \# Позиция для вставки в правом поддереве 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*). + +##### Удаление узла + +> Сначала в двоичном дереве выполняется поиск целевого узла, после чего он удаляется. Как и при вставке узла, необходимо гарантировать, что после за- вершения операции удаления сохраняется свойство двоичного дерева поиска: левое поддерево \< корневой узел \< правое поддерево. Поэтому, в зависимости от количества дочерних узлов целевого узла (0, 1 или 2), выполняются соответ- ствующие операции его удаления. +> +> Если степень удаляемого узла равна 0, значит он является листовым, и его можно удалить напрямую, см. рис. 7.19. + +![](ru/docs/assets/media/image293.jpeg) + +> **Рис. 7.19.** Удаление узла в двоичном дереве поиска (степень 0) +> +> Если степень удаляемого узла равна 1, его можно заменить дочерним узлом, см. рис. 7.20. + +![](ru/docs/assets/media/image295.jpeg) **Удаление узла из двоичного дерева поиска** + +1. *Найти узел* **cur**, + +> *подлежащий удалению* +> +> *У узла* **cur** *количество дочерних узлов = 1* + +2. *Заменить узел* **cur** *его дочерним узлом* + +> *Выполнить* **pre**.left = **cur**.right +> +> **Рис. 7.20.** Удаление узла в двоичном дереве поиска (степень 1) +> +> Если степень удаляемого узла равна 2, его нельзя удалить напрямую, и не- обходимо заменить его другим узлом. Согласно свойству двоичного дерева по- иска левое поддерево \< корневой узел \< правое поддерево, **этот узел может быть минимальным узлом правого поддерева или максимальным узлом левого поддерева**. +> +> Предположим, что мы выбираем минимальный узел правого поддерева (следующий узел при симметричном обходе), тогда процесс удаления будет следующим (см. рис. 7.21): + +1) найти следующий узел в последовательности симметричного обхода для узла, который необходимо удалить, и обозначить его как tmp; + +2) заменить значение удаляемого узла значением tmp и рекурсивно уда- лить узел tmp из дерева. + +![](ru/docs/assets/media/image297.jpeg) + +> **Рис. 7.21.** Удаление узла в двоичном дереве поиска (степень 2). Шаг 1 +> +> ![](ru/docs/assets/media/image299.jpeg) + +![](ru/docs/assets/media/image301.jpeg) + +> **Рис. 7.21.** *Продолжение*.. Шаг 2--3 +> +> ![](ru/docs/assets/media/image303.jpeg) +> +> **Рис. 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 +> +> \# Если узел для удаления не найден, возврат. 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*) без необходимости в дополнительных операциях сортировки, что очень эффективно. +> +> ![](ru/docs/assets/media/image306.jpeg) +> +> **Рис. 7.22.** Симметричный обход двоичного дерева поиска + +### Эффективность двоичного дерева поиска + +> Для заданного набора данных можно использовать для его хранения массив или двоичное дерево поиска. Как показано в табл. 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*). +> +> ![](ru/docs/assets/media/image309.jpeg) +> +> **Рис. 7.23.** Вырождение двоичного дерева поиска + +### Типичные сценарии применения двоичного дерева поиска + +- Используется в качестве многоуровневого индекса в системах для эф- фективного поиска, вставки и удаления. + +- Служит базовой структурой данных для некоторых алгоритмов поиска. + +- Применяется для хранения потока данных для поддержания его упоря- доченного состояния. + + 1. **АВЛ-дерево\*** + +> В разделе «Двоичное дерево поиска» упоминалось, что после многократ- ных операций вставки и удаления двоичное дерево поиска может выродит- ся в список. В таких случаях временная сложность всех операций ухудшается с *O*(log *n*) до *O*(*n*). +> +> На рис. 7.24 приведен пример, когда после двух операций удаления узлов двоичное дерево поиска вырождается в список. +> +> В другом примере после вставки двух узлов в идеальное двоичное дерево, показанное на рис. 7.25, дерево сильно наклоняется влево, и временная слож- ность операций поиска также ухудшается. +> +> В 1962 году советские математики Г. М. Адельсон-Вельский и Е. М. Ландис в статье «Один алгоритм организации информации» предложили структуру АВЛ- дерева. В статье подробно описывается серия операций, которые гарантируют, что после постоянного добавления и удаления узлов АВЛ-дерево не деградиру- ет, что позволяет поддерживать временную сложность различных операций на уровне *O*(log *n*). Иными словами, в сценариях, требующих частых операций до- бавления, удаления, поиска и изменения, АВЛ-дерево обеспечивает высокую эф- фективность операций с данными и имеет значительную прикладную ценность. +> +> ![](ru/docs/assets/media/image311.jpeg) +> +> **Рис. 7.24.** Вырождение АВЛ-дерева после удаления узлов + +![](ru/docs/assets/media/image313.jpeg) + +> **Рис. 7.25.** Вырождение АВЛ-дерева после вставки узлов + +### Основные понятия АВЛ-дерева + +> *АВЛ-дерево* является одновременно и двоичным деревом поиска, и сбалан- сированным двоичным деревом, удовлетворяя всем свойствам этих двух типов деревьев. Таким образом, оно представляет собой сбалансированное двоичное дерево поиска. + +##### Высота узла + +> Поскольку операции с АВЛ-деревом требуют получения высоты узла, необхо- димо добавить в класс узла переменную height. +> +> class TreeNode: +> +> \"\"\"Класс узла AVL-дерева.\"\"\" +> +> 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 + +##### Фактор баланса узла + +> Фактор баланса узла определяется как высота левого поддерева узла минус высота правого поддерева, при этом фактор баланса пустого узла равен 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) + +### Вращение в АВЛ-дереве + +> Особенностью АВЛ-дерева является операция вращения, которая позволяет восстановить баланс узла, не влияя на порядок обхода двоичного дерева. Ины- ми словами, **вращение поворота сохраняет свойства двоичного дерева поиска и делает дерево снова сбалансированным двоичным деревом**. +> +> Узлы с абсолютным значением фактора баланса \> 1 называются несбалан- сированными узлами. В зависимости от типа несбалансированности узла опе- рации вращения делятся на четыре типа: правое; левое; сначала правое, затем левое; сначала левое, затем правое. Рассмотрим их подробнее. + +##### Правое вращение + +> На рис. 7.26 ниже узла указан фактор баланса. Если идти снизу вверх, в дво- ичном дереве первым несбалансированным узлом является узел 3. Рассмо- трим поддерево с этим узлом в качестве корня: обозначим этот узел как node, а его левый дочерний узел как child и выполним операцию правого вращения. После завершения операции поддерево восстанавливает баланс и сохраняет свойства двоичного дерева поиска. + +![](ru/docs/assets/media/image315.jpeg)![](ru/docs/assets/media/image317.jpeg) + +> **Рис. 7.26.** Этапы правого вращения. Шаги 1--2 +> +> ![](ru/docs/assets/media/image319.jpeg) + +![](ru/docs/assets/media/image321.jpeg) + +> **Рис. 7.26.** *Окончание*.. Шаги 3--4 +> +> Если у узла child есть правый дочерний узел (обозначим его как grand_child), необходимо добавить в правое вращение еще один шаг: сделать grand_child ле- вым дочерним узлом для node. +> +> Правое вращение -- это образное выражение, фактически оно реализуется путем изменения указателей узлов, как показано в приведенном ниже коде. +> +> ![](ru/docs/assets/media/image323.jpeg) +> +> **Рис. 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 + +##### Левое вращение + +> Соответственно, если рассмотреть зеркальное отражение вышеупомянутого несбалансированного двоичного дерева, необходимо выполнить операцию левого вращения, как показано на рис. 7.28. + +![](ru/docs/assets/media/image325.jpeg) + +> **Рис. 7.28.** Левое вращение +> +> Аналогично, если у узла child есть левый дочерний узел (обозначим его как grand_child), необходимо добавить в левое вращение еще один шаг: сделать grand_child правым дочерним узлом для node, как показано на рис. 7.29. + +![](ru/docs/assets/media/image327.jpeg) + +> **Рис. 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 на рис. 7.30 использование только левого или правого вращения не позволяет восстановить баланс поддерева. В этом случае необходимо сначала выполнить левое вращение для child, а затем правое вра- щение для node. +> +> ![](ru/docs/assets/media/image329.jpeg) +> +> **Рис. 7.30.** Сначала левое, затем правое вращение + +##### Сначала правое, затем левое вращение + +> Для зеркальной ситуации вышеупомянутого разбалансированного двоичного дерева необходимо сначала выполнить правое вращение для child, а затем ле- вое вращение для node, как показано на рис. 7.31. + +![](ru/docs/assets/media/image331.jpeg) + +> **Рис. 7.31.** Сначала правое, затем левое вращение + +##### Выбор типа вращения + +> На рис. 7.32 изображено четыре типа несбалансированности, соответствую- щие вышеописанным случаям, для которых необходимо применять операции: правого вращения; сначала левого, затем правого вращения; сначала правого, затем левого вращения; левого вращения соответственно. +> +> Из табл. 7.3 видно, что для определения того, к какому случаю из рис. 7.32 относится несбалансированный узел, используется фактор баланса узла и знак фактора баланса дочернего узла с большей высотой. +> +> ![](ru/docs/assets/media/image333.jpeg) +> +> **Рис. 7.32.** Четыре типа вращений в АВЛ-дереве + ++-------------------------------------------------+----------------------+----------------------------------------+ +| > **Фактор баланса несбаланси- рованного узла** | > **Фактор баланса** | > **Рекомендуемый метод вращения** | ++=================================================+======================+========================================+ +| > \> 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) +> +> \# Правостороннее дерево. 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 + +### Основные операции с АВЛ-деревом + +##### Вставка узла + +> Операция вставки узла в АВЛ-дерево в основном схожа с двоичным деревом поиска. Единственное отличие заключается в том, что после вставки узла в АВЛ-дерево на пути от этого узла к корню могут возникнуть несбалансиро- ванные узлы. Поэтому*,* **начиная с этого узла**, **необходимо выполнять вра- щения снизу вверх**, **чтобы восстановить баланс всех несбалансирован- ных узлов**. Ниже приведен код реализации. +> +> \# === 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) + +##### Удаление узла + +> Для удаления узла можно также взять метод удаления узла в двоичном дереве поиска и добавить вращения при движении снизу вверх, чтобы восстановить баланс всех несбалансированных узлов. Ниже приведен код реализации. +> +> \# === 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) + +##### Поиск узла + +> Операция поиска узла в АВЛ-дереве идентична поиску в двоичном дереве по- иска, поэтому здесь повторно не рассматривается. + +### Типичные сценарии применения АВЛ-дерева + +- Организация и хранение больших объемов данных подходит для сцена- риев с частыми поисками и редкими вставками и удалениями. + +- Используется для построения индексных систем в базах данных. + +- Красно-черное дерево также является распространенным видом сбалан- сированного двоичного дерева поиска. По сравнению с АВЛ-деревом ус- ловия баланса в красно-черном дереве более мягкие, что требует меньше вращений при вставке и удалении узлов и обеспечивает более высокую среднюю эффективность этих операций. + +#### резюме + +##### Ключевые моменты + +- Двоичное (бинарное) дерево -- это нелинейная структура данных, отра- жающая логику «разделяй и властвуй». Каждый узел двоичного дерева содержит значение и два указателя, указывающих на его левый и правый дочерние узлы соответственно. + +- Для любого узла в двоичном дереве его левый (правый) дочерний узел и об- разуемое им дерево называются левым (правым) поддеревом этого узла. + +- Связанные с двоичным деревом понятия включают корневой узел, ли- стовой узел, уровень, степень, ребро, высоту, глубину и др. + +- Инициализация двоичного дерева, вставка и удаление узлов аналогичны методам работы со списками. + +- К распространенным типам двоичных деревьев относятся идеальное двоичное дерево, совершенное двоичное дерево, полное двоичное де- рево и сбалансированное двоичное дерево. Идеальное двоичное дерево является наиболее желаемым состоянием, а список -- наихудшим состо- янием после вырождения. + +- Двоичное дерево может быть представлено массивом, в котором значе- ния узлов и пустые места располагаются в порядке обхода по уровням, а указатели реализуются на основе индексации между родительскими и дочерними узлами. + +- Обход двоичного дерева по уровням является методом поиска в шири- ну, который отражает способ обхода по кругам, расширяющимся наружу и обычно реализуется с помощью очереди. + +- Прямой, симметричный и обратный обходы относятся к методам поиска в глубину. Они демонстрируют способ обхода «сначала до конца, затем воз- врат и продолжение», обычно реализуемый с использованием рекурсии. + +- Двоичное дерево поиска представляет собой эффективную структуру дан- ных для поиска элементов, где временная сложность операций поиска, вставки и удаления составляет *O*(log *n*). Когда двоичное дерево поиска вы- рождается в список, временная сложность всех операций ухудшается до *O*(*n*). + +- АВЛ-дерево, также известное как сбалансированное двоичное дерево поиска, поддерживает выполнение балансировки дерева после вставки и удаления узлов с помощью операций вращения. + +> 7.6. Резюме ❖ **211** + +- Операции вращения в АВЛ-дереве включают: правое вращение; левое вращение; сначала левое, затем правое вращение; сначала правое, затем левое вращение. После вставки или удаления узлов АВЛ-дерево выпол- няет операции вращения снизу вверх, чтобы восстановить баланс. + +##### Вопросы и ответы + +> **Вопрос**. Для двоичного дерева с единственным узлом высота дерева и глубина корневого узла равны 0? +> +> **Ответ**. Да, поскольку высота и глубина обычно определяются как количе- ство пройденных ребер. +> +> **Вопрос**. Вставка и удаление в двоичном дереве обычно выполняются с по- мощью набора операций. Что подразумевается под набором операций? Мож- но ли это понимать как освобождение ресурсов дочерних узлов? +> +> **Ответ**. Возьмем, к примеру, двоичное дерево поиска: операция удаления узла требует обработки трех различных случаев, в каждом из которых необхо- димо выполнить несколько шагов операций с узлами. +> +> **Вопрос**. Почему для обхода двоичного дерева в глубину существуют три по- рядка -- прямой, симметричный и обратный, и в чем их преимущества? +> +> **Ответ**. Подобно прямому и обратному обходу массива, прямой, симметрич- ный и обратный обходы являются тремя методами обхода двоичного дерева. Они позволяют получить результат обхода в определенном порядке. Напри- мер, в двоичном дереве поиска, поскольку значения узлов удовлетворяют ус- ловию «значение левого дочернего узла \< значение корневого узла \< значение правого дочернего узла», обход дерева в порядке «левый → корень → правый» позволяет получить упорядоченную последовательность узлов. +> +> **Вопрос**. Операция правого вращения обрабатывает отношения между не- сбалансированным узлом node, дочерним узлом child и внуком grand_child. Не нужно ли поддерживать связь node с его родительским узлом? +> +> **Ответ**. Необходимо рассматривать этот вопрос с рекурсивной точки зрения. Операция правого вращения right_rotate(root) принимает корневой узел под- дерева, и в итоге возвращает child, который после вращения становится кор- невым узлом. Связь корневого узла поддерева с его родительским узлом уста- навливается после завершения функции и не входит в область поддержания операции правого вращения. +> +> **Вопрос**. В C++ функции разделяются на private и public. Какие соображения при этом нужно учитывать? Почему функции height() и updateHeight() разме- щены в public и private соответственно? +> +> **Ответ**. Это зависит от области применения метода. Если метод использу- ется только внутри класса, его следует сделать private. Например, вызов up- dateHeight() пользователем не имеет смысла, так как это лишь этап в опера- циях вставки и удаления. А height() используется для доступа к высоте узла аналогично методу vector.size(), поэтому он помечен как public для удобства использования. +> +> **Вопрос**. Как построить двоичное дерево поиска из набора входных данных? +> +> Является ли важным способ выбора корневого узла? +> +> **Ответ**. Да, метод построения дерева описан в методе build_tree() в коде дво- ичного дерева поиска. Что касается выбора корневого узла, обычно входные +> +> данные сортируются, затем средний элемент выбирается в качестве корневого узла, после чего рекурсивно строятся левые и правые поддеревья. Это позво- ляет максимально сохранить баланс дерева. +> +> **Вопрос**. Всегда ли в Java для сравнения строк нужно использовать метод +> +> equals()? +> +> **Ответ**. В Java для базовых типов данных оператор == используется для срав- нения значений двух переменных. Для ссылочных типов принцип работы этих операторов различен. +> +> ① ==: используется для сравнения того, указывают ли две переменные на один и тот же объект, т. е. совпадают ли их позиции в памяти. + +- equals(): используется для сравнения значений двух объектов. + +> Таким образом, если необходимо сравнить значения, следует использовать метод equals(). Однако строки, инициализированные как String a = \"hi\"; String b = \"hi\";, хранятся в пуле строковых констант и указывают на один и тот же объект, поэтому для сравнения содержимого этих строк можно использовать a == b. +> +> **Вопрос**. До достижения самого нижнего уровня при обходе в ширину коли- чество узлов в очереди равно 2*h*? +> +> **Ответ**. Да, например, для полного двоичного дерева высотой *h* = 2 общее количество узлов *n* = 7, тогда количество узлов на нижнем уровне равно 4 = 2*h* = (*n* + 1)/2. +> +> Глава 8 + +# Куча + +![](ru/docs/assets/media/image334.jpeg){width="3.8072911198600177in" height="4.927083333333333in"} + +1. **куча** + +> Куча -- это полное двоичное дерево, удовлетворяющее определенным услови- ям, и делится на два основных типа, как показано на рис. 8.1. + +- **Минимальная куча**: значение любого узла ≤ значений его дочерних узлов. + +- **Максимальная куча**: значение любого узла ≥ значений его дочерних узлов. + +> ![](ru/docs/assets/media/image336.jpeg)Является совершенным двоичным деревом, и значение любого узла **≤** значения его дочерних узлов +> +> Является совершенным двоичным деревом, и значение любого узла **≥** значения его дочерних узлов + +**Минимальная куча Максимальная куча** + +> **Рис. 8.1.** Минимальная и максимальная кучи +> +> Куча, как частный случай полного двоичного дерева, обладает следующими свойствами: + +- узлы на самом нижнем уровне заполняются слева, остальные уровни полностью заполнены; + +- корневой узел двоичного дерева называется вершиной кучи, а самый правый узел на нижнем уровне -- основанием кучи; + +- для максимальной (минимальной) кучи значение элемента на вершине (т. е. корневом узле) является наибольшим (наименьшим). + +### Основные операции с кучей + +> Следует отметить, что многие языки программирования содержат приоритет- ную очередь, которая является абстрактной структурой данных, определяемой как очередь с приоритетной сортировкой. +> +> На практике **куча часто используется для реализации приоритетной очереди**, **где максимальная куча соответствует приоритетной очереди**, **из которой элементы извлекаются в порядке убывания**. С точки зрения использования приоритетную очередь и кучу можно считать эквивалентными структурами данных. Поэтому в данной книге они не различаются и называ- ются просто кучей. +> +> Основные операции с кучей представлены в табл. 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 = \[\], 1 +> +> \# Инициализация максимальной кучи. +> +> max_heap, flag = \[\], -1 +> +> \# Модуль heapq в Python по умолчанию реализует минимальную кучу. +> +> \# Рассматривается вариант, при котором элементы инвертируются перед +> +> \# добавлением в кучу, что позволяет изменить порядок и реализовать максимальную кучу. \# В этом примере flag = 1 соответствует минимальной куче, flag = -1 -- максимальной. +> +> \# Вставка элемента в кучу. heapq.heappush(max_heap, flag \* 1) heapq.heappush(max_heap, flag \* 3) heapq.heappush(max_heap, flag \* 2) heapq.heappush(max_heap, flag \* 5) heapq.heappush(max_heap, flag \* 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 +> +> \# Получение размера кучи. 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.2, для заданного индекса массива *i* индекс левого до- чернего узла равен 2*i* + 1, правого -- 2*i* + 2, а индекс родительского узла -- (*i* -- 1) / 2 (целочисленное деление вниз). Выход за пределы индексации обозначает пустой узел или его отсутствие. + +![](ru/docs/assets/media/image338.jpeg) + +> **Рис. 8.2.** Представление и хранение кучи +> +> Формулы индексации можно для удобства использования обернуть в функции. +> +> \# === 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 \# Целочисленное деление вниз. + +##### Доступ к элементу на вершине кучи + +> Элемент на вершине кучи -- это корневой узел двоичного дерева, т. е. первый элемент списка. +> +> \# === File: my_heap.py === def peek(self) -\> int: +> +> \"\"\" Доступ к элементу на вершине кучи.\"\"\" +> +> return self.max_heap\[0\] + +##### Вставка элемента в кучу + +> Нам дан элемент val, который сначала добавляется в основание кучи. После добавления условия корректности кучи могут быть нарушены, поскольку элемент val может быть больше других элементов кучи. **Поэтому необхо- димо восстановить порядок на пути от вставленного узла до корнево- го узла**. Эта операция называется упорядочиванием кучи. +> +> Рассмотрим **выполнение упорядочивания кучи снизу вверх**, начиная с узла, который был добавлен. Как показано на рис. 8.3, необходимо срав- нивать значения вставленного узла и его родительского узла. Если встав- ленный узел больше, они меняются местами. Затем продолжается выпол- нение этой операции с исправлением каждого узла кучи снизу вверх, пока не будет достигнут корневой узел или не встретится узел, который не тре- бует обмена. +> +> ![](ru/docs/assets/media/image340.jpeg) + +![](ru/docs/assets/media/image342.jpeg)![](ru/docs/assets/media/image344.jpeg) + +> **Рис. 8.3.** Этапы добавления элемента в кучу. Шаги 1--3 +> +> ![](ru/docs/assets/media/image346.jpeg) + +![](ru/docs/assets/media/image348.jpeg)![](ru/docs/assets/media/image350.jpeg) + +> **Рис. 8.3.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image352.jpeg) + +![](ru/docs/assets/media/image354.jpeg)![](ru/docs/assets/media/image356.jpeg) + +> **Рис. 8.3.** *Окончание*. Шаги 7--9 +> +> Пусть общее количество узлов равно *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 + +##### Извлечение элемента с вершины кучи + +> Элемент на вершине кучи является корневым узлом двоичного дерева, т. е. первым элементом списка. Если просто удалить первый элемент из списка, индексы всех узлов в двоичном дереве изменятся, что затруднит дальней- шее исправление с помощью упорядочивания кучи. Чтобы минимизиро- вать изменения индексов элементов, используется следующий порядок действий: + +1) обмен вершины кучи с элементом в основании кучи (обмен корневого узла с самым правым листовым узлом); + +2) после обмена удаляется элемент в основании кучи из списка (обрати- те внимание, что фактически удаляется исходный элемент на вершине кучи, так как они были поменяны); + +3) **упорядочивание кучи сверху вниз**, начиная с корневого узла. + +> **Направление операции упорядочивания кучи сверху вниз противо- положно операции упорядочивания кучи снизу вверх**, как показано на рис. 8.4. Значение корневого узла сравнивается со значениями его двух дочер- них узлов, и самый большой дочерний узел обменивается с корневым узлом. Затем эта операция выполняется циклически, пока не будет достигнут листо- вой узел или не встретится узел, который не требует обмена. +> +> ![](ru/docs/assets/media/image358.jpeg) + +![](ru/docs/assets/media/image360.jpeg)![](ru/docs/assets/media/image362.jpeg) + +> **Рис. 8.4.** Этапы извлечения элемента с вершины кучи. Шаги 1--3 +> +> ![](ru/docs/assets/media/image364.jpeg) + +![](ru/docs/assets/media/image366.jpeg)![](ru/docs/assets/media/image368.jpeg) + +> **Рис. 8.4.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image370.jpeg) + +![](ru/docs/assets/media/image372.jpeg)![](ru/docs/assets/media/image374.jpeg) + +> **Рис. 8.4.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image376.jpeg) +> +> **Рис. 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 +> +> \# Если узел i максимальный или индексы l, r выходят за пределы, \# упорядочивание кучи не требуется, выход из цикла. +> +> if ma == i: break +> +> \# Обмен двух узлов. self.swap(i, ma) +> +> \# Циклическое упорядочивание кучи вниз. i = ma + +### Типичные сценарии применения кучи + +- **Очередь с приоритетом**: куча обычно используется как структура дан- ных для реализации очереди с приоритетом, где операции добавления и извлечения имеют временную сложность *O*(log *n*), а операция постро- ения кучи -- *O*(*n*). Эти операции очень эффективны. + +- **Пирамидальная сортировка**: для заданного набора данных можно построить кучу, а затем последовательно выполнять операции извле- чения элементов из кучи, чтобы получить отсортированные данные. Однако обычно используется более изящный способ реализации пи- рамидальной сортировки, подробнее см. в разделе «Пирамидальная сортировка». + +- **Получение** *k* **наибольших элементов**: это классическая задача алго- ритмов и типичное применение. Например, выбор 10 самых популярных новостей для горячих тем в социальных сетях или выбор 10 самых про- даваемых товаров. + +#### пОСтрОение кучи + +> В некоторых случаях требуется использовать все элементы списка для постро- ения кучи, этот процесс называется построением кучи. + +### Реализация с помощью операции добавления в кучу + +> Сначала создается пустая куча, затем обходится список, и для каждого элемен- та последовательно выполняется операция добавления в кучу. То есть элемент сначала добавляется в основание кучи, а затем для него выполняется упорядо- чивание кучи снизу вверх. +> +> При добавлении элемента в кучу ее длина увеличивается на единицу. Посколь- ку узлы добавляются в двоичное дерево сверху вниз, куча строится сверху вниз. +> +> Пусть количество элементов равно *n* и каждый элемент добавляется в кучу за время *O*(log *n*). Тогда временная сложность этого метода построения кучи составляет *O*(*n* log *n*). + +### Реализация через обход и упорядочивание + +> На самом деле можно реализовать более эффективный метод построения кучи, который состоит из двух шагов: + +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) + +### Анализ сложности + +> Теперь попытаемся оценить временную сложность второго метода построе- ния кучи. + +- Предположим, что количество узлов в полном двоичном дереве равно *n*, тогда количество листовых узлов равно (*n* + 1)/2, где «/» обозначает целочисленное деление вниз. Следовательно, количество узлов, которые необходимо упорядочить, равно (*n* − 1)/2. + +- В процессе упорядочивания сверху вниз каждый узел может быть упо- рядочен до листового узла, поэтому максимальное количество итераций равно высоте двоичного дерева log *n*. + +> Умножив эти два значения, можно получить временную сложность процесса построения кучи *O*(*n* log *n*). **Однако эта оценка не точна**, **так как не учиты- вает**, **что количество узлов на нижних уровнях двоичного дерева значи- тельно больше**, **чем на верхних**. +> +> Проведем более точный расчет. Чтобы упростить вычисления, предполо- жим, что дано идеальное двоичное дерево с количеством узлов *n* и высотой *h*. Это предположение не повлияет на правильность результата. +> +> ![](ru/docs/assets/media/image378.jpeg) +> +> **Рис. 8.5.** Количество узлов на каждом уровне идеального двоичного дерева +> +> Как видно из рис. 8.5, максимальное количество итераций упорядочивания сверху вниз для узла равно расстоянию от этого узла до листового узла, что соответствует высоте узла. Таким образом, можно суммировать произведения количество узлов × высота узла для каждого уровня, чтобы **получить общее количество итераций упорядочивания для всех узлов**: +> +> ![](ru/docs/assets/media/image380.png)![](ru/docs/assets/media/image383.png)![](ru/docs/assets/media/image385.png){width="9.519356955380577e-2in" height="9.505468066491689e-2in"}![](ru/docs/assets/media/image383.png)![](ru/docs/assets/media/image388.png){width="0.10160761154855644in" height="0.14292979002624673in"} ![](ru/docs/assets/media/image389.png){width="0.10633092738407698in" height="0.14292979002624673in"} . +> +> Для упрощения этого выражения воспользуемся знаниями из школьного курса о последовательностях и умножим сначала *T*(*h*) на 2: +> +> ![](ru/docs/assets/media/image393.png), +> +> . +> +> Используя метод вычитания со сдвигом, вычтем из уравнения 2*T*(*h*) уравне- ние *T*(*h*): + +![](ru/docs/assets/media/image398.png)![](ru/docs/assets/media/image400.png){width="0.3446259842519685in" height="0.1429407261592301in"} ![](ru/docs/assets/media/image401.png){width="1.0516010498687665in" height="0.1429407261592301in"} . + +> ![](ru/docs/assets/media/image404.png)![](ru/docs/assets/media/image406.png){width="7.259623797025372e-2in" height="0.10093722659667542in"}Можно заметить, что *T*(*h*) является геометрической прогрессией, и можно использовать формулу ее суммы, чтобы получить временную сложность: + +![](ru/docs/assets/media/image407.png){width="0.3288571741032371in" height="0.16171587926509187in"}. + +> Далее, количество узлов в идеальном двоичном дереве высоты *h* равно *n* = 2*h*+1 − 1, отсюда легко получить сложность *O*(2*h*) = *O*(*n*). Эти вычисления показывают, что временная сложность построения кучи из входного списка составляет *O*(*n*), что очень эффективно. + +#### поиск k наибольших элементов + +> Для решения этой задачи сначала рассмотрим два простых подхода, а затем представим более эффективный метод с использованием кучи. + +### Первый метод: выбор через обход + +> Можно выполнить *k* раундов обхода, извлекая в каждом раунде 1-й, 2-й, \..., *k*-й по величине элемент, как показано на рис. 8.6. Временная сложность этого алгоритма составляет *O*(*nk*). Этот метод подходит только для случаев, когда *k* ≪ *n*, так как при *k*, близком к *n*, временная сложность стремится к *O*(*n*2), что очень затратно по времени. + +![](ru/docs/assets/media/image409.jpeg) + +> **Рис. 8.6.** Поиск k наибольших элементов через обход + +### Второй метод: сортировка + +> Можно сначала отсортировать массив nums, а затем вернуть *k* самых правых элементов, как показано на рис. 8.7. Временная сложность этого метода со- ставляет *O*(*n* log *n*). Очевидно, что данный метод перевыполняет задачу, так как необходимо найти лишь *k* наибольших элементов, а не сортировать все остальные. + +![](ru/docs/assets/media/image411.jpeg) + +> **Рис. 8.7.** Поиск k наибольших элементов с помощью сортировки + +### Третий метод: куча + +> Задачу поиска *k* наибольших элементов можно решить более эффективно с по- мощью кучи (см. рис. 8.8). + +1. Инициализация минимальной кучи, в которой корневой элемент явля- ется наименьшим. + +2. Вначале в кучу помещаются первые *k* элементов массива. + +3. Начиная с элемента *k* + 1, если текущий элемент больше корневого эле- мента, то корневой элемент извлекается из кучи, а текущий элемент по- мещается в кучу. + +4. После завершения обхода в куче остаются *k* наибольших элементов. + +> ![](ru/docs/assets/media/image413.jpeg) + +![](ru/docs/assets/media/image415.jpeg)![](ru/docs/assets/media/image417.jpeg) + +> **Рис. 8.8.** Поиск k наибольших элементов с помощью кучи. Шаги 1--3 +> +> ![](ru/docs/assets/media/image419.jpeg) + +![](ru/docs/assets/media/image421.jpeg)![](ru/docs/assets/media/image423.jpeg) + +> **Рис. 8.8.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image425.jpeg) + +![](ru/docs/assets/media/image427.jpeg)![](ru/docs/assets/media/image429.jpeg) + +> **Рис. 8.8.** *Окончание*. Шаги 7--9 +> +> Ниже приведен пример кода. +> +> \# === 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* наибольших элементов. + +#### резюме + +##### Ключевые моменты + +- Куча представляет собой полное двоичное дерево и может быть двух ти- пов: максимальной и минимальной. Корневой элемент максимальной (минимальной) кучи является наибольшим (наименьшим). + +- Очередь с приоритетом определяется как очередь с приоритетом извле- чения, обычно реализуемая с помощью кучи. + +- Основные операции с кучей и их временная сложность включают: по- мещение элемента в кучу *O*(log *n*), извлечение корневого элемента *O*(log *n*) и доступ к корневому элементу *O*(1). + +- Совершенное двоичное дерево удобно представлять в виде массива, по- этому обычно для хранения кучи используется массив. + +- Операция упорядочивания кучи используется для поддержания свойств кучи и применяется при операциях помещения и извлечения. + +- Временную сложность построения кучи из *n* элементов можно оптими- зировать до *O*(*n*), что очень эффективно. + +- Задача поиска *k* наибольших элементов является классической алгорит- мической задачей и может быть эффективно решена с использованием кучи с временной сложностью *O*(*n* log *k*). + +> 8.4. Резюме ❖ **235** + +##### Вопросы и ответы + +> **Вопрос**. Является ли «куча» в структуре данных тем же понятием, что и «куча» в управлении памятью? +> +> **Ответ**. Это не одно и то же понятие, хотя они по случайному стечению обсто- ятельств и имеют одинаковое название. Куча в памяти компьютерной системы является частью динамического распределения памяти, которую программа может использовать для хранения данных во время выполнения. Программа может запросить определенное количество памяти в куче для хранения таких сложных структур, как объекты и массивы. Когда эти данные больше не нуж- ны, программа должна освободить эту память, чтобы предотвратить утечку памяти. В отличие от стека управление и использование памяти в куче требует большей осторожности, неправильное использование может привести к утеч- кам памяти и проблемам с указателями. +> +> Глава 9 + +# Графы + +![](ru/docs/assets/media/image430.jpeg){width="3.5416732283464567in" height="4.583333333333333in"} + +#### графы + +> *Граф --* это нелинейная структура данных, состоящая из вершин и ребер. Граф *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. + +![](ru/docs/assets/media/image432.jpeg) + +> **Рис. 9.1.** Связь между списком, деревом и графом + +1. **Основные типы и понятия графов** + +> В зависимости от наличия направления у ребер графы делятся на неориенти- рованные и ориентированные, как показано на рис. 9.2. + +- В неориентированном графе ребро представляет собой двустороннюю связь между двумя вершинами, например дружеские отношения в соци- альных сетях. + +- В ориентированном графе ребро имеет направление. То есть ребра *A* → *B* и *A* ← *B* независимы друг от друга, например отношения подписки--под- писчики. + +> ![](ru/docs/assets/media/image434.jpeg) +> +> **Рис. 9.2.** Ориентированный и неориентированный графы +> +> Если все вершины связаны, то граф называется связным, иначе -- несвяз- ным, как показано на рис. 9.3. +> +> В связном графе из любой вершины можно достичь любой другой вершины. В несвязном графе существуют по крайней мере две вершины, между кото- +> +> рыми нет пути. + +![](ru/docs/assets/media/image436.jpeg) + +> **Рис. 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. + +- **Степень**: количество ребер, присоединенных к вершине. Для ориен- тированного графа входящая степень показывает, сколько ребер ведет к данной вершине, а исходящая степень показывает, сколько ребер вы- ходит из данной вершины. + +> ![](ru/docs/assets/media/image438.jpeg) +> +> **Рис. 9.4.** Взвешенный и невзвешенный графы + +### Представление графа + +> Графы можно представить с помощью матрицы смежности и списка смежно- сти. Рассмотрим пример с неориентированным графом. + +##### Матрица смежности + +> Пусть количество вершин графа равно *n*, *матрица смежности* представля- ет граф в виде матрицы размером *n*×*n*, где каждая строка (столбец) соот- ветствует вершине, а элементы матрицы обозначают наличие ребра. Зна- чение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию. +> +> Обозначим матрицу смежности как *M*, а список вершин как *V*. Тогда элемент матрицы *M*\[*i*, *j*\] = 1 указывает на наличие ребра между вершинами *V*\[*i*\] и *V*\[*j*\], в противном случае элемент матрицы *M*\[*i*, *j*\] = 0, см. рис. 9.5. + +![](ru/docs/assets/media/image440.jpeg) + +> **Рис. 9.5.** Представление графа с помощью матрицы смежности +> +> Матрица смежности обладает следующими свойствами. + +- В простом графе вершина не может быть соединена с самой собой, по- этому элементы на главной диагонали матрицы смежности не имеют значения. + +- Для неориентированного графа ребра в обоих направлениях эквива- лентны, поэтому матрица смежности симметрична относительно глав- ной диагонали. + +- Заменив элементы матрицы смежности с 1 и 0 на веса ребер, можно представить взвешенный граф. + +> Используя матрицу смежности для представления графа, можно напрямую обращаться к элементам матрицы для получения информации о ребрах, что делает операции добавления, удаления, поиска и изменения достаточно эф- фективными с временной сложностью *O*(1). Однако пространственная слож- ность матрицы составляет *O*(*n*2), что требует значительных затрат памяти. + +##### Список смежности + +> *Список смежности* представляет граф с помощью *n* списков, где узлы списка представляют вершины. *i*-й список соответствует вершине *i* и содержит все смежные вершины (вершины, соединенные с данной вершиной). На рис. 9.6 показан пример графа, представленного с помощью списка смежности. + +![](ru/docs/assets/media/image442.jpeg) + +> **Рис. 9.6.** Представление графа с помощью списка смежности +> +> В списке смежности хранятся только существующие ребра, а общее количе- ство ребер обычно значительно меньше *n*2, что делает его более экономичным по памяти. Однако для поиска ребра в списке смежности необходимо просма- тривать список, что делает его менее эффективным по времени по сравнению с матрицей смежности. + +###### Как видно из рис. 9.6, структура списка смежности очень похожа на цепную адресацию в хеш-таблицах, поэтому можно использовать ана- + +> **логичные методы для оптимизации эффективности**. Например, если список длинный, его можно преобразовать в АВЛ-дерево или красно-черное дерево, чтобы повысить временную эффективность с *O*(*n*) до *O*(log *n*). Так- же можно преобразовать список в хеш-таблицу, чтобы снизить временную сложность до *O*(1). + +### Типичные сценарии применения графов + +> Многие реальные системы можно моделировать с помощью графов, а соответ- ствующие задачи могут быть сведены к задачам вычисления на графах, см. табл. 9.1. + ++---------------------+-----------------+-------------------------+------------------------------------------+ +| | > **Вершина** | > **Ребро** | > **Задача вычисления на графе** | ++=====================+=================+=========================+==========================================+ +| > Социальные сети | > Пользователи | > Дружеские связи | > Рекомендации потенци- альных друзей | ++---------------------+-----------------+-------------------------+------------------------------------------+ +| > Линии метро | > Станции | > Связь между станциями | > Рекомендации по крат- чайшему маршруту | ++---------------------+-----------------+-------------------------+------------------------------------------+ +| > Солнечная система | > Небесные тела | > Взаимодействие грави- | > Расчет орбит планет | ++---------------------+-----------------+-------------------------+------------------------------------------+ + +> тации между телами + +#### ОСнОвные Операции С графами + +> Основные операции с графами можно разделить на операции с ребрами и опе- рации с вершинами. В зависимости от способа представления (матрица смеж- ности или список смежности) реализация будет различаться. + +### Реализация на основе матрицы смежности + +> Ниже приведены операции для заданного неориентированного графа с коли- чеством вершин *n*. Способы реализации показаны на рис. 9.7. + +- **Добавление или удаление ребра**: достаточно изменить соответствую- щее ребро в матрице смежности за время *O*(1). Поскольку граф неориен- тированный, необходимо обновить ребра в обоих направлениях. + +- **Добавление вершины**: в конец матрицы смежности добавляется строка и столбец, которые заполняются нулями. Временная сложность равна *O*(*n*). + +- **Удаление вершины**: удаляется строка и столбец из матрицы смеж- ности. В худшем случае при удалении первой строки и столбца не- обходимо переместить (*n* − 1)2 элементов влево вверх, что занимает время *O*(*n*2). + +- **Инициализация**: передается *n* вершин, инициализируется список вер- шин vertices длиной *n* за время *O*(*n*). Инициализируется матрица смеж- ности adjMat размером *n*×*n* за время *O*(*n*2). + +> ![](ru/docs/assets/media/image444.jpeg) + +![](ru/docs/assets/media/image446.jpeg)![](ru/docs/assets/media/image448.jpeg) + +> **Рис. 9.7.** Инициализация матрицы смежности, добавление и удаление ребер и вершин. Шаги 1--3 +> +> ![](ru/docs/assets/media/image450.jpeg) + +![](ru/docs/assets/media/image452.jpeg) + +> **Рис. 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\]\] = \[\] +> +> \# Добавление вершин. 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. +> +> \# Обработка выхода за границы индексов и равенства. +> +> 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) + +### Реализация на основе списка смежности + +> Ниже приведены описания операций для неориентированного графа с общим количеством вершин *n* и ребер *m*. Способы реализации показаны на рис. 9.8. + +- **Добавление ребра**: достаточно добавить ребро в конец связного списка, соответствующего вершине за время *O*(1). Поскольку граф неориентиро- ванный, необходимо добавить ребра в обоих направлениях. + +- **Удаление ребра**: необходимо найти и удалить указанное ребро в связ- ном списке, соответствующем вершине, за время *O*(*m*). В неориентиро- ванном графе необходимо удалить ребра в обоих направлениях. + +- **Добавление вершины**: добавляется связный список в список смеж- ности, а новая вершина становится головным узлом списка. Требуется время *O*(1). + +- **Удаление вершины**: необходимо пройтись по всему списку смежности и удалить все ребра, содержащие указанную вершину. Требуется время *O*(*n* + *m*). + +- **Инициализация**: в списке смежности создается *n* вершин и 2*m* ребер за время O(*n* + *m*). + +![](ru/docs/assets/media/image454.jpeg) + +> **Рис. 9.8.** Инициализация списка смежности, добавление и удаление ребер и вершин. +> +> Шаг 1 +> +> ![](ru/docs/assets/media/image456.jpeg) + +![](ru/docs/assets/media/image458.jpeg)![](ru/docs/assets/media/image460.jpeg) + +> **Рис. 9.8.** *Продолжение*. Шаг 2--4 +> +> ![](ru/docs/assets/media/image462.jpeg) +> +> **Рис. 9.8.** *Окончание*. Шаг 5 +> +> Ниже приведен код реализации списка смежности. По сравнению с рис. 9.8 код имеет следующие отличия: + +- для удобства добавления и удаления вершин, а также упрощения кода вместо связного списка используется список (динамический массив); + +- для хранения списка смежности используется хеш-таблица, где ключом является экземпляр вершины, а значением -- список смежных вершин (связный список). + +> Кроме того, в списке смежности используется класс 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\]) +> +> 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},\") + +### Сравнение эффективности + +> Пусть дан граф с *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 видно, что временная и пространственная эффективность спи- ска смежности (хеш-таблица) наиболее оптимальна. Однако на практике опе- рации с ребрами в матрице смежности более эффективны, так как требуют лишь одного доступа или присвоения в массиве. В целом матрица смежности реализует принцип обмена пространства на время, тогда как список смежно- сти -- обмена времени на пространство. + +#### Обход графа + +> Дерево представляет собой отношение «один ко многим», тогда как граф обла- дает большей степенью свободы и может представлять произвольные отноше- ния «многие ко многим». Таким образом, **дерево можно рассматривать как частный случай графа**. Очевидно, что операции обхода дерева также являют- ся частным случаем обхода графа. +> +> И графы, и деревья требуют применения алгоритмов поиска для реализации операций обхода. Способы обхода графа можно разделить на два типа: обход в ширину и обход в глубину. + +### Обход в ширину + +> **Обход в ширину (BFS)** -- **это метод обхода от ближнего к дальнему**, **начи- ная с определенного узла**, **с посещением в первую очередь ближайших вершин с постепенным расширением наружу**. Начиная с левого верхнего угла, сначала обходятся все смежные вершины текущей вершины, затем все смежные вершины следующей вершины и т. д., пока не будут посещены все вершины, как показано на рис. 9.9. +> +> ![](ru/docs/assets/media/image464.jpeg) +> +> **Рис. 9.9.** Обход графа в ширину + +##### Реализация алгоритма + +> Обход в ширину обычно реализуется с помощью очереди, код реализации при- веден ниже. Очередь обладает свойством «первый пришел -- первый вышел», что соответствует идее обхода в ширину «от ближнего к дальнему». Алгоритм следующий: + +1) добавить начальную вершину обхода startVet в очередь и начать цикл; + +2) на каждой итерации цикла извлекать вершину из головы очереди и за- писывать посещение, затем добавлять все смежные вершины этой вер- шины в хвост очереди; + +3) повторять шаг 2, пока не будут посещены все вершины. + +> Чтобы избежать повторного обхода вершин, необходимо использовать хеш- множество visited для записи посещенных узлов. +> +> \# === File: graph_bfs.py === +> +> def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -\> list\[Vertex\]: \"\"\" Обход в ширину.\"\"\" +> +> \# Использование списка смежности для представления графа, чтобы получить +> +> все смежные вершины текущей вершины. +> +> \# Последовательность обхода вершин. res = \[\] +> +> \# Хеш-множество для записи уже посещенных вершин. visited = set\[Vertex\](\[start_vet\]) +> +> \# Очередь для реализации поиска в ширину. 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 для более глубокого понимания. + +![](ru/docs/assets/media/image466.jpeg) + +> **Рис. 9.10.** Этапы обхода графа в ширину. Шаг 1 +> +> ![](ru/docs/assets/media/image468.jpeg) + +![](ru/docs/assets/media/image470.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image472.jpeg) + +![](ru/docs/assets/media/image474.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 4--5 +> +> ![](ru/docs/assets/media/image476.jpeg) + +![](ru/docs/assets/media/image478.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 6--7 +> +> ![](ru/docs/assets/media/image480.jpeg) + +![](ru/docs/assets/media/image482.jpeg) + +> **Рис. 9.10.** *Продолжение*. Шаги 8--9 +> +> ![](ru/docs/assets/media/image484.jpeg) + +![](ru/docs/assets/media/image486.jpeg) + +##### Анализ сложности + +> **Рис. 9.10.** *Окончание*. Шаги 10--11 +> +> **Временная сложность**: все вершины будут добавляются в очередь и удаляют- ся из нее ровно один раз, что требует времени *O*(\|*V*\|). В процессе обхода смеж- ных вершин, поскольку граф неориентированный, все ребра будут посещены дважды, что занимает время *O*(2\|*E*\|). В целом требуется время *O*(\|*V*\| + \|*E*\|). +> +> **Пространственная сложность**: список res, хеш-множество visited и количе- ство вершин в очереди que максимум равны \|*V*\|, что требует пространства *O*(\|*V*\|). + +### Обход в глубину + +> **Обход в глубину (DFS)** -- **это метод обхода, при котором сначала ис- следуются все возможные пути до самого конца**, **а затем происходит возврат**. Начиная с левой верхней вершины, посещается какая-либо смеж- ная вершина текущей вершины, пока не будет достигнут конец пути, после чего происходит возврат и опять продолжается обход до конца. Продолжа- ем процесс и так далее, пока все вершины не будут посещены, как показано на рис. 9.11. + +![](ru/docs/assets/media/image488.jpeg) + +> **Рис. 9.11.** Обход графа в глубину + +##### Реализация алгоритма + +> Этот алгоритмический подход «до конца и назад» обычно реализуется с помо- щью рекурсии. Подобно обходу в ширину, в обходе в глубину также необходи- мо использовать хеш-множество visited для записи уже посещенных вершин, чтобы избежать их повторного посещения. +> +> \# === File: graph_dfs.py === +> +> def dfs(graph: GraphAdjList, visited: set\[Vertex\], res: list\[Vertex\], vet: Ver- tex): +> +> \"\"\" Вспомогательная функция для обхода в глубину.\"\"\" 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\]: \"\"\" Обход в глубину.\"\"\" +> +> \# Использование списка смежности для представления графа, чтобы получить все смежные вершины текущей вершины. +> +> \# Последовательность обхода вершин. res = \[\] +> +> \# Хеш-множество для записи уже посещенных вершин. +> +> visited = set\[Vertex\]() +> +> dfs(graph, visited, res, start_vet) return res +> +> Алгоритм обхода в глубину показан на рис. 9.12. + +- **Прямые пунктирные линии** обозначают нисходящую рекурсию, ука- зывая на начало нового рекурсивного метода для посещения новой вер- шины. + +- **Изогнутые пунктирные линии** обозначают восходящую рекурсию, указывая на возврат данного рекурсивного метода к месту его начала. + +> Для лучшего понимания рекомендуется на примере рис. 9.12 и кода реали- зации мысленно (или с помощью рисунка) смоделировать весь процесс обхода в глубину, включая моменты начала и возврата каждого рекурсивного метода. + +![](ru/docs/assets/media/image490.jpeg)![](ru/docs/assets/media/image492.jpeg) + +> **Рис. 9.12.** Этапы обхода графа в глубину. Шаги 1--2 +> +> ![](ru/docs/assets/media/image494.jpeg) + +![](ru/docs/assets/media/image496.jpeg)![](ru/docs/assets/media/image498.jpeg) + +> **Рис. 9.12.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image500.jpeg) + +![](ru/docs/assets/media/image502.jpeg)![](ru/docs/assets/media/image504.jpeg) + +> **Рис. 9.12.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image506.jpeg) + +![](ru/docs/assets/media/image508.jpeg)![](ru/docs/assets/media/image510.jpeg) + +> **Рис. 9.12.** *Окончание*. Шаги 9--11 + +##### Анализ сложности + +> **Временная сложность**: все вершины будут посещены один раз, что требует времени *O*(\|*V*\|). Все ребра будут посещены дважды, что требует времени *O*(2\|*E*\|). В целом требуется время *O*(\|*V*\| + \|*E*\|). +> +> **Пространственная сложность**: список res и хеш-множество visited имеют максимум \|*V*\| вершин, максимальная глубина рекурсии равна \|*V*\|, следователь- но, требуется пространство *O*(\|*V*\|). + +#### резюме + +##### Ключевые моменты + +- Граф состоит из вершин и ребер, его можно задать как множество вер- шин и множество ребер. + +- По сравнению с линейными отношениями (список) и отношениями раз- деления (дерево), сетевые отношения (граф) обладают большей степе- нью свободы и, следовательно, более сложны. + +- Ребра ориентированного графа имеют направленность, в связном графе любые вершины достижимы, во взвешенном графе каждое ребро содер- жит переменную веса. + +- Матрица смежности использует матрицу для представления графа, каж- дая строка (столбец) представляет вершину, элементы матрицы пред- ставляют ребра. Значение 1 соответствует наличию ребра между двумя вершинами, значение 0 -- отсутствию. Матрица смежности эффектив- на в операциях добавления, удаления, поиска и изменения, но требует больше пространства. + +- Список смежности использует несколько списков для представления графа, *i*-й список соответствует вершине *i* и хранит все смежные верши- ны этой вершины. Список смежности экономнее по сравнению с матри- цей смежности, но из-за необходимости обхода списка для поиска ребра его временная эффективность ниже. + +- Когда списки в списке смежности становятся слишком длинными, их можно преобразовать в красно-черное дерево или хеш-таблицу для по- вышения эффективности поиска. + +> 9.4. Резюме ❖ **263** + +- С точки зрения алгоритмических подходов матрица смежности реализу- ет принцип обмена пространства на время, а список смежности -- обмена времени на пространство. + +- Графы используются для моделирования различных реальных систем, таких как социальные сети, линии метро и т. д. + +- Дерево является частным случаем графа, а обход дерева -- частным слу- чаем обхода графа. + +- Обход графа в ширину (BFS) представляет собой метод поиска, который расширяется от ближних к дальним уровням, обычно реализуется с по- мощью очереди. + +- Обход графа в глубину (DFS) -- это метод поиска, который сначала прохо- дит до конца, а затем отступает назад, когда дальнейшего пути нет, часто реализуется на основе рекурсии. + +##### Вопросы и ответы + +> **Вопрос**. Путь -- это последовательность вершин или ребер? +> +> **Ответ**. В разных языковых версиях «Википедии» определения различа- ются: в английской версии путь -- это последовательность ребер, а в русской версии путь -- это последовательность вершин. Приведем оригинальный текст английской версии: In graph theory, a path in a graph is a finite or infinite se- quence of edges which joins a sequence of vertices. +> +> В этой книге путь рассматривается как последовательность ребер, а не вер- шин. Это связано с тем, что между двумя вершинами может существовать не- сколько соединяющих ребер, и каждое из них соответствует отдельному пути. **Вопрос**. Могут ли существовать в несвязном графе недостижимые вершины? +> +> **Ответ**. В несвязном графе существует по крайней мере две вершины -- та- кие, что одна не достижима из другой. Для обхода несвязного графа необхо- димо установить несколько начальных точек, чтобы обойти все связные ком- поненты графа. +> +> **Вопрос**. Существует ли в списке смежности требование к выбору порядка всех вершин, связанных с данной вершиной? +> +> **Ответ**. Порядок может быть произвольным. Однако на практике может по- требоваться сортировка по определенным правилам, например в порядке до- бавления вершин или в порядке значений вершин, что помогает быстро на- ходить вершины с определенным экстремумом. +> +> Глава 10 + +# Поиск + +![](ru/docs/assets/media/image511.jpeg){width="3.5416633858267716in" height="4.583333333333333in"} + +#### двоичный поиск + +> Двоичный (бинарный) поиск -- это эффективный алгоритм поиска, основан- ный на стратегии «разделяй и властвуй». Он использует упорядоченность дан- ных, сокращая на каждом шаге область поиска вдвое, пока не будет найден целевой элемент или область поиска не станет пустой. + +![](ru/docs/assets/media/image513.jpeg) + +> **Рис. 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. +> +> ![](ru/docs/assets/media/image515.jpeg) + +![](ru/docs/assets/media/image517.jpeg)![](ru/docs/assets/media/image519.jpeg) + +> **Рис. 10.2.** Процесс двоичного поиска. Шаги 1--3 + +![](ru/docs/assets/media/image521.jpeg) + +> ![](ru/docs/assets/media/image523.jpeg) + +![](ru/docs/assets/media/image525.jpeg) + +> **Рис. 10.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image527.jpeg) +> +> **Рис. 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* занимают +> +> постоянное количество памяти. + +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* также симметричны. Это снижает вероятность ошибок, поэтому обыч- но **рекомендуется использовать запись «двойной замкнутый интервал»**. +> +> ![](ru/docs/assets/media/image529.jpeg)**Элемент** +> +> Индекс +> +> Обе границы включены в интервал +> +> Интервал поиска: двойной замкнутый \[**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.** Два определения интервалов + +### Преимущества и ограничения + +> Двоичный поиск обладает хорошей производительностью как по времени, так и по пространству. + +- Двоичный поиск отличается высокой эффективностью по времени. При большом объеме данных логарифмическая временная сложность име- ет значительное преимущество. Например, при размере данных *n* = 220 линейный поиск требует 220 = 1 048 576 итераций, тогда как двоичный поиск -- всего log2 220 = 20 итераций. + +- Двоичный поиск, в отличие от некоторых других алгоритмов поиска (на- + +> пример, хеш-поиска), не требует дополнительного пространства и по- этому более экономичен в плане использования памяти. +> +> Тем не менее двоичный поиск не подходит для всех случаев по следующим основным причинам. + +- Двоичный поиск применим только к упорядоченным данным. Если входные данные неупорядоченные, то их сортировка специально для использования двоичного поиска не оправдана. Это связано с тем, что временная сложность алгоритмов сортировки обычно составляет *O*(*n* log *n*), что выше, чем у линейного и двоичного поиска. В сценари- ях с частыми добавлениями элементов для поддержания упорядочен- ности массива необходимо вставлять элементы в определенные по- зиции, что имеет временную сложность *O*(*n*) и также является весьма затратным. + +- Двоичный поиск применим только к массивам, поскольку требует скач- кообразного (непрерывного) доступа к элементам. В связных списках выполнение скачкообразного доступа менее эффективно, поэтому такой поиск не подходит для применения в связных списках и структурах дан- ных, основанных на них. + +- При небольших объемах данных линейный поиск более эффективен. В линейном поиске на каждом этапе требуется только одна операция сравнения; в двоичном поиске требуется одна операция сложения, одна операция деления, от одной до трех операций сравнения и одна опера- ция сложения (вычитания), всего от четырех до шести элементарных операций. Поэтому, когда объем данных *n* невелик, линейный поиск ока- зывается быстрее двоичного. + +#### вставка с использованием двоичного поиска + +> Двоичный поиск можно использовать не только для поиска целевого элемен- та, но и для решения множества других задач, таких как поиск позиции для вставки целевого элемента. + +### Случай без повторяющихся элементов + +![](ru/docs/assets/media/image531.jpeg) + +> **Рис. 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 === +> +> 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 + +### Случай с повторяющимися элементами + +> Если в массиве существует несколько одинаковых target, то обычный двоич- ный поиск может вернуть индекс только одного из них, не определяя, сколько target находится слева и справа от этого элемента. +> +> Задача требует вставить целевой элемент в самое левое положение, поэто- му необходимо найти индекс самого левого target в массиве. Первоначально предполагается реализовать решение следующим образом (см. рис. 10.5): + +1) выполнить двоичный поиск и получить индекс любого target, обозна- чить его как *k*; + +2) начиная с индекса *k*, выполнить линейный обход влево и вернуть ин- декс, когда будет найден самый левый target. + +![](ru/docs/assets/media/image533.jpeg) + +> **Рис. 10.5.** Линейный поиск точки вставки для повторяющихся элементов +> +> Это рабочий метод, но он включает линейный поиск, поэтому его времен- ная сложность составляет *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* **является точкой вставки**. + +![](ru/docs/assets/media/image535.jpeg)![](ru/docs/assets/media/image537.jpeg) + +> **Рис. 10.6.** Этапы двоичного поиска точки вставки для повторяющихся элементов. Шаги 1--2 + +![](ru/docs/assets/media/image523.jpeg) + +> ![](ru/docs/assets/media/image539.jpeg) + +![](ru/docs/assets/media/image541.jpeg) + +> **Рис. 10.6.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image543.jpeg) + +![](ru/docs/assets/media/image545.jpeg)![](ru/docs/assets/media/image547.jpeg) + +> **Рис. 10.6.** *Окончание*. Шаги 6--8 +> +> Ниже приведен пример кода. Операции в ветвях 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* постепенно приближаются к заранее установленной цели. В конечном итоге они либо +> +> успешно находят ответ, либо останавливаются после выхода за границы. + +#### двоичный поиск границ + +### Поиск левой границы + +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 + +### Поиск правой границы + +> Как найти самый правый элемент target? Самый очевидный способ -- изменить код, заменив операцию сужения указателя в случае nums\[m\] == target. Мы не будем приводить код для этого случая, заинтересованные читатели могут реа- лизовать его самостоятельно. +> +> Ниже представлены два более изящных подхода. + +##### Повторное использование поиска левой границы + +> На самом деле можно использовать функцию поиска самого левого элемента для поиска самого правого элемента. Что именно нужно сделать: преобразо- вать поиск самого правого элемента target в поиск самого левого target + 1. +> +> После завершения поиска указатель *i* указывает на самый левый элемент target + 1 (если он существует), а *j* указывает на самый правый target, поэтому можно вернуть *j*, как показано на рис. 10.7. +> +> ![](ru/docs/assets/media/image549.jpeg) +> +> **Рис. 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 + +##### Преобразование в поиск элемента + +> Известно, что, когда массив не содержит элемент target, индексы *i* и *j* в конеч- ном итоге указывают на первый элемент, больший target, и на первый эле- мент, меньший target, соответственно. +> +> Таким образом, для поиска левой и правой границ можно создать элемент, отсутствующий в массиве, как показано на рис. 10.8. + +- **Поиск самого левого** target: можно преобразовать в поиск target - 0.5 + +> и вернуть указатель *i*. + +- **Поиск самого правого** target: можно преобразовать в поиск target + 0.5 + +> и вернуть указатель *j*. +> +> ![](ru/docs/assets/media/image551.jpeg) +> +> **Рис. 10.8.** Преобразование поиска границ в поиск элемента +> +> Код мы не приводим, но стоит обратить внимание на следующие два мо- мента: + +1) данный массив не содержит дробных чисел, т. е. не нужно беспокоиться об обработке случаев равенства другим элементам массива; + +2) поскольку этот метод вводит дробные числа, необходимо изменить тип переменной target на тип с плавающей запятой (в Python это изменение не требуется). + +#### Стратегии оптимизации хеширования + +> В алгоритмических задачах **линейный поиск часто заменяется на хеш- поиск**, **чтобы снизить временную сложность алгоритма**. Рассмотрим за- дачу для углубленного понимания этого приема. + +### Линейный поиск: обмен времени на пространство + +> Рассмотрим *прямой перебор* всех возможных комбинаций. Мы запускаем два вложенных цикла и на каждой итерации проверяем, равна ли сумма двух це- лых чисел target. Если да, то возвращаем их индексы, см. рис. 10.9. +> +> ![](ru/docs/assets/media/image553.jpeg) +> +> **Рис. 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(*n*2), а пространственная сложность *O*(1), что делает его крайне медленным при большом объеме данных. + +### Хеш-поиск: обмен пространства на время + +> Рассмотрим использование *хеш-таблицы*, в которой ключами и значениями являются элементы массива и их индексы. Циклически обходим массив, вы- полняя следующие шаги, показанные на рис. 10.10: + +1) проверить, содержится ли число target - nums\[i\] в хеш-таблице. Если да, то сразу вернуть индексы этих двух элементов; + +2) добавить в хеш-таблицу пару ключ--значение: nums\[i\] и *i*. + +> ![](ru/docs/assets/media/image555.jpeg) + +![](ru/docs/assets/media/image557.jpeg) + +> **Рис. 10.10.** Использование вспомогательной хеш-таблицы для нахождения двух чисел, сумма которых равна заданному. Шаги 1--2 +> +> ![](ru/docs/assets/media/image559.jpeg) +> +> **Рис. 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*(*n*2) до *O*(*n*) благодаря хеш- поиску, значительно повышая эффективность выполнения. +> +> Поскольку требуется поддерживать дополнительную хеш-таблицу, про- странственная сложность составляет *O*(*n*). **Тем не менее общая эффектив- ность этого метода более сбалансирована**, **что делает его оптимальным решением данной задачи**. + +#### переосмысление алгоритмов поиска + +> Алгоритмы поиска используются для нахождения одного или нескольких эле- ментов, удовлетворяющих определенным условиям, в структурах данных, та- ких как массивы, списки, деревья или графы. +> +> Алгоритмы поиска можно классифицировать по принципу их реализации на следующие категории. + +- **Поиск целевого элемента путем обхода структуры данных**, напри- мер обход массива, списка, дерева и графа. + +- **Эффективный поиск элементов с использованием структуры орга- низации данных или априорной информации**, например двоичный поиск, хеш-поиск и поиск в двоичных деревьях. + +> Нетрудно заметить, что эти темы уже были рассмотрены в предыдущих гла- вах, поэтому алгоритмы поиска нам уже знакомы. В этом разделе мы система- тизируем полученные ранее знания. + +### Полный перебор + +> *Полный перебор* заключается в обходе каждого элемента структуры данных для нахождения целевого элемента. + +- Линейный поиск применяется к линейным структурам данных, таким как массивы и списки. Он начинается с одного конца структуры данных и последовательно проверяет элементы, пока не будет найден целевой элемент или не будет достигнут другой конец. + +- Поиск в ширину и поиск в глубину -- это две стратегии обхода графов и деревьев. Поиск в ширину начинается с начального узла и исследу- ет все узлы на текущем уровне перед переходом на следующий. Поиск в глубину начинается с начального узла и следует по пути до конца, за- тем возвращается и пробует другие пути, пока не будет полностью прой- дена вся структура данных. + +> Преимущество полного перебора заключается в его простоте и универсаль- ности, **так как он не требует предварительной обработки данных и ис- пользования дополнительных структур данных**. +> +> Однако **временная сложность таких алгоритмов составляет** *O*(*n*), где *n* -- количество элементов, что делает их менее эффективными при большом объ- еме данных. + +### Адаптивный поиск + +> *Адаптивный поиск* использует специфические свойства данных (например, упорядоченность) для оптимизации процесса поиска, что позволяет более эф- фективно находить целевой элемент. + +- Двоичный поиск использует упорядоченность данных для эффективно- го поиска и применим только к массивам. + +- Хеш-поиск использует хеш-таблицы для создания отображения между данными поиска и целевыми данными, что позволяет эффективно вы- полнять операции поиска. + +- Поиск в дереве осуществляется в определенной структуре дерева (на- пример, в двоичном дереве поиска) путем сравнения значений узлов для быстрого исключения узлов и нахождения целевого элемента. + +> Преимуществом таких алгоритмов является высокая эффективность, **вре- менная сложность может достигать** *O*(log *n*) **и даже** *O*(1). +> +> Однако **использование этих алгоритмов часто требует предваритель- ной обработки данных**. Например, для двоичного поиска необходимо пред- +> +> варительно отсортировать массив, хеш-поиск и поиск в дереве требуют ис- пользования дополнительных структур данных, поддержание которых также требует дополнительных временных и пространственных затрат. + +### Выбор метода поиска + +> Для поиска целевого элемента в заданном наборе данных размером *n* можно использовать различные методы, такие как линейный поиск, двоичный поиск, поиск в дереве, хеш-поиск и др. Принципы работы каждого метода показаны на рис. 10.11. + +![](ru/docs/assets/media/image561.jpeg) + +> **Рис. 10.11.** Различные стратегии поиска +> +> Эффективность и характеристики указанных методов приведены в табл. 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.1* | ++====================================+======================+:==================:+========================================+:==================================:+ +| | > **Линейный поиск** | > **Двоичный** | > **Поиск в дереве** | > **Хеш-поиск** | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ +| > Предварительная обработка данных | > -- | > Сортировка | > Построение де- рева *O*(*n* log *n*) | > Построение хеш- таблицы *O*(*n*) | +| | | > | | | +| | | > *O*(*n* log *n*) | | | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ +| > Упорядоченность данных | > Неупорядо- ченные | > Упорядочен- | > Упорядоченные | > Неупорядоченные | ++------------------------------------+----------------------+--------------------+----------------------------------------+------------------------------------+ + +> ные +> +> Выбор алгоритма поиска также зависит от объема данных, требований к производительности поиска, частоты запросов и обновлений данных. + +##### Линейный поиск + +- Обладает хорошей универсальностью, не требует предварительной об- работки данных. Если необходимо выполнить только один запрос, вре- мя предварительной обработки данных для других трех методов будет дольше, чем время линейного поиска. + +- Подходит для небольших объемов данных, в этом случае временная сложность мало влияет на эффективность. + +- Подходит для сценариев с высокой частотой обновления данных, так как этот метод не требует дополнительного обслуживания данных. + +##### Двоичный поиск + +- Подходит для больших объемов данных, демонстрирует стабильную эф- фективность, худшая временная сложность составляет *O*(log *n*). + +- Объем данных не должен быть слишком большим, так как массив требует непрерывного пространства в памяти. + +- Не подходит для сценариев с частыми добавлениями и удалениями дан- ных, так как поддержание упорядоченного массива требует значитель- ных затрат. + +##### Хеш-поиск + +- Подходит для сценариев с высокими требованиями к производительно- сти поиска, средняя временная сложность составляет *O*(1). + +- Не подходит для случаев, когда требуется упорядоченность данных или поиск по диапазону, так как хеш-таблица не может поддерживать упоря- доченность данных. + +- Сильно зависит от хеш-функции и стратегии обработки коллизий, суще- ствует значительный риск ухудшения производительности. + +- Не подходит для слишком больших объемов данных, так как хеш-таблица требует дополнительного пространства для минимизации коллизий и обеспечения хорошей производительности поиска. + +##### Поиск в дереве + +- Подходит для огромных объемов данных, так как узлы дерева хранятся в памяти раздельно. + +- Подходит для случаев, когда требуется поддержание упорядоченности данных или поиск по диапазону. + + + +- В процессе постоянного добавления и удаления узлов двоичное дерево поиска может стать несбалансированным, и временная сложность ухуд- шится до *O*(*n*). + +- Если используется АВЛ-дерево или красно-черное дерево, все операции могут выполняться со стабильной эффективностью *O*(log *n*), но операции по поддержанию баланса дерева увеличивают дополнительные затраты. + +#### резюме + +- Двоичный поиск требует упорядоченности данных и выполняется путем циклического сокращения области поиска в два раза. Требует упорядо- ченности входных данных и подходит только для массивов или структур данных, основанных на массивах. + +- Полный перебор осуществляется путем обхода структуры данных для нахождения целевого значения. Линейный поиск подходит для массивов и списков, поиск в ширину и поиск в глубину подходят для графов и де- ревьев. Эти алгоритмы обладают хорошей универсальностью, не требу- ют предварительной обработки данных, но их временная сложность *O*(*n*) достаточно высока. + +- Хеш-поиск, поиск в деревьях и двоичный поиск относятся к эффек- тивным методам поиска, которые позволяют быстро находить целевой элемент в определенных структурах данных. Эти алгоритмы отличают- ся высокой эффективностью, их временная сложность может достигать *O*(log *n*) или даже *O*(1), однако обычно они требуют использования до- полнительных структур данных. + +- На практике для выбора подходящего метода необходимо проводить конкретный анализ таких факторов, как объем данных, требования к производительности поиска, частота запросов и обновлений данных. + +- Линейный поиск подходит для небольших или часто обновляемых дан- ных. Двоичный поиск -- для больших, отсортированных данных. Хеш- поиск -- когда важна высокая эффективность запросов и не требуется поиск диапазонов. Поиск в деревьях -- для больших динамических дан- ных, в которых необходимо поддерживать порядок и выполнять запросы диапазонов. + +- Замена линейного поиска на хеш-поиск является распространенной стратегией оптимизации времени выполнения, позволяющей снизить временную сложность с *O*(*n*) до *O*(*1*). + +> Глава 11 + +# Сортировка + +![](ru/docs/assets/media/image562.jpeg){width="3.5416655730533684in" height="4.583333333333333in"} + +#### Алгоритмы сортировки + +> *Алгоритмы сортировки* используются для упорядочивания набора данных в определенном порядке. Они имеют широкое применение, поскольку упоря- доченные данные обычно можно более эффективно анализировать, обрабаты- вать и выполнять в них поиск. +> +> Типы данных в алгоритмах сортировки могут быть целыми числами, числа- ми с плавающей запятой, символами или строками, как показано на рис. 11.1. Правила сортировки могут быть установлены в зависимости от потребностей, например по величине чисел, порядку ASCII-кодов символов или произволь- ным пользовательским правилам. + +![](ru/docs/assets/media/image564.jpeg) + +> **Рис. 11.1.** Пример типов данных и правил сортировки + +1. **Критерии оценки** + +> **Эффективность выполнения**: ожидается, что временная сложность алгорит- ма сортировки будет как можно ниже, а общее количество операций -- мини- мальным (уменьшение константного множителя во временной сложности). Для больших объемов данных эффективность выполнения особенно важна. +> +> **Местность**: как следует из названия, сортировка на месте осуществляется путем непосредственной работы с исходным массивом без использования до- полнительных вспомогательных массивов, что позволяет экономить память. Обычно операции перемещения данных при сортировке на месте малочис- ленны, а скорость выполнения выше. +> +> **Стабильность**: стабильная сортировка сохраняет относительный порядок равных элементов в массиве после завершения сортировки. +> +> Стабильная сортировка является необходимым условием для многоуров- невой сортировки. Предположим, что у нас есть таблица с информацией о студентах, где 1-й и 2-й столбцы -- это имя и возраст соответственно. В этом случае нестабильная сортировка может привести к потере упорядоченности входных данных. +> +> \# Входные данные отсортированы по имени. \# (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*), но ее универсальность относительно ниже. + +### Идеальный алгоритм сортировки + +> **Быстрый**, **на месте**, **стабильный**, **адаптивный**, **с хорошей универсально- стью**. Очевидно, что до сих пор нет алгоритма сортировки, сочетающего все эти характеристики. Поэтому при выборе алгоритма необходимо учитывать особенности данных и требования задачи. +> +> Далее мы изучим различные алгоритмы сортировки и проанализируем их достоинства и недостатки на основе вышеуказанных критериев оценки. + +#### Сортировка выбором + +> Принцип работы *сортировки выбором* весьма прост: запускается цикл, в каж- дой итерации которого из неотсортированной части массива выбирается наи- меньший элемент и помещается в конец отсортированной части. +> +> Пусть длина массива равна *n*, алгоритм сортировки выбором заключается в следующем (см. рис. 11.2): + +1) в начальном состоянии все элементы не отсортированы, т. е. неотсорти- рованный (индексный) диапазон равен \[0, *n* -- 1\]; + +2) выбирается наименьший элемент из диапазона \[0, *n* -- 1\] и меняется ме- стами с элементом с индексом 0. После этого первый элемент массива отсортирован; + +3) выбирается наименьший элемент из диапазона \[1, *n* -- 1\] и меняется ме- стами с элементом с индексом 1. После этого первые два элемента мас- сива отсортированы; + +4) таким образом, после *n* -- 1 итераций выбора и обмена первые *n* -- 1 эле- ментов массива отсортированы; + +5) единственный оставшийся элемент обязательно является наибольшим, поэтому сортировка массива завершена. + +![](ru/docs/assets/media/image566.jpeg)![](ru/docs/assets/media/image568.jpeg)![](ru/docs/assets/media/image570.jpeg) + +> **Рис. 11.2.** Этапы сортировки выбором. Шаги 1--3 +> +> ![](ru/docs/assets/media/image572.jpeg) + +![](ru/docs/assets/media/image574.jpeg)![](ru/docs/assets/media/image576.jpeg) + +> **Рис. 11.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image578.jpeg) + +![](ru/docs/assets/media/image580.jpeg)![](ru/docs/assets/media/image582.jpeg) + +> **Рис. 11.2.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image584.jpeg) + +![](ru/docs/assets/media/image586.jpeg) + +> **Рис. 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\] + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **неадаптивная сортировка**: внешний цикл выполняется *n* -- 1 раз, длина неотсортированного диапазона на первой итерации равна *n*, на последней -- 2, т. е. каждый внешний цикл включает *n*, *n* -- 1, \..., 3, 2 итераций внутреннего цикла, сумма которых равна (*n* -- 1)(*n* + 2)/2. + +- **Пространственная сложность** *O*(1), **сортировка на месте**: указате- ли *i* и *j* используют дополнительное пространство постоянного раз- мера. + +- **Нестабильная сортировка**: как показано на рис. 11.3, элемент nums\[i\] может быть перемещен вправо от равного ему элемента, что изменяет их относительный порядок. + +![](ru/docs/assets/media/image588.jpeg) + +> **Рис. 11.3.** Пример нестабильности сортировки выбором + +#### Сортировка пузырьком + +> *Сортировка пузырьком* реализует сортировку путем последовательного срав- нения и обмена соседних элементов. Этот процесс напоминает подъем пу- зырьков со дна на поверхность, отсюда и такое название. +> +> Процесс поднятия пузырька можно смоделировать операцией обмена элементов: начиная с самого левого конца массива, производится последо- вательное сравнение соседних элементов, и, если левый элемент \> правый элемент, они меняются местами, как показано на рис. 11.4. После заверше- ния прохода наибольший элемент будет перемещен в самый правый конец массива. + +![](ru/docs/assets/media/image590.jpeg) + +![](ru/docs/assets/media/image592.jpeg)![](ru/docs/assets/media/image594.jpeg)![](ru/docs/assets/media/image596.jpeg) + +> **Рис. 11.4.** Моделирование поднятия пузырька с помощью обмена элементов. Шаги 1--4 +> +> ![](ru/docs/assets/media/image598.jpeg) + +![](ru/docs/assets/media/image600.jpeg)![](ru/docs/assets/media/image602.jpeg) + +### Алгоритм + +> **Рис. 11.4.** *Окончание*. Шаги 5--7 +> +> Пусть дан массив длиной *n*, тогда сортировка пузырьком выглядит следующим образом (см. рис. 11.5): + +1) сначала выполняется пузырек для *n* элементов, **перемещая наиболь- ший элемент в правильное положение**; + +2) затем выполняется пузырек для оставшихся *n* -- 1 элементов, **переме- щая второй по величине элемент в правильное положение**; + +3) таким образом, после *n* -- 1 итераций пузырька первые *n* -- 1 **наиболь- ших элементов перемещены в правильные положения**; + +4) единственный оставшийся элемент обязательно является наименьшим, поэтому сортировка массива завершена. + +> ![](ru/docs/assets/media/image604.jpeg) +> +> **Рис. 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\] + +### Оптимизация эффективности + +> Если в какой-либо итерации пузырька не выполняется ни одной операции об- мена, это означает, что массив уже отсортирован, и можно сразу вернуть ре- зультат. Поэтому можно добавить флаг flag для отслеживания этой ситуации, и как только она возникнет, немедленно выйти из цикла. +> +> После оптимизации наихудшая и средняя временные сложности сортиров- ки пузырьком остаются *O*(*n*2); однако, если входной массив полностью отсо- ртирован, можно достичь лучшей временной сложности *O*(*n*). +> +> \# === 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 \# В этой итерации \"пузырька\" не было обмена, выход из цикла. + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **адаптивная сортировка**: длина масси- ва, проходящего каждую итерацию пузырька, последовательно равна *n* -- 1, *n* -- 2, \..., 2, 1. Сумма этих значений равна (*n* -- 1)*n*/2. После вве- дения оптимизации с флагом лучшая временная сложность может до- стигать *O*(*n*). + +- **Пространственная сложность** *O*(1), **сортировка на месте**: указатели + +> *i* и *j* используют дополнительную память постоянного размера. + +- **Стабильная сортировка**: поскольку при сортировке пузырьком равные элементы не меняются местами. + +#### Сортировка вставками + +> *Сортировка вставками* -- это простой алгоритм сортировки, работа которого схожа с процессом ручной сортировки карт в колоде. +> +> Более конкретно: в неотсортированном сегменте выбирается опорный эле- мент, который сравнивается по величине с элементами в отсортированном сегменте слева и вставляется на правильное место. +> +> На рис. 11.6 иллюстрируется процесс вставки элемента в массив. Пусть опорный элемент обозначен как base, необходимо сдвинуть все элементы от целевого индекса до base вправо на одну позицию, затем присвоить base целе- вому индексу. + +4. ![](ru/docs/assets/media/image606.jpeg)Сортировка вставками ❖ **299** + +> **Рис. 11.6.** Операция одиночной вставки + +### Алгоритм + +> Процесс сортировки вставками выглядит следующим образом (см. рис. 11.7): + +1) в начальном состоянии первый элемент массива уже отсортирован; + +2) выбирается второй элемент массива в качестве base, **после его вставки на правильное место первые два элемента массива отсортированы**; + +3) выбирается третий элемент в качестве base, **после его вставки на пра- вильное место первые три элемента массива отсортированы**; + +4) таким образом, в последнем раунде выбирается последний элемент в качестве base, **после его вставки на правильное место все элементы отсортированы**. + +> ![](ru/docs/assets/media/image608.jpeg)**Отсортированный** +> +> **Рис. 11.7.** Процесс сортировки вставками +> +> Ниже приведен пример кода: +> +> \# === 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 правильному месту. + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n*2), **адаптивная сортировка**: в худшем слу- чае каждая операция вставки требует *n* − 1, *n* − 2, \..., 2, 1 циклов. Сумма этих чисел составляет (*n* − 1)*n*/2, поэтому временная сложность равна *O*(*n*2). При наличии упорядоченных данных операция вставки заверша- ется досрочно. Когда входной массив полностью упорядочен, сортировка вставками достигает лучшей временной сложности *O*(*n*). + +- **Пространственная сложность** O(1), **сортировка на месте**: указатели + +> *i* и *j* используют дополнительную память постоянного размера. + +- **Стабильная сортировка**: в процессе вставки элементы вставляются справа от равных элементов, не изменяя их порядок. + +### Преимущества сортировки вставками + +> Временная сложность сортировки вставками составляет *O*(*n*2), тогда как вре- менная сложность быстрой сортировки, которую мы скоро изучим, равна *O*(*n* log *n*). Несмотря на более высокую временную сложность, **сортировка вставками обычно быстрее при небольших объемах данных**. +> +> Этот вывод аналогичен применению линейного поиска и двоичного по- иска. Алгоритмы сортировки, такие как быстрая сортировка с временной сложностью *O*(*n* log *n*), основаны на стратегии «разделяй и властвуй» и часто содержат больше элементарных вычислительных операций. Однако при не- больших объемах данных значения *n*2 и *n* log *n* близки, и сложность не явля- ется доминирующей, а количество элементарных операций в каждом раунде играет решающую роль. +> +> Фактически многие языки программирования (например, Java) использу- ют встроенные функции сортировки, которые применяют сортировку встав- ками. Основная идея заключается в следующем: для длинных массивов ис- +> +> пользуется сортировка на основе стратегии «разделяй и властвуй», например быстрая сортировка. Для коротких массивов -- сортировка вставками. +> +> Хотя временная сложность сортировки пузырьком, сортировки выбором и сортировки вставками одинакова и равна *O*(*n*2), **на практике сортировка вставками используется значительно чаще** по следующим причинам. + +- Сортировка пузырьком основана на обмене элементов, требует исполь- зования временной переменной и включает три элементарные опера- ции. Сортировка вставками основана на присвоении элементов и тре- бует только одну элементарную операцию. Поэтому **вычислительные затраты сортировки пузырьком обычно выше, чем у сортировки вставками**. + +- В любом случае временная сложность сортировки выбором равна *O*(*n*2). **Если задана частично упорядоченная группа данных**, **сортировка вставками обычно эффективнее сортировки выбором**. + +- Сортировка выбором нестабильна и не может быть применена для мно- гоуровневой сортировки. + +#### Быстрая сортировка + +> *Быстрая сортировка* -- это алгоритм сортировки, основанный на стратегии +> +> «разделяй и властвуй». Он отличается высокой эффективностью и широким применением. +> +> Основной операцией быстрой сортировки является разделение с помощью стража, цель которого заключается в следующем: выбрать один из элементов массива в качестве опорного и переместить все элементы, меньшие опорно- го, влево от него, а элементы, большие опорного, вправо. Процесс разделения с помощью стража выглядит следующим образом (см. рис. 11.8): + +1) выбрать элемент на крайнем левом конце массива в качестве опор- ного, инициализировать два указателя *i* и *j*, указывающих на концы массива; + +2) установить цикл, в каждой итерации которого *i* (*j*) ищет первый элемент, больший (меньший) опорного, после чего эти два элемента меняются местами; + +3) продолжать выполнение шага 2 до тех пор, пока *i* и *j* не встретятся, затем переместить опорный элемент на границу между двумя под- массивами. + +> ![](ru/docs/assets/media/image611.jpeg) + +![](ru/docs/assets/media/image614.jpeg) + +> **Рис. 11.8.** Этапы разделения с помощью стража. Шаги 1--2 +> +> ![](ru/docs/assets/media/image616.jpeg) + +![](ru/docs/assets/media/image618.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 3--4 +> +> ![](ru/docs/assets/media/image620.jpeg) + +![](ru/docs/assets/media/image622.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 5--6 +> +> ![](ru/docs/assets/media/image624.jpeg) + +![](ru/docs/assets/media/image626.jpeg) + +> **Рис. 11.8.** *Продолжение*. Шаги 7--8 +> +> ![](ru/docs/assets/media/image628.jpeg) +> +> **Рис. 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.9): + +1) сначала выполнить одно разделение с помощью стража для исходного массива, получив неотсортированные левый и правый подмассивы; + +2) затем рекурсивно выполнить разделение с помощью стража для левого и правого подмассивов; + +3) продолжать рекурсию до тех пор, пока длина подмассива не станет рав- ной 1, таким образом завершая сортировку всего массива. + +![](ru/docs/assets/media/image630.jpeg) + +> **Рис. 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: в сред- нем случае количество рекурсивных уровней разделения с помощью стража равно log *n*, общее количество циклов на каждом уровне равно *n*, + +> что соответствует времени *O*(*n* log *n*). В худшем случае каждая операция разделения с помощью стража делит массив длиной *n* на два подмассива длиной 0 и *n* -- 1, в этом случае количество рекурсивных уровней дости- гает *n*, количество циклов на каждом уровне равно *n*, что соответствует времени O(*n*2). + +- **Пространственная сложность** *O*(*n*), **сортировка на месте**: в случае пол- ностью обратным порядком входного массива достигается худшая рекур- сивная глубина *n*, используется *O*(*n*) кадров стека. Сортировка выполня- ется на исходном массиве без использования дополнительных массивов. + +- **Нестабильная сортировка**: на последнем шаге разделения с помощью стража опорный элемент может быть перемещен вправо от равных ему элементов. + +### Почему быстрая сортировка быстрая + +> Уже из названия понятно, что быстрая сортировка должна иметь определен- ные преимущества в плане эффективности. Хотя средняя временная слож- ность быстрой сортировки такая же, как у сортировки слиянием и пирами- дальной сортировки, обычно быстрая сортировка более эффективна, по сле- дующим причинам. + +- **Вероятность возникновения худшего случая очень низка**: хотя худ- шая временная сложность быстрой сортировки составляет *O*(*n*2), что не так стабильно, как у сортировки слиянием, в подавляющем большин- стве случаев быстрая сортировка работает со сложностью *O*(*n* log *n*). + +- **Высокая эффективность использования кеша**: при выполнении опе- рации разделения с помощью стража система может загрузить весь под- массив в кеш, что повышает эффективность доступа к элементам. Такие алгоритмы, как пирамидальная сортировка, требуют скачкообразного доступа к элементам, что лишает их этого преимущества. + +- **Низкий коэффициент постоянной сложности**: среди трех упомяну- тых алгоритмов общее количество операций сравнения, присваивания и обмена в быстрой сортировке минимально. Это похоже на причину, по которой сортировка вставками быстрее пузырьковой сортировки. + +### Оптимизация выбора опорного элемента + +> **Быстрая сортировка может демонстрировать снижение эффективности на некоторых входных данных**. Например, в случае, когда входной массив полностью отсортирован в обратном порядке, если выбирать самый левый элемент в качестве опорного, то после завершения разделения по методу стра- жей опорный элемент перемещается в самый правый конец массива. В этом случае левый подмассив будет длиной *n* -- 1, а правый -- длиной 0. При таком рекурсивном подходе после каждого разделения один из подмассивов оказы- вается длиной 0, стратегия «разделяй и властвуй» не работает, и быстрая со- ртировка вырождается в форму, близкую к сортировке пузырьком. +> +> Чтобы минимизировать вероятность возникновения такой ситуации, **мож- но оптимизировать стратегию выбора опорного элемента в методе раз-** +> +> **деления**. Например, можно выбрать опорный элемент случайным образом. Однако, если вам не повезет и каждый раз будет выбран неудачный опорный элемент, эффективность все равно будет неудовлетворительной. +> +> Следует отметить, что программные языки обычно генерируют псевдослу- чайные числа. Если создать специальный тестовый пример для последователь- ности псевдослучайных чисел, эффективность быстрой сортировки все равно может ухудшиться. +> +> Для дальнейшего улучшения можно выбирать трех кандидатов из массива (обычно это первый, последний и средний элементы массива) и использо- вать медиану этих трех кандидатов в качестве опорного элемента. Таким об- разом, вероятность того, что опорный элемент будет ни слишком маленьким, ни слишком большим, значительно возрастает. Конечно, можно выбрать больше кандидатов, чтобы еще больше повысить устойчивость алгоритма. Применение этого метода значительно снижает вероятность ухудшения вре- менной сложности до *O*(*n*2). +> +> Ниже приведен пример кода. +> +> \# === 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 \# Возврат индекса опорного элемента + +### Оптимизация хвостовой рекурсии + +> **На некоторых входных данных быстрая сортировка может потреблять много памяти**. Например, в случае полностью отсортированного массива если длина подмассива в рекурсии равна *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.10. + +1. **Этап разделения**: массив рекурсивно делится пополам, превращая задачу сортировки длинного массива в задачу сортировки коротких массивов. + +2. **Этап слияния**: когда длина подмассива достигает 1, разделение пре- кращается и начинается слияние, при котором два более коротких упо- рядоченных массива объединяются в один более длинный упорядочен- ный массив. + +> ![](ru/docs/assets/media/image632.jpeg) +> +> **Рис. 11.10.** Этапы разделения и слияния в сортировке слиянием + +### Алгоритм + +> Этап разделения рекурсивно делит массив на два подмассива от вершины до основания, как показано на рис. 11.11. + +1. Вычисление средней точки массива mid, рекурсивное разделение левого подмассива (интервал \[left, mid\]) и правого подмассива (интервал \[mid + +> \+ 1, right\]). + +2. Рекурсивное выполнение шага 1 до тех пор, пока длина интервала под- массива не станет равной 1. + +> Этап слияния заключается в объединении левого и правого подмассивов в один упорядоченный массив снизу вверх. Следует отметить, что слияние на- чинается с подмассивов длиной 1, при этом каждый подмассив на этапе слия- ния уже упорядочен. + +![](ru/docs/assets/media/image634.jpeg) + +> **Рис. 11.11.** Этапы сортировки слиянием. Шаг 1 +> +> ![](ru/docs/assets/media/image636.jpeg) + +![](ru/docs/assets/media/image638.jpeg)![](ru/docs/assets/media/image640.jpeg) + +> **Рис. 11.11.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image642.jpeg) + +![](ru/docs/assets/media/image644.jpeg)![](ru/docs/assets/media/image646.jpeg) + +> **Рис. 11.11.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image648.jpeg) + +![](ru/docs/assets/media/image650.jpeg)![](ru/docs/assets/media/image652.jpeg) + +> **Рис. 11.11.** *Окончание*. Шаги 8--10 +> +> Можно заметить, что порядок рекурсии в сортировке слиянием совпадает с порядком обхода в глубину двоичного дерева. + +- **Обход в глубину**: сначала рекурсивный обход левого поддерева, затем правого поддерева и в конце обработка корневого узла. + +- **Сортировка слиянием**: сначала рекурсивное разделение левого под- массива, затем правого подмассива и в конце обработка слияния. + +> Ниже приведен код реализации сортировки слиянием. Обратите внимание, что интервал для слияния в массиве 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: разделе- ние создает рекурсивное дерево высотой log *n*, общее количество опера- ций слияния на каждом уровне составляет *n*, поэтому общая временная сложность равна *O*(*n* log *n*). + +- **Пространственная сложность** *O*(*n*), **не на месте**: глубина рекурсии равна log *n*. Используется кадр стека размером *O*(log *n*). Операция слия- ния требует использования вспомогательного массива, что занимает до- полнительное пространство *O*(*n*). + +- **Стабильная сортировка**: в процессе слияния порядок равных элемен- тов сохраняется. + +### Сортировка связного списка + +> Для связного списка сортировка слиянием имеет значительное преимущество перед другими алгоритмами, **позволяя оптимизировать пространствен- ную сложность задачи сортировки связного списка до** *O*(1). + +- **Этап разделения**: для выполнения разделения связного списка можно использовать итерацию вместо рекурсии, что позволяет избежать ис- пользования стекового кадра рекурсии. + +- **Этап слияния**: в связном списке операции добавления и удаления уз- лов требуют лишь изменения ссылок (указателей), поэтому на этапе слияния (объединение двух коротких упорядоченных списков в один длинный упорядоченный список) нет необходимости создавать допол- нительный список. + +> Конкретные детали реализации довольно сложны, заинтересованные чита- тели могут обратиться к соответствующей литературе для более глубокого из- учения этого приема. + +#### пирамидальная сортировка + +> *Пирамидальная сортировка* -- это эффективный алгоритм сортировки, осно- ванный на структуре данных «куча». Для реализации пирамидальной сорти- ровки можно использовать уже изученные операции построения кучи и из- влечения элемента из кучи. + +1. Ввод массива и построение минимальной кучи, при этом минимальный элемент находится на вершине кучи. + +2. Постоянное выполнение операции извлечения из кучи. Последователь- ная запись извлеченных элементов позволяет получить последователь- ность, отсортированную по возрастанию. + +> Хотя этот метод и работает, он требует использования дополнительного массива для хранения извлеченных элементов, что неэффективно с точки зрения использования пространства. На практике обычно используется более элегантный способ реализации. + +### Алгоритм + +> Пусть дан массив длины *n*, процесс пирамидальной сортировки выглядит сле- дующим образом (см. рис. 11.12): + +1) ввод массива и построение максимальной кучи. После завершения мак- симальный элемент находится на вершине кучи; + +2) обмен вершины кучи (первого элемента) с элементом в основании кучи (последним элементом). После завершения обмена длина кучи уменьшается на 1, а количество отсортированных элементов увеличи- вается на 1; + +3) начать с вершины кучи и выполнить операцию упорядочивания сверху вниз. После завершения упорядочивания свойства кучи восстанавли- ваются; + +4) циклическое выполнение шагов 2 и 3. После *n* -- 1 итераций сортировка массива будет завершена. + +![](ru/docs/assets/media/image654.jpeg) + +> **Рис. 11.12.** Этапы пирамидальной сортировки. Шаг 1 +> +> ![](ru/docs/assets/media/image657.jpeg) + +![](ru/docs/assets/media/image661.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image665.jpeg) + +![](ru/docs/assets/media/image669.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 4--5 +> +> ![](ru/docs/assets/media/image672.jpeg) + +![](ru/docs/assets/media/image674.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 6--7 +> +> ![](ru/docs/assets/media/image677.jpeg) + +![](ru/docs/assets/media/image680.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 8--9 +> +> ![](ru/docs/assets/media/image683.jpeg) + +![](ru/docs/assets/media/image686.jpeg) + +> **Рис. 11.12.** *Продолжение*. Шаг 10--11 +> +> ![](ru/docs/assets/media/image688.jpeg) +> +> **Рис. 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 +> +> 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) + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* log *n*), **неадаптивная сортировка**: опера- ция построения кучи занимает время *O*(*n*). Временная сложность извле- чения максимального элемента из кучи составляет *O*(log *n*), всего *n* -- 1 итераций. + +- **Пространственная сложность** *O*(1), **сортировка на месте**: несколько указателей используют пространство *O*(1). Обмен элементов и операции упорядочивания выполняются на исходном массиве. + +- **Нестабильная сортировка**: при обмене элементов на вершине и внизу кучи относительное положение равных элементов может измениться. + +#### Блочная сортировка + +> Ранее рассмотренные алгоритмы сортировки относятся к алгоритмам сорти- ровки на основе сравнения, которые осуществляют сортировку путем срав- нения величин элементов. Временная сложность таких алгоритмов не может превысить *O*(*n* log *n*). Далее рассмотрим алгоритмы сортировки без сравнения, временная сложность которых может достигать линейного порядка. +> +> *Блочная сортировка* является типичным применением стратегии «разделяй и властвуй». Она создает набор упорядоченных по величине блоков, где каж- дый блок соответствует определенному диапазону данных, и равномерно рас- пределяет элементы по этим блокам. Затем сортировка выполняется отдель- но внутри каждого блока, после чего отсортированные данные объединяются в соответствии с порядком блоков. + +### Алгоритм + +> Пусть дан массив длиной *n*, элементы которого являются числами с плаваю- щей запятой в диапазоне \[0, 1). Процесс блочной сортировки выглядит следу- ющим образом (см. рис. 11.13): + +1) инициализация *k* блоков, распределение *n* элементов по *k* блокам; + +2) выполнение сортировки отдельно для каждого блока (здесь использует- ся встроенная функция сортировки языка программирования); + +3) объединение результатов в порядке от меньшего блока к большему. + + + +8. Блочная сортировка ❖ **325** + +> ![](ru/docs/assets/media/image690.jpeg)Массив для сортировки **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 + +### Характеристики алгоритма + +> Блочная сортировка подходит для обработки очень больших объемов дан- ных. Например, если входные данные содержат 1 млн элементов и из-за +> +> ограничений по памяти система не может загрузить все данные сразу, то можно разделить данные на 1000 блоков, затем отсортировать отдельно каждый блок и в конце объединить результаты. + +- **Временная сложность** *O*(*n* + *k*): при условии равномерного распределе- ния элементов по блокам количество элементов в каждом блоке равно *n*/*k*. Если сортировка одного блока занимает время *O*(*n*/*k* log *n*/*k*), то сорти- ровка всех блоков занимает время *O*(*n* log *n*/*k*). Когда количество блоков *k* достаточно велико, временная сложность стремится к *O*(*n*). При объеди- нении результатов необходимо обойти все блоки и элементы, что зани- мает время *O*(*n* + *k*). В худшем случае все данные распределяются в один блок, и сортировка этого блока занимает время *O*(*n*2). + +- **Пространственная сложность** *O*(*n* + *k*), **не на месте**: требуется допол- нительное пространство для *k* блоков и всех *n* элементов. + +- Стабильность блочной сортировки зависит от стабильности алгоритма сортировки элементов внутри блоков. + +### Реализация равномерного распределения + +> Время выполнения блочной сортировки теоретически может достигать *O*(*n*). **Ключевым моментом здесь является равномерное распределение эле- ментов по блокам**, так как в реальных данных распределение часто нерав- номерное. Например, мы хотим распределить все товары на маркетплейсе по ценовым диапазонам в 10 блоков, но цены товаров распределены неравно- мерно: очень много товаров дешевле 100 руб. и очень мало дороже 1000 руб. Если разделить ценовой диапазон на 10 равных частей, количество товаров в каждом блоке будет значительно различаться. +> +> Для достижения равномерного распределения можно сначала установить приблизительную границу и грубо распределить данные по 3 блокам. **После этого блоки с большим количеством товаров можно разделить еще на 3 блока**, **пока количество элементов в каждом блоке не станет примерно одинаковым**. +> +> Этот метод, по сути, создает рекурсивное дерево, цель которого -- сделать значения в листовых узлах как можно более равномерными, см. рис. 11.14. Конечно, не обязательно каждый раз делить данные на 3 блока, конкрет- ный способ деления можно выбирать гибко в зависимости от особенностей данных. +> +> Если заранее известна вероятность распределения цен товаров, **можно установить границы цен для каждого блока на основе этого распреде- ления**. Стоит отметить, что распределение данных не обязательно подсчиты- вать точно, можно использовать вероятностную модель для приближенного определения. +> +> Предположим, что цены товаров подчиняются нормальному распределе- нию, таким образом, можно разумно установить ценовые диапазоны и равно- мерно распределить товары по блокам, как показано на рис. 11.15. +> +> ![](ru/docs/assets/media/image692.jpeg) +> +> **Рис. 11.14.** Рекурсивное деление блоков + +![](ru/docs/assets/media/image694.jpeg) + +> **Рис. 11.15.** Деление блоков на основе вероятностного распределения + +#### Сортировка подсчетом + +> *Сортировка подсчетом* реализует сортировку путем подсчета количества эле- ментов и обычно применяется к массивам целых чисел. + +### Простая реализация + +> Рассмотрим простой пример. Пусть дан массив nums длиной *n*, элементы кото- рого -- неотрицательные целые числа. Процесс сортировки подсчетом выгля- дит следующим образом (см. рис. 11.16): + +1) обойти массив, найти максимальное число, обозначить его как *m*, затем создать вспомогательный массив counter длиной *m* + 1; + +2) **с помощью** counter **подсчитать количество вхождений каждого числа в** nums, где counter\[num\] соответствует количеству вхождений числа num. Метод подсчета прост: нужно обойти nums (пусть текущее число -- num), и на каждой итерации увеличивать counter\[num\] на 1; + +3) **так как индексы в** counter **естественно упорядочены**, **значит все чис- ла уже отсортированы**. Далее обходим counter и заполняем nums в по- рядке возрастания количества вхождений каждого числа. + +> ![](ru/docs/assets/media/image697.jpeg)Массив для сортировки **nums** +> +> Индекс **(число)** +> +> Массив счетчиков +> +> **counter** +> +> Результирующий массив **nums** +> +> Обход **counter**, заполнение **nums** +> +> [в]{.smallcaps} соответствии с количеством вхождений. Элемент **counter**\[**num**\] содержит количество вхождений **num** +> +> Обход **nums** и подсчет количества вхождений каждого числа. +> +> Элемент **counter**\[**num**\] содержит количество вхождений **num** +> +> **Рис. 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 + +### Полная реализация + +> Внимательный читатель мог заметить, что **если входные данные** -- **объекты**, **то шаг 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 демонстрируется полный процесс сортировки подсчетом. + +![](ru/docs/assets/media/image700.jpeg) + +> **Рис. 11.17.** Этапы сортировки подсчетом. Шаг 1 +> +> ![](ru/docs/assets/media/image702.jpeg) + +![](ru/docs/assets/media/image704.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 2--3 +> +> ![](ru/docs/assets/media/image706.jpeg) + +![](ru/docs/assets/media/image708.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 4--5 +> +> ![](ru/docs/assets/media/image710.jpeg) + +![](ru/docs/assets/media/image712.jpeg) + +> **Рис. 11.17.** *Продолжение*. Шаги 6--7 +> +> ![](ru/docs/assets/media/image714.jpeg) +> +> **Рис. 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\] + +### Характеристики алгоритма + +- **Временная сложность** *O*(*n* + *m*), **неадаптивная сортировка**: включает обход nums и counter, оба обхода выполняются за линейное время. Обычно *n* ≫ *m*, временная сложность стремится к *O*(*n*). + +- **Пространственная сложность** *O*(*n* + *m*), **не на месте**: используются вспомогательные массивы res и counter длиной *n* и *m* соответственно. + +- **Стабильная сортировка**: поскольку элементы добавляются в res от конца к началу, обратный обход nums позволяет избежать изменения от- носительного положения равных элементов, обеспечивая стабильность сортировки. На самом деле прямой обход nums также дает правильный результат, но он уже не будет стабильным. + +### Ограничения + +> На этом этапе может показаться, что сортировка подсчетом весьма изящна, так как позволяет эффективно сортировать, просто подсчитывая количество. Однако условия для применения сортировки подсчетом довольно строгие. +> +> **Сортировка подсчетом применима только к неотрицательным целым числам**. Если требуется использовать ее для других типов данных, необходи- мо убедиться, что их можно преобразовать в неотрицательные целые числа, не изменяя относительное положение элементов. Например, для массива це- лых чисел с отрицательными значениями можно сначала добавить ко всем числам константу, чтобы все числа стали положительными, а после сортиров- ки вернуть их к первоначальным значениям. +> +> **Сортировка подсчетом подходит для случаев*,* когда объем данных велик**, **а диапазон данных мал**. Например, в приведенном выше примере *m* не должно быть слишком велико, иначе потребуется слишком много памяти. А когда *n* ≪ *m*, сортировка подсчетом использует время *O*(*m*), что может быть медленнее, чем алгоритмы сортировки с временной сложностью *O*(*n* log *n*). + +#### поразрядная сортировка + +> В предыдущем разделе была рассмотрена сортировка подсчетом, которая хо- рошо подходит для случаев, когда объем данных *n* велик, а диапазон данных *m* мал. Предположим, необходимо отсортировать *n* = 106 номеров студентов, где номер -- это восьмизначное число, т. е. диапазон данных *m* = 108 очень велик. Использование сортировки подсчетом потребует выделения большого объема памяти, тогда как поразрядная сортировка позволяет избежать этой проблемы. *Поразрядная сортировка* основывается на той же идее, что и сортировка подсчетом, и также реализуется путем подсчета количества. Но поразрядная сортировка использует прогрессивные отношения между разрядами чисел, +> +> выполняя сортировку по каждому разряду. + +### Алгоритм + +> Возьмем в качестве примера данные о номерах студентов. Предположим, что наименьший разряд -- это 1-й разряд, а наибольший -- 8-й разряд. Процесс по- разрядной сортировки выглядит следующим образом (см. рис. 11.18): + +10. Поразрядная сортировка ❖ **335** + + 1) инициализация разряда *k* = 1; + + 2) выполнение сортировки подсчетом по *k*-му разряду номеров студентов. После завершения данные будут отсортированы по *k*-му разряду в по- рядке возрастания; + + 3) увеличение *k* на 1 и возврат на шаг 2. Продолжение итераций до завер- шения сортировки по всем разрядам. + +![](ru/docs/assets/media/image716.jpeg) + +> **Рис. 11.18.** Процесс алгоритма сортировки по разрядам +> +> Теперь проанализируем код реализации. Пусть дано число *x* в *d*-ричной си- стеме исчисления. Чтобы получить его *k*-й разряд *xk*, можно использовать сле- дующую формулу: + +Ι 1 + +> ***k*** Λ *d **k*** −1 Υ +> +> где ⌊*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 +> +> 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 + +### Характеристики алгоритма + +11. Резюме ❖ **337** + +> По сравнению с сортировкой подсчетом поразрядная сортировка подходит для случаев с большим диапазоном чисел, **но при условии**, **что данные мож- но представить в формате фиксированной разрядности**, **и разрядность не должна быть слишком большой**. Например, числа с плавающей запятой не подходят для поразрядной сортировки, поскольку их разрядность *k* слиш- ком велика, что может привести к временной сложности *O*(*nk*) ≫ *O*(*n*2). + +- **Временная сложность** *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. резюме** + +##### Ключевые моменты + +- Сортировка пузырьком реализует сортировку путем обмена соседних элементов. Добавив флаг для досрочного выхода из цикла, можно оп- тимизировать лучшую временную сложность пузырьковой сортиров- ки до *O*(*n*). + +- Сортировка вставками в каждом раунде вставляет элемент из неот- сортированного диапазона в правильное место в отсортированном диапазоне. Хотя временная сложность этой сортировки составля- ет *O*(*n*2), благодаря относительно малому количеству элементарных операций она хорошо подходит для задач сортировки небольших объемов данных. + +- Быстрая сортировка основана на операции разделения с использова- нием опорного элемента. При разделении возможна ситуация, когда каждый раз выбирается наихудший опорный элемент, что приводит к ухудшению временной сложности до *O*(*n*2). Введение медианного или случайного опорного элемента может снизить вероятность такого ухудшения. Метод хвостовой рекурсии может эффективно уменьшить глубину рекурсии и оптимизировать пространственную сложность до *O*(log *n*). + +- Сортировка слиянием включает два этапа -- разделение и слияние -- и является типичным представителем стратегии «разделяй и властвуй». В сортировке слиянием для сортировки массива требуется создание + +> вспомогательного массива, поэтому пространственная сложность со- ставляет *O*(*n*). Однако для сортировки связного списка пространствен- ную сложность можно оптимизировать до *O*(1). + +- Блочная сортировка включает три этапа: распределение данных по блокам, сортировку внутри блоков и объединение результатов. Она также демонстрирует стратегию «разделяй и властвуй» и подходит для случаев с большими объемами данных. Ключ к эффективной блоч- ной сортировке заключается в равномерном распределении данных по блокам. + +- Сортировка подсчетом является частным случаем блочной сортировки, она реализует сортировку путем подсчета количества вхождений дан- ных. Сортировка подсчетом подходит для случаев с большим объемом данных, но ограниченным диапазоном и требует, чтобы данные могли быть преобразованы в положительные целые числа. + +- Поразрядная сортировка реализует сортировку данных путем последо- вательной сортировки по разрядам. Для этого требуется, чтобы данные можно было представить в виде чисел фиксированной разрядности. + +- В целом мы стремимся найти алгоритм сортировки, обладающий такими преимуществами, как высокая эффективность, стабильность, выполне- ние на месте и адаптивность. Однако, как и в случае с другими структу- рами данных и алгоритмами, не существует алгоритма сортировки, ко- торый одновременно удовлетворял бы всем этим условиям. На практике необходимо выбирать подходящий алгоритм сортировки в зависимости от характеристик данных. + +- На рис. 11.19 приведено сравнение таких характеристик основных ал- горитмов сортировки, как эффективность, стабильность, выполнение на месте и адаптивность. + ++-------------------------------------+---------------------------+-------------------------------------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > **Алгоритм сортировки** | > **Временная сложность** | > **Простран- ственная сложность** | > **Стабиль- ность** | > **Местность** | > **Адаптив- ность** | > **Основан- ность на сравнении** | +| | +--------------+---------------+------------+ | | | | | +| | | > **Лучшая** | > **Средняя** | **Худшая** | | | | | | ++=====================================+===========================+:============:+===============+===========:+:==================================:+======================+:===============:+======================+:=================================:+ +| > Сортировка обходом O(n2) | > Выбором | > O(n2) | > O(n2) | > O(n2) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Пузырьком | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Вставками | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| > Сортировка разделением O(n log n) | > Быстрая | > O(n log n) | > O(n log n) | > O(n2) | > O(log n) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Слиянием | > O(n log n) | > O(n log n) | O(n log n) | > O(n) | > Стабильный | > Не на месте | > Неадаптив- ный | > Сравнение | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Пирами- дальная | > O(n log n) | > O(n log n) | O(n log n) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| > Линейная со- ртировка O(n) | > Блочная | > O(n + k) | > O(n + k) | > O(n2) | > O(n + k) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Подсчетом | > O(n + m) | > O(n + m) | O(n + m) | > O(n + m) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | +| +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ +| | > Поразряд- ная | > O(n k) | > O(n k) | > O(n k) | > O(n + b) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | ++-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ + +> 11.11. Резюме ❖ **339** +> +> Плохая Средняя Хорошая + +- **\ + n** -- размер данных + +- В блочной сортировке **k** -- количество блоков + +- В сортировке подсчетом **m** -- диапазон данных + +- В поразрядной сортировке **k** -- максимальное количество разрядов, **b** -- основание системы счисления данных + +> **Рис. 11.19.** Сравнение алгоритмов сортировки + +##### Вопросы и ответы + +> **Вопрос**. В каких случаях необходима стабильность алгоритма сортировки? +> +> **Ответ**. В реальной жизни может возникнуть необходимость сортировки объектов по какому-либо атрибуту. Например, у студентов есть два атрибута: имя и рост. Мы хотим осуществить многоуровневую сортировку: сначала по имени, получив (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*(*n*2)? Как справиться с таким вырождением? +> +> **Ответ**. Да. В этом случае можно рассмотреть возможность разделения массива на три части с использованием стража: меньше, равно и больше опорного элемента. Рекурсия продолжается только для частей, меньших и больших опорного элемента. При таком подходе массив с одинаковыми +> +> 11.11. Резюме ❖ **341** +> +> элементами будет отсортирован за одну итерацию разделения с использо- ванием стража. +> +> **Вопрос**. Почему временная сложность сортировки подсчетом в худшем слу- чае составляет *O*(*n*2)? +> +> **Ответ**. В худшем случае все элементы попадут в одну корзину. Если для со- ртировки этих элементов используется алгоритм с временной сложностью *O*(*n*2), то общая временная сложность составит *O*(*n*2). +> +> Глава 12 + +# Разделяй и властвуй + +![](ru/docs/assets/media/image717.jpeg){width="3.5416655730533684in" height="4.583333333333333in"} + +#### Стратегия «разделяй и властвуй» + +> «*Разделяй и властвуй*» -- это важная и распространенная стратегия в алгорит- мах. Обычно она реализуется с помощью рекурсии и включает два этапа: раз- деление и объединение. + +- **Разделение (этап разбиения)**: рекурсивное разбиение исходной зада- чи на две или более подзадачи до тех пор, пока не будет достигнута наи- меньшая подзадача. + +- **Объединение (этап слияния)**: начиная с решения наименьших подза- дач, снизу вверх объединяются решения всех других подзадач, чтобы по- строить решение исходной задачи. + +> Сортировка слиянием является типичным примером применения страте- гии «разделяй и властвуй» (см. рис. 12.1). + +- **Разделение**: рекурсивное разбиение исходного массива (исходной за- дачи) на два подмассива (подзадачи) до тех пор, пока в подмассивах не останется по одному элементу (наименьшая подзадача). + +- **Объединение**: снизу вверх объединяются упорядоченные подмассивы (решения подзадач), чтобы получить упорядоченный исходный массив (решение исходной задачи). + +![](ru/docs/assets/media/image719.jpeg) + +> **Рис. 12.1.** Стратегия «разделяй и властвуй» в сортировке слиянием + +1. **Определение задачи для метода** + +> **«разделяй и властвуй»** +> +> Чтобы определить, подходит ли задача для решения методом «разделяй и вла- ствуй», можно использовать следующие критерии: + +1) **задачу можно разбить**: исходную задачу можно разбить на более мел- кие, аналогичные подзадачи, которые можно рекурсивно разделить ана- логичным образом; + +2) **подзадачи независимы**: подзадачи не пересекаются, не зависят друг от друга и могут быть решены независимо; + +3) **решения подзадач можно объединить**: решение исходной задачи по- лучается путем объединения решений подзадач. + +> Очевидно, что сортировка слиянием соответствует этим трем критериям. + +- **Задачу можно разбить**: рекурсивное разбиение массива (исходной за- дачи) на два подмассива (подзадачи). + +- **Подзадачи независимы**: каждый подмассив можно отсортировать не- зависимо (подзадачи можно решить независимо). + +- **Решения подзадач можно объединить**: два упорядоченных подмасси- ва (решения подзадач) можно объединить в один упорядоченный массив (решение исходной задачи). + +### Повышение эффективности с помощью стратегии «разделяй и властвуй» + +> **Стратегия «разделяй и властвуй» позволяет не только эффективно ре- шать алгоритмические задачи**, **но и повышать эффективность алгорит- мов**. Алгоритмы быстрой сортировки, сортировки слиянием и пирамидальной сортировки быстрее, чем сортировка выбором, пузырьком и вставками, имен- но благодаря применению стратегии «разделяй и властвуй». +> +> Возникает вопрос: **почему метод «разделяй и властвуй» повышает эф- фективность алгоритма**, **в чем его основная логика**? Иными словами, поче- му разбиение большой задачи на несколько подзадач, решение этих подзадач и объединение их решений в решение исходной задачи оказывается более эф- фективным, чем непосредственное решение исходной задачи? Этот вопрос мож- но обсудить с точки зрения количества операций и параллельных вычислений. + +##### Оптимизация количества операций + +> Возьмем, к примеру, сортировку пузырьком, которая требует времени *O*(*n*2) для обработки массива длиной *n*. Предположим, что мы разделили массив на два подмассива, как показано на рис. 12.2. Тогда разбиение потребует времени *O*(*n*), сортировка каждого подмассива -- *O*((*n*/2)2), а объединение двух подмас- сивов -- *O*(*n*). Общая временная составит: + +( ( *[n]{.underline}*  2   ( *n*^2^   + +*O* ∴ *n+* ∴   ξ 2 + *n*   = *O* ∴ + 2*n*  . + +  + +  + +>     2   +> +> Далее, решим следующее неравенство, в котором левая и правая части пред- ставляют общее количество операций до и после разбиения соответственно: +> +> *n*^2^ \> *n*2 + 2*n,* + +2 + +> *n*^2^ − *n*2 − 2*n \>* 0, + +2 + +> *n*(*n* − *4*) \> 0. +> +> ![](ru/docs/assets/media/image721.jpeg) +> +> **Рис. 12.2.** Сортировка пузырьком до и после разбиения массива +> +> **Это означает**, **что при** *n* \> 4 **количество операций после разбиения мень- ше**, **и эффективность сортировки должна быть выше**. Обратите внимание, что временная сложность после разбиения остается квадратичной *O*(*n*2), но по- стоянный коэффициент в сложности уменьшается. +> +> **Если продолжить разбиение подмассивов пополам**, **пока в них не оста- нется по одному элементу**, то получится сортировка слиянием, временная сложность которой составляет *O*(*n* log *n*). +> +> А что, **если мы установим несколько дополнительных точек разделе- ния** и равномерно разделим исходный массив на *k* подмассивов? Эта ситуация очень похожа на блочную сортировку, которая хорошо подходит для сортиров- ки очень больших объемов данных, и теоретически ее временная сложность может достигать *O*(*n* + *k*). + +##### Оптимизация параллельных вычислений + +> Известно, что подзадачи, созданные методом «разделяй и властвуй», незави- симы друг от друга, **поэтому их обычно можно решать параллельно**. Таким образом, этот метод не только снижает временную сложность алгоритма, **но и способствует параллельной оптимизации операционной системы**. +> +> Параллельная оптимизация особенно эффективна в многоядерной или многопроцессорной среде, поскольку система может одновременно обраба- тывать несколько подзадач, более полно используя вычислительные ресурсы, что значительно сокращает общее время выполнения. +> +> Например, в блочной сортировке, изображенной на рис. 12.3, огромный объем данных равномерно распределяется по блокам. Задачи сортировки всех блоков можно распределить по вычислительным единицам, а затем объеди- нить результаты. +> +> ![](ru/docs/assets/media/image723.jpeg) +> +> **Рис. 12.3.** Параллельные вычисления в блочной сортировке + +### Типичные сценарии применения стратегии «разделяй и властвуй» + +> С одной стороны, стратегию «разделяй и властвуй» можно использовать для решения многих классических алгоритмических задач. + +- **Поиск ближайшей пары точек**: этот алгоритм сначала делит множе- ство точек на две части, затем находит ближайшую пару точек в каж- дой части, а затем находит ближайшую пару точек, охватывающую обе части. + +- **Умножение больших чисел**: например, алгоритм Карацубы, который разлагает умножение больших чисел на несколько операций умножения и сложения меньших чисел. + +- **Умножение матриц**: например, алгоритм Штрассена, который разлага- ет умножение больших матриц на несколько операций умножения и сло- жения матриц меньшего размера. + +- **Задача о Ханойских башнях**: эту задачу можно решить с помощью рекурсии, что является типичным применением стратегии «разделяй и властвуй». + +- **Задача о количестве инверсий**: если в последовательности предыду- щее число больше последующего, то эти два числа образуют инверсию. Задачу о количестве инверсий можно решить с помощью подхода «раз- деляй и властвуй» и сортировки слиянием. + +> С другой стороны, стратегия «разделяй и властвуй» широко применяется в разработке алгоритмов и структур данных. + +- **Двоичный поиск**: такой поиск делит отсортированный массив на две части по индексу среднего элемента. Затем, в зависимости от + +> результата сравнения целевого значения со средним элементом, ре- шает, какую половину исключить, и выполняет ту же операцию на оставшейся части. + +- **Сортировка слиянием**: уже была рассмотрена в начале этого раздела, не будем еще раз повторяться. + +- **Быстрая сортировка**: эта сортировка выбирает опорное значение, затем делит массив на два подмассива, элементы одного из которых меньше опорного значения, а элементы другого -- больше. Затем выполняет ту же операцию с обеими частями, пока в подмассиве не останется один элемент. + +- **Блочная сортировка**: основная идея этой сортировки заключается в распределении данных по нескольким блокам и сортировке элемен- тов в каждом из них. Затем происходит последовательное извлечение элементов из каждого блока для построения отсортированного массива. + +- **Деревья**: например, двоичные деревья поиска, АВЛ-дерево, красно-чер- ное дерево, B-дерево, дерево B+ и т. д. Операции поиска, вставки и уда- ления в них можно рассматривать как применение стратегии «разделяй и властвуй». + +- **Кучи**: куча -- это особый вид полного двоичного дерева, и такие опера- ции, как вставка, удаление и упорядочивание, фактически подразумева- ют использование метода «разделяй и властвуй». + +- **Хеш-таблицы**: хотя хеш-таблицы напрямую не применяют подход + +> «разделяй и властвуй», некоторые решения для разрешения коллизий в хешировании косвенно используют эту стратегию. Например, длинные цепочки в методе цепной адресации преобразуются в красно-черные де- ревья для повышения эффективности поиска. +> +> Можно сказать, **что стратегия «разделяй и властвуй»** -- **это своего рода** +> +> **«скрытая» алгоритмическая идея**, присутствующая в различных алгорит- мах и структурах данных. + +#### применение стратегии «разделяй + +> **и властвуй» для поиска** +> +> Мы уже знаем, что алгоритмы поиска делятся на две большие категории. + +- **Полный перебор**: реализуется путем обхода структуры данных, вре- менная сложность составляет *O*(*n*). + +- **Адаптивный поиск**: использует особую организацию данных или апри- орную информацию, временная сложность может достигать *O*(log *n*) или даже *O*(1). + +> На практике **алгоритмы поиска с временной сложностью** *O*(log *n*) **обыч- но реализуются на основе стратегии «разделяй и властвуй»**, например двоичный поиск и деревья. + +- **Двоичный поиск** на каждом шаге разбивает задачу (поиск целевого элемента в массиве) на более мелкую задачу (поиск целевого элемента в половине массива). Этот процесс продолжается до тех пор, пока массив не станет пустым или не будет найден целевой элемент. + + + +- **Деревья** являются представителями стратегии «разделяй и властвуй». В структурах данных, таких как двоичное дерево поиска, АВЛ-дерево, куча и др., временная сложность различных операций составляет *O*(log *n*). + +> Стратегия «разделяй и властвуй» для двоичного поиска выглядит следую- щим образом. + +- **Задачу можно разбить**: двоичный поиск рекурсивно разбивает ис- ходную задачу (поиск в массиве) на подзадачи (поиск в половине массива), что достигается сравнением среднего элемента с целевым элементом. + +- **Подзадачи независимы**: в двоичном поиске на каждом этапе об- рабатывается только одна подзадача, которая не зависит от других подзадач. + +- **Решения подзадач не требуют объединения**: двоичный поиск на- правлен на поиск конкретного элемента, поэтому объединять решения подзадач не требуется. Когда подзадача решена, исходная задача также считается решенной. + +> Стратегия «разделяй и властвуй» повышает эффективность поиска, по- скольку при грубом поиске на каждом этапе можно исключить только один вариант, тогда как при поиске «разделяй и властвуй» на каждом этапе можно исключить половину вариантов. + +##### Реализация двоичного поиска на основе стратегии «разделяй и властвуй» + +> В предыдущих главах двоичный поиск был реализован на основе итераций. Теперь мы реализуем его на основе принципа «разделяй и властвуй» (ре- курсии). +> +> Для применения стратегии «разделяй и властвуй» обозначим подзадачу для поискового интервала \[*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 в массиве. +> +> ![](ru/docs/assets/media/image725.jpeg) +> +> **Рис. 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) + +#### задача построения двоичного дерева + +![](ru/docs/assets/media/image727.jpeg) + +> **Рис. 12.5.** Пример данных для построения двоичного дерева + +##### Проверка критериев стратегии «разделяй и властвуй» + +> Исходная задача, заключающаяся в построении двоичного дерева из обходов +> +> preorder и inorder, является типичной задачей типа «разделяй и властвуй». + +- **Задачу можно разбить**: с точки зрения стратегии «разделяй и вла- ствуй» исходную задачу можно разделить на две подзадачи. Построение левого поддерева и построение правого поддерева плюс один шаг: ини- циализация корневого узла. Для каждого поддерева (подзадачи) можно повторно использовать вышеуказанный метод разделения и разделить его на более мелкие поддеревья (подзадачи), пока не будет достигнута минимальная подзадача (пустое поддерево). + +- **Подзадачи независимы**: левое и правое поддеревья независимы друг от друга, между ними нет пересечений. При построении левого подде- рева необходимо учитывать только части симметричного и прямого по- рядка обхода, соответствующие левому поддереву. Для правого подде- рева аналогично. + +- **Решения подзадач можно объединить**: как только получены левое и правое поддеревья (решения подзадач), их можно связать с корневым узлом и получить решение исходной задачи. + +##### Разделение поддеревьев + +> Мы определили, что эту задачу можно решить с помощью стратегии «разделяй и властвуй». **Но как именно разделить левое и правое поддеревья с помо- щью прямого** (preorder) **и симметричного** (inorder) **порядков обхода**? +> +> Согласно определению 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\]. + +> ![](ru/docs/assets/media/image729.jpeg)**Первый элемент является корневым узлом** +> +> Прямой обход +> +> preorder +> +> **Поиск индекса корневого узла** +> +> Симметричный обход inorder +> +> Левое поддерево имеет **1** узел + +Прямой обход + +> Правое поддерево имеет **3** узла +> +> **Рис. 12.6.** Разделение поддеревьев в прямом и симметричном обходах + +##### Описание интервалов поддеревьев на основе переменных + +> По вышеописанному методу разделения **мы получили интервалы индек- сов корневого узла**, **левого и правого поддеревьев в** preorder и inorder. Для описания этих интервалов индексов необходимо использовать несколько указателей. + +- Индекс корневого узла текущего дерева в preorder обозначим как *i*. + +- Индекс корневого узла текущего дерева в inorder обозначим как *m*. + +- Интервал индексов текущего дерева в inorder обозначим как \[*l*, *r*\]. + +> С помощью этих переменных можно описать индекс корневого узла в preorder +> +> и интервал индексов поддеревьев в inorder, как показано в табл. 12.1. +> +> Обратите внимание, что значение (*m* -- l) в индексе корневого узла правого поддерева означает количество узлов левого поддерева, рекомендуется разо- брать эту таблицу вместе с рис. 12.7. +> +> **Таблица 12.1.** Индексы корневого узла и поддеревьев в прямом и симметричном обходах + ++--------------------+----------------------------------------+-------------------------------------------------+ +| | > **Индекс корневого узла в** preorder | > **Интервал индексов подде- ревьев в** inorder | ++====================+========================================+=================================================+ +| > Текущее дерево | > i | > \[*l*, *r*\] | ++--------------------+----------------------------------------+-------------------------------------------------+ +| > Левое поддерево | > *i* + 1 | > \[*l*, *m* -- 1\] | ++--------------------+----------------------------------------+-------------------------------------------------+ +| > Правое поддерево | > *i* + 1 + (*m* -- l) | > \[*m* + 1, r\] | ++--------------------+----------------------------------------+-------------------------------------------------+ + +![](ru/docs/assets/media/image731.jpeg) + +> **Рис. 12.7.** Представление интервалов индексов корневого узла, левого и правого поддеревьев + +##### Код реализации + +> Для повышения эффективности поиска средней точки *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) +> +> \# Возврат корневого узла. return root +> +> def build_tree(preorder: list\[int\], inorder: list\[int\]) -\> TreeNode \| None: \"\"\" Построение двоичного дерева.\"\"\" +> +> \# Инициализация хеш-таблицы для хранения отображения элементов inorder +> +> [в]{.smallcaps} индексы. +> +> inorder_map = {val: i for i, val in enumerate(inorder)} root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1) return root +> +> На рис. 12.8 демонстрируется рекурсивный процесс построения двоичного дерева, в котором каждый узел создается в процессе спуска, а каждое ребро (ссылка) создается в процессе подъема. + +![](ru/docs/assets/media/image733.jpeg)![](ru/docs/assets/media/image735.jpeg) + +> **Рис. 12.8.** Рекурсивный процесс построения двоичного дерева. Шаги 1--2 +> +> ![](ru/docs/assets/media/image737.jpeg) + +![](ru/docs/assets/media/image739.jpeg)![](ru/docs/assets/media/image741.jpeg) + +> **Рис. 12.8.** *Продолжение*. Шаги 3--5 +> +> ![](ru/docs/assets/media/image743.jpeg) + +![](ru/docs/assets/media/image745.jpeg)![](ru/docs/assets/media/image747.jpeg) + +> **Рис. 12.8.** *Продолжение*. Шаги 6--8 +> +> ![](ru/docs/assets/media/image749.jpeg) +> +> **Рис. 12.8.** *Окончание*. Шаг 9 +> +> Результаты разделения прямого обхода preorder и симметричного обхода in- order в каждом рекурсивном вызове показаны на рис. 12.9. + +![](ru/docs/assets/media/image751.jpeg) + +> **Рис. 12.9.** Результаты разделения в каждом рекурсивном вызове +> +> Пусть количество узлов в дереве равно *n*, инициализация каждого узла (выполнение одного рекурсивного вызова dfs()) занимает время *O*(1). **Сле- довательно**, **общая временная сложность составляет** *O*(*n*). +> +> Хеш-таблица хранит отображение элементов inorder в индексы, про- странственная сложность составляет *O*(*n*). В худшем случае, когда двоичное дерево вырождается в список, глубина рекурсии достигает *n*, что требует *O*(*n*) пространства стека. **Поэтому общая пространственная сложность составляет** *O*(*n*). + +#### задача о ханойских башнях + +> В алгоритмах сортировки слиянием и построения двоичного дерева мы раз- бивали исходную задачу на две подзадачи, каждая из которых имела полови- ну размера исходной задачи. Однако для задачи о Ханойских башнях исполь- зуется другая стратегия разбиения. + +![](ru/docs/assets/media/image753.jpeg) + +> **Рис. 12.10.** Пример задачи о Ханойских башнях +> +> Обозначим задачу о Ханойских башнях с *i* дисками как *f*(*i*). Например, *f*(3) соответствует задаче о перемещении 3 дисков с A на C. + +##### 1. Базовый случай + +> Для случая *f*(1), когда имеется только один диск, можно просто переместить единственный диск с A на C, как показано на рис. 12.11. + +![](ru/docs/assets/media/image755.png) + +![](ru/docs/assets/media/image757.jpeg) + +> **Рис. 12.11.** Решение задачи размера 1 +> +> Для задачи *f*(2), когда имеется два диска, уже требуется соблюдать условие, что меньший диск находится на большем. **Поэтому для выполнения пере- мещения потребуется использовать стержень** B. + +1. Сначала переместить верхний диск с A на B. + +2. Затем переместить большой диск с A на C. + +3. ![](ru/docs/assets/media/image759.jpeg)Переместить маленький диск с B на C. + +> **Рис. 12.12.** Решение задачи размера 2. Шаг 1 +> +> ![](ru/docs/assets/media/image761.jpeg) + +![](ru/docs/assets/media/image763.jpeg)![](ru/docs/assets/media/image765.jpeg) + +> **Рис. 12.12.** *Окончание*. Шаги 2--4 +> +> Процесс решения задачи *f*(2) можно кратко описать следующим образом: **переместить два диска с** A **на** C **с помощью** B. Здесь C называется целевым стержнем, а B -- вспомогательным стержнем. + +##### Разделение на подзадачи + +> Для задачи *f*(3), когда имеется три диска, ситуация становится несколько сложнее. Поскольку решения *f*(1) и *f*(2) уже известны, можно рассмотреть задачу с точ- ки зрения метода «разделяй и властвуй». Можно считать два верхних диска на A единым целым и выполнить шаги, показанные на рис. 12.13. Таким образом, +> +> три диска успешно переместятся с A на C. + +1. Пусть B будет целевым стержнем, а C -- вспомогательным. Переместить два диска с A на B. + +2. Переместить оставшийся диск с A непосредственно на C. + +3. Пусть C будет целевым стержнем, а A -- вспомогательным стержнем. Пе- реместить два диска с B на C. + +![](ru/docs/assets/media/image767.jpeg)![](ru/docs/assets/media/image769.jpeg) + +> **Рис. 12.13.** Решение задачи размера 3. Шаги 1-2 +> +> ![](ru/docs/assets/media/image771.jpeg) + +![](ru/docs/assets/media/image773.jpeg) + +> **Рис. 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) уже известно и требует только одного перемещения. +> +> ![](ru/docs/assets/media/image775.jpeg) +> +> **Рис. 12.14.** Стратегия «разделяй и властвуй» для решения задачи Ханойской башни + +##### Код реализации + +> В коде объявляется рекурсивная функция 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) + +1. Резюме ❖ **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*(2*n*), **а пространственная сложность** -- *O*(*n*). + +![](ru/docs/assets/media/image777.jpeg) + +> **Рис. 12.15.** Рекурсивное дерево задачи Ханойской башни +> +> **12.5. резюме** + +- «Разделяй и властвуй» -- это распространенная стратегия разработки ал- горитмов, включающая два этапа -- разделение (декомпозиция) и объ- единение (синтез) -- и обычно реализуемая с помощью рекурсии. + + + +- Критерии применимости этой стратегии к задаче включают: возмож- ность декомпозиции задачи, независимость подзадач и возможность их объединения. + +- Сортировка слиянием -- это типичное применение стратегии «разделяй и властвуй». Эта сортировка рекурсивно разделяет массив на два под- массива равной длины, пока не останется массив из одного элемента. После чего начинается поэтапное объединение. + +- Введение стратегии «разделяй и властвуй» часто позволяет повысить эффективность алгоритма. С одной стороны, стратегия уменьшает коли- чество операций. С другой стороны, после разделения она способствует оптимизации для параллельного выполнения. + +- Принцип «разделяй и властвуй» не только позволяет решать множество алгоритмических задач, но и широко применяется в проектировании структур данных и алгоритмов, его можно встретить повсюду. + +- Адаптивный поиск более эффективен по сравнению с полным перебо- ром. Алгоритмы поиска со сложностью *O*(log *n*) обычно реализуются на основе стратегии «разделяй и властвуй». + +- Двоичный поиск -- это еще одно типичное применение стратегии «разде- ляй и властвуй», которое не содержит этап объединения решений подзадач. Двоичный поиск можно реализовать с помощью рекурсивного подхода. + +- Задачу построения двоичного дерева можно разделить на построение левого и правого поддеревьев (подзадачи), что достигается путем раз- деления индексов в порядке предварительного и симметричного обхода. + +- Задачу Ханойской башни размера *n* можно разделить на две подзадачи размера *n* -- 1 и одну подзадачу размера 1. После последовательного ре- шения этих трех подзадач исходная задача будет также решена. + +> Глава 13 + +# Поиск с возвратом + +![](ru/docs/assets/media/image778.jpeg){width="3.630207786526684in" height="4.697916666666667in"} + +#### Алгоритмы поиска с возвратом + +> *Алгоритм поиска с возвратом* -- это метод решения задач путем перебора. Его основная идея заключается в том, чтобы, начиная с начального состояния, осуществлять грубый поиск всех возможных решений, фиксируя правильное найденное решение. Процесс поиска продолжается до тех пор, пока не будет найдено решение или не будут исчерпаны все возможные варианты. +> +> Алгоритмы поиска с возвратом обычно используют поиск в глубину для об- хода пространства решений. В разделе «Двоичные деревья» упоминалось, что прямой, симметричный и обратный обходы относятся к поиску в глубину. Да- лее, используя прямой обход, мы реализуем задачу поиска с возвратом, чтобы постепенно понять принцип работы этого алгоритма. +> +> Для решения этой задачи мы выполняем предварительный обход дерева и проверяем, равно ли значение текущего узла 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) +> +> ![](ru/docs/assets/media/image781.jpeg)Поиск в глубину +> +> Прямой порядок обхода Посетить узел в +> +> **Выполнить прямой обход двоичного дерева и записать узлы со значением 7** +> +> **Рис. 13.1.** Поиск узлов в предварительном обходе + +1. **Попытка и возврат** + +> **Алгоритм называется поиском с возвратом, потому что при поиске в пространстве решений он использует стратегию попытки и возврата**. Когда алгоритм сталкивается с состоянием, в котором невозможно продолжать или невозможно получить удовлетворительное решение, он отменяет преды- дущий выбор, возвращается к предыдущему состоянию и пробует другие воз- можные варианты. +> +> В примере 1 посещение каждого узла представляет собой попытку, а пере- ход через листовой узел или возврат к родительскому узлу через return озна- чает возврат. +> +> Стоит отметить, что **откат включает не только возврат функции**. Чтобы объяснить это, мы немного расширим пример 1. +> +> Возьмем за основу код для примера 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, **можно представить попытку и возврат как движение вперед и отмену**, как два противополож- ных действия. +> +> ![](ru/docs/assets/media/image784.jpeg) + +![](ru/docs/assets/media/image786.jpeg)![](ru/docs/assets/media/image788.jpeg) + +> **Рис. 13.2.** Попытка и возврат. Шаги 1--3 +> +> ![](ru/docs/assets/media/image790.jpeg) + +![](ru/docs/assets/media/image792.jpeg)![](ru/docs/assets/media/image794.jpeg) + +> **Рис. 13.2.** *Продолжение*. Шаги 4--6 +> +> ![](ru/docs/assets/media/image796.jpeg) + +![](ru/docs/assets/media/image798.jpeg)![](ru/docs/assets/media/image800.jpeg) + +> **Рис. 13.2.** *Продолжение*. Шаги 7--9 +> +> ![](ru/docs/assets/media/image802.jpeg) + +![](ru/docs/assets/media/image804.jpeg) + +### Обрезка + +> **Рис. 13.2.** *Окончание*. Шаги 10--11 +> +> Сложные задачи поиска с возвратом обычно содержат одно или несколько ограничений, **которые можно использовать для обрезки**. +> +> Для выполнения данного условия **требуется добавить операцию обрезки**: в процессе поиска, если встречается узел со значением 3, следует немедленно вернуться, не продолжая поиск. Код реализации представлен ниже. +> +> \# === 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. + +![](ru/docs/assets/media/image806.jpeg) + +> **Рис. 13.3.** Обрезка в соответствии с заданными условиями + +### Каркас кода + +> Далее мы попытаемся сформировать основной каркас операций «попытка, возврат, обрезка» для повышения универсальности кода. +> +> В следующем каркасе кода state обозначает текущее состояние задачи, а choices -- возможные выборы в текущем состоянии: +> +> def backtrack(state: State, choices: list\[choice\], res: list\[state\]): \"\"\" Каркас алгоритма поиска с возвратом.\"\"\" +> +> \# Проверка, является ли состояние решением. if is_solution(state): +> +> \# Запись решения. record_solution(state, res) +> +> \# Не продолжать поиск. 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: +> +> \# Обрезка: проверка легитимности выбора. 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. +> +> ![](ru/docs/assets/media/image808.jpeg)После записи решения остановить поиск +> +> **Сохранение return** +> +> Возврат после записи решения, не продолжать поиск +> +> **Удаление return** +> +> Не возвращаться после записи решения, продолжить поиск +> +> **Рис. 13.4.** Сравнение процесса поиска с сохранением и удалением return +> +> По сравнению с реализацией на основе предварительного обхода, реали- зация на основе каркаса поиска с возвратом выглядит более громоздкой, но обладает большей универсальностью. На самом деле многие задачи поиска с возвратом можно решить в рамках этого каркаса. Необходимо лишь опре- делить state и choices в соответствии с конкретной задачей и реализовать ме- тоды каркаса. + +### Основные термины + +> Для более четкого понимания алгоритмических задач мы систематизируем значения часто используемых терминов обратного поиска и приведем соот- ветствующие примеры для задачи 3, как показано в табл. 13.1. +> +> **Таблица 13.1.** Основные термины обратного поиска + ++---------------+----------------------------------------+-----------------------------------------+ +| > **Термин** | > **Определение** | > **Пример 3** | ++===============+========================================+=========================================+ +| > Решение | > Ответ, удовлетворяющий определен- | > Все пути от корневого узла до узла 7, | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ным условиям задачи, может быть одно | > удовлетворяющие условиям | ++---------------+----------------------------------------+-----------------------------------------+ +| | > или несколько решений | | ++---------------+----------------------------------------+-----------------------------------------+ +| > Ограничение | > Ограничение на допустимость реше- | > Путь не содержит узлов со значением 3 | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ния, обычно используется для обрезки | | ++---------------+----------------------------------------+-----------------------------------------+ +| > Состояние | > Ситуация задачи в определенный мо- | > Текущий посещенный путь узлов, т. е. | ++---------------+----------------------------------------+-----------------------------------------+ +| | > мент, включая сделанные выборы | > список узлов path | ++---------------+----------------------------------------+-----------------------------------------+ +| > Попытка | > Процесс исследования пространства | > Рекурсивный доступ к левому (право- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > решений на основе доступных вы- | > му) дочернему узлу, добавление узла | ++---------------+----------------------------------------+-----------------------------------------+ +| | > боров, включая выбор, обновление | > в path, проверка значения узла на | ++---------------+----------------------------------------+-----------------------------------------+ +| | > состояния, проверку на решение | > равенство 7 | ++---------------+----------------------------------------+-----------------------------------------+ +| > Возврат | > Отмена предыдущих выборов и воз- | > При переходе через листовой узел, | ++---------------+----------------------------------------+-----------------------------------------+ +| | > врат к предыдущему состоянию при | > завершении посещения узла, встрече | ++---------------+----------------------------------------+-----------------------------------------+ +| | > встрече состояния, не удовлетворяю- | > узла со значением 3 поиск прекраща- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > щего ограничению | > ется, происходит выход из функции | ++---------------+----------------------------------------+-----------------------------------------+ +| > Обрезка | > Метод избегания бессмысленных путей | > При встрече узла со значением 3 по- | ++---------------+----------------------------------------+-----------------------------------------+ +| | > поиска на основе условий и ограниче- | > иск прекращается | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ний задачи, повышающий эффектив- | | ++---------------+----------------------------------------+-----------------------------------------+ +| | > ность поиска | | ++---------------+----------------------------------------+-----------------------------------------+ + +### Преимущества и ограничения + +> Алгоритм поиска с возвратом, по сути, является алгоритмом поиска в глуби- ну, который пытается найти все возможные решения до тех пор, пока не будет найдено решение, удовлетворяющее условиям. Преимущество этого метода заключается в возможности нахождения всех возможных решений, и при раз- умной обрезке он обладает высокой эффективностью. +> +> Однако при решении крупных или сложных задач **эффективность работы алгоритма возврата может оказаться неприемлемой.** + +- **Время**: алгоритм поиска с возвратом обычно требует перебора всех воз- можных состояний пространства, и временная сложность может дости- гать экспоненциального или факториального порядка. + +- **Пространство**: в рекурсивных вызовах необходимо сохранять текущее состояние (например, путь, вспомогательные переменные для обрезки и т. д.), и при большой глубине потребность в пространстве может стать значительной. + +> Тем не менее **алгоритм поиска с возвратом по-прежнему является наи- лучшим решением для некоторых задач поиска и задач с ограничени- ями**. В этих задачах невозможно предсказать, какие выборы могут привести к эффективному решению, поэтому необходимо перебирать все возможные варианты. В таких случаях ключевым моментом является оптимизация эф- фективности, и существуют две распространенные стратегии. + +- **Обрезка**: избегание поиска по путям, которые заведомо не приведут к решению, что позволяет сэкономить время и пространство. + +- **Эвристический поиск**: введение некоторых стратегий или оценочных значений в процессе поиска, чтобы в первую очередь исследовать пути, которые с наибольшей вероятностью могут привести к эффективному решению. + +### Типичные задачи поиска с возвратом + +> Алгоритм поиска с возвратом можно использовать для решения множества за- дач поиска, задач с ограничениями и задач комбинаторной оптимизации. +> +> **Задачи поиска**: цель этих задач -- найти решение, удовлетворяющее опре- деленным условиям. + +- Задача о перестановках: дано множество, требуется найти все возмож- ные перестановки элементов. + +- Задача о сумме подмножеств: дано множество и целевая сумма, необхо- димо найти все подмножества, сумма которых равна целевой. + +- Задача о Ханойских башнях: даны три стержня и несколько дисков раз- ного размера, требуется переместить все диски с одного стержня на дру- гой, перемещая по одному диску за раз, при этом нельзя класть больший диск на меньший. + +> **Задачи с ограничениями**: цель этих задач -- найти решение, удовлетворя- ющее всем ограничениям. + +- Задача об *n* ферзях: разместить *n* ферзей на шахматной доске размером + +> *n*×*n* так, чтобы они не рубили друг друга. + +- Судоку: заполнить числами от 1 до 9 сетку 9×9 так, чтобы в каждой стро- ке, каждом столбце и каждой подгруппе 3×3 числа не повторялись. + +- Задача о раскраске графа: дан неориентированный граф, требуется рас- красить его вершины минимальным числом цветов так, чтобы соседние вершины имели разные цвета. + +> **Задачи комбинаторной оптимизации**: цель этих задач -- найти опти- мальное решение в комбинаторном пространстве, удовлетворяющее опреде- ленным условиям. + +- Задача о рюкзаке 0-1: дано множество предметов и рюкзак. Каждый предмет имеет определенную ценность и вес, требуется выбрать пред- меты так, чтобы их общая ценность была максимальной при ограничен- ной вместимости рюкзака. + +- Задача коммивояжера: начиная с некоторой вершины графа, требуется посетить все остальные вершины ровно один раз и вернуться в началь- ную. Найдя при этом кратчайший путь. + + - Задача о максимальной клике: дан неориентированный граф, требуется найти максимальный полный подграф, т. е. подграф, в котором любые две вершины соединены ребром. + +> Следует отметить, что для многих задач комбинаторной оптимизации алго- ритм поиска с возвратом не является оптимальным решением. + +- Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования для достижения более высокой временной эффек- тивности. + +- Задача коммивояжера является известной NP-трудной задачей, для ее решения часто используются генетические и муравьиные алгоритмы. + +- Задача о максимальной клике является классической задачей теории графов и может быть решена с помощью жадных алгоритмов или других эвристических методов. + +#### задача о перестановках + +> Задача о перестановках является типичным примером применения алгорит- ма поиска с возвратом. Она определяется как задача нахождения всех воз- можных перестановок элементов в заданном множестве (например, массиве или строке). +> +> В табл. 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\] | ++----------------------+--------------------------------------------------------------------------------+ + +### Случай без равных элементов + +> С точки зрения алгоритма поиска с возвратом **процесс генерации пере- становок можно представить как результат серии выборов**. Предполо- жим, что входной массив равен \[1, 2, 3\]. Если сначала выбрать 1, затем 3, а в +> +> конце 2, то получится перестановка \[1, 3, 2\]. Возврат означает отмену выбора и продолжение попыток других вариантов. +> +> С точки зрения кода реализации поиска с возвратом множество кандидатов +> +> choices -- это все элементы входного массива, а состояние state -- это элементы, +> +> выбранные до текущего момента. Следует отметить, что каждый элемент мо- жет быть выбран только один раз, **поэтому все элементы в** state **должны быть уникальными**. +> +> Процесс поиска можно развернуть в виде рекурсивного дерева, в котором каждый узел представляет текущее состояние state, как показано на рис. 13.5. Начиная с корневого узла, после трех раундов выбора достигается листовой узел, а каждый листовой узел соответствует одной перестановке. + +![](ru/docs/assets/media/image810.jpeg) + +> **Рис. 13.5.** Рекурсивное дерево полных перестановок + +##### Обрезка повторного выбора + +> Чтобы обеспечить выбор каждого элемента только один раз, вводится булев массив 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*!). +> +> ![](ru/docs/assets/media/image812.jpeg) +> +> **Рис. 13.6.** Пример обрезки в задаче полных перестановок + +##### Код реализации + +> На основе вышеизложенное можно заполнить пробелы в каркасе кода. Чтобы сократить код, функции каркаса кода не реализуются отдельно, а объединены в функцию 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() +> +> def permutations_i(nums: list\[int\]) -\> list\[list\[int\]\]: \"\"\" Полные перестановки.\"\"\" +> +> res = \[\] +> +> backtrack(state=\[\], choices=nums, selected=\[False\] \* len(nums), res=res) return res + +### Учет равных элементов + +> Предположим, что входной массив равен \[1, 1, 2\]. Для удобства различения двух повторяющихся элементов 1 второй 1 обозначим как 1\^. +> +> Как видно на рис. 13.7, половина перестановок, сгенерированных вышеука- занным методом, являются одинаковыми. + +![](ru/docs/assets/media/image814.jpeg) + +> **Рис. 13.7.** Повторяющиеся перестановки +> +> Как же избавиться от повторяющихся перестановок? Самый прямой спо- соб -- использовать хеш-набор для удаления дубликатов из результата пере- становок. Однако это не самый изящный подход, **так как ветви поиска, гене- рирующие повторяющиеся перестановки**, **излишни**, **и их нужно заранее распознавать и обрезать** -- это повысит эффективность алгоритма. + +##### Обрезка равных элементов + +> В первом раунде выбор 1 или 1\^ эквивалентен, так как все перестановки, сге- нерированные под этими двумя выборами, повторяются, как показано на рис. 13.8. Поэтому элемент 1\^ нужно обрезать. +> +> Аналогично после выбора 2 в первом раунде выбор 1 и 1\^ во втором раун- де также создадут повторяющиеся ветви, поэтому 1\^ во втором раунде также нужно обрезать. + +###### По сути, наша цель -- убедиться, что в каждом раунде выбора несколько равных элементов будут выбраны только один раз. + +![](ru/docs/assets/media/image816.jpeg) + +> **Рис. 13.8.** Обрезка повторяющихся перестановок + +##### Код реализации + +> Возьмем за основу код из предыдущей задачи. В каждом раунде выбора вве- дем хеш-набор 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) \# Запись выбранного значения элемента. +> +> 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*(*n*2) пространства. Таким образом, **пространственная сложность составляет** *O*(*n*2). + +##### Сравнение двух видов обрезки + +> Обратите внимание, что, хотя и selected, и duplicated используются для обрез- ки, их цели различны. + +- **Обрезка повторного выбора**: в процессе всего поиска существует только один массив selected. В нем фиксируется элементы, включен- ные в текущее состояние, а его цель -- избежать повторного появления элемента в state. + +- **Обрезка равных элементов**: каждый раунд выбора (каждый вызов функции backtrack) включает один хеш-набор duplicated. Он фиксирует, какие элементы были выбраны в текущем обходе (цикл for), а его цель -- гарантировать, что равные элементы выбираются только один раз. + +> На рис. 13.9 демонстрируется область действия двух условий обрезки. Об- ратите внимание, что каждый узел в дереве представляет собой выбор, а узлы на пути от корня до листа составляют одну перестановку. +> +> ![](ru/docs/assets/media/image818.jpeg) +> +> **Рис. 13.9.** Область действия двух условий обрезки + +1. **задача о сумме подмножеств** + +### Случай без повторяющихся элементов + +> Например, для входного множества {3, 4, 5} и целевого числа 9 решениями +> +> будут {3, 3, 3}, {4, 5}. Следует обратить внимание на следующие два момента: + +- элементы входного множества можно выбирать неограниченное коли- чество раз; + +- порядок элементов в подмножестве не имеет значения, например {4, 5} и {5, 4} -- одно и то же подмножество. + +##### Сравнение с решением задачи о полных перестановках + +> Подобно задаче о полных перестановках, процесс генерации подмножеств можно представить как серию выборов, а в процессе выбора в реальном вре- мени обновлять сумму элементов. Когда сумма элементов равна target, под- множество записывается в список результатов. +> +> Однако, в отличие от задачи о полных перестановках, **в данной задаче элементы множества можно выбирать неограниченное количество раз**, +> +> поэтому нет необходимости использовать булев список 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 -- это разные ветви, но они соответствуют одному и тому же подмножеству. +> +> ![](ru/docs/assets/media/image820.jpeg) +> +> **Рис. 13.10.** Поиск подмножеств и обрезка по превышению целевого значения +> +> **Одним из очевидных подходов к устранению повторяющихся подмно- жеств является удаление дубликатов из списка результатов**. Однако этот метод очень неэффективен по двум причинам. + +- Когда в массиве много элементов, особенно когда значение target ве- лико, процесс поиска генерирует множество повторяющихся подмно- жеств. + +- Сравнение подмножеств (массивов) на различия очень затратная по вре- мени операция. Она требует сначала сортировки массивов, затем срав- нения различий каждого элемента в массиве. + +##### Обрезка повторяющихся подмножеств + +> Рассмотрим устранение дубликатов в процессе поиска с помощью обрез- ки. На рис. 13.11 показано, что повторяющиеся подмножества возникают при выборе элементов массива в разном порядке, например в следующих случаях: + +1) пусть на первом и втором этапах выбираются 3 и 4 соответственно, соз- даются все подмножества, содержащие эти два элемента, обозначенные как \[3, 4, ...\]; + +2) затем если на первом этапе выбирается 4, **то на втором этапе следует пропустить** 3, так как подмножество \[4, 3, ...\] полностью повторяет под- множество, созданное на этапе 1. + +> В процессе поиска выбор на каждом уровне осуществляется слева направо. +> +> Поэтому чем правее ветвь, тем больше она обрезается. + +1. На первых двух этапах выбираются 3 и 5 и создаются подмножества \[3, 5, ...\]. + +2. На первых двух этапах выбираются 4 и 5 и создаются подмножества \[4, 5, ...\]. + +3. Если на первом этапе выбирается 5, **то на втором этапе следует про- пустить** 3 **и** 4, так как подмножества \[5, 3, ...\] и \[5, 4, ...\] полностью повто- ряют подмножества, описанные на этапах 1 и 2. + +![](ru/docs/assets/media/image822.jpeg) + +> **Рис. 13.11.** Повторяющиеся подмножества, полученные в результате различного порядка выбора +> +> Обобщим эту мысль. Пусть задан входной массив \[*x*1, *x*2, ..., *xn*\]. Тогда в про- цессе поиска последовательность выбора \[*xi*1, *xi*2, ..., *xim*\] должна удовлетво- рять условию *i*1 ≤ *i*2 ≤ ⋯ ≤ *im*, **в противном случае она приведет к дубликатам, и ее нужно обрезать**. + +##### Код реализации + +> Для реализации этой обрезки мы инициализируем переменную start, которая указывает начальную точку обхода. **После выбора** *xi* **следующая итерация начинается с индекса** *i*. Это позволяет для последовательности выбора со- блюдать условие *i*1 ≤ *i*2 ≤ ⋯ ≤ *im*, обеспечивая уникальность подмножеств. +> +> Кроме того, в код были внесены следующие две оптимизации. + +- Перед началом поиска массив nums сортируется. При обходе всех вариан- тов, если сумма подмножества превышает target, цикл завершается, так как последующие элементы больше, и их сумма также превысит target. + +- Исключение переменной total, подсчет суммы элементов осуществля- ется с помощью вычитания из target. Решение фиксируется, когда target равен 0. + +> \# === 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. +> +> ![](ru/docs/assets/media/image824.jpeg) +> +> **Рис. 13.12.** Процесс поиска с возвратом для реализации задачи о сумме подмножеств I + +### Случай с повторяющимися элементами + +> В отличие от предыдущей задачи **входной массив может содержать по- вторяющиеся элементы**, что создает новую проблему. Например, для мас- сива \[4, 4, 5\] и целевого элемента 9, текущий код выдает результат \[4, 5\], \[4, 5\], что приводит к повторяющимся подмножествам. +> +> **Причина этих повторов в том**, **что равные элементы выбираются не- сколько раз на одном этапе**. На рис. 13.13 показано, что на первом этапе есть три варианта выбора, два из которых равны 4. Это приводит к двум повторя- ющимся ветвям поиска и, следовательно, к повторяющимся подмножествам. Аналогично два элемента 4 на втором этапе также создают повторяющиеся подмножества. +> +> ![](ru/docs/assets/media/image826.jpeg) +> +> **Рис. 13.13.** Повторяющиеся подмножества из-за равных элементов + +##### Обрезка равных элементов + +> Для решения этой проблемы **необходимо сделать выбор равных элементов на каждом этапе однократным**. Реализация этого подхода довольно изящ- на: поскольку массив отсортирован, равные элементы находятся рядом друг с другом. Это означает, что если текущий элемент равен предыдущему, то он уже был выбран, и его следует пропустить. +> +> В то же время **в этой задаче предусмотрено**, **что каждый элемент мас- сива может быть выбран только один раз**. К счастью, можно использовать переменную start для выполнения этого ограничения: после выбора *xi* начи- наем следующий цикл с индекса *i* + 1. Это позволяет исключить повторяющие- ся подмножества и избежать повторного выбора элементов. + +##### Код реализации + +> \# === 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, чтобы избежать повторного выбора \# одного и того же элемента. +> +> 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, включающий четыре вида обрезки. Проана- лизируйте рисунок и комментарии в коде, чтобы лучше понять весь процесс поиска и как работают различные операции обрезки. +> +> ![](ru/docs/assets/media/image828.jpeg)*1-й раунд выбора* +> +> *2-й раунд выбора* +> +> **Обрезка 4** +> +> *В одном раунде равные элементы можно выбирать только один раз* +> +> **Обрезка 2** +> +> *Не допускать создания повторяющихся подмножеств* +> +> **Обрезка 1** +> +> *Сумма элементов* +> +> *не может превышать* +> +> **target** +> +> **Рис. 13.14.** Процесс поиска с возвратом для реализации задачи о сумме подмножеств II + +1. **задача об n ферзях** + +> Для *n* = 4 можно найти два решения, которые изображены на рис. 13.15. С точки зрения алгоритма поиска с возвратом шахматная доска размером *n*×*n* имеет *n*2 клеток, которые предоставляют собой все варианты выбора. В про- цессе размещения ферзей состояние доски постоянно меняется, и в каждый момент времени доска имеет состояние state. + +![](ru/docs/assets/media/image829.jpeg){width="3.312998687664042in" height="1.2706244531933508in"} + +> **Рис. 13.15.** Решения задачи о 4 ферзях +> +> На рис. 13.16 изображено три условия ограничения для данной задачи: несколько ферзей не могут находиться на одной строке, в одном столбце или на одной диагонали. Стоит отметить, что диагонали делятся на главную диа- гональ \\ и побочную диагональ /. + +![](ru/docs/assets/media/image831.jpeg) + +> **Рис. 13.16.** Ограничения задачи об n ферзях + +##### Стратегия построчного размещения + +> Количество ферзей и количество строк на доске равно *n*, поэтому можно сделать вывод: **на каждой строке доски может быть размещен только один ферзь**. Из этого следует, что мы можем использовать стратегию построчного раз- мещения: размещать по одному ферзю на каждой строке, начиная с первой +> +> и заканчивая последней. +> +> На рис. 13.17 изображен процесс построчного размещения для задачи о 4 ферзях. Из-за ограничений на размер изображения, на рис. 13.17 развернута только одна ветвь поиска первой строки, а все решения, не удовлетворяющие ограничениям по столбцам и диагоналям, обрезаны. + +![](ru/docs/assets/media/image833.jpeg) + +> **Рис. 13.17.** Стратегия построчного размещения +> +> По сути**, стратегия построчного размещения выполняет функцию об- резки**, отсекая все ветви поиска, в которых на одной строке может находиться более одного ферзя. + +##### Обрезка по столбцам и диагоналям + +> Чтобы выполнить ограничениям по столбцам, можно использовать булев мас- сив cols длиной *n*, в котором будет фиксироваться наличие ферзя в каждом столбце. На его основе перед каждым решением о размещении будут обре- заться столбцы, в которых уже есть ферзь. Состояние cols будет динамически обновляться в процессе возврата. +> +> Как теперь отследить ограничения по диагоналям? Пусть индексы строки и столбца какой-либо клетки на доске равны (row, col). Выбрав определенную главную диагональ в матрице, можно заметить, что разность индексов строки и столбца всех клеток на этой диагонали одинакова, т. е. **для всех клеток на главной диагонали значение** row − col **является постоянной величиной**. +> +> Это означает, что если для двух клеток выполняется условие row1 -- col2 = row2 -- col2, то они находятся на одной главной диагонали. Пользуясь этим пра- вилом, можно с помощью массива diags1 фиксировать наличие ферзя на каж- дой главной диагонали, как показано на рис. 13.18. +> +> Аналогично **сумма** row + col **для всех клеток на побочной диагонали яв- ляется постоянной величиной**. Мы можем использовать еще один массив diags2 для обработки ограничений на побочной диагонали. + +![](ru/docs/assets/media/image835.jpeg) + +> **Рис. 13.18.** Обработка ограничений по столбцам и диагоналям + +##### Код реализации + +> Следует отметить, что в *n*-мерной матрице диапазон row -- col составляет \[−*n* + 1, *n* − 1\], а диапазон row + col составляет \[0, 2*n* − 2\], поэтому количество главных и побочных диагоналей равно 2*n* − 1, т. е. длина массивов diags1 и diags2 также равна 2*n* − 1. +> +> \# === File: n_queens.py === def backtrack( +> +> row: int, n: int, +> +> state: list\[list\[str\]\], res: list\[list\[list\[str\]\]\], cols: list\[bool\], +> +> 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*(*n*2). Таким образом, **общая временная сложность составляет** *O*(*n*! ⋅ *n*2). На прак- тике обрезка по ограничениям диагоналей значительно сокращает простран- ство поиска, поэтому эффективность поиска часто превосходит указанную временную сложность. +> +> Массив state использует *O*(*n*2) пространства, массивы cols, diags1 и diags2 ис- пользуют *O*(*n*) пространства. Максимальная глубина рекурсии составляет *n*, что требует *O*(*n*) пространства стека. Следовательно, **пространственная слож- ность равна** *O*(*n*2). + +1. Резюме ❖ **395** + +> **13.5. резюме** + +##### Ключевые моменты + +- Алгоритм поиска с возвратом по сути является методом полного пере- бора, который ищет подходящие решения путем обхода в глубину про- странства решений. В процессе поиска фиксируются удовлетворяющие условиям решения до тех пор, пока не будут найдены все решения или обход не будет завершен. + +- Поиск с возвратом включает в себя попытки и возвраты. Он использует по- иск в глубину и выполняет попытки для различных вариантов. При несо- ответствии заданным условиям отменяет предыдущий выбор, возвраща- ется к предыдущему состоянию и продолжает проверять другие варианты. Попытки и возвраты -- это операции в противоположных направлениях. + +- Задачи поиска с возвратом обычно содержат несколько ограничений, кото- рые можно использовать для обрезки. Обрезка позволяет заранее завершить ненужные ветви поиска, что значительно повышает эффективность поиска. + +- Алгоритм поиска с возвратом в основном применяется для решения по- исковых задач и задач с ограничениями. Задачи комбинаторной опти- мизации можно решать с помощью поиска с возвратом, но часто суще- ствуют более эффективные или более подходящие методы. + +- Задача о перестановках направлена на поиск всех возможных переста- новок элементов заданного множества. В решении используется массив для учета выбранных элементов и обрезки ветвей поиска с повторным выбором одного и того же элемента. Это позволяет обеспечить выбор каждого элемента только один раз. + +- В задаче о перестановках с повторяющимися элементами нужно отсе- кать повторяющиеся перестановки в конечном результате. Необходимо обеспечить однократный выбор равных элементов в каждом раунде, что обычно реализуется с помощью хеш-множества. + +- Цель задачи о сумме подмножеств -- найти все подмножества с суммой, равной целевому значению, в заданном множестве. Порядок элементов в множестве не важен, но процесс поиска выводит результаты во всех возможных порядках, создавая повторяющиеся подмножества. Перед выполнением поиска с возвратом данные сортируются, а также уста- навливается переменная для указания начальной точки каждого раунда, чтобы обрезать ветви поиска с повторяющимися подмножествами. + +- В задаче о сумме подмножеств равные элементы в массиве создают по- вторяющиеся множества. При наличии предварительно отсортирован- ного массива обрезка осуществляется путем проверки равенства сосед- них элементов, что гарантирует выбор равных элементов только один раз в каждом раунде. + +- Задача об *n* ферзях заключается в нахождении способа размещения *n* ферзей на шахматной доске размером *n*×*n* так, чтобы никакие два фер- зя не рубили друг друга. Ограничения задачи включают ограничения по строкам, столбцам, главным и побочным диагоналям. Для соблюдения ограничения по строкам используется стратегия размещения по стро- кам, что гарантирует размещение одного ферзя в каждой строке. + + + +- Обработка ограничений по столбцам и диагоналям осуществляется ана- логично. Для ограничения по столбцам используется массив, который фиксирует наличие ферзя в каждом столбце. Для ограничения по диаго- налям используются два массива, которые фиксируют наличие ферзя на главной и побочной диагоналях соответственно. Сложность заключается в нахождении закономерности индексов строк и столбцов для клеток, находящихся на одной и той же главной (или побочной) диагонали. + +##### Вопросы и ответы + +> **Вопрос**. Какова связь между возвратом и рекурсией? +> +> **Ответ**. В общем, возврат -- это стратегия алгоритма, тогда как рекурсия ско- рее является инструментом. + +- Алгоритмы поиска с возвратом обычно реализуются на основе рекурсии. Однако поиск с возвратом -- это один из вариантов применения рекур- сии, а именно применение рекурсии в задачах поиска. + +- Структура рекурсии отражает парадигму разбиения на подзадачи и ча- сто используется для решения задач, связанных со стратегией «разделяй и властвуй», поиском с возвратом, динамическим программированием (мемоизация рекурсии) и др. + +> Глава 14 + +# Динамическое программирование + +![](ru/docs/assets/media/image836.jpeg){width="3.2760411198600177in" height="4.239583333333333in"} + +1. **введение в динамичеСкОе прОграммирОвание** *Динамическое программирование* является важной парадигмой в алгоритмах. Ее суть заключается в разбиении задачи на серию более мелких подзадач. Со- + +> хранение решений подзадач позволяет избежать повторных вычислений, что значительно повышает временную эффективность. +> +> В этом разделе мы начнем с классического примера и сначала представим его решение методом перебора. Мы понаблюдаем за наличием перекрыва- ющихся подзадач, а затем постепенно выведем более эффективное решение с использованием динамического программирования. +> +> Как показано на рис. 14.1, для лестницы с тремя ступенями существует три способа добраться до вершины. +> +> ![](ru/docs/assets/media/image838.jpeg)Количество ступеней **n** = + +Есть 3 способа подняться на **3-ю** ступень: + +> **Рис. 14.1.** Количество способов добраться до 3-й ступени +> +> Цель этой задачи -- найти количество способов, **и можно попробовать использовать для ее решения метод поиска с возвратом**. Более конкрет- но -- можно представить подъем по лестнице как процесс многократного выбора: начать с пола, на каждом этапе выбирать подъем на одну или две ступени, при достижении вершины лестницы количество способов увели- чивается на 1, а при превышении вершины происходит обрезка. Ниже при- веден код реализации. +> +> \# === 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\] + +### Первый метод: полный перебор + +> Алгоритм поиска с возвратом обычно не разбивает задачу явным образом, а рассматривает ее решение как серию шагов принятия решений, исследуя пути обхода и выполняя обрезку. +> +> Можно попытаться проанализировать эту задачу с точки зрения разбиения. Пусть для достижения *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 демонстрируется эта рекуррент- ная зависимость. +> +> ![](ru/docs/assets/media/image840.jpeg) +> +> **Рис. 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*(2*n*). Экспоненциальный рост приводит к взрывному увели- чению, и при вводе достаточно большого *n* можно столкнуться с длительной работой алгоритма. +> +> ![](ru/docs/assets/media/image842.jpeg) +> +> **Рис. 14.3.** Рекурсивное дерево для подъема по лестнице +> +> Как видно из рис. 14.3, **экспоненциальная временная сложность вызва- на перекрывающимися подзадачами**. Например, *dp*\[9\] разбивается на *dp*\[8\] и *dp*\[7\], *dp*\[8\] разбивается на *dp*\[7\] и *dp*\[6\] -- обе задачи содержат подзадачу *dp*\[7\]. Таким образом, в подзадачах содержатся более мелкие перекрывающиеся подзадачи, и большая часть вычислительных ресурсов тратится на их обработку. + +### Второй метод: мемоизация поиска + +> Для повышения эффективности алгоритма **необходимо**, **чтобы все пере- крывающиеся подзадачи вычислялись только один раз**. Для этого мы объ- явим массив 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\] +> +> \# 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. + +![](ru/docs/assets/media/image845.jpeg) + +> **Рис. 14.4.** Мемоизация поиска и соответствующее дерево рекурсии + +### Третий метод: динамическое программирование + +> **Мемоизация поиска -- это метод «сверху вниз»**: мы начинаем с исходной задачи (корневой узел) и рекурсивно разбиваем более крупные подзадачи на более мелкие, пока не достигнем минимальных подзадач с известным реше- нием (листовые узлы). Затем через возврат поэтапно собираем решения под- задач, чтобы построить решение исходной задачи. +> +> В отличие от этого подхода **динамическое программирование представ- ляет собой метод «снизу вверх»**: начиная с решения минимальных подза- дач, итеративно строится решение более крупных подзадач, пока не будет по- лучено решение исходной задачи. +> +> Поскольку динамическое программирование не включает этап возврата, оно реализуется с использованием циклов и итераций, без необходимости +> +> в рекурсии. В следующем коде мы инициализируем массив 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 иллюстрируется процесс выполнения приведенного выше кода. + +![](ru/docs/assets/media/image848.jpeg) + +> **Рис. 14.5.** Применение динамического программирования для подъема по лестнице +> +> Как и в алгоритмах поиска с возвратом, в динамическом программировании используется концепция состояния для обозначения определенной стадии ре- шения задачи. Каждое состояние соответствует подзадаче и соответствующе- му локальному оптимальному решению. Например, состояние задачи подъема по лестнице определяется текущей ступенью *i*. +> +> На основе этого можно обобщить часто используемые термины динамиче- ского программирования. + +- Массив dp называется таблицей dp, *dp*\[*i*\] обозначает решение подзадачи, соответствующей состоянию *i*. + + + +- Состояния, соответствующие минимальным подзадачам (1-я и 2-я сту- пени лестницы), называются начальными состояниями. + +- Рекуррентное соотношение *dp*\[*i*\] = *dp*\[*i* − 1\] + *dp*\[*i* − 2\] называется уравне- нием перехода состояния. + +### Оптимизация пространства + +> Внимательный читатель может заметить, что, **поскольку** *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*). +> +> В задачах динамического программирования текущее состояние часто за- висит только от ограниченного числа предыдущих состояний. В этом случае можно сохранить только необходимые состояния, чтобы сэкономить память. **Эта техника оптимизации пространства называется скользящие пере- менные или скользящий массив**. + +1. **Особенности задач динамического программирования** + +В предыдущем разделе мы изучили, как динамическое программирование ре- шает исходную задачу путем разложения на подзадачи. На самом деле раз- + +> ложение на подзадачи -- это универсальный алгоритмический подход, кото- рый по-разному применяется в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом. + +- Алгоритм «разделяй и властвуй» рекурсивно делит исходную задачу на несколько независимых подзадач до самых минимальных и в процессе обратного хода объединяет решения всех подзадач. + +- Динамическое программирование также осуществляет рекурсивное раз- биение задачи. Основное отличие от алгоритмов «разделяй и властвуй» заключается в том, что подзадачи в динамическом программировании взаимозависимы, и в процессе разбиения возникает множество пере- крывающихся подзадач. + + - Алгоритмы поиска с возвратом исчерпывают все возможные решения методом проб и возвратов, осекая ненужные ветви поиска с помощью обрезки. Решение исходной задачи состоит из серии шагов принятия ре- шений, каждый шаг можно рассматривать как подзадачу. + +> На практике динамическое программирование часто используется для ре- шения задач оптимизации, которые не только содержат перекрывающиеся подзадачи, но и обладают двумя другими важными свойствами: оптимальной подструктурой и отсутствием последствий. + +### Оптимальная подструктура + +> Чтобы лучше продемонстрировать концепцию оптимальной подструктуры, рассмотрим задачу о подъеме по лестнице с небольшими изменениями. +> +> Если стоимость на 1-й, 2-й и 3-й ступенях составляет 1, 10 и 1 соответствен- но, то минимальная стоимость подъема с пола на 3-ю ступень равна 2, как по- казано на рис. 14.6. + +![](ru/docs/assets/media/image850.jpeg) + +> **Рис. 14.6.** Минимальная стоимость подъема на 3-ю ступень +> +> Пусть *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 демонстрируется процесс динамического программирования в данном коде. +> +> ![](ru/docs/assets/media/image852.jpeg) +> +> **Рис. 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 + +### Отсутствие последствий + +> *Отсутствие последствий* -- одно из важных свойств, позволяющих динамиче- скому программированию эффективно решать задачи. Оно определяется сле- дующим образом: **при заданном определенном состоянии его дальней- шее развитие зависит только от текущего состояния и не зависит от всех предыдущих состояний**. +> +> Возьмем, к примеру, задачу о подъеме по лестнице. При заданном состоя- нии *i* оно может развиться в состояния *i* + 1 или *i* + 2, что соответствует подъ- ему на одну или две ступени. При выборе одного из этих вариантов нет не- обходимости учитывать состояния, предшествующие *i*, так как они не влияют на будущее состояние. +> +> Однако, если добавить к задаче о подъеме по лестнице ограничения, ситуа- ция изменится. +> +> Как показано на рис. 14.8, для достижения 3-й ступени остается только два возможных варианта. Вариант с тремя последовательными подъемами по од- ной ступени не удовлетворяет условиям и поэтому отбрасывается. + +![](ru/docs/assets/media/image854.jpeg) + +> **Рис. 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\]. + + - Если на предыдущем шаге был подъем на две ступени, то на шаг до этого можно было выбрать подъем на одну или две ступени, т. е. *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λϑ. + +![](ru/docs/assets/media/image856.jpeg) + +> **Рис. 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\] +> +> В приведенном выше примере необходимо учитывать только одно преды- дущее состояние, поэтому можно расширить определение состояния, и задача все равно будет удовлетворять условию отсутствия последствий. Однако неко- торые задачи обладают серьезными условиями последствий. +> +> В этой задаче следующий шаг зависит от всех предыдущих состояний, так как каждый предыдущий шаг устанавливает препятствие на более высокой ступени. Для таких задач динамическое программирование часто оказывается неэффективным. +> +> На самом деле многие сложные задачи комбинаторной оптимизации (на- пример, задача коммивояжера) не удовлетворяют условию отсутствия по- следствий. Для решения таких задач обычно выбираются другие методы, та- кие как эвристический поиск, генетические алгоритмы, обучение с подкре- плением и т. д., чтобы получить приемлемое локальное оптимальное решение за ограниченное время. + +#### подход к решению задач динамического программирования + +> В предыдущих разделах были рассмотрены основные характеристики задач динамического программирования, теперь исследуем два более практичных вопроса. + +1. Как определить, является ли задача задачей динамического программи- рования? + +2. С чего начать решение задачи динамического программирования, како- ва полная схема решения? + +### Определение задачи + +> В общем случае, если задача содержит перекрывающиеся подзадачи, опти- мальную подструктуру и удовлетворяет условию отсутствия последствий, она обычно подходит для решения методом динамического программи- рования. Однако трудно извлечь эти характеристики непосредственно из описания задачи. Поэтому обычно условия смягчаются, и **сначала прове- ряется, подходит ли задача для решения методом поиска с возвратом (перебора)**. +> +> **Задачи, подходящие для решения методом поиска с возвратом, обыч- но соответствуют модели дерева решений**. Такие задачи можно описать с помощью древовидной структуры, в которой каждый узел представляет со- бой решение, а каждый путь -- последовательность решений. +> +> Иными словами, если задача включает в себя явную концепцию принятия решений и решение получается в результате серии решений, то она соответ- ствует модели дерева решений. Обычно такую задачу можно решить с помо- щью метода обратного поиска. +> +> Задачи динамического программирования, помимо вышеуказанных, долж- ны иметь некоторые дополнительные характеристики. + +- Задача содержит описание оптимизации, например максимизацию или минимизацию. + +- Состояние задачи можно представить с помощью списка, многомерной матрицы или дерева, и существует рекурсивная связь между состоянием и его окружением. + +> Соответственно, существуют маркеры, которые говорят о неприменимости стратегии динамического программирования. + +- Цель задачи -- найти все возможные решения, а не оптимальное решение. + +- Описание задачи имеет явные признаки комбинаторики, и требуется вернуть несколько конкретных решений. + +> Если задача соответствует модели дерева решений и обладает достаточно явными дополнительным характеристиками, можно предположить, что это задача динамического программирования, и подтвердить это в процессе решения. + +### Этапы решения задачи + +> Процесс решения задач динамического программирования может разли- чаться в зависимости от природы и сложности задачи, но обычно следует следующей схеме: описание решений, определение состояния, построение таблицы *dp*, вывод уравнения перехода состояния, определение граничных условий и т. д. +> +> Для более наглядного представления этапов решения рассмотрим в каче- стве примера классическую задачу «минимальная стоимость пути». +> +> На рис. 14.10 показан пример, в котором минимальная сумма пути для дан- ного массива равна 13. +> +> ![](ru/docs/assets/media/image858.jpeg) +> +> **Рис. 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. +> +> **Решение:** пройти на одну клетку вправо или вниз +> +> **Определение состояния:** индексы строки и столбца **\[i, j\]** +> +> **Таблица dp** +> +> ![](ru/docs/assets/media/image860.jpeg)**Подзадача:** минимальная сумма пути из левого верхнего угла до **\[i, j\]** +> +> **Таблица dp:** матрица того же размера, что и grid +> +> **Рис. 14.11.** Определение состояния и таблица 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*λϑ . + +![](ru/docs/assets/media/image862.jpeg) + +> **Рис. 14.12.** Оптимальная подструктура и уравнение перехода состояния + +##### Шаг 3: определение граничных условий и порядка перехода состояния + +> В этой задаче состояния в первой строке можно получить только из левых со- стояний, а состояния в первом столбце -- только из верхних состояний, поэто- му первая строка *i* = 0 и первый столбец *j* = 0 являются граничными условиями. Поскольку каждую ячейку можно получить только из ячейки слева или сверху, мы используем цикл для обхода матрицы: внешний цикл проходит по +> +> строкам, а внутренний -- по столбцам, как показано на рис. 14.13. +> +> ![](ru/docs/assets/media/image864.jpeg)**Граничные условия:** инициализировать первые строку и столбец +> +> **Порядок перехода состояний:** +> +> прямой обход матрицы +> +> **Рис. 14.13.** Граничные условия и порядок перехода состояний +> +> На основе вышеизложенного анализа можно сразу написать код динами- ческого программирования. Однако разбиение подзадач -- это подход сверху вниз, поэтому реализация в порядке полный перебор → мемоизация → дина- мическое программирование более соответствует привычному мышлению. + +##### Первый метод: полный перебор + +> Поиск начинается с состояния \[*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 индекс выходит за допустимые пределы, в этом случае возвращается стоимость +∞, что означает недопустимость. + +> Ниже приведен код реализации. +> +> \# === 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. +> +> По сути, причиной перекрывающихся подзадач является **наличие несколь- ких путей**, **ведущих из верхнего левого угла к одной ячейке**. + +![](ru/docs/assets/media/image866.jpeg) + +> **Рис. 14.14.** Дерево рекурсии полного перебора +> +> Каждое состояние имеет два варианта выбора: вниз и вправо. Чтобы пройти из верхнего левого угла в нижний правый, требуется *m* + *n* -- 2 шагов, поэтому в худшем случае временная сложность составляет *O*(2*m*+*n*). Обратите внимание, что этот расчет не учитывает случаи, когда путь достигает границы сетки, где остается только один вариант выбора, поэтому фактическое количество путей будет меньше. + +##### Второй метод: мемоизация + +> Вводится список 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*). + +![](ru/docs/assets/media/image868.jpeg) + +> **Рис. 14.15.** Дерево рекурсии мемоизации + +##### Третий метод: динамическое программирование + +> Ниже представлена реализация решения с использованием итеративного под- хода динамического программирования. +> +> \# === 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*). + +![](ru/docs/assets/media/image870.jpeg) + +> **Рис. 14.16.** Динамическое программирование для минимальной стоимости пути. Шаг 1 +> +> ![](ru/docs/assets/media/image873.jpeg) + +![](ru/docs/assets/media/image876.jpeg)![](ru/docs/assets/media/image878.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image880.jpeg) + +![](ru/docs/assets/media/image882.jpeg)![](ru/docs/assets/media/image884.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image886.jpeg) + +![](ru/docs/assets/media/image888.jpeg)![](ru/docs/assets/media/image890.jpeg) + +> **Рис. 14.16.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image892.jpeg) + +![](ru/docs/assets/media/image894.jpeg) + +> **Рис. 14.16.** *Окончание*. Шаги 11--12 + +##### Оптимизация пространства + +> Поскольку каждая ячейка зависит только от ячеек слева и сверху, для реали- зации таблицы *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 +> +> \# Переход состояния: первая строка. 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\] + +#### задача о рюкзаке 0-1 + +> Задача о рюкзаке является отличным примером для начала изучения динами- ческого программирования и представляет собой одну из наиболее распростра- ненных форм этой задачи. Существует множество ее вариаций, таких как задача о рюкзаке 0-1, задача о полном рюкзаке, задача о многократном рюкзаке и др. +> +> В этом разделе мы сначала решим наиболее распространенную задачу о рюкзаке 0-1. +> +> Обратите внимание на рис. 14.17: поскольку нумерация предметов *i* начи- нается с 1, а индексация массива с 0, то предмету *i* соответствует масса *wgt*\[*i* -- 1\] и стоимость *val*\[*i* -- 1\]. + +![](ru/docs/assets/media/image897.jpeg) + +> **Рис. 14.17.** Пример данных для задачи о рюкзаке 0-1 +> +> Задачу о рюкзаке 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*\]. + + + +- **Условие завершения**: номер предмета выходит за пределы *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*(2*n*), как показано на рис. 14.18. +> +> ![](ru/docs/assets/media/image900.jpeg)При наблюдении за деревом рекурсии легко заметить наличие перекрыва- ющихся подзадач, таких как *dp*\[1, 10\]. А когда количество предметов и вмести- мость рюкзака велики, особенно если есть много предметов с одинаковым ве- сом, количество перекрывающихся подзадач значительно увеличивается. + ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ +| *Нет* | | | | > *Да* | | > *Положить предмет 3?* | ++===========+========+==============+=====+========+=====+========================================================+ +| | | | | | | > **Обрезка:** *Масса предмета \> Вместимость рюкзака* | ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ +| > *Нет* | > *Да* | *Не* | *т* | | | > *Да Положить предмет* **2***?* | ++-----------+--------+--------------+-----+--------+-----+--------------------------------------------------------+ + +> **Рис. 14.18.** Дерево рекурсии полного перебора для задачи о рюкзаке 0-1 + +##### Второй метод: мемоизация + +> Чтобы вычислять перекрывающиеся подзадачи только один раз, используем список запоминания 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 изображены обрезанные ветви поиска в процессе мемоизации. + +![](ru/docs/assets/media/image902.jpeg) + +> **Рис. 14.19.** Рекурсивное дерево мемоизации для задачи о рюкзаке 0-1 + +##### Третий метод: динамическое программирование + +> Динамическое программирование представляет собой процесс заполнения таблицы *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. + +![](ru/docs/assets/media/image904.jpeg) + +> **Рис. 14.20.** Динамическое программирование для задачи о рюкзаке 0-1. Шаг 1 +> +> ![](ru/docs/assets/media/image906.jpeg) + +![](ru/docs/assets/media/image908.jpeg)![](ru/docs/assets/media/image910.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image912.jpeg) + +![](ru/docs/assets/media/image914.jpeg)![](ru/docs/assets/media/image916.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image918.jpeg) + +![](ru/docs/assets/media/image920.jpeg)![](ru/docs/assets/media/image922.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image924.jpeg) + +![](ru/docs/assets/media/image926.jpeg)![](ru/docs/assets/media/image928.jpeg) + +> **Рис. 14.20.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image930.jpeg) +> +> **Рис. 14.20.** *Окончание*. Шаг 14 + +##### Оптимизация пространства + +> Поскольку каждое состояние зависит только от состояния предыдущей строки, можно использовать два массива для продвижения и снизить пространствен- ную сложность с *O*(*n*2) до *O*(*n*). +> +> А можно ли реализовать оптимизацию пространства, используя только один массив? Заметим, что каждое состояние переходит из верхней или левой верх- ней ячейки. Если используется только один массив, то при начале обхода стро- ки *i* массив все еще хранит состояние строки *i* -- 1. + +- Если обход выполняется в прямом порядке, то при достижении *dp*\[*i*, *j*\] значения из левой верхней части *dp*\[*i* -- 1, 1\] \~ *dp*\[*i* -- 1, *j* -- 1\] могут быть уже перезаписаны, что делает невозможным получение правильного резуль- тата перехода состояния. + +- Если обход выполняется в обратном порядке, то проблема перезаписи не возникает, и переход состояния можно выполнить корректно. + +> На рис. 14.21 демонстрируется процесс перехода от строки *i* = 1 к строке *i* = 2 с использованием одного массива. Проанализируйте различия при прямом и обратном обходах. + +![](ru/docs/assets/media/image932.jpeg) + +> **Рис. 14.21.** Динамическое программирование с оптимизацией пространства для задачи о рюкзаке 0-1. Шаг 1 +> +> ![](ru/docs/assets/media/image934.jpeg) + +![](ru/docs/assets/media/image936.jpeg)![](ru/docs/assets/media/image938.jpeg) + +> **Рис. 14.21.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image940.jpeg) + ++---------------------------------------------------------------------------------------------------------------+ +| > Шаг 6 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > После завершения обхода в массиве **dp** содержатся все решения для i = 2 | ++=======================================================+===========================+===========================+ +| | | | ++-------------------------------------------------------+---------------------------+---------------------------+ + +> ![](ru/docs/assets/media/image942.jpeg)**Рис. 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\] +> +> else: +> +> \# Наибольшее значение из двух вариантов: не выбирать и выбирать \# предмет i. +> +> dp\[c\] = max(dp\[c\], dp\[c - wgt\[i - 1\]\] + val\[i - 1\]) return dp\[cap\] + +#### задача о полном рюкзаке + +> В этом разделе мы сначала решим еще одну распространенную задачу о рюк- заке -- задачу о полном рюкзаке. А затем рассмотрим ее частный случай -- за- дачу о размене монет. + +### Задача о полном рюкзаке + +![](ru/docs/assets/media/image946.png) + +> **Рис. 14.22.** Пример данных для задачи о полном рюкзаке + +##### Динамическое программирование + +> Задача о полном рюкзаке очень похожа на задачу о рюкзаке 0-1. **Различие лишь в том**, **что количество выборов предметов не ограничено**. + +- В задаче о рюкзаке 0-1 каждый предмет существует в единственном эк- земпляре, поэтому после помещения предмета *i* в рюкзак можно выби- рать только из первых *i* -- 1 предметов. + + - В задаче о полном рюкзаке количество предметов не ограничено, поэто- му **после помещения предмета** *i* **в рюкзак можно продолжать вы- бирать из первых** *i* **предметов**. + +> В условиях задачи о полном рюкзаке изменение состояния \[*i*, *c*\] делится на два случая. + +- **Не помещать предмет** *i*: аналогично задаче о рюкзаке 0-1, переход к \[*i* -- 1, *[c]{.underline}*\]. + +- **Помещать предмет** *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\]). + +##### Код реализации + +> По сравнению с кодом предыдущей задачи есть одно изменение в переходе состояния с *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\] + +##### Оптимизация пространства + +> Поскольку текущее состояние исходит из состояний слева и сверху, **после оп- тимизации пространства следует выполнять прямой обход каждой стро- ки в таблице** *dp*. +> +> Этот порядок обхода противоположен порядку в задаче о рюкзаке 0-1. Из- учите рис. 14.23 для понимания различий между ними. +> +> ![](ru/docs/assets/media/image950.jpeg) + +![](ru/docs/assets/media/image952.jpeg)![](ru/docs/assets/media/image954.jpeg) + +> **Рис. 14.23.** Динамическое программирование для задачи о полном рюкзаке после оптими- зации пространства. Шаги 1--3 + +![](ru/docs/assets/media/image956.jpeg)![](ru/docs/assets/media/image958.jpeg) + ++---------------------------------------------------------------------------------------------+ +| > Шаг 4 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > Прямой обход строки i = 2, выполнение перехода состояния | ++========================+===========================================+========================+ +| | | | ++------------------------+-------------------------------------------+------------------------+ + ++---------------------------------------------------------------------------------------------+ +| > Шаг 5 Масса Стоимость | +| > | +| > **wgt val** | +| > | +| > Используется один | +| > | +| > одномерный массив **dp** | +| > | +| > Прямой обход строки i = 2, выполнение перехода состояния | ++========================+===========================================+========================+ +| | | | ++------------------------+-------------------------------------------+------------------------+ + +![](ru/docs/assets/media/image960.jpeg) + +> **Рис. 14.23.** *Окончание*. Шаги 4--6 +> +> Код реализации достаточно прост, необходимо лишь удалить первую раз- мерность массива 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\] + +### Задача о размене монет + +> Задача о рюкзаке является представителем большого класса задач динамиче- ского программирования, имеющего множество вариаций, таких как задача о размене монет. + +![](ru/docs/assets/media/image962.jpeg) + +> **Рис. 14.24.** Пример данных для задачи о размене монет + +##### Динамическое программирование + +> **Задачу о размене монет можно рассматривать как частный случай зада- чи о полном рюкзаке** со следующими сходствами и различиями. + +- Обе задачи можно преобразовать друг в друга: предмет соответствует монете, масса предмета соответствует номиналу монеты, вместимость рюкзака соответствует целевой сумме. + +- Цели оптимизации противоположны: задача о полном рюкзаке стре- мится максимизировать стоимость предметов, задача о размене монет -- минимизировать количество монет. + +- Задача о полном рюкзаке ищет решение, не превышающее вместимость рюкзака, задача о размене монет -- решение, точно соответствующее це- левой сумме. + +####### Шаг 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*\] в первой строке равны +∞. + +##### Код реализации + +> В большинстве языков программирования нет представления для значения +∞, поэтому часто используется максимальное значение типа int. Однако это может привести к переполнению при выполнении операции +1 в уравнении перехода. Поэтому для обозначения недопустимого решения будем использовать чис- ло *amt* + 1, поскольку максимальное количество монет для составления *amt* равно *amt*. Перед возвратом проверяется, равно ли *dp*\[*n*, *amt*\] значению *amt* + 1. +> +> Если равно, возвращается --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 демонстрируется процесс динамического программирова- ния для задачи о размене монет, который очень похож на задачу о полном рюкзаке. + +![](ru/docs/assets/media/image964.jpeg) + +> **Рис. 14.25.** Динамическое программирование задачи о размене монет. Шаг 1 + +![](ru/docs/assets/media/image965.jpeg){width="3.3324004811898513in" height="1.8960411198600176in"} + +> ![](ru/docs/assets/media/image967.jpeg) + +![](ru/docs/assets/media/image969.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 2--4 +> +> ![](ru/docs/assets/media/image971.jpeg) + +![](ru/docs/assets/media/image973.jpeg)![](ru/docs/assets/media/image975.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image977.jpeg) + +![](ru/docs/assets/media/image979.jpeg)![](ru/docs/assets/media/image981.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image983.jpeg) + +![](ru/docs/assets/media/image985.jpeg)![](ru/docs/assets/media/image987.jpeg) + +> **Рис. 14.25.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image989.jpeg) + +![](ru/docs/assets/media/image991.jpeg) + +> **Рис. 14.25.** *Окончание*. Шаги 14--15 + +##### Оптимизация пространства + +> Оптимизация пространства в задаче о размене монет осуществляется анало- гично задаче о полном рюкзаке. +> +> \# === 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): \# Прямой обход. +> +> 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 + +![](ru/docs/assets/media/image993.jpeg) + +> **Рис. 14.26.** Пример данных для задачи о размене монет II + +##### Динамическое программирование + +> В отличие от предыдущей задачи здесь целью является определение количе- ства комбинаций, поэтому подзадача формулируется следующим образом: **количество комбинаций, которыми можно составить сумму** *a*, **используя первые** *i* **видов монет**. Таблица *dp* по-прежнему представляет собой двумер- ную матрицу размером (*n* + 1) × (*amt* + 1). +> +> Количество комбинаций для текущего состояния равно сумме количества комбинаций без выбора текущей монеты и с выбором текущей монеты. Урав- нение перехода состояния имеет вид: +> +> *dp*\[*i*, *a*\] = *dp*\[*i* -- 1, *a*\] + *dp*\[*i*, *a* -- *coins*\[*i* -- 1\]\]. +> +> Если целевая сумма равна 0, то для достижения этой суммы не требуется вы- бирать монеты, поэтому все *dp*\[*i*, 0\] в первом столбце нужно инициализировать значением 1. Если монет нет, невозможно составить любую сумму больше 0, поэтому все *dp*\[0, *a*\] в первой строке равны 0. + +##### Код реализации + +> \# === 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\] + +##### Оптимизация пространства + +> Метод оптимизации пространства аналогичен предыдущей задаче, достаточ- но удалить измерение монет. +> +> \# === 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: +> +> \# Если превышает целевую сумму, монета i не выбирается. dp\[a\] = dp\[a\] +> +> else: +> +> \# Сумма двух вариантов: без выбора и с выбором монеты i. dp\[a\] = dp\[a\] + dp\[a - coins\[i - 1\]\] +> +> return dp\[amt\] + +#### задача расстояния редактирования + +> Расстояние редактирования, также известное как расстояние Левенштейна, -- это минимальное количество изменений, необходимых для преобразования од- ной строки в другую. Обычно используется для измерения сходства двух после- довательностей в информационном поиске и обработке естественного языка. +> +> Для преобразования kitten в sitting требуется три шага редактирования, включая две операции замены и одну операцию добавления, как показано на рис. 14.27. Для преобразования hello в algo требуется три шага, включая две операции замены и одну операцию удаления. + +![](ru/docs/assets/media/image995.jpeg) + +> **Рис. 14.27.** Пример данных для задачи расстояния редактирования +> +> **Задачу расстояния редактирования можно естественным образом объяснить с помощью модели дерева решений**. Строки соответствуют уз- лам дерева, а один шаг редактирования (одна операция редактирования) соот- ветствует ребру дерева. +> +> При отсутствии ограничений на операции каждый узел может порождать множество ребер, каждое из которых соответствует одной операции, как пока- зано на рис. 14.28. Это означает, что существует множество возможных путей для преобразования hello в algo. +> +> С точки зрения дерева решений цель задачи -- найти кратчайший путь меж- ду узлом hello и узлом algo. + +![](ru/docs/assets/media/image997.jpeg) + +> **Рис. 14.28.** Представление задачи расстояния редакти- рования на основе модели дерева решений + +##### Динамическое программирование + +####### Шаг 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). + +####### Шаг 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\]. + +![](ru/docs/assets/media/image999.jpeg) + +> **Рис. 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*. +> +> Анализируя уравнение перехода состояния, решение *dp*\[*i*, *j*\] зависит от ре- шения слева, сверху и слева сверху. Поэтому можно обойти всю таблицу *dp* в прямом порядке с помощью двух вложенных циклов. + +##### Код реализации + +> \# === 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, процесс перехода состояния для задачи расстояния редактирования очень похож на задачу о рюкзаке, и его можно рассматривать как заполнение двумерной сетки. + +![](ru/docs/assets/media/image1001.jpeg) + +> **Рис. 14.30.** Динамическое программирование для расстояния редактирования. Шаг 1 + +![](ru/docs/assets/media/image1002.jpeg){width="3.4433311461067366in" height="2.0345833333333334in"} + +> ![](ru/docs/assets/media/image1004.jpeg) + +![](ru/docs/assets/media/image1006.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 2--4 + +![](ru/docs/assets/media/image1007.jpeg){width="3.4471598862642168in" height="2.0345833333333334in"} + +![](ru/docs/assets/media/image1009.jpeg)![](ru/docs/assets/media/image1011.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 5--7 +> +> ![](ru/docs/assets/media/image1013.jpeg) + +![](ru/docs/assets/media/image1015.jpeg)![](ru/docs/assets/media/image1017.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 8--10 +> +> ![](ru/docs/assets/media/image1019.jpeg) + +![](ru/docs/assets/media/image1021.jpeg)![](ru/docs/assets/media/image1023.jpeg) + +> **Рис. 14.30.** *Продолжение*. Шаги 11--13 +> +> ![](ru/docs/assets/media/image1025.jpeg) + +![](ru/docs/assets/media/image1027.jpeg) + +> **Рис. 14.30.** *Окончание*. Шаги 14--15 + +##### Оптимизация пространства + +> Поскольку *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: +> +> \"\"\" Расстояние редактирования: динамическое программирование с оптимизацией пространства.\"\"\" +> +> 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\] + +#### резюме + +- Динамическое программирование разбивает задачу на подзадачи, со- храняет их решения и избегает повторных вычислений, что повышает эффективность. + +- Все задачи динамического программирования можно решить с помо- щью перебора (поиска в глубину), но в дереве рекурсии много повторяю- щихся подзадач, что делает его крайне неэффективным. Использование мемоизации позволяет сохранить решения всех вычисленных подзадач, гарантируя, что каждая из них будет решена только один раз. + +- Мемоизация -- это рекурсивный подход сверху вниз, тогда как динами- ческое программирование -- это итеративный подход снизу вверх, по- хожий на заполнение таблицы. Поскольку текущее состояние зависит только от некоторых локальных состояний, можно устранить одно из- мерение таблицы *dp* и уменьшить пространственную сложность. + +- Разбиение задачи на подзадачи -- это общий алгоритмический подход, который имеет различную реализацию в методах «разделяй и властвуй», динамическом программировании и поиске с возвратом. + +- Задачи динамического программирования обладают тремя основны- ми свойствами: повторяющиеся подзадачи, оптимальная подструктура и отсутствие последствий. + +- Если оптимальное решение исходной задачи можно построить из оптимальных решений подзадач, то оно обладает оптимальной под- структурой. + + + +- Отсутствие последствий означает, что будущее развитие состояния за- висит только от этого состояния и не зависит от всех предыдущих со- стояний. Многие задачи комбинаторной оптимизации не обладают этим свойством, и для их быстрого решения нельзя использовать динамиче- ское программирование. + +##### Задача о рюкзаке + +- Задача о рюкзаке -- одна из самых типичных задач динамического про- граммирования, имеющая такие варианты, как рюкзак 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*\], редактирование текущего символа не требуется. + + - В задаче о расстоянии редактирования состояние зависит от состояний + +> «прямо сверху», «прямо слева» и «слева сверху». Поэтому после опти- мизации пространства ни прямой, ни обратный обход не позволяют корректно выполнить переход состояния. Для решения этой проблемы используется переменная для временного хранения состояния слева сверху. Это позволяет преобразовать задачу в эквивалентную задаче о полном рюкзаке, и после оптимизации пространства можно выпол- нять прямой обход. +> +> Глава 15 + +# Жадность + +![](ru/docs/assets/media/image1028.jpeg){width="3.71875in" height="4.8125in"} + +#### жадные алгоритмы + +> *Жадный алгоритм* -- это распространенный метод решения задач оптими- зации. Его основная идея заключается в том, чтобы на каждом этапе приня- тия решения выбирать наиболее оптимальный на данный момент вариант, т. е. с жадностью принимать локально оптимальные решения в надежде по- лучить глобально оптимальное решение. Жадные алгоритмы просты и эф- фективны, и они находят широкое применение в решении многих практи- ческих задач. +> +> Жадные алгоритмы и динамическое программирование часто использу- ются для решения задач оптимизации. Между ними есть некоторые сходства, например оба метода зависят от свойств оптимальной подструктуры, но их принципы работы различны. + +- Динамическое программирование для получения текущего решения учитывает все предыдущие решения и использует решения предыдущих подзадач для построения решения текущей подзадачи. + +- Жадный алгоритм не учитывает предыдущие решения, а просто движет- ся вперед, делая жадные выборы и постепенно сокращая область задачи, пока она не будет решена. + +> Чтобы лучше понять принцип работы жадного алгоритма, рассмотрим его применение к задаче о размене монет. Она уже была рассмотрена в разделе +> +> «Задача о полном рюкзаке», и, вероятно, вы с ней уже знакомы. +> +> Жадная стратегия, применяемая в этой задаче, показана на рис. 15.1. Для заданной целевой суммы **мы жадно выбираем монету, которая не превы- шает и наиболее близка к этой сумме**, и повторяем этот шаг, пока не будет достигнута целевая сумма. +> +> ![](ru/docs/assets/media/image1030.jpeg) +> +> **Рис. 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 +> +> Вы можете невольно воскликнуть: «Эврика!» Жадный алгоритм решает за- дачу размена монет всего за десяток строк кода. + +1. **Преимущества и ограничения жадных алгоритмов** + +> **Жадные алгоритмы не только просты в реализации, но и обычно очень эффективны**. Если в приведенном выше коде обозначить минимальный но- минал монеты как *min*(*coins*), то жадный выбор выполняется не более *amt* / *min*(*coins*) раз. Тогда временная сложность составляет *O*(*amt* / *min*(*coins*)). Это на порядок меньше временной сложности решения с использованием дина- мического программирования *O*(*n* × *amt*). +> +> Однако **для некоторых комбинаций номиналов монет жадный алгоритм не сможет найти оптимальное решение**. На рис. 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 монеты. + +> ![](ru/docs/assets/media/image1032.jpeg)**Комбинация монет Целевая сумма Оптимальное решение** +> +> **жадного алгоритма (локальный оптимум)** +> +> **Оптимальное решение динамического программирования (глобальный оптимум)** +> +> **Рис. 15.2.** Примеры, когда жадный алгоритм не может найти оптимальное решение +> +> Таким образом, для задачи размена монет жадный алгоритм не гарантиру- ет нахождение глобально оптимального решения и может привести к очень плохому решению. Для решения этой задачи лучше подходит динамическое программирование. +> +> В общем случае жадные алгоритмы применимы в следующих двух ситуациях: + +1) **можно гарантировать нахождение оптимального решения**: в этом случае жадный алгоритм часто является лучшим выбором, так как он обычно более эффективен, чем методы обратного поиска и динамиче- ского программирования; + +2) **можно найти приближенное оптимальное решение**: в этом случае жадный алгоритм также применим. Для многих сложных задач поиск глобально оптимального решения очень затруднителен, и возможность найти субоптимальное решение с высокой эффективностью является весьма хорошим результатом. + +### Свойства жадных алгоритмов + +> Итак, возникает вопрос: какие задачи подходят для решения с помощью жад- ного алгоритма? Или, иначе говоря, в каких случаях жадный алгоритм может гарантировать нахождение оптимального решения? +> +> По сравнению с динамическим программированием условия применения жадного алгоритма более строгие, и они в основном сосредоточены на двух свойствах задачи. + +1. **Свойство жадного выбора**: жадный алгоритм может гарантировать получение оптимального решения только в случае, если локально опти- мальный выбор всегда приводит к глобально оптимальному решению. + +2. **Оптимальная подструктура**: оптимальное решение исходной задачи содержит оптимальное решение подзадачи. + +> Оптимальная подструктура уже была рассмотрена в главе «Динамическое программирование», поэтому здесь не будем повторяться. Стоит отметить, что оптимальная подструктура некоторых задач не всегда очевидна, но их все же можно решить с помощью жадного алгоритма. +> +> Основное внимание уделяется методам определения свойства жадного вы- бора. Хотя его описание кажется простым, **на практике доказательство это- го свойства для многих задач является сложной задачей**. +> +> Например, в задаче о размене монет мы можем легко привести контрпри- мер для опровержения свойства жадного выбора. Однако доказательство его истинности значительно сложнее. На вопрос «**При каких условиях можно использовать жадный алгоритм для решения задачи размена монет**?» обычно мы можем дать лишь интуитивный или примерный ответ, но не мо- жем предоставить строгое математическое доказательство. + +### Этапы решения задач жадным алгоритмом + +> Процесс решения жадных задач можно разделить на следующие три этапа: + +1) **анализ задачи**: изучение и понимание характеристик задачи, включая определение состояния, цели оптимизации и ограничения. Этот этап также присутствует в методах поиска с возвратом и динамического про- граммирования; + +2) **определение жадной стратегии**: определение того, как делать жадный выбор на каждом шаге. Эта стратегия позволяет уменьшать размер за- дачи на каждом шаге и в конечном итоге решить всю задачу; + +3) **доказательство корректности**: обычно требуется доказать нали- чие свойства жадного выбора и оптимальной подструктуры задачи. Этот этап может потребовать использования математических доказа- тельств, таких как метод математической индукции или доказатель- ство от противного. + +> Определение жадной стратегии является ключевым этапом решения зада- чи, но его реализация может быть непростой по следующим причинам. + +- **Жадные стратегии для различных задач могут значительно разли- чаться**. Для многих задач жадная стратегия очевидна, и ее можно опре- делить с помощью общего размышления и эмпирических проб. Однако для некоторых сложных задач жадная стратегия может оказаться очень скрытой, что потребует значительного опыта в решении задач и навы- ков работы с алгоритмами. + +- **Некоторые жадные стратегии могут быть обманчивыми**. Бывает, жадная стратегия разработана с полной уверенностью в ее правильности, код написан и отправлен на выполнение. Но оказывается, что некоторые тестовые примеры не проходят проверку на корректность. Это происхо- дит потому, что разработанная жадная стратегия является лишь частично правильной, как в случае с задачей о размене монет, описанной выше. + +> Для обеспечения корректности необходимо провести строгое математиче- ское доказательство жадной стратегии, **обычно с использованием метода доказательства от противного или метода математической индукции**. +> +> Тем не менее доказательство корректности может оказаться непростой за- дачей. Если нет ясности, обычно выбирается отладка кода на основе тестовых примеров с постепенной модификацией и проверкой жадной стратегии. + +### Типичные задачи для жадного алгоритма + +> Жадный алгоритм часто применяется в задачах оптимизации, удовлетворяю- щих свойству жадного выбора и оптимальной подструктуре. Ниже перечисле- ны некоторые типичные задачи для жадного алгоритма. + +- **Задача о размене монет**: при некоторых комбинациях монет жадный алгоритм всегда может получить оптимальное решение. + +- **Задача о расписании интервалов**: пусть у вас есть несколько задач, каждая из которых выполняется в течение определенного времени, и ваша цель -- выполнить как можно больше задач. Если каждый раз вы- бирать задачу с наименьшим временем окончания, то жадный алгоритм может дать оптимальное решение. + +- **Задача о дробном рюкзаке**: дана группа предметов и вместимость. Ваша цель -- выбрать группу предметов так, чтобы общая масса не превышала вместимость, а общая стоимость была максимальной. Если каждый раз выбирать предмет с наивысшим соотношением стоимости к массе, то жадный алгоритм в некоторых случаях может дать оптимальное решение. + +- **Задача о покупке и продаже акций**: дана группа акций с историей цены, можно совершать многократные покупки и продажи, но если ак- ции уже куплены, то перед следующей покупкой их необходимо продать. Цель -- получить максимальную прибыль. + +- **Код Хаффмана** -- это жадный алгоритм, используемый для сжатия дан- ных без потерь. Строится дерево Хаффмана: каждый раз выбираются два узла с наименьшей частотой появления и объединяются, в резуль- тате чего получается дерево с минимальной длиной взвешенного пути (длиной кодирования). + + + +- **Алгоритм Дейкстры** -- это жадный алгоритм, решающий задачу нахож- дения кратчайшего пути от заданной исходной вершины до всех осталь- ных вершин. + +#### задача о дробном рюкзаке + +![](ru/docs/assets/media/image1036.jpeg) + +> **Рис. 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\]. + + 2. Задача о дробном рюкзаке ❖ **467** + +![](ru/docs/assets/media/image1040.jpeg) + +> **Рис. 15.4.** Стоимость предметов на единицу массы + +##### Определение жадной стратегии + +> Максимизация общей стоимости предметов в рюкзаке, по сути, является мак- симизацией стоимости предметов на единицу массы. Из этого можно вывести жадную стратегию, изображенную на рис. 15.5. + +1. Отсортировать предметы по убыванию стоимости на единицу массы. + +2. Перебирать все предметы и **жадно выбирать на каждом этапе пред- мет с наивысшей стоимостью на единицу массы**. + +3. Если оставшейся вместимости рюкзака недостаточно, использовать часть текущего предмета для заполнения рюкзака. + +Номер Масса Стои- + +![](ru/docs/assets/media/image1042.jpeg)мость + +> Стоимость на ед. +> +> массы Сортировка по +> +> убыванию стоимости на ед. массы +> +> **Жадная стратегия:** +> +> В первую очередь выбирать предметы с более высокой стоимостью на ед. массы +> +> **Рис. 15.5.** Жадная стратегия для задачи о дробном рюкзаке + +##### Код реализации + +> Создадим класс предметов Item, чтобы можно было сортировать предметы по удельной стоимости. Будем циклически выполнять жадный выбор, если рюк- зак заполнен, выход из цикла и возврат решения. +> +> \# === 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*). + +##### Доказательство корректности + +> Используем метод доказательства от противного. Предположим, что предмет *x* -- это предмет с наивысшей удельной стоимостью, и некоторый алгоритм на- шел максимальную ценность res, но это решение не включает предмет *x*. +> +> Извлечем из рюкзака любой предмет с единичной массой и заменим его на предмет *x* с той же массой. Поскольку предмет *x* обладает наибольшей удельной стоимостью, общая стоимость после замены будет больше, чем res. **Это противоречит тому**, **что** res **является оптимальным решением**, **сле- довательно**, **в оптимальном решении обязательно должен присутство- вать предмет** *x*. +> +> Для других предметов в этом решении также можно построить аналогичное противоречие. В итоге **предметы с большей удельной стоимостью всегда являются более предпочтительным выбором**, что подтверждает эффектив- ность жадной стратегии. +> +> Если рассматривать массу предметов и их удельную стоимость как оси двухмерной диаграммы, то задачу о дробном рюкзаке можно преобразо- вать в нахождение максимальной площади, ограниченной конечным ин- тервалом по горизонтальной оси, как показано на рис. 15.6. Это сравнение помогает понять эффективность жадной стратегии с геометрической точ- ки зрения. + +![](ru/docs/assets/media/image1044.jpeg) + +> **Рис. 15.6.** Геометрическое представление задачи о дробном рюкзаке + +#### задача о максимальной вместимости + +> ![](ru/docs/assets/media/image1046.jpeg) +> +> **Рис. 15.7.** Пример данных для задачи о максимальной вместимости +> +> Контейнер образуется любыми двумя перегородками, **поэтому состоя- ние задачи определяется индексами двух перегородок, обозначим ее как** \[*i*, *j*\]. +> +> Согласно условию вместимость равна произведению высоты на ширину, где высота определяется более короткой перегородкой, а ширина -- разницей ин- дексов двух перегородок в массиве. Обозначим вместимость как *cap*\[*i*, *j*\], тогда формула для расчета будет следующей: +> +> 𝑐𝑎𝑝\[𝑖, 𝑗\] = min(ℎ𝑡\[𝑖\] , ℎ𝑡\[𝑗\] ) × (𝑗 − 𝑖). + +n + +##### Определение жадной стратегии + +> Для этой задачи существует более эффективное решение. Выберем состояние \[*i*, *j*\], которое удовлетворяет условиям *i* \< *j* и *ht*\[*i*\] \< *ht*\[*j*\], т. е. *i* является короткой перегородкой, а *j* -- длинной, как показано на рис. 15.8. +> +> **Если в этот момент переместить длинную перегородку** *j* **ближе к ко- роткой** *i*, **вместимость обязательно уменьшится**, как показано на рис. 15.9. +> +> Это происходит потому, что после перемещения длинной перегородки *j* ширина *j* -- *i* обязательно уменьшится. Высота же определяется короткой перегородкой, поэтому высота может остаться прежней (*i* остается короткой перегородкой) либо уменьшиться (перемещенная *j* становится короткой пе- регородкой). +> +> ![](ru/docs/assets/media/image1048.jpeg) +> +> **Рис. 15.8.** Начальное состояние + +![](ru/docs/assets/media/image1050.jpeg) + +> **Рис. 15.9.** Состояние после перемещения длинной перегородки внутрь +> +> Обратное рассуждение: **увеличить вместимость можно только переме- щая короткую перегородку** *i* **внутрь**. Хотя ширина обязательно уменьшится, **высота может увеличиться** (перемещенная короткая перегородка *i* может стать длиннее). Например, на рис. 15.10 после перемещения короткой пере- городки площадь увеличивается. +> +> Таким образом, можно сформулировать жадную стратегию для этой задачи: инициализировать два указателя, расположив их по краям контейнера, и на каждом шаге перемещать указатель, соответствующий короткой перегородке, внутрь, пока указатели не встретятся. +> +> ![](ru/docs/assets/media/image1052.jpeg) +> +> **Рис. 15.10.** Состояние после перемещения короткой перегородки внутрь +> +> На рис. 15.11 демонстрируется этот процесс выполнения жадной стратегии. + +1. В начальном состоянии указатели *i* и *j* расположены по краям массива. + +2. Вычисление вместимости текущего состояния *cap*\[*i*, *j*\] и обновление мак- симальной вместимости. + +3. Сравнение высот перегородок *i* и *j* и перемещение короткой перегород- ки на одну позицию внутрь. + +4. Повторение шагов 2 и 3 до тех пор, пока *i* и *j* не встретятся. + +![](ru/docs/assets/media/image1054.jpeg) + +> **Рис. 15.11.** Жадный алгоритм для задачи о максимальной вместимости. Шаг 1 +> +> ![](ru/docs/assets/media/image1056.jpeg) + +![](ru/docs/assets/media/image1058.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 2--3 +> +> ![](ru/docs/assets/media/image1060.jpeg) + +![](ru/docs/assets/media/image1062.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 4--5 +> +> ![](ru/docs/assets/media/image1064.jpeg) + +![](ru/docs/assets/media/image1066.jpeg) + +> **Рис. 15.11.** *Продолжение*. Шаг 6--7 +> +> ![](ru/docs/assets/media/image1068.jpeg) + +![](ru/docs/assets/media/image1070.jpeg) + +> **Рис. 15.11.** *Окончание*. Шаг 8--9 + +##### Код реализации + +> Цикл выполняется не более *n* раз, поэтому **временная сложность состав- ляет** *O*(*n*). +> +> Переменные *i*, *j*, *res* используют дополнительное пространство постоянного размера, поэтому **пространственная сложность равна** *O*(1). +> +> \# === 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 + +##### Доказательство корректности + +> Жадный алгоритм быстрее перебора, потому что каждое жадное решение про- пускает некоторые состояния. +> +> Например, имеется состояние *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* **перемещает- ся внутрь**. Ранее было доказано, что перемещение длинной доски внутрь обя- зательно приведет к уменьшению емкости. Это означает, что пропущенные состояния не могут быть оптимальным решением, и **их пропуск не приведет к упущению оптимального решения**. +> +> Этот анализ показывает, что операция перемещения короткой перегородки является безопасной, и жадная стратегия эффективна. +> +> ![](ru/docs/assets/media/image1072.jpeg) +> +> **Рис. 15.12.** Перемещение короткой перегородки приводит к пропущенным состояниям + +#### задача о максимальном произведении разбиения + +![](ru/docs/assets/media/image1074.jpeg) + +> **Рис. 15.13.** Определение задачи о максимальном произведении разбиения +> +> Предположим, что мы разложили *n* на *m* целых множителей, где *i*-й множи- тель обозначен как *ni*, т. е.: + +***m*** + +> ***n*** = Ι*ni* . +> +> ***i*** =1 +> +> Цель данной задачи -- найти максимальное произведение всех целых мно- жителей, т. е.: +> +> max= ( ∉***m** n* 1. +> +>   ***i***   +> +>   ***i***  1   +> +> Необходимо решить вопрос: насколько велико должно быть количество раз- биений *m* и каковы должны быть значения каждого *ni*? + +##### Определение жадной стратегии + +> Эмпирический факт заключается в том, произведение двух чисел часто больше их суммы. Предположим, что из *n* выделяется множитель 2, тогда итоговое про- изведение равно 2(*n* − 2). Сравним это произведение с *n*: +> +> 2(*n* − 2) ≥ *n* +> +> 2 *n* − *n* − 4 ≥ 0 +> +> *n* ≥ 4. +> +> Когда *n* ≥ 4, выделение множителя 2 увеличивает произведение, как пока- зано на рис. 15.14. Это означает, что **целые числа, равные или большие** 4, **необходимо раскладывать на несколько множителей**. +> +> **Жадная стратегия 1**: если в схеме разбиения присутствует множитель ≥ 4, то его следует продолжать раскладывать. В окончательной схеме разбиения должны присутствовать только множители 1, 2, 3. + +![](ru/docs/assets/media/image1076.jpeg) + +> **Рис. 15.14.** Разбиение увеличивает произведение +> +> Далее следует обдумать, какой множитель является оптимальным. Сре- ди множителей 1, 2, 3 очевидно, что 1 -- наихудший, поскольку неравенство 1 × (*n* − 1) \< *n* всегда верно, т. е. выделение 1 приведет к уменьшению произ- ведения. +> +> Если *n* = 6, 3 × 3 \> 2 × 2 × 2, **значит разбиение на тройки предпочтительнее разбиения на двойки**, см рис. 15.15. +> +> **Жадная стратегия 2**: в схеме разбиения должно быть не более двух зна- чений 2, поскольку три 2 всегда можно заменить двумя 3 и получить большее произведение. + +![](ru/docs/assets/media/image1078.jpeg) + +> **Рис. 15.15.** Оптимальные множители разбиения +> +> Таким образом, можно вывести общую жадную стратегию. + +1. Задать целое число *n* и выделять из него множитель 3 до тех пор, пока остаток не станет 0, 1 или 2. + +2. Если остаток равен 0, значит *n* кратно 3, и дальнейшие действия не тре- буются. + +3. Если остаток равен 2, не продолжать разбиение, оставить как есть. + +4. Если остаток равен 1, то, поскольку 2 × 2 \> 1 × 3, следует заменить послед- ний множитель 3 на 2. + +##### Код реализации + +> Из рис. 15.16 видно, что для разбиения числа нет необходимости использовать цикл. Можно воспользоваться операцией целочисленного деления вниз для получения количества троек *a*, а также операцией взятия остатка для получе- ния остатка *b*, в этом случае: +> +> *n* = 3*a* + *b*. +> +> Обратите внимание, что для граничных случаев, когда *n* ≤ 3, необходимо вы- делить множитель 1, произведение будет равно 1 × (*n* − 1). +> +> \# === 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)) + +![](ru/docs/assets/media/image1080.png) + +> **Рис. 15.16.** Метод вычисления максимального произведения разбиения +> +> **Временная сложность зависит от метода реализации операции возве- дения в степень в языке программирования**. Для Python обычно использу- ются три функции для вычисления степени. + +- Оператор \*\* и функция pow() имеют временную сложность *O*(log *a*). + +- Функция math.pow() вызывает функцию pow() из библиотеки C, выполняющую возведение в степень с плавающей точкой c временной сложностью O(1). + +> Переменные *a* и *b* используют дополнительное пространство постоянного размера, поэтому **пространственная сложность составляет** *O*(1). + +##### Доказательство корректности + +> Используем метод от противного и проанализируем только случай *n* ≥ 3. + +1. **Все множители** ≤ 3: предположим, что в оптимальной схеме разбиения существует множитель ≥ 4, тогда его можно разложить на 2(*x* − 2) и полу- чить большее произведение. Это противоречит предположению. + +2. **Схема разбиения не содержит** 1: предположим, что в оптимальной схеме разбиения существует множитель 1, тогда его можно объединить + +> с другим множителем и получить большее произведение. Это противо- речит предположению. + +3. **Максимальное количество двоек в разбиении равно** 2: предполо- жим, что в оптимальном разбиении содержатся три двойки, тогда их можно заменить на две тройки и получить большее произведение. Это противоречит предположению. + +#### резюме + +- Жадные алгоритмы обычно применяются для решения задач оптимиза- ции. Их принцип заключается в том, чтобы на каждом этапе принятия решения делать локально оптимальный выбор с целью получения гло- бально оптимального решения. + +- В жадных алгоритмах циклически выполняются жадные выборы, каж- дый раз превращая задачу в меньшую подзадачу, пока задача не будет решена. + +- Жадные алгоритмы не только просты в реализации, но и обладают высо- кой эффективностью решения. По сравнению с динамическим програм- мированием временная сложность жадных алгоритмов обычно ниже. + +- В задаче о размене монет для некоторых комбинаций монет жадный ал- горитм может гарантировать нахождение оптимального решения. Но для других комбинаций жадный алгоритм может найти очень плохое решение. + +- Задачи, подходящие для решения жадными алгоритмами, обладают двумя основными свойствами: свойство жадного выбора и оптимальная подструктура. Свойство жадного выбора свидетельствует об эффектив- ности жадной стратегии. + +- Для некоторых сложных задач доказательство свойства жадного выбо- ра является сложной задачей. Относительно проще найти контрпример и опровергнуть это свойство, например в задаче о размене монет. + +- Решение жадных задач обычно включает три этапа: анализ задачи, опре- деление жадной стратегии, доказательство корректности. Среди них ключевым этапом является определение жадной стратегии, а доказа- тельство корректности часто представляет собой сложную задачу. + +- Задача о дробном рюкзаке, в отличие от задачи о рюкзаке 0-1, позволяет выбирать часть предметов, поэтому ее можно решить с помощью жадно- го алгоритма. Корректность жадной стратегии можно доказать методом от противного. + +- Задачу о максимальной вместимости можно решить методом перебо- ра, временная сложность которого составляет *O*(*n*2). Разработав жадную стратегию, в которой на каждом шаге граница перемещается внутрь, временную сложность можно оптимизировать до *O*(*n*). + +- В задаче о максимальном произведении разбиения мы последователь- но формулируем две жадные стратегии. Во-первых, для целых чисел ≥ 4 нужно продолжать разбиение. Во-вторых, оптимальным множителем разбиения является 3. В коде содержатся операции возведения в сте- пень, временная сложность которых зависит от метода их реализации и обычно составляет *O*(1) или *O*(log *n*). + +> Глава 16 + +# Приложение + +![](ru/docs/assets/media/image1081.jpeg){width="4.072915573053368in" height="5.270833333333333in"} + +###### Содержание главы + +1. Установка программной среды + +2. Совместная разработка + +3. Глоссарий + +#### Установка программной среды + +1. **Установка IDE** + +> Рекомендуется использовать открытую и быструю интегрированную среду разработки (IDE) VS Code. Откройте официальный сайт VS Code и выберите подходящую вашей операционной системе версию для загрузки и установки. + +![](ru/docs/assets/media/image1083.jpeg) + +> **Рис. 16.1.** Загрузка VS Code с официального сайта +> +> VS Code обладает мощной экосистемой расширений и поддерживает выпол- нение и отладку большинства языков программирования. Например, после установки расширения Python Extension Pack можно выполнять отладку кода на Python. Этапы установки показаны на рис. 16.2. + +1. ![](ru/docs/assets/media/image1085.jpeg)Установка программной среды ❖ **485** + +> **Рис. 16.2.** Установка расширений в VS Code + +### Установка языковой среды + +##### Среда Python + +1. Загрузите и установите инструмент Miniconda3, требуется Python 3.10 или более поздняя версия. + +2. В магазине расширений VS Code выполните поиск по слову python и уста- новите расширение Python Extension Pack. + +3. (Не обязательно) Введите в командной строке pip install black для уста- новки инструмента форматирования кода. + +##### Среда C/C++ + +1. В системе Windows необходимо установить набор инструментов MinGW (руководство по настройке). В MacOS имеется встроенный компилятор Clang, дополнительная установка не требуется. + +2. В магазине расширений VS Code выполните поиск по слову c++ и устано- вите расширение C/C++ Extension Pack. + +3. (Не обязательно) Откройте страницу настроек, найдите параметр фор- матирования кода Clang_format_fallback Style и установите его в значе- ние { BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }. + +##### Среда Java + +1. Загрузите и установите OpenJDK (версия \> JDK 9). + +2. В магазине расширений VS Code выполните поиск по слову java и уста- новите расширение Extension Pack for Java. + +##### Среда C# + +1. Загрузите и установите .Net 8.0. + +2. В магазине расширений VS Code выполните поиск по фразе C# Dev Kit + +> и установите расширение C# Dev Kit (руководство по настройке). + +3. Также можно использовать интегрированную среду разработки Visual Studio (руководство по установке). + +##### Среда Go + +1. Загрузите и установите Go. + +2. В магазине расширений VS Code выполните поиск по слову go и устано- вите Go. + +3. Нажмите сочетание клавиш Ctrl + Shift + P, чтобы открыть командную строку. Введите команду go, выберите Go: Install/Update Tools, отметьте все и установите. + +##### Среда Swift + +1. Загрузите и установите Swift. + +2. В магазине расширений VS Code выполните поиск по слову swift и уста- новите расширение Swift for Visual Studio Code. + +##### Среда JavaScript + +1. Загрузите и установите Node.js. + +2. (Не обязательно) В магазине расширений VS Code выполните поиск по слову Prettier и установите инструмент форматирования кода. + +##### Среда TypeScript + +1. Выполните шаги установки для среды JavaScript. + +2. Установите TypeScript Execute (tsx). + +3. В магазине расширений VS Code выполните поиск по слову typescript + +> и установите расширение Pretty TypeScript Errors. + +##### Среда Dart + +1. Загрузите и установите Dart. + +2. В магазине расширений VS Code выполните поиск по слову dart и уста- новите расширение Dart. + +##### Среда Rust + +1. Загрузите и установите Rust. + +2. В магазине расширений VS Code выполните поиск по слову rust и уста- новите расширение rust-analyzer. + +#### Совместная разработка + +> Ввиду ограниченных возможностей автора в книге неизбежно присутству- ют некоторые упущения и ошибки, просим отнестись к этому с пониманием. Если вы обнаружите опечатки, неработающие ссылки, неполное содержание, + +2. Совместная разработка ❖ **487** + +> двусмысленности в тексте, неясные объяснения или нерациональную структу- ру изложения, пожалуйста, помогите нам в исправлении, чтобы предоставить читателям более качественные учебные ресурсы. +> +> Все идентификаторы GitHub авторов будут представлены на странице ре- позитория книги, в веб-версии и PDF-версии в знак благодарности за их бес- корыстный вклад в сообщество с открытым исходным кодом. + +##### Небольшая корректировка содержимого + +> В правом верхнем углу каждой страницы есть значок редактирования, как по- казано на рис. 16.3. Следуйте следующим шагам для изменения текста или кода. + +1. Нажмите на значок редактирования. Если появится сообщение «Необходи- мо создать ответвление этого репозитория», согласитесь на это действие. + +2. Измените содержимое исходного файла Markdown, проверьте правиль- ность содержания и постарайтесь сохранить единый формат оформления. + +3. В нижней части страницы заполните описание изменений, затем на- жмите кнопку **Propose file change** (предложить изменение файла). После перехода на следующую страницу нажмите кнопку **Create pull request** (Создать запрос на слияние), чтобы инициировать запрос на слияние. + +![](ru/docs/assets/media/image1087.jpeg) + +> **Рис. 16.3.** Кнопка редактирования страницы +> +> Изображения нельзя изменить напрямую, необходимо создать новую Issue (Задачу) или оставить комментарий для описания проблемы. Мы как можно быстрее изменим и обновим изображение. + +##### Создание содержимого + +> Если вы заинтересованы в участии в этом проекте с открытым исходным ко- дом, включая перевод кода на другие языки программирования, расширение содержания статей и т. д., необходимо выполнить следующий рабочий процесс Pull Request (Запрос на слияние). + +1. Войдите в GitHub, создайте ответвление хранилища кода книги в свой личный аккаунт. + +2. Перейдите на страницу ответвления и используйте команду git clone + +> для клонирования хранилища на локальный компьютер. + +3. На локальном компьютере создайте содержимое и проведите полное те- стирование, чтобы проверить правильность кода. + +4. Зафиксируйте изменения, сделанные локально, затем отправьте их в удаленное хранилище. + +5. Обновите страницу хранилища и нажмите кнопку **Create pull request** + +> (Создать запрос на слияние), чтобы инициировать запрос на слияние. + +##### Развертывание Docker + +> В корневом каталоге hello-algo выполните следующий сценарий Docker, чтобы настроить доступ к проекту по адресу http://localhost:8000: +> +> docker-compose up -d +> +> Для удаления развертывания выполните следующую команду: +> +> docker-compose down + +#### Глоссарий + +> В табл. 16.1 приведен англо-русский словарь важных терминов, встречающих- ся в книге. Он поможет вам в чтении англоязычной литературы. +> +> **Таблица 16.1.** Англо-русский словарь терминов 1's complement обратный код +> +> 2's complement дополнительный код +> +> adjacency смежность +> +> adjacency list список смежности +> +> adjacency matrix матрица смежности +> +> algorithm алгоритм +> +> array массив +> +> asymptotic complexity analysis асимптотический анализ сложности +> +> 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 задача расстояния редактирования +> +> 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 сортировка слиянием +> +> 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 временная сложность +> +> 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**](http://www.dmkpress.com/) +> +> с доставкой в любой регион РФ. + +##### Цзинь Юйдун (@krahets) + +> **Алгоритмы и структуры данных с примерами на Python** +> +> Главный редактор *Мовчан Д. А.* +> +> Зам. главного редактора *Яценков В. С.* +> +> +> +> Перевод *Шевкун И. А.* +> +> Корректор *Абросимова Л. А.* +> +> Верстка *Луценко С. В.* +> +> Дизайн обложки *Трофимова С. В.* +> +> Формат 70×100 1/16. +> +> Гарнитура «PT Serif». Печать цифровая. +> +> Усл. печ. л. 40,14. Тираж 100 экз. +> +> Веб-сайт издательства: [www.dmkpress.com](http://www.dmkpress.com/)