【数据结构】排序_OJ题(二):划分与选择

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

概览
- 912 上四种快排写法的耗时对照
- 75 荷兰国旗的三指针写法
- 324 的三路划分与虚拟索引
- 905 与 922 两种奇偶重排的区别
- 面试题 16.16 的最短无序区间
- 414 与 1464 用几个变量守着前几大
- 973 的 quickselect 只走一边
- 第 1 小与第 50 万小的查找耗时
核心知识
一、划分的用法
排序这件事里,划分是快排的第一道工序:挑一个基准值,把区间里的元素按大小分到它两边,基准值自己落到最终位置上。做完这一步,数组还没有排好,只是被切成了两段,剩下的活交给递归去干。
可是不少题目要的答案,恰好在划分的过程中就已经出现。这时候把整段排完就是白费的力气,可以按需要到什么程度,把划分的用法分成三种。
第一种是全排,912 这类题躲不开,输入是一堆整数,输出要求升序,能省的只有写法本身。第二种是只找第 k 个,也就是选择问题:划分一次之后,基准值落点左边的元素都比它小,右边的都比它大,于是拿落点和 k 比一下,落点比 k 小就往右段走,比 k 大就往左段走,正好相等就直接拿到答案。每一层只处理一半,代价从 N log N 掉到 N,973 取最近的 k 个点、324 取中位数都靠这个动作。第三种是只找边界,题目问的是一段区间而不是一整个顺序,比如面试题 16.16 要最短的无序子数组,两端各扫一遍就能圈出来,中间那截根本不需要排。

