Bài 2 chúng ta đã so sánh Dijkstra và A* qua số node phải khám phá. Bài này áp dụng đúng tư duy đó cho một bài toán quen thuộc hơn nhiều: sắp xếp mảng — nhưng lần này đua trực tiếp 4 giải thuật sắp xếp kinh điển cạnh nhau để xem thuật toán nào thực sự nhanh hơn, và tại sao.

1. Vì sao có nhiều giải thuật sắp xếp đến vậy?

Không có một giải thuật sắp xếp nào "tốt nhất tuyệt đối" — mỗi giải thuật đánh đổi giữa độ phức tạp thời gian, bộ nhớ phụ trợ, và tính ổn định (stability — giữ nguyên thứ tự tương đối của các phần tử bằng nhau). Phần lớn giải thuật sắp xếp dựa trên so sánh (comparison-based) đều bị chặn dưới ở $\Omega(n \log n)$ trong trường hợp trung bình — không thể nhanh hơn về mặt lý thuyết. Nhưng vẫn có ngoại lệ, như Radix Sort ở phần 4.

ℹ️ Lưu ý: Vì sao tính ổn định (stability) quan trọng?
Một giải thuật ổn định giữ nguyên thứ tự tương đối của các phần tử có giá trị bằng nhau. Điều này cực kỳ quan trọng khi sắp xếp theo nhiều tiêu chí — ví dụ sắp xếp danh sách học sinh theo điểm, rồi sắp lại theo lớp: nếu bước sắp theo lớp không ổn định, thứ tự điểm đã sắp trước đó trong cùng lớp sẽ bị xáo trộn ngẫu nhiên.

2. Quick Sort: Chia để trị bằng Pivot

Quick Sort (Tony Hoare, 1959) chọn một phần tử làm pivot, chia mảng thành 2 phần — nhỏ hơn pivot và lớn hơn pivot — rồi đệ quy sắp xếp từng phần. Với sơ đồ phân hoạch Lomuto dưới đây, pivot luôn là phần tử cuối cùng:

quick-sort.js
function quickSort(arr, lo = 0, hi = arr.length - 1) {
  if (lo >= hi) return;
  const pivot = arr[hi];
  let i = lo - 1;
  for (let j = lo; j < hi; j++) {
    if (arr[j] < pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]]; // hoán đổi
    }
  }
  [arr[i + 1], arr[hi]] = [arr[hi], arr[i + 1]]; // đặt pivot vào đúng vị trí
  quickSort(arr, lo, i);      // đệ quy nửa nhỏ hơn pivot
  quickSort(arr, i + 2, hi);  // đệ quy nửa lớn hơn pivot
}

Trung bình Quick Sort đạt $O(n \log n)$ và thường nhanh nhất trong thực tế nhờ locality bộ nhớ tốt (in-place, ít cấp phát mảng phụ). Nhưng nó không ổn định và có trường hợp xấu nhất $O(n^2)$.

🕳️ Cạm bẫy thường gặp: Quick Sort chậm như O(n²) trên mảng đã sắp
Nếu luôn chọn pivot là phần tử cuối cùng (như code trên) và mảng đầu vào đã sắp sẵn (hoặc gần như đã sắp), mỗi lần phân hoạch chỉ tách được 1 phần tử — biến đệ quy $O(\log n)$ cấp thành $O(n)$ cấp, tổng thời gian rơi về $O(n^2)$. Cách khắc phục thực tế: chọn pivot ngẫu nhiên hoặc median-of-three (giữa đầu/giữa/cuối) để tránh trường hợp xấu nhất có thể đoán trước.

3. Merge Sort: Ổn định nhờ chia đôi đệ quy

Merge Sort (John von Neumann, 1945) chia mảng làm đôi liên tục tới khi còn 1 phần tử, rồi hợp nhất (merge) từng cặp mảng con đã sắp lại với nhau. Vì luôn ưu tiên lấy phần tử từ mảng trái khi bằng nhau, Merge Sort ổn định tuyệt đối:

merge-sort.js
function mergeSort(arr) {
  if (arr.length <= 1) return arr;
  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));
  return merge(left, right);
}

function merge(left, right) {
  const result = [];
  let i = 0, j = 0;
  while (i < left.length && j < right.length) {
    // "<=" (không phải "<") giữ ổn định: ưu tiên bên trái khi bằng nhau
    if (left[i] <= right[j]) result.push(left[i++]);
    else result.push(right[j++]);
  }
  return result.concat(left.slice(i), right.slice(j));
}

Merge Sort luôn đạt $O(n \log n)$ ở mọi trường hợp (không có worst case tệ như Quick Sort), nhưng cần thêm bộ nhớ phụ $O(n)$ vì không sắp xếp tại chỗ (in-place).

4. Heap Sort: Sắp xếp tại chỗ bằng Binary Heap

Heap Sort xây một max-heap từ mảng (phần tử lớn nhất luôn ở gốc), rồi liên tục lấy gốc ra và đưa xuống cuối mảng, "heapify" lại phần còn lại. Đây là cách kết hợp ưu điểm $O(n \log n)$ đảm bảo của Merge Sort với bộ nhớ $O(1)$ tại chỗ của Quick Sort:

heap-sort.js
function heapSort(arr) {
  const n = arr.length;
  // Bước 1: Xây max-heap từ dưới lên
  for (let i = Math.floor(n / 2) - 1; i >= 0; i--) heapify(arr, n, i);

  // Bước 2: Lấy gốc (max) ra cuối, thu nhỏ heap, heapify lại
  for (let end = n - 1; end > 0; end--) {
    [arr[0], arr[end]] = [arr[end], arr[0]];
    heapify(arr, end, 0);
  }
}

