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

DP จากล่างขึ้นบนด้วยตาราง

แปลงคำตอบจากบนลงล่างเป็นตาราง DP แบบวนซ้ำ และลดพื้นที่จาก O(n) เป็น O(1) เมื่อจำเป็นต้องเก็บเพียงค่าบางรายการล่าสุด

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

DP จากล่างขึ้นบน: แนวทางการเติมตาราง

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

# Converting top-down to bottom-up:
# Top-down: start at fib(n), recurse to smaller, cache
# Bottom-up: start at fib(0), fill table to fib(n)

# Key question for bottom-up:
# 'In what order do I fill the table so that when I compute dp[i],
# all values dp[i] depends on are already filled?'
# For Fibonacci: dp[i] needs dp[i-1] and dp[i-2]
# Fill order: i = 2, 3, 4, ..., n (left to right)
print('Bottom-up: fill small sub-problems first, build to answer')

ฟีโบนักชีจากล่างขึ้นบน

ฟีโบนักชีแบบจากล่างขึ้นบนจะเติม dp[0..n] จากซ้ายไปขวา โดย dp[i] = dp[i-1] + dp[i-2] สำหรับ i >= 2 กรณีฐานคือ dp[0] = 0 และ dp[1] = 1 ซึ่งเก็บไว้โดยตรงในอาร์เรย์ ความซับซ้อนด้านเวลาคือ O(n) และความซับซ้อนด้านพื้นที่คือ O(n) สำหรับตารางเต็ม เมื่อเห็นว่า dp[i] พึ่งพาเพียงค่าสองค่าล่าสุด คุณสามารถลดพื้นที่เหลือ O(1) ด้วยตัวแปรสองตัวได้ ซึ่งเป็นขั้นตอนการปรับปรุงการใช้พื้นที่

def fib_bottom_up(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0] = 0  # base case
    dp[1] = 1  # base case
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

print([fib_bottom_up(i) for i in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

# Space-optimised to O(1):
def fib_optimised(n):
    if n <= 1: return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

print(fib_optimised(50))  # 12586269025

การแลกเหรียญจากล่างขึ้นบน

สำหรับ การแลกเหรียญ ตารางจากล่างขึ้นบนคือ dp[0..amount] โดย dp[i] = จำนวนเหรียญขั้นต่ำที่ใช้สร้างจำนวนเงิน i กำหนดค่าเริ่มต้นให้ dp[0] = 0 (จำนวนเงินเป็นศูนย์ใช้เหรียญศูนย์เหรียญ) และ dp[1..amount] = infinity สำหรับจำนวนเงิน i ตั้งแต่ 1 ถึงเป้าหมาย ให้ลองเหรียญแต่ละชนิด หาก i >= coin ให้คำนวณ dp[i] = min(dp[i], 1 + dp[i - coin]) คำตอบคือ dp[amount] หรือ -1 หากค่ายังคงเป็น infinity

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0  # base case: 0 coins for amount 0
    for i in range(1, amount + 1):
        for coin in coins:
            if i >= coin:  # can use this coin
                dp[i] = min(dp[i], 1 + dp[i - coin])
    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change([1, 5, 6, 9], 11))  # 2: (5+6)
print(coin_change([2], 3))             # -1: impossible
print(coin_change([1, 2, 5], 11))      # 3: 5+5+1
print(coin_change([186, 419, 83, 408], 6249))  # 20

ลำดับการเติมค่า: ข้อสังเกตสำคัญ

ลำดับการเติมค่าคือหัวใจของ DP จากล่างขึ้นบน สำหรับสถานะ dp[i] ใด ๆ ต้องคำนวณสถานะทั้งหมดที่สถานะนั้นพึ่งพาให้เสร็จก่อน สำหรับ DP แบบ 1 มิติที่ dp[i] พึ่งพา dp[i-1] และ dp[i-2] ให้เติมค่าจากซ้ายไปขวา สำหรับ DP แบบ 2 มิติที่ dp[i][j] พึ่งพา dp[i-1][j] และ dp[i][j-1] ให้เติมค่าทีละแถว (จากบนลงล่าง และจากซ้ายไปขวา) ควรวาดลูกศรแสดงการพึ่งพาก่อนเขียนโค้ดเสมอ เพื่อยืนยันลำดับการเติมค่า

# Fill order examples:

# 1D: dp[i] = f(dp[i-1], dp[i-2])
# Arrows point LEFT: fill LEFT TO RIGHT
# i: 0 -> 1 -> 2 -> ... -> n

# 2D: dp[i][j] = f(dp[i-1][j], dp[i][j-1])
# Arrows point LEFT and UP: fill TOP-LEFT TO BOTTOM-RIGHT
# Fill row 0 first, then row 1, etc.

# 2D reversed: dp[i][j] = f(dp[i+1][j], dp[i][j+1])
# Arrows point RIGHT and DOWN: fill BOTTOM-RIGHT TO TOP-LEFT
# Used in interval DP and some string problems

print('Draw dependencies first, then determine fill order')

LCS จากล่างขึ้นบน: ตารางแบบ 2 มิติ

ตารางจากล่างขึ้นบนของ ลำดับย่อยร่วมที่ยาวที่สุดมีขนาด (m+1) × (n+1) โดย dp[i][j] = LCS ของ s1[:i] และ s2[:j] กรณีฐานคือ dp[0][j] = dp[i][0] = 0 (สตริงว่างมีค่า LCS เท่ากับ 0 เมื่อเทียบกับสิ่งใดก็ตาม) เติมค่าทีละแถว: หาก 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]) คำตอบคือ dp[m][n]

