Implement · Quick Sort · Go

快速排序:Go 手撕实现

理论一分钟讲透,代码三种写法直接可背——Lomuto(面试默认)· Hoare(工业同款)· 三向切分(重复元素克星),外加一份本机 go run 验证过的完整版。

一个引擎

选枢轴 → 一趟扫描把区间分成「< pv」与「≥ pv」两半 → 两半递归。全部工作在 partition,合并零成本

三种写法

Lomuto:pivot 归位、最好写;Hoare:双向扫描、交换少;三向切分:等值段退出递归,海量重复元素 O(n)

两条底线

随机枢轴防退化(已序/全等输入专杀固定枢轴)+ 只递归短半,栈深钉死在 O(log n)

定位:本 deck 是快排的「思路 + 手撕代码」,面向面试白板默写;横向对比与工业源码深拆(期望复杂度推导、pdqsort/introsort 源码级)见排序总览 deck。三条主线:partition 是唯一引擎、三种分区写法语义不同、防退化是手写底线。所有代码已在 go1.22 实测验证。

Why Quick Sort

先看差距:把 n 轮变成 log₂n 轮

做法一次操作解决多少100 万条的量级
冒泡 / 选择 / 插入每轮只让 1 个元素到位,要跑 n 轮约 10¹² 次比较 → 千秒级
归并先对半分,再花代价合并约 2×10⁷ → 亚秒级,但要额外一倍内存
快排一趟扫描把区间粗略分成大小两半,合并零成本约 2×10⁷ → 亚秒级,且不用额外数组
一句话记住快排的洞察:如果我能用一趟线性扫描,把数组分成"左边都小、右边都大",那么两边各自排好之后,整体天然就有序了——合并不需要做任何事。省掉合并,是它比归并更快、更省内存的根源。
代价也在这里:它快不快,全看每一趟分得均不均匀。分得均匀 → 层数是 log₂n;分得极端(每次只分出 1 个)→ 层数退化成 n,直接掉回 O(n²)。快排的全部工程问题,几乎都是"怎么分得均匀"。

用一个具体数字感受

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)

本 deck 的路线

先讲清这一趟扫描(分区)在干什么 → 再给三种可直接默写的写法 → 最后收成一份带防退化措施的完整可运行版。读完你应该能在白板上从零写出快排,并说清每个边界为什么这么写。

动机页:先用"100 万条"这个具体数字把 O(n²) 与 O(n log n) 的差距变成体感,再说明慢算法慢在"一轮只搞定一个元素"。核心洞察是"合并零成本",代价是"分得均不均匀"——后者直接预告后面所有防退化措施(随机枢轴、三向切分、短半递归)。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(先记住这个,细节后面展开)
枢轴 pivot这一趟拿来做"分界标准"的那个元素;比它小的去左边,比它大的去右边
分区 partition一趟从左到右的扫描 + 原地交换,把区间按枢轴切成两半
原地 in-place不开新数组,只在原数组里换元素;快排的额外内存只有递归栈
分治把大问题拆成同构的小问题,各自解决后答案自然合并
递归深度 / 栈递归调用了"几层";层数太多会打爆调用栈
稳定性相等元素的原有相对顺序是否保持不变;快排不稳定(交换是远距离的)
不变量 invariant循环过程中始终成立的那句话;写对循环的唯一抓手,也是向面试官解释代码的凭据

如果这些词还有一个是陌生的

先读这几篇再回来,本 deck 默认你已经能读懂 O(n log n)、递归和数组下标:
复杂度分析 → O(n log n) 到底是什么
排序算法总览 → 快排在整个排序家族里的位置
数组与链表 → 为什么分区依赖"随机访问"

本 deck 的最小心智模型

把快排记成一个不断重复的动作:随便挑一个元素当标尺 → 小的扔左边、大的扔右边 → 对左右两边重复这个动作 → 区间长度 ≤ 1 时停止。剩下所有细节(三种写法、边界、防退化)都是这个动作的变体。

为什么"不变量"值得单独记

