本篇文章是我对之前写过的八个排序算法的总结,感兴趣的小伙伴可以去我的八大排序算法专栏浏览,也可以点击下方标题跳转。
提示:本篇博客篇幅较长,建议小伙伴们查看目录,按需浏览
正文
本篇博客将对“直接插入排序”、“希尔排序”、“直接选择排序”、“堆排序”、“冒泡排序”、“快速排序”、“归并排序”、“计数排序”进行详细的解析(算法逻辑、具体实现过程、时间复杂度、稳定性),并附以图片帮助大家更好地理解算法逻辑以及实现每个排序的参考代码。
注:在本文中,均以升序排序进行讲解
1 直接插入排序
基本思想:
- 直接插入排序是一种简单明了的插入排序法,其基本思想是:把待排序的数据按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有数据插入完为止。
- 在现实生活中,我们玩扑克对牌进行排序就运用了这种思想。
整体插入思想:
统一使用升序
- 当插入第(i >= 1)个元素时,前面的array[0], array[1], ……, array[i - 1]已经有序,此时用array[i]的值与array[i - 1], array[i - 2],……的值顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
- 如对数组{2, 5, 4, 6, 8, 7, 1}进行升序排序:
代码实现:
- 我们假设数组中[0,end]的数据已经有序,要将nums[end + 1]这个元素插入到[0,end]中,使[0,end + 1]也有序
- 因为可能要进行数据后移的操作,为防止nums[end + 1]被覆盖而无法得到其值,要事先用临时变量保存
int end; int temp = nums[end + 1];
- 运用插入排序的思想,将nums[end + 1](即temp)从后往前依次与有序的元素进行比较,满足条件就插入
while (end >= 0) { /* 如果后面的数比前面的小,那就将前面的数后移 继续将后面的数与更前面的数(即更小的数)比较 */ if (temp < nums[end]) { nums[end + 1] = nums[end]; end--; } /* 否则,如果后面的数比前面的大 那么满足升序条件,将temp插入到nums[end]的后面 */ else nums[end + 1] = temp; }
- 但是,上面这段代码还是存在一个小小的bug,即如果我们要插入的元素temp比有序序列的第一个元素nums[0]还要小,那么执行完
nums[end + 1] = nums[end];end--;
这一操作后,end就等于-1了,而循环的条件是end >= 0
,无法进入循环,end[0]这一位置也无法被赋值,因此,我们要进行改进:
while (end >= 0) { if (temp < nums[end]) { nums[end + 1] = nums[end]; end--; } /* 如果后面的数比前面的大 那么满足升序条件,直接退出循环 */ else break; } //将(end >= 0 && temp > nums[end])和 (end == -1)这两种情况整合到一起 nums[end + 1] = temp;
- 设待排序数组有numsSize个元素,因此要使数组有序,就要用一层for循环来分别判断数组的每个值是否处于正确的位置。
//为防止数组越界,i的最大值为numsSize - 2 for (int i = 0; i < numsSize - 1; i++) { int end = i; int temp = nums[end + 1]; ………… }
动图演示:
实现代码:
void sort(int* nums, int numsSize) { //为防止数组越界,i的最大值为numsSize - 2 for (int i = 0; i < numsSize - 1; i++) { /* 假设[0,end]已经有序 要将nums[end + 1]这个元素插入到[0,end]中,使[0,end + 1]也有序 */ int end = i; int temp = nums[end + 1]; //因为可能要进行数据后移的操作,为防止nums[end + 1]被覆盖而无法得到其值,要事先用临时变量保存 while (end >= 0) { if (temp < nums[end]) { nums[end + 1] = nums[end]; end--; } /* 如果后面的数比前面的大 那么满足升序条件,直接退出循环 */ else break; } //将(end >= 0 && temp > nums[end])和 (end == -1)这两种情况整合到一起 nums[end + 1] = temp; } }
时间复杂度:
统一为升序排序
- 最好的情况:最好的情况就是数组已经升序有序,只有最外面一层for循环遍历一次数组,里面的while循环每一次都是直接退出,因此最好的情况时间复杂度为O(N)
- 最坏的情况:最坏的情况就是数组是降序排序,里面while循环的时间复杂度为O(N),因此最坏情况下,时间复杂度为O(N^2)
- 综上,直接插入法的时间复杂度为O(N^2)
2 希尔排序
注1:本篇是基于对直接插入排序法的拓展,如果对直接插入法不了解,建议先看看直接插入排序
注2:本篇统一采用升序排序
基本思想:
- 希尔排序法又称缩小增量法。
- 希尔排序其实是直接插入排序的改进。
- 其基本思想是:先选定一个整数gap,把待排序文件中所有记录分成数组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后缩小gap,重复上述步骤,当gap == 1时,所有记录在统一组内已经排好序。
整体插入思想:
- 在直接插入排序中,我们知道最坏的情况是待排序列降序逆序的情况,如序列:
8,7,6,5,4,3,2,1
,这时时间复杂度为O(N2),显然效率不高 - 而希尔排序的思想,就是先对待排序列进行预排序,使待排序列接近有序。我们知道,当待排序列接近有序时,直接插入排序法的时间复杂度接近O(N),效率很高,因此预排序过后,就使用直接插入排序法,从而提高了效率。
预排序:
- 预排序实际上也是直接插入排序,但是是将待排序列分成数组来排
- 根据基本思想,规定间隔为gap的数为一组
- 我们以数组
{9,8,7,6,5,4,3,2,1}
,gap = 3为例:
- 每gap为一组:
- 对第一组排序:
- 对第二组排序:
- 对第三组排序:
- 这时相较于最开始,待排序列更加接近于有序,此时我们不断缩小gap,不断预排序,直到最后gap == 1时最后使用一次直接插入排序(gap == 1时的直接插入排序实际上就是最原始的直接插入排序),使待排序列有序。
- 又例如:
结论:
- 希尔排序实际上就是多组间隔为gap的预排序,gap由大到小
- gap越大,大的数能越快到后面,小的数能越快到前面
- gap越大,预排序之后待排序列越不接近于有序
- gap越小,预排序之后待排序列越接近于有序
- 当gap == 1时,预排序实际上就是对整个序列进行直接插入排序,排完后序列即有序
- 因此,最后一次预排序,gap必须为1.
代码实现:
- 对每间隔gap的一组数据进行排序,本质上就是直接插入排序,故不作过多讲解
int end; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp;
- 对多组间隔为gap的数据进行预排序
- 以这张图为例:
- 我们上面的步骤只是将间隔为pap的一组数据进行了排序,但待排序列不止一组间隔为gap的数据,因此我们要做到将所有间隔为gap的每组数据都进行排序。
- 怎么实现呢?可能最容易想到的是分别将每组间隔为gap的数据进行排序,例如上面分别对第一组,第二组,第三组排序,但是这样做效率不高,且操作复杂。因此我们要换一种想法,即把间隔为gap的数据同时排序。
- 如图:
for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; }
- 最后还要不断缩小gap的值,直到gap == 1
int gap = numsSize; while (gap > 1) { gap /= 2; //不断缩小gap /* 也可以写成 gap = gap / 3 + 1; 总之,必须要保证最后一次gap == 1 */ for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; } }
动图演示:
实现代码:
void ShellSort(int* nums, int numsSize) { int gap = numsSize; while (gap > 1) { gap /= 2; for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; } } }
直接插入排序与希尔排序的效率比较:
- 看到希尔排序有三层循环,可能有小伙伴会疑惑希尔排序为什么会比直接插入排序快,这里我们先上测试代码,直观的来感受这两个排序算法之间的差距:
测试代码:
#include<stdio.h> #include<stdlib.h> #include<time.h> //直接插入排序 void InsertSort(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { int end = i; int temp = nums[end + 1]; while (end >= 0) { if (temp < nums[end]) { nums[end + 1] = nums[end]; end--; } else break; } nums[end + 1] = temp; } } //希尔排序 void ShellSort(int* nums, int numsSize) { int gap = numsSize; while (gap > 1) { gap /= 2; for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; } } } int main() { srand((unsigned int)time(NULL)); //创建两个大小为N的数组 const int N = 100000; int* a1 = (int*)malloc(sizeof(int) * N); int* a2 = (int*)malloc(sizeof(int) * N); //为数组赋随机值 for (int i = 0; i < N; i++) { a1[i] = rand(); a2[i] = a1[i]; } /* clock()函数可以记录当前时间 begin和end的差即排序算法运行的时间 注:时间的单位为毫秒(ms) */ int beginl = clock(); InsertSort(a1, N); int end1 = clock(); int begin2 = clock(); ShellSort(a2, N); int end2 = clock(); printf("InsertSort:%d\n", end1 - beginl); printf("ShellSort:%d\n", end2 - begin2); //释放内存 free(a1); free(a2); return 0; }
- 测试结果:
- 我们可以看到,当数据个数为十万个时,直接插入排序所需要的时间是的希尔排序的100多倍
- 当数据个数为一百万个时,直接插入排序所需要的时间时希尔排序的2000倍、
- 可见,数据越多,希尔排序的优势就越明显,节省点时间就越多
时间复杂度:
- 从上面的测试中,我们直观的感受到了相较于直接插入排序,希尔排序的优越性,那么具体的希尔排序的时间复杂度为多少呢?
- 我们先来看最外层的循环:
int gap = numsSize; while (gap > 1) { gap /= 2; ………… }
- 设最外层循环运行了x次,那么2x = numsSize,x = log2N,即最外层的时间复杂度为log2N
- 再看里面两层循环:
for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; }
- 当gap很大时,尽管有两层循环,但数据之间跳跃的很大,需要排序的次数很少,因此时间复杂度为O(N),例如这种情况:
- 当gap很小时,尽管有两层循环,但此时数据已经接近有序,需要排序的次数也很少,因此时间复杂度也为O(N)。
- 综上,希尔排序的时间复杂度为O(NLogN)
- 也可以认为时间复杂度为O(N^1.3)
3 直接选择排序
以升序排序为例
算法步骤:
方法一:直接交换数组元素
- 将第一个元素与其他元素进行比较,若其他元素小于第一个元素,则交换位置,最后第一个元素为最小元素
- 将剩余元素的第一个元素与其他元素进行比较,若其他元素小于第一个元素,则交换位置
- 重复上述步骤,直到第(n-1)个元素比较完毕
方法二:利用数组下标间接交换数组元素
- 将第一个元素的下标标记为min,将第一个元素与其他元素进行比较,若其他元素小于第一个元素,则令该元素的数组下标为min,一轮比较完后,若第一个元素的下标不等于min,则交换第一个元素与下标为min的元素的位置
- 对剩下的元素重复上述步骤,直到没有元素需要交换位置
动图演示:
实现代码:
#include<stdio.h> void WayOne(int *p,int num) //利用直接交换数组元素,从小到大排列数组 { int i,j,temp; for(i=0;i<num-1;i++) //需比较(数组元素-1)次 for(j=i+1;j<num;j++) if(p[i]>p[j]) { temp=p[i]; p[i]=p[j]; p[j]=temp; } for(i=0;i<num;i++) printf("%-5d",p[i]); } void WayTwo(int *p,int num) //利用元素下标间接交换数组元素,从大到小排列数组 { int i,j,temp,max; for(i=0;i<num-1;i++) { max=i; //设置标记 for(j=i+1;j<num;j++) if(p[j]>p[max]) max=j; if(max!=i) { temp=p[max]; p[max]=p[i]; p[i]=temp; } } for(i=0;i<num;i++) printf("%-5d",p[i]); } int main() { int a[]={12,134,46,688,563,145,7357,26,24}; WayOne(a,sizeof(a)/sizeof(int)); printf("\n"); WayTwo(a,sizeof(a)/sizeof(int)); return 0; }
3.1 改进算法(双指针)
具体步骤:
- 上面的直接选择排序每一次只能选出一个数据,但是,我们可以用双指针的方法进行改进,做到每一次可以选出两个数据
- 首先,我们令begin指向数组第一个元素,end指向数组最后一个元素
- 然后,遍历
[begin,end]
这一块区域,同时保存最大值和最小值元素的下标max_index
、min_index
- 由于进行的是升序排序,begin位置应该放置最小值,end位置应该放置最大值,我们就可以利用下标来交换begin、min_index和end、max_index的数据
- 缩小区域
[begin,end]
,重复上述步骤,直到不能满足条件begin < end
实现代码:
void SelectSort(int* nums, int numsSize) { int begin = 0; int end = numsSize - 1; while (begin < end) { int max_index = end; int min_index = begin; for (int i = begin; i <= end; i++) { //得到最小值的下标 if (nums[i] < nums[min_index]) min_index = i; //得到最大值的下标 if (nums[i] > nums[max_index]) max_index = i; } //将最小值放到前面 Swap(&nums[begin], &nums[min_index]); //将最大值放到后面 Swap(&nums[end], &nums[max_index]); //缩小区间 begin++; end--; } }
具体过程:
处理特殊情况:
- 如果遍历完后存在这么一种情况:
- 我们执行完第一次交换
Swap(&nums[begin], &nums[min_index])
后: - 再执行第二次交换
Swap(&nums[end], &nums[max_index])
: - 我们发现,最小值-1竟然被放到了最后,这显然是不对的,为什么会出现这样的情况呢?
- 出现这种情况是因为最大值元素的下标刚好是begin,当我们执行第一次交换
Swap(&nums[begin], &nums[min_index])
后,begin(max_index)代表的值就是最小值,因此当我们执行第二次交换Swap(&nums[end], &nums[max_index])
时,就会将最小值放到最后(即end的位置) - 为了避免这种情况,应该在第一次交换后进行一次判断,对max_index的位置进行修正
/* 如果 begin == max_index 那么第一次交换Swap(&nums[begin], &nums[min_index])后,min_index的值其实是最大值 因此,要将max_index的值修正为min_index */ if (begin == max_index) max_index = min_index;
实现代码:
void Swap(int* num1, int* num2) { int temp = *num1; *num1 = *num2; *num2 = temp; } void SelectSort(int* nums, int numsSize) { int begin = 0; int end = numsSize - 1; while (begin < end) { int max_index = end; int min_index = begin; for (int i = begin; i <= end; i++) { //得到最小值的下标 if (nums[i] < nums[min_index]) min_index = i; //得到最大值的下标 if (nums[i] > nums[max_index]) max_index = i; } //将最小值放到前面 Swap(&nums[begin], &nums[min_index]); //对max_index进行修正 if (begin == max_index) max_index = min_index; //将最大值放到后面 Swap(&nums[end], &nums[max_index]); //缩小区间 begin++; end--; } }
改进后和为改进的直接选择排序算法的比较:
- 我们来看看这两个数对100000个数排序分别消耗了多少时间(其中SelectSort_One为为改进的直接选择排序,SelectSort_Two为改进了的直接选择排序):
- 可见相较于一次只选一个数,一次选两个数还是可以显著的提高排序效率。
时间复杂度:
- 直接选择排序虽然容易理解,但可以说是效率最低的一个排序算法
- 在为改进的直接选择排序中,我们要遍历数组的每个元素,同时还要将每个元素和其他未有序的元素一一比较,因此时间复杂度为O(N^2)
- 在改进的直接选择排序中,最外层的while()循环的时间复杂度为O(N),里面for循环的时间复杂度也是O(N),因此整体的时间复杂度仍为O(N^2)
- 综上,直接选择排序的时间复杂度为O(N^2)
4 堆排序
注:堆排序涉及到二叉树,对二叉树还不了解到小伙伴建议先看看数和二叉树
什么是堆:
- 在学习堆排序之前,我们要先知道堆是什么
- 堆的逻辑结构是一棵完全二叉树
- 堆的物理结构是一个数组
- 即我们可以将堆看成是一棵完全二叉树的顺序存储
父子节点的关系:
- 我们可以通过数组的下标来确定父子节点的关系,结论如下
leftchild = parent * 2 + 1
rightchild = parent * 2 + 2
parent = (child - 1) / 2
对的两个特性:
- 结构性:用数组表示的完全二叉树
- 有序性:任意节点的关键值是其子树所有节点的最大值(或最小值)
- ”最大堆(MaxHeap)“,也叫”大顶堆“:最大值,即每棵子树的根节点的值都大等于于其子节点的值
- ”最小堆(MaxHeap)“,也叫”小顶堆“:最小值,即每棵子树的根节点的值都小于等于其子节点的值
- 例如上图,是小顶堆
- 又例如:
(101, 88, 46, 70, 34, 39, 45, 58, 66, 10)
是大堆
怎么堆排序:
- 堆排序实际上也是一种选择排序,只是利用了堆来提高了效率
第一步——建堆:
- 假设我们要对一个乱序数组进行堆排序,那么首先就要在这个数组的基础上建立大堆或小堆
- 我们以建小堆为例:
- 由于堆这一概念基于完全二叉树,因此我们先要将数组看成完全二叉树的形式,如对于数组
{27,15,19,18,28,34,65,49,25,37}
: - 接下来我们就采用向下调整算法来实现先建堆
- 向下调整算法:
- 使用前提:左右子树都是小堆
- 步骤:从根节点开始,选出左右孩子值较小的那一个并跟父亲比较,如果小于,那么就交换父亲节点和较小值节点的值,然后继续向下调整,调到叶子节点或父亲小于较小值就终止
- 具体过程如图所示:
向下调整算法代码实现:
void AdjustDown(int *nums, int numsSize, int root) { int parent = root; int child = parent * 2 + 1; //首先默认左右孩子的较小值是左孩子,然后再在循环里判断取值 while ( child < numsSize) { //如果右孩子小于左孩子,那么child取值为右孩子的下标 if (child + 1 < numsSize &&nums[child] > nums[child + 1]) child += 1; //如果字节点的值小于父节点,那么就交换这两个值 if (nums[child] < nums[parent]) { Swap(&nums[child], &nums[parent]); //Swap需要自己实现 //继续向下调整 parent = child; child = parent * 2 + 1; } //如果没有交换子节点和父节点,就说明这已经是一个小堆 else break; } }
- 如果是建大堆,则将代码改为:
void AdjustDown(int *nums, int numsSize, int root) { int parent = root; int child = parent * 2 + 1; //首先默认左右孩子的较小值是左孩子,然后再在循环里判断取值 while ( child < numsSize) { //如果右孩子大于左孩子,那么child取值为右孩子的下标 if (child + 1 < numsSize &&nums[child] < nums[child + 1]) child += 1; //如果字节点的值大于父节点,那么就交换这两个值 if (nums[child] > nums[parent]) { Swap(&nums[child], &nums[parent]); //Swap需要自己实现 //继续向下调整 parent = child; child = parent * 2 + 1; } //如果没有交换子节点和父节点,就说明这已经是一个小堆 else break; } }
确保左右子树都是小堆:
- 我们不可能保证,对于给定的任意一个数组,进行堆排序的向下调整算法时都能保证它的左右子树都是大堆或小堆
- 因此在进行向下调整算法前我们还要先对数组进行调整,使其左右子树满足使用条件。那么如何实现?
- 我们以数组
{3,5,2,7,8,6,1,9,4,0}
为例: - 要满足每个父节点的左右子树都是小堆,那么我们就可以采用分治思想,从最小、最后的一棵子树开始看,不断进行向下调整算法,从而可以做到每棵子树是小堆,即自下而上建堆
- 有小伙伴可能会问怎么得到最后一棵子树的父节点呢?不知道小伙伴还是否记得左右孩子与父节点的关系式:
leftchild = parent * 2 + 1、rightchild = parent * 2 + 2
,我们知道最后一个叶子节点的下标是numsSize-1
,那么最后一棵子树的父节点下标就是(numsSize - 2) / 2
建堆实现代码:
- 注:此处建立的是小堆
void HeapBuild(int* nums, int numsSize) { for (int i = (numsSize - 2) / 2; i >= 0; i--) AdjustDown(nums, numsSize, i); }
第二步——实现排序
- 假设我们要实现对数组
{3,5,2,7,8,6,1,9,4,0}
的升序排序,那么首先我们就要思考一个问题:是用大堆还是用小堆? - 假设我们使用的是小堆:那么建堆后的数组是这样的:
0 3 1 4 5 6 2 9 7 8
,把它看成完全二叉树形式: - 由于是要实现升序排序,且用的是小堆,因此堆顶元素就是最小值,在第二次建小堆得到次小值时就要将第一个数“0”忽略,即对数组
(3 1 4 5 6 2 9 7 8)
建小堆,但是如果这样,数据之间的关系就会被打乱,在使用向下调整算法时就不能满足所有子树都是小堆的情况,如图: - 因此如果要用小堆来实现升序排序,就先要保证其左右子树都是小堆,效率不高
- 而如果用的是大堆来排序,那第一次建堆后是这样的:
- 因为是要进行升序排序且用的是大堆,因此,堆顶元素就是最大值,我们可以将堆顶元素和最后一个元素交换位置,这样数组中最大的元素就到了数组最后的位置,之后也只需要对前
numsSize-1
个数据处理即可,如图: - 从上图中我们可以看到,根节点“0”的左右子树仍然为大堆,可见利用大堆来进行升序排序不会打乱数据之间的关系,这样就可以直接用向下调整算法来再建大堆,重复上述步骤,最终得到升序序列。
动图演示:
实现升序排序代码:
//建大堆 void AdjustDown(int *nums, int numsSize, int root) { int parent = root; int child = parent * 2 + 1; //首先默认左右孩子的较小值是左孩子,然后再在循环里判断取值 while ( child < numsSize) { //如果右孩子大于左孩子,那么child取值为右孩子的下标 if (child + 1 < numsSize &&nums[child] < nums[child + 1]) child += 1; //如果字节点的值大于父节点,那么就交换这两个值 if (nums[child] > nums[parent]) { Swap(&nums[child], &nums[parent]); //Swap需要自己实现 //继续向下调整 parent = child; child = parent * 2 + 1; } //如果没有交换子节点和父节点,就说明这已经是一个小堆 else break; } } void HeapSort(int* nums, int numsSize) { //建堆 for (int i = (numsSize - 2) / 2; i >= 0; i--) AdjustDown(nums, numsSize, i); //排序 int end = numsSize - 1; while (end > 0) { /* 不断交换堆顶和最后一个元素的值 交换完后忽略最后一个元素,重新建大堆 继续交换 直到只剩下一个元素 */ Swap(&nums[end], &nums[0]); AdjustDown(nums, end, 0); end--; } }
- 同理,如果要实现降序排序,只需要将建大堆改为建小堆即可
时间复杂度:
- 由于论证过程过于复杂,下面直接给出结论
- 向下调整算法
AdjustDown()
的时间复杂度为O(logN) - 自下而上建堆的时间复杂度为O(N)
- 建堆后排序的时间复杂度为O(NlogN)
- 因此整个堆排序的时间复杂度为O(NlogN)
回顾:
- 我们再来看看为什么实现升序排序为什么要用大堆而不是用小堆:
- 我们前面提到,如果是小堆,那么将第一个数(小堆堆顶元素)排除后,数据次序就会被打乱,要建小堆首先就需要保证其左右子树都是小堆,而这一过程的时间复杂度是O(N),由于要对N个元素排序,那么这一过程就要进行N次,因此整体的时间复杂度为O(N2)
- 如果用的是大堆,那么将第一个数(大堆堆顶元素)和最后一个数交换后,将最后一个数排除在外,数据的次序不会被打乱,因此我们可以直接以第一个数为根,它的左右子树直接就是大堆,可以直接进行向下调整算法,时间复杂度为O(logN),因为有N个数,因此整体的时间复杂度就是O(Nlog2N)
5 冒泡排序
算法步骤:
以升序排序为例:
- 比较相邻元素,如果前面的比后面的元素大,则两元素交换位置
- 对每一对相邻元素进行比较,大的放后,这样最后的元素将是最大的元素
- 对越来越少的混乱元素重复上述步骤(最后的元素已经有序,不需比较),直到没有元素需要交换位置
动图演示:
实现代码:
void BubbleSort(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { for (int j = 0; j < numsSize - 1 - i; j++) { if (nums[j] > nums[j + 1]) { Swap(&nums[j], &nums[j + 1]); //Swap()函数需要自己实现 } } } }
5.1 稍加优化
- 如果在一趟遍历中未发生交换,就可以说明数组已经有序
- 因此我们可以设计一个标记flag,每次外层循环开始时都初始化为1,若内层循环发生了交换就将其改为0
- 因此如果内层循环结束后flag仍为1,就说明这一次遍历未发生交换,即数组已经有序
优化后的代码:
void BubbleSort(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { int flag = 1; for (int j = 0; j < numsSize - 1 - i; j++) { if (nums[j] > nums[j + 1]) { Swap(&nums[j], &nums[j + 1]); flag = 0; } } if (flag) break; } }
时间复杂度:
- 易得冒泡排序的时间复杂度为O(N^2)
6 快速排序
以升序为例
何为快速排序:
- 快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法。
- 基本思想:任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
- 例如下图就是将数字“5”放在正确位置上的过程
- 将区间按照基准值划分为左右两半部分的常见方法有:
6.1 方法一:挖坑法
挖坑法顾名思义,就是在待排数组的某一特定位置“挖坑”,然后用另外的元素填坑,从而达到将区间按照基准值划分为左右两半部分的目的
实现的基本思想:
第一步——处理第一个数:
- 由于现在还没有数据处在正确的位置,因此排序区间是整个数组的长度
- 可以将待排数组的第一个数(最左边的数)作为基准值key,同时将这个位置设为坑pivot
- 设区间最左边的下标为left,最右边的下标为right,此时坑的位置即最左边left
int key = nums[0]; //基准值 int pivot = 0; //坑的位置 int left = 0; //区间最左边 int right = numsSize - 1; //区间最右边
- 由于是升序排序,要做到基准值key左边的元素全小于key,右边的元素全大于key。而坑pivot现在在左边,因此我们要利用右边的right,通过对其的移动找到小于key的数,并将这个数“挖走”,并填入坑pivot中,此时,被挖走的位置就成了新的坑
//right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right;
- 这样坑pivot就到了右边,右边放的应该是大于基准值key的数,因此我们就要通过移动left来找到小于key的数,将其挖走并填入坑pivot中,同样,被挖走的位置也成了新的坑
//left不断向右移动,找到大于key的数 while (begin < end && nums[begin] <= key) begin++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[begin]; //被挖走数字的区域变成新的坑 pivot = begin;
- 通过循环,不断移动left和right,直到不能满足条件
left < right
,此时第一个数就被放到正确的位置了。
放置第一个数的具体过程:
我们以数组{5,8,2,9,1,3,7,4,6}
为例:
放置第一个数的实现代码:
void QuickSort(int* nums, int numsSize) { int key = nums[0]; //基准值 int pivot = 0; //坑的位置 int left = 0; //区间最左边 int right = numsSize - 1; //区间最右边 while (begin < end) { //right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right; //left不断向右移动,找到大于key的数 while (begin < end && nums[begin] <= key) begin++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[begin]; //被挖走数字的区域变成新的坑 pivot = begin; } //将基准值填入最后坑的位置 nums[pivot] = key; }
第二步——处理所有数:
- 我们能够将一个数放在正确的位置,那么自然其他的数我们也可以用类似的方法来处理
- 这里我们利用分治思想:分而治之。大问题分成类似的子问题,子问题再分成子问题……直到子问题不能再分割。
- 处理完第一个数后,整个待排序列已经被分割成了两个子序列:
[0,pivot - 1]和[pivot + 1, right]
,我们可以利用同样的办法将个子区间继续分割,这样越来越多的元素到了正确的位置,直到每个区间的长度为1或0就可以表明待排序列以已经有序 - 我们用递归来解决:
实现代码:
void QuickSort(int* nums, int begin, int end) { //如果区间长度为1或0,则表示只有一个数,直接退出 if (begin >= end) return; int key = nums[begin]; //基准值 int pivot = begin; //坑的位置 int left = begin; //区间最左边 int right = end; //区间最右边 while (left < right) { //right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right; //left不断向右移动,找到大于key的数 while (left < right && nums[left] <= key) left++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[left]; //被挖走数字的区域变成新的坑 pivot = left; } //将基准值填入最后坑的位置 nums[pivot] = key; //递归处理子区间 QuickSort(nums, begin, pivot - 1); QuickSort(nums, pivot + 1, end); }
6.2 方法二:左右指针法
实现的基本思想:
- 左右指针法其实和挖坑法的思想十分类似,同样是先确定一个基准值key,然后通过左边的left找大于key的数和右边的right找小于key的数,然后进行一定的操作,从而达到有序
- 不同点在于:左右指针法不会挖坑,而是在在右边的right找到小于key的数后,直接让左边的left找大于key的数,然后交换这两个值
//找到小于基准值的数 while (left < right && nums[right] >= key) right--; //找到大于基准值的数 while (left < right && nums[left] <= key) left++; //交换这两个数 Swap(&nums[right], &nums[left]);
- 同样的,不断循环,直到不能满足条件
left < right
结束,最后再交换基准值和left、right相遇位置的数。
int key = nums[begin]; //基准值 int left = begin; int right = end; while (left < right) { //找到小于基准值的数 while (left < right && nums[right] >= key) right--; //找到大于基准值的数 while (left < right && nums[left] <= key) left++; //交换这两个数 Swap(&nums[right], &nums[left]); } //交换相遇值和基准值 Swap(&nums[begin], &nums[left]);
一趟循环的具体过程:
- 和挖坑法一样,知道处理一个数,我们就可以用递归的方法来对其余的数进行处理
整体实现代码:
void QuickSort(int* nums, int begin, int end) { if (begin >= end) return; int key = nums[begin]; //基准值 int left = begin; int right = end; while (left < right) { //找到小于基准值的数 while (left < right && nums[right] >= key) right--; //找到大于基准值的数 while (left < right && nums[left] <= key) left++; //交换这两个数 Swap(&nums[right], &nums[left]); } //交换相遇值和基准值 Swap(&nums[begin], &nums[left]); QuickSort(nums, begin, left - 1); QuickSort(nums, left + 1, end); }
6.3 方法三:前后指针法
实现的基本思想:
- 前后指针法和前面两种方法不同,这里要定义指针prev指向待排区域的起始位置,指针cur指向prev的后一个位置
int prev = begin; int cur = begin + 1;
- 令cur不断向右移动遍历待排区域,当碰到小于基准值key的数就停止,同时让prev也向右移动一个(即prev++),交换prev和cur位置的数据
- 不断循环,直到cur遍历完整个数组
while (cur <= end) { if (nums[cur] < key) { prev++; Swap(&nums[cur], &nums[prev]); } cur++; }
- 最后,再将基准值放到正确的位置,即将最后prev和begin位置的元素交换位置
Swap(&nums[begin], &nums[prev]);
- 可能有小伙伴会疑惑,为什么当
nums[cur] < key
时,将prev++,再交换cur和prev位置的数据,就可以将小的数据放在前面,大的数据放在后面,我通过下面这张图来解释:
一趟循环的具体过程:
改进:
- 如果待排数组是这样的:
- 那么会出现这样的情况:
- 因此我们可以对if判断多加一个条件:
++prev != cur
,这样就可以避免对一个数字进行交换了
实现代码:
void QuickSort(int* nums, int begin, int end) { if (begin >= end) return; int key = nums[begin]; int prev = begin; int cur = begin + 1; while (cur <= end) { if (nums[cur] < key && ++prev != cur) Swap(&nums[cur], &nums[prev]); cur++; } Swap(&nums[begin], &nums[prev]); //对余下数字进行递归整理 QuickSort(nums, begin, prev - 1); QuickSort(nums, prev + 1, end); }
6.4 方法四:非递归
- 我们知道,递归有一个致命的缺陷,即如果递归的深度太深,就可能会发生栈溢出,从而导致程序无法正常运行
- 因此我们有必要掌握快速排序的非递归算法
- 通过上面的递归讲解,我们知道,快速排序实际上就是不断重复将一个数放到正确位置这一过程,在这个过程中,待排序列会被分割成数个长度已知的子序列,因此可以用分治思想和递归来解决
- 而要利用非递归来解决快速排序,我们可以利用数据结构中的栈,来进行模拟递归。
实现的基本思路:
- 由于C语言的局限性,我们要用到栈,当然就要先创造一个栈,并实现有关其的基本操作。这里不赘述,如有疑问,可以去看看栈的相关操作
- 在递归解法中,我们是对不断细分的子区间进行数据的整理,同样的,在非递归解法中,我们也需要利用这些不断细分的子区间来进行排序,而要能够像递归一样利用这些子区间,就需要用栈来对这些子区间的左右端的下标进行存储,为了方便讲解,我们先来看看具体的过程展示:
具体做法:
- 假设我们要对长度为numsSize的数组进行排序
- 先将数组两端的下标入栈
- 注意: 注意先后顺序由于栈先入后出的特性应该先入后面的,再入前面的
StackPush(st, numsSize - 1); StackPush(st, 0);
- 进入循环,循环进行的条件为栈不能为空
- 取出栈顶的两个元素,作为待排区间的左右端
- 我们可以用挖坑法、前后指针法、左右指针法这三种方法对这一段区间进行一趟排序(即得到一个数正确的位置),同时得到这个正确位置的下标
- 这样,这个正确位置就将待排序列分割为了两个子序列
- 如果左边的子序列长度大于一,那么就将这个子序列的左右端入栈,对右序列进行相同的处理
- 重复上述步骤,直到栈空
实现代码:
//挖坑法的一趟排序(即将一个数放在正确位置) int PartSort(int* nums, int begin, int end) { int key = nums[begin]; //基准值 int pivot = begin; //坑的位置 int left = begin; //区间最左边 int right = end; //区间最右边 while (left < right) { //right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right; //left不断向右移动,找到大于key的数 while (left < right && nums[left] <= key) left++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[left]; //被挖走数字的区域变成新的坑 pivot = left; } //将基准值填入最后坑的位置 nums[pivot] = key; //返回正确位置的下标 return pivot; } void QuickSort(int* nums, int numsSize) { ST* st = (ST*)malloc(sizeof(ST)); StackInit(st); //初始化栈 //先将待排序列的左右端点入栈 StackPush(st, numsSize - 1); StackPush(st, 0); //当栈不为空进行循环 while (!StackEmpty(st)) { //出栈,得到序列区间 int begin = StackFront(st); StackPop(st); int end = StackFront(st); StackPop(st); //进行一趟排序,得到一个数的正确位置 //这一位置将待排序列分割为两个子序列 int key_index = PartSort(nums, begin, end); //如果右边的子序列长度大于一,那么将左右端点入栈 if (end - key_index > 0) { StackPush(st, end); StackPush(st, key_index + 1); } //如果左边的子序列长度大于一,那么将左右端点入栈 if (key_index - begin > 0) { StackPush(st, key_index - 1); StackPush(st, begin); } } }
时间复杂度:
由于四种方法的思想有共通之处,故拿挖坑法为例
- 我们先看其一趟排序:
//挖坑法的一趟排序(即将一个数放在正确位置) int PartSort(int* nums, int begin, int end) { int key = nums[begin]; //基准值 int pivot = begin; //坑的位置 int left = begin; //区间最左边 int right = end; //区间最右边 while (left < right) { //right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right; //left不断向右移动,找到大于key的数 while (left < right && nums[left] <= key) left++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[left]; //被挖走数字的区域变成新的坑 pivot = left; } //将基准值填入最后坑的位置 nums[pivot] = key; //返回正确位置的下标 return pivot; }
- 实际上就是left,right两个指针分别从左右遍历一次待排区间,时间复杂度为O(N)
- 接下来,就是对这一过程进行不断递归,直到待排区间被分割为一个数,我们可以将这个分割过程看成是一棵满二叉树的情况:
- 因此,递归的时间复杂度就是O(logN)
- 综上,快速排序的时间复杂度为O(NlogN)
6.5 优化
处理最坏情况(以挖坑法为例):
- 先下结论:对于快速排序,最坏情况就是当数组为有序时(无论是正序还是逆序)
- 为了处理类似的情况,我们就要对基准值的取值进行改变,我们一般采用三数取中的方法来进行对key的取值
- 三数取中:比较待排区间两端点和中间的数,选择不大不小的那一个,和左端点的值交换,再将左端点的值作为基准值key
三数去中实现代码:
int GetMid(int* nums, int left, int right) { int mid = (right - left) / 2 + left; if (nums[left] <= nums[mid]) { if (nums[right] > nums[mid]) return mid; else if (nums[right] > nums[left]) return right; else return left; } else //nums[left] > nums[mid] { if (nums[right] > nums[left]) return left; else if (nums[right] > nums[mid]) return right; else return mid; } }
改善后的代码:
void QuickSort(int* nums, int begin, int end) { if (begin >= end) return; /* 为了不改变后序代码的逻辑 三数取中后,应交换中间数和开头数 */ int index = GetMid(nums, begin, end); Swap(&nums[index], &nums[begin]); int key = nums[begin]; //基准值 int pivot = begin; //坑的位置 int left = begin; //区间最左边 int right = end; //区间最右边 while (left < right) { //right不断向左移动,找到小于key的数 while (left < right && nums[right] >= key) right--; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[right]; //被挖走数字的区域变成新的坑 pivot = right; //left不断向右移动,找到大于key的数 while (left < right && nums[left] <= key) left++; //将满足条件的数“挖走”,并填入坑pivot中 nums[pivot] = nums[left]; //被挖走数字的区域变成新的坑 pivot = left; } //将基准值填入最后坑的位置 nums[pivot] = key; QuickSort(nums, begin, pivot - 1); QuickSort(nums, pivot + 1, end); }
7 归并排序
以升序为例
基本思想:
- 归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用:将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
- 如果对两个有序序列的归并操作还不太熟悉,建议先看看合并两个有序链表
核心步骤:
- 由上图我们可以看到,归并排序首先要对待排序列不断二分,直到分成不可分割的子序列(即只有一个元素的序列,相当于有序)
- 然后,再对有序的子序列不断进行归并操作,最后得到完全有序的序列。
- 归并排序有递归和非递归两种写法,接下来我们来讨论如何实现的具体细节:
递归写法:
- 首先我们要注意,在进行归并操作时,为了防止原序列的元素被覆盖而导致排序错误,我们需要向内存申请一块空间用来临时存放合并的序列,同时,由于归并的此时不止一次,为防止多次申请内存而导致效率不高,我们直接向内存申请一块和原序列大小相等的空间。
void MergeSort(int *nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); …………; free(temp); }
- 同时,归并排序在进行归并操作时需要知道每个子序列的区间,由于递归参数的限制,我们需要再定义一个子函数MergeSort(),并对这个子函数递归
void _MergeSort(int *nums, int *temp, int left, int right) { …………; } void MergeSort(int *nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); _MergeSort(nums, temp, 0, numsSize - 1); free(temp); }
- 易知,当子序列长度为1时,就可以不再进行二分
void _MergeSort(int *nums, int *temp, int left, int right) { if (left >= right) return; …………; }
- 对待排序列的左半部分和右半部分不断递归分割
void _MergeSort(int *nums, int *temp, int left, int right) { if (left >= right) return; int mid = (right - left) / 2 + left; _MergeSort(nums, temp, left, mid); _MergeSort(nums, temp, mid + 1, right); ……………; }
- 接下来,就是对两个有序序列的合并操作
- 注:可以走到合并这一步说明待合并的两个序列
[left,mid]和[mid + 1,right]
是有序的,存在两种情况:
- 情况一:例如序列
{9,2}
,进入函数_MergeSort()
后,其子序列是单个数字,满足left >= right
的条件,直接退出递归,开始合并。 - 情况二:例如序列
{9,2,5,4}
,进入函数_MergeSort()
后,子序列{9,2}
递归,合并后退出递归,然后子序列{5,4}
递归,合并,退出递归,最后就变成了两个有序序列{2,9}和{4,5}
的合并 - 建议对递归不是很清楚的小伙伴可以尝试画画递归展开图,这对了解递归逻辑大有裨益。
void _MergeSort(int *nums, int *temp, int left, int right) { if (left >= right) return; //递归分割 int mid = (right - left) / 2 + left; _MergeSort(nums, temp, left, mid); _MergeSort(nums, temp, mid + 1, right); int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = left; //归并 while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] > nums[begin2]) temp[index++] = nums[begin2++]; else temp[index++] = nums[begin1++]; } while (begin1 <= end1) temp[index++] = nums[begin1++]; while (begin2 <= end2) temp[index++] = nums[begin2++]; //将temp暂时存储的数据覆盖待排序列nums原有位置的数据,实现待排序列区间有序 for (int i = left; i <= right; i++) nums[i] = temp[i]; }
动图展示:
实现代码:
void _MergeSort(int *nums, int *temp, int left, int right) { if (left >= right) return; //递归分割 int mid = (right - left) / 2 + left; _MergeSort(nums, temp, left, mid); _MergeSort(nums, temp, mid + 1, right); int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = left; //归并 while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] > nums[begin2]) temp[index++] = nums[begin2++]; else temp[index++] = nums[begin1++]; } while (begin1 <= end1) temp[index++] = nums[begin1++]; while (begin2 <= end2) temp[index++] = nums[begin2++]; //将temp暂时存储的数据覆盖待排序列nums原有位置的数据(拷贝回去),实现待排序列区间有序 for (int i = left; i <= right; i++) nums[i] = temp[i]; } void MergeSort(int *nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); _MergeSort(nums, temp, 0, numsSize - 1); free(temp); }
7.1 非递归
- 我们可以直接用循环解决问题,如图所示:
- 由上面的递归分析可以知道,两个单个数字可以直接合并成一个有序序列。因此我们定义gap,表示每次合并的两个序列长度为gap,gap从1递增,直到不能满足条件
gap < numsSize
,然后就进行和递归相同的合并操作就可以了
void MergeSort(int* nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); int gap = 1; while (gap < numsSize) { /* 因为每次是对两个有序序列进行合并 因此每次合并过后i应该跳过两个序列长度 */ for (int i = 0; i < numsSize; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; int index = begin1; //归并 while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] < nums[begin2]) temp[index++] = nums[begin1++]; else temp[index++] = nums[begin2++]; } while(begin1 <= end1) temp[index++] = nums[begin1++]; while(begin2 <= end2) temp[index++] = nums[begin2++]; //将temp暂时存储的数据覆盖待排序列nums原有位置的数据(拷贝回去),实现待排序列区间有序 for (int j = i; j <= end2; j++) nums[j] = temp[j]; } gap *= 2; } free(temp); }
处理边界情况:
- 看起来好像很简单,但上面的代码仍存在些许bug,仍需要我们谨慎处理
- 我们来看一个情况,如果给的待排数组是
{5,4,3,2,9,7,1,6,8}
- 如果给的待排数组是
{5,4,3,2,9,7
int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; int index = begin1; /* 如果右半区间不存在,只有左半区间 说明待合并的只有一个区间 显然没有合并的必要,直接退出合并循环即可 */ if (begin2 >= numsSize) break; //如果右半区间算多了,那么对end2进行修正 if (end2 >= numsSize) end2 = numsSize - 1;
实现代码:
void MergeSort(int* nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); int gap = 1; while (gap < numsSize) { /* 因为每次是对两个有序序列进行合并 因此每次合并过后i应该跳过两个序列长度 */ for (int i = 0; i < numsSize; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; int index = begin1; /* 如果右半区间不存在,只有左半区间 说明待合并的只有一个区间 显然没有合并的必要,直接退出合并循环即可 */ if (begin2 >= numsSize) break; //如果右半区间算多了,那么对end2进行修正 if (end2 >= numsSize) end2 = numsSize - 1; //归并 while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] < nums[begin2]) temp[index++] = nums[begin1++]; else temp[index++] = nums[begin2++]; } while(begin1 <= end1) temp[index++] = nums[begin1++]; while(begin2 <= end2) temp[index++] = nums[begin2++]; //将temp暂时存储的数据覆盖待排序列nums原有位置的数据(拷贝回去),实现待排序列区间有序 for (int j = i; j <= end2; j++) nums[j] = temp[j]; } gap *= 2; } free(temp); }
时间复杂度:
- 易得,合并两个有序序列的时间复杂度为O(N)
- 由于是对待排序列的不断二分,知道分割为不可分割的子序列,因此这一过程的时间复杂度为O(logN)
- 因此归并排序的时间复杂度为O(NlogN)
8 计数排序
以升序排序为例
什么是计数排序:
- 计数排序是一个基于非比较的排序算法,该算法于1954年由 Harold H. Seward 提出
- 基本思想:是对于给定的输入序列中的每一个元素x,确定该序列中值小于x的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上。例如,如果输入序列中只有17个元素的值小于x的值,则x可以直接存放在输出序列的第18个位置上。当然,如果有多个元素具有相同的值时,我们不能将这些元素放在输出序列的同一个位置上,因此,上述方案还要作适当的修改。——百度百科
实现思路:
- 根据待排序列的最大值和最小值,向系统申请一块空间
- 空间内每个位置就代表着待排序列最小值到最大值每个数据的映射
- 遍历待排序序列,统计每个数据出现的次数,并记录于申请的空间内
- 最后根据空间中记录的每个数据出现的次数,实现对待排序列的排序
具体步骤:
- 我们以数组
{101,105,102,107,105,106}
为例 - 首先,我们遍历整个乱序序列,找到最大值max,最小值min,从而求出待排序列的数据范围range
for (int i = 0; i < numsSize; i++) { if (nums[i] > max) max = nums[i]; if (nums[i] < min) min = nums[i]; } int range = max - min + 1;
- 向内存中申请
range * sizeof(数据类型)
大小的空间,并将其初始化为0(这块空间记录的是range范围内每个数据出现的次数,因此初始值为0)
int* count = (int*)malloc(sizeof(int) * range); //申请内存 memset(count, 0, range * sizeof(int)); //初始化为0
- 遍历乱序序列,记录每个数据出现的次数,并记录到申请的空间中
- 那么问题来了,我们怎么得到每个数据在申请的空间中的位置呢?由上面的分析可以得到吗,空间的第一个位置即下标0处代表着最小值min(例子中的101),102就是101后面的一个数,下标就是102-101 = 1,103就是101后面的第二个数,下标就是103-101 = 2…………,因此数据nums[i]在count中的位置就是
nums[i] - min
for (int i = 0; i < numsSize; i++) count[nums[i] - min]++;
- 由于申请的空间是range范围内从小到大的映射,这已经是一个升序序列,因此最后我们只需要根据空间中记录的每个数据出现的次数,将这些数据重新按顺序放到原序列中就可以实现升序排序了。
int cur = 0; for (int i = 0; i < range; i++) { while (count[i]--) nums[cur++] = i + min; }
动图展示:
实现代码:
void CountSort(int* nums, int numsSize) { int max = nums[0]; int min = nums[0]; for (int i = 0; i < numsSize; i++) { if (nums[i] > max) max = nums[i]; if (nums[i] < min) min = nums[i]; } int range = max - min + 1; int* count = (int*)malloc(sizeof(int) * range); memset(count, 0, range * sizeof(int)); for (int i = 0; i < numsSize; i++) count[nums[i] - min]++; int cur = 0; for (int i = 0; i < range; i++) { while (count[i]--) nums[cur++] = i + min; } free(count); }
时间复杂度:
- 计数排序的时间复杂度为:O(N + K),其中N为待排序列元素个数,K为待排序列数据范围(即range)
- 是线性的时间复杂度,快于任何比较排序算法
- 我们可以来看看对于排序一亿个数,它和快速排序所耗时间(单位为毫秒)的比较
- 由此可见,对于整数的排序,计数排序的强大性
局限性:
- 计数排序只能对整数排序
- 如果数据范围range过大,那么不适合用计数排序来排
- 例如,要对5个数排序,但这20个数的数据范围是一千万,这样像内存申请的空间就会很大,从而造成空间浪费
- 当
range > NlogN
时,计数排序的效率反而不如基于比较的排序算法,因为堆排序、希尔排序、快速排序、归并排序等排序时间复杂度的理论下限为O(NlogN)
9 八大排序稳定性分析
9.1 什么是稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
9.2 直接插入排序
- 由于我们规定区间[0,end]内都是有序的只有满足nums[end + 1] < nums[end](即end前面的数)时,nums[end + 1]才会向前移动,因此不存在相等的数被交换位置的情况。
- 即直接插入排序时稳定的
9.3 希尔排序
- 千万不要以为希尔排序是直接插入排序的优化,那希尔排序就是稳定的了
- 由于希尔排序存在将间隔为gap的数据分成一组的操作,那我们就不能保证相等的数字会被分在同一组,也就不能保证预排序时相等的数字不会被打乱顺序
- 因此希尔排序是不稳定的
9.4 直接选择排序
- 如果用的方法是未改进的方法(即从左往右选,每次只选一个数),那么直接选择排序是稳定的
- 而如果用的是改进后的方法(即逐渐缩小区间,每次选两个数),那么直接选择排序就是不稳定的,如图
- 因此,直接选择排序可能是稳定的也可能是不稳定的
9.5 堆排序
- 如图所示
- 即堆排序是不稳定的
9.6 冒泡排序
- 冒泡的过程中,只有满足nums[j + 1] < nums[j]才会发生交换,因此不会出现两个相等的数被交换位置的情况。
- 即冒泡排序是稳定的
9.7 快速排序
- 如图所示,用左右指针法实现快速排序
- 可见,快速排序是不稳定的。
9.8 归并排序
- 归并排序是通过对待排序列不断二分至不可分割的子序列,然后进行合并,这期间不会改变相等的元素的相对位置
- 因此,归并排序是稳定的
9.9 计数排序
- 计数排序是基于非比较的排序,自然就不存在数据之间的交换,也就不会改变相等元素的相对位置。
- 即,计数排序是稳定的
10 总结
排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 |
直接插入排序 | O(N^2) | O(1) | 稳定 |
希尔排序 | O(NLogN) | O(1) | 不稳定 |
直接选择排序 | O(N^2) | O(1) | 不确定 |
堆排序 | O(NLogN) | O(1) | 不稳定 |
冒泡排序 | O(N^2) | O(1) | 稳定 |
快速排序 | O(NLogN) | O(LogN)~O(N) | 不稳定 |
归并排序 | O(NLogN) | O(N) | 稳定 |
计数排序 | O(N) | O(N) | 稳定 |
11 参考代码
#include<stdio.h> #include<stdlib.h> #include<time.h> #include<stdbool.h> #include<assert.h> #define N 100000 /*****************************实现栈*****************************/ typedef int StackDataType; typedef struct stack { StackDataType* stack; int top; }ST; void StackInit(ST* st) { st->stack = (int*)malloc(sizeof(int) * N); st->top = 0; } bool StackEmpty(ST* st) { return st->top == 0; } void StackPush(ST* st, StackDataType val) { st->stack[st->top++] = val; } void StackPop(ST* st) { assert(!StackEmpty(st)); st->top--; } StackDataType StackTop(ST* st) { assert(!StackEmpty(st)); return st->stack[st->top - 1]; } /*****************************交换两数*****************************/ void Swap(int* num1, int* num2) { int temp = *num1; *num1 = *num2; *num2 = temp; } /*****************************直接插入排序*****************************/ void InsertSort(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { int end = i; int temp = nums[end + 1]; while (end >= 0) { if (temp < nums[end]) { nums[end + 1] = nums[end]; end--; } else break; } nums[end + 1] = temp; } } /*****************************希尔排序*****************************/ void ShellSort(int* nums, int numsSize) { int gap = numsSize / 2; while (gap >= 1) { for (int i = 0; i < numsSize - gap; i++) { int end = i; int temp = nums[end + gap]; while (end >= 0) { if (temp < nums[end]) { nums[end + gap] = nums[end]; end -= gap; } else break; } nums[end + gap] = temp; } gap /= 2; } } /*****************************直接选择排序(未改进)*****************************/ void SelectSort_One(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { for (int j = i + 1; j < numsSize; j++) { if (nums[i] > nums[j]) Swap(&nums[i], &nums[j]); } } } /*****************************直接选择排序(改进)*****************************/ void SelectSort_Two(int* nums, int numsSize) { int begin = 0; int end = numsSize - 1; while (begin < end) { int max_index = begin; int min_index = begin; for (int i = begin; i <= end; i++) { if (nums[i] > nums[max_index]) max_index = i; if (nums[i] < nums[min_index]) min_index = i; } Swap(&nums[begin], &nums[min_index]); if (max_index == begin) max_index = min_index; Swap(&nums[end], &nums[max_index]); begin++; end--; } } /*****************************堆排序*****************************/ //ChildLeft = 2 * Parent + 1 //ChildRight = 2 * Parent + 2 //Parent = (Child - 1) / 2 //向下调整算法 void AdjustDown(int* nums, int numsSize, int root) { int parent = root; int child_max = parent * 2 + 1; while (child_max < numsSize) { if (child_max + 1 < numsSize && nums[child_max] < nums[child_max + 1]) child_max++; if (nums[child_max] > nums[parent]) Swap(&nums[child_max], &nums[parent]); else break; parent = child_max; child_max = parent * 2 + 1; } } void HeapSort(int* nums, int numsSize) { //建堆 int root = (numsSize - 1 - 1) / 2; for (int i = root; i >= 0; i--) AdjustDown(nums, numsSize, i); //排序 while (numsSize) { Swap(&nums[0], &nums[numsSize - 1]); AdjustDown(nums, --numsSize, 0); } } /*****************************冒泡排序*****************************/ void BubbleSort(int* nums, int numsSize) { for (int i = 0; i < numsSize - 1; i++) { int flag = 1; for (int j = 0; j < numsSize - i - 1; j++) { if (nums[j] > nums[j + 1]) { Swap(&nums[j], &nums[j + 1]); flag = 0; } } if (flag == 1) break; } } /*****************************快速排序*****************************/ //三数取中 int FindMid(int *nums, int left, int right) { int mid = (right - left) / 2 + left; if (nums[left] < nums[mid]) { if (nums[right] > nums[mid]) return mid; else if (nums[right] < nums[left]) return left; else return right; } else //nums[mid] < nums[left] { if (nums[right] > nums[left]) return left; else if (nums[right] < nums[mid]) return mid; else return right; } } //挖坑法 int PartSort_One(int* nums, int left, int right) { int begin = left; int end = right; int privot = begin; int index = FindMid(nums, begin, end); Swap(&nums[begin], &nums[index]); int key = nums[begin]; while (begin < end) { while (begin < end && nums[end] >= key) end--; nums[privot] = nums[end]; privot = end; while (begin < end && nums[begin] <= key) begin++; nums[privot] = nums[begin]; privot = begin; } nums[privot] = key; return privot; } //左右指针法 int PartSort_Two(int* nums, int left, int right) { int begin = left; int end = right; int index = FindMid(nums, begin, end); Swap(&nums[begin], &nums[index]); int key = nums[begin]; while (begin < end) { while (begin < end && nums[end] >= key) end--; while (begin < end && nums[begin] <= key) begin++; Swap(&nums[end], &nums[begin]); } Swap(&nums[left], &nums[end]); return end; } //前后指针法、 int PartSort_Three(int* nums, int left, int right) { int index = FindMid(nums, left, right); Swap(&nums[left], &nums[index]); int key = nums[left]; int prev = left; int cur = prev + 1; while (cur <= right) { if (nums[cur] < key && ++prev != cur) Swap(&nums[cur], &nums[prev]); cur++; } Swap(&nums[left], &nums[prev]); return prev; } //递归 void QuickSort(int* nums, int left, int right) { if (left >= right) return; /*int keyindex = PartSort_One(nums, left, right);*/ /*int keyindex = PartSort_Two(nums, left, right);*/ int keyindex = PartSort_Three(nums, left, right); QuickSort(nums, left, keyindex - 1); QuickSort(nums, keyindex + 1, right); } //非递归 void QuickSort_NoRecursive(int* nums, int numsSize) { ST* st = (ST*)malloc(sizeof(ST)); StackInit(st); StackPush(st, numsSize - 1); StackPush(st, 0); while (!StackEmpty(st)) { int begin = StackTop(st); StackPop(st); int end = StackTop(st); StackPop(st); int key_index = PartSort_One(nums, begin, end); if (key_index - begin > 0) { StackPush(st, key_index - 1); StackPush(st, begin); } if (end - key_index > 0) { StackPush(st, end); StackPush(st, key_index + 1); } } free(st); } /*****************************归并排序*****************************/ void _MergeSort(int* nums, int* temp, int left, int right) { if (left >= right) return; int mid = (right - left) / 2 + left; _MergeSort(nums, temp, left, mid); _MergeSort(nums, temp, mid + 1, right); int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = left; while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] < nums[begin2]) temp[index++] = nums[begin1++]; else temp[index++] = nums[begin2++]; } while (begin1 <= end1) temp[index++] = nums[begin1++]; while (begin2 <= end2) temp[index++] = nums[begin2++]; for (int i = left; i <= right; i++) nums[i] = temp[i]; } //递归 void MergeSort(int* nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); _MergeSort(nums, temp, 0, numsSize - 1); free(temp); } //非递归 void MergeSort_NoRecursive(int* nums, int numsSize) { int* temp = (int*)malloc(sizeof(int) * numsSize); int gap = 1; while (gap < numsSize) { for (int i = 0; i < numsSize; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; int index = begin1; if (end2 >= numsSize) end2 = numsSize - 1; if (begin2 >= numsSize) break; while (begin1 <= end1 && begin2 <= end2) { if (nums[begin1] < nums[begin2]) temp[index++] = nums[begin1++]; else temp[index++] = nums[begin2++]; } while (begin1 <= end1) temp[index++] = nums[begin1++]; while (begin2 <= end2) temp[index++] = nums[begin2++]; for (int j = i; j <= end2; j++) nums[j] = temp[j]; } gap *= 2; } free(temp); } /*****************************计数排序*****************************/ void CountSort(int* nums, int numsSize) { int min = nums[0]; int max = nums[0]; for (int i = 0; i < numsSize; i++) { if (nums[i] > max) max = nums[i]; if (nums[i] < min) min = nums[i]; } int range = max - min + 1; int* Count = (int*)malloc(sizeof(int) * range); memset(Count, 0, range * sizeof(int)); for (int i = 0; i < numsSize; i++) Count[nums[i] - min]++; int index = 0; for (int i = 0; i < range; i++) { while (Count[i]--) nums[index++] = i + min; } } /*****************************打印数组*****************************/ void PrintArr(int* nums, int numsSize) { for (int i = 0; i < numsSize; i++) printf("%d ", nums[i]); printf("\n"); } /*****************************测试*****************************/ void TextInsertSort() { int *nums = (int *)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); InsertSort(nums, N); int end_time = clock(); printf("InsertSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextShellSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); ShellSort(nums, N); int end_time = clock(); printf("ShellSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextSelectSort_One() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); SelectSort_One(nums, N); int end_time = clock(); printf("SelectSort_One : %d/ms\n", end_time - begin_time); // PrintArr(nums, len); } void TextSelectSort_Two() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); SelectSort_Two(nums, N); int end_time = clock(); printf("SelectSort_Two : %d/ms\n", end_time - begin_time); // PrintArr(nums, len); } void TextHeapSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); HeapSort(nums, N); int end_time = clock(); printf("HeapSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextBubbleSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); BubbleSort(nums, N); int end_time = clock(); printf("BubbleSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextQuickSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); QuickSort(nums, 0, N - 1); int end_time = clock(); printf("QuickSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextQuickSort_NoRecursive() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); QuickSort_NoRecursive(nums, N); int end_time = clock(); printf("QuickSort_NoRecursive : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextMergeSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); MergeSort(nums, N); int end_time = clock(); printf("MergeSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextMergeSort_NoRecursive() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); MergeSort_NoRecursive(nums, N); int end_time = clock(); printf("MergeSort_NoRecursive : %d\n", end_time - begin_time); // PrintArr(nums, len); } void TextCountSort() { int* nums = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; i++) nums[i] = rand() % N + 1; int begin_time = clock(); CountSort(nums, N); int end_time = clock(); printf("CountSort : %d\n", end_time - begin_time); // PrintArr(nums, len); } /*****************************主函数*****************************/ int main() { srand((unsigned int)time(NULL)); TextInsertSort(); TextShellSort(); TextSelectSort_One(); TextSelectSort_Two(); TextHeapSort(); TextBubbleSort(); TextQuickSort(); TextQuickSort_NoRecursive(); TextMergeSort(); TextMergeSort_NoRecursive(); TextCountSort(); return 0; }
12 八大排序效率比较
对于十万个整数,各排序消耗的时间(毫秒):
13 结语
由于本片篇幅较长,难免会有疏忽错误的地方,欢迎各位指出,斧正;以及,如果大家对文章格式、排版有什么建议,也欢迎私信,博主会不断改进。
这篇博客可以说是博主迄今为止投入时间最长的一篇博客了,如果觉得这篇文章对你有所帮助的话,还请不要吝啬你的点赞、收藏加关注呀!
最后,博主仍会继续学习,不断创作出更加优质的内容供大家参考,学习。
共勉!!!( ̄y▽ ̄)╭ Ohohoho.....