
LeetCode #70 Climbing Stairs: ก้าว 1 หรือ 2 แล้วจำผลย่อย
นึกถึงบันไดออฟฟิศที่ก้าวได้ทีละ 1 หรือ 2 ขั้น แล้วอยากรู้ว่ามีกี่วิธีขึ้นถึงชั้นบน โจทย์ climbing-stairs ถามจำนวนวิธีขึ้น n ขั้น โดยแต่ละก้าวเลือก 1 หรือ 2 เท่านั้น ลำดับก้าวต่างกันนับคนละวิธี
วิธีที่ 1: recursion ตรงนิยาม
จำนวนวิธีถึงขั้น n เท่ากับวิธีถึง n-1 บวกวิธีถึง n-2 เพราะก้าวสุดท้ายมาจากขั้นก่อนหน้าหนึ่งหรือสองขั้น Base case คือ n = 1 ได้ 1 ทาง และ n = 2 ได้ 2 ทาง เวลา O(2ⁿ) พื้นที่ call stack O(n) อธิบายง่าย แต่ช้ามากเมื่อ n โต
วิธีที่ 2: top-down memoization
เก็บผลของแต่ละ n ไว้ใน dict หรืออาร์เรย์ แล้ว recurse เหมือนเดิม เมื่อเจอค่าที่เคยคำนวณแล้วคืนทันที เวลา O(n) พื้นที่ O(n) ดีตอนอยากคงโครง recursion และ debug จากบนลงล่าง
วิธีที่ 3: bottom-up DP สองตัวแปร (แนะนำหลัก)
เดินจากเล็กไปใหญ่ เก็บแค่สองค่าล่าสุด a, b แทน ways(i-2) กับ ways(i-1) แล้วอัปเดต a, b = b, a + b เวลา O(n) พื้นที่ O(1) อ่านง่ายและพอสำหรับ constraints ของ LeetCode
นี่คือลำดับ Fibonacci นั่นเองครับ แค่เลื่อน index ให้ตรงกับจำนวนขั้น
วิธีที่ 4: สูตรปิด / matrix
ใช้สูตร Binet หรือยกกำลังเมทริกซ์ของ Fibonacci ได้ใกล้ O(log n) แต่ต้องระวังความแม่นยำของทศนิยมและขอบเขตจำนวนเต็ม เหมาะเมื่อ n ใหญ่มากและยอมรับความซับซ้อนเพิ่ม ไม่จำเป็นในสัมภาษณ์ข้อนี้
def climb_stairs(n):
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
samples = [(1, 1), (2, 2), (3, 3), (4, 5), (5, 8), (10, 89)]
for n, expected in samples:
got = climb_stairs(n)
assert got == expected, (n, got, expected)
print("ok", len(samples), "cases")
เลือกตามสถานการณ์
| สถานการณ์ | วิธีที่เหมาะ | เหตุผลสั้นๆ |
|---|---|---|
| อธิบาย recurrence ครั้งแรก | plain recursion | เห็น base case ชัด |
| อยากคงโครง recursive | memoization | O(n) โดยไม่เขียน loop |
| สัมภาษณ์ / โค้ดสั้น | bottom-up สองตัวแปร | O(n)/O(1) และพิสูจน์ง่าย |
n ใหญ่มากในระบบจริง | matrix / สูตรปิด | ลดรอบเหลือประมาณ log n |
หลุมที่ผมเคยตก: ตั้ง base case ผิด (f(0)=1 กับ f(1)=1 สลับ index), overflow ถ้าภาษาจำกัด int และลืมว่าลำดับก้าวสำคัญ ไม่ใช่แค่จำนวนก้าว 1 กับ 2
รู้จักผู้เข้าแข่งขัน
4 วิธีข้างบนยังแตกได้อีกนิดครับ bottom-up มีสองแบบ แบบแรกเก็บคำตอบทุกขั้นไว้ในตาราง dp อีกแบบเก็บแค่สองค่าล่าสุด รอบนี้ผมเลยแยกเป็น 5 วิธี ให้สัตว์ 5 ตัวมาเถียงกันว่าใครเร็วสุดที่บันได 45 ขั้น (ค่าสูงสุดที่โจทย์ยอมให้) แล้วเอา benchmark ที่วัดจริงมาตัดสิน ปิดท้ายด้วยบารอนสรุป
| ตัวละคร | วิธี | นิสัย |
|---|---|---|
| น้องแฮม แฮมสเตอร์สีส้มทอง คาดผ้าคาดหัวแดง เสื้อกล้ามสีฟ้า รองเท้าผ้าใบแดง | recursion ตรงนิยาม | ไฮเปอร์ วิ่งไม่หยุด มั่นใจว่าตัวเองถูกเสมอ |
| ป้าช้าง ช้างใส่คาร์ดิแกนไหมพรมสีม่วงอ่อน แว่นกลม ถือสมุดจดกับดินสอ | memoization (จำผลย่อย) | ความจำดีแบบช้าง อะไรเคยนับแล้วจดลงสมุดหมด |
| พี่ผึ้ง ผึ้งตัวกลมใส่ผ้ากันเปื้อนสีมิ้นต์ ถือแผ่นรังผึ้งหกเหลี่ยม | DP ตาราง (bottom-up เก็บทุกขั้น) | ขยัน เป็นระเบียบ เติมรังทีละช่องจากล่างขึ้นบน |
| ลิงจั๊ก ลิงใส่เสื้อลายขวางส้มขาว หมวกแก๊ปกลับหลัง โยนลูกบอลสองลูก | สองตัวแปร | ขี้เล่น ถือของแค่สองชิ้นก็พอ |
| จิงโจ้ จิงโจ้ใส่แจ็กเก็ตวอร์มสีเขียวอมฟ้า คาดผ้าคาดหัวขาว | ยกกำลังเมทริกซ์ | สายกระโดดไกล ชอบทางลัด |
| บารอน สิงโตทะเลใส่โค้ทกรมท่า เสื้อคอเต่าสีเทาอมเขียว | กรรมการ | เข้มแต่แฟร์ ฟังตัวเลขมากกว่าเสียงดัง |

