Какая асимптотическая скорость работы самая медленная
Перейти к содержимому

Какая асимптотическая скорость работы самая медленная

  • автор:

Какая асимптотическая скорость самая медленная?

введите сюда описание изображения

Я ответил неправильно О(3^n), только я не понимаю, почему я не прав? Нас учили, что нужно исключить все несложные асимптотики и оставить самую сложную Получается , по такой логике самый медленный алгоритм О(3^n) или я неправильно всё понял?

Отслеживать

13.7k 12 12 золотых знаков 43 43 серебряных знака 75 75 бронзовых знаков

Асимптотический анализ алгоритмов

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

Во многих работах описывающих те или иные алгоритмы, часто можно встретить обозначения типа:
O(g(n)), &#937(g(n)), &#920(g(n)).
Давайте разбермся, что же это такое, но сначала я перечислю основные классы сложности применяемые при анализе:
f(n) = O(1) константа
f(n) = O(log(n)) логарифмический рост
f(n) = O(n) линейный рост
f(n) = O(n*log(n)) квазилинейный рост
f(n) = O(n^m) полиномиальный рост
f(n) = O(2^n) экспоненциальный рост

Если раньше вы не встречались с подобными обозначениями, не переживайте, после прочтения этой статьи, все станет намного понятнее.
А начнем мы с символа O.

Сначала приведу формальное определение:
(1.1) Запись вида f(n) = O(g(n)) означает, что ф-ия f(n) возрастает медленнее чем ф-ия g(n) при с = с1 и n = N, где c1 и N могут быть сколь угодно большими числами, т.е. При c = c1 и n >= N, c*g(n) >=f(n).
Таким образом O – означает верхнее ограничение сложности алгоритма.

Давайте теперь попробуем применить это знание на практике.

Возьмем известную любому программисту задачу сортировки. Допустим нам необходимо отсортировать массив чисел, размерностью 10 000 000 элементов. Договоримся рассматривать худший случай, при котором для выполнения алгоритма понадобится наибольшее количество итераций. Не долго думая, мы решаем применять сортировку пузырьком как самую простую с точки зрения реализации.
Сортировка пузырьком позволяет отсортировать массив любой размерности без дополнительных выделений памяти. Вроде бы все прекрасно и мы с чистой совестью начинаем писать код (для примеров здесь и далее будет использоваться язык Python).

def bubble_sort(arr):
. . for j in xrange(0, N — 1):
. . . . for i in xrange(0, N — 1):
. . . . . . if(arr[i] > arr[i+1]):
. . . . . . . . tmp = arr[i]
. . . . . . . . arr[i] = arr[i+1]
. . . . . . . . arr[i+1] = tmp

Я намерено не ввожу проверку на наличие хотябы одного обмена (которая кстати не сказывается на O — сложности алгоритма), ради упрощения понимания сути.
Взглянув на код, сразу же обращаем внимание на вложеный цикл. О чем нам это говорит? Я надеюсь, что читатель знаком с основами программирования на любом языке (кроме функциональных, в которых циклы отсутствуют как таковые, а повторения реализуются рекурсией) и наглядно представляет себе, что такое вложеные циклы и как они работают. В данном примере, внешний цикл выполняется ровно n = 10 000 000 (т.к. наш массив состоит из 10 000 000 элементов) и столько же раз выполняется внутренний цикл, из этого очевидно следует, что общее кол-во итераций будет порядка n^2. Т.е. кол-во итераций зависит от размерности входных данных как ф-ия от n^2. Теперь, зная сложность алгоритма, мы можем проверить, насколько хорошо он будет работать в нашем случае. Подставив цифры в формулу n^2 получаем ответ 10 000 000 * 10 000 000 = 10 000 000 000 0000 итераций.
Хммм, впечатляет… В цикле у нас 3 комманды, предположим, что на выполнение каждой из них требуется 5 единиц времени (с1 = 5), тогда общее кол-во времени затраченного на сортировку нашего массива будет равно 5*f(n^2) (синяя кривая на рис.1).

