Главной целью количественного анализа алгоритмов является прогнозирование ресурсов, необходимых для их выполнения. В первую очередь это касается времени выполнения и затрат памяти, хотя в некоторых задачах могут быть важны и другие показатели – например, пропускная способность сетевого канала.
Наиболее точные оценки можно получить с помощью бенчмаркинга, то есть практического измерения производительности алгоритма на конкретных данных и в конкретной среде. Однако в большинстве случаев такой подход либо невозможен, либо не даёт универсальных результатов. Для сравнения алгоритмов между собой и выбора наиболее подходящих решений используется теоретический анализ, который позволяет абстрагироваться от конкретной реализации и делать выводы о поведении алгоритма при увеличении объёмов задачи.
При небольших объёмах данных разница в производительности между алгоритмами может быть незначительной. Однако с ростом входных данных она становится всё более ощутимой. Следовательно, ключевым фактором эффективности является скорость роста затрат при увеличении размера входных данных. Под размером входных данных в зависимости от алгоритма может пониматься: количество элементов, длина массива или текста, порядковый номер элемента в последовательности (вычисление числа Фибоначчи) и т.д.
Пусть – функция, определяющая количество времени, требуемое алгоритму для обработки входных данных размера . Под количеством времени здесь можно синонимично понимать как реальное время, так и число инструкций, т.к. эти величины пропорциональны. Основной характеристикой роста этой функции служит её асимптотическая граница сверху, обозначаемая (читается: «о большое»). Она описывает максимальную скорость роста функции с точностью до постоянного множителя:
Это означает, что при достаточно больших функция не превосходит с точностью до постоянного множителя. Такую оценку называют временной сложностью алгоритма.
Существуют и другие виды асимптотических оценок:
Пространственная сложность (или сложность по памяти) оценивает рост объёма используемой памяти при увеличении размера входных данных. Подход аналогичен анализу по времени. Однако здесь существует один нюанс: входные данные уже занимают память, которая не зависит от алгоритма. Поэтому при анализе чаще учитываются только вспомогательные структуры, создаваемые в процессе выполнения. В рекурсивных алгоритмах необходимо учитывать и рост системного стека, происходящий при углублении рекурсивных вызовов.
Высокая временная и пространственная эффективность зачастую недостижимы одновременно. Как правило, повысить эффективность алгоритма по времени можно за счёт хранения и использования вспомогательных данных. И наоборот, эффективность по памяти обычно достигается за счёт дополнительных вычислений. Поэтому при разработке или выборе алгоритма приходится искать компромисс, наиболее соответствующий требованиям текущей задачи.
Некоторые важные свойства асимптотических оценок:
Примеры:
В последнем примере растёт медленнее , поэтому второе слагаемое доминирует. Для анализа подобных случаев стоит вспомнить основные классы математических функций и соотношение их скоростей роста. Классы сложности алгоритмов называют по имени соответствующей функций.
Основные классы сложности:
Скорости их роста соотносятся как:
Константная, линейная, квадратичная и кубическая сложности – частные случаи полиномиальной сложности , где – константа. Всякая полиномиальная сложность является более эффективной, чем экспоненциальная и факториальная.
Эффективность алгоритма может зависеть не только от размера входных данных, но и от их характеристик и структуры. Поэтому принято различать:
Оценка худшего случая наиболее универсальна и распространена, так как гарантирует, что алгоритм не превысит указанный уровень затрат. Средний случай даёт среднестатистический реалистичный прогноз. Оценка лучшего случая редко полезна.
На примере линейного поиска в массиве (последовательного обхода всех элементов):
Анализ среднего случая нередко требует вероятностной модели и может быть сложным. Однако приближённую оценку можно получить эмпирически – замеряя поведение алгоритма на случайных данных. При осуществлении замеров самым точным будет использовать счётчик тактов процессора, если такой доступен в используемом технологическом стеке. В противном случае, подойдёт любой счётчик времени с достаточной точностью. Для построения зависимости необходимо производить замеры на разных размерах данных. Для большей точности значения должны усредняться по многим запускам.
Применяя асимптотический анализ на практике, не стоит забывать о допущениях, из которых он исходит. Алгоритм с лучшей асимптотикой может уступать на малых объёмах данных. К примеру, стандартные библиотечные реализации сортировки общего назначения хоть и основаны обычно на быстрой сортировке, подменяются на малом размере массива (до 16-32 в разных реализациях) на сортировку вставками, которая оказывается эффективнее (в том числе из-за лучшего использования процессорного кеша).
Для оценки временной (и пространственной) сложности алгоритма в худшем случае по его коду необходимо проанализировать структуру: условные операторы, циклы, вызовы функций. Цель анализа – выразить максимальное число выполняемых операций как функцию от параметров, описывающих размер входных данных.
В большинстве случаев достаточно руководствоваться следующими эмпирическими правилами:
if условие:
блок A
else:
блок B
Общая сложность равна: O(условие) + max(O(A), O(B)).
O(число итераций × (O(итерации) + O(условия))).Пример 1. Условный оператор
if item in array:
result = True
else:
result = False
Оператор in выполняет линейный поиск, проходя массив до первого совпадения или до конца. Следовательно, условие выполняется за , где – длина массива. Основной и альтернативный блок команд – элементарные операции (). Итоговая сложность: .
Пример 2. Вложенный цикл со сдвигом счётчика
for i = 0; i < n; i++:
for k = i; k < n; k += 2:
# тело цикла
Посчитаем общее количество итераций внутреннего цикла. Для каждого внутренний цикл выполняется примерно раз. Суммарно по получаем:
Итоговая сложность – . Таким образом, шаг счётчика и смещение диапазона (если фиксированы) не влияют на класс сложности – лишь на константу.
Пример 3. Геометрическая прогрессия счётчика
for i = 1; i < n; i *= 2:
# тело цикла
Здесь счётчик растёт как степени двойки: . Последняя итерация: . Таким образом, итоговая сложность – .
Пример 4. Подсчёт частот символов в строке
def count_frequencies(text):
freq = {}
for ch in text:
if ch in freq:
freq[ch] += 1
else:
freq[ch] = 1
return freq
Переменная freq содержит динамическую структуру словаря. Поэтому чтобы проанализировать сложность алгоритма, необходимо знать детали реализации. В Python словари основаны на хеш-таблицах с константной сложностью выполнения основных операций, тогда сложность алгоритма по времени будет линейной. Т.к. имеются динамически расширяемые вспомогательные данные, имеет смысл оценить и пространственную сложность. В переменную freq будут помещаться все встречающиеся символы, а также их частоты. В худшем случае потребуется памяти, где – количество различных символов в исходном тексте (ограничено размером алфавита или ). Если размер алфавита ограничен константой, то имеем константную сложность по памяти.
Чтобы оценить сложность рекурсивного алгоритма необходимо составить и решить рекуррентное уравнение. Существуют различные методы решения вроде метода рекурсивного дерева или метода подстановки. Однако большинство наиболее популярных рекурсивных алгоритмов являются результатом применения стратегии декомпозиции (принципа «разделяй и властвуй»). И для анализа таких алгоритмов сформулирована так называемая Мастер теорема.
Метод декомпозиции заключается в разбиении задачи на некоторое число подзадач, их независимом решении и, затем, объединении результатов в решение исходной задачи. Если задача размера делится на подзадач в раз меньше исходной, а алгоритм объединения результатов решения подзадач имеет временную сложность (если разбиение требует времени, оно также учитывается), то оценка временной сложности алгоритма выражается рекуррентным соотношением:
Мастер теорема. Пусть алгоритм объединения результатов имеет полиномиальную сложность , где , тогда временная сложность алгоритма равна
К примеру, сортировка слиянием, которая будет обсуждаться далее, разбивает последовательность на две равные половины (, ) и выполняет сортировку рекурсивно для каждой, объединяя полученные отсортированные последовательности алгоритмом с линейной сложностью (). Это соответствует второму случаю, следовательно, временная сложность равна .
Некоторые алгоритмы или операции над структурами данных имеют непостоянную временную сложность при многократном последовательном выполнении: в большинстве случаев они выполняются быстро, но изредка требуют значительных затрат. Эти редкие «дорогие» операции обычно обусловлены не входными данными, а внутренним состоянием структуры данных.
Классический пример – динамический массив (динамическая таблица). Его типичная реализация заключается в следующем: изначально выделяется память под массив фиксированного размера. При вставке элементы заполняют ячейки. Когда массив полностью заполняется, при попытке следующей вставки создаётся новый массив увеличенного размера (например, в 2 раза больше), и все элементы копируются в него. Такая стратегия называется мультипликативной. Пока в массиве есть свободные ячейки, вставка требует постоянного времени . Однако при увеличении массива перенос всех элементов занимает , где – текущее число элементов.
Обычный анализ фокусируется на оценке времени выполнения отдельной операции. В приведённом примере это означает, что оценкой сверху станет худший случай – . Но такая оценка не отражает реальную эффективность, если операция выполняется многократно: «дорогие» вставки редки и компенсируются множеством дешёвых.
Это подводит нас к идее амортизационного анализа – подхода, при котором оценивается среднее время выполнения одной операции в серии из последовательных вызовов. Соответствующая оценка называется амортизационной. Для её обозначения иногда используется специальная нотация: , или сноска с пояснением.
Рассмотрим пример вставки элементов в динамический массив с удвоением размера. При каждом переполнении массив копируется в новый, вдвое больший по размеру. Пусть изначально массив имеет размер 1. Тогда на всём пути вставки элементов копирования будут происходить при достижении размеров 1, 2, 4, 8, ..., . Затраты на вставку новых элементов в сумме составят . Следовательно, общее время работы алгоритма:
Т.к. , то . Получаем среднее время выполнения одной операции: .
Описанный подход называется методом усреднения или методом группового анализа. Существую также другие: метод потенциалов, метод предоплаты. Все они, тем не менее, позволяют получить одну и ту же амортизационную оценку.