Большинство людей со степенью в CS, безусловно , знают , что Big O означает . Это помогает нам измерить, насколько хорошо масштабируется алгоритм.
Но мне любопытно, как вы рассчитываете или приближаете сложность ваших алгоритмов?
Большинство людей со степенью в CS, безусловно , знают , что Big O означает . Это помогает нам измерить, насколько хорошо масштабируется алгоритм.
Но мне любопытно, как вы рассчитываете или приближаете сложность ваших алгоритмов?
Ответы:
Я сделаю все возможное, чтобы объяснить это здесь простыми терминами, но имейте в виду, что эта тема займет у моих студентов пару месяцев, чтобы наконец понять. Вы можете найти больше информации о Главе 2 книги « Структуры данных и алгоритмы в Java» .
Там нет механической процедуры, которая может быть использована для получения BigOh.
Как «кулинарная книга», чтобы получить BigOh из фрагмента кода, вам сначала нужно понять, что вы создаете математическую формулу для подсчета количества выполненных шагов вычислений с учетом входных данных некоторого размера.
Цель проста: сравнить алгоритмы с теоретической точки зрения без необходимости выполнения кода. Чем меньше количество шагов, тем быстрее алгоритм.
Например, предположим, у вас есть этот кусок кода:
int sum(int* data, int N) {
int result = 0; // 1
for (int i = 0; i < N; i++) { // 2
result += data[i]; // 3
}
return result; // 4
}
Эта функция возвращает сумму всех элементов массива, и мы хотим создать формулу для подсчета вычислительной сложности этой функции:
Number_Of_Steps = f(N)
Итак, у нас f(N)
есть функция для подсчета количества вычислительных шагов. Ввод функции - это размер структуры для обработки. Это означает, что эта функция называется так:
Number_Of_Steps = f(data.length)
Параметр N
принимает data.length
значение. Теперь нам нужно фактическое определение функции f()
. Это делается из исходного кода, в котором каждая интересующая строка пронумерована от 1 до 4.
Есть много способов рассчитать BigOh. Начиная с этого момента, мы будем предполагать, что каждое предложение, которое не зависит от размера входных данных, выполняет C
вычислительные шаги с постоянным числом.
Мы собираемся добавить индивидуальное количество шагов функции, и ни объявление локальной переменной, ни оператор возврата не зависят от размера data
массива.
Это означает, что строки 1 и 4 занимают по C шагов, и функция выглядит примерно так:
f(N) = C + ??? + C
Следующая часть должна определить значение for
заявления. Помните, что мы рассчитываем количество вычислительных шагов, а это означает, что тело for
инструкции получает N
время выполнения . Это тоже самое, что добавить C
, N
раз:
f(N) = C + (C + C + ... + C) + C = C + N * C + C
Не существует механического правила для подсчета того, сколько раз будет выполнено тело объекта for
, вам нужно сосчитать его, посмотрев, что делает код. Чтобы упростить вычисления, мы игнорируем переменные инициализации, условия и части приращения for
оператора.
Чтобы получить реальный BigOh, нам понадобится асимптотический анализ функции. Это примерно сделано так:
C
.f()
достают свой .standard form
N
приближается infinity
.У нас f()
есть два условия:
f(N) = 2 * C * N ^ 0 + 1 * C * N ^ 1
Убираем все C
константы и лишние части:
f(N) = 1 + N ^ 1
Поскольку последний член - это тот, который увеличивается по мере f()
приближения к бесконечности (подумайте об ограничениях ), это аргумент BigOh, а sum()
функция имеет значение BigOh:
O(N)
Есть несколько хитростей, чтобы решить некоторые хитрые: используйте суммирование, когда можете.
Например, этот код может быть легко решен с помощью суммирования:
for (i = 0; i < 2*n; i += 2) { // 1
for (j=n; j > i; j--) { // 2
foo(); // 3
}
}
Первое, что вам нужно было спросить, это порядок исполнения foo()
. В то время как обычно O(1)
, вы должны спросить своих профессоров об этом. O(1)
означает (почти, в основном) постоянную C
, не зависящую от размера N
.
for
Заявление на номер один предложение сложно. В то время как индекс заканчивается в 2 * N
, увеличение сделано двумя. Это означает, что первый for
выполняется только N
шаги, и нам нужно разделить счет на два.
f(N) = Summation(i from 1 to 2 * N / 2)( ... ) =
= Summation(i from 1 to N)( ... )
Предложение номер два еще сложнее, поскольку оно зависит от значения i
. Посмотрите: индекс i принимает значения: 0, 2, 4, 6, 8, ..., 2 * N, и выполняется второе for
: N раз первый, N - 2 второй, N - 4 третий ... до этапа N / 2, на котором второй for
никогда не исполняется.
По формуле это означает:
f(N) = Summation(i from 1 to N)( Summation(j = ???)( ) )
Опять же, мы рассчитываем количество шагов . И по определению, каждое суммирование всегда должно начинаться с единицы и заканчиваться числом, большим или равным единице.
f(N) = Summation(i from 1 to N)( Summation(j = 1 to (N - (i - 1) * 2)( C ) )
(Мы предполагаем, что foo()
есть O(1)
и предпринимаем C
шаги.)
У нас здесь проблема: когда i
значение N / 2 + 1
увеличивается, внутреннее суммирование заканчивается отрицательным числом! Это невозможно и неправильно. Нам нужно разделить суммирование на две части, являясь ключевым моментом, который i
занимает момент N / 2 + 1
.
f(N) = Summation(i from 1 to N / 2)( Summation(j = 1 to (N - (i - 1) * 2)) * ( C ) ) + Summation(i from 1 to N / 2) * ( C )
Начиная с поворотного момента i > N / 2
, внутреннее for
не будет выполнено, и мы предполагаем постоянную сложность выполнения C на его теле.
Теперь суммирования можно упростить, используя некоторые правила идентификации:
w
)Применяя некоторую алгебру:
f(N) = Summation(i from 1 to N / 2)( (N - (i - 1) * 2) * ( C ) ) + (N / 2)( C )
f(N) = C * Summation(i from 1 to N / 2)( (N - (i - 1) * 2)) + (N / 2)( C )
f(N) = C * (Summation(i from 1 to N / 2)( N ) - Summation(i from 1 to N / 2)( (i - 1) * 2)) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2)( i - 1 )) + (N / 2)( C )
=> Summation(i from 1 to N / 2)( i - 1 ) = Summation(i from 1 to N / 2 - 1)( i )
f(N) = C * (( N ^ 2 / 2 ) - 2 * Summation(i from 1 to N / 2 - 1)( i )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N / 2 - 1) * (N / 2 - 1 + 1) / 2) ) + (N / 2)( C )
=> (N / 2 - 1) * (N / 2 - 1 + 1) / 2 =
(N / 2 - 1) * (N / 2) / 2 =
((N ^ 2 / 4) - (N / 2)) / 2 =
(N ^ 2 / 8) - (N / 4)
f(N) = C * (( N ^ 2 / 2 ) - 2 * ( (N ^ 2 / 8) - (N / 4) )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - ( (N ^ 2 / 4) - (N / 2) )) + (N / 2)( C )
f(N) = C * (( N ^ 2 / 2 ) - (N ^ 2 / 4) + (N / 2)) + (N / 2)( C )
f(N) = C * ( N ^ 2 / 4 ) + C * (N / 2) + C * (N / 2)
f(N) = C * ( N ^ 2 / 4 ) + 2 * C * (N / 2)
f(N) = C * ( N ^ 2 / 4 ) + C * N
f(N) = C * 1/4 * N ^ 2 + C * N
И BigOh это:
O(N²)
O(n)
где n
количество элементов, или O(x*y)
где x
и y
размеры массива. Big-oh "относительно ввода", так что это зависит от того, что вы вводите.
Большое О дает верхнюю границу временной сложности алгоритма. Обычно он используется в сочетании с обработкой наборов данных (списков), но может использоваться в другом месте.
Несколько примеров того, как это используется в C-коде.
Скажем, у нас есть массив из n элементов
int array[n];
Если бы мы хотели получить доступ к первому элементу массива, это было бы O (1), поскольку не важно, насколько велик массив, всегда требуется одинаковое постоянное время для получения первого элемента.
x = array[0];
Если мы хотим найти номер в списке:
for(int i = 0; i < n; i++){
if(array[i] == numToFind){ return i; }
}
Это было бы O (n), так как самое большее нам пришлось бы просматривать весь список, чтобы найти наш номер. Big-O по-прежнему равен O (n), хотя мы можем найти наше число с первой попытки и пройти через цикл один раз, потому что Big-O описывает верхнюю границу для алгоритма (омега для нижней границы, а тета для жесткой границы) ,
Когда мы доберемся до вложенных циклов:
for(int i = 0; i < n; i++){
for(int j = i; j < n; j++){
array[j] += 2;
}
}
Это O (n ^ 2), поскольку для каждого прохода внешнего цикла (O (n)) мы должны снова пройти весь список, чтобы n умножалось, оставляя нас с n в квадрате.
Это едва царапает поверхность, но когда вы приступаете к анализу более сложных алгоритмов, в игру вступает сложная математика, включающая доказательства. Надеюсь, это хотя бы знакомит вас с основами.
O(1)
сами по себе. В стандартных API C, например, bsearch
изначально O(log n)
, strlen
есть O(n)
и qsort
есть O(n log n)
(технически это не дает никаких гарантий, а сама быстрая сортировка имеет наихудшую сложность случая O(n²)
, но если предположить, что ваш libc
автор не дебил, его средняя сложность случая равна, O(n log n)
и он использует стратегия выбора опоры, которая уменьшает вероятность попадания в O(n²)
дело). И то, и другое bsearch
и qsort
может быть хуже, если функция сравнения является патологической.
Хотя знание того, как рассчитать время Big O для вашей конкретной проблемы, полезно, знание некоторых общих случаев может иметь большое значение для принятия решений в вашем алгоритме.
Вот некоторые из наиболее распространенных случаев, взятых из http://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions :
O (1) - определение, является ли число четным или нечетным; используя таблицу соответствия постоянного размера или хэш-таблицу
O (logn) - поиск элемента в отсортированном массиве с помощью двоичного поиска
O (n) - поиск элемента в несортированном списке; добавление двух n-значных чисел
O (n 2 ) - Умножение двух n-значных чисел простым алгоритмом; добавление двух n × n матриц; пузырьковая сортировка или вставка
O (n 3 ) - Умножение двух матриц n × n простым алгоритмом
Вывода (с n ) - Нахождение (точного) решения задачи коммивояжера с использованием динамического программирования; определение, если два логических утверждения эквивалентны, используя грубую силу
O (n!) - решение проблемы коммивояжера с помощью перебора
O (n n ) - часто используется вместо O (n!) Для получения более простых формул для асимптотической сложности
x&1==1
для проверки на странность?
x & 1
было бы просто проверить, проверять не нужно == 1
; в C x&1==1
это оценивается как x&(1==1)
приоритет оператора , так что фактически это то же самое, что и тестирование x&1
). Я думаю, что вы неправильно читаете ответ; там есть точка с запятой, а не запятая. Это не говорит о том, что вам нужна таблица поиска для четного / нечетного тестирования, это говорит о том, что как четное / нечетное тестирование, так и проверка таблицы поиска являются O(1)
операциями.
Небольшое напоминание: big O
обозначение используется для обозначения асимптотической сложности (то есть, когда размер задачи увеличивается до бесконечности), и она скрывает константу.
Это означает, что между алгоритмом в O (n) и одним в O (n 2) ) самый быстрый не всегда является первым (хотя всегда существует значение n такое, что для задач размером> n первый алгоритм быстрейший).
Обратите внимание, что скрытая константа очень сильно зависит от реализации!
Кроме того, в некоторых случаях среда выполнения не является детерминированной функцией размера n входных данных. Возьмем, к примеру, сортировку с использованием быстрой сортировки: время, необходимое для сортировки массива из n элементов, не является константой, а зависит от начальной конфигурации массива.
Есть разные временные сложности:
Средний случай (обычно гораздо сложнее выяснить ...)
...
Хорошим введением является Введение в анализ алгоритмов Р. Седжвика и П. Флайолета.
Как вы говорите, premature optimisation is the root of all evil
и (если возможно) профилирование действительно всегда должно использоваться при оптимизации кода. Это может даже помочь вам определить сложность ваших алгоритмов.
Видя ответы здесь, я думаю, мы можем сделать вывод, что большинство из нас действительно приближают порядок алгоритма, взглянув на него, и используют здравый смысл вместо того, чтобы вычислять его, например, с помощью основного метода, как мы думали в университете. С учетом вышесказанного я должен добавить, что даже профессор побуждал нас (позже) на самом деле думать об этом, а не просто рассчитывать.
Также я хотел бы добавить, как это делается для рекурсивных функций :
Предположим, у нас есть такая функция ( код схемы ):
(define (fac n)
(if (= n 0)
1
(* n (fac (- n 1)))))
который рекурсивно вычисляет факториал данного числа.
Первый шаг - попытаться определить характеристику производительности для тела функции, только в этом случае в теле ничего особенного не делается, только умножение (или возвращение значения 1).
Таким образом, производительность для тела: O (1) (постоянная).
Затем попробуйте определить это для количества рекурсивных вызовов . В этом случае у нас n-1 рекурсивных вызовов.
Таким образом, производительность для рекурсивных вызовов: O (n-1) (порядок равен n, так как мы отбрасываем незначительные части).
Затем сложите их вместе, и вы получите производительность для всей рекурсивной функции:
1 * (n-1) = O (n)
Питер , чтобы ответить на твои поднятые вопросы; метод, который я здесь опишу, на самом деле справляется с этим довольно хорошо. Но имейте в виду, что это все же приблизительный, а не полный математически правильный ответ. Описанный здесь метод также является одним из методов, которым нас учили в университете, и, если я правильно помню, использовался для гораздо более сложных алгоритмов, чем факториал, который я использовал в этом примере.
Конечно, все зависит от того, насколько хорошо вы сможете оценить время выполнения тела функции и количество рекурсивных вызовов, но это также верно и для других методов.
Если ваша стоимость является полиномом, просто оставьте член высшего порядка без его множителя. Например:
O ((N / 2 + 1) * (п / 2)) = О (п 2 /4 + п / 2) = О (п 2 /4) = О (п 2 )
Заметьте, это не работает для бесконечных серий. Единого рецепта для общего случая не существует, хотя для некоторых общих случаев применяются следующие неравенства:
O (log N ) <O ( N ) <O ( N log N ) <O ( N 2 ) <O ( N k ) <O (e n ) <O ( n !)
Я думаю об этом с точки зрения информации. Любая проблема состоит в изучении определенного количества битов.
Ваш основной инструмент - это концепция точек принятия решений и их энтропия. Энтропия точки принятия решения - это усредненная информация, которую она вам даст. Например, если программа содержит точку принятия решения с двумя ветвями, ее энтропия - это сумма вероятностей каждой ветви, умноженная на log 2 обратной вероятности этой ветви. Вот как много вы узнаете, выполняя это решение.
Например, if
оператор, имеющий две ветви, обе одинаково вероятны, имеет энтропию 1/2 * log (2/1) + 1/2 * log (2/1) = 1/2 * 1 + 1/2 * 1 = 1. Таким образом, его энтропия равна 1 биту.
Предположим, вы ищете таблицу из N элементов, например, N = 1024. Это 10-битная проблема, потому что log (1024) = 10 бит. Так что, если вы можете искать его с помощью утверждений IF, которые имеют одинаково вероятные результаты, необходимо принять 10 решений.
Вот что вы получаете с помощью бинарного поиска.
Предположим, вы делаете линейный поиск. Вы смотрите на первый элемент и спрашиваете, хотите ли вы. Вероятности составляют 1/1024, а 1023/1024 - нет. Энтропия этого решения составляет 1/1024 * log (1024/1) + 1023/1024 * log (1024/1023) = 1/1024 * 10 + 1023/1024 * около 0 = около 0,01 бита. Вы узнали очень мало! Второе решение не намного лучше. Вот почему линейный поиск такой медленный. На самом деле это экспоненциальное количество бит, которые вам нужно выучить.
Предположим, вы занимаетесь индексацией. Предположим, что таблица предварительно отсортирована по множеству бинов, и вы используете некоторые из всех битов в ключе для индексации непосредственно к записи таблицы. При наличии 1024 бинов энтропия составляет 1/1024 * log (1024) + 1/1024 * log (1024) + ... для всех 1024 возможных результатов. Это 1/1024 * 10 × 1024 результатов или 10 битов энтропии для этой одной операции индексации. Вот почему поиск по индексу происходит быстро.
Теперь подумайте о сортировке. У вас есть N предметов, и у вас есть список. Для каждого элемента вы должны найти, куда он попадает в список, а затем добавить его в список. Таким образом, сортировка занимает примерно N раз количество шагов основного поиска.
Таким образом, сортировки, основанные на бинарных решениях, имеющих примерно одинаково вероятные результаты, занимают около O (N log N) шагов. Алгоритм сортировки O (N) возможен, если он основан на поиске по индексу.
Я обнаружил, что почти все проблемы производительности алгоритмов можно рассматривать таким образом.
Давайте начнем с самого начала.
Прежде всего, примите принцип, что некоторые простые операции с данными могут быть выполнены во O(1)
времени, то есть во времени, которое не зависит от размера ввода. Эти примитивные операции в C состоят из
Обоснование этого принципа требует детального изучения машинных инструкций (примитивных шагов) типичного компьютера. Каждая из описанных операций может быть выполнена с небольшим количеством машинных инструкций; часто требуется только одна или две инструкции. Как следствие, несколько видов операторов в C могут быть выполнены во O(1)
времени, то есть в некоторый постоянный промежуток времени, независимый от ввода. Эти простые включают
В C многие циклы for формируются путем инициализации переменной индекса некоторым значением и увеличения этой переменной на 1 каждый раз вокруг цикла. Цикл for заканчивается, когда индекс достигает некоторого предела. Например, цикл for
for (i = 0; i < n-1; i++)
{
small = i;
for (j = i+1; j < n; j++)
if (A[j] < A[small])
small = j;
temp = A[small];
A[small] = A[i];
A[i] = temp;
}
использует индексную переменную i. Он увеличивает i на 1 каждый раз вокруг цикла, и итерации прекращаются, когда i достигает n - 1.
Однако на данный момент сосредоточимся на простой форме цикла for, где разница между конечным и начальным значениями, деленная на величину, на которую увеличивается индексная переменная, говорит нам, сколько раз мы обходим цикл . Это число является точным, если нет способов выйти из цикла с помощью оператора jump; в любом случае это верхняя граница числа итераций.
Например, цикл for выполняет итерации ((n − 1) − 0)/1 = n − 1 times
, так как 0 - это начальное значение i, n - 1 - это наибольшее значение, достигнутое i (т. Е. Когда i достигает n − 1, цикл останавливается, и итерация не происходит с i = n− 1), и 1 добавляется к i на каждой итерации цикла.
В простейшем случае, когда время, проведенное в теле цикла, одинаково для каждой итерации, мы можем умножить верхнюю границу большого тела для тела на количество раз вокруг цикла . Строго говоря, мы должны затем добавить O (1) время для инициализации индекса цикла и O (1) время для первого сравнения индекса цикла с пределом , потому что мы тестируем еще один раз, чем обходим цикл. Однако, если нет возможности выполнить цикл ноль раз, время для инициализации цикла и однократной проверки предела является членом низкого порядка, который может быть отброшен правилом суммирования.
Теперь рассмотрим этот пример:
(1) for (j = 0; j < n; j++)
(2) A[i][j] = 0;
Мы знаем, что строка (1) требует O(1)
времени. Ясно, что мы обходим цикл n раз, как мы можем определить, вычитая нижний предел из верхнего предела, найденного в строке (1), и затем добавляя 1. Поскольку тело, строка (2), занимает O (1) время, мы можем пренебречь временем увеличения j и временем сравнения j с n, оба из которых также являются O (1). Таким образом, время выполнения строк (1) и (2) является произведением n и O (1) , то есть O(n)
.
Точно так же мы можем ограничить время выполнения внешнего цикла, состоящего из строк (2) - (4), которое
(2) for (i = 0; i < n; i++)
(3) for (j = 0; j < n; j++)
(4) A[i][j] = 0;
Мы уже установили, что цикл линий (3) и (4) занимает O (n) времени. Таким образом, мы можем пренебречь временем O (1), чтобы увеличить i и проверить, является ли i <n в каждой итерации, заключив, что каждая итерация внешнего цикла занимает O (n) времени.
Инициализация i = 0 внешнего цикла и (n + 1) -й тест условия i <n также занимают O (1) время и им можно пренебречь. Наконец, мы видим, что мы обходим внешний цикл n раз, затрачивая время O (n) на каждую итерацию, давая общее
O(n^2)
время выполнения.
Более практичный пример.
Если вы хотите оценить порядок вашего кода эмпирически, а не анализировать код, вы можете использовать ряд возрастающих значений n и время вашего кода. Составьте график времени в логарифмическом масштабе. Если код O (x ^ n), значения должны располагаться на линии наклона n.
Это имеет несколько преимуществ по сравнению с простым изучением кода. Во-первых, вы можете увидеть, находитесь ли вы в диапазоне, где время выполнения приближается к асимптотическому порядку. Кроме того, вы можете обнаружить, что некоторый код, который, по вашему мнению, был порядка O (x), действительно является порядком O (x ^ 2), например, из-за времени, затрачиваемого на библиотечные вызовы.
В основном, вещь, которая возникает в 90% случаев, это просто анализ циклов. У вас есть одинарные, двойные, тройные вложенные циклы? У вас есть O (n), O (n ^ 2), O (n ^ 3) время выполнения.
Очень редко (если вы не пишете платформу с обширной базовой библиотекой (например, .NET BCL или C ++ STL), вы столкнетесь с чем-то более сложным, чем просто просмотр ваших циклов (для операторов while, goto, так далее...)
Обозначение Big O полезно, потому что с ним легко работать и оно скрывает ненужные сложности и детали (для некоторого определения ненужных). Одним из хороших способов определения сложности алгоритмов «разделяй и властвуй» является метод дерева. Допустим, у вас есть версия быстрой сортировки с медианной процедурой, поэтому вы каждый раз разбиваете массив на идеально сбалансированные подмассивы.
Теперь создайте дерево, соответствующее всем массивам, с которыми вы работаете. В корне у вас есть исходный массив, корень имеет двух дочерних элементов, которые являются подмассивами. Повторяйте это до тех пор, пока у вас не будет одноэлементных массивов внизу.
Поскольку мы можем найти медиану за время O (n) и разделить массив на две части за время O (n), работа, выполняемая на каждом узле, - это O (k), где k - это размер массива. Каждый уровень дерева содержит (самое большее) весь массив, поэтому работа на уровень составляет O (n) (размеры подмассивов складываются в n, и, поскольку у нас есть O (k) на уровень, мы можем сложить это) , В дереве есть только уровни log (n), так как каждый раз мы делим входные данные пополам.
Поэтому мы можем ограничить объем работы сверху (O * n log (n)).
Однако Big O скрывает некоторые детали, которые мы иногда не можем игнорировать. Рассмотрим вычисление последовательности Фибоначчи с
a=0;
b=1;
for (i = 0; i <n; i++) {
tmp = b;
b = a + b;
a = tmp;
}
и давайте просто предположим, что a и b являются BigIntegers в Java или что-то, что может обрабатывать произвольно большие числа. Большинство людей сказали бы, что это алгоритм O (n) без дрожания. Причина в том, что у вас есть n итераций в цикле for, а O (1) работает в стороне цикла.
Но числа Фибоначчи большие, n-е число Фибоначчи экспоненциально по n, поэтому простое его сохранение займет порядка n байтов. Выполнение сложения с большими целыми числами потребует O (n) объема работы. Таким образом, общий объем работы, выполненной в этой процедуре
1 + 2 + 3 + ... + n = n (n-1) / 2 = O (n ^ 2)
Так что этот алгоритм работает в квадратическое время!
Я думаю, что в целом он менее полезен, но для полноты картины есть также большая омега Ω , которая определяет нижнюю границу сложности алгоритма, и большая тэта Θ , которая определяет как верхнюю, так и нижнюю границу.
Разбейте алгоритм на части, для которых вы знаете большие обозначения O, и объедините их с помощью больших операторов O. Это единственный способ, которым я знаю.
Для получения дополнительной информации, проверьте страницу Википедии на эту тему.
Знакомство с алгоритмами / структурами данных, которые я использую, и / или быстрый анализ вложенности итераций. Трудность заключается в том, что когда вы вызываете библиотечную функцию, возможно, несколько раз - вы часто можете быть не уверены в том, вызываете ли вы функцию излишне время от времени или какую реализацию они используют. Возможно, библиотечные функции должны иметь меру сложности / эффективности, будь то Big O или какой-либо другой показатель, который доступен в документации или даже IntelliSense .
Что касается того, «как вы рассчитываете» Big O, это часть теории вычислительной сложности . Для некоторых (многих) особых случаев вы можете использовать некоторые простые эвристики (например, подсчет числа циклов для вложенных циклов), особенно. когда все, что вам нужно, это какая-либо верхняя оценка, и вы не возражаете, если она слишком пессимистична - наверное, именно в этом и заключается ваш вопрос.
Если вы действительно хотите ответить на свой вопрос для любого алгоритма, лучшее, что вы можете сделать, это применить теорию. Помимо упрощенного анализа «наихудшего случая» я нашел амортизированный анализ очень полезным на практике.
Для 1-го случая внутренний цикл выполняется n-i
раз, поэтому общее количество выполнений является суммой i
перехода от 0
к n-1
(потому что меньше, не меньше или равно) из n-i
. Вы, наконец n*(n + 1) / 2
, так и получили O(n²/2) = O(n²)
.
Для 2-го цикла i
находится между 0
и n
включен для внешнего цикла; тогда внутренний цикл выполняется, когда j
он строго больше чем n
, что тогда невозможно.
Помимо использования основного метода (или одной из его специализаций), я тестирую свои алгоритмы экспериментально. Это не может доказать, что какой-либо конкретный класс сложности достигнут, но это может дать уверенность в том, что математический анализ уместен. Чтобы помочь с этим заверением, я использую инструменты покрытия кода в сочетании с моими экспериментами, чтобы гарантировать, что я выполняю все случаи.
В качестве очень простого примера скажем, что вы хотите проверить правильность сортировки списка .NET Framework. Вы можете написать что-то вроде следующего, а затем проанализировать результаты в Excel, чтобы убедиться, что они не превышают кривую n * log (n).
В этом примере я измеряю количество сравнений, но также целесообразно изучить фактическое время, необходимое для каждого размера выборки. Однако тогда вы должны быть еще более осторожны, поскольку вы просто измеряете алгоритм и не учитываете артефакты из своей тестовой инфраструктуры.
int nCmp = 0;
System.Random rnd = new System.Random();
// measure the time required to sort a list of n integers
void DoTest(int n)
{
List<int> lst = new List<int>(n);
for( int i=0; i<n; i++ )
lst[i] = rnd.Next(0,1000);
// as we sort, keep track of the number of comparisons performed!
nCmp = 0;
lst.Sort( delegate( int a, int b ) { nCmp++; return (a<b)?-1:((a>b)?1:0)); }
System.Console.Writeline( "{0},{1}", n, nCmp );
}
// Perform measurement for a variety of sample sizes.
// It would be prudent to check multiple random samples of each size, but this is OK for a quick sanity check
for( int n = 0; n<1000; n++ )
DoTest(n);
Не забудьте также учесть космические сложности, которые также могут вызывать беспокойство, если у вас ограниченные ресурсы памяти. Так, например, вы можете услышать, что кто-то хочет использовать алгоритм с постоянным пространством, который, по сути, является способом сказать, что объем пространства, занимаемого алгоритмом, не зависит от каких-либо факторов внутри кода.
Иногда сложность может быть связана с тем, сколько раз что-то вызывается, как часто выполняется цикл, как часто выделяется память, и так далее, это еще одна часть, чтобы ответить на этот вопрос.
Наконец, большой O может использоваться для наихудшего случая, лучшего случая и случаев амортизации, где обычно это наихудший случай, который используется для описания того, насколько плохим может быть алгоритм.
Что часто упускается из виду, так это ожидаемое поведение ваших алгоритмов. Это не меняет Big-O вашего алгоритма , но относится к утверждению «преждевременная оптимизация ...»
Ожидаемое поведение вашего алгоритма - очень глупо - насколько быстро вы можете ожидать, что ваш алгоритм будет работать с данными, которые вы, скорее всего, увидите.
Например, если вы ищете значение в списке, это O (n), но если вы знаете, что большинство списков, которые вы видите, имеют ваше значение заранее, типичное поведение вашего алгоритма быстрее.
Чтобы по-настоящему закрепить это, вам нужно уметь описать распределение вероятностей вашего «пространства ввода» (если вам нужно отсортировать список, как часто этот список уже будет отсортирован? Как часто он полностью переворачивается? Как часто это в основном сортируется?) Не всегда возможно, что вы это знаете, но иногда вы знаете.
отличный вопрос!
Отказ от ответственности: этот ответ содержит ложные утверждения, см. Комментарии ниже.
Если вы используете Big O, вы говорите о худшем случае (подробнее о том, что это значит позже). Кроме того, есть тэта-столица для среднего случая и большая омега для лучшего случая.
Посетите этот сайт, чтобы получить прекрасное формальное определение Big O: https://xlinux.nist.gov/dads/HTML/bigOnotation.html.
f (n) = O (g (n)) означает, что существуют положительные постоянные c и k, такие что 0 ≤ f (n) ≤ cg (n) для всех n ≥ k. Значения c и k должны быть фиксированы для функции f и не должны зависеть от n.
Хорошо, теперь, что мы подразумеваем под сложностями «лучший случай» и «худший случай»?
Это, вероятно, наиболее четко показано на примерах. Например, если мы используем линейный поиск, чтобы найти число в отсортированном массиве, то худший случай - это когда мы решаем искать последний элемент массива, так как для этого потребуется столько шагов, сколько элементов в массиве. Лучший случай будет , когда мы ищем первый элемент , так как мы бы сделали после первой проверки.
Смысл всех этих сложностей прилагательного в том, что мы ищем способ построить график времени, в течение которого гипотетическая программа выполняется до конца, с точки зрения размера определенных переменных. Однако для многих алгоритмов можно утверждать, что не существует единственного времени для определенного размера ввода. Обратите внимание, что это противоречит основному требованию функции, любой вход должен иметь не более одного выхода. Итак, мы придумали несколько функций для описания сложности алгоритма. Теперь, хотя поиск в массиве размера n может занимать различное количество времени в зависимости от того, что вы ищете в массиве и пропорционально n, мы можем создать информативное описание алгоритма, используя лучший случай, средний случай и классы наихудшего случая.
Извините, это так плохо написано и не хватает технической информации. Но, надеюсь, это облегчит думать о классах сложности времени. Как только вы освоитесь с ними, вам станет просто разбирать вашу программу и искать такие вещи, как циклы for, которые зависят от размеров массива и рассуждений, основанных на ваших структурах данных, какой тип ввода приведет к тривиальным случаям, а какой - к получению. в худших случаях.
Я не знаю, как программно решить эту проблему, но первое, что делают люди, это то, что мы выбираем алгоритм для определенных шаблонов по количеству выполненных операций, скажем, 4n ^ 2 + 2n + 1, у нас есть 2 правила:
Если мы упростим f (x), где f (x) - формула для числа выполненных операций (4n ^ 2 + 2n + 1, объясненное выше), мы получим значение big-O [O (n ^ 2) в этом дело]. Но это должно было бы учитывать интерполяцию Лагранжа в программе, что может быть трудно реализовать. И что, если реальное значение big-O было O (2 ^ n), и у нас могло бы быть что-то вроде O (x ^ n), поэтому этот алгоритм, вероятно, не был бы программируемым. Но если кто-то докажет, что я неправ, дайте мне код. , , ,
Для кода A внешний цикл будет выполняться в течение n+1
времени, время «1» означает процесс, который проверяет, соответствует ли я требованию. И внутренний цикл работает n
раз, n-2
раз .... Таким образом,0+2+..+(n-2)+n= (0+n)(n+1)/2= O(n²)
.
Для кода B, хотя внутренний цикл не вступает и не выполняет foo (), внутренний цикл будет выполняться в течение n раз, в зависимости от времени выполнения внешнего цикла, которое равно O (n)
Я хотел бы объяснить Big-O в несколько ином аспекте.
Big-O просто сравнивает сложность программ, которая показывает, насколько быстро они растут, когда увеличиваются входные данные, а не точное время, затрачиваемое на выполнение действия.
ИМХО в формулах big-O лучше не использовать более сложные уравнения (вы можете просто придерживаться приведенных на следующем графике.) Однако вы все равно можете использовать другие более точные формулы (например, 3 ^ n, n ^ 3, .. .) но иногда это может вводить в заблуждение! Так что лучше держать как можно проще.
Я хотел бы еще раз подчеркнуть, что здесь мы не хотим получить точную формулу для нашего алгоритма. Мы только хотим показать, как он растет, когда входы растут, и сравнить в этом смысле с другими алгоритмами. В противном случае вам лучше использовать другие методы, такие как бенчмаркинг.