← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202629 phút đọc24 min read

Thuật toán sắp xếpSorting Algorithms

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Bộ ba bậc hai
  4. Divide and conquer: merge sort
  5. Divide and conquer: quicksort
  6. Heap sort
  7. Khái niệm chính
  8. Cận dưới Ω(n log n) cho comparison sort
  9. Non-comparison sort: cách né cận dưới
  10. Bảng so sánh đầy đủ
  11. Các thư viện chuẩn thực tế dùng gì
  12. Sắp xếp trong Python, một cách thực dụng
  13. Best Practices
  14. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The quadratic three
  4. Divide and conquer: merge sort
  5. Divide and conquer: quicksort
  6. Heap sort
  7. Key Concepts
  8. The Ω(n log n) lower bound for comparison sorts
  9. Non-comparison sorts: getting around the bound
  10. Full comparison table
  11. What real standard libraries use
  12. Sorting in Python, practically
  13. Best Practices
  14. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Sắp xếp là việc sắp lại một tập dữ liệu theo một thứ tự xác định dựa trên một toán tử so sánh. Định nghĩa đó nghe rất hẹp, và chủ đề này trông như một bảo tàng những món đồ cổ — chẳng ai đem bubble sort lên production cả. Vậy mà sắp xếp lại là bài toán được nghiên cứu nhiều nhất trong ngành, và nó xứng đáng với sự chú ý đó vì ba lý do.

Thứ nhất, sắp xếp là bước tiền xử lý biến các bài toán khác thành dễ. Một array đã sắp xếp hỗ trợ binary search ở O(log n) (./09-search-algorithms.md), biến việc phát hiện trùng lặp thành một lần quét tuyến tính, biến “tìm trung vị” thành một phép truy cập index, biến việc gộp interval thành chuyện hiển nhiên, và biến nhiều vòng lặp brute-force O(n²) thành các lần quét O(n log n). Hình dáng thường thấy của một lời giải hiệu quả là sort, rồi duyệt một lượt.

Thứ hai, các thuật toán sắp xếp là một danh mục các paradigm thuật toán chính ở dạng rõ ràng nhất. Insertion sort là xây dựng tăng dần. Merge sort và quicksort là divide and conquer (./19-recursion-and-divide-and-conquer.md). Heap sort là “dựng đúng cấu trúc dữ liệu thì thuật toán tự rơi ra” (./12-heaps-and-priority-queues.md). Counting sort là đổi bộ nhớ lấy thời gian. Học sáu thuật toán sắp xếp dạy bạn nhiều về thiết kế thuật toán hơn là học sáu bài toán không liên quan tới nhau.

Thứ ba, sắp xếp là nơi khoảng cách giữa lý thuyết và thực tế lộ ra rõ nhất. Có một cận dưới Ω(n log n) đã được chứng minh cho các comparison sort — vậy mà các thư viện thật vẫn vượt qua nó trong trường hợp đặc biệt, né hoàn toàn nó với số nguyên, và dùng insertion sort O(n²) cho các subarray nhỏ vì ở quy mô đó hằng số nhân và hành vi cache mới là thứ quyết định. Hiểu tại sao Timsort, introsort và pdqsort lại có hình dáng như vậy có ích hơn bất kỳ thuật toán đơn lẻ nào.

Hai tính chất xuất hiện xuyên suốt và đáng định nghĩa ngay từ đầu:

Kiến thức nền tảng

Bộ ba bậc hai

Ba thuật toán này đều là O(n²). Bạn sẽ không triển khai chúng làm thuật toán sort chính, nhưng riêng insertion sort thì nằm bên trong mọi sort production, và hiểu cả ba gần như không tốn gì.

Bubble sort

Liên tục đi qua array và hoán đổi các cặp liền kề sai thứ tự. Sau lượt thứ i, i phần tử lớn nhất đã “nổi” lên cuối và nằm đúng vị trí cuối cùng.

def bubble_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        swapped = False
        # Sau lượt i, i phần tử cuối đã nằm đúng vị trí cuối cùng,
        # nên vòng lặp trong có thể thu hẹp lại.
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:        # không có gì di chuyển: đã sắp xếp — best case O(n)
            break
    return arr

Độ phức tạp: O(n²) trung bình và worst case (n(n−1)/2 phép so sánh), best case O(n) chỉ khi có cờ swapped, bộ nhớ O(1), stable (nó chỉ hoán đổi các phần tử liền kề thực sự sai thứ tự). Trong thực tế nó chậm nhất trong ba thuật toán vì thực hiện nhiều lần hoán đổi nhất — mỗi cặp nghịch thế đều tốn một lần ghi. Ưu điểm duy nhất là dễ giải thích và phát hiện array đã sắp xếp chỉ trong một lượt.

Selection sort

Liên tục chọn phần tử nhỏ nhất trong phần đuôi chưa sắp xếp và hoán đổi nó về đúng chỗ.

def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        m = i
        for j in range(i + 1, n):        # quét phần đuôi chưa sắp để tìm min
            if arr[j] < arr[m]:
                m = j
        arr[i], arr[m] = arr[m], arr[i]  # đúng một lần hoán đổi mỗi lượt
    return arr

Độ phức tạp: O(n²) trong mọi trường hợp — việc quét tìm min luôn diễn ra bất kể input, nên không có best case. Bộ nhớ O(1). Không stable: phép hoán đổi đường dài có thể nhảy một phần tử qua một phần tử bằng nó ([2a, 2b, 1][1, 2b, 2a]).

Nó có một ngách ứng dụng thật sự: nó thực hiện đúng n−1 lần hoán đổi, mức tối thiểu có thể. Khi ghi đắt hơn đọc rất nhiều — EEPROM, flash memory với số chu kỳ xóa giới hạn, hoặc khi phải di chuyển các bản ghi lớn về mặt vật lý — O(n) lần ghi của selection sort thắng O(n²) lần ghi của insertion sort. (Cycle sort đẩy ý tưởng này tới cực hạn với số lần ghi tối thiểu đã được chứng minh.)

