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

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


🔥 H.莓飛: 个人主页

❄️个人专栏:「C++」「数据结构」「Linux系统编程」

✨ 心不畏死,踏屍前行。

📦 代码仓库: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
答案与理由
  1. 答案 A,快排把区间按基准切成两半,每半独立求解再拼起来,走的是分治的分、治、合三步,递归只是实现分治的手段,不是它归属的算法思想。
  2. 答案 C,程序把前七个排好得到 15、23、38、54、60、72、96,插 45 时从后往前依次与 96、72、60、54、38 比较,与 38 比较时停住,一共五次,程序里数出来的也是五次。最后一次没有发生移动,但仍然算一次比较,漏掉它就会选成四次。
  3. 答案 D,选择、快速、堆的额外空间分别是常数、递归栈、常数,只有归并要开一块与元素个数等长的临时数组。
  4. 答案 B,快速排序的最坏是 N 平方且不稳定,选择排序不稳定,归并排序的时间是 N log N。四个选项里只有冒泡同时满足稳定与平方两个条件。
  5. 答案 D,D 的后半句把堆排序的额外空间说错了,堆排序是原地排序,额外空间是常数,不是 O(log N)。A 说快排时间 N log N、空间 log N,正确;B 说归并稳定、堆与快排不稳定,与第五节的实测一致;C 说序列基本有序时快排退化成冒泡、直接插入最快,第四节表里接近有序时插入 0.418 毫秒,比快排的 0.451 毫秒还快一点。
  6. 答案 A,快排最坏是 N 平方,希尔最坏是 N 平方,冒泡最坏是 N 平方,四个选项里只有堆排序的最坏也停在 N log N 上。这一题问的是最坏而不是平均,所以答案是堆排序,不是快排。
  7. 答案 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(值域) 值域窄

把上面这些数据收一收,能落出六条结论,每一条都对应前面的一张表。

  1. 比较排序的时间下界卡在 N log N,一百万条在一百毫秒量级,四种写法之间最慢最快差一点五倍,选哪个更多看稳定性与空间,而不是看这点时间差。
  2. 非比较排序能跳出一个量级,一百万条十几毫秒,代价是值域必须窄、必须是整数,条件不满足就一步都用不上。
  3. 平方级的三兄弟在一万条上就已经上百毫秒,十万条要十几秒,实战里只在元素很少或者数据几乎有序时才有位置。
  4. 稳定性不能靠猜,第五章的穷举给出最短反例:堆排序两个元素就打乱,希尔要六个,选择与快速三个,插入、冒泡、归并、计数、基数在 29520 组里一次都没翻。
  5. 空间的大头只有归并的临时数组与计数的桶,前者与条数同阶,后者与值域同阶,其余几种的额外开销可以忽略。
  6. 库函数 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 位范围内固定四趟,一百万条实测十四毫秒上下,而且它稳定,所以这个组合下基数排序最合适。数据里有负数时先整体加一个偏移搬进非负范围,排完再减回去。

参考

相关推荐
爱吃香菜的初学者8 小时前
八.数据结构——哈希表(1)
数据结构·散列表
YYRAN_ZZU9 小时前
FlameGraph 火焰图介绍和使用
linux·嵌入式硬件
正在走向自律9 小时前
Linux 服务器部署 DeepSeek 大模型完全指南(2026 版)
linux·运维·服务器·deepseek·部署deepseek·安装ollama·配置api服务
步行cgn9 小时前
JSON.stringify 详解:把 JavaScript 对象变成 JSON 字符串
开发语言·javascript·json
大江东去浪淘尽千古风流人物9 小时前
【FLORA】机器人视频标注引擎解析:三模型如何产出动作指令与四类奖励信号
matlab·机器人·跨模态·遥感·图像匹配·1024程序员节·特征描述符
Soneswy9 小时前
C++基础篇——类与对象(中)
开发语言·c++
CoderYanger9 小时前
Java EE 进阶:5. Spring IoC&DI(第二弹):Spring IoC
java·开发语言·spring boot·spring·面试·职场和发展·java-ee
KeyAction66669 小时前
从西班牙高铁追尾看安全系统设计:当违规指令成为输入,防护逻辑如何兜底
运维·服务器·安全
夜雪一千9 小时前
Linux 管道、重定向命令介绍
linux·运维·chrome
江华森9 小时前
数据中心网络技术基础 —— 从原理到实操
linux·运维