Tiệm Công Nghệ

Đang hiện gợi ý.

Đang mở danh mục của tiệm…
↑↓ để di chuyển↵ để mởesc để đóng
Tìm kiếm Tiệm Công Nghệ

Bài giải thích miễn phí · Video · Code · Trắc nghiệm

Sắp xếp trộn (Merge Sort)

Cắt mảng làm đôi, sắp xếp từng nửa, rồi trộn hai nửa đã có thứ tự trong một lượt. Bản sắp xếp trộn từ trên xuống, ổn định, dùng chung một bộ đệm cho mọi lần trộn: O(n log n) với mọi đầu vào, thêm O(n) bộ nhớ.

Độ khó
Trung bình
Thời gian
O(n log n)
Bộ nhớ
O(n)

Cách hoạt động

Ý tưởng, một ví dụ chạy thử, và những bẫy nên tránh.

Ý tưởng

Hai người bạn mỗi người đưa bạn một chồng bài kiểm tra, đã xếp sẵn theo điểm. Muốn gộp thành một chồng có thứ tự thì chẳng cần nghĩ nhiều: nhìn bài trên cùng của mỗi chồng, lấy bài điểm thấp hơn, lặp lại. Một lượt là xong. Việc đó gọi là trộn (merge).

Sắp xếp trộn biến việc dễ ấy thành cả một thuật toán sắp xếp. Mảng lộn xộn thì không trộn được, nhưng ta có thể cắt nó làm đôi, sắp xếp từng nửa (theo đúng cách này, bằng đệ quy), rồi trộn hai nửa đã có thứ tự. Cứ cắt mãi sẽ tới những mảnh chỉ một phần tử, mà một phần tử thì vốn đã có thứ tự. Từ đó chỉ việc trộn ngược lên.

Đây là chia để trị, giống sắp xếp nhanh, nhưng phần việc chính nằm ở chỗ ngược lại. Sắp xếp nhanh làm phần khó khi chia, chia xong là hết việc. Sắp xếp trộn chia chẳng cần suy tính (luôn cắt đúng giữa) và làm việc thật sự khi ghép.

Chạy thử từng bước

Sắp xếp [8, 3, 5, 3, 9, 1]. Có hai số 3, gọi là 3a (chỉ số 1) và 3b (chỉ số 3) để theo dõi.

Chia đôi liên tục cho tới khi mỗi mảnh chỉ còn một giá trị:

            [8, 3a, 5, 3b, 9, 1]
          /                      \
    [8, 3a, 5]                [3b, 9, 1]
     /      \                  /      \
   [8]    [3a, 5]           [3b]    [9, 1]
           /   \                    /   \
         [3a]  [5]                [9]   [1]

Trộn ngược lên, mỗi lần hai mảnh đã có thứ tự:

  • [3a] + [5] → [3a, 5], rồi [8] + [3a, 5] → [3a, 5, 8]
  • [9] + [1] → [1, 9], rồi [3b] + [1, 9] → [1, 3b, 9]

Lần trộn cuối, [3a, 5, 8] với [1, 3b, 9], từng bước một:

Bước Đầu nửa trái Đầu nửa phải Lấy Kết quả tới lúc này
1 3a 1 1 (phải) [1]
2 3a 3b 3a (trái: hòa thì lấy bên trái) [1, 3a]
3 5 3b 3b (phải) [1, 3a, 3b]
4 5 9 5 (trái) [1, 3a, 3b, 5]
5 8 9 8 (trái) [1, 3a, 3b, 5, 8]
6 – 9 nửa trái đã hết, 9 đã nằm đúng chỗ [1, 3a, 3b, 5, 8, 9]

Bước 2 là bước đáng nhớ. Khi hai bên bằng nhau, ta lấy bên trái, nên 3a, vốn đứng trước, vẫn ra trước. Chính lựa chọn nhỏ đó làm sắp xếp trộn ổn định.

Các lựa chọn trong đoạn code