Insertion sort

Mở rộng dần một tiền tố đã sắp xếp, mỗi lần chèn một phần tử mới vào đúng vị trí bằng cách đẩy các phần tử lớn hơn sang phải. Đây chính là cách con người sắp bài trên tay.

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        # Đẩy mọi phần tử lớn hơn hẳn `key` sang phải một ô.
        # Dấu `>` (chứ không phải `>=`) giữ nguyên thứ tự các phần tử bằng nhau,
        # và đó chính là điều làm cho insertion sort stable.
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr

Độ phức tạp: O(n²) trung bình và worst case (input sắp ngược khiến mọi lần đều phải đẩy toàn bộ), nhưng O(n) với input đã sắp hoặc gần sắp — vòng while bên trong thoát ngay lập tức. Chính xác hơn, nó là O(n + I) với I là số cặp nghịch thế, điều này khiến nó adaptive. Bộ nhớ O(1), stable, in-place.

Chính tính adaptive cộng với hằng số nhân rất nhỏ là lý do insertion sort là base case của gần như mọi thuật toán sort production: dưới ngưỡng khoảng 16–32 phần tử, nó thắng hẳn merge sort và quicksort, vì không có chi phí đệ quy, không cấp phát bộ nhớ, và cache locality hoàn hảo. Nó cũng là thuật toán online — có thể sort một stream ngay khi phần tử tới, điều mà không thuật toán nào khác ở đây làm được.

Divide and conquer: merge sort

Chia array làm đôi, sort đệ quy từng nửa, rồi merge hai nửa đã sắp trong thời gian tuyến tính.

                 [38, 27, 43, 3, 9, 82, 10]
                 /                        \
        [38, 27, 43]                  [3, 9, 82, 10]
        /          \                  /            \
    [38]      [27, 43]           [3, 9]        [82, 10]
              /     \            /    \         /    \
           [27]     [43]      [3]     [9]    [82]    [10]
              \     /            \    /         \    /
              [27, 43]           [3, 9]        [10, 82]
        \          /                  \            /
        [27, 38, 43]                  [3, 9, 10, 82]
                 \                        /
                 [3, 9, 10, 27, 38, 43, 82]
def merge_sort(arr):
    if len(arr) <= 1:                  # base case: 0 hoặc 1 phần tử là đã sắp xếp
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return _merge(left, right)


def _merge(left, right):
    """Trộn hai list đã sắp thành một list đã sắp. O(len(left) + len(right))."""
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        # Dùng `<=` thay vì `<` chính là thứ làm merge sort stable: khi bằng nhau,
        # phần tử ở nửa trái — phần tử xuất hiện trước — được lấy trước.
        if left[i] <= right[j]:
            out.append(left[i])
            i += 1
        else:
            out.append(right[j])
            j += 1
    out.extend(left[i:])               # một trong hai lệnh này thao tác trên list rỗng
    out.extend(right[j:])
    return out

Độ phức tạp: O(n log n) trong mọi trường hợp. Cây đệ quy có log₂ n tầng và mỗi tầng làm O(n) công việc merge; công thức truy hồi T(n) = 2T(n/2) + O(n) cho nghiệm Θ(n log n) theo Master Theorem. Bộ nhớ là O(n) cho các buffer merge — đây là điểm yếu thật sự duy nhất của merge sort, và là lý do nó thua quicksort ở vai trò thuật toán sort mặc định trong bộ nhớ.

Điểm mạnh của merge sort nằm đúng vào chỗ quicksort yếu:

Divide and conquer: quicksort

Chọn một pivot, phân hoạch array sao cho mọi phần tử nhỏ hơn nằm bên trái và lớn hơn nằm bên phải, rồi đệ quy vào hai bên. Khác merge sort, toàn bộ công việc nằm ở bước chia, còn bước gộp thì miễn phí.

import random


def quick_sort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    while lo < hi:
        p = _partition(arr, lo, hi)
        # Đệ quy vào nửa nhỏ hơn và lặp trên nửa lớn hơn. Cách này giữ độ sâu
        # đệ quy ở O(log n) ngay cả khi phân hoạch bị lệch.
        if p - lo < hi - p:
            quick_sort(arr, lo, p - 1)
            lo = p + 1
        else:
            quick_sort(arr, p + 1, hi)
            hi = p - 1
    return arr


def _partition(arr, lo, hi):
    """Phân hoạch Lomuto với pivot chọn ngẫu nhiên.
    Trả về chỉ số mà pivot dừng lại — đúng vị trí cuối cùng của nó."""
    r = random.randint(lo, hi)
    arr[r], arr[hi] = arr[hi], arr[r]      # đưa pivot ra cuối cho gọn
    pivot = arr[hi]
    i = lo                                 # bất biến: arr[lo:i] < pivot
    for j in range(lo, hi):
        if arr[j] < pivot:
            arr[i], arr[j] = arr[j], arr[i]
            i += 1
    arr[i], arr[hi] = arr[hi], arr[i]      # đưa pivot về đúng chỗ
    return i

Độ phức tạp: O(n log n) trung bình, O(n²) worst case, bộ nhớ O(log n) (chỉ là recursion stack — phân hoạch diễn ra in-place). Không stable, vì phân hoạch hoán đổi các phần tử ở khoảng cách xa.

Worst case xảy ra khi pivot luôn là phần tử nhỏ nhất hoặc lớn nhất, khiến mỗi lần phân hoạch chỉ bóc ra được một phần tử và đệ quy sâu n tầng: T(n) = T(n−1) + O(n) = O(n²). Với pivot cố định là “phần tử đầu tiên”, input đã sắp xếp chính là worst case — một tính chất cực kỳ tệ, vì dữ liệu đã sắp hoặc gần sắp là cực kỳ phổ biến trong thực tế. Đây không phải giả thuyết: nó đã từng gây ra các lỗ hổng denial-of-service thật trước khi các implementation chuyển sang pivot ngẫu nhiên.

