【数据结构】排序总结与规模实测

🔥 H.莓飛: 个人主页
✨ 心不畏死,踏屍前行。
📦 代码仓库:cocatrice_csdn_code
📖 博主简介:

概览
- 九种排序按比不比大小分成两类
- 时间、空间、稳定性总表
- 一万、十万、一百万三档的耗时
- 随机、接近有序、逆序、全等值四种形态
- 带编号的记录查出的稳定性反例
- 递归栈、临时数组与计数表各占多少
- qsort 与六种手写排序的同场对照
核心知识
一、分类
九种排序分两大类,一类靠比较两个元素的大小来决定先后,插入、希尔、选择、冒泡、堆、快速、归并都属于这一类,它们的下界卡在 N log N 上;另一类根本不做比较,靠把元素分配到一个个桶里再按顺序收回来,计数与基数属于这一类,它们的快慢不取决于比较次数,而取决于值域有多宽。

图 1 九种排序先分成两类
| 序号 | 名字 | 一句话概括 | 在哪个部分讲过 |
|---|---|---|---|
| 1 | 插入 | 把元素插到前面已经排好的段里 | 简单排序那篇 |
| 2 | 希尔 | 先按间隔分组预排,再逐步缩小间隔 | 简单排序那篇 |
| 3 | 选择 | 每一趟只挑一个最小值换到前面 | 简单排序那篇 |
| 4 | 堆 | 建大堆,每次把堆顶换到末尾 | 堆那一篇 |
| 5 | 冒泡 | 相邻两两比较,带提前退出 | 简单排序那篇 |
| 6 | 快速 | 选基准分组,左右两边递归 | 快排专题 |
| 7 | 归并 | 两半排好再合并 | 归并与外排序 |
| 8 | 计数 | 数每个值出现几次,按表铺回去 | 简单排序那篇 |
| 9 | 基数 | 从低位到高位,逐位分配收集 | 排序算法题一 |
这九种里只有归并和基数需要额外的 O(N) 空间,剩下的要么原地交换,要么只多一个几十层的递归栈。代价换来的东西不一样,归并换来稳定,基数换来线性的时间。
c
/* sorts.c 里把九种排序的名字与编号对上,实验程序按编号调用 */
int SortNum(void)
{
return 9;
}
const char* SortName(int k)
{
static const char* names[] = {
"插入", "希尔", "选择", "堆", "冒泡",
"快速", "归并", "计数", "基数"
};
return (k >= 0 && k < SortNum()) ? names[k] : "?";
}
c
/* sorts.h:九种排序统一成同一个签名,实验程序才能一张表跑完 */
void InsertSort(int* a, int n);
void ShellSort(int* a, int n);
void SelectSort(int* a, int n);
void HeapSort(int* a, int n);
void BubbleSort(int* a, int n);
void QuickSort(int* a, int n);
void MergeSort(int* a, int n);
int CountSort(int* a, int n); /* 值域过宽返回 -1,不动原数组 */
int RadixSort(int* a, int n); /* 出现负数返回 -1 */
二、时间、空间、稳定性
下面这张表是后面所有实测的骨架,平均与最坏两列分开写,是因为有好几种排序的最坏情况比平均差一个量级。
| 排序 | 最好 | 平均 | 最坏 | 额外空间 | 稳定 | 是否原地 |
|---|---|---|---|---|---|---|
| 插入 | O(N) | O(N^2) | O(N^2) | O(1) | 是 | 是 |
| 希尔 | O(N log N) | 约 O(N^1.25) | O(N^2) | O(1) | 否 | 是 |
| 选择 | O(N^2) | O(N^2) | O(N^2) | O(1) | 否 | 是 |
| 堆 | O(N log N) | O(N log N) | O(N log N) | O(1) | 否 | 是 |
| 冒泡 | O(N) | O(N^2) | O(N^2) | O(1) | 是 | 是 |
| 快速 | O(N log N) | O(N log N) | O(N^2) | O(log N) 栈 | 否 | 是 |
| 归并 | O(N log N) | O(N log N) | O(N log N) | O(N) | 是 | 否 |
| 计数 | O(N + 范围) | O(N + 范围) | O(N + 范围) | O(范围) | 是 | 否 |
| 基数 | O(d(N + 基数)) | 同左 | 同左 | O(N + 基数) | 是 | 否 |
这张表还有四句要补,前两句在写希尔排序与快速排序时说过,后两句关系到怎么读它。希尔排序的复杂度跟增量序列绑在一起,用 Knuth 的 3 倍加一序列时大量实测的结果落在 O(N^1.25) 附近,理论上的最坏情况仍是 N 平方;快速排序的最坏情况出现在每次基准都落到区间最边上,那时递归深度退化成 N,时间退化成 N 平方,加上三数取中与内省之后这个最坏基本被挡住;计数排序的 N 加范围要一起看,值域窄的时候它就是线性,值域比元素个数还大很多的时候反而不如比较排序;基数排序的 d 是位宽分摊的趟数,基数 256 时 32 位整数走四趟,每趟 N 加 256。
稳定性在这里指同一关键字的两条记录排完之后先后不变。表里标否的五种不是随便标的,第五章拿带编号的记录逐个检查,其中堆排序只需要两个元素就能翻出反例。
三、实测
实测的写法很朴素:同一份源码编出一个程序,把规模与数据形态排成矩阵,每种组合跑三遍取最快的值。计时用 clock_gettime(CLOCK_MONOTONIC),毫秒为单位;三遍取最快是为了压住机器上的噪声,取中位数也可以,两种做法的差别在百分之一以内。
先看随机数据上的六种快排序,插入、选择、冒泡三种平方级的写法一万条就要几十到一百多毫秒,规模和耗时都上不去,只在小规模那一档出现。
| 排序 | 一万条 | 十万条 | 一百万条 |
|---|---|---|---|
| 三路快排 | 0.549 | 7.151 | 83.858 |
| 归并 | 0.696 | 8.667 | 103.092 |
| 堆 | 0.720 | 9.450 | 125.621 |
| 希尔 | 0.729 | 9.918 | 131.251 |
| 基数 | 0.099 | 0.989 | 14.246 |
| 计数 | 0.800 | 1.491 | 14.826 |

