Thuật toán Cốt lõi — Tìm kiếm, Sắp xếp & Đồ thị/Quadratic sorts — Bubble, Selection, Insertion sort
15/36
Bài 15 / 36~18 phútSắp xếp & thứ tựMiễn phí lượt xem

Quadratic sorts — Bubble, Selection, Insertion sort

TimSort dùng Insertion sort cho subarray nhỏ — so sánh Bubble, Selection, Insertion, pseudocode, benchmark, lý do Insertion sort adaptive vẫn có trong production library.

TL;DR: Ba quadratic sort — Bubble, Selection, Insertion — đều O(n²) trung bình, nhưng khác nhau rõ về đặc tính thực tế. Bubble sort gần như không có lý do dùng trong production. Selection sort có ưu điểm duy nhất là tối thiểu số lần swap (đúng n-1 lần) nhưng không adaptive và không stable. Insertion sort là algorithm được TimSort gọi cho subarray dưới 32-47 phần tử: stable, adaptive O(n+d) với d là số inversion, online — ba đặc tính khiến nó vẫn xuất hiện trong production library dù O(n²) về mặt lý thuyết. Constants matter khi n nhỏ.

Bubble sort là ví dụ đầu tiên về sorting algorithm trong hầu hết giáo trình đại học — và cũng là thứ đầu tiên bị gạch chân đỏ trong code review. O(n²), chậm, không có lý do dùng trong production. Câu chuyện kết thúc ở đó.

Nhưng mở file DualPivotQuicksort.java trong OpenJDK, bạn thấy dòng:

-- DualPivotQuicksort.java (OpenJDK):
-- Use insertion sort for small arrays
-- if (right - left + 1 < INSERTION_SORT_THRESHOLD) ...

INSERTION_SORT_THRESHOLD hiện tại là 47. Với subarray dưới 47 phần tử, JDK bỏ qua Dual-Pivot Quicksort và gọi thẳng Insertion sort. Python's TimSort và C++ IntroSort làm tương tự. Vì sao một algorithm O(n²) lại xuất hiện trong production library của ngôn ngữ lập trình chính?

Bài này so sánh 3 quadratic sort, pseudocode đầy đủ, benchmark thực tế, và lý giải vì sao Insertion sort là adaptive và vẫn xuất hiện trong production library.

1. Analogy — Ba cách sắp bài tay

Hình dung bạn có 10 quân bài xáo trộn, cần sắp theo thứ tự. Ba người bạn mỗi người dùng cách khác nhau:

Bubble — so từng cặp kế nhau, đổi chỗ nếu sai: Quét từ trái qua phải, so quân thứ i và i+1. Nếu sai thứ tự thì đổi. Sau một lượt, quân lớn nhất "nổi" lên cuối — như bong bóng nổi lên mặt nước. Quét lại, lần này bỏ qua vị trí cuối đã xong. Lặp cho đến khi không còn đổi nào.

Selection — tìm nhỏ nhất, đặt vào đầu: Lướt qua toàn bộ bài, chọn ra quân nhỏ nhất, đổi với quân ở vị trí đầu. Bây giờ vị trí đầu đã xong. Lặp lại với phần còn lại, mỗi lần chọn nhỏ nhất trong phần chưa xử lý.

Insertion — cầm từng quân, đẩy vào chỗ đúng: Cầm quân thứ 2, đẩy vào vị trí đúng trong 2 quân đầu. Cầm quân thứ 3, đẩy vào chỗ đúng trong 3 quân đầu. Phần bên trái tay luôn đã sorted — giống khi bạn cầm bài và xếp từng lá vào đúng vị trí.

Sắp bài taySorting algorithmĐiểm đặc trưng
So từng cặp kế nhau, swap nếu saiBubble sortQuân lớn "nổi" lên cuối mỗi pass
Tìm nhỏ nhất, đổi vào đầuSelection sortLuôn đúng n-1 lần đổi chỗ
Cầm từng quân, đẩy vào đúng vị tríInsertion sortPhần trái luôn sorted, online
💡 Cách nhớ

