Bài 4 · Vận dụng · 26 phút
Sắp xếp & tìm kiếm
Biên soạn bởi Nguyễn Anh Tuấn
Sắp xếp nổi bọt, chèn, trộn (merge sort), nhanh (quicksort) - vì sao O(n²) khác O(n log n); rồi tìm nhị phân (binary search) trên dãy đã sắp.
Ở bài Độ phức tạp (Big-O), mèo con thấy tìm nhị phân nhanh hơn tìm tuyến tính cả trăm lần - nhưng có một điều kiện: dãy phải được sắp xếp trước. Sắp xếp là bước chuẩn bị đứng sau vô số việc: tìm nhanh, hiển thị có thứ tự, gộp dữ liệu, phát hiện trùng lặp.
Vấn đề là: sắp xếp cũng tốn chi phí, và cách sắp khác nhau thì đắt rẻ khác nhau rất xa. Bài này gặp bốn thuật toán kinh điển, chia làm hai nhóm: nhóm ngây thơ O(n²) và nhóm chia để trị O(n log n).
- ▸Sắp xếp là bước chuẩn bị cho tìm kiếm nhanh và nhiều việc khác.
- ▸Sắp một lần O(n log n), sau đó tìm nhị phân O(log n) cho mỗi truy vấn.
- ▸Các thuật toán sắp xếp khác nhau ở chi phí: O(n²) so với O(n log n).
Sắp xếp nổi bọt (bubble sort): đi từ trái sang phải, gặp cặp kề nhau nào sai thứ tự thì đổi chỗ; lặp đi lặp lại tới khi không còn cặp nào sai. Phần tử lớn cứ thế “nổi” dần về cuối như bọt khí.
noi-bot.ts · đổi chỗ cặp kề nhau cho tới khi xuôi
function noiBot(a: number[]): number[] {
const arr = [...a];
for (let i = 0; i < arr.length - 1; i++)
for (let j = 0; j < arr.length - 1 - i; j++)
if (arr[j] > arr[j + 1]) [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
return arr;
}
console.log(noiBot([5, 2, 8, 1, 9])); Kết quả khi chạy
[ 1, 2, 5, 8, 9 ]
Hai vòng lặp lồng nhau cho ta ngay O(n²) (đúng kiểu đã học ở bài Big-O). Sắp xếp chèn (insertion sort) cũng O(n²) nhưng có điểm hay: trên dãy gần như đã sắp, nó chỉ tốn gần O(n) vì hầu như không phải dời gì.
- ▸Nổi bọt: đổi chỗ cặp kề sai thứ tự, lặp tới khi xuôi - O(n²).
- ▸Chèn: lấy từng phần tử, chèn vào đúng chỗ trong phần đã sắp ở bên trái.
- ▸Cả hai O(n²), nhưng chèn rất nhanh khi dữ liệu gần như đã sắp (gần O(n)).
Muốn vượt rào O(n²), ta dùng chiến lược chia để trị (divide and conquer): chẻ bài toán thành các phần nhỏ, giải rồi ghép lại. Sắp xếp trộn (merge sort) chia đôi dãy tới khi mỗi phần chỉ còn một phần tử (hiển nhiên đã sắp), rồi trộn từng cặp lại theo thứ tự:
tron.ts · chia đôi rồi trộn hai nửa đã sắp
function tron(a: number[]): number[] {
if (a.length <= 1) return a; // mot phan tu thi da sap
const mid = a.length >> 1;
const trai = tron(a.slice(0, mid)); // sap nua trai
const phai = tron(a.slice(mid)); // sap nua phai
const out: number[] = [];
let i = 0, j = 0;
while (i < trai.length && j < phai.length) // tron hai nua da sap
out.push(trai[i] <= phai[j] ? trai[i++] : phai[j++]);
return [...out, ...trai.slice(i), ...phai.slice(j)];
}
console.log(tron([5, 2, 8, 1, 9, 3])); Kết quả khi chạy
[ 1, 2, 3, 5, 8, 9 ]
Chia đôi log n lần, mỗi tầng trộn tốn n - thành O(n log n). Sắp xếp nhanh (quicksort) cũng chia để trị: chọn một chốt (pivot), dồn số nhỏ hơn sang trái, lớn hơn sang phải, rồi đệ quy hai bên - trung bình cũng O(n log n).
Trung thực
Chọn một thuật toán, bấm Tiếp → hoặc Tự chạy và để ý ô Phép so sánh. Cùng một dãy, nhóm O(n²) và nhóm O(n log n) chênh nhau thấy rõ - và càng giãn khi bạn bấm Trộn lại với dãy khó hơn:
Bắt đầu
Cùng một dãy, thử lần lượt 4 thuật toán rồi nhìn tổng phép so sánh: nổi bọt và chèn (O(n²)) tốn nhiều hơn hẳn trộn và nhanh (O(n log n)) - khoảng cách giãn rất nhanh khi dãy dài thêm.
- ▸Nổi bọt luôn tốn n(n-1)/2 phép so sánh, bất kể dãy thế nào.
- ▸Trộn và nhanh tốn ít hơn hẳn khi dãy dài - đó là khác biệt O(n²) với O(n log n).
- ▸Chèn nhanh bất ngờ khi dãy đã gần sắp (ít phải dời).
Sắp xong rồi, tìm kiếm trở nên cực nhanh. Tìm nhị phân (binary search) nhìn phần tử ở giữa: nếu trúng thì xong; nếu mục tiêu nhỏ hơn thì chỉ tìm tiếp nửa trái, lớn hơn thì nửa phải. Mỗi bước bỏ đi một nửa, nên chỉ tốn O(log n):
tim-nhi-phan.ts · mỗi bước bỏ đi một nửa vùng tìm
function timNhiPhan(a: number[], x: number): number {
let lo = 0, hi = a.length - 1;
while (lo <= hi) {
const giua = (lo + hi) >> 1;
if (a[giua] === x) return giua; // trung
if (a[giua] < x) lo = giua + 1; // tim nua phai
else hi = giua - 1; // tim nua trai
}
return -1; // khong co
}
const daSap = [1, 3, 5, 7, 9, 11, 13];
console.log(timNhiPhan(daSap, 9)); // tim thay o chi so 4
console.log(timNhiPhan(daSap, 6)); // khong co Kết quả khi chạy
4 -1
- ▸Tìm nhị phân: O(log n), nhưng CHỈ chạy trên dãy đã sắp.
- ▸1 000 phần tử cần tối đa ~10 phép; 1 triệu phần tử ~20 phép.
- ▸Sắp một lần O(n log n), sau đó mỗi lần tìm chỉ O(log n) - rất đáng nếu tìm nhiều.
Mèo con giờ phân được hai “đẳng cấp” sắp xếp: ngây thơ O(n²) và chia để trị O(n log n), và biết vì sao phải sắp trước khi tìm nhị phân. Nhưng có một điều phiền: cứ thêm/xoá một phần tử là dãy lại lệch thứ tự, phải sắp lại.
Bước tiếp theo
Câu hỏi thường gặp
Hiểu kỹ vài cái đại diện là đủ: nổi bọt/chèn (O(n²), đơn giản, hợp dữ liệu nhỏ hoặc gần như đã sắp) và trộn/nhanh (O(n log n), dùng cho dữ liệu lớn). Ngoài đời, bạn thường gọi hàm sắp xếp có sẵn của ngôn ngữ - nhưng phải hiểu chi phí của nó.
Tuỳ engine, nhưng thường là một biến thể O(n log n) (V8 dùng TimSort - lai giữa trộn và chèn, rất nhanh trên dữ liệu gần như đã sắp). Một bẫy: sort() mặc định so sánh theo CHUỖI, nên [10, 2, 1].sort() ra [1, 10, 2]; muốn so số phải truyền hàm (a, b) => a - b.
Trung bình thì có, nhưng trường hợp xấu nhất là O(n²) - khi chọn chốt (pivot) liên tục rơi vào phần tử nhỏ nhất/lớn nhất (vd dãy đã sắp mà chọn chốt ở cuối). Cài thực tế chọn chốt ngẫu nhiên hoặc “trung vị của ba” để né trường hợp xấu này.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Vì sao sắp xếp nổi bọt có độ phức tạp O(n²)?
- 1
Vì sao phải sắp trước
Giải thích trong 2 câu vì sao muốn dùng tìm nhị phân thì mảng bắt buộc phải được sắp xếp.
Hoàn thành khi: Nêu được: tìm nhị phân dựa vào việc so phần tử giữa để bỏ hẳn một nửa; điều đó chỉ đúng khi dãy đã có thứ tự.
- 2
Chạy tay nổi bọt
Sắp
[4, 1, 3, 2]tăng dần bằng sắp xếp nổi bọt, viết trạng thái mảng sau mỗi lần đổi chỗ.Hoàn thành khi: Kết quả cuối là
[1, 2, 3, 4]; các bước đổi chỗ hợp lý (phần tử lớn “nổi” dần về cuối). - 3
Đếm phép so sánh
Sắp xếp nổi bọt một dãy 6 phần tử tốn tối đa bao nhiêu phép so sánh? Viết công thức tổng quát cho n phần tử.
Hoàn thành khi: Với 6 phần tử:
6 × 5 / 2 = 15. Tổng quát:n(n-1)/2, tứcO(n²). - 4
Chia để trị
Bằng lời mèo con, mô tả 3 bước của sắp xếp trộn (merge sort) trên một dãy.
Hoàn thành khi: Nêu được: (1) chia đôi dãy tới khi mỗi phần còn 1 phần tử; (2) trộn từng cặp đã sắp lại với nhau; (3) lặp tới khi gộp thành một dãy sắp hoàn chỉnh.
- 5
Khi nào chèn thắng
Nêu một tình huống mà sắp xếp chèn (insertion) lại nhanh hơn cả trộn/nhanh.
Hoàn thành khi: Khi dãy gần như đã sắp sẵn (hoặc rất nhỏ): chèn chỉ tốn gần
O(n)vì hầu như không phải dời. - 6
Tự viết tìm nhị phân
Viết hàm
timNhiPhan(a, x)bằng TypeScript trả về chỉ số củaxtrong mảng đã sắpa, hoặc -1 nếu không có.Hoàn thành khi: Dùng hai biến
lo/hi, mỗi vòng so phần tử giữa rồi thu hẹp một nửa; chạy đúng trên vài ca thử; độ phức tạpO(log n).