cv.cm

Lập trình · Thuật toán

Thuật toán sắp xếp nhanh: cách hoạt động, mã và độ phức tạp

7 phút đọc

Trả lời nhanh

Sắp xếp nhanh là thuật toán sắp xếp chia để trị. Nó chọn một phần tử làm pivot (phần tử chốt), sắp xếp lại mảng sao cho các phần tử nhỏ hơn đứng trước pivot và các phần tử lớn hơn đứng sau (phân hoạch), rồi sắp xếp hai phía bằng đệ quy. Thuật toán chạy O(n log n) trung bình và sắp xếp tại chỗ với O(log n) không gian ngăn xếp, nhưng suy giảm thành O(n²) khi các pivot luôn tệ. Nó không ổn định. Với pivot ngẫu nhiên hoặc trung vị của ba, đây là một trong những thuật toán sắp xếp đa dụng nhanh nhất trong thực tế.

Mở Sắp dòng

Ý tưởng bằng lời đơn giản

  1. Chọn một phần tử pivot, ví dụ phần tử cuối hoặc một phần tử ngẫu nhiên.
  2. Phân hoạch: chuyển mọi phần tử nhỏ hơn pivot sang bên trái nó và mọi phần tử lớn hơn sang bên phải. Lúc này pivot đã ở đúng vị trí cuối cùng đã sắp xếp.
  3. Sắp xếp đệ quy phần bên trái pivot và phần bên phải pivot. Sau đó không cần kết hợp gì thêm.

Sắp xếp trộn chia dễ nhưng trộn khó. Sắp xếp nhanh ngược lại: việc phân hoạch làm công việc chính, còn nối hai nửa miễn phí vì chúng đã nằm đúng chỗ trong cùng một mảng.

Ví dụ minh họa: một lần phân hoạch Lomuto

Phân hoạch [10, 80, 30, 90, 40, 50, 70] với phần tử cuối, 70, làm pivot. Chỉ số i đánh dấu cuối vùng các phần tử nhỏ hơn pivot; j quét từ trái sang phải.

jitems[j]Nhỏ hơn 70?Hành độngMảng sau đó
010cói = 0, đổi chỗ 0↔0[10, 80, 30, 90, 40, 50, 70]
180khôngkhông có[10, 80, 30, 90, 40, 50, 70]
230cói = 1, đổi chỗ 1↔2[10, 30, 80, 90, 40, 50, 70]
390khôngkhông có[10, 30, 80, 90, 40, 50, 70]
440cói = 2, đổi chỗ 2↔4[10, 30, 40, 90, 80, 50, 70]
550cói = 3, đổi chỗ 3↔5[10, 30, 40, 50, 80, 90, 70]
cuốiđặt pivot vào i + 1 = 4[10, 30, 40, 50, 70, 90, 80]

Giờ 70 nằm ở chỉ số 4, vị trí cuối cùng của nó. Sau đó sắp xếp nhanh sắp xếp riêng [10, 30, 40, 50] và [90, 80].

Cài đặt bằng Python

import random

def partition(a, lo, hi):
    """Lomuto partition of a[lo..hi] around a random pivot."""
    r = random.randint(lo, hi)
    a[r], a[hi] = a[hi], a[r]       # move the pivot to the end
    pivot = a[hi]
    i = lo - 1
    for j in range(lo, hi):
        if a[j] < pivot:
            i += 1
            a[i], a[j] = a[j], a[i]
    a[i + 1], a[hi] = a[hi], a[i + 1]
    return i + 1

def quicksort(a, lo=0, hi=None):
    """Sort list a in place."""
    if hi is None:
        hi = len(a) - 1
    while lo < hi:
        p = partition(a, lo, hi)
        if p - lo < hi - p:           # recurse on the smaller side
            quicksort(a, lo, p - 1)
            lo = p + 1
        else:
            quicksort(a, p + 1, hi)
            hi = p - 1
    return a

print(quicksort([10, 80, 30, 90, 40, 50, 70]))   # [10, 30, 40, 50, 70, 80, 90]
print(quicksort([3, 3, 3, 1]))                   # [1, 3, 3, 3]

Đệ quy vào phía nhỏ hơn và lặp trên phía lớn hơn giữ độ sâu ngăn xếp ở O(log n) ngay cả trong trường hợp xấu nhất. Một phiên bản ngắn, không tại chỗ, rất phổ biến để giải thích ý tưởng, nhưng nó cấp phát list mới ở mỗi mức:

def quicksort_simple(items):
    if len(items) <= 1:
        return items
    pivot = items[len(items) // 2]
    less = [x for x in items if x < pivot]
    equal = [x for x in items if x == pivot]
    greater = [x for x in items if x > pivot]
    return quicksort_simple(less) + equal + quicksort_simple(greater)

print(quicksort_simple([5, 2, 9, 2, 7]))   # [2, 2, 5, 7, 9]

Độ phức tạp thời gian và không gian

Trường hợpThời gianLý do
Tốt nhấtO(n log n)mỗi pivot chia đôi phạm vi
Trung bìnhO(n log n)pivot ngẫu nhiên cho các phép chia đủ cân bằng, khoảng 1,39 n log₂ n phép so sánh
Xấu nhấtO(n²)mỗi pivot đều là phần tử nhỏ nhất hoặc lớn nhất
Không gian phụO(log n)ngăn xếp đệ quy khi đệ quy vào phía nhỏ hơn

Trường hợp xấu nhất kinh điển là pivot cố định ở phần tử đầu hoặc cuối trên đầu vào đã sắp xếp hoặc sắp xếp ngược: mỗi lần phân hoạch chỉ loại được pivot, tạo ra n mức với O(n) công việc. Pivot ngẫu nhiên khiến điều này cực kỳ khó xảy ra với mọi đầu vào.

So sánh sắp xếp nhanh với các thuật toán khác

Thuật toánTốt nhấtTrung bìnhXấu nhấtKhông gian phụỔn định
Sắp xếp nhanhO(n log n)O(n log n)O(n²)O(log n)Không
Sắp xếp trộnO(n log n)O(n log n)O(n log n)O(n)Có
Sắp xếp vun đốngO(n log n)O(n log n)O(n log n)O(1)Không
Sắp xếp chènO(n)O(n²)O(n²)O(1)Có
Sắp xếp nổi bọtO(n)O(n²)O(n²)O(1)Có
Sắp xếp chọnO(n²)O(n²)O(n²)O(1)Không
Timsort (sorted của Python)O(n)O(n log n)O(n log n)O(n)Có

Sắp xếp nhanh không ổn định: một phép đổi chỗ khi phân hoạch có thể đưa một phần tử vượt qua phần tử bằng nó. Nó thường thắng sắp xếp trộn và sắp xếp vun đống trên mảng trong bộ nhớ vì vòng lặp trong đơn giản, làm việc tại chỗ và truy cập bộ nhớ chủ yếu tuần tự. Introsort, được nhiều bản cài đặt std::sort trong thư viện chuẩn C++ dùng, bắt đầu bằng sắp xếp nhanh và chuyển sang sắp xếp vun đống nếu đệ quy quá sâu, nhờ đó đảm bảo O(n log n).

Lựa chọn pivot, biến thể và lỗi thường gặp

  • Lựa chọn pivot: phần tử đầu hoặc cuối (đơn giản, nhưng O(n²) trên đầu vào đã sắp xếp), phần tử ngẫu nhiên, hoặc trung vị của ba (đầu, giữa, cuối).
  • Phân hoạch Hoare: hai chỉ số tiến vào nhau và đổi chỗ các cặp nằm sai phía. Trung bình nó đổi chỗ ít hơn Lomuto.
  • Phân hoạch ba đường (cờ quốc gia Hà Lan): chia thành nhỏ hơn, bằng và lớn hơn. Nó giúp mảng có nhiều khóa trùng chạy nhanh; Lomuto thuần túy suy giảm thành O(n²) khi mọi khóa bằng nhau.
  • Dual-pivot quicksort dùng hai pivot và ba phần; Java dùng nó để sắp xếp mảng kiểu nguyên thủy.
  • Quickselect dùng một lần phân hoạch và chỉ đệ quy vào một phía để tìm phần tử nhỏ thứ k trong O(n) trung bình.

Khi nào nên dùng sắp xếp nhanh

Sắp xếp nhanh là lựa chọn mặc định tốt để sắp xếp mảng trong bộ nhớ khi không cần tính ổn định. Trong Python, hãy dùng sorted() hoặc list.sort(); chúng dùng Timsort, ổn định và nhanh trên dữ liệu đã sắp xếp một phần. Cài đặt sắp xếp nhanh là bài phỏng vấn rất phổ biến, thường kèm câu hỏi về trường hợp xấu nhất, cách chọn pivot, tính ổn định, hoặc dùng quickselect để tìm phần tử lớn thứ k.

Câu hỏi thường gặp

Sắp xếp nhanh có ổn định không?

Không. Phân hoạch đổi chỗ các phần tử ở khoảng cách xa, nên các phần tử bằng nhau có thể đổi thứ tự tương đối. Có các phiên bản ổn định nhưng cần thêm bộ nhớ.

Trường hợp xấu nhất của sắp xếp nhanh là gì?

O(n²), khi mọi pivot đều là giá trị nhỏ nhất hoặc lớn nhất trong phạm vi của nó, ví dụ pivot là phần tử cuối trên đầu vào đã sắp xếp. Pivot ngẫu nhiên hoặc trung vị của ba tránh được điều này trong thực tế.

Vì sao sắp xếp nhanh nhanh trong thực tế?

Nó sắp xếp tại chỗ, vòng lặp trong chỉ là so sánh và đổi chỗ đơn giản, và quét bộ nhớ tuần tự nên tận dụng tốt bộ nhớ đệm CPU. Hằng số trung bình của nó nhỏ hơn sắp xếp vun đống và không cần bộ đệm như sắp xếp trộn.

Sắp xếp nhanh khác sắp xếp trộn thế nào?

Sắp xếp nhanh phân hoạch trước và không cần trộn; sắp xếp trộn chia trước và làm việc ở bước trộn. Sắp xếp nhanh tại chỗ nhưng xấu nhất O(n²) và không ổn định; sắp xếp trộn ổn định, luôn O(n log n) và cần O(n) không gian phụ.

Sắp xếp nhanh dùng bao nhiêu bộ nhớ?

Nó sắp xếp tại chỗ. Ngăn xếp đệ quy là O(log n) trung bình, và cũng O(log n) ở trường hợp xấu nhất nếu bạn luôn đệ quy vào phía nhỏ hơn trước.

Tất cả hướng dẫn lập trình