图 2 规模翻十倍,三路快排的耗时也翻十倍
表里最显眼的是两列:三路快排从 0.549 到 83.858,规模涨一百倍,耗时涨一百五十倍;基数排序从 0.099 到 14.246,规模涨一百倍,耗时涨一百四十三倍。前者的比例比一百大,是因为 N log N 里的 log 部分在涨,一百万条的 log 比一万条的 log 多了约百分之五十;后者的比例接近一百,是因为基数排序每趟都是线性扫,四趟的趟数不随规模变。
平方级的三兄弟在一万条上就已经很吃力。选择排序 118 毫秒,冒泡 53 毫秒,插入 15 毫秒,三者之间的差别来自内层循环里做的事不一样:选择排序每一趟都要扫完剩余全部找最小值,比较次数恒为 N 乘 N 减一再除以二,一次都不能省;冒泡在一万条随机数据上平均只跑到一半就提前退出了;插入排序的内层循环一旦遇到比它小的元素就停,随机数据下平均只走一半。到了十万条,选择排序要 11.8 秒,一百万条要十九分钟,所以程序里干脆让这三种只跑一万条。
c
/* 计时的一段,同一批数据跑三遍,留最快的一遍 */
for (int t = 0; t < 3; t++) {
memcpy(buf, src, (size_t)n * sizeof(int));
double begin = NowMs();
g_fn[k](buf, n);
double ms = NowMs() - begin;
if (ms < best) best = ms;
}
四、随机、接近有序、逆序、全等值
只跑随机数据看不出排序的脾气,还得换四种形态再跑一遍。数据一共造了四种形态:完全随机、接近有序(升序之后随机换掉百分之一的元素)、完全逆序、全等值(所有元素都是 7)。一万条这一档九种全部跑,结果如下,单位毫秒。
| 排序 | 随机 | 接近有序 | 逆序 | 全等值 |
|---|---|---|---|---|
| 插入 | 15.064 | 0.418 | 29.861 | 0.009 |
| 希尔 | 0.729 | 0.463 | 0.098 | 0.067 |
| 选择 | 118.402 | 118.427 | 118.373 | 118.343 |
| 堆 | 0.720 | 0.495 | 0.504 | 0.031 |
| 冒泡 | 53.225 | 38.545 | 76.753 | 0.007 |
| 快速 | 0.549 | 0.451 | 0.666 | 0.006 |
| 归并 | 0.696 | 0.205 | 0.187 | 0.175 |
| 计数 | 0.800 | 0.028 | 0.028 | 0.049 |
| 基数 | 0.099 | 0.112 | 0.116 | 0.163 |

