0Pricing
DSA Interview Prep · Lekcja

Kompromisy między rekurencją a iteracją

Przekształcą Państwo rekurencyjne obliczanie silni i ciągu Fibonacciego w pętle iteracyjne oraz wyjaśnią, kiedy limit rekurencji i rozmiar stosu w Pythonie przemawiają za iteracją.

Kompromisy między rekurencją a iteracją to bezpłatna lekcja DSA Interview Prep na CoddyKit. To lekcja 3 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej DSA Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Dwoistość rekurencji i iteracji

Każdy algorytm, który można zapisać rekurencyjnie, można również zapisać iteracyjnie, i odwrotnie. Wersja rekurencyjna często dokładniej odzwierciedla matematyczną definicję problemu, natomiast wersja iteracyjna zapewnia jawną kontrolę nad pamięcią i eliminuje ryzyko przepełnienia stosu. Wybór między nimi jest decyzją praktyczną, zależną od czytelności, limitów głębokości i wymagań dotyczących wydajności.

Podczas rozmów kwalifikacyjnych umiejętność przedstawienia obu wersji i wyjaśnienia kompromisów jest wyraźnym sygnałem biegłości.

Silnia: rekurencyjnie czy iteracyjnie

Silnia jest klasycznym przykładem. Wersja rekurencyjna bezpośrednio koduje definicję matematyczną n! = n × (n-1)!. Wykorzystuje pamięć stosu o złożoności O(n) ze względu na n oczekujących wartości zwracanych. Wersja iteracyjna wykonuje pętlę od 1 do n, wykorzystując pamięć O(1). Dla n = 1000 wersja rekurencyjna osiąga domyślny limit Pythona, natomiast wersja iteracyjna obsługuje dowolnie duże n.

def factorial_rec(n):
    if n == 0:
        return 1
    return n * factorial_rec(n - 1)   # O(n) stack

def factorial_iter(n):
    result = 1
    for i in range(2, n + 1):
        result *= i                    # O(1) stack
    return result

print(factorial_rec(10))   # 3628800
print(factorial_iter(10))  # 3628800

# Large n: iterative works, recursive may overflow
print(factorial_iter(1000) > 0)  # True (Python handles big ints)

Fibonacci: wykładniczo czy liniowo

Naiwna rekurencyjna implementacja Fibonacciego ma złożoność czasową O(2^n) — dla dużych wartości n jest dramatycznie powolna. Wersja iteracyjna ma złożoność czasową O(n) i pamięciową O(1). Rekurencja z memoizacją (w następnej lekcji) również ma złożoność czasową O(n), ale pamięciową O(n) ze względu na słownik memoizacji i stos o głębokości O(n). W przypadku Fibonacciego podejście iteracyjne jest optymalne pod każdym względem. Dla n = 50 naiwna rekurencja wykonuje się przez kilka sekund, a wersja iteracyjna przez kilka mikrosekund.

import time

def fib_rec(n):
    if n <= 1: return n
    return fib_rec(n-1) + fib_rec(n-2)   # O(2^n)

def fib_iter(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a                              # O(n) time, O(1) space

# Timing comparison for n=35
start = time.time()
fib_rec(35)
print(f'Recursive n=35: {time.time()-start:.3f}s')

start = time.time()
fib_iter(35)
print(f'Iterative n=35: {time.time()-start:.6f}s')

print(fib_iter(100))  # handles large n

Przechodzenie drzewa: rekurencyjnie a iteracyjnie

Rekurencyjne przechodzenie drzewa jest naturalnie przejrzyste, ponieważ struktura drzewa odzwierciedla rekurencję. Jednak w przypadku głęboko niezrównoważonego drzewa (w praktyce listy jednokierunkowej) głębokość rekurencji jest równa wysokości drzewa = O(n), co grozi przepełnieniem stosu. Wersja iteracyjna z użyciem jawnego stosu nie ma limitu głębokości i pozwala zwiększać rozmiar stosu na stercie zamiast na stosie wywołań.

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val   = val; self.left = left; self.right = right

def preorder_rec(root, result=None):
    if result is None: result = []
    if root:
        result.append(root.val)
        preorder_rec(root.left, result)
        preorder_rec(root.right, result)
    return result

def preorder_iter(root):
    if not root: return []
    result, stack = [], [root]
    while stack:
        node = stack.pop()
        result.append(node.val)
        if node.right: stack.append(node.right)
        if node.left:  stack.append(node.left)
    return result

root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3))
print(preorder_rec(root))   # [1, 2, 4, 5, 3]
print(preorder_iter(root))  # [1, 2, 4, 5, 3]

Sortowanie przez scalanie: rekurencyjne a iteracyjne (oddolne)

Sortowanie przez scalanie jest naturalnie rekurencyjne (podziel, wywołaj rekurencyjnie, scal). Iteracyjne sortowanie przez scalanie oddolne całkowicie eliminuje rekurencję: zaczyna od podtablic o rozmiarze 1, scala sąsiednie pary w podtablice o rozmiarze 2, następnie o rozmiarze 4 itd., podwajając rozmiar podtablicy w każdym przebiegu. Sortowanie przez scalanie oddolne ma złożoność czasową O(n log n), zajmuje O(n) pamięci (na bufor scalania) i O(1) miejsca na stosie.