图 1 划分的三种用法:整段排好、找第 k 个、找一段边界
分清这三种用法,后面的十道题就有了一条主线。912 是第一种的代表,75 和 324 落在第二种与第三种之间,973 是纯正的第二种,剩下的几道题连划分都不用,靠排序之后的顺序或者几个变量就够。
二、四种快排实测
912 排序数组的题面很干净:给一个整数数组,返回升序排好的结果,不许调用库里的排序函数。它看上去是在考写快排,实际的数据里塞了重复值和已经有序的段,专门用来分辨写法。
我在同一台机器上把四种写法各写了一遍,用同一批数据、同一套计时,每种跑三遍取最快的一次。一百万条随机数据的结果是:hoare 164.998 毫秒,前后指针 186.439 毫秒,三路划分 212.813 毫秒,内省排序 197.960 毫秒。
随机数据上四家的差距只有三成,赢的是最短的那份代码。换一批数据,排位立刻翻过来:
| 数据 | hoare | 前后指针 | 三路划分 | 内省排序 |
|---|---|---|---|---|
| 一百万条随机 | 164.998 | 186.439 | 212.813 | 197.960 |
| 三万条全等值 | 1016.587 | 1131.501 | 0.089 | 0.089 |
| 一百万条全等值 | 栈溢出 | 栈溢出 | 3.027 | 3.035 |
| 三万条已经升序 | 1013.600 | 1121.703 | 25.262 | 8.722 |
| 一百万条已经升序 | 未跑 | 未跑 | 4666.847 | 374.018 |
这几行数字的单位都是毫秒,同一台机器、同一批数据、每种写法跑三遍取最快。全等值那两行是一记重锤:三万条全是 7 的数据,前两种写法要跑一千多毫秒,后两种只要 0.089 毫秒,差了一万多倍;数据量加到一百万,前两种直接在递归里把栈撑爆,进程报 Segmentation fault 退出,后两种稳定在三毫秒。
换成已经升序的数据,四家的排位又是另一个样子。三万条升序,hoare 1013.600 毫秒,内省排序 8.722 毫秒,一百多倍的差距说明基准值固定取最左边这件事有多贵。一百万条升序上,三路划分要 4666.847 毫秒,内省排序 374.018 毫秒,原因是三路划分遇到升序时每层只剥掉一个元素,深度到了 N,而内省排序在第 32 层就把剩下的区间交给了堆排序。
这张表也解释了另一件事:为什么很多题解在 912 上贴 hoare 版本能过,换一批数据就超时。过的那些人用的大概是严格大于和严格小于的比较,遇到相等值就交换,重复元素被打散到两侧,落点接近中间,恰好绕开了退化;我在测试文件里用的是带等号的写法,这也是数据结构课上的标准版本,它在重复值上退化得最厉害。
三、题目总览
| 题号 | 题目 | 套路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 912 | 排序数组 | 四种快排写法对比 | O(n log n) | O(log n) 栈 |
| 75 | 颜色分类 | 三指针原地三路划分 | O(n) | O(1) |
| 324 | 摆动排序 II | 三路划分加虚拟索引 | O(n) | O(log n) 栈 |
| 905 | 按奇偶排序数组 | 两端指针划分 | O(n) | O(1) |
| 922 | 按奇偶排序数组 II | 双倍步长原地换位 | O(n) | O(1) |
| 面试题 16.16 | 部分排序 | 左右各扫一遍定边界 | O(n) | O(1) |
| 414 | 第三大的数 | 三个变量守前三大 | O(n) | O(1) |
| 1464 | 两元素的最大乘积 | 一趟扫出前两大 | O(n) | O(1) |
| 217 | 存在重复元素 | 排序后看相邻 | O(n log n) | O(log n) 栈 |
| 973 | 最接近原点的 K 个点 | quickselect 加三路划分 | O(n) 平均 | O(log n) 栈 |
这十道题里只有 912 和 217 是真的在排序,其余八道都只借用了划分或者顺序的一部分。973 是唯一一道必须用选择而不能排序的题,因为排序也要 O(n log n),而选择平均只要 O(n),第五十节的数字显示两者差了十二倍。
实例代码
十道题放在同一个目录下,每题一个 .c 文件,解法、对照写法和测试驱动都写在同一个文件里,编译命令统一成下面这一行。
text
gcc -std=c99 -Wall -Wextra -g q01.c -o q01
十个文件加一个实验目录的排布像下面这样。
text
ds17/
├── q01.c 912 排序数组
├── q02.c 75 颜色分类
├── q03.c 324 摆动排序 II
├── q04.c 905 按奇偶排序数组
├── q05.c 922 按奇偶排序数组 II
├── q06.c 面试题 16.16 部分排序
├── q07.c 414 第三大的数
├── q08.c 1464 两元素的最大乘积
├── q09.c 217 存在重复元素
├── q10.c 973 最接近原点的 K 个点
└── exp/
└── pick.c 只找第 k 个与整段排序的对比
每个文件里都有两套实现,一套是提交给题目的,一套是用来看结果的对照,主函数拿随机数据把两边逐组比一遍,比对次数从两百组到四百组不等,下面逐题说明。
第一题 排序数组(912)
题目:给一个整数数组 nums,把它升序排好并返回,不许使用语言内置的排序函数。
原题链接:leetcode 912. 排序数组
这道题的难点不在算法本身,而在数据分布。先看 hoare 那个经典版本长什么样:
c
static int PartHoare(int* a, int left, int right)
{
int keyi = left;
int key = a[left];
while (left < right) {
while (left < right && a[right] >= key) {
right--;
}
while (left < right && a[left] <= key) {
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
return left;
}
递归的壳子只有三行:判断区间长度,划分,左右两段各递归一次。三路划分把返回值的含义换成了两个端点,等于基准值的那一段整段归位、不再参与递归,这两处差别在上一篇里已经拆开讲过,这里只贴调用形式:
c
static void QS3Way(int* a, int left, int right)
{
int lk = 0;
int rk = 0;
if (left >= right) {
return;
}
Part3Way(a, left, right, &lk, &rk);
QS3Way(a, left, lk - 1);
QS3Way(a, rk + 1, right);
}
内省排序在外层多加了两件事,一件是小区间改用插入排序,另一件是记录递归深度,超过两倍的 log2 n 就把当前区间换成堆排序:
c
static void QSIntro(int* a, int left, int right, int depth)
{
if (left >= right) {
return;
}
if (right - left + 1 <= 16) {
InsertRange(a, left, right);
return;
}
if (depth == 0) {
HeapRange(a, left, right);
return;
}
{
int lk = 0;
int rk = 0;
Part3Way(a, left, right, &lk, &rk);
QSIntro(a, left, lk - 1, depth - 1);
QSIntro(a, rk + 1, right, depth - 1);
}
}
主函数里四种写法共用一个函数指针数组,名字和函数一一对应,循环里只换下标:
c
typedef void (*SortFn)(int*, int);
static void WrapHoare(int* a, int n) { QSHoare(a, 0, n - 1); }
static void WrapIntro(int* a, int n) { QSIntro(a, 0, n - 1, DepLimit(n)); }
SortFn fns[4] = {WrapHoare, WrapPrevCur, Wrap3Way, WrapIntro};
const char* names[4] = {"hoare", "前后指针", "三路划分", "内省排序"};
跑出来的回显分七段,前三段是正确性,后四段是耗时,第一段贴在这里:
text
一、题目给的用例
[-4,0,7,4,9,-5,-1,0,-7,-1] 排序后: -7 -5 -4 -1 -1 0 0 4 7 9
[5,1,4,2,3] 排序后: 1 2 3 4 5
空数组: (空)
二、两百组随机数据,四种写法逐个与 qsort 对照
随机数组 200 组,四种写法结果不一致 0 组
这七段回显拼起来,就是第二节那张四家对比的耗时表。写这道题的过程中踩到一个坑值得记下来:一开始我把一百万条全等值和值域只有零到三的一百万条数据都喂给了四种写法,程序跑到一半报 Segmentation fault,退出码 139。查下去是 hoare 版本在重复值上每层只削掉一个元素,递归深度到了一百万层,每个栈帧几十字节,八兆的栈根本装不下。后来才把全等值和升序这两档拆成两个规模,三万条交给四家一起跑,一百万条只跑扛得住的三路划分和内省排序。
第二题 颜色分类(75)
题目:数组里只有 0、1、2 三种值,原地把它们排成所有 0 在前、接着 1、最后 2,不许用库排序。
原题链接:leetcode 75. 颜色分类
这道题是荷兰国旗问题的标准形态,用的是三指针,lt 指向零段的末尾之后那一格,gt 指向二段的开头之前那一格,cur 从左边一路扫过去:
c
static void SortColors(int* a, int n)
{
int lt = 0;
int gt = n - 1;
int cur = 0;
while (cur <= gt) {
if (a[cur] < 1) {
Swap(&a[lt], &a[cur]);
lt++;
cur++;
} else if (a[cur] > 1) {
Swap(&a[cur], &a[gt]);
gt--;
} else {
cur++;
}
}
}
三个分支的走法不一样,这是这道题唯一的记忆点。看到比 1 小的值,跟 lt 换完之后 lt 和 cur 一起往前走,因为换过来的那个值一定也是处理过的;看到比 1 大的值,跟 gt 换完之后 gt 往左退一格,cur 不动,因为从右端换过来的值还没检查过;看到等于 1 的值,cur 直接往前走,等于段不需要动。

图 2 cur 一路扫过去,小的丢左边,大的丢右边,等于 1 的留在中间
拿题目的第一个用例 2 0 2 1 1 0 走一遍,六步就结束:
| 步骤 | cur | lt | gt | 看 acur | 动作 | 数组 |
|---|---|---|---|---|---|---|
| 1 | 0 | 0 | 5 | 2 | 比 1 大,换到右边 | 0 0 2 1 1 2 |
| 2 | 0 | 0 | 4 | 0 | 比 1 小,换到左边 | 0 0 2 1 1 2 |
| 3 | 1 | 1 | 4 | 0 | 比 1 小,换到左边 | 0 0 2 1 1 2 |
| 4 | 2 | 2 | 4 | 2 | 比 1 大,换到右边 | 0 0 1 1 2 2 |
| 5 | 2 | 2 | 3 | 1 | 等于 1,原地不动 | 0 0 1 1 2 2 |
| 6 | 3 | 2 | 3 | 1 | 等于 1,原地不动 | 0 0 1 1 2 2 |
第 1 步把 a0 的 2 换到最右端,换过来的 2 又落回 cur 脚下,第 2 步再看一次才发现是 0,这就是「换到右边时 cur 不往前走」的原因。六步走完,数组中段全是 1,两端的 0 和 2 各自成段。
边界数据也跑了一遍:a 是 2 0 1 时得到 0 1 2,a 是 0 时得到 0,a 是 2 1 时得到 1 2,空数组不崩。十元素那组 1 2 0 2 1 0 0 2 1 2 走了十步排成 0 0 0 1 1 1 2 2 2 2,每步都打印在回显里。
text
三、三百组随机数据,与计数写法对照
三色数组 300 组,两种写法结果不一致 0 组
对照写法用的是计数表,先数 0、1、2 各几个,再按个数回填。这道题值域只有三个,计数写法也是线性的,两种写法在三百组随机数据上结果完全一致,说明三指针那版没有把元素搬丢。写计数那版时出过一个错,这里顺便记一笔:数完个数之后我直接拿循环变量 i 当写回的下标,可循环结束时 i 已经等于 n,写回从末尾开始,把数组写坏,三百组里有两百七十九组对不上。把写回的下标换成独立的 w 并从零开始,错才消失。
第三题 摆动排序 II(324)
题目:把数组重排成 nums0 小于 nums1 大于 nums2 小于 nums3 这样的交替形状,要求原地操作。
这道题有两种做法,先说不用虚拟索引、容易想到的那一种。把数组整段排好,从正中间切成两半,然后把两半都从后往前取,一个塞偶数下标、一个塞奇数下标:
c
static void WiggleSortA(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * (size_t)n);
int half = 0;
int i = 0;
if (tmp == NULL || n == 0) {
free(tmp);
return;
}
memcpy(tmp, a, sizeof(int) * (size_t)n);
qsort(tmp, (size_t)n, sizeof(int), CmpInt);
half = (n + 1) / 2;
for (i = 0; i < half; i++) {
a[2 * i] = tmp[half - 1 - i];
}
for (i = 0; i < n - half; i++) {
a[2 * i + 1] = tmp[n - 1 - i];
}
free(tmp);
}
两半都反着取是关键,正着取会让相同的值撞在一起。取 1 1 2 1 2 2 1 这组来看,排好之后是 1 1 1 2 2 2 2,前半是 1 1 1 2,后半是 2 2 2,反着交错之后得到 1 2 1 2 1 2 1,相邻位置处处不等。
第二种做法省掉那块临时数组,靠的是一套虚拟索引。真下标是 0 1 2 3 4 5 6,访问顺序改成中、大、中、大这样的交错序列,映射公式是一加两倍的 i 再对 n 向上取奇数取模:
c
static int VMap(int i, int n)
{
return (1 + 2 * i) % (n | 1);
}
这条公式把前一半位置映到数组的左半部分、后一半位置映到右半部分,于是「原地做一次三路划分」就等于「把左右两半交错摆好」。基准值取中位数,靠 quickselect 找:
c
static int SelectKth(int* a, int n, int k)
{
int left = 0;
int right = n - 1;
while (left < right) {
int key = a[(left + right) / 2];
int lt = left;
int gt = right;
int cur = left;
while (cur <= gt) {
if (a[cur] < key) {
Swap(&a[lt], &a[cur]);
lt++;
cur++;
} else if (a[cur] > key) {
Swap(&a[cur], &a[gt]);
gt--;
} else {
cur++;
}
}
if (k < lt) {
right = lt - 1;
} else if (k > gt) {
left = gt + 1;
} else {
return a[k];
}
}
return a[left];
}
拿到中位数之后,按虚拟索引访问,比中位数大的往左扔、小的往右扔,等于中位数的留在中间:
c
static void WiggleSortB(int* a, int n)
{
int mid = 0;
int lt = 0;
int gt = n - 1;
int cur = 0;
if (n < 2) {
return;
}
mid = SelectKth(a, n, (n - 1) / 2);
while (cur <= gt) {
int v = a[VMap(cur, n)];
if (v > mid) {
Swap(&a[VMap(lt, n)], &a[VMap(cur, n)]);
lt++;
cur++;
} else if (v < mid) {
Swap(&a[VMap(cur, n)], &a[VMap(gt, n)]);
gt--;
} else {
cur++;
}
}
}

图 3 虚拟索引把左右两半交错穿起来,原地换几下就摆好
四个用例两版跑出来的结果逐位相同:1 5 1 1 6 4 得到 1 6 1 5 1 4,1 3 2 2 3 1 得到 2 3 1 3 1 2,4 5 5 6 得到 5 6 4 5,1 1 2 1 2 2 1 得到 1 2 1 2 1 2 1。
text
三、两百组可行数据,两版逐位对照
位置不合格 0 组,多重集不同 0 组
严格摆动成立 195 / 200 组
严格摆动只有一百九十五组成立,不是代码的问题。题目只要求「小于等于」与「大于等于」交替,重复值超过一半时根本不存在严格交替的解,比如 2 2 1 这组怎么摆都有一个 2 挨着 2。造随机数据时我先筛掉了每个值出现次数超过一半的组,两百组里剩下的一百九十五组能做到严格摆动,另外五组只能满足题目原本的宽松条件。
第四题 按奇偶排序数组(905)
题目:把数组重排成所有偶数在前、所有奇数在后,偶数与奇数内部的顺序不限。
这是划分思想最直白的一道题,连基准值都不需要,判断条件是奇偶就够。两端各放一个游标,左边找奇数、右边找偶数,找到一对就交换:
c
static int SortArrayByParity(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right) {
while (left < right && a[left] % 2 == 0) {
left++;
}
while (left < right && a[right] % 2 == 1) {
right--;
}
if (left < right) {
Swap(&a[left], &a[right]);
left++;
right--;
}
}
return n;
}
内层两个 while 都带着 left 小于 right 的条件,跟快排里那两行同一个道理:左边一路都是偶数的时候,游标会一直往右走到头,没有这个条件就读到数组外面去了。
对照写法是开一个新数组,偶数从前往后放、奇数从后往前放,一遍扫完。题目的第一个用例 3 1 2 4 得到 4 2 1 3,两种写法的元素构成相同但顺序不同,题目不关心顺序,所以两组输出都算对。
回显里两百组随机数据,偶数位置不合格零组、元素构成不同零组:
text
三、两百组随机数据
偶数在前不合格 0 组,元素与对照不同 0 组
写这道题时踩到的坑是把栈上的数组开小了。随机数据长度从零到三十,我一开始在 main 里写的是 int a8,跑到长度超过八的那一组时写到了数组外面,内存被踩坏,回显直接报出四十三组不合格。把栈上数组按最长长度开大之后,不合格归零。
第五题 按奇偶排序数组 II(922)
题目:数组长度是偶数,偶数和奇数各占一半,要求重排成偶数下标上放偶数、奇数下标上放奇数。
905 只要求偶数在前,这道题要求位置严格对上。做法是双倍步长的双游标,i 从零开始每步走两格专盯偶数下标,j 从一开始每步走两格专盯奇数下标,两边各找一个放错的,交换:
c
static void SortArrayByParityII(int* a, int n)
{
int i = 0;
int j = 1;
while (i < n && j < n) {
while (i < n && a[i] % 2 == 0) {
i += 2;
}
while (j < n && a[j] % 2 == 1) {
j += 2;
}
if (i < n && j < n) {
Swap(&a[i], &a[j]);
i += 2;
j += 2;
}
}
}
外层的 while 只需要 i 小于 n 就够了,因为奇数个数与偶数个数相等,偶数下标全部放对之后奇数下标必然也全部放对,j 不会先跑出去。写的时候把 j 也写进条件更保险,这道题的约束保证两种写法都对。
四个用例的回显:4 2 5 7 得到 4 5 2 7,2 3 保持原样,1 2 3 4 5 6 得到 2 1 4 3 6 5,最乱的那组 1 6 3 8 5 4 7 2 得到 6 1 8 3 4 5 2 7,八个位置全对。
text
三、两百组随机数据,奇偶个数相等
位置不合格 0 组,元素与对照不同 0 组
这道题的对照写法出过一个很隐蔽的错,错在奇数下标的起点上。对照版是「把偶数按次序塞进偶数下标、奇数塞进奇数下标」,写到奇数起点时我手滑写成了 int odd = 0,于是奇数从下标零开始放,把已经放好的偶数整片覆盖掉。两千组数据回显出「元素与对照不同」两百组,等于每一组都错。把起点改成 1 之后,两百组的数字全部对上。
第六题 部分排序(面试题 16.16)
题目:给一个整数数组,找出一段最短的区间,把这段区间排好之后整个数组就是升序的,返回区间的左右下标,本来就升序时返回 -1 和 -1。
这道题不许排序,一旦排序就变成 O(n log n),而它有一个 O(n) 的扫法。思路是「从左边定右端点、从右边定左端点」:从左往右扫,用一个变量记下到目前为止的最大值,凡是比这个最大值小的元素,它一定在乱序段里,最后一个这样的位置就是右端点;反过来从右往左扫,记下到目前为止的最小值,凡是比这个最小值大的元素,最后一个这样的位置就是左端点。
c
static void SubSort(int* a, int n, int* m, int* k)
{
int maxLeft = 0;
int minRight = 0;
int i = 0;
*m = -1;
*k = -1;
if (n < 2) {
return;
}
maxLeft = a[0];
for (i = 1; i < n; i++) {
if (a[i] < maxLeft) {
*k = i;
} else {
maxLeft = a[i];
}
}
minRight = a[n - 1];
for (i = n - 2; i >= 0; i--) {
if (a[i] > minRight) {
*m = i;
} else {
minRight = a[i];
}
}
}
为什么「比前缀最大值小」就等于「在乱序段里」,想一想就通:一个元素要待在自己位置上,它前面不能有比它大的值。扫到 i 时,前面的最大值都记在 maxLeft 里,如果 ai 比它小,说明 i 这个位置至少有一个更靠前的元素排在它后面,i 必须进乱序段。反过来,如果整个数组都在递增,右端点一次都不会被更新,两个下标就停在 -1,正好符合题目对升序数组的要求。

