Analýza zložitosti algoritmov

vyhľadávanie a triedenie

Osnova

  • Prečo riešime zložitosť algoritmov
  • Základné triedy zložitosti: O(1), O(log n), O(n), O(n²)
  • Kombinovaná zložitosť, prípady výpočtu a rast funkcií
  • Vyhľadávanie: lineárne, binárne a pomocou množiny
  • Triediace algoritmy a ich porovnanie
  • Záver

Prečo nás zaujíma zložitosť?

Keď píšeme programy, nestačí, aby „fungovali“. Musia byť aj efektívne.

  • Algoritmus môže bežať na rôznom hardvéri
  • Algoritmus môže spracovávať rôzne množstvo dát.

Koľko prostriedkov a zdrojov potrebujeme?

  • pamäť: RAM, pevný disk
  • výpočtová zložitosť
    • čas spracovania
    • potrobné procesory, CPU, GPU
    • elektrická energia

Ako hodnotíme "výkonnosť" algoritmu?

  • Emprické meranie je závislé od konkrétnej implementácie, konkrétnych dát a konkrétneho hardvéru.
    • meraním potrebných zdrojov v konkrétnej situácii (benchmarking)
  • Teoretická analýza umožní porovnávať algoritmy podľa toho, ako rastie ich náročnosť pri zväčšovaní vstupu.
    • teoretickou analýzou (vyjadrením asymptotickej zložitosti).

Benchmarking

  • meranie prostriedkov potrebných na vykonanie konkrétnej úlohy na konkrétnej hardvérovej konfigurácii.

Potrebujeme:

  • implementáciu
  • vzorové dáta rôznej veľkosti
  • fyzicky spustiť úlohy na rôznom hardvéri, "lepší" hardvér - potrebuje menej času

Ktorý algoritmus je lepší? (pre rôzne dáta)

máme dva rôzne algoritmy vyhľadávania v poli veľkosti N. Jeden (A) je implementovaný v C++, druhý (B) v Pythone.

Veľkosť zoznamu n Počítač A (ns) Počítač B (ns)
16 8 100 000
63 32 150 000
250 125 200 000
1 000 500 250 000

Na prvý pohľad vyzerá algoritmus A výrazne lepšie

Pri veľkých dátach sa situácia mení

Veľkosť zoznamu n Algoritmus A (ns) Algoritmus B (ns)
1 000 000 500 000 500 000
4 000 000 2 000 000 550 000
16 000 000 8 000 000 600 000
63 072 × 10¹² 1 rok 1,375 ms

Výsledky znázorníme ako krivku závislosti veľkosti dát a potrebného času alebo potrebnej pamäte

T (s)
^
|                  *
|                *
|             *
|          *
|      *
|  *
+---------------------------> N

Exponenciálna krivka na začiatku vyzerá ako lineárna

T (s)
^
|                 x*
|               x  *
|             x    *
|           x     *
|         x      *
| ..... x .... * ............
|     x     *
|   x   *
| x*
+---------------------------> N

Po čase exponenciálna krivka pretne lineárnu, nezávisle od konštánt

Zložitosť algoritmu vyjadrujeme pomocou podobnej krivky

Ktorá krivka je lepšia?

  • lineárna?
  • kvadratická?
  • log lineárna?
  • kubická?

Benchmark verzus teoretická analýza

Benchmark závisí od konkrétnej implementácie, dát a hardvéru. Teoretická analýza umožní porovnávať algoritmy podľa toho, ako rastie ich náročnosť pri zväčšovaní vstupu.

📚 Analýza algoritmov na Wikipédii

Pre získanie všeobecného porovnania je potrebná teoretická analýza:

  • definujeme teoretickú jednotku výpočtu
  • definujeme teoretickú veľkosť dát.

Potom nemusíme nič programovať a spúšťať, stačí porozmýšľať.

Abstraktné stroje pre analýzu zložitosti

umožnia analyzovať algoritmy nezávisle od konkrétnej implementácie a hardvéru.

Definujú množinu operácií, ktoré trvajú jednu jednotku času.

  • RAM model (Random Access Machine)
  • RASP model (Register Abstract Storage Machine)

📚 Referencie: EMU Studio

📚 Model RAM na Wikipédii

Rovnako veľké dáta môžu byť rôzne komplikované

  • v najlepšom prípade: dáta sú priaznivé, algoritmus nájde riešenie hneď
  • v priemernom prípade: dáta sú náhodné, algoritmus pracuje priemerne
  • v najhoršom prípade: dáta sú nepriaznivé, algoritmus musí prejsť všetky možnosti

Najlepší, priemerný a najhorší prípad

Štart funkcie potrebuje S operácií, jeden cyklus C operácií. Pole má veľkosť N.