Một bộ đệm, cấp phát đúng một lần. Khi trộn, ta cần một chỗ để cất hai nửa trong lúc ghi kết quả ngược vào mảng. Hàm mergeSort ở ngoài cùng cấp phát một bộ đệm bằng kích thước mảng rồi truyền nó cho mọi lời gọi. Mỗi lần trộn chép khoảng [lo, hi) của mình sang đúng những ô đó trong bộ đệm, rồi trộn từ bộ đệm trở lại mảng. Bản trong giáo trình viết left = values.slice(lo, mid) ở mỗi lời gọi vẫn chạy đúng, nhưng phải cấp phát và sao chép khoảng n lần, toàn là chi phí thừa.

Khoảng nửa mở [lo, hi). Tính lo, không tính hi. Hai nửa là [lo, mid) và [mid, hi), gặp nhau đúng ở mid, không có + 1 hay - 1 nào để viết nhầm, và kích thước chỉ là hi - lo. Khoảng có ít hơn hai giá trị thì đã có thứ tự, đó là điều kiện dừng.

Phần còn thừa của nửa phải cứ để yên. Khi nửa trái hết trước, những giá trị còn chờ ở nửa phải đã nằm sẵn ở vị trí cuối cùng của chúng: vị trí ghi k đã đuổi kịp j. Chỉ phần thừa của nửa trái cần chép, nên chỉ có một vòng dọn dẹp chứ không phải hai.

Vì sao thuật toán đúng

Phép trộn giữ một lời hứa: values[lo..k) chứa k - lo giá trị nhỏ nhất của hai nửa, theo đúng thứ tự. Mỗi bước lấy giá trị nhỏ hơn trong hai giá trị đứng đầu. Hai nửa đều đã có thứ tự, nên không giá trị nào còn chờ ở cả hai nửa có thể nhỏ hơn giá trị vừa lấy, và lời hứa vẫn đúng sau thêm một bước. Khi cả hai nửa đã hết, cả khoảng đã có thứ tự.

Đệ quy chắc chắn dừng vì mỗi lần chia tạo ra hai khoảng không rỗng và nhỏ hơn hẳn, còn khoảng có không hoặc một phần tử thì đương nhiên đã có thứ tự. Hai nửa có thứ tự cộng với một phép trộn đúng cho ra một khoảng có thứ tự, cứ thế lên tới đỉnh.

Độ phức tạp

  • Thời gian: O(n log n), với mọi đầu vào. Lần chia nào cũng cắt đúng giữa, bất kể giá trị ra sao, nên có khoảng log₂ n tầng chia đôi. Ở mỗi tầng, các phép trộn cộng lại duyệt qua toàn bộ n giá trị đúng một lần. n giá trị nhân log₂ n tầng: một triệu giá trị mất khoảng 20 lượt. Đã sắp xếp, ngược, toàn bằng nhau hay ngẫu nhiên đều không đổi được hình dạng cây chia, nên không có trường hợp xấu nào, khác với O(n²) của sắp xếp nhanh.
  • Bộ nhớ: O(n). Bộ đệm to bằng mảng. Ngăn xếp lời gọi chỉ sâu khoảng log₂ n tầng, rất nhỏ so với bộ đệm. Phần bộ nhớ thêm đó là cái giá của sự đảm bảo trên. Sắp xếp nhanh chỉ cần O(log n).

Tính ổn định, và vì sao nó quan trọng

Một thuật toán sắp xếp là ổn định nếu các giá trị bằng nhau ra theo đúng thứ tự chúng đi vào. Với số nguyên trơn thì không thấy khác biệt. Với bản ghi thì thấy ngay.

Giả sử danh sách đơn hàng của một cửa hàng đã xếp theo thời gian, và bạn xếp lại theo khách hàng. Sắp xếp ổn định giữ nguyên thứ tự thời gian trong đơn hàng của từng khách mà không tốn thêm gì. Sắp xếp không ổn định có thể xáo trộn chúng. Bảng dữ liệu làm "xếp theo giá, rồi theo đánh giá" đúng theo cách này: xếp theo khóa thứ hai trước, rồi xếp ổn định theo khóa thứ nhất.

