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

1. Вычисление чисел Фибоначчи

Последовательность чисел Фибоначчи

$$ 1,1,2,3,5,8,13,21,34,55,89, \ldots $$

определяется следующим образом:

Определение 1.1.1

$F_{n}$ - $n$-ое число Фибоначчи, где $F_{0}=F_{1}=1, F_{n}=F_{n-1}+F_{n-2}, n>1 .$

Числа Фибоначчи растут экспоненциально быстро:

Лемма 1.1.1

$2^{\lfloor n / 2\rfloor} \leqslant F_{n} \leqslant 2^{n}$.

Доказательство

Докажем утверждение по индукции.

База. Утверждение верно для $n=0,1$.

Переход. Пусть $n>1$, тогда $F_{n}=F_{n-1}+F_{n-2} \leqslant 2^{n-1}+2^{n-2}<2^{n}$.

С другой стороны, $F_{n} \geqslant 2 \cdot F_{n-2} \geqslant 2 \cdot 2^{\lfloor(n-2) / 2\rfloor}=2^{\lfloor n / 2\rfloor}$.

Экспоненциальный алгоритм

Следующий рекурсивный алгоритм вычисляет $n$-ое число Фибоначчи, точно следуя определению:

fib(n):
    if n <= 1:
        return 1
    return fib(n - 1) + fib(n - 2)

Каково время работы этого алгоритма? Оценим $T(n)$ - суммарное количество вызовов fib , происходящих при выполнении $\mathrm{fib}(\mathrm{n})$ : если $n \leqslant 1$, то $T(n)=1$; иначе

$$ T(n)=1+T(n-1)+T(n-2) . $$

Несложно доказать по индукции, что $F_{n} \leqslant T(n)<2 F_{n}$. В каждом вызове fib совершается ограниченное число (скажем, не больше пяти) операций, поэтому время работы алгоритма примерно пропорционально $F_{n}$ (чуть позже у нас появится формальное определение этого “примерно”).

Из леммы 1.1.1 следует, например, что, $F_{300} \geqslant 2^{150}>10^{45}$. На компьютере, выполняющем $10^{9}$ операций в секунду, fib(300) будет выполняться больше $10^{36}$ секунд.

Полиномиальный алгоритм

Посмотрим на дерево рекурсивных вызовов алгоритма fib (рис. 1.1). Видно, что алгоритм много раз вычисляет одно и то же. Давайте сохранять результаты промежуточных вычислений в массив:

Рис. 1.1: Дерево рекурсивных вызовов fib

fibFast(n):
    if n <= 1:
        return 1
    int f[n + 1] # создаём массив с индексами 0..n
    f[0] = f[1] = 1
    for i = 2..n:
        f[i] = f[i - 1] + f[i - 2]
    return f[n]

На каждой итерации цикла совершается одно сложение, всего итераций примерно $n$, поэтому количество сложений, выполняемых в ходе нового алгоритма, примерно пропорционально $n$. Есть ещё одна тонкость - из леммы 1.1.1 следует, что двоичная запись $F_{n}$ имеет длину порядка $n$. Чуть позже мы увидим, что сложение двух $n$-битовых чисел требует порядка $n$ элементарных операций, значит общее время работы нового алгоритма примерно пропорционально $n^{2}$. С помощью fibFast можно уже за разумное время вычислить не только $F_{300}$, но и $F_{100000}$.

1.2 О-символика

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

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

Определение 1.2.1

Рассмотрим функции $f, g: \mathbb{N} \rightarrow \mathbb{R}$.

  • $f=O(g)$, если существуют такие $C>0, N>0$, что для любого $n>N$ : $|f(n)| \leqslant C \cdot|g(n)|$.
  • $f=\Omega(g)$, если существуют такие $C>0, N>0$, что для любого $n>N$ : $|f(n)| \geqslant C \cdot|g(n)|$.
  • $f=\Theta(g)$, если существуют такие $C_{1}>0, C_{2}>0, N>0$, что для любого $n>N$ : $C_{1} \cdot|g(n)| \leqslant|f(n)| \leqslant C_{2} \cdot|g(n)|$.

Запись $f=O(g)$ можно понимать как " $|f| \leqslant|g|$ с точностью до константы". Аналогично, $\Omega(\cdot)$ можно считать аналогом $\geqslant$, а $\Theta(\cdot)$ - аналогом $=$. Ещё один способ понимать запись $f=O(g)$ - отношение $\frac{|f(n)|}{|g(n)|}$ ограничено сверху некоторой константой.

Многие естественные свойства операторов сравнения $\leqslant,=, \geqslant$ выполняются и для их асимптотических аналогов. Сформулируем некоторые из этих свойств:

Свойства 1.2.1
  1. $f=\Theta(g)$ тогда и только тогда, когда $f=O(g)$ и $f=\Omega(g)$.
  2. $f=O(g)$ тогда и только тогда, когда $g=\Omega(f)$.
  3. $f=\Theta(g)$ тогда и только тогда, когда $g=\Theta(f)$.
    Доказательство

    Докажем для примера п. 2.

    Пусть $C>0$, тогда $|f(n)| \leqslant C \cdot|g(n)|$ равносильно $|g(n)| \geqslant \frac{1}{C} \cdot|f(n)|$.

    Таким образом, $f=O(g)$ с константой $C$ тогда и только тогда, когда $g=\Omega(f)$ с константой $\frac{1}{C}$.

Существуют также асимптотические аналоги $<$ и $>: o(\cdot)$ и $\omega(\cdot)$.

Определение 1.2.2

Рассмотрим функции $f, g: \mathbb{N} \rightarrow \mathbb{R}$.

  • $f=o(g)$, если для любого $C>0$ найдётся такое $N>0$, что для любого $n>N$ : $|f(n)| \leqslant C \cdot|g(n)|$.

  • $f=\omega(g)$, если для любого $C>0$ найдётся такое $N>0$, что для любого $n>N$ : $|f(n)| \geqslant C \cdot|g(n)|$.

Запись $f=o(g)$ можно понимать как “отношение $\frac{|f(n)|}{|g(n)|}$ стремится к нулю с увеличением $n$ “, а запись $f=\omega(g)$ - как “отношение $\frac{|f(n)|}{|g(n)|}$ стремится к бесконечности с увеличением $n$ “.

Свойства 1.2.2
  1. $f=o(g)$ тогда и только тогда, когда $g=\omega(f)$.
  2. Если $f=O(g)$ и $g=O(h)$, то $f=O(h)$. То же верно для $\Omega, \Theta, o, \omega$.
  3. Если $f=O(g), g=o(h)$, то $f=o(h)$. Если $f=\Omega(g), g=\omega(h)$, то $f=\omega(h)$.
  4. Если $f=O(h)$ и $g=O(h)$, то $f+g=O(h)$. То же верно для $o$. Если $f, g \geqslant 0$, то то же верно и для $\Omega, \Theta, \omega$.
  5. Если $f=o(g)$, то $f+g=\Theta(g)$. Если $f, g \geqslant 0, f=O(g)$, то $f+g=\Theta(g)$.
  6. Для любого $C \neq 0$ верно $C \cdot f=\Theta(f)$.

Заметим, что у последних трёх свойств нет аналогов для обычных операторов сравнения.

Доказательство

Докажем для примера п. 3 (вариант с $O$ и $о$).

Нужно показать, что для любого $C>0$ найдётся $N>0$, что для любого $n>N$ : $|f(n)| \leqslant C \cdot|h(n)|$. Зафиксируем $C>0$.

$f=O(g)$, поэтому найдутся $C_{1}>0, N_{1}>0$, что для любого $n>N_{1}:|f(n)| \leqslant C_{1} \cdot|g(n)|$. $g=o(h)$, поэтому найдётся такое $N_{2}>0$, что для любого $n>N_{2}:|g(n)| \leqslant \frac{C}{C_{1}} \cdot|h(n)|$.

Тогда для любого $n>\max \left(N_{1}, N_{2}\right):|f(n)| \leqslant C_{1} \cdot|g(n)| \leqslant C_{1} \cdot \frac{C}{C_{1}} \cdot|h(n)|=C \cdot|h(n)|$.

Вернемся к алгоритмам вычисления чисел Фибоначчи. Пользуясь новыми обозначениями, мы показали, что время работы fibFast(n) можно оценить как $O\left(n^{2}\right)$. При этом время работы $\mathrm{fib}(\mathrm{n})$ можно оценить (сверху) как $O\left(2^{n} \cdot n\right)$, и (снизу) как $\Omega\left(2^{\lfloor n / 2\rfloor}\right)$.

1.3 Многочлены, экспоненты и логарифмы

Очень часто время работы алгоритма удаётся оценить функцией, являющейся комбинацией каких-то из трёх базовых типов: многочленов, экспонент и логарифмов. Так, время работы fibFast мы оценили многочленом $n^{2}$, а время работы fib - произведением экспоненты на многочлен: $2^{n} \cdot n$. В связи с этим полезно изучить асимптотические свойства этих функций и научиться сравнивать их между собой.

Лемма 1.3.1

Для любых $l>k$ верно $n^{k}=o\left(n^{l}\right)$.

Доказательство

Для любого $C>0$, при $n \geqslant\left(\frac{1}{C}\right)^{\frac{1}{l-k}}$ верно

$$ n^{k} \leqslant C \cdot \frac{1}{C} \cdot n^{k} \leqslant C \cdot n^{l-k} \cdot n^{k}=C \cdot n^{l} $$

Определение 1.3.1

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