Các chiến lược chọn pivot, theo thứ tự tăng dần độ vững chắc:

Chiến lượcWorst case bị kích hoạt bởi
Phần tử đầu/cuốiInput đã sắp hoặc sắp ngược — rất phổ biến
Phần tử giữaMột input được dựng có chủ đích; an toàn với dữ liệu đã sắp
Median-of-three (đầu, giữa, cuối)Một input “median-of-three killer” được dựng riêng
Pivot ngẫu nhiênKhông có gì kẻ tấn công dựng trước được — worst case có xác suất O(1/n!)
Median-of-mediansKhông gì cả — bảo đảm O(n log n), nhưng hằng số nhân lớn tới mức không ai dùng thực tế

Hai điểm thực tế nữa:

Dù worst case tệ hơn, quicksort thường nhanh hơn merge sort về thời gian thực tế: nó in-place (không cấp phát, không tải bộ nhớ thêm), vòng lặp trong là một lần quét tuần tự chặt chẽ mà cache và branch predictor rất thích, và hằng số nhân của nó chỉ khoảng một nửa merge sort.

Heap sort

Dựng một max-heap ngay trên array, rồi liên tục hoán đổi root (phần tử lớn nhất) với phần tử cuối của heap và thu nhỏ heap đi một, khôi phục tính chất heap sau mỗi lần. Array dần hình thành một đuôi đã sắp xếp từ bên phải.

def _sift_down(arr, start, end):
    """Khôi phục tính chất max-heap tại chỉ số `start` trong phạm vi arr[0:end]."""
    root = start
    while True:
        child = 2 * root + 1               # con trái theo layout array
        if child >= end:
            return
        if child + 1 < end and arr[child] < arr[child + 1]:
            child += 1                     # lấy con lớn hơn trong hai con
        if arr[root] >= arr[child]:
            return                         # tính chất heap đã thỏa
        arr[root], arr[child] = arr[child], arr[root]
        root = child


