前言
在个人算法的学习过程中,排序算法是绕不过去的一道坎。不管是在现实生活中排序算法的应用,还是大多数算法比赛里对排序的考察,都体现了学习排序算法的重要性以及必要性。笔者也只是刚开始学习算法准备竞赛的大学生,因此这篇文章也仅仅是作为自己学习过程中的一个记录。
首先要明确,什么是排序?排序是按照一定的规定或标准对一系列数据或字符串进行重新排列顺序的操作。在我们日常刷题的过程中,遇到的题目不乏有许多有关排序的题。可能在刚开始接触这类题时,我们会无从下手。但经过对他人代码的阅读,或者对他人博客的学习,我们也能慢慢了解排序的过程在我们的代码中是如何实现的。以下是几种常用的排序方法的分析和总结。
1.计数排序
我们先来看看下面的问题
题目描述
学校正在选举学生会成员,有 n(n≤999)名候选人,每名候选人编号分别从 11 到 n,现在收集到了 m(m≤2000000)张选票,每张选票都写了一个候选人编号。现在想把这些堆积如山的选票按照投票数字从小到大排序。
输入格式
输入 n 和 m 以及 m 个选票上的数字。
输出格式
求出排序后的选票编号。
输入输出样例
输入 #1
5 10 2 5 2 2 5 2 2 2 1 2
输出 #1
1 2 2 2 2 2 2 2 5 5
注意到,m的取值范围非常大,因此最好不要先模拟投票过程,再将所有人得票进行排序,最后输出。这样会非常耗时,统计效率很低。因此,有一种不必我们主动去写排序程序的方法——计数排序。我们可以定义含有候选人人数个元素的数组并初始化,然后在键盘输入投票人所选择的候选人序号时,直接将数组中对应的元素值+1。待所有人都投票完成后,数组中每个元素的值就是每位候选人的得票数,并且是按照候选人序号排列好的。最后我们输出的时候直接写一个双重for循环从i = 0开始输出候选人序号即可。
代码如下:
#include <iostream> //counting sort 计数排序
using namespace std;
int a[1000] = {0}, n, m, tmp;
int main() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
cin >> tmp;
a[tmp]++; //选票直接计入对应数组的值中,数组下标作为排序对象
}
for (int i = 1; i <= n; i++)
for (int j = 0; j < a[i]; j++)
cout << i << ' '; //按照数组下标的顺序并遵循其值的多少来输出下标次数
return 0;
}
计数排序的时间复杂度一般会低于后面会介绍的排序算法,但是计数排序也有很难避免的弊端,它只能用于排序编号范围不是很大的数据。若整个数据量过大,数组可能无法定义那么多元素来一个个存放每个数据的得票数。此外,如果希望对浮点数或者字符串进行排序,计数排序也可能不能成为最优解。
2.选择排序
当我们面对一堆杂乱无章的扑克牌时,如果我们想将其按顺序重新整理好,我们应该如何去整理?
题目描述
将读入的 N 个数从小到大排序后输出。
输入格式
第一行为一个正整数 N。
第二行包含 N 个空格隔开的正整数 ai,为你需要进行排序的数。
输出格式
将给定的 N 个数从小到大输出,数之间空格隔开,行末换行且无空格。
输入输出样例
输入 #1
5 4 2 4 5 1
输出 #1
1 2 4 4 5
说明/提示
对于 20% 的数据,有 1≤N≤103;
对于 100% 的数据,有 1≤N≤105,1≤ai≤109。
我们可以从这个题中总结出三种常用的排序方法,相较于上面第一种计数排序,这三种对于大量的数据排序更加适用。首先是选择排序。
所谓选择排序,重点在于“选择”二字。选择要有一个对象,就是我们在排序的时候选择的是什么。拿从小到大排序举例,我们面对一个完全杂乱的牌堆,我们应该是期望先从第一张牌开始往后面找,找到一个比第一张小的牌,想办法把这一张小牌放到整个牌堆的前面。因此,我们可以这样来实现选择排序算法:
for (int i = 0; i < n-1; i++) {
for (int j = i+1; j < n; j++)
if (a[j] < a[i]) {
int p = a[i];
a[i] = a[j];
a[j] = p;
}
}
外层for循环先确定一张牌,然后进入内层for循环,从外层所确定的那一张牌开始往牌堆后面呢遍历,当我们找到一个比我们先确定的一张牌小的牌时,我们交换两张牌的位置。如此往复,直到内层循环走到牌堆末尾,这样我们第一张牌就已经放到了它该待的地方,然后外层for循环i++,进入第二轮循环……
选择排序算法有两层循环,时间复杂度是O(n²),由于需要一个数组来存放排序的数据,所以空间复杂度是O(n)。
3.冒泡排序
我们使用选择排序是将牌堆后面较小的牌扔到前面,冒泡排序则是将前面较大的牌放到牌堆后面。并且冒泡排序采用的是非常容易理解的“打擂台法”,很适合初学者理解排序程序的执行过程。
for (int i = 0; i < n-1; i++) {
for (int j = 0; j < n-1-i; j++)
if (a[j] > a[j+1]) {
int p = a[j];
a[j] = a[j+1];
a[j+1] = p;
}
}
第一层循环表示总体比较的轮数,第二层循环表示 i 选中的数与后面的数相比较,若没有 i 选中的数字大,则交换。整个排序过程类似“吐泡泡”上升的过程,因此被称为冒泡排序。
冒泡排序和选择排序的逻辑很类似,它的时间复杂度和选择排序也是相同的,都是O(n²)。另外,冒泡排序还有一种改良版,称为希尔排序,它所比较的不是两个相邻的数,而是间隔几个位置的数字,这里不加赘述。
4.插入排序
相较于前两种排序,插入排序的逻辑有些许不同,但同样是较为常用的排序方法。举个例子,当我们和其他人打牌时,我们拿到发到的牌后,会把它插入到比它小的数和比他大的数之间,即将牌定位到合适的位置。插入排序的方法可以类比这个例子。
for (int i = 1; i < n; i++) { //定义第一张牌i = 0为有序区,从第二张牌i = 1开始为无序区,从此处开始往前插入
int now = a[i], j; //now表示待插牌,j用来表示待插牌前面有序区的牌
for (j = i-1; j >= 0; j--) //从待插牌前一张开始,一直遍历到第一张牌
if (a[j] > now) //若前面的牌大于待插牌
a[j+1] = a[j]; //将该牌往后挪一位
else break;
a[j+1] = now; //若有序区无比待插牌更小的牌,或以达到第一张牌的位置,则将待插牌插入即排序完成
}
为了实现插入排序,我们首先要将混乱的牌堆分为两个区域:有序区和无序区。如何将其分为这两个区域呢?我们可以先规定牌堆的第一张牌位于有序区,其他的牌位于无序区,然后从第二张牌开始,选择其为待插牌,往前面遍历。如若遍历到的牌点数大于我们的待插牌,则将其往后挪一位,直到找到一张比待插牌小的牌或者遍历到第一位,再将该待插牌插入其位置。
假设原本的牌堆序列已经有了一定的顺序,则插入排序可以省去一定的插入步骤,直接扩大有序区,这样可以降低排序的时间复杂度。但如果原来的序列完全从大到小排序,则需要数次遍历整个序列,整个算法的时间复杂度就是O(n²),和选择、冒泡排序一样。不过,如果能保证要排序的序列基本有序,使用插入排序说不定可以达到不错的效果。
小总结~
由于以上三种算法的时间复杂度并不佳,如果在算法竞赛中遇到非常庞大的数据量,以上三种排序方法可能会耗时很久,所以一般算法竞赛中并不会使用这些排序算法。而学习这些算法的目的是理解排序算法的底层逻辑,明确排序是如何在程序中将无序排成有序,以及对自己思维进行一定的训练。下面将介绍一种在算法竞赛中较为常用的排序算法。
5.快速排序
当我们面对非常庞大的数据量,并且要对他进行排序时,选择一个时间复杂度合适的排序算法就非常重要了,快速排序就是一种选择。快速排序简称“快排”,其思想类似于二分,核心代码是快排函数里的递归运用,下面来介绍一下快排的原理。
写快速排序函数的时候,面对整个杂乱的数字序列,我们需要先随机确定一个基准点flag,然后定义两个指针,一个指向序列最左侧,另一个指向序列最右侧,函数开始运行后两个指针分别向我们定义的flag靠拢。位于flag左边的变量,都得比flag的值要小,而位于flag右边的变量,都要比flag要大。因此,当左侧指针指向的变量比flag大时,需要将其放到flag右侧,右侧指针指向的变量比flag小时,需要将其放到flag左侧。直到第一轮分类结束,之后再对flag两侧进行同样的操作,进行选基准点、分两侧排序等操作,直到整个序列不可再分位置,图解如下。
当我们每次分割序列随机选择flag时,快速排序的时间复杂度就是O(nlog n),空间复杂度为O(n),并且整个过程不需要额外的辅助空间,也因此,快速排序是竞赛中一种非常实用的排序。
快速排序函数代码如下
void qsort (int a[], int l, int r) { //a[]表示读入序列,l为最左侧,r为最右侧
int i = l, j = r, flag = a[(l+r)/2], tmp;
do {
while (a[i] < flag) i++; //从左侧往flag靠近
while (a[j] > flag) j--; //从右侧往flag靠近
if (i <= j) { //当左侧找到了大于flag、右侧找到了小于flag时
tmp = a[i]; a[i] = a[j]; a[j] = tmp;
i++; j--; //进行交换,完成左右分类
}
} while (i <= j); //左右指针相遇
if (l < j) qsort (a, l, j); //对flag左侧再进行快速排序
if (i < r) qsort (a, i, r); //对flag右侧再进行快速排序
}
C++中更为便捷的排序函数
众所周知,C和C++的一个重要区别在于前者为面向过程的计算机语言,后者为面向对象的计算机语言。所以,与C不同的,C++拥有功能强大的库函数,可以用来辅助排序,甚至直接高效解决排序问题。
我们先来看以下代码
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int N;
cin >> N;
int num[N];
for (int i = 0; i < N; i++)
cin >> num[i];
sort(num, num + N);
for (int i = 0; i < N; i++)
cout << num[i] << ' ';
return 0;
}
可以看到,代码中引用了C++中algorithm库中的快速排序函数sort对序列进行快速排序,并且在oj上看到运行时间后,可以发现sort函数的时间复杂度相较其他算法有较大的优势。至于sort函数的运行原理,可以在社区中查阅相关博客进行深入了解。
那么在程序设计中,我们应该如何使用sort函数呢?sort函数的参数如下
sort (a, a+n, cmp)
表示对数组a从a[0]到a[n-1]进行排序,其中的cmp表示自定义排序函数。
对于大多数含有排序的题,都可以直接使用sort函数解决排序问题。至于为什么说sort函数的功能比较强大,因为其中的cmp可以又我们自己来定义。当然,如果只需要将序列从小到大排序,则cmp可以直接省略。如若需要对序列从小到大排序,或者按照一定规则进行结构体排序,又或者对字符串进行排序,则需要自己定义编写一个cmp布尔函数,再以此作为参数传到sort函数中,就可以直接对序列按照自己的需求进行排序。
例如,对于非常大的整数,无法用int或long long储存,只能用string或字符数字储存。如若我们要对这种超大整数排序,就要自己定义一个cmp函数,再用sort函数进行排序,代码如下
#include <iostream>
#include <algorithm>
using namespace std;
struct human {
string x;
int num;
};
bool cmp (human a, human b) {
if (a.x.length() != b.x.length())
return a.x.length() > b.x.length();
return a.x > b.x;
}
int main() {
int n;
cin >> n;
struct human t[n];
for (int i = 0; i < n; i++) {
t[i].num = i+1;
cin >> t[i].x;
}
sort (t, t+n, cmp);
cout << t[0].num << '\n' << t[0].x;
return 0;
}
再比如,当我们需要对学生成绩按照总成绩、语文成绩、学号大小的优先级来进行排序,同样可以编写一个cmp函数,再用sort函数进行排序,代码如下
#include <iostream>
#include <algorithm>
using namespace std;
int n;
struct Stu {
int chi, math, eng, sum, xh;
};
bool cmp (struct Stu stu1, struct Stu stu2) {
if (stu1.sum != stu2.sum) return stu1.sum > stu2.sum;
if (stu1.chi != stu2.chi) return stu1.chi > stu2.chi;
return stu1.xh < stu2.xh;
}
int main() {
cin >> n;
struct Stu stu[n], tmp;
for (int i = 0; i < n; i++) {
cin >> stu[i].chi >> stu[i].eng >> stu[i].math;
stu[i].xh = i + 1;
stu[i].sum = stu[i].chi + stu[i].eng + stu[i].math;
}
sort (stu, stu+n, cmp);
for (int i = 0; i < 5; i++)
cout << stu[i].xh << ' ' << stu[i].sum << '\n';
return 0;
}
结束语
这几种排序是个人认为比较好理解,好入门的排序方法,其中最后一种sort函数也可以应用于各种算法竞赛中。当然还有很多其他的排序方法,归并排序、桶排序等等,笔者此时还未学习其他算法,将来学习后再回顾这篇记录相信会有其他的发现和想法。
以上。