Bài giải thích miễn phí · Video · Code · Trắc nghiệm
Sắp xếp nhanh (Quick Sort)
Chọn một phần tử chốt, dồn giá trị nhỏ hơn sang trái và lớn hơn sang phải, rồi sắp xếp từng bên theo cùng cách đó. Bản sắp xếp tại chỗ dùng phân hoạch Hoare, chốt trung vị của ba và ngăn xếp chỉ O(log n).
- Độ khó
- Trung bình
- Thời gian
- O(n log n) avg, O(n²) worst
- Bộ nhớ
- O(log n)
Tự tay chạy thử
Chạy từng bước theo nhịp của bạn — đây là Quick Sort Animation, chạy ngay trên trang.
Đang chạyQuick Sort Animation
Đang tải bản xem trước…
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
Bạn cần xếp một chồng bài kiểm tra lộn xộn theo điểm. Rút ra một bài, chẳng hạn bài 7 điểm. Bài nào thấp hơn để sang chồng bên trái, cao hơn để sang chồng bên phải. Chưa có gì được sắp xếp cả, nhưng bài 7 điểm giờ đã nằm đúng giữa hai chồng, và mỗi chồng lại là một phiên bản nhỏ hơn của chính bài toán ban đầu. Cứ làm tiếp với từng chồng cho tới khi mỗi chồng chỉ còn một bài.
Đó là sắp xếp nhanh, một thuật toán chia để trị. Giá trị được chọn gọi là phần tử chốt (pivot), còn việc chia mảng quanh nó gọi là phân hoạch (partition). Sắp xếp trộn làm phần việc chính khi ghép hai nửa lại; sắp xếp nhanh thì làm khi chia, nên chia xong là không cần ghép gì nữa.
Chạy thử từng bước
Sắp xếp [7, 2, 9, 4, 3, 8, 1].
Chọn chốt. Nhìn ba giá trị ở đầu, giữa và cuối: 7, 4 và 1. Xếp ba giá trị đó theo thứ tự ngay trong mảng, ta được [1, 2, 9, 4, 3, 8, 7]. Giá trị ở giữa, 4, là phần tử chốt.
Phân hoạch (cách của Hoare). Con trỏ i đi từ đầu sang phải, dừng ở giá trị ≥ 4. Con trỏ j đi từ cuối sang trái, dừng ở giá trị ≤ 4. Nếu hai con trỏ chưa vượt qua nhau thì cả hai giá trị đang nằm sai phía: đổi chỗ chúng rồi đi tiếp.
| Lượt | i dừng ở | j dừng ở | Việc làm | Mảng |
|---|---|---|---|---|
| 1 | chỉ số 2 (9) | chỉ số 4 (3) | đổi chỗ 9 và 3 | [1, 2, 3, 4, 9, 8, 7] |
| 2 | chỉ số 3 (4) | chỉ số 3 (4) | hai con trỏ gặp nhau, chia tại 3 | [1, 2, 3, 4 │ 9, 8, 7] |
Mọi giá trị trong [0..3] đều ≤ 4, mọi giá trị trong [4..6] đều ≥ 4. Sắp xếp hai bên theo cùng cách là cả mảng được sắp xếp. Lưu ý: với cách của Hoare, ta đệ quy trên [lo, split] và [split + 1, hi], tức là cả hai đầu đều được tính.
Các lựa chọn trong đoạn code
Dùng Hoare thay vì Lomuto. Phân hoạch Lomuto, kiểu hay gặp trong giáo trình, dễ đọc hơn nhưng đổi chỗ nhiều gấp khoảng ba lần. Tệ hơn, với mảng toàn giá trị giống nhau, lần chia nào cũng ra "tất cả" và "không có gì", và thuật toán thành O(n²). Con trỏ của Hoare dừng lại ngay ở giá trị bằng chốt và đổi chỗ chúng, nên phần tử trùng được rải đều cho hai bên. Mảng toàn giá trị bằng nhau được chia đúng ở giữa.
Chốt là trung vị của ba. Luôn lấy phần tử đầu tiên làm chốt là cái bẫy kinh điển: với mảng đã sắp xếp sẵn, loại dữ liệu "thật" hay gặp nhất, mỗi lần chia chỉ tách ra được một phần tử và thuật toán thành bậc hai. Với mảng đã sắp xếp hoặc sắp xếp ngược, trung vị của đầu, giữa và cuối chính là trung vị thật, nên trường hợp đó lại thành trường hợp tốt nhất. Chọn chốt ngẫu nhiên cũng được (kỳ vọng O(n log n) với mọi đầu vào); ở đây ta giữ cách tất định để lần chạy nào cũng đi đúng những bước như nhau.
Đệ quy bên nhỏ, lặp bên lớn. Mỗi lời gọi đệ quy chỉ nhận tối đa một nửa khoảng, nên ngăn xếp không bao giờ sâu quá khoảng log₂ n tầng, kể cả khi chọn chốt xui. Đệ quy kiểu ngây thơ có thể cần tới n tầng và tràn ngăn xếp với mảng lớn.
Vì sao thuật toán đúng
Trong lúc phân hoạch, mọi giá trị bên trái i đều ≤ chốt, mọi giá trị bên phải j đều ≥ chốt. Mỗi lần đổi chỗ sửa cùng lúc hai giá trị nằm sai phía. Khi hai con trỏ vượt qua nhau, khoảng được chia gọn gàng: mọi giá trị trong [lo, split] đều ≤ mọi giá trị trong [split + 1, hi].
Cả hai bên đều không rỗng, nên mỗi bên luôn nhỏ hơn hẳn khoảng ban đầu, và đệ quy chắc chắn dừng. Nếu mỗi bên đã được sắp xếp, và mọi giá trị bên trái không lớn hơn bất kỳ giá trị nào bên phải, thì cả khoảng đã được sắp xếp.
Độ phức tạp
- Trung bình và tốt nhất: O(n log n). Mỗi tầng đệ quy duyệt qua toàn bộ n giá trị một lần, và chia cân bằng thì có khoảng log₂ n tầng. Ngay cả khi lần nào cũng chia lệch 10/90, độ phức tạp vẫn là O(n log n), chỉ có hằng số lớn hơn.
- Tệ nhất: O(n²). Nếu chốt luôn là giá trị nhỏ nhất hoặc lớn nhất, mỗi tầng chỉ bớt được một phần tử. Trung vị của ba loại bỏ các trường hợp thường gặp (đã sắp xếp, sắp xếp ngược, toàn giá trị bằng nhau), nhưng đầu vào được dựng cố ý vẫn có thể gây ra nó. Vì vậy
std::sortcủa C++ là introsort: chuyển sang sắp xếp vun đống khi đệ quy quá sâu. - Bộ nhớ: O(log n) cho ngăn xếp. Bản thân việc sắp xếp diễn ra tại chỗ.
- Không ổn định. Hoare đổi chỗ các giá trị ở xa nhau, nên các giá trị bằng nhau có thể đổi thứ tự: ngay cả
[5a, 5b]cũng ra[5b, 5a]. Khi thứ tự đó quan trọng (sắp theo ngày, rồi theo giá), hãy dùng một thuật toán ổn định như sắp xếp trộn hoặc Timsort.
Trên thực tế, sắp xếp nhanh thường là thuật toán sắp xếp so sánh nhanh nhất cho mảng: chạy tại chỗ, vòng lặp trong rất gọn, và đọc bộ nhớ tuần tự nên bộ nhớ đệm (cache) rất "thích".
Lỗi hay gặp
- Mang thói quen của Lomuto sang Hoare. Đệ quy trên
[lo, split - 1]là bỏ sót một phần tử. Với Hoare phải là[lo, split]và[split + 1, hi]. - Dùng
<=khi quét.while (values[i] <= pivot)có thể chạy vượt khỏi khoảng, và dồn hết phần tử trùng về một phía. - Lấy chốt ở chỉ số cuối. Với Hoare và phép chia làm tròn xuống, điểm chia có thể rơi đúng vào
hi. Khi đó bên phải rỗng và đệ quy không bao giờ dừng. Chốt của ta lấy từmid, màmidluôn nhỏ hơnhi. - Tràn số ở
(lo + hi) / 2trong Java hoặc C++. Hãy dùnglo + (hi - lo) / 2.
Bạn cũng sẽ gặp bản Python ba dòng, tạo ba danh sách nhỏ hơn, bằng và lớn hơn chốt. Rất hợp để minh họa ý tưởng, nhưng nó sao chép dữ liệu ở mọi tầng và không sắp xếp tại chỗ.
Khi nào nên dùng, khi nào không
Dùng cho việc sắp xếp chung trong bộ nhớ với số hoặc khóa, nhất là khi bộ nhớ eo hẹp. Chọn thứ khác khi cần thứ tự ổn định (sắp xếp trộn), cần đảm bảo trường hợp tệ nhất (sắp xếp vun đống hoặc sắp xếp trộn), hoặc khi sắp xếp danh sách liên kết (sắp xếp trộn). Với vài phần tử gần như đã có thứ tự, sắp xếp chèn thắng. Trong dự án thật, cứ gọi hàm sắp xếp có sẵn của ngôn ngữ: đó là bản lai đã được tinh chỉnh từ chính những ý tưởng này.
Biến thể khi phỏng vấn
- Phần tử nhỏ (hoặc lớn) thứ k, tức Quickselect. Phân hoạch một lần, rồi chỉ đi tiếp vào bên chứa vị trí k. Mỗi tầng bỏ được một nửa công việc, nên trung bình chỉ O(n) thay vì O(n log n).
- Sort colors / bài toán cờ Hà Lan. Phân hoạch thành ba nhóm
< chốt,= chốtvà> chốttrong một lượt duyệt. Phân hoạch ba nhánh này cũng là cách chữa cho dữ liệu có rất nhiều phần tử trùng. - Top-k từ xuất hiện nhiều nhất, hay k điểm gần nhất. Chạy Quickselect trên khóa (tần suất hoặc khoảng cách) để lấy ra k phần tử tốt nhất mà không cần sắp xếp toàn bộ.
Code
Cùng một thuật toán, từng bước như nhau, bằng 4 ngôn ngữ.
// Quick sort, in place: Hoare partition around a median-of-three pivot.
// Pick a pivot, move the small values left and the big values right, then sort each side.
/**
* Sorts `values` in ascending order, in place.
* Not stable: equal values may end up in a different order than they started.
*
* @param {number[]} values the array to sort
* @returns {void}
*/
export function quickSort(values) {
sortRange(values, 0, values.length - 1);
}
/**
* Sorts values[lo..hi] (both ends included).
* Recurses into the smaller side and loops on the bigger one, so the call stack never grows
* past about log2(n) frames — even when the pivots are unlucky.
*
* @param {number[]} values
* @param {number} lo
* @param {number} hi
*/
function sortRange(values, lo, hi) {
while (lo < hi) {
const split = hoarePartition(values, lo, hi);
const leftSize = split - lo + 1;
const rightSize = hi - split;
if (leftSize < rightSize) {
sortRange(values, lo, split); // small side: recursion
lo = split + 1; // big side: next turn of the loop
} else {
sortRange(values, split + 1, hi);
hi = split;
}
}
}
/**
* Hoare partition of values[lo..hi]. Returns `split` with lo <= split < hi such that
* every value in [lo, split] is <= every value in [split + 1, hi].
* Both sides are non-empty, so each one is strictly smaller than the range we started with.
*
* @param {number[]} values
* @param {number} lo
* @param {number} hi
* @returns {number}
*/
function hoarePartition(values, lo, hi) {
const pivot = medianOfThree(values, lo, hi);
let i = lo - 1;
let j = hi + 1;
while (true) {
// Walk i right past values that already belong on the left…
do {
i++;
} while (values[i] < pivot);
// …and j left past values that already belong on the right.
do {
j--;
} while (values[j] > pivot);
if (i >= j) return j; // the two walks met: everything is on its side
swap(values, i, j); // both are on the wrong side — trade places
}
}
/**
* Puts values[lo], values[mid] and values[hi] in order and returns the middle one.
* On sorted or reversed input this picks the true median, which is exactly where
* "always take the first element" falls to O(n²).
*
* @param {number[]} values
* @param {number} lo
* @param {number} hi
* @returns {number}
*/
function medianOfThree(values, lo, hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (values[mid] < values[lo]) swap(values, mid, lo);
if (values[hi] < values[lo]) swap(values, hi, lo);
if (values[hi] < values[mid]) swap(values, hi, mid);
return values[mid];
}
/**
* @param {number[]} values
* @param {number} a
* @param {number} b
*/
function swap(values, a, b) {
[values[a], values[b]] = [values[b], values[a]];
}
# Quick sort, in place: Hoare partition around a median-of-three pivot.
# Pick a pivot, move the small values left and the big values right, then sort each side.
from typing import List
def quick_sort(values: List[int]) -> None:
"""Sort values in ascending order, in place.
Not stable: equal values may end up in a different order than they started.
"""
_sort_range(values, 0, len(values) - 1)
def _sort_range(values: List[int], lo: int, hi: int) -> None:
"""Sort values[lo..hi] (both ends included).
Recurse into the smaller side and loop on the bigger one, so the call
stack never grows past about log2(n) frames — even with unlucky pivots.
"""
while lo < hi:
split = _hoare_partition(values, lo, hi)
left_size = split - lo + 1
right_size = hi - split
if left_size < right_size:
_sort_range(values, lo, split) # small side: recursion
lo = split + 1 # big side: next turn of the loop
else:
_sort_range(values, split + 1, hi)
hi = split
def _hoare_partition(values: List[int], lo: int, hi: int) -> int:
"""Hoare partition of values[lo..hi].
Return split with lo <= split < hi such that every value in [lo, split]
is <= every value in [split + 1, hi]. Both sides are non-empty, so each
one is strictly smaller than the range we started with.
"""
pivot = _median_of_three(values, lo, hi)
i = lo - 1
j = hi + 1
while True:
# Walk i right past values that already belong on the left…
i += 1
while values[i] < pivot:
i += 1
# …and j left past values that already belong on the right.
j -= 1
while values[j] > pivot:
j -= 1
if i >= j:
return j # the two walks met: everything is on its side
values[i], values[j] = values[j], values[i] # both on the wrong side — trade places
def _median_of_three(values: List[int], lo: int, hi: int) -> int:
"""Put values[lo], values[mid], values[hi] in order and return the middle one.
On sorted or reversed input this picks the true median, which is exactly
where "always take the first element" falls to O(n²).
"""
mid = lo + (hi - lo) // 2
if values[mid] < values[lo]:
values[mid], values[lo] = values[lo], values[mid]
if values[hi] < values[lo]:
values[hi], values[lo] = values[lo], values[hi]
if values[hi] < values[mid]:
values[hi], values[mid] = values[mid], values[hi]
return values[mid]
/**
* Quick sort, in place: Hoare partition around a median-of-three pivot.
* Pick a pivot, move the small values left and the big values right, then sort each side.
*/
public final class QuickSort {
private QuickSort() {
}
/**
* Sorts values in ascending order, in place.
* Not stable: equal values may end up in a different order than they started.
*/
public static void quickSort(int[] values) {
sortRange(values, 0, values.length - 1);
}
/**
* Sorts values[lo..hi] (both ends included).
* Recurses into the smaller side and loops on the bigger one, so the call stack never grows
* past about log2(n) frames — even when the pivots are unlucky.
*/
private static void sortRange(int[] values, int lo, int hi) {
while (lo < hi) {
int split = hoarePartition(values, lo, hi);
int leftSize = split - lo + 1;
int rightSize = hi - split;
if (leftSize < rightSize) {
sortRange(values, lo, split); // small side: recursion
lo = split + 1; // big side: next turn of the loop
} else {
sortRange(values, split + 1, hi);
hi = split;
}
}
}
/**
* Hoare partition of values[lo..hi]. Returns split with lo <= split < hi such that
* every value in [lo, split] is <= every value in [split + 1, hi].
* Both sides are non-empty, so each one is strictly smaller than the range we started with.
*/
private static int hoarePartition(int[] values, int lo, int hi) {
int pivot = medianOfThree(values, lo, hi);
int i = lo - 1;
int j = hi + 1;
while (true) {
// Walk i right past values that already belong on the left…
do {
i++;
} while (values[i] < pivot);
// …and j left past values that already belong on the right.
do {
j--;
} while (values[j] > pivot);
if (i >= j) {
return j; // the two walks met: everything is on its side
}
swap(values, i, j); // both are on the wrong side — trade places
}
}
/**
* Puts values[lo], values[mid] and values[hi] in order and returns the middle one.
* On sorted or reversed input this picks the true median, which is exactly where
* "always take the first element" falls to O(n²).
*/
private static int medianOfThree(int[] values, int lo, int hi) {
int mid = lo + (hi - lo) / 2;
if (values[mid] < values[lo]) {
swap(values, mid, lo);
}
if (values[hi] < values[lo]) {
swap(values, hi, lo);
}
if (values[hi] < values[mid]) {
swap(values, hi, mid);
}
return values[mid];
}
private static void swap(int[] values, int a, int b) {
int tmp = values[a];
values[a] = values[b];
values[b] = tmp;
}
}
// Quick sort, in place: Hoare partition around a median-of-three pivot.
// Pick a pivot, move the small values left and the big values right, then sort each side.
// (In real code reach for std::sort — an introsort built on the same idea.)
#include <utility>
#include <vector>
// Puts values[lo], values[mid] and values[hi] in order and returns the middle one.
// On sorted or reversed input this picks the true median, which is exactly where
// "always take the first element" falls to O(n²).
int medianOfThree(std::vector<int>& values, int lo, int hi) {
int mid = lo + (hi - lo) / 2;
if (values[mid] < values[lo]) {
std::swap(values[mid], values[lo]);
}
if (values[hi] < values[lo]) {
std::swap(values[hi], values[lo]);
}
if (values[hi] < values[mid]) {
std::swap(values[hi], values[mid]);
}
return values[mid];
}
// Hoare partition of values[lo..hi]. Returns split with lo <= split < hi such that
// every value in [lo, split] is <= every value in [split + 1, hi].
// Both sides are non-empty, so each one is strictly smaller than the range we started with.
int hoarePartition(std::vector<int>& values, int lo, int hi) {
int pivot = medianOfThree(values, lo, hi);
int i = lo - 1;
int j = hi + 1;
while (true) {
// Walk i right past values that already belong on the left…
do {
++i;
} while (values[i] < pivot);
// …and j left past values that already belong on the right.
do {
--j;
} while (values[j] > pivot);
if (i >= j) {
return j; // the two walks met: everything is on its side
}
std::swap(values[i], values[j]); // both are on the wrong side — trade places
}
}
// Sorts values[lo..hi] (both ends included).
// Recurses into the smaller side and loops on the bigger one, so the call stack never grows
// past about log2(n) frames — even when the pivots are unlucky.
void sortRange(std::vector<int>& values, int lo, int hi) {
while (lo < hi) {
int split = hoarePartition(values, lo, hi);
int leftSize = split - lo + 1;
int rightSize = hi - split;
if (leftSize < rightSize) {
sortRange(values, lo, split); // small side: recursion
lo = split + 1; // big side: next turn of the loop
} else {
sortRange(values, split + 1, hi);
hi = split;
}
}
}
// Sorts values in ascending order, in place.
// Not stable: equal values may end up in a different order than they started.
void quickSort(std::vector<int>& values) {
sortRange(values, 0, static_cast<int>(values.size()) - 1);
}
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.
Tự kiểm tra
10 câu hỏi nhanh xem bạn đã nắm chưa.
Câu 1 · Bản quick sort này chọn pivot là trung vị của ba phần tử. Với [7, 2, 9, 4, 3, 8, 1], pivot là giá trị nào?
Dùng ngay trong dự án
- Animation
Quick Sort Animation
Sắp xếp nhanh từng bước: phần tử chốt, hai con trỏ của Hoare và ngăn xếp gọi hàm luôn nông
Miễn phí
