进阶排序:快排 / 归并 / 堆排序
本节目标
- 手写快速排序、归并排序、堆排序
- 理解它们为何是 O(n log n) 及最坏情况
- 知道工程里如何选排序算法
// 运行环境:Node.js 14+
// 保存为 dsa-l18.js,执行:node dsa-l18.js
// 快速排序:选基准 pivot,分区使左小右大,递归两侧
function quickSort(a) {
if (a.length <= 1) return a
const [pivot, ...rest] = a
const left = rest.filter(x => x < pivot)
const right = rest.filter(x => x >= pivot)
return [...quickSort(left), pivot, ...quickSort(right)]
}
// 原地版(更省空间,用双指针分区,经典写法):
function quickSortInPlace(a, lo = 0, hi = a.length - 1) {
if (lo >= hi) return a
let i = lo
for (let j = lo; j < hi; j++) if (a[j] < a[hi]) { [a[i], a[j]] = [a[j], a[i]]; i++ }
[a[i], a[hi]] = [a[hi], a[i]] // 基准归位
quickSortInPlace(a, lo, i - 1); quickSortInPlace(a, i + 1, hi)
return a
}
// 归并排序:分两半各自排好,再合并两个有序数组
function mergeSort(a) {
if (a.length <= 1) return a
const mid = a.length >> 1
return merge(mergeSort(a.slice(0, mid)), mergeSort(a.slice(mid)))
}
function merge(L, R) {
const out = []; let i = 0, j = 0
while (i < L.length && j < R.length) out.push(L[i] <= R[j] ? L[i++] : R[j++])
return out.concat(L.slice(i), R.slice(j))
}
// 堆排序:建最大堆,反复把堆顶(最大)换到末尾
function heapSort(a) {
a = a.slice()
const n = a.length
const siftDown = (i, size) => {
while (true) {
let m = i, l = 2*i+1, r = 2*i+2
if (l < size && a[l] > a[m]) m = l
if (r < size && a[r] > a[m]) m = r
if (m === i) break
[a[m], a[i]] = [a[i], a[m]]; i = m
}
}
for (let i = (n >> 1) - 1; i >= 0; i--) siftDown(i, n) // 建堆
for (let end = n - 1; end > 0; end--) { [a[0], a[end]] = [a[end], a[0]]; siftDown(0, end) }
return a
}
console.log(quickSort([5,2,4,1,3])) // [1,2,3,4,5]
console.log(quickSortInPlace([5,2,4,1,3])) // [1,2,3,4,5]
console.log(mergeSort([5,2,4,1,3])) // [1,2,3,4,5]
console.log(heapSort([5,2,4,1,3])) // [1,2,3,4,5]复杂度与最坏:快排平均 O(n log n),最坏(已排序 + 选末位 pivot)O(n²),可随机选 pivot 规避;归并稳定 O(n log n) 但需 O(n) 空间;堆排 O(n log n) 原地但不稳定。
名词解释
- 快速排序(Quick Sort):分治 + 分区,选基准把数组分成"小/大"两半再递归。平均 O(n log n)、原地、缓存友好,是很多语言默认排序的核心。
- 归并排序(Merge Sort):先分后合,合并两个有序数组。稳定、确定 O(n log n),代价是 O(n) 额外空间;常用于外部排序(数据太大放不下内存)。
- 堆排序(Heap Sort):建堆 + 反复取堆顶。O(n log n)、原地、不需额外空间,但不稳定且缓存局部性较差。
课后练习
- 快排最坏 O(n²) 怎么避免?
- 答案:随机选 pivot(或三数取中),让"每次都取到极端值"的概率降到可忽略,实际几乎总是 O(n log n)。
- 为什么归并排序稳定而快排不稳定?
- 答案:归并合并时遇到相等元素优先取左半的,保持原序;快排的分区交换会跨过相等元素,破坏原序。
总结
快排、归并、堆排是"O(n log n) 三杰",理解了它们,排序这一关就真正过了。三者殊途同归都靠"分治"把问题拆小,却各有性格:快排最"快"——平均最快、缓存友好、原地,是工程默认;归并最"稳"——无论数据如何都是稳稳的 O(n log n) 且稳定,适合外部排序;堆排最"省"——原地零额外空间,但不稳定。这里有个重要工程认知:没有"最好"的排序,只有"最合适"的。V8 的 Array.sort 对短数组用插入、长数组用快排变体(TimSort 思路),正是这种"混合策略"的体现。我建议把快排的分区思想(选基准、小于的放左、大于的放右)当作核心套路反复练——它不只用于排序,还在"第 k 大""快速选择"里大显身手。