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

Алгоритм – это точное предписание, однозначно определяющее вычислительный процесс, ведущий от варьируемых начальных данных к искомому результату [1].

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

Модель RAM (Random Access Machine)

Каждое вычислительное устройство имеет свои особенности, которые могут влиять на длительность вычисления. Обычно при разработке алгоритма не берутся во внимание такие детали, как размер кэша процессора или тип многозадачности, реализуемый операционной системой. Анализ алгоритмов проводят на модели абстрактного вычислителя, называемого машиной с произвольным доступом к памяти (RAM).

Модель состоит из памяти и процессора, которые работают следующим образом:

  • память состоит из ячеек, каждая из которых имеет адрес и может хранить один элемент данных;
  • каждое обращение к памяти занимает одну единицу времени, независимо от номера адресуемой ячейки;
  • количество памяти достаточно для выполнения любого алгоритма;
  • процессор выполняет любую элементарную операцию (основные логические и арифметические операции, чтение из памяти, запись в память, вызов подпрограммы и т.п.) за один временной шаг;
  • циклы и функции не считаются элементарными операциями.

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

Подсчет операций. Классы входных данных

Одним из способов оценки трудоемкости (\(T_n\)) является подсчет количества выполняемых операций. Рассмотрим в качестве примера алгоритм поиска минимального элемента массива.

начало; поиск минимального элемента массива array из N элементов
min := array[1]
для i от 2 до N выполнять:
  если array[i] < min
    min := array[i]
конец; вернуть min

При выполнении этого алгоритма будет выполнена:

  1. N – 1 операция присваивания счетчику цикла i нового значения;
  2. N – 1 операция сравнения счетчика со значением N;
  3. N – 1 операция сравнения элемента массива со значением min;
  4. от 1 до N операций присваивания значения переменной min.

Точное количество операций будет зависеть от обрабатываемых данных, поэтому имеет смысл говорить о наилучшем, наихудшем и среднем случаях. При этом худшему случаю всегда уделяется особое внимание, в том числе потому, что “плохие” данные могут быть намеренно поданы на вход злоумышленником.

Понятие среднего случая используется для оценки поведения алгоритма с расчетом на то, что наборы данных равновероятны. Однако, такая оценка достаточно сложна:

  1. исходные данные разбиваются на группы так, что трудоемкость алгоритма (\(t_i\)) для любого набора данных одной группы одинакова;
  2. исходя из доли наборов данных группы в общем числе наборов, рассчитывается вероятность для каждой группы (\(p_i\));
  3. оценка среднего случая вычисляется по формуле: \(\sum\limits_{i=1}^m p_i\cdot t_i\).

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

Подсчет количества операций позволяет сравнить эффективность алгоритмов. Однако, аналогичный результат можно получить более простым путем. Анализ проводят с расчетом на достаточно большой объем обрабатываемых данных (\( n \to \infty \)), поэтому ключевое значение имеет скорость роста функции сложности, а не точное количество операций.

При анализе скорости роста игнорируются постоянные члены и множители в выражении, т.е. функции \(f_x = 10 \cdot x^2 + 20 \) и \( g_x = x^2\) эквивалентны с точки зрения скорости роста. Незначащие члены лишь добавляют “волнистости”, которая затрудняет анализ.

В оценке алгоритмов используются специальные асимптотические обозначения, задающие следующие классы функций:

  • \(\mathcal{O}(g)\) – функции, растущие медленнее чем g;
  • \(\Omega(g)\) – функции, растущие быстрее чем g;
  • \(\Theta(g)\) – функции, растущие с той же скоростью, что и g.

Запись \(f_n = \mathcal{O}(g_n)\) означает принадлежность функции f классу \(\mathcal{O}(g)\), т.е. функция f ограничена сверху функцией g для достаточно больших значений аргумента. \(\exists n_0 > 0, c > 0 : \forall n > n_0, f_n \leq c \cdot g_n\).

Ограниченность функции g снизу функцией f записывается следующим образом: \(g_n =\Omega(f_n)\). Нотации \(\Omega\) и \(\mathcal{O}\) взаимозаменяемы: \(f_n = \mathcal{O}(g_n) \Leftrightarrow g_n =\Omega(f_n)\).

asymptotic notation_Omega

Асимптотические обозначения “О большое” и “Омега большое”

