Files
hello-algo/ru/chapters/chapter_02.md
2026-03-25 16:54:42 +08:00

1082 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Анализ сложности
![](ru/docs/assets/media/image68.jpeg){width="3.71875656167979in" height="4.8125in"}
> 2.1. Оценка эффективности алгоритмов ❖ **35**
#### Оценка эффективности алгоритмов
> В процессе разработки алгоритмов мы стремимся к достижению следующих целей:
1) **найти решение задачи**: алгоритм должен надежно находить правиль- ное решение задачи в заданных пределах входных данных;
2) **найти оптимальное решение**: для одной и той же задачи может суще- ствовать несколько решений, и мы стремимся найти максимально эф- фективный алгоритм.
> Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта:
1) **временную эффективность**: продолжительность выполнения алго- ритма;
2) **пространственную эффективность**: объем памяти, занимаемой алго- ритмом.
> В двух словах, **наша цель -- разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процес- сом их разработки и оптимизации.
>
> Методы оценки эффективности делятся на два типа: практическое тестиро- вание и теоретическую оценку.
1. **Практическое тестирование**
> Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же зада- чу, и необходимо сравнить их эффективность. Самый прямой метод -- это за- пустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения.
>
> С одной стороны, **сложно исключить влияние факторов тестовой сре- ды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных процессорах. Если алгоритм интен- сивно использует память, его производительность будет выше на высоко- производительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, и потребуется тестирова- ние на различных платформах для получения средней эффективности, что крайне затруднительно.
>
> С другой стороны, **проведение полного тестирования требует значи- тельных ресурсов**. С изменением объема входных данных алгоритмы демон- стрируют разную эффективность. Например, при небольшом объеме данных алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме данных результат может быть противоположным. Следовательно, для полу- чения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов.
### Теоретическая оценка
> Из-за значительных ограничений практического тестирования можно рас- смотреть возможность оценки эффективности алгоритмов только с помощью математических расчетов. Этот метод называется анализом асимптотической сложности или просто анализом сложности.
>
> Анализ сложности позволяет отразить зависимость между ресурсами вре- мени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и простран- ства, необходимых для выполнения алгоритма, по мере увеличения раз- мера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента.
1. Ресурсы времени и пространства соответствуют временной сложности и пространственной сложности.
2. «По мере увеличения размера входных данных» означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных.
3. Тенденция роста времени и пространства указывает, что анализ слож- ности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста.
> **Анализ сложности преодолевает недостатки метода практического те- стирования**, что выражается в следующих аспектах:
1) он не требует фактического выполнения кода, что делает его более эко- логичным и энергосберегающим;
2) он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения;
3) он может продемонстрировать эффективность алгоритма при различ- ных объемах данных, особенно при больших объемах.
> Анализ сложности предоставляет нам мерило оценки эффективности ал- горитмов, позволяя измерять время и ресурсы, необходимые для выполне- ния конкретного алгоритма, а также сравнивать эффективность различных алгоритмов.
>
> Сложность -- это математическое понятие, которое новичкам может по- казаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуж- дая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти.
>
> Таким образом, перед погружением в изучение структур данных и алго- ритмов рекомендуется получить базовое представление об анализе слож- ности, чтобы иметь возможность выполнять хотя бы базовую оценку их эф- фективности.
#### итерация и рекурсия
> В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к об- суждению временной и пространственной сложности, рассмотрим, как реа- лизовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию.
### Итерации
> *Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение опреде- ленного участка кода, пока выполняется определенное условие.
##### Цикл for
> *Цикл* for -- одна из наиболее распространенных форм итерации, которая под- ходит для использования, когда количество итераций известно заранее.
>
> Следующая функция реализует суммирование 1 + 2 + \... + *n* с использо- ванием цикла for, результат суммирования сохраняется в переменной res. Следует отметить, что в Python диапазон range(a, b) соответствует лево- му закрытому, правому открытому интервалу, т. е. перебираются значения *a*, *a* + 1, \... , *b* 1:
>
> \# === File: iteration.py === def for_loop(n: int) -\> int:
>
> \"\"\"Цикл for.\"\"\"
>
> res = 0
>
> \# Цикл суммирования 1, 2, \..., n-1, n. for i in range(1, n + 1):
>
> res += i return res
>
> Количество операций этой функции суммирования пропорционально раз- меру входных данных *n*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в сле- дующем разделе.
>
> ![](ru/docs/assets/media/image70.png)
##### Цикл while
> **Рис. 2.1.** Блок-схема функции суммирования
>
> Подобно циклу for, цикл while также представляет собой метод реализации итерации. В цикле while программа перед каждой итерацией проверяет ус- ловие: если условие истинно, то выполнение продолжается, иначе цикл за- вершается.
>
> Ниже приведен пример реализации суммирования 1 + 2 + \... + *n* с использо- ванием цикла while:
>
> \# === File: iteration.py === def while_loop(n: int) -\> int:
>
> \"\"\"Цикл while.\"\"\"
>
> res = 0
>
> i = 1 \# Инициализация условной переменной. \# Цикл для суммирования 1, 2, \..., n-1, n. while i \<= n:
>
> res += i
>
> i += 1 \# Обновление значения условной переменной. return res
>
> **Цикл** while **обладает большей степенью свободы по сравнению с ци- клом** for. В цикле while можно свободно управлять инициализацией и обнов- лением условной переменной.
>
> Например, в следующем коде условная переменная *i* обновляется дваж- ды на каждой итерации, что затруднительно сделать с использованием цикла for:
>
> \# === File: iteration.py ===
>
> def while_loop_ii(n: int) -\> int:
>
> \"\"\"Цикл while (двойное обновление).\"\"\" res = 0
>
> i = 1 \# Инициализация условной переменной. \# Цикл для суммирования 1, 4, 10, \... while i \<= n:
>
> res += i
>
> \# Обновление значения условной переменной. i += 1
>
> i \*= 2 return res
>
> В целом **код с использованием цикла** for **более компактный**, **а цикл** while **более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи.
##### Вложенные циклы
> Внутрь одной циклической структуры можно вложить другую, например используя два цикла for:
>
> \# === File: iteration.py ===
>
> def nested_for_loop(n: int) -\> str: \"\"\"Двойной цикл for.\"\"\"
>
> res = \"\"
>
> \# Цикл i = 1, 2, \..., n-1, n.
>
> for i in range(1, n + 1):
>
> \# Цикл j = 1, 2, \..., n-1, n for j in range(1, n + 1):
>
> res += f\"({i}, {j}), \" return res
>
> В этом случае количество выполненных действий пропорционально *n*2, или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости.
>
> Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической за- висимости, зависимости четвертой степени и т. д.
>
> ![](ru/docs/assets/media/image72.png)
### Рекурсия
> **Рис. 2.2.** Блок-схема вложенного цикла
>
> *Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа.
1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения.
2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя ре- зультаты каждого уровня.
> С точки зрения реализации рекурсивный код включает три основных элемента.
1. **Условие завершения**: используется для определения момента перехода от вызова к возврату.
2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами.
3. **Возврат результата**: соответствует возврату, возвращает результат те- кущего уровня рекурсии на предыдущий уровень.
> Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*.
>
> \# === File: recursion.py ===
>
> def recur(n: int) -\> int: \"\"\" Рекурсия.\"\"\"
>
> \# Условие завершения. if n == 1:
>
> return 1
>
> \# Вызов: рекурсивный вызов. res = recur(n - 1)
>
> \# Возврат: возврат результата. return n + res
![](ru/docs/assets/media/image74.png)
> **Рис. 2.3.** Рекурсивный вызов функции суммирования
>
> Хотя с точки зрения вычислений итерация и рекурсия могут давать оди- наковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач.
- **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи.
- **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная за- дача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно).
> Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*.
- **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы полу- чить итоговое значение *f*(*n*).
- **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) =
> *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1.
##### Стек вызовов
> Каждый раз, когда рекурсивная функция вызывает саму себя, система выделя- ет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия.
1. Контекстные данные функции хранятся в области памяти, называемой про- странством стекового кадра, и освобождаются только после возврата функ- ции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**.
2. Рекурсивный вызов функции создает дополнительные накладные расходы.
###### Поэтому рекурсия обычно менее эффективна по времени, чем цикл.
> До срабатывания условия завершения одновременно существует *n* невоз- вращенных рекурсивных функций, как показано на рис. 2.4. Число *n* называет- ся глубиной рекурсии.
![](ru/docs/assets/media/image76.jpeg)
> **Рис. 2.4.** Глубина рекурсивного вызова
>
> На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека.
##### Хвостовая рекурсия
> Интересно, что если рекурсивный вызов происходит на последнем шаге перед возвратом функции, то компилятор или интерпретатор может оптимизиро- вать этот вызов, сделав его по эффективности использования памяти сопоста- вимым с итерацией. Это называется хвостовой рекурсией.
- **Обычная рекурсия**: когда функция возвращается на предыдущий уро- вень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова.
- **Хвостовая рекурсия**: рекурсивный вызов является последней операци- ей перед возвратом функции, что означает, что после возврата на преды- дущий уровень не требуется выполнять другие операции, поэтому систе- ме не нужно сохранять контекст предыдущей функции.
> В качестве примера вычисления суммы 1 + 2 + \... + *n* можно установить пере- менную результата res в качестве параметра функции, чтобы реализовать хво- стовую рекурсию:
>
> \# === File: recursion.py === def tail_recur(n, res):
>
> \"\"\" Хвостовая рекурсия. \"\"\"
>
> \# Условие завершения. if n == 0:
>
> return res
>
> \# Хвостовой рекурсивный вызов. return tail_recur(n - 1, res + n)
>
> Процесс выполнения хвостовой рекурсии показан на рис. 2.5. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения опе- рации суммирования у них различается.
- **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить опера- цию суммирования.
- **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, процесс возврата требует только последовательного возврата.
![](ru/docs/assets/media/image78.jpeg)
> **Рис. 2.5.** Процесс выполнения хвостовой рекурсии
##### Дерево рекурсии
> При решении задач, связанных с алгоритмами типа «разделяй и властвуй», ре- курсия зачастую оказывается более интуитивной и читабельной, чем итера- ция. Рассмотрим в качестве примера последовательность Фибоначчи.
>
> Обозначив *n*-й член последовательности Фибоначчи как *f*(*n*), можно сфор- мулировать два утверждения.
1. Первые два числа последовательности: *f*(1) = 0 и *f*(2) = 1.
2. Каждое число последовательности является суммой двух предыдущих чисел, т. е. *f*(*n*) = *f*(*n* 1) + *f*(*n* 2).
> Используя рекурсивные вызовы в соответствии с рекуррентным соотноше- нием и принимая первые два числа за условия остановки, можно написать ре- курсивный код. Вызов fib(n) позволит получить *n*-й член последовательности Фибоначчи.
>
> \# === File: recursion.py === def fib(n: int) -\> int:
>
> \"\"\" Последовательность Фибоначчи: рекурсия. \"\"\" \# Условия остановки f(1) = 0, f(2) = 1
>
> if n == 1 or n == 2: return n - 1
>
> \# Рекурсивный вызов f(n) = f(n-1) + f(n-2). res = fib(n - 1) + fib(n - 2)
>
> \# Возврат результата f(n). return res
>
> Проанализировав приведенный код, можно заметить, что внутри функ- ции осуществляется рекурсивный вызов двух функций, т. е. из одного вызо- ва образуются два ветвления. При последующем выполнении рекурсивных вызовов в итоге образуется рекурсивное дерево глубиной *n*, как показано на рис. 2.6.
>
> По своей сути рекурсия отражает парадигму мышления «разбиение зада- чи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной.
>
> С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое программирование, прямо или косвенно используют этот подход.
>
> С точки зрения **структур данных** рекурсия естественно подходит для реше- ния задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй».
>
> ![](ru/docs/assets/media/image79.jpeg){width="5.095693350831146in" height="2.08in"}
>
> **Рис. 2.6.** Рекурсивное дерево последовательности Фибоначчи
### Сравнение
> Подводя итог, можно сказать, что итерация и рекурсия различаются по реали- зации, производительности и применимости, как показано в табл. 2.1.
>
> **Таблица 2.1.** Сравнение итерации и рекурсии
+-----------------------+---------------------------------+-----------------------------------------+
| | > **Итерация** | > **Рекурсия** |
+=======================+=================================+=========================================+
| > Способ реализации | > Циклическая структура | > Функция вызывает саму себя |
+-----------------------+---------------------------------+-----------------------------------------+
| > Временная | > Обычно высокая | > Каждый вызов функции создает |
+-----------------------+---------------------------------+-----------------------------------------+
| > эффективность | > эффективность, нет | > затраты |
+-----------------------+---------------------------------+-----------------------------------------+
| | > затрат на вызов функции | |
+-----------------------+---------------------------------+-----------------------------------------+
| > Использование | > Обычно используется | > Накопление вызовов функции может |
+-----------------------+---------------------------------+-----------------------------------------+
| > памяти | > фиксированный объем | > использовать значительное количество |
+-----------------------+---------------------------------+-----------------------------------------+
| | > памяти | > пространства стека |
+-----------------------+---------------------------------+-----------------------------------------+
| > Сфера использования | > Подходит для простых | > Подходит для разбиения на подзадачи; |
+-----------------------+---------------------------------+-----------------------------------------+
| | > циклических задач, код | > для структур деревья, графы; алгорит- |
+-----------------------+---------------------------------+-----------------------------------------+
| | > интуитивно понятен | > мов «разделяй и властвуй», возврат |
+-----------------------+---------------------------------+-----------------------------------------+
| | > [и]{.smallcaps} хорошо читаем | > и т. д.; структура кода проста и ясна |
+-----------------------+---------------------------------+-----------------------------------------+
> Какова же внутренняя связь между итерацией и рекурсией? В рассмотрен- ном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу сте- ка «первым пришел -- последним вышел»**.
>
> Фактически такие термины рекурсии, как «вызов стека» и «пространство стекового кадра», уже намекают на тесную связь между рекурсией и стеком.
1. **Вызов**: когда вызывается функция, система выделяет новый стековый кадр в вызове стека» для хранения локальных переменных функции, па- раметров, адреса возврата и других данных.
2. **Возврат**: когда функция завершает выполнение и возвращает результат, соответствующий стековый кадр удаляется из вызова стека, восстанав- ливая среду выполнения предыдущей функции.
> Таким образом, **можно использовать явный стек для моделирования по- ведения вызова стека**, чтобы преобразовать рекурсию в итеративную форму.
>
> \# === File: recursion.py ===
>
> def for_loop_recur(n: int) -\> int:
>
> \"\"\" Использование итерации для моделирования рекурсии. \"\"\"
>
> \# Использование явного стека для моделирования системного вызова стека. stack = \[\]
>
> res = 0
>
> \# Вызов: рекурсивный вызов. for i in range(n, 0, -1):
>
> \# Моделирование вызова через операцию добавления в стек. stack.append(i)
>
> \# Возврат: возвращение результата. while stack:
>
> \# Моделирование возврата через операцию удаления из стека. res += stack.pop()
>
> \# res = 1+2+3+\...+n
>
> return res
>
> Проанализировав приведенный код, можно заметить, что после преобразо- вания рекурсии в итерацию код становится более сложным. Хотя в большин- стве случаев итерацию и рекурсию можно взаимно преобразовать, это не всег- да оправдано по следующим двум причинам:
1) преобразованный код может стать более трудным для понимания, с худ- шей читаемостью;
2) для некоторых сложных задач моделирование поведения системного вызова стека может быть крайне сложным.
> В общем, выбор между итерацией и рекурсией зависит от природы конкрет- ной задачи. В программной практике крайне важно взвешивать преимуще- ства и недостатки обоих подходов и выбирать наиболее подходящий метод в зависимости от ситуации.
#### временная сложность
> *Время выполнения* является наглядным и точным показателем эффективности алгоритма. Что же нам нужно сделать, чтобы точно оценить время выполне- ния кода?
1. **Определить платформу выполнения**, включая аппаратную конфи- гурацию, язык программирования, системную среду и т. д., так как эти факторы влияют на эффективность выполнения кода.
2. **Оценить время выполнения различных вычислительных опера- ций** например, операция сложения + требует 1 нс, операция умноже- ния \* требует 10 нс, операция печати print() требует 5 нс и т. д.
3. **Подсчитать все вычислительные операции в коде** и суммировать время выполнения всех операций, чтобы получить общее время выпол- нения.
> Например, в следующем коде размер входных данных равен *n*:
>
> \# На некоторой платформе выполнения. def algorithm(n: int):
>
> a = 2 \# 1 нс.
>
> a = a + 1 \# 1 нс. a = a \* 2 \# 10 нс. \# Цикл n итераций.
>
> for \_ in range(n): \# 1 нс. print(0) \# 5 нс.
>
> Согласно вышеописанному методу можно определить, что время выполне- ния алгоритма равно (6*n* + 12) нс:
>
> 1 + 1 + 10 + (1 + 5) × *n* = 6*n* + 12.
>
> Однако на практике подсчет времени выполнения алгоритма не является ни разумным, ни реалистичным. Во-первых, мы не хотим связывать оценочное время с конкретной платформой выполнения, так как алгоритм должен рабо- тать на различных платформах. Во-вторых, очень трудно узнать время выпол- нения каждой операции, и это значительно усложняет процесс оценки.
### Тенденция роста времени
> Анализ временной сложности не оценивает время выполнения алгоритма, **а исследует тенденцию роста времени выполнения алгоритма по мере увеличения объема данных**.
>
> Понятие «тенденция роста времени» довольно абстрактно, и для его лучше- го понимания рассмотрим пример. Предположим, что размер входных данных равен *n*, и имеется три алгоритма A, B и C:
>
> \# Временная сложность алгоритма 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)
>
> На рис. 2.7 изображена схема временной сложности функций этих трех ал- горитмов.
- Алгоритм A содержит только одну операцию печати, и время выполне- ния алгоритма не увеличивается с ростом *n*. Временная сложность этого алгоритма называется константной.
- В алгоритме B операция печати выполняется в цикле *n* раз, и время вы- полнения алгоритма увеличивается линейно с ростом *n*. Временная сложность этого алгоритма называется линейной.
- В алгоритме C операция печати выполняется в цикле 1 000 000 раз, и, хотя время выполнения очень долгое, оно не зависит от размера входных данных *n*. Поэтому временная сложность C такая же, как у A, и остается константной.
![](ru/docs/assets/media/image81.jpeg)
> **Рис. 2.7.** Тенденция роста времени для алгоритмов A, B и C
>
> Какие особенности имеет анализ временной сложности по сравнению с пря- мым подсчетом времени выполнения алгоритма?
- **Временная сложность позволяет эффективно оценить эффектив- ность алгоритма**. Например, время выполнения алгоритма B увеличи- вается линейно, и при *n* \> 1 он медленнее алгоритма A, а при *n* \> 1 000 000 медленнее алгоритма C. На самом деле, если размер входных данных *n* достаточно велик, алгоритм с константной сложностью всегда будет луч- ше, чем алгоритм с линейной сложностью, и это и есть суть тенденции роста времени.
- **Метод вычисления временной сложности проще**. Очевидно, что платформа выполнения и типы вычислительных операций не связаны с тенденцией роста времени выполнения алгоритма. Поэтому в анали- зе временной сложности можно просто считать, что время выполнения всех вычислительных операций одинаково и равно единичному време- ни, что позволяет упростить статистику времени выполнения вычисли- тельных операций до статистики количества вычислительных операций, значительно снижая сложность оценки.
- **Однако временная сложность имеет определенные ограничения**. Например, хотя временная сложность алгоритмов A и C одинакова, их фак- тическое время выполнения значительно отличается. Аналогично, хотя временная сложность алгоритма B выше, чем у C, при малых значениях *n* алгоритм B явно лучше C. В таких случаях часто трудно оценить эффек- тивность алгоритма только по временной сложности. Тем не менее, не- смотря на эти проблемы, анализ сложности остается наиболее эффектив- ным и распространенным методом оценки эффективности алгоритмов.
### Асимптотическая верхняя граница функции
> Пусть дан входной размер *n* для функции:
>
> 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.
>
> Пусть *T*(*n*) -- это количество операций алгоритма, являющееся функцией от размера входных данных *n*. Тогда количество операций для вышеуказанной функции равно:
>
> *T*(*n*) = 3 + 2*n*.
>
> *T*(*n*) является линейной функцией, что указывает на линейную тенденцию роста времени выполнения, следовательно, у алгоритма временная сложность линейного порядка.
>
> Линейная временная сложность обозначается как *O*(*n*), этот математиче- ский символ называется «О» большое и представляет асимптотическую верх- нюю границу функции *T*(*n*).
>
> Анализ временной сложности, по сути, является вычислением асимптоти- ческой верхней границы количества операций *T*(*n*), которая имеет четкое ма- тематическое определение.
>
> Вычисление асимптотической верхней границы заключается в нахождении функции *f*(*n*) такой, что при стремлении *n* к бесконечности *T*(*n*) и *f*(*n*) нахо- дятся на одном уровне роста, отличаясь лишь на множитель константы *c*, как показано на рис. 2.8.
>
> ![](ru/docs/assets/media/image83.jpeg)
>
> **Рис. 2.8.** Асимптотическая верхняя граница функции
### Методы вычисления
> Асимптотическая верхняя граница является абстрактным математическим понятием, и, если вы не полностью понимаете его, не стоит беспокоиться. Можно сначала освоить методику вывода, и в процессе практики постепенно осознать его математическое значение.
>
> Согласно определению, если найти функцию *f*(*n*), можно получить времен- ную сложность *O*(*f*(*n*)). Как же определить асимптотическую верхнюю грани- цу *f*(*n*)? В общем случае это делается в два этапа: сначала подсчитывается ко- личество операций, затем определяется асимптотическая верхняя граница.
##### Подсчет количества операций
> Для кода это делается путем построчного подсчета сверху вниз. Однако, по- скольку константа *c* в выражении *c* × *f*(*n*) может принимать любое значение, **все коэффициенты и константы в** *T*(*n*) **можно игнорировать**. На основе этого принципа можно сформулировать следующие упрощенные приемы подсчета:
1) **игнорирование констант в** *T*(*n*). Они не зависят от *n* и поэтому не вли- яют на временную сложность;
2) **опускание всех коэффициентов**. Например, циклы из 2*n* итераций, 5*n*
> \+ 1 итераций и т. д. можно упростить до *n* итераций, поскольку коэффи- циенты перед *n* не влияют на временную сложность;
3) **при вложенных циклах используется умножение**. Общее количество операций равно произведению количества операций внешнего и вну- треннего циклов, при этом для каждого уровня цикла можно применять приемы 1 и 2.
> Следующую функцию можно использовать для подсчета количества опера- ций с помощью вышеуказанных приемов:
>
> def algorithm(n: int):
>
> a = 1 \# +0 (прием 1).
>
> a = a + n \# +0 (прием 1). \# +n (прием 2).
>
> for i in range(5 \* n + 1): print(0)
>
> \# +n\*n (прием 3).
>
> for i in range(2 \* n):
>
> for j in range(n + 1): print(0)
>
> Следующая формула демонстрирует результаты подсчета до и после примене- ния вышеуказанных приемов, временная сложность в обоих случаях равна *O*(*n*²).
>
> *T*(*n*) = 2*n*(*n* + 1) + (5*n* + 1) + 2 Полный подсчет (-.-\|\|\|)
>
> = 2*n*2 + 7*n* + 3
>
> *T*(*n*) = *n*2 + *n* Упрощенный подсчет (o.O)
##### Определение асимптотической верхней границы
> Временная сложность определяется старшей степенью в *T*(*n*). Это связано с тем, что при стремлении *n* к бесконечности старшая степень будет играть до- минирующую роль, а влиянием других членов можно пренебречь.
>
> В табл. 2.2 приведены некоторые гипертрофированные примеры, иллю- стрирующие вывод о том, что «коэффициенты не могут изменить поря- док». Когда *n* стремится к бесконечности, эти константы становятся несу- щественными.
>
> **Таблица 2.2.** Временная сложность для различных количеств операций
>
> **Количество операций** *T***(***n***) Временная сложность** *O***(***f***(***n***))**
100 000 *O*(1)
3*n* + 2 *O*(*n*)
2*n*2 + 3*n* + 2 *O*(*n*2)
*n*3 + 10000*n*2 *O*(*n*3)
2*n* + 10000*n*10000 *O*(2*n*)
### Основные типы
> Пусть размер входных данных равен *n*, основные типы временной сложности показаны на рис. 2.9 (в порядке от низшего к высшему).
>
> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n* log *n*) \< *O*(*n*2) \< *O*(2*n*) \< *O*(*n*!)
>
> Константная \< Логарифмическая \< Линейная \< Линейно- логарифмическая \< Квадратичная \< Экспоненциальная \< Факториальная
>
> **От низшего к высшему,**
>
> **от лучшего к худшему**
##### Константная сложность *O*(1)
> Количество операций *константной сложности* не зависит от размера входных данных *n*, т. е. не изменяется по мере роста *n*.
>
> В следующей функции временная сложность не зависит от *n* и равна *O*(1), несмотря на то что количество операций size может быть очень большим.
>
> \# === File: time_complexity.py === def constant(n: int) -\> int:
>
> \"\"\" Константный порядок.\"\"\"
>
> count = 0
>
> size = 100000
>
> for \_ in range(size): count += 1
>
> return count
##### Линейная сложность *O*(*n*)
> Количество операций *линейной сложности* растет линейно относительно раз- мера входных данных *n*. Линейная сложность обычно встречается в однопро- ходных циклах.
>
> \# === File: time_complexity.py === def linear(n: int) -\> int:
>
> \"\"\" Линейный порядок.\"\"\" count = 0
>
> for \_ in range(n): count += 1
>
> return count
>
> Временная сложность операций, таких как обход массива и обход связного списка, равна *O*(*n*), где *n* -- длина массива или списка.
>
> \# === File: time_complexity.py ===
>
> def array_traversal(nums: list\[int\]) -\> int: \"\"\" Линейный порядок (обход массива).\"\"\" count = 0
>
> \# Количество циклов пропорционально длине массива. for num in nums:
>
> count += 1 return count
>
> Следует отметить, что **размер входных данных *n* необходимо опреде- лять в зависимости от типа входных данных**. Например, в первом при- мере переменная *n* обозначает размер входных данных; во втором примере размер данных определяется длиной массива *n*.
##### Квадратичная сложность *O*(*n*2)
> Количество операций *квадратичной сложности* растет с квадратом размера входных данных *n*. Квадратичная сложность обычно возникает в случае вложен- ных циклов, когда временная сложность как внешнего, так и внутреннего ци- клов равна *O*(*n*) и, следовательно, общая временная сложность составляет *O*(n2):
>
> \# === File: time_complexity.py === def quadratic(n: int) -\> int:
>
> \"\"\" Квадратичная сложность.\"\"\" count = 0
>
> \# Количество операций в циклах пропорционально квадрату размера данных n. for i in range(n):
>
> for j in range(n): count += 1
>
> return count
>
> На рис. 2.10 приведено сравнение трех видов временной сложности: кон- стантной, линейной и квадратичной.
![](ru/docs/assets/media/image87.jpeg)
> **Рис. 2.10.** Временная сложность константного, линейного и квадратичного порядка
>
> В качестве примера рассмотрим пузырьковую сортировку: внешний цикл выполняется *n* -- 1 раз, внутренний цикл выполняется *n* -- 1, *n* -- 2, \..., 2, 1 раз, в среднем *n*/2 раз, поэтому временная сложность составляет O((*n* 1)*n*/2) = O(*n*2).
>
> \# === File: time_complexity.py ===
>
> def bubble_sort(nums: list\[int\]) -\> int:
>
> \"\"\" Квадратичная сложность (пузырьковая сортировка).\"\"\" count = 0 \# Счетчик.
>
> \# Внешний цикл: неотсортированный диапазон \[0, i\]. for i in range(len(nums) - 1, 0, -1):
>
> \# Внутренний цикл: перестановка максимального элемента
>
> \# неотсортированного диапазона \[0, i\] и элемента на правом конце. for j in range(i):
>
> if nums\[j\] \> nums\[j + 1\]:
>
> \# Перестановка nums\[j\] и nums\[j + 1\]. tmp: int = nums\[j\]
>
> nums\[j\] = nums\[j + 1\] nums\[j + 1\] = tmp
>
> count += 3 \# Перестановка элементов включает 3 элементарные операции return count
##### Экспоненциальная сложность *O*(2*n*)
> Деление клеток в биологии является типичным примером *экспоненциально- го роста*: исходное состояние -- 1 клетка, после одного цикла деления их ста- новится 2, после двух циклов -- 4 и т. д. После *n* циклов деления получается 2*n* клеток.
>
> В следующем коде моделируется процесс деления клеток, временная слож- ность этого алгоритма составляет *O*(2*n*), см. рис. 2.11. Обратите внимание, что входное значение *n* обозначает количество циклов деления, а возвращаемое значение count обозначает общее количество делений.
>
> \# === File: time_complexity.py === def exponential(n: int) -\> int:
>
> \"\"\" Экспоненциальная сложность (реализация c циклом).\"\"\"
>
> count = 0
>
> base = 1
>
> \# Каждая клетка делится на две в каждом цикле, формируя последовательность 1, 2, 4, 8, \..., 2\^(n-1).
>
> for \_ in range(n):
>
> for \_ in range(base): count += 1
>
> base \*= 2
>
> \# count = 1 + 2 + 4 + 8 + \...+ 2\^(n-1) = 2\^n -- 1.
>
> return count
>
> ![](ru/docs/assets/media/image89.jpeg)
>
> **Рис. 2.11.** Временная сложность экспоненциального порядка
>
> В реальных алгоритмах экспоненциальная сложность часто встречается в рекурсивных функциях. Например, в следующем коде функция рекурсивно делится на две части и останавливается после *n* циклов деления.
>
> \# === File: time_complexity.py === def exp_recur(n: int) -\> int:
>
> \"\"\" Экспоненциальная сложность (реализация с рекурсией).\"\"\" if n == 1:
>
> return 1
>
> return exp_recur(n - 1) + exp_recur(n - 1) + 1
>
> Экспоненциальный рост является очень быстрым и часто встречается в ме- тодах полного перебора (грубая сила, возврат и т. д.). Для задач с большим объ- емом данных экспоненциальная сложность неприемлема, обычно требуется использование динамического программирования или жадных алгоритмов.
##### Логарифмическая сложность *O*(log *n*)
> В отличие от экспоненциальной, *логарифмическая сложность* отражает прин- цип «каждый цикл сокращается вдвое». Пусть размер входных данных равен *n*, поскольку каждый цикл сокращается вдвое, количество циклов равно log2 *n*, т. е. обратной функции к 2n.
>
> В следующем коде моделируется принцип «каждый цикл сокращается вдвое», временная сложность которого составляет *O*(log2 *n*), или сокращенно *O*(log *n*), см. рис. 2.12.
>
> \# === File: time_complexity.py ===
>
> def logarithmic(n: int) -\> int:
>
> \"\"\" Логарифмическая сложность (реализация с циклом.)\"\"\" count = 0
>
> while n \> 1:
>
> n = n / 2 count += 1
>
> return count
>
> **Длина оставшегося массива**
>
> **Длина входного массива равна n,** на каждом уровне исключается половина
>
> **Каждый уровень содержит 1 вычислительную операцию**
>
> **Общее количество вычислительных операций log2n + 1**
>
> **Временная сложность Логарифмическая O(log n)**
>
> ![](ru/docs/assets/media/image91.png)**Рис. 2.12.** Временная сложность логарифмического порядка
>
> Подобно экспоненциальной, логарифмическая сложность также часто встречается в рекурсивных функциях. Следующий код формирует рекурсив- ное дерево высотой log2 *n*.
>
> \# === File: time_complexity.py === def log_recur(n: int) -\> int:
>
> \"\"\" Логарифмическая сложность (реализация с рекурсией).\"\"\"
>
> if n \<= 1:
>
> return 0
>
> return log_recur(n / 2) + 1
>
> Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии «разделяй и властвуй», отражая идеи разделения на части и упро- щения сложного. Она растет медленно и является идеальной временной слож- ностью после константной.
##### Линейно-логарифмическая сложность *O*(*n* log *n*)
> *Линейно-логарифмическая сложность* часто встречается во вложенных циклах, когда временные сложности двух уровней циклов составляют *O*(log *n*) и *O*(*n*) соответственно. Ниже приведен пример кода.
>
> \# === File: time_complexity.py === def linear_log_recur(n: int) -\> int:
>
> \"\"\" Линейно-логарифмическая сложность.\"\"\" if n \<= 1:
>
> return 1
>
> \# Разделение на две части, размер подзадачи уменьшается вдвое. count = linear_log_recur(n // 2) + linear_log_recur(n // 2)
>
> \# Текущая подзадача содержит n операций. for \_ in range(n):
>
> count += 1 return count
>
> На рис. 2.13 изображен способ формирования линейно-логарифмической сложности. Общее количество операций на каждом уровне двоичного дерева равно *n*, всего в дереве log2 *n* + 1 уровней, поэтому временная сложность со- ставляет *O*(*n* log *n*).
>
> Временная сложность основных алгоритмов сортировки обычно составляет *O*(*n* log *n*), например быстрой сортировки, сортировки слиянием, пирамидаль- ной сортировки и т. д.
>
> ![](ru/docs/assets/media/image93.jpeg)
>
> **Рис. 2.13.** Временная сложность линейно-логарифмического порядка
##### Факториальная сложность *O*(*n*!)
> *Факториальная сложность* соответствует математической задаче «полной пе- рестановки». Для заданных *n* различных элементов необходимо определить все возможные варианты их перестановки, количество которых вычисляется по следующей формуле:
>
> *n*! = *n* × (*n* -- 1) × (*n* -- 2) × \... × 2 × 1.
>
> Факториал обычно реализуется с использованием рекурсии. Как показано на рис. 2.14 и в коде ниже, на первом уровне происходит разбиение на *n* ча- стей, на втором -- на *n* -- 1 частей и т. д., пока на *n*-м уровне разбиение не пре- кращается.
>
> \# === File: time_complexity.py === def factorial_recur(n: int) -\> int:
>
> \"\"\" Факториал (реализация с рекурсией).\"\"\"
>
> if n == 0:
>
> return 1
>
> count = 0
>
> \# Разбиение 1 части на n частей. for \_ in range(n):
>
> count += factorial_recur(n - 1) return count
>
> ![](ru/docs/assets/media/image95.jpeg)
>
> **Рис. 2.14.** Временная сложность факториала
>
> Следует отметить, что, поскольку при *n* ≥ 4 всегда выполняется *n*! \> 2*n*, фак- ториал растет быстрее, чем экспоненциальная функция, и при больших *n* он становится неприемлемым для практического использования.
### Худшая, лучшая и средняя временная сложность
> **Эффективность алгоритма во времени часто не является фиксирован- ной**, **а зависит от распределения входных данных**. Предположим, что на вход подается массив nums длиной *n*, содержащий числа от 1 до *n*, каждое из которых встречается ровно один раз. Однако порядок элементов перемешан случайным образом, и задача заключается в том, чтобы вернуть индекс эле- мента 1. Можно сформулировать следующие факты:
1) когда nums = \[?, ?, \..., 1\], т. е. когда последний элемент равен 1, необ- ходимо полностью пройти массив, достигая **худшей временной слож- ности** *O*(*n*);
2) когда nums = \[1, ?, ?, \...\], т. е. когда первый элемент равен 1, независи- мо от длины массива не требуется продолжать проход, достигая **лучшей временной сложности** Ω(1).
> Худшая временная сложность соответствует асимптотическому верхнему пределу функции и обозначается символом *O*. Соответственно, лучшая вре- менная сложность соответствует асимптотическому нижнему пределу функ- ции и обозначается символом Ω.
>
> \# === File: worst_best_time_complexity.py === def random_numbers(n: int) -\> list\[int\]:
>
> \"\"\" Генерация массива с элементами 1, 2, \..., n в случайном порядке.\"\"\"
>
> \# Генерация массива nums =: 1, 2, 3, \..., n. nums = \[i for i in range(1, n + 1)\]
>
> \# Случайное перемешивание элементов массива. random.shuffle(nums)
>
> return nums
>
> def find_one(nums: list\[int\]) -\> int:
>
> \"\"\" Поиск индекса числа 1 в массиве nums.\"\"\" for i in range(len(nums)):
>
> \# Когда элемент 1 в начале массива, достигается лучшая временная слож- ность O(1).
>
> \# Когда элемент 1 в конце массива, достигается худшая временная слож- ность O(n).
>
> if nums\[i\] == 1: return i
>
> return -1
>
> Стоит отметить, что на практике лучшая временная сложность используется редко, так как обычно она достигается с малой вероятностью и может вводить в заблуждение. **Худшая временная сложность более полезна**, **так как она предоставляет безопасное значение эффективности**, позволяя уверенно использовать алгоритм.
>
> Из приведенного примера видно, что худшая и лучшая временные сложно- сти возникают только при особом распределении данных, вероятность появ- ления которых может быть мала, и они не отражают реальной эффективности алгоритма. **Кроме того*,* средняя временная сложность может показать эффективность алгоритма при случайных входных данных**, она обозна- чается символом Θ.
>
> Для некоторых алгоритмов можно просто вычислить средний показатель при случайном распределении данных. Например, в приведенном примере, поскольку входной массив перемешан случайным образом, вероятность появ- ления элемента 1 на любом индексе одинакова, и среднее количество циклов алгоритма составляет половину длины массива *n*/2, а средняя временная слож- ность равна Θ(*n*/2) = Θ(*n*).
>
> Однако для более сложных алгоритмов вычисление средней временной сложности часто затруднительно, так как сложно проанализировать общее ма- тематическое ожидание при распределении данных. В таких случаях обычно используется худшая временная сложность в качестве критерия оценки эф- фективности алгоритма.
#### пространственная сложность
> Пространственная сложность используется для измерения тенденции роста занимаемой алгоритмом памяти по мере увеличения объема данных. Это по- нятие очень похоже на временную сложность, только вместо времени выпол- нения рассматривается занимаемая память.
### Пространство алгоритма
> Память, используемая алгоритмом в процессе выполнения, главным образом включает следующие виды.
- **Входное пространство**: используется для хранения входных данных алгоритма.
- **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных в процессе выполнения алгоритма.
- **Выходное пространство**: используется для хранения выходных данных алгоритма.
> Как правило, пространственная сложность рассчитывается на основе вре- менного пространства и выходного пространства.
>
> Временное пространство можно, в свою очередь, разделить на три части.
- **Временные данные**: используются для сохранения различных кон- стант, переменных, объектов и т. д. в процессе выполнения алгоритма.
- **Пространство стека**: используется для сохранения контекстных дан- ных вызываемой функции. При каждом вызове функции система соз- дает фрейм стека на вершине стека, который освобождается после воз- врата функции.
- **Пространство инструкций**: используется для хранения скомпилиро- ванных инструкций программы, в реальной статистике обычно игнори- руется.
> При анализе пространственной сложности программы обычно учитывают- ся **временные данные**, **пространство стека и выходные данные**, как по- казано на рис. 2.15.
![](ru/docs/assets/media/image97.jpeg)
> **Рис. 2.15.** Пространство, используемое алгоритмом
>
> Ниже приведен пример кода.
>
> 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 \# Выходные данные.
### Методы вычисления
> Методы вычисления пространственной сложности аналогичны методам вы- числения временной сложности, только объект статистики изменяется с коли- чества операций на размер используемого пространства.
>
> В отличие от временной сложности **обычно учитывается только наихуд- шая пространственная сложность**. Это связано с тем, что память является жестким требованием, и необходимо убедиться, что для всех входных данных будет зарезервировано достаточное количество памяти.
>
> В следующем примере кода наихудшая пространственная сложность может иметь два значения.
1. **Наихудшие входные данные**: когда *n* \< 10, пространственная слож- ность составляет *O*(1); но когда *n* \> 10, инициализированный массив nums занимает пространство *O*(*n*), поэтому наихудшая пространственная сложность составляет *O*(*n*).
2. **Пиковое использование памяти во время выполнения**: например, до выполнения последней строки программа занимает пространство *O*(1). При инициализации массива nums программа занимает про- странство *O*(*n*), поэтому наихудшая пространственная сложность со- ставляет *O*(*n*).
> def algorithm(n: int): a = 0 \# O(1)
>
> b = \[0\] \* 10000 \# O(1)
>
> if n \> 10:
>
> nums = \[0\] \* n \# O(n)
>
> **В рекурсивных функциях необходимо учитывать статистику про- странства стека.** Рассмотрим следующий код.
>
> 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)
>
> Функции loop() и recur() имеют временную сложность *O*(*n*), но различаются по пространственной сложности.
- Функция loop() вызывает function() *n* раз в цикле, и каждый раз function() возвращает значение и освобождает пространство стека, поэтому про- странственная сложность остается *O*(1).
- Рекурсивная функция recur() во время выполнения одновременно содер- жит *n* невозвращенных вызовов recur(), занимая пространство стека *O*(*n*).
### Основные типы
> Пусть размер входных данных равен *n*, на рис. 2.16 показаны основные типы пространственной сложности (в порядке возрастания).
>
> *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n*2) \< *O*(2*n*)
>
> Константная \< Логарифмическая \< Линейная \< Квадратичная \< Экспоненциальная
>
> **От низкой к высокой,**
>
> **от лучшей к худшей**
##### Константная сложность *O*(1)
> *Константная сложность* обычно встречается у констант, переменных, объек- тов, количество которых не зависит от размера входных данных *n*.
>
> Следует отметить, что память, занимаемая инициализацией переменных или вызовом функций в цикле, освобождается при переходе к следующему циклу, поэтому она не накапливается, и пространственная сложность остается *O*(1).
>
> \# === File: space_complexity.py === def function() -\> int:
>
> \"\"\" Функция.\"\"\"
>
> \# Выполнение некоторых операций. return 0
>
> def constant(n: int):
>
> \"\"\" Константная сложность.\"\"\"
>
> \# Константы, переменные; объекты, занимающие пространство O(1). a = 0
>
> nums = \[0\] \* 10000 node = ListNode(0)
>
> \# Переменные в цикле занимают пространство O(1). for \_ in range(n):
>
> c = 0
>
> \# Функции в цикле занимают пространство O(1). for \_ in range(n):
>
> function()
##### Линейная сложность *O*(*n*)
> *Линейная сложность* часто встречается у массивов, списков, стеков, очередей и других объектов, количество элементов которых пропорционально *n*.
>
> \# === File: space_complexity.py === def linear(n: int):
>
> \"\"\" Линейная сложность.\"\"\"
>
> \# Список длиной n занимает пространство O(n). nums = \[0\] \* n
>
> \# Хеш-таблица длиной n занимает пространство O(n). hmap = dict\[int, str\]()
>
> for i in range(n): hmap\[i\] = str(i)
>
> Глубина рекурсии этой функции равна *n*, т. е. одновременно существует *n* невозвращенных вызовов функции linear_recur(), использующих простран- ство стека *O*(*n*):
>
> \# === File: space_complexity.py === def linear_recur(n: int):
>
> \"\"\" Линейная сложность (реализация с рекурсией).\"\"\"
>
> print(\" Рекурсия n =\", n) if n == 1:
>
> return linear_recur(n - 1)
>
> ![](ru/docs/assets/media/image101.jpeg)
>
> **Рис. 2.17.** Линейная пространственная сложность, создаваемая рекурсивной функцией
##### Квадратичная сложность *O*(*n*2)
> *Квадратичная сложность* часто встречается у матриц и графов, количество элементов в которых пропорционально квадрату *n*.
>
> \# === File: space_complexity.py === def quadratic(n: int):
>
> \"\"\" Квадратичная сложность.\"\"\"
>
> \# Двумерный список занимает пространство O(n\^2). num_matrix = \[\[0\] \* n for \_ in range(n)\]
>
> Глубина рекурсии данной функции равна *n*, в каждом рекурсивном вызове инициализируется массив, длина которого последовательно уменьшается от *n* до 1, средняя длина равна *n*/2, поэтому в целом используется *O*(*n*2) простран- ства, как показано на рис. 2.18.
>
> \# === File: space_complexity.py === def quadratic_recur(n: int) -\> int:
>
> \"\"\" Квадратичная сложность (реализация с рекурсией).\"\"\"
>
> if n \<= 0:
>
> return 0
>
> \# Массив nums длиной n, n-1, \..., 2, 1. nums = \[0\] \* n
>
> return quadratic_recur(n - 1)
>
> ![](ru/docs/assets/media/image103.jpeg)
>
> **Рис. 2.18.** Квадратичная пространственная сложность рекурсивной функции
##### Экспоненциальная сложность *O*(2*n*)
> *Экспоненциальная сложность* часто встречается в двоичных деревьях. Количе- ство узлов в полном двоичном дереве с *n* уровнями равно 2*n* 1, что занимает *O*(2*n*) пространства, как показано на рис. 2.19.
>
> \# === File: space_complexity.py ===
>
> def build_tree(n: int) -\> TreeNode \| None:
>
> \"\"\" Экспоненциальная сложность (создание полного двоичного дерева).\"\"\" if n == 0:
>
> return None root = TreeNode(0)
>
> ![](ru/docs/assets/media/image105.jpeg)root.left = build_tree(n - 1) root.right = build_tree(n - 1) return root
>
> **Рис. 2.19.** Экспоненциальная пространственная сложность полного двоичного дерева
##### Логарифмическая сложность *O*(log *n*)
> *Логарифмическая сложность* часто встречается в алгоритмах типа «разделяй и властвуй». Например, в сортировке слиянием, где входной массив длиной *n* на каждой итерации рекурсивно делится пополам и формирует рекурсивное дерево высотой log *n*, используется *O*(log *n*) пространства для стека вызовов.
>
> Или, например, при преобразовании числа в строку если входное значение является положительным целым числом *n*, то количество его цифр равно ⌊log10 *n* ⌋ + 1, что соответствует длине строки, и поэтому пространственная сложность равна *O*(log10 *n* + 1) = *O*(log *n*).
### 2.4.4 Баланс времени и пространства
> В идеальных условиях мы стремимся к тому, чтобы временная и простран- ственная сложности алгоритма были оптимальными. Однако на практике од- новременно оптимизировать обе сложности зачастую очень затруднительно.
>
> **Снижение временной сложности обычно требует увеличения про- странственной сложности, и наоборот**. Будем называть подход, при кото- ром нужно пожертвовать памятью для увеличения скорости выполнения ал- горитма, обменом пространства на время. Обратный подход будем называть обменом времени на пространство.
>
> Выбор подхода зависит от того, какой аспект для нас более важен. В боль- шинстве случаев время ценнее пространства, поэтому обмен пространства на время является более распространенной стратегией. Конечно, при больших объемах данных контроль пространственной сложности также очень важен.
#### резюме
##### Ключевые моменты
###### Оценка эффективности алгоритмов:
- временная и пространственная эффективность являются двумя основ- ными критериями для оценки качества алгоритмов;
- эффективность алгоритмов можно оценивать с помощью реальных те- стов, однако это сложно из-за влияния тестовой среды и значительных
> затрат вычислительных ресурсов;
- анализ сложности позволяет устранить недостатки реальных тестов, ре- зультаты анализа применимы ко всем платформам и могут выявить эф- фективность алгоритма при различных объемах данных.
###### Временная сложность:
- временная сложность используется для оценки тенденции изменения времени выполнения алгоритма с увеличением объема данных, что по- зволяет оценивать его эффективность. Однако в некоторых случаях она может работать не так хорошо, например когда объем входных данных мал или временная сложность одинакова, что не позволяет точно срав- нить эффективность алгоритмов;
<!-- -->
- худшая временная сложность обозначается символом *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*(n2) и *O*(2n).
##### Вопросы и ответы
> **Вопрос**. Пространственная сложность хвостовой рекурсии равна *O*(1)?
>
> **Ответ**. Теоретически пространственную сложность хвостовой рекурсии можно оптимизировать до *O*(1). Однако большинство языков программиро- вания (например, Java, Python, C++, Go, C# и др.) не поддерживают автомати- ческую оптимизацию хвостовой рекурсии, поэтому обычно считается, что ее пространственная сложность равна *O*(*n*).
>
> **Вопрос**. В чем разница между терминами «функция» и «метод»?
>
> **Ответ**. Функция может выполняться независимо, все параметры передают- ся явно. Метод связан с объектом и неявно передается вызывающему его объ- екту. Он может оперировать данными, содержащимися в экземпляре класса.
>
> Ниже приведены примеры из нескольких распространенных языков про- граммирования:
- язык C является процедурным языком и не имеет концепции объек- тно ориентированного программирования, поэтому в нем есть только функции. Однако можно создать структуры, чтобы имитировать клас- сы. Функции, связанные со структурами, будут эквивалентны методам в других языках программирования;
- Java и C# являются объектно ориентированными языками программи- рования, и блоки кода (методы) обычно являются частью какого-либо класса. Статические методы ведут себя как функции, так как они при- вязаны к классу и не могут обращаться к конкретным свойствам экзем- пляра;
- C++ и Python поддерживают как процедурное программирование (функ- ции), так и объектно ориентированное программирование (методы).
> **Вопрос**. Отражает ли схема «Типы пространственной сложности» абсолют- ный размер занимаемого пространства?
>
> **Ответ**. Нет, данная схема демонстрирует пространственную сложность, отра- жающую тенденцию роста, а не абсолютный размер занимаемого пространства. Предположим, для *n* = 8 вы можете заметить, что значения каждой кривой не соответствуют значениям функции. Это происходит потому, что каждая кривая содержит постоянную составляющую, которая используется для сжа-
>
> тия диапазона значений до визуально комфортного уровня.
>
> На практике, поскольку обычно постоянная составляющая метода неизвест- на, невозможно выбрать оптимальное решение только на основе сложности при *n* = 8. Однако при *n* = 85 выбор становится очевидным, так как тенденция роста уже доминирует.
>
> Глава 3