Przejdź do treści
Algorytmy na maturę z informatyki
Algorytmy na maturę rozszerzoną z informatyki — kod i złożoność

Algorytmy na maturę rozszerzoną z informatyki — kod i złożoność

W skrócie — co naprawdę trzeba umieć z pamięci
  • Pierwiastek zamiast pełnego zakresu: test pierwszości i rozkład na czynniki chodzą do pierwiastka z liczby, bo dzielniki chodzą w parach. Ta jedna zmiana skraca czas z minut do ułamka sekundy.

  • Sito, gdy liczb jest wiele: jedna liczba do sprawdzenia — test do pierwiastka. Wszystkie liczby pierwsze w zakresie — sito Eratostenesa. Wewnętrzna pętla sita startuje od kwadratu, nie od podwojenia.

  • Euklides i Horner to schematy do przepisania z głowy: cztery linijki każdy, a wracają niemal w każdej sesji — NWD w zadaniach z teorii liczb, Horner przy konwersji systemów i wartości wielomianu.

  • Sortowanie własne piszesz tylko na wyraźne żądanie treści. W części praktycznej sorted() jest szybsze i pewniejsze niż ręczny quicksort; bąbelkowe i przez wstawianie znasz na potrzeby części teoretycznej i pytań o złożoność.

  • Złożoność szacujesz z zagnieżdżenia pętli. Jedna pętla po danych to koszt liniowy, pętla w pętli — kwadratowy, połowienie zakresu — logarytmiczny. To wystarcza do odpowiedzi na wszystkie pytania z arkusza.

Lista algorytmów maturalnych jest krótka i od lat ta sama. Problem polega na tym, że sama lista nic nie daje: uczeń wie, że „trzeba znać sito", ale nie wie, czym się różni od testu pierwszości, dlaczego wewnętrzna pętla zaczyna się akurat od kwadratu i co odpowiedzieć, gdy arkusz pyta o złożoność.

Ten wpis idzie po każdym algorytmie osobno i przy każdym odpowiada na cztery pytania: kiedy pojawia się na maturze, jak wygląda kod, jaka jest jego złożoność i skąd się bierze, oraz na czym uczniowie się na nim wykładają. Na końcu jest sekcja o rozpoznawaniu, którego algorytmu oczekuje treść zadania — bo to zwykle trudniejsze niż samo napisanie kodu.

Złożoność obliczeniowa: jak ją czytać i jak ją oszacować#

Złożoność to odpowiedź na pytanie „ile razy więcej pracy wykona program, gdy danych będzie dziesięć razy więcej". Nie mierzy się jej w sekundach, tylko w liczbie operacji zależnej od rozmiaru danych, oznaczanego zwykle jako n.

ZłożonośćNazwaPrzykład z maturyn = 1 000 000 to około
O(1)stałaodczyt t[5], sprawdzenie parzystości1 operacja
O(log n)logarytmicznawyszukiwanie binarne, algorytm Euklidesa20 operacji
O(√n)pierwiastkowatest pierwszości do pierwiastka1 000 operacji
O(n)liniowasuma elementów, minimum, Fibonacci iteracyjnie1 000 000 operacji
O(n log n)liniowo-logarytmicznasorted(), sortowanie szybkie, sito20 000 000 operacji
O(n²)kwadratowasortowanie bąbelkowe, porównywanie par1 000 000 000 000 operacji
O(2ⁿ)wykładniczaFibonacci naiwną rekurencjąliczba nie do zapisania
Ostatnia kolumna to powód, dla którego algorytm kwadratowy na pliku z milionem wierszy nie skończy się w czasie egzaminu, a liniowy skończy się natychmiast.

Jak oszacować złożoność, patrząc na pętle

Nie trzeba niczego liczyć na kartce. Wystarczy przejrzeć kod i zadać sobie trzy pytania.

Pytanie 1: ile pętli jest zagnieżdżonych jedna w drugiej? Każdy poziom zagnieżdżenia, w którym pętla przechodzi przez wszystkie dane, mnoży koszt przez n. Jedna pętla to n, pętla w pętli to , trzy poziomy to .

Trzy fragmenty, trzy różne złożoności

# O(n) - jedna petla po danych
for x in dane:
  suma += x

# O(n) - dwie petle OBOK siebie, koszt sie dodaje: n + n = 2n
for x in dane:
  suma += x
for x in dane:
  if x > maks:
      maks = x

# O(n^2) - petla W petli, koszt sie mnozy
for i in range(len(dane)):
  for j in range(len(dane)):
      if dane[i] == dane[j]:
          pary += 1
💡
Pętle obok siebie się dodają, pętle w sobie się mnożą. Przy dodawaniu liczy się tylko człon największy, więc n plus n to nadal koszt liniowy.

Pytanie 2: czy licznik pętli rośnie o jeden, czy zmienia się razy dwa? Jeżeli zakres w każdym kroku dzieli się na pół albo zmienna mnoży się przez dwa — złożoność jest logarytmiczna. Milion podzielony na pół dwadzieścia razy daje jedynkę, i stąd bierze się „dwadzieścia kroków dla miliona elementów".

Pętla, która połowi zakres

ile = 0
n = 1000000
while n > 1:
  n = n // 2
  ile += 1
print(ile)   # 19
💡
Zmienna nie maleje o jeden, tylko dzieli się przez dwa. Liczba obiegów to logarytm dwójkowy z n, czyli dla miliarda około trzydziestu.

