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

ลิงก์ลิสต์ สแตก และคิว

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

วินัยของการจำกัด (discipline of restriction) คือหัวใจ: ยิ่งโครงสร้างทำอะไรได้น้อย เรายิ่งให้เหตุผลกับมันได้ง่าย

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

ลิงก์ลิสต์แก้ปัญหานี้ด้วยแนวคิด โหนด (node) ที่กระจายอยู่ในหน่วยความจำ แต่ละโหนดเก็บ “ข้อมูล” และ “พอยน์เตอร์ (pointer)” ที่ชี้ไปยังโหนดถัดไป ไม่มีบล็อกต่อเนื่องให้เลื่อน — มีแต่ลูกศรให้ปรับ

head
┌────┬───┐ ┌────┬───┐ ┌────┬───┐
│ 10 │ ●─┼──▶│ 20 │ ●─┼──▶│ 30 │ ✕ │
└────┴───┘ └────┴───┘ └────┴───┘
data next data next data next(null)

เมื่อเรามีพอยน์เตอร์อยู่ที่โหนดใดโหนดหนึ่งแล้ว การแทรกหรือลบ ณ ตำแหน่งนั้นเป็นเพียงการ “ปรับลูกศร” ไม่กี่เส้น จึงเป็น O(1) ไม่ต้องเลื่อนสมาชิกใด ๆ

แต่มีราคาที่ต้องจ่าย: ลิงก์ลิสต์ไม่มีดัชนี การจะไปถึงโหนดที่ i ต้องเดินจาก head ทีละก้าว จึงเป็น O(n) และการค้นหาค่าก็ O(n) เช่นกัน

ตัวอย่างคลี่ทีละขั้นที่ 1 — prepend เทียบกับ append สมมติเราสร้างลิสต์ [4, 8, 15] โดยแทรกที่หัวทีละค่า เริ่มจากลิสต์ว่าง

ขั้น ปฏิบัติการ ลิสต์หลังทำ พอยน์เตอร์ที่แตะ ต้นทุน
1 prepend 15 15 → null head = โหนดใหม่ O(1)
2 prepend 8 8 → 15 → null new.next = head, head = new O(1)
3 prepend 4 4 → 8 → 15 → null new.next = head, head = new O(1)

ทุก prepend เป็น O(1) เพราะแตะแค่ head กับพอยน์เตอร์ next เส้นเดียว ลองเทียบกับการ append ค่าเดิมสามค่าตามลำดับ โดยมีแค่พอยน์เตอร์ head ไม่มี tail:

ขั้น ปฏิบัติการ งานที่ต้องทำ ต้นทุน
1 append 4 เดิน 0 โหนดจาก head (ลิสต์ว่าง) O(1)
2 append 8 เดิน 1 โหนดเพื่อหาโหนดสุดท้าย O(n)
3 append 15 เดิน 2 โหนดเพื่อหาโหนดสุดท้าย O(n)

การ append โดยไม่มีพอยน์เตอร์ tail จะกลายเป็น O(n) ต่อครั้ง เพราะต้องเดินทั้งลิสต์เพื่อหาโหนดสุดท้ายก่อนปรับลูกศร ถ้าเก็บพอยน์เตอร์ tail ไว้ (อัปเดตทุกครั้งที่ append) จะกลับมาเป็น O(1) — นี่คือกลเม็ดเดียวกับที่ collections.deque และการเขียนลิงก์ลิสต์ระดับโปรดักชันส่วนใหญ่ใช้

ลิงก์ลิสต์แบบสองทาง เพิ่มพอยน์เตอร์ที่สองต่อโหนด คือ prev ชี้ย้อนกลับ ราคาที่จ่ายคือหน่วยความจำต่อโหนดมากขึ้น แต่แลกมาด้วยความสามารถเดินย้อนกลับได้ O(1) ต่อก้าว และลบโหนดได้ O(1) เมื่อมีแค่พอยน์เตอร์ไปยังโหนดนั้น โดยไม่ต้องตามหาหรือเก็บโหนดก่อนหน้าแยกต่างหาก

head tail
│ │
▼ ▼
┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┬────┐
│null│ 10 │ ●──┼───────▶│ ●──┼ 20 │ ●──┼───────▶│ ●──┼ 30 │null│
└────┴────┴────┘◀───────┴────┴────┴────┘◀───────┴────┴────┴────┘
prev data next prev data next prev data next

ตัวอย่างคลี่ทีละขั้นที่ 2 — ลบโหนดกลาง เพื่อลบโหนดที่เก็บ 20 ออกจากลิสต์ข้างบน ต้องปรับลูกศรพอดี 2 เส้น: โหนด ก่อนหน้า ต้องชี้ไปข้างหน้าข้ามมันไป และโหนด ถัดไป ต้องชี้ย้อนกลับข้ามมันไป

ขั้น การกระทำ ผลลัพธ์
1 node20.prev.next = node20.next โหนด 10 ชี้ไปข้างหน้าหาโหนด 30 ข้าม 20
2 node20.next.prev = node20.prev โหนด 30 ชี้ย้อนกลับหาโหนด 10 ข้าม 20
3 (โหนด 20 เข้าถึงไม่ได้อีกต่อไป) ถูกเก็บกวาดโดย garbage collector ลิสต์เหลือ 10 ⇄ 30

ต้องทำทั้งสองขั้น ถ้าข้ามขั้นที่ 2 ลิสต์จะ “อ่านถูก” เมื่อเดินไปข้างหน้า แต่การเดิน ย้อนกลับ จากโหนด 30 จะวกกลับไปเจอโหนด 20 ที่ถูกลบไปแล้วอย่างผิดพลาด — บั๊กแอบแฝงที่โผล่มาก็ต่อเมื่อมีใครเดินย้อนทิศ (ดูข้อ 2 ในแบบฝึกหัด)

