排序
在生活中,经常需要对一些东西排序。比如,考试后按照成绩高低排序;打扑克时要按点数或花色排序手牌。很多问题可以利用排序将无序的杂乱无章的东西整理清楚,便于查询统计和利用。
计数排序
例题:P1271 [深基9.例1] 选举学生会
学校正在选举学生会成员,有 \(n \ (n \le 999)\) 名候选人,每名候选人编号分别从 \(1\) 到 \(n\),现在收集到了 \(m \ (m<2000000)\) 张选票,每张选票都写了一个候选人编号。现在想把这些堆积如山的选票按照投票数字从小到大排序。输入 \(n\) 和 \(m\) 以及 \(m\) 个选票上的数字,求出排序后的选票编号。例如,当有 \(5\) 个候选人,\(10\) 张选票分别是 \(2,5,2,2,5,2,2,2,1,2\) 时,需要输出
1 2 2 2 2 2 2 2 5 5。
分析:在投票区放上 \(n\) 个投票箱,投票人支持谁就把票投入对应的投票箱中。投票后只需统计每个投票箱有几张选票,直接按照候选人编号取出来就可以完成排序操作。

#include <cstdio>
const int N = 1005;
int cnt[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x; scanf("%d", &x);
cnt[x]++; // 更新x出现的次数
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= cnt[i]; j++) { // 根据i出现的次数输出对应个数的i
printf("%d ", i);
}
}
return 0;
}
只需要开一个大小不小于 \(n\) 的数组作为票箱,依次读入选票,然后将选票号数加到对应的票箱中,最后根据每个票箱中的票数输出候选人编号即可。在这个过程中,甚至不需要用数组存储每一张选票。
这种排序方法被称为计数排序。读入选票并统计的时间复杂度为 \(O(m)\),输出选票的时间复杂度是 \(O(m+n)\)(这里虽然有两重循环,但并非是二次方的复杂度),空间复杂度是 \(O(n)\)。所以计数排序只能用于排序编号(值域)范围不是很大的数字。如果需要排序的数字要到 \(10^9\),那么别说运行时间了,内存也无法存下这么大范围的数组。此外,如果希望将一些浮点数或者字符串进行排序,就没办法去设置合适的“投票箱”了,所以就不能使用这种算法了。计数排序是一种基于分类而非基于比较的排序算法,不依赖排序对象间的直接大小比较。
习题:P1059 [NOIP2006 普及组] 明明的随机数
解题思路
考虑到随机数的范围是 1 到 1000,这是一个非常小且固定的范围,使用计数排序的思想就能解决去重和排序的问题。
参考代码
#include <cstdio>
const int A = 1005; // 因为数字范围是1-1000,所以大小设为1005足够安全
// 定义计数数组
// cnt[i] > 0 表示数字 i 出现过
// 全局数组会自动初始化所有元素为 0
int cnt[A];
int main()
{
int n; scanf("%d", &n);
int ans = 0; // 用于统计不重复数字的个数
// 1. 去重与计数
for (int i = 1; i <= n; i++) { // 循环读取 n 个数
int x; scanf("%d", &x); // 读取一个数
if (cnt[x] == 0) {
// 如果为0,说明数字 x 是第一次出现
ans++; // 不重复数字的个数加1
}
cnt[x]++; // 将 x 对应的计数值加1,标记为已出现
}
// 2. 输出结果
printf("%d\n", ans); // 第一行输出不重复数字的个数
// 第二行从小到大输出所有不重复的数字
for (int i = 1; i <= 1000; i++) { // 遍历所有可能的数字(1到1000)
if (cnt[i] > 0) { // 如果数字 i 对应的计数值大于0,说明这个数字在输入中出现过
printf("%d ", i); // 输出这个数字,因为 i 是从小到大递增的,所以输出自然是排好序的
}
}
return 0;
}
习题:B4094 [CSP-X2021 山东] 成绩排名
解题思路
这个问题的核心是计算一个给定分数的排名,根据题意,排名取决于有多少人的分数比给定分数更高。具体来说,一个分数的排名等于 1 加上高于这个分数的总人数。
根据分数范围为 \([0,100]\) 这一条件,可以采用“计数排序”的思想来高效解决这个问题。
参考代码
#include <cstdio>
// 创建一个大小为 101 的整数数组,用作计数桶。
// cnt[i] 将存储分数为 i 的学生人数。分数范围是 [0, 100]。
int cnt[101];
int main()
{
// 读入参赛的学生总数 n
int n;
scanf("%d", &n);
// 循环 n 次,读取每个学生的分数并统计
for (int i = 1; i <= n; i++) {
int x; // 临时存储当前读取的分数
scanf("%d", &x);
cnt[x]++; // 将对应分数的计数加一
}
// 读入要查询名次的那个学生的分数 s
int s;
scanf("%d", &s);
// 初始化排名为 1。这是基础排名。
int ans = 1;
// 遍历所有可能比 s 高的分数(从 100 到 s+1)
for (int i = 100; i > s; i--) {
// 将所有得分比 s 高的学生人数累加到排名上
ans += cnt[i];
}
// 打印最终计算出的排名
printf("%d", ans);
return 0;
}
习题:P7072 [CSP-J2020] 直播获奖
解题思路
观察数据范围,注意到每个选手的成绩都是不超过 \(600\) 的非负整数,因此每新增一个选手成绩时,都可以利用计数排序的思想倒着扫描分数范围,从而找到即时的获奖分数线。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int MAXSCORE = 1000;
int cnt[MAXSCORE];
int main()
{
int n, w, maxs = 0;
scanf("%d%d", &n, &w);
for (int i = 1; i <= n; i++) {
int score; scanf("%d", &score);
cnt[score]++; maxs = max(maxs, score);
int sum = 0, least = max(i * w / 100, 1);
for (int j = maxs; j >= 0; j--) {
sum += cnt[j];
if (sum >= least) {
printf("%d ", j); break;
}
}
}
return 0;
}
基数排序
例题:P1177 【模板】排序
基数排序是一种非比较型整数排序算法。
基数排序的核心思想是多关键字排序,它将整数按位数切割成不同的“关键字”,然后按每个关键字(即数位)分别进行排序。
最常见的实现是 LSD(Least Significant Digit first)基数排序,具体步骤如下:
- 确定最大位数:首先,遍历整个数组,找到最大的数,并计算出它的位数(比如最大数是 987,则位数为 3),这个位数决定了需要进行几轮排序。
- 按位排序:从最低位(个位)开始,到最高位,依次对数组进行排序。
- 分配:在每一轮中,创建 10 个“桶”(因为数字由 0-9 组成),编号从 0 到 9。遍历数组中的每一个数,根据当前处理的数位(个位、十位、百位、……)的值,将这个数放入对应的桶中。例如,处理个位时,数字 123 的个位是 3,就把它放进 3 号桶。
- 收集:将所有数字分配到桶中后,再按照桶的编号顺序(从 0 号桶到 9 号桶),依次将桶里的数取出来,放回原数组。这样,原数组就完成了按当前数位的排序。
- 重复:重复第 2 步,直到最高位也排序完成。此时,整个数组就是完全有序的了。
基数排序的巧妙之处在于,每一轮的“收集”操作都必须是稳定的。也就是说,在桶中具有相同关键字的元素,它们在被收集回原数组时,必须保持它们在上一轮排序后的相对位置。
参考代码
#include <cstdio>
#include <vector>
using namespace std;
int main()
{
int n;
scanf("%d", &n);
vector<int> a(n); // 使用 vector 存储待排序的数字
int m = 0; // 用于记录数组中的最大值
for (int i = 0; i < n; i++) {
scanf("%d", &a[i]);
if (a[i] > m) {
m = a[i];
}
}
// 计算最大值 m 的位数 r
int r = 1;
while (m >= 10) {
m /= 10;
r++;
}
int base = 1; // 基数
// 外层循环
for (int i = 1; i <= r; i++) {
vector<vector<int>> c(10);
// 分配过程
for (int x : a) {
c[x / base % 10].push_back(x);
}
a.clear();
// 收集过程
for (int j = 0; j < 10; j++) {
for (int x : c[j]) {
a.push_back(x);
}
}
base *= 10;
}
// 输出数组
for (int x : a) {
printf("%d ", x);
}
printf("\n");
return 0;
}
选择题:假设在基数排序过程中,受宇宙射线的影响,某项数据异变为一个完全不同的值。请问排序算法结束后,可能出现的最坏情况是?
- A. 移除受影响的数据后,最终序列是有序序列
- B. 移除受影响的数据后,最终序列是前后两个有序的子序列
- C. 移除受影响的数据后,最终序列是一个有序的子序列和一个基本无序的子序列
- D. 移除受影响的数据后,最终序列基本无序
答案
这道题的正确答案是 A。
这道题考查的是对基数排序原理的深入理解。
基数排序是一种非比较排序算法,它将整数按位数切割成不同的数字,然后按每个位数分别比较。关键在于,每一轮的排序(通常使用稳定的计数排序)是独立地对当前位上的数字进行操作。
数据异变只会影响那个数据本身最终被放置的位置,而不会干扰到其他任何数据的排序过程。因此,当把那个被污染的数据移除后,剩下的序列必然是有序的。
选择排序、冒泡排序、插入排序
选择排序
对于一个待排序的数组(以升序排序为例):
- 设定一个“已排序”区域,初始为空,位于数组的左侧;以及一个“未排序”区域,初始为整个数组,位于右侧。
- 在“未排序”区域中,找到值最小的元素。
- 将这个最小的元素与“未排序”区域的第一个元素交换位置。
- 现在,这个被交换过来的元素可以被看作是“已排序”区域的末尾。将“已排序”区域向右扩大一个位置。
- 重复步骤 2-4,直到“未排序”区域为空。
用数组 \([64,25,12,22,11]\) 来演示:
第 1 轮
- 未排序区域:\([64,25,12,22,11]\)
- 找到最小值:\(11\),在索引 \(5\)
- 与未排序区域的第一个元素 \(64\)(在索引 \(1\))交换
- 结果:\([11,25,12,22,64]\)
- 已排序区域:\([11]\)
第 2 轮
- 未排序区域:\([25,12,22,64]\)
- 找到最小值:\(12\),在索引 \(3\)
- 与未排序区域的第一个元素 \(25\)(在索引 \(2\))交换
- 结果:\([11,12,25,22,64]\)
- 已排序区域:\([11,12]\)
第 3 轮
- 未排序区域:\([25,22,64]\)
- 找到最小值:\(22\),在索引 \(4\)
- 与未排序区域的第一个元素 \(25\)(在索引 \(3\))交换
- 结果:\([11,12,22,25,64]\)
- 已排序区域:\([11,12,22]\)
第 4 轮
- 未排序区域:\([25,64]\)
- 找到最小值:\(25\)(它自己就是第一个)
- 与自己交换(或不交换)
- 结果:\([11,12,22,25,64]\)
- 已排序区域:\([11,12,22,25]\)
最后一个元素 \(64\) 自动就位,排序完成。
算法分析
- 时间复杂度:\(O(n^2)\)
- 无论输入数组的初始顺序如何,算法都需要两个嵌套循环。外层循环走 \(n-1\) 次,内层循环平均走 \(n/2\) 次。所以总的比较次数大约是 \((n-1)+(n-2)+\cdots+1=n(n-1)/2\),这导致了 \(O(n^2)\) 的时间复杂度。
- 它的最佳、最差和平均时间复杂度都是 \(O(n^2)\)。
- 空间复杂度:\(O(1)\)
- 它是一个原地排序(in-place)算法。除了存储原始数据外,它只需要几个额外的变量来辅助交换,所以空间是常数级别的。
#include <cstdio>
#include <algorithm>
using std::swap;
int a[10] = {0, 3, 5, 2, 1, 4, 7, 6};
int main() {
int n = 7;
for (int i = 1; i <= n-1; i++) {
// 在a[i]~a[n]中寻找最小值(的位置)
int mini = i; // 刚开始就是i
for (int j = i+1; j <= n; j++) {
if (a[j]<a[mini]) {
mini = j; // 如果发现更小的值,就记下新的位置
}
}
// 最终mini就是最小值的位置
// 和a[i]交换
swap(a[i], a[mini]);
}
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
return 0;
}
习题:P7614 [COCI 2011/2012 #2] NAJBOLJIH 5
解题思路
为了在排序后还能找到数字的原始位置,使用另一个数组 \(idx\) 来存储每个数字的初始编号(1 到 8),\(x_i\) 对应的原始编号就是 \(idx_i\)。
通过部分选择排序找到最大的五个数,当每一趟排序交换元素时,也同时交换对应的 \(idx\),这保证了数字和它的原始编号始终是绑定在一起的。经过一轮交换后,将换上来的元素的原始编号在标记数组中打上标记,这样最后按顺序遍历 8 个标记,输出的编号自然就是升序的。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
// x 数组用于存储输入的8个分数
// idx 数组用于存储每个分数对应的原始编号
int x[9], idx[9];
// f 数组作为标记,如果 f[i] 为 true,表示编号为 i 的元素被选中
bool f[9];
int main()
{
// --- 读取输入 ---
// 使用 1-based 索引,从 1 到 8
for (int i = 1; i <= 8; i++) {
scanf("%d", &x[i]); // 读取第 i 个数字
idx[i] = i; // 初始化原始编号,x[i] 的初始编号是 i
}
int sum = 0; // 用于计算总分
// --- 核心逻辑:找出最大的5个数 ---
// 这个循环执行5次,每次找出一个当前未被选中的最大数
// 本质上是一个执行了5轮的选择排序
for (int i = 1; i <= 5; i++) {
// 假设当前范围 [i, 8] 内的最大值在索引 i 处
int maxi = i;
// 在 [i+1, 8] 范围内寻找真正的最大值
for (int j = i + 1; j <= 8; j++) {
if (x[j] > x[maxi]) {
maxi = j; // 更新最大值所在的索引
}
}
// 将找到的最大值交换到第 i 个位置
swap(x[i], x[maxi]);
// 关键:同时交换对应的原始编号,确保分数和编号的配对关系不变
swap(idx[i], idx[maxi]);
// 此时,x[i] 是找到的第 i 大的数,idx[i] 是它的原始编号
f[idx[i]] = true; // 在标记数组中记录这个编号已被选中
sum += x[i]; // 将分数累加到总和
}
// --- 输出结果 ---
// 第一行输出总分
printf("%d\n", sum);
// 第二行按升序输出选中的5个题目的编号
for (int i = 1; i <= 8; i++) {
// 遍历标记数组,如果 f[i] 为 true,则输出编号 i
// 因为 i 是从 1 到 8 递增的,所以输出的编号自然是升序的
if (f[i]) {
printf("%d ", i);
}
}
return 0;
}
习题:P1093 [NOIP2007 普及组] 奖学金
解题思路
可以参考选择排序的思路。选择排序的第一轮会将最小/最大值移动到序列的开头,第二轮将剩下的数据中的最小/最大值移动到序列的第二个位置,……,因此如果限定选择排序最多做 \(k\) 轮就相当于排出了最优的 \(k\) 个人。时间复杂度为 \(O(kn)\),本题中相当于限定 \(k=5\)。
参考代码
#include <cstdio>
#include <algorithm>
using std::swap;
const int N = 305;
struct Student {
int chi, math, eng, sum, id;
};
Student a[N];
bool greater(Student s1, Student s2) { // 判断s1是否大于s2
// 多关键字比较
// 总分高、语文高、学号小
if (s1.sum != s2.sum) return s1.sum>s2.sum;
if (s1.chi != s2.chi) return s1.chi>s2.chi;
return s1.id<s2.id;
}
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d%d%d", &a[i].chi, &a[i].math, &a[i].eng);
a[i].sum = a[i].chi+a[i].math+a[i].eng;
a[i].id = i;
}
int t = 5;
if (n < 5) t = n;
// 前5名(做5轮选择排序)
for (int i = 1; i <= t; i++) {
int maxi = i;
for (int j = i+1; j <= n; j++) {
if (greater(a[j], a[maxi])) { // “a[j]>a[maxi]”
maxi = j;
}
}
swap(a[maxi], a[i]);
}
for (int i = 1; i <= t; i++) printf("%d %d\n", a[i].id, a[i].sum);
return 0;
}
冒泡排序
冒泡排序(Bubble Sort)是一种简单直观的排序算法。基本思想是:比较相邻两个元素,如果两者之间的顺序有误,则交换这两个元素,直到整个序列中任意两个相邻位置顺序都正确。

