mirror of
https://github.com/krahets/hello-algo.git
synced 2026-05-05 07:14:34 +08:00
1082 lines
93 KiB
Markdown
1082 lines
93 KiB
Markdown
# Анализ сложности
|
||
|
||
{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*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в сле- дующем разделе.
|
||
>
|
||
> 
|
||
|
||
##### Цикл 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* находятся в квадратичной зависимости.
|
||
>
|
||
> Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической за- висимости, зависимости четвертой степени и т. д.
|
||
>
|
||
> 
|
||
|
||
### Рекурсия
|
||
|
||
> **Рис. 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
|
||
|
||

|
||
|
||
> **Рис. 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* называет- ся глубиной рекурсии.
|
||
|
||

|
||
|
||
> **Рис. 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. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения опе- рации суммирования у них различается.
|
||
|
||
- **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить опера- цию суммирования.
|
||
|
||
- **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, процесс возврата требует только последовательного возврата.
|
||
|
||

|
||
|
||
> **Рис. 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.
|
||
>
|
||
> По своей сути рекурсия отражает парадигму мышления «разбиение зада- чи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной.
|
||
>
|
||
> С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое программирование, прямо или косвенно используют этот подход.
|
||
>
|
||
> С точки зрения **структур данных** рекурсия естественно подходит для реше- ния задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй».
|
||
>
|
||
> {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, и остается константной.
|
||
|
||

|
||
|
||
> **Рис. 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.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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 приведено сравнение трех видов временной сложности: кон- стантной, линейной и квадратичной.
|
||
|
||

|
||
|
||
> **Рис. 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
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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)**
|
||
>
|
||
> **Рис. 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*), например быстрой сортировки, сортировки слиянием, пирамидаль- ной сортировки и т. д.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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.
|
||
|
||

|
||
|
||
> **Рис. 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)
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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)
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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)
|
||
>
|
||
> 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
|