常见的十种排序算法总结及Java实现
一、排序算法概述
1、定义
将杂乱无章的数据元素,通过一定的方法按关键字顺序排列的过程叫做排序。
2、分类
十种常见排序算法可以分为两大类:
-
非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。
-
线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。
3、比较
4、相关概念
- 稳定:如果a原本在b前面且a=b,排序之后a仍然在b的前面。
- 不稳定:如果a原本在b的前面且a=b,排序之后 a 可能会出现在 b 的后面。
- 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
- 空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
- 内部排序:所有排序操作都在内存中完成。本文主要介绍的是内部排序。
- 外部排序:待排序记录的数量很大,以致于内存不能一次容纳全部记录,所以在排序过程中需要对外存进行访问的排序过程。
二、各算法原理及实现
1、算法的基本思想是什么?
2、算法的代码实现?
3、算法的时间复杂度是多少?(平均、最好、最坏)什么情况下最好?什么情况下最坏?
4、算法的空间复杂度是多少?
5、算法的稳定性如何?
1、冒泡排序(Bubble Sort)
1.1 算法描述
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
1.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); sort1(arr); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /*冒泡排序 * 依次比较相邻的两个元素,通过一次比较把未排序序列中最大(或最小)的元素放置在未排序序列的末尾 * */ public static void sort1(int[] arr) { System.out.println("冒泡排序:"); //外层循环,遍历次数 for (int i = 0; i < arr.length - 1; i++) { //内层循环,升序(如果前一个值比后一个值大,则交换) 第i趟比较arr.length-i次 //内层循环一次,获取一个最大值 for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j + 1]; arr[j + 1] = arr[j]; arr[j] = temp; } } } } }
1.4 算法分析
1.4.1时间复杂度
冒泡排序平均时间复杂度为O(n2),最好时间复杂度为O(n),最坏时间复杂度为O(n2)。
最好情况:如果待排序元素本来是正序的,那么一趟冒泡排序就可以完成排序工作,比较和移动元素的次数分别是 (n - 1) 和 0,因此最好情况的时间复杂度为O(n)。
最坏情况:如果待排序元素本来是逆序的,需要进行 (n - 1) 趟排序,所需比较和移动次数分别为 n * (n - 1) / 2和 3 * n * (n-1) / 2。因此最坏情况下的时间复杂度为O(n2)。
1.4.2 空间复杂度
冒泡排序使用了常数空间,空间复杂度为O(1)
1.4.3稳定性
当 array[j] == array[j+1] 的时候,我们不交换 array[i] 和 array[j],所以冒泡排序是稳定的。
1.4.4算法拓展
鸡尾酒排序,又称定向冒泡排序、搅拌排序等,是对冒泡排序的改进。在把最大的数往后面冒泡的同时,把最小的数也往前面冒泡,同时收缩无序区的左右边界,有序区在序列左右逐渐累积。
public static void cocktailSort(int[] array) { int left = 0,right = array.length-1; while(left < right) { for(int i = left; i < right; i++) if(array[i] > array[i+1]) swap(array,i,i + 1); right--; for(int i = right; i > left; i--) if(array[i] < array[i-1]) swap(array,i,i-1); left++; } }
鸡尾酒排序是稳定的。它的平均时间复杂度为O(n2),最好情况是待排序列原先就是正序的,时间复杂度为O(n),最坏情况是待排序列原先是逆序的,时间复杂度为O(n2)。空间复杂度为O(1).
2、选择排序(Selection Sort)
选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
2.1 算法描述
n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:
- 初始状态:无序区为R[1..n],有序区为空;
- 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
- n-1趟结束,数组有序化了。
2.2 动图演示
2.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); sort2(arr); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /*选择排序 a、将第一个值看成最小值 b、然后和后续的比较找出最小值和下标 c、交换本次遍历的起始值和最小值 d、说明:每次遍历的时候,将前面找出的最小值,看成一个有序的列表,后面的看成无序的列表,然后每次遍历无序列表找出最小值 * */ public static void sort2(int[] arr){ System.out.println("选择排序:"); //外层循环,遍历次数 for(int i=0;i<arr.length;i++){ int min = arr[i];//默认第一个是最小的 int index = i;//记录最小的下标 //通过与后面的数据进行比较得出,最小值和下标 for(int j=i+1;j<arr.length;j++){ if(min>arr[j]){ min=arr[j]; index = j; } } //然后将最小值与本次循环的开始值交换 if (min != arr[i] && index != i) { int temp = arr[i]; arr[i] = min; arr[index] = temp; //说明:将i前面的数据看成一个排好的队列,i后面的看成一个无序队列。每次只需要找无序的最小值,做替换 } } } }
2.4 算法分析
2.4.1 时间复杂度
简单选择排序平均时间复杂度为O(n2),最好时间复杂度为O(n2),最坏时间复杂度为O(n2)。
最好情况:如果待排序元素本来是正序的,则移动元素次数为 0,但需要进行 n * (n - 1) / 2 次比较。
最坏情况:如果待排序元素中第一个元素最大,其余元素从小到大排列,则仍然需要进行 n * (n - 1) / 2 次比较,且每趟排序都需要移动 3 次元素,即移动元素的次数为3 * (n - 1)次。
需要注意的是,简单选择排序过程中需要进行的比较次数与初始状态下待排序元素的排列情况无关。
2.4.2 空间复杂度
简单选择排序使用了常数空间,空间复杂度为O(1)
2.4.3 稳定性
简单选择排序不稳定,比如序列 2、4、2、1,我们知道第一趟排序第 1 个元素 2 会和 1 交换,那么原序列中 2 个 2 的相对前后顺序就被破坏了,所以简单选择排序不是一个稳定的排序算法。
3、插入排序(Insertion Sort)
插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
3.1 算法描述
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
3.2 动图演示
3.2 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); sort3(arr); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /*插入排序 a、默认从第二个数据开始比较。 b、如果第二个数据比第一个小,则交换。然后在用第三个数据比较,如果比前面小,则插入(交换)。否则,退出循环 c、说明:默认将第一数据看成有序列表,后面无序的列表循环每一个数据,如果比前面的数据小则插入(交换)。否则退出。 * */ public static void sort3(int[] arr){ System.out.println("插入排序:"); //外层循环,从第二个开始比较 for(int i=0;i<arr.length;i++){ //内存循环,与前面排好序的数据比较,如果后面的数据小于前面的则交换 for(int j=i;j>0;j--){ if(arr[j]<arr[j-1]){ int temp = arr[j]; arr[j]=arr[j-1]; arr[j-1]=temp; } } } } }
3.4 算法分析
3.4.1 时间复杂度
直接插入排序平均时间复杂度为O(n2),最好时间复杂度为O(n),最坏时间复杂度为O(n2)。
最好情况:如果待排序元素本来是正序的,比较和移动元素的次数分别是 (n - 1) 和 0,因此最好情况的时间复杂度为O(n)。
最坏情况:如果待排序元素本来是逆序的,需要进行 (n - 1) 趟排序,所需比较和移动次数分别为 n * (n - 1) / 2和 n * (n - 1) / 2。因此最坏情况下的时间复杂度为O(n2)。
3.4.2 空间复杂度
直接插入排序使用了常数空间,空间复杂度为O(1)
3.4.3 稳定性
直接插入排序是稳定的。
3.4.4算法拓展
在直接插入排序中,待插入的元素总是在有序区线性查找合适的插入位置,没有利用有序的优势,考虑使用二分查找搜索插入位置进行优化,即二分插入排序。
public static int[] BinaryInsertionSort(int[] array) { if (array.length == 0) return array; for(int i = 1;i < array.length;i++) { int left = 0; int right = i - 1; // left 和 right 分别为有序区的左右边界 int current = array[i]; while (left <= right) { //搜索有序区中第一个大于 current 的位置,即为 current 要插入的位置 int mid = left + ((right - left) >> 1); if(array[mid] > current){ right = mid - 1; }else{ left = mid + 1; } } for(int j = i - 1;j >= left;j--) { array[j + 1] = array[j]; } array[left] = current; // left 为第一个大于 current 的位置,插入 current } return array; }
二分插入排序是稳定的。它的平均时间复杂度是O(n2),最好时间复杂度为O(nlogn),最坏时间复杂度为O(n2)。
4、希尔排序(Shell Sort)
1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
4.1 算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
-
增量d 的范围: **1<= d < 待排序数组的长度 (d 需为 int 值)
增量的取值: **一般的初次取序列(数组)的一半为增量,以后每次减半,直到增量为1。
第一个增量=数组的长度/2,
第二个增量= 第一个增量/2,
第三个增量=第二个增量/2,
以此类推,最后一个增量=1。
4.2 动图演示
4.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); shellSort(arr); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } //希尔排序 /* 希尔排序是将待排序的数组元素 按下标的一定增量分组 ,分成多个子序列,然后对各个子序列进行直接插入排序算法排序; 然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。 **增量d 的范围: **1<= d < 待排序数组的长度 (d 需为 int 值) **增量的取值: **一般的初次取序列(数组)的一半为增量,以后每次减半,直到增量为1。 第一个增量=数组的长度/2, 第二个增量= 第一个增量/2, 第三个增量=第二个增量/2, 以此类推,最后一个增量=1 (注:为方便记忆算法,我习惯将其记作“三层for循环+if” ------ for(for(for(if)))) a、基本上和插入排序一样的道理 b、不一样的地方在于,每次循环的步长,通过减半的方式来实现 * */ public static void shellSort(int[] arr){ System.out.println("希尔排序:"); //i层循环控制步长 for(int i=arr.length/2;i>0;i=i/2){ //j:代表即将插入的元素角标,作为每一组比较数据的最后一个元素角标 for(int j = i; j < arr.length; j++){ //k:代表与j同一组的数组元素角标 for(int k=j-i;k>=0;k=k-i){ //在此处-i 为了避免下面数组角标越界 if(arr[k]>arr[k+i]){// k+i 代表即将插入的元素所在的角标 //符合条件,插入元素(交换位置) int temp = arr[k]; arr[k]=arr[k+i]; arr[k+i]=temp; } // for (int k = j; k > 0 && k - i >= 0; k -= i) { // if (arr[k] < arr[k - i]) { // int temp = arr[k - i]; // arr[k - i] = arr[k]; // arr[k] = temp; // } else { // break; // } // } } } } } }
4.4 算法分析
希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。
4.4.1 时间复杂度
希尔排序平均时间复杂度为O(nlogn),最好时间复杂度为O(nlog2n),最坏时间复杂度为O(nlog2n)。希尔排序的时间复杂度与增量序列的选取有关。
4.4.2 空间复杂度
希尔排序使用了常数空间,空间复杂度为O(1)
4.4.3 稳定性
由于相同的元素可能在各自的序列中插入排序,最后其稳定性就会被打乱,比如序列 2、4、1、2,所以希尔排序是不稳定的。
5、归并排序(Merge Sort)
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
5.1 算法描述
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
5.2 动图演示
5.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); mergeSort(arr,0,arr.length-1); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /** * 归并排序: 将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。 a、将列表按照对等的方式进行拆分 b、拆分最小快的时候,在将最小块按照原来的拆分,进行合并 c、合并的时候,通过左右两块的左边开始比较大小。小的数据放入新的块中 d、说明:简单一点就是先对半拆成最小单位,然后将两半数据合并成一个有序的列表。 * */ public static void mergeSort(int arr[], int start, int end){ if(start < end){ mergeSort(arr,start,(start+end)/2); mergeSort(arr,(start+end)/2+1,end); //记录开始/结束位置 int left = start; int right = (start + end) / 2 + 1; //记录每个小单位的排序结果 int index = 0; int[] result = new int[end - start + 1]; //如果查分后的两块数据,都还存在 while(left<=(start+end)/2&&right<=end){ //比较两块数据的大小,然后赋值,并且移动下标 if(arr[left]<=arr[right]){ result[index]=arr[left]; left++; }else{ result[index]=arr[right]; right++; } //移动单位记录的下标 index++; } //当某一块数据不存在了时 while(left <= (start + end) / 2 || right <= end){ //直接赋值到记录下标 if(left<=(start + end) / 2){ result[index]=arr[left]; left++; }else{ result[index] = arr[right]; right++; } index++; } //最后将新的数据赋值给原来的列表,并且是对应分块后的下标。 for(int i=start;i<=end;i++){ arr[i]=result[i-start]; } } } }
5.4 算法分析
归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。
5.4.1时间复杂度
归并排序平均时间复杂度为O(nlogn),最好时间复杂度为O(nlogn),最坏时间复杂度为O(nlogn)。
归并排序的形式就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的可以得出它在任何情况下时间复杂度均是O(nlogn)。
5.4.2空间复杂度
归并排序空间复杂度为O(n)
5.4.3 稳定性
归并排序是稳定的。
5.4.4算法应用
归并排序可以用于求解逆序对数量问题,具体见:剑指offer - 数组中的逆序对
import java.util.*; public class Solution { private static final int MOD = 1000000007; private int cnt = 0; //递归调用 private int[] MergeSort(int[] array) { if (array.length < 2) return array; int mid = array.length / 2; int[] left = Arrays.copyOfRange(array, 0, mid); int[] right = Arrays.copyOfRange(array, mid, array.length); return merge(MergeSort(left), MergeSort(right)); } /** * 将两段有序数组结合成一个有序数组 * * @param left * @param right * @return */ private int[] merge(int[] left, int[] right) { int[] result = new int[left.length + right.length]; int i = 0,j = 0,k = 0; while (i < left.length && j < right.length) { if (left[i] <= right[j]) { result[k++] = left[i++]; } else { result[k++] = right[j++]; /*归并同时统计逆序对数量,因为归并的两个子序列都已有序,故当left[i] > right[j],有left[i...left.length - 1]均大于right[j]*/ this.cnt = (this.cnt % MOD + (left.length - i) % MOD) % MOD; } } while (i < left.length) { result[k++] = left[i++]; } while (j < right.length) { result[k++] = right[j++]; } return result; } public int InversePairs(int [] array) { MergeSort(array); return cnt % MOD; } }
6、快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
快速排序算法是比较重要的算法,回头会单独在写一篇。
6.1 算法描述
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
6.2 动图演示
6.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); quickSort(arr,0,arr.length-1); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /* * 快速排序: a、确认列表第一个数据为中间值,第一个值看成空缺(低指针空缺)。 b、然后在剩下的队列中,看成有左右两个指针(高低)。 c、开始高指针向左移动,如果遇到小于中间值的数据,则将这个数据赋值到低指针空缺,并且将高指针的数据看成空缺值(高指针空缺)。 然后先向右移动一下低指针,并且切换低指针移动。 d、当低指针移动到大于中间值的时候,赋值到高指针空缺的地方。然后先高指针向左移动,并且切换高指针移动。重复c、d操作。 e、直到高指针和低指针相等时退出,并且将中间值赋值给对应指针位置。 f、然后将中间值的左右两边看成行的列表,进行快速排序操作。 自己的理解: ①先从队尾开始向前扫描且当low < high时,如果a[high] > tmp,则high–,但如果a[high] < tmp,则将high的值赋值给low,即arr[low] = a[high], 同时要转换数组扫描的方式,即需要从队首开始向队尾进行扫描了 ②同理,当从队首开始向队尾进行扫描时,如果a[low] < tmp,则low++,但如果a[low] > tmp了,则就需要将low位置的值赋值给high位置, 即arr[low] = arr[high],同时将数组扫描方式换为由队尾向队首进行扫描. ③不断重复①和②,知道low>=high时(其实是low=high),low或high的位置就是该基准数据在数组中的正确索引位置. */ public static void quickSort(int[] arr,int low,int high){ //如果指针在同一位置(只有一个数据时),退出 if(low<high){ //进行第一轮排序获取分割点 int index = parttion(arr,low,high); // 进行迭代对index之前和之后的数组进行相同的操作使整个数组变成有序 //排序前半部分 quickSort(arr,low,index-1); //排序前半部分 quickSort(arr,index+1,high); } } private static int parttion(int[] arr, int low, int high) { int key = arr[low];//选取基准点 while(low<high){ // 当队尾的元素大于等于基准数据时,向前挪动high指针 while(low < high && arr[high] >= key){ high--; } // 如果队尾元素小于key了,需要将其赋值给low if(low<high){ arr[low] = arr[high]; } // 当队首元素小于等于key时,向前挪动low指针 while(low < high && arr[low] <= key){ low++; } // 当队首元素大于tmp时,需要将其赋值给high if(low<high){ arr[high] = arr[low]; } } // 跳出循环时low和high相等,此时的low或high就是tmp的正确索引位置 // 由原理部分可以很清楚的知道low位置的值并不是key,所以需要将key赋值给arr[low] arr[low] = key; return low; // 返回key的正确位置 } }
6.4 算法分析
6.4.1 时间复杂度
快速排序平均时间复杂度为O(nlogn),最好时间复杂度为O(nlogn),最坏时间复杂度为O(n2)。
最好情况:基准选择得当,partition函数每次恰好能均分序列,其递归树的深度就为logn,时间复杂度为O(nlogn)。
最坏情况:选择了最大或者最小数字作为基准,每次划分只能将序列分为一个元素与其他元素两部分,此时快速排序退化为冒泡排序,如果用树画出来,得到的将会是一棵单斜树,即所有的结点只有左(右)结点的树,树的深度为 n,时间复杂度为O(n2)。
6.4.2 空间复杂度
快速排序的空间复杂度主要考虑递归时使用的栈空间。
在最好情况下,即partition函数每次恰好能均分序列,空间复杂度为O(logn);在最坏情况下,即退化为冒泡排序,空间复杂度为O(n)。平均空间复杂度为O(logn)。
6.4.3 稳定性
快速排序是不稳定的。
6.4.4算法拓展
快速选择算法
快速选择算法用于求解 Kth Element 问题(无序数组第K大元素),使用快速排序的 partition() 进行实现。
快速排序的 partition() 方法会返回一个整数 j 使得 a[left..j-1] 小于等于 a[j],且 a[j+1..right] 大于等于 a[j]。
此时 a[j] 就是数组的第 j 小的元素,我们可以转换一下题意,第 k 大的元素就是第 nums.size() - k 小的元素。
找到 Kth Element 之后,再遍历一次数组,所有大于等于 Kth Element 的元素都是 TopK Elements。
时间复杂度 O(N),空间复杂度 O(1)。
还可以使用小根堆求解此问题,时间复杂度 O(NlogK),空间复杂度 O(K)。具体见:leetcode 215
7、堆排序(Heap Sort)
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
堆是一个树形结构,其实堆的底层是一棵完全二叉树。而完全二叉树是一层一层按照进入的顺序排成的。按照这个特性,我们可以用数组来按照完全二叉树实现堆。
每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
7.1 算法描述
- 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
7.2 动图演示
7.3 代码实现
public class SortDemo { public static void main(String[] args) { int[] arr = {8, 4, 9, 2, 1, 6, 3, 7, 5, 8}; System.out.println("排序前:"); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } System.out.println(""); System.out.println("排序后:"); heapSort(arr); for (int i = 0; i < arr.length; i++) { System.out.print(arr[i] + " "); } } /** * 堆排序 * 创建堆 arr待排序列 */ public static void heapSort(int[] arr){ //1.构造初始大顶堆 for(int i=(arr.length-1)/2;i>=0;i--){ //从第一个非叶子结点从下至上,从右至左调整结构,左右孩子节点中较大的交换到父节点中 adjustHeap(arr,i,arr.length); } //2.调整堆结构+交换堆顶元素与末尾元素 for(int j=arr.length-1;j>0;j--){ //将堆顶元素与末尾元素进行交换 int temp = arr[j]; arr[j] = arr[0]; arr[0] = temp; adjustHeap(arr,0,j);//重新对堆进行调整 } } /** * 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上) * i 父节点, j 待排序列尾元素索引 * */ private static void adjustHeap(int[] arr, int parent, int length) { //将temp作为父节点 int temp = arr[parent]; //左孩子 int lChild = 2 * parent + 1; while(lChild<length){ //右孩子 int rChild = lChild + 1; // 如果有右孩子结点,并且右孩子结点的值大于左孩子结点,则选取右孩子结点 if(rChild<length&&arr[lChild] < arr[rChild]){ lChild++; } // 如果父结点的值已经大于孩子结点的值,则直接结束 if (temp >= arr[lChild]) { break; }else{ // 把孩子结点的值赋给父结点 arr[parent] = arr[lChild]; //选取孩子结点的左孩子结点,继续向下筛选 parent = lChild; lChild = 2 * lChild + 1; } } arr[parent] = temp;//将temp值放到最终的位置 } }
7.4 算法分析
1)插入元素:只需要把待插入的元素放置在堆尾,然后 len++ 把其纳入堆,然后调用 adjustHeap 函数重新调整堆即可。
2)删除堆顶元素:只需要把堆顶元素交换到堆尾,然后 len-- 把其移出堆,然后调用 adjustHeap 函数重新调整堆即可。
7.4.1 时间复杂度
堆排序平均时间复杂度为O(nlogn),最好时间复杂度为O(nlogn),最坏时间复杂度为O(nlogn)。
堆排序的形式就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的可以得出它在任何情况下时间复杂度均是O(nlogn)。
7.4.2 空间复杂度
堆排序使用了常数空间,空间复杂度为O(1)。
7.4.3 稳定性
堆排序是不稳定的
后三种外部排序算法:计数排序、桶排序、基数排序,本人没有测试过代码,只是为了整理。
8、计数排序(Counting Sort)
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
8.1 算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
8.2 动图演示
8.3 代码实现
/** * 计数排序 * * @param array * @return */ public static int[] CountingSort(int[] array) { if (array.length == 0) return array; int bias, min = Integer.MAX_VALUE, max = Integer.MIN_VALUE; for (int i = 0; i < array.length; i++) { max = Math.max(max, array[i]); min = Math.min(min, array[i]); } //计算偏移量,将 min ~ max 映射到 bucket 数组的 0 ~ (max - min) 位置上 bias = -min; int[] bucket = new int[max - min + 1]; Arrays.fill(bucket, 0); for (int i = 0; i < array.length; i++) { bucket[array[i] + bias]++; } int index = 0, i = 0; while (index < array.length) { if (bucket[i] != 0) { array[index] = i - bias; bucket[i]--; index++; } else i++; } return array; }
8.4 算法分析
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
8.4.1 时间复杂度
计数排序平均时间复杂度为O(n + k),最好时间复杂度为O(n + k),最坏时间复杂度为O(n + k)。n 为遍历一趟数组计数过程的复杂度,k 为遍历一趟桶取出元素过程的复杂度。
8.4.2 空间复杂度
计数排序空间复杂度为O(k),k为桶数组的长度。
8.4.3稳定性
计数排序是稳定的。
9、桶排序(Bucket Sort)
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
9.1 算法描述
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
9.2 动图演示
9.3 代码实现
public static int[] bucketSort(int[] array){ int max = Integer.MIN_VALUE; int min = Integer.MAX_VALUE; for(int i = 0; i < array.length; i++){ max = Math.max(max, array[i]); min = Math.min(min, array[i]); } /*桶映射函数:自己设计,要保证桶 i 的数均小于桶 j (i < j)的数, 即必须桶间必须有序,桶内可以无序。这里桶映射函数为:(i - min) / arr.length*/ int bucketNum = (max - min) / array.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 < array.length; i++){ int num = (array[i] - min) / (array.length); bucketArr.get(num).add(array[i]); } //对每个桶进行排序 for(int i = 0; i < bucketArr.size(); i++){ Collections.sort(bucketArr.get(i)); } int k = 0; for(int i = 0; i < bucketArr.size(); i++){ for(int j = 0;j < bucketArr.get(i).size();j++) { array[k++] = bucketArr.get(i).get(j); } } return array; }
9.4 算法分析
桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
9.4.1 时间复杂度
桶排序平均时间复杂度为O(n + k),最好时间复杂度为O(n + k),最坏时间复杂度为O(n2)。
9.4.2 空间复杂度
桶排序空间复杂度为O(n + k)。
9.4.3 稳定性
桶排序是稳定的。
10、基数排序(Radix Sort)
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
10.1 算法描述
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
10.2 动图演示
10.3 代码实现
/** * 基数排序 * @param array * @return */ public static int[] RadixSort(int[] array) { if (array == null || array.length < 2) return array; // 1.先算出最大数的位数; int max = Integer.MIN_VALUE; for (int i = 0; i < array.length; i++) { max = Math.max(max, array[i]); } int maxDigit = 0; while (max != 0) { max /= 10; maxDigit++; } int div = 1; ArrayList<ArrayList<Integer>> bucketList = new ArrayList<ArrayList<Integer>>(); for (int i = 0; i < 10; i++) bucketList.add(new ArrayList<Integer>()); //2.进行maxDigit趟分配 for (int i = 0; i < maxDigit; i++,div *= 10) { for (int j = 0; j < array.length; j++) { int num = (array[j] / div) % 10; bucketList.get(num).add(array[j]); } //3.收集 int index = 0; for (int j = 0; j < bucketList.size(); j++) { for (int k = 0; k < bucketList.get(j).size(); k++) array[index++] = bucketList.get(j).get(k); bucketList.get(j).clear(); } } return array; }
10.4 算法分析
基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。
10.4.1 时间复杂度
基数排序平均时间复杂度为O(n * k),最好时间复杂度为O(n * k),最坏时间复杂度为O(n * k)。
10.4.2 空间复杂度
基数排序空间复杂度为O(n + k)。
10.4.3 稳定性
基数排序是稳定的。
基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 基数排序: 根据键值的每位数字来分配桶
- 计数排序: 每个桶只存储单一键值
- 桶排序: 每个桶存储一定范围的数值
三、各排序算法应用场景及选择
2)若元素初始状态基本有序(正序),直接插入、冒泡或快速排序为宜。
3)若 n 较大,则应采用时间复杂度为O(nlogn)的排序方法:快速排序、堆排序或归并排序。
快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短。
堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况。这两种排序都是不稳定的。
若要求排序稳定,则可选用归并排序。但本文介绍的从单个记录起进行两两归并的归并排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。
4)当范围已知,且空间不是很重要的情况下可以考虑使用桶排序。
动图网站: https://visualgo.net/zh/sorting?slide=10-2
参考地址: