Forberedelse til kodeinterviews · Lektion

Genkendelse af DP: overlappende delproblemer

Identificér, hvornår rekursion med brute force løser det samme delproblem igen, tegn rekursionstræet for Fibonacci, og se den eksponentielle vækst.

Lektion 1 af 413 trin

Genkendelse af DP: overlappende delproblemer er en gratis Forberedelse til kodeinterviews-lektion på CoddyKit. Dette er lektion 1 af 4. Du kan læse hele lektionen gratis nedenfor — og derefter øve dig praktisk i browseren med en indbygget kodeeditor og en AI-vejleder, der er tilgængelig døgnet rundt. Den er en del af læringsforløbet i Forberedelse til kodeinterviews, og dine fremskridt synkroniseres på tværs af nettet og CoddyKit-appen. Forberedelse til kodeinterviews-kurset indeholder 4 lektioner i alt.

Hvad er dynamisk programmering?

Dynamisk programmering (DP) løser komplekse problemer ved at opdele dem i enklere overlappende delproblemer, løse hvert delproblem én gang og gemme resultatet for at undgå overflødige beregninger. DP kan anvendes, når et problem har to egenskaber: overlappende delproblemer (det samme delproblem løses flere gange i en naiv rekursion) og optimal delstruktur (den optimale løsning kan bygges ud fra optimale løsninger på delproblemer). Uden begge egenskaber hjælper DP ikke.

# 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: Det klassiske udgangspunkt for DP

Fibonacci-følgen (fib(n) = fib(n-1) + fib(n-2)) er det klassiske eksempel på overlappende delproblemer. Den naive rekursion har eksponentiel tidskompleksitet på O(2^n), fordi den genberegner de samme værdier igen og igen. Rekursionstræet for fib(6) viser, at fib(3) beregnes 3 gange, fib(2) 5 gange og så videre. Denne eksponentielle vækst er netop det, DP eliminerer ved at gemme beregnede resultater.

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

Visualisering af rekursionstræet

Når du tegner rekursionstræet for fib(5), bliver spildet tydeligt: Hver knude skaber to børn, og identiske deltræer optræder gentagne gange. Det samlede antal knuder i træet er O(2^n). Når du ser dette mønster — identiske funktionskald med de samme argumenter, der gentages i træet — viser det, at DP kan hjælpe ved at gemme resultater i en cache. Denne evne til at visualisere er afgørende: Hvis du kan identificere de gentagne deltræer, ved du, at DP kan anvendes.

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

Identificering af overlappende delproblemer

Sådan genkender du overlappende delproblemer: Skriv den udtømmende rekursion, og spørg derefter: »Er der flere rekursive kald med de SAMME argumenter?« Hvis ja, kan DP hjælpe. Almindelige signaler i problemformuleringer er: »mindste/største antal X«, »hvor mange måder kan vi udføre Y på?« og »kan vi opnå Z?«. Disse formuleringer peger næsten altid på et problem med optimal delstruktur, hvor svaret ved position i afhænger af svarene ved tidligere positioner.

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

Optimal delstruktur forklaret

Optimal delstruktur betyder, at problemets optimale løsning kan konstrueres ud fra optimale løsninger på delproblemerne. Den korteste sti fra A til C gennem B er for eksempel optimal, hvis og kun hvis delstierne A→B og B→C hver især er optimale. Hvis denne egenskab gælder, kan du bygge det globale optimum nedefra og op ud fra lokale optima. Problemer uden optimal delstruktur (f.eks. den længste sti i en generel graf med cyklusser) kan ikke løses med 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')

Trappegang: Din første DP

