← Cấu trúc dữ liệu & Giải thuật

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

Sắp xếp nhanh trung bình O(n log n) nhưng xấu nhất là O(n²) khi chốt chọn dở (vd luôn rơi vào phần tử lớn nhất). Cài thực tế chọn chốt ngẫu nhiên để né. Đổi lại, quicksort sắp ngay trên mảng (ít tốn bộ nhớ phụ), còn merge sort cần thêm chỗ để trộn nhưng luôn ổn định O(n log n).

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:

5
2
8
1
9
3
7
4

Bắt đầu

Phép so sánh: 0 / 28 Độ phức tạp: O(n²)

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):

Dãy đã sắp 8 phần tử với con trỏ lo ở đầu, hi ở cuối, mid ở giữa; so giá trị giữa để bỏ một nửa vùng tìm mỗi bước.
Tìm nhị phân: so với phần tử ở giữa (mid) để bỏ một nửa vùng tìm sau mỗi bước - 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

Liệu có cấu trúc nào luôn giữ thứ tự trong khi vẫn cho thêm/xoá nhanh, để không phải sắp đi sắp lại? Có - đó là Cây nhị phân tìm kiếm. Còn nếu chỉ cần tra cứu “có hay không” thật nhanh mà chẳng cần thứ tự, ta dùng Bảng băm.

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.

Mỗi bước chia đôi vùng còn lại nên chỉ tốn O(log n): 1 000 phần tử cần tối đa ~10 phép so sánh, 1 triệu phần tử ~20 phép. Nhưng nhớ là tìm nhị phân CHỈ chạy trên dãy đã sắp - đó là lý do ta bỏ công sắp trước.

Rất đáng. Sắp tốn O(n log n) một lần, sau đó mỗi lần tìm chỉ O(log n). Nếu phải tìm hàng nghìn lần trên cùng dữ liệu, cái giá sắp ban đầu chia đều ra gần như miễn phí.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Vì sao sắp xếp nổi bọt có độ phức tạp O(n²)?

  1. 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. 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. 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ức O(n²).

  4. 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. 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. 6

    Tự viết tìm nhị phân

    Viết hàm timNhiPhan(a, x) bằng TypeScript trả về chỉ số của x trong mảng đã sắp a, 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ạp O(log n).