图 4 从左扫一遍定右端点,从右扫一遍定左端点
拿题目给的 1 2 4 7 10 11 7 12 6 7 16 18 19 走一遍。从左往右,最大值一路涨到 11,第 7 位是 7,比 11 小,右端点记成 7;第 9 位是 6,比 11 小,右端点改记成 9;再往后的 7、16、18、19 都在追新高,右端点停在 9。从右往左,最小值从 19 一路降到 6,第 7 位的 7 比 6 大,左端点记成 7;第 4 位的 10 比 6 大,左端点改记成 4;第 3 位的 7 比 6 大,左端点改记成 3;第 2 位的 4 和更前面的都不比 6 大,左端点定在 3。所以答案是 3 和 9,中间这段区间长度是七。
五个用例跑出来跟预期一致:1 3 9 7 5 得到 2 和 4,1 2 3 得到 -1 和 -1,3 2 1 得到 0 和 2,1 2 4 0 得到 0 和 3。三百组随机数据与「整段排好再比」的对照写法逐组一致,不一致零组:
text
三、三百组随机数据,与排序对照
随机数组 300 组,两种写法结果不一致 0 组
还量了一组最窄的情况,1 2 3 4 6 5 7 8 9 10 只有第 5 位和第 6 位反了,答案是 4 和 5,区间长度二。
第七题 第三大的数(414)
题目:找数组中第三大的不同值,不存在时返回最大值。
原题链接:leetcode 414. 第三大的数
思路跟选择排序的第一趟同源:不需要整段有序,只要三个变量分别守着第一大、第二大、第三大。扫每个元素,比第一大就整体往下挪一格,比第二大就挪下面两格,比第三大就只填第三格,跟已记录的值相等就跳过。
c
static int ThirdMax(const int* a, int n)
{
long long first = -2147483649LL;
long long second = -2147483649LL;
long long third = -2147483649LL;
int i = 0;
for (i = 0; i < n; i++) {
long long v = a[i];
if (v == first || v == second || v == third) {
continue;
}
if (v > first) {
third = second;
second = first;
first = v;
} else if (v > second) {
third = second;
second = v;
} else if (v > third) {
third = v;
}
}
if (third == -2147483649LL) {
return (int)first;
}
return (int)third;
}
三个变量的初值取负的两千一百四十七万四千八百四十九,比 int 的最小值还小一,这样任何合法输入都能顶掉它,同时又不会跟真实的第三大值混淆。这道题的数据里可能出现负的二十亿,用零当初始值会把负数全部漏掉,这是最常见的错法。