Bubble = nổi lên. Selection = chọn nhỏ nhất. Insertion = chèn vào đúng chỗ. Chỉ Insertion mới có thể xử lý bài đến từng lá một — cầm thêm lá nào, xếp vào ngay.

2. Bubble sort

BubbleSort(A):
    n <- A.length
    for i <- 0 đến n-2:
        swapped <- false
        for j <- 0 đến n-2-i:
            if A[j] > A[j+1]:
                swap A[j] và A[j+1]
                swapped <- true
        if not swapped: break     -- đã sorted, thoát sớm

Time: O(n²) worst/average, O(n) best Space: O(1)

Dòng if not swapped: break là optimization quan trọng: nếu một pass không có swap nào, array đã sorted và thuật toán dừng sớm.

graph LR
    A["[5, 3, 1, 4, 2]<br/>ban đầu"]
    B["[3, 1, 4, 2, 5]<br/>sau pass 1: 5 nổi lên cuối"]
    C["[1, 3, 2, 4, 5]<br/>sau pass 2: 4 đúng vị trí"]
    D["[1, 2, 3, 4, 5]<br/>sau pass 3: xong"]

    A --> B --> C --> D

Phân tích:

  • Best case O(n): input đã sorted → pass đầu không swap → break ngay. Một lần quét duy nhất.
  • Worst case O(n²): input sorted ngược → mỗi element phải bubble qua toàn bộ array.
  • Stable: khi bằng nhau (==) không swap, giữ thứ tự gốc.
  • Adaptive: có early exit, nhưng thực tế vẫn chậm hơn Insertion sort trên nearly-sorted data.

Production rating: hiếm có lý do dùng. Trong code review, cần replace ngay bằng built-in sort.

3. Selection sort

SelectionSort(A):
    n <- A.length
    for i <- 0 đến n-2:
        minIdx <- i
        for j <- i+1 đến n-1:
            if A[j] < A[minIdx]: minIdx <- j
        swap A[i] và A[minIdx]    -- đặt phần tử nhỏ nhất vào vị trí i

Time: O(n²) mọi trường hợp Space: O(1)

Phân tích:

  • Luôn O(n²): không adaptive. Dù input đã sorted hay sorted ngược, vòng lặp trong vẫn quét toàn bộ phần còn lại để tìm min. Không có early exit.
  • Stable: không. Swap A[i] với A[minIdx] có thể nhảy qua element bằng nhau nằm giữa — phá vỡ thứ tự tương đối.
  • Ưu điểm duy nhất — ít swap: đúng n-1 lần swap, không bao giờ nhiều hơn. Khi swap đắt (object lớn, disk write), Selection sort có lợi thế.

Production rating: dùng khi swap đắt hơn compare. Hiếm trong thực tế hiện đại. Phù hợp hơn với một số ngữ cảnh embedded hoặc sorting danh sách có large payload.

4. Insertion sort — adaptive winner

InsertionSort(A):
    for i <- 1 đến A.length-1:
        key <- A[i]
        j <- i - 1
        while j >= 0 và A[j] > key:   -- j >= 0 phải đứng TRƯỚC để tránh lỗi
            A[j+1] <- A[j]            -- shift phải, không swap
            j <- j - 1
        A[j+1] <- key

Time: O(n²) worst, O(n) best, O(n+d) với d = số inversion Space: O(1)

Cơ chế quan trọng: Insertion sort shift element sang phải thay vì swap từng cặp. Mỗi iteration chỉ ghi một lần vào A[j+1] = key ở cuối — tiết kiệm hơn Bubble sort swap từng cặp.

