排序是数据结构与算法中,重要的一环,本节来学习及实现各大排序算法。
常见排序算法
插入排序 -插入排序
-希尔排序
选择排序 - 选择排序
-堆排序
交换排序 -冒泡排序
-快速排序
归并排序 -归并排序
选择排序
插入排序
思想:假设前面部分有序,后面插入一个新的数到前面有序部分。
-
插入一个数:往前挪到适当位置保证前面部分有序。
-
把第一个数当作有序部分,有序插入后面的n-1个元素,最终完成排序。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UBwSpPW8-1658585696791)(X:\编程\code\CSDN 博客\插入排序.gif)]
实现
1.有序地插入一个数:
初始化:用end保存有序部分最后一个元素下标,用tmp存储插入的数。
arr[end]与tmp比较,若前者大于tmp,后挪arr[end+1]=arr[end]
。
最后arr[end]<tmp
时,说明找到有序的位置,令arr[end+1]=tmp
。(这里可以每次交换,但效率不高)
2.最后一次单躺排序时,end在倒数第一个位置:对应索引为n-2 ,故代码如下
//升序
void InsertSort(int* a, int n)
{
for (int end = 0; end < n - 1; end++) {
//[0,end]有序
int tmp = a[end + 1];
//end最少为0的位置
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
//找到正确的位置
else
break;
}
a[end + 1] = tmp;
}
}
希尔排序
-
预排序 :分组(步长gap) 每组分别单躺,完成大致有序
-
再一次整体单趟插排
实现
i遍历每组的每个元素。 i 初始值为一组的首元素索引,i的边界应为lenth-gap(刚好为上图的6对应的索引),步增gap
end的应该等于每一组的开头索引,end=i
。此时,
- 可以再套一层循环,遍历每一组
- i 的遍历顺序改为i++,因此变成了轮流遍历各分组的元素。最终效果与第一种方法一样,这种方法更加巧妙。
代码
void ShellSort(int* a, int n) {
//1.预排序 分组步长gap 每组分别单躺插入
int gap = 2;
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;
}
//2.再单趟插入
InsertSort(a, n);
}
关于gap的取值
升序: gap取值越大,大数越快到后面,小数越快到前面,越不接近有序
gap取值越小,越接近有序。当gap==1时,为插入排序。
假设数据有10000个,此时gap取3就没意义了,因为与gap==1相差不大。
这里有种比较官方的算法:gap初始化为n,每次自除3。为了让gap最后能等于1,又需要+1:gap=gap/3+1
这样能让当gap==2,3时,必得到1 。
优化后代码
void ShellSort(int* a, int n) {
//gap>1时 预排序
//gap==1时,插入排序
int gap = n;
while (gap > 1)
{
gap /= 3 + 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;
}
}
}
时间复杂度:希尔排序的时间复杂度非常难算,上面的算法可以近似视为:n/3/3/3/……=1 →3^x=n (x为除以3的次数)
所以x约等于log3(N) 以3为底N的对数
根据前人的经验,我们一般记作O(n^1.3)
选择排序
选择排序,故名思意,遍历一遍选择数组里面最大(小)的,与首(尾)交换。
然后因为遍历一次是可以记录最小值,跟最大值的,所以这里是可以对其再进行优化。
实现
void SelectSort(int* a, int n)
{
//记录最大值,最小值的索引
int maxi = 0, mini = 0;
//用begin,end记录需要交换的索引
int begin = 0, end = n - 1;
while (begin < end) {
//单趟,一个数需要做的
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]);
//有可能交换后,maxi指向不是最大值,原最大值被交换到mini,所以要重定向
if (maxi == begin)
maxi = mini;
swap(&a[end], &a[maxi]);
end--;
begin++;
}
}
堆排序
冒泡排序
一个非常容易理解的排序。
这里只讲讲冒泡的优化,当数组已经有序的时候,遍历一边是不发生交换的,但是程序仍然会往下遍历,可以在这里加入个flag用来判断是否交换,以省去后续的遍历。
void BubbleSort(int* a, int n)
{
int flag = 0;
for (int i = 0; i < n; i++)
{
for (int j = i; j < n - 1; j++)
{
if (a[j] > a[j + 1]) {
swap(&a[j], &a[j + 1]);
flag = 1;
}
}
if (!flag)
break;
}
}
快速排序
hoare版本
发明快排的计算机科学家的最初始的版本。
选出一个keyi,记录最左/最右的值(记住索引而不是值,是为了后面方便交换)
单趟完后,左部分得比key小;右部分得比key大。
一趟快排的简单实现
int left = begin, right = end;
int keyi = left;
//单趟
while (left < right)
{
//当右边全都大于左边,这个时候end会越界到左边而停不下来,故需要再加一层判断left<right【防越界】
while (left < right && a[right] >= a[keyi])//当当前右边是大于key,就不动他;让end--
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[right]);
为什么选左边的时候,要先移动右指针?
这个算法必须保证相遇位置的值,要比key小。【左找大,右找小】
如果L先走,就可能会找比Key大的值;只有R先走才能保证找到比key小或等于的值。
然后用分治的思想,递归的解决两边排序,退出的条件是当begin>=end时(区间不存在或者只有一个值的时候)
实现
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin, right = end;
int keyi = left;
//单趟
while (left < right)
{
//当右边全都大于左边,这个时候end会越界到左边而停不下来,故需要再加一层判断left<right【防越界】
while (left < right && a[right] >= a[keyi])//当当前右边是大于key,就不动他;让end--
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[right]);
//更新keyi
keyi = left;
//[begin,keyi-1] keyi [keyi+1,end]
//排序左右
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
挖坑法
相对上一个版本,思想上没有特别大的区别,只是用一个key,及piti保存位置,替代了多次交换
实现
//单趟 挖坑版
int partsort2(int* a, int begin, int end) {
int key = a[begin];
int piti = begin;
while (begin < end)
{
while (begin < end && a[end] >= key)
end--;
//填坑,并更新piti
a[piti] = a[end];
piti = end;
while (begin < end && a[begin] <= key)
begin++;
//填坑,并更新piti
a[piti] = a[begin];
piti = begin;
}
//key填入最后的相遇的坑
a[piti] = key;
return piti;
}
前后指针版
prev维护前面小于key的左部分,cur往后找大于key的右部分
对于cur指向值:
-
小于key,让prev维护的部分扩大,即prev自己往后一步,然后把cur指向小于key的值跟prev指向的值交换;
prev++; swap(&a[cur],&a[prev]);
-
大于key,让cur往后走,不动prev;保持cur维护大于key值部分。
cur++;
一趟走完,prev在左部分的最后一个,跟key交换,使其在中间:[小于key] key [大于key]
swap(&a[keyi], &a[prev]);
再对prev交换进行优化:因为prev与cur重叠时是不需要交换的,故可以跳过: 加上条件prev!=cur
实现
//单趟 前后指针版
int partsort3(int* a, int begin, int end) {
int prev = begin, cur = begin + 1;
int keyi = begin;
while (cur <= end)
{
判断cur值:小于key,让prev往后一步(扩大左部分)
//if (a[cur] < a[keyi]) {
// prev++;
// swap(&a[prev], &a[cur]);
//}
if (a[cur] < a[keyi] && ++prev != cur)
{
swap(&a[prev], &a[cur]);
}
cur++;
}
swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
优化
快排的最坏情况,每次key的值拿到了最小/最大,时间单趟时间复杂度变成O(N),整体变成O(N²);更糟的是,快排很有可能导致栈溢出。
所以有了三数取中的算法,每次取首尾中三个数中的中间值,确保不会是整体的最大/最小值。
//三数取中
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid]) //begin mid
{
if (a[mid] < a[end]) // xx mid end
return mid;
else if (a[begin] < a[end])// xx xx mid
return end;
else
return begin;
}
else //mid begin
{
if (a[mid] > a[end])//xx mid begin
return mid;
else if (a[begin] > a[end])//mid end begin
return end;
else//mid begin end
return begin;
}
}
从上可以得知当,栈溢出是快排最大的问题,我们可以在当区间缩小到一定范围的时候,选择其他排序,减少递归的次数。
下图h为高度,右边标的为每层递归调用次数
非递归快排
-
改循环
-
利用数据结构栈(堆中开辟),模拟内存开辟的栈区。
归并排序
类似二叉树的后序遍历
思路
对一堆数据排序,先分成左右两组,两组排序好后,
再把2组有序地归并到一个tmp数组,再复制回原数组。
结束条件就是,区间只有一个,或者不存在(奇数)时,直接返回。
实现
void _MergeSort(int* a, int begin, int end, int* tmp) {
//如果,只有一个值/不存在区间,就不需要合并 即begin>=end
if (begin >= end)
return;
int mid = (begin + end) / 2;
//分治,后序遍历
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并
//有序左区间[begin,mid] 有序右区间 [mid+1,end]
//再合成有序的大区间tmp :双指针
//归并到tmp:利用索引
int begin1 = begin, end1 = mid;//表示左区间
int begin2 = mid + 1, end2 = end;//右区间
int i = begin1;//记录tmp存放索引
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//其中一个没有走完
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
//再把tmp的值,按顺序给a数组
memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n) {
//开辟动态临时数组
int* tmp = malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
//释放
free(tmp);
}
非递归版
直接对数组进行迭代归并
归并的算法与上面大致一样。但是因为如果数组不是2的次方倍,而每次gap*=2 ,会导致越界。
而又不能用i<=n-gap
这会使i跳过某些区间 ,所以还需要对边界进行修正
void MergeSortNonR(int* a, int n) {
int* tmp = malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("");
exit(-1);
}
int gap = 1;//区间个数
while (gap < n) {
//单趟:
for (int i = 0; i < n; i += 2 * gap)
{
// gap gap 2*gap
//[begin1,end1][begin2,end2]
//[i,i+gap-1][i+gap,i+gap+gap-1]
int begin1 = i, end1 = i + gap - 1;//表示左区间
int begin2 = i + gap, end2 = i + gap + gap - 1;//右区间
//区间个数非2的次方数,会出现越界
//在此对end修正
if (end1 >= n) {//end1越界 ,把后面右区间修为不存在的区间 因为end1都越界了,所以后面的绝对越界
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n) {//begin2越界,区间不存在
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)//end2越界
{
end2 = n - 1;
}
int tmpi = begin1;//记录tmp存放索引
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[tmpi++] = a[begin1++];
}
else
{
tmp[tmpi++] = a[begin2++];
}
}
//其中一个没有走完
while (begin1 <= end1)
tmp[tmpi++] = a[begin1++];
while (begin2 <= end2)
tmp[tmpi++] = a[begin2++];
}
memcpy(a, tmp, n * sizeof(int));
//每次让区间变成2倍
gap *= 2;
}
}
外排序:海量数据读取磁盘文件排序。
以上7种排序只有归并排序适合外排序。
时间复杂度分析
稳定性:是指排序数组中,一组相同数据在排完序后,仍能保持一样的相对顺序,即称为稳定性好。
稳定性并非指,比如快排有时能到最好情况O(nlogN),有时最坏情况O(N²),这种波动并非指这里的稳定性。
最坏(时间) 最 好 空间 稳定性
-插入排序 O(N²) O(N) O(1) 稳定
-希尔排序 O(N^1.3)(平均) O(1) 不稳定 (预排时,相同的数据可能分到不同的组)
-选择排序 O(N²) O(N²) O(1) 不稳定 (每次选最小/最大,可能把相对顺序改掉,如4a 4b 1变1 4b 4a)
-堆排序 O(NlogN) O(NlogN) O(1) 不稳定 9, 7- 6 ,7- 7 最后一个7于9换,已经改变相对顺序
-冒泡排序 O(N²) O(N) O(1) 稳定
-快速排序 O(N²) O(NlogN) O(logN) 不稳定 5 6 5 -2- 6 5 7,排完第一个5跑到中间,顺序改变了
-归并排序 O(NlogN) O(NlogN) O(N) 稳定 1 2 - 1 3 让左1先排即可稳定