Table of Contents

Základní algoritmy a datové struktury pro vyhledávání a řazení. Vyhledávací stromy, rozptylovací tabulky. Prohledávání grafu. Úlohy dynamického programování. Asymptotická složitost a její určování.

B4B33ALG Webové stránky předmětu

1. Řád růstu funkcí

Asymptotická složitost

Je způsob klasifikace algoritmů podle chování při rostoucím množství vstupních dat. Důležité pro srovnání algoritmů nezávisle na implementaci a výpočetní síle.

Horní asymptotický odhad (velké O): $f(n) \in O(g(n))$ → funkce $f$ je shora ohraničena funkcí $g$, až na konstantu. Formálně: $\exists c > 0, n_0 > 0 : \forall n > n_0 : f(n) \leq c \cdot g(n)$

Dolní asymptotický odhad (velká Ω): $f(n) \in \Omega(g(n))$ → funkce $f$ je zdola ohraničena funkcí $g$. Formálně: $\exists c > 0, n_0 > 0 : \forall n > n_0 : f(n) \geq c \cdot g(n)$

Těsný asymptotický odhad (velké Θ): $f(n) \in \Theta(g(n))$ → funkce $f$ je ohraničena funkcí $g$ shora i zdola. Formálně: $f(n) \in O(g(n))$ a zároveň $f(n) \in \Omega(g(n))$

Poznámka: Třída složitosti obsahuje všechny algoritmy s “podobným” chováním až na konstantu.

→ Jinými slovy: asymptotická složitost nám říká, jak se bude algoritmus chovat pro opravdu velké vstupy. Nezáleží na detailech, implementaci nebo výkonu stroje – zajímá nás jen „trend“ růstu počtu operací.

Klasifikace dle složitosti

Od nejmenší po největší:

→ Čím „nižší“ složitost, tím lépe škáluje. Např. algoritmus s $O(n)$ zpracuje dvojnásobný vstup asi dvakrát pomaleji, ale $O(n^2)$ už čtyřikrát.

2. Rekurze

Stromy

Strom je základní datová struktura, která se skládá z kořene, uzlů a listů. Používá se pro uchovávání hierarchických dat, jako jsou například složky v počítači nebo organizační struktury.

Strom je speciálním případem grafu – je to souvislý, neorientovaný graf bez cyklů, ve kterém mezi každými dvěma uzly existuje právě jedna cesta.

V informatice se často používá tzv. kořenový strom, což je strom, kde začínáme právě od jednoho výchozího kořenového uzlu.

Binární stromy

Binární strom je strom, kde každý uzel má maximálně dva potomky. Tito potomci se obvykle nazývají levý a pravý. Tato jednoduchá struktura je ale velmi výkonná a často používaná.

Reprezentace v jazyce C:

typedef struct node {
    int data;
    struct node *left;
    struct node *right;
} Node;

Využití

Stromy se široce využívají k reprezentaci hierarchických informací:

Také se využívají k realizaci algoritmů, např. při prohledávání, řazení nebo optimalizaci.

Procházení stromem

Jednou z nejčastějších operací nad stromy je jejich průchod – tedy návštěva všech uzlů. Nejčastěji se k tomu používá rekurze.

Existují tři klasické způsoby průchodu binárního stromu:

Rekurze

Rekurze je programovací technika, kdy funkce volá sama sebe, aby vyřešila menší instanci téhož problému. Je velmi vhodná pro strukturované problémy – jako jsou stromy nebo dělení na menší části.

Důležitou podmínkou každé rekurze je tzv. ukončovací podmínka, která zabrání nekonečnému volání – tedy přetečení zásobníku.

Rekurze se typicky používá v technice rozděl a panuj:

Příklady využití rekurze:

Backtracking (prohledávání s návratem)

Backtracking je technika, která prochází všechny možné kombinace, ale inteligentně vynechává ty, které už zjevně nevedou ke správnému řešení. Zkoušíme řešení krok za krokem a když zjistíme, že už nemůže být platné, vracíme se zpět a zkusíme jinou větev.

Tato metoda je známá z problémů jako jsou $n$-dámy nebo sudoku. Umí ušetřit obrovské množství času oproti čistému brute-force.

Aby backtracking fungoval, musí být splněno:

Jak takový algoritmus vypadá?

def candidates(solution):
  # Z předchozího řešení vygenerujeme další možné volby
  ...
  return candidates
 
def add(candidate, solution):
  # Přidání kandidáta do řešení
  ...
  return new_solution
 
