【数据结构】排序_OJ题(一):计数、桶与排序后的处理
概览
- 计数与前缀和:高度检查器、相对排序、有多少小于当前数字
- 值域大到装不下时换基数排序:最大间距
- 排序之后逐位比对:有效的字母异位词
- 排序之后双指针:两个数组的交集、数组拆分、有序数组的平方
- 排序之后看两头:三个数的最大乘积
- 不排序也能出答案:H 指数用长度为 n 加一的桶
核心知识
一、共同点
这十二道题的共同点是输入给了一堆数,输出却不要排好序的数组,而要一个由顺序决定的结果。既然顺序只是中间产物,思路就可以固定成两段:先把数组变成有序的,再用一次线性扫描把答案取出来。第二段怎么扫决定了题目分成哪一族,于是就有了下面三种收尾方式。
第一族,逐位比对。 期望的顺序可以直接算出来,或者可以另外造一份,然后拿原数组和它一位一位对下去。高度检查器就是这样,把身高排好,再跟原始站位逐位比,位置不同的人数就是答案。有效的字母异位词把这一步做成了计数表相减,两张表逐格抵消完之后全零就说明字母构成一致。
第二族,双指针扫。 两个数组都排好之后,用两个游标从各自的开头往前走,谁小谁往前挪一格,相等的时候把值收下来。两个数组的交集靠这个动作去重,数组拆分靠排序之后隔一个取一个,有序数组的平方把游标放在两端,每次取绝对值大的那一端往结果的末尾填,三个数的最大乘积干脆只看排序之后的两头。
第三族,查表。 有些题目要的是「比我小的数有几个」这类名次信息,排序反而绕远了。开一张以值为下标的计数表,求一遍前缀和,问一次就知道答案。有多少小于当前数字的数字、H 指数、数组的相对排序都落在这一族里,最大间距是它的加强版,值域大到装不下一张表的时候,用两趟十六位的基数排序把表压回六万五千格。

