← Strona główna

11 — Algorytmy: etap 2

Sortowanie przez wstawianie, wyszukiwanie binarne, algorytmy na liczbach.

Przetwarzanie i algorytmy

1. Sortowanie przez wstawianie (Insertion Sort)

Algorytm buduje posortowaną część listy element po elemencie — każdy nowy element jest wstawiany w odpowiednie miejsce w już posortowanej części. Działa jak sortowanie kart w ręce.

  • Złożoność: O(n²) najgorszy i średni, O(n) najlepszy (lista już posortowana)
  • Stabilny — zachowuje kolejność równych elementów
  • W miejscu — nie potrzebuje dodatkowej pamięci
  • Bardzo efektywny dla małych list (n < 20) i prawie posortowanych danych
  • Lewa część listy (0..i-1) jest zawsze posortowana
Intuicja — karty do gry Trzymasz posortowane karty w lewej ręce. Bierzesz kolejną kartę z prawej i wsuwasz ją we właściwe miejsce wśród już posortowanych kart — przesuwając większe karty w prawo.
Implementacja — sortowanie przez wstawianie
def insertion_sort(lista):
    for i in range(1, len(lista)):
        klucz = lista[i]       # element do wstawienia
        j = i - 1
        # Przesuń elementy większe od klucza w prawo
        while j >= 0 and lista[j] > klucz:
            lista[j + 1] = lista[j]
            j -= 1
        lista[j + 1] = klucz   # wstaw klucz na miejsce

# Wersja z debugiem — pokazuje każde wstawienie
def insertion_sort_debug(lista):
    for i in range(1, len(lista)):
        klucz = lista[i]
        j = i - 1
        while j >= 0 and lista[j] > klucz:
            lista[j + 1] = lista[j]
            j -= 1
        lista[j + 1] = klucz
        print(f"Wstawiam {klucz:2}: {lista}")

# Test
dane = [12, 11, 13, 5, 6]
print("Przed:", dane)
insertion_sort_debug(dane)
print("Po:   ", dane)

# Wstawiam 11: [11, 12, 13,  5,  6]
# Wstawiam 13: [11, 12, 13,  5,  6]
# Wstawiam  5: [ 5, 11, 12, 13,  6]
# Wstawiam  6: [ 5,  6, 11, 12, 13]

2. Wyszukiwanie binarne (Binary Search)

Algorytm dla posortowanych list — za każdym razem sprawdza środkowy element i eliminuje połowę zakresu poszukiwań. Znacznie szybszy od liniowego dla dużych danych.

  • Złożoność: O(log n) — dla 1000 elementów max 10 kroków, dla miliona — 20!
  • Wymaga: posortowanej listy — bez tego wyniki błędne
  • Trzy wskaźniki: lewy, prawy, srodek
  • Po każdym kroku zakres zmniejsza się o połowę
  • Pętla kończy się gdy lewy > prawy (element nie istnieje)
Środek — jak liczyć srodek = (lewy + prawy) // 2 — dzielenie całkowite. Dla lewy=0, prawy=8: środek=4. Jeśli szukana < lista[4] — szukamy w [0..3]. Jeśli większa — w [5..8].
Implementacja — wyszukiwanie binarne
def wyszukiwanie_binarne(lista, szukana):
    """Zwraca indeks elementu lub -1. Lista musi być posortowana."""
    lewy   = 0
    prawy  = len(lista) - 1
    kroki  = 0

    while lewy <= prawy:
        kroki += 1
        srodek = (lewy + prawy) // 2

        if lista[srodek] == szukana:
            print(f"Znaleziono po {kroki} krokach.")
            return srodek
        elif lista[srodek] < szukana:
            lewy = srodek + 1    # szukamy w prawej połowie
        else:
            prawy = srodek - 1   # szukamy w lewej połowie

    print(f"Nie znaleziono po {kroki} krokach.")
    return -1

# Test
posortowane = [2, 5, 8, 12, 16, 23, 38, 45, 56, 72, 91]

idx = wyszukiwanie_binarne(posortowane, 23)
print(f"23 → indeks: {idx}")   # indeks: 5

idx = wyszukiwanie_binarne(posortowane, 50)
print(f"50 → indeks: {idx}")   # -1