Sắp xếp trộn ổn định chỉ nhờ một ký tự: phép trộn lấy bên trái khi left <= right. Viết < thì gặp hòa sẽ lấy bên phải, mảng vẫn ra đúng thứ tự nhưng hết ổn định. Bài kiểm thử nào chỉ so thứ tự của số trơn sẽ không bao giờ bắt được lỗi này.

Lỗi hay gặp

  • Dùng < thay vì <= khi trộn. Vẫn có thứ tự, nhưng âm thầm mất ổn định. Hãy kiểm thử bằng các bản ghi trùng khóa.
  • Tạo mảng mới ở mỗi lời gọi. slice hay new Array bên trong đệ quy sẽ cấp phát và sao chép đi sao chép lại. Cấp phát bộ đệm một lần rồi truyền xuống.
  • Lẫn lộn hai kiểu khoảng. Với khoảng đóng [lo, hi], hai nửa là [lo, mid] và [mid + 1, hi]. Viết thành [lo, mid - 1] và [mid, hi] thì khoảng hai phần tử [0, 1] có mid = 0 và đệ quy mãi vào [0, 1]. Dùng khoảng nửa mở là khỏi phải băn khoăn.
  • Quên phần còn thừa. Khi một nửa hết, phần còn lại của nửa kia vẫn phải được đưa vào. Ở đây chỉ nửa trái cần vòng lặp; nếu ghi ra một mảng kết quả riêng thì cả hai nửa đều cần.
  • Gán lại tên thay vì ghi vào (Python). values = merged trong hàm chỉ đổi tên biến cục bộ, danh sách của người gọi vẫn y nguyên. Hãy ghi vào values[k], hoặc dùng values[:] = merged.
  • Tràn số ở (lo + hi) / 2 trong Java hoặc C++. Hãy dùng lo + (hi - lo) / 2.

Khi nào nên dùng, khi nào không

Nên chọn sắp xếp trộn khi:

  • Cần sắp xếp ổn định. sorted của Python và Arrays.sort cho mảng đối tượng của Java dùng Timsort, một biến thể sắp xếp trộn được tinh chỉnh cho dữ liệu thật. Array.prototype.sort của JavaScript bắt buộc phải ổn định từ ES2019, và V8 cũng dùng Timsort. C++ có std::stable_sort.
  • Cần đảm bảo trường hợp tệ nhất. O(n log n) dù đầu vào trông thế nào.
  • Sắp xếp danh sách liên kết. Trộn hai danh sách chỉ là nối lại các nút, không cần bộ đệm nào, và sắp xếp trộn không bao giờ phải nhảy tới một chỉ số. Sắp xếp nhanh và sắp xếp vun đống thì phải.
  • Dữ liệu không vừa bộ nhớ (sắp xếp ngoài). Sắp xếp từng phần vừa bộ nhớ, ghi mỗi phần ra đĩa thành một dãy đã có thứ tự, rồi trộn các dãy đó, đọc mỗi dãy tuần tự từ đầu tới cuối. Cơ sở dữ liệu sắp xếp bảng lớn theo cách này.

Chọn thứ khác khi bộ nhớ eo hẹp và không cần ổn định: sắp xếp nhanh hay sắp xếp vun đống đều chạy tại chỗ. Với vài chục giá trị thì sắp xếp chèn nhanh hơn, và Timsort cũng dùng nó cho các đoạn nhỏ. Trong dự án thật, cứ gọi hàm sắp xếp có sẵn.