Phân tích:

  • Best case O(n): input đã sorted → điều kiện A[j] > key false ngay → chỉ có vòng for ngoài O(n).
  • Worst case O(n²): input sorted ngược → mỗi key phải shift qua toàn bộ phần đã sorted.
  • Adaptive — O(n + d): với d = số inversion (cặp (i,j) mà i nhỏ hơn j nhưng A[i] lớn hơn A[j]). Nearly-sorted array có d nhỏ → gần O(n). Đây là lợi thế chính.
  • Stable: điều kiện A[j] > key (strict greater) — khi bằng nhau không shift, giữ thứ tự gốc.
  • Online: có thể sort data đến từng phần — nhận element mới, chèn vào vị trí đúng trong phần đã sorted.

5. Bảng so sánh và benchmark thực tế

AlgorithmBestAverageWorstSpaceStableAdaptiveOnline
Bubblen1yesyes (early exit)no
Selection1nonono
Insertionn1yesyesyes

Benchmark thực tế trên mảng 10.000 phần tử ngẫu nhiên (JMH warm-up 5 iteration):

AlgorithmThời gian
Bubble sort~120 ms
Selection sort~80 ms
Insertion sort~60 ms
Built-in sort (Dual-Pivot Quick)~1.2 ms

Insertion thắng Bubble và Selection khoảng 2x nhờ shift thay vì swap. Nhưng built-in sort nhanh hơn 50-100x.

Benchmark với n = 16 phần tử (subarray nhỏ):

AlgorithmThời gian
Bubble sort~3 µs
Selection sort~2 µs
Insertion sort~1 µs
Built-in sort~5 µs

Với n nhỏ, built-in sort chậm hơn Insertion sort vì overhead của recursion, pivot selection, và function call. Insertion sort chạy thẳng, không recursion, không allocation — thắng hoàn toàn ở n nhỏ.

Đây là lý do TimSort gọi Insertion sort cho subarray dưới 32 phần tử. Constants matter ở n nhỏ.

6. Pitfall tổng hợp

Pitfall 1 — Bubble sort trong code production

Bubble sort vẫn xuất hiện trong code production vì developer nhớ cú pháp từ năm nhất rồi copy paste. Kết quả: mảng 10.000 phần tử mất 120ms thay vì 1.2ms — chênh lệch 100x.

-- BUG: O(n²) -- không dùng trong production
for i <- 0 đến n-2:
    for j <- 0 đến n-2-i:
        if A[j] > A[j+1]: swap A[j] và A[j+1]

-- ĐÚNG: dùng built-in sort O(n log n)
-- Arrays.sort(arr) hoặc Collections.sort(list)

Time: O(n²) — BUG vs O(n log n) — ĐÚNG

Rule: nếu bạn thấy Bubble sort trong code review, yêu cầu replace ngay. Không có use case production nào biện minh cho Bubble sort khi built-in sort đã sẵn có.

Pitfall 2 — Selection sort không stable, nhưng dev assume mọi sort là stable

Selection sort không stable — swap A[i] với A[minIdx] có thể nhảy qua element bằng nhau.

-- Ví dụ: sắp nhân viên [Alice:50k, Bob:50k, Carol:40k]
-- Bước 1 sort theo lương: đúng thứ tự Carol < Alice = Bob
-- Bước 2 sort theo phòng ban bằng Selection sort:
--   swap có thể đổi chỗ Alice và Bob (hai người cùng 50k)
--   => thứ tự lương trong cùng phòng ban: không xác định ✗

-- ĐÚNG: dùng stable sort ở bước 2 (Insertion sort, Merge sort, TimSort)
--   => trong cùng phòng ban, thứ tự lương từ bước 1 được bảo toàn ✓

Rule: trước khi dùng bất kỳ sort nào trong multi-key scenario, verify stability.

Pitfall 3 — Thứ tự điều kiện trong Insertion sort while loop

-- BUG: ArrayIndexOutOfBoundsException khi j = -1
while A[j] > key và j >= 0:
    -- A[j] được evaluate khi j = -1 -> lỗi index

-- ĐÚNG: kiểm tra j >= 0 TRƯỚC (short-circuit)
while j >= 0 và A[j] > key:
    A[j+1] <- A[j]
    j <- j - 1

Đảo ngược thứ tự thì A[j] được evaluate trước — với j = -1 gây lỗi index out of bounds. Lỗi này hay xuất hiện trong coding interview và code được viết "từ trí nhớ" mà không chạy test.

