排序
插入、合併、快速、堆積四種排序放在同一份資料上比較:誰快取決於資料原本長什麼樣子。
資料
演算法
元素數量
顯示
輸入
拿最後一個數字當基準,比它小的都搬到左邊,再分別排左右兩邊。
1/54
左右捲動可保持標籤大小,也可以減少元素或選「顯示全部」。
正在比較搬動/寫入p:基準目前的位置不在目前範圍
lo/hi:分區範圍;i:寫入邊界;j:掃描位置;p:基準的位置。
標籤表示這一步使用的索引;陣列顯示寫入後的結果。
比較次數
0
寫入次數
0
全部跑完:比較
37
全部跑完:寫入
30
12 個數字,隨機。按播放或下一步開始。
亮起來的是這一步執行的程式碼
function insertionSort(a: number[]): void { for (let i = 1; i < a.length; i++) { for (let j = i; j > 0 && a[j] < a[j - 1]; j--) { [a[j], a[j - 1]] = [a[j - 1], a[j]]; } }} function mergeSort(a: number[], lo = 0, hi = a.length - 1): void { if (hi <= lo) return; const mid = lo + Math.floor((hi - lo) / 2); mergeSort(a, lo, mid); mergeSort(a, mid + 1, hi); const left = a.slice(lo, mid + 1); const right = a.slice(mid + 1, hi + 1); let i = 0, j = 0, k = lo; while (i < left.length && j < right.length) { if (right[j] < left[i]) { a[k++] = right[j++]; } else { a[k++] = left[i++]; } } while (i < left.length) a[k++] = left[i++]; while (j < right.length) a[k++] = right[j++];} function quickSort(a: number[], lo = 0, hi = a.length - 1): void { const pending: [number, number][] = hi > lo ? [[lo, hi]] : []; while (pending.length) { const [lo, hi] = pending.pop()!; const pivot = a[hi]; let i = lo; for (let j = lo; j < hi; j++) { if (a[j] < pivot) { if (i !== j) [a[i], a[j]] = [a[j], a[i]]; i++; } } if (i !== hi) [a[i], a[hi]] = [a[hi], a[i]]; // LIFO: visit the left partition first, without recursive calls. if (i + 1 < hi) pending.push([i + 1, hi]); if (lo < i - 1) pending.push([lo, i - 1]); }} function heapSort(a: number[]): void { const n = a.length; for (let i = Math.floor(n / 2) - 1; i >= 0; i--) siftDown(a, i, n); for (let end = n - 1; end > 0; end--) { [a[0], a[end]] = [a[end], a[0]]; siftDown(a, 0, end); }} function siftDown(a: number[], i: number, size: number): void { while (true) { const left = 2 * i + 1, right = left + 1; let largest = i; if (left < size && a[left] > a[largest]) largest = left; if (right < size && a[right] > a[largest]) largest = right; if (largest === i) return; [a[i], a[largest]] = [a[largest], a[i]]; i = largest; }}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
- 比較只看數字鍵;身分字母用來觀察相同鍵的順序。寫入計原陣列的元素賦值,交換算兩次,跳過自己與自己的交換;暫存複製、堆疊和動畫快照另計。合併指標表示這一步使用的來源與目的位置。
什麼時候用
- 絕大多數時候直接用語言內建的排序。自己挑演算法,是因為資料或環境有特殊條件。
- 資料幾乎已經排好:插入排序只需要差不多 n 次比較。
- 需要穩定(相同的鍵保持原本順序,例如先按日期、再按姓名排):合併排序。
- 記憶體很緊、不能另開陣列:堆積排序原地進行,而且最壞情況也是 n log n。
- 平均最快:快速排序,但基準要隨機挑或三數取中。
和其他主題的關係
語言內建的版本
Array.prototype.sort · toSorted| 操作 | 寫法 | 成本 |
|---|---|---|
| 原地排序(數字) | nums.sort((x, y) => y - x) | O(n log n) |
| 排序成新陣列 | nums.toSorted((x, y) => x - y) | O(n log n) |
| 字串依語系排序 | x.localeCompare(y, "en") | O(n log n) |
| 多個鍵 | q.age - p.age || p.name.localeCompare(q.name) | O(n log n) |
const nums = [10, 9, 1];[...nums].sort(); // → [1, 10, 9]nums.toSorted((x, y) => x - y); // → [1, 9, 10]nums; // → [10, 9, 1]nums.sort((x, y) => y - x); // → [10, 9, 1] ["b", "a", "B"].sort(); // → ["B", "a", "b"]["b", "a", "B"].sort((x, y) => x.localeCompare(y, "en")); // → ["a", "b", "B"] const people = [ { name: "Cy", age: 30 }, { name: "Bob", age: 25 }, { name: "Ann", age: 30 },];// Stable: people of equal age keep their original order.people.toSorted((p, q) => p.age - q.age).map((p) => p.name); // → ["Bob", "Cy", "Ann"]// Age descending, then name ascending.people.toSorted((p, q) => q.age - p.age || p.name.localeCompare(q.name)).map((p) => p.name); // → ["Ann", "Cy", "Bob"]每個 → 後面的結果都是實際執行這段程式碼驗證過的。
- 不給比較函式時,
sort()把元素轉成字串再比,數字會排錯。比較函式回傳負數代表 x 在前、正數代表 y 在前。 sort()會改掉原陣列並回傳同一個陣列;toSorted()(ES2023,Node 20 起)回傳新的、原陣列不動。- 從 ES2019 起規格要求
sort是穩定排序(V8 用 Timsort),所以「先按名字排,再按年齡排」會得到年齡相同時按名字排的結果。 - 多個鍵可以用
||串起來:前一個鍵比出來是 0(相等)才會看下一個。
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 插入排序 幾乎排好的資料只要 O(n) | O(n²) | O(n²) |
| 合併排序 | O(n log n) | O(n log n) |
| 快速排序 這裡拿最後一個當基準,已排序的輸入就是最差情況 | O(n log n) | O(n²) |
| 堆積排序 | O(n log n) | O(n log n) |
空間:O(1) – O(n),插入與堆積排序原地進行 O(1);快速排序改用明確堆疊,平均 O(log n)、最差 O(n),不依賴呼叫堆疊上限;合併排序暫存區 O(n)。圖中的身分與快照另計
Big O 實測:n 變大時步數怎麼長
數的是:比較次數,各跑一次
| Big O | n = 250 | n = 500 | n = 1,000 | n = 2,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 插入排序(隨機) | O(n²) | 16,103 | 58,520 | 244,053 | 981,773 | ×61 (×64) |
| 合併排序(隨機) | O(n log n) | 1,692 | 3,843 | 8,707 | 19,437 | ×11 (×11) |
| 快速排序(隨機) | O(n log n) | 2,412 | 5,442 | 11,182 | 29,337 | ×12 (×11) |
| 堆積排序(隨機) | O(n log n) | 3,200 | 7,455 | 16,861 | 37,700 | ×12 (×11) |
| 快速排序(已排序) | O(n²) | 31,125 | 124,750 | 499,500 | 1,999,000 | ×64 (×64) |
n 變成 8 倍:n log n 的三種多了 11–12 倍,n² 的兩種多了 61–64 倍。插入排序在 n 小的時候其實很快,差距是 n 變大之後才拉開的。
和其他做法比
| 隨機 | 已排序 | 反序 | 大量重複 | |
|---|---|---|---|---|
| 插入排序 | 244,053 / 486,116 | 999 / 0 | 499,500 / 999,000 | 189,864 / 377,730 |
| 合併排序 | 8,707 / 9,976 | 5,044 / 9,976 | 4,932 / 9,976 | 7,935 / 9,976 |
| 快速排序 | 11,182 / 8,396 | 499,500 / 0 | 499,500 / 1,000 | 127,160 / 3,884 |
| 堆積排序 | 16,861 / 18,164 | 17,583 / 19,416 | 15,965 / 16,632 | 13,891 / 14,104 |
每格是「比較次數 / 寫入次數」:把 1,000 個數字交給上面同一份程式實際排一次數出來的(隨機與大量重複用固定種子)。交換算兩次寫入。
真實世界裡的它
- V8(JavaScript)、Python 與 Java 的物件排序都用 TimSort:合併排序加上插入排序,專門吃「已經部分排好」的資料。
- C++ 的 std::sort 是 introsort:快速排序,遞迴太深就換堆積排序,切到很小段就換插入排序。
- 資料庫的 ORDER BY 放不進記憶體時用外部合併排序:分批排好寫到磁碟,再一路合併回來。
取捨與陷阱
- 快速排序拿最後一個當基準,遇到已排序的資料就退化:1,000 筆要比較 499,500 次,正好是 n(n−1)/2。實務上用隨機基準避開。
- 大量重複值也會拖慢這種切法:等於基準的值全擠到同一邊。三路切分(小於/等於/大於)可以解決。
- 展示的快速排序用明確堆疊保存尚未處理的分區,不用遞迴,所以 Python 的預設遞迴上限不會限制輸入筆數。但最差情況的 O(n²) 比較成本仍然存在。
- 比較次數不是全部:合併排序要一個一樣大的暫存陣列;插入排序的寫入次數可能比比較還多。
- JavaScript 的 [10, 9, 1].sort() 預設照字串排,結果是 [1, 10, 9];數字要傳 (a, b) => a - b。