图 3 一把插入排序,四种数据形态差三千倍
选择排序四个数字几乎一模一样,118 上下一路走到底,因为它的比较次数只跟长度有关,跟数据怎么排没有关系,这是它最典型的特征。插入排序从 0.418 到 29.861,差了七十倍,原因是内层循环的退出条件:接近有序时每个元素只往前挪一两步,逆序时每个元素要一路挪到底。冒泡排序的差距没这么大,因为逆序时它每一趟都要把最大的元素换到最后,跑的趟数固定是 N 减一趟。
快速排序在全等值上只花了 0.006 毫秒,这是三路划分的功劳。一趟划分把全等的元素全归到中间段,左右两段都是空的,递归直接结束,等于只扫一遍。换成一趟一元素的老版本写法,全等值会退化成 N 平方,一百万条要跑好几分钟。
计数排序在接近有序与逆序上都是 0.028 毫秒,在全等值上反而升到 0.049,看起来反常。原因是接近有序与逆序这两组的数据是 1 到 N 的连续整数,值域正好是 N,桶一次分配到位;全等值那组的桶只有一个格子,但申请与回收的开销摊在一万条上比例被放大了,绝对时间本来就只剩几十微秒,抖动看起来就明显。
一百万条这一档只跑六种快排序,四种形态的结果如下。
| 排序 | 随机 | 接近有序 | 逆序 | 全等值 |
|---|---|---|---|---|
| 快速 | 83.858 | 55.644 | 101.766 | 0.602 |
| 归并 | 103.092 | 27.797 | 25.184 | 24.033 |
| 堆 | 125.621 | 65.843 | 65.947 | 3.135 |
| 希尔 | 131.251 | 92.873 | 13.799 | 8.413 |
| 基数 | 14.246 | 19.295 | 19.584 | 19.789 |
| 计数 | 14.826 | 5.969 | 5.502 | 5.133 |
这张表里最该看的一行是希尔排序,它在一万条随机数据上只要 0.729 毫秒。它在随机数据上 131 毫秒,是六种里最慢的;在逆序数据上只要 13.799 毫秒,比快速排序快七倍。原因是 Knuth 序列第一趟的间隔就有一百多万的四分之一,逆序数据在这种大间隔下每一趟都能把元素挪很长一段,二十来万次比较就能把整体拉得差不多有序,后面的小间隔只需要收尾。
快速排序在逆序上从 83.858 涨到 101.766,涨了两成,这是三数取中在起作用:逆序数据取左中右三个位置,中间那个值正好落在区间的中点附近,划分比例不会太偏,所以没有退化成平方。要是换成取最左边做基准,逆序数据会退化成最深的那条链,一百万条要跑到分钟级。
c
/* 稳定性判定:同关键字的记录,编号必须还递增 */
static int Stable(const Rec* r, int n)
{
for (int i = 1; i < n; i++)
if (r[i].key == r[i - 1].key && r[i].id < r[i - 1].id)
return 0;
return 1;
}
五、查稳定性
查稳定性最直接的办法是把元素换成带编号的记录,关键字相同而编号不同,排完之后检查同关键字的编号是否还递增。测试程序里定义了 typedef struct Rec { int key; char id; } Rec;,九种排序各写一份针对 Rec 的实现,比较处只看 key。
第一组是 5a 3b 5c 1d 5e 3f 2g,三个 5、两个 3,正好能看出等值元素有没有被拆开。
| 排序 | 排完之后 | 判定 |
|---|---|---|
| 插入 | 1d 2g 3b 3f 5a 5c 5e | 稳定 |
| 希尔 | 1d 2g 3b 3f 5a 5e 5c | 不稳定 |
| 选择 | 1d 2g 3f 3b 5e 5c 5a | 不稳定 |
| 堆 | 1d 2g 3f 3b 5c 5e 5a | 不稳定 |
| 冒泡 | 1d 2g 3b 3f 5a 5c 5e | 稳定 |
| 快速 | 1d 2g 3b 3f 5e 5a 5c | 不稳定 |
| 归并 | 1d 2g 3b 3f 5a 5c 5e | 稳定 |
| 计数 | 1d 2g 3b 3f 5a 5c 5e | 稳定 |
| 基数 | 1d 2g 3b 3f 5a 5c 5e | 稳定 |
这一组能看出方向:稳定的几种把 5a、5c、5e 的先后完整保住,不稳定的几种把 5e 挪到了 5a 前面,或者把 5c 甩到了最后。不稳的原因各不相同,希尔排序在间隔为 3 的那一趟里把 5e 与别的元素换了位置,选择排序在第一趟把最小值 1d 与 5a 交换,一交换就把三个 5 的顺序打乱。
一组用例的说明力有限,所以又补了穷举:长度从 2 到 9,每个位置的关键字取 0、1、2 三个值,把全部组合跑一遍,一共 29520 组,对每一种排序记录它最短的反例。
| 排序 | 穷举结论 | 最短反例 |
|---|---|---|
| 插入 | 29520 组全部保序 | 无 |
| 冒泡 | 29520 组全部保序 | 无 |
| 归并 | 29520 组全部保序 | 无 |
| 计数 | 29520 组全部保序 | 无 |
| 基数 | 29520 组全部保序 | 无 |
| 希尔 | 到长度 6 才出现反例 | 1a 0b 0c 0d 0e 0f |
| 选择 | 长度 3 就有反例 | 1a 1b 0c |
| 堆 | 长度 2 就有反例 | 0a 0b |
| 快速 | 长度 3 就有反例 | 1a 0b 0c |
堆排序的最短反例只有两个元素,这个结果值得单独说一句。两个元素、两个相同的关键字,排完之后却反了,原因在堆排序的收尾动作:建好堆之后它无条件把堆顶与末尾交换,再缩小堆的范围。两个元素相等时这一步照样执行,0a 与 0b 就换了位置。只要堆排序还是这么写的,它在两个元素上就不稳定,跟实现细节无关,是算法本身的动作决定的。
希尔排序的反例要六个元素才出现,说明它的不稳定性藏得比较深。它在小规模上常常表现得像稳定排序,很多人在几个元素的用例上验不出问题,换到真实数据里才发现等值元素的顺序乱了。
快速的反例是 1a 0b 0c,三个元素里两个关键字不同,一趟三路划分把 0b 换到最前面时把 1a 挤到了后面。选择排序的反例同长度,1a 1b 0c 里两个 1 在最小值交换时反了序。
六、额外空间
九种排序里只有归并排序需要一块与元素个数同阶的额外空间,其他几种的额外开销藏在三个地方:递归调用栈、临时变量、桶数组。

