文章目录
- 前言
- 插入排序
- 希尔排序
- 选择排序
- 堆排序
- 快速排序
- hoare原生版本
- 挖坑法
- 前后指针法
- 三数取中优化
- 随机数取key优化
- 三路划分版
- 非递归
- 归并排序
- 递归
- 非递归
- 调整边界
- 单次归并单次拷贝
- 总结
前言
排序,以字面意思来说就是通过特定的算法将一组或多组无序或者接近有序的数据,以升序或者降序的方式重新进行排序组合;
[7,4,2,9,8,6,5,1,3]; |
以升序的方式进行排序最终为:
[1,2,3,4,5,6,7,8,9]; |
排序算法就是如何使得数据按照要求排列的方法;
排序的算法多种多样,基本的排序算法共有八种,分别为:
冒泡排序 | 选择排序 | 插入排序 | 希尔排序 | 归并排序 | 快速排序 | 堆排序 | 计数排序 |
插入排序
插入排序作为排序中较为简单和经典的排序,插入排序的思维可以看作为一个打牌中摸牌顺牌的阶段一样;
假设手里的牌都是顺序的,摸下一张牌与之前的牌一张一张进行比较,设这张牌为n,将n与之前顺序的牌进行比较,当遇到比它小的牌的时候即将该牌放到此处;
设[0,end]区间为有序,cur为end后的下一个数据,将该数据进行保存,若是遇到比cur大的数据则挪动数据,将cur放至比cur小的数据之前,若是cur比end位置大则不需要进行上步操作,若是cur比[0,end]区间内的任意数据都小,那就挪动数据,将cur放置0处,同时++end;
void InsertSort(int* a, int n)
{
for (int i = 0; i < n-1; i++) {
int end = i;
int tmp = a[end + 1];
while (end >= 0) {
if (a[end] > tmp) {
a[end + 1] = a[end];
end--;
}
else break;
}
a[end+1] = tmp;
}
}
时间复杂度:
插入排序的时间复杂度有两种情况:
- 当数据为有序的情况下,只需要遍历n-1次,为最优的情况,时间复杂度为O(N);
- 当数据为逆序的情况时,为最坏的情况,最需要遍历1+2+3+4+…+N-1次,时间复杂度为O(N2);
空间复杂度:
- 插入排序的空间复杂度为常数阶O(1);
稳定性
- 稳定
希尔排序
希尔排序是在以插入排序为前提下进行的一种优化的排序,由于插入排序不太擅长排序逆序的情况,所以一位叫做Donald L. Shell的大佬对该排序进行了改进;
希尔排序又被称为" 缩小增量排序 ",即在插入排序之前进行预排序;
设gap,将间隔为gap的数据分为一组,共分为gap组;
gap没有具体的规范,但是一般而言gap可以跟数据量有关系;
将每组数据利用插入排序的方法进行一次预排序,当预排序结束时,数据并没有完全有序,但是较为接近有序;
当然,根据数据量的变化,预排序可以进行不止一次;
再预排序过后即可进行插入排序;
void ShellSort(int* a, int n)
{
/*
* gap根据数据个数进行变化
* 故初始值赋值数据个数
*/
int gap = n;
while(gap>1){
/*
* 控制gap使其不停进行变化
* 当gap为1时则为最后一次排序,也是直接插入排序
*/
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++) {//n-gap防止越界
/*
* 直接插入排序的思路
*/
int end = i;
int tmp = a[end + gap];
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end-=gap;
}
else break;
}
a[end + gap] = tmp;
}
}
}
时间复杂度
- 希尔排序的时间复杂度为O(N3/2),相对于直接插入排序的最坏情况O(N2)来说要快一些;
空间复杂度
- 希尔排序的空间复杂度也为O(1);
稳定性
- 不稳定
选择排序
选择排序的思路为双指针:
设begin为0,cur为begin+1,cur遍历一遍数据,找到最小或者最大的那个数据并将该位置保存,最后与begin所在的数据进行交换;
交换过后begin++;
为了使排序不那么繁琐可以使用begin与end两个指针记录头尾,cur指针从前往后分别找到最小和最大的数据并保存位置,最小的数据与begin交换,最大的数据与end进行交换,交换过后++begin与–end从而控制区间;
void SelectSort(int*a ,int n)
{
int begin = 0, end = n - 1;
//[begin,end]左闭右闭区间
while (begin < end) {
int min = begin, max = begin;
for (int i = begin + 1; i <= end; i++) {
if (a[i] < a[min]) {
min = i;
}
if (a[i] > a[max]) {
max = i;
}
}
swap(&a[begin], &a[min]);
if (begin == max) {
max = min;
}
swap(&a[max], &a[end]);
++begin, --end;
}
}
时间复杂度
- 当数据为完全顺序的情况下,不需要进行交换,则时间复杂度为O(N);
- 若是数据为逆序的情况下则为最坏的情况,交换次数即为O(N-1)次;
空间复杂度
- 选择排序的空间复杂度为常数阶O(1);
稳定性
- 不稳定
堆排序
堆排序是基于堆而做的一种排序,同时堆排序也算是选择排序中的一种排序;
既然是基于堆的排序,首先数据必须为一个堆(升序建大堆,降序建小堆),而所给的每组数据中是堆的可能性并不大;
所以再进行排序之前应该先进行建堆,建堆的同时分为向上调整建堆与向下调整建堆;
堆排的思路为将头尾数据进行交换再进行向下调整,故向下调整是必须具有的一个,同时基于向下调整建堆的时间复杂度(O(N))优于向上调整建堆(O(N*logN)),故在这里只需要写一个向下调整算法就能实现堆排序;
void AdjustDown(int*a,int parent,int n)//parent==0 n == 6
{
int child = parent * 2 + 1;//1
while(child<n) {//child<6
if (child + 1<n&& a[child + 1] > a[child]) {
child++;
}
if (a[child] > a[parent]) {
swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else break;
}
}
void HeatSort(int* a, int n)//n==7
{
/*建堆*/
for (int i = (n-2)/2; i >= 0; i--) {
AdjustDown(a, i, n);
}
int end = n - 1;
while (end>0) {
swap(&a[0], &a[end]); //交换头尾
AdjustDown(a, 0,end);//传值传的是个数
end--;
}
}
时间复杂度
- 堆排是在堆中进行选书,相对传统的选择排序来说效率快了很多,时间复杂度为O(N*logN);
空间复杂度
- 堆排的空间复杂度为常数阶O(1);
稳定性
- 不稳定
快速排序
快速排序是一个相对来说应用最广泛的一种排序算法,它一开始为一名英国计算机科学家霍尔于1960年所发明;
流行的原因是这个排序算法实现简单,且适用于不同的数据,同时相对于排序算法中的其他算法要快得多;
快速排序的基本思想为一种左右分治的思想,就如二叉树相同,将问题分为两个子问题进行解决;
基本思想为:
任取待排元素序列中的其中一个元素作为key值基准值;
按照待排序的区间分为两个序列,key值左边的数据均小于key,key值右边的数据均大于key;
再进行不断分治,最终将对应顺序的数据排列再相应位置即结束;
hoare原生版本
这个版本的也是最初的版本
其单次排序的思想即为:
选取数据中最左边的数据作为基准值key,保存该数据的下标keyi方便数据与key进行比较;
设左右两个指针left与right,left为keyi+1,right为最后一个数据的下标;
left从前往后遍历,right从后往前进行遍历,left找比key大的数据,right找比eky小的数据;
当left找到相应的数据同时right也找到相应的数据时,将两个数据进行交换;
交换过后再继续遍历;
当left与right相遇时,则停止遍历,同时将相遇位置的值与key进行交换;
同时,在遍历时若是需要左右指针都在中间位置停止,那必须保证key在左边时右指针先往前遍历,key若是在最右边选数则需要左边指针先进行遍历
//单次排序
int PartSort(int* a, int left, int right)
{
int keyi = left;
while (left < right) {
while (left < right&&a[right] >= a[keyi])
right--;
while (left < right&&a[left ] <= a[keyi])
left++;
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[left]);
return left;
}
当第一次排序过后,将要以前序遍历的方式进行分治;
区间将会以[begin,keyi-1]keyi[keyi+1,end]进行划分;
根据该思维进行递归;
void QuitSort(int* a, int begin, int end)
{
if (begin>=end) {
return;
}
int kiey = PartSort(a, begin, end);
QuitSort(a, begin, kiey-1);
QuitSort(a, kiey+1, end);
}
挖坑法
挖坑法的思路与hoare原生版本的思路大体相同:
思路即为在开始前记住最左边的基准值key的值并将其保存,左右指针同时往中间进行遍历
左边找比基准值key大的值,右边找小的值,与hoare相同,key在左边时右指针先进行遍历;
当最左边key值被保存之后,key所在的位置即形成一个坑;
当右指针找到相应的值时则将右指针的值给给这个坑,此时left所在的位置,也就是key的位置的坑被填;
同时right指针所在的位置又新生成一个坑,left指针再从后往前遍历找到对应位置进行填补,如此反复;
当两个指针相遇或者相交时,所在的位置必定是一个坑,此时再把key值填入次坑;
//单次排序
int PartSort(int* a, int left, int right)
{
int key = a[left];
int hole = left;
while (left < right) {
while (left < right&&a[right] >= key)
right--;
a[hole] = a[right];
hole = right;
while (left < right&&a[left ] <= key)
left++;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
其递归的思路都一致;
前后指针法
前后指针,顾名思义即为前后两个指针进行遍历;
其思路为:
设指针keyi,指针prev与指针cur,用cur指针进行遍历,当cur所在位置小于基准值key值时,
prev指针先++,而后将cur指针所在的数据与prev所在的数据进行交换;
当cur大于right时,则停止遍历,将此时prev所在位置的值与key值进行交换;
此时key值左边的数小于key,右边则大于key;
//单趟排序
int PartSort(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur<=right) {
if (a[cur] < a[keyi]) {
prev++;
swap(&a[prev], &a[cur]);
}
cur++;
}
swap(&a[prev], &a[keyi]);
return prev;
}
该方法的递归思路与上面两种方式相当;
三数取中优化
快速排序,是基于在综合性能以及综合使用场景情况下速度较快,效率较高才之所以叫做快速排序;
当然在一定情况下,快速排序依旧有短板;
在一般情况下,综合来说,快速排序的时间复杂度为O(N*logN),而不排除一些极端的情况;
即数据本身就为有序的情况;
在这个情况下,由于上述选基准值key值的方式都是为数据中最左边或者最右边的数据,而若是数据本身有序的情况;
快速排序的时间复杂度将会为O(N2);
由于key值每次都为最小值,导致快速排序遍历速度更慢,最终时间复杂度将会为O(N2);
针对该种情况,有人设计了一种情况,即为将key值取头尾中间三个数据中,既不是最大也不是最小的值;
int GetMidIndex(int* a, int left, int right) {
int mid = (left + right) / 2;
if (a[mid] > a[left]) {
if (a[right] > a[mid]) {
return mid;
}
else if (a[left] > a[right]) {
return left;
}
else return right;
}
else // a[left]>a[mid]
{
if (a[right] > a[left]) {
return left;
}
else if (a[mid] > a[right]) {
return mid;
}
else return right;
}
}
当每次单趟排序时,设置一个指针mid指向中间位置,将该中间值与最左以及最右进行三数取中并返回下标;
再将它与key值进行交换;
//使用hoare原生版本的单趟排序作为演示
int PartSort(int* a, int left, int right)
{
int mid = GetMidIndex(a,left,right);
swap(&a[left], &a[mid]);
int keyi = left;
while (left < right) {
while (left < right&&a[right] >= a[keyi])
right--;
while (left < right&&a[left ] <= a[keyi])
left++;
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[left]);
return left;
}
该方法的优化解决了快速排序中遇到的有序情况;
随机数取key优化
由于三数取中是能解决一部分办法,但是并没有完全解决,有些题目的测试用例对于专门三数取中的优化还是并不能完全解决;
因此在此基础上有出现了一个为随机数取key的优化;
即为将数据中[left,right]这段区间中随机选一个数做key;
//使用hoare原生版本的单趟排序作为演示
int PartSort(int* a, int left, int right)
{
int randi = left+rand()%(right-left);//在使用rand函数前需先调用srand函数
swap(&a[left], &a[randi]);
int keyi = left;
while (left < right) {
while (left < right&&a[right] >= a[keyi])
right--;
while (left < right&&a[left ] <= a[keyi])
left++;
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[left]);
return left;
}
三路划分版
三路划分版本的快速排序针对一些重复率较高的情况:
[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,] |
若是遇到该种情况,三数取中与随机数取key的方法将会再次失效,时间复杂度也会再度来到 O(N2)的极端情况;
则在该种情况下,又有人想出了一种对应的方法,这个方法即为三路划分法;
该方法的思想为:
将原本的[left,keyi-1]keyi[keyi+1,right]区间;
划分为[<key][==key][>key]三个区间;
思路为:
使用三数取中的方式选取一个适和的数据作为key值并将key值进行保存(保存的为数据不为下标),
设指针cur指向left所指数据的后一个位置,即cur = left+1;
当cur指针不大于最右边界的情况进行循环遍历;
当cur所指向的数据小于key值时,则交换left指向的数据与cur指向的数据,并将两个指针都向后走一步;
当cur所指向的数据等于key值时,则不进行交换,cur继续遍历;
当cur指向的数据大于key值时,将cur所指向的数据与right所指向的数据进行交换,同时right–;
此时cur指针保存不懂避免交换过后cur所指向的数据小于key值;
void QuitSort(int* a, int begin, int end)
{
if (begin >= end) {
return;
}
int left = begin;
int right = end;
int mid = GetMidIndex(a, left, right);
swap(&a[mid], &a[left]);
int key = a[left];
int cur = left+1;
while (cur <= right) {
if (a[cur] < key) {
swap(&a[left], &a[cur]);
cur++, left++;
}
else if (a[cur] > key) {
swap(&a[right], &a[cur]);
right--;
}
else {
cur++;
}
}
QuitSort(a,begin, left - 1);
QuitSort(a, right+1, end);
}
非递归
快速排序除了递归以外同时也存在非递归的写法,
非递归的快速排序依靠为栈或者队列;
基本思想为:
将每次需要处理的区间进行入栈(注意栈的顺序为LIFO),
出栈后将出栈的区间进行处理(处理采用单趟排序处理);
当栈不为空的时候说明栈内仍存在区间需要进行出栈并处理,反复直到区间不存在或是区间内只有一个数时则不进行处理;
以改图为例,按照顺序先进[6,0];
再出栈[0,6]并将这段区间进行处理;
处理过后返回keyi,即区间又可以划分为[left,keyi-1]keyi[keyi+1,right];
如此循环;
void QuiteSortNonR(int* a, int begin, int end) {
ST st;
InitStack(&st);
/*
*第一次入栈整段区间
*并在循环中进行处理(循环将会返回keyi值)
*/
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st)) {
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int key = PartSort(a, left, right);
if (key + 1 < right) {
StackPush(&st,right);
StackPush(&st, key+1);
}
if (key - 1 > left) {
StackPush(&st, key - 1);
StackPush(&st, left);
}
}
Destruc(&st);//销毁栈
}
稳定性
- 综合上述来说,快速排序并不是一个十分稳定的排序,它在综合情况以及使用场景之中,总体效率都比较不错
但是若是遇到比较极端的情况下却不能很好解决,如有序的情况,有序的情况下一些排序将会停止排序,而快速排序若是在这种
情况下用三数取中的方式进行处理,仍会有一定的开销;
故快速排序不稳定;
归并排序
归并排序,是一种基于归并操作的排序算法,该算法与快速排序算法一样采用了分治法的排序;
基本思想为:
将两个已经有序的子序列进行合并,得到一个完全有序的序列;
要使两个子序列有序,一样要将子序列中的子序列变得有序;将问题化为子问题;
递归
递归的思路即为上述思路,
相应的思路可以参考二叉树中的后序遍历;
具体步骤为:
开出一块与数据内容相应大小的空间temp,这块空间用来进行每两组之间的归并;
以上图为参考,因为每个子区间中的数据不一定都是有序的;
所以要使用后序遍历的思路,最终将数据分为单个数据(单个数据可以看作有序);
使用归并的思路将数据归并至temp中;
最终再每次递归结束时将数据拷贝回原数组;
//递归
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin == end) {
return;
}
int mid = (begin + end) / 2;
/*
*这里分出来的区间为[begin,mid][mid+1,end]
*走后续,因为要左右区间都为有序才能进行归并
*/
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//与快排不同,快排的区间可以看作为三块
//而归并排序只需要分为左右两块进行归并即可
int left1 = begin, right1 = mid;
int left2 = mid + 1, right2 = end;
int i = begin;
//使用归并的思路将数据依次放至tmp数组中
while (left1 <= right1 && left2 <= right2) {
if (a[left1] < a[left2]) {
tmp[i++] = a[left1++];
}
else//a[left1]>=a[left2]
tmp[i++] = a[left2++];
}
//但凡两个其中一个结束
//将剩余数据依次拷贝至tmp数组
while (left1 <= right1) {
tmp[i++] = a[left1++];
}
while (left2 <= right2) {
tmp[i++] = a[left2++];
}
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));//拷贝回原数组
}
在看过这段代码过后就会发现,其实归并排序的递归需要用到两个函数;
其中一个函数用来作子函数从而达到递归的效果;
主函数用来开辟空间并释放空间;
void MergeSort(int* a, int n/*数据个数*/)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("MergeSort::malloc fail");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
非递归
相比于递归不同,非递归比递归所需要在意的细节更多;
但是思路上大体相同;
与快排不同,归并排序的非递归并不需要用到栈或者队列;
归并排序的非递归大致的思路为:
设一个gap为间距值,gap一开始为1,且gap在每次归并过一次时*=2;
当gap为1时实现一一归并,
当gap为2时实现两两归并,以此类推;
void MergeSortNonR(int* a, int n/*数据个数*/)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("MergeSort::malloc fail");
exit(-1);
}
int gap = 1;
//1 2 4 8
while (gap < n) {
for (int j = 0;j<n; j += 2 * gap) {
int left1 = j, right1 = j + gap - 1;
int left2 = j + gap, right2 = j + 2 * gap - 1;
int i = j;
while (left1 <= right1 && left2 <= right2) {
if (a[left1] < a[left2]) {
tmp[i++] = a[left1++];
}
else//a[left1]>=a[left2]
tmp[i++] = a[left2++];
}//单反两个其中一个结束
while (left1 <= right1) {
tmp[i++] = a[left1++];
}
while (left2 <= right2) {
tmp[i++] = a[left2++];
}
}
memcpy(a , tmp , sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
该段代码可以完美实现上图中所对应的数据所给的排序;
但是这个方法的缺陷特别明显;
缺陷:
- 当数据量不为2n时,将会有越界风险,即gap是一个不是很好控制的因素所以相应的需要进行边界的修正;
- 当数据在拷贝时,由于在控制边界之后,有些数据不能直接转入temp空间,导致空间中存在随机值,若是盲目拷贝整块空间将会出现随机数覆盖原有效数组的风险;
根据相应的缺陷需要制定出有效的措施;
调整边界
既然gap不能很好的控制调整,那就在遍历的过程中调整边界以达到不会越界;
从上述情况种可以了解到在非递归种共有四个指针,分别为left1,right1,left2与right2;
而非递归中越界的情况分为3种;
right1,left2与right2都越界 |
left2与right2越界 |
right2越界 |
相应的解法即为修改边界,当right1或者left2越界时,
即表示该段区间后的数据不需要进行归并,若是归并则会越界;
当right2越界时,则将right2的边界改为n-1;
void MergeSortNonR(int* a, int n/*数据个数*/)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("MergeSort::malloc fail");
exit(-1);
}
int gap = 1;
//1 2 4 8
while (gap < n) {
for (int j = 0;j<n; j += 2 * gap) {
int left1 = j, right1 = j + gap - 1;
int left2 = j + gap, right2 = j + 2 * gap - 1;
int i = j;
//right1 left2 right2 全部越界
//left2 right2 越界
//right2
//修正前
if (right1 >= n || left2 >= n){
break;
}
if (right2 >= n) {
right2 = n - 1;
}
//修正后
while (left1 <= right1 && left2 <= right2) {
if (a[left1] < a[left2]) {
tmp[i++] = a[left1++];
}
else//a[left1]>=a[left2]
tmp[i++] = a[left2++];
}//两个其中一个结束
while (left1 <= right1) {
tmp[i++] = a[left1++];
}
while (left2 <= right2) {
tmp[i++] = a[left2++];
}
}
memcpy(a, tmp, sizeof(int)n);
gap *= 2;
}
free(tmp);
单次归并单次拷贝
第二个缺点的解决方法即为,每次归并结束后,都将这段归并的数据拷贝回原数组,归并一段拷贝一段;
即不会出现随机数覆盖原有效数据的问题;
最终代码:
void MergeSortNonR(int* a, int n/*数据个数*/)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL) {
perror("MergeSort::malloc fail");
exit(-1);
}
int gap = 1;
//1 2 4 8
while (gap < n) {
for (int j = 0;j<n; j += 2 * gap) {
int left1 = j, right1 = j + gap - 1;
int left2 = j + gap, right2 = j + 2 * gap - 1;
int i = j;
//right1 left2 right2 全部越界
//left2 right2 越界
//right2
if (right1 >= n || left2 >= n){
break;
}
if (right2 >= n) {
right2 = n - 1;
}
while (left1 <= right1 && left2 <= right2) {
if (a[left1] < a[left2]) {
tmp[i++] = a[left1++];
}
else//a[left1]>=a[left2]
tmp[i++] = a[left2++];
}
while (left1 <= right1) {
tmp[i++] = a[left1++];
}
while (left2 <= right2) {
tmp[i++] = a[left2++];
}
memcpy(a + j, tmp + j, sizeof(int) * (right2 - j + 1));
// 0 0 1 2
// 1 1 3
}
gap *= 2;
}
free(tmp);
}
时间复杂度
- 归并排序的时间复杂度为O(N*logN),但是以综合性能的话,略逊快排一点,但无伤大雅;
空间复杂度
- 归并排序的缺点之一即为空间复杂度,在归并的过程中的开销并不多,但由于归并排序需要
开一段大小为N的空间,故空间复杂度为O(N);
稳定性
- 稳定
总结
排序算是一种较为基本的且需要掌握的算法,优秀的排序算法能在程序中使得效率倍式的提升;
不同的排序算法可以根据不同的使用场景分别进行使用;
排序中较为难的点为一些排序算法中的边界问题,以及递归中分治思路的理解;
在类似快速排序样式的排序中依旧可以再进行小区间优化,
即在递归到一定数据量时使用稳定的且效率还行的非复杂排序对数据进行排序;
小区间优化的目的是为了减少排序过程中递归调用的次数从而达成效率的优化;