朋友们、伙计们,我们又见面了,本期来给大家解读一下有关排序算法的相关知识点,如果看完之后对你有一定的启发,那么请留下你的三连,祝大家心想事成!
C 语 言 专 栏:C语言:从入门到精通
数据结构专栏:数据结构
个 人 主 页 :stackY、
目录
前言:
排序无处不在,在生活中我们无时无刻都在间接或者直接的使用排序这个方法,很多复杂的事情在经过排序之后都会变得简单许多。那么,在现阶段我们学习的数据结构一这块排序又该怎么实现呢?
1.排序的概念及其运用
1.1排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。内部排序:数据元素全部放在内存中的排序。外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2排序的应用
比如我们电脑桌面上的应用的排序方式:比如我们网购的时候也可以对商品进行排序:
比如2023中国高校排行榜:
等等都是通过排序来展示出来的,所以可见排序无论是在哪个领域都应用广泛。
1.3常见的排序算法
如果大家想要测试自己的排序算法正确与否可以在这个OJ链接来进行测试:https://leetcode.cn/problems/sort-an-array/
2.排序算法的实现
2.1插入排序
2.1.1基本思想
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列 。这其实就跟我们玩扑克牌一样,摸牌、插入:
2.1.2直接插入排序
当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用 array[i] 的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将 array[i] 插入,原来位置上的元素顺序后移。排升序:简单的总结就是在[0, end]这个区间中插入tmp,从end的位置开始比较,如果tmp小于end这个位置的元素,那么就将end往后面挪,然后与end-1这个元素再作比较(前提是:[0, end] 这个区间的元素有序)。
我们先来写出一趟插入排序的基本逻辑:
排升序:在[0,end]区间插入tmp,先比较tmp与end的大小关系,如果比end小,那么将end往后面挪一个,在用tmp和end-1比较,如果比end-1小,再将end-1往后挪,直到tmp比[0,end]区间中那个元素大,那么就停止,如果tmp比这个区间的所有数都要小,那么直接将tmp放在第一个位置即可。
代码演示:
//直接插入排序 //在[0,end]插入tmp //升序 void InsertSort(int* a, int n) { //一趟插入排序 int end; int tmp; while (end >= 0) { if (a[end] > tmp) { //挪数据 a[end + 1] = a[end]; end--; } else { break; } } //放数据 //走到这里有两种情况: //1.tmp比a[end]大 //2.tmp比a中的任何数都小 a[end + 1] = tmp; }
完成了一趟的插入排序,那么怎么样实现在数组中排序呢?
第一次取数组的第一个元素直接放进去即可,第二次就需要和第一次放进去的元素进行比较,然后排成有序,也就是说我们可以将第一个元素看成有序,然后把后面的依次插入,所以呢,我们可以将已插入的数据看作是手里的手牌,未插入的数据看作牌堆的牌,我们每一次摸一张插入一张,这样子就实现了使用插入排序排序数组
#直接插入排序完整代码:
//直接插入排序 //在[0,end]插入tmp //升序 void InsertSort(int* a, int n) { 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 { break; } } //放数据 //走到这里有两种情况: //1.tmp比a[end]大 //2.tmp比a中的任何数都小 a[end + 1] = tmp; } }
直接插入排序的特性总结:1. 元素集合越接近有序,直接插入排序算法的时间效率越高2. 时间复杂度:O(N^2)(最好的情况可以做到O(N))3. 空间复杂度:O(1),它是一种稳定的排序算法4. 稳定性:稳定
直接插入排序和冒泡排序比较:
通过上面的实现过程还看不出来插入排序的速度,我们可以和之前学过的 冒泡排序作一比较
冒泡排序的时间复杂度也为O(N^2),那么使用它们两个算法分别来排升序一个原本就是升序的数组,那么即便数据一次都不需要挪动,冒泡排序还需要O(N^2),但是直接插入排序只需要O(N)。所以直接插入排序在某种特殊情况下还是蛮快的。
2.1.3希尔排序
希尔排序也被称为缩小增量排序
希尔排序法的基本思想是:1.预排序:接近有序2.插入排序
整体步骤就是确定一个整数值gap,然后间隔为gap,将所有数据分为gap组,对着gap组数据分别进行插入排序,最后再整体来一次插入排序,这个预排序的目的就是为了让数据接近有序,以便减少最后一次插入排序的时间。例如要将:9 8 7 6 5 4 3 2 1 0 排成升序
#预排序
接下来我们就用代码来演示:
我们先不管gap怎么设置,在这里我们就先将gap设置为3
还是使用上面的例子:9 8 7 6 5 4 3 2 1 0 排成升序
1.先将这组数据分为gap组,然后分别对这gap组数据进行插入排序。
①我们先来进行一次排序,这次类比之前的直接插入排序不一样的是:先得保存下一个数据,因为往后面挪动数据会进行覆盖,然后每一次比较的是间隔为gap的数据:
代码演示:
//希尔排序 void ShellSort(int* a, int n) { int gap = 3; //一次插入排序 int end; //保存下一个位置的数据 int tmp = a[end + gap]; //比较、挪动 while (end >= 0) { if (a[end] > tmp) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; }
②接着我们如何将这gap组中的其中一组进行排序呢?
这里要注意一个问题,对其中一组数据进行插入排序时每一次跳过的不是1,而是gap,并且end在往后面走的过程中不能大于n - gap,如果大于n - gap,那么tmp就会造成越界访问。
代码演示:
//希尔排序 void ShellSort(int* a, int n) { 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 { break; } } a[end + gap] = tmp; } }
③进行完其中一组的排序,接下来就要进行gap组分别排序,如果我们仔细观察不难发现,当end为0的时候排序的是第一组,当end为1的时候排序的是第二组,那么我们可以再设置一个循环,以gap为控制循环条件:
代码演示:
//希尔排序 void ShellSort(int* a, int n) { int gap = 3; //循环gap组,对gap组的数据分别进行插入排序 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 { break; } } a[end + gap] = tmp; } } }
上面的代码用了三层循环,看起来是比较复杂的,但是我们可以进行改版一下,大家注意看代码的变化:
//希尔排序 void ShellSort(int* a, int n) { int gap = 3; //对gap组数据分别进行插入排序 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 { break; } } a[end + gap] = tmp; } }
很多老铁在这里看不懂改版之后代码,小编在这里带大家解释一下:首先上面的这两种代码的所需要的时间是完全没有区别的,如果使用三层循环,比较容易理解,改版之后的代码比较难理解,三层循环采用的是分组排序,两层循环采用的是多组并排(当i == 0时排序的是第一组第一个元素,当i == 1时排序的是第二组的第一个元素......以此类推就将这全部的数据排完了),这样解释应该就不难理解。
这里采取自愿,两种方法的效率都是一样,没有区别,按照个人喜欢来自取。
#直接插入排序
当把gap组数据分别插入排序之后,还需要进行最后一次插入排序,那么最直接的方法就是直接在上面的代码的最后面再调用一次普通的插入排序即可,但是这未免有点太麻烦了,所以我们需要从gap下手,我们可以先来比较一下插入排序和希尔排序:
gap在这里决定了每一次的步长,所以:
gap越大,大的数可以更快的到后面,小的数可以更快的到前面,但是数据越不接近有序。
gap越小,大的数和小的数移动的比较缓慢,但是数据在移动完之后越接近有序。
gap == 1时,就是直接插入排序。
在上面排序10个数据的时候gap为3是比较合适的,但是如果要排序10000个数据,gap还是为3的话就有点挫了,所以gap的设定要根据元素个数来进行确定,然后在排序到最后一次时gap得是1,为了完成最后一次的直接插入排序。
#希尔排序完整代码:
//希尔排序 void ShellSort(int* a, int n) { //1. gap > 1:预排序 //2. gap == 1 :直接插入排序 int gap = n; while (gap > 1) { gap = gap / 3 + 1; //对gap组数据分别进行插入排序 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 { break; } } a[end + gap] = tmp; } } }
希尔排序的特性总结:1. 希尔排序是对直接插入排序的优化。2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们后面可以进行性能测试的对比。3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定,在某些大佬的书籍中就提到过:《数据结构 (C 语言版 ) 》 --- 严蔚敏《数据结构 - 用面相对象方法与 C++ 描述》 --- 殷人昆所以呢,希尔排序的时间复杂度是
4. 稳定性:不稳定
3.算法的效率比较
在这里我们来比较我们已学过的冒泡排序、堆排序、直接插入排序和希尔排序的效率,再比较之前我们再来回顾一下冒泡排序和堆排序:
//冒泡排序 void BubbleSort(int* a, int n) { //设置冒泡排序的趟数 for (int i = 0; i < n - 1; i++) { //一趟冒泡排序的次数 bool exchange = false; for (int j = 1; j < n - j; j++) { if (a[j] > a[j + 1]) //如果前面的一个数字大于后面的数字就交换 { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; exchange = true; } } if (exchange == false) { break; } } }
//堆排序 //交换函数 void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } //向下调整 void AdjustDown(int* a, int n, int parent) { //假设左孩子为左右孩子中最小的节点 int child = parent * 2 + 1; while (child < n) //当交换到最后一个孩子节点就停止 { if (child + 1 < n //判断是否存在右孩子 && a[child + 1] < a[child]) //判断假设的左孩子是否为最小的孩子 { child++; //若不符合就转化为右孩子 } //判断孩子和父亲的大小关系 if (a[child] < a[parent]) { Swap(&a[child], &a[parent]); //更新父亲和孩子节点 parent = child; child = parent * 2 + 1; } else { break; } } } //O(N * logN) void HeapSort(int* a, int n) { //建堆--向下调整算法建堆 //时间复杂度为O(N) for (int i = ((n - 1) - 1) / 2; i >= 0; --i) {// 这里的n-1表示最后一个叶子节点 // 最后一个叶子节点的父亲就是: // (n-1)-1/2; AdjustDown(a, n, i); } //O(N * logN) int end = n - 1; while (end > 0) { //交换堆顶和最后一个数据的位置 Swap(&a[0], &a[end]); //向下调整,找次小的 AdjustDown(a, end, 0); end--; } }
接下来我们可以使用一段代码来测试一下上面的这四种算法的效率区别。
采用的思路就是随机生成1w个数据,然后使用这些算法进行排序:
代码演示:
// 测试排序的性能对比 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); for (int i = 0; i < N; ++i) { a1[i] = rand(); a2[i] = a1[i]; a3[i] = a1[i]; a4[i] = a1[i]; } int begin1 = clock(); InsertSort(a1, N); int end1 = clock(); int begin2 = clock(); ShellSort(a2, N); int end2 = clock(); int begin3 = clock(); BubbleSort(a3, N); int end3 = clock(); int begin4 = clock(); HeapSort(a4, N); int end4 = clock(); int begin5 = clock(); printf("InsertSort:%d\n", end1 - begin1); printf("ShellSort:%d\n", end2 - begin2); printf("BubbleSort:%d\n", end3 - begin3); printf("HeapSort:%d\n", end4 - begin4); free(a1); free(a2); free(a3); free(a4); }
在这里测试的时候建议大家使用Release版本,测试效果会更好(这里测试的单位都是毫秒):
朋友们、伙计们,美好的时光总是短暂的,我们本期的的分享就到此结束,最后看完别忘了留下你们弥足珍贵的三连喔,感谢大家的支持!