image

рис.1.
зеленая кривая соответствует графику ф-ии x^2 при c = 1, синия c = 5, красная c = 7

Здесь и далее на всех графиках ось абсцисс будет соответствовать размерности входных данных, а ось ординат кол-ву итераций необходимых для выполнения алгоритма.
Нас интересует только та часть координатной плоскости, которая соответствует значениям x большим 0, т.к. любой массив, по-определению, не может иметь отрицательный размер, поэтому, для удобства, уберем левые части графиков ф-ий, ограничившись лишь первой четвертью координатной плоскости.

image

рис.2.
зеленая кривая соответствует графику ф-ии x^2 при c = 1, синия c = 5, красная c = 7

Возьмем c2 = 7. Мы видим, что новая ф-ия растет быстрее предыдущей (красная кривая на рис.2). Из определения (1.1), делаем вывод, что при с2 = 7 и n >= 0, g(n) = 7*n^2 всегда больше или равна ф-ии f(n) = 5*n^2, следовательно наша программа имет сложность O(n^2).
Кто не до конца понял это объяснение, посмотрите на графики ф-ий и заметьте, что ф-ия отмеченная на нем красным, при n >= 0 всегда имеет большее значение по y, чем ф-ия отмеченная синим.

Теперь подумаем, хорошо ли это или плохо в нашем случае и можем ли мы заставить алгоритм работать эффективнее?
Как можно понять из графиков, приведенных на рис. 2, квадратичная ф-ия возрастает довольно быстро и при большом объеме входных данных, сортировка массива таким способом, может занять довольно длительное время, причем увеличение мощности процессора, будет сказываться лишь на коэффициенте c1, но сам алгоритм, по-прежнему будет принадлежать к классу алгоритмов с полиномиальной функцией роста O(n^2) (тут мы опять же используем определение (1.1) и подбираем такие c2 и N при которых с2*n^2 будет возрастать быстрее чем наша ф-ия c1*n^2 начиная с некоторого n >= N) и если в ближайшем будущем изобретут процессор, который будет сортировать наш массив всего за пару секунд, то при немного большем объеме входных данных, этот прирост в производительности будет полностью нивелирован кол-вом необходимых вычислений.
Таким же временным средством является решение реализовать алгоритм на низкоуровневом языке (например ассемблере), так как все, что мы сможем сделать – это, опять же, лишь немного уменьшить коэффициент c1 при этом все-равно оставаясь в классе сложности O(n^2).
А что будет, если вместо одноядерного процессора, мы будем использовать 4-х ядерный? На самом деле, результат изменится не сильно.
Разобьем наш массив на 4 части и каждую часть поручим выполнять отдельному ядру. Что мы получим? На сортировку каждой части понадобится ровно O((n / 4)^2). Так как все части сортируются одновременно, то этот результат и будет конечным временем сортировки четырех подмассивов, размерностью n/4. Возведем получившееся выражение в квадрат, получив таким образом сложность равную O(1/16*n^2 + n), где n — итерации необходимые на сортировку итогового массива полученного конкатенацией четырех готовых подмассивов.
Поскольку анализ у нас асимптотический, то при достаточно больших n, ф-ия n^2 будет вносить гораздо больший вклад в итоговый результат, чем n и поэтому мы спокойно можем им пренебречь, как наиболее медленно возрастающем членом, также за малозначимостью принебрегаем коэффициентом 1/16 (см. (1.1)), что в итоге дает нам все тоже O(n^2).
Мы приходим к неутишительному выводу о том, что увеличением числа процессоров, равно как и повышение их частоты, не дают существенного прироста при данном алгоритме сортировки.

Что же можно сделать в этой ситуации, чтобы радикально ускорить сортировку? Необходимо, чтобы в худшем случае сложность алгоритма была меньше чем O(n^2). Поразмыслив немного, мы решаем, что не плохо бы было, придумать такой алгоритм, сложность которого не превышает O(n), т.е. является линейной. Очевидно, что в случае O(n) скорость работы программы возрастет в N раз, так как вместо N^2 итераций, нам необходимо будет сделать всего лишь N. Прогнозируемый прирост скорости отлично виден на рис.3.

