Сложност на алгоритми
Computation Complexity
Сравнение на различни решения. Как да апроксимираме бързината на алгоритми?
Какво е сложност? Big-O нотация. Сложност по време. Сложност по памет.
Какво е сложност? Big-O нотация. Сложност по време. Сложност по памет.
В темата за сортиране разгледахме два алгоритъма (Bubble Sort и Selection Sort), за които казахме, че са относително бавни. Как можем да определим, че един алгоритъм е по-бърз от друг? Един начин би бил да напишем и двата и да тестваме с различни входни данни, като така правим емпирична проверка. Това, обаче, не винаги е особено хубаво решение, тъй като за целта трябва да имплементираме и двата алгоритъма. На състезание рядко ще имате достатъчно време за да направите това, така че би било хубаво да знаем друг начин, по който да предвиждаме бързината на алгоритъма, който смятаме да напишем, преди да го напишем. Тук ще разгледаме един много разпространен начин за оценка, който в повечето случаи дава достатъчно добра апроксимация на времето, което ще е нужно на вашето решение да обработи дадени входни данни.
Сравнение на различни решения
За мен лично, най-лесният начин за разбиране на идеята за сложности на алгоритми е чрез даване на конкретни примери. Затова ще разгледаме една сравнително проста задача (модификация на Zero Sum), за която ще покажем три различни решения.Ели има N различни цели числа А1, А2, …, АN (1 ≤ Ai ≤ 100,000,000), сортирани в нарастващ ред. Тя се чуди колко двойки има сред тях, чиято сума е равна на X?
Търсим броя на различните двойки индекси (i, j), такива, че Ai + Aj == X. Ще тестваме с шест набора тестове, като всеки следващ е точно 10 пъти по-голям от предходния:N = 1,000
N = 10,000
N = 100,000
N = 1,000,000
N = 10,000,000
N = 100,000,000
Решение 1: проста итерация
Най-простото решение е да направим два цикъла, които да обхождат числата. За всяка двойка индекси, проверяваме дали сумата на съответните елементи дава X, и ако това е така увеличаваме отговора.int pairsWithSumXNaive() {
int ans = 0;
for (int i = 0; i < N; i++)
for (int c = i + 1; c < N; c++)
if (a[i] + a[c] == X) ans++;
return ans;
}
Решение 2: двоично търсене
Какво не ползвахме в миналото решение? Това, че числата са сортирани. А какво казахме за сортирани числа? Винаги помислете дали не можете да приложите двоично търсене! В случая можем - след като фиксираме едно от двете числа, вместо да търсим линейно другото, може да ползваме двоично търсене.int pairsWithSumXBSearch() {
int ans = 0;
for (int i = 0; i < N; i++) {
int left = i + 1, right = N - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (a[i] + a[mid] < X)
left = mid + 1;
else right = mid - 1;
}
if (left < N && a[i] + a[left] == X)
ans++;
}
return ans;
}
Решение 3: нека бъдем умни
Все пак има накъде да оптимизираме. С известни наблюдения можем да елиминираме дори двоичното търсене (тази оптимизация не е особено очевидна). Ако имаме двойка индекси (i, j), какво можем да кажем за сумата Ai + Aj в сравнение с Ai+1 + Aj? Тъй като числата са различни и сортирани, то със сигурност Ai + Aj < Ai+1 + Aj. Тоест увеличаването на някой от индексите води до увеличаване на сумата. Нещо повече, сходно наблюдение можем да направим при намалянето им. С намалянето на j сумата намалява. Тъй като индексите трябва да са различни, можем да считаме, че винаги i < j.! | Не е толкова очевидно как тази оптимизация е свързана с двоичното търсене. Реално, индексът j е този индекс, който двоичното би намерилo. Но тъй като увеличаването на i води до потенциално преместване на j само и единствено наляво, то не е нужно да правим ново двоично търсене при всяко местене. |
Така нашето решение ще е следното: в началото
i = 0;
и j = N - 1;
. Докато сумата е по-малка от X мърдаме i надясно. Ако пък е по-голяма, мърдаме j наляво. Продължаваме да правим това, докато i < j
. Може би забелязвате, че с тази хитра оптимизация ние правим точно N сравнения.
int pairWithSumXSmart() {
int ans = 0;
for (int i = 0, j = N - 1; i < j; i++) {
while (i < j && a[i] + a[j] > X)
j--;
if (i < j && a[i] + a[j] == X)
ans++;
}
return ans;
}
Ето и пълната таблица с резултатите:
Input: 1000 10000 100000 1000000 10000000 100000000
============================================================================
Naive: 0.000s 0.024s 2.376s 237.700s 6.6h 27d*
BSearch: 0.000s 0.001s 0.002s 0.026s 0.278s 2.998s
Smart: 0.000s 0.000s 0.001s 0.004s 0.035s 0.082s
----------------------------------------------------------------------------
Answers: 0 3 49 4874 476900 47172456
* Last test for Naive solution is an estimate
Наблюдения
Нека се вгледаме по-внимателно в горната таблица. Забелязвате ли, че при наивния алгоритъм времето скача от 2.376 секунди на 237.700 секунди, когато входът бива увеличен от 100,000 на 1,000,000? На по-наблюдателните от вас сигурно им е направило впечатление, че числата 2.376 и 237.700 съмнително си приличат - второто е почти точно 100 пъти по-голямо от първото. А входът беше увеличен само 10 пъти! Такова решение се нарича квадратно, тъй като времето за изпълнение расте с K2 пъти при K пъти по-голям вход. Аналогично можете да видите, че 6.6 часа е 23760 секунди, което е отново приблизително 100 пъти повече от предходния тест.Подобни наблюдения можем да се опитаме да направим и за останалите две решения, но тъй като времената са сравнително малки, те няма да са особено точни. Все пак, второто решение е почти 10 пъти по-бавно след всеки тест. Прави ни впечатление, че не е точно 10 пъти, ами малко повече.
? | Всъщност времената в последното решение варират толкова много (и са под 10 пъти) главно защото кодът е много прост и компилаторът прилага редица оптимизации - примерно извършва по няколко събирания и сравнения на такт. |
Какво е сложност?
Сложност, накратко, е как се изменя изискваното време или памет за изпълнение на алгоритъм, с изменяне на размера на входните данни. Най-често ни интересува сложността по време, но понякога ще ни трябва и тази по памет. За сега ще разглеждаме само времето, като в края на темата ще обърнем внимание и на паметта.Логично е колкото по-големи са входните данни, толкова по-бавно да работи даден алгоритъм. Вече видяхме, че в горните алгоритми това беше точно така - колкото по-голямо беше N, толкова по-бавно работеха и трите.
! | Тук ползваме леко опростен вариант, в който константата за брой операции в секунда speed е еднаква и в двете формули. Това, обикновено, не е точно така, тъй като по принцип различни алгоритми ползват различни типове операции, а различни операции се изпълняват различно бързо. Все пак, разликата в общия случай не е голяма. |
Така стигаме и до това какво е сложност на алгоритъм по време. В най-общи линии, това е функция, описваща колко операции ще са му нужни, при вход с дадена големина.
Big-O нотация
Някакви хора са измислили специален начин да се обозначава, че говорим за сложности. Разбира се, направили са го сложно и теоретично, с маса дефиниции и изисквания. Ние ще игнорираме повечето от това (йее!). Ако се интересувате от точни дефиниции, погледнете Introduction to Algorithms (MIT Press, Cormen, Leiserson, Rivest, Stein), където това е обяснено много, много добре. Прекалено добре всъщност, в следствие на което е над двадесет страници.Ние ще игнорираме theta, omega, small-o и другите възможни, като се спрем единствено на най-често ползваната нотация: Big-O. Нейното име идва от начина на изразяване на сложността като главно О, отваряща скоба, функция и затваряща скоба. Демек
O(f(n))
. Например сложността на наивния алгоритъм, който написахме, изразена чрез Big-O, би била O(N2)
(тук функцията е N2), тъй като, както видяхме вече, той зависи квадратично спрямо големината на входните данни. Хитрият алгоритъм пък би бил O(N)
, тъй като зависеше линейно от тях.За да сметнем сложността на алгоритъма с двоично търсене трябва да помислим малко. Както казахме, двоичното търсене работи за логаритъм на брой стъпки - тоест
O(log(N))
, изразено в Big-O. А в нашата имплементация ползвахме двоично търсене за да намерим втория индекс за всеки първи индекс. Така на практика направихме N на брой двоични търсения, като сложността на целия алгоритъм става O(N∙log(N))
. Ето откъде идват "малко по-бързо от линейно" нарастващите времена!В най-лошия случай...
Big-O нотацията всъщност не казва точно колко операции ще са нужни на нашия алгоритъм, нито дава приблизителния им брой. Нещо повече, Big-O не показва дори средния случай, което би било много по-полезно за задачи от реалния живот. Тя дава негова горна граница. За повечето алгоритми, ако горната граница е изчислена правилно, тя ще е еквивалентна и на средния случай, но това не винаги е така.Ако помните, в задачата с познаването на числото на Станчо, Ели имаше шанс да го познае още в самото начало и да не задава пълния брой
log(N)
въпроси, които казахме, че са ѝ нужни.
! | Дори 99.99% от случаите вашата програма да ползва N*log(N) операции, ако в някои от останалите ползва N2, то тя е със сложност O(N2) . Пример за много известен такъв алгоритъм е quicksort. |
Константи
Малко шокиращо за хора, които за първи път срещат Big-O нотацията е, че константите "изчезват", тоест биват игнорирани, при изчисляване на сложности. Например два алгоритъма, единият от които прави 5*N операции, а другият само 2*N, биват оценени катоO(N)
. Това е така, тъй константите не влияят на скоростта на нарастване на необходимото време, нужно за изпълнение на алгоритъма ни, с нарастване на входните данни. Както казахме и малко по-рано, когато премахнахме константата за "брой операции в секунда", с нарастване на големината на входа два пъти, скоростта също нараства два пъти, и това е най-важното.Наистина, при алгоритми с еднаква сложност константите биха били полезни - за да изберем този от тях, който действително би вървял най-бързо. Когато сложностите са различни, обаче, константите могат да са заблуждаващи. За следващия пример ще позволим да имаме константи в сложността, което по принцип не е правилно.
Ако забелязахте, в наивната имплементация на задачата от началото на темата не правим точно N2 операции, тъй като вторият цикъл започва от i+1 вместо от 0. Така той прави N2/2 операции, следователно много по-точна негова горна граница би била
O(0.5∙N2)
. Версията с двоичното търсене пък има деление и допълнителни сметки вътре в себе си. Тъй като делението е сравнително по-бавна операция от събирането, можем да кажем, че неговата константа е, примерно, 100, тоест O(100∙N∙log(N))
. Това е доста завишено, но точно това се опитваме да покажем - че всъщност няма значение.? | Когато има два възможни алгоритъма с различна сложност, като този с по-висока сложност има много по-малка константа, те могат да се комбинират. За малки стойности да се ползва теоретично по-бавния (но който ще се справи много по-бързо за малки стойности поради ниската си константа), а за големи - другия. Този трик е приложен в STL-ския sort(), където за малък брой елементи се ползва едно сортиране, а за голям - друго. |
Примери за сложности
Съществуват много различни функции на сложности, които може да срещнете. Само някои от тях обаче са наистина често срещани. Тук ще разгледаме тези, които най-често ще ви трябват за състезания по информатика.Константна
Константната сложност, илиO(1)
, е най-бързата възможна сложност, тъй като тя не зависи от обема на входните данни - тя е винаги някакъв константен брой операции.
Логаритмична
Логаритмичната сложност, илиO(log(N))
, ще срещнете в някои бързи и сравнително прости алгоритми, които най-често ще са само част от решението ви. Примери за такива са вече разгледаното двоично търсене, алгоритъм на Евклид и бързото вдигане на степен, което ще видим по-нататък.Тя е една от най-бързите такива, които все пак зависят от входа. Както видяхме в темата за двоично търсене, при нарастване на входа ни от 1000 на 1,000,000,000,000,000,000 разликата в броя стъпки беше 10 срещу 60 - тоест само 6 пъти нарастване на времето за 1,000,000,000,000,000 пъти нарастване на входа.
! | Тъй като loga(x) = logb(x) / logb(a) , а logb(a) е константа (при това малка, за повечето реални примери), то считаме, че loga(x) ~= logb(x) . |
Корен квадратен
Сложност корен квадратен, илиO(sqrt(N))
, ще срещнете често, когато искате да балансирате бързодействието на два алгоритъма (или две негови части). Всъщност, ако помните, вече срещнахме един пример за такъв алгоритъм! Спомнете си оптимизацията на наивното търсене, която приложихме в темата за двоично търсене. Там стигнахме до точно O(sqrt(N))
алгоритъм за познаване на числото на Станчо.
Линейна сложност
Линейна сложност, илиO(N)
, имате често в алгоритми, които обработват всеки обект (елемент на масив, връх на граф, т.н.) по константен брой пъти. Два много разпространени примера за такива алгоритми са търсенето в ширина и търсенето в дълбочина, които ще разгледаме малко по-нататък. В много задачи, където искаме да постигнем O(N)
сложност на цялото решение, идеята е да се ползва линейна структура данни и няколко (примерно един, два или три) "паса" на входните данни (всеки с линейна сложност) за да се стигне до крайния резултат. Както казахме, идеята е всеки елемент от входните данни да се обработи по O(1)
пъти.Често допускана грешка от начинаещи състезатели е да се пропуска четенето на входа и печатането на изхода, при изчисляването на сложността. Например ако в дадената по-горе задача се питаше дали в дадените числа се среща X, то донякъде интуитивно би било да допуснем, че има решение със сложност
O(log(N))
- просто да ползваме двоично търсене за да проверим това. Но за да направим двоично търсене в случая, трябва да имаме масив с числата, а самото взимане (четене, генериране или копиране) на масив с N елемента е със сложност O(N)
.Енлог сложност
Добре де, това не е някаква специална сложност, но решихме да я споменем, защото е много често срещана. ЕнЛог(Ен) сложност, илиO(N∙log(N))
, ще срещнете в задачи, където трябва да сортирате елементите (най-бързите сортирания са с такава сложност). Както видяхме, когато елементите са сортирани, можем да приложим редица бързи алгоритми върху тях (например двоично търсене, а по-късно ще разгледаме и други, като, да кажем, алчни алгоритми). Също така алгоритми, базирани на Разделяй и Владей, много често са с O(N∙log(N))
сложност.Квадратична сложност
Квадратична сложност, илиO(N2)
, можете да срещнете също сравнително често, тъй като редица прости алгоритми (например простите сортирания, които вече разгледахме) я изискват.Кубична сложност
Кубична сложност, илиO(N3)
, ще срещнете в много на брой, по-сложни алгоритми, като например умножение на матрици, алгоритъм на Флойд, Гаусова елиминация и други. Донякъде забавно (по-скоро иронично) е, че макар и с относително голяма изчислителна сложност, повечето алгоритми, които са с такава сложност, са прости за имплементация - те не са нищо повече от три вложени цикъла, които вършат сравнително прости операции.Експоненциална сложност
Експоненциалната сложност може да е кактоO(2N)
(по-добрият вариант), така и O(N!)
(по-лошият вариант). Обикновено можем да ги срещнем при изчерпващи алгоритми (например backtrack), или пък динамично по битова маска. Понякога (най-вече в състезания като TopCoder) можем да срещнем задачи, в които трябва да направим всички пермутации на нещо и да пробваме дали всяка от тях е (оптимален) отговор. Това води до O(N∙N!)
- една от най-високите сложности, които можете да срещнете в състезания.Малко по-формално
Ама наистина държите да споменем формалната дефиниция, така ли? Добре... После да не съжалявате!Нека имаме две функции
f(x)
и g(x)
, дефинирани върху подмножество на реалните числа. f(x) = O(g(x))
при x клонящо към безкрайност, тогава и само тогава, когато съществува положителна константа M, такава, че за всички достатъчно големи стойности на x, f(x)
e не повече от M умножено по g(x)
по абсолютна стойност.С други думи,
f(x) = O(g(x))
тогава и само тогава, когато съществуват положително реално число M и реално число x0, при които |f(x)| <= M*|g(x)|
за всяко x > x0.Надявам се да сте щастливи. Накарахте човек, който не можа да си вземе анализа до трети курс, да пише такива неща. Shame on you!
Леко объркващо
Игнорирането на константите може да е малко объркващо. С наученото до сега би било логично да предположим, че сложността на алгоритъм, който прави приблизително 2*N2 операции, не може да еO(N2)
, тъй като N2 < 2*N2, а казахме, че Big-O задава горна граница. Той, обаче, все пак е O(N2)
, поради игнорирането на константите. Например, функцията в О-то може да е, да кажем, 3*N2, което е по-голямо от 2*N2. След това, обаче, сме премахнали константата във вътрешната функция, тъй като Big-O го изисква. Тоест, на практика може функцията, показана вътре в О-то, да е по-малка от броя операции, които се извършват в действителност, и все пак да е горна граница.Всъщност това следва директно от формалната дефиниция, но тъй като изобщо не ви трябва да я знаете за състезания, особено пък ако сте по-малки (да се разбира под група А), то преценихме, че ще е полезно ако го покажем допълнително.
Зависимост от повече от една променлива
? | Ако F пъти изпълним функция, която има вложен цикъл до M, като вътре в него имаме операция със сложност O(G) , то цялата сложност би била O(MFG) . |
O(N+M)
, O(N∙M)
, О(N2∙M)
, O(MN)
или други възможни комбинации между тях.Секунда е много
Каква сложност можем да очакваме да върви за една секунда? Разбира се, това е много относително, но все пак, алгоритми с дадените сложности почти винаги се изпълняват за под секунда на хубав, модерен процесор (в момента е 2012 година), а също така вървят на TopCoder (където процесорите вече не са толкова нови, но пък time limit-ът е 2 секунди).O(1)
при N < INFO(log(N))
при N < INFO(sqrt(N))
при N ~ 1016O(N)
при N ~ 100,000,000O(N∙log(N))
при N ~ 1,000,000O(N∙sqrt(N))
при N ~ 100,000O(N2)
при N ~ 10,000O(N3)
при N ~ 500O(2N)
при N ~ 22O(3N)
при N ~ 15O(N!)
при N ~ 10
? | Алтернативни Big-O нотации: O(1) = O(yeah) O(logN) = O(nice) O(N) = O(okay) O(N2) = O(my) O(2N) = O(no) O(N!) = O(mg!) |
Сложност по памет
След като сме обяснили сложността по време е почти тривиално да обясним сложността по памет. Тя представлява скоростта, с която нараства ползваната памет с нарастване на входните данни. За горната задача, примерно, тази памет еO(N)
, тъй като се нуждаем от масив с N клетки, за да пазим входа. Тя рядко ще ви трябва, освен ако не искате да проверите дали решението ви ще се събере в ограниченията по памет. Но, обикновено, в задачата имате един голям масив, който доминира всички останали променливи и масивчета, та неговият размер е единственият, който е от значение. Затова на състезания много по-рядко се налага да изчислявате в Big-O нотация сложността на алгоритъма ви по памет.Важност
Тази тема, макар и относително теоретична и досадна, е изключително важна. И по-странното е, че не е важна защото някой ще ви пита каква е сложността на алгоритъма, който сте написали (макар че и това ще се случва). Тя е важна, защото ще трябва вие да определяте дали даден алгоритъм си струва да бъде написан или не, и колко точки ще хване, ако го напишете (ако сте на ученическо състезание).Готиното е, че ще ползваме сложности буквално за всеки алгоритъм и структура данни, които разглеждаме оттук нататък, като така с времето ще се научите сами интуитивно да определяте каква е сложността на дадено парче код.
Допълнителни материали
- Статия за Big-O нотация (en.wikipedia.org)
- Доста подобна на тази статия (www.topcoder.com)
- Изчисляване на сложности в итеративни и рекурсивни алгоритми (www.topcoder.com)
- Мастър теорема (en.wikipedia.org)
За да предложите корекция, селектирайте думата или текста, който искате да бъде променен,
натиснете Enter и изпратете Вашето предложение.
натиснете Enter и изпратете Вашето предложение.
Страницата е посетена 43951 пъти.