排序是所有算法题的底座,真正要掌握的不是六份代码,而是每种排序在时间、空间、稳定性上的取舍。
线上订单列表十万条,前端排一次序卡了 300ms,改后端分页后顺滑。选哪种排序,由数据规模、分布、内存和稳定性共同决定。
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定 | 原地 |
|---|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 是 | 是 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 | 是 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 | 是 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 | 否 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 | 是 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 | 是 |
| TimSort | O(n) | O(n log n) | O(n log n) | O(n) | 是 | 否 |
稳定性指相等元素相对顺序不变,跨位置交换会破坏它,选择排序和快排因此不稳定。
冒泡每轮把最大值冒到末尾,无交换即提前退出。选择排序每轮选最小值放到已排序区末尾,交换少但要扫完整个数组。插入排序把当前元素插进前面有序区的正确位置。
function bubbleSort(a) {
for (let end = a.length - 1; end > 0; end--) {
let swapped = false;
for (let i = 0; i < end; i++)
if (a[i] > a[i + 1]) { [a[i], a[i + 1]] = [a[i + 1], a[i]]; swapped = true; }
if (!swapped) return a;
}
return a;
}
function selectionSort(a) {
for (let i = 0; i < a.length; i++) {
let min = i;
for (let j = i + 1; j < a.length; j++) if (a[j] < a[min]) min = j;
[a[i], a[min]] = [a[min], a[i]];
}
return a;
}
function insertionSort(a) {
for (let i = 1; i < a.length; i++) {
const cur = a[i];
let j = i - 1;
while (j >= 0 && a[j] > cur) a[j + 1] = a[j--];
a[j + 1] = cur;
}
return a;
}
插入排序在基本有序时内层 while 几乎不执行,退化成 O(n),移动是连续写,缓存友好。V8 的 sort 在短数组上直接用它:常数因子小于归并递归,TimSort 切出的 run 又有序。
分治是"拆到单元素有序,再两两合并"。合并用双指针,相等时取左边,稳定性来自这个 <=。
function mergeSort(a) {
if (a.length <= 1) return a;
const mid = a.length >> 1;
const left = mergeSort(a.slice(0, mid));
const right = mergeSort(a.slice(mid));
const res = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) res.push(left[i] <= right[j] ? left[i++] : right[j++]);
while (i < left.length) res.push(left[i++]);
while (j < right.length) res.push(right[j++]);
return res;
}
临时数组要容纳两段数据,空间 O(n);链表归并只改指针,可做到 O(1)。
partition 选 pivot,小的放左、大的放右,pivot 归位后左右递归。Lomuto 用单指针,好写但交换多;Hoare 两端靠拢,边界必须写 i >= j。
function quickSortLomuto(a, lo = 0, hi = a.length - 1) {
if (lo >= hi) return a;
const pivot = a[hi];
let i = lo;
for (let j = lo; j < hi; j++)
if (a[j] < pivot) { [a[i], a[j]] = [a[j], a[i]]; i++; }
[a[i], a[hi]] = [a[hi], a[i]];
quickSortLomuto(a, lo, i - 1);
quickSortLomuto(a, i + 1, hi);
return a;
}
function quickSortHoare(a, lo = 0, hi = a.length - 1) {
if (lo >= hi) return a;
const pivot = a[(lo + hi) >> 1];
let i = lo - 1, j = hi + 1;
while (true) {
do i++; while (a[i] < pivot);
do j--; while (a[j] > pivot);
if (i >= j) break;
[a[i], a[j]] = [a[j], a[i]];
}
quickSortHoare(a, lo, j);
quickSortHoare(a, j + 1, hi);
return a;
}
function quickSort3Way(a, lo = 0, hi = a.length - 1) {
if (lo >= hi) return a;
const pivot = a[lo];
let lt = lo, gt = hi, i = lo + 1;
while (i <= gt) {
if (a[i] < pivot) [a[lt++], a[i++]] = [a[i], a[lt]];
else if (a[i] > pivot) [a[i], a[gt--]] = [a[gt], a[i]];
else i++;
}
quickSort3Way(a, lo, lt - 1);
quickSort3Way(a, gt + 1, hi);
return a;
}
固定取首尾做 pivot,遇到有序数组每次只切一个,递归深度 n,退化成 O(n²),随机化只能降低概率。重复元素多时二路分区会反复划分,三路快排把它们聚成一段,只递归两侧。
先自底向上建大顶堆,再反复把堆顶换到末尾下沉。原地、最坏稳定,代价是常数大、缓存不友好。
function heapSort(a) {
const siftDown = (i, n) => {
for (let max; ; i = max) {
max = i;
if (2 * i + 1 < n && a[2 * i + 1] > a[max]) max = 2 * i + 1;
if (2 * i + 2 < n && a[2 * i + 2] > a[max]) max = 2 * i + 2;
if (max === i) return;
[a[i], a[max]] = [a[max], a[i]];
}
};
for (let i = (a.length >> 1) - 1; i >= 0; i--) siftDown(i, a.length);
for (let end = a.length - 1; end > 0; end--) {
[a[0], a[end]] = [a[end], a[0]];
siftDown(0, end);
}
return a;
}
TimSort 混合归并与插入:扫描出天然升序或降序的 run,降序 run 翻转;run 短于 minrun(32~64)用插入排序补齐;再两两归并,并用 galloping 整段跳过有序数据。日志按时间、订单按金额天然局部有序,TimSort 直接吃掉这些 run,逼近 O(n),这是它胜过教科书快排的原因。
console.log([10, 9, 1].sort()); // [1, 10, 9]
console.log([10, 9, 1].sort((a, b) => a - b)); // [1, 9, 10]
不传比较函数时元素转成字符串按 UTF-16 码元比较,"10" < "9" 成立,所以 10 排在 9 前。数字排序必须传 (a, b) => a - b。
很多人以为 sort() 默认按数值排序,实际上按字符串字典序,[1, 10, 9] 就是这么来的。
很多人以为快排平均更快就该无脑用,实际上业务数据局部有序,TimSort 都更稳。
小顶堆维护 k 个元素,堆顶是第 k 大,时间 O(n log k)、空间 O(k),适合数据流;快速选择只递归目标一侧,平均 O(n)、空间 O(log n)。
function findKthLargest(nums, k) {
const h = [], swap = (i, j) => ([h[i], h[j]] = [h[j], h[i]]);
const up = (i) => {
while (i && h[(i - 1) >> 1] > h[i]) { swap(i, (i - 1) >> 1); i = (i - 1) >> 1; }
};
const down = (i) => {
for (let m; ; i = m) {
m = i;
const l = 2 * i + 1, r = l + 1;
if (l < h.length && h[l] < h[m]) m = l;
if (r < h.length && h[r] < h[m]) m = r;
if (m === i) return;
swap(i, m);
}
};
for (const x of nums) {
h.push(x), up(h.length - 1);
if (h.length > k) { h[0] = h.pop(); down(0); }
}
return h[0];
}
function quickSelect(nums, k) {
const target = nums.length - k;
let lo = 0, hi = nums.length - 1;
while (lo < hi) {
const pivot = nums[hi];
let i = lo;
for (let j = lo; j < hi; j++) if (nums[j] < pivot) { [nums[i], nums[j]] = [nums[j], nums[i]]; i++; }
[nums[i], nums[hi]] = [nums[hi], nums[i]];
if (i === target) return nums[i];
if (i < target) lo = i + 1; else hi = i - 1;
}
return nums[lo];
}
按左端点排序后扫描,重叠就合并右端点,否则追加。排序 O(n log n) 是瓶颈,扫描 O(n),空间 O(n)。
function merge(intervals) {
intervals.sort((a, b) => a[0] - b[0]);
const res = [];
for (const [start, end] of intervals) {
const last = res[res.length - 1];
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
else res.push([start, end]);
}
return res;
}