Recursion ใน Python: หลักการทำงาน เมื่อไรควรใช้ และตัวอย่าง
บทเรียนนี้แปลจากต้นฉบับภาษาอังกฤษRead in EnglishLeer en españolЧитать на русскомLer em portuguêsแจ้งข้อผิดพลาดในการแปล
ลองนึกถึงตุ๊กตาแม่ลูกดกของรัสเซีย คุณเปิดตัวที่ใหญ่ที่สุดแล้วเจอตัวที่เล็กกว่าอยู่ข้างใน เปิดตัวนั้นก็เจอตัวที่เล็กลงไปอีก คุณเปิดไปเรื่อย ๆ จนเจอตัวที่เล็กที่สุดซึ่งเปิดไม่ได้แล้ว นั่นคือจุดสิ้นสุด
การเรียกซ้ำ (recursion) ทำงานแบบเดียวกัน ฟังก์ชันแบบเรียกซ้ำจะเรียกตัวเองด้วยโจทย์ที่เล็กลง ซ้ำแล้วซ้ำอีก จนกว่าจะถึงจุดหยุด นี่คือหนึ่งในเทคนิคที่สวยงามที่สุดในการเขียนโปรแกรม และเมื่อเข้าใจแล้ว คุณจะเห็นมันอยู่ทุกที่
ในบทเรียนนี้ คุณจะได้เรียนรู้ว่าการเรียกซ้ำคืออะไร call stack ติดตามแต่ละขั้นอย่างไร เมื่อไรการเรียกซ้ำคือเครื่องมือที่ถูกต้อง และจะหลีกเลี่ยงข้อผิดพลาดที่พบบ่อยที่สุดได้อย่างไร เราจะเริ่มจากตัวอย่างง่าย ๆ ไปจนถึงอัลกอริทึมคลาสสิกอย่างแฟกทอเรียลและฟีโบนัชชี
การเรียกซ้ำคืออะไร
การเรียกซ้ำคือการที่ฟังก์ชันเรียกตัวเอง นิยามมีแค่นั้น แต่ทำไมเราถึงอยากให้ฟังก์ชันเรียกตัวเองล่ะ
ลองนึกถึงการนับถอยหลังจาก 5 คุณอธิบายขั้นตอนได้แบบนี้ "การนับถอยหลังจาก N ให้พูด N แล้วนับถอยหลังจาก N ลบ 1 หยุดเมื่อถึง 0" นี่คือคำอธิบายแบบเรียกซ้ำ เพราะคำสั่งอ้างถึงตัวเอง
5 4 3 2 1 Go!
ฟังก์ชัน countdown(5) แสดง 5 แล้วเรียก countdown(4) ซึ่งแสดง 4 แล้วเรียก countdown(3) เป็นแบบนี้ไปเรื่อย ๆ จนถึง countdown(0) ที่แสดง "Go!" แล้วหยุด ไม่มีการเรียกต่ออีก
ฟังก์ชันแบบเรียกซ้ำทุกตัวมีส่วนสำคัญสองส่วน ถ้าขาดส่วนใดส่วนหนึ่ง ฟังก์ชันจะทำงานไปตลอดกาล (หรือจนกว่า Python จะหยุดมัน)
กรณีฐาน: จุดที่ต้องหยุด
กรณีฐาน (base case) คือเงื่อนไขที่หยุดการเรียกซ้ำ เปรียบได้กับตุ๊กตาตัวที่เล็กที่สุดซึ่งเปิดไม่ได้ ถ้าไม่มีกรณีฐาน ฟังก์ชันจะเรียกตัวเองไปตลอดกาล
กรณีเรียกซ้ำ (recursive case) คือส่วนที่ฟังก์ชันเรียกตัวเองด้วยอินพุตที่ง่ายลง การเรียกแต่ละครั้งควรพาเข้าใกล้กรณีฐานมากขึ้น
3 2 1 Go!
หลักง่าย ๆ ที่ควรจำ: เขียนกรณีฐาน ก่อน เสมอ แล้วจึงเขียนกรณีเรียกซ้ำที่เคลื่อนเข้าหากรณีฐาน ลำดับนี้ช่วยกันไม่ให้คุณลืมเงื่อนไขทางออกโดยไม่ได้ตั้งใจ
Call stack ทำงานอย่างไร
เมื่อฟังก์ชันเรียกตัวเอง Python ไม่ได้ลืมว่ากำลังทำอะไรค้างอยู่ แต่ใช้ call stack (สแตกการเรียกฟังก์ชัน) ซึ่งเป็นกองของ "เฟรม" โดยแต่ละเฟรมจำตัวแปรภายในของฟังก์ชันและตำแหน่งที่จะกลับมาทำงานต่อ
ลองนึกถึงกองกระดาษโน้ตแบบแปะ ทุกครั้งที่เรียกฟังก์ชัน Python จะเขียนโน้ตแผ่นใหม่พร้อมรายละเอียดของการเรียกครั้งนั้นแล้ววางไว้บนสุด เมื่อฟังก์ชันคืนค่า Python จะดึงโน้ตแผ่นบนสุดออกแล้วกลับไปที่แผ่นข้างใต้
Calling factorial(5) Calling factorial(4) Calling factorial(3) Calling factorial(2) Calling factorial(1) Base case! factorial(1) = 1 factorial(2) = 2 * factorial(1) = 2 factorial(3) = 3 * factorial(2) = 6 factorial(4) = 4 * factorial(3) = 24 factorial(5) = 5 * factorial(4) = 120 Result: 120
สังเกตว่าข้อความ "Calling" ลึกลงไปเรื่อย ๆ แล้วผลลัพธ์ก็ค่อย ๆ ส่งกลับขึ้นมา call stack โตขึ้นเมื่อมีการเรียกเข้าไป แล้วหดลงเมื่อผลลัพธ์ส่งกลับออกมา จังหวะ "เข้าแล้วออก" แบบนี้คือหัวใจของการเรียกซ้ำ
ตัวอย่างคลาสสิกของการเรียกซ้ำ
แฟกทอเรียล
แฟกทอเรียล (factorial) ของจำนวน N (เขียนว่า N!) คือ N คูณกับจำนวนเต็มบวกทุกตัวที่น้อยกว่ามัน ตัวอย่างเช่น 5! = 5 x 4 x 3 x 2 x 1 = 120
แฟกทอเรียลมีโครงสร้างแบบเรียกซ้ำโดยธรรมชาติ: 5! = 5 x 4! และ 4! = 4 x 3! ไปเรื่อย ๆ กรณีฐานคือ 1! = 1
1 120 3628800
ผลรวมของลิสต์
มาดูอีกรูปแบบหนึ่ง การหาผลรวมของลิสต์แบบเรียกซ้ำ ทำได้โดยนำสมาชิกตัวแรกไปบวกกับผลรวมของส่วนที่เหลือ
15 10 0
รูปแบบคือ จัดการกรณีฐานก่อน (ลิสต์ว่างคืนค่า 0) แล้วแบ่งโจทย์เป็น "สมาชิกตัวแรก" บวก "การเรียกซ้ำกับส่วนที่เหลือ" รูปแบบนี้ใช้ได้กับงานลิสต์อีกหลายอย่าง
การเรียกซ้ำกับลูป: เมื่อไรควรใช้แบบไหน
ทุกอย่างที่ทำได้ด้วยการเรียกซ้ำ ก็ทำได้ด้วยลูปเช่นกัน แล้วเมื่อไรควรเลือกแบบไหน
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120def factorial(n):
result = 1
for i in range(2, n + 1):
result = result * i
return result
print(factorial(5)) # 120สำหรับโจทย์ง่าย ๆ อย่างแฟกทอเรียล ลูปมักเร็วกว่าและใช้หน่วยความจำน้อยกว่า เพราะการเรียกซ้ำแต่ละครั้งจะเพิ่มเฟรมใหม่ลงใน call stack ส่วนลูปใช้ตัวแปรชุดเดิมซ้ำไปเรื่อย ๆ
การเรียกซ้ำโดดเด่นเมื่อโจทย์มีโครงสร้างเป็นต้นไม้หรือคล้ายตัวเองโดยธรรมชาติ ตัวอย่างเช่น การสำรวจโครงสร้างโฟลเดอร์ การแยกวิเคราะห์ข้อมูลซ้อนกัน (เช่น JSON หรือ HTML) และการท่องต้นไม้หรือกราฟ ในกรณีเหล่านี้ การเรียกซ้ำให้โค้ดที่สะอาดและสั้นกว่า
เมื่อไรการเรียกซ้ำคือเครื่องมือที่ใช่
การเรียกซ้ำเก่งมากกับโจทย์ที่ต้องสำรวจทุกกิ่งของโครงสร้างแบบต้นไม้ มาดูตัวอย่างที่ใช้งานได้จริง คือการแผ่ลิสต์ซ้อนกันให้เป็นลิสต์ชั้นเดียว
[1, 2, 3, 4, 5, 6, 7]
ลองเขียนโค้ดนี้โดยไม่ใช้การเรียกซ้ำดู คุณจะต้องสร้างสแตกเองและจดบันทึกสถานะที่ซับซ้อน ส่วนแบบเรียกซ้ำอ่านแทบจะเหมือนภาษาพูด: "สำหรับสมาชิกแต่ละตัว ถ้าเป็นลิสต์ ให้แผ่มันออก ไม่เช่นนั้นก็เก็บไว้"
ข้อผิดพลาดที่พบบ่อยในการเรียกซ้ำ
ข้อผิดพลาดที่ 1: ไม่มีกรณีฐาน
5 4 3 2 1
ข้อผิดพลาดที่ 2: ไม่เคลื่อนเข้าหากรณีฐาน
120
แบบฝึกหัด
เริ่มจากข้อที่ง่ายกว่าแล้วค่อย ๆ ไต่ระดับขึ้นไป แต่ละข้อต่อยอดจากสิ่งที่คุณได้เรียนมา อย่าลืม: เขียนกรณีฐานก่อนเสมอ
อ่านโค้ดนี้แล้วทายผลลัพธ์ จากนั้นเขียนคำสั่ง print() ที่ให้ผลลัพธ์เหมือนกันทุกตัวอักษร
def mystery(n):
if n <= 0:
return
mystery(n - 1)
print(n)
mystery(3)สังเกตว่า print(n) อยู่ หลัง การเรียกซ้ำ คิดเรื่องลำดับให้ดี
เขียนฟังก์ชันแบบเรียกซ้ำชื่อ sum_to ที่รับจำนวนเต็มบวก n แล้วคืนค่าผลรวมของตัวเลขทั้งหมดตั้งแต่ 1 ถึง n
ตัวอย่างเช่น sum_to(5) ควรคืนค่า 1 + 2 + 3 + 4 + 5 = 15
กรณีฐาน: sum_to(1) คืนค่า 1
แสดงผลของ sum_to(5) และ sum_to(10) คนละบรรทัด
ฟังก์ชันแบบเรียกซ้ำนี้ควรคำนวณ base ยกกำลัง exp (เหมือน base ** exp) แต่มีบั๊กอยู่สองจุด
แก้บั๊กทั้งสองจุดให้ได้ผลลัพธ์ดังนี้:
8
1
81เขียนฟังก์ชันแบบเรียกซ้ำชื่อ fibonacci ที่คืนค่าจำนวนฟีโบนัชชีตัวที่ n
ลำดับฟีโบนัชชีเริ่มต้นแบบนี้: 0, 1, 1, 2, 3, 5, 8, 13, ...
กฎ:
fibonacci(0) คืนค่า 0fibonacci(1) คืนค่า 1fibonacci(n) = fibonacci(n-1) + fibonacci(n-2)แสดงผลเมื่อ n = 0, 1, 5 และ 10 คนละบรรทัด
เขียนฟังก์ชันแบบเรียกซ้ำชื่อ reverse ที่รับสตริงแล้วคืนค่าสตริงนั้นแบบกลับด้าน
ตัวอย่างเช่น reverse("hello") ควรคืนค่า "olleh"
คำใบ้: ตัวอักษรตัวสุดท้าย บวกกับสตริงกลับด้านของทุกตัวยกเว้นตัวสุดท้าย
แสดงผลของ reverse("hello") และ reverse("Python") คนละบรรทัด
เขียนฟังก์ชันแบบเรียกซ้ำชื่อ count_digits ที่รับจำนวนเต็มที่ไม่ติดลบ แล้วคืนค่าจำนวนหลักของตัวเลขนั้น
ตัวอย่างเช่น:
count_digits(0) คืนค่า 1count_digits(5) คืนค่า 1count_digits(123) คืนค่า 3count_digits(99999) คืนค่า 5คำใบ้: การหารด้วย 10 ด้วย // จะตัดหลักสุดท้ายออก
แสดงผลสำหรับ 0, 5, 123 และ 99999 คนละบรรทัด
สรุป
สิ่งที่คุณได้เรียนเกี่ยวกับการเรียกซ้ำ:
| แนวคิด | ความหมาย |
|---|---|
| การเรียกซ้ำ (recursion) | ฟังก์ชันที่เรียกตัวเอง |
| กรณีฐาน (base case) | เงื่อนไขที่หยุดการเรียกซ้ำ |
| กรณีเรียกซ้ำ (recursive case) | ส่วนที่เรียกตัวเองด้วยอินพุตที่ง่ายลง |
| Call stack | ระบบของ Python ที่ติดตามการเรียกฟังก์ชันที่ซ้อนกัน |
| สแตกล้น (stack overflow) | สิ่งที่เกิดขึ้นเมื่อการเรียกซ้ำลึกเกินไป (ไม่มีกรณีฐาน) |
| การเรียกซ้ำกับลูป | ลูปสำหรับการทำซ้ำแบบง่าย การเรียกซ้ำสำหรับโจทย์แบบต้นไม้ |
กฎทองของการเรียกซ้ำ: เขียนกรณีฐานก่อนเสมอ และตรวจให้แน่ใจว่าการเรียกซ้ำทุกครั้งเคลื่อนเข้าหากรณีฐาน ถ้าทำได้ คุณก็จะเขียนฟังก์ชันแบบเรียกซ้ำได้ทุกแบบ
เรียนอะไรต่อ
บทเรียนถัดไปคือ map(), filter() และ reduce() เครื่องมือการเขียนโปรแกรมเชิงฟังก์ชันที่ทรงพลังของ Python คุณจะได้เรียนรู้วิธีแปลง กรอง และรวมชุดข้อมูลในบรรทัดเดียว