image

Серой прямой обозначена линейная сложность, а три кривых показывают сложность n^2 при различных коэффициентах c.

Но оказывается, что сделать сортировку со сложностью O(n) в общем случае просто не возможно (док-во)! Так на какую же сложность мы в лучшем случае можем расчитывать? Она равна O(n*log(n)), что является теоретически доказаным минимальным верхнем пределом для любого алгоритма сортировки основанного на сравнении элементов. Это конечно немного не то, чего мы ожидали получить, но все же это уже и не O(n^2). Осталось найти алгоритм сортировки удовлетворяющий нашим требованиям.
Покопавшись в интернете, видим, что им удовлетворяет «пирамидальная сортировка». Я не буду вдаваться в подробности алгоритма, желающие могут прочитать о нем самостоятельно на wiki, скажу только, что его сложность принадлежит классу O(n*log(n)) (т.е. максимально возможная производительность для сортировки), но при этом, он довольно труден в реализации.

Посмотрим на графики ниже и сравним скорости возрастания кол-ва вычислений для двух рассмотренных алгоритмов сортировки.

image

рис.4.

Фиолетовая кривая показывает алгоритм со сложностью n*log(n). Видно, что на больших n, пирамидальная сортировка существенно выигрывает у сортировки пузырьком при любом из трех опробованных нами коэффициентах, однако все-равно уступает гипотетической сортировке линейной сложности (серая прямая на графике).
В любом случае, даже на медленном компьютере она будет работать гораздо быстрее, чем «пузырек» на быстром.

Остается открытым вопрос, целесообразно ли всегда предпочитать пирамидальную сортировку сортировке пузырьком?

image

рис.5.

Как видно на рис.5, при малых n, различия между двумя алгоритмами достаточно малы и выгода от использования пирамидальной сортировки — совсем незначительна, а учитывая, что «пузырек» реализуется буквально 5-6 строчками кода, то при малых n, он действительно является более предпочтительным с точки зрения умственных и временных затрат на реализацию 🙂

В заключении, чтобы более наглядно продемонстрировать разницу между классами сложности, приведу такой пример:
Допустим у нас етсь ЭВМ 45-ти летней давности. В голове сразу всплывают мысли о больших шкафах, занимающих довольно-таки обширную площадь. Допустим, что каждая команда на такой машине выполняется примерно за 10 миллисек. С такой скоростью вычислений, имея алгоритм сложности O(n^2), на решение поставленой задачи уйдет оооочень много времени и от затеи придется отказаться как от невыполнимой за допустимое время, если же взять алгоритм со сложностью O(n*log(n)), то ситуация в корне изменится и на расчет уйдет не больше месяца.

Посчитаем, сколько именно займет сортировка массива в обоих случаях

сверх-неоптимальный алгоритм (бывает и такое, но редко):
O(2^n) = оооооочень медленно, вселенная умрет, прежде чем мы закончим наш расчет…
пузырек:
O(n^2) = 277777778 часов (классический “пузырек”)
пирамидальная сортировка:
O(n*log(n)) = 647 часов (то чего мы реально можем добиться, применяя пирамидальную сортировку)
фантастически-эффективный алгоритм:
O(n) = 2.7 часов (наш гипотетический алгоритм, сортирующий за линейное время)
и наконец:
O(log(n)) = оооооочень быстро, жаль, что чудес не бывает…

Два последних случая (хоть они и не возможны в реальности при сортировке данных) я привел лишь для того, чтобы читатель ощутил огромную разницу между алгоритмами различных классов сложности.
На последок хочу заметить, что буквой O обычно обозначают минимальный из классов сложности, которому соответствует данный алгоритм. К примеру, я мог бы сказать, что сложность сортировки пузырьком равна O(2^n) и теоретически это было бы абсолютно верным утверждением, однако на практике такая оценка была бы лишена смысла.

  • algorithms
  • asymptotic analysis

