ข้ามไปยังเนื้อหา

การค้นหาและการจัดเรียง

การค้นหา (searching) และการจัดเรียง (sorting) คือจุดที่ Big-O กลายเป็นสิ่งที่ สัมผัสได้จริง — ความต่างระหว่าง O(n) กับ O(log n) หรือ O(n²) กับ O(n log n) ไม่ใช่แค่ตัวเลขบนกระดาษ แต่คือความต่างระหว่างโปรแกรมที่ตอบในพริบตากับโปรแกรมที่ค้างไปหลายวินาที บทเรียนนี้ยังเป็นแบบฝึกการตัดสินใจ: AI สร้างฟังก์ชันจัดเรียงให้ได้ห้าแบบในไม่กี่วินาที แต่มีแค่คุณเท่านั้นที่บอกได้ว่าแบบไหนถูกต้อง แบบไหนเร็วพอ และแบบไหน เสถียร (stable) — คุณสมบัติที่มักถูกมองข้ามจนไปพังระบบจริงแบบเงียบ ๆ

การค้นหาเชิงเส้น (linear search) คือการไล่ดูทีละตัวตั้งแต่ต้นจนเจอ (หรือจนหมดรายการ) ทำงานได้กับข้อมูลทุกแบบ ไม่ต้องเรียงลำดับมาก่อน แต่กรณีเลวร้ายต้องดูครบทุกตัว จึงเป็น O(n)

การค้นหาทวิภาค (binary search) เร็วกว่ามากที่ O(log n) แต่มีเงื่อนไขสำคัญ: ข้อมูลต้องเรียงลำดับมาก่อน แล้วเท่านั้น

แนวคิดคือ “ตัดครึ่ง” (halving): มองที่ค่ากลางของช่วง

  • ถ้าค่ากลาง = ค่าที่หา → เจอแล้ว
  • ถ้าค่ากลาง < ค่าที่หา → ค่าที่หาต้องอยู่ครึ่งขวา ทิ้งครึ่งซ้ายไป
  • ถ้าค่ากลาง > ค่าที่หา → ค่าที่หาต้องอยู่ครึ่งซ้าย ทิ้งครึ่งขวาไป

ทุกก้าวตัดข้อมูลที่เหลือทิ้งครึ่งหนึ่ง ข้อมูล ๑,๐๐๐,๐๐๐ ตัว ใช้แค่ราว ๒๐ ก้าวก็เจอ (เพราะ log₂(1,000,000) ≈ 20) เทียบกับการค้นหาเชิงเส้นที่อาจต้องดูถึงล้านครั้ง

binary search เขียนผิดง่ายอย่างน่าตกใจ จุดพลาดยอดฮิตคือขอบเขตของ lo, mid, hi และ off-by-one (พลาดไปหนึ่ง) จนเกิดวนไม่รู้จบหรือข้ามค่าที่ต้องเจอ นี่คือเวอร์ชันที่ถูกต้อง:

def binary_search(arr, target):
lo = 0
hi = len(arr) - 1 # hi ชี้ดัชนีตัวสุดท้ายที่ "ยังเป็นไปได้"
while lo <= hi: # ต้องเป็น <= ไม่ใช่ < เพื่อตรวจช่วงที่เหลือ 1 ตัว
mid = (lo + hi) // 2 # หารปัดลง ได้ดัชนีกลาง
if arr[mid] == target:
return mid # เจอแล้ว คืนดัชนี
elif arr[mid] < target:
lo = mid + 1 # ตัดครึ่งซ้ายทิ้ง (+1 กันค้าง)
else:
hi = mid - 1 # ตัดครึ่งขวาทิ้ง (-1 กันค้าง)
return -1 # ไม่เจอ

จุดที่ต้องระวัง:

  • hi = len(arr) - 1 ไม่ใช่ len(arr) — เพราะ hi คือดัชนีของตัวสุดท้ายที่ยังพิจารณาอยู่
  • while lo <= hi ต้องใช้ <= ไม่ใช่ < มิฉะนั้นช่วงที่เหลือเพียง ๑ ตัวจะไม่ถูกตรวจ
  • lo = mid + 1 และ hi = mid - 1 ต้องมี +1/-1 เสมอ ถ้าเขียน lo = mid หรือ hi = mid เมื่อช่วงแคบเหลือ ๒ ตัว mid จะไม่ขยับ → วนไม่รู้จบ

เคล็ดลับกันบั๊ก: ใช้ mid = lo + (hi - lo) // 2 แทน (lo + hi) // 2 ในภาษาที่จำนวนเต็มมีขีดจำกัด (เช่น Java/C++) เพื่อกัน integer overflow — ใน Python ไม่จำเป็นเพราะ int ขยายได้ไม่จำกัด

อัลกอริทึมจัดเรียงที่เรียนกันส่วนใหญ่เป็น comparison sort คือตัดสินใจจากการ “เปรียบเทียบ” ค่าทีละคู่ว่าตัวไหนมาก่อน ที่เหลือของบทเรียนนี้จะแบ่งอัลกอริทึมเป็นสองกลุ่ม: กลุ่ม O(n²) ที่ง่าย ควรเข้าใจให้ลึกแต่แทบไม่ได้เขียนใช้จริงในโปรดักชัน กับกลุ่ม O(n log n) ที่อยู่เบื้องหลัง standard library ทุกภาษาที่ใช้จริง

ความเสถียร (stability) — อัลกอริทึมจัดเรียงจะ เสถียร ก็ต่อเมื่อ ค่าที่เท่ากันยังคงลำดับเดิมเทียบกันหลังจัดเรียงเสร็จ

ทำไมต้องสนใจ? สมมติเรียงรายชื่อพนักงานตามชื่อไว้ก่อน แล้วเรียงอีกครั้งตามแผนก ถ้าอัลกอริทึม เสถียร คนในแผนกเดียวกันจะยังเรียงตามชื่ออยู่ — เราจึง “ซ้อนการเรียง” หลายชั้นได้ ถ้าไม่เสถียร ลำดับชื่อเดิมจะปั่นป่วน

สามอัลกอริทึมนี้คือการจัดเรียง “แบบตรงไปตรงมา” — แบบที่คุณอาจคิดเองได้ก่อนเรียนอะไรที่ฉลาดกว่านี้ ทั้งสามตัวเปรียบเทียบค่าที่อยู่ติดกันหรือใกล้กัน และในกรณีเลวร้ายต้องเปรียบเทียบราว n²/2 ครั้ง ความต่างอยู่ที่ วิธีย้ายข้อมูล และที่สำคัญคือ ตัวไหนรักษาความเสถียรได้บ้าง

ไล่สแกนอาร์เรย์ซ้ำ ๆ แล้วสลับคู่ที่อยู่ติดกันหากลำดับผิด แต่ละรอบ (pass) จะ “ดัน” ค่าที่มากที่สุดในส่วนที่เหลือไปอยู่ตำแหน่งท้ายสุดของมัน

def bubble_sort(arr):
n = len(arr)
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i): # ตัวท้าย i ตัวเรียงเสร็จแล้ว ไม่ต้องแตะ
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped: # ออกก่อนเวลา: รอบนี้ไม่มีการสลับเลย
break
return arr

เพราะจะสลับก็ต่อเมื่อ arr[j] > arr[j+1] (มากกว่าจริง ๆ) ค่าที่ เท่ากัน จึงไม่มีวันถูกสลับข้ามกัน — bubble sort จึง เสถียร

ไล่ตามมือบนอาร์เรย์ [5, 3, 8, 4, 2]:

