update russian v2

This commit is contained in:
krahets
2026-03-25 16:54:42 +08:00
parent 036b41b5cb
commit d2da1b4a83
20 changed files with 25011 additions and 0 deletions

337
ru/chapters/chapter_00.md Normal file
View File

@@ -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

169
ru/chapters/chapter_01.md Normal file
View File

@@ -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

1081
ru/chapters/chapter_02.md Normal file

File diff suppressed because it is too large Load Diff

797
ru/chapters/chapter_03.md Normal file
View File

@@ -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^E127^ ⌡(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 = 22 + 23 = 0.375, тогда:
>
> val = (1)^0^ ⌡ 2^124127^ ⌡(1 + 0.375) = 0.171875.
>
> Теперь можно ответить на первоначальный вопрос: **представление типа** float **включает показатель степени, поэтому его диапазон значений зна- чительно больше, чем у** int. Согласно вышеуказанным вычислениям макси- мальное положительное число, которое может быть представлено float, равно 2254 127 × (2 223) ≈ 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 × 2126 × (0.N) |
+--------------------+----------------------+--------------------------+----------------------------+
| > 1, 2, \..., 254 | > Нормально число | > Нормально число | > (1)S × 2(E127) × (1.N) |
+--------------------+----------------------+--------------------------+----------------------------+
| > 255 | > ±∞ | > NaN | |
+--------------------+----------------------+--------------------------+----------------------------+
> Стоит отметить, что слабо нормальные числа значительно повышают точ- ность чисел с плавающей запятой. Минимальное положительное нормальное число равно 2126, минимальное положительное слабо нормальное число равно 2126 × 223.
>
> В числах с двойной точностью 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

775
ru/chapters/chapter_04.md Normal file
View File

@@ -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

797
ru/chapters/chapter_05.md Normal file
View File

@@ -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

807
ru/chapters/chapter_06.md Normal file
View File

@@ -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)
>
> \# Хеш-значение объекта \<ListNode object at 0x1058fd810\> равно 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

1079
ru/chapters/chapter_07.md Normal file

File diff suppressed because it is too large Load Diff

437
ru/chapters/chapter_08.md Normal file
View File

@@ -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

547
ru/chapters/chapter_09.md Normal file
View File

@@ -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

543
ru/chapters/chapter_10.md Normal file
View File

@@ -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

1192
ru/chapters/chapter_11.md Normal file

File diff suppressed because it is too large Load Diff

511
ru/chapters/chapter_12.md Normal file
View File

@@ -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

861
ru/chapters/chapter_13.md Normal file
View File

@@ -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

1415
ru/chapters/chapter_14.md Normal file

File diff suppressed because it is too large Load Diff

493
ru/chapters/chapter_15.md Normal file
View File

@@ -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

441
ru/chapters/chapter_16.md Normal file
View File

@@ -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**
>
> Главный редактор *Мовчан Д. А.*
>
> Зам. главного редактора *Яценков В. С.*
>
> <editor@dmkpress.com>
>
> Перевод *Шевкун И. А.*
>
> Корректор *Абросимова Л. А.*
>
> Верстка *Луценко С. В.*
>
> Дизайн обложки *Трофимова С. В.*
>
> Формат 70×100 1/16.
>
> Гарнитура «PT Serif». Печать цифровая.
>
> Усл. печ. л. 40,14. Тираж 100 экз.
>
> Веб-сайт издательства: [www.dmkpress.com](http://www.dmkpress.com/)