排序算法终极篇之手撕常见排序算法

2023-10-25 04:59

本文主要是介绍排序算法终极篇之手撕常见排序算法,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

  

文章目录

引入

一、插入排序

1、1 插入排序的实现思想

1、2 插入排序的代码实现及特点分析 

二、希尔排序

2、1 希尔排序的实现思想

2、2 希尔排序的代码实现及特点分析 

三、选择排序

3、1 选择排序的实现思想

3、2 选择排序的代码实现及特点分析

四、堆排序

五、冒泡排序

六、快速排序

6、1 快速排序递归形式实现

6、2 快速排序的非递归形式实现

6、2、1 快速排序非递归形式的实现思想

6、2、2 快速排序非递归形式的代码实现 

七、归并排序

7、1 递归实现归并排序

7、2 归并排序非递归实现 

7、2、1 归并排序非递归形式的实现思想

7、2、2 归并排序非递归形式的实现代码及边界处理

总结


🙋‍♂️ 作者:@Ggggggtm 🙋‍♂️

👀 专栏:数据结构与 👀

💥 标题:排序算法 💥

 ❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️

  本编文章详解常见七大大排序(插入排序、希尔排序、选择排序、冒泡排序、堆排序、快速排序、归并排序),其中快速排序和归并排序递归实现是我们常见的思路,但是非递归实现的情况相对少见,理解起来也会有点难度,本篇文章会给出详解!

引入

  排序算法重要吗?排序算法有什么用呢?我给大家举一个例子先看一下:

  我想网上购物是平常很常见的事情了吧。我们为了更好的选择出自己理想的物品,我们通常会根据物品的价格或者销量等条件进行筛选。那么这就是一个排序

  还有我们经常说到的大学的好坏:

  大学之间的排名也会是根据很多指标进行综合排序来定名次的。在日常生活中,用到的排序的例子还有很多很多,也间接体现出来排序的重要性。我们应该熟练的掌握常见排序的细节及其用法。  上图即为我们要详解的七大常见排序,我们接下来进入整体。

一、插入排序

1、1 插入排序的实现思想

  插入排序是我们在生活中经常见的一种排序。例如玩扑克牌时,我们一张一张的抽取扑克牌,每抽取一张,我们自己会对它进行排序,放到合适的位置

   这也是插入排序的基本思想把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。

  我们可结合下图理解:

  从上图中,我们再详细分析一下插入排序的实现思路:

  1. 首先第一个数据不用进行比较,当只有一个数据时,我们可以理解为是有序的;
  2. 选取下一个数据,看是否大于前一个元素。如果大于前一个元素,我们直接可以把该数据放到前一个元素的后面。如果小于前一个元素,我们需要把前一个元素后移一位。
  3. 每插入一个新的元素,我们需要比较的时前面已经有序的所有数据,直到找到大于前一个元素数据即可停下,否则将会放在首位。
  4. 每插入一个元素,重复上述操作即可完成有序。

  上述即为插入排序的整个实现的过程,我们接下来看一下插入排序的代码实现。

1、2 插入排序的代码实现及特点分析 

void InsertSort(int* a, int n)
{for (int i = 0; i < n-1; i++){int end = i;int tmp = a[i + 1];while (end >= 0){if (a[end] > tmp){a[end + 1] = a[end];end--;}else{break;}}a[end + 1] = tmp;}
}

  我们学完插入排序后不难发现,正常情况下插入排序的时间复杂度为O(N^2)。只有极少的特殊情况(有序)的实现复杂度为O(N),效率并不是太理想。

  直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高;
  2. 时间复杂度:O(N^2);
  3. 空间复杂度:O(1),它是一种稳定的排序算法;
  4. 稳定性:稳定

  我们还可对插入排序进行优化码?答案是可以的。下面我们看希尔排序,就是在插入排序上进行了优化。

二、希尔排序

2、1 希尔排序的实现思想

  通过上述的插入排序特性元素集合越接近有序,直接插入排序算法的时间效率越高,我们想到:是否在对改组数据排序前进行预排序呢?通过预排序是的数组达到接近有序的状态呢?希尔排序因此诞生了。

  希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,重复上述分组和排序的工作。当到达所取数=1时,所有记录在统一组内排好序

  具体思路可结合下图理解: 

  那gap的大小怎么选呢?当gap越大时,大的数据往后跳跃的越块,但是排完后并没有那么有序。当gap越小时,大的数据往后跳跃的越慢,但是排完后相对已经很有序了。所以在这里我们gap的取法就是从数组的大小折半开始,一直折半,直到为1时,排完后有序就停止。当然,也就可以除以3,但是要注意其中的细节,最后一趟排序gap必为1

  这里就会有一些疑问:上述的每一趟以gap为一组的数据排序都是用插入排序的思想实现的,那时间复杂度不是会更高吗?我们先看希尔排序的代码实现。

