Čo je asymptotická (Big-O) notácia, časová vs. pamäťová zložitosť a interpretácia najčastejších výsledkov.
Asymptotická notácia
.
Časová zložitosť
Pri časovej zložitosti používame asymptotickú notáciu vo forme . Predstavuje, koľko operácií vykoná určitý kód pre prvkov na vstupe. Inými slovami, majme napríklad tento kód pre zistenie názvu dňa podľa jeho poradia v týždni (1 až 7):
DNI = ["Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"]
# 0 1 2 3 4 5 6
poradie_v_tyzdni = 3 # tretí deň v týždni
den = DNI[poradie_v_tyzdni-1] # -1, pretože indexujeme od 0
print(den)
# "Streda"
Operácie, ktoré kód vyššie vykonáva, môžeme rozpísať a analyzovať. Interpretácia mojej analýzy je zobrazená nižšie:
DNI = ["Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"]
# alokuje sa premenná "DNI" a priradí sa jej hodnota
# z hľadiska časovej náročnosti sú konštanty O(1)
# (pretože prebehne práve 1 operácia, teda vytvorenie premennej)
poradie_v_tyzdni = 3 # toto je stále konštanta, takže O(1)
den = DNI[poradie_v_tyzdni-1] # znovu O(1)
print(den) # O(1)
Tuto sa ešte nenachádza žiadna premenná , keďže vstup sa pre kód nikdy nemení – vždy máme pevne danú množinu v ktorej je 7 prvkov (dní) a vyberáme práve jeden, nie sú tam žiadne cykly ani iné vetvenie).
Nezáleží na tom, koľkokrát kód vyššie spustíme, môžeme tvrdiť že vždy sa vykoná práve 6 operácií:
- vytvorenie premennej
DNI
; - vytvorenie premennej
poradie_v_tyzdni
; - vytvorenie premennej
den
; - výpis tejto premennej na terminál;
Teda, výsledok by teoreticky mohol byť (iba sčítame operácie: ). Číslo v zátvorke by predstavovalo počet operácií ktoré kód vykoná - avšak, v praxi to nedáva zmysel, pretože cieľom nie je vypočítať presný počet operácií ktoré kód vykoná, ale iba určiť trend, podľa ktorého sa mení výkon algoritmu s meniacim sa objemom údajov na vstupe.
Aby sme ale mohli správne určiť pomer výkonu k vstupu, potrebujeme pridať premennú , ktorá bude udávať počet prvkov na vstupe, napríklad:
# premenná "n" môže byť akékoľvek prirodzené číslo (väčšie ako alebo rovné nule)
# deklarácia by mohla vyzerať napríklad takto:
n = int(input())
def scitaj_prvych(n):
"""
Funkcia, ktorá sčíta prvých `n` čísel a vráti výsledok sčítania.
"""
sucet = 0 # konštantné priradenie, O(1)
# toto si vysvetlíme nižšie
# (spoiler: bude nás zaujímať iba toto :))
for cislo in range(n):
sucet += cislo
return sucet # aj operácia vrátenia ("return") sa pokladá za náročnosť O(1)
Vidíme, že počet čísiel ktoré sa majú sčítať sa dynamicky menia v závislosti od toho čo zadá používateľ do terminálu (respektíve, podľa toho aké číslo je v premennej ).
Z toho vyplýva, že musíme odvodiť všeobecnú rovnicu ktorá bude hovoriť, ako sa časová náročnosť kódu bude meniť v závislosti od počtu prvkov na vstupe – aspoň približne. Ak sa pozrieme na hlavný cyklus v kóde:
for cislo in range(n): # n-krát
sucet += cislo # O(1)
Teda, výsledná rovnica je: (počet operácií súčtu je rovný počtu čísel ktoré máme sčítať)
Čo sa vlastne myslí pod pojmom "operácia"? Aj najjednoduchšie inštrukcie môžu trvať niekoľko CPU cyklov - ako veľmi musíme kód deliť a "rozkúskovať"? A prečo nás vlastne zaujíma iba ten cyklus? Aj priradenie premennej n
na začiatku je predsa operácia?
Big-O sa používa na vyjadrenie asymptotického správania algoritmu, teda ako sa jeho výkon mení pri veľkých hodnotách . Mohli by sme brať do úvahy aj konštantné faktory (napríklad, priradenie premenných mimo hlavného cyklu programu), ale nemá to zmysel pretože pri veľkom množstve vstupných hodnôt sú alokácie pár (globálnych) premenných zanedbateľné a nezávisia priamo od premennej . Skúmame iba trend, to znamená že ak máme napríklad dva výsledky: a , oba naznačujú že trend je lineárny – presný počet operácií nás nemusí zaujímať.
Ak by sme mali ako výsledok napríklad , zredukovali by sme ho iba na , pretože táto hodnota má tendenciu rásť najrýchlejšie podľa toho ako by sa menilo (rastie kvadraticky, a to má prednosť pred lineárnym alebo konštantným správaním pretože rastie najrýchlejšie v závislosti od ).
No a operácia je vlastne iba určitá abstrakcia nad nejakou časťou kódu ktorá niečo robí a má konštantný čas . Môže ísť napríklad o vykonanie nejakej funkcie, priradenie premennej, aritmetickú operáciu, ale aj napríklad porovnanie a výmenu prvkov v množine (v algoritme Bubble sort). Ak je za sebou viacero operácií s konštantným časom, neovplyvní to graf časovej zložitosti funkcie pretože konštantné operácie nezávisia priamo od , a teda tieto operácie môžu byť spolu chápané ako len jedna operácia s časom [1].
Všeobecný postup pre výpočet časovej zložitosti
Všeobecný postup pre určenie časovej zložitosti by teda mohol vyzerať takto:
- Identifikovať hlavnú slučku programu ktorá spracuje údaje zo vstupu, a teda jej výkon závisí od celkového množstva údajov na vstupe (zväčša od premennej );
- Zorientovať sa, čo táto časť kódu vlastne robí a koľko údajov je vlastne potrebné spracovať. Napríklad: je potrebné prejsť všetkými údajmi na vstupe, alebo môže nastať aj prípad že slučka skončí skôr? Spracujú sa všetky údaje alebo iba polovica? A podobne...;
- Určiť časovú zložitosť operácií ktoré sú jednoznačné;
- Dať to všetko dokopy (ak je to cyklus, pravdepodobne budeme násobiť toľkokrát, koľko sa kód bude opakovať, inak sčítavame)
Interpretácia výsledkov časovej zložitosti
V tabuľke možno vidieť porovnanie najčastejších výsledkov asymptotickej notácie (pre časovú zložitosť):
Konštantná časová zložitosť –
Najlepší prípad je konštantný faktor, teda . V analógií z reálneho života by to napríklad znamenalo, že máme balíček kariet a chceme vybrať prvú kartu – a to urobíme okamžite, bez ohľadu na to koľko kariet je v balíčku. Môže ich tam byť 32, ale mohlo by ich byť aj 1 000, no prvú kartu vyberieme vo všetkých prípadoch vždy prakticky okamžite, teda je to iba akoby sme vykonali len jednu operáciu.
Konkrétne príklady z programovania sú napríklad jednoduché aritmetické operácie (sčítanie, odčítanie, násobenie, delenie...), výber konkrétneho prvku na n-tej pozícií v indexovanom poli, vrátenie hodnoty a podobne...
Logaritmická časová zložitosť –
Aha, máme tu aj obľúbené logaritmy! Samozrejme je za tým matematika ktorá funguje, ale pre uľahčenie života si stačí iba zapamätať:
Ak sa veľkosť spracovávaného vstupu (prvkov) postupne skracuje (neskráti sa naraz!) o polovicu, tak tento algoritmus bude mať logaritmickú rýchlosť.
Obľúbeným príkladom z praxe je napríklad binárne vyhľadávanie. Binárne vyhľadávanie hľadá prvok vo vopred zoradenom poli tak, že:
- Skontroluje stredný prvok;
- Ak je hľadaný prvok menší, pokračuje v ľavej polovici; ak je väčší, pokračuje v pravej polovici;
- Opakuje sa to, kým nenájdeme prvok ktorý hľadáme alebo kým už nezostane žiadna podmnožina na hľadanie (v tom prípade sa tam prvok nenachádza);
Každým krokom zmenšíme veľkosť vyhľadávanej časti na polovicu.
Prečo je tam logaritmus a nie napríklad , ak sa vstup skráti o poloviu?
Pretože vstup sa skracuje postupne, neskráti sa celý naraz o polovicu. Ak začneme s poľom veľkosti , postupne sa veľkosť vyhľadávanej časti zmenšuje:
Iterácia () | Počet zostávajúcich prvkov () | Poznámka |
---|---|---|
1. iterácia | Začiatočný počet všetkých prvkov (). | |
2. iterácia | Prvý krát rozdelíme množinu na polovicu. | |
3. iterácia | To, čo sme rozdelili ešte rozdelíme na polovicu. | |
4. iterácia | Ešte delíme ďalej... | |
... | ... | ...až pokým nezostane nič, čo by sa dalo deliť
(matematicky to avšak pokračuje donekonečna). |
Vidíme, že číslo ktoré je v zlomku v menovateli (dole) je vždy výsledok . Všeobecný vzorec pre výpočet zostávajúceho počtu prvkov v iterácií je teda: .
Zastavíme sa keď zostane iba 1 prvok, teda keď . Ak chceme určiť , tak najprv vyjadríme ako a použijeme logaritmus aby sme dostali: . Časová zložitosť je teda vyjadrená ako: .
Poznámka: je to isté ako .
O detailoch binárneho vyhľadávania a konkrétnej implementácií v Pythone si povieme neskôr v časti o binárnom vyhľadávaní.
Lineárna časová zložitosť –
Pravdepodobne asi najčastejší prípad v praxi. Predstavuje lineárnu rýchlosť algoritmu - inými slovami, o koľko viac prvkov pridáme do vstupu, o toľko sa spomalí algoritmus. Ak by sme mali balík kariet, znamenalo by to napríklad že chceme zrátať koľko tam tých kariet je. No a aby sme to spravili, musíme prejsť každou kartou v balíku. Ak máme 10 kariet, zrátame ich relatívne rýchlo, no pri milióne by sme už mali problém.
Poznámka: Pri výpočtovej zložitosti vždy predpokladáme že každá operácia - v našom prípade prirátanie každej karty do celkového počtu - nám zaberie rovnaký čas, napríklad pol sekundy. To znamená, že 10 kariet by sme rátali 5 sekúnd, milión kariet by sme v tom prípade rátali približne 140 hodín.
Ale ako sme hovorili predtým, zaujíma nás iba trend - pretože aj tak nedokážeme rozpočítať koľko trvajú jednotlivé procesy v počítači, napríklad koľko trvá prístup k prvku v poli (mení sa to v závislosti od výkonu hardvéru, aktuálneho zaťaženia počítača a tak ďalej...). Zaujíma nás všeobecný trend toho, ako sa výkon mení a to nám stačí pre jednoznačné určenie lepšieho algoritmu.
Kvázilineárna časová zložitosť –
Kým v logaritmickej zložitosti () sa rozdeľuje v každom kroku celkový počet prvkov na polovicu, kvázilineárna zložitosť hovorí že každý prvok na vstupe je spracovávaný logaritmicky (teda, logaritmus sa násobí -krát).
Táto zložitosť sa objavuje hlavne v efektívnych triediacich algoritmoch a v algoritmoch, ktoré kombinujú lineárnu a logaritmickú prácu (preto sa nazýva "kvázilineárna" – nie je úplne lineárna, ale ani úplne logaritmická). Medzi príklady patria: Merge sort, Quick sort, Heap sort.
Kvadratická časová zložitosť –
V praxi predstavuje kvadratická zložitosť najčastejšie algoritmy, ktoré využívajú pre prácu vnorené cykly (napríklad, triediaci algoritmus Bubble sort).
Exponenciálna časová zložitosť –
Horšia ako kvadratická zložitosť, exponenciálna zložitosť má ako exponent číslo . Napríklad, povedzme že máme nájsť všetky kombinácie pre binárne číslo dĺžky . Máme k dispozícií dva znaky (0 a 1), teda časová zložitosť pre algoritmus ktorý takéto kombinácie hľadá bude .
Ďalší prípad je, ak by sme mali napríklad nájsť a vyskúšať všetky kombinácie určitého hesla dĺžky (ak predpokladáme, že funkcia ktorá heslo vyskúša má konštantnú časovú zložitosť a heslo sa skladá iba z čísiel od 0 po 9). Časová zložitosť by bola , teda vieme zistiť presný počet operácií pre dĺžku hesla . Avšak, pre stanovenie typu časovej zložitosti sa ako základ väčšinou konvenčne uvádza iba číslo 2 (teda, ), pretože nám to stačí pre vyjadrenie že čas rastie exponenciálne.
Faktoriálová časová zložitosť –
Najhorší prípad je . Ak si zabudol, ten "výkričník" v matematike sa nazýva faktoriál a je to súčin všetkých prvkov od po 1 – napríklad, znamená: .
znamená že máme napríklad balík kariet na ktorých sú čísla. Tieto karty chceme zoradiť od najväčšieho čísla po najmenšie. Ale robíme to takým spôsobom, že karty vyhodíme do vzduchu a dúfame že padnú na zem správne zoradené. Predpokladáme teda, že musíme vykonať všetky možné kombinácie operácií pre to, aby sme ich niekedy vytriedili správne (preto je tam ten faktoriál, kombinatorika funguje...). To môže pri malom množstve kariet trvať niekoľko minút, no pri väčších číslach sa môžeme trápiť bezvýsledne aj niekoľko rokov. Takže, vo všeobecnosti sa vždy snažíme zložitosti vyhnúť.
Čo je aj dôvod, prečo vlastne všetko toto počítame a analyzujeme. Je rozdiel, ak nejaký algoritmus trvá pár sekúnd versus pár rokov, že? Naším cieľom je samozrejme tvoriť algoritmy ktoré sú efektívne a prehľadné. A Big-O notácia nám umožňuje porovnávať výkon dvoch rozličných algoritmov matematicky pre veľké vstupy. A keďže sme v dobe kedy bežne pracujeme s veľkým objemom dát na dennej báze, je veľmi dôležité aby bolo všetko čo najrýchlejšie.
Pamäťová zložitosť
Podobne ako pri časovej, tak aj pri pamäťovej zložitosti využívame asymptotickú notáciu (Big-O). Predstavuje, koľko dodatočnej pamäte spotrebuje algoritmus počas behu (dodatočnej preto, že neberieme ohľad na pamäť do ktorej sa uložia prvky na vstupe). Pri pamäťovej zložitosti analyzujeme premenné, polia, pomocné štruktúry, rekurzívne zásobníky.
Niektoré algoritmy šetria pamäť, ale sú pomalšie. Naopak, iné sú rýchlejšie ale zaberajú viacej pamäte. Preto neexistuje jeden najlepší algoritmus, a treba si vybrať (aj po zvážení časovej zložitosti) aký algoritmus použijeme. Ak spracovávame malé množstvo dát, pravdepodobne budeme priorizovať lepšiu časovú zložitosť. Pri veľkom množstve dát budeme brať ohľad aj na zväčšujúcu sa pamäťovú náročnosť.
Tu je náš predošlý kód:
DNI = ["Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"]
# 0 1 2 3 4 5 6
poradie_v_tyzdni = 3 # tretí deň v týždni
den = DNI[poradie_v_tyzdni-1] # -1, pretože indexujeme od 0
print(den)
# "Streda"
Je možné vypočítať, koľko zaberie celý takýto program miesta v pamäti, presne na bajty. Avšak, podobne ako pri časovej zložitosti kde neriešime presný počet operácií, aj pri pamäťovej zložitosti nás bude zaujímať iba trend (graf toho, ako náročnosť klesá alebo sa zvyšuje v závislosti od objemu dát na vstupe, teda ).
Kód vyššie by sa dal analyzovať takto:
DNI = ["Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota", "Nedeľa"]
# toto predstavuje akoby premennú "n"
# môžme ju ignorovať, pretože pamäťová zložitosť vstupných dát nás nezaujíma,
# zaujíma nás iba dodatočná pamäť ktorú program potrebuje počas behu
poradie_v_tyzdni = 3 # je to jednoduché priradenie konštanty...
den = DNI[poradie_v_tyzdni-1] # ...rovnako aj tu
# preto je zložitosť pre obidva riadky vyššie = O(1)
print(den) # môžme iba predpokladať pamäťovú zložitosť O(1)
Ak to všetko dáme dokopy, získame
. Po zanedbaní nižších rádov má kód vyššie pamäťovú zložitosť
. Čo ak by sme mali iný kód. Napríklad, chceme všetky reťazce na vstupe transformovať na reťazce, kde budú všetky písmená veľké. Tu je jedno z riešení:
def na_velke(n):
vsetky_velke = []
for retazec in n:
novy = retazec.upper() # konverzia na reťazec kde sú všetky písmená veľké
vsetky_velke.append(novy) # pridáme nový reťazec do výslednej množiny
return vsetky_velke
Časová zložitosť je samozrejme – čím väčší máme zoznam na vstupe, tým viac operácií (iterácií) bude kód vykonávať, teda časová zložitosť rastie lineárne. Rovnako je to aj s pamäťovou zložitosťou, ktorá je tiež – dodatočná pamäť, ktorú program spotrebuje rastie lineárne s pamäťou kde sú uložené vstupné prvky. Inými slovami, ak máme na začiatku 10 prvkov, tak naspäť aj 10 prvkov dostaneme (s tým, že v pamäti bude spolu 20 prvkov, 10 na vstupe a 10 kde sme premenili všetky písmená na veľké - ale ako sme hovorili, zaujíma nás dodatočná pamäť a nie pamäť na vstupe, teda iba 10 prvkov s veľkými písmenami).
Referencie
- ↑ "Time Complexity Theory" (W3 Schools; upravené, odporúčané) – https://www.w3schools.com/dsa/dsa_timecomplexity_theory.php