DP จากบนลงล่างด้วยการจดจำผลลัพธ์
เพิ่มดิกชันนารี memo ให้คำตอบแบบเรียกซ้ำเพื่อตัดการเรียกซ้ำ และใช้ @lru_cache เพื่อจดจำผลลัพธ์ด้วยโค้ดเพียงเล็กน้อย
DP จากบนลงล่างด้วยการจดจำผลลัพธ์ เป็นบทเรียน Coding Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน Coding Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
DP จากบนลงล่าง: แนวคิดการจดจำผลลัพธ์
DP จากบนลงล่างเริ่มจากคำตอบแบบเรียกซ้ำดั้งเดิม แล้วเพิ่ม การจดจำผลลัพธ์เข้าไป ซึ่งก็คือแคชที่เก็บผลลัพธ์ของแต่ละปัญหาย่อยเมื่อคำนวณครั้งแรก ในการเรียกครั้งต่อ ๆ ไปด้วยอาร์กิวเมนต์เดิม ระบบจะคืนค่าที่เก็บไว้ในแคชทันทีโดยไม่ต้องเรียกซ้ำอีก วิธีนี้เปลี่ยนการเรียกซ้ำแบบไร้ประสิทธิภาพ O(2^n) ให้เป็น O(n) โดยแก้ไขโค้ดเพียงเล็กน้อย ซึ่งมักเป็นการเพิ่มเพียง 2–3 บรรทัดในคำตอบแบบเรียกซ้ำที่มีอยู่
# Top-down approach:
# 1. Write the recursive solution (natural but slow)
# 2. Add a memo dict to cache results
# 3. Before recursing, check if the result is cached
# 4. Before returning, store the result in the cache
# This is also called 'memoization' (US spelling)
# 'memoize' means 'to remember', not 'memorize'
# The cache key is the function arguments
# For fib: key is n
# For 2D DP: key is (i, j)
# For 3D DP: key is (i, j, k)
print('Top-down = recursion + memo cache')ฟีโบนักชีที่จดจำผลลัพธ์
การเพิ่มพจนานุกรมสำหรับจดจำผลลัพธ์ให้กับการเรียกซ้ำฟีโบนักชีแบบไร้ประสิทธิภาพ จะลดเวลาจาก O(2^n) เหลือ O(n) การเรียก fib(k) ครั้งแรกจะคำนวณและเก็บผลลัพธ์ไว้ การเรียกครั้งต่อ ๆ ไปสำหรับ k เดิมจะคืนค่าที่เก็บไว้ในแคชได้ทันที ความซับซ้อนด้านพื้นที่คือ O(n) สำหรับพจนานุกรมจดจำผลลัพธ์ บวกกับ O(n) สำหรับสแตกการเรียก ลองเปรียบเทียบจำนวนการเรียก: เมื่อไม่ใช้การจดจำผลลัพธ์ fib(30) จะมีการเรียกประมาณ 2 ล้านครั้ง แต่เมื่อใช้การจดจำผลลัพธ์จะมีการเรียก 30 ครั้งพอดี
def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n in memo:
return memo[n] # return cached result
if n <= 1:
return n
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
return memo[n]
# Verify speed improvement:
print(fib_memo(30)) # fast!
print(fib_memo(50)) # still fast
print(fib_memo(100)) # no problem
# Without memo, fib_naive(50) would take minutes
# With memo: each of the 50 sub-problems computed onceการใช้ @functools.lru_cache
ตัวตกแต่ง @functools.lru_cache(maxsize=None) ของภาษาไพทอน (หรือชื่อเรียกแทน @cache ในไพทอน 3.9 ขึ้นไป) จะจดจำผลลัพธ์ของฟังก์ชันโดยอัตโนมัติตามอาร์กิวเมนต์ วิธีนี้เป็นวิธีที่สะอาดที่สุดในการเพิ่ม DP จากบนลงล่างระหว่างการสัมภาษณ์งาน เพียงเขียนคำตอบแบบเรียกซ้ำ เติมตัวตกแต่ง แล้วก็เสร็จ ตัวตกแต่งจะเก็บผลลัพธ์ทั้งหมดไว้ในพจนานุกรม โดยใช้ชุดอาร์กิวเมนต์ของฟังก์ชันเป็นกุญแจ ซึ่งอาร์กิวเมนต์เหล่านั้นต้องเป็นชนิดข้อมูลที่ แฮชได้ (ห้ามใช้รายการ ให้ใช้ทูเพิลแทน)
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
print(fib(50)) # 12586269025
print(fib(100)) # works instantly
# Clear cache between tests if needed:
fib.cache_clear()
# Python 3.9+ shorthand:
# from functools import cache
# @cache
# def fib(n): ...
print(fib.cache_info()) # shows hits, misses, maxsize, currsizeการแลกเหรียญจากบนลงล่าง
การแลกเหรียญ (LeetCode #322) คือโจทย์ที่ให้มูลค่าเหรียญและจำนวนเป้าหมาย แล้วให้หาจำนวนเหรียญขั้นต่ำที่ต้องใช้ สูตรแบบเรียกซ้ำคือ ลองใช้เหรียญแต่ละชนิด แล้วแก้ปัญหาจำนวนที่เหลือ จากนั้นเลือกค่าต่ำสุด ใช้การจดจำผลลัพธ์กับจำนวนเงินเพื่อหลีกเลี่ยงการคำนวณซ้ำ กรณีฐานคือ amount=0 ต้องใช้เหรียญ 0 เหรียญ ส่วนจำนวนเงินที่เป็นไปไม่ได้ให้ค่าเป็นอนันต์ (หรือคืนค่า -1 หลังการเรียกซ้ำ)
import functools
def coin_change_top_down(coins, amount):
@functools.lru_cache(maxsize=None)
def dp(remaining):
if remaining == 0:
return 0 # no coins needed
if remaining < 0:
return float('inf') # impossible
# Try each coin and take the minimum
return 1 + min(dp(remaining - c) for c in coins)
result = dp(amount)
return result if result != float('inf') else -1
print(coin_change_top_down([1, 5, 6, 9], 11)) # 2: (5+6) or (2*5+1?no: 9+2?no) 5+6=11 YES
print(coin_change_top_down([2], 3)) # -1: impossible
print(coin_change_top_down([1, 2, 5], 11)) # 3: 5+5+1การปีนบันไดจากบนลงล่างด้วย K ขั้น
ขยายโจทย์การปีนบันไดให้สามารถก้าวได้ครั้งละ 1 ถึง k ขั้น สถานะคือขั้นปัจจุบัน และจากขั้นที่ i คุณสามารถไปถึงขั้น i+1, i+2, ..., i+k ความสัมพันธ์เวียนเกิดคือ dp(i) = sum of dp(i-j) for j in 1..k if i-j >= 0 การจดจำผลลัพธ์ทำให้ความซับซ้อนเป็น O(n*k) แทนที่จะเป็น O(k^n) การขยายแนวคิดนี้ปรากฏในโจทย์อย่างการหาค่าใช้จ่ายขั้นต่ำเพื่อไปถึงขั้นสุดท้าย และการนับวิธีเติมตาราง
import functools
def climb_k_steps(n, k):
@functools.lru_cache(maxsize=None)
def dp(i):
if i == 0:
return 1 # base: one way to stay at ground
if i < 0:
return 0 # impossible
# From stair i, you could have come from i-1, i-2, ..., i-k
return sum(dp(i - j) for j in range(1, k+1) if i - j >= 0)
return dp(n)
# k=2 (original): should match fib-like sequence
print([climb_k_steps(n, 2) for n in range(7)]) # [1,1,2,3,5,8,13]
# k=3: more options
print([climb_k_steps(n, 3) for n in range(7)]) # [1,1,2,4,7,13,24]LCS จากบนลงล่าง: การจดจำผลลัพธ์แบบ 2 มิติ
ลำดับย่อยร่วมที่ยาวที่สุด (LCS)ต้องใช้สถานะแบบ 2 มิติ: dp(i, j) = ความยาวของ LCS ของ s1[:i] และ s2[:j] หาก s1[i-1] == s2[j-1] อักขระจะตรงกัน: dp(i,j) = 1 + dp(i-1, j-1) มิฉะนั้น: dp(i,j) = max(dp(i-1,j), dp(i,j-1)) — ข้ามอักขระหนึ่งตัวจากสตริงใดสตริงหนึ่ง การจดจำผลลัพธ์ตาม (i, j) ให้ความซับซ้อน O(mn) แทน O(2^(m+n))
import functools
def lcs_top_down(s1, s2):
m, n = len(s1), len(s2)
@functools.lru_cache(maxsize=None)
def dp(i, j):
if i == 0 or j == 0:
return 0 # empty prefix has LCS of 0
if s1[i-1] == s2[j-1]:
return 1 + dp(i-1, j-1) # characters match
return max(dp(i-1, j), dp(i, j-1)) # skip one
return dp(m, n)
print(lcs_top_down('abcde', 'ace')) # 3: 'ace'
print(lcs_top_down('abc', 'abc')) # 3: 'abc'
print(lcs_top_down('abc', 'def')) # 0: no common charsพจนานุกรมจดจำผลลัพธ์เทียบกับ lru_cache: ควรเลือกใช้เมื่อใด
ให้ใช้ @lru_cache เมื่ออาร์กิวเมนต์ของฟังก์ชันเป็นชนิดข้อมูลพื้นฐานที่แฮชได้ (int, str, tuple) ให้ใช้พจนานุกรมจดจำผลลัพธ์แบบเขียนเองเมื่อคุณต้องส่งสถานะที่เปลี่ยนแปลงได้ (รายการ พจนานุกรม) โดยแปลงเป็นทูเพิล คุณต้องติดตามว่ากุญแจใดถูกคำนวณแล้ว หรือกำลังอยู่ในเมธอดของคลาสที่ไม่ควรเก็บ self ไว้ในแคช พจนานุกรมจดจำผลลัพธ์แบบเขียนเองมีความชัดเจนมากกว่า และหลีกเลี่ยงปัญหาคลอสเชอร์ที่สังเกตได้ยากในฟังก์ชันช่วยแบบเรียกซ้ำ
# @lru_cache: clean, automatic, O(1) overhead
# Use when: arguments are simple (int, str, tuple)
import functools
@functools.lru_cache(maxsize=None)
def simple_dp(n):
if n <= 1: return n
return simple_dp(n-1) + simple_dp(n-2)
# Manual memo dict: explicit, flexible
# Use when: complex state, need to inspect memo, class methods
def manual_memo_dp(s1, s2):
memo = {}
def dp(i, j):
if (i,j) in memo: return memo[(i,j)]
if i == 0 or j == 0:
return 0
if s1[i-1] == s2[j-1]:
memo[(i,j)] = 1 + dp(i-1, j-1)
else:
memo[(i,j)] = max(dp(i-1,j), dp(i,j-1))
return memo[(i,j)]
return dp(len(s1), len(s2))
print(manual_memo_dp('abcde', 'ace')) # 3ผลรวมเป้าหมายจากบนลงล่าง
ผลรวมเป้าหมาย (LeetCode #494) คือโจทย์ที่ให้กำหนดเครื่องหมาย + หรือ - ให้ตัวเลขแต่ละตัว แล้วนับจำนวนการกำหนดเครื่องหมายที่ทำให้ได้ผลรวมตามเป้าหมาย สถานะคือ dp(index, current_sum) ในแต่ละดัชนี ให้ลองบวก (+) และลบ (-) ตัวเลขปัจจุบัน การจดจำผลลัพธ์ตาม (index, current_sum) เปลี่ยนวิธีลองทุกกรณีที่มีความซับซ้อน O(2^n) ให้เป็น O(n * sum_range) ช่วงผลรวมถูกจำกัดด้วยผลรวมของตัวเลขทั้งหมด จึงมีสถานะทั้งหมด O(n * S)
import functools
def find_target_sum_ways(nums, target):
@functools.lru_cache(maxsize=None)
def dp(index, current_sum):
if index == len(nums):
return 1 if current_sum == target else 0
# Try adding the number
add = dp(index + 1, current_sum + nums[index])
# Try subtracting the number
subtract = dp(index + 1, current_sum - nums[index])
return add + subtract
return dp(0, 0)
print(find_target_sum_ways([1,1,1,1,1], 3)) # 5
print(find_target_sum_ways([1], 1)) # 1
print(find_target_sum_ways([1], -1)) # 1DP จากบนลงล่างเทียบกับจากล่างขึ้นบน: ข้อดีและข้อเสีย
จากบนลงล่าง (การจดจำผลลัพธ์)มีข้อดีคือเขียนได้เป็นธรรมชาติ (เริ่มจากคำตอบแบบเรียกซ้ำ), คำนวณเฉพาะปัญหาย่อยที่จำเป็นจริง (แบบประเมินเมื่อจำเป็น) และเพิ่มแคชทีละส่วนได้ง่าย จากล่างขึ้นบน (การเติมตาราง)มีข้อดีคือไม่มีต้นทุนของสแตกการเรียก (ไม่ติดขีดจำกัดการเรียกซ้ำของไพทอน), การเข้าถึงหน่วยความจำใช้แคชได้มีประสิทธิภาพมากกว่า และปรับลดการใช้พื้นที่ได้ง่ายกว่า ทั้งสองวิธีมีความซับซ้อนเชิงลำดับเดียวกัน ในการสัมภาษณ์งาน ให้เริ่มจากวิธีจากบนลงล่างเพื่อตรวจสอบความถูกต้อง แล้วจึงแปลงเป็นวิธีจากล่างขึ้นบนหากถูกขอให้ใช้พื้นที่น้อยลง
# Top-down advantages:
# + Natural: write recursive, add @cache
# + Lazy: only computes needed sub-problems
# + Easy to reason about correctness
# - Uses call stack (recursion limit in Python)
# - Higher constant factor (function call overhead)
# Bottom-up advantages:
# + No recursion limit
# + Better cache performance (sequential memory)
# + Easier to space-optimise (rolling array)
# - Must compute all sub-problems in order
# - Less intuitive for complex 2D/3D problems
# Interview strategy:
# Start with top-down to verify recurrence,
# convert to bottom-up only if asked.
print('Top-down: easy to write | Bottom-up: efficient for large n')การแยกคำด้วย DP จากบนลงล่าง
การแยกคำ (LeetCode #139) ถามว่าสตริง s สามารถแบ่งเป็นคำจากพจนานุกรมได้หรือไม่ สถานะคือ dp(i) = สตริง s[i:] สามารถแบ่งเป็นคำได้หรือไม่ จากดัชนี i ให้ลองทุกคำ หาก s[i:i+len(w)] == w ให้เรียกซ้ำกับส่วนต่อท้ายที่เหลือ การจดจำผลลัพธ์ตามดัชนีเริ่มต้นเปลี่ยนวิธีลองทุกกรณีที่มีความซับซ้อน O(2^n) ให้เป็น O(n^2) (หรือ O(n * max_word_len)) เมื่อรวมการตรวจสอบสมาชิกในเซต
import functools
def word_break(s, word_dict):
word_set = set(word_dict)
@functools.lru_cache(maxsize=None)
def dp(start):
if start == len(s):
return True # successfully segmented entire string
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and dp(end):
return True
return False
return dp(0)
print(word_break('leetcode', ['leet', 'code'])) # True
print(word_break('applepenapple', ['apple', 'pen'])) # True
print(word_break('catsandog', ['cats', 'dog', 'and', 'cat', 'san', 'andog'])) # Falseขีดจำกัดการเรียกซ้ำและเครื่องมือสำหรับการวนซ้ำ
ขีดจำกัดการเรียกซ้ำเริ่มต้นของภาษาไพทอนคือ 1000 (กำหนดโดย sys.getrecursionlimit()) สำหรับโจทย์ DP ที่มีข้อมูลเข้าขนาดใหญ่ (n = 10,000 ขึ้นไป) การจดจำผลลัพธ์จากบนลงล่างจะชนขีดจำกัดนี้ ทางเลือกคือเพิ่มขีดจำกัดด้วย sys.setrecursionlimit(100000) หรือแปลงเป็น DP จากล่างขึ้นบน ในการเขียนโปรแกรมแข่งขัน การเพิ่มขีดจำกัดเป็นเรื่องปกติ แต่ในโค้ดสำหรับใช้งานจริง ควรเลือกคำตอบแบบจากล่างขึ้นบนหรือแบบวนซ้ำเสมอเพื่อความน่าเชื่อถือ
import sys
print('Default recursion limit:', sys.getrecursionlimit()) # 1000
# For large DP problems, increase if needed:
# sys.setrecursionlimit(100000)
# Better: convert to bottom-up DP for large n
def fib_bottom_up(n):
if n <= 1: return n
a, b = 0, 1
for _ in range(2, n+1):
a, b = b, a + b
return b
# No recursion limit issue:
print(fib_bottom_up(10000)) # works fine, no recursionตรวจสอบความเข้าใจ
ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูล & อัลกอริทึม — การเตรียมตัวสำหรับการสัมภาษณ์เขียนโปรแกรมจากบทเรียนนี้
สรุปบทเรียน
ในบทเรียนนี้ คุณได้เรียนรู้เกี่ยวกับ: DP จากบนลงล่างด้วยพจนานุกรมจดจำผลลัพธ์และตัวตกแต่ง @lru_cache, คำตอบที่ใช้การจดจำผลลัพธ์สำหรับ ฟีโบนักชี การแลกเหรียญ LCS ผลรวมเป้าหมาย และการแยกคำ รวมถึงเวลาที่ควรเลือกวิธีจากบนลงล่างแทนวิธีจากล่างขึ้นบน ต่อไปเราจะนำ DP จากล่างขึ้นบนมาเขียนด้วยการเติมตารางและการปรับปรุงการใช้พื้นที่
คำถามที่พบบ่อย
บทเรียน “DP จากบนลงล่างด้วยการจดจำผลลัพธ์” ฟรีหรือไม่
ใช่ — ข้อความเต็มของ “DP จากบนลงล่างด้วยการจดจำผลลัพธ์” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส Coding Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
คุณจะเรียนรู้อะไรในบทเรียน “DP จากบนลงล่างด้วยการจดจำผลลัพธ์”
เพิ่มดิกชันนารี memo ให้คำตอบแบบเรียกซ้ำเพื่อตัดการเรียกซ้ำ และใช้ @lru_cache เพื่อจดจำผลลัพธ์ด้วยโค้ดเพียงเล็กน้อย คุณปฏิบัติ Coding Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน
คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน Coding Interview Prep หรือไม่
ไม่จำเป็นต้องมีประสบการณ์มาก่อน Coding Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน
บทเรียน “DP จากบนลงล่างด้วยการจดจำผลลัพธ์” ใช้เวลานานแค่ไหน
บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย
ฉันเขียนและรันโค้ดในบทเรียน Coding Interview Prep นี้ได้ไหม
ได้ บทเรียน Coding Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ
บทเรียนทั้งหมดในหลักสูตรนี้
- รู้จัก DP: ปัญหาย่อยที่ซ้ำซ้อน
- DP จากบนลงล่างด้วยการจดจำผลลัพธ์
- DP จากล่างขึ้นบนด้วยตาราง
- การทอนเหรียญและบันไดต้นทุนต่ำสุด