-
Je použitelné na problémy, které lze rozdělit na podproblémy.
-
Obzvlášť vhodné je pak v těch případech, kde se podproblémy překrývají — dochází k tomu, že se něco počítá víckrát.
Algoritmy a datové struktury
Editovat
Note
|
Pokročilé techniky návrhu algoritmů: dynamické programování, hladové strategie, backtracking. Amortizovaná analýza. Vyhledávání řetězců: naivní algoritmus pro hledání řetězců, Karp-Rabinův algoritmus, hledání řetězců pomocí konečných automatů. Algoritmus Knuth-Morris-Pratt. IV003 |
Pokročilé techniky návrhu algoritmů
Dynamické programování
I thought dynamic programming was a good name. It was something not even a Congressman could object to. So I used it as an umbrella for my activities.
Eye of the Hurricane: An Autobiography (1984
Intutivně je dynamické programování spojením dvou věcí: "rozbalené" rekurze (taky se tomu říká bottom-up přístup) a memoizace.
Konkrétněji, dynamické programování je vhodnou technikou, pokud:
-
podproblémů je polynomiální počet,
-
(optimální) řešení původního problému lze jednoduše spočítat z (optimálních) řešení jeho podproblémů,
-
podproblémy jde přirozeně seřadit od nejmenšího po největší.
Tip
|
O tom, že problémů musí být polynomiální počet, přemýšlím intuitivně tak, že se musí dát vyřešit v nějakém vícenásobném Pokud mám zanořených cyklů, vyřeším nejvíc podproblémů. |
Memoizace
Memoizace v zásadě není nic jiného než tabulka, pole, HashSet
, nebo něco podobného, kam si algoritmus ukládá řešení jednotlivých podproblémů.
Tip
|
V pseudokódu se označuje jako (asi memory), (asi array), nebo (asi cache). |
Bottom-up
Rekurze tradičně řeší problém zeshora — začně celým problémem, který si rozdělí na podproblémy, a ty na podpodproblémy, atd. Bottom-up approach jde na to obráceně. Začně těmi nejmenšími podproblémy a postupně se prokousává k rešení celku.
Jediným háček je v tom přijít na to, které podproblémy jsou ty nejmenší a v jakém pořádí je musíme spočítat, aby byly všechny připravené pro výpočet větších podproblémů. Bez tohohle algoritmus nebude fungovat korektně.
Note
|
Zjednodušeně jde o to přetransformovat rekurzi na cykly. Pěkný vedlejším efektem je, že je jednodušší určit složitost algoritmu. |
Kuchařka
-
Rozděl problém na (překrývající se) podproblémy.
-
Napiš rekurzivní algoritmus nebo alespoň Bellmanův rekurentní vztah (značený protože dává optimální řešení).
-
Urči správné pořadí počítání podproblémů tak, aby se každý počítal právě jednou (bottom-up přístup).
-
Pokud je to nutné, sestav z optimální hodnoty její realizaci (třeba cestu nebo něco).
-
Sepiš pseudokód.
-
Dokaž korektnost rekurentního vztahu, bottom-up pořadí a rekonstrukce (zejména terminace).
-
Okomentuj složitost.
Problémy
- Weighted interval scheduling
-
Z množiny intervalů (událostí, úkolů, atd.), které se mohou překrývat v čase, a mají určitou váhu , vyber takovou množinu intervalů , pro kterou je maximální.
- Řešení
-
Řešení využívá toho, že čas plyne výhradně dopředu, takže se můžeme na podproblémy dívat chronologicky a nebudou se překrývat.
Nechť je index takové události , že a jsou kompatibilní.
- Parenthesization
-
Mějme hromadu matic, které chceme pronásobit. Víme, že maticové násobení je asociativní, takže můžeme zvolit různé pořadí násobení — různé odzávorkování. Nicméně, není komutativní, takže nesmíme matice prohazovat. Cena násobení matice o velikosti a je . Jaké pořadí zvolit, aby byl výsledný součin co nejlevnější?
- Problém
-
Máme matice , které chceme pronásobit.
Potřebujeme najít index takový, že je nefektivnější. To nám problém rozděluje na dva podproblémy: červený a modrý.
- Řešení
-
- Knapsack
-
Mějme batoh s nosností a věcí, které bychom do něj rádi naložili. Každá věc má hodnotu a váhu . Jaké věci vybrat, aby byla hodnota naložených věcí co největší, ale batoh je furt unesl?
- Řešení
-
Vychází z myšlenky, že batoh, ve kterém už něco je, je jakoby batoh s nižší nosností.
Procházíme věci postupně přes index a pro každou řešíme, jestli ji chceme v batohu o nosnosti :
Hladové (greedy) strategie
Přijde Honza na pracovní pohovor a budoucí šéf se ho ptá: "Co je vaše dobrá schopnost?" Honza odpoví: "Umím rychle počítat." "Kolik je 1024 na druhou?" "MILION STO TISÍC," vyhrkne ze sebe Honza. Šéf se chvíli zamyslí a povídá: "Ale to je špatně, výsledek je 1048576!" A Honza na to: "No sice špatně, ale sakra rychle!"
Greedy algoritmy nachází řešení globálního problému tak, že volí lokálně optimální řešení. Tahle taktika nemusí vést ke globálně optimálnímu řešení, ale alespoň ho spočítá rychle.
-
Ve výpočtu směřuje bottom-up.
-
Ideálně funguje na problémy, kde optimální řešení podproblému je součástí optimálního řešení celého problému.
-
Dobře se navrhuje, špatně dokazuje.
Problémy
- Cashier’s algorithm (mince)
-
Jak zaplatit danou částku s co nejmenším počtem mincí různých hodnot?
- Řešení
-
V každé iteraci vol minci s nejvyšší hodnotou, dokud není zaplacena celá částka.
- Interval scheduling
-
Z množiny intervalů, které mají začátek a konec, ale mají stejnou hodnotu, vyber největší podmnožinu intervalů, které se nepřekrývají.
- Řešení
-
Vybereme ty, které končí nejdřív.
Backtracking
Inteligentní brute-force nad prostorem řešení.
Technika hledání řešení problému postupným sestavováním kandidátního řešení. [5]
-
Částečný kandidát může být zavrhnut, pokud nemůže být dokončen.
-
Můžeme dokonce zavrhnout kompletní řešení, pokud je chceme najít všechna.
-
Pokud je kandidát zavrhnut, algoritmus se vrátí o kus zpět (backtrackuje), upraví parametry a zkusí to znovu.
Dynamické programování | Backtracking |
---|---|
Hledá řešení překrývajících se podproblémů. |
Hledá všechna řešení. |
Hledá optimální řešení. |
Hledá všechna, libovolná řešení, hrubou silou. |
Má blíž k BFS — staví "vrstvy". |
Má blíž k DFS — zanoří se do jednoho řešení a pak se vrátí. |
Typicky zabírá víc paměti kvůli memoizaci. |
Typicky trvá déle, protože hledá všechna řešení. |
Mívá cykly. |
Mívá rekurzi. |
Problémy
- Sudoku
-
Hledá řešení tak, že pro pole vybere možné řešení a zanoří se, pokud funguje tak hurá, pokud ne, tak backtrackuje a zkusí jinou možnou cifru.
- Eight queens
-
Jak rozestavit osm šachových královen na šachovnic tak, aby se vzájemně neohrožovaly?
Amortizovaná analýza
- amortize(v)
amortisen — "to alienate lands", "to deaden, destroy"
amortir (Old French) — "deaden, kill, destroy; give up by right"
*admortire (Vulgar Latin) — to extinquish
Umožňuje přesnější analýzu časové a prostorové složitosti, protože uvažujeme kontext, ve které se analyzovaný algoritmus používá. Určujeme složitost operace v posloupnosti operací, ne samostatně.
Tip
|
Viz bakalářská otázka Korektnost a složitost algoritmu. |
Základními pojmy analýzy složitosti jsou:
- Časová složitost
-
Funkce velikosti vstupu algoritmu. Počítá počet kroků (nějaké výpočetní jednotky) potřebných k vyřešení problému.
- Prostorová složitost
-
Funkce velikosti vstup algoritmu. Počítá počet polí (nějaké jednotky prostoru), která algoritmus potřebuje navštívit k vyřešení problému.
- Asymptotická notace
-
Umožňuje zanedbat hardwarové rozdíly. Popisuje, že složitost roste alespoň tak, nejvýš tak nebo stejně jako jiná funkce.
- Big O
-
Horní mez, složitost v nejhorším případě. Množina funkcí rostoucích stejně rychle jako , nebo pomaleji:
- Omega
-
Spodní mez, složitost v nejlepším případě. Množina funkcí rostoucích stejně rychle jako , nebo rychleji.
- Theta
-
Horní i spodní mez. Množina funkcí rostoucích stejně rychle jako .
Aggregate method (brute force)
Analyzujeme celou sekvenci operací najednou. Nepoužíváme žádné chytristiky ani fígle.
- Věta
-
Pokud začneme s prázdným zásobníkem, pak libovolná posloupnost operací
Push
,Pop
aMulti-Pop
zabere času. - Důkaz
-
-
Každý prvek je
Pop
nut nejvýše jednou pro každý jehoPush
. -
V posloupnosti je
Push
ů. -
V posloupnosti je
Pop
ů (včetně těch vMulti-Pop
u). -
Celá posloupnost má tak nejvýše složitost .
-
Accounting method (banker’s method)
Používá fígl, kdy velké množství levných operací "předplatí" jednu drahou operaci. Využívá metaforu bankovního účtu.
-
Každé operaci přiřadíme fiktivní kreditovou cenu.
-
Při realizaci operace zaplatíme skutečnou cenu naspořenými kredity.
-
Počáteční stav je 0 kreditů.
Pro každou operaci v posloupnosti:
-
Pokud je skutečná cena nižší než kreditová, tak zaplatíme skutečnou cenu a přebývající kredity uspoříme na účtu.
-
Pokud je skutečná cena vyšší než kreditová, tak zaplatíme skutečnou cenu a případný nedostatek kreditů doplatíme z úspor na účtu.
Important
|
Pokud je po celou dobu provádění operací stav účtu nezáporný, pak je skutečná složitost celé posloupnosti operací menší nebo rovna součtu kreditových cen operací. |
Warning
|
Pokud stav účtu kdykoliv během posloupnosti klesne pod nulu, pak jsou kreditové ceny nastaveny špatně! |
Tip
|
Tato metoda se dá upravit tak, že kredity náleží individuálním objektům ve struktuře místo struktury jako celku. Cena operace se pak platí z kreditů objektů, nad kterým operace probíhá. |
Operace | Skutečná cena | Kreditová cena |
---|---|---|
|
1 |
2 |
|
1 |
0 |
|
0 |
- Invariant
-
Počet kreditů na účtu je rovný počtu prvků na zásobníku.
- Důkaz
-
-
Invariant platí pro prádný zásobník.
-
S
Push
operací se na účet připíše právě 1 kredit. (Čímž se předplatíPop
neboMulti-Pop
.) -
Pop
aMulti-Pop
operace spotřebují právě 1 kredit z účtu. -
Tedy stav účtu nikdy neklesne pod 0.
-
Tedy složitost posloupnosti je nejvýše součet kreditových cen, tedy .
-
Potential method (physicist’s method)
Hraje si s představou toho, že struktura je fyzikální systém s nějakou energetickou hladinou — potenciálem. Výhodou této metody je, že stačí zvolit jednu funkci, která splňuje dané podmínky. Nevýhodou je, že takovou funkci najít je těžké. Člověk zkrátka buď dostane nápad nebo ne.
- Potenciálová funkce
-
Funkce , která přiřadí dané struktuře hodnotu. Platí, že:
- Amortizovaná cena
-
Pokud je skutečná cena operace, pak pro amortizovanou cenu platí:
- Potenciálová věta
-
Počínaje počátečním stavem , celková skutečná cena posloupnosti operací je nejvýše součet jejich amortizovaných cen.
- Důkaz
-
(počet prvků na zásobníku)
Operace | Skutečná cena | Amortizovaná cena |
---|---|---|
|
1 |
|
|
1 |
|
|
- Věta
-
Počínaje prázdným zásobníkem, libovolná sekvence operací zabere času.
- Důkaz (případ
Push
) -
-
Skutečná cena je .
-
Amortizovaná cena je .
-
- Důkaz (případ
Pop
) -
-
Skutečná cena je .
-
Amortizovaná cena je .
-
- Důkaz (případ
Multi-Pop
) -
-
Skutečná cena je .
-
Amortizovaná cena je .
-
- Důkaz (závěr)
-
-
Amortizovaná cena všech operací je .
-
Součet amortizovaných cen posloupnosti operací je pak .
-
Z potenciálnové věty plyne, že skutečná cena posloupnosti je .
-
Vyhledávání řetězců (string matching)
String matching označuje rodinu problémů obsahující třeba:
-
Nalezení prvního přesného výskytu podřetězce (patternu) v řetězci (stringu / textu).
-
Nalezení všech výskytů podřetězce v řetězci.
-
Výpočet vzdálenosti dvou řetězců.
-
Hledání opakujících se podřetězců.
Většinou je řetězec polem znaků z konečné abecedy . String matching algoritmy se ale dají použít na ledacos.
Vzorek se vyskytuje v textu s posunem , pokud a zároveň . Pro nalezení platných posunů lze použít řadu algoritmů, které se liší složitostí předzpracování i samotného vyhledávání: [2]
Algoritmus | Preprocessing | Searching |
---|---|---|
Brute force / naivní |
||
Karp-Rabin |
||
finite automata |
||
Knuth-Morris-Pratt |
||
Boyer-Moore |
-
nebo — text.
-
nebo — pattern.
-
— délka textu .
-
— délka vzorku / podřetězce / patternu .
-
— konečná abeceda, ze které je složen text i pattern.
Brute force / naivní
Prochází všechny pozice v textu a porovnává je s patternem. Pokud se neshodují, posune se o jedno pole dopředu.
Pokud se text neshoduje už v prvním znaku, je složitost lineární. Avšak v nejhorším případě, kdy se pattern shoduje s textem až na poslední znak, je složitost až kvadratická.
int Naive(string text, string pattern)
{
int n = text.Length;
int m = pattern.Length;
for (int i = 0; i < n - m + 1; i++)
{
// NB: Substring creates a new string but let's not worry about that.
if (text.Substring(i, m) == pattern)
{
return i;
}
}
return -1;
}
- Složitost
-
Cyklus je proveden -krát. V každé iteraci se provede přinejhorším až porovnání. (Zanedbáváme složitost
Substring
, která v C# dělá kopii.)
Karp-Rabin
Používá hashování. Vytvoří hash z patternu a hashuje i všechny podřetězce délky v textu. Nejprve eliminuje všechny pozice, kde se hashe neshodují. Pokud se shodují, porovná je znak po znaku.
int KarpRabin(string text, string pattern)
{
int n = text.Length;
int m = pattern.Length;
int patternHash = pattern.GetHash();
for (int i = 0; i < n - m + 1; i++)
{
// NB: Assume that `GetHash` is a rolling hash, even though in .NET it's not.
if (text.Substring(i, m).GetHash() == patternHash)
{
if (text.Substring(i, m) == pattern)
{
return i;
}
}
}
return -1;
}
- Hashování
-
Trik spočívá v použití rolling hashovacího algoritmu. Ten je schopný při výpočtu hashe pro použít hash s využitím pouze jednoho dalšího znaku . Přepočet hashe je tedy .
Jeden takový algoritmus lze získat použitím Hornerova schématu pro evaluaci polynomu. [4] Předpokládejme, že (velikost může být libovolná), pak pro hash platí
Pokud jsou hashe příliš velké, lze navíc použít modulo , kde .
- Složitost
-
Předzpracování zahrnuje výpočet v .
Složitost výpočtu je v nejhorším případě , jelikož je potřeba porovnat všechny podřetězce délky s patternem.
Tento algoritmus se hodí použít, pokud hledáme v textu celé věty, protože neočekáváme velké množství "falešných" shod, které mají stejný hash jako . V tomto případě je průměrná složitost .
Konečné automaty
Složitost naivního algortmu lze vylepšit použitím konečného automatu.
Mějmě DFA , kde přechodobou funkci definujeme jako:
Jinými slovy, vrací delků nejdelšího možného začátku , který se nachází na daném místě (stavu ) v řetězci .
Prakticky by příprava přechodové funkce mohla vypadat takto:
int[,] CreateAutomaton(string pattern)
{
int m = pattern.Length;
// NB: Assumes that the alphabet is ASCII.
int[,] automaton = new int[m + 1, 256];
for (int q = 0; q <= m; q++)
{
for (int c = 0; c < 256; c++)
{
int k = Math.Min(m + 1, q + 2);
do
{
k--;
}
while (!pattern.Substring(0, q).Equals(pattern.Substring(0, k) + (char)c));
automaton[q, c] = k;
}
}
return automaton;
}
Vyhledávání v textu pak bude vypadat takto:
int FiniteAutomaton(string text, string pattern)
{
int n = text.Length;
int m = pattern.Length;
int[,] automaton = CreateAutomaton(pattern);
int q = 0;
for (int i = 0; i < n; i++)
{
q = automaton[q, text[i]];
if (q == m)
{
return i - m + 1;
}
}
return -1;
}
Tato metoda šetří čas, pokud se pattern v některých místech opakuje. Mějmě například pattern abcDabcE
a text abcDabcDabcE
. Tato metoda nemusí začínat porovnávat pattern od začátku po přečtení druhého D
, ale začne od (včetně), protože ví, že předchozí část patternu se již vyskytla v textu.
Jinými slovy na indexu druhého D
je abcD
nejdelší prefix , který je zároveň suffixem už načteného řetězce.
- Složitost
-
Vytvoření automatu vyžaduje času, dá se však provést efektivněji než v
CreateAutomaton
a to v čase .Složitost hledání je pak v . [2]
Knuth-Morris-Pratt (KMP)
KMP představuje efektivnější využití idei z metody konečného automatu:
-
Každý stav je označen písmenem z patternu. Výjimkou je počáteční stav a koncový stav .
-
Každý stav má hranu
success
, která popisuje sekvenci znaků z patternu, afailure
hranu, která míří do některého z předchozích stavů — takového, že už načtené znaky jsou největší možný prefix patternu.
V reálné implementaci nejsou success
hrany potřeba; potřebujeme jen vědět, kam skočit v případě neúspěchu.
/// <summary>
/// Computes the longest proper prefix of P[0..i]
/// that is also a suffix of P[0..i].
/// </summary>
int[] ComputeFailure(string pattern)
{
int m = pattern.Length;
int[] fail = new int[m];
int j = 0;
for (int i = 1; i < m; i++)
{
while (j >= 0 && pattern[j] != pattern[i])
{
j = fail[j];
}
// If comparison at i fails,
// return to j as the new starting point.
fail[i] = j;
j++;
}
return fail;
}
int KnuthMorrisPratt(string text, string pattern)
{
int[] fail = ComputeFailure(pattern);
int n = text.Length;
int m = pattern.Length;
// NB: I index from 0 here. Although I use 1..n in the text.
int i = 0;
int j = 0;
for (int i = 0; i < n; i++)
{
while (j >= 0 && text[i] != pattern[j])
{
/*
There can be at most n-1 failed comparisons
since the number of times we decrease j cannot
exceed the number of times we increment i.
*/
j = fail[j];
}
j++;
if (j == m)
{
return i - m;
}
}
return -1;
}
Warning
|
Nejsem si jistý, že ty indexy v kódu výše mám dobře. |
Note
|
"In other words we can amortize character mismatches against earlier character matches." [2] |
- Složitost
-
Amortizací neúspěšných porovnání vůči úspěšným získáme pro
ComputeFailure
a proKnuthMorrisPratt
.