8. Оценка сложности алгоритма
Где это работает
Большинство современных вычислительных устройств имеют RAM-модель оперативной памяти.
RAM – Random Access Memory – произвольный доступ к памяти.
RAM модель оперативной памяти представлена массивом, состоящем из ячеек, каждая из которых имеет уникальный индекс (адрес). Каждая ячейка может хранить некоторое количество данных, обычно представленных в виде битов или байтов.
Т.к. RAM модель представлена массивом, то доступ к данным (чтение или запись) в ячейке с любым адресом происходит за постоянное время.
Также в RAM модели предполагается, что доступ к любым двум ячейкам оперативной памяти осуществляется за постоянное время, вне зависимости от того, насколько они удалены друг от друга.
RAM модель оперативной памяти – абстракция, упрощающая вычисление сложности алгоритма.
Что такое сложность алгоритма?
Сложность алгоритма – это мера количества вычислительных ресурсов, необходимых алгоритму для решения данной по мере роста размера входных данных.
Сложность алгоритма описывает, как время работы и/или использование памяти алгоритма увеличивается в зависимости от размера входных данных.
Сложность алгоритма используется для описания поведения алгоритма по мере приближения размера его входных данных к бесконечности.
Т.е. сложность алгоритма – это функция , где – количество входных данных, описывающая его производительность.
Зачем нужно оценивать сложность алгоритма?
- Предсказание производительности алгоритма
- Зная сложность алгоритма, можно оценить вычислительные затраты (потенциально время) для выполнения при заданном размере входных данных
- Сравнение алгоритмов
- Зная сложности двух алгоритмов, можно сравнить их и выбрать оптимальный для решаемой задачи
- Оптимизация алгоритмов
- Большинство алгоритмов состоят из других алгоритмов, поэтому, зная сложность составляющих частей, можно найти более производительное решение и оптимизировать весь алгоритм
Почему нельзя оценить производительность алгоритма опытным путём
Можно, но:
- Нужно потратить время на изучение, реализацию и отладку алгоритма
- Результаты могут отличаться друг от друга в зависимости от условий:
- Запуск на различных устройствах и/или операционных системах
- Запуск при различной нагрузке на устройство
- Различное время простоя процессора
- и многие другие
- На выполнение алгоритма может не хватить ресурсов
Соответственно, теоретическая оценка сложности алгоритма позволит быстро понять, подходит ли алгоритм для решения задачи или нет.
Виды сложности
По ресурсам:
- Временная – зависимость количества операций, совершённых алгоритмом, от размера входных данных
- Пространственная – зависимость использованной оперативной памяти от размера входных данных
Пространственная сложность алгоритма может учитывать как всю используемую алгоритмом память, так и только дополнительную, не учитывая ресурсы, затраченные на хранение входных данных.
Существуют 3 случая:
- Худший случай – оценка сложности алгоритма для входных данных, требующих максимальные временные и/или пространственные затраты для решения задачи
- Средний случай – оценка сложности алгоритма для случайных входных данных, т.е. это математическое ожидание сложности работы алгоритма при случайных входных данных
- Лучший случай – оценка сложности алгоритма для входных данных, требующих минимальные временные и/или пространственные затраты для решения задачи, т.е. созданы идеальные условия для работы алгоритма
Функции, использующиеся для оценки сложности алгоритма
* Функции ниже записаны от наиболее производительной до наименее производительной.
- – постоянная сложность алгоритма, не зависит от размера входных данных
- – логарифмическая сложность алгоритма
- Под функцией обычно подразумевается
- – степенная сложность алгоритма
- Примеры: , , , и т.д.
- – показательная сложность алгоритма
- Примеры: ,
- – факториальная сложность алгоритма
O-большое (O)
Эта математическая нотация (функция) показывает верхнюю границу сложности алгоритма и даёт оценку сложности алгоритма в худшем случае.
Пусть функция , где – количество входных данных, представляет действительную сложность алгоритма. Тогда сложность этого алгоритма в O-нотации определяется как , если существуют и такие, что для и функция ограничена сверху функцией , а – известная функция. То есть, если алгоритм имеет временную сложность в O-нотации , то время, необходимое для его выполнения, растёт не быстрее, чем .
Омега-большое (Ω)
Эта математическая нотация демонстрирует нижнюю границу сложности алгоритма и даёт оценку сложности алгоритма в лучшем случае.
Пусть функция , где – количество входных данных, представляет действительную сложность алгоритма. Тогда сложность этого алгоритма в Ω-нотации определяется как , если существуют и такие, что для и функция ограничена снизу функцией , а – известная функция. То есть, если алгоритм имеет временную сложность в Ω-нотации , то время, необходимое для его выполнения, растёт не медленее, чем .
Тета-большое (Θ)
Эта математическая нотация даёт оценку сложности алгоритма в среднем случае.
Если сложность алгоритма в O-нотации равна и в Ω-нотации равна , то в Θ-нотации сложность алгоритма также равна .
Пусть функция , где – количество входных данных, представляет действительную сложность алгоритма. Тогда сложность этого алгоритма в Θ-нотации определяется как , если существуют , и такие, что для и функция ограничена снизу функцией и ограничена сверху функцией , а – известная функция. То есть если алгоритм имеет временную сложность в Θ-нотации , то время, необходимое для его выполнения, растёт не медленнее, чем , и не быстрее, чем .
O-малое (o)
Эта математическая нотация также используется для анализа сложности алгоритма в худшем случае и демонстрирует верхнюю границу сложности алгоритма, которую невозможно достичь.
Она используется, когда необходимо показать, что производительность одного алгоритма строго лучше производительности другого.
Пусть функция , где – количество входных данных представляет действительную сложность алгоритма. Тогда сложность этого алгоритма в o-нотации определяется как , если для всех выполняется: – известная функция.
Омега-малое (ω)
Эта математическая нотация используется для анализа сложности алгоритма в лучшем случае и демонстрирует нижнюю границу сложности алгоритма, которую невозможно достичь.
Аналогично o-нотации, она используется, когда необходимо сравнить алгоритмы, но в их лучшем случае.
Пусть функция , где – количество входных данных представляет действительную сложность алгоритма. Тогда сложность этого алгоритма вω -нотации определяется как , если для всех выполняется: – известная функция.
Как рассчитать сложность алгоритма
Замечания
- Обычно для алгоритма рассчитываются:
- Временная сложность в худшем и среднем случаях
- Пространственная сложность в худшем случае
- Часто константы не имеют значения, поэтому они отбрасываются
- Младшие члены в многочлене также не учитываются: преобразуется в
- Временная сложность алгоритма рассчитывается в количестве совершаемых элементарных операций
Определения
Элементарная операция – операция, сложность которой принимается за . Соответственно, операции аналогичные данной и более производительные имеют такую же сложность.
Рассчёт сложности алгоритма
Сложность алгоритма рассчитывается как сумма элементарных операций, совершённых в течение работы алгоритма.
Сложность последовательных операций
Общая сложность последовательных операций равна сумме сложностей каждой операции.
функция f():
операция1;
операция2;
...
операцияM;
\begin{gather} O(f) = O(f_{op_1}) + O(f_{op_2}) + \ldots + O(f_{op_m}) = \\ = O(f_{op_1} + f_{op_2} + \ldots + f_{op_m}) = O(1) \end{gather} Если не совершается операций над входными данными, то сложность данной функции равняется .
функция f(array длины N):
операция1;
nlogn_sort(array); // сложность nlogn
операция2;
Сложность данной функции f также рассчитывается, как сумма сложностей каждой операции. Т.к. одна из операций имеет сложность , то сложность всей функции становится .
Сложность условий
если условие1:
блок_условий1
...
иначе если условие2:
блок_условий2
...
иначе если ...
...
иначе если условиеM:
блок_условийM
...
иначе:
блок_условийM+1
...
Сложность условий рассчитывается исходя из наиболее сложного блока:
Сложность цикла
цикл для i от 1 до N:
блок_операций
Цикл можно представить как повторение одного и того же блока операций N раз, следовательно сложность цикла можно представить как:
Следовательно, сложность цикла выражается произведением сложности операций внутри цикла и количества повторений цикла.
Если количество повторений цикла не зависит от входных данных, то количество повторений считается константным. Следовательно:
цикл для i от 1 до 10:
блок_операций
Также циклы могут иметь нелинейную сложность. Например, бинарный поиск выполняется со сложностью , а генерация перестановок за .
Сложность вложенных циклов
цикл для i от 1 до k:
цикл для j от 1 до m: // цикл для j = блок_i
блок_j;
Т.к. цикл для j – это блок операций цикла для i, то получим: Т.к. – цикл j, то получим: Следовательно: Сложность вложенных циклов равна произведению сложностей каждого цикла.
Сложность рекурсии
Общий подход к подсчёту сложности рекурсивного алгоритма – определение функции роста дерева рекурсивных вызовов.
То есть необходимо понять, сколько рекурсивных вызовов совершает функция на каждом шаге и до какой глубины это будет происходить (когда вызовы остановятся).
Объяснение на рекурсивном алгоритме поиска n-го члена последовательности Фиббоначчи
Алгоритм:
функция fib(n):
# 0 1 1 2 3 5 8 13 ...
если n < 2:
вернуть n
вернуть fib(n - 1) + fib(n - 2)
Если вызвать функцию fib(5), то получится данное дерево рекурсивных вызовов
fib(5)
- fib(4)
- fib(3)
- fib(2)
- fib(1)
- fib(0)
- fib(1)
- fib(2)
- fib(1)
- fib(0)
- fib(3)
- fib(2)
- fib(1)
- fib(0)
- fib(1)
На каждом вызове функции fib происходит 2 рекурсивных вызова, эти вызовы будут происходит, пока значение n не станет равным 0 или 1. Т.к. каждый раз передаваемое в функцию значение уменьшается на 1 или на 2, то потребуется n таких вызовов.
Соответственно, если на каждом вызове функции происходит 2 рекурсивных вызова и таких вызовов n, то сложность данного алгоритма равна: – n раз, т.е. .
Геометрический подход
Данный метод применим не во всех случаях, а также не является математически точным. С помощью него можно оценить сложность алгоритма схожего по подходу с алгоритмом сортировки слиянием.
Суть данного вида алгоритмов заключается в использовании стратегии "Разделяй и властвуй". Это значит, что на каждом рекурсивном вызове функции количество обрабатываемых данных уменьшается в 2 раза.
Например, для массива чисел от 1 до 8 разделение будет выглядеть так:
| 1 2 3 4 5 6 7 8 | |||||||
| 1 2 3 4 | 5 6 7 8 | ||||||
| 1 2 | 3 4 | 5 6 | 7 8 | ||||
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
Получился прямоугольник, длина которого равна , а высота , т.к. с каждым вызовом функции, количество обрабатываемых данных в ней уменьшается в 2 раза.
Тогда сложность этого алгоритма можно рассчитать как площадь данной фигуры: .
Сложность структур данных и амортизационный анализ
Амортизационный анализ — это метод анализа вычислительной сложности алгоритма, когда время исполнения одного шага алгоритма, умноженное на число шагов, даёт бОльшую оценку на время его исполнения по сравнению с реальной.
Амортизационный анализ применяют для алгоритмов, которые запускаются несколько раз, при этом сложность алгоритма на каждом запуске может сильно колебаться.
Рассмотрим последовательность операций . Пусть каждая операция выполняется за фактическое время . Для каждой операции нужно посчитать амортизационное время , такое что:
Рассмотрим структуру данных, содержащую стек, которая поддерживает операцию popMpushX:
function popMpushX(stack, m, x):
for i in [0, m):
stack.pop()
stack.push(x)
Предполагается, что размер хранимого в структуре данных стека больше m. Также предполагается, что данная операция выполняется за операций.
Можно заметить, что если совершить таких операций, удаляющих все элементы из стека размера на каждом шаге (), то сложность всех операций в сумме будет равняться .
Амортизационный анализ позволит оценить действительную сложность таких операций.
Метод банкира (бухгалтерский метод)
Суть метода заключается в том, что каждой операции выдаётся монет и разрешается тратить монет. При этом все доступные монеты операция может разложить по структуре данных. Тогда амортизационная стоимость одной операции составит .
Чтобы выполнялось условие необходимо ввести ограничение: невозможно потратить больше монет, чем их существует, т.е. – .
Так как , то .
Для совершения операции popMpushX выдадим 1 монету () и определим её на элемент X, который был добавлен в стек. Таким образом будет "оплачена" операция удаления этого элемента X в будущем.
Чтобы удалить элементов из стека, необходимо потратить монеты, лежащие на последних элементах, соответственно .
Тогда амортизационная стоимость каждой операции будет равна .
Тогда сложность выполнения таких операция будет равна .
Получается, что амортизационная сложность выполнения таких операций в худшем случае – , меньше сложности, вычисленной стандартным подходом – .
Метод потенциалов
Суть метода заключается в том, что структура данных может принимать несколько состояний. – начальное состояние структуры, – состояние структуры после выполнения -ой операции.
Введём функцию , которая будет принимать текущее состояние структуры данных и возвращать неотрицательное число – потенциал структуры на состоянии . При этом .
Тогда потенциал структуры данных на состоянии – . А разность потенциалов для перехода из состояния к как .
Т.к. при переходе из состояния в состояние нужно совершить 1 операцию push и операций pop, то .
Тогда амортизационная стоимость операции равна .
Следовательно, амортизационная сложность таких операций равна .
Агрегационный метод
Суть данного метода заключается в том, что необходимо найти и поделить это значение на количество совершённых операций.
Если предположить, что каждая операция удаляет все элементы из стека и добавляет 1, то на выполнение первой операции потребуется 1 действие; на выполнение следующих: действий (1 на push, 1 на удаление всех элементов, т.к. в стеке всегда будет 1 элемент).
Соответственно, первая операция будет происходить за 1 действие, другие операций за 2 действия.
Следовательно, амортизированная сложность таких операция равна .