Voorbereiding op programmeerinterviews · Les

DP herkennen: overlappende deelproblemen

Herken wanneer brute-force-recursie hetzelfde deelprobleem opnieuw oplost, teken de recursieboom voor Fibonacci en zie de exponentiële groei.

Les 1 van 413 stappen

DP herkennen: overlappende deelproblemen is een gratis Voorbereiding op programmeerinterviews-les op CoddyKit. Dit is les 1 van 4. Je kunt de volledige les hieronder gratis lezen en daarna in de browser praktisch oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is. Deze les maakt deel uit van het leertraject Voorbereiding op programmeerinterviews. Je voortgang wordt gesynchroniseerd op het web en in de CoddyKit-app. De cursus Voorbereiding op programmeerinterviews bevat in totaal 4 lessen.

Wat is dynamisch programmeren

Dynamisch programmeren (DP) lost complexe problemen op door ze op te splitsen in eenvoudigere overlappende deelproblemen, elk deelprobleem één keer op te lossen en het resultaat op te slaan om dubbele berekeningen te voorkomen. DP is toepasbaar wanneer een probleem twee eigenschappen heeft: overlappende deelproblemen (hetzelfde deelprobleem wordt bij naïeve recursie meerdere keren opgelost) en optimale deelstructuur (de optimale oplossing kan worden opgebouwd uit optimale oplossingen voor deelproblemen). Zonder beide eigenschappen helpt DP niet.

# 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: het klassieke startpunt voor DP

De rij van Fibonacci (fib(n) = fib(n-1) + fib(n-2)) is het standaardvoorbeeld van overlappende deelproblemen. De naïeve recursie heeft een exponentiële tijdscomplexiteit O(2^n), omdat dezelfde waarden steeds opnieuw worden berekend. De recursieboom voor fib(6) laat zien dat fib(3) 3 keer wordt berekend, fib(2) 5 keer, enzovoort. Deze exponentiële groei elimineert DP precies door berekende resultaten op te slaan.

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 growth

De recursieboom visualiseren

Als je de recursieboom voor fib(5) tekent, zie je de verspilling: elk knooppunt brengt twee kinderen voort en identieke deelbomen verschijnen steeds opnieuw. Het totale aantal knooppunten in de boom is O(2^n). Wanneer je dit patroon ziet — identieke functieaanroepen met dezelfde argumenten die in de boom steeds terugkomen — wijst dat erop dat DP kan helpen door resultaten te cachen. Deze vaardigheid om te visualiseren is cruciaal: als je de herhaalde deelbomen kunt herkennen, weet je dat DP toepasbaar is.

# 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)')

Overlappende deelproblemen herkennen

Zo herken je overlappende deelproblemen: schrijf eerst de brute-force-recursie en vraag jezelf daarna af: 'zijn er meerdere recursieve aanroepen met DEZELFDE argumenten?' Zo ja, dan kan DP helpen. Veelvoorkomende signalen in probleemomschrijvingen zijn: 'minimum/maximum aantal X', 'hoeveel manieren zijn er om Y te bereiken?' en 'kunnen we Z bereiken?'. Zulke formuleringen wijzen bijna altijd op een probleem met een optimale deelstructuur, waarbij het antwoord op positie i afhangt van antwoorden op eerdere posities.

# 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')

Optimale deelstructuur uitgelegd

Optimale deelstructuur betekent dat de optimale oplossing voor een probleem kan worden opgebouwd uit optimale oplossingen voor de deelproblemen. Het kortste pad van A naar C via B is bijvoorbeeld optimaal dan en slechts dan als de deelpaden A→B en B→C elk afzonderlijk optimaal zijn. Als deze eigenschap geldt, kun je het globale optimum van onderaf opbouwen uit lokale optima. Problemen zonder optimale deelstructuur (bijv. het langste pad in een algemene graaf met cycli) kunnen niet met DP worden opgelost.

# 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')

Trappen beklimmen: je eerste DP

Trappen beklimmen (LeetCode #70): op hoeveel verschillende manieren kun je n trappen beklimmen als je per keer 1 of 2 treden neemt? Stel dp[i] gelijk aan het aantal manieren om trede i te bereiken. Je kunt trede i bereiken vanaf trede i-1 (één trede) of trede i-2 (twee treden), dus dp[i] = dp[i-1] + dp[i-2]. Dit is Fibonacci! Basisgevallen: dp[1] = 1 en dp[2] = 2. Inzien dat 'trappen beklimmen' neerkomt op Fibonacci is een klassiek inzicht voor technische sollicitatiegesprekken.

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!

Het DP-raamwerk: definiëren, recursie opstellen, volgorde bepalen

Een betrouwbaar DP-raamwerk in 3 stappen: 1. Bepaal de toestand — wat stelt dp[i] (of dp[i][j]) voor? Schrijf dit in het Engels op. 2. Stel de recursie op — druk dp[i] uit in termen van kleinere deelproblemen. Neem alle gevallen op. 3. Bepaal de invulvolgorde — zorg dat dp[i-1] (en andere afhankelijkheden) berekend zijn voordat dp[i] aan de beurt is. Basisgevallen initialiseren de grens. Dit raamwerk zet een vage DP-intuïtie om in een concreet implementatieplan.

# 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')

Wanneer je DP NIET gebruikt

DP is niet altijd het antwoord. Gebruik greedy wanneer één lokaal optimale keuze altijd tot de globaal optimale oplossing leidt (activiteitenselectie, jump game I). Gebruik verdeel-en-heers wanneer deelproblemen elkaar niet overlappen (samenvoegsortering, binair zoeken). Gebruik BFS wanneer het probleem het vinden van het kortste pad in een ongewogen graaf is. DP is correct, maar vaak onnodig complex wanneer er een greedy- of eenvoudigere aanpak bestaat. Bespreek tijdens technische sollicitatiegesprekken waarom je voor DP hebt gekozen in plaats van voor alternatieven.

# 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.')

Aantal verschillende deelproblemen

Het aantal verschillende deelproblemen bepaalt de tijds- en ruimtecomplexiteit van DP. Bij 1D-DP voor een invoer met grootte n zijn er O(n) deelproblemen. Bij 2D-DP voor twee invoeren met groottes m en n zijn er O(mn) deelproblemen. Elk deelprobleem wordt opgelost in O(k)-tijd (voor k keuzes bij elke stap), wat een totale tijd oplevert van O(n*k) of O(mn*k). Tel altijd eerst de verschillende deelproblemen — zo bepaal je de tijdscomplexiteit van DP voordat je ook maar één regel code schrijft.

# 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')

Huisrover: overlappende keuzes

Huisrover (LeetCode #198) vraagt naar het maximale bedrag dat je kunt buitmaken uit huizen op een rij, zonder aangrenzende huizen te beroven. Bij elk huis kies je: beroof het (tel de waarde erbij op en sla het vorige over) of sla het over (neem het beste resultaat van het vorige huis). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Dit patroon waarbij je bij elke stap een keuze maakt, is de eenvoudigste 1D-DP-recursie en komt in tientallen problemen uit technische sollicitatiegesprekken voor.

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 3

Plausibiliteitscontrole: brute force versus DP

Vergelijk je DP altijd met een brute-forceoplossing op kleine invoeren. De brute-forceoplossing is je referentie. Zodra de DP-uitkomst overeenkomt met brute force voor alle testgevallen, weet je dat de recursie correct is. Optimaliseer pas daarna het ruimtegebruik. Deze testgestuurde aanpak — brute force → top-down-DP → bottom-up-DP → ruimtegeoptimaliseerde DP — is de professionele manier om DP-oplossingen tijdens een technisch sollicitatiegesprek te ontwikkelen en te verifiëren.

# 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}')

Korte controle

Controleer je begrip van de concepten uit Data Structures & Algorithms — Coding Interview Prep uit deze les.

Samenvatting van de les

In deze les heb je geleerd: de twee ingrediënten van DP (overlappende deelproblemen en optimale deelstructuur), hoe je de recursieboom visualiseert om herhaalde aanroepen te herkennen, het drie stappen tellende DP-raamwerk (toestand definiëren, recursie opstellen, invulvolgorde bepalen) en eerste voorbeelden, waaronder Fibonacci, trappen beklimmen en Huisrover. Hierna implementeren we top-down-DP met memoïsatie.

Gratis beginnen

Leer Voorbereiding op programmeerinterviews met een AI-tutor — gratis

Schrijf echte code en voer die uit in je browser, krijg direct hulp van een AI-tutor die 24/7 beschikbaar is en ga verder waar je gebleven bent op het web of in de app.

Cursussen
90
Lessen
360

Veelgestelde vragen

Is de les “DP herkennen: overlappende deelproblemen” gratis?

Ja — de volledige tekst van “DP herkennen: overlappende deelproblemen” kun je hier gratis op het web lezen. Als je interactief wilt oefenen met een ingebouwde code-editor en een AI-begeleider die 24/7 beschikbaar is, en de rest van de cursus Voorbereiding op programmeerinterviews wilt ontgrendelen, kun je upgraden naar CoddyKit PRO. De cursus Voorbereiding op programmeerinterviews bevat in totaal 4 lessen.

Wat leer ik in “DP herkennen: overlappende deelproblemen”?

Herken wanneer brute-force-recursie hetzelfde deelprobleem opnieuw oplost, teken de recursieboom voor Fibonacci en zie de exponentiële groei. Je oefent met Voorbereiding op programmeerinterviews door code rechtstreeks in de browser uit te voeren. Een AI-begeleider die 24/7 beschikbaar is beantwoordt je vragen terwijl je de les doorwerkt.

Heb ik ervaring nodig om met Voorbereiding op programmeerinterviews te beginnen?

Ervaring vooraf is niet nodig. Voorbereiding op programmeerinterviews op CoddyKit is opgebouwd voor beginners tot gevorderden, zodat je hier of bij het begin kunt starten en in je eigen tempo kunt leren. Dit is les 1 van 4.

Hoe lang duurt de les “DP herkennen: overlappende deelproblemen”?

De meeste lessen van CoddyKit duren ongeveer 5–10 minuten. Elke les is kort en interactief, zodat je gestaag vooruitgaat en op het web en in de app precies verdergaat waar je was gebleven.

Kan ik code schrijven en uitvoeren in deze les over Voorbereiding op programmeerinterviews?

Ja. Elke les over Voorbereiding op programmeerinterviews bevat een ingebouwde code-editor, zodat je rechtstreeks in je browser echte code kunt schrijven en uitvoeren en direct feedback van AI krijgt — lokale installatie is niet nodig.

Alle lessen in deze cursus

  1. DP herkennen: overlappende deelproblemen
  2. Top-down-DP met memoisation
  3. Bottom-up-DP met tabulatie
  4. Coin change en trap met minimale kosten
← Terug naar Voorbereiding op programmeerinterviews