0Pricing
Coding Interview Prep · Lekcja

DP z góry na dół z memoizacją

Dodadzą Państwo słownik memo do rozwiązania rekurencyjnego, aby eliminować powtórne wywołania, oraz użyją @lru_cache do memoizacji przy minimalnej ilości kodu.

DP z góry na dół z memoizacją to bezpłatna lekcja Coding Interview Prep na CoddyKit. To lekcja 2 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 Coding Interview Prep, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.

DP od góry: idea memoizacji

DP od góry zaczyna od oryginalnego rozwiązania rekurencyjnego i dodaje memoizację: pamięć podręczną przechowującą wynik każdego podproblemu przy jego pierwszym obliczeniu. Przy kolejnych wywołaniach z tymi samymi argumentami wynik z pamięci podręcznej jest zwracany natychmiast, bez ponownego wykonywania rekurencji. Przekształca to naiwną rekurencję o złożoności O(2^n) w rozwiązanie o złożoności O(n), przy minimalnych zmianach w kodzie — często wystarczy dodać 2–3 wiersze do istniejącego rozwiązania rekurencyjnego.

# Top-down approach:
# 1. Write the recursive solution (natural but slow)
# 2. Add a memo dict to cache results
# 3. Before recursing, check if the result is cached
# 4. Before returning, store the result in the cache

# This is also called 'memoization' (US spelling)
# 'memoize' means 'to remember', not 'memorize'

# The cache key is the function arguments
# For fib: key is n
# For 2D DP: key is (i, j)
# For 3D DP: key is (i, j, k)
print('Top-down = recursion + memo cache')

Fibonacci z memoizacją

Dodanie słownika memo do naiwnej rekurencji Fibonacci zmniejsza złożoność czasową z O(2^n) do O(n). Pierwsze wywołanie fib(k) oblicza i zapisuje wynik. Wszystkie kolejne wywołania dla tego samego k natychmiast zwracają wartość z pamięci podręcznej. Złożoność pamięciowa wynosi O(n) dla słownika memo oraz O(n) dla stosu wywołań. Warto porównać liczbę wywołań: bez memo fib(30) wykonuje około 2 milionów wywołań, a z memo dokładnie 30 wywołań.

def fib_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]  # return cached result
    if n <= 1:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

# Verify speed improvement:
print(fib_memo(30))   # fast!
print(fib_memo(50))   # still fast
print(fib_memo(100))  # no problem

# Without memo, fib_naive(50) would take minutes
# With memo: each of the 50 sub-problems computed once

Używanie @functools.lru_cache

Dekorator @functools.lru_cache(maxsize=None) w Pythonie (lub alias @cache dostępny w Pythonie 3.9+) automatycznie wykonuje memoizację funkcji na podstawie jej argumentów. Jest to najprostszy sposób dodania DP od góry podczas rozmowy kwalifikacyjnej — wystarczy napisać rozwiązanie rekurencyjne i dodać dekorator. Dekorator przechowuje wszystkie wyniki w słowniku, którego kluczami są argumenty funkcji. Argumenty te muszą być haszowalne (nie mogą to być listy — należy użyć krotek).

import functools

@functools.lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

print(fib(50))   # 12586269025
print(fib(100))  # works instantly

# Clear cache between tests if needed:
fib.cache_clear()

# Python 3.9+ shorthand:
# from functools import cache
# @cache
# def fib(n): ...

print(fib.cache_info())  # shows hits, misses, maxsize, currsize

Coin Change od góry

