摘要:本文系统梳理八种常见排序算法------直接插入排序、希尔排序、直接选择排序、堆排序、冒泡排序、快速排序、归并排序与计数排序。全文按「插入、选择、交换、归并、非比较」五条路线展开,逐一讲清每种算法的核心思想、C 语言实现、复杂度与稳定性,并重点剖析快速排序的三种划分写法、退化原因与优化手段。最后用一张总表对比时间、空间与稳定性,给出工程选型建议,并附上 11 个版本的完整测试代码与 10 万随机整数的实测数据,帮助读者从「会写代码」进阶到「会选算法」。
文章目录
-
- [1. 排序:把「无序」变成「可用」](#1. 排序:把「无序」变成「可用」)
-
- [1.1 为什么排序值得单独讲](#1.1 为什么排序值得单独讲)
- [1.2 排序的定义与关键字](#1.2 排序的定义与关键字)
- [1.3 评价排序的四个维度](#1.3 评价排序的四个维度)
- [1.4 八种算法,五条路线](#1.4 八种算法,五条路线)
- [1.5 文件规划](#1.5 文件规划)
- [2. 插入排序:像整理手里的扑克牌](#2. 插入排序:像整理手里的扑克牌)
-
- [2.1 直接插入排序](#2.1 直接插入排序)
- [2.2 希尔排序](#2.2 希尔排序)
- [3. 选择排序:每趟锁定一个最值](#3. 选择排序:每趟锁定一个最值)
-
- [3.1 直接选择排序](#3.1 直接选择排序)
- [3.2 堆排序](#3.2 堆排序)
- [4. 交换排序:让大的往后走](#4. 交换排序:让大的往后走)
-
- [4.1 冒泡排序](#4.1 冒泡排序)
- [4.2 快速排序](#4.2 快速排序)
-
- [4.2.1 Hoare 版本](#4.2.1 Hoare 版本)
- [4.2.2 挖坑法](#4.2.2 挖坑法)
- [4.2.3 前后指针法(Lomuto)](#4.2.3 前后指针法(Lomuto))
- [4.2.4 快排的退化与优化](#4.2.4 快排的退化与优化)
- [4.2.5 非递归版本](#4.2.5 非递归版本)
- [4.2.6 复杂度与稳定性](#4.2.6 复杂度与稳定性)
- [5. 归并排序:先分解,再合并](#5. 归并排序:先分解,再合并)
- [6. 计数排序:不比较也能排](#6. 计数排序:不比较也能排)
- [7. 放在一张表里:复杂度、稳定性与选择](#7. 放在一张表里:复杂度、稳定性与选择)
-
- [7.1 复杂度与稳定性总表](#7.1 复杂度与稳定性总表)
- [7.2 工程上如何选择](#7.2 工程上如何选择)
- [7.3 性能实测](#7.3 性能实测)
- [7.4 测试代码](#7.4 测试代码)
- [8. 易错点清单](#8. 易错点清单)
- [9. 小结](#9. 小结)
1. 排序:把「无序」变成「可用」
1.1 为什么排序值得单独讲
排行榜要按销量从高到低,成绩表要按总分排名,数据库里的 ORDER BY、电商里的「价格从低到高」,本质上都是排序。
排序本身往往不是终点,而是为了降低后续操作的成本:
- 有序数组才能做二分查找,把 O ( n ) O(n) O(n) 的查找降成 O ( log n ) O(\log n) O(logn);
- 两个有序段才能在线性时间里合并成一个大有序段,这是归并排序和外排序的基础;
- 求 TopK 时,堆只需要维护 k k k 个候选,不必把全部数据排好;
- 去重、求中位数、找第 k k k 小,也常常先把数据整理有序,问题就简单了。
前面几篇已经介绍过线性表、栈、队列、二叉树和堆。堆能用 O ( log n ) O(\log n) O(logn) 维护最值,但只保证堆顶是最大值或最小值,不保证整个数组有序。从「能取最值」到「整体有序」,还差一套排序算法。
这一篇把八种常见排序放在一起,重点不在记代码,而在回答三个问题:
- 每一步到底在做什么?
- 时间、空间、稳定性各付出什么代价?
- 面对具体数据,应该选哪一个?
1.2 排序的定义与关键字
排序 (sorting):把一组记录按照某个或某些关键字的大小,递增或递减地排列起来。
一条记录通常不只包含关键字,还带着其他数据。例如学生记录 (总分, 姓名, 学号),按总分排序时,总分是关键字,姓名和学号是卫星数据(satellite data)。交换两条记录时,要把整条记录一起交换,不能只换关键字。
本篇的代码统一对 int 数组排序,目的是把注意力放在算法本身。真实工程里,比较和交换的对象可能是结构体,通常会传入一个比较函数------C 标准库的 qsort 就是这种接口。
关键字允许重复,这也带来了稳定性问题。
1.3 评价排序的四个维度
评价排序不能只看速度,常用四个维度:
| 维度 | 具体看什么 | 典型问题 |
|---|---|---|
| 时间 | 比较次数、移动次数;最好 / 平均 / 最坏情况 | 数据逆序时会不会退化? |
| 空间 | 是否原地排序;额外数组有多大 | 内存紧张时还能不能用? |
| 稳定性 | 相同关键字的记录,相对次序是否保持不变 | 多关键字排序时次序会不会被打乱? |
| 适用条件 | 数据范围、存储结构、是否链表 | 计数排序能用于字符串吗? |
稳定性(stability)的定义:待排序序列中有多个相同关键字的记录,排序后这些记录的相对次序保持不变,就说这个算法是稳定的;否则是不稳定的。
举个例子,按关键字排序:
( 5 , A ) , ( 3 , B ) , ( 5 , C ) (5,A),\ (3,B),\ (5,C) (5,A), (3,B), (5,C)
稳定排序一定得到:
( 3 , B ) , ( 5 , A ) , ( 5 , C ) (3,B),\ (5,A),\ (5,C) (3,B), (5,A), (5,C)
不稳定排序可能得到:
( 3 , B ) , ( 5 , C ) , ( 5 , A ) (3,B),\ (5,C),\ (5,A) (3,B), (5,C), (5,A)
A 和 C 的关键字都是 5,但原来的先后次序被破坏了。
稳定性有实际用途。比如先按姓名排序,再按分数排序:第二次排序稳定时,分数相同的同学仍保持姓名的字典序;不稳定时,前一次排序的结果就被破坏。表格、订单列表、多条件筛选都依赖这个性质。
这四个维度经常互相制约:
- 快速排序平均性能好、原地,但不稳定,最坏会退化成 O ( n 2 ) O(n^2) O(n2);
- 归并排序稳定、最坏也是 O ( n log n ) O(n\log n) O(nlogn),但需要 O ( n ) O(n) O(n) 的额外空间;
- 计数排序能到线性时间,却只适合范围不大的整数。
1.4 八种算法,五条路线
常见的八种排序可以按「是否比较关键字」分成两大类:比较排序(comparison sort)与非比较排序(non-comparison sort)。

比较排序内部又按核心动作分成四条路线:
- 插入类:直接插入排序、希尔排序;
- 选择类:直接选择排序、堆排序;
- 交换类:冒泡排序、快速排序;
- 归并类:归并排序。
计数排序不比较关键字,单独算一类。
分类不只是名词,它直接决定算法关注的问题:
- 插入类关心「元素离最终位置有多远」;
- 选择类关心「每一趟怎么把最值找出来」;
- 交换类关心「怎么通过交换把逆序对消掉」;
- 归并类关心「怎么把两个有序段合并」;
- 计数类根本不比较,直接把关键字的值当作数组下标。
比较排序有一个理论下界。 把比较过程看成一棵决策树: n n n 个元素有 n ! n! n! 种排列,树至少要有 n ! n! n! 个叶子,树高至少是 log 2 ( n ! ) = Θ ( n log n ) \log_2(n!)=\Theta(n\log n) log2(n!)=Θ(nlogn)。所以,任何只靠比较关键字的排序,最坏情况都不可能低于 Ω ( n log n ) \Omega(n\log n) Ω(nlogn)。
计数排序不受这个下界约束,因为它不比较,代价是要求关键字是范围不大的整数。
堆排序此前已经实现过,本篇只从选择排序的角度重新看它。
1.5 文件规划
三文件结构:
Sort.h:类型与函数声明;Sort.c:全部排序实现;test.c:正确性测试与性能对比。
先给出头文件:
c
#pragma once
// 常见排序算法接口声明
// 所有函数都按「升序」排序闭区间 [0, n-1]
void InsertSort(int* a, int n); // 直接插入排序
void ShellSort(int* a, int n); // 希尔排序
void SelectSort(int* a, int n); // 直接选择排序(一趟选双端)
void BubbleSort(int* a, int n); // 冒泡排序(带 exchange 优化)
void HeapSort(int* a, int n); // 堆排序(升序建大堆)
void QuickSort(int* a, int left, int right); // 快速排序:Hoare 划分
void QuickSortHole(int* a, int left, int right); // 快速排序:挖坑法
void QuickSortLomuto(int* a, int left, int right); // 快速排序:前后指针法
void QuickSortNonR(int* a, int left, int right); // 快速排序:非递归
void MergeSort(int* a, int n); // 归并排序
void CountSort(int* a, int n); // 计数排序(稳定版)
第 2~6 节按功能分组给出 Sort.c 的实现。函数顺序可以调整,只要保证「先定义、后使用」即可。
2. 插入排序:像整理手里的扑克牌
2.1 直接插入排序
基本思想:把待排序的记录逐个插入到已排好序的序列中,直到所有记录插完。
整理扑克牌就是这个过程:左手已经有一把按大小排好的牌,摸到一张新牌,就从右往左比较,找到位置插进去,原来位置右边的牌整体后移。

具体到数组:
- 认为
a[0..i]已经有序; - 取出
a[i + 1],存到临时变量tmp; - 从
a[i]开始往前比较,比tmp大的元素统一向后移动一位; - 把
tmp放到空出来的位置。
Sort.c 的文件头和公共函数如下,后续实现都放在同一个文件里:
c
#define _CRT_SECURE_NO_WARNINGS
#include "Sort.h"
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 交换两个整数
static void Swap(int* px, int* py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
void InsertSort(int* a, int n)
{
// 把 a[i + 1] 插入到已经有序的 a[0..i] 中
for (int i = 0; i < n - 1; ++i)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end]; // 比 tmp 大的元素整体后移
--end;
}
a[end + 1] = tmp; // 空出来的位置放入 tmp
}
}
这段代码有三个细节:
- 循环条件是
end >= 0,因为tmp可能比前面所有元素都小,要一路插到下标 0; - 内层用
a[end] > tmp,不是>=。遇到相等元素就停下,tmp放在它后面,相同关键字的先后次序不变,所以直接插入排序稳定; - 外层只走到
n - 2,因为最后一个元素在i = n - 2时已经被插入。
复杂度:
| 情况 | 比较与移动 | 时间复杂度 |
|---|---|---|
| 最好(已经有序) | 每个元素比较 1 次,不移动 | O ( n ) O(n) O(n) |
| 最坏(完全逆序) | 第 i i i 个元素移动 i i i 次 | O ( n 2 ) O(n^2) O(n2) |
| 平均 | 约为最坏的一半 | O ( n 2 ) O(n^2) O(n2) |
额外空间 O ( 1 ) O(1) O(1),稳定。
数据量小或数据已经基本有序时,插入排序常数小、访存连续,往往比快速排序更快;很多标准库的快排在递归到很小的区间时,会切回插入排序。
2.2 希尔排序
直接插入排序有一个明显短板:元素一次只能移动一位。如果最小值在数组末尾,它要一步一步挪到最前面,代价是 O ( n ) O(n) O(n) 次移动。
希尔排序(Shell sort)的思路是:先让元素以较大的步长移动,把离最终位置很远的元素快速送到位;等数组大致有序后,再用 gap = 1 的直接插入排序收尾。
具体做法:
- 选一个增量
gap,把所有下标相差gap的元素分成一组; - 对每一组做直接插入排序;
- 缩小
gap,重复上面的过程; - 当
gap = 1时,就是普通的直接插入排序。

图中的 10 个元素按 5 → 2 → 1 的折半取法演示;下面代码用的是 gap = gap / 3 + 1,增量序列不同,但「分组做插入排序 + 最后一趟 gap = 1」的思想完全一样。
同一组的元素下标相距 gap,并不相邻。比如 gap = 5 时,下标 0, 5 一组,1, 6 一组,2, 7 一组......每组内部用插入排序。
c
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
// 常见的增量取法:每次除以 3 再加 1,保证最后一定到 1
gap = gap / 3 + 1;
// 对所有相隔 gap 的元素做插入排序
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0 && a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = tmp;
}
}
}
这段代码和直接插入排序几乎一样,只把相邻元素换成了相隔 gap 的元素:取 a[end + gap],比较和移动跨 gap 步,循环边界相应改为 i < n - gap。
为什么最后一趟必须是 gap = 1? 只有 gap = 1 时所有元素才在同一组里,整个数组才真正有序;更大的 gap 都只是预排序。
复杂度为什么说不清? 希尔排序的时间取决于增量序列,gap 取法不同,比较和移动次数就不同,没有统一的公式。常见结论是:
- 平均情况大约在 O ( n 1.3 ) O(n^{1.3}) O(n1.3) 量级(经验值,随增量序列变化);
- 最坏情况可以到 O ( n 2 ) O(n^2) O(n2);
- 额外空间 O ( 1 ) O(1) O(1);
- 不稳定:相同元素可能被分到不同的组,跨组移动时相对次序会变。
3. 选择排序:每趟锁定一个最值
3.1 直接选择排序
基本思想:每一次从待排序区间里选出最小(或最大)的元素,放到区间的起始位置,直到全部排完。
下面的版本每趟同时选出最小值和最大值,分别放到区间两端,扫描趟数减少一半。
c
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
// 一趟同时选出最小值和最大值,分别放到两端
while (begin < end)
{
int mini = begin;
int maxi = begin;
for (int i = begin; i <= end; ++i)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
// 如果最大值就在 begin,第一次交换会把它换到 mini 位置
if (maxi == begin)
maxi = mini;
Swap(&a[begin], &a[mini]);
Swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
这里有一个容易遗漏的边界:如果最大值就在 begin,第一次交换会把它换走。
以 [9, 1, 2, 3] 为例:mini = 1、maxi = 0。交换 a[begin] 和 a[mini] 后,数组变成 [1, 9, 2, 3],最大值 9 已被换到下标 1;若不修正 maxi,第二次交换仍按旧下标 0 处理,9 就会留在中间。因此在第一次交换前要判断 maxi == begin,并令 maxi = mini。
复杂度:最好、平均、最坏都是 O ( n 2 ) O(n^2) O(n2),与数据是否有序无关;额外空间 O ( 1 ) O(1) O(1);不稳定 ,远距离交换会跨过相同元素。例如 [2, 5a, 5b] 中,5a 被换到末尾时会跨过 5b,两个 5 的次序就反了。
3.2 堆排序
直接选择排序的瓶颈很清楚:每一趟都要花 O ( n ) O(n) O(n) 把最值找出来。如果能用某种结构让最值始终位于顶端,取走之后只花 O ( log n ) O(\log n) O(logn) 恢复,选择排序就能提速。
这个结构就是堆(heap)。
堆是一棵完全二叉树,同时满足「父亲 ≥ 孩子」(大堆)或「父亲 ≤ 孩子」(小堆)。大堆的堆顶是全局最大值,小堆的堆顶是全局最小值。因为是完全二叉树,堆可以直接用数组存,父子下标关系不用额外保存。
堆排序(heap sort)分两步:
- 建堆 :把无序数组调整成大堆, O ( n ) O(n) O(n);
- 排序 :重复「堆顶与末尾交换 → 堆缩小 → 向下调整」,共 n − 1 n-1 n−1 次,每次 O ( log n ) O(\log n) O(logn)。
建堆为什么能做到 O ( n ) O(n) O(n)?因为大部分节点都在底层,下沉距离很短;把所有节点的下沉距离加起来是 O ( n ) O(n) O(n),并不是每个节点都要下沉 O ( log n ) O(\log n) O(logn) 层。
c
// 大堆的向下调整:parent 的左右子树必须已经是堆
static void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1; // 左孩子
while (child < n)
{
// 选出较大的孩子
if (child + 1 < n && a[child + 1] > a[child])
++child;
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
if (n <= 1)
return;
// 1. 从最后一个非叶子节点开始倒序下沉,建大堆
for (int i = (n - 2) / 2; i >= 0; --i)
AdjustDown(a, n, i);
// 2. 每次把堆顶(最大值)换到末尾,再对剩下的部分恢复大堆
for (int end = n - 1; end > 0; --end)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
}
}
为什么升序排序要建大堆?因为大堆的堆顶是最大值,把它换到数组末尾,末尾就放好了当前最大的元素;然后堆缩小一位,继续处理剩下的元素。如果建小堆,堆顶是最小值,换到末尾会得到降序。
升序建大堆,降序建小堆。
复杂度:
- 建堆 O ( n ) O(n) O(n);
- 排序阶段 n − 1 n-1 n−1 次下沉,每次 O ( log n ) O(\log n) O(logn),共 O ( n log n ) O(n\log n) O(nlogn);
- 最好、平均、最坏都是 O ( n log n ) O(n\log n) O(nlogn);
- 额外空间 O ( 1 ) O(1) O(1);
- 不稳定:堆顶和末尾的远距离交换会打乱相同元素的相对次序。
内存紧张、或不能接受快排最坏 O ( n 2 ) O(n^2) O(n2) 时,堆排序是稳妥的选择。堆的完整接口、建堆分析和 TopK 见《堆的概念及其实现》,这里只保留排序所需的下沉函数。
4. 交换排序:让大的往后走
4.1 冒泡排序
基本思想:比较相邻的两个元素,如果前一个比后一个大,就交换;每一趟结束,当前最大值会像气泡一样「冒」到末尾。
c
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
int exchange = 0; // 本趟是否发生过交换
for (int j = 0; j < n - 1 - i; ++j)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (exchange == 0) // 一整趟都没交换,说明已经有序
break;
}
}
exchange 标记是一个有效的优化:如果某一趟从头到尾没有发生交换,说明数组已经有序,可以立刻结束,不必再执行剩余的趟。因此:
- 最好情况(已经有序):一趟扫描, O ( n ) O(n) O(n);
- 最坏情况(完全逆序): O ( n 2 ) O(n^2) O(n2);
- 平均: O ( n 2 ) O(n^2) O(n2);
- 额外空间 O ( 1 ) O(1) O(1);
- 稳定 :只有
a[j] > a[j + 1]才交换,相等元素不会互换。
冒泡排序工程价值不高,但「提前结束」这个优化很典型。
4.2 快速排序
快速排序是 Hoare 在 1962 年提出的交换排序,也是实际工程中最常用的排序之一。它的核心是分治法(divide and conquer):
- 选一个基准值(pivot);
- 把数组划分成两部分:左边都不大于基准值,右边都不小于基准值;
- 基准值归位后,左右两部分分别递归。
框架很短:
text
// 递归框架,划分函数见下文
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int meet = Partition(a, left, right); // 基准值最终下标
QuickSort(a, left, meet - 1);
QuickSort(a, meet + 1, right);
}
如果每次划分都能把区间大致分成两半,递归深度是 O ( log n ) O(\log n) O(logn),每层划分的总代价是 O ( n ) O(n) O(n),总时间 O ( n log n ) O(n\log n) O(nlogn):
T ( n ) = 2 T ( n 2 ) + O ( n ) = O ( n log n ) T(n)=2T\left(\frac{n}{2}\right)+O(n)=O(n\log n) T(n)=2T(2n)+O(n)=O(nlogn)
但划分不保证均匀。基准值选得不好时,划分可能一边为空、另一边有 n − 1 n-1 n−1 个元素,递归退化成一条链,时间变成 O ( n 2 ) O(n^2) O(n2)。这是快排的核心风险,4.2.4 节会展开。
4.2.1 Hoare 版本
Hoare 版本用左右两个指针向中间移动:
- 基准值放在
left,记作key; - 右指针从右往左找小于等于
key的元素; - 左指针从左往右找大于等于
key的元素; - 两个指针都停下时交换,然后继续;
- 指针相遇后,把基准值和相遇位置的元素交换。

c
// Hoare 版本:返回基准值的最终下标
static int PartHoare(int* a, int left, int right)
{
int keyi = left;
int key = a[keyi];
++left;
while (left <= right)
{
// 右边找小(遇到等于 key 的也要停下)
while (left <= right && a[right] > key)
--right;
// 左边找大
while (left <= right && a[left] < key)
++left;
if (left <= right)
{
Swap(&a[left], &a[right]);
++left;
--right;
}
}
// 相遇位置的元素一定不大于 key,可以和基准值交换
Swap(&a[keyi], &a[right]);
return right;
}
这段代码有三个关键点。
第一,为什么右指针先走?
因为基准值在 left,最后要把它和相遇位置的元素交换。右指针先走,可以保证相遇位置 right 一定落在「不大于 key」的一侧;如果左指针先走,相遇点可能停在比 key 大的元素上,交换后基准值左边就会出现比它大的数,划分失效。
第二,为什么内层用严格的 > 和 <?
如果写成 a[right] >= key、a[left] <= key,遇到和 key 相等的元素就会直接跳过。数组里大量重复时,两个指针可能一直移动到边界,单趟划分几乎没有发生交换,区间无法缩小,递归就会退化。
用严格的 > / <,遇到相等元素就停下并交换,虽然多了一些交换,但能保证指针继续前进,也能让重复元素比较均匀地分到两边。
第三,为什么相遇位置一定不大于 key?
右指针的任务是找「小于等于 key」的元素:它跳过所有大于 key 的元素,停下来的位置要么是小于等于 key 的元素,要么是左指针已经扫过的区域。左指针扫过的元素都不大于 key。所以当 left > right 时,right 指向的元素一定不大于 key,和基准值交换是安全的。
4.2.2 挖坑法
挖坑法把「交换」换成了「填坑」,过程更直观:
- 把
left位置的基准值存到key,left变成一个「坑」; - 右指针从右往左找小于
key的元素,填到左边的坑里,右边原来的位置变成新坑; - 左指针从左往右找大于
key的元素,填到右边的坑里,左边原来的位置变成新坑; - 两个指针相遇时,把
key放进最后的坑。
c
// 挖坑法:返回基准值的最终下标
static int PartHole(int* a, int left, int right)
{
int hole = left;
int key = a[hole];
while (left < right)
{
// 右边找小,填到左边的坑里
while (left < right && a[right] >= key)
--right;
a[hole] = a[right];
hole = right;
// 左边找大,填到右边的坑里
while (left < right && a[left] <= key)
++left;
a[hole] = a[left];
hole = left;
}
a[hole] = key; // 最后把基准值放回坑中
return hole;
}
挖坑法不需要额外的 Swap 调用,过程也更容易用图表示。但它用 >= 和 <= 跳过相等元素,大量重复键时会把相等元素都堆到一边,递归可能退化。数据重复很多时,Hoare 版本或三路划分更合适。
4.2.3 前后指针法(Lomuto)
前后指针法用两个指针从左往右扫描:
prev维护「小于 key 的区域」的右边界;cur从left + 1开始向右扫描;- 遇到小于 key 的元素,就把它换到
prev后面,扩大「小于区」; - 扫描结束后,把基准值换到
prev的位置。
c
// 前后指针法(Lomuto):返回基准值的最终下标
static int PartLomuto(int* a, int left, int right)
{
int key = a[left];
int prev = left;
for (int cur = left + 1; cur <= right; ++cur)
{
if (a[cur] < key && ++prev != cur)
Swap(&a[prev], &a[cur]);
}
Swap(&a[left], &a[prev]);
return prev;
}
++prev != cur 是一个小优化:如果 prev 自增后正好等于 cur,说明这个元素本来就在「小于区」的末尾,不需要自己和自己交换。
Lomuto 代码最短,适合改写成非递归;代价是交换次数多,重复键多时仍会退化。重复键极多时递归深度可达 O ( n ) O(n) O(n),大数组上可能栈溢出,这类数据优先用 Hoare 或三路划分。
三种划分放在一起对比:
| 版本 | 核心动作 | 优点 | 注意点 |
|---|---|---|---|
| Hoare | 左右指针向中间移动,交换逆序对 | 经典、原地、对重复键相对友好 | 右指针先走;边界最容易写错 |
| 挖坑法 | 右找小填左坑,左找大填右坑 | 过程直观,不需要额外交换 | 重复键多时容易退化 |
| Lomuto | cur 扫描,prev 维护小于区 |
代码短,适合非递归 | 交换次数多,重复键多时退化 |
4.2.4 快排的退化与优化
快速排序最坏情况是 O ( n 2 ) O(n^2) O(n2),典型触发条件是:数据已经有序或完全逆序,而基准值又固定取端点。
以升序数组 [1, 2, 3, ..., n] 为例,取最左边的 1 做基准,划分后左边为空、右边有 n − 1 n-1 n−1 个元素;下一次还是取端点,继续单边递归。递归深度变成 n n n,时间变成 O ( n 2 ) O(n^2) O(n2)。
工程上有三种常见优化。
1. 三数取中
从 a[left]、a[mid]、a[right] 中选出大小居中的那个,换到 left 再做划分。这样有序数据不会再固定选到最小值或最大值:
c
// 三数取中:返回 a[left]、a[mid]、a[right] 中位于中间的那个下标
static int GetMid(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
if (a[left] < a[right])
return right;
return left;
}
else
{
if (a[left] < a[right])
return left;
if (a[mid] < a[right])
return right;
return mid;
}
}
最终的 QuickSort 把三数取中放在划分之前:
c
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 先把中间大小的元素换到 left,避免有序数据导致单边递归
int midi = GetMid(a, left, right);
Swap(&a[left], &a[midi]);
int meet = PartHoare(a, left, right);
QuickSort(a, left, meet - 1);
QuickSort(a, meet + 1, right);
}
另外两种划分的递归外壳相同,只替换划分函数;为了在测试中对比,一并列出:
c
void QuickSortHole(int* a, int left, int right)
{
if (left >= right)
return;
int midi = GetMid(a, left, right);
Swap(&a[left], &a[midi]);
int meet = PartHole(a, left, right);
QuickSortHole(a, left, meet - 1);
QuickSortHole(a, meet + 1, right);
}
void QuickSortLomuto(int* a, int left, int right)
{
if (left >= right)
return;
int midi = GetMid(a, left, right);
Swap(&a[left], &a[midi]);
int meet = PartLomuto(a, left, right);
QuickSortLomuto(a, left, meet - 1);
QuickSortLomuto(a, meet + 1, right);
}
2. 小区间插入
递归到很短的区间(比如长度小于 16)时,直接调用 InsertSort。小区间里插入排序的常数比快排小,而且能减少大量递归调用。
3. 三路划分
当数组里有大量重复键时,把区间分成三部分:小于 key、等于 key、大于 key。等于 key 的元素一次性归位,不再参与递归,能避免 Lomuto / 挖坑法在重复键上的退化。
三数取中能解决「有序数据」这个常见陷阱,但不能解决所有最坏情况。真正工业级的实现通常用内省排序(introsort) :快排递归深度超过 2 log n 2\log n 2logn 时,自动切换到堆排序,保证最坏 O ( n log n ) O(n\log n) O(nlogn)。
4.2.5 非递归版本
递归快排的递归深度平均是 O ( log n ) O(\log n) O(logn),但最坏是 O ( n ) O(n) O(n)。数据量很大、又遇到极端划分时,递归栈可能溢出。
非递归版本用一个栈保存待处理的区间,把「递归调用」换成「压栈 / 出栈」:
c
// 非递归版本:用数组模拟栈,保存待处理的区间
typedef struct
{
int left;
int right;
} Range;
void QuickSortNonR(int* a, int left, int right)
{
if (left >= right)
return;
int n = right - left + 1;
Range* stk = (Range*)malloc(sizeof(Range) * (size_t)n);
if (stk == NULL)
{
perror("malloc fail");
return;
}
int top = 0;
stk[top++] = (Range){left, right};
while (top > 0)
{
Range cur = stk[--top];
if (cur.left >= cur.right)
continue;
int midi = GetMid(a, cur.left, cur.right);
Swap(&a[cur.left], &a[midi]);
int meet = PartLomuto(a, cur.left, cur.right);
// 先压右区间,再压左区间,出栈时先处理左区间
if (meet + 1 < cur.right)
stk[top++] = (Range){meet + 1, cur.right};
if (cur.left < meet - 1)
stk[top++] = (Range){cur.left, meet - 1};
}
free(stk);
}
几个细节:
- 栈里存区间边界,不存元素,一个区间最多两个
int; - 同时存在的区间互不相交,最多 n n n 个,因此开 n n n 个
Range足够; - 先压右区间、再压左区间,出栈时先处理左区间,与递归顺序一致。
4.2.6 复杂度与稳定性
| 情况 | 时间复杂度 | 额外空间 |
|---|---|---|
| 最好 | O ( n log n ) O(n\log n) O(nlogn) | O ( log n ) O(\log n) O(logn) |
| 平均 | O ( n log n ) O(n\log n) O(nlogn) | O ( log n ) O(\log n) O(logn) |
| 最坏 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) |
快速排序不稳定,划分时的远距离交换会改变相同关键字的相对次序。
它的优势是平均常数小、原地、访存连续,因此是通用内存排序的默认选择;风险是最坏 O ( n 2 ) O(n^2) O(n2) 且不稳定,并非所有场景都适用。
一句话记住
快排 = 选基准 + 划分;平均 O ( n log n ) O(n\log n) O(nlogn),最坏 O ( n 2 ) O(n^2) O(n2);右指针先走、三数取中、小区间插入是三个关键细节。
5. 归并排序:先分解,再合并
归并排序(merge sort)是分治法的另一个典型应用:把数组分成两半,分别排好序,再把两个有序段合并成一个有序段。

分解很简单:一直分到每个区间只有一个元素,单个元素天然有序。真正的核心是合并:
- 两个有序段
[left, mid]和[mid + 1, right]; - 用两个指针分别指向两段的开头;
- 每次取较小的那个放进临时数组;
- 一段取完后,把另一段剩下的元素直接拷贝到临时数组;
- 最后把临时数组的内容拷回原数组。
c
static void MergeSortRec(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = left + (right - left) / 2;
MergeSortRec(a, left, mid, tmp);
MergeSortRec(a, mid + 1, right, tmp);
// 合并 [left, mid] 和 [mid + 1, right] 两段有序区间
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
// 相等时先取左边,保证稳定
if (a[begin1] <= a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
while (begin1 <= end1)
tmp[index++] = a[begin1++];
while (begin2 <= end2)
tmp[index++] = a[begin2++];
// 把合并结果拷回原数组
memcpy(a + left, tmp + left, sizeof(int) * (size_t)(right - left + 1));
}
void MergeSort(int* a, int n)
{
if (n <= 1)
return;
int* tmp = (int*)malloc(sizeof(int) * (size_t)n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
MergeSortRec(a, 0, n - 1, tmp);
free(tmp);
}
几个细节:
mid = left + (right - left) / 2,而不是(left + right) / 2,避免left + right溢出;- 合并时用
a[begin1] <= a[begin2],相等先取左边,保证稳定; - 一段取完后,另一段剩下的元素直接拷贝,不需要再比较;
- 临时数组
tmp只开一次,递归过程中反复使用,避免每层都malloc。
复杂度:
- 分解是 O ( log n ) O(\log n) O(logn) 层,每层合并总共 O ( n ) O(n) O(n),所以最好、平均、最坏都是 O ( n log n ) O(n\log n) O(nlogn);
- 额外空间 O ( n ) O(n) O(n),这是典型的「以空间换时间」;
- 稳定。
归并排序也是**外排序(external sorting)**的基础。内存装不下全部数据时,先把大文件分成若干块,每块在内存里排序后写回磁盘,最后做多路归并------思路和这里的二路归并完全一致,只是「两段」变成了「多段」。
归并排序对链表特别友好:链表不需要随机访问,合并两个有序链表只需要改指针,额外空间可以做到 O ( 1 ) O(1) O(1)(不计递归栈)。这也是为什么很多链表排序题的标准答案都是归并排序。
一句话记住
归并排序用 O ( n ) O(n) O(n) 的额外空间,换来稳定且最坏也是 O ( n log n ) O(n\log n) O(nlogn);主要用于稳定排序、链表排序和外排序。
6. 计数排序:不比较也能排
前面七种都是比较排序,最坏不可能低于 Ω ( n log n ) \Omega(n\log n) Ω(nlogn)。计数排序(counting sort)采用了另一种思路:不比较元素大小,而是统计每个值出现了多少次,再按值把元素放回去。
它的思路很直接:关键字是整数且值域不大时,可以直接用「值」作为数组下标。

步骤:
- 找出数组的最小值
min和最大值max,算出范围range = max - min + 1; - 开一个长度为
range的count数组,统计每个值出现的次数; - 对
count做前缀和,得到每个值在结果中的右边界(开区间); - 从后往前遍历原数组,把元素放到对应的位置;
- 把结果拷回原数组。
c
void CountSort(int* a, int n)
{
if (n <= 1)
return;
int min = a[0];
int max = a[0];
for (int i = 1; i < n; ++i)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
// 用 long long 算范围,防止 max - min + 1 在极端数据下溢出
long long range64 = (long long)max - min + 1;
if (range64 <= 0 || range64 > INT_MAX)
{
fprintf(stderr, "数据范围过大,计数排序不适用\n");
return;
}
int range = (int)range64;
int* count = (int*)calloc((size_t)range, sizeof(int));
int* output = (int*)malloc(sizeof(int) * (size_t)n);
if (count == NULL || output == NULL)
{
free(count);
free(output);
perror("malloc fail");
return;
}
// 1. 统计每个值出现的次数
for (int i = 0; i < n; ++i)
++count[a[i] - min];
// 2. 前缀和:count[i] 变成「小于等于 i + min 的元素个数」
for (int i = 1; i < range; ++i)
count[i] += count[i - 1];
// 3. 从后往前放置,保证相同元素的相对次序不变(稳定)
for (int i = n - 1; i >= 0; --i)
output[--count[a[i] - min]] = a[i];
memcpy(a, output, sizeof(int) * (size_t)n);
free(count);
free(output);
}
这里有几个关键点。
为什么要减去 min? 因为数组下标不能是负数。如果数据是 [100, 101, 109],直接分配 110 个元素会浪费前 100 个;减去 min = 100 后,只需要 10 个元素,100 映射到下标 0,109 映射到下标 9。
为什么前缀和之后要从后往前放? 前缀和 count[v] 表示「小于等于 v 的元素个数」,也就是值 v 这一段在结果里的右边界(开区间)。所以最后一个 v 应该放在 count[v] - 1,每放一个就把 count 减一。从后往前遍历原数组,相同值的元素会按原顺序依次落到结果里,相对次序不变,计数排序是稳定的。
只统计次数再按值回写时,空间可降到 O ( range ) O(\text{range}) O(range),但相同关键字的次序无法保持,因此不稳定;下文表格按稳定版计算空间。
复杂度:
- 时间 O ( n + range ) O(n + \text{range}) O(n+range):统计 O ( n ) O(n) O(n),前缀和 O ( range ) O(\text{range}) O(range),放置 O ( n ) O(n) O(n);
- 空间 O ( n + range ) O(n + \text{range}) O(n+range):
count数组加输出数组; - 稳定(标准写法)。
计数排序的边界也很清楚:
- 只适合整数,或者能一一映射到整数的关键字;
range不能太大。如果range远大于n,开数组的空间和时间都不划算;- 数据范围集中、重复元素多时,它非常快,可以突破比较排序的 Ω ( n log n ) \Omega(n\log n) Ω(nlogn) 下界。
一句话记住
计数排序适合「范围小、重复多」的整数;
range远大于 n n n 时,额外空间会明显增加,不如用比较排序。
7. 放在一张表里:复杂度、稳定性与选择
7.1 复杂度与稳定性总表
| 排序方法 | 平均情况 | 最好情况 | 最坏情况 | 额外空间 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
| 希尔排序 | O ( n 1.3 ) O(n^{1.3}) O(n1.3)(经验) | O ( n log n ) O(n\log n) O(nlogn) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 不稳定 |
| 直接选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 不稳定 |
| 堆排序 | O ( n log n ) O(n\log n) O(nlogn) | O ( n log n ) O(n\log n) O(nlogn) | O ( n log n ) O(n\log n) O(nlogn) | O ( 1 ) O(1) O(1) | 不稳定 |
| 冒泡排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
| 快速排序 | O ( n log n ) O(n\log n) O(nlogn) | O ( n log n ) O(n\log n) O(nlogn) | O ( n 2 ) O(n^2) O(n2) | O ( log n ) ∼ O ( n ) O(\log n)\sim O(n) O(logn)∼O(n) | 不稳定 |
| 归并排序 | O ( n log n ) O(n\log n) O(nlogn) | O ( n log n ) O(n\log n) O(nlogn) | O ( n log n ) O(n\log n) O(nlogn) | O ( n ) O(n) O(n) | 稳定 |
| 计数排序 | O ( n + range ) O(n+\text{range}) O(n+range) | O ( n + range ) O(n+\text{range}) O(n+range) | O ( n + range ) O(n+\text{range}) O(n+range) | O ( n + range ) O(n+\text{range}) O(n+range) | 稳定 |
关于这张表,有两点要说明:
- 希尔排序的复杂度取决于增量序列,表中给的是常见经验值,不是严格上界;
- 快速排序的空间是递归栈深度,平均 O ( log n ) O(\log n) O(logn),最坏 O ( n ) O(n) O(n)。
7.2 工程上如何选择
没有最好的排序,只有最适合当前数据的排序。可以按下面的顺序判断:
- 数据量很小吗? 小于几十个元素时,直接插入排序往往最快,常数小、代码简单。
- 数据已经基本有序吗? 直接插入排序接近 O ( n ) O(n) O(n);快排如果基准选不好,反而可能退化。
- 需要稳定吗? 需要就选归并排序;数据量小的情况下,插入排序也稳定。
- 内存紧张吗? 内存紧张选堆排序, O ( 1 ) O(1) O(1) 额外空间,最坏也有 O ( n log n ) O(n\log n) O(nlogn) 保证。
- 关键字是范围不大的整数吗? 是就考虑计数排序,能到线性时间。
- 数据在链表上吗? 归并排序最自然,合并只需改指针。
- 数据大到内存装不下吗? 用外排序:分块排序 + 多路归并。
常见的工程组合是:
- 小数组 + 插入排序:减少递归和函数调用开销;
- 通用内存排序 + 快速排序:平均性能最好,配合三数取中、小区间插入和内省排序,为最坏情况提供保证;
- 稳定排序 + 归并排序 :接受 O ( n ) O(n) O(n) 额外空间;
- 最坏情况有保证 + 堆排序:内存敏感、实时性要求高的场景;
- 范围小的整数 + 计数排序:统计、桶排序的基础。
C 标准库的 qsort 不保证具体用哪种算法,不同平台可能是快排、归并或内省排序;它也不保证稳定。需要稳定排序时,不能直接依赖 qsort。与其记住某个库函数用了什么算法,不如理解这些权衡。
7.3 性能实测
测试代码做两件事:
- 正确性:11 个排序版本(含快排的 4 种写法)× 9 组用例,覆盖空数组、单元素、两元素、全相等、已有序、完全逆序、重复元素、负数;
- 性能 :10 万个随机整数,每个算法在同样的一份数据副本上排序,用
clock()计时。
测试环境:Windows 11,gcc 16.1.0,编译选项 -O2。本机一次运行的结果如下(数值因机器而异,只看相对快慢):
| 排序方法 | 100000 个随机整数耗时 |
|---|---|
| 直接插入排序 | 530.0 ms |
| 希尔排序 | 6.0 ms |
| 直接选择排序 | 4884.0 ms |
| 冒泡排序 | 11791.0 ms |
| 堆排序 | 6.0 ms |
| 快速排序(Hoare) | 5.0 ms |
| 快速排序(挖坑) | 5.0 ms |
| 快速排序(前后指针) | 4.0 ms |
| 快速排序(非递归) | 4.0 ms |
| 归并排序 | 6.0 ms |
| 计数排序 | 0.0 ms |
计数排序的
0.0 ms表示低于clock()的 1 ms 分辨率,不代表完全不花时间。
这张表能看出三件事:
- O ( n 2 ) O(n^2) O(n2) 的三个算法(直接插入、直接选择、冒泡)和 O ( n log n ) O(n\log n) O(nlogn) 级算法差了几十倍甚至上千倍;数据量越大,差距越明显;
- 同样是 O ( n log n ) O(n\log n) O(nlogn),快速排序的常数通常比归并、堆排序小,所以平均最快;
- 计数排序在这个测试里最快,因为数据范围只有
0..32767,range不大;如果范围扩大到几十亿,它就不能用了。
注意
计时受机器、编译器优化和数据分布影响很大。不同机器的绝对数字不能直接比较,只看同一台机器上的相对关系。
7.4 测试代码
c
#define _CRT_SECURE_NO_WARNINGS
#include "Sort.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef void (*SortFn)(int*, int);
// 快排的区间参数包装成统一的 (int*, int) 接口
static void QuickHoare(int* a, int n) { QuickSort(a, 0, n - 1); }
static void QuickHole(int* a, int n) { QuickSortHole(a, 0, n - 1); }
static void QuickLomuto(int* a, int n) { QuickSortLomuto(a, 0, n - 1); }
static void QuickNonR(int* a, int n) { QuickSortNonR(a, 0, n - 1); }
typedef struct
{
const char* name;
SortFn fn;
} SortItem;
static const SortItem g_sorts[] = {
{"直接插入排序", InsertSort},
{"希尔排序", ShellSort},
{"直接选择排序", SelectSort},
{"冒泡排序", BubbleSort},
{"堆排序", HeapSort},
{"快速排序(Hoare)", QuickHoare},
{"快速排序(挖坑)", QuickHole},
{"快速排序(前后指针)", QuickLomuto},
{"快速排序(非递归)", QuickNonR},
{"归并排序", MergeSort},
{"计数排序", CountSort},
};
static int CmpInt(const void* p1, const void* p2)
{
int x = *(const int*)p1;
int y = *(const int*)p2;
return (x > y) - (x < y);
}
static void TestCorrect(void)
{
static const struct
{
const char* name;
int a[16];
int n;
} cases[] = {
{"随机数据", {5, 3, 9, 6, 2, 4, 7, 1, 8}, 9},
{"单个元素", {7}, 1},
{"两个元素逆序", {2, 1}, 2},
{"全部相等", {5, 5, 5, 5, 5}, 5},
{"已经有序", {1, 2, 3, 4, 5, 6, 7, 8, 9}, 9},
{"完全逆序", {9, 8, 7, 6, 5, 4, 3, 2, 1}, 9},
{"含重复元素", {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}, 11},
{"含负数", {-3, 5, 0, -1, 5, -10, 8}, 7},
{"空数组", {0}, 0},
};
int sortCount = (int)(sizeof(g_sorts) / sizeof(g_sorts[0]));
int caseCount = (int)(sizeof(cases) / sizeof(cases[0]));
printf("========== 正确性测试 ==========\n");
for (int i = 0; i < sortCount; ++i)
{
for (int j = 0; j < caseCount; ++j)
{
int a[16];
int expect[16];
int n = cases[j].n;
memcpy(a, cases[j].a, sizeof(int) * (size_t)n);
memcpy(expect, a, sizeof(int) * (size_t)n);
qsort(expect, (size_t)n, sizeof(int), CmpInt);
g_sorts[i].fn(a, n);
if (memcmp(a, expect, sizeof(int) * (size_t)n) != 0)
{
printf("%s 在「%s」用例失败\n", g_sorts[i].name, cases[j].name);
exit(1);
}
}
printf("%-22s 通过(%d 组用例)\n", g_sorts[i].name, caseCount);
}
}
static void TestPerf(void)
{
const int N = 100000;
int* base = (int*)malloc(sizeof(int) * (size_t)N);
int* a = (int*)malloc(sizeof(int) * (size_t)N);
if (base == NULL || a == NULL)
{
perror("malloc fail");
free(base);
free(a);
return;
}
srand(1);
for (int i = 0; i < N; ++i)
base[i] = rand() % 32768;
printf("\n========== 性能对比 ==========\n");
printf("数据规模:%d,随机整数范围 0..32767\n", N);
for (int i = 0; i < (int)(sizeof(g_sorts) / sizeof(g_sorts[0])); ++i)
{
memcpy(a, base, sizeof(int) * (size_t)N);
clock_t begin = clock();
g_sorts[i].fn(a, N);
clock_t end = clock();
// 计时后检查结果是否有序
for (int j = 0; j + 1 < N; ++j)
{
if (a[j] > a[j + 1])
{
printf("%s 结果不是升序\n", g_sorts[i].name);
exit(1);
}
}
double ms = 1000.0 * (double)(end - begin) / (double)CLOCKS_PER_SEC;
printf("%-22s %8.1f ms\n", g_sorts[i].name, ms);
}
free(base);
free(a);
}
int main(void)
{
TestCorrect();
TestPerf();
return 0;
}
把前面给出的代码分别保存为三个文件,放在同一个目录:
Sort.h:1.5 节的接口声明;Sort.c:第 2~6 节的排序实现;test.c:7.4 节的测试代码。
然后编译并运行:
bash
gcc -std=c17 -O2 -Wall -Wextra Sort.c test.c -o sort_test
./sort_test
Windows 的 cmd 或 PowerShell 中,最后一行改为 .\sort_test.exe;Git Bash 可以直接使用 ./sort_test。
编译运行后,输出如下(数值因机器而异):
text
========== 正确性测试 ==========
直接插入排序 通过(9 组用例)
希尔排序 通过(9 组用例)
直接选择排序 通过(9 组用例)
冒泡排序 通过(9 组用例)
堆排序 通过(9 组用例)
快速排序(Hoare) 通过(9 组用例)
快速排序(挖坑) 通过(9 组用例)
快速排序(前后指针) 通过(9 组用例)
快速排序(非递归) 通过(9 组用例)
归并排序 通过(9 组用例)
计数排序 通过(9 组用例)
========== 性能对比 ==========
数据规模:100000,随机整数范围 0..32767
直接插入排序 530.0 ms
希尔排序 6.0 ms
直接选择排序 4884.0 ms
冒泡排序 11791.0 ms
堆排序 6.0 ms
快速排序(Hoare) 5.0 ms
快速排序(挖坑) 5.0 ms
快速排序(前后指针) 4.0 ms
快速排序(非递归) 4.0 ms
归并排序 6.0 ms
计数排序 0.0 ms
输出分两部分:正确性测试中,每个版本用 9 组用例与 qsort 的结果比较,全部一致才打印「通过(9 组用例)」;共 8 种算法,其中快速排序列了 4 种写法,所以有 11 行记录。性能数字随机器变化,只看相对快慢。
8. 易错点清单
- 快排递归边界 :划分函数返回的是基准值的最终下标
meet,递归时左右区间分别是[left, meet - 1]和[meet + 1, right]。把meet再放进任何一边,都会死循环。 - Hoare 的右指针必须先走 :基准值在
left时,只有右指针先走,才能保证相遇位置不大于 key;顺序写反,划分可能失效。 - 选择排序的
maxi == begin:如果最大值在begin,第一次交换会把它换到mini,必须提前修正maxi,否则最大值会被错误地换到中间。 - 希尔排序的 gap 最后必须为 1:gap > 1 都只是预排序,只有 gap = 1 才能保证整个数组有序。
- 归并排序的
<=:合并时相等元素先取左边,才能保证稳定;写成<会破坏相同关键字的相对次序。最后要把tmp拷回原数组。 - 计数排序的 range :
range = max - min + 1,不是max + 1;有负数时要减去min做偏移;n = 0要提前返回,否则a[0]越界。 - 堆排序的方向 :升序建大堆,降序建小堆;
AdjustDown选孩子时要判断child + 1 < n,防止越界。 - 快排的重复键 :Hoare 用严格的
>/<,遇到相等也停下交换;挖坑法和 Lomuto 用>=/<=,重复键多时会退化,必要时改用三路划分。 - 空数组和单元素 :每个排序函数都要能处理
n = 0和n = 1;快排的区间参数是闭区间,left >= right直接返回。 malloc检查与释放 :归并排序、计数排序、非递归快排都动态申请了内存,失败要perror并返回,成功要free;计数排序的两个数组在失败路径上也要一起释放。
9. 小结
- 排序是让后续操作变便宜的前置步骤 :有序才能二分,有序才能线性归并,有序才能快速去重和求第 k k k 小。评价排序看时间、空间、稳定性和适用条件。
- 插入类 :直接插入排序在数据接近有序时接近 O ( n ) O(n) O(n);希尔排序用「分组 + 缩小增量」把远距离元素快速送到位,最后一趟 gap 必须为 1。
- 选择类 :直接选择排序比较次数固定为 O ( n 2 ) O(n^2) O(n2),胜在交换少;堆排序用堆把「找最值」降到 O ( log n ) O(\log n) O(logn),最坏也有 O ( n log n ) O(n\log n) O(nlogn),且只需 O ( 1 ) O(1) O(1) 额外空间。
- 交换类 :冒泡排序适合理解「提前结束」;快速排序平均最快、原地,但最坏 O ( n 2 ) O(n^2) O(n2)、不稳定,需要三数取中、小区间插入和内省排序作为最坏情况保证。
- 归并类与非比较类 :归并排序稳定、最坏 O ( n log n ) O(n\log n) O(nlogn),代价是 O ( n ) O(n) O(n) 空间;计数排序能到 O ( n + range ) O(n+\text{range}) O(n+range),但只适合范围小的整数。
- 选择口诀:小数组用插入排序,通用内存排序用快速排序,需要稳定用归并排序,内存紧张用堆排序,范围小的整数用计数排序。
- 没有一种排序适合所有数据。先看规模与分布,再看稳定性和空间限制,最后决定算法。