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