系列文章目录
速通数据结构与算法系列
1 速通数据结构与算法第一站 复杂度 http://t.csdnimg.cn/sxEGF
2 速通数据结构与算法第二站 顺序表 http://t.csdnimg.cn/WVyDb
3 速通数据结构与算法第三站 单链表 http://t.csdnimg.cn/cDpcC
4 速通数据结构与算法第四站 双链表 http://t.csdnimg.cn/0VyDl
5 速通数据结构与算法第五站 栈&&队列 http://t.csdnimg.cn/MRU83
6 速通数据结构与算法第六站 树&&堆 速通数据结构与算法第六站 树&&堆-CSDN博客
感谢佬们支持!
目录
- 前言
- 一、插入排序
- 复杂度分析
- 二、希尔排序
- 复杂度分析
- 三、选择排序
- 复杂度分析
- 四、堆排序
- 复杂度分析
- 五、冒泡排序
- 复杂度分析
- 六、快排
- 1 hoare法
- 2 挖坑法
- 3 前后指针法(recommend)
- 4 非递归
- 5 优化之三数取中(随机选key)
- 6 优化之小区间优化
- 7 优化之三路划分
- 8 SGI sort设计
- 9 复杂度分析
- 七、归并排序
- 1 递归版本
- 2 非递归但梭哈实现法
- 3 非递归但不梭哈实现法
- 4 复杂度分析
- 八、其他排序的介绍
- 1 计数排序
- 复杂度分析
- 2 桶排序
- 复杂度分析
- 3 基数排序
- 九、排序总结
- 总结
前言
这一节是速通数据结构的最后一节,我们要来学习排序。排序,看起来是个很简单的话题,实则一点也不简单。
举个例子,为了达到极致的效率,STL(SGI)算法库中的排序优化一大堆,首先要用快排,当数据个数小于16为了减少递归层数,改调插排,为了key的大小更适中搞了三数取中
一旦"划分恶化"改调堆排,防止时间复杂度恶化到O(n方)。所以排序还是很值得我们学习滴~
注:这篇文章收稿时达到了2w字!最用心的一集
一、插入排序
插入排序类似于我们整理扑克的过程
学排序首先要从单趟排序开始
假设现在有一个[2,4,7]的有序序列,我们要往其中插入一个数
1、假设我们插入的是1,2、4、7就都要往后移动
2、假设我们插入的是5,那需要挪的就是7
3、假设我们插入的是8,那就不需要挪,直接在最后插入即可
有了思路之后,我们就可以先来搞单趟排序了
单趟排序的逻辑是将tmp插入[0,end]的有序区间时
"我比你小,你挪;我比你大,插你后面"
cpp
void InsertSort(int*a,int tmp)
{
int end;
int tmp;
while(end>=0)
{
if(a[end]>tmp)
{
a[end+1]=a[end];
}
else
{
break;
}
//我比你大||我比所有人都小,即end=-1
a[end+1]=tmp;
}
}
有了单趟排序,就能推出整个排序了,显然,第一个数不用排,最后一个数的位置是i-1
所以每次的end为i-1,tmp为a [i]
最后得出的代码就是这样的。
cpp
void InsertSort(int *a,int n)
{
assert(a);
for (int i = 1; i < n; ++i)
{
int end = i-1;
int tmp=a[i];
while (end >= 0)
{
if (a[end] >tmp)
{
a[end + 1] = a[end];
--end;
}
else//a[end]<=x
{
break;
}
}
a[end + 1] = tmp;
}
}
//SGI°æ²åÈëÅÅÐò
int main()
{
int arr[] = { 3,5.2,9,8,10,2 };
int size = sizeof(arr) / sizeof(arr[0]);
InsertSort(arr, size);
}
打印结果:
复杂度分析
按最坏来看,如果是完全逆序的情况
时间复杂度就是1+2+......+n,最后就是O(n^2)
但是如果数据是完全有序/接近有序的情况,我们仅需一次遍历即可
也就是O(n)
空间复杂度是O(1)
二、希尔排序
希尔排序算是对插入的升级
希尔排序又称缩小增量法,希尔排序的基本增量是:先选定一个整数,将待排序中的所有记录分成个组,所有距离为选定整数的记录分在同一组内,并对每一组的记录做好排序
。然后将选定整数除以一个数,重复上述步骤,当这个整数到达1时,进行的最后一次排序
会得到一个有序的结果
看似逻辑很复杂,实际很简单,就是分组进行插入排序而已,利用了插入排序数据越有序
时间复杂度越小这一特性
如何分组?
我们通过选取一个gap来操作,让间隔为gap的数为一组
例:
gap=3
[9,1,2,5,7,4,8,6,3,5]
我们将其分为红、绿、蓝三组
现对红色一组进行插入排序
蓝色一组
绿色一组
我们先来写红色组排队的逻辑
只需一次让i+=gap即可
cpp
int gap=3;
for (int i = 0; i < n - gap; i+=gap)
{
int end=i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else//a[end]<=x
{
break;
}
}
a[end + gap] = tmp;
}
下来如果要排三组,就要再加一层for循环
由于只有3组,所以我们给一个gap次的for循环即可
cpp
int gap=3;
for(int j=0;j<gap;++j)
{
for (int i = j; i < n - gap; i+=gap)
{
int end=i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else//a[end]<=x
{
break;
}
}
a[end + gap] = tmp;
}
}
或者用另一种方法,这样就可以在代码层面上减少一层循环(实际循环是没有减少的)
我们不让i一次+=gap了,而是一次加一个
cpp
int gap=3;
for (int i = 0; i < n - gap; i++)
{
int end=i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else//a[end]<=x
{
break;
}
}
a[end + gap] = tmp;
}
这样就从之前的一组一组插排变成了多组同时开始插排
在此基础上,我们要变化gap的值,最终让gap变为1,这样的话,最终gap就会等于1,最后一次排序的时候就会变成插排,实现有序
cpp
#include<stdio.h>
#include<assert.h>
//ϣ
void ShellSort(int* a, int n)
{
assert(a);
int gap = n;
while (gap > 1)//gap==1
{
gap /= 2;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else//a[end]<=x
{
break;
}
}
a[end + gap] = tmp;
}
}
}
int main()
{
int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
int size = sizeof(arr) / sizeof(arr[0]);
ShellSort(arr, size);
}
打印结果
复杂度分析
希尔排序按我们的常理来看,其时间复杂度取决于gap的选择
所以其时间复杂度很难计算,这里给到两本权威书上的解释
由于咱们的gap是按照knuth的方式来提出的,所以时间复杂度就O(n^1.25)~1.6*O(n^1.25)来算
所以这个排序是个很玄学的排序,就像跳表中选取每次插入数据是否提高层数的概率p一样
空间复杂度依旧是O(1)
三、选择排序
先介绍一下选择排序的基本思想
每一次从待排序的数据元素中选出最大/最小的一个元素,存放在序列的起始位置,直到待排序的所有元素全部排完
我们首先容易想到的就是直接选择排序,每次遍历我们可以同时选出最大/最小的数,将其放在序列的最左/最右
单趟就很容易想了,就是每次遍历选出最小/最大即可
cpp
int left = 0, right = n - 1;
int maxi = left, mini = left;
for (int i = left; i <= right; ++i)//ұ
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
swap(&a[left], &a[mini]);
swap(&a[right], &a[maxi]);
剩下只要每次维护left,right即可
但是还有一个坑,如果某一波,maxi正好就在left的位置
那更新最小值的时候就把最大值换走了
所以我们加个特判即可
cpp
#include<stdio.h.>
void swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void SelectSort(int*a,int n)
{
int left = 0, right = n - 1;
while (left < right)
{
int maxi = left, mini = left;
for (int i = left; i <= right; ++i)//ұ
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
swap(&a[left], &a[mini]);
if (maxi == left)
{
maxi = mini;
}
swap(&a[right], &a[maxi]);
++left;
--right;
}
}
int main()
{
int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
int size = sizeof(arr) / sizeof(arr[0]);
SelectSort(arr, size);
for (int i = 0; i < size; ++i)
{
printf("%d ", arr[i]);
}
打印结果
复杂度分析
选择排序由于每次都需要遍历找最大/最小值,所以时间复杂度最好最坏情况都是O(n^2),确实是最fvv的排序
空间复杂度是O(1)
四、堆排序
堆排序就是从直接选择排序变成了用堆选数。
关于堆排序的逻辑我们在上篇博客已经介绍过了,这里就不再赘述了
直接上代码
cpp
#include<stdio.h>
//swap
void swap(int* px, int* py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
//向上调整
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)//最多调到根
{
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//向下调整
void AdjustDown(int* a, const int n, int parent)
{
//找左右孩子大的一个交换
int child = parent * 2 + 1;//suppose左孩子大,经典玩法
while (child < n)//如果孩子超出了数组范围,说明parent是叶子节点
{
if (child + 1 < n && a[child + 1] > a[child])//防止越界
{
child++;
}
if (a[child] > a[parent])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//排升序--建大堆(向上调整建堆)
void HeapSort1(int* a, int n)
{
for (int i = 1; i < n; ++i)//O(lgN)
{
AdjustUp(a, i);
}
int end = n - 1;
while(end>0)
{
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
//排升序--建大堆(向下调整建堆)
void HeapSort2(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
int main()
{
int arr[] = { 2,4,5,6,7,2,6 };
const int n = sizeof(arr) / sizeof(arr[0]);
HeapSort2(arr, n);
for (int i = 0; i < n; ++i)
{
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
打印结果
复杂度分析
关于堆排序的时间复杂度我们也分析过了
如果是向下调整建堆,就是O(n*lgn)
由于是原地建堆,所以空间复杂度是O(1)
五、冒泡排序
冒泡排序属于交换排序
我们来介绍一下交换排序的思想
所谓交换,就是根据序列中的两个记录键值对的比较结果来swap这两个记录在序列的位置
交换排序的特点是:将键值较大的记录先后移动,将键值较小的记录先前移动
冒泡排序也是我们非常熟悉的排序了
这里直接上代码
cpp
#include<stdio.h>
#include<stdbool.h>
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)
{
bool flag = false;
for (int j = i + 1; j < n; ++j)
{
if (a[i] > a[j])
{
flag = true;
swap(&a[i], &a[j]);
}
}
if (flag == false)
{
break;
}
}
}
int main()
{
int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
int size = sizeof(arr) / sizeof(arr[0]);
BubbleSort(arr, size);
for (int i = 0; i < size; ++i)
{
printf("%d ", arr[i]);
}
}
打印结果
复杂度分析
显然,如果序列本身有序,遍历一次即可排好序
所以时间复杂度最好情况为O(n)
如果是最坏情况,则是O(n^2)
需要注意的是,在相对有序的情况下,冒泡排序的时间复杂度还是不如插入排序的
空间复杂度是O(1)
六、快排
1 hoare法
单趟:选出一个基准值key,把他放到正确的位置(最终排好序要蹲的事)
例:
最终就会变成
我们有三种方法,第一种是这样的:
法一:
1、具体操作为左边找严格比key大的,右边找严格比key小的,然后swap
2、最后在最后停下的位置和key所在的位置交换一下
一波单趟排序就做好啦~
cpp
//hoare法
int partition1(int* a, int left, int right)
{
int mid = GetMidNumi(a, left, right);
swap(&a[left], &a[mid]);
int keyi = left;
while (left < right)
{
//左边是key,右边先走
//带上=,表示找严格大/严格小
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
//最后left和key换一下
swap(&a[left], &a[keyi]);
return keyi;
}
注:由于是循环套循环,所以做好边界判断
为什么要找严格大于/小于的?如果某个用例为[2,2,2,2,2,2];那就完了,会一直死循环
2 挖坑法
由于hoare大佬的这个方法太拉拉了,又给出了第二种(感觉更复杂了,意思差不多)
法二:
1、先将key值存在一个变量里,就会形成一个坑位
2、依然是左边找大,右边找小
右边找小,放到坑里,更新坑的位置
左边再找大,放进坑里,更新坑的位置
3、当left和right相遇,将key填入坑中
按图来看,就是这样
cpp
//挖坑法
int partition2(int* a, int left, int right)
{
int mid = GetMidNumi(a, left, right);
swap(&a[left], &a[mid]);
int key = a[left];
int hole = left;
//此时 left为坑
while (left < right)
{
//左边是key,右边先走
//带上=,表示找严格大/严格小
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;
}
//最后坑的位置放ke
a[left] = key;
return hole;
}
3 前后指针法(recommend)
这个方法还是很清爽很简洁的
法三
1、初始时,prev指向序列开头,cur指针指向prev指针后的一个位置,就像这样
2、cur所在位置比key小的,
如果找到了,就++prev,和cur交换
交换完以后,++cur
3 如果cur所在位置比key大
就++cur
在排序过程中,大概就是两种情况
1 prev紧跟着cur
2 prev和cur隔着比key大的一段数的区间
按图来看,就是这样
cpp
//前后指针法
int partition3(int* a, int left, int right)
{
int mid = GetMidNumi(a, left, right);
swap(&a[left], &a[mid]);
int keyi = left;
int cur = left+1;
int prev = left;
while (cur <= right)
{
if (a[cur] < a[keyi] && (++prev) < cur)//不要自己和自己换~
swap(&a[cur], &a[prev]);
++cur;
}
swap(&a[keyi], &a[prev]);
return prev;
}
那么问题来了,我们已经学习了3种单趟排序的方法?
如何实现完整的排序呢?
我们知道,第一波单趟排序排好的是最终key的位置
所以下来的操作非常简单
1 我们对key的左右区间递归使用该函数即可
2 当区间只有一个数/区间不存在时,递归调用结束
有了上一节我们学到的递归的经验,这波函数体就会是这样(以第三种方法为例)
cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi+1, right);
}
需要注意的是,三种单趟排序对同一序列的结果可能不同,如果有数据结构题目问
单趟排序的结果,我们需要考虑3种方式
4 非递归
非递归的思路也很简单,只要将递归的逻辑用栈代替即可
1、每次将left、right入栈;
2、每次取两次栈顶组成begin、end调用单趟排序
3、将单趟排序返回的keyi左右两端区间的左右顶点入栈
4、当栈为空时,循环结束
cpp
//前后指针法
int partition3(int* a, int left, int right)
{
int keyi = left;
int cur = left + 1;
int prev = left;
while (cur <= right)
{
if (a[cur] < a[keyi] && (++prev) < cur)//不要自己和自己换~
swap(&a[cur], &a[prev]);
++cur;
}
swap(&a[keyi], &a[prev]);
return prev;
}
void QuickSortNonR(int* a, int left, int right)
{
stack<int> st;
st.push(left);
st.push(right);
while (!st.empty())
{
int end = st.top();
st.pop();
int begin = st.top();
st.pop();
int keyi = partition3(a, begin, end);
//begin,keyi-1 ; keyi+1,end
if (begin < keyi - 1)
{
st.push(begin);
st.push(keyi-1);
}
if ( keyi + 1<end)
{
st.push(keyi+1);
st.push(end);
}
}
}
int main()
{
int arr[] = { 3,5,2,9,8,10,2,2,9,8 };
int size = sizeof(arr) / sizeof(arr[0]);
QuickSortNonR(arr, 0, size - 1);
for (int i = 0; i < size; ++i)
{
printf("%d ", arr[i]);
}
}
打印结果
5 优化之三数取中(随机选key)
三数取中其实很简单,由于我们固定选最左边的数为key会有不确定性,所以我们选取
左中右三个数中中间的那个为key
cpp
//三数取中
int GetMidNumi(int* a, int left, int right)
{
int mid = (right + left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
//说明mid是最大的
else if (a[right] > a[left])
{
return right;
}
else
{
return left;
}
}
else
{
if (a[mid] > a[right])
{
return mid;
}
//说明mid是最小的
else if (a[right] > a[left])
{
return left;
}
else
{
return right;
}
}
}
6 优化之小区间优化
我们注意到,当递归到一定深度时,每次的区间长度不长
但仍需要递归,这就会导致递归次数太多,有栈溢出的风险
所以我们限制一个长度,在此长度以下,我们直接调插入排序
由于是接近有序,所以效率不会太拉还可以减少递归层数
cpp
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 小区间优化--小区间直接使用插入排序
if ((right - left + 1) > 10)
{
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else
{
InsertSort(a+left, right - left + 1);
}
}
7 优化之三路划分
当你有了以上优化之后,你会发现,你依然无法通过力扣的那道排序题
因为有个用例是大量重复的数
这对我们的快排是极为不利的
大量的重复意味着大量的递归+遍历,而每次单趟排序都不能做事
所以有人提出了所谓三路划分
之前我们通过选取key找到大于key/小于key的区间,算的上是一种双路划分
所以三路划分是这样的
单独划分出一个等于key的区间
核心思想是这样的
1、 跟key相等的值,往后推
2、比key小的在左边,比key大的在右边,和key相等的在中间
所以我们的双指针法就变成了三指针法了,
1、a[cur]<key,交换left和cur,left++,cur++
2、a[cur]>key,交换right和cur,right--,cur++
3、a[cur]==key,cur++(只动cur!)
搞定了单趟其他就简单了
我们和之前一样递归即可
简单实现一波
cpp
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
if((end-begin+1)<15)
{
InsertSort(a+begin, end - begin + 1);
}
// 小区间优化--小区间直接使用插入排序
else
{
int mid=GetMidNumi(a,begin,end);
swap(a[begin],a[mid]);
int left=begin;
int right=end;
int key=a[begin];
int cur=begin+1;
while(cur<=right)
{
if(a[cur]<key)
{
left++;
cur++;
}
else if(a[cur]>key)
{
swap(a[cur],a[right]);
--right;
}
else
{
cur++;
}
}
QuickSort(a,begin,left-1);
QuickSort(a,right+1,end);
}
}
有了这个优化,我们就可以通过力扣的排序题啦
8 SGI sort设计
(参考侯捷老师的《STL源码剖析》)
学习了上述优化,我们就可以来评鉴一下C++的算法库中的sort了
总述:STL的sort算法,数据量大时采用QuickSort,分段递归排序,一旦分段后的数据量小于某个门槛,为避免QuickSort的递归调用带来过大的额外负荷,就改用InsertSort
如果递归层数过深,还会改用HeapSort
InsertSort
SGI的InsertSort有两个版本,一种是递增,另一种是仿函数
版本二的可以先忽略,我们重点来看版本一的
这个是插入排序的外循环
cpp
template<class RandomAccessIterator>
void __insertion_sort(RandomAccessIterator first,RandomAccessIterator last)
{
if(first==last)
return ;
for(RandomAccessIterator i=first+1;i!=last;++i)//外循环
{
__liner_insert(first,i,value_type(first));
}
}
__linear_insert是这么做的
cpp
template<class RandomAccessIterator,class T>
inline void __liner_insert(RandomAccessIterator first,RandomAccessIterator last,T*)
{
T value =*last;//记录尾元素
//和我们写的不同的是,这里分了两种情况,是否尾比头还小(头为最小元素)
if(value<*first)
{
copy_backward(first,last,last+1);//整体后移
*first=value;
}
else
{
__unguarded_linear_insert(last,value);
}
}
这里的__unguarded的命名很讲究,表明这波不用判断是否超过边界(也就是我们所写代码中end>=0的逻辑),
因为我们的源码确保了最小值必然在内循环区间的最边缘
cpp
void __unguarded_linear_insert( RandomAccessIterator last,T value)
{
RandomAccessIterator next=last;
while(value<*next)//内循环的逻辑
{
*last=*next;
last=next;
--next;
}
*last=value;
}
细节满满,看似只是省下了一个简单的判断,但是在大数据量下,影响还是很可观的
QuickSort
SGI的QuickSort提供了两种单趟排序(partioning)分别是我们的单趟1和单趟3
别的都差不多,没什么好说的
threshold
SGI sort还认为,对于一个小数据量序列,甚至简单的插入排序可能更快
鉴于这种情况,适度评估序列的大小,再决定选择插排还是快排,是值得采纳的优化措施
侯捷老师认为并无定论,5~20都有可能,因设备而定
final Insert Sort
SGI也采用了当序列小于一个值就调用插入排序的做法
(在源码中,这个值是16)
cpp
const int __stl_threshold =16;
原文:如果我们令某个大小以下的序列滞留在"几近排序但尚未成功"的状态,最后再以
一次InsertSort将所有这些"几近排序但尚未成功"的子序列做一次完整的排序,其效率一般认为会比"将所有子序列彻底排好"更好,这是因为InsertSort在面对"几近排序"的序列时,有更好的表现
introsort
不当的轴承(就是key值)选择,可能导致QuickSort退化为O(n^2),所以David大佬提出了一种混合式排序,内省式排序,简称introsort。在分割行为有二次行为的倾向时,能自动检测,转而使用HeapSort,保住O(n*lgn)的下限
自我检测的函数为__lg,其实就是在找2^k<=n的最大值k
cpp
template<class Size>
inline Size __lg(Size n)
{
Size k;
for(k=0;n>1;n>>=1)
++k;
return k;
}
所以,最终的sort本体是这样的~
cpp
template<class RandomAccessIterator>
inline void sort(RandomAccessIterator first,RandomAccessIterator last)
{
__introsort_loop(first,last,value_type(first),__lg(last-first)*2);
__final_insertion_sort(first,last);
}
cpp
template<class RandomAccessIterator,class T,class Size>
void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last,T* ,Size depth_limit)
{
while(last-first>__stl_threshold)
{
if(depth_limit==0)
{
//分裂恶化
//调用堆排!
partial_sort(first,last,last);
return ;
}
--depth_limit;
//下来就是单趟排序+三数取中的逻辑了,这里简化一下,就不写了
RandomAccessIterator cut;
//递归左右区间调用__introsort_loop,这里也简化一下
__introsort_loop();
__introsort_loop()
}
当__introsort_loop()结束后,[first,last)会存在多个元素大于16的元素
此时回到主函数sort,再进行__final_insertion_sort(first,last);
cpp
template<class RandomAccessIterator>
void __final_insertion_sort(RandomAccessIterator first,RandomAccessIterator last)
{
if(last-first>__stl_threshold)
{
__insertion_sort(first,first+__stl_threshold);
__unguarded_insertion_sort(first+__stl_threshold,last);//就是循环调用__unguarded_linear_insert;
}
else
{
__insertion_sort(first,last);
}
}
该函数首先判断元素个数是否大于16,如果为否,直接调用__insertion_sort
如果为是,就分割为一段长为16的区间和剩下的区间,分别调用__insertion_sort和------__unguarded__insertion_sort处理
这就是SGI STL sort的故事了,设计非常巧妙(最用心的一集)
9 复杂度分析
根据我们在SGI sort分析的那样,如果是普通快排,时间复杂度一般是O(n*lgn)
但是在极端情况下,会退化至O(n^2)。
但是SGI sort,我们可以保证一个O(n*lgn)的下限
空间复杂度的来源是栈帧的建立,为O(lgn)~O(n)
七、归并排序
归并排序是建立在归并操作上的一种有效的排序算法,采用了经典的分治法。
将已有序的子序列合并,得到完全有序的序列
归并的逻辑很简单,就是我们之前的合并两个有序数组的逻辑最后再拷回原数组即可
1 递归版本
cpp
void _merge(int* a, int left, int right,int *tmp)
{
//一个值时/区间不存在时结束
if (left >= right)
{
return;
}
int mid = (left+right) / 2;
//子区间递归排序
_merge(a, left, mid, tmp);
_merge(a, mid + 1, right, tmp);
//归并
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
printf("begin1:%d end1:%d\n", begin1, end1);
printf("begin2:%d end2:%d\n", begin2, end2);
int i = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
if (begin1 > end1)
{
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
}
if (begin2 > end2)
{
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
}
//记住加left!
memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
}
void MergeSort(int* a, int n)
{
int* nums = (int*)malloc(sizeof(int) * n);
_merge(a,0, n-1,nums);
free(nums);
}
int main()
{
int arr[] = { 3,5,2,9,8,10,2,2};
int size = sizeof(arr) / sizeof(arr[0]);
MergeSort(arr, size);
for (int i = 0; i < size; ++i)
{
printf("%d ", arr[i]);
}
}
2 非递归但梭哈实现法
非递归如何模拟模拟归并的过程呢?
归并的逻辑是先一一归并,再二二归并,在此基础上四四归并,他的逻辑更像一个后序遍历
而栈则对应的是前序遍历,所以这波不能用栈,而可以控制一个gap,实现一一、二二、四四......归并
gap==1,此时为一一归并
gap*=2,就变成了二二归并
......
光这样还不够,我们只考虑了元素个数是二的次方的情况
如果是9,20个数据,end2,begin2,begin1会有越界的风险
所以我们要修正边界
光修正边界还不够,我们要考虑拷回原数组的问题,这里分为一把拷(梭哈)和归一部分拷一部分(不梭哈)两种情况
我们可以打印一波边界来看看情况
我们试试9个元素
cpp
//一把梭哈
void MergeSortNonRS(int* a, int n)
{
int* nums = (int*)malloc(sizeof(int) * n);
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//归并
int begin1 = i;
int end1 = i+gap-1;
int begin2 = i+gap;
int end2 = i+2*gap-1;
printf("begin1:%d end1:%d\n", begin1, end1);
printf("begin2:%d end2:%d\n", begin2, end2);
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
nums[j++] = a[begin1++];
}
else
{
nums[j++] = a[begin2++];
}
}
if (begin1 > end1)
{
while (begin2 <= end2)
{
nums[j++] = a[begin2++];
}
}
if (begin2 > end2)
{
while (begin1 <= end1)
{
nums[j++] = a[begin1++];
}
}
}
//记住加left!
memcpy(a , nums, sizeof(int) *n);
gap *= 2;
}
free(nums);
}
int main()
{
int a[] = { 6,1,2,6,9,3,4,6,10};
MergeSortNonRS(a, 9);
}
由于begin1为i,所以不可能越界
下来,越界有这么几种情况
1、end1越了,不归并了,但是是要拷贝的(因为只剩一个数了)
【8,11】【12,15】
由于我们要一次梭哈拷贝,所以这波我们要修正边界,才能不被覆盖
2、end1没越界,begin2越界了
同理,不用归并,也要修正边界
【8,8】【9,9】
3、只有end2越界了
他是需要归并的,修正end2
【0,7】【8,15】
不归并的修正边界很简单,我们只需修成一个不存在的区间,就不进循环了
而最后需要归并的end2,我们需要计算一下修正到的值,即n-1
cpp
//一把梭哈
void MergeSortNonRS(int* a, int n)
{
int* nums = (int*)malloc(sizeof(int) * n);
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//归并
int begin1 = i;
int end1 = i+gap-1;
int begin2 = i+gap;
int end2 = i+2*gap-1;
printf("begin1:%d end1:%d\n", begin1, end1);
printf("begin2:%d end2:%d\n", begin2, end2);
//修正
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
///修成一个不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
nums[j++] = a[begin1++];
}
else
{
nums[j++] = a[begin2++];
}
}
if (begin1 > end1)
{
while (begin2 <= end2)
{
nums[j++] = a[begin2++];
}
}
if (begin2 > end2)
{
while (begin1 <= end1)
{
nums[j++] = a[begin1++];
}
}
}
//记住加left!
memcpy(a , nums, sizeof(int) *n);
gap *= 2;
}
free(nums);
}
3 非递归但不梭哈实现法
好消息是,不拷贝,前两个就可以不用修正边界了,直接break出去
只需修正第二个区间的右边界即可
cpp
//归一部分拷一部分
void MergeSortNonR(int* a, int n)
{
int* nums = (int*)malloc(sizeof(int) * n);
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//归并
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
printf("begin1:%d end1:%d\n", begin1, end1);
printf("begin2:%d end2:%d\n", begin2, end2);
//修正
if (end1 >= n || begin2 >= n)
{
break;
}
else if (end2 >= n)
{
end2 = n - 1;
}
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
nums[j++] = a[begin1++];
}
else
{
nums[j++] = a[begin2++];
}
}
if (begin1 > end1)
{
while (begin2 <= end2)
{
nums[j++] = a[begin2++];
}
}
if (begin2 > end2)
{
while (begin1 <= end1)
{
nums[j++] = a[begin1++];
}
}
memcpy(a +i , nums+i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(nums);
}
复杂度分析
归并排序的时间复杂度也是标准的O(n*lgn)
空间复杂度来源于开辟的新数组,为O(n)
归并排序还可以用作外排序,大家感兴趣的话,可以再了解一下~
八、其他排序的介绍
计数排序
计数排序又称鸽巢原理,是对哈希直接定址法的应用
操作为:
1、统计相同元素个数
2、根据统计结果将序列回收回原来的序列
既然是模仿哈希,那么原理是这样的
思考一下元素的大致范围,开一个大的哈希表(数组),通过定址法将带牌序列的元素映射至
哈希表,最后再从哈希表提取元素即可
例:
{6,1,2,1,9,3,3,2,2,8}
我们直接开一个大小为10的数组就够了
然后遍历数组,直接定址,再对应下标值加1即可
遍历之后是这样的
下来就简单,遍历哈希表排序即可
看似很简单的计数排序,我们要考虑一些别的问题
如果序列是这样
{100,101,101,103,109,120},我们再从0开始定址就有些浪费了
所以我们可以统计最大,最小值,就能最大程度的节省空间了
需要注意的是,如果序列含有负数,我们的排序也可以解决
那代码是这样的~
cpp
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
for (int i = 1; i < n; ++i)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int range = max - min + 1;
int* countA = (int*)malloc(sizeof(int) * range);
if (countA == NULL)
{
perror("malloc fail\n");
return;
}
memset(countA, 0, sizeof(int) * range);
// 计数
for (int i = 0; i < n; i++)
{
countA[a[i] - min]++;
}
// 排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (countA[i]--)
{
a[j++] = i + min;
}
}
free(countA);
}
int main()
{
int arr[] = { 2,10,3,90,589,184,505.29,8,83 };
for (auto e : arr)
{
std::cout << e << " ";
}
std::cout << std ::endl;
int sz = sizeof(arr) / sizeof(arr[0]);
CountSort(arr, 0, sz);
for (auto e : arr)
{
std::cout << e << " ";
}
return 0;
}
复杂度分析
显然,如果序列内的值相差不太大,计数排序的时间复杂度能逼急O(n),非常逆天
其实是O(n+range)
但是由于我们用到了哈希表,那就意味着你得能哈希出一个值来才能排序
所以浮点数肯定是寄了,但是字符串可以(字符串哈希)。
空间复杂度是O(range)
桶排序
桶排序(Bucket Sort )是一种基于分布的排序算法,特别适用于数据分布比较均匀的情况。它的基本思想是将数据分成若干个桶(Bucket),然后分别对每个桶中的数据进行排序,最后再将各个桶中的数据按顺序合并,得到最终的有序数据。
桶排序的基本步骤:
- 创建桶 :
- 根据数据范围创建一定数量的桶,每个桶代表一个区间范围。
- 将元素分配到桶中 :
- 遍历输入数据,将每个数据根据其值分配到对应的桶中。
- 对每个桶内的数据排序 :
- 对每个桶内的数据单独进行排序(可以使用任何排序算法,通常使用快速排序或插入排序)。
- 合并桶中的数据 :
- 最后按顺序将各个桶中的数据合并成一个整体,即完成排序
复杂度分析
时间复杂度:
-
最好情况: O(n+k)O(n + k)O(n+k)
- 如果输入数据均匀分布到桶中,且每个桶内的数据可以使用一个高效的排序算法(如插入排序)来进行排序,桶排序的时间复杂度接近线性,等于 O(n)O(n)O(n)。
- 此外,每个桶的排序时间是与桶内元素数量有关的。如果使用插入排序,最好情况下每个桶的排序复杂度是 O(1)O(1)O(1),总的时间复杂度为 O(n+k)O(n + k)O(n+k),其中 nnn 是输入数据的数量,kkk 是桶的数量。
-
平均情况: O(n+k)O(n + k)O(n+k)
- 当数据大致均匀分布到桶中时,桶排序的平均时间复杂度与最好情况类似,也是 O(n+k)O(n + k)O(n+k),因为分配数据到桶中的过程是线性的。
-
最坏情况: O(n2)O(n^2)O(n2)
- 最坏情况下,所有数据都被分配到同一个桶中,此时桶排序退化为在单个桶中对 nnn 个元素进行排序。若该桶内使用插入排序或其他 O(n2)O(n^2)O(n2) 时间复杂度的排序算法,则总的时间复杂度为 O(n2)O(n^2)O(n2)。
空间复杂度:
- 空间复杂度: O(n+k)
这种排序一般没什么用,大家了解一下即可
基数排序
首先我们要知道,基数排序也是一个和之前不同,即不需要比较、移动的排序
是一种借助一种多关键字的排序对单关键字进行排序的方法
例:
我们的扑克牌就有两种关键字进行排序
一种是花色,♠️,♣️,♦️,♥️
另一种是数字大小,1,2,3,4,5,6,7,8,9,10,J,Q,K
再介绍两个概念
最高位优先(MSD)&最低位优先(LSD)
以扑克牌为例
MSD为:每个子序列花色相同但数字不同
LSD为:4个1,4个2,4个3......
给一个例子
我们以低位优先为例
由于是低位优先,所以我们先按个位排,
将其遍历,重组回原数组(注意,这波063先进,所以先出063)
下来再排十位
排好百位,这波就结束啦
原理还是很简单的,建一个挂队列的哈希表
然后进行数位次操作
每次操作先分发,再组合
写好的代码就是这样的~
cpp
#define K 3
#define RADIX 10
std::queue<int> Q[RADIX];
int GetKey(int value,int k)
{
int key = 0;
while (k >= 0)
{
key = value % 10;
value /= 10;
k--;
}
return key;
}
void Distribute(int*arr,int left,int right,int k)
{
for (int i = left; i < right; ++i)
{
int key = GetKey(arr[i],k);
Q[key].push(arr[i]);
}
}
void Collect(int*arr)
{
int k = 0;
for (int i = 0; i < RADIX; ++i)
{
while (!Q[i].empty())
{
arr[k++] = Q[i].front();
Q[i].pop();
}
}
}
void RadixSort(int* arr, int left, int right)
{
for (int i = 0; i < K; ++i)
{
//分发
Distribute(arr,left,right,i);
//组合
Collect(arr);
}
}
int main()
{
int arr[] = { 278,10,63,930,589,184,505.269,8,83 };
for (auto e : arr)
{
std::cout << e << " ";
}
std::cout << std ::endl;
int sz = sizeof(arr) / sizeof(arr[0]);
RadixSort(arr, 0, sz);
for (auto e : arr)
{
std::cout << e << " ";
}
return 0;
}
复杂度分析
假设我们搞了r个队列
那我们的空间复杂度就是O(r)
假设我们要进行k次操作
每次分发和组合的时间复杂度就是(n+r)
所以总体就是O(k(n+r))
九、排序总结
这里我们再介绍一个所谓的稳定性的概念
假定在待排序的记录序列中,存在多个具有相同关键字的记录,若经过排序,这些记录的
相对次序保持不变。
即在原序列中,r[i]=r[j],且r[i]在r[j]之前,在排序之后,如果r[i]仍在r[j]之前,就说明这种排序是稳定的,否则就是不稳定的
说人话就是,假设某个序列有相同的数字,经过排序之后,这些相同的数的顺序不变,就是稳定的
有什么用呢?
假定考试时规定分数相同先交卷的人更牛逼,我们就用得着这个稳定性了
根据这些排序的原理,我们便可推出一个排序是否稳定了
|------|-------|----------------------------|
| 排序名 | 是否稳定? | 解释 |
| 插入排序 | 稳定 | 显然,如果规定了相等了不往后 挪即可 |
| 希尔排序 | 不稳定 | 相同的数可能被分到不同的组 |
| 选择排序 | 不稳定 | 每次选到的数不固定 |
| 堆排序 | 不稳定 | 每次作为堆顶的元素不确定,而堆顶之下可能会有重复元素 |
| 冒泡排序 | 稳定 | 相同不交换就可以做到稳定 |
| 快速排序 | 不稳定 | 每次的key值不确定 |
| 归并排序 | 稳定 | |
| 计数排序 | 稳定 | |
| 基数排序 | 稳定 | 辅助数组元素是队列,可以通过先进先出保证 |
总结
做总结,这篇博客我们学习了排序,这将是速通数据结构算法初阶的最后一集,接下来我们将迅速的投入沉淀C++的篇章中
水平有限,还请各位大佬指正。如果觉得对你有帮助的话,还请三连关注一波。希望大家都能拿到心仪的offer哦。