О-символика

О-символика, или асимптотическая запись, — это система символов, позволяющая оценить время выполнения алгоритма, устанавливая зависимость времени выполнения от увеличения объёма входных данных. Она также известна как оценка сложности алгоритмов. Станет ли алгоритм невероятно медленным, когда объём входных данных увеличится? Будет ли алгоритм выполняться достаточно быстро, если объём входных данных возрастёт? О-символика позволяет ответить на эти вопросы.

Можно ли по-другому найти ответы на эти вопросы?

Один способ — это подсчитать число элементарных операций в зависимости от различных объёмов входных данных. Хотя это и приемлемое решение, тот объём работы, которого оно потребует, даже для простых алгоритмов делает его использование неоправданным.

Другой способ — это измерить, какое время алгоритм потребует для завершения на различных объёмах входных данных. В то же время, точность и относительность этого метода (полученное время будет относиться только к той машине, на которой оно вычислено) зависит от среды выполнения: компьютерного аппаратного обеспечения, мощности процессора и т.д.

Виды О-символики

В первом разделе этого документа мы определили, что О-символика позволяет оценивать алгоритмы в зависимости от изменения размера входных данных. Представим, что алгоритм — это функция f, n — размер входных данных и f(n) — время выполнения. Тогда для данного алгоритма f с размером входных данных n получим какое-то результирующее время выполнения f(n). Из этого можно построить график, где ось y — время выполнения, ось x — размер входных данных, а точки на графике — это время выполнения для заданного размера входных данных.

С помощью О-символики можно оценить функцию или алгоритм несколькими различными способами. Например, можно оценить алгоритм исходя из нижней оценки, верхней оценки, тождественной оценки. Чаще всего встречается анализ на основе верхней оценки. Как правило не используется нижняя оценка, потому что она не подходит под планируемые условия. Отличный пример — алгоритмы сортировки, особенно добавление элементов в древовидную структуру. Нижняя оценка большинства таких алгоритмов может быть дана как одна операция. В то время как в большинстве случаев добавляемые элементы должны быть отсортированы соответствующим образом при помощи дерева, что может потребовать обхода целой ветви. Это и есть худший случай, для которого планируется верхняя оценка.

Виды функций, пределы и упрощения

Логарифмическая функция — log n Линейная функция — an + b Квадратичная функция — an^2 + bn +c Степенная функция — an^z + . . . + an^2 + a*n^1 + a*n^0, где z — константа Показательная функция — a^n, где a — константа

Приведены несколько базовых функций, используемых при определении сложности в различных оценках. Список начинается с самой медленно возрастающей функции (логарифм, наиболее быстрое время выполнения) и следует до самой быстро возрастающей функции (экспонента, самое медленное время выполнения). Отметим, что в то время, как «n», или размер входных данных, возрастает в каждой из этих функций, результат намного быстрее возрастает в квадратичной, степенной и показательной по сравнению с логарифмической и линейной.

Крайне важно понимать, что при использовании описанной далее нотации необходимо использовать упрощённые выражения. Это означает, что необходимо отбрасывать константы и слагаемые младших порядков, потому что если размер входных данных (n в функции f(n) нашего примера) увеличивается до бесконечности (в пределе), тогда слагаемые младших порядков и константы становятся пренебрежительно малыми. Таким образом, если есть константа, например, размера 2^9001 или любого другого невообразимого размера, надо понимать, что её упрощение внесёт значительные искажения в точность оценки.

Т.к. нам нужны упрощённые выражения, немного скорректируем нашу таблицу…

Логарифм — log n Линейная функция — n Квадратичная функция — n^2 Степенная функция — n^z, где z — константа Показательная функция — a^n, где a — константа

О Большое

