Valmistautuminen ohjelmointihaastatteluihin · Oppitunti

DP:n tunnistaminen: päällekkäiset aliongelmat

Tunnistakaa, milloin brutaali rekursio ratkaisee saman aliongelman uudelleen, piirtäkää Fibonacci-rekursiopuu ja nähkää eksponentiaalinen kasvupyrähdys.

Oppitunti 1/413 vaihetta

DP:n tunnistaminen: päällekkäiset aliongelmat on ilmainen Valmistautuminen ohjelmointihaastatteluihin-oppitunti CoddyKitissä. Tämä on oppitunti 1/4. Voit lukea koko oppitunnin alta ilmaiseksi ja harjoitella sen jälkeen käytännössä selaimessa sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla. Oppitunti kuuluu Valmistautuminen ohjelmointihaastatteluihin-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. Valmistautuminen ohjelmointihaastatteluihin-kurssilla on yhteensä 4 oppituntia.

Mitä dynaaminen ohjelmointi on

Dynaaminen ohjelmointi (DP) ratkaisee monimutkaisia ongelmia jakamalla ne yksinkertaisemmiksi päällekkäisiksi osaongelmiksi, ratkaisemalla kunkin osaongelman kerran ja tallentamalla tuloksen turhien laskutoimitusten välttämiseksi. DP soveltuu ongelmiin, joissa on kaksi ominaisuutta: päällekkäiset osaongelmat (sama osaongelma ratkaistaan yksinkertaisessa rekursiossa useita kertoja) ja optimaalinen alirakenne (optimaalinen ratkaisu voidaan muodostaa osaongelmien optimaalisista ratkaisuista). Jos jompikumpi ominaisuus puuttuu, DP:stä ei ole hyötyä.

# 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: klassinen johdatus DP:hen

Fibonacci-jono (fib(n) = fib(n-1) + fib(n-2)) on päällekkäisten osaongelmien perusesimerkki. Yksinkertainen rekursio on aikavaativuudeltaan eksponentiaalinen O(2^n), koska se laskee samat arvot toistuvasti uudelleen. Funktion fib(6) rekursiopuu osoittaa, että fib(3) lasketaan kolme kertaa, fib(2) viisi kertaa ja niin edelleen. Tämä eksponentiaalinen kasvu on juuri se, minkä DP poistaa tallentamalla lasketut tulokset.

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

Rekursiopuun visualisointi

Funktion fib(5) rekursiopuun piirtäminen paljastaa hukkaan menevän työn: jokainen solmu synnyttää kaksi lapsisolmua, ja samanlaiset alipuut toistuvat. Puun solmujen kokonaismäärä on O(2^n). Kun näette tämän mallin — samat argumentit saavat aikaan samoja funktiokutsuja, jotka toistuvat puussa — se on merkki siitä, että DP voi auttaa tallentamalla tulokset välimuistiin. Tämän rakenteen visualisointitaito on ratkaiseva: jos tunnistatte toistuvat alipuut, tiedätte DP:n soveltuvan ongelmaan.

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

Päällekkäisten osaongelmien tunnistaminen

Tunnistaaksenne päällekkäiset osaongelmat kirjoittakaa ensin brute force -rekursio ja kysykää sitten: 'onko useilla rekursiivisilla kutsuilla SAMAT argumentit?' Jos on, DP voi auttaa. Tavallisia vihjeitä ongelmakuvauksissa ovat esimerkiksi 'X:n vähimmäis-/enimmäismäärä', 'kuinka monella tavalla Y voidaan tehdä' ja 'voidaanko Z saavuttaa?'. Tällaiset sanamuodot viittaavat lähes aina optimaalisen alirakenteen ongelmaan, jossa vastaus kohdassa i riippuu aiempien kohtien vastauksista.

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

Optimaalinen alirakenne selitettynä