def remove(candidate, new_solution):
  # Odebrání kandidáta, krok zpět
  ...
  return old_solution
 
def is_valid(solution):
  # Ověříme platnost řešení
  ...
  return True/False
 
def process(solution):
  # Hotové řešení nějak zpracujeme
  print(solution)
 
def backtracking(solution):
  if solution is complete:
    process(solution)
  else:
    for next in candidates(solution):
      if is_valid(next):
        solution = add(next, solution)
        backtracking(solution)
        solution = remove(next, solution)

Mistrovská věta

Když máme rekurzivní algoritmus, chceme často zjistit jeho časovou složitost. Pokud má tvar:

$T(n) = a \cdot T(n / b) + f(n)$ můžeme použít tzv. mistrovskou větu, která nám umožní složitost určit rychle – bez nutnosti řešit rekurentní rovnici ručně.

Možnosti:

Příklad:

Substituční metoda

Další způsob, jak určit časovou složitost, je tzv. substituční metoda. Používá se k důkazu pomocí matematické indukce.

Jak postupovat:

Příklad:

Odhad: $T(n) = 2T(n/2) + n \in O(n \log n)$

Pak dokážeme, že: $$ T(n) \leq 2 \cdot c \cdot \frac{n}{2} \log \frac{n}{2} + n = c \cdot n \log n - c \cdot n + n $$ Stačí zvolit vhodnou konstantu $c$ a dostaneme požadovanou nerovnost.

Rekurzivní strom

Tato metoda spočívá v tom, že si celý průběh rekurze nakreslíme jako strom a sečteme náklady na všech úrovních.

Příklad: $T(n) = 3T(n/4) + n$

To je geometrická řada: $$ T(n) = n \cdot \left(1 + \frac{3}{4} + \left(\frac{3}{4}\right)^2 + \dots \right) = n \cdot \frac{1}{1 - 3/4} = 4n $$

⇒ Celková složitost $T(n) \in O(n)$

Shrnutí

3. Fronta, zásobník

Fronta

Fronta je abstraktní datová struktura, která funguje na principu FIFO (First In, First Out).

Zásobník

Zásobník je datová struktura s pořadím LIFO (Last In, First Out).

Průchod stromem do šířky (BFS)

BFS (Breadth-First Search) prochází graf po vrstvách.

Průchod stromem do hloubky (DFS)

DFS (Depth-First Search) se snaží jít co nejhlouběji, než se vrátí zpět.

IDDFS – iterativní prohledávání do hloubky

IDDFS (Iterative Deepening DFS) kombinuje výhody BFS a DFS.

Průchody grafem (BFS/DFS)

Přístupy platí nejen pro stromy, ale i pro obecné grafy.

Využití v grafech:

Průchody binárním stromem

BFS a DFS průchody pro následující graf:

 
[1] bfs show
  class Graph:
    def __init__(self):
        self.graph = defaultdict(list)
 
    def addEdge(self, u, v):
        self.graph[u].append(v)
 
    def BFS(self, s):
        visited = [False] * (max(self.graph) + 1)
 
        queue = []
 
        queue.append(s)
        visited[s] = True
 
        while queue:
            s = queue.pop(0)
            print(s, end=“ ”)
 
            for i in self.graph[s]:
                if not visited[i]:
                    queue.append(i)
                    visited[i] = True
 
  if __name__ == '__main__':
    g = Graph()
    g.addEdge(0, 1)
    g.addEdge(0, 2)
    g.addEdge(1, 2)
    g.addEdge(2, 0)
    g.addEdge(2, 3)
    g.addEdge(3, 3)
 
    print(“Following is Breadth First Traversal” “ (starting from vertex 2))
    g.BFS(2)
 
    > 2 0 3 1
 
 

 

[2] dfs show
  class Graph:
 
    def __init__(self):
        self.graph = defaultdict(list)
 
    def addEdge(self, u, v):
        self.graph[u].append(v)
 
    def DFSUtil(self, v, visited):
        visited.add(v)
        print(v, end=' ')
 
        for neighbour in self.graph[v]:
            if neighbour not in visited:
                self.DFSUtil(neighbour, visited)
 
    def DFS(self, v):
        visited = set()
 
        self.DFSUtil(v, visited)
 
  if __name__ ==__main__”:
    g = Graph()
    g.addEdge(0, 1)
    g.addEdge(0, 2)
    g.addEdge(1, 2)
    g.addEdge(2, 0)
    g.addEdge(2, 3)
    g.addEdge(3, 3)
 
    print(“Following is Depth First Traversal (starting from vertex 2))
 
    g.DFS(2)
 
    > 2 0 1 3 
 

 