Coin Change (LeetCode #322): mając dane nominały monet i kwotę docelową, należy znaleźć minimalną liczbę monet potrzebnych do jej uzyskania. Sformułowanie rekurencyjne: dla każdej monety należy ją wybrać i rozwiązać problem dla pozostałej kwoty, a następnie wybrać minimum. Memoizację należy przeprowadzić względem kwoty, aby uniknąć ponownych obliczeń. Przypadek bazowy: amount=0 wymaga 0 monet; dla niemożliwej do uzyskania kwoty zwracana jest nieskończoność (lub -1 po zakończeniu rekurencji).

import functools

def coin_change_top_down(coins, amount):
    @functools.lru_cache(maxsize=None)
    def dp(remaining):
        if remaining == 0:
            return 0  # no coins needed
        if remaining < 0:
            return float('inf')  # impossible
        # Try each coin and take the minimum
        return 1 + min(dp(remaining - c) for c in coins)

    result = dp(amount)
    return result if result != float('inf') else -1

print(coin_change_top_down([1, 5, 6, 9], 11))  # 2: (5+6) or (2*5+1?no: 9+2?no) 5+6=11 YES
print(coin_change_top_down([2], 3))             # -1: impossible
print(coin_change_top_down([1, 2, 5], 11))      # 3: 5+5+1

Climbing Stairs od góry z K krokami

Uogólnijmy problem Climbing Stairs tak, aby można było pokonywać od 1 do k kroków. Stanem jest aktualny stopień, a ze stopnia i można przejść na stopnie i+1, i+2, ..., i+k. Rekurencja: dp(i) = sum of dp(i-j) for j in 1..k if i-j >= 0. Memoizacja zmniejsza złożoność do O(n*k) zamiast O(k^n). To uogólnienie pojawia się w zadaniach takich jak „minimalny koszt dotarcia do ostatniego stopnia” i „zliczanie sposobów wypełnienia siatki”.

import functools

def climb_k_steps(n, k):
    @functools.lru_cache(maxsize=None)
    def dp(i):
        if i == 0:
            return 1  # base: one way to stay at ground
        if i < 0:
            return 0  # impossible
        # From stair i, you could have come from i-1, i-2, ..., i-k
        return sum(dp(i - j) for j in range(1, k+1) if i - j >= 0)

    return dp(n)

# k=2 (original): should match fib-like sequence
print([climb_k_steps(n, 2) for n in range(7)])  # [1,1,2,3,5,8,13]
# k=3: more options
print([climb_k_steps(n, 3) for n in range(7)])  # [1,1,2,4,7,13,24]

LCS od góry: memoizacja 2D

Najdłuższy wspólny podciąg (LCS) wymaga stanu 2D: dp(i, j) = długość LCS dla s1[:i] i s2[:j]. Jeśli s1[i-1] == s2[j-1], znaki są takie same: dp(i,j) = 1 + dp(i-1, j-1). W przeciwnym razie: dp(i,j) = max(dp(i-1,j), dp(i,j-1)) — należy pominąć jeden znak z dowolnego łańcucha. Memoizacja względem (i, j) daje złożoność O(mn) zamiast O(2^(m+n)).

import functools

def lcs_top_down(s1, s2):
    m, n = len(s1), len(s2)

    @functools.lru_cache(maxsize=None)
    def dp(i, j):
        if i == 0 or j == 0:
            return 0  # empty prefix has LCS of 0
        if s1[i-1] == s2[j-1]:
            return 1 + dp(i-1, j-1)  # characters match
        return max(dp(i-1, j), dp(i, j-1))  # skip one

    return dp(m, n)

print(lcs_top_down('abcde', 'ace'))   # 3: 'ace'
print(lcs_top_down('abc', 'abc'))     # 3: 'abc'
print(lcs_top_down('abc', 'def'))     # 0: no common chars

Słownik memo a lru_cache: kiedy wybrać

Należy użyć @lru_cache, gdy argumentami funkcji są haszowalne typy proste (int, str, tuple). Ręczny słownik memo należy wybrać, gdy: trzeba przekazywać zmienny stan (listy, słowniki), konwertując go na krotki; trzeba śledzić, które klucze zostały obliczone; lub gdy funkcja jest metodą klasy, w której self nie powinno być przechowywane w pamięci podręcznej. Ręczny słownik memo jest bardziej jawny i pozwala uniknąć subtelnych problemów z domknięciami w rekurencyjnych funkcjach pomocniczych.

# @lru_cache: clean, automatic, O(1) overhead
# Use when: arguments are simple (int, str, tuple)
import functools
@functools.lru_cache(maxsize=None)
def simple_dp(n):
    if n <= 1: return n
    return simple_dp(n-1) + simple_dp(n-2)

# Manual memo dict: explicit, flexible
# Use when: complex state, need to inspect memo, class methods
def manual_memo_dp(s1, s2):
    memo = {}
    def dp(i, j):
        if (i,j) in memo: return memo[(i,j)]
        if i == 0 or j == 0:
            return 0
        if s1[i-1] == s2[j-1]:
            memo[(i,j)] = 1 + dp(i-1, j-1)
        else:
            memo[(i,j)] = max(dp(i-1,j), dp(i,j-1))
        return memo[(i,j)]
    return dp(len(s1), len(s2))

print(manual_memo_dp('abcde', 'ace'))  # 3

Target Sum od góry

Target Sum (LeetCode #494): należy przypisać znak + lub - do każdej liczby i zliczyć przypisania, których suma jest równa wartości docelowej. Stan: dp(index, current_sum). Dla każdego indeksu należy spróbować dodać (+) i odjąć (-) bieżącą liczbę. Memoizacja względem (index, current_sum) przekształca metodę brute force o złożoności O(2^n) w rozwiązanie o złożoności O(n * sum_range). Zakres sum jest ograniczony przez sumę wszystkich liczb, co daje łącznie O(n * S) stanów.

import functools

def find_target_sum_ways(nums, target):
    @functools.lru_cache(maxsize=None)
    def dp(index, current_sum):
        if index == len(nums):
            return 1 if current_sum == target else 0
        # Try adding the number
        add = dp(index + 1, current_sum + nums[index])
        # Try subtracting the number
        subtract = dp(index + 1, current_sum - nums[index])
        return add + subtract

    return dp(0, 0)

print(find_target_sum_ways([1,1,1,1,1], 3))  # 5
print(find_target_sum_ways([1], 1))            # 1
print(find_target_sum_ways([1], -1))           # 1

DP od góry a DP od dołu: zalety i wady

DP od góry (memoizacja) — zalety: naturalny sposób zapisu (zaczyna się od rozwiązania rekurencyjnego), obliczane są tylko rzeczywiście potrzebne podproblemy (podejście leniwe), a pamięć podręczną można łatwo dodawać stopniowo. DP od dołu (tabulacja) — zalety: brak narzutu stosu wywołań (brak limitu rekurencji Pythona), lepsza lokalność odwołań do pamięci podręcznej oraz łatwiejsza optymalizacja pamięci. Oba podejścia mają taką samą złożoność asymptotyczną. Podczas rozmowy kwalifikacyjnej należy zacząć od rozwiązania od góry, aby zweryfikować poprawność, a następnie przekształcić je w rozwiązanie od dołu, jeśli wymagana jest mniejsza ilość pamięci.

# Top-down advantages:
# + Natural: write recursive, add @cache
# + Lazy: only computes needed sub-problems
# + Easy to reason about correctness
# - Uses call stack (recursion limit in Python)
# - Higher constant factor (function call overhead)

# Bottom-up advantages:
# + No recursion limit
# + Better cache performance (sequential memory)
# + Easier to space-optimise (rolling array)
# - Must compute all sub-problems in order
# - Less intuitive for complex 2D/3D problems

# Interview strategy:
# Start with top-down to verify recurrence,
# convert to bottom-up only if asked.
print('Top-down: easy to write | Bottom-up: efficient for large n')

Word Break z DP od góry

Word Break (LeetCode #139) polega na sprawdzeniu, czy łańcuch s można podzielić na słowa ze słownika. Stan: dp(i) = informacja, czy s[i:] można podzielić. Dla indeksu i należy wypróbować wszystkie słowa: jeśli s[i:i+len(w)] == w, należy rekurencyjnie rozwiązać problem dla pozostałego sufiksu. Memoizacja względem indeksu początkowego zmniejsza złożoność metody brute force z O(2^n) do O(n^2) (lub O(n * max_word_len), wraz ze sprawdzaniem przynależności do zbioru).

import functools

def word_break(s, word_dict):
    word_set = set(word_dict)

    @functools.lru_cache(maxsize=None)
    def dp(start):
        if start == len(s):
            return True  # successfully segmented entire string
        for end in range(start + 1, len(s) + 1):
            if s[start:end] in word_set and dp(end):
                return True
        return False

    return dp(0)

print(word_break('leetcode', ['leet', 'code']))       # True
print(word_break('applepenapple', ['apple', 'pen'])) # True
print(word_break('catsandog', ['cats', 'dog', 'and', 'cat', 'san', 'andog'])) # False

Limit rekurencji i Itertools

Domyślny limit rekurencji w Pythonie wynosi 1000 (ustawia go sys.getrecursionlimit()). W przypadku problemów DP dla dużych danych wejściowych (n = 10,000+) memoizacja od góry osiągnie ten limit. Możliwe rozwiązania to zwiększenie limitu za pomocą sys.setrecursionlimit(100000) albo przekształcenie rozwiązania w DP od dołu. W programowaniu konkursowym zwiększanie limitu jest powszechne; w kodzie produkcyjnym ze względu na niezawodność należy zawsze preferować rozwiązania od dołu lub iteracyjne.

import sys

print('Default recursion limit:', sys.getrecursionlimit())  # 1000

# For large DP problems, increase if needed:
# sys.setrecursionlimit(100000)

# Better: convert to bottom-up DP for large n
def fib_bottom_up(n):
    if n <= 1: return n
    a, b = 0, 1
    for _ in range(2, n+1):
        a, b = b, a + b
    return b

# No recursion limit issue:
print(fib_bottom_up(10000))  # works fine, no recursion

Szybki test

Proszę sprawdzić swoje zrozumienie koncepcji Data Structures & Algorithms — Coding Interview Prep z tej lekcji.

Podsumowanie lekcji

W tej lekcji poznali Państwo: DP od góry ze słownikiem memo i dekorator @lru_cache, rozwiązania z memoizacją dla Fibonacci, Coin Change, LCS, Target Sum i Word Break, a także sytuacje, w których należy wybrać DP od góry zamiast DP od dołu lub odwrotnie. Następnie zaimplementują Państwo DP od dołu z tabulacją i optymalizacją pamięci.

Często zadawane pytania

Czy lekcja „DP z góry na dół z memoizacją” jest bezpłatna?

Tak — pełny tekst „DP z góry na dół z memoizacją” 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 Coding Interview Prep, przejdź na CoddyKit PRO. Kurs Coding Interview Prep zawiera 4 lekcji w sumie.

Co nauczysz się w „DP z góry na dół z memoizacją”?

Dodadzą Państwo słownik memo do rozwiązania rekurencyjnego, aby eliminować powtórne wywołania, oraz użyją @lru_cache do memoizacji przy minimalnej ilości kodu. Ćwiczysz Coding 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ąć Coding Interview Prep?

Nie wymagamy żadnego doświadczenia. Coding 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 2 z 4.

Ile czasu zajmuje lekcja „DP z góry na dół z memoizacją”?

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 Coding Interview Prep?

Tak. Każda lekcja Coding 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. Rozpoznawanie DP: nakładające się podproblemy
  2. DP z góry na dół z memoizacją
  3. DP z dołu do góry z tabulacją
  4. Wydawanie reszty i schody o minimalnym koszcie
← Powrót do Coding Interview Prep