Если функции f и g имеют одинаковую скорость роста (\(f_n = \Theta(g_n)\)), то существуют положительные константы \(c_1\) и \(c_2\) такие, что \(\exists n_0 > 0 : \forall n > n_0, f_n \leq c_1 \cdot g_n, f_n \geq c_2 \cdot g_n\). При этом \(f_n = \Theta(g_n) \Leftrightarrow g_n = \Theta(f_n)\).

asymptotic notation_Theta

Асимптотическое обозначение “Тета большое”

Примеры анализа алгоритмов

Алгоритм поиска минимального элемента массива, приведенный выше, выполнит N итераций цикла. Трудоемкость каждой итерации не зависит от количества элементов массива, поэтому имеет сложность \(T^{iter} = \mathcal{O}(1)\). В связи с этим, верхняя оценка всего алгоритма \(T^{min}_n = \mathcal{O}(n) \cdot \mathcal{O}(1) = \mathcal{O}(n \cdot 1) = \mathcal{O}(n)\). Аналогично вычисляется нижняя оценка сложности, а в силу того, что она совпадает с верхней – можно утверждать \(T^{min}_n = \Theta(n) \).

Алгоритм пузырьковой сортировки (bubble sort) использует два вложенных цикла. Во внутреннем последовательно сравниваются пары элементов и если оказывается, что элементы стоят в неправильном порядке – выполняется перестановка. Внешний цикл выполняется до тех пор, пока в массиве найдется хоть одна пара элементов, нарушающих требуемый порядок [2].

1. начало; пузырьковая сортировка массива array из N элементов
2. nPairs := N; количество пар элементов
3. hasSwapped := false; пока что ни одна пара не нарушила порядок
4. для всех i от 1 до nPairs-1 выполнять:
5.   если array[i] > array[i+1] то:
6.     swap(array[i], array[i+1]); обменять элементы местами
7.     hasSwapped := true; найдена перестановка
8. nPairs := nPairs - 1; наибольший элемент гарантированно помещен в конец
9. если hasSwapped = true - то перейти на п.3
10. конец; массив array отсортирован

Трудоемкость функции swap не зависит от количества элементов в массиве, поэтому оценивается как \(T^{swap} = \Theta(1) \). В результате выполнения внутреннего цикла, наибольший элемент смещается в конец массива неупорядоченной части, поэтому через N таких вызовов массив в любом случае окажется отсортирован. Если же массив отсортирован, то внутренний цикл будет выполнен лишь один раз.

Таким образом:

  • \(T^{bubble}_n = \mathcal{O}(\sum\limits_{i=1}^n \sum\limits_{j=1}^{n-i} 1) = \mathcal{O}(\sum\limits_{i=1}^n n) = \mathcal{O}(n ^2)\);
  • \(T^{bubble}_n = \Omega(1 \cdot \sum\limits_{j=1}^{n-i} 1) = \Omega(n)\).

В алгоритме сортировки выбором массив мысленно разделяется на упорядоченную и необработанную части. На каждом шаге из неупорядоченной части массива выбирается минимальный элемент и добавляется в отсортированную часть [2].

начало; selection_sort - сортировка массива array из N элементов методом выбора
для всех i от 1 до N выполнять:
  imin := indMin(array, N, i)
  swap(array[i], array[imin])
конец; массив array отсортирован

Для поиска наименьшего элемента неупорядоченной части массива используется функция indMin, принимающая массив, размер массива и номер позиции, начиная с которой нужно производить поиск. Анализ сложности этой функции можно выполнить аналогично тому, как это сделано для функции min – количество операций линейно зависит от количества обрабатываемых элементов: \( T^{indMin}_{n, i} = \Theta(n – i)\).

У сортировки выбором нет ветвлений, которые могут внести различия в оценку наилучшего и наихудшего случаев, ее трудоемкость: \(T^{select}_n = \Theta(\sum\limits_{i=1}^{n} (T^{indMin}_{n, i} + T^{swap})) =\Theta(\sum\limits_{i=1}^{n} (n-i)) = \Theta(\frac{n-1}{2} \cdot n) = \Theta(n^2) \).

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

Выше были рассмотрены асимптотические обозначения, используемые при анализе скорости роста. Они позволяют существенно упростить задачу отбросив значительную часть выражения. Однако, в математическом анализе имеется целый ворох таких приемов.

