Більшість людей зі ступенем в 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
не буде виконаний, і ми припускаємо постійну складність виконання С на своєму тілі.
Тепер підсумки можна спростити за допомогою деяких правил ідентичності:
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 - "відносно введення", тому це залежить від того, який ваш вклад.
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(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
може бути гірше , якщо функція компаратора патологічна.
Хоча знаєте, як визначити час 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
для перевірки на дивацтво?
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 біт. Тож якщо ви можете шукати його за твердженнями ІФ, які мають однакові ймовірні результати, слід прийняти 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) можливий, якщо він заснований на пошуку в індексації.
Я виявив, що майже всі проблеми алгоритмічної продуктивності можна розглядати таким чином.
Почнемо з початку.
Перш за все, прийняти принцип, що певні прості операції над даними можна робити в O(1)
часі, тобто в часі, що не залежить від розміру вводу. Ці примітивні операції в С складаються з
Обґрунтування цього принципу вимагає детального вивчення машинних інструкцій (примітивних етапів) типового комп'ютера. Кожну з описаних операцій можна виконати за допомогою невеликої кількості інструкцій з машини; часто потрібні лише одна-дві інструкції. Як наслідок, декілька видів висловлювань на C можуть бути виконані в O(1)
часі, тобто за деяку постійну кількість часу, незалежну від введення. Ці прості включають
У 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)
час роботи.
Більш практичний приклад.
Якщо ви хочете оцінити порядок свого коду емпіричним шляхом, а не шляхом аналізу коду, ви можете дотримуватися ряду зростаючих значень n та часу вашого коду. Складіть свої часові позначки в масштабі журналу. Якщо код дорівнює O (x ^ n), значення повинні припадати на лінію нахилу n.
Це має ряд переваг перед просто вивченням коду. З одного боку, ви можете бачити, чи перебуваєте ви в діапазоні, коли час виконання наближається до свого асимптотичного порядку. Також ви можете виявити, що якийсь код, який, на вашу думку, був замовлений O (x), дійсно є порядком O (x ^ 2), наприклад, через час, витрачений на дзвінки з бібліотеки.
По суті, річ, яка займає 90% часу, - це лише аналіз циклів. Чи є у вас одинарні, подвійні, потрійні вкладені петлі? У вас є час роботи O (n), O (n ^ 2), O (n ^ 3).
Дуже рідко (якщо ви не пишете платформу із розгалуженою базовою бібліотекою (наприклад, .NET BCL або ST + C ++), ви зіткнетесь із чим завгодно складніше, ніж просто переглядати свої петлі (для висловлювань, тоді як, goto, тощо ...)
Нотація 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)
Тож цей алгоритм працює в квадратичний час!
Я вважаю менш корисним загалом, але для повноти існує також Велика Омега Ω , яка визначає нижню межу по складності алгоритму, і Велика Тета Θ , яка визначає і верхню, і нижню межу.
Розбийте алгоритм на частини, для яких ви знаєте велику нотацію O, і комбінуйте через великих операторів O. Це єдиний спосіб, про який я знаю.
Для отримання додаткової інформації перегляньте сторінку Вікіпедії з цього питання.
Ознайомлення з алгоритмами / структурами даних, які я використовую, та / або швидким оглядом аналізу ітерації вкладення. Складність полягає в тому, що ви викликаєте функцію бібліотеки, можливо, багаторазово - ви часто не можете бути впевнені в тому, чи викликаєте ви виклик функції без необхідності в рази, або яку реалізацію вони використовують. Можливо, функції бібліотеки повинні мати міру складності / ефективності, будь то Big O або якась інша метрика, що є в документації або навіть IntelliSense .
Щодо "як можна обчислити" Великий О, це частина теорії складності обчислювальної техніки . Для деяких (багатьох) спеціальних випадків ви можете мати деякі прості евристики (наприклад, множення кількості циклів для вкладених циклів), esp. коли все, що ви хочете, - це будь-яка оцінка верхньої межі, і ви не заперечуєте, якщо вона занадто песимістична - напевно, це, мабуть, те, що стосується вашого питання.
Якщо ви дійсно хочете відповісти на своє запитання щодо будь-якого алгоритму, найкраще ви можете це застосувати теорію. Окрім спрощеного аналізу "найгіршого випадку", я вважаю, що Амортизований аналіз дуже корисний на практиці.
За 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 пояснено вище), отримаємо велике значення O-[O (n ^ 2) в цьому випадок]. Але це повинно було б враховувати інтерполяцію Лагранжа в програмі, що може бути важко реалізувати. Що робити, якщо справжнє велике значення 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 - це просто порівняти складність програм, що означає, наскільки швидко вони ростуть, коли вклад збільшується, а не точний час, який витрачається на виконання дії.
IMHO у формулах big-O вам краще не використовувати складніші рівняння (ви можете просто дотримуватися наведених нижче графіків.) Однак ви все ще можете використовувати іншу більш точну формулу (наприклад, 3 ^ n, n ^ 3, .. .), але більше того може бути часом оманливим! Тож краще тримати це якомога простіше.
Я хотів би ще раз підкреслити, що тут ми не хочемо отримувати точну формулу нашого алгоритму. Ми хочемо лише показати, як він зростає, коли вкладення зростають, і порівняти з іншими алгоритмами в цьому сенсі. В іншому випадку вам краще використовувати різні методи, наприклад, розмітка стенда.