def find(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i
    return -1
  • najlepší prípad - nájdeme hneď - potrebujeme S + 1 * C operáciu
  • najhorší - nájdeme na konci - potrebujeme S + N * C operácií.
  • priemerný - nájdeme v strede. - potrebujeme S + N * C / 2 operácií

📚 Analýza algoritmov

Čo je asymptotická zložitosť?

Popisuje, podľa ktorej krivky sa mení počet teoretických operácií v závislosti od veľkosti vstupu n.

  • Pri analýze zložitosti sa zameriavame na najhorší prípad.
  • Pri asymptotickej zložitosti sa zameriavame na rast funkcie. Konštanty zanedbávame, pretože pri veľkých vstupoch sú zanedbateľné.

📚 Big O notácia na Wikipédii

Základné triedy zložitosti algoritmov:

Používame najmä:

  • O(1) – konštantná - potrebný čas je stále rovnaký
  • O(n) – lineárna - potrebný čas je priamo úmerný veľkosti vstupu
  • O(n²) – kvadratická - potrebný čas je druhou mocninou veľkosti vstupu
  • O(log n) – logaritmická - potrebný čas rastie pomalšie ako priama úmera.

👉 Ignorujeme konštanty a menej významné členy.

O(1) – konštantná zložitosť

Operácia trvá rovnako dlho bez ohľadu na veľkosť vstupu.

def get_first(arr):
    return arr[0]

✔ Vždy 1 operácia ✔ Nezáleží na veľkosti poľa

📚 Algoritmická efektívnosť

O(n) – lineárna zložitosť

Čas rastie priamo úmerne s n.

def sum_array(arr):
    total = 0
    for x in arr:
        total += x
    return total

✔ Pre každý prvok robíme jednu operáciu ✔ Pri dvojnásobnom vstupe → dvojnásobný čas

📚 Lineárne vyhľadávanie

O(n²) – kvadratická zložitosť

Typicky vnorené cykly.

def print_pairs(arr):
    for i in arr:
        for j in arr:
            print(i, j)

✔ Počet operácií ≈ n × n ✔ Veľmi rýchlo rastie

📚 Rastová miera

O(log n) – logaritmická zložitosť

V každej iterácii zmenšujeme problém na polovicu (napr. binárne vyhľadávanie).

def binary_search(arr, target):
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = (left + right) // 2

        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1

📚 Binárne vyhľadávanie

Porovnanie rastu

Od najlepšieho po najhorší:

O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ)

📚 Porovnanie rastových mier

Kombinovaná zložitosť

Niektoré algoritmy majú viac častí:

def example(arr):
    # O(n)
    for x in arr:
        print(x)
    # O(n²)
    for i in arr:
        for j in arr:
            print(i, j)

👉 Výsledná zložitosť:

O(n + n²) = O(n²)

📚 Asymptotická analýza

Vyhľadávanie

Vyhľadávanie znamená nájsť prvok v kolekcii.

Typické otázky:

  • Nachádza sa prvok v zozname?
  • Na akom indexe sa nachádza?
  • Existuje prvok s danou vlastnosťou?

Zložitosť závisí od toho, či sú dáta usporiadané.

Lineárne vyhľadávanie

Prechádzame prvky jeden po druhom.