图 4 空间花在四处,四种来路
| 排序 | 额外空间 | 具体花在哪 |
|---|---|---|
| 插入、选择、冒泡 | O(1) | 一个临时变量,交换或挪位时中转 |
| 堆 | O(1) | 同上的临时变量,堆本身用的是原数组 |
| 希尔 | O(1) | 临时变量加一个间隔值 |
| 快速 | O(log N) | 递归调用的栈帧,每层存左右端点与基准下标 |
| 归并 | O(N) | 一块与元素个数等长的临时数组,合并时中转 |
| 计数 | O(范围) | 计数表,值域有多宽就要多少格子 |
| 基数 | O(N + 基数) | 一块与原数组等长的中转数组,加一个 256 格的计数表 |
具体到量级更容易理解,把上面那张表换算成字节数来看。一百万条 32 位整数,归并排序的临时数组是四百万字节,也就是三点八兆,这是它跑一趟最实在的成本;快排的递归栈在实测里最深 32 层,每层几十个字节,加起来不到两兆的千分之一;计数排序的计数表按值域算,本篇的随机数据值域是一百万,四兆,如果值域是十亿,这张表就是四吉,所以代码里加了一道门槛,超过一千六百万个格子就直接返回失败,不动原数组。
外排序那篇里用的一千万条整数文件就是三十九兆,那时候归并排序的临时数组已经装不下了,只能改成先切块排好再归并文件。空间与规模的关系就是这样一条线:能一次性放进内存就用内存版,放不下就把归并搬到磁盘上做。
七、qsort 对比
标准库的 qsort 是所有 C 程序员的默认选择,它到底站在哪一档,同一批一百万条数据上比一次最直观。程序里并排跑了六种:qsort、堆排序、三路快排、归并、基数、计数。
| 做法 | 随机 | 接近有序 | 逆序 |
|---|---|---|---|
| qsort | 126.432 | 36.733 | 30.584 |
| 堆排序 | 122.383 | 57.289 | 59.437 |
| 三路快排 | 86.990 | 51.912 | 95.589 |
| 归并 | 105.640 | 27.360 | 22.322 |
| 基数排序 | 13.968 | 19.179 | 19.126 |
| 计数排序 | 14.423 | 5.885 | 5.002 |

