排序

在生活中,经常需要对一些东西排序。比如,考试后按照成绩高低排序;打扑克时要按点数或花色排序手牌。很多问题可以利用排序将无序的杂乱无章的东西整理清楚,便于查询统计和利用。

计数排序

例题: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\) 个投票箱,投票人支持谁就把票投入对应的投票箱中。投票后只需统计每个投票箱有几张选票,直接按照候选人编号取出来就可以完成排序操作。

image

#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 普及组] 明明的随机数

参考代码
#include <cstdio>
const int A = 1005;
int cnt[A];
int main()
{
    int n; scanf("%d", &n);
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        if (cnt[x] == 0) ans++; // 如果这个数第一次出现,则不同的数的个数+1
        cnt[x]++;
    }
    printf("%d\n", ans);
    for (int i = 1; i <= 1000; i++) {
        if (cnt[i] > 0) {
            printf("%d ", i);
        }
    }
    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;
}

选择排序、冒泡排序、插入排序

选择排序

#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;
}

例题: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)是一种简单直观的排序算法。基本思想是:比较相邻两个元素,如果两者之间的顺序有误,则交换这两个元素,直到整个序列中任意两个相邻位置顺序都正确。

image

如图所示,一轮过后,最大值被挤到了序列的最后面,因此下一轮可以将最后一个位置视为有序的,对剩余部分继续使用同样的流程即可。因此如果有 \(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;
}

程序阅读题:

#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。

插入排序

#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 默认是不保证稳定性的,如果需要让排序具备稳定性可以给每个原始元素配上它的原始下标作为一个额外的属性,从而在定义元素比较方式时将原始下标作为第二关键字。

例题: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;
}

习题:P1104 生日

参考代码
#include <cstdio>
#include <algorithm>
using std::sort;
const int N = 105;
const int LEN = 30;
struct Student {
	char name[LEN];
	int y, m, d, id;
};
Student a[N];
int main()
{
	int n; scanf("%d", &n);
	for (int i = 1; i <= n; i++) {
		scanf("%s%d%d%d", a[i].name, &a[i].y, &a[i].m, &a[i].d);
		a[i].id = i;
	}
	sort(a + 1, a + n + 1, [](Student s1, Student s2){
		// 年越小、月越小、日越小、id越大	
		if (s1.y != s2.y) return s1.y<s2.y;
		if (s1.m != s2.m) return s1.m<s2.m;
		if (s1.d != s2.d) return s1.d<s2.d;
		return s1.id>s2.id;
	});
	for (int i = 1; i <= n; i++) printf("%s\n", a[i].name);
    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 普及组] 分数线划定

参考代码
#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);
    for (int i = 1; i <= n; ++i)
        scanf("%d%d", &v[i].k, &v[i].s);
    // 先按照成绩和报名号将所有数据排序
    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;
    });
    m = min(m * 3 / 2, n); // 注意这个人数不能超过n
    int cnt = 0;
    for (int i = 1; i <= n; ++i) {
        // 以v[m]的成绩作为分数线,一旦遇到低于分数线的则结束循环
        if (v[i].s >= v[m].s) ++cnt;
        else break; 
    }
    printf("%d %d\n", v[m].s, 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

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
struct Work {
    int l, r;
};
Work 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, [](Work w1, Work w2) {
        return w1.l < w2.l;
    });
    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; // 当前正在工作的时间段切换为a[i]的时间段
            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;
}
posted @ 2023-08-08 20:47  RonChen  阅读(50)  评论(0编辑  收藏  举报