Pytanie 3: czy pętla wewnętrzna zależy od zewnętrznej? Jeśli druga pętla startuje od i + 1 albo kończy się na i, liczba obiegów to nie , tylko mniej więcej połowa tego — ale nadal jest to złożoność kwadratowa. Stałych i podziału przez dwa się nie zapisuje: interesuje nas tempo wzrostu, a nie dokładna liczba.

Trzy zasady, które załatwiają większość pytań o złożoność
  • Stałe znikają: koszt 3n zapisujemy jako O(n), a 100 operacji niezależnych od danych to O(1). Dwukrotnie szybszy program ma tę samą złożoność.

  • Zostaje tylko najsilniejszy człon: n² plus n plus 5 to O(n²), bo dla dużych danych reszta przestaje mieć znaczenie.

  • Operacje na kolekcjach też kosztują: x in lista to przejście całej listy, czyli O(n), a x in zbior dla zbioru (set) to praktycznie O(1). Ten sam zapis, zupełnie inny koszt — to najczęściej przeoczana pętla w kodzie ucznia.

Wniosek: zanim uruchomisz program na właściwym pliku, pomnóż w głowie liczbę wierszy przez liczbę zagnieżdżonych przejść. Wynik rzędu milionów jest w porządku, rzędu miliardów — nie. Systematycznie temat rozwija dział algorytmiki i logiki.

Sprawdzanie pierwszości: dlaczego pierwiastek naprawdę wystarczy#

