在我们学过数据结构之后,我们知道冒泡排序和选择排序的时间复杂度是非常高的都是:〇(N^2)。这样在处理大数据的排序时就会很慢。
排序分类:
大家肯定非常熟悉这个代码了就直接上代码:
//冒泡排序
void BubbleSort(int* arr, int len)
{
assert(arr);
for (int i = 0; i < len - 1; i++)
{
int flag = 1;
for (int j = 0; j < len - i - 1; j++)
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = 0;
}
}
if (flag == 1)
break;
}
}
1.时间复杂度:
2.空间复杂度:
这个排序我们也不陌生,这里提两种办法,一种是一次选出一个数据,另一种是一次选出最大和最小两个值分别放到序列的头和尾部。
1.一次选一个:
//选择排序 - 一次选一个
void SelectSort(int* arr, int len)
{
assert(arr);
for (int i = 0; i < len - 1; i++)
{
for (int j = i + 1; j < len; j++)
{
if (arr[i] < arr[j])
{
Swap(&arr[i], &arr[j]);
}
}
}
}
2.一次选两个
//选择排序 - 一次选两个
void SelectSort_(int* arr, int len)
{
int left = 0, right = len - 1;
while (left <= right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i] > arr[maxi])
{
maxi = i;
}
}
Swap(&arr[left], &arr[mini]);
//如果left和maxi重叠
if (left == maxi)
{
maxi = mini;
}
Swap(&arr[right], &arr[maxi]);
left++;
right--;
}
}
值得注意的是:
3.时间复杂度:
4.空间复杂度:
1.基本思想:
实际中我们玩扑克牌时,就用了插入排序的思想:
2.实现方法:
//插入排序
void InsertSort(int* arr, int len)
{
assert(arr);
//单趟排序:[0,end]有序 end + 1 位置的值,插入进去,保持它依旧有序
for (int i = 0; i < len - 1; i++)
{
int end = i;
int tmp = arr[end + 1];
//升序
while (end >= 0)
{
if (tmp < arr[end])
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end + 1] = tmp;
}
}
循环条件是i < len - 1是因为当end == len时arr[end + 1]就会越界!
3.时间复杂度:
综上所述:
插入排序的 时间复杂度:在〇(N) ~ 〇(N^2)之间
4.空间复杂度:
插入排序和冒泡排序比较:
希尔排序是对插入排序的优化,分为两个部分:
1.预排序 – 目的(使序列接近排序)
2.对gap的讨论
3.预排序可以排多组 - gap处理
//希尔排序 - 缩小增量排序
void ShellSort(int* arr, int len)
{
assert(arr);
//预排序
//两层循环 -- 多组并排
//1.gap > 1 预排序
//2.gap == 1 直接插入排序
int gap = len;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < len - gap; i++)
{
int end = i;
int tmp = arr[end + gap];
//升序
while (end >= 0)
{
if (tmp < arr[end])
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
循环条件是i < len - gap是因为当end == len时arr[end + gap]就会越界!
4.希尔排序的缺陷:
5.时间复杂度:
希尔排序的时间复杂度很难计算,这里只提供非常粗略的估算
先来看预排序的时间复杂度:
当gap很大时 (相对于len还是小的):
内部while循环可以忽略,因为gap跳的很快,i 相比 gap 很小,只用计算外部for循环即可,取大头近似取,时间复杂度:〇(N)。
当gap很小时(gap已经减到了很小):
此时的序列,较大的数已经靠近数组前面,较小的数已经靠近数组后面,也就是说这个数组接近有序,这时候内部while循环也可以忽略,因为几乎不执行,或者执行很少,可以看作只有for循环,时间复杂度:〇(N)。
我们来去一个平均值:
(gap很大的时间复杂度 + gap很小的时间复杂度) / 2 = 平均时间复杂度 = 〇(N)
外层循环的时间复杂度
就很好算了,忽略 + 1,gap一直除以 3 ,直到gap为1为之
那么执行的次数就是logN次,外层循环的时间复杂度:〇(logN)
综合来看希尔排序的时间复杂度
时间复杂度:〇(N * logN)
当然上述计算存在不科学之处,下面通过查阅资料得到详情时间复杂度解析:
《数据结构(C语言版)》—— 严蔚敏
在之前的数据结构的学习中,我们已经学习过堆排序,堆排序是一个非常出色的算法,时间复杂度〇(N*logN),空间复杂度〇(1)。
不熟悉的小伙伴点这里来复习一下我之前写的博文:
堆排序复习: 传送门
快速排序的历史:
它诞生于1960年,首次被英国计算机科学家霍尔 (Sir Charles Antony Richard Hoare) 发现。其发现紧随Shell排序之后,成为又一个突破的算法,但是因为其独树一帜的复杂度分析方式,让其成为一个高级排序算法,并且不断影响后世,成为了众多高级语言库自带的算法。
下面提供三种实现快排的方法:
这个方法是发明快排的大佬(Hoare)提供的,假设一组数最左边的数为基准数key(关键字)。用左右指针来遍历这个数组,在介绍这个方法之前,我们先来做个铺垫。
有序序列:
1.有序的特性:
2.先走一趟 - 排升序
我们先将一个关键字key放在正确位置:
要求:
方法:
示意图:
右指针先走找比6小的数,最右边第一个就是比3小的数,左再边走去找比6大的数。
代码如下:
//hoare - 单趟排序
int PartSort1(int* arr, int left, int right)
{
//int midi = GetMidIndex(arr, left, right);
//Swap(&arr[midi], &arr[left]);
int keyi = left;
while (left < right)
{
//右边先走 - 找小
while (left < right && arr[right] >= arr[keyi])
{
right--;
}
//左边先走 - 找大
while (left < right && arr[left] <= arr[keyi])
{
left++;
}
Swap(&arr[left], &arr[right]);
}
Swap(&arr[keyi], &arr[left]);
//返回相遇的位置 - (right == left)
return left;
}
hoare - 单趟排序极端情景思维:
3.常见疑问:(以动图为例)
最后两个指针相遇的时候一定是比key小的吗?答案是一定的。
左边做key,如何保证相遇位置的值比key小呢,右边先走保证的。
分两种情况来说:
所以哪边指针先走,最后两个指针停下来相遇的位置指向的值,一定是先走的那个指针要找的值。
4.Hoare法小结:
根据序列的有序这一个特性:
举个栗子:
如果要排降序:(序列前面数值大,后面数值小)
那么R要找比key大的数,L要找比key小的数,两数一交换就将大的数放在了前面,小的数放在了后面。
如果R先走: 最后两个指针相遇的共同指向的就是比key大的数,因为相遇点要和key交换,又因为是降序比key大的数要放在前面,所以这个key要选在左边。
如果L先走: 最后两个指针相遇的共同指向的就是比key小的数,因为相遇点要和key交换,又因为是降序比key小的数要放在后面,所以这个key要选在右边。
选key的位置: 总是放在先走那一边的相反的一边,比如先走左指针,key放在右边,先走右指针,key放在左边。
如果要排升序:(序列前面数值小,后面数值大)
大家可以类推,和上面思路是一样的……
相较于Hoare法而言,挖坑法与它的思路几乎一样,但是理解起来会比Hoare法更容易。
//挖坑法 - 单趟排序
int PartSort2(int* arr, int left, int right)
{
//int midi = GetMidIndex(arr, left, right);
//Swap(&arr[midi], &arr[left]);
int key = arr[left];
//坑位
int pit = left;
while (left < right)
{
//右边先走 - 找小
while (left < right && arr[right] >= key)
{
right--;
}
//填坑 - 形成新的坑
arr[pit] = arr[right];
pit = right;
//左边先走 - 找大
while (left < right && arr[left] <= key)
{
left++;
}
//填坑 - 形成新的坑
arr[pit] = arr[left];
pit = left;
}
arr[pit] = key;
return pit;
}
3.坑位的选择:
4.挖坑法和Hoare法的区别:
1.目的
2.方法:
//前后指针法 - 最简洁的写法
int PartSort3(int* arr, int left, int right)
{
int midi = GetMidIndex(arr, left, right);
Swap(&arr[midi], &arr[left]);
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && arr[++prev] != arr[cur])
{
Swap(&arr[prev], &arr[cur]);
}
cur++;
}
Swap(&arr[prev], &arr[keyi]);
return prev;
}
3.条件if (arr[cur] < arr[keyi] && arr[++prev] != arr[cur])的意义
如何将序列中每个数都放在“正确的位置”呢?
1.递归快排完整过程:
//快速排序 - 没加小区间优化
void QuickSort1(int* arr, int begin, int end)
{
//当子区间相等 - 只有一个值
//或者 不存在子区间 那么就是递归结束的子问题
if (begin >= end)
{
return;
}
//int keyi = PartSort1(arr, begin, end);
//int keyi = PartSort2(arr, begin, end);
int keyi = PartSort3(arr, begin, end);
//单趟排序之后, 保证keyi的左边比keyi小,keyi的右边比keyi大
//[begin, keyi - 1] keyi [keyi + 1, end]
QuickSort1(arr, begin, keyi - 1);
QuickSort1(arr, keyi + 1, end);
}
当子区间相等 - 只有一个值,或者 不存在子区间 那么就是递归结束的子问题。
这个过程和遍历二叉树的前序遍历是非常相似的。
2.递归执行的条件
快速排序的时间复杂度是怎么计算的呢?
1.最好的情况:
(1)时间复杂度:
(2)空间复杂度:
2.最坏的情况:
(1)时间复杂度:
(2)空间复杂度:
注意:
因为排序的过程是在内存中进行的,又因为函数栈帧是在栈区创建的,而栈的大小只有8M左右,当要排的数据过于庞大时,就要创建非常多的函数栈帧,就有栈溢出的风险(爆栈)!!
3.补充:
之前最好的情况中,为什么key每次选在中间是最好的?
综上所述:
快速排序的 时间复杂度:在〇(N*logN) ~ 〇(N^2)之间
在了解完快排的时间复杂度的范围和缺陷之后,我们要想办法将时间复杂度为:〇(N^2)的情况避免掉
脑袋里走一走PartSort将key放在正确位置的过程,仔细想一想是不是这么回事!!
既然找到了病根,我们就要对症下药!!
具体代码如下:
//在数组里面选中间值的下标 - 三数取中
int GetMidIndex(int* arr, int left, int right)
{
//int mid = (left + right) / 2;
int mid = left + ((right - left) >> 1); //防溢出
// left mid right
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
//right mid left
else // arr[left] > arr[mid]
{
if (arr[mid] > arr[right])
{
return mid;
}
else if(arr[left] < arr[right])
{
return left;
}
else
{
return right;
}
}
}
小区间优化:
效果不佳:
//快速排序 - 加了小区间优化
void QuickSort2(int* arr, int begin, int end)
{
//当子区间相等 - 只有一个值
//或者 不存在子区间 那么就是递归结束的子问题
if (begin >= end)
{
return;
}
//对小区间排序,减少递归调用 - 小区间排序改用插入排序
//小区间直接插入排序控制有序 - 闭区间要 + 1
if (end - begin + 1 <= 13)
{
InsertSort(arr + begin, end - begin + 1);
}
else
{
//int keyi = PartSort1(arr, begin, end);
//int keyi = PartSort2(arr, begin, end);
int keyi = PartSort2(arr, begin, end);
//单趟排序之后, 保证keyi的左边比keyi小,keyi的右边比keyi大
//[begin, keyi - 1] keyi [keyi + 1, end]
QuickSort2(arr, begin, keyi - 1);
QuickSort2(arr, keyi + 1, end);
}
}
栈 还不熟悉的小伙伴点这里来复习一下我之前写的博文:
栈复习 传送门
在递归版本的快排中,当要排非常多的数时,就很有可能会出现爆栈的风险,这时候我们就要将递归改为非递归,可以有效的避免爆栈这个问题。
虽然我们是用栈来代替递归的,但是非递归的思路还是和递归类似的,递归是划分区间,不断地将左区间分割,结束后再去分割右区间,我们用栈也能很好的模拟这个过程。
1.具体过程:
//快速排序 - 非递归版本(用栈改)
void QuickSort3(int* arr, int begin, int end)
{
ST st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
//单趟排
int keyi = PartSort3(arr, left, right);
//[left, keyi - 1] keyi [keyi + i, 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);
}
核心思想都是将区间分割,将子区间排成有序的,上述代码是想将分割的右区间选key,再选左区间,和二叉树遍历中的根 - 右子树 - 左子树这个过程很相似。
2.快排非递归的写法 和 层序遍历的区别是:
3.快排的非递归还可以用 队列来实现:
1.归并排序基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序核心步骤:
2.问题与解决办法:
具体代码实现:
void _MergeSort(int* arr, int begin, int end, int* tmp)
{
if (begin >= end)
{
return;
}
int mid = begin + ((end - begin) >> 1);
// [begin, mid][mid + 1, end]
//左区间
_MergeSort(arr, begin, mid, tmp);
//右区间
_MergeSort(arr, mid + 1, end, tmp);
//归并 [begin, mid][mid + 1, end](左右半边都有序)
//printf("归并[%d, %d][%d, %d]\n", begin, mid, mid + 1, end);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int index = begin;
//归并
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
//剩余的接上
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
memcpy(arr + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}
//归并排序 - 递归写法
void MergeSort(int* arr, int len)
{
int* tmp = (int*)malloc(len * sizeof(int));
if (tmp == NULL)
{
printf("%s\n", strerror(errno));
}
_MergeSort(arr, 0, len - 1, tmp);
free(tmp);
}
printf(“归并[%d, %d][%d, %d]\n”, begin, mid, mid + 1, end);可以将每次划分出的区间打印出来
这里的区间划分要考虑到方方面面不然稍不留神就会有爆栈的风险!
我们想这里能像快排那里用栈或者队列来代替递归吗?
那么不能的原因是什么?
这里我们采用循环的方式来实现:
1.具体过程:
//归并排序 - 非递归写法(用循环改)
void MergeSortNoR(int* arr, int len)
{
int* tmp = (int*)malloc(sizeof(int) * len);
assert(tmp);
int gap = 1;
while (gap < len)
{
//分组归并, 间距为gap的是一组, 两两归并
for (int i = 0; i < len; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//end1 越界, 修正
if (end1 >= len)
{
end1 = len - 1;
}
//begin2 越界, 第二个区间不存在, while就不会进去
if (begin2 >= len)
{
begin2 = len;
end2 = len - 1;
}
//begin2 - OK, end2 - 越界, 修正一下 end2 即可
if (begin2 < len && end2 >= len)
{
end2 = len - 1;
}
//printf("归并[%d, %d][%d, %d] -- gap = %d\n", begin1, end1, begin2, end2, gap);
int index = i;
//归并
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
//剩余的接上
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
}
memcpy(arr, tmp, len * sizeof(int));
//PrintArray(arr, len);
gap *= 2;
}
free(tmp);
}
2.边界问题
1.递归实现:
(1)时间复杂度:
(2)空间复杂度:
2.非递归实现:
(1)时间复杂度:
(2)空间复杂度:
1.排序的划分
归并排序既可以做 内排序, 又可以做 外排序。
2.归并实现外排序的方法:
1.核心思想:
2.缺陷:
//计数排序
void CountSort(int* arr, int len)
{
int min = arr[0], max = arr[0];
for (int i = 1; i < len; i++)
{
if (arr[i] < min)
{
min = arr[i];
}
if (arr[i] > max)
{
max = arr[i];
}
}
int range = max - min + 1;
//开范围大的数组
int* countA = (int*)malloc(sizeof(int) * range);
assert(countA);
memset(countA, 0, sizeof(int) * range);
//计数 - 遍历一遍数组
for (int i = 0; i < len; i++)
{
countA[arr[i] - min]++;
}
//排序 - 遍历开辟的数组
int j = 0;
for (int i = 0; i < range; i++)
{
while (countA[i]--)
{
arr[j++] = i + min;
}
}
}
1.时间复杂度:
综上所述:
计数排序的时间复杂度为:
2.空间复杂度:
随机给大量个随机数,将它们排序,用clock函数计算出该排序算法排序用的时长(毫秒):
具体代码:
// 测试排序的性能对比
void TestOP()
{
srand((unsigned int)time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
int* a8 = (int*)malloc(sizeof(int) * N);
assert(a1 && a2 && a3 && a4 && a5 && a6 && a7 && a8);
for (int i = 0; i < N; i++)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin1 = clock();
BubbleSort(a1, N);
int end1 = clock();
int begin2 = clock();
SelectSort(a2, N);
int end2 = clock();
int begin3 = clock();
InsertSort(a3, N);
int end3 = clock();
int begin4 = clock();
ShellSort(a4, N);
int end4 = clock();
int begin5 = clock();
HeapSort(a5, N);
int end5 = clock();
int begin6 = clock();
QuickSort2(a6, 0, N - 1);
int end6 = clock();
int begin7 = clock();
MergeSort(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
printf("BubbleSort:%d\n", end1 - begin1);
printf("SelectSort:%d\n", end2 - begin2);
printf("InsertSort:%d\n", end3 - begin3);
printf("ShellSort:%d\n", end4 - begin4);
printf("HeapSort:%d\n", end5 - begin5);
printf("QuickSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
各排序时长结果:
从图中我们可以清楚地看到各个排序算法的优劣性。
补充:
1.概念:
2.各大排序的稳定性:
注意:
选择排序不是稳定的:
如上图排升序,当找到最小值1时和3交换,1是稳定的而3却不是稳定的。
这一点在很多资料和书籍中讲的都是错误的。