โครงสร้างข้อมูล
Data Structures
แนะนำโครงสร้างข้อมูล
1.1 โครงสร้างข้อมูลคืออะไร
โครงสร้างข้อมูล (Data Structure) คือรูปแบบการจัดเก็บและจัดการข้อมูลในคอมพิวเตอร์ เพื่อให้สามารถเข้าถึง แก้ไข ค้นหา หรือจัดเรียงข้อมูลได้อย่างมีประสิทธิภาพ โดยมีจุดมุ่งหมายหลักคือการลดเวลาและพื้นที่ที่ใช้ในการประมวลผล การเลือกใช้โครงสร้างข้อมูลที่เหมาะสมกับปัญหาจะส่งผลโดยตรงต่อความเร็ว และความประหยัดหน่วยความจำของโปรแกรม
กล่าวอีกแบบหนึ่ง โครงสร้างข้อมูลคือวิธีการนามธรรม (Abstract) ในการมองข้อมูล เช่น การมองชุดข้อมูลเป็น "ลำดับ" หรือ "ต้นไม้" หรือ "กราฟ" การเลือกมุมมองที่เหมาะสมจะช่วยให้เราเลือกอัลกอริทึมที่ทำงานบนโครงสร้างนั้น ๆ ได้ตามธรรมชาติ ตัวอย่างเช่น ปัญหาการหาคำนำหน้า (prefix) ที่ใช้ร่วมกัน จะง่ายขึ้นมากหากเราเก็บคำศัพท์ในรูปแบบ Trie แทนที่จะเป็นเพียง array ของสตริง
โครงสร้างข้อมูล (Data Structure) คือวิธีการจัดระเบียบข้อมูลในหน่วยความจำ คอมพิวเตอร์ เพื่อให้สามารถดำเนินการ (operation) กับข้อมูลได้อย่างมีประสิทธิภาพ ตามข้อกำหนดของปัญหาที่ต้องการแก้
โครงสร้างข้อมูลแต่ละชนิดจะมี "นามธรรม" (abstraction) ของตัวเอง กล่าวคือ ผู้ใช้ไม่จำเป็นต้องทราบรายละเอียดการเก็บข้อมูลภายใน แต่จะรู้เพียงว่าสามารถเรียกใช้ operation ใดได้บ้าง และแต่ละ operation ใช้เวลาเท่าใด ยกตัวอย่างเช่น เมื่อเราใช้ std::stack ใน C++ เราทราบว่าการ push และ pop ใช้เวลา O(1) โดยไม่ต้องรู้ว่าภายในถูก implement ด้วย linked list หรือ dynamic array
1.2 ความสัมพันธ์ระหว่างโครงสร้างข้อมูลและอัลกอริทึม
โครงสร้างข้อมูลและอัลกอริทึมเป็นสองสิ่งที่แยกจากกันไม่ได้ อัลกอริทึมคือขั้นตอนการแก้ปัญหา ส่วนโครงสร้างข้อมูลคือตัวถือข้อมูลที่อัลกอริทึมจะดำเนินการ อัลกอริทึมที่ดีจะไม่มีประโยชน์ถ้าเลือกใช้โครงสร้างข้อมูลที่ไม่เหมาะสม และในทางกลับกัน โครงสร้างข้อมูลที่ดีเองก็ถูกออกแบบมาเพื่อรองรับ operation ที่อัลกอริทึมบางประเภต้องการ
ตัวอย่างคลาสสิกคือ Dijkstra's Algorithm สำหรับหา shortest path บนกราฟ ถ้าใช้ array เก็บระยะทาง ความซับซ้อนจะเป็น O(V²) แต่ถ้าใช้ priority queue (binary heap) ความซับซ้อนจะลดลงเป็น O((V+E) log V) ซึ่งเร็วขึ้นมากเมื่อกราฟมีความหนาแน่นต่ำ เห็นได้ว่าการเปลี่ยนโครงสร้างข้อมูลเพียงอย่างเดียว สามารถเปลี่ยนความซับซ้อนของอัลกอริทึมทั้งตัวได้
กฎทั่วไปในการเลือกโครงสร้างข้อมูล: ให้เริ่มจากการวิเคราะห์ operations ที่ปัญหาต้องการ จากนั้นเลือกโครงสร้างที่รองรับ operations เหล่านั้น ด้วยความซับซ้อนเฉลี่ยต่ำสุด ในบางครั้งอาจต้องใช้โครงสร้างผสม เช่น hash map + doubly linked list สำหรับ LRU Cache
ตัวอย่างเปรียบเทียบ
พิจารณาปัญหา "หาว่ามีข้อมูล X อยู่ในชุดหรือไม่"
- Array ที่ไม่เรียง: ค้นหา O(n), แทรก O(1)
- Array ที่เรียงแล้ว: ค้นหา O(log n) ด้วย binary search, แทรก O(n)
- Hash Set: ค้นหา O(1) เฉลี่ย, แทรก O(1) เฉลี่ย
- BST (balanced): ค้นหา O(log n), แทรก O(log n) และยังเก็บลำดับได้
1.3 ประเภทของโครงสร้างข้อมูล
โครงสร้างข้อมูลสามารถแบ่งออกเป็นหลายประเภทตามมุมมองที่ใช้พิจารณา การแบ่งที่นิยมที่สุดคือแบ่งตาม "ลักษณะความสัมพันธ์" ระหว่างข้อมูล ซึ่งจะส่งผลต่อวิธีการเข้าถึงและลำดับการจัดเก็บ
1) Linear Data Structures (เชิงเส้น)
ข้อมูลถูกจัดเก็บเป็นลำดับ มีต้นและปลายที่ชัดเจน การเข้าถึงมักเป็นแบบ sequential ตัวอย่าง: Array, Linked List, Stack, Queue, Deque
2) Non-Linear Data Structures (ไม่เชิงเส้น)
ข้อมูลไม่ได้จัดเก็บเป็นลำดับเดียว แต่มีความสัมพันธ์แบบลำดับชั้น (hierarchical) หรือแบบเครือข่าย (network) ตัวอย่าง: Tree (BST, AVL, Trie), Graph, Heap
3) Hash-based Data Structures
ใช้ฟังก์ชัน hash เพื่อแมป key ไปยังตำแหน่งในตาราง ทำให้สามารถเข้าถึงข้อมูลแบบ randomized ได้ในเวลาเฉลี่ย O(1) ตัวอย่าง: Hash Table, Hash Map, Hash Set, Bloom Filter
4) Abstract Data Types (ADT)
เป็นนามธรรมที่อธิบาย พฤติกรรม ของโครงสร้างข้อมูล โดยไม่ระบุวิธีการ implement ตัวอย่าง: List, Stack, Queue, Map, Set, Priority Queue — ADT หนึ่งสามารถ implement ได้หลายวิธี เช่น Stack สามารถ implement ด้วย array หรือ linked list ก็ได้
ใน C++ STL โครงสร้างข้อมูลแต่ละชนิดถูก implement เพื่อรองรับ ADT หนึ่งชนิด เช่น std::vector รองรับ ADT ชื่อ "List" std::stack รองรับ ADT "Stack" การเข้าใจแนวคิด ADT จะช่วยให้สลับ implementation ได้โดยไม่กระทบตรรกะของโปรแกรม
1.4 การเลือกโครงสร้างข้อมูลที่เหมาะสม
การเลือกโครงสร้างข้อมูลเป็นทักษะที่สำคัญที่สุดอย่างหนึ่งของนักโปรแกรม การเลือกผิดอาจทำให้โปรแกรมช้าลงหลายสิบเท่า หรือใช้หน่วยความจำมากเกินไป ขั้นตอนการเลือกที่ดีคือเริ่มจากการตอบคำถามต่อไปนี้
- ข้อมูลที่จะเก็บคืออะไร? ตัวเลข สตริง คู่ key-value หรือวัตถุที่ซับซ้อน
- Operations ที่ต้องการคืออะไร? เช่น insert, delete, search, find-min, iterate
- ความถี่ของแต่ละ operation? ถ้า search บ่อยกว่า insert อาจเลือก hash set
- ต้องการรักษาลำดับหรือไม่? ถ้าใช่ ใช้ sorted structure เช่น BST หรือ array ที่เรียงแล้ว
- ขนาดของข้อมูล? n เล็ก (เช่น ≤ 50) อาจใช้ array แบบง่าย ๆ ก็พอ
- ข้อจำกัดด้านหน่วยความจำ? ระบบฝังตัวมักต้องการโครงสร้างที่ประหยัด
ในทางปฏิบัติ มักเริ่มต้นด้วยโครงสร้างที่ง่ายที่สุดที่ตอบโจทย์ แล้วค่อยปรับให้ดีขึ้น หลังจาก profile และพบว่าโครงสร้างนั้นเป็นคอขวด เป็นแนวปฏิบัติทั่วไปในวงการ software engineering ว่า "premature optimization is the root of all evil" การเลือก hash map ตั้งแต่แรกโดยไม่จำเป็นอาจทำให้โค้ดซับซ้อนเกินไป
สำหรับการแข่งขัน (competitive programming) โครงสร้างที่ใช้บ่อยที่สุดคือ std::vector, std::set, std::map, std::unordered_map, std::priority_queue และ std::deque ถ้าจำได้ครบก็สามารถแก้ปัญหาได้มากกว่า 80% ของโจทย์ทั่วไป
1.5 ภาพรวมเนื้อหาในหนังสือเล่มนี้
หนังสือเล่มนี้จะดำเนินจากพื้นฐานสู่ขั้นสูง เริ่มต้นด้วยแนวคิดการวิเคราะห์ประสิทธิภาพ จากนั้นแนะนำโครงสร้างข้อมูลเชิงเส้นทั้งหมด (Array, Linked List, Stack, Queue) จากนั้นไปสู่โครงสร้างแบบต้นไม้ (Tree, BST, Heap) และโครงสร้างแบบกราฟรวมถึง Disjoint Set ในตอนท้ายจะกล่าวถึงโครงสร้างขั้นสูง เช่น Trie, Segment Tree, Fenwick Tree, AVL Tree และโครงสร้างเชิงแข่งขัน เช่น Heavy-Light Decomposition, Persistent Data Structures และ Mo's Algorithm
| บทที่ | หัวข้อ | ระดับความยาก |
|---|---|---|
| 2 | การวิเคราะห์ประสิทธิภาพ | พื้นฐาน |
| 3 | Array และ String | พื้นฐาน |
| 4 | Linked List | พื้นฐาน |
| 5 | Stack และ Queue | พื้นฐาน |
| 6 | Hash Table | กลาง |
| 7 | Tree และ BST | กลาง |
| 8 | Heap และ Priority Queue | กลาง |
| 9 | Graph | กลาง |
| 10 | Disjoint Set Union | กลาง |
| 11 | โครงสร้างขั้นสูง | ขั้นสูง |
| 12 | เชิงแข่งขัน | ขั้นสูง |
แผนผังเนื้อหาตลอดทั้งเล่ม
ผู้อ่านที่เพิ่งเริ่มต้นควรอ่านตามลำดับบท เนื่องจากบทต่อ ๆ ไปมักอ้างอิงแนวคิดจากบทก่อนหน้า สำหรับผู้ที่มีพื้นฐานแล้ว สามารถข้ามไปยังบทที่สนใจได้โดยตรง โดยเฉพาะบทที่ 11 และ 12 ที่เน้นเทคนิคเชิงแข่งขัน
การวิเคราะห์ประสิทธิภาพ
2.1 Big-O Notation
Big-O Notation เป็นวิธีมาตรฐานในการอธิบายการเติบโตของเวลาทำงาน หรือพื้นที่ที่ใช้เมื่อขนาดของข้อมูลเข้า (input) เพิ่มขึ้น โดยเน้นที่อัตราการเติบโตในระยะยาว (asymptotic behavior) และละเลยค่าคงที่และพจน์ที่มีขนาดเล็กกว่า ทำให้สามารถเปรียบเทียบประสิทธิภาพของอัลกอริทึมได้อย่างเป็นสากล
ในทางคณิตศาสตร์ กำหนดให้ฟังก์ชัน f(n) = O(g(n)) ถ้ามีค่าคงที่ c > 0 และ n₀ ≥ 0 ที่ทำให้ |f(n)| ≤ c · |g(n)| สำหรับทุก n ≥ n₀ กล่าวคือ g(n) เป็น "ขอบเขตบน" ของ f(n) เมื่อ n ใหญ่พอ
Big-O ใช้สำหรับบอก ขอบเขตบน (upper bound) ของฟังก์ชันการเติบโต Big-Ω ใช้บอกขอบเขตล่าง (lower bound) Big-Θ ใช้บอกขอบเขตที่แน่นหนา (tight bound) — ในทางปฏิบัติ เรามักใช้ Big-O เพื่อสื่อสารความเร็วของอัลกอริทึม
ลำดับการเติบโตที่พบบ่อย
| ชื่อ | Big-O | ตัวอย่างเมื่อ n = 10⁶ |
|---|---|---|
| ค่าคงที่ | O(1) | 1 |
| ลอการิทึม | O(log n) | 20 |
| เชิงเส้น | O(n) | 1,000,000 |
| เชิงเส้น × ลอการิทึม | O(n log n) | 20,000,000 |
| กำลังสอง | O(n²) | 10¹² |
| กำลังสาม | O(n³) | 10¹⁸ |
| เอกซ์โพเนนเชียล | O(2ⁿ) | ใหญ่เกินจะคำนวณ |
| แฟกทอเรียล | O(n!) | ใหญ่เกินจะคำนวณ |
เปรียบเทียบอัตราการเติบโตของฟังก์ชันสำคัญ
Big-O บอกเพียงอัตราการเติบโต ไม่ได้บอกเวลาจริง ตัวอย่างเช่น O(n log n) อัลกอริทึมหนึ่งอาจเร็วกว่า อีกอัลกอริทึมที่เป็น O(n log n) ได้หลายเท่า เนื่องจากค่าคงที่ (constant factor) ที่ Big-O ละเลย ดังนั้นในงานจริง ต้อง profile ด้วย ไม่ใช่พิจารณา Big-O อย่างเดียว
2.2 การวิเคราะห์ Time Complexity
การวิเคราะห์ Time Complexity คือการประเมินจำนวน operation พื้นฐาน ที่อัลกอริทึมทำเมื่อ input มีขนาด n โดยนับเฉพาะ operation ที่ส่งผลต่อเวลาโดยรวม เช่น การเปรียบเทียบ การกำหนดค่า และการคำนวณทางคณิตศาสตร์ ในทางปฏิบัติ เรามักประเมินจาก loop และ recursive call
ตัวอย่าง: การหาผลรวมใน array
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}ลูปนี้ทำงาน n ครั้ง ในแต่ละครั้งทำ operation O(1) ดังนั้น Time Complexity เป็น O(n)
ตัวอย่าง: Bubble Sort
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
std::swap(arr[j], arr[j + 1]);
}
}
}ลูปนอกทำงาน n-1 ครั้ง ลูปในทำงาน ≤ n-1 ครั้งต่อครั้ง รวมเป็น (n-1)² ≈ n² operation ดังนั้น Time Complexity เป็น O(n²)
ตัวอย่าง: Binary Search
int lo = 0, hi = n - 1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}ในแต่ละรอบ ช่วงการค้นหาลดลงครึ่งหนึ่ง จาก n → n/2 → n/4 → ... → 1 ใช้รอบประมาณ log₂(n) ดังนั้น Time Complexity เป็น O(log n)
2.3 การวิเคราะห์ Space Complexity
Space Complexity วัดปริมาณหน่วยความจำที่อัลกอริทึมใช้ รวมทั้ง input, output และพื้นที่ช่วย (auxiliary space) เช่นเดียวกับ Time Complexity เราใช้ Big-O ในการอธิบาย การวิเคราะห์ Space มักง่ายกว่า Time เนื่องจากแค่นับจำนวนตัวแปร และโครงสร้างข้อมูลที่สร้างขึ้น
ตัวอย่าง: การสลับสองค่า
void swap(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}ใช้ตัวแปรเพิ่มเพียง 1 ตัว (temp) ดังนั้น Space Complexity เป็น O(1)
ตัวอย่าง: Merge Sort
Merge Sort ใช้อาเรย์ชั่วคราวขนาด n ตอน merge ดังนั้น Space Complexity เป็น O(n) ในขณะที่ Quick Sort ในกรณีที่ดีใช้เพียง O(log n) สำหรับ call stack ของ recursion
ใน competitive programming ค่าเฉลี่ย memory limit มักอยู่ที่ 256 MB การประเมินคร่าว ๆ: int = 4 bytes, long long = 8 bytes, vector<int> ขนาด 10⁶ = 4 MB สามารถเก็บ array ขนาด 10⁸ ได้ ~ 400 MB (เกิน limit)
2.4 Best, Average, Worst Case
หลายอัลกอริทึมมีเวลาทำงานแตกต่างกันตามลักษณะของ input จึงมีการแยกวิเคราะห์เป็นสามกรณี:
- Best Case: เวลาน้อยที่สุดที่เป็นไปได้ มักเกิดกับ input ที่ "เหมาะที่สุด"
- Average Case: เวลาเฉลี่ยเมื่อสุ่ม input ทั่วไป
- Worst Case: เวลามากที่สุดที่เป็นไปได้ ใช้สำหรับรับประกันเวลา
ตัวอย่าง Quick Sort: Best = O(n log n) เมื่อ pivot แบ่งครึ่งพอดี Average = O(n log n) เมื่อสุ่ม input Worst = O(n²) เมื่อ pivot เลือกตัวที่มาก/น้อยที่สุดทุกครั้ง ในทางกลับกัน Merge Sort มี Best = Average = Worst = O(n log n) เสมอ แต่มักช้ากว่า Quick Sort ในกรณีทั่วไปเพราะ constant factor สูงกว่า
| อัลกอริทึม | Best | Average | Worst |
|---|---|---|---|
| Quick Sort | O(n log n) | O(n log n) | O(n²) |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) |
| Bubble Sort | O(n) | O(n²) | O(n²) |
| Insertion Sort | O(n) | O(n²) | O(n²) |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) |
| Binary Search | O(1) | O(log n) | O(log n) |
การวิเคราะห์ Best/Average/Worst ของอัลกอริทึมสำคัญ
ในการแข่งขัน ผู้ออกโจทย์มักตั้งใจสร้าง test case ที่ทำให้เกิด worst case ดังนั้นควรเลือกอัลกอริทึมที่ worst case ดีพอ Quick Sort ที่ไม่มีการสุ่ม pivot อาจโดน TLE ได้ ในขณะที่ std::sort ของ C++ ใช้ Introsort ที่รับประกัน worst case O(n log n)
2.5 Amortized Analysis
Amortized Analysis ใช้สำหรับอัลกอริทึมที่บาง operation ช้ามาก แต่ operation อื่นเร็ว จนกระทั่งเฉลี่ยแล้วถือว่าเร็ว ตัวอย่างคลาสสิกคือ std::vector::push_back ใน C++ เมื่อเพิ่มข้อมูลจนความจุเต็ม จะมีการ allocate ใหม่และ copy ข้อมูลเก่า ซึ่งใช้เวลา O(n) แต่ operation ปกติใช้เวลาเพียง O(1)
Amortized cost คือค่าเฉลี่ยของ cost ต่อ operation เมื่อพิจารณาลำดับ operation ทั้งหมด ถ้าลำดับ operation ยาว k ใช้เวลารวม T(k) amortized cost คือ T(k)/k ซึ่งเป็น "cost เฉลี่ยต่อ operation"
สำหรับ vector::push_back เมื่อทำ n ครั้งติดต่อกัน จะมีการ resize ประมาณ log₂(n) ครั้ง แต่ละครั้งคัดลอก 1, 2, 4, ..., n ตัว รวม ≤ 2n ตัว รวมกับการ push ปกติ n ครั้ง (n operation O(1)) รวมเป็น 3n operation สำหรับ n push ดังนั้น amortized = O(1) ต่อ push
วิธีการวิเคราะห์ Amortized
- Aggregate Method: คำนวณ cost รวมทั้งหมด หารด้วยจำนวน operation
- Accounting Method: เก็บ "เครดิต" ตอน operation ถูก แล้วใช้ตอน operation แพง
- Potential Method: นิยามฟังก์ชันศักย์ Φ(S) ของสถานะ ใช้ฟังก์ชันนี้คำนวณ amortized cost
โครงสร้างข้อมูลที่มี amortized complexity ที่สำคัญได้แก่ Disjoint Set Union (DSU) ที่มี amortized O(α(n)) ≈ O(1) และ Splay Tree ที่มี amortized O(log n) ต่อ operation — ทั้งสองโครงสร้างมี worst case ต่อ operation ที่ช้ากว่านั้นมาก แต่เฉลี่ยแล้วถือว่าเร็ว
Array และ String
3.1 Array พื้นฐาน
Array เป็นโครงสร้างข้อมูลพื้นฐานที่สุด เก็บข้อมูลหลายตัวในบล็อกหน่วยความจำที่ต่อเนื่องกัน แต่ละตัวเรียกว่า "element" และสามารถเข้าถึงได้ด้วย "index" ซึ่งเป็นตัวเลขลำดับของ element นั้นใน array การเข้าถึงแบบสุ่ม (random access) ทำได้ในเวลา O(1) เพราะคำนวณตำแหน่งในหน่วยความจำได้ตรงจาก index
Array คือโครงสร้างข้อมูลที่เก็บข้อมูลหลายตัว ในหน่วยความจำที่ต่อเนื่องกัน ขนาดของ array อาจถูกกำหนดตั้งแต่ตอนประกาศ (static array) หรือเปลี่ยนแปลงได้ระหว่างการทำงาน (dynamic array)
Static Array ใน C++
int arr[5] = {10, 20, 30, 40, 50};
cout << arr[0]; // 10
cout << arr[4]; // 50
arr[2] = 100; // แก้ค่าตำแหน่งที่ 2
การคำนวณตำแหน่งในหน่วยความจำ
ถ้า array เริ่มที่ address base แต่ละ element มีขนาด k bytes ตำแหน่งของ element index i คือ base + i * k การคำนวณนี้ใช้เวลาคงที่ O(1) ทำให้การเข้าถึง array โดย index เป็น O(1) เสมอ
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| เข้าถึงด้วย index (access) | O(1) | O(1) | คำนวณตำแหน่งตรง |
| ค้นหาค่า (linear search) | O(n) | O(1) | ต้องไล่ดูทุกตัว |
| แทรกท้าย (push) | O(1)* | O(1) | *amortized สำหรับ dynamic array |
| แทรกกลาง (insert) | O(n) | O(1) | ต้องเลื่อน element ที่อยู่หลัง |
| ลบ (delete) | O(n) | O(1) | ต้องเลื่อน element ที่อยู่หลัง |
สรุป Time / Space Complexity
3.2 Dynamic Array (std::vector)
Static array มีขนาดคงที่ตั้งแต่ประกาศ ไม่ยืดหยุ่นพอสำหรับงานจริง ภาษาโปรแกรมสมัยใหม่จึงมี "Dynamic Array" ที่ขยายขนาดได้อัตโนมัติ ใน C++ คือ std::vector, ใน Python คือ list, ใน Java คือ ArrayList
Dynamic array เก็บ pointer ไปยัง buffer ใน heap เมื่อเพิ่มข้อมูลจน buffer เต็ม จะ allocate buffer ใหม่ที่ใหญ่กว่า ปกติขยายเป็น 2 เท่า แล้ว copy ข้อมูลเก่ามา การ copy ใช้เวลา O(n) ต่อครั้ง แต่ amortized ตลอด n ครั้งคือ O(1) ต่อ push
การใช้ std::vector
#include <vector>
std::vector<int> v;
v.push_back(10); // [10]
v.push_back(20); // [10, 20]
v.push_back(30); // [10, 20, 30]
cout << v.size(); // 3
cout << v[1]; // 20
cout << v.back(); // 30 (ดูตัวสุดท้าย)
v.pop_back(); // ลบตัวสุดท้าย → [10, 20]
v.clear(); // ล้างทั้งหมด → []
การวนลูป
// วิธี 1: index
for (size_t i = 0; i < v.size(); i++) {
cout << v[i] << " ";
}
// วิธี 2: range-based for (C++11)
for (int x : v) {
cout << x << " ";
}
// วิธี 3: iterator
for (auto it = v.begin(); it != v.end(); ++it) {
cout << *it << " ";
}
ใน C++ ใช้ v.emplace_back(args...) แทน push_back เมื่อเพิ่ม object ที่ต้องสร้างใหม่ เพราะ emplace_back สร้าง object ในตำแหน่งปลายทางโดยตรง ไม่ต้อง copy/move เร็วกว่าเล็กน้อยในกรณีที่ object มีขนาดใหญ่
3.3 2D Array และ Matrix
Array สองมิติ (2D array) หรือ "matrix" เก็บข้อมูลในรูปแบบตาราง มีแถว (row) และคอลัมน์ (column) ในหน่วยความจำ 2D array มักถูกเก็บแบบ row-major คือเรียงแถวที่ 0 ก่อน แล้วตามด้วยแถวที่ 1, 2, ... การเข้าถึง arr[i][j] คำนวณตำแหน่งเป็น base + (i * cols + j) * element_size
การประกาศและใช้งาน
// Static 2D array
int grid[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// Dynamic 2D array (vector of vectors)
std::vector<std::vector<int>> mat(
3, std::vector<int>(4, 0)
);
// การเข้าถึง
mat[1][2] = 99;
// วนลูปผ่านทุกตัว
for (int i = 0; i < (int)mat.size(); i++) {
for (int j = 0; j < (int)mat[0].size(); j++) {
cout << mat[i][j] << " ";
}
cout << "\n";
}
vector<vector<int>> ไม่ได้เก็บข้อมูลต่อเนื่องกันจริง ๆ แต่ละแถวเป็น vector คนละตัว ทำให้ cache locality แย่กว่า 2D array แบบ static สำหรับงานที่ต้องการความเร็วสูง แนะนำให้ใช้ 1D vector ขนาด rows*cols แล้วคำนวณ index เอง เช่น v[i*cols + j]
ตัวอย่าง: เมทริกซ์คูณเมทริกซ์
// C = A × B, ที่ A เป็น m×k, B เป็น k×n, C เป็น m×n
std::vector<std::vector<int>> multiply(
const std::vector<std::vector<int>>& A,
const std::vector<std::vector<int>>& B) {
int m = A.size(), k = A[0].size(), n = B[0].size();
std::vector<std::vector<int>> C(m, std::vector<int>(n, 0));
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
for (int p = 0; p < k; p++)
C[i][j] += A[i][p] * B[p][j];
return C;
}Matrix Multiplication แบบ O(m·k·n) — มี algorithm ที่เร็วกว่านี้ เช่น Strassen
3.4 String และการประมวลผล
String ในทางคอมพิวเตอร์คือลำดับของตัวอักษร (character) สามารถมองเป็น array ของ char ได้ ใน C++ มี std::string ซึ่งเป็น dynamic array ของ char พร้อม method สำหรับจัดการสตริงต่าง ๆ การเข้าใจวิธีประมวลผล string อย่างมีประสิทธิภาพเป็นพื้นฐานของหลายอัลกอริทึม เช่น pattern matching, parsing, และ text processing
พื้นฐาน std::string
#include <string>
std::string s = "hello";
std::string t = " world";
cout << s.length(); // 5
cout << s[0]; // 'h'
cout << s + t; // "hello world"
cout << s.substr(1, 3); // "ell" (เริ่มที่ 1, ยาว 3)
s += "!"; // "hello!"
s.find("ll"); // 2 (index ที่เริ่ม "ll")
s.find("xyz"); // std::string::npos ถ้าไม่พบ
// เปรียบเทียบ
if (s == "hello!") { /* ... */ }
if (s < "world") { /* ... */ } // เรียงพจนานุกรม
ตัวอย่าง: การกลับสตริง
std::string reverse(std::string s) {
int n = s.length();
for (int i = 0; i < n / 2; i++) {
std::swap(s[i], s[n - 1 - i]);
}
return s;
}
// reverse("hello") → "olleh"C++17 มี std::string_view ซึ่งเป็น "มุมมอง" ของ string โดยไม่ต้อง copy เหมาะกับการส่ง string ระหว่างฟังก์ชัน เพราะใช้เวลา O(1) ในการสร้าง แต่ระวัง: string_view ไม่ได้เป็นเจ้าของข้อมูล ถ้า string ต้นทางถูกทำลาย string_view จะชี้ไปที่ memory ที่ invalid
3.5 Prefix Sum และ Difference Array
Prefix Sum เป็นเทคนิคสำหรับตอบคำถาม "ผลรวมในช่วง [l, r]" ของ array หลายครั้งอย่างรวดเร็ว หลักการคือสร้าง array prefix ที่ prefix[i] เก็บผลรวมตั้งแต่ arr[0] ถึง arr[i-1] จากนั้นผลรวมในช่วง [l, r] คือ prefix[r+1] - prefix[l] ซึ่งคำนวณได้ใน O(1)
การสร้าง Prefix Sum
std::vector<int> arr = {2, 4, 1, 3, 5};
int n = arr.size();
// prefix[i] = arr[0] + arr[1] + ... + arr[i-1]
std::vector<long long> prefix(n + 1, 0);
for (int i = 0; i < n; i++) {
prefix[i + 1] = prefix[i] + arr[i];
}
// ผลรวมในช่วง [l, r] (inclusive)
auto range_sum = [&](int l, int r) -> long long {
return prefix[r + 1] - prefix[l];
};
cout << range_sum(1, 3); // 4 + 1 + 3 = 8
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| สร้าง prefix array | O(n) | O(n) | ครั้งเดียวตอนต้น |
| Query ผลรวมช่วง [l,r] | O(1) | O(1) | หัวใจของ prefix sum |
| อัปเดตค่า arr[i] | O(n) | O(1) | ต้องสร้าง prefix ใหม่ |
สรุป Time / Space Complexity
Difference Array (ผกผันของ Prefix Sum)
สำหรับปัญหา "เพิ่มค่าในช่วง [l, r] ไป x" หลายครั้ง แล้วจึงอยากทราบค่าสุดท้ายของแต่ละตำแหน่ง ใช้ Difference Array: เพิ่ม x ที่ diff[l] และลบ x ที่ diff[r+1] ค่าจริงของตำแหน่ง i คือ prefix sum ของ diff ถึง i
// เพิ่มค่า +5 ในช่วง [2, 4]
int n = 10;
std::vector<int> diff(n + 1, 0);
auto add_range = [&](int l, int r, int x) {
diff[l] += x;
diff[r + 1] -= x;
};
add_range(2, 4, 5);
add_range(0, 3, 3);
// สร้าง array จริงจาก difference
std::vector<int> arr(n);
int cur = 0;
for (int i = 0; i < n; i++) {
cur += diff[i];
arr[i] = cur;
}โจทย์: มีนักเรียน N คน เลขที่ 1..N ครูจะแจกขนมในช่วงเลขที่ [l, r] ทีละ k ชิ้น รวม M ครั้ง จงหาว่านักเรียนแต่ละคนได้ขนมกี่ชิ้น
วิธีแก้: ใช้ difference array เพิ่ม k ที่ตำแหน่ง l ลบ k ที่ตำแหน่ง r+1 หลังกระทำ M ครั้ง ทำ prefix sum ครั้งเดียว ได้คำตอบใน O(N + M) แทนที่จะเป็น O(N·M) แบบ brute force
3.6 Sliding Window
Sliding Window เป็นเทคนิคที่ใช้กับ array/string เพื่อลดความซับซ้อน จาก O(n²) เป็น O(n) ในปัญหาที่เกี่ยวกับ "ช่วงย่อย" (subarray/substring) หลักการคือรักษา "หน้าต่าง" ที่ขยาย/หด โดยใช้สอง pointer ซ้าย-ขวา เมื่อขยายขวาเพิ่ม element ใหม่เข้ามา และหดซ้ายเมื่อต้องการตัด element ออก
ตัวอย่าง: ผลรวมสูงสุดใน subarray ขนาด k
// หาผลรวมสูงสุดของ subarray ที่มีขนาด k พอดี
int max_sum_k(const std::vector<int>& arr, int k) {
int n = arr.size();
int cur_sum = 0;
// ผลรวม k ตัวแรก
for (int i = 0; i < k; i++) cur_sum += arr[i];
int max_sum = cur_sum;
// เลื่อนหน้าต่าง
for (int i = k; i < n; i++) {
cur_sum += arr[i] - arr[i - k];
max_sum = std::max(max_sum, cur_sum);
}
return max_sum;
}Time: O(n), Space: O(1)
ตัวอย่าง: Longest Substring Without Repeating
// หา substring ที่ยาวที่สุดที่ไม่มีตัวอักษรซ้ำ
int lengthOfLongestSubstring(std::string s) {
std::vector<int> last_seen(256, -1);
int left = 0, max_len = 0;
for (int right = 0; right < (int)s.size(); right++) {
char c = s[right];
if (last_seen[c] >= left) {
left = last_seen[c] + 1;
}
last_seen[c] = right;
max_len = std::max(max_len, right - left + 1);
}
return max_len;
}LeetCode 3 — Time: O(n), Space: O(1) (alphabet จำกัด)
Sliding window เหมาะกับปัญหาที่ "ผลลัพธ์ของช่วงใหม่" สามารถคำนวณจาก "ผลลัพธ์ของช่วงเก่า + การเปลี่ยนแปลง" ได้ หากไม่ใช่ อาจต้องพิจารณา data structure อื่น เช่น monotonic deque หรือ segment tree
3.7 Two Pointers
Two Pointers คือเทคนิคที่ใช้ pointer สองตัวเดินผ่าน array โดย pointer ทั้งสองอาจเดินไปทางเดียวกัน (fast-slow) หรือสวนกัน (left-right) เทคนิคนี้ลดความซับซ้อนจาก O(n²) เป็น O(n) ในหลายปัญหา โดยเฉพาะปัญหาที่เกี่ยวกับ array ที่เรียงแล้ว
ตัวอย่าง: Two Sum บน sorted array
// หาสองตำแหน่งที่ผลรวม = target ใน array ที่เรียงแล้ว
std::pair<int,int> two_sum_sorted(
const std::vector<int>& arr, int target) {
int l = 0, r = arr.size() - 1;
while (l < r) {
int sum = arr[l] + arr[r];
if (sum == target) return {l, r};
if (sum < target) l++;
else r--;
}
return {-1, -1}; // ไม่พบ
}ตัวอย่าง: Remove Duplicates in-place
// ลบตัวซ้ำใน array ที่เรียงแล้ว ส่งคืนความยาวใหม่
int removeDuplicates(std::vector<int>& nums) {
if (nums.empty()) return 0;
int slow = 0;
for (int fast = 1; fast < (int)nums.size(); fast++) {
if (nums[fast] != nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}LeetCode 26 — slow ชี้ตำแหน่งสุดท้ายของ array ที่ไม่ซ้ำ
ตัวอย่าง: 3Sum
ปัญหา 3Sum: หา triple (a, b, c) ที่ a + b + c = 0 ใน array ที่อาจไม่เรียง วิธีแก่คือ sort ก่อน แล้ว fix a ตัวแรก จากนั้นใช้ two pointers หา b และ c
std::vector<std::vector<int>> threeSum(std::vector<int>& nums) {
std::vector<std::vector<int>> ans;
std::sort(nums.begin(), nums.end());
int n = nums.size();
for (int i = 0; i < n - 2; i++) {
if (i > 0 && nums[i] == nums[i - 1]) continue; // ข้ามตัวซ้ำ
int l = i + 1, r = n - 1;
while (l < r) {
int sum = nums[i] + nums[l] + nums[r];
if (sum == 0) {
ans.push_back({nums[i], nums[l], nums[r]});
while (l < r && nums[l] == nums[l+1]) l++;
while (l < r && nums[r] == nums[r-1]) r--;
l++; r--;
} else if (sum < 0) l++;
else r--;
}
}
return ans;
}LeetCode 15 — Time: O(n²), Space: O(1) (ไม่นับ output)
3.8 String Matching: KMP
ปัญหา string matching คือการหาว่า pattern P ปรากฏใน text T ที่ตำแหน่งใดบ้าง วิธี naive คือลองทุกตำแหน่งเริ่มต้นและเปรียบเทียบทีละตัว ใช้เวลา O(|T|·|P|) ในกรณี worst case แต่ด้วย KMP (Knuth-Morris-Pratt) เราสามารถลดเหลือ O(|T| + |P|)
เมื่อเกิด mismatch ที่ตำแหน่ง i ของ T และ j ของ P เราไม่จำเป็นต้องย้อนกลับไปเริ่มใหม่ที่ตำแหน่งเดิมใน T แต่ใช้ "failure function" (หรือ prefix function) ที่ precompute ไว้ เพื่อกระโดดไปตำแหน่งที่เหมาะสมใน P โดยใช้ประโยชน์จากการที่เราทราบว่าส่วนใดของ P ที่ match แล้ว มีความเป็น "prefix ของตัวเอง" อยู่กี่ตัว
ขั้นที่ 1: สร้าง Failure Function
// pi[i] = ความยาวที่ยาวที่สุดของ prefix ของ P ที่เท่ากับ suffix ของ P[0..i]
std::vector<int> build_failure(const std::string& P) {
int m = P.length();
std::vector<int> pi(m, 0);
for (int i = 1; i < m; i++) {
int j = pi[i - 1];
while (j > 0 && P[i] != P[j]) j = pi[j - 1];
if (P[i] == P[j]) j++;
pi[i] = j;
}
return pi;
}ขั้นที่ 2: ค้นหา Pattern
// คืนตำแหน่งเริ่มต้นทั้งหมดที่ P ปรากฏใน T
std::vector<int> kmp_search(
const std::string& T, const std::string& P) {
std::vector<int> result;
if (P.empty()) return result;
auto pi = build_failure(P);
int n = T.length(), m = P.length();
int j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && T[i] != P[j]) j = pi[j - 1];
if (T[i] == P[j]) j++;
if (j == m) {
result.push_back(i - m + 1);
j = pi[j - 1];
}
}
return result;
}KMP String Matching — Time: O(n + m), Space: O(m)
P = "ababaca", T = "abababacaba"
pi = [0,0,1,2,3,0,1]
เมื่อเปรียบเทียบแล้วเจอ mismatch ที่ P[4] != T[6] เราใช้ pi[3]=2 ทำให้ jump ไป P[2] ทันที ไม่ต้องย้อน T
นอกจาก KMP ยังมี Rabin-Karp ที่ใช้ rolling hash (เฉลี่ย O(n+m)) และ Boyer-Moore ที่เร็วในกรณีทั่วไป สำหรับ multi-pattern matching ใช้ Aho-Corasick ซึ่งใช้ Trie เป็นโครงสร้างพื้นฐาน (ดูบทที่ 11)
Linked List
4.1 แนวคิดของ Linked List
Linked List เป็นโครงสร้างข้อมูลเชิงเส้นที่เก็บข้อมูลในรูปแบบ "node" แต่ละ node ประกอบด้วยข้อมูล (data) และ pointer ชี้ไปยัง node ถัดไป ไม่เหมือน array ที่เก็บข้อมูลต่อเนื่องกันในหน่วยความจำ linked list แต่ละ node อาจอยู่ที่ตำแหน่งใดก็ได้ใน heap ทำให้การเพิ่ม/ลบที่ตำแหน่งใด ๆ ทำได้ใน O(1) ถ้าเรามี pointer ชี้ไปยังตำแหน่งนั้นอยู่แล้ว
Linked List คือโครงสร้างข้อมูลเชิงเส้น ที่เก็บข้อมูลใน node แยกกัน แต่ละ node มี data และ pointer ชี้ไป node ถัดไป การเข้าถึงแบบสุ่ม (random access) ไม่ได้ ต้องไล่จาก head
Singly Linked List Node
struct Node {
int data;
Node* next;
Node(int v) : data(v), next(nullptr) {}
};
Node* head = nullptr;
head = new Node(10);
head->next = new Node(20);
head->next->next = new Node(30);
// 10 → 20 → 30 → nullptr
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| เข้าถึง index i (access) | O(n) | O(1) | ต้องไล่จาก head |
| ค้นหาค่า (search) | O(n) | O(1) | ไม่มีวิธีเร็วกว่านี้ |
| แทรกที่ head | O(1) | O(1) | เพียงแก้ pointer ของ head |
| แทรกที่ท้าย (ถ้ามี tail) | O(1) | O(1) | ต้องเก็บ tail pointer |
| แทรกกลาง (ถ้ามี pointer) | O(1) | O(1) | แก้ pointer 2 ตัว |
| ลบ node (ถ้ามี pointer) | O(1)* | O(1) | *Singly ต้องหาก่อนหน้า ยกเว้นมี next+prev |
สรุป Time / Space Complexity
Linked List มี cache locality ที่แย่กว่า array มาก เนื่องจาก node กระจายอยู่ทั่ว heap ในงานจริง การ traverse linked list อาจช้ากว่า array หลายเท่า ถึงแม้ Big-O จะเท่ากัน นี่คือเหตุผลที่ std::vector มักเป็นที่นิยมกว่า std::list ใน C++ สำหรับ use case ทั่วไป
4.2 Doubly Linked List
Singly Linked List มีข้อจำกัดคือ ถ้าเรามี pointer ไปยัง node หนึ่ง เราไม่สามารถย้อนกลับไปดู node ก่อนหน้าได้โดยตรง Doubly Linked List แก้ปัญหานี้โดยให้แต่ละ node มี pointer สองตัว คือ next และ prev ทำให้สามารถเดินทั้งสองทิศทาง และลบ node ที่กำลังชี้อยู่ได้ใน O(1)
struct DNode {
int data;
DNode *prev, *next;
DNode(int v) : data(v), prev(nullptr), next(nullptr) {}
};
DNode* head = new DNode(10);
DNode* b = new DNode(20);
head->next = b;
b->prev = head;
// head ⇄ b ⇄ nullptr
การลบ Node (ที่มี pointer อยู่แล้ว)
void delete_node(DNode* node) {
if (node->prev) node->prev->next = node->next;
if (node->next) node->next->prev = node->prev;
delete node;
}
// ใช้เวลา O(1) เพราะเราทราบ prev และ next โดยตรงเพื่อให้โค้ดสะอาดและไม่ต้องเช็ค null มักใช้ "sentinel" node ที่ head และ tail เชื่อมกันเป็นวงกลม list ว่าง = sentinel->next == sentinel การ insert/delete ไม่ต้องเช็คกรณีพิเศษ ทำได้ในโค้ดเดียวทุกกรณี
std::list ใน C++ STL
#include <list>
std::list<int> lst = {1, 2, 3};
lst.push_back(4); // {1,2,3,4}
lst.push_front(0); // {0,1,2,3,4}
lst.pop_back(); // {0,1,2,3}
// แทรกก่อน iterator
auto it = lst.begin();
std::advance(it, 2);
lst.insert(it, 99); // {0,1,99,2,3}
// ลบที่ iterator
lst.erase(it); // ลบ "2"
4.3 การ Traverse และการ Reverse
การ traverse linked list ทำได้โดยเริ่มจาก head แล้วตามด้วย next pointer จนถึง nullptr การ reverse linked list เป็นโจทย์คลาสสิกที่ทดสอบความเข้าใจ pointer ทำได้ทั้งแบบ iterative และ recursive
Reverse แบบ Iterative
Node* reverse(Node* head) {
Node *prev = nullptr, *cur = head;
while (cur) {
Node* nxt = cur->next; // เก็บ next ไว้ก่อน
cur->next = prev; // สลับทิศ
prev = cur;
cur = nxt;
}
return prev; // head ใหม่
}Time: O(n), Space: O(1)
Reverse แบบ Recursive
Node* reverse_recursive(Node* head) {
if (!head || !head->next) return head;
Node* new_head = reverse_recursive(head->next);
head->next->next = head;
head->next = nullptr;
return new_head;
}Time: O(n), Space: O(n) สำหรับ call stack
หาความยาว
int length(Node* head) {
int n = 0;
while (head) {
n++;
head = head->next;
}
return n;
}4.4 Fast & Slow Pointers (Floyd's Algorithm)
เทคนิค Fast & Slow Pointers ใช้ pointer สองตัว ที่เดินด้วยความเร็วต่างกัน — slow ทีละ 1 node, fast ทีละ 2 node เทคนิคนี้ใช้สำหรับตรวจหา cycle ใน linked list และหาจุดกึ่งกลางของ list โดยไม่ต้องนับความยาวก่อน
ถ้ามี cycle, fast จะวนอยู่ใน cycle slow เข้ามาใน cycle ภายหลัง เมื่อทั้งคู่อยู่ใน cycle ระยะห่างระหว่างสอง pointer จะลดลงทีละ 1 ในแต่ละรอบ (เพราะ fast เร็วกว่า slow 1 node ต่อรอบ) ดังนั้นในที่สุดจะต้องเจอกัน ภายในเวลา O(n)
Cycle Detection (Floyd's Tortoise & Hare)
bool hasCycle(Node* head) {
Node *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}LeetCode 141 — Time: O(n), Space: O(1)
หาจุดเริ่มต้นของ Cycle
หลังจากพบจุดที่ slow == fast ให้เลื่อน slow กลับไปที่ head จากนั้นเคลื่อนทั้ง slow และ fast ทีละ 1 node จุดที่ slow == fast อีกครั้งคือจุดเริ่มต้นของ cycle
Node* detectCycleStart(Node* head) {
Node *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
// เจอจุดเจอกัน ตอนนี้เลื่อน slow กลับ head
slow = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
}
return nullptr; // ไม่มี cycle
}LeetCode 142 — ทำไมทำแบบนี้ได้? คณิตศาสตร์: ระยะ head→cycle_start = ระยะ meet→cycle_start
หา Node ตรงกลาง
// คืน node ตรงกลาง (ถ้า length คู่ คืนตัวที่สอง)
Node* middleNode(Node* head) {
Node *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}เทคนิค fast-slow นี้ใช้ในอัลกอริทึมหลายตัว เช่น Merge Sort บน Linked List (แบ่งครึ่งด้วย fast-slow แล้ว sort แต่ละครึ่ง + merge) และ ตรวจ Palindrome Linked List (หาครึ่งหลัง, reverse, เปรียบเทียบ)
4.5 การ Merge และการจัดเรียง
การ merge สอง linked list ที่เรียงแล้วเป็น operation พื้นฐาน ที่ใช้ใน merge sort บน linked list การ implement ถูกต้องจะทำให้ได้ list ผลลัพธ์ที่เรียงและใช้พื้นที่เพิ่มเพียง O(1)
Merge สอง Sorted List
Node* merge(Node* a, Node* b) {
Node dummy(0); // sentinel
Node* tail = &dummy;
while (a && b) {
if (a->data <= b->data) {
tail->next = a;
a = a->next;
} else {
tail->next = b;
b = b->next;
}
tail = tail->next;
}
tail->next = a ? a : b;
return dummy.next;
}LeetCode 21 — Time: O(n+m), Space: O(1)
Merge Sort บน Linked List
Node* sortList(Node* head) {
if (!head || !head->next) return head;
// หา mid ด้วย fast-slow
Node *slow = head, *fast = head->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
Node* mid = slow->next;
slow->next = nullptr; // ตัดครึ่ง
Node* left = sortList(head);
Node* right = sortList(mid);
return merge(left, right);
}LeetCode 148 — Time: O(n log n), Space: O(log n) สำหรับ call stack
4.6 Circular Linked List
Circular Linked List คือ linked list ที่ node สุดท้ายชี้กลับไปยัง node แรก แทนที่จะชี้ nullptr ทำให้สามารถวนซ้ำได้ไม่รู้จบ เหมาะกับปัญหาที่ต้องวน loop ตลอด เช่น round-robin scheduler หรือเกมที่ผู้เล่นผลัดกันเล่น
struct Node {
int data;
Node* next;
Node(int v) : data(v), next(nullptr) {}
};
Node* make_circular(std::vector<int>& vals) {
if (vals.empty()) return nullptr;
Node* head = new Node(vals[0]);
Node* cur = head;
for (size_t i = 1; i < vals.size(); i++) {
cur->next = new Node(vals[i]);
cur = cur->next;
}
cur->next = head; // ปิดวง
return head;
}มี n คนยืนเป็นวงกลม เริ่มนับจากคนที่ 1 ทุก ๆ k คน คนที่ถูกนับจะถูกตัดออก ทำซ้ำจนเหลือคนสุดท้าย จงหาตำแหน่งของผู้รอด
ปัญหานี้เหมาะกับ circular linked list เพราะต้องวน loop ตลอด และตัดทีละคน อย่างไรก็ตาม มีสูตรคณิตศาสตร์ที่ให้คำตอบใน O(n) โดยไม่ต้องจำลองจริง ๆ
// Josephus แบบสูตร O(n)
int josephus(int n, int k) {
int res = 0; // ฐาน: n=1 ตอบ 0 (index)
for (int i = 2; i <= n; i++) {
res = (res + k) % i;
}
return res + 1; // แปลงเป็น 1-indexed
}Stack และ Queue
5.1 Stack (LIFO)
Stack เป็น ADT ที่ทำงานตามหลัก LIFO (Last In, First Out) ข้อมูลที่เพิ่มเข้าล่าสุดจะถูกนำออกก่อนเสมอ เหมือนกองหนังสือที่เราวางซ้อนกัน หนังสือที่วางล่าสุดอยู่บนสุด และจะหยิบออกก่อน stack มี operation หลักสองตัวคือ push (เพิ่ม) และ pop (นำออก) รวมถึง top (ดูข้อมูลบนสุด)
push(x): เพิ่ม x เป็น element บนสุด
pop(): นำ element บนสุดออก
top() / peek(): ดูค่า element บนสุดโดยไม่นำออก
empty(): ตรวจว่า stack ว่างหรือไม่
size(): นับจำนวน element
ทุก operation เป็น O(1)
การใช้ std::stack
#include <stack>
std::stack<int> st;
st.push(10);
st.push(20);
st.push(30);
cout << st.top(); // 30
st.pop(); // ลบ 30
cout << st.top(); // 20
cout << st.size(); // 2
cout << st.empty(); // false (0)
std::stack ใน C++ เป็น "container adaptor" ที่ใช้ std::deque เป็น container เริ่มต้น แต่สามารถเปลี่ยนเป็น std::vector หรือ std::list ได้ ด้วย std::stack<int, std::vector<int>>
5.2 การประยุกต์ใช้ Stack
Stack มี use case มากมายในวิทยาการคอมพิวเตอร์ ตั้งแต่การจัดการ function call ในตัวภาษา ไปจนถึงอัลกอริทึม parsing และ backtrack ความสามารถในการ "จดจำ" สถานะก่อนหน้าทำให้ stack เหมาะกับปัญหาที่มีโครงสร้างซ้อนกัน
1) ตรวจวงเล็บให้ถูกต้อง
bool isValidParentheses(std::string s) {
std::stack<char> st;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
st.push(c);
} else {
if (st.empty()) return false;
char t = st.top(); st.pop();
if ((c == ')' && t != '(') ||
(c == ']' && t != '[') ||
(c == '}' && t != '{')) return false;
}
}
return st.empty();
}LeetCode 20 — Time: O(n)
2) Evaluate Postfix Expression
// คำนวณ postfix เช่น "3 4 + 5 *" = (3+4)*5 = 35
int evalPostfix(std::vector<std::string>& tokens) {
std::stack<int> st;
for (auto& t : tokens) {
if (t == "+" || t == "-" || t == "*" || t == "/") {
int b = st.top(); st.pop();
int a = st.top(); st.pop();
if (t == "+") st.push(a + b);
else if (t == "-") st.push(a - b);
else if (t == "*") st.push(a * b);
else st.push(a / b);
} else {
st.push(std::stoi(t));
}
}
return st.top();
}3) แปลง Infix เป็น Postfix (Shunting Yard)
อัลกอริทึม Shunting Yard ของ Dijkstra แปลงนิพจน์ infix (เช่น 3 + 4 * 2) เป็น postfix (3 4 2 * +) โดยใช้ stack เก็บ operator และจัดลำดับ precedence
4) Function Call Stack
ตัวภาษาใช้ stack เก็บ call frame ทุกครั้งที่เรียกฟังก์ชัน เก็บ local variables, return address, และ parameters recursion ทำงานได้ก็เพราะ stack นี้ ถ้า recursion ลึกเกินไป จะเกิด Stack Overflow
เมื่อ recursion ลึกเกินจนเกิด stack overflow เราสามารถแปลงเป็น "explicit stack" ได้ โดยเก็บ state ใน std::stack แทน call stack ของภาษา เทคนิคนี้ใช้บ่อยใน tree traversal บนต้นไม้ที่ลึกมาก
5.3 Monotonic Stack
Monotonic Stack เป็นเทคนิคที่รักษาให้ stack มีค่าเรียงลำดับ (increasing หรือ decreasing) ตลอดเวลา เทคนิคนี้ใช้แก้ปัญหา "หา element ถัดไปที่ใหญ่กว่า/เล็กกว่า" ในเวลา O(n) แทนที่จะเป็น O(n²) แบบ brute force
Next Greater Element
// สำหรับแต่ละ element หา "element ถัดไปที่ใหญ่กว่า"
// ถ้าไม่มี ให้ใส่ -1
std::vector<int> nextGreater(std::vector<int>& nums) {
int n = nums.size();
std::vector<int> ans(n, -1);
std::stack<int> st; // เก็บ index ที่ยังไม่มีคำตอบ
for (int i = 0; i < n; i++) {
while (!st.empty() && nums[st.top()] < nums[i]) {
ans[st.top()] = nums[i];
st.pop();
}
st.push(i);
}
return ans;
}
// input: [2, 1, 4, 3, 5]
// output: [4, 4, 5, 5, -1]Time: O(n) — แต่ละ element push/pop ไม่เกิน 1 ครั้ง
Largest Rectangle in Histogram
ปัญหาคลาสสิก: มี histogram ที่แต่ละแท่งมีความกว้าง 1 และความสูงตาม array heights[i] จงหาพื้นที่สี่เหลี่ยมที่ใหญ่ที่สุดที่สามารถวาดใน histogram ได้
int largestRectangleArea(std::vector<int>& heights) {
heights.push_back(0); // sentinel ปิดท้าย
std::stack<int> st; // index ที่ height เรียง increasing
int max_area = 0;
for (int i = 0; i < (int)heights.size(); i++) {
while (!st.empty() && heights[st.top()] > heights[i]) {
int h = heights[st.top()]; st.pop();
int w = st.empty() ? i : i - st.top() - 1;
max_area = std::max(max_area, h * w);
}
st.push(i);
}
heights.pop_back();
return max_area;
}LeetCode 84 — Time: O(n)
Increasing Stack: หา next greater / previous smaller
Decreasing Stack: หา next smaller / previous greater
กฎทั่วไป: เมื่อมี element ใหม่มา "ทำลาย" monotonicity ให้ pop จนกว่าจะคง monotonic ได้ใหม่ การ pop แต่ละครั้งให้คำตอบสำหรับ element ที่ pop ออก
5.4 Queue (FIFO)
Queue เป็น ADT ที่ทำงานตามหลัก FIFO (First In, First Out) ข้อมูลที่เพิ่มเข้าก่อนจะถูกนำออกก่อน เหมือนคิวซื้อของ — คนที่มาก่อนได้รับบริการก่อน queue มี operation หลักคือ enqueue (เพิ่มท้าย) และ dequeue (นำหน้าออก)
push/enqueue(x): เพิ่ม x ที่ท้าย queue
pop/dequeue(): นำ element หน้าสุดออก
front(): ดูค่าหน้าสุดโดยไม่นำออก
back(): ดูค่าท้ายสุด
empty(), size(): ตรวจว่าง / นับจำนวน
ทุก operation เป็น O(1)
การใช้ std::queue
#include <queue>
std::queue<int> q;
q.push(10);
q.push(20);
q.push(30);
cout << q.front(); // 10
cout << q.back(); // 30
q.pop(); // ลบ 10
cout << q.front(); // 20
cout << q.size(); // 2
การประยุกต์
- BFS: Breadth-First Search บนกราฟใช้ queue เก็บ node ที่จะไปต่อ
- Level-order traversal: ไล่ tree ทีละชั้นใช้ queue
- Scheduler: ระบบปฏิบัติการใช้ queue จัดการ process
- Producer-Consumer: ส่งข้อมูลระหว่าง thread
- Cache eviction (FIFO): ลบข้อมูลเก่าที่เข้าก่อน
std::queue ไม่สนับสนุน iterator หรือ index ทำให้ไม่สามารถวนลูปเพื่ออ่านค่าได้ ถ้าต้องการเข้าถึงข้อมูลแบบนั้น ให้ใช้ std::deque แทน หรือเปลี่ยนไปใช้ std::list
5.5 Deque (Double-Ended Queue)
Deque (อ่านว่า "deck") คือ queue ที่เพิ่ม/ลบได้ทั้งสองด้าน ทำให้สามารถทำหน้าที่เป็น stack หรือ queue ได้พร้อมกัน ใน C++ std::deque เป็น implementation ที่รองรับ operation ทุกตัวใน O(1) และเข้าถึงด้วย index ได้
#include <deque>
std::deque<int> dq;
dq.push_back(10); // [10]
dq.push_back(20); // [10, 20]
dq.push_front(5); // [5, 10, 20]
dq.push_front(1); // [1, 5, 10, 20]
cout << dq[2]; // 10 (random access ได้)
cout << dq.front(); // 1
cout << dq.back(); // 20
dq.pop_front(); // ลบ 1 → [5, 10, 20]
dq.pop_back(); // ลบ 20 → [5, 10]
std::deque ภายในใช้ "segmented array" คือเก็บหลาย ๆ chunk ขนาดคงที่ และมี array ของ pointer ชี้ไปแต่ละ chunk ทำให้การ push/pop สองด้านทำได้ O(1) และยังเข้าถึงด้วย index ได้ O(1) แต่ cache locality ไม่ดีเท่า std::vector
Monotonic Deque สำหรับ Sliding Window Max
// หาค่ามากสุดในทุก subarray ขนาด k
std::vector<int> maxSlidingWindow(
std::vector<int>& nums, int k) {
std::vector<int> ans;
std::deque<int> dq; // เก็บ index, ค่าลดลง
for (int i = 0; i < (int)nums.size(); i++) {
// ลบ index ที่ออกนอกหน้าต่าง
while (!dq.empty() && dq.front() <= i - k) dq.pop_front();
// ลบ index ที่ค่าน้อยกว่า nums[i] (ไม่มีทางเป็น max แล้ว)
while (!dq.empty() && nums[dq.back()] <= nums[i]) dq.pop_back();
dq.push_back(i);
if (i >= k - 1) ans.push_back(nums[dq.front()]);
}
return ans;
}LeetCode 239 — Time: O(n) เพราะแต่ละ index push/pop ไม่เกิน 1 ครั้ง
5.6 Priority Queue พื้นฐาน
Priority Queue เป็น ADT ที่คล้าย queue แต่ element แต่ละตัวมี "priority" เมื่อ dequeue จะได้ element ที่มี priority สูงสุด (หรือต่ำสุด) ก่อน ไม่ใช่ตามลำดับเวลาที่เข้ามา ในทางปฏิบัติ priority queue มักถูก implement ด้วย "binary heap" ที่รองรับทั้ง push และ pop ใน O(log n)
push(x): เพิ่ม x
top(): ดูค่าที่มี priority สูงสุด
pop(): นำค่าที่มี priority สูงสุดออก
empty(), size()
ใน C++ std::priority_queue เป็น max-heap โดย default ดูรายละเอียด heap ในบทที่ 8
การใช้ std::priority_queue
#include <queue>
// Max-heap (default)
std::priority_queue<int> max_pq;
max_pq.push(3);
max_pq.push(1);
max_pq.push(5);
max_pq.push(2);
while (!max_pq.empty()) {
cout << max_pq.top() << " "; // 5 3 2 1
max_pq.pop();
}
// Min-heap
std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq;
min_pq.push(3);
min_pq.push(1);
// min_pq.top() == 1
Custom Comparator
// ใช้กับคู่ (priority, value)
using pii = std::pair<int,int>; // (distance, node)
std::priority_queue<pii, std::vector<pii>, std::greater<pii>> pq;
pq.push({10, 1});
pq.push({3, 2});
pq.push({7, 3});
// pop ได้ลำดับ: {3,2}, {7,3}, {10,1}
เหมาะกับ Dijkstra's Algorithm
ในการแข่งขัน ใช้ priority_queue แทน self-implemented heap เพราะเร็วพอและมี bug น้อยกว่า ระวัง: priority_queue ไม่รองรับ "decrease-key" ใน Dijkstra จึงต้อง push ใหม่แทน (เก่าจะถูกข้ามไปเมื่อ pop)
Hash Table
6.1 แนวคิดของ Hash Table
Hash Table เป็นโครงสร้างข้อมูลที่ใช้ "hash function" แมป key ไปยัง index ใน array ทำให้สามารถเข้าถึงข้อมูล ได้ในเวลาเฉลี่ย O(1) เป็นโครงสร้างที่ทรงพลังมาก ใช้ในแทบทุกภาษาโปรแกรม เช่น dict ใน Python, HashMap ใน Java, unordered_map ใน C++
Hash Table คือโครงสร้างข้อมูลที่เก็บคู่ key-value โดยใช้ hash function แปลง key เป็น index ของ array เมื่อเกิด "collision" (หลาย key ได้ index เดียวกัน) จะใช้วิธี "chaining" หรือ "open addressing" จัดการ
องค์ประกอบหลัก
- Bucket Array: array ที่เก็บข้อมูลจริง ขนาด m
- Hash Function: แปลง key → index ใน [0, m-1]
- Collision Resolution: วิธีจัดการเมื่อหลาย key ชนกัน
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Insert (insert/add) | O(1) เฉลี่ย, O(n) worst | O(n) | ต่อ key |
| Search (find) | O(1) เฉลี่ย, O(n) worst | O(n) | |
| Delete (erase) | O(1) เฉลี่ย, O(n) worst | O(n) | |
| Iterate | O(n) | O(n) | ลำดับไม่ guarantee |
สรุป Time / Space Complexity
worst case เกิดเมื่อ hash function แย่จน key ทั้งหมดไปอยู่ bucket เดียวกัน ในงานจริงมักใช้ hash function ที่กระจายดี และเลือกขนาดตารางเป็นจำนวนเฉพาะ ใน competitive programming โจทย์บางข้อออกแบบมาให้เกิด worst case ให้ระวังและพิจารณาใช้ tree-based map แทน
6.2 Hash Function
Hash Function เป็นหัวใจของ hash table ฟังก์ชันที่ดีต้อง (1) กระจาย key อย่างสม่ำเสมอ (2) คำนวณเร็ว (3) กำหนด (deterministic) — key เดียวกันต้องได้ค่าเดียวกันเสมอ ในทางปฏิบัติมักใช้ฟังก์ชันที่ผ่านการทดสอบ เช่น FNV, MurmurHash, CityHash
Hash สำหรับจำนวนเต็ม
// แบบง่าย: modulo ด้วยขนาดตาราง
int hash_int(int key, int m) {
return key % m; // m ควรเป็นจำนวนเฉพาะ
}
Hash สำหรับสตริง (Polynomial Rolling)
// h(s) = (s[0]*p^(k-1) + s[1]*p^(k-2) + ... + s[k-1]) mod m
long long hash_string(const std::string& s) {
const long long p = 31;
const long long m = 1e9 + 9;
long long h = 0;
long long p_pow = 1;
for (char c : s) {
h = (h + (c - 'a' + 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return h;
}ใช้ใน Rabin-Karp และ string hashing
สำหรับ string ที่มีเฉพาะตัวอักษรเล็ก ใช้ p = 31 สำหรับทั้งเล็ก-ใหญ่ ใช้ p = 53 ใช้ m เป็นจำนวนเฉพาะใหญ่ เช่น 10⁹+9 หรือ 10⁹+7 เพื่อลดโอกาส collision
6.3 Collision Resolution
เมื่อสอง key ให้ hash เดียวกัน เรียกว่า "collision" เป็นเรื่องปกติที่ต้องเกิดเพราะจำนวน key ที่เป็นไปได้มากกว่าขนาดตารางมาก มีสองวิธีหลักในการจัดการ collision
1) Separate Chaining
แต่ละ bucket เก็บ linked list ของคู่ key-value ที่ hash มาที่ bucket นั้น เมื่อค้นหา ให้ hash key ไป bucket แล้วไล่ list เพื่อหา key ที่ตรงกัน วิธีนี้ง่ายและ robust แต่มี overhead ของ pointer
struct Node {
int key, value;
Node* next;
};
Node* table[TABLE_SIZE];
void insert(int key, int value) {
int idx = hash(key);
Node* n = new Node{key, value, table[idx]};
table[idx] = n;
}
2) Open Addressing
ทุก element เก็บใน bucket array โดยตรง เมื่อเกิด collision ให้หา bucket อื่นตาม "probe sequence" เช่น Linear Probing: ลอง idx+1, idx+2, ... หรือ Double Hashing: ใช้ hash function ที่สองกำหนด step
// Linear Probing
int find_slot(int key) {
int i = hash(key);
while (table[i].occupied && table[i].key != key) {
i = (i + 1) % TABLE_SIZE;
}
return i;
}
| วิธี | ข้อดี | ข้อเสีย |
|---|---|---|
| Chaining | ง่าย ไม่ต้อง resize บ่อย, ทน load factor สูง | ใช้ pointer overhead, cache แย่ |
| Linear Probing | cache locality ดี, ไม่มี pointer | clustering, ต้อง resize ตอน load factor > 0.7 |
| Double Hashing | ไม่เกิด clustering | คำนวณ hash สองครั้ง, cache แย่กว่า linear |
เปรียบเทียบวิธีจัดการ collision
6.4 Load Factor และ Rehashing
Load Factor (α) คืออัตราส่วน จำนวน element / ขนาดตาราง เป็นตัวชี้วัดว่า hash table "เต็ม" แค่ไหน ถ้า α สูงเกินไป collision จะมาก ทำให้ operation ช้าลง โดยทั่วไปกำหนด threshold (เช่น 0.75) เมื่อ α เกิน threshold จะทำ "rehash" — ขยายตารางและย้ายข้อมูลทั้งหมด
ขั้นตอน: (1) allocate array ใหม่ที่ใหญ่กว่า (มัก 2 เท่า) (2) คำนวณ hash ใหม่สำหรับทุก element (3) ย้ายไปยังตำแหน่งใหม่ (4) ลบ array เก่า ใช้เวลา O(n) ต่อครั้ง แต่ amortized ตลอด n operation คือ O(1) ต่อ operation
การเลือกขนาดตารางใหม่มักเป็น 2 เท่าของเดิม แต่ถ้าใช้ modulo hash (h = key % m) ควรเลือก m เป็นจำนวนเฉพาะเพื่อกระจาย key ได้ดีกว่า ดังนั้นบาง implementation เลือกจำนวนเฉพาะถัดไปแทน 2 เท่า
std::unordered_map ของ C++ จะ rehash อัตโนมัติ เมื่อ load factor เกิน max_load_factor() ซึ่ง default คือ 1.0 สามารถตั้งเองได้ด้วย um.max_load_factor(0.5) ค่าต่ำ = เร็วขึ้นแต่เปลือง memory, ค่าสูง = ประหยัด memory แต่ช้าลง
6.5 std::unordered_map และ std::unordered_set
C++ STL มี hash table สองตัวหลัก: std::unordered_map เก็บคู่ key-value และ std::unordered_set เก็บเฉพาะ key ทั้งคู่รองรับ insert/search/erase ใน O(1) เฉลี่ย
unordered_map
#include <unordered_map>
std::unordered_map<std::string, int> mp;
mp["apple"] = 50;
mp["banana"] = 30;
mp.insert({"orange", 40});
// ค้นหา
if (mp.count("apple")) {
cout << mp["apple"]; // 50
}
// ลูปทุก element (ลำดับไม่ guarantee)
for (auto& [k, v] : mp) {
cout << k << " = " << v << "\n";
}
// ลบ
mp.erase("banana");
unordered_set
#include <unordered_set>
std::unordered_set<int> s;
s.insert(10);
s.insert(20);
s.insert(10); // ไม่เพิ่ม (มีอยู่แล้ว)
cout << s.count(10); // 1
cout << s.size(); // 2
s.erase(10);
ในการแข่งขัน บางครั้งผู้ออกโจทย์สร้าง test case ที่ทำให้ unordered_map เกิด collision จนช้าลงเป็น O(n²) วิธีแก้: (1) ใช้ std::map แทน (O(log n) แต่ stable) (2) เขียน custom hash ที่สุ่ม (เพิ่ม salt ตอน runtime)
// Custom hash สำหรับ int ที่ต้าน collision
struct SafeHash {
size_t operator()(int x) const {
// splitmix64
uint64_t z = (uint64_t)x + 0x9e3779b97f4a7c15ULL;
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL;
z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL;
return z ^ (z >> 31);
}
};
std::unordered_map<int, int, SafeHash> safe_map;
6.6 การประยุกต์ใช้ Hash Table
Hash table มี use case มากมายเพราะให้ O(1) lookup ทำให้เป็นโครงสร้างพื้นฐานของอัลกอริทึมหลายตัว
1) Two Sum
// หาสองตัวใน array ที่รวมกันได้ target
std::vector<int> twoSum(std::vector<int>& nums, int target) {
std::unordered_map<int, int> seen; // value → index
for (int i = 0; i < (int)nums.size(); i++) {
int need = target - nums[i];
if (seen.count(need)) {
return {seen[need], i};
}
seen[nums[i]] = i;
}
return {};
}LeetCode 1 — Time: O(n)
2) Frequency Counting
// นับความถี่ของแต่ละ element
std::unordered_map<int, int> freq;
for (int x : arr) {
freq[x]++;
}
// หา element ที่พบบ่อยที่สุด
int best = -1, best_count = 0;
for (auto& [k, v] : freq) {
if (v > best_count) {
best = k;
best_count = v;
}
}
3) Memoization
// ใช้ hash เก็บผลลัพธ์ที่คำนวณแล้ว
std::unordered_map<long long, long long> memo;
long long fib(long long n) {
if (n < 2) return n;
if (memo.count(n)) return memo[n];
return memo[n] = fib(n - 1) + fib(n - 2);
}
ลดจาก O(2ⁿ) เป็น O(n)
4) LRU Cache
Least Recently Used Cache ใช้ unordered_map + doubly linked list เพื่อรองรับ get/put ใน O(1) ดูรายละเอียดในบทถัดไป
ใน competitive programming เมื่อต้องเก็บคู่ (a, b) เป็น key ใช้ std::pair หรือ std::array C++ มี std::hash สำหรับ pair อยู่แล้วบางส่วน แต่ถ้าไม่มี ต้องเขียน custom hash เอง
6.7 LRU Cache Implementation
LRU (Least Recently Used) Cache เป็นโครงสร้างที่เก็บข้อมูลจำกัด เมื่อเต็ม จะลบข้อมูลที่ "ไม่ถูกใช้นานที่สุด" ออก เป็นโครงสร้างคลาสสิกที่สัมภาษณ์งานนิยมถาม และเป็นหัวใจของหลายระบบ เช่น browser cache, database buffer
get(key): คืนค่าของ key, และทำให้ key เป็น "recently used"
put(key, value): เพิ่ม/อัปเดตค่า, ถ้าเต็มให้ลบ LRU ออกก่อน
ทั้งคู่ต้องเป็น O(1) เฉลี่ย
การทำให้ get/put เป็น O(1) ต้องใช้สองโครงสร้างร่วมกัน:
- Doubly Linked List เก็บข้อมูลเรียงตาม "ความใหม่" (head = ใหม่สุด, tail = เก่าสุด)
- Hash Map แมป key → node pointer เพื่อ O(1) lookup
Implementation
struct Node {
int key, value;
Node *prev, *next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
class LRUCache {
int cap;
Node *head, *tail; // sentinels
std::unordered_map<int, Node*> mp;
public:
LRUCache(int capacity) : cap(capacity) {
head = new Node(0, 0);
tail = new Node(0, 0);
head->next = tail;
tail->prev = head;
}
void remove(Node* n) {
n->prev->next = n->next;
n->next->prev = n->prev;
}
void add_front(Node* n) {
n->next = head->next;
n->prev = head;
head->next->prev = n;
head->next = n;
}
int get(int key) {
if (!mp.count(key)) return -1;
Node* n = mp[key];
remove(n);
add_front(n);
return n->value;
}
void put(int key, int value) {
if (mp.count(key)) {
Node* n = mp[key];
n->value = value;
remove(n);
add_front(n);
} else {
if ((int)mp.size() >= cap) {
Node* lru = tail->prev;
mp.erase(lru->key);
remove(lru);
delete lru;
}
Node* n = new Node(key, value);
mp[key] = n;
add_front(n);
}
}
};LeetCode 146 — get/put เป็น O(1) เฉลี่ย
Tree และ Binary Search Tree
7.1 แนวคิดของ Tree
Tree เป็นโครงสร้างข้อมูลแบบไม่เชิงเส้น (non-linear) ที่จัดเก็บข้อมูลในรูปแบบลำดับชั้น (hierarchical) ประกอบด้วย "node" ที่เชื่อมด้วย "edge" ไม่มี cycle และเชื่อมโยงกันทั้งหมด เหมาะกับการเก็บข้อมูลที่มีความสัมพันธ์แบบ "บุตร-มารดา" เช่น file system, organization chart, DOM tree
Tree เป็น acyclic connected graph
Root: node บนสุด (ไม่มี parent)
Leaf: node ที่ไม่มี child
Internal node: node ที่มีอย่างน้อย 1 child
Depth ของ node: ระยะจาก root
Height ของ tree: depth มากสุด
Degree ของ node: จำนวน child
ประเภทของ Tree
- Binary Tree: แต่ละ node มี child ≤ 2
- Full Binary Tree: ทุก node มี child 0 หรือ 2
- Complete Binary Tree: ทุกชั้นเต็ม ยกเว้นชั้นสุดท้ายที่เติมจากซ้าย
- Perfect Binary Tree: ทุก leaf อยู่ที่ depth เดียวกัน
- Balanced Binary Tree: left/right subtree สูงต่างกัน ≤ 1 (เช่น AVL, Red-Black)
- N-ary Tree: แต่ละ node มี child ≤ N
- Binary Search Tree (BST): binary tree ที่ left < parent < right
Binary Tree Node
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
คุณสมบัติสำคัญ
- Binary Tree สูง h มีได้สูงสุด 2^h - 1 node
- Complete Binary Tree ที่มี n node สูง ⌊log₂ n⌋
- จำนวน edge = จำนวน node - 1
- ใน binary tree: จำนวน leaf = จำนวน internal node + 1 (สำหรับ full binary tree)
7.2 การ Traverse Tree
การ traverse tree คือการไปเยี่ยมทุก node วิธีหลัก 4 แบบ แบ่งตามลำดับ root กับ child: preorder (root → left → right), inorder (left → root → right), postorder (left → right → root), และ level-order (ทีละชั้น) สามแบบแรกมักเขียนเป็น recursive ได้ง่าย
Recursive Traversals
void preorder(TreeNode* root) {
if (!root) return;
visit(root); // root
preorder(root->left); // left
preorder(root->right); // right
}
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left); // left
visit(root); // root
inorder(root->right); // right
}
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left); // left
postorder(root->right); // right
visit(root); // root
}
สำหรับ BST, inorder traversal จะได้ค่าที่เรียงน้อยไปมากเสมอ เพราะ left < root < right — นี่คือเหตุผลที่ BST มีประโยชน์ สามารถ iterate ตามลำดับได้ O(n)
Level-Order Traversal (BFS)
#include <queue>
#include <vector>
std::vector<std::vector<int>> levelOrder(TreeNode* root) {
std::vector<std::vector<int>> ans;
if (!root) return ans;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int sz = q.size();
std::vector<int> level;
for (int i = 0; i < sz; i++) {
TreeNode* n = q.front(); q.pop();
level.push_back(n->val);
if (n->left) q.push(n->left);
if (n->right) q.push(n->right);
}
ans.push_back(level);
}
return ans;
}LeetCode 102 — Time: O(n)
Iterative Inorder (Morris Traversal)
เราสามารถ traverse tree โดยไม่ใช้ stack หรือ recursion โดยใช้เทคนิค "Morris Traversal" ที่ temporarily แก้ pointer ทำให้ได้ O(1) space แต่โค้ดซับซ้อนกว่า
void inorder_morris(TreeNode* root) {
TreeNode* cur = root;
while (cur) {
if (!cur->left) {
visit(cur);
cur = cur->right;
} else {
// หา predecessor ของ cur
TreeNode* pred = cur->left;
while (pred->right && pred->right != cur) {
pred = pred->right;
}
if (!pred->right) {
pred->right = cur; // สร้าง thread
cur = cur->left;
} else {
pred->right = nullptr; // ลบ thread
visit(cur);
cur = cur->right;
}
}
}
}Time: O(n), Space: O(1)
7.3 Binary Search Tree (BST)
Binary Search Tree (BST) คือ binary tree ที่จัดเก็บค่าแบบ "left subtree < node < right subtree" ทำให้สามารถค้นหา/แทรก/ลบใน O(log n) โดยเฉลี่ย และยังเก็บข้อมูลแบบ "sorted" ทำให้ iterate ตามลำดับได้ เป็นทั้ง binary tree และ sorted structure ในตัวเดียว
สำหรับทุก node N ใน tree:
• ทุก node ใน left subtree ของ N มีค่า < N.val
• ทุก node ใน right subtree ของ N มีค่า > N.val
• left และ right subtree ก็เป็น BST เช่นเดียวกัน
การค้นหาใน BST
TreeNode* search(TreeNode* root, int target) {
while (root) {
if (target == root->val) return root;
root = (target < root->val) ? root->left : root->right;
}
return nullptr; // ไม่พบ
}
Time: O(log n) เฉลี่ย, O(n) worst
การแทรก
TreeNode* insert(TreeNode* root, int val) {
if (!root) return new TreeNode(val);
if (val < root->val) root->left = insert(root->left, val);
else if (val > root->val) root->right = insert(root->right, val);
// ถ้า val == root->val: ไม่แทรก (no duplicate) หรือจัดการตามปัญหา
return root;
}
การลบ (3 กรณี)
การลบ node ใน BST มี 3 กรณี:
- Node ไม่มี child: ลบทิ้งได้เลย
- Node มี child หนึ่งตัว: ย้าย child ขึ้นแทนที่
- Node มี child สองตัว: หา "inorder successor" (node ที่น้อยที่สุดใน right subtree) คัดลอกค่ามา แล้วลบ successor ตัวนั้น
TreeNode* find_min(TreeNode* root) {
while (root->left) root = root->left;
return root;
}
TreeNode* remove(TreeNode* root, int val) {
if (!root) return nullptr;
if (val < root->val) {
root->left = remove(root->left, val);
} else if (val > root->val) {
root->right = remove(root->right, val);
} else {
// พบ node ที่จะลบ
if (!root->left) {
TreeNode* r = root->right;
delete root;
return r;
}
if (!root->right) {
TreeNode* l = root->left;
delete root;
return l;
}
// มี 2 child: หา inorder successor
TreeNode* succ = find_min(root->right);
root->val = succ->val;
root->right = remove(root->right, succ->val);
}
return root;
}| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Search | O(log n) เฉลี่ย | O(n) | O(log n) ถ้า balanced |
| Insert | O(log n) เฉลี่ย | O(n) | |
| Delete | O(log n) เฉลี่ย | O(n) | |
| Inorder (sorted iteration) | O(n) | O(n) | |
| Find min/max | O(log n) เฉลี่ย | O(n) | min = leftmost, max = rightmost |
สรุป Time / Space Complexity
BST ที่ไม่ balanced อาจเสื่อมเป็น linked list เมื่อ insert ข้อมูลที่เรียงแล้ว ทำให้ search กลายเป็น O(n) ดังนั้นในงานจริงมักใช้ balanced BST เช่น AVL หรือ Red-Black Tree (ดูบทที่ 11)
7.4 การตรวจสอบและวัด Tree
มีคำถามพื้นฐานหลายตัวเกี่ยวกับ tree ที่ควรรู้วิธีแก้ เช่น วัด height, นับจำนวน node, ตรวจ balanced, หา diameter ส่วนใหญ่แก้ได้ด้วย recursion อย่างง่าย
วัด Height
int height(TreeNode* root) {
if (!root) return 0;
return 1 + std::max(height(root->left), height(root->right));
}Time: O(n)
ตรวจ Balanced
// คืน height ถ้า balanced, -1 ถ้าไม่ balanced
int check_balanced(TreeNode* root) {
if (!root) return 0;
int lh = check_balanced(root->left);
if (lh == -1) return -1;
int rh = check_balanced(root->right);
if (rh == -1) return -1;
if (std::abs(lh - rh) > 1) return -1;
return 1 + std::max(lh, rh);
}
bool isBalanced(TreeNode* root) {
return check_balanced(root) != -1;
}LeetCode 110 — Time: O(n) เพราะตรวจทุก node ทีเดียว
หา Diameter (เส้นผ่านศูนย์กลาง)
Diameter ของ tree คือจำนวน edge ใน path ที่ยาวที่สุดระหว่าง node สอง node ใด ๆ อาจผ่าน root หรือไม่ก็ได้
int diameter;
int height_for_diameter(TreeNode* root) {
if (!root) return 0;
int lh = height_for_diameter(root->left);
int rh = height_for_diameter(root->right);
diameter = std::max(diameter, lh + rh); // path ผ่าน root
return 1 + std::max(lh, rh);
}
int diameterOfBinaryTree(TreeNode* root) {
diameter = 0;
height_for_diameter(root);
return diameter;
}LeetCode 543 — Time: O(n)
ตรวจ BST Validity
bool isValidBST(TreeNode* root, long long lo = LLONG_MIN, long long hi = LLONG_MAX) {
if (!root) return true;
if (root->val <= lo || root->val >= hi) return false;
return isValidBST(root->left, lo, root->val) &&
isValidBST(root->right, root->val, hi);
}LeetCode 98 — ใช้ range (lo, hi) เพื่อจำกัดค่าที่ถูกต้อง
7.5 Lowest Common Ancestor (LCA)
Lowest Common Ancestor (LCA) ของ node p, q คือ node ที่อยู่ลึกที่สุด ที่เป็นบรรพบุรุษร่วมของทั้งคู่ เป็นปัญหาสำคัญใน tree และมีหลาย algorithm
LCA ใน Binary Tree (Recursive)
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || root == p || root == q) return root;
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
if (left && right) return root; // p, q อยู่คนละข้าง
return left ? left : right;
}LeetCode 236 — Time: O(n)
LCA ใน BST
TreeNode* lca_bst(TreeNode* root, TreeNode* p, TreeNode* q) {
while (root) {
if (root->val > p->val && root->val > q->val) {
root = root->left;
} else if (root->val < p->val && root->val < q->val) {
root = root->right;
} else {
return root; // p, q อยู่คนละข้าง หรือ root = p หรือ q
}
}
return nullptr;
}LeetCode 235 — Time: O(log n) ถ้า balanced
ถ้าต้องหา LCA หลายคู่พร้อมกัน ใช้อัลกอริทึม Tarjan's Offline LCA ที่ตอบทุกคำถามใน O((n + q) α(n)) เกือบ O(n + q) หรือใช้ Binary Lifting (preprocess O(n log n), query O(log n)) สำหรับการ query online
7.6 std::map และ std::set
C++ STL มี std::map และ std::set ซึ่งเป็น balanced BST (implement ด้วย Red-Black Tree) ทำให้ทุก operation เป็น O(log n) ที่ guarantee และยังเก็บข้อมูลเรียงลำดับอยู่เสมอ
std::map
#include <map>
std::map<std::string, int> mp;
mp["apple"] = 50;
mp["banana"] = 30;
mp["cherry"] = 40;
// ค้นหา
auto it = mp.find("apple");
if (it != mp.end()) cout << it->second; // 50
// ลูปตามลำดับ sorted
for (auto& [k, v] : mp) {
cout << k << " = " << v << "\n";
}
// apple = 50, banana = 30, cherry = 40 (เรียงตามตัวอักษร)
// หาค่าแรกที่ >= key
auto it2 = mp.lower_bound("banana");
// หาค่าแรกที่ > key
auto it3 = mp.upper_bound("banana");
std::set
#include <set>
std::set<int> s = {5, 3, 8, 1, 9};
// ภายในเรียงเป็น {1, 3, 5, 8, 9}
cout << *s.begin(); // 1 (น้อยสุด)
cout << *s.rbegin(); // 9 (มากสุด)
// หา "ค่าถัดไปที่มากกว่า x"
auto it = s.upper_bound(5);
if (it != s.end()) cout << *it; // 8
std::map: O(log n) ทุก operation, เก็บข้อมูล sorted, รองรับ lower_bound/upper_bound — ดีเมื่อต้องการลำดับ
std::unordered_map: O(1) เฉลี่ย, ไม่ sorted — ดีเมื่อต้องการความเร็วและไม่สนลำดับ
ในการแข่งขัน ถ้าไม่ต้องการลำดับ ใช้ unordered_map เร็วกว่า ~3-5x
7.7 การประยุกต์ใช้ Tree
Tree มี use case มากมายในโลกจริง
- File System: directory tree บน OS
- DOM: โครงสร้างของหน้าเว็บ
- Abstract Syntax Tree (AST): ตัวแปรภาษาใช้ parse โค้ด
- Decision Tree: โมเดล machine learning
- Huffman Tree: บีบอัดข้อมูล
- Trie: เก็บสตริง (ดูบทที่ 11)
- Segment Tree: range query (ดูบทที่ 11)
ตัวอย่าง: สร้าง Tree จาก Inorder + Preorder
ปัญหาคลาสสิก: ให้ inorder และ preorder traversal ของ binary tree, สร้าง tree กลับคืนมา
std::unordered_map<int, int> in_idx;
int pre_idx;
TreeNode* build(std::vector<int>& pre, int lo, int hi) {
if (lo > hi) return nullptr;
int root_val = pre[pre_idx++];
TreeNode* root = new TreeNode(root_val);
int mid = in_idx[root_val];
root->left = build(pre, lo, mid - 1);
root->right = build(pre, mid + 1, hi);
return root;
}
TreeNode* buildTree(std::vector<int>& pre, std::vector<int>& ino) {
int n = ino.size();
for (int i = 0; i < n; i++) in_idx[ino[i]] = i;
pre_idx = 0;
return build(pre, 0, n - 1);
}LeetCode 105 — Time: O(n)
ตัวอย่าง: Serialize / Deserialize Tree
การแปลง tree เป็น string (เช่น "1,2,#,#,3,4,#,#,5,#,#") เพื่อเก็บหรือส่งผ่านเครือข่าย ใช้ preorder + marker สำหรับ null
หา path จาก root ไป leaf ที่ผลรวม = target — ใช้ DFS ส่งผลรวมสะสมลงไป เมื่อถึง leaf เช็คว่าผลรวมตรงเป้าหมายหรือไม่
Heap และ Priority Queue
8.1 แนวคิดของ Heap
Heap เป็น binary tree แบบ complete ที่มีคุณสมบัติ "heap property" ใน Max-Heap: ค่าของ parent ≥ ค่าของ child ทุกตัว ใน Min-Heap: ค่าของ parent ≤ ค่าของ child ทุกตัว heap มัก implement ด้วย array ทำให้การเข้าถึง parent/child ผ่าน index ทำได้ใน O(1) และมี cache locality ที่ดี
Max-Heap: ทุก node มีค่า ≥ ค่าของ child ทั้งสอง — root เก็บค่ามากสุด
Min-Heap: ทุก node มีค่า ≤ ค่าของ child ทั้งสอง — root เก็บค่าน้อยสุด
Heap ไม่ใช่ sorted structure แค่รักษา "ความสัมพันธ์ระหว่าง parent-child" เท่านั้น
การเก็บ Heap ใน Array
Heap ที่เป็น complete binary tree สามารถเก็บใน array ได้ โดยใช้กฎ:
- Root: index 0
- Parent ของ index i:
(i - 1) / 2 - Left child ของ i:
2 * i + 1 - Right child ของ i:
2 * i + 2
// ตัวอย่าง Max-Heap [9, 5, 7, 1, 3, 6]
//
// 9 ← index 0
// / \
// 5 7 ← index 1, 2
// / \ /
// 1 3 6 ← index 3, 4, 5
int parent(int i) { return (i - 1) / 2; }
int left(int i) { return 2 * i + 1; }
int right(int i) { return 2 * i + 2; }
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Find max/min (peek) | O(1) | O(n) | แค่ดู root |
| Insert (push) | O(log n) | O(n) | sift up |
| Extract max/min (pop) | O(log n) | O(n) | sift down |
| Build heap จาก array | O(n) | O(n) | Floyd's algorithm |
| Search (หาค่า x) | O(n) | O(n) | heap ไม่ optimize สำหรับ search |
สรุป Time / Space Complexity
อาจดูแปลกใจที่ build heap จาก array เป็น O(n) ทั้งที่ insert แต่ละตัว O(log n) เหตุผล: insert จาก "leaf ขึ้น root" ใช้เวลาน้อย leaf ประมาณ n/2 ตัว แต่ละตัว sift up ไม่เกิน 1 ระดับ node ที่อยู่ลึกจะ sift up น้อยกว่า node ที่อยู่ตื้น รวมแล้วเป็นผลรวมของ h(n) ที่ลดเหลือ O(n)
8.2 การ Implement Heap
มา implement min-heap เองเพื่อทำความเข้าใจ operation หลักคือ sift_up (หลัง insert) และ sift_down (หลัง extract) — ทั้งคู่รักษา heap property
Min-Heap Implementation
class MinHeap {
std::vector<int> a;
int parent(int i) { return (i - 1) / 2; }
int left(int i) { return 2 * i + 1; }
int right(int i) { return 2 * i + 2; }
void sift_up(int i) {
while (i > 0 && a[parent(i)] > a[i]) {
std::swap(a[parent(i)], a[i]);
i = parent(i);
}
}
void sift_down(int i) {
int n = a.size();
while (true) {
int smallest = i;
int l = left(i), r = right(i);
if (l < n && a[l] < a[smallest]) smallest = l;
if (r < n && a[r] < a[smallest]) smallest = r;
if (smallest == i) break;
std::swap(a[i], a[smallest]);
i = smallest;
}
}
public:
void push(int x) {
a.push_back(x);
sift_up(a.size() - 1);
}
int top() { return a[0]; }
void pop() {
a[0] = a.back();
a.pop_back();
if (!a.empty()) sift_down(0);
}
bool empty() { return a.empty(); }
int size() { return a.size(); }
void build(std::vector<int>& v) {
a = v;
for (int i = (int)a.size() / 2 - 1; i >= 0; i--) {
sift_down(i);
}
}
};ในงานจริง ไม่ควร implement heap เอง ให้ใช้ std::priority_queue หรือ std::make_heap/std::push_heap/std::pop_heap แทน เพราะผ่านการทดสอบและเร็วกว่า
8.3 Heap Sort
Heap Sort เป็น sorting algorithm ที่ใช้ heap เป็นแกนกลาง ความซับซ้อน O(n log n) ที่ guarantee ไม่มี worst case O(n²) เหมือน Quick Sort แต่มักช้ากว่า Quick Sort ในกรณีทั่วไปเพราะ cache locality แย่กว่า
ขั้นตอน Heap Sort
- Build Max-Heap จาก array — O(n)
- วนซ้ำ n-1 ครั้ง: สลับ root (max) กับตัวสุดท้าย, ลดขนาด heap 1, sift_down root
- หลังจบ: array จะเรียงน้อยไปมาก
void heap_sort(std::vector<int>& a) {
int n = a.size();
// Build max-heap (in-place)
auto sift_down = [&](int i, int heap_size) {
while (true) {
int l = 2*i+1, r = 2*i+2, largest = i;
if (l < heap_size && a[l] > a[largest]) largest = l;
if (r < heap_size && a[r] > a[largest]) largest = r;
if (largest == i) break;
std::swap(a[i], a[largest]);
i = largest;
}
};
for (int i = n/2 - 1; i >= 0; i--) sift_down(i, n);
// Extract ทีละตัว
for (int i = n - 1; i > 0; i--) {
std::swap(a[0], a[i]); // ส่ง max ไปท้าย
sift_down(0, i);
}
}Time: O(n log n), Space: O(1) — in-place sort
Quick Sort: เฉลี่ย O(n log n), worst O(n²), เร็วที่สุดในทั่วไป
Merge Sort: O(n log n) เสมอ, ใช้พื้นที่เพิ่ม O(n), stable
Heap Sort: O(n log n) เสมอ, in-place, ไม่ stable
Intro Sort (C++ std::sort): ใช้ Quick + Heap + Insertion ตามสถานการณ์ รับประกัน O(n log n)
8.4 การประยุกต์ใช้ Heap
Heap มี use case มากมาย เนื่องจากรองรับ "หาค่ามาก/น้อยสุด" ใน O(1) และ "เพิ่ม/ลบ" ใน O(log n)
1) Top K Elements
// หา k element ที่มากสุดใน array
std::vector<int> topK(std::vector<int>& nums, int k) {
std::priority_queue<int, std::vector<int>, std::greater<int>> pq;
// min-heap ขนาด k
for (int x : nums) {
pq.push(x);
if ((int)pq.size() > k) pq.pop(); // เอาตัวน้อยสุดออก
}
std::vector<int> ans;
while (!pq.empty()) { ans.push_back(pq.top()); pq.pop(); }
return ans;
}Time: O(n log k), Space: O(k) — ดีกว่า sort ทั้งหมด O(n log n)
2) Merge K Sorted Lists
struct ListNode {
int val;
ListNode *next;
};
struct Cmp {
bool operator()(ListNode* a, ListNode* b) {
return a->val > b->val; // min-heap
}
};
ListNode* mergeKLists(std::vector<ListNode*>& lists) {
std::priority_queue<ListNode*, std::vector<ListNode*>, Cmp> pq;
for (auto* h : lists) if (h) pq.push(h);
ListNode dummy(0), *tail = &dummy;
while (!pq.empty()) {
ListNode* n = pq.top(); pq.pop();
tail->next = n;
tail = n;
if (n->next) pq.push(n->next);
}
return dummy.next;
}LeetCode 23 — Time: O(N log k) ที่ N = จำนวน node รวม
3) Dijkstra's Shortest Path
std::vector<long long> dijkstra(
int src, int n,
std::vector<std::vector<std::pair<int,int>>>& adj) {
std::vector<long long> dist(n, LLONG_MAX);
std::priority_queue<std::pair<long long,int>,
std::vector<std::pair<long long,int>>,
std::greater<>> pq;
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // เป็น entry เก่า
for (auto& [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}Time: O((V + E) log V) สำหรับกราฟ non-negative weight
4) Median of Stream
class MedianFinder {
std::priority_queue<int> max_heap; // ครึ่งซ้าย (≤ median)
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
public:
void addNum(int num) {
if (max_heap.empty() || num <= max_heap.top()) {
max_heap.push(num);
} else {
min_heap.push(num);
}
// balance
if (max_heap.size() > min_heap.size() + 1) {
min_heap.push(max_heap.top());
max_heap.pop();
} else if (min_heap.size() > max_heap.size()) {
max_heap.push(min_heap.top());
min_heap.pop();
}
}
double findMedian() {
if (max_heap.size() > min_heap.size()) return max_heap.top();
return (max_heap.top() + min_heap.top()) / 2.0;
}
};LeetCode 295 — add O(log n), findMedian O(1)
8.5 Variations ของ Heap
นอกจาก binary heap ยังมี variation อื่น ๆ ที่เหมาะกับ use case ต่าง ๆ
| ชื่อ | โครงสร้าง | จุดเด่น |
|---|---|---|
| Binary Heap | binary tree (array) | ง่าย, cache ดี, ใช้ใน std::priority_queue |
| Binomial Heap | forest ของ binomial tree | merge O(log n), ใช้ใน Fibonacci |
| Fibonacci Heap | collection of trees + lazy | amortized O(1) insert, decrease-key O(1) amortized |
| Pairing Heap | multi-way tree + lazy merge | ง่าย ใกล้เคียง Fibonacci ในทางปฏิบัติ |
| d-ary Heap | tree ที่แต่ละ node มี d child | ลด height ใช้ใน Dijkstra บางครั้ง |
| Leftist Heap | binary tree + leftist property | merge O(log n), ง่าย ไม่ต้อง traversal |
| Skew Heap | คล้าย leftist แต่ swap เสมอ | merge amortized O(log n) |
Variations ของ Heap
Fibonacci Heap มี asymptotic complexity ที่ดีมาก insert และ decrease-key เป็น O(1) amortized ทำให้ Dijkstra กลายเป็น O(E + V log V) แต่ constant factor ใหญ่มาก ในทางปฏิบัติแทบไม่มีใครใช้ ยกเว้นในทางทฤษฎี
Indexed Heap (สำหรับ Dijkstra ที่ดีขึ้น)
เพื่อให้ decrease-key ทำได้ O(log n) ใน binary heap ต้อง maintain "position map" ที่บอกว่าแต่ละ key อยู่ index ไหน เรียกว่า "Indexed Heap" หรือ "Indexed Priority Queue"
ไม่ต้อง implement indexed heap เอง ใน Dijkstra แค่ push entry ใหม่ทุกครั้งที่พบ distance ที่ดีกว่า แล้ว skip ตอน pop ถ้า d > dist[u] วิธีนี้ใช้ memory O(E) แต่โค้ดง่ายและเร็วพอ
Graph
9.1 แนวคิดของ Graph
Graph เป็นโครงสร้างข้อมูลที่เก็บความสัมพันธ์ระหว่าง object หลาย ๆ ตัว ประกอบด้วย "vertex" (จุด) และ "edge" (เส้นเชื่อม) เหมาะกับปัญหาที่มีความสัมพันธ์แบบเครือข่าย เช่น เครือข่ายสังคม, แผนที่, โครงข่ายคอมพิวเตอร์, ความสัมพันธ์ระหว่างงาน
Graph G = (V, E): V = set of vertices, E = set of edges
Directed: edge มีทิศ (u → v)
Undirected: edge ไม่มีทิศ (u — v)
Weighted: แต่ละ edge มี weight (cost)
Adjacent vertices: มี edge เชื่อมตรง
Degree ของ vertex: จำนวน edge ติดกัน
Path: ลำดับ vertex เชื่อมด้วย edge
Cycle: path ที่เริ่มและจบที่ vertex เดียวกัน
Connected: มี path ระหว่างทุกคู่ vertex
ประเภทของ Graph
- Directed Graph (Digraph): edge มีทิศ
- Undirected Graph: edge ไม่มีทิศ
- Weighted Graph: แต่ละ edge มี weight
- Tree: connected acyclic undirected graph
- DAG (Directed Acyclic Graph): directed ไม่มี cycle
- Complete Graph: ทุกคู่ vertex มี edge
- Bipartite Graph: แบ่ง V เป็น 2 ส่วน ไม่มี edge ในส่วนเดียวกัน
รูปแบบการเก็บ Graph
| รูปแบบ | พื้นที่ | เช็ค edge (u,v) | ไล่ neighbors ของ u |
|---|---|---|---|
| Adjacency Matrix | O(V²) | O(1) | O(V) |
| Adjacency List | O(V + E) | O(degree) | O(degree) |
| Edge List | O(E) | O(E) | O(E) |
เปรียบเทียบรูปแบบการเก็บ graph
Adjacency Matrix: ดีเมื่อ V เล็ก (≤ 1000) หรือ graph หนาแน่น (E ≈ V²)
Adjacency List: ทั่วไป เกือบทุกกรณี ประหยัด memory
Edge List: ใช้ใน Kruskal's algorithm ที่ต้อง sort edge
9.2 การ Represent Graph ใน C++
มาดูวิธี implement graph ใน C++ ทั้งสามรูปแบบ
1) Adjacency Matrix
// graph ที่มี V vertices (เก็บ weight, 0 = ไม่มี edge)
int V;
int adj[1000][1000]; // หรือ vector<vector<int>>
void add_edge(int u, int v, int w) {
adj[u][v] = w;
// adj[v][u] = w; // ถ้า undirected
}
bool has_edge(int u, int v) {
return adj[u][v] != 0;
}
2) Adjacency List (vector)
// weighted graph
int V;
std::vector<std::vector<std::pair<int,int>>> adj;
// adj[u] = list ของ (v, weight)
void init(int n) {
V = n;
adj.assign(n, {});
}
void add_edge(int u, int v, int w) {
adj[u].push_back({v, w});
adj[v].push_back({u, w}); // ถ้า undirected
}
// ไล่ neighbors ของ u
for (auto& [v, w] : adj[u]) {
// ...
}
3) Edge List
struct Edge {
int u, v, w;
};
std::vector<Edge> edges;
edges.push_back({0, 1, 5});
edges.push_back({1, 2, 3});
// ใช้ใน Kruskal: sort by weight
std::sort(edges.begin(), edges.end(),
[](const Edge& a, const Edge& b) { return a.w < b.w; });
ในการแข่งขัน input มักอยู่ในรูปแบบ: บรรทัดแรก = V E, ตามด้วย E บรรทัดของ (u, v, w)
V E u1 v1 w1 u2 v2 w2 ...อ่านด้วย:
cin >> V >> E; adj.assign(V, {}); for (i=0;i<E;i++) { cin>>u>>v>>w; adj[u].push_back({v,w}); adj[v].push_back({u,w}); }9.3 Breadth-First Search (BFS)
BFS เป็นอัลกอริทึมเดินทางใน graph ที่สำรวจทีละ "ชั้น" เริ่มจาก source เยี่ยม neighbor ทั้งหมดก่อน จากนั้นไป neighbor ของ neighbor ทีละชั้น ใช้ queue ในการจัดลำดับ BFS หา shortest path ใน unweighted graph ได้
• ใช้เวลา O(V + E)
• ใช้พื้นที่ O(V) สำหรับ queue + visited
• หา shortest path ใน unweighted graph
• ใช้หา connected components
BFS Implementation
std::vector<int> bfs(int src, const std::vector<std::vector<int>>& adj) {
int n = adj.size();
std::vector<int> dist(n, -1);
std::vector<int> parent(n, -1);
std::queue<int> q;
dist[src] = 0;
q.push(src);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) { // ยังไม่เคยเยี่ยม
dist[v] = dist[u] + 1;
parent[v] = u;
q.push(v);
}
}
}
return dist; // dist[v] = ระยะสั้นสุดจาก src → v
}Time: O(V + E), Space: O(V)
Reconstruct Path
std::vector<int> reconstruct(int src, int target,
const std::vector<int>& parent) {
std::vector<int> path;
for (int cur = target; cur != -1; cur = parent[cur]) {
path.push_back(cur);
if (cur == src) break;
}
std::reverse(path.begin(), path.end());
return path;
}Shortest path in maze: มี grid ขนาด n×m แต่ละช่องเดินได้หรือเป็นกำแพง หาจำนวนก้าวน้อยสุดจาก start → end
วิธี: มองเป็น graph ที่แต่ละช่องคือ vertex และ edge เชื่อมช่องที่อยู่ติดกัน ใช้ BFS จาก start จะได้ shortest path ทันที
9.4 Depth-First Search (DFS)
DFS สำรวจ graph โดยไป "ให้ลึกที่สุด" ก่อน จาก source จะไป neighbor แรก, จาก neighbor นั้นไป neighbor ของมันต่อ ไปจนถึง dead-end แล้วย้อนกลับ (backtrack) ใช้ recursion หรือ explicit stack DFS ใช้ในหา cycle, topological sort, connected components
DFS Recursive
std::vector<bool> visited;
void dfs(int u, const std::vector<std::vector<int>>& adj) {
visited[u] = true;
// visit(u) — ประมวลผล u
for (int v : adj[u]) {
if (!visited[v]) {
dfs(v, adj);
}
}
}
void traverse(int n, const std::vector<std::vector<int>>& adj) {
visited.assign(n, false);
for (int i = 0; i < n; i++) {
if (!visited[i]) dfs(i, adj);
}
}DFS Iterative (Stack)
void dfs_iter(int src, const std::vector<std::vector<int>>& adj) {
std::vector<bool> visited(adj.size(), false);
std::stack<int> st;
st.push(src);
while (!st.empty()) {
int u = st.top(); st.pop();
if (visited[u]) continue;
visited[u] = true;
// visit(u)
for (int v : adj[u]) {
if (!visited[v]) st.push(v);
}
}
}BFS: ใช้ queue, หา shortest path (unweighted), ใช้ memory O(branch^depth)
DFS: ใช้ stack/recursion, ใช้ memory O(depth) ดีกว่าสำหรับ graph กว้างมาก, ใช้หา cycle, topological sort
ทั้งคู่ O(V + E) เวลาเท่ากัน แต่ DFS มักใช้ memory น้อยกว่าในกราฟทั่วไป
Cycle Detection ใน Directed Graph
enum State { WHITE, GRAY, BLACK }; // ยังไม่เยี่ยม / กำลัง / เสร็จ
bool dfs_cycle(int u, const std::vector<std::vector<int>>& adj,
std::vector<State>& state) {
state[u] = GRAY;
for (int v : adj[u]) {
if (state[v] == GRAY) return true; // back edge → cycle
if (state[v] == WHITE && dfs_cycle(v, adj, state)) {
return true;
}
}
state[u] = BLACK;
return false;
}
bool has_cycle(int n, const std::vector<std::vector<int>>& adj) {
std::vector<State> state(n, WHITE);
for (int i = 0; i < n; i++) {
if (state[i] == WHITE && dfs_cycle(i, adj, state)) {
return true;
}
}
return false;
}9.5 Topological Sort
Topological Sort เป็นการจัดเรียง vertex ใน DAG ให้ทุก edge u → v, u มาก่อน v เหมาะกับการจัดลำดับงานที่มี dependency เช่น การเรียนวิชาที่ต้องผ่านวิชาบังคับก่อน
Graph ต้องเป็น DAG (Directed Acyclic Graph) — ถ้ามี cycle ไม่สามารถ topological sort ได้ อัลกอริทึมนี้สามารถใช้ตรวจ cycle ได้ด้วย
วิธี 1: DFS-based
void dfs_topo(int u, const std::vector<std::vector<int>>& adj,
std::vector<bool>& visited, std::vector<int>& order) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) dfs_topo(v, adj, visited, order);
}
order.push_back(u); // เพิ่มหลัง dfs ลูกเสร็จ
}
std::vector<int> topological_sort(int n,
const std::vector<std::vector<int>>& adj) {
std::vector<bool> visited(n, false);
std::vector<int> order;
for (int i = 0; i < n; i++) {
if (!visited[i]) dfs_topo(i, adj, visited, order);
}
std::reverse(order.begin(), order.end());
return order;
}วิธี 2: Kahn's Algorithm (BFS-based)
std::vector<int> kahn(int n, std::vector<std::vector<int>>& adj) {
std::vector<int> in_degree(n, 0);
for (int u = 0; u < n; u++) {
for (int v : adj[u]) in_degree[v]++;
}
std::queue<int> q;
for (int i = 0; i < n; i++) {
if (in_degree[i] == 0) q.push(i);
}
std::vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) {
if (--in_degree[v] == 0) q.push(v);
}
}
if ((int)order.size() != n) {
// มี cycle — topological sort ไม่ได้
return {};
}
return order;
}Time: O(V + E), Space: O(V)
Course Schedule: มี N วิชา และ list ของ prerequisite จงหาลำดับการเรียนที่ทำได้
วิธี: สร้าง graph ที่ edge (a, b) หมายถึงต้องเรียน a ก่อน b แล้ว topological sort ถ้าได้ครบ N วิชา → ทำได้, ถ้าไม่ครบ → มี cycle ทำไม่ได้
9.6 Shortest Path Algorithms
ปัญหา shortest path มีหลาย algorithm แล้วแต่ลักษณะของ graph
| Algorithm | กรณีใช้ | Time | ข้อจำกัด |
|---|---|---|---|
| BFS | unweighted, หาทุก vertex จาก 1 source | O(V+E) | ไม่มี weight |
| Dijkstra | weighted non-negative, 1 source | O((V+E) log V) | weight ≥ 0 |
| Bellman-Ford | มี weight ลบ, 1 source, ตรวจ cycle ลบ | O(V·E) | — |
| Floyd-Warshall | ทุกคู่ (all-pairs) | O(V³) | V เล็ก |
| A* | หา path เฉพาะจุด ใช้ heuristic | ขึ้นกับ heuristic | — |
สรุป Shortest Path Algorithms
Dijkstra (สำหรับ weight ≥ 0)
ดู implementation ในบทที่ 8.4 — ใช้ priority_queue เลือก vertex ที่มี dist น้อยสุด
Bellman-Ford (รองรับ weight ลบ)
std::vector<long long> bellman_ford(
int src, int n, const std::vector<std::tuple<int,int,int>>& edges) {
// edges = list of (u, v, w)
std::vector<long long> dist(n, LLONG_MAX);
dist[src] = 0;
// relax ทุก edge V-1 ครั้ง
for (int i = 0; i < n - 1; i++) {
for (auto& [u, v, w] : edges) {
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
}
}
}
// ตรวจ negative cycle
for (auto& [u, v, w] : edges) {
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v]) {
// มี negative cycle!
return {};
}
}
return dist;
}Time: O(V·E), Space: O(V)
Floyd-Warshall (All-Pairs)
// dist[i][j] = shortest path จาก i → j
void floyd_warshall(std::vector<std::vector<long long>>& dist) {
int n = dist.size();
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] != LLONG_MAX && dist[k][j] != LLONG_MAX) {
dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
}
// การใช้งาน: เริ่มต้น dist[i][i] = 0, dist[i][j] = weight, dist[i][j] = INF ถ้าไม่มี edgeTime: O(V³), Space: O(V²)
9.7 Minimum Spanning Tree
Minimum Spanning Tree (MST) คือ subgraph ของ weighted undirected graph ที่เป็น tree, เชื่อมทุก vertex, และมีผลรวม weight น้อยสุด มีสอง algorithm หลัก: Kruskal (ใช้ DSU) และ Prim (ใช้ priority queue)
Kruskal (Edge-based)
#include <algorithm>
// สมมติมี DSU จากบทที่ 10
long long kruskal(int n, std::vector<std::tuple<int,int,int>>& edges) {
// edges = list of (w, u, v) — sort ด้วย w
std::sort(edges.begin(), edges.end());
DSU dsu(n);
long long total = 0;
int edges_used = 0;
for (auto& [w, u, v] : edges) {
if (dsu.find(u) != dsu.find(v)) {
dsu.unite(u, v);
total += w;
edges_used++;
if (edges_used == n - 1) break; // MST สมบูรณ์
}
}
if (edges_used != n - 1) return -1; // graph ไม่ connected
return total;
}Time: O(E log E) จาก sort + DSU ≈ O(E α(V))
Prim (Vertex-based)
long long prim(int n, std::vector<std::vector<std::pair<int,int>>>& adj) {
std::vector<long long> min_w(n, LLONG_MAX);
std::vector<bool> in_mst(n, false);
min_w[0] = 0;
std::priority_queue<std::pair<long long,int>,
std::vector<std::pair<long long,int>>,
std::greater<>> pq;
pq.push({0, 0});
long long total = 0;
while (!pq.empty()) {
auto [w, u] = pq.top(); pq.pop();
if (in_mst[u]) continue;
in_mst[u] = true;
total += w;
for (auto& [v, weight] : adj[u]) {
if (!in_mst[v] && weight < min_w[v]) {
min_w[v] = weight;
pq.push({weight, v});
}
}
}
return total;
}Time: O((V + E) log V)
Kruskal: ดีเมื่อมี edge list อยู่แล้ว หรือ graph กระจาย (sparse)
Prim: ดีเมื่อ graph หนาแน่น (dense) หรือต้องการ adjacency list
ในการแข่งขัน ใช้ Kruskal + DSU บ่อยกว่าเพราะโค้ดสั้นกว่า
9.8 Strongly Connected Components
Strongly Connected Component (SCC) ของ directed graph คือ subset ของ vertex ที่มี path ระหว่างทุกคู่ใน subset การหา SCC มีสอง algorithm หลัก: Tarjan's และ Kosaraju's ทั้งคู่ O(V + E)
Tarjan's SCC
int timer = 0;
std::vector<int> disc, low, on_stack;
std::stack<int> st;
std::vector<std::vector<int>> sccs;
void tarjan(int u, const std::vector<std::vector<int>>& adj) {
disc[u] = low[u] = timer++;
st.push(u);
on_stack[u] = true;
for (int v : adj[u]) {
if (disc[v] == -1) {
tarjan(v, adj);
low[u] = std::min(low[u], low[v]);
} else if (on_stack[v]) {
low[u] = std::min(low[u], disc[v]);
}
}
if (low[u] == disc[u]) {
std::vector<int> scc;
while (true) {
int v = st.top(); st.pop();
on_stack[v] = false;
scc.push_back(v);
if (v == u) break;
}
sccs.push_back(scc);
}
}
std::vector<std::vector<int>> find_sccs(int n,
const std::vector<std::vector<int>>& adj) {
disc.assign(n, -1);
low.assign(n, -1);
on_stack.assign(n, false);
sccs.clear();
timer = 0;
for (int i = 0; i < n; i++) {
if (disc[i] == -1) tarjan(i, adj);
}
return sccs;
}Time: O(V + E), Space: O(V)
2-SAT: เป็นปัญหา boolean satisfiability ที่แต่ละ clause มี 2 literal เช่น (a OR b) AND (NOT a OR c)
วิธี: สร้าง implication graph ที่ (a OR b) = (NOT a → b) AND (NOT b → a) จากนั้นหา SCC ถ้า literal x และ NOT x อยู่ใน SCC เดียวกัน → unsatisfiable ถ้าไม่ → satisfiable, เลือกค่าจากลำดับ SCC ที่ออกก่อน
Disjoint Set Union (DSU)
10.1 แนวคิดของ DSU
Disjoint Set Union (DSU) หรือ Union-Find เป็นโครงสร้างข้อมูล ที่จัดการ "set ที่ไม่มีสมาชิกซ้ำกัน" หลายชุด รองรับ operation หลักสองตัว: find(x) หาตัวแทน (representative) ของ set ที่มี x และ union(a, b) รวม set ที่มี a และ b เข้าด้วยกัน DSU เป็นโครงสร้างที่มีประสิทธิภาพสูงมาก operation แต่ละตัวมี amortized complexity O(α(n)) ซึ่ง α(n) < 5 สำหรับ n ทุกตัวในจักรวาลที่สังเกตได้
make_set(x): สร้าง set ใหม่ที่มี x เป็นสมาชิกเดียว
find(x): คืน representative ของ set ที่มี x
union(a, b): รวม set ของ a และ b
connected(a, b): ตรวจว่า a, b อยู่ set เดียวกันหรือไม่
Idea: Forest of Trees
DSU เก็บแต่ละ set เป็น tree root ของ tree คือ representative ของ set การ find คือไต่จาก node ขึ้นไป root การ union คือเชื่อม root ของสอง tree — เทคนิคหลักคือ "path compression" และ "union by rank" ที่ทำให้ tree ไม่สูงเกินไป
| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Find (without optimization) | O(n) | O(n) | — |
| Find (with path compression) | O(α(n)) amortized | O(n) | α ≈ 4 ในงานจริง |
| Union (with union by rank) | O(α(n)) amortized | O(n) | — |
| Connected | O(α(n)) amortized | O(1) | — |
สรุป Time / Space Complexity
ฟังก์ชัน α(n) (inverse Ackermann) เติบโตช้ามาก α(1) = 0, α(2) = 1, α(4) = 2, α(16) = 3, α(65536) = 4 สำหรับทุก n ที่เป็นไปได้ในจักรวาล α(n) ≤ 4 — ในทางปฏิบัติ DSU operation คือ O(1) จริง ๆ
10.2 การ Implement DSU
มา implement DSU ทีละขั้น เริ่มจากแบบง่าย ๆ ก่อน แล้วค่อยเพิ่ม optimization
1) Naive Implementation
class DSU {
std::vector<int> parent;
public:
DSU(int n) : parent(n) {
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
while (parent[x] != x) x = parent[x];
return x;
}
void unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra != rb) parent[ra] = rb;
}
};
แบบนี้ find/unite เป็น O(n) ใน worst case เพราะ tree อาจสูงมาก
2) Path Compression
หลังจาก find แต่ละครั้ง ให้ "flatten" tree โดยชี้ทุก node ที่ผ่าน ตรงไปยัง root ทำให้ครั้งต่อไปเร็วขึ้น
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // compress
}
return parent[x];
}
3) Union by Rank
เวลา union ให้ตั้ง rank (ความสูงประมาณการ) ของแต่ละ root แล้วเอา root ที่ rank ต่ำกว่า ไปชี้ root ที่ rank สูงกว่า ทำให้ tree ไม่สูงเกิน log n
4) Union by Size (ง่ายกว่า)
อีกแบบ: เอา tree ที่เล็กกว่า ไปเป็นลูกของ tree ที่ใหญ่กว่า — ง่ายกว่า rank เพราะนับจำนวนสมาชิก
Final Implementation
class DSU {
std::vector<int> parent, sz;
public:
DSU(int n) : parent(n), sz(n, 1) {
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
bool unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false; // อยู่ set เดียวกัน
if (sz[ra] < sz[rb]) std::swap(ra, rb);
parent[rb] = ra;
sz[ra] += sz[rb];
return true;
}
bool connected(int a, int b) {
return find(a) == find(b);
}
int size(int x) {
return sz[find(x)];
}
};Path Compression + Union by Size = O(α(n)) amortized
10.3 การประยุกต์ใช้ DSU
DSU เป็นโครงสร้างที่ใช้ในหลายปัญหา graph โดยเฉพาะที่เกี่ยวกับ "connected components"
1) ตรวจ Cycle ใน Undirected Graph
bool has_cycle(int n, std::vector<std::pair<int,int>>& edges) {
DSU dsu(n);
for (auto& [u, v] : edges) {
if (dsu.connected(u, v)) return true; // มี cycle
dsu.unite(u, v);
}
return false;
}2) Kruskal MST
ดูบทที่ 9.7 — ใช้ DSU ตรวจว่า edge ใหม่ไม่สร้าง cycle — ถ้าสอง vertex อยู่ set เดียวกัน = มี path แล้ว = ข้าม edge นี้
3) Connected Components
std::vector<int> components(int n, std::vector<std::pair<int,int>>& edges) {
DSU dsu(n);
for (auto& [u, v] : edges) dsu.unite(u, v);
std::vector<int> comp_id(n);
for (int i = 0; i < n; i++) comp_id[i] = dsu.find(i);
return comp_id;
}4) Dynamic Connectivity (offline)
มีโจทย์แบบ: เพิ่ม edge, ลบ edge, ถามว่าสอง vertex เชื่อมกันไหม — DSU ตอบได้แค่ "เพิ่ม edge" สำหรับ "ลบ edge" ด้วย ต้องใช้ dynamic connectivity algorithm ที่ซับซ้อนกว่า แต่ถ้า query ทั้งหมดทราบล่วงหน้า (offline) สามารถแบ่งเวลา edge เป็นช่วง ๆ และใช้ DSU กับ rollback ได้
5) Accounts Merge (LeetCode 721)
โจทย์: รวม account ที่มี email ร่วมกัน — สร้าง DSU ที่มี email ทุกอันเป็นสมาชิก unite email ที่อยู่ใน account เดียวกัน จากนั้น unite ทุกคู่ที่มี email ร่วมกันระหว่าง account
มี n เมือง และ matrix isConnected[i][j] บอกว่าเมือง i และ j เชื่อมกันไหม จงหาจำนวน "จังหวัด" (province) — กลุ่มเมืองที่เชื่อมกัน
วิธี: สร้าง DSU แล้ว unite ทุกคู่ที่ isConnected จากนั้นนับจำนวน root ที่แตกต่างกัน = จำนวน province
10.4 DSU with Rollback
บางครั้งเราต้อง "undo" การ union เช่น ใน dynamic connectivity offline DSU ปกติที่ใช้ path compression ไม่สามารถ rollback ได้ เพราะ path compression เปลี่ยน parent หลาย node ดังนั้นต้องใช้ union by rank only (ไม่ compress path) — ทำให้แต่ละ operation เป็น O(log n) แทน O(α)
class DSU_Rollback {
std::vector<int> parent, rank_;
std::stack<std::tuple<int,int,int,int>> history; // (a, pa, b, pb)
public:
DSU_Rollback(int n) : parent(n), rank_(n, 0) {
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
while (parent[x] != x) x = parent[x];
return x; // ไม่ compress path
}
bool unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) {
history.push({-1, -1, -1, -1}); // mark no-op
return false;
}
if (rank_[ra] < rank_[rb]) std::swap(ra, rb);
history.push({ra, parent[ra], rb, parent[rb]});
parent[rb] = ra;
if (rank_[ra] == rank_[rb]) rank_[ra]++;
return true;
}
void rollback() {
auto [a, pa, b, pb] = history.top();
history.pop();
if (a == -1) return; // no-op
parent[b] = pb;
parent[a] = pa;
if (rank_[a] > 0 && parent[a] == a) rank_[a]--;
}
};Time: O(log n) per operation
DSU with rollback ใช้ใน "Divide and Conquer on Time" เพื่อแก้ dynamic connectivity offline หรือใช้ในส่วนของ "Meximization" ของคำถาม — ดูรายละเอียดเพิ่มเติมใน competitive programming references
โครงสร้างข้อมูลขั้นสูง
11.1 AVL Tree (Self-Balancing BST)
AVL Tree เป็น BST ที่ "balance" ตัวเอง โดยรักษาให้ความสูงของ left และ right subtree ต่างกันไม่เกิน 1 หลังทุก insert/delete จะตรวจและ "rotate" ถ้าจำเป็น ทำให้ height เป็น O(log n) เสมอ — รับประกัน search/insert/delete O(log n)
Balance Factor ของ node = height(left) - height(right) AVL รักษาให้ balance factor ∈ {-1, 0, 1} ทุก node — ถ้าเกิน จะ rotate เพื่อ rebalance
การ Rotation
มี 4 กรณีของ imbalance ที่ต้องจัดการ:
- Left-Left: หนักซ้าย 2, left child หนักซ้าย/สมดุล → Right Rotation
- Right-Right: หนักขวา 2, right child หนักขวา/สมดุล → Left Rotation
- Left-Right: หนักซ้าย 2, left child หนักขวา → Left-Right (Double)
- Right-Left: หนักขวา 2, right child หนักซ้าย → Right-Left (Double)
Implementation (Sketch)
struct AVLNode {
int val, height;
AVLNode *left, *right;
AVLNode(int v) : val(v), height(1), left(nullptr), right(nullptr) {}
};
int height(AVLNode* n) { return n ? n->height : 0; }
int bf(AVLNode* n) { return n ? height(n->left) - height(n->right) : 0; }
void update(AVLNode* n) {
n->height = 1 + std::max(height(n->left), height(n->right));
}
AVLNode* rotate_right(AVLNode* y) {
AVLNode* x = y->left;
AVLNode* t2 = x->right;
x->right = y;
y->left = t2;
update(y); update(x);
return x; // root ใหม่
}
AVLNode* rotate_left(AVLNode* x) {
AVLNode* y = x->right;
AVLNode* t2 = y->left;
y->left = x;
x->right = t2;
update(x); update(y);
return y;
}
AVLNode* insert(AVLNode* root, int val) {
if (!root) return new AVLNode(val);
if (val < root->val) root->left = insert(root->left, val);
else if (val > root->val) root->right = insert(root->right, val);
else return root;
update(root);
int balance = bf(root);
// Left-Left
if (balance > 1 && val < root->left->val)
return rotate_right(root);
// Right-Right
if (balance < -1 && val > root->right->val)
return rotate_left(root);
// Left-Right
if (balance > 1 && val > root->left->val) {
root->left = rotate_left(root->left);
return rotate_right(root);
}
// Right-Left
if (balance < -1 && val < root->right->val) {
root->right = rotate_right(root->right);
return rotate_left(root);
}
return root;
}แทบไม่มีใคร implement AVL เอง เพราะ C++ std::map ใช้ Red-Black Tree ซึ่งก็เป็น balanced BST เช่นกัน ความแตกต่าง: AVL คุม height เข้มกว่า (≤ 1), RB Tree คุมผ่าน property สี ที่ยอมให้สูง ≤ 2 log(n+1) AVL เร็วกว่าใน search, RB Tree เร็วกว่าใน insert/delete
11.2 Trie (Prefix Tree)
Trie (อ่านว่า "try") หรือ Prefix Tree เป็น tree สำหรับเก็บสตริง เก็บสตริงที่มี prefix ร่วมกันใน path เดียวกัน ทำให้ประหยัด memory และค้นหา/แทรกเร็ว เหมาะกับงาน autocomplete, spell checker, IP routing
• root เป็น node ว่าง
• แต่ละ edge แทนตัวอักษร
• path จาก root → node = prefix ของสตริงที่ผ่าน
• node ที่ mark "end of word" = มีสตริงจบที่ตรงนี้
Implementation
struct TrieNode {
TrieNode* children[26];
bool is_end;
TrieNode() : is_end(false) {
for (int i = 0; i < 26; i++) children[i] = nullptr;
}
};
class Trie {
TrieNode* root;
public:
Trie() : root(new TrieNode()) {}
void insert(const std::string& word) {
TrieNode* cur = root;
for (char c : word) {
int i = c - 'a';
if (!cur->children[i]) cur->children[i] = new TrieNode();
cur = cur->children[i];
}
cur->is_end = true;
}
bool search(const std::string& word) {
TrieNode* cur = root;
for (char c : word) {
int i = c - 'a';
if (!cur->children[i]) return false;
cur = cur->children[i];
}
return cur->is_end;
}
bool startsWith(const std::string& prefix) {
TrieNode* cur = root;
for (char c : prefix) {
int i = c - 'a';
if (!cur->children[i]) return false;
cur = cur->children[i];
}
return true;
}
};| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Insert word ความยาว L | O(L) | O(L·alphabet) | |
| Search word ความยาว L | O(L) | O(1) | เร็วกว่า hash เพราะไม่ต้อง hash สตริง |
| StartsWith(prefix) | O(L) | O(1) | |
| พื้นที่รวม | — | O(ผลรวมความยาวคำทั้งหมด) |
สรุป Time / Space Complexity
การประยุกต์
- Autocomplete ในช่องค้นหา
- Spell checker
- IP routing (Longest prefix match)
- การแปลรหัส T9 ในมือถือเก่า
- Aho-Corasick สำหรับ multi-pattern matching
Word Search II: มี grid ตัวอักษร และ list คำ หาทุกคำใน list ที่สามารถสะกดได้ใน grid (เดินติดกัน, ไม่กลับตัว)
วิธี: สร้าง trie ของคำที่ต้องหา จากนั้น DFS บน grid พร้อมเช็ค path ใน trie ไปด้วย เมื่อเจอ is_end = true ก็บันทึกคำนั้น
11.3 Segment Tree
Segment Tree เป็นโครงสร้างสำหรับ "range query" เช่น หา min/max/sum ในช่วง [l, r] และ "point update" — เปลี่ยนค่า arr[i] = x ทั้งสอง operation เป็น O(log n) เร็วกว่า prefix sum ตรงที่รองรับ update ได้
แต่ละ node ใน segment tree เก็บค่าที่ summarize range ของ array root เก็บค่าของ [0, n-1] child ซ้าย = [0, mid], child ขวา = [mid+1, n-1] ไปเรื่อย ๆ จนถึง leaf = ค่า 1 ตัว — tree สูง log n ทำให้ query/update เป็น O(log n)
Implementation: Sum Query
int n;
std::vector<long long> tree; // size 4n
void build(const std::vector<int>& arr, int node, int lo, int hi) {
if (lo == hi) { tree[node] = arr[lo]; return; }
int mid = (lo + hi) / 2;
build(arr, 2*node, lo, mid);
build(arr, 2*node+1, mid+1, hi);
tree[node] = tree[2*node] + tree[2*node+1];
}
void update(int node, int lo, int hi, int idx, int val) {
if (lo == hi) { tree[node] = val; return; }
int mid = (lo + hi) / 2;
if (idx <= mid) update(2*node, lo, mid, idx, val);
else update(2*node+1, mid+1, hi, idx, val);
tree[node] = tree[2*node] + tree[2*node+1];
}
long long query(int node, int lo, int hi, int l, int r) {
if (r < lo || hi < l) return 0; // outside
if (l <= lo && hi <= r) return tree[node]; // fully inside
int mid = (lo + hi) / 2;
return query(2*node, lo, mid, l, r) +
query(2*node+1, mid+1, hi, l, r);
}
// Wrapper
void init(const std::vector<int>& arr) {
n = arr.size();
tree.assign(4 * n, 0);
build(arr, 1, 0, n - 1);
}
void update(int idx, int val) { update(1, 0, n - 1, idx, val); }
long long query(int l, int r) { return query(1, 0, n - 1, l, r); }| การดำเนินการ (Operation) | เวลา (Time) | พื้นที่ (Space) | หมายเหตุ |
|---|---|---|---|
| Build | O(n) | O(n) | สร้างครั้งเดียว |
| Point update | O(log n) | O(log n) | |
| Range query [l,r] | O(log n) | O(log n) | min/max/sum/etc. |
| Range update (lazy) | O(log n) | O(n) | ต้องใช้ lazy propagation |
สรุป Time / Space Complexity
สำหรับ "range update" (เช่น เพิ่มค่าทุกตัวใน [l, r] ไป x) ต้องใช้ "lazy propagation" — ทุก node มี "lazy tag" เก็บค่าที่ยังไม่ได้ push ลง child เมื่อ query/update ผ่าน node ที่มี lazy tag ให้ push ลง child ก่อนแล้วค่อยทำงานต่อ — ทำให้ range update เป็น O(log n) แทน O(n)
11.4 Fenwick Tree (Binary Indexed Tree)
Fenwick Tree หรือ Binary Indexed Tree (BIT) เป็นโครงสร้างสำหรับ "prefix sum query + point update" ใน O(log n) เร็วและใช้ memory น้อยกว่า segment tree แต่รองรับ operation จำกัดกว่า เหมาะกับ prefix sum และ "inversion counting"
Fenwick ใช้ "binary representation" ของ index แต่ละ node i เก็บผลรวมในช่วง (i - lowbit(i), i] โดย lowbit(i) = i & -i = พจน์ 1 ที่น้อยสุดของ i — การ update/query ใช้ลำดับ index ที่เปลี่ยนแปลงตาม lowbit
Implementation
class Fenwick {
std::vector<long long> bit;
int n;
public:
Fenwick(int n) : n(n), bit(n + 1, 0) {}
// เพิ่ม delta ที่ตำแหน่ง idx (1-indexed)
void update(int idx, long long delta) {
for (; idx <= n; idx += idx & -idx) {
bit[idx] += delta;
}
}
// ผลรวมตั้งแต่ 1 ถึง idx (prefix sum)
long long query(int idx) {
long long sum = 0;
for (; idx > 0; idx -= idx & -idx) {
sum += bit[idx];
}
return sum;
}
// ผลรวมในช่วง [l, r] (1-indexed)
long long range_query(int l, int r) {
return query(r) - query(l - 1);
}
};Inversion Count
// นับ inversion: คู่ (i,j) ที่ i < j และ arr[i] > arr[j]
long long count_inversions(std::vector<int> arr) {
int n = arr.size();
// compress ค่าให้อยู่ใน [1, n]
std::vector<int> sorted = arr;
std::sort(sorted.begin(), sorted.end());
sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end());
for (int& x : arr) {
x = std::lower_bound(sorted.begin(), sorted.end(), x) - sorted.begin() + 1;
}
Fenwick fw(n);
long long inv = 0;
for (int i = n - 1; i >= 0; i--) {
// นับจำนวนตัวที่น้อยกว่า arr[i] ที่เห็นแล้ว (อยู่ขวา)
inv += fw.query(arr[i] - 1);
fw.update(arr[i], 1);
}
return inv;
}Time: O(n log n)
Fenwick: โค้ดสั้น, memory น้อย (n+1 vs 4n), constant เล็ก — ใช้ได้แค่กับ operation ที่ invertible (sum, xor) หรือ "range update + point query"
Segment Tree: ยืดหยุ่นกว่า, รองรับทุก associative operation (min, max, gcd), range update+query — แต่โค้ดยาวกว่า
11.5 Sparse Table
Sparse Table เป็นโครงสร้างสำหรับ range query แบบ "idempotent" (min, max, gcd) ที่ไม่รองรับ update แต่ query เร็วมาก O(1) build O(n log n), memory O(n log n)
class SparseTable {
std::vector<std::vector<int>> table;
std::vector<int> log2;
public:
void build(const std::vector<int>& arr) {
int n = arr.size();
log2.assign(n + 1, 0);
for (int i = 2; i <= n; i++) log2[i] = log2[i / 2] + 1;
int K = log2[n] + 1;
table.assign(K, std::vector<int>(n));
for (int i = 0; i < n; i++) table[0][i] = arr[i];
for (int k = 1; k < K; k++) {
for (int i = 0; i + (1 << k) <= n; i++) {
table[k][i] = std::min(
table[k-1][i],
table[k-1][i + (1 << (k-1))]
);
}
}
}
int query(int l, int r) { // [l, r] inclusive
int k = log2[r - l + 1];
return std::min(table[k][l], table[k][r - (1 << k) + 1]);
}
};min/max เป็น idempotent: min(x, x) = x ทำให้ query สามารถ overlap ได้โดยไม่กระทบผลลัพธ์ — ใช้ช่วง [l, l+2^k-1] และ [r-2^k+1, r] ที่ overlap ครอบคลุม [l, r] แต่ sum ไม่ idempotent: x + x ≠ x จะนับซ้ำ — ต้องใช้ prefix sum หรือ segment tree แทน
การประยุกต์: LCA ด้วย Sparse Table
ใช้ sparse table เก็บ "minimum ใน range" ของ Euler tour เพื่อหา LCA ใน O(1) หลัง preprocess O(n log n) — เร็วกว่า binary lifting แต่ใช้ memory มากกว่า
11.6 Suffix Array และ LCP
Suffix Array เป็นโครงสร้างสำหรับประมวลผลสตริง เก็บ index เริ่มต้นของ suffix ทั้งหมดของ string เรียงตามตัวอักษร — ทำให้สามารถ binary search pattern ได้ O(|P| + log |T|) และเป็นฐานของ algorithm ขั้นสูง เช่น finding longest repeated substring
การสร้าง Suffix Array O(n log² n)
std::vector<int> build_suffix_array(const std::string& s) {
int n = s.size();
std::vector<int> sa(n), rank(n), tmp(n);
for (int i = 0; i < n; i++) {
sa[i] = i;
rank[i] = s[i];
}
for (int k = 1; k < n; k *= 2) {
auto cmp = [&](int a, int b) {
if (rank[a] != rank[b]) return rank[a] < rank[b];
int ra = (a + k < n) ? rank[a + k] : -1;
int rb = (b + k < n) ? rank[b + k] : -1;
return ra < rb;
};
std::sort(sa.begin(), sa.end(), cmp);
tmp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
tmp[sa[i]] = tmp[sa[i-1]] + (cmp(sa[i-1], sa[i]) ? 1 : 0);
}
rank = tmp;
}
return sa;
}Time: O(n log² n) — มี version O(n log n) และ O(n) ที่ซับซ้อนกว่า
LCP Array (Kasai's Algorithm)
// lcp[i] = longest common prefix ของ sa[i] และ sa[i-1]
std::vector<int> build_lcp(const std::string& s, const std::vector<int>& sa) {
int n = s.size();
std::vector<int> rank(n), lcp(n, 0);
for (int i = 0; i < n; i++) rank[sa[i]] = i;
int h = 0;
for (int i = 0; i < n; i++) {
if (rank[i] > 0) {
int j = sa[rank[i] - 1];
while (i + h < n && j + h < n && s[i + h] == s[j + h]) h++;
lcp[rank[i]] = h;
if (h > 0) h--;
}
}
return lcp;
}Time: O(n)
• หา longest repeated substring (max ของ lcp array)
• หาจำนวน distinct substring: ผลรวม (n - sa[i]) - lcp[i] ทุก i
• ค้นหา pattern หลายตัวพร้อมกัน
• ใช้ใน data compression
โครงสร้างข้อมูลเชิงแข่งขัน
12.1 Sparse Table สำหรับ LCA (Binary Lifting)
Binary Lifting เป็นเทคนิค precompute ancestor ของแต่ละ node เพื่อตอบคำถาม LCA (Lowest Common Ancestor) ได้ O(log n) ต่อ query และยังตอบ k-th ancestor ได้ O(log n) หลักการคือเก็บ up[u][k] = บรรพบุรุษที่ 2^k ของ u
Preprocess
int LOG;
std::vector<std::vector<int>> up; // up[u][k] = 2^k ancestor ของ u
std::vector<int> depth;
void preprocess(int n, const std::vector<std::vector<int>>& children) {
LOG = std::__lg(n) + 1;
up.assign(n, std::vector<int>(LOG, -1));
depth.assign(n, 0);
// BFS หา depth และ up[u][0] = parent[u]
std::queue<int> q;
q.push(0);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : children[u]) {
depth[v] = depth[u] + 1;
up[v][0] = u;
q.push(v);
}
}
// สร้าง table
for (int k = 1; k < LOG; k++) {
for (int u = 0; u < n; u++) {
if (up[u][k-1] != -1) {
up[u][k] = up[up[u][k-1]][k-1];
}
}
}
}Preprocess: O(n log n), Space: O(n log n)
Query LCA
int lca(int u, int v) {
if (depth[u] < depth[v]) std::swap(u, v);
int diff = depth[u] - depth[v];
// เลื่อน u ขึ้นจนเท่า v
for (int k = 0; k < LOG; k++) {
if ((diff >> k) & 1) u = up[u][k];
}
if (u == v) return u;
// เลื่อนทั้งคู่ขึ้นพร้อมกัน
for (int k = LOG - 1; k >= 0; k--) {
if (up[u][k] != up[v][k]) {
u = up[u][k];
v = up[v][k];
}
}
return up[u][0];
}Query: O(log n)
LCA ใช้ในการตอบคำถาม "path query" บน tree เช่น หาผลรวม/ค่ามากสุดของ path จาก u ไป v โดย path = u → lca(u,v) → v ใช้ร่วมกับ prefix sum บน tree (จาก root)
12.2 Heavy-Light Decomposition (HLD)
Heavy-Light Decomposition เป็นเทคนิคแบ่ง tree เป็น "chain" เพื่อให้สามารถใช้ segment tree ตอบ path query ได้ — หาผลรวม/ค่ามากสุด บน path จาก u ไป v หรือ update ค่าบน path ทำได้ O(log² n) ต่อ operation
สำหรับแต่ละ node, เลือก child ที่มี subtree ใหญ่สุดเป็น "heavy child" child อื่นเป็น "light child" — เชื่อม heavy child ของแต่ละ node เป็น "heavy chain" จาก root ไป leaf ใด ๆ จะมี light edge ไม่เกิน log n ทำให้จำนวน chain ที่ต้อง query น้อย
Implementation Sketch
int n;
std::vector<std::vector<int>> adj;
std::vector<int> parent, depth, heavy, head, pos, sz;
int cur_pos;
int dfs(int u) {
sz[u] = 1;
int max_sz = 0;
heavy[u] = -1;
for (int v : adj[u]) {
if (v == parent[u]) continue;
parent[v] = u;
depth[v] = depth[u] + 1;
int v_sz = dfs(v);
sz[u] += v_sz;
if (v_sz > max_sz) {
max_sz = v_sz;
heavy[u] = v;
}
}
return sz[u];
}
void decompose(int u, int h) {
head[u] = h;
pos[u] = cur_pos++;
if (heavy[u] != -1) decompose(heavy[u], h); // ต่อ chain
for (int v : adj[u]) {
if (v != parent[u] && v != heavy[u]) {
decompose(v, v); // เริ่ม chain ใหม่
}
}
}
// Path query จาก a ไป b (สมมติใช้ segment tree)
int query_path(int a, int b) {
int res = 0; // identity
while (head[a] != head[b]) {
if (depth[head[a]] < depth[head[b]]) std::swap(a, b);
// query chain [pos[head[a]], pos[a]]
res = combine(res, seg_query(pos[head[a]], pos[a]));
a = parent[head[a]];
}
if (depth[a] > depth[b]) std::swap(a, b);
// query ส่วนสุดท้าย [pos[a], pos[b]]
res = combine(res, seg_query(pos[a], pos[b]));
return res;
}Build: O(n), Query: O(log² n) — ใช้ segment tree
ในการแข่งขัน HLD มักใช้กับโจทย์ "path query" บน tree ที่ segment tree บนเส้นทางไม่ง่าย แต่บางครั้งมีวิธีที่ง่ายกว่า เช่น ใช้ Euler tour + BIT หรือ centroid decomposition — ควรพิจารณาทางเลือกอื่นก่อน
12.3 Mo's Algorithm
Mo's Algorithm เป็นเทคนิคตอบหลาย query แบบ offline โดยจัดลำดับ query ให้ "เคลื่อน pointer น้อย" เหมาะกับ query ที่ไม่สามารถตอบด้วย segment tree แต่สามารถ "เพิ่ม/ลบ element หนึ่งตัว" ได้ใน O(1) หรือ O(log n) เช่น "หาจำนวน distinct ในช่วง [l, r]"
หลักการ
1. แบ่ง array เป็น block ขนาด √n
2. เรียง query ตาม (block ของ l, r) — block เดียวกัน sort r ตาม parity
3. ไล่ query ตามลำดับ — ปรับ left/right pointer ทีละ 1
4. การเคลื่อน pointer รวมเป็น O(n √n)
Implementation: Distinct Elements in Range
struct Query {
int l, r, idx;
};
std::vector<int> mo_algorithm(std::vector<int>& arr,
std::vector<Query>& queries) {
int n = arr.size();
int block = std::max(1, (int)std::sqrt(n));
// sort queries
std::sort(queries.begin(), queries.end(),
[block](const Query& a, const Query& b) {
int ba = a.l / block, bb = b.l / block;
if (ba != bb) return ba < bb;
// parity optimization: even block sort r asc, odd desc
return (ba & 1) ? a.r > b.r : a.r < b.r;
});
std::vector<int> freq(*std::max_element(arr.begin(), arr.end()) + 1, 0);
std::vector<int> ans(queries.size());
int cur_l = 0, cur_r = -1, cur_distinct = 0;
auto add = [&](int x) {
if (freq[x] == 0) cur_distinct++;
freq[x]++;
};
auto remove = [&](int x) {
freq[x]--;
if (freq[x] == 0) cur_distinct--;
};
for (auto& q : queries) {
while (cur_l > q.l) add(arr[--cur_l]);
while (cur_r < q.r) add(arr[++cur_r]);
while (cur_l < q.l) remove(arr[cur_l++]);
while (cur_r > q.r) remove(arr[cur_r--]);
ans[q.idx] = cur_distinct;
}
return ans;
}Time: O((n + q) √n)
ใน sort query ถ้า block ของ l เป็นเลขคู่ ให้ sort r น้อยไปมาก ถ้า block เป็นเลขคี่ ให้ sort r มากไปน้อย — ทำให้หลังจบ block หนึ่ง r อยู่ท้าย ๆ แล้วเริ่ม block ถัดไปจากท้าย ไม่ต้องย้อนกลับ — เร็วขึ้น ~2x
12.4 Persistent Data Structures
Persistent Data Structure คือโครงสร้างที่ "เก็บประวัติ" — เมื่อ update จะสร้าง version ใหม่โดย version เก่ายังคงเข้าถึงได้ ทำได้โดยไม่ copy ทั้งโครงสร้าง แต่ share ส่วนที่ไม่เปลี่ยน เช่น persistent segment tree ใช้ memory O(log n) ต่อ update
Persistent Segment Tree
struct Node {
int val;
Node *left, *right;
Node(int v = 0) : val(v), left(nullptr), right(nullptr) {}
};
Node* build(const std::vector<int>& arr, int lo, int hi) {
Node* n = new Node;
if (lo == hi) { n->val = arr[lo]; return n; }
int mid = (lo + hi) / 2;
n->left = build(arr, lo, mid);
n->right = build(arr, mid + 1, hi);
n->val = n->left->val + n->right->val;
return n;
}
// Update: สร้าง path ใหม่เฉพาะส่วนที่เปลี่ยน
Node* update(Node* prev, int lo, int hi, int idx, int val) {
Node* n = new Node(*prev); // copy
if (lo == hi) { n->val = val; return n; }
int mid = (lo + hi) / 2;
if (idx <= mid) {
n->left = update(prev->left, lo, mid, idx, val);
} else {
n->right = update(prev->right, mid + 1, hi, idx, val);
}
n->val = n->left->val + n->right->val;
return n;
}
int query(Node* n, int lo, int hi, int l, int r) {
if (!n || r < lo || hi < l) return 0;
if (l <= lo && hi <= r) return n->val;
int mid = (lo + hi) / 2;
return query(n->left, lo, mid, l, r) +
query(n->right, mid + 1, hi, l, r);
}
// ตัวอย่างการใช้งาน
std::vector<Node*> versions;
versions.push_back(build(arr, 0, n - 1));
versions.push_back(update(versions[0], 0, n - 1, 3, 99));
// query version 0: ผลรวมใน [l, r]
query(versions[0], 0, n - 1, l, r);
// query version 1 หลัง update
query(versions[1], 0, n - 1, l, r);K-th smallest in range: โจทย์คลาสสิก หา element ที่เล็กที่ k ในช่วง [l, r] ของ array ใช้ persistent segment tree เก็บ frequency ของแต่ละค่า ในแต่ละ "version" (i = prefix [0..i]) จากนั้นนำ version l-1 และ version r มาลบกัน — ได้ frequency ของ [l, r] ที่สามารถ binary search หา k ได้
มี persistent version ของ DSU, Trie, BST แต่ segment tree เป็นที่นิยมที่สุดเพราะ implement ง่าย ใน C++ สามารถใช้ rope หรือเขียน custom allocator เพื่อจัดการ memory ได้ดีขึ้น
12.5 Centroid Decomposition
Centroid Decomposition เป็นเทคนิคแบ่ง tree ซ้ำ ๆ โดยเลือก "centroid" (node ที่ทำให้ทุก subtree ≤ n/2) recurse บนแต่ละ subtree — ทำให้ tree สูง O(log n) เหมาะกับ path query บน tree ที่ซับซ้อน
Centroid ของ tree คือ node c ที่ลบ c แล้ว ทุก connected component มีขนาด ≤ n/2 — centroid หาได้โดย DFS: เริ่มจาก root, ถ้ามี child ที่ size > n/2 ให้เลื่อนไป child นั้น มิฉะนั้น c คือ centroid
Implementation Sketch
std::vector<std::vector<int>> adj;
std::vector<bool> removed;
std::vector<int> sz;
int dfs_size(int u, int p) {
sz[u] = 1;
for (int v : adj[u]) {
if (v != p && !removed[v]) {
sz[u] += dfs_size(v, u);
}
}
return sz[u];
}
int find_centroid(int u, int p, int tree_size) {
for (int v : adj[u]) {
if (v != p && !removed[v] && sz[v] > tree_size / 2) {
return find_centroid(v, u, tree_size);
}
}
return u;
}
void decompose(int u) {
int tree_size = dfs_size(u, -1);
int c = find_centroid(u, -1, tree_size);
removed[c] = true;
// ประมวลผลที่ centroid c
// (เช่น นับ path ที่ผ่าน c)
// recurse บนแต่ละ component
for (int v : adj[c]) {
if (!removed[v]) decompose(v);
}
}Build: O(n log n) — แต่ละขั้น O(n), log n ขั้น
Count paths with sum k: นับจำนวน path ใน tree ที่ผลรวมของ edge weight = k
วิธี: ใช้ centroid decomposition ที่ centroid c, นับ path ที่ผ่าน c (combine สอง half) จากนั้น recurse บน subtree รวม O(n log n)
12.6 Splay Tree (Self-Adjusting BST)
Splay Tree เป็น BST ที่ "self-adjust" ทุกครั้งที่ access node, จะ "splay" ยก node นั้นขึ้น root ด้วย rotation หลาย ๆ ครั้ง — amortized O(log n) ต่อ operation ไม่ต้องเก็บข้อมูลเพิ่มเติม (เช่น height หรือ color) ทำให้ใช้ memory น้อย
Splay Operations
มี 3 กรณีของ splay (p = parent, g = grandparent):
- Zig: p เป็น root — rotate ครั้งเดียว
- Zig-Zig: x และ p เป็น child ฝั่งเดียวกัน — rotate p ก่อน, แล้ว rotate x
- Zig-Zag: x และ p เป็น child คนละฝั่ง — rotate x สองครั้ง
• amortized O(log n) ทุก operation
• recently accessed nodes อยู่ใกล้ root — exploit locality
• ใช้ใน Link-Cut Tree และทฤษฎีบางตัว
• แต่ละ operation อาจเป็น O(n) ใน worst case — แย่กว่า AVL แต่ amortized ดีกว่า
Splay tree มักไม่คุ้มที่จะ implement เอง เพราะ std::map ครอบคลุม use case ส่วนใหญ่ แต่ถ้าต้องการ "split" และ "merge" tree บ่อย ๆ — splay เป็นทางเลือกที่ดี หรือใช้ __gnu_pbds::tree ของ GCC
12.7 Link-Cut Tree
Link-Cut Tree เป็นโครงสร้างข้อมูลสำหรับ forest ของ tree รองรับ "link" (เชื่อม tree) และ "cut" (ตัด edge) และ query บน path ได้ O(log n) amortized — ใช้สำหรับ dynamic tree problem
link(u, v): เชื่อม edge (u, v) ถ้า u, v อยู่คนละ tree
cut(u, v): ลบ edge (u, v)
connected(u, v): ตรวจ u, v อยู่ tree เดียวกัน
query(u, v): หาค่าบน path u→v (เช่น min/max/sum)
ทั้งหมด amortized O(log n)
Link-Cut Tree ภายในใช้ "splay tree" เก็บ "preferred path" — path จาก root ไปยัง recently accessed node preferred path แบ่ง tree เป็น chains ที่จัดการผ่าน splay — ทำให้ operation เร็วขึ้น
Link-Cut Tree เป็นโครงสร้างที่ซับซ้อนมากที่สุดตัวหนึ่ง การ implement เองต้องระวัง bugs ในการแข่งขัน ใช้เฉพาะเมื่อโจทย์บังคับ — ถ้า query เป็น offline, HLD หรือ DSU อาจพอ
เคล็ดลับการเลือกโครงสร้าง
| ปัญหา | โครงสร้าง | Time |
|---|---|---|
| Path query บน static tree | HLD หรือ Binary Lifting | O(log²n) หรือ O(log n) |
| Path query บน dynamic tree | Link-Cut Tree | O(log n) amortized |
| Count path ที่ผ่าน node | Centroid Decomposition | O(n log n) build |
| LCA query หลายครั้ง | Binary Lifting | O(log n) per query |
| Path sum + update | HLD + Segment Tree | O(log²n) |
ตารางเลือกโครงสร้างสำหรับ path query บน tree
12.8 สรุปและแนวทางต่อ
เราได้ผ่านโครงสร้างข้อมูลตั้งแต่พื้นฐานจนถึงขั้นสูง ต่อไปนี้ควรจะสามารถเลือกโครงสร้างที่เหมาะสม กับปัญหาที่ต้องเผชิญได้ — ขอแนะนำให้ฝึกฝนผ่านปัญหาจริงบนเว็บเช่น Codeforces, LeetCode, AtCoder
แหล่งเรียนรู้เพิ่มเติม
- CP-Algorithms (cp-algorithms.com) — คู่มือครอบคลุม competitive programming
- USACO Guide — โครงสร้างตามระดับ
- Codeforces EDU — วิดีโอสอน interactive
- The Algorithm Design Codebook (Halim) — หนังสือคลาสสิก
- Introduction to Algorithms (CLRS) — พื้นฐานวิชาการ
- Algorithms (Jeff Erickson) — ฟรี, เน้นเข้าใจ
เคล็ดลับการแข่งขัน
- เริ่มจาก brute force เสมอ แล้วค่อย optimize
- รู้จัก STL/C++ ให้แม่น — ลดเวลา implement
- ฝึก "type fast" — แต่ไม่เสียความถูกต้อง
- อ่าน problem statement ละเอียด — จับ keyword สำคัญ
- เวลาทดสอบ: ลอง brute force เทียบผล
- อย่ายืดกับโจทย์เดียวนานเกิน 30 นาที
- หลังแข่ง — read editorial และฝึกโจทย์ที่พลาด
การเข้าใจ "ทำไม" สำคัญกว่า "จำ" พยายามเข้าใจ intuition ของแต่ละโครงสร้าง — ทำไม DSU ต้อง path compression? ทำไม segment tree สูง log n? — ความเข้าใจนี้จะช่วยให้แก้ปัญหาใหม่ ๆ ได้
หวังว่าหนังสือเล่มนี้จะเป็นพื้นฐานที่ดี สำหรับการเดินทางสู่โลกของ algorithms และ data structures — ขอให้สนุกกับการเรียนรู้ต่อไป