排序算法说明:
定义:将一组杂乱无章的数据按一定规律顺次排列起来,例如:
输入:a1, a2, a3, ..., an
输出:a1' , a2' , a3' ,..., an'(满足a1' <= a2' <=a3' <= ...<=an'排列)
算法性能评估术语
稳定:如果a原本在b前面,而a=b时,排序之后a仍然在b的前面。
不稳定:如果a原本在b的前面,而a=b时,排序之后a可能出现在b的后面。
内排序(In-place):所有排序操作都在内存中完成。
外排序(Out-place):通常是由于数据太大,不能同时存放在内存中,根据排序过程的需要而在外存与内存之间 数据传输才能进行。
时间复杂度:时间频度,一个算法执行所耗费的时间。算法中通常用数据比较次数与数据移动次数 进行衡量。
空间复杂度:算法执行所需要的内存大小。
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 排序方式 | 稳定性 |
---|---|---|---|---|---|---|
冒泡排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | In-place | 稳定 |
直接插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | In-place | 稳定 |
希尔排序 | O ( n l o g n ) ∼ O ( n ( 3 / 2 ) ) O(nlogn)\sim O(n^{(3/2)}) O(nlogn)∼O(n(3/2)) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | In-place | 不稳定 |
简单选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | In-place | 不稳定 |
快速排序 | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n 2 ) O(n^2) O(n2) | O ( l o g n ) O(logn) O(logn) | In-place | 不稳定 |
归并排序 | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n ) O(n) O(n) | Out-place | 稳定 |
堆排序 | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( n l o g n ) O(nlogn) O(nlogn) | O ( 1 ) O(1) O(1) | In-place | 不稳定 |
计数排序 | O ( n + k ) O(n + k) O(n+k) | O ( n + k ) O(n + k) O(n+k) | O ( n + k ) O(n + k) O(n+k) | O ( n + k ) O(n + k) O(n+k) | Out-place | 稳定 |
桶排序 | O ( n + k ) O(n+k) O(n+k) | O ( n + k ) O(n+k) O(n+k) | O ( n 2 ) O(n^2) O(n2) | O ( n + k ) O(n+k) O(n+k) | Out-place | 稳定 |
基数排序 | O ( n × k ) O(n \times k) O(n×k) | O ( n × k ) O(n \times k) O(n×k) | O ( n × k ) O(n \times k) O(n×k) | O ( n + k ) O(n+k) O(n+k) | Out-place | 稳定 |
一. 冒泡排序
-
基本思想:冒泡排序是一种交换排序,核心是冒泡,把数组中最小的那个往上冒,冒的过程就是和他相邻的元素交换。
-
实现逻辑:
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
通过两层循环控制
- 第一个循环(外循环),负责把需要冒泡的那个数字排除在外;
- 第二个循环(内循环),负责两两比较交换。
-
动图演示
-
性能分析:
冒泡排序涉及相邻两两数据的比较,故需要嵌套两层for循环来控制;
外层循环n次,内层最多时循环n-1次,最少循环0次,平均循环(n-1)/2;所以循环体内总的比较次数为:n*(n-1)/2;按照计算时间复杂度的规则,去掉常数、去掉最高项系数,其时间复杂度为O(N^2) ;
平均时间复杂度:O(N^2) 最佳时间复杂度:O(N) 最差时间复杂度:O(N^2) 空间复杂度:O(1) 排序方式:In-place 稳定性:稳定 最优的空间复杂度,同样,就是不需要借用第三方内存空间,则复杂度为0 最差的空间复杂度就是开始元素逆序排序,每次都要借用一次内存,按照实际的循环次数,为O(N) 平均的空间负杂度为:O(1) 注: n:数据规模 k:”桶”的个数 In-place:占用常数内存,不占用额外内存 Out-place:占用额外内存
-
代码实现:
class Solution { public void bubble_sort(int[] arr) { for (int i=0; i<arr.length; i++){ for (int j=0; j< arr.length-1-i; j++){ if(arr[j] > arr[j+1]){ //交换arr[j]和arr[j+1] int temp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = temp; } } } System.out.println(Arrays.toString(arr)); } }
-
总结:冒泡排序毕竟是一种效率低下的排序方法,在数据规模很小时,可以采用。数据规模比较大时,建议采用其它排序方法。
二. 直接插入排序
-
基本思想:插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
例如我们有一组数字:{5,2,4,6,1,3},我们要将这组数字从小到大进行排列。 我们从第二个数字开始,将其认为是新增加的数字,这样第二个数字只需与其左边的第一个数字比较后排好序;在第三个数字,认为前两个已经排好序的数字为手里整理好的牌,那么只需将第三个数字与前两个数字比较即可;以此类推,直到最后一个数字与前面的所有数字比较结束,插入排序完成。
-
实现逻辑
- ①从第一个元素开始,该元素可以认为已经被排序
- ②取出下一个元素,在已经排序的元素序列中从后向前扫描
- ③如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复步骤③,直到找到已排序的元素小于或者等于新元素的位置
- ⑤将新元素插入到该位置后
- ⑥ 重复步骤②~⑤
-
动画演示
-
性能分析
如果插入排序的目标是把n个元素的序列升序排列,那么采用插入排序存在最好情况和最坏情况: (1) 最好情况:序列已经是升序排列,在这种情况下,需要进行的比较操作需(n-1)次即可。 (2) 最坏情况:序列是降序排列,那么此时需要进行的比较共有n(n-1)/2次。 平均来说插入排序算法复杂度为O(N^2)。 最优的空间复杂度为开始元素已排序,则空间复杂度为 0; 最差的空间复杂度为开始元素为逆排序,则空间复杂度最坏时为 O(N); 平均的空间复杂度为O(1) 平均时间复杂度:O(N^2) 最差时间复杂度:O(N^2) 空间复杂度:O(1) 排序方式:In-place 稳定性:稳定 注: n:数据规模 k:”桶”的个数 In-place:占用常数内存,不占用额外内存 Out-place:占用额外内存
-
代码实现
class Solution { public void Insert_sort(int[] arr) { //检查数据合法性 if(null == arr || arr.length <= 0) return; for(int i=1; i<arr.length; i++){ int temp = arr[i]; int j; for (j=i-1; j>=0; j--){ //如果temp比a[j]小的话,就把a[j]后移一位 if(temp < arr[j]){ arr[j+1] = arr[j]; }else break; } arr[j+1] = temp; } System.out.println(Arrays.toString(arr)); } }
-
插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,例如,量级小于千,那么插入排序还是一个不错的选择。**尤其当数据基本有序时,**采用插入排序可以明显减少数据交换和数据移动次数,进而提升排序效率。 在STL的sort算法和stdlib的qsort算法中,都将插入排序作为快速排序的补充,用于少量元素的排序。
三. 希尔排序
-
背景:插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位
-
基本思想:先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。
因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上比前两种方法有较大提高。
-
实现逻辑:
① 先取一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组。 ② 所有距离为d1的倍数的记录放在同一个组中,在各组内进行直接插入排序。 ③ 取第二个增量d2小于d1重复上述的分组和排序,直至所取的增量dt=1(dt小于dt-l小于…小于d2小于d1),即所有记录放在同一组中进行直接插入排序为止。
-
动图演示:
假设有一组{9, 1, 2, 5, 7, 4, 8, 6, 3, 5}无序序列。
第一趟排序: 设 gap1 = N / 2 = 5,即相隔距离为 5 的元素组成一组,可以分为 5 组。接下来,按照直接插入排序的方法对每个组进行排序。
第二趟排序:
将上次的 gap 缩小一半,即 gap2 = gap1 / 2 = 2 (取整数)。这样每相隔距离为 2 的元素组成一组,可以分为2组。按照直接插入排序的方法对每个组进行排序。第三趟排序:
再次把 gap 缩小一半,即gap3 = gap2 / 2 = 1。 这样相隔距离为1的元素组成一组,即只有一组。按照直接插入排序的方法对每个组进行排序。此时,排序已经结束。注:需要注意一下的是,图中有两个相等数值的元素5和5。我们可以清楚的看到,在排序过程中,两个元素位置交换了。
-
性能分析:
希尔排序的效率取决于增量值gap的选取,时间复杂度并不是一个定值。
开始时,gap取值较大,子序列中的元素较少,排序速度快,克服了直接插入排序的缺点;其次,gap值逐渐变小后,虽然子序列的元素逐渐变多,但大多元素已基本有序,所以继承了直接插入排序的优点,能以近线性的速度排好序。
最优的空间复杂度为开始元素已排序,则空间复杂度为 0;最差的空间复杂度为开始元素为逆排序,则空间复杂度为 O(N);平均的空间复杂度为O(1)希尔排序并不只是相邻元素的比较,有许多跳跃式的比较,难免会出现相同元素之间的相对位置发生变化。比如上面的例子中希尔排序中相等数据5就交换了位置,所以希尔排序是不稳定的算法。
平均时间复杂度:O(Nlog2N) 最佳时间复杂度: 最差时间复杂度:O(N^2) 空间复杂度:O(1) 稳定性:不稳定 复杂性:较复杂
-
代码实现:
class Solution { public void shell_sort(int[] arr) { //检查数据合法性 if(null == arr || arr.length <= 0) return; int gap=1,i,j,len= arr.length; int temp; while (gap < len / 3){ gap = gap * 3 + 1; } for (;gap>0; gap /= 3){ for (i=gap; i<len ; i++) { temp = arr[i]; for (j=i-gap; j>=0 && arr[j] > temp; j -= gap){ arr[j+gap] = arr[j]; } arr[j+gap] = temp; } } System.out.println(Arrays.toString(arr)); } }
-
重点说明
步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。算法最开始以一定的步长进行排序。然后会继续以一定步长进行排序,最终算法以步长为1进行排序。当步长为1时,算法变为插入排序,这就保证了数据一定会被排序。
作者最初的建议是折半再折半知道最后的步长为1<也就是插入排序>,虽然这样取可以比O(n2)类的算法(插入排序)更好,但这样仍然有减少平均时间和最差时间的余地。可能希尔排序最重要的地方在于当用较小步长排序后,以前用的较大步长仍然是有序的。比如, 如果一个数列以步长5进行了排序然后再以步长3进行排序,那么该数列不仅是以步长3有序,而且是以步长5有序。如果不是这样,那么算法在迭代过程中会打乱以前的顺序,那就不会以如此短的时间完成排序了。
常见的步长序列:
①步长序列: n / 2 i n/2^i n/2i 最坏情况复杂度: O ( n 2 ) O(n^2) O(n2)
②步长序列: 2 k − 1 2^k-1 2k−1 最坏情况复杂度: O ( n 3 / 2 ) O(n^{3/2}) O(n3/2)
③步长序列: 2 i 3 j 2^i3^j 2i3j 最坏情况复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
-
总结:希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能,交换不相邻的元素以对数组的局部进行排序,最终用插入排序将局部有序的数组排序。
希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于增量因子序列d的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。目前还没有人给出选取最好的增量因子序列的方法。增量因子序列可以有各种取法,有取奇数的,也有取质数的,但需要注意:增量因子中除1外没有公因子,且最后一个增量因子必须为1。希尔排序方法是一个不稳定的排序方法。
为什么增量因子不能取偶数?比如先取4,再取2,2里面的排序就包含了4里面的排序了,相当于4就白排了,浪费时间空间。
四. 选择排序
-
基本思想:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序的思想其实和冒泡排序有点类似,都是在一次排序后把最小的元素放到最前面,或者将最大值放在最后面。但是过程不同,冒泡排序是通过相邻的比较和交换。而选择排序是通过对整体的选择,每一趟从前往后查找出无序区最小值,将最小值交换至无序区最前面的位置。
-
实现逻辑:
① 第一轮从下标为 1 到下标为 n-1 的元素中选取最小值,若小于第一个数,则交换 ② 第二轮从下标为 2 到下标为 n-1 的元素中选取最小值,若小于第二个数,则交换 ③ 依次类推下去……
-
动图演示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6inrDlB5-1624428781908)()]
注:红色表示当前最小值,黄色表示已排序序列,绿色表示当前位置。
-
复杂度分析
选择排序的交换操作介于和(n-1)次之间。选择排序的比较操作为n(n-1)/2次之间。选择排序的赋值操作介于0和3(n-1)次之间。
比较次数O(n^2),比较次数与关键字的初始状态无关,总的比较次数N = (n-1) + (n-2) +…+ 1 = n x (n-1)/2。交换次数O(n),最好情况是,已经有序,交换0次;最坏情况是,逆序,交换n-1次。
平均时间复杂度:O(N^2) 最佳时间复杂度:O(N^2) 最差时间复杂度:O(N^2) 空间复杂度:O(1) 排序方式:In-place 稳定性:不稳定
-
代码实现
class Solution { public void selection_sort(int[] arr) { //检查数据合法性 if(null == arr || arr.length <= 0) return; for (int i=0; i< arr.length; i++){ int min = i; for (int j=i+1; j<arr.length; j++){ if(arr[j] < arr[min]){ min = j; } } //交换arr[i]和arr[min] if(arr[min] != arr[i]){ int temp = arr[i]; arr[i] = arr[min]; arr[min] = temp; } } System.out.println(Arrays.toString(arr)); } }
-
总结:选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对n个元素的表进行排序总共进行至多n-1次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
五. 快速排序
-
快速排序,又称划分交换排序(partition-exchange sort)
-
基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
-
实现逻辑:
① 从数列中挑出一个元素,称为 “基准”(pivot), ② 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。 ③ 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
-
动图演示:
-
复杂度分析
平均时间复杂度:O(NlogN) 最佳时间复杂度:O(NlogN) 最差时间复杂度:O(N^2) 空间复杂度:根据实现方式的不同而不同
-
代码实现
class Solution { int[] arr; public void swap(int x, int y){ int temp = arr[x]; arr[x] = arr[y]; arr[y] = temp; } public void sort(int[] arr){ this.arr = arr; quick_sort_recursive(0, arr.length - 1); } public void quick_sort_recursive(int start, int end) { //检查数据合法性 if (start >= end){ return; } int mid = arr[end]; int left = start, right = end - 1; while(left < right){ while(arr[left] <= mid && left < right) //找到左边中大于mid的数 left++; while(arr[right] >= mid && left < right) //找到右边中小于mid的数 right--; swap(left, right); } if (arr[left] >= arr[end]) swap(left, end); else left++; quick_sort_recursive(start, left - 1); quick_sort_recursive(left + 1, end); System.out.println(Arrays.toString(arr)); } }
-
总结
快速排序在排序算法中具有排序速度快,而且是就地排序等优点,使得在许多编程语言的内部元素排序实现中采用的就是快速排序,很多面试题中也经常遇到。
六. 归并排序
-
归并排序,是创建在归并操作上的一种有效的排序算法。算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
-
基本思想:归并排序是用分治思想,分治模式在每一层递归上有三个步骤:
- 分解(Divide):将n个元素分成个含n/2个元素的子序列。
- 解决(Conquer):用合并排序法对两个子序列递归的排序。
- 合并(Combine):合并两个已排序的子序列已得到排序结果。
-
实现逻辑:
- 迭代法:① 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
② 设定两个指针,最初位置分别为两个已经排序序列的起始位置
③ 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
④ 重复步骤③直到某一指针到达序列尾
⑤ 将另一序列剩下的所有元素直接复制到合并序列尾 - 递归法:① 将序列每相邻两个数字进行归并操作,形成floor(n/2)个序列,排序后每个序列包含两个元素
② 将上述序列再次归并,形成floor(n/4)个序列,每个序列包含四个元素
③ 重复步骤②,直到所有元素排序完毕
- 迭代法:① 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
-
动图演示
-
复杂度分析
平均时间复杂度:O(nlogn) 最佳时间复杂度:O(nlogn) 最差时间复杂度:O(nlogn) 空间复杂度:O(n) 排序方式:In-place 稳定性:稳定
不管元素在什么情况下都要做这些步骤,所以花销的时间是不变的,所以该算法的最优时间复杂度和最差时间复杂度及平均时间复杂度都是一样的为:O( nlogn )
归并的空间复杂度就是那个临时的数组和递归时压入栈的数据占用的空间:n + logn;所以空间复杂度为: O(n)。
归并排序算法中,归并最后到底都是相邻元素之间的比较交换,并不会发生相同元素的相对位置发生变化,故是稳定性算法。
-
代码实现
class Solution { public static void sort(int []arr){ int []temp = new int[arr.length];//在排序前,先建好一个长度等于原数组长度的临时数组,避免递归中频繁开辟空间 sort(arr,0,arr.length-1,temp); } private static void sort(int[] arr,int left,int right,int []temp){ if(left<right){ int mid = (left+right)/2; sort(arr,left,mid,temp);//左边归并排序,使得左子序列有序 sort(arr,mid+1,right,temp);//右边归并排序,使得右子序列有序 merge(arr,left,mid,right,temp);//将两个有序子数组合并操作 } } private static void merge(int[] arr,int left,int mid,int right,int[] temp){ int i = left;//左序列指针 int j = mid+1;//右序列指针 int t = 0;//临时数组指针 while (i<=mid && j<=right){ if(arr[i]<=arr[j]){ temp[t++] = arr[i++]; }else { temp[t++] = arr[j++]; } } while(i<=mid){//将左边剩余元素填充进temp中 temp[t++] = arr[i++]; } while(j<=right){//将右序列剩余元素填充进temp中 temp[t++] = arr[j++]; } t = 0; //将temp中的元素全部拷贝到原数组中 while(left <= right){ arr[left++] = temp[t++]; } } }
-
总结:归并排序和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。
七. 堆排序
-
堆一般指的是二叉堆,顾名思义,二叉堆是完全二叉树或者近似完全二叉树。
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
-
基本思想:利用大顶堆(小顶堆)堆顶记录的是最大关键字(最小关键字)这一特性,使得每次从无序中选择最大记录(最小记录)变得简单。
① 将待排序的序列构造成一个最大堆,此时序列的最大值为根节点 ② 依次将根节点与待排序序列的最后一个元素交换 ③ 再维护从根节点到该元素的前一个节点为最大堆,如此往复,最终得到一个递增序列
-
实现逻辑
① 先将初始的R[0…n-1]建立成最大堆,此时是无序堆,而堆顶是最大元素。 ② 再将堆顶R[0]和无序区的最后一个记录R[n-1]交换,由此得到新的无序区R[0…n-2]和有序区R[n-1],且满足R[0…n-2].keys ≤ R[n-1].key ③ 由于交换后新的根R[1]可能违反堆性质,故应将当前无序区R[1..n-1]调整为堆。然后再次将R[1..n-1]中关键字最大的记录R[1]和该区间的最后一个记录R[n-1]交换,由此得到新的无序区R[1..n-2]和有序区R[n-1..n],且仍满足关系R[1..n-2].keys≤R[n-1..n].keys,同样要将R[1..n-2]调整为堆。 ④ 直到无序区只有一个元素为止。
-
动画演示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XbvS69vB-1624428781913)(https://i.loli.net/2021/06/21/vIu2x86EaPlfOhM.gif)]
分布解析说明:
实现堆排序需要解决两个问题:
1. 如何由一个无序序列简称一个堆? 2. 如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?
假设给定一个组无序数列{100,5,3,11,6,8,7},带着问题,我们对其进行堆排序操作进行分步操作说明。
-
创建最大堆
(1) 首先我们将数组我们将数组从上至下按顺序排列,转换成二叉树:一个无序堆。每一个三角关系都是一个堆,上面是父节点,下面两个分叉是子节点,两个子节点俗称左孩子、右孩子;
(2) 转换成无序堆之后,我们要努力让这个无序堆变成最大堆(或是最小堆),即每个堆里都实现父节点的值都大于任何一个子节点的值。
(3) 从最后一个堆开始,即左下角那个没有右孩子的那个堆开始;首先对比左右孩子【每次在和父节点对比之前先对比左右孩子,用其中的大孩子和父节点比】,由于这个堆没有右孩子,所以只能用左孩子,左孩子的值比父节点的值小所以不需要交换。如果发生交换,要检测子节点是否为其他堆的父节点,如果是,递归进行同样的操作。
(4) 第二次对比红色三角形内的堆,取较大的子节点,右孩子8胜出,和父节点比较,右孩子8大于父节点3,升级做父节点,与3交换位置,3的位置没有子节点,这个堆建成最大堆。
(5) 对黄色三角形内堆进行排序,过程和上面一样,最终是右孩子33升为父节点,被交换的右孩子下面也没有子节点,所以直接结束对比。
(6) 最顶部绿色的堆,堆顶100比左右孩子都大,所以不用交换,至此最大堆创建完成。
-
堆排序(最大堆调整)
(1) 首先将堆顶元素100交换至最底部7的位置,7升至堆顶,100所在的底部位置即为有序区,有序区不参与之后的任何对比。
(2) 在7升至顶部之后,对顶部重新做最大堆调整,左孩子33代替7的位置。
(3) 在7被交换下来后,下面还有子节点,所以需要继续与子节点对比,左孩子11比7大,所以11与7交换位置,交换位置后7下面为有序区,不参与对比,所以本轮结束,无序区再次形成一个最大堆。
(4) 将最大堆堆顶33交换至堆末尾,扩大有序区;
(5) 不断建立最大堆,并且扩大有序区,最终全部有序。
-
-
复杂度分析
平均时间复杂度:O(nlogn) 最佳时间复杂度:O(nlogn) 最差时间复杂度:O(nlogn) 空间复杂度:O(1) 稳定性:不稳定
堆排序其实也是一种选择排序,是一种树形选择排序。只不过直接选择排序中,为了从R[1…n]中选择最大记录,需比较n-1次,然后从R[1…n-2]中选择最大记录需比较n-2次。事实上这n-2次比较中有很多已经在前面的n-1次比较中已经做过,而树形选择排序恰好利用树形的特点保存了部分前面的比较结果,因此可以减少比较次数。对于n个关键字序列,最坏情况下每个节点需比较log2(n)次,因此其最坏情况下时间复杂度为nlogn。堆排序为不稳定排序,不适合记录较少的排序。
-
代码实现
import java.util.Arrays; public class HeapSort { private int[] arr; public HeapSort(int[] arr){ this.arr = arr; } /** * 堆排序的主要入口方法,共两步。 */ public void sort(){ /* * 第一步:将数组堆化 * beginIndex = 第一个非叶子节点。 * 从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。 * 叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。 */ int len = arr.length - 1; int beginIndex = (len - 1) >> 1; //相当于(len-1)/2 for(int i = beginIndex; i >= 0; i--){ maxHeapify(i, len); } /* * 第二步:对堆化数据排序 * 每次都是移出最顶层的根节点A[0],与最尾部节点位置调换,同时遍历长度 - 1。 * 然后从新整理被换到根节点的末尾元素,使其符合堆的特性。 * 直至未排序的堆长度为 0。 */ for(int i = len; i > 0; i--){ swap(0, i); maxHeapify(0, i - 1); } } private void swap(int i,int j){ int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } /** * 调整索引为 index 处的数据,使其符合堆的特性。 * * @param index 需要堆化处理的数据的索引 * @param len 未排序的堆(数组)的长度 */ private void maxHeapify(int index,int len){ int li = (index << 1) + 1; // 左子节点索引,相当于index*2 + 1 int ri = li + 1; // 右子节点索引 int cMax = li; // 子节点值最大索引,默认左子节点。 if(li > len) return; // 左子节点索引超出计算范围,直接返回。 if(ri <= len && arr[ri] > arr[li]) // 先判断左右子节点,哪个较大。 cMax = ri; if(arr[cMax] > arr[index]){ swap(cMax, index); // 如果父节点被子节点调换, maxHeapify(cMax, len); // 则需要继续判断换下后的父节点是否符合堆的特性。 } } /** * 测试用例 * * 输出: * [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9] */ public static void main(String[] args) { int[] arr = new int[]{9,8,7,6,5,4,3,2,1}; new HeapSort(arr).sort(); System.out.println(Arrays.toString(arr)); }}
-
总结
堆是一种很好做调整的结构,在算法题里面使用频度很高。常用于想知道最大值或最小值的情况,比如优先级队列,作业调度等场景。
堆排序相看似比较复杂(建堆的过程,堆调整的过程,堆排序等等),需要好好推敲揣摩理清思路。堆排序操作过程中其运行时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上。
八. 计数排序
-
计数排序是一种稳定的线性时间排序算法。
-
基本思想:计数排序使用一个额外的数组C,其中第i个元素是待排序数组A中值等于i的元素的个数。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),然后进行分配、收集处理:
① 分配。扫描一遍原始数组,以当前值-minValue作为下标,将该下标的计数器增1。 ② 收集。扫描一遍计数器数组,按顺序把值收集起来。
-
实现逻辑
① 找出待排序的数组中最大和最小的元素 ② 统计数组中每个值为i的元素出现的次数,存入数组C的第i项 ③ 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加) ④ 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
-
动图演示
举个例子,假设有无序数列nums=[2, 1, 3, 1, 5], 首先扫描一遍获取最小值和最大值,maxValue=5, minValue=1,于是开一个长度为5的计数器数组counter。
(1) 分配 统计每个元素出现的频率,得到counter=[2, 1, 1, 0, 1],例如counter[0]表示值0+minValue=1出现了2次。 (2) 收集 counter[0]=2表示1出现了两次,那就向原始数组写入两个1,counter[1]=1表示2出现了1次,那就向原始数组写入一个2,依次类推,最终原始数组变为[1,1,2,3,5],排序好了。
-
复杂度分析
平均时间复杂度:O(n + k) 最佳时间复杂度:O(n + k) 最差时间复杂度:O(n + k) 空间复杂度:O(n + k)
当输入的元素是n 个0到k之间的整数时,它的运行时间是 O(n + k)。在实际工作中,当k=O(n)时,我们一般会采用计数排序,这时的运行时间为O(n)。
计数排序需要两个额外的数组用来对元素进行计数和保存排序的输出结果,所以空间复杂度为O(k+n)。
计数排序的一个重要性质是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序是相同的。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。
计数排序的稳定性很重要的一个原因是:计数排序经常会被用于基数排序算法的一个子过程。我们将在后面文章中介绍,为了使基数排序能够正确运行,计数排序必须是稳定的。
-
代码实现
public class CountingSort { public static void main(String[] argv) { int[] A = CountingSort.countingSort(new int[]{16, 4, 10, 14, 7, 9, 3, 2, 8, 1, 1}); System.out.println(Arrays.toString(A)); } public static int[] countingSort(int[] A) { int[] B = new int[A.length]; // 假设A中的数据a'有:0<=a' && a' < k并且k=100 int k = 100; countingSort(A, B, k); //A是原数组,B是经过排序后的数组,k是A数组中的取值范围 return B; } private static void countingSort(int[] A, int[] B, int k) { int[] C = new int[k];//C是计数数组 // 计数 for (int j = 0; j < A.length; j++) { int a = A[j]; C[a] += 1; } System.out.println(Arrays.toString(C)); // 求计数和 for (int i = 1; i < k; i++) { C[i] = C[i] + C[i - 1]; } System.out.println(Arrays.toString(C)); // 整理 for (int j = A.length - 1; j >= 0; j--) { int a = A[j]; B[C[a] - 1] = a; C[a] -= 1; } } }
一点思考:
计数排序为什么要逆序遍历整理呢?直接按照统计的结果,依次输出不就好了吗?可能这样做的话,就不是稳定的了,因为当原序列中有超过一个相同的数时,最终输出的结果无法保证其顺序和原顺序是一致的。
-
总结:计数算法只能使用在已知序列中的元素在0-k之间,且要求排序的复杂度在线性效率上。 Â 计数排序和基数排序很类似,都是非比较型排序算法。但是,它们的核心思想是不同的,基数排序主要是按照进制位对整数进行依次排序,而计数排序主要侧重于对有限范围内对象的统计。基数排序可以采用计数排序来实现。
九. 桶排序
-
桶排序(Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后依次把各个桶中的记录列出来记得到有序序列。桶排序是鸽巢排序的一种归纳结果。当要被排序的数组内的数值是均匀分配的时候,桶排序使用线性时间(Θ(n))。但桶排序并不是比较排序,他不受到O(n log n)下限的影响。
-
基本思想:
桶排序的思想近乎彻底的分治思想。
桶排序假设待排序的一组数均匀独立的分布在一个范围中,并将这一范围划分成几个子范围(桶)。
然后基于某种映射函数f ,将待排序列的关键字 k 映射到第i个桶中 (即桶数组B 的下标i) ,那么该关键字k 就作为 B[i]中的元素 (每个桶B[i]都是一组大小为N/M 的序列 )。
接着将各个桶中的数据有序的合并起来 : 对每个桶B[i] 中的所有元素进行比较排序 (可以使用快排)。然后依次枚举输出 B[0]….B[M] 中的全部内容即是一个有序序列。
补充: 映射函数一般是 f = array[i] / k; k^2 = n; n是所有元素个数
为了使桶排序更加高效,我们需要做到这两点:
1、在额外空间充足的情况下,尽量增大桶的数量;
2、使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中;
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
-
实现逻辑
- 设置一个定量的数组当作空桶子。 - 寻访序列,并且把项目一个一个放到对应的桶子去。 - 对每个不是空的桶子进行排序。 - 从不是空的桶子里把项目再放回原来的序列中。
-
动图演示
-
复杂度分析
对于N个待排数据,M个桶,平均每个桶[N/M]个数据的桶排序平均时间复杂度为: O(N)+O(M*(N/M)*log(N/M))=O(N+N*(logN-logM))=O(N+N*logN-N*logM),当N=M时,即极限情况下每个桶只有一个数据时,桶排序的最好效率能够达到O(N)。 空间复杂度:O(N + M) 稳定性:稳定
-
代码实现
// 计数排序(Java) public class CountingSort { public static void main(String[] argv) { CountingSort.bucketSort(new int[]{16, 4, 10, 14, 7, 9, 3, 2, 8, 1, 1}); } public static void bucketSort(int[] arr){ // 计算最大值与最小值 int max = Integer.MIN_VALUE; int min = Integer.MAX_VALUE; for(int i = 0; i < arr.length; i++){ max = Math.max(max, arr[i]); min = Math.min(min, arr[i]); } // 计算桶的数量 int bucketNum = (max - min) / arr.length + 1; ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum); for(int i = 0; i < bucketNum; i++){ bucketArr.add(new ArrayList<Integer>()); } // 将每个元素放入桶 for(int i = 0; i < arr.length; i++){ int num = (arr[i] - min) / (arr.length); bucketArr.get(num).add(arr[i]); } // 对每个桶进行排序 for(int i = 0; i < bucketArr.size(); i++){ Collections.sort(bucketArr.get(i)); } // 将桶中的元素赋值到原序列 int index = 0; for(int i = 0; i < bucketArr.size(); i++){ for(int j = 0; j < bucketArr.get(i).size(); j++){ arr[index++] = bucketArr.get(i).get(j); } } } }
-
总结
桶排序是计数排序的变种,它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。把计数排序中相邻的m个”小桶”放到一个”大桶”中,在分完桶后,对每个桶进行排序(一般用快排),然后合并成最后的结果。
算法思想和散列中的开散列法差不多,当冲突时放入同一个桶中;可应用于数据量分布比较均匀,或比较侧重于区间数量时。
桶排序最关键的建桶,如果桶设计得不好的话桶排序是几乎没有作用的。通常情况下,上下界有两种取法,第一种是取一个 1 0 n 10^n 10n或者是 2 n 2^n 2n的数,方便实现。另一种是取数列的最大值和最小值然后均分作桶.
十. 基数排序
-
基数排序(Radix sort)是一种非比较型整数排序算法。
-
基本思想:原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。基数排序的方式可以采用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由键值的最右边开始,而MSD则相反,由键值的最左边开始。
- MSD:先从高位开始进行排序,在每个关键字上,可采用计数排序
- LSD:先从低位开始进行排序,在每个关键字上,可采用桶排序
-
实现逻辑
① 将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。 ② 从最低位开始,依次进行一次排序。 ③ 这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
-
动图演示
例如我们现在要对这组元素来排序:
由于我们是以每个数的某位数来排序的,这位数的范围是0-9,所以我们需要10个桶。
第一遍,先以个位数排序,把具有相同个位数的数放进桶里,结果如下:之后再按照从0号桶到9号桶的顺序取出来,结果如下:
个位数排序完成。
第二遍,以十位数来排,结果如下:
再取出来放回去:
十位数排序完成,最终的结果就是一组有序的元素。如果元素中有百位数的话,大不了就按照百位数再给他重复排一遍。
思考:为什么要从个位数开始排序呢?可以直接从最高位开始排序吗?如果从最高位开始排序的话,如果一个数最高位比另一个数大,那么这个数就一定比另外一个数大了,不用在比较次高位了。这样的话,不是可以排的更快吗?
第一遍:最高位十位数排序,结果如下(有些没用到的桶给省略了):
显然,不在桶一个桶里的数,他们的大小顺序已经是已知的了,也就是说,右边桶的数一定比左边桶的数大,所有在接下来的个位数排序里,我们只需要进行“各部分”单独排序就可以了,每一小部分都类似于原问题的一个子问题,做的时候可以采用递归的形式来处理。
最后汇总,即可完成排序:这种方法确实可以减少比较的次数,不过请大家注意,在每个小部分的排序中,我们也是需要10个桶来将他们进行排序,最后导致的结果就是,每个不同值的元素都会占据一个“桶”,如果你有1000个元素,并且1000个元素都是不同值的话,那么从最高位排序到最低位,需要1000个桶。
这样子的话,空间花费不仅大,而且看起来有点背离基数排序最初的思想了(“背离”这个词,个人感觉而已)。所以,我们一般采用从最低位到最高位的顺序哦。
思考底层原理:为什么基数排序(从个位数开始比,逐渐升高位数)是有效的?
举个例子:比如要排的数有68,38,42,55,64四个数,首先通过个位数排的话,就可以得到顺序42,64,55,68,38,这个结果说明如果单纯看个位的话,个位是8的数是更可能整体上靠后的,而具体个位数都是8的那些数如何比较呢?就需要通过十位数的排序了,通过个位和十位一起排序,68和38就能很轻易地排序了。而对于64和68这样个位数本身就不同,而十位数相同的,就更直接了,在个位排序的时候就能决定了;而对于像64和38这样的数,尽管从个位数看64会排在38前面,但十位排序可以更改个位排序的结果。总之就是,低位数的排序能够给排序提供可能性的依据,而高位数的排序能够对低位数排序的结果做判断,决定其是否合理。
-
复杂度分析
时间复杂度:O(k*N) 空间复杂度:O(k + N) 稳定性:稳定
-
代码实现
// 基数排序(Java) public class RadixSort { /* * 获取数组a中最大值 * * 参数说明: * a -- 数组 * n -- 数组长度 */ private static int getMax(int[] a) { int max; max = a[0]; for (int i = 1; i < a.length; i++) if (a[i] > max) max = a[i]; return max; } /* * 对数组按照"某个位数"进行排序(桶排序) * * 参数说明: * a -- 数组 * exp -- 指数。对数组a按照该指数进行排序。 * * 例如,对于数组a={50, 3, 542, 745, 2014, 154, 63, 616}; * (01) 当exp=1表示按照"个位"对数组a进行排序 * (02) 当exp=10表示按照"十位"对数组a进行排序 * (03) 当exp=100表示按照"百位"对数组a进行排序 * ... */ private static void countSort(int[] a, int exp) { //int output[a.length]; // 存储"被排序数据"的临时数组 int[] output = new int[a.length]; // 存储"被排序数据"的临时数组 int[] buckets = new int[10]; // 将数据出现的次数存储在buckets[]中 for (int i = 0; i < a.length; i++) buckets[ (a[i]/exp)%10 ]++; // 更改buckets[i]。目的是让更改后的buckets[i]的值,是该数据在output[]中的位置。 for (int i = 1; i < 10; i++) buckets[i] += buckets[i - 1]; // 将数据存储到临时数组output[]中 for (int i = a.length - 1; i >= 0; i--) { output[buckets[ (a[i]/exp)%10 ] - 1] = a[i]; buckets[ (a[i]/exp)%10 ]--; } // 将排序好的数据赋值给a[] for (int i = 0; i < a.length; i++) a[i] = output[i]; output = null; buckets = null; } /* * 基数排序 * * 参数说明: * a -- 数组 */ public static void radixSort(int[] a) { int exp; // 指数。当对数组按各位进行排序时,exp=1;按十位进行排序时,exp=10;... int max = getMax(a); // 数组a中的最大值 // 从个位开始,对数组a按"指数"进行排序 for (exp = 1; max/exp > 0; exp *= 10) countSort(a, exp); } public static void main(String[] args) { int i; int a[] = {53, 3, 542, 748, 14, 214, 154, 63, 616}; System.out.printf("before sort:"); for (i=0; i<a.length; i++) System.out.printf("%d ", a[i]); System.out.printf("\n"); radixSort(a); // 基数排序 System.out.printf("after sort:"); for (i=0; i<a.length; i++) System.out.printf("%d ", a[i]); System.out.printf("\n"); } }
-
总结
基数排序与计数排序、桶排序这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 基数排序:根据键值的每位数字来分配桶;
- 计数排序:每个桶只存储单一键值;
- 桶排序:每个桶存储一定范围的数值;
基数排序不是直接根据元素整体的大小进行元素比较,而是将原始列表元素分成多个部分,对每一部分按一定的规则进行排序,进而形成最终的有序列表。
总结一下:这些算法的复杂度计算过程,没弄懂,太乱了,网上都是只给了个结果,没有说理由。
参考的博客:https://www.zhihu.com/people/developer1024