คุณสมบัติ อาเรย์ ลิงก์ลิสต์ทางเดียว ลิงก์ลิสต์สองทาง
เข้าถึงด้วยดัชนี O(1) O(n) O(n)
ค้นหาค่า O(n) O(n) O(n)
แทรก/ลบ ณ โหนดที่รู้ตำแหน่ง O(n) O(1) ไปข้างหน้าเท่านั้น O(1) ทั้งสองทิศ
แทรก/ลบ ที่หัว O(n) O(1) O(1)
แทรก/ลบ ที่ท้าย O(n) O(1) ถ้ามี tail ไม่งั้น O(n) O(1) ถ้ามี tail
เดินย้อนกลับ O(1) ต่อก้าว ทำไม่ได้ O(1) ต่อก้าว
หน่วยความจำต่อสมาชิก น้อย (เฉพาะข้อมูล) ปานกลาง (ข้อมูล + พอยน์เตอร์ 1 ตัว) มาก (ข้อมูล + พอยน์เตอร์ 2 ตัว)

สแตกคือโครงสร้างแบบ LIFO (Last In, First Out) — สิ่งที่ใส่เข้าไปล่าสุดจะถูกหยิบออกก่อน เหมือนกองจานที่วางซ้อนกัน เราหยิบจานบนสุดได้เท่านั้น มีเพียงสองปฏิบัติการหลัก:

  • push — วางข้อมูลไว้บนสุด O(1)
  • pop — หยิบข้อมูลบนสุดออก O(1)

การใช้งานในโลกจริง: ปุ่ม undo ในโปรแกรม, backtracking (ลองเดินทางหนึ่ง แล้วย้อนกลับไปจุดตัดสินใจล่าสุดเมื่อทางนั้นตัน — เขาวงกต ตัวไขปริศนา การค้นหาแบบเรียกซ้ำ), call stack ที่ภาษาโปรแกรมใช้จำว่าฟังก์ชันไหนเรียกฟังก์ชันไหน, และการตรวจ วงเล็บสมดุล ในคอมไพเลอร์และเอดิเตอร์

stack = [] # ใช้ list ของ Python เป็นสแตกได้เลย
stack.append("A") # push
stack.append("B") # push -> ['A', 'B']
top = stack.pop() # pop -> คืน 'B', เหลือ ['A']

ตัวอย่างคลี่ทีละขั้นที่ 3 — ตรวจวงเล็บสมดุลด้วยสแตก ไล่ is_balanced("{[()]}"): push วงเล็บเปิดทุกตัว และเมื่อเจอวงเล็บปิด ต้องจับคู่กับตัวที่อยู่บนสุด

