【数据结构】排序_OJ题(一):计数、桶与排序后的处理

【数据结构】排序_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 里出现过的元素要按升序放在末尾。

原题链接:leetcode 1122 数组的相对排序

思路。 这道题的顺序由两张表决定,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 的字母异位词,也就是两个串的字母种类和每种字母的个数完全一样,只是排列顺序不同。

原题链接:leetcode 242 有效的字母异位词

思路。 排序之后比较两个串是否完全相等,是最直白的做法,时间复杂度 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,从中找出三个数,使它们的乘积最大,返回这个乘积。

原题链接:leetcode 628 三个数的最大乘积

思路。 把数组排序之后,最大的乘积只有两种可能,三个最大的数相乘,或者两个最小的数乘上最大的那个数。之所以只有这两种,是因为要让乘积尽量大,要么三个正数,要么两个负数配一个正数,负数再多的话符号会变负,所以只需要考虑绝对值最大的那两三个负数。

这两种情况取较大者就是答案,两头各算一次。排序之后这两种组合都落在数组的两头,写起来就是几个下标的事情。

用例走一遍。 输入 -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 前面,这个关系满足传递性,所以它是一个全序关系,可以直接作为比较器的返回值。传递性的严格证明要用到字符串拼接的性质,面试里能说出拼接比较构成全序这一点就够了。

问:有序数组的平方为什么能从两端取?

核心要点:原数组是升序的,负数段平方之后从大到小,正数段平方之后也是从大到小,所以平方值的最大值只可能出现在左端或者右端。每次取两端较大的那个填到结果末尾,然后收窄那一端,一次比较定一个位置。

参考

相关推荐
wangchunyu1142 小时前
JAVA AQS与Lock实现原理(含CAS)
java·开发语言·word
爱喝水的鱼丶3 小时前
SAP-ABAP:ECC→S/4HANA ABAP 迁移升级指南——程序改造、语法兼容与性能优化 导语
运维·开发语言·性能优化·sap·abap·经验交流·开发交流
w_zero_one3 小时前
算法--模拟
java·数据结构·算法
小灰灰搞电子4 小时前
Rust+Slint 实现FTP客户端调试助手源码分享
开发语言·后端·rust
外收内放4 小时前
面向对象高级(实战案例)
开发语言·python·学习
Javatutouhouduan4 小时前
阿里1658页Java面试题总结(含答案)
java·高并发·java面试·java面试题·后端开发·java程序员·java八股文
秋天的怀念_David4 小时前
算法一千零一夜 · 第十三夜 幻方,武侠世界里的数学迷宫
java·程序人生·算法
专业程序开发源4 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计
秋天的怀念_David5 小时前
算法一千零一夜 · 第十四夜 幻方与奇妙的二进制
java·程序人生·算法