排序演算法 — Bubble、Merge、Quick Sort 等十大排序完整比較 | 資料結構與演算法
排序演算法(Sorting Algorithm) 是電腦科學中最基礎也最重要的演算法類別。從 Bubble Sort 的逐步交換,到 Merge Sort 的分治合併,再到突破比較框架的 Counting Sort——每種排序都有其適用場景與設計哲學。本文帶你完整走過十大排序演算法,搭配 TypeScript 與 C++ 實作,掌握選擇排序策略的決策框架。
前言
想像一座巨大的圖書館,館員需要將數萬本書依書號重新排列上架。如果每次只能比較兩本書的書號並決定要不要交換位置,需要多少步驟才能完成?更有效率的方式,是否是把書先依百位數分堆,再依十位數、個位數分別整理?
這兩種思路,正對應了電腦科學中 比較排序 與 非比較排序 的核心差異。
讀完這篇文章,你將學會:
- 排序的兩大分類:比較排序與非比較排序
- 什麼是 穩定性(Stability),以及為什麼它在多欄位排序時至關重要
- 為什麼比較排序的理論下界是 O(n log n),無法突破
- 六大比較排序(Bubble、Selection、Insertion、Merge、Quick、Heap)的完整 TypeScript 實作與逐行解析
- 三大非比較排序(Counting、Radix、Bucket)的原理與實作
- C++
std::sort、std::stable_sort的正確用法 - 全部十大排序的複雜度大比較表
- 面試考點:如何選擇最合適的排序演算法
核心概念
排序的兩大分類
排序演算法依照核心操作分為兩大類:
比較排序(Comparison Sort):透過比較元素大小來決定順序。每次操作只能問「a 比 b 大嗎?」。包含 Bubble、Selection、Insertion、Merge、Quick、Heap Sort。
非比較排序(Non-Comparison Sort):不依賴元素間的直接比較,而是利用元素值本身的特性(如位數、值域)來分配位置。包含 Counting、Radix、Bucket Sort。
非比較排序可以在特定條件下突破 O(n log n) 的下界,達到 O(n) 或 O(n + k),但代價是對輸入資料有額外限制(通常要求整數或有限值域)。
比較排序的理論下界
信息理論下界:任何基於比較的排序演算法,最壞情況下必須執行 Ω(n log n) 次比較,無法突破。
直覺理解:對 n 個不同元素排序,共有 n! 種可能的排列。每次比較最多將可能性空間減半(是或否),因此至少需要 ⌈log₂(n!)⌉ 次比較。由 Stirling 公式:
log₂(n!) ≈ n log₂(n) - n log₂(e) = Θ(n log n)
這意味著 Merge Sort 和 Heap Sort 的 O(n log n) 是 理論最優,在比較排序的框架內不可能做得更好。
穩定性(Stability)
定義:若兩個元素的鍵值相等,穩定排序(Stable Sort) 保證排序後其相對順序與排序前相同。
為什麼重要? 考慮以下場景:電商平台需要先按評分降序排列商品,再按價格升序排列。如果排序是穩定的,第二次按價格排序後,同價格商品仍保持評分高的在前;若是不穩定排序,評分順序可能被打亂。
| 類別 | 穩定 | 不穩定 |
|---|---|---|
| 比較排序 | Merge Sort、Insertion Sort、Bubble Sort | Quick Sort、Heap Sort、Selection Sort |
| 非比較排序 | Counting Sort、Radix Sort、Bucket Sort(視桶內演算法) | — |
原地排序(In-place)
原地排序(In-place Sort)指除了輸入陣列外,只需 O(1) 額外空間的排序。Selection、Insertion、Bubble、Quick、Heap Sort 都是原地排序;Merge Sort 需要 O(n) 額外空間。
Merge Sort 分治過程圖解
原始陣列:[38, 27, 43, 3, 9, 82, 10]
分割階段(Divide):
[38, 27, 43, 3, 9, 82, 10]
↙ ↘
[38, 27, 43] [3, 9, 82, 10]
↙ ↘ ↙ ↘
[38, 27] [43] [3, 9] [82, 10]
↙ ↘ ↙ ↘ ↙ ↘
[38] [27] [3] [9] [82] [10]
合併階段(Merge):
[38] + [27] → [27, 38]
[27, 38] + [43] → [27, 38, 43]
[3] + [9] → [3, 9]
[82] + [10] → [10, 82]
[3, 9] + [10, 82] → [3, 9, 10, 82]
[27, 38, 43] + [3, 9, 10, 82] → [3, 9, 10, 27, 38, 43, 82]
JS/TS 實作 — 六大比較排序
Bubble Sort(泡沫排序)
最直觀的排序:反覆掃描陣列,每次將相鄰的逆序元素交換,讓最大值「浮」到頂端。加入 swapped 提前終止優化,使最佳情況降為 O(n)。
function bubbleSort(arr: number[]): number[] {
const a = [...arr]; // 不修改原陣列
const n = a.length;
for (let i = 0; i < n - 1; i++) {
let swapped = false;
// 每輪結束後,最後 i 個元素已就位,不需再比較
for (let j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
[a[j], a[j + 1]] = [a[j + 1], a[j]]; // 解構交換
swapped = true;
}
}
// 若本輪無任何交換,陣列已有序,提前結束
if (!swapped) break;
}
return a;
}
// 測試
console.log(bubbleSort([64, 34, 25, 12, 22, 11, 90]));
// [11, 12, 22, 25, 34, 64, 90]
特性:穩定、原地。最佳 O(n)(已有序)、平均/最壞 O(n²)。實務上幾乎不使用,但面試常考其優化思路。
Selection Sort(選擇排序)
每輪從未排序區間找最小值,將其放到正確位置。
function selectionSort(arr: number[]): number[] {
const a = [...arr];
const n = a.length;
for (let i = 0; i < n - 1; i++) {
let minIdx = i; // 假設當前位置是最小值
// 在未排序區間 [i+1, n-1] 中找真正的最小值
for (let j = i + 1; j < n; j++) {
if (a[j] < a[minIdx]) {
minIdx = j;
}
}
// 若最小值不在 i,就交換到正確位置
if (minIdx !== i) {
[a[i], a[minIdx]] = [a[minIdx], a[i]];
}
}
return a;
}
// 測試
console.log(selectionSort([64, 25, 12, 22, 11]));
// [11, 12, 22, 25, 64]
特性:不穩定(交換可能破壞相等元素的相對順序)、原地。所有情況均為 O(n²),但交換次數只有 O(n),適合「交換代價高」的場景。
Insertion Sort(插入排序)
像整理撲克牌:每次將一張新牌插入手牌的正確位置。
function insertionSort(arr: number[]): number[] {
const a = [...arr];
const n = a.length;
for (let i = 1; i < n; i++) {
const key = a[i]; // 待插入的元素
let j = i - 1;
// 將比 key 大的元素往右移,騰出空位
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
j--;
}
// 將 key 插入正確位置
a[j + 1] = key;
}
return a;
}
// 測試
console.log(insertionSort([12, 11, 13, 5, 6]));
// [5, 6, 11, 12, 13]
特性:穩定、原地。最佳 O(n)(近乎有序時極快),適合小規模資料(n < 20)或幾乎有序的陣列。Timsort 的核心元件之一。
Merge Sort(合併排序)
分治法的經典實作:將陣列遞迴分割成左右兩半,各自排序後再合併。
function mergeSort(arr: number[]): number[] {
// 遞迴基底:長度 0 或 1 的陣列已有序
if (arr.length <= 1) return arr;
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid)); // 遞迴排序左半
const right = mergeSort(arr.slice(mid)); // 遞迴排序右半
return merge(left, right);
}
function merge(left: number[], right: number[]): number[] {
const result: number[] = [];
let i = 0;
let j = 0;
// 比較兩個有序陣列的頭部,取較小者放入結果
while (i < left.length && j < right.length) {
// 注意:使用 <= 而非 <,保證穩定性(相等時優先取左側)
if (left[i] <= right[j]) {
result.push(left[i++]);
} else {
result.push(right[j++]);
}
}
// 接上剩餘未處理的元素(只有其中一側會有剩餘)
while (i < left.length) result.push(left[i++]);
while (j < right.length) result.push(right[j++]);
return result;
}
// 測試
const arr1 = [38, 27, 43, 3, 9, 82, 10];
console.log(mergeSort(arr1)); // [3, 9, 10, 27, 38, 43, 82]
常見陷阱:合併時使用 < 而非 <= 會破壞穩定性——相等元素相等時應優先取左側。
特性:穩定、非原地(需 O(n) 額外空間)。所有情況均為 O(n log n),是 Timsort 和外部排序的核心。
Quick Sort(快速排序)
選定一個 pivot,將陣列分為「小於 pivot」和「大於 pivot」兩區,再遞迴排序。使用隨機 pivot 避免最壞情況。
// Hoare Partition:效率較高,交換次數少
function quickSortHoare(arr: number[], low = 0, high = arr.length - 1): void {
if (low >= high) return;
const pivotIdx = hoarePartition(arr, low, high);
quickSortHoare(arr, low, pivotIdx); // 排序左半
quickSortHoare(arr, pivotIdx + 1, high); // 排序右半
}
function hoarePartition(arr: number[], low: number, high: number): number {
// 隨機選 pivot,避免對已排序陣列退化為 O(n²)
const randIdx = low + Math.floor(Math.random() * (high - low + 1));
[arr[low], arr[randIdx]] = [arr[randIdx], arr[low]];
const pivot = arr[low];
let i = low - 1;
let j = high + 1;
while (true) {
do { i++; } while (arr[i] < pivot); // 從左找 >= pivot 的元素
do { j--; } while (arr[j] > pivot); // 從右找 <= pivot 的元素
if (i >= j) return j; // 兩指標相遇,分割完成
[arr[i], arr[j]] = [arr[j], arr[i]]; // 交換兩側不對的元素
}
}
// 三路分割(Dutch National Flag)— 處理大量重複元素的最佳化
// 將陣列分為三區:[< pivot] [== pivot] [> pivot]
function quickSort3Way(arr: number[], low = 0, high = arr.length - 1): void {
if (low >= high) return;
const pivot = arr[low + Math.floor(Math.random() * (high - low + 1))];
let lt = low; // arr[low..lt-1] 全 < pivot
let gt = high; // arr[gt+1..high] 全 > pivot
let i = low; // i 為當前考察指標
while (i <= gt) {
if (arr[i] < pivot) {
[arr[lt], arr[i]] = [arr[i], arr[lt]];
lt++;
i++;
} else if (arr[i] > pivot) {
[arr[i], arr[gt]] = [arr[gt], arr[i]];
gt--;
// i 不前進!換過來的 arr[i] 還未比較
} else {
i++; // arr[i] === pivot,直接略過
}
}
// arr[lt..gt] 全等於 pivot,已就位,不需再遞迴這段
quickSort3Way(arr, low, lt - 1);
quickSort3Way(arr, gt + 1, high);
}
// 測試
const arr2 = [3, 6, 8, 10, 1, 2, 1];
quickSortHoare(arr2);
console.log(arr2); // [1, 1, 2, 3, 6, 8, 10]
const arr3 = [1, 1, 1, 2, 1, 1, 1]; // 大量重複,三路分割效率極佳
quickSort3Way(arr3);
console.log(arr3); // [1, 1, 1, 1, 1, 1, 2]
三路分割的優勢:當陣列有大量重複元素時,標準 Quick Sort 仍需遞迴處理 pivot 所在的每個位置,時間複雜度退化為 O(n²);三路分割將所有等於 pivot 的元素一次就位,遞迴只處理嚴格小於和嚴格大於的部分,實際效率大幅提升。
特性:不穩定、原地(遞迴棧 O(log n))。平均 O(n log n),最壞 O(n²)(固定 pivot 遇到已排序輸入),隨機 pivot 後最壞情況機率極低。
Heap Sort(堆積排序)
利用最大堆的性質:先將陣列建成最大堆,再反覆取出堆頂(最大值)放到陣列末尾。
function heapSort(arr: number[]): void {
const n = arr.length;
// 建最大堆(從最後一個非葉節點開始,向上做 sift down)
// O(n) 建堆,比逐一 push O(n log n) 更快
for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 逐步取出堆頂(最大值),放到陣列末尾
for (let i = n - 1; i > 0; i--) {
// 將當前最大值(arr[0])與末尾交換
[arr[0], arr[i]] = [arr[i], arr[0]];
// 對縮小後的堆頂重新 sift down,維持堆性質
heapify(arr, i, 0);
}
}
// Sift Down:將節點 i 下沉到正確位置(在大小為 n 的堆中)
function heapify(arr: number[], n: number, i: number): void {
let largest = i; // 假設根節點最大
const left = 2 * i + 1; // 左子節點索引
const right = 2 * i + 2; // 右子節點索引
// 找出根、左子、右子中最大者
if (left < n && arr[left] > arr[largest]) largest = left;
if (right < n && arr[right] > arr[largest]) largest = right;
// 若最大值不在根,交換並繼續向下調整
if (largest !== i) {
[arr[i], arr[largest]] = [arr[largest], arr[i]];
heapify(arr, n, largest);
}
}
// 測試
const arr4 = [12, 11, 13, 5, 6, 7];
heapSort(arr4);
console.log(arr4); // [5, 6, 7, 11, 12, 13]
特性:不穩定(跨越多層的交換會打亂相對順序)、原地(O(1) 額外空間)。所有情況均為 O(n log n),但因非連續記憶體存取(快取不友好),實務上比 Quick Sort 慢。
非比較排序
非比較排序利用元素值本身的結構性質,突破 O(n log n) 的下界,達到線性時間——但要求輸入必須是整數或可映射為整數的資料,且值域範圍有限。
Counting Sort(計數排序)
統計每個值出現的次數,再利用累積計數重建有序陣列。
function countingSort(arr: number[]): number[] {
if (arr.length === 0) return [];
const max = Math.max(...arr);
const min = Math.min(...arr);
const range = max - min + 1;
// 計數每個值出現的次數
const count = new Array<number>(range).fill(0);
for (const num of arr) {
count[num - min]++; // 以 num - min 為索引,支援負數
}
// 累積計數:count[i] 表示 <= (i + min) 的元素共有幾個
// 用於計算每個元素在輸出中的最終位置
for (let i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 從右往左填入輸出陣列(保持穩定性)
const output = new Array<number>(arr.length);
for (let i = arr.length - 1; i >= 0; i--) {
const idx = arr[i] - min;
output[count[idx] - 1] = arr[i];
count[idx]--;
}
return output;
}
// 測試
const arr5 = [4, 2, 2, 8, 3, 3, 1];
console.log(countingSort(arr5)); // [1, 2, 2, 3, 3, 4, 8]
適用條件:元素為整數,值域 k 不能太大(k 遠大於 n 時記憶體浪費)。例如排序成績(0-100)就非常適合。
時間/空間複雜度:O(n + k),k 為值域範圍。
Radix Sort(基數排序)
對整數從個位數到最高位,依序以 Counting Sort 穩定排序每一位。LSD(Least Significant Digit)版本最常見。
function radixSort(arr: number[]): number[] {
if (arr.length === 0) return [];
const max = Math.max(...arr);
let exp = 1; // 當前處理的位數(1 = 個位,10 = 十位,...)
let result = [...arr];
// 對每一位數執行一次穩定的 Counting Sort
while (Math.floor(max / exp) > 0) {
result = countingSortByDigit(result, exp);
exp *= 10;
}
return result;
}
function countingSortByDigit(arr: number[], exp: number): number[] {
const n = arr.length;
const output = new Array<number>(n).fill(0);
const count = new Array<number>(10).fill(0); // 十進位,0-9
// 統計當前位數各數字出現次數
for (const num of arr) {
const digit = Math.floor(num / exp) % 10;
count[digit]++;
}
// 累積計數
for (let i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 從右往左輸出,保持穩定性(這是 Radix Sort 正確性的關鍵)
for (let i = n - 1; i >= 0; i--) {
const digit = Math.floor(arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
return output;
}
// 測試
const arr6 = [170, 45, 75, 90, 802, 24, 2, 66];
console.log(radixSort(arr6)); // [2, 24, 45, 66, 75, 90, 170, 802]
為什麼從個位開始(LSD)? 因為穩定排序保證了後一輪的排序不會破壞前一輪已建立的高位順序。若從最高位開始(MSD),則需要遞迴分桶處理,實作較複雜。
時間複雜度:O(nk),k 為最大數字的位數。整數最多 10 位,可視為 O(n)。
Bucket Sort(桶排序)
將資料分散到多個「桶」中,每桶包含一個範圍的值,桶內各自排序,最後依序合併。
function bucketSort(arr: number[], bucketCount = 10): number[] {
if (arr.length === 0) return [];
const max = Math.max(...arr);
const min = Math.min(...arr);
const range = max - min;
// 建立空桶
const buckets: number[][] = Array.from({ length: bucketCount }, () => []);
// 將每個元素分配到對應的桶
for (const num of arr) {
// 計算元素屬於哪個桶(最後一個元素特殊處理避免越界)
const bucketIdx = Math.min(
Math.floor(((num - min) / (range + 1)) * bucketCount),
bucketCount - 1
);
buckets[bucketIdx].push(num);
}
// 對每個桶內部排序(這裡使用 Insertion Sort,適合小規模)
// 再依序合併所有桶
return buckets.flatMap(bucket => insertionSort(bucket));
}
// 測試(適合均勻分布的浮點數)
const floats = [0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68];
console.log(bucketSort(floats));
// [0.12, 0.17, 0.21, 0.23, 0.26, 0.39, 0.68, 0.72, 0.78, 0.94]
適用條件:資料均勻分布(否則退化為 O(n²))。最理想的場景是 0 到 1 之間的均勻分布浮點數。
C++ 對照
C++ 標準庫提供了一套完整且高度優化的排序工具,實際開發中優先使用標準庫而非自行實作。
#include <algorithm>
#include <vector>
#include <functional>
void cppSortExamples() {
std::vector<int> v = {5, 2, 8, 1, 9, 3};
// 1. std::sort — 預設升序,使用 IntroSort(Quick + Heap + Insertion 混合)
// 保證 O(n log n) 最壞時間,實務上極快
std::sort(v.begin(), v.end());
// v = {1, 2, 3, 5, 8, 9}
// 2. 降序排列(使用預設比較器 greater<int>)
std::sort(v.begin(), v.end(), std::greater<int>());
// v = {9, 8, 5, 3, 2, 1}
// 3. Lambda 自訂比較器 — 按絕對值升序
std::vector<int> v2 = {-3, 1, -5, 2, -1};
std::sort(v2.begin(), v2.end(), [](int a, int b) {
return std::abs(a) < std::abs(b);
});
// v2 = {1, -1, -3, 2, -5}
// 4. std::stable_sort — 使用 Merge Sort,保證穩定性
// 同值元素的相對順序保持不變
struct Student { std::string name; int grade; };
std::vector<Student> students = {
{"Alice", 90}, {"Bob", 85}, {"Charlie", 90}
};
std::stable_sort(students.begin(), students.end(),
[](const Student& a, const Student& b) {
return a.grade > b.grade; // 按成績降序
}
);
// Alice 和 Charlie 同分,相對順序保持 Alice 在前
// 5. 多欄位排序(不需要穩定排序時的另一種寫法)
std::sort(students.begin(), students.end(),
[](const Student& a, const Student& b) {
if (a.grade != b.grade) return a.grade > b.grade; // 成績降序
return a.name < b.name; // 同分按名字升序
}
);
// 6. std::partial_sort — 只排序前 k 個最小元素,O(n log k)
std::vector<int> v3 = {5, 2, 8, 1, 9, 3, 4};
std::partial_sort(v3.begin(), v3.begin() + 3, v3.end());
// v3 前 3 個是最小的 3 個,已排序:{1, 2, 3, ?, ?, ?, ?}
// 7. std::nth_element — 保證第 k 個位置就位,左側 <= 右側,O(n) 期望
std::vector<int> v4 = {5, 2, 8, 1, 9, 3, 4};
std::nth_element(v4.begin(), v4.begin() + 3, v4.end());
// v4[3] 是第 4 小的元素
}
std::sort vs std::stable_sort 選擇原則:
- 不需要穩定性 → 用
std::sort(IntroSort,實務最快) - 需要穩定性 → 用
std::stable_sort(Merge Sort,O(n log n) 保證穩定) - 只需前 k 小 → 用
std::partial_sort(O(n log k),比全部排序快) - 只需第 k 小(中位數、Top-K)→ 用
std::nth_element(O(n) 期望)
複雜度完整比較表
| 演算法 | 最佳時間 | 平均時間 | 最差時間 | 空間 | 穩定 | 原地 |
|---|---|---|---|---|---|---|
| Bubble Sort | O(n) | O(n²) | O(n²) | O(1) | ✓ | ✓ |
| Selection Sort | O(n²) | O(n²) | O(n²) | O(1) | ✗ | ✓ |
| Insertion Sort | O(n) | O(n²) | O(n²) | O(1) | ✓ | ✓ |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | ✓ | ✗ |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | ✗ | ✓ |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) | ✗ | ✓ |
| Counting Sort | O(n+k) | O(n+k) | O(n+k) | O(k) | ✓ | ✗ |
| Radix Sort | O(nk) | O(nk) | O(nk) | O(n+k) | ✓ | ✗ |
| Bucket Sort | O(n+k) | O(n+k) | O(n²) | O(n+k) | ✓* | ✗ |
k = 值域範圍(Counting/Radix)或桶的數量(Bucket)。Bucket Sort 穩定性取決於桶內排序演算法。
變體與延伸
Timsort — Python 與 Java 的預設排序
Timsort 是目前工程實踐中最廣泛使用的排序演算法,由 Tim Peters 在 2002 年為 Python 設計,後來也被 Java(Arrays.sort 對物件)採用。
核心思路:
- 偵測陣列中天然存在的有序子序列(稱為 run),長度不足 32-64 時用 Insertion Sort 補足
- 使用 Merge Sort 策略合併 run,遵循特殊的堆疊規則確保 O(n log n) 的最壞保證
- 對幾乎有序的真實資料,最佳情況接近 O(n)
Timsort 的天才之處在於它充分利用了真實世界資料「局部有序」的特性,而非假設輸入是完全隨機的。
IntroSort — C++ std::sort 的實作
IntroSort(Introspective Sort)是 C++ 標準庫 std::sort 的底層演算法,由三種排序混合:
- Quick Sort:遞迴深度正常時使用(平均最快)
- Heap Sort:遞迴深度超過
2 * log(n)時切換(防止 O(n²) 最壞情況) - Insertion Sort:子陣列大小 < 16 時切換(常數小,適合小規模)
結果:Quick Sort 的實際高效能 + O(n log n) 的最壞情況保證。
External Merge Sort — 外部排序
當資料量超過記憶體容量(如資料庫排序 TB 級資料),需要使用外部排序:
- 分割階段:將大檔案切成能放入記憶體的小塊,各自排序後寫回磁碟(每塊稱為一個 run)
- 合併階段:使用 K-way Merge(最小堆)逐步合併所有 run,只需在記憶體中保存每個 run 的頭部元素
// K-way merge 核心邏輯(模擬外部排序的合併階段)
// runs 為多個已排序的子陣列
function kWayMerge(runs: number[][]): number[] {
// 使用最小堆追蹤每個 run 的當前最小值
// [value, runIndex, itemIndex]
const heap: [number, number, number][] = [];
const result: number[] = [];
// 初始化:每個 run 的第一個元素加入堆
for (let i = 0; i < runs.length; i++) {
if (runs[i].length > 0) {
heap.push([runs[i][0], i, 0]);
}
}
// 簡化版:使用陣列模擬最小堆(實務中應使用真正的堆)
heap.sort((a, b) => a[0] - b[0]);
while (heap.length > 0) {
const [value, runIdx, itemIdx] = heap.shift()!;
result.push(value);
// 若該 run 還有剩餘元素,將下一個加入堆
const nextIdx = itemIdx + 1;
if (nextIdx < runs[runIdx].length) {
const nextVal = runs[runIdx][nextIdx];
// 插入並維持排序(實務中用堆 push + sift up)
const insertPos = heap.findIndex(([v]) => v > nextVal);
if (insertPos === -1) {
heap.push([nextVal, runIdx, nextIdx]);
} else {
heap.splice(insertPos, 0, [nextVal, runIdx, nextIdx]);
}
}
}
return result;
}
// 測試
const runs = [[1, 4, 7], [2, 5, 8], [3, 6, 9]];
console.log(kWayMerge(runs)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
面試考點 — 選擇排序演算法的決策框架
面試中常見的問題是「你會選哪種排序演算法?」。以下決策框架幫助你給出有條理的回答:
決策樹
資料量大嗎?(n > 1000)
├── 否 → 資料幾乎有序?
│ ├── 是 → Insertion Sort(O(n) 最佳情況)
│ └── 否 → Insertion Sort 或 Shell Sort 都可
└── 是 → 有穩定性需求嗎?
├── 是 → Merge Sort(O(n log n) 穩定)
└── 否 → 元素是整數且值域有限?
├── 是,值域小 → Counting Sort(O(n + k))
├── 是,位數少 → Radix Sort(O(nk))
└── 否,一般情況 → Quick Sort(平均最快)
+ 三路分割(大量重複元素)
+ 隨機 pivot(避免最壞情況)
常見場景速查
| 場景 | 推薦演算法 | 理由 |
|---|---|---|
| 通用排序,不需穩定 | Quick Sort(含隨機 pivot) | 平均 O(n log n),常數小 |
| 需要穩定排序 | Merge Sort | 穩定且 O(n log n) 保證 |
| 幾乎已有序 | Insertion Sort | 最佳情況 O(n) |
| 整數,值域 0-1000 | Counting Sort | O(n + k) 線性時間 |
| 多位整數 | Radix Sort | O(nk),k 為位數 |
| 浮點數且均勻分布 | Bucket Sort | 平均 O(n + k) |
| 超出記憶體 | External Merge Sort | 最小化磁碟 I/O |
| 只需前 k 小元素 | Quick Select / 最小堆 | O(n) ~ O(n log k) |
| C++ 實務開發 | std::sort | IntroSort,工業級優化 |
| JavaScript 內建 | Array.prototype.sort | V8 使用 Timsort,穩定 |
JavaScript Array.sort 的陷阱
// 陷阱:不提供比較函式時,數字被轉成字串排序!
const nums = [10, 9, 2, 21, 3];
nums.sort(); // [10, 2, 21, 3, 9] ← 字典序,錯誤!
nums.sort((a, b) => a - b); // [2, 3, 9, 10, 21] ← 數值升序,正確
// 好消息:ES2019 起,V8 的 Array.sort 使用 Timsort,保證穩定性
LeetCode 練習
| # | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 912 | Sort an Array | Medium | 實作 Merge Sort 或 Quick Sort |
| 75 | Sort Colors | Medium | Dutch National Flag 三路分割 |
| 148 | Sort List | Medium | Merge Sort on Linked List |
| 56 | Merge Intervals | Medium | 排序 + 掃描線 |
| 179 | Largest Number | Medium | 自訂比較器 |
LeetCode 75 — Sort Colors(荷蘭旗問題)
使用三指標(Dutch National Flag),一次掃描將陣列分為三區:[0...0][1...1][2...2]。
function sortColors(nums: number[]): void {
let low = 0; // [0, low) 全是 0
let high = nums.length - 1; // (high, n-1] 全是 2
let mid = 0; // [low, mid) 全是 1
while (mid <= high) {
if (nums[mid] === 0) {
// 將 0 換到左區,low 和 mid 同步前進
[nums[low], nums[mid]] = [nums[mid], nums[low]];
low++;
mid++;
} else if (nums[mid] === 2) {
// 將 2 換到右區,只縮小 high
[nums[mid], nums[high]] = [nums[high], nums[mid]];
high--;
// 注意:mid 不前進!換過來的元素尚未檢查
} else {
// nums[mid] === 1,直接前進
mid++;
}
}
}
// 測試
const colors = [2, 0, 2, 1, 1, 0];
sortColors(colors);
console.log(colors); // [0, 0, 1, 1, 2, 2]
複雜度:時間 O(n),空間 O(1)。一次掃描,每個元素最多被交換一次。
LeetCode 56 — Merge Intervals
先按起點排序,再掃描合併重疊的區間。
function merge(intervals: number[][]): number[][] {
if (intervals.length <= 1) return intervals;
// 按起點升序排列
intervals.sort((a, b) => a[0] - b[0]);
const result: number[][] = [intervals[0]];
for (let i = 1; i < intervals.length; i++) {
const curr = intervals[i];
const last = result[result.length - 1];
if (curr[0] <= last[1]) {
// 重疊(當前起點 <= 上一個終點):合併,取終點最大值
last[1] = Math.max(last[1], curr[1]);
} else {
// 不重疊:直接加入結果
result.push(curr);
}
}
return result;
}
// 測試
console.log(merge([[1,3],[2,6],[8,10],[15,18]])); // [[1,6],[8,10],[15,18]]
console.log(merge([[1,4],[4,5]])); // [[1,5]]
複雜度:時間 O(n log n)(排序主導),空間 O(n)(結果陣列)。
LeetCode 148 — Sort List
鏈結串列上的 Merge Sort:找中點、遞迴排序、合併。
class ListNode {
val: number;
next: ListNode | null;
constructor(val = 0, next: ListNode | null = null) {
this.val = val;
this.next = next;
}
}
function sortList(head: ListNode | null): ListNode | null {
// 長度 0 或 1,直接回傳
if (!head || !head.next) return head;
// 快慢指標找中點(slow 停在左半末尾)
let slow: ListNode = head;
let fast: ListNode | null = head.next;
while (fast && fast.next) {
slow = slow.next!;
fast = fast.next.next;
}
// 切斷鏈結,分成左右兩半
const mid = slow.next;
slow.next = null;
// 遞迴排序兩半
const left = sortList(head);
const right = sortList(mid);
// 合併兩個有序鏈結串列
return mergeLists(left, right);
}
function mergeLists(
l1: ListNode | null,
l2: ListNode | null
): ListNode | null {
const dummy = new ListNode(0); // 虛擬頭節點,簡化邊界處理
let curr = dummy;
while (l1 && l2) {
if (l1.val <= l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next;
}
// 接上剩餘節點
curr.next = l1 ?? l2;
return dummy.next;
}
複雜度:時間 O(n log n),空間 O(log n)(遞迴棧)。
LeetCode 179 — Largest Number
自訂比較器:比較兩個數字拼接後哪種順序更大。
function largestNumber(nums: number[]): string {
const strs = nums.map(String);
// 自訂比較:比較 a+b 和 b+a 哪個更大
strs.sort((a, b) => {
const ab = a + b;
const ba = b + a;
return ba.localeCompare(ab); // 降序:大的在前
});
// 特殊情況:全是 0(如 [0, 0])
if (strs[0] === '0') return '0';
return strs.join('');
}
// 測試
console.log(largestNumber([3, 30, 34, 5, 9])); // "9534330"
console.log(largestNumber([10, 2])); // "210"
總結
本文帶你走過了排序演算法的完整知識地圖:
比較排序(O(n log n) 下界):
- Bubble / Selection:概念簡單,O(n²),實務不用但理解基礎重要
- Insertion Sort:小資料或近乎有序時的首選,Timsort 的核心
- Merge Sort:穩定且保證 O(n log n),適合需要穩定性或外部排序
- Quick Sort:平均最快,隨機 pivot + 三路分割應對重複元素
- Heap Sort:O(n log n) 且 O(1) 空間,但快取不友好
非比較排序(可達線性):
- Counting Sort:整數且值域小時極快
- Radix Sort:多位整數的分位排序
- Bucket Sort:均勻分布資料的利器
實務建議:
- JavaScript:
Array.sort()已是穩定的 Timsort,自訂比較器即可 - C++:
std::sort(不穩定需求)、std::stable_sort(穩定需求) - 面試:熟練手寫 Merge Sort 和 Quick Sort(Hoare Partition),理解穩定性的重要性
下一篇我們將進入 二分搜尋(Binary Search)——同樣以有序為前提的高效搜尋策略,與排序形成完美搭配。
希望這篇文章能幫助你建立對排序演算法的系統性認識。如果你在實作過程中有任何問題或疑惑,歡迎到聯絡頁面留言交流!