รอบ การเปรียบเทียบ (สลับไหม?) อาร์เรย์หลังรอบนี้ จำนวนสลับ
1 (5,3)✓ (5,8)✗ (8,4)✓ (8,2)✓ [3, 5, 4, 2, 8] 3
2 (3,5)✗ (5,4)✓ (5,2)✓ [3, 4, 2, 5, 8] 2
3 (3,4)✗ (4,2)✓ [3, 2, 4, 5, 8] 1
4 (3,2)✓ [2, 3, 4, 5, 8] 1

เรียงเสร็จหลัง ๔ รอบ รวมสลับ ๗ ครั้ง รอบที่ ๕ จะไม่มีการสลับเลย ยืนยันว่าเสร็จแล้ว — การเช็ก “ไม่มีการสลับ” นี้เองที่ทำให้ bubble sort กรณีดีสุด (ข้อมูลเรียงมาแล้ว) เป็น O(n)

หา ค่าน้อยที่สุด ในส่วนที่ยังไม่เรียง แล้วสลับมันมาไว้ตำแหน่งหน้าสุด ทำซ้ำกับส่วนที่เหลือ

def selection_sort(arr):
n = len(arr)
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i] # สลับแค่ครั้งเดียวต่อรอบ
return arr

selection sort จะสแกน ทั้งหมด ของส่วนที่ยังไม่เรียงเพื่อหาค่าน้อยสุดเสมอ ไม่ว่าข้อมูลจะเรียงมาดีแค่ไหน จึงไม่มี “ออกก่อนเวลา” แบบ bubble sort และเป็น O(n²) แม้แต่กรณีดีสุด

ที่แย่กว่านั้นคือ การสลับครั้งเดียวท้ายแต่ละรอบสามารถ “กระโดดข้าม” ค่าที่เท่ากันไปได้ selection sort จึง ไม่เสถียร ตัวอย่าง: จัดเรียงตามตัวเลข โดยเก็บตัวอักษรไว้เป็นแท็กบอกตัวตนเดิม

[(5,A), (3,B), (5,C), (1,D)] → ค่าน้อยสุดคือ (1,D) ที่ดัชนี 3 สลับกับดัชนี 0: [(1,D), (3,B), (5,C), (5,A)] — สังเกตว่า (5,C) ตอนนี้อยู่ ก่อน (5,A) ทั้งที่เดิม A อยู่ก่อน C ในอาร์เรย์ตั้งต้น ลำดับสัมพัทธ์ของค่าที่เท่ากันถูกสลับ

สร้าง “ส่วนที่เรียงแล้ว” ทีละตัวจากซ้ายไปขวา: หยิบตัวถัดไป แล้วเลื่อนมันไปทางซ้ายผ่านทุกตัวที่มากกว่าในส่วนที่เรียงแล้ว จากนั้นวางมันลงในช่องที่ถูกต้อง

def insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key: # ต้องมากกว่าจริง ๆ ถึงจะเลื่อน (กันเสียเสถียรภาพ)
arr[j + 1] = arr[j] # เลื่อนตัวที่มากกว่าไปทางขวา
j -= 1
arr[j + 1] = key # วาง key ลงในช่องว่าง
return arr

เงื่อนไข while ใช้ arr[j] > key (มากกว่าจริง ๆ) ค่าที่เท่ากันจึงไม่มีวันถูกเลื่อนข้าม key ไป — insertion sort จึง เสถียร และยังเร็วที่สุดในสามตัวนี้เมื่อข้อมูล เกือบเรียงอยู่แล้ว เพราะแต่ละตัวเลื่อนแค่เท่าที่จำเป็นจริง ๆ กรณีดีสุดจึงเป็น O(n)

ทั้งสองอัลกอริทึมด้านล่างเอาชนะ O(n²) ได้ด้วยกลเม็ดเดียวกัน: แบ่งแยกและเอาชนะ (divide and conquer) — แบ่งปัญหาเป็นชิ้นเล็กลง แก้แต่ละชิ้นแบบเวียนเกิด (recursive) แล้วรวมผลลัพธ์ ความลึกของการเวียนเกิดคือ log n (ตัดครึ่งทุกครั้ง) และแต่ละชั้นของการเวียนเกิดทำงานรวม O(n) จึงได้ O(n log n)

แบ่งรายการครึ่งหนึ่งไปเรื่อย ๆ แบบเวียนเกิดจนเหลือตัวเดียว (ถือว่า “เรียงแล้ว” โดยปริยาย) แล้ว ผสาน (merge) ครึ่งที่เรียงแล้วกลับเข้าด้วยกัน โดยหยิบค่าหน้าสุดที่น้อยกว่าออกมาทีละตัว

def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid]) # แบ่ง
right = merge_sort(arr[mid:]) # แบ่ง
return merge(left, right) # ผสาน
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= (ไม่ใช่ <) ให้ฝั่งซ้ายชนะเมื่อเท่ากัน: เสถียร
result.append(left[i]); i += 1
else:
result.append(right[j]); j += 1
result.extend(left[i:]) # เก็บที่เหลือของฝั่งซ้าย
result.extend(right[j:])
return result

แบ่งครึ่ง [8, 3, 7, 4, 2, 6, 1, 5] ไปเรื่อย ๆ:

[8,3,7,4,2,6,1,5]
[8,3,7,4] [2,6,1,5]
[8,3] [7,4] [2,6] [1,5]
[8][3] [7][4] [2][6] [1][5] <- กรณีฐาน: เหลือตัวเดียว
[3,8] [4,7] [2,6] [1,5] <- ผสานเป็นคู่
[3,4,7,8] [1,2,5,6] <- ผสานเป็นสี่ตัว
[1,2,3,4,5,6,7,8] <- ผสานครั้งสุดท้าย

merge sort เป็น O(n log n) ทั้งกรณี ดีสุด เฉลี่ย และเลวร้ายสุด — ไม่มีข้อมูลอินพุตแบบไหนทำให้มันช้าลงได้ — และ เสถียร เพราะ merge เลือกจากฝั่งซ้ายเมื่อค่าเท่ากัน ต้นทุนคือ list result ต้องใช้หน่วยความจำเพิ่ม O(n) (ไม่ใช่ in-place)

เลือก ตัวหลัก (pivot) แล้วจัดอาร์เรย์ใหม่ให้ทุกตัวที่ <= pivot อยู่ก่อนมัน และทุกตัวที่มากกว่าอยู่หลังมัน (เรียกว่า partitioning) จากนั้นเวียนเกิดจัดเรียงแต่ละฝั่ง ต่างจาก merge sort ตรงที่ขั้นตอนรวมผลลัพธ์แทบไม่มีต้นทุน — พอสองฝั่งเรียงเสร็จ อาร์เรย์ทั้งหมดก็เรียงเสร็จทันที

def quicksort(arr, lo=0, hi=None):
if hi is None:
hi = len(arr) - 1
if lo < hi:
p = partition(arr, lo, hi)
quicksort(arr, lo, p - 1) # เวียนเกิดฝั่งซ้ายของ pivot
quicksort(arr, p + 1, hi) # เวียนเกิดฝั่งขวาของ pivot
def partition(arr, lo, hi):
pivot = arr[hi] # ใช้ตัวท้ายเป็น pivot (ง่าย แต่เสี่ยงที่สุด)
i = lo - 1
for j in range(lo, hi):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[hi] = arr[hi], arr[i + 1]
return i + 1 # ดัชนีที่ pivot ไปอยู่จริง

