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$-ое число Фибоначчи, точно следуя определению:
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
- $f=\Theta(g)$ тогда и только тогда, когда $f=O(g)$ и $f=\Omega(g)$.
- $f=O(g)$ тогда и только тогда, когда $g=\Omega(f)$.
- $f=\Theta(g)$ тогда и только тогда, когда $g=\Theta(f)$.
Существуют также асимптотические аналоги $<$ и $>: o(\cdot)$ и $\omega(\cdot)$.
Рассмотрим функции $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)|$.
Определение 1.2.2
Запись $f=o(g)$ можно понимать как “отношение $\frac{|f(n)|}{|g(n)|}$ стремится к нулю с увеличением $n$ “, а запись $f=\omega(g)$ - как “отношение $\frac{|f(n)|}{|g(n)|}$ стремится к бесконечности с увеличением $n$ “.
Свойства 1.2.2
- $f=o(g)$ тогда и только тогда, когда $g=\omega(f)$.
- Если $f=O(g)$ и $g=O(h)$, то $f=O(h)$. То же верно для $\Omega, \Theta, o, \omega$.
- Если $f=O(g), g=o(h)$, то $f=o(h)$. Если $f=\Omega(g), g=\omega(h)$, то $f=\omega(h)$.
- Если $f=O(h)$ и $g=O(h)$, то $f+g=O(h)$. То же верно для $o$. Если $f, g \geqslant 0$, то то же верно и для $\Omega, \Theta, \omega$.
- Если $f=o(g)$, то $f+g=\Theta(g)$. Если $f, g \geqslant 0, f=O(g)$, то $f+g=\Theta(g)$.
- Для любого $C \neq 0$ верно $C \cdot f=\Theta(f)$.
Заметим, что у последних трёх свойств нет аналогов для обычных операторов сравнения.
Вернемся к алгоритмам вычисления чисел Фибоначчи. Пользуясь новыми обозначениями, мы показали, что время работы 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)$.
Определение 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)$.
Определение 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}$.
Видим, что $\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}$.
Теорема 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)$.