既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上C C++开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
O
(
N
∗
l
o
g
2
N
)
O(N*log_2 ^N)
O(N∗log2N)
这时候我们再通过100000个数的伪随机数来试试看排序的速度(单位是毫秒)
效果很明显
3. 交换排序
3.1 冒泡排序
3.1.1 基本想法
起源于水里的气泡随着上升,越来越大
3.1.2 实现冒泡排序
- 问题拆解
- 单趟
- 多趟
单趟
for (int i = 1; i < sz; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i], &a[i - 1]);
}
}
多趟
void BubbleSort(int\* a, int sz)
{
for (int j = 0; j < sz; ++j)
{
for (int i = 1; i < sz-j; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i], &a[i - 1]);
}
}
}
}
优化
创建一个优化的标志,某一次如果没有交换,直接跳出循环,因为已经有序
void BubbleSort(int\* a, int sz)
{
for (int j = 0; j < sz; ++j)
{
int exchange = 0;
for (int i = 1; i < j; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i], &a[i - 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
3.1.3 分析冒泡复杂度
最好与最坏 | 复杂度 | 发生情况 |
---|---|---|
最坏的情况 | O(N2) | 逆序的一个数组 |
最好的情况 | O(N) | 顺序有序的数组 |
看上去好像和插入排序是一样的,那么到底谁更好呢?
顺序有序的话,一样好
接近有序的话,插入好
3.2.快速排序
注:本图片来源于https://commons.wikimedia.org
快排,如其名说明很快,且了解后发现其变形多
3.2.1 基本想法(Hoare)
Hoare的实现
- 单趟排序
选出一个key,一般是最左边的或者是最右边的,key放到正确的位置去,左边比key要小,右边的比key要大
这里看图来举个例子:
假设我们把这个数组里面的最左边的6选作为key,让right先走
其中right找小数,left找大数,目的其实是为了让左边的比key小右边的比key大
找完之后互相交换
继续找,继续交换
直到相遇,发现该值比key小,交换一下
这样的话就走完一趟了
- 这里有人会提出疑问
- 我们在之前为什么要求让right先走?
- 那如果我发现相遇的时候的值比key大怎么办?
其实我们让right先走这样设置的目的就是为了防止相遇的时候的值比key大的情况产生,就能保证始终比key小了
当走到最后一步时,如果先走的是left而不是right的话,left在找大的时候直接略过了3,使得相遇的时候的数字变成9,那最后一步要让比key小的数字和key互换就实现不了,所以最左边数字为key的时候,要让右边的right先走 。也正是右边先走才能使得两种相遇形式,即左遇右(右边先找到小数就停,左边没找到继续走使得相遇)和右遇左(右边找不到小数直接走使得和左所在位置相遇),这两种情况都会使right找到小的(仔细画图分析)
接下来,我们来完成多趟
在确定了之前的key之后我们把中间的现在放着key的左右两边拆开,就像分成左子树和右子树一样
对左一段和右一段分别重复上述操作,左边和右边就成了这样
继续如上操作
发现已经是有序的了
当最后只剩下一个的时候就是有序的
3.2.2 实现快速排序
对于单次排序,我们需要排除一种可能,也就是顺序数组可能会使得right一直找不到,使得right最后越界也没有完成任务
开始进入的时候while (left<right)
,并不能排除right越界,要在找大和找小的时候再判断一次
while (left<right&&a[right] > a[keyi])
while (left<right&&a[left]<a[keyi])
还要注意在找大和找小的时候=
不能忘记,如果忘记就会使得当left和right遇到同样大的时候不++和–,最后直接交换陷入死循环,所以找大别带上等于,找小也别带上等于
while (left<right&&a[right] >= a[keyi])
while (left < right&&a[left]<=a[keyi])
所以细节很多,最后正确的单趟应该是这样
void QuickSort(int\* a, int sz)
{
int left = 0, right = sz - 1;
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]);//最后交换一下key的值和相遇的值
}
接下来完成多趟,完整实现
首先为了要递归,那么函数的参数肯定还用sz是不合适的,那么改成start和end,同时增加一个递归终止条件
那么这样就是交换版的快速排序
void QuickSort(int\* a, int begin,int end)
{
if (begin >= end)
{
return;
}
int left = begin, right = end;
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]);
}
int meeti = left;
Swap(&a[keyi], &a[left]);//最后交换一下key的值和相遇的值
//左[begin,meeti-1] 右[meeti+1,end]
QuickSort(a, begin, meeti - 1);
QuickSort(a, meeti+1, end);
}
那么这个思想就是类似于二叉树的–“分治”排序
3.2.3 实现多种方法
这里我们把单趟排序抽离出来,将单趟返回参数来递归,那么
PartSort(a, begin, end)
其实是由多种写法的,接下来分析多种写法
首先这个本体是不变的
void QuickSort(int\* a, int begin,int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort(a, begin, end);//传key值
//左[begin,meeti-1] 右[meeti+1,end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi+1, end);
}
3.2.3.1 Hoare法(左右指针法)
这里我们称之前所讲的方法为Hoare法,或许命名可能是最早发现这个方法的人
int PartSort1(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]);//最后交换一下key的值和相遇的值
return left;
}
试试看其他几种方法实现单趟排序
3.2.3.2 挖坑法
- 还是取出left的值用key保存,比如说下图的6
- 这里不同的点在于当前left所指向的变成了一个坑
- 同样的还是右边的right开始走,找小,找到小数之后这次不一样了,不是交换,而是把right的值放到坑里面,于是形成了新的坑
- 再然后left找大,填到坑里面,再产生新坑
- 最后会相遇,然后放进去key就好了
int PartSort2(int\* a, int left, int right)
{
int key = a[left];
while (left < right)
{//找小
while (left < right && a[right] >= key)
{
--right;
}
//放到左边的坑中,右边形成新的坑
a[left] = a[right];
//找大
while (left < right && a[left] <= key)
{
++left;
}
//放到右边的坑中,左边形成新的坑
a[right] = a[left];
}
a[left] = key;
return left;
}
那么这就是挖坑法
3.2.3.3 前后指针法
- 先是有两个指针,分别是cur和prev,开始的时候分别在一前一后
2. 然后cur去找比key小的值,找到以后++prev,再交换prev和cur位置的值
- 前两次的交换之后没什么变化,因为
cur==prev
,所以等会写代码的时候可以排除这个可能性
- 第三次之后看到了变化,其实观察这个操作的意义是在把大的往后放,小的往前放
- 直到cur走出数组尾之后停止
- 最后把key位置和prev位置互相交换
这样的单趟的目的是什么呢,就是使得prev左边的数都比key小,右边的数都比key大
int PartSort3(int\* a, int left, int right)
{
int prev = left;
int cur = left+1;
int keyi = left;
while (cur <= right)
{
if (a[cur] < a[keyi]&&++prev!=cur)
{
Swap(&a[left], &a[right]);
}
++cur;//不进或者交换之后都要往后走
}
Swap(&a[keyi], &a[prev]);
return prev;//返回分隔的值
}
3.2.4 时间复杂度测试
接下来用同样的方法,随机生成数组感受一下快排的速度
debug版在优化递归的时候不太好,性能不会太明显,建议用release来测性能
debug版 100000
release版 1000000
4.4.1 理想时间复杂度计算
最好的情况是:
理想的快排应该是每次的key恰巧是中位数,然后相当于二分法一样的走下去
时间复杂度是
O
(
l
o
g
2
N
∗
N
)
O(log_2^N*N)
O(log2N∗N)
最坏的情况是
有序的数组,则此时每个排序都取最左边为key
这样的时间复杂度是
O
(
N
2
)
O(N^2)
O(N2)
因此这样的话来看,我们之前写的快排太不稳定,这样不是一个合格排序,我们要对这个快排优化,让它对所有的情况都好用
于是我们可以采取
- 三数取中法(通过采取改进选key的方法来优化)
- 小区间排序优化
3.2.5 优化快排
3.2.5.1三数取中法(通过采取改进选key的方法来优化)
既然问题出在选key,那么我们使得选恰好中间的数能够最优时间复杂度,于是我们可以每次找出头尾和中间那个三个数字中的中间数,并返回给key,使key的选取变得优化
int GetMidIndex(int\* a, int left, int right)
{//找出头尾和中间那个三个数字中的中间数
int mid = (left + right) >> 1;
if (a[left] < a[mid]){
if(a[mid]<a[right]){
return mid;
}else if(a[left]>a[right]){
return left;
}else{
return right;
}
} else{
if (a[mid] > a[right]){
return mid;
}else if (a[left] < a[right]){
return left;
}else {
return right;
}
}
}
void QuickSort(int\* a, int begin,int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort1(a, begin, end);
//左[begin,meeti-1] 右[meeti+1,end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi+1, end);
}
3.2.5.1.1 三数取中法+Hoare
int PartSort1(int\* a, int left, int right)
{
int midIndex = GetMidIndex(a, left, right);
Swap(&a[left], &a[midIndex]);//到这步left里的值将会使头中尾的中间值
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]);//最后交换一下key的值和相遇的值
return left;
}
3.2.5.1.2 三数取中法+前后指针
int PartSort3(int\* a, int left, int right)
{
int midIndex = GetMidIndex(a, left, right);
Swap(&a[left], &a[midIndex]);//到这步left里的值将会使头中尾的中间值
int prev = left;
int cur = left+1;
int keyi = left;
while (cur <= right)
{
if (a[cur] < a[keyi]&&++prev!=cur)
{
Swap(&a[left], &a[right]);
}
++cur;//不进或者交换之后都要往后走
}
Swap(&a[keyi], &a[prev]);
return prev;//返回分隔的值
}
下图是对100000个数量的有序数组排序的比较,可以看出三数取中法有 效解决了左右指针的缺陷, 甚至当遇到顺序数组的时候效率反而最高
3.2.5.2 小区间排序优化
也就是递归型的快排,是从头至尾一直是递归的,不断左右分段,像二叉树,不过即使到了最后几个数没有顺序,都要一直递归,于是想法是最后分割到数据到10个的时候用插排或选择解决,毕竟再用希尔和堆排序还要建堆和产生gap,那么又因为之前测过的插排优于选排,所以最后一个小区间里面就使用插排
那么我们改变这个本体
void QuickSort(int\* a, int begin,int end) { if (begin >= end) { return; } int keyi = PartSort(a, begin, end);//传key值 //左[begin,meeti-1] 右[meeti+1,end] QuickSort(a, begin, keyi - 1); QuickSort(a, keyi+1, end); }
void QuickSort(int\* a, int begin, int end)
{
if (begin >= end)
{
return;
}
//1.如果这个区间是数据多,继续选key单趟,分割子区间
//2.如果子区间数据量太小,再去递归不合适,不划算
if (end - begin > 100)
{
int keyi = PartSort3(a, begin, end);
//左[begin,meeti-1] 右[meeti+1,end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
else
{
InsertSort(a + begin, end - begin + 1);
}
}
当然如果用编译器其实优化效果不是很明显,因为 编译器已经就递归做到了一些优化,但不像三数取中,优化来的好
注意:优化可以通过改变end-begin
的值,具体多少要还是看数据的数量
3.2.5.3 非递归快排
递归,现代编译器优化的比较好,性能问题不是很大,最大的问题在于,递归的深度太深,程序本身没问题,但是栈空间不够,导致栈溢出,只能改非递归stack overflow警告😁
改成非递归有两种方式:
- 直接改循环,比如斐波那契数列
- 树遍历非递归和快排非递归,只能用栈模拟递归过程
接下来我们用栈来模拟递归过程
其实分治递归的思想和从栈中放入数据再取出来一样,不是递归,但是和递归很像
我们把单趟排序的区间给入栈,然后依次取栈里面的区间出来单趟排序,再循环需要处理的子区间入栈,直到栈为空。
void QuickSortNonR(int\* a, int begin, int end)
{
Stack st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int left, right;
right = StackTop(&st);
StackPop(&st);
left = StackTop(&st);
StackPop(&st);
int keyi = PartSort1(a, left, right);
if (left < keyi - 1)
{
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
if (keyi + 1 < right)
{
StackPush(&st, keyi + 1);
StackPush(&st, right);
}
}
StackDestroy(&st);
}
注:非递归方法中栈的实现就放在GitHub中,码上来显得文章冗长,所以不写上来了,那么后期也会就栈这个数据结构仔细展开
4. 归并排序
4.1 基本想法
先把这个区间分成两半,然后均分成两个数组进行排序,最后得出两个有序的子区间之后再归并在一起就成为了有序数组
怎么归并?
设置左右指针两个互相比一比,取小的尾插到下面的数据,然后往后走,直到一个区间结束,再把另外一个区间尾插到结束
但是要归并的前提是有序,如果我还是没有序怎么办?就继续拆拆拆
拆到一个的时候就是有序的,然后归并就可以了
那么也就是说这样一个区间经过这样的过程就是有序的了
4.2 实现归并排序
void \_MergeSort(int\* a, int left,int right,int \*tmp)
{
if (left >= right)
return;
int mid = (left + right) >> 1;
//[left,mid] [mid+1,right]
\_MergeSort(a, left, mid, tmp);
\_MergeSort(a, mid+1, right, tmp);
//两段有序子区间归并tmp,并拷贝回去
int begin1 = left,end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = left;
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++];
}
//归并完之后,拷回原数组
for (int j = left; j <= right; ++j)
{
a[j] = tmp[j];
}
}
void MergeSort(int\* a, int sz)
{
int\* tmp = (int\*)malloc(sizeof(int) \* sz);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
\_MergeSort(a, 0, sz - 1,tmp);
free(tmp);
tmp = NULL;
}
4.3 归并的复杂度
排序算法 | 时间复杂度 | 空间复杂度 |
---|---|---|
归并排序 | O(N*logN) | O(N) |
release版海量数据排序,所需时间如下
注:这里的快排是三数取中+Hoare
100000数据
1000000数据
4.4 非递归实现归并排序
类似的,我们知道递归会带给快排的问题,也会给归并排序带来,所以我们尝试用非递归方式实现归并排序
- 想法
要一半一半分开这个区间,放入tmp中,然后再取回来放到a中,使开始的gap=1,然后每次gap翻一倍,每次以gap为大小的区间是有序的,直到最后总长一半
- 注意事项
- 随着两两划分是有可能到最后一个只有一个数,
[i+gap,i+2*gap-1]
,也就是说第二个区间可能是不存在的
那修改一下,不存在就不要归了
2. 还有一种可能性是第二组区间有但是不完整
3. 还有一种可能性是第二组区间没有,第一组区间也不完整
把1和3归为一类去处理,直接不去归并了
要检查的是第二种可能,要修正一下
void MergeSortNonR(int\* a, int sz)
{
int\* tmp = (int\*)malloc(sizeof(int) \* sz);
if (tmp == NULL){
printf("malloc fail\n");
exit(-1);
}
int left;
int right;
int gap = 1;
while (gap < sz){
for (int i = 0; i < sz; i += (2 \* gap)){
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 \* gap - 1;
//第二个区间不存在就不需要归并了,结束循环
if (begin2>=sz){
break;
}
//第二个小区间存在,但是第二个小区间不够gap个,结束位置越界,需要修正
if (end2 >= sz){
end2 = sz - 1;
}
//[i,i+gap-1] [i+gap,i+2\*gap-1]
left = begin1;
right = end2;
int index = begin1;
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++];
}
//归并完之后,拷回原数组
for (int j = left; j <=right; ++j)
{
a[j] = tmp[j];
}
}
gap \*= 2;
}
free(tmp);
tmp = NULL;
}
4.5 补充
我们这里要提到外排序和内排序,如何对大数据文件进行排序
归并排序天然支持外排序
- 举个栗子:
假如我们有10亿个整数在文件中,需要排序,计算后大小约等于4个G,不能直接放在内存中运算假如我们的内存是0.5G
- 我们可以对文件每次读1/8,也就是512M到内存中去排序(这里的排序不能用归并,因为归并空间复杂度O(N),可以用快排),然后写到文件里,再继续重复这个过程,最后将这几个有序小文件分别读数进行归并,两两归就成为4个,再两两归,两两归,最后合成一个
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fa7WQ30Y-1639028292067)(…/…/…/…/AppData/Roaming/Typora/typora-user-images/image-20211209085241562.png)]
- 第二种可能性是第一个文件和第二个文件归并,然后第二个和第三个文件归并大文件,最后使得
5. 计数排序
计数排序有成为鸽巢原理,是对哈希直接定址法的变形应用
之前的排序都是比较大小来进行排序的,计数排序采取一种新思路,他的思想是
- 统计相同元素出现个数
- 根据统计结果将序列回收到原来的序列中
5.1 基本想法
如何统计每个数的次数
这里我们有一个A[i]数组,A[i]的值就是对Count数组该位置++
for(int i-0;i<sz;++i)
{
++Count[A[i]];
}
然后根据count数组按照count数组的数据的下标和值是多少,产生相应值数量的下标区间
这个叫做绝对映射
注:
加入我给了如上几个数字,有一半多都没有映射,那我不可能去浪费0-9存空
所以我为什么不把下标为0的保存10然后下标为5的保存15,跳过1-9呢?
这就叫相对映射
那么因为绝对映射还是存在浪费的,所以在这里的计数排序我们采用相对映射
5.2 实现计数排序
void CountSort(int\* a, int sz)
{
int max = a[0], min = a[0];
for (int i = 0; i < sz; ++i)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
min = a[i];
}
int range = max - min + 1;
int\* count = (int \*)malloc(sizeof(int) \* range);
memset(count, 0, sizeof(int) \* range);
for (int i = 0; i < sz; i++)
{
count[a[i] - min]++;
}
int i = 0;
for (int j = 0; j < range; ++j)
{
while (count[j]--)
{
a[i++] = j+min;
}
}
}
5.3 时间复杂度分析
计数排序的复杂度
排序名 | 时间复杂度 | 空间复杂度 |
---|---|---|
计数排序 | O(N+range) | O(range) |
所以说其实是和range有关的,最好是这组数据中,数据的范围都比较集中,很适合用计数排序,这个排序还是很优秀的,也有局限性,该排序只适合整数,浮点数字符串都不行,数据不集中也不行
6. 分析八大排序
6.1 八大排序分析
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上C C++开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
39e5e8ef8a5ae2.png)
加入我给了如上几个数字,有一半多都没有映射,那我不可能去浪费0-9存空
所以我为什么不把下标为0的保存10然后下标为5的保存15,跳过1-9呢?
这就叫相对映射
那么因为绝对映射还是存在浪费的,所以在这里的计数排序我们采用相对映射
5.2 实现计数排序
void CountSort(int\* a, int sz)
{
int max = a[0], min = a[0];
for (int i = 0; i < sz; ++i)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
min = a[i];
}
int range = max - min + 1;
int\* count = (int \*)malloc(sizeof(int) \* range);
memset(count, 0, sizeof(int) \* range);
for (int i = 0; i < sz; i++)
{
count[a[i] - min]++;
}
int i = 0;
for (int j = 0; j < range; ++j)
{
while (count[j]--)
{
a[i++] = j+min;
}
}
}
5.3 时间复杂度分析
计数排序的复杂度
排序名 | 时间复杂度 | 空间复杂度 |
---|---|---|
计数排序 | O(N+range) | O(range) |
所以说其实是和range有关的,最好是这组数据中,数据的范围都比较集中,很适合用计数排序,这个排序还是很优秀的,也有局限性,该排序只适合整数,浮点数字符串都不行,数据不集中也不行
6. 分析八大排序
6.1 八大排序分析
[外链图片转存中…(img-kgWNHfZg-1715728042332)]
[外链图片转存中…(img-o7Vnb9Pb-1715728042332)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上C C++开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新