Counting Sort TypeScript: Thuật toán sắp xếp O(n+k)
Làm sao để sắp xếp một mảng số nguyên cực nhanh mà không cần so sánh từng phần tử? Counting Sort chính là câu trả lời! Khác với Quick Sort hay Merge Sort, Counting Sort đếm tần suất xuất hiện của từng phần tử và đạt độ phức tạp O(n+k) - nhanh hơn nhiều so với các thuật toán so sánh O(n log n).
Trong thực tế, Counting Sort được dùng rộng rãi để sắp xếp dữ liệu có phạm vi giá trị hạn chế: điểm thi (0-100), độ tuổi (0-120), mã màu RGB (0-255). Bài viết này sẽ hướng dẫn bạn cách hoạt động, implement trong TypeScript, và khi nào nên áp dụng thuật toán này.
#1. Counting Sort là gì?
Counting Sort (thuật toán sắp xếp đếm) là một thuật toán sắp xếp không dựa trên so sánh (non-comparison based sorting). Thay vì so sánh các phần tử với nhau, thuật toán này đếm số lần xuất hiện của mỗi giá trị và sử dụng thông tin đó để xác định vị trí chính xác của từng phần tử trong mảng đã sắp xếp.
#1.1. Đặc điểm chính
- Không so sánh: Không cần so sánh giá trị giữa các phần tử
- Sắp xếp ổn định (stable): Giữ nguyên thứ tự tương đối của các phần tử có giá trị bằng nhau
- Hiệu quả với phạm vi nhỏ: Hoạt động tốt nhất khi phạm vi giá trị (k) không quá lớn so với số phần tử (n)
- Sử dụng bộ nhớ phụ: Cần thêm không gian để lưu mảng đếm
#2. Cách hoạt động của Counting Sort
#2.1. Tìm giá trị min và max
Đầu tiên, ta cần xác định phạm vi giá trị của mảng để biết kích thước mảng đếm cần tạo.
1 | let min = numbers[0]; |
Giải thích:
- Duyệt qua mảng một lần để tìm giá trị nhỏ nhất và lớn nhất
- Độ phức tạp: O(n)
#2.2. Tạo mảng đếm và tính tần suất
1 | const countArrSize = max - min + 1; |
Giải thích:
- Kích thước mảng đếm =
max - min + 1(bao gồm cả min và max) - Sử dụng
num - minlàm index để xử lý trường hợp mảng không bắt đầu từ 0 countArray[i]lưu số lần xuất hiện của giá trịi + min
#Lưu ý quan trọng
Nếu không dùng num - min mà chỉ dùng num làm index, code sẽ lỗi khi mảng chứa số âm:
1 | // ❌ SAI - không xử lý số âm |
Ví dụ:
1 | // Input: [11, 13, 12, 11, 15, 12] |
#2.3. Tính tổng tích lũy
1 | for (let i = 1; i < countArray.length; i++) { |
Giải thích:
- Mỗi phần tử trong countArray bây giờ chứa số lượng phần tử nhỏ hơn hoặc bằng giá trị tương ứng
- Giá trị này cho biết vị trí cuối cùng của phần tử trong mảng đã sắp xếp
Ví dụ tiếp theo:
1 | // Sau bước 2: countArray = [2, 2, 1, 0, 1] |
#2.4. Xây dựng mảng kết quả
1 | const output = Array.from<number>({ length: n }).fill(0); |
Giải thích:
- Duyệt từ cuối về đầu để đảm bảo tính ổn định (stable)
- Với mỗi phần tử
numbers[i]:- Tìm vị trí trong countArray:
numbers[i] - min - Lấy vị trí đặt phần tử:
countArray[countArrayIndex] - 1(trừ 1 vì index bắt đầu từ 0) - Đặt phần tử vào output
- Giảm countArray đi 1 (để phần tử trùng giá trị tiếp theo được đặt ở vị trí trước đó)
- Tìm vị trí trong countArray:
Tại sao phải duyệt ngược? Nếu duyệt từ đầu về cuối, thứ tự các phần tử trùng giá trị sẽ bị đảo ngược, làm mất tính stable của thuật toán.
#3. Implementation đầy đủ trong TypeScript
1 | function countingSort(numbers: number[]): number[] { |
#3.1. Ví dụ mở rộng với log chi tiết
1 | function countingSortWithLog(numbers: number[]): number[] { |
Kết quả:
1 | Input: [4, 2, 2, 8, 3, 3, 1] |
#4. Phân tích độ phức tạp
#4.1. Time Complexity (Độ phức tạp thời gian)
| Trường hợp | Độ phức tạp |
|---|---|
| Best case | O(n + k) |
| Average case | O(n + k) |
| Worst case | O(n + k) |
Trong đó:
- n: Số phần tử trong mảng
- k: Phạm vi giá trị (max - min + 1)
Giải thích:
- Tìm min/max: O(n)
- Đếm tần suất: O(n)
- Tính tổng tích lũy: O(k)
- Xây dựng output: O(n)
- Tổng: O(n + k)
#4.2. Space Complexity (Độ phức tạp không gian)
O(n + k) - cần:
- Mảng đếm: O(k)
- Mảng kết quả: O(n)
#Lưu ý quan trọng
Khi k >> n (phạm vi giá trị lớn hơn nhiều so với số phần tử), Counting Sort trở nên không hiệu quả:
1 | // ❌ Không nên dùng Counting Sort |
#5. So sánh với các thuật toán khác
| Thuật toán | Time Complexity | Space | Stable | Khi nào dùng |
|---|---|---|---|---|
| Counting Sort | O(n + k) | O(n + k) | Yes | k nhỏ, số nguyên |
| Quick Sort | O(n log n) | O(log n) | No | Đa dụng |
| Merge Sort | O(n log n) | O(n) | Yes | Cần stable sort |
| Bubble Sort | O(n²) | O(1) | Yes | Mảng nhỏ/học tập |
Ưu điểm của Counting Sort:
- Nhanh hơn các thuật toán so sánh khi k không quá lớn
- Stable - giữ nguyên thứ tự tương đối
- Dễ hiểu và implement
Nhược điểm:
- Chỉ hoạt động với số nguyên hoặc có thể map về số nguyên
- Tốn bộ nhớ khi phạm vi giá trị lớn
- Không hiệu quả khi k >> n
#6. Ứng dụng thực tế
#6.1. Sắp xếp điểm số
1 | interface Student { |
#6.2. Sắp xếp độ tuổi
1 | function sortByAge(ages: number[]): number[] { |
Giải thích:
- Phương pháp đơn giản hơn khi không cần giữ stable
- Chỉ cần đếm tần suất rồi rebuild mảng theo thứ tự
#7. Khi nào nên và không nên dùng Counting Sort
#7.1. Nên dùng khi:
✅ Phạm vi giá trị nhỏ và biết trước
1 | // Điểm số 0-100 |
✅ Cần sắp xếp stable - giữ nguyên thứ tự tương đối của các phần tử trùng giá trị
✅ Dataset lớn với range nhỏ - k ≈ n hoặc k << n
#7.2. Không nên dùng khi:
❌ Phạm vi giá trị quá lớn
1 | const arr = [1, 1000000]; // Range = 999,999 cho 2 phần tử |
❌ Dữ liệu không phải số nguyên (hoặc không map được về số nguyên)
1 | const floats = [3.14, 2.71, 1.41]; // Counting Sort không áp dụng |
❌ Bộ nhớ hạn chế - Counting Sort cần O(n + k) bộ nhớ phụ
Vậy là bạn đã nắm vững Counting Sort - từ cách hoạt động, implementation cho đến khi nào nên áp dụng. Với độ phức tạp O(n + k), Counting Sort vượt trội các thuật toán so sánh O(n log n) khi điều kiện phù hợp. Trong thực tế, hãy kiểm tra phạm vi giá trị trước khi quyết định dùng Counting Sort, và cân nhắc fallback sang Quick Sort hoặc Merge Sort nếu range quá lớn. Hãy thử áp dụng vào dự án của bạn - sắp xếp điểm thi, xếp hạng người dùng theo tuổi, hay bất kỳ dataset nào có phạm vi giá trị hạn chế!