def lcs_bottom_up(s1, s2):
    m, n = len(s1), len(s2)
    # (m+1) x (n+1) table, initialised to 0
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:         # characters match
                dp[i][j] = 1 + dp[i-1][j-1]
            else:                            # skip one character
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    return dp[m][n]

print(lcs_bottom_up('abcde', 'ace'))   # 3
print(lcs_bottom_up('ABCBDAB', 'BDCAB'))  # 4: 'BCAB' or 'BDAB'

การปรับปรุงการใช้พื้นที่: อาร์เรย์แบบเลื่อน

ตาราง DP แบบ 2 มิติจำนวนมากสามารถลดเหลือ 1 มิติ (หรือ 2 แถว) ได้โดยสังเกตว่า dp[i][j] พึ่งพาเพียงแถวปัจจุบันและแถวก่อนหน้า เก็บอาร์เรย์สองชุดคือ prev และ curr หรืออัปเดตอาร์เรย์ชุดเดียวตามลำดับที่ถูกต้อง สำหรับ LCS dp[i][j] พึ่งพา dp[i-1][j], dp[i][j-1] และ dp[i-1][j-1] ดังนั้นการเก็บไว้เพียงแถวก่อนหน้าก็เพียงพอ

def lcs_space_optimised(s1, s2):
    m, n = len(s1), len(s2)
    # Keep only one row (previous row state)
    prev = [0] * (n + 1)
    for i in range(1, m + 1):
        curr = [0] * (n + 1)
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                curr[j] = 1 + prev[j-1]  # dp[i-1][j-1]
            else:
                curr[j] = max(prev[j], curr[j-1])  # dp[i-1][j] and dp[i][j-1]
        prev = curr
    return prev[n]

print(lcs_space_optimised('abcde', 'ace'))   # 3
# Space: O(n) instead of O(mn)

โจรปล้นบ้านจากล่างขึ้นบน

โจรปล้นบ้านแบบจากล่างขึ้นบนจะเติม dp[0..n-1] โดย dp[i] = ผลประโยชน์สูงสุดจากการขโมยบ้านตั้งแต่ 0 ถึง i dp[0] = nums[0], dp[1] = max(nums[0], nums[1]) และสำหรับ i >= 2: dp[i] = max(dp[i-1], dp[i-2] + nums[i]) เนื่องจาก dp[i] พึ่งพาเพียงค่าสองค่าล่าสุด จึงปรับปรุงการใช้พื้นที่ให้เหลือ O(1) ได้ทันทีด้วยตัวแปรสองตัว ซึ่งเป็นรูปแบบที่พบบ่อยสำหรับ DP แบบ 1 มิติที่มีการพึ่งพาค่าก่อนหน้าสองขั้น

def rob_bottom_up(nums):
    if not nums: return 0
    if len(nums) == 1: return nums[0]

    # Full table version: O(n) space
    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], dp[i-2] + nums[i])
    return dp[-1]

def rob_optimised(nums):
    # O(1) space: only need last two values
    if not nums: return 0
    if len(nums) == 1: return nums[0]
    prev2, prev1 = nums[0], max(nums[0], nums[1])
    for i in range(2, len(nums)):
        prev2, prev1 = prev1, max(prev1, prev2 + nums[i])
    return prev1

print(rob_optimised([2, 7, 9, 3, 1]))  # 12

ผลรวมเส้นทางต่ำสุดในตาราง

ผลรวมเส้นทางต่ำสุด (LeetCode #64) คือโจทย์ที่ให้หาเส้นทางจากมุมซ้ายบนไปยังมุมขวาล่าง โดยทำให้ผลรวมของค่าต่าง ๆ ต่ำที่สุด (เคลื่อนที่ได้เฉพาะไปทางขวาหรือลงด้านล่าง) DP แบบ 2 มิติ: dp[i][j] = ผลรวมต่ำสุดเพื่อไปถึงช่อง (i,j) dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) เติมค่าจากซ้ายไปขวาและจากบนลงล่าง กรณีฐานคือ dp[0][0] = grid[0][0] แถวแรกเติมค่าโดยเคลื่อนไปทางขวาเท่านั้น และคอลัมน์แรกเติมค่าโดยเคลื่อนลงด้านล่างเท่านั้น

def min_path_sum(grid):
    rows, cols = len(grid), len(grid[0])
    dp = [[0] * cols for _ in range(rows)]
    dp[0][0] = grid[0][0]
    # Fill first row (can only come from left)
    for c in range(1, cols):
        dp[0][c] = dp[0][c-1] + grid[0][c]
    # Fill first column (can only come from above)
    for r in range(1, rows):
        dp[r][0] = dp[r-1][0] + grid[r][0]
    # Fill rest of the table
    for r in range(1, rows):
        for c in range(1, cols):
            dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1])
    return dp[rows-1][cols-1]

grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum(grid))  # 7: 1+3+1+1+1

การแก้ไขตาราง DP ในข้อมูลต้นฉบับ

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

def min_path_sum_inplace(grid):
    rows, cols = len(grid), len(grid[0])
    # Modify grid in-place (O(1) extra space, destroys input)
    for r in range(rows):
        for c in range(cols):
            if r == 0 and c == 0:
                continue  # starting cell
            elif r == 0:
                grid[r][c] += grid[r][c-1]  # first row
            elif c == 0:
                grid[r][c] += grid[r-1][c]  # first column
            else:
                grid[r][c] += min(grid[r-1][c], grid[r][c-1])
    return grid[rows-1][cols-1]

import copy
grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum_inplace(copy.deepcopy(grid)))  # 7

เปรียบเทียบ DP แบบจากบนลงล่างกับแบบจากล่างขึ้นบนในการทอนเหรียญ

ทั้งสองแนวทางแก้ปัญหาการทอนเหรียญได้อย่างเหมาะสมที่สุด แต่มีความแตกต่างกันในการใช้งานจริง แบบจากบนลงล่างเขียนได้ชัดเจนกว่า และคำนวณเฉพาะปัญหาย่อยที่เข้าถึงได้จริงเท่านั้น ส่วนแบบจากล่างขึ้นบนจะคำนวณจำนวนเงินทั้งหมดตั้งแต่ 0 ถึงเป้าหมาย แม้แต่จำนวนที่ไม่สามารถทำได้ด้วยเหรียญที่กำหนด ซึ่งจะยังคงมีค่าเป็นอนันต์ สำหรับปัญหาแบบเบาบางที่มีสถานะเข้าถึงได้ไม่มาก แบบจากบนลงล่างจะมีประสิทธิภาพกว่า ส่วนปัญหาแบบหนาแน่น แบบจากล่างขึ้นบนจะมีค่าใช้จ่ายส่วนเกินต่ำกว่า

import functools

# Top-down: only computes reachable amounts
def coin_change_top(coins, amount):
    @functools.lru_cache(maxsize=None)
    def dp(rem):
        if rem == 0: return 0
        if rem < 0: return float('inf')
        return 1 + min(dp(rem - c) for c in coins)
    r = dp(amount)
    return r if r != float('inf') else -1

# Bottom-up: computes all amounts 0 to target
def coin_change_bottom(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for c in coins:
            if i >= c: dp[i] = min(dp[i], 1 + dp[i-c])
    return dp[amount] if dp[amount] != float('inf') else -1

print(coin_change_top([1,5,6,9], 11))    # 2
print(coin_change_bottom([1,5,6,9], 11)) # 2

เส้นทางไม่ซ้ำกัน: DP สองมิติแบบคลาสสิก

เส้นทางไม่ซ้ำกัน (LeetCode #62) ให้หาจำนวนเส้นทางจากมุมซ้ายบนไปยังมุมขวาล่างของตารางขนาด m×n โดยเคลื่อนที่ได้เฉพาะไปทางขวาหรือด้านล่าง สูตรเวียนเกิดนั้นตรงไปตรงมา: dp[i][j] = dp[i-1][j] + dp[i][j-1] ซึ่งหมายถึงเส้นทางจากด้านบนบวกกับเส้นทางจากด้านซ้าย กรณีฐานคือ แถวแรกทั้งหมดและคอลัมน์แรกทั้งหมดมีเส้นทางได้เพียง 1 เส้นทาง เพราะมีทิศทางให้เดินได้เพียงทางเดียว DP สองมิตินี้ใช้เวลา O(mn) และลดพื้นที่ได้เหลือ O(n) ด้วยแถวแบบเลื่อน

def unique_paths(m, n):
    # dp[i][j] = number of paths to reach cell (i,j)
    dp = [[1] * n for _ in range(m)]
    # Base: first row and first column are all 1
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[m-1][n-1]

print(unique_paths(3, 7))   # 28
print(unique_paths(3, 2))   # 3

# O(n) space rolling row:
def unique_paths_opt(m, n):
    row = [1] * n
    for _ in range(1, m):
        for j in range(1, n):
            row[j] += row[j-1]
    return row[n-1]

print(unique_paths_opt(3, 7))  # 28

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

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

สรุปบทเรียน

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

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

บทเรียน “DP จากล่างขึ้นบนด้วยตาราง” ฟรีหรือไม่

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

คุณจะเรียนรู้อะไรในบทเรียน “DP จากล่างขึ้นบนด้วยตาราง”

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

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

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

บทเรียน “DP จากล่างขึ้นบนด้วยตาราง” ใช้เวลานานแค่ไหน

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

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

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

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

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