Review the ru version with Codex. (#1870)

This commit is contained in:
Yudong Jin
2026-03-30 07:27:40 +08:00
committed by GitHub
parent 7a78369e4c
commit fe6443235b
97 changed files with 769 additions and 767 deletions
@@ -4,6 +4,6 @@
!!! abstract
Анализ сложности подобен пространственно-временному проводнику в огромной вселенной алгоритмов.
Анализ сложности подобен пространственно-временному проводнику в необъятной вселенной алгоритмов.
Он ведет нас вглубь двух измерений - времени и пространства, помогая искать более изящные решения.
Он ведет нас в глубину двух измерений - времени и пространства, помогая искать более изящные решения.
@@ -1,194 +1,194 @@
# Итерация и рекурсия
В алгоритмах очень часто приходится многократно выполнять одну и ту же задачу, и это тесно связано с анализом сложности. Поэтому, прежде чем переходить к временной и пространственной сложности, давай сначала разберемся, как в программах организуется повторяющееся выполнение задач, то есть с двумя базовыми управляющими структурами: итерацией и рекурсией.
В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к обсуждению временной и пространственной сложности, рассмотрим, как реализовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию.
## Итерация
<u>Итерация (iteration)</u> - это управляющая структура, предназначенная для многократного выполнения некоторой задачи. При итерации программа повторно выполняет определенный фрагмент кода при соблюдении некоторого условия, пока это условие не перестанет выполняться.
<u>Итерация (iteration)</u> - это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение определенного участка кода, пока выполняется определенное условие.
### Цикл for
Цикл `for` - одна из самых распространенных форм итерации, **она хорошо подходит в тех случаях, когда число повторений известно заранее**.
Цикл `for` - одна из наиболее распространенных форм итерации, **которая подходит для использования, когда количество итераций известно заранее**.
Следующая функция реализует вычисление суммы $1 + 2 + \dots + n$ на основе цикла `for` , а результат сохраняется в переменной `res` . Обрати внимание, что в Python `range(a, b)` соответствует "лево-замкнутому, право-открытому" интервалу, то есть перебираются значения $a, a + 1, \dots, b-1$ :
Следующая функция реализует суммирование $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$ , то есть между ними существует "линейная зависимость". На самом деле **временная сложность как раз и описывает такую "линейную зависимость"**. Соответствующий материал будет подробно разобран в следующем разделе.
Количество операций этой функции суммирования пропорционально размеру входных данных $n$ , или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в следующем разделе.
### Цикл while
Подобно циклу `for` , цикл `while` тоже является способом реализации итерации. В цикле `while` программа в каждом раунде сначала проверяет условие: если условие истинно, выполнение продолжается, иначе цикл завершается.
Подобно циклу `for` , цикл `while` также представляет собой метод реализации итерации. В цикле `while` программа перед каждой итерацией проверяет условие: если условие истинно, то выполнение продолжается, иначе цикл завершается.
Ниже мы используем цикл `while` для реализации суммы $1 + 2 + \dots + n$ :
Ниже приведен пример реализации суммирования $1 + 2 + \dots + n$ с использованием цикла `while` :
```src
[file]{iteration}-[class]{}-[func]{while_loop}
```
**Цикл `while` обладает большей свободой, чем цикл `for` **. В цикле `while` мы можем свободно задавать шаги инициализации и обновления условной переменной.
**Цикл `while` обладает большей степенью свободы по сравнению с циклом `for` **. В цикле `while` можно свободно управлять инициализацией и обновлением условной переменной.
Например, в следующем коде условная переменная $i$ обновляется два раза за один проход, и такой случай уже не слишком удобно выражать через цикл `for` :
Например, в следующем коде условная переменная $i$ обновляется дважды на каждой итерации, что затруднительно сделать с использованием цикла `for` :
```src
[file]{iteration}-[class]{}-[func]{while_loop_ii}
```
В целом **код с `for` обычно компактнее, а `while` более гибок**; обе конструкции позволяют реализовывать итерационные структуры. Выбор между ними должен определяться требованиями конкретной задачи.
В целом **код с использованием цикла `for` более компактный, а цикл `while` более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи.
### Вложенные циклы
Мы можем вкладывать одну циклическую структуру в другую; ниже показан пример на основе цикла `for` :
Внутрь одной циклической структуры можно вложить другую, например используя два цикла `for` :
```src
[file]{iteration}-[class]{}-[func]{nested_for_loop}
```
На рисунке ниже показана блок-схема такого вложенного цикла.
Ниже приведена блок-схема такого вложенного цикла.
![Блок-схема вложенного цикла](iteration_and_recursion.assets/nested_iteration.png)
В этом случае число операций функции пропорционально $n^2$ , то есть время работы алгоритма и размер входных данных $n$ находятся в "квадратичной зависимости".
В этом случае количество выполненных действий пропорционально $n^2$ , или, другими словами, время выполнения алгоритма и размер входных данных $n$ находятся в квадратичной зависимости.
Мы можем продолжать добавлять вложенные циклы, и каждое новое вложение будет означать очередное "повышение размерности", увеличивая временную сложность до "кубической зависимости", "зависимости четвертой степени" и так далее.
Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической зависимости, зависимости четвертой степени и так далее.
## Рекурсия
<u>Рекурсия (recursion)</u> - это алгоритмическая стратегия, в которой функция решает задачу, вызывая саму себя. В основном она включает две фазы.
<u>Рекурсия (recursion)</u> - это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа.
1. **Спуск**: программа все глубже вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не достигнет "условия завершения".
2. **Подъем**: после срабатывания "условия завершения" программа начинает возвращаться от самой глубокой рекурсивной функции вверх, собирая результаты с каждого уровня.
1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения.
2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя результаты каждого уровня.
С точки зрения реализации рекурсивный код в основном состоит из трех элементов.
С точки зрения реализации рекурсивный код включает три основных элемента.
1. **Условие завершения**: определяет момент перехода от "спуска" к "подъему".
2. **Рекурсивный вызов**: соответствует "спуску", когда функция вызывает саму себя, обычно с меньшими или более упрощенными параметрами.
3. **Возврат результата**: соответствует "подъему", когда результат текущего уровня рекурсии передается предыдущему.
1. **Условие завершения**: используется для определения момента перехода от вызова к возврату.
2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами.
3. **Возврат результата**: соответствует возврату, возвращает результат текущего уровня рекурсии на предыдущий уровень.
Посмотри на следующий код: нам достаточно вызвать функцию `recur(n)` , чтобы вычислить $1 + 2 + \dots + n$ :
Рассмотрим следующий код: вызов функции `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$ .
Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача $f(n) = 1 + 2 + \dots + n$ .
- **Итерация**: в цикле моделируется процесс суммирования от $1$ до $n$ , и на каждом шаге выполняется операция сложения, в результате чего получается $f(n)$ .
- **Рекурсия**: задача раскладывается на подзадачу $f(n) = n + f(n-1)$ , а затем продолжает раскладываться (рекурсивно) до базового случая $f(1) = 1$ .
- **Итерация**: моделирование процесса суммирования в цикле проходит от $1$ до $n$ , выполняя операцию суммирования на каждом шаге, чтобы получить итоговое значение $f(n)$ .
- **Рекурсия**: последовательное разбиение задачи на подзадачи вида $f(n) = n + f(n - 1)$ до достижения базового случая $f(1) = 1$ .
### Стек вызовов
Каждый раз, когда рекурсивная функция вызывает сама себя, система выделяет память для нового экземпляра функции, чтобы хранить локальные переменные, адрес возврата и другую информацию. Это приводит к двум последствиям.
Каждый раз, когда рекурсивная функция вызывает саму себя, система выделяет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия.
- Контекстные данные функции хранятся в области памяти, называемой "пространством кадра стека", и освобождаются только после возврата функции. Поэтому **рекурсия обычно требует больше памяти, чем итерация**.
- Вызов рекурсивной функции создает дополнительный накладной расход. **Поэтому рекурсия обычно уступает циклам по временной эффективности**.
- Контекстные данные функции хранятся в области памяти, называемой пространством стекового кадра, и освобождаются только после возврата функции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**.
- Рекурсивный вызов функции создает дополнительные накладные расходы. **Поэтому рекурсия обычно менее эффективна по времени, чем цикл**.
Как показано на рисунке ниже, до срабатывания условия завершения одновременно существует $n$ еще не завершившихся рекурсивных вызовов, а **глубина рекурсии равна $n$** .
Как показано на рисунке ниже, до срабатывания условия завершения одновременно существует $n$ невозвращенных рекурсивных функций, а **глубина рекурсии равна $n$** .
![Глубина рекурсивного вызова](iteration_and_recursion.assets/recursion_sum_depth.png)
На практике разрешенная языком программирования глубина рекурсии обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека.
На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека.
### Хвостовая рекурсия
Интересно, что **если функция выполняет рекурсивный вызов в самом последнем действии перед возвратом** , то компилятор или интерпретатор может оптимизировать такую функцию так, чтобы по использованию памяти она была сопоставима с итерацией. Такой случай называется <u>хвостовой рекурсией (tail recursion)</u>.
Интересно, что **если рекурсивный вызов происходит на последнем шаге перед возвратом функции** , то компилятор или интерпретатор может оптимизировать этот вызов, сделав его по эффективности использования памяти сопоставимым с итерацией. Это называется <u>хвостовой рекурсией (tail recursion)</u>.
- **Обычная рекурсия**: когда функция возвращается на предыдущий уровень, ей все еще нужно продолжать выполнять код, поэтому системе приходится сохранять контекст вызова предыдущего уровня.
- **Хвостовая рекурсия**: рекурсивный вызов - это последняя операция перед возвратом, а значит, после возвращения на предыдущий уровень не требуется выполнять дополнительных действий, и системе не нужно сохранять контекст предыдущей функции.
- **Обычная рекурсия**: когда функция возвращается на предыдущий уровень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова.
- **Хвостовая рекурсия**: рекурсивный вызов является последней операцией перед возвратом функции, что означает, что после возврата на предыдущий уровень не требуется выполнять другие операции, поэтому системе не нужно сохранять контекст предыдущей функции.
На примере вычисления $1 + 2 + \dots + n$ можно сделать переменную результата `res` параметром функции и тем самым реализовать хвостовую рекурсию:
В качестве примера вычисления суммы $1 + 2 + \dots + n$ можно установить переменную результата `res` в качестве параметра функции, чтобы реализовать хвостовую рекурсию:
```src
[file]{recursion}-[class]{}-[func]{tail_recur}
```
Процесс выполнения хвостовой рекурсии показан на рисунке ниже. Если сравнить обычную рекурсию и хвостовую рекурсию, то видно, что точка выполнения операции суммирования у них различается.
Процесс выполнения хвостовой рекурсии показан на рисунке ниже. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения операции суммирования у них различается.
- **Обычная рекурсия**: операция суммирования выполняется в процессе "подъема", то есть после возврата с каждого уровня еще нужно выполнить очередное сложение.
- **Хвостовая рекурсия**: операция суммирования выполняется в процессе "спуска", а сам "подъем" сводится лишь к последовательному возврату.
- **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить операцию суммирования.
- **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, а процесс возврата требует только последовательного возврата.
![Процесс хвостовой рекурсии](iteration_and_recursion.assets/tail_recursion_sum.png)
!!! tip
Обрати внимание: многие компиляторы и интерпретаторы не поддерживают оптимизацию хвостовой рекурсии. Например, Python по умолчанию такую оптимизацию не выполняет, поэтому даже функция в хвостово-рекурсивной форме все равно может привести к переполнению стека.
Обратите внимание: многие компиляторы и интерпретаторы не поддерживают оптимизацию хвостовой рекурсии. Например, Python по умолчанию такую оптимизацию не выполняет, поэтому даже функция в хвостово-рекурсивной форме все равно может привести к переполнению стека.
### Дерево рекурсии
При решении алгоритмических задач, связанных с "разделяй и властвуй", рекурсия часто дает более интуитивный способ рассуждения и более читаемый код, чем итерация. Возьмем в качестве примера "последовательность Фибоначчи".
При решении задач, связанных с алгоритмами типа "разделяй и властвуй", рекурсия часто оказывается более интуитивной и читабельной, чем итерация. Рассмотрим в качестве примера последовательность Фибоначчи.
!!! question
Дана последовательность Фибоначчи $0, 1, 1, 2, 3, 5, 8, 13, \dots$ ; найди $n$-й элемент этой последовательности.
Обозначим $n$-й элемент последовательности Фибоначчи как $f(n)$ . Тогда нетрудно получить два вывода.
Обозначив $n$-й член последовательности Фибоначчи как $f(n)$ , можно сформулировать два утверждения.
- Первые два числа последовательности равны $f(1) = 0$ и $f(2) = 1$ .
- Каждое последующее число равно сумме двух предыдущих, то есть $f(n) = f(n - 1) + f(n - 2)$ .
- Первые два числа последовательности: $f(1) = 0$ и $f(2) = 1$ .
- Каждое число последовательности является суммой двух предыдущих чисел, то есть $f(n) = f(n - 1) + f(n - 2)$ .
Следуя рекуррентному соотношению и используя первые два числа как условия завершения, мы можем написать рекурсивный код. Вызов `fib(n)` даст нам $n$-й элемент последовательности Фибоначчи:
Используя рекурсивные вызовы в соответствии с рекуррентным соотношением и принимая первые два числа за условия остановки, можно написать рекурсивный код. Вызов `fib(n)` позволит получить $n$-й член последовательности Фибоначчи:
```src
[file]{recursion}-[class]{}-[func]{fib}
```
Если посмотреть на приведенный код, внутри функции выполняются два рекурсивных вызова, **а это означает, что один вызов рождает две ветви вызова**. Как показано на рисунке ниже, при таком продолжении рекурсивных вызовов в итоге получается <u>дерево рекурсии (recursion tree)</u> глубиной $n$ .
Проанализировав приведенный код, можно заметить, что внутри функции осуществляется рекурсивный вызов двух функций, **то есть из одного вызова образуются два ветвления**. Как показано на рисунке ниже, при последующем выполнении рекурсивных вызовов в итоге образуется <u>дерево рекурсии (recursion tree)</u> глубиной $n$ .
![Дерево рекурсии последовательности Фибоначчи](iteration_and_recursion.assets/recursion_tree.png)
По своей сути рекурсия воплощает парадигму "разбиения задачи на более мелкие подзадачи", и именно поэтому стратегия разделяй-и-властвуй столь важна.
По своей сути рекурсия отражает парадигму мышления "разбиение задачи на более мелкие подзадачи", что делает стратегию "разделяй и властвуй" крайне важной.
- С точки зрения алгоритмов многие важнейшие стратегии, такие как поиск, сортировка, бэктрекинг, разделяй-и-властвуй и динамическое программирование, прямо или косвенно используют такой образ мышления.
- С точки зрения структур данных рекурсия естественным образом подходит для решения задач, связанных со связными списками, деревьями и графами, потому что они хорошо поддаются анализу через идеи разделения задачи.
- С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, "разделяй и властвуй" и динамическое программирование, прямо или косвенно используют этот подход.
- С точки зрения **структур данных** рекурсия естественно подходит для решения задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи "разделяй и властвуй".
## Сравнение двух подходов
## Сравнение
Обобщая все сказанное выше, можно представить различия между итерацией и рекурсией с точки зрения реализации, производительности и применимости в следующей таблице.
Подводя итог, можно сказать, что итерация и рекурсия различаются по реализации, производительности и применимости, как показано в таблице ниже.
<p align="center"> Таблица <id> &nbsp; Сравнение характеристик итерации и рекурсии </p>
<p align="center"> Таблица <id> &nbsp; Сравнение итерации и рекурсии </p>
| | Итерация | Рекурсия |
| -------- | -------------------------------------- | ------------------------------------------------------------ |
| Реализация | Циклическая структура | Функция вызывает сама себя |
| Временная эффективность | Обычно выше, так как нет накладных расходов на вызовы функций | Каждый вызов функции создает накладные расходы |
| Использование памяти | Обычно требуется фиксированный объем памяти | Накопление вызовов функции может занимать много места в кадрах стека |
| Подходящие задачи | Хорошо подходит для простых циклических задач, код интуитивен и легко читается | Хорошо подходит для разложения на подзадачи, например для деревьев, графов, разделяй-и-властвуй, бэктрекинга и т. д.; код при этом получается компактным и ясным |
| Способ реализации | Циклическая структура | Функция вызывает саму себя |
| Временная эффективность | Обычно высокая эффективность, нет затрат на вызов функции | Каждый вызов функции создает затраты |
| Использование памяти | Обычно используется фиксированный объем памяти | Накопление вызовов функции может использовать значительное количество пространства стека |
| Сфера использования | Подходит для простых циклических задач, код интуитивно понятен и хорошо читаем | Подходит для разбиения на подзадачи, для структур деревья и графы, алгоритмов "разделяй и властвуй", возврата и т. д.; структура кода проста и ясна |
!!! tip
Если тебе сложно понять дальнейшее содержание, можешь вернуться к нему после чтения главы о "стеке".
Если дальнейшее содержание кажется сложным, можно вернуться к нему после чтения главы о "стеке".
Какова же внутренняя связь между итерацией и рекурсией? Если снова взять рекурсивную функцию выше, операция суммирования выполняется в фазе "подъема" рекурсии. Это означает, что функция, вызванная первой, на самом деле завершает сложение последней, **и такой механизм очень похож на принцип стека "последним пришел - первым ушел"**.
Какова же внутренняя связь между итерацией и рекурсией? В рассмотренном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу стека "первым пришел - последним вышел"**.
На самом деле такие термины рекурсии, как "стек вызовов" и "пространство кадра стека", уже прямо намекают на тесную связь между рекурсией и стеком.
На самом деле такие термины рекурсии, как "стек вызовов" и "пространство стекового кадра", уже намекают на тесную связь между рекурсией и стеком.
1. **Спуск**: когда вызывается функция, система выделяет для нее новый кадр стека в "стеке вызовов", чтобы хранить локальные переменные, параметры, адрес возврата и другие данные.
2. **Подъем**: когда функция завершает выполнение и возвращается, соответствующий кадр стека удаляется из "стека вызовов", а среда выполнения предыдущей функции восстанавливается.
1. **Вызов**: когда вызывается функция, система выделяет для нее новый стековый кадр в "стеке вызовов" для хранения локальных переменных функции, параметров, адреса возврата и других данных.
2. **Возврат**: когда функция завершает выполнение и возвращает результат, соответствующий стековый кадр удаляется из "стека вызовов", восстанавливая среду выполнения предыдущей функции.
Поэтому **мы можем использовать явный стек для имитации поведения стека вызовов** и тем самым преобразовать рекурсию в итеративную форму:
Таким образом, **можно использовать явный стек для моделирования поведения стека вызовов**, чтобы преобразовать рекурсию в итеративную форму:
```src
[file]{recursion}-[class]{}-[func]{for_loop_recur}
```
Если посмотреть на приведенный выше код, видно, что после преобразования рекурсии в итерацию код становится сложнее. Хотя во многих случаях итерация и рекурсия действительно могут быть преобразованы друг в друга, это не всегда стоит делать по двум причинам.
Наблюдая за приведенным выше кодом, можно заметить, что после преобразования рекурсии в итерацию код становится более сложным. Хотя во многих случаях итерация и рекурсия действительно могут быть преобразованы друг в друга, это не всегда стоит делать по двум причинам.
- Преобразованный код может стать труднее для понимания и менее читаемым.
- Для некоторых сложных задач имитация поведения системного стека вызовов может оказаться очень трудной.
- Для некоторых сложных задач моделирование поведения системного стека вызовов может оказаться очень трудным.
Итак, **выбор между итерацией и рекурсией зависит от природы конкретной задачи**. В практическом программировании крайне важно взвешивать плюсы и минусы обоих подходов и выбирать подходящий метод с учетом контекста.
Итак, **выбор между итерацией и рекурсией зависит от природы конкретной задачи**. В практическом программировании крайне важно взвешивать преимущества и недостатки обоих подходов и выбирать подходящий метод с учетом контекста.
@@ -1,49 +1,49 @@
# Оценка эффективности алгоритмов
При проектировании алгоритмов мы последовательно стремимся к двум уровням целей.
В процессе разработки алгоритмов мы стремимся к достижению следующих целей.
1. **Найти решение задачи**: алгоритм должен надежно получать правильный ответ в заданном диапазоне входных данных.
2. **Найти оптимальное решение**: для одной и той же задачи может существовать несколько решений, и нам хочется выбрать максимально эффективный алгоритм.
1. **Найти решение задачи**: алгоритм должен надежно находить правильное решение задачи в заданных пределах входных данных.
2. **Найти оптимальное решение**: для одной и той же задачи может существовать несколько решений, и мы стремимся найти максимально эффективный алгоритм.
Иными словами, если задача в принципе решается, эффективность алгоритма становится главным критерием оценки его качества. Она включает два следующих измерения.
Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта.
- **Временная эффективность**: сколько времени работает алгоритм.
- **Пространственная эффективность**: сколько памяти занимает алгоритм.
- **Временная эффективность**: продолжительность выполнения алгоритма.
- **Пространственная эффективность**: объем памяти, занимаемой алгоритмом.
Короче говоря, **наша цель - проектировать структуры данных и алгоритмы, которые "и быстры, и экономны по памяти"**. Эффективная оценка алгоритмов крайне важна, потому что только так можно сравнивать разные алгоритмы и направлять процесс их проектирования и оптимизации.
В двух словах, **наша цель - разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процессом их разработки и оптимизации.
Методы оценки эффективности в основном делятся на два типа: практическое тестирование и теоретическая оценка.
Методы оценки эффективности делятся на два типа: практическое тестирование и теоретическую оценку.
## Практическое тестирование
Предположим, у нас есть алгоритм `A` и алгоритм `B`, оба решают одну и ту же задачу, и нам нужно сравнить их эффективность. Самый прямой способ - взять компьютер, запустить оба алгоритма и зафиксировать время работы и объем используемой памяти. Такой способ оценки отражает реальную ситуацию, но имеет и серьезные ограничения.
Предположим, у нас есть алгоритмы `A` и `B`, которые решают одну и ту же задачу, и необходимо сравнить их эффективность. Самый прямой метод - это запустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения.
С одной стороны, **трудно исключить влияние факторов тестовой среды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм имеет высокий уровень параллелизма, он лучше подходит для многоядерных CPU; если алгоритм интенсивно работает с памятью, он покажет себя лучше на быстрой памяти. Иными словами, результаты тестирования одного и того же алгоритма на разных машинах могут различаться. Это означает, что пришлось бы тестировать на самых разных машинах и усреднять результаты, а на практике это нереалистично.
С одной стороны, **сложно исключить влияние факторов тестовой среды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных CPU; если алгоритм интенсивно использует память, его производительность будет выше на высокопроизводительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, а для получения средней эффективности пришлось бы тестировать на различных платформах, что крайне затруднительно.
С другой стороны, **полное тестирование требует больших ресурсов**. По мере изменения объема входных данных алгоритм может вести себя по-разному. Например, при небольшом объеме входных данных время работы алгоритма `A` может быть меньше, чем у алгоритма `B`; но при большом объеме результаты могут оказаться прямо противоположными. Поэтому для убедительных выводов пришлось бы тестировать входные данные множества разных масштабов, а это требует значительных вычислительных ресурсов.
С другой стороны, **проведение полного тестирования требует значительных ресурсов**. С изменением объема входных данных алгоритмы демонстрируют разную эффективность. Например, при небольшом объеме данных алгоритм `A` может работать быстрее, чем алгоритм `B`, но при большом объеме данных результат может быть противоположным. Следовательно, для получения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов.
## Теоретическая оценка
Поскольку практическое тестирование имеет серьезные ограничения, можно попытаться оценить эффективность алгоритма только с помощью вычислений. Такой метод называется <u>асимптотическим анализом сложности (asymptotic complexity analysis)</u>, или сокращенно <u>анализом сложности</u>.
Из-за значительных ограничений практического тестирования можно рассмотреть возможность оценки эффективности алгоритмов только с помощью вычислений. Такой метод называется <u>анализом асимптотической сложности (asymptotic complexity analysis)</u>, или сокращенно <u>анализом сложности</u>.
Анализ сложности показывает зависимость между временем и пространственными ресурсами, требуемыми алгоритму, и масштабом входных данных. **Он описывает тенденцию роста времени и памяти, необходимых алгоритму, по мере увеличения размера входных данных**. Это определение звучит немного тяжеловесно, поэтому полезно разложить его на три ключевые идеи.
Анализ сложности позволяет отразить зависимость между ресурсами времени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и пространства, необходимых для выполнения алгоритма, по мере увеличения размера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента.
- "Временные и пространственные ресурсы" соответствуют <u>временной сложности (time complexity)</u> и <u>пространственной сложности (space complexity)</u> соответственно.
- "По мере увеличения размера входных данных" означает, что сложность отражает связь между эффективностью алгоритма и масштабом входа.
- "Тенденция роста времени и пространства" означает, что анализ сложности интересуется не конкретными значениями времени или памяти, а тем, насколько быстро они растут.
- "Ресурсы времени и пространства" соответствуют <u>временной сложности (time complexity)</u> и <u>пространственной сложности (space complexity)</u>.
- "По мере увеличения размера входных данных" означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных.
- "Тенденция роста времени и пространства" указывает, что анализ сложности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста.
**Анализ сложности устраняет недостатки практического тестирования**, что проявляется в следующих аспектах.
**Анализ сложности преодолевает недостатки метода практического тестирования**, что выражается в следующих аспектах.
- Для него не нужно реально запускать код, а значит, он экологичнее и экономит ресурсы.
- Он не зависит от тестовой среды, поэтому результаты анализа применимы ко всем платформам выполнения.
- Он позволяет увидеть эффективность алгоритма при разных объемах данных, особенно на больших данных.
- Он не требует фактического выполнения кода, что делает его более экологичным и энергосберегающим.
- Он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения.
- Он может продемонстрировать эффективность алгоритма при различных объемах данных, особенно при больших объемах.
!!! tip
Если понятие сложности пока все еще кажется тебе запутанным, не переживай: мы подробно разберем его в следующих разделах.
Если понятие сложности пока все еще кажется вам запутанным, не переживайте: мы подробно разберем его в следующих разделах.
Анализ сложности дает нам "линейку" для оценки эффективности алгоритмов, позволяя измерять, сколько времени и памяти требуется для выполнения конкретного алгоритма, и сравнивать эффективность разных алгоритмов между собой.
Анализ сложности предоставляет нам мерило оценки эффективности алгоритмов, позволяя измерять время и ресурсы, необходимые для выполнения конкретного алгоритма, а также сравнивать эффективность различных алгоритмов.
Сложность - это математическое понятие, поэтому для начинающих оно может показаться довольно абстрактным и сравнительно трудным. С этой точки зрения анализ сложности, возможно, не лучший самый первый материал для знакомства. Однако, когда мы обсуждаем особенности конкретной структуры данных или алгоритма, почти невозможно не затронуть скорость его работы и использование памяти.
Сложность - это математическое понятие, которое новичкам может показаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуждая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти.
В итоге рекомендуется еще до глубокого погружения в структуры данных и алгоритмы **сформировать хотя бы первичное понимание анализа сложности, чтобы уметь выполнять анализ сложности простых алгоритмов**.
Таким образом, перед погружением в изучение структур данных и алгоритмов рекомендуется получить базовое представление об анализе сложности, чтобы иметь возможность выполнять хотя бы базовую оценку их эффективности.
@@ -1,28 +1,28 @@
# Пространственная сложность
<u>Пространственная сложность (space complexity)</u> используется для оценки того, как меняется объем памяти, занимаемой алгоритмом, по мере роста объема данных. Это понятие очень похоже на временную сложность, только вместо "времени выполнения" мы рассматриваем "объем используемой памяти".
<u>Пространственная сложность (space complexity)</u> служит для оценки того, как меняется объем памяти, требуемой алгоритму, по мере роста объема данных. Это понятие очень похоже на временную сложность, только вместо времени выполнения рассматривается объем используемой памяти.
## Пространство, связанное с алгоритмом
Память, которую использует алгоритм во время работы, в основном включает несколько следующих частей.
Память, которую использует алгоритм во время работы, в основном делится на следующие части.
- **Входное пространство**: используется для хранения входных данных алгоритма.
- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных, возникающих во время выполнения алгоритма.
- **Выходное пространство**: используется для хранения выходных данных алгоритма.
В общем случае при анализе пространственной сложности в расчет включают "временное пространство" и "выходное пространство".
Как правило, при анализе пространственной сложности в расчет включают временное пространство и выходное пространство.
Временное пространство можно дополнительно разделить на три части.
- **Временные данные**: используются для хранения различных констант, переменных, объектов и т.д., возникающих во время выполнения алгоритма.
- **Пространство кадров стека**: используется для хранения контекстных данных вызываемых функций. Система при каждом вызове функции создает на вершине стека новый кадр; после возврата функции пространство этого кадра освобождается.
- **Пространство кадров стека**: используется для хранения контекстных данных вызываемых функций. При каждом вызове функции система создает на вершине стека новый кадр; после возврата функции пространство этого кадра освобождается.
- **Пространство инструкций**: используется для хранения скомпилированных инструкций программы и в реальном подсчете обычно не учитывается.
При анализе пространственной сложности программы **мы обычно учитываем три части: временные данные, пространство кадров стека и выходные данные**, как показано на рисунке ниже.
При анализе пространственной сложности программы **обычно учитываются временные данные, пространство стека и выходные данные**, как показано на рисунке ниже.
![Пространство, используемое алгоритмом](space_complexity.assets/space_types.png)
Соответствующий код выглядит следующим образом:
Ниже приведен соответствующий код:
=== "Python"
@@ -363,14 +363,14 @@
## Метод вывода
Метод вывода пространственной сложности в целом аналогичен временному анализу: меняется только объект подсчета, с "количества операций" на "размер используемого пространства".
Метод вывода пространственной сложности в целом аналогичен выводу временной сложности: меняется только объект подсчета, с количества операций на размер используемого пространства.
В отличие от временной сложности, **обычно мы рассматриваем только худшую пространственную сложность**. Это связано с тем, что память является жестким ограничением: нам нужно гарантировать, что для любых входных данных у программы будет достаточно памяти.
В отличие от временной сложности, **обычно рассматривается только худшая пространственная сложность**. Это связано с тем, что память является жестким ограничением: необходимо гарантировать, что для любых входных данных у программы будет достаточно памяти.
Рассмотрим следующий код. Слово "худшая" в "худшей пространственной сложности" имеет два значения.
Рассмотрим следующий код. Понятие худшей пространственной сложности здесь имеет два значения.
1. **Ориентир на худшие входные данные**: когда $n < 10$ , пространственная сложность равна $O(1)$ ; но когда $n > 10$ , инициализированный массив `nums` занимает $O(n)$ пространства, поэтому худшая пространственная сложность равна $O(n)$ .
2. **Ориентир на пиковое потребление памяти во время выполнения алгоритма**: например, до выполнения последней строки программа занимает $O(1)$ пространства; при инициализации массива `nums` она занимает $O(n)$ пространства, поэтому худшая пространственная сложность равна $O(n)$ .
2. **Ориентир на пиковое использование памяти во время выполнения**: например, до выполнения последней строки программа занимает $O(1)$ пространства; при инициализации массива `nums` она занимает $O(n)$ пространства, поэтому худшая пространственная сложность также равна $O(n)$ .
=== "Python"
@@ -802,7 +802,7 @@
## Распространенные типы
Пусть размер входных данных равен $n$ . На рисунке ниже показаны распространенные типы пространственной сложности (в порядке от меньшей к большей).
Пусть размер входных данных равен $n$ . На рисунке ниже показаны распространенные типы пространственной сложности в порядке от меньшей к большей.
$$
\begin{aligned}
@@ -815,7 +815,7 @@ $$
### Постоянная сложность $O(1)$
Постоянная сложность часто встречается у констант, переменных и объектов, количество которых не зависит от размера входных данных $n$ .
Постоянная сложность обычно встречается у констант, переменных и объектов, количество которых не зависит от размера входных данных $n$ .
Следует заметить, что память, занятая инициализацией переменных или вызовом функций внутри цикла, освобождается при переходе к следующей итерации, поэтому она не накапливается, и пространственная сложность по-прежнему остается $O(1)$ :
@@ -825,7 +825,7 @@ $$
### Линейная сложность $O(n)$
Линейная сложность часто встречается у массивов, связных списков, стеков, очередей и других структур, число элементов в которых пропорционально $n$ :
Линейная сложность часто встречается у массивов, списков, стеков, очередей и других структур, число элементов в которых пропорционально $n$ :
```src
[file]{space_complexity}-[class]{}-[func]{linear}
@@ -857,7 +857,7 @@ $$
### Экспоненциальная сложность $O(2^n)$
Экспоненциальная сложность часто встречается у бинарных деревьев. Обрати внимание на рисунок ниже: "полное бинарное дерево" с $n$ уровнями содержит $2^n - 1$ узлов и занимает $O(2^n)$ пространства:
Экспоненциальная сложность часто встречается у бинарных деревьев. Полное бинарное дерево с $n$ уровнями содержит $2^n - 1$ узлов и занимает $O(2^n)$ пространства:
```src
[file]{space_complexity}-[class]{}-[func]{build_tree}
@@ -867,14 +867,14 @@ $$
### Логарифмическая сложность $O(\log n)$
Логарифмическая сложность часто встречается в алгоритмах "разделяй и властвуй". Например, при сортировке слиянием входной массив длины $n$ на каждом шаге рекурсии делится пополам по середине, образуя рекурсивное дерево высоты $\log n$ и используя $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)$ .
## Компромисс между временем и пространством
В идеале нам хотелось бы, чтобы и временная, и пространственная сложность алгоритма были оптимальными. Однако на практике одновременно оптимизировать и время, и память обычно очень трудно.
В идеальных условиях хотелось бы, чтобы и временная, и пространственная сложность алгоритма были оптимальными. Однако на практике одновременно оптимизировать и время, и память обычно очень трудно.
**Снижение временной сложности обычно достигается ценой увеличения пространственной сложности, и наоборот**. Подход, при котором мы жертвуем памятью ради ускорения работы алгоритма, называется "обмен пространства на время"; обратный подход называется "обмен времени на пространство".
**Снижение временной сложности обычно достигается ценой увеличения пространственной сложности, и наоборот**. Подход, при котором жертвуют памятью ради ускорения работы алгоритма, называется обменом пространства на время; обратный подход называется обменом времени на пространство.
Выбор между этими двумя идеями зависит от того, что для нас важнее. В большинстве случаев время ценнее памяти, поэтому стратегия "обмена пространства на время" используется чаще. Но при очень больших объемах данных контроль пространственной сложности тоже становится крайне важным.
Выбор между этими двумя идеями зависит от того, что важнее в конкретной задаче. В большинстве случаев время ценнее памяти, поэтому стратегия обмена пространства на время используется чаще. Но при очень больших объемах данных контроль пространственной сложности тоже становится крайне важным.
@@ -4,25 +4,25 @@
**Оценка эффективности алгоритмов**
- Временная эффективность и пространственная эффективность - два главных показателя, по которым оценивают качество алгоритма.
- Мы можем оценивать эффективность алгоритма с помощью практического тестирования, но при этом трудно устранить влияние тестовой среды, а само тестирование потребляет много вычислительных ресурсов.
- Анализ сложности устраняет недостатки практического тестирования, дает результаты, применимые ко всем платформам выполнения, и позволяет увидеть эффективность алгоритма при разных масштабах данных.
- Временная и пространственная эффективность являются двумя основными критериями для оценки качества алгоритмов.
- Эффективность алгоритмов можно оценивать с помощью практических тестов, однако это сложно из-за влияния тестовой среды и значительных затрат вычислительных ресурсов.
- Анализ сложности позволяет устранить недостатки практических тестов, а результаты анализа применимы ко всем платформам и могут выявить эффективность алгоритма при различных объемах данных.
**Временная сложность**
- Временная сложность используется для оценки того, как меняется время работы алгоритма с ростом объема данных. Она хорошо подходит для оценки эффективности, но в некоторых случаях может давать недостаточно точное сравнение, например когда входные данные малы или когда временные сложности совпадают.
- Худшая временная сложность обозначается с помощью нотации Big $O$ и соответствует асимптотической верхней границе функции, отражая уровень роста числа операций $T(n)$ при стремлении $n$ к положительной бесконечности.
- Вывод временной сложности включает два шага: сначала подсчитывается число операций, затем определяется асимптотическая верхняя граница.
- Распространенные временные сложности в порядке роста: $O(1)$, $O(\log n)$, $O(n)$, $O(n \log n)$, $O(n^2)$, $O(2^n)$ и $O(n!)$.
- Временная сложность некоторых алгоритмов не фиксирована, а зависит от распределения входных данных. Различают худшую, лучшую и среднюю временную сложность; лучшая временная сложность используется редко, потому что для ее достижения вход обычно должен удовлетворять строгим условиям.
- Средняя временная сложность отражает эффективность алгоритма на случайных входных данных и ближе всего к его поведению в практических сценариях. Для ее вычисления нужно знать распределение входных данных и рассчитать соответствующее математическое ожидание.
- Временная сложность используется для оценки тенденции изменения времени выполнения алгоритма с увеличением объема данных, что позволяет оценивать его эффективность. Однако в некоторых случаях она может работать не так хорошо, например когда объем входных данных мал или временная сложность одинакова, что не позволяет точно сравнить эффективность алгоритмов.
- Худшая временная сложность обозначается символом 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)$.
- Пространственная сложность аналогична временной сложности и используется для оценки тенденции изменения объема памяти, занимаемой алгоритмом, с увеличением объема данных.
- Память, используемую в процессе выполнения алгоритма, можно разделить на входное пространство, временное пространство и выходное пространство. Обычно при расчете пространственной сложности входное пространство не учитывается. Временное пространство делится на временные данные, пространство стека и пространство инструкций, причем пространство стека обычно влияет на сложность только в рекурсивных функциях.
- Обычно рассматривается только худшая пространственная сложность, то есть пространственная сложность алгоритма при худших входных данных и в худший момент выполнения.
- Наиболее распространенные пространственные сложности в порядке возрастания: $O(1)$, $O(\log n)$, $O(n)$, $O(n^2)$ и $O(2^n)$.
### Q & A
@@ -1,6 +1,6 @@
# Временная сложность
Время выполнения может наглядно и точно отражать эффективность алгоритма. Если мы хотим точно оценить время работы некоторого фрагмента кода, как это сделать?
Время выполнения действительно может наглядно и точно отражать эффективность алгоритма. Но если мы захотим точно оценить время работы некоторого фрагмента кода, то столкнемся со следующими шагами.
1. **Определить платформу выполнения**, включая конфигурацию оборудования, язык программирования, системную среду и т.д., поскольку все эти факторы влияют на эффективность выполнения кода.
2. **Оценить время выполнения различных вычислительных операций**, например операция сложения `+` требует 1 нс , операция умножения `*` требует 10 нс , операция вывода `print()` требует 5 нс и т.д.
@@ -207,13 +207,13 @@ $$
1 + 1 + 10 + (1 + 5) \times n = 6n + 12
$$
Но на практике **подсчитывать реальное время выполнения алгоритма и неразумно, и нереалистично**. Во-первых, мы не хотим привязывать оценку времени к конкретной платформе, потому что алгоритм должен запускаться на самых разных платформах. Во-вторых, нам трудно узнать время выполнения каждого типа операций, а это сильно усложняет оценку.
Но на практике **подсчитывать реальное время выполнения алгоритма и неразумно, и нереалистично**. Во-первых, мы не хотим привязывать оценку времени к конкретной платформе, потому что алгоритм должен запускаться на самых разных платформах. Во-вторых, нам трудно определить время выполнения каждого типа операций, а это делает точную оценку крайне затруднительной.
## Подсчет тенденции роста времени
Анализ временной сложности оценивает не само время выполнения алгоритма, **а тенденцию роста этого времени по мере увеличения объема данных**.
Понятие "тенденции роста времени" довольно абстрактно, поэтому разберем его на примере. Предположим, размер входных данных равен $n$ , и даны три алгоритма `A` , `B` и `C` :
Понятие "тенденции роста времени" выглядит довольно абстрактным, поэтому разберем его на примере. Предположим, размер входных данных равен $n$ , и даны три алгоритма `A` , `B` и `C` :
=== "Python"
@@ -484,19 +484,19 @@ $$
end
```
На рисунке ниже показана временная сложность трех функций алгоритмов выше.
Ниже показаны временные сложности трех приведенных выше функций.
- У алгоритма `A` есть только 1 операция вывода, и время его работы не растет с увеличением $n$ . Мы называем такую временную сложность "постоянной".
- В алгоритме `B` операция вывода выполняется в цикле $n$ раз, поэтому время работы растет линейно по мере увеличения $n$ . Такая временная сложность называется "линейной".
- В алгоритме `C` операция вывода выполняется $1000000$ раз; хотя время работы велико, оно не зависит от размера входных данных $n$ . Поэтому временная сложность `C` такая же, как у `A` , и тоже является "постоянной".
- У алгоритма `A` есть только одна операция вывода, и время его работы не растет с увеличением $n$ . Такую временную сложность называют постоянной.
- В алгоритме `B` операция вывода выполняется в цикле $n$ раз, поэтому время работы растет линейно по мере увеличения $n$ . Такая временная сложность называется линейной.
- В алгоритме `C` операция вывода выполняется $1000000$ раз; хотя время работы велико, оно не зависит от размера входных данных $n$ . Поэтому временная сложность `C` такая же, как у `A` , и тоже является постоянной.
![Тенденции роста времени для алгоритмов A, B и C](time_complexity.assets/time_complexity_simple_example.png)
Какие особенности имеет анализ временной сложности по сравнению с непосредственным измерением времени работы алгоритма?
- **Временная сложность позволяет эффективно оценивать эффективность алгоритма**. Например, время работы алгоритма `B` растет линейно: при $n > 1$ он медленнее алгоритма `A` , а при $n > 1000000$ медленнее алгоритма `C` . На самом деле, если размер входных данных $n$ достаточно велик, алгоритм с "постоянной" сложностью обязательно лучше алгоритма с "линейной" сложностью. В этом и состоит смысл тенденции роста времени.
- **Метод вывода временной сложности проще**. Очевидно, что платформа выполнения и тип вычислительных операций не влияют на тенденцию роста времени работы алгоритма. Поэтому в анализе временной сложности мы можем считать время выполнения всех вычислительных операций одинаковым "единичным временем" и тем самым упростить "подсчет времени выполнения операций" до "подсчета количества операций", что существенно снижает сложность оценки.
- **У временной сложности есть и определенные ограничения**. Например, хотя временная сложность алгоритмов `A` и `C` одинакова, их реальное время выполнения сильно различается. Точно так же, хотя временная сложность `B` выше, чем у `C` , при малых $n$ алгоритм `B` явно лучше `C` . В таких случаях нам часто трудно судить об эффективности алгоритма, опираясь только на временную сложность. Тем не менее, несмотря на эти ограничения, анализ сложности все равно остается самым эффективным и самым распространенным способом оценки алгоритмов.
- **Временная сложность позволяет эффективно оценивать эффективность алгоритма**. Например, время работы алгоритма `B` растет линейно: при $n > 1$ он медленнее алгоритма `A` , а при $n > 1000000$ медленнее алгоритма `C` . Если размер входных данных достаточно велик, алгоритм с постоянной сложностью обязательно лучше алгоритма с линейной сложностью. В этом и состоит смысл тенденции роста времени.
- **Метод вывода временной сложности проще**. Платформа выполнения и тип вычислительных операций не влияют на тенденцию роста времени работы алгоритма. Поэтому в анализе временной сложности можно считать время выполнения всех вычислительных операций одинаковым единичным временем и тем самым упростить подсчет времени выполнения до подсчета количества операций.
- **У временной сложности есть и определенные ограничения**. Например, хотя временная сложность алгоритмов `A` и `C` одинакова, их реальное время выполнения сильно различается. Точно так же, хотя временная сложность `B` выше, чем у `C` , при малых $n$ алгоритм `B` очевидно лучше `C` . Несмотря на эти ограничения, анализ сложности все равно остается самым эффективным и самым распространенным способом оценки алгоритмов.
## Асимптотическая верхняя граница функции
@@ -689,11 +689,11 @@ $$
T(n) = 3 + 2n
$$
$T(n)$ - линейная функция, а это означает, что тенденция роста времени работы линейна, следовательно, ее временная сложность является линейной.
$T(n)$ - линейная функция, а это означает, что тенденция роста времени работы линейна, следовательно, временная сложность здесь тоже линейна.
Линейную временную сложность мы записываем как $O(n)$ ; этот математический символ называется <u>нотацией Big $O$ (big-$O$ notation)</u> и обозначает <u>асимптотическую верхнюю границу (asymptotic upper bound)</u> функции $T(n)$ .
Линейную временную сложность записывают как $O(n)$ ; этот математический символ называется <u>нотацией Big $O$ (big-$O$ notation)</u> и обозначает <u>асимптотическую верхнюю границу (asymptotic upper bound)</u> функции $T(n)$ .
По сути анализ временной сложности - это вычисление асимптотической верхней границы "количества операций $T(n)$", и у него есть строгое математическое определение.
Иными словами, анализ временной сложности сводится к определению асимптотической верхней границы числа операций $T(n)$, и у этого понятия есть строгое математическое определение.
!!! note "Асимптотическая верхняя граница функции"
@@ -705,13 +705,13 @@ $T(n)$ - линейная функция, а это означает, что т
## Метод вывода
Математическое определение асимптотической верхней границы выглядит довольно формально, и если ты понял его не до конца, переживать не стоит. Сначала можно освоить сам метод вывода, а в процессе дальнейшей практики постепенно почувствовать его математический смысл.
Математическое определение асимптотической верхней границы выглядит довольно формально, и если оно пока не до конца понятно, переживать не стоит. Сначала можно освоить сам метод вывода, а в процессе дальнейшей практики постепенно почувствовать его математический смысл.
Согласно определению, после того как мы определили $f(n)$ , мы можем получить временную сложность $O(f(n))$ . Но как определить саму асимптотическую верхнюю границу $f(n)$ ? В целом процесс состоит из двух шагов: сначала подсчитать количество операций, затем определить асимптотическую верхнюю границу.
Согласно определению, после того как мы определили $f(n)$ , можно получить временную сложность $O(f(n))$ . Но как определить саму асимптотическую верхнюю границу $f(n)$ ? В целом процесс состоит из двух шагов: сначала подсчитать количество операций, затем определить асимптотическую верхнюю границу.
### Шаг 1: подсчет количества операций
Для кода это можно делать построчно сверху вниз. Однако, поскольку в выражении $c \cdot f(n)$ выше постоянный коэффициент $c$ может быть сколь угодно большим, **различные коэффициенты и постоянные члены в числе операций $T(n)$ можно игнорировать**. Исходя из этого принципа, можно сформулировать следующие упрощающие приемы подсчета.
Для кода это можно делать построчно сверху вниз. Однако, поскольку в выражении $c \cdot f(n)$ постоянный коэффициент $c$ может быть сколь угодно большим, **различные коэффициенты и постоянные члены в числе операций $T(n)$ можно игнорировать**. Исходя из этого принципа, можно сформулировать следующие упрощающие приемы подсчета.
1. **Игнорировать константы в $T(n)$**. Они не зависят от $n$ , а значит не влияют на временную сложность.
2. **Опускать все коэффициенты**. Например, циклы на $2n$ раз или $5n + 1$ раз можно упростить до $n$ раз, потому что коэффициент перед $n$ не влияет на временную сложность.
@@ -974,7 +974,7 @@ $$
**Временная сложность определяется старшим по степени членом в $T(n)$ **. Это связано с тем, что при стремлении $n$ к бесконечности именно старший член начинает доминировать, а влиянием остальных членов можно пренебречь.
В таблице ниже приведены несколько примеров. Некоторые значения специально сделаны преувеличенными, чтобы подчеркнуть вывод: "коэффициент не способен изменить порядок". Когда $n$ стремится к бесконечности, эти константы становятся несущественными.
В таблице ниже приведены несколько примеров. Некоторые значения специально сделаны преувеличенными, чтобы подчеркнуть вывод: коэффициент не способен изменить порядок. Когда $n$ стремится к бесконечности, эти константы становятся несущественными.
<p align="center"> Таблица <id> &nbsp; Временная сложность, соответствующая разному количеству операций </p>
@@ -988,7 +988,7 @@ $$
## Распространенные типы
Пусть размер входных данных равен $n$ ; распространенные типы временной сложности показаны на рисунке ниже (в порядке от меньшей к большей).
Пусть размер входных данных равен $n$ ; распространенные типы временной сложности показаны на рисунке ниже в порядке от меньшей к большей.
$$
\begin{aligned}
@@ -1011,7 +1011,7 @@ $$
### Линейная сложность $O(n)$
Число операций при линейной сложности растет линейно относительно размера входных данных $n$ . Линейная сложность обычно встречается в одноуровневых циклах:
Линейная сложность характеризуется тем, что число операций растет линейно относительно размера входных данных $n$ . Линейная сложность обычно встречается в одноуровневых циклах:
```src
[file]{time_complexity}-[class]{}-[func]{linear}
@@ -1023,11 +1023,11 @@ $$
[file]{time_complexity}-[class]{}-[func]{array_traversal}
```
Стоит отметить, что **размер входных данных $n$ нужно определять конкретно в зависимости от типа входа**. Например, в первом примере переменная $n$ сама является размером входных данных; во втором примере размером данных служит длина массива $n$ .
Стоит отметить, что **размер входных данных $n$ нужно определять конкретно в зависимости от типа входа**. Например, в первом примере переменная $n$ сама является размером входных данных; во втором примере размером данных служит длина массива.
### Квадратичная сложность $O(n^2)$
Число операций при квадратичной сложности растет квадратично относительно размера входных данных $n$ . Квадратичная сложность обычно встречается во вложенных циклах: временная сложность внешнего и внутреннего циклов равна $O(n)$ , поэтому общая временная сложность составляет $O(n^2)$ :
Квадратичная сложность характеризуется тем, что число операций растет квадратично относительно размера входных данных $n$ . Квадратичная сложность обычно встречается во вложенных циклах: временная сложность внешнего и внутреннего циклов равна $O(n)$ , поэтому общая временная сложность составляет $O(n^2)$ :
```src
[file]{time_complexity}-[class]{}-[func]{quadratic}
@@ -1037,7 +1037,7 @@ $$
![Постоянная, линейная и квадратичная временная сложность](time_complexity.assets/time_complexity_constant_linear_quadratic.png)
Возьмем в качестве примера пузырьковую сортировку: внешний цикл выполняется $n - 1$ раз, внутренний цикл выполняется $n-1$ , $n-2$ , $\dots$ , $2$ , $1$ раз, в среднем это $n / 2$ раз, поэтому временная сложность равна $O((n - 1) n / 2) = O(n^2)$ :
Возьмем в качестве примера пузырьковую сортировку: внешний цикл выполняется $n - 1$ раз, внутренний цикл выполняется $n-1$ , $n-2$ , $\dots$ , $2$ , $1$ раз, в среднем это $n / 2$ раз, поэтому временная сложность равна $O((n - 1)n / 2) = O(n^2)$ :
```src
[file]{time_complexity}-[class]{}-[func]{bubble_sort}
@@ -1045,9 +1045,9 @@ $$
### Экспоненциальная сложность $O(2^n)$
Типичный пример экспоненциального роста в биологии - "деление клеток": в начальном состоянии есть 1 клетка, после одного деления их становится 2, после двух делений - 4 и так далее; после $n$ раундов деления клеток становится $2^n$ .
Типичный пример экспоненциального роста в биологии - деление клеток: в начальном состоянии есть одна клетка, после одного деления их становится 2, после двух делений - 4 и так далее; после $n$ раундов деления клеток становится $2^n$ .
На рисунке ниже и в следующем коде моделируется процесс деления клеток; временная сложность равна $O(2^n)$ . Обрати внимание, что входное значение $n$ обозначает число раундов деления, а возвращаемое значение `count` обозначает общее число делений.
На рисунке ниже и в следующем коде моделируется процесс деления клеток; временная сложность равна $O(2^n)$ . Здесь входное значение $n$ обозначает число раундов деления, а возвращаемое значение `count` обозначает общее число делений.
```src
[file]{time_complexity}-[class]{}-[func]{exponential}
@@ -1061,13 +1061,13 @@ $$
[file]{time_complexity}-[class]{}-[func]{exp_recur}
```
Экспоненциальный рост происходит очень быстро и часто встречается в переборных методах (грубая сила, backtracking и т.д.). Для задач большого масштаба экспоненциальная сложность неприемлема, и обычно приходится применять динамическое программирование, жадные алгоритмы и другие подходы.
Экспоненциальный рост происходит очень быстро и часто встречается в переборных методах, грубой силе, поиске с возвратом и тому подобных подходах. Для задач большого масштаба экспоненциальная сложность неприемлема, и обычно приходится применять динамическое программирование, жадные алгоритмы и другие стратегии.
### Логарифмическая сложность $O(\log n)$
В противоположность экспоненциальной, логарифмическая сложность описывает ситуацию "каждый раунд уменьшение вдвое". Пусть размер входных данных равен $n$ ; так как на каждом шаге размер уменьшается вдвое, число итераций равно $\log_2 n$ , то есть является обратной функцией к $2^n$ .
В противоположность экспоненциальной, логарифмическая сложность описывает ситуацию, когда **в каждом раунде размер задачи уменьшается вдвое**. Пусть размер входных данных равен $n$ ; так как на каждом шаге размер уменьшается вдвое, число итераций равно $\log_2 n$ , то есть является обратной функцией к $2^n$ .
На рисунке ниже и в следующем коде моделируется процесс "каждый раунд уменьшение вдвое"; временная сложность равна $O(\log_2 n)$ и кратко записывается как $O(\log n)$ :
На рисунке ниже и в следующем коде моделируется процесс, в котором **в каждом раунде размер задачи уменьшается вдвое**; временная сложность равна $O(\log_2 n)$ и кратко записывается как $O(\log n)$ :
```src
[file]{time_complexity}-[class]{}-[func]{logarithmic}
@@ -1081,7 +1081,7 @@ $$
[file]{time_complexity}-[class]{}-[func]{log_recur}
```
Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии "разделяй и властвуй", и отражает идеи "разделить одно на много" и "упростить сложное". Она растет медленно и является идеальной временной сложностью, уступающей только постоянной.
Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии "разделяй и властвуй", и отражает идеи разбиения на части и упрощения сложной задачи. Она растет медленно и считается одной из самых желательных временных сложностей после константной.
!!! tip "Каково основание у $O(\log n)$ ?"
@@ -1095,7 +1095,7 @@ $$
### Линейно-логарифмическая сложность $O(n \log n)$
Линейно-логарифмическая сложность часто встречается во вложенных циклах, когда временная сложность двух уровней соответственно равна $O(\log n)$ и $O(n)$ . Соответствующий код выглядит следующим образом:
Линейно-логарифмическая сложность часто встречается в рекурсивных разбиениях, где временная сложность одного измерения равна $O(\log n)$ , а другого - $O(n)$ . Соответствующий код выглядит следующим образом:
```src
[file]{time_complexity}-[class]{}-[func]{linear_log_recur}
@@ -1109,13 +1109,13 @@ $$
### Факториальная сложность $O(n!)$
Факториальная сложность соответствует математической задаче "все перестановки". Если даны $n$ попарно различных элементов, то число всех возможных перестановок равно:
Факториальная сложность соответствует математической задаче полной перестановки. Если даны $n$ попарно различных элементов, то число всех возможных перестановок равно:
$$
n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1
$$
Факториал обычно реализуют через рекурсию. Как показано на рисунке ниже и в следующем коде, на первом уровне происходит ветвление на $n$ подзадач, на втором - на $n - 1$ и так далее, пока на $n$ -м уровне ветвление не прекращается:
Факториал обычно реализуют через рекурсию. Как показано на рисунке ниже и в следующем коде, на первом уровне происходит ветвление на $n$ подзадач, на втором - на $n - 1$ и так далее, пока на $n$-м уровне ветвление не прекращается:
```src
[file]{time_complexity}-[class]{}-[func]{factorial_recur}
@@ -1123,7 +1123,7 @@ $$
![Факториальная временная сложность](time_complexity.assets/time_complexity_factorial.png)
Обрати внимание: поскольку при $n \geq 4$ всегда выполняется $n! > 2^n$ , факториальная сложность растет еще быстрее, чем экспоненциальная, и при больших $n$ также неприемлема.
Следует отметить, что поскольку при $n \geq 4$ всегда выполняется $n! > 2^n$ , факториальная сложность растет еще быстрее, чем экспоненциальная, и при больших $n$ становится неприемлемой.
## Худшая, лучшая и средняя временная сложность
@@ -1132,19 +1132,19 @@ $$
- Когда `nums = [?, ?, ..., 1]` , то есть когда последний элемент равен $1$ , нужно полностью пройти по массиву, **что дает худшую временную сложность $O(n)$** .
- Когда `nums = [1, ?, ?, ...]` , то есть когда первый элемент равен $1$ , независимо от длины массива продолжать обход не нужно, **что дает лучшую временную сложность $\Omega(1)$** .
"Худшая временная сложность" соответствует асимптотической верхней границе функции и обозначается нотацией Big $O$ . Соответственно, "лучшая временная сложность" соответствует асимптотической нижней границе функции и обозначается символом $\Omega$ :
Худшая временная сложность соответствует асимптотической верхней границе функции и обозначается нотацией Big $O$ . Соответственно, лучшая временная сложность соответствует асимптотической нижней границе функции и обозначается символом $\Omega$ :
```src
[file]{worst_best_time_complexity}-[class]{}-[func]{find_one}
```
Стоит отметить, что на практике мы редко используем лучшую временную сложность, поскольку обычно она достигается лишь с очень малой вероятностью и может вводить в заблуждение. **Худшая временная сложность гораздо практичнее, потому что задает безопасную оценку эффективности** и позволяет уверенно использовать алгоритм.
Стоит отметить, что на практике лучшая временная сложность используется редко, поскольку обычно она достигается лишь с очень малой вероятностью и может вводить в заблуждение. **Худшая временная сложность гораздо практичнее, потому что задает безопасную оценку эффективности** и позволяет уверенно использовать алгоритм.
Из приведенного выше примера видно, что худшая и лучшая временные сложности возникают только при "особых распределениях данных"; вероятность таких случаев может быть низкой, и они не всегда реально отражают эффективность алгоритма. Напротив, **средняя временная сложность способна показать эффективность алгоритма на случайных входных данных** и обозначается символом $\Theta$ .
Из приведенного выше примера видно, что худшая и лучшая временные сложности возникают только при особых распределениях данных; вероятность таких случаев может быть низкой, и они не всегда реально отражают эффективность алгоритма. Напротив, **средняя временная сложность способна показать эффективность алгоритма на случайных входных данных** и обозначается символом $\Theta$ .
Для некоторых алгоритмов мы можем относительно просто вывести средний случай при случайном распределении данных. Например, в приведенном выше примере входной массив перемешан, а значит вероятность появления элемента $1$ на любом индексе одинакова; следовательно, среднее число итераций алгоритма равно половине длины массива, то есть $n / 2$ , а средняя временная сложность равна $\Theta(n / 2) = \Theta(n)$ .
Для некоторых алгоритмов можно относительно просто вывести средний случай при случайном распределении данных. Например, в приведенном выше примере входной массив перемешан, а вероятность появления элемента $1$ на любом индексе одинакова; следовательно, среднее число итераций алгоритма равно половине длины массива, то есть $n / 2$ , а средняя временная сложность равна $\Theta(n / 2) = \Theta(n)$ .
Но для более сложных алгоритмов вычислить среднюю временную сложность часто непросто, потому что трудно проанализировать полное математическое ожидание на заданном распределении данных. В таких случаях мы обычно используем худшую временную сложность как критерий оценки эффективности алгоритма.
Однако для более сложных алгоритмов вычислить среднюю временную сложность часто непросто, потому что трудно проанализировать полное математическое ожидание на заданном распределении данных. В таких случаях обычно используют худшую временную сложность как критерий оценки эффективности алгоритма.
!!! question "Почему символ $\Theta$ встречается так редко?"