ขั้น ตัวอักษร การกระทำ สแตก (บนสุด → ขวา)
1 { push {
2 [ push { [
3 ( push { [ (
4 ) pop, คาดหวัง ( — ตรงกัน { [
5 ] pop, คาดหวัง [ — ตรงกัน {
6 } pop, คาดหวัง { — ตรงกัน (ว่าง)

สแตกว่างตอนจบ → สมดุล ถ้าวงเล็บปิดตัวใดเจอสิ่งที่ผิดบนสุด หรือสแตกว่างตอนที่ต้อง pop ทันที ให้สรุปว่าไม่สมดุลได้เลย

ตัวอย่างคลี่ทีละขั้นที่ 4 — เห็น call stack ด้วยตาตัวเอง ทุกครั้งที่โปรแกรมเรียกฟังก์ชัน รันไทม์ของภาษาจะ push มันลงสแตกโดยอัตโนมัติ และ pop ออกเมื่อฟังก์ชันนั้น return ไล่ factorial(4):

ขั้น การกระทำ call stack (บนสุด → ล่างสุด)
1 เรียก factorial(4) factorial(4)
2 มันเรียก factorial(3) factorial(3), factorial(4)
3 มันเรียก factorial(2) factorial(2), factorial(3), factorial(4)
4 มันเรียก factorial(1) → เงื่อนไขฐาน คืนค่า 1 factorial(2), factorial(3), factorial(4)
5 factorial(2) คืนค่า 2 * 1 = 2 factorial(3), factorial(4)
6 factorial(3) คืนค่า 3 * 2 = 6 factorial(4)
7 factorial(4) คืนค่า 4 * 6 = 24 (ว่าง)

นี่คือเหตุผลที่การเรียกซ้ำแบบลึกไม่จำกัดจะพังด้วย RecursionError / “stack overflow” — call stack คือสแตกจริง ๆ ที่มีพื้นที่จำกัด และเป็นเหตุผลที่อัลกอริทึม backtracking (แก้เขาวงกต, N-Queens, ไข Sudoku) มักเขียนด้วยการเรียกซ้ำโดยธรรมชาติ: call stack ของภาษา คือ สแตกของการตัดสินใจที่ต้องคลายเมื่อทางนั้นตัน

คิวคือโครงสร้างแบบ FIFO (First In, First Out) — ใครมาก่อนได้ก่อน เหมือนคนต่อแถวซื้อของ ปฏิบัติการหลักคือ:

  • enqueue — เพิ่มข้อมูลที่ “ท้ายแถว” O(1)
  • dequeue — นำข้อมูลที่ “หัวแถว” ออก O(1)

การใช้งานในโลกจริง: การจัดคิวงาน (task scheduling — คิวงานพิมพ์, job queue, ready queue ของระบบปฏิบัติการ), การค้นหาตามความกว้าง (BFS) ในกราฟและต้นไม้, และการจัดการคำขอแบบจำกัดอัตรา (rate limiting)

ตัวอย่างคลี่ทีละขั้นที่ 5 — BFS ด้วยคิว กำหนดกราฟ A–B, A–C, B–D, C–D, D–E ไล่การค้นหาตามความกว้างเริ่มจาก A คิวรับประกันว่าเราจะเยี่ยมทุกโหนดที่ระยะ 1 ก่อนโหนดใด ๆ ที่ระยะ 2

ขั้น dequeue เยี่ยม โหนดที่เพิ่งเพิ่มเข้าคิว (เพื่อนบ้านที่ยังไม่เยี่ยม) คิวหลังทำ
0 [A]
1 A A B, C [B, C]
2 B B D [C, D]
3 C C (D อยู่ในคิวแล้ว) [D]
4 D D E [E]
5 E E []

ลำดับการเยี่ยม A, B, C, D, E — ทีละชั้น ระยะ 0 ก่อน แล้วจึง 1 แล้วจึง 2 ถ้าสลับคิวเป็นสแตกจะได้ลำดับแบบ depth-first แทน (A, C, D, E, B หรือใกล้เคียง) — กราฟเดิม โครงสร้างต่าง ผลลัพธ์การท่องต่างกันโดยสิ้นเชิง

list ของ Python คือ อาเรย์ แบบไดนามิกภายใต้ผิว — หน่วยความจำต่อเนื่องกัน การ enqueue ด้วย append() เป็น O(1) (แบบตัดจ่าย) แต่การ dequeue จากหัวด้วย pop(0) บังคับให้สมาชิกที่เหลือทุกตัวเลื่อนซ้ายทีละช่องเพื่อปิดช่องว่าง — O(n) ต่อครั้ง และ O(n²) เพื่อ dequeue ให้หมด n ชิ้น

collections.deque ถูกสร้างเป็นลิงก์ลิสต์แบบสองทางของบล็อกขนาดคงที่เล็ก ๆ ทั้งสองปลายเป็น “พลเมืองชั้นหนึ่ง” เท่ากัน — การเพิ่มหรือลบที่ปลายใดก็เพียงปรับพอยน์เตอร์ไม่กี่เส้น เหมือนลิงก์ลิสต์สองทางในหัวข้อก่อนหน้าเป๊ะ ไม่มีการเลื่อนเลย

from collections import deque
q = deque()
q.append("A") # enqueue ที่ท้าย O(1)
q.append("B")
first = q.popleft() # dequeue ที่หัว O(1) -> คืน 'A'

deque (อ่านว่า “เด็ก”) ครอบคลุมทั้งสแตกและคิวไว้ในตัวเดียว: รองรับ push และ pop ที่ปลาย ทั้งสอง ด้าน ด้วย O(1)

from collections import deque
d = deque([2, 3, 4])
d.appendleft(1) # [1, 2, 3, 4] O(1) ที่หัว
d.append(5) # [1, 2, 3, 4, 5] O(1) ที่ท้าย
d.pop() # -> 5, เหลือ [1, 2, 3, 4] O(1) ที่ท้าย
d.popleft() # -> 1, เหลือ [2, 3, 4] O(1) ที่หัว

ใช้ deque แทน list ธรรมดาทุกครั้งที่ต้องเพิ่ม/ลบจากปลาย ทั้งสองด้าน อย่างมีประสิทธิภาพ: หน้าต่างเลื่อน (sliding window) เหนือสตรีมข้อมูล, ประวัติเบราว์เซอร์แบบโครงสร้างเดียว, ตัวจัดสรรงานแบบ work-stealing (คนงานขโมยงานจากปลายหนึ่งขณะเจ้าของ push/pop อีกปลาย) หรือง่าย ๆ คือ “คิวที่ต้องมี undo ด้วย”

ปฏิบัติการ อาเรย์ ลิงก์ลิสต์ สแตก คิว Deque
เข้าถึงด้วยดัชนี O(1) O(n) O(1) เฉพาะบนสุด O(1) เฉพาะหัว O(1) ทั้งสองปลาย
แทรกที่หัว O(n) O(1) O(1)
แทรกที่ท้าย O(n)* O(1) ถ้ามี tail O(1) (push) O(1) (enqueue) O(1)
ลบที่หัว O(n) O(1) O(1) (dequeue) O(1)
ลบที่ท้าย O(1)* O(1) สองทาง / O(n) ทางเดียว O(1) (pop) O(1)
ค้นหา O(n) O(n) O(n) O(n) O(n)

*อาเรย์ไดนามิกของ Python ตัดจ่าย append/pop ที่ ท้าย ให้เป็น O(1) เฉพาะปฏิบัติการที่ หัว เท่านั้นที่เป็น O(n)

สแตก คิว และ deque มักถูก “สร้างทับ” บนอาเรย์หรือลิงก์ลิสต์อีกที พวกมันคือ กติกาการเข้าถึง ไม่ใช่วิธีจัดเก็บใหม่ — บทเรียนทั้งหน้านี้คือกติกาที่คุณเลือกต่างหากที่กำหนด Big-O ไม่ใช่ตัวข้อมูลดิบ

โจทย์จากโลกจริง — ปุ่มย้อนกลับ/ไปข้างหน้าของเบราว์เซอร์

หัวข้อที่มีชื่อว่า “โจทย์จากโลกจริง — ปุ่มย้อนกลับ/ไปข้างหน้าของเบราว์เซอร์”

ปุ่ม Back และ Forward ของเบราว์เซอร์จำลองได้ด้วย สแตกสองตัว:

  • เมื่อเข้าหน้าใหม่ → push หน้าปัจจุบันลงสแตก back แล้วล้างสแตก forward
  • กด Back → pop จากสแตก back มาเป็นหน้าปัจจุบัน และ push หน้าเดิมลงสแตก forward
  • กด Forward → pop จากสแตก forward มาเป็นหน้าปัจจุบัน และ push หน้าเดิมกลับลงสแตก back

ทำไมต้องเป็นสแตก? เพราะพฤติกรรม “ย้อนกลับ” คือ LIFO โดยธรรมชาติ — เราต้องกลับไปยังหน้าที่ “เพิ่งออกมา” ก่อนเสมอ และทุกปฏิบัติการเป็น O(1) ถ้าใช้คิวซึ่งเป็น FIFO ลำดับจะกลับด้าน ทำให้ผิดความหมายไปเลย: กด Back จะพาไปหน้า เก่าที่สุด ที่เคยเข้า ไม่ใช่หน้าล่าสุด

เมื่อขยายสเกล (เซสชันที่มีการนำทางนับพันครั้ง) ทางเลือกไร้เดียงสา — เก็บประวัติเป็น list ธรรมดาแล้ว slice ใหม่ทุกครั้งที่นำทาง — จะเป็น O(n) ต่อคลิก เพราะ slice คือการคัดลอก การออกแบบด้วยสแตกสองตัวทำให้ทุกคลิกเป็น O(1) ไม่ว่าประวัติจะยาวแค่ไหน

ทางเลือกอื่น: เบราว์เซอร์จริงหลายตัวจำลองประวัติด้วย ลิงก์ลิสต์สองทางเส้นเดียว พร้อมพอยน์เตอร์ current แทนสแตกสองตัว การกด Back/Forward คือการขยับ current ไปตาม prev/next; การเข้าหน้าใหม่คือการตัดสาย next จาก current เป็นต้นไป รับประกัน O(1) เท่ากัน แต่มุมมองต่างกัน — พิสูจน์ว่าสแตก คิว และลิงก์ลิสต์มักเป็น “เปลือก” ที่สลับกันได้บนไอเดียเดียวกัน

ข้อ 1 — Big-O: append โดยมีและไม่มีพอยน์เตอร์ tail ลิงก์ลิสต์แบบทางเดียวเก็บแค่พอยน์เตอร์ head (ไม่มี tail) การ append สมาชิกใหม่หนึ่งตัวที่ท้ายมีความซับซ้อนเท่าไร? ถ้าลิสต์เก็บพอยน์เตอร์ tail ที่อัปเดตทันเวลาด้วย จะเปลี่ยนอย่างไร?

เฉลย

ถ้าไม่มีพอยน์เตอร์ tail การ append เป็น O(n): ต้องเดินจาก head ไปจนถึงโหนดสุดท้ายก่อนจึงต่อโหนดใหม่ได้ ถ้ามีพอยน์เตอร์ tail ที่อัปเดตทุกครั้งที่แทรก การ append จะกลายเป็น O(1): ปรับ tail.next = new_node แล้ว tail = new_node นี่คือกลเม็ดเดียวกับที่ทำให้ collections.deque เร็วทั้งสองปลาย

ข้อ 2 — จับบั๊กในการลบโหนดของลิงก์ลิสต์สองทาง

def remove(node: "Node") -> None:
node.prev.next = node.next
# (ไม่มีอย่างอื่น)

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

เฉลย

โค้ดลืม node.next.prev = node.prev การเดินไปข้างหน้า (node.next, node.next.next, …) ไม่แตะ prev เลย จึงดูถูกต้อง แต่การเดิน ย้อนกลับ จากโหนดใดก็ตามหลังโหนดที่ถูกลบ จะยังเจอ prev ชี้ไปที่โหนดที่ถูกลบอยู่ — เดินย้อนกลับเข้าไปในหน่วยความจำที่ “ถูกลบ” ไปแล้วได้ นอกจากนี้ยังพังเงียบ ๆ ถ้า node เป็นโหนดท้าย (node.next เป็น None) หรือหัว (node.prev เป็น None) — กรณีเหล่านี้ต้องอัปเดต head/tail แทนการอ้างอิง None เวอร์ชันแก้ไข:

def remove(node: "Node") -> None:
if node.prev:
node.prev.next = node.next
else:
head = node.next # node เป็น head
if node.next:
node.next.prev = node.prev
else:
tail = node.prev # node เป็น tail

ข้อ 3 — ทายผลลัพธ์ (สแตก)

s = []
s.append(1)
s.append(2)
s.append(3)
s.pop()
s.append(4)
s.pop()
s.append(5)
print(s)
เฉลย

ไล่ทีละขั้น: [1][1,2][1,2,3] → pop เอา 3 ออก → [1,2] → append 4[1,2,4] → pop เอา 4 ออก → [1,2] → append 5[1,2,5] คำตอบสุดท้าย: [1, 2, 5]

ข้อ 4 — ทายผลลัพธ์ (คิวเทียบกับสแตก อินพุตชุดเดียวกัน) ปฏิบัติการชุดเดียวกัน — ใส่ A, ใส่ B, เอาออกหนึ่งตัว, ใส่ C — รันบนสแตกครั้งหนึ่งและบนคิวครั้งหนึ่ง แต่ละอันจะมีอะไรเหลืออยู่ตอนจบ?

เฉลย

สแตก: push A, push B[A, B]; pop เอา B ออก (เข้าล่าสุด) → [A]; push C[A, C] คิว: enqueue A, enqueue B[A, B]; dequeue เอา A ออก (เข้าก่อน) → [B]; enqueue C[B, C] ปฏิบัติการเดียวกัน อินพุตเดียวกัน แต่ผลลัพธ์สุดท้ายต่างกัน — พิสูจน์ว่า กติกาการเข้าถึง ต่างหากที่กำหนดผล ไม่ใช่ตัวข้อมูล

ข้อ 5 — เลือกโครงสร้างข้อมูลให้ถูก แต่ละสถานการณ์ ให้ระบุโครงสร้างที่เหมาะสมที่สุด (ลิงก์ลิสต์ทางเดียว, ลิงก์ลิสต์สองทาง, สแตก, คิว หรือ deque) พร้อมเหตุผลหนึ่งประโยค:

  1. การกด “เพลงก่อนหน้า” / “เพลงถัดไป” ในเครื่องเล่นเพลง
  2. ระบบตั๋วซัพพอร์ตลูกค้าที่ต้องให้บริการตามลำดับที่มาถึง
  3. การ undo และ redo การแก้ไขในโปรแกรมแก้เอกสาร
  4. อัลกอริทึมหน้าต่างเลื่อน (sliding window) ที่ต้องตัดสมาชิกออกทั้งจากหัวและท้ายขณะหน้าต่างเลื่อนไป
เฉลย
  1. ลิงก์ลิสต์สองทาง (หรือ deque ที่ทำหน้าที่แบบนั้น) — ต้องเคลื่อนที่ O(1) ได้ ทั้งสองทิศ จากเพลงปัจจุบัน
  2. คิว — ความเป็นธรรมแบบ FIFO เข้มงวด ตั๋วที่มาก่อนต้องได้รับบริการก่อน
  3. สแตกสองตัว (หรือ deque หนึ่งตัว) — undo/redo เป็น LIFO ในแต่ละทิศทาง เหมือนรูปแบบปุ่ม Back/Forward ของเบราว์เซอร์ข้างต้นเป๊ะ
  4. Deque — ต้องการลบที่ ทั้งสองปลาย ด้วย O(1) ขณะหน้าต่างเลื่อนไปข้างหน้า

ข้อ 6 — สร้างฟีเจอร์ undo ออกแบบคลาส TextEditor ที่มีเมท็อด type(ch) และ undo() โดยใช้สแตกเก็บประวัติ

เฉลย
class TextEditor:
def __init__(self) -> None:
self.history: list[str] = []
def type(self, ch: str) -> None:
self.history.append(ch)
def undo(self) -> None:
if self.history:
self.history.pop()
def text(self) -> str:
return "".join(self.history)

ทุกการกดแป้นคือ push, O(1); ทุกการ undo คือ pop, O(1) ทั้งฟีเจอร์คือการประยุกต์ใช้ LIFO โดยตรง

ข้อ 7 — ตรวจวงเล็บสมดุล เขียนฟังก์ชัน is_balanced(s) ที่คืน True หากวงเล็บ ()[]{} ทุกตัวใน s จับคู่และซ้อนกันถูกต้อง

เฉลย
def is_balanced(s: str) -> bool:
pairs = {")": "(", "]": "[", "}": "{"}
stack: list[str] = []
for ch in s:
if ch in "([{":
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack

เวลา O(n) พื้นที่ O(n) ดูตัวอย่างคลี่ทีละขั้นที่ 3 ข้างบนสำหรับการไล่ทวนแบบเต็ม

ข้อ 8 — สร้างคิวจากสองสแตก สร้างคิว (enqueue/dequeue) โดยใช้สแตกสองตัวเท่านั้น (ห้ามใช้โครงสร้างอื่น)

เฉลย
class QueueFromStacks:
def __init__(self) -> None:
self.in_s: list[int] = []
self.out_s: list[int] = []
def enqueue(self, x: int) -> None:
self.in_s.append(x)
def dequeue(self) -> int:
if not self.out_s:
while self.in_s:
self.out_s.append(self.in_s.pop())
return self.out_s.pop()

สมาชิกทุกตัวข้ามจาก in_s ไป out_s เพียงครั้งเดียว ดังนั้นแม้ dequeue ครั้งใดครั้งหนึ่งอาจมีต้นทุน O(n) เป็นครั้งคราว แต่ต้นทุน ตัดจ่าย เฉลี่ยตลอด n ปฏิบัติการคือ O(1) ต่อครั้ง

ข้อ 9 — โบนัส: กลับลำดับสตริงด้วยสแตก

เฉลย
def reverse(s: str) -> str:
stack = list(s)
out: list[str] = []
while stack:
out.append(stack.pop())
return "".join(out)

การ push ทุกตัวอักษรแล้ว pop ออกทั้งหมด จะกลับลำดับโดยธรรมชาติ — LIFO คลี่ลำดับ FIFO เดิมที่ตัวอักษรเข้ามาให้กลับด้าน

ปัญญาประดิษฐ์เสนอคิวดังนี้ ดูเผิน ๆ ทำงานถูกต้อง แต่มีต้นทุนซ่อนอยู่ — หาให้เจอ

class Queue:
def __init__(self) -> None:
self.items: list[int] = []
def enqueue(self, x: int) -> None:
self.items.append(x) # O(1) — ดี
def dequeue(self) -> int:
return self.items.pop(0) # ❓ ราคาเท่าไร?

คำถาม: list.pop(0) มีความซับซ้อนเท่าไร? ถ้ามีงาน n ชิ้น การ dequeue ทั้งหมดจะกลายเป็นเท่าไร?

เฉลย

list.pop(0) เป็น O(n) เพราะ list ของ Python คืออาเรย์ต่อเนื่องกัน สมาชิกที่เหลือทุกตัวต้องเลื่อนขึ้นมาเติมช่องว่างที่หัว ดังนั้นการ dequeue ครบ n ชิ้นจะกลายเป็น O(n²) — ช้าลงอย่างมากเมื่อข้อมูลโต และเป็นบั๊กประเภทที่ผ่านทุก unit test (ผลลัพธ์ถูกต้อง) แต่แอบพังทุก performance test เมื่อขยายสเกล

แก้ด้วย collections.deque ซึ่งสร้างจากลิงก์ลิสต์สองทางของบล็อกย่อย ทำให้ popleft() เป็น O(1) — ไม่มีการเลื่อน มีแต่การปรับพอยน์เตอร์:

from collections import deque
class Queue:
def __init__(self) -> None:
self.items: deque[int] = deque()
def enqueue(self, x: int) -> None:
self.items.append(x) # O(1)
def dequeue(self) -> int:
return self.items.popleft() # O(1)

นิสัยการเป็น “ผู้ตัดสิน” ที่ดี: ทุกครั้งที่ AI เลือกใช้ list แล้วเรียก .pop(0), .insert(0, x), หรือ slice จากหัวในลูป ให้ถามว่า ข้างใต้ list นั้นคืออะไร — อาเรย์ต่อเนื่องกันเสมอ — และปฏิบัติการนั้นแตะที่หัวหรือไม่ ถ้าใช่ collections.deque มักเป็นทางแก้เสมอ

🎮 เกมเดฟ: สแตกย้อนกลับ (Undo) และคิวคำสั่ง/คิวเทิร์น

หัวข้อที่มีชื่อว่า “🎮 เกมเดฟ: สแตกย้อนกลับ (Undo) และคิวคำสั่ง/คิวเทิร์น”

ประวัติ undo/redo ของเลเวลเอดิเตอร์คือปัญหา LIFO แบบเดียวกับรูปแบบปุ่ม Back/Forward ของเบราว์เซอร์ข้างบน — การแก้ไข ล่าสุด คือสิ่งแรกที่เราต้องการย้อนกลับ ส่วนบัฟเฟอร์อินพุตของเกมต่อสู้ หรือคิวปฏิบัติการของเกมแบบผลัดตา (turn-based) คือภาพสะท้อนแบบ FIFO — คำสั่งต้องยิงตามลำดับที่ผู้เล่นกดจริง และความไร้เดียงสาแบบ “ใช้ list ก็พอ” นี่เองคือจุดที่กับดัก pop(0) จากบทเรียนนี้กัดแรงที่สุด เพราะบัฟเฟอร์อินพุตถูกอ่านทุก ๆ เฟรม

ตัวอย่างคลี่ทีละขั้น — สแตก undo สำหรับเลเวลเอดิเตอร์ และคิวอินพุตที่ใช้ deque

class LevelEditor:
"""วางไทล์คือ push ลงสแตก, undo คือ pop ตัวล่าสุดออก"""
def __init__(self) -> None:
self.tiles: list[str] = []
self.undo_stack: list[str] = []
self.redo_stack: list[str] = []
def place(self, tile: str) -> None:
self.tiles.append(tile)
self.undo_stack.append(tile) # push — O(1)
self.redo_stack.clear() # แก้ไขใหม่ ล้างประวัติ redo ทิ้ง
def undo(self) -> None:
if not self.undo_stack:
return
tile = self.undo_stack.pop() # LIFO — undo ตัวที่วางล่าสุดก่อน
self.tiles.remove(tile)
self.redo_stack.append(tile)
def redo(self) -> None:
if not self.redo_stack:
return
tile = self.redo_stack.pop()
self.tiles.append(tile)
self.undo_stack.append(tile)

list ธรรมดาคือเครื่องมือที่ถูกต้องตรงนี้ — ทุก push และ pop แตะแค่ “ปลาย” เดียว ซึ่งเป็น O(1) อยู่แล้ว ไม่มีอะไรต้องแก้ วินัยของ “แตะแค่ปลายเดียว” คือเหตุผลทั้งหมดที่มันใช้ได้ผล

บัฟเฟอร์อินพุตเป็นอีกเรื่องหนึ่ง ร่างแรกแบบไร้เดียงสา:

class InputBuffer:
"""ไร้เดียงสา: ทำงานถูก แต่ถูกเช็คทุกเฟรม — ต้นทุนซ่อนอยู่ตรงไหน?"""
def __init__(self) -> None:
self.queue: list[str] = []
def press(self, action: str) -> None:
self.queue.append(action) # enqueue — O(1)
def next_action(self) -> str | None:
return self.queue.pop(0) if self.queue else None # ❓ O(n)!

ที่ 60 เฟรมต่อวินาที pop(0) นี้เลื่อนอินพุตที่ค้างอยู่ทุกตัวไปทางซ้ายทีละช่อง ทุกเฟรม ตราบเท่าที่บัฟเฟอร์ยังมีของอยู่ — กับดัก O(n) เป๊ะแบบเดียวกับในหัวข้อ “วิพากษ์โค้ดจากปัญญาประดิษฐ์” ข้างบน เพียงแต่ย้ายมาอยู่ใน hot loop ทางแก้แบบขยายสเกลก็เหมือนเดิม: สลับไปใช้ collections.deque

from collections import deque
class InputBuffer:
def __init__(self) -> None:
self.queue: deque[str] = deque()
def press(self, action: str) -> None:
self.queue.append(action) # enqueue — O(1)
def next_action(self) -> str | None:
return self.queue.popleft() if self.queue else None # dequeue — O(1)
สแตก undo (LIFO) place wall move spawn place torch ← undo pop ตรงนี้

คิวอินพุต (FIFO) jump dash attack dequeue หัวคิว → เล่นซ้ำตามลำดับที่กด

รูป: สแตก undo pop การเปลี่ยนแปลงล่าสุดออกก่อน (LIFO) ส่วนคิวอินพุตเล่นซ้ำคำสั่งที่บัฟเฟอร์ไว้ตามลำดับที่มาถึง (FIFO)

แต่ไม่ใช่ทุกลำดับในเกมจะเป็น FIFO ธรรมดา — เกมแบบผลัดตาที่ตัวละครความเร็วสูงต้องได้ลงมือก่อนเสมอ ไม่ว่าเทิร์นของมันจะถูกเข้าคิวเมื่อไร ต้องใช้คิวที่เรียงตาม ความสำคัญ (ค่าความเร็ว) แทนลำดับการมาถึง ลองเล่นด้านล่าง: มันคือคำถาม “ใครไปต่อ” แบบเดียวกัน เพียงแต่กติกาของคำว่า “ต่อไป” เปลี่ยนไป

ข้อ G1 — สแตกหรือคิว? แต่ละฟีเจอร์ ให้ระบุ สแตก หรือ คิว พร้อมเหตุผลหนึ่งประโยค:

  1. สถานีคราฟต์ที่ต้องคราฟต์ไอเทมเสร็จตามลำดับที่ผู้เล่นสั่งเข้าคิว
  2. กลไก “ย้อนเวลา” ที่ให้ผู้เล่นถอยหลังผ่านการเคลื่อนไหวไม่กี่ครั้งล่าสุด
  3. ระบบบทสนทนา NPC ที่ต้องแสดงประโยคตามลำดับที่ถูกกระตุ้น
  4. ปุ่ม Ctrl+Z ของเลเวลเอดิเตอร์
เฉลย
  1. คิว — สั่งก่อนคราฟต์ก่อน ความเป็นธรรมแบบ FIFO
  2. สแตก — ย้อนเวลาคือ undo การเคลื่อนไหว ล่าสุด ก่อน แบบ LIFO
  3. คิว — บทสนทนาต้องเล่นตามลำดับที่ถูกกระตุ้น ไม่ใช่กลับด้าน
  4. สแตก — เหตุผลเดียวกับเลเวลเอดิเตอร์ข้างบน Ctrl+Z คือการ pop

ข้อ G2 — ทายผลลัพธ์ undo/redo

ed = LevelEditor()
ed.place("torch")
ed.place("chest")
ed.undo()
ed.place("door")
ed.undo()
ed.redo()
print(ed.tiles)
เฉลย

ไล่ทีละขั้น: place torch[torch]; place chest[torch, chest]; undo pop chest ออก → [torch] (redo_stack = [chest]); place door[torch, door] ซึ่ง ล้าง redo_stack ทิ้ง; undo pop door ออก → [torch] (redo_stack = [door]); redo pop door กลับมา → [torch, door] คำตอบสุดท้าย: ['torch', 'door'] — สังเกตว่า chest หายไปตลอดกาล เพราะการวาง door ล้างประวัติ redo ที่เคยเก็บมันไว้

ข้อ G3 — ทำไมไม่สแกนประวัติทั้งหมดแทน? โปรแกรมเมอร์จูเนียร์เสนอให้เก็บทุกปฏิบัติการของเอดิเตอร์ใน list เดียว แล้วเวลา undo ก็หา action “ล่าสุด” ด้วยการสแกนจากดัชนี 0 ไปจนถึง len(list) - 1 ทำไมกรอบคิดแบบสแตกยังดีกว่า ทั้งที่ list[-1] ก็เป็น O(1) อยู่แล้วใน Python?

เฉลย

list[-1] และ list.pop() เป็น O(1) ทั้งคู่ใน CPython จริง — list ธรรมดา ก็คือ สแตกที่ดีอยู่แล้วตราบใดที่แตะแค่ปลายเดียว คุณค่าของการเรียกมันว่า “สแตก” ตรงนี้ไม่ใช่การแก้ปัญหาความเร็ว แต่เป็นวินัยเรื่อง ความถูกต้อง: มันบังคับให้เราไม่มีวันเอื้อมเข้าไปกลางลิสต์ (เช่น “undo action ที่ 3 จากท้าย”) ไม่งั้นจะเสียการรับประกันว่าประวัติ redo ยังคงสอดคล้องกัน คำศัพท์ push/pop คือสัญญา ไม่ใช่แค่การปรับความเร็ว

ข้อ G4 — บัฟเฟอร์อินพุต deque เทียบกับ list เกมจังหวะ (rhythm game) อ่านอินพุตที่บัฟเฟอร์ไว้หนึ่งตัวต่อเฟรมที่ 60 FPS และบัฟเฟอร์รับได้สูงสุด 200 อินพุตค้างคิวช่วงที่ยุ่ง ประเมินงานเลื่อนสมาชิกทั้งหมดที่ list.pop(0) ต้องทำใน 1 วินาที เทียบกับ deque.popleft()

เฉลย

ด้วย list.pop(0) การ dequeue แต่ละครั้งเลื่อนสมาชิกได้ถึงประมาณ 200 ตัว และเกิดขึ้นได้ถึง 60 ครั้งต่อวินาที — กรณีเลวร้ายสุดราว 60 × 200 = 12,000 การเลื่อนสมาชิกต่อวินาที และโตแบบกำลังสองตามขนาดบัฟเฟอร์ deque.popleft() ทำ dequeue 60 ครั้งเท่ากันแต่ที่ O(1) แต่ละครั้ง — การปรับพอยน์เตอร์จำนวนน้อยคงที่ต่อวินาที ไม่ว่าบัฟเฟอร์จะใหญ่แค่ไหน ความต่างนี้มองไม่เห็นตอนบัฟเฟอร์มี 5 อินพุต แต่เห็นชัดมาก (เฟรมหลุด) ตอน 200 อินพุต

โจทย์ท้าทาย 1 — ตัวจับอินพุตคอมโบ สร้างบัฟเฟอร์อินพุตของเกมต่อสู้ที่จำแค่การกดปุ่ม 5 ครั้งล่าสุด และตรวจจับว่าตรงกับลำดับท่าไม้ตายหรือไม่ เช่น ["down", "down-forward", "forward", "punch"]

แนวทาง

ใช้ collections.deque(maxlen=5) — การ append เกินขีดจำกัดจะทิ้งตัวเก่าสุดออกอัตโนมัติ ไม่ต้องตัดบัฟเฟอร์เองเลย หลังทุก append ให้เทียบสมาชิก ท้ายสุด จำนวน len(sequence) ตัว (สไลซ์ของ deque ที่แปลงเป็นลิสต์) กับลำดับเป้าหมาย:

from collections import deque
class ComboBuffer:
def __init__(self, size: int = 5) -> None:
self.buffer: deque[str] = deque(maxlen=size)
def press(self, action: str) -> None:
self.buffer.append(action)
def matches(self, sequence: list[str]) -> bool:
recent = list(self.buffer)[-len(sequence):]
return recent == sequence

maxlen คือไอเดียสำคัญ: มันเปลี่ยน deque ให้เป็นหน้าต่างเลื่อนขนาดคงที่ที่ push เป็น O(1) และเขี่ยตัวเก่าออกอัตโนมัติ แทนที่จะสไลซ์ลิสต์ที่โตขึ้นเรื่อย ๆ เองทุกเฟรม

โจทย์ท้าทาย 2 — ตารางเวลาปฏิบัติการหน่วง RPG แบบผลัดตาต้องการเอฟเฟกต์แบบ “พิษ: 3 ดาเมจต่อเทิร์น เป็นเวลา 3 เทิร์น” และเอฟเฟกต์หน่วง/ซ้ำแบบอื่น ๆ ออกแบบระบบที่ทุกเทิร์นจะยิงทุกเอฟเฟกต์ที่นับถอยหลังถึงศูนย์แล้ว โดยใช้แค่โครงสร้างแบบคิวเท่านั้น

แนวทาง

เก็บคิวของคู่ (turns_remaining, effect) ทุกเทิร์น: dequeue ทุกอย่างที่อยู่ในคิวตอนนี้ ลดตัวนับแต่ละตัวลง ใช้เอฟเฟกต์ แล้ว enqueue กลับเฉพาะตัวที่ turns_remaining > 0:

from collections import deque
def process_turn(effects: deque[tuple[int, str]]) -> None:
for _ in range(len(effects)):
turns_left, effect = effects.popleft()
apply_effect(effect) # เช่น ให้ดาเมจพิษ
if turns_left - 1 > 0:
effects.append((turns_left - 1, effect))

วิธีนี้ใช้ได้เพราะทุกอย่างในคิวถูกเช็คทุกเทิร์น — เหมาะกับเอฟเฟกต์จำนวนไม่มาก ถ้าเอฟเฟกต์ยิงที่ เทิร์นอนาคตต่างกัน แทน (เช่น “ร่ายเวทที่จะลงในอีก 5 เทิร์น” ปนกับ “พิษติ๊กทุกเทิร์น”) การสแกนทั้งคิวทุกเทิร์นจะเสียงานเปล่า ควรใช้คิวลำดับความสำคัญ (priority queue) ที่เรียงตามเทิร์นที่จะเกิดแทน เพื่อดูแค่ตัวที่ใกล้ที่สุดเท่านั้น — ไอเดียเดียวกับคิวเทิร์นเรียงตามความเร็วข้างบน เพียงแต่ใช้ “เมื่อไร” แทน “เร็วแค่ไหน” เป็นตัวเรียง

  • MIT 6.006 — Introduction to Algorithms (Lecture 2: data structures)
  • Harvard CS50 — Data Structures (สแตก คิว ลิงก์ลิสต์)
  • VisuAlgo — Linked List และ Stack/Queue (ภาพเคลื่อนไหวเชิงโต้ตอบ)
  • CLRS — Cormen, Leiserson, Rivest, Stein, Introduction to Algorithms (ฉบับที่ 4) บทที่ 10: Elementary Data Structures — การอธิบายเชิงทางการฉบับมาตรฐานของสแตก คิว และลิงก์ลิสต์ รวมถึงการอิมพลีเมนต์แบบพอยน์เตอร์โดยไม่มี garbage collector
  • Sedgewick & WayneAlgorithms (ฉบับที่ 4) ตอนที่ 1.3: Bags, Queues, and Stacks — โค้ด Java ที่สะอาด และการเปรียบเทียบข้อดีข้อเสียระหว่างลิงก์ลิสต์กับอาเรย์ที่ปรับขนาดได้
  • WeissData Structures and Algorithm Analysis บทที่ 3: Lists, Stacks, and Queues — อธิบายลิงก์ลิสต์แบบทางเดียว/สองทาง/วงกลมอย่างละเอียด พร้อมการวิเคราะห์แบบตัดจ่าย (amortized analysis)
  • Goodrich, Tamassia & GoldwasserData Structures and Algorithms in Python บทที่ 6–7: Stacks, Queues, and Deques; Linked Lists — อิมพลีเมนต์แบบ Python ล้วน ที่แม็ปตรงกับโค้ดในบทเรียนนี้