Поиск…


замечания

Определение

Обозначение Big-O в основе своей представляет собой математическую нотацию, используемую для сравнения скорости сходимости функций. Пусть n -> f(n) и n -> g(n) - функции, определенные над натуральными числами. Тогда будем говорить, что f = O(g) тогда и только тогда, когда f(n)/g(n) ограничена, когда n стремится к бесконечности. Другими словами, f = O(g) тогда и только тогда, когда существует константа A, такая, что для всех n, f(n)/g(n) <= A

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

Что это значит ?

Возьмем случай f(n) = 100n^2 + 10n + 1 и g(n) = n^2 . Совершенно очевидно, что обе эти функции стремятся к бесконечности, поскольку п стремится к бесконечности. Но иногда знание предела не достаточно, и мы также хотим знать скорость, с которой функции приближаются к их пределу. Такие понятия, как Big-O, помогают сравнивать и классифицировать функции по скорости их конвергенции.

Давайте выясним, если f = O(g) , применяя определение. Мы имеем f(n)/g(n) = 100 + 10/n + 1/n^2 . Так как 10/n равно 10 , когда п равно 1 , и уменьшается, и так как 1/n^2 равен 1 , когда п равно 1 , а также уменьшается, мы F f(n)/g(n) <= 100 + 10 + 1 = 111 . Определение выполняется, потому что мы нашли оценку f(n)/g(n) (111) и, следовательно, f = O(g) (мы говорим, что f является Big-O of n^2 ).

Это означает, что f стремится к бесконечности примерно с той же скоростью, что и g. Теперь это может показаться странным, потому что то, что мы обнаружили, состоит в том, что f в 111 раз больше, чем g, или, другими словами, когда g растет на 1, f растет не более чем на 111. Может показаться, что рост В 111 раз быстрее не «примерно такая же скорость». И действительно, нотация Big-O - не очень точный способ классификации скорости конвергенции функций, поэтому в математике мы используем соотношение эквивалентности, когда хотим точную оценку скорости. Но для разделения алгоритмов на больших скоростных классах Big-O достаточно. Нам не нужно выделять функции, которые растут фиксированное число раз быстрее друг друга, а только функции, которые растут бесконечно быстрее друг друга. Например, если взять h(n) = n^2*log(n) , мы видим, что h(n)/g(n) = log(n) который стремится к бесконечности с n, так что h не является O (n ^ 2), так как h растет бесконечно быстрее, чем n ^ 2.

Теперь мне нужно сделать замечание: вы могли заметить, что если f = O(g) и g = O(h) , то f = O(h) . Например, в нашем случае мы имеем f = O(n^3) и f = O(n^4) ... В анализе сложности алгоритма мы часто говорим, что f = O(g) означает, что f = O(g) и g = O(f) , что можно понимать как «g - наименьший Big-O для f». В математике мы говорим, что такие функции являются биг-тетами друг друга.

Как он используется?

При сравнении производительности алгоритма нас интересует количество операций, выполняемых алгоритмом. Это называется временной сложностью . В этой модели мы считаем, что каждая базовая операция (сложение, умножение, сравнение, присвоение и т. Д.) Занимает фиксированный промежуток времени, и мы подсчитываем количество таких операций. Обычно мы можем выразить это число как функцию размера ввода, которую мы называем n. И, к сожалению, это число обычно растет до бесконечности с n (если это не так, мы говорим, что алгоритм O (1)). Мы разделяем наши алгоритмы на больших скоростных классах, определенных Big-O: когда мы говорим о «алгоритме O (n ^ 2)», мы имеем в виду, что число выполняемых им операций, выраженное как функция n, равно O ( п ^ 2). Это говорит о том, что наш алгоритм примерно такой же быстрый, как алгоритм, который будет выполнять ряд операций, равных квадрату размера его ввода, или быстрее . «Скоростная» часть есть, потому что я использовал Big-O вместо Big-Theta, но обычно люди говорят, что Big-O означает Big-Theta.

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

Быстрое замечание: быстрый алгоритм - это тот, который выполняет несколько операций, поэтому, если число операций возрастает до бесконечности быстрее , то алгоритм работает медленнее : O (n) лучше, чем O (n ^ 2).

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

Простая петля

Следующая функция находит максимальный элемент в массиве:

int find_max(const int *array, size_t len) {
    int max = INT_MIN;
    for (size_t i = 0; i < len; i++) {
        if (max < array[i]) {
            max = array[i];
        }
    }
    return max;
}

Размер ввода - это размер массива, который я назвал len в коде.

Давайте подсчитаем операции.

int max = INT_MIN;
size_t i = 0;

Эти два назначения выполняются только один раз, так что это две операции. Операциями, которые являются петлями, являются:

if (max < array[i])
i++;
max = array[i]

Поскольку в цикле есть три операции, и цикл выполняется n раз, мы добавляем 3n в наши уже существующие 2 операции, чтобы получить 3n + 2 . Таким образом, наша функция выполняет 3n + 2 операции, чтобы найти max (его сложность 3n + 2 ). Это многочлен, где наиболее быстро растущий член является фактором n, поэтому O (n).

Вероятно, вы заметили, что «операция» не очень четко определена. Например, я сказал, что if (max < array[i]) была одна операция, но в зависимости от архитектуры этот оператор может скомпилировать, например, три команды: одно чтение памяти, одно сравнение и одну ветвь. Я также рассматривал все операции как одно и то же, хотя, например, операции с памятью будут медленнее, чем другие, и их производительность будет сильно различаться из-за, например, эффекта кеширования. Я также полностью проигнорировал оператор return, тот факт, что будет создан кадр для функции и т. Д. В конце концов, это не имеет значения для анализа сложности, потому что, как бы я ни выбрал подсчет операций, он изменит только коэффициент n-фактора и константы, поэтому результат все равно будет O (n). Сложность показывает, как алгоритм масштабируется с размером ввода, но это не единственный аспект производительности!

Вложенная петля

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

_Bool contains_duplicates(const int *array, size_t len) {
    for (int i = 0; i < len - 1; i++) {
        for (int j = 0; j < len; j++) {
            if (i != j && array[i] == array[j]) {
                return 1;
            }
        }
    }
    return 0;
}

Внутренний цикл выполняет на каждой итерации ряд операций, которые являются постоянными с n . Внешний цикл также выполняет несколько постоянных операций и запускает внутренний цикл n раз. Сам внешний цикл запускается n раз. Таким образом, операции внутри внутреннего цикла выполняются n^2 раза, операции во внешнем цикле выполняются n раз, а назначение i выполняется один раз. Таким образом, сложность будет чем-то вроде an^2 + bn + c , а так как старший член n^2 , обозначение O(n^2) равно O(n^2) .

Как вы, возможно, заметили, мы можем улучшить алгоритм, избегая повторения одних и тех же сравнений несколько раз. Мы можем начинать с i + 1 во внутреннем цикле, потому что все элементы перед ним уже были проверены на все элементы массива, в том числе на индекс i + 1 . Это позволяет нам отказаться от проверки i == j .

_Bool faster_contains_duplicates(const int *array, size_t len) {
    for (int i = 0; i < len - 1; i++) {
        for (int j = i + 1; j < len; j++) {
            if (array[i] == array[j]) {
                return 1;
            }
        }
    }
    return 0;
}

Очевидно, что эта вторая версия делает меньше операций и, следовательно, более эффективна. Как это переводится в нотацию Big-O? Итак, теперь тело внутреннего цикла запускается 1 + 2 + ... + n - 1 = n(n-1)/2 раза. Это все еще многочлен второй степени, и поэтому остается только O(n^2) . Мы явно снизили сложность, так как мы грубо разделили на 2 числа операций, которые мы делаем, но мы все еще находимся в том же классе сложности, что и Big-O. Чтобы снизить сложность до более низкого класса, нам нужно было бы разделить количество операций на то, что стремится к бесконечности с n .

Пример O (log n)

Вступление

Рассмотрим следующую проблему:

L - отсортированный список, содержащий n значащих целых чисел ( n достаточно большой), например [-5, -2, -1, 0, 1, 2, 4] (здесь n имеет значение 7). Если L как известно, содержит целое число 0, как вы можете найти индекс 0?

Наивный подход

Первое, что приходит на ум, - это просто прочитать каждый индекс до тех пор, пока не будет найдено 0. В худшем случае число операций равно n , поэтому сложность O (n).

Это отлично работает при малых значениях n , но есть ли более эффективный способ?

Дихотомия

Рассмотрим следующий алгоритм (Python3):

a = 0
b = n-1
while True:
  h = (a+b)//2 ## // is the integer division, so h is an integer
  if L[h] == 0:
    return h
  elif L[h] > 0:
    b = h
  elif L[h] < 0:
    a = h

a и b - индексы, между которыми должно быть найдено 0. Каждый раз, когда мы вводим цикл, мы используем индекс между a и b и используем его для сужения области поиска.

В худшем случае нам нужно подождать, пока a и b равны. Но сколько операций это делает? Не n, потому что каждый раз, когда мы вводим цикл, мы делим расстояние между a и b примерно на два. Скорее, сложность O (log n).

объяснение

Примечание. Когда мы записываем «журнал», мы имеем в виду двоичный логарифм или базу данных 2 (которую мы будем писать «log_2»). Поскольку O (log_2 n) = O (log n) (вы можете выполнить математику), мы будем использовать «log» вместо «log_2».

Назовем x числом операций: мы знаем, что 1 = n / (2 ^ x).

Итак, 2 ^ x = n, то x = log n

Заключение

Когда вы сталкиваетесь с последовательными делениями (будь то по два или по любому числу), помните, что сложность логарифмическая.

O (log n) типов алгоритмов

Допустим, у нас проблема размера n. Теперь для каждого шага нашего алгоритма (который нам нужно написать) наша исходная проблема становится половиной ее предыдущего размера (n / 2).

Поэтому на каждом шаге наша проблема становится вдвойне.

шаг проблема
1 п / 2
2 п / 4
3 п / 8
4 п / 16

Когда проблемное пространство сокращается (т. Е. Решается полностью), оно не может быть уменьшено до конца (n становится равным 1) после выхода из условия проверки.

  1. Скажем, на к-м шаге или количестве операций:

    размер проблемы = 1

  2. Но мы знаем на k-м шаге, наш размер проблемы должен быть:

    размер проблемы = n / 2 k

  3. От 1 до 2:

    n / 2 k = 1 или

    n = 2 k

  4. Взять журнал с обеих сторон

    log e n = k log e 2

    или же

    k = log e n / log e 2

  5. Используя формулу log x m / log x n = log n m

    k = log 2 n

    или просто k = log n

Теперь мы знаем, что наш алгоритм может работать максимум до log n, поэтому временная сложность возникает как
O (log n)


Очень простой пример в коде для поддержки вышеприведенного текста:

for(int i=1; i<=n; i=i*2)
{
    // perform some operation
}

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

k = log 2 256

k = log 2 2 8 (=> log a a = 1)

k = 8

Другим очень хорошим примером для подобного случая является алгоритм двоичного поиска .

int bSearch(int arr[],int size,int item){
 int low=0;
 int high=size-1;

 while(low<=high){                 
     mid=low+(high-low)/2;                 
     if(arr[mid]==item)                        
         return mid;                 
     else if(arr[mid]<item)                         
         low=mid+1;                
     else  high=mid-1;      
     }  
  return –1;// Unsuccessful result
}


Modified text is an extract of the original Stack Overflow Documentation
Лицензировано согласно CC BY-SA 3.0
Не связан с Stack Overflow