文章目录
- 前言
- 一、常见排序算法的实现
- 1.1冒泡排序
- 1.2 快速排序
- 1.2.1递归版本
- 1.2.1.1hoare版本
- 1.2.1.2挖坑法
- 1.2.1.3前后指针版本
- 1.2.2非递归版本
- 1.2.3快速排序的特性总结
- 1.3归并排序
- 1.3.1递归版本
- 1.3.2非递归版本
- 1.3.3归并排序的特性总结
- 1.4计数排序
- 1.4.1计数排序实现
- 1.4.2计数排序的特性总结:
- 1.53.排序算法复杂度及稳定性分析
- 总结
前言
本篇文章继续上一篇来探讨排序当中的冒泡排序,快排、归并排序和计数排序,关于上一篇的直接插入排序、希尔排序、选择排和堆排序我们就不探讨了。大家可以参考这篇文章:【数据结构从0到1】第五篇:排序(上)
一、常见排序算法的实现
1.1冒泡排序
首先我们用一张动图来展示冒泡排序:
相信大家看完动图后都能够明白冒泡排序的原理了吧,其实就是前一个数和后一个数相比较,排升序的话,就把大的数往后面移,排降序就把小的数往后面移,那么我们就来实现一下:
首先我们先建立一个和上面相同的数组:
int a[] = { 24, 6, 4, 1, 3, 34, 28, 5, 9, 20, 18 };
第一步:单趟排序,将最大的数移到最后:
我们比较大小有两种方法:
第一种,下标从0开始和后面的数比较:
第二种:下标从1开始和前面的数比较:
我们就选择第二种方法比较
代码实现:
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
第二步:多趟排序,每次将最大的数都往后移,排成升序:
代码实现:
void BubbleSort(int* a, int n)
{
assert(a);
/*方法一:*/
/*控制多趟,排成有序*/
for (int j = 0; j < n; j++)
{
//方法二:
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
}
}
我们来通过打印数组来看一下排序是否成功:
此时我们发现没有错误。但是这样通过n - j 的方式控制单趟排序可能有些人认为有点麻烦,那么不妨来看另一种方法。此时我们我们可以同过一个end变量来控制边界:
时间复杂度 O(N^2)
void BubbleSort(int* a, int n)
{
assert(a);
// 方法二:
int end = n;
while (end)
{
for (int i = 1; i < end; i++)
{
// 控制单趟找出最大值
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
}
}
end--;
}
}
此时这样我们是不是更方便我们理解!!
那么我们来算一下冒泡排序的时间复杂度,(第一趟排序比较次数)+(第二趟排序比较次数)+…(最后一趟比较次数)= (n - 1)+ (n - 2)…+ 1,根据等差数列求和就是一个O(N^2)的时间复杂度了,当排的数据本身就是有序的时候,此时这个代码实现排序复杂度还是不变,那么我们可不可我一为优化一下这个代码呢?答案是可以的。
思路:如果第一趟排序我们发现是升序的话,我们就不进行后面的排序了,数组都有序了,我们就直接打印就是。首先定义以exchange变量,初始化为0
//定义exchange变量,用来检查这些数据是否有序
int exchange = 0;
for (int i = 1; i < end; i++)
{
if (a[i - 1] > a[i])
{
exchange = 1;
Swap(&a[i - 1], &a[i]);
}
}
然后根据exchange是否被改变判断数组是否是升序:
//假如本身就是有序的话,上面排序的时间复杂度还是O(N^2)
//我们现在优化一下数组本身就是有序或则数组接近有序的的情况。
void BubbleSort(int* a, int n)
{
assert(a);
int end = n;
//控制多趟排序
while (end)
{
//定义exchange变量,用来检查这些数据是否有序
int exchange = 0;
for (int i = 1; i < end; i++)
{
if (a[i - 1] > a[i])
{
exchange = 1;
Swap(&a[i - 1], &a[i]);
}
}
//单趟排序完成后,判断整个数组是否有序
if (exchange == 0)
{
break;
}
end--;
}
}
这样我们再来算一下时间复杂度,当是乱序的情况下很显然时间复杂度是O(N^2),但是当是有序的情况下时间复杂度就是O(N)了,我们也就达到了优化的目的。
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
此时我们来横线对比一下直接插入排序、直接选择排序和冒泡排序,最差的排序肯定大家都能想到,肯定是直接选择排序,因为不管是有序还是无序,时间复杂度都是O(N^2),但是直接插入排序和冒泡排序在有序的情况下时间复杂度是O(N),那么我们现在要在它们两个当中选出一个最好的,那么我们选哪个呢?
相信大家很难判断出来,那么我们来举个例子吧,假设现在有一个数组
int a[] = {1, 2, 3, 5, 4 };
我们用直接插入排序算比较次数就是:5次,2和1比较一次,3和2比较…,最后4和5比较一次4和3比较一次。
我们用冒泡排序来算比较次数 4 + 3次就是7次。相信大家都能算出来。
我们将数据放大为n,最后两个无序,前面的都有序,直接插入排序的比较次数就是n次,而冒泡排序的比较次数就是(n-1)+(n-2)次。那么我们看到是不是直接插入排序相比较于冒泡来讲是会更优一点的。冒泡的实现相对比较简单,我们接下来的快排可能相对比较难。
1.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:.任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
这个是什么意思呢?我们用动图演示:
哎,这是怎么变换的呢?我们一步一步来看。
首先我们还是先建一个和上面动图相同的数组
int a[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
快排的单趟排序就是选出一个数作为key值,然后再遍历数组将小于key值的数放在前面大于key值的数放在后面,然后我们来进行单趟排序,单趟排序有三种方法,我们先将最初始的方法。
1.2.1递归版本
1.2.1.1hoare版本
假设我们现在最左边的数作为key值,如图:
第一步:右边先走,去找比key小的数,找到就停下:
第二步:左边再走,去找比key大的数,找到就停下:
第三步:交换这两个数:
第四步:重复上面操作,直到left大于等于right停下。
第五步:交换key的值和 left的值或者key的值和right值
代码实现:
//1. hoare版本
int Partion1(int* a, int left, int right)
{
assert(a);
int keyi = left;
while (left < right)
{
//右边找小
while (a[right] >= a[keyi])
{
right--;
}
//左边找大
while (a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
//最后交换keyi位置的值和相遇位置的值。
Swap(&a[keyi], &a[left]);
return left;
}
同理:上面的单趟排选最右边的数作为key也是一样的,那么大家可能会有这样一个疑问?我们为什么选最左边作为key的时候,先走的右边,而中间相遇的值为什么又比key小呢?这其实是一个问题,我们先画图分析。
假设我们现在左边作为key,而左边先走:
此时我们就发现9被交换到前面去了,所以在最左边作key的时候,我们就要先走right,因为不管是当数组在交换n次之后,最后一次先走的还是right,如果找到小了,就停下,等left和它相遇,此时交换就是比key小,而如果right没有找到,最后也要和left相遇,此时相遇left指向的还是比key小的数。
我们在看单趟排序的代码大家可能很快就发现了错误,因为可能left和right不会相遇
比如说数组元素是一样的时候或则数组是升序的时候:
那么我们在上面修改一下代码:
//1. hoare版本
int Partion1(int* a, int left, int right)
{
assert(a);
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]);
}
//最后交换keyi位置的值和相遇位置的值。
Swap(&a[keyi], &a[left]);
return left;
}
此时我们单趟排序就完成了,此时相遇点左边的值都比key小,而右边的值都比key大。我们就做到将一个数排成有序了。但是我们要排的是多个数,此时我们是不是可以又去递归它的左区间和它的右区间,将每个区间的值都排好,那么我们整个数组是不是也就排好了。这就跟二叉树的遍历差不多,我们先打印,再递归左子树和右子树。
我们画图演示:
代码实现:
int Partion1(int* a, int left, int right)
{
assert(a);
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]);
}
//最后交换keyi位置的值和相遇位置的值。
Swap(&a[keyi], &a[left]);
return left;
}
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int keyi = Partion1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
我们先来测试一下代码是否能排成升序:
接下来为了更好理解我们就来画递归展开图:
相信大家大概又有了更深的印象了吧!那么我们来测试1000000个数来看一下:
void TestOP()
{
srand(time(0));
const int N = 1000000;
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);
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];
}
/*int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();*/
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
/*int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();*/
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
//printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
此时我们看到基本上希尔排序、堆排序和快排都差不多的。因为时间复杂度大概都是O(NlogN)
那么快排就完了吗?就这么简单?快排有什么缺陷没有?那可肯定是有的:我们刚刚排的数组,每次选可key的时候,都基本上是选的中位数,所以时间复杂度就是O(NlogN),那么我们此时选key不是选的中位数,那么排序的时间复杂度就会变成O(N^2)了。
怎么证明呢?
我们分析选key每次都选的是中位数或则接近于中位数:
如果此时我们的数组是有序的:
不光是时间复杂度变了,我们递归调用还肯能要栈溢出。我们可以测试一下
void TestOP()
{
srand(time(0));
const int N = 1000000;
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);
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];
}
/*int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();*/
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
/*int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();*/
int begin4 = clock();
HeapSort(a2, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a2, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
//printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
此时a2数组已经排成有序了,我们在用快排a2数组去排序程序就直接崩了。
那么怎么办呢?这样的快排还是快吗?我们可以采取3数取中的方法,具体就是在三个数中选出中间的那个数,有很多种选择方法,可以随机选key,例如:
但是这种方法就像把命运交给随机,我们要自己掌握命运,我们可以就选取最左边、中间和最右边的数,然后选出它们三个数当中中间的那个数,我们还是画图分析。
比如现在有一个数组:
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
此时我们就将中间的6交换到了最左边,此时右相当于二分了。接下来就和上面的步骤是一样的了,还是选key,让后left和right走。当它们相遇的时候就停下。再递归排左区间和右区间。
代码实现:
//三数取中,防止数组是有序递归调用次数太多而导致栈溢出
int GetMidIndex(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[left] > a[right])
{
return left;
}
else
{
return right;
}
}
//a[left] >= a[mid]
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
//1. hoare版本
int Partion1(int* a, int left, int right)
{
assert(a);
//取三个数中中间那个数和左边的数交换
int mini= GetMidIndex(a, left, right);
Swap(&a[left], &a[mini]);
//左边的值做key,右边先走
//右边的值做key,左边先走
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]);
}
//最后交换keyi位置的值和相遇位置的值。
Swap(&a[keyi], &a[left]);
return left;
}
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int keyi = Partion1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
我们测试一下
我们在看是否会栈溢出:
void TestOP()
{
srand(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);
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];
}
/*int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();*/
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
/*int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();*/
int begin4 = clock();
HeapSort(a2, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a2, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
//printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
很显然没有栈溢出,这个就很容易就解决了这个问题,而且一下从最坏的情况变为了做好的情况,相信大家现在理解起来并不难。
1.2.1.2挖坑法
挖坑法的意思就是将第一个数据存放在临时变量key中,形成一个坑位,也可以不选第一个数据,我们就先选第一个数据作为坑位,然后也是先走right去找小,找到了就扔到坑位里面,此时右边又形成了一个新的坑位,再left走,去找大,找到了又扔到新的坑位,重复这一系列操作,当left和right相遇的时候,我们就将key放在最后一个坑位上。
首先还是先创建一个数组:
int a[] = { 6, 1, 2, 7, 9, 3, 4, 5, 10, 8 };
我们画图来看:
此时我们也就排好了一个数,而排一组数据的过程也和上面的是一样的。
代码实现:
//2. 挖坑法
int Partion2(int* a, int left, int right)
{
//取三个数中中间那个数和左边的数交换
int mini = GetMidIndex(a, left, right);
Swap(&a[left], &a[mini]);
//取左边为key值
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[left] = key;
return left;
}
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int keyi = Partion3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
这个挖坑法其实和前面第一个方法是差不多的。我们也来测试一下:
没有错!我们接下来看第三种方法
1.2.1.3前后指针版本
这个前后指针是什么意思呢?先选一个数作为key,然后就是两个指针同时从开头走,cur指针先走去找小的数,找到了就和prev交换,找到大的数就继续走,当cur到尾了就停下。最后交换key和prev。
方法一:
我们也画图分析:
代码实现:
3. 前后指针版本一
int Partion3(int* a, int left, int right)
{
//取三个数中中间那个数和左边的数交换
int mini = GetMidIndex(a, left, right);
Swap(&a[left], &a[mini]);
//取左边下标为keyi
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
//往右去找小
while (cur <= right && a[cur] >= a[keyi])
{
cur++;
}
//找到了就交换
if (cur <= right)
{
Swap(&a[++prev], &a[cur]);
cur++;
}
}
//最后交换keyi的值和prev的值
Swap(&a[keyi], &a[prev]);
return prev;
}
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int keyi = Partion3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
我们测试一下:
这里的结果没有错!此时我们发现不管怎么交换,cur都要往前走,那么我们可不可以将cur向前走归为一种方法呢?
方法二:
//3. 前后指针版本二
//将区间按照基准值划分为左右两半部分
//这种方法重点掌握,其他方法也要了解
int Partion3(int* a, int left, int right)
{
//取三个数中中间那个数和左边的数交换
int mini = GetMidIndex(a, left, right);
Swap(&a[left], &a[mini]);
//取左边的数下标为keyi
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
//找小,找到就交换
if (a[cur] < a[keyi])
{
Swap(&a[++prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
int keyi = Partion3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
打印结果也是不会错的。
小区间优化
现在我们还有一种小区间。这是什么意思呢?先看图:
此时我们最后10层就用直接插入来排序,而大于10层的数就用快速排序来排,整体也优化了一点。主要是减少了递归调用的次数。
代码实现:
//时间复杂度:O(N*logN)
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
{
return;
}
//现在有1000个数:假设每次keyi刚好是中间那个数,那么就要递归10次,
//最后10层递归的次数远大于前面递归的次数,所以现在有一种小区间优化法
//当最后10层的时候就采用直接插入排序,C++原码有小区间优化
//[0, 9]
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
//当递归层数大于10
else
{
int keyi = Partion3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
那么此时这个快排还有不有什么缺陷呢?我们想一想,当排序数组的数据是相同的是不是会出错呢?那么我们来测试一下:
void TestOP()
{
srand(time(0));
const int N = 10000;
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);
for (int i = 0; i < N; i++)
{
a1[i] = 1;
a2[i] = 1;
a3[i] = 1;
a4[i] = 1;
a5[i] = 1;
a6[i] = 1;
/*a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];*/
}
/*int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();*/
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
/*int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();*/
int begin4 = clock();
HeapSort(a2, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a2, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
//printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
我们调试就会发现栈溢出了。
这是为什么呢?我们画图分析:
这样的情况如何是不是没有办法解决呢?此时我们就要学习快排的非递归。
1.2.2非递归版本
这个非递归版本怎么搞呢?我们可以用栈来模拟实现:
如过大家忘了栈怎么实现的,可以参考这一片文章:【数据结构从0到1】第三篇:栈和队列
代码实现:
//缺陷当是2,3,2,3,2,3...的时候,排序就是n n-1 n-2...
// 递归调用的层数就会非常多,递归式的快排可能就会崩
//此时我们采用非递归式的快排
//用栈来实现非递归快排
void QuickSortNonR(int* a, int left, int right)
{
assert(a);
ST st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
//取出区间
int end = StackTop(&st);
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
//通过一趟排序得到keyi的下标
int keyi = Partion3(a, begin, end);
//区间分为[left, keyi - 1] [keyi+1, right]
//先入右区间就后出右区间
if (keyi + 1 < end)
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
if (begin < keyi - 1)
{
StackPush(&st, begin);
StackPush(&st, keyi - 1);
}
}
StackDestroy(&st);
}
我们用非递归来测试一下:
void TestOP()
{
srand(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);
for (int i = 0; i < N; i++)
{
a1[i] = 1;
a2[i] = 1;
a3[i] = 1;
a4[i] = 1;
a5[i] = 1;
a6[i] = 1;
/*a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];*/
}
/*int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();*/
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
/*int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();*/
int begin4 = clock();
HeapSort(a2, N);
int end4 = clock();
int begin5 = clock();
QuickSortNonR(a2, 0, N - 1);
int end5 = clock();
/*int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();*/
//printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
/*printf("MergeSort:%d\n", end6 - begin6);*/
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
此时我们发现就没有栈溢出,只不过时间复杂度就很高了,是O(N^2)。但是这种情况极少,如过遇到这种情况的话,就不要用快排。
1.2.3快速排序的特性总结
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
1.3归并排序
1.3.1递归版本
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤
我们用一张动图展示:
我们还是一步一步分析,假设现在有一个数组:
int a[] = { 1,6, 7,10,2, 3,4,9 };
左右都是有序的,我们就直接归并:
代码实现:
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
//将左右两个区间的数据排序拷贝到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里面的数拷贝回去
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
但是数组现在数组的左右区间不有序,该怎么办呢?我们是不是可以去让这个数组的去区间有序,右区间也有序呢?这不就可以用递归吗?
现在这个数组:
int a[] = { 10, 6, 7, 1, 3, 9, 4, 2 };
我们就直接递归将它排成有序:
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
//[left, mid] [mid + 1,right]
//分别将左右区间归并为有序
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
//将左右两个区间的数据排序拷贝到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里面的数拷贝回去
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
//归并排序
//时间复杂度:O(N*logN)
//空间复杂度:O(N)
void MergeSort(int* a,int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
我们测试一下:
如果不懂的话,我们来画递归展开图:
1.3.2非递归版本
非递归实现归并排序我们就需要控制一个gap,从一个数开始排、然后两个数排、4个数排…最后排到小于n就停止。
版本一:
我们也画图分析:
//非递归归并排序
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int)*n);
if (tmp == NULL)
{
printf("malloc fail");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//分为两个区间[i, i+gap-1] [i+gap, i+2gap-1]
//归并
}
//拷贝回数组
for (int i = 0; i < n; i++)
{
a[i] = tmp[i];
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
我们先不实现具体的过程,我们吧整体框架实现了出来。此时我们再通过框架去分析这个过程
此时我们就实现里面的递归过程了
代码实现:
//非递归归并排序
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int)*n);
if (tmp == NULL)
{
printf("malloc fail");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//分为两个区间[i, i+gap-1] [i+gap, i+2gap-1]
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int 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++];
}
}
//拷贝回数组
for (int i = 0; i < n; i++)
{
a[i] = tmp[i];
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
我们测试一下:
这就排好了,这个非递归也太简单了吧!!那么此时我们在增加一个数呢?
int a[] = { 10, 6, 7, 1, 3, 9, 4, 2, 8 };
我们看程序就直接崩了,说明一定出现了问题,我们此时又来画图分析:
我们调试一下:
顺便将下标位置打印:
此时我们发下和我们分析的是一样的,那么我们怎么解决呢?我们只需要控制边界就可以了,比如当begin1越界的时候,就修改begin1的值边界,当begin2越界的时候就修改begin2的边界,end2结束的时候就修改end的边界
//非递归归并排序
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int)*n);
if (tmp == NULL)
{
printf("malloc fail");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//分为两个区间[i, i+gap-1] [i+gap, i+2gap-1]
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
/*printf("[%d,%d][%d,%d]", begin1, end1, begin2, end2);*/
int index = i;
/* 三种越界情况:
1.end1越界,begin2和end2也越界
2.begin1越界,end2越界
3 end2越界
防止归并时越界*/
if (end1 >= n)
{
end1 = n - 1;
}
if (begin2 >= n)
{
begin2 = n;
}
if (end2 >= n)
{
end2 = n - 1;
}
//拷贝数据
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++];
}
}
/* printf("\n");*/
//拷贝回数组
for (int i = 0; i < n; i++)
{
a[i] = tmp[i];
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
此时的begin2要特别注意如果我们也将begin2修改为n-1的话,很有可能tmp数组会越界,所以我们选择将begin2修改n,此时[begin2,end2]就不会在进入归并当中。
我们测试一下:
版本二:
上一个版本是我们在排完一次过后才将tmp数组拷贝回去,我们也可以在归并一组完成的时候就拷贝回去。
代码:
//非递归归并排序
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//分为两个区间[i, i+gap-1] [i+gap, i+2gap-1]
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
/*printf("[%d,%d][%d,%d]", begin1, end1, begin2, end2);*/
int index = i;
//三种越界情况:
// 1.end1越界,begin2和end2也越界
// 2.begin1越界,end2越界
// 3 end2越界
//防止归并时越界
if (end1 >= n || begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
//拷贝数据
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 = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
同样我们也要控制begin1、begin2和end2的边界。
我们也测试一下:
结果也是完全吻合。
1.3.3归并排序的特性总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
1.4计数排序
1.4.1计数排序实现
我们动图演示:
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 计数排序不是去比较大,而是通过相对映射的方式去统计次数。
我们画图分析:
代码实现:
//时间复杂度:O(MAX(n,range))
//空间复杂度O(range)
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
//选出最大值和最小值
for (int i = 0; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
//malloc数组[0, 9]就是10个数据
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
memset(count, 0, sizeof(int) * range);
if (count == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//统计次数
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;
j++;
}
}
}
1.4.2计数排序的特性总结:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定
1.53.排序算法复杂度及稳定性分析
总结
排序到这里基本上就完全介绍完了,还有其它的排序可能会没有涉及,因为有序排序太老了,现在基本上不会用到,现在基本上掌握这些排序就可以了。