Add ru version (#1865)

* Add Russian docs site baseline

* Add Russian localized codebase

* Polish Russian code wording

* Update ru code translation.

* Update code translation and chapter covers.

* Fix pythontutor extraction.

* Add README and landing page.

* placeholder of profiles

* Use figures of English version

* Remove chapter paperbook
This commit is contained in:
Yudong Jin
2026-03-28 04:24:07 +08:00
committed by GitHub
parent 2ca570cc33
commit 772183705e
1958 changed files with 108186 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+47
View File
@@ -0,0 +1,47 @@
# Присоединяйтесь к созданию книги
Возможности автора ограничены, поэтому в книге неизбежно могут встречаться упущения и ошибки. Просим отнестись к этому с пониманием. Если вы заметите опечатки, неработающие ссылки, пропуски в содержании, двусмысленные формулировки, неясные объяснения или неудачную структуру изложения, пожалуйста, помогите нам это исправить, чтобы читатели получили более качественный учебный ресурс.
GitHub ID всех [участников](https://github.com/krahets/hello-algo/graphs/contributors) будут указаны на главных страницах репозитория книги, веб-версии и PDF-версии в знак благодарности за их бескорыстный вклад в сообщество открытого исходного кода.
!!! success "Сила открытого исходного кода"
Интервал между двумя тиражами бумажной книги обычно довольно велик, поэтому обновлять содержание очень неудобно.
В этой же открытой книге цикл обновления содержания сокращается до нескольких дней, а иногда даже до нескольких часов.
### Небольшие правки содержания
Как показано на рисунке ниже, в правом верхнем углу каждой страницы есть "значок редактирования". Вы можете изменить текст или код следующим образом.
1. Нажмите на "значок редактирования". Если появится сообщение "You need to fork this repository", согласитесь с этим действием.
2. Измените содержимое исходного Markdown-файла, проверьте корректность правок и постарайтесь сохранить единый стиль оформления.
3. Внизу страницы заполните описание изменений, затем нажмите кнопку "Propose file change". После перехода на следующую страницу нажмите кнопку "Create pull request", чтобы создать pull request.
![Кнопка редактирования страницы](contribution.assets/edit_markdown.png)
Изображения нельзя изменить напрямую, поэтому проблему с ними нужно описывать через новый [Issue](https://github.com/krahets/hello-algo/issues) или комментарий. Мы постараемся как можно быстрее перерисовать и заменить изображение.
### Создание содержания
Если вам интересно участвовать в этом проекте с открытым исходным кодом, например переводить код на другие языки программирования или расширять содержание статей, то следует придерживаться следующего рабочего процесса Pull Request.
1. Войдите в GitHub и сделайте Fork [репозитория книги](https://github.com/krahets/hello-algo) в свой личный аккаунт.
2. Перейдите на страницу своего Fork-репозитория и с помощью команды `git clone` клонируйте репозиторий локально.
3. Создавайте и редактируйте содержание локально, затем проведите полное тестирование и проверьте корректность кода.
4. Сделайте Commit для локальных изменений, после чего выполните Push в удаленный репозиторий.
5. Обновите страницу репозитория и нажмите кнопку "Create pull request", чтобы отправить pull request.
### Развертывание Docker
В корневом каталоге `hello-algo` выполните следующий Docker-скрипт, после чего проект будет доступен по адресу `http://localhost:8000`:
```shell
docker-compose up -d
```
Удалить развертывание можно следующей командой:
```shell
docker-compose down
```
+3
View File
@@ -0,0 +1,3 @@
# Приложение
![Приложение](../assets/covers/chapter_appendix.jpg)
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+68
View File
@@ -0,0 +1,68 @@
# Установка среды программирования
## Установка IDE
В качестве локальной интегрированной среды разработки (IDE) рекомендуется использовать открытую и легковесную VS Code. Перейдите на [официальный сайт VS Code](https://code.visualstudio.com/), выберите версию для своей операционной системы и установите ее.
![Загрузка VS Code с официального сайта](installation.assets/vscode_installation.png)
VS Code обладает мощной экосистемой расширений и поддерживает запуск и отладку большинства языков программирования. Например, после установки расширения "Python Extension Pack" можно отлаживать код на Python. Процесс установки показан на рисунке ниже.
![Установка расширений VS Code](installation.assets/vscode_extension_installation.png)
## Установка языковой среды
### Среда Python
1. Загрузите и установите [Miniconda3](https://docs.conda.io/en/latest/miniconda.html), требуется Python 3.10 или более новая версия.
2. В магазине расширений VS Code найдите `python` и установите Python Extension Pack.
3. (Необязательно) Введите в командной строке `pip install black`, чтобы установить инструмент форматирования кода.
### Среда C/C++
1. В Windows требуется установить [MinGW](https://sourceforge.net/projects/mingw-w64/files/) ([руководство по настройке](https://blog.csdn.net/qq_33698226/article/details/129031241)); в macOS Clang уже установлен по умолчанию.
2. В магазине расширений VS Code найдите `c++` и установите C/C++ Extension Pack.
3. (Необязательно) Откройте страницу Settings, найдите параметр форматирования `Clang_format_fallback Style` и задайте значение `{ BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }`.
### Среда Java
1. Загрузите и установите [OpenJDK](https://jdk.java.net/18/) (требуемая версия: > JDK 9).
2. В магазине расширений VS Code найдите `java` и установите Extension Pack for Java.
### Среда C#
1. Загрузите и установите [.Net 8.0](https://dotnet.microsoft.com/en-us/download).
2. В магазине расширений VS Code найдите `C# Dev Kit` и установите C# Dev Kit ([руководство по настройке](https://code.visualstudio.com/docs/csharp/get-started)).
3. Также можно использовать Visual Studio ([руководство по установке](https://learn.microsoft.com/zh-cn/visualstudio/install/install-visual-studio?view=vs-2022)).
### Среда Go
1. Загрузите и установите [go](https://go.dev/dl/).
2. В магазине расширений VS Code найдите `go` и установите Go.
3. Нажмите `Ctrl + Shift + P`, чтобы открыть командную палитру, введите `go`, выберите `Go: Install/Update Tools`, отметьте все инструменты и установите их.
### Среда Swift
1. Загрузите и установите [Swift](https://www.swift.org/download/).
2. В магазине расширений VS Code найдите `swift` и установите [Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang).
### Среда JavaScript
1. Загрузите и установите [Node.js](https://nodejs.org/en/).
2. (Необязательно) В магазине расширений VS Code найдите `Prettier` и установите инструмент форматирования кода.
### Среда TypeScript
1. Выполните те же шаги, что и для среды JavaScript.
2. Установите [TypeScript Execute (tsx)](https://github.com/privatenumber/tsx?tab=readme-ov-file#global-installation).
3. В магазине расширений VS Code найдите `typescript` и установите [Pretty TypeScript Errors](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors).
### Среда Dart
1. Загрузите и установите [Dart](https://dart.dev/get-dart).
2. В магазине расширений VS Code найдите `dart` и установите [Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code).
### Среда Rust
1. Загрузите и установите [Rust](https://www.rust-lang.org/tools/install).
2. В магазине расширений VS Code найдите `rust` и установите [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
+137
View File
@@ -0,0 +1,137 @@
# Глоссарий
В таблице ниже перечислены важные термины, встречающиеся в книге. Обратите внимание на следующие моменты.
- Рекомендуем запомнить английские названия терминов, чтобы легче читать англоязычную литературу.
- В русской версии третий столбец дублирует основной перевод, чтобы сохранить единый формат таблицы.
<p align="center"> Таблица <id> &nbsp; Важные термины по структурам данных и алгоритмам </p>
| English | Русский | Русский |
| ------------------------------ | ------------------------------ | ------------------------------ |
| algorithm | алгоритм | алгоритм |
| data structure | структура данных | структура данных |
| code | код | код |
| file | файл | файл |
| function | функция | функция |
| method | метод | метод |
| variable | переменная | переменная |
| asymptotic complexity analysis | асимптотический анализ сложности | асимптотический анализ сложности |
| time complexity | временная сложность | временная сложность |
| space complexity | пространственная сложность | пространственная сложность |
| loop | цикл | цикл |
| iteration | итерация | итерация |
| recursion | рекурсия | рекурсия |
| tail recursion | хвостовая рекурсия | хвостовая рекурсия |
| recursion tree | дерево рекурсии | дерево рекурсии |
| big-$O$ notation | нотация big-$O$ | нотация big-$O$ |
| asymptotic upper bound | асимптотическая верхняя граница | асимптотическая верхняя граница |
| sign-magnitude | прямой код | прямой код |
| 1s complement | обратный код | обратный код |
| 2s complement | дополнительный код | дополнительный код |
| array | массив | массив |
| index | индекс | индекс |
| linked list | связный список | связный список |
| linked list node, list node | узел связного списка | узел связного списка |
| head node | головной узел | головной узел |
| tail node | хвостовой узел | хвостовой узел |
| list | список | список |
| dynamic array | динамический массив | динамический массив |
| hard disk | жесткий диск | жесткий диск |
| random-access memory (RAM) | оперативная память | оперативная память |
| cache memory | кеш-память | кеш-память |
| cache miss | промах кеша | промах кеша |
| cache hit rate | коэффициент попадания в кеш | коэффициент попадания в кеш |
| stack | стек | стек |
| top of the stack | вершина стека | вершина стека |
| bottom of the stack | основание стека | основание стека |
| queue | очередь | очередь |
| double-ended queue | двусторонняя очередь | двусторонняя очередь |
| front of the queue | голова очереди | голова очереди |
| rear of the queue | хвост очереди | хвост очереди |
| hash table | хеш-таблица | хеш-таблица |
| hash set | хеш-набор | хеш-набор |
| bucket | корзина | корзина |
| hash function | хеш-функция | хеш-функция |
| hash collision | хеш-коллизия | хеш-коллизия |
| load factor | коэффициент заполнения | коэффициент заполнения |
| separate chaining | цепная адресация | цепная адресация |
| open addressing | открытая адресация | открытая адресация |
| linear probing | линейное зондирование | линейное зондирование |
| lazy deletion | ленивое удаление | ленивое удаление |
| binary tree | двоичное дерево | двоичное дерево |
| tree node | узел дерева | узел дерева |
| left-child node | левый дочерний узел | левый дочерний узел |
| right-child node | правый дочерний узел | правый дочерний узел |
| parent node | родительский узел | родительский узел |
| left subtree | левое поддерево | левое поддерево |
| right subtree | правое поддерево | правое поддерево |
| root node | корневой узел | корневой узел |
| leaf node | листовой узел | листовой узел |
| edge | ребро | ребро |
| level | уровень | уровень |
| degree | степень | степень |
| height | высота | высота |
| depth | глубина | глубина |
| perfect binary tree | идеальное двоичное дерево | идеальное двоичное дерево |
| complete binary tree | совершенное двоичное дерево | совершенное двоичное дерево |
| full binary tree | полное двоичное дерево | полное двоичное дерево |
| balanced binary tree | сбалансированное двоичное дерево | сбалансированное двоичное дерево |
| binary search tree | двоичное дерево поиска | двоичное дерево поиска |
| AVL tree | АВЛ-дерево | АВЛ-дерево |
| red-black tree | красно-черное дерево | красно-черное дерево |
| level-order traversal | обход по уровням | обход по уровням |
| breadth-first traversal | обход в ширину | обход в ширину |
| depth-first traversal | обход в глубину | обход в глубину |
| binary search tree | двоичное дерево поиска | двоичное дерево поиска |
| balanced binary search tree | сбалансированное двоичное дерево поиска | сбалансированное двоичное дерево поиска |
| balance factor | фактор баланса | фактор баланса |
| heap | куча | куча |
| max heap | максимальная куча | максимальная куча |
| min heap | минимальная куча | минимальная куча |
| priority queue | приоритетная очередь | приоритетная очередь |
| heapify | упорядочивание кучи | упорядочивание кучи |
| top-$k$ problem | поиск $k$ наибольших элементов | поиск $k$ наибольших элементов |
| graph | граф | граф |
| vertex | вершина | вершина |
| undirected graph | неориентированный граф | неориентированный граф |
| directed graph | ориентированный граф | ориентированный граф |
| connected graph | связный граф | связный граф |
| disconnected graph | несвязный граф | несвязный граф |
| weighted graph | взвешенный граф | взвешенный граф |
| adjacency | смежность | смежность |
| path | путь | путь |
| in-degree | входящая степень | входящая степень |
| out-degree | исходящая степень | исходящая степень |
| adjacency matrix | матрица смежности | матрица смежности |
| adjacency list | список смежности | список смежности |
| breadth-first search | поиск в ширину | поиск в ширину |
| depth-first search | поиск в глубину | поиск в глубину |
| binary search | двоичный поиск | двоичный поиск |
| searching algorithm | алгоритм поиска | алгоритм поиска |
| sorting algorithm | алгоритм сортировки | алгоритм сортировки |
| selection sort | сортировка выбором | сортировка выбором |
| bubble sort | сортировка пузырьком | сортировка пузырьком |
| insertion sort | сортировка вставкой | сортировка вставкой |
| quick sort | быстрая сортировка | быстрая сортировка |
| merge sort | сортировка слиянием | сортировка слиянием |
| heap sort | пирамидальная сортировка | пирамидальная сортировка |
| bucket sort | блочная сортировка | блочная сортировка |
| counting sort | сортировка подсчетом | сортировка подсчетом |
| radix sort | поразрядная сортировка | поразрядная сортировка |
| divide and conquer | разделяй и властвуй | разделяй и властвуй |
| hanota problem | задача о Ханойской башне | задача о Ханойской башне |
| backtracking algorithm | алгоритм поиска с возвратом | алгоритм поиска с возвратом |
| constraint | ограничение | ограничение |
| solution | решение | решение |
| state | состояние | состояние |
| pruning | отсечение | отсечение |
| permutations problem | задача о перестановках | задача о перестановках |
| subset-sum problem | задача о сумме подмножеств | задача о сумме подмножеств |
| $n$-queens problem | задача о $n$ ферзях | задача о $n$ ферзях |
| dynamic programming | динамическое программирование | динамическое программирование |
| initial state | начальное состояние | начальное состояние |
| state-transition equation | уравнение перехода состояния | уравнение перехода состояния |
| knapsack problem | задача о рюкзаке | задача о рюкзаке |
| edit distance problem | задача о расстоянии редактирования | задача о расстоянии редактирования |
| greedy algorithm | жадный алгоритм | жадный алгоритм |
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,227 @@
# Массив
<u>Массив (array)</u> - это линейная структура данных, которая хранит элементы одного типа в непрерывной области памяти. Положение элемента в массиве называется его <u>индексом (index)</u>. На рисунке ниже показаны основные понятия, связанные с массивом, и способ его хранения.
![Определение массива и способ хранения](array.assets/array_definition.png)
## Основные операции с массивом
### Инициализация массива
В зависимости от задачи мы можем выбрать один из двух способов инициализации массива: без начальных значений или с заданными начальными значениями. Если начальные значения не указаны, большинство языков программирования инициализируют элементы массива значением $0$ :
=== "Python"
```python title="array.py"
# Инициализация массива
arr: list[int] = [0] * 5 # [ 0, 0, 0, 0, 0 ]
nums: list[int] = [1, 3, 2, 5, 4]
```
=== "C++"
```cpp title="array.cpp"
/* Инициализация массива */
// Хранится в стеке
int arr[5];
int nums[5] = { 1, 3, 2, 5, 4 };
// Хранится в куче (требуется ручное освобождение памяти)
int* arr1 = new int[5];
int* nums1 = new int[5] { 1, 3, 2, 5, 4 };
```
=== "Java"
```java title="array.java"
/* Инициализация массива */
int[] arr = new int[5]; // { 0, 0, 0, 0, 0 }
int[] nums = { 1, 3, 2, 5, 4 };
```
=== "C#"
```csharp title="array.cs"
/* Инициализация массива */
int[] arr = new int[5]; // [ 0, 0, 0, 0, 0 ]
int[] nums = [1, 3, 2, 5, 4];
```
=== "Go"
```go title="array.go"
/* Инициализация массива */
var arr [5]int
// В Go указание длины ([5]int) создает массив, а отсутствие длины ([]int) - срез
// Поскольку длина массива в Go определяется на этапе компиляции, для задания длины можно использовать только константы
// Чтобы упростить реализацию метода extend(), ниже будем рассматривать срезы (Slice) как массивы (Array)
nums := []int{1, 3, 2, 5, 4}
```
=== "Swift"
```swift title="array.swift"
/* Инициализация массива */
let arr = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0]
let nums = [1, 3, 2, 5, 4]
```
=== "JS"
```javascript title="array.js"
/* Инициализация массива */
var arr = new Array(5).fill(0);
var nums = [1, 3, 2, 5, 4];
```
=== "TS"
```typescript title="array.ts"
/* Инициализация массива */
let arr: number[] = new Array(5).fill(0);
let nums: number[] = [1, 3, 2, 5, 4];
```
=== "Dart"
```dart title="array.dart"
/* Инициализация массива */
List<int> arr = List.filled(5, 0); // [0, 0, 0, 0, 0]
List<int> nums = [1, 3, 2, 5, 4];
```
=== "Rust"
```rust title="array.rs"
/* Инициализация массива */
let arr: [i32; 5] = [0; 5]; // [0, 0, 0, 0, 0]
let slice: &[i32] = &[0; 5];
// В Rust указание длины ([i32; 5]) создает массив, а отсутствие длины (&[i32]) - срез
// Поскольку длина массива в Rust определяется на этапе компиляции, для задания длины можно использовать только константы
// Vector в Rust обычно используется как динамический массив
// Чтобы упростить реализацию метода extend(), ниже будем рассматривать vector как массив (array)
let nums: Vec<i32> = vec![1, 3, 2, 5, 4];
```
=== "C"
```c title="array.c"
/* Инициализация массива */
int arr[5] = { 0 }; // { 0, 0, 0, 0, 0 }
int nums[5] = { 1, 3, 2, 5, 4 };
```
=== "Kotlin"
```kotlin title="array.kt"
/* Инициализация массива */
var arr = IntArray(5) // { 0, 0, 0, 0, 0 }
var nums = intArrayOf(1, 3, 2, 5, 4)
```
=== "Ruby"
```ruby title="array.rb"
# Инициализация массива
arr = Array.new(5, 0)
nums = [1, 3, 2, 5, 4]
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D0%BC%D0%B0%D1%81%D1%81%D0%B8%D0%B2%0Aarr%20%3D%20%5B0%5D%20%2A%205%20%20%23%20%5B%200%2C%200%2C%200%2C%200%2C%200%20%5D%0Anums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D&cumulative=false&curInstr=0&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Доступ к элементам
Элементы массива хранятся в непрерывной области памяти, а это означает, что вычислить адрес любого элемента очень просто. Зная адрес массива в памяти (то есть адрес первого элемента) и индекс некоторого элемента, мы можем по формуле с рисунка ниже вычислить адрес этого элемента и напрямую обратиться к нему.
![Вычисление адреса элемента массива](array.assets/array_memory_location_calculation.png)
Если посмотреть на рисунок выше, можно заметить, что индекс первого элемента массива равен $0$ , и это кажется не слишком интуитивным, ведь естественнее было бы начинать счет с $1$ . Однако с точки зрения формулы адресации **индекс по сути является смещением относительно адреса памяти**. Смещение первого элемента равно $0$ , поэтому индекс $0$ вполне логичен.
Доступ к элементам массива очень эффективен: любой элемент массива можно получить за $O(1)$ времени.
```src
[file]{array}-[class]{}-[func]{random_access}
```
### Вставка элемента
Элементы массива в памяти расположены "вплотную" друг к другу, и между ними нет места для размещения новых данных. Как показано на рисунке ниже, если мы хотим вставить элемент в середину массива, то все элементы после этой позиции нужно сдвинуть на одну позицию вправо, а затем записать новое значение в освободившийся индекс.
![Пример вставки элемента в массив](array.assets/array_insert_element.png)
Стоит отметить, что длина массива фиксирована, поэтому вставка нового элемента неизбежно приведет к "потере" элемента на конце массива. Решение этой проблемы мы оставим для обсуждения в разделе о "списках".
```src
[file]{array}-[class]{}-[func]{insert}
```
### Удаление элемента
Аналогично, как показано на рисунке ниже, если нужно удалить элемент по индексу $i$ , то все элементы после индекса $i$ необходимо сдвинуть на одну позицию влево.
![Пример удаления элемента из массива](array.assets/array_remove_element.png)
Обрати внимание: после удаления исходный последний элемент становится "бессмысленным", поэтому специально изменять его не требуется.
```src
[file]{array}-[class]{}-[func]{remove}
```
В целом операции вставки и удаления в массиве имеют следующие недостатки.
- **Высокая временная сложность**: средняя временная сложность и вставки, и удаления равна $O(n)$ , где $n$ - длина массива.
- **Потеря элементов**: поскольку длина массива неизменяема, после вставки элементы, выходящие за пределы длины массива, будут потеряны.
- **Потери памяти**: можно заранее инициализировать более длинный массив и использовать только его переднюю часть; тогда "теряемые" при вставке элементы на конце не будут нести смысла, но такой подход приводит к лишнему расходу памяти.
### Обход массива
В большинстве языков программирования массив можно обходить как по индексу, так и напрямую перебирая каждый элемент:
```src
[file]{array}-[class]{}-[func]{traverse}
```
### Поиск элемента
Чтобы найти заданный элемент в массиве, нужно пройти по массиву и на каждой итерации проверять, совпадает ли значение; если совпадает, вернуть соответствующий индекс.
Поскольку массив - это линейная структура данных, такая операция поиска называется "линейным поиском".
```src
[file]{array}-[class]{}-[func]{find}
```
### Расширение массива
В сложной системной среде программа не может гарантировать, что память сразу после массива доступна, поэтому безопасно расширить емкость массива невозможно. Поэтому в большинстве языков программирования **длина массива неизменяема**.
Если мы хотим расширить массив, нужно заново создать больший массив и затем по одному скопировать в него элементы исходного массива. Это операция с временной сложностью $O(n)$ , и при больших массивах она очень затратна. Соответствующий код показан ниже:
```src
[file]{array}-[class]{}-[func]{extend}
```
## Преимущества и ограничения массива
Массив хранится в непрерывной области памяти, и все его элементы имеют один и тот же тип. Такой подход содержит много априорной информации, которую система может использовать для оптимизации эффективности операций со структурой данных.
- **Высокая пространственная эффективность**: массив выделяет для данных непрерывный блок памяти без дополнительного структурного накладного расхода.
- **Поддержка произвольного доступа**: массив позволяет обращаться к любому элементу за $O(1)$ времени.
- **Локальность кэша**: при обращении к элементу массива компьютер загружает не только сам элемент, но и соседние данные, что позволяет использовать кэш для ускорения последующих операций.
Хранение в непрерывной области памяти - палка о двух концах, и у него есть следующие ограничения.
- **Низкая эффективность вставки и удаления**: когда элементов в массиве много, вставка и удаление требуют сдвига большого количества элементов.
- **Неизменяемая длина**: после инициализации длина массива фиксирована; расширение массива требует копирования всех данных в новый массив, что стоит дорого.
- **Потери памяти**: если выделенный массив больше, чем реально необходимо, лишнее пространство пропадает впустую.
## Типичные применения массива
Массив - это базовая и очень распространенная структура данных. Он часто используется как в различных алгоритмах, так и при реализации более сложных структур данных.
- **Произвольный доступ**: если мы хотим случайным образом выбирать некоторые образцы, можно сохранить их в массиве и сгенерировать случайную последовательность индексов для выборки.
- **Сортировка и поиск**: массив - самая распространенная структура данных для алгоритмов сортировки и поиска. Быстрая сортировка, сортировка слиянием, бинарный поиск и многие другие алгоритмы в основном работают именно с массивами.
- **Таблица поиска**: когда нужно быстро находить элемент или его соответствие, массив можно использовать как lookup table. Например, если мы хотим реализовать отображение символов в коды ASCII, можно использовать значение ASCII как индекс, а соответствующий элемент хранить по этой позиции массива.
- **Машинное обучение**: в нейронных сетях широко используются операции линейной алгебры над векторами, матрицами и тензорами, и все эти данные строятся в форме массивов. Массив - самая часто используемая структура данных в программировании нейросетей.
- **Реализация структур данных**: массивы можно использовать для реализации стеков, очередей, хеш-таблиц, куч, графов и других структур данных. Например, матрица смежности графа по сути является двумерным массивом.
@@ -0,0 +1,9 @@
# Массивы и списки
![Массивы и списки](../assets/covers/chapter_array_and_linkedlist.jpg)
!!! abstract
Мир структур данных напоминает прочную кирпичную стену.
Кирпичи массива уложены ровно и плотно прилегают друг к другу. Кирпичи связного списка разбросаны в разных местах, а соединяющие их лозы свободно тянутся между щелями.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,702 @@
# Связный список
Память является общим ресурсом для всех программ, и в сложной среде выполнения свободные участки памяти могут быть разбросаны по всему адресу памяти. Мы знаем, что память для хранения массива должна быть непрерывной, а если массив очень велик, память может не суметь предоставить столь большой непрерывный блок. Именно здесь проявляется преимущество гибкости связного списка.
<u>Связный список (linked list)</u> - это линейная структура данных, в которой каждый элемент представляет собой объект-узел, а сами узлы соединены между собой через "ссылки". Ссылка хранит адрес памяти следующего узла, благодаря чему из текущего узла можно получить доступ к следующему.
Конструкция связного списка позволяет хранить отдельные узлы в разных местах памяти, и их адреса вовсе не обязаны быть непрерывными.
![Определение связного списка и способ хранения](linked_list.assets/linkedlist_definition.png)
Если посмотреть на рисунок выше, можно заметить, что базовой единицей связного списка является объект <u>узел (node)</u>. Каждый узел содержит две части данных: "значение" узла и "ссылку" на следующий узел.
- Первый узел связного списка называется "головным узлом", а последний - "хвостовым узлом".
- Хвостовой узел указывает на "пусто", что в Java, C++ и Python обозначается как `null` , `nullptr` и `None` соответственно.
- В языках, поддерживающих указатели, таких как C, C++, Go и Rust, упомянутую выше "ссылку" следует заменить на "указатель".
Как показано в коде ниже, узел связного списка `ListNode` хранит не только значение, но и дополнительную ссылку (указатель). Поэтому **при одинаковом объеме данных связный список занимает больше памяти, чем массив**.
=== "Python"
```python title=""
class ListNode:
"""Класс узла связного списка"""
def __init__(self, val: int):
self.val: int = val # Значение узла
self.next: ListNode | None = None # Ссылка на следующий узел
```
=== "C++"
```cpp title=""
/* Структура узла связного списка */
struct ListNode {
int val; // Значение узла
ListNode *next; // Указатель на следующий узел
ListNode(int x) : val(x), next(nullptr) {} // Конструктор
};
```
=== "Java"
```java title=""
/* Класс узла связного списка */
class ListNode {
int val; // Значение узла
ListNode next; // Ссылка на следующий узел
ListNode(int x) { val = x; } // Конструктор
}
```
=== "C#"
```csharp title=""
/* Класс узла связного списка */
class ListNode(int x) { // Конструктор
int val = x; // Значение узла
ListNode? next; // Ссылка на следующий узел
}
```
=== "Go"
```go title=""
/* Структура узла связного списка */
type ListNode struct {
Val int // Значение узла
Next *ListNode // Указатель на следующий узел
}
// NewListNode Конструктор, создает новый связный список
func NewListNode(val int) *ListNode {
return &ListNode{
Val: val,
Next: nil,
}
}
```
=== "Swift"
```swift title=""
/* Класс узла связного списка */
class ListNode {
var val: Int // Значение узла
var next: ListNode? // Ссылка на следующий узел
init(x: Int) { // Конструктор
val = x
}
}
```
=== "JS"
```javascript title=""
/* Класс узла связного списка */
class ListNode {
constructor(val, next) {
this.val = (val === undefined ? 0 : val); // Значение узла
this.next = (next === undefined ? null : next); // Ссылка на следующий узел
}
}
```
=== "TS"
```typescript title=""
/* Класс узла связного списка */
class ListNode {
val: number;
next: ListNode | null;
constructor(val?: number, next?: ListNode | null) {
this.val = val === undefined ? 0 : val; // Значение узла
this.next = next === undefined ? null : next; // Ссылка на следующий узел
}
}
```
=== "Dart"
```dart title=""
/* Класс узла связного списка */
class ListNode {
int val; // Значение узла
ListNode? next; // Ссылка на следующий узел
ListNode(this.val, [this.next]); // Конструктор
}
```
=== "Rust"
```rust title=""
use std::rc::Rc;
use std::cell::RefCell;
/* Класс узла связного списка */
#[derive(Debug)]
struct ListNode {
val: i32, // Значение узла
next: Option<Rc<RefCell<ListNode>>>, // Указатель на следующий узел
}
```
=== "C"
```c title=""
/* Структура узла связного списка */
typedef struct ListNode {
int val; // Значение узла
struct ListNode *next; // Указатель на следующий узел
} ListNode;
/* Конструктор */
ListNode *newListNode(int val) {
ListNode *node;
node = (ListNode *) malloc(sizeof(ListNode));
node->val = val;
node->next = NULL;
return node;
}
```
=== "Kotlin"
```kotlin title=""
/* Класс узла связного списка */
// Конструктор
class ListNode(x: Int) {
val _val: Int = x // Значение узла
val next: ListNode? = null // Ссылка на следующий узел
}
```
=== "Ruby"
```ruby title=""
# Класс узла связного списка
class ListNode
attr_accessor :val # Значение узла
attr_accessor :next # Ссылка на следующий узел
def initialize(val=0, next_node=nil)
@val = val
@next = next_node
end
end
```
## Основные операции со связным списком
### Инициализация связного списка
Построение связного списка состоит из двух шагов: сначала нужно инициализировать объекты всех узлов, затем установить связи-ссылки между ними. После завершения инициализации мы можем, начиная с головы списка, последовательно проходить все узлы по ссылке `next`.
=== "Python"
```python title="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
```
=== "C++"
```cpp title="linked_list.cpp"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
// Построение ссылок между узлами
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
```
=== "Java"
```java title="linked_list.java"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
ListNode n0 = new ListNode(1);
ListNode n1 = new ListNode(3);
ListNode n2 = new ListNode(2);
ListNode n3 = new ListNode(5);
ListNode n4 = new ListNode(4);
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "C#"
```csharp title="linked_list.cs"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
ListNode n0 = new(1);
ListNode n1 = new(3);
ListNode n2 = new(2);
ListNode n3 = new(5);
ListNode n4 = new(4);
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "Go"
```go title="linked_list.go"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
n0 := NewListNode(1)
n1 := NewListNode(3)
n2 := NewListNode(2)
n3 := NewListNode(5)
n4 := NewListNode(4)
// Построение ссылок между узлами
n0.Next = n1
n1.Next = n2
n2.Next = n3
n3.Next = n4
```
=== "Swift"
```swift title="linked_list.swift"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
let n0 = ListNode(x: 1)
let n1 = ListNode(x: 3)
let n2 = ListNode(x: 2)
let n3 = ListNode(x: 5)
let n4 = ListNode(x: 4)
// Построение ссылок между узлами
n0.next = n1
n1.next = n2
n2.next = n3
n3.next = n4
```
=== "JS"
```javascript title="linked_list.js"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
const n0 = new ListNode(1);
const n1 = new ListNode(3);
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "TS"
```typescript title="linked_list.ts"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
const n0 = new ListNode(1);
const n1 = new ListNode(3);
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "Dart"
```dart title="linked_list.dart"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */\
// Инициализация отдельных узлов
ListNode n0 = ListNode(1);
ListNode n1 = ListNode(3);
ListNode n2 = ListNode(2);
ListNode n3 = ListNode(5);
ListNode n4 = ListNode(4);
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "Rust"
```rust title="linked_list.rs"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
let n0 = Rc::new(RefCell::new(ListNode { val: 1, next: None }));
let n1 = Rc::new(RefCell::new(ListNode { val: 3, next: None }));
let n2 = Rc::new(RefCell::new(ListNode { val: 2, next: None }));
let n3 = Rc::new(RefCell::new(ListNode { val: 5, next: None }));
let n4 = Rc::new(RefCell::new(ListNode { val: 4, next: None }));
// Построение ссылок между узлами
n0.borrow_mut().next = Some(n1.clone());
n1.borrow_mut().next = Some(n2.clone());
n2.borrow_mut().next = Some(n3.clone());
n3.borrow_mut().next = Some(n4.clone());
```
=== "C"
```c title="linked_list.c"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
ListNode* n0 = newListNode(1);
ListNode* n1 = newListNode(3);
ListNode* n2 = newListNode(2);
ListNode* n3 = newListNode(5);
ListNode* n4 = newListNode(4);
// Построение ссылок между узлами
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
```
=== "Kotlin"
```kotlin title="linked_list.kt"
/* Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4 */
// Инициализация отдельных узлов
val n0 = ListNode(1)
val n1 = ListNode(3)
val n2 = ListNode(2)
val n3 = ListNode(5)
val n4 = ListNode(4)
// Построение ссылок между узлами
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
```
=== "Ruby"
```ruby title="linked_list.rb"
# Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4
# Инициализация отдельных узлов
n0 = ListNode.new(1)
n1 = ListNode.new(3)
n2 = ListNode.new(2)
n3 = ListNode.new(5)
n4 = ListNode.new(4)
# Построение ссылок между узлами
n0.next = n1
n1.next = n2
n2.next = n3
n3.next = n4
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=class%20ListNode%3A%0A%20%20%20%20%22%22%22%D1%81%D0%B2%D1%8F%D0%B7%D0%BD%D1%8B%D0%B9%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%D1%83%D0%B7%D0%B5%D0%BB%D0%BA%D0%BB%D0%B0%D1%81%D1%81%22%22%22%0A%20%20%20%20def%20__init__%28self%2C%20val%3A%20int%29%3A%0A%20%20%20%20%20%20%20%20self.val%3A%20int%20%3D%20val%20%20%23%20%D0%97%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8%D0%B5%20%D1%83%D0%B7%D0%BB%D0%B0%0A%20%20%20%20%20%20%20%20self.next%3A%20ListNode%20%7C%20None%20%3D%20None%20%20%23%20%D0%A1%D1%81%D1%8B%D0%BB%D0%BA%D0%B0%20%D0%BD%D0%B0%20%D1%81%D0%BB%D0%B5%D0%B4%D1%83%D1%8E%D1%89%D0%B8%D0%B9%20%D1%83%D0%B7%D0%B5%D0%BB%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%B2%D1%8F%D0%B7%D0%BD%D1%8B%D0%B9%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%201%20-%3E%203%20-%3E%202%20-%3E%205%20-%3E%204%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D0%BA%D0%B0%D0%B6%D0%B4%D1%8B%D0%B9%20%D1%83%D0%B7%D0%B5%D0%BB%0A%20%20%20%20n0%20%3D%20ListNode%281%29%0A%20%20%20%20n1%20%3D%20ListNode%283%29%0A%20%20%20%20n2%20%3D%20ListNode%282%29%0A%20%20%20%20n3%20%3D%20ListNode%285%29%0A%20%20%20%20n4%20%3D%20ListNode%284%29%0A%20%20%20%20%23%20%D0%9F%D0%BE%D1%81%D1%82%D1%80%D0%BE%D0%B8%D1%82%D1%8C%20%D1%81%D1%81%D1%8B%D0%BB%D0%BA%D0%B8%20%D0%BC%D0%B5%D0%B6%D0%B4%D1%83%20%D1%83%D0%B7%D0%BB%D0%B0%D0%BC%D0%B8%0A%20%20%20%20n0.next%20%3D%20n1%0A%20%20%20%20n1.next%20%3D%20n2%0A%20%20%20%20n2.next%20%3D%20n3%0A%20%20%20%20n3.next%20%3D%20n4&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
Массив в целом - это одна переменная: например, массив `nums` содержит элементы `nums[0]` , `nums[1]` и т.д. Связный список же состоит из множества независимых объектов-узлов. **Обычно в качестве обозначения всего связного списка используют головной узел**; например, в приведенном выше коде связный список можно обозначить как список `n0` .
### Вставка узла
Вставить узел в связный список очень легко. Как показано на рисунке ниже, предположим, что мы хотим вставить новый узел `P` между двумя соседними узлами `n0` и `n1` ; **для этого нужно изменить всего две ссылки (указателя)**, а временная сложность будет равна $O(1)$ .
Для сравнения: временная сложность вставки элемента в массив составляет $O(n)$ , и при большом объеме данных это неэффективно.
![Пример вставки узла в связный список](linked_list.assets/linkedlist_insert_node.png)
```src
[file]{linked_list}-[class]{}-[func]{insert}
```
### Удаление узла
Как показано на рисунке ниже, удалить узел из связного списка тоже очень удобно: **нужно изменить всего одну ссылку (указатель)**.
Обрати внимание: хотя после завершения операции удаления узел `P` все еще указывает на `n1` , при обходе связного списка до `P` уже нельзя добраться, а значит `P` больше не принадлежит данному списку.
![Удаление узла из связного списка](linked_list.assets/linkedlist_remove_node.png)
```src
[file]{linked_list}-[class]{}-[func]{remove}
```
### Доступ к узлу
**Доступ к узлам в связном списке менее эффективен**. Как уже обсуждалось в предыдущем разделе, к любому элементу массива можно обратиться за $O(1)$ времени. Со связным списком это не так: программе нужно стартовать от головного узла и последовательно двигаться дальше, пока не будет найден целевой узел. То есть для доступа к $i$ -му узлу списка нужно выполнить $i - 1$ итераций, а временная сложность составляет $O(n)$ .
```src
[file]{linked_list}-[class]{}-[func]{access}
```
### Поиск узла
Выполни обход связного списка, найди в нем узел со значением `target` и верни индекс этого узла в списке. Этот процесс тоже относится к линейному поиску. Код выглядит следующим образом:
```src
[file]{linked_list}-[class]{}-[func]{find}
```
## Сравнение массива и связного списка
В таблице ниже обобщаются свойства массива и связного списка, а также сравнивается эффективность соответствующих операций. Поскольку они используют противоположные стратегии хранения, их свойства и эффективность операций тоже во многом противоположны.
<p align="center"> Таблица <id> &nbsp; Сравнение эффективности массива и связного списка </p>
| | Массив | Связный список |
| ------------- | ---------------------------------------------- | ----------------------- |
| Способ хранения | Непрерывная область памяти | Разрозненная область памяти |
| Расширение емкости | Длина неизменяема | Гибкое расширение |
| Эффективность памяти | Элементы занимают меньше памяти, но возможны потери пространства | Элементы занимают больше памяти |
| Доступ к элементу | $O(1)$ | $O(n)$ |
| Добавление элемента | $O(n)$ | $O(1)$ |
| Удаление элемента | $O(n)$ | $O(1)$ |
## Основные типы связных списков
Как показано на рисунке ниже, существует три распространенных типа связных списков.
- **Односвязный список**: это обычный связный список, рассмотренный выше. Узел односвязного списка содержит значение и ссылку на следующий узел. Первый узел называется головным, последний - хвостовым, и хвост указывает на пусто `None` .
- **Циклический список**: если заставить хвостовой узел односвязного списка указывать на головной (то есть соединить хвост с головой), получится циклический список. В циклическом списке любой узел можно рассматривать как головной.
- **Двусвязный список**: по сравнению с односвязным списком двусвязный хранит ссылки в двух направлениях. Определение узла двусвязного списка включает как ссылку на следующий узел, так и ссылку на предыдущий узел. По сравнению с односвязным списком двусвязный более гибок и позволяет проходить список в обе стороны, но за это приходится платить дополнительной памятью.
=== "Python"
```python title=""
class ListNode:
"""Класс узла двусвязного списка"""
def __init__(self, val: int):
self.val: int = val # Значение узла
self.next: ListNode | None = None # Ссылка на следующий узел
self.prev: ListNode | None = None # Ссылка на предыдущий узел
```
=== "C++"
```cpp title=""
/* Структура узла двусвязного списка */
struct ListNode {
int val; // Значение узла
ListNode *next; // Указатель на следующий узел
ListNode *prev; // Указатель на предыдущий узел
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // Конструктор
};
```
=== "Java"
```java title=""
/* Класс узла двусвязного списка */
class ListNode {
int val; // Значение узла
ListNode next; // Ссылка на следующий узел
ListNode prev; // Ссылка на предыдущий узел
ListNode(int x) { val = x; } // Конструктор
}
```
=== "C#"
```csharp title=""
/* Класс узла двусвязного списка */
class ListNode(int x) { // Конструктор
int val = x; // Значение узла
ListNode next; // Ссылка на следующий узел
ListNode prev; // Ссылка на предыдущий узел
}
```
=== "Go"
```go title=""
/* Структура узла двусвязного списка */
type DoublyListNode struct {
Val int // Значение узла
Next *DoublyListNode // Указатель на следующий узел
Prev *DoublyListNode // Указатель на предыдущий узел
}
// NewDoublyListNode Инициализация
func NewDoublyListNode(val int) *DoublyListNode {
return &DoublyListNode{
Val: val,
Next: nil,
Prev: nil,
}
}
```
=== "Swift"
```swift title=""
/* Класс узла двусвязного списка */
class ListNode {
var val: Int // Значение узла
var next: ListNode? // Ссылка на следующий узел
var prev: ListNode? // Ссылка на предыдущий узел
init(x: Int) { // Конструктор
val = x
}
}
```
=== "JS"
```javascript title=""
/* Класс узла двусвязного списка */
class ListNode {
constructor(val, next, prev) {
this.val = val === undefined ? 0 : val; // Значение узла
this.next = next === undefined ? null : next; // Ссылка на следующий узел
this.prev = prev === undefined ? null : prev; // Ссылка на предыдущий узел
}
}
```
=== "TS"
```typescript title=""
/* Класс узла двусвязного списка */
class ListNode {
val: number;
next: ListNode | null;
prev: ListNode | null;
constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) {
this.val = val === undefined ? 0 : val; // Значение узла
this.next = next === undefined ? null : next; // Ссылка на следующий узел
this.prev = prev === undefined ? null : prev; // Ссылка на предыдущий узел
}
}
```
=== "Dart"
```dart title=""
/* Класс узла двусвязного списка */
class ListNode {
int val; // Значение узла
ListNode? next; // Ссылка на следующий узел
ListNode? prev; // Ссылка на предыдущий узел
ListNode(this.val, [this.next, this.prev]); // Конструктор
}
```
=== "Rust"
```rust title=""
use std::rc::Rc;
use std::cell::RefCell;
/* Тип узла двусвязного списка */
#[derive(Debug)]
struct ListNode {
val: i32, // Значение узла
next: Option<Rc<RefCell<ListNode>>>, // Указатель на следующий узел
prev: Option<Rc<RefCell<ListNode>>>, // Указатель на предыдущий узел
}
/* Конструктор */
impl ListNode {
fn new(val: i32) -> Self {
ListNode {
val,
next: None,
prev: None,
}
}
}
```
=== "C"
```c title=""
/* Структура узла двусвязного списка */
typedef struct ListNode {
int val; // Значение узла
struct ListNode *next; // Указатель на следующий узел
struct ListNode *prev; // Указатель на предыдущий узел
} ListNode;
/* Конструктор */
ListNode *newListNode(int val) {
ListNode *node;
node = (ListNode *) malloc(sizeof(ListNode));
node->val = val;
node->next = NULL;
node->prev = NULL;
return node;
}
```
=== "Kotlin"
```kotlin title=""
/* Класс узла двусвязного списка */
// Конструктор
class ListNode(x: Int) {
val _val: Int = x // Значение узла
val next: ListNode? = null // Ссылка на следующий узел
val prev: ListNode? = null // Ссылка на предыдущий узел
}
```
=== "Ruby"
```ruby title=""
# Класс узла двусвязного списка
class ListNode
attr_accessor :val # Значение узла
attr_accessor :next # Ссылка на следующий узел
attr_accessor :prev # Ссылка на предыдущий узел
def initialize(val=0, next_node=nil, prev_node=nil)
@val = val
@next = next_node
@prev = prev_node
end
end
```
![Распространенные типы связных списков](linked_list.assets/linkedlist_common_types.png)
## Типичные применения связных списков
Односвязные списки обычно используются для реализации стеков, очередей, хеш-таблиц и графов.
- **Стеки и очереди**: если операции вставки и удаления выполняются на одном конце связного списка, он проявляет свойства LIFO, соответствующие стеку; если вставка происходит на одном конце, а удаление на другом, он проявляет свойства FIFO, соответствующие очереди.
- **Хеш-таблицы**: метод цепочек - один из основных способов разрешения коллизий в хеш-таблицах. В этом подходе все конфликтующие элементы помещаются в связный список.
- **Графы**: список смежности - это распространенный способ представления графа, при котором каждой вершине графа соответствует связный список, а каждый элемент этого списка представляет другую вершину, соединенную с данной.
Двусвязные списки обычно используются там, где нужен быстрый доступ как к предыдущему, так и к следующему элементу.
- **Продвинутые структуры данных**: например, в красно-черных деревьях и B-деревьях нам нужен доступ к родительскому узлу; этого можно добиться, сохранив в узле ссылку на родителя, по аналогии с двусвязным списком.
- **История браузера**: когда пользователь в браузере нажимает кнопки "вперед" или "назад", браузеру нужно знать предыдущую и следующую веб-страницы, которые он посещал. Свойства двусвязного списка делают такую операцию простой.
- **Алгоритм LRU**: в алгоритмах вытеснения из кэша (LRU) нужно быстро находить наименее недавно использованные данные, а также быстро добавлять и удалять узлы. Для этого двусвязный список подходит очень хорошо.
Циклические списки часто применяются в сценариях, требующих периодических операций, например при планировании ресурсов в операционной системе.
- **Алгоритм циклического распределения кванта времени**: в операционных системах round-robin scheduling - это распространенный алгоритм планирования CPU, который циклически обходит набор процессов. Каждому процессу выделяется квант времени, и когда он исчерпан, CPU переключается на следующий процесс. Такую циклическую операцию удобно реализовать с помощью кольцевого списка.
- **Буферы данных**: в некоторых реализациях буферов данных также могут использоваться циклические списки. Например, в аудио- и видеоплеерах поток данных может делиться на несколько буферных блоков и помещаться в кольцевой список для обеспечения непрерывного воспроизведения.
@@ -0,0 +1,961 @@
# Список
<u>Список (list)</u> - это абстрактное понятие структуры данных, обозначающее упорядоченную коллекцию элементов, которая поддерживает доступ к элементам, их изменение, добавление, удаление и обход, не требуя от пользователя учитывать ограничения по емкости. Список может быть реализован как на основе связного списка, так и на основе массива.
- Связный список естественным образом можно рассматривать как список: он поддерживает операции добавления, удаления, поиска и изменения элементов и может гибко расширяться динамически.
- Массив тоже поддерживает операции добавления, удаления, поиска и изменения элементов, но из-за неизменяемости длины его можно считать лишь списком с ограниченной длиной.
Когда список реализуется с помощью массива, **неизменяемость длины снижает его практическую полезность**. Причина в том, что мы обычно не можем заранее точно знать, сколько данных нужно хранить, а значит, трудно выбрать подходящую длину списка. Если длина слишком мала, она может не покрыть реальные потребности; если слишком велика, будет зря расходоваться память.
Чтобы решить эту проблему, можно использовать <u>динамический массив (dynamic array)</u> для реализации списка. Он сохраняет все преимущества массива и при этом может динамически расширяться во время выполнения программы.
На практике **списки из стандартных библиотек многих языков программирования реализованы именно на основе динамических массивов**, например `list` в Python, `ArrayList` в Java, `vector` в C++ и `List` в C#. В дальнейшем обсуждении мы будем считать понятия "список" и "динамический массив" эквивалентными.
## Основные операции со списком
### Инициализация списка
Обычно мы используем два способа инициализации: "без начальных значений" и "с начальными значениями":
=== "Python"
```python title="list.py"
# Инициализация списка
# Без начальных значений
nums1: list[int] = []
# С начальными значениями
nums: list[int] = [1, 3, 2, 5, 4]
```
=== "C++"
```cpp title="list.cpp"
/* Инициализация списка */
// Обрати внимание: в C++ vector соответствует описываемому здесь nums
// Без начальных значений
vector<int> nums1;
// С начальными значениями
vector<int> nums = { 1, 3, 2, 5, 4 };
```
=== "Java"
```java title="list.java"
/* Инициализация списка */
// Без начальных значений
List<Integer> nums1 = new ArrayList<>();
// С начальными значениями (обрати внимание: элементы массива должны использовать обертку Integer[] вместо int[])
Integer[] numbers = new Integer[] { 1, 3, 2, 5, 4 };
List<Integer> nums = new ArrayList<>(Arrays.asList(numbers));
```
=== "C#"
```csharp title="list.cs"
/* Инициализация списка */
// Без начальных значений
List<int> nums1 = [];
// С начальными значениями
int[] numbers = [1, 3, 2, 5, 4];
List<int> nums = [.. numbers];
```
=== "Go"
```go title="list_test.go"
/* Инициализация списка */
// Без начальных значений
nums1 := []int{}
// С начальными значениями
nums := []int{1, 3, 2, 5, 4}
```
=== "Swift"
```swift title="list.swift"
/* Инициализация списка */
// Без начальных значений
let nums1: [Int] = []
// С начальными значениями
var nums = [1, 3, 2, 5, 4]
```
=== "JS"
```javascript title="list.js"
/* Инициализация списка */
// Без начальных значений
const nums1 = [];
// С начальными значениями
const nums = [1, 3, 2, 5, 4];
```
=== "TS"
```typescript title="list.ts"
/* Инициализация списка */
// Без начальных значений
const nums1: number[] = [];
// С начальными значениями
const nums: number[] = [1, 3, 2, 5, 4];
```
=== "Dart"
```dart title="list.dart"
/* Инициализация списка */
// Без начальных значений
List<int> nums1 = [];
// С начальными значениями
List<int> nums = [1, 3, 2, 5, 4];
```
=== "Rust"
```rust title="list.rs"
/* Инициализация списка */
// Без начальных значений
let nums1: Vec<i32> = Vec::new();
// С начальными значениями
let nums: Vec<i32> = vec![1, 3, 2, 5, 4];
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Инициализация списка */
// Без начальных значений
var nums1 = listOf<Int>()
// С начальными значениями
var numbers = arrayOf(1, 3, 2, 5, 4)
var nums = numbers.toMutableList()
```
=== "Ruby"
```ruby title="list.rb"
# Инициализация списка
# Без начальных значений
nums1 = []
# С начальными значениями
nums = [1, 3, 2, 5, 4]
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20%23%20%D0%91%D0%B5%D0%B7%20%D0%BD%D0%B0%D1%87%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D1%85%20%D0%B7%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8%D0%B9%0A%20%20%20%20nums1%20%3D%20%5B%5D%0A%20%20%20%20%23%20%D0%A1%20%D0%BD%D0%B0%D1%87%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%BC%D0%B8%20%D0%B7%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8%D1%8F%D0%BC%D0%B8%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D&cumulative=false&curInstr=4&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Доступ к элементам
Список по своей сути является массивом, поэтому доступ к элементам и их обновление можно выполнять за $O(1)$ времени, что очень эффективно.
=== "Python"
```python title="list.py"
# Доступ к элементу
num: int = nums[1] # Доступ к элементу по индексу 1
# Обновление элемента
nums[1] = 0 # Обновить элемент по индексу 1 значением 0
```
=== "C++"
```cpp title="list.cpp"
/* Доступ к элементу */
int num = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "Java"
```java title="list.java"
/* Доступ к элементу */
int num = nums.get(1); // Доступ к элементу по индексу 1
/* Обновление элемента */
nums.set(1, 0); // Обновить элемент по индексу 1 значением 0
```
=== "C#"
```csharp title="list.cs"
/* Доступ к элементу */
int num = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "Go"
```go title="list_test.go"
/* Доступ к элементу */
num := nums[1] // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0 // Обновить элемент по индексу 1 значением 0
```
=== "Swift"
```swift title="list.swift"
/* Доступ к элементу */
let num = nums[1] // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0 // Обновить элемент по индексу 1 значением 0
```
=== "JS"
```javascript title="list.js"
/* Доступ к элементу */
const num = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "TS"
```typescript title="list.ts"
/* Доступ к элементу */
const num: number = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "Dart"
```dart title="list.dart"
/* Доступ к элементу */
int num = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "Rust"
```rust title="list.rs"
/* Доступ к элементу */
let num: i32 = nums[1]; // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0; // Обновить элемент по индексу 1 значением 0
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Доступ к элементу */
val num = nums[1] // Доступ к элементу по индексу 1
/* Обновление элемента */
nums[1] = 0 // Обновить элемент по индексу 1 значением 0
```
=== "Ruby"
```ruby title="list.rb"
# Доступ к элементу
num = nums[1] # Доступ к элементу по индексу 1
# Обновление элемента
nums[1] = 0 # Обновить элемент по индексу 1 значением 0
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D%0A%0A%20%20%20%20%23%20%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B8%D1%82%D1%8C%20%D0%B4%D0%BE%D1%81%D1%82%D1%83%D0%BF%20%D0%BA%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%D1%83%0A%20%20%20%20num%20%3D%20nums%5B1%5D%20%20%23%20%D0%BE%D0%B1%D1%80%D0%B0%D1%82%D0%B8%D1%82%D1%8C%D1%81%D1%8F%20%D0%BA%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81%201%20%D0%BF%D0%BE%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%0A%0A%20%20%20%20%23%20%D0%9E%D0%B1%D0%BD%D0%BE%D0%B2%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%0A%20%20%20%20nums%5B1%5D%20%3D%200%20%20%20%20%23%20%D0%9E%D0%B1%D0%BD%D0%BE%D0%B2%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%20%D0%BF%D0%BE%20%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81%D1%83%201%20%D0%B4%D0%BE%200&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Вставка и удаление элементов
По сравнению с массивами список позволяет свободно добавлять и удалять элементы. Добавление элемента в конец списка имеет временную сложность $O(1)$ , но операции вставки и удаления по-прежнему имеют ту же эффективность, что и у массива, то есть $O(n)$ .
=== "Python"
```python title="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
```
=== "C++"
```cpp title="list.cpp"
/* Очистить список */
nums.clear();
/* Добавить элементы в конец */
nums.push_back(1);
nums.push_back(3);
nums.push_back(2);
nums.push_back(5);
nums.push_back(4);
/* Вставить элемент в середину */
nums.insert(nums.begin() + 3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.erase(nums.begin() + 3); // Удалить элемент по индексу 3
```
=== "Java"
```java title="list.java"
/* Очистить список */
nums.clear();
/* Добавить элементы в конец */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
/* Вставить элемент в середину */
nums.add(3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.remove(3); // Удалить элемент по индексу 3
```
=== "C#"
```csharp title="list.cs"
/* Очистить список */
nums.Clear();
/* Добавить элементы в конец */
nums.Add(1);
nums.Add(3);
nums.Add(2);
nums.Add(5);
nums.Add(4);
/* Вставить элемент в середину */
nums.Insert(3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.RemoveAt(3); // Удалить элемент по индексу 3
```
=== "Go"
```go title="list_test.go"
/* Очистить список */
nums = nil
/* Добавить элементы в конец */
nums = append(nums, 1)
nums = append(nums, 3)
nums = append(nums, 2)
nums = append(nums, 5)
nums = append(nums, 4)
/* Вставить элемент в середину */
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // Вставить число 6 по индексу 3
/* Удалить элемент */
nums = append(nums[:3], nums[4:]...) // Удалить элемент по индексу 3
```
=== "Swift"
```swift title="list.swift"
/* Очистить список */
nums.removeAll()
/* Добавить элементы в конец */
nums.append(1)
nums.append(3)
nums.append(2)
nums.append(5)
nums.append(4)
/* Вставить элемент в середину */
nums.insert(6, at: 3) // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.remove(at: 3) // Удалить элемент по индексу 3
```
=== "JS"
```javascript title="list.js"
/* Очистить список */
nums.length = 0;
/* Добавить элементы в конец */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* Вставить элемент в середину */
nums.splice(3, 0, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.splice(3, 1); // Удалить элемент по индексу 3
```
=== "TS"
```typescript title="list.ts"
/* Очистить список */
nums.length = 0;
/* Добавить элементы в конец */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* Вставить элемент в середину */
nums.splice(3, 0, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.splice(3, 1); // Удалить элемент по индексу 3
```
=== "Dart"
```dart title="list.dart"
/* Очистить список */
nums.clear();
/* Добавить элементы в конец */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
/* Вставить элемент в середину */
nums.insert(3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.removeAt(3); // Удалить элемент по индексу 3
```
=== "Rust"
```rust title="list.rs"
/* Очистить список */
nums.clear();
/* Добавить элементы в конец */
nums.push(1);
nums.push(3);
nums.push(2);
nums.push(5);
nums.push(4);
/* Вставить элемент в середину */
nums.insert(3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.remove(3); // Удалить элемент по индексу 3
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Очистить список */
nums.clear();
/* Добавить элементы в конец */
nums.add(1);
nums.add(3);
nums.add(2);
nums.add(5);
nums.add(4);
/* Вставить элемент в середину */
nums.add(3, 6); // Вставить число 6 по индексу 3
/* Удалить элемент */
nums.remove(3); // Удалить элемент по индексу 3
```
=== "Ruby"
```ruby title="list.rb"
# Очистить список
nums.clear
# Добавить элементы в конец
nums << 1
nums << 3
nums << 2
nums << 5
nums << 4
# Вставить элемент в середину
nums.insert(3, 6) # Вставить число 6 по индексу 3
# Удалить элемент
nums.delete_at(3) # Удалить элемент по индексу 3
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%A1%20%D0%BD%D0%B0%D1%87%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%BC%D0%B8%20%D0%B7%D0%BD%D0%B0%D1%87%D0%B5%D0%BD%D0%B8%D1%8F%D0%BC%D0%B8%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%9E%D1%87%D0%B8%D1%81%D1%82%D0%B8%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums.clear%28%29%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%94%D0%BE%D0%B1%D0%B0%D0%B2%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%20%D0%B2%20%D0%BA%D0%BE%D0%BD%D0%B5%D1%86%0A%20%20%20%20nums.append%281%29%0A%20%20%20%20nums.append%283%29%0A%20%20%20%20nums.append%282%29%0A%20%20%20%20nums.append%285%29%0A%20%20%20%20nums.append%284%29%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%92%D1%81%D1%82%D0%B0%D0%B2%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%20%D0%B2%20%D1%81%D0%B5%D1%80%D0%B5%D0%B4%D0%B8%D0%BD%D1%83%0A%20%20%20%20nums.insert%283%2C%206%29%20%20%23%20%D0%92%D1%81%D1%82%D0%B0%D0%B2%D0%B8%D1%82%D1%8C%20%D1%87%D0%B8%D1%81%D0%BB%D0%BE%206%20%D0%BF%D0%BE%20%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81%D1%83%203%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%A3%D0%B4%D0%B0%D0%BB%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%0A%20%20%20%20nums.pop%283%29%20%20%20%20%20%20%20%20%23%20%D0%A3%D0%B4%D0%B0%D0%BB%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%20%D0%BF%D0%BE%20%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81%D1%83%203&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Обход списка
Как и массив, список можно обходить как по индексам, так и напрямую по элементам.
=== "Python"
```python title="list.py"
# Обход списка по индексам
count = 0
for i in range(len(nums)):
count += nums[i]
# Прямой обход элементов списка
for num in nums:
count += num
```
=== "C++"
```cpp title="list.cpp"
/* Обход списка по индексам */
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
}
/* Прямой обход элементов списка */
count = 0;
for (int num : nums) {
count += num;
}
```
=== "Java"
```java title="list.java"
/* Обход списка по индексам */
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums.get(i);
}
/* Прямой обход элементов списка */
for (int num : nums) {
count += num;
}
```
=== "C#"
```csharp title="list.cs"
/* Обход списка по индексам */
int count = 0;
for (int i = 0; i < nums.Count; i++) {
count += nums[i];
}
/* Прямой обход элементов списка */
count = 0;
foreach (int num in nums) {
count += num;
}
```
=== "Go"
```go title="list_test.go"
/* Обход списка по индексам */
count := 0
for i := 0; i < len(nums); i++ {
count += nums[i]
}
/* Прямой обход элементов списка */
count = 0
for _, num := range nums {
count += num
}
```
=== "Swift"
```swift title="list.swift"
/* Обход списка по индексам */
var count = 0
for i in nums.indices {
count += nums[i]
}
/* Прямой обход элементов списка */
count = 0
for num in nums {
count += num
}
```
=== "JS"
```javascript title="list.js"
/* Обход списка по индексам */
let count = 0;
for (let i = 0; i < nums.length; i++) {
count += nums[i];
}
/* Прямой обход элементов списка */
count = 0;
for (const num of nums) {
count += num;
}
```
=== "TS"
```typescript title="list.ts"
/* Обход списка по индексам */
let count = 0;
for (let i = 0; i < nums.length; i++) {
count += nums[i];
}
/* Прямой обход элементов списка */
count = 0;
for (const num of nums) {
count += num;
}
```
=== "Dart"
```dart title="list.dart"
/* Обход списка по индексам */
int count = 0;
for (var i = 0; i < nums.length; i++) {
count += nums[i];
}
/* Прямой обход элементов списка */
count = 0;
for (var num in nums) {
count += num;
}
```
=== "Rust"
```rust title="list.rs"
// Обход списка по индексам
let mut _count = 0;
for i in 0..nums.len() {
_count += nums[i];
}
// Прямой обход элементов списка
_count = 0;
for num in &nums {
_count += num;
}
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Обход списка по индексам */
var count = 0
for (i in nums.indices) {
count += nums[i]
}
/* Прямой обход элементов списка */
for (num in nums) {
count += num
}
```
=== "Ruby"
```ruby title="list.rb"
# Обход списка по индексам
count = 0
for i in 0...nums.length
count += nums[i]
end
# Прямой обход элементов списка
count = 0
for num in nums
count += num
end
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%9E%D0%B1%D1%85%D0%BE%D0%B4%D0%B8%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%20%D0%BF%D0%BE%20%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81%D0%B0%D0%BC%0A%20%20%20%20count%20%3D%200%0A%20%20%20%20for%20i%20in%20range%28len%28nums%29%29%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20nums%5Bi%5D%0A%0A%20%20%20%20%23%20%D0%9D%D0%B5%D0%BF%D0%BE%D1%81%D1%80%D0%B5%D0%B4%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D0%BE%20%D0%BE%D0%B1%D1%85%D0%BE%D0%B4%D0%B8%D1%82%D1%8C%20%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%D1%8B%20%D1%81%D0%BF%D0%B8%D1%81%D0%BA%D0%B0%0A%20%20%20%20for%20num%20in%20nums%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20num&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Конкатенация списков
Если дан новый список `nums1` , мы можем присоединить его к хвосту исходного списка.
=== "Python"
```python title="list.py"
# Конкатенация двух списков
nums1: list[int] = [6, 8, 7, 10, 9]
nums += nums1 # Присоединить список nums1 к концу nums
```
=== "C++"
```cpp title="list.cpp"
/* Конкатенация двух списков */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// Присоединить список nums1 к концу nums
nums.insert(nums.end(), nums1.begin(), nums1.end());
```
=== "Java"
```java title="list.java"
/* Конкатенация двух списков */
List<Integer> nums1 = new ArrayList<>(Arrays.asList(new Integer[] { 6, 8, 7, 10, 9 }));
nums.addAll(nums1); // Присоединить список nums1 к концу nums
```
=== "C#"
```csharp title="list.cs"
/* Конкатенация двух списков */
List<int> nums1 = [6, 8, 7, 10, 9];
nums.AddRange(nums1); // Присоединить список nums1 к концу nums
```
=== "Go"
```go title="list_test.go"
/* Конкатенация двух списков */
nums1 := []int{6, 8, 7, 10, 9}
nums = append(nums, nums1...) // Присоединить список nums1 к концу nums
```
=== "Swift"
```swift title="list.swift"
/* Конкатенация двух списков */
let nums1 = [6, 8, 7, 10, 9]
nums.append(contentsOf: nums1) // Присоединить список nums1 к концу nums
```
=== "JS"
```javascript title="list.js"
/* Конкатенация двух списков */
const nums1 = [6, 8, 7, 10, 9];
nums.push(...nums1); // Присоединить список nums1 к концу nums
```
=== "TS"
```typescript title="list.ts"
/* Конкатенация двух списков */
const nums1: number[] = [6, 8, 7, 10, 9];
nums.push(...nums1); // Присоединить список nums1 к концу nums
```
=== "Dart"
```dart title="list.dart"
/* Конкатенация двух списков */
List<int> nums1 = [6, 8, 7, 10, 9];
nums.addAll(nums1); // Присоединить список nums1 к концу nums
```
=== "Rust"
```rust title="list.rs"
/* Конкатенация двух списков */
let nums1: Vec<i32> = vec![6, 8, 7, 10, 9];
nums.extend(nums1);
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Конкатенация двух списков */
val nums1 = intArrayOf(6, 8, 7, 10, 9).toMutableList()
nums.addAll(nums1) // Присоединить список nums1 к концу nums
```
=== "Ruby"
```ruby title="list.rb"
# Конкатенация двух списков
nums1 = [6, 8, 7, 10, 9]
nums += nums1
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%9E%D0%B1%D1%8A%D0%B5%D0%B4%D0%B8%D0%BD%D0%B8%D1%82%D1%8C%20%D0%B4%D0%B2%D0%B0%20%D1%81%D0%BF%D0%B8%D1%81%D0%BA%D0%B0%0A%20%20%20%20nums1%20%3D%20%5B6%2C%208%2C%207%2C%2010%2C%209%5D%0A%20%20%20%20nums%20%2B%3D%20nums1%20%20%23%20%D0%9F%D1%80%D0%B8%D1%81%D0%BE%D0%B5%D0%B4%D0%B8%D0%BD%D0%B8%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%20nums1%20%D0%BA%20nums&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
### Сортировка списка
После сортировки списка мы сможем применять алгоритмы "бинарный поиск" и "два указателя", которые очень часто встречаются в задачах по массивам.
=== "Python"
```python title="list.py"
# Отсортировать список
nums.sort() # После сортировки элементы списка идут по возрастанию
```
=== "C++"
```cpp title="list.cpp"
/* Отсортировать список */
sort(nums.begin(), nums.end()); // После сортировки элементы списка идут по возрастанию
```
=== "Java"
```java title="list.java"
/* Отсортировать список */
Collections.sort(nums); // После сортировки элементы списка идут по возрастанию
```
=== "C#"
```csharp title="list.cs"
/* Отсортировать список */
nums.Sort(); // После сортировки элементы списка идут по возрастанию
```
=== "Go"
```go title="list_test.go"
/* Отсортировать список */
sort.Ints(nums) // После сортировки элементы списка идут по возрастанию
```
=== "Swift"
```swift title="list.swift"
/* Отсортировать список */
nums.sort() // После сортировки элементы списка идут по возрастанию
```
=== "JS"
```javascript title="list.js"
/* Отсортировать список */
nums.sort((a, b) => a - b); // После сортировки элементы списка идут по возрастанию
```
=== "TS"
```typescript title="list.ts"
/* Отсортировать список */
nums.sort((a, b) => a - b); // После сортировки элементы списка идут по возрастанию
```
=== "Dart"
```dart title="list.dart"
/* Отсортировать список */
nums.sort(); // После сортировки элементы списка идут по возрастанию
```
=== "Rust"
```rust title="list.rs"
/* Отсортировать список */
nums.sort(); // После сортировки элементы списка идут по возрастанию
```
=== "C"
```c title="list.c"
// В C нет встроенного динамического массива
```
=== "Kotlin"
```kotlin title="list.kt"
/* Отсортировать список */
nums.sort() // После сортировки элементы списка идут по возрастанию
```
=== "Ruby"
```ruby title="list.rb"
# Отсортировать список
nums = nums.sort { |a, b| a <=> b } # После сортировки элементы списка идут по возрастанию
```
??? pythontutor "Визуализация выполнения"
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%D0%98%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums%20%3D%20%5B1%2C%203%2C%202%2C%205%2C%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%D0%9E%D1%82%D1%81%D0%BE%D1%80%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D1%82%D1%8C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%0A%20%20%20%20nums.sort%28%29%20%20%23%20%D0%A1%D0%BE%D1%80%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%BA%D0%B0%D0%BF%D0%BE%D1%81%D0%BB%D0%B5%2C%20%D1%81%D0%BF%D0%B8%D1%81%D0%BE%D0%BA%D1%8D%D0%BB%D0%B5%D0%BC%D0%B5%D0%BD%D1%82%D1%80%D0%B0%D1%81%D0%BF%D0%BE%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D1%8B%20%D0%BF%D0%BE%20%D0%B2%D0%BE%D0%B7%D1%80%D0%B0%D1%81%D1%82%D0%B0%D0%BD%D0%B8%D1%8E&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
## Реализация списка
Во многих языках программирования списки встроены в стандартную библиотеку, например в Java, C++ и Python. Их реализация довольно сложна, а настройки параметров тщательно продуманы: начальная емкость, коэффициент расширения и так далее. Если тебе интересно, стоит заглянуть в исходный код.
Чтобы лучше понять принцип работы списка, попробуем реализовать его упрощенную версию, в которой есть три ключевых аспекта проектирования.
- **Начальная емкость**: выбрать разумную начальную емкость внутреннего массива. В этом примере мы берем 10.
- **Учет количества элементов**: объявить переменную `size` , которая будет хранить текущее число элементов в списке и обновляться в реальном времени при вставке и удалении элементов. С помощью этой переменной можно находить конец списка и понимать, требуется ли расширение.
- **Механизм расширения**: если при вставке элементов емкость списка исчерпана, нужно выполнить расширение. Для этого сначала создается больший массив с учетом коэффициента расширения, а затем все элементы текущего массива по порядку переносятся в новый. В этом примере мы считаем, что каждый раз массив расширяется в 2 раза.
```src
[file]{my_list}-[class]{my_list}-[func]{}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,71 @@
# Оперативная память и кэш *
В первых двух разделах этой главы мы разобрали массивы и связные списки - две фундаментальные и важные структуры данных, которые соответственно представляют две физические структуры хранения: "непрерывное хранение" и "разрозненное хранение".
На практике **физическая структура во многом определяет, насколько эффективно программа использует память и кэш**, а это, в свою очередь, влияет на общую производительность алгоритмической программы.
## Устройства хранения данных в компьютере
В компьютере есть три типа устройств хранения данных: <u>жесткий диск (hard disk)</u> , <u>оперативная память (random-access memory, RAM)</u> и <u>кэш-память (cache memory)</u> . В таблице ниже показаны их различные роли и характеристики производительности в компьютерной системе.
<p align="center"> Таблица <id> &nbsp; Устройства хранения данных в компьютере </p>
| | Жесткий диск | Оперативная память | Кэш |
| -------------- | --------------------------------------- | ----------------------------------------- | ------------------------------------------------------- |
| Назначение | Долговременное хранение данных, включая ОС, программы, файлы и т.д. | Временное хранение выполняемых программ и обрабатываемых данных | Хранение часто используемых данных и инструкций, уменьшающее число обращений CPU к памяти |
| Энергозависимость | Данные не теряются после отключения питания | Данные теряются после отключения питания | Данные теряются после отключения питания |
| Емкость | Большая, уровень TB | Меньшая, уровень GB | Очень малая, уровень MB |
| Скорость | Низкая, от сотен до тысяч MB/s | Высокая, десятки GB/s | Очень высокая, десятки и сотни GB/s |
| Цена (юани) | Дешевый, от долей юаня до нескольких юаней за GB | Дорогая, десятки и сотни юаней за GB | Очень дорогой, входит в стоимость упаковки CPU |
Компьютерную систему хранения можно представить в виде пирамиды, показанной на рисунке ниже. Чем ближе устройство хранения к вершине пирамиды, тем оно быстрее, тем меньше его емкость и тем выше его стоимость. Такая многоуровневая конструкция возникла не случайно, а стала результатом тщательных инженерных компромиссов.
- **Жесткий диск трудно заменить оперативной памятью**. Во-первых, данные в оперативной памяти исчезают после отключения питания, поэтому она не подходит для долговременного хранения. Во-вторых, память стоит в десятки раз дороже жесткого диска, что мешает ее широкому применению в потребительском сегменте.
- **Кэш не может одновременно быть и очень большим, и очень быстрым**. По мере роста емкости кэшей L1, L2 и L3 их физический размер увеличивается, расстояние до ядра CPU становится больше, время передачи данных растет, а задержка доступа к элементам увеличивается. При текущем уровне технологий многоуровневая структура кэша является лучшим балансом между емкостью, скоростью и стоимостью.
![Система хранения данных компьютера](ram_and_cache.assets/storage_pyramid.png)
!!! tip
Иерархия памяти компьютера отражает тонкий баланс между скоростью, емкостью и стоимостью. На самом деле подобные компромиссы встречаются почти во всех отраслях инженерии: приходится искать оптимальный баланс между преимуществами и ограничениями.
В итоге **жесткий диск используется для долговременного хранения больших объемов данных, оперативная память - для временного хранения данных, с которыми программа работает прямо сейчас, а кэш - для хранения часто используемых данных и инструкций**, чтобы ускорять выполнение программ. Все три уровня работают совместно и обеспечивают эффективную работу компьютерной системы.
Как показано на рисунке ниже, во время выполнения программы данные читаются с жесткого диска в оперативную память, а затем используются CPU в вычислениях. Кэш можно рассматривать как часть CPU: **он интеллектуально подгружает данные из оперативной памяти**, обеспечивая CPU высокоскоростной доступ и тем самым значительно ускоряя выполнение программы и уменьшая зависимость от более медленной RAM.
![Поток данных между жестким диском, RAM и кэшем](ram_and_cache.assets/computer_storage_devices.png)
## Эффективность использования памяти структурами данных
С точки зрения использования пространства памяти массивы и связные списки имеют свои преимущества и ограничения.
С одной стороны, **память ограничена, и один и тот же участок памяти не может совместно использоваться несколькими программами**, поэтому нам хочется, чтобы структуры данных использовали пространство как можно эффективнее. Элементы массива расположены плотно и не требуют дополнительного места для хранения ссылок (указателей) между узлами списка, поэтому массивы эффективнее по памяти. Однако массиву нужно сразу выделить достаточно большой непрерывный участок памяти, что может приводить к потерям пространства, а его расширение требует дополнительных затрат времени и памяти. Напротив, связные списки выполняют динамическое выделение и освобождение памяти "по узлам", что дает большую гибкость.
С другой стороны, во время выполнения программы **при многократном выделении и освобождении памяти фрагментация свободной памяти становится все более серьезной**, что снижает эффективность ее использования. Массивы из-за непрерывного хранения относительно менее подвержены фрагментации. Напротив, элементы связного списка распределены по памяти, и частые операции вставки и удаления легче приводят к фрагментации.
## Эффективность использования кэша структурами данных
Хотя по объему кэш намного меньше оперативной памяти, он значительно быстрее и играет критически важную роль в скорости выполнения программ. Поскольку объем кэша ограничен и в нем можно хранить только небольшую долю часто используемых данных, когда CPU пытается обратиться к данным, которых в кэше нет, происходит <u>промах кэша (cache miss)</u> , и CPU вынужден загружать нужные данные из более медленной памяти.
Очевидно, что **чем меньше "промахов кэша", тем выше эффективность чтения и записи данных CPU**, а значит, тем лучше производительность программы. Долю обращений, при которых CPU успешно получает данные из кэша, называют <u>коэффициентом попадания в кэш (cache hit rate)</u> ; этот показатель обычно используют для оценки эффективности кэша.
Чтобы добиться как можно большей эффективности, кэш использует следующие механизмы загрузки данных.
- **Строки кэша**: кэш хранит и загружает данные не по одному байту, а строками кэша. По сравнению с передачей по байтам это гораздо эффективнее.
- **Механизм предвыборки**: процессор старается предсказать шаблон доступа к данным (например последовательный доступ, доступ с фиксированным шагом и т.д.) и на основе этого шаблона заранее загружает данные в кэш, повышая вероятность попадания.
- **Пространственная локальность**: если к некоторым данным уже обратились, то велика вероятность, что в ближайшее время понадобятся и соседние данные. Поэтому, загружая некоторые данные, кэш часто подгружает и окружающие их данные.
- **Временная локальность**: если к данным уже обратились, то высока вероятность, что к ним снова обратятся в ближайшем будущем. Кэш использует это свойство, сохраняя недавно использованные данные.
На практике **массивы и связные списки по-разному используют кэш**, и это проявляется в нескольких аспектах.
- **Занимаемое пространство**: элементы связного списка занимают больше места, чем элементы массива, поэтому в кэше помещается меньше полезных данных.
- **Строки кэша**: данные списка разбросаны по памяти, а кэш загружает данные "строками", поэтому доля бесполезно загружаемых данных оказывается выше.
- **Механизм предвыборки**: шаблон доступа к данным у массивов более "предсказуем", чем у списков, то есть системе легче угадать, какие данные понадобятся следующими.
- **Пространственная локальность**: массив хранится в компактной области памяти, поэтому данные рядом с уже загруженными с большей вероятностью скоро будут использованы.
В целом **массивы имеют более высокий коэффициент попадания в кэш, поэтому по эффективности операций они обычно превосходят связные списки**. Именно поэтому при решении алгоритмических задач структуры данных на основе массивов часто оказываются предпочтительнее.
Важно понимать, что **высокая эффективность кэша не означает, что массивы во всех случаях лучше связных списков**. В реальных приложениях выбор структуры данных должен определяться конкретными требованиями. Например, и массивы, и списки могут использоваться для реализации "стека" (подробнее об этом будет рассказано в следующей главе), но подходят они для разных сценариев.
- При решении алгоритмических задач мы обычно предпочитаем стек на основе массива, потому что он дает более высокую эффективность операций и поддерживает произвольный доступ, а цена за это - необходимость заранее выделить некоторый объем памяти под массив.
- Если объем данных очень велик, структура сильно динамична, а ожидаемый размер стека трудно оценить заранее, то более уместен стек на основе связного списка. Список позволяет распределить большой объем данных по разным участкам памяти и избегает накладных расходов, связанных с расширением массива.
@@ -0,0 +1,86 @@
# Резюме
### Ключевые выводы
- Массивы и связные списки - это две базовые структуры данных, представляющие два способа хранения данных в памяти компьютера: хранение в непрерывной области и хранение в разрозненных областях. Их свойства во многом взаимно дополняют друг друга.
- Массив поддерживает произвольный доступ и занимает меньше памяти; однако вставка и удаление элементов в нем неэффективны, а длина после инициализации неизменяема.
- Связный список позволяет эффективно вставлять и удалять узлы путем изменения ссылок (указателей), а также гибко менять длину; однако доступ к узлам неэффективен, а памяти он занимает больше. Распространенные типы списков включают односвязные, циклические и двусвязные списки.
- Список - это упорядоченная коллекция элементов, поддерживающая добавление, удаление, поиск и изменение, и обычно реализуемая на основе динамического массива. Он сохраняет преимущества массива и при этом может гибко менять длину.
- Появление списка значительно повысило практическую полезность массива, хотя это и может приводить к потерям части памяти.
- Во время работы программы данные в основном хранятся в оперативной памяти. Массив обеспечивает более высокую эффективность использования пространства памяти, а связный список дает большую гибкость в использовании памяти.
- Кэш, используя строки кэша, механизм предвыборки, а также пространственную и временную локальность, предоставляет CPU быстрый доступ к данным и заметно повышает эффективность выполнения программ.
- Поскольку массивы обычно имеют более высокий коэффициент попадания в кэш, они в большинстве случаев работают эффективнее списков. При выборе структуры данных нужно исходить из конкретных требований и сценариев.
### Q & A
**Q**: Влияет ли хранение массива в стеке или в куче на временную и пространственную эффективность?
Массивы, расположенные и в стеке, и в куче, все равно хранятся в непрерывной области памяти, поэтому эффективность операций с данными у них в целом одинакова. Однако у стека и кучи есть собственные особенности, из-за которых возникают следующие различия.
1. Эффективность выделения и освобождения: стек представляет собой относительно небольшой участок памяти, а выделение в нем обычно выполняется автоматически компилятором; куча же обычно больше, может выделяться динамически из кода и легче фрагментируется. Поэтому выделение и освобождение памяти в куче обычно медленнее, чем в стеке.
2. Ограничение размера: объем стека относительно невелик, а размер кучи обычно ограничивается доступной памятью. Поэтому куча лучше подходит для хранения больших массивов.
3. Гибкость: размер массива в стеке должен быть известен во время компиляции, а размер массива в куче может определяться динамически во время выполнения.
**Q**: Почему для массива требуется, чтобы все элементы были одного типа, а для связного списка это не подчеркивается?
Связный список состоит из узлов, а узлы соединяются между собой через ссылки (указатели), поэтому каждый узел в принципе может хранить данные разного типа, например `int` , `double` , `string` , `object` и т.д.
Напротив, элементы массива должны быть одного типа, иначе нельзя будет вычислять адрес элемента через смещение. Например, если массив одновременно содержит `int` и `long` , один элемент занимает 4 байта, а другой - 8 байт ; в этом случае формула ниже уже не позволит вычислить смещение, потому что в массиве будут присутствовать элементы разной длины.
```shell
# Адрес элемента в памяти = адрес массива в памяти (адрес первого элемента) + длина элемента * индекс элемента
```
**Q**: После удаления узла `P` нужно ли присваивать `P.next = None` ?
Можно и не изменять `P.next` . С точки зрения данного списка, при обходе от головы к хвосту узел `P` уже больше не встретится. Это означает, что узел `P` уже удален из списка, и то, куда он указывает после этого, на сам список больше не влияет.
С точки зрения задач по структурам данных и алгоритмам, отсутствие такого разрыва обычно не критично, если логика программы остается корректной. Но с точки зрения стандартной библиотеки разорвать связь безопаснее и логичнее. Если этого не сделать и удаленный узел не будет нормально собран, он может мешать освобождению памяти последующих узлов.
**Q**: Временная сложность вставки и удаления в связном списке равна $O(1)$ . Но до вставки или удаления обычно еще нужно потратить $O(n)$ на поиск элемента. Почему тогда общая сложность не $O(n)$ ?
Если сначала искать элемент, а потом удалять его, то временная сложность действительно будет $O(n)$ . Однако преимущество связного списка с $O(1)$ вставкой и удалением проявляется в других сценариях. Например, двустороннюю очередь удобно реализовывать именно на связном списке: мы поддерживаем указатели на голову и хвост, и тогда каждая операция вставки или удаления остается $O(1)$ .
**Q**: На рисунке "Определение связного списка и способ хранения" светло-голубой блок с указателем узла - это отдельный адрес памяти? Или он делит память пополам со значением узла?
Этот рисунок дает только качественное представление; количественно все зависит от конкретных условий.
- Значения узлов разных типов занимают разный объем памяти, например `int` , `long` , `double` и объекты-экземпляры.
- Размер памяти, занимаемой переменной-указателем, зависит от операционной системы и среды компиляции и обычно составляет 8 байт или 4 байта.
**Q**: Всегда ли добавление элемента в конец списка имеет сложность $O(1)$ ?
Если при добавлении элемента длина списка превышается, то сначала приходится расширять список, а уже затем добавлять новый элемент. Система выделяет новый участок памяти и переносит туда все элементы исходного списка, и в этот момент временная сложность становится $O(n)$ .
**Q**: В утверждении "появление списка сильно повысило практическую полезность массива, но может приводить к потере части памяти" под потерями памяти имеется в виду дополнительная память под такие переменные, как емкость, длина и коэффициент расширения?
Потери памяти здесь в основном имеют два значения: во-первых, список обычно имеет некоторую начальную емкость, которая может быть нам не нужна целиком; во-вторых, чтобы избежать слишком частых расширений, емкость при расширении обычно умножается на некоторый коэффициент, например $\times 1.5$ . Из-за этого появляется много пустых слотов, которые обычно нельзя полностью заполнить.
**Q**: В Python после инициализации `n = [1, 2, 3]` адреса этих трех элементов выглядят непрерывными, но после `m = [2, 1, 3]` можно заметить, что `id` элементов не идут подряд, а совпадают с одинаковыми числами из `n` . Если адреса элементов не непрерывны, остается ли `m` массивом?
Предположим, что элементами списка являются узлы `n = [n1, n2, n3, n4, n5]` . Обычно эти 5 объектов-узлов тоже будут храниться в разных местах памяти. Однако, имея индекс списка, мы по-прежнему можем за $O(1)$ получить адрес памяти соответствующего узла и обратиться к нему. Это связано с тем, что в массиве хранятся ссылки на узлы, а не сами узлы.
В отличие от многих других языков, в Python даже числа обернуты в объекты, и в списке хранятся не сами числа, а ссылки на них. Поэтому мы и наблюдаем, что одинаковые числа в двух массивах имеют один и тот же `id` , а адреса этих чисел не обязаны быть непрерывными.
**Q**: В C++ STL уже есть двусвязный список `std::list` , но в некоторых учебниках по алгоритмам им пользуются не так часто. Это связано с какими-то ограничениями?
С одной стороны, при разработке алгоритмов мы обычно предпочитаем структуры на основе массива, а к связным спискам прибегаем только при необходимости, по двум главным причинам.
- Накладные расходы по памяти: поскольку каждому элементу нужны два дополнительных указателя (на предыдущий и следующий элементы), `std::list` обычно занимает больше памяти, чем `std::vector` .
- Низкая дружелюбность к кэшу: поскольку данные не лежат непрерывно, `std::list` хуже использует кэш. В большинстве случаев `std::vector` показывает лучшую производительность.
С другой стороны, случаи, когда связный список действительно необходим, в основном возникают в деревьях и графах. Для стеков и очередей чаще используют предоставляемые языком `stack` и `queue` , а не связный список напрямую.
**Q**: Операция `res = [[0]] * n` создает двумерный список. Каждый `[0]` в нем независим?
Нет, они не независимы. В таком двумерном списке все `[0]` на самом деле являются ссылками на один и тот же объект. Если изменить один из них, окажется, что меняются и все остальные соответствующие элементы.
Если нужно, чтобы каждый `[0]` был независимым, можно использовать `res = [[0] for _ in range(n)]` . В этом варианте создаются $n$ независимых объектов-списков `[0]` .
**Q**: Операция `res = [0] * n` создает список. Каждый целочисленный `0` в нем независим?
В этом списке все целые числа `0` являются ссылками на один и тот же объект. Это связано с тем, что Python использует механизм кэш-пула для маленьких целых чисел (обычно от -5 до 256), чтобы максимально переиспользовать объекты и повысить производительность.
Хотя все элементы указывают на один и тот же объект, мы все равно можем независимо изменять элементы списка, потому что целые числа в Python - это "неизменяемые объекты". Когда мы изменяем некоторый элемент, на самом деле происходит переключение ссылки на другой объект, а не изменение исходного объекта.
Однако если элементами списка являются "изменяемые объекты" (например списки, словари или экземпляры классов), то изменение одного элемента прямо меняет сам объект, и все элементы, ссылающиеся на него, увидят одно и то же изменение.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,503 @@
# Алгоритм поиска с возвратом
<u>Алгоритм поиска с возвратом (backtracking algorithm)</u> - это метод решения задач путем полного перебора. Его основная идея состоит в том, чтобы, начиная с некоторого исходного состояния, грубо перебрать все возможные решения, записывать корректные решения и продолжать поиск до тех пор, пока решение не будет найдено или пока не будут исчерпаны все возможные варианты.
Обычно алгоритмы поиска с возвратом используют "поиск в глубину" для обхода пространства решений. В главе "Бинарные деревья" мы уже упоминали, что прямой, симметричный и обратный обходы относятся к поиску в глубину. Теперь мы на основе прямого обхода построим задачу backtracking и постепенно разберем принцип работы этого алгоритма.
!!! question "Пример 1"
Дано двоичное дерево. Найдите и запишите все узлы со значением $7$ ; верните список этих узлов.
Для этой задачи мы выполняем прямой обход дерева и проверяем, равно ли значение текущего узла $7$ ; если да, то добавляем значение этого узла в список результатов `res` . Соответствующий процесс показан на рисунке ниже и в коде:
```src
[file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order}
```
![Поиск узлов при прямом обходе](backtracking_algorithm.assets/preorder_find_nodes.png)
## Попытка и откат
**Алгоритм называется backtracking, потому что при поиске в пространстве решений он использует стратегию "попытка" и "откат"**. Когда в процессе поиска алгоритм приходит в состояние, из которого нельзя двигаться дальше или нельзя получить удовлетворяющее условиям решение, он отменяет предыдущий выбор, возвращается к более раннему состоянию и пробует другие возможные варианты.
Для примера 1 посещение каждого узла представляет собой "попытку", а прохождение листового узла или возврат к родителю через `return` означает "откат".
Важно понимать, что **откат не сводится только к возврату из функции**. Чтобы показать это, слегка расширим пример 1.
!!! question "Пример 2"
Найдите в двоичном дереве все узлы со значением $7$ и **верните пути от корня до этих узлов**.
Взяв за основу код примера 1, добавим список `path` для записи пути посещенных узлов. Когда встречается узел со значением $7$ , мы копируем `path` и добавляем его в список результатов `res` . После завершения обхода именно `res` будет содержать все решения. Код приведен ниже:
```src
[file]{preorder_traversal_ii_compact}-[class]{}-[func]{pre_order}
```
В каждой "попытке" мы добавляем текущий узел в `path` , чтобы записать путь; а перед "откатом" нам нужно удалить этот узел из `path` , **чтобы восстановить состояние, существовавшее до текущей попытки**.
Если посмотреть на процесс, изображенный на рисунке ниже, **то попытку и откат можно понимать как "движение вперед" и "отмену"**: это два взаимно противоположных действия.
=== "<1>"
![Попытка и откат](backtracking_algorithm.assets/preorder_find_paths_step1.png)
=== "<2>"
![preorder_find_paths_step2](backtracking_algorithm.assets/preorder_find_paths_step2.png)
=== "<3>"
![preorder_find_paths_step3](backtracking_algorithm.assets/preorder_find_paths_step3.png)
=== "<4>"
![preorder_find_paths_step4](backtracking_algorithm.assets/preorder_find_paths_step4.png)
=== "<5>"
![preorder_find_paths_step5](backtracking_algorithm.assets/preorder_find_paths_step5.png)
=== "<6>"
![preorder_find_paths_step6](backtracking_algorithm.assets/preorder_find_paths_step6.png)
=== "<7>"
![preorder_find_paths_step7](backtracking_algorithm.assets/preorder_find_paths_step7.png)
=== "<8>"
![preorder_find_paths_step8](backtracking_algorithm.assets/preorder_find_paths_step8.png)
=== "<9>"
![preorder_find_paths_step9](backtracking_algorithm.assets/preorder_find_paths_step9.png)
=== "<10>"
![preorder_find_paths_step10](backtracking_algorithm.assets/preorder_find_paths_step10.png)
=== "<11>"
![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png)
## Обрезка
Сложные задачи backtracking обычно содержат одно или несколько ограничений, **которые часто можно использовать для "обрезки"**.
!!! question "Пример 3"
Найдите в двоичном дереве все узлы со значением $7$ , верните пути от корня до этих узлов, **причем путь не должен содержать узлы со значением $3$**.
Чтобы выполнить это ограничение, **нам нужно добавить операцию обрезки**: во время поиска, если встречается узел со значением $3$ , мы сразу возвращаемся и не продолжаем дальнейший поиск. Код выглядит так:
```src
[file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order}
```
Термин "обрезка" очень нагляден. Как показано на рисунке ниже, во время поиска **мы "срезаем" ветви поиска, не удовлетворяющие ограничениям** , тем самым избегая множества бессмысленных попыток и повышая эффективность поиска.
![Обрезка по условиям задачи](backtracking_algorithm.assets/preorder_find_constrained_paths.png)
## Каркас кода
Теперь попробуем извлечь общий каркас из действий "попытка", "откат" и "обрезка", чтобы сделать код более универсальным.
В следующем каркасе кода `state` обозначает текущее состояние задачи, а `choices` - список выборов, доступных в текущем состоянии:
=== "Python"
```python title=""
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)
```
=== "C++"
```cpp title=""
/* Каркас алгоритма поиска с возвратом */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (Choice choice : choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice);
backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice);
}
}
}
```
=== "Java"
```java title=""
/* Каркас алгоритма поиска с возвратом */
void backtrack(State state, List<Choice> choices, List<State> res) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (Choice choice : choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice);
backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice);
}
}
}
```
=== "C#"
```csharp title=""
/* Каркас алгоритма поиска с возвратом */
void Backtrack(State state, List<Choice> choices, List<State> res) {
// Проверка, является ли текущее состояние решением
if (IsSolution(state)) {
// Запись решения
RecordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
foreach (Choice choice in choices) {
// Обрезка: проверка допустимости выбора
if (IsValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
MakeChoice(state, choice);
Backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
UndoChoice(state, choice);
}
}
}
```
=== "Go"
```go title=""
/* Каркас алгоритма поиска с возвратом */
func backtrack(state *State, choices []Choice, res *[]State) {
// Проверка, является ли текущее состояние решением
if isSolution(state) {
// Запись решения
recordSolution(state, res)
// Дальше не продолжаем поиск
return
}
// Перебор всех возможных выборов
for _, choice := range choices {
// Обрезка: проверка допустимости выбора
if isValid(state, choice) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice)
backtrack(state, choices, res)
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice)
}
}
}
```
=== "Swift"
```swift title=""
/* Каркас алгоритма поиска с возвратом */
func backtrack(state: inout State, choices: [Choice], res: inout [State]) {
// Проверка, является ли текущее состояние решением
if isSolution(state: state) {
// Запись решения
recordSolution(state: state, res: &res)
// Дальше не продолжаем поиск
return
}
// Перебор всех возможных выборов
for choice in choices {
// Обрезка: проверка допустимости выбора
if isValid(state: state, choice: choice) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state: &state, choice: choice)
backtrack(state: &state, choices: choices, res: &res)
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state: &state, choice: choice)
}
}
}
```
=== "JS"
```javascript title=""
/* Каркас алгоритма поиска с возвратом */
function backtrack(state, choices, res) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (let choice of choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice);
backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice);
}
}
}
```
=== "TS"
```typescript title=""
/* Каркас алгоритма поиска с возвратом */
function backtrack(state: State, choices: Choice[], res: State[]): void {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (let choice of choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice);
backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice);
}
}
}
```
=== "Dart"
```dart title=""
/* Каркас алгоритма поиска с возвратом */
void backtrack(State state, List<Choice>, List<State> res) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (Choice choice in choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice);
backtrack(state, choices, res);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice);
}
}
}
```
=== "Rust"
```rust title=""
/* Каркас алгоритма поиска с возвратом */
fn backtrack(state: &mut State, choices: &Vec<Choice>, res: &mut Vec<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);
}
}
}
```
=== "C"
```c title=""
/* Каркас алгоритма поиска с возвратом */
void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res, numRes);
// Дальше не продолжаем поиск
return;
}
// Перебор всех возможных выборов
for (int i = 0; i < numChoices; i++) {
// Обрезка: проверка допустимости выбора
if (isValid(state, &choices[i])) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, &choices[i]);
backtrack(state, choices, numChoices, res, numRes);
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, &choices[i]);
}
}
}
```
=== "Kotlin"
```kotlin title=""
/* Каркас алгоритма поиска с возвратом */
fun backtrack(state: State?, choices: List<Choice?>, res: List<State?>?) {
// Проверка, является ли текущее состояние решением
if (isSolution(state)) {
// Запись решения
recordSolution(state, res)
// Дальше не продолжаем поиск
return
}
// Перебор всех возможных выборов
for (choice in choices) {
// Обрезка: проверка допустимости выбора
if (isValid(state, choice)) {
// Попытка: сделать выбор и обновить состояние
makeChoice(state, choice)
backtrack(state, choices, res)
// Откат: отменить выбор и восстановить предыдущее состояние
undoChoice(state, choice)
}
}
}
```
=== "Ruby"
```ruby title=""
### Каркас алгоритма поиска с возвратом ###
def backtrack(state, choices, res)
# Проверка, является ли текущее состояние решением
if is_solution?(state)
# Запись решения
record_solution(state, res)
return
end
# Перебор всех возможных выборов
for choice in choices
# Обрезка: проверка допустимости выбора
if is_valid?(state, choice)
# Попытка: сделать выбор и обновить состояние
make_choice(state, choice)
backtrack(state, choices, res)
# Откат: отменить выбор и восстановить предыдущее состояние
undo_choice(state, choice)
end
end
end
```
Теперь, опираясь на этот каркас, решим пример 3. Состояние `state` здесь - это путь обхода узлов, выбор `choices` - левый и правый потомки текущего узла, а результат `res` - список путей:
```src
[file]{preorder_traversal_iii_template}-[class]{}-[func]{backtrack}
```
Согласно условию задачи, после нахождения узла со значением $7$ мы должны продолжать поиск, **поэтому оператор `return` после записи решения нужно удалить**. На рисунке ниже сравниваются процессы поиска в случаях, когда `return` сохраняется и когда он удаляется.
![Сравнение поиска при сохранении и удалении return](backtracking_algorithm.assets/backtrack_remove_return_or_not.png)
По сравнению с реализацией на основе прямого обхода, версия на основе общего каркаса backtracking выглядит более громоздкой, но при этом обладает лучшей универсальностью. На практике **многие задачи backtracking можно решать в рамках этого каркаса**. Для этого нужно лишь определить `state` и `choices` под конкретную задачу и реализовать соответствующие методы каркаса.
## Часто используемые термины
Чтобы яснее анализировать алгоритмические задачи, подытожим значения часто используемых терминов backtracking и сопоставим их с примером 3, как показано в таблице ниже.
<p align="center"> Таблица <id> &nbsp; Часто используемые термины алгоритма backtracking </p>
| Термин | Определение | Пример 3 |
| ------------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Решение (solution) | Решение - это ответ, удовлетворяющий условиям задачи; решений может быть одно или несколько | Все пути от корня до узла $7$ , удовлетворяющие ограничениям |
| Ограничение (constraint) | Ограничение определяет допустимость решения и обычно используется для обрезки | Путь не содержит узлы со значением $3$ |
| Состояние (state) | Состояние описывает ситуацию задачи в некоторый момент времени, включая уже сделанные выборы | Текущий путь посещенных узлов, то есть список узлов `path` |
| Попытка (attempt) | Попытка - это исследование пространства решений на основе доступных выборов, включая выбор, обновление состояния и проверку, является ли состояние решением | Рекурсивный переход к левому или правому потомку, добавление узла в `path` и проверка, равно ли значение узла $7$ |
| Откат (backtracking) | Откат означает отмену предыдущих выборов и возврат к более раннему состоянию при встрече состояния, не удовлетворяющего ограничениям | Завершение поиска при проходе через лист, окончании посещения узла или встрече узла со значением $3$ , то есть возврат из функции |
| Обрезка (pruning) | Обрезка - это способ избегать бессмысленных путей поиска на основе свойств задачи и ее ограничений, повышающий эффективность | При встрече узла со значением $3$ поиск по этой ветви прекращается |
!!! tip
Такие понятия, как задача, решение и состояние, являются общими и встречаются не только в backtracking, но и в divide and conquer, динамическом программировании, жадных алгоритмах и других темах.
## Преимущества и ограничения
Алгоритм поиска с возвратом по своей сути является алгоритмом поиска в глубину, который перебирает все возможные решения, пока не найдет удовлетворяющее условиям. Преимущество этого подхода в том, что он позволяет находить все возможные решения и при разумной обрезке может работать весьма эффективно.
Однако при работе с большими или сложными задачами **эффективность backtracking может оказаться неприемлемой**.
- **Время**: backtracking обычно требует обхода всех возможных состояний пространства состояний, и его временная сложность может достигать экспоненциального или факториального порядка.
- **Память**: при рекурсивных вызовах нужно хранить текущее состояние (например, путь, вспомогательные переменные для обрезки и т.д.), поэтому при большой глубине рекурсии потребность в памяти может стать значительной.
Тем не менее **backtracking по-прежнему остается лучшим решением для некоторых поисковых задач и задач удовлетворения ограничений**. В таких задачах заранее невозможно предсказать, какие выборы приведут к эффективному решению, поэтому приходится перебирать все возможные варианты. В этой ситуации **ключевым становится вопрос оптимизации эффективности** , и для этого обычно используют две стратегии.
- **Обрезка**: избегать поиска по тем путям, которые заведомо не приведут к решению, тем самым экономя время и память.
- **Эвристический поиск**: вводить во время поиска дополнительные стратегии или оценки, чтобы в первую очередь исследовать пути, наиболее вероятно ведущие к эффективному решению.
## Типичные задачи backtracking
Алгоритм поиска с возвратом можно использовать для решения множества поисковых задач, задач удовлетворения ограничений и задач комбинаторной оптимизации.
**Поисковые задачи**: целью таких задач является поиск решений, удовлетворяющих определенным условиям.
- Задача о перестановках: дано множество, требуется найти все возможные перестановки его элементов.
- Задача о сумме подмножеств: даны множество и целевая сумма; нужно найти все подмножества, сумма элементов которых равна целевой.
- Задача о Ханойской башне: даны три стержня и набор дисков разного размера; требуется перенести все диски с одного стержня на другой, перемещая за раз только один диск и не помещая больший диск на меньший.
**Задачи удовлетворения ограничений**: целью таких задач является поиск решений, удовлетворяющих всем ограничениям.
- Задача о $n$ ферзях: разместить $n$ ферзей на шахматной доске размера $n \times n$ так, чтобы они не атаковали друг друга.
- Судоку: заполнить сетку $9 \times 9$ числами от $1$ до $9$ так, чтобы в каждой строке, каждом столбце и каждом блоке $3 \times 3$ числа не повторялись.
- Задача раскраски графа: дан неориентированный граф; требуется раскрасить его вершины минимальным числом цветов так, чтобы соседние вершины имели разные цвета.
**Задачи комбинаторной оптимизации**: целью таких задач является поиск оптимального решения в некотором комбинаторном пространстве при заданных ограничениях.
- Задача о рюкзаке 0-1: даны набор предметов и рюкзак; у каждого предмета есть ценность и вес, и нужно выбрать предметы так, чтобы при ограниченной вместимости рюкзака суммарная ценность была максимальной.
- Задача коммивояжера: начиная из некоторой вершины графа, требуется посетить все остальные вершины ровно по одному разу и вернуться в исходную вершину, найдя при этом кратчайший путь.
- Задача о максимальной клике: дан неориентированный граф; требуется найти в нем максимальный полный подграф, то есть подграф, в котором любая пара вершин соединена ребром.
Обратите внимание: для многих задач комбинаторной оптимизации backtracking не является оптимальным способом решения.
- Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования, что дает более высокую временную эффективность.
- Задача коммивояжера является известной NP-Hard задачей; для ее решения часто используют генетические алгоритмы, муравьиные алгоритмы и другие методы.
- Задача о максимальной клике является классической задачей теории графов и может решаться жадными и другими эвристическими алгоритмами.
+9
View File
@@ -0,0 +1,9 @@
# Поиск с возвратом
![Поиск с возвратом](../assets/covers/chapter_backtracking.jpg)
!!! abstract
Мы словно исследователи в лабиринте: на пути вперед могут встречаться тупики и трудности.
Сила возврата позволяет нам начать заново, пробовать снова и снова и в конце концов найти выход к свету.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,53 @@
# Задача о n ферзях
!!! question
Согласно правилам шахмат ферзь может атаковать фигуры, находящиеся с ним на одной строке, в одном столбце или на одной диагонали. Даны $n$ ферзей и шахматная доска размера $n \times n$ ; требуется найти такие расстановки, при которых ни одна пара ферзей не может атаковать друг друга.
Как показано на рисунке ниже, при $n = 4$ существует два решения. С точки зрения backtracking доска размера $n \times n$ содержит $n^2$ клеток, которые образуют все возможные выборы `choices` . По мере поочередного размещения ферзей состояние доски непрерывно меняется, и текущее содержимое доски образует состояние `state` .
![Решения задачи о 4 ферзях](n_queens_problem.assets/solution_4_queens.png)
На рисунке ниже показаны три ограничения этой задачи: **несколько ферзей не могут находиться на одной строке, в одном столбце или на одной диагонали**. При этом нужно помнить, что диагонали бывают двух типов: главная `\` и побочная `/` .
![Ограничения задачи о n ферзях](n_queens_problem.assets/n_queens_constraints.png)
### Построчная стратегия размещения
Число ферзей и число строк доски одинаково и равно $n$ , поэтому легко получить следующий вывод: **в каждой строке доски разрешено и нужно разместить ровно одного ферзя**.
Иначе говоря, можно использовать построчную стратегию: начиная с первой строки, размещать по одному ферзю в каждой строке, пока не будет достигнута последняя.
На рисунке ниже показан процесс построчного размещения для задачи о 4 ферзях. Из-за ограничений размера изображения на нем раскрыта только одна ветвь поиска для первой строки, а все варианты, не удовлетворяющие ограничениям по столбцам и диагоналям, были отсечены.
![Построчная стратегия размещения](n_queens_problem.assets/n_queens_placing.png)
По своей сути **построчная стратегия сама по себе выполняет роль обрезки** , потому что заранее исключает все ветви поиска, в которых в одной строке оказалось бы несколько ферзей.
### Обрезка по столбцам и диагоналям
Чтобы удовлетворить ограничению по столбцам, можно использовать булев массив `cols` длины $n$ , который записывает, есть ли ферзь в каждом столбце. Перед каждым размещением мы используем `cols` для отсечения столбцов, уже занятых ферзями, а затем динамически обновляем состояние `cols` во время отката.
!!! tip
Обратите внимание: начало координат матрицы находится в левом верхнем углу, при этом индексы строк растут сверху вниз, а индексы столбцов - слева направо.
Как теперь обработать ограничения по диагоналям? Пусть клетка на доске имеет координаты $(row, col)$ . Выбрав некоторую главную диагональ в матрице, можно заметить, что разность индексов строки и столбца одинакова для всех клеток этой диагонали, **то есть для всех клеток главной диагонали значение $row - col$ постоянно**.
Это означает, что если для двух клеток выполняется равенство $row_1 - col_1 = row_2 - col_2$ , то они обязательно лежат на одной и той же главной диагонали. Используя это правило, можно с помощью массива `diags1` , показанного на рисунке ниже, отмечать наличие ферзя на каждой главной диагонали.
Аналогично **для всех клеток побочной диагонали значение $row + col$ является постоянным**. Поэтому для обработки ограничений по побочным диагоналям можно использовать еще один массив `diags2` .
![Обработка ограничений по столбцам и диагоналям](n_queens_problem.assets/n_queens_cols_diagonals.png)
### Реализация кода
Заметим, что в квадратной матрице размера $n$ диапазон значений $row - col$ равен $[-n + 1, n - 1]$ , а диапазон значений $row + col$ равен $[0, 2n - 2]$ . Следовательно, число главных и побочных диагоналей равно $2n - 1$ , а значит, длины массивов `diags1` и `diags2` тоже равны $2n - 1$ .
```src
[file]{n_queens}-[class]{}-[func]{n_queens}
```
Если размещать ферзей построчно $n$ раз, учитывая ограничение по столбцам, то начиная с первой строки и заканчивая последней мы получаем соответственно $n$, $n-1$, $\dots$, $2$, $1$ вариантов выбора, что дает $O(n!)$ времени. При записи решения нужно скопировать матрицу `state` и добавить ее в `res` , а копирование требует $O(n^2)$ времени. Следовательно, **общая временная сложность равна $O(n! \cdot n^2)$** . На практике обрезка по диагональным ограничениям дополнительно сильно уменьшает пространство поиска, поэтому фактическая эффективность часто лучше этой оценки.
Массив `state` использует $O(n^2)$ пространства, а массивы `cols` , `diags1` и `diags2` используют по $O(n)$ пространства. Максимальная глубина рекурсии равна $n$ , что требует $O(n)$ памяти стека. Следовательно, **пространственная сложность равна $O(n^2)$** .
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,95 @@
# Задача о перестановках
Задача о перестановках является типичным применением алгоритма поиска с возвратом. Ее определение состоит в том, чтобы для данного множества элементов (например, массива или строки) найти все возможные перестановки этих элементов.
В таблице ниже приведено несколько примеров входных массивов и соответствующих им перестановок.
<p align="center"> Таблица <id> &nbsp; Примеры перестановок </p>
| Входной массив | Все перестановки |
| :------------- | :----------------------------------------------------------------- |
| $[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]$ |
## Случай без равных элементов
!!! question
Дан массив целых чисел, в котором нет повторяющихся элементов. Верните все возможные перестановки.
С точки зрения backtracking **процесс построения перестановок можно представить как результат последовательности выборов**. Пусть входной массив равен $[1, 2, 3]$ ; если мы сначала выберем $1$ , затем $3$ , а потом $2$ , то получим перестановку $[1, 3, 2]$ . Откат означает отмену одного из выборов с последующей попыткой других вариантов.
С точки зрения кода backtracking множество кандидатов `choices` состоит из всех элементов входного массива, а состояние `state` - из элементов, уже выбранных к текущему моменту. Обратите внимание, что каждый элемент разрешено выбирать только один раз, **поэтому все элементы в `state` должны быть уникальны**.
Как показано на рисунке ниже, процесс поиска можно развернуть в дерево рекурсии, где каждый узел представляет текущее состояние `state` . Начиная от корня, после трех раундов выбора мы попадаем в листья, и каждый лист соответствует одной перестановке.
![Дерево рекурсии для перестановок](permutations_problem.assets/permutations_i.png)
### Обрезка повторного выбора
Чтобы гарантировать, что каждый элемент выбирается только один раз, введем булев массив `selected` , где `selected[i]` обозначает, был ли уже выбран `choices[i]` , и на его основе выполним следующую обрезку.
- После того как сделан выбор `choice[i]` , мы присваиваем `selected[i]` значение $\text{True}$ , тем самым отмечая, что этот элемент уже выбран.
- При обходе списка вариантов `choices` пропускаем все уже выбранные элементы, то есть выполняем обрезку.
Как показано на рисунке ниже, если в первом раунде мы выберем 1 , во втором - 3 , а в третьем - 2 , то во втором раунде нужно отсечь ветвь элемента 1 , а в третьем - ветви элементов 1 и 3 .
![Пример обрезки в задаче о перестановках](permutations_problem.assets/permutations_i_pruning.png)
Из рисунка видно, что такая обрезка уменьшает размер пространства поиска с $O(n^n)$ до $O(n!)$ .
### Реализация кода
После прояснения всей логики можно просто "заполнить пропуски" в шаблоне backtracking. Чтобы сократить общий объем кода, мы не будем отдельно реализовывать каждую функцию из каркаса, а раскроем их прямо внутри `backtrack()` :
```src
[file]{permutations_i}-[class]{}-[func]{permutations_i}
```
## Учет равных элементов
!!! question
Дан массив целых чисел, **который может содержать повторяющиеся элементы**. Верните все неповторяющиеся перестановки.
Пусть входной массив равен $[1, 1, 2]$ . Чтобы различать два одинаковых элемента $1$ , будем обозначать второй из них как $\hat{1}$ .
Как показано на рисунке ниже, описанный выше метод создаст результат, половина которого окажется дублирующейся.
![Повторяющиеся перестановки](permutations_problem.assets/permutations_ii.png)
Как же убрать повторяющиеся перестановки? Самый прямолинейный способ - воспользоваться хеш-множеством и удалить дубликаты уже после генерации результата. Но это не слишком изящно, **потому что ветви поиска, порождающие дубликаты, вообще не нужно посещать: их следует распознавать заранее и отсекать**, что дополнительно повышает эффективность алгоритма.
### Обрезка равных элементов
Посмотрите на рисунок ниже: в первом раунде выбрать $1$ или выбрать $\hat{1}$ - это одно и то же, а значит, все перестановки, полученные из этих двух выборов, будут дублироваться. Поэтому ветвь $\hat{1}$ нужно отсечь.
Точно так же, если в первом раунде выбрать $2$ , то во втором раунде выборы $1$ и $\hat{1}$ снова создадут дублирующиеся ветви, поэтому и в этом случае ветвь $\hat{1}$ нужно отсечь.
По своей сути **наша цель заключается в том, чтобы на каждом раунде выбора каждый из нескольких равных элементов выбирался только один раз**.
![Обрезка повторяющихся перестановок](permutations_problem.assets/permutations_ii_pruning.png)
### Реализация кода
На основе решения из предыдущей задачи можно на каждом раунде выбора заводить хеш-множество `duplicated` , которое будет записывать элементы, уже встречавшиеся в этом раунде, и отсекать повторы:
```src
[file]{permutations_ii}-[class]{}-[func]{permutations_ii}
```
Если предположить, что все элементы попарно различны, то из $n$ элементов можно получить $n!$ перестановок; при записи результата требуется копировать список длины $n$ , что занимает $O(n)$ времени. **Следовательно, временная сложность равна $O(n!n)$** .
Максимальная глубина рекурсии равна $n$ , что требует $O(n)$ стековой памяти. Массив `selected` занимает $O(n)$ пространства. Одновременно может существовать до $n$ хеш-множеств `duplicated` , что дает $O(n^2)$ памяти. **Следовательно, пространственная сложность равна $O(n^2)$** .
### Сравнение двух видов обрезки
Обратите внимание: хотя и `selected` , и `duplicated` используются для обрезки, их цели различаются.
- **Обрезка повторного выбора**: во всем процессе поиска существует только один `selected` . Он записывает, какие элементы уже входят в текущее состояние, и нужен для того, чтобы один и тот же элемент не появлялся в `state` дважды.
- **Обрезка равных элементов**: каждый раунд выбора (каждый вызов `backtrack`) содержит собственный `duplicated` . Он записывает, какие элементы уже выбирались в текущем раунде (`for` цикле), и нужен для того, чтобы равные элементы выбирались только один раз.
На рисунке ниже показана область действия двух условий обрезки. Помните, что каждый узел дерева соответствует одному выбору, а путь от корня до листа образует одну перестановку.
![Область действия двух условий обрезки](permutations_problem.assets/permutations_ii_pruning_summary.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,95 @@
# Задача о сумме подмножеств
## Случай без повторяющихся элементов
!!! question
Дан массив положительных целых чисел `nums` и целое положительное значение `target` . Найдите все возможные комбинации, сумма элементов которых равна `target` . Во входном массиве нет повторяющихся элементов, и каждый элемент можно выбирать неограниченное число раз. Верните эти комбинации в виде списка; в результате не должно быть повторяющихся комбинаций.
Например, для входного множества $\{3, 4, 5\}$ и целевого значения $9$ решениями будут $\{3, 3, 3\}$ и $\{4, 5\}$ . При этом нужно обратить внимание на два обстоятельства.
- Элементы входного множества можно выбирать повторно неограниченное число раз.
- Подмножество не различает порядок элементов, поэтому $\{4, 5\}$ и $\{5, 4\}$ считаются одним и тем же подмножеством.
### Отталкиваемся от решения задачи о перестановках
Как и в задаче о перестановках, можно представлять построение подмножеств как результат последовательности выборов и во время выбора динамически обновлять "сумму элементов"; когда эта сумма становится равной `target` , соответствующее подмножество записывается в список результатов.
Однако в отличие от задачи о перестановках **в этой задаче элементы множества можно выбирать неограниченное число раз**, поэтому нам не нужен булев список `selected` для записи того, был ли выбран элемент. Можно слегка изменить код для перестановок и получить первоначальную версию решения:
```src
[file]{subset_sum_i_naive}-[class]{}-[func]{subset_sum_i_naive}
```
Если подать на этот код массив $[3, 4, 5]$ и целевое значение $9$ , то на выходе мы получим $[3, 3, 3], [4, 5], [5, 4]$ . **Хотя все подмножества с суммой $9$ успешно найдены, среди них все же присутствуют дубликаты: $[4, 5]$ и $[5, 4]$** .
Причина в том, что процесс поиска различает порядок выбора, тогда как для подмножеств порядок не важен. Как показано на рисунке ниже, сначала выбрать $4$ , а затем $5$ , и сначала выбрать $5$ , а затем $4$ - это разные ветви поиска, но им соответствует одно и то же подмножество.
![Поиск подмножеств и обрезка по выходу за границу](subset_sum_problem.assets/subset_sum_i_naive.png)
Чтобы убрать повторяющиеся подмножества, **одна из прямых идей - удалить дубликаты уже из итогового списка результатов**. Но это решение малоэффективно по двум причинам.
- Когда массив содержит много элементов, а особенно когда `target` велик, процесс поиска порождает огромное число повторяющихся подмножеств.
- Сравнение подмножеств (то есть массивов) само по себе довольно затратно: сначала приходится сортировать массивы, а затем поэлементно сравнивать их.
### Обрезка повторяющихся подмножеств
**Поэтому стоит выполнять устранение дубликатов прямо во время поиска, с помощью обрезки**. Посмотрите на рисунок ниже: повторяющиеся подмножества возникают тогда, когда элементы массива выбираются в разном порядке, например так.
1. Если в первом и втором раундах выбрать соответственно $3$ и $4$ , то будут сгенерированы все подмножества, содержащие эти два элемента, и их можно обозначить как $[3, 4, \dots]$ .
2. После этого, если в первом раунде выбрать $4$ , **то во втором раунде нужно пропустить $3$** , потому что подмножества $[4, 3, \dots]$ полностью дублируют подмножества, уже построенные на шаге `1.` .
Во время поиска выборы на каждом уровне пробуются по одному слева направо, поэтому чем правее ветвь, тем больше ветвей оказывается отсечено.
1. В первых двух раундах выбираются $3$ и $5$ , что дает подмножества $[3, 5, \dots]$ .
2. В первых двух раундах выбираются $4$ и $5$ , что дает подмножества $[4, 5, \dots]$ .
3. Если же в первом раунде выбрать $5$ , **то во втором раунде нужно пропустить $3$ и $4$** , потому что подмножества $[5, 3, \dots]$ и $[5, 4, \dots]$ полностью дублируют случаи, описанные в шагах `1.` и `2.` .
![Повторяющиеся подмножества из-за разного порядка выбора](subset_sum_problem.assets/subset_sum_i_pruning.png)
В общем виде, если входной массив имеет вид $[x_1, x_2, \dots, x_n]$ , а последовательность выборов в ходе поиска равна $[x_{i_1}, x_{i_2}, \dots, x_{i_m}]$ , то она должна удовлетворять условию $i_1 \leq i_2 \leq \dots \leq i_m$ ; **все последовательности выборов, не удовлетворяющие этому условию, приводят к дубликатам и должны отсекаться**.
### Реализация кода
Чтобы реализовать такую обрезку, инициализируем переменную `start` , которая будет указывать начальную точку обхода. **После выбора элемента $x_i$ следующий раунд начинается с индекса $i$**. Благодаря этому последовательность выборов всегда удовлетворяет условию $i_1 \leq i_2 \leq \dots \leq i_m$ , а значит, каждое подмножество создается только один раз.
Помимо этого, мы внесем в код еще два улучшения.
- Перед началом поиска отсортируем массив `nums` . Тогда при обходе всех вариантов **можно сразу прервать цикл, как только сумма подмножества превысит `target`** , потому что все последующие элементы будут еще больше и их сумма тоже превысит `target` .
- Откажемся от отдельной переменной суммы `total` и **будем учитывать сумму через вычитание из `target`** ; когда `target` станет равным $0$ , решение фиксируется.
```src
[file]{subset_sum_i}-[class]{}-[func]{subset_sum_i}
```
На рисунке ниже показан полный процесс backtracking для массива $[3, 4, 5]$ и целевого значения $9$ .
![Процесс backtracking для задачи о сумме подмножеств I](subset_sum_problem.assets/subset_sum_i.png)
## Учет повторяющихся элементов
!!! question
Дан массив положительных целых чисел `nums` и целое положительное значение `target` . Найдите все возможные комбинации, сумма элементов которых равна `target` . **Во входном массиве могут присутствовать повторяющиеся элементы, и каждый элемент разрешено выбирать только один раз**. Верните эти комбинации в виде списка; в результате не должно быть повторяющихся комбинаций.
По сравнению с предыдущей задачей **во входном массиве теперь могут присутствовать повторяющиеся элементы**, и это создает новую проблему. Например, если дан массив $[4, \hat{4}, 5]$ и целевое значение $9$ , то существующий код вернет результат $[4, 5], [\hat{4}, 5]$ , то есть с повторяющимся подмножеством.
**Причина появления дублей в том, что равные элементы выбираются несколько раз в одном и том же раунде**. На рисунке ниже в первом раунде существует три варианта выбора, и два из них равны $4$ ; из-за этого появляются две дублирующиеся ветви поиска и, соответственно, повторяющиеся подмножества. Точно так же два элемента $4$ во втором раунде тоже порождают дубликаты.
![Повторяющиеся подмножества из-за равных элементов](subset_sum_problem.assets/subset_sum_ii_repeat.png)
### Обрезка равных элементов
Чтобы решить эту проблему, **нужно ограничить выбор равных элементов так, чтобы в каждом раунде каждый из них выбирался только один раз**. Реализуется это довольно изящно: поскольку массив отсортирован, равные элементы стоят рядом. Значит, если в текущем раунде текущий элемент равен соседнему слева, то этот вариант уже был рассмотрен, и текущий элемент нужно пропустить.
Одновременно **по условию этой задачи каждый элемент массива можно выбрать только один раз**. К счастью, это ограничение тоже можно реализовать через переменную `start` : после выбора элемента $x_i$ следующий раунд начинается с индекса $i + 1$ . Так мы одновременно убираем повторяющиеся подмножества и исключаем повторный выбор одного и того же элемента.
### Реализация кода
```src
[file]{subset_sum_ii}-[class]{}-[func]{subset_sum_ii}
```
На рисунке ниже показан процесс backtracking для массива $[4, 4, 5]$ и целевого значения $9$ . В нем используются четыре вида обрезки. Попробуйте сопоставить рисунок с комментариями в коде, чтобы понять полный процесс поиска и то, как работает каждый тип обрезки.
![Процесс backtracking для задачи о сумме подмножеств II](subset_sum_problem.assets/subset_sum_ii.png)
+23
View File
@@ -0,0 +1,23 @@
# Резюме
### Ключевые выводы
- Алгоритм поиска с возвратом по своей сути является методом полного перебора: он ищет решения путем обхода пространства решений в глубину. Во время поиска он фиксирует решения, удовлетворяющие условиям, пока не найдет все такие решения или пока обход не завершится.
- Процесс backtracking состоит из двух частей: попытки и отката. Он с помощью поиска в глубину пробует разные варианты выбора; когда встречается состояние, не удовлетворяющее ограничениям, алгоритм отменяет предыдущий выбор, возвращается к прошлому состоянию и продолжает пробовать другие варианты. Попытка и откат являются двумя противоположными по направлению действиями.
- Задачи backtracking обычно содержат несколько ограничений, которые можно использовать для обрезки. Обрезка позволяет заранее завершать ненужные ветви поиска и тем самым значительно повышать эффективность.
- Алгоритм backtracking в первую очередь применяется для решения поисковых задач и задач с ограничениями. Задачи комбинаторной оптимизации тоже можно решать с его помощью, но для них часто существуют более эффективные или более подходящие методы.
- Задача о перестановках нацелена на поиск всех возможных перестановок элементов данного множества. Мы используем массив для записи того, был ли выбран каждый элемент, и отсекаем ветви, где один и тот же элемент выбирается повторно, чтобы гарантировать однократный выбор каждого элемента.
- В задаче о перестановках, если во множестве присутствуют повторяющиеся элементы, в итоговом результате возникнут повторяющиеся перестановки. Поэтому нужно ограничить выбор равных элементов так, чтобы в каждом раунде каждый из них выбирался только один раз; обычно это реализуется с помощью хеш-множества.
- Цель задачи о сумме подмножеств - найти все подмножества данного множества, сумма которых равна целевому значению. В множестве порядок элементов не важен, однако процесс поиска порождает результаты во всех возможных порядках, из-за чего появляются повторяющиеся подмножества. Поэтому перед запуском backtracking мы сортируем данные и вводим переменную, указывающую начальную точку обхода в каждом раунде, чтобы отсечь ветви, создающие дубликаты.
- В задаче о сумме подмножеств равные элементы массива также порождают повторяющиеся множества. При наличии предварительной сортировки их можно отсекать, проверяя равенство соседних элементов, и тем самым гарантировать, что в каждом раунде равные элементы будут выбираться только один раз.
- Задача о $n$ ферзях состоит в поиске способов разместить $n$ ферзей на доске размера $n \times n$ так, чтобы никакие два ферзя не атаковали друг друга. Ограничения этой задачи включают строки, столбцы, главные диагонали и побочные диагонали. Чтобы выполнить ограничение по строкам, используется построчная стратегия размещения, гарантирующая по одному ферзю в каждой строке.
- Обработка ограничений по столбцам и диагоналям устроена похожим образом. Для ограничения по столбцам используется массив, фиксирующий наличие ферзя в каждом столбце. Для диагоналей используются два массива, записывающие наличие ферзей на главных и побочных диагоналях. Основная сложность здесь состоит в том, чтобы найти закономерность индексов строк и столбцов клеток, лежащих на одной и той же главной или побочной диагонали.
### Q & A
**Q**: Как понять связь между поиском с возвратом и рекурсией?
В целом backtracking - это скорее "алгоритмическая стратегия", а рекурсия больше похожа на "инструмент".
- Алгоритмы поиска с возвратом обычно реализуются на основе рекурсии. Однако backtracking - это лишь один из вариантов применения рекурсии, а именно ее использование в поисковых задачах.
- Структура рекурсии отражает парадигму разбиения на подзадачи и часто применяется для решения задач divide and conquer, backtracking, динамического программирования (мемоизированной рекурсии) и других подобных задач.
@@ -0,0 +1,9 @@
# Анализ сложности
![Анализ сложности](../assets/covers/chapter_complexity_analysis.jpg)
!!! abstract
Анализ сложности подобен пространственно-временному проводнику в огромной вселенной алгоритмов.
Он ведет нас вглубь двух измерений - времени и пространства, помогая искать более изящные решения.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,194 @@
# Итерация и рекурсия
В алгоритмах очень часто приходится многократно выполнять одну и ту же задачу, и это тесно связано с анализом сложности. Поэтому, прежде чем переходить к временной и пространственной сложности, давай сначала разберемся, как в программах организуется повторяющееся выполнение задач, то есть с двумя базовыми управляющими структурами: итерацией и рекурсией.
## Итерация
<u>Итерация (iteration)</u> - это управляющая структура, предназначенная для многократного выполнения некоторой задачи. При итерации программа повторно выполняет определенный фрагмент кода при соблюдении некоторого условия, пока это условие не перестанет выполняться.
### Цикл for
Цикл `for` - одна из самых распространенных форм итерации, **она хорошо подходит в тех случаях, когда число повторений известно заранее**.
Следующая функция реализует вычисление суммы $1 + 2 + \dots + n$ на основе цикла `for` , а результат сохраняется в переменной `res` . Обрати внимание, что в Python `range(a, b)` соответствует "лево-замкнутому, право-открытому" интервалу, то есть перебираются значения $a, a + 1, \dots, b-1$ :
```src
[file]{iteration}-[class]{}-[func]{for_loop}
```
На рисунке ниже показана блок-схема этой функции суммирования.
![Блок-схема функции суммирования](iteration_and_recursion.assets/iteration.png)
Число операций в этой функции суммирования пропорционально размеру входных данных $n$ , то есть между ними существует "линейная зависимость". На самом деле **временная сложность как раз и описывает такую "линейную зависимость"**. Соответствующий материал будет подробно разобран в следующем разделе.
### Цикл while
Подобно циклу `for` , цикл `while` тоже является способом реализации итерации. В цикле `while` программа в каждом раунде сначала проверяет условие: если условие истинно, выполнение продолжается, иначе цикл завершается.
Ниже мы используем цикл `while` для реализации суммы $1 + 2 + \dots + n$ :
```src
[file]{iteration}-[class]{}-[func]{while_loop}
```
**Цикл `while` обладает большей свободой, чем цикл `for` **. В цикле `while` мы можем свободно задавать шаги инициализации и обновления условной переменной.
Например, в следующем коде условная переменная $i$ обновляется два раза за один проход, и такой случай уже не слишком удобно выражать через цикл `for` :
```src
[file]{iteration}-[class]{}-[func]{while_loop_ii}
```
В целом **код с `for` обычно компактнее, а `while` более гибок**; обе конструкции позволяют реализовывать итерационные структуры. Выбор между ними должен определяться требованиями конкретной задачи.
### Вложенные циклы
Мы можем вкладывать одну циклическую структуру в другую; ниже показан пример на основе цикла `for` :
```src
[file]{iteration}-[class]{}-[func]{nested_for_loop}
```
На рисунке ниже показана блок-схема такого вложенного цикла.
![Блок-схема вложенного цикла](iteration_and_recursion.assets/nested_iteration.png)
В этом случае число операций функции пропорционально $n^2$ , то есть время работы алгоритма и размер входных данных $n$ находятся в "квадратичной зависимости".
Мы можем продолжать добавлять вложенные циклы, и каждое новое вложение будет означать очередное "повышение размерности", увеличивая временную сложность до "кубической зависимости", "зависимости четвертой степени" и так далее.
## Рекурсия
<u>Рекурсия (recursion)</u> - это алгоритмическая стратегия, в которой функция решает задачу, вызывая саму себя. В основном она включает две фазы.
1. **Спуск**: программа все глубже вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не достигнет "условия завершения".
2. **Подъем**: после срабатывания "условия завершения" программа начинает возвращаться от самой глубокой рекурсивной функции вверх, собирая результаты с каждого уровня.
С точки зрения реализации рекурсивный код в основном состоит из трех элементов.
1. **Условие завершения**: определяет момент перехода от "спуска" к "подъему".
2. **Рекурсивный вызов**: соответствует "спуску", когда функция вызывает саму себя, обычно с меньшими или более упрощенными параметрами.
3. **Возврат результата**: соответствует "подъему", когда результат текущего уровня рекурсии передается предыдущему.
Посмотри на следующий код: нам достаточно вызвать функцию `recur(n)` , чтобы вычислить $1 + 2 + \dots + n$ :
```src
[file]{recursion}-[class]{}-[func]{recur}
```
На рисунке ниже показан рекурсивный процесс этой функции.
![Рекурсивный процесс функции суммирования](iteration_and_recursion.assets/recursion_sum.png)
Хотя с вычислительной точки зрения итерация и рекурсия могут давать один и тот же результат, **они представляют собой две совершенно разные парадигмы мышления и решения задач**.
- **Итерация**: решает задачу "снизу вверх". Мы начинаем с самых базовых шагов, а затем многократно повторяем или накапливаем их, пока задача не будет завершена.
- **Рекурсия**: решает задачу "сверху вниз". Исходная задача разбивается на более мелкие подзадачи той же формы. Затем эти подзадачи продолжают разбиваться еще дальше, пока не будет достигнут базовый случай (для которого решение уже известно).
Возьмем в качестве примера указанную выше функцию суммирования и обозначим задачу как $f(n) = 1 + 2 + \dots + n$ .
- **Итерация**: в цикле моделируется процесс суммирования от $1$ до $n$ , и на каждом шаге выполняется операция сложения, в результате чего получается $f(n)$ .
- **Рекурсия**: задача раскладывается на подзадачу $f(n) = n + f(n-1)$ , а затем продолжает раскладываться (рекурсивно) до базового случая $f(1) = 1$ .
### Стек вызовов
Каждый раз, когда рекурсивная функция вызывает сама себя, система выделяет память для нового экземпляра функции, чтобы хранить локальные переменные, адрес возврата и другую информацию. Это приводит к двум последствиям.
- Контекстные данные функции хранятся в области памяти, называемой "пространством кадра стека", и освобождаются только после возврата функции. Поэтому **рекурсия обычно требует больше памяти, чем итерация**.
- Вызов рекурсивной функции создает дополнительный накладной расход. **Поэтому рекурсия обычно уступает циклам по временной эффективности**.
Как показано на рисунке ниже, до срабатывания условия завершения одновременно существует $n$ еще не завершившихся рекурсивных вызовов, а **глубина рекурсии равна $n$** .
![Глубина рекурсивного вызова](iteration_and_recursion.assets/recursion_sum_depth.png)
На практике разрешенная языком программирования глубина рекурсии обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека.
### Хвостовая рекурсия
Интересно, что **если функция выполняет рекурсивный вызов в самом последнем действии перед возвратом** , то компилятор или интерпретатор может оптимизировать такую функцию так, чтобы по использованию памяти она была сопоставима с итерацией. Такой случай называется <u>хвостовой рекурсией (tail recursion)</u>.
- **Обычная рекурсия**: когда функция возвращается на предыдущий уровень, ей все еще нужно продолжать выполнять код, поэтому системе приходится сохранять контекст вызова предыдущего уровня.
- **Хвостовая рекурсия**: рекурсивный вызов - это последняя операция перед возвратом, а значит, после возвращения на предыдущий уровень не требуется выполнять дополнительных действий, и системе не нужно сохранять контекст предыдущей функции.
На примере вычисления $1 + 2 + \dots + n$ можно сделать переменную результата `res` параметром функции и тем самым реализовать хвостовую рекурсию:
```src
[file]{recursion}-[class]{}-[func]{tail_recur}
```
Процесс выполнения хвостовой рекурсии показан на рисунке ниже. Если сравнить обычную рекурсию и хвостовую рекурсию, то видно, что точка выполнения операции суммирования у них различается.
- **Обычная рекурсия**: операция суммирования выполняется в процессе "подъема", то есть после возврата с каждого уровня еще нужно выполнить очередное сложение.
- **Хвостовая рекурсия**: операция суммирования выполняется в процессе "спуска", а сам "подъем" сводится лишь к последовательному возврату.
![Процесс хвостовой рекурсии](iteration_and_recursion.assets/tail_recursion_sum.png)
!!! tip
Обрати внимание: многие компиляторы и интерпретаторы не поддерживают оптимизацию хвостовой рекурсии. Например, Python по умолчанию такую оптимизацию не выполняет, поэтому даже функция в хвостово-рекурсивной форме все равно может привести к переполнению стека.
### Дерево рекурсии
При решении алгоритмических задач, связанных с "разделяй и властвуй", рекурсия часто дает более интуитивный способ рассуждения и более читаемый код, чем итерация. Возьмем в качестве примера "последовательность Фибоначчи".
!!! question
Дана последовательность Фибоначчи $0, 1, 1, 2, 3, 5, 8, 13, \dots$ ; найди $n$-й элемент этой последовательности.
Обозначим $n$-й элемент последовательности Фибоначчи как $f(n)$ . Тогда нетрудно получить два вывода.
- Первые два числа последовательности равны $f(1) = 0$ и $f(2) = 1$ .
- Каждое последующее число равно сумме двух предыдущих, то есть $f(n) = f(n - 1) + f(n - 2)$ .
Следуя рекуррентному соотношению и используя первые два числа как условия завершения, мы можем написать рекурсивный код. Вызов `fib(n)` даст нам $n$-й элемент последовательности Фибоначчи:
```src
[file]{recursion}-[class]{}-[func]{fib}
```
Если посмотреть на приведенный код, внутри функции выполняются два рекурсивных вызова, **а это означает, что один вызов рождает две ветви вызова**. Как показано на рисунке ниже, при таком продолжении рекурсивных вызовов в итоге получается <u>дерево рекурсии (recursion tree)</u> глубиной $n$ .
![Дерево рекурсии последовательности Фибоначчи](iteration_and_recursion.assets/recursion_tree.png)
По своей сути рекурсия воплощает парадигму "разбиения задачи на более мелкие подзадачи", и именно поэтому стратегия разделяй-и-властвуй столь важна.
- С точки зрения алгоритмов многие важнейшие стратегии, такие как поиск, сортировка, бэктрекинг, разделяй-и-властвуй и динамическое программирование, прямо или косвенно используют такой образ мышления.
- С точки зрения структур данных рекурсия естественным образом подходит для решения задач, связанных со связными списками, деревьями и графами, потому что они хорошо поддаются анализу через идеи разделения задачи.
## Сравнение двух подходов
Обобщая все сказанное выше, можно представить различия между итерацией и рекурсией с точки зрения реализации, производительности и применимости в следующей таблице.
<p align="center"> Таблица <id> &nbsp; Сравнение характеристик итерации и рекурсии </p>
| | Итерация | Рекурсия |
| -------- | -------------------------------------- | ------------------------------------------------------------ |
| Реализация | Циклическая структура | Функция вызывает сама себя |
| Временная эффективность | Обычно выше, так как нет накладных расходов на вызовы функций | Каждый вызов функции создает накладные расходы |
| Использование памяти | Обычно требуется фиксированный объем памяти | Накопление вызовов функции может занимать много места в кадрах стека |
| Подходящие задачи | Хорошо подходит для простых циклических задач, код интуитивен и легко читается | Хорошо подходит для разложения на подзадачи, например для деревьев, графов, разделяй-и-властвуй, бэктрекинга и т. д.; код при этом получается компактным и ясным |
!!! tip
Если тебе сложно понять дальнейшее содержание, можешь вернуться к нему после чтения главы о "стеке".
Какова же внутренняя связь между итерацией и рекурсией? Если снова взять рекурсивную функцию выше, операция суммирования выполняется в фазе "подъема" рекурсии. Это означает, что функция, вызванная первой, на самом деле завершает сложение последней, **и такой механизм очень похож на принцип стека "последним пришел - первым ушел"**.
На самом деле такие термины рекурсии, как "стек вызовов" и "пространство кадра стека", уже прямо намекают на тесную связь между рекурсией и стеком.
1. **Спуск**: когда вызывается функция, система выделяет для нее новый кадр стека в "стеке вызовов", чтобы хранить локальные переменные, параметры, адрес возврата и другие данные.
2. **Подъем**: когда функция завершает выполнение и возвращается, соответствующий кадр стека удаляется из "стека вызовов", а среда выполнения предыдущей функции восстанавливается.
Поэтому **мы можем использовать явный стек для имитации поведения стека вызовов** и тем самым преобразовать рекурсию в итеративную форму:
```src
[file]{recursion}-[class]{}-[func]{for_loop_recur}
```
Если посмотреть на приведенный выше код, видно, что после преобразования рекурсии в итерацию код становится сложнее. Хотя во многих случаях итерация и рекурсия действительно могут быть преобразованы друг в друга, это не всегда стоит делать по двум причинам.
- Преобразованный код может стать труднее для понимания и менее читаемым.
- Для некоторых сложных задач имитация поведения системного стека вызовов может оказаться очень трудной.
Итак, **выбор между итерацией и рекурсией зависит от природы конкретной задачи**. В практическом программировании крайне важно взвешивать плюсы и минусы обоих подходов и выбирать подходящий метод с учетом контекста.
@@ -0,0 +1,49 @@
# Оценка эффективности алгоритмов
При проектировании алгоритмов мы последовательно стремимся к двум уровням целей.
1. **Найти решение задачи**: алгоритм должен надежно получать правильный ответ в заданном диапазоне входных данных.
2. **Найти оптимальное решение**: для одной и той же задачи может существовать несколько решений, и нам хочется выбрать максимально эффективный алгоритм.
Иными словами, если задача в принципе решается, эффективность алгоритма становится главным критерием оценки его качества. Она включает два следующих измерения.
- **Временная эффективность**: сколько времени работает алгоритм.
- **Пространственная эффективность**: сколько памяти занимает алгоритм.
Короче говоря, **наша цель - проектировать структуры данных и алгоритмы, которые "и быстры, и экономны по памяти"**. Эффективная оценка алгоритмов крайне важна, потому что только так можно сравнивать разные алгоритмы и направлять процесс их проектирования и оптимизации.
Методы оценки эффективности в основном делятся на два типа: практическое тестирование и теоретическая оценка.
## Практическое тестирование
Предположим, у нас есть алгоритм `A` и алгоритм `B`, оба решают одну и ту же задачу, и нам нужно сравнить их эффективность. Самый прямой способ - взять компьютер, запустить оба алгоритма и зафиксировать время работы и объем используемой памяти. Такой способ оценки отражает реальную ситуацию, но имеет и серьезные ограничения.
С одной стороны, **трудно исключить влияние факторов тестовой среды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм имеет высокий уровень параллелизма, он лучше подходит для многоядерных CPU; если алгоритм интенсивно работает с памятью, он покажет себя лучше на быстрой памяти. Иными словами, результаты тестирования одного и того же алгоритма на разных машинах могут различаться. Это означает, что пришлось бы тестировать на самых разных машинах и усреднять результаты, а на практике это нереалистично.
С другой стороны, **полное тестирование требует больших ресурсов**. По мере изменения объема входных данных алгоритм может вести себя по-разному. Например, при небольшом объеме входных данных время работы алгоритма `A` может быть меньше, чем у алгоритма `B`; но при большом объеме результаты могут оказаться прямо противоположными. Поэтому для убедительных выводов пришлось бы тестировать входные данные множества разных масштабов, а это требует значительных вычислительных ресурсов.
## Теоретическая оценка
Поскольку практическое тестирование имеет серьезные ограничения, можно попытаться оценить эффективность алгоритма только с помощью вычислений. Такой метод называется <u>асимптотическим анализом сложности (asymptotic complexity analysis)</u>, или сокращенно <u>анализом сложности</u>.
Анализ сложности показывает зависимость между временем и пространственными ресурсами, требуемыми алгоритму, и масштабом входных данных. **Он описывает тенденцию роста времени и памяти, необходимых алгоритму, по мере увеличения размера входных данных**. Это определение звучит немного тяжеловесно, поэтому полезно разложить его на три ключевые идеи.
- "Временные и пространственные ресурсы" соответствуют <u>временной сложности (time complexity)</u> и <u>пространственной сложности (space complexity)</u> соответственно.
- "По мере увеличения размера входных данных" означает, что сложность отражает связь между эффективностью алгоритма и масштабом входа.
- "Тенденция роста времени и пространства" означает, что анализ сложности интересуется не конкретными значениями времени или памяти, а тем, насколько быстро они растут.
**Анализ сложности устраняет недостатки практического тестирования**, что проявляется в следующих аспектах.
- Для него не нужно реально запускать код, а значит, он экологичнее и экономит ресурсы.
- Он не зависит от тестовой среды, поэтому результаты анализа применимы ко всем платформам выполнения.
- Он позволяет увидеть эффективность алгоритма при разных объемах данных, особенно на больших данных.
!!! tip
Если понятие сложности пока все еще кажется тебе запутанным, не переживай: мы подробно разберем его в следующих разделах.
Анализ сложности дает нам "линейку" для оценки эффективности алгоритмов, позволяя измерять, сколько времени и памяти требуется для выполнения конкретного алгоритма, и сравнивать эффективность разных алгоритмов между собой.
Сложность - это математическое понятие, поэтому для начинающих оно может показаться довольно абстрактным и сравнительно трудным. С этой точки зрения анализ сложности, возможно, не лучший самый первый материал для знакомства. Однако, когда мы обсуждаем особенности конкретной структуры данных или алгоритма, почти невозможно не затронуть скорость его работы и использование памяти.
В итоге рекомендуется еще до глубокого погружения в структуры данных и алгоритмы **сформировать хотя бы первичное понимание анализа сложности, чтобы уметь выполнять анализ сложности простых алгоритмов**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,880 @@
# Пространственная сложность
<u>Пространственная сложность (space complexity)</u> используется для оценки того, как меняется объем памяти, занимаемой алгоритмом, по мере роста объема данных. Это понятие очень похоже на временную сложность, только вместо "времени выполнения" мы рассматриваем "объем используемой памяти".
## Пространство, связанное с алгоритмом
Память, которую использует алгоритм во время работы, в основном включает несколько следующих частей.
- **Входное пространство**: используется для хранения входных данных алгоритма.
- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных, возникающих во время выполнения алгоритма.
- **Выходное пространство**: используется для хранения выходных данных алгоритма.
В общем случае при анализе пространственной сложности в расчет включают "временное пространство" и "выходное пространство".
Временное пространство можно дополнительно разделить на три части.
- **Временные данные**: используются для хранения различных констант, переменных, объектов и т.д., возникающих во время выполнения алгоритма.
- **Пространство кадров стека**: используется для хранения контекстных данных вызываемых функций. Система при каждом вызове функции создает на вершине стека новый кадр; после возврата функции пространство этого кадра освобождается.
- **Пространство инструкций**: используется для хранения скомпилированных инструкций программы и в реальном подсчете обычно не учитывается.
При анализе пространственной сложности программы **мы обычно учитываем три части: временные данные, пространство кадров стека и выходные данные**, как показано на рисунке ниже.
![Пространство, используемое алгоритмом](space_complexity.assets/space_types.png)
Соответствующий код выглядит следующим образом:
=== "Python"
```python title=""
class Node:
"""Класс"""
def __init__(self, x: int):
self.val: int = x # Значение узла
self.next: Node | None = None # Ссылка на следующий узел
def function() -> int:
"""Функция"""
# Выполнить некоторые операции...
return 0
def algorithm(n) -> int: # Входные данные
A = 0 # Временные данные (константа, обычно обозначается заглавной буквой)
b = 0 # Временные данные (переменная)
node = Node(0) # Временные данные (объект)
c = function() # Пространство кадра стека (вызов функции)
return A + b + c # Выходные данные
```
=== "C++"
```cpp title=""
/* Структура */
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(nullptr) {}
};
/* Функция */
int func() {
// Выполнить некоторые операции...
return 0;
}
int algorithm(int n) { // Входные данные
const int a = 0; // Временные данные (константа)
int b = 0; // Временные данные (переменная)
Node* node = new Node(0); // Временные данные (объект)
int c = func(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "Java"
```java title=""
/* Класс */
class Node {
int val;
Node next;
Node(int x) { val = x; }
}
/* Функция */
int function() {
// Выполнить некоторые операции...
return 0;
}
int algorithm(int n) { // Входные данные
final int a = 0; // Временные данные (константа)
int b = 0; // Временные данные (переменная)
Node node = new Node(0); // Временные данные (объект)
int c = function(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "C#"
```csharp title=""
/* Класс */
class Node(int x) {
int val = x;
Node next;
}
/* Функция */
int Function() {
// Выполнить некоторые операции...
return 0;
}
int Algorithm(int n) { // Входные данные
const int a = 0; // Временные данные (константа)
int b = 0; // Временные данные (переменная)
Node node = new(0); // Временные данные (объект)
int c = Function(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "Go"
```go title=""
/* Структура */
type node struct {
val int
next *node
}
/* Создать структуру node */
func newNode(val int) *node {
return &node{val: val}
}
/* Функция */
func function() int {
// Выполнить некоторые операции...
return 0
}
func algorithm(n int) int { // Входные данные
const a = 0 // Временные данные (константа)
b := 0 // Временные данные (переменная)
newNode(0) // Временные данные (объект)
c := function() // Пространство кадра стека (вызов функции)
return a + b + c // Выходные данные
}
```
=== "Swift"
```swift title=""
/* Класс */
class Node {
var val: Int
var next: Node?
init(x: Int) {
val = x
}
}
/* Функция */
func function() -> Int {
// Выполнить некоторые операции...
return 0
}
func algorithm(n: Int) -> Int { // Входные данные
let a = 0 // Временные данные (константа)
var b = 0 // Временные данные (переменная)
let node = Node(x: 0) // Временные данные (объект)
let c = function() // Пространство кадра стека (вызов функции)
return a + b + c // Выходные данные
}
```
=== "JS"
```javascript title=""
/* Класс */
class Node {
val;
next;
constructor(val) {
this.val = val === undefined ? 0 : val; // Значение узла
this.next = null; // Ссылка на следующий узел
}
}
/* Функция */
function constFunc() {
// Выполнить некоторые операции
return 0;
}
function algorithm(n) { // Входные данные
const a = 0; // Временные данные (константа)
let b = 0; // Временные данные (переменная)
const node = new Node(0); // Временные данные (объект)
const c = constFunc(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "TS"
```typescript title=""
/* Класс */
class Node {
val: number;
next: Node | null;
constructor(val?: number) {
this.val = val === undefined ? 0 : val; // Значение узла
this.next = null; // Ссылка на следующий узел
}
}
/* Функция */
function constFunc(): number {
// Выполнить некоторые операции
return 0;
}
function algorithm(n: number): number { // Входные данные
const a = 0; // Временные данные (константа)
let b = 0; // Временные данные (переменная)
const node = new Node(0); // Временные данные (объект)
const c = constFunc(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "Dart"
```dart title=""
/* Класс */
class Node {
int val;
Node next;
Node(this.val, [this.next]);
}
/* Функция */
int function() {
// Выполнить некоторые операции...
return 0;
}
int algorithm(int n) { // Входные данные
const int a = 0; // Временные данные (константа)
int b = 0; // Временные данные (переменная)
Node node = Node(0); // Временные данные (объект)
int c = function(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "Rust"
```rust title=""
use std::rc::Rc;
use std::cell::RefCell;
/* Структура */
struct Node {
val: i32,
next: Option<Rc<RefCell<Node>>>,
}
/* Создать структуру Node */
impl Node {
fn new(val: i32) -> Self {
Self { val: val, next: None }
}
}
/* Функция */
fn function() -> i32 {
// Выполнить некоторые операции...
return 0;
}
fn algorithm(n: i32) -> i32 { // Входные данные
const a: i32 = 0; // Временные данные (константа)
let mut b = 0; // Временные данные (переменная)
let node = Node::new(0); // Временные данные (объект)
let c = function(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "C"
```c title=""
/* Функция */
int func() {
// Выполнить некоторые операции...
return 0;
}
int algorithm(int n) { // Входные данные
const int a = 0; // Временные данные (константа)
int b = 0; // Временные данные (переменная)
int c = func(); // Пространство кадра стека (вызов функции)
return a + b + c; // Выходные данные
}
```
=== "Kotlin"
```kotlin title=""
/* Класс */
class Node(var _val: Int) {
var next: Node? = null
}
/* Функция */
fun function(): Int {
// Выполнить некоторые операции...
return 0
}
fun algorithm(n: Int): Int { // Входные данные
val a = 0 // Временные данные (константа)
var b = 0 // Временные данные (переменная)
val node = Node(0) // Временные данные (объект)
val c = function() // Пространство кадра стека (вызов функции)
return a + b + c // Выходные данные
}
```
=== "Ruby"
```ruby title=""
### Класс ###
class Node
attr_accessor :val # Значение узла
attr_accessor :next # Ссылка на следующий узел
def initialize(x)
@val = x
end
end
### Функция ###
def function
# Выполнить некоторые операции...
0
end
### Алгоритм ###
def algorithm(n) # Входные данные
a = 0 # Временные данные (константа)
b = 0 # Временные данные (переменная)
node = Node.new(0) # Временные данные (объект)
c = function # Пространство кадра стека (вызов функции)
a + b + c # Выходные данные
end
```
## Метод вывода
Метод вывода пространственной сложности в целом аналогичен временному анализу: меняется только объект подсчета, с "количества операций" на "размер используемого пространства".
В отличие от временной сложности, **обычно мы рассматриваем только худшую пространственную сложность**. Это связано с тем, что память является жестким ограничением: нам нужно гарантировать, что для любых входных данных у программы будет достаточно памяти.
Рассмотрим следующий код. Слово "худшая" в "худшей пространственной сложности" имеет два значения.
1. **Ориентир на худшие входные данные**: когда $n < 10$ , пространственная сложность равна $O(1)$ ; но когда $n > 10$ , инициализированный массив `nums` занимает $O(n)$ пространства, поэтому худшая пространственная сложность равна $O(n)$ .
2. **Ориентир на пиковое потребление памяти во время выполнения алгоритма**: например, до выполнения последней строки программа занимает $O(1)$ пространства; при инициализации массива `nums` она занимает $O(n)$ пространства, поэтому худшая пространственная сложность равна $O(n)$ .
=== "Python"
```python title=""
def algorithm(n: int):
a = 0 # O(1)
b = [0] * 10000 # O(1)
if n > 10:
nums = [0] * n # O(n)
```
=== "C++"
```cpp title=""
void algorithm(int n) {
int a = 0; // O(1)
vector<int> b(10000); // O(1)
if (n > 10)
vector<int> nums(n); // O(n)
}
```
=== "Java"
```java title=""
void algorithm(int n) {
int a = 0; // O(1)
int[] b = new int[10000]; // O(1)
if (n > 10)
int[] nums = new int[n]; // O(n)
}
```
=== "C#"
```csharp title=""
void Algorithm(int n) {
int a = 0; // O(1)
int[] b = new int[10000]; // O(1)
if (n > 10) {
int[] nums = new int[n]; // O(n)
}
}
```
=== "Go"
```go title=""
func algorithm(n int) {
a := 0 // O(1)
b := make([]int, 10000) // O(1)
var nums []int
if n > 10 {
nums := make([]int, n) // O(n)
}
fmt.Println(a, b, nums)
}
```
=== "Swift"
```swift title=""
func algorithm(n: Int) {
let a = 0 // O(1)
let b = Array(repeating: 0, count: 10000) // O(1)
if n > 10 {
let nums = Array(repeating: 0, count: n) // O(n)
}
}
```
=== "JS"
```javascript title=""
function algorithm(n) {
const a = 0; // O(1)
const b = new Array(10000); // O(1)
if (n > 10) {
const nums = new Array(n); // O(n)
}
}
```
=== "TS"
```typescript title=""
function algorithm(n: number): void {
const a = 0; // O(1)
const b = new Array(10000); // O(1)
if (n > 10) {
const nums = new Array(n); // O(n)
}
}
```
=== "Dart"
```dart title=""
void algorithm(int n) {
int a = 0; // O(1)
List<int> b = List.filled(10000, 0); // O(1)
if (n > 10) {
List<int> nums = List.filled(n, 0); // O(n)
}
}
```
=== "Rust"
```rust title=""
fn algorithm(n: i32) {
let a = 0; // O(1)
let b = [0; 10000]; // O(1)
if n > 10 {
let nums = vec![0; n as usize]; // O(n)
}
}
```
=== "C"
```c title=""
void algorithm(int n) {
int a = 0; // O(1)
int b[10000]; // O(1)
if (n > 10)
int nums[n] = {0}; // O(n)
}
```
=== "Kotlin"
```kotlin title=""
fun algorithm(n: Int) {
val a = 0 // O(1)
val b = IntArray(10000) // O(1)
if (n > 10) {
val nums = IntArray(n) // O(n)
}
}
```
=== "Ruby"
```ruby title=""
def algorithm(n)
a = 0 # O(1)
b = Array.new(10000) # O(1)
nums = Array.new(n) if n > 10 # O(n)
end
```
**В рекурсивных функциях необходимо учитывать пространство кадров стека**. Рассмотрим следующий код:
=== "Python"
```python title=""
def function() -> int:
# Выполнить некоторые операции
return 0
def loop(n: int):
"""Пространственная сложность цикла равна O(1)"""
for _ in range(n):
function()
def recur(n: int):
"""Пространственная сложность рекурсии равна O(n)"""
if n == 1:
return
return recur(n - 1)
```
=== "C++"
```cpp title=""
int func() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
func();
}
}
/* Пространственная сложность рекурсии равна O(n) */
void recur(int n) {
if (n == 1) return;
recur(n - 1);
}
```
=== "Java"
```java title=""
int function() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
function();
}
}
/* Пространственная сложность рекурсии равна O(n) */
void recur(int n) {
if (n == 1) return;
recur(n - 1);
}
```
=== "C#"
```csharp title=""
int Function() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
void Loop(int n) {
for (int i = 0; i < n; i++) {
Function();
}
}
/* Пространственная сложность рекурсии равна O(n) */
int Recur(int n) {
if (n == 1) return 1;
return Recur(n - 1);
}
```
=== "Go"
```go title=""
func function() int {
// Выполнить некоторые операции
return 0
}
/* Пространственная сложность цикла равна O(1) */
func loop(n int) {
for i := 0; i < n; i++ {
function()
}
}
/* Пространственная сложность рекурсии равна O(n) */
func recur(n int) {
if n == 1 {
return
}
recur(n - 1)
}
```
=== "Swift"
```swift title=""
@discardableResult
func function() -> Int {
// Выполнить некоторые операции
return 0
}
/* Пространственная сложность цикла равна O(1) */
func loop(n: Int) {
for _ in 0 ..< n {
function()
}
}
/* Пространственная сложность рекурсии равна O(n) */
func recur(n: Int) {
if n == 1 {
return
}
recur(n: n - 1)
}
```
=== "JS"
```javascript title=""
function constFunc() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
function loop(n) {
for (let i = 0; i < n; i++) {
constFunc();
}
}
/* Пространственная сложность рекурсии равна O(n) */
function recur(n) {
if (n === 1) return;
return recur(n - 1);
}
```
=== "TS"
```typescript title=""
function constFunc(): number {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
function loop(n: number): void {
for (let i = 0; i < n; i++) {
constFunc();
}
}
/* Пространственная сложность рекурсии равна O(n) */
function recur(n: number): void {
if (n === 1) return;
return recur(n - 1);
}
```
=== "Dart"
```dart title=""
int function() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
function();
}
}
/* Пространственная сложность рекурсии равна O(n) */
void recur(int n) {
if (n == 1) return;
recur(n - 1);
}
```
=== "Rust"
```rust title=""
fn function() -> i32 {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
fn loop(n: i32) {
for i in 0..n {
function();
}
}
/* Пространственная сложность рекурсии равна O(n) */
fn recur(n: i32) {
if n == 1 {
return;
}
recur(n - 1);
}
```
=== "C"
```c title=""
int func() {
// Выполнить некоторые операции
return 0;
}
/* Пространственная сложность цикла равна O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
func();
}
}
/* Пространственная сложность рекурсии равна O(n) */
void recur(int n) {
if (n == 1) return;
recur(n - 1);
}
```
=== "Kotlin"
```kotlin title=""
fun function(): Int {
// Выполнить некоторые операции
return 0
}
/* Пространственная сложность цикла равна O(1) */
fun loop(n: Int) {
for (i in 0..<n) {
function()
}
}
/* Пространственная сложность рекурсии равна O(n) */
fun recur(n: Int) {
if (n == 1) return
return recur(n - 1)
}
```
=== "Ruby"
```ruby title=""
def function
# Выполнить некоторые операции
0
end
### Пространственная сложность цикла равна O(1) ###
def loop(n)
(0...n).each { function }
end
### Пространственная сложность рекурсии равна O(n) ###
def recur(n)
return if n == 1
recur(n - 1)
end
```
Функции `loop()` и `recur()` имеют временную сложность $O(n)$ , но их пространственная сложность различается.
- Функция `loop()` вызывает `function()` в цикле $n$ раз; на каждой итерации `function()` возвращается и освобождает пространство своего кадра стека, поэтому пространственная сложность по-прежнему равна $O(1)$ .
- Рекурсивная функция `recur()` во время выполнения одновременно содержит $n$ еще не завершившихся экземпляров `recur()` , поэтому занимает $O(n)$ пространства кадров стека.
## Распространенные типы
Пусть размер входных данных равен $n$ . На рисунке ниже показаны распространенные типы пространственной сложности (в порядке от меньшей к большей).
$$
\begin{aligned}
O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
\text{Постоянная} < \text{Логарифмическая} < \text{Линейная} < \text{Квадратичная} < \text{Экспоненциальная}
\end{aligned}
$$
![Распространенные типы пространственной сложности](space_complexity.assets/space_complexity_common_types.png)
### Постоянная сложность $O(1)$
Постоянная сложность часто встречается у констант, переменных и объектов, количество которых не зависит от размера входных данных $n$ .
Следует заметить, что память, занятая инициализацией переменных или вызовом функций внутри цикла, освобождается при переходе к следующей итерации, поэтому она не накапливается, и пространственная сложность по-прежнему остается $O(1)$ :
```src
[file]{space_complexity}-[class]{}-[func]{constant}
```
### Линейная сложность $O(n)$
Линейная сложность часто встречается у массивов, связных списков, стеков, очередей и других структур, число элементов в которых пропорционально $n$ :
```src
[file]{space_complexity}-[class]{}-[func]{linear}
```
Как показано на рисунке ниже, глубина рекурсии этой функции равна $n$ , то есть одновременно существует $n$ еще не завершившихся функций `linear_recur()` , которые используют $O(n)$ пространства кадров стека:
```src
[file]{space_complexity}-[class]{}-[func]{linear_recur}
```
![Линейная пространственная сложность, порождаемая рекурсивной функцией](space_complexity.assets/space_complexity_recursive_linear.png)
### Квадратичная сложность $O(n^2)$
Квадратичная сложность часто встречается у матриц и графов, где число элементов связано с $n$ квадратичной зависимостью:
```src
[file]{space_complexity}-[class]{}-[func]{quadratic}
```
Как показано на рисунке ниже, глубина рекурсии этой функции равна $n$ , и в каждой рекурсивной функции инициализируется массив длины $n$ , $n-1$ , $\dots$ , $2$ , $1$ ; его средняя длина равна $n / 2$ , поэтому в сумме используется $O(n^2)$ пространства:
```src
[file]{space_complexity}-[class]{}-[func]{quadratic_recur}
```
![Квадратичная пространственная сложность, порождаемая рекурсивной функцией](space_complexity.assets/space_complexity_recursive_quadratic.png)
### Экспоненциальная сложность $O(2^n)$
Экспоненциальная сложность часто встречается у бинарных деревьев. Обрати внимание на рисунок ниже: "полное бинарное дерево" с $n$ уровнями содержит $2^n - 1$ узлов и занимает $O(2^n)$ пространства:
```src
[file]{space_complexity}-[class]{}-[func]{build_tree}
```
![Экспоненциальная пространственная сложность, порождаемая полным бинарным деревом](space_complexity.assets/space_complexity_exponential.png)
### Логарифмическая сложность $O(\log n)$
Логарифмическая сложность часто встречается в алгоритмах "разделяй и властвуй". Например, при сортировке слиянием входной массив длины $n$ на каждом шаге рекурсии делится пополам по середине, образуя рекурсивное дерево высоты $\log n$ и используя $O(\log n)$ пространства кадров стека.
Еще один пример - преобразование числа в строку. Если задано положительное целое число $n$ , то количество его цифр равно $\lfloor \log_{10} n \rfloor + 1$ , то есть длина соответствующей строки тоже равна $\lfloor \log_{10} n \rfloor + 1$ , следовательно, пространственная сложность составляет $O(\log_{10} n + 1) = O(\log n)$ .
## Компромисс между временем и пространством
В идеале нам хотелось бы, чтобы и временная, и пространственная сложность алгоритма были оптимальными. Однако на практике одновременно оптимизировать и время, и память обычно очень трудно.
**Снижение временной сложности обычно достигается ценой увеличения пространственной сложности, и наоборот**. Подход, при котором мы жертвуем памятью ради ускорения работы алгоритма, называется "обмен пространства на время"; обратный подход называется "обмен времени на пространство".
Выбор между этими двумя идеями зависит от того, что для нас важнее. В большинстве случаев время ценнее памяти, поэтому стратегия "обмена пространства на время" используется чаще. Но при очень больших объемах данных контроль пространственной сложности тоже становится крайне важным.
@@ -0,0 +1,55 @@
# Резюме
### Ключевые выводы
**Оценка эффективности алгоритмов**
- Временная эффективность и пространственная эффективность - два главных показателя, по которым оценивают качество алгоритма.
- Мы можем оценивать эффективность алгоритма с помощью практического тестирования, но при этом трудно устранить влияние тестовой среды, а само тестирование потребляет много вычислительных ресурсов.
- Анализ сложности устраняет недостатки практического тестирования, дает результаты, применимые ко всем платформам выполнения, и позволяет увидеть эффективность алгоритма при разных масштабах данных.
**Временная сложность**
- Временная сложность используется для оценки того, как меняется время работы алгоритма с ростом объема данных. Она хорошо подходит для оценки эффективности, но в некоторых случаях может давать недостаточно точное сравнение, например когда входные данные малы или когда временные сложности совпадают.
- Худшая временная сложность обозначается с помощью нотации Big $O$ и соответствует асимптотической верхней границе функции, отражая уровень роста числа операций $T(n)$ при стремлении $n$ к положительной бесконечности.
- Вывод временной сложности включает два шага: сначала подсчитывается число операций, затем определяется асимптотическая верхняя граница.
- Распространенные временные сложности в порядке роста: $O(1)$, $O(\log n)$, $O(n)$, $O(n \log n)$, $O(n^2)$, $O(2^n)$ и $O(n!)$.
- Временная сложность некоторых алгоритмов не фиксирована, а зависит от распределения входных данных. Различают худшую, лучшую и среднюю временную сложность; лучшая временная сложность используется редко, потому что для ее достижения вход обычно должен удовлетворять строгим условиям.
- Средняя временная сложность отражает эффективность алгоритма на случайных входных данных и ближе всего к его поведению в практических сценариях. Для ее вычисления нужно знать распределение входных данных и рассчитать соответствующее математическое ожидание.
**Пространственная сложность**
- Пространственная сложность играет роль, аналогичную временной: она показывает тенденцию роста потребления памяти по мере увеличения объема данных.
- Память, связанная с выполнением алгоритма, можно разделить на входное пространство, временное пространство и выходное пространство. Обычно входное пространство не включается в расчет пространственной сложности. Временное пространство можно разбить на временные данные, пространство кадров стека и пространство инструкций; при этом пространство кадров стека обычно влияет на сложность только в рекурсивных функциях.
- Обычно нас интересует только худшая пространственная сложность, то есть пространственная сложность алгоритма при худшем наборе входных данных и в худший момент времени выполнения.
- Распространенные пространственные сложности в порядке роста: $O(1)$, $O(\log n)$, $O(n)$, $O(n^2)$ и $O(2^n)$.
### Q & A
**Q**: Является ли пространственная сложность хвостовой рекурсии равной $O(1)$?
Теоретически пространственную сложность хвостово-рекурсивных функций можно оптимизировать до $O(1)$ . Однако большинство языков программирования (например Java, Python, C++, Go, C# и другие) не поддерживают автоматическую оптимизацию хвостовой рекурсии, поэтому на практике пространственная сложность обычно считается равной $O(n)$ .
**Q**: В чем разница между терминами function и method?
<u>Функция (function)</u> может выполняться независимо, и все ее параметры передаются явно. <u>Метод (method)</u> связан с объектом, неявно получает объект, который его вызывает, и может работать с данными, содержащимися в экземпляре класса.
Ниже это проиллюстрировано на примере нескольких распространенных языков программирования.
- C - процедурный язык программирования без объектно-ориентированной модели, поэтому в нем есть только функции. Однако мы можем имитировать объектно-ориентированное программирование через структуры (`struct`), и функции, связанные со структурами, эквивалентны методам в других языках.
- Java и C# - объектно-ориентированные языки программирования, в которых блоки кода (методы) обычно являются частью класса. Статические методы по поведению похожи на функции, потому что они привязаны к классу и не могут обращаться к конкретным переменным экземпляра.
- C++ и Python поддерживают как процедурное программирование (функции), так и объектно-ориентированное программирование (методы).
**Q**: Отражает ли диаграмма "распространенных типов пространственной сложности" абсолютный размер занятой памяти?
Нет, эта диаграмма показывает пространственную сложность, а значит отражает именно тенденцию роста, а не абсолютный объем занятого пространства.
Если взять $n = 8$ , можно заметить, что значения на кривых не совпадают напрямую с соответствующими функциями. Это связано с тем, что каждая кривая содержит константный член, который сжимает диапазон значений до визуально удобного масштаба.
На практике, поскольку мы обычно не знаем, какова "константная" сложность каждого метода, только по сложности мы, как правило, не можем выбрать оптимальное решение для случая $n = 8$ . Но для $n = 8^5$ выбор уже очевиден: в этой области доминирует именно тенденция роста.
**Q**: Бывают ли случаи, когда в реальных сценариях алгоритм специально проектируют так, чтобы жертвовать временем ради пространства или пространством ради времени?
На практике в большинстве случаев выбирают обмен пространства на время. Например, для индексов в базах данных обычно строят B+ деревья или хеш-индексы, расходуя значительный объем памяти ради эффективных запросов уровня $O(\log n)$ или даже $O(1)$.
В сценариях, где память особенно дорога, наоборот, могут жертвовать временем ради пространства. Например, в embedded-разработке память устройства очень ограничена, поэтому инженеры могут отказаться от хеш-таблиц и выбрать последовательный поиск по массиву, экономя память ценой более медленного поиска.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More