quicksort โดยเฉลี่ยเป็น O(n log n) และเพราะทำงานแบบ in-place ด้วยค่าคงที่ (constant) ที่เล็ก จึง เร็วกว่าในทางปฏิบัติ กว่า merge sort บ่อยครั้ง แต่กรณีเลวร้ายสุดคือ O(n²): ถ้า pivot เป็นค่าน้อยสุดหรือมากสุดของส่วนที่เหลือเสมอ การแบ่งกั้นจะแยก n ตัวเป็นกลุ่ม n-1 กับ 0 — ไม่ต่างจากไม่ได้แบ่งเลย อาร์เรย์ที่เรียงมาแล้วพร้อมกับใช้ pivot เป็น “ตัวท้าย” คือกรณีเลวร้ายสุดนี้เป๊ะ ๆ การสลับข้างในเวอร์ชัน partition ยังจัดลำดับค่าที่เท่ากันใหม่แบบไม่แน่นอน quicksort จึงโดยทั่วไป ไม่เสถียร (การสุ่มเลือก pivot หรือเลือกค่ามัธยฐานจากสามตัวเลือก ช่วยให้กรณีเลวร้ายสุด O(n²) แทบไม่เกิดในทางปฏิบัติ — แต่ไม่มีทางเป็นไปไม่ได้เลยด้วยโครงสร้างของอัลกอริทึมเอง)

ขีดจำกัดล่างของ O(n log n) — ทำไมทำได้ดีกว่านี้ไม่ได้

หัวข้อที่มีชื่อว่า “ขีดจำกัดล่างของ O(n log n) — ทำไมทำได้ดีกว่านี้ไม่ได้”

พิสูจน์ได้จริง ไม่ใช่แค่สังเกตเห็น ว่าอัลกอริทึมใดก็ตามที่จัดเรียงด้วยการเปรียบเทียบคู่ค่าล้วน ๆ ต้องใช้การเปรียบเทียบอย่างน้อย Ω(n log n) ครั้งในกรณีเลวร้ายสุด แนวคิดคือ decision tree (ต้นไม้การตัดสินใจ):

  • comparison sort ใด ๆ ที่รันบนข้อมูล n ตัวที่แตกต่างกัน ต้องสามารถสร้างผลลัพธ์เป็นได้ทุกหนึ่งใน n! ลำดับที่เป็นไปได้
  • แต่ละการเปรียบเทียบมีแค่สองผลลัพธ์ (< หรือ >) จึงมองอัลกอริทึมเป็นต้นไม้ไบนารีของการเปรียบเทียบ: ใบแต่ละใบตรงกับลำดับผลลัพธ์สุดท้ายหนึ่งแบบ
  • เพื่อแยกแยะ n! ลำดับที่ต่างกันได้ ต้นไม้ต้องมีใบอย่างน้อย n! ใบ ความลึกของต้นไม้ — จำนวนการเปรียบเทียบกรณีเลวร้ายสุด — จึงต้องอย่างน้อย log₂(n!)
  • ตามการประมาณของ Stirling log₂(n!) = Ω(n log n)

ตัวอย่างเล็ก: สำหรับ n = 3 ตัว มี 3! = 6 ลำดับที่เป็นไปได้ ต้นไม้ตัดสินใจแบบไบนารีต้องมี ⌈log₂ 6⌉ = 3 ระดับจึงจะมีใบที่แยกแยะได้ครบ ๖ ใบ — ดังนั้น comparison sort ที่ถูกต้อง ใด ๆ ต้องใช้การเปรียบเทียบสูงสุดถึง ๓ ครั้งเพื่อจัดเรียง ๓ ตัว ไม่ว่าจะเขียนฉลาดแค่ไหน นี่คือเหตุผลที่ merge sort และ quicksort ที่ O(n log n) ถือว่าเหมาะสมที่สุดในบรรดา comparison sort แล้ว — คุณไม่มีทางคิดค้น comparison sort ที่เร็วกว่านี้ในเชิงเส้นกำกับ (asymptotic) ได้ (แต่อัลกอริทึมที่ไม่เปรียบเทียบค่า เช่น counting sort หรือ radix sort เอาชนะ O(n log n) ได้ — เพราะอาศัยโครงสร้างพิเศษเพิ่มเติม เช่น คีย์เป็นจำนวนเต็มที่มีขอบเขตจำกัด แทนที่จะเปรียบเทียบค่า)

ความเสถียรฟังดูเป็นเรื่องวิชาการ จนกว่าคุณจะต้องเรียงข้อมูลด้วยมากกว่าหนึ่งคีย์ สมมติมีรายการคำสั่งซื้อ อยากเรียงตาม region ก่อน แล้ว ภายในแต่ละภูมิภาค เรียงตาม order_date:

orders = [
{"region": "West", "date": "2024-03-01"},
{"region": "East", "date": "2024-01-15"},
{"region": "West", "date": "2024-01-20"},
{"region": "East", "date": "2024-02-10"},
]
# เรียงคีย์รองก่อน แล้วค่อยเรียงคีย์หลักทีหลัง
# ถ้าอัลกอริทึมเสถียร ลำดับวันที่ภายในแต่ละภูมิภาคจะยังคงอยู่
orders.sort(key=lambda o: o["date"]) # เรียงครั้งที่ 1: ตามวันที่
orders.sort(key=lambda o: o["region"]) # เรียงครั้งที่ 2: ตามภูมิภาค (เสถียร! วันที่ยังเรียงอยู่)

เทคนิค “เรียงสองรอบ” นี้ใช้ได้เพราะ sort() ของ Python รับประกันว่าเสถียร ถ้าใช้อัลกอริทึมที่ไม่เสถียร (เช่น quicksort หรือ selection sort แบบไร้เดียงสา) เรียงรอบที่สอง ลำดับวันที่ภายในแต่ละภูมิภาคอาจปั่นป่วนได้ — บั๊กแบบแนบเนียนที่โผล่มาก็ต่อเมื่อมีคีย์ซ้ำกัน ซึ่งเป็นสิ่งที่ข้อมูลจริงเจอตลอดเวลา (คำสั่งซื้อหลายรายการมักอยู่ภูมิภาคเดียวกัน)

sorted() และ list.sort() ของ Python (รวมถึง Arrays.sort() ของ Java สำหรับ object) ไม่ได้ใช้ merge sort หรือ quicksort แบบตำราตรง ๆ — แต่ใช้ Timsort อัลกอริทึมลูกผสมที่ Tim Peters ออกแบบมาเพื่อใช้ประโยชน์จากรูปแบบของข้อมูลจริงโดยเฉพาะ:

  • สแกนหา run ที่มีอยู่แล้ว — ช่วงต่อเนื่องที่เรียงขึ้นหรือลงอยู่แล้วในข้อมูล (ข้อมูลจริงแทบไม่เคยสุ่มล้วน ๆ มักมีลำดับบางส่วนอยู่แล้ว) — แล้วกลับด้าน run ที่เรียงลงให้เรียงขึ้นทันที
  • run สั้น ๆ (ต่ำกว่าเกณฑ์ ปกติ ๓๒–๖๔ ตัว) จะถูกขยายและเรียงด้วย insertion sort — ซึ่งเร็วมากบนข้อมูลขนาดเล็กหรือเกือบเรียงแล้ว
  • run ที่ยาวกว่าจะถูกรวมด้วยการ ผสาน แบบ merge sort อย่างพิถีพิถัน
  • ผลลัพธ์: กรณีดีสุดเป็น O(n) (เช่น ข้อมูลเรียงมาแล้วหรือเรียงกลับด้านอยู่แล้ว) กรณีเฉลี่ยและเลวร้ายสุดเป็น O(n log n) และที่สำคัญคือ รับประกันความเสถียร ซึ่งเป็นเหตุผลที่แพทเทิร์น “เรียงสองรอบ” ด้านบนเชื่อถือได้