Test pierwszości pojawia się albo jako samodzielny podpunkt („ile liczb pierwszych jest w pliku"), albo jako cegiełka w większym zadaniu — przy liczbach będących sumą cyfr, przy palindromach pierwszych, przy bliźniakach. Wraca niemal w każdej sesji.

Wersja naiwna, której należy unikać

def pierwsza_naiwnie(n):
  if n < 2:
      return False
  for d in range(2, n):
      if n % d == 0:
          return False
  return True
💡
Dla liczby rzędu miliarda to miliard obiegów na jedną sprawdzaną wartość. Przy pliku z tysiącem liczb program nie skończy się w czasie egzaminu.

Dowód intuicyjny: dzielniki chodzą w parach

Weź liczbę 36. Jej dzielniki to 1, 2, 3, 4, 6, 9, 12, 18, 36. Zapisz je w pary, których iloczyn daje 36:

Dzielnik mniejszyDzielnik większyIloczyn
13636
21836
31236
4936
6636 — para się skleja, bo 6 to pierwiastek z 36
Każdy dzielnik większy od pierwiastka ma swojego partnera mniejszego od pierwiastka. Przeglądając liczby do pierwiastka, przeglądasz więc reprezentanta każdej pary.

Rozumowanie w jednym zdaniu: jeżeli n rozkłada się na iloczyn a razy b, to niemożliwe jest, żeby oba czynniki były większe od pierwiastka z n — ich iloczyn byłby wtedy większy niż n. Zatem co najmniej jeden z nich jest mniejszy lub równy pierwiastkowi. Jeśli więc nie znalazłeś żadnego dzielnika do pierwiastka włącznie, żadnego nie ma w ogóle.

Jeśli n=abn = a \cdot b oraz a>na > \sqrt{n} i b>nb > \sqrt{n}, to ab>na \cdot b > n — sprzeczność. Stąd min(a,b)n\min(a, b) \le \sqrt{n}.

Wersja poprawna

def pierwsza(n):
  if n < 2:
      return False
  d = 2
  while d * d <= n:
      if n % d == 0:
          return False
      d += 1
  return True
💡
Warunek d razy d mniejsze lub równe n nie korzysta z liczb zmiennoprzecinkowych, więc nie ma ryzyka, że pierwiastek policzony na float wyjdzie minimalnie za mały. To bezpieczniejszy zapis niż range z int(n ** 0.5).
Pułapki testu pierwszości
  • Zgubiona równość przy pierwiastku: zapis range(2, int(n ** 0.5)) bez dodania jedynki pomija sam pierwiastek. Skutek: dziewiątka, dwudziestka piątka i każdy kwadrat liczby pierwszej wychodzą jako liczby pierwsze.

  • Brak obsługi liczb mniejszych od dwóch: zero, jedynka i liczby ujemne nie są pierwsze, a pętla dla nich w ogóle się nie wykona i funkcja zwróci prawdę. Jedynka jako liczba pierwsza to klasyczny sposób na wynik różniący się od klucza o jeden.

  • Złożoność: O(√n) na jedną liczbę, bo pętla dochodzi do pierwiastka, wykonując po jednej operacji dzielenia z resztą na obieg.

Sito Eratostenesa i pytanie o kwadrat#

Sito odpowiada na inne pytanie niż test pierwszości: nie „czy ta liczba jest pierwsza", tylko „które liczby w całym zakresie są pierwsze". Typowa treść maturalna brzmi „wypisz wszystkie liczby pierwsze mniejsze od miliona" albo „sprawdź, ile liczb z pliku jest pierwszych", gdzie plik ma dziesiątki tysięcy wierszy.

Idea: robisz listę wszystkich liczb, a potem wykreślasz wielokrotności. To, co zostanie niewykreślone, jest pierwsze.

Sito Eratostenesa

def sito(n):
  czy_pierwsza = [True] * (n + 1)
  czy_pierwsza[0] = czy_pierwsza[1] = False

  i = 2
  while i * i <= n:
      if czy_pierwsza[i]:
          for j in range(i * i, n + 1, i):
              czy_pierwsza[j] = False
      i += 1

  return [x for x in range(n + 1) if czy_pierwsza[x]]

print(sito(30))
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
💡
Tablica logiczna zamiast listy liczb — indeks jest liczbą, a wartość mówi, czy jest pierwsza. Zewnętrzna pętla idzie do pierwiastka, wewnętrzna startuje od kwadratu i skacze co i.

Dlaczego wewnętrzna pętla startuje od i razy i

To jedno z ulubionych pytań w części teoretycznej, bo sprawdza zrozumienie, a nie pamięć. Odpowiedź jest prosta: wszystko poniżej kwadratu zostało już wykreślone wcześniej.

Weź i równe 5. Wielokrotności piątki to 5, 10, 15, 20, 25, 30, ... Ale:

  • 10 to 5 razy 2 — wykreśliła je już pętla dla dwójki,
  • 15 to 5 razy 3 — wykreśliła je pętla dla trójki,
  • 20 to 5 razy 4, a czwórka jest wielokrotnością dwójki — też już wykreślone,
  • 25 to 5 razy 5 — pierwsza wielokrotność piątki, której mniejszy czynnik nie jest mniejszy od piątki.

Ogólnie: każda wielokrotność i postaci i razy k, gdzie k jest mniejsze od i, ma mniejszy czynnik k i została skreślona, gdy pętla zewnętrzna była przy k (albo przy dzielniku pierwszym liczby k). Pierwsza wielokrotność wymagająca uwagi to i razy i.

iStart od 2i (naiwnie)Start od i²Zaoszczędzone kroki
24, 6, 8, 10, ...4, 6, 8, 10, ...żadne — to ten sam start
36, 9, 12, 15, ...9, 12, 15, ...szóstka była już skreślona przez 2
510, 15, 20, 25, ...25, 30, 35, ...10, 15, 20 skreślone wcześniej
714, 21, 28, ..., 4949, 56, 63, ...wszystko poniżej 49
Zysk rośnie razem z i — dlatego warunek zewnętrznej pętli też kończy się na pierwiastku: dla i większego od pierwiastka kwadrat i tak wychodzi poza zakres tablicy.
Sito — czego pilnować
  • Rozmiar tablicy: [True] * (n + 1), a nie [True] * n. Chcesz indeksować liczbą n włącznie, więc miejsc musi być o jedno więcej.

  • Zero i jedynka: trzeba je ustawić na fałsz ręcznie, bo żadna pętla ich nie dotknie.

  • Warunek if czy_pierwsza[i]: bez niego program wykreśla wielokrotności liczb złożonych, robiąc pracę już wykonaną. Działa, ale wolniej.

  • Złożoność: około O(n log log n), czyli w praktyce niemal liniowa. Sprawdzanie każdej liczby osobno testem do pierwiastka kosztowałoby O(n√n) — dla miliona to różnica między ułamkiem sekundy a minutami.

  • Kiedy sito, kiedy test: jedna albo kilka liczb — test do pierwiastka. Wiele liczb z jednego zakresu — sito raz, a potem odczyt z tablicy w czasie stałym.

Rozkład na czynniki pierwsze#

Zadanie brzmi zwykle: „dla każdej liczby z pliku podaj jej największy czynnik pierwszy", „znajdź liczby mające dokładnie trzy różne dzielniki pierwsze" albo „wypisz liczby będące iloczynem dwóch liczb pierwszych". Wszystkie sprowadzają się do jednej procedury.

Rozkład na czynniki pierwsze

def czynniki(n):
  wynik = []
  d = 2
  while d * d <= n:
      while n % d == 0:
          wynik.append(d)
          n = n // d
      d += 1
  if n > 1:
      wynik.append(n)
  return wynik

print(czynniki(60))    # [2, 2, 3, 5]
print(czynniki(97))    # [97]
print(czynniki(1024))  # osiem dwojek
💡
Wewnętrzna pętla dzieli tak długo, jak się da, więc czynnik powtarzający się trafia na listę wielokrotnie. Kiedy dochodzi do d, liczba nie ma już żadnego mniejszego dzielnika, więc każdy znaleziony d jest automatycznie pierwszy — nie trzeba tego sprawdzać.

Dwa miejsca w tym kodzie wyglądają na drobiazgi, a decydują o poprawności.

Warunek if n > 1 na końcu. Pętla chodzi tylko do pierwiastka z bieżącej wartości. Jeśli po wszystkich podziałach zostaje coś większego od jedynki, jest to liczba pierwsza większa od pierwiastka — i to ostatni czynnik. Bez tej linijki rozkład liczby 14 dałby samą dwójkę, a siódemka przepadłaby. Dla liczby pierwszej funkcja zwróciłaby pustą listę.

Dzielenie podwójnym ukośnikiem. Zapis n = n / d zamienia liczbę na typ zmiennoprzecinkowy. Po kilku obiegach reszta z dzielenia przestaje wychodzić dokładnie zerowa i rozkład się rozjeżdża — po cichu, bez komunikatu o błędzie.

Złożoność: O(√n) w najgorszym przypadku, czyli gdy n jest pierwsze i pętla przechodzi cały zakres. W typowym przypadku znacznie szybciej, bo każdy znaleziony czynnik zmniejsza liczbę, a razem z nią pierwiastek będący granicą pętli. Dlatego warunek d * d <= n musi porównywać się z bieżącą wartością, a nie z zapamiętaną kopią pierwotnej liczby.

Algorytm Euklidesa: odejmowanie, modulo i NWW#

NWD pojawia się przy skracaniu ułamków, przy zadaniach o wspólnej mierze, o synchronizacji cykli, a także jako narzędzie w podpunktach, które wprost o niego nie proszą. Sam algorytm ma dwie wersje i różnica między nimi jest częstym pytaniem egzaminacyjnym.

Podstawa obu: NWD dwóch liczb nie zmienia się, gdy większą zastąpimy różnicą lub resztą z dzielenia. Skoro wspólny dzielnik dzieli a i b, to dzieli również a minus b.

Wersja z odejmowaniem

def nwd_odejmowanie(a, b):
  while a != b:
      if a > b:
          a = a - b
      else:
          b = b - a
  return a

print(nwd_odejmowanie(48, 18))   # 6
💡
Wersja historyczna, ta z pseudokodu w podręcznikach. Pętla działa, dopóki liczby są różne; gdy się zrównają, ta wspólna wartość jest szukanym NWD.

Wersja z resztą z dzielenia

def nwd(a, b):
  while b != 0:
      a, b = b, a % b
  return a

print(nwd(48, 18))    # 6
print(nwd(1000000, 3))  # 1 - dwa obiegi petli
💡
Reszta z dzielenia robi w jednym kroku to, co odejmowanie robi wielokrotnie. Podmiana dwóch zmiennych naraz to standardowy zapis — najpierw liczona jest prawa strona, więc nie trzeba zmiennej pomocniczej.
CechaWersja z odejmowaniemWersja z resztą
Warunek pętlidopóki a różne od bdopóki b różne od zera
Wynikwspólna wartość a i bwartość a po wyzerowaniu b
Liczba kroków dla (1000000, 3)ponad trzysta tysięcy odejmowańdwa obiegi
ZłożonośćO(a + b) w najgorszym razieO(log min(a, b))
Zachowanie dla zerapętla nieskończona, gdy jedna liczba to 0poprawnie zwraca drugą liczbę
Wersja z resztą jest lepsza pod każdym względem. Wersję z odejmowaniem warto znać, bo to ona pojawia się w zadaniach z analizą pseudokodu i w pytaniach o liczbę wykonanych operacji.

NWW przez NWD

Najmniejszej wspólnej wielokrotności nie liczy się osobnym algorytmem. Wynika z zależności: iloczyn dwóch liczb równa się iloczynowi ich NWD i NWW.

NWW(a,b)=abNWD(a,b)\mathrm{NWW}(a, b) = \dfrac{a \cdot b}{\mathrm{NWD}(a, b)}

NWW oraz NWD dla wielu liczb

def nww(a, b):
  return a // nwd(a, b) * b

print(nww(4, 6))    # 12

# dla calej listy - skladanie parami
def nwd_listy(liczby):
  wynik = liczby[0]
  for x in liczby[1:]:
      wynik = nwd(wynik, x)
  return wynik

print(nwd_listy([24, 36, 60]))   # 12
💡
Kolejność działań ma znaczenie: najpierw dzielenie, potem mnożenie, żeby iloczyn nie urósł niepotrzebnie. NWD i NWW z listy liczy się, składając wynik parami.

Pułapka: dla pary zawierającej zero wzór na NWW dzieli przez zero. Jeśli w danych mogą być zera, warunek trzeba dopisać jawnie.

Schemat Hornera: konwersja systemów i wartość wielomianu#

Horner to jeden pomysł w dwóch zastosowaniach, które na pierwszy rzut oka nie mają ze sobą nic wspólnego. Warto je zobaczyć razem, bo wtedy schemat zostaje w głowie na dobre.

Pomysł: zamiast liczyć potęgi, wyciągasz podstawę przed nawias tyle razy, ile się da. Liczba 1101 w systemie dwójkowym to zwykle zapisywane jako suma potęg:

123+122+021+1201 \cdot 2^3 + 1 \cdot 2^2 + 0 \cdot 2^1 + 1 \cdot 2^0

Po wyciągnięciu dwójki przed nawias:

(((12+1)2+0)2+1)(((1 \cdot 2 + 1) \cdot 2 + 0) \cdot 2 + 1)

Potęgowanie zniknęło. Zostały same mnożenia i dodawania, po jednym na cyfrę.

Konwersja z dowolnego systemu na dziesiętny

def horner(zapis, podstawa):
  wynik = 0
  for cyfra in zapis:
      wynik = wynik * podstawa + int(cyfra)
  return wynik

print(horner("1101", 2))    # 13
print(horner("777", 8))     # 511
print(horner("2024", 10))   # 2024

# system szesnastkowy
CYFRY = "0123456789ABCDEF"

def horner16(zapis):
  wynik = 0
  for znak in zapis.upper():
      wynik = wynik * 16 + CYFRY.index(znak)
  return wynik

print(horner16("FF"))       # 255
💡
Jedna pętla po cyfrach od lewej. Dla systemów powyżej dziesiątkowego cyfry A-F trzeba zamienić na wartości liczbowe — stąd słownik albo funkcja pomocnicza.

Wartość wielomianu w punkcie

def wartosc(wspolczynniki, x):
  wynik = 0
  for w in wspolczynniki:
      wynik = wynik * x + w
  return wynik

# 2x^3 - 6x^2 + 2x - 1 dla x = 3
print(wartosc([2, -6, 2, -1], 3))   # 5
💡
Ten sam schemat, tylko zamiast podstawy systemu podstawiasz x, a zamiast cyfr — współczynniki od najwyższej potęgi. Wersja naiwna z potęgowaniem wykonuje kwadratowo wiele mnożeń, Hornerowska liniowo.
Horner na maturze
  • Gdzie się pojawia: w części teoretycznej jako pseudokod do uzupełnienia lub prześledzenia krok po kroku, w części praktycznej przy nietypowych podstawach — systemie trójkowym, siódemkowym, a bywa że z podstawą ujemną.

  • Złożoność O(n) względem liczby cyfr: jedno mnożenie i jedno dodawanie na cyfrę. Wersja z potęgowaniem liczona naiwnie ma O(n²), bo podstawa do potęgi k to k mnożeń.

  • Kierunek przeglądania: cyfry idą od najbardziej znaczącej, czyli od lewej. Odwrócenie kolejności daje wynik zupełnie inny i nadal wyglądający sensownie — to najczęstszy błąd przy tym schemacie.

  • Kiedy wolno użyć wbudowanej funkcji: w części praktycznej int("1101", 2) jest dozwolone i szybsze. Własna implementacja jest potrzebna, gdy treść wprost każe zapisać algorytm albo gdy podstawa wykracza poza to, co obsługuje funkcja wbudowana.

Wyszukiwanie binarne: dwa błędy, które je psują#

Wyszukiwanie binarne działa wyłącznie na danych posortowanych. Za to wtedy jest nie do pobicia: dla miliona elementów odpowiada po około dwudziestu porównaniach, bo każdy krok odrzuca połowę pozostałego zakresu.

Na maturze pojawia się w dwóch rolach: jako samodzielne zadanie z pseudokodem w części teoretycznej i jako narzędzie w części praktycznej, gdy trzeba wielokrotnie sprawdzać obecność wartości w dużym zbiorze danych.

Wyszukiwanie binarne — wersja iteracyjna

def szukaj(t, x):
  lewy = 0
  prawy = len(t) - 1
  while lewy <= prawy:
      srodek = (lewy + prawy) // 2
      if t[srodek] == x:
          return srodek
      elif t[srodek] < x:
          lewy = srodek + 1
      else:
          prawy = srodek - 1
  return -1

dane = [1, 4, 7, 9, 12, 15, 20]
print(szukaj(dane, 12))   # 4
print(szukaj(dane, 13))   # -1
💡
Zwraca indeks znalezionego elementu albo minus jeden. Warunek pętli z równością i przesunięcia o jeden przy aktualizacji granic to dwa miejsca, w których ten algorytm najczęściej pada.

Błąd pierwszy: warunek pętli bez równości

Zapis while lewy < prawy wygląda niewinnie, a gubi przypadek, w którym zakres zwęził się do jednego elementu — czyli dokładnie ten, w którym często siedzi szukana wartość. Program zwraca „nie znaleziono" dla elementów, które w tablicy są. Najłatwiej to wyłapać na danych brzegowych: tablica jednoelementowa oraz szukanie pierwszego i ostatniego elementu.

ZapisSkutekOcena
while lewy <= prawysprawdza także zakres jednoelementowypoprawny
while lewy < prawygubi ostatni element zakresuczęsty błąd
lewy = srodek + 1odrzuca środek, zakres malejepoprawny
lewy = srodekśrodek zostaje w zakresiepętla nieskończona
srodek = (lewy + prawy) // 2indeks całkowitypoprawny
srodek = (lewy + prawy) / 2indeks typu floatTypeError przy indeksowaniu
Cztery ostatnie wiersze to jedna i ta sama lekcja: zakres musi się w każdym obiegu naprawdę zmniejszać, a indeks musi być liczbą całkowitą.

Błąd drugi: liczenie środka pojedynczym ukośnikiem

(lewy + prawy) / 2 daje liczbę zmiennoprzecinkową, a listy nie da się indeksować liczbą z kropką — program kończy się wyjątkiem. To akurat błąd głośny, więc łatwy. Gorsza jest jego wersja z round(): zaokrąglenie w górę przy pewnych układach granic sprawia, że zakres przestaje się kurczyć i pętla nigdy się nie kończy. Środek liczy się podwójnym ukośnikiem — zawsze.

Wniosek: jeśli szukasz w danych wielokrotnie, masz dwie drogi — posortować raz i szukać binarnie, albo wrzucić dane do zbioru set i sprawdzać operatorem in. Druga jest krótsza i na maturze zwykle wystarcza; pierwszą musisz umieć napisać, bo teoria o nią pyta. Wersje do przećwiczenia znajdziesz w quizach programistycznych.

Sortowanie: bąbelkowe, przez wstawianie, szybkie — i kiedy wystarczy sorted()#

Zacznijmy od wniosku, bo jest zaskakująco jednoznaczny: w części praktycznej sortujesz wbudowaną funkcją, chyba że treść zadania wyraźnie każe zapisać własny algorytm. sorted() używa algorytmu o złożoności O(n log n), jest przetestowany lepiej niż cokolwiek, co napiszesz pod presją czasu, i obsługuje sortowanie po kluczu jedną linijką.

Sortowanie wbudowane — trzy warianty, które załatwiają większość zadań

liczby = [5, 2, 9, 1]
print(sorted(liczby))                 # [1, 2, 5, 9]
print(sorted(liczby, reverse=True))   # [9, 5, 2, 1]

osoby = [("Kowalski", 3, 45), ("Nowak", 1, 45), ("Abacki", 2, 12)]

# po liczbie punktow malejaco
print(sorted(osoby, key=lambda o: o[2], reverse=True))

# po punktach malejaco, a przy remisie po nazwisku rosnaco
print(sorted(osoby, key=lambda o: (-o[2], o[0])))
💡
Parametr key przyjmuje funkcję wyliczającą wartość, po której się sortuje. Krotka jako klucz daje sortowanie po kilku polach naraz — najpierw po pierwszym, przy remisach po drugim.

Po co więc uczyć się sortowań ręcznie? Bo część teoretyczna pyta o ich własności: ile porównań wykona algorytm, jak zachowa się na danych już posortowanych, który jest stabilny. Na te pytania nie odpowie się, znając tylko nazwę funkcji.

Sortowanie bąbelkowe

def babelkowe(t):
  n = len(t)
  for i in range(n - 1):
      zamiana = False
      for j in range(n - 1 - i):
          if t[j] > t[j + 1]:
              t[j], t[j + 1] = t[j + 1], t[j]
              zamiana = True
      if not zamiana:
          break
  return t
💡
Największy element w każdym przebiegu wędruje na koniec jak bąbelek. Flaga zamiana pozwala przerwać pracę, gdy w całym przebiegu nic się nie zmieniło — dzięki niej dane już posortowane kosztują tylko jedno przejście.

Zakres wewnętrznej pętli, range(n - 1 - i), bierze się stąd, że po i przebiegach i największych elementów siedzi już na swoich miejscach na końcu i nie ma sensu ich ruszać. Pominięcie odjęcia jedynki daje wyjście poza listę.

Sortowanie przez wstawianie

def przez_wstawianie(t):
  for i in range(1, len(t)):
      klucz = t[i]
      j = i - 1
      while j >= 0 and t[j] > klucz:
          t[j + 1] = t[j]
          j -= 1
      t[j + 1] = klucz
  return t
💡
Działa tak, jak porządkuje się karty w ręce: bierzesz kolejną i cofasz ją w lewo, dopóki poprzednie są większe. Na danych prawie posortowanych wewnętrzna pętla prawie się nie wykonuje, więc koszt spada do liniowego.

Sortowanie szybkie — wersja czytelna

def szybkie(t):
  if len(t) <= 1:
      return t
  os = t[len(t) // 2]
  mniejsze = [x for x in t if x < os]
  rowne    = [x for x in t if x == os]
  wieksze  = [x for x in t if x > os]
  return szybkie(mniejsze) + rowne + szybkie(wieksze)

print(szybkie([5, 2, 9, 1, 5, 6]))   # [1, 2, 5, 5, 6, 9]
💡
Wybierasz element rozdzielający, dzielisz resztę na mniejsze i większe, a potem sortujesz obie części tym samym sposobem. Ta wersja tworzy nowe listy, więc jest krótka i łatwa do zapamiętania, choć zużywa dodatkową pamięć.
AlgorytmZłożoność średniaPrzypadek najgorszyKiedy o niego pytają
bąbelkoweO(n²)O(n²) — dane odwrotnie posortowanejako przykład algorytmu kwadratowego i przy liczeniu zamian
przez wstawianieO(n²)O(n²)gdy pytanie dotyczy danych prawie posortowanych — wtedy O(n)
przez wybieranieO(n²)O(n²)gdy pytanie dotyczy liczby porównań, zawsze tej samej
szybkieO(n log n)O(n²) — zły wybór elementu rozdzielającegojako przykład metody dziel i zwyciężaj
przez scalanieO(n log n)O(n log n)gdy liczy się gwarancja, a nie średnia
sorted() w PythonieO(n log n)O(n log n)w części praktycznej — domyślny wybór
Wszystkie algorytmy kwadratowe mają tę samą złożoność, ale różnią się zachowaniem na danych szczególnych — i właśnie o to pyta arkusz.

Wniosek: naucz się rozpoznawać trzy sortowania z kodu lub pseudokodu i umieć powiedzieć, jaka jest ich złożoność oraz jak zachowają się na danych już uporządkowanych. Do samego sortowania w zadaniu praktycznym używaj sorted().

Fibonacci: rekurencja wykładnicza kontra iteracja liniowa#

Ciąg Fibonacciego sam w sobie jest prosty — każdy wyraz to suma dwóch poprzednich. Na maturze służy do czegoś innego: jest standardowym przykładem pokazującym, że poprawny algorytm może być bezużyteczny.

Wersja rekurencyjna — poprawna i nieużywalna

def fib_rek(n):
  if n <= 1:
      return n
  return fib_rek(n - 1) + fib_rek(n - 2)
💡
Kod jest dosłownym przepisaniem definicji ciągu, więc trudno o pomyłkę. Problem w tym, że ta sama wartość liczona jest w kółko od zera.

Policzmy, ile wywołań funkcji potrzeba, żeby dostać jedną liczbę. Wywołanie dla 5 wymaga wywołań dla 4 i 3; wywołanie dla 4 znowu wymaga dla 3 i 2 — a wynik dla trójki nie jest nigdzie zapamiętany, więc liczy się drugi raz od zera.

nWynikLiczba wywołań fib_rekLiczba obiegów pętli w wersji iteracyjnej
10551779
206 76521 89119
30832 0402 692 53729
40102 334 155331 160 28139
5012 586 269 025ponad 40 miliardów49
Liczba wywołań rośnie mniej więcej tak jak sam ciąg Fibonacciego, czyli wykładniczo. Liczba obiegów pętli rośnie liniowo — o jeden na każdą jednostkę n.

Wersja iteracyjna — ta, której używasz

def fib(n):
  a, b = 0, 1
  for _ in range(n):
      a, b = b, a + b
  return a

print(fib(50))   # 12586269025 - natychmiast
💡
Dwie zmienne pamiętają tylko to, co potrzebne: dwa ostatnie wyrazy. Podmiana obu naraz sprawia, że nie trzeba zmiennej pomocniczej — prawa strona liczy się przed przypisaniem.

Wersja rekurencyjna ze spamiętywaniem

pamiec = {}

def fib_pam(n):
  if n <= 1:
      return n
  if n in pamiec:
      return pamiec[n]
  pamiec[n] = fib_pam(n - 1) + fib_pam(n - 2)
  return pamiec[n]
💡
Kompromis: zachowuje czytelność rekurencji, ale każdy wyraz liczy się raz i trafia do słownika. Złożoność spada z wykładniczej do liniowej. Uwaga na domyślny limit głębokości rekurencji w Pythonie, który wynosi około tysiąca.

Wniosek dla całej rekurencji, nie tylko dla Fibonacciego: rekurencja jest bezpieczna, gdy każde wywołanie generuje jedno wywołanie zależne (jak w silni czy w algorytmie Euklidesa). Gdy generuje dwa lub więcej, a wyniki nie są zapamiętywane, koszt rośnie wykładniczo. Rozpoznasz to po tym, że po prawej stronie znaku równości nazwa funkcji pojawia się więcej niż raz.

Algorytmy na napisach: palindrom, anagram, zliczanie wystąpień#

Zadania tekstowe wracają w każdej sesji, zwykle jako podpunkt większego zadania z plikiem: „ile słów w pliku to palindromy", „które pary wyrazów są anagramami", „jaka litera występuje najczęściej".

Palindrom

Palindrom — trzy podejścia

# 1. najkrocej - porownanie z odwroconym napisem
def palindrom(s):
  return s == s[::-1]

# 2. dwa wskazniki od konca i od poczatku
def palindrom2(s):
  i = 0
  j = len(s) - 1
  while i < j:
      if s[i] != s[j]:
          return False
      i += 1
      j -= 1
  return True

# 3. wersja odporna na wielkosc liter i spacje
def palindrom3(s):
  czysty = "".join(z.lower() for z in s if z.isalnum())
  return czysty == czysty[::-1]

print(palindrom("kajak"))              # True
print(palindrom3("Kobyla ma maly bok"))  # True
💡
Wersja z odwróceniem jest najkrótsza i w zupełności wystarcza. Wersja z dwoma wskaźnikami przydaje się, gdy treść każe zapisać algorytm w pseudokodzie albo policzyć liczbę porównań — wykonuje ich n przez 2.

Zapis s[::-1] to wycinek z krokiem minus jeden, czyli napis od końca. Warto go umieć, bo skraca też odwracanie liczby czy listy. Pułapka: liczby nie da się odwrócić wycinkiem — najpierw str(n), a przy porównaniu z powrotem int().

Anagram

Anagram — dwa sposoby

# 1. przez sortowanie liter - O(n log n)
def anagram(a, b):
  return sorted(a.lower()) == sorted(b.lower())

# 2. przez zliczanie liter - O(n)
def anagram2(a, b):
  if len(a) != len(b):
      return False
  licznik = {}
  for z in a.lower():
      licznik[z] = licznik.get(z, 0) + 1
  for z in b.lower():
      if z not in licznik:
          return False
      licznik[z] -= 1
      if licznik[z] == 0:
          del licznik[z]
  return len(licznik) == 0

print(anagram("Barok", "Kobra"))   # True
💡
Sposób z sortowaniem jest krótszy i całkowicie wystarczający: dwa napisy są anagramami wtedy i tylko wtedy, gdy po posortowaniu liter są identyczne. Sposób ze słownikiem pokazuje mechanizm zliczania i jest szybszy, bo nie sortuje.

Pułapka: porównywanie bez ujednolicenia wielkości liter. „Barok" i „kobra" to anagramy, ale wielka litera na początku sprawia, że kod bez lower() odpowie przecząco. To samo dotyczy polskich znaków — jeśli treść je dopuszcza, sprawdź, czy plik czytasz z właściwym kodowaniem.

Zliczanie wystąpień

Zliczanie znaków, słów i elementów

tekst = "matura z informatyki"

licznik = {}
for znak in tekst:
  if znak != " ":
      licznik[znak] = licznik.get(znak, 0) + 1

# najczestszy znak
najczestszy = max(licznik, key=licznik.get)
print(najczestszy, licznik[najczestszy])

# posortowane malejaco po liczbie wystapien,
# a przy remisie alfabetycznie
for znak, ile in sorted(licznik.items(), key=lambda p: (-p[1], p[0])):
  print(znak, ile)
💡
Metoda get ze zwracaną wartością domyślną to najprostszy sposób na zliczanie bez sprawdzania, czy klucz już istnieje. Wyszukanie największej wartości robi się funkcją max z parametrem key.

Zliczanie w słowniku ma złożoność liniową i jest właściwą odpowiedzią wszędzie tam, gdzie odruch podpowiada pętlę w pętli z licznikiem. Ten drugi zapis daje złożoność kwadratową i na dużym pliku przestaje się liczyć w rozsądnym czasie. Więcej o strukturach danych w Pythonie znajdziesz w dziale teorii programowania.

Jak rozpoznać, którego algorytmu chce od Ciebie zadanie#

To umiejętność, którą trenuje się osobno — i której brak jest częstszą przyczyną straty punktów niż nieznajomość kodu. Treści maturalne rzadko mówią wprost „użyj algorytmu Euklidesa". Mówią za to rzeczy, które jednoznacznie na niego wskazują.

Co mówi treść zadaniaCzego oczekujeSygnał rozpoznawczy
skróć ułamek, wspólna miara, podziel na równe części bez resztyNWD — algorytm Euklidesadwie liczby i słowo wspólny
po ilu dniach oba zdarzenia zbiegną się ponownieNWW policzone przez NWDdwa cykle o różnych długościach
ile liczb pierwszych w przedziale, wypisz liczby pierwsze do Nsito Eratostenesazakres, a nie pojedyncza liczba
czy liczba z pliku jest pierwszatest dzielników do pierwiastkapojedyncze liczby, każda inna
iloczyn dwóch liczb pierwszych, największy dzielnik pierwszyrozkład na czynnikisłowo czynnik lub dzielnik pierwszy
zapisz liczbę w systemie, oblicz wartość wyrażenia w systemieschemat Hornerapodstawa inna niż dziesiątkowa
sprawdź, czy wartość występuje w posortowanym ciąguwyszukiwanie binarnesłowo posortowany w treści
uporządkuj malejąco, a przy remisie alfabetyczniesorted() z kluczem krotkowymdwa kryteria porządku
ile razy występuje, najczęstszy, ile jest różnych wartościsłownik lub zbiór, jedno przejściezliczanie w dużym zbiorze danych
każdy wyraz zależy od dwóch poprzednichiteracja z dwiema zmiennymidefinicja rekurencyjna w treści
Warto przeczytać tę tabelę przed sesją i po każdym rozwiązanym arkuszu dopisać do niej własny wiersz.

Trzy dodatkowe wskazówki, które pomagają wybrać dobrze:

  • Sprawdź rozmiar danych, zanim wybierzesz metodę. Plik z dwudziestoma liczbami wybaczy wszystko. Plik z setkami tysięcy wierszy wyklucza rozwiązania kwadratowe, a to często jedyna podpowiedź, że zadanie oczekuje sita, słownika albo wyszukiwania binarnego.
  • Zwróć uwagę, czy pytanie dotyczy jednej wartości, czy zakresu. To rozstrzyga między testem pierwszości a sitem, między pojedynczym przejściem a przygotowaniem struktury pomocniczej.
  • Sprawdź, czy podpunkty się na sobie opierają. Zadania maturalne mają zwykle cztery lub pięć podpunktów liczonych na tych samych danych. Jeśli pierwszy każe wczytać dane, a trzeci pyta o liczby pierwsze — sito policzone raz na początku obsłuży wszystkie kolejne podpunkty.

Plan powtórki: dwa tygodnie po pół godziny dziennie#

Algorytmów maturalnych jest tyle, że da się je przerobić w dwa tygodnie, jeśli robi się to systematycznie i pisząc kod z pamięci, a nie czytając gotowy. Sprawdzianem nie jest „rozumiem", tylko „potrafię odtworzyć na pustej kartce".

Tydzień pierwszy — schematy z pamięci. Każdego dnia jeden algorytm: piszesz go bez zaglądania, uruchamiasz na trzech zestawach danych (typowym, brzegowym i pustym), a potem porównujesz ze wzorcem.

  1. Test pierwszości do pierwiastka wraz z obsługą liczb mniejszych od dwóch.
  2. Sito Eratostenesa — ze świadomym uzasadnieniem startu od kwadratu.
  3. Rozkład na czynniki pierwsze wraz z warunkiem końcowym.
  4. Algorytm Euklidesa w obu wersjach oraz NWW.
  5. Schemat Hornera w dwóch zastosowaniach: konwersja i wielomian.
  6. Wyszukiwanie binarne — z testem na tablicy jedno- i dwuelementowej.
  7. Fibonacci iteracyjnie oraz trzy funkcje na napisach.

Tydzień drugi — zadania, nie algorytmy. Bierzesz arkusze z poprzednich sesji i przed napisaniem choćby jednej linijki zapisujesz na brudno: który algorytm, jaka złożoność, ile jest danych. Dopiero potem kodujesz. To ćwiczenie na rozpoznawanie, a nie na pisanie — i to ono najbardziej się opłaca.

Zanim usiądziesz do arkusza
  • Przygotuj plik szablonowy. Wczytanie danych z pliku ze strip(), funkcja sprawdzająca pierwszość, NWD, sito i szkielet zapisu wyników. Przepisanie tego na egzaminie zajmuje kilka minut, które lepiej przeznaczyć na właściwy algorytm.

  • Testuj na danych brzegowych. Liczba 1, liczba 2, kwadrat liczby pierwszej (na przykład 49), lista jednoelementowa, lista pusta, wartości powtórzone. Większość błędów opisanych w tym wpisie wychodzi właśnie na nich.

  • Ćwicz na prawdziwych arkuszach, nie na wymyślonych zadaniach. Zestawy z poprzednich sesji znajdziesz w dziale arkuszy maturalnych — po trzech przerobionych sam zauważysz, że schematy się powtarzają.

  • Jeśli kod piszesz, ale wciąż nie wiesz, który algorytm wybrać, problem leży w czytaniu treści, nie w programowaniu — i to da się ustawić w kilka spotkań. Napisz przez formularz kontaktowy i podaj, przy którym typie zadania najczęściej się zacinasz.

Korepetycje z informatyki

Zdajesz maturę rozszerzoną z informatyki?

Prowadzimy indywidualne przygotowanie do matury z informatyki — algorytmy, Python i C++, bazy danych i arkusz kalkulacyjny. Zajęcia online, na arkuszach CKE. Zobacz program i cennik.

Zobacz korepetycje z informatyki

Uczysz się w technikum informatycznym? Prowadzimy też przygotowanie do kwalifikacji INF.02, INF.03 i INF.04. Zobacz egzaminy zawodowe

Podoba Ci się ten artykuł? 💻

To tylko wycinek wiedzy! Na naszych korepetycjach omawiamy te tematy jeszcze dokładniej. Zapisz się na lekcję próbną i zdaj egzamin na 100%.

🚀 Zadzwoń i umów darmową konsultację

Autor: Alan Ostrowski