Summary

排序看起来是个"把一堆数从小到大摆好"的小学生问题,但是它是算法入门里性价比最高的一站。 学习这个算法需要建立三种直觉: 分治(把大问题切成小问题再合并) 复杂度(为什么有的 O(n²) 有的 O(n log n),差在哪) 稳定性(相等元素的相对顺序会不会被打乱)。 这三样东西后面到处都用得上——快排的分治骨架和归并的合并手法,排序也是理解"工业级代码怎么把几种算法缝在一起扬长避短“的最佳样本。

两大分类

排序算法氛围两大类,分界线是”它靠不靠元素之间两两比较来决定顺序":

  • 比较排序:只用 a < b 这种比较操作。冒泡、选择、插入、快排、归并、堆排全在这一类。
  • 非比较排序:不做元素比较,而是利用元素本身的值当下标/分桶。计数、桶、基数排序属于这类。它们能做到 O(n),但有前提(值域有限、能映射成整数等),拿空间和假设换时间。
排序
├── 比较排序(下界 O(n log n))
│   ├── O(n²):  冒泡 / 选择 / 插入
│   └── O(nlogn):快排 / 归并 / 堆排
└── 非比较排序(可到 O(n),有前提)
    └── 计数 / 桶 / 基数

再叠加两个维度看每个算法:是否稳定、是否原地(额外空间 O(1))。这两个维度决定了工程里到底选谁。心里先摆好这张表,下面逐个填。

O(n²) 三兄弟:冒泡、选择、插入

这三个都是 O(n²),看着"没用",但插入排序是工业级排序的秘密武器,别急着跳过。

冒泡排序(Bubble Sort)

相邻两个比,大的往后"冒"。每一趟把当前最大的顶到末尾。

func bubbleSort(a []int) {
    n := len(a)
    for i := 0; i < n-1; i++ {
        swapped := false
        for j := 0; j < n-1-i; j++ {
            if a[j] > a[j+1] {
                a[j], a[j+1] = a[j+1], a[j]
                swapped = true
            }
        }
        if !swapped { // 一趟没交换,说明已经有序,提前退出,节省时间
            break
        }
    }
}

特点:稳定(相等不交换)、原地。加了 swapped 提前退出后,近乎有序的数据只需 O(n)。实际使用价值很低,教学价值很大,这个算法是接触排序的第一个认识的。

选择排序(Selection Sort)

每趟从未排序区选出最小的,放到已排序区末尾。

func selectionSort(a []int) {
    n := len(a)
    for i := 0; i < n-1; i++ {
        min := i
        for j := i + 1; j < n; j++ {
            if a[j] < a[min] {
                min = j
            }
        }
        a[i], a[min] = a[min], a[i]
    }
}

特点:不稳定(远距离交换会打乱相等元素顺序)、原地。它有个独特优点——交换次数最少(最多 n-1 次),所以当"写/交换成本极高"(比如元素很大、搬一次很贵)时它有点意义。除此之外比较次数恒定 O(n²),不会因为数据有序而变快,比冒泡还死板。

插入排序(Insertion Sort)—— 重点

像理扑克牌:左手已排好序,右手摸一张,从右往左找到它该插的位置塞进去。

// Example:
// 将整个数组分为 已排序 | 乱序 两个分区
// [2 5 8]| 3      取 3,往左挪:8>3、5>3、2<3 → 停
// [2 3 5 8]        3 插到 2 和 5 之间

func insertionSort(a []int) {
    for i := 1; i < len(a); i++ { // i 从1开始
        key := a[i]
        j := i - 1
        for j >= 0 && a[j] > key { // 比 key 大的整体右移一格
            a[j+1] = a[j]
            j--
        }
        a[j+1] = key
    }
}

特点:稳定、原地。最坏 O(n²)。插入排序在小数组和近乎有序的数据上快得离谱。近乎有序时内层几乎不挪动,退化成 O(n);小数组时它没有递归、没有函数调用、没有额外内存分配,常数因子极小,直接吊打快排/归并。

工业级排序几乎都在小区间切回插入排序:快排/归并递归到子区间长度小于某个阈值(Go 的 pdqsort 是 12,很多实现取 16 左右)就不再递归,改用插入排序收尾。这不是偷懒,是实测出来的最优解——递归的固定开销在小数据上比 O(n²) 还贵。记住这个"大用分治、小用插入“的缝合思路,后面讲 pdqsort 时会再见到它。