def linear_search(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i
    return -1

Zložitosť:

  • najlepší prípad: O(1)
  • najhorší prípad: O(n)
  • priemerný prípad: O(n)

📚 Lineárne vyhľadávanie

Kedy použiť lineárne vyhľadávanie?

Lineárne vyhľadávanie je vhodné, keď:

  • dáta nie sú zoradené,
  • kolekcia je malá,
  • vyhľadávame iba raz,
  • nechceme platiť cenu za triedenie.

Nevýhoda:

pri veľkom n môže byť pomalé

Binárne vyhľadávanie

Binárne vyhľadávanie funguje iba na zoradených dátach.

Myšlienka:

  1. pozri sa do stredu,
  2. rozhodni, v ktorej polovici môže byť hľadaná hodnota,
  3. druhú polovicu zahodíš,
  4. opakuj.

Každým krokom zmenšíme problém približne na polovicu.

📚 Binárne vyhľadávanie

Binárne vyhľadávanie v Pythone

def binary_search(arr, target):
    left = 0
    right = len(arr) - 1

    while left <= right:
        mid = (left + right) // 2

        if arr[mid] == target:
            return mid
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1

Zložitosť: O(log n)

📚 Binárne vyhľadávanie

Priebeh binárneho vyhľadávania

Hľadáme 23:

[2, 5, 8, 12, 16, 23, 38, 56]
             ↑
            12

23 je väčšie ako 12, ľavú polovicu zahodíme.

[16, 23, 38, 56]
      ↑
     23

Hodnota bola nájdená.

Cena za zoradenie

Ak dáta ešte nie sú zoradené, binárne vyhľadávanie vyžaduje triedenie.

arr = sorted(arr)          # O(n log n)
index = binary_search(arr, target)  # O(log n)

Pre jedno vyhľadanie sa to nemusí oplatiť.

Pre veľa vyhľadávaní nad rovnakými dátami sa triedenie môže vyplatiť.

📚 Triediaci algoritmus

Vyhľadávanie pomocou množiny

Ak nás zaujíma iba existencia prvku, často je vhodná množina.

items = {10, 20, 30, 40}

print(30 in items)

Priemerná zložitosť:

O(1)

Množina používa hash tabuľku.

📚 Hašovacia tabuľka

🎓 Triedacie algoritmy a ich zložitosť

Triedenie je základ pre:

  • vyhľadávanie (binary search funguje len na zoradených dátach)
  • databázy
  • optimalizáciu algoritmov

📚 Triediaci algoritmus

Bubble Sort – O(n²)

Jednoduchý, ale neefektívny algoritmus.

Myšlienka:

Opakovane prechádzame pole a vymieňame susedné prvky, ak sú v zlom poradí.

Zložitosť Bubble Sort

def bubble_sort(arr):
    n = len(arr)

    for i in range(n):
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
  • najhorší: O(n²)
  • priemerný: O(n²)
  • najlepší: O(n) (ak už je zoradené + optimalizácia)

👉 Veľmi pomalý pre veľké dáta

📚 Bubble sort

Selection Sort – O(n²)

V každom kroku nájdeme najmenší prvok a dáme ho na správne miesto.

  • vždy: O(n²)

👉 Jednoduchý, ale neefektívny

📚 Selection sort

def selection_sort(arr):
    n = len(arr)

    for i in range(n):
        min_idx = i
        # Hľadáme index s najmenším prvkom
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        # Vymenime aktualny a najmensi prvok
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
        # na indexe i je potom vždy najmenší prvok zo zvyšku poľa

Insertion Sort – O(n²), ale...

Budujeme zoradenú časť poľa postupne.

Zložitosť:

  • najhorší: O(n²)
  • najlepší: O(n) (takmer zoradené dáta)

👉 V praxi veľmi dobrý pre malé alebo skoro zoradené polia

📚 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

Merge Sort – myšlienka

Efektívny algoritmus založený na divide & conquer.

Myšlienka:

  • rozdel pole na polovice
  • zorad každú polovicu
  • spoj ich

Zložitosť: O(n log n)

📚 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

📚 Merge sort

Quick Sort – priemerne O(n log n)

  • vyber pivot
  • rozdeľ na menšie a väčšie
  • rekurzívne tried

Zložitosť:

  • priemerná: O(n log n)
  • najhoršia: O(n²)

👉 Veľmi rýchly v praxi, ale závisí od pivotu

📚 Quicksort

def quick_sort(arr):
    if len(arr) <= 1:
        return arr

    pivot = arr[len(arr) // 2]

    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]

    return quick_sort(left) + middle + quick_sort(right)

Zabudované triedenie v Pythone (Timsort)

arr.sort()
# alebo
sorted_arr = sorted(arr)

Zložitosť:

  • najhorší: O(n log n)
  • najlepší: O(n)

👉 Kombinácia:

  • Merge Sort
  • Insertion Sort

✔ Optimalizovaný pre reálne dáta ✔ Používa sa v praxi

📚 Timsort

Porovnanie triediacich algoritmov

Algoritmus Najhorší prípad Priemerný Najlepší
Bubble Sort O(n²) O(n²) O(n)
Selection Sort O(n²) O(n²) O(n²)
Insertion Sort O(n²) O(n²) O(n)
Merge Sort O(n log n) O(n log n) O(n log n)
Quick Sort O(n²) O(n log n) O(n log n)

📚 Porovnanie triediacich algoritmov

Praktická ukážka rozdielu

import random
import time

arr = [random.randint(0, 10000) for _ in range(1000)]

start = time.time()
sorted(arr)  # Timsort
print("Built-in:", time.time() - start)

start = time.time()
bubble_sort(arr.copy())
print("Bubble:", time.time() - start)

👉 Rozdiel môže byť stokrát až tisíckrát

📚 Timsort

Kedy použiť ktorý algoritmus?

  • malé dáta → Insertion Sort
  • veľké dáta → Merge / Quick / built-in
  • skoro zoradené → Insertion alebo Timsort
  • garantovaný výkon → Merge Sort

📚 Triediaci algoritmus

Mini cvičenie

Skús určiť zložitosť:

def mystery(n):
    i = 1
    while i < n:
        print(i)
        i *= 2

Očakávaná zložitosť: O(log n).

📚 Logaritmus

🔚 Záver

  • Asymptotická zložitosť hovorí, ako rastie čas algoritmu
  • Najčastejšie používame Big-O notáciu
  • Dôležité triedy: O(1), O(log n), O(n), O(n²)
  • Ignorujeme konštanty, sledujeme najhorší prípad
  • Triedenie krásne ukazuje rozdiel medzi O(n²) (pomalé) a O(n log n) (efektívne)
  • V praxi takmer vždy použi sorted(arr)

📚 Algoritmická efektívnosť

Reload?