文章目录
- 前言
- 一、什么是排序?
- 二、插入排序
-
- [1. 直接插入排序](#1. 直接插入排序)
-
- [1) 直接插入排序的思想](#1) 直接插入排序的思想)
- [2) 直接插入排序的时间复杂度](#2) 直接插入排序的时间复杂度)
- [3) 直接插入排序代码实现](#3) 直接插入排序代码实现)
- [2. 希尔排序](#2. 希尔排序)
-
- [1) 希尔排序的思想](#1) 希尔排序的思想)
- [2) 希尔排序的时间复杂度](#2) 希尔排序的时间复杂度)
- [3) 希尔排序代码实现](#3) 希尔排序代码实现)
- 三、选择排序
-
- [1. 直接选择排序](#1. 直接选择排序)
- [2. 堆排序](#2. 堆排序)
- 四、交换排序
-
- [1. 冒泡排序](#1. 冒泡排序)
- 2.快速排序
- 总结
前言
今天我们一起来学习排序,并且比较他们的时间复杂度~
一、什么是排序?
排序:所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的⼤⼩,递增或递减的排列起来的
操作。
就像我们逛淘宝看到的商品界面:
或者是抖音的热搜排行:
这些都需要用到排序,而我们接下来就会介绍几种常见的排序:
二、插入排序
基本思想:
直接插⼊排序是⼀种简单的插⼊排序法,其基本思想是:把待排序的记录按其关键码值的⼤⼩逐个插⼊到⼀个已经排好序的有序序列中,直到所有的记录插⼊完为⽌,得到⼀个新的有序序列。
实际中我们玩扑克牌时,就⽤了插⼊排序的思想。
1. 直接插入排序
1) 直接插入排序的思想
cpp
//直接插入排序
void InsertSort(int* arr, int n)
当插⼊第 i(i>=1) 个元素时,前⾯的 array[0],array[1],...,array[i-1] 已经排好序,此时⽤ array[i] 的排序码与 array[i-1],array[i-2],... 的排序码顺序进⾏⽐较,找到插⼊位置即将 array[i] 插⼊,原来位置上的元素顺序后移。
直接插入排序是一种简单的排序算法。它通过逐步构建有序序列,将未排序元素插入到已排序部分的合适位置。其工作原理类似于打扑克牌时整理手牌。算法的时间复杂度为O(n²),适用于小规模数据集。
这段代码实现了直接插入排序。简要步骤如下:
-
外层循环 :从
i = 0
开始遍历整个数组,i
代表当前已排序部分的最后一个元素的位置。 -
保存未排序元素 :
tem = arr[end + 1]
用来暂存当前未排序的元素。 -
内层循环 :
while (end >= 0)
逐步检查已排序部分。如果当前已排序元素比tem
大,则将其向右移动,为tem
腾出位置。 -
插入元素 :找到
tem
应插入的位置后,arr[end + 1] = tem
将暂存的元素放置到正确位置。
整个算法通过逐步将未排序元素插入到已排序部分,最终完成排序。
如下图所示:
第一步: 记录end 与 tem 的值。
第二步:比较arr[ end ] 与tem,以升序为例,如果大于就让 end + 1 位置的值被end位置的值覆盖,end- -。
第三步:重复第二步直到 arr[end] <= tem, 或end越界变为-1,将end[ end + 1 ]的值变为tem。
下一次循环时end与tem变为下一组,重复上述三步:
一直遍历到最后得到的结果为:
2) 直接插入排序的时间复杂度
直接插入排序的时间复杂度可以通过分析内外层循环的运行次数来推导。
最坏情况(逆序):
在最坏情况下,数组完全逆序排列,每次插入时需要遍历已排序的所有元素:
- 外层循环 :从
i = 0
到n-2
,运行了n-1
次。 - 内层循环 :对于每个
i
,内层循环需要最多i+1
次比较和移动(即最坏情况时需要向左比较所有已排序元素)。
所以总的比较和移动次数为:
因此,时间复杂度为 O(n²)。
最好情况(已排序)
在最好情况下,数组已经有序,每次插入时只需要做一次比较,内层循环几乎不运行:
- 外层循环 :同样执行
n-1
次。 - 内层循环 :每次只比较一次,所以内层循环总共执行
n-1
次。
因此,总的时间复杂度为 O(n)。
平均情况:
平均情况下,数组的元素是随机排列的。内层循环的执行次数在每次插入时介于最坏情况和最好情况之间,平均需要遍历约一半的已排序元素。此时总的比较次数仍为:
因此,平均时间复杂度也是 O(n²)。
总结:
- 最坏时间复杂度: O(n²)
- 最好时间复杂度: O(n)
- 平均时间复杂度: O(n²)
3) 直接插入排序代码实现
实现的代码是:
cpp
//直接插入排序
void InsertSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tem = arr[end + 1];
while (end >= 0)
{
if (arr[end] > tem)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end + 1] = tem;
}
}
2. 希尔排序
1) 希尔排序的思想
希尔排序法⼜称缩⼩增量法。希尔排序法的基本思想是:先选定⼀个整数(通常是gap = n/3+1),把待排序⽂件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进⾏排序,然后gap=gap/3+1得到下⼀个整数,再将数组分成各组,进⾏插⼊排序,当gap=1时,就相当于
直接插⼊排序。
它是在直接插⼊排序算法的基础上进⾏改进⽽来的,综合来说它的效率肯定是要⾼于直接插⼊排序。
他的总体思想是:
- 对已有数组进行预排序
- 当组数为一是进行直接插入排序
具体的实现思想如下图所示:
对于每一组数据都是进行直接插入排序,对数组进行预处理
2) 希尔排序的时间复杂度
希尔排序的时间复杂度估算:
外层循环:
外层循环的时间复杂度可以直接给出为: O(log2 n) 或者 O(log3 n) ,即 O(log n)
内层循环:
3) 希尔排序代码实现
cpp
//希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tem = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tem)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tem;
}
}
}
这里有一个小疑问:
gap / 3是因为经过大量实验,除3分组情况时间复杂度相对最好。
gap / 3 + 1是因为要保证最后一次循环一定为1,进行直接插入排序
三、选择排序
1. 直接选择排序
1)直接选择排序的思想
选择排序的基本思想:
每⼀次从待排序的数据元素中选出最⼩(或最⼤)的⼀个元素,存放在序列的起始位置,直到全部待
排序的数据元素排完 。
- 在元素集合
array[i]--array[n-1]
中选择关键码最⼤(⼩)的数据元素. - 若它不是这组元素中的
最后⼀个(第⼀个)
元素,则将它与这组元素中的最后⼀个(第⼀个)
元素
交换。 - 在剩余的
array[i]--array[n-2]
(array[i+1]--array[n-1]) 集合中,重复上述步
骤,直到集合剩余 1 个元素。
具体的实现步骤可看下图:
而在这过程中有一个小问题,如果当前maxi == begin
,那么元素就被交换了两次。
如:
因此我们需要特殊处理。
我们需要让maxi = mini
2)直接选择排序的时间复杂度
-
外层
while
循环:- 变量
begin
和end
控制未排序部分的边界。每次循环都会将未排序部分的最大值和最小值分别交换到两端,因此while
循环运行的次数是n/2
次(n
是数组的长度)。
- 变量
-
内层
for
循环:- 每次
for
循环遍历未排序部分的所有元素,从begin + 1
到end
,以找出最小值和最大值。因此每次for
循环的复杂度是 O(n),然后每次begin
和end
会向中间靠拢,减少了遍历的元素。
- 每次
-
交换操作:
- 每次循环结束后有两次交换操作,时间复杂度为 O(1),可忽略不计。
总的时间复杂度:
-
最坏情况、最好情况和平均情况:O(n²)。
- 每次
while
循环中的for
循环都会扫描所有未排序的元素,随着begin
和end
的靠拢,遍历元素的数量逐渐减少,但数量级仍然是 O(n)。 - 由于
while
循环执行了 O(n/2) 次,每次for
循环的时间复杂度是 O(n),因此总体时间复杂度是 O(n²)。
- 每次
3)直接选择排序代码实现
cpp
//选择排序
void SelectSort(int* arr, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i] > arr[maxi])
{
maxi = i;
}
}
if (maxi == begin)
{
maxi = mini;
}
Swap(&arr[mini], &arr[begin]);
Swap(&arr[maxi], &arr[end]);
begin++;
end--;
}
}
2. 堆排序
堆排序之前J桑已经实现过了,写的非常详细~
详情请看数据结构:Heap堆应用(堆排序,TOP-K问题)手把手带你入门数据结构~
四、交换排序
1. 冒泡排序
1)冒泡排序的思想
cpp
//冒泡排序
void BubbleSort(int* arr, int n);
冒泡排序作为我们最早接触到的排序,它的时间复杂度最坏的情况是
O(n^2),最好的情况时间复杂度为O(n)。
它的主要思想是外层循环控制内层循环遍历次数,内层循环遍历数组。
以升序为例,如果 arr[ j ] > arr[ j + 1 ],就两数交换,内层循环每结束一次就将当前数组最大的数放置在数组末尾。
2)冒泡排序的时间复杂度
冒泡排序时间复杂度分析
- 最坏情况
在最坏情况下,数组完全逆序排列,每次都需要进行完整的比较和交换。
- 外层循环 :执行
n
次,i
从0
到n-1
。 - 内层循环 :执行
n - 1 - i
次,其中i
表示当前已排序的元素数量。
因此,每次内层循环的总次数为:
所以最坏情况的时间复杂度为 O(n²)。
- 最好情况
在最好情况下,数组已经有序,每次都不需要交换元素。
- 外层循环 :依然执行
n
次。 - 内层循环 :在第一次执行时,没有任何交换操作,
exchange
保持为0
,因此直接跳出循环。
由于内层循环只执行了一次,因此最好情况的时间复杂度为 O(n)。
- 平均情况
在平均情况下,数组元素是随机排列的,内层循环执行次数约为总比较次数的一半:
因此,平均时间复杂度仍为 O(n²)。
总结:
- 最坏时间复杂度:O(n²)
- 最好时间复杂度:O(n)
- 平均时间复杂度:O(n²)
冒泡排序的效率通常不高,尤其是在数据量较大时,最坏情况下需要大量的比较和交换操作。
3)冒泡排序代码实现
cpp
//冒泡排序
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n; i++)
{
int exchange = 0;
for (int j = 0; j < n - 1 - i; j++)
{
//排升序
if (arr[j] > arr[j + 1])
{
exchange = 1;
Swap(&arr[j], &arr[j + 1]);
}
}
if (exchange == 0)
{
break;
}
}
}
2.快速排序
1)快速排序的思想(Hoare版)
(1)主要思想
快速排序是Hoare于1962年提出的⼀种⼆叉树结构的交换排序⽅法,其基本思想为:任取待排序元素
序列中的某元素作为基准值,按照该排序码将待排序集合分割成两⼦序列,左⼦序列中所有元素均⼩
于基准值,右⼦序列中所有元素均⼤于基准值,然后最左右⼦序列重复该过程,直到所有元素都排列
在相应位置上为⽌。
快速排序的思想如下:
首先一个数组,我们给他找一个基准值,这个基准值就使得基准值左侧的所有数据全都小于基准值,基准值右侧的所有数据全都大于基准值。
然后,所有基准值左侧的部分再划分一个基准值,分成小于和大于基准值的两侧。原本基准值的右侧也同理
然后我们再细分,直到划分到只有一个或没有元素
我们可以把它看成二叉树的结构,对于每一个结点,排列好的数组就相当于它左右子树通过基准值排列好的样子叠加起来。
对于我们初始的数组来说,最主要的任务就是如何找基准值,以及如何遍历我们的数组。
(2)如何遍历数组
这是我们初始的数组
假设有一个函数int _QuickSort(int* arr, int left, int right)
int _QuickSort(int* arr, int left, int right)
的功能是实现返回数组arr
中左边界left
和右边界right
的基准值。
那么我们就可以用这个数组模拟前序遍历的方式遍历这个数组,
假设基准值为keyi
,那么它的左子树就是[ left, keyi-1 ],右子树就是[ keyi+1, right]
如下图所示:
而递归的结束条件是 left >= right 这个后面就会理解
因此我们可以写出代码:
cpp
//快速排序
void QuickSort(int* arr, int left, int right)
{
//问题1:有没有等于呢?
if (left >= right)
{
return;
}
int keyi = _QuickSort(arr, left, right);
//遍历它的左子树
QuickSort(arr, left, keyi - 1);
//遍历它的右子树
QuickSort(arr, keyi + 1, right);
}
(3)找基准值
如何找基准值呢?
还是我们的数组,我们将第一个元素定位基准值,left为第二个元素,right为最后一个元素
如图:
- 首先,
right
从右向左找小于基准值
的位置,left
从左向右找大于基准值
的位置
- 然后交换
arr[left]
与arr[right]
- 交换过后
left ++
,right - -
此时left与right走到相同位置
注意:在left 与 right 相等时还需要继续循环,为了二分左右子树
- 我们接着
right
从右向左找小于基准值
的位置,left
从左向右找大于基准值
的位置
- 但是现在
left <= right
因此不交换,结束循环 - 将
keyi
位置的值与right
位置的值进行交换,此时right
的位置就是基准值的位置
- 特殊情况
如果rihgt或者left找到的值刚好等于基准值呢?我们来看一个特殊的数组
如果rihgt或者left找到的值刚好等于基准值还能循环的话最后就会变成这样
right就会来到如图所示的位置,那么最后我们return right相当于左子树为空,其余元素全在右子树,我们快排就是要一直二分数据。因此如果rihgt或者left找到的值刚好等于基准值不能循环
2)快速排序的时间复杂度
总结:O(n*logn)
3)快速排序代码实现
cpp
//找基准值
int _QuickSort(int* arr, int left, int right)
{
int keyi = left;
left++;
//对于传来的left与right,left从左往右找大,right从右往左找小
//问题1:有没有等于? 答:有,为了平衡左右子树达成二分的作用为了让right在往前走一格
while (left <= right)
{
//问题2:有没有等于? 答:没有,假设数组元素全部都是基准值,那么每次递归之分出去一个数据
//问题3: 为什么要加left <= right,因为left<=right就可以结束了不用循环了
while (left <= right && arr[right] > arr[keyi])
{
right--;
}
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
//出了这两个循环之后,就代表 left 与 right 都找到了各自的值,如果没找到也就越界了
if (left <= right)
{
Swap(&arr[left++], &arr[right--]);
}
}
//出了大的while循环就代表left已经超过right了,那么就需要交换right位置的值和保存的基准值
Swap(&arr[keyi], &arr[right]);
//right位置就是我们的基准值下标
return right;
}
//快速排序
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = _QuickSort(arr, left, right);
//遍历它的左子树
QuickSort(arr, left, keyi - 1);
//遍历它的右子树
QuickSort(arr, keyi + 1, right);
}
总结
到此为止我们基本的排序算法就讲完了,我们通过一则测试代码观察这些排序的时间复杂度
cpp
// 测试排序的性能对⽐
#include"Sort.h"
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
/*int* a6 = (int*)malloc(sizeof(int) * N);*/
int* a7 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
/*a6[i] = a1[i];*/
a7[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
printf("BubbleSort:%d\n", end7 - begin7);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
/*free(a6);*/
free(a7);
}
int main()
{
/*int a[] = { 5, 3, 9, 6, 2, 4, 7, 1, 8 };
int n = sizeof(a) / sizeof(int);
printf("排序前:");
PrintArr(a, n);
QuickSort(a, 0, n-1);
printf("排序后:");
PrintArr(a, n);*/
TestOP();
return 0;
}
结果为:
可以看到,快速排序是最快的,堆排,希尔排序也不错。
而直插,直选,和冒泡就比较慢了,特别是冒泡排序最慢用了12秒
那么我们来总结一下他们的时间复杂度:
以下是快速排序、堆排序、直接插入排序、直接选择排序、希尔排序和冒泡排序的时间复杂度和空间复杂度汇总表:
谢谢大家!