0Pricing
DSA Interview Prep · บทเรียน

รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน

ระบุว่าเมื่อใดการเรียกซ้ำแบบลองทุกทางแก้ปัญหาย่อยเดิมซ้ำ วาดต้นไม้การเรียกซ้ำของฟีโบนัชชี และเห็นการเพิ่มขึ้นแบบเลขชี้กำลัง

รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน เป็นบทเรียน DSA Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน DSA Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

การเขียนโปรแกรมพลวัตคืออะไร

การเขียนโปรแกรมพลวัต (DP) แก้ปัญหาซับซ้อนโดยแบ่งเป็นปัญหาย่อยที่ง่ายกว่าและซ้อนทับกัน แก้ปัญหาย่อยแต่ละปัญหาเพียงครั้งเดียว และจัดเก็บผลลัพธ์เพื่อหลีกเลี่ยงการคำนวณซ้ำ DP ใช้ได้เมื่อปัญหามีองค์ประกอบสองประการ: ปัญหาย่อยที่ซ้อนทับกัน (ปัญหาย่อยเดียวกันถูกแก้ซ้ำหลายครั้งในการเรียกซ้ำแบบพื้นฐาน) และโครงสร้างย่อยที่เหมาะที่สุด (คำตอบที่ดีที่สุดสร้างได้จากคำตอบที่ดีที่สุดของปัญหาย่อย) หากขาดองค์ประกอบใดองค์ประกอบหนึ่ง DP จะไม่ช่วยแก้ปัญหา

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

ฟีโบนัชชี: จุดเริ่มต้นคลาสสิกของ DP

ลำดับฟีโบนัชชี (fib(n) = fib(n-1) + fib(n-2)) เป็นตัวอย่างมาตรฐานของปัญหาย่อยที่ซ้อนทับกัน การเรียกซ้ำแบบพื้นฐานมีเวลาแบบเอ็กซ์โพเนนเชียล O(2^n) เพราะคำนวณค่าเดิมซ้ำหลายครั้ง ต้นไม้การเรียกซ้ำของ fib(6) แสดงให้เห็นว่า fib(3) ถูกคำนวณ 3 ครั้ง fib(2) ถูกคำนวณ 5 ครั้ง และเป็นเช่นนี้ต่อไป การเพิ่มขึ้นอย่างรวดเร็วแบบเอ็กซ์โพเนนเชียลนี้คือสิ่งที่ DP กำจัดได้ด้วยการจัดเก็บผลลัพธ์ที่คำนวณแล้ว

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

การสร้างภาพต้นไม้การเรียกซ้ำ

การวาดต้นไม้การเรียกซ้ำสำหรับ fib(5) เผยให้เห็นความสิ้นเปลือง: แต่ละโหนดสร้างโหนดลูกสองโหนด และต้นไม้ย่อยที่เหมือนกันปรากฏซ้ำหลายครั้ง จำนวนโหนดทั้งหมดในต้นไม้คือ O(2^n) เมื่อคุณเห็นรูปแบบนี้ ซึ่งมีการเรียกฟังก์ชันเดียวกันด้วยอาร์กิวเมนต์เดิมซ้ำในต้นไม้ นั่นเป็นสัญญาณว่า DP สามารถช่วยได้ด้วยการเก็บผลลัพธ์ไว้ใช้ซ้ำ ทักษะการมองเห็นรูปแบบนี้มีความสำคัญอย่างยิ่ง เพราะหากคุณระบุต้นไม้ย่อยที่ซ้ำกันได้ คุณก็จะรู้ว่าเหมาะสมที่จะใช้ DP

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

การระบุปัญหาย่อยที่ซ้อนทับกัน

หากต้องการสังเกตปัญหาย่อยที่ซ้อนทับกัน ให้เขียนการเรียกซ้ำแบบลองทุกกรณีก่อน จากนั้นถามว่า “มีการเรียกซ้ำหลายครั้งด้วยอาร์กิวเมนต์ SAME หรือไม่” หากมี DP ก็ช่วยได้ สัญญาณที่พบบ่อยในคำอธิบายปัญหา ได้แก่ “จำนวน X ต่ำสุด/สูงสุด”, “มีวิธีทำ Y ได้กี่วิธี” และ “เราสามารถทำให้ได้ Z หรือไม่” รูปแบบถ้อยคำเหล่านี้มักบ่งชี้ถึงปัญหาที่มีโครงสร้างย่อยที่เหมาะที่สุด ซึ่งคำตอบที่ตำแหน่ง i ขึ้นอยู่กับคำตอบในตำแหน่งก่อนหน้า

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