เถียงกันทีละยก
ยกที่ 1: น้องแฮม เรียกตัวเองไปเลย
น้องแฮม: “ง่ายจะตาย! วิธีขึ้นขั้น n ก็คือวิธีขึ้นขั้นก่อนหน้า บวกวิธีขึ้นสองขั้นก่อน เรียกตัวเองไปเลย!”
ป้าช้าง: “หนูรู้ตัวไหม ที่ 45 ขั้น หนูเรียกฟังก์ชันไปกว่า 2 พันล้านครั้ง นับขั้นเดิมซ้ำไปซ้ำมา”
น้องแฮม: “ก็หนูวิ่งเร็วนี่ป้า”
ตัวเลขนี้ไม่ได้แต่งครับ ways(45) เรียก ways(44) กับ ways(43) แล้ว ways(44) ก็เรียก ways(43) ซ้ำอีกรอบ ไล่ลงไปเรื่อยๆ ways(43) ถูกคิด 2 รอบ ways(42) 3 รอบ ways(41) 5 รอบ จำนวนรอบที่ซ้ำโตเป็นลำดับ Fibonacci เหมือนคำตอบเลย รวมทั้งหมดที่ n = 45 คือ 2,269,806,339 ครั้ง (test ด้านล่างนับจากการเรียกจริงตอน n เล็ก แล้วเทียบกับสูตรที่ใช้คิดเลขนี้)
ยกที่ 2: ป้าช้าง จดลงสมุด
ป้าช้าง: “ป้าจดไว้ทุกขั้น ขั้นไหนเคยนับแล้วก็เปิดสมุดตอบเลย ไม่ต้องนับใหม่”
พี่ผึ้ง: “แต่ป้ายังเรียกตัวเองซ้อนกันลึกสี่สิบกว่าชั้นอยู่ดีนะคะ”
ป้าช้าง: “ลึกแค่นี้สบายมาก”
memoization คือจำผลของฟังก์ชันไว้ เรียกด้วยค่าเดิมอีกก็คืนค่าที่จำไว้ทันที ใน Python ใช้ functools.lru_cache แปะบนฟังก์ชันได้เลย แต่ละขั้นคิดแค่ครั้งเดียว เหลือเวลา O(n) ที่ต้องระวังคือป้าช้างยังเป็น recursion อยู่ ถ้า n เป็นหลักพันจะชนเพดานความลึกของ Python ซึ่งค่าเริ่มต้นคือ 1,000 ชั้น (ดูได้จาก sys.getrecursionlimit()) แล้วได้ RecursionError แทนคำตอบ test ด้านล่างลองที่ n = 5000 ให้เห็น
ยกที่ 3: พี่ผึ้ง เติมรังทีละช่อง
พี่ผึ้ง: “ไม่ต้องเรียกตัวเองเลยค่ะ เติมรังจากช่อง 1 ขึ้นไปทีละช่อง ช่องใหม่ก็เอาสองช่องข้างล่างมาบวกกัน”
ลิงจั๊ก: “แล้วพี่จะเก็บทั้งรังไว้ทำไม ช่องใหม่ใช้แค่สองช่องล่าสุดเอง”
พี่ผึ้ง: “เผื่อมีคนถามคำตอบขั้นอื่นไง”
dp เป็น list ยาว n + 1 ช่อง ช่อง dp[i] คือจำนวนวิธีขึ้นขั้น i เวลา O(n) ที่เพิ่ม O(n) ไม่มี recursion เลยไม่ชนเพดานแบบป้าช้าง และได้คำตอบของทุกขั้นติดมือมาด้วย ถ้าโจทย์ถามต่อว่า “ขอจำนวนวิธีของทุกขั้นตั้งแต่ 1 ถึง n” พี่ผึ้งตอบได้ทันที
ยกที่ 4: ลิงจั๊ก โยนแค่สองลูก
ลิงจั๊ก: “เห็นไหม ผมโยนลูกบอลแค่สองลูก ลูกเก่าส่งต่อ ลูกใหม่คือผลบวกของสองลูก
a, b = b, a + bจบ”จิงโจ้: “ยังต้องเดินทีละขั้นอยู่ดี ช้าไป!”
นี่คือวิธีที่ 3 ข้างบนครับ เก็บแค่สองค่าล่าสุด เวลา O(n) ที่เพิ่ม O(1) โค้ดสั้น พิสูจน์ง่าย
ยกที่ 5: จิงโจ้ กระโดดทีละเท่าตัว
จิงโจ้: “ผมไม่เดินทีละขั้นหรอก ยกกำลังเมทริกซ์ กระโดดทีละเท่าตัว 45 ขั้นวนแค่ 6 รอบ!”
ลิงจั๊ก: “รอบน้อยก็จริง แต่แต่ละรอบพี่แบกของหนักนะ”
เมทริกซ์ [[1, 1], [1, 0]] ยกกำลัง k จะได้เลข Fibonacci ตัวที่ k อยู่ในช่องขวาบน ยกกำลังแบบยกกำลังสองซ้ำ (เลขชี้กำลังลดครึ่งทุกรอบ) จึงใช้แค่ประมาณ log n รอบ ที่ n = 45 โค้ดนี้หา F(46) วน 6 รอบ แต่คูณเมทริกซ์ 2×2 รวม 10 ครั้ง ครั้งละคูณเลข 8 ตัว และสร้าง tuple ใหม่ทุกครั้ง ส่วนลิงจั๊กแค่บวกเลข 43 ครั้ง จำตรงนี้ไว้ เดี๋ยวได้ใช้ตอนดูผล
โค้ดทั้ง 5 วิธีพร้อม test
บล็อกนี้รันจริงตอนตรวจบทความ ทดสอบทุกวิธีกับตัวอย่าง เช็กว่าทุกวิธีตอบตรงกันตั้งแต่ 1 ถึง 25 ขั้น เช็กคำตอบที่ 45 ขั้น นับจำนวนครั้งที่น้องแฮมเรียกตัวเอง และลองให้ป้าช้างชนเพดาน recursion
import unittest
from functools import lru_cache
def climb_recursive(n):
if n <= 2:
return n
return climb_recursive(n - 1) + climb_recursive(n - 2)
def climb_memo(n):
@lru_cache(maxsize=None)
def ways(k):
if k <= 2:
return k
return ways(k - 1) + ways(k - 2)
return ways(n)
def climb_table(n):
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1], dp[2] = 1, 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
def climb_two_vars(n):
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
def climb_matrix(n):
def mul(a, b):
return ((a[0][0] * b[0][0] + a[0][1] * b[1][0], a[0][0] * b[0][1] + a[0][1] * b[1][1]),
(a[1][0] * b[0][0] + a[1][1] * b[1][0], a[1][0] * b[0][1] + a[1][1] * b[1][1]))
result, base, k = ((1, 0), (0, 1)), ((1, 1), (1, 0)), n + 1
while k:
if k & 1:
result = mul(result, base)
base = mul(base, base)
k >>= 1
return result[0][1] # F(n + 1) = ways(n)
ALL = [climb_recursive, climb_memo, climb_table, climb_two_vars, climb_matrix]
def calls_recursive(n):
"""Count how many calls plain recursion makes for n."""
if n <= 2:
return 1
return 1 + calls_recursive(n - 1) + calls_recursive(n - 2)
def calls_loop(n):
"""Same count without recursion: calls(n) = 1 + calls(n - 1) + calls(n - 2)."""
a, b = 1, 1
for _ in range(3, n + 1):
a, b = b, 1 + a + b
return b
class TestClimbStairs(unittest.TestCase):
def test_examples(self):
samples = [(1, 1), (2, 2), (3, 3), (4, 5), (5, 8), (10, 89)]
for fn in ALL:
for n, expected in samples:
with self.subTest(fn=fn.__name__, n=n):
self.assertEqual(fn(n), expected)
def test_all_agree(self):
for n in range(1, 26):
self.assertEqual(len({fn(n) for fn in ALL}), 1, n)
def test_max_n(self):
# plain recursion at n = 45 takes tens of seconds, so it is skipped here
for fn in ALL[1:]:
self.assertEqual(fn(45), 1_836_311_903)
def test_recursive_call_count(self):
for n in range(1, 25):
self.assertEqual(calls_recursive(n), calls_loop(n))
self.assertEqual(calls_loop(45), 2_269_806_339)
def test_answer_fits_int32_up_to_45(self):
self.assertLess(climb_two_vars(45), 2**31 - 1)
self.assertGreater(climb_two_vars(46), 2**31 - 1)
def test_memo_hits_recursion_limit(self):
with self.assertRaises(RecursionError):
climb_memo(5000)
self.assertEqual(climb_table(5000), climb_two_vars(5000))
self.assertEqual(climb_matrix(5000), climb_two_vars(5000))
if __name__ == "__main__":
unittest.main()
test_answer_fits_int32_up_to_45 มีไว้ตอบหลุม overflow ที่เล่าไว้ข้างบนครับ คำตอบที่ 45 ขั้นคือ 1,836,311,903 ยังไม่เกิน int 32 บิต (สูงสุด 2,147,483,647) แต่ขั้นที่ 46 ได้ 2,971,215,073 เกินแล้ว ภาษาอย่าง Java หรือ C++ ที่ใช้ int จึงพอดีกับโจทย์นี้แบบเฉียดๆ ส่วน Python ไม่มีปัญหาเพราะ int ใหญ่ได้ไม่จำกัด
benchmark ตัดสิน
บารอน: “พอได้แล้ว เถียงกันไปก็ไม่จบ ใครเร็วจริงไปวัดมาให้ดู”
วัดบน Mac ชิป Apple M5 Pro ด้วย Python 3.15.0 (CPython) ใช้ timeit เรียกแต่ละวิธีที่ n = 45 ซ้ำ 20,000 ครั้งต่อรอบ ทำ 5 รอบ แล้วเอารอบที่เร็วที่สุดหารด้วย 20,000 เป็นเวลาต่อการเรียกหนึ่งครั้ง (เอกสาร timeit แนะนำให้ดูค่าต่ำสุด เพราะค่าที่สูงกว่านั้นมักโดนงานอื่นในเครื่องรบกวน ไม่ได้มาจากโค้ดเอง) ส่วนน้องแฮมวัดแค่ครั้งเดียว เพราะครั้งเดียวก็เกือบ 40 วินาทีแล้ว ก่อนจับเวลาทุกวิธีต้องตอบ 1,836,311,903 ให้ถูกก่อน และป้าช้างสร้างสมุดใหม่ทุกครั้งที่ถูกเรียก ไม่ได้แอบใช้คำตอบที่จำไว้จากรอบก่อน
โค้ดด้านล่างเป็นโค้ดบางส่วน ไม่ได้รันตอนตรวจบทความ ใช้ฟังก์ชันจากบล็อกด้านบนที่เซฟเป็นไฟล์ solutions.py
import timeit
from solutions import ALL, climb_recursive
n = 45
for fn in ALL:
number, repeat = (1, 1) if fn is climb_recursive else (20_000, 5)
assert fn(n) == 1_836_311_903
best = min(timeit.repeat(lambda: fn(n), number=number, repeat=repeat)) / number
print(f"{fn.__name__:16s} {best * 1e6:,.2f} µs")
µs คือไมโครวินาที หรือหนึ่งในล้านวินาทีครับ
| ตัวละคร (วิธี) | เวลาต่อการเรียก 1 ครั้ง | ช้ากว่าลิงจั๊ก |
|---|---|---|
| น้องแฮม (recursion) | 38.99 วินาที | ราว 90 ล้านเท่า |
| ป้าช้าง (memo) | 4.94 µs | 11.5 เท่า |
| พี่ผึ้ง (DP ตาราง) | 0.84 µs | 2.0 เท่า |
| ลิงจั๊ก (สองตัวแปร) | 0.43 µs | — |
| จิงโจ้ (matrix) | 1.79 µs | 4.2 เท่า |
ผมรันชุดเดียวกันซ้ำอีกรอบ ตัวเลขขยับไปไม่กี่เปอร์เซ็นต์ แต่อันดับเหมือนเดิมทุกตัว ตัวเลขทั้งหมดมาจากเครื่องเดียว เครื่องอื่นหรือ Python เวอร์ชันอื่นจะได้ค่าไม่เท่านี้ สิ่งที่เห็นจากการวัดคือ
- น้องแฮมช้ากว่าลิงจั๊กราว 90 ล้านเท่า (38.99 วินาที ÷ 0.43 µs) นี่คือราคาของการเรียกฟังก์ชัน 2,269,806,339 ครั้ง เทียบกับการบวกเลข 43 ครั้ง
- ป้าช้างกับพี่ผึ้ง Big-O เท่ากันคือ O(n) แต่ป้าช้างช้ากว่าราว 6 เท่า (4.94 กับ 0.84 µs) ผมเดาว่าเป็นค่าเรียกฟังก์ชันซ้อน 44 ชั้นกับการสร้าง cache ใหม่ทุกครั้ง แต่ข้อนี้ไม่ได้วัดแยกให้เห็น
- พี่ผึ้งช้ากว่าลิงจั๊กราว 2 เท่า เพราะต้องสร้าง list 46 ช่องแล้วอ่านเขียนผ่าน index ส่วนลิงจั๊กแค่สลับสองตัวแปร
- จิงโจ้ที่ Big-O ดีที่สุด (O(log n)) กลับช้ากว่าลิงจั๊กราว 4 เท่า เพราะบันไดแค่ 45 ขั้น จำนวนรอบที่ประหยัดได้ยังไม่คุ้มกับงานต่อรอบที่หนักกว่ามาก (คูณเมทริกซ์ 10 ครั้งกับบวกเลข 43 ครั้ง)
- ถ้า
nใหญ่ระดับแสนหรือล้าน ตามทฤษฎีจิงโจ้จะเริ่มได้เปรียบ เพราะเลขจะยาวมากและจำนวนรอบต่างกันเยอะ แต่รอบนี้ไม่ได้วัด ผมเลยไม่ใส่ตัวเลข
จิงโจ้: “เอ๊ะ! ผมกระโดดไกลกว่า ทำไมแพ้ลิง?”
บารอน: “บันไดแค่ 45 ขั้น คูณเมทริกซ์งานเยอะกว่าบวกเลข”
น้องแฮม: “หนูขอไปนั่งพักก่อนนะ วิ่งมาสองพันล้านรอบแล้ว”
บารอนสรุป
บารอน: “ฟังนะ ทุกคนตอบ 1,836,311,903 วิธีถูกหมด แต่เร็วไม่เท่ากัน
น้องแฮมเอาไว้อธิบาย recurrence ตอนเริ่ม แล้วต้องบอกเองว่ามันเป็น O(2ⁿ) ใช้จริงไม่ได้ ป้าช้างดีตอนอยากคงโครง recursion ไว้ แต่ถ้า n เป็นหลักพันจะชนเพดานของ Python พี่ผึ้งเหมาะตอนโจทย์อยากได้คำตอบของทุกขั้น ส่วนจิงโจ้เก็บไว้คุยตอนเขาถามต่อว่าถ้า n เป็นล้านจะทำยังไง
คำตอบหลักตอนสัมภาษณ์คือลิงจั๊ก เวลา O(n) หน่วยความจำ O(1) วัดจริงก็เร็วสุด ลิงชนะ!”
| วิธี | เวลา | ที่เพิ่ม | วัดจริงที่ n = 45 | ใช้เมื่อ |
|---|---|---|---|---|
| น้องแฮม (recursion) | O(2ⁿ) | O(n) call stack | 38.99 วินาที | อธิบาย recurrence ครั้งแรก |
| ป้าช้าง (memo) | O(n) | O(n) | 4.94 µs | อยากคงโครง recursive และ n ไม่ใหญ่ |
| พี่ผึ้ง (DP ตาราง) | O(n) | O(n) | 0.84 µs | ต้องการคำตอบของทุกขั้น |
| ลิงจั๊ก (สองตัวแปร) | O(n) | O(1) | 0.43 µs | คำตอบหลักตอนสัมภาษณ์ |
| จิงโจ้ (matrix) | O(log n) รอบการคูณ | O(1) | 1.79 µs | n ใหญ่มาก (ยังไม่ได้วัดในบทความนี้) |
Recap
- เขียน recurrence:
ways(n) = ways(n-1) + ways(n-2) - ตั้ง base
1 → 1,2 → 2ให้ถูก - ใช้สองตัวแปรอัปเดตจากเล็กไปใหญ่
- เทียบกับค่า Fibonacci ที่รู้จักก่อนเชื่อโค้ด
- วัดจริงที่ 45 ขั้น: สองตัวแปร 0.43 µs ส่วน recursion เปล่า 38.99 วินาที
- O(log n) ไม่ได้ชนะเสมอ ตอน n เล็ก งานต่อรอบสำคัญกว่าจำนวนรอบ
- default เลือก bottom-up O(n)/O(1) ครับ