图 5 库函数站在中间那一档,不是第一
随机数据上三路快排 86.990 毫秒最快,qsort 126.432 毫秒排在堆排序那一档;接近有序与逆序数据上归并最快的 27.360 与 22.322 毫秒,qsort 的 36.733 与 30.584 毫秒只比归并慢一半,比堆排序与三路快排都快。
qsort 慢在哪里,有两件事能解释。一是它是通用接口,每次交换要按元素宽度走一次 memcpy,不像手写版本直接赋值两个 int;二是 glibc 的实现里先用归并在临时缓冲里做,缓冲申请不到才退回快速排序,所以它在接近有序的数据上表现好,跟归并那一行的走势一致。
非比较的两种在随机数据上是另一个量级,十几毫秒对一百毫秒,差了七八倍。这个差距的代价是适用范围:基数排序要求非负整数,计数排序要求值域窄,题目里只要出现负数或者值域很宽,这两种就直接出局。
c
/* 通用接口的比较函数:每次比较都要跳一次函数指针 */
static int CmpInt(const void* x, const void* y)
{
int a = *(const int*)x, b = *(const int*)y;
return (a > b) - (a < b);
}
/* 手写版本直接比大小,没有回调这一层 */
if (a[j] > key) { ... }
八、挑排序之前先看数据量、值域和要不要稳定方式
把前面几节的数据合起来看,选排序其实是三个问题按顺序问下来:数据有多少、值域有多宽、要不要稳定。