def merge_sort_iterative(arr):
    n = len(arr)
    size = 1
    while size < n:
        for start in range(0, n, 2 * size):
            mid   = min(start + size, n)
            end   = min(start + 2 * size, n)
            left  = arr[start:mid]
            right = arr[mid:end]
            # Merge
            i = j = 0
            for k in range(start, end):
                if i < len(left) and (j >= len(right) or left[i] <= right[j]):
                    arr[k] = left[i]; i += 1
                else:
                    arr[k] = right[j]; j += 1
        size *= 2
    return arr

print(merge_sort_iterative([5, 2, 4, 6, 1, 3]))  # [1,2,3,4,5,6]

Kiedy rekurencja jest zdecydowanie lepsza

Rekurencja sprawdza się doskonale, gdy problem ma strukturę drzewiastą, która bezpośrednio odwzorowuje graf wywołań, gdy przypadki bazowe są naturalne oraz gdy głębokość jest ograniczona (O(log n) dla zrównoważonych drzew i algorytmów dziel i zwyciężaj). Przykłady to parsowanie JSON, przechodzenie po katalogach, drzewa gier i problemy z nawrotami. W takich przypadkach kod rekurencyjny jest krótszy, przejrzystszy i łatwiejszy do formalnego uzasadnienia niż jego odpowiednik iteracyjny.

# Recursion is clearest for JSON-like nested structures
def flatten(nested):
    result = []
    for item in nested:
        if isinstance(item, list):
            result.extend(flatten(item))  # recurse on sub-list
        else:
            result.append(item)
    return result

print(flatten([1, [2, [3, 4], 5], 6]))  # [1, 2, 3, 4, 5, 6]
print(flatten([]))                        # []
print(flatten([[1, [2]], [3, [4, [5]]]])) # [1, 2, 3, 4, 5]

Kiedy iteracja jest zdecydowanie lepsza

Iteracja jest właściwym wyborem, gdy: głębokość wynosi O(n), a n jest duże (więcej niż około 500 w bezpiecznym kodzie w Pythonie), wersje rekurencyjna i iteracyjna są równie czytelne (ciąg Fibonacciego, silnia) lub problem ma zasadniczo sekwencyjny charakter i nie da się go naturalnie rozłożyć na podproblemy. Proste pętle przetwarzające tablice od lewej do prawej — sumy narastające, okna przesuwne, metoda dwóch wskaźników — zawsze powinny być implementowane iteracyjnie.

# Iterative is clearest for sequential array processing
def running_max(nums):
    result = []
    curr_max = float('-inf')
    for n in nums:
        curr_max = max(curr_max, n)
        result.append(curr_max)
    return result

print(running_max([3, 1, 4, 1, 5, 9, 2, 6]))  # [3,3,4,4,5,9,9,9]

# No natural recursion here — iteration is the only sensible choice

Konwersja rekurencji DFS na iterację

Systematyczne podejście polega na tym, że każde rekurencyjne DFS można zamienić na wersję iteracyjną, umieszczając argumenty rekurencyjnego wywołania na jawnym stosie. Kluczowa obserwacja jest taka, że wywołanie rekurencyjne f(args) jest równoważne odłożeniu args na stos i wykonaniu pętli. W przypadku przetwarzania w porządku postorder (gdy przed przetworzeniem rodzica potrzebne są wyniki dla dzieci) może być potrzebne podejście dwuprzebiegowe lub flaga odwiedzenia.

# Post-order iterative using two stacks
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val=val; self.left=left; self.right=right

def postorder_iter(root):
    if not root: return []
    s1, s2 = [root], []
    while s1:
        node = s1.pop()
        s2.append(node.val)
        if node.left:  s1.append(node.left)
        if node.right: s1.append(node.right)
    return s2[::-1]  # reverse gives post-order

root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3))
print(postorder_iter(root))  # [4, 5, 2, 3, 1]

Koszt wydajnościowy rekurencji

Każde rekurencyjne wywołanie w Pythonie wiąże się z istotnym kosztem: tworzona jest nowa ramka (co powoduje przydzielenie pamięci na stercie), inicjalizowane są zmienne lokalne, a wskaźnik adresu powrotu zostaje zapisany. Testy porównawcze pokazują, że koszt wywołania funkcji w Pythonie wynosi około 100–200 nanosekund na wywołanie. Przy głębokości rekurencji 10^6 daje to łącznie 0,1–0,2 sekundy czystego narzutu, niezależnie od pracy wykonywanej przez algorytm. Pętle iteracyjne całkowicie unikają tego narzutu.

import time

def rec_sum(n):
    if n == 0: return 0
    return n + rec_sum(n - 1)

def iter_sum(n):
    total = 0
    for i in range(n + 1):
        total += i
    return total

import sys; sys.setrecursionlimit(10000)

