การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ
แปลงแฟกทอเรียลและฟีโบนัชชีแบบเรียกซ้ำเป็นลูปแบบวนซ้ำ และอธิบายว่าเมื่อใดขีดจำกัดการเรียกซ้ำและขนาดสแตกของ Python ทำให้ควรใช้การวนซ้ำ
การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ เป็นบทเรียน DSA Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 3 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน DSA Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
ความเป็นคู่กันของการเรียกซ้ำและการวนซ้ำ
อัลกอริทึมทุกแบบที่เขียนเป็นแบบเรียกซ้ำได้ สามารถเขียนเป็นแบบวนซ้ำได้เช่นกัน และในทางกลับกัน เวอร์ชันแบบเรียกซ้ำมักสะท้อนนิยามทางคณิตศาสตร์ของปัญหาได้ใกล้เคียงกว่า ขณะที่เวอร์ชันแบบวนซ้ำให้คุณควบคุมหน่วยความจำได้โดยตรงและหลีกเลี่ยงความเสี่ยงจากสแตกโอเวอร์โฟลว์ การเลือกระหว่างสองแบบเป็นการตัดสินใจเชิงปฏิบัติที่พิจารณาจากความอ่านง่าย ขีดจำกัดด้านความลึก และข้อกำหนดด้านประสิทธิภาพ
ในการสัมภาษณ์ การสามารถนำเสนอทั้งสองแบบและอธิบายข้อแลกเปลี่ยนได้เป็นสัญญาณที่ชัดเจนว่าคุณเข้าใจเนื้อหาอย่างเชี่ยวชาญ
factorial: แบบเรียกซ้ำเทียบกับแบบวนซ้ำ
factorial เป็นตัวอย่างมาตรฐาน คำตอบแบบเรียกซ้ำเข้ารหัสนิยามทางคณิตศาสตร์ n! = n × (n-1)! โดยตรง และใช้พื้นที่สแตก O(n) เนื่องจากมีค่าส่งคืนที่รออยู่ n ค่า เวอร์ชันแบบวนซ้ำวนลูปตั้งแต่ 1 ถึง n โดยใช้พื้นที่ O(1) เมื่อ n = 1000 เวอร์ชันแบบเรียกซ้ำจะชนขีดจำกัดเริ่มต้นของไพธอน ส่วนเวอร์ชันแบบวนซ้ำรองรับ n ที่ใหญ่เพียงใดก็ได้
def factorial_rec(n):
if n == 0:
return 1
return n * factorial_rec(n - 1) # O(n) stack
def factorial_iter(n):
result = 1
for i in range(2, n + 1):
result *= i # O(1) stack
return result
print(factorial_rec(10)) # 3628800
print(factorial_iter(10)) # 3628800
# Large n: iterative works, recursive may overflow
print(factorial_iter(1000) > 0) # True (Python handles big ints)ฟีโบนักชี: เอ็กซ์โพเนนเชียลเทียบกับเชิงเส้น
ฟีโบนักชีแบบเรียกซ้ำพื้นฐานมี time เป็น O(2^n) — ช้ามากจนใช้งานไม่ได้สำหรับ n ที่มีค่ามาก เวอร์ชันแบบวนซ้ำมี time เป็น O(n) และใช้พื้นที่ O(1) การเรียกซ้ำที่จดจำผลลัพธ์ (ในบทเรียนถัดไป) ก็มี time เป็น O(n) แต่ใช้พื้นที่ O(n) เนื่องจากพจนานุกรมบันทึกผลและสแตก O(n) สำหรับฟีโบนักชี วิธีแบบวนซ้ำเหมาะสมที่สุดในทุกด้าน เมื่อ n = 50 การเรียกซ้ำแบบพื้นฐานใช้เวลาหลายวินาที แต่วิธีแบบวนซ้ำใช้เวลาเพียงไมโครวินาที
import time
def fib_rec(n):
if n <= 1: return n
return fib_rec(n-1) + fib_rec(n-2) # O(2^n)
def fib_iter(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a # O(n) time, O(1) space
# Timing comparison for n=35
start = time.time()
fib_rec(35)
print(f'Recursive n=35: {time.time()-start:.3f}s')
start = time.time()
fib_iter(35)
print(f'Iterative n=35: {time.time()-start:.6f}s')
print(fib_iter(100)) # handles large nการท่องผ่านต้นไม้: แบบเรียกซ้ำเทียบกับแบบวนซ้ำ
การท่องผ่านต้นไม้แบบเรียกซ้ำนั้นอ่านเข้าใจง่ายโดยธรรมชาติ เพราะโครงสร้างต้นไม้สอดคล้องกับการเรียกซ้ำ แต่สำหรับต้นไม้ที่เอียงมากและลึก (ซึ่งโดยพื้นฐานแล้วคล้ายลิงก์ลิสต์) ความลึกของการเรียกซ้ำจะเท่ากับความสูงของต้นไม้ = O(n) จึงเสี่ยงทำให้สแตกเต็ม ส่วนแบบวนซ้ำที่ใช้สแตกอย่างชัดเจนจะไม่มีข้อจำกัดด้านความลึก และทำให้ขนาดสแตกขยายบนฮีปแทนที่จะใช้สแตกการเรียกใช้
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val; self.left = left; self.right = right
def preorder_rec(root, result=None):
if result is None: result = []
if root:
result.append(root.val)
preorder_rec(root.left, result)
preorder_rec(root.right, result)
return result
def preorder_iter(root):
if not root: return []
result, stack = [], [root]
while stack:
node = stack.pop()
result.append(node.val)
if node.right: stack.append(node.right)
if node.left: stack.append(node.left)
return result
root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3))
print(preorder_rec(root)) # [1, 2, 4, 5, 3]
print(preorder_iter(root)) # [1, 2, 4, 5, 3]การเรียงลำดับแบบผสาน: แบบเรียกซ้ำเทียบกับแบบวนซ้ำ (จากล่างขึ้นบน)
การเรียงลำดับแบบผสานเหมาะกับการเขียนแบบเรียกซ้ำโดยธรรมชาติ (แบ่ง เรียกซ้ำ แล้วผสาน) ส่วนการเรียงลำดับแบบผสานจากล่างขึ้นบนแบบวนซ้ำจะไม่ใช้การเรียกซ้ำเลย โดยเริ่มจากอาร์เรย์ย่อยขนาด 1 ผสานคู่ที่อยู่ติดกันให้เป็นอาร์เรย์ย่อยขนาด 2 จากนั้นเป็นขนาด 4 และต่อไปเรื่อย ๆ โดยเพิ่มขนาดอาร์เรย์ย่อยเป็นสองเท่าในแต่ละรอบ การเรียงลำดับแบบผสานจากล่างขึ้นบนใช้เวลา O(n log n) ใช้พื้นที่ O(n) (สำหรับบัฟเฟอร์การผสาน) และใช้พื้นที่สแตก O(1)
def merge_sort_iterative(arr):
n = len(arr)
size = 1
while size < n:
for start in range(0, n, 2 * size):
mid = min(start + size, n)
end = min(start + 2 * size, n)
left = arr[start:mid]
right = arr[mid:end]
# Merge
i = j = 0
for k in range(start, end):
if i < len(left) and (j >= len(right) or left[i] <= right[j]):
arr[k] = left[i]; i += 1
else:
arr[k] = right[j]; j += 1
size *= 2
return arr
print(merge_sort_iterative([5, 2, 4, 6, 1, 3])) # [1,2,3,4,5,6]เมื่อการเรียกซ้ำดีกว่าอย่างชัดเจน
การเรียกซ้ำเหมาะอย่างยิ่งเมื่อปัญหามี โครงสร้างคล้ายต้นไม้ ที่สอดคล้องโดยตรงกับกราฟการเรียกใช้ เมื่อกรณีฐานเกิดขึ้นได้อย่างเป็นธรรมชาติ และเมื่อความลึกมีขอบเขต (O(log n) สำหรับต้นไม้สมดุลและการแบ่งแล้วพิชิต) ตัวอย่างเช่น การแยกวิเคราะห์เจสัน การท่องผ่านไดเรกทอรี ต้นไม้เกม และปัญหาแบบย้อนกลับ ในกรณีเหล่านี้ โค้ดแบบเรียกซ้ำจะสั้นกว่า ชัดเจนกว่า และพิสูจน์ความถูกต้องได้ง่ายกว่าโค้ดแบบวนซ้ำที่เทียบเท่ากัน
# Recursion is clearest for JSON-like nested structures
def flatten(nested):
result = []
for item in nested:
if isinstance(item, list):
result.extend(flatten(item)) # recurse on sub-list
else:
result.append(item)
return result
print(flatten([1, [2, [3, 4], 5], 6])) # [1, 2, 3, 4, 5, 6]
print(flatten([])) # []
print(flatten([[1, [2]], [3, [4, [5]]]])) # [1, 2, 3, 4, 5]เมื่อการวนซ้ำดีกว่าอย่างชัดเจน
ควรเลือกการวนซ้ำเมื่อ: ความลึกเป็น O(n) และ n มีค่ามาก (มากกว่า ~500 ในโค้ดไพธอนที่ปลอดภัย), แบบเรียกซ้ำและแบบวนซ้ำอ่านเข้าใจได้พอ ๆ กัน (ฟีโบนัชชี แฟกทอเรียล) หรือปัญหานั้นเป็นลำดับต่อเนื่องโดยพื้นฐานและไม่มีการแยกเป็นปัญหาย่อยอย่างเป็นธรรมชาติ ลูปง่าย ๆ ที่ประมวลผลอาร์เรย์จากซ้ายไปขวา — เช่น ผลรวมสะสม หน้าต่างเลื่อน และตัวชี้สองตัว — ควรใช้การวนซ้ำเสมอ
# Iterative is clearest for sequential array processing
def running_max(nums):
result = []
curr_max = float('-inf')
for n in nums:
curr_max = max(curr_max, n)
result.append(curr_max)
return result
print(running_max([3, 1, 4, 1, 5, 9, 2, 6])) # [3,3,4,4,5,9,9,9]
# No natural recursion here — iteration is the only sensible choiceการเปลี่ยนการเรียกซ้ำของ DFS ให้เป็นการวนซ้ำ
แนวทางที่เป็นระบบคือ การเรียกซ้ำของ DFS ทุกแบบสามารถเปลี่ยนเป็นแบบวนซ้ำได้ด้วยการนำอาร์กิวเมนต์ของการเรียกซ้ำไปใส่ไว้ในสแตกอย่างชัดเจน แนวคิดสำคัญคือ การเรียกซ้ำ f(args) มีความหมายเทียบเท่ากับการใส่ args ลงในสแตกแล้ววนซ้ำ สำหรับการประมวลผลแบบหลังลำดับ (ซึ่งต้องใช้ผลลัพธ์จากลูกก่อนประมวลผลโหนดแม่) อาจต้องใช้แนวทางสองรอบหรือแฟล็กการเยี่ยมชม
# Post-order iterative using two stacks
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val=val; self.left=left; self.right=right
def postorder_iter(root):
if not root: return []
s1, s2 = [root], []
while s1:
node = s1.pop()
s2.append(node.val)
if node.left: s1.append(node.left)
if node.right: s1.append(node.right)
return s2[::-1] # reverse gives post-order
root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3))
print(postorder_iter(root)) # [4, 5, 2, 3, 1]ค่าใช้จ่ายส่วนเกินของการเรียกซ้ำ
การเรียกซ้ำแต่ละครั้งในไพธอนมีค่าใช้จ่ายส่วนเกินไม่น้อย: จะมีการสร้างเฟรมใหม่ (ซึ่งจัดสรรหน่วยความจำบนฮีป) ตัวแปรเฉพาะที่จะถูกกำหนดค่าเริ่มต้น และมีการจัดเก็บตัวชี้ที่อยู่สำหรับการคืนกลับ การทดสอบประสิทธิภาพแสดงให้เห็นว่า ค่าใช้จ่ายของการเรียกใช้ฟังก์ชันในไพธอนอยู่ที่ประมาณ 100–200 นาโนวินาทีต่อครั้ง สำหรับความลึกของการเรียกซ้ำ 10^6 ครั้ง ค่าใช้จ่ายนี้จะรวมเป็น 0.1–0.2 วินาทีของค่าใช้จ่ายส่วนเกินล้วน ๆ โดยไม่ขึ้นกับงานของอัลกอริทึม ลูปแบบวนซ้ำจะหลีกเลี่ยงค่าใช้จ่ายนี้ได้ทั้งหมด
import time
def rec_sum(n):
if n == 0: return 0
return n + rec_sum(n - 1)
def iter_sum(n):
total = 0
for i in range(n + 1):
total += i
return total
import sys; sys.setrecursionlimit(10000)
n = 5000
start = time.time()
for _ in range(100): rec_sum(n)
print(f'Recursive sum({n}) x100: {(time.time()-start)*1000:.2f}ms')
start = time.time()
for _ in range(100): iter_sum(n)
print(f'Iterative sum({n}) x100: {(time.time()-start)*1000:.2f}ms')การตัดสินใจในการสัมภาษณ์
ในการสัมภาษณ์การเขียนโปรแกรม หากเลือกได้ ให้ถามว่า: "ความลึกของการเรียกซ้ำมีขอบเขตเป็น O(log n) หรือไม่" หากใช่ การเรียกซ้ำก็เหมาะสม "ความลึกของการเรียกซ้ำเป็น O(n) หรือไม่" — ควรเลือกการวนซ้ำ หรือกล่าวว่าจะเปลี่ยนเป็นแบบวนซ้ำเมื่อนำไปใช้งานจริง "ปัญหานี้มีรูปทรงเป็นต้นไม้หรือเป็นการแบ่งแล้วพิชิตโดยธรรมชาติหรือไม่" — ให้เอนเอียงไปทางการเรียกซ้ำ "ปัญหานี้เป็นการสแกนตามลำดับหรือไม่" — ให้ใช้การวนซ้ำ
ควรอธิบายเหตุผลเสมอ: "ผมจะใช้การเรียกซ้ำในที่นี้ เพราะความลึกเป็น O(log n) สำหรับ BST ที่สมดุล ดังนั้นการใช้พื้นที่สแตก O(log n) จึงยอมรับได้"
สรุป: ตารางข้อแลกเปลี่ยน
สรุปข้อแลกเปลี่ยน: โค้ดแบบเรียกซ้ำมักสั้นกว่าและสะท้อนโครงสร้างของปัญหา แต่ใช้พื้นที่สแตก O(ความลึก) และมีค่าใช้จ่ายจากการเรียกใช้ฟังก์ชัน โค้ดแบบวนซ้ำยาวกว่า แต่ใช้พื้นที่สแตก O(1) และหลีกเลี่ยงข้อจำกัดของการเรียกซ้ำ การเรียกซ้ำที่จดจำผลลัพธ์ (บทถัดไป) เป็นแนวทางตรงกลาง ซึ่งยังคงความชัดเจนของการเรียกซ้ำไว้พร้อมกำจัดการคำนวณซ้ำที่ไม่จำเป็น ควรระบุความซับซ้อนด้านพื้นที่ให้ชัดเจนเสมอ โดยรวมพื้นที่ของสแตกการเรียกใช้ไว้ด้วยเมื่อวิเคราะห์คำตอบของคุณ
rows = [
('Factorial', 'O(n) / O(1)', 'O(n) / O(1)', 'Same time; iter wins on space'),
('Fibonacci', 'O(2^n) / O(n)', 'O(n) / O(1)', 'Iter massively wins'),
('Binary search','O(log n) / O(log n)', 'O(log n) / O(1)', 'Iter wins on space'),
('Tree DFS', 'O(n) / O(h)', 'O(n) / O(h)', 'Equal; rec cleaner'),
('Merge sort', 'O(n log n) / O(log n)', 'O(n log n) / O(1)', 'BU-iter wins on stack'),
]
for name, rec, it, note in rows:
print(f'{name:<15} rec={rec:<22} iter={it:<22} {note}')ตรวจสอบความเข้าใจอย่างรวดเร็ว
ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูลและอัลกอริทึม — การเตรียมสัมภาษณ์การเขียนโปรแกรมจากบทเรียนนี้
ทบทวนบทเรียน
ในบทเรียนนี้ คุณได้เรียนรู้ว่า: ควรใช้การเรียกซ้ำเมื่อความลึกเป็น O(log n) หรือเมื่อปัญหามีรูปทรงเป็นต้นไม้โดยธรรมชาติ และควรใช้การวนซ้ำเมื่อความลึกเป็น O(n) หรือเมื่อปัญหาเป็นลำดับต่อเนื่อง, ฟีโบนัชชีแบบเรียกซ้ำอย่างตรงไปตรงมามีเวลา O(2^n) — ส่วนแบบวนซ้ำใช้เวลา O(n) และพื้นที่ O(1) และ DFS แบบเรียกซ้ำทุกแบบสามารถเปลี่ยนเป็นแบบวนซ้ำได้ด้วยการจัดการสแตกอย่างชัดเจนบนฮีป ต่อไปเราจะใช้การจดจำผลลัพธ์เพื่อกำจัดการเรียกซ้ำที่ไม่จำเป็น
คำถามที่พบบ่อย
บทเรียน “การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ” ฟรีหรือไม่
ใช่ — ข้อความเต็มของ “การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส DSA Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
คุณจะเรียนรู้อะไรในบทเรียน “การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ”
แปลงแฟกทอเรียลและฟีโบนัชชีแบบเรียกซ้ำเป็นลูปแบบวนซ้ำ และอธิบายว่าเมื่อใดขีดจำกัดการเรียกซ้ำและขนาดสแตกของ Python ทำให้ควรใช้การวนซ้ำ คุณปฏิบัติ DSA Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน
คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน DSA Interview Prep หรือไม่
ไม่จำเป็นต้องมีประสบการณ์มาก่อน DSA Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 3 จากทั้งหมด 4 บทเรียน
บทเรียน “การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ” ใช้เวลานานแค่ไหน
บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย
ฉันเขียนและรันโค้ดในบทเรียน DSA Interview Prep นี้ได้ไหม
ได้ บทเรียน DSA Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ
บทเรียนทั้งหมดในหลักสูตรนี้
- กรอบการเรียกซ้ำ: กรณีฐาน ความเชื่อมั่น การสร้าง
- การแสดงภาพสแตกการเรียก
- การแลกเปลี่ยนระหว่างแบบเรียกซ้ำกับแบบวนซ้ำ
- การจดจำผลลัพธ์: แคชผลลัพธ์จากการเรียกซ้ำ