Rozpoznawanie DP: nakładające się podproblemy
Rozpoznają Państwo sytuacje, w których rekurencja brute-force ponownie rozwiązuje ten sam podproblem, narysują drzewo rekurencji dla Fibonacciego i zobaczą wykładniczy wzrost liczby obliczeń.
Rozpoznawanie DP: nakładające się podproblemy to bezpłatna lekcja DSA Interview Prep na CoddyKit. To lekcja 1 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.
Czym jest programowanie dynamiczne?
Programowanie dynamiczne (DP) rozwiązuje złożone problemy, dzieląc je na prostsze, nakładające się podproblemy, rozwiązując każdy podproblem tylko raz i przechowując wynik, aby uniknąć zbędnych obliczeń. DP ma zastosowanie, gdy problem spełnia dwa warunki: występują w nim nakładające się podproblemy (ten sam podproblem jest rozwiązywany wielokrotnie w naiwnej rekurencji) oraz optymalna podstruktura (optymalne rozwiązanie można zbudować z optymalnych rozwiązań podproblemów). Bez obu tych warunków DP nie pomaga.
# Two ingredients of DP:
# 1. Overlapping sub-problems:
# fib(5) -> fib(4) + fib(3)
# fib(4) -> fib(3) + fib(2) <- fib(3) computed twice!
# Without caching: O(2^n) calls for Fibonacci
# 2. Optimal substructure:
# Shortest path from A to C through B:
# shortest(A,C) = shortest(A,B) + shortest(B,C)
# The sub-path A->B must itself be the shortest
# Contrast with greedy: greedy makes one locally optimal
# choice; DP tries all choices and picks the best.
print('DP = overlapping sub-problems + optimal substructure')Fibonacci: klasyczny punkt wyjścia do DP
Ciąg Fibonacciego (fib(n) = fib(n-1) + fib(n-2)) jest klasycznym przykładem nakładających się podproblemów. Naiwna rekurencja ma wykładniczą złożoność czasową O(2^n), ponieważ wielokrotnie oblicza te same wartości. Drzewo rekurencji dla fib(6) pokazuje, że fib(3) jest obliczane 3 razy, fib(2) 5 razy i tak dalej. Ten wykładniczy wzrost liczby obliczeń jest dokładnie tym, co eliminuje DP dzięki przechowywaniu obliczonych wyników.
import time
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n-1) + fib_naive(n-2)
# Count the calls:
call_count = [0]
def fib_count(n):
call_count[0] += 1
if n <= 1: return n
return fib_count(n-1) + fib_count(n-2)
fib_count(10)
print(f'Calls for fib(10): {call_count[0]}') # 177 calls for n=10!
call_count[0] = 0
fib_count(20)
print(f'Calls for fib(20): {call_count[0]}') # 21891 calls
# n=30 -> ~2.7 million calls: exponential growthWizualizacja drzewa rekurencji
Narysowanie drzewa rekurencji dla fib(5) ujawnia nieefektywność: każdy węzeł tworzy dwoje dzieci, a identyczne poddrzewa pojawiają się wielokrotnie. Łączna liczba węzłów w drzewie wynosi O(2^n). Gdy widzą Państwo taki wzorzec — identyczne wywołania funkcji z tymi samymi argumentami, powtarzające się w drzewie — oznacza to, że DP może pomóc dzięki zapisywaniu wyników w pamięci podręcznej. Umiejętność wizualizacji jest kluczowa: jeśli potrafią Państwo rozpoznać powtarzające się poddrzewa, wiedzą Państwo, że DP ma zastosowanie.
# fib(5) recursion tree (simplified):
# fib(5)
# / \
# fib(4) fib(3)
# / \ / \
# fib(3) fib(2) fib(2) fib(1)
# / \ \
# fib(2) fib(1) fib(1)
# / \
# fib(1) fib(0)
# fib(3) appears TWICE
# fib(2) appears THREE TIMES
# Each redundant call wastes exponential time
# Key insight: fib(n) only has O(n) DISTINCT sub-problems
# (fib(0), fib(1), ..., fib(n))
# DP computes each ONCE -> O(n) total
print('Distinct sub-problems: O(n) but naive calls: O(2^n)')Rozpoznawanie nakładających się podproblemów
Aby rozpoznać nakładające się podproblemy, należy zapisać rekurencję siłową, a następnie zadać sobie pytanie: „czy istnieje wiele rekurencyjnych wywołań z TYMI SAMYMI argumentami?”. Jeśli tak, DP może pomóc. Typowe sygnały w opisach zadań to: „minimalna/maksymalna liczba X”, „na ile sposobów można osiągnąć Y”, „czy można osiągnąć Z?”. Takie sformułowania niemal zawsze wskazują na problem z optymalną podstrukturą, w którym odpowiedź dla pozycji i zależy od odpowiedzi dla wcześniejszych pozycji.
# DP signal phrases in problem statements:
# 'minimum number of coins to make amount X'
# 'maximum profit from stock trades'
# 'number of ways to climb n stairs'
# 'can you reach the last index?'
# 'longest common subsequence'
# 'edit distance between two strings'
# All have this shape:
# solve(input) = f(solve(smaller_input_1), solve(smaller_input_2), ...)
# And multiple branches end up calling solve with the same argument.
# If the recursion tree has repeated nodes: DP
# If subproblems are all independent: divide-and-conquer (no DP needed)
print('Repeated arguments in recursion tree -> DP')Wyjaśnienie optymalnej podstruktury
Optymalna podstruktura oznacza, że optymalne rozwiązanie problemu można zbudować z optymalnych rozwiązań jego podproblemów. Na przykład najkrótsza ścieżka z A do C przez B jest optymalna wtedy i tylko wtedy, gdy podścieżki A→B i B→C są każda z osobna optymalne. Jeśli ta właściwość zachodzi, można zbudować globalne optimum od dołu, korzystając z lokalnych optimów. Problemów pozbawionych optymalnej podstruktury (np. problemu najdłuższej ścieżki w ogólnym grafie zawierającym cykle) nie można rozwiązać za pomocą DP.
# Optimal substructure examples:
# SHORTEST PATH: shortest(A,C) = min over all B: shortest(A,B) + w(B,C)
# -> Sub-paths must be optimal: YES, has optimal substructure
# LONGEST PATH (no cycles, DAG): can also use DP
# -> Longer path through node B means sub-path A->B must be longest
# LONGEST PATH (with cycles): NO optimal substructure
# -> Best path from A to C might reuse nodes: sub-problems not independent
# COIN CHANGE: min coins for amount n = 1 + min(min coins for n-coin_i)
# -> YES: optimal for n-coin_i is needed for optimal n
print('Optimal substructure: build global optimum from local optima')Wspinanie się po schodach: pierwszy DP
Wspinanie się po schodach (LeetCode #70): na ile różnych sposobów można wejść na n schodów, pokonując za każdym razem 1 lub 2 stopnie? Niech dp[i] oznacza liczbę sposobów dotarcia do stopnia i. Na stopień i można dotrzeć ze stopnia i-1 (jeden krok) albo i-2 (dwa kroki), więc dp[i] = dp[i-1] + dp[i-2]. To ciąg Fibonacciego! Przypadki bazowe: dp[1] = 1, dp[2] = 2. Rozpoznanie, że „wspinanie się po schodach” sprowadza się do ciągu Fibonacciego, to klasyczna wskazówka przy rozmowach rekrutacyjnych.
def climb_stairs(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1] = 1 # 1 way to reach step 1
dp[2] = 2 # 2 ways to reach step 2: (1+1) or (2)
for i in range(3, n + 1):
dp[i] = dp[i-1] + dp[i-2] # come from i-1 or i-2
return dp[n]
for n in range(1, 8):
print(f'climb_stairs({n}) = {climb_stairs(n)}')
# 1, 2, 3, 5, 8, 13, 21 -- Fibonacci sequence!Schemat DP: definiowanie, rekurencja, kolejność
Niezawodny 3-etapowy schemat DP: 1. Zdefiniować stan — co reprezentuje dp[i] (lub dp[i][j])? Należy opisać to po angielsku. 2. Zapisać rekurencję — wyrazić dp[i] za pomocą mniejszych podproblemów. Należy uwzględnić wszystkie przypadki. 3. Ustalić kolejność wypełniania — upewnić się, że dp[i-1] (i inne zależności) zostaną obliczone przed dp[i]. Przypadki bazowe inicjalizują granicę. Ten schemat przekształca niejasną intuicję dotyczącą DP w konkretny plan implementacji.
# Framework applied to climbing stairs:
# Step 1 - Define state:
# dp[i] = number of distinct ways to reach step i
# Step 2 - Recurrence:
# dp[i] = dp[i-1] + dp[i-2] (come from step i-1 or i-2)
# Step 3 - Fill order:
# Compute dp[1], dp[2], dp[3], ..., dp[n] in order
# Because dp[i] depends on dp[i-1] and dp[i-2] (smaller)
# Base cases: dp[1]=1, dp[2]=2
# Framework applied to coin change:
# Step 1: dp[amount] = minimum coins to make that amount
# Step 2: dp[i] = 1 + min(dp[i-coin] for coin in coins if i >= coin)
# Step 3: Fill i from 1 to amount
# Base: dp[0] = 0 (zero coins for zero amount)
print('DP framework: define state -> recurrence -> fill order')Kiedy NIE używać DP
DP nie zawsze jest odpowiedzią. Algorytmu zachłannego należy użyć, gdy pojedynczy lokalnie optymalny wybór zawsze prowadzi do globalnie optymalnego rozwiązania (activity selection, jump game I). Metodę dziel i zwyciężaj należy stosować, gdy podproblemy nie nakładają się na siebie (merge sort, binary search). Z BFS należy skorzystać, gdy problem dotyczy najkrótszej ścieżki w grafie nieważonym. DP jest poprawne, ale często przesadne, gdy istnieje rozwiązanie zachłanne lub prostsze podejście. Podczas rozmów kwalifikacyjnych należy omówić, dlaczego wybrano DP zamiast alternatyw.
# DP vs alternatives:
# Problem: can you jump to the end of the array?
# Greedy: track max reachable index -> O(n) O(1) BETTER than DP
# Problem: shortest path unweighted graph?
# BFS: O(V+E) BETTER than DP on general graph
# Problem: sort an array?
# Comparison sort: O(n log n), no DP needed
# DP IS the right choice when:
# - Greedy fails (choices interact)
# - Need to count/enumerate all possibilities
# - Problem has 'how many ways' or 'minimum/maximum' flavor
# - Recursion tree clearly shows overlapping sub-problems
print('Ask: does greedy fail? If yes, consider DP.')Zliczanie różnych podproblemów
Liczba różnych podproblemów określa złożoność czasową i pamięciową DP. W przypadku jednowymiarowego DP dla danych wejściowych o rozmiarze n występuje O(n) podproblemów. W przypadku dwuwymiarowego DP dla dwóch danych wejściowych o rozmiarach m i n występuje O(mn) podproblemów. Każdy podproblem jest rozwiązywany w czasie O(k) (dla k możliwości na każdym kroku), co daje łączny czas O(n*k) lub O(mn*k). Zawsze należy najpierw zliczyć różne podproblemy — pozwala to określić złożoność czasową DP jeszcze przed napisaniem kodu.
# Sub-problem count examples:
# Problem | Sub-problems | Each costs | Total
# Fibonacci | O(n) | O(1) | O(n)
# Coin change | O(amount) | O(coins) | O(amount * coins)
# LCS (m,n chars) | O(m*n) | O(1) | O(m*n)
# Edit distance | O(m*n) | O(1) | O(m*n)
# 0/1 Knapsack | O(n*W) | O(1) | O(n*W)
# Matrix chain | O(n^2) | O(n) | O(n^3)
# Rule: DP time = (# distinct sub-problems) * (time per sub-problem)
print('Time = subproblems * work-per-subproblem')House Robber: nakładające się wybory
House Robber (LeetCode #198) polega na znalezieniu maksymalnej kwoty, którą można zrabować z domów ustawionych w rzędzie, bez rabowania sąsiednich domów. Przy każdym domu należy wybrać: zrabować go (dodać jego wartość i pominąć poprzedni dom) albo go pominąć (wybrać najlepszy wynik dla poprzedniego domu). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Ten wzorzec wyboru na każdym kroku jest najprostszą rekurencją dla jednowymiarowego DP i pojawia się w dziesiątkach zadań rekrutacyjnych.
def rob(nums):
if not nums: return 0
if len(nums) == 1: return nums[0]
dp = [0] * len(nums)
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, len(nums)):
dp[i] = max(dp[i-1], # skip house i
dp[i-2] + nums[i]) # rob house i
return dp[-1]
print(rob([1, 2, 3, 1])) # 4: rob house 0 and 2 (1+3)
print(rob([2, 7, 9, 3, 1]))# 12: rob house 0, 2, 4 (2+9+1)
print(rob([2, 1, 1, 2])) # 4: rob house 0 and 3Kontrola poprawności: brute force a DP
Zawsze należy weryfikować DP za pomocą rozwiązania brute force dla małych danych wejściowych. Rozwiązanie brute force stanowi punkt odniesienia. Gdy DP daje taki sam wynik jak rozwiązanie brute force dla wszystkich przypadków testowych, można mieć pewność, że rekurencja jest poprawna. Dopiero wtedy należy optymalizować pamięć. To podejście oparte na testach — brute force → DP od góry → DP od dołu → DP zoptymalizowane pamięciowo — jest profesjonalnym sposobem opracowywania i weryfikowania rozwiązań DP podczas rozmowy kwalifikacyjnej.
# Brute-force for house robber (exponential)
def rob_brute(nums, i=0):
if i >= len(nums):
return 0
# Option 1: rob house i
rob_it = nums[i] + rob_brute(nums, i + 2)
# Option 2: skip house i
skip_it = rob_brute(nums, i + 1)
return max(rob_it, skip_it)
# Verify on small inputs:
test_cases = [[1,2,3,1], [2,7,9,3,1], [2,1,1,2]]
for tc in test_cases:
bf = rob_brute(tc)
dp = rob(tc)
print(f'{tc}: brute={bf}, dp={dp}, match={bf==dp}')Szybki test
Proszę sprawdzić swoje zrozumienie koncepcji Data Structures & Algorithms — Coding Interview Prep z tej lekcji.
Podsumowanie lekcji
W tej lekcji poznali Państwo: dwa składniki DP (nakładające się podproblemy i optymalną podstrukturę), sposób wizualizowania drzewa rekurencji w celu identyfikowania powtarzających się wywołań, trzyetapowy schemat DP (definiowanie stanu, rekurencja, kolejność wypełniania), a także pierwsze przykłady obejmujące Fibonacci, Climbing Stairs i House Robber. Następnie zaimplementują Państwo DP od góry z memoizacją.
Często zadawane pytania
Czy lekcja „Rozpoznawanie DP: nakładające się podproblemy” jest bezpłatna?
Tak — pełny tekst „Rozpoznawanie DP: nakładające się podproblemy” 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 „Rozpoznawanie DP: nakładające się podproblemy”?
Rozpoznają Państwo sytuacje, w których rekurencja brute-force ponownie rozwiązuje ten sam podproblem, narysują drzewo rekurencji dla Fibonacciego i zobaczą wykładniczy wzrost liczby obliczeń. Ć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 1 z 4.
Ile czasu zajmuje lekcja „Rozpoznawanie DP: nakładające się podproblemy”?
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
- Rozpoznawanie DP: nakładające się podproblemy
- DP z góry na dół z memoizacją
- DP z dołu do góry z tabulacją
- Wydawanie reszty i schody o minimalnym koszcie