分区代码之所以容易写错下标,是因为看着一堆 i、j 会失去方向。不变量是锚:比如 Lomuto 的"a[l..i−1] 全都小于枢轴"。每写一行就检查一次这句话是否还成立,边界自然就对了;面试时把不变量讲出来,比把代码背下来更有说服力。

阅读提示:术语不用背,忘了回来查这一页;真正要能默写的只有第 8 页的完整代码,以及最后那张速查表里的边界。
前置页:七个术语先定义再使用。右侧给出"最小心智模型"(挑标尺→扔两边→重复),把后三页的三种写法统一成同一动作的三个变体,降低记忆负担;并点明"不变量"既是写码抓手也是面试表达凭据。

Theory · Divide & Conquer

核心思路:全部工作在一趟 partition

上一页说"用一趟扫描消掉一整层规模",这一页就把那趟扫描画出来——它是快排唯一的引擎。

三步走

选枢轴一趟分区两半递归

① 选一个枢轴 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 默查一遍边界:递归区间必须严格缩小,否则死循环。

一趟 partition 的分治流程 数组 5 3 8 1 9 2 选枢轴 5:一趟原地扫描后数组变为 3 1 2 5 8 9,小于 5 的都在左半、大于等于 5 的都在右半、5 归位;随后对左右两半分别递归,各自排好即整体有序,合并不需要任何工作。 选枢轴 pv = 5(示例) 5 3 8 1 9 2 一趟扫描 · 原地交换:< pv 的换到左边 · ≥ pv 的留在右边 3 1 2 5 8 9 左半 < pv → 递归 归位 右半 ≥ pv → 递归 quickSort(左半) quickSort(右半) 两半各自排好 1 2 3 5 8 9 全程原地:辅助空间只有递归栈 · 深度 log₂n × 每层 Θ(n) = O(n log n)
唯一的理论页:三步走 + 复杂度速记 + 手撕底线。右图强调两件事——partition 是一趟线性原地扫描、合并不花任何代价。枢轴取哪个位置(首/尾/随机)在不同写法里不同,图里只标「选枢轴」,具体语义见后三页代码。

Implement I · Lomuto Partition

实现一 · Lomuto:好写好讲,面试默认

// 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 归位返回下标p 两端排除

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 页)。

Lomuto 三句语义:pivot 取 a[r]、归位返回下标、递归两端排除 p。快选依赖「归位」语义。随机枢轴两行改造是手写默认必带;全等数组 O(n²) 的机理(每层只消 1 个)引出第 5 页三向切分。

Implement II · Hoare Partition

实现二 · Hoare:双向扫描,工业同款

// 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)
}

与 Lomuto 的三个语义差异

pivot 不归位返回边界j 留在左半

① 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 变体。

Hoare 三个易错点:pivot 不归位、返回值是边界不是下标、递归边界包含 j。[1,2] 死循环陷阱真实可复现(枢轴换 a[r] 不改边界),面试可主动讲出来展示边界意识。工业库几乎都是 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)
}
复杂度随「等值个数」跳水:全等数组 O(n) 一趟完成(等值区吞掉全部元素)。二向每层只确定 pivot 一个元素的位置,三向每层确定全部等值元素——这是对重复元素免疫的根源。
三向切分的三色区域演进 数组 4 2 6 1 4 3 4 以 4 为枢轴:小于 4 的换到左蓝区、大于 4 的换到右橙区、等于 4 的留在中间灰区,指针交错后数组变成 2 1 3 4 4 4 6,等值段直接退出递归。 初始 lt=i=l, gt=r 4 2 6 1 4 3 4 中途:三区成型 2 4 4 1 4 3 6 =区在中央生长 结束 i > gt 2 1 3 4 4 4 6 三区语义 [l, lt−1] < pv —— 递归 [lt, gt] == pv —— 就位,永不移动 [gt+1, r] > pv —— 递归 递归只碰两端,等值段整体退出
三向切分把「与 pivot 的三种关系」变成三个区域:等值区元素一轮后永久就位。易错点:>pv 分支交换后 i 原地不动(换来的还是待定元素)。LC 912 海量重复用例卡死朴素快排的标准正解;LC 75 颜色分类 = 本页去掉递归。