Trappegang (LeetCode #70): Hvor mange forskellige måder kan du gå op ad n trin på, når du tager 1 eller 2 trin ad gangen? Lad dp[i] være antallet af måder at nå trin i på. Du kan nå trin i fra trin i-1 (ét trin) eller trin i-2 (to trin), så dp[i] = dp[i-1] + dp[i-2]. Det er Fibonacci! Basistilfælde: dp[1] = 1, dp[2] = 2. At genkende, at »trappegang« kan reduceres til Fibonacci, er en klassisk indsigt til jobsamtaler.

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!

DP-rammen: Definér, rekurrér, ordn

En pålidelig DP-ramme i 3 trin: 1. Definér tilstanden — hvad repræsenterer dp[i] (eller dp[i][j])? Skriv det på engelsk. 2. Skriv rekurrensrelationen — udtryk dp[i] ved hjælp af mindre delproblemer. Medtag alle tilfælde. 3. Fastlæg udfyldningsrækkefølgen — sørg for, at dp[i-1] (og andre afhængigheder) er beregnet, før dp[i] beregnes. Basistilfælde initialiserer grænserne. Denne ramme omsætter en vag DP-intuition til en konkret implementeringsplan.

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

Hvornår skal du IKKE bruge DP

DP er ikke altid svaret. Brug en grådig algoritme, når ét lokalt optimalt valg altid fører til den globalt optimale løsning (aktivitetsudvælgelse, springspil I). Brug del og hersk, når delproblemerne ikke overlapper (fletningssortering, binær søgning). Brug BFS, når problemet handler om den korteste sti i en uvægtet graf. DP er korrekt, men ofte unødvendigt omfattende, når der findes en grådig eller enklere metode. Diskutér under tekniske jobsamtaler, hvorfor du valgte DP frem for alternativerne.

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

Optælling af forskellige delproblemer

Antallet af forskellige delproblemer bestemmer DP-løsningens tids- og pladskompleksitet. For en 1D-DP på et input af størrelse n er der O(n) delproblemer. For en 2D-DP på to input med størrelserne m og n er der O(mn) delproblemer. Hvert delproblem løses på O(k) tid (for k valg ved hvert trin), hvilket giver en samlet tid på O(n*k) eller O(mn*k). Optæl altid først de forskellige delproblemer — så får du DP-løsningens tidskompleksitet, før du overhovedet skriver kode.

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

Husrøveren: Overlappende valg

Husrøveren (LeetCode #198) spørger, hvor stort et beløb du maksimalt kan røve fra huse på række uden at røve nabohuse. Ved hvert hus vælger du: røv det (læg dets værdi til, og spring det forrige over) eller lad være (tag det bedste resultat fra det forrige). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Dette mønster med et valg ved hvert trin er den enkleste 1D-DP-rekurrensrelation og optræder i dusinvis af opgaver til tekniske jobsamtaler.

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

Sundhedstjek: Udtømmende søgning sammenlignet med DP

Verificér altid din DP-løsning mod en udtømmende løsning på små input. Den udtømmende løsning er din sandhedskilde. Når DP-løsningen stemmer overens med den udtømmende løsning i alle testtilfælde, ved du, at rekurrensrelationen er korrekt. Optimér først derefter pladsforbruget. Denne testdrevne tilgang — udtømmende søgning → top-down-DP → bottom-up-DP → pladsoptimeret DP — er den professionelle måde at udvikle og verificere DP-løsninger på under en teknisk jobsamtale.

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

Hurtigt tjek

Afprøv din forståelse af begreberne Data Structures & Algorithms — Coding Interview Prep fra denne lektion.

Opsummering af lektionen

I denne lektion lærte du: to DP-ingredienser (overlappende delproblemer og optimal delstruktur), hvordan du kan visualisere rekursionstræet for at identificere gentagne kald, den tretrins-DP-ramme (definér tilstand, rekurrensrelation, udfyldningsrækkefølge) samt de første eksempler, herunder Fibonacci, trappegang og husrøveren. Næste gang implementerer vi top-down-DP med memoisering.

Gratis at komme i gang

Lær Forberedelse til kodeinterviews med en AI-underviser — gratis

Skriv og kør rigtig kode i din browser, få øjeblikkelig hjælp fra en AI-underviser døgnet rundt, og fortsæt, hvor du slap, på web eller i appen.

Kurser
90
Lektioner
360

Ofte stillede spørgsmål

Er lektionen “Genkendelse af DP: overlappende delproblemer” gratis?

Ja — hele teksten til “Genkendelse af DP: overlappende delproblemer” kan læses gratis her på nettet. Hvis du vil øve dig interaktivt med en indbygget kodeeditor og en AI-vejleder døgnet rundt og få adgang til resten af Forberedelse til kodeinterviews-kurset, skal du opgradere til CoddyKit PRO. Forberedelse til kodeinterviews-kurset indeholder 4 lektioner i alt.

Hvad lærer jeg i “Genkendelse af DP: overlappende delproblemer”?

Identificér, hvornår rekursion med brute force løser det samme delproblem igen, tegn rekursionstræet for Fibonacci, og se den eksponentielle vækst. Du øver dig i Forberedelse til kodeinterviews med praktisk kode, som du kører direkte i browseren, og en AI-vejleder døgnet rundt besvarer dine spørgsmål, mens du arbejder dig gennem lektionen.

Skal jeg have erfaring for at begynde på Forberedelse til kodeinterviews?

Der kræves ingen tidligere erfaring. Forberedelse til kodeinterviews på CoddyKit er tilrettelagt for både begyndere og øvede, så du kan starte her eller fra begyndelsen og lære i dit eget tempo. Dette er lektion 1 af 4.

Hvor lang tid tager lektionen “Genkendelse af DP: overlappende delproblemer”?

De fleste CoddyKit-lektioner tager cirka 5–10 minutter. Hver lektion er kort og interaktiv, så du gør løbende fremskridt og kan fortsætte, hvor du slap – på både web og app.

Kan jeg skrive og køre kode i denne Forberedelse til kodeinterviews-lektion?

Ja. Alle Forberedelse til kodeinterviews-lektioner har en indbygget kodeeditor, så du kan skrive og køre rigtig kode direkte i din browser og få øjeblikkelig feedback fra AI – uden lokal opsætning.

Alle lektioner i dette kursus

  1. Genkendelse af DP: overlappende delproblemer
  2. Top-down-DP med memoization
  3. Bottom-up-DP med tabulering
  4. Coin change og trappe med minimale omkostninger
← Tilbage til Forberedelse til kodeinterviews