Формулы суммирования

В примерах, рассмотренных выше, мы уже сталкивались с нетривиальными формулами суммирования. Чтобы дать оценку суммы можно использовать ряд известных тождеств:

  • вынос постоянного множителя за знак суммы: \(\sum\limits_{i=1}^{n} (c \cdot f_i) = c \cdot \sum\limits_{i=1}^{n} \cdot f_i\);
  • упрощение суммы составного выражения: \(\sum\limits_{i=1}^{n} (f_i + g_i) = \sum\limits_{i=1}^{n} (f_i) + \sum\limits_{i=1}^{n} (g_i)\);
  • сумма чисел арифметической прогрессии: \(\sum\limits_{i=1}^{n} (i^p) = \Theta(n^{p+1})\);
  • сумма чисел геометрической прогрессии: \(\sum\limits_{i=0}^{n} (a^i) = \frac {a \cdot (a^{n+1} – 1)}{a – 1}\). При \( n \to \infty \) возможны 2 варианта:
    • если a < 1, то сумма стремится к константе: \(\Theta(1)\);
    • если a > 1, то сумма стремится к бесконечности: \(\Theta(a^{n+1})\);
    • если a = 0, формула неприменима, сумма стремится к N: \(\Theta(n)\);
  • логарифмическая сложность алгоритма: \(\sum\limits_{i=1}^{n} \frac{1}{i} = \Theta(\log n)\);
  • сумма логарифмов: \(\sum\limits_{i=1}^{n} \log i = \Theta(n \cdot \log n)\).

Первые из этих тождеств достаточно просты, их можно вывести используя метод математической индукции. Если под знаком суммы стоит более сложная формула, как в двух последних тождествах – суммирование можно заменить интегрированием (взять интеграл функции гораздо проще, ведь для этого существует широкий спектр приемов).

Суммирование и интегрирование

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

На рисунках приведен пример аппроксимации функции \(f_x = \log x\) левыми и правыми прямоугольниками. Очевидно, они дадут верхнюю и нижнюю оценку площади под графиком:

  • \(\int\limits_a^n f_x dx \leq \sum\limits_{i=a }^{n} f_i \leq \int\limits_{a+1}^{n+1} f_x dx\) для возрастающих функций;
  • \(\int\limits_a^n f_x dx \geq \sum\limits_{i=a }^{n} f_i \geq \int\limits_{a+1}^{n+1} f_x dx\) для убывающих функций.

В частности, такой метод позволяет оценить алгоритмы, имеющие логарифмическую сложность (две последние формулы суммирования).

Сравнение сложности алгоритмов. Пределы

Сложность алгоритмов определяется для больших объемов обрабатываемых данных, т.е. при \(n\to\infty\). В связи с этим, при сравнении трудоемкости двух алгоритмов можно рассмотреть предел отношения функций их сложности: \(\lim\limits_{n \to \infty} \frac {f_n}{g_n}\). В зависимости от значения предела возможно сделать вывод относительно скоростей роста функций:

  • предел равен константе и не равен нулю, значит функции растут с одной скоростью:\(f_n = \Theta(g_n)\);
  • предел равен нулю, следовательно \(g_n\) растет быстрее чем \(f_n\): \(f_n = \mathcal{O}(g_n)\);
  • предел равен бесконечности, \(g_n\) растет медленнее чем \(f_n\): \(f_n = \Omega(g_n)\).

Если функции достаточно сложны, то такой прием значительно упрощает задачу, т.к. вместо предела отношения функций можно анализировать предел отношения их производных (согласно правилу Лопиталя): \(\lim\limits_{n \to \infty} \frac {f_n}{g_n} = \lim\limits_{n \to \infty} \frac {f’_n}{g’_n}\). Правило Лопиталя можно использовать если функции обладают следующими свойствами:

  • \(\lim\limits_{n \to \infty} \frac {f_n}{g_n} = \frac{0}{0} или \frac {\infty}{\infty} \);
  • \( f_n \) и \(g_n\) дифференцируемы;
  • \( g’_n \ne 0 \) при \(n \to \infty\);
  • \(\exists \lim\limits_{n \to \infty} \frac {f’_n}{g’_n}\).

Допустим, требуется сравнить эффективность двух алгоритмов с оценками сложности \(\mathcal{O}(a^n)\) и \(\mathcal{O}(n^b)\), где a и b – константы. Известно, что \((c^x)’ =c^x \cdot ln(c)\), но \((x^c)’ = c \cdot x^{c-1} \). Применим правило Лопиталя к пределу отношения наших функций b раз, получим: \(\lim\limits_{n \to \infty} \frac {\mathcal{O}(a^n)}{\mathcal{O}(n^b)} = \lim\limits_{n \to \infty} \frac {\mathcal{O}(a^n * ln^b (a))}{\mathcal{O}(b!)} = \infty\). Значит функция \(a^n\) имеет более высокую скорость роста. Без использования пределов и правила Лопиталя такой вывод может казаться не очевидным.

Логарифмы и сложность алгоритмов. Пример

По определению \(\log_a{x} = b \Leftrightarrow x = a^b\). Необходимо знать, что \(x \to \infty \Rightarrow \log_a{x} \to \infty\), однако, логарифм – это очень медленно растущая функция. Существует ряд формул, позволяющих упрощать выражения с логарифмами:

  • \(\log_a{x \cdot y} = \log_a{x} \cdot \log_a{ y}\);
  • \(\log_a{x^b} = b \cdot \log_a{ x}\);
  • \(\log_a{x} =\frac{\log_b{x}}{\log_b{a}}\).

При анализе алгоритмов обычно встречаются логарифмы по основанию 2, однако основание не играет большой роли, поэтому зачастую не указывается. Последняя формула позволяет заменить основание логарифма, домножив его на константу, но константы отбрасываются при асимптотическом анализе.

Логарифмической сложностью обладают алгоритмы, для которых не требуется обрабатывать все исходные данные, они используют принцип “разделяй и властвуй”. На каждом шаге часть данных (обычно половина) отбрасывается. Примером может быть функция поиска элемента в отсортированном массиве (двоичного поиска):

начало; поиск элемента E в отсортированном по возрастанию массиве array из N элементов
left := 1, right := N; левая и правая граница, в которых находится искомый элемент
выполнять пока left =< right:
  mid := (right + left) div 2; вычисление индекса элемента посередине рассматриваемой части массива
  если array[mid] = E
    конец; вернуть true (элемент найден)
  если array[mid] < E; искомый элемент больше середины, значит все элементы левее mid можно отбросить
    left := mid + 1;
  иначе
    right := mid
конец; вернуть false (элемент не найден)

Очевидно, что на каждом шаге алгоритма количество рассматриваемых элементов сокращается в 2 раза. Количество элементов, среди которых может находиться искомый, на k-том шаге определяется формулой \(\frac{n}{2^k}\). В худшем случае поиск будет продолжаться пока в массиве не останется один элемент, т.е. чтобы определить количество итераций для верхней оценки, достаточно решить уравнение \(1 = \frac{n}{2^k} \Rightarrow 2^i = n \Rightarrow i = \log_2 n\). Следовательно, алгоритм имеет логарифмическую сложность: \(T^{binSearch}_n = \mathcal{O}(\log n)\).

Результаты анализа. Замечания

Оценка сложности – замечательный способ не только сравнения алгоритмов, но и прогнозирования времени их работы. Никакие тесты производительности не дадут такой информации, т.к. зависят от особенностей конкретного компьютера и обрабатывают конкретные данные (не обязательно худший случай).

Анализ алгоритмов позволяет определить минимально возможную трудоемкость, например:

  • алгоритм поиска элемента в неупорядоченном массиве не может иметь верхнюю оценку сложности лучше, чем \(\mathcal{O}(n)\). Это связано с тем, что невозможно определить отсутствие элемента в массиве (худший случай) не просмотрев все его элементы;
  • возможно формально доказать, что не возможен алгоритм сортировки произвольного массива, работающий лучше чем \(\mathcal{O}(n \cdot \log n)\) [5].

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

Список использованных источников

  1. Васильев В. С. Алгоритм. Свойства алгоритма [Электронный ресурс] – режим доступа: https://pro-prof.com/archives/578. Дата обращения: 06.01.2014.
  2. Васильев В. С. Блок-схемы алгоритмов сортировки пузырьком, выбором и вставками [Электронный ресурс] – режим доступа: https://pro-prof.com/archives/1462. Дата обращения: 06.01.2014.
  3. Дж. Макконелл Анализ алгоритмов. Активный обучающий подход. — 3-е дополненное издание. М: Техносфера, 2009. -416с.
  4. Миллер, Р. Последовательные и параллельные алгоритмы: Общий подход / Р. Миллер, Л. Боксер ; пер. с англ. — М. : БИНОМ. Лаборатория знаний, 2006. — 406 с.
  5. Скиена С. Алгоритмы. Руководство по разработке. 2-е изд.: Пер. с англ. — СПб.: БХВ-Петербург. 2011. — 720 с.: ил.

7 thoughts on “Анализ сложности алгоритмов. Примеры

    1. admin Post author

      Здравствуйте. Я думаю, надо брать примерные вопросы с экзаменов прошлый лет и готовиться к ним.
      Даже ЕГЭ по информатике не меняется за год кардинально. Часть задач будет полностью совпадать, в другой части задач переставят слова местами. Я не думаю, что в части А меняется больше 10% вопросов, если что-то и меняется – то часть С.

      С другой стороны, я смотрел ЕГЭ по информатике и точно знаю, что некоторые вопросы по теме сетей из части А не ответит 90% системных администраторов с высшим образованием (и даже сертифицированные специалисты какой-нибудь Cisco). Вопросы даже не на зазубривание – там (редко но бывает) спрашивают невероятные тонкости, которые есть в справочнике и поэтому даже профессионалы, занимающиеся этим каждый день не ответят.

      В общем, ЕГЭ – это странная штука. Задачи части В и С там продумываются тщательно и к ним стоит готовиться по тестам из предыдущих экзаменов. К части А особо готовиться ИМХО не стоит, ну можно прорешать десяток тестов и разобрать ошибки. 70% вопросов будут примерно совпадать с теми что попадутся вам на экзамене, но от косяков ЕГЭ (которые я тут выше описал) никто не застрахован, но их процент и вес очень маленький.

      На блоге есть статьи, которые могут помочь, смотрите ветку по алгоритмам, например – материалы могут быть полезны при решении части B и C, но я не пишу про сети, системы счисления и многие другие вопросы, которые выносятся на экзамен.

  1. Антон

    несколько замечаний:

    • в алгоритме сортировки пузырьком во второй строке должно быть N (n большое), а в шестой должно быть “i+1” вместо “j”
    • описанный “алгоритм сортировки выбором” сломается на массиве [1, 2], т.к. на первом же шаге будет выбран индекс 2 (минимальное число из набора [2]), и результатом “сортировки” окажется массив [2, 1]. Т.е. нужно в стоке 3 заменить “i+1” на “i”.
    • в оценке сложности алгоритма сортировки выбором Tswap должно быть внутри суммы, т.к. эта операция выполняется на каждом шаге цикла, а не один раз
    • формула суммы геометрической прогрессии – неверная: должно быть a^n вместо a^(n+1). В начале там стоит “c”, а потом везде “a”. И неправильно описан случай a = 1, когда эта формула не применима: должно быть O(n), а не O(1)
  2. Anton

    Доброго времени суток. Вы уверены в этом?

    предел равен нулю, следовательно gn растет быстрее чем fn: fn=Ω(gn);

    если gn растет быстрей, почему она ограничивает снизу?

    предел равен бесконечности, gn растет медленнее чем fn: fn=O(gn).

    Аналогично первому.

    1. admin Post author

      Здравствуйте. Насколько я понял, Ваш вопрос относится к анализу функций при помощи пределов. Я перечитал соответствующий раздел статьи, словами там все было описано верно (включая выводы), но в обозначениях была ошибка. Там рассматривается предел отношения функций:
      [plain collapse=”false”]lim(fn/gn), при n стремящемся к бесконечности.[/plain]
      Обозначение fn=Ω(gn) означает, что fn растет быстрее чем gn. При этом очевидно, что:
      – функция gn ограничена сверху функцией fn, а функция fn ограничена снизу функцией gn;
      – предел отношения будет равен бесконечности, т.к. при большом значении n значение функции fn будет гораздо больше чем gn.
      С fn=O(gn) все с точностью наоборот.

      Спасибо за внимательное чтение статьи.

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