function heapify(arr, size, root) {
  let largest = root;
  const left = 2 * root + 1, right = 2 * root + 2;
  if (left < size && arr[left] > arr[largest]) largest = left;
  if (right < size && arr[right] > arr[largest]) largest = right;
  if (largest !== root) {
    [arr[root], arr[largest]] = [arr[largest], arr[root]];
    heapify(arr, size, largest); // heapify tiếp xuống nhánh vừa đổi
  }
}
🔬 Đào sâu: Vì sao Radix Sort "phá vỡ" giới hạn O(n log n)?
Quick/Merge/Heap Sort đều là comparison-based (chỉ biết so sánh lớn/nhỏ giữa 2 phần tử) — về mặt lý thuyết cây quyết định so sánh cho $n$ phần tử có tối thiểu $\log_2(n!) \approx n\log_2 n$ tầng, nên không thể nhanh hơn $O(n \log n)$. Radix Sort không so sánh giá trị trực tiếp — nó phân loại số theo từng chữ số (digit) bằng counting sort, đạt $O(d \cdot (n + k))$ với $d$ là số chữ số và $k$ là cơ số (thường 10). Khi $d$ và $k$ là hằng số, đây thực chất là $O(n)$ — nhanh hơn giới hạn lý thuyết của sort so sánh!

5. Radix Sort: Sắp xếp không so sánh theo từng chữ số

Radix Sort (LSD — Least Significant Digit) sắp xếp số nguyên bằng cách phân nhóm (bucket) lần lượt theo chữ số hàng đơn vị, hàng chục, hàng trăm... Mỗi lượt dùng counting sort ổn định để không phá vỡ thứ tự đã sắp ở các chữ số thấp hơn trước đó:

radix-sort.js
function radixSort(arr) {
  const max = Math.max(...arr);
  for (let exp = 1; Math.floor(max / exp) > 0; exp *= 10) {
    countingSortByDigit(arr, exp);
  }
}

function countingSortByDigit(arr, exp) {
  const n = arr.length;
  const output = new Array(n);
  const count = new Array(10).fill(0);

  for (let i = 0; i < n; i++) count[Math.floor(arr[i] / exp) % 10]++;
  for (let i = 1; i < 10; i++) count[i] += count[i - 1]; // cộng dồn vị trí

  // Duyệt NGƯỢC để giữ ổn định (stable)
  for (let i = n - 1; i >= 0; i--) {
    const digit = Math.floor(arr[i] / exp) % 10;
    output[--count[digit]] = arr[i];
  }
  for (let i = 0; i < n; i++) arr[i] = output[i];
}

6. So sánh 4 giải thuật sắp xếp

Giải thuật Tốt nhất / Trung bình / Xấu nhất Bộ nhớ phụ Ổn định?
Quick Sort $O(n\log n)$ / $O(n\log n)$ / $O(n^2)$ $O(\log n)$ (đệ quy) Không
Merge Sort $O(n\log n)$ / $O(n\log n)$ / $O(n\log n)$ $O(n)$
Heap Sort $O(n\log n)$ / $O(n\log n)$ / $O(n\log n)$ $O(1)$ Không
Radix Sort $O(n)$ / $O(n)$ / $O(n)$ (với d, k hằng số) $O(n + k)$
💡 Mẹo: Chọn giải thuật nào trong thực tế?
Đa số ngôn ngữ hiện đại (V8/JavaScript Array.prototype.sort, Python Timsort) dùng thuật toán lai (hybrid) kết hợp Merge Sort + Insertion Sort cho mảng nhỏ. Tự cài Quick Sort khi cần tốc độ trung bình cao và không quan tâm ổn định. Dùng Merge Sort khi bắt buộc ổn định (sắp nhiều tiêu chí). Dùng Heap Sort khi bộ nhớ cực kỳ hạn chế. Dùng Radix Sort khi dữ liệu là số nguyên/chuỗi có độ dài giới hạn — nhanh hơn hẳn mọi sort so sánh.

Sân chơi tương tác: Sorting Race Visualizer

Bấm "Chạy đua" để xem cả 4 giải thuật sắp xếp cùng một mảng ngẫu nhiên song song — quan sát số phép so sánh/hoán đổi và xem thuật toán nào "về đích" trước.

🏁 Sân chơi tương tác: Sorting Race

Điều khiển

Thứ hạng về đích

Quick Sort

So sánh: 0 · Hoán đổi: 0

Merge Sort

So sánh: 0 · Ghi: 0

Heap Sort

So sánh: 0 · Hoán đổi: 0

Radix Sort

Lượt duyệt: 0 · Ghi: 0
sorting-race-live.js

Trắc nghiệm ôn tập

Câu 1: Vì sao Quick Sort có thể chậm tới O(n²) trên một mảng đã gần như sắp sẵn, nếu luôn chọn pivot là phần tử cuối cùng?

Trắc nghiệm ôn tập

Câu 2: Radix Sort đạt được O(n) trong khi mọi giải thuật sắp xếp dựa trên so sánh đều bị chặn dưới ở O(n log n). Vì sao Radix Sort "phá vỡ" được giới hạn này?

Trắc nghiệm ôn tập

Câu 3: Bạn cần sắp xếp danh sách 1 triệu đơn hàng theo ngày đặt, sau đó sắp lại theo mã khách hàng mà vẫn muốn các đơn hàng cùng khách hàng giữ nguyên thứ tự ngày đã sắp trước đó. Giải thuật nào phù hợp cho bước sắp thứ hai?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 4: Trie — Cấu Trúc Từ Điển Bài 2: Pathfinding Dijkstra & A* Quay lại Lộ trình Series DSA Series C: Cấu Trúc Dữ Liệu & Độ Phức Tạp Big-O