О Большое, записывается как О, — это асимптотическая запись для оценки худшего случая, или для ограничения заданной функции сверху. Это позволяет сделать асимптотическую оценку верхней границы скорости роста времени выполнения алгоритма. Пусть f(n) — время выполнения алгоритма, а g(n) — заданная временная сложность, которая проверяется для алгоритма. Тогда f(n) — это O(g(n)), если существуют действительные константы c (c > 0) и n0, такие, что f(n) 0 (n > n0).

f(n) = 3log n + 100 g(n) = log n

Является ли f(n) O(g(n))? Является ли 3 log n + 100 O(log n)? Посмотрим на определение О Большого:

3log n + 100 

Существуют ли константы c и n0, такие, что выражение верно для всех n > n0?

3log n + 100 2 (не определенно для n = 1)

Да! По определению О Большого f(n) является O(g(n)).

f(n) = 3 * n^2 g(n) = n

Является ли f(n) O(g(n))? Является ли 3 * n^2 O(n)? Посмотрим на определение О Большого:

3 * n^2 

Существуют ли константы c и n0, такие, что выражение верно для всех n > n0? Нет, не существуют. f(n) НЕ ЯВЛЯЕТСЯ O(g(n)).

Омега Большое

Омега Большое, записывается как Ω, — это асимптотическая запись для оценки лучшего случая, или для ограничения заданной функции снизу. Это позволяет сделать асимптотическую оценку нижней границы скорости роста времени выполнения алгоритма.

f(n) является Ω(g(n)), если существуют действительные константы c (c > 0) и n0 (n0 > 0), такие, что f(n) >= c g(n) для всех n > n0.

Примечание

Асимптотические оценки, сделаные при помощи О Большого и Омега Большого, могут как являться, так и не являться точными. Для того, чтобы обозначить, что границы не являются асимптотически точными, используются записи О Малое и Омега Малое.

О Малое

O Малое, записывается как о, — это асимптотическая запись для оценки верхней границы времени выполнения алгоритма при условии, что граница не является асимптотически точной.

f(n) является o(g(n)), если можно подобрать такие действительные константы, что для всех c (c > 0) найдётся n0 (n0 > 0), так что f(n) < c g(n) выполняется для всех n (n >n0).

Определения О-символики для О Большого и О Малого похожи. Главное отличие в том, что если f(n) = O(g(n)), тогда условие f(n) существует константа c > 0, но если f(n) = o(g(n)), тогда условие f(n) < c g(n) выполняется для всех констант c > 0.

Омега Малое

Омега Малое, записывается как ω, — это асимптотическая запись для оценки верхней границы времени выполнения алгоритма при условии, что граница не является асимптотически точной.

f(n) является ω(g(n)), если можно подобрать такие действительные константы, что для всех c (c > 0) найдётся n0 (n0 > 0), так что f(n) > c g(n) выполняется для всех n (n > n0).

Определения Ω-символики и ω-символики похожи. Главное отличие в том, что если f(n) = Ω(g(n)), тогда условие f(n) >= c g(n) выполняется, если существует константа c > 0, но если f(n) = ω(g(n)), тогда условие f(n) > c g(n) выполняется для всех констант c > 0.

Тета

Тета, записывается как Θ, — это асимптотическая запись для оценки асимптотически точной границы времени выполнения алгоритма.

f(n) является Θ(g(n)), если для некоторых действительных констант c1, c2 и n0 (c1 > 0, c2 > 0, n0 > 0) c1 g(n) < f(n) < c2 g(n) для всех n (n >n0).

∴ f(n) является Θ(g(n)) означает, что f(n) является O(g(n)) и f(n) является Ω(g(n)).

О Большое — основной инструмент для анализа сложности алгоритмов. Также см. примеры по ссылкам.

Заключение

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

Дополнительная литература

  • Алгоритмы на Java
  • Алгоритмы. Построение и анализ

Ссылки

  • Оценки времени исполнения. Символ O()
  • Асимптотический анализ и теория вероятностей

Ссылки (англ.)

  • Algorithms, Part I
  • Cheatsheet 1
  • Cheatsheet 2

