Klase složenosti algoritama (O notacija)
Uvod
U članku o algoritmima nagovestili smo da efikasnost predstavlja (praktično) najbitniju osobinu određenog postupka za obradu podataka (naravno, uz preduslov da je postupak ispravan), i stoga ne čudi da je od samog početaka razvoja računarske industrije postojala težnja da se navedena efikasnost izrazi matematičkim putem, to jest, da se na neki način "kvantifikuje".
Efikasnost algoritama ne može se za prave izraziti matematičkim metodama (na iole praktičan način), ali, može se utvrditi:
- na koji način vreme izvršavanja algoritma zavisi od veličine ulaznih podataka
- na koji način dodatno memorijsko zauzeće programa zavisi od veličine ulaznih podatka
Dve navedene stavke, predstavljaju vremensku i prostornu složenost algoritama.
Kratak osvrt na nekoliko uobičajenih funkcija
Prethodno smo (praktično) implicirali da su vremenska i prostorna složenost (matematičke) funkcije, i stoga, pre nego što se posvetimo glavnim temama, osvrnućemo se ukratko na nekoliko matematičkih funkcija koje se poklapaju sa najuobičajenijim klasama složenosti (takav osvrt biće od koristi u daljem praćenju članka) ....

Za sada, samo primetite da postoje ('prilično nezanemarljive') razlike u brzinu rasta različitih funkcija (a kasnije, sa svakim novim poglavljem članka, razlike među prikazanim funkcijama, tj. klasama složenosti, postaće još jasnije).
Vremenska složenost algoritama
Kao što smo prethodno nagovestili, "vremenska složenost algoritma" je termin preko koga se opisuje odnos između vremena izvršavanja programa i količine ulaznih podataka, odnosno, u najpraktičnijem smislu, u pitanju je funkcija koja opisuje uticaj veličine ulaznih podataka na ukupan broja operacija koje je potrebno obaviti u cilju rešavanja određenog problema (na primer: koliko puta je potrebno, pri pretrazi ili sortiranju niza i sl, pristupiti svakom elementu i obaviti operacije koje se tiču pojedinačnog elementa).
Može se reći da je vremenska složenost algoritma praktično 'sinonim' za složenost algoritma u opštem smislu, dok se o prostornoj složenosti ipak manje vodi računa.
Takođe, kada se govori o nekom konkretnom algoritmu (pogotovo kada je u pitanju vremenska složenost), tipično se posmatra najgori slučaj izvršavanja, pored čega se još može posmatrati:
- kako se algoritam ponaša u najboljem slučaju
- kako se algoritam ponaša tipično
Razlog za takav pristup, jeste to što većina algoritama u najboljem slučaju ima složenost (pogotovo, vremensku složenost), koja je daleko manja od najgoreg slučaja (ponekad je složenost u najboljem slučaju čak i konstantna), dok se tipično ponašanje algoritma (u praktičnom smislu), u mnogim situacijama poklapa sa najgorim slučajem (što ćemo detaljnije objasniti kasnije u članku).
Za opisivanje 'najgoreg slučaja' izvršavanja algoritama koristi se poseban zapis - tzv. "O notacija" (ispred zagrade stoji veliko slovo O, unutar zagrade je navedena konkretna klasa složenosti, ili (praktično), funkcija koja opisuje vremensku ili prostornu složenost algoritma (smisao takvog zapisa biće prikazan u nastavku)).
O(1) - konstantna složenost
Za algoritme klase složenosti O(1) svojstveno je to da vreme izvršavanja i broj operacija koje je potrebno obaviti - ne zavise od veličine ulaznih podataka (da bi problem koji se tiče određene strukture podataka bio rešen: potrebno je pristupiti samo jednom elementu, i uvek se obavlja isti broj operacija).
Drugi naziv za klasu složenosti O(1) je konstantna složenost, a što se tiče konkretnih primera, prvo ćemo se osvrnuti na verovatno najpoznatiji primer algoritma konstantne složenosti - pristup elementu statičkog niza.
Kao što je poznato, u nizu koji je deklarisan (npr) kao int a[7320]
, za očitavanje elemenata: a[0]
(element na početku niza), a[1419]
('udaljeni' element) i a[7319]
(element na kraju niza) - potrebno je isto vreme.
Dodavanje elemenata na stek i uklanjanje elemenata sa steka (o čemu smo pisali u članku o strukturama podataka), takođe su algoritmi složenosti O(1).
O(logn) - logaritamska složenost
Logaritam je matematička funkcija preko koje se određuje eksponent n
, u funkciji an = x
, tako da važi: loga x = n
("logaritam vrednosti x, za osnovu a, iznosi n").
Osnova a
u računarskim algoritmima ima vrednost 2, pa stoga važi: log2 32 = 5
(25 = 32) i, takođe, log2 64 = 6
(26 = 64).
Logaritam sa osnovom 2 ima i poseban naziv - binarni logaritam.
Ako pored prethodno navedenih jednakosti uzmemo u obzir i sledeće jednakosti: log2 1048576 = 20
(220 = 1048576), kao i log2 4294967296 = 32
, lako je zaključiti da logaritamska funkcija veoma sporo raste!
U algoritmima logaritamske složenosti, vreme izvršavanja proporcionalno je logaritmu veličine ulaznih podataka, i (takođe), algoritmi logaritamske složenosti (za razliku od algoritama konstantne složenosti), nisu retki.
Verovatno najpoznatiji primer je algoritam binarne pretrage, koji u nizu od 32 elementa (uzećemo prethodno pomenute vrednosti), pronalazi traženi element u najviše pet koraka, u nizu od 64 elemenata - u najviše šest koraka, ili, u nizu od milion elemenata - u najviše dvadeset koraka (sistematičnim pregledom svega 5, 6 ili 20 elemenata, problem je rešen, i sve tri vrednosti predstavljaju binarne logaritme broja elemenata ulaznih nizova).
O(n) - linearna složenost
Algoritmi linearne složenosti su jednostavni za prepoznavanje: za rešavanje problema potrebno je jedanput pristupiti svakom elementu ulazne strukture (to jest, broj operacija koje je potrebno obaviti, direktno je proporcionalan veličini ulaznih podataka).
Što se tiče 'opšteg utiska' o efikasnosti, O(n) algoritmi se smatraju prilično efikasnim u većini situacija, ali - ne uvek.
Na primer, ukoliko je potrebno ispisati sve elemente ulančane liste ili statičkog niza, svako je potrebno (i) obići sve elemente.
Međutim, ukoliko je potrebno pronaći, samo jedan element, linearna pretraga se nikako ne može smatrati efikasnim algoritmom!
Zarad pronalaženja elementa (u najgorem slučaju):
- u nizu od 32 elementa, potrebno je obići, tj pregledati, upravo 32 elementa
- u nizu od 64 elementa potrebno je obići 64 elementa
- u nizu od 106 elemenata, potrebno je obići svih milion elemenata (odnosno, napraviti milion koraka pretrage)
Ako se setimo da, u slučaju binarne pretrage, pretraživanje niza od milion elemenata podrazumeva ~20 koraka, lako je uvideti da je razlika - prilično drastična.
O(nlogn) - "međukorak" između linearne i kvadratne složenosti
Klasa O(nlogn), svojevrsna je kombinacija logaritamske složenosti i linearne složenosti, i često se sreće u računarskim algoritmima.
U smislu vremenske složenosti, klasa O(nlogn) se nalazi između linearne i kvadratne složenosti - s tim da je znatno bliža linearnoj, * i (u većini situacija), algoritmi klase O(nlogn) se i dalje smatraju efikasnima.
Kada se pomene klasa složenosti O(nlogn), većini programera prvo će pasti na pamet dva najpoznatija efikasna algoritma za sortiranje nizova: Quick sort i Heap sort, ali, pošto bi neko od starijih i upućenijih čitalaca mogao da se seti da u najgorem slučaju (koji svakako nije tipičan), algoritam Quick sort ima složenost O(n2), kao primer će poslužiti (samo) algoritam Heap sort.
Heap je binarno stablo, čija se visina može shvatiti kao binarni logaritam broja elemenata stabla, a sortiranje niza upotrebom strukture heap, podrazumeva uklanjanje čvorova.
Uklanjanje jednog elementa (tj. čvora) sa heap-a, zahteva (upravo) log(n) koraka, ali, pošto je u pitanju samo jedan element (kojih ima ukupno n), jasno je da se procedura uklanjanja čvora mora ponoviti n puta, i stoga je ukupna složenost algoritma: n * log(n).
O(n2) - kvadratna složenost
Klasu složenosti O(n2), za koju se takođe koristi i odrednica kvadratna složenost, odlikuje vreme izvršavanja koje raste sa kvadratom veličine ulaznih podataka (ili, drugačije: broj operacija koje je potrebno izvršiti zarad rešavanja problema, proporcionalan je kvadratu veličine ulaznih podataka).
Algoritmi kase O(n2) tipično se smatraju neefikasnim, ali, takva klasifikacija je takođe podložna interpretaciji i zavisi od toga da li se za određeni (konkretan) problem može pronaći bolje rešenje.
Da pojasnimo: sa jedne strane, postoje algoritmi za sortiranje nizova čija je složenost kvadratna (bubble sort, selection sort i sl) - i pri tom se navedeni algoritmi (i slični), smatraju izuzetno neefikasnim.
Sa druge strane, mnoge algoritme iz oblasti dinamičkog programiranja odlikuje upravo kvadratna složenost, ali - takvi se algoritmi smatraju (veoma) efikasnim.
Situacija je relativna i (kao što smo već naveli), zavisi od toga da li algoritam o kome se diskutuje predstavlja "najoptimalnije" (do tada pronađeno) rešenje za određeni problem (ili ne).
Kao tipične primere klase složenosti O(n2), možemo navesti prethodno pomenute neefikasne algoritme za sortiranje nizova, bubble sort i selection sort (kod kojih se elementi porede "svaki sa svakim").
Još malo teorije ....
Pominjali smo u uvodu: najbolji, najgori i tipični slučaj izvršavanja algoritma, pri čemu smo nagovestili da se najgori slučaj i tipični slučaj često poklapaju, što možemo pokazati na primeru algoritma binarne pretrage.
U najboljem slučaju, traženi element je moguće pronaći već u prvom koraku, ali, u velikoj većini situacija - to se neće desiti (praktično: ako se desi, u pitanju je 'sreća', splet okolnosti i sl).
Najgori slučaj podrazumeva traženje elementa na nivou koji je najudaljeniji od korenog čvora.
Što se tiče "tipičnog slučaja", ako se usmerimo (upravo) na elemente sa 'najudaljenijih' nivoa u stablu koje smo koristili u članku o binarnoj pretrazi) ....

.... može se primetiti da su na poslednjem nivou prisutni: najmanji element, najveći element, kao i razni elementi čija vrednost postepeno raste od najmanjeg ka najvećem (a slično je i u većini drugih iole razgranatih stabala pretrage).
Jednostavnije: može se reći da su u pitanju tipični elementi niza.
Stoga, budući da su u pitanju (nasumični) elementi kakvi se "tipično" potražuju, ali, pozicionirani tako da se nalaze na mestu gde algoritam pokazuje najveće vreme izvršavanja, vidimo (praktično) da se najgori slučaj izvršavanja i tipični slučaj izvršavanja algoritma - poklapaju (naravno, to što smo naveli, ipak ne važi za sve algoritme).
Ostale klase složenosti
Kada se govori o "ostalim" klasama složenosti (kao što je navedeno u gornjem naslovu), tipično se misli na algoritme čija je složenost veća od kvadratne, pri čemu se najčešće srećemo sa klasama O(n3) - kubna složenost i O(kn) - eksponencijalna složenost (gde k može ali i ne mora biti 2).
Naravno, u pitanju su algoritmi velike složenosti, i poželjno je ovakve algoritme izbegavati (ukoliko je ikako moguće).
O(n3) - kubna složenost
Algoritme kubne složenosti odlikuje vreme izvršavanja koje raste sa kubom veličine ulaznih podatka (ukoliko se količina ulaznih podataka duplira, vreme izvršavanja raste 8 puta, to jest, 23*x).
Trostruko ugnežđene petlje, sa kakvima se ne srećemo suviše često (ali isto tako - ni iz daleka retko), verovatno su najočigledniji primer kubne složenosti:
for (i = 0; i < n; ++i) {
for (j = 0; j < m; ++j) {
for (k = 0; k < p; ++k) {
// naredbe
}
}
}
O(kn) - eksponencijalna složenost
Za algoritme eksponencijalne složenosti važi da je veličina ulaznih podataka, eksponent (tj. stepen), u funkciji koja opisuje složenost algoritma.
Uzmimo za primer algoritam za pronalaženje lozinke metodom "grube sile".
Složenost se može izraziti kao O(kn), gde vrednost k
predstavlja broj mogućih znakova (za svako mesto), a n
predstavlja broj mesta.
Kratka analiza
U najpraktičnijem smislu, može se reći da algoritme klase složenosti O(n3) odlikuje složenost koja je .... donekle umerena .... pod uslovom da je veličina ulaznih podataka takođe (bar koliko-toliko) umerena. *
Međutim, eksponencijalna složenost podrazumeva (u najvećem broju situacija), da vreme izvršavanja raste izrazito velikom brzinom, pri čemu ulazni podaci ne moraju imati (ni iz daleka) veliki obim.
Na primer, u članku koji je posvećen lozinkama, prikazali smo situaciju u kojoj, ukoliko se broj mesta u lozinki poveća sa četiri na osam (pri čemu smo računali da je broj mogućih znakova 26), ** vreme izvršavanja programa poraste, sa nekoliko desetina milisekundi, na nekoliko - sati!
Prostorna složenost algoritama
Na početku smo se upoznali i sa terminom "prostorna složenost algoritma", koji opisuje uticaj količine ulaznih podataka na dodatno memorijsko zauzeće, pri čemu smo takođe nagovestili da prostorna složenost različitih algoritama najčešće nema praktičan značaj kakav ima vremenska složenost, međutim - poznavanje osnovnih principa nikako ne može 'štetiti' ....
Što se tiče 'tehnikalija': pri razmatranju prostorne složenosti algoritama, u obzir se uzima samo dodatno memorijsko zauzeće (nezavisno od memorijskog prostora za smeštaj ulaznih podataka), a prostorna složenost algoritma (to jest, dodatno memorijsko zauzeće), takođe se izražava preko klasa sa kojima smo se već upoznali: O(1), O(n), O(n2) i sl, pri čemu je zanimljivo primetiti da se klasa prostorne složenosti određenog algoritma, vrlo često ne poklapa sa klasom vremenske složenosti istog algoritma (nije neočekivano, ali, vredi primetiti :)).
Na primer, neefikasni algoritmi za sortiranje nizova koje smo pominjali (bubble sort i selection sort), koje odlikuje "nezavidna" klasa vremenske složenosti, zapravo imaju prostornu složenost O(1), jer dodatno memorijsko zauzeće, podrazumeva - samo pomoćnu promenljivu koja se koristi u pomoćnoj funkciji za razmenu vrednosti dve promenljive (a isto važi i za efikasni algoritam za sortiranje heap sort, koji ne koristi dodatne strukture podataka za razmeštanje elemenata niza).
Nasuprot navedenim primerima, drugi efikasni algoritam koji smo pominjali, quick sort, ima prostornu složenost O(n).
Međutim (da se vratimo na raniju primedbu), u praksi, teoretska razmatranja u vezi sa prostornom složenošću algoritama (kako u početnom periodu razvoja računarske industrije tako i u današnje vreme), * nemaju toliki značaj koliki ima praktična analiza maksimalnog (očekivanog) memorijskog zauzeća (u smislu, da li se za pokretanje programa uvek može obezbediti dovoljno memorije u realnim uslovima eksploatacije (ili ne može)).
Drugim rečima: ako određeni algoritam pripada (primera radi) klasi prostorne složenosti O(n4), što "ne deluje dobro", ali je zato maksimalno memorijsko zauzeće manje od (recimo) ~80MB, što je količina memorije koja se (u današnje vreme) gotovo sigurno može obezbediti bilo kom programu, može se smatrati da, u odlučivanju koje se tiče toga da li će algoritam biti izabran kao konačno rešenje (ili neće), klasa prostorne složenosti, verovatno neće igrati preveliku ulogu (to jest, pažnja će verovatno biti usmerena na klasu vremenske složenosti, realnu brzinu izvršavanja na uobičajenim računarima i druge slične detalje).
Osvrnućemo se na još dva primera koji se tiču prostorne složenosti algoritama (odnosno, složenosti algoritama u opštem smislu) ....
Algoritmi iz oblasti dinamičkog programiranja, u većini slučajeva se ne odlikuju malom prostornom složenošću, međutim, 'poenta' dinamičkog programiranja je doslovno u primetnom umanjivanju vremenske složenosti algoritma nauštrb prostorne složenosti.
Drugi primer je 'suprotan': algoritam za pronalaženje lozinke metodom grube sile, odlikuje se zanemarljivim dodatnim prostornim zauzećem, međutim, to - u praktičnom smislu - 'pada u vodu' kada uzmemo u obzir izrazito veliku vremensku složenost.
Preko dodatnih primera, želeli smo da (dodatno) potcrtamo da se pri izboru algoritma moraju uzeti u obzir razne pojedinosti.
Za kraj - još malo prakse ....
Vraćamo se (za sam kraj), na vremensku složenost algoritama.
Videli smo kako stvari stoje sa algoritmima eksponencijalne složenosti, koji se koriste za obradu manjih kolekcija podataka, pa preostaje da se osvrnemo i na primer koji smo najavili, koji se tiče primene algoritama složenosti O(n2) u obradi većih kolekcija ....
Deluje (u prethodno navedenom kontekstu), da smo ranije "neopravdano ocrnili" algoritme za sortiranje nizova čija je složenost O(n2)?!
Naravno da O(n2) algoritmi za sortiranje nisu "loši" sami po sebi (u tom smislu da postupci nisu 'nerazumni' i da navedeni algoritmi imaju izvesnu pedagošku vrednost), ali, što se tiče toga da li su O(n2) algoritmi za sortiranje "brzi ili spori", u praksi se da primetiti sledeće:
- za sortiranje statičkog niza od pola miliona elemenata, brzi(nski)m algoritmima za sortiranje tipično je potrebno između jedne i dve sekunde
- Bubble sort i Selection sort isti posao obavljaju (verovatno već znate šta sledi :)) - više sati *
Dakle, možemo videti da složenost neefikasnijeg (od dva) algoritma ne mora biti veća od O(n2), a da pri tom rezultati izvršavanja programa budu veoma 'očigledni'.
Slobodno obavite samostalni eksperiment kojim ćete isprobati prethodno navedene algoritme za sortiranje i sami ustanoviti da li se veliki nizovi sortiraju toliko dugo preko O(n2) algoritama - ako baš imate vremena. :)