อธิบายโครงสร้างย่อยที่เหมาะที่สุด

โครงสร้างย่อยที่เหมาะที่สุดหมายความว่าคำตอบที่ดีที่สุดของปัญหาสามารถสร้างจากคำตอบที่ดีที่สุดของปัญหาย่อยได้ ตัวอย่างเช่น เส้นทางที่สั้นที่สุดจาก A ไป C ผ่าน B จะดีที่สุดก็ต่อเมื่อเส้นทางย่อย A→B และ B→C ต่างก็ดีที่สุดในตัวเอง หากคุณสมบัตินี้เป็นจริง คุณสามารถสร้างคำตอบที่ดีที่สุดโดยรวมจากคำตอบที่ดีที่สุดเฉพาะส่วนแบบล่างขึ้นบน ปัญหาที่ไม่มีโครงสร้างย่อยที่เหมาะที่สุด เช่น เส้นทางที่ยาวที่สุดในกราฟทั่วไปที่มีวัฏจักร ไม่สามารถแก้ด้วย 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')

การปีนบันได: DP แรกของคุณ

การปีนบันได (LeetCode #70): มีวิธีที่แตกต่างกันกี่วิธีในการปีนบันได n ขั้น โดยก้าวครั้งละ 1 หรือ 2 ขั้น ให้ dp[i] = จำนวนวิธีไปถึงขั้นที่ i คุณสามารถมาถึงขั้นที่ i จากขั้นที่ i-1 (หนึ่งขั้น) หรือขั้นที่ i-2 (สองขั้น) ดังนั้น dp[i] = dp[i-1] + dp[i-2] นี่คือลำดับฟีโบนัชชี! กรณีฐานคือ dp[1] = 1 และ dp[2] = 2 การมองออกว่า “การปีนบันได” ลดรูปเป็นลำดับฟีโบนัชชีได้ เป็นข้อสังเกตคลาสสิกในการสัมภาษณ์

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: กำหนด เขียนสมการเวียนเกิด จัดลำดับ

กรอบงาน DP 3 ขั้นตอนที่เชื่อถือได้: 1. กำหนดสถานะ — dp[i] (หรือ dp[i][j]) แทนอะไร ให้เขียนคำอธิบายเป็นภาษาอังกฤษ 2. เขียนความสัมพันธ์เวียนเกิด — เขียน dp[i] ให้อยู่ในรูปของปัญหาย่อยที่เล็กกว่า และครอบคลุมทุกกรณี 3. กำหนดลำดับการเติมค่า — ตรวจสอบให้แน่ใจว่า dp[i-1] (รวมถึงค่าที่ต้องพึ่งพาอื่น ๆ) ถูกคำนวณก่อน dp[i] กรณีฐานจะกำหนดค่าเริ่มต้นให้ขอบเขต กรอบงานนี้เปลี่ยนสัญชาตญาณเกี่ยวกับ DP ที่คลุมเครือให้เป็นแผนการนำไปใช้งานที่เป็นรูปธรรม

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

เมื่อใดไม่ควรใช้ DP (NOT)

DP ไม่ใช่คำตอบเสมอไป ให้ใช้ วิธีเลือกแบบโลภ เมื่อการเลือกที่ดีที่สุดเฉพาะที่เพียงครั้งเดียวจะนำไปสู่คำตอบที่ดีที่สุดโดยรวมเสมอ (การเลือกกิจกรรม, เกมกระโดด I) ให้ใช้ การแบ่งแยกแล้วพิชิต เมื่อปัญหาย่อยไม่ซ้อนทับกัน (การเรียงลำดับแบบผสาน, การค้นหาแบบทวิภาค) ให้ใช้ BFS เมื่อปัญหาเป็นการหาเส้นทางสั้นที่สุดในกราฟที่ไม่มีน้ำหนัก DP ถูกต้อง แต่บ่อยครั้งซับซ้อนเกินความจำเป็นเมื่อมีวิธีเลือกแบบโลภหรือวิธีที่ง่ายกว่านั้น ในการสัมภาษณ์งาน ควรอธิบายว่าเหตุใดจึงเลือก DP แทนทางเลือกอื่น

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

การนับปัญหาย่อยที่แตกต่างกัน

จำนวนของ ปัญหาย่อยที่แตกต่างกันเป็นตัวกำหนดความซับซ้อนด้านเวลาและพื้นที่ของ DP สำหรับ DP แบบ 1 มิติที่มีข้อมูลเข้าขนาด n จะมีปัญหาย่อย O(n) ปัญหา สำหรับ DP แบบ 2 มิติที่รับข้อมูลเข้าสองชุดขนาด m และ n จะมีปัญหาย่อย O(mn) ปัญหา หากแก้ปัญหาย่อยแต่ละปัญหาใช้เวลา O(k) (เมื่อมีตัวเลือก k รายการในแต่ละขั้น) เวลารวมจะเป็น O(n*k) หรือ O(mn*k) ควรนับปัญหาย่อยที่แตกต่างกันก่อนเสมอ เพราะข้อมูลนี้จะบอกความซับซ้อนด้านเวลาของ DP ก่อนที่คุณจะเริ่มเขียนโค้ดด้วยซ้ำ

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

โจรปล้นบ้าน: ทางเลือกที่ซ้อนทับกัน

โจรปล้นบ้าน (LeetCode #198) เป็นโจทย์ที่ถามหาจำนวนเงินสูงสุดที่คุณสามารถขโมยจากบ้านที่เรียงต่อกันเป็นแถว โดยห้ามขโมยบ้านที่อยู่ติดกัน ในแต่ละบ้าน คุณเลือกได้ว่าจะขโมยบ้านนั้น (บวกค่าของบ้าน แล้วข้ามบ้านก่อนหน้า) หรือข้ามบ้านนั้น (ใช้ค่าที่ดีที่สุดจากบ้านก่อนหน้า) dp[i] = max(dp[i-1], dp[i-2] + nums[i]) รูปแบบการเลือกในแต่ละขั้นนี้เป็นความสัมพันธ์เวียนเกิดของ DP แบบ 1 มิติที่ง่ายที่สุด และปรากฏในโจทย์สัมภาษณ์งานหลายสิบข้อ

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

ตรวจสอบความสมเหตุสมผล: วิธีลองทุกกรณีเทียบกับ DP

ควรตรวจสอบ DP ของคุณเทียบกับวิธีลองทุกกรณีด้วยข้อมูลเข้าขนาดเล็กเสมอ วิธีลองทุกกรณีคือคำตอบอ้างอิงที่ถูกต้อง เมื่อ DP ให้ผลตรงกับวิธีลองทุกกรณีในชุดทดสอบทั้งหมด คุณจึงมั่นใจได้ว่าความสัมพันธ์เวียนเกิดถูกต้อง หลังจากนั้นจึงค่อยปรับปรุงการใช้พื้นที่ วิธีที่ขับเคลื่อนด้วยการทดสอบนี้ — วิธีลองทุกกรณี → DP จากบนลงล่าง → DP จากล่างขึ้นบน → DP ที่ปรับปรุงการใช้พื้นที่ — คือแนวทางระดับมืออาชีพในการพัฒนาและตรวจสอบคำตอบ DP ระหว่างการสัมภาษณ์งาน

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

ตรวจสอบความเข้าใจ

ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูล & อัลกอริทึม — การเตรียมตัวสำหรับการสัมภาษณ์เขียนโปรแกรมจากบทเรียนนี้

สรุปบทเรียน

ในบทเรียนนี้ คุณได้เรียนรู้เกี่ยวกับ: องค์ประกอบสองประการของ DP (ปัญหาย่อยที่ซ้อนทับกันและโครงสร้างย่อยที่เหมาะที่สุด), วิธี สร้างภาพต้นไม้การเรียกซ้ำ เพื่อระบุการเรียกซ้ำที่เกิดขึ้นหลายครั้ง, กรอบงาน DP 3 ขั้นตอน (กำหนดสถานะ ความสัมพันธ์เวียนเกิด และลำดับการเติมค่า) รวมถึงตัวอย่างแรกเริ่มอย่างฟีโบนักชี การปีนบันได และโจรปล้นบ้าน ต่อไปเราจะนำ DP จากบนลงล่างที่ใช้การจดจำผลลัพธ์มาเขียน

คำถามที่พบบ่อย

บทเรียน “รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส DSA Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน”

ระบุว่าเมื่อใดการเรียกซ้ำแบบลองทุกทางแก้ปัญหาย่อยเดิมซ้ำ วาดต้นไม้การเรียกซ้ำของฟีโบนัชชี และเห็นการเพิ่มขึ้นแบบเลขชี้กำลัง คุณปฏิบัติ DSA Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน DSA Interview Prep หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน DSA Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน

บทเรียน “รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน DSA Interview Prep นี้ได้ไหม

ได้ บทเรียน DSA Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน
  2. DP จากบนลงล่างด้วยการจดจำผลลัพธ์
  3. DP จากล่างขึ้นบนด้วยตาราง
  4. การทอนเหรียญและบันไดต้นทุนต่ำสุด
← กลับไปที่ DSA Interview Prep