2. Анализ сложности алгоритмов

2.1. Основы асимптотического анализа

Главной целью количественного анализа алгоритмов является прогнозирование ресурсов, необходимых для их выполнения. В первую очередь это касается времени выполнения и затрат памяти, хотя в некоторых задачах могут быть важны и другие показатели – например, пропускная способность сетевого канала.

Наиболее точные оценки можно получить с помощью бенчмаркинга, то есть практического измерения производительности алгоритма на конкретных данных и в конкретной среде. Однако в большинстве случаев такой подход либо невозможен, либо не даёт универсальных результатов. Для сравнения алгоритмов между собой и выбора наиболее подходящих решений используется теоретический анализ, который позволяет абстрагироваться от конкретной реализации и делать выводы о поведении алгоритма при увеличении объёмов задачи.

При небольших объёмах данных разница в производительности между алгоритмами может быть незначительной. Однако с ростом входных данных она становится всё более ощутимой. Следовательно, ключевым фактором эффективности является скорость роста затрат при увеличении размера входных данных. Под размером входных данных в зависимости от алгоритма может пониматься: количество элементов, длина массива или текста, порядковый номер элемента в последовательности (вычисление числа Фибоначчи) и т.д.

Пусть T(n)T(n) – функция, определяющая количество времени, требуемое алгоритму для обработки входных данных размера nn. Под количеством времени здесь можно синонимично понимать как реальное время, так и число инструкций, т.к. эти величины пропорциональны. Основной характеристикой роста этой функции служит её асимптотическая граница сверху, обозначаемая OO (читается: «о большое»). Она описывает максимальную скорость роста функции с точностью до постоянного множителя:

T(n)=O(f(n))⇔∃c,n0:∀n≥n0:T(n)≤c⋅f(n)(1)T(n) = O(f(n)) \Leftrightarrow \exists c, n_0: \forall n \ge n_0: T(n) \le c \cdot f(n) \tag{1}

Это означает, что при достаточно больших nn функция T(n)T(n) не превосходит f(n)f(n) с точностью до постоянного множителя. Такую оценку называют временной сложностью алгоритма.

Существуют и другие виды асимптотических оценок:

  • Ω(𝑓(𝑛))Ω(𝑓(𝑛)) («омега большое») – нижняя граница: функция T(n)T(n) возрастает не медленнее f(n)f(n):
T(n)=Ω(f(n))⇔∃c,n0:∀n≥n0:T(n)≥c⋅f(n)(2)T(n) = Ω(f(n)) \Leftrightarrow \exists c, n_0: \forall n \ge n_0: T(n) \ge c \cdot f(n) \tag{2}
  • Θ(𝑓(𝑛))Θ(𝑓(𝑛)) («тета большое») – точная граница (одновременно и верхняя, и нижняя): T(n)T(n) растёт с той же скоростью, что и f(n)f(n) при больших nn:
T(n)=Θ(f(n))⇔∃c1,c2,n0:∀n≥n0:c1⋅f(n)≤T(n)≤c2⋅f(n)(3)T(n) = Θ(f(n)) \Leftrightarrow \exists c_1, c_2, n_0: \forall n \ge n_0: c_1 \cdot f(n) \le T(n) \le c_2 \cdot f(n) \tag{3}

Пространственная сложность (или сложность по памяти) оценивает рост объёма используемой памяти при увеличении размера входных данных. Подход аналогичен анализу по времени. Однако здесь существует один нюанс: входные данные уже занимают память, которая не зависит от алгоритма. Поэтому при анализе чаще учитываются только вспомогательные структуры, создаваемые в процессе выполнения. В рекурсивных алгоритмах необходимо учитывать и рост системного стека, происходящий при углублении рекурсивных вызовов.

Высокая временная и пространственная эффективность зачастую недостижимы одновременно. Как правило, повысить эффективность алгоритма по времени можно за счёт хранения и использования вспомогательных данных. И наоборот, эффективность по памяти обычно достигается за счёт дополнительных вычислений. Поэтому при разработке или выборе алгоритма приходится искать компромисс, наиболее соответствующий требованиям текущей задачи.

Некоторые важные свойства асимптотических оценок:

∀c>0:O(c⋅f(n))=O(f(n));(4)\forall c > 0: O(c \cdot f(n)) = O(f(n)); \tag{4}O(f(n))+O(g(n))=O(max(f(n),g(n)));(5)O(f(n)) + O(g(n)) = O(max(f(n), g(n))); \tag{5}O(f(n))⋅O(g(n))=O(f(n)⋅g(n)).(6)O(f(n)) \cdot O(g(n)) = O(f(n) \cdot g(n)). \tag{6}

Примеры:

  • 5n2+n+2=O(n2)5n^2 + n + 2 = O(n^2);
  • 3n⋅(2n2+6)=O(n3)3n \cdot (2n^2+6) = O(n^3);
  • log⁡2n+5n=O(n)\log_2 n + 5n = O(n).

В последнем примере log2nlog_2 n растёт медленнее nn, поэтому второе слагаемое доминирует. Для анализа подобных случаев стоит вспомнить основные классы математических функций и соотношение их скоростей роста. Классы сложности алгоритмов называют по имени соответствующей функций.

Основные классы сложности:

  • O(1)O(1) – константная;
  • O(log⁡n)O(\log n) – логарифмическая;
  • O(n)O(n) – линейная;
  • O(nlog⁡n)O(n \log n) – линейно-логарифмическая;
  • O(n2)O(n^2) – квадратичная;
  • O(n3)O(n^3) – кубическая;
  • O(cn)O(c^n) – экспоненциальная;
  • O(n!)O(n!) – факториальная.

Скорости их роста соотносятся как:

1<log⁡n<n<nlog⁡n<n2<n3<cn<n!(7)1 < \log n < n < n \log n < n^2 < n^3 < c^n < n! \tag{7}

Константная, линейная, квадратичная и кубическая сложности – частные случаи полиномиальной сложности O(nk)O(n^k), где kk – константа. Всякая полиномиальная сложность является более эффективной, чем экспоненциальная и факториальная.

2.2. Случаи

Эффективность алгоритма может зависеть не только от размера входных данных, но и от их характеристик и структуры. Поэтому принято различать:

  • Худший случай – данные, при которых алгоритм работает дольше всего;
  • Средний случай – математическое ожидание времени выполнения по всем возможным входным данным;
  • Лучший случай – наилучшие для алгоритма данные.

Оценка худшего случая наиболее универсальна и распространена, так как гарантирует, что алгоритм не превысит указанный уровень затрат. Средний случай даёт среднестатистический реалистичный прогноз. Оценка лучшего случая редко полезна.

На примере линейного поиска в массиве (последовательного обхода всех элементов):

  • Худший случай: искомого элемента нет или он последний – 𝑂(n)𝑂(n);
  • Лучший случай: элемент стоит первым – 𝑂(1)𝑂(1);
  • Средний случай: элемент равновероятно находится в любой позиции, поэтому в среднем обходится половина массива – 𝑂(n/2)=𝑂(n)𝑂(n/2)=𝑂(n).

Анализ среднего случая нередко требует вероятностной модели и может быть сложным. Однако приближённую оценку можно получить эмпирически – замеряя поведение алгоритма на случайных данных. При осуществлении замеров самым точным будет использовать счётчик тактов процессора, если такой доступен в используемом технологическом стеке. В противном случае, подойдёт любой счётчик времени с достаточной точностью. Для построения зависимости необходимо производить замеры на разных размерах данных. Для большей точности значения должны усредняться по многим запускам.

Применяя асимптотический анализ на практике, не стоит забывать о допущениях, из которых он исходит. Алгоритм с лучшей асимптотикой может уступать на малых объёмах данных. К примеру, стандартные библиотечные реализации сортировки общего назначения хоть и основаны обычно на быстрой сортировке, подменяются на малом размере массива (до 16-32 в разных реализациях) на сортировку вставками, которая оказывается эффективнее (в том числе из-за лучшего использования процессорного кеша).

2.3. Примеры анализа сложности

Для оценки временной (и пространственной) сложности алгоритма в худшем случае по его коду необходимо проанализировать структуру: условные операторы, циклы, вызовы функций. Цель анализа – выразить максимальное число выполняемых операций как функцию от параметров, описывающих размер входных данных.

