ดูโค้ดวิ่งทีละบรรทัด แล้วจะเข้าใจโครงสร้างข้อมูลเอง
ทุกหัวข้อมี "ตัวไล่โค้ด" เหมือนกด debug ใน IDE: โค้ดแบ่งเป็นบล็อกสี ไฮไลต์บรรทัดที่กำลังทำ ภาพขยับตาม ค่าตัวแปรเปลี่ยนให้เห็น และมีคำอธิบายภาษาง่าย ๆ ทุกขั้น
ใช้งานยังไง
1 · ใส่ข้อมูลหรือกดปุ่มคำสั่ง
หัวข้อเรียงข้อมูลให้พิมพ์ตัวเลขเอง หรือกด "สุ่ม" · หัวข้อโครงสร้างข้อมูล (list, tree, hash) มีปุ่มคำสั่ง เช่น insert(e) delete(e) กดต่อกันได้เรื่อย ๆ
2 · ไล่ทีละขั้น
กด ถัดไป ▶ / ◀ ก่อนหน้า หรือ ⏵ เล่น ให้วิ่งเอง ลากแถบเลื่อนไปขั้นไหนก็ได้ · คลิกในกล่องแล้วใช้ปุ่ม ← → และ Space ได้
3 · ดูให้ครบ 4 จุด
- บรรทัดสีส้ม ▶ = บรรทัดที่กำลังทำ
- บล็อกสี = โค้ดส่วนนั้นทำหน้าที่อะไร
- ป้ายตัวแปร กล่องที่เปลี่ยนสีคือค่าที่เพิ่งเปลี่ยน
- 💬 = อธิบายว่าเกิดอะไรขึ้นและทำไม
4 · ทบทวนท้ายเล่ม
มีตาราง Big-O รวมพร้อมกราฟ, Flashcards และข้อสอบปรนัย 50 ข้อ (ทำไปอ่านไปได้ คำตอบถูกบันทึกไว้)
5 บทนี้เรียนอะไร
Sorting — เรียงข้อมูล
7 วิธีเรียง ตั้งแต่ง่ายแต่ช้า O(n²) ไปจนถึงฉลาด O(n log n) และวิธีที่ไม่ต้องเทียบเลย (radix)
บทที่ 24List / Stack / Queue
สร้าง ArrayList และ LinkedList เอง เข้าใจว่าทำไมบางงานเร็ว บางงานช้า แล้วต่อยอดเป็น stack, queue, priority queue
บทที่ 25Binary Search Tree
ต้นไม้ที่ซ้ายน้อย-ขวามาก ใส่/ค้น/ลบ/ท่อง และใช้ต้นไม้บีบอัดข้อมูล (Huffman)
บทที่ 26AVL Tree
BST ที่คอยหมุนตัวเองให้สมดุล เพื่อไม่ให้ต้นไม้เบี้ยวจนช้า
บทที่ 27Hashing
คำนวณตำแหน่งจาก key โดยตรง ค้นหาเฉลี่ย O(1) และวิธีรับมือเมื่อ key ชนกัน
ทบทวนBig-O คืออะไร
บอกว่า "ถ้าข้อมูลเพิ่มขึ้น งานเพิ่มเร็วแค่ไหน" เช่น O(n²) = ข้อมูล 2 เท่า งาน 4 เท่า
currentElement, pivot, rightMost, balancePath) จึงเทียบกับหนังสือได้ตรง ๆ บางที่ย่อ เช่นใช้ swap(a, b) แทนการสลับ 3 บรรทัดInsertion Sort — เรียงแบบเสียบไพ่
หยิบข้อมูลทีละตัวจากทางขวา แล้ว "เสียบ" ลงในส่วนซ้ายที่เรียงแล้วให้ถูกที่
นึกภาพตามนี้
เหมือนจั่วไพ่มาทีละใบ: ไพ่ในมือเรียงอยู่แล้ว พอได้ใบใหม่ก็เลื่อนใบที่ใหญ่กว่าออกไปทางขวา แล้วเสียบใบใหม่ลงช่องว่าง
ขั้นตอน
- เริ่มที่ตัวที่ 2 (i = 1) เพราะตัวแรกตัวเดียวถือว่าเรียงแล้ว
- หยิบ list[i] ขึ้นมาถือไว้ (currentElement)
- ตัวทางซ้ายที่ใหญ่กว่า ให้เลื่อนไปขวาทีละช่อง แล้ววางตัวที่ถือลงช่องที่ว่าง
- แย่สุด (กลับหัว) O(n²) · ดีสุด (เรียงแล้ว) O(n) เพราะลูปในหยุดทันที
- หลังรอบที่ i ส่วน list[0..i] เรียงแล้ว แต่ยังไม่ใช่ค่าสุดท้ายเสมอไป
- เรียงในที่เดิม ไม่ใช้ array เพิ่ม
Bubble Sort — ฟองอากาศตัวใหญ่ลอยขึ้นท้าย
เทียบคู่ที่อยู่ติดกันไล่จากซ้ายไปขวา ถ้าผิดลำดับก็สลับ ทำครบหนึ่งรอบ ตัวที่ใหญ่สุดจะไหลไปอยู่ท้ายสุด
นึกภาพตามนี้
ฟองอากาศใหญ่ลอยขึ้นผิวน้ำก่อน · แต่ละรอบ ตัวที่ใหญ่สุดในส่วนที่เหลือจะ "ลอย" ไปท้าย ส่วนท้ายจึงค่อย ๆ เรียงจากขวามาซ้าย
ขั้นตอน
- รอบที่ k: เทียบ list[i] กับ list[i+1] ตั้งแต่ i = 0 ถึง n−k−1
- ถ้า list[i] > list[i+1] สลับกัน และจดไว้ว่า needNextPass = true
- ถ้าทั้งรอบไม่มีการสลับเลย แปลว่าเรียงแล้ว หยุดได้ทันที
- หลังรอบที่ k ตัวท้าย k ตัวอยู่ในตำแหน่งสุดท้ายแล้ว รอบถัดไปจึงเทียบน้อยลงทีละ 1
- needNextPass คือ "แบบปรับปรุง" — ถ้าเรียงอยู่แล้ว ใช้แค่ O(n)
- แย่สุดและเฉลี่ย O(n²)
Merge Sort — แบ่งแล้วค่อยรวม
แบ่ง list ออกครึ่ง ๆ ไปเรื่อย ๆ จนเหลือกองละตัวเดียว (ซึ่งเรียงอยู่แล้ว) แล้วรวมกองเล็ก ๆ กลับเป็นกองใหญ่แบบเรียงไปด้วย
นึกภาพตามนี้
ครูแบ่งกองข้อสอบให้นักเรียน 2 คนไปเรียง แต่ละคนก็แบ่งต่อให้เพื่อนอีก จนแต่ละคนได้แผ่นเดียว พอส่งคืน ครูแค่ "รวมสองกองที่เรียงแล้ว" โดยหยิบแผ่นบนสุดที่น้อยกว่าทีละแผ่น
ขั้นตอน
- ถ้ามีมากกว่า 1 ตัว: ก๊อปครึ่งแรกเป็น firstHalf ครึ่งหลังเป็น secondHalf
- เรียก mergeSort กับแต่ละครึ่ง (recursion — method เรียกตัวเอง)
- merge: เทียบหัวแถวสองฝั่ง หยิบตัวที่น้อยกว่าไปก่อน ฝั่งไหนเหลือก็ต่อท้าย
- O(n log n) ทุกกรณี — แบ่งได้ log n ชั้น ทุกชั้น merge รวม n ตัว
- ใช้หน่วยความจำเพิ่ม (array firstHalf / secondHalf)
- เป็นตัวอย่างของ divide and conquer
Quick Sort — เลือกตัวแบ่ง (pivot) แล้วแยกสองฝั่ง
เลือก pivot หนึ่งตัว จัดให้ตัวที่ไม่เกิน pivot ไปอยู่ซ้าย ตัวที่มากกว่าไปอยู่ขวา (partition) pivot จะได้ที่ถาวรทันที แล้วทำแบบเดียวกันกับสองฝั่ง
นึกภาพตามนี้
ครูให้นักเรียนคนแรกยืนเป็นหลัก แล้วสั่ง "ใครเตี้ยกว่าหรือเท่าไปยืนซ้าย ใครสูงกว่าไปขวา" คนที่เป็นหลักก็ได้ตำแหน่งที่ถูกเลย จากนั้นแต่ละฝั่งก็เลือกหลักใหม่ทำซ้ำ
partition ทำงานยังไง
- pivot = ตัวแรก · low เดินจากซ้าย หยุดที่ตัวที่ มากกว่า pivot
- high เดินจากขวา หยุดที่ตัวที่ ไม่มากกว่า pivot · ถ้ายังไม่สวนกันก็สลับสองตัวนั้น
- สวนกันแล้ว วาง pivot ลงที่ high แล้วคืนตำแหน่งนั้น
- เฉลี่ย O(n log n) · แย่สุด O(n²) เมื่อ pivot แบ่งไม่สมดุลทุกครั้ง (เช่น list เรียงอยู่แล้วและเลือกตัวแรก)
- เรียงในที่เดิม ไม่ต้องใช้ array เพิ่มแบบ merge sort
- หลัง partition: ซ้าย ≤ pivot < ขวา แต่สองฝั่งยังไม่เรียง
Heap และ Heap Sort
heap คือ binary tree ที่ (1) เต็มทีละชั้นจากซ้ายไปขวา และ (2) พ่อมากกว่าหรือเท่ากับลูกเสมอ → ตัวใหญ่สุดอยู่ที่ root เสมอ
นึกภาพตามนี้
ผังบริษัทที่หัวหน้าต้องเก่งกว่าลูกน้องเสมอ คนใหม่เข้ามาที่ล่างสุดก่อน ถ้าเก่งกว่าหัวหน้าก็สลับขึ้นไปเรื่อย ๆ
เก็บใน ArrayList ได้เลย
ไม่ต้องมีลูกศร เพราะคำนวณตำแหน่งได้:
- ลูกซ้ายของช่อง i =
2i + 1 - ลูกขวาของช่อง i =
2i + 2 - พ่อของช่อง i =
(i − 1) / 2
2 งานหลัก
- add: ต่อท้าย แล้ว "ลอยขึ้น" สลับกับพ่อจนถูกกฎ
- remove: เอา root ออก เอาตัวท้ายขึ้นแทน แล้ว "จมลง" สลับกับลูกตัวที่ใหญ่กว่า
- ทั้งคู่ O(log n) = ความสูงต้นไม้
Heap sort
ใส่ทุกตัวลง heap แล้ว remove() ทีละตัว จะได้ตัวใหญ่สุดออกมาก่อนเสมอ เอามาวางจากท้าย list ไล่มาหน้า ก็ได้ list ที่เรียงแล้ว
- heap sort O(n log n) ทุกกรณี (สร้าง heap n ครั้ง + เอาออก n ครั้ง ครั้งละ O(log n))
- ความสูงของ heap ที่มี n ตัว = O(log n) เพราะเต็มทีละชั้น
- ระวังสับสน: heap แค่ "พ่อ ≥ ลูก" ไม่ได้บอกว่าซ้ายน้อยกว่าขวาแบบ BST
Bucket Sort และ Radix Sort — เรียงโดยไม่ต้องเทียบกัน
วิธีก่อนหน้าทั้งหมดเรียงด้วยการเทียบค่า (เร็วสุดได้แค่ O(n log n)) แต่ถ้า key เป็นจำนวนเต็ม ใช้ "ถัง" แจกได้เลยโดยไม่ต้องเทียบ
นึกภาพตามนี้
บุรุษไปรษณีย์แยกจดหมายลงช่องตามรหัสไปรษณีย์ทีละหลัก ไม่ต้องเอาจดหมายมาเทียบกันเลย
Bucket sort
key อยู่ในช่วง 0..t → เตรียมถัง t+1 ใบ ใส่ตัวที่ key = i ลงถัง i แล้วเก็บจากถัง 0 ถึง t · ใช้เวลา O(n + t) แต่ถ้า t ใหญ่มาก (เช่น 0..1,000,000) เปลืองถังเกินไป
Radix sort
ใช้ถังแค่ 10 ใบ (0–9) แต่ทำหลายรอบ ทีละหลัก เริ่มจากหลักหน่วย · ถังต้องเป็นแบบ stable (มาก่อนอยู่ก่อน) ลำดับจากรอบก่อนจึงไม่เสีย · O(dn)
- radix sort เริ่มจากหลักที่มีค่าน้อยสุด (หลักหน่วย) ไปหลักที่มากขึ้น
- stable sort = ค่าที่เท่ากัน ลำดับเดิมไม่เปลี่ยน
- เหมาะกับ key ที่เป็นจำนวนเต็ม · ตัวอย่างในหนังสือ: 331, 454, 230, 34, 343, 45, 59, 453, 345, 231, 9
External Sort — เรียงไฟล์ที่ใหญ่เกินหน่วยความจำ
ถ้าไฟล์มีข้อมูล 2 ล้านตัวแต่ array ใส่ได้ทีละ 100,000 ตัว ก็โหลดมาเรียงทั้งหมดในครั้งเดียวไม่ได้ ต้องเรียงทีละก้อน แล้วค่อย merge
นึกภาพตามนี้
เรียงข้อสอบ 2,000 แผ่นบนโต๊ะที่วางได้ทีละ 100 แผ่น: เรียงทีละ 100 แผ่นแล้ววางเป็นกอง ๆ จากนั้นรวมกองที่เรียงแล้วเป็นคู่ ๆ (เหมือน merge) จนเหลือกองเดียว
2 phase
- Phase I: อ่านเข้า array ทีละก้อน (MAX_ARRAY_SIZE) → เรียงในหน่วยความจำ → เขียนลง f1.dat เป็นก้อน S1, S2, …
- Phase II: ย้ายครึ่งแรกของก้อนไป f2.dat แล้ว merge ก้อนคู่กัน (S1 กับ S(k/2+1), …) ลง f3.dat
- ก้อนใหญ่ขึ้นเท่าตัวทุกรอบ ทำซ้ำจนเหลือก้อนเดียว
- เป็น merge sort แบบหนึ่งที่ทำบนไฟล์ · ความซับซ้อนรวม O(n log n)
- ไม่จำเป็นต้อง merge ก้อนที่ติดกัน — merge S1 กับ S5, S2 กับ S6 ได้ ทำให้เขียนโค้ดง่ายขึ้น (แค่ย้ายครึ่งแรกไปอีกไฟล์)
MyList และ Array List — ล็อกเกอร์ที่เรียงติดกัน
"ออกแบบโครงสร้างข้อมูล = ออกแบบคลาส" มีฟิลด์เก็บข้อมูล และเมธอดสำหรับใส่ ลบ ค้นหา · บทนี้สร้าง MyArrayList เองโดยข้างในเป็น array ธรรมดา
นึกภาพตามนี้
ตู้ล็อกเกอร์ที่มีเลขช่องติดกัน หาช่องที่ 5 ได้ทันที แต่ถ้าจะแทรกของไว้ช่องแรก ต้องย้ายของทุกช่องถอยไปหนึ่งช่อง
MyList<E> (interface)
สัญญาว่า list ทุกแบบต้องมี: add(index, e) get set remove(index) indexOf lastIndexOf และมี default method เช่น add(e) = ใส่ที่ index size()
ข้างใน MyArrayList
E[] data— array เก็บของsize— มีของกี่ตัวdata.length— ความจุ (capacity) ≥ size- array ขยายตัวเองไม่ได้ → ต้องสร้างใหม่ใหญ่กว่าแล้วก๊อป
- get / set / add ท้าย = O(1) · add / remove ที่หน้าหรือกลาง = O(n) (ต้องเลื่อน)
- ensureCapacity สร้าง array ใหม่ขนาด
size * 2 + 1("+1" กันกรณี size = 0) - หนังสือใช้ INITIAL_CAPACITY = 16 · trimToSize() ตัดช่องว่างให้ capacity = size
- Java สร้าง
new E[n]ไม่ได้ (generic) จึงต้องเขียน(E[]) new Object[n]
Linked List — ขบวนรถไฟที่ต่อกันด้วยตะขอ
ของไม่ได้อยู่ติดกันในหน่วยความจำ แต่ละโหนดเก็บค่า (element) และ "ตะขอ" next ที่ชี้ไปโหนดถัดไป · มี head ชี้หัวขบวน tail ชี้ท้ายขบวน
นึกภาพตามนี้
เพิ่มตู้ที่หัวขบวนง่ายมาก แค่เกี่ยวตะขอใหม่ · แต่ถ้าอยากไปตู้ที่ 7 ต้องเดินผ่านตู้ 1–6 ไปเรื่อย ๆ เพราะไม่มีเลขช่องให้กระโดด
กฎทองของการต่อสาย
- ต่อสายใหม่ "ก่อน" ตัดสายเดิมเสมอ ไม่งั้นส่วนที่เหลือของขบวนหายไป
- อย่าลืมกรณีพิเศษ: list ว่าง (head = tail = null) และมีตัวเดียว
- ทุกครั้งที่แก้ head/tail ต้องเช็คว่าอีกตัวยังถูกอยู่ไหม
แบบอื่นของ linked list
ระบบปฏิบัติการใช้ circular list วนแบ่งเวลาให้ผู้ใช้หลายคน · doubly linked list ทำให้ removeLast เป็น O(1) เพราะย้อนจาก tail ได้ (java.util.LinkedList เป็นแบบ doubly)
เทียบเวลา (ตาราง 24.1)
| เมธอด | MyArrayList | MyLinkedList | ทำไม |
|---|---|---|---|
add(e) ต่อท้าย | O(1) | O(1) | array มีที่ว่าง / linked list มี tail |
get(index) | O(1) | O(n) | array คำนวณตำแหน่งได้ · linked list ต้องเดิน |
addFirst / removeFirst | O(n) | O(1) | array ต้องเลื่อนทุกตัว · linked list แค่ย้าย head |
add(index, e) / remove(index) | O(n) | O(n) | array เลื่อน · linked list เดินหา |
removeLast() | O(1) | O(n) | singly linked list ต้องเดินหาตัวรองสุดท้าย |
contains / indexOf | O(n) | O(n) | ต้องไล่ดูทีละตัวทั้งคู่ |
หมายเหตุ: ตาราง 24.1 ในหนังสือพิมพ์ set(index, e) ของ ArrayList เป็น O(n) แต่จริง ๆ array เข้าช่องได้ทันที จึงเป็น O(1) (ส่วน LinkedList เป็น O(n))
Stack และ Queue
ทั้งคู่คือ list ที่ "จำกัดว่าใส่และเอาออกได้ที่ไหน" · stack เข้า-ออกทางเดียว · queue เข้าท้าย ออกหัว
Stack = กองจาน (LIFO)
วางจานใบใหม่ไว้บนสุด หยิบก็หยิบใบบนสุด — เข้าทีหลังออกก่อน (Last In First Out) · ใช้ทำปุ่ม Undo, ตรวจวงเล็บ, call stack ของ method
Queue = แถวซื้อข้าว (FIFO)
มาก่อนได้ก่อน (First In First Out) ต่อท้ายแถว คนหน้าสุดออกก่อน · ใช้กับคิวงานพิมพ์ คิวคำขอ breadth-first search
- stack ใส่/เอาออกที่ท้ายอย่างเดียว → ArrayList ทำได้ O(1)
- queue เอาออกที่หัว → LinkedList.removeFirst() O(1) แต่ ArrayList ต้องเลื่อนทุกตัว O(n)
extends ArrayList ได้ แต่จะติดเมธอดที่ไม่ควรมีมาด้วย (เช่น add ตรงกลาง) หนังสือจึงใช้ composition: เอา list มาเป็นฟิลด์ข้างใน แล้วเปิดให้ใช้แค่ push/pop/peekPriority Queue — คิวที่ออกตามความสำคัญ
ไม่สนว่าใครมาก่อน ตัวที่สำคัญที่สุด (priority สูงสุด) ได้ออกก่อนเสมอ · หนังสือทำด้วย heap จากบทที่ 23
นึกภาพตามนี้
ห้องฉุกเฉินโรงพยาบาล: คนอาการหนักได้เข้าก่อน แม้จะมาทีหลัง
MyPriorityQueue = ห่อ Heap
enqueue(e)→heap.add(e)ตัวที่สำคัญลอยขึ้นบนdequeue()→heap.remove()เอา root (สำคัญสุด) ออก- ทั้งคู่ O(log n) · Patient ต้อง implements Comparable เพื่อเทียบ priority
- ถ้าใช้ list ธรรมดา ต้องไล่หาตัวสำคัญสุดทุกครั้ง O(n) · heap ทำให้เหลือ O(log n)
- ตัวอย่างหนังสือ: John(2), Jim(1), Tim(5), Cindy(7) → dequeue ได้ Cindy, Tim, John, Jim
BST — ต้นไม้ที่ซ้ายน้อย ขวามาก
binary tree = ทุกโหนดมีลูกได้ไม่เกิน 2 (ซ้าย/ขวา) · binary search tree เพิ่มกฎ: ทุกค่าในฝั่งซ้าย < ตัวเอง < ทุกค่าในฝั่งขวา และไม่มีค่าซ้ำ
นึกภาพตามนี้
เกมทายเลข 1–100: ทายว่า 50 แล้วได้คำใบ้ "น้อยกว่า" ก็ตัดครึ่งบนทิ้งได้ทันที · BST ทำแบบเดียวกัน ทุกครั้งที่เลือกซ้ายหรือขวา ตัดข้อมูลทิ้งไปทั้งฝั่ง
คำศัพท์ของต้นไม้
depth ของโหนด = จำนวนเส้นจาก root ลงมาถึงมัน (60 → 0, 45 → 2) · height ของต้นไม้ = ทางยาวสุดจาก root ถึงใบ (ต้นนี้ = 2) · ต้นไม้ย่อย (subtree) = โหนดหนึ่งพร้อมลูกหลานทั้งหมด
- search / insert / delete ใช้เวลา O(h) h = ความสูง · ต้นไม้สมดุล h ≈ log n แต่ถ้าใส่ข้อมูลเรียงกัน ต้นไม้เบี้ยว h = n → O(n)
- BST ในหนังสือ:
class BST<E extends Comparable<E>> implements Tree<E>มีฟิลด์rootและsize· โหนดคือTreeNode<E>(element, left, right) path(e)คืน list ของโหนดจาก root ไปถึง e (AVL ใช้เมธอดนี้)
การท่องต้นไม้ (traversal) และ Iterator
traversal = เยี่ยมทุกโหนดครั้งละหนึ่งครั้ง ต่างกันแค่ "เยี่ยมตัวเองตอนไหน" เทียบกับลูกซ้าย/ขวา
inorder
ซ้าย → ตัวเอง → ขวา
ได้ค่าเรียงน้อยไปมาก
45 55 57 60 67 100 107
preorder
ตัวเอง → ซ้าย → ขวา
= depth-first
60 55 45 57 100 67 107
postorder
ซ้าย → ขวา → ตัวเอง
ลูกก่อนพ่อเสมอ
45 57 55 67 107 100 60
breadth-first
ทีละชั้น บน→ล่าง ซ้าย→ขวา ใช้ queue
60 55 100 45 57 67 107
Iterator (25.5)
วัตถุที่ให้ "ไล่ทีละตัว" แบบเดียวกันทุกโครงสร้าง: hasNext() ยังมีตัวถัดไปไหม · next() คืนตัวถัดไป · BST ใน Java ใช้ for (E e : tree) ได้เพราะ implements Iterable
InorderIterator ในหนังสือทำง่าย ๆ: เรียก inorder เก็บใส่ ArrayList ไว้ก่อน แล้วค่อยหยิบทีละตัว · ลองกด iterator.next() ด้านบนหลายครั้ง
Tree visualization + MVC (25.4)
โปรแกรมแสดงต้นไม้ (BSTAnimation) แยกเป็น 3 ส่วน:
- Model = BST (ข้อมูล)
- View = BTView วาดต้นไม้ (วาดซ้ำแบบ recursion: วาดโหนด แล้ววาดต้นไม้ย่อยซ้าย/ขวา)
- Controller = ปุ่ม Insert/Delete ที่รับคำสั่งแล้วสั่ง model และให้ view วาดใหม่
- ทุกแบบใช้เวลา O(n) เพราะเยี่ยมแต่ละโหนดครั้งเดียว
- ให้ต้นไม้มาแล้วถามผล preorder / inorder / postorder — ฝึกจากปุ่มด้านบนจนจับรูปแบบได้
- inorder ของ BST = ข้อมูลเรียงแล้ว (ใช้ตรวจว่าเป็น BST จริงไหมก็ได้)
ลบโหนดออกจาก BST — 2 กรณี
ลบแล้วต้นไม้ต้องยังเป็น BST ถูกต้อง · หนังสือแบ่งตามว่าโหนดที่จะลบ (current) มีลูกซ้ายหรือไม่
กรณี 1: ไม่มีลูกซ้าย
ให้พ่อ (parent) ชี้ไปที่ลูกขวาของ current แทน แล้ว current ก็หลุดไปเอง · ถ้า current เป็นใบ ลูกขวาคือ null ก็จบ · ถ้า current เป็น root ให้ root = current.right
กรณี 2: มีลูกซ้าย
หา rightMost = ตัวขวาสุดในต้นไม้ย่อยซ้าย (ตัวใหญ่สุดที่ยังน้อยกว่า current) → ก๊อปค่ามาใส่ current → ตัด rightMost ทิ้งโดยต่อ rightMost.left ให้พ่อของมัน · rightMost ไม่มีลูกขวาแน่นอน แต่อาจมีลูกซ้าย
- กรณีพิเศษ: ถ้า current.left ไม่มีลูกขวาเลย rightMost = current.left และ parentOfRightMost = current → ต้องต่อที่
parentOfRightMost.leftไม่ใช่ .right - ลบใบเข้ากรณี 1 · ใช้เวลา O(h)
- ตัวอย่างหนังสือ (รูป 25.11): ลบ 20 → rightMost คือ 16 → 20 ถูกแทนด้วย 16 และ 14 ไปต่อกับ 10
Huffman Coding — ใช้ต้นไม้บีบอัดข้อความ
ปกติทุกตัวอักษรใช้ 8 บิตเท่ากัน · Huffman ให้ตัวที่โผล่บ่อยได้รหัสสั้น ตัวที่โผล่น้อยได้รหัสยาว ไฟล์รวมจึงเล็กลง
นึกภาพตามนี้
คำที่พิมพ์บ่อยอย่าง "ครับ" ถ้ามีปุ่มลัดสั้น ๆ ก็พิมพ์เร็วขึ้นมาก · Huffman แจก "ปุ่มลัด" สั้นที่สุดให้ตัวอักษรที่ใช้บ่อยที่สุด
สร้างต้นไม้ (greedy)
- นับความถี่ แต่ละตัวอักษรเป็นต้นไม้ 1 โหนด น้ำหนัก = ความถี่
- หยิบ 2 ต้นที่เบาที่สุด รวมเป็นต้นใหม่ (น้ำหนักบวกกัน) ทำซ้ำจนเหลือต้นเดียว
- เดินจาก root: ซ้าย = 0 ขวา = 1 ถึงใบก็ได้รหัสของตัวนั้น
- prefix property: ไม่มีรหัสไหนเป็นส่วนหน้าของรหัสอื่น จึงถอดรหัสได้ไม่กำกวม (เดินต้นไม้ตามบิตจนถึงใบ)
- เป็น greedy algorithm — เลือกสิ่งที่ดีที่สุดตอนนี้ (2 ต้นที่เบาสุด) แล้วได้ผลดีที่สุดโดยรวม
- หนังสือ: Mississippi → M=000, p=001, s=01, i=1 รวม 21 บิต
Balance Factor และการหมุน 4 แบบ
AVL tree = BST ที่คอยรักษาสมดุล: ทุกโหนด ความสูงฝั่งซ้ายกับขวาต่างกันไม่เกิน 1 → ต้นไม้สูงแค่ O(log n) ค้นหาเร็วเสมอ
นึกภาพตามนี้
ไม้กระดกที่มีคนนั่ง ถ้าฝั่งหนึ่งหนักเกินก็ "หมุน" ย้ายจุดหมุนให้กลับมาสมดุล โดยลำดับซ้าย-ขวาไม่เสีย
Balance factor (bf)
bf = ความสูงขวา − ความสูงซ้าย
- ใบ (leaf) สูง 0 · ต้นไม้ว่าง (null) สูง −1
- bf = −1, 0, +1 → สมดุล
- bf ≤ −1 = เอียงซ้าย (left-heavy) · ≥ +1 = เอียงขวา
- bf = −2 หรือ +2 → ต้องหมุน
เลือกการหมุน
| A | ลูกฝั่งหนัก | หมุน |
|---|---|---|
| −2 | ลูกซ้าย −1 หรือ 0 | LL |
| −2 | ลูกซ้าย +1 | LR |
| +2 | ลูกขวา +1 หรือ 0 | RR |
| +2 | ลูกขวา −1 | RL |
ภาพรวมการหมุน (T1–T4 = ต้นไม้ย่อย)
LL → หมุนขวาครั้งเดียว (RR คือกลับด้าน)
B ขึ้นเป็นพ่อ · A ลงไปเป็นลูกขวาของ B · T2 ย้ายข้างจากลูกขวาของ B ไปเป็นลูกซ้ายของ A (ค่าใน T2 อยู่ระหว่าง B กับ A พอดี)
LR → หมุนสองต่อ ให้ C ขึ้นเป็นพ่อ (RL คือกลับด้าน)
ทางเอียงหักมุม (ซ้ายแล้วขวา) หมุนครั้งเดียวไม่หาย → C ซึ่งอยู่ระหว่าง B กับ A ขึ้นเป็นพ่อของทั้งคู่ · T2 ไปเป็นลูกขวาของ B · T3 ไปเป็นลูกซ้ายของ A
ลองกดปุ่ม LL / RR / LR / RL ด้านล่าง: ระบบจะใส่ตัวเลขทุกตัวยกเว้นตัวสุดท้ายให้ก่อน แล้วไล่ทีละขั้นตอนใส่ตัวสุดท้ายซึ่งทำให้เสียสมดุล ตัวเลขใต้แต่ละโหนดคือ bf (สีแดง = ต้องหมุน)
AVL insert / delete และ balancePath
AVLTree extends BST: ใส่/ลบแบบ BST ตามปกติก่อน (super.insert) แล้วค่อยไล่ "ซ่อม" สมดุลบนเส้นทางกลับขึ้นไปถึง root
AVLTreeNode
สืบทอดจาก TreeNode แล้วเพิ่มฟิลด์ height เพื่อคำนวณ bf ได้เร็วโดยไม่ต้องนับใหม่ทั้งต้นไม้ · override createNewNode ให้สร้าง AVLTreeNode
balancePath(e)
- ขอ path จาก root ถึงโหนด e
- ไล่จากล่างขึ้นบน: updateHeight แล้วดู bf
- bf = ±2 → หมุนแบบที่ตรงกรณี
ทำไมเร็ว
ความสูง AVL ≤ ประมาณ 1.44 log(n+2) → O(log n) · การหมุนแต่ละครั้งแก้ลูกศรไม่กี่เส้น O(1) · insert, delete, search จึงเป็น O(log n) ทั้งหมด
- หลัง insert ตรวจเฉพาะโหนดบนเส้นทางจากโหนดใหม่ขึ้นไป root (ตัวอื่นความสูงไม่เปลี่ยน)
- delete: ลบแบบ BST แล้ว balancePath จาก
parent(กรณี 1) หรือparentOfRightMost(กรณี 2) - TestAVLTree ในหนังสือ: ใส่ 25, 20, 5 → เกิด LL ที่ 25 ได้ root เป็น 20
- ลองปุ่ม "ใส่เรียง 1–7" เทียบกับ BST ธรรมดาที่จะเบี้ยวเป็นเส้นตรง
Hashing — คำนวณตำแหน่งจาก key ได้ทันที
map เก็บคู่ key → value · ถ้าใช้ hashing จะค้นหา ใส่ ลบ ได้เฉลี่ย O(1) เพราะไม่ต้องไล่ค้น แค่คำนวณว่า key นี้ต้องอยู่ช่องไหน
นึกภาพตามนี้
ตู้ล็อกเกอร์ที่ช่องของคุณคำนวณจากชื่อ เช่น นับตัวอักษรแล้วหารเอาเศษด้วยจำนวนช่อง ไม่ต้องเดินเปิดดูทุกช่อง · ถ้าสองคนได้ช่องเดียวกัน เรียกว่า ชน (collision)
hashCode ของแต่ละชนิด (27.3)
| ชนิด key | hashCode |
|---|---|
| byte, short, int, char | ค่าตัวมันเองเป็น int เลย (Integer 42 → 42) |
| long (64 บิต) | folding: เอาครึ่งบน XOR ครึ่งล่าง (int)(key ^ (key >>> 32)) |
| float / double | แปลงบิตเป็น int/long ก่อน (Double.doubleToLongBits) แล้ว folding |
| String | polynomial hash code: s₀·b^(n−1) + s₁·b^(n−2) + … + s_(n−1) โดย b = 31 คำนวณแบบ Horner: h = 31*h + c |
- hash function ที่ดี: คำนวณเร็ว กระจาย index สม่ำเสมอ ชนน้อย · perfect hash function = ไม่ชนเลย (หายาก)
- ถ้าแค่บวกรหัสตัวอักษร "cat", "act", "tac" จะชนกันหมด จึงคูณ 31 ตามตำแหน่ง
- ใช้ % N ให้ N เป็นจำนวนเฉพาะจะกระจายดี · Java ใช้ N = 2^k แล้ว
& (N − 1)ซึ่งเร็วกว่า + ใช้ supplementalHash ผสมบิตบนลงมาก่อน
Open Addressing — ชนแล้วหาช่องว่างอื่น
ทุก key ต้องอยู่ในตารางเอง ถ้าช่องที่คำนวณได้มีคนอยู่แล้ว ก็ลองช่องอื่นตามสูตร (เรียกว่า probing) จนเจอช่องว่าง · ตารางถือว่าวนรอบ (ท้ายต่อหัว)
Linear probing
ลอง k, k+1, k+2, … (ทีละ 1)
ข้อเสีย: clustering ของจับกลุ่มเป็นก้อนยาว ยิ่งก้อนใหญ่ยิ่งชนบ่อย ค้นหาช้า · ข้อดี: ถ้าตารางไม่เต็ม เจอที่ว่างแน่นอน
Quadratic probing
ลอง k, k+1², k+2², k+3², … (k+1, k+4, k+9)
แก้ clustering แบบติดกันได้ แต่ยังมี secondary clustering (key ที่เริ่มช่องเดียวกันไปทางเดียวกัน) และไม่รับประกันว่าจะเจอช่องว่าง
Double hashing
ลอง k, k+h'(key), k+2h'(key), … เช่น h'(key) = 7 − key % 7
ระยะกระโดดขึ้นกับ key แต่ละตัว กระจายดีที่สุดในสามแบบ · h'(key) ห้ามเป็น 0
เลือกแบบ probing ในกล่องด้านบนซ้ายของตัวไล่โค้ด (ตารางจะรีเซ็ต) แล้วกดปุ่มชุดตัวอย่าง จากนั้นลอง put(40) เทียบกันแต่ละแบบ
- ตัวอย่างหนังสือ double hashing: N = 11, ใส่ 45, 58, 4, 28, 21 แล้วใส่ 12 → h(12) = 1, h'(12) = 2 → ลองช่อง 1, 3, 5 ได้ช่อง 5
- แบบฝึกหัดที่ออกบ่อย: ใส่ 34, 29, 53, 44, 120, 39, 45, 40 ลงตาราง 11 ช่องด้วยแต่ละวิธี
- open addressing ควรรักษา load factor ให้ต่ำกว่า 0.5
Separate Chaining, Load Factor และ MyHashMap
อีกวิธีรับมือการชน: ช่องหนึ่งเก็บได้หลายตัว โดยทำเป็น "ถัง" (bucket) เช่น LinkedList ใครได้ index เดียวกันก็ต่อท้ายถังเดียวกัน
นึกภาพตามนี้
ตู้จดหมายคอนโดที่แต่ละช่องเป็นถุง ใครได้เลขช่องเดียวกันก็ใส่ถุงเดียวกัน หาของก็ไปดูแค่ถุงเดียว
Load factor λ = n / N
n = จำนวนข้อมูล · N = ขนาดตาราง · λ สูง = แน่น ชนบ่อย
- open addressing ควร λ < 0.5
- separate chaining ควร λ < 0.9
- java.util.HashMap ใช้เกณฑ์ 0.75
Rehashing
เกินเกณฑ์ → สร้างตารางใหม่ใหญ่ขึ้น (อย่างน้อย 2 เท่า) แล้ว put ทุกตัวใหม่ เพราะ index ขึ้นกับขนาดตาราง · ช้า O(capacity) แต่เกิดนาน ๆ ครั้ง put โดยเฉลี่ยจึงยัง O(1)
MyMap / MyHashMap / MyHashSet
ข้างใน MyHashMap
LinkedList<MyMap.Entry<K,V>>[] table— ตาราง ช่องละหนึ่งถังcapacityเป็นกำลังของ 2 เสมอ (เริ่ม 4 ·trimToPowerOf2ปัดให้เป็น 2^k) สูงสุด1 << 30loadFactorThresholdค่าเริ่ม 0.75fhash(h) = supplementalHash(h) & (capacity − 1)- เงื่อนไข rehash ใน put:
size >= capacity * loadFactorThreshold
MyHashSet
แนวเดียวกับ MyHashMap แต่เก็บแค่ key (ไม่มี value) ใช้ทำ set ที่ไม่มีค่าซ้ำ: add เช็คก่อนว่ามีอยู่แล้วหรือไม่ · contains / add / remove เฉลี่ย O(1)
| เมธอด (ตาราง 27.1) | เวลา |
|---|---|
| get, put, remove, containsKey | O(1) |
| clear, containsValue, entrySet, keySet, values, rehash | O(capacity) |
- ตาราง capacity 4 ใส่ได้ถึง size 3 (4 × 0.75) ตัวที่ 4 จะทำให้ rehash เป็น 8
- containsValue ต้องไล่ทุกถัง → O(capacity) ไม่ใช่ O(1)
- ถ้า hash แย่จนทุก key ลงถังเดียว จะช้าเหมือน linked list O(n)
ตาราง Big-O รวมทุกบท
Big-O บอกว่า "เมื่อข้อมูลเพิ่มขึ้น งานเพิ่มเร็วแค่ไหน" ไม่ได้บอกเวลาเป็นวินาที · ลากแถบดูว่าเมื่อ n ใหญ่ขึ้น แต่ละแบบห่างกันขนาดไหน
ตัวอย่าง: n = 1,000,000 → O(log n) ≈ 20 ขั้น · O(n log n) ≈ 20 ล้านขั้น · O(n²) = 1 ล้านล้านขั้น
Sorting (บทที่ 23)
| algorithm | ดีสุด | เฉลี่ย | แย่สุด | ใช้ที่เพิ่ม | จำง่าย ๆ |
|---|---|---|---|---|---|
| Insertion | O(n) | O(n²) | O(n²) | O(1) | เสียบไพ่ · เกือบเรียงแล้วเร็วมาก |
| Bubble (มี needNextPass) | O(n) | O(n²) | O(n²) | O(1) | ตัวใหญ่ลอยไปท้ายทีละรอบ |
| Merge | O(n log n) | O(n log n) | O(n log n) | O(n) | คงที่ทุกกรณี แต่กินหน่วยความจำ |
| Quick | O(n log n) | O(n log n) | O(n²) | O(log n) stack | เร็วในทางปฏิบัติ · pivot แย่ = ช้า |
| Heap | O(n log n) | O(n log n) | O(n log n) | O(1)* | *ในหนังสือใช้ Heap แยก = O(n) |
| Bucket | O(n + t) | O(n + t) | t = ช่วงของ key | ||
| Radix | O(dn) | O(n) | d = จำนวนหลัก · ไม่เทียบค่า | ||
| External | O(n log n) | ไฟล์ชั่วคราว | merge sort บนไฟล์ | ||
โครงสร้างข้อมูล (บทที่ 24 – 27)
| โครงสร้าง | ค้นหา | ใส่ | ลบ | หมายเหตุ |
|---|---|---|---|---|
| Array list | O(n) | O(1) ท้าย · O(n) หน้า/กลาง | O(n) | get(index) O(1) |
| Linked list | O(n) | O(1) หัว/ท้าย | O(1) หัว · O(n) ท้าย | get(index) O(n) |
| Stack / Queue | — | O(1) | O(1) | push/pop · enqueue/dequeue |
| Heap / Priority queue | O(n) | O(log n) | O(log n) (root) | ดูตัวสูงสุด O(1) |
| BST | O(h) | O(h) | O(h) | h = log n ถึง n (ถ้าเบี้ยว) |
| AVL tree | O(log n) | O(log n) | O(log n) | หมุนให้สมดุลเสมอ |
| Hash map / set | O(1) | O(1) | O(1) | เฉลี่ย · แย่สุด O(n) ถ้าชนหมด · rehash O(capacity) |
| Tree traversal | O(n) | เยี่ยมทุกโหนดครั้งเดียว | ||
Flashcards
กดการ์ดเพื่อพลิกดูคำอธิบาย เลือกบทหรือสุ่มลำดับได้
ข้อสอบรวม 50 ข้อ
ปรนัย 4 ตัวเลือก บทละ 10 ข้อ สุ่มลำดับข้อและตัวเลือกทุกครั้ง · ออกไปอ่านหน้าอื่นระหว่างทำได้ กลับมาต่อจากปุ่มมุมขวาล่าง