十大排序算法总结
一、相关术语
1、原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。
2、稳定性指的是如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
3、逆序度 = 满有序度 - 有序度
有序度是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:有序元素对:a[i] <= a[j], 如果i < j。例如:
同理,逆序度的定义正好跟有序度相反(默认从小到大为有序),对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。我们把这种完全有序的数组的有序度叫作满有序度。
二、冒泡排序
以升序为例,两个相邻的数比较大小,如果前面的数比后面的数大,那么交换位置。双重for循环,外层循环控制轮数,内层循环控制每轮比较的次数,每轮比较会将该轮最大数交换到最后面。
代码:
public class BubbleSort { public static void main(String[] args) { int[] arr = {3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}; BubbleSort(arr); System.out.println(Arrays.toString(arr)); } public static void BubbleSort(int []arr) { //外层循环控制轮数 for(int i=1;i<=arr.length-1;i++) { //内层循环控制每轮比较的次数 for(int j=1;j<=arr.length-i;j++) { if(arr[j-1]>arr[j]) { //借助中间变量交换两个位置上的值 int temp = arr[j-1]; arr[j-1]= arr[j]; arr[j]=temp; } } } } }
动态演示
三、插入排序
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
代码
public class InsertSort { public static void main(String[] args) { int[] arr = {3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}; insertionSort(arr); System.out.println(Arrays.toString(arr)); } private static void insertionSort(int []arr){ for(int i=1; i<arr.length; i++){ int preIndex = i-1; int current = arr[i]; while(preIndex>=0 && arr[preIndex]>current){ arr[preIndex+1]=arr[preIndex]; preIndex--; } arr[preIndex+1]=current; } } }
动态演示
四、选择排序
以升序为例,在未排序序列中找到最小元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
代码
public class SelectionSort { private void mian() { int[] arr = {3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}; SelectionSort(arr); System.out.println(Arrays.toString(arr)); } private void SelectionSort(int[] arr) { for(int i =0;i<arr.length-1;i++) { int minIndex = i; for(int j=i;j<arr.length-1;j++) { if(arr[minIndex]>arr[j]) { minIndex = j; //记下目前找到的最小值所在的位置 } } //在内层循环结束,也就是找到本轮循环的最小的数以后,再进行交换 if (minIndex != i){ int temp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = temp; } } } }
动态演示
五、快速排序:
步骤为:
- 从数列中挑出一个元素,称为"基准"(pivot),
- 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
代码
public class QuickSort { public static void main(String[] args) { int[] arr = {3,44,38,5,47,15,36,26,27,2,46,4,19,50,48}; quickSort(arr,0,arr.length-1); System.out.println(Arrays.toString(arr)); } private static void quickSort(int[] arr, int low, int high) { if (low < high) { // 找寻基准数据的正确索引 int index = getIndex(arr, low, high); // 进行迭代对index之前和之后的数组进行相同的操作使整个数组变成有序 quickSort(arr, 0, index - 1); quickSort(arr, index + 1, high); } } private static int getIndex(int[] arr, int low, int high) { // 基准数据 int tmp = arr[low]; while (low < high) { // 当队尾的元素大于等于基准数据时,向前挪动high指针 while (low < high && arr[high] >= tmp) { high--; } // 如果队尾元素小于tmp了,需要将其赋值给low arr[low] = arr[high]; // 当队首元素小于等于tmp时,向前挪动low指针 while (low < high && arr[low] <= tmp) { low++; } // 当队首元素大于tmp时,需要将其赋值给high arr[high] = arr[low]; } // 跳出循环时low和high相等,此时的low或high就是tmp的正确索引位置 // 由原理部分可以很清楚的知道low位置的值并不是tmp,所以需要将tmp赋值给arr[low] arr[low] = tmp; return low; // 返回tmp的正确位置 } }
动态演示:
六、归并排序
(1) 归并排序的流程
(2) 合并两个有序数组的流程
public static void mergeSort(int[] arr) { sort(arr, 0, arr.length - 1); } public static void sort(int[] arr, int L, int R) { if(L == R) { return; } int mid = L + ((R - L) >> 1); sort(arr, L, mid); sort(arr, mid + 1, R); merge(arr, L, mid, R); } public static void merge(int[] arr, int L, int mid, int R) { int[] temp = new int[R - L + 1]; int i = 0; int p1 = L; int p2 = mid + 1; // 比较左右两部分的元素,哪个小,把那个元素填入temp中 while(p1 <= mid && p2 <= R) { temp[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++]; } // 上面的循环退出后,把剩余的元素依次填入到temp中 // 以下两个while只有一个会执行 while(p1 <= mid) { temp[i++] = arr[p1++]; } while(p2 <= R) { temp[i++] = arr[p2++]; } // 把最终的排序的结果复制给原数组 for(i = 0; i < temp.length; i++) { arr[L + i] = temp[i]; } }
七、计数排序
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
动图演示
算法实现
public static void countSort(int[] a, int max, int min) { int[] b = new int[a.length];//存储数组 int[] count = new int[max - min + 1];//计数数组 for (int num = min; num <= max; num++) { //初始化各元素值为0,数组下标从0开始因此减min count[num - min] = 0; } for (int i = 0; i < a.length; i++) { int num = a[i]; count[num - min]++;//每出现一个值,计数数组对应元素的值+1 } for (int num = min + 1; num <= max; num++) { //加总数组元素的值为计数数组对应元素及左边所有元素的值的总和 count[num - min] += sum[num - min - 1] } for (int i = 0; i < a.length; i++) { int num = a[i];//源数组第i位的值 int index = count[num - min] - 1;//加总数组中对应元素的下标 b[index] = num;//将该值存入存储数组对应下标中 count[num - min]--;//加总数组中,该值的总和减少1。 } //将存储数组的值一一替换给源数组 for(int i=0;i<a.length;i++){ a[i] = b[i]; } }
稳定性
适用场景
八、桶排序
算法描述
- 找出待排序数组中的最大值max、最小值min
- 我们使用 动态数组ArrayList 作为桶,桶里放的元素也用 ArrayList 存储。桶的数量为(max-min)/arr.length+1
- 遍历数组 arr,计算每个元素 arr[i] 放的桶
- 每个桶各自排序
- 遍历桶数组,把排序好的元素放进输出数组。
图片演示
算法实现
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)); } System.out.println(bucketArr.toString()); }
稳定性
可以看出,在分桶和从桶依次输出的过程是稳定的。但是,由于我们在对每个桶进行排序时使用了其他算法,所以,桶排序的稳定性依赖于这一步。如果我们使用了快排,显然,算法是不稳定的。
适用场景
桶排序可用于最大最小值相差较大的数据情况,但桶排序要求数据的分布必须均匀,否则可能导致数据都集中到一个桶中。比如[104,150,123,132,20000], 这种数据会导致前4个数都集中到同一个桶中。导致桶排序失效。
九、基数排序
基数排序(Radix Sort)是桶排序的扩展,它的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较。
排序过程:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
算法描述
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
动图
十、堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。可以利用数组的特点快速定位指定索引的元素。堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。
树的概念:代补充。
堆的概念
堆是一种特殊的完全二叉树(complete binary tree)。完全二叉树的一个“优秀”的性质是,除了最底层之外,每一层都是满的,这使得堆可以利用数组来表示(普通的一般的二叉树通常用链表作为基本容器表示),每一个结点对应数组中的一个元素。
如下图,是一个堆和数组的相互关系:
对于给定的某个结点的下标 i,可以很容易的计算出这个结点的父结点、孩子结点的下标:
- Parent(i) = floor(i/2),i 的父节点下标
- Left(i) = 2i,i 的左子节点下标
- Right(i) = 2i + 1,i 的右子节点下标
二叉堆一般分为两种:最大堆和最小堆。
最大堆
最大堆中的最大元素值出现在根结点(堆顶)
堆中每个父节点的元素值都大于等于其孩子结点(如果存在)
最小堆
最小堆中的最小元素值出现在根结点(堆顶)
堆中每个父节点的元素值都小于等于其孩子结点(如果存在)
堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。在堆中定义以下几种操作:
- 最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
- 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆
- 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
继续进行下面的讨论前,需要注意的一个问题是:数组都是 Zero-Based,这就意味着我们的堆数据结构模型要发生改变
相应的,几个计算公式也要作出相应调整:
- Parent(i) = floor((i-1)/2),i 的父节点下标
- Left(i) = 2i + 1,i 的左子节点下标
- Right(i) = 2(i + 1),i 的右子节点下标
堆的建立和维护
堆可以支持多种操作,但现在我们关心的只有两个问题:
- 给定一个无序数组,如何建立为堆?
- 删除堆顶元素后,如何调整数组成为新堆?
先看第二个问题。假定我们已经有一个现成的大根堆。现在我们删除了根元素,但并没有移动别的元素。想想发生了什么:根元素空了,但其它元素还保持着堆的性质。我们可以把最后一个元素(代号A)移动到根元素的位置。如果不是特殊情况,则堆的性质被破坏。但这仅仅是由于A小于其某个子元素。于是,我们可以把A和这个子元素调换位置。如果A大于其所有子元素,则堆调整好了;否则,重复上述过程,A元素在树形结构中不断“下沉”,直到合适的位置,数组重新恢复堆的性质。上述过程一般称为“筛选”,方向显然是自上而下。
删除后的调整,是把最后一个元素放到堆顶,自上而下比较
删除一个元素是如此,插入一个新元素也是如此。不同的是,我们把新元素放在末尾,然后和其父节点做比较,即自下而上筛选。
插入是把新元素放在末尾,自下而上比较
那么,第一个问题怎么解决呢?
常规方法是从第一个非叶子结点向下筛选,直到根元素筛选完毕。这个方法叫“筛选法”,需要循环筛选n/2个元素。
但我们还可以借鉴“插入排序”的思路。我们可以视第一个元素为一个堆,然后不断向其中添加新元素。这个方法叫做“插入法”,需要循环插入(n-1)个元素。
由于筛选法和插入法的方式不同,所以,相同的数据,它们建立的堆一般不同。大致了解堆之后,堆排序就是水到渠成的事情了。
动图演示
算法描述
我们需要一个升序的序列,怎么办呢?我们可以建立一个最小堆,然后每次输出根元素。但是,这个方法需要额外的空间(否则将造成大量的元素移动,其复杂度会飙升到O(n^2) )。如果我们需要就地排序(即不允许有O(n)空间复杂度),怎么办?
有办法。我们可以建立最大堆,然后我们倒着输出,在最后一个位置输出最大值,次末位置输出次大值……由于每次输出的最大元素会腾出第一个空间,因此,我们恰好可以放置这样的元素而不需要额外空间。很漂亮的想法,是不是?
算法实现
public class ArrayHeap { private int[] arr; public ArrayHeap(int[] arr) { this.arr = arr; } private int getParentIndex(int child) { return (child - 1) / 2; } private int getLeftChildIndex(int parent) { return 2 * parent + 1; } private void swap(int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } /** * 调整堆。 */ private void adjustHeap(int i, int len) { int left, right, j; left = getLeftChildIndex(i); while (left <= len) { right = left + 1; j = left; if (j < len && arr[left] < arr[right]) { j++; } if (arr[i] < arr[j]) { swap(array, i, j); i = j; left = getLeftChildIndex(i); } else { break; // 停止筛选 } } } /** * 堆排序。 * */ public void sort() { int last = arr.length - 1; // 初始化最大堆 for (int i = getParentIndex(last); i >= 0; --i) { adjustHeap(i, last); } // 堆调整 while (last >= 0) { swap(0, last--); adjustHeap(0, last); } } }
稳定性
堆排序存在大量的筛选和移动过程,属于不稳定的排序算法。
适用场景
堆排序在建立堆和调整堆的过程中会产生比较大的开销,在元素少的时候并不适用。但是,在元素比较多的情况下,还是不错的一个选择。尤其是在解决诸如“前n大的数”一类问题时,几乎是首选算法。
十一、希尔排序(插入排序的改良版)
在希尔排序出现之前,计算机界普遍存在“排序算法不可能突破O(n2)”的观点。希尔排序是第一个突破O(n2)的排序算法,它是简单插入排序的改进版。希尔排序的提出,主要基于以下两点:
- 插入排序算法在数组基本有序的情况下,可以近似达到O(n)复杂度,效率极高。
- 但插入排序每次只能将数据移动一位,在数组较大且基本无序的情况下性能会迅速恶化。
算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行 k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
动图演示
算法实现
Donald Shell增量
public static void shellSort(int[] arr){ int temp; for (int delta = arr.length/2; delta>=1; delta/=2){ //对每个增量进行一次排序 for (int i=delta; i<arr.length; i++){ for (int j=i; j>=delta && arr[j]<arr[j-delta]; j-=delta){ //注意每个地方增量和差值都是delta temp = arr[j-delta]; arr[j-delta] = arr[j]; arr[j] = temp; } }//loop i }//loop delta }
O(n^(3/2)) by Knuth
public static void shellSort2(int[] arr){ int delta = 1; while (delta < arr.length/3){//generate delta delta=delta*3+1; // <O(n^(3/2)) by Knuth,1973>: 1, 4, 13, 40, 121, ... } int temp; for (; delta>=1; delta/=3){ for (int i=delta; i<arr.length; i++){ for (int j=i; j>=delta && arr[j]<arr[j-delta]; j-=delta){ temp = arr[j-delta]; arr[j-delta] = arr[j]; arr[j] = temp; } }//loop i }//loop delta }
希尔排序的增量
希尔排序的增量数列可以任取,需要的唯一条件是最后一个一定为1(因为要保证按1有序)。但是,不同的数列选取会对算法的性能造成极大的影响。上面的代码演示了两种增量。
切记:增量序列中每两个元素最好不要出现1以外的公因子!(很显然,按4有序的数列再去按2排序意义并不大)。
下面是一些常见的增量序列。
第一种增量是最初Donald Shell提出的增量,即折半降低直到1。据研究,使用希尔增量,其时间复杂度还是O(n2)。
第二种增量Hibbard:{1, 3, ..., 2k-1}。该增量序列的时间复杂度大约是O(n1.5)。
第三种增量Sedgewick增量:(1, 5, 19, 41, 109,...),其生成序列或者是94^i - 92^i + 1或者是4^i - 3*2^i + 1。
稳定性
我们都知道插入排序是稳定算法。但是,Shell排序是一个多次插入的过程。在一次插入中我们能确保不移动相同元素的顺序,但在多次的插入中,相同元素完全有可能在不同的插入轮次被移动,最后稳定性被破坏,因此,Shell排序不是一个稳定的算法。
适用场景
Shell排序虽然快,但是毕竟是插入排序,其数量级并没有后起之秀--快速排序O(n㏒n)快。在大量数据面前,Shell排序不是一个好的算法。但是,中小型规模的数据完全可以使用它。
十二、常用算法的时间复杂度和空间复杂度
从执行效率:冒泡排序<选择排序<插入排序<快速排序。
从稳定性: 快速排序,选择排序 不稳定; 冒泡排序,插入排序 稳定。
十三、问答
1、为什么插入排序比冒泡排序更受欢迎?
答:针对同一个数组,冒泡排序和插入排序,最优情况下需要交互数据的次数是一样(即原数组的逆序度一样)
每次数据交换,冒泡排序的移动数据要比插入排序复杂。冒泡排序进行了 3 次赋值,插入排序进行了 1 次赋值。
代码对比:
//冒泡排序 int temp = arr[j-1]; arr[j-1]= arr[j]; arr[j]=temp; //插入排序 if (array[j] > value) { array[j+1] = array[j]; } else { break; }
2、如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?
有这样一个算法题:有一个无序数组,要求找出数组中的第K大元素。比如给定的无序数组如下所示:
如果k=6,也就是要寻找第6大的元素,很显然,数组中第一大元素是24,第二大元素是20,第三大元素是17...... 第六大元素是9。
如果从大到小都排序一边,然后再寻找第K大元素,显然不能确保时间复杂度为O(n)。那么可以用到快速排序的分治法。
学习过快速排序就知道,快排每次分区会把数据分为三个部分,a[0,p-1],a[p]以及a[p+1,n-1]三个区间。每次分区我们都将大于分区点元素放置在分区元素左边,小于分区元素的统一放到分区元素右边,那么这样一来,我们就可以利用快速排序实现从大到小的数组排序了。但是,这里说这个不是为了排序,而是想说一个关于从大到小排序后数组的一些规则:
1 如果P+1=k,那么a[P]就是要找的元素。 2 如果P+1<k,那么要继续在a[p+1,n-1]之间找。 3 如果P+1>k,那么要继续在a[0,p-1]之间找。
我们要查找第K大的数据,P作为分区点,那么利用以上规则就可以找到这个第K大元素,如果你还不明白,看下下边这个图:
这个图示我要查第K大元素,k=4时的图解过程。可以看出来每次分区我都可以把范围缩小。我只需要在这个小范围内找就可以了。
时间复杂度如何推导呢?
第一次分区,我需要对大小为n的数组执行分区操作,需要遍历n个元素。第二次分区查找,只需要对n/2个数据执行分区,只需要遍历n/2个数组,当然这里说的都是平均情况。依次类推,分区遍历的元素个数为n/2、n/4、n/8一直到区间为元素为1为止。把每次分区遍历的元素个数加起来:n+n/2+n/4+n/8...+1,最终等比数列求和的结果就是2n-1,所以时间复杂度为O(n).
代码如下:
/** * 在O(n)的时间复杂度内查找第K大的元素 * @author Administrator * */ public class FindNData { /**快速排序*/ public static void quickSort(int[] arr,int n,int k){ quickSort(arr,0,n-1,k); } /** * 根据分区点,递归继续分解子分区 * @param arr * @param p * @param r */ public static void quickSort(int[] arr,int p,int r,int k){ if(p>=r){ System.out.println("第"+k+"大元素为:"+arr[p]); return; } int q = partition(arr,p,r); if(q+1==k) { System.out.println("第"+k+"大元素为:"+arr[q]); return; }else if(q+1<k){ quickSort(arr,q+1, r,k); }else if(q+1>k) { quickSort(arr,p,q-1,k); } } /** * 快排分区 *随机生成[p,r]区间内pivot,将比pivot大的放在其右侧,比pivot小的放在其左侧 * @param arr * @param p * @param r */ public static int partition(int[] arr,int p,int r){ int pivot = arr[r]; int i=p; for (int j = p; j < r; j++) { //当前元素比分区点小,则交换当前元素arr[j]到arr[i],也就是将小的移动到左侧, //大的移动到右侧,而这个大小也是相对于分区点来说的,将来分区点会放到中间位置 if(arr[j] > pivot){ int temp = arr[j]; arr[j] = arr[i]; arr[i] = temp; //交换完位置(每确定完一个小元素后)以后,移动i指针到下一位(这个位置也是为下一个小元素准备的), //只要arr[j]比分区点小,就将其交换到指针i的位置,并将i后移 i++; } } //迭代完成后,所有相对于pivot小的元素都被移动到靠左的位置(i指针动态指向的位置), //所有相对于pivot大的元素都被移动到右侧,但是还是需要pivot将大小区间分割开 int temp = arr[i]; arr[i]=arr[r]; arr[r]=temp; return i; } public static void main(String[] args) { int[] arr= {6,1,3,5,7,2,4,9,11,8}; int k=4; quickSort(arr,10,k); } }
执行结果:第4大元素为:7
参考文献:https://blog.csdn.net/weixin_40205234/article/details/86699088
https://blog.csdn.net/adusts/article/details/80882649
https://www.jianshu.com/p/33cffa1ce613
https://blog.csdn.net/shengqianfeng/article/details/100058780