排序算法概述
排序的分类
排序的两种分类
1、内部排序:将所需要的数据先加到内存中,然后进行排序
2、外部排序:数据量无法一次性全部加载到内存中,需要借助外部存储器进行排序
常见的排序算法分类
内部排序
1、插入
- 直接插入排序
- 希尔排序
2、选择
- 简单选择排序
- 堆排序
3、交换
- 冒泡排序
- 快速排序
4、归并
5、基数(桶排序升级版)
算法的时间复杂度
度量一个算法执行时间的两种方法
1、事后统计法:程序先运行,然后根据程序运行的时间来判断哪个算法更快
2、事前估算法:通过分析某个算法的时间复杂度来判断哪个算法更优
事后统计法与很多因素有关系,比如计算机的硬件,时间可能太长等等。。。
所以我们需要事前估算法
时间频度
一个算法花费的时间与语句的执行次数成正比例,哪个算法中语句执行次数多,它花费的时间就多
==一个算法中的语句执行次数成为语句频度(或者时间频度),记为T(n)==
举例:
计算1~100所有数字之和,有两种算法
1 | int total = 0; |
算法1的时间频度T(n)=n+1;因为要进行end+1次判断,end就是n,所以是n+1,简写为T(n+1)
算法2的T(n)=1;因为只需要执行一次就知道了结果,简写为T(1)
时间频度的注意事项
1、忽略常数项
我们可以看到,前两个为一组和后两个为一组,这两组随着n的无限增大,在自己组内的差距其实是在无限减小的
所以我们可以明白:常数项是可以忽略不计的,比如第一组里的20,第二组里的10
2、忽略低次项
前两个为一组,后两个为一组
我们可以看到,随着数值的不断增大,各自组内的数据也在无限接近
可以忽略3n+10和5n+20
3、忽略系数
随着n的变大
5n^2+7n 和 3n^2+2n逐渐重合,说明这种情况下5和3可以忽略
n^3+5n 和 6n^3+4n逐渐分离
在二次方的时候,5和3这两个系数最后在不断重合
在三次方的时候,6和1却在不断分离
因为幂的不同,系数的改变出现了不同的结果。所以说,系数不重要,多少次幂才重要
时间复杂度
1、一般情况下,算法中的基本操作语句的重复执行次数是问题规模n的某个函数,用T(n)表示【时间频度】,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)趋近于不等于零的常数,则称f(n)是T(n)的同数量级函数。
记做T(n)=(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度
举个例子
例1:
现在时间频度为T(n) = n+1,我们知道,系数,常数是可以忽略的,那么就相当于n
现在只要有一个函数f(n),满足T(n)/f(n)无限趋近于非零整数,那么O(f(n))就是时间复杂度
让n/f(n)的这个f(n)自然就是n
那么算下来,O(f(n))=n
也就是说,我们找时间复杂度,最后都要寻找O(f(n)),这个f(n)是我们自己找的
例2:
现在有T(n)=n^2^+7n+6 ,我们知道,系数,常数,低次项是可以忽略的,所以就是n^2^
现在我们要得到时间复杂度,就要找一个T(n)/f(n)不为0的常数,那么f(n)=n^2^
所以时间复杂度为O(n^2^)
所以我们的推断有以下几个步骤,就拿T(n)=n^2^+7n+6来说
1、用常数1替代函数的所有加法常数:T(n)=n^2^+7n+1
2、只保留最高阶项:T(n)=n^2^
3、将最高阶项的系数改为1:这里已经是1了,所以不需要改
4、得出时间复杂度:O(n^2^)
常见的时间复杂度
时间复杂度由小到大:
常数阶<对数阶<线性阶<线性对数阶<平方阶<立方阶<k次方阶<指数阶<n次阶
对数阶不一定非得是2,也可能是3,4,….
要极力避免我们程序中出现指数阶的时间复杂度,更不用说n的n次阶了,这个图上直接都没有,直接爆炸了
举几个例子
1、常数阶
1 | int i = 1; |
不管i和j是什么数字,它执行的时间不会随着数字的增长而增长,从始至终就是五条语句
2、对数阶
1 | int i = 1; |
N=a^x^,即a的x次方等于N(其中a>0且a!=1),那么数x叫做以a为底的对数,记做x=log
aN在这个while循环中,每次都将i乘2,乘完之后,i就是i乘2的一次次方,2的二次方,2的三次方,….,直到第x次为2的x次方等于n(这个x此时不一定是整数),然后大于n,退出循环
也就是说2的x次方等于n,也就是x=log
an
3、线性阶
1 | for(i=1; i<n; ++i){ |
里面的for循环会执行n遍,因此消耗的时间是随着n变化的
4、线性对数阶
1 | for(m=1; m<n; m++){ |
将时间复杂度为对数阶的代码循环n遍,也就是n*O(log
2N),也就是O(nlog2N)
5、平方阶
1 | for(x=1; i<=n; x++){ |
也就是双层for循环
参考上面的几个时间复杂度,我们可以知道,从最里面开始看起,然后依次向外看
先找好最里面的时间复杂度,然后外面套里面即可
平均时间复杂度和最坏时间复杂度
平均时间复杂度
是指所有可能的情况输入实例平均到等概率出现的情况下,这个算法所运行的时间
最坏时间复杂度
是指最坏情况下发生的时间复杂度
我们一般讨论时间复杂度是讨论最坏的时间复杂度,这样做的原因是:最坏请款下的时间复杂度是在任何输入实例的界限,这就保证了运行时间不会超过最坏的界限
平均时间复杂度和最坏时间复杂度是否一致,和算法有关系
常见算法的时间复杂度
排序法 | 平均时间 | 最差情形 | 稳定度 | 占用的额外空间 | 备注 |
---|---|---|---|---|---|
冒泡 | O(n^2^) | O(n^2^) | 稳定 | O(1) | n小时较好 |
交换 | O(n^2^) | O(n^2^) | 不稳定 | O(1) | n小时较好 |
选择 | O(n^2^) | O(n^2^) | 不稳定 | O(1) | n小时较好 |
插入 | O(n^2^) | O(n^2^) | 稳定 | O(1) | 大部分已排序时较好 |
基数 | O(log |
O(log |
稳定 | O(n) | B是真数(0-9) R是基数(个十百) |
Shell | O(nlog^n^) | O(n^s^) 1<s<2 | 不稳定 | O(1) | s是所选分组 |
快速 | O(nlog^n^) | O(n^2^) | 不稳定 | O(nlog^n^) | n大时较好 |
归并 | O(nlog^n^) | O(nlog^n^) | 稳定 | O(1) | n大时较好 |
堆 | O(nlog^n^) | O(nlog^n^) | 不稳定 | O(1) | n大时较好 |
算法的空间复杂度
类似于时间复杂度的讨论,一个算法的空间复杂度定义为该算法所耗费的存储空间,它也是问题规模n的函数
空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的度量。
有的算法需要占用的临时工作单元数与解决问题的规模n有关,它随着n的增大而增大,当n较大时,占用更多的存储单元,例如快排或者归并
在做算法分析时,主要讨论的是时间复杂度。从用户使用体验上来看,更看重的程序执行的速度。
一些缓存产品(redis,memcache)和算法(基数排序)本质就是用空间换时间
冒泡排序
冒泡排序概述
它的基本思路是
排序序列从前到后(下标从小到大),依次比较相邻元素的值,若发现逆序则交换,使值较大的元素主键从前意向后部
就像水底下的气泡一样逐渐上冒
冒泡排序需要执行多次,每一次都确定一个最大值
第一次确定所有数据的最大值,放到最后
第二次确定剩下数据的最大值,放到倒数第二个位置
第三次确定剩下数据的最大值,放到倒数第三个位置
…
第n次剩下了两个数据,确定这两个数据的较大值,放到第二个位置
排序完成
1、一共进行了数组大小-1次循环
2、每次排序的次数在主键减少
3、如果我们发现在某次排序中,没有发生一次交换,那么就可以提前结束冒泡排序
冒泡排序代码实现
1 | package com.howling; |
1 | 排序:[3, -1, 9, -2, 10] |
冒泡排序的适当优化
当我们发现某个排序中完全没有交换,那么冒泡提前结束
1 | package com.howling; |
选择排序
选择排序概述
选择排序,也是一种简单的排序方法,它的基本思想是:
第一次从arr[0]-arr[n-1]中选取最小值,与arr[0]交换
第二次从arr[1]-arr[n-1]中选择最小值,与arr[1]交换
第三次从arr[2]-arr[n-1]中选择最小值,与arr[2]交换
….
第i次从arr[i-1]-arr[n-1]中选择最小值,与arr[i-1]交换
….
第n-1次从arr[n-2]-arr[n-1]中选择最小值,与arr[n-2]交换
共交换n-1次,得到一个按照排序码从小到大的有序序列
选择排序和冒泡排序其实差不多,很容易混淆
选择排序比较的次数没有改变,但是交换的次数少了
1、选择排序一共有数组大小-1次排序
2、每一轮排序,又是一次循环
3、先假定当前这个数是最小数,然后和后面的每个数进行比较,如果发现有比当前数更小的数,就重新确定最小数,并得到下标
4、当遍历到了数组的最后时,就得到本轮的最小数和下标
5、交换
选择排序代码实现
1 | package com.howling; |
1 | 进行排序:[1, 34, 119, 101, 121, 82, 59] |
插入排序
插入排序概述
插入排序的基本思想是:
把n个待排序的元素看为一个有序表和一个无序表
一开始:有序表只包含一个元素,无序表包含n-1个元素
后来:每次从无序表中取得第一个元素,将他的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表
比如,现在有一个数组R
int[] R = {17,3,25,14,20,9}
初始状态,将17看成一个有序表,剩下的看成一个无序表
第一次插入,取得无序表中的第一个值3,与有序表最后进行比较,发现3<17,则3向前挪一位,然后向前比较,没值了,就这样
第二次插入,取得无序表中的第一个值25,与无序表最后进行比较,发现25>17,那就这样
第三次插入,取得无序表中的第一个值14,与无序表最后进行比较,发现14<25,则向前移动一位,然后向前比较,发现14<17,则向前一位,然后向前比较,发现大于3,就这样
……
最后得到足后的数据
代码实现
1 | package com.howling; |
1 | 排序前:[101, 34, 119, 1, -1, 89] |
插入排序存在的问题
当需要插入的数比较小时,后移的次数会明显增多,对效率有影响
希尔排序
希尔排序概述
希尔排序也是一种插入排序,是根据插入排序进行了一些简单的改进之后的一个更加高效的版本
由1959年,希尔提出的,所以称为希尔排序,也称为缩小增量排序。
插入排序的基本思想:
把记录按照下标的一定增量分组,对每组使用直接插入排序算法排序。
随着增量逐渐减少,每组包含的关键词越来越多,当增量减少至1时,整个文件恰好被分为一组,算法便终止
比如说,现在有一组数据:int[] arr = {8,9,1,7,2,3,5,4,6,0},长度为10
1、arr.length/2 = 5,也就是分为5组,步长也是5
根据步长,我们可以将他们分出来:
1组:arr[0],arr[0+5],也就是8和3
2组:arr[1],arr[1+5],也就是9和5
3组:arr[2],arr[2+5],也就是1和4
4组:arr[3],arr[3+5],也就是7和6
5组:arr[4],arr[4+5],也就是2和0
2、每一个组内进行数据的比较,然后进行排序,那么就变为了
int[] arr = {3,5,1,6,0,8,9,4,7,2}
3、缩小增量,原来的增量为5,现在进行5/2=2.5,但是我们取整数2,那么增量就为2,也就分为2组
1组:arr[0],arr[2],arr[4],arr[6],arr[8]。也就是3,1,0,9,7
2组:arr[1],arr[3],arr[5],arr[7],arr[9]。也就是5,6,8,4,2
4、组内进行排序,就变为了
int[] arr = {0,2,1,4,3,5,7,6,9,8};
5、缩小增量为2/2,也就是1,也就是分为了一组,那么在一组内进行了排序
希尔排序其实是插入排序的改进版,他最大的作用就是改善了较小值移动次数过多的情况
代码实现
希尔排序的交换法
1 | /** |
希尔排序的交换法
希尔排序的移动法
希尔排序的移位法真正整合了插入排序的优点,进行了移位的操作
1 | /** |
希尔排序的插入法是比较难以理解的,我在这里做一下说明,使用希尔排序的位移法要从一开始看
1、arr[0]和arr[gap]无论谁大谁小,那么走一遍上面的路线,会发现两者会变为有序组
2、进行其他位置的排序….
3、arr[gapx2]和之前的位置比较,我们知道arr[0]和arr[gap]是有序的,所以无论arr[gapx2]插入到哪个位置,都不会扰乱这两者的顺序
4、….
5、arr[gapxn]和之前的位置比较,我们知道之前的等步长的所有位置上都是有序的,所以无论arr[gapxn]插入到哪里,都不会扰乱之前的顺序
快速排序
快速排序的概述
快速排序(Quicksort)是对冒泡排序的一种改进。
基本思想是:
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小。
然后再按此方法对这两部分数据分别进行快速排序。
整个排序过程可以递归进行,以此达到整个数据变成有序序列
比如说,现在有A,B,C,D,E五个数,我们以C为基准,把比C小的都放到C的左边,把比C大的都放到C的右边。
然后这样就分割成了两个区域X,Y。然后分别对两个区域内的数据重新进行排序分割,直到最后排序完成
图中是以最后一个数为基准来进行分割的,但其实一什么为基准来分割并不重要,重要的是快速排序的思想
代码实现
1 | /** |
1 | public static void main(String[] args) { |
归并排序
归并排序概述
归并排序,是分支策略思想进行实现的一种排序算法。
分治策略的意思是:
1、分:将大的问题分为小的问题,分解完成之后。没有什么实质性的动作
2、治:将分出来的结果形成答案,合并到一起
我们后面还有一个分治算法,也是分支策略的一种实现。
我们可以看到,分治中的分为了单独的数据,然而治是合并了7次。
数据一共有8个,合并了七次,这就是效率十分高明的一种合并方式。
这么算来,即使有八十万,八百万,也会在它之上减一次,这是效率十分高的一种方式。它是线性增长,如果是冒泡,早就已经凉了。
我们来讲一下它是怎么进行合并的,我们以最后一次的合并为例子来进行讲解
也就是上图中的[4 , 5 , 7 , 8]和[1 , 2 , 3 , 6]来作为例子来进行讲解
1、我们首先定义两个指针: i 和 j ,其中 i 指向左边的分类, j 指向右边的分类。然后定义一个临时数组 temp 用于存放数据
2、 i 和 j 指向的数据进行比较,假如 i 比较小,那么将 i 指向的数据放到 temp 中,然后i+1。反之亦然
3、重复以上步骤,直到排序完成
4、假如出现 i 部分已经完成, j 部分未完成,只需要将 j 部分全部加入 temp 中即可,因为i和j两个单独的部分都是顺序的。
代码实现
1 | /** |
1 | /** |
基数排序
基数排序概述
基数排序介绍
1、基数排序(radix sort)是属于分配式排序(distribution sort),又称桶子法(bucket sort 或者 bin sort)。
顾名思义,它是通过键值的各个位的值,将要排序的元素分配到某一些桶中,达到排序的作用
2、基数排序是属于稳定性的排序,基数排序法是效率最高的稳定性排序法
稳定性排序的意思是:我现在有四个数字,3,1,41,1
排序之后是:1,1,3,41
但是1的顺序还是原来的顺序,第一个1就是排序之后的第一个1,第二个1就是排序之后的第二个1
相同元素的顺序是不变的
3、基数排序是桶排序的扩展,也就是说,学完了基数排序之后,学习桶排序也是十分简单的
基数排序的基本思想
1、将所有待比较的数值统一为同样的数位长度,数位较短的数字前面补零。
2、从最低为开始,依次进行一次排序
3、这样从最低位排序一直到最高位排序完成之后,数列就变为了一个有序序列
它的图文表现是这样的:
我们的原数组是这样的:
int[] arr = {53 , 3 , 542 , 748 , 14 , 214}
第一轮:比较个位数
第二轮:比较十位数
第三轮:比较百位数
因为没有更高位了,所以没有第四轮
代码实现
1 | /** |
八万个数据下,各个排序的时间
1、首先我们需要一个工具
1 | public static void main(String[] args) { |
1 | 经过我们的测试: |