4. Binární vyhledávací stromy

Binární vyhledávací stromy (BST)

Binární vyhledávací strom je binární strom, ve kterém jsou uzly uspořádány podle hodnoty klíčů:

Každý uzel může mít maximálně dva potomky. Díky tomuto uspořádání je možné rychle vyhledávat, vkládat nebo mazat prvky – pokud je strom vyvážený, mají operace časovou složitost $O(\log n)$.

Vyhledávání v BST

Vkládání

Mazání

AVL stromy

AVL stromy jsou samovyvažující binární vyhledávací stromy. Zajišťují, že rozdíl výšky levého a pravého podstromu (tzv. balance factor) každého uzlu je maximálně 1.

Rotace se používají k vyvážení stromu po vkládání nebo mazání:

Vyhledávání

Vkládání

Mazání

B-stromy

B-stromy jsou vyvážené vyhledávací stromy, které nejsou binární – uzly mohou mít více klíčů i potomků

B-strom je řízen parametrem minimální stupeň _t_ (t ≥ 2):

B-stromy umožňují efektivní vyhledávání, vkládání a odstraňování uzlů. Mají tyto vlastnosti:

Vyhledávání

Vkládání

→ Kořen se může rozdělit – tím se strom zvýší o úroveň.

Mazání

→ Všechny listy B-stromu jsou vždy na stejné úrovni – struktura je přirozeně vyvážená.

Srovnání stromů

Operace BST (nevyvážený) AVL B-strom
——————————–———
Vyhledávání $O(n)$ $O(\log n)$ $O(\log n)$
Vkládání $O(n)$ $\Theta(\log n)$ $\Theta(\log n)$
Mazání $O(n)$ $\Theta(\log n)$ $\Theta(\log n)$
Vyváženost ne ano ano

5. Algoritmy řazení

Insert Sort

Řadíme postupně – začínáme druhým prvkem v poli a vkládáme ho na správné místo do již seřazené části vlevo. Tímto způsobem postupně “rozšiřujeme” seřazenou oblast.

# Insertion Sort
def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr

Selection Sort

V každé iteraci najdeme nejmenší prvek z neseřazené části a vyměníme ho s prvkem na aktuální pozici. Tímto způsobem posunujeme nejmenší prvky na začátek.

# Selection Sort
def selection_sort(arr):
    for i in range(len(arr)):
        min_idx = i
        for j in range(i + 1, len(arr)):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr

Bubble Sort

Postupně procházíme pole a porovnáváme sousední prvky. Pokud jsou v nesprávném pořadí, prohodíme je. Největší prvek tak “bublá” směrem doprava.

# Bubble Sort
def bubble_sort(arr):
    for n in range(len(arr) - 1, 0, -1):
        swapped = False
        for i in range(n):
            if arr[i] > arr[i + 1]:
                arr[i], arr[i + 1] = arr[i + 1], arr[i]
                swapped = True
        if not swapped:
            break
    return arr

QuickSort

Vysoce efektivní algoritmus typu “rozděl a panuj”. Vybereme prvek jako pivot, rozdělíme pole na dvě části (menší a větší než pivot) a rekurzivně řadíme každou část.

# QuickSort
def quicksort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[0]
    less = [x for x in arr[1:] if x < pivot]
    greater_eq = [x for x in arr[1:] if x >= pivot]
    return quicksort(less) + [pivot] + quicksort(greater_eq)

Merge Sort

Rozdělíme pole na poloviny, každou rekurzivně seřadíme a pak sloučíme do jednoho seřazeného celku. Při slévání porovnáváme vždy první prvek obou podpolí.

# Merge Sort
def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)
 
def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

Heap Sort

Vytvoříme haldu z pole (max-heap nebo min-heap), a opakovaně vyjímáme kořen (největší/nejmenší) a přesouváme ho na konec pole. Po každém vyjmutí haldu opravíme.

# Heap Sort
def heapify(arr, n, i):
    largest = i
    l = 2 * i + 1
    r = 2 * i + 2
    if l < n and arr[l] > arr[largest]:
        largest = l
    if r < n and arr[r] > arr[largest]:
        largest = r
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)
 
def heap_sort(arr):
    n = len(arr)
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]
        heapify(arr, i, 0)
    return arr

Radix Sort

Řadí čísla nebo řetězce po cifrách (např. nejprve podle jednotek, pak desítek…). Používá stabilní řadicí algoritmus jako Counting Sort pro každou cifru.

