Алгоритмы. Ч1. Асимптотики, мастер-теорема
Сменить тему
Алгоритмы. Ч1. Асимптотики, мастер-теорема

Algo
Math



С операционной точки зрения, мы можем определить алгоритм, как конечную последовательность команд, использующих исходные данные для получения результата. С функциональной точки зрения это отображение f:XYf: X \rightarrow Y, где XX - входные данные YY - элемент результирующего множества.

О-нотация

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

Допустим, мы уже разработали некий АбстрактныйАлгоритм. Если мы его запустим, но можем узнать:

  • Насколько долго он работал(обозначим как TT)
  • Сколько памяти использовал(обозначим как MM)
  • и т.д.

Может показаться, что этого достаточно, но мы решили отправить АбстрактныйАлгоритм нашему АбстрактномуДругу, у которого компьютер намного хуже нашего. И оказалось, что то, что у нас занимало 1 минуту, у АбстрактногоДруга занимает 2 часа.

Мы хотим избежать подобных проблем. Поэтому решаем оценивать наш АбстрактныйАлгоритм не с секундомером, а по количеству операций(абстрактных).

Рассмотрим небольшую функцию из алгоритма.

Haskell:
exists :: [Int] -> Int -> Bool

exists (h:_tail) tgt = findElement _tail tgt $ (h == tgt) 
exists x target      = (x == tgt)
Python:
def exists(arr, tgt):
    for e in arr:
        if e == tgt:
            return True
    return False

Разбор примера и определения

Видим, что функция просто проверяет, есть ли элемент в массиве. Посчитаем, сколько операций для этого требуется(с учётом того, что размер нашего массива nn):

  • В худшем случае nn операций для обхода массива(forfor)
  • В худшем случае nn операций сравнения(ifif)
  • Одна операция возврата результата(returnreturn)

В сумме получили: n+n+1=2n+1n + n + 1= 2n+1 операций. Обратим внимание на фразу в худшем случае. Под этим мы подразумеваем, что количество операций может быть меньше, но, например, в случае, когда tgttgt - последний, нам потребуется именно столько (n)(n) операций. Для удобства обозначают O(n)O(n) . Для операции возврата значения нам всегда требуется одна операция(и в худшем и в лучшем случае). Для лучшего случая есть обозначение Ω(1)\Omega(1). А если O(1)O(1) и Ω(1)\Omega(1), то Θ(1)\Theta(1) .

Теперь обобщим результат из нашего пример. Получим: O(n)+O(n)+Θ(1)O(n) + O(n) + \Theta(1).

Заметим ещё одну деталь. Нам нужно знать, сколько операций требуется для вычисления не только на фиксированном размере массива, намного интереснее знать, как растёт сложность нашего алгоритма при росте nn (будем называть это число размером входа).

Выражение O(n)+O(n)+Θ(1)O(n) + O(n) + \Theta(1) сейчас не является для нас чем-то удобным, так как мы не можем производить над ним какие-то операции. Для решения этой проблемы формализуем наши определения:


O(n)O(n): Пусть, начиная с некоторого размера входа kk сложность ff нашего алгоритма меньше или равна некоторой другой сложности gg, умноженной на какую-то константу CC. Тогда f=O(g)f = O(g)

Запишем это выражение ещё более формально: Пусть f,g:R+R+f,g: R^+ \rightarrow R^+ тогда C,k\exists C,k такие что lk:f(l)Cg(l)f=O(g)\forall l \geq k: f(l) \leq C * g(l) \Rightarrow f = O(g)


Ω(n)\Omega(n): Пусть, начиная с некоторого размера входа kk сложность ff нашего алгоритма больше или равна некоторой другой сложности gg, умноженной на какую-то константу CC. Тогда f=Ω(g)f = \Omega(g)


Θ(n)\Theta(n): f=O(g)f=Ω(g)f=Θ(g)f=O(g) \land f=\Omega(g) \Rightarrow f = \Theta(g)


Из определения выходит, что:

  • n=O(n2)n = O(n^2)
  • n+1=O(n)n+1=O(n)
  • n=O(n2)n2=Ω(n)n = O(n^2) \Rightarrow n^2 = \Omega(n)
  • n=O(n)n=Ω(n)n=Θ(n)n=O(n) \land n=\Omega(n) \Rightarrow n = \Theta(n)

Вернёмся к нашему выражению и упростим его: O(n)+O(n)+Θ(1)=O(n)+O(n)+O(C)=O(n)O(n) + O(n) + \Theta(1)=O(n)+O(n)+O(C)=O(n) То есть теперь мы можем сказать, что АбстрактныйАлгоритм выполняется за линейное время или O(n)O(n)

Резюмируем

На данный момент мы можем написать некоторый АбстрактныйАлгоритм, измерить его сложность при помощи O,ΩO,\Omega и Θ\Theta, а потом отправить АбстрактномуДругу и быть уверенным, что у него всё будет работать также хорошо, как и у вас.

Мастер-теорема

Мы уже умеем измерять сложности. Но что делать, если алгоритм рекурсивный? Мы ведь не можем так просто посчитать все возможные операции? Или всё-таки можем...

Немного изменим наш пример:

Haskell:
split :: [a] -> ([a], [a])
split l = splitAt (((length l) + 1) `div` 2) l

exists :: [Int] -> Int -> Bool
exists [x] tgt = x == tgt
exists arr tgt = uncurry (\x y -> (exists x tgt) || (exists y tgt)) (split arr)
Python:
import math

