เหรียญหลายขนาดวางบนโต๊ะมิ้นต์สว่าง เรียงจากน้อยไปมากเพื่อแลกเป็นยอดรวม

LeetCode #322 Coin Change: แลกเหรียญให้น้อยสุดด้วย DP

น้องกายEngineering

เคยยืนที่ตู้กดน้ำ แล้วอยากทอนเงินด้วยเหรียญให้น้อยชิ้นที่สุดไหม โจทย์ coin-change ให้ชุดมูลค่าเหรียญและยอด amount แล้วหาจำนวนเหรียญน้อยสุดที่รวมได้พอดี ถ้าทำไม่ได้ให้ตอบ -1 เหรียญแต่ละชนิดใช้กี่ครั้งก็ได้

วิธีที่ 1: greedy หยิบเหรียญใหญ่ก่อน

เรียงเหรียญจากมากไปน้อยแล้วหยิบทีละเหรียญที่ใหญ่สุดที่ยังไม่เกินยอด เวลาประมาณ O(k log k + k) ตามชนิดเหรียญ ข้อดีคือสั้นและถูกกับเหรียญมาตรฐานบางระบบ ข้อเสียใหญ่: ไม่ถูกต้องทั่วไป เช่น coins = [1, 3, 4], amount = 6 greedy ได้ 4+1+1 (3 เหรียญ) แต่คำตอบจริงคือ 3+3 (2 เหรียญ)

วิธีที่ 2: BFS ตามจำนวนเหรียญ

มองแต่ละยอดเงินเป็นโหนด เริ่มจาก 0 แล้วขยายด้วยการบวกเหรียญทีละชนิด ชั้นของ BFS คือจำนวนเหรียญที่ใช้ เมื่อถึง amount ครั้งแรกได้คำตอบน้อยสุด เวลา O(amount × k) พื้นที่ O(amount) เหมาะเมื่ออยากคิดเป็น shortest path และตัดยอดซ้ำด้วย visited set

วิธีที่ 3: bottom-up DP (แนะนำหลัก)

ให้ dp[x] = จำนวนเหรียญน้อยสุดที่ทำยอด x ได้ ตั้ง dp[0] = 0 และค่าอื่นเป็น infinity แล้วสำหรับทุกยอด x ลองทุกเหรียญ c อัปเดต dp[x] = min(dp[x], dp[x - c] + 1) เมื่อ x >= c เวลา O(amount × k) พื้นที่ O(amount) หากจบแล้วยัง infinity แปลว่าแลกไม่ได้ ให้คืน -1

Invariant: หลังประมวลผลถึง x ค่า dp[x] เป็นคำตอบที่ถูกต้องของย่อยนั้นแล้ว จึงสร้างคำตอบใหญ่จากคำตอบเล็กได้อย่างปลอดภัย

วิธีที่ 4: top-down memoization

เขียนฟังก์ชัน f(remain) ที่ลองลดทุกเหรียญแล้วเก็บผลใน memo Base เป็น 0 → 0 และติดลบเป็นเป็นไปไม่ได้ เวลา/พื้นที่เทียบเท่า bottom-up แต่ recursion ลึกอาจชน limit ใน Python เมื่อ amount ใหญ่ ควรใช้เมื่ออยากอ่านโค้ดแบบ “ถามยอดที่เหลือ”

def coin_change(coins, amount):
    INF = amount + 1
    dp = [0] + [INF] * amount
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x:
                dp[x] = min(dp[x], dp[x - c] + 1)
    return -1 if dp[amount] == INF else dp[amount]


samples = [
    ([1, 2, 5], 11, 3),
    ([2], 3, -1),
    ([1], 0, 0),
    ([1, 3, 4], 6, 2),
    ([2, 5, 10, 1], 27, 4),
]
for coins, amount, expected in samples:
    got = coin_change(coins, amount)
    assert got == expected, (coins, amount, got, expected)
print("ok", len(samples), "cases")

เลือกตามสถานการณ์

สถานการณ์วิธีที่เหมาะเหตุผลสั้นๆ
เหรียญ canonical ที่พิสูจน์แล้วว่า greedy ถูกgreedyเร็วและโค้ดสั้น
มองเป็นระยะทางสั้นสุดBFSได้คำตอบแรกคือจำนวนน้อยสุด
สัมภาษณ์ / ใช้ทั่วไปbottom-up DPถูกต้องเสมอใน O(amount × k)
อ่านจากยอดที่เหลือลงมาtop-down memoโครงใกล้คำถามโจทย์

เคสที่ต้องเช็ก: amount = 0 ตอบ 0, เหรียญใหญ่กว่ายอดทั้งหมด, มีเหรียญ 1 หรือไม่มี, และชุดที่ greedy พลาดอย่าง [1, 3, 4] อย่าคืน 0 เมื่อแลกไม่ได้ — โจทย์ต้องการ -1

Recap

  • อย่าเชื่อ greedy จนกว่าจะพิสูจน์ระบบเหรียญ
  • ตั้ง dp[0] = 0 แล้วเติมทุกยอดด้วยเหรียญทุกชนิด
  • ถ้าค่าสุดท้ายยัง infinity ให้ตอบ -1
  • เทียบกับ BFS หรือ brute บน amount เล็กก่อนเชื่อ
  • default เลือก bottom-up DP ครับ