การเรียงแบบผสาน: แบ่ง เรียง ผสาน
สร้างการเรียงแบบผสานด้วยการเรียกซ้ำ ติดตามต้นไม้แบบแบ่งแล้วพิชิต และอธิบายว่าเหตุใดจึงรับประกัน O(n log n) ได้ทุกกรณี
การเรียงแบบผสาน: แบ่ง เรียง ผสาน เป็นบทเรียน Coding Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน Coding Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
สัญชาตญาณของการแบ่งแล้วพิชิต
การเรียงลำดับแบบผสาน เป็นอัลกอริทึมแบบแบ่งแล้วพิชิตที่เป็นที่รู้จัก: แบ่งอาร์เรย์ออกเป็นสองส่วน เรียงลำดับแต่ละส่วนด้วยการเรียกซ้ำ แล้ว merge ครึ่งที่เรียงลำดับแล้วทั้งสองส่วนให้เป็นผลลัพธ์ที่เรียงลำดับเดียวกัน ข้อสังเกตสำคัญคือการผสานอาร์เรย์ที่เรียงลำดับแล้วสองอาร์เรย์ใช้เวลา O(n) ซึ่งถูกกว่าการเรียงลำดับใหม่ตั้งแต่ต้นมาก การแบ่งย่อยนี้สร้างต้นไม้การเรียกซ้ำที่มี log n ระดับ โดยแต่ละระดับต้องใช้การผสาน O(n) จึงได้ขอบเขตที่เหมาะสมที่สุดของการเรียงลำดับแบบเปรียบเทียบคือ O(n log n)
# High-level merge sort structure
def merge_sort(arr):
# Base case: 0 or 1 element already sorted
if len(arr) <= 1:
return arr
# Divide
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # sort left half
right = merge_sort(arr[mid:]) # sort right half
# Conquer (merge)
return merge(left, right)
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
# [3, 9, 10, 27, 38, 43, 82]อธิบายขั้นตอนการผสาน
การผสานอาร์เรย์ที่เรียงลำดับแล้วสองอาร์เรย์: ใช้ตัวชี้สองตัว โดยมีตัวชี้หนึ่งตัวสำหรับแต่ละครึ่ง เปรียบเทียบสมาชิกด้านหน้า แล้วใช้ copy สมาชิกที่มีค่าน้อยกว่าลงในผลลัพธ์และเลื่อนตัวชี้นั้นไปข้างหน้า เมื่อครึ่งหนึ่งใดถูกใช้จนหมด ให้ copy ส่วนที่เหลือของอีกครึ่งลงไปโดยตรง วิธีนี้ใช้เวลา O(n) และใช้พื้นที่ O(n) สำหรับอาร์เรย์ผลลัพธ์ ขั้นตอนการผสานคือหัวใจเชิงอัลกอริทึมของการเรียงลำดับแบบผสาน จึงควรทำความเข้าใจให้ลึกซึ้ง
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= preserves stability
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# Append remaining elements
result.extend(left[i:])
result.extend(right[j:])
return result
print(merge([1,3,5,7], [2,4,6,8]))
# [1, 2, 3, 4, 5, 6, 7, 8]การเรียงลำดับแบบผสานฉบับสมบูรณ์
การนำการแบ่งและการผสานมารวมกัน: การเรียกซ้ำจะแบ่งปัญหาออกเป็นครึ่งหนึ่งจนเหลือสมาชิกเดี่ยว ๆ (ซึ่งเรียงลำดับแล้วโดยไม่ต้องทำอะไร) จากนั้นการเรียกผสานจะรวมกลับเข้าด้วยกัน แต่ละระดับของต้นไม้การเรียกซ้ำจะผสานสมาชิกทั้งหมด n ตัวเท่าเดิม (โดยกระจายอยู่ในการผสานหลายครั้ง) ความลึกของการเรียกซ้ำคือ log₂(n) จึงใช้เวลารวม O(n log n) และใช้พื้นที่หน่วยความจำเสริม O(n) สำหรับอาร์เรย์ผลลัพธ์ของการผสาน รวมถึงความลึกของสแตกการเรียก O(log n)
def merge_sort_full(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort_full(arr[:mid])
right = merge_sort_full(arr[mid:])
# Merge the two sorted halves
merged = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: merged.append(left[i]); i += 1
else: merged.append(right[j]); j += 1
merged.extend(left[i:] + right[j:])
return merged
print(merge_sort_full([5,2,4,6,1,3,2,6]))
# [1, 2, 2, 3, 4, 5, 6, 6]ต้นไม้การเรียกซ้ำของการเรียงลำดับแบบผสาน
ลองนึกภาพต้นไม้การเรียกซ้ำของการเรียงลำดับแบบผสานสำหรับ n=8: ระดับ 0 มีอาร์เรย์หนึ่งชุดที่มีสมาชิก 8 ตัว ระดับ 1 มีอาร์เรย์สองชุด ชุดละ 4 ตัว ระดับ 2 มีอาร์เรย์สี่ชุด ชุดละ 2 ตัว และระดับ 3 มีสมาชิกเดี่ยว 8 ตัว (กรณีฐาน) เมื่อย้อนกลับขึ้นมา ระดับ 3→2 จะผสานสมาชิกทั้งหมด 8 ตัว ระดับ 2→1 จะผสานทั้งหมด 8 ตัว และระดับ 1→0 จะผสานทั้งหมด 8 ตัว ดังนั้นจึงมี 3 ระดับ × สมาชิก 8 ตัว = 24 การดำเนินการ ≈ 8 × log₂(8) = 24 ซึ่งยืนยันความซับซ้อน O(n log n)
# Trace the tree depth
level_work = []
def merge_sort_traced(arr, depth=0):
if depth >= len(level_work):
level_work.append(0)
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort_traced(arr[:mid], depth+1)
right = merge_sort_traced(arr[mid:], depth+1)
level_work[depth] += len(arr) # track merge work
merged = sorted(left + right) # simplified merge
return merged
merge_sort_traced(list(range(8, 0, -1)))
for d, work in enumerate(level_work):
print(f'Level {d}: {work} elements merged')การเรียงลำดับแบบผสานภายในพื้นที่เดิม
การเรียงลำดับแบบผสานด้วยการเรียกซ้ำตามมาตรฐานจะจัดสรรพื้นที่หน่วยความจำเสริม O(n) สำหรับผลลัพธ์ของการผสาน การเรียงลำดับแบบผสานที่ทำงานภายในพื้นที่เดิมมีอยู่จริง แต่ซับซ้อนและมีค่าคงที่สูง จึงแทบไม่ถูกถามในการสัมภาษณ์ คำถามต่อยอดที่พบบ่อยคือ: 「คุณสามารถทำการเรียงลำดับแบบผสานโดยใช้พื้นที่เพิ่มเติม O(1) ได้หรือไม่」 คำตอบที่ถูกต้องคือ: 「ในทางทฤษฎีทำได้ แต่การใช้งานจริงต้องแลกด้วยพื้นที่ O(n) หรือเพิ่มความซับซ้อน โดย Timsort ของไพธอนใช้พื้นที่ O(n) สำหรับการผสาน」
# Bottom-up merge sort: iterative, avoids recursion stack
def merge_sort_bottomup(arr):
n = len(arr)
width = 1
while width < n:
for i in range(0, n, 2 * width):
left = arr[i:i+width]
right = arr[i+width:i+2*width]
# Merge and put back
merged = []
a, b = 0, 0
while a < len(left) and b < len(right):
if left[a] <= right[b]: merged.append(left[a]); a+=1
else: merged.append(right[b]); b+=1
merged += left[a:] + right[b:]
arr[i:i+len(merged)] = merged
width *= 2
return arr
print(merge_sort_bottomup([5,2,4,6,1,3]))
# [1, 2, 3, 4, 5, 6]การเรียงลำดับแบบผสานมีความเสถียร
การเรียงลำดับแบบผสานมีความ เสถียร: สมาชิกที่มีค่าเท่ากันจากครึ่งซ้ายจะปรากฏก่อนสมาชิกที่มีค่าเท่ากันจากครึ่งขวาในผลลัพธ์ที่ผสานแล้วเสมอ สิ่งนี้รับประกันได้ด้วยการใช้ <= (ไม่ใช่ <) เมื่อเลือกสมาชิกจากฝั่งซ้ายก่อน ความเสถียรมีความสำคัญต่อการเรียงลำดับด้วยหลายคีย์ ฟังก์ชันในตัวของไพธอนอย่าง sorted() และ list.sort() ใช้ Timsort ซึ่งมีความเสถียรและมีความซับซ้อน O(n log n) เช่นกัน จึงเป็นตัวเลือกที่ปลอดภัยสำหรับโค้ดที่ใช้งานจริงทั้งหมด
# Demonstrating stability: sort (value, original_index) pairs
items = [(3,'A'), (1,'B'), (3,'C'), (2,'D')]
# Sort by value only
result = merge_sort_full(items) # won't work directly
# Use Python's stable sort:
result = sorted(items, key=lambda x: x[0])
print(result)
# [(1,'B'),(2,'D'),(3,'A'),(3,'C')]
# 'A' comes before 'C' for value=3 (stable order)การผสานอาร์เรย์ที่เรียงลำดับแล้ว k ชุด
การผสานอาร์เรย์ที่เรียงลำดับแล้ว k ชุดซึ่งมีสมาชิกทั้งหมด n ตัว สามารถทำได้โดยผสานอาร์เรย์เป็นคู่ ๆ ซ้ำไป (คล้ายสายการแข่งขันแบบแพ้คัดออก) ใช้เวลา O(n log k) แต่ละระดับของการผสานประมวลผลสมาชิก n ตัว และมีทั้งหมด log k ระดับ อีกวิธีหนึ่งคือใช้มินฮีพขนาด k: เพิ่มสมาชิกที่เหลือซึ่งมีค่าน้อยที่สุดจากแต่ละอาร์เรย์ลงในฮีพ นำค่าต่ำสุดออก แล้วเพิ่มสมาชิกถัดไปจากอาร์เรย์นั้น วิธีใช้ฮีพก็มีความซับซ้อน O(n log k) เช่นกัน แต่ใช้หน่วยความจำได้มีประสิทธิภาพกว่าเมื่อ k มีขนาดใหญ่มาก
import heapq
def merge_k_sorted(arrays):
result = []
heap = []
# Push first element from each array with array index
for i, arr in enumerate(arrays):
if arr:
heapq.heappush(heap, (arr[0], i, 0))
while heap:
val, arr_i, elem_i = heapq.heappop(heap)
result.append(val)
if elem_i + 1 < len(arrays[arr_i]):
next_val = arrays[arr_i][elem_i + 1]
heapq.heappush(heap, (next_val, arr_i, elem_i+1))
return result
arrs = [[1,4,7],[2,5,8],[3,6,9]]
print(merge_k_sorted(arrs)) # [1,2,3,4,5,6,7,8,9]นับการกลับลำดับด้วยการเรียงลำดับแบบผสาน
การนับการกลับลำดับ (คู่สมาชิกที่ a[i] > a[j] และ i < j) ในเวลา O(n log n) ใช้การเรียงลำดับแบบผสานที่ดัดแปลง ระหว่างขั้นตอนการผสาน เมื่อสมาชิกจากอาร์เรย์ย่อยด้านขวามีค่าน้อยกว่าสมาชิกจากอาร์เรย์ย่อยด้านซ้าย สมาชิกนั้นจะสร้างการกลับลำดับกับสมาชิกที่เหลือทุกตัวในอาร์เรย์ย่อยด้านซ้าย ให้เพิ่ม len(left) - i ลงในตัวนับ ณ จังหวะนั้น
def count_inversions(arr):
if len(arr) <= 1:
return arr, 0
mid = len(arr) // 2
left, l_inv = count_inversions(arr[:mid])
right, r_inv = count_inversions(arr[mid:])
merged = []
inversions = l_inv + r_inv
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i]); i += 1
else:
merged.append(right[j]); j += 1
inversions += len(left) - i # all remaining left elements > right[j]
merged.extend(left[i:] + right[j:])
return merged, inversions
_, inv = count_inversions([3, 1, 2])
print(inv) # 2: (3,1) and (3,2)การเรียงลำดับแบบผสานเทียบกับการเรียงลำดับแบบเร็ว
การเรียงลำดับแบบผสานรับประกันความซับซ้อน O(n log n) ในทุกกรณี มีความเสถียร และเป็นตัวเลือกที่ดีกว่าสำหรับรายการเชื่อมโยงกับการเรียงลำดับภายนอก การเรียงลำดับแบบเร็วมีกรณีเฉลี่ยเป็น O(n log n) แต่กรณีแย่ที่สุดเป็น O(n²) ทำงานภายในพื้นที่เดิม (ใช้พื้นที่สแตก O(log n)) และมักทำงานเร็วกว่าในทางปฏิบัติเนื่องจากใช้งานแคชของอาร์เรย์ได้มีประสิทธิภาพ การเรียงลำดับในตัวของไพธอนใช้ Timsort (รูปแบบหนึ่งของการเรียงลำดับแบบผสาน) จึงเป็นตัวเลือกเริ่มต้นที่ถูกต้องเสมอ
# Head-to-head complexity comparison:
# Algorithm | Best | Avg | Worst | Space | Stable
# Bubble sort | O(n) | O(n^2) | O(n^2) | O(1) | Yes
# Insertion sort| O(n) | O(n^2) | O(n^2) | O(1) | Yes
# Merge sort | O(nlogn)| O(nlogn)| O(nlogn)| O(n) | Yes
# Quick sort | O(nlogn)| O(nlogn)| O(n^2) | O(logn)| No
# Heap sort | O(nlogn)| O(nlogn)| O(nlogn)| O(1) | No
print('Merge sort: stable, O(n log n) guaranteed, O(n) space')การเรียงลำดับภายนอก: การเรียงลำดับแบบผสานในระดับขนาดใหญ่
การเรียงลำดับแบบผสานคืออัลกอริทึมที่อยู่เบื้องหลัง การเรียงลำดับภายนอก (การเรียงลำดับข้อมูลที่มีขนาดใหญ่เกินกว่าจะใส่ไว้ใน RAM ได้) ข้อมูลจะถูกอ่านเป็นส่วน ๆ โดยแต่ละส่วนจะถูกเรียงลำดับในหน่วยความจำ แล้วจึงผสานส่วนต่าง ๆ จากดิสก์ ขั้นตอนการผสานจะอ่านสมาชิกทีละตัวจากข้อมูลที่เรียงลำดับแล้วแต่ละชุด โดยเก็บสมาชิกไว้ในหน่วยความจำพร้อมกันเพียง O(k) ตัว (ชุดละหนึ่งตัว) ด้วยเหตุนี้การเรียงลำดับแบบผสานจึงถูกใช้ในฐานข้อมูล ฮาดูป MapReduce และอัลกอริทึมการเรียงลำดับบนเทปแบบดั้งเดิม
# Simulated external sort: sort in chunks then merge
def external_sort(data, chunk_size):
chunks = []
for i in range(0, len(data), chunk_size):
chunk = sorted(data[i:i+chunk_size]) # sort in-memory
chunks.append(chunk)
print(f'Created {len(chunks)} sorted chunks')
# Merge all chunks
import heapq
heap = [(c[0], i, 0) for i, c in enumerate(chunks) if c]
heapq.heapify(heap)
result = []
while heap:
val, ci, ei = heapq.heappop(heap)
result.append(val)
if ei + 1 < len(chunks[ci]):
heapq.heappush(heap, (chunks[ci][ei+1], ci, ei+1))
return result
print(external_sort(list(range(20,0,-1)), 5)[:10])สรุปการเรียงลำดับแบบผสานและเคล็ดลับการสัมภาษณ์
ในการสัมภาษณ์ การเขียนการเรียงลำดับแบบผสานให้สะอาดแสดงให้เห็นว่าคุณเข้าใจการเรียกซ้ำ ขั้นตอนการผสาน และการแบ่งแล้วพิชิต คำถามต่อยอดที่พบบ่อย:
- เหตุใดจึงเป็น O(n log n) ไม่ใช่ O(n²) (มี log n ระดับ × งาน n รายการต่อระดับ)
- มีความเสถียรหรือไม่ (มี ให้ใช้ <= ในการผสาน)
- ใช้พื้นที่เท่าใด (พื้นที่เสริม O(n) + สแตก O(log n))
- ทำแบบวนซ้ำได้หรือไม่ (ได้ ใช้การเรียงลำดับแบบผสานจากล่างขึ้นบน)
- จะใช้กับรายการเชื่อมโยงอย่างไร (ง่ายกว่าใช้อาร์เรย์ เพราะไม่มีต้นทุนการตัดแบ่ง O(n) และใช้ตัวชี้ช้า-เร็วเพื่อหาจุดกึ่งกลาง)
# One-shot merge sort for interview clarity:
def ms(a):
if len(a) <= 1: return a
m = len(a) // 2
l, r, res, i, j = ms(a[:m]), ms(a[m:]), [], 0, 0
while i < len(l) and j < len(r):
if l[i] <= r[j]: res.append(l[i]); i+=1
else: res.append(r[j]); j+=1
return res + l[i:] + r[j:]
print(ms([5,2,4,6,1,3])) # [1,2,3,4,5,6]แบบทดสอบสั้น ๆ
ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูลและอัลกอริทึม — การเตรียมตัวสัมภาษณ์การเขียนโค้ดจากบทเรียนนี้
ทบทวนบทเรียน
ในบทเรียนนี้ คุณได้เรียนรู้ว่า: การเรียงลำดับแบบผสานจะแบ่งอาร์เรย์ที่จุดกึ่งกลาง เรียงลำดับแต่ละครึ่งด้วยการเรียกซ้ำ และผสานครึ่งที่เรียงลำดับแล้วทั้งสองฝั่งในเวลา O(n) ทำให้เวลาทำงานรวมเป็น O(n log n) ตลอด log n ระดับของการเรียกซ้ำ ขั้นตอนการผสานใช้ <= เพื่อเลือกสมาชิกจากฝั่งซ้ายเมื่อค่าเท่ากัน ซึ่งรับประกันความเสถียร และ การเรียงลำดับแบบผสานเป็นอัลกอริทึมที่ควรเลือกใช้กับรายการเชื่อมโยง การเรียงลำดับภายนอก และกรณีที่ต้องการความเสถียร ขณะที่การเรียงลำดับแบบเร็วมักเหมาะกับอาร์เรย์ในหน่วยความจำเมื่อพื้นที่มีจำกัด บทถัดไปเราจะลงมือทำการเรียงลำดับแบบเร็วและสำรวจกลยุทธ์การเลือกจุดหมุน
คำถามที่พบบ่อย
บทเรียน “การเรียงแบบผสาน: แบ่ง เรียง ผสาน” ฟรีหรือไม่
ใช่ — ข้อความเต็มของ “การเรียงแบบผสาน: แบ่ง เรียง ผสาน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส Coding Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน
คุณจะเรียนรู้อะไรในบทเรียน “การเรียงแบบผสาน: แบ่ง เรียง ผสาน”
สร้างการเรียงแบบผสานด้วยการเรียกซ้ำ ติดตามต้นไม้แบบแบ่งแล้วพิชิต และอธิบายว่าเหตุใดจึงรับประกัน O(n log n) ได้ทุกกรณี คุณปฏิบัติ Coding Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน
คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน Coding Interview Prep หรือไม่
ไม่จำเป็นต้องมีประสบการณ์มาก่อน Coding Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน
บทเรียน “การเรียงแบบผสาน: แบ่ง เรียง ผสาน” ใช้เวลานานแค่ไหน
บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย
ฉันเขียนและรันโค้ดในบทเรียน Coding Interview Prep นี้ได้ไหม
ได้ บทเรียน Coding Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ
บทเรียนทั้งหมดในหลักสูตรนี้
- การเรียงแบบฟองและการเรียงแบบแทรก
- การเรียงแบบผสาน: แบ่ง เรียง ผสาน
- การเรียงแบบเร็วและการเลือกหมุด
- การเรียงที่ไม่เปรียบเทียบและ sort() ของ Python