First version.

This commit is contained in:
krahets
2026-01-20 15:08:42 +08:00
parent 2213a59ff6
commit 8071daddaa
106 changed files with 11790 additions and 0 deletions
@@ -0,0 +1,9 @@
# Анализ сложности
![Анализ сложности](../assets/covers/chapter_complexity_analysis.jpg)
!!! abstract
Анализ сложности подобен проводнику в пространстве-времени необъятной вселенной алгоритмов.
Он ведет нас к глубокому исследованию в двух измерениях — времени и пространстве, в поисках более элегантных решений.
@@ -0,0 +1,247 @@
# Итерация и рекурсия
В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к обсуждению временной и пространственной сложности, рассмотрим, как реализовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию.
## Итерации
*Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение определенного участка кода, пока выполняется определенное условие.
### Цикл for
*Цикл* for -- одна из наиболее распространенных форм итерации, которая подходит для использования, когда количество итераций известно заранее.
Следующая функция реализует суммирование 1 + 2 + \... + *n* с использованием цикла for, результат суммирования сохраняется в переменной res. Следует отметить, что в Python диапазон range(a, b) соответствует левому закрытому, правому открытому интервалу, т. е. перебираются значения *a*, *a* + 1, \... , *b* 1:
```src
[file]{iteration}-[class]{}-[func]{for_loop}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图是该求和函数的流程框图。 -->
![Блок-схема функции суммирования](../assets/iteration_and_recursion.assets/iteration.png)
Количество операций этой функции суммирования пропорционально размеру входных данных *n*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в следующем разделе.
### Цикл while
Подобно циклу for, цикл while также представляет собой метод реализации итерации. В цикле while программа перед каждой итерацией проверяет условие: если условие истинно, то выполнение продолжается, иначе цикл завершается.
Ниже приведен пример реализации суммирования 1 + 2 + \... + *n* с использованием цикла while:
```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}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图是该嵌套循环的流程框图。 -->
![Блок-схема вложенного цикла](../assets/iteration_and_recursion.assets/nested_iteration.png)
В этом случае количество выполненных действий пропорционально *n*², или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости.
Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической зависимости, зависимости четвертой степени и т. д.
## Рекурсия
*Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа.
1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения.
2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя результаты каждого уровня.
С точки зрения реализации рекурсивный код включает три основных элемента.
1. **Условие завершения**: используется для определения момента перехода от вызова к возврату.
2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами.
3. **Возврат результата**: соответствует возврату, возвращает результат текущего уровня рекурсии на предыдущий уровень.
Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*.
```src
[file]{recursion}-[class]{}-[func]{recur}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图展示了该函数的递归过程。 -->
![Рекурсивный вызов функции суммирования](../assets/iteration_and_recursion.assets/recursion_sum.png)
Хотя с точки зрения вычислений итерация и рекурсия могут давать одинаковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач.
- **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи.
- **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная задача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно).
Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*.
- **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы получить итоговое значение *f*(*n*).
- **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) = *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1.
### Стек вызовов
Каждый раз, когда рекурсивная функция вызывает саму себя, система выделяет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия.
- Контекстные данные функции хранятся в области памяти, называемой пространством стекового кадра, и освобождаются только после возврата функции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**.
- Рекурсивный вызов функции создает дополнительные накладные расходы. **Поэтому рекурсия обычно менее эффективна по времени, чем циклы**.
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:如下图所示,在触发终止条件前,同时存在 $n$ 个未返回的递归函数,**递归深度为 $n$** 。 -->
![Глубина рекурсивного вызова](../assets/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}
``` -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如下图所示,这样不断递归调用下去,最终将产生一棵层数为 $n$ 的<u>递归树(recursion tree</u>。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:![斐波那契数列的递归树](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,48 @@
# Оценка эффективности алгоритмов
В процессе разработки алгоритмов мы стремимся к достижению следующих целей:
1. **найти решение задачи**: алгоритм должен надежно находить правильное решение задачи в заданных пределах входных данных;
2. **найти оптимальное решение**: для одной и той же задачи может существовать несколько решений, и мы стремимся найти максимально эффективный алгоритм.
Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта:
- **временную эффективность**: продолжительность выполнения алгоритма;
- **пространственную эффективность**: объем памяти, занимаемой алгоритмом.
В двух словах, **наша цель -- разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процессом их разработки и оптимизации.
Методы оценки эффективности делятся на два типа: практическое тестирование и теоретическую оценку.
## Практическое тестирование
Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же задачу, и необходимо сравнить их эффективность. Самый прямой метод -- это запустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения.
С одной стороны, **сложно исключить влияние факторов тестовой среды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных процессорах. Если алгоритм интенсивно использует память, его производительность будет выше на высокопроизводительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, и потребуется тестирование на различных платформах для получения средней эффективности, что крайне затруднительно.
С другой стороны, **проведение полного тестирования требует значительных ресурсов**. С изменением объема входных данных алгоритмы демонстрируют разную эффективность. Например, при небольшом объеме данных алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме данных результат может быть противоположным. Следовательно, для получения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов.
## Теоретическая оценка
Из-за значительных ограничений практического тестирования можно рассмотреть возможность оценки эффективности алгоритмов только с помощью математических расчетов. Этот метод называется анализом асимптотической сложности или просто анализом сложности.
Анализ сложности позволяет отразить зависимость между ресурсами времени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и пространства, необходимых для выполнения алгоритма, по мере увеличения размера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента.
- Ресурсы времени и пространства соответствуют временной сложности и пространственной сложности.
- «По мере увеличения размера входных данных» означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных.
- Тенденция роста времени и пространства указывает, что анализ сложности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста.
**Анализ сложности преодолевает недостатки метода практического тестирования**, что выражается в следующих аспектах:
- он не требует фактического выполнения кода, что делает его более экологичным и энергосберегающим;
- он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения;
- он может продемонстрировать эффективность алгоритма при различных объемах данных, особенно при больших объемах.
!!! tip
Анализ сложности предоставляет нам мерило оценки эффективности алгоритмов, позволяя измерять время и ресурсы, необходимые для выполнения конкретного алгоритма, а также сравнивать эффективность различных алгоритмов.
Сложность -- это математическое понятие, которое новичкам может показаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуждая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти.
Таким образом, перед погружением в изучение структур данных и алгоритмов рекомендуется получить базовое представление об анализе сложности, чтобы иметь возможность выполнять хотя бы базовую оценку их эффективности.
@@ -0,0 +1,860 @@
# Пространственная сложность
<u>Пространственная сложность (space complexity)</u> используется для измерения роста объема памяти, занимаемого алгоритмом, по мере увеличения объема данных. Эта концепция очень похожа на временную сложность, только нужно заменить "время выполнения" на "занимаемый объем памяти".
## Пространство, связанное с алгоритмом
Память, используемая алгоритмом в процессе выполнения, в основном включает следующие типы.
- **Входное пространство**: используется для хранения входных данных алгоритма.
- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных в процессе выполнения алгоритма.
- **Выходное пространство**: используется для хранения выходных данных алгоритма.
В общем случае диапазон статистики пространственной сложности — это "временное пространство" плюс "выходное пространство".
Временное пространство можно дополнительно разделить на три части.
- **Временные данные**: используются для сохранения различных констант, переменных, объектов и т. д. в процессе выполнения алгоритма.
- **Пространство стекового кадра**: используется для сохранения контекстных данных вызываемой функции. Система создает стековый кадр в верхней части стека при каждом вызове функции, и пространство стекового кадра освобождается после возврата функции.
- **Пространство инструкций**: используется для сохранения скомпилированных инструкций программы, обычно игнорируется при фактической статистике.
При анализе пространственной сложности фрагмента программы **мы обычно учитываем три части: временные данные, пространство стекового кадра и выходные данные**, как показано на рисунке ниже.
![Пространство, связанное с использованием алгоритма](../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}
$$
![Распространенные типы пространственной сложности](../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}
```
![Линейная пространственная сложность, создаваемая рекурсивной функцией](../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}
```
![Квадратичная пространственная сложность, создаваемая рекурсивной функцией](../assets/space_complexity_recursive_quadratic.png)
### Экспоненциальный порядок $O(2^n)$
Экспоненциальный порядок часто встречается в двоичных деревьях. Как показано на рисунке ниже, "полное двоичное дерево" с $n$ уровнями име
@@ -0,0 +1,55 @@
# Резюме
### Ключевые моменты
**Оценка эффективности алгоритмов**
- Временная эффективность и пространственная эффективность являются двумя основными критериями оценки качества алгоритма.
- Мы можем оценивать эффективность алгоритмов с помощью практического тестирования, но сложно исключить влияние тестовой среды, и это требует значительных вычислительных ресурсов.
- Анализ сложности позволяет преодолеть недостатки практического тестирования, результаты анализа применимы ко всем платформам выполнения и могут продемонстрировать эффективность алгоритма при различных объемах данных.
**Временная сложность**
- Временная сложность используется для измерения тенденции роста времени выполнения алгоритма по мере увеличения объема данных, может эффективно оценивать эффективность алгоритма, но в некоторых случаях может быть неприменима, например, при небольшом объеме входных данных или при одинаковой временной сложности невозможно точно сравнить эффективность алгоритмов.
- Наихудшая временная сложность обозначается символом большого $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)$ и т. д.
### Вопросы и ответы
**В**: Является ли пространственная сложность хвостовой рекурсии $O(1)$?
Теоретически пространственная сложность хвостовой рекурсивной функции может быть оптимизирована до $O(1)$. Однако большинство языков программирования (например, Java, Python, C++, Go, C# и т. д.) не поддерживают автоматическую оптимизацию хвостовой рекурсии, поэтому обычно считается, что пространственная сложность составляет $O(n)$.
**В**: В чем разница между терминами функция и метод?
<u>Функция (function)</u> может выполняться независимо, все параметры передаются явно. <u>Метод (method)</u> связан с объектом, неявно передается вызывающему его объекту, может работать с данными, содержащимися в экземпляре класса.
Рассмотрим на примере нескольких распространенных языков программирования.
- C является процедурным языком программирования, не имеет концепции объектно-ориентированного программирования, поэтому имеет только функции. Но мы можем имитировать объектно-ориентированное программирование, создавая структуры (struct), и функции, связанные со структурой, эквивалентны методам в других языках программирования.
- Java и C# являются объектно-ориентированными языками программирования, блоки кода (методы) обычно являются частью какого-либо класса. Статические методы ведут себя как функции, поскольку они привязаны к классу и не могут обращаться к конкретным переменным экземпляра.
- C++ и Python поддерживают как процедурное программирование (функции), так и объектно-ориентированное программирование (методы).
**В**: Отражает ли иллюстрация "Распространенные типы пространственной сложности" абсолютный размер занимаемого пространства?
Нет, эта иллюстрация показывает пространственную сложность, которая отражает тенденцию роста, а не абсолютный размер занимаемого пространства.
Предположим, что $n = 8$, вы можете заметить, что значение каждой кривой не соответствует функции. Это связано с тем, что каждая кривая содержит константный член, используемый для сжатия диапазона значений до визуально комфортного диапазона.
На практике, поскольку мы обычно не знаем, какова "константная" сложность каждого метода, обычно невозможно выбрать оптимальное решение только на основе сложности для $n = 8$. Но для $n = 8^5$ выбор очевиден, так как тенденция роста уже доминирует.
**В**: Существуют ли случаи, когда в зависимости от реального сценария использования выбирают жертвовать временем (или пространством) при разработке алгоритма?
В реальных приложениях в большинстве случаев выбирают жертвовать пространством ради времени. Например, индексы баз данных, мы обычно выбираем построение B+ дерева или хеш-индекса, занимая большой объем памяти, чтобы получить высокоэффективный поиск $O(\log n)$ или даже $O(1)$.
В сценариях, где пространственные ресурсы ценны, также выбирают жертвовать временем ради пространства. Например, во встроенной разработке память устройства очень ценна, инженеры могут отказаться от использования хеш-таблицы, выбрав последовательный поиск в массиве для экономии памяти, ценой чего является замедление поиска.
@@ -0,0 +1,718 @@
# Временная сложность
Время выполнения может интуитивно и точно отражать эффективность алгоритма. Если мы хотим точно предсказать время выполнения фрагмента кода, как нам следует действовать?
1. **Определить платформу выполнения**, включая аппаратную конфигурацию, язык программирования, системное окружение и т. д., так как все эти факторы влияют на эффективность выполнения кода.
2. **Оценить время выполнения различных вычислительных операций**, например, операция сложения `+` требует 1 нс, операция умножения `*` требует 10 нс, операция вывода `print()` требует 5 нс и т. д.
3. **Подсчитать все вычислительные операции в коде** и суммировать время выполнения всех операций, чтобы получить время выполнения.
Например, в следующем коде размер входных данных равен $n$:
=== "Python"
```python title=""
# На определенной платформе выполнения
def algorithm(n: int):
a = 2 # 1 нс
a = a + 1 # 1 нс
a = a * 2 # 10 нс
# Цикл n раз
for _ in range(n): # 1 нс
print(0) # 5 нс
```
=== "C++"
```cpp title=""
// На определенной платформе выполнения
void algorithm(int n) {
int a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for (int i = 0; i < n; i++) { // 1 нс
cout << 0 << endl; // 5 нс
}
}
```
=== "Java"
```java title=""
// На определенной платформе выполнения
void algorithm(int n) {
int a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for (int i = 0; i < n; i++) { // 1 нс
System.out.println(0); // 5 нс
}
}
```
=== "C#"
```csharp title=""
// На определенной платформе выполнения
void Algorithm(int n) {
int a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for (int i = 0; i < n; i++) { // 1 нс
Console.WriteLine(0); // 5 нс
}
}
```
=== "Go"
```go title=""
// На определенной платформе выполнения
func algorithm(n int) {
a := 2 // 1 нс
a = a + 1 // 1 нс
a = a * 2 // 10 нс
// Цикл n раз
for i := 0; i < n; i++ { // 1 нс
fmt.Println(a) // 5 нс
}
}
```
=== "Swift"
```swift title=""
// На определенной платформе выполнения
func algorithm(n: Int) {
var a = 2 // 1 нс
a = a + 1 // 1 нс
a = a * 2 // 10 нс
// Цикл n раз
for _ in 0 ..< n { // 1 нс
print(0) // 5 нс
}
}
```
=== "JS"
```javascript title=""
// На определенной платформе выполнения
function algorithm(n) {
var a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for(let i = 0; i < n; i++) { // 1 нс
console.log(0); // 5 нс
}
}
```
=== "TS"
```typescript title=""
// На определенной платформе выполнения
function algorithm(n: number): void {
var a: number = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for(let i = 0; i < n; i++) { // 1 нс
console.log(0); // 5 нс
}
}
```
=== "Dart"
```dart title=""
// На определенной платформе выполнения
void algorithm(int n) {
int a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for (int i = 0; i < n; i++) { // 1 нс
print(0); // 5 нс
}
}
```
=== "Rust"
```rust title=""
// На определенной платформе выполнения
fn algorithm(n: i32) {
let mut a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for _ in 0..n { // 1 нс
println!("{}", 0); // 5 нс
}
}
```
=== "C"
```c title=""
// На определенной платформе выполнения
void algorithm(int n) {
int a = 2; // 1 нс
a = a + 1; // 1 нс
a = a * 2; // 10 нс
// Цикл n раз
for (int i = 0; i < n; i++) { // 1 нс
printf("%d", 0); // 5 нс
}
}
```
=== "Kotlin"
```kotlin title=""
// На определенной платформе выполнения
fun algorithm(n: Int) {
var a = 2 // 1 нс
a = a + 1 // 1 нс
a = a * 2 // 10 нс
// Цикл n раз
for (i in 0..<n) { // 1 нс
println(0) // 5 нс
}
}
```
=== "Ruby"
```ruby title=""
# На определенной платформе выполнения
def algorithm(n)
a = 2 # 1 нс
a = a + 1 # 1 нс
a = a * 2 # 10 нс
# Цикл n раз
(0...n).each do # 1 нс
puts 0 # 5 нс
end
end
```
Согласно вышеуказанному методу, можно получить время выполнения алгоритма $(6n + 12)$ нс:
$$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
$$
Но на самом деле, **подсчет времени выполнения алгоритма неразумен и нереалистичен**. Во-первых, мы не хотим привязывать прогнозируемое время к платформе выполнения, поскольку алгоритм должен работать на различных платформах. Во-вторых, очень сложно узнать время выполнения каждой операции, что создает огромные трудности для процесса оценки.
## Анализ тенденции роста времени
Анализ временной сложности не подсчитывает время выполнения алгоритма, **а анализирует тенденцию роста времени выполнения алгоритма по мере увеличения объема данных**.
Концепция "тенденции роста времени" довольно абстрактна, давайте разберем ее на примере. Предположим, размер входных данных равен $n$, даны три алгоритма `A`, `B` и `C`:
=== "Python"
```python title=""
# Временная сложность алгоритма A: константная
def algorithm_A(n: int):
print(0)
# Временная сложность алгоритма B: линейная
def algorithm_B(n: int):
for _ in range(n):
print(0)
# Временная сложность алгоритма C: константная
def algorithm_C(n: int):
for _ in range(1000000):
print(0)
```
=== "C++"
```cpp title=""
// Временная сложность алгоритма A: константная
void algorithm_A(int n) {
cout << 0 << endl;
}
// Временная сложность алгоритма B: линейная
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// Временная сложность алгоритма C: константная
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
```
=== "Java"
```java title=""
// Временная сложность алгоритма A: константная
void algorithm_A(int n) {
System.out.println(0);
}
// Временная сложность алгоритма B: линейная
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
System.out.println(0);
}
}
// Временная сложность алгоритма C: константная
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
System.out.println(0);
}
}
```
=== "C#"
```csharp title=""
// Временная сложность алгоритма A: константная
void AlgorithmA(int n) {
Console.WriteLine(0);
}
// Временная сложность алгоритма B: линейная
void AlgorithmB(int n) {
for (int i = 0; i < n; i++) {
Console.WriteLine(0);
}
}
// Временная сложность алгоритма C: константная
void AlgorithmC(int n) {
for (int i = 0; i < 1000000; i++) {
Console.WriteLine(0);
}
}
```
=== "Go"
```go title=""
// Временная сложность алгоритма A: константная
func algorithm_A(n int) {
fmt.Println(0)
}
// Временная сложность алгоритма B: линейная
func algorithm_B(n int) {
for i := 0; i < n; i++ {
fmt.Println(0)
}
}
// Временная сложность алгоритма C: константная
func algorithm_C(n int) {
for i := 0; i < 1000000; i++ {
fmt.Println(0)
}
}
```
=== "Swift"
```swift title=""
// Временная сложность алгоритма A: константная
func algorithmA(n: Int) {
print(0)
}
// Временная сложность алгоритма B: линейная
func algorithmB(n: Int) {
for _ in 0 ..< n {
print(0)
}
}
// Временная сложность алгоритма C: константная
func algorithmC(n: Int) {
for _ in 0 ..< 1_000_000 {
print(0)
}
}
```
=== "JS"
```javascript title=""
// Временная сложность алгоритма A: константная
function algorithm_A(n) {
console.log(0);
}
// Временная сложность алгоритма B: линейная
function algorithm_B(n) {
for (let i = 0; i < n; i++) {
console.log(0);
}
}
// Временная сложность алгоритма C: константная
function algorithm_C(n) {
for (let i = 0; i < 1000000; i++) {
console.log(0);
}
}
```
=== "TS"
```typescript title=""
// Временная сложность алгоритма A: константная
function algorithm_A(n: number): void {
console.log(0);
}
// Временная сложность алгоритма B: линейная
function algorithm_B(n: number): void {
for (let i = 0; i < n; i++) {
console.log(0);
}
}
// Временная сложность алгоритма C: константная
function algorithm_C(n: number): void {
for (let i = 0; i < 1000000; i++) {
console.log(0);
}
}
```
=== "Dart"
```dart title=""
// Временная сложность алгоритма A: константная
void algorithmA(int n) {
print(0);
}
// Временная сложность алгоритма B: линейная
void algorithmB(int n) {
for (int i = 0; i < n; i++) {
print(0);
}
}
// Временная сложность алгоритма C: константная
void algorithmC(int n) {
for (int i = 0; i < 1000000; i++) {
print(0);
}
}
```
=== "Rust"
```rust title=""
// Временная сложность алгоритма A: константная
fn algorithm_A(n: i32) {
println!("{}", 0);
}
// Временная сложность алгоритма B: линейная
fn algorithm_B(n: i32) {
for _ in 0..n {
println!("{}", 0);
}
}
// Временная сложность алгоритма C: константная
fn algorithm_C(n: i32) {
for _ in 0..1000000 {
println!("{}", 0);
}
}
```
=== "C"
```c title=""
// Временная сложность алгоритма A: константная
void algorithm_A(int n) {
printf("%d", 0);
}
// Временная сложность алгоритма B: линейная
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
printf("%d", 0);
}
}
// Временная сложность алгоритма C: константная
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
printf("%d", 0);
}
}
```
=== "Kotlin"
```kotlin title=""
// Временная сложность алгоритма A: константная
fun algoritm_A(n: Int) {
println(0)
}
// Временная сложность алгоритма B: линейная
fun algorithm_B(n: Int) {
for (i in 0..<n){
println(0)
}
}
// Временная сложность алгоритма C: константная
fun algorithm_C(n: Int) {
for (i in 0..<1000000) {
println(0)
}
}
```
=== "Ruby"
```ruby title=""
# Временная сложность алгоритма A: константная
def algorithm_A(n)
puts 0
end
# Временная сложность алгоритма B: линейная
def algorithm_B(n)
(0...n).each { puts 0 }
end
# Временная сложность алгоритма C: константная
def algorithm_C(n)
(0...1_000_000).each { puts 0 }
end
```
На следующем рисунке показана временная сложность этих трех алгоритмических функций.
- Алгоритм `A` имеет только $1$ операцию вывода, время выполнения алгоритма не увеличивается с ростом $n$. Мы называем временную сложность этого алгоритма "константной".
- В алгоритме `B` операция вывода выполняется $n$ раз в цикле, время выполнения алгоритма растет линейно с увеличением $n$. Временная сложность этого алгоритма называется "линейной".
- В алгоритме `C` операция вывода выполняется $1000000$ раз в цикле, хотя время выполнения велико, оно не зависит от размера входных данных $n$. Поэтому временная сложность `C` такая же, как у `A`, и остается "константной".
![Тенденция роста времени алгоритмов A, B и C](../assets/time_complexity_simple_example.png)
По сравнению с прямым подсчетом времени выполнения алгоритма, какие особенности имеет анализ временной сложности?
- **Временная сложность может эффективно оценить эффективность алгоритма**. Например, время выполнения алгоритма `B` растет линейно, при $n > 1$ он медленнее алгоритма `A`, при $n > 1000000$ он медленнее алгоритма `C`. Фактически, при достаточно большом размере входных данных $n$ алгоритм с "константной" сложностью всегда лучше алгоритма с "линейной" сложностью, что и есть смысл тенденции роста времени.
- **Метод вычисления временной сложности более прост**. Очевидно, что платформа выполнения и типы вычислительных операций не связаны с тенденцией роста времени выполнения алгоритма. Поэтому при анализе временной сложности мы можем просто считать время выполнения всех вычислительных операций одинаковым "единичным временем", тем самым упрощая "подсчет времени выполнения вычислительных операций" до "подсчета количества вычислительных операций", что значительно снижает сложность оценки.
- **Временная сложность также имеет определенные ограничения**. Например, хотя временная сложность алгоритмов `A` и `C` одинакова, фактическое время выполнения сильно различается. Аналогично, хотя временная сложность алгоритма `B` выше, чем у `C`, при небольшом размере входных данных $n$ алгоритм `B` явно лучше алгоритма `C`. В таких случаях часто трудно судить об эффективности алгоритма только по временной сложности. Конечно, несмотря на вышеуказанные проблемы, анализ сложности остается наиболее эффективным и часто используемым методом оценки эффективности алгоритмов.
## Асимптотическая верхняя граница функции
Дана функция с размером входных данных $n$:
=== "Python"
```python title=""
def algorithm(n: int):
a = 1 # +1
a = a + 1 # +1
a = a * 2 # +1
# Цикл n раз
for i in range(n): # +1
print(0) # +1
```
=== "C++"
```cpp title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for (int i = 0; i < n; i++) { // +1(на каждой итерации выполняется i ++)
cout << 0 << endl; // +1
}
}
```
=== "Java"
```java title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for (int i = 0; i < n; i++) { // +1(на каждой итерации выполняется i ++)
System.out.println(0); // +1
}
}
```
=== "C#"
```csharp title=""
void Algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for (int i = 0; i < n; i++) { // +1(на каждой итерации выполняется i ++)
Console.WriteLine(0); // +1
}
}
```
=== "Go"
```go title=""
func algorithm(n int) {
a := 1 // +1
a = a + 1 // +1
a = a * 2 // +1
// Цикл n раз
for i := 0; i < n; i++ { // +1
fmt.Println(a) // +1
}
}
```
=== "Swift"
```swift title=""
func algorithm(n: Int) {
var a = 1 // +1
a = a + 1 // +1
a = a * 2 // +1
// Цикл n раз
for _ in 0 ..< n { // +1
print(0) // +1
}
}
```
=== "JS"
```javascript title=""
function algorithm(n) {
var a = 1; // +1
a += 1; // +1
a *= 2; // +1
// Цикл n раз
for(let i = 0; i < n; i++){ // +1(на каждой итерации выполняется i ++)
console.log(0); // +1
}
}
```
=== "TS"
```typescript title=""
function algorithm(n: number): void{
var a: number = 1; // +1
a += 1; // +1
a *= 2; // +1
// Цикл n раз
for(let i = 0; i < n; i++){ // +1(на каждой итерации выполняется i ++)
console.log(0); // +1
}
}
```
=== "Dart"
```dart title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for (int i = 0; i < n; i++) { // +1(на каждой итерации выполняется i ++)
print(0); // +1
}
}
```
=== "Rust"
```rust title=""
fn algorithm(n: i32) {
let mut a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for _ in 0..n { // +1(на каждой итерации выполняется i ++)
println!("{}", 0); // +1
}
}
```
=== "C"
```c title=""
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// Цикл n раз
for (int i = 0; i < n; i++) { // +1(на каждой итерации выполняется i ++)
printf("%d", 0); // +1
}
}
```
=== "Kotlin"
```kotlin title=""
fun algorithm(n: Int) {
var a = 1 // +1
a = a + 1 // +1
a = a * 2 // +1
// Цикл n раз
for (i in 0..<n) { // +1(на каждой итерации выполняется i ++)
println(0) // +1
}
}
```
=== "Ruby"
```ruby title=""
def algorithm(n)
a = 1 # +1
a = a + 1 # +1
a = a * 2 # +1
# Цикл n раз
(0...n).each do # +1
puts 0 # +1
end
end
```
Пусть количество операций алгоритма является функцией от размера входных данных $n$, обозначим ее как $T(n)$, тогда количество операций вышеуказанной функции равно:
$$
T(n) = 3 + 2n
$$
$T(n)$ является линейной функцией, что означает, что тенденция роста времени выполнения линейна, поэтому ее временная сложность является линейной.
Мы обозначаем линейную временную сложность как $O(n)$, этот математический символ называется <u>нотацией большого $O$ (big-$O$ notation)</u>, представляющей <u>асимптотическую верхнюю границу (asymptotic upper bound)</u> функции $T(n)$.
Анализ временной сложности по сути является вычислением асимптотической верхней границы "количества операций $T(n)$", он имеет четкое математическое определение.
!!! note "Асимптотическая верхняя граница функции"
Если существуют положительные действительные числа $c$ и $n_0$, такие что для всех $n > n_0$ выполняется $T(n) \leq c \cdot f(n)$, то можно считать, что $f(n)$ дает асимптотическую верхнюю границу для $T(n)$, обозначается как $T(n) = O(f(n))$.
Как показано на следующем рисунке, вычисление асимптотической верхней границы заключается в поиске функции $f(n)$, такой что при $n$ стремящемся к бесконечности $T(n)$ и $f(n)$ находятся на одном уровне роста, отличаясь только константным коэффициентом $c$.
![Асимптотическая верхняя граница функции](../assets/asymptotic_upper_bound.png)
## Метод вычисления
Асимптотическая верхняя граница имеет довольно математический характер, если вы чувствуете, что не полностью поняли ее, не беспокойтесь. Мы можем сначала освоить метод вычисления, и в процессе постоянной практики постепенно понять ее математический смысл.
Согласно определению, после определения $f(n)$ мы можем получить временную сложность $O(f(n))$. Так как же определить асимптотическую верхнюю границу $f(n)$? В целом это делается в два шага: сначала подсчитывается количество операций, затем определяется асимптотическая верхняя граница.
### Шаг первый: подсчет количества операций
Для кода вычисление производится построчно сверху вниз. Однако, поскольку в вышеуказанном $c \cdot f(n)$ константный коэффициент $c$ может принимать любое значение, **различные коэффициенты и константные члены в количестве операций $T(n)$ можно игнорировать**. Исходя из этого принципа, можно обобщить следующие приемы упрощения подсчета.
1. **Игнорировать константы в $T(n)$**. Поскольку они не зависят от $n$, они не влияют на временную сложность.
2. **Опускать все коэффициенты**. Например, циклы $2n$ раз, $5n + 1$ раз и т. д. можно упростить до $n$ раз, поскольку коэффициент перед $n$ не влияет на временную сложность.
3. **При вложенных циклах использовать умножение**. Общее количество операций равно произведению количества операций внешнего и внутреннего циклов, при этом для каждого уровня цикла можно применять приемы из пункт