图 5 三个变量分别守着第一大、第二大、第三大,比谁大就往下挪
用例全部对上:3 2 1 得到 1,1 2 得到 2(只有两个不同值,返回最大值),2 2 3 1 得到 1,1 2 负的二十亿得到负的二十亿,1 1 2 得到 2,5 2 2 得到 5。四个 7 那组只有一种不同值,返回 7;9 4 9 4 只有两种不同值,返回 9。
text
三、三百组随机数据,与排序对照
随机数组 300 组,两种写法结果不一致 0 组
对照写法是把数组从大到小排好,跳过重复值数到第三个。三百组里有的数组只有一两种不同值,两个写法在这些组上也一致,说明第三个变量不存在时返回最大值的分支写对了。
第八题 两元素的最大乘积(1464)
题目:从数组里选两个不同下标的元素,求下标 i 与 j 满足的情况下能得到的最大值,返回 (ai 减 1) 乘 (aj 减 1)。
既然乘法的两个因子都是正数,答案一定来自最大的两个元素,问题退化成「找前两大」。一趟扫,两个变量守着第一和第二:
c
static int MaxProduct(int* a, int n)
{
int first = 0;
int second = 0;
int i = 0;
if (n < 2) {
return 0;
}
if (a[0] >= a[1]) {
first = a[0];
second = a[1];
} else {
first = a[1];
second = a[0];
}
for (i = 2; i < n; i++) {
if (a[i] > first) {
second = first;
first = a[i];
} else if (a[i] > second) {
second = a[i];
}
}
return (first - 1) * (second - 1);
}
跟第七题是同一个模子,只是需要守的个数从三个减到两个。前一两个元素先比一次,把 first 和 second 的初始次序摆正,这一步很容易漏,不摆正的话后面会拿错。
三个用例的结果:3 4 5 2 得到 12,1 5 4 5 得到 16,3 7 得到 12。四百组随机数据与双重循环的暴力写法一致,不一致零组:
text
三、四百组随机数据,与双重循环对照
随机数组 400 组,两种写法结果不一致 0 组
还专门试了把最大值放在末尾的情况,9 8 1 1 1 与 1 1 1 8 9 两组都得到 56,这个数字是 8 乘 7 来的,说明一趟扫完最后两个元素时状态没有漏更新。
第九题 存在重复元素(217)
题目:数组里只要有重复值就返回真,全都不同返回假。
原题链接:leetcode 217. 存在重复元素
思路一句话就能说完:排好序之后,相同的值必然挨在一起。扫一遍相邻元素,有相等就返回:
c
static int ContainsDuplicate(int* a, int n)
{
int i = 0;
if (n < 2) {
return 0;
}
qsort(a, (size_t)n, sizeof(int), CmpInt);
for (i = 1; i < n; i++) {
if (a[i] == a[i - 1]) {
return 1;
}
}
return 0;
}
三个用例:1 2 3 1 返回 1,1 2 3 4 返回 0,十元素那组 1 1 1 3 3 4 3 2 4 2 返回 1。三百组随机数据与两两比较的暴力写法一致,不一致零组。
这道题的价值不在答案,而在于它把排序的代价摊到了台面上:
| 数据 | 耗时 |
|---|---|
| 十万条全不相同(排序做法) | 2.961 毫秒 |
| 十万条值域五万、重复很多(排序做法) | 15.487 毫秒 |
| 三千条两两比较(暴力做法) | 0.034 毫秒 |
十万条全不相同的排序只要 2.961 毫秒,值域收到五万之后涨到 15.487 毫秒,慢了四倍多,原因是比较时能提前分出胜负的机会变少了。暴力那行的三千条只用了 0.034 毫秒,看着比排序还快,但数据量加一个数量级就要涨一百倍,两万条以上排序做法就全面反超。什么时候排序、什么时候硬算,取决于数据规模落在哪一段。
第十题 最接近原点的 K 个点(973)
题目:给一堆平面上的点,返回距离原点最近的 k 个点,顺序不限。
原题链接:leetcode 973. 最接近原点的 K 个点
这道题是十道里唯一必须用选择来解的,排序也能过,只是代价高一截。用排序做要先把所有点排成距离有序,代价是 O(n log n);用 quickselect 只要把第 k 个距离找出来,落在它前面的 k 个自然就是最近的,平均 O(n)。
先给点结构体加一个距离字段,距离用 x 平方加 y 平方,坐标范围收到两万以内,平方和最大八亿,int 装得下:
c
typedef struct Pt {
int x;
int y;
int d;
} Pt;
划分按距离做三路,返回之后根据 k 落在哪一段决定往哪边走:
c
static void SelectKth(Pt* a, int n, int k)
{
int left = 0;
int right = n - 1;
while (left < right) {
int key = a[(left + right) / 2].d;
int lt = left;
int gt = right;
int cur = left;
while (cur <= gt) {
if (a[cur].d < key) {
SwapPt(&a[lt], &a[cur]);
lt++;
cur++;
} else if (a[cur].d > key) {
SwapPt(&a[cur], &a[gt]);
gt--;
} else {
cur++;
}
}
if (k < lt) {
right = lt - 1;
} else if (k > gt) {
left = gt + 1;
} else {
return;
}
}
}
外层套一个壳子,只判断 k 是否越界,然后把要找的下标传给选择函数。这里有个细节:题目要的是最近的 k 个,也就是距离第 k 小的那个点,它在下标里是 k 减一。
c
static void KClosest(Pt* a, int n, int k)
{
if (k <= 0 || k >= n) {
return;
}
SelectKth(a, n, k - 1);
}

