《数据结构与算法》之十大基础排序算法
一.冒泡排序
什么是冒泡排序?
冒泡排序是一种交换排序,它的思路就是在待排序的数据中,两两比较相邻元素的大小,看是否满足大小顺序的要求,如果满足则不动,如果不满足则让它们互换。
然后继续与下一个相邻元素的比较,一直到一次遍历完成。一次遍历的过程就被成为一次冒泡,一次冒泡的结束至少会让一个元素移动到了正确的位置。
所以要想让所有元素都排序好,一次冒泡还不行,我们得重复N次去冒泡,这样最终就完成了N个数据的排序过程。
冒泡排序又有两种情况:向上冒泡和向下冒泡
向上冒泡指的是:在排序的时候把大的数据排在前面,小数据在后面,这样排序出来的小序列会是从大到小的顺序
向下冒泡指的是:在排序的时候把大的数据排在后面,小数据在前面,这样排序出来的顺序会是从小到大的
具体的冒泡方向要根据自己的要求去设计
冒泡排序实际上是相邻两个数据的按条件交换排序,然后遍历式的交换排序
它排序需要两次循环来设计:
第一循环控制排序几次
第二循环负责每次交换遍历数据的相邻数据交换
对于排序次数,可以根据数据的个数n,确定次数为:n-1次
下面我们来图示一下我们的排序方法:
下面我们来看看冒泡排序的伪代码:
冒泡排序可以说是比较简单的排序方法了:它的时间复杂的是O(n^2)
冒泡排序的交换关键在于那个if判断语句:
这里的【if语句】可以用大于等于也可以使用大于符号,
但是我使用的是大于号,因为排序的稳定性,如果使用大于等于号,两个相等的数据,它也会交换,这样的排序计算就不稳定,也提高了系统开销,
因为当两个相同的数据本身是有序的,如果让它们交换,在内存中它们也会发生相应的移动,提升了开销,所以这里建议使用大于号就好了
二.选择排序
什么是选择排序?
选择排序的原理就是在一个待排序的数组中,首先从前至后(或从后至前)对数组遍历后选取最大 (或最小)的数,与数组首(尾)的数进行交换。
选择排序的排序次数也是数据个数n,排序次数就是:n - 1
他会根据次数的减少,待排序的数据也会减少
它与冒泡排序不同的是它不会频繁的发生交换,交换次数比冒泡排序要少,每次就直接遍历数据,然后把最大/小的数据放在序列后/前面
这里图解一下选择排序:
选择排序只会在一次排序中交换一次,所以交换的次数就是,n - 1次
时间复杂度是O(n^2)
我们使用伪代码来实现一下选择排序:
选择排序一次遍历只会把当前的序列的最大数找出来,然后放在最前或者最后面
遍历的次数也会随着已排序的次数减少,因为没排序最大/小数就已经被找出来了,不需反复去校对
三.插入排序
什么是插入排序?
插入排序,也被称为直接插入排序。对于少量元素的排序,它是一个有效的算法 。插入排序是一种最简单的排序方法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而一个新的、记录数增1的有序表。
在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。
插入排序是稳定的。
插入排序是最简单容易理解的一种排序算法,它依靠的是直接覆盖,只要找到它因该在的位置,就让其它的数据往后移一个一个的覆盖,直到留出它应该插入的位置
这个覆盖其实是动态的,只要没找到位置,就会先往后覆盖一个,然后向前继续找位置,继续覆盖
这个数据在开始就被其它变量存储了,所以不会存在丢失数据的情况
我们一起去看看插入排序的伪代码:
算法是由for循环和while共同构成的,着重看看while循环
j > = 0 如果这个条件不满足,说明前面的数据都比它大,这个数据肯定是最小数据,就直接插入在 第一个位置
arr[ j ] > temp 说明当前数据比前面一个数据小,我们就要排它前面
插入排序的时间复杂度:O(N ^2)
四.希尔排序
什么是希尔排序?
希尔排序(Shell's Sort)是插入排序的一种又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。希尔排序是非稳定排序算法。该方法因D.L.Shell于1959年提出而得名。从上面我们很容易看出来,它是插入排序的高级版
希尔排序依赖于增量序列,一个好的增量序列可以是希尔排序的复杂度达到 N 的 4/3 次方,
最快的Sedgewick增量序列:时间复杂度只有 N的7/6次方
希尔排序在每次选定增量序列后,排序都是选择排序,但是它的算法优于选择排序,因为增量序列是成比例缩小的,并不是一定会排序 n-1次
希尔排序的排序次数比选择排序要少很多,
增量序列的选定:数据个数 N
一般使用 N/2 , N/4 , N/8 等等构成的数据序列
我们使用的是 n/2 构成的序列组合
在增量循环中时,我们使用的也是插入排序,
所以第二次增量排序为什么能直接把 5 , 6 , 9给一次排序出来就是根据插入排序的移动覆盖
我们来看看希尔排序的伪代码:
希尔排序比插入排序的优点在于,它的增量序列比 插入排序的 n-1次 排序次数少
也导致了希尔排序比插入排序快
总的来说,希尔排序的优劣取决于增量序列
五.归并排序
什么是归并排序?
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法
- 自上而下的递归
- 自下而上的迭代
归并排序的时间复杂度始终为O(NLogN),在排序算法算是快的了,但是他对空间需求大,需要额外开辟一个相同大小的空间
归并排序是分治法演化来的,他与分治法一样,
先把数据分成最小单位元素,然后再按要求合并,最后在一次次合并中慢慢变成最后排序的答案
我们来图解一下归并排序:
我们在拆分的时候必须给数组找一个分界点,在取分界点的时候,一般使用 数组的 最大下标 / 2 ,大家可以想想为什么不是数组长度直接 /2(也可以用,但不建议) ?
找到分界点的时候划分成两半,然后依次进行,直到划分到最小分量的时候就合并。
在合并的时候,我们要注意,有两个半区,左半区和右半区都要有一个变量来指向左半区和右半区数据的当前的位置,然后左右半区的数据依次比较,数据值小的就先进入临时数组
当比较完了,可能有一个半区还有数据,因为那个(或那几个数据都是)最大的,没有数据能把它们比进临时数组,所以要注意把这些数据直接copy到临时数组中
现在我们用伪代码来描述一下这个算法:
merge_sort 函数负责的是申请一个临时的空间,辅助我们在归并的时候转存这个数据,因为这个函数是归并排序的入口,当归并排序完成以后他还会回到这个函数,
所以为了方便,我们直接把申请的空间,释放代码部分也放在这里,我们在使用C语言写了这些内存的代码一定要释放掉,不然会一直占用系统资源,而且会发生内存泄漏。
msort 函数负责把传过来的一个函数给他拆分成成局部最小分量
merge 函数负责归并,连=两个小半区的比较,小的就先存入临时数组,当小分区比较完成以后,我们需要对有一个半区残留的元素直接copy到临时数组中,残留的元素一定是当前分区最大的且是有序的,
最后一定要记得把临时数组中的数据覆盖到原数组,毕竟最后的返回值还是原数组;
归并排序:
每一层归并的时间是O(N)
归并层数为O(LogN+1)
所以:时间复杂度是 O(NLogN)
六. 快速排序
什么是快速排序?
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。
-
从数列中挑出一个元素,称为 "基准"(pivot);
-
重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
-
递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序
在快速排序中,基准的选择一般是数据第一个元素,中间元素,最后一个元素,
在快速排序的我们需要抽象两个指针(或者数组下标),
一个是左指针,它是从左往右去找比基准大的数据
一个是右指针,它会从右往左去找比基准小的数据,
当左右指针都找到以后进行交换,然后一直重复,直到两指针相遇,再相遇点我们要比较,如果此数据比基准大,则交换,反之,左指针就往后移动一个位置
目的是:当前左指针所在的位置左边是都比基准小的数据,右边都是比基准大的数据
然后这个数据就是有序的了,我们需要对于有序的数据继续去划分左右半区,然后把左半区和右半区的最后一个数据作为两个半区的基准,分别计算,
这里就是分治的思想,只要一直分治下去,每一次分治都有一个数据被有序,而且随着分治的层数变多,所需要计算的次数越来越小
所以快速排序的时间复杂度是:O(nlogn) 最坏的情况就是 O(n^2)
快速排序的最坏运行情况是 O(n2),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。
所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
下面我们来图解一下快速排序:
快速排序在选定了基准以后,就会使用左右指针去查找和基准对比的数据,当找到合乎要求 的数据以后就会交换,直到指针重合
当重合的时候要考虑一下是否需要和基准交换,只有比基准大的数据才能和基准交换否则就让左指针向后移动一次
每次被选为基准的数据在使用完了以后他自己其实就是有序的了然后在左右两边有数据的情况下是会继续划分的,这是分治法的思想做出来的算法
我们一定要注意:一定是左指针先移动,不然是拍不出来结果的
我们接下来使用伪代码实现一下(递归的方式):
我们可以看到代码其实和前面的归并排序都有异曲同工之妙,毕竟都是基于分治法的思想,
归并排序需要先拆分成最小元素后,然后在比较的时候,边比较,便合并
快速排序是在比较完了一次就拆分,再拆分前就已经有数据是有序的了,当拆分完成后,序列就是各个有序的了,所以没有合并的过程
在绝大多数情况下,快速排序都要优于归并排序
七.堆排序
什么是堆排序?
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。
两种堆的结构:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的核心思想就是在大顶堆和小顶堆上建立起来的,因为大/小顶堆的性质:根节点比子节点大/小,所以在这两种顶堆中,最上面一层只有一个根节点的哪里,他一定是此序列中最大/小的
基于这个性质,我们只需要把一组数据先构造成为大/小顶堆,每次把最上面的元素和最后一个元素交换,再然后断开连接
这个数据就是有序的了即最大/小数,然后被换到根节点的数就开始维护,此时排序中最大的数又到了根节点,也就是整个序列的第二大数,然后重复操作
最后肯定是把序列从大到小或从小到大给排序出来的
堆排序的平均时间复杂度为 Ο(nlogn),它的排序过程很复杂,包括建堆,对维护,然后排序,它们相互依赖最后才做出最后的堆排序
首先我们来看看建堆的过程:
在建堆的时候,一般是三个节点参与构建,父节点,左子节点,右子节点,三个节点会有一个小型的维护过程,把最大的元素放到父节点,此时数据还刚开始
当下一次又会来新的数据,依旧构成三个节点构成,它们也会有一个维护阶段,把最大的元素放到父节点,
堆多了以后,他们的串联关系就会很明显了,然后可能建堆时只有三个,当维护时就会维护到下一层去了,到这里就表示它已经快要构成一个完全的数据堆了
当堆构建完成以后,我们就会开始进行排序:
堆排序就是在大顶堆或小顶堆的基础上,不断的把最后一个元素和堆顶元素置换,每置换一次,就删除一次连接,然后从堆顶开始维护,直到又构成一个大顶堆
然后二次交换,重复操作,最后完成排序
我们来看看堆排序的伪代码:
代码中可能有的疑问:
堆排序中我们构造的堆其实也是抽象的,并不是在内存中真的开辟了一个图形像堆一样,它是利用数组下标构造的一个联系,比如 下标为 0 的 ,它的子节点是 :下标为 1 和下标为2
我们图中所说的删除连接就是少一个数去维护,也就是循环的次数越来越少 : i -- 就是在删除连接
堆排序是不稳定的,大家可以自己去推一下
八.计数排序
它只适合小范围的排序,空间开销大
什么是计数排序?
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
计数排序思路很简单,但是它需要的时间开销很大
首先它需要一个数组去计数,还需要一个数据去计算累计值,还需要一个数组用于临时存储 原数组和累计数组的计算结果
原数组arr[ ];
计数数组 count [ ]:数据每出现一次 ,对应数据为下标的计数数组加一 ,计数数组是临时数组,需要申请,它的大小取决于原数组数据的最大值的加一
累计数组 count 1 [ ]:计算原数据所在的位置, 它需要和计数数组结合,count 1 [ i] = count 1 [ i - 1] + count [ i ]
排序数组output [ ] : 排序数组是最后的结果,需要拷贝回原数组,它是通过 原数组和累计数组计算的 output[ count 1 [ arr [ i ] ] -1 ] = arr [ i ]
下面我们使用图解一下计数排序:
然后是伪代码实现:
计数排序都是线性的,但是空间开销大,算法和名字一样,需要计算,其他的排序方法都是靠元素交换来排序的,计数排序是很特别的一种算法
但是它只适合小范围的计算,因为计算数组的申请需要根据元素的最大值,而不是元素个数,也就是我只有一个元素 ,它的值是100,那么我也得申请 100的空间大小
所以计数排序在运行时非常占空间,一下要申请两到三个,空间开销太大
计数排序的时间复杂度是:O(n) ,空间开销太大
九.桶排序
什么是桶排序?
桶排序是另外一种以O(n)或者接近O(n)的复杂度排序的算法. 它假设输入的待排序元素是等可能的落在等间隔的值区间内.
一个长度为N的数组使用桶排序, 需要长度为N的辅助数组. 等间隔的区间称为桶, 每个桶内落在该区间的元素. 桶排序是基数排序的一种归纳结果。
桶排序与其它排序不同,它依靠的是下标代表元素,数组里的值只是确定整个序列有没有此元素,我们最开始会初始化我们的桶数组,都初始化为零
然后依次遍历原数据,把元素作为桶数组的下标,对应的数组值加一,代表此下标有值,这是一次对原数组的数据
当数据装完了以后我们再去遍历桶数组,当数组的值不为零的时候就打印下标,
所以桶排序是一种基于下标来作为元素的排序
我们来图解一下桶排序:
桶排序也是一个线性的排序,时间复杂度一般为O(n),但是空间开销也很大,它申请请辅助数组时,并不是根据原数组的长度而判定的,
而是根据原数组的数据中的最大值来判定的的,也就是我只有两个元素分别是,100和1000的时候,在申请辅助数组的时候,我们要根据数组的中元素的最大值
也就是 1000 +1 来申请辅助数组,原数组大小才 2 ,申请的数组长度为 1001,这个空间开销浪费的资源太多了,
所以桶排序只适合小范围的计数,他和计数排序的适用区间差不多,比如:记录班级各个同学的成绩,然后排序,这种计算是很合适的,它不仅能排序还能计数,这种小范围的计数是可以使用桶排序的
此外,后来的人对桶排序又进行了优化,把桶不仅限于装一个元素了,而是装一个区间,这些使用都是基于桶排序的思想来的,目的是为了节约一下桶排序的空间开销
下面就是使用区间来描述桶的容量:
在区间内,如果一个桶中有多个元素,那么就需要对单个桶进行插入排序,由于一般桶内的数据少,所以插入排序比较快
这种一个桶就代表一个区间的算法就不能光使用辅助数组了,还需要使用链表,把位于同一桶的元素串联起来
最坏的情况就是全部位于一个区间(桶)内,我们的堆排序就不在是线性的了
它的复杂度就是:O(N ^2)
接下来我们看看桶排序的一般代码描述:
这是一个桶只装一个元素的代码描述,很显然可以看出来它是一个线性的算法
空间开销就很大了,在使用桶排序的时候一定注意它的使用区间,它只是用于小范围且连续的序列,而且序列中的最大元素尽量不断地靠近 0 ,开辟的空间越小越好
桶排序的时间复杂度: O(n)
十.基数排序
什么是基数排序?
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。
由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
基数排序也延续了桶排序的思想,与桶排序不同的是,基数排序是根据数据被拆分的结果来划分桶的位置
比如 : 我们对两位数要根据基数排序 33 和 69
先让个位进入桶 :33 进入 3号桶,69进入9号桶
个位进桶了以后表示各位已经是在整个序列是有序的了,
然后十位进入桶:33 进入 3号桶,69进入6号桶
此时十位在整个序列是有序的了,只需要将桶内的元素按顺序拿出,然后往后一个个拿桶,最后就的到了最后的数据
基数排序比区间桶排序的优点在于,基数排序不管元素多少,它只需要10个桶,即0~9号桶,空间开销比桶排序小
但是思想上还是桶排序的思想
我们来图解一下基数排序:
我们看图,其实基数排序也是建立在桶排序的基础上的,为了节省空间开销我们还用了计数排序中的累计数组思想,
对比桶排序,很明显的就是在数据量变多的时候,桶排序所需的空间就会越来越大,反而基数排序就只需要十个桶,但它和基数排序一样要申请一个辅助数组,所以开销依旧很大
基数排序结合了桶排序和计数排序,所以在思想上三个都有很多相同之处
我们来看看基数排序的代码实现:
在伪代码中我们可以看到max和base这两个变量:
max是数组中的最大数,它是几位数就决定了要进行几次基数排序
base:就是用来分拨每一位数的变量,它每 乘十就意味着向上一位整数
基数排序的时间复杂度是:O(n) 也是线性的,也是稳定的排序
十一.总结
十大排序的时间空间复杂度以及稳定性
前面的七种排序都是非常经典的排序方法,它们时间复杂度比后面三种大,但是空间开销小
后面三种排序大多是线性的,理解起来也比较容易,时间复杂度最好的情况都是O(n),但是空间开销真的很大
所以后面三种算法重点还是在理解思想,怎么计算的就行,
当然我推荐必须掌握的就是:快速排序,归并排序,堆排序,这也是重点的算法。