如何衡量一个排序算法?
- 执行效率
- 最好情况、最坏情况、平均情况时间复杂度。对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
- 时间复杂度的系数、常数 、低阶。实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
- 比较次数和交换(或移动)次数。基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动
- 内存消耗
- 稳定性。如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。很多数据结构和算法课程,在讲排序的时候,都是用整数来举例,但在真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。比如c++中的pair。
对于稳定性,举例子:我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。
解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。
冒泡排序(Bubble Sort)——带标志位
void bubble_sort_flag(int *L, int len) {
int flag = 1;
for (int i = 0; i < len && flag; i++) {
flag = 0;
for (int j = len - 1; j > i; j--) {
if (L[j] < L[j - 1]) {
swap(L[j], L[j - 1]);
flag = 1;
}
}
}
}
选择排序(Selection Sort)
核心:分已排序区间和未排序区间。选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
void select_sort(int *arr, int len) {
int index = -1;
for (int i = 0; i < len; i++) {
index = i;
for (int j = i + 1; j < len; j++) {
if (arr[index] > arr[j])
index = j;
}
if (i != index) swap(arr[index], arr[i]);
}
}
选择排序是一种不稳定的排序算法。比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。
插入排序(Insertion Sort)
void insert_sort(int *L, int len) {
for (int i = 1; i < len; i++) {
if (L[i] < L[i - 1]) {
int j = i - 1;//j表示待插入的位置,j是有序表的最后一个元素的位置
int temp = L[i];//i表示无序表
while (j >= 0 && temp < L[j]) {
L[j + 1] = L[j];
j--;
}
//表示找到了arr[j],新的数temp比它大,可以插在它后面。
//就像扑克牌一样,找到了满足条件的数,应该插在后面。
L[j + 1] = temp;
}
}
}
这三种时间复杂度为 O(n2) 的排序算法中,冒泡排序、选择排序,可能就纯粹停留在理论的层面了,学习的目的也只是为了开拓思维,实际开发中应用并不多,但是插入排序还是挺有用的。有些编程语言中的排序函数的实现原理会用到插入排序算法。
课后思考:
如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
超越了O(n^2)的新排序算法
希尔排序(shell sort),平均时间复杂度O(nlogn~n^2),最好时间复杂度是O(n^1.3),最坏时间复杂度O(n^2)。
希尔排序,递减增量排序算法,是插入排序的一种更高效的改进版本,是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。
- 插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。
基本有序:小的关键字基本在前面,大的基本在后面,不大不小的基本在中间
跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序
希尔排序的关键并不是随便分组各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序效率更高。
算法步骤:
- 分割子序列
- 将相距某个增量“increment”的记录组成一个子序列
- 在每个子序列插入排序
void shell_sort(int *arr, int len) {
for (int increment = len / 2; increment > 0; increment /= 2) {
for (int i = increment; i < len; i++) {
/*多个子序列和插入排序的单个子序列不同
单个子序列,分为已排序和待排序,所以从待排序取出一个,需要去插入到已排序序列
当分割成多个子序列,如果后面一个分割序列大于前面,不用插入
[1,3,6,9]和[12,14]此时12不用再插入到前面一个子序列中*/
if (arr[i] < arr[i - increment]) {
int temp = arr[i];
int j = i - increment;
while (j >= 0 && temp < arr[j]) {
arr[j + increment] = arr[j];
j -= increment;
}
arr[j + increment] = temp;
}
}
}
}
堆排序
堆:完全二叉树,假设下标从0开始。下标i的结点左孩子是2i+1,右孩子2i+2
说起堆排序,我一直以为需要构建二叉树,因为每次学习原理的时候都是用二叉树来表示,后来发现,其实只是利用二叉树的形,实则是利用数组下标的连接关系来实现。
堆排序的步骤(思想):
- 将待排序序列构造最大堆
- 将最大值的根节点和末尾交换,然后再调整将剩余的n-1个序列重新构造成最大堆
时间复杂度:构建堆的时间复杂度是O(n),重建堆的时间复杂度O(nlogn),所以堆排序的时间复杂度为O(nlogn)。
void heap_adjust(int *arr, int s, int m /*数组长度*/) {
int temp = arr[s];//s表示传入的节点,先把节点的值保存
for (int i = 2 * s+1; i< m; i = i*2+1) {
//i = 2*s+1表示左孩子,i<m因为左孩子下标最大是数组最后一位m-1,i = 2*i+1找它的左孩子
if (i+1 < m &&arr[i] < arr[i + 1])//i和i+1表示父节点s的左右孩子
++i;
if (temp >= arr[i])//如果根节点已经比孩子大了没有必要再交换
break;
arr[s] = arr[i];//把孩子结点值赋值给根节点,注意此时两者值都成了孩子结点。
s = i;//对这个孩子结点再去比较
}
arr[s] = temp;//把暂时保存根节点的值temp给孩子结点
}
void heap_sort(int *arr, int len) {
//将现在的待排序序列构建成一个大顶堆,从下而上的调整
for (int i = len / 2; i >= 0; i--) {
//为什么要从i = len/2开始,因为观察一个堆可以发现,节点 :len/2以下的才会是父节点
heap_adjust(arr, i, len);
}
//逐步将每个最大值的根节点与末尾元素交换,并再调整其成为大顶堆。
for (int i = len - 1; i > 0; i--) {
swap(arr[0], arr[i]);//将堆顶记录和最后一个记录交换
heap_adjust(arr, 0, i-1);
}
}
冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O(n^2),比较高,适合小规模数据的排序。归并排序和快速排序两种时间复杂度为 O(nlogn) 的排序算法。这两种排序算法适合大规模的数据排序。
归并排序(Merge Sort)
思想:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。分治是一种解决问题的处理思想,递归是一种编程技巧。
稳定性分析:归并函数中有if(a[i] < a[j])语句,说明它需要两两比较,不存在跳跃。因此就是一种稳定的排序算法。
归并算法时间复杂度和空间复杂度:
一趟归并需要把相邻first~last的记录扫描一遍放到arr中,需要把待排序序列中所有记录扫描一遍,因此耗费O(n)时间,由完全二叉树的深度可知,整个归并排序需要进行[log2n]次,因此总的时间复杂度O(nlogn);由于开辟了n个元素的额外数组,所以空间复杂度为O(n)。
//1.合并两个有序序列
void merge_array(int *arr, int first, int mid, int last, int* temp_arry) {
int i = first, j = mid + 1;
int k = 0;
while (i <= mid && j <= last) {
if (arr[i] <= arr[j]) {
temp_arry[k++] = arr[i++];
}
else {
temp_arry[k++] = arr[j++];
}
}
while (i <= mid) temp_arry[k++] = arr[i++];
while (j <= last) temp_arry[k++] = arr[j++];
for (int count = 0; count < k; count++) {
arr[first + count] = temp_arry[count];
}
}
//2.归并排序——递归实现
void merge_sort(int *arr, int left, int right, int *temp_arry) {
if (left >= right) return;
int mid = left + (right - left) / 2;
merge_sort(arr, left, mid, temp_arry);
merge_sort(arr, mid + 1, right, temp_arry);
merge_array(arr, left, mid, right, temp_arry);
}
void Merge_Sort(int *arr, int len) {
int* p_arry = new int[len];
merge_sort(arr, 0, len - 1, p_arry);
delete[] p_arry;//删除数组指针要特别说明
}
快速排序:
如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
下图中选取的pivot是从最右边开始的,不过代码中我们选取的pivot是最左边。
partition函数:选取的temp不断交换,比它小的放左边,比它大的放右边,然后temp也在不断变换。直到两部分完全分开。
快排算法为什么一定要从右边开始的原因:https://blog.csdn.net/w282529350/article/details/50982650
时间复杂度和空间复杂度:
平均时间复杂度O(n*logn),最好时间复杂度O(n*logn),最坏时间复杂度O(n^2)
(当排序数组时有序的情况,逆序的情况,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况)
为什么数组有序的情况下时间复杂度最坏呢?
因为有序你选取的pivot就是最大或者最小的,在partition函数就浪费大量比较时间。而且这也说明了为什么改进的快排要用三数取中,因为我们每次选取的pivot都更希望是数组的中位数,这样就能很好把数组分开。
快排的空间复杂度,本身是O(1)的,但是随着你调用递归函数,不断开辟临时变量temp,所以递归带来的额外空间复杂度O(logn)。
int partition(int *arr, int low, int high) {
int temp = arr[low];
while (low < high) {
while (low < high && temp <= arr[high]) {
--high;
}
swap(arr[low], arr[high]);
while (low < high && temp >= arr[low]) {
++low;
}
swap(arr[low], arr[high]);
}
return low;
}
//三数取中法,改进partition函数,要保证pivot不是最大或者最小的。
int partition_modified(int *arr, int low, int high) {
int temp;
int mid = low + (high - low) / 2;
if (arr[low] > arr[high]) swap(arr[low], arr[high]);
if (arr[mid] > arr[high]) swap(arr[mid], arr[high]);
if (arr[mid] > arr[low]) swap(arr[low], arr[mid]);
//交换完成之后,low数位上是整个序列左中右关键字的中间值
temp = arr[low];
while (low < high) {
while (low < high && temp <= arr[high]) {
--high;
}
swap(arr[low], arr[high]);
while (low < high && temp >= arr[low]) {
++low;
}
swap(arr[low], arr[high]);
}
return low;
}
void quicksort(int *arr, int low, int high) {
if (low >= high) return;
int t = partition_modified(arr, low, high);
//t的左边都是小于,右边都是比它大,像二分一样,所以不用在把arr[t]排序
quicksort(arr, low, t - 1);
quicksort(arr, t + 1, high);
}
题目:O(n) 时间复杂度内求无序数组中的第 K 大元素。比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。
利用partiation函数,当 m = k - 1时,m:=partiation函数返回的下标,k := 求得第K大元素。
三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序。这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
桶排序(Bucket sort)
核心思想是将要排序的数据均匀分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度分析:如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
桶排序的局限性:注意在核心思想提到的“均匀分到”,其实是一个很苛刻的假设,如果有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中(外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中)。
应用场景:
比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。
理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?
针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
计数排序(Counting sort)
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8] 中,它们分别是:2,5,3,0,2,3,0,3。考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。不过,C[6] 数组内存储的并不是考生,而是对应的考生个数。
我们对 C[6] 数组顺序求和,C[6] 数组存储的数据就变成了下面这样子。C[k] 里存储小于等于分数 k 的考生个数。
三个数组:
- 待排序数组:arr
- 最大值减去最小值再加1大小的计数数组:count
- 排好序的数组:psort
计数排序的基本思想为一组数在排序之前先统计这组数中其他数小于等于这个数的个数,则可以确定这个数的位置。
算法的步骤如下:
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为i的元素出现的次数,存入数组count的第i项
- 对所有的计数累加(从count中的位置为1的元素开始,每一项和前一项相加)
- 反向填充目标数组:将每个元素i放在新数组的第count(i)项,每放一个元素就将count(i)减去1(反向填充为了保持稳定性)
思考一个问题:一开始已经在count数组中统计了每个元素出现的次数,根据这个不就可以直接输出排序好的数组吗?
如果数据跨度特别大,本来是n个数字,count数组却是2n或者更多?如果你某个元素出现了k次,那么你在时间复杂度上不就又变成了O(k*2*n)?所以不能直接利用hash的性质来排序。
总结:计数排序做题要画出三个数组,根据count数组保存的是比它小的元素个数来写就很简单。
void CountSort(int *arr, int len) {
if (arr == NULL) return;
int max = arr[0], min = arr[0];
for (int i = 1; i < len; i++) {
if (arr[i] > max) max = arr[i];
if (arr[i] < min) min = arr[i];
}
int size = max - min + 1;//计数排序的缺点就是数据跨度特别大要开辟的额外空间很大。
int *count = (int*)malloc(sizeof(int)*size);
memset(count, 0, sizeof(int)*size);
for (int i = 0; i < len; i++) count[arr[i] - min]++;//确定每个元素出现次数
for (int i = 1; i < size; i++) count[i] += count[i - 1];
//比i小的元素出现次数
int* psort = (int*)malloc(sizeof(int)*len);
memset(psort, 0, sizeof(int)*len);
for (int i = len - 1; i >= 0; i--) {
int pos = count[arr[i] - min]- 1;
psort[pos] = arr[i];
//如果有count[pos] = 3,说明有3个比它小,直接在psort[3]插入就可以了,因为前面还有0 1 2
count[arr[i] - min]--;
}
for (int i = 0; i < len; i++) {
arr[i] = psort[i];
}
free(count);
free(psort);
count = NULL;
psort = NULL;
}
应用场景:
我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?
考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。
基数排序(Radix sort)
算法步骤:(以排序为整数非负整数举例,也可以是字母)
- 将所有待排序整数(注意,必须是非负整数)统一为位数相同的整数,位数较少的前面补零。一般用10进制,也可以用16进制甚至2进制。所以前提是能够找到最大值,得到最长的位数,设 k 进制下最长为位数为 d 。
- 从最低位开始,依次进行一次稳定排序。这样从最低位一直到最高位排序完成以后,整个序列就变成了一个有序序列。
举个例子,有一个整数序列,0, 123, 45, 386, 106,下面是排序过程:
第一次排序,个位,000 123 045 386 106,无任何变化
第二次排序,十位,000 106 123 045 386
第三次排序,百位,000 045 106 123 386
最终结果,0, 45, 106, 123, 386, 排序完成。
应用场景1:假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
手机号码有 11 位,范围太大,显然不是和计数排序和桶排序。我们可以按照以前讲的“稳定性”问题提到的订单问题。一个订单有两个属性,时间戳和金额。首先按照时间戳排序依次,再按照金额排序依次。所以这里也可以用相同的思路。先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。(注意一定要是稳定的排序算法,所以我们可以用桶排序或者计数排序,时间复杂度为O(n))。
应用场景2:比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的我特意去查了下,有 45 个字母,中文翻译是尘肺病。对于这种不等长的数据,基数排序还适用吗?实际上,我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。
要使用基数排序的数据,其中的某一位必须有明确的递进关系,比如数字0~9,字母a~b。其次,每一位数据范围不能太大。否则就不是一个O(n)算法。
如何实现一个通用的,高性能排序算法?
从时间复杂度考虑放弃O(n^2)的,选择O(n^2)的,再从空间复杂度考虑,不会使用归并,即使他平均,最坏时间复杂度都是O(nlogn),因为额外的O(n)空间复杂度。所以一般选取快排,那么如何避免快排的最坏时间复杂度O(n^2)的情况?在于pivot的选取。常见有三数取中法:首,中,尾取三个数,选取其平均值作为pivot。
举例分析C中排序函数qsort()
对于小数据量的排序,qsort()优先使用归并排序;排序数据量比较大的时候,会改为qsort()来排序,并且使用三数取中来选取pivo;当数据量小于等于4的时候,qsort()又会退化为插入排序。在快排调用时一定要注意合理的pivot选择,避免递归太深等等。
tips:在小数据量面前,O(n^2)的时间复杂度并不一定比O(nlogn)的算法执行时间长。在大O复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时,n2的值实际上比 knlogn+c 还要小。
knlogn+c = 1000 * 100 * log100 + 200 远大于10000
n^2 = 100*100 = 10000
课后思考:
C++中sort()是怎么实现的呢?用了哪些优化技巧?