O(n log n) 三大将

主力就这三个:快排、归并、堆排。都是 O(n log n),使用哪个取决于你要稳定、要省内存、还是要抗最坏。

快速排序(Quick Sort)—— 分治的代表

快速排序就是选一个 pivot(基准),把数组分成"比 pivot 小的"和"比 pivot 大的"两半,pivot 落到它的最终位置;然后对左右两半递归。分而治之,原地完成。

它快在哪?每一次分区,都一口气确定了一个元素的最终位置,并且把问题规模砍成两半。而且全程在原数组上交换,不需要额外空间。

// Example:
// 选 pivot=5
// [3 8 1 5 7 2]
// partition 后: [3 1 2] 5 [8 7]    5 已归位
//               ↓递归      ↓递归

func quickSort(a []int, low, hi int) {
    if low >= hi {
        return
    }
    p := partition(a, low, hi) // p 是 pivot 归位后的下标
    quickSort(a, low, p-1)
    quickSort(a, p+1, hi)
}

func partition(a []int, low, hi int) int { // 拿 a[hi] 当 pivot,把它放到最终位置并返回该下标
    pivot := a[hi]
    i := low // i 指向"小于区"的下一个位置
    for j := low; j < hi; j++ {
        if a[j] < pivot {
            a[i], a[j] = a[j], a[i]
            i++
        }
    }
    a[i], a[hi] = a[hi], a[i] // pivot 归位:左边全小于它,右边全不小于它
    return i
}

快排就像是体育老师给一群学生按身高排队。一种笨办法是两两比对慢慢挪。老师的办法是:随手拉出一个同学站中间,喊一声"比他矮的去左边,比他高的去右边”——一句话下去,人群立刻分成两堆,而且被拉出来的那位同学位置从此定死了(左边全比他矮,右边全比他高)。然后对左右两堆各自重复这个操作。

快排复杂度平均 O(n log n),最坏 O(n²)。最坏发生在 pivot 每次都选到最大或最小值——比如对已经有序的数组用"取末尾当 pivot"的朴素快排,每次分区只切掉一个元素,退化成 O(n²) 还爆栈。

怎么救最坏情况,两招:

  1. 随机化 pivot:随机选一个位置和末尾交换再分区,让攻击者构造不出必坏输入。
  2. 三数取中(median-of-three):取 lo、mid、hi 三个位置的中位数当 pivot,对有序/逆序数据尤其有效。数据量大时还有"九数取中"(median-of-medians 的近似)。

特点:原地(只用 O(log n) 递归栈)、不稳定(分区里的远距离交换会打乱相等元素)。缓存友好、常数小,是平均最快的比较排序,所以是很多语言未排序数组的默认底子。

一个更抗退化的分区法是 三路快排(Dutch National Flag),把数组分成 <pivot、==pivot、>pivot 三段。当数据里有大量重复元素时,普通快排会在相等元素上白干很多活,三路快排直接把 ==pivot 一整段跳过,重复越多越快。

归并排序(Merge Sort)—— 稳定 + 外部排序之王

思想:先无脑对半切到底(切到单个元素天然有序),再两两归并成有序段,一路合并回去。

// Example
//        [5 3 8 1 2 7]
//        /           \
//   [5 3 8]         [1 2 7]      递归切分
//   ...              ...
//   [3 5 8]         [1 2 7]      触底后逐层 merge
//        \           /
//        [1 2 3 5 7 8]           双指针合并两个有序段

func mergeSort(a []int) []int {
    if len(a) <= 1 {
        return a
    }
    mid := len(a) / 2
    left := mergeSort(a[:mid])
    right := mergeSort(a[mid:])
    return merge(left, right)
}

func merge(l, r []int) []int {
    res := make([]int, 0, len(l)+len(r))
    i, j := 0, 0
    for i < len(l) && j < len(r) {
        if l[i] <= r[j] { // <= 是稳定的关键:相等时先取左边
            res = append(res, l[i])
            i++
        } else {
            res = append(res, r[j])
            j++
        }
    }
    res = append(res, l[i:]...)
    res = append(res, r[j:]...)
    return res
}

复杂度:稳稳的 O(n log n),没有最坏退化——切分和数据内容无关,永远对半。代价是需要 O(n) 额外空间。

特点:稳定(merge 里相等取左边)、非原地。它有两个杀手级场景:

  • 链表排序:链表没有随机访问,快排的"取中间 pivot"很别扭,而归并只需顺序遍历 + 指针拼接,归并是链表排序的首选,还能做到 O(1) 额外空间(指针重连,不用辅助数组)。
  • 使用场景:当数据大到内存装不下——几十 GB 的日志、排不进内存的海量链上数据——就得靠归并。思路是分而治之的磁盘版:把大文件切成能进内存的小块,每块在内存里排好序写回磁盘(叫"顺串 run"),再用多路归并把这些有序小文件合并成一个大有序文件。归并的"合并两个有序序列"天生就是流式的,只需要每个文件的当前队头,内存占用极小。这就是归并排序不可替代的地方。

堆排序(Heap Sort)—— 原地 + O(1) 额外空间

思想:把数组原地建成一个大顶堆(最大值在堆顶),然后反复"把堆顶和末尾交换,堆大小减一,再下沉修复",每次固定一个最大值到末尾。

// Example
// 1. build max-heap:     [8 5 7 1 3 2]  堆顶=8 最大
// 2. 堆顶↔末尾,size--:    [2 5 7 1 3|8]  8 归位,再 sift-down 修复前 5 个
// 3. 重复...最终整体有序

func heapSort(a []int) {
    n := len(a)
    // 建堆:从最后一个非叶子节点开始下沉
    for i := n/2 - 1; i >= 0; i-- {
        siftDown(a, i, n)
    }
    // 反复取堆顶
    for end := n - 1; end > 0; end-- {
        a[0], a[end] = a[end], a[0] // 最大值扔到末尾
        siftDown(a, 0, end)         // 修复 [0, end)
    }
}

func siftDown(a []int, i, n int) {
    for {
        l, r, largest := 2*i+1, 2*i+2, i
        if l < n && a[l] > a[largest] {
            largest = l
        }
        if r < n && a[r] > a[largest] {
            largest = r
        }
        if largest == i {
            return
        }
        a[i], a[largest] = a[largest], a[i]
        i = largest
    }
}

复杂度:恒定 O(n log n),无最坏退化。特点:原地、O(1) 额外空间(这是它相对归并的最大优势),但不稳定,而且它的访问模式在数组里"跳来跳去"(父子下标 2i、2i+1),缓存不友好,实测常数比快排大,所以纯堆排单用得不多。

它真正的价值在优先队列和 Top-K:你不需要全排序,只想要"最大的那个"“前 K 个”。用一个大小为 K 的堆扫一遍数据,就能 O(n log K) 拿到 Top-K,不用把整个数据集排完。这在 mempool 选最高 gas price、热点统计里直接能用。堆排的另一个用途是给快排兜底:内省排序(introsort)在快排递归太深、快要退化成 O(n²) 时,切换到堆排保证最坏也是 O(n log n)。

排序对比

算法时间(平均 / 最坏)额外空间稳定特点
快排n log n / n²O(log n)否平均最快、缓存友好、可能退化
归并n log n / n log nO(n)是稳定、抗退化、链表/外部排序首选
堆排n log n / n log nO(1)否原地省内存、不退化、缓存差

一句话选型:默认快排(快),要稳定用归并,要省内存/防退化用堆排。 工业库(pdqsort)干脆把三个缝在一起,各取所长。


排序的稳定性

稳定(stable) 的定义:排序后,值相等的元素,相对顺序和排序前一样。

用排队买票理解:一群人按年龄排队,张三和李四同岁。稳定排序保证"原本谁站前面,排完还是谁站前面";不稳定排序则可能把他俩的先后顺序随手调换——反正都同岁,算法不在乎,但你可能在乎(比如那个顺序代表了先来后到)。

原始(按到达顺序): (A,3) (B,1) (C,3) (D,1)
按第二个字段排序:
稳定结果:   (B,1) (D,1) (A,3) (C,3)   ← 1 里 B 仍在 D 前,3 里 A 仍在 C 前
不稳定结果: (D,1) (B,1) (C,3) (A,3)   ← 相等元素顺序被打乱了