图 6 先看数据长什么样,再挑排序
数据量很小,几十个到几百个元素。 直接插入排序就够,它的常数最小,没有递归没有额外申请,一百个元素几十微秒。刷题时小区间切到插入排序的写法也就是这个道理,这一点在快排专题里试过,阈值取 16 到 64 之间效果最好。
数据量到几千,且未必有序。 希尔排序性价比高,一万条随机数据 0.729 毫秒,跟堆排序、三路快排同一个量级,代码只有十来行。它的代价是不稳定,遇到需要保序的场合要换。
数据量到十万以上,要求稳定。 归并用,代价是一块与数据等长的临时数组。C 程序里把 tmp 申请一次传下去,不要每层递归都申请,否则光是申请与释放就够呛。
数据量到百万,通用场景。 三路快排是默认选择,实测一百万条随机 83.858 毫秒,逆序 101.766 毫秒,全等值 0.602 毫秒,三种形态都没有塌下去。要在极端数据上也不塌,就把深度上限与小区间插入一起加上,上面这组数字用的就是这个版本。
数据是整数,值域不宽。 计数排序或基数排序,前者在值域窄的时候更快,后者不挑值域只看位宽。一百万条随机数据上两者分别 14.826 与 14.246 毫秒,比比较排序快七八倍,值域一宽就换成比较排序。
内存吃紧,或者元素很贵(长字符串、大结构体)。 这时候看的是移动次数,选择排序最省,每一趟只在最后交换一次。五千条随机数据上它的移动次数是一万四千九百七十九次,同一批数据冒泡排序要一千八百七十九万次,差了三个数量级;堆排序的移动次数比选择多,但它是原地排序,额外空间只要常数。
数据在磁盘上,内存装不下。 只能用外排序,切成小文件各自排好,再两两归并。这条路在归并与外排序那篇走过一遍,一千万条整数、每批十万条切出一百块,七趟归并加起来的耗时 1792 毫秒。
九、选择题练习
七道题考的是前面九节的结论,每题只有一个选项正确,答案与理由统一放在本节末尾。
第 1 题 快速排序算法是基于( )的一种排序算法。
- A. 分治法
- B. 贪心法
- C. 递归法
- D. 动态规划法
第 2 题 对 54、38、96、23、15、72、60、45、83 做直接插入排序,把第 8 个记录 45 插入有序表时需要比较几次?
- A. 三次
- B. 四次
- C. 五次
- D. 六次
第 3 题 下列排序算法中,占用 O(n) 辅助存储空间的是( )。
- A. 选择排序
- B. 快速排序
- C. 堆排序
- D. 归并排序
第 4 题 下列排序算法中,稳定且时间复杂度为 O(n^2) 的是( )。
- A. 快速排序
- B. 冒泡排序
- C. 选择排序
- D. 归并排序
第 5 题 关于排序,下面四种说法中不正确的是( )。
- A. 快速排序的平均时间复杂度为 O(n log n),额外空间为 O(log n)
- B. 归并排序是稳定的,堆排序与快速排序不稳定
- C. 序列基本有序时,快速排序退化成冒泡排序,直接插入排序最快
- D. 归并排序的空间复杂度为 O(n),堆排序的空间复杂度为 O(log n)
第 6 题 下列排序算法中,最坏情况下时间复杂度最小的是( )。
- A. 堆排序
- B. 快速排序
- C. 希尔排序
- D. 冒泡排序
第 7 题 以 65 为基准对 65、56、72、99、86、25、34、66 走一趟快速排序,结果是( )。
- A. 34、56、25、65、86、99、72、66
- B. 25、56、34、65、86、99、72、66
- C. 34、56、25、65、86、72、99、66
- D. 34、56、25、65、99、86、72、66
答案与理由
- 答案 A,快排把区间按基准切成两半,每半独立求解再拼起来,走的是分治的分、治、合三步,递归只是实现分治的手段,不是它归属的算法思想。
- 答案 C,程序把前七个排好得到 15、23、38、54、60、72、96,插 45 时从后往前依次与 96、72、60、54、38 比较,与 38 比较时停住,一共五次,程序里数出来的也是五次。最后一次没有发生移动,但仍然算一次比较,漏掉它就会选成四次。
- 答案 D,选择、快速、堆的额外空间分别是常数、递归栈、常数,只有归并要开一块与元素个数等长的临时数组。
- 答案 B,快速排序的最坏是 N 平方且不稳定,选择排序不稳定,归并排序的时间是 N log N。四个选项里只有冒泡同时满足稳定与平方两个条件。
- 答案 D,D 的后半句把堆排序的额外空间说错了,堆排序是原地排序,额外空间是常数,不是 O(log N)。A 说快排时间 N log N、空间 log N,正确;B 说归并稳定、堆与快排不稳定,与第五节的实测一致;C 说序列基本有序时快排退化成冒泡、直接插入最快,第四节表里接近有序时插入 0.418 毫秒,比快排的 0.451 毫秒还快一点。
- 答案 A,快排最坏是 N 平方,希尔最坏是 N 平方,冒泡最坏是 N 平方,四个选项里只有堆排序的最坏也停在 N log N 上。这一题问的是最坏而不是平均,所以答案是堆排序,不是快排。
- 答案 A,程序把三种划分法各跑了一遍:挖坑法得到 34、56、25、65、86、99、72、66,与 A 逐位相同;hoare 版本得到 25、56、34、65、86、99、72、66,正是选项 B;前后指针法得到 34、56、25、65、86、72、99、66,正是选项 C;D 把 86 与 99 的位置换了。这道题默认的是挖坑法,换成别的写法就对不上选项。
十、汇总
把一百万条随机数据上六种做法的结果并到一张表里。
| 做法 | 时间 | 稳定性 | 额外空间 | 适用前提 |
|---|---|---|---|---|
| 三路快排 | 83.858 | 否 | O(log N) | 无 |
| 归并 | 103.092 | 是 | O(N) | 无 |
| 堆 | 125.621 | 否 | O(1) | 无 |
| 希尔 | 131.251 | 否 | O(1) | 无 |
| qsort | 126.432 | 否 | 视实现 | 无 |
| 基数 | 14.246 | 是 | O(N) | 非负整数 |
| 计数 | 14.826 | 是 | O(值域) | 值域窄 |
把上面这些数据收一收,能落出六条结论,每一条都对应前面的一张表。
- 比较排序的时间下界卡在 N log N,一百万条在一百毫秒量级,四种写法之间最慢最快差一点五倍,选哪个更多看稳定性与空间,而不是看这点时间差。
- 非比较排序能跳出一个量级,一百万条十几毫秒,代价是值域必须窄、必须是整数,条件不满足就一步都用不上。
- 平方级的三兄弟在一万条上就已经上百毫秒,十万条要十几秒,实战里只在元素很少或者数据几乎有序时才有位置。
- 稳定性不能靠猜,第五章的穷举给出最短反例:堆排序两个元素就打乱,希尔要六个,选择与快速三个,插入、冒泡、归并、计数、基数在 29520 组里一次都没翻。
- 空间的大头只有归并的临时数组与计数的桶,前者与条数同阶,后者与值域同阶,其余几种的额外开销可以忽略。
- 库函数 qsort 是通用选项,不是最快选项,随机数据上比手写三路快排慢一半,接近有序与逆序上跟归并接近,用它图的是通用,想压时间还是得自己写。
踩坑点
- 函数指针数组留了空位,程序跑到一半段错误。对比程序一开始用一张函数指针数组把六种做法串起来,数组按算法编号写,计数与基数那两个位置留空;循环走到第二种做法时读到空指针,程序直接退出,前面只打了一行 qsort 的结果。改成按做法序号单独映射一层,六种做法各占一个分支,问题才消失。
- 穷举稳定性时九种排序共用一份数组,打印出来的反例是别人的。第一次把原始组合排一遍就交给第一种排序,排完继续交给第二种,反例在几个排序之间来回串;排序会改数组,第二次进来拿到的已经是排好的数据。改成每一组、每一种排序各拷一份副本,29520 组跑完才稳,希尔长度 6、选择长度 3、堆长度 2 这三个数字才可信。
- 计时用 clock(),小规模的数据全是 0.000。它在这台机器上的粒度是十毫秒,一千条与一万条的耗时都读成 0.000,只有十万条以上才有数字;换成 clock_gettime 配 CLOCK_MONOTONIC 之后一万条的差距才显出来,基数排序 0.099 毫秒这种数字才有意义。用 clock_gettime 要在文件第一行写 _POSIX_C_SOURCE 200809L。
- 全等值上计数排序变慢,查了半天不是逻辑错。计数排序在接近有序与逆序上都是 0.028 毫秒,到全等值上变成 0.049 毫秒;这个量级只剩几十微秒,一次内存申请与回收的抖动摊进去就是七成。绝对时间小到微秒级时,比较两个数字的差距意义不大。
- 数比较次数漏掉最后一次,五次变四次。第 2 题第一次跑出来是 4 次,跟答案的五次对不上,原因是把内层循环写成只在发生移动时计数,而插入排序与 38 比较时虽然没有移动,却确实比较了一次;把计数放在比较之前,不区分移不移动,结果正好五次。
- 第 7 题用 hoare 版本跑,四个选项一个都对不上。按 hoare 的双指针写,一趟划分得到 25、56、34、65、86、99、72、66,四个选项逐个比都不对;换挖坑法重跑得到 34、56、25、65、86、99、72、66,与选项 A 逐位相同。这道题默认的划分法是挖坑法,同样叫快速排序的一趟,三种划分法给出的中间状态不一样。
- 九种排序没法用同一个原型串起来。计数排序要传值域上限,基数排序要传位宽与缓冲区,直接塞进 int 指针加长度的原型里只能在外面开全局变量;后来把统一原型改成 int ()(int, int) 加一层分发表,特殊情况在分发表里包一层,对比程序才写得干净。
本篇总结(模拟面试问题)
问:比较排序的时间下界为什么卡在 N log N,非比较排序凭什么跳出去?
核心要点:比较排序的每一步只能得到两种结果,一次比较最多把可能的排列数砍成两半;N 个元素一共有 N 的阶乘种排列,要把它们区分出来至少要 log2(N 的阶乘) 次比较,用斯特林公式展开约等于 N log N。计数与基数排序不做比较,判定信息来自元素的值本身,不受这个下界约束,代价是只能处理整数而且值域要可控。
问:堆排序的最坏是 N log N,快速排序却会退化成 N 的平方,差别在哪?
核心要点:堆排序每一趟都是固定动作,把堆顶换到末尾再下沉调整,深度是 log N,跟数据长什么样没有关系,所以最坏也稳在 N log N。快速排序的划分比例由基准决定,基准每次落在区间最边上时一趟只排好一个元素,递归深度变成 N;三数取中只能让这种情形很难出现,不能从算法上排除,内省排序靠深度上限换堆排序来兜底。
问:三路快排在大量重复数据上为什么快那么多,一百万条全等值差多少?
核心要点:两路划分只能切成小于基准与不小于基准两半,全等值时等于的部分全被塞进右半,右半每趟只少一个元素,递归深度是 N;三路划分切成小于、等于、大于三段,等于段是排好的成品不再进递归,一趟就把整段收掉。实测一百万条全等值,三路快排 0.602 毫秒,两路写法要跑到分钟级。
问:计数排序的复杂度写成 O(N 加范围),范围大到什么程度该换算法?
核心要点:计数表要开值域那么多格子,值域一百万时是四兆字节,值域十亿时是四吉字节,申请都申请不下来。经验的分界是值域不超过元素个数的几倍,超出这个比例,建表与回收的成本就盖过了它省下的比较;代码里给了一千六百万格子的上限,超过直接返回失败,让调用方换回比较排序。
问:什么场合必须用稳定排序,什么场合完全不用管?
核心要点:多关键字排序必须用稳定排序,先按成绩排一次、再按班级排一次,第二次稳定排序能让同班内部保持成绩有序,换成不稳定排序,同班的成绩顺序就乱了。只按单个关键字排序、元素也没有附加属性时,稳定性不影响结果,这时候可以换用常数更小的写法。
问:库函数 qsort 为什么不是最快的,还有那么多人用?
核心要点:它是通用接口,元素宽度在运行时才知道,比较靠函数指针回调,每次交换都要按宽度做内存搬运,函数调用与搬运的开销在 int 数组上占了很大比例;换来的好处是任何类型、任何比较规则都能用。实测一百万条随机数据上它 126.432 毫秒,手写三路快排 86.990 毫秒,差距一半左右;规模小的时候这点差距可以接受,规模大且类型固定时值得自己写。
问:一百万条整数、值域一亿、要求稳定,你选哪个?
核心要点:值域一亿意味着计数表要开四亿字节,基数排序按位处理则可行;基数排序在 32 位范围内固定四趟,一百万条实测十四毫秒上下,而且它稳定,所以这个组合下基数排序最合适。数据里有负数时先整体加一个偏移搬进非负范围,排完再减回去。