← Strona główna

12 — Algorytmy: etap 3

Rekurencja, silnia, Fibonacci, NWD, złożoność obliczeniowa w praktyce.

Przetwarzanie i algorytmy

1. Rekurencja — model mentalny

Rekurencja to technika gdzie funkcja wywołuje samą siebie z mniejszym problemem. Każde wywołanie to nowa kopia zmiennych lokalnych na stosie wywołań.

  • Warunek bazowy — zatrzymuje rekurencję, musi być zawsze!
  • Krok rekurencyjny — problem musi się zmniejszać ku warunkowi bazowemu
  • Stos wywołań — każde wywołanie czeka aż głębsze się zakończy
  • RecursionError — Python domyślnie ogranicza stos do ~1000 poziomów
  • Każdy algorytm rekurencyjny można przepisać na iteracyjny i odwrotnie
Stos wywołań — jak działa silnia(3) → czeka na silnia(2) → czeka na silnia(1) → czeka na silnia(0) → zwraca 1. Potem wartości wracają: 1→1→2→6. Wynik "cofa się" przez stos.
Wizualizacja stosu wywołań
def silnia_debug(n, poziom=0):
    wciec = "  " * poziom
    print(f"{wciec}silnia({n}) wywołana")

    if n == 0:
        print(f"{wciec}silnia(0) → zwraca 1")
        return 1

    wynik = n * silnia_debug(n - 1, poziom + 1)
    print(f"{wciec}silnia({n}) → zwraca {wynik}")
    return wynik

silnia_debug(4)
# silnia(4) wywołana
#   silnia(3) wywołana
#     silnia(2) wywołana
#       silnia(1) wywołana
#         silnia(0) wywołana
#         silnia(0) → zwraca 1
#       silnia(1) → zwraca 1
#     silnia(2) → zwraca 2
#   silnia(3) → zwraca 6
# silnia(4) → zwraca 24

2. Silnia — iteracyjna i rekurencyjna

Silnia n! = 1 × 2 × 3 × ... × n. Definicja: 0! = 1, n! = n × (n-1)!

  • 0! = 1 (z definicji — warunek bazowy)
  • 1! = 1
  • 5! = 120
  • 10! = 3 628 800
  • Silnia rośnie bardzo szybko — 20! już nie mieści się w int C/Java (ale Python ma dowolną precyzję)
Na egzaminie Musisz napisać silnię zarówno iteracyjnie (pętla while lub for) jak i rekurencyjnie. Obie wersje muszą zwracać poprawny wynik dla n=0.
Implementacja — silnia wszystkimi sposobami
# Rekurencyjna
def silnia_rek(n):
    if n == 0:
        return 1
    return n * silnia_rek(n - 1)

# Iteracyjna — for
def silnia_for(n):
    wynik = 1
    for i in range(2, n + 1):
        wynik *= i
    return wynik

# Iteracyjna — while
def silnia_while(n):
    wynik = 1
    i     = 2
    while i <= n:
        wynik *= i
        i += 1
    return wynik

# Test wszystkich wersji
for n in range(8):
    r = silnia_rek(n)
    f = silnia_for(n)
    w = silnia_while(n)
    print(f"{n}! = {r:6}  "
          f"{'✓' if r==f==w else '✗'}")

# Tabela silni
print("\nTabela silni:")
for n in range(11):
    print(f"{n:2}! = {silnia_for(n):10}")

3. Ciąg Fibonacciego

Ciąg Fibonacciego: F(0)=0, F(1)=1, F(n) = F(n-1) + F(n-2). Każdy wyraz to suma dwóch poprzednich.

  • 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144...
  • Prosta rekurencja ma złożoność O(2ⁿ) — bardzo wolna!
  • Wersja iteracyjna ma złożoność O(n) — zawsze preferuj na egzaminie
  • Memoizacja — zapamiętywanie wyników — redukuje złożoność do O(n)
Dlaczego prosta rekurencja jest wolna? fib(5) wywołuje fib(4) i fib(3). fib(4) wywołuje fib(3) i fib(2). fib(3) jest liczone dwukrotnie! Dla fib(40) — miliardy zbędnych wywołań.
Implementacja — Fibonacci kilkoma sposobami
# Rekurencyjna — prosta (wolna dla dużych n)
def fib_rek(n):
    if n <= 1:
        return n
    return fib_rek(n-1) + fib_rek(n-2)