2、2 希尔排序的代码实现及特点分析 

void ShellSort(int* a, int n)
{//以gap为3的数据一组的预排序代码,该排序分3趟/*int gap = 3;for (int j = 0; j <gap; j++){for (int i = j; i < n - gap; i += gap){int end = i;int tmp = a[i + gap];while (end >= 0){if (tmp < a[end]){a[end + gap] = a[end];end -= gap;}else{break;}}a[end + gap] = tmp;}}*///希尔排序//n!=1的情况,全为预排序int gap = n;while (gap > 1){gap /= 2;//同时对所有以gap为一组的进行排序for (int i = 0; i < n - gap; i ++){int end = i;int tmp = a[i + gap];while (end >= 0){if (tmp < a[end]){a[end + gap] = a[end];end -= gap;}else{break;}}a[end + gap] = tmp;}}
}

   通过上述代码,我们这里是根据插入排序特性元素集合越接近有序,直接插入排序算法的时间效率越高进行的预排序。当gap为1时,代码与插入排序的代码相同,但是此时的数据已经几乎接近有序,并不需要消耗太多,时间复杂度接近O(N)。

  希尔排序的特性总结:

  1.  希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定:
  4. 稳定性:不稳定。

三、选择排序

3、1 选择排序的实现思想

  选择排序的实现就比较简单了。选择排序的基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。也就是我们需要进行n趟寻找,每趟去寻找未排序的最小(或最大)元素完成排序。

  我们结合下图一起理解一下:

  我们直接看代码的实现:

3、2 选择排序的代码实现及特点分析

