Большой О, как это вычислить / приблизить?

Большинство людей со степенью в CS, безусловно , знают , что Big O означает . Это помогает нам измерить, насколько хорошо алгоритм масштабируется.

Но мне любопытно, как вы рассчитываете или приближаете сложность ваших алгоритмов?

Ответов (23)

Решение

Я сделаю все, что в моих силах, чтобы объяснить это здесь простым языком, но имейте в виду, что эта тема займет у моих учеников пару месяцев, чтобы, наконец, понять. Дополнительную информацию можно найти в главе 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, нам понадобится асимптотический анализ функции. Это делается примерно так:

  1. Убери все константы C.
  2. Из f()получи многочлен в свой standard form.
  3. Разделите члены полинома и отсортируйте их по скорости роста.
  4. Держите тот, который становится больше при 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 для ее тела.

Теперь суммирование можно упростить с помощью некоторых правил идентификации:

  1. Суммирование (w от 1 до N) (C) = N * C
  2. Суммирование (w от 1 до N) (A (+/-) B) = Суммирование (w от 1 до N) (A) (+/-) Суммирование (w от 1 до N) (B)
  3. Суммирование (w от 1 до N) (w * C) = C * Суммирование (w от 1 до N) (w) (C - постоянная, не зависящая от w)
  4. Суммирование (w от 1 до N) (w) = (N * (N + 1)) / 2

Применяя некоторую алгебру:

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, и объедините с помощью операторов большого O. Это единственный способ, о котором я знаю.

Для получения дополнительной информации посетите страницу Википедии по этой теме.

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

Несколько примеров того, как это используется в коде C.

Скажем, у нас есть массив из n элементов

int array[n];

If we wanted to access the first element of the array this would be O(1) since it doesn't matter how big the array is, it always takes the same constant time to get the first item.

x = array[0];

If we wanted to find a number in the list:

for(int i = 0; i < n; i++){
    if(array[i] == numToFind){ return i; }
}

This would be O(n) since at most we would have to look through the entire list to find our number. The Big-O is still O(n) even though we might find our number the first try and run through the loop once because Big-O describes the upper bound for an algorithm (omega is for lower bound and theta is for tight bound).

When we get to nested loops:

for(int i = 0; i < n; i++){
    for(int j = i; j < n; j++){
        array[j] += 2;
    }
}

This is O(n^2) since for each pass of the outer loop ( O(n) ) we have to go through the entire list again so the n's multiply leaving us with n squared.

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

Я думаю, что в целом менее полезно, но для полноты картины существует также Big Omega Ω , определяющая нижнюю границу сложности алгоритма, и Big Theta Θ , которая определяет как верхнюю, так и нижнюю границы.

В первом случае внутренний цикл выполняется n-i раз, поэтому общее количество выполнений является суммой i перехода от 0 к n-1 (потому что меньше, не меньше или равно) n-i . Получишь, наконец n*(n + 1) / 2, так O(n²/2) = O(n²) .

Для 2-го цикла i находится между 0 и n включен для внешнего цикла; тогда внутренний цикл выполняется, когда j он строго больше 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, которые зависят от размеров массива и рассуждения, основанные на ваших структурах данных, какой тип ввода приведет к тривиальным случаям и какой результат будет в худшем случае.

Начнем с самого начала.

Прежде всего, примите принцип, согласно которому некоторые простые операции с данными могут выполняться во O(1) времени, то есть во времени, которое не зависит от размера входных данных. Эти примитивные операции в C состоят из

  1. Арифметические операции (например, + или%).
  2. Логические операции (например, &&).
  3. Операции сравнения (например, <=).
  4. Операции доступа к структуре (например, индексирование массива, например A [i], или указатель, следующий за оператором ->).
  5. Простое присвоение, такое как копирование значения в переменную.
  6. Вызов функций библиотеки (например, scanf, printf).

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

  1. Операторы присваивания, не содержащие в своих выражениях вызовов функций.
  2. Прочтите заявления.
  3. Напишите операторы, которые не требуют вызовов функций для оценки аргументов.
  4. Операторы перехода break, continue, goto и return expression, где выражение не содержит вызова функции.

В 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, где разница между конечным и начальным значениями, деленная на величину, на которую увеличивается индексная переменная, говорит нам, сколько раз мы обходим цикл . Этот счетчик является точным, если нет способов выйти из цикла с помощью оператора перехода; в любом случае это верхняя граница количества итераций.

Например, цикл 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 и проверять, <n на каждой итерации, заключая, что каждая итерация внешнего цикла занимает время O (n).

Инициализация i = 0 внешнего цикла и (n + 1) -й тест условия i <n также занимают время O (1) и им можно пренебречь. Наконец, мы наблюдаем, что мы обходим внешний цикл n раз, принимая время O (n) для каждой итерации, что дает общее O(n^2) время выполнения.


Более практичный пример.

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

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

Также хочу добавить, как это делается для рекурсивных функций :

предположим, у нас есть такая функция ( код схемы ):

(define (fac n)
    (if (= n 0)
        1
            (* n (fac (- n 1)))))

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

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

Таким образом, производительность для тела: O (1) (постоянная).

Затем попробуйте определить это количество рекурсивных вызовов . В этом случае у нас есть n-1 рекурсивный вызов.

Таким образом, производительность для рекурсивных вызовов: O (n-1) (порядок равен n, поскольку мы отбрасываем незначительные части).

Затем соедините их вместе, и тогда у вас будет производительность для всей рекурсивной функции:

1 * (п-1) = О (п)


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

Я хотел бы объяснить Big-O в несколько ином аспекте.

Big-O - это просто сравнение сложности программ, что означает, насколько быстро они растут при увеличении входных данных, а не точное время, затрачиваемое на выполнение действия.

ИМХО, в формулах большого O вам лучше не использовать более сложные уравнения (вы можете просто придерживаться тех, что на следующем графике.) Однако вы все равно можете использовать другие более точные формулы (например, 3 ^ n, n ^ 3, .. .), но иногда это может ввести в заблуждение! Так что лучше сделать это как можно проще.

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

Хочу еще раз подчеркнуть, что здесь мы не хотим получать точную формулу нашего алгоритма. Мы только хотим показать, как он растет при увеличении входных данных, и сравнить с другими алгоритмами в этом смысле. В противном случае вам лучше использовать другие методы, например, сравнительный анализ.

Знакомство с алгоритмами / структурами данных, которые я использую, и / или быстрый анализ вложенности итераций. Сложность заключается в том, что вы вызываете библиотечную функцию, возможно, несколько раз - вы часто можете не знать, вызываете ли вы функцию время от времени без надобности или какую реализацию они используют. Возможно, у библиотечных функций должна быть мера сложности / эффективности, будь то Big O или какая-то другая метрика, доступная в документации или даже в IntelliSense .

Обозначение 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 - это BigInteger в Java или что-то, что может обрабатывать сколь угодно большие числа. Большинство людей, не дрогнув, скажут, что это алгоритм O (n). Причина в том, что у вас есть n итераций в цикле for, а O (1) работает в стороне от цикла.

Но числа Фибоначчи велики, n-е число Фибоначчи экспоненциально по n, поэтому простое его сохранение займет порядка n байтов. Выполнение сложения с большими целыми числами потребует O (n) объема работы. Таким образом, общий объем работы, проделанной в этой процедуре, составляет

1 + 2 + 3 + ... + n = n (n-1) / 2 = O (n ^ 2)

Итак, этот алгоритм работает в квадратическом времени!

В основном то, что возникает в 90% случаев, - это просто анализ петель. У вас есть одинарные, двойные, тройные вложенные петли? У вас есть время работы O (n), O (n ^ 2), O (n ^ 3).

Очень редко (если вы не пишете платформу с обширной базовой библиотекой (например, .NET BCL или C++ STL), вы встретите что-то более сложное, чем просто просмотр ваших циклов (для операторов, while, goto, так далее...)

Небольшое напоминание: эта big O запись используется для обозначения асимптотической сложности (то есть, когда размер проблемы увеличивается до бесконечности), и она скрывает константу.

Это означает, что между алгоритмом в O (n) и алгоритмом в O (n 2 ) самый быстрый не всегда является первым (хотя всегда существует значение n, такое, что для задач размера> n первый алгоритм будет самый быстрый).

Обратите внимание, что скрытая константа очень сильно зависит от реализации!

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

Есть разные временные сложности:

  • Худший случай (обычно самый простой для выяснения, хотя и не всегда значимый)
  • Средний случай (обычно гораздо сложнее выяснить ...)

  • ...

Хорошим введением является «Введение в анализ алгоритмов » Р. Седжвика и П. Флажоле.

Как вы говорите, premature optimisation is the root of all evil и (если возможно) профилирование действительно всегда следует использовать при оптимизации кода. Это даже может помочь вам определить сложность ваших алгоритмов.

Помимо использования основного метода (или одной из его специализаций), я тестирую свои алгоритмы экспериментально. Это не может доказать, что достигается какой-либо конкретный класс сложности, но может дать уверенность в правильности математического анализа. Чтобы обрести такую ​​уверенность, я использую инструменты покрытия кода в сочетании с моими экспериментами, чтобы убедиться, что я прорабатываю все кейсы.

В качестве очень простого примера скажем, что вы хотите проверить работоспособность скорости сортировки списка .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);

Хотя знание того, как вычислить время 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 простым алгоритмом

O (c n ) - Нахождение (точного) решения задачи коммивояжера с использованием динамического программирования; определение эквивалентности двух логических операторов с помощью грубой силы

O (n!) - Решение задачи коммивояжера с помощью перебора

O (n n ) - часто используется вместо O (n!) Для вывода более простых формул асимптотической сложности.

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

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

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

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

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

Я думаю об этом с точки зрения информации. Любая задача состоит в изучении определенного количества бит.

Ваш основной инструмент - это концепция точек принятия решения и их энтропии. Энтропия точки принятия решения - это средняя информация, которую она вам дает. Например, если программа содержит точку принятия решения с двумя ветвями, ее энтропия представляет собой сумму вероятности каждой ветви, умноженную на лог 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 бит. Итак, если вы можете выполнить поиск с помощью операторов ЕСЛИ, которые имеют равновероятные результаты, он должен принять 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 (n), но если вы знаете, что в большинстве списков, которые вы видите, ваше значение указано заранее, типичное поведение вашего алгоритма будет быстрее.

Чтобы понять это, вы должны быть в состоянии описать распределение вероятностей вашего «входного пространства» (если вам нужно отсортировать список, как часто этот список уже будет отсортирован? Как часто он полностью перевернут? Как часто это в основном отсортировано?) Не всегда возможно, чтобы вы это знали, но иногда вы это делаете.

Что касается того, «как вы вычисляете» Big O, это часть теории вычислительной сложности . Для некоторых (многих) особых случаев вы можете использовать некоторые простые эвристики (например, умножение количества циклов для вложенных циклов), особенно. когда все, что вам нужно, - это любая оценка верхней границы, и вы не возражаете, если она будет слишком пессимистичной - что, я думаю, вероятно, о чем ваш вопрос.

Если вы действительно хотите ответить на свой вопрос по какому-либо алгоритму, лучшее, что вы можете сделать, - это применить теорию. Помимо упрощенного анализа «наихудшего случая», я нашел очень полезным на практике амортизированный анализ .

Если ваша стоимость является полиномом, просто оставьте член высшего порядка без его множителя. Например:

O ((N / 2 + 1) * (п / 2)) = О (п 2 /4 + п / 2) = О (п 2 /4) = О (п 2 )

Имейте в виду, что это не работает для бесконечных серий. Единого рецепта для общего случая не существует, хотя для некоторых общих случаев применимы следующие неравенства:

O (журнал N ) <O ( N ) <O ( N журнал N ) <O ( N 2 ) <O ( N k ) <O (e n ) <O ( 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)

Я не знаю, как решить эту проблему программно, но первое, что делают люди, это то, что мы пробуем алгоритм для определенных шаблонов по количеству выполненных операций, скажем, 4n ^ 2 + 2n + 1, у нас есть 2 правила:

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

Если мы упростим f (x), где f (x) - это формула для количества выполненных операций (4n ^ 2 + 2n + 1 объяснено выше), мы получим значение большого O [O (n ^ 2) в этом кейс]. Но это должно учитывать интерполяцию Лагранжа в программе, которую может быть трудно реализовать. А что, если бы реальное значение большого O было O (2 ^ n), и у нас могло бы быть что-то вроде O (x ^ n), поэтому этот алгоритм, вероятно, не был бы программируемым. Но если кто-то докажет, что я ошибаюсь, дайте мне код. . . .