Runnable · go run main.go

完整可运行版:随机枢轴 + 栈深 O(log n)

// 随机枢轴 + 只递归短半:栈深 ≤ 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]

和「裸 Lomuto」差在哪

① 分区前先把随机元换到末尾,已序/逆序/全等都不再退化;② 只递归短半、长半留在 for 循环——每层栈帧对应区间至少减半,栈深从「切分深度、最坏 O(n)」钉死到 ≤ log₂n,大数据不会打爆 goroutine 栈(默认初始仅 8KB)。

验证方式(本 deck 代码均为实测)

go1.22.5 实测:空 / 单元素 / 已序 / 逆序 / 全等 + 500 组随机重复用例,四种写法(Lomuto / Hoare / 三向 / 本页)结果与 sort.Ints 全部一致,0 失败。

追问预备:「海量重复元素怎么办」→ partition 换三向切分(上一页),外层骨架不变;「能不能再稳一点」→ 深度超限切堆排(introsort 思想,见排序总览 deck)。
白板终稿:随机枢轴 + 小半递归大半循环,直接可背可跑。栈深证明一句话:循环每轮对应一次 partition,栈帧所在的那一半区间至少减半。509 组用例实测 0 失败,可放心默写。追问按 callout 两条预备。

Cheat Sheet

一页带走:三种写法 · 复杂度 · 五条边界

① 三种写法:差异全在这四格

写法枢轴取谁返回值含义递归边界
Lomutoa[r]枢轴的最终下标 p(已归位)(l, p-1) / (p+1, r)
Hoarea[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 循环里,栈帧所在的那一半每层至少减半。

高频追问,一句话预备:
· "最坏什么时候发生" → 已序输入 + 端点枢轴,或全等数组 + 二向分区;
· "怎么防" → 随机枢轴(概率事件化)+ 三向切分(重复元素)+ 深度超限切堆排(introsort 思想);
· "和归并怎么选" → 要省内存 / 数据量大且不要求稳定 → 快排;要稳定 / 链表结构 → 归并;
· "快排能用在链表上吗" → 能但吃亏:分区依赖随机访问,链表排序首选归并。
一句话背下来:快排 = 一趟分区把规模减半,合并零成本;它所有的工程细节,都是为了让"减半"这件事稳定发生
速查页:三种写法的差异收敛到"枢轴 / 返回值 / 递归边界"四格,是默写出错率最高的地方;五条检查清单覆盖死循环、无限递归、退化三类致命错误;最后压缩四句高频追问。

Related & References

相关知识点与参考

算法系列

排序算法总览 →(快排 vs 归并 vs 堆排、工业混合配方、快选/堆选对比)
复杂度分析 →(O(n log n) 背后的主定理语言)
二分查找 →(快选 = partition 版「单边递归减治」)

数据结构 & Go 系列

堆与优先队列 →(introsort / pdqsort 的兜底引擎)
数组与链表 →(partition 依赖随机访问——链表排序选归并)
Go slice →(sort.Slice 的操作对象)

参考来源(思路与写法可溯源至下列一手材料;本 deck 代码经 go1.22.5 编译运行验证)

C.A.R. Hoare (1962) · QuicksortThe Computer Journal 5(1):10–16 —— 双向扫描分区原始方案
CLRS ch.7partition 不变量与随机化期望分析
E.W. Dijkstra (1976) · A Discipline of Programming荷兰国旗三向切分(ch.14)
go.dev/doc/go1.19 · src/sort/zsortinterface.gosort 包换装 pattern-defeating quicksort 的官方说明与 Hoare 变体源码
本机 go1.22.5 实测509 组用例(空 / 单元素 / 已序 / 逆序 / 全等 / 随机重复)× 4 种写法,0 失败
收尾互链:算法三条 + 数据结构/Go 三条。参考表 Hoare 1962 是所有分区算法的源头;最后一行是本 deck 的实测记录。总页数 10。