Асимптотическая сложность алгоритмов встречается повсеместно. Доступно рассказываем, что это такое, где и как используется.
Если хочешь подтянуть свои знания, загляни на наш курс «Алгоритмы и структуры данных», на котором ты:
- углубишься в решение практических задач;
- узнаешь все про сложные алгоритмы, сортировки, сжатие данных и многое другое.
Ты также будешь на связи с преподавателем и другими студентами.
В итоге будешь браться за сложные проекты и повысишь чек за свою работу 🙂
Интересно, хочу попробовать
Асимптотическая сложность алгоритмов представляет собой время и память, которые понадобятся вашей программе в процессе работы. Одно дело – знать, что существуют линейные или логарифмические алгоритмы, но совсем другое – понимать, что же за всем этим стоит.
С помощью Big O Notation можно математически описать то, как поведет себя программа в условиях наихудшего сценария при большом количестве входных данных. Например, если вы используете сортировку пузырьком, чтобы отсортировать элементы в целочисленном массиве по возрастанию, то худшим сценарием в этом случае будет отсортированный в убывающем порядке массив. При таком раскладе вашему алгоритму понадобится наибольшее количество операций и памяти.
Разобравшись в принципе работы Big O Notation, вы сможете решить проблему оптимизации ваших программ и добиться максимально эффективного кода.
Сложности О(1)
и O(n)
самые простые для понимания.
О(1)
означает, что данной операции требуется константное время. Например, за константное время выполняется поиск элемента в хэш-таблице, так как вы напрямую запрашиваете какой-то элемент, не делая никаких сравнений. То же самое относится к вызову i-того элемента в массиве. Такие операции не зависят от количества входных данных.
В случае, если у вас не очень удачная хэш-функция, которая привела к большому количеству коллизий, поиск элемента может занять О(n)
, где n – это количество объектов в хэш-таблице. В худшем случае Вам понадобится сделать n сравнении, а именно пройтись по всем элементам структуры. Очевидно, что скорость линейных алгоритмов, то есть алгоритмов, работающих в О(n)
, зависит от количества входных данных. В случае с линейными алгоритмами в худшем случае вам придется провести какую-то операцию с каждый элементом.
Бинарный поиск является одним из классических примеров алгоритмов, работающих за O(log_2(n))
. Каждая операция уменьшает количество входных данных вдвое.
Например, если у вас есть отсортированный массив, насчитывающий 8 элементов, в худшем случае вам понадобится сделать Log_2(8)=3
сравнений, чтобы найти нужный элемент.
Рассмотрим отсортированный одномерный массив с 16 элементами:
Допустим, нам нужно найти число 13.
Мы выбираем медиану массива и сравниваем элемент под индексом 7 с числом 13.
Так как наш массив отсортирован, а 16 больше, чем 13, мы можем не рассматривать элементы, которые находятся под индексом 7 и выше. Так мы избавились от половины массива.
Дальше снова выбираем медиану в оставшейся половине массива.
Сравниваем и получаем, что 8 меньше, чем 13. Уменьшаем массив вдвое, выбираем элемент посередине и делаем еще одно сравнение. Повторяем процесс, пока не останется один элемент в массиве. Последнее сравнение возвращает нужный элемент. Лучшее развитие событий – если первая выбранная медиана и есть ваше число. Но в худшем случае вы совершите log_2(n)
сравнений.
Сложность O(n*log n)
можно представить в виде комбинации O(log n)
и O(n)
. Такую сложность можно получить, если в вашей программе есть один for-цикл, который содержит еще один for-цикл. То есть нестинг циклов, один из которых работает за O(log n)
, а другой – за O(n)
.
for(int i = 0; i < n; i++) //выполняется n раз for(int j = 1; j < n; j = j * 2) // выполняется blogs раз print “hello"; //константное время
Сложность O(n^2)
встречается довольно часто в алгоритмах сортировки. Ее легко можно получить, если в программе имплементировать нестинг двух for-циклов, работающих в O(n)
.
Например:
for(int i = 0; i < n; i++) // выполняется n раз for(int j = 0; j < n; j++) //выполняется n раз print “hello”; // константно
То же самое произойдет, если вам надо будет пройтись по элементам двумерного массива.
Алгоритмы, которые можно описать с помощью нотации O(n^2 + n + 3)
или O(n^2-100000)
, или даже O(n^2 + 100000000)
все равно считаются квадратными. При большом количестве входных данных только наибольшая степень n является важной. Константы и коэффициенты обычно не берут во внимание. То же самое касается линейных и логарифмических функций. O( n +1000)
и O(1000*n)
все равно будут работать в O(n)
.
Использование алгоритмов, которые работают в O(n^2)
и выше (например, экспоненциальная функция 2^n
), является плохой практикой. Следует избегать подобных алгоритмов, если хотите, чтобы ваша программа работала за приемлемое время и занимала оптимальное количество памяти.
Почерпнули для себя что-то новое? Делитесь тем, что мы могли упустить
Для
решения одной и той же задачи часто
приходится выбирать алгоритм как из
числа алгоритмов различных
по принципу своей работы, так
и из числа возможных
реализаций одного алгоритма.
При
разных исходных данных за
конечное число шагов все они приведут
к правильному решению задачи. Но из
всего спектра вариантов, следует выбирать
наиболее оптимальные.
Когда
при решении некоторой задачи есть
несколько разных алгоритмов и стоит
задача выбора одного из них для реализации,
необходима оценка
алгоритма с использованием
некоторого критерия.
Критерием
оптимальности алгоритма выбрана
количественная характеристика –
сложность алгоритма.
Виды
сложностей алгоритма:
-
Емкостная
(пространственная) -
Временная
-
Асимптотическая
Емкостная сложность алгоритма
Под
емкостной сложностью (пространственной
сложностью, эффективностью) понимают
объем памяти, требуемой
для выполнения программы. Это функция
размера входных и выходных данных.
Но
память не является критическим ресурсом
для современных ВМ.
Временная сложность алгоритма
Чаще
всего под анализом сложности алгоритма
понимают исследование
времени, необходимого для его выполнения.
Но
одна и та же программа при одних и тех
же входных данных на разных ПК будет
выполняться разное время. Поэтому
измерения не могут быть
единицами времени (секунды и т.д.).
т.е.
физическое время выполнения алгоритма
должно зависеть от количества
выполняемых в алгоритме команд и
времени их выполнения:
i*t,
где
i
— число действий (элементарных
операций, команд),
элементарные операции – это
операции, из которых складывается
алгоритм решения задачи (:=, <>,
=, +, –, *, /; and,
or, not,
xor; call,
return)
t
— среднее время выполнения одного
элементарного действия, зависит от
скорости обработки сигналов ВМ.
Поэтому
временная сложность (трудоемкость
или вычислительная сложность
алгоритма) определяется
количеством элементарных операций,
совершаемых алгоритмом для решения им
поставленной задачи
Временная
сложность алгоритма (в худшем
случае) — это функция размера входных
и выходных данных, равная максимальному
количеству элементарных операций,
алгоритма.
Количество
элементарных операций, зависит не только
от размера входных данных,
но и от самих данных (например,
сортировка вставками, быстрее работает
с уже отсортированными данными).
Если
размер выхода не превосходит размер
входа, то временную сложность можно
рассматривать как функцию только размера
входных данных.
T(N)
— функция временной сложности
(трудоемкости) — зависимость времени
работы от количества входных данных
N – размер задачи.
Однако:
-
не всегда
ясно, какие операции следует считать
элементарными -
разные
операции требуют для своего выполнения
разного времени -
перевод
операций алгоритма в операции,
используемые в компьютере зависит от
свойства компилятора и квалификации
программиста и т.п. -
в ряде
случаев неизвестна структура программы
Кроме
того, точное значение
количества операций, выполненных
алгоритмом, не играет существенной
роли в его анализе, т.к. не является
качественным показателем
эффективности алгоритма.
Более
важной оказывается скорость
роста числа выполняемых
операций при возрастании объема входных
данных.
Два
алгоритма можно сравнить
по скорости роста сложности алгоритма.
Скоростью
роста сложности алгоритма называется
скорость роста числа операций
при возрастании объема входных
данных.
Именно
эта характеристика часто и фигурирует
как оценка вычислительной сложности
алгоритма.
Поэтому
анализ сложности алгоритма предлагается
осуществлять, исследуя как меняется
время работы программы при увеличении
объема входных данных (N)
и с какой скоростью.
Вычислительная
сложность алгоритмов по-разному зависит
от входных данных:
-
только
от объема данных -
От значений
данных -
От порядка
поступления данных -
От всех
перечисленных выше факторов
Например, многие алгоритмы сортировки
потратят гораздо меньше времени на
упорядочивание массива, если он уже
отсортирован.
Обычно
у задачи есть какой-нибудь естественный
параметр, характеризующий объем входных
данных, и сложность оценивается по
отношению к этому параметру
Размер
входа определяется для каждой задачи
индивидуально.
Например,
размером входа принято считать:
-
в задачах
обработки одномерных массивов —
количество элементов в массиве; -
в задачах
обработки двумерных массивов — количество
элементов в массиве или количество
строк и столбцов массива; -
в задачах
обработки чисел (длинная арифметика,
проверка на простоту и т.д.) — общее
число битов, необходимое для представления
данных в памяти ВМ; -
в задачах
обработки графов — количество вершин
графа или число вершин и число ребер
графа.
Т.о.
время выполнения алгоритма T зависит
от объема входных данных N:
T= f (N),
где
T – время выполнения алгоритма, мс;
Для
построения аналитической зависимости
скорости роста:
-
оценивают
функцию T(N)
при некотором интервале [Nmin,
Nmax] -
Проводят
аппроксимацию этой кривой с помощью
некоторой аналитической функции f(N),
поведение которой хорошо
исследовано -
Изменяют параметры функции и оценивают
ошибки аппроксимации.
По
виду функции f(N)
алгоритмы разделяются на следующие
классы:
-
С линейной оценкой сложности, если
функцияf(N) = C· N
;
-
С
квадратичной сложностью, если
f(N)
= C · N 2;
-
С
полиномиальной сложностью, если
f(N)
= C0 + C1· N
+…+ Ck · N
k;
-
С
факториальной сложностью, если
f(N) =C· N!; -
С
экспоненциальной сложностью, если
f(N) = C · a N
; -
С
гиперэкспоненциальной сложностью,
еслиf(N)
= C · a t, где t = a N.
Здесь
C, a и k = некоторые константы, при этом
число C может быть очень большим.
Скорость
роста определяется старшим
– доминирующим членом формулы.
Отбросив
все младшие члены получаем
порядок функции или алгоритма.
Эта функция является скоростью роста
сложности алгоритма.
Сложность алгоритма |
Сложность задачи (кол-во операций) |
|||||
10 |
20 |
30 |
40 |
50 |
60 |
|
N |
0.00001сек |
0,00002сек |
0,00003сек |
0,0004 |
0,0005 |
0,00006 |
N2 |
0,0001 |
0,0004 |
0,0009сек |
0,0016 |
0,025 |
0,0036 |
N3 |
0,001 |
0,008 |
0,027сек |
0,064сек |
0,125 |
0,216 |
N5 |
0,1 |
3,2 |
24,3 сек |
1,7 мин |
5,2 мин |
13,0мин |
2n |
0,001 сек |
1 |
17,9 мин |
12,7 дней |
35,7 лет |
366 столетий |
3n |
0,059 |
58 мин |
6,5 лет |
3855 столетий |
2*108 |
1,3*1013 |
Функция
временной сложности алгоритма может
быть определена точно, но в большинстве
случаев искать точное ее
значение бессмысленно,
т.к.
-
точное
значение временной сложности зависит
от определения элементарных
операций, -
при
увеличении размера входных данных
вклад постоянных множителей и слагаемых
низших порядков, становится
незначительным.
Характер
поведения временной сложности при
увеличении объема данных
N (N)
называется асимптотической сложностью
алгоритма.
Асимптотическая
сложность (асимптотическое
описание временной сложности) —
оценка скорости роста времени работы
алгоритмов, предназначенных для решения
одной и той же задачи, при больших объемах
входных данных.
Именно
асимптотическая сложность алгоритма
определяет в конечном итоге
размер задач, которые можно решать
данным алгоритмом.
Чем
меньше степень роста, тем больше
размер задачи, которую
можно решить за фиксированный промежуток
времени.
Для
описания скорости роста асимптотической
сложности используются
– нотации.
Говорят,
что T(N) —
временная сложность алгоритма (время
работы алгоритма) – имеет
порядок роста f(N),
если для T(N)
найдётся такая константа С, что,
начиная с некоторого N0,
выполняется условие:
T(N) ≤ C*f(N)
,
функция f(N)
неотрицательна при N
≥ N0.
Или,
по-другому, говорят
– алгоритм имеет теоретическую
сложность O(f(N)):
Т(N) =
О(f(N))
O
— нотация (нотация Бахмана-Ландау).
Обозначение
Т(N) =
О(f(N))
относится
к функциям, для которых отношение
T(N)/f(N)→0
(стремится
к 0 при росте N).
и
означает принадлежность функции Т(N)
классу O(f(N)),
т.е. функция Т(N)
ограничена сверху
функцией f
для достаточно больших значений
аргумента.
Пример
1
Если
временная сложность записывается в
виде полинома
T(N)=C1N2+C2N+C3,
то
такой алгоритм имеет порядок сложности,
кратный степени максимального элемента
полинома, т. к. он растет наиболее быстро
при N:
T(N)=O(N2).
Пример
2:
Функция
f(n) = n2/2
+ 3n/2 + 1
возрастает
приблизительно как n2/2
(отбрасываем
сравнительно медленно растущее
слагаемое 3n/2+1).
Константный
множитель 1/2 также убираем
и получаем асимптотическую оценку
для алгоритма, которая обозначается
O(n2) [читается
как «О большое от эн квадрат»].
Незначащие
члены лишь добавляют “волнистости”,
которая затрудняет анализ.
Пример
3:
Пусть
Т(0)=1, Т(1)=4, …, Т(N)=(N+1)2,
тогда временная сложность этого
алгоритма имеет порядок роста
T(N)=
O(N2)
= O(f(N)).
Можно
показать, что для всех N
> n0
при n0 =
1, C = 4 выполняется
неравенство:
(N+1)2
4*N2
Алгоритмы
можно сгруппировать по
скорости роста их сложностей.
При
анализе алгоритма рассматривается
не точное количество выполняемых им
операций, а класс скорости
роста, к которому относится алгоритм,
т.е. общий характер поведения алгоритма,
а не подробности этого поведения
Используются
специальные асимптотические
обозначения, задающие следующие
классы функций:
-
класс Ω(f(N)
— (читается омега большое) —
«лучше не бывает» -
класс О(f(N)
— (читается О большое) —
«хуже не бывает» -
класс Θ
(f(N))
– (читается тета большое)
точная оценка — настоящая сложность
Соседние файлы в папке Лекции
- #
- #
- #
- #
- #
#статьи
- 18 янв 2022
-
0
Software Engineer Валерий Жила подробно рассказал, что такое O(n), и показал, как её считать на примере бинарного поиска и других алгоритмов.
Кадр: фильм «Мальчишник в Вегасе»
Онлайн-журнал для тех, кто влюблён в код и информационные технологии. Пишем для айтишников и об айтишниках.
Валерий Жила
об эксперте
Software Engineer, разрабатывает системы управления городской инфраструктурой для мегаполисов. Основная деятельность: backend, database engineering. Ведёт твиттер @ValeriiZhyla и пишет научные работы по DevOps.
Сегодня я расскажу о сложности алгоритмов, или, как её часто называют, Big O Notation. Статья рассчитана на новичков, поэтому постараюсь изложить идеи и концепции просто, не теряя важных деталей.
Алгоритмы описывают с помощью двух характеристик — времени и памяти.
Время — это… время, которое нужно алгоритму для обработки данных. Например, для маленького массива — 10 секунд, а для массива побольше — 100 секунд. Интуитивно понятно, что время выполнения алгоритма зависит от размера массива.
Но есть проблема: секунды, минуты и часы — это не очень показательные единицы измерения. Кроме того, время работы алгоритма зависит от железа, на котором он выполняется, и других внешних факторов. Поэтому время считают не в секундах и часах, а в количестве операций, которые алгоритм совершит. Это надёжная и, главное, независимая от железа метрика.
Когда говорят о Time Complexity или просто Time, то речь идёт именно о количестве операций. Для простоты расчётов разница в скорости между операциями обычно опускается. Поэтому, несмотря на то, что деление чисел с плавающей точкой требует от процессора больше действий, чем сложение целых чисел, обе операции в теории алгоритмов считаются равными по сложности.
Запомните: в О-нотации на операции с одной или двумя переменными вроде i++, a * b, a / 1024, max(a,b) уходит всего одна единица времени.
Память, или место, — это объём оперативной памяти, который потребуется алгоритму для работы. Одна переменная — это одна ячейка памяти, а массив с тысячей ячеек — тысяча ячеек памяти.
В теории алгоритмов все ячейки считаются равноценными. Например, int a на 4 байта и double b на 8 байт имеют один вес. Потребление памяти обычно называется Space Complexity или просто Space, редко — Memory.
Алгоритмы, которые используют исходный массив как рабочее пространство, называют in-place. Они потребляют мало памяти и создают одиночные переменные — без копий исходного массива и промежуточных структур данных. Алгоритмы, требующие дополнительной памяти, называют out-of-place. Прежде чем использовать алгоритм, надо понять, хватит ли на него памяти, и если нет — поискать менее прожорливые альтернативы.
Статья написана на основе треда Валерия в его аккаунте в Twitter. Автор благодарит @usehex за помощь в создании материала.
Давайте вспомним 8-й класс и разберёмся: что значит запись O(n) с математической точки зрения. При расчёте Big O Notation используют два правила:
Константы откидываются. Нас интересует только часть формулы, которая зависит от размера входных данных. Проще говоря, это само число n, его степени, логарифмы, факториалы и экспоненты, где число находится в степени n.
Примеры:
O(3n) = O(n)
O(10000 n^2) = O(n^2)
O(2n * log n) = O(n * log n)
Если в O есть сумма, нас интересует самое быстрорастущее слагаемое. Это называется асимптотической оценкой сложности.
Примеры:
O(n^2 + n) = O(n^2)
O(n^3 + 100n * log n) = O(n^3)
O(n! + 999) = O(n!)
O(1,1^n + n^100) = O(1,1^n)
У каждого алгоритма есть худший, средний и лучший сценарии работы — в зависимости от того, насколько удачно выбраны входные данные. Часто их называют случаями.
Худший случай (worst case) — это когда входные данные требуют максимальных затрат времени и памяти.
Например, если мы хотим отсортировать массив по возрастанию (Ascending Order, коротко ASC), то для простых алгоритмов сортировки худшим случаем будет массив, отсортированный по убыванию (Descending Order, коротко DESC).
Для алгоритма поиска элемента в неотсортированном массиве worst case — это когда искомый элемент находится в конце массива или если элемента нет вообще.
Лучший случай (best case) — полная противоположность worst case, самые удачные входные данные. Правильно отсортированный массив, с которым алгоритму сортировки вообще ничего делать не нужно. В случае поиска — когда алгоритм находит нужный элемент с первого раза.
Средний случай (average case) — самый хитрый из тройки. Интуитивно понятно, что он сидит между best case и worst case, но далеко не всегда понятно, где именно. Часто он совпадает с worst case и всегда хуже best case, если best case не совпадает с worst case. Да, иногда они совпадают.
Как определяют средний случай? Считают статистически усреднённый результат: берут алгоритм, прокручивают его с разными данными, составляют сводку результатов и смотрят, вокруг какой функции распределились результаты. В общем, расчёт average case — дело сложное. А мы приступаем к конкретным алгоритмам.
Начнём с самого простого алгоритма — линейного поиска, он же linear search. Дальнейшее объяснение подразумевает, что вы знаете, что такое числа и как устроены массивы. Напомню, это всего лишь набор проиндексированных ячеек.
Допустим, у нас есть массив целых чисел arr, содержащий n элементов. Вообще, количество элементов, размер строк, массивов, списков и графов в алгоритмах всегда обозначают буквой n или N. Ещё дано целое число x. Для удобства обусловимся, что arr точно содержит x.
Задача: найти, на каком месте в массиве arr находится элемент 3, и вернуть его индекс.
Меткий человеческий глаз сразу видит, что искомый элемент содержится в ячейке с индексом 2, то есть в arr[2]. А менее зоркий компьютер будет перебирать ячейки друг за другом: arr[0], arr[1]… и так далее, пока не встретит тройку или конец массива, если тройки в нём нет.
Теперь разберём случаи:
Worst case. Больше всего шагов потребуется, если искомое число стоит в конце массива. В этом случае придётся перебрать все n ячеек, прочитать их содержимое и сравнить с искомым числом. Получается, worst case равен O(n). В нашем массиве худшему случаю соответствует x = 2.
Best case. Если бы искомое число стояло в самом начале массива, то мы бы получили ответ уже в первой ячейке. Best case линейного поиска — O(1). Именно так обозначается константное время в Big O Notation. В нашем массиве best case наблюдается при x = 7.
Average case. В среднем случае результаты будут равномерно распределены по массиву. Средний элемент можно рассчитать по формуле (n + 1) / 2, но так как мы отбрасываем константы, то получаем просто n. Значит, average case равен O(n). Хотя иногда в среднем случае константы оставляют, потому что запись O(n / 2) даёт чуть больше информации.
Хорошо, мы уже три минуты обсуждаем linear search, но до сих пор не видели кода. Потерпите, скоро всё будет. Но сперва познакомимся с очень полезным инструментом — псевдокодом.
Разработчики пишут на разных языках программирования. Одни похожи друг на друга, а другие — сильно различаются. Часто мы точно знаем, какую операцию хотим выполнить, но не уверены в том, как она выглядит в конкретном языке.
Возьмём хотя бы получение длины массива. По историческим причинам практически во всех языках эта операция называется по-разному: .length, length(), len(), size(), .size — попробуй угадай! Как же объяснить свой код коллегам, которые пишут на другом языке? Написать программу на псевдокоде.
Псевдокод — достаточно формальный, но не слишком требовательный к мелочам инструмент для изложения мыслей, не связанный с конкретным языком программирования.
Прелесть псевдокода в том, что конкретных правил для его написания нет. Я, например, предпочитаю использовать смесь синтаксиса Python и C: обозначаю вложенность с помощью отступов и называю методы в стиле Python.
А вот и пример псевдокода для нашей задачи, со всеми допущениями и упрощениями. Метод должен возвращать -1, если arr пуст или не содержит x:
int linear_search(int[] arr, int x): if arr is empty: return -1 for i in 0..n: if (arr[i] == x): return i return -1 //x was not found
В псевдокоде часто используют -1 в качестве invalid index, а если алгоритм возвращает объекты — null, nil (то же самое, что и null) или специальный символ «ничего», похожий на перевёрнутую букву «т». Также встречаются конструкции для исключений, вроде throw error («Very Bad»).
Уметь писать псевдокод полезно. Например, с его помощью можно решать задачи на доске на технических собеседованиях. Я пишу код на бумаге и доске почти так же, как в компьютере, но ещё явно выделяю отступы — иначе строчки кода разъезжаются куда глаза глядят:
Следующая остановка — binary search, он же бинарный, или двоичный, поиск.
В чём отличие бинарного поиска от уже знакомого линейного? Чтобы его применить, массив arr должен быть отсортирован. В нашем случае — по возрастанию.
Часто binary search объясняют на примере с телефонным справочником. Возможно, многие читатели никогда не видели такую приблуду — это большая книга со списками телефонных номеров, отсортированных по фамилиям и именам жителей. Для простоты забудем об именах.
Итак, есть огромный справочник на тысячу страниц с десятками тысяч пар «фамилия — номер», отсортированных по фамилиям. Допустим, мы хотим найти номер человека по фамилии Жила. Как бы мы действовали в случае с линейным поиском? Открыли бы книгу и начали её перебирать, строчку за строчкой, страницу за страницей: Астафьев… Безье… Варнава… Ги… До товарища Жилы он дошёл бы за пару часов, а вот господин Янтарный заставил бы алгоритм попотеть ещё дольше.
Бинарный поиск мудрее и хитрее. Он открывает книгу ровно посередине и смотрит на фамилию, например Мельник — буква «М». Книга отсортирована по фамилиям, и алгоритм знает, что буква «Ж» идёт перед «М».
Алгоритм «разрывает» книгу пополам и выкидывает часть с буквами, которые идут после «М»: «Н», «О», «П»… «Я». Затем открывает оставшуюся половинку посередине — на этот раз на фамилии Ежов. Уже близко, но Ежов не Жила, а ещё буква «Ж» идёт после буквы «Е». Разрываем книгу пополам, а левую половину с буквами от «А» до «Е» выбрасываем. Алгоритм продолжает рвать книгу пополам до тех пор, пока не останется единственная измятая страничка с заветной фамилией и номером.
Перенесём этот принцип на массивы. У нас есть отсортированный массив arr и число 7, которое нужно найти. Почему поиск называется бинарным? Дело в том, что алгоритм на каждом шаге уменьшает проблему в два раза. Он буквально отрезает на каждом шаге половину arr, в которой гарантированно нет искомого числа.
На каждом шаге мы проверяем только середину. При этом есть три варианта развития событий:
- попадаем в 7 — тогда проблема решена;
- нашли число меньше 7 — отрезаем левую половину и ищем в правой половине;
- нашли число больше 7 — отрезаем правую половину и ищем в левой половине.
Почему это работает? Вспомните про требование к отсортированности массива, и всё встанет на свои места.
Итак, смотрим в середину. Карандаш будет служить нам указателем.
В середине находится число 5, оно меньше 7. Значит, отрезаем левую половину и проверенное число. Смотрим в середину оставшегося массива:
В середине число 8, оно больше 7. Значит, отрезаем правую половинку и проверенное число. Остаётся число 7 — как раз его мы и искали. Поздравляю!
Теперь давайте попробуем записать это в виде красивого псевдокода. Как обычно, назовём середину mid и будем перемещать «окно наблюдения», ограниченное двумя индексами — low (левая граница) и high (правая граница).
int binary_search(int [] arr, int low, int high, int x): if high >= low: mid = round_down((low + high)/2) if arr[mid] == x: // check middle element return mid else if arr[mid] > x: // recursive check left hals return binary_search(arr, low, mid-1, x) else: // recursive check right half return binary_search(arr, mid+1, high, x) else: return -1 // x was not found
Алгоритм организован рекурсивно, то есть вызывает сам себя на строках 7 и 9. Есть итеративный вариант с циклом, без рекурсии, но он кажется мне уродливым. Если не находим искомый элемент, возвращаем -1. В начале работы алгоритма значение low совпадает с началом массива, а high — с его концом. И они бегут навстречу друг другу…
Чтобы запускать алгоритм, не задумываясь о начальных значениях индексов low и high, можно написать такую функцию-обёртку:
int binary_search(int [] arr, int x): if arr_is_empty: return -1 lower_bound == 0 upper_bound == arr.length -1 return binary_search(arr, lower_bound, upper_bound, x)
Посчитаем сложность бинарного поиска:
Best case. Как и у линейного поиска, лучший случай равен O(1), ведь искомое число может находиться в середине массива, и тогда мы найдём его с первой попытки.
Worst case. Чтобы найти худший случай, нужно ответить на вопрос: «Сколько раз нужно разделить массив на 2, чтобы в нём остался один элемент?» Или найти минимальное число k, при котором справедливо 2^k ≥ n.
Надеюсь, что большинство читателей смогут вычислить k. Но на всякий случай подскажу решение: k = log n по основанию 2 (в алгоритмах практически все логарифмы двоичные). Поэтому worst case бинарного поиска — O(log n).
Average case. Он тоже равен O(log n). И если для линейного поиска average case в два раза лучше, чем worst case, то тут они обычно отличаются всего на несколько шагов.
Часто студенты спрашивают: «Зачем нужен линейный поиск, если бинарный обходит его по всем позициям?» Отвечаю: линейный поиск работает с любыми массивами, а бинарный — только с отсортированными.
Мы дошли до важного принципа: чем сложнее структура данных, тем более быстрые алгоритмы к ней можно применять. Отсортированный массив — более сложная структура, чем неотсортированный. Забегу вперёд и скажу, что сортировка в общем случае требует от O(n * log(n)) до O(n^2) времени.
Создать дополнительные структуры данных несложно, вот только это не бесплатное удовольствие. Они едят много памяти. Как правило, O(n). Отсюда вытекает довольно логичный, но обидный вывод: время и память — «взаимообмениваемые» ресурсы. Алгоритм можно ускорить, пожертвовав памятью, или решать задачу медленно, зато in-place. Кроме того, почти всегда есть промежуточный вариант.
Решить одну и ту же проблему зачастую можно тремя способами. Задача разработчика — выбрать самый подходящий.
Мы рассмотрели два алгоритма и увидели примеры их сложности. Но так и не поговорили о том, как эту сложность определять. Есть три основных способа.
Первый и наиболее часто используемый способ. Именно так мы определяли сложность linear search и binary search. Обобщим эти примеры.
Первый случай. Есть алгоритм some_function, который выполняет действие А, а после него — действие В. На А и В нужно K и J операций соответственно.
int some_function():
action_A() // K operations
action_B() // J operations
В случае последовательного выполнения действий сложность алгоритма будет равна O(K + J), а значит, O(max (K, J)). Например, если А равно n^2, а В — n, то сложность алгоритма будет равна O(n^2 + n). Но мы уже знаем, что нас интересует только самая быстрорастущая часть. Значит, ответ будет O(n^2).
Второй случай. Посчитаем сложность действий или вызова методов в циклах. Размер массива равен n, а над каждым элементом будет выполнено действие А (n раз). А дальше всё зависит от «содержимого» A.
Посчитаем сложность бинарного поиска:
int some_function(int [] arr): n = arr.length for i in 0..n: action_A() // K operations
Если на каждом шаге A работает с одним элементом, то, независимо от количества операций, получим сложность O(n). Если же A обрабатывает arr целиком, то алгоритм совершит n операций n раз. Тогда получим O(n * n) = O(n^2). По такой же логике можно получить O(n * log n), O(n^3) и так далее.
Третий случай — комбо. Для закрепления соединим оба случая. Допустим, действие А требует log(n) операций, а действие В — n операций. На всякий случай напомню: в алгоритмах всегда идёт речь о двоичных логарифмах.
Добавим действие С с пятью операциями и вот что получим:
int some_function(int [] arr): n = arr.length for i in 0..n: for j in 0..n: action_A(arr) // log(n) operations action_B(arr) // n operations action_C() // 5 operations
O(n * (n * log(n) + n) + 5) = O(n^2 * log(n) + n^2 + 5) = O(n^2 * log(n)).
Мы видим, что самая дорогая часть алгоритма — действие А, которое выполняется во вложенном цикле. Поэтому именно оно доминирует в функции.
Есть разновидность определения на глаз — амортизационный анализ. Это относительно редкий, но достойный упоминания гость. В двух словах его можно объяснить так: если на X «дешёвых» операций (например, с O(1)) приходится одна «дорогая» (например, с O(n)), то на большом количестве операций суммарная сложность получится неотличимой от O(1).
Частый пациент амортизационного анализа — динамический массив. Это массив, который при переполнении создаёт новый, больше оригинального в два раза. При этом элементы старого массива копируются в новый.
Практически всегда добавление элементов в такой массив «дёшево» — требует лишь одной операции. Но когда он заполняется, приходится тратить силы: создавать новый массив и копировать N старых элементов в новый. Но так как массив каждый раз увеличивается в два раза, переполнения случаются всё реже и реже, поэтому average case добавления элемента равен O(1).
Слабое место прикидывания на глаз — рекурсия. С ней и правда приходится тяжко. Поэтому для оценки сложности рекурсивных алгоритмов широко используют мастер-теорему.
По сути, это набор правил по оценке сложности. Он учитывает, сколько новых ветвей рекурсии создаётся на каждом шаге и на сколько частей дробятся данные в каждом шаге рекурсии. Это если вкратце.
Метод Монте-Карло применяют довольно редко — только если первые два применить невозможно. Особенно часто с его помощью описывают производительность систем, состоящих из множества алгоритмов.
Суть метода: берём алгоритм и гоняем его на случайных данных разного размера, замеряем время и память. Полученные измерения выкладываем на отдельные графики для памяти и времени. А затем автоматически вычисляется функция, которая лучше всего описывает полученное облако точек.
На протяжении всей статьи мы говорили про Big O Notation. А теперь сюрприз: это только одна из пяти существующих нотаций. Вот они слева направо: Намджун, Чонгук, Чингачгук… простите, не удержался. Сверху вниз: Small o, Big O, Big Theta, Big Omega, Small omega. f — это реальная функция сложности нашего алгоритма, а g — асимптотическая.
Несколько слов об этой весёлой компании:
- Big O обозначает верхнюю границу сложности алгоритма. Это идеальный инструмент для поиска worst case.
- Big Omega (которая пишется как подкова) обозначает нижнюю границу сложности, и её правильнее использовать для поиска best case.
- Big Theta (пишется как О с чёрточкой) располагается между О и омегой и показывает точную функцию сложности алгоритма. С её помощью правильнее искать average case.
- Small o и Small omega находятся по краям этой иерархии и используются в основном для сравнения алгоритмов между собой.
«Правильнее» в данном контексте означает — с точки зрения математических пейперов по алгоритмам. А в статьях и рабочей документации, как правило, во всех случаях используют «Большое „О“».
Если хотите подробнее узнать об остальных нотациях, посмотрите интересный видос на эту тему. Также полезно понимать, как сильно отличаются скорости возрастания различных функций. Вот хороший cheat sheet по сложности алгоритмов и наглядная картинка с графиками оттуда:
Хоть картинка и наглядная, она плохо передаёт всю бездну, лежащую между функциями. Поэтому я склепал таблицу со значениями времени для разных функций и N. За время одной операции взял 1 наносекунду:
В последнем разделе поговорим о скрытых константах. Это хитрая штука, там собака зарыта.
Возьмём умножение матриц. При размерности матрицы n * n наивный алгоритм, который многие знают с начальных курсов универа, «строчка на столбик» имеет кубическую временную сложность O(n^3). Кубическая, зато честная. Без констант и O(10000 * n^3) под капотом. И памяти не ест — только время.
У Валерия есть отдельный тред об умножении матриц.
В некоторых алгоритмах умножения матриц степень n сильно «порезана». Самые «быстрые» из них выдают время около O(n^2,37). Какой персик, правда? Почему бы нам не забыть про «наивный» алгоритм?
Проблема в том, что у таких алгоритмов огромные константы. В пейперах гонятся за более компактной экспонентой, а степени отбрасываются. Я не нашёл внятных значений констант. Даже авторы оригинальных пейперов называют их просто «очень большими».
Давайте от балды возьмём не очень большую константу 100 и сравним n^3 c 100 * n^2,37. Правая функция даёт выигрыш по сравнению с левой для n, начинающихся с 1495. А ведь мы взяли довольно скромную константу. Подозреваю, что на практике они не в пример больше…
В то же время умножение матриц 1495 × 1495 — очень редкий случай. А матрицы миллиард на миллиард точно нигде не встретишь. Да простит меня Император Душнил с «Хабра» за вольное допущение
Такие алгоритмы называются галактическими, потому что дают выигрыш только на масштабах, нерелевантных для нас. А в программном умножении матриц, если я правильно помню курс алгоритмов и умею читать «Википедию», очень любят алгоритм Штрассена с его O(2,807) и маленькими константами. Но и те, к сожалению, жрут слишком много памяти.
Во время своей работы программы используют различные структуры данных и алгоритмы, в связи с чем обладают разной эффективностью и скоростью решения задачи. Дать оценку оптимальности решения, реализованного в программе, поможет понятие вычислительной сложности алгоритмов.
Содержание
-
Основные понятия
-
Асимптотические нотации
-
Верхняя оценка и (O)-нотация
-
-
Оценка сложности алгоритмов
-
Операции над структурами данных
-
Список и кортеж
-
Множество
-
Словарь
-
-
Закон сложения и умножения для (O)-нотации
-
-
Сравнение производительности работы алгоритмов
6.1.1. Основные понятия¶
Вычислительная сложность (алгоритмическая сложность) — понятие, обозначающее функцию зависимости объема работы алгоритма от размера обрабатываемых данных.
Вычислительная сложность пытается ответить на центральный вопрос разработки алгоритмов: как изменится время исполнения и объем занятой памяти в зависимости от размера входных данных?. С помощью вычислительной сложности также появляется возможность классификации алгоритмов согласно их производительности.
В качестве показателей вычислительной сложности алгоритма выступают:
-
Временная сложность (время выполнения).
Временная сложность алгоритма — это функция от размера входных данных, равная количеству элементарных операций, проделываемых алгоритмом для решения экземпляра задачи указанного размера.
Временная сложность алгоритма зачастую может быть определена точно, однако в большинстве случаев искать точное ее значение бессмысленно, т.к. работа алгоритма зависит от ряда факторов, например, скорости процессора, набора его инструкций и т.д.
-
Асимптотическая сложность.
Асимптотическая сложность оценивает сложность работы алгоритма с использованием асимптотического анализа.
Алгоритм с меньшей асимптотической сложностью является более эффективным для всех входных данных.
6.1.2. Асимптотические нотации¶
Асимптотическая сложность алгоритма описывается соответствующей нотацией:
-
О-нотация, (O) («О»-большое): описывает верхнюю границу времени (время выполнения «не более, чем…»);
-
Омега-нотация, (Omega) («Омега»-большое): описывает нижнюю границу времени (время выполнения «не менее, чем…»).
Например,
[T(n) = O(N^{2})]
говорит о том, что алгоритм имеет квадратичное время выполнения относительно размера входных данных в качестве верхней оценки («О большое от эн квадрат»).
Каждая оценка при этом может быть:
-
наилучшая: минимальная временная оценка;
-
наихудшая: максимальная временная оценка;
-
средняя: средняя временная оценка.
При оценке, как правило, указывается наихудшая оценка.
Примечание
Допустим, имеется задача поиска элемента в массиве. При полном переборе слева направо:
-
наилучшая оценка: (O(1)), если искомый элемент окажется в начале списка;
-
наихудшая оценка: (O(N)), если искомый элемент окажется в конце списка;
-
средняя оценка: (O left ( cfrac{N}{2} right ) = O(N)).
6.1.2.1. Верхняя оценка и (O)-нотация¶
Наиболее часто используемой оценкой сложности алгоритма является верхняя (наихудшая) оценка, которая обычно выражается с использованием нотации O-большое.
Выделяют следующие основные категории алгоритмической сложности в (O)-нотации:
-
Постоянное время: (O(1)).
Время выполнения не зависит от количества элементов во входном наборе данных.
Пример: операции присваивания, сложения, взятия элемента списка по индексу и др.
-
Линейное время: (O(N)).
Время выполнения пропорционально количеству элементов в коллекции.
Пример: найти имя в телефонной книге простым перелистыванием, почистить ковер пылесосом и т.д.
-
Логарифмическое время: (O(log{N})).
Время выполнения пропорционально логарифму от количества элементов в коллекции.
Пример: найти имя в телефонной книге (используя двоичный поиск).
-
Линейно-логарифмическое время: (O(N log{N})).
Время выполнения больше чем, линейное, но меньше квадратичного.
Пример: обработка (N) телефонных справочников двоичным поиском.
-
Квадратичное время: (O(N^{2})).
Время выполнения пропорционально квадрату количества элементов в коллекции.
Пример: вложенные циклы (сортировка, перебор делителей и т.д.).
На Рисунке 6.1.1 приведен график роста (O)-большое.
Рисунок 6.1.1 — График роста (O)-большое 6¶
6.1.3. Оценка сложности алгоритмов¶
Для оценки вычислительной сложности алгоритмов необходимо знать и учитывать сложности:
-
используемых структур данных;
-
совокупности различных операций.
6.1.3.1. Операции над структурами данных¶
В Python имеются коллекции (структуры данных), операции над которыми имеют определенную сложность.
6.1.3.1.1. Список и кортеж¶
Большинство операций со списком/кортежем имеют сложность (O(N)) (Таблица 6.1.1).
Операция |
Сложность |
Примечание |
---|---|---|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
Аналогично |
|
(O(1)) |
Аналогично |
|
(O(b-a)) |
|
|
(O(len(…))) |
Зависит от длины аргумента |
|
(O(len(…))) |
Зависит от длины аргумента |
|
(O(N)) |
|
|
(O(N)) |
|
|
(O(N)) |
|
|
(O(N)) |
|
|
(O(N)) |
Поиск в списке |
|
(O(N)) |
Аналогично |
|
(O(N)) |
Точнее (O(N)) |
|
(O(N)) |
|
|
(O(N)) |
|
|
(O(N)) |
|
|
(O(N log{N})) |
Направление сортировки не играет роли |
|
(O(k cdot N)) |
6.1.3.1.2. Множество¶
По сравнению со списком/кортежем множества большую часть операций выполняют со сложностью (O(1)) (Таблица 6.1.2).
Операция |
Сложность |
Примечание |
---|---|---|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
В отличие от списка, где (O(N)) |
|
(O(1)) |
В отличие от списка, где (O(N)) |
|
(O(1)) |
|
|
(O(1)) |
В отличие от списка, где (O(N)) |
|
(O(1)) |
Аналогично |
|
(O(len(…))) |
Зависит от длины аргумента |
|
(O((len(s))) |
Аналогично |
|
(O(len(s))) |
|
|
(O(len(t))) |
|
|
(O(len(s)+len(t))) |
|
|
(O(len(s)+len(t))) |
|
|
(O(len(s)+len(t))) |
|
|
(O(len(s)+len(t))) |
|
|
(O(N)) |
|
|
(O(N)) |
6.1.3.1.3. Словарь¶
Большинство операций словарей имеет сложность (O(1)) (Таблица 6.1.3).
Операция |
Сложность |
Примечание |
---|---|---|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
|
|
(O(1)) |
Аналогично |
|
(O(1)) |
|
|
(O(len(…))) |
Зависит от длины аргумента |
|
(O(N)) |
Для всех методов: keys(), values(), items() |
Примечание
Важно выбирать структуру данных, которая была бы оптимальной для конкретной задачи.
Например, если приложения будет осуществлять частый поиск информации, словарь даст большую эффективность, при этом, если необходимо просто хранить упорядоченный набор данных — словарь или кортеж подойдут лучше.
6.1.3.2. Закон сложения и умножения для (O)-нотации¶
Для оценки сложности совокупности операций используются законы сложения и умножения.
-
Закон сложения: итоговая сложность двух последовательных действий равна сумме их сложностей:
[O(f(n)) + O(g(n)) = O(f(n) + g(n))]
Особенности:
итоговая сложность алгоритма оценивается наихудшим из слагаемых:
[O(N) + O(log{n}) = O(N + log{n}) = O(N)]
в итоговой сложности константы отбрасываются
[O(N) + O(N) + O(N) = 3 cdot O(N) = O(N)]
при ветвлении берется наихудший вариант
if test: # O(t) block 1 # O(b1) else: block 2 # O(b2)[O(N) = O(t) + max(O(b1), O(b2))]
-
Закон умножения: итоговая сложность двух вложенных действий равна произведению их сложностей:
[O(f(n)) + O(g(n)) = O(f(n) cdot g(n))]
# Общая O(N^2) for i in range(N): # O(N) for j in range(N): # O(N)
6.1.4. Сравнение производительности работы алгоритмов¶
Как правило существует не один алгоритм, позволяющий решить требуемую задачу, поэтому при разработке алгоритма необходимо учитывать его вычислительную сложность.
В Листинге 6.1.1 приведен пример решений одной и той же задачи, используя алгоритмы с разной алгоритмической сложностью.
Листинг 6.1.1 — Примеры определения сложности алгоритмов | скачать
¶
# 3 алгоритма, выполняющих одну и ту же задачу - проверку, что # все значения списка различны - но имеющие разную вычислительную сложность import random import time def timeit(func, *args, **kw): """Выполнить функицю 'func' с параметрами '*args', '**kw' и вернуть время выполнения в мс.""" time_start = time.time() res = func(*args, **kw) time_end = time.time() return (time_end - time_start) * 1000.0, res def is_unique_1(data): """Вернуть 'True', если все значения 'data' различны. Алгоритм 1: Пробежимся по списку с первого до последнего элемента и для каждого из них проверим, что такого элемента нет в оставшихся справа элементах. Сложность: O(N^2). """ for i in range(len(data)): # O(N) if data[i] in data[i+1:]: # O(N) - срез + in: O(N) + O(N) = O(N) return False # O(1) - в худшем случае не выполнится return True # O(1) def is_unique_2(data): """Вернуть 'True', если все значения 'data' различны. Алгоритм 2: Отсортируем список. Затем, если в нем есть повторяющиеся элементы, они будут расположены рядом — т.о. необходимо лишь попарно их сравнить. Сложность: O(N Log N). """ copy = list(data) # O(N) copy.sort() # O(N Log N) - «быстрая» сортировка for i in range(len(data) - 1): # O(N) - N-1, округлим до O(N) if copy[i] == copy[i+1]: # O(1) - [i] и ==, оба по O(1) return False # O(1) - в худшем случае не выполнится return True # O(1) def is_unique_3(data): """Вернуть 'True', если все значения 'data' различны. Алгоритм 3: Создадим множество из списка, при создании автоматически удалятся дубликаты если они есть. Если длина множества == длине списка, то дубликатов нет. Сложность: O(N). """ aset = set(data) # O(N) return len(aset) == len(data) # O(1) - 2 * len (O(1)) + == O(1) # Проверка res = [["i", "1 (мс.)", "2 (мс.)", "3 (мс.)"]] for i in (100, 1000, 10000, 20000, 30000): # Из 100000 чисел выбираем 'i' случайных data = random.sample(range(-100000, 100000), i) res.append([i, timeit(is_unique_1, data)[0], timeit(is_unique_2, data)[0], timeit(is_unique_3, data)[0]]) print("{:>10} {:>10} {:>10} {:>10}".format(*res[0])) for r in res[1:]: print("{:>10} {:>10.2f} {:>10.2f} {:>10.2f}".format(*r)) # ------------- # Пример вывода: # # i 1 (мс.) 2 (мс.) 3 (мс.) # 100 0.00 0.00 0.00 # 1000 15.01 0.00 0.00 # 10000 1581.05 5.00 2.00 # 20000 7322.86 12.01 2.00 # 30000 35176.36 25.02 8.01
Примечание
При написании алгоритмов стремитесь выбирать решение, обладающее наименьшей вычислительной сложностью.
- 1
-
Sebesta, W.S Concepts of Programming languages. 10E; ISBN 978-0133943023.
- 2
-
Python — официальный сайт. URL: https://www.python.org/.
- 3
-
Python — FAQ. URL: https://docs.python.org/3/faq/programming.html.
- 4
-
Саммерфилд М. Программирование на Python 3. Подробное руководство. — М.: Символ-Плюс, 2009. — 608 с.: ISBN: 978-5-93286-161-5.
- 5
-
Лучано Рамальо. Python. К вершинам мастерства. — М.: ДМК Пресс , 2016. — 768 с.: ISBN: 978-5-97060-384-0, 978-1-491-94600-8.
- 6
-
Знай сложности алгоритмов. URL: https://habrahabr.ru/post/188010/.
Асимптотический анализ
В этой статье вы узнаете, что такое асимптотические нотации, познакомитесь с О-нотацией, нотациями Тета и Омега.
Эффективность алгоритма зависит от следующих параметров: время выполнения, объем памяти и других ресурсов, необходимых для его выполнения. Эффективность измеряется с помощью асимптотических нотаций.
Производительность алгоритма может отличаться при работе с различными типами данных. С увеличением количества входных данных она также изменяется.
Асимптотический анализ — метод изучения производительности алгоритмов при различных объемах и типах входных данных.
Асимптотические нотации
Асимптотические нотации — это графики функций. Они служат для описания времени работы алгоритма, когда размер входных данных стремится к определенному значению или пределу.
Возьмем, к примеру, сортировку пузырьком. Когда массив, который мы вводим, уже отсортирован, время выполнения алгоритма линейно — это лучший случай.
Но если массив отсортирован в обратном порядке, то время выполнения будет максимальным (квадратичное). То есть, это худший случай.
Если входной массив не находится в этих двух состояниях, то время выполнения алгоритма среднее. Длительность выполнения алгоритма описывается асимптотическими нотациями.
В основном используются три нотации:
- большое «О»,
- омега-нотация,
- тета-нотация.
Большое «О»
Большое «О» — это верхняя граница скорости выполнения алгоритма. Эта нотация показывает скорость алгоритма в худшем случае.
O(g(n)) = { f(n): существуют такие константы c и n0,
что 0 ≤ f(n) ≤ cg(n) для любых n ≥ n0 }
Это выражение — описание функции f(n)
. Она принадлежит множеству O(g(n))
, если существует положительная константа c, при которой f(n)
лежит в промежутке от 0 до cg(n)
при достаточно больших n
.
Для любого n функция времени выполнения алгоритма не пересечет функцию O(g(n))
.
Так как эта нотация дает нам представления о худшей скорости выполнения алгоритма, то ее анализ обязателен — нам всегда интересна эта характеристика.
Омега нотация (Ω-нотация)
Омега нотация — противоположность большому «О». Она показывает нижнюю границу скорости выполнения алгоритма. Она описывает лучший случай выполнения алгоритма.
Ω(g(n)) = { f(n): существуют такие положительные константы c и n0,
что 0 ≤ cg(n) ≤ f(n) для всех n ≥ n0 }
Это выражение — описание функции f(n)
. Она лежит в множестве Ω(g(n))
, если существует такая положительная константа c
, при которой f(n)
лежит выше cg(n)
при достаточно больших n
.
Для любого n
минимальное время выполнения алгоритма задается как Ω(g(n))
.
Тета-нотация (Θ-нотация)
Тета-нотация объединяет в себе сразу две функции — верхнюю и нижнюю. Эта нотация отражает и верхнюю, и нижнюю границу скорости выполнения алгоритма. Именно поэтому она используется для анализа средней скорости выполнения алгоритма.
Для функции g(n)
Θ(g(n))
задается следующим образом:
Θ(g(n)) = { f(n): существуют такие положительные константы c1, c2 и n0,
что 0 ≤ c1g(n) ≤ f(n) ≤ c2g(n) для любых n ≥ n0 }
Это выражение — описание функции f(n)
. Она принадлежит множеству Θ(g(n))
, если существуют положительные константы с1
и с2
такие, что f(n)
лежит между c1g(n)
и c2g(n)
при достаточно больших n
.
Анализ сравнения затрат времени алгоритмов, выполняемых решение экземпляра некоторой задачи, при больших объемах входных данных, называется асимптотическим. Алгоритм, имеющий меньшую асимптотическую сложность, является наиболее эффективным.
В асимптотическом анализе, сложность алгоритма – это функция, позволяющая определить, как быстро увеличивается время работы алгоритма с увеличением объёма данных.
Основные оценки роста, встречающиеся в асимптотическом анализе:
- Ο (О-большое) – верхняя асимптотическая оценка роста временной функции;
- Ω (Омега) – нижняя асимптотическая оценка роста временной функции;
- Θ (Тета) – нижняя и верхняя асимптотические оценки роста временной функции.
Пусть n – величина объема данных. Тогда рост функции алгоритма f(n) можно ограничить функций g(n) асимптотически:
Обозначение | Описание |
f(n) ∈ Ο(g(n)) | f ограничена сверху функцией g с точностью до постоянного множителя |
f(n) ∈ Ω(g(n)) | f ограничена снизу функцией g с точностью до постоянного множителя |
f(n) ∈ Θ(g(n)) | f ограничена снизу и сверху функцией g |
Например, время уборки помещения линейно зависит от площади этого самого помещения (Θ(S)), т. е. с ростом площади в n раз, время уборки увеличиться также в n раз. Поиск имени в телефонной книге потребует линейного времени Ο(n), если воспользоваться алгоритмом линейного поиска, либо времени, логарифмически зависящего от числа записей (Ο(log2(n))), в случае применения двоичного поиска.
Для нас наибольший интерес представляет Ο-функция. Кроме того, в последующих главах, сложность алгоритмов будет даваться только для верхней асимптотической границы.
Под фразой «сложность алгоритма есть Ο(f(n))» подразумевается, что с увеличением объема входных данных n, время работы алгоритма будет возрастать не быстрее, чем некоторая константа, умноженная на f(n).
Важные правила асимптотического анализа:
- O(k*f) = O(f) – постоянный множитель k (константа) отбрасывается, поскольку с ростом объема данных, его смысл теряется, например:
O(9,1n) = O(n)
- O(f*g) = O(f)*O(g) – оценка сложности произведения двух функций равна произведению их сложностей, например:
O(5n*n) = O(5n)*O(n) = O(n)*O(n) = O(n*n) = O(n2)
- O(f/g)=O(f)/O(g) – оценка сложности частного двух функций равна частному их сложностей, например:
O(5n/n) = O(5n)/O(n) = O(n)/O(n) = O(n/n) = O(1)
- O(f+g) равна доминанте O(f) и O(g) – оценка сложности суммы функций определяется как оценка сложности доминанты первого и второго слагаемых, например:
O(n5+n10) = O(n10)
Подсчет количества операций – дело утомительное и, что важно, совсем не обязательное. Исходя из выше перечисленных правил, чтобы определить сложность алгоритма, не нужно, как мы это делали прежде, считать все операции, достаточно знать какой сложностью обладает та или иная конструкция алгоритма (оператор или группа операторов). Так, алгоритм, не содержащий циклов и рекурсий, имеет константную сложность O(1). Сложность цикла, выполняющего n итераций, равна O(n). Конструкция их двух вложенных циклов, зависящих от одной и той же переменной n, имеет квадратичную сложность O(n2).
Вот наиболее часто встречающиеся классы сложности:
- O(1) – константная сложность;
- О(n) – линейная сложность;
- О(nа) – полиномиальная сложность;
- О(Log(n)) – логарифмическая сложность;
- O(n*log(n)) – квазилинейная сложность;
- O(2n) – экспоненциальная сложность;
- O(n!) – факториальная сложность.
Похожие записи:
- Основы анализа сложности алгоритмов.