单看一列数字,稳不稳定看不出区别。稳定性只在元素带"附加信息"、或者要按多关键字排序时才要命:

  • 多关键字 / 二次排序:先按"部门"排,再按"薪资"排,想要"同薪资内仍保持部门内的原顺序"——这就必须用稳定排序做第二趟。不稳定的话,第二趟会把第一趟排好的顺序搅乱。工程里"先按 A 排、再按 B 排,得到 (B 主序, A 次序)“的技巧,完全依赖排序稳定。
  • 对象排序:结构体按某字段排,你通常不希望相等字段的记录顺序被无谓打乱。

稳定的排序法:插入、冒泡、归并 不稳定:选择、快排、堆排

需要稳定又要 O(n log n),标准答案就是归并排序(所以 Go 的 sort.Stable / SortStableFunc 底层用的是归并思路)。

比较排序的下界:为什么快不过 O(n log n)

这个结论听起来很强(”没有人能做得更快"),但道理其实很朴素,像猜数字游戏。

你和朋友玩猜数,他心里想一个 1~1000 的数,你每次只能问"是不是比 X 大"这种是非问题。每问一次最多把范围砍一半,所以你至少要问 10 次——这不是你笨,是信息量决定的下限:1000 种可能需要 10 个二进制位才能区分。

排序也一样:n 个元素有 n! 种可能的排列,你要从中锁定唯一正确的那一种。而每次比较只能得到"是/否"一个比特的信息。要区分 n! 种情况,至少需要 log₂(n!) 次比较,而这个值约等于 n log n。

用**决策树(decision tree)**把它画出来更清楚:任何比较排序,本质是一连串"a<b?“的判断,每个判断有两个分支(是/否),整个算法就是一棵二叉树,每一条从根到叶的路径对应一种可能的输入排列。

n 个元素有 n! 种排列,算法必须能区分它们,所以决策树至少要有 n! 个叶子。一棵高度为 h 的二叉树最多 2^h 个叶子,于是:

2^h ≥ n!  →  h ≥ log2(n!)  ≈  n·log n   (斯特林公式)

树的高度 = 最坏情况下的比较次数。所以任何比较排序,最坏至少要 O(n log n) 次比较。这就是为什么快排/归并/堆排到 O(n log n) 就"到顶了”,想更快只能跳出"比较"这个框——那就是下一节的非比较排序。


非比较排序:用空间/前提换线性时间

比较算法有 O(n log n) 的天花板,那就干脆不比较——直接拿元素的值当下标去"数"或"分桶"。

想想邮局怎么分信:分拣员不会把信两两拿起来比较邮编大小,而是墙上钉一排格子,看一眼邮编,直接扔进对应的格子。每封信只看一次、扔一次,和信的总数成正比,压根没有"比较"这个动作。这就是非比较排序能突破 O(n log n) 的原因——它跳出了"只能比较"这个游戏规则。

这些算法的代价就是要有额外假设:你得先知道有哪些格子(值域),而且格子不能太多。

计数排序(Counting Sort)

计数排序不是在比较元素,而是在统计每个值出现了多少次。

前提:元素是范围不大的非负整数(值域 k 不大)。开一个大小为 k 的计数数组,数每个值出现几次,再按顺序摊开——就是上面那排"格子"。

// Example
// 数据: [2 5 2 1 5]   值域 0..5
// 计数: cnt[1]=1 cnt[2]=2 cnt[5]=2
// 摊开: 1,2,2,5,5

O(n + k),当 k = O(n) 时就是线性。适合"年龄、成绩、字节值“这种值域窄的场景,值域一大(比如排 64 位整数)就废了——数组开不下。

桶排序(Bucket Sort)

前提:数据大致均匀分布在某个区间。把区间切成若干"桶”,元素按值散到对应桶里,每个桶内部再用别的排序(或插入排序),最后按桶顺序拼接。

均匀分布时平均 O(n)。它是"分而治之 + 局部小排序"的思路,外部排序的分块也有它的影子。数据分布倾斜(全挤在一个桶)时退化成桶内那个排序的复杂度。

基数排序(Radix Sort)

前提:能按"位"来比(整数、定长字符串)。从最低位到最高位(LSD),每一位用一次稳定的计数排序。因为每趟稳定,低位排好的顺序在高位相等时会被保留,最终整体有序。

按个位排 → 按十位排 → 按百位排 ...   每趟必须用稳定排序
func radixSort(a []int) { // 非负整数
	if len(a) == 0 {
		return
	}
	max := slices.Max(a)
	buf := make([]int, len(a))
	for exp := 1; max/exp > 0; exp *= 10 { // 依次按个位、十位、百位……
		var cnt [10]int
		for _, v := range a {
			cnt[v/exp%10]++ // 只看当前这一位是数字几
		}
		for d := 1; d < 10; d++ {
			cnt[d] += cnt[d-1] // 前缀和:cnt[d] = 这一位 ≤ d 的元素个数,
		}                      // 也就是数字 d 那一段在结果里的右边界
		for i := len(a) - 1; i >= 0; i-- { // 从后往前搬 → 相等的保持原顺序(稳定)
			d := a[i] / exp % 10
			cnt[d]--
			buf[cnt[d]] = a[i]
		}
		copy(a, buf) // 这一位排完,进入下一位
	}
}

中间那三小段(计数 → 前缀和 → 从后往前搬)就是稳定版计数排序:前缀和把"每个数字出现几次"变成"每个数字的段落在结果里的边界",从后往前搬则保证同一段里先来的还在前面。

复杂度 O(d·(n+k)),d 是位数。排大量定长整数/字符串(如 IP、定长 ID、哈希前缀)时很能打。

小结:非比较排序不是万能钥匙,它们靠"值域有限 / 分布均匀 / 定长可分位“这些前提,拿内存换速度。工程里遇到"海量小整数"“定长 key"才考虑,通用场景还是比较排序当家。


Go 工程实战:标准库到底用了什么

作为 Go 工程师最该吃透的一节。需要你搞清它用了什么、为什么。

排序的三种调用姿势

import (
    "sort"
    "slices" // Go 1.21+
)

// 1. sort.Interface:老派,自定义类型实现 Len/Less/Swap
type ByAge []Person
func (p ByAge) Len() int           { return len(p) }
func (p ByAge) Less(i, j int) bool { return p[i].Age < p[j].Age }
func (p ByAge) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
sort.Sort(ByAge(people))

// 2. sort.Slice:传比较闭包,不用定义类型,最常用
sort.Slice(people, func(i, j int) bool {
    return people[i].Age < people[j].Age
})

// 3. slices.Sort / SortFunc:Go 1.21+ 泛型,首选
slices.Sort(unsortArray)                        // 基础类型,直接排
slices.SortFunc(people, func(a, b Person) int {
    return cmp.Compare(a.Age, b.Age)     // 返回 <0 / 0 / >0
})

注意 slices.SortFunc 的比较函数返回 int(三态:负/零/正),不是 bool——这是新 API 的风格,和 cmp.Compare 配套。

Go底层:pdqsort(pattern-defeating quicksort)

工业级排序是什么呢,它就是把几个各有所长的排序算法糅合在一起,例如:Go 1.19 起,sort 和 slices 的非稳定排序底层是 pdqsort。

pdqsort 的缝合逻辑:
① 默认走快排(平均最快、缓存友好)
② 子区间长度 ≤ 12  → 切回插入排序
③ 递归深度超过阈值(约 2*log n,快排要退化了)→ 切堆排(保证最坏也是 O(n log n))
④ pivot 用近似中位数,并检测"已经有序/近乎有序"的模式直接短路
⑤ 对大量重复元素做三路处理,避免在相等元素上白干

一句话:快排打平均,插入排收小尾,堆排兜最坏,再加上模式检测防退化。

要稳定就用 Stable 版

sort.Sort / slices.Sort 是不稳定的(底层快排/pdqsort)。要稳定,用:

sort.Stable(ByAge(people))
sort.SliceStable(people, func(i, j int) bool { return ... })
slices.SortStableFunc(people, func(a, b Person) int { return ... }) // 1.21+

稳定版底层是归并思路(块内插入排序 + 块间原地归并),复杂度 O(n log n),但常数比不稳定版大——不需要稳定就别用 Stable,白交学费。

排序+搜索

sort.Ints(a); sort.Strings(s); sort.Float64s(f) // 老式便捷函数
slices.SortFunc(...)                            // 新式泛型,优先用
i, found := slices.BinarySearch(sortedNums, x)  // 排好序后二分查找
slices.IsSorted(nums)                           // 判断是否已有序
sort.Search(n, func(i int) bool { ... })        // 通用二分,找满足条件的最小下标

排序 + 二分是一对:排一次序,后续多次 O(log n) 查询,这个组合在报价表、订单簿查询里天天用。