前言:本文排序顺序均为从小到大
一、冒泡排序
1、算法描述
- ⽐较相邻的元素。如果第⼀个⽐第⼆个⼤,就交换它们两个;
- 对每⼀对相邻元素作同样的⼯作,从开始第⼀对到结尾的最后⼀对,这样每一趟排序,所有未排序元素中最后的元素即是最⼤的数;
- 重复复以上两步骤 n - 1 次( n 为数组的大小),直到排序完成。
- 本算法从后向前逐渐有序
2、代码展示
void bubbleSort(vector<int>& num)
{
int n=num.size();
for(int i=0;i<n-1;++i)
{
for(int j=0;j<n-1-i;++j)//第 i 次排序最后 i 位有序
{
if(num[j]>num[j+1])
swap(num[j],num[j+1]);
}
}
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
冒泡排序 | O(n^2) | O(1) | In-place | 稳定 |
二、插入排序
1、算法描述
- 分为已排序和未排序区间,初始已排序区间只有⼀个元素,是数组第⼀个元素。
- 遍历未排序的每⼀个元素在已排序区间⾥找到合适的位置插⼊并保证数据⼀直有序。
- 本算法从前向后逐渐有序(类似打扑克插牌)
2、代码展示
void insertSort(vector<int>& num)
{
int n=num.size();
for(int i=0;i<n;++i)
{
for(int j=i;j>0;--j)//第 i 次排序前 i 位有序
{
if(num[j-1]>num[j])
swap(num[j-1],num[j]);
}
}
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
插入排序 | O(n^2) | O(1) | In-place | 稳定 |
三、选择排序
1、算法描述
- 分已排序区间和未排序区间。每次会从未排序区间中找到最⼩的元素,将其放到已排序区间的末尾。
- 本算法从前向后逐渐有序
2、代码展示
void selectSort(vector<int>& num)
{
int n=num.size();
for(int i=0;i<n-1;++i)
{
int pos=i;
for(int j=i+1;j<n;++j)//第 i 次排序前 i 位有序
{
if(num[pos]>num[j])
pos=j;
}
swap(num[pos],num[i]);
}
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
选择排序 | O(n^2) | O(1) | In-place | 不稳定 |
四、快速排序
1、算法描述
- 先找到⼀个枢纽;在原来的元素⾥根据这个枢纽划分 ⽐这个枢纽⼩的元素排前⾯;⽐这个枢纽⼤的元素排后⾯;两部分数据依次递归排序下去直到最终有序。
- 荷兰国旗问题、递归实现
- 细节分析参考https://blog.csdn.net/saxon_li/article/details/121875003
2、代码展示
void quickSort(vector<int>& num,int left,int right)
{
if(left>=right) return;//递归边界
int tmp=rand()%(right-left+1)+left;
swap(num[tmp],num[left]);//随机base,降低时间复杂度
int base=num[left];//选择最左边的数字为 base
int i=left,j=right;
while(i<j)
{
//当基准数选择最左边的数字时,那么就应该先从右边开始搜索
while(i<j && num[j]>=base) j--;//从右向左,找到小于 base 的数字
while(i<j && num[i]<=base) i++;//从左向右,找到大于 base 的数字
if(i<j)//交换上述找到的位置不对的两个数字
{
int temp=num[i];
num[i]=num[j];
num[j]=temp;
}
}
//基数归位:此时 i 位置上的数一定小于 base
num[left]=num[i];
num[i]=base;
quickSort(num,left,i-1);//递归 base 的左半部分
quickSort(num,i+1,right);//递归 base 的右半部分
}
3、复杂度及稳定性
平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | O(logn) | In-place | 不稳定 |
注:时间复杂度取决于基址base的选择
五、归并排序
1、算法描述
- 假设 num[start…mid]、num[mid+1…end] 分别有序
- ⽤两个游标 left 和 right,分别指向 num[start…mid] 和 num[mid+1…end] 的第⼀个元素
- ⽐较这两个元素 num[left] 和num[right],如果num[left] <= num[right],我们就把 num[left] 放⼊到临时数组 tmp,并且 left 后移⼀位,否则将 num[right] 放⼊到数组 tmp,right 后移⼀位
- 递归实现
2、代码展示
void merge(vector<int>& num,int start,int mid,int end)
{
int n=end-start+1;
int *tmp=new int[n];
int left=start;
int right=mid+1;
int tmpPos=0;
while(left<=mid && right<=end)
{
if(num[left]<=num[right]) tmp[tmpPos++]=num[left++];
else tmp[tmpPos++]=num[right++];
}
while(left<=mid)
{
tmp[tmpPos++]=num[left++];
}
while(right<=end)
{
tmp[tmpPos++]=num[right++];
}
for(int i=0;i<n;++i)
{
num[start+i]=tmp[i];
}
delete[] tmp;
}
void mergeSort(vector<int>& num,int start,int end)//采取左闭右闭写法(end 最开始不为数组长度,而是最后一个有效下标)
{
if(start>=end) return;
int mid=(end-start)/2+start;
mergeSort(num,start,mid);
mergeSort(num,mid+1,end);
merge(num,start,mid,end);
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
归并排序 | O(nlogn) | O(n) | Out-place | 稳定 |
六、堆排序
1、算法描述(细节参考代码注释)
- 利⽤给定数组的 1-n/2号元素 创建⼀个堆(这⾥使⽤最大堆,保证排序结果为从小到大)
- 最后⼀个元素与堆顶交换,调整成堆,此时堆末元素已有序
- 重复上一步,直到堆的尺⼨为 1 ,排序完成
2、代码展示
void adjustHeap(vector<int>& num,int father,int end)
{
int leftChild=father*2;//左孩子下标为 父亲节点的序号 * 2
int rightChild=father*2+1;//右孩子下标为 父亲节点的序号 * 2 + 1
int max=father;
if(leftChild<=end && num[leftChild]>num[max]) max=leftChild;
if(rightChild<=end && num[rightChild]>num[max]) max=rightChild;
if(max!=father)
{
swap(num[max],num[father]);//交换两个数值,但此时max还是指向孩子节点
adjustHeap(num,max,end);//交换可能破坏堆的性质, 故要进行调整
}
}
void heapSort(vector<int>& num)//为了方便堆的孩子节点的计算,我们假设 num[0] 不存储有效元素
{
int n=num.size()-1;//采取右闭区间
for(int i=n/2;i>0;--i)//从最后一个非叶子节点,创建大顶堆(叶子节点不需要堆化)
{
adjustHeap(num,i,n);
}
for(int i=n;i>0;--i)
{
swap(num[i],num[1]);//将堆顶元素(数组首位)与堆末元素(数组末位)互换
adjust(num,1,i-1);//交换可能破坏堆的性质, 故要进行调整,此时堆末元素已经有序(存的是最大值)
}
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
堆排序 | O(nlogn) | O(1) | In-place | 不稳定 |
注1:堆排序的不稳定性主要发生在 adjustHeap 函数嵌套的 adjustHeap 函数中,即在调整堆的过程中破坏了其左右孩子的堆结构,左右孩子再调整过程中,就会产生不稳定。
注2:空间复杂度不像快排为O(logn)而是O(1),主要是因为堆排序的递归不是要走完树的所有路径,而是选择一条路径走到底完成堆调整即可,故是O(1)级别的。
七、桶排序
1、算法描述
注意事项参考第八项的计数排序
- 将数组分到有限数量的桶⾥,每个桶再个别排序(有可能再使⽤别的排序算法或是以递归⽅式继续使⽤桶排序进⾏排序)。
- 本算法介绍简单的整数排序。
2、代码展示
void bucketSort(vector<int>& num)
{
int min = min_element(nums.begin(), nums.end());
int max = max_element(nums.begin(), nums.end());
int n = max - min + 1;
vector<int> buckets(n);
vector<int> res;
for (auto x : nums) ++buckets[x - min];//按照数据相对于最小值的大小来定位,同时计数
for (int i = 0; i < n; ++i) //安装遍历输出bucket所计数的元素
{
while(bucket[i]>0)
{
res.push_back(i+min);//恢复原数据大小
bucket[i]--;
}
}
num.assign(res.begin(),res.end());
}
3、复杂度及稳定性
平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
桶排序 | O(n+k) | O(n) | O(n^2) | O(n+k) | Out-place | 稳定 |
注:桶排序的时间复杂度取决于桶内排序使用的方法。
八、计数排序
1、算法描述
注意:计数排序不是比较排序,且需要⽐较多的辅助空间,是牺牲空间换取时间的做法。其前提条件,就是待排序的数要满⾜⼀定的范围的整数。 它只能⽤在数据范围不⼤的场景中,如果数据范围 k ⽐要排序的数据 n ⼤很多,就不适合⽤计数排序了。⽽且,计数排序只能给⾮负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对⼤⼩的情况下,转化为⾮负整数。
- 找到待排序数组的最大值和最小值;
- 统计数组中每个数字出现的次数,存入辅助数组中;
- 对辅助数组总数累计(后面的值出现的位置为前面所有值出现的次数之和);
- 逆序输出辅助数组中的值。
2、代码展示
void countSort(vector<int>& num)
{
int min=num[0],max=min;
//寻找原数组的最大值和最小值(解决负数问题)
for(int i=0;i<num.size();++i)
{
if(num[i]>max) max=num[i];
if(num[i]<min) min=num[i];
}
//计数
int size=max-min+1;
vector<int> count(size,0);
for(int i=0;i<num.size();++i)
count[num[i]-min]++;
//前缀和:求大于等于i的数字的个数,以供后续确定每个数字在最终有序数组中的位置
for(int i=1;i<size;++i)
count[i]+=count[i-1];
//确定每个数字在最终有序数组中的位置
vector<int> res(num.begin(),num.end());
for(int i=num.size()-1;i>=0;--i)//注意:从后向前安放
{
res[count[num[i]-min]-1]=num[i];//稳定性的体现,把num数组中从后往前的每个数放在正确的位置上
--count[num[i]-min];
}
num.assign(res.begin(),res.end());
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
计数排序 | O(n+k) | O(k) | Out-place | 稳定 |
注:计数排序是稳定的,它的稳定性体现在后出现的元素在最终结果中也在后面的位置。
九、基数排序
1、算法描述
注意:基数排序同样是有适用范围的,需要排序的元素要有“进制”这个概念,并且其进制中每一位的范围不能过大,需要可以利用线性排序算法,只有这样基数排序的时间复杂度才可达到O(n)。
- 以十进制为例,需先知道预排序数组 num 的最大位数 d;
- 从 1 ~ d 位循环获取 num 的每个数据的该位数,并且针对该位数进行计数排序;
- 每次使得数据按照某位叠加有序,循环 d 次后整个数组有序;
- 相当于利用循环进行了多次计数排序。
2、代码展示
int getDigit(int num,int radix,int d)
{
num=num/pow(radix,d-1);
return num%10;
}
void radixSort(vector<int>& num,int d)//d表示num数组中最大数一共有多少位
{
int radix=10;//十进制为例
vector<int> cnt(radix,0);//计数数组
vector<int> bucket(num.begin(),num.end());//辅助排序数组
for(int i=1;i<=d;++i)//循环处理每一位
{
for(int i=0;i<radix;++i) cnt[i]=0;//计数数组清零
for(int j=0;j<num.size();++j)
{
int digit=getDigit(num[j],radix,i);//获取num中第 j 个数的从小到大第 i 位
cnt[digit]++;
}
for(int j=1;j<cnt.size();++j) cnt[j]+=cnt[j-1];//前缀和计算位置
for(int j=num.size()-1;j>=0;--j)//注意:从后向前安放
{
int digit=getDigit(num[j],radix,i);
bucket[cnt[digit]-1]=num[j];//安放数据到排序好的位置上
cnt[digit]--;
}
num.assign(bucket.begin(),bucket.end());//每次从各个桶中重新收集数据
}
}
3、复杂度及稳定性
最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
基数排序 | O(n+k) | O(k) | Out-place | 稳定 |
十、希尔排序
1、算法描述
- 通过将⽐较的全部元素分为⼏个区域来提升插⼊排序的性能。这样可以让⼀个元素可以⼀次性地朝最终位置前进⼀⼤步。然后算法再取越来越⼩的步⻓进⾏排序,算法的最后⼀步就是普通的插⼊排序,但是到了这步,需排序的数据⼏乎是已排好的了。
- 具体参考五分钟学会希尔排序【中英字幕】_哔哩哔哩_bilibili
2、代码展示
void shellSort(vector<int>& num)
{
for (int gap = nums.size() / 2; gap > 0; gap /= 2) //选择间隙大小
{
for (int i = gap; i < nums.size(); ++i) //选择每次的base量
{
for (int j = i; j - gap >= 0 && nums[j - gap] > nums[j]; j -= gap) //base-gap
{
swap(nums[j - gap], nums[j]);
}
}
}
}
3、复杂度及稳定性
平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 排序方式 | 稳定性 | |
希尔排序 | O(n^1.3) | O(n) | O(n^2) | O(1) | In-place | 不稳定 |
十一、小结
1、基于比较的排序时间复杂度不低于O(nlogn);
证明:
1> 首先,对于 n 个待排序元素,在未比较时,可能的正确结果有 n! 种。
2> 在经过 1 次比较后,其中两个元素的顺序被确定,所以可能的正确结果剩余 n! / 2 种。
3> 依次类推,直到经过 m 次比较,剩余可能性 n! / 2^m 种。
4> 直到 n! / 2^m <= 1 时,结果只剩余 1 种。此时的比较次数 m 为 o(nlogn) 次。(logn! 与 nlogn 同阶)
5> 所以基于排序的比较算法,最优情况下,复杂度是 o(nlogn) 的。
2、不存在时间复杂度为O(nlogn),空间复杂度低于O(n),且稳定的基于比较的排序算法;
3、快排是在实践中最快的排序方法,要求又快又稳定的选择归并排序,要求又快空间复杂度又较低选择堆排序。
4、工程上对于排序的改进:
- 多路(综合)排序:充分利用不同排序的各自优势,例如在快排(O(nlogn))时,判断当样本数量小于等于60时,直接采用插入排序(O(n^2)),样本量大于60时,才继续使用快排。这样做是因为插入排序在样本量较小时O(n^2)的时间复杂度瓶颈并不高,可以充分其常数操作十分少的优势提高代码效率;在样本量较大时又充分利用了快排的快速调度。
- 稳定性考虑:在使用系统提供的代码时,当输入数据为基础类型时用快排(不需要考虑稳定性),否则用归并。