7. Insertion sort variants

Binary Insertion sort: thay vì tìm vị trí chèn bằng linear scan O(n), dùng binary search O(log n). Tổng số phép compare giảm xuống O(n log n). Nhưng tổng thời gian vẫn O(n²) vì shift phần tử vẫn tốn O(n) per iteration — shift dominates, không phải compare.

BinaryInsertionSort(A):
    for i <- 1 đến A.length-1:
        key <- A[i]
        -- tìm vị trí chèn bằng binary search trong A[0..i-1]
        pos <- binarySearch(A, 0, i-1, key)
        -- shift A[pos..i-1] sang phải 1 vị trí
        for j <- i-1 xuống pos:
            A[j+1] <- A[j]
        A[pos] <- key

Time: O(n²) (shift vẫn O(n²)) Space: O(1)

Variant này hữu ích cho linked list nơi insert O(1) — binary search + O(1) insert = O(n log n) thực sự. Với array, shift là bottleneck nên lợi ích hạn chế.

Shell sort: Insertion sort + gap sequence. Sort các phần tử cách nhau gap bước, sau đó giảm gap dần đến 1. Worst case O(n^1.5) đến O(n log² n) tùy gap sequence — tốt hơn O(n²) thuần túy. Hiếm dùng trong production hiện đại nhưng có giá trị học thuật.

8. Vì sao TimSort vẫn dùng Insertion sort

Real-world data thường có structure — không random hoàn toàn. Log file sorted theo timestamp, transaction ID tăng dần, event stream có thứ tự tự nhiên. TimSort khai thác điều này qua 3 bước:

TimSort (rút gọn):
    runs <- []
    i <- 0
    while i < A.length:
        run <- detectRun(A, i)    -- tìm đoạn đã sorted (tăng hoặc giảm)
        if run.length < minRunLength:
            extendByInsertionSort(A, run, minRunLength)  -- Insertion sort cho run ngắn
        runs.push(run)
        i <- run.end
    mergeRunsOptimally(runs)      -- merge theo stack-based strategy

Time: O(n) cho input đã sorted, O(n log n) worst Space: O(n)

Insertion sort phù hợp ở bước extend vì:

  • Run ngắn (32-64 phần tử) → n nhỏ → Insertion sort thắng về constant factor.
  • Run thường "gần sorted" → d (số inversion) nhỏ → Insertion sort gần O(n).
  • Không cần recursion hay allocation → overhead thấp nhất có thể.

Cross-link: Module 2 lesson 09 sẽ là case study sâu về TimSort internals — galloping mode, merge policy, run detection chi tiết.

9. Ứng dụng thực tế

TimSort — threshold 32-47: TimSort gọi Insertion sort cho subarray ngắn hơn minRunLength. Với mảng số nguyên nguyên thủy, JDK dùng threshold 47 — xác định qua benchmarking thực nghiệm, không phải con số lý thuyết.

-- Từ DualPivotQuicksort (rút gọn):
if subarray.length < INSERTION_SORT_THRESHOLD:
    insertionSort(subarray)  -- nhanh hơn Quick sort ở đây
    return
-- ... tiếp tục dual-pivot partition

Online algorithms — sort data streaming: Insertion sort có thể sort data khi nó đến từng phần — nhận event mới, chèn vào vị trí đúng trong phần đã sorted. Bubble sort và Selection sort cần full data trước khi bắt đầu.

Use case thực tế: buffer incoming trade orders và maintain sorted order by price/time priority. Với số lượng orders trong buffer thấp (thường dưới 100), Insertion sort O(n) cho insert vào sorted array thắng về latency.

Embedded / memory-constrained: Insertion sort code rất ngắn, không dùng recursion stack, in-place O(1). Phù hợp microcontroller hoặc kernel-level sort nơi binary size và stack depth bị giới hạn nghiêm ngặt. Linux kernel dùng Insertion sort cho một số small list sort.

