Rekurencja, silnia, Fibonacci, NWD, złożoność obliczeniowa w praktyce.
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ń.
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.
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
Silnia n! = 1 × 2 × 3 × ... × n. Definicja: 0! = 1, n! = n × (n-1)!
# 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}")
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.
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ń.
# 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!
Rekurencja naturalnie opisuje problemy które mają strukturę "problem → mniejszy problem tego samego rodzaju".
# 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
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.
| Notacja | Nazwa | n=100 | n=1000 | Przykład |
|---|---|---|---|---|
| O(1) | stała | 1 | 1 | dostęp do listy po indeksie |
| O(log n) | logarytmiczna | 7 | 10 | wyszukiwanie binarne |
| O(n) | liniowa | 100 | 1000 | wyszukiwanie liniowe |
| O(n log n) | liniowo-log. | 700 | 10 000 | merge sort, timsort |
| O(n²) | kwadratowa | 10 000 | 1 000 000 | bubble/selection/insertion sort |
| O(2ⁿ) | wykładnicza | ogromna | ∞ | fib rekurencyjny, brute force |
# 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:]
Memoizacja to technika zapamiętywania wyników funkcji — przy ponownym wywołaniu z tymi samymi argumentami zwracamy zapamiętany wynik zamiast liczyć od nowa.
@functools.lru_cache — automatyczna memoizacja@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.
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)}")
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.
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}")
try/except.
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.
:>4, :>15 — wyrównanie do prawej w kolumnach odpowiedniej szerokości. Wyniki silni rosną szybko — 10! = 3 628 800, potrzebujemy miejsca.
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.
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}")
time.time() — mierzy z precyzją do nanosekund. Używaj go do pomiaru krótkich operacji.
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.
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 '✗'}")
Rekurencja jest trudna — ćwicz pisząc warunek bazowy jako pierwszy. Zawsze sprawdź czy problem się zmniejsza ku warunkowi bazowemu.
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.
x * potega(x, n-1)Suma listy rekurencyjnie
Napisz rekurencyjną funkcję suma_rek(lista) sumującą elementy listy bez użycia sum() ani pętli.
lista[0] + suma_rek(lista[1:])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.
a,b,c = b,c,a+b+cWszystkie 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]].
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ą.
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).
scal() zwraca (lista, liczba_porownan), sumuj w górę rekurencji