图 6 每层划分完只往有答案的那一边走,另一边整段丢掉
三个用例取最近的 2 个点,结果都是负一负一和 1 加 3,距离分别是 2 和 10。三百组随机点集用排序做法对照,选错的零组。
真正有意思的是规模上去之后的差距:
text
三、二十万个点,取最近的 1000 个
quickselect 3.279 毫秒
整段排序 40.232 毫秒
倍数 12.27 倍
两版选取结果一致:是
二十万个点,quickselect 三毫秒出头,整段排序要四十毫秒,前者快十二点二七倍。差距出在一个很直白的地方,排序把两半都处理完,选择只处理有答案的那一半,层数从 log n 降到常数级别。
还有一组数据值得看:距离大量相等的时候,比如所有点的坐标都在零到四之间,二十万个点里只有二十几种不同的距离,两版选出的结果依然正确。三路划分在这里起了作用,等于基准值的那一段整段跳过,不会在某一段上反复划分。
第十一节 只找第 k 小与整段排序的耗时差
为了单独量这件事,我写了一个实验程序,造一百万条随机整数,先整段排序,再分别找第 1 小、第 1000 小、第 50 万小、第 100 万小:
| 任务 | 耗时 | 结果 |
|---|---|---|
| 整段排序 | 187.151 | 全部有序 |
| 找第 1 小 | 10.533 | 2906 |
| 找第 1000 小 | 10.571 | 2141213 |
| 找第 50 万小 | 47.601 | 1073495422 |
| 找第 100 万小 | 6.250 | 2147479563 |
找第 50 万小最慢,四十七毫秒,因为中位数正好落在排序之后的最深处,划分要多走几层;找第 1 小和找第 1000 小都只要十毫秒出头,比整段排序快十七倍;找第 100 万小最快,六点二五毫秒,因为最大值一路都在右段,划分一层就剥掉一大片。
数据分布同样会影响选择的开销,重复值多和少是两种表现。把值域收到零到九,一百万个元素里只有十种值,找第 1 小要 20.579 毫秒,比随机数据慢一倍;找第 50 万小只要 15.860 毫秒,比随机数据快三倍。重复值多的时候三路划分每次都能跳掉等于段,进度比随机数据更快,但第 1 小这种目标要跨过大量重复值,反而多花时间。
踩坑点
- 纯重复值的数据上不能用两段划分。一万条全等值时每层只把基准值放到最边上,递归深度等于元素个数,栈帧越压越多,八兆的栈在几十万层就撑爆,进程报 Segmentation fault 退出;一百万条那条用例在本机直接崩掉,换成三路划分或者内省排序才跑完。
- 两段划分与三路划分的返回值不能混用。前两者返回基准值落点,递归跳到落点减一与落点加一;三路返回等于段的两端,递归跳到左端减一与右端加一。写成一样的偏移,等于段会被反复递归,程序在重复值上跑不完。
- quickselect 里 k 的边界要先判断。题目给的 k 等于零或者不小于 n 时,选择函数会拿越界下标去划分,读到数组外面;壳子里先判断 k 小于等于零或者 k 大于等于 n 直接返回,是一个动作就能补上的防护。
- 距离用 int 存会溢出。坐标范围放到十万时平方和能到两百亿,int 装不下,溢出成负数之后距离的排序完全错乱,两个版本都返回错的答案;把坐标范围收到两万以内,或者直接用 long long 存距离,都能解决。
- 测试用的数组要按随机数据的最长长度开。十道题里有四道题的随机数据长度能到三十以上,main 里写 int a8 会在长度超过八的那一组写坏内存,现象常常是几十组结果对不上,很少直接崩掉,很容易被当成算法写错。
- 造随机数据时要筛掉本来就无解的那些组。324 的摆动排序要求重复值不超过一半,随机造出来的一百多组根本无解,代码再对也交不出严格交替,先是报了一百五十三组不合格;加上每个值的个数不超过长度加一的一半这个条件之后,误报归零。
- 颜色分类里换到右边之后游标不能前进。右边换过来的元素还没被检查过,它可能是零、一、二中的任何一个,cur 停在原地才能在下一次循环里处理它;左边换过来的元素一定已经处理过,可以直接跨过去。
- 第三大的数不能用零当初始值。题目允许负数和负的二十亿,零会让所有负数都比不过它,第三大的判断直接失效;要取一个比 int 最小值还小的数,用 long long 存才稳。
- 摆动排序不能从两半的开头正着取。排序之后重复值挤在中间,两半的末尾恰好是各自最大的几个,把大的先放到相邻位置上,重复值才会被推到隔开的位置去;正着取会让两个相同的中位数在交界处挨在一起。
本篇总结(模拟面试问题)
问:划分除了排序还能拿来做什么?
核心要点:还能做选择与圈边界。选择是只找第 k 个,划分一次之后拿落点和 k 比,只往有答案的那一半递归,平均代价从 n log n 降到 n;圈边界是从左扫一遍定右端点、从右扫一遍定左端点,面试题 16.16 就是这两趟扫描的组合。二十万个点上实测,只找前 k 个比整段排序快十二倍还多。
问:quickselect 的最坏情况是什么,怎么防?
核心要点:最坏是每次划分都落在区间的一端,比如数据已经有序而基准值又取了最左边,每层只削掉一个元素,复杂度退化成 n 的平方。防法有三种:基准值取中位数或者随机取,用三路划分把重复值整段跳过,或者加深度计数、超过两倍 log n 就换堆排序去选第 k 个。
问:颜色分类换到右边时 cur 为什么不往前走?
核心要点:右边换过来的元素还没有被检查过,它可能是零、一、二中的任何一个,cur 停在原地才能在下一次循环里处理它;左边换过来的元素一定已经处理过,直接跨过去不会漏掉任何一个。
问:摆动排序为什么从两半的末尾往前取?
核心要点:排序之后重复值会挤在中间,两半的末尾恰好是各自最大的那几个,先把最大的放到相邻位置上,重复值就被推到隔开的位置去;正着取会让两个相同的中位数在交界处挨在一起,输出就不满足严格交替。
问:第三大的数为什么不能用零当初始值?
核心要点:题目允许负数和负的二十亿,零当初始值会让所有负数都比不过它,第三大的判断直接失效。要取一个比 int 最小值还小的数,用 long long 存,才能保证第一个负数进来时三个变量都会被刷新。
问:找最近的 k 个点,排序与选择差在哪里?
核心要点:排序要把所有点变成距离有序,两半都得处理,代价是 n log n;选择只要保证前 k 个的距离都不大于第 k 个,另一边的相对顺序无所谓,每层只处理一半,平均是 n。二十万个点上实测,两者差了十二倍还多。
问:部分排序为什么从左、右各扫一遍就够了?
核心要点:右端点靠从左往右扫,一路维护已经见过的最大值,出现比它小的元素就说明这里必须进重排段;左端点靠从右往左扫,一路维护已经见过的最小值,出现比它大的元素就说明这里必须进重排段。两趟都只走一次,合起来是线性的,而把整段排好要 n log n。
问:存在重复元素为什么可以先排序?
核心要点:排序之后相等的元素一定相邻,扫一遍看有没有相邻相等就够了,代价是 n log n 加上一次线性扫描。用哈希表能做到平均线性,但要额外开表并处理冲突;元素是整数、范围已知时还可以开标记数组,代价是空间跟着值域走。这三种写法按数据规模与值域挑一种就行。