$$ f(n)=a_{0}+a_{1} n+a_{2} n^{2}+\cdots+a_{d} n^{d} $$

для некоторого $d \geqslant 0$, так, что $a_{d} \neq 0$. Это $d$ называют степенью многочлена и обозначают как $\operatorname{deg}(f)$.

Следствие 1.3.2

Пусть $f(n)$ - многочлен степени $d$. Тогда $f(n)=\Theta\left(n^{d}\right)$.

Доказательство

Пусть $f(n) = a_{0} + a_{1} n + a_{2} n^{2} + \cdots + a_{d} n^{d}$. Из леммы 1.3.1 и п. 6 предложения 1.2.2 следует, что $a_{j} n^{j} = o\left(n^{d}\right)$ для любого $0 \leqslant j

Тогда, несколько раз применив п. 5 предложения 1.2.2, получаем $f(n) = \Theta\left(n^{d}\right)$.

Определение 1.3.2

Пусть $n > 0, 0 < b \neq 1$. Логарифм $n$ по основанию $b\left(\log _{b} n\right)$ - это такое число $x$, что $b^{x}=n$.

Под $\log n$ без указания основания мы будем иметь в виду $\log _{2} n$.

Если $n \in \mathbb{N}$, то $1+\left\lfloor\log _{b} n\right\rfloor$ - это количество цифр в $b$-ичной записи $n$.

Лемма 1.3.3

Пусть $n>0, a, b>0, a, b \neq 1$. Тогда $\log _{a} n=\frac{\log _{b} n}{\log _{b} a}$.

Доказательство

$b^{\log _{b} a \cdot \log _{a} n}=a^{\log _{a} n}=n$, поэтому $\log _{b} a \cdot \log _{a} n=\log _{b} n$.

Видим, что $\log _{a} n$ отличается от $\log _{b} n$ в константу $\left(\frac{1}{\log _{b} a}\right)$ раз, то есть $\log _{a} n=\Theta\left(\log _{b} n\right)$ для любых оснований $a, b$. Поэтому для удобства часто используют записи вида $O(\log n)$, не уточняя основание.

Следствие 1.3.4

Для любых $x, y>0, z>1$ найдётся такое $N>0$, что для любого $n>N$ верно $\log^{x}n < n^{y} < z^{n}$.

Доказательство

Начнём с первого неравенства. Обозначим $\log n=k$, тогда нам нужно показать, что для достаточно больших $k$ верно $k^{x}<\left(2^{k}\right)^{y}=\left(2^{y}\right)^{k}=z^{k}$ для $z=2^{y}>1$. Таким образом, первое неравенство следует из второго.

Второе неравенство равносильно $n < z^{\frac{n}{y}} = 2^{\frac{n}{y} \log z}$. Обозначим $m=\frac{n}{y} \log z, C=\frac{y}{\log z}$. Тогда нам нужно доказать, что $C \cdot m<2^{m}$, где $m=\frac{n}{C}, C$ - некая константа, не зависящая от $n$.

Для любого $n>C^{2}$ верно $m>C$, то есть $C \cdot m

База. При $6 \leqslant m<7$ верно, что $m^{2}<49<64 \leqslant 2^{m}$.

Переход. Пусть $r \leqslant m < r + 1$ для некоторого целого $r \geqslant 6$, тогда

$$ 2^{m+1}=2 \cdot 2^{m}>2 m^{2}>m^{2}+3 m>m^{2}+2 m+1=(m+1)^{2}, $$

то есть утверждение верно и для $r + 1 \leqslant m < r + 2$. Таким образом, для любого $n>\max \left(C^{2}, 6 \cdot C\right)$ выполняется $m>\max (C, 6)$, и

$$ n=C \cdot m < m^{2}<2^{m}=z^{\frac{n}{y}} . $$

Теорема 1.3.5

Пусть $x, y>0 ; b, z>1$. Тогда $\log _{b}^{x} n=o\left(n^{y}\right), n^{y}=o\left(z^{n}\right)$.

Доказательство

Мы уже знаем, что $\log _{b} n=\Theta(\log n)$ для любого $b>1$, тогда верно и $\log _{b}^{x} n=\Theta\left(\log ^{x} n\right)$ (соответствующие константы из определения $\Theta(\cdot)$ нужно возвести в степень $x$ ). Значит, достаточно доказать, что $\log ^{x} n=o\left(n^{y}\right)$.

Рассмотрим $0<\varepsilon

Тогда $\log ^{x} n=O\left(n^{y-\varepsilon}\right)=o\left(n^{y}\right), n^{y}=o\left(n^{y+\varepsilon}\right)=o\left(z^{n}\right)$. Итак, мы видим, что любая полилогарифмическая функция растёт медленнее любой полиномиальной, а любая полиномиальная функция растёт медленнее любой экспоненциальной.