Implement · Quick Sort · Go
理论一分钟讲透,代码三种写法直接可背——Lomuto(面试默认)· Hoare(工业同款)· 三向切分(重复元素克星),外加一份本机 go run 验证过的完整版。
选枢轴 → 一趟扫描把区间分成「< pv」与「≥ pv」两半 → 两半递归。全部工作在 partition,合并零成本
Lomuto:pivot 归位、最好写;Hoare:双向扫描、交换少;三向切分:等值段退出递归,海量重复元素 O(n)
随机枢轴防退化(已序/全等输入专杀固定枢轴)+ 只递归短半,栈深钉死在 O(log n)
Why Quick Sort
| 做法 | 一次操作解决多少 | 100 万条的量级 |
|---|---|---|
| 冒泡 / 选择 / 插入 | 每轮只让 1 个元素到位,要跑 n 轮 | 约 10¹² 次比较 → 千秒级 |
| 归并 | 先对半分,再花代价合并 | 约 2×10⁷ → 亚秒级,但要额外一倍内存 |
| 快排 | 一趟扫描把区间粗略分成大小两半,合并零成本 | 约 2×10⁷ → 亚秒级,且不用额外数组 |
100 万条数据,把 n = 10⁶ 代进去:
O(n²) = 10¹² 次比较;O(n log n) ≈ 10⁶ × 20 = 2×10⁷ 次。
两者相差约 5 万倍——这就是"算法复杂度"从纸面落到体感的时刻:不是快一点,是同一个任务从"跑不完"变成"瞬间完成"。
冒泡、选择、插入的共同点:一轮只能把一个元素放到最终位置(一个最大、一个最小、或者局部插入一个)。n 个元素就要 n 轮,每轮还要扫一遍 → n×n。它们不是"写得不好",是策略上就必须做这么多工作。
它用"一趟扫描"一次性消掉一整层规模:这一趟不把任何元素放最终位置,但保证"小的都在左、大的都在右"——于是问题规模减半。规模减半只需要 log₂n 次,每次代价 Θ(n) → 合计 O(n log n)。
先讲清这一趟扫描(分区)在干什么 → 再给三种可直接默写的写法 → 最后收成一份带防退化措施的完整可运行版。读完你应该能在白板上从零写出快排,并说清每个边界为什么这么写。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 枢轴 pivot | 这一趟拿来做"分界标准"的那个元素;比它小的去左边,比它大的去右边 |
| 分区 partition | 一趟从左到右的扫描 + 原地交换,把区间按枢轴切成两半 |
| 原地 in-place | 不开新数组,只在原数组里换元素;快排的额外内存只有递归栈 |
| 分治 | 把大问题拆成同构的小问题,各自解决后答案自然合并 |
| 递归深度 / 栈 | 递归调用了"几层";层数太多会打爆调用栈 |
| 稳定性 | 相等元素的原有相对顺序是否保持不变;快排不稳定(交换是远距离的) |
| 不变量 invariant | 循环过程中始终成立的那句话;写对循环的唯一抓手,也是向面试官解释代码的凭据 |
先读这几篇再回来,本 deck 默认你已经能读懂 O(n log n)、递归和数组下标:
复杂度分析 → O(n log n) 到底是什么
排序算法总览 → 快排在整个排序家族里的位置
数组与链表 → 为什么分区依赖"随机访问"
把快排记成一个不断重复的动作:随便挑一个元素当标尺 → 小的扔左边、大的扔右边 → 对左右两边重复这个动作 → 区间长度 ≤ 1 时停止。剩下所有细节(三种写法、边界、防退化)都是这个动作的变体。
分区代码之所以容易写错下标,是因为看着一堆 i、j 会失去方向。不变量是锚:比如 Lomuto 的"a[l..i−1] 全都小于枢轴"。每写一行就检查一次这句话是否还成立,边界自然就对了;面试时把不变量讲出来,比把代码背下来更有说服力。
Theory · Divide & Conquer
上一页说"用一趟扫描消掉一整层规模",这一页就把那趟扫描画出来——它是快排唯一的引擎。
① 选一个枢轴 pv;② partition:一趟线性扫描、原地交换,把区间分成「< pv」与「≥ pv」两半;③ 对两半递归——两半各自有序后整体即有序,合并不需要任何工作。
切分均匀时递归深度 log₂n、每层分区总代价 Θ(n),合计 O(n log n);全程原地,辅助空间只有递归栈。与归并对偶:归并递归极简、工作在合并;快排合并零成本、工作在划分——所以快排的命运系于切分均匀性。
平均/期望 O(n log n);最坏 O(n²)——枢轴每次切出 1 : n−1(已序输入 + 端点枢轴、全等数组 + 单向扫描);空间 O(log n) 递归栈;不稳定(分区交换是远距离的)。
固定取端点枢轴 = 专吃已序/全等输入的 O(n²);随机枢轴把最坏从「输入决定」变成「概率事件」。写完 partition 默查一遍边界:递归区间必须严格缩小,否则死循环。
Implement I · Lomuto Partition
// Lomuto:j 单向扫描,pivot = a[r] 归位,返回 pivot 下标 func lomutoPartition(a []int, l, r int) int { pv := a[r] i := l // a[l..i-1] 全部 < pv for j := l; j < r; j++ { if a[j] < pv { a[i], a[j] = a[j], a[i] i++ //「< pv」区右扩一格 } } a[i], a[r] = a[r], a[i] // pivot 换到分界处,归位 return i } func quickSort(a []int, l, r int) { if l >= r { return } p := lomutoPartition(a, l, r) quickSort(a, l, p-1) // p 已就位,两端都排除 quickSort(a, p+1, r) }
k := l + rand.Intn(r-l+1); a[k], a[r] = a[r], a[k],把随机元换到末尾、其余照旧——已序/逆序输入不再触发 O(n²)。手写快排没随机化 ≈ 没写完。
pivot 取 a[r];扫描结束后 pivot 归位在下标 i,返回值 = pivot 的最终下标;递归边界 (l..p−1) / (p+1..r)——p 两端都排除。不变量:a[l..i−1] < pv,a[i..j−1] ≥ pv,a[j..r−1] 待定。
单指针、最好写最好讲;「返回值 = 已确定就位的下标」是快速选择(LC 215 第 K 大)直接复用的前提——partition 后只递归目标所在的一边,期望 O(n)。
每个 < pv 的元素都要交换一次;全等数组退化 O(n²)——等值元素全落「≥ pv」一侧,每层递归只消掉 pivot 自己。海量重复元素换三向切分(第 5 页)。
Implement II · Hoare Partition
// Hoare(1962):pivot = a[l] 当哨兵,双向扫描 // 返回「左半的右边界 j」——pivot 不归位 func hoarePartition(a []int, l, r int) int { pv := a[l] i, j := l-1, r+1 for { for { i++; if a[i] >= pv { break } } // 至少走一步 for { j--; if a[j] <= pv { break } } if i >= j { return j } // j = 左半右边界 a[i], a[j] = a[j], a[i] } } func quickSort(a []int, l, r int) { if l >= r { return } j := hoarePartition(a, l, r) quickSort(a, l, j) // 注意:j 留在左半,不排除! quickSort(a, j+1, r) }
① pivot 不归位,扫描结束时不保证在最终位置;② 返回值是「左半部分的右边界」j——a[l..j] ≤ pv、a[j+1..r] ≥ pv,不是 pivot 下标;③ 递归边界 (l..j) / (j+1..r),j 留在左半、不排除。
pv = a[l]:i 的第一趟扫描必停在 pivot 自己身上;此后交换到两侧的元素各自充当「挡板」。两个内层循环「至少走一步」(do-while 语义)保证每轮都有进展。
枢轴位置与递归边界必须成套——把枢轴换成 a[r] 而不改边界,升序输入 [1,2] 上 partition 返回 j == r,(l..j) 与原区间相同 → 无限递归。
相向扫描只交换「确定乱序」的元素对,交换次数少;等值元素停下交换、两指针同步推进,全等数组也大致平分——Go sort 包(pdqsort)等工业实现都是 Hoare 变体。
Implement III · 3-Way Partition
// Dijkstra 荷兰国旗:[l,lt-1]<pv · [lt,i-1]==pv · [i,gt]待定 · [gt+1,r]>pv func quickSort3(a []int, l, r int) { if l >= r { return } pv := a[l+rand.Intn(r-l+1)] // 随机枢轴 lt, i, gt := l, l, r for i <= gt { switch { case a[i] < pv: a[lt], a[i] = a[i], a[lt]; lt++; i++ case a[i] > pv: a[i], a[gt] = a[gt], a[i]; gt-- // i 原地不动! default: i++ // 等值区扩张 } } quickSort3(a, l, lt-1) // 等值段 [lt,gt] 整体退出递归 quickSort3(a, gt+1, r) }
Runnable · go run main.go
// 随机枢轴 + 只递归短半:栈深 ≤ log₂n func quickSort(a []int, l, r int) { for l < r { k := l + rand.Intn(r-l+1) a[k], a[r] = a[r], a[k] // 随机元换到末尾 p := partition(a, l, r) if p-l < r-p { quickSort(a, l, p-1) // 短半递归 l = p + 1 // 长半留在本轮循环 } else { quickSort(a, p+1, r) r = p - 1 } } } // Lomuto 分区:< pv 换到左边,pivot 归位 func partition(a []int, l, r int) int { pv := a[r] i := l for j := l; j < r; j++ { if a[j] < pv { a[i], a[j] = a[j], a[i] i++ } } a[i], a[r] = a[r], a[i] return i }
// import ( "fmt" "math/rand" ) func main() { a := []int{5, 3, 8, 1, 9, 2, 7, 3} quickSort(a, 0, len(a)-1) fmt.Println(a) } // 输出:[1 2 3 3 5 7 8 9]
① 分区前先把随机元换到末尾,已序/逆序/全等都不再退化;② 只递归短半、长半留在 for 循环——每层栈帧对应区间至少减半,栈深从「切分深度、最坏 O(n)」钉死到 ≤ log₂n,大数据不会打爆 goroutine 栈(默认初始仅 8KB)。
go1.22.5 实测:空 / 单元素 / 已序 / 逆序 / 全等 + 500 组随机重复用例,四种写法(Lomuto / Hoare / 三向 / 本页)结果与 sort.Ints 全部一致,0 失败。
Cheat Sheet
| 写法 | 枢轴取谁 | 返回值含义 | 递归边界 |
|---|---|---|---|
| Lomuto | a[r] | 枢轴的最终下标 p(已归位) | (l, p-1) / (p+1, r) |
| Hoare | a[l] | 左半的右边界 j(枢轴不归位) | (l, j) / (j+1, r) j 不排除 |
| 三向切分 | 随机 | 无返回值,靠 lt/gt 划分 | (l, lt-1) / (gt+1, r) 等值段退出 |
| 写法 | 什么时候用它 |
|---|---|
| Lomuto | 面试默认:单指针最好写;返回值是下标,可直接复用到快速选择(LC 215 第 K 大) |
| Hoare | 工业同款(Go sort 的 pdqsort 是它的变体):交换次数少,等值元素也能大致平分 |
| 三向切分 | 海量重复元素(LC 912 卡朴素快排的标准解):全等数组 O(n) 一趟完成 |
| 平均 / 期望 | O(n log n) 切分均匀时,层数 log₂n × 每层 Θ(n) |
| 最坏 | O(n²) 每趟只切出 1 : n−1(已序输入 + 端点枢轴、全等数组 + 二向分区) |
| 空间 | O(log n) 原地排序,只有递归栈;"只递归短半"后栈深钉死 ≤ log₂n |
| 稳定性 | 不稳定 分区交换是远距离的(要稳定用归并) |
1 · 随机枢轴加了吗?分区前把随机元换到末尾:已序 / 逆序 / 全等输入专杀固定枢轴。手写快排不随机化 ≈ 没写完。
2 · 枢轴位置和递归边界成套吗?Hoare 的枢轴取 a[l]、边界是 (l, j);换成 a[r] 却不动边界,在 [1,2] 上会返回 j == r → 无限递归。
3 · 递归区间严格缩小了吗?检查递归出口 l >= r 与传参,任一分支区间不变就是死循环。
4 · 三向切分的 >pv 分支 i 动了吗?不能动:从 gt 换过来的还是"待定元素",需要下一轮重新判定。
5 · 栈深控制了吗?只递归短半、长半留在 for 循环里,栈帧所在的那一半每层至少减半。
Related & References
排序算法总览 →(快排 vs 归并 vs 堆排、工业混合配方、快选/堆选对比)
复杂度分析 →(O(n log n) 背后的主定理语言)
二分查找 →(快选 = partition 版「单边递归减治」)
堆与优先队列 →(introsort / pdqsort 的兜底引擎)
数组与链表 →(partition 依赖随机访问——链表排序选归并)
Go slice →(sort.Slice 的操作对象)
参考来源(思路与写法可溯源至下列一手材料;本 deck 代码经 go1.22.5 编译运行验证)
| C.A.R. Hoare (1962) · Quicksort | The Computer Journal 5(1):10–16 —— 双向扫描分区原始方案 |
| CLRS ch.7 | partition 不变量与随机化期望分析 |
| E.W. Dijkstra (1976) · A Discipline of Programming | 荷兰国旗三向切分(ch.14) |
| go.dev/doc/go1.19 · src/sort/zsortinterface.go | sort 包换装 pattern-defeating quicksort 的官方说明与 Hoare 变体源码 |
| 本机 go1.22.5 实测 | 509 组用例(空 / 单元素 / 已序 / 逆序 / 全等 / 随机重复)× 4 种写法,0 失败 |