# Radix Sort
def counting_sort_by_digit(arr, exp):
    n = len(arr)
    output = [0] * n
    count = [0] * 10
    for i in range(n):
        index = (arr[i] // exp) % 10
        count[index] += 1
    for i in range(1, 10):
        count[i] += count[i - 1]
    for i in reversed(range(n)):
        index = (arr[i] // exp) % 10
        output[count[index] - 1] = arr[i]
        count[index] -= 1
    return output
 
def radix_sort(arr):
    if not arr:
        return []
    max_val = max(arr)
    exp = 1
    while max_val // exp > 0:
        arr = counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr

Counting Sort

Nevyužívá porovnávání, ale frekvenční pole. Nejprve spočítáme výskyty všech hodnot, pak je seřadíme podle počtu. Funguje pouze pro celočíselné hodnoty v omezeném rozsahu.

# Counting Sort
def counting_sort(arr):
    if not arr:
        return []
    max_val = max(arr)
    count = [0] * (max_val + 1)
    for num in arr:
        count[num] += 1
    output = []
    for i in range(len(count)):
        output.extend([i] * count[i])
    return output

Shrnutí složitostí a vlastností

Algoritmus Průměrná složitost Stabilní In-place Vhodné použití
————————————–———-———-———————————
Insert Sort $O(n^2)$ Ano Ano Malé nebo téměř seřazené pole
Selection Sort $O(n^2)$ Ne Ano Výuka, málo pohybů
Bubble Sort $O(n^2)$ Ano Ano Jednoduchá implementace
QuickSort $O(n \log n)$ Ne Ano Prakticky nejrychlejší
Merge Sort $O(n \log n)$ Ano Ne Stabilita, externí řazení
Heap Sort $O(n \log n)$ Ne Ano Stabilní výkon, bez rekurze
Counting Sort $O(n + k)$ Ano Ne Hodně opakujících se čísel
Radix Sort $O(n \cdot k)$ Ano Ne Řetězce/čísla s omezenou délkou

Stabilita znamená, že prvky se stejnou hodnotou zůstávají ve stejném pořadí jako na vstupu.

6. Dynamické programování

Je to všeobecná strategie pro řešení optimalizačních úloh. Významné vlastnosti:

Dynamické programování pro Nejdelší Společnou Posloupnost (LCS)

def lcs(X, Y):
    m = len(X)
    n = len(Y)
    # Vytvoření DP tabulky s nulami (m+1 x n+1)
    dp = [[0]*(n+1) for _ in range(m+1)]
 
    # Naplnění tabulky
    for i in range(1, m+1):
        for j in range(1, n+1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
 
    # Rekonstrukce LCS z tabulky
    i, j = m, n
    result = []
    while i > 0 and j > 0:
        if X[i-1] == Y[j-1]:
            result.append(X[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    return ''.join(reversed(result))

Příklad tabulky pro `X = “BDCABA”`, `Y = “ABCBDAB”`:

Řešení: “BCBA” (délka 4).

Tabulka:

A B C B D A B
0 0 0 0 0 0 0 0
B 0 0 1 1 1 1 1 1
D 0 0 1 1 1 2 2 2
C 0 0 1 2 2 2 2 2
A 0 1 1 2 2 2 3 3
B 0 1 2 2 3 3 3 4
A 0 1 2 2 3 3 4 4

7. Rozptylovací tabulky (Hashing)

Algoritmické úlohy (soutěžní)

Praktické použití

Kolize v hashovacích tabulkách

Existují tři běžné způsoby řešení kolizí:

Zřetězený hashing

Výhody:

Nevýhody:

Složitost:

Otevřené rozptylování – Linear probing

Jak to funguje – pokud je políčko z hashu obsazeno, posouváme se lineárně dál (modulo n).

Výhody

Nevýhody

Složitost

Otevřené rozptylování – Double hashing

Používáme dvě hashovací funkce:

Algoritmus sondování $index(i) = (h1(k) + i * h2(k)) \mod n$, $i = 0,1,2,…$

Výhody

Nevýhody

Složitost

Srůstající (coalesced) hashing

Kombinuje výhody otevřeného rozptylování a zřetězení:

Různé varianty:

Složitost

Výhody

Nevýhody

Univerzální hashování

Myšlenka: místo jediné pevné hash-funkce zvolíme při inicializaci náhodně $h$ z rodiny $ℋ$, která splňuje podmínku univerzality:

$$ \forall x\neq y \quad \Pr_{h\inℋ}[\,h(x)=h(y)\,] \le \frac1m , $$

kde $m$ je počet bucketů.

Důsledky

Složitost