前言:这篇文章将带你学习以下排序
前提小知识:
排序中的稳定性是指,相同大小的两个值,排序之后它们的相对顺序不发生改变。
小总结:
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(N²) | O(N) | O(N²) | O(1) | 稳定 |
简单选择排序 | O(N²) | O(N²) | O(N²) | O(1) | 不稳定 |
直接插入排序 | O(N²) | O(N) | O(N²) | O(1) | 稳定 |
希尔排序 | O(N*logN)~O(N²) | O(N^1.3) | O(N²) | O(1) | 不稳定 |
堆排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(1) | 不稳定 |
归并排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(N) | 稳定 |
快速排序 | O(N*logN) | O(N*logN) | O(N²) | O(logN)~O(N) | 不稳定 |
一、直接插入排序
1.基本思想
当插入第i个对象时,前i-1个都已经排好序,第i个逐一往前比较,找到合适的位置进行插入,这个位置以及后面位置的对象向后顺移。
我们在玩扑克牌的时候,对手中的牌进行排序就是用到了插入排序的思想。
2.代码
void InsertSort(int* a, int n)n为数组个数
{
int end,temp;
for (int i = 0; i < n - 1; i++)
{
end = i;
temp = a[end + 1];
while (end >= 0 && temp < a[end])
{
a[end + 1] = a[end];
end--;
}
a[end + 1] = temp;
}
}
3.分析
在直接插入排序中,最坏的情况就是当前顺序和你要的顺序完全相反,例如你要将5,4,3,2,1排成升序1,2,3,4,5,此时时间复杂度为O(n²)。最好的情况是顺序相同有序,此时时间复杂度为O(n)。
在排序中,如果两个值相等不会变化位置,则该排序是稳定排序。
时间复杂度:O(n²)
空间复杂度:O(1)
稳定性:稳定
二、希尔排序
1.基本思想
选定一个整数,先将整个待排记录序列分割成为若干序列,对这些序列分别进行直接插入排序,等到整个序列“基本有序”时(意思是接近有序了),再对整体进行一次直接插入排序。
比如上面的动图中,排序前是7、5、1、4、2、3、0、6,排序后0,2,1,4,5,3,7,6,对比于排序前,排序后更加接近有序。
2.代码
void ShellSort(int* a, int n)n为数组个数
{
//先进行预排序,让其接近有序
//gap越大,大的越快来到后面,但越不接近有序
//gap越小,大的越慢来到后面,但越接近有序
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;//最后gap一定会为1,此时就是插入排序
for (int i = 0; i < n - gap; i++)//一组中一趟进行排序会和一组前进行比较
{
int end = i;
int temp = a[end + gap];
while (end >= 0 && temp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = temp;
}
}
}
当最后gap为1时,对比直接插入排序的代码,我们会发现循环里面的内容是相同的。
gap的值的选取通常是n/3+1
3.分析
希尔排序的时间复杂度大概是O(N^1.3)。
我们来对这里的时间复杂度进行一个大概的分析:
我们在计算中,为了方便计算,将gap=gap/3+1中的1忽略掉。
那么第一趟排序gap=n/3,在最坏情况下进行排序:
我们将n分为gap组,那么每组就为3个,分别对每组进行直接插入排序,这里最坏情况是,每组都是逆序,则每组都要比较1+2次,而此处一共有gap组,总比较次数为3*gap=3*n/3=n。
那么第二趟排序gap=gap/3=n/9,在最坏情况下排序:
我们将n分为gap组,那么每组就为9个,分别对每组进行直接插入排序,这里最坏情况是,每组都是逆序,则每组都要比较1+2+3+4+5+6+7+8=36次,而此处一共有gap组,总比较次数为36*gap=36*n/9=4n。
依次计算.......
最后当gap=1,此时已经接近有序,就是对整体进行一次插入排序,按照接近有序的时间复杂度算这一趟时间复杂度应该为O(N)。
但是需要注意的是,在进行了一次排序后的排序,已经不能按最坏情况进行计算了,因为在进行了一次排序之后,此次排序已经不会是完全逆序了。
所以我们不能这样算出它的全部时间复杂度,但是我们可以画出一个大致的走向,我们大概可以了解刚开始的时候,时间复杂度在上升,但是它最终要来到最后一趟的O(N),所以上升到一定程度,它应该会下降:
至于该排序的稳定性,希尔排序是一个不稳定的排序,这里我举一个简单的例子帮助理解。
在这种情况下,第二个5会被换到第一个5的前面去。
时间复杂度:O(n^1.3)
空间复杂度:O(1)
稳定性:不稳定
三、选择排序
1.基本思想
将待排序的序列进行遍历选出极值,放到已排序的序列最后面。
在上面的动图中,我们遍历一遍选出最小值将其放到未排序列的第一个,未排序序列-1,再进行遍历。
2.代码
在这里我们对其进行一些改进,一次遍历选出最大值和最小值,让最小值和第一个交换,最大值与最后一个交换。
void SelectSort(int* a, int n)//n为数组个数
{
int begin = 0, end = n-1;
while (begin <end)
{
int maxi, mini;
maxi = mini = begin;
for (int i = begin+1; i <=end; i++)//后面的数依次与第一个进行比较
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
if (maxi == begin)//这一步防止处于begin的max在第一次交换的时候被换走
maxi = mini;
//Swap交换两个数
Swap(&a[mini], &a[begin]);
Swap(&a[maxi], &a[end]);
begin++;
end--;
}
}
3.分析
每一次选出极值,我们都需要对待排序列进行一次遍历,无论是一次选出一个极值还是一次选出两个极值,这两个的消耗都是N²。
值得注意的是,很多人会认为这是一个稳定的排序,实际上选择排序是一个不稳定的排序,我在这里举一个例子就能很清楚地说明:6、6、5、4、1。在排序后这两个6的相互顺序将会颠倒,因此,这是一个不稳定的排序。
时间复杂度:O(n²)
空间复杂度:O(1)
稳定性:不稳定
四、堆排序
1.基本思想
堆排序是选择排序的一种,它通过堆来选择数据。
我们先对数据进行建堆,再进行排序。在这里我们需要注意的是,升序建小堆,降序建大堆。
在这里建堆有两种方式,向上调整建堆和向下调整建堆。
向上调整建堆在末尾插入数据,然后再向上进行调整
过程如下:
向下调整是最后一个树节点开始依次对树节点进行向下调整
过程如下:
2.代码
void AdjustUp(int* a, int child, int n)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])//建大堆
{
Swap(&a[parent], &a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(int* a, int parent,int n)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1])//建大堆
{
child += 1;
}
if (a[parent] < a[child])//建大堆,如果是else就没有必要继续向下比较,因为向下调整的前提是左右都是堆
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
//建堆
for (int i = (n-1-1)/2; i >= 0; i--)//向下调整建堆
{
AdjustDown(a, i, n);
}
//建堆
/*for (int i = 1; i < n; i++)
{
AdjustUp(a, i, n);
}*/
//排序
int end = n - 1;
while (end >0)
{
Swap(&a[0], &a[end]);
end--;
AdjustDown(a, 0, end + 1);
}
}
3.分析
该排序不是稳定排序,它不能保证相同的数的相对位置不发生变化。我们照样举一个例子帮助理解,假如数据全为2、2、2、2、2、2、2,我们在排序的时候会发生最后一个数与第一个数进行交换,此时相对位置发生了变化。
我们再来对时间复杂度进行分析:
当二叉树的高度为h层时,我们对最多节点和最少节点进行讨论
当二叉树的高度为h层时,最多能有2^h-1个节点,最少能有2^(h-1)个节点。为了计算最坏情况的时间复杂度,这里我们使用最多节点进行计算。
首先我们来看看向下调整建堆的时间复杂度:
向下调整是从最后一个树节点依次往上进行向下调整
在向下调整中,节点个数多的层向下调整次数少,节点个数少的层向下调整个数多。
T(h)=2^0*(h-1)+2^1*(h-2)+2^2*(h-3)+...+2^(h-3)*2+2^(h-2)*1
用错位相减法进行计算,我们可以得到T(h)=2^h-1-h
根据N=2^h-1可以得到T(N)=N- -- > O(N)
我们再来看看向上调整建堆的时间复杂度:
向上调整建堆是每一个节点(除了第一个)进行向上比较调整
在向上调整中,节点多的层调整次数多,节点少的层调整次数少。
T(h)=2^(h-1)*(h-1)+2^(h-2)*(h-2)+...+2^2*2+2^1*1
用错位相减法进行计算,可以得到T(h)=-(2^h-1)+2^h*(h-1)+1
根据N=2^h-1可以得到T(N)=-N+(N+1)(-1)+1 -- >O(N*logN)
可以看出,向上调整建堆比向下调整建堆消耗更大,所以我们通常使用向下调整建堆。
建完堆后我们再来看看排序的时间复杂度,排序这一过程与向上调整建堆非常相似,第h层的节点交换到第一层后需要向下调整h-1次,第h-1层的节点交换到第一层后需要向下调整h-2次,因此排序的时间复杂度与向上调整建堆相同,都是O(N*logN)
于是这两个过程总时间复杂度为O(N+N*logN),所以堆排序的时间复杂度为O(N*logN)。
时间复杂度:O(N*logN)
空间复杂度:O(1)
稳定性:不稳定
五、冒泡排序
1.基本思想
冒泡排序是交换排序的一种,交换排序就是两个值根据比较的结果进行交换,特点是根据比较可以做到将较大值向序列后面移动,将较小值向序列前面移动。
2.代码
void BubbleSort(int* a, int n)
{
for (int i=0;i<n;i++)
{
int flag = 0;
for (int j = 0; j < n - i-1; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);//交换两个数
flag = 1;
}
}
if (flag == 0)
{
break;
}
}
}
3.分析
它的一趟排序可以做到将最大值沉到序列后面,每一趟都会进行遍历两两比较,最坏情况下每一个数都要遍历一遍沉到后面,时间复杂度为O(N²)。最好情况是已经是排好序,只需要遍历一次,时间复杂度为O(N)。而时间复杂度是按照最坏情况,故该排序的时间复杂度为O(N²)。
我们可以看出,当两个数字相等时,不会进行交换,并且该排序是相邻的进行两两比较,所以两个相等的值的相对位置在排序过程中不会发生改变,故该排序为稳定排序。
时间复杂度:O(n²)
空间复杂度:O(1)
稳定性:稳定
六、快速排序
在快速排序中,我将介绍三种方法:
(1)hoare方法
(2)前后指针法
(3)挖坑法
1.基本思想
在快速排序中,我们任意选中序列中的一个值作为基准值,根据这个值将序列其它值分为比它大的和比它小的,比它大的都放在它的左边,比它小的都放在它的右边。每一趟都会将基准值排好序,再对基准值的左右序列进行相同的步骤去排序。
2.代码
在实现升序中,为了保证相遇位置比基准值小,我们通常选择左边作为基准值,右边先走。
这里相遇有两种情况:
1.右边先遇到左边,右边为了找较小值一直走,直到遇到了左边,而左边要么是基准值(左边还一次都没走过),要么左边这个位置的值是上一轮与右边交换得到的较小值,此时它们相遇的位置是较小值。
2.左边先遇到右边,此时右边找到了较小值停了下来,左边为了找较大值一直走直到遇到了右边,此时它们相遇位置的值是右边找到的较小值,故相遇位置还是较小值。
在递归版本中,如果排序的序列大小不大,此时递归也会消耗一定空间,所以这里引入了小区间优化,当排序序列较小时使用时间复杂度为O(N²)的排序。
再者,如果选中的最左边的基准值太小或者太大,会导致左右又一边的序列递归太深导致,所以这里又一次引入了三数取中,用简单的方式是基准值不至于太大或者太小。
递归版本
void Swap(int* x, int* y)
{
int temp = *x;
*x = *y;
*y = temp;
}
void BubbleSort(int* a, int left, int right)
{
for (int i = 0; i < right - left + 1; i++)
{
for (int j = left; j < right - i; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
}
}
}
}
int GetMidi(int* a, int left, int right)//三数取中
{
int mid = left + (right - left) / 2;
if (a[mid] > a[left])//mid left
{
if (a[right] > a[mid])//right mid left
{
return mid;
}
else if (a[right] > a[left])//mid right left
{
return right;
}
else //mid left right
{
return left;
}
}
else//left mid
{
if (a[right] > a[left])//right left mid
{
return left;
}
else if (a[right] > a[mid])//left right mid
{
return right;
}
else//left mid right
{
return mid;
}
}
}
int QuickSort1(int* a, int left, int right)//hoare法 右找大,左找小
{
int keyi = left;
int begin = left, end = right;
while (left < right)
{
while (left<right&&a[right] >= a[keyi])//加上=不会让keyi从一开始就被换走
{
right--;
}
while (left<right&&a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
return keyi;
}
int QuickSort2(int* a, int left, int right)//挖坑法
{
int keyi = left;
int begin = left, end = right;
int temp = a[keyi];//begin作为坑位
while (begin < end)
{
while (begin<end && a[end]>=temp)//左边作为keyi,让右边先走,保证相遇点比a[keyi]小
{
end--;
}
a[begin] = a[end];//将begin填好,end变成了坑位
while (begin < end && a[begin] <= temp)
{
begin++;
}
a[end] = a[begin];//将end填好,begin变成了坑位
}
a[begin] = temp;
keyi = begin;
return keyi;
}
int QuickSort3(int* a, int left, int right)//前后指针法
{
int keyi = left;
int pre = left;
int cur = pre + 1;
while (cur <= right)
{
while (cur<=right&&a[cur++] < a[keyi])
{
Swap(&a[++pre], &a[cur-1]);
}
}
Swap(&a[keyi], &a[pre]);
keyi = pre;
return keyi;
}
void QuickSort(int* a, int left,int right)
{
if (left >= right)//区间不存在或者只有一个值
{
return;
}
//小区间优化
if (right - left < 10)
{
BubbleSort(a, left, right);
return;
}
//三数取中
int keyi=GetMidi(a, left, right);
Swap(&a[left], &a[keyi]);
//排序
keyi = QuickSort1(a, left, right);//通过一趟排好一个并且进行分割
//递归对左右区间进行排序
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
非递归版本
我们将序列的左右区间存入栈中,排序的时候再出栈取区间出来进行排序,一直到栈为空时,则整个序列都排好序了。
#include"Stack.h"
void QuickSortNonR(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int begin = left;
int keyi = left;
int end = right;
S stack;
SInit(&stack);
//入最初的区间
SPush(&stack, begin);
SPush(&stack, end);
while (!SEmpty(&stack))
{
//取区间
end = SGetTop(&stack);
SPop(&stack);
begin = SGetTop(&stack);
SPop(&stack);
//三数取中
keyi = GetMidi(a, begin, end);
Swap(&a[begin], &a[keyi]);
keyi = QuickSort1(a, begin, end);//随便选取一个方法
if (begin < keyi - 1)//入左区间
{
SPush(&stack, begin);
SPush(&stack, keyi-1);
}
if (keyi + 1 < end)//入右区间
{
SPush(&stack, keyi + 1);
SPush(&stack, end);
}
}
SDes(&stack);
}
3.分析
简要分析一下,在递归版本中,如果基准值选得比较好,每次都能将其二分时,它的递归跟二叉树差不多,递归层数大概时logN层。而每次排序的时候,左右遍历一次是N。即它的时间复杂度为O(N*logN)。在递归版本中,递归需要重复多次为函数开辟栈帧,一般情况的时间复杂度为O(logN),其最坏的空间复杂度为O(N)。而非递归也要用栈存储下标,空间复杂度为O(N),时间复杂度与递归相同都是O(N*logN)。
至于它的稳定性,我们还是举一个例子:5 3 5 7 8 22 2。在第一趟排序中,第一个5会排到第二个5的后面,它是一个不稳定的排序。
时间复杂度:O(N*logN)
空间复杂度:O(logN)
稳定性:不稳定
七、归并排序
1.基本思想
将序列分为子序列,先使得子序列有序,再将子序列进行合并,将两个有序的子序列进行合并形成了有序的序列。
2.代码
(1)递归版本
将序列二分为两个子序列,一直分到子序列不存在或者只有一个值,对两个子序列进行合并,形成有序的序列。左右区间要选好,否则会陷入死循环。
在[left,mid-1],[mid,right]这种区间可能会造成死循环。
在0,1,2,3区间,当left=2,right=3时,mid=2+(3-2)/2=2。则mid和left会永远为2,无限递归导致栈溢出。
可以改成[left,mid],[mid+1,right]。
void _MergeSort(int* a, int left, int right, int* temp)
{
if (left >= right)
{
return;
}
int mid = left + (right - left) / 2;//二分
//对左右区间进行排序
_MergeSort(a, left, mid,temp);
_MergeSort(a, mid + 1, right,temp);
//合并
int i = 0;
int begin1 = left;
int begin2 = mid + 1;
while (begin1 <= mid && begin2 <= right)//遍历左右两边有序序列,挑选小的放到temp
{
if (a[begin1] <= a[begin2])// =保证稳定性
{
temp[i++] = a[begin1];
begin1++;
}
else
{
temp[i++] = a[begin2];
begin2++;
}
}
//将未放完的序列中的数放到temp
while (begin1 <= mid)
{
temp[i++] = a[begin1];
begin1++;
}
while (begin2 <= right)
{
temp[i++] = a[begin2];
begin2++;
}
//每合并一次都需要copy一次,因为下次合并需要用到上次合并产生的有序序列
memcpy(a + left, temp, sizeof(int) * (right - left + 1));
}
void MergeSort(int* a, int left, int right)
{
int n = right - left + 1;
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("MergeSort::malloc fail");
exit(1);
}
_MergeSort(a, left, right, temp);
free(temp);
}
(2)非递归版本
显示两两合并,再是四四合并,再是八八合并......
但由于序列不一定都是4的倍数,可能会产生下标越界,在这里面要做好相应的调整。
在这里我们也举一个例子帮助理解
在这一过程中,最后一次越界我们可以直接修改end2=right,而过程中的越界,我们可以不进行合并,留到最后一次合并时作总合并,所以在过程中检测begin2是否越界就行。
void MergeSortNonR(int* a, int left, int right)
{
int* temp = (int*)malloc(sizeof(int) * (right - left + 1));
if (temp == NULL)
{
perror("MergeSortNonR::malloc fail");
exit(1);
}
int begin1, end1, begin2, end2;
int gap = 1;
while (gap < right-left+1)
{
int j = 0;
for (int i = 0; i <= right; i += 2 * gap)
{
begin1 = i;//先进行两两合并,再是44合并,再是88合并
end1 = i + gap - 1;
begin2 = i + gap;
end2 = i + 2 * gap - 1;
if (begin2 > right)//序列中有超出范围,不用合并,留给最后一次汇总再进行合并,最后一次汇总begin2一定不会越界
{
break;
}
if (end2 > right)//最后一次合并,对其进行修正
{
end2 = right;
}
//合并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])// =保证稳定性
{
temp[j++] = a[begin1];
begin1++;
}
else
{
temp[j++] = a[begin2];
begin2++;
}
}
while (begin1 <= end1)
{
temp[j++] = a[begin1];
begin1++;
}
while (begin2 <= end2)
{
temp[j++] = a[begin2];
begin2++;
}
memcpy(a+i, temp+i, sizeof(int) * (end2-i+1));//注意复制个数的表达
}
gap *= 2;
}
free(temp);
}
3.分析
在归并排序中,大概会递归logN层,而每层将会对接近N个数进行相互比较排序。所以时间复杂度为O(N*logN)。关于稳定性,归并排序是一个稳定的排序,当两边数值相同时,我们可以优先取前面的序列。由于在排序时,如果我们有n个数据,那我们就需要额外开辟n个int空间的数组,所以它的空间复杂度为O(N)。
时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定
八、计数排序(小扩展)
1.基本思想
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用
2.代码
(1)不稳定版本
我们在这里举一个例子对计数排序进行理解:
假设现在有10个数:11,9,9,4,6,2,3,8,2,2
void CountSort(int* a, int n)
{
int maxi, mini;
maxi = mini = 0;
for (int i = 1; i < n; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
int max = a[maxi];
int min = a[mini];
int range = a[maxi] - a[mini] + 1;
int* temp = (int*)calloc(range, sizeof(int));
if (temp == NULL)
{
perror("CountSort::calloc fail");
exit(1);
}
for (int i = 0; i < n; i++)
{
temp[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (temp[i]--)
{
a[j++] = i + min;
}
}
free(temp);
temp = NULL;
}
(2)稳定版本
void CountSort1(int* a, int n)
{
int maxi, mini;
maxi = mini = 0;
for (int i = 1; i < n; i++)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
int max = a[maxi];
int min = a[mini];
int range = a[maxi] - a[mini] + 1;
int* temp = (int*)calloc(range, sizeof(int));
for (int i = 0; i < n; i++)
{
temp[a[i] - min]++;
}
for (int i = 1; i < range; i++)
{
temp[i] = temp[i] + temp[i - 1];
}
int* ans = (int*)calloc(n, sizeof(int));
for (int i = n - 1; i >= 0; i--)
{
ans[temp[a[i] - min] - 1] = a[i];
temp[a[i] - min]--;
}
for (int i = 0; i < n; i++)
{
a[i] = ans[i];
}
free(ans);
ans = NULL;
free(temp);
temp = NULL;
}
3.分析
不难看出,在排序过程中,我们进行了两次遍历,第一次遍历的大小为N,第二次遍历的大小为数组中的最大值-最小值+1,取两次遍历中的最大值,即时间复杂度为O(MAX(N,range))。至于空间复杂度,在不稳定版本我们额外开了range个int空间,即O(range);在稳定版本我们开辟了n个int的空间和range个int的空间,即O(MAX(N,range))。