def heap_sort(arr):
    n = len(arr)
    # Giai đoạn 1: heapify từ dưới lên. Đây là O(n), KHÔNG phải O(n log n) — phần
    # lớn node nằm gần lá và chỉ phải sift down một hai tầng.
    for start in range(n // 2 - 1, -1, -1):
        _sift_down(arr, start, n)
    # Giai đoạn 2: liên tục rút phần tử lớn nhất vào phần đuôi đã sắp.
    for end in range(n - 1, 0, -1):
        arr[0], arr[end] = arr[end], arr[0]   # max về đúng vị trí cuối cùng
        _sift_down(arr, 0, end)               # khôi phục heap trên phần tiền tố
    return arr

Độ phức tạp: O(n log n) trong mọi trường hợp — dựng heap tốn O(n), sau đó n lần rút, mỗi lần O(log n). Bộ nhớ O(1), in-place thật sự. Không stable.

Heap sort là thuật toán duy nhất ở đây vừa có bảo đảm O(n log n) vừa dùng O(1) bộ nhớ. Vậy mà nó hiếm khi nhanh nhất: nó truy cập bộ nhớ theo kiểu nhảy cóc (index i2i+1), rất bất lợi cho cache, và nó thực hiện khoảng gấp đôi số phép so sánh của quicksort. Vai trò production thật sự của nó là làm phương án dự phòng trong introsort — tốc độ của quicksort cộng bảo đảm của heap sort, đúng là đánh đổi mà std::sort của C++ chọn.

Khái niệm chính

Cận dưới Ω(n log n) cho comparison sort

Bất kỳ thuật toán nào chỉ tìm hiểu về input thông qua việc so sánh từng cặp phần tử đều có thể mô hình hóa bằng một cây quyết định (decision tree): mỗi node trong là một phép so sánh, mỗi nhánh là một kết quả, mỗi lá là một hoán vị mà thuật toán có thể xuất ra.

                        a₁ : a₂
                    ≤ /         \ >
              a₂ : a₃             a₁ : a₃
             /       \           /       \
       ⟨1,2,3⟩    a₁ : a₃   ⟨2,1,3⟩    a₂ : a₃
                  /     \               /     \
            ⟨1,3,2⟩  ⟨3,1,2⟩      ⟨2,3,1⟩  ⟨3,2,1⟩

Lập luận gồm ba dòng:

  1. Để sort đúng, cây phải có ít nhất n! — mỗi hoán vị input một lá, vì thuật toán phải có khả năng tạo ra bất kỳ hoán vị nào.
  2. Cây nhị phân có L lá thì chiều cao ít nhất là log₂ L. Số phép so sánh trong worst case chính là chiều cao.
  3. log₂(n!) = Θ(n log n) theo xấp xỉ Stirling (n! ≈ (n/e)ⁿ√(2πn), nên log₂(n!) ≥ n log₂ n − n log₂ e).

Do đó mọi comparison sort đều cần Ω(n log n) phép so sánh trong worst case. Merge sort và heap sort đạt đúng cận này nên tối ưu về mặt tiệm cận. Không có comparison sort thông minh nào đang chờ được khám phá — cận này là một định lý, không phải một phỏng đoán.

Hãy chú ý chính xác nó ràng buộc điều gì: số phép so sánh, trong worst case. Nó không nói gì về các thuật toán nhìn vào chính nội dung của phần tử.

Non-comparison sort: cách né cận dưới

Nếu bạn có thể nhìn vào bên trong key — đọc từng chữ số của nó, dùng nó làm index của array — thì bạn đã ở ngoài mô hình decision tree và cận dưới không áp dụng nữa.

Counting sort

Dành cho key là số nguyên trong một khoảng nhỏ đã biết [0, k]: đếm số lần xuất hiện của mỗi giá trị, tính prefix sum để có vị trí bắt đầu, rồi đặt trực tiếp từng phần tử.

def counting_sort(arr, k):
    """Counting sort stable cho số nguyên trong [0, k]. Thời gian và bộ nhớ O(n + k)."""
    counts = [0] * (k + 1)
    for x in arr:
        counts[x] += 1
    # Prefix sum biến "có bao nhiêu phần tử bằng v" thành "chỉ số ngay sau ô cuối của v"
    for v in range(1, k + 1):
        counts[v] += counts[v - 1]
    out = [None] * len(arr)
    for x in reversed(arr):            # duyệt ngược để giữ tính stable
        counts[x] -= 1
        out[counts[x]] = x
    return out


assert counting_sort([4, 2, 2, 8, 3, 3, 1], 8) == [1, 2, 2, 3, 3, 4, 8]

Thời gian và bộ nhớ O(n + k). Tuyến tính khi k = O(n), và vô dụng khi k khổng lồ — counting-sort 1000 số nguyên 64-bit ngẫu nhiên sẽ cấp phát một array 2⁶⁴ bộ đếm. Tính stable quan trọng ở đây vì counting sort là vòng lặp trong của radix sort.

Radix sort

Sort lần lượt theo từng vị trí chữ số, bắt đầu từ chữ số ít quan trọng nhất (LSD), dùng một sort stable cho mỗi chữ số. Tính stable không phải thứ có cũng được: nó chính là thứ bảo toàn thứ tự đã được thiết lập bởi các chữ số ít quan trọng hơn ở lượt trước.

input     lượt 1 (đơn vị)  lượt 2 (chục)   lượt 3 (trăm)
 170        170              802             002
  45        090              002             045
  75        802              024             066
  90        024              045             075
 802        045              075             090
  24        075              170             170
   2        066              090             802
  66        002              066             ...
def radix_sort(arr, base=10):
    """LSD radix sort cho số nguyên không âm. Thời gian O(d * (n + base)),
    với d là số chữ số của giá trị lớn nhất."""
    if not arr:
        return []
    out = list(arr)
    max_val = max(out)
    exp = 1
    while max_val // exp > 0:
        buckets = [[] for _ in range(base)]
        for x in out:
            buckets[(x // exp) % base].append(x)   # stable: append theo đúng thứ tự
        out = [x for bucket in buckets for x in bucket]
        exp *= base
    return out


assert radix_sort([170, 45, 75, 90, 802, 24, 2, 66]) == [2, 24, 45, 66, 75, 90, 170, 802]

O(d·(n + b)) với d là số chữ số và b là cơ số. Với key có độ rộng cố định (số nguyên 32-bit, b = 256, d = 4) thì đó là O(n) — tuyến tính thật sự. Implementation thực tế dùng cơ số 256 và phép dịch bit, chứ không dùng cơ số 10 và phép chia. Radix sort là thứ mà các database engine và hệ thống phân tích dạng cột dùng cho các cột số nguyên, và nó thắng thoải mái các sort O(n log n) trên array số nguyên lớn.

Bucket sort

Rải phần tử vào n bucket theo giá trị, sort từng bucket, rồi nối lại. Hoạt động tốt khi input phân bố xấp xỉ đều trên một khoảng đã biết.

def bucket_sort(arr, n_buckets=None):
    """Bucket sort cho số thực phân bố đều trong [0, 1).
    Kỳ vọng O(n), worst case O(n^2) (mọi phần tử rơi vào một bucket)."""
    if not arr:
        return []
    n = n_buckets or len(arr)
    buckets = [[] for _ in range(n)]
    for x in arr:
        buckets[min(int(x * n), n - 1)].append(x)
    out = []
    for bucket in buckets:
        out.extend(insertion_sort(bucket))   # kỳ vọng mỗi bucket chỉ chứa ~1 phần tử
    return out

O(n) kỳ vọng — và kỳ vọng đó phụ thuộc hoàn toàn vào phân bố dữ liệu. Đưa vào dữ liệu lệch thì mọi thứ rơi vào một bucket, và bạn nhận về O(n²) của sort bên trong. Bucket sort là thuật toán bạn chọn khi biết chắc điều gì đó cụ thể về dữ liệu (số đo cảm biến phân bố đều, giá trị hash, điểm số đã chuẩn hóa), còn ngoài ra thì không.

Bảng so sánh đầy đủ

Thuật toánBestTrung bìnhWorstBộ nhớStableIn-placeGhi chú
Bubble sortO(n)¹O(n²)O(n²)O(1)Nhiều lần hoán đổi nhất; chỉ để dạy học
Selection sortO(n²)O(n²)O(n²)O(1)KhôngĐúng n−1 lần hoán đổi — số lần ghi tối thiểu
Insertion sortO(n)O(n²)O(n²)O(1)Adaptive, online; base case của các sort thật
Merge sortO(n log n)O(n log n)O(n log n)O(n)KhôngBảo đảm chặt; dùng cho linked list & external sort
QuicksortO(n log n)O(n log n)O(n²)²O(log n)KhôngNhanh nhất thực tế; cần chọn pivot tốt
Heap sortO(n log n)O(n log n)O(n log n)O(1)KhôngVừa bảo đảm vừa in-place; bất lợi cho cache
TimsortO(n)O(n log n)O(n log n)O(n)KhôngMặc định của Python/Java; tận dụng run có sẵn
Counting sortO(n + k)O(n + k)O(n + k)O(n + k)KhôngChỉ với khoảng số nguyên nhỏ
Radix sort (LSD)O(d(n + b))O(d(n + b))O(d(n + b))O(n + b)KhôngKey độ rộng cố định; tuyến tính trong thực tế
Bucket sortO(n + k)O(n + k)O(n²)O(n)Có³KhôngĐòi hỏi phân bố đều

¹ Khi có cờ swapped thoát sớm; không có nó thì là O(n²). ² O(n log n) với xác suất rất cao nếu pivot ngẫu nhiên; O(n log n) bảo đảm nếu có phương án dự phòng heap sort của introsort. ³ Stable nếu sort bên trong mỗi bucket là stable.

Các thư viện chuẩn thực tế dùng gì

Không ai đem một thuật toán sách giáo khoa lên production. Mọi sort production đều là một hybrid, và đọc chúng là bản tóm tắt tốt nhất cho mọi thứ ở trên.

Timsort — list.sort() / sorted() của Python, Arrays.sort cho object của Java

Do Tim Peters phát minh cho CPython năm 2002 dựa trên nhận xét rằng dữ liệu thật hiếm khi ngẫu nhiên — nó thường đã sắp một phần, được nối từ các đoạn đã sắp, hoặc đã được sort theo một key khác.

  1. Quét array tìm các run tự nhiên — các đoạn tăng dần hoặc giảm dần nghiêm ngặt dài nhất (đoạn giảm được đảo ngược tại chỗ, cách này giữ được tính stable).
  2. Run ngắn hơn minrun (32–64, tính từ n) được kéo dài tới minrun bằng binary insertion sort.
  3. Các run được đẩy vào một stack và merge theo các bất biến giữ cho độ dài các run cân bằng, nhờ đó các lần merge tổng cộng là O(n log n).
  4. Galloping mode: khi một run liên tục thắng trong quá trình merge, chuyển từ bước nhảy tuyến tính sang tìm kiếm mũ để bỏ qua các phần tử của nó trong O(log n) thay vì O(k).

Kết quả: O(n) với input đã sắp hoặc sắp ngược, O(n log n) worst case, stable, và nhanh hơn merge sort thuần rất nhiều trên dữ liệu thực tế. Tính stable của sort trong Python là bảo đảm ở mức ngôn ngữ đã được ghi trong tài liệu, không phải chi tiết implementation — bạn có thể dựa vào nó.

Introsort — std::sort của C++

Là quicksort, nhưng có gắn thiết bị đo:

Bạn nhận được tốc độ trung bình của quicksort cùng bảo đảm worst case của heap sort. std::sort không stable; std::stable_sort là một hàm riêng (một merge sort adaptive) chính vì tính stable phải trả giá bằng bộ nhớ.

pdqsort — sort_unstable của Rust, sort/slices.Sort của Go 1.19+

“Pattern-defeating quicksort” mở rộng introsort:

Cách đặt tên của Rust rất thành thật về đánh đổi: sort() stable và có cấp phát; sort_unstable() là pdqsort, in-place, và nhanh hơn. Go chuyển sort chuẩn từ một biến thể introsort sang pdqsort ở phiên bản 1.19 vì cùng những lý do đó.

Sắp xếp trong Python, một cách thực dụng

from operator import itemgetter, attrgetter

people = [("Chi", 40), ("Anh", 31), ("Binh", 31)]

# `sorted` trả về list mới; `list.sort()` sort tại chỗ và trả về None.
by_age = sorted(people, key=itemgetter(1))          # O(n log n), stable
people.sort(key=itemgetter(1))                      # không copy, nhanh hơn một chút

# Sort theo nhiều key, cùng chiều tăng dần — dựng key dạng tuple
by_age_then_name = sorted(people, key=lambda p: (p[1], p[0]))

# Khác chiều nhau: dựa vào tính STABLE và sort theo key ít quan trọng nhất
# trước. Đây chính là nguyên lý làm LSD radix sort hoạt động.
rows = sorted(people, key=itemgetter(0))            # phụ: tên tăng dần
rows = sorted(rows, key=itemgetter(1), reverse=True) # chính: tuổi giảm dần

# Hàm key được gọi đúng một lần cho mỗi phần tử (decorate-sort-undecorate),
# nên key tốn kém vẫn chấp nhận được. Comparator kiểu cmp thì KHÔNG:
# functools.cmp_to_key bị gọi O(n log n) lần và chậm hơn hẳn — chỉ dùng khi thứ
# tự thật sự không thể biểu diễn bằng một key.

Hai thói quen quan trọng hơn cả việc chọn thuật toán:

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

Sorting is rearranging a collection into a defined order according to a comparison operator. That definition sounds narrow, and the subject looks like a museum of historical curiosities — nobody ships bubble sort. Yet sorting is the single most-studied problem in computing, and it earns that attention for three reasons.

First, sorting is a preprocessing step that makes other problems easy. A sorted array supports binary search in O(log n) (./09-search-algorithms.md), makes duplicate detection a single linear scan, makes “find the median” an array index, makes interval merging trivial, and turns many O(n²) brute-force loops into O(n log n) sweeps. The usual shape of an efficient solution is sort, then walk once.

Second, the sorting algorithms are a catalogue of the major algorithmic paradigms in their clearest form. Insertion sort is incremental construction. Merge sort and quicksort are divide and conquer (./19-recursion-and-divide-and-conquer.md). Heap sort is “build the right data structure and the algorithm falls out” (./12-heaps-and-priority-queues.md). Counting sort is trading space for time. Studying six sorting algorithms teaches you more about algorithm design than studying six unrelated problems.

Third, sorting is where the theory-versus-practice gap is most visible. There is a proven Ω(n log n) lower bound for comparison sorts — and real library sorts beat it in special cases, dodge it entirely for integers, and use O(n²) insertion sort on small subarrays because constant factors and cache behaviour dominate at that scale. Understanding why Timsort, introsort, and pdqsort look the way they do is more instructive than any single algorithm.

Two properties recur throughout and are worth defining up front:

Fundamentals

The quadratic three

These three are O(n²). You will not deploy them as your general sort, but insertion sort in particular is inside every production sort, and understanding all three costs almost nothing.

Bubble sort

Repeatedly walk the array swapping adjacent out-of-order pairs. After pass i, the i largest elements have “bubbled” to the end and are in final position.

def bubble_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        swapped = False
        # After pass i, the last i elements are already in their final place,
        # so the inner loop can shrink.
        for j in range(n - 1 - i):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:        # nothing moved: the array is sorted — O(n) best case
            break
    return arr

Complexity: O(n²) average and worst (n(n−1)/2 comparisons), O(n) best case only with the swapped flag, O(1) space, stable (it only swaps strictly out-of-order neighbours). It is the slowest of the three in practice because it performs the most swaps — every inversion costs a write. Its only merit is that it is easy to explain and it detects an already-sorted array in one pass.

Selection sort

Repeatedly select the minimum of the unsorted suffix and swap it into place.

def selection_sort(arr):
    n = len(arr)
    for i in range(n - 1):
        m = i
        for j in range(i + 1, n):        # scan the unsorted suffix for its minimum
            if arr[j] < arr[m]:
                m = j
        arr[i], arr[m] = arr[m], arr[i]  # exactly one swap per pass
    return arr

Complexity: O(n²) in all cases — the scan for the minimum happens regardless of the input, so there is no best case. O(1) space. Not stable: the long-distance swap can jump an element over an equal one ([2a, 2b, 1][1, 2b, 2a]).

It has one genuine niche: it performs exactly n−1 swaps, the minimum possible. When writes are far more expensive than reads — EEPROM, flash memory with limited erase cycles, or moving physically large records — selection sort’s O(n) writes beat insertion sort’s O(n²) writes. (Cycle sort takes this to its logical extreme with a provably minimal number of writes.)

Insertion sort

Grow a sorted prefix one element at a time, inserting each new element into its correct position by shifting larger elements right. It is how people sort a hand of playing cards.

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        # Shift everything strictly greater than `key` one slot to the right.
        # Strict `>` (not `>=`) leaves equal elements in their original order,
        # which is exactly what makes insertion sort stable.
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr

Complexity: O(n²) average and worst (reverse-sorted input shifts everything every time), but O(n) on already-sorted or nearly-sorted input — the inner while exits immediately. More precisely it is O(n + I) where I is the number of inversions, which makes it adaptive. O(1) space, stable, in-place.

That adaptivity plus a tiny constant factor is why insertion sort is the base case of essentially every production sort: below a threshold of roughly 16–32 elements it beats merge sort and quicksort outright, because it has no recursion overhead, no allocation, and perfect cache locality. It is also online — it can sort a stream as elements arrive, which no other sort here can do.

Divide and conquer: merge sort

Split the array in half, sort each half recursively, then merge the two sorted halves in linear time.

                 [38, 27, 43, 3, 9, 82, 10]
                 /                        \
        [38, 27, 43]                  [3, 9, 82, 10]
        /          \                  /            \
    [38]      [27, 43]           [3, 9]        [82, 10]
              /     \            /    \         /    \
           [27]     [43]      [3]     [9]    [82]    [10]
              \     /            \    /         \    /
              [27, 43]           [3, 9]        [10, 82]
        \          /                  \            /
        [27, 38, 43]                  [3, 9, 10, 82]
                 \                        /
                 [3, 9, 10, 27, 38, 43, 82]
def merge_sort(arr):
    if len(arr) <= 1:                  # base case: 0 or 1 element is sorted
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return _merge(left, right)


def _merge(left, right):
    """Merge two sorted lists into one sorted list. O(len(left) + len(right))."""
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        # `<=` rather than `<` is what makes merge sort stable: on a tie, the
        # element from the left half — the one that came first — is taken first.
        if left[i] <= right[j]:
            out.append(left[i])
            i += 1
        else:
            out.append(right[j])
            j += 1
    out.extend(left[i:])               # one of these is empty
    out.extend(right[j:])
    return out

Complexity: O(n log n) in all cases. The recursion tree has log₂ n levels and each level does O(n) merging work; the recurrence T(n) = 2T(n/2) + O(n) solves to Θ(n log n) by the Master Theorem. Space is O(n) for the merge buffers — this is merge sort’s one real weakness, and the reason it lost to quicksort as the default in-memory sort. Stable.

Merge sort’s strengths are exactly where quicksort is weak:

Divide and conquer: quicksort

Pick a pivot, partition the array so that everything smaller is on the left and everything larger is on the right, then recurse on both sides. Unlike merge sort, all the work is in the divide step and the combine step is free.

import random


def quick_sort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    while lo < hi:
        p = _partition(arr, lo, hi)
        # Recurse into the smaller side and loop on the larger one. This keeps
        # the recursion depth at O(log n) even when the splits are bad.
        if p - lo < hi - p:
            quick_sort(arr, lo, p - 1)
            lo = p + 1
        else:
            quick_sort(arr, p + 1, hi)
            hi = p - 1
    return arr


def _partition(arr, lo, hi):
    """Lomuto partition with a randomly chosen pivot.
    Returns the index where the pivot ended up — its final sorted position."""
    r = random.randint(lo, hi)
    arr[r], arr[hi] = arr[hi], arr[r]      # park the pivot at the end
    pivot = arr[hi]
    i = lo                                 # invariant: arr[lo:i] < pivot
    for j in range(lo, hi):
        if arr[j] < pivot:
            arr[i], arr[j] = arr[j], arr[i]
            i += 1
    arr[i], arr[hi] = arr[hi], arr[i]      # pivot into its final place
    return i

Complexity: O(n log n) average, O(n²) worst case, O(log n) space (recursion stack only — the partition is in-place). Not stable, because partitioning swaps across long distances.

The worst case happens when the pivot is always the minimum or maximum, so each partition peels off one element and the recursion is n levels deep: T(n) = T(n−1) + O(n) = O(n²). With a fixed “first element” pivot, already-sorted input is the worst case — which is a spectacularly bad property, because sorted or nearly-sorted input is extremely common in real data. This is not a hypothetical: it caused real denial-of-service vulnerabilities before implementations moved to randomization.

Pivot strategies, in increasing order of robustness:

StrategyWorst case triggered by
First/last elementSorted or reverse-sorted input — very common
Middle elementA crafted input; safe against sorted data
Median-of-three (first, middle, last)A crafted “median-of-three killer” input
Random pivotNothing an attacker can construct in advance — worst case has probability O(1/n!)
Median-of-mediansNothing — guarantees O(n log n), but the constant is so large it is never used in practice

Two more practical points:

Despite the worse worst case, quicksort is usually faster than merge sort in wall-clock terms: it is in-place (no allocation, no memory traffic), its inner loop is a tight sequential scan that the cache and the branch predictor love, and its constant factor is roughly half merge sort’s.

Heap sort

Build a max-heap from the array in place, then repeatedly swap the root (the maximum) with the last element of the heap and shrink the heap by one, restoring the heap property each time. The array grows a sorted suffix from the right.

def _sift_down(arr, start, end):
    """Restore the max-heap property at index `start` within arr[0:end]."""
    root = start
    while True:
        child = 2 * root + 1               # left child in the array layout
        if child >= end:
            return
        if child + 1 < end and arr[child] < arr[child + 1]:
            child += 1                     # take the larger of the two children
        if arr[root] >= arr[child]:
            return                         # heap property already holds
        arr[root], arr[child] = arr[child], arr[root]
        root = child


def heap_sort(arr):
    n = len(arr)
    # Phase 1: heapify bottom-up. This is O(n), NOT O(n log n) — most nodes are
    # near the leaves and sift down only a level or two.
    for start in range(n // 2 - 1, -1, -1):
        _sift_down(arr, start, n)
    # Phase 2: repeatedly extract the max into the growing sorted suffix.
    for end in range(n - 1, 0, -1):
        arr[0], arr[end] = arr[end], arr[0]   # max goes to its final position
        _sift_down(arr, 0, end)               # restore the heap on the prefix
    return arr

Complexity: O(n log n) in all cases — build is O(n), then n extractions at O(log n) each. O(1) space, genuinely in-place. Not stable.

Heap sort is the only algorithm here with both a guaranteed O(n log n) bound and O(1) space. Yet it is rarely the fastest: it accesses memory in a jumping pattern (index i2i+1), which is cache-hostile, and it performs roughly twice quicksort’s comparisons. Its real production role is as the fallback in introsort — quicksort’s speed with heap sort’s guarantee, which is exactly the trade-off C++‘s std::sort makes.

Key Concepts

The Ω(n log n) lower bound for comparison sorts

Any algorithm that learns about the input only by comparing pairs of elements can be modelled as a decision tree: each internal node is a comparison, each branch is an outcome, each leaf is a permutation the algorithm can output.

                        a₁ : a₂
                    ≤ /         \ >
              a₂ : a₃             a₁ : a₃
             /       \           /       \
       ⟨1,2,3⟩    a₁ : a₃   ⟨2,1,3⟩    a₂ : a₃
                  /     \               /     \
            ⟨1,3,2⟩  ⟨3,1,2⟩      ⟨2,3,1⟩  ⟨3,2,1⟩

The argument is three lines:

  1. To sort correctly, the tree must have at least n! leaves — one per possible input permutation, since the algorithm must be able to produce any of them.
  2. A binary tree with L leaves has height at least log₂ L. The worst-case number of comparisons is the height.
  3. log₂(n!) = Θ(n log n) by Stirling’s approximation (n! ≈ (n/e)ⁿ√(2πn), so log₂(n!) ≥ n log₂ n − n log₂ e).

Therefore every comparison sort needs Ω(n log n) comparisons in the worst case. Merge sort and heap sort match the bound and are therefore asymptotically optimal. There is no clever comparison sort waiting to be discovered — the bound is a theorem, not a conjecture.

Note precisely what it constrains: comparisons, in the worst case. It says nothing about algorithms that inspect the elements themselves.

Non-comparison sorts: getting around the bound

If you can look inside the keys — read their digits, use them as array indices — you are outside the decision-tree model and the bound does not apply.

Counting sort

For integer keys in a small known range [0, k]: count the occurrences of each value, prefix-sum the counts into starting positions, and place each element directly.

def counting_sort(arr, k):
    """Stable counting sort of integers in [0, k]. O(n + k) time, O(n + k) space."""
    counts = [0] * (k + 1)
    for x in arr:
        counts[x] += 1
    # Prefix sums turn "how many equal v" into "index just past the last slot for v"
    for v in range(1, k + 1):
        counts[v] += counts[v - 1]
    out = [None] * len(arr)
    for x in reversed(arr):            # iterate backwards to preserve stability
        counts[x] -= 1
        out[counts[x]] = x
    return out


assert counting_sort([4, 2, 2, 8, 3, 3, 1], 8) == [1, 2, 2, 3, 3, 4, 8]

O(n + k) time and space. Linear when k = O(n), and useless when k is huge — counting-sorting 1000 random 64-bit integers would allocate an array of 2⁶⁴ counters. Stability matters here because counting sort is the inner loop of radix sort.

Radix sort

Sort by each digit position in turn, least significant first (LSD), using a stable sort per digit. Stability is not a nice-to-have: it is what preserves the ordering established by the previous, less significant digits.

input     pass 1 (units)   pass 2 (tens)   pass 3 (hundreds)
 170        170              802             002
  45        090              002             045
  75        802              024             066
  90        024              045             075
 802        045              075             090
  24        075              170             170
   2        066              090             802
  66        002              066             ...
def radix_sort(arr, base=10):
    """LSD radix sort of non-negative integers. O(d * (n + base)) time,
    where d is the number of digits in the largest value."""
    if not arr:
        return []
    out = list(arr)
    max_val = max(out)
    exp = 1
    while max_val // exp > 0:
        buckets = [[] for _ in range(base)]
        for x in out:
            buckets[(x // exp) % base].append(x)   # stable: appended in order
        out = [x for bucket in buckets for x in bucket]
        exp *= base
    return out


assert radix_sort([170, 45, 75, 90, 802, 24, 2, 66]) == [2, 24, 45, 66, 75, 90, 170, 802]

O(d·(n + b)) where d is the digit count and b the base. For fixed-width keys (32-bit integers, b = 256, d = 4) that is O(n) — genuinely linear. Real implementations use base 256 and bit shifts, not base 10 and division. Radix sort is what database engines and columnar analytics systems use for integer columns, and it comfortably beats O(n log n) sorts on large integer arrays.

Bucket sort

Scatter elements into n buckets by value, sort each bucket, concatenate. Works when the input is roughly uniformly distributed over a known range.

def bucket_sort(arr, n_buckets=None):
    """Bucket sort for floats uniformly distributed in [0, 1).
    O(n) expected, O(n^2) worst case (everything in one bucket)."""
    if not arr:
        return []
    n = n_buckets or len(arr)
    buckets = [[] for _ in range(n)]
    for x in arr:
        buckets[min(int(x * n), n - 1)].append(x)
    out = []
    for bucket in buckets:
        out.extend(insertion_sort(bucket))   # buckets are expected to hold ~1 element
    return out

O(n) expected — and that expectation is entirely conditional on the distribution. Feed it skewed data and everything lands in one bucket, giving the inner sort’s O(n²). Bucket sort is the algorithm you reach for when you know something concrete about your data (uniformly distributed sensor readings, hash values, normalized scores) and not otherwise.

Full comparison table

AlgorithmBestAverageWorstSpaceStableIn-placeNotes
Bubble sortO(n)¹O(n²)O(n²)O(1)YesYesMost swaps of the three; teaching only
Selection sortO(n²)O(n²)O(n²)O(1)NoYesExactly n−1 swaps — minimal writes
Insertion sortO(n)O(n²)O(n²)O(1)YesYesAdaptive, online; base case of real sorts
Merge sortO(n log n)O(n log n)O(n log n)O(n)YesNoGuaranteed bound; linked lists & external sort
QuicksortO(n log n)O(n log n)O(n²)²O(log n)NoYesFastest in practice; needs a good pivot
Heap sortO(n log n)O(n log n)O(n log n)O(1)NoYesOnly guaranteed + in-place; cache-hostile
TimsortO(n)O(n log n)O(n log n)O(n)YesNoPython/Java default; exploits existing runs
Counting sortO(n + k)O(n + k)O(n + k)O(n + k)YesNoSmall integer range only
Radix sort (LSD)O(d(n + b))O(d(n + b))O(d(n + b))O(n + b)YesNoFixed-width keys; linear in practice
Bucket sortO(n + k)O(n + k)O(n²)O(n)Yes³NoRequires a uniform distribution

¹ With the early-exit swapped flag; without it, O(n²). ² O(n log n) with high probability given a random pivot; O(n log n) guaranteed with introsort’s heap-sort fallback. ³ Stable if the per-bucket sort is stable.

What real standard libraries use

Nobody ships a textbook sort. Every production sort is a hybrid, and reading them is the best possible summary of everything above.

Timsort — Python list.sort() / sorted(), Java Arrays.sort for objects

Invented by Tim Peters for CPython in 2002 on the observation that real data is rarely random — it is usually partly sorted, concatenated from sorted pieces, or sorted by a different key.

  1. Scan the array for natural runs — maximal already-ascending or strictly-descending stretches (descending ones are reversed in place, which keeps stability).
  2. Runs shorter than minrun (32–64, computed from n) are extended to minrun with binary insertion sort.
  3. Runs are pushed on a stack and merged under invariants that keep the run lengths balanced, so the merges are O(n log n).
  4. Galloping mode: when one run keeps winning the merge, switch from linear stepping to exponential search to skip its elements in O(log n) instead of O(k).

Result: O(n) on already-sorted or reverse-sorted input, O(n log n) worst case, stable, and dramatically faster than plain merge sort on real-world data. Python’s sort stability is a documented language guarantee, not an implementation detail — you may rely on it.

Introsort — C++ std::sort

Quicksort, but instrumented:

You get quicksort’s average speed with heap sort’s worst-case guarantee. std::sort is not stable; std::stable_sort is a separate function (an adaptive merge sort) precisely because stability costs memory.

pdqsort — Rust sort_unstable, Go 1.19+ sort/slices.Sort

“Pattern-defeating quicksort” extends introsort:

Rust’s naming is honest about the trade-off: sort() is stable and allocates; sort_unstable() is pdqsort, in-place, and faster. Go switched its standard sort from an introsort variant to pdqsort in 1.19 for the same reasons.

Sorting in Python, practically

from operator import itemgetter, attrgetter

people = [("Chi", 40), ("Anh", 31), ("Binh", 31)]

# `sorted` returns a new list; `list.sort()` sorts in place and returns None.
by_age = sorted(people, key=itemgetter(1))          # O(n log n), stable
people.sort(key=itemgetter(1))                      # no copy, slightly faster

# Multi-key sort, ascending on both — build a tuple key
by_age_then_name = sorted(people, key=lambda p: (p[1], p[0]))

# Mixed directions: rely on STABILITY and sort by the least significant key
# first. This is the same principle that makes LSD radix sort work.
rows = sorted(people, key=itemgetter(0))            # secondary: name ascending
rows = sorted(rows, key=itemgetter(1), reverse=True) # primary: age descending

# The key function is called exactly once per element (decorate-sort-undecorate),
# so an expensive key is fine. A cmp-style comparator is NOT: functools.cmp_to_key
# is called O(n log n) times and is markedly slower — use it only when the order
# genuinely cannot be expressed as a key.

Two habits that matter more than the algorithm choice:

Best Practices

References