n = 5000
start = time.time()
for _ in range(100): rec_sum(n)
print(f'Recursive sum({n}) x100: {(time.time()-start)*1000:.2f}ms')

start = time.time()
for _ in range(100): iter_sum(n)
print(f'Iterative sum({n}) x100: {(time.time()-start)*1000:.2f}ms')

Podejmowanie decyzji podczas rozmowy kwalifikacyjnej

Podczas rozmowy kwalifikacyjnej dotyczącej programowania, jeśli ma Pan/Pani wybór, proszę zadać sobie pytania: „Czy głębokość rekurencji jest ograniczona przez O(log n)?”. Jeśli tak, rekurencja jest odpowiednia. „Czy głębokość rekurencji wynosi O(n)?” — należy preferować iterację lub wspomnieć, że w kodzie produkcyjnym rozwiązanie zostałoby przekształcone w iteracyjne. „Czy problem ma naturalnie strukturę drzewa albo postać dziel i zwyciężaj?” — należy skłaniać się ku rekurencji. „Czy problem polega na sekwencyjnym przeglądaniu?” — należy użyć iteracji.

Zawsze należy uzasadnić swój wybór: „Użyję tutaj rekurencji, ponieważ dla zrównoważonego BST głębokość wynosi O(log n), więc zajęcie O(log n) miejsca na stosie jest akceptowalne”.

Podsumowanie: tabela kompromisów

Podsumowując kompromisy: kod rekurencyjny jest często krótszy i odzwierciedla strukturę problemu, ale wymaga O(głębokość) miejsca na stosie i wiąże się z kosztem wywołań funkcji. Kod iteracyjny jest dłuższy, ale zajmuje O(1) miejsca na stosie i nie podlega ograniczeniom rekurencji. Rekurencja z zapamiętywaniem wyników (w następnej lekcji) stanowi rozwiązanie pośrednie: zachowuje przejrzystość rekurencji, eliminując jednocześnie zbędne obliczenia. Analizując rozwiązanie, zawsze należy jawnie uwzględniać złożoność pamięciową, w tym miejsce zajmowane przez stos wywołań.

rows = [
    ('Factorial',   'O(n) / O(1)', 'O(n) / O(1)', 'Same time; iter wins on space'),
    ('Fibonacci',   'O(2^n) / O(n)', 'O(n) / O(1)', 'Iter massively wins'),
    ('Binary search','O(log n) / O(log n)', 'O(log n) / O(1)', 'Iter wins on space'),
    ('Tree DFS',    'O(n) / O(h)',  'O(n) / O(h)', 'Equal; rec cleaner'),
    ('Merge sort',  'O(n log n) / O(log n)', 'O(n log n) / O(1)', 'BU-iter wins on stack'),
]
for name, rec, it, note in rows:
    print(f'{name:<15} rec={rec:<22} iter={it:<22} {note}')

Szybki sprawdzian

Sprawdź swoje zrozumienie zagadnień z kursu Data Structures & Algorithms — Coding Interview Prep omówionych w tej lekcji.

Podsumowanie lekcji

W tej lekcji nauczył(a) się Pan/Pani, że: rekurencja jest preferowana, gdy głębokość wynosi O(log n) lub problem ma naturalnie strukturę drzewa, a iteracja — gdy głębokość wynosi O(n) lub problem ma charakter sekwencyjny, naiwna rekurencja dla ciągu Fibonacciego ma złożoność O(2^n) — wersja iteracyjna ma złożoność czasową O(n) i pamięciową O(1), a także że każde rekurencyjne DFS można przekształcić w wersję iteracyjną, zarządzając jawnym stosem na stercie. W następnej kolejności zastosujemy zapamiętywanie wyników, aby wyeliminować zbędne wywołania rekurencyjne.

Często zadawane pytania

Czy lekcja „Kompromisy między rekurencją a iteracją” jest bezpłatna?

Tak — pełny tekst „Kompromisy między rekurencją a iteracją” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu DSA Interview Prep, przejdź na CoddyKit PRO. Kurs DSA Interview Prep zawiera 4 lekcji w sumie.

Co nauczysz się w „Kompromisy między rekurencją a iteracją”?

Przekształcą Państwo rekurencyjne obliczanie silni i ciągu Fibonacciego w pętle iteracyjne oraz wyjaśnią, kiedy limit rekurencji i rozmiar stosu w Pythonie przemawiają za iteracją. Ćwiczysz DSA Interview Prep z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć DSA Interview Prep?

Nie wymagamy żadnego doświadczenia. DSA Interview Prep w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 3 z 4.

Ile czasu zajmuje lekcja „Kompromisy między rekurencją a iteracją”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji DSA Interview Prep?

Tak. Każda lekcja DSA Interview Prep zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Schemat rekurencji: przypadek bazowy, zaufanie, budowa
  2. Wizualizacja stosu wywołań
  3. Kompromisy między rekurencją a iteracją
  4. Memoizacja: buforowanie wyników rekurencji
← Powrót do DSA Interview Prep