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

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

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

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

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

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

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

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

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

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

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

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

Например:

  • n3=O(n)\frac{n}{3} = O(n)
  • n(n1)(n2)6=O(n3)\frac{n(n-1)(n-2)}{6} = O(n^3)
  • 1+2+3++n=O(n2)1 + 2 + 3 + \ldots + n = O(n^2)
  • 12+22+32++n2=O(n3)1^2 + 2^2 + 3^2 + \ldots + n^2 = O(n^3)
  • log2n+3=O(logn)\log_2{n} + 3 = O(\log n)
  • 179=O(1)179 = O(1)
  • 10100=O(1)10^{100} = O(1)

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

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

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

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

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

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