# Porównanie z liniowym — dla 11 elementów:
# Liniowe max 11 porównań
# Binarne max 4 porównania (log2(11) ≈ 3.5)

3. Wyszukiwanie binarne — wersja rekurencyjna

Wyszukiwanie binarne można elegancko zapisać rekurencyjnie — problem dzieli się na mniejszy podproblem za każdym wywołaniem.

  • Warunek bazowy: lewy > prawy — nie znaleziono
  • Warunek bazowy 2: lista[srodek] == szukana — znaleziono
  • Wywołanie rekurencyjne z zawężonym zakresem
  • Wersja iteracyjna jest bardziej efektywna pamięciowo — nie buduje stosu wywołań
Iteracyjna vs rekurencyjna Obie mają złożoność O(log n). Rekurencyjna jest czytelniejsza — iteracyjna efektywniejsza (nie zużywa stosu wywołań). Na egzaminie musisz umieć napisać obie wersje.
Implementacja — binarne rekurencyjnie
def binarne_rek(lista, szukana, lewy, prawy):
    """Rekurencyjne wyszukiwanie binarne."""
    # Warunek bazowy — nie znaleziono
    if lewy > prawy:
        return -1

    srodek = (lewy + prawy) // 2

    # Warunek bazowy — znaleziono
    if lista[srodek] == szukana:
        return srodek
    # Szukaj w prawej połowie
    elif lista[srodek] < szukana:
        return binarne_rek(lista, szukana, srodek + 1, prawy)
    # Szukaj w lewej połowie
    else:
        return binarne_rek(lista, szukana, lewy, srodek - 1)

# Wygodny wrapper
def wyszukaj(lista, szukana):
    return binarne_rek(lista, szukana, 0, len(lista) - 1)

# Test
dane = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
print(wyszukaj(dane, 7))    # 3
print(wyszukaj(dane, 13))   # 6
print(wyszukaj(dane, 4))    # -1

# Wizualizacja kroków rekurencji dla szukanej=7:
# binarne_rek([1..19], 7, 0, 9)  → srodek=4, lista[4]=9 > 7
# binarne_rek([1..19], 7, 0, 3)  → srodek=1, lista[1]=3 < 7
# binarne_rek([1..19], 7, 2, 3)  → srodek=2, lista[2]=5 < 7
# binarne_rek([1..19], 7, 3, 3)  → srodek=3, lista[3]=7 == 7 ✓

4. Algorytmy na liczbach — dzielniki, NWD, NWW

Klasyczne algorytmy matematyczne często pojawiają się na egzaminie INF.04 — musisz je znać i implementować zarówno iteracyjnie jak i rekurencyjnie.

  • Dzielniki liczby — iteracja do √n
  • Liczba pierwsza — brak dzielników oprócz 1 i siebie
  • NWD — algorytm Euklidesa: nwd(a,b) = nwd(b, a%b)
  • NWW — ze wzoru: nww(a,b) = a*b // nwd(a,b)
  • Rozkład na czynniki pierwsze — dziel przez kolejne liczby
NWD — zapamiętaj Algorytm Euklidesa: dopóki b ≠ 0, zastąp (a, b) przez (b, a mod b). Gdy b = 0, wynikiem jest a. Np. nwd(48, 18): (48,18) → (18,12) → (12,6) → (6,0) → wynik: 6.
Implementacja — algorytmy na liczbach
# NWD — iteracyjnie (Euklides)
def nwd(a, b):
    while b != 0:
        a, b = b, a % b
    return a

# NWW ze wzoru
def nww(a, b):
    return a * b // nwd(a, b)

print(f"NWD(48, 18) = {nwd(48, 18)}")   # 6
print(f"NWW(4, 6)   = {nww(4, 6)}")     # 12