void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}void SelectSort(int* a, int n)
{int left = 0, right = n - 1;while (left < right){int maxi = left, mini = right;for (int i = left; i <= right; i++){if (a[i] > a[maxi]){maxi = i;}if (a[i] < a[mini]){mini = i;}}Swap(&a[left], &a[mini]);//如果左边界与最大的数重合,交换左边界与最小的值后,最大值的位置下标变为交换后的下标if (left == maxi){maxi = mini;}Swap(&a[right], &a[maxi]);left++;right--;}
}
  直接选择排序的特性总结:
  1.  直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用;
  2.  时间复杂度:O(N^2);
  3. 空间复杂度:O(1);
  4. 稳定性:不稳定

四、堆排序

  堆排序,我们在之前的文章中(重点算法排序之堆排序)详细讲述了其实现的细节。我们可以直接去参考学习一下。

五、冒泡排序

  我们之前的文章也有对冒泡排序(冒泡排序详解)的详解,需要学习的也可去参考一下。

六、快速排序

6、1 快速排序递归形式实现

  重点算法排序之快速排序详解了快速排序的三种递归实现的方法,可以直接参考学习。

6、2 快速排序的非递归形式实现

6、2、1 快速排序非递归形式的实现思想

  通过学习了递归形式实现的快速排序后,我们了解到递归实现其实就是采用了分治的思想 。但是当数据量特别大、递归层数较多时,有可能会造成栈溢出(爆栈),导致进程无法正常进行。这里也可采用小区间优化方法,但是我们想能不能采用非递归的方式去实现快排呢?如果不递归,采用循环的方式同样不会爆栈,而且更加安全。答案是可以的。

  我们首先需要去分析递归方式实现快排的一些性质:

  • 每次递归前我们会先选出一个关键数据,作为我们的keyi来划分左右区间;
  • 每次的递归是去递归的左子区间和右子区间,对左子区间和右子区间进行排序划分;
  • 当递归到左子区间和右子区间不存在时,就停止递归;
  • 当递归到左子区间和右子区间只有一个数据时,我们认为它就是有序的,停止递归。

  通过上面分析,我们发现每次递归都是需要明确知道左右子区间下标的。于是在这里我们 就想到如果采用非递归,我们需要把所有的左右子区间下标保存下来。对其进行划分排序倒不难,主要是怎么保存起来左右子区间的下标呢?

   当我们自己去画一下递归实现快排的展开图时我们会发现到,递归完左子区间才会去递归右子区间。每递归一个区间,会划分出新的两个区间。我们这时候想到用栈去存储区间下标。最初我们存储进去其实的下标,再去循环划分排序左右子区间,并且把左右子区间把左右子区间的下标加入栈中。循环时,从栈中去取出下标。当左右子区间不存在时,或者只有一个数据时,我们认为它就是有序的,不再加入栈中。直到栈为空时,循环停止。我们结合下面代码一起理解一下。

6、2、2 快速排序非递归形式的代码实现 

int PartSort(int* a, int left, int right)
{if (left >= right)return;//int randi = left + (rand() % (right - left));int midi = MidKey(a, left, right);if (midi != left)Swap(&a[left], &a[midi]);int keyi = left;int begin = left, end = right;while (left < right){//如果是从左边界为分界点,那么必须从右边开始找,以确定right==left的位置为较小的哪一个//右边找小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]);keyi = left;return keyi;
}
void QuickSortNoCir(int* a, int left, int right)
{ST st;  //建立一个栈,这里用C语言实现,引用了已实现栈的头文件,在C++中,可直接用STL中的容器STInit(&st);STPush(&st, left);STPush(&st, right);while (!STIsEmpty(&st)){int right = STTop(&st);STPop(&st);int left = STTop(&st);STPop(&st);int keyi = PartSort(a, left, right);if (keyi + 1 < right){STPush(&st, keyi + 1);STPush(&st,  right);}if (keyi - 1 > left){STPush(&st, left);STPush(&st, keyi - 1);}}STDestory(&st);
}

  这里的一个区间的排序与递归实现的排序相同,我们在这里只不过是把它单独划分为一个函数。 

七、归并排序

7、1 递归实现归并排序

  重点算法排序之归并排序 对归并排序进行了详解,可参考学习。

7、2 归并排序非递归实现 

7、2、1 归并排序非递归形式的实现思想

  归并排序的非递归实现又该怎么实现呢?当我们了解了递归实现后,递归实现是先将整个区间划分为左右两个大区间,接着不断划分子区间,直到一个区间的大小为1开始归并的。于是我们非递归实现也可从一个区间的大小为1开始归并。当区间为1时,也就只有一个数据就被视为有序,所以就可以开始归并。当1个数与1个数归并完后,相当于两个数的区间是有序的,就开始归并两个数据的区间以此类推,最终将整个区间归并为有序区间。大致过程可参考下图:

  这样考虑似乎并没有什么错。但是当数组的元素个数为9个呢?我们打印出归并的区间看看:

  我们发现确实有很多越界的情况。因此,我们要对越界的情况进行特殊处理。我们先来开归并排序非递归实现的代码,在对越界情况处理进行解释。

7、2、2 归并排序非递归形式的实现代码及边界处理

void MergeSortNoCir(int* a, int left, int right)
{int* tmp = (int *)malloc(sizeof(int) * (right - left + 1));if (tmp == NULL){perror("malloc failed");return;}int n = right - left + 1;int gap = 1;while (gap < n){for(int i = 0; i < n;i += 2*gap ){int begin1 = i, end1 = i + gap - 1;int begin2 = i + gap, end2 = i + 2 * gap - 1;if (begin2 >= n){break;}else if (end2 >= n){end2 = n - 1;}int j = 0;while (begin1 <= end1 && begin2 <= end2){if (a[begin1] < a[begin2]){tmp[j++] = a[begin1];begin1++;}else{tmp[j++] = a[begin2];begin2++;}}while (begin1 <= end1){tmp[j++] = a[begin1];begin1++;}while (begin2 <= end2){tmp[j++] = a[begin2];begin2++;}memcpy(a + i, tmp, sizeof(int)*(end2 - i + 1));}gap *= 2;}free(tmp);
}

  在这里我们把区间的边界划分为[ begin1 ] [ end1 ] - [ begin2 ] [ end2 ]。首先,begin1 是不可能越界的,因为我们是把 i 的值 begin1。然后我们再分情况处理越界的问题:

  • end1 越界,就不再动该组数据(break);
  • begin2 越界,也是不再动该组数据(break);
  • end2 越界,begin2 没有越界。修改end2,再归并改组数据。

   上述情况中,end1和begin2越界为什么就不在动该组数据呢?归并的前提是该组数据有左右两个有序子区间,只有一个区间是不用归并的。因为一但end1和begin2越界,该组数据就不能够进行归并。越界情况的数据就在原数组中没有动,也就是并没有归并到tmp数组中,所以也不用再拷贝回去。 

  end1越界的情况,begin2也一定越界。但是end1不越界的话,begin2也有可能越界。如果end1不越界,同时begin2也不越界,就进入到了else if (end2 >= n)的情况。所以,在这里我们发现,不用判断end1的情况也是可以的。

  我们再来看加入越界判断后的区间划分:

  通过上述划分区间,我们发现不会再出现区间越界的情况,同时也不会漏掉任何区间。

总结

  排序总体难度还是比较大的,也是重点掌握内容,尤其是快速排序和归并排序。在这里给大家总结出一张关于排序的表格:   大家也可借助此表格来理解和记忆排序算法。本篇文章的讲解就到这里,希望以上内容会对你有所帮助,感谢观看ovo~ 

这篇关于排序算法终极篇之手撕常见排序算法的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/280187

相关文章

MySQL 中的 CAST 函数详解及常见用法

《MySQL中的CAST函数详解及常见用法》CAST函数是MySQL中用于数据类型转换的重要函数,它允许你将一个值从一种数据类型转换为另一种数据类型,本文给大家介绍MySQL中的CAST... 目录mysql 中的 CAST 函数详解一、基本语法二、支持的数据类型三、常见用法示例1. 字符串转数字2. 数字

Java中的雪花算法Snowflake解析与实践技巧

《Java中的雪花算法Snowflake解析与实践技巧》本文解析了雪花算法的原理、Java实现及生产实践,涵盖ID结构、位运算技巧、时钟回拨处理、WorkerId分配等关键点,并探讨了百度UidGen... 目录一、雪花算法核心原理1.1 算法起源1.2 ID结构详解1.3 核心特性二、Java实现解析2.

Python中win32包的安装及常见用途介绍

《Python中win32包的安装及常见用途介绍》在Windows环境下,PythonWin32模块通常随Python安装包一起安装,:本文主要介绍Python中win32包的安装及常见用途的相关... 目录前言主要组件安装方法常见用途1. 操作Windows注册表2. 操作Windows服务3. 窗口操作

ModelMapper基本使用和常见场景示例详解

《ModelMapper基本使用和常见场景示例详解》ModelMapper是Java对象映射库,支持自动映射、自定义规则、集合转换及高级配置(如匹配策略、转换器),可集成SpringBoot,减少样板... 目录1. 添加依赖2. 基本用法示例:简单对象映射3. 自定义映射规则4. 集合映射5. 高级配置匹

深度解析Python装饰器常见用法与进阶技巧

《深度解析Python装饰器常见用法与进阶技巧》Python装饰器(Decorator)是提升代码可读性与复用性的强大工具,本文将深入解析Python装饰器的原理,常见用法,进阶技巧与最佳实践,希望可... 目录装饰器的基本原理函数装饰器的常见用法带参数的装饰器类装饰器与方法装饰器装饰器的嵌套与组合进阶技巧

Mysql常见的SQL语句格式及实用技巧

《Mysql常见的SQL语句格式及实用技巧》本文系统梳理MySQL常见SQL语句格式,涵盖数据库与表的创建、删除、修改、查询操作,以及记录增删改查和多表关联等高级查询,同时提供索引优化、事务处理、临时... 目录一、常用语法汇总二、示例1.数据库操作2.表操作3.记录操作 4.高级查询三、实用技巧一、常用语

python 常见数学公式函数使用详解(最新推荐)

《python常见数学公式函数使用详解(最新推荐)》文章介绍了Python的数学计算工具,涵盖内置函数、math/cmath标准库及numpy/scipy/sympy第三方库,支持从基础算术到复杂数... 目录python 数学公式与函数大全1. 基本数学运算1.1 算术运算1.2 分数与小数2. 数学函数

一文详解Java Stream的sorted自定义排序

《一文详解JavaStream的sorted自定义排序》Javastream中的sorted方法是用于对流中的元素进行排序的方法,它可以接受一个comparator参数,用于指定排序规则,sorte... 目录一、sorted 操作的基础原理二、自定义排序的实现方式1. Comparator 接口的 Lam

SpringBoot开发中十大常见陷阱深度解析与避坑指南

《SpringBoot开发中十大常见陷阱深度解析与避坑指南》在SpringBoot的开发过程中,即使是经验丰富的开发者也难免会遇到各种棘手的问题,本文将针对SpringBoot开发中十大常见的“坑... 目录引言一、配置总出错?是不是同时用了.properties和.yml?二、换个位置配置就失效?搞清楚加

HTML中meta标签的常见使用案例(示例详解)

《HTML中meta标签的常见使用案例(示例详解)》HTMLmeta标签用于提供文档元数据,涵盖字符编码、SEO优化、社交媒体集成、移动设备适配、浏览器控制及安全隐私设置,优化页面显示与搜索引擎索引... 目录html中meta标签的常见使用案例一、基础功能二、搜索引擎优化(seo)三、社交媒体集成四、移动