1. 排序的概念
- 排序:所谓排序,就是一串记录,按照其他的某个或某些关键字的大小,递增或递减的排列起来的操作
- 稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]==r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的,否则称之为不稳定
- 内部排序:数据元素全部放在内存中的排序
- 外部排序:数据元素太多,不能同时放在内存中,根据排序过程的要求不能再内存之间移动数据的排序
2. 排序
详细的各种排序过程可以参考下面这个网站
https://visualgo.net/en/sorting?slide=6
2.1. 插入排序
插入排序的思想:从第2个元素开始标记,别标记的数每次与前面紧挨着的数据进行比较,只要比前面的数据小,就与前面的数据交换,直到紧挨的数据大于被标记的数据.
-
第1次标记第2个元素5
5与第一个元素6比较,小于6,互换位置
-
第2次标记第3个元素4
4与6相比,小于6,互换
4与5比较,小于5,互换
-
第3次标记第4个元素7
7与6相比,大于6,不操作
-
第4次标记第5个元素8
8与7相比,大于7,不操作
-
第5次标记第6个元素3
3与8相比,小于8,互换
3与7相比,小于7,互换
3与6相比,小于6,互换
…
3与4相比,小于4,互换
…
通过每次标记值,与前面的数据比较,不断使被标记前的数据达到有序
代码实现:
void InsertSort(int* a,int n)
{
for(int i=0;i<n-1;i++)
{
//tmp是被标记的数字,end是与被标记数字的下标
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;
//tmp保存了之前被标记的数,将tmp的数放在正确的位置
}
}
时间复杂度:
最好的情况下:O(N)
最坏的情况下:O(N^2)
空间复杂度:O(1)
2.2.希尔排序
2.2.1. 希尔排序的思想
希尔排序的主要步骤:
- 预排序
- 直接插入排序
预排序:分组排,间隔为gap的数据分为一组,gap大于1
分组:gap=3的时候
9,5,8,5为一组。
1,7,6为一组。
2,4,3为一组。
然后分别对三组进行预排序(直接插入排序)
与最开始的顺序相比,稍微有序了一点,大的数据靠后点,小的数据靠前点。
如果先用gap=5处理,再用gap=3处理,数据会越来越有序
当gap=1的时候,就是直接插入排序
注:
没有预排序的情况下,处于第一元素位置的9想要去后面需要移动9次
但是前几次的预排序会快速将9移到后面,使9想要进入有序的过程中,只需要移动几次就可以了,如gap=3的情况下,只需要移动3次
2.2.2.代码实现
对一组数据进行排序
void Shell(int*a,int n)
{
int gap=3;
//按每隔3个数为一组,将所有数据分为三粗
//下面就是对每组进行直接插入排序
for(int i=0;i<n-gap;i+=gap)
{
int end=i;
int tmp=a[end+gap];
while(end>=0)
{
if(tmp<a[end])
{
a[end+gap]=a[end];
end-=gap;
}
else
{
break;
}
}
a[end+gap]=tmp;
}
}
对所有组进行预排序
void Shell(int*a,int n)
{
int gap=3;
//一共三组,每组进行一次排序
for(int j=0;i<gap;j++)
{
for(int i=0;i<n-gap;i+=gap)
{
int end=i;
int tmp=a[end+gap];
while(end>=0)
{
if(tmp<a[end])
{
a[end+gap]=a[end];
end-=gap;
}
else
{
break;
}
}
a[end+gap]=tmp;
}
}
}
简化
void Shell(int*a,int n)
{
void ShellSort(int*a,int n)
{
int gap=n;
while(gap>1)
{
gap/=2;
//gap越分越小,直到为1
for(int i=0;i<n-gap;i++)
{
int end=i;
int tmp=a[end+gap];
while(end>=0)
{
if(tmp<a[end])
{
a[end+gap]=a[end];
end-=gap;
}
else
{
break;
}
}
a[end+gap]=tmp;
}
}
}
gap/2的情况下,预排序较多
可以使用gap/3+1,但是gap/3最后的结果不一定是1(8/3=2),所以,我们可以在后面加一,(gap/3+1),这样能保证最后一次的结果也是1
2.2.3.希尔与直接插入的对比
希尔排序经历过那么多次的预排序,时间真的会比直接插入少吗
我们可以验证一下
用下面的函数判断两个时间长短
(ps:在release下运行会更快)
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int)*N);
int* a2 = (int*)malloc(sizeof(int)*N);
for (int i = 0; i < N; i++)
{
a1[i] = rand();
a2[i] = a1[i];
}
int begin1 = clock();
//InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
}
1万个数据的情况下
10万个数据的情况下
100万个数据下,插入排序跑崩了
但是希尔在100万和1000万下还是很猛
100万数据的情况下
1000万数据的情况下
2.2.4.希尔排序的时间复杂度
- gap很大的时候,如n/3,整个数组逆序,n/3组数据
最坏的情况下,每组比较3次
单趟排序合计n/3*3=n次 - gap很大的时候,如n/9,整个数组逆序,n/9组数据
最坏的情况下,每组比较9个数据
单组合计需要(8+1)8/2=36次
单趟排序合计n/936=4*n - gap很小的时候,如1,这个时候数组接近有序,单趟排序合计:n
结论:O(N^1.3)
(通过大量实验后得到的结论,反正按照我的数学水平想算出来很难)
比N*logN略差,但是对于排序来说已经很牛逼了
2.3. 冒泡排序
冒泡排序是最开始接触c语言时就使用的排序
这里简单提一下
冒泡排序是将最大的值一步一步交换到数组的最后一个位置
- 第一次循环:
第1个元素和第2个元素比,大的元素放2号元素位
第2个元素和第3个元素比,大的元素放3号元素位
第3个元素和第4个元素比,大的元素放4号元素位
…
第n-1个元素和第n个元素比,大的元素放n号位
第一次循环结束,能保证最大的元素处于最后一个n的位置 - 第二次循环
从第1个元素和第2个元素开始,大的放2号位
…
第n-2个元素和第n-1个元素比,大的放n-1号位
第二次循环结束,能保证第二大的元素在n-1的位置 - 一直到只剩下1号位后面的元素排好序,冒泡排序算完成
代码实现:
void Swap(int*a,int *b)
{
int tmp=*a;
*a=*b;
*b=tmp;
}
void BubbleSort(int*a,int n)
{
for(int i=0;i<n;i++)//控制总的循环次数
{
for(int j=1;j<n-i-1;j++)//
{
if(a[j-1]>a[j])
{
Swap(&a[j-1],&a[j]);
}
}
}
}
冒泡排序还有可以有优化的地方,如果在某次从第一个元素到m(m<=n)个元素都已经有序了,冒泡还会进入下一次循环,从第一个遍历到最后m-1个,但是这些数据还是有序的,依旧不会改变顺序,
我们可以设置一个变量,当某次排序没有发生交换,则说明后面已经有序,需需要进入下次循环
void BubbleSort(int*a,int n)
{
for(int i=0;i<n;i++)//控制总的循环次数
{
int flag=1;
for(int j=1;j<n-i;j++)
{
if(a[j-1]>a[j])
{
Swap(&a[j-1],&a[j]);
flag=0;
}
}
if(flag==1)
{
break;
}
}
}
时间复杂度:O(N^2);
2.4. 选择排序
选择排序在每次遍历数组的时候,将这次遍历中最大的数和最小的数的下标标记,最小的数和下标为begin互换,最大的数和下标为end的数互换
第一次遍历
mini将最小的数字2的下标标记
maxi将最大的数字8的下标标记
然后将maxi下标的数字与end位置互换
mini下标的数字与begin位置互换
此时begin++,end–,再将maxi和mini的下标都重置为begin
进入第二次遍历:
maxi找最大,mini找最小
然后将begin和mini的值互换,end与maxi的值
后面的思路一致,不过进行的条件是begin<end,如果条件不满足,begin前和end后面的序已经排好,两个相遇,整个数组的序已经排好,可以结束
代码实现:
void SelectSort(int*a,int n)
{
int begin=0,end=n-1;
while(begin<end)
{
int maxi=begin,mini=begin;
for(int i=begin+1;i<=end;i++)
{
if(a[i]>a[maxi])
{
maxi=i;
}
if(a[i]<a[mini])
{
mini=i;
}
}
Swap(&a[begin],&a[mini]);
Swap(&a[end],&a[maxi]);
end--;
begin++;
}
}
虽然理论看似没问题,但是结果出现了问题,3排在倒数第4位
我们用下面的数据举例解释这种情况
如果maxi此时指向的就是从begin到end中最大的数字
首先mini的数字会与begin下标的数字发生交换
交换以后,maxi指向的不是最大的,但是因为这是在改变maxi之后发生的操作,maxi此后会与end的位置交换,这个时候交换过去的就不是这串数中的最大值,而在交换之后,end–,我们就会默认后面已经排好序,也就不回去管那个数字是否在正确的位置
我们可以加个判断,当maxi的值还是begin的时候,begin会和mini交换,此时最大的数字交换到mini的位置上,这个时候我们就让mini位置的数字与end交换即可
void SelectSort(int*a,int n)
{
int begin=0,end=n-1;
while(begin<end)
{
int maxi=begin,mini=begin;
for(int i=begin+1;i<=end;i++)
{
if(a[i]>a[maxi])
{
maxi=i;
}
if(a[i]<a[mini])
{
mini=i;
}
}
Swap(&a[begin],&a[mini]);
if(maxi==begin)
{
maxi=mini;
}
Swap(&a[end],&a[maxi]);
end--;
begin++;
}
}
这个时候的结构没问题
2.5.快速排序
- 快速排序是Hoare(霍尔)在1962年提出的一种二叉树结构的交换顺序
- 基本思想:任取待排序元素序列中的某些元素作为基准值,按照该排序码将排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有的元素都安排到对应位置上为止
2.5.1hoare版本
这个方法是最开始Hoare想出的方法
实现思路:
-
确定数组中首个元素作为key,然后将所有小于key的元素放在key左边,所有大于key的元素放在key的右边
-
R从数组右边开始走,当R走到小于key的元素时,R不动,L从数组的左侧出发,找大于key的元素
-
当L为找到大于key的值,R为小于key的值,L和R的值分别进行交换
-此时R接着往左边走,找小于key的值,L往右走,找大于key的值(R先走,L后走)
-
接着互换位置
-
R和L接着走,当相遇的时候停下
-
此时,将key与L和R相遇位置的数字3与key的值6交换
第一次循环数组,我们将所有小于key的元素放在了key左边,大于key的元素放在了右边,然后我们从key的位置将数组分为左右子序列
第二层处理: -
左边数组:
与上面类似,先找将些数的第一个元素作为key
R从右边走找小,L从左边走找大(R先走,L后走)
L和R相遇停止,将2和3位置互换
-
右边数组:
找key,R从右走找小,L从左走找大(R先走,L后走)
第二层左右数组处理完毕,然后从两个key的位置,再将左右数组分别分成两个数组
第三次处理: -
第二层左数组的左数组:
将2作为新的key,R从右边1开始找,L从左边2开始找(R先走,L后走)
相遇,互换
-
第二层左数组的右数组:
key=5,R右找小,L左找大(R先走,L后走)
-
第二层右数组的左数组
阿巴阿巴(R先走,L后走)
-
第二层右数组的右数组
-
只有一个数比不了,直接返回
结果:
排完序:1 2 3 4 5 6 7 8 9 10
2.5.2代码实现hoare版本
- 第一次找key进行操作
int PartSort(int*a,int left,int right)//right是下标,传入的参数为sz-1
{
int key=a[left];
while(left<right)
{
if(a[right]>key)//R找小,R大于key的时候R--,小于跳出循环,保存下标
{
right--;
}
if(a[left]<key)
{
left++;
}
Swap(&a[left],&a[right]);//这里的交换函数只有聪明人才能看见(doge)
}
Swap(&a[left],&key);
return left;
}
理论上好像没问题,但是程序好像死循环了呢
返回程序修改
当L和R都是和key的值相同的时候,P不会往左走,L也不会向右走,会进入死循环
所以在判断条件要改成
if(la[right]>=key)
if(a[left]<=key)
但是修改以后不循环了,但是还有问题
接着修改,我们的key定义的是第一个元素的值,但只是有值,与数字中对应的值是两块空间,而我们每次交换的时候,是用key和后面的值进行交换,数组中我们确定的那个key对应值的位置我们没有改变
我们可以将key设置为数组下标,每次交换的时候可以与数组key位置交换
将key=left,然后把所有的key换为a[key]
然后我们会惊奇的发现,还错了
这里还存在数组越界的问题
内存循环中,当R和L相遇的时候,我们只有在外层循环才能判断,内层判断不了,这就导致内存循环R和L相遇的时候,R或L还会接着向左或向右找下去
这个时候L和R不相遇,并且已经处理好的数据还被打乱了,我们需要在内层循环也加上判断的条件
修改后:
int PartSort(int* a, int left, int right)//right是下标,传入的参数为sz-1
{
int key = left;
while (left < right)
{
if (left<right&&a[right] >= a[key])//R找小,R大于key的时候R--,小于跳出循环,保存下标
{
right--;
}
if (left<right && a[left] <=a[key])
{
left++;
}
Swap(&a[left], &a[right]);//这里的交换函数只有聪明人才能看见(doge)
}
Swap(&a[left], &a[key]);
return left;
}
注:
相遇:
- R动L不动,和L相遇
每一轮都是R先走,前一轮R和L互换之后,L的位置是小于key的,此时R向左走,能保证R向左遇见L的时候,遇见地方的值小于key - L动R不动, 和R相遇
R不动的时候,R此时已经处于小于key的位置,此时L向右走,遇见R的位置的值必定小于key
2.5.3效率及优化
快速排序是配得上他的称号的
在与希尔排序,堆排序的比较下,快排都很强
10w个数据(随机数据)
100w个数据(随机数据)
1000w个数据(随机数据)
在随机数据情况下,快排很强
但是在数组有序的情况下,上面的方法就不太行了
10w个数据(有序)
100w个数据(有序)
我们分析一下这个问题
R会一路找,找到L的位置,此时1左面没有元素,右边n-1个元素会进入右边的递归,然后L,R遍历完n-1个数据,而在右边的递归中,还是会出现这种情况,n-2个元素进入右层递归,然后L,R遍历完n-2个数据,此时时间复杂度大大增加,会变成O(n^2)
正常情况下
优化的方法:三树取中
每次在最左边,中间,最右边取值,找大小在中间的数,将它设置为key,这样的话,能保证每次处理完一遍后,key对应的值在数组中间,或者中间附近
代码实现:
int GetMidi(int a,int left,int right)
{
int mid=(left+right)/2;
if(a[left]>a[mid])
{
if(a[mid]>a[right])
{
return mid;
}
else if(a[right]>a[left])
{
return left;
}
else
{
return right;
}
}
else//left<mid
{
if(a[mid]<a[right])
{
return mid;
}
else if(a[right]<a[left])
{
return left;
}
else
{
return right;
}
}
}
int PartSort(int* a, int left, int right)
{
int midi = GetMidi(a,left,right);
Swap(&a[left], &a[midi]);
int key = left;
while(left<right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[key], &a[left]);
return left;
}
void QuickSort(int* a, int begin,int end)
{
if (begin >= end)
{
return;
}
int ret = PartSort(a, begin, end);
QuickSort(a, begin, ret - 1);
QuickSort(a, ret+1, end);
}
10w数据(有序)
20w个数据(有序)
1亿个数据(有序)
1亿个数据(乱序)
快速排序,传入的数据必须是N-1(找bug找了几十分钟,就是这个小东西害的)
2.5.4挖坑法
挖坑法也是依据快排的思想,只不过对每次处理的方法不同
先确顶首元素,用key标记数,此时坑是left位置,R向左找小,找到后,将R位置的小值直接填在坑位置,R此时的位置变成坑,L在向左走,找大,找到后,将L位置的大值,填在坑处,L的位置留下一个坑,然后R再向前找小,直到L和R相遇
- 先确定第一个元素作为key,left位置就变成一个坑,用hole标记
R向左找小
找到小了之后,将小值填到坑里
此时R位置为坑,空hole标记
L向右走找大,遇见大值停止
将L的大值填到hole处,然后用hole标记L位置
R左找小
换到hole处,hole标记R位置,L右找大
阿巴阿巴阿巴
阿巴巴巴阿巴
最后相遇的时候,将相遇位置坑用key的值填进去
注:挖坑法排完一次的顺序,可能和中分法结果不一样
中分:
挖坑:
2.5.5代码实现挖坑法
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;
}
2.5.6前后指针
这个相较于前面两个很难理解一点,但是学会了之后,代码量和出错率会比前面两种方法小很多
- cur找小,++prev,交换prev和cur的值
-
prev有两种情况
-
在prev没有遇见比ket大的时候,prev紧跟着cur
-cur遇到了比key大的时候,prev在比key大的一组值前面 -
首先prev指向第一个元素,cur指向prev下一个元素,将第一个元素用key标记,前提是cur要小于等于数组中元素个数,这样才有意义
-
如果cur遇见小于key的值,就让prev停下
-
如果此时cur指向的位置小于key,并且prev++!=cur,说明两个不是同一个位置,将cur的小值与prev后面的大值进行交换
cur找到的值大于key,prev不动
cur找到的值小于key,并且prev++不等于cur,将prev++的值与cur的互换
2.5.7代码实现前后指针法
int PartSort(int*a,int left,int right)
{
int key=left;
int prev=left;
int cur=prev+1;
whhile(cur<=right)
{
if(a[cur]<a[key]&&prev++!=cur)
{
Swap(&a[prev],&a[cur]);
}
cur++;
}
Swap(&a[prev],&a[key]);
return prev;
}
2.5.8. 小区间优化
-
快速排序的方法确实快,但是当只有10个数据的时候,还是要进行最少两次的递归,而这种类似二叉树的结构,子节点占所有节点的50%,也就是说有最后会有许多零散的数据还会不停地进入递归,哪怕最后的数据量很少。
-
-
所以当递归到某次,传入的需要调整位置的数据小于等于10个左右的时候,为了减少递归次数,我们可以用其他排序的方法。
-
这里建议快速排序,在数据量很小的情况下,各个排序差别不大。
代码实现:
void QuickSort1(int*a,int begin,int end)
{
if(begin>=end)
{
return ;
}
//数组数据量大于10,快排
if(end-begin+1>10)
{
int ret=PartSort(a,begin,end);//这里的PartSort是中分法(哪个方法都可以)
QuickSort1(a,begin,ret-1);
QuickSort1(a,ret+1,end);
}
else//数据量少于等于10,插入排序
{
InsertSort(a+begin,end-begin+1);
}
}
在保证结果没问题的情况下,我们对比一下效率
100w数据(无序,release)
在debug版本下的效率
总体来说确实优化了一点(部分情况下会出现优化后没跑过原版,但是出现的次数很少很少)
2.5.9. 非递归快排
想要实现类似于递归的思路,可以使用栈来实现
注:这里的数字仅仅代表下标,同时设置的keyi是中间的下标,左数组的区间为(0,keyi-1),右边数组的下标是(keyi+1,end)
- 我们先将(0(begin),9(end))下标入栈
- 然后将(0,9)拆成(0, 4)和(6, 9),同时将(0,9)出栈入的时候先入右边数组(6,9)的下标,然后在入左边数组的下标(0,4)
- 然后我们再拆,将(0,4)拆成(0,1)和(3,4),将(0,4)出栈的同时,将(3,4),(0,1)按顺序入栈
- (0,1)拆开,拆成(0,0)和(2,1)此时将(2, 1)和(0,0)入栈,又因为这个时候begin>=end,下一步又要将(0,0)和(2,1)出栈
实际上(0,0)(2,1)不进栈,这里为了方便理解
- (0,0)和(2,1)出栈会去处理(3,4),(3,4)可分为(3,3)和(5,4),入栈,begin>=end,又要出栈(3,3)不进栈,这里只是方便理解
这种顺序类似深度遍历,如果使用队列的话类似广度遍历
栈代码:
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
#define N 10
//typedef struct stack
//{
// int arr[N];
// int top;
//}stack;
typedef int STDdatatype;
typedef struct stack
{
STDdatatype* a;
int top;//top 栈顶
int capacity;
}ST;
void STinit(ST* ps);//初始化
void STDestory(ST* ps);//销毁
void STPush(ST* ps,STDdatatype x);//入栈
void STPop(ST* ps);//出栈
int STSize(ST* ps);//栈元素数量
STDdatatype STTop(ST* ps);//返回栈顶元素
bool STEmpty(ST* ps);//是否为空
void STinit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->capacity = 0;
ps->top=0;
}
void STDestory(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDdatatype x)
{
assert(ps);
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4: ps->capacity * 2;
STDdatatype* tmp = (STDdatatype*)realloc(ps->a, sizeof(STDdatatype) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
void STPop(ST* ps)
{
assert(ps);
assert(ps->top>0);
--ps->top;
}
STDdatatype STSize(ST* ps)
{
assert(ps);
return ps->top;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
STDdatatype STTop(ST* ps)
{
assert(ps);
assert(ps->top > 0);
return ps->a[ps->top-1];
}
void QuickSortNonR(int*a,int begin, int end)
{
ST st;
STinit(&st);
STPush(&st,end);
STPush(&st,begin);
while(!STEmpty(&st))
{
//取栈顶小的下标
int left=STTop(&st);
STPop(&st);
int right=STTop(&st);
STPop(&st);
//确定中间位置的元素
int keyi=PartSort(a,left,right);
if(keyi+1<right)
{
STPush(&st,right);
STPush(&st,keyi+1);
}
if(keyi-1>left)
{
STPush(&st,keyi-1);
STPush(&st,left);
}
}
STDestory(&st);
}
2.6. 归并排序
2.6.1归并思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是次用分治法(Divide and Conquer)的一个非常典型的应用。将已有的子序列合并,得到完全有序的序列,即先使每个子序列有序,再使子序列段间有序,若将两个有序表合并成一个有序表,称为二路归并
以上都是概念
归并排序,就是将两个有序的子序列,按照大小连接起来的过程,要使子序列有序,可以把子序列用两个它的有序子序列连接,依次类推,直到子序列只含有一个元素的时候向上返回
运用的方法是有序数组合并,不过只能在新开辟的数组中操作,在新开辟的数组中填入值,只需要判断a左右子序列元素的大小
但是如果在原数组中操作,既要比较两个子序列数组元素的大小,同时还要在里面覆盖数据,很容易混乱
所以归并排序最好或者只能开辟新数组储存
2.6.2递归代码实现
注:这里每个子序列排完序就会将对应区间覆盖在a数组对应的区间
void _MargeSort(int *a,int*tmp,int begin,int end)
{
if(begin>=end)
{
return ;
}
int mid=(left+right)/2;
_MargeSort(a,tmp,begin,mid);
_MargeSort(a,tmp,mid+1,end);
int begin1=begin,end1=mid;
int begin2=mid+1,end2=end;
int index=begin;
while(begin1<=end1&&begin2<=end2)
{
if(a[begin1]<a[begin2])
{
tmp[index++]=a[begin1++];
}
else
{
tmp[index++]=a[begin2++];
}
while(begin1<=end1)
{
tmp[index++]=a[begin1++];
}
while(begin2<=end2)
{
tmp[index++]=a[begin2++];
}
memcpy(a+begin,tmp+begin,(end-begin+1)*sizeof(int));
}
void MargeSort(int*a,int n)
{
int tmp=(int*)malloc(n*sizeof(int));
if(tmp==NULL)
{
perror("malloc failed");
exit(-1);
}
_MargeSort(a,tmp,0,n-1);
free(tmp);
}
空间复杂度:O(N)
时间复杂度:O(N*logN)
2.6.3. 非递归代码实现
非递归的思路和上面的思路类似,一一归并,两两归并,四四归并。
- 第一次对数组进行一一归并,每一个一组,每两组进行归并,当整个数组都归并完之后,归并后的tmp数组中,每两个数之间是有序的,此时将tmp覆盖在a数组
- 第二次对数组进行二二归并,每两个一组,每两个进行归并,当整个数组都归并完之后,归并后的tmp数组中,每四个数之间是有序的,此时将tmp覆盖在a数组
- …
- 当最后一次归并完后,tmp也就变成有序的一个数组,将其覆盖在a数字上即可
- 下面的代码,gap是我们控制的每组数的数量,gap为一,一个数为一组,每两组进行归并
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//gap=1,i=0
//begin1=0,end1=0,begin2=1,end2=1,每组有一个数
//gap=2,i=0;
//begin1=0,end1=1,begin2=2,end2=3;每组有两个数
void MergeSortSNonR(int* a, int n)
{
int* tmp = (int*)malloc(n*sizeof(int));
if (tmp == NULL)
{
perror("malloc failed");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int index = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
memcpy(a + i, tmp + i, (2 * gap) * sizeof(int));
}
gap *= 2;
}
free(tmp);
}
这里可以看见程序是成功运行了,但是真的没问题吗
我们试试10位数据的情况
10位数据的情况下,程序崩了,还出现了越界的情况
我们可以看到,因为这个gap每次是2的倍数,循环的条件是i<n,也就是说当begin1<n的时候,此循环还会进行,但是只有begin1是小于1的,我们不能保证进入循环后的end1,begin2,end2都小于n,也就是说end1,begin2,end2存在越界的情况
- 在加入输出每组begin1,end1,begin2,end2的区间后,可以发现,除了第一次循环,后面的循环均出现了越界的情况
在8位数(2的次方数)的情况下begin2刚好是数组最后一个元素,下一次的i==n,进不去循环,所以没发生越界
2.6.4. 非递归修正
首先,我们上面的代码,begin1不可能发生越界,上一个end2是数组最后一个元素的话,begin1就不会进入下一次循环。
根据上面的结论,最可能出现越界的是end1,begin2,end2,我们要处理好这三个越界的情况
- 首先,当begin2越界的时候,因为上一组我们已经将对应位置排好序,所以这块的数据可以不用排,直接跳过
- 并且,如果是end1越界,由于上组已经将序排好,这里也可以选择直接跳出这次循环
- 如果是end2越界,我们只需要将嗯end2的位置移到最后一个元素位置即可,因为我们的数组合并,是两个有序数组进行合并,对数组的长度没有要求,所以这里end2位置只要不小于begin2,不大于n即可。
end1越界,begin2一定越界,只要当begin2越界的时候,也就是右边没有可比较的数组时,就可以break;
所以判断条件可以写成(begin2>=2)
if (end1 >= n || begin2 >= n)
{
break;
}
if (end2>=n)
{
end2 = n - 1;
}
虽然但是,他还是崩了
我们再看一下上面的代码
memcpy(a + i, tmp + i, (2 * gap) * sizeof(int));
//(2*gap)不是修正后的空间大小
//(end2-i+1)是空间大小,这里不能使用begin1,在上面的循环中,begin1的值发生了变化
//修改后:
memcpy(a+i,tmp+i,(end2-i+1)*sizeof(int));
修改后
void MergeSortSNonR(int* a, int n)
{
int* tmp = (int*)calloc(n, sizeof(int));
if (tmp == NULL)
{
perror("malloc falied");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int index = i;
if (begin2 >= n )
{
break;
}
if (end2>=n)
{
end2 = n - 1;
}
printf("[ %d , %d ], [ %d , %d ]", begin1, end1, begin2, end2);
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
memcpy(a + i, tmp + i, (end2-i+1) * sizeof(int));
}
gap *= 2;
printf("\n");
}
free(tmp);
}
完美运行
2.7.计数排序(非比较排序)
2.7.1思路及实现
计数排序就是字面意思,不需要比较数组中元素的大小,只需要统计每个元素出现的次数
这种结构类似哈希表
也属于映射–每个值与位置建立一个关系,这里将数组中的值直接当做数组下标,采用的是绝对映射
当数据不是从0开始的
这里值是从100开始的,如果还使用绝对映射,前面99块空间都会被浪费,这里可以采用相对映射,从最小的100,到最大的199,开辟99块空间
中间元素的下标可以使用a[i]-min来表示,如果想要找回原来的值,只需要a[i]+min即可。
特点:
- 计数排序适合数据很集中的数组,如果一个数组中只含有100个同一个数字,那么只需要开辟一块空间即可,但是如果一个数组中只含有1和9999999,那么需要开辟9999999的空间
- 这是个小众排序,没办法排序浮点数,字符串,或者是结构体,但是对于数据集中的整形来说,有奇效,超过快排和希尔都很简单
代码实现:
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] >max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int range = max - min+1;
int* count = (int*)calloc(range , sizeof(int));
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
2.7.2高端局
注:rand()只能产生最多3万个随机数,因为数据较为集中,所以这种对比情况下,计数排序很占优势
10w个数据(无序)
100w个数据
1000万数据
1亿数据
计数排序
时间复杂度:O(N+range)
空间复杂度:O(range)
3. 拓展
3.1.内外排序
- 内排序:数据在内存中,量较少,可以直接通过开辟空间或者对数组进行修改排序
- 外排序:数据在磁盘中,量很大,内存装不下,对这种数据的排序
归并排序是很好的能外排序的方法
假设我们有10亿个数据(约4G),但是我们的内存只有1G
我们可以采用归并的思想 - 先将4G的文件平均,分为4个1G的文件,保证文件都可以放在内存中,然后采用归并的思想,先将4个1G的文件排序,然后一个文件为一组,用文件指针对2个文件同时进行归并,归并后的结果放在2G的文件中,之后在对2个2G的文件进行归并,结果放入4G的文件
- 注:读取和存放我们可以每次只读取一个,存放一个,这样能保证内存够用
3.2.稳定性
稳定性:相同数据排完序后,相同的数据相对位置是否变化
比如在上面的数组中,有三个5,黑5最前,红5中间,绿5最后,如果排完序后,三个5的位置不发生变化,可以说排序是稳定的,相反,如果排完序后,三个5的顺序发生变化,就是不稳定的排序
稳定性的意义:
比如,有个结构体,存储的信息有总成绩和各科成绩,此时想要按总成绩排序,相同成绩的同学按语文成绩大小排序,在语文成绩有序的情况下,排总成绩,不稳定的排序可能会在排总成绩的过程中,将相同总分下的语文成绩顺序打乱,稳定的排序会保证相同总成绩下的语文成绩依旧有序
3.3.排序的对比
1. 直接插入排序
- 时间复杂度:
最坏的情况下,也就是所有数据都是逆序,此时第几次循环就要交换几次位置,从一次到n-1次所以为O(N^2) - 最好的情况下,数据顺序,每次都不需要交换,此时为O(N)
- 空间复杂度:
不需要开辟额外空间,为O(1) - 稳定性:
每次都会与前面大的数据交换,小于或等于不会交换位置,所以稳定
2. 希尔排序 - 时间复杂度:
前面的结论:O(N^1.3),由大量试验得出的结论 - 空间复杂度:
不需要开辟空间,O(1) - 稳定性:
由于在预排序过程中,将数组原数据的顺序已经打乱,比如数组中有两个9,第一个9预排序在第一组,第二个9在第二组,排完序后,第一个9很可能会被排到第二个9后面的位置,最后在稳定的插入排序下,9的位置会互换,所以不稳定
3. 选择排序 - 时间复杂度:
数组从begin=0向右走,end=n向左走,begin和end相遇时停止,每次begin和end走一步,都要在中间的数据中找最大和最小,两层循环,外面走n/2,内层每次走(end-begin)次,时间复杂度为O(N^2) - 空间复杂度:
不开辟额外空间,O(1) - 稳定性:
从左往右看,每次找最小值,如果是上面的情况,因为是交换的原因,min找到的第一个3和1会进行一次交换,此时,两个3的顺序不同,所以不稳定
从右往左看,每次找最大值,如果有两个9,第一次max找到第一个9,第二个9不会替换max的值,最后前面的9会替换end,导致两个9的顺序改变,因此对max来说,不稳定
因此,得出结论,找大和找小均存在的情况下,不稳定
4.堆排序
- 时间复杂度:
O(N*logN) - 空间复杂度:
O(1) - 稳定性:
堆顶的9和最下面的2进行位置互换,此时在物理结构的数组中,两个9的前后位置发生变化,所以不稳定
5. 冒泡排序 - 时间复杂度:
未优化的情况下:因为每次只要前面大于后面都要进行交换,每次循环两个两个对比,所以不管有没有序,都是O(N^2)
优化后的版本:在前面数据均有序的情况下能少点 - 空间复杂度:
O(1) - 稳定性:
作为上面所有排序中最慢的,冒哥起码很稳定
相等数据的情况下,后面不可能跑到前面,稳定
6.快速排序 - 时间复杂度
类似二叉树的结构,每次将数组分成两段,两段子序列再分别分成两段,为logN,每次调整位置,需要走n/2次,所以最后的时间复杂度O(N*logN)(这里是优化后的),没有优化的情况下要分情况讨论 - 空间复杂度
因为程序采用了递归的方法,每次递归都会在栈上使用空间,使用的空间也就是类似二叉树的层数O(logN) - 稳定性
每次是将标记的位置放在合适的位置,为了避免死循环,我们不得不在数据相同的时候也进行替换,因此不稳定
7.归并排序 - 时间复杂度:
归并的思路,将所有数据一一归并,二二归并,四四归并等,每两组的数据进行排序,直到所有数据有序,很稳定的0(N*logN) - 空间复杂度:
需要开辟n快空间保存原数组的数据,所以为O(N) - 稳定性:
- 每次前后两个数组进行归并,相同数据也是左边数组的先排,所以稳定
总结: