Великий О, як це обчислити / наблизити?


881

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

Але мені цікаво, як ви обчислюєте чи наближаєте складність своїх алгоритмів?


4
Можливо, вам насправді не потрібно вдосконалювати складність свого алгоритму, але ви повинні принаймні мати можливість обчислити його, щоб вирішити ...
Xavier Nodet

5
Я вважаю це дуже чітким поясненням Big O, Big Omega та Big Theta: xoax.net/comp/sci/algorithms/Lesson6.php
Сем Даттон

33
-1: Зітхання, чергове зловживання BigOh. BigOh - це лише асимптотика верхньої межі, яка може використовуватися для будь-якого, і не стосується лише CS. Якщо говорити про BigOh так, ніби існує один унікальний безглуздий (алгоритм лінійного часу також O (n ^ 2), O (n ^ 3) тощо). Сказати, що це допомагає нам вимірювати ефективність, також вводить в оману. Також, що з посиланням на класи складності? Якщо все, що вас цікавить, це методи обчислення часу виконання алгоритмів, наскільки це актуально?

34
Big-O не вимірює ефективність; він вимірює, наскільки алгоритм масштабується з розміром (він може застосовуватися і до інших речей, ніж розмір, але це, напевно, нас тут цікавить) - і це лише асимптотично, тому якщо вам не пощастить алгоритм з "меншим" великим розміром, O може бути повільніше (якщо Big-O застосовується до циклів), ніж інший, поки ви не досягнете надзвичайно великої кількості.
ILoveFortran

4
Вибір алгоритму на основі його складності Big-O, як правило, є істотною частиною розробки програми. Це, безумовно, не випадок "передчасної оптимізації", яка в будь-якому випадку є сильно зловживаною селективною цитатою.
користувач207421

Відповіді:


1480

Я докладу всіх зусиль, щоб пояснити це простими словами, але попередити, що ця тема потребує моїх учнів через кілька місяців, щоб нарешті зрозуміти. Додаткову інформацію можна знайти у розділі 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не буде виконаний, і ми припускаємо постійну складність виконання С на своєму тілі.

Тепер підсумки можна спростити за допомогою деяких правил ідентичності:

  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²)

6
@arthur Це було б O (N ^ 2), тому що вам знадобиться одна петля для прочитання всіх стовпців і одна для читання всіх рядків певного стовпця.
Abhishek Dey Das

@arthur: Це залежить. Це O(n)де nкількість елементів, або O(x*y)де xі yрозміри масиву. Big-oh - "відносно введення", тому це залежить від того, який ваш вклад.
Mooing Duck

1
Чудова відповідь, але я справді застряг. Як підсумовування (i від 1 до N / 2) (N) перетворюється на (N ^ 2/2)?
Парса

2
@ParsaAkbari Як правило, сума (i від 1 до a) (b) - a * b. Це лише інший спосіб сказати b + b + ... (a times) + b = a * b (за визначенням для деяких визначень цілого множення).
Маріо Карнейро

Не так актуально, але просто для уникнення плутанини в цьому реченні є крихітна помилка: "індекс i приймає значення: 0, 2, 4, 6, 8, ..., 2 * N". Індекс i насправді піднімається до 2 * N - 2, цикл зупиниться тоді.
Альберт

201

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

Кілька прикладів того, як він використовується в коді С.

Скажімо, у нас є масив з 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 (n ^ 2), це означає, що він буде використовувати вкладені петлі?
Navaneeth KN

2
Насправді будь-який аспект, що призводить до n квадратних разів, буде розглядатися як n ^ 2
asyncwait

@NavaneethKN: Ви не завжди побачите вкладений цикл, оскільки виклики функцій можуть робити> O(1)працювати самостійно. Наприклад, у стандартних API API C, bsearchце по суті O(log n), strlenє O(n)і qsortє O(n log n)(технічно він не має гарантій, а сам quicksort має найгірший складний випадок O(n²), але якщо припустити, що ваш libcавтор не є дебілом, його середня складність справи є, O(n log n)і він використовує стратегія вибору стрижня, яка зменшує шанси потрапити у O(n²)справу). І як bsearchі qsortможе бути гірше , якщо функція компаратора патологічна.
ShadowRanger

95

Хоча знаєте, як визначити час 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!) Для отримання більш простих формул для асимптотичної складності


Чому б не використати x&1==1для перевірки на дивацтво?
Самі Бенчеріф

2
@SamyBencherif: Це був би типовий спосіб перевірити (насправді достатньо лише тестування x & 1, не потрібно перевіряти == 1; в C x&1==1оцінюється як x&(1==1) перевагу оператора , тому це насправді те саме, що тестування x&1). Я думаю, що ти неправильно читаєш відповідь; там є напівколонка, а не кома. Це не означає, що вам знадобиться таблиця пошуку для парного / непарного тестування, це говорить, що і парне / непарне тестування, і перевірка таблиці пошуку - це O(1)операції.
ShadowRanger

Я не знаю про претензію на використання в останньому реченні, але хто робить це, замінює клас іншим, що не є еквівалентом. Клас O (n!) Містить, але суворо більший, ніж O (n ^ n). Фактична еквівалентність буде O (n!) = O (n ^ ne ^ {- n} sqrt (n)).
conditionalMethod

43

Невелике нагадування: big Oпозначення використовується для позначення асимптотичної складності (тобто коли розмір проблеми зростає до нескінченності), і він приховує константу.

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

Зауважте, що прихована константа дуже сильно залежить від реалізації!

Крім того, в деяких випадках час виконання не є детермінованою функцією розміру n вводу. Візьмемо для сортування, наприклад, швидке сортування: час, необхідний для сортування масиву з n елементів, не є постійним, а залежить від початкової конфігурації масиву.

Існують різні часові складності:

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

  • ...

Хорошим вступом є Вступ до аналізу алгоритмів Р. Седжевіка та П. Флайолета.

Як ви кажете, premature optimisation is the root of all evilі (якщо можливо) профілювання дійсно завжди слід використовувати при оптимізації коду. Це навіть може допомогти вам визначити складність ваших алгоритмів.


3
У математиці O (.) Означає верхню межу, а theta (.) Означає, що ви маєте обмеження вгорі та внизу. Чи дефініція насправді відрізняється в CS, чи це просто звичайне зловживання позначенням? За математичним визначенням sqrt (n) є і O (n), і O (n ^ 2), тому не завжди буває деякий n, після якого функція O (n) менша.
Дуглас Заре

28

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

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

припустимо, у нас є функція типу ( код схеми ):

(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)


Петро , щоб відповісти на поставлені вами питання; описаний тут метод насправді справляється з цим досить добре. Але майте на увазі, що це все-таки наближення, а не повна математично правильна відповідь. Описаний тут метод також є одним із методів, які нас навчали в університеті, і якщо я добре пам’ятаю, він використовувався для набагато досконаліших алгоритмів, ніж факторіал, який я використовував у цьому прикладі.
Звичайно, все залежить від того, наскільки добре ви можете оцінити час роботи тіла функції та кількість рекурсивних викликів, але це так само справедливо для інших методів.


Свен, я не впевнений, що ваш спосіб судити про складність рекурсивної функції буде працювати для більш складних, таких як пошук пошуку / підсумовування / зверху донизу / щось у бінарному дереві. Звичайно, ви можете міркувати про простий приклад і придумати відповідь. Але я думаю, вам доведеться насправді займатися математикою для рекурсивних?
Пітер

3
+1 для рекурсії ... Також ця прекрасна: "... навіть професор спонукав нас до думки ..." :)
TT_

Так, це так добре. Я схильний вважати це так, чим вище термін всередині O (..), тим більше виконується робота, яку ти / машина робиш. Думати про це, посилаючись на щось, може бути наближенням, але такі межі є. Вони просто розповідають, як збільшується робота, яку потрібно виконати, коли кількість входів збільшується.
Абхінав Гауніял

26

Якщо ваша вартість є многочленом, просто зберігайте термін найвищого порядку без його множника. Наприклад:

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 !)


8
неодмінно O (N) <O (NlogN)
jk.

22

Я думаю про це з точки зору інформації. Будь-яка проблема полягає у вивченні певної кількості біт.

Ваш основний інструмент - це концепція точок вирішення та їх ентропія. Ентропія точки прийняття рішення - це середня інформація, яку вона вам дасть. Наприклад, якщо програма містить точку прийняття рішення з двома гілками, її ентропія - це сума ймовірності кожної гілки, що перевищує 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 біт. Тож якщо ви можете шукати його за твердженнями ІФ, які мають однакові ймовірні результати, слід прийняти 10 рішень.

Ось що ви отримуєте при двійковому пошуку.

Припустимо, ви робите лінійний пошук. Ви дивитесь на перший елемент і запитуєте, чи це той, який ви хочете. Ймовірність становить 1/1024, що є, і 1023/1024, що це не так. Ентропія цього рішення становить 1/1024 * log (1024/1) + 1023/1024 * log (1024/1023) = 1/1024 * 10 + 1023/1024 * приблизно 0 = приблизно .01 біт. Ви навчилися дуже мало! Друге рішення не набагато краще. Ось чому лінійний пошук настільки повільний. Насправді це експоненціальна кількість бітів, які потрібно вивчити.

Припустимо, ви проводите індексацію. Припустимо, таблиця попередньо сортується у велику кількість бункерів, і ви використовуєте деякі з бітів у ключі для індексації безпосередньо до запису таблиці. Якщо є 1024 бункери, ентропія становить 1/1024 * журнал (1024) + 1/1024 * журнал (1024) + ... для всіх 1024 можливих результатів. Це 1/1024 * 10 разів 1024 результати, або 10 біт ентропії для цієї операції індексації. Ось чому пошук в індексації швидкий.

Тепер подумайте про сортування. У вас є N елементів, і у вас є список. Для кожного елемента потрібно шукати, куди він переходить у списку, а потім додавати його до списку. Таким чином, сортування займає приблизно N разів, ніж кількість кроків основного пошуку.

Таким чином, сортування на основі двійкових рішень, що мають приблизно однакові ймовірні результати, сприймає кроки O (N log N). Алгоритм сортування O (N) можливий, якщо він заснований на пошуку в індексації.

Я виявив, що майже всі проблеми алгоритмічної продуктивності можна розглядати таким чином.


Ого. Чи є у вас корисні посилання на це? Я вважаю, що цей матеріал мені корисний для проектування / рефактора / налагодження програм.
Jesvin Jose Jose

3
@aitchnyu: Для чого це варто, я написав книгу, що висвітлює цю та інші теми. Давно вже не надруковано, але копії збираються за розумну ціну. Я намагався змусити GoogleBooks схопити його, але наразі трохи важко зрозуміти, хто має авторські права.
Майк Данлаве

21

Почнемо з початку.

Перш за все, прийняти принцип, що певні прості операції над даними можна робити в O(1)часі, тобто в часі, що не залежить від розміру вводу. Ці примітивні операції в С складаються з

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

Обґрунтування цього принципу вимагає детального вивчення машинних інструкцій (примітивних етапів) типового комп'ютера. Кожну з описаних операцій можна виконати за допомогою невеликої кількості інструкцій з машини; часто потрібні лише одна-дві інструкції. Як наслідок, декілька видів висловлювань на C можуть бути виконані в O(1)часі, тобто за деяку постійну кількість часу, незалежну від введення. Ці прості включають

  1. Виписки про призначення, які не включають виклики функцій у своїх виразах.
  2. Прочитайте заяви.
  3. Пишіть заяви, які не потребують викликів функцій для оцінки аргументів.
  4. Оперативні стрибки розбиваються, продовжуються, переходять до та повертають вираз, де вираз не містить виклику функції.

У C багато циклів for-циклів утворюються шляхом ініціалізації змінної індексу до деякого значення та збільшення цієї змінної на 1 кожного разу навколо циклу. Цикл for-циклу закінчується, коли індекс досягає деякої межі. Наприклад, цикл for-loop

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 щоразу навколо циклу, і ітерації припиняються, коли я досягає n - 1.

Однак на даний момент зосередимо увагу на простій формі for-loop, де різниця між кінцевим та початковим значеннями, поділена на суму, на яку збільшується змінна індекс, говорить нам, скільки разів ми обходимо цикл . Цей підрахунок є точним, якщо немає способів вийти з циклу через оператор стрибка; це верхня межа кількості повторень у будь-якому випадку.

Наприклад, ітерація for-циклу ((n − 1) − 0)/1 = n − 1 times, оскільки 0 є початковим значенням i, n - 1 - це найвище значення, досягнуте 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)час роботи.


Більш практичний приклад.

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


Що робити, якщо оператор goto містить виклик функції? Щось на зразок step3: if (M.step == 3) {M = step3 (зроблено, M); } step4: якщо (M.step == 4) {M = step4 (M); } if (M.step == 5) {M = step5 (M); goto step3; } if (M.step == 6) {M = step6 (M); перейти крок4; } повернути cut_matrix (A, M); як би тоді була розрахована складність? це було б додавання чи множення? Зважаючи на крок 4, це n ^ 3, а крок 5 - n ^ 2.
Таха Тарік

14

Якщо ви хочете оцінити порядок свого коду емпіричним шляхом, а не шляхом аналізу коду, ви можете дотримуватися ряду зростаючих значень n та часу вашого коду. Складіть свої часові позначки в масштабі журналу. Якщо код дорівнює O (x ^ n), значення повинні припадати на лінію нахилу n.

Це має ряд переваг перед просто вивченням коду. З одного боку, ви можете бачити, чи перебуваєте ви в діапазоні, коли час виконання наближається до свого асимптотичного порядку. Також ви можете виявити, що якийсь код, який, на вашу думку, був замовлений O (x), дійсно є порядком O (x ^ 2), наприклад, через час, витрачений на дзвінки з бібліотеки.


Просто для оновлення цієї відповіді: en.wikipedia.org/wiki/Analysis_of_algorithms , це посилання має формулу, яка вам потрібна. Багато алгоритмів дотримуються правила живлення, якщо ваш так, за допомогою 2-х точок часу та 2-х хвилин виконання на машині, ми можемо обчислити нахил на графіку журналу журналу. Що таке = log (t2 / t1) / log (n2 / n1), це дало мені показник алгоритму в, O (N ^ a). Це можна порівняти з ручним розрахунком за допомогою коду.
Крістофер Джон

1
Привіт, приємна відповідь. Мені було цікаво, чи знаєте ви про будь-яку бібліотеку чи методологію (я працюю, наприклад, з python / R), щоб узагальнити цей емпіричний метод, що означає, як пристосування різних функцій складності до збільшення набору даних про розміри, і з'ясувати, що є релевантним. Спасибі
агенти

10

По суті, річ, яка займає 90% часу, - це лише аналіз циклів. Чи є у вас одинарні, подвійні, потрійні вкладені петлі? У вас є час роботи O (n), O (n ^ 2), O (n ^ 3).

Дуже рідко (якщо ви не пишете платформу із розгалуженою базовою бібліотекою (наприклад, .NET BCL або ST + C ++), ви зіткнетесь із чим завгодно складніше, ніж просто переглядати свої петлі (для висловлювань, тоді як, goto, тощо ...)


1
Залежить від петель.
келалака

8

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

Тепер побудуйте дерево, яке відповідає всім масивам, з якими ви працюєте. У корені у вас є початковий масив, у кореня є два дочірні, які є підряддями. Повторіть це, поки у вас не буде масивів одного елемента внизу.

Оскільки ми можемо знайти медіану в O (n) час і розділити масив на дві частини за O (n) час, робота, виконана на кожному вузлі, є O (k), де k - розмір масиву. Кожен рівень дерева містить (максимум) весь масив, тому робота на рівні дорівнює O (n) (розміри підматривок складають до n, а оскільки у нас є O (k) на рівні, ми можемо додавати це) . У дереві є лише рівні журналу (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)

Тож цей алгоритм працює в квадратичний час!


1
Вам не варто дбати про те, як зберігаються числа, це не змінюється, що алгоритм росте на верхній границі O (n).
mikek3332002


7

Розбийте алгоритм на частини, для яких ви знаєте велику нотацію O, і комбінуйте через великих операторів O. Це єдиний спосіб, про який я знаю.

Для отримання додаткової інформації перегляньте сторінку Вікіпедії з цього питання.


7

Ознайомлення з алгоритмами / структурами даних, які я використовую, та / або швидким оглядом аналізу ітерації вкладення. Складність полягає в тому, що ви викликаєте функцію бібліотеки, можливо, багаторазово - ви часто не можете бути впевнені в тому, чи викликаєте ви виклик функції без необхідності в рази, або яку реалізацію вони використовують. Можливо, функції бібліотеки повинні мати міру складності / ефективності, будь то Big O або якась інша метрика, що є в документації або навіть IntelliSense .


6

Щодо "як можна обчислити" Великий О, це частина теорії складності обчислювальної техніки . Для деяких (багатьох) спеціальних випадків ви можете мати деякі прості евристики (наприклад, множення кількості циклів для вкладених циклів), esp. коли все, що ви хочете, - це будь-яка оцінка верхньої межі, і ви не заперечуєте, якщо вона занадто песимістична - напевно, це, мабуть, те, що стосується вашого питання.

Якщо ви дійсно хочете відповісти на своє запитання щодо будь-якого алгоритму, найкраще ви можете це застосувати теорію. Окрім спрощеного аналізу "найгіршого випадку", я вважаю, що Амортизований аналіз дуже корисний на практиці.


6

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

Для 2-ї петлі, iзнаходиться між 0і nвключається для зовнішньої петлі; тоді внутрішня петля виконується тоді, коли jвона суворо більша, ніж nце неможливо.


5

Окрім використання основного методу (або однієї з його спеціалізацій), я експериментую свої алгоритми. Це не може довести, що досягається якийсь конкретний клас складності, але він може запевнити, що математичний аналіз є відповідним. Щоб допомогти в цьому, я використовую інструменти висвітлення коду разом із своїми експериментами, щоб гарантувати, що я виконую всі випадки.

Як дуже простий приклад, можна сказати, що ви хотіли перевірити обґрунтованість швидкості сортування списку .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);

4

Не забудьте також передбачити космічні складності, які також можуть викликати занепокоєння, якщо у вас обмежені ресурси пам'яті. Так, наприклад, ви можете почути, що хтось хоче алгоритм постійного простору, що в основному є способом сказати, що кількість місця, зайнятого алгоритмом, не залежить від будь-яких факторів всередині коду.

Іноді складність може виникати через те, скільки разів щось називається, як часто виконується цикл, як часто виділяється пам'ять тощо, це ще одна частина відповіді на це питання.

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


4

Що часто не помічається - очікувана поведінка ваших алгоритмів. Це не змінює Big-O вашого алгоритму , але це стосується твердження "передчасна оптимізація ..."

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

Наприклад, якщо ви шукаєте значення в списку, це O (n), але якщо ви знаєте, що більшість списків, які ви бачите, мають ваше значення наперед, типова поведінка вашого алгоритму відбувається швидше.

Щоб дійсно це зробити, вам потрібно вміти описати розподіл ймовірностей вашого "вхідного простору" (якщо вам потрібно сортувати список, як часто цей список вже сортується? Як часто він цілком перевертається? Як часто це в основному сортується?) Це не завжди можливо, що ви знаєте це, але іноді так і робите.


4

чудове запитання!

Відмова від відповідальності: ця відповідь містить помилкові твердження, дивіться коментарі нижче.

Якщо ви використовуєте 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-петлі, які залежать від розмірів масиву та міркувань на основі вашої структури даних, який тип вводу призведе до тривіальних випадків і який вхід призведе до в гірших випадках.


1
Це неправильно. Big O означає "верхню межу" не найгірший випадок.
Самі Бенчеріф

1
Поширена помилка, що big-O відноситься до найгіршого випадку. Як O і Ω ставляться до найгіршого та найкращого випадку?
Бернхард Баркер

1
Це вводить в оману. Big-O означає верхню межу функції f (n). Омега означає нижню межу функції f (n). Це зовсім не пов'язано з найкращим або гіршим випадком.
Таснейм Хайдер

1
Ви можете використовувати Big-O як верхню межу як для найкращого, так і для найгіршого випадку, але крім цього, так, ніякого відношення немає.
Самі Бенчеріф

2

Я не знаю, як це програмно вирішити, але перше, що люди роблять, це те, що ми вибираємо алгоритм для певних шаблонів у кількості виконаних операцій, скажімо, 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), тож цей алгоритм, ймовірно, не може бути програмованим. Але якщо хтось докаже мене неправильно, дайте мені код. . . .


2

Для коду A зовнішній цикл буде виконуватися в n+1рази, час '1' означає процес, який перевіряє, чи я все ще відповідає вимозі. І внутрішня петля працює nраз, n-2раз .... Отже,0+2+..+(n-2)+n= (0+n)(n+1)/2= O(n²) .

Для коду B, хоча внутрішній цикл не наступає і не виконує foo (), внутрішній цикл буде виконуватися n разів залежно від часу виконання зовнішнього циклу, який є O (n)


1

Я хотів би пояснити Big-O в трохи іншому аспекті.

Big-O - це просто порівняти складність програм, що означає, наскільки швидко вони ростуть, коли вклад збільшується, а не точний час, який витрачається на виконання дії.

IMHO у формулах big-O вам краще не використовувати складніші рівняння (ви можете просто дотримуватися наведених нижче графіків.) Однак ви все ще можете використовувати іншу більш точну формулу (наприклад, 3 ^ n, n ^ 3, .. .), але більше того може бути часом оманливим! Тож краще тримати це якомога простіше.

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

Я хотів би ще раз підкреслити, що тут ми не хочемо отримувати точну формулу нашого алгоритму. Ми хочемо лише показати, як він зростає, коли вкладення зростають, і порівняти з іншими алгоритмами в цьому сенсі. В іншому випадку вам краще використовувати різні методи, наприклад, розмітка стенда.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.