Na rozmowach z algorytmów najczęściej pada kilkanaście tematów: złożoność obliczeniowa (notacja Big O), wyszukiwanie binarne, sortowanie, rekurencja, tablica mieszająca i klasyczne zadania, takie jak Two Sum, palindrom czy poprawne nawiasy. Rekruter nie sprawdza, czy znasz algorytm na pamięć, tylko czy potrafisz opisać, jak działa, ile kosztuje i jak go ulepszyć. Dobra odpowiedź to rozwiązanie wprost, jego złożoność i krok do szybszej wersji.
Rozmowa z algorytmów brzmi groźnie, ale na stanowisko juniora to zwykle te same kilkanaście zadań i pytań. Rekruter chce zobaczyć, że rozumiesz, co robi kod, ile to kosztuje i jak możesz to poprawić, a nie że wkułeś rozwiązania z platformy do ćwiczeń. Poniżej znajdziesz pytania, które wracają najczęściej, razem z tym, jak na nie odpowiadać. To lista kontrolna do powtórki, a nie podręcznik od zera: jeśli któryś temat jest zupełnie nowy, zacznij od osobnego artykułu, np. o tym, jak działa pętla for.
Główne przykłady napisaliśmy w Pythonie, a przy najczęstszych zadaniach dopisaliśmy też wersje w JavaScripcie i PHP, żebyś mógł porównać, jak ten sam pomysł wygląda w każdym z języków. Wszystko uruchomiliśmy (Python 3.13.7, Node.js 22 i PHP 8.4.5), więc wyniki podane w tekście to faktyczne wyniki, a nie zgadywanie. Zmienia się składnia i kilka pułapek, ale sam algorytm jest ten sam. Pytania o sam język zebraliśmy osobno: o Pythona i o JavaScript, a o bazy danych w artykule o SQL i bazach danych.
Jak odpowiadać na zadania algorytmiczne na rozmowie?
Najlepiej w pięciu krokach, mówiąc na głos:
- Powtórz zadanie własnymi słowami i dopytaj o dane: ile elementów, czy mogą się powtarzać, czy lista jest posortowana, co zwrócić, gdy nie ma wyniku.
- Podaj rozwiązanie wprost (brute force), nawet jeśli jest wolne. Lepsze działające wolne rozwiązanie niż brak rozwiązania.
- Powiedz, jaka jest jego złożoność i gdzie jest wąskie gardło.
- Zaproponuj szybszą wersję, zwykle przez inną strukturę danych (zbiór, słownik, stos) albo sortowanie.
- Przetestuj na przypadkach brzegowych (edge case): pusta lista, jeden element, duplikaty, liczby ujemne.
Taki schemat zajmuje kilka minut i pokazuje sposób myślenia, który rekruterzy oceniają wyżej niż błyskawicznie napisany kod.
Podstawy: złożoność obliczeniowa
Czym jest algorytm i po czym poznać, że jest dobry?
Algorytm to skończony ciąg kroków, który rozwiązuje określony problem. Ocenia się go po trzech rzeczach: poprawności (czy zawsze zwraca dobry wynik, także na przypadkach brzegowych), czasie (ile kroków wykona w zależności od rozmiaru danych) i pamięci (ile dodatkowego miejsca potrzebuje). Do tego dochodzi czytelność, bo kod, którego nikt nie zrozumie, jest w pracy kosztem.
Co to jest notacja Big O?
Notacja Big O (Big O notation) opisuje, jak rośnie liczba kroków algorytmu wraz z rozmiarem danych n. Nie mierzy sekund, tylko tempo wzrostu. Dzięki temu możesz porównać dwa rozwiązania niezależnie od komputera i języka.
| Złożoność | Nazwa | Przykład | Co się dzieje, gdy dane rosną 10 razy |
|---|---|---|---|
| O(1) | stała | dostęp do elementu listy po indeksie | bez zmian |
| O(log n) | logarytmiczna | wyszukiwanie binarne | o kilka kroków więcej |
| O(n) | liniowa | przejście po liście, szukanie maksimum | 10 razy więcej |
| O(n log n) | quasi-liniowa | dobre sortowanie (merge sort) | nieco ponad 10 razy więcej |
| O(n²) | kwadratowa | dwie zagnieżdżone pętle | 100 razy więcej |
| O(2ⁿ) | wykładnicza | naiwny Fibonacci | rośnie lawinowo |
Jak policzyć złożoność fragmentu kodu?
Policz, ile razy wykona się najgłębiej zagnieżdżona instrukcja, i odrzuć stałe oraz mniej znaczące składniki. Jedna pętla po n elementach to O(n). Dwie pętle jedna w drugiej, obie po n, to O(n²). Dwie pętle jedna po drugiej to O(n + n), czyli nadal O(n). Pętla, która za każdym razem dzieli zakres na pół, to O(log n).
def total(items): # O(n): jedna pętla
result = 0
for x in items:
result += x
return result
def count_pairs(items): # O(n²): pętla w pętli
count = 0
for a in items:
for b in items:
count += 1
return count
def halvings(n): # O(log n): zakres maleje o połowę
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
print(count_pairs(range(10))) # 100
print(halvings(1024)) # 10
Dla 10 elementów count_pairs wykonuje 100 kroków, a dla 1000 elementów już milion. Właśnie to widać w kolumnie „dane rosną 10 razy” w tabeli wyżej.
Czym różni się złożoność czasowa od pamięciowej?
Czasowa mówi, ile kroków wykona algorytm, a pamięciowa, ile dodatkowego miejsca zajmie poza danymi wejściowymi. Często jedno kupuje się kosztem drugiego: słownik w zadaniu Two Sum przyspiesza rozwiązanie z O(n²) do O(n), ale zajmuje dodatkowe O(n) pamięci. Na rozmowie dobrze jest nazwać ten kompromis wprost, zamiast mówić tylko „jest szybciej”.
Czym są najgorszy, średni i najlepszy przypadek?
To trzy sposoby opisania tego samego algorytmu w zależności od danych. Wyszukiwanie liniowe w najlepszym przypadku znajdzie element od razu (O(1)), w najgorszym przejdzie całą listę (O(n)). Quicksort ma średnio O(n log n), ale przy niefortunnym wyborze pivota O(n²). Rekruterzy pytają o najgorszy przypadek najczęściej, bo to on decyduje, czy rozwiązanie wytrzyma duże dane.
Czy wyższa złożoność może być lepszym wyborem?
Tak, dla małych danych i prostego kodu. Algorytm O(n²) bez narzutu bywa szybszy od O(n log n) z dużym stałym kosztem, gdy n to kilkadziesiąt elementów. Dlatego wiele bibliotek przełącza się na proste sortowanie przy małych zakresach. Odpowiedź „zmierzyłbym na realnych danych, zanim skomplikuję kod” pokazuje dojrzałe podejście.
Struktury danych
Czym różni się tablica (lista) od listy powiązanej?
Tablica trzyma elementy obok siebie w pamięci, więc dostęp po indeksie jest natychmiastowy (O(1)), ale wstawianie lub usuwanie w środku wymaga przesunięcia reszty (O(n)). Lista powiązana składa się z węzłów, z których każdy wskazuje następny: wstawienie w znanym miejscu jest tanie (O(1)), ale dotarcie do k-tego elementu wymaga przejścia po drodze (O(n)). Lista (list) w Pythonie i tablica w JavaScripcie to w praktyce tablice dynamiczne.
Klasyczne zadanie to odwrócenie listy powiązanej:
class Node:
def __init__(self, value, next=None):
self.value = value
self.next = next
def reverse(head):
previous = None
while head:
following = head.next
head.next = previous
previous = head
head = following
return previous
def to_list(node):
result = []
while node:
result.append(node.value)
node = node.next
return result
print(to_list(reverse(Node(1, Node(2, Node(3)))))) # [3, 2, 1]
print(to_list(reverse(None))) # []
Złożoność to O(n) czasu i O(1) pamięci, bo odwracamy wskaźniki w miejscu. Przy tym zadaniu rekruter sprawdza głównie, czy zapamiętasz następny węzeł, zanim zerwiesz połączenie.
Czym różni się stos od kolejki?
Stos (stack) działa według zasady LIFO: ostatni włożony element wychodzi pierwszy, jak stos talerzy. Kolejka (queue) działa według FIFO: pierwszy włożony wychodzi pierwszy, jak kolejka w sklepie. Stos pojawia się przy cofaniu (undo), nawiasach i rekurencji, kolejka przy zadaniach w tle i przeszukiwaniu grafu wszerz. W Pythonie stos to zwykła lista (append, pop), a kolejka to collections.deque.
Dlaczego w Pythonie do kolejki używa się deque, a nie listy?
Bo list.pop(0) usuwa pierwszy element i przesuwa wszystkie pozostałe, czyli kosztuje O(n), a deque.popleft() to O(1). Przy 50 tysiącach elementów opróżnienie listy przez pop(0) zajęło ułamek sekundy, a deque około stu razy mniej. Dokładne liczby zależą od komputera, ale różnica rzędu wielkości jest zawsze widoczna. Ta sama pułapka jest w innych językach: array.shift() w JavaScripcie i array_shift() w PHP też przesuwają pozostałe elementy. U nas opróżnienie 50 tysięcy elementów przez shift() zajęło w Node ok. 0,2 s, a przez array_shift() w PHP ok. 1,2 s. Przy większych kolejkach trzymaj indeks początku zamiast usuwać pierwszy element albo użyj gotowej struktury (SplQueue w PHP).
Jak działa tablica mieszająca (hash map) i jaką ma złożoność?
Słownik (dict) w Pythonie, obiekt Map w JavaScripcie i HashMap w Javie to tablice mieszające (hash map). Klucz jest przepuszczany przez funkcję skrótu, a wynik wskazuje miejsce, w którym leży wartość. Dzięki temu odczyt, zapis i sprawdzenie, czy klucz istnieje, kosztują średnio O(1). Gdy dwa klucze trafią w to samo miejsce, mamy kolizję, którą struktura rozwiązuje wewnętrznie. W najgorszym przypadku, przy wielu kolizjach, operacje spadają do O(n), ale w praktyce zdarza się to rzadko.
Jak szybko sprawdzić, czy element jest w kolekcji: lista czy zbiór?
Zbiór (set), bo sprawdzenie x in zbior to średnio O(1), a w liście O(n), gdyż trzeba przejść po elementach. Przy liście stu tysięcy liczb i szukaniu ostatniej z nich zbiór był u nas o około cztery rzędy wielkości szybszy.
import timeit
data_list = list(range(100_000))
data_set = set(data_list)
print(timeit.timeit(lambda: 99_999 in data_list, number=200)) # ok. 0,07 s
print(timeit.timeit(lambda: 99_999 in data_set, number=200)) # ok. 0,000007 s
W JavaScripcie i PHP jest ta sama różnica: tablica z includes / in_array przegląda elementy po kolei, a Set / tablica asocjacyjna z isset trafia prosto do klucza.
const big = Array.from({ length: 100_000 }, (_, i) => i);
const bigSet = new Set(big);
big.includes(99_999); // O(n): przegląda elementy
bigSet.has(99_999); // O(1): trafia prosto w klucz
$big = range(0, 99999);
$set = array_flip($big); // wartości stają się kluczami
in_array(99999, $big, true); // O(n)
isset($set[99999]); // O(1)
W naszym pomiarze 200 wyszukań ostatniego elementu zajęło w JavaScripcie ok. 4 ms dla includes i ułamek milisekundy dla Set, a w PHP ok. 18 ms dla in_array i praktycznie 0 dla isset. Dokładne liczby zależą od komputera, ale różnica rzędu wielkości jest taka sama jak w Pythonie.
To najczęstszy sposób przyspieszenia rozwiązań na rozmowie: zamiana wewnętrznej pętli „szukaj w liście” na zapytanie do zbioru lub słownika. Ceną jest dodatkowa pamięć i kolejność, której zbiór nie gwarantuje.
Wyszukiwanie i sortowanie
Jak działa wyszukiwanie binarne?
Działa tylko na posortowanej liście. Porównujesz szukaną wartość ze środkowym elementem i odrzucasz połowę zakresu, w której na pewno jej nie ma, aż zostanie jeden element lub pusty zakres. Złożoność to O(log n): na milionie elementów wystarczy około 20 porównań.
def binary_search(items, target):
low, high = 0, len(items) - 1
while low <= high:
mid = (low + high) // 2
if items[mid] == target:
return mid
if items[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
numbers = [1, 3, 5, 7, 9, 11]
print(binary_search(numbers, 7)) # 3
print(binary_search(numbers, 4)) # -1
print(binary_search([], 1)) # -1
print(binary_search([5], 5)) # 0
To samo w JavaScripcie i PHP:
function binarySearch(items, target) {
let low = 0;
let high = items.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (items[mid] === target) return mid;
if (items[mid] < target) low = mid + 1;
else high = mid - 1;
}
return -1;
}
console.log(binarySearch([1, 3, 5, 7, 9, 11], 7)); // 3
console.log(binarySearch([1, 3, 5, 7, 9, 11], 4)); // -1
console.log(binarySearch([], 1)); // -1
function binarySearch(array $items, int $target): int
{
$low = 0;
$high = count($items) - 1;
while ($low <= $high) {
$mid = intdiv($low + $high, 2);
if ($items[$mid] === $target) return $mid;
if ($items[$mid] < $target) $low = $mid + 1;
else $high = $mid - 1;
}
return -1;
}
echo binarySearch([1, 3, 5, 7, 9, 11], 7); // 3
echo binarySearch([1, 3, 5, 7, 9, 11], 4); // -1
echo binarySearch([], 1); // -1
Pułapki językowe: w JavaScripcie dzielenie / zwraca liczbę niecałkowitą, więc potrzebne jest Math.floor; w PHP intdiv robi to samo. W Pythonie wystarcza //.
Najczęstsze błędy to zła granica pętli (< zamiast <=, więc ginie ostatni element), pominięcie + 1 i - 1, przez które pętla się zapętla, oraz użycie algorytmu na liście, która nie jest posortowana. Dlatego zawsze sprawdź pustą listę i listę jednoelementową.
Czym różnią się główne algorytmy sortowania?
Rekruter rzadko każe pisać wszystkie, ale oczekuje, że rozróżnisz najważniejsze:
| Algorytm | Średnio | Najgorszy przypadek | Dodatkowa pamięć | Stabilny | Uwaga |
|---|---|---|---|---|---|
| Bąbelkowe (bubble sort) | O(n²) | O(n²) | O(1) | tak | proste, ale wolne; tylko do nauki |
| Przez wstawianie (insertion sort) | O(n²) | O(n²) | O(1) | tak | szybkie dla małych lub prawie posortowanych danych |
| Scalanie (merge sort) | O(n log n) | O(n log n) | O(n) | tak | przewidywalne, dziel i zwyciężaj |
| Szybkie (quicksort) | O(n log n) | O(n²) | O(log n) | zwykle nie | szybkie w praktyce, zależy od pivota |
Stabilne sortowanie zachowuje względną kolejność elementów o równym kluczu. W Pythonie funkcja sorted() i metoda list.sort() używają algorytmu Timsort, który jest stabilny i ma O(n log n) w najgorszym przypadku. W praktyce na rozmowie odpowiedź „użyłbym wbudowanego sortowania” jest dobra, o ile potrafisz wyjaśnić, co ono robi.
people = [("Ola", 30), ("Adam", 25), ("Ewa", 30), ("Jan", 25)]
print(sorted(people, key=lambda p: p[1]))
# [('Adam', 25), ('Jan', 25), ('Ola', 30), ('Ewa', 30)]
Osoby o tym samym wieku zostały w pierwotnej kolejności (Adam przed Janem, Ola przed Ewą). To właśnie znaczy „stabilny”.
W JavaScripcie i PHP jest podobnie, ale z jedną pułapką w JavaScripcie. sort() w JavaScripcie jest stabilny od ES2019, a usort() w PHP od wersji 8.0. Jednak sort() bez funkcji porównującej w JavaScripcie sortuje jako napisy:
console.log([10, 9, 1].sort()); // [1, 10, 9]
console.log([10, 9, 1].sort((a, b) => a - b)); // [1, 9, 10]
$people = [["Ola", 30], ["Adam", 25], ["Ewa", 30], ["Jan", 25]];
usort($people, fn($x, $y) => $x[1] <=> $y[1]);
// Adam, Jan, Ola, Ewa: kolejność równych elementów zachowana
To częste pytanie dodatkowe: „co zwróci [10, 9, 1].sort()?”.
Jak działa sortowanie przez scalanie (merge sort)?
Dzielisz listę na pół, sortujesz rekurencyjnie obie połowy, a potem scalasz dwie posortowane listy w jedną, za każdym razem biorąc mniejszy z dwóch pierwszych elementów. Głębokość podziałów to log n, a każdy poziom kosztuje O(n) scalania, stąd O(n log n).
def merge_sort(items):
if len(items) <= 1:
return items
mid = len(items) // 2
left = merge_sort(items[:mid])
right = merge_sort(items[mid:])
merged, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
return merged + left[i:] + right[j:]
print(merge_sort([5, 2, 9, 1, 5, 6])) # [1, 2, 5, 5, 6, 9]
print(merge_sort([])) # []
Znak <= w porównaniu nie jest przypadkowy: przy równych elementach bierzemy lewy, co zachowuje stabilność.
Jak działa quicksort i kiedy jest wolny?
Wybierasz element zwany pivotem, dzielisz resztę na mniejsze i większe od niego, a następnie sortujesz obie części rekurencyjnie. Średnio daje to O(n log n). Jeśli pivot jest za każdym razem najmniejszym lub największym elementem (np. pierwszy element w już posortowanej liście), podział jest skrajnie nierówny i złożoność spada do O(n²).
def quick_sort(items):
if len(items) <= 1:
return items
pivot = items[len(items) // 2]
smaller = [x for x in items if x < pivot]
equal = [x for x in items if x == pivot]
larger = [x for x in items if x > pivot]
return quick_sort(smaller) + equal + quick_sort(larger)
print(quick_sort([5, 2, 9, 1, 5, 6])) # [1, 2, 5, 5, 6, 9]
To wersja czytelna, ale zajmuje dodatkową pamięć. Wersje „w miejscu” zamieniają elementy w jednej liście. Na rozmowie wystarczy ta pierwsza, jeśli powiesz o tej różnicy.
Rekurencja
Czym różni się rekurencja od iteracji i skąd bierze się RecursionError?
Rekurencja to funkcja, która wywołuje samą siebie na mniejszej wersji problemu i musi mieć warunek bazowy, który ją zatrzymuje. Iteracja robi to samo pętlą. Każde wywołanie rekurencyjne odkłada ramkę na stosie wywołań, a w Pythonie jego głębokość jest ograniczona (domyślnie 1000), więc zbyt głęboka rekurencja kończy się błędem RecursionError. Szczegółowe omówienie z przykładami znajdziesz w artykule o tym, jak działa rekurencja jako alternatywa dla pętli w JavaScript.
import sys
def count_down(n):
return 0 if n == 0 else 1 + count_down(n - 1)
def count_iter(n):
count = 0
while n:
count += 1
n -= 1
return count
print(sys.getrecursionlimit()) # 1000
try:
count_down(5000)
except RecursionError as error:
print("RecursionError:", error) # maximum recursion depth exceeded
print(count_iter(5000)) # 5000
Limit rekurencji zależy od języka. W JavaScripcie przekroczenie stosu kończy się błędem RangeError: Maximum call stack size exceeded (u nas przy milionie wywołań), a w PHP nie ma domyślnego limitu głębokości jak w Pythonie, więc countDown(100000) zadziałało. Każde wywołanie zajmuje jednak pamięć, więc przy znacznie większej głębokości proces w końcu zakończy się błędem.
Odpowiedź w stylu rekrutacyjnym: rekurencja jest czytelna dla problemów, które same się dzielą (drzewa, przeszukiwanie, scalanie), ale iteracja jest bezpieczniejsza dla głębokich danych i zużywa mniej pamięci.
Dlaczego naiwny Fibonacci jest wolny i jak go przyspieszyć?
Bo te same podproblemy liczą się wielokrotnie. fib(25) w wersji naiwnej wykonuje 242 785 wywołań, żeby policzyć 75 025. Złożoność to O(2ⁿ). Wystarczy zapamiętywać wyniki (memoization), a liczba wywołań spada do 26, czyli O(n).
from functools import lru_cache
calls = 0
def fib(n):
global calls
calls += 1
return n if n < 2 else fib(n - 1) + fib(n - 2)
@lru_cache(maxsize=None)
def fib_memo(n):
return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2)
def fib_iter(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fib(25), calls) # 75025 242785
print(fib_memo(25)) # 75025
print(fib_iter(90)) # 2880067194370816120
Trzecia wersja, iteracyjna, ma O(n) czasu i O(1) pamięci, więc jest najlepsza do samego wyniku. To pytanie jest tak popularne, bo w jednym zadaniu sprawdza rekurencję, złożoność i pamięć podręczną.
W JavaScripcie i PHP zapamiętywanie robisz ręcznie, bo nie ma lru_cache:
const memo = new Map();
function fibMemo(n) {
if (n < 2) return n;
if (memo.has(n)) return memo.get(n);
const value = fibMemo(n - 1) + fibMemo(n - 2);
memo.set(n, value);
return value;
}
console.log(fibMemo(25)); // 75025
console.log(fibMemo(90)); // 2880067194370816000, a poprawnie: 2880067194370816120
function fibMemo(int $n, array &$memo = []): int
{
if ($n < 2) return $n;
return $memo[$n] ??= fibMemo($n - 1, $memo) + fibMemo($n - 2, $memo);
}
echo fibMemo(25); // 75025
echo fibMemo(90); // 2880067194370816120
Zwróć uwagę na wynik dla fibMemo(90) w JavaScripcie. Liczby w JavaScripcie to 64-bitowe liczby zmiennoprzecinkowe, które są dokładne tylko do Number.MAX_SAFE_INTEGER (około 9 biliardów), więc wynik jest zaokrąglony i kończy się 000 zamiast 120. Poprawny wynik daje BigInt. Pytanie „co się stanie dla bardzo dużych liczb?” pada na rozmowach zaskakująco często. PHP używa 64-bitowych liczb całkowitych i tu mieści się w zakresie.
Klasyczne zadania z kodem
Jak rozwiązać zadanie Two Sum?
Dla listy liczb i sumy docelowej zwróć indeksy dwóch elementów, które dają tę sumę. Rozwiązanie wprost sprawdza wszystkie pary w dwóch pętlach, czyli O(n²). Szybsze używa słownika: dla każdej liczby sprawdzasz, czy jej „dopełnienie” (target - n) już się pojawiło, co daje O(n).
def two_sum_slow(nums, target): # O(n²)
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return None
def two_sum(nums, target): # O(n) czasu, O(n) pamięci
seen = {}
for i, n in enumerate(nums):
if target - n in seen:
return [seen[target - n], i]
seen[n] = i
return None
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum([3, 3], 6)) # [0, 1]
print(two_sum([1, 2], 10)) # None
Zwróć uwagę na przypadek [3, 3]: słownik wypełniamy dopiero po sprawdzeniu, żeby nie użyć tego samego elementu dwa razy.
W JavaScripcie rolę słownika pełni Map, a w PHP zwykła tablica asocjacyjna:
function twoSum(nums, target) {
const seen = new Map();
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (seen.has(complement)) return [seen.get(complement), i];
seen.set(nums[i], i);
}
return null;
}
console.log(twoSum([2, 7, 11, 15], 9)); // [0, 1]
console.log(twoSum([3, 3], 6)); // [0, 1]
console.log(twoSum([1, 2], 10)); // null
function twoSum(array $nums, int $target): ?array
{
$seen = [];
foreach ($nums as $i => $n) {
$complement = $target - $n;
if (isset($seen[$complement])) return [$seen[$complement], $i];
$seen[$n] = $i;
}
return null;
}
echo json_encode(twoSum([2, 7, 11, 15], 9)); // [0,1]
echo json_encode(twoSum([3, 3], 6)); // [0,1]
echo json_encode(twoSum([1, 2], 10)); // null
Obie wersje to ten sam algorytm: O(n) czasu i O(n) pamięci.
Jak sprawdzić, czy napis jest palindromem?
Porównaj napis oczyszczony z odwrotnością. Zapytaj, czy ignorować wielkość liter i znaki niealfanumeryczne, bo to zmienia rozwiązanie.
def is_palindrome(text):
cleaned = [c.lower() for c in text if c.isalnum()]
return cleaned == cleaned[::-1]
print(is_palindrome("A man, a plan, a canal: Panama")) # True
print(is_palindrome("kajak")) # True
print(is_palindrome("abc")) # False
print(is_palindrome("")) # True
Złożoność to O(n). Wariant z dwoma wskaźnikami (two pointers), jednym od początku i drugim od końca, ma O(1) dodatkowej pamięci, jeśli nie tworzysz oczyszczonej kopii.
Jak sprawdzić, czy dwa słowa są anagramami?
Anagramy mają te same litery w tych samych ilościach. Najkrócej policzyć litery w obu słowach i porównać liczniki. Alternatywa to posortować oba słowa, ale to O(n log n) zamiast O(n).
from collections import Counter
def is_anagram(a, b):
return Counter(a.lower()) == Counter(b.lower())
print(is_anagram("kajak", "kakaj")) # True
print(is_anagram("abc", "abd")) # False
print(is_anagram("aab", "abb")) # False
Ostatni przykład to pułapka: te same litery, ale w różnych ilościach. Samo porównanie zbiorów liter dałoby tu błędne True.
Jak znaleźć pierwszy niepowtarzający się znak?
Policz wystąpienia każdego znaku w jednym przebiegu, a drugim znajdź pierwszy, który wystąpił raz. Całość to O(n).
from collections import Counter
def first_unique(text):
counts = Counter(text)
for i, char in enumerate(text):
if counts[char] == 1:
return i
return -1
print(first_unique("leetcode")) # 0
print(first_unique("aabb")) # -1
print(first_unique("")) # -1
Jak sprawdzić, czy lista ma duplikaty i jak je usunąć?
Zamień listę na zbiór i porównaj długości. To O(n) i najkrótszy zapis. Jeśli musisz zachować kolejność, przejdź po liście i pamiętaj w zbiorze, co już widziałeś.
def has_duplicates(items):
return len(items) != len(set(items))
print(has_duplicates([1, 2, 3, 1])) # True
print(has_duplicates([1, 2, 3])) # False
const hasDuplicates = (items) => items.length !== new Set(items).size;
console.log(hasDuplicates([1, 2, 3, 1])); // true
console.log(hasDuplicates([1, 2, 3])); // false
function hasDuplicates(array $items): bool
{
return count($items) !== count(array_unique($items));
}
var_dump(hasDuplicates([1, 2, 3, 1])); // bool(true)
var_dump(hasDuplicates([1, 2, 3])); // bool(false)
Rozwiązanie z dwiema zagnieżdżonymi pętlami też działa, ale to O(n²). Rekruter zwykle pyta właśnie o ten krok: „A czy da się szybciej?”.
Jak sprawdzić, czy nawiasy są poprawnie sparowane?
To klasyczne zadanie na stos. Każdy nawias otwierający odkładasz na stos, a przy zamykającym zdejmujesz ostatni i sprawdzasz, czy do siebie pasują. Na końcu stos musi być pusty.
def is_valid(text):
pairs = {")": "(", "]": "[", "}": "{"}
stack = []
for char in text:
if char in "([{":
stack.append(char)
elif char in pairs:
if not stack or stack.pop() != pairs[char]:
return False
return not stack
print(is_valid("([]{})")) # True
print(is_valid("(]")) # False
print(is_valid("((")) # False, niedomknięte
print(is_valid(")(")) # False, zamknięcie bez otwarcia
print(is_valid("")) # True
function isValid(text) {
const pairs = { ")": "(", "]": "[", "}": "{" };
const stack = [];
for (const char of text) {
if ("([{".includes(char)) stack.push(char);
else if (char in pairs) {
if (stack.pop() !== pairs[char]) return false;
}
}
return stack.length === 0;
}
console.log(isValid("([]{})")); // true
console.log(isValid("(]")); // false
console.log(isValid(")(")); // false
function isValid(string $text): bool
{
$pairs = [')' => '(', ']' => '[', '}' => '{'];
$stack = [];
foreach (str_split($text) as $char) {
if (str_contains('([{', $char)) {
$stack[] = $char;
} elseif (isset($pairs[$char])) {
if (array_pop($stack) !== $pairs[$char]) return false;
}
}
return $stack === [];
}
var_dump(isValid("([]{})")); // bool(true)
var_dump(isValid("(]")); // bool(false)
var_dump(isValid(")(")); // bool(false)
W tych wersjach pusty stos nie wymaga osobnego warunku: pop() na pustej tablicy zwraca undefined w JavaScripcie i null w PHP, więc porównanie z oczekiwanym nawiasem się nie powiedzie i funkcja zwróci false. W Pythonie pop() na pustej liście rzuca wyjątek, dlatego tam sprawdzamy not stack.
Przypadki "((" i ")(" łapią dwa najczęstsze błędy: zapomnienie o sprawdzeniu pustego stosu przy zamknięciu i o sprawdzeniu, czy stos jest pusty na końcu.
Jak napisać FizzBuzz i dlaczego jest tak często pytany?
Dla liczb od 1 do 15 wypisz Fizz dla wielokrotności 3, Buzz dla wielokrotności 5 i FizzBuzz dla obu, a w pozostałych przypadkach samą liczbę. To zadanie sprawdza, czy w ogóle potrafisz pisać kod, i odsiewa osoby, które nigdy nie napisały pętli z warunkiem. Pułapka: sprawdź przypadek podzielny przez 15 przed przypadkami podzielnymi przez 3 i 5.
def fizzbuzz(n):
result = []
for i in range(1, n + 1):
if i % 15 == 0:
result.append("FizzBuzz")
elif i % 3 == 0:
result.append("Fizz")
elif i % 5 == 0:
result.append("Buzz")
else:
result.append(i)
return result
print(fizzbuzz(15))
# [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz']
Jak znaleźć największą sumę ciągłego fragmentu listy?
To zadanie o największej sumie podtablicy rozwiązuje algorytm Kadane’a. Idziesz po liście i w każdym kroku decydujesz: rozpocząć nowy fragment od bieżącej liczby czy kontynuować poprzedni. Złożoność to O(n) czasu i O(1) pamięci, w przeciwieństwie do sprawdzania wszystkich fragmentów, które kosztuje O(n²) lub O(n³).
def max_subarray(nums):
best = current = nums[0]
for n in nums[1:]:
current = max(n, current + n)
best = max(best, current)
return best
print(max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4])) # 6
print(max_subarray([-3, -1, -2])) # -1
Drugi przykład to pułapka: gdy wszystkie liczby są ujemne, poprawny wynik to największa z nich, a nie zero. Rozwiązanie, które zaczyna od best = 0, tu się myli.
Grafy i drzewa
Czym różni się BFS od DFS?
Oba przeszukują graf lub drzewo, ale w innej kolejności. BFS (breadth-first search, przeszukiwanie wszerz) odwiedza najpierw wszystkich sąsiadów, potem sąsiadów sąsiadów, i używa kolejki. DFS (depth-first search, przeszukiwanie w głąb) schodzi jak najgłębiej jedną ścieżką, zanim wróci, i używa stosu (lub rekurencji). Oba mają złożoność O(V + E), czyli liczba wierzchołków plus krawędzi.
from collections import deque
graph = {
"A": ["B", "C"], "B": ["D"], "C": ["D", "E"],
"D": ["F"], "E": ["F"], "F": [],
}
def bfs(graph, start):
visited, queue, order = {start}, deque([start]), []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return order
def dfs(graph, node, visited=None):
if visited is None:
visited = set()
visited.add(node)
order = [node]
for neighbor in graph[node]:
if neighbor not in visited:
order += dfs(graph, neighbor, visited)
return order
print(bfs(graph, "A")) # ['A', 'B', 'C', 'D', 'E', 'F']
print(dfs(graph, "A")) # ['A', 'B', 'D', 'F', 'C', 'E']
Zbiór visited jest konieczny, bo bez niego algorytm zapętliłby się na grafie z cyklem. Jeśli dopiero poznajesz tę strukturę, zacznij od artykułu o tym, czym jest graf w programowaniu, jak wyglądają węzły i krawędzie.
Którego algorytmu użyć do najkrótszej ścieżki?
W grafie bez wag (każda krawędź kosztuje tyle samo) użyj BFS, bo odwiedza wierzchołki w kolejności rosnącej odległości. W grafie z wagami potrzebny jest algorytm Dijkstry, który korzysta z kolejki priorytetowej. Na rozmowie juniorskiej zwykle wystarcza BFS i wyjaśnienie, dlaczego Dijkstra jest potrzebny, gdy krawędzie mają różne koszty.
from collections import deque
def shortest(graph, start, goal):
queue, seen = deque([(start, 0)]), {start}
while queue:
node, distance = queue.popleft()
if node == goal:
return distance
for neighbor in graph[node]:
if neighbor not in seen:
seen.add(neighbor)
queue.append((neighbor, distance + 1))
return -1
print(shortest(graph, "A", "F")) # 3
print(shortest(graph, "F", "A")) # -1, brak ścieżki w tym kierunku
Czym jest drzewo binarne poszukiwań (BST)?
To drzewo, w którym każdy węzeł ma najwyżej dwoje dzieci, a wartości w lewym poddrzewie są mniejsze od węzła, w prawym większe. Dzięki temu wyszukiwanie, wstawianie i usuwanie kosztują O(h), gdzie h to wysokość drzewa. Przejście in-order (lewe, węzeł, prawe) zwraca wartości w kolejności rosnącej.
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def insert(root, value):
if root is None:
return TreeNode(value)
if value < root.value:
root.left = insert(root.left, value)
else:
root.right = insert(root.right, value)
return root
def inorder(node):
if node is None:
return []
return inorder(node.left) + [node.value] + inorder(node.right)
def height(node):
return 0 if node is None else 1 + max(height(node.left), height(node.right))
root = None
for value in [8, 3, 10, 1, 6, 14]:
root = insert(root, value)
print(inorder(root), height(root)) # [1, 3, 6, 8, 10, 14] 3
skewed = None
for value in range(1, 8): # dane już posortowane
skewed = insert(skewed, value)
print(height(skewed)) # 7
Drugi przykład pokazuje najgorszy przypadek: wstawiając posortowane dane do zwykłego BST, dostajesz drzewo wyglądające jak lista, o wysokości równej liczbie elementów, więc operacje spadają do O(n). Dlatego w praktyce używa się drzew samobalansujących.
Strategie rozwiązywania
Czym różni się algorytm zachłanny od programowania dynamicznego?
Algorytm zachłanny (greedy) w każdym kroku wybiera najlepszą lokalnie opcję i nigdy się nie cofa. Programowanie dynamiczne (dynamic programming) rozbija problem na podproblemy, zapamiętuje ich wyniki i łączy je w odpowiedź. Zachłanny jest szybszy i prostszy, ale daje optymalny wynik tylko dla pewnych problemów.
Klasyczny przykład to wydawanie reszty minimalną liczbą monet:
def greedy(coins, amount):
count = 0
for coin in sorted(coins, reverse=True):
count += amount // coin
amount %= coin
return count if amount == 0 else -1
def dp(coins, amount):
best = [0] + [float("inf")] * amount
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
best[a] = min(best[a], best[a - coin] + 1)
return best[amount] if best[amount] != float("inf") else -1
print(greedy([1, 5, 10, 25], 41), dp([1, 5, 10, 25], 41)) # 4 4
print(greedy([1, 3, 4], 6), dp([1, 3, 4], 6)) # 3 2
print(dp([2], 3)) # -1
Dla monet 1, 5, 10, 25 oba podejścia dają ten sam wynik. Dla monet 1, 3, 4 i kwoty 6 algorytm zachłanny bierze 4 + 1 + 1 (trzy monety), a programowanie dynamiczne znajduje 3 + 3 (dwie monety). Ten kontrprzykład jest najlepszą odpowiedzią na pytanie „dlaczego nie zawsze wystarczy zachłanny”.
Co to są dwa wskaźniki (two pointers) i okno przesuwne (sliding window)?
To dwa wzorce, które zamieniają zagnieżdżone pętle O(n²) na jedno przejście O(n). Dwa wskaźniki to indeksy przesuwane z obu końców lub w tym samym kierunku, np. w palindromie albo w szukaniu pary w posortowanej liście. Okno przesuwne to zakres o zmiennym lub stałym rozmiarze, który przesuwasz po liście, aktualizując wynik zamiast liczyć go od zera, np. przy „największej sumie k kolejnych elementów”. Gdy w zadaniu padają słowa „podciąg”, „kolejne elementy” lub „posortowana lista”, to dobry znak, żeby pomyśleć o jednym z tych wzorców.
Co zwróci ten kod? Mini-test na rozgrzewkę
Spróbuj odpowiedzieć, zanim zajrzysz do rozwiązania.
- Jaka jest złożoność pętli, która iteruje po liście
nelementów i dla każdego wywołujex in other_list, gdzieother_listteż manelementów? - Ile razy wykona się pętla
while n > 1: n //= 2dlan = 1024? - Co zwróci
binary_search([1, 3, 5], 5), jeśli w pętli zapiszeszwhile low < highzamiastwhile low <= high? - Dlaczego
is_anagram("aab", "abb")zwracaFalse, a porównanieset("aab") == set("abb")byłobyTrue?
Odpowiedzi:
- O(n²):
inna liście kosztuje O(n) i wykonujesz jenrazy. Zamianaother_listna zbiór daje O(n). - 10 razy, bo 1024 = 2¹⁰. To dokładnie przykład O(log n).
- Pętla zakończy się, gdy
low == high, i nigdy nie sprawdzi ostatniego kandydata, więc w niektórych przypadkach zwróci-1, mimo że element jest na liście. Warunek<=jest konieczny. - Zbiór liter ignoruje liczbę wystąpień.
Counterliczy, ile razy wystąpiła każda litera, więc widzi różnicę (dwaai jednobkontra jednoai dwab).
Jak przygotować się do rozmowy z algorytmów?
- Opanuj kilkanaście wzorców, nie setki zadań. Zbiór/słownik, dwa wskaźniki, okno przesuwne, stos, BFS/DFS, wyszukiwanie binarne i rekurencja z zapamiętywaniem pokrywają większość zadań juniorskich.
- Rozwiązuj z kartką i na głos. Rozmowa to nie tylko kod, ale też tłumaczenie. Ćwicz mówienie, co robisz i dlaczego.
- Po każdym zadaniu podaj złożoność czasu i pamięci. Po tygodniu stanie się to odruchem.
- Wracaj do zadań, których nie rozwiązałeś, po kilku dniach. Powtórka po przerwie uczy więcej niż kolejne nowe zadanie.
- Przetestuj swój kod na trzech przypadkach: pusta lista, jeden element, duplikaty.
- Ćwicz na środowisku bez podpowiedzi, bo na rozmowie nie będziesz miał autouzupełniania ani gotowych rozwiązań pod ręką.
Na czym juniorzy najczęściej tracą punkty?
Nie na trudnych algorytmach, tylko na trzech drobiazgach:
- Brak przypadków brzegowych. Pusta lista, jeden element, same duplikaty albo liczby ujemne. Rozwiązanie, które działa na przykładzie z treści zadania i wywraca się na pustej liście, wygląda jak wyuczone na pamięć.
- Rozwiązanie bez słowa o złożoności. Nawet poprawny kod bez informacji, ile kosztuje, zostawia rekruterowi pytanie, na które powinieneś odpowiedzieć pierwszy.
- Milczenie przy utknięciu. Cisza po 3 minutach zostawia rekrutera bez żadnych danych do oceny. Powiedz na głos, co próbujesz i co Cię blokuje.
Na co zwraca uwagę mentor, gdy sprawdza rozwiązania zadań algorytmicznych?
Przy code review zadań algorytmicznych mentor zwykle zaczyna od trzech rzeczy, które pasują też do rozmowy rekrutacyjnej.
Pierwsze: „Jaka jest złożoność i ile jest danych?” Zanim napiszesz kod, powiedz na głos, ile kosztuje rozwiązanie wprost, i zapytaj o rozmiar danych. Dla 100 elementów rozwiązanie O(n²) jest w pełni w porządku, dla miliona już nie. Kto zaczyna od tego pytania, pokazuje, że wybiera rozwiązanie do problemu, a nie na odwrót. Dopiero potem dobiera strukturę danych.
Drugie: „Co się stanie dla pustej listy, jednego elementu i duplikatów?” Większość rozwiązań działa na przykładzie z treści zadania i psuje się na przypadku brzegowym. W tym artykule widać to w kilku miejscach: best = 0 w największej sumie podtablicy daje zły wynik dla samych liczb ujemnych, "((" ujawnia brak sprawdzenia pustego stosu, a [3, 3] w Two Sum pokazuje, czy użyłeś tego samego elementu dwa razy. Trzy testy (pusta lista, jeden element, duplikaty) zajmują minutę i łapią większość błędów.
Trzecie: „Mów na głos, nawet gdy utkniesz.” Rekruter ocenia sposób myślenia, nie tylko gotowy kod. Zdanie „widzę, że to O(n²), bo szukam w liście w pętli, spróbuję to zamienić na zbiór” jest warte więcej niż pięć minut ciszy zakończonych poprawnym rozwiązaniem. Jeśli utkniesz, nazwij, na czym stoisz: łatwiej wtedy dostać podpowiedź, a podpowiedź przyjęta i dobrze wykorzystana jest dla rekrutera dobrym sygnałem.
Jak brzmi dobra odpowiedź, a jak słaba?
Weźmy pytanie „Jak sprawdzić, czy lista ma duplikaty?”:
| Odpowiedź | Dlaczego | |
|---|---|---|
| Słaba | „Dwie pętle i porównuję każdy z każdym.” | To działa, ale kończy się na pierwszej wersji, bez złożoności i bez próby ulepszenia. |
| Dobra | „Najprostsze rozwiązanie to dwie pętle, ale to O(n²). Szybciej: zamieniam listę na zbiór i porównuję długości, co jest O(n) czasu kosztem O(n) pamięci. Jeśli lista jest posortowana, wystarczy porównać sąsiadów, co zużywa O(1) pamięci.” | Jest rozwiązanie, jego koszt, ulepszenie i kompromis. |
Ten sam schemat działa przy każdym zadaniu: rozwiązanie wprost, złożoność, ulepszenie, kompromis.
Co zrobić, gdy nie wiesz, jak rozwiązać zadanie?
Nie milcz i nie zgaduj. Zacznij od najprostszego rozwiązania, nawet nieefektywnego, i powiedz, że wiesz, że jest wolne. Potem zadaj sobie na głos pytanie: „co się powtarza, czego mógłbym nie liczyć drugi raz?”. Często od razu prowadzi to do zbioru, słownika albo sortowania. Jeśli to nadal nie pomaga, rozpisz mały przykład na kartce i prześledź go krok po kroku. Rekruterzy chętnie podpowiadają osobom, które myślą na głos, a umiejętność dojścia do rozwiązania jest w pracy ważniejsza niż pamiętanie gotowych odpowiedzi.
Warto też wiedzieć, jak wygląda sama rozmowa techniczna i co na niej sprawdzają. W podcaście Radek Wojtysiak opowiada o tym, jak przygotować się do rekrutacji technicznej.
Algorytmy to tylko jeden etap rekrutacji. Cały proces, od CV po rozmowę, opisuje artykuł o tym, jak z sukcesem przejść cały proces rekrutacyjny na juniora, a przygotowanie krok po kroku znajdziesz w tekście o tym, jak przygotować się do rekrutacji na juniora. Jeśli szukasz ofert, sięgnij po darmowy poradnik o tym, jak szukać pracy w 2026 roku, w którym zebraliśmy 3 sposoby poszukiwania pracy, które u naszych podopiecznych dają najlepsze efekty.
Jeśli braki wyjdą przy złożoności, strukturach danych albo rekurencji, to sygnał, że warto uporządkować wiedzę z mentorem. W kursie Python online pracujesz z mentorem nad kodem i zadaniami, a mentor zwraca uwagę nie tylko na wynik, ale też na to, jak wyjaśniasz swoje rozwiązanie.


