Rekurzivní algoritmy, tedy vlastně algoritmy s exponenciální složitostí, se dají technikami dynamického programování zrychlit do použitelné podoby, takže je možné je v praxi používat. U mnoha jiných algoritmů to ale pravda není a při jejich řešení si musíme pomoci nějakým „trikem“.
V této přednášce si ukážeme srovnání tří zcela rozdílných přístupů k řešení problému a také poukážu na nebezpečí, jaká na nás při tom čekají:
Mějme za úkol vyřešit následující úlohu:
Máte k dispozici libovolný počet mincí o hodnotě 9, 4 a 1. Vaším úkolem je sestavit z nich částku 12 tak, abyste jich použili co nejméně.
Každý hned vidí, že optimálním řešením jsou tři mince o hodnotě 4. Jak k němu ale dojít programově?
Jelikož cílová částka musí být tvořena nějakou kombinací uvedených tří druhů mincí, první, co by mohlo přijít na mysl, je hrubá síla (brute force) – generovat z množiny {1, 4, 9}
všechny možné kombinace s opakováním, vybrat z nich ty, které dávají požadovaný součet 12 a z nich pak vybrat tu s nejmenším počtem mincí.
I když je jasné, že s generováním kombinací se nemůžeme moc „rozohnit“ – zjevně se stačí omezit na maximálně dvanáctiprvkové (protože 12 * 1 = 12; tohle číslo bude také třeba nějak dopředu zjistit), napsat takový algoritmus v Python'u bude snesitelné asi jenom proto, že Python poskytuje nástroje na snadné generování požadovaných skupin hodnot. Kterých mimochodem bude podle známého vzorce:
C'(3, 1) + C'(3, 2) + ... + C'(3, 12) = 454
Díky Python'u je výsledný program docela hezký. Nicméně kombinací obou smyček se i pro takto jednoduché zadání musela vnitřní podmínka testovat čtyři sta padesát čtyřikrát. A bude-li hledaná částka vyšší…
Je-li množina možných výsledků, ze kterých je třeba vybrat, příliš obsáhlá, nemusí se generování všech možností hrubou silou už vůbec vyplatit (může trvat moc dlouho, nemusí se vejít do paměti ani na disk…). Pomoci si můžeme třebas právě hladovým algoritmem (též „žravým“ či „chamtivým“; anglicky pak univerzálně greedy), který se k řešení staví úplně jinak:
V každém kroku vybereme z dostupných možností tu, která nás pravděpodobně dovede nejrychleji k cíli.
V našem případě by hladový algoritmus vybral jako první minci 9, protože ta nás ze všech mincí nejvíce (a tudíž nejrychleji) přiblíží k požadované cílové částce. Jenže pak už mu nezbyde nic jiného, než přihodit třikrát minci 1, protože mincí 4 by okamžitě přehodil. Výsledkem tedy bude množina {9, 1, 1, 1}
– požadovaný součet 12 dává, ale optimální řešení to není, protože spotřebovalo čtyři mince.
Ovšem pozor – hladový algoritmus v nejjednodušším provedení také nemusí žádné řešení najít, přestože existuje! Kupříkladu v naší úloze následující zadání..
..selže, přestože řešením jsou zcela jasně tři čtyřky, protože algoritmus jde „slepě“ za svým cílem a po přihození počáteční devítky už jednoduše nemá jak dokončit úlohu.
Obecně se tedy dá říci, že pokud „hladový“ algoritmus najde nějaké řešení, nedá se nijak zajistit, že to bude řešení optimální, a pokud žádné nenajde, tak to ještě neznamená, že úloha nemá řešení.
Podobnost hladového algoritmu s hledáním globálního minima funkce není náhodná – vyrazit ve směru největšího gradientu funkce je rozumný postup, jen prostě můžeme často skončit v minimu pouze lokálním.
Algoritmy pro hledání globálního minima to často zkouší řešit tak, že se naopak zkusí někdy posunout i ve směru přesně opačném – s trochou štěstí mohou přeskočit místní lokální/globální maximum a skončit na „svahu“, který už je po „spádu“ zavede do toho kýženého minima. Nebo taky ne.
Pokusit se podobným způsobem „oblafnout“ výběr cesty v hladovém algoritmu můžete také – třebas se v každém (nebo ne úplně každém) kroku rozhodujte o výběru dalšího prvku náhodně. Vyzkoušejte takto několik možných cest a až z nich vyberte tu nejlepší, třeba budete mít zrovna štěstí. A nebo taky ne ^_~
Chcete-li najít optimální řešení, nezbyde vám nic jiného, než možnosti vyzkoušet všechny.
Zatímco hrubá síla bude v tomto konkrétním případě možnosti těžce nadgenerovávat (protože mezi všemi automaticky generovanými kombinacemi bude naprostá většina zcela nepoužitelných – budou mít moc velký součet), rekurzivní úvaha množinu prozkoumávaných možností velmi omezí a za použití některé z technik dynamického programování bude i velmi rychlá.
Ovšem jak na to? Zatímco hladový algoritmus šel v každém kroku „podle čichu“ jedním konkrétním směrem (tím lokálně optimálním), takže snadno mohl minout ten správný, při hledání globálně optimálního řešení si to nemůžeme dovolit. Musíme tedy vyzkoušet (a to rekurzivně) každou z cest, která se nám nabízí, a poté z nich vybrat tu, která se ukázala nejlepší. Jinými slovy:
Vždy, když se bude třeba rozhodnout mezi více možnostmi, zavoláme rekurzivně všechny, a pak mezi navrácenými výsledky podle nějakého hodnocení* vybereme tu nejlepší.
Analýza uveného problému je asi nejpochopitelnější směrem shora – máme počáteční částku (12) a sadu mincí (9, 4 a 1), ze kterých ji máme složit:
Vyberme ze sady mincí jednu hodnotu (může to být zcela náhodně!) a provedeme následující:
Jako další krok pak zavoláme rekurzivně stejnou funkci, ale pro mírně upravený problém – v prvním případě pro stejnou sadu mincí, ale s nižší hledanou částkou, ve druhém pro stejnou částku, ale pro menší sadu mincí. Mezi oběma spočítanými možnostmi pak rozhodneme podle toho, kolik mincí která z nich potřebovala – vybereme tu s menším počtem.
Z výše naznačeného postupu je zjevné, že v nějakém kroku (nebo při jiném zadání i hned na začátku – třebas pro částku 7) dojde i na následující triviální případ:
Z předchozího naznačeného schématu řešení problému je zjevné, že koncové (triviální, kotevní) stavy rekurze budou dva:
Co v uvedených případech vrátit? Musí to být zjevně takový počet mincí, že při výběru lepšího řešení o „patro“ rekurzivního stromu volání výše zajistí výběr toho správného, tedy menšího:
Druhý případ zjevně volá po vrácení nekonečna, a ač to může vypadat neřešitelně, programovací jazyky většinou nějakou reprezentaci nekonečna obsahují, takže uvedený algoritmus napsat půjde.
float('infinity')
nebo zkráceně float('inf')
, od verze 3.5 pak lépe rovnou math.inf
.
Následující implementace výše popsaného algoritmu prozkoumá všechny možnosti a vrátí počet mincí použitých při nejlepší z nich:
Mnohem užitečnější by ale bylo, kdyby program spolu s počtem vracel i mince, které při tomto řešení použil.
To za prvé znamená, že se nám zkomplikuje návratová hodnota – bude to muset být něco, co bude držet jak počet mincí, tak mince samotné.
A za druhé to znamená, že tyto komplikovanější návratové hodnoty musíme nějak zpracovat.
Ukažme si nejdříve rovnou výsledné řešení, a pak si ho teprve okomentujme.
Vezměme to od prostředka – na větvi, kde minci nepoužijeme, se nic nemění. Na té druhé, kde ji naopak použijeme, se ale dějí věci:
Z didaktických důvodů jsem zvýraznil princip výběru lepší větve pomocí dotazu na první prvky příslušných dvojic (kde je uložen počet použitých mincí za příslušnou větev), přestože původní min() by fungoval stejně dobře (jsme v Python'u :-).
Když je mince, kterou chceme použít, na aktuální cílovou částku příliš veliká, opět se na řešení nic nemění (prostě rovnou vrátíme návratovou dvojci z příslušného rekurzivního volání). Jediná další změna se týká koncových případů – protože vracíme dvojci, musíme se rozhodnout, co má býti jejím druhým prvkem. Nu a invariantem na skládání seznamů je prázdný seznam, takže vrátíme ten.
Vzhledem k povaze problému by nás asi mohlo napadnout, že jsme si mohli ušetřit dvojci jako návratový typ, a vracet rovnou jenom seznam použitých mincí – jeho délka totiž hlásí přesně tu samou informaci použitou k rozhodování.
Bohužel to nejde tak snadno, protože bychom buď museli vyrobit strukturu, která na dotaz len()
vrací celočíselné nekonečno (a to v Python'u není), nebo přepsat (a výrazně zesložitit) rozhodovací část algoritmu.
Z grafu průběhu výpočtu je vidět, že řešit takovéto úlohy bez memoizace je nebezpečné – spousta výpočtů se zbytečně opakuje (červeně jsou zvýrazněny podstromy pro částku 3 s jedinou mincí 1).
Optimální řešení je zvýrazněno zelenou barvou. Všimněte si především, že „hladový“ algoritmus se hned v prvním rekurzivním volání zanořil do levého podstromu, čímž sice dostal výsledek velmi rychle (tak, jak je napsán, ho to stálo pouhých pět zavolání sebe sama), ale nebyl to tedy ten nejlepší.
Uvedené řešení vyžaduje důležitý komentář:
Z obou rekurzivních větví vybíráme vždy pouze jedno „vítězné“ řešení, přestože by se mohlo (snadno) stát, že stejně „optimální“ budou řešení obě.
A ještě více zvýrazněný by byl tento problém u algoritmů, které mají více než dvě rekurzivní větve*! V závislosti na požadovaném řešení se uvedené dilema v praxi řeší dvěma způsoby:
V dalším si náš algorimtus upravíme tak, aby vracel všechna řešení a ne jen pouze jednoho v podstatě náhodně vybraného kandidáta.
Budeme-li chtít rozměnit částku 12 za pomoci mincí o hodnotách 5, 4, 2 a 1, zjevně bychom kromě spousty neoptimálních řešení (budou obsahovat různý počet mincí 1) měli dostat především dvě různá řešení optimální se třemi mincemi:
{5, 5, 2}
{4, 4, 4}
Náš program musíme tedy upravit tak, aby dokázal všechna stejně optimální řešení vrátit (najít je už totiž umí). Nejjednodušší asi bude vracet použité mince jako seznam všech řešení a ne jen pouze jedno „vítězné“ jako dosud.
Nejdříve výsledný program:
A nyní komentáře k němu. Vezmeme to od konce:
+
je složíme dohromady.
[[]]
.
x
metodu append(), když je to seznam? Protože generátorovou notací vytváříme nový návratový seznam, jehož prvky by se v tomto případě nestaly upravené (pod)seznamy x, nýbrž návratové hodnoty operace x.append()
, kterými je ovšem hodnota None
!