如图所示,一轮过后,最大值被挤到了序列的最后面,因此下一轮可以将最后一个位置视为有序的,对剩余部分继续使用同样的流程即可。因此如果有 \(n\) 个元素需要排序,最多 \(n-1\) 轮之后,整个序列一定能完成排序。
#include <cstdio>
#include <algorithm>
using std::swap;
const int N = 1e5 + 5;
int a[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n - 1; i++) {
for (int j = 1; j <= n - i; j++) {
if (a[j] > a[j + 1]) swap(a[j], a[j + 1]);
}
}
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
return 0;
}
冒泡排序的时间复杂度是 \(O(n^2)\),空间复杂度是 \(O(n)\)。
逆序对
如果有序的目标是升序,则逆序对指的是原序列中当 \(i<j\) 时却 \(a_i > a_j\) 的数对。例如序列 \([1,2,2,6,3,5,9,8]\) 中包含 \(3\) 个逆序对:\((6,3),(6,5),(9,8)\)。如果一个序列已经有序,则它就没有逆序对。而如果一个序列正好完全和要求的顺序倒过来,它的逆序对数量将达到最大:\(1+2+\cdots + (n-1) = \dfrac{n(n-1)}{2} = O(n^2)\)。交换相邻的一对逆序元素正好消除了一个逆序对,因此冒泡排序中每发生一次交换,就相当于消除了一个逆序对。由此也可以发现,冒泡排序相邻元素交换发生的次数正好就是原序列中逆序对的数量。
冒泡排序的改进
考虑到如果一轮冒泡排序下来没有发生交换,则说明此时整个序列已经变得有序,因此冒泡排序不一定要跑满 \(n-1\) 轮。
#include <cstdio>
#include <algorithm>
using std::swap;
const int N = 1e5 + 5;
int a[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n - 1; i++) {
bool flag = false; // 本轮冒泡排序是否发生交换
for (int j = 1; j <= n - i; j++) {
if (a[j] > a[j + 1]) {
swap(a[j], a[j + 1]);
flag = true;
}
}
if (!flag) break; // 如果本轮冒泡排序没发生交换,说明已经有序
}
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
return 0;
}
选择题:设有一个长度为 \(n\) 的 01 字符串,其中有 \(k\) 个 1。每次操作可以交换相邻两个字符。在最坏情况下将这 \(k\) 个 1 移到字符串最右边所需要的交换次数是多少?
- A. \(k\)
- B. \(k \times (k-1) / 2\)
- C. \((n-k) \times k\)
- D. \((2n-k-1) \times k / 2\)
答案
C。要使交换次数最多,这些 \(1\) 的初始位置应该离目标位置最远。目标位置是最右边的 \(k\) 个槽位,因此,最坏的情况就是所有 \(k\) 个 1 都集中在字符串的最左边。最坏情况的初始字符串是 11...100...0(前面是 \(k\) 个 1,后面是 \(n-k\) 个 0),目标字符串是 00...011...1(前面是 \(n-k\) 个 0,后面是 \(k\) 个 1)。
每一次交换,都是一个 1 和 0 交换位置,要达到最终状态,每一个 1 都必须与每一个 0 交换一次位置。总共有 \(k\) 个 1 和 \(n-k\) 个 0,因此,总的交换次数就是 1 和 0 之间可以形成的配对数 \(k \times (n-k)\)。
选择题:定义一种字符串操作为交换相邻两个字符。将 DACFEB 变为 ABCDEF 最少需要多少次上述操作?
- A. 7
- B. 8
- C. 9
- D. 6
答案
A。
这个问题可以转化为计算字符串的“逆序对”数量。将一个序列通过“交换相邻元素”的方式变换成目标有序序列,其最少操作次数等于原序列中逆序对的数量。
目标序列是 ABCDEF,初始序列是 DACFEB。一个“逆序对”是指在初始序列中,一个本应排在后面的字符出现在了本应排在前面的字符的左边。
右边比 D 小的字符有 A, C, B;右边没有比 A 小的字符;右边比 C 小的字符有 B;右边比 F 小的字符有 E, B;右边比 E 小的字符有 B。将所有逆序对数量相加得到 \(3+0+1+2+1+0 = 7\)。
程序阅读题:
#include <bits/stdc++.h>
using namespace std;
int n, k;
int func(vector<int> &nums) {
int ret = 0;
for (int i = n; i > k; i--) {
if (nums[i] > nums[i - k]) {
swap(nums[i], nums[i - k]);
ret++;
}
}
return ret;
}
int main() {
cin >> n >> k;
vector<int> a(n + 1, 0);
for (int i = 1; i <= n; i++)
cin >> a[i];
int counter = 0, previous = -1;
while (counter != previous) {
previous = counter;
counter += func(a);
}
for (int i = 1; i <= n; i++)
cout << a[i] << ",";
cout << endl << counter << endl;
return 0;
}
假设输入的 \(n,k\) 是不超过 \(100000\) 的正整数,输入的 a[i] 是不超过 \(10^9\) 的整数,\(k\) 小于等于 \(n\)。
判断题
当输入的 \(k\) 为 \(1\),程序将 \(a\) 从小到大排序。
答案
错误。当 k=1 时,第 9 行的条件相当于 nums[i]>nums[i-1],也就是说相邻两个元素交换的条件是左边小右边大,那么想要排的顺序就是左边大右边小。
在题目限制的输入规模下,counter 可能会溢出。
答案
正确。counter 每次累加的是一趟扫描下来发生的交换次数。当 k=1 时,如果初始序列和排序后的序列完全相反,这样的 counter 最大。因为 n 最大 100000,所以 counter 最大能达到 \(\dfrac{100000 \times 99999}{2} = 4999950000\),这个值超出了 int 的表示范围。
当输入为 8 1 1 9 2 3 4 6 8 7,输出共有 18 个可见字符。
答案
正确。counter 计算的是总的交换次数,也就是相对有序序列的“逆序对”数量。而这个程序是从大到小排序,所以“逆序对”就是 \(i<j \wedge a_i<a_j\) 的数对。所以对于原始序列 1 9 2 3 4 6 8 7,最终的 counter 计算结果应该是 21,程序最后会输出排好序的数组以及“逆序对”数量,8 个元素以及 8 个逗号共 16 个可见字符,再加上 counter 数量是个两位数,所以总共 18 个可见字符。
单选题
当输入的 k 为 1,该程序的排序方法最接近?
A. 冒泡排序 / B. 选择排序 / C. 计数排序 / D. 插入排序
答案
A。每一趟都是扫描序列,发现相邻位置元素顺序不对则进行交换,这是冒泡排序每一轮做的事。第 23 行的循环控制条件 counter != previous 相当于某一轮如果没有发生交换则结束排序过程。
该程序的时间复杂度为?
A. \(O(n+k^2)\) / B. \(O(n^2)\) / C. \(O(nk)\) / D. \(O(\dfrac{n^2}{k})\)
答案
D。当 \(k>1\) 时,相当于扩展了“相邻”元素的含义,此时可以看作是将原数组分成了 \(k\) 个组,组内的“相邻”元素在原数组中位置间隔为 \(k\),排序过程在每组内进行,最终将每组内从大到小排序。因为每组元素大约是 \(\dfrac{n}{k}\),每一趟扫描至少会让每组内新增一个有序元素,因此排序过程最多 \(O(\dfrac{n}{k})\) 轮,每一轮对序列的扫描是 \(O(n)\),总的时间复杂度为 \(O(\dfrac{n^2}{k})\)。
当输入为 8 3 1 5 2 6 3 7 4 8,输出的第一行第三个数字为?
A. 2 / B. 6 / C. 7 / D. 8
答案
C。8 个数,分成 3 组进行排序。最后输出的第三个数子所处的组涉及到 a[3],a[6],这个组内的排序结果应为 7 2,因此最后输出的第三个数字是 7。
习题:P8197 [传智杯 #4 决赛] 排排队
解题思路
题目要求通过相邻元素交换,将初始序列 \(a\) 变为目标序列 \(b\)。一个基本结论是:任何序列都可以通过只交换相邻元素的方式,变成它的任意一种排列。因此,\(a\) 能够变成 \(b\) 的充要条件是:序列 \(a\) 和序列 \(b\) 是彼此的排列。换句话说,它们必须包含完全相同的元素,且每个元素出现的次数也必须相同。要验证这个条件,最简单的方法是将 \(a\) 和 \(b\) 分别排序,然后比较排序后的两个序列是否完全相同。如果相同,则有解;否则无解。
如果确定有解,需要给出一组具体的交换步骤,且总步数不能超过 \(n^2\)。可以采用一种简单直接的构造性算法,即逐个归位。
- 第一步:让 \(a_1\) 的值等于 \(b_1\)。在当前的 \(a\) 数组中(从 \(a_1\) 开始)找到值为 \(b_1\) 的那个元素,假设它在位置 \(j\)。然后,通过一系列的相邻交换,把它冒泡到位置 1,具体操作是:
swap(a[j], a[j-1]), swap(a[j-1], a[j-2]), ..., swap(a[2], a[1])。 - 第二步:此时 \(a_1\) 已经就位,不用再动它,接下来让 \(a_2\) 的值等于 \(b_2\)。在当前的 \(a\) 数组中(从 \(a_2\) 开始)找到值为 \(b_2\) 的元素,假设在位置 \(j\),然后用同样的方式把它冒泡到位置 2。
- 重复此过程:依次对 \(i=1, 2, \dots, n-1\) 进行操作,在 \(a_{i \dots n}\) 的范围内找到 \(b_i\) 并将其移动到位置 \(i\)。当处理完前 \(n-1\) 个位置后,第 \(n\) 个位置的元素也自然就位了。
在第 \(i\) 步,将一个元素从位置 \(j\)(\(j \ge i\))移动到位置 \(i\),需要 \(j-i\) 次交换。在最坏的情况下,\(j\) 总是 \(n\),那么总交换次数大约是 \((n-1)+(n-2)+ \cdots + 1 = n(n-1)/2\)。这个次数远小于题目给出的 \(n^2\) 的上限,所以这个构造方法是可行的。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 1e3 + 5;
int a[N], b[N], x[N], y[N];
void bubbleSort(int arr[], int n) {
for (int i = 1; i < n; i++) {
for (int j = 1; j <= n - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
}
}
}
}
void solve() {
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
x[i] = a[i];
}
for (int i = 1; i <= n; i++) {
scanf("%d", &b[i]);
y[i] = b[i];
}
bubbleSort(x, n);
bubbleSort(y, n);
for (int i = 1; i <= n; i++) {
if (x[i] != y[i]) {
printf("NO\n");
return;
}
}
printf("YES\n");
for (int i = 1; i < n; i++) {
for (int j = i; j <= n; j++) {
if (a[j] == b[i]) {
for (int k = j; k > i; k--) {
swap(a[k], a[k - 1]);
printf("%d %d\n", k, k - 1);
}
break;
}
}
}
printf("0 0\n");
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) {
solve();
}
return 0;
}
插入排序
#include <cstdio>
#include <algorithm>
using std::swap;
int a[10] = {0, 3, 5, 2, 1, 4, 7, 6};
/*
一开始 a[1]~a[1]是有序的
第一轮:把a[2]插入到前面的合适位置
结束后a[1]~a[2]都是有序的
第二轮:把a[3]插入到前面的合适位置
结束后a[1]~a[3]都是有序的
……
直到把a[n]插入到合适位置
*/
int main() {
int n = 7;
for (int i = 2; i <= n; i++) {
int num = a[i]; // 把a[i]插入到合适位置
// 此时a[1]~a[i-1]是有序的
int j = i-1;
// 注意j>=1先写,保证最终一定能停下来
while (j>=1 && a[j]>num) { // 如果a[j]大,说明插入位置还在前面
a[j+1]=a[j]; // 把a[j]向后移动一位,腾出一个插入位置
j--;
}
// 此时a[j]是所有<=num中最右边的一个
// a[j+1]是>num中最左边的一个
// a[j+1]~a[i-1]已经在上面的循环中整体向右移动了一位
// 所以腾出来的位置就是a[j+1]
a[j+1]=num;
// for (int i = 1; i <= n; i++) printf("%d ", a[i]);
// printf("\n");
}
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
return 0;
}
实际上将待插入元素插入到前面的某个位置的过程在实现上也可以简化为从待插入元素的位置开始向前不断比较相邻元素,若相邻元素顺序不合理,则交换这两个相邻元素。(这个操作看起来很像冒泡排序,但这个算法的本质还是插入排序)
#include <cstdio>
#include <algorithm>
using std::swap;
int a[10] = {0, 3, 5, 2, 1, 4, 7, 6};
/*
一开始 a[1]~a[1]是有序的
第一轮:把a[2]插入到前面的合适位置
结束后a[1]~a[2]都是有序的
第二轮:把a[3]插入到前面的合适位置
结束后a[1]~a[3]都是有序的
……
直到把a[n]插入到合适位置
*/
int main() {
int n = 7;
for (int i = 2; i <= n; i++) {
// 这其实就是在向前找插入的合适位置并腾出相应的空间
for (int j = i; j >= 2; j--) {
if (a[j - 1] > a[j]) {
swap(a[j - 1], a[j]);
}
}
}
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
return 0;
}
排序算法的稳定性指的是原来值相等的两个元素在排序后是否还保持原来的前后关系。如果能保持,则称为稳定的,反之则为不稳定的。选择排序是不稳定的(例如有五个元素 5 8 5 2 9,经过第一轮后会将 2 换上来,而一旦 2 换上来两个 5 的前后顺序就被破坏了),而冒泡排序、插入排序是稳定的(本质上是因为对于相邻的两个相等元素,冒泡排序和插入排序此时不进行元素的交换)。
标准库中的排序函数
在 C++ 的标准模板库(STL)中,有实现好的排序函数,需要用到头文件 algorithm,其方法如下:sort(a, a+n, cmp) 表示对 a 数组中的 a[0] 到 a[n-1] 部分进行排序,cmp 是指自定义比较函数,如果是将数组 a 从小到大排序,那么这一项可以忽略。
sort 的时间复杂度是 \(O(n \log n)\)。如果要对数组 a[1] 到 a[n] 排序,就要用 sort(a+1, a+n+1)。那么,如果要求从大到小排序呢?只需要定义一个自定义的比较函数,将从大到小的逻辑写在比较函数中,并将这个函数名作为 sort 函数的第三个参数。这个函数实现时接收两个参数(即需要比较的两个元素),如果希望第一个排在第二个的前面,则返回 true,否则返回 false。
#include <cstdio>
#include <algorithm>
using std::sort;
int a[10] = {0, 3, 5, 2, 1, 4, 7, 6};
bool cmp(int x, int y) { // 这个函数会比较两个int,希望大的排在前面
return x > y;
}
int main()
{
int n = 7;
sort(a + 1, a + n + 1); // 表示对a[1]~a[n]进行排序
// 默认基于 < 排序
// 时间复杂度 O(n logn)
// logn log8 -> 3 log16 -> 4 ……
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
printf("\n");
// 如果从大到小排序
sort(a + 1, a + n + 1, cmp); // 这里的cmp是提供了一个比较函数
// 把一个函数作为另一个函数的参数
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
printf("\n");
sort(a + 1, a + n + 1); // 恢复从小到大
// 另一种写法
// 匿名函数
sort(a + 1, a + n + 1, [](int x, int y){
return x > y;
});
// [涉及到外面的参数](函数参数){函数体} 匿名函数
for (int i = 1; i <= n; i++) printf("%d ", a[i]);
printf("\n");
return 0;
}
sort 默认是不保证稳定性的,如果需要让排序具备稳定性可以给每个原始元素配上它的原始下标作为一个额外的属性,从而在定义元素比较方式时将原始下标作为第二关键字。
对于 int,double,string 等基本类型,sort 天生就知道如何比较。但如果是自定义的结构体,必须明确地提供比较规则才能使用 sort 来排序。
除了提供比较器函数以外,也可以对结构体类型重载小于运算符(operator<):在结构体内部定义一个名为 operator< 的成员函数,这相当于为结构体定义了一个“默认”的或“自然”的排序规则,一旦定义,sort 就可以直接使用它,无需额外参数。
语法格式:
struct MyStruct {
// ... 成员变量 ...
bool operator<(const MyStruct& other) const {
// 在这里编写比较逻辑
// 如果当前对象应该排在 other 前面,就返回 true
// 否则返回 false
}
};
关键点解释:
bool operator<:这是函数签名,声明要重载<运算符。const MyStruct& other:参数是另一个同类型的对象的常量引用,使用引用可以避免不必要的对象拷贝,提高效率;使用const则保证这个函数不会修改被比较的对象other。const(末尾的):这个const表示该成员函数不会修改当前对象自身的成员变量,这是一个好习惯,对于排序这类只读操作尤为重要。
排序逻辑:例如,对于一个 Student 结构体,可以决定先按分数排序,如果分数相同,再按学号排序。
#include <iostream>
#include <vector>
#include <string>
#include <algorithm> // std::sort 在这里
using namespace std;
// 定义一个学生结构体
struct Student {
string name;
int id;
double score;
// --- 方法一:重载小于运算符 (operator<) ---
// 定义一个“自然”的排序规则:优先按学号(id)升序排序。
// 这样,当直接调用 std::sort(vec.begin(), vec.end()) 时,就会使用这个规则。
bool operator<(const Student& other) const {
// 如果当前学生的id小于另一个学生的id,则认为当前学生“小于”另一个学生
return id < other.id;
}
};
// 用于打印 vector 内容的辅助函数
void printStudents(const string& title, const vector<Student>& students) {
cout << "--- " << title << " ---\n";
for (const auto& s : students) {
cout << "ID: " << s.id << ", Name: " << s.name << ", Score: " << s.score << "\n";
}
cout << "\n";
}
int main() {
// 创建一个 Student 对象的 vector
vector<Student> students = {
{"Alice", 102, 95.5},
{"Bob", 101, 88.0},
{"Charlie", 103, 95.5},
{"David", 100, 76.5}
};
printStudents("Initial Order", students);
// --- 演示1:使用重载的 operator< 进行默认排序 ---
// 因为 Student 结构体内部定义了 operator<,所以可以直接调用 sort
sort(students.begin(), students.end());
printStudents("Sorted by ID (using overloaded operator<)", students);
// --- 演示2:使用自定义比较器 (Lambda表达式) 进行排序 ---
// 假设现在需要一个不同的排序规则:优先按分数(score)降序排序,
// 如果分数相同,则按学号(id)升序排序。
sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
// 如果分数不相等,则分数高的排前面 (降序)
if (a.score != b.score) {
return a.score > b.score;
}
// 如果分数相等,则学号小的排前面 (升序)
return a.id < b.id;
});
printStudents("Sorted by Score (desc) then ID (asc) using a Lambda", students);
return 0;
}
例题:P1808 单词分类
要解决这个问题,关键在于为每一个“单词类别”找到一个唯一的、标准化的表示形式,称之为该类别的指纹或范式。如果能将每个单词都转换成其对应类别的唯一指纹,那么问题就简化为统计有多少个不同的指纹。
一个非常适合本题的“指纹”是字母频率数组:
- 可以用一个长度为 26 的数组来代表一个指纹,数组的第 0 个位置存储字母 A 的出现次数,第 1 个位置存储 B 的次数,以此类推。
- 例如,单词 AABAC 和 CBAAA 经过转换后,都会得到同一个频率数组:
[3, 1, 1, 0, 0, ...](3 个 A,1 个 B,1 个 C),而 AAABB 的频率数组是[3, 2, 0, 0, 0, ...]。 - 这样,所有属于同一类的单词都会有完全相同的频率数组指纹。
算法的实现步骤如下:
- 转换:遍历输入的 \(N\) 个单词,对于每个单词,都创建一个长度为 26 的频率数组,并计算其中 A 到 Z 的出现次数,将所有 \(N\) 个单词转换后的频率数组存储在一个列表(如
std::vector)中。 - 排序:对这个存储了所有频率数组的列表进行排序,排序后,所有相同的频率数组(即代表同一类别的“指纹”)都会被排列在一起,变成相邻的元素。
- 去重与计数:使用 C++ 标准库中经典的
sort+unique组合技。std::unique函数会遍历排好序的列表,将所有不重复的元素移动到列表的前面,并返回一个指向不重复部分的尾后迭代器。vector::erase函数接收unique返回的迭代器,将列表中所有重复的元素彻底删除。
- 输出:经过上述处理后,列表中剩下的就是所有独一无二的“指纹”,列表的大小即为单词的总类别数。
参考代码
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
int main()
{
int n;
cin >> n; // 读取单词数量
// words: 一个二维数组,用于存储每个单词的“指纹”。
// 每个“指纹”是一个长度为26的数组,代表该单词中'A'-'Z'的出现次数。
vector<vector<int>> words;
// 步骤1: 将每个单词转换为其字母频率数组(指纹)
for (int i = 1; i <= n; i++) {
string word;
cin >> word; // 读取一个单词
// 创建一个长度为26的数组cnt,所有元素初始化为0
vector<int> cnt(26);
// 遍历单词中的每个字符
for (char ch : word) {
// ch - 'A' 计算出字符相对于'A'的偏移量 (A->0, B->1, ...)
// 并将对应位置的计数加一
cnt[ch - 'A']++;
}
// 将计算好的频率数组(指纹)存入 words 列表中
words.push_back(cnt);
}
// 步骤2: 排序。目的是让所有相同的“指纹”都相邻,为后续去重做准备。
sort(words.begin(), words.end());
// 步骤3: 去重和计数。
// unique 函数会将所有不重复的元素移动到数组的前面,并返回一个指向末尾不重复元素的下一个位置的迭代器。
// erase 函数则根据 unique 返回的迭代器,删除掉后面所有重复的元素。
words.erase(unique(words.begin(), words.end()), words.end());
// 经过排序和去重后,words 中剩下的就是所有不重复的“指纹”。
// 其大小就是单词的类别总数。
cout << words.size() << "\n";
return 0;
}
例题:P1496 火烧赤壁
有 \(n\) 个线段,每一个线段用 \([a_i, b_i]\) 表示,求所有线段的并集的总长度。
数据范围:\(n \le 20000, \ -2^{31} \le a_i \le b_i \le 2^{31}\),且 \(a_i\) 和 \(b_i\) 都是整数。
考虑两个有交集的线段,左端点靠左的那个是 \([a_1, b_1]\),另一个是 \([a_2, b_2]\),因为假定了 \(a_2 \ge a_1\),所以总的长度取决于 \(b_1\) 和 \(b_2\) 的关系。如果 \(b_2 \le b_1\),那么第二个线段被第一个线段完全包含,合并后线段的左右端点和靠左的线段一模一样;如果 \(b_2 > b_1\),则相当于左端点不变的情况下,右端点在向右延展。
所以可以得到一个解法:将所有线段按左端点从小到达排序,如果相邻两个线段有交集,按上面的规则更新当前正在处理的线段的左右端点;如果相邻两个线段没有交集,则统计前面那个整体线段的长度,并将新的这个线段作为当前正在处理的线段。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 20005;
struct Fire {
int l, r;
};
Fire a[N];
int main()
{
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d%d", &a[i].l, &a[i].r);
// 按左端点排序
sort(a + 1, a + n + 1, [](Fire f1, Fire f2) {
return f1.l < f2.l;
});
int ans = 0, bg = a[1].l, ed = a[1].r; // 目前处理的燃烧段是[bg,ed]
for (int i = 2; i <= n; i++) {
if (a[i].l <= ed) ed = max(ed, a[i].r); // 与当前段有交叉
else { // 与当前段无交叉
// 答案加上上一段的燃烧总长,并将新的燃烧段端点设为a[i]的两个端点
ans += ed - bg; bg = a[i].l; ed = a[i].r;
}
}
ans += ed - bg; // 不要忘了处理最后一段
printf("%d\n", ans);
return 0;
}
例题:ABC354C AtCoder Magics
如果卡牌 \(x\) 的强度大于卡牌 \(y\),同时代价小于卡牌 \(y\)(即 \(A_x \gt A_y\) 且 \(C_x \lt C_y\)),那么卡牌 \(y\) 就会被淘汰,将其称之为卡牌 \(x\) 支配了卡牌 \(y\)。
最终要找的,就是所有不被任何其他卡牌支配的卡牌。
直接对每张卡牌都遍历所有其他卡牌来判断是否被支配,时间复杂度为 \(O(N^2)\),无法通过本题。需要一个更高效的算法,而这类“支配”问题通常可以通过排序来优化。
将所有卡牌按照强度从小到大进行排序,排序后,对于列表中的任意一张卡牌 \(i\),能够支配它的卡牌 \(j\)(因为需要 \(A_j \gt A_i\))必然位于它的右侧。
从最强的卡牌(排序后列表的末尾)开始,从右向左(即从强到弱)遍历这个列表。同时,维护一个变量 \(c\),用于记录到目前为止已经见过的所有(更强的)卡牌中的最低代价。
当遍历到第 \(i\) 张卡牌时,变量 \(c\) 的值代表了所有比卡牌 \(i\) 更强的卡牌(即 \(i+1\) 到 \(N\))中的最低代价。比较当前卡牌 \(i\) 的代价和变量 \(c\):如果当前卡牌的代价小于等于 \(c\),这个条件意味着,在所有比卡牌 \(i\) 强的牌中,没有一张的代价比当前卡牌的代价更低,因此,不存在任何卡牌能支配卡牌 \(i\),这张卡牌 \(i\) 应该被保留;如果当前卡牌的代价大于 \(c\),这意味着至少存在一张卡牌(即代价等于 \(c\) 的那张),它既比卡牌 \(i\) 强,又比卡牌 \(i\) 便宜,因此,卡牌 \(i\) 会被支配并淘汰。每当保留一张卡牌 \(i\) 时,需要用它的代价来更新变量 \(c\),因为对于后续更弱的卡牌来说,这张被保留的卡牌 \(i\) 也成为了一个潜在的支配者。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 2e5 + 5;
// 定义卡牌结构体,存储强度、代价和原始编号
struct Card {
int a, c, id;
};
Card cards[N]; // 存储所有卡牌信息的数组
bool f[N]; // 标记数组,f[i]为true表示原始编号为i的卡牌被保留
int main()
{
int n;
scanf("%d", &n);
// 步骤1: 读取输入,存储卡牌信息及其原始编号
for (int i = 1; i <= n; i++) {
scanf("%d%d", &cards[i].a, &cards[i].c);
cards[i].id = i;
}
// 步骤2: 按强度 a 从小到大对卡牌进行排序
// 使用 C++11 的 lambda 表达式定义排序规则
sort(cards + 1, cards + n + 1, [](Card c1, Card c2) {
return c1.a < c2.a;
});
// 步骤3: 从强到弱(即从后向前)遍历卡牌,决定哪些被保留
int ans = 1; // 结果计数,最强的卡牌一定会被保留,所以初始为1
int c = cards[n].c; // 变量c用于记录“已遍历过的更强卡牌”中的最低代价
f[cards[n].id] = true; // 最强的卡牌(cards[n])一定不会被淘汰,直接标记保留
for (int i = n - 1; i >= 1; i--) {
// 对于当前卡牌i,变量c中存储的是所有比它强的卡牌(i+1 到 n)中的最低代价。
// 如果当前卡牌的代价 cards[i].c 不比 c 更大(即小于等于c),
// 说明没有“更强且更便宜”的卡牌能淘汰它,所以它应该被保留。
if (cards[i].c <= c) {
ans++; // 保留的卡牌数加一
c = cards[i].c; // 更新“已遍历过的更强卡牌”的最低代价,因为这张卡牌现在是代价最低的了
f[cards[i].id] = true; // 标记这张卡牌的原始编号为保留
}
}
// 步骤4: 按格式输出结果
printf("%d\n", ans);
// 遍历编号1到n,如果标记为true,则输出该编号。
// 这样做可以保证输出的编号是升序的。
for (int i = 1; i <= n; i++) {
if (f[i]) {
printf("%d ", i);
}
}
return 0;
}
习题:P1104 生日
解题思路
题目要求按照年龄从大到小的顺序对学生排序,年龄大,等价于出生日期更早。
如果两个学生的出生日期完全相同,题目要求输入靠后的同学先输出,可以在输入时给每个学生配一个原始索引,这个索引也参与到多关键字排序的过程中。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
struct Stu { // 定义学生结构体
int y, m, d; // 年、月、日
int idx; // 记录原始输入顺序的索引,用于处理生日相同的平局情况
char s[25]; // 姓名
};
Stu stu[105]; // 学生结构体数组
// 自定义比较函数,用于 std::sort
// 返回 true 代表 s1 应该排在 s2 的前面
bool cmp(const Stu& s1, const Stu& s2) {
if (s1.y != s2.y) return s1.y < s2.y; // 规则1:比较年份,年份小的(年龄大)排前面
if (s1.m != s2.m) return s1.m < s2.m; // 规则2:年份相同,比较月份,月份小的排前面
if (s1.d != s2.d) return s1.d < s2.d; // 规则3:年月相同,比较日期,日期小的排前面
// 规则4(平局规则):生日完全相同,比较原始输入顺序
// 题目要求“输入靠后的同学先输出”,即原始索引大的排前面
return s1.idx > s2.idx;
}
int main()
{
int n;
scanf("%d", &n); // 读取学生总数
for (int i = 0; i < n; ++i) { // 循环读取每个学生的信息
scanf("%s%d%d%d", stu[i].s, &stu[i].y, &stu[i].m, &stu[i].d);
stu[i].idx = i; // 关键步骤:存储每个学生的原始输入顺序(索引)
}
sort(stu, stu + n, cmp); // 使用 std::sort 和自定义的比较函数 cmp 对学生数组进行排序
for (int i = 0; i < n; ++i) printf("%s\n", stu[i].s); // 循环遍历排序后的数组,并输出姓名
return 0;
}
习题:P5143 攀爬者
解题思路
因为是从最低的点爬到最高的点,则可以计算两个高度相邻的坐标之间的直线距离,把所有这样的距离加起来就是需要攀爬的总距离。因此需要先将所有的点按高度从小到大排序。
参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int N = 50005;
struct Coordinate {
int x, y, z;
};
Coordinate a[N];
double sqr(double x) {
return x * x;
}
double distance(Coordinate c1, Coordinate c2) {
return sqrt(sqr(c1.x - c2.x) + sqr(c1.y - c2.y) + sqr(c1.z - c2.z));
}
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d%d%d", &a[i].x, &a[i].y, &a[i].z);
sort(a + 1, a + n + 1, [](Coordinate c1, Coordinate c2) {
return c1.z < c2.z;
});
double ans = 0;
for (int i = 1; i < n; ++i) ans += distance(a[i], a[i + 1]);
printf("%.3f\n", ans);
return 0;
}
习题:P1068 [NOIP2009 普及组] 分数线划定
解题思路
先对所有考生进行一次完整的排序,这么做的好处是,一旦排序完成后,可以快速定位切分线。
分数线由排名第 \(m \times 150\%\)(向下取整)的考生的成绩决定,在 C++ 中,m * 150 / 100 或 m * 3 / 2 的整数除法可以完美地实现这个效果。把这个排名计算出来,在排序后的考生中可以快速定位到这个排名对应的考生,其成绩就是面试分数线。
由于所有不低于面试分数线的考生都可以进入面试,这个规则意味着,如果有多名考生的成绩恰好等于分数线,他们都将入围。因此,最终入围的人数可能会大于刚才计算出来的排名。
遍历排好序的考生序列,从第一个人开始,只要当前考生的成绩大于等于分数线,就将其计入最终名单。因为已经按成绩降序排列了,一旦遇到一个成绩低于分数线的考生,就可以确定之后的所有考生也都低于分数线,可以直接停止遍历。统计所有满足条件的考生人数,即为最终入围人数。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
struct Volunteer {
int k, s; // 报名号,成绩
};
Volunteer v[N]; // 结构体数组,存储所有考生信息
int main()
{
int n, m;
scanf("%d%d", &n, &m); // n:总人数,m:计划录取人数
for (int i = 1; i <= n; ++i) // 循环读取 n 个考生的信息
scanf("%d%d", &v[i].k, &v[i].s);
// 1. 先按照成绩和报名号将所有数据排序
sort(v + 1, v + n + 1, [](Volunteer v1, Volunteer v2) {
if (v1.s != v2.s) return v1.s > v2.s; // 主要比较条件:按成绩从高到低
return v1.k < v2.k; // 次要排序条件:成绩相同时,按报名号从小到大
}); // 使用 lambda 表达式作为自定义比较函数
// 2. 确定分数线所在的排名
// 计算 m * 150% 向下取整,得到划定分数线的排名
m = min(m * 3 / 2, n); // 注意这个人数不能超过n
// 3. 统计最终入围人数
int cnt = 0; // 记录实际入围人数
for (int i = 1; i <= n; ++i) {
// 以v[m]的成绩作为分数线,一旦遇到低于分数线的则结束循环
if (v[i].s >= v[m].s) ++cnt;
else break;
}
// 4. 输出结果
printf("%d %d\n", v[m].s, cnt); // 第一行输出分数线和实际入围人数
// 从第二行开始,输出所有入围者的信息
// 因为数组已经按要求排好序,直接输出前 cnt 个即可
for (int i = 1; i <= cnt; ++i) printf("%d %d\n", v[i].k, v[i].s);
return 0;
}
习题:P1204 [USACO1.2] 挤牛奶Milking Cows
解题思路
这道题的目标是计算多个时间段合并后的“最长连续工作时间”和“最长连续空闲时间”,是一个经典的区间合并问题。
有一堆表示工作时间的区间 \([l,r]\),当多个区间有重叠时,它们可以被视为一个更长的、连续的工作时间段。例如 \([300,1000]\) 和 \([700,1200]\) 重叠,可以合并成一个大的连续工作段 \([300,1200]\)。需要找到所有这样合并后形成的连续工作段中,最长的一个。同时,也需要找到这些合并后的连续工作段之间的空隙(无人工作时间),并找出其中最长的一个。
首先,将所有的区间(工作时段)按照它们的左端点(开始时间)从小到大排序。这是至关重要的一步,它保证了处理区间时是按照时间顺序向前推进的。
初始化一个“当前合并区间” \([bg,ed]\),它的值最初等于第一个(排序后)工作时段。对于接下来的每个工作时段 \(a_i\),有两种情况需要考虑。
- 有重叠:如果当前工作时段 \(a_i\) 的开始时间 \(a_i.l\) 小于等于 \(ed\)(当前合并区间的结束时间),说明 \(a_i\) 与正在构建的连续工作段有重叠,就可以尝试合并它。合并操作也很简单,只需要更新 \(ed\) 为 \(\max (ed, a_i.r)\),这是因为 \(a_i\) 可能会将连续工作段向右延伸。
- 无重叠:如果 \(a_i.l\) 大于 \(ed\),说明 \(a_i\) 与当前的连续工作段之间出现了一个空隙。这意味着一个连续的工作段到 \(ed\) 就结束了,一个新的工作段从 \(a_i.l\) 开始。此时,需要计算刚刚结束的那个连续工作段的长度,并更新“最长连续工作时间”的答案。同时,也计算出了一个空闲时段,其长度为 \(a_i.l - ed\),用这个长度来更新“最长空闲时间”的答案。然后,将“当前合并区间” \([bg,ed]\) 重置为新的工作时段 \([a_i.l, a_i.r]\),准备开始构建下一个连续工作段。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
struct Work { // 定义工作时段结构体
int l, r; // l:开始时间,r:结束时间
};
Work a[N]; // 结构体数组,存储所有工作时段
int main()
{
int n;
scanf("%d", &n); // 读取农民(工作时段)的数量
for (int i = 1; i <= n; i++) { // 循环读取 n 个工作时段
scanf("%d%d", &a[i].l, &a[i].r);
}
// 1. 将所有的工作时段按开始时间(左端点)从小到大排序
sort(a + 1, a + n + 1, [](Work w1, Work w2) {
return w1.l < w2.l;
});
// 2. 合并与计算
int bg = a[1].l, ed = a[1].r; // [bg,ed]为当前正在工作的时段,刚开始等于第一个时间段
int ans1 = ed - bg, ans2 = 0; // ans1为最长连续工作时间,ans2为最长连续空闲时间
for (int i = 2; i <= n; i++) { // 从第二个工作时段开始遍历
if (a[i].l <= ed) { // 说明a[i]这个工作时段与当前正在工作的时间段有交叉
ed = max(ed, a[i].r); // 更新合并区间的结束时间,取两者中更晚的那个
ans1 = max(ans1, ed - bg); // 可能需要更新最长连续工作时长
} else { // 与正在工作的时间段没有交叉,出现空隙
ans2 = max(ans2, a[i].l - ed); // 计算刚刚结束的空闲时段的长度,并更新最长空闲时间
bg = a[i].l; ed = a[i].r; // 重置合并区间,开始一个新的连续工作段
ans1 = max(ans1, ed - bg); // 可能需要更新最长连续工作时长
}
}
printf("%d %d\n", ans1, ans2); // 输出最终结果
return 0;
}
习题:P7910 [CSP-J 2021] 插入排序
解题思路(52 分)
完全模拟题目的要求,当遇到操作 2 时将原数组复制一份,带上原始下标进行排序,最后扫描一遍寻找原来的 \(a_x\) 在排序后的什么位置。时间复杂度为 \(O(qn \log n)\)。
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 8005;
int a[N];
struct Data {
int val, id;
};
Data b[N];
bool cmp(Data d1, Data d2) {
if (d1.val != d2.val) return d1.val < d2.val;
return d1.id < d2.id;
}
int main()
{
int n, q; scanf("%d%d", &n, &q);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= q; i++) {
int op; scanf("%d", &op);
if (op == 1) {
int v, x; scanf("%d%d", &v, &x);
a[v] = x;
} else {
int x; scanf("%d", &x);
for (int i = 1; i <= n; i++) {
b[i].val = a[i]; b[i].id = i;
}
sort(b + 1, b + n + 1, cmp);
for (int i = 1; i <= n; i++) {
if (b[i].id == x) {
printf("%d\n", i); break;
}
}
}
}
return 0;
}
解题思路(预期得分 76 分,但因测试数据不够强能获得 100 分)
注意到连续的操作 2 没有必要反复排序,因为操作 2 只查询排序后的位置而不是真的要将原数组变成有序数组。因此我们实现时真正需要做一次排序的其实只是数组被修改后的第一次查询,结合题中条件“操作 1 最多 5000 次”,则这样实现后时间复杂度为 \(O(qn+q_1 n \log n)\),其中 \(q_1\) 指操作 1 的最多次数,在本题中这个上限是 5000。
不过这一步优化并不能让我们通过更多的数据点,\(q_1 n \log n\) 依然很大,这里的瓶颈在于排序一次要 \(O(n \log n)\)。
这就涉及到了题目名称“插入排序”,考虑一个已经排好序的数组,将其中某一个元素修改后真的需要对整个数组重新排序吗?其实不然,因为被修改的数据只有一个,如果值不变,显然不需要重新排序;而如果这个修改的值是增大的,则这个修改的位置的前面实际上不需要动,只需要利用插入排序的思想将其移动到后面的合适的位置;同理,如果这个修改的值是减小的,只需要移动到前面的合适位置。所以,如果在一个本来有序的数组上修改一个元素后再排序,是可以做到 \(O(n)\) 的时间复杂度的。
由此,我们考虑先将原始数据排个序,此后一直维护这个排序后的数组,不过由于操作 1 涉及到修改原数组上的数据,所以这样一来,当遇到操作 1 时,我们得扫描一遍有序数组,找到原来的 \(a_x\) 在这个有序数组上的位置后再修改,修改完成后利用上面提到的插入排序思想,进行重排序,因此操作 1 的单次时间复杂度为 \(O(n)\)。遇到操作 2 时,扫描一遍整个有序数组,寻找原来的 \(a_x\) 在有序数组的什么位置上,这个时间复杂度也为 \(O(n)\)。
最终整个做法的时间复杂度为 \(O(n \log n + qn)\)。
#include <cstdio>
#include <algorithm>
using std::sort;
using std::swap;
const int N = 8005;
struct Data {
int val, id;
};
Data a[N];
bool cmp(Data d1, Data d2) { // 判断d1是否应该在d2前面
if (d1.val != d2.val) return d1.val < d2.val;
return d1.id < d2.id;
}
int main()
{
int n, q; scanf("%d%d", &n, &q);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i].val); a[i].id = i;
}
sort(a + 1, a + n + 1, cmp);
for (int i = 1; i <= q; i++) {
int op; scanf("%d", &op);
if (op == 1) {
int x, v; scanf("%d%d", &x, &v);
for (int i = 1; i <= n; i++) {
if (a[i].id == x) {
int old = a[i].val;
a[i].val = v;
// 利用插入排序思想处理单个元素修改时的重排序
if (v > old) {
for (int j = i; j <= n - 1; j++) {
if (!cmp(a[j], a[j + 1])) {
swap(a[j], a[j + 1]);
} else break;
}
} else {
for (int j = i - 1; j >= 1; j--) {
if (!cmp(a[j], a[j + 1])) {
swap(a[j], a[j + 1]);
} else break;
}
}
break;
}
}
} else {
int x; scanf("%d", &x);
for (int i = 1; i <= n; i++) {
if (a[i].id == x) {
printf("%d\n", i); break;
}
}
}
}
return 0;
}
解题思路(100 分正解)
为什么非得扫描一遍才能知道原数组里的 \(a_x\) 在有序数组中的哪个位置呢?
直接再创建一个数组记录原数组中的第几个元素对应排序后的哪个位置即可,当遇到操作 2 时,直接查询这个数组里的内容即可;在修改元素需要重排序时,如果元素位置发生变动,也顺势修改这个数组中的内容。
时间复杂度为 \(O(n \log n + q + q_1 n)\)。
#include <cstdio>
#include <algorithm>
using std::sort;
using std::swap;
const int N = 8005;
struct Data {
int val, idx;
};
Data a[N];
int ans[N]; // ans[i]记录原数组中的a[i]位于排序后数组的哪个位置
bool cmp(Data e1, Data e2) { // 比较e1是否小于e2
if (e1.val != e2.val) return e1.val < e2.val;
return e1.idx < e2.idx;
}
int main()
{
int n, q; scanf("%d%d", &n, &q);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i].val);
a[i].idx = i;
}
sort(a + 1, a + n + 1, cmp);
for (int i = 1; i <= n; i++) {
ans[a[i].idx] = i; // 记录原位置对应的排序后位置
}
for (int i = 1; i <= q; i++) {
int op; scanf("%d", &op);
if (op == 1) {
int x, v; scanf("%d%d", &x, &v);
// 操作1要修改的a[x]实际上是现在的a[ans[x]]
int old = a[ans[x]].val;
a[ans[x]].val = v;
if (v > old) {
// 向后重新插入
for (int j = ans[x]; j <= n - 1; j++) {
if (!cmp(a[j], a[j + 1])) {
swap(a[j], a[j + 1]);
// 交换后更新相应的排序后位置
ans[a[j].idx] = j; ans[a[j + 1].idx] = j + 1;
} else break;
}
} else if (v < old) {
// 向前重新插入
for (int j = ans[x] - 1; j >= 1; j--) {
if (!cmp(a[j], a[j + 1])) {
swap(a[j], a[j + 1]);
// 交换后更新相应的排序后位置
ans[a[j].idx] = j; ans[a[j + 1].idx] = j + 1;
} else break;
}
}
} else {
int x; scanf("%d", &x);
printf("%d\n", ans[x]);
}
}
return 0;
}
习题:P3819 松江 1843 路
解题思路
题目的目标是找到一个公交站的位置 \(p\),使得所有居民到这个车站的距离之和最小。这个总距离可以表示为数学公式:\(\text{TotalDistance}(p) = \sum |x_i - p| \cdot r_i\)(对所有房子 \(i\) 求和)。任务就是找到一个 \(p\),使得 \(\text{TotalDistance}(p)\) 最小。
对于这种形式的求和最小化问题,有一个非常重要的结论:最优解点 \(p\) 一定是所有居民位置的带权中位数。什么是带权中位数?想象一下,不把房子看作一个整体,而是把每个居民都看作一个独立的个体。将这 \(\sum r_i\) 个居民按照他们家的位置 \(x_i\) 从小到大排成一队。那么,排在最中间的那位居民,他家的位置就是要求的“带权中位数”。公交站建在这个位置,可以保证总距离和最小。为什么?可以把这个问题看作是一个物理上的力矩平衡问题,如果把车站设在 \(p\),每个在 \(p\) 左边的居民都会产生一个想把车站“拉”向右边的“力”,右边的居民则想把车站“拉”向左边。当左右两边的“总人数”(权重)相等时,系统达到平衡,总距离和最小,这个平衡点就是中位数所在的位置。
为了找到中位数,必须先将所有的房子按照它们的坐标 \(x_i\) 从小到大进行排序。
计算出总居民数 \(sumr\),中位数的位置就是累计居民总数第一次达到或超过 \(sumr/2\) 的那个点。遍历排序后的房子,并累加居民数,当累计居民数第一次满足这个条件时,那个房子的位置就是要找的最优公交站位置 \(p\)。
一旦找到了最优的车站位置,接下来的任务就是计算出总距离和。
参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
using ll = long long;
const int N = 100005;
struct House {
ll x;
int r;
};
House a[N];
int main()
{
ll l; int n;
scanf("%lld%d", &l, &n);
int sumr = 0;
for (int i = 1; i <= n; i++) {
scanf("%lld%d", &a[i].x, &a[i].r);
sumr += a[i].r;
}
sort(a + 1, a + n + 1, [](House &h1, House &h2) {
return h1.x < h2.x;
});
int pos = -1;
ll sum = 0;
for (int i = 1; i <= n; i++) {
sum += a[i].r;
if (pos == -1 && sum * 2 >= sumr) {
pos = i;
}
}
ll ans = 0;
for (int i = 1; i <= n; i++) {
ans += abs(a[i].x - a[pos].x) * a[i].r;
}
printf("%lld\n", ans);
return 0;
}

浙公网安备 33010602011771号