0Pricing
Coding Interview Prep · บทเรียน

การเรียงแบบผสาน: แบ่ง เรียง ผสาน

สร้างการเรียงแบบผสานด้วยการเรียกซ้ำ ติดตามต้นไม้แบบแบ่งแล้วพิชิต และอธิบายว่าเหตุใดจึงรับประกัน 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 ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. การเรียงแบบฟองและการเรียงแบบแทรก
  2. การเรียงแบบผสาน: แบ่ง เรียง ผสาน
  3. การเรียงแบบเร็วและการเลือกหมุด
  4. การเรียงที่ไม่เปรียบเทียบและ sort() ของ Python
← กลับไปที่ Coding Interview Prep