Асимптотический анализ - Алгоритмика
Асимптотический анализ

Асимптотический анализ

Часто бывает полезно оценить, сколько времени работает алгоритм. Конечно, можно его просто реализовать и запустить, но тут возникают проблемы:

  • На разных компьютерах время работы всегда будет отличаться.
  • Не всегда заранее доступны именно те данные, на которых он в реальности будет запускаться.
  • Иногда приходится оценивать алгоритмы, требующие часы или даже дни работы.
  • Жизнь коротка, и если есть несколько вариантов алгоритмов, то хочется заранее предсказать, какой из них будет быстрее, чтобы реализовывать только его.

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

  • арифметические операции с числами: +, -, *, /
  • сравнение чисел: <, >, <=, >=, ==, !=
  • присваивание: a[0] = 3

При этом важно не просто считать строчки, а ещё учитывать, как реализованы некоторые отдельные вещи в самом языке. Например, в питоне срезы массива (array[3:10]) копируют этот массив, то есть этот срез работает за 7 элементарных действий. А swap, например, можно реализовать за 3 присваивания.

Упражнение. Попробуйте посчитать точное число сравнений и присваиваний в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае. Это должна быть какая-то формула, зависящая от $n$ — длины массива.

Чтобы учесть вообще все элементарные операции, ещё надо посчитать, например, сколько раз прибавилась единичка внутри цикла for. А ещё, например, строчка n = len(array) — это тоже действие. Поэтому даже посчитав их, не сразу очевидно, какой из этих алгоритмов работает быстрее — сравнивать формулы сложно. Хочется придумать способ упростить эти формулы так, чтобы

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

Для этого придумали О-нотацию — асимптотическое время работы вместо точного (часто его ещё называют просто асимптотикой).

Определение. Пусть $f(n)$ — это какая-то функция. Говорят, что функция $g(n) = O(f(n))$, если существует такие константы $c$ и $n_0$, что $g(n) < c \cdot f(n)$ для всех $n \geq n_0$.

Например:

  • $\frac{n}{3} = O(n)$
  • $\frac{n(n-1)(n-2)}{6} = O(n^3)$
  • $1 + 2 + 3 + \ldots + n = O(n^2)$
  • $1^2 + 2^2 + 3^2 + \ldots + n^2 = O(n^3)$
  • $\log_2{n} + 3 = O(\log n)$
  • $179 = O(1)$
  • $10^{100} = O(1)$

В контексте анализа алгоритмов, фраза «алгоритм работает за $O(f(n))$ операций» означает, что начиная с какого-то $n$ он делает не более чем за $c \cdot f(n)$ операций.

В таких обозначениях можно сказать, что

  • сортировка пузырьком работает за $O(n^2)$;
  • сортировка выбором работает за $O(n^2)$;
  • сортировка вставками работает за $O(n^2)$;
  • сортировка подсчетом работает за $O(n + m)$.

Это обозначение удобно тем, что оно короткое и понятное, а также не зависит от умножения на константу или прибавления константы. Например, если алгоритм работает за $O(n^2)$, то это может значить, что он работает за $n^2$, за $n^2 + 3$, за $\frac{n(n-1)}{2}$ или даже за $1000 \cdot n^2 + 1$ действие. Главное — что функция ведет себя как $n^2$, то есть при увеличении $n$ она увеличивается как некоторая квадратичная функция: если увеличить $n$ в 10 раз, время работы программы увеличится приблизительно в 100 раз.

Основное преимущество О-нотации в том, что все рассуждения про то, сколько операций в swap или считать ли отдельно присваивания, сравнения и циклы — отпадают. Каков бы ни был ответ на эти вопросы, они меняют ответ лишь на константу, а значит асимптотическое время работы алгоритма не меняется.

Первые три сортировки поэтому называют квадратичными — они работают за $O(n^2)$. Сортировка подсчетом может работать намного быстрее — она работает за $O(n + m)$, а если $m \leq n$, то это вообще линейная функция $O(n)$.