图 1 排序之后还有三招:对位、扫描、查表
三种收尾方式的取舍标准很直接:想要位置对不对,就用逐位比对;想要成对出现的东西,就用双指针扫;想要第几名、有几个比它小,就直接查表。判错了族,代码能跑对但复杂度会多一个数量级。
二、计数表什么时候真的比排序快
计数表的复杂度写成 O(n + 值域),排序写成 O(n log n)。这两个式子放在一起看不出胜负,因为常数不一样,计数表要开一块内存、清一次零、按值域扫一遍,排序只是在数组内部来回搬。真正决定快慢的是值域相对于 n 有多大。
实测的一百万条数据上,值域一百零一的时候计数表 1.386 毫秒,qsort 86.132 毫秒,前者快 62.1 倍;值域一万零一的时候计数表 1.353 毫秒,qsort 113.553 毫秒,前者快 83.9 倍。计数表的耗时几乎不随值域变化,因为多出来的那点零头被清表的一次 memset 淹掉了;qsort 的耗时却随着值域变大而上升,值域越宽,比较时能提前分出胜负的机会越少。
值域继续放大到十亿,一张以值为下标的表需要三千八百多万个格子,纯计数这条路就废了。这时候换成两趟十六位的基数排序,第一趟按低十六位分进六万五千个桶再收回来,第二趟按高十六位再来一次,两趟之后整个数组有序。同样的数据量上基数排序 15.855 毫秒,qsort 127.135 毫秒,快 8.0 倍。
三个数字摆在一起,选择的界线就出来了:值域在一万上下,计数表可以直接用;值域到百万级,计数表还撑得住但优势在缩小;值域再大就该换基数排序或者回去用比较排序,因为这时候表本身的内存开销会盖过一切。
三、题目总览
| 题号 | 题目 | 套路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 1051 | 高度检查器 | 计数排序加逐位比对 | O(n + 值域) | O(值域) |
| 1122 | 数组的相对排序 | 计数加按给定顺序回填 | O(n + m + 值域) | O(值域) |
| 242 | 有效的字母异位词 | 一张计数表相加相减 | O(n) | O(26) |
| 349 | 两个数组的交集 | 排序加双指针去重 | O(n log n + m log m) | O(1) 额外 |
| 164 | 最大间距 | 两趟基数排序加一次扫描 | O(n) | O(n + 65536) |
| 274 | H 指数 | 长度为 n 加一的桶 | O(n) | O(n) |
| 179 | 最大数 | 自定义比较器排序 | O(n log n) | O(n) |
| 561 | 数组拆分 | 排序后隔一个取一个 | O(n log n) | O(1) 额外 |
| 977 | 有序数组的平方 | 两端双指针从后往前填 | O(n) | O(n) |
| 1365 | 有多少小于当前数字的数字 | 计数表求前缀和 | O(n + 值域) | O(值域) |
| 506 | 相对名次 | 带下标的排序加回填 | O(n log n) | O(n) |
| 628 | 三个数的最大乘积 | 排序后看两头 | O(n log n) | O(1) 额外 |
十二道题里只有五道的时间复杂度带 log,剩下的都是线性的,原因在于它们都没做「任意两个元素比较大小」这件事。凡是题目给出了值的范围,或者只关心名次而不关心具体顺序,线性做法就有机会,这一组题里有九道是靠这个前提把复杂度压下来的。
题目顺序按套路递进:第一题到第三题是计数表的三连,第四题到第六题把排序之后的扫描方式铺开,第七题到第九题涉及自定义比较和两端指针,第十题到第十二题收在名次与乘法上。时间紧的话,第一题、第四题、第五题先做,它们分别是计数、双指针和基数排序的代表。
实例代码
十二道题放在同一个目录下,每题一个 .c 文件,解法、暴力对照和测试驱动写在同一个文件里。编译命令统一是下面这一行,十二个程序都是零告警。
text
gcc -std=gnu99 -Wall -Wextra -O2 q01.c -o q01
十二个文件和实验目录的排布像下面这样。
text
ds15/
├── q01.c 1051 高度检查器
├── q02.c 1122 数组的相对排序
├── q03.c 242 有效的字母异位词
├── q04.c 349 两个数组的交集
├── q05.c 164 最大间距
├── q06.c 274 H 指数
├── q07.c 179 最大数
├── q08.c 561 数组拆分
├── q09.c 977 有序数组的平方
├── q10.c 1365 有多少小于当前数字的数字
├── q11.c 506 相对名次
├── q12.c 628 三个数的最大乘积
└── exp/
└── count_vs_sort.c 计数与基数排序的对比计时
每个文件里都有两个实现,主解法写在前面,暴力解法写在后面,主函数随机造数组把两边的结果逐组对一遍。十二个程序的暴力做法各不相同,有的用 qsort 排完再扫,有的用三重循环枚举,有的用递归把全部配对枚举出来,逐题在下面说明。
第一题 高度检查器(1051)
题目:学校打算给学生们拍一张年度照片,学生们按身高排成一列,身高数组 heights 给出这一列的顺序。返回身高没有站在正确位置上的学生人数。
原题链接:leetcode 1051 高度检查器
思路。 期望的站位顺序就是把 heights 从小到大排好。笨办法是把排序结果复制一份,然后逐位比对,时间是 O(n log n),空间也是 O(n)。这道题的身高范围被限制在一到一百,于是排序那一步可以换成计数:开一张一百零一格的计数表,先数一遍每个身高出现几次,再按身高从小到大把期望序列展开,展开的同时跟原数组逐位比,位置不对就记一笔。
展开的过程不需要真的写一个新数组出来。计数表的下标本身就是升序的,外层从一到一百遍历身高,内层按这个身高的出现次数重复访问原数组的同一位置,下标一路往后推,写起来就是一个双循环。
用例走一遍。 先看一组输入,身高依次是 1 1 4 2 1 3。
| 身高 | 计数表中的个数 |
|---|---|
| 1 | 3 |
| 2 | 1 |
| 3 | 1 |
| 4 | 1 |
展开出来的期望序列是 1 1 1 2 3 4,跟原数组逐位比:第一位一等于一,第二位一等于一,第三位四对一,第四位二等于二,第五位一对三,第六位三对四。第三、第五、第六位对不上,一共三位学生需要调整,返回 3。
代码。
c
# 文件:/home/cocatrice/ds15/q01.c
static int HeightChecker(const int* h, int n)
{
int cnt[101] = {0};
for (int i = 0; i < n; i++) {
cnt[h[i]]++;
}
int idx = 0, bad = 0;
for (int v = 1; v <= 100; v++) {
for (int k = 0; k < cnt[v]; k++, idx++) {
if (h[idx] != v) {
bad++;
}
}
}
return bad;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 1 1 4 2 1 3 | 3 | 三位学生站错了位置 |
| 5 1 2 3 4 | 5 | 整体右移一格,五位学生都在错的位置 |
| 1 2 3 4 5 | 0 | 已经有序,一次都不用调整 |
| 长度为一 | 0 | 只有一个人,位置永远是对的 |
复杂度。 时间 O(n + 值域),值域固定是一百;空间 O(值域),也就是一百零一个 int。跟 qsort 那份实现相比,一百万条数据上计数版本 1.386 毫秒,qsort 版本 86.132 毫秒。
第二题 数组的相对排序(1122)
题目:给你两个数组 arr1 和 arr2,arr2 中的元素互不相同,arr1 中出现的元素在 arr2 里也都出现过。把 arr1 重新排序,使得 arr1 中元素的相对顺序和 arr2 保持一致,没有在 arr2 里出现过的元素要按升序放在末尾。
思路。 这道题的顺序由两张表决定,arr2 给出了第一部分元素之间的先后,剩下的元素按值升序排在后面。既然如此,就不要去排序,直接把 arr1 的值域当成下标数一遍,再按 arr2 的顺序把数倒出来。
具体做法是先对 arr1 做一遍计数,然后从左到右扫 arr2,每扫到一个值,看它在计数表里还剩几个,剩几个就往结果里放几个,同时把计数减到零。arr2 扫完之后,第一部分就拼好了;第二部分按值从小到大扫一遍计数表,把剩下的都放进去。整个过程中每个数字只被写一次,写入位置由一个游标 k 一路往后推。
用例走一遍。 arr1 是 2 3 1 3 2 4 6 7 9 2 19,arr2 是 2 1 4 3 9 6。
| 步 | arr2 里的值 | 计数表里剩几个 | 这一轮写进结果的 |
|---|---|---|---|
| 1 | 2 | 3 | 2 2 2 |
| 2 | 1 | 1 | 1 |
| 3 | 4 | 1 | 4 |
| 4 | 3 | 2 | 3 3 |
| 5 | 9 | 1 | 9 |
| 6 | 6 | 1 | 6 |
arr2 走完之后结果是 2 2 2 1 4 3 3 9 6,剩下的 7 和 19 按升序补在后面,最终得到 2 2 2 1 4 3 3 9 6 7 19。

图 2 先按 arr2 的顺序把数取出来,剩下的按大小补在后面
代码。
c
# 文件:/home/cocatrice/ds15/q02.c
#define VALUE_MAX 1000
static int g_cnt[VALUE_MAX + 1];
static void RelativeSort(int* a, int n1, const int* b, int n2)
{
memset(g_cnt, 0, sizeof g_cnt);
for (int i = 0; i < n1; i++) {
g_cnt[a[i]]++;
}
int k = 0;
for (int i = 0; i < n2; i++) {
while (g_cnt[b[i]] > 0) {
a[k++] = b[i];
g_cnt[b[i]]--;
}
}
for (int v = 0; v <= VALUE_MAX; v++) {
while (g_cnt[v] > 0) {
a[k++] = v;
g_cnt[v]--;
}
}
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| arr1 只有一个元素 | 原样返回 | 计数表里只有一个值,不影响顺序 |
| arr2 比 arr1 的元素种类多 | 只按交集处理 | 计数表里没有的值不会被写出来 |
| arr1 全部元素都在 arr2 里 | 按 arr2 的顺序排完 | 第二段循环一次都不进 |
| arr1 有负数 | 需要把下标整体平移 | 原题限定零到一千,代码里没做平移 |
复杂度。 时间 O(n + m + 值域),值域是一千;空间 O(值域)。一百万条、值域一千的数据上,计数路线 1.300 毫秒,qsort 路线 99.317 毫秒。
第三题 有效的字母异位词(242)
题目:给定两个字符串 s 和 t,判断 t 是不是 s 的字母异位词,也就是两个串的字母种类和每种字母的个数完全一样,只是排列顺序不同。
思路。 排序之后比较两个串是否完全相等,是最直白的做法,时间复杂度 O(n log n)。字母只有二十六个,用计数表可以做到 O(n):开一张二十六格的表,扫 s 的时候加一,扫 t 的时候减一,最后看表里是不是全零。
减法这个动作顺带解决了两件事,一是两个串长度不同的时候一定有某一格不为零,二是不同字母的多和少会自动抵消掉。所以不需要先比长度,循环也不需要额外判断边界。
用例走一遍。 s 是 anagram,t 是 nagaram。
| 字母 | s 中的个数 | t 中的个数 | 相加相减之后 |
|---|---|---|---|
| a | 3 | 3 | 0 |
| g | 1 | 1 | 0 |
| m | 1 | 1 | 0 |
| n | 1 | 1 | 0 |
| r | 1 | 1 | 0 |
五个字母的差值全是零,表里没有一格非零,返回真。换成 s 是 rat、t 是 car,字母 r 和 c 的差值都不是零,返回假。
代码。
c
# 文件:/home/cocatrice/ds15/q03.c
static int IsAnagram(const char* s, const char* t)
{
int cnt[26] = {0};
while (*s) {
cnt[*s - 'a']++;
s++;
}
while (*t) {
cnt[*t - 'a']--;
t++;
}
for (int i = 0; i < 26; i++) {
if (cnt[i] != 0) {
return 0;
}
}
return 1;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| a 与 aa | 假 | 长度不同,某个格子的差值不为零 |
| ab 与 ba | 真 | 顺序不同但字母构成一致 |
| 两个空串 | 真 | 两个循环都不进,表全零 |
| a 与 a | 真 | 最短的真值输入 |
复杂度。 时间 O(n),n 是两个串长度的较大者;空间 O(26),常量。题目没有限定只有小写字母时,把表的长度换成二百五十六就能覆盖全部 ASCII。
第四题 两个数组的交集(349)
题目:给定两个数组 nums1 和 nums2,返回它们的交集,结果中的每个元素一定是唯一的,顺序不作要求。
原题链接:leetcode 349 两个数组的交集
思路。 两个数组都排好序之后,交集变成一个很规整的动作:两个游标分别指向两个数组的开头,比较当前的两个值,小的那个往后挪一格,相等的时候把值收进结果,然后两个游标同时往后挪。排序让相等的值一定会同时出现,只需要一次线性扫描就能把全部共同元素找出来。
去重是这道题多出来的一步,判断放在收值之前。因为结果里每个值只能出现一次,收下值之前要看一下上一个收下的是不是同一个值,相同就跳过。这个判断放在双指针循环内部,代价是常数级的比较。
用例走一遍。 nums1 是 4 9 5,nums2 是 9 4 9 8 4,两个数组排好序之后分别是 4 5 9 和 4 4 8 9 9。
| 步 | i 位置的值 | j 位置的值 | 动作 |
|---|---|---|---|
| 1 | 4 | 4 | 相等,收下 4,两个游标都往前 |
| 2 | 5 | 4 | 四比较小,j 往前 |
| 3 | 5 | 8 | 五比较小,i 往前 |
| 4 | 9 | 8 | 八比较小,j 往前 |
| 5 | 9 | 9 | 相等,收下 9,两个游标都往前 |
| 6 | 越界 | 9 | i 到头,循环结束 |
扫完之后结果里是 4 和 9 这两个值。

图 3 两个有序数组上一路往右扫的两个游标
代码。
c
# 文件:/home/cocatrice/ds15/q04.c
static int Intersection(int* a, int n, int* b, int m, int* out)
{
InsertSort(a, n);
InsertSort(b, m);
int i = 0, j = 0, k = 0;
while (i < n && j < m) {
if (a[i] < b[j]) {
i++;
} else if (a[i] > b[j]) {
j++;
} else {
if (k == 0 || out[k - 1] != a[i]) {
out[k++] = a[i];
}
i++;
j++;
}
}
return k;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 4 9 5 与 9 4 9 8 4 | 4 9 | 两个值都在,第二个 9 不再重复收 |
| 1 2 2 1 与 2 2 | 2 | 重复元素只保留一个 |
| 1 2 3 与 4 5 6 | 空 | 两个游标一路挪到越界,一次都没相等 |
| 两个空数组 | 空 | 循环一次都不进 |
复杂度。 时间由排序决定,O(n log n + m log m),扫描是 O(n + m);额外空间 O(1),结果数组不算在内。这道题的排序用的是插入排序,因为测试数据的规模很小,换成 qsort 在长度上万的输入上会有明显优势。
第五题 最大间距(164)
题目:给定一个无序的数组 nums,返回数组在排序之后相邻元素之间最大的差值,数组元素个数小于二的时候返回零,并且要求时间和空间都做到线性。
原题链接:leetcode 164 最大间距
思路。 题目把复杂度卡在线性,比较排序直接出局,因为比较排序的下界是 O(n log n)。值域给了出路:元素是 32 位范围内的整数,按十六位一段拆成两半,做两趟基数排序,每趟只扫两遍数组,加起来仍然是线性的。
一趟基数排序的动作分三步,先按当前十六位把每个元素数进对应的桶,再把桶的计数改写成前缀和,得到每个桶在输出数组里的起始位置,最后按原数组的顺序把元素放进输出数组。两趟之间要交换输入和输出的角色,用一个临时指针轮流指向两块内存就行。第二趟跑完,数据已经在其中一块里有序,拷回原数组。
用例走一遍。 数组 3 6 9 1,按低十六位看,桶号分别是 3、6、9、1。
| 趟 | 按哪几位分桶 | 收集之后的数组 |
|---|---|---|
| 第一趟 | 低十六位 | 1 3 6 9 |
| 第二趟 | 高十六位 | 1 3 6 9 |
第二趟之所以看不出变化,是因为这四个数的高位都是零,本来就已经按低位排好了。相邻差依次是 2、3、3,最大值 3,返回 3。

图 4 三十二位的整数分两趟十六位就排完了
代码。
c
# 文件:/home/cocatrice/ds15/q05.c
#define RADIX 65536
static unsigned g_buf[100005];
static void RadixSort(int* a, int n)
{
unsigned* src = (unsigned*)malloc(sizeof(unsigned) * (size_t)n);
unsigned* dst = (unsigned*)malloc(sizeof(unsigned) * (size_t)n);
for (int i = 0; i < n; i++) {
src[i] = (unsigned)a[i];
}
for (int pass = 0; pass < 2; pass++) {
int shift = pass * 16;
memset(g_buf, 0, sizeof(unsigned) * RADIX);
for (int i = 0; i < n; i++) {
g_buf[(src[i] >> shift) & 0xFFFF]++;
}
unsigned sum = 0;
for (int v = 0; v < RADIX; v++) {
unsigned c = g_buf[v];
g_buf[v] = sum;
sum += c;
}
for (int i = 0; i < n; i++) {
dst[g_buf[(src[i] >> shift) & 0xFFFF]++] = src[i];
}
unsigned* tmp = src;
src = dst;
dst = tmp;
}
for (int i = 0; i < n; i++) {
a[i] = (int)src[i];
}
free(src);
free(dst);
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 3 6 9 1 | 3 | 排序之后相邻差最大是 3 |
| 只有一个元素 | 0 | 题目要求直接返回零 |
| 1 10000000 | 9999999 | 两个数的差可以很大,用 int 存得下 |
| 全部元素相同 | 0 | 相邻差全是零 |
复杂度。 时间 O(n + 65536),两趟都只扫数组和桶;空间 O(n + 65536),其中两块 n 大小的缓冲区由 malloc 拿到。实测一百万条、值域十亿的数据上,基数排序 15.855 毫秒,qsort 127.135 毫秒。
第六题 H 指数(274)
题目:给定一位研究者的论文被引用次数的数组 citations,计算他的 H 指数。H 指数的定义是有 h 篇论文分别被引用了至少 h 次,且其余论文的被引用次数都不超过 h 次。
原题链接:leetcode 274 H 指数
思路。 直接按定义从 h 等于 n 往下试,每次数一遍有多少篇论文的引用数不小于 h,是 O(n²)。优化点在于 n 篇论文的 H 指数一定不超过 n,于是引用次数大于 n 的那些论文可以合并看待,统统当成 n 次算。
开一个长度为 n 加一的桶,下标表示被引用了几次,超过 n 的统一放进下标 n 那一格。然后从 n 往下扫,一边扫一边累加论文篇数,当累加的篇数第一次大于等于当前下标的时候,这个下标就是 H 指数。
用例走一遍。 引用数是 3 0 6 1 5,n 等于 5,其中 6 超过 5 被并进下标 5 那一格。
| 桶下标 | 桶里的篇数 | 从大到小扫到这里时的累加 | 累加是否不小于下标 |
|---|---|---|---|
| 5 | 2 | 2 | 否,二小于五 |
| 4 | 0 | 2 | 否,二小于四 |
| 3 | 1 | 3 | 是,三不小于三 |
| 1 | 1 | 不再继续 | 循环已经返回 |
扫到下标 3 的时候累加是三,三不小于三,返回 3。这个结果与定义对得上:引用数是 6、5、3 的三篇论文都至少被引用三次,剩下两篇是零和一次,都不超过三次。
代码。
c
# 文件:/home/cocatrice/ds15/q06.c
static int HIndex(const int* c, int n)
{
int bucket[1024];
memset(bucket, 0, sizeof bucket);
for (int i = 0; i < n; i++) {
int v = c[i] > n ? n : c[i];
bucket[v]++;
}
int cnt = 0;
for (int h = n; h >= 0; h--) {
cnt += bucket[h];
if (cnt >= h) {
return h;
}
}
return 0;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 3 0 6 1 5 | 3 | 三篇论文至少被引用三次 |
| 1 3 1 | 1 | 只有一篇达到一次 |
| 0 | 0 | 引用数为零,H 指数是零 |
| 100 | 1 | 引用数超过 n 的并进最后一格 |
复杂度。 时间 O(n),建桶扫一遍、从 n 扫一遍;空间 O(n)。跟 O(n²) 的做法相比,省掉的是每一轮都重新数一遍所有论文这件事。
第七题 最大数(179)
题目:给定一组非负整数 nums,重新排列每个数的顺序,使之拼成的数最大,返回这个最大数的字符串形式。
原题链接:leetcode179 最大数
思路。 这题的难点在于谁排前面不是按数值大小决定的。三和三十比,按数值三小,可拼起来是 330 对 303,三应该排在前面。判据要换成拼接之后比较:两个数 x 和 y,如果 xy 拼起来的数比 yx 大,那么 x 就排在 y 前面。这个比较关系满足传递性,可以交给 qsort 用。
实现上,把两个数分别写进字符串,拼成两份,再用 strcmp 比大小。字符串比较的结果要反过来返回,因为 strcmp 是升序语义,而这里要的是拼起来更大的排前面。全部元素都是零的时候要单独处理,否则会输出一长串零。
用例走一遍。 拿 3 30 34 5 9 这组数据走一遍看看。
| 比较的一对 | 拼成 xy | 拼成 yx | 谁在前 |
|---|---|---|---|
| 3 与 30 | 330 | 303 | 3 |
| 3 与 34 | 334 | 343 | 34 |
| 5 与 9 | 59 | 95 | 9 |
| 30 与 34 | 3034 | 3430 | 34 |
按这个规则排完是 9 5 34 3 30,拼起来 9534330,这就是最大的那个数。

图 5 谁放前面拼出来的数更大,谁就排在前面
代码。
c
# 文件:/home/cocatrice/ds15/q07.c
static int CatCmp(const void* x, const void* y)
{
char bx[24], by[24], s1[64], s2[64];
snprintf(bx, sizeof bx, "%d", *(const int*)x);
snprintf(by, sizeof by, "%d", *(const int*)y);
snprintf(s1, sizeof s1, "%s%s", bx, by);
snprintf(s2, sizeof s2, "%s%s", by, bx);
return strcmp(s2, s1);
}
static void LargestNumber(int* a, int n, char* out)
{
qsort(a, (size_t)n, sizeof(int), CatCmp);
out[0] = 0;
if (n > 0 && a[0] == 0) {
strcpy(out, "0");
return;
}
for (int i = 0; i < n; i++) {
char buf[24];
snprintf(buf, sizeof buf, "%d", a[i]);
strcat(out, buf);
}
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 10 2 | 210 | 二排在前 |
| 3 30 34 5 9 | 9534330 | 需要自定义比较器 |
| 0 0 | 0 | 全是零,只输出一个零 |
| 1 | 1 | 单元素直接返回 |
复杂度。 时间 O(n log n) 次比较,每次比较要拼字符串,常数比整数比较大;空间 O(n) 用来放排序后的结果和临时缓冲。暴力做法是把全部排列枚举一遍取最大,n 的上限是十,全排列三百多万种,只能用来校验小数据。
第八题 数组拆分(561)
题目:给定长度为 2n 的整数数组 nums,把这些数分成 n 对,每对取最小值,要求这 n 个最小值的和最大,返回这个最大的和。
原题链接:leetcode 561 数组拆分
思路。 结论是把数组排序之后相邻两两配对,取偶数下标的和。理由用交换论证就能说清:如果一对里的两个数不是相邻的,那么把较大的那个换成紧邻的小数,另一个数就能跟更大的数配对,总和不会变小。重复这个过程,最优解一定会收敛到相邻配对。
所以代码只有两步,排序,累加偶数下标。这道题真正值得看的是暴力解法怎么写,把全部配对方式枚举出来取最大,递归的过程中要记录哪些下标已经用过,每次挑一个没用过的跟后面每一个没用过的配对。
用例走一遍。 拿 1 4 3 2 这组数据走一遍看看。
| 配对方式 | 每对的最小值 | 和 |
|---|---|---|
| 1 与 4、3 与 2 | 1、2 | 3 |
| 1 与 3、4 与 2 | 1、2 | 3 |
| 1 与 2、4 与 3 | 1、3 | 4 |
排序之后是 1 2 3 4,相邻配对得到 1 与 2、3 与 4,最小值之和 1 加 3 等于 4,跟枚举出来的最优值一致。
代码。
c
# 文件:/home/cocatrice/ds15/q08.c
static int ArrayPairSum(int* a, int n)
{
qsort(a, (size_t)n, sizeof(int), CmpInt);
int sum = 0;
for (int i = 0; i < n; i += 2) {
sum += a[i];
}
return sum;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 1 4 3 2 | 4 | 排序后取一和三 |
| 6 2 6 5 1 2 | 9 | 排完是 1 2 2 5 6 6,取 1、2、6 |
| -1 0 | -1 | 负数也成立,取最小值 -1 |
| 长度为二 | 较小的那个 | 只有一对 |
复杂度。 时间 O(n log n),空间 O(1) 额外。暴力做法的复杂度是配对数乘上枚举深度,n 稍大就跑不动,只适合长度十以内的数据。
第九题 有序数组的平方(977)
题目:给定一个按非递减顺序排序的整数数组 nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
原题链接:leetcode 977 有序数组的平方
思路。 平方之后,原本的升序会被负数段打乱,负得越多平方越大。可这件事有个好处,平方值的最大值一定出现在数组的两端,因为负数段从大到小往中间收,正数段也是从大到小往中间收。
于是把游标放在两端,每次比较两端的平方值,大的那个就是当前剩下的最大值,填到结果的最后一个空位,然后把那一端的游标往中间挪一格。填的位置从后往前推,填完整个数组也就排好了,整个过程一次比较一个元素。
用例走一遍。 拿 -4 -1 0 3 10 这组数据走一遍。
| 步 | 左端 | 右端 | 比较 | 填入结果的位置 |
|---|---|---|---|---|
| 1 | -4 的平方 16 | 10 的平方 100 | 右边大 | 第五位 100 |
| 2 | -4 的平方 16 | 3 的平方 9 | 左边大 | 第四位 16 |
| 3 | -1 的平方 1 | 3 的平方 9 | 右边大 | 第三位 9 |
| 4 | -1 的平方 1 | 0 的平方 0 | 左边大 | 第二位 1 |
| 5 | 0 的平方 0 | 0 的平方 0 | 相等取右边 | 第一位 0 |
填完之后结果是 0 1 9 16 100 这五个数。

图 6 有序数组平方:两头比较,大的那个先定位置
代码。
c
# 文件:/home/cocatrice/ds15/q09.c
static int SortedSquares(const int* a, int n, int* out)
{
int i = 0, j = n - 1, k = n - 1;
while (i <= j) {
int l = a[i] * a[i], r = a[j] * a[j];
if (l > r) {
out[k--] = l;
i++;
} else {
out[k--] = r;
j--;
}
}
return n;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| -4 -1 0 3 10 | 0 1 9 16 100 | 负数段的平方倒序 |
| -7 -3 2 3 11 | 4 9 9 49 121 | 两端都有大值 |
| -1 | 1 | 单元素 |
| 全部是负数 | 平方后倒序 | 左端一直最大 |
复杂度。 时间 O(n),每个元素只被访问一次;空间 O(n),结果数组。乘积用 int 存,题目保证平方值不超过 int 的上限,换成更大的值域要用 long long。
第十题 有多少小于当前数字的数字(1365)
题目:给定一个数组 nums,对其中每个元素,返回一个数组,表示 nums 中有多少个数比它小。
原题链接:leetcode 1365 有多少小于当前数字的数字
思路。 暴力是每个元素都扫一遍整个数组,O(n²)。题目限定元素范围在零到一百,于是可以先数一遍每个值出现几次,再求一遍前缀和,前缀和数组里下标 v 的值就是小于等于 v 的元素个数。
有了这张表,nums 里每个元素 numsi 的答案就是前缀和表里下标 numsi 减一的值。唯一要特判的是零,因为减一会越界,而零没有比它更小的数,答案直接是零。
用例走一遍。 拿 8 1 2 2 3 这组数据走一遍看看。
| 值 | 出现次数 | 前缀和,也就是小于等于它的个数 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 1 |
| 2 | 2 | 3 |
| 3 | 1 | 4 |
| 8 | 1 | 5 |
按前缀和表查每一个元素,8 的答案查下标 7,是 4;1 的答案查下标 0,是 0;2 的答案查下标 1,是 1;第二个 2 的答案还是 1;3 的答案查下标 2,是 3。结果是 4 0 1 1 3,与程序输出一致。
代码。
c
# 文件:/home/cocatrice/ds15/q10.c
#define VALUE_MAX 100
static int g_cnt[VALUE_MAX + 1];
static void SmallerNumbers(const int* a, int n, int* out)
{
memset(g_cnt, 0, sizeof g_cnt);
for (int i = 0; i < n; i++) {
g_cnt[a[i]]++;
}
for (int v = 1; v <= VALUE_MAX; v++) {
g_cnt[v] += g_cnt[v - 1];
}
for (int i = 0; i < n; i++) {
out[i] = a[i] == 0 ? 0 : g_cnt[a[i] - 1];
}
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 8 1 2 2 3 | 4 0 1 1 3 | 前缀和表直接查 |
| 6 5 4 8 | 2 1 0 3 | 每个值都不重复 |
| 7 7 7 7 | 0 0 0 0 | 全部相同,谁都数不出更小的 |
| 元素为零 | 0 | 减一会越界,单独处理 |
复杂度。 时间 O(n + 值域),空间 O(值域)。值域一万零一的那组对比数据上,计数路线 1.353 毫秒,qsort 路线 113.553 毫秒。
第十一题 相对名次(506)
题目:给定一个长度为 n 的数组 score 表示运动员的成绩,返回一个长度为 n 的字符串数组,表示每位运动员的排名。前三名分别写成 Gold Medal、Silver Medal、Bronze Medal,其余写成名次的数字。
原题链接:leetcode 506 相对名次
思路。 名次是按成绩降序排出来的位置,所以先把每个成绩和它的原始下标绑在一起,按成绩降序排序,排完之后第 r 个元素的名次就是 r 加一。写回结果的时候不能按下标顺序写,要按这个元素原始的下标写回原位置,这正是把下标一起存起来的原因。
前三名要换成字符串,判断放在写回的时候做,用名次 r 来决定内容,不需要额外排序或者查找。题目保证成绩互不相同,没有平局的处理。
用例走一遍。 拿 10 3 8 9 4 这组数据走一遍看看。
| 排序之后的值 | 原始下标 | 名次 | 写回的内容 |
|---|---|---|---|
| 10 | 0 | 1 | Gold Medal |
| 9 | 3 | 2 | Silver Medal |
| 8 | 2 | 3 | Bronze Medal |
| 4 | 4 | 4 | 4 |
| 3 | 1 | 5 | 5 |
按原始下标排列的结果是 Gold Medal、5、Bronze Medal、Silver Medal、4。
代码。
c
# 文件:/home/cocatrice/ds15/q11.c
typedef struct {
int val;
int idx;
} Node;
static int CmpNode(const void* x, const void* y)
{
const Node* a = (const Node*)x;
const Node* b = (const Node*)y;
if (a->val != b->val) {
return (a->val < b->val) - (a->val > b->val);
}
return a->idx - b->idx;
}
static void FindRelativeRanks(const int* a, int n, char out[][16])
{
Node* nodes = (Node*)malloc(sizeof(Node) * (size_t)n);
for (int i = 0; i < n; i++) {
nodes[i].val = a[i];
nodes[i].idx = i;
}
qsort(nodes, (size_t)n, sizeof(Node), CmpNode);
for (int r = 0; r < n; r++) {
if (r == 0) {
strcpy(out[nodes[r].idx], "Gold Medal");
} else if (r == 1) {
strcpy(out[nodes[r].idx], "Silver Medal");
} else if (r == 2) {
strcpy(out[nodes[r].idx], "Bronze Medal");
} else {
sprintf(out[nodes[r].idx], "%d", r + 1);
}
}
free(nodes);
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 5 4 3 2 1 | Gold Medal、Silver Medal、Bronze Medal、4、5 | 顺序正好逆过来 |
| 10 3 8 9 4 | Gold Medal、5、Bronze Medal、Silver Medal、4 | 名次和下标不同序 |
| 只有一个元素 | Gold Medal | 最短的输入 |
| 成绩极小或极大 | 不影响 | 只比较大小,不做算术 |
复杂度。 时间 O(n log n),一次排序;空间 O(n),存放节点的数组。字符串数组的每一格要开十六字节,因为 Bronze Medal 加结束符正好十二字节,再留一点余量。
第十二题 三个数的最大乘积(628)
题目:给定一个整型数组 nums,从中找出三个数,使它们的乘积最大,返回这个乘积。
思路。 把数组排序之后,最大的乘积只有两种可能,三个最大的数相乘,或者两个最小的数乘上最大的那个数。之所以只有这两种,是因为要让乘积尽量大,要么三个正数,要么两个负数配一个正数,负数再多的话符号会变负,所以只需要考虑绝对值最大的那两三个负数。
这两种情况取较大者就是答案,两头各算一次。排序之后这两种组合都落在数组的两头,写起来就是几个下标的事情。
用例走一遍。 输入 -100 -98 -1 2 3 4。
| 组合 | 乘积 |
|---|---|
| 最后三个 2、3、4 | 24 |
| 前两个加最后一个 -100、-98、4 | 39200 |
两种组合里 39200 更大,返回 39200。这个用例是这道题的关键,只看最大的三个数会得到 24,漏掉了负负得正这条路。
代码。
c
# 文件:/home/cocatrice/ds15/q12.c
static int MaximumProduct(int* a, int n)
{
qsort(a, (size_t)n, sizeof(int), CmpInt);
int p1 = a[n - 1] * a[n - 2] * a[n - 3];
int p2 = a[0] * a[1] * a[n - 1];
return p1 > p2 ? p1 : p2;
}
边界值。
| 输入 | 结果 | 说明 |
|---|---|---|
| 1 2 3 | 6 | 只有三个数,直接乘 |
| -1 -2 -3 | -6 | 全是负数,取绝对值最小的三个 |
| -100 -98 -1 2 3 4 | 39200 | 两个最小负数乘最大正数 |
| 含零 | 零或正数 | 排序后两头取大,零自然不会赢 |
复杂度。 时间 O(n log n),空间 O(1) 额外。暴力三重循环是 O(n³),用来校验小数据;三个数的乘积可能超出 int,题目保证结果在 int 范围内。
踩坑点
- 高度检查器的计数表从一到一百,下标零那一格空着不用,写成从零开始会把身高为零的非法值也数进去。
- 数组的相对排序里 arr2 只是顺序的约定,arr1 里没出现过的值不会因为 arr2 里没有就被漏掉,第二段扫描要覆盖整个值域。
- 字母异位词用一张表做加减,不要开两张表再逐格比较,开两张表会在长度不同的时候多一次提前返回的判断。
- 两个数组的交集要先排序再双指针,如果改用标记数组去重,值域大了就退化成计数排序,反而比排序更占内存。
- 交集的去重判断写在上一个写出去的值上,不要写成判断这个值在结果里有没有出现过,后者是线性的查找。
- 最大间距的基数排序要按无符号数处理,带符号的右移会把负数的符号位一起带进来,桶号全乱。
- 基数排序的两块缓冲区大小都按元素个数算,第一趟结束时指针交换,第二趟的输入是上一趟的输出,交换的时候三个指针要一起换。
- H 指数的桶长度是 n 加一,下标 n 那一格专门放引用数超过 n 的那些,漏掉这一格会让 H 指数偏大。
- H 指数从 n 往下扫、每格累加后立刻判断,条件写成累加篇数不小于当前下标,反过来写会得出完全错的结果。
- 最大数的比较器要把 strcmp 的结果反过来返回,忘了取反得到的是最小排列。
- 最大数要特判全是零的情况,否则会输出一长串零,而答案只应该是一个零。
- 数组拆分的暴力枚举用递归,配对的时候要给已经用过的下标做标记,忘了回溯会让后面的分支提前返回。
- 暴力枚举的初始最优值不能写成负一,负数数据上负一永远比真实答案大,会让整个枚举白跑,这一条是实测里真的踩到的,改成负一百万之后三百组数据的核对才全部一致。
- 有序数组的平方从后往前填结果,循环条件写成 i 小于等于 j,写成小于会漏掉中间那个元素。
- 有多少小于当前数字的数字,前缀和查表要查下标减一,查下标本身得到的是小于等于而不是小于。
- 相对名次的写回要按原始下标写,按下标顺序写回会让名次和运动员错位。
- 只比成绩不记下标的话,排序之后原始下标就找不回来了,所以排序前要把下标一起存进节点。
- 三个数的最大乘积要把两个最小负数的情况算进去,只取最后三个数会在含负数的数据上出错。
本篇总结(模拟面试问题)
问:给一个数组,要求返回每个元素左边或右边有多少个比它小的数,复杂度要求线性,怎么做?
核心要点:看值域。如果值域在能开表的范围内,用一张计数表数出现次数,求前缀和,之后每个元素的答案都是一次数组访问。值域大到开不下表的时候,先离散化把值域压到 n 以内再建表,顺序按大小关系保留下来,答案不受影响。
问:最大间距为什么不能用比较排序?
核心要点:题目把时间和空间都卡在线性,而基于比较的排序下界是 O(n log n),所以必须换一条路。这里用的是值域,三十二位整数按十六位一段拆两趟,每趟用六万五千个桶做一次分配和收集,两趟之后有序,整个过程的复杂度是 O(n + 65536),与 n 是同阶的。
问:数组的相对排序里,怎么保证没有在 arr2 里出现过的元素按升序排在后面?
核心要点:第一部分按 arr2 的顺序从计数表里取值,取完的值计数归零;第二部分从零到值域上界扫一遍计数表,把还剩的值按顺序放进去。因为第二段是按下标升序扫的,所以自动满足升序要求,不需要额外再排一次。
问:两个数组的交集,为什么要先排序再扫?
核心要点:排序之后,两个数组里相等的元素一定会同时出现在同一个扫描位置上,于是可以用两个游标一路往前比较。没排序的话,判断一个值在另一个数组里有没有出现过,要么用哈希表,要么退化成嵌套循环,前者要额外空间,后者是平方复杂度。
问:数组拆分为什么排序之后隔一个取一个就是最优的?
核心要点:用交换论证。假设最优解里有一对不是相邻的,那么把较大的那个换成与较小者紧邻的数,另一个数就能跟更大的数配对,总和不会变小。反复做这个替换,最优解一定可以变成相邻配对的形式,而相邻配对只有一种,取偶数下标的和。
问:H 指数的桶为什么要开到 n 加一,而不是按最大引用数开?
核心要点:n 篇论文的 H 指数不可能超过 n,所以引用数超过 n 的论文可以合并成一个类别看待,它们在桶里的作用完全一样,就是被引用次数足够多。桶开到 n 加一,扫描范围固定在 n 以内,复杂度就是 O(n) 而不是 O(最大引用数),后者可能是十亿级。
问:最大数的比较关系为什么可以直接交给 qsort?
核心要点:把 xy 拼起来的数比 yx 大定义为 x 排在 y 前面,这个关系满足传递性,所以它是一个全序关系,可以直接作为比较器的返回值。传递性的严格证明要用到字符串拼接的性质,面试里能说出拼接比较构成全序这一点就够了。
问:有序数组的平方为什么能从两端取?
核心要点:原数组是升序的,负数段平方之后从大到小,正数段平方之后也是从大到小,所以平方值的最大值只可能出现在左端或者右端。每次取两端较大的那个填到结果末尾,然后收窄那一端,一次比较定一个位置。
参考
- 计数排序与前缀和的应用:https://oi-wiki.org/basic/counting-sort/
- 基数排序的分配与收集过程:https://oi-wiki.org/basic/radix-sort/
- 双指针技巧的常见形态:https://oi-wiki.org/misc/two-pointer/
- 排序算法稳定性的定义与判断:https://oi-wiki.org/basic/sort-intro/
- 自定义比较器与全序关系:https://en.cppreference.com/w/cpp/named_req/Compare
- qsort 的手册页,比较器函数签名的依据:https://man7.org/linux/man-pages/man3/qsort.3.html