Biến thể khi phỏng vấn

  1. Đếm nghịch thế (count inversions). Có bao nhiêu cặp i < j mà a[i] > a[j]? Chạy sắp xếp trộn, và mỗi lần phép trộn lấy một giá trị từ nửa phải, giá trị đó vượt qua mọi giá trị còn chờ ở nửa trái: cộng mid - i vào bộ đếm. O(n log n) thay vì xét mọi cặp trong O(n²). Nhờ <=, các giá trị bằng nhau không bị tính, đúng như đề yêu cầu.
  2. Trộn k danh sách đã sắp xếp. Dùng một min-heap giữ phần tử đầu của mỗi danh sách. Lấy ra phần tử nhỏ nhất, đẩy vào phần tử kế tiếp của chính danh sách đó. O(N log k) với N là tổng số giá trị. Trộn các danh sách theo từng cặp, hết vòng này tới vòng khác, cũng cho cùng độ phức tạp.
  3. Sắp xếp trộn từ dưới lên (bottom-up). Bỏ đệ quy: trộn các đoạn độ dài 1 thành độ dài 2, rồi 2 thành 4, 4 thành 8, cho tới khi một đoạn phủ hết mảng. Cùng phép trộn, cùng bộ đệm, cùng O(n log n), mà không cần ngăn xếp lời gọi. Đây cũng là cách tự nhiên để sắp xếp danh sách liên kết (bài "Sort List" trên LeetCode) chỉ với O(1) bộ nhớ thêm.

Code

Cùng một thuật toán, từng bước như nhau, bằng 4 ngôn ngữ.

JavaScript
// Merge sort, top-down: split the range in half, sort each half, then merge the two sorted
// halves into one. A single scratch buffer, allocated once, holds the halves while they merge.

/**
 * Sorts `values` in ascending order, in place.
 * Stable: equal values keep the order they started in.
 *
 * @param {number[]} values the array to sort
 * @returns {void}
 */
export function mergeSort(values) {
  const buffer = new Array(values.length); // shared by every merge — never allocated again
  sortRange(values, buffer, 0, values.length);
}

/**
 * Sorts values[lo..hi) — lo included, hi excluded.
 *
 * @param {number[]} values
 * @param {number[]} buffer scratch space, at least as long as `values`
 * @param {number} lo
 * @param {number} hi
 */
function sortRange(values, buffer, lo, hi) {
  if (hi - lo < 2) return; // zero or one value: already sorted

  const mid = lo + Math.floor((hi - lo) / 2);
  sortRange(values, buffer, lo, mid); // left half:  [lo, mid)
  sortRange(values, buffer, mid, hi); // right half: [mid, hi)
  mergeHalves(values, buffer, lo, mid, hi);
}

/**
 * Merges the sorted halves values[lo..mid) and values[mid..hi) into one sorted values[lo..hi).
 *
 * @param {number[]} values
 * @param {number[]} buffer
 * @param {number} lo
 * @param {number} mid
 * @param {number} hi
 */
function mergeHalves(values, buffer, lo, mid, hi) {
  for (let k = lo; k < hi; k++) buffer[k] = values[k]; // set both halves aside

  let i = lo; // next unused value of the left half
  let j = mid; // next unused value of the right half
  let k = lo; // next slot to fill in values

  while (i < mid && j < hi) {
    // <=, not <: on a tie the left value goes first, which is what makes the sort stable.
    if (buffer[i] <= buffer[j]) {
      values[k++] = buffer[i++];
    } else {
      values[k++] = buffer[j++];
    }
  }
  while (i < mid) values[k++] = buffer[i++];
  // Whatever is left of the right half is already in place: k has caught up with j.
}

Hỏi về thuật toán này

Kẹt ở bước nào? Hỏi thêm ví dụ hoặc vì sao nó chạy đúng.

Câu trả lời do AI viết dựa trên trang này và có thể sai. Nội dung trên trang mới là chuẩn.

Tự kiểm tra

10 câu hỏi nhanh xem bạn đã nắm chưa.

Câu 1 · mergeSort chạy trên [38, 27, 43, 3, 9, 82, 10], bảy giá trị. Code đặt mid = lo + Math.floor((hi - lo) / 2). Lần chia đầu tiên tạo ra hai nửa nào?

Làm trắc nghiệm