Základy algoritmizace a programování Složitost algoritmů. Třídění Přednáška 8 16. listopadu 2009
Který algoritmus je "lepší"? Různé algoritmy, které řeší stejnou úlohu zbytek = p % i; zbytek = p - p/i*i; Časové a pamět ové nároky algoritmu (programu) časová a pamět ová složitost. Volba vhodného algoritmu : Vyhledání telefonního čísla v telefonním seznamu. Nejjednodušší algoritmus: prohledávat od začátku postupně jméno po jméně, dokud nenajdeme hledané. (sekvenční vyhledávání). ALE my postupujeme jinak: Využíváme toho, že v seznamu jsou jména utříděna podle abecedy. Postup, který obvykle používáme je založen na myšlence půlení intervalu.
Složitost algoritmů Časová složitost závislost časových nároků na velikosti vstupních dat. Měříme počtem elementárních operací (kroků algoritmu), které budou provedeny v programu s danými vstupními daty. Elementární operace instrukce typické pro daný problém počet porovnání, přesunů v paměti, aritmetické operace apod. N (velikost konkrétního řešeného problému) počet operací vykonaných při výpočtu podle daného algoritmu. (rostoucí funkce ) Pamět ová složitost závislost pamět ových nároků na velikosti vstupních dat. Měříme počtem pamět ových míst, které budou při výpočtu podle algoritmu zapotřebí.
Složitost algoritmů Složitost v nejhorším případě jak nejdéle bude trvat výpočet s libovolnými daty délky N (zaručená horní mez)... hrubé odhady... Složitost v průměném případě při odhadu nutné uvažovat pravděpodobnostní rozložení dat. Zpravidla horní odhad Příklad: Je číslo prvočíslo? sudá čísla výpočet hned skončí číslo je prvočíslo testování všech možných dělitelů N. Hrubý odhad časové složitosti : N. Který ze dvou algoritmů je lepší? f (n) = 10n, g(n) = n 2 pro n < 10 platí : f (n) > g(n), pro n > 10 platí f (n) < g(n) Pokud nevíme, pro jak velká data bude algoritmus používán, je podstatné, který algoritmus je rychlejší pro velké hodnoty N
Asymptotická složitost Asymptotická složitost rychlost růstu funkce složitosti Například: počet operací je charakterizován 2N 2 + 3N + 1 časové nároky jsou úměrné N 2 funkce časové složitosti je kvadratická O(N 2 ). V praxi používané algoritmu mívají většinou některou následujících složitostí: O(log N), O(N), O(N log N), O(N 2 ), O(N 2 log N), O(N 3 ),... O(2 N ) uspořádáno podle rychlosti růstu polynomiální... exponenciální Zrychlení procesoru 10 násobné lineární algoritmus zpracuje 10 krát větší objem dat, kvadratický trojnásobně, exponenciální jen o 3.
Příklady Úloha utřídění N čísel podle velikosti. Jednodušší algoritmy mají časovou složitost O(N 2 ), lepší pracují v čase O(N log N). Předpokládejme, že operace porovnání dvou čísel trvá 0,1 ms. Utřídění 100 čísel "nepatrný" rozdíl (1 sekunda nebo 0,07 s) i pomalý algoritmus vyhovuje. Třídění 100 000 čísel rozdíl 11 dní nebo necelé 3 minuty.
Příklady Hledání v telefonním seznamu (cca 500 000 jmen) Prohledávání nejprimitivnějším způsobem... lineární složitost Binární vyhledávání v uspořádané posloupnosti otevřeme seznam uprostřed porovnáme s hledaným zjistíme, ve které polovině máme dál hledat v každém kroku zmenšíme sledovanou část na polovinu Při N jménech musíme udělat přibližně O(log 2 N) kroků, aby se zkoumaný úsek zúžil na jediné jméno. Člověk porovná dvě jména za cca 1 sekundu rozdíl dny nebo sekundy.
Matematická definice symbolu O Mějme dvě funkce f a g, definované v oboru přirozených čísel (v matematice matematické analýze se totéž zavádí v oboru reálných čísel). Řekneme, že funkce f je třídy O(g), jestliže existuje taková reálná konstanta C, že pro všechna přirozená čísla od jistého n 0 počínaje platí f (n) Cg(n). To znamená, že funkce g shora omezuje funkci f až na multiplikativní konstantu.
Algoritmy třídění Úloha Přerovnat data do správného pořadí, protože se seřazenými údaji se mnohem lépe pracuje, například pokud v nich pak potřebujeme vyhledávat. Budeme třídit pole celých čísel. Metody třídění můžeme rozdělit do dvou hlavních skupin: vnitřní třídění, kdy si můžeme dovolit všechna data načíst do (rychlé) paměti počítače, vnější třídění, kdy již třídění musíme realizovat opakovaným čtením a vytvářením diskových souborů. Omezíme se pouze na algoritmy vnitřního třídění.
Nejjednodušší algoritmy Nejjednodušší třídící algoritmy patří do skupiny přímých metod. Tyto algoritmy mají většinou časovou složitost O(N 2 ). Jsou použitelné tehdy, když tříděných dat není příliš mnoho. Stručně si přiblížíme tři nejznámější přímé algoritmy. přímým výběrem přímým vkládáním bublinkové třídění
Třídění přímým výběrem (SelectSort) Třídění přímým výběrem je založeno na opakovaném vybírání nejmenšího čísla z dosud nesetříděných čísel. Vybereme nejmenší číslo v celém poli Nalezené číslo prohodíme s prvkem na začátku pole Vybereme nejmenší číslo z čísel 2,...,N, Prohodíme s druhým prvkem v poli. Vybereme nejmenší číslo z čísel s indexy 3,...,N, atd.... Je snadné si uvědomit, že když takto postupně vybíráme minimum z menších a menších intervalů, setřídíme celé pole (v i-tém kroku nalezneme i-tý nejmenší prvek a zařadíme ho v poli na pozici s indexem i).
Realizace void SelectSort(int * A, int N) { int i,j,k,x; for (i=0; i<n-1; i++) { k=i; //k : index prvního z prohledávaných for (j = i+1; j< N; j++) if (A[j]<A[k]) k = j;//k : index nejmenšího x = A[k]; A[k] = A[i]; A[i] = x;//prohodíme } return; } Časová složitost algoritmu V i-tém kroku musíme nalézt minimum z N-i+1 čísel O(N i + 1). Ve všech krocích dohromady O(N + (N 1) + + 3 + 2 + 1) = O(N 2 ).
Třídění přímým vkládáním (InsertSort) Třídění přímým vkládáním (InsertSort) funguje na podobném principu jako třídění přímým výběrem. Na začátku pole vytváříme správně utříděnou posloupnost, kterou postupně rozšiřujeme. Na začátku i-tého kroku má tato utříděná posloupnost délku i-1. V i-tém kroku určíme pozici i-tého čísla v dosud utříděné posloupnosti a zařadíme ho do utříděné posloupnosti (zbytek utříděné posloupnosti se posune o jednu pozici doprava). Každý krok lze provést v čase O(N). Protože počet kroků algoritmu je N, celková časová složitost právě popsaného algoritmu je opět O(N 2 ).
Realizace void InsertSort(int * A, int N) { int i,j,x; for (i = 1; i< N; i++) { x = A[i]; j =i-1; while (j>0 && x <A[j]) { A[j+1] = A[j]; j = j-1; } A[j+1] = x; } return; }
Bublinkové třídění (BubbleSort) Bublinkové třídění (BubbleSort) pracuje jinak než dva dříve popsané algoritmy. Algoritmu se říká "bublinkový", protože podobně jako bublinky v limonádě "stoupají" vysoká čísla v poli vzhůru. Postupně se porovnávají dvojice sousedních prvků, řekněme zleva doprava, a pokud v porovnávané dvojici následuje menší číslo po větším, tak se tato dvě čísla prohodí. Celý postup opakujeme, dokud probíhají nějaké výměny. Protože algoritmus skončí, když nedojde k žádné výměně, je pole na konci algoritmu setříděné.
Realizace void BubbleSort(int * A, int N) { int i,x; int zmena; do { zmena = 0; for (i = 0; i< N-1; i++) { x = A[i]; A[i] = A[i+1]; A[i+1] = x; zmena = 1; } } while (zmena = = 1); return; }
Bublinkové třídění Po i průchodech while cyklem bude posledních i prvků obsahovat největších i prvků setříděných od nejmenšího po největší. Popsaný algoritmus se tedy zastaví po nejvýše N průchodech jeho celková časová složitost v nejhorším případě je O(N 2 ), nebot na každý průchod spotřebuje čas O(N). Výhodou tohoto algoritmu oproti předchozím dvěma je, že je tím rychlejší, čím blíže bylo zadané pole k setříděnému stavu pokud bylo úplně setříděné, tehdy algoritmus spotřebuje jen lineární čas, O(N).
Algoritmus jménem QuickSort Pracuje v čase O(N log N) Tento algoritmus je založen na metodě "Rozděl a panuj". Nejprve si zvolíme nějaké číslo, kterému budeme říkat pivot. Poté pole přeuspořádáme a rozdělíme je na dvě části tak, že žádný prvek v první části nebude větší než pivot a žádný prvek v druhé části naopak menší. Prvky v obou částech pak setřídíme rekurzivním zavoláním téhož algoritmu. Musíme ale dát pozor, aby byly v každém kroku obě části neprázdné (a rekurze tedy byla konečná). Po skončení algoritmu bude pole setříděné.
Výběr pivota Malá zrada spočívá ve volbě pivota. Hodilo by se, aby po přeházení prvků levá i pravá část pole byly přibližně stejně velké. Nejlepší volbou pivota by byl prvek takový, jenž by byl v setříděném poli přesně uprostřed. Přeuspořádání zvládneme v lineárním čase a pokud by pivoty na všech úrovních byly mediány, pak by počet úrovní rekurze byl O(log N) a celková časová složitost O(N log N) (na každé úrovni rekurze je součet délek tříděných posloupnosti nejvýše N). Většinou se pivot volí náhodně z dosud nesetříděného úseku Dá se ukázat, že takovýto algoritmus s velmi vysokou pravděpodobností poběží v čase O(N log N). V naší implementaci QuickSortu pro názornost nebudeme pivot volit náhodně, ale vždy jako pivot vybereme prostřední prvek tříděného úseku.
Realizace void QuickSort(int * A, int left, int right) {int i,j,pivot,x; i = left; j = right; pivot =A[(i+j) / 2]; do { while (A[i]<pivot) i = i+1; while (A[j]>pivot j = j-1; if (i<=j) { x=a[i]; A[i]=A[j]; A[j]=x; i=i+1; j=j-1; } } while (i< j); if (j>left) QuickSort(A, left, j); if (i<right) QuickSort(A, i, right); return; }
Vyhledávání Jak v uspořádaných datech něco efektivně najít a jak si data udržovat stále uspořádaná. K tomu se nám bude hodit zejména binární vyhledávání a různé druhy vyhledávacích stromů.
Binární vyhledávání. Najít nějaký konkrétní záznam z mezi utříděnými. Nalistujeme prostřední záznam (označíme si ho xm) a porovnáme s ním naše z. Při z<xm víme, že se z nemůže vyskytovat "napravo" od xm, protože tam jsou všechny záznamy větší než xm Analogicky, pokud z>xm, nemůže se z vyskytovat v první polovině pole. V obou případech nám zbude jedna polovina a v ní budeme pokračovat stejným způsobem. Tak budeme postupně zmenšovat interval, ve kterém se z může nacházet, až bud to z najdeme zjistíme, že v seznamu není. Tomuto principu se obvykle říká binární vyhledávání nebo také hledání půlením intervalu a snadno ho naprogramujeme pomocí cyklu, v němž si budeme udržovat interval < l, r >, ve kterém se hledaný prvek může nacházet.
Binární vyhledávání int BinSearch(int * x, int N, int z ) {int l,r,m ; l = 0; //interval, ve kterém hledáme r = N-1; while (l<= r) //ještě není prázdný { m = (l+r) / 2; // střed intervalu if (z < x[m]) r = m-1; // je vlevo else if (z > x[m]) l = m+1 ; // je vpravo else return m; // Bingo! } return -1; // nebyl nikde }
Složitost binárního vyhledávání Průchodů cyklem while může být nejvýše (log 2 N), protože interval < l, r > na počátku obsahuje N prvků a v každém průchodu jej zmenšíme na polovinu (ve skutečnosti ještě o jedničku. Proto po k průchodech bude interval obsahovat nejvýše N/2k prvků a jelikož pro N/2k<1 se algoritmus zastaví, může být k nejvýše log 2 N. Proto je časová složitost binárního vyhledávání O(log N). [Základ logaritmu nemusíme psát, protože logaritmy o různých základech se liší jen konstantou, která se "schová do O čka."] Hledání půlením intervalu je tedy velmi rychlé, pokud máme možnost si data předem setřídit.