# Dzielniki liczby
def dzielniki(n):
    wynik = []
    for i in range(1, int(n**0.5) + 1):
        if n % i == 0:
            wynik.append(i)
            if i != n // i:
                wynik.append(n // i)
    return sorted(wynik)

print(dzielniki(36))  # [1, 2, 3, 4, 6, 9, 12, 18, 36]

# Rozkład na czynniki pierwsze
def czynniki_pierwsze(n):
    wynik = []
    d = 2
    while d * d <= n:
        while n % d == 0:
            wynik.append(d)
            n //= d
        d += 1
    if n > 1:
        wynik.append(n)
    return wynik

print(czynniki_pierwsze(360))  # [2, 2, 2, 3, 3, 5]

5. Algorytmy na ciągach i tekstach

Algorytmy tekstowe — sprawdzanie właściwości słów i zdań. Częste tematy egzaminacyjne.

  • Palindrom — słowo czytające się tak samo od przodu i tyłu
  • Anagram — te same litery w innej kolejności
  • Zliczanie znaków — słownik lub lista liczników
  • Odwracanie — własna implementacja przez pętlę
  • Zamiana znaków — iteracja i budowanie nowego stringa
String jest niemutowalny Nie można zmienić znaku w stringu przez indeks: s[0] = 'X'TypeError. Buduj nowy string: przez konkatenację, przez listę znaków → ''.join(lista), lub przez replace().
Implementacja — algorytmy tekstowe
# Palindrom — bez slice
def czy_palindrom(s):
    s = s.lower().replace(" ", "")
    n = len(s)
    for i in range(n // 2):
        if s[i] != s[n - 1 - i]:
            return False
    return True

print(czy_palindrom("kajak"))        # True
print(czy_palindrom("Kobyła ma mały bok"))  # True
print(czy_palindrom("Python"))       # False

# Anagram — przez sortowanie
def czy_anagram(s1, s2):
    s1 = s1.lower().replace(" ", "")
    s2 = s2.lower().replace(" ", "")
    return sorted(s1) == sorted(s2)

print(czy_anagram("listen", "silent"))  # True
print(czy_anagram("hello", "world"))    # False

# Zliczanie liter (bez Counter)
def zlicz_litery(tekst):
    licznik = {}
    for znak in tekst.lower():
        if znak.isalpha():
            licznik[znak] = licznik.get(znak, 0) + 1
    return dict(sorted(licznik.items()))

print(zlicz_litery("Python"))
# {'h': 1, 'n': 1, 'o': 1, 'p': 1, 't': 1, 'y': 1}

6. Algorytm Euklidesa — analiza krok po kroku

Algorytm Euklidesa to jeden z najstarszych algorytmów — pochodzi z ok. 300 p.n.e. Na egzaminie INF.04 pojawia się bardzo często w różnych wariantach.

  • Podstawa: nwd(a, b) = nwd(b, a mod b)
  • Warunek końca: gdy b = 0, nwd = a
  • Działa dla a, b > 0
  • Złożoność: O(log(min(a,b)))
  • Wersja rozszerzona wyznacza współczynniki Bézouta — na egzaminie raczej nie wymagana
NWD a, b — co gdy a < b? Algorytm sam sobie z tym radzi: nwd(6, 48) → (48, 6%48=6) → (6, 48%6=0) → wynik 6. Pierwszy krok "zamienia" kolejność gdy a < b.
Analiza — Euklides z wizualizacją
def nwd_debug(a, b):
    """NWD z wypisaniem każdego kroku."""
    krok = 0
    print(f"nwd({a}, {b})")
    while b != 0:
        krok += 1
        r    = a % b
        print(f"  krok {krok}: nwd({b}, {a}%{b}={r})")
        a, b = b, r
    print(f"  wynik: {a}")
    return a

nwd_debug(48, 18)
# nwd(48, 18)
#   krok 1: nwd(18, 48%18=12)
#   krok 2: nwd(12, 18%12=6)
#   krok 3: nwd(6,  12%6=0)
#   wynik: 6

# NWD wielu liczb — przez reduce
from functools import reduce
def nwd_wielu(*liczby):
    return reduce(nwd, liczby)

print(nwd_wielu(12, 18, 24))    # 6
print(nwd_wielu(100, 75, 50))   # 25

# NWW wielu liczb
def nww_wielu(*liczby):
    return reduce(nww, liczby)

print(nww_wielu(4, 6, 10))      # 60

Zadania przykładowe z omówieniem

Zadanie 1 Insertion sort — wstawianie z krokami łatwe

Posortuj listę [9, 3, 7, 1, 8, 4] przez wstawianie. Dla każdego wstawianego elementu wypisz: który element wstawiasz, ile przesunięć wykonałeś i stan listy po wstawieniu.

Rozwiązanie
lista = [9, 3, 7, 1, 8, 4]
print(f"Start:   {lista}")
lacznie_przesuniec = 0

for i in range(1, len(lista)):
    klucz      = lista[i]
    j          = i - 1
    przesuniecia = 0

    while j >= 0 and lista[j] > klucz:
        lista[j + 1] = lista[j]
        j -= 1
        przesuniecia += 1

    lista[j + 1] = klucz
    lacznie_przesuniec += przesuniecia
    print(f"Wstawiam {klucz}: "
          f"przesunięć={przesuniecia}, "
          f"lista={lista}")

print(f"\nWynik:   {lista}")
print(f"Łącznie przesunięć: {lacznie_przesuniec}")
Omówienie krok po kroku
  1. klucz = lista[i] przed pętlą while
    Zapisujemy element do wstawienia zanim pętla while zacznie go nadpisywać. Gdybyśmy tego nie zrobili — wartość by zaginęła podczas przesuwania elementów.
  2. Przesuwanie w prawo — lista[j+1] = lista[j]
    Nie zamieniamy elementów — kopiujemy element o jeden w prawo. To tworzy "dziurę" gdzie wstawimy klucz. Dlatego insertion sort robi mniej operacji niż bubble sort.
  3. Wstawianie po pętli while
    lista[j+1] = klucz — po wyjściu z pętli j wskazuje na ostatni element nie większy od klucza (lub j=-1 gdy klucz jest mniejszy od wszystkich). j+1 to właściwa pozycja.
  4. Licznik przesunięć
    Dla prawie posortowanej listy liczba przesunięć będzie mała — stąd O(n) w najlepszym przypadku. Dla odwrotnie posortowanej — maksymalna.
Zadanie 2 Wyszukiwanie binarne — symulacja średnie

Napisz program który symuluje wyszukiwanie binarne krok po kroku — dla każdej iteracji wypisz: lewy i prawy zakres, środkowy indeks i element, oraz decyzję (szukam w lewej/prawej/znalazłem). Na końcu podsumuj ile kroków zajęło.

Rozwiązanie
def binarne_symulacja(lista, szukana):
    lewy  = 0
    prawy = len(lista) - 1
    krok  = 0

    print(f"Szukam {szukana} w: {lista}")
    print("-" * 50)

    while lewy <= prawy:
        krok   += 1
        srodek  = (lewy + prawy) // 2
        elem    = lista[srodek]

        print(f"Krok {krok}: zakres [{lewy}..{prawy}], "
              f"środek={srodek}, wartość={elem}")

        if elem == szukana:
            print(f"✓ Znaleziono na indeksie {srodek}!")
            return srodek
        elif elem < szukana:
            print(f"  {elem} < {szukana} → szukam w prawej")
            lewy = srodek + 1
        else:
            print(f"  {elem} > {szukana} → szukam w lewej")
            prawy = srodek - 1

    print(f"✗ Nie znaleziono. Kroków: {krok}")
    return -1

dane = list(range(1, 22, 2))  # [1,3,5,7,9,11,13,15,17,19,21]
binarne_symulacja(dane, 13)
Omówienie krok po kroku
  1. srodek = (lewy + prawy) // 2
    Dzielenie całkowite — środek zawsze jest indeksem całkowitym. Dla zakresu [0..10] środek = 5. Dla [3..7] środek = 5. Dla [3..6] środek = 4 (zaokrąglenie w dół).
  2. lewy = srodek + 1 lub prawy = srodek - 1
    Nie wystarczy lewy = srodek — środkowy element był już sprawdzony i nie jest szukanym. Musimy go wykluczyć dodając lub odejmując 1.
  3. Warunek pętli: lewy <= prawy
    Gdy lewy > prawy zakres jest pusty — element nie istnieje. Gdybyśmy użyli <, pominęlibyśmy przypadek gdy zakres ma jeden element.
  4. list(range(1, 22, 2))
    Tworzy posortowaną listę nieparzystych od 1 do 21. Wyszukiwanie binarne wymaga posortowanej listy — to idealne dane testowe.
Zadanie 3 NWD i NWW — zastosowanie praktyczne średnie

Wczytaj dwie liczby naturalne. Oblicz i wypisz ich NWD i NWW. Wypisz wizualizację kroków algorytmu Euklidesa. Sprawdź czy podane liczby są względnie pierwsze (NWD = 1).

Rozwiązanie
def nwd_z_krokami(a, b):
    kroki = []
    oryg_a, oryg_b = a, b
    while b != 0:
        r = a % b
        kroki.append(f"  nwd({a}, {b}): {a} = {b}×{a//b} + {r}")
        a, b = b, r
    kroki.append(f"  nwd({a}, 0) = {a}  ← wynik")
    return a, kroki

a = int(input("Pierwsza liczba: "))
b = int(input("Druga liczba: "))

wynik_nwd, kroki = nwd_z_krokami(a, b)
wynik_nww        = a * b // wynik_nwd

print(f"\nAlgorytm Euklidesa dla nwd({a}, {b}):")
for k in kroki:
    print(k)

print(f"\nNWD({a}, {b}) = {wynik_nwd}")
print(f"NWW({a}, {b}) = {wynik_nww}")

if wynik_nwd == 1:
    print(f"\n{a} i {b} są względnie pierwsze!")
else:
    print(f"\n{a} i {b} nie są względnie pierwsze.")
    print(f"Ułamek {a}/{b} = {a//wynik_nwd}/{b//wynik_nwd}")
Omówienie krok po kroku
  1. Zbieranie kroków do listy
    Zamiast drukować kroki od razu, zbieramy je w liście — możemy najpierw obliczyć wynik, a potem wypisać kroki w odpowiednim miejscu. Oddziela obliczenia od prezentacji.
  2. a = {b}×{a//b} + {r}
    Wypisujemy pełny zapis dzielenia z resztą — to forma znana z matematyki. Pokazuje jak działa algorytm Euklidesa.
  3. NWW ze wzoru
    a * b // wynik_nwd — mnożymy oryginalne wartości (nie te zmodyfikowane przez algorytm Euklidesa). Dlatego zapamiętujemy oryginalne a i b.
  4. Skracanie ułamka
    Dzieląc licznik i mianownik przez NWD otrzymujemy ułamek nieskracalny. Prosta i praktyczna aplikacja algorytmu.

Zadania do samodzielnego rozwiązania

Algorytmy z tego kafelka to fundament egzaminu — ćwicz pisanie ich z pamięci bez zaglądania do notatek.

1★☆☆

Insertion sort malejąco

Zmodyfikuj insertion sort żeby sortował malejąco. Przetestuj na liście 8 losowych liczb. Wypisz stan listy po każdym wstawieniu.

Wskazówka: zmień warunek > na < w pętli while
2★☆☆

Liczby względnie pierwsze

Wypisz wszystkie pary liczb (a, b) z zakresu 1–20 dla których NWD = 1 (są względnie pierwsze). Policz ile takich par jest.

Wskazówka: dwie zagnieżdżone pętle, nwd(a, b) == 1
3★★☆

Binarne — pierwsza i ostatnia pozycja

Na posortowanej liście z duplikatami znajdź binarnie pierwszą i ostatnią pozycję szukanej wartości. Np. w [1,2,2,2,3,4] dla szukanej=2: pierwsza=1, ostatnia=3.

Wskazówka: dwie osobne funkcje — po znalezieniu środka szukaj dalej w lewo lub w prawo
4★★☆

Rozkład na czynniki

Wczytaj liczbę naturalną. Wypisz jej rozkład na czynniki pierwsze w postaci: 360 = 2³ × 3² × 5¹. Użyj słownika do zliczania potęg.

Wskazówka: czynniki_pierwsze() → zlicz powtórzenia → formatuj z ³ ² ¹
5★★☆

Anagramy w liście

Masz listę słów. Pogrupuj je w pary/grupy anagramów. Wypisz każdą grupę osobno. Np. ["listen","silent","hello","world","enlist"] → dwie grupy.

Wskazówka: klucz słownika = tuple(sorted(slowo))
6★★★

Sortowanie przez scalanie — merge sort

Zaimplementuj merge sort (sortowanie przez scalanie) — podziel listę na pół rekurencyjnie, posortuj każdą połowę, scal wyniki. Złożoność O(n log n).

Wskazówka: warunek bazowy len(lista) <= 1, scal dwie posortowane listy porównując kolejne elementy