Хотите предложить свой перевод? Может быть, улучшение перевода? Откройте Issue в репозитории Github или сделайте pull request сами!

Первоначально предоставлено автором Jake Prather, и обновлено 0 автором (-ами).

Асимптотическая нотация

Измерить скорость алгоритма реальным временем — секундами и минутами — непросто. Одна программа может работать медленнее другой не из-за собственной неэффективности, а по причине медлительности операционной системы, несовместимости с оборудованием, занятости памяти компьютера другими процессами…

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

Вот формальное определение:

Пусть f(x) и g(x) — функции, определенные в некой окрестности x0 , причем g там не обращается в 0.

Тогда f является «O» большим от g при (x -> x0) , если существует константа C>0 , что для всех x из некоторой окрестности точки x0 имеет место неравенство

Чуть менее строго:
f является «O» большим от g , если существует константа C>0 , что для всех x > x0

Не бойтесь формального определения! По сути, оно определяет асимптотическое возрастание времени работы программы, когда количество ваших данных на входе растет в сторону бесконечности. Например, вы сравниваете сортировку массива из 1000 элементов и 10 элементов, и нужно понять, как возрастёт при этом время работы программы. Или нужно подсчитать длину строки символов путем посимвольного прохода и прибавлением 1 за каждый символ:

c - 1 a – 2 t - 3

Асимптотическая скорость такого "алгоритма" (последовательного прохода по символам) равна О(n) , где n — количество букв в слове. Это означает, что если считать по такому принципу, время, необходимое для подсчета всей строки, пропорционально количеству символов. Подсчет количества символов в строке из 20 символов занимает вдвое больше времени, нежели при подсчете количества символов в десятисимвольной строке.

Представьте, что в переменной length вы сохранили значение, равное количеству символов в строке. Например, length = 1000. Чтобы получить длину строки, нужен только доступ к этой переменной, на саму строку можно даже не смотреть. И как бы мы ни меняли длину, мы всегда можем обратиться к этой переменной. В таком случае асимптотическая скорость = O(1). От изменения входных данных скорость работы в такой задаче не меняется, поиск завершается за постоянное время. В таком случае программа является асимптотически постоянной.

Недостаток: вы тратите память компьютера на хранение дополнительной переменной и дополнительное время для обновления её значения.

Если вам кажется, что линейное время — это плохо, смеем заверить: бывает гораздо хуже. Например, O(n 2 ) . Такая запись означает, что с ростом n рост времени, нужного для перебора элементов (или другого действия) будет расти всё резче.

При маленьких n алгоритмы с асимптотической скоростью O(n 2 ) могут работать быстрее, чем алгоритмы с асимптотикой О(n) .

Асимптотическая нотация - 1

Но в какой-то момент квадратичная функция обгонит линейную, от этого не уйти.

Еще одна асимптотическая сложность — логарифмическое время или O(log n) . С ростом n эта функция возрастает очень медленно. О(log n) — время работы классического алгоритма бинарного поиска в отсортированном массиве (помните разорванный телефонный справочник на лекции?). Массив делится надвое, затем выбирается та половина, в которой находится элемент и так, каждый раз уменьшая область поиска вдвое, мы ищем элемент.

Асимптотическая нотация - 2

Что будет, если мы сразу наткнемся на искомое, разделив в первый раз наш массив данных пополам? Для лучшего времени есть термин — Омега-большое или Ω(n) .

В случае бинарного поиска = Ω(1) (находит за постоянное время независимо от размера массива).

Линейный поиск работает за время O(n) и Ω(1) , если искомый элемент — самый первый.

Еще один символ — тета (Θ(n)) . Он используется, когда лучший и худший варианты одинаковы. Это подходит для примера, где мы хранили длину строки в отдельной переменной, поэтому при любой её длине достаточно обратиться к этой переменной. Для этого алгоритма лучший и худший варианты равны соответственно Ω(1) и O(1) . Значит, алгоритм работает за время Θ(1) .

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *