(seznam, zásobník, fronta, binární strom, obecný strom, vyhledávací stromy a jejich modifikace. Implementace binárních a vyhledávacích stromů a operací nad nimi)
Pro ukládání hodnot používáme v programech vedle jednoduchých proměnných různé datové struktury. Na ty se lze dívat ze dvou pohledů:
V této otázce se budeme zabývat některými základními datovými strukturami podle logiky přístupu k jednotlivým položkám dat (druhý pohled).
Seznam je datová struktura pro ukládání údajů, která je na rozdíl od datové struktury množina tvořena posloupností prvků s pevně stanoveným pořadím. V seznamu se mohou nacházet opakovaně stejné údaje, což je jeho další důležitá odlišnost od množiny. Do seznamu chceme mít schopnost ukládat nové hodnoty, vypouštět z něj údaje, popř. i testovat, zda je daná hodnota v seznamu obsažena. Pro implementaci seznamu se nabízí několik možností:
n
připadá log2(n)
realokací. Kromě toho si musíme pamatovat počet uložených prvků (velikost seznamu) a velikost alokovaného pole (kapacitu seznamu). Na rozdíl od klasického pole můžeme prvky libovolně odebírat a přidávat a nemusíme znát předem jejich počet. Výhodou je rychlý přístup k jednotlivým prvkům (O(1)), nevýhodou pomalé odebírání a vkládání prvků doprostřed seznamu (O(n)). Při tomto úkonu se všechny prvky za inkriminovaným místem musí posunout o jednu pozici doleva či doprava. Pomocí seznamu se dá lehce implementovat zásobník nebo fronta. Rozdíl je pouze v tom, do jaké části seznamu se prvky přidávají a z jaké se odebírají.
Zásobník je asi nejpoužívanějším typem seznamů. Je to takový seznam, u něhož jeden konec (tzv. vrchol zásobníku) slouží k přidání i odebírání prvků. Prvky se nemohou přidávat ani odebírat na druhém konci seznamu (označovaném jako dno zásobníku) ani nikde uprostřed. Kdykoli tedy chceme odebrat jeden prvek ze zásobníku, bude to ten, který byl do něj vložen jako poslední (je momentálně na vrcholu zásobníku). Zásobník proto bývá někdy označován jako struktura typu LIFO (last in - first out). Používá se např. jako základní datová struktura při řízení průchodu do hloubky, při zpracování aritmetických výrazů, při realizaci volání procedur a funkcí apod. Z hlediska obsluhy je to nejjednodušší typ seznamu.
První prvek pole vždy představuje dno zásobníku. Vedle vlastního pole dat použijeme ještě jednu proměnnou, která bude v každém okamžiku udávat index toho prvku pole, kde je momentálně vrchol zásobníku.
const Maxdelka = N; {N je číslo omezující maximální délku zásobníku} var zasobnik : array[1..Maxdelka] of datovytyp; vrchol : 0..Maxdelka; procedure Vytvor; begin vrchol := 0; end; procedure Vloz(X: datovytyp); begin if vrchol = Maxdelka then begin {ošetření překročení maximální velikosti} write('Pozor - zasobnik je plny.'); Halt; end; else begin vrchol := vrchol + 1; zasobnik[vrchol] := X; end; end; procedure Odeber(var X: datovytyp); begin if vrchol = 0 then begin {ošetření prázdnosti zásobníku} write('Pozor - zasobnik je prazdny.'); Halt; end; else begin X := zasobnik[vrchol]; vrchol := vrchol - 1; end; end; function Jeprazdny: boolean; begin if vrchol = 0 then Jeprazdny := true; else Jeprazdny := false; end;
Vystačíme si s obyčejným jednosměrným seznamem. Je jen třeba si uvědomit, v jakém směru mají být jednotlivé prvky seznamu zřetězeny. Přidávání prvků na oba konce jednosměrného seznamu je snadné, vypouštění prvku ze začátku jednosměrného seznamu také. Protože vypuštění posledního prvku je ale o dost náročnější (bylo by třeba nalézt předposlední prvek, k čemuž je nutné projít celý seznam od začátku), programuje proto zásobník vždy tak, aby v pořadí první prvek spojového seznamu představoval vrchol zásobníku.
type spoj = ^element; element = record hodnota: datovytyp; dalsi: spoj; end; var zasobnik : spoj; procedure Vytvor; begin zasobnik := nil; end; procedure Vloz(X: datovytyp); var pom: spoj; begin new(pom); with pom^ do begin hodnota := X; dalsi := zasobnik; end; zasobnik := pom; end; procedure Odeber(var X: datovytyp); var pom: spoj; begin if zasobnik = nil then begin write('Zasobnik je prazdny.'); Halt; end; else begin X := zasobnik^.hodnota; pom := zasobnik; zasobnik := zasobnik^.dalsi; dispose(pom); end; end; function Jeprazdny: boolean; begin if zasobnik = nil then Jeprazdny := true; else Jeprazdny := false; end;
V seznamu označovaném jako fronta se pracuje s uloženými daty takovým způsobem, jaký odpovídá čekání lidí ve frontě v běžném životě. Přicházející se řadí za sebe; kdo dřív přišel, bude dříve obsloužen a odejde. Do seznamu dat typu fronta jsou proto na jednom konci („příchod“) vkládány nové prvky, zatímco z druhého konce („odchod“) jsou odebírány prvky vyřazované. V anglicky psané literatuře bývá fronta označována jako FIFO (first in - first out).
Fronta se v programech používá tehdy, pokud potřebujeme dočasně odložit nějaká data a zachovat pro další zpracování jejich původní pořadí. Slouží jako základní datová struktura pro řízení průchodu do šířky. Její programová realizace polem i dynamicky je o něco komplikovanější, než tomu bylo v případě zásobníku.
Frontu můžeme naprogramovat pomocí pole, pokud známe horní odhad její maximální možné délky. Budeme přitom potřebovat dvě celočíselné proměnné, které budou udávat indexy těch prvků pole, kde je uložen první prvek fronty (tj. začátek fronty, odchod) a poslední (konec fronty, příchod). Opakovanými operacemi vkládání a vypouštění prvků se budou hodnoty obou těchto proměnných stále zvyšovat, jak se bude směrem k vyšším indexům posunovat obsazená část pole. Tak by se mohlo po čase stát, že ačkoli aktuální délka fronty nikdy nepřekročí velikost pole, nebude možné vložit do fronty další údaj, neboť poslední prvek pole bude obsazen. Na toto nebezpečí existují tři základní varianty řešení:
K naprogramování fronty pomocí lineárního spojového seznamu nám opět postačí jednosměrný seznam. Podobně jako v případě zásobníku musíme jen dobře zvolit směr zřetězení prvků v seznamu. K docílení efektivnosti je opět třeba frontu reprezentovat tak, aby její začátek (odchod) byl na začátku seznamu a její konec (příchod) na konci seznamu. Každý uzel zařazený do fronty tedy obsahuje ukazatel na svého následníka. Je to obráceně, než bývá zvykem v běžném životě (např. v čekárně u lékaře nebo u holiče), kde si lidé zpravidla pamatují svého předchůdce, tj. po kom přijdou na řadu.
Existují i různé modifikace fronty, jako tzv. obousměrná fronta (vkládat i odebírat lze na obou jejích koncích) či tzv. prioritní fronta (seznam, který není uspořádán v pořadí příchodu prvků, ale podle jejich priorit - každý zařazovaný prvek má svou prioritu a při vkládání není zařazován na konec fronty, ale „předbíhá“ všechny prvky fronty, které mají nižší prioritu než on). Realizace prioritní fronty pomocí seznamu je však méně efektivní, proto se prioritní fronta obvykle reprezentuje binární haldou.
Pojem strom je v teorii grafů formálně definován jako zvláštní případ acyklického grafu. Zvláštní skupinu mezi stromy pak tvoří tzv. zakořeněné stromy, v nichž je jeden význačný vrchol označen jako kořen stromu.
V informatice se jako strom obvykle označuje útvar, který je složen z jednotlivých vrcholů a má následující tvar:
Výška stromu, tj. počet jeho hladin, je pro prázdný strom rovna 0 a pro neprázdný strom je rovna počtu uzlů na jeho nejdelší větvi. V programech pracujeme pouze se stromy s konečnou výškou.
Zvláštním případem stromu je strom binární, v němž může mít každý vrchol nejvýše dva následníky. S binárním stromem se v programování setkáváme častěji než se stromem obecným. Slouží například pro reprezentaci aritmetického výrazu s binárními operátory nebo pro uložení dat do binárního vyhledávacího stromu či do haldy.
Datovou strukturu strom lze výhodně použít k ukládání údajů a vyhledávání nad nimi. Bi
nární vyhledávací strom je (na rozdíl od předchozích trochu složitější) datová struktura používaná pro ukládání a vyhledávání údajů. Je to binární strom, v jehož uzlech jsou umístěna data. Přitom pro uspořádání hodnot uložených ve stromu platí následující pravidlo:
Časová složitost operací nad vyhledávacím stromem závisí na jeho výšce. Bude-li množina uložených údajů předem pevně dána, není problém vybudovat tzv. vyvážený vyhledávací strom. Pokud se budou data v průběhu programu měnit, lze pro zajištění vyváženosti či další optimalizace vyhledávání obecně a v různých konkrétních případech zavést některou z modifikací vyhledávacích stromů:
Význam klíčů ve vnitřních uzlech je obdobný jako u binárních stromů: První klíč udává maximální hodnotu uloženou v levém podstromu, na nějž ukazuje první ukazatel, druhý klíč může udávat maximální hodnotu uloženou v prostředním podstromu (pokud má uzel jen dva následníky, tento klíč není definován). Pro uložení N údajů je třeba 2-3-strom o N listech. Pro mnohá N lze sestavit více ekvivalentních 2-3-stromů. Výška 2-3-stromu je logaritmicky úměrná počtu údajů N.
Tato pravidla nám zaručují, že nejdelší cesta z kořene do nějakého listu není delší než dvojnásobek nejkratší cesty z kořene do nějakého (jiného) listu.
Halda (heap) je speciální datová struktura, která má také tvar binárního stromu, ale využívá se nejen k vyhledávání, ale např.
i třídění pomocí haldy. Halda je binární strom, pro který jsou splněny následující podmínky:1)
Binární strom lze v programu reprezentovat např. v následující dynamické podobě:
type Uk = ^Uzel; Uzel = record Info: T; {uložená informace} L,P : Uk {levý a pravý následník} end;
Teoreticky je možné jakýkoliv obecný strom reprezentovat stromem binárním (a tedy v této struktuře), k čemuž se využívá tzv. kanonická reprezentace stromu - použitý trik spočívá v tom, že v každém uzlu je uložen odkaz na prvního syna a na „bratra“ tohoto uzlu. Z libovolného uzlu se tak dostaneme ke všem jeho následníkům tak, že přejdeme na prvního z nich po ukazateli na syna a a mezi následníky pak přecházíme pomocí jejich ukazatelů na bratry.
Vyhledávání vrcholu s hodnotou X v daném binárním vyhledávacím stromě je snadné. Začneme v kořeni stromu a postupujeme směrem k listům na základě porovnání hodnoty uložené v uzlu stromu s hodnotu X. Prohledávání končí buď nalezením uzlu s hodnotu X, nebo zjištěním, že v potřebném směru následník neexistuje a hodnota X tedy není ve stromě uložena. Počet provedených porovnání je menší nebo roven výšce stromu, u vyvážených vyhledávacích stromů je tedy časová složitost vyhledávání logaritmická.
Při přidávání uzlu do binárního vyhledávacího stromu se postupuje stejně jako při vyhledávání, jen je třeba si zapomatovat uzel, v němž procházení stromem skončilo a pod který je třeba zapojit nový uzel s přidávanou hodnotou. Složitost je opět logaritmická, ale ta může být narušena, jestliže stromy průběžně nevyvažujeme.
Operace vypouštění uzlu je o něco komplikovanější - je třeba do zbytku stromu správně navázat případné následníky rušeného uzlu - ale také ji lze implementovat s logaritmickou složitostí.
Implementace AVL-stromu spočívá v zavedení pomocné „technické položky“ (zmíněné výše) a provedení vyvážovacího algoritmu po každém provedení operace nad stromem, která způsobila nevyváženost stromu. Vlastní algoritmy jsou však dosti dlouhé a komplikované a dobře jsou popsány v literatuře a je dostupná jejich implementace v mnoha programovacích jazycích.2)
Operace vyhledávání v 2-3-stromě probíhá podobně jako v binárnéím vyhledávacím stromě, při průchodu se orientujeme podle hodnot klíčů. Trochu náročnější je vkládání nových hodnot a jejich vypouštění - příslušné algoritmy představují vždy jeden průchod od kořene k listu (vyhledání místa, kam se má připojovat nebo kde se má rušit) a poté jeden průchod zpět ke kořeni (modifikace struktury stromu a hodnot klíčů). Z důvodu závislosti počtu operací na hloubce stromu je časová složitost všech těchto operací opět logaritmická.
Vyhledávání, vkládání i rušení uzlu v červeno-černém stromě probíhá stejně jako v binárním vyhledávacím stromě. Vložení nebo odebrání prvku však může způsobit porušení pravidel. Proto je nutné v takovém případě strom vyvažovat. Vyvažování se provádí přebarvováním uzlů a rotací větví stromu a je dobře popsáno v literatuře3), svou komplikovaností ovšem přesahuje rozsah toto textu. Co je podstatné, je fakt, že i přes toto komplikované vyvažování mají operace operace vložení a odebrání prvku logaritmickou složitost – . Složitost vyhledání prvku je ve stejné třídě.
Svou strukturou zajišťuje halda nalezení minimálního prvku v konstantním čase, provedení operací odebrání minima a přidání nového prvku pak v čase logaritmickém. Její výhodou je algoritmická jednoduchost a možnost snadné realizace v poli, známe-li horní odhad maximálního počtu prvků v haldě. Přidání nového prvku do haldy probíhá jeho připojením na první volné místo v haldě (tj. konec obsazené části pole; aby byla zachována pravidla 1 a 2 pro haldu) a provádění série výměn hodnoty v uzlu s hodnotou v jeho předchůdci, dokud je to třeba (aby bylo zachováno pravidlo 3). Vypuštění minimálního prvku probíhá dosti podobně - hodnotu z kořenu smažeme a namísto toho do kořene přesuneme hodnotu z posledního uzlu haldy, kterou pak necháme dle potřeby „probublat“ na její správné místo dolů.
Marek Blahuš ICQ: 105136489 - většina textu otázky
Martin Gracík - implementace zásobníků v Pascalu
Shkodran Gerguri - červeno-černé stromy
Z mé strany je toto všechno, připomínky pište buďto do diskuze, nebo klidně i přímo upravujte, budu stránku průběžně sledovat. (Marek Blahuš)
Otázku si přečetl pan RNDr. Libor Škarvada a rámcově prošel. Jeho podněty pro doplnění textu, opravy nesrovnalostí a odstranění matoucích či k otázce se nevztahujících textů byly do otázky zaneseny. Tato kontrola je jen rámcová, stále se může stát, že v otázce zůstala zapomenutá chybka či nesrovnalost, vyučující za toto nenese odpovědnost, berte tuto rámcovou kontrolu jako formu pomoci od vyučujících pro studenty.