В большинстве случаев достаточно руководствоваться следующими эмпирическими правилами:

  • Элементарная операция (присваивание, арифметическая или логическая операция, обращение по адресу в памяти) выполняется за постоянное время: O(1)O(1).
  • Последовательное выполнение блоков: если несколько операций или блоков выполняются один за другим, общая сложность определяется максимальной из них (если блоки не зависят друг от друга): O(max(f(n),g(n)))O(max(f(n), g(n))).
  • Условный оператор:
if условие:
    блок A
else:
    блок B

Общая сложность равна: O(условие) + max(O(A), O(B)).

  • Цикл: O(число итераций × (O(итерации) + O(условия))).
  • Сложность вызова функции равна сложности этой функции. Если функция рекурсивная – используется рекуррентный подход.

Пример 1. Условный оператор

if item in array:
    result = True
else:
    result = False

Оператор in выполняет линейный поиск, проходя массив до первого совпадения или до конца. Следовательно, условие выполняется за O(n)O(n), где nn – длина массива. Основной и альтернативный блок команд – элементарные операции (O(1)O(1)). Итоговая сложность: O(n)+max(O(1),O(1))=O(n)O(n) + max(O(1), O(1)) = O(n).

Пример 2. Вложенный цикл со сдвигом счётчика

for i = 0; i < n; i++:
  for k = i; k < n; k += 2:
    # тело цикла

Посчитаем общее количество итераций внутреннего цикла. Для каждого ii внутренний цикл выполняется ⌈(n−i)/2⌉\lceil (n - i) / 2 \rceil раз. Суммарно по i=0..(n−1)i = 0..(n-1) получаем:

T(n)≈∑i=0n−1n−i2=12∑i=0n−1(n−i)=12⋅n⋅n+12=n(n+1)4(8)T(n) \approx \sum_{i=0}^{n-1} \frac{n - i}{2} = \frac{1}{2} \sum_{i=0}^{n-1} (n - i) = \frac{1}{2} \cdot n \cdot \frac{n+1}{2} = \frac{n(n+1)}{4} \tag{8}

Итоговая сложность – O(n2)O(n^2). Таким образом, шаг счётчика и смещение диапазона (если фиксированы) не влияют на класс сложности – лишь на константу.

Пример 3. Геометрическая прогрессия счётчика

for i = 1; i < n; i *= 2:
  # тело цикла

Здесь счётчик ii растёт как степени двойки: 1,2,4,8,...,2k1, 2, 4, 8, ..., 2^k. Последняя итерация: 2k<n⇒k<log⁡2(n)2^k < n ⇒ k < \log_2(n). Таким образом, итоговая сложность – O(log⁡2(n))O(\log_2(n)).

Пример 4. Подсчёт частот символов в строке

def count_frequencies(text):
  freq = {}
  for ch in text:
    if ch in freq:
      freq[ch] += 1
    else:
      freq[ch] = 1
  return freq

Переменная freq содержит динамическую структуру словаря. Поэтому чтобы проанализировать сложность алгоритма, необходимо знать детали реализации. В Python словари основаны на хеш-таблицах с константной сложностью выполнения основных операций, тогда сложность алгоритма по времени будет линейной. Т.к. имеются динамически расширяемые вспомогательные данные, имеет смысл оценить и пространственную сложность. В переменную freq будут помещаться все встречающиеся символы, а также их частоты. В худшем случае потребуется O(k)O(k) памяти, где kk – количество различных символов в исходном тексте (ограничено размером алфавита или O(n)O(n)). Если размер алфавита ограничен константой, то имеем константную сложность по памяти.

2.4. Анализ рекурсивных алгоритмов. Мастер-теорема

Чтобы оценить сложность рекурсивного алгоритма необходимо составить и решить рекуррентное уравнение. Существуют различные методы решения вроде метода рекурсивного дерева или метода подстановки. Однако большинство наиболее популярных рекурсивных алгоритмов являются результатом применения стратегии декомпозиции (принципа «разделяй и властвуй»). И для анализа таких алгоритмов сформулирована так называемая Мастер теорема.

Метод декомпозиции заключается в разбиении задачи на некоторое число подзадач, их независимом решении и, затем, объединении результатов в решение исходной задачи. Если задача размера nn делится на a≥1a≥1 подзадач в bb раз меньше исходной, а алгоритм объединения результатов решения подзадач имеет временную сложность f(n)f(n) (если разбиение требует времени, оно также учитывается), то оценка временной сложности алгоритма выражается рекуррентным соотношением:

T(n)=a⋅T(n/b)+f(n)(9)T(n) = a \cdot T(n/b) + f(n) \tag{9}

Мастер теорема. Пусть алгоритм объединения результатов имеет полиномиальную сложность Θ(nd)\Theta(n^d), где d≥0d \ge 0, тогда временная сложность алгоритма равна

T(n)={Θ(nd),a<bdΘ(ndlog⁡n),a=bdΘ(nlog⁡ba),a>bd.(10)T(n) = \begin{cases} \Theta(n^d), & a < b^d \\ \Theta(n^d \log n), & a = b^d \\ \Theta(n^{\log_b a}), & a > b^d \end{cases}. \tag{10}

К примеру, сортировка слиянием, которая будет обсуждаться далее, разбивает последовательность на две равные половины (a=2a = 2, b=2b = 2) и выполняет сортировку рекурсивно для каждой, объединяя полученные отсортированные последовательности алгоритмом с линейной сложностью (d=1d = 1). Это соответствует второму случаю, следовательно, временная сложность равна O(n1⋅log(n))=O(n⋅log(n))O(n^1 \cdot log(n)) = O(n \cdot log(n)).

2.5. Амортизационный анализ

Некоторые алгоритмы или операции над структурами данных имеют непостоянную временную сложность при многократном последовательном выполнении: в большинстве случаев они выполняются быстро, но изредка требуют значительных затрат. Эти редкие «дорогие» операции обычно обусловлены не входными данными, а внутренним состоянием структуры данных.

Классический пример – динамический массив (динамическая таблица). Его типичная реализация заключается в следующем: изначально выделяется память под массив фиксированного размера. При вставке элементы заполняют ячейки. Когда массив полностью заполняется, при попытке следующей вставки создаётся новый массив увеличенного размера (например, в 2 раза больше), и все элементы копируются в него. Такая стратегия называется мультипликативной. Пока в массиве есть свободные ячейки, вставка требует постоянного времени O(1)O(1). Однако при увеличении массива перенос всех элементов занимает O(n)O(n), где nn – текущее число элементов.

Обычный анализ фокусируется на оценке времени выполнения отдельной операции. В приведённом примере это означает, что оценкой сверху станет худший случай – O(n)O(n). Но такая оценка не отражает реальную эффективность, если операция выполняется многократно: «дорогие» вставки редки и компенсируются множеством дешёвых.

Это подводит нас к идее амортизационного анализа – подхода, при котором оценивается среднее время выполнения одной операции в серии из nn последовательных вызовов. Соответствующая оценка называется амортизационной. Для её обозначения иногда используется специальная нотация: ∼O(1)\sim O(1), O(1)\mathit{O}(1) или сноска с пояснением.

Рассмотрим пример вставки nn элементов в динамический массив с удвоением размера. При каждом переполнении массив копируется в новый, вдвое больший по размеру. Пусть изначально массив имеет размер 1. Тогда на всём пути вставки nn элементов копирования будут происходить при достижении размеров 1, 2, 4, 8, ..., 2⌊log⁡2n⌋2^{\lfloor\log_2 n\rfloor}. Затраты на вставку новых элементов в сумме составят nn. Следовательно, общее время работы алгоритма:

T(n)=n+1+2+...+2⌊log⁡2n⌋=n+2⋅2⌊log⁡2n⌋−1(11)T(n) = n + 1 + 2 + ... + 2^{\lfloor\log_2 n\rfloor} = n + 2 \cdot 2^{\lfloor\log_2 n\rfloor} - 1 \tag{11}

Т.к. 2⌊log⁡2n⌋≤n2^{\lfloor\log_2 n\rfloor} \le n, то T(n)≤3n−1T(n) \le 3n -1. Получаем среднее время выполнения одной операции: T(n)/n=3n−1n=∼O(1)T(n) / n = \frac{3n - 1}{n} = \sim O(1).

Описанный подход называется методом усреднения или методом группового анализа. Существую также другие: метод потенциалов, метод предоплаты. Все они, тем не менее, позволяют получить одну и ту же амортизационную оценку.