Funkce f(n)f(n) je vždy na stejné úrovni nebo nad funkcí g(n)g(n). To znamená, že f(n)f(n) roste rychleji než g(n)g(n). Funkci g(n)g(n) můžeme považovat za asymptotickou dolní mez pro funkci f(n)f(n). Tento pojem je zřejmý z následujícího grafu.

Příklad: vezměme f(n)=2n2+5f(n) = 2n^2 + 5 a g(n)=7ng(n) = 7n. Pro hodnotu n=0n = 0 máme:

f(n)=2(0)2+5=5,g(n)=7(0)=0,f(n) = 2(0)^2 + 5 = 5, \quad g(n) = 7(0) = 0,

což znamená, že f(n)>g(n)f(n) > g(n). Pokud n=1n = 1:

f(n)=2(1)2+5=7,g(n)=7(1)=7,f(n) = 2(1)^2 + 5 = 7, \quad g(n) = 7(1) = 7,

tudíž f(n)=g(n)f(n) = g(n). A pokud n=3n = 3:

f(n)=2(3)2+5=18+5=23,g(n)=7(3)=21,f(n) = 2(3)^2 + 5 = 18 + 5 = 23, \quad g(n) = 7(3) = 21,

což opět znamená, že f(n)>g(n)f(n) > g(n). Pro n > 3 \ tedy máme \( f(n) > c \cdot g(n), což lze napsat jako:

2n2+5Ω(n)2n^2 + 5 \in \Omega(n)

V nejhorším případě můžeme říci, že:

  1. f(n)=O(g(n))f(n) = O(g(n)) znamená, že f(n)f(n) je maximální čas, který implementace vyžaduje pro všechny instance velikosti nn.

  2. f(n)=Ω(g(n))f(n) = \Omega(g(n)) znamená, že existuje alespoň jedna instance velikosti nn, pro kterou implementace vyžaduje alespoň cg(n)c \cdot g(n).

  3. V nejhorším případě O(n2)O(n^2) znamená, že každou velkou instanci lze seřadit kvadratickým časem, zatímco Ω(n2)\Omega(n^2) znamená, že alespoň jedna instance velké velikosti vyžaduje kvadratický čas.

Příklady na notaci Ω\Omega

Příklad 1:

Prokažte, že 4n+3=Ω(n)4n + 3 = \Omega(n).

Řešení: Máme f(n)=4n+3f(n) = 4n + 3 a použijeme definici notace Ω\Omega. Pokud existují kladné konstanty cc a n0n_0 tak, že:

0cg(n)f(n) pro vsˇechny nn0,0 \leq c \cdot g(n) \leq f(n) \text{ pro všechny } n \leq n_0,

pak platí f(n)=Ω(n)f(n) = \Omega(n). Zde c=4c = 4 a n01n_0 \geq 1, takže platí:

f(n)=Ω(n)pro vsˇechna n1.f(n) = \Omega(n) \quad \text{pro všechna } n \geq 1.

Příklad 2:
Prokažte, že 4n3+3n+2=Ω(n3)4n^3 + 3n + 2 = \Omega(n^3).

Použijeme pravidlo limit. Podle pravidla limit:

limnf(n)g(n)=c(kde 0<c).\lim_{n \to \infty} \frac{f(n)}{g(n)} = c \quad \text{(kde } 0 < c \leq \infty\text{)}.

Máme f(n)=4n3+3n+2f(n) = 4n^3 + 3n + 2 a g(n)=n3g(n) = n^3. Spočteme limitu:

limn4n3+3n+2n3=limn(4+3nn3+2n3)=4.\lim_{n \to \infty} \frac{4n^3 + 3n + 2}{n^3} = \lim_{n \to \infty} \left( 4 + \frac{3n}{n^3} + \frac{2}{n^3} \right) = 4.

Tedy f(n)=Ω(n3)f(n) = \Omega(n^3).

Notace Θ\Theta (Těsně ohraničená)

Notace Θ\Theta se používá k vyjádření těsně ohraničené doby běhu algoritmu, která je mezi horní a dolní mezí. Představme si dvě funkce f(n)f(n) a g(n)g(n), kde existují dvě kladné konstanty c1c_1 a c2c_2 takové, že:

c1g(n)f(n)c2g(n).c_1 \cdot g(n) \leq f(n) \leq c_2 \cdot g(n).

To znamená, že růst funkce f(n)f(n) je těsně ohraničen funkcí g(n)g(n). Tento koncept je ukázán v následujícím grafu.

Příklad: Mějme f(n)=2n+8f(n) = 2n + 8 a g(n)=7ng(n) = 7n. Pro n2n \geq 2 platí:

5n<2n+8<7n.5n < 2n + 8 < 7n.

Tímto způsobem máme c1=5c_1 = 5 a c2=7c_2 = 7 pro n0=2n_0 = 2. Tato notace je přesnější než notace OO a Ω\Omega, protože přesně určuje, jak se funkce chová v limitním chování.

Důležité aspekty notace Θ\Theta

  1. Je to metoda pro vyjádření těsného ohraničení růstové rychlosti běhu algoritmu, jak zhora, tak zdola (horní a dolní mez).

  2. Popisuje minimální a maximální dobu, kterou algoritmus může potřebovat k dokončení.

  3. Notace Θ\Theta se používá pro analýzu průměrného případu algoritmu.

  4. Notace Θ\Theta přesně definuje „pořadí“ algoritmu, což znamená, že c1g(n)c_1 \cdot g(n) je horní mez pro f(n)f(n) a c2g(n)c_2 \cdot g(n) je dolní mez pro f(n)f(n), když f(n)=Θ(g(n))f(n) = \Theta(g(n)).

Limita a porovnání růstu funkcí

Pro porovnání růstu dvou funkcí se běžně používá limitní metoda. Existují tři hlavní případy, které nám umožňují určit vztah mezi dvěma funkcemi:

  1. Pokud limnt(n)g(n)=0\lim_{n \to \infty} \frac{t(n)}{g(n)} = 0, znamená to, že t(n)t(n) má menší růstovou rychlost než g(n)g(n).

  2. Pokud limnt(n)g(n)=\lim_{n \to \infty} \frac{t(n)}{g(n)} = konstanta, znamená to, že t(n)t(n) má stejnou růstovou rychlost jako g(n)g(n).

  3. Pokud limnt(n)g(n)=\lim_{n \to \infty} \frac{t(n)}{g(n)} = \infty, znamená to, že t(n)t(n) roste rychleji než g(n)g(n).

Pomocí těchto limitních pravidel a případů je možné efektivně porovnávat růst funkcí a určit, která funkce roste rychleji než jiná.

Jak správně využít haldy v algoritmech a jejich implementace

Pro vytvoření a správu haldy, což je speciální druh binárního stromu, který splňuje určitou vlastnost, je nutné porozumět několika základním operacím. Halda je datová struktura, která zajišťuje efektivní hledání a výběr maximálních nebo minimálních hodnot. Jakmile je halda správně postavena, může být použita k implementaci různých algoritmů, například pro třídění (heap sort) nebo efektivní vyhledávání.

V případě maximální haldy má každý uzel větší nebo rovnou hodnotu než jeho potomci. V případě minimální haldy je hodnota každého uzlu menší než hodnota jeho potomků. Struktura haldy je obvykle implementována jako binární strom, kde každý uzel může mít maximálně dva potomky. Důležitý je především způsob, jakým jsou uzly v haldě uspořádány. Pokud máme binární strom uložený v poli, můžeme snadno vypočítat pozice levého a pravého potomka uzlu.

Pro uzel na pozici ii v poli platí následující vztahy:

  • Levý potomek uzlu je na pozici 2i2i.

  • Pravý potomek uzlu je na pozici 2i+12i + 1.

Tento vzorec nám umožňuje vyhledat děti jakéhokoli uzlu v haldě, což je klíčové pro provádění operací jako je vkládání, mazání nebo úprava hodnoty.

Pro příklad, pokud máme uzel na pozici 1 v poli (hodnota 9), levý potomek se nachází na pozici 2 (hodnota 6) a pravý potomek na pozici 3 (hodnota 8). Pokud bychom chtěli implementovat operaci haldy, musíme zajistit, že každý rodič má hodnotu větší (nebo menší) než jeho potomci, v závislosti na typu haldy, kterou vytváříme.

V případě, že při manipulaci s haldou dojde k porušení její vlastnosti (například když rodič má menší hodnotu než jeden z potomků), je nutné provést operaci "heapify" neboli opravování haldy. Tento proces se provádí odspodu nahoru nebo naopak, v závislosti na konkrétní implementaci. Při heapify proces se porovná rodičovský uzel s jeho potomky a v případě potřeby se hodnoty mezi nimi prohodí, čímž se obnoví vlastnost haldy.

Představme si příklad, kde máme následující hodnoty v poli: [27, 17, 3, 16, 13, 10, 1, 5, 7, 12, 4, 8, 9, 0]. Když použijeme funkci Max_heapify na pozici 3, zjistíme, že hodnota 3 je menší než její levý potomek 16. Proto musíme hodnotu 3 posunout dolů a 16 nahoru, čímž obnovíme vlastnost maximální haldy.

Dalším způsobem práce s haldou je vkládání nových prvků. Pokud chceme přidat nový prvek do haldy, vložíme ho na poslední pozici a porovnáme s jeho rodičem. Pokud nový prvek porušuje vlastnost haldy, prohodíme ho s rodičem a tento proces opakujeme, dokud nebude halda opět v pořádku. Tento postup se nazývá "push-up" nebo "bubble-up".

Stejně tak můžeme prvek z haldy odstranit, obvykle se odstraňuje kořen, což je maximální (nebo minimální) prvek. Tento proces zahrnuje výměnu kořene s posledním prvkem v haldě, odstranění posledního prvku a následnou opravu haldy pomocí operace heapify, aby byla zachována její vlastnost.

Algoritmus pro odstranění kořene haldy je následující:

  1. Vyměníme kořen s posledním uzlem v haldě.

  2. Snížíme velikost haldy o 1.

  3. Provedeme operaci heapify od kořene, aby byla halda znovu v pořádku.

Při třídění pomocí haldy (heap sort) začneme vytvořením haldy z daných prvků. Poté opakovaně odstraňujeme kořen haldy (který obsahuje maximální nebo minimální hodnotu, v závislosti na typu haldy) a přidáváme tuto hodnotu do výsledného pole. Tento proces opakujeme, dokud nejsou všechny prvky odstraněny a seřazeny.

Pokud bychom chtěli setřídit seznam [1, 2, 3, 4, 5], můžeme začít tím, že z něj vytvoříme minimální haldu. Poté budeme opakovaně mazat kořen (nejmenší prvek) a umísťovat ho na poslední pozici v seznamu. Tento proces opakujeme, dokud není celý seznam seřazený.

Důležité je si uvědomit, že pro správnou funkci haldy je nezbytné zajistit, že všechny operace, ať už jde o přidávání, mazání nebo heapify, probíhají správně a efektivně. Při každé operaci se musíme ujistit, že halda vždy zachová svou vlastnost, což je klíčem k její efektivitě.

Další nezbytnou součástí práce s haldami je pochopení výpočetní složitosti jednotlivých operací. Například, operace vložení nebo odstranění prvku z haldy má časovou složitost O(logn)O(\log n), protože v nejhorším případě musíme procházet celou výšku stromu. Složitost samotného třídění pomocí haldy je O(nlogn)O(n \log n), což je vynikající pro velké množství dat.

Jak najít optimální uspořádání závorek v řetězci matic?

Při řešení problému řetězce matic se často setkáváme s potřebou najít optimální způsob, jak seřadit matice, abychom minimalizovali počet skalárních násobení. Tento problém je klasickým příkladem použití dynamického programování, kde cílem je najít optimální uspořádání pro daný řetězec matic.

Mějme daný řetězec matic, jehož dimenze jsou: <5, 10, 3, 12, 5, 50, 6>. Takové zadání obsahuje 6 matic a jejich dimenze jsou následovně:

  • P0 = 5, P1 = 10, P2 = 3, P3 = 12, P4 = 5, P5 = 50, P6 = 6.
    Délka řetězce matic je tedy 7, což znamená, že n = 6 (počet matic minus jedna).

Pro tento problém budeme využívat dvě pomocné tabulky: m a s. Tabulka m bude uchovávat minimální počet skalárních násobení pro různé intervaly, zatímco tabulka s bude uchovávat pozice, kde se dělení řetězce optimálně provádí.

Krok 1: Konstrukce pomocných tabulek

Začneme tím, že inicializujeme tabulku m tak, že pro každou diagonální položku (kde i = j) nastavíme hodnotu na 0. To je logické, protože pokud máme jen jednu matici, žádná násobení se neprovádějí.

Krok 2: Výpočet minimálních hodnot

Pro všechny ostatní hodnoty, kde i < j, musíme spočítat hodnoty v tabulce m a zvolit minimální počet skalárních násobení. To provedeme podle následujícího vzorce:

m[i,j]=minik<j(m[i,k]+m[k+1,j]+P[i1]P[k]P[j])m[i, j] = \min_{i \leq k < j} \left( m[i, k] + m[k+1, j] + P[i-1] \cdot P[k] \cdot P[j] \right)

Příklad pro m[1, 2] (tedy pro první a druhou matici) bude vypadat takto:

m[1,2]=m[1,1]+m[2,2]+P0P1P2=0+0+(5103)=150m[1, 2] = m[1, 1] + m[2, 2] + P_0 \cdot P_1 \cdot P_2 = 0 + 0 + (5 \cdot 10 \cdot 3) = 150

Pokračujeme ve výpočtu pro všechny ostatní hodnoty v tabulce m. Pro každé m[i, j] vybíráme hodnotu k, která minimalizuje počet násobení.

Krok 3: Rekonstrukce optimálního uspořádání

Jakmile máme vypočteny všechny hodnoty v tabulce m, musíme zjistit optimální uspořádání závorek. K tomu použijeme tabulku s, která nám ukazuje, kde bychom měli rozdělit řetězec matic, abychom dosáhli minimálního počtu násobení.

Začneme s indexy i = 1 a j = 6 a rekurzivně voláme funkci PRINT_OPTIMAL_PARENS, která nám vrátí správné uspořádání závorek.

Příklad výsledného uspořádání:

Po rekurzivním volání získáme optimální uspořádání jako:

((A1A2)(A3A4))((A5A6))((A_1 A_2) (A_3 A_4)) ((A_5 A_6))

Celkový počet skalárních násobení bude 2010. Tento výsledek ukazuje, jak můžeme optimálně zorganizovat matice v řetězci, abychom minimalizovali náklady na jejich násobení.

Co je důležité pro čtenáře?

Při řešení problému řetězce matic je klíčové pochopit, že optimalizace počtu násobení není závislá jen na samotných dimenzích matic, ale i na způsobu, jakým rozdělujeme řetězec matic. Dynamické programování poskytuje efektivní způsob, jak tento problém vyřešit, ale vyžaduje správnou konstrukci tabulek a rekurzivní analýzu. Výsledné řešení ukazuje nejen počet násobení, ale také optimální strukturu závorek, která minimalizuje náklady na násobení.