def exists(arr, tgt):
    length = len(arr)
    if length == 1:
        return arr[0] == tgt
    sep_id = math.ceil(length/2)
    return exists(arr[:sep_id],tgt) or exists(arr[sep_id:],tgt)

Теперь мы рекурсивно ищем элемент в правом и левом поддереве, пока длина массива не будет равна 1.

Рассмотрим часть соответствующего дерева рекурсивных вызовов.

Видим, что размер входа на каждом последующем уровне становится в 2 раза меньше. Получается, что на ii уровне(с нуля) размер входа для одного вызова будет равен: n2i\frac{n}{2^i} При этом сложность суммарно на уровне будет: 2iC2^i C А количество уровней будет равно log2(n)log_2(n), т.е для n=8n=8 - 3 уровня, для n=16n=16 - 4 уровня и т.д.

Для того, чтобы найти общую сложность алгоритма, просуммируем по всем уровням: T(n)=i=0log2(n)2iC=2log2(n)C=O(n)T(n) = \sum_{i=0}^{log_2(n)} 2^i C = 2^{log_2(n)}C = O(n)

Отсюда получается, что наш рекурсивный алгоритм также эффективен, как и его нерекурсивный аналог из предыдущего раздела.

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

Теорема:

{T(n)=O(1),n=1(1)T(n)=aT(nb)+O(nc), n>1 (2)\begin{equation*} \begin{cases} T(n) = O(1),n=1(1)\\ T(n) = a * T(\frac{n}{b}) + O(n^{c}), \ n > 1 \ (2)\end{cases} \end{equation*}

,где aa - кол-во подзадач, nb\frac{n}{b} - размер подзадачи, O(nc)O(n^c) - нерекурсивная сложность(сравнения, присваивания и т.д.)

Док-во: Как и в примере выше найдём суммарную сложность для всех уровней. Сложность одного вызова: (nbi)c(\frac{n}{b^i})^c Сложность суммарно на уровне: ai(nbi)ca^i(\frac{n}{b^i})^c Суммарная сложность: i=0logb(n)ai(nbi)c\sum^{log_b(n)}_{i=0} a^i (\frac{n}{b^i})^c

Подставим получившийся результат в выражение (2)(2) системы:

T(n)=aT(nb)+O(nc)=i=0logb(n)ai(nbi)c+O(1)=T(n) = a * T(\frac{n}{b}) + O(n^{c}) = \sum^{log_b(n)}_{i=0} a^i (\frac{n}{b^i})^c + O(1)=

nci=0logb(n)(abc)i+O(1)n^c \sum^{log_b(n)}_{i=0} (\frac{a}{b^{c}})^i + O(1)

Результат полученного выражения зависит только от abc\frac{a}{b^{c}}: abc=1a=bclogb(a)=c\frac{a}{b^{c}} = 1 \Leftrightarrow a = b^{c} \Leftrightarrow log_b(a) = c Получаем всего 3 случая:

  1. logb(a)=clog_b(a) = c : nci=0lobb(n)(abc)i+O(1)=nci=0lobb(n)(ablogb(a))i+O(1)=nci=0lobb(n)1i+O(1)=nc(logb(n)+1)+O(1)=n^c \sum_{i=0}^{lob_b(n)} (\frac{a}{b^{c}})^i + O(1) = n^c \sum_{i=0}^{lob_b(n)} (\frac{a}{b^{log_b(a)}})^i + O(1) = n^c \sum_{i=0}^{lob_b(n)} 1^i + O(1) = n^c(log_b(n) + 1) + O(1) = O(nc logb(n))O(n^c \ log_b(n))
  2. logb(a)<clog_b(a) < c : nci=0lobb(n)(abc)i+O(1)=O(nc)n^c \sum_{i=0}^{lob_b(n)} (\frac{a}{b^{c}})^i + O(1) = O(n^c)
  3. logb(a)>clog_b(a) > c : nci=0lobb(n)(abc)i+O(1)=O(nc(abc)logb(n))=O(ncalogb(n)blogbn c)=O(ncalogb(n)nc)=O(alogb(n))n^c \sum_{i=0}^{lob_b(n)} (\frac{a}{b^{c}})^i + O(1) = O(n^c (\frac{a}{b^{c}})^{log_b(n)}) = O(n^c \frac{a^{log_b(n)}}{b^{log_bn \ c}}) = O(n^c \frac{a^{log_b(n)}}{n^c}) = O(a^{log_b(n)})

Резюмируем

Для рекурсивной подзадачи вида: {T(n)=O(1), n=1 T(n)=aT(nb)+O(nc), n>1 \begin{equation*} \begin{cases} T(n) = O(1),\ n=1 \ \\ T(n) = a * T(\frac{n}{b}) + O(n^{c}), \ n > 1 \ \end{cases} \end{equation*} Сложность будет равна:

  • logb(a)=cO(nc logb(n))log_b(a) = c \Rightarrow O(n^c \ log_b(n))
  • logb(a)<cO(nc)log_b(a) < c \Rightarrow O(n^c)
  • logb(a)>cO(alogb(n))log_b(a) > c \Rightarrow O(a^{log_b(n)})

Вернёмся к нашему примеру и оценим его с помощью теоремы:

T(n)=2T(n2)+O(n0)T(n) = 2 * T(\frac{n}{2}) + O(n^{0}) Т.к. log2(2)>0log_2(2) > 0, то T(n)=O(2log2(n))=O(n)T(n) = O(2^{log_2(n)}) = O(n) , что подтверждает нашу оценку выше.