# Iteracyjna — szybka, O(n)
def fib_iter(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

# Z memoizacją — słownik wyników
def fib_memo(n, pamiec={}):
    if n in pamiec:
        return pamiec[n]
    if n <= 1:
        return n
    pamiec[n] = fib_memo(n-1) + fib_memo(n-2)
    return pamiec[n]

# Wypisanie ciągu
def fib_ciag(ile):
    a, b = 0, 1
    for _ in range(ile):
        print(a, end=" ")
        a, b = b, a + b
    print()

print("Ciąg Fibonacciego (15 wyrazów):")
fib_ciag(15)
# 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

print(f"\nfib(10)  = {fib_iter(10)}")    # 55
print(f"fib(30)  = {fib_iter(30)}")    # 832040
print(f"fib(100) = {fib_memo(100)}")   # ogromna liczba!

4. Inne klasyczne algorytmy rekurencyjne

Rekurencja naturalnie opisuje problemy które mają strukturę "problem → mniejszy problem tego samego rodzaju".

  • Potęgowanie — xⁿ = x × xⁿ⁻¹
  • Suma cyfr — sum_cyfr(n) = n%10 + sum_cyfr(n//10)
  • Odwracanie stringa — rev(s) = rev(s[1:]) + s[0]
  • Wieże Hanoi — klasyczny problem rekurencyjny
  • Permutacje — wszystkie kolejności zbioru elementów
Szybkie potęgowanie — O(log n) xⁿ = (x²)^(n/2) gdy n parzyste, xⁿ = x × x^(n-1) gdy nieparzyste. Zamiast n mnożeń — tylko log₂(n). Dla n=1000: 1000 mnożeń vs 10!
Implementacja — klasyki rekurencji
# Potęgowanie — O(n)
def potega(x, n):
    if n == 0:
        return 1
    return x * potega(x, n - 1)

# Szybkie potęgowanie — O(log n)
def potega_szybka(x, n):
    if n == 0:
        return 1
    if n % 2 == 0:
        p = potega_szybka(x, n // 2)
        return p * p
    return x * potega_szybka(x, n - 1)

print(potega_szybka(2, 10))   # 1024

# Suma cyfr
def suma_cyfr(n):
    if n < 10:
        return n
    return n % 10 + suma_cyfr(n // 10)

print(suma_cyfr(12345))   # 15

# Odwracanie stringa
def odwroc(s):
    if len(s) <= 1:
        return s
    return odwroc(s[1:]) + s[0]

print(odwroc("Python"))   # nohtyP

# NWD rekurencyjnie
def nwd(a, b):
    if b == 0:
        return a
    return nwd(b, a % b)

print(nwd(48, 18))   # 6

5. Złożoność obliczeniowa — praktyka

Złożoność obliczeniowa mówi jak rośnie czas działania algorytmu gdy rośnie rozmiar danych n. Notation O(…) to "rząd wielkości" — ignorujemy stałe i czynniki mniej znaczące.

NotacjaNazwan=100n=1000Przykład
O(1)stała11dostęp do listy po indeksie
O(log n)logarytmiczna710wyszukiwanie binarne
O(n)liniowa1001000wyszukiwanie liniowe
O(n log n)liniowo-log.70010 000merge sort, timsort
O(n²)kwadratowa10 0001 000 000bubble/selection/insertion sort
O(2ⁿ)wykładniczaogromnafib rekurencyjny, brute force
Jak czytać złożoność kodu? Jedna pętla po n elementach → O(n). Dwie zagnieżdżone pętle po n → O(n²). Każde wywołanie rekurencyjne dzieli problem na pół → O(log n). Dwa wywołania rekurencyjne → O(2ⁿ).
Przykład — rozpoznawanie złożoności
# O(1) — stały czas
def pierwszy(lista):
    return lista[0]

# O(n) — jedna pętla
def suma(lista):
    wynik = 0
    for x in lista:       # n iteracji
        wynik += x
    return wynik

# O(n²) — dwie zagnieżdżone pętle
def wszystkie_pary(lista):
    pary = []
    for i in range(len(lista)):         # n razy
        for j in range(i+1, len(lista)):  # ~n/2 razy
            pary.append((lista[i], lista[j]))
    return pary   # łącznie: n*(n-1)/2 = O(n²)

# O(log n) — dzielenie zakresu na pół
def binarne(lista, x):
    l, p = 0, len(lista) - 1
    while l <= p:          # log2(n) iteracji
        s = (l + p) // 2
        if lista[s] == x: return s
        elif lista[s] < x: l = s + 1
        else: p = s - 1
    return -1

# O(n log n) — sortowanie z podziałem
def merge_sort(lista):
    if len(lista) <= 1:
        return lista
    srod  = len(lista) // 2
    lewo  = merge_sort(lista[:srod])    # log n poziomów
    prawo = merge_sort(lista[srod:])    # każdy poziom: O(n)
    return scal(lewo, prawo)

def scal(a, b):
    wynik = []
    i = j = 0
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            wynik.append(a[i]); i += 1
        else:
            wynik.append(b[j]); j += 1
    return wynik + a[i:] + b[j:]

6. Memoizacja i optymalizacja rekurencji

Memoizacja to technika zapamiętywania wyników funkcji — przy ponownym wywołaniu z tymi samymi argumentami zwracamy zapamiętany wynik zamiast liczyć od nowa.

  • Słownik jako "cache" wyników
  • Dekorator @functools.lru_cache — automatyczna memoizacja
  • Zamienia O(2ⁿ) na O(n) dla Fibonacciego
  • Programowanie dynamiczne — memoizacja + planowanie kolejności obliczeń
lru_cache na egzaminie Dekorator @lru_cache(maxsize=None) z modułu functools to najprostszy sposób na memoizację. Wystarczy jedna linia nad definicją funkcji — Python sam zapamiętuje wyniki.
Implementacja — memoizacja
import time
from functools import lru_cache

# Bez memoizacji — bardzo wolne dla dużych n
def fib_wolny(n):
    if n <= 1:
        return n
    return fib_wolny(n-1) + fib_wolny(n-2)

# Z lru_cache — bardzo szybkie
@lru_cache(maxsize=None)
def fib_szybki(n):
    if n <= 1:
        return n
    return fib_szybki(n-1) + fib_szybki(n-2)

# Ręczna memoizacja — słownik
cache = {}
def fib_cache(n):
    if n in cache:
        return cache[n]
    if n <= 1:
        return n
    cache[n] = fib_cache(n-1) + fib_cache(n-2)
    return cache[n]

# Porównanie czasu
n = 35

start = time.time()
print(f"fib_wolny({n})  = {fib_wolny(n)}")
print(f"Czas: {time.time()-start:.3f}s")

start = time.time()
print(f"fib_szybki({n}) = {fib_szybki(n)}")
print(f"Czas: {time.time()-start:.6f}s")

# fib_szybki działa błyskawicznie nawet dla n=500
print(f"fib_szybki(100) = {fib_szybki(100)}")

Zadania przykładowe z omówieniem

Zadanie 1 Silnia — iteracyjna vs rekurencyjna — porównanie łatwe

Napisz obie wersje silni. Dla n od 0 do 10 wypisz tabelę: n, n! (iteracyjnie), n! (rekurencyjnie) i informację czy wyniki są zgodne. Obsłuż przypadek n < 0.

Rozwiązanie
def silnia_rek(n):
    if n < 0:
        raise ValueError("Silnia nie istnieje dla n < 0")
    if n == 0:
        return 1
    return n * silnia_rek(n - 1)

def silnia_iter(n):
    if n < 0:
        raise ValueError("Silnia nie istnieje dla n < 0")
    wynik = 1
    for i in range(2, n + 1):
        wynik *= i
    return wynik

print(f"{'n':>4} {'Iteracyjna':>15} {'Rekurencyjna':>15} {'Zgoda':>6}")
print("-" * 44)

for n in range(11):
    it  = silnia_iter(n)
    rek = silnia_rek(n)
    ok  = "✓" if it == rek else "✗"
    print(f"{n:>4} {it:>15} {rek:>15} {ok:>6}")

# Obsługa błędnego wejścia
try:
    silnia_rek(-3)
except ValueError as e:
    print(f"\nBłąd: {e}")
Omówienie krok po kroku
  1. raise ValueError — rzucanie wyjątku
    Zamiast zwracać None lub -1 dla niepoprawnego wejścia, rzucamy wyjątek z opisowym komunikatem. Wywołujący może go złapać przez try/except.
  2. Warunek bazowy n == 0
    Sprawdzamy n < 0 przed n == 0 — gdyby kolejność była odwrotna i n=-1, funkcja rekurencyjna wywołałaby się z n=-2, -3... nieskończenie.
  3. Formatowanie tabeli
    :>4, :>15 — wyrównanie do prawej w kolumnach odpowiedniej szerokości. Wyniki silni rosną szybko — 10! = 3 628 800, potrzebujemy miejsca.
  4. try/except dla demonstracji
    Pokazujemy że błędne wejście jest obsługiwane elegancko. Program nie crashuje — wypisuje komunikat i kontynuuje działanie.
Zadanie 2 Fibonacci — porównanie szybkości średnie

Zaimplementuj trzy wersje Fibonacciego: naiwną rekurencję, iteracyjną i z memoizacją. Zmierz czas każdej dla n=30 i n=35. Wypisz wyniki i czasy w tabeli. Wyjaśnij w komentarzach dlaczego różnice są tak duże.

Rozwiązanie
import time
from functools import lru_cache

def fib_naiwna(n):
    if n <= 1:
        return n
    return fib_naiwna(n-1) + fib_naiwna(n-2)
    # O(2^n) — podwaja pracę przy każdym kroku

def fib_iter(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n+1):
        a, b = b, a + b
    return b
    # O(n) — jeden przebieg

@lru_cache(maxsize=None)
def fib_lru(n):
    if n <= 1:
        return n
    return fib_lru(n-1) + fib_lru(n-2)
    # O(n) — każdy wynik liczony raz

print(f"{'n':>4} {'Wersja':<12} {'Wynik':>12} {'Czas [s]':>12}")
print("-" * 44)

for n in [30, 35]:
    for nazwa, func in [("naiwna",   fib_naiwna),
                        ("iteracyjna", fib_iter),
                        ("lru_cache",  fib_lru)]:
        start = time.perf_counter()
        wynik = func(n)
        czas  = time.perf_counter() - start
        print(f"{n:>4} {nazwa:<12} {wynik:>12} {czas:>12.6f}")
Omówienie krok po kroku
  1. time.perf_counter()
    Dokładniejszy timer niż time.time() — mierzy z precyzją do nanosekund. Używaj go do pomiaru krótkich operacji.
  2. Lista tupli (nazwa, funkcja)
    Zamiast trzech osobnych bloków kodu — jedna pętla po liście par. To wzorzec "table-driven" — dane sterują przepływem programu.
  3. Dlaczego naiwna jest wolna?
    fib_naiwna(35) wykonuje fib_naiwna(34) i fib_naiwna(33). Każde z nich wywołuje dwa kolejne... Łączna liczba wywołań ≈ 2³⁵ ≈ 34 miliardy. Iteracyjna i lru_cache — tylko 35 operacji.
  4. @lru_cache a wynik dla dużych n
    lru_cache zapamiętuje wyniki między wywołaniami — po pierwszym uruchomieniu fib_lru(35) jest błyskawiczne, bo wyniki są już w cache.
Zadanie 3 Wieże Hanoi trudne

Zaimplementuj algorytm Wież Hanoi dla n krążków. Wypisz każdy ruch w formacie "Przenieś krążek X z palika A na palik C". Policz ile ruchów było łącznie i porównaj z wzorem 2ⁿ - 1.

Rozwiązanie
licznik_ruchow = 0

def hanoi(n, zrodlo, cel, pomocniczy):
    global licznik_ruchow
    if n == 1:
        licznik_ruchow += 1
        print(f"  Przenieś krążek 1 z {zrodlo} na {cel}")
        return
    # Przenieś n-1 krążków na palik pomocniczy
    hanoi(n-1, zrodlo, pomocniczy, cel)
    # Przenieś największy krążek na cel
    licznik_ruchow += 1
    print(f"  Przenieś krążek {n} z {zrodlo} na {cel}")
    # Przenieś n-1 krążków z pomocniczego na cel
    hanoi(n-1, pomocniczy, cel, zrodlo)

for n in range(1, 5):
    licznik_ruchow = 0
    print(f"\nHanoi dla n={n} krążków:")
    hanoi(n, "A", "C", "B")
    wzor = 2**n - 1
    print(f"Ruchów: {licznik_ruchow}, "
          f"wzór 2^{n}-1 = {wzor}, "
          f"{'✓' if licznik_ruchow == wzor else '✗'}")
Omówienie krok po kroku
  1. Trzy paliki jako parametry
    Elegancja rekurencji: "przenieś n-1 krążków z A na B (cel staje się pomocniczym), przenieś największy z A na C, przenieś n-1 z B na C (pomocniczy staje się źródłem)". Każde wywołanie rotuje role palików.
  2. Warunek bazowy — n == 1
    Jeden krążek — jeden ruch. Nie wywołujemy rekurencji. To najprostszy przypadek który zatrzymuje zagłębianie.
  3. global licznik_ruchow
    Zmienna globalna do liczenia — każde wywołanie bazowe i każdy duży ruch inkrementuje licznik. Alternatywa: funkcja zwraca liczbę ruchów (czystsze podejście).
  4. Wzór 2ⁿ - 1
    Minimalna liczba ruchów dla n krążków to 2ⁿ - 1. Dla n=10 to 1023 ruchy, dla n=64 (legenda o mnichu) to 1,8 × 10¹⁹ — przy 1 ruchu/sekundę: 585 miliardów lat.

Zadania do samodzielnego rozwiązania

Rekurencja jest trudna — ćwicz pisząc warunek bazowy jako pierwszy. Zawsze sprawdź czy problem się zmniejsza ku warunkowi bazowemu.

1★☆☆

Potęga rekurencyjna

Napisz rekurencyjną funkcję potega(x, n) dla n ≥ 0. Sprawdź dla x=2, n=0..10 czy wyniki zgadzają się z x**n.

Wskazówka: warunek bazowy n==0 → 1, krok: x * potega(x, n-1)
2★☆☆

Suma listy rekurencyjnie

Napisz rekurencyjną funkcję suma_rek(lista) sumującą elementy listy bez użycia sum() ani pętli.

Wskazówka: warunek bazowy pusta lista → 0, krok: lista[0] + suma_rek(lista[1:])
3★★☆

Liczba Tribonacciego

Ciąg Tribonacciego: T(0)=0, T(1)=1, T(2)=1, T(n)=T(n-1)+T(n-2)+T(n-3). Napisz wersję iteracyjną i z memoizacją. Wypisz pierwsze 15 wyrazów.

Wskazówka: trzy zmienne a, b, c zamiast dwóch; a,b,c = b,c,a+b+c
4★★☆

Wszystkie podzbiory

Napisz rekurencyjną funkcję generującą wszystkie podzbiory zbioru (listę list). Np. dla [1,2,3]: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]].

Wskazówka: element albo jest w podzbiorze albo nie — dwa wywołania rekurencyjne
5★★☆

Analiza złożoności

Napisz trzy funkcje: O(n), O(n²), O(log n). Zmierz czas dla n=1000, 10000, 100000. Wypisz tabelę i sprawdź czy proporcje czasów zgadzają się z teorią.

Wskazówka: O(n) suma, O(n²) wszystkie pary, O(log n) binarne na posortowanej liście
6★★★

Merge Sort z licznikiem

Zaimplementuj merge sort. Zlicz łączną liczbę porównań podczas scalania. Sprawdź dla list o długości 8, 16, 32, 64 czy liczba porównań rośnie zgodnie z O(n log n).

Wskazówka: funkcja scal() zwraca (lista, liczba_porownan), sumuj w górę rekurencji