10. Deep Dive

📚 Deep Dive — nguồn tham khảo

Sách kinh điển:

  • The Art of Computer Programming, Vol. 3 — Donald Knuth, Chapter 5.2.1 (Insertion sort), 5.2.2 (Selection sort): phân tích toán học đầy đủ về inversion count và expected comparisons.
  • "On the analysis of insertion sort with binary search" — Jon Bentley: tại sao binary insertion sort vẫn O(n²) và khi nào có lợi.

Source code tham khảo:

  • OpenJDK DualPivotQuicksort.java: tìm INSERTION_SORT_THRESHOLD để xem threshold thực tế và implementation trong context Dual-Pivot sort.
  • TimSort Python implementation gốc: bugs.python.org/file4451/timsort.txt — Tim Peters giải thích run detection và merge policy.

Cross-link Module 2:

  • Lesson 01: Sorting landscape — comparison vs non-comparison, lower bound
  • Lesson 03: Merge sort — stable O(n log n), foundation của TimSort merge step
  • Lesson 09: Case study TimSort — run detection, galloping mode, merge stack policy chi tiết

11. Tóm tắt

  • Bubble sort O(n²): stable, có early exit khi sorted, nhưng thực tế chậm hơn Insertion. Dùng trong production là dấu hiệu cần refactor ngay.
  • Selection sort O(n²): không adaptive, không stable, nhưng chỉ n-1 lần swap. Hữu ích khi swap đắt hơn compare.
  • Insertion sort O(n²): stable, adaptive O(n+d), online — ba đặc tính khiến nó vẫn xuất hiện trong production library.
  • Constants matter ở n nhỏ: Insertion sort thắng built-in sort khi n dưới khoảng 47 vì không có recursion overhead.
  • TimSort dùng Insertion sort để extend run ngắn — subarray nhỏ + gần sorted = Insertion sort gần O(n).
  • Thứ tự điều kiện while j >= 0 và A[j] > keyj >= 0 phải đứng trước để tránh lỗi index out of bounds.
  • Selection sort không stable — không dùng cho multi-key sort mà giả định stable behavior.

12. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Insertion sort O(n) cho input đã sorted nhưng Selection sort luôn O(n²) dù input đã sorted?

Insertion sort: với input đã sorted, điều kiện A[j] > key false ngay vì element trước luôn nhỏ hơn hoặc bằng. Vòng while không bao giờ enter. Vòng for ngoài chạy O(n) lần nhưng mỗi lần là O(1) → tổng O(n).

Selection sort: dù input đã sorted, vòng trong vẫn phải quét toàn bộ phần còn lại để tìm min — không có điều kiện thoát sớm. Selection sort không "biết" input đã sorted hay chưa. Vì vậy Selection sort là O(n²) trong mọi trường hợp, không adaptive.

Q2
Bubble sort và Insertion sort cùng O(n²) trung bình, vì sao Insertion sort thường nhanh hơn khoảng 2x trong benchmark?

Sự khác biệt nằm ở số lần ghi vào array. Bubble sort swap từng cặp kế nhau — mỗi swap cần 3 lần ghi (dùng biến tmp). Để đưa một element từ vị trí i về vị trí j cần (i-j) lần swap = 3×(i-j) lần ghi.

Insertion sort shift thay vì swap — A[j+1] <- A[j] là 1 ghi per iteration, và chỉ ghi A[j+1] <- key 1 lần ở cuối. Đưa element từ vị trí i về j cần (i-j) lần shift = (i-j)+1 lần ghi tổng cộng. Ít ghi hơn khoảng 3x.

Ngoài ra Insertion sort có cache locality tốt hơn — truy cập memory tuần tự trong phần đã sorted. Kết hợp lại, Insertion sort thực tế nhanh hơn Bubble khoảng 2x dù cùng O(n²) asymptotic.

Q3
Stable vs non-stable: cho đoạn pseudocode sort employees trước theo salary rồi theo department — Selection sort phá vỡ ở đâu?