ข้อคิดเชิงปฏิบัติ: อย่าเขียน bubble/selection/insertion sort เองเพื่อใช้จัดเรียงข้อมูลจริงในโปรดักชัน ใช้ตัวจัดเรียงในตัวของภาษา (Timsort ใน Python/Java, Introsort ใน C++/std::sort) — เพราะทั้งเหมาะสมที่สุดเชิงเส้นกำกับ และผ่านการทดสอบกับ edge case ที่คุณอาจนึกไม่ถึงมาแล้ว

อัลกอริทึม ดีสุด (best) เฉลี่ย (avg) เลวร้าย (worst) หน่วยความจำ เสถียร?
Linear search O(1) O(n) O(n) O(1)
Binary search O(1) O(log n) O(log n) O(1)
Bubble sort O(n) O(n²) O(n²) O(1) ใช่
Selection sort O(n²) O(n²) O(n²) O(1) ไม่
Insertion sort O(n) O(n²) O(n²) O(1) ใช่
Merge sort O(n log n) O(n log n) O(n log n) O(n) ใช่
Quicksort O(n log n) O(n log n) O(n²) O(log n) ไม่
Timsort (sorted()) O(n) O(n log n) O(n log n) O(n) ใช่

binary search กรณีดีสุดเป็น O(1) เมื่อเจอที่ค่ากลางทันที ส่วน selection sort กรณีดีสุด ยังคง เป็น O(n²) — เพราะมันสแกนส่วนที่เหลือทั้งหมดเพื่อหาค่าน้อยสุดเสมอ ต่างจาก bubble/insertion sort ที่ออกก่อนเวลาได้เมื่อข้อมูลเรียงมาแล้ว

ลองนึกถึงแอประบบสมาชิกที่มีผู้ใช้ ๕๐ ล้านคน เก็บเรียงตาม user_id ไว้ในไฟล์ดัชนี เมื่อมีคนล็อกอิน เราต้องค้นหา user_id ของเขา

ถ้าใช้ linear scan ไล่ทีละแถว กรณีเลวร้ายต้องอ่าน ๕๐ ล้านแถวต่อการล็อกอินหนึ่งครั้ง — ช้าจนใช้งานจริงไม่ได้เมื่อมีคนล็อกอินพร้อมกันมาก ๆ

ถ้าใช้ binary search บนข้อมูลที่เรียงแล้ว ใช้เพียงราว log₂(50,000,000) ≈ 26 ก้าว ก็ตอบได้ — เร็วกว่าหลายล้านเท่า

เมื่อใดการเรียงก่อนถึงคุ้ม? การเรียงข้อมูลครั้งแรกมีต้นทุน O(n log n) แต่ถ้าหลังจากนั้นต้องค้นหาซ้ำ ๆ หลายครั้ง (k ครั้ง) ต้นทุนรวมจะเป็น O(n log n + k log n) ซึ่งคุ้มกว่าค้นหาเชิงเส้น O(k·n) มาก เมื่อ k ใหญ่ — แต่ถ้าค้นหาแค่ครั้งเดียวแล้วเลิก linear scan O(n) อาจคุ้มกว่าการเสียเวลาเรียงทั้งกอง

๑. ไล่ตามมือ: binary search จากอาร์เรย์ [1, 3, 5, 7, 9, 11, 13, 15, 17, 19] ค้นหา target = 13 ให้เขียนค่าของ lo, mid, hi ในแต่ละรอบจนเจอ

เฉลย
รอบ lo hi mid arr[mid] ทำอะไร
1 0 9 4 9 9 < 13 → lo = 5
2 5 9 7 15 15 > 13 → hi = 6
3 5 6 5 11 11 < 13 → lo = 6
4 6 6 6 13 เจอ! คืนดัชนี 6

ใช้เพียง ๔ รอบเทียบกับการไล่เชิงเส้นที่ต้องดูถึงตัวที่ ๗

๒. หาตำแหน่งแรกและตำแหน่งสุดท้าย ถ้าอาร์เรย์มีค่าซ้ำได้ เช่น [2, 4, 4, 4, 6, 8] แล้วต้องการหา ดัชนีแรกสุด ที่มีค่า 4 จะแก้ binary search อย่างไร?

เฉลย

เมื่อเจอ arr[mid] == target อย่ารีบคืนค่า ให้ “จำดัชนีไว้แล้วเดินต่อไปทางซ้าย” เพื่อหาตัวที่อยู่ก่อนหน้า:

def first_occurrence(arr, target):
lo, hi = 0, len(arr) - 1
ans = -1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
ans = mid # จำไว้ แล้วลองหาทางซ้ายต่อ
hi = mid - 1
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return ans

หาตำแหน่งสุดท้ายทำกลับกัน: เมื่อเจอให้ lo = mid + 1 เพื่อเดินไปทางขวา

๓. ทำไมต้องเรียงก่อน อธิบายว่าทำไม binary search จึงใช้กับข้อมูลที่ “ยังไม่เรียง” ไม่ได้ ยกตัวอย่างประกอบ

เฉลย

binary search ตัดสินใจว่าจะไปซ้ายหรือขวาจากกฎ “ถ้าค่ากลางน้อยกว่า target คำตอบต้องอยู่ขวา” ซึ่งกฎนี้เป็นจริง ก็ต่อเมื่อข้อมูลเรียงลำดับแล้ว เท่านั้น

ตัวอย่าง: ใน [9, 1, 5, 3, 7] หา 7 ค่ากลางคือ 5 เนื่องจาก 5 < 7 เราจึงทิ้งครึ่งซ้ายและไปดูครึ่งขวา [3, 7] — บังเอิญเจอ แต่ถ้าหา 1 ค่ากลาง 5 > 1 เราจะทิ้งครึ่งขวาและไปครึ่งซ้าย [9] แล้วสรุปผิดว่า “ไม่มี 1” ทั้งที่ 1 อยู่ในรายการ การตัดทิ้งครึ่งหนึ่งจึงเชื่อถือได้เฉพาะกับข้อมูลที่เรียงแล้ว

๔. จับ off-by-one โค้ดนี้ผิดตรงไหน?

def search(arr, target):
lo, hi = 0, len(arr) # (ก)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid # (ข)
else:
hi = mid
return -1
เฉลย

บรรทัด (ข) ผิด: lo = mid ทำให้ lo ไม่ขยับเมื่อช่วงแคบลงเหลือ ๒ ตัว (เพราะ mid จะเท่ากับ lo) → วนไม่รู้จบ ต้องแก้เป็น lo = mid + 1

(หมายเหตุ: รูปแบบ hi = len(arr) คู่กับ while lo < hi และ hi = mid เป็นสไตล์ half-open ที่ถูกต้องได้ ตราบใดที่ฝั่ง lo ใช้ mid + 1 เสมอ)

๕. นับจำนวนก้าวของ binary search อาร์เรย์มี ๑,๐๐๐,๐๐๐ ตัว binary search ใช้ก้าวมากสุดกี่ก้าวในกรณีเลวร้าย?

เฉลย

ประมาณ ⌈log₂(1,000,000)⌉ = 20 ก้าว เพราะแต่ละก้าวตัดข้อมูลครึ่งหนึ่ง 2²⁰ = 1,048,576 > 1,000,000 เทียบกับ linear search ที่อาจต้องดูถึง ๑,๐๐๐,๐๐๐ ครั้ง

๖. ไล่ตามมือ: bubble sort ไล่ตามมือ bubble sort บนอาร์เรย์ [5, 3, 8, 4, 2] แต่ละรอบให้ระบุการเปรียบเทียบ ตัวไหนสลับบ้าง และอาร์เรย์ที่ได้ รวมแล้วมีการสลับกี่ครั้ง?

เฉลย
รอบ การเปรียบเทียบ (สลับไหม?) อาร์เรย์หลังรอบนี้
1 (5,3)✓ (5,8)✗ (8,4)✓ (8,2)✓ [3, 5, 4, 2, 8]
2 (3,5)✗ (5,4)✓ (5,2)✓ [3, 4, 2, 5, 8]
3 (3,4)✗ (4,2)✓ [3, 2, 4, 5, 8]
4 (3,2)✓ [2, 3, 4, 5, 8]

รวมสลับ: 3 + 2 + 1 + 1 = ๗ ครั้ง ใน ๔ รอบ รอบที่ ๕ จะไม่มีการสลับเลย ยืนยันว่าเรียงเสร็จแล้ว

๗. selection sort ทำลายความเสถียร เรียง [(5,'A'), (3,'B'), (5,'C'), (1,'D')] ตามค่าตัวเลขด้วย selection sort พร้อมไล่ตามการสลับ ลำดับสุดท้ายของ 5 สองตัวยังตรงกับลำดับสัมพัทธ์เดิมหรือไม่?

เฉลย

รอบ 1: ค่าน้อยสุดของทั้งอาร์เรย์คือ (1,'D') ที่ดัชนี 3 สลับกับดัชนี 0 → [(1,'D'), (3,'B'), (5,'C'), (5,'A')]

รอบ 2: ค่าน้อยสุดของ [(3,'B'), (5,'C'), (5,'A')] คือ (3,'B') อยู่ตำแหน่งเดิมแล้ว — ไม่สลับ

รอบ 3: ค่าน้อยสุดของ [(5,'C'), (5,'A')] คือ (5,'C') (ซ้ายสุดเมื่อเท่ากัน) อยู่ตำแหน่งเดิมแล้ว — ไม่สลับ

ผลลัพธ์สุดท้าย: [(1,'D'), (3,'B'), (5,'C'), (5,'A')] เดิม A อยู่ก่อน C แต่ตอนนี้ C อยู่ก่อน A ลำดับสลับกัน → selection sort ไม่เสถียร

๘. กรณีดีสุดของ insertion sort ไล่ตามมือ insertion sort บนอาร์เรย์ที่เกือบเรียงแล้ว [1, 2, 4, 3, 5] นับจำนวนการเลื่อนตัว (shift) ทั้งหมด แล้วอธิบายว่าทำไมนี่จึงใกล้เคียงกรณีดีสุด O(n) ของ insertion sort มากกว่ากรณีเลวร้ายสุด O(n²)

เฉลย
  • i=1, key=2: เทียบกับ 1 (ไม่มากกว่า) → เลื่อน 0 ครั้ง
  • i=2, key=4: เทียบกับ 2 (ไม่มากกว่า) → เลื่อน 0 ครั้ง
  • i=3, key=3: เทียบกับ 4 (มากกว่า เลื่อน) → เลื่อน 1 ครั้ง จากนั้นเทียบกับ 2 (ไม่มากกว่า) → หยุด วาง 3
  • i=4, key=5: เทียบกับ 4 (ไม่มากกว่า) → เลื่อน 0 ครั้ง

รวม: เลื่อน ๑ ครั้ง เพราะอาร์เรย์เกือบเรียงอยู่แล้ว ตัวส่วนใหญ่จึงแค่เทียบครั้งเดียวก็รู้ว่าอยู่ถูกที่แล้ว ลูป while ด้านในของ insertion sort แทบไม่ทำงาน ถ้าเป็นอาร์เรย์ที่เรียงกลับด้าน (reverse-sorted) ทุกตัวจะต้องเลื่อนไปจนสุดหน้า กลายเป็นกรณีเลวร้ายสุด O(n²) เต็มรูปแบบ

๙. กรณีเลวร้ายสุดของ quicksort โค้ด quicksort/partition ด้านบนเลือก arr[hi] (ตัวท้าย) เป็น pivot เสมอ ถ้ารันบนอาร์เรย์ที่เรียงมาแล้ว [1, 2, 3, 4, 5] จะเกิดอะไรขึ้น? ความซับซ้อนด้านเวลาคืออะไร และทำไม?

เฉลย

บนอาร์เรย์ที่เรียงแล้ว arr[hi] จะเป็นค่า มากที่สุด ของส่วนที่เหลือเสมอ การแบ่งกั้นแต่ละครั้งจึงเอาทุกตัวไปไว้ฝั่ง “น้อยกว่า” และไม่มีตัวไหนอยู่ฝั่ง “มากกว่า” เลย — การแบ่งจึงเป็น n-1 กับ 0 ทุกครั้ง แทนที่จะเป็นประมาณ n/2 กับ n/2 นั่นแปลว่าการเวียนเกิดไม่ได้ตัดครึ่งปัญหา แต่ลดลงแค่ทีละหนึ่งตัวต่อการเรียก ทำให้มีการเวียนเกิด n ชั้นแทนที่จะเป็น log n แต่ละชั้นทำงาน O(n) → รวม O(n²) วิธีแก้ในทางปฏิบัติ: เลือก pivot แบบสุ่ม หรือเลือกค่ามัธยฐานของตัวแรก/กลาง/ท้าย เพื่อไม่ให้อินพุตที่เรียงมาแล้ว (หรือถูกออกแบบมาโจมตี) กระตุ้นกรณีเลวร้ายสุดได้อย่างแน่นอน

๑๐. ทายผลลัพธ์: ความเสถียรในการเรียงสองรอบ

students = [
{"name": "Anan", "grade": "B"},
{"name": "Beau", "grade": "A"},
{"name": "Cham", "grade": "B"},
{"name": "Dara", "grade": "A"},
]
students.sort(key=lambda s: s["name"])
students.sort(key=lambda s: s["grade"])
print([s["name"] for s in students])

ผลลัพธ์คืออะไร และคำตอบจะเปลี่ยนไปหรือไม่ถ้า sort() ไม่เสถียร?

เฉลย

พิมพ์ ['Beau', 'Dara', 'Anan', 'Cham']

เรียงตามชื่อก่อนได้ [Anan, Beau, Cham, Dara] การเรียงครั้งที่สองตามเกรดจะจัดกลุ่ม A (Beau, Dara) ไว้ก่อนกลุ่ม B (Anan, Cham) — และเพราะ sort() ของ Python เสถียร แต่ละกลุ่มจึง ยังคง ลำดับตัวอักษรเดิมจากการเรียงครั้งแรกไว้ ถ้า sort() ไม่เสถียร ลำดับภายในกลุ่ม A และกลุ่ม B อาจออกมาแบบไหนก็ได้ (เช่น [Dara, Beau, Cham, Anan]) — การรับประกันว่า “เรียงตามตัวอักษรภายในแต่ละเกรด” จะหายไป

ขอให้ AI เขียน binary search ให้ ได้โค้ดนี้มาพร้อมคำรับรองว่า “ทดสอบแล้ว ทำงานถูกต้องแน่นอน”

def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid # <-- ดูบรรทัดนี้
else:
hi = mid # <-- และบรรทัดนี้
return -1

โจทย์: รันด้วย binary_search([1, 2, 3], 2) ดู — มันจะ ค้างไม่จบ ให้หาว่าผิดตรงไหนและแก้

เฉลย

บั๊กคือ lo = mid และ hi = mid ที่ ไม่มี +1/-1 ทำให้ช่วงไม่หดเมื่อเหลือข้อมูล ๒ ตัว

ลองไล่ binary_search([1, 2, 3], 0): เริ่ม lo=0, hi=2, mid=1, arr[1]=2 > 0hi = 1; รอบต่อมา lo=0, hi=1, mid=0, arr[0]=1 > 0hi = 0; ตอนนี้ lo=0, hi=0, mid=0, arr[0]=1 > 0hi = 0 ค่าเดิมไม่ขยับ วนซ้ำชั่วนิรันดร์

แก้โดยเลื่อนขอบเขตให้ข้าม mid เสมอ:

def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1 # แก้แล้ว
else:
hi = mid - 1 # แก้แล้ว
return -1

บทเรียน: โค้ดจาก AI ที่ “ดูเหมือนถูก” อาจมี off-by-one ซ่อนอยู่ ต้องทดสอบด้วยกรณีขอบ (เช่นค่าที่ไม่มีในรายการ) เสมอ

คำสั่งที่สอง: “เขียนฟังก์ชันจัดเรียงที่เสถียร” AI ตอบกลับมาด้วย selection sort พร้อมป้ายกำกับอย่างมั่นใจว่าเสถียร

def stable_sort(arr, key=lambda x: x):
# คอมเมนต์จาก AI: "Selection sort — เรียบง่ายและเสถียร"
n = len(arr)
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if key(arr[j]) < key(arr[min_idx]):
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
เฉลย

selection sort ไม่เสถียร — ดูตัวอย่างค้านในข้อ ๗ ด้านบนที่คีย์เท่ากันสองตัวถูกสลับลำดับโดยการสลับครั้งสุดท้ายของแต่ละรอบ คอมเมนต์ของ AI ผิดล้วน ๆ — “เรียบง่าย” ไม่ได้แปลว่า “เสถียร” สองคุณสมบัตินี้ไม่เกี่ยวข้องกัน ถ้าต้องการ sort ที่เสถียรจริง ๆ ให้ใช้ sorted()/list.sort() ในตัว (Timsort รับประกันเสถียร) หรือเขียน merge sort โดยใช้ <= ในขั้นตอน merge บทเรียน: ต้องตรวจสอบ คุณสมบัติ ที่อ้างไว้เสมอ (เสถียร, in-place, O(n log n)) เทียบกับกลไกจริงของอัลกอริทึม — อย่าเชื่อคอมเมนต์หรือ docstring ตามที่เขียนไว้เฉย ๆ โดยเฉพาะอันที่ AI เขียนอธิบายโค้ดของตัวเอง

ทุก ๆ เฟรม เรนเดอเรอร์ 2D ต้องตัดสินใจว่าจะวาดสไปรต์ที่ซ้อนทับกันตาม ลำดับ ไหน — “อัลกอริทึมของจิตรกร” (painter’s algorithm) แบบคลาสสิกคือวาดของที่อยู่ไกลก่อน แล้ววาดของที่อยู่ใกล้ทับลงไปทีหลัง ซึ่งไม่ใช่อะไรอื่นนอกจากการจัดเรียงสไปรต์ตามความลึก (y หรือ z-index เฉพาะ) ก่อนวาด สไปรต์ขยับแค่ไม่กี่พิกเซลต่อเฟรม ลำดับของเฟรมก่อนหน้าจึงเกือบเรียงอยู่แล้ว — นี่คือกรณีที่ insertion sort ถูกออกแบบมาให้เหมาะเป๊ะ และเมื่อมีรายการที่เรียงแล้วอยู่ในมือ — ไม่ว่าจะเป็นเวลาสปอว์นหรือตารางคะแนนสูงสุด — binary search จะเปลี่ยน “หาอีเวนต์ถัดไป” จากการสแกน O(n) ให้เป็นการค้นหา O(log n)

จัดเรียงสไปรต์ตามความลึก: อัลกอริทึมของจิตรกร

หัวข้อที่มีชื่อว่า “จัดเรียงสไปรต์ตามความลึก: อัลกอริทึมของจิตรกร”
class Sprite:
def __init__(self, name, y, spawn_order):
self.name = name
self.y = y # คีย์ความลึก: y มากกว่า = ใกล้กล้องมากกว่า
self.spawn_order = spawn_order # ตัวตัดสินเสมอ: สไปรต์ไหนถูกสร้างก่อน
# ร่างแรกแบบไร้เดียงสา: เรียงตาม y แล้วหวังว่าจะไม่มีปัญหา
def naive_depth_sort(sprites):
return sorted(sprites, key=lambda s: s.y)

ดูเหมือนจะโอเค — จนกว่าสไปรต์สองตัวจะมี y เท่ากัน (คบเพลิงกับลายพื้นบนไทล์เดียวกัน) sorted() ของ Python เสถียร (Timsort) ดังนั้น ภายในเฟรมเดียว สไปรต์ที่เท่ากันจะคงลำดับตามรายการอินพุตไว้ แต่ถ้า sprites ถูกสร้างใหม่ระหว่างเฟรม (ศัตรูตายแล้วถูกลบออก รายการถูกกรอง) ลำดับ อินพุต ของคู่ที่เท่ากันก็เปลี่ยนได้เช่นกัน — ลำดับการวาดจึงพลิกกลับแบบเงียบ ๆ และลายพื้นตัวหนึ่งกะพริบขึ้นมาทับอีกตัวในเฟรมนั้น ทั้งที่ไม่มีตัวไหนขยับเลย ทางแก้ที่ขยายผลได้คือใส่ตัวตัดสินเสมออย่างชัดเจน เพื่อให้สไปรต์ที่ความลึกเท่ากันได้ผลลัพธ์แบบเดิมเสมอ ไม่ว่าลำดับรายการจะบังเอิญเป็นอย่างไร:

def stable_depth_sort(sprites):
# ตัวตัดสินเสมอชัดเจน: y เท่ากัน -> สไปรต์ที่มีอยู่ก่อนวาดก่อน
return sorted(sprites, key=lambda s: (s.y, s.spawn_order))

เพราะสไปรต์ขยับแค่นิดเดียวต่อเฟรม รายการที่เรียงไว้เมื่อเฟรมก่อนจึง เกือบเรียงอยู่แล้ว ในเฟรมนี้ — มีแค่ไม่กี่ตัวที่ต้องขยับ นี่คืออินพุตที่ insertion sort เร็วที่สุดพอดี:

def insertion_depth_sort(sprites):
for i in range(1, len(sprites)):
current = sprites[i]
key = (current.y, current.spawn_order)
j = i - 1
while j >= 0 and (sprites[j].y, sprites[j].spawn_order) > key:
sprites[j + 1] = sprites[j]
j -= 1
sprites[j + 1] = current
return sprites

บนเฟรมที่เกือบเรียงแล้ว โค้ดนี้ทำงานใกล้เคียงกรณีดีสุด O(n) ของ insertion sort — ถูกกว่าการเรียงใหม่จากศูนย์ด้วยอัลกอริทึมทั่วไป O(n log n) มาก เมื่อจำนวนสไปรต์มากขึ้น

y=40 (วาดก่อน ไกลสุด) A

y=70 B

y=100 (วาดทีหลังสุด ใกล้สุด) C

รูป: สไปรต์ A (y=40), B (y=70), C (y=100) ถูกวาดจากไกลไปใกล้ตามลำดับ y จากน้อยไปมาก — แต่ละตัวที่วาดทีหลังจะซ้อนทับตัวก่อนหน้า เหมือนจิตรกรลงสีทีละชั้น

เกมแบบเวฟ (wave-based) เก็บอีเวนต์สปอว์นเป็นรายการที่เรียงตามเวลาไว้ เช่น [2, 6, 6, 12, 18, 25, 30, 40] ทุกเฟรมต้องตอบว่า “อีเวนต์ถัดไปหลัง now คืออันไหน” — ถ้าสแกนจากต้นทุกเฟรม พอตารางเวลายาวขึ้นก็จะเสียเวลาโดยเปล่าประโยชน์ binary search หาตัวแรกที่มากกว่า now จริง ๆ ได้:

def next_spawn_index(timeline, now):
"""timeline คือเวลาสปอว์นที่เรียงแล้ว; คืนดัชนีของอีเวนต์แรกที่มากกว่า now จริง ๆ"""
lo, hi = 0, len(timeline)
while lo < hi:
mid = (lo + hi) // 2
if timeline[mid] <= now:
lo = mid + 1
else:
hi = mid
return lo

ตารางคะแนนสูงสุดที่เรียงแล้วก็ใช้หลักการเดียวกัน: เมื่อจะหาว่าคะแนนใหม่ควรอยู่อันดับไหน ใช้ binary search หาตำแหน่งที่ควรแทรก แทนที่จะไล่ดูทุกแถว

แบบฝึกหัด

๑. เขียนคีย์สำหรับการจัดเรียง สไปรต์เป็นทูเพิล (name, y): [("torch", 40), ("player", 70), ("floor_a", 55), ("floor_b", 55)] ให้เขียน key= ที่จะส่งให้ sorted() เพื่อจัดเรียงตามความลึกแบบอัลกอริทึมของจิตรกร แล้วอะไรที่เปราะบางถ้าเรียงด้วย y อย่างเดียวเมื่อ floor_a กับ floor_b มีค่าเท่ากัน?

เฉลย

key=lambda s: s[1] เรียงตาม y เพราะ floor_a กับ floor_b เท่ากันที่ y=55 sorted() ที่เสถียรของ Python จะให้ตัวที่ อยู่ก่อนในรายการอินพุต อยู่ก่อนในผลลัพธ์ — ถูกต้องภายในเฟรมเดียว แต่ขึ้นอยู่กับลำดับรายการที่บังเอิญเป็นแบบนั้น ไม่ใช่คุณสมบัติใด ๆ ของสไปรต์เอง ถ้ารายการอินพุตถูกสร้างใหม่หรือถูกกรองระหว่างเฟรม ลำดับที่เท่ากันอาจพลิกกลับได้โดยไม่มีเหตุผลในเกม ตัวตัดสินเสมอแบบ key=lambda s: (s[1], spawn_order) จะทำให้ผลลัพธ์แน่นอนไม่ว่าลำดับรายการจะเป็นอย่างไร

๒. อธิบายอาการกะพริบ ลายพื้นสองอัน คือเงา S (y=50) และแอ่งน้ำ P (y=50) ถูกวาดทุกเฟรมด้วย sorted(sprites, key=lambda s: s.y) — ไม่มีตัวตัดสินเสมอ เฟรมที่ 1 รายการสไปรต์บังเอิญเป็น [S, P] หลังจากศัตรูตัวอื่นตายและรายการถูกสร้างใหม่ เฟรมที่ 2 รายการบังเอิญกลายเป็น [P, S] ทั้งที่ S กับ P ไม่ได้ขยับเลย บนหน้าจอจะเกิดอะไรขึ้นระหว่างสองเฟรมนี้ และการเพิ่ม spawn_order เป็นตัวตัดสินเสมอช่วยแก้ปัญหานี้อย่างไร?

เฉลย

sorted() เสถียรก็จริง แต่ “เสถียร” หมายความแค่ว่าค่าที่เท่ากันคงลำดับของ รายการอินพุตในการเรียกครั้งนั้น ไว้เท่านั้น — ไม่ได้การันตีความสม่ำเสมอข้ามเฟรม เฟรมที่ 1 เรียง [S, P] (เรียงอยู่แล้ว) แล้ววาด S ก่อน P อินพุตของเฟรมที่ 2 กลายเป็น [P, S] แบบเงียบ ๆ การเรียงที่เสถียรจึงคง P ไว้ก่อน S และวาด P ก่อนในรอบนี้ — ลำดับการวาดของลายพื้นสองอันที่ไม่ขยับและความลึกเท่ากันพลิกกลับ ซึ่งบนหน้าจอจะเห็นเป็นอาการกะพริบหรือ z-fight หนึ่งเฟรม การเรียงด้วย key=lambda s: (s.y, s.spawn_order) แทน จะทำให้ลำดับขึ้นอยู่กับคุณสมบัติของสไปรต์เองเท่านั้น ไม่ใช่ลำดับที่บังเอิญมาถึงในรายการของเฟรมนั้น ค่าที่เท่ากันจึงได้ผลลัพธ์เดิมเสมอ

๓. ไล่ตามมือ binary search บนตารางเวลา ใช้ next_spawn_index ด้านบนกับ timeline = [2, 6, 6, 12, 18, 25, 30, 40] และ now = 6 ให้ไล่ค่า lo, hi, mid แต่ละรอบ ผลลัพธ์คือดัชนีอะไร และชี้ไปที่เวลาสปอว์นไหน แล้วทำไมมันข้าม 6 ทั้งสองตัวไป?

เฉลย
รอบ lo hi mid timeline[mid] ทำอะไร
1 0 8 4 18 18 > 6hi = 4
2 0 4 2 6 6 <= 6lo = 3
3 3 4 3 12 12 > 6hi = 3

ลูปจบ (lo == hi == 3) คืนดัชนี 3 ซึ่งชี้ไปที่ timeline[3] = 12 ค่า 6 ทั้งสองตัวถูกข้ามเพราะเงื่อนไข timeline[mid] <= now ถือว่าสปอว์นที่กำหนดไว้ พอดีกับ now เป็นอันที่จัดการไปแล้ว ฟังก์ชันจึงคืนอีเวนต์แรกที่มากกว่า now จริง ๆ ตรงกับความหมาย “อันถัดไป” สำหรับนาฬิกาเกมที่เพิ่งเดินผ่าน 6 ไป

๔. insertion sort เทียบกับ sort ในตัว เมื่อเกือบทุกอย่างเรียงอยู่แล้ว สไปรต์ ๒๐๐ ตัวเรียงมาจากเฟรมก่อนแล้ว มีเพียง ๓ ตัวที่ขยับผ่านตัวข้างเคียงไปในเฟรมนี้ เปรียบเทียบการเรียงใหม่ด้วย sorted() ในตัวของ Python (Timsort) กับ insertion_depth_sort ด้านบน ทำไมทั้งสองแบบถึงเร็วในกรณีนี้ และถ้าโหลดด่านใหม่ทำให้รายการสไปรต์ถูกสร้างใหม่แบบสุ่มลำดับ แบบไหนที่ยังคงเร็วอยู่?

เฉลย

ทั้งสองแบบใกล้เคียง O(n) บนอินพุตนี้: Timsort ตรวจพบ run ที่เรียงแล้วยาว ๆ ในสไปรต์ ๑๙๗ ตัวที่ไม่ขยับ แล้วแค่ผสานตัวไม่กี่ตัวที่ขยับเข้าไป ส่วน insertion_depth_sort ก็เลื่อนแค่ไม่กี่ตัวที่อยู่ผิดที่ผ่านตัวข้างเคียง เพราะตัวอื่นทั้งหมดไม่ผ่านเงื่อนไข while ตั้งแต่แรก ทั้งสองแบบทำงานถูกและถูกที่แบบเดียวกัน เพียงผ่านกลไกต่างกัน ความต่างจะเห็นชัดบนอินพุตที่ แย่: ถ้าโหลดด่านใหม่ทำให้รายการสไปรต์ถูกสร้างใหม่แบบสุ่มลำดับ insertion sort จะแย่ลงแบบเงียบ ๆ กลายเป็นกรณีเลวร้ายสุดเต็มรูปแบบ O(n²) (ทุกตัวอาจต้องเลื่อนข้ามทั้งอาร์เรย์) ในขณะที่ Timsort ยังคงรับประกัน O(n log n) ไม่ว่าอินพุตจะปนเปแค่ไหน กฎง่าย ๆ: เขียน insertion sort เองเฉพาะตอนที่การันตีได้ว่า “เกือบเรียงแล้วจากเฟรมสู่เฟรม” เท่านั้น มิฉะนั้นปล่อยให้ sort ในตัวรับความเสี่ยงแทน

โจทย์ท้าทาย

ก. เรียงใหม่เฉพาะสไปรต์ที่ขยับ การเรียงสไปรต์ทั้ง ๑,๐๐๐ ตัวใหม่จากศูนย์ทุกเฟรมเสียเวลาเปล่าเมื่อมีแค่ไม่กี่ตัวที่ขยับเทียบกับตัวข้างเคียง ให้ร่างวิธีอัปเดตรายการที่เรียงตามความลึกไว้แบบค่อยเป็นค่อยไป โดยแตะเฉพาะสไปรต์ที่เปลี่ยน

แนวทาง

ติดตามว่าสไปรต์ตัวไหนขยับในเฟรมนี้ (เซตเล็ก ๆ ที่เป็น “dirty”) สำหรับแต่ละสไปรต์ที่ dirty: ลบมันออกจากรายการที่เรียงไว้ แล้วใช้ bisect.insort แทรกกลับเข้าไปในตำแหน่งที่ถูกต้อง — bisect หาตำแหน่งแทรกด้วย binary search ใน O(log n) แทนการสแกน ต้นทุนต่อสไปรต์ที่ขยับหนึ่งตัวจึงเป็น O(log n) สำหรับการหาตำแหน่ง บวกต้นทุนการเลื่อนตัวอื่นเพื่อเปิดที่ว่าง เทียบกับ O(n log n) ถ้าเรียงทุกตัวใหม่จากศูนย์ สไปรต์ที่ไม่เคยขยับ (ไทล์พื้นหลัง ของประดับตกแต่งที่อยู่นิ่ง) จะไม่ถูกแตะในการเรียงอีกเลยหลังวางตำแหน่งครั้งแรก ข้อควรระวัง: bisect ทำงานถูกต้องก็ต่อเมื่อรายการ เรียงสมบูรณ์อยู่ก่อนและหลัง การเรียกแต่ละครั้ง ดังนั้นการลบและแทรกกลับเข้า Python list ธรรมดายังเสียเวลา O(n) อยู่ดี — ถ้า n ใหญ่มากและมีตัวที่ขยับหลายตัวต่อเฟรม ให้แบ่งสไปรต์เป็นกลุ่มตามแถว/พื้นที่แล้วเรียงใหม่แค่ภายในกลุ่ม หรือใช้โครงสร้างข้อมูลที่ออกแบบมาสำหรับอัปเดตแบบเรียงลำดับทีละน้อย (เช่น sorted skip list หรือโครงสร้างที่รองรับด้วย balanced tree)

ข. หาศัตรูที่ใกล้ที่สุดโดยไม่ต้องสแกนทุกตัว ขีปนาวุธติดตามเป้าหมายต้องหาศัตรูที่ใกล้ผู้เล่นที่สุดจากศัตรู n ตัวทุกเฟรม การสแกนแบบไร้เดียงสาตรวจระยะห่างของศัตรูทุกตัว — O(n) ต่อการค้นหาหนึ่งครั้ง โอเคสำหรับศัตรู ๒๐ ตัว แต่แพงมากถ้ามีศัตรู ๒,๐๐๐ ตัวและขีปนาวุธหลายสิบลูกค้นหาทุกเฟรม ให้ร่างวิธีที่เร็วขึ้นโดยใช้แนวคิด “เรียงครั้งเดียว ค้นหาหลายครั้ง”

แนวทาง

ถ้าศัตรูขยับช้าเมื่อเทียบกับความถี่ในการค้นหา ให้เก็บดัชนีที่เรียงตามแกนใดแกนหนึ่ง (สมมติ x) ไว้ เมื่อจะหาศัตรูที่ใกล้ตำแหน่ง x ของผู้เล่นที่สุด ใช้ binary search (bisect) หาตำแหน่งที่ x ของผู้เล่นจะถูกแทรก — จะพาไปอยู่ระหว่างศัตรูสองตัวที่ใกล้ที่สุดในแกน x ด้วย O(log n) จากนั้นเดินออกจากจุดนั้นทั้งสองทิศทาง ตรวจระยะห่างจริง (x, y) และหยุดขยายเมื่อระยะห่างในแกน x เพียงอย่างเดียวของตัวที่เหลือมากกว่าระยะที่ดีที่สุดที่เจอแล้ว (เพราะระยะจริงมีแต่จะมากกว่านั้น) วิธีนี้เปลี่ยนกรณีทั่วไปให้เหลือประมาณ O(log n) แทน O(n) โดยแลกกับการต้องคอยอัปเดตดัชนีที่เรียงตาม x ให้ทันเมื่อศัตรูขยับ (การอัปเดตแบบเกือบเรียงแล้วที่เหมาะกับ insertion sort เหมือนรายการความลึกด้านบน) สำหรับศัตรูที่กระจายอยู่ในพื้นที่ 2D เปิดโล่งแทนที่จะอยู่ตามแนวเส้นคร่าว ๆ spatial hash grid — แบ่งศัตรูตามช่องกริด แล้วตรวจแค่ช่องของผู้เล่นกับช่องข้างเคียง — จะขยายผลได้ดีกว่าการเรียงมิติเดียว

  • MIT 6.006 Introduction to Algorithms — เลกเชอร์เรื่องการค้นหาและการจัดเรียง
  • Stanford CS161 — Design and Analysis of Algorithms — การวิเคราะห์ merge sort, quicksort และขีดจำกัดล่าง
  • CLRS (Cormen, Leiserson, Rivest, Stein), Introduction to Algorithms — บทที่ ๒ (insertion sort และแนวคิด divide-and-conquer), บทที่ ๖ (heapsort), บทที่ ๗ (quicksort), บทที่ ๘ (ขีดจำกัดล่าง Ω(n log n) ของ comparison sort พร้อม counting/radix sort ที่เอาชนะมันได้)
  • Sedgewick & Wayne, Algorithms (ฉบับที่ ๔), Princeton — อธิบายชัดเจนมาก เน้นโค้ดจริง ครอบคลุมทุกอัลกอริทึมในบทนี้ พร้อมเปรียบเทียบประสิทธิภาพเชิงประจักษ์อย่างเข้มงวด
  • Knuth, The Art of Computer Programming, เล่ม ๓ — “Sorting and Searching” — งานเจาะลึกที่สุดในเรื่องนี้ เป็นที่มาของการวิเคราะห์ขีดจำกัดล่างของ comparison sort และตัวแปรการจัดเรียงนับสิบแบบอย่างละเอียดที่สุด
  • Roughgarden, Algorithms Illuminated, เล่ม ๑ — อธิบายที่มาของขอบเขต O(n log n) ของ merge sort ผ่าน recursion tree อย่างเข้าถึงง่ายแต่เข้มงวด เหมาะคู่กับ Stanford CS161
  • VisuAlgo — Sorting — เครื่องมือแสดงภาพการทำงานของอัลกอริทึมจัดเรียงแบบโต้ตอบ