Optimaalinen alirakenne tarkoittaa, että ongelman optimaalinen ratkaisu voidaan muodostaa sen osaongelmien optimaalisista ratkaisuista. Esimerkiksi lyhin polku A:sta C:hen B:n kautta on optimaalinen täsmälleen silloin, kun osapolut A→B ja B→C ovat kumpikin erikseen optimaalisia. Jos tämä ominaisuus pätee, voitte muodostaa globaalin optimin alhaalta ylöspäin paikallisista optimeista. Ongelmat, joista optimaalinen alirakenne puuttuu (kuten pisin polku syklejä sisältävässä yleisessä graafissa), eivät ole ratkaistavissa DP:llä.

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

Portaiden kiipeäminen: ensimmäinen DP-tehtävä

Portaiden kiipeäminen (LeetCode #70): kuinka monella eri tavalla voitte kiivetä n portaan portaikkoa ottamalla kerrallaan yhden tai kaksi askelta? Määritellään dp[i] = portaalle i pääsemiseen tarvittavien tapojen määrä. Portaalle i voi saapua portaalta i-1 (yksi askel) tai portaalta i-2 (kaksi askelta), joten dp[i] = dp[i-1] + dp[i-2]. Tämä on Fibonacci-jono! Perustapaukset ovat dp[1] = 1 ja dp[2] = 2. Sen tunnistaminen, että 'portaiden kiipeäminen' pelkistyy Fibonacciksi, on klassinen oivallus työhaastatteluissa.

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-kehys: määritä, muodosta rekurrenssi, järjestä

Luotettava kolmivaiheinen DP-kehys: 1. Määritä tila — mitä dp[i] (tai dp[i][j]) esittää? Kirjoittakaa se englanniksi. 2. Muodosta rekurrenssi — ilmaiskaa dp[i] pienempien osaongelmien avulla. Ottakaa kaikki tapaukset huomioon. 3. Määritä täyttöjärjestys — varmistakaa, että dp[i-1] (ja muut riippuvuudet) on laskettu ennen dp[i]:tä. Kantatapaukset alustavat reuna-arvot. Tämä kehys muuttaa epämääräisen DP-intuition konkreettiseksi toteutussuunnitelmaksi.

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

Milloin DP:tä EI pidä käyttää

DP ei ole aina oikea ratkaisu. Käyttäkää ahnetta menetelmää, kun yksi paikallisesti optimaalinen valinta johtaa aina globaalisti optimaaliseen ratkaisuun (activity selection, Jump Game I). Käyttäkää hajota ja hallitse -menetelmää, kun osaongelmat eivät ole päällekkäisiä (merge sort, binary search). Käyttäkää BFS:ää, kun ongelma on painottamattoman graafin lyhimmän polun etsiminen. DP on oikea mutta usein ylimitoitettu ratkaisu, jos ahne tai yksinkertaisempi menetelmä riittää. Haastatteluissa perustelkaa, miksi valitsitte DP:n vaihtoehtojen sijaan.

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

Erillisten osaongelmien laskeminen

Erillisten osaongelmien määrä määrittää DP:n aika- ja tilavaativuuden. Kun 1D-DP:n syöte on kooltaan n, osaongelmia on O(n). Kun 2D-DP:n kaksi syötettä ovat kooltaan m ja n, osaongelmia on O(mn). Jos jokainen osaongelma ratkaistaan ajassa O(k) (kun k vaihtoehtoa käsitellään jokaisessa vaiheessa), kokonaisaikavaativuus on O(n*k) tai O(mn*k). Laskekaa erilliset osaongelmat aina ensin — näin saatte DP:n aikavaativuuden selville jo ennen koodin kirjoittamista.

# 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: päällekkäiset valinnat

House Robber (LeetCode #198) kysyy, mikä on suurin summa, jonka voitte ryöstää peräkkäin olevista taloista ryöstämättä vierekkäisiä taloja. Jokaisen talon kohdalla valitsette, ryöstättekö sen (lisäätte sen arvon ja ohitatte edellisen) vai jätättekö sen väliin (otatte parhaan tuloksen edelliseltä talolta). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Tämä jokaisessa vaiheessa tehtävään valintaan perustuva malli on yksinkertaisin 1D-DP:n rekurrenssi, ja se esiintyy kymmenissä haastattelutehtävissä.

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

Järkevyystarkistus: brute force vs. DP

Verratkaa DP-ratkaisua aina brute force -ratkaisuun pienillä syötteillä. Brute force toimii vertailukohtana ja antaa oikeat tulokset. Kun DP vastaa brute force -ratkaisua kaikilla testitapauksilla, tiedätte rekurrenssin olevan oikein. Optimoikaa tilankäyttö vasta sen jälkeen. Tämä testivetoinen lähestymistapa — brute force → ylhäältä alas etenevä DP → alhaalta ylös etenevä DP → tilan suhteen optimoitu DP — on ammattimainen tapa kehittää ja varmistaa DP-ratkaisut haastattelussa.

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

Pikatarkistus

Testatkaa tämän oppitunnin Data Structures & Algorithms — Coding Interview Prep -aiheiden ymmärtämistänne.

Oppitunnin yhteenveto

Tässä oppitunnissa opitte: DP:n kaksi keskeistä rakennuspalikkaa (päällekkäiset osaongelmat ja optimaalinen alirakenne), miten rekursiopuu visualisoidaan toistuvien kutsujen tunnistamiseksi, DP:n kolmivaiheisen kehyksen (tilan määrittäminen, rekurrenssi, täyttöjärjestys) sekä ensimmäiset esimerkit, kuten Fibonacci, portaiden kiipeäminen ja House Robber. Seuraavaksi toteutamme ylhäältä alas etenevän DP:n memoisaation avulla.

Aloita maksutta

Opi Valmistautuminen ohjelmointihaastatteluihin tekoälytuutorin avulla — ilmaiseksi

Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.

Kurssit
90
Oppitunnit
360

Usein kysytyt kysymykset

Onko oppitunti ”DP:n tunnistaminen: päällekkäiset aliongelmat” ilmainen?

Kyllä – oppitunnin ”DP:n tunnistaminen: päällekkäiset aliongelmat” koko tekstin voi lukea täällä verkossa ilmaiseksi. Jos haluat harjoitella interaktiivisesti sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla sekä avata koko Valmistautuminen ohjelmointihaastatteluihin-kurssin, päivitä CoddyKit PROhon. Valmistautuminen ohjelmointihaastatteluihin-kurssilla on yhteensä 4 oppituntia.

Mitä opin oppitunnilla ”DP:n tunnistaminen: päällekkäiset aliongelmat”?

Tunnistakaa, milloin brutaali rekursio ratkaisee saman aliongelman uudelleen, piirtäkää Fibonacci-rekursiopuu ja nähkää eksponentiaalinen kasvupyrähdys. Harjoittelet Valmistautuminen ohjelmointihaastatteluihin-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.

Tarvitsenko kokemusta aloittaakseni Valmistautuminen ohjelmointihaastatteluihin-opiskelun?

Aiempi kokemus ei ole tarpeen. CoddyKitin Valmistautuminen ohjelmointihaastatteluihin-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 1/4.

Kuinka kauan ”DP:n tunnistaminen: päällekkäiset aliongelmat”-oppitunnin suorittaminen kestää?

Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.

Voinko kirjoittaa ja suorittaa koodia tällä Valmistautuminen ohjelmointihaastatteluihin-oppitunnilla?

Kyllä. Jokainen Valmistautuminen ohjelmointihaastatteluihin-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.

Kaikki tämän kurssin oppitunnit

  1. DP:n tunnistaminen: päällekkäiset aliongelmat
  2. Ylhäältä alas etenevä DP memoisaatiolla
  3. Alhaalta ylös etenevä DP taulukoinnilla
  4. Kolikkovaihto ja portaikon pienin kustannus
← Takaisin: Valmistautuminen ohjelmointihaastatteluihin