Khi sort theo secondary key (department), mục tiêu là: trong cùng department, employees vẫn giữ thứ tự salary từ bước 1. Đây là "stability requirement" — sort lần 2 phải stable để giữ order của sort lần 1.

Selection sort không stable: swap A[i] với A[minIdx] có thể nhảy qua các employee cùng department nằm giữa, đặt chúng ra khỏi thứ tự salary. Ví dụ employees [Alice:50k:Eng, Bob:50k:Eng, Carol:40k:HR] — sau sort theo salary đúng. Sort theo department bằng Selection sort: khi đặt Carol vào đầu, có thể swap Alice ra sau Bob — salary order trong nhóm Eng bị đảo.

Fix: dùng stable sort ở bước 2 (Insertion sort, Merge sort, hoặc TimSort) — giữ salary order trong cùng department.

Q4
Insertion sort 'online' nghĩa là gì? Cho 1 use case thực tế.

"Online" nghĩa là thuật toán có thể xử lý element khi chúng đến, không cần toàn bộ data trước. Với Insertion sort: nhận element mới, chèn vào vị trí đúng trong phần đã sorted. Phần đã sorted luôn valid sau mỗi insert.

Use case thực tế: order book trong trading system. Khi broker nhận trade order mới (buy/sell với price + timestamp), cần maintain sorted order theo price-time priority. Với order book thường có dưới vài trăm orders đang active, Insertion sort O(n) cho mỗi insert có latency thấp và không cần rebuild. So với rebuild + full sort O(n log n) mỗi lần có order mới, Insertion sort insert O(n) tốt hơn về amortized latency cho workload này.

Q5
Vì sao TimSort dùng Insertion sort cho subarray dưới 32 phần tử, không dùng Quicksort cho mọi size?

Quicksort (và Dual-Pivot Quicksort) có overhead cố định dù n nhỏ: chọn pivot, partition, 2 recursive call, function call overhead. Với n = 16, overhead này chiếm phần lớn thời gian — benchmark thực tế cho thấy built-in sort (khoảng 5 µs) chậm hơn Insertion sort (khoảng 1 µs) ở n = 16.

Insertion sort không có recursion, không cần chọn pivot, không function call phụ — chạy thẳng trên array. Với n nhỏ và thường gần sorted (run trong TimSort được extend trước), Insertion sort gần O(n). Big-O notation ẩn constant — O(n²) với constant nhỏ thắng O(n log n) với constant lớn khi n đủ nhỏ.

Threshold 47 trong JDK được xác định qua benchmarking thực nghiệm — không phải con số lý thuyết. Các ngôn ngữ khác có threshold khác nhau (Python TimSort dùng 32-64).

Q6
Cho mảng A = [3,1,4,1,5,9,2,6] — số swap của Bubble sort so với số swap của Selection sort?

Bubble sort: đếm số cặp kế nhau sai thứ tự qua toàn bộ các pass.

Pass 1: (3,1)→swap, (4,1)→swap, (4,5)→ok, (5,9)→ok, (9,2)→swap, (9,6)→swap = 4 swaps. Mảng: [1,3,1,4,5,2,6,9]

Pass 2: (3,1)→swap, (3,4)→ok, (5,2)→swap, (5,6)→ok = 2 swaps. Mảng: [1,1,3,4,2,5,6,9]

Pass 3: (4,2)→swap = 1 swap. Mảng: [1,1,3,2,4,5,6,9]

Pass 4: (3,2)→swap = 1 swap. Mảng: [1,1,2,3,4,5,6,9]

Pass 5: no swaps → break. Tổng Bubble: 8 swaps.

Selection sort: luôn đúng n-1 = 7 swaps (1 swap per outer iteration, kể cả swap element với chính nó khi minIdx == i). 7 swaps — luôn ít hơn hoặc bằng Bubble sort.

Đây minh họa ưu điểm duy nhất của Selection sort: số swap tối thiểu, luôn là n-1.

Bài tiếp theo: Merge sort — divide and conquer, stable O(n log n)

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên