หลัง partition pivot อยู่ตำแหน่งที่ถูกต้องทันที
ค่าซ้าย pivot <= pivot, ค่าขวา pivot >= pivot — pivot ไม่ต้องถูกขยับอีก
Advanced Sorting
เรียนรู้ Quick Sort ตั้งแต่แนวคิด pivot, partitioning ไปจนถึง implementation พร้อมตัวอย่างและแบบฝึกหัด
Quick Sort คืออัลกอริทึมเรียงข้อมูลแบบ Divide and Conquer ที่ทำงานโดยเลือกค่าหนึ่งมาเป็นตัวอ้างอิง (pivot) แล้วจัดระเบียบให้ค่าที่น้อยกว่า pivot อยู่ฝั่งซ้าย และค่าที่มากกว่าหรือเท่ากับอยู่ฝั่งขวา — กระบวนการนี้เรียกว่า partition
หลัง partition เสร็จ pivot จะอยู่ในตำแหน่งสุดท้ายที่ถูกต้องของมันทันที — จากนั้นเรียกซ้ำ (recursive) กับฝั่งซ้ายที่เล็กกว่าและฝั่งขวาที่ใหญ่กว่าจนครบทั้งอาเรย์
หัวใจของ Quick Sort
เลือก pivot → partition ข้อมูลรอบ pivot → เรียกซ้ำกับซ้ายและขวา — ทำซ้ำจนแต่ละช่วงเหลือ 0 หรือ 1 ตัว = เรียงแล้ว
Quick Sort แยกเป็น 3 ส่วนที่ทำงานสอดประสานกัน: pivot (ค่าอ้างอิง), partition (จัดสมาชิกรอบ pivot), และ recursion (เรียกฟังก์ชันเดิมกับช่วงย่อยที่ยังไม่เรียง)
partition เสร็จ = pivot อยู่ตำแหน่งถูกต้องตลอดกาล
จุดเด่นที่ต่างจาก Merge Sort: หลัง partition 1 รอบ pivot จะอยู่ตำแหน่งสุดท้ายที่ถูกต้องของมัน — เราจะไม่ยุ่งกับ pivot ตัวนั้นอีกเลย — แบ่งช่วงซ้าย/ขวาให้เล็กลงแล้วจัดการต่อ
3 ข้อที่ต้องเข้าใจก่อนเขียนโค้ด
ค่าซ้าย pivot <= pivot, ค่าขวา pivot >= pivot — pivot ไม่ต้องถูกขยับอีก
quickSort(arr, low, pivotIndex-1) และ quickSort(arr, pivotIndex+1, high) — base case คือ low >= high (ช่วงว่างหรือ 1 ตัว)
ถ้า pivot แบ่งไม่สมดุลซ้ำ ๆ (เช่น ข้อมูลเรียงแล้ว + pivot ตัวท้าย) — call stack ลึกถึง n ชั้น แต่ละชั้น scan เกือบทั้งอาเรย์ = O(n²)
Pivot คือค่าในอาเรย์ที่เราหยิบขึ้นมาเป็น "เส้นแบ่ง" — ค่าที่น้อยกว่า pivot จะถูกย้ายไปทางซ้าย ค่าที่มากกว่าหรือเท่ากับจะถูกย้ายไปทางขวา คุณภาพของ pivot เป็นตัวกำหนดประสิทธิภาพทั้งอัลกอริทึม
แผนภาพแสดงตำแหน่ง pivot — ตัวแรก ตัวท้าย หรือค่ากลาง
ในทางปฏิบัติ library ส่วนใหญ่ใช้ median-of-three (เลือก 3 ค่า: ตัวแรก กลาง ท้าย แล้วเอาค่ามัธยฐาน) หรือ random pivot — เพราะลดความเสี่ยงจากข้อมูลที่มีรูปแบบทำให้เกิด worst case ได้ดี
Partition คือหัวใจที่สำคัญที่สุดของ Quick Sort — เป็นขั้นตอนการจัดเรียงสมาชิกในอาเรย์ให้ค่าที่น้อยกว่า pivot อยู่ด้านซ้าย และค่าที่มากกว่าหรือเท่ากับ pivot อยู่ด้านขวา หลัง partition เสร็จ pivot จะถูกวางในตำแหน่งที่ถูกต้องของมัน
ในตัวอย่างของเราใช้ pivot เป็นตัวท้ายของช่วง (Lomuto partition scheme): 1. ตั้ง i = low - 1 (ตัวชี้ขอบเขตของ "ฝั่งน้อยกว่า pivot") 2. ใช้ j วิ่งจาก low ถึง high-1 — ถ้า arr[j] <= pivot ให้ขยายฝั่งซ้าย (i++) แล้วสลับ arr[i] กับ arr[j] 3. หลังวนจบ: สลับ arr[i+1] กับ arr[high] (ตัว pivot) — pivot จะอยู่ในตำแหน่ง i+1 ซึ่งถูกต้องแล้ว
ภาพการแบ่งข้อมูลรอบ pivot — ซ้ายน้อยกว่า ขวามากกว่า
ภาพ step-by-step การ partition — เปรียบเทียบและสลับทีละตำแหน่ง
i คือเส้นแบ่ง — j คือลูกเสือตรวจการณ์
จำง่าย: i = ขอบเขตของ "โซนน้อยกว่า pivot" (เริ่มที่ low-1), j = ตัวที่กำลังตรวจ (วิ่งจาก low ถึง high-1) — ถ้า arr[j] <= pivot → ขยายโซน i++ แล้วสลับ arr[i] กับ arr[j]
ดูการทำงานของ partition แบบ step-by-step กับอาเรย์ [10, 7, 8, 9, 1, 5] ที่เลือก pivot = 5 (ตัวท้าย) — สังเกตการเปลี่ยนแปลงของ i, j, arr ทุกครั้งที่เข้าเงื่อนไข arr[j] <= pivot
ผลลัพธ์: อาเรย์กลายเป็น [1, 5, 8, 9, 10, 7] — 5 อยู่ index 1 พอดี โดยซ้ายมีแค่ 1 (น้อยกว่า 5) และขวามี 8,9,10,7 (มากกว่า 5) — จากนั้น Quick Sort จะเรียกซ้ำกับฝั่งซ้าย [0..0] (1 ตัว = เรียงแล้ว) และฝั่งขวา [2..5] (ต้อง partition ต่อ)
ต่อจาก partition รอบแรกที่ pivot=5 ได้อาเรย์ [1, 5, 8, 9, 10, 7] — ฝั่งซ้าย [1] มีสมาชิกเดี่ยว = จบทันที (base case), ฝั่งขวา [8, 9, 10, 7] ต้อง partition ต่ออีกหลายรอบ
รอบที่ 2 (ฝั่งขวา): pivot = 7 หลัง partition → [7, 8, 10, 9] — pivot=7 อยู่ index 2 → ฝั่งซ้ายว่าง (จบ) → ฝั่งขวา [8,10,9] partition ต่อ รอบที่ 3: pivot = 9 → [8, 9, 10] — pivot=9 อยู่ index 5 → ฝั่งซ้าย [8] จบ → ฝั่งขวา [10] จบ ผลลัพธ์สุดท้าย: [1, 5, 7, 8, 9, 10]
ภาพการเรียกซ้ำฝั่งซ้ายและฝั่งขวา — partition ไปจนครบทุกช่วง
โครงคิดหลัก — quickSort เรียก partition แล้วเรียกตัวเองกับซ้าย/ขวา
ฟังก์ชัน quickSort(arr, low, high):
ถ้า low >= high:
คืนค่า
pivotIndex = partition(arr, low, high)
quickSort(arr, low, pivotIndex - 1)
quickSort(arr, pivotIndex + 1, high)
ฟังก์ชัน partition(arr, low, high):
pivot = arr[high] // เลือกตัวท้ายเป็น pivot
i = low - 1 // ขอบเขตของโซน "น้อยกว่า pivot"
ทำซ้ำ j จาก low ถึง high - 1:
ถ้า arr[j] <= pivot:
i = i + 1
สลับ arr[i] กับ arr[j]
สลับ arr[i + 1] กับ arr[high] // วาง pivot ในตำแหน่งที่ถูกต้อง
คืนค่า i + 1ใช้ Lomuto partition — quickSort ทำ shallow copy เพื่อไม่เปลี่ยนอาเรย์ต้นฉบับ แล้วเรียก quickSortInPlace
function partition(arr, low, high) {
const pivot = arr[high];
let i = low - 1;
for (let j = low; j < high; j += 1) {
if (arr[j] <= pivot) {
i += 1;
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
[arr[i + 1], arr[high]] = [arr[high], arr[i + 1]];
return i + 1;
}
function quickSortInPlace(arr, low = 0, high = arr.length - 1) {
if (low >= high) {
return arr;
}
const pivotIndex = partition(arr, low, high);
quickSortInPlace(arr, low, pivotIndex - 1);
quickSortInPlace(arr, pivotIndex + 1, high);
return arr;
}
function quickSort(values) {
const arr = [...values];
return quickSortInPlace(arr);
}
console.log(quickSort([10, 7, 8, 9, 1, 5]));
// [1, 5, 7, 8, 9, 10]โครงสร้าง 3 ฟังก์ชัน: `quickSort` (public API — shallow copy + ป้องกัน side effect), `quickSortInPlace` (recursive entry — base case + partition + recursive calls), `partition` (หัวใจ — จัดสมาชิกรอบ pivot แบบ in-place)
| กรณี | Time Complexity | สาเหตุ |
|---|---|---|
| Best | O(n log n) | pivot แบ่งอาเรย์ได้ครึ่งซ้าย/ขวาใกล้เคียงกันทุกรอบ — partition n ตัวต่อชั้น × log n ชั้น |
| Average | O(n log n) | ในทางปฏิบัติ pivot มักแบ่งค่อนข้างสมดุล — ค่าเฉลี่ยเร็วกว่า Merge Sort จาก cache-locality |
| Worst | O(n²) | pivot แบ่งไม่สมดุลซ้ำ ๆ เช่น ข้อมูลเรียงแล้ว + pivot ตัวท้าย → ฝั่งหนึ่งว่าง อีกฝั่งเกือบเต็ม |
Space Complexity: Quick Sort แบบ in-place ใช้พื้นที่เสริมแค่ call stack ของ recursion — เฉลี่ย O(log n) (ต้นไม้สมดุล) แต่กรณีแย่สุดอาจลึกถึง O(n) (ต้นไม้ไม่สมดุลสุดขีด) — ดีกว่า Merge Sort ที่ต้องใช้ O(n) เสริมแน่ ๆ
ระวัง Worst Case O(n²)
กรณีที่เข้าใจง่ายสุด: ข้อมูลเรียงจากน้อยไปมากอยู่แล้ว `[1,2,3,4,5]` + เราเลือก pivot เป็นตัวท้ายตลอด → รอบแรกเจอ pivot=5 partition ให้ฝั่งซ้ายยาว 4 ตัว ฝั่งขวาว่าง → รอบต่อไปเจอ pivot=4 partition ให้ซ้าย 3 ตัว → รวมเรียกซ้ำ n รอบ แต่ละรอบ scan เกือบ n ตัว = ≈ n²/2 = O(n²)
ประสิทธิภาพของ Quick Sort ขึ้นกับการเลือก pivot แทบทั้งหมด — เลือกดี (แบ่งสมดุล) → เร็วระดับ O(n log n) — เลือกไม่ดีซ้ำ ๆ → ตกเป็น O(n²) — มาดูกลยุทธ์ที่ใช้ในทางปฏิบัติ
In practice: Random pivot หรือ Median-of-Three → โอกาสเจอ worst case ต่ำมาก
ในการแข่งขันจริงหรือระบบ production แทบไม่มีใครใช้ pivot ตัวท้ายเปล่า ๆ — ใช้ random หรือ median-of-three แทบทั้งสิ้น — เพื่อให้ Quick Sort ทำงานที่ O(n log n) โดยเฉลี่ยสำหรับข้อมูลทุกรูปแบบ
| คุณสมบัติ | Merge Sort | Quick Sort |
|---|---|---|
| Time Average | O(n log n) | O(n log n) |
| Time Worst | O(n log n) | O(n²) |
| Space | O(n) | O(log n) — in-place |
| Stable | ใช่ (standard) | ไม่ (basic) |
| In-place | ไม่ใช่ | ใช่ |
| เหมาะกับ linked list | ดีมาก | ไม่เหมาะ (ต้อง random access) |
| เหมาะกับอาเรย์ | ดี — แต่ใช้พื้นที่เสริม | ดีมาก — cache-friendly |
| Worst case เกิดเมื่อ | ไม่เกิด | pivot แบ่งไม่สมดุลซ้ำ ๆ |
เลือก Quick Sort เมื่อต้องการ in-place, ข้อมูลอยู่ในอาเรย์, และยอมรับ worst case ได้ (หรือใช้ random/median-of-three pivot) — เลือก Merge Sort เมื่อต้องการ stable sort, คาดเดาเวลาได้แน่นอน, หรือเรียง linked list
ทดสอบความเข้าใจก่อนเริ่มทำแบบฝึกหัด
เริ่มจากแกนของ Quick Sort — ฟังก์ชัน partitionByPivot ที่รับอาเรย์กับ pivot แล้วแยกค่าออกเป็นฝั่งน้อยกว่า (less) และฝั่งมากกว่าหรือเท่ากับ (greaterOrEqual)
ใช้ partitionByPivot จาก Lab 01 มาสร้าง quickSort เต็มรูปแบบ — base case + เลือก pivot + partition + เรียกซ้ำกับซ้าย/ขวา
ประยุกต์ Quick Sort เพื่อเรียงจากมากไปน้อย — แค่สลับฝั่งในขั้น partition และตอนประกอบผลลัพธ์