前言
说到排序,大家肯定不会陌生,因为在日常生活中我们无时无刻不与排序打交道。我们在学校操场举行活动的时候,往往是按照高矮顺序站队的;当我们购在手机上购物的时候,我们会考虑到价格,所以我们会选择按照价格高低排序。这是在日常生活中排序的重要性,在我们日常写代码题的时候就更不用说了,很多题目都需要我们先排序然后才能继续进行下面的操作,但是如果我们使用暴力排序的方式进行排序的时候,往往会因为时间复杂度太高而导致超时跑不过去,所以我们就需要使用更优的排序方法来减少时间的使用。那么今天大家就随我来了解有哪八大排序吧(本文章以升序为例)。
什么是排序、稳定性
🎆排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
🎆稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
排序实现
插入排序
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。实际中我们玩扑克牌时,就用了插入排序的思想。
直接插入排序
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
当我们插入第i个数据的时候,前面的i-1个数据已经是有序的了,所以我们只需要将这个第i个数据与前面的第i-1,i-2,······,第0个数据进行比较。假设我们是从小到大排序,那么我们的第i个数据就需要插入到前面的数据小于这个待插入的数据,后面的数据大于这个待插入的数据。我们先将第i个数据存储起来,防止被覆盖。如果在比较的过程中,正在比较的数据大于这个数据,那么就将这个正在比较的数据向后移动一位,然后继续用我们待插入的数据与前面的数据进行比较,直到遇到小于或等于这个数据的值,然后将该数据插入到这个正在比较的数据的后面一位。如果第i个数据的前i-1个数据都比第i个数据大,那么我们就将这个数据插入到下标为0位置处。
public static void insertSort(int[] array) { //我们从第二个数据开始排序 for(int i = 1; i < array.length; i++) { //j是用来遍历第i个值之前的数据的 int j = i-1; //将第i个数据的值存储起来,防止被覆盖 int tmp = array[i]; for(; j >= 0; j--) { if(array[j] > tmp) { array[j+1] = array[j]; }else { break; } } array[j+1] = tmp; } }
直接插入排序的特性总结:
1.元素集合越接近有序,直接插入排序算法的时间效率越高
2.时间复杂度:O(N^2)
3.空间复杂度:O(1),它是一种稳定的排序算法
4.稳定性:稳定
希尔排序
希尔排序是直接插入排序的优化。它其实还是直接插入排序,只是希尔排序是将数据按照增量gap将数据分成多个组,再分别在每个组中进行直接插入排序,然后逐渐缩小增量gap,重复上面的直接插入排序的做法,直到当gap=1的时候对整个数据进行一次直接插入排序。
这里可能会有人问了,既然是分组,那么我为什么不能将几个连续的数据分到一个组呢?而是要将每隔gap个数据的数据分到一个组呢?那是因为如果我们几个连续的数据分到一个组,就不能达到预排序的目的,那么其实跟一次直接插入排序没什么区别。而你每隔gap个数据分组,每一次排序后,你的数据就会逐渐趋于有序,
因为直接插入排序操作的数据越接近有序,那么它的时间效率就越高,我们希尔排序的思想就是先将数据进行预排序,在预排序的过程中,每个组中的数据因为gap增量较大,所以每个组中的数据较少,而直接插入排序的时间复杂度是0(N^2),N越小,时间复杂度也就越低,所以希尔排序相比于直接插入排序就比较快了。
gap的取法有很多种,我们不能说哪种取法是最好的,不同情况gap的取值方法不同,我们就先将gap取为数组长度的一半,然后一次排序完成后,我们gap/=2。
public static void shellSort(int[] array) { int gap = array.length; while(gap > 1) { gap /= 2; for (int i = 0; i < array.length-gap; i++) { int tmp = array[i+gap]; int end = i; while(end >= 0) { if(array[end] > tmp) { array[end+gap] = array[end]; end -= gap; }else { break; } } array[end+gap] = tmp; } } }
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很 快。这样整体而言,可以达到优化的效果。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在很多书中给出的希尔排序的时间复杂度都不固定,但是希尔排序的平均时间复杂度是O(N^1.3)
选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
直接选择排序
直接选择排序的思想是:我们先将整个数组的n个数据遍历一遍,找出最小(最大)的数据,然后将这个最小(最大)的数据与数组下标为0处的数据交换,接着我们遍历剩下的n-1个数据,选择出最小(最大)的数据,将这个最小(最大)的数据与数组下标为1处的数据进行交换。重复进行此操作,直到剩下最后一个数据。
public static void selectSort(int[] array) { for(int i = 0; i < array.length-1; i++) { int minIndex = i; for(int j = i + 1; j < array.length; j++) { if(array[j] < array[minIndex]) { minIndex = j; } } int tmp = array[i]; array[i] = array[minIndex]; array[minIndex] = tmp; } }
直接选择排序优化
上面的思路中,每一次遍历只是找出一个最大(最小)的数据,那么我们是不是可以每一次遍历就将最大和最小的数据都找出来,将最大的数据放在最右(最左),将最小的数据放在最左(最右)呢?这样可以减少遍历的次数,减少所需要的时间。
public static void selectSort2(int[] array) { int left = 0; int right = array.length-1; while(left < right) { int minIndex = left; int maxIndex = right; for(int i = left+1; i < array.length; i++) { if(array[i] > array[maxIndex]) { maxIndex = i; } if(array[i] < array[minIndex]) { minIndex = i; } } int tmp = array[left]; array[left] = array[minIndex]; array[minIndex] = tmp; //这里可能把最大值换到minIndex的位置,也就是说最大值刚好在left位置 //所以我们需要做出调整 if(left == maxIndex) { maxIndex = minIndex; } int tmp2 = array[right]; array[right] = array[maxIndex]; array[maxIndex] =tmp2; } }
堆排序
什么是堆?,堆是一种根节点总是大于等于或者小于等于孩子节点的数据结构。所以我们需要先建一个堆,如果我们想要排升序,那么我们就建大堆,排降序,我们就建小堆。每次将堆的根节点与最后一个节点交换,然后除去最后一个节点外,我们重新向下调整堆,继续将堆的根节点与倒数第二个数据进行交换,然后再除去最后的两个数据外,向下调整堆,重复该操作,直到剩下根节点。
如果你还不会建堆,那么就看看这篇文章吧什么是堆,以及怎样建堆。
//建大堆 private static void creatBigHeap(int[] array) { for(int parent = (array.length-1-1)/2; parent >= 0; parent--) { shiftDown(array,parent,array.length); } } private static void shiftDown(int[] array,int parent,int end) { int child = 2*parent+1; while(child < end) { if(child + 1 < end && array[child] < array[child+1]) { child++; } if(array[parent] < array[child]) { int tmp = array[parent]; array[parent] = array[child]; array[child] = tmp; parent = child; child = 2*parent+1; }else { break; } } } //堆排序 public static void heapSort(int[] array) { creatBigHeap(array); int end = array.length-1; while(end > 0) { //交换 int tmp = array[0]; array[0] = array[end]; array[end] = tmp; shiftDown(array,0,end); end--; } }
直接选择排序的特性总结
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用。
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序
冒泡排序是指每次从第一个元素开始,与后面的第二个元素进行比较,如果第一个元素大于(小于)第二个元素,则两个元素进行交换,然后再比较第二个元素和第三个元素,直到比较到数组的最后一个元素停止,这样每比较一趟就可以将一个元素放到它该在的位置。一共需要比较ArrayLenth-1趟,第一趟需要比较Arraylength-1次,第二次需要比较Arraylength-2次,每走完一趟,下一次比较的次数就减少一次。
/
这个冒泡排序还可以优化,当我们走完一趟却并没有进行交换的时候,我们就可以认为此时数组已经有序,我们就可以停止排序了。
快速排序
任取待排序元素序列中的某元素作为基准值,按照该排序方法将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
递归实现快速排序
Hoare版
我们先选择数组的最左边的元素或者最右边的值作为基准pivot,让left和right分别从数组的左边和后边遍历数组,但值得注意的是,left和right不是同时遍历的,当我们选择数组的左边作为基准时,我们让right先走,如果选择数组最右边的元素作为基准,就left先走。找到比pivot大于(小于)的值,然后再是left开始向右遍历,找到比基准pivot大的值,将left和right所在的值进行交换,继续这个操作,仍然是right先走继而是left。直到最后left和right相遇的时候停止,最后将left所在的值与pivot的值交换,最终left左边的值都是小于(大于)left的值,left右边的值都是大于(小于)left的值。
当我们进行一次排序时,我们就排好了一个元素。如果我们想将整个数据都排成升序,我们就可以通过递归来继续将left与right相遇的位置的左边与右边的数据进行排序。
这里我们来考虑一个问题。如果我们选择数组最左边的数为基准,然后左边先走会发生什么。
我们可以看到6的左边的数不是都小于6,所以我们可以知道:如果选择最左边为基准,先让right走,如果选择最右边为基准,left先走。
public static void quickSort(int[] array) { quick(array,0,array.length-1); } private static void quick(int[] array,int start,int end) { //递归结束的条件 if(start >= end) { return; } //pivot获取left与right相遇的位置 int pivot = partition(array,start,end); quick(array,start,pivot-1); quick(array,pivot+1,end); } //hoare法 private static int partition(int[] array,int left,int right) { int tmp = array[left]; //以最左边作为基准 int index = left; while(left < right) { //这里left<right需要加上,防止在内部循环的时候数组越界 //这个=必须加上,否则会死循环,可以拿1,2,3,4举例子 while(left < right && array[right] >= tmp) { right--; } while(left < right && array[left] <= tmp) { left++; } swap(array,left,right); } swap(array,index,left); return left; } private static void swap(int[] array,int i,int j) { int tmp = array[i]; array[i] = array[j]; array[j] = tmp; }
挖坑法
挖坑法是每次将数组最左边或者最右边的基准用一个临时变量存储下来,当前位置就可以看作是一个坑。然后同样是如果选择最左边为基准,先让right走,如果选择最右边为基准,left先走。我们用right所在的值将前面的坑给补上,这个right所在的地方就也成为了一个坑,继续从左边找大于pivot的值将right这个坑补上,left又成为了坑,重复此操作,直到left与right相遇,最后用临时存储的值将坑给填上。
public static void quickSort(int[] array) { quick(array,0,array.length-1); } private static void quick(int[] array,int start,int end) { //递归结束的条件 if(start >= end) { return; } //pivot获取left与right相遇的位置 int pivot = partition(array,start,end); quick(array,start,pivot-1); quick(array,pivot+1,end); } private static int partition(int[] array,int left,int right) { int tmp = array[left]; while(left < right) { //这个=必须加上,否则会死循环,可以拿1,2,3,4举例子 while(left < right && array[right] >= tmp) { right--; } array[left] = array[right]; while(left < right && array[left] <= tmp) { left++; } array[right] = array[left]; } //left与right相遇 array[left] = tmp; return left; }
前后指针法
我们还是以最左边的值作为基准,然后定义两个指针cur和prev,prev最开始处于基准所在的这个位置,cur最开始在基准所在位置的下一个位置。如果cur所在位置的值大于(小于)基准值,则让cur继续向后走,否则让prev++并判断prev是否等于cur,如果等于就继续让cur走,不相等就交换prev和cur的值,当cur遍历完数组的时候,就将prev的值与基准值交换,prev所在的位置的左边就是小于prev的元素,右边是大于prev的元素。
public static void quickSort(int[] array) { quick(array,0,array.length-1); } private static void quick(int[] array,int start,int end) { //递归结束的条件 if(start >= end) { return; } //pivot获取left与right相遇的位置 int pivot = partition(array,start,end); quick(array,start,pivot-1); quick(array,pivot+1,end); } private static void swap(int[] array,int i,int j) { int tmp = array[i]; array[i] = array[j]; array[j] = tmp; } //前后指针法 【了解即可】 private static int partition(int[] array,int left,int right) { int prev = left; int cur = left+1; while(cur <= right) { while(array[cur] < array[left] && array[++prev] != array[cur]) { swap(array,prev,cur); } cur++; } swap(array,left,prev); return prev; }
快速排序优化
快速排序的过程类似于二叉树,pivot是根节点,pivot左边的部分是左子树,右边的部分是右子树。需要分别对左子树和右子树进行排序,然后再将左、右子树当成根节点,对子树的左子树和右子树进行排序。也就是说,如果二叉树是完全二叉树,那么它递归的深度就浅一点,时间效率就越高,相反,如果二叉树只有左孩子或者右孩子,那么它的递归深度就比较深,时间效率越慢。
如果出现这种情况,那么当你数据较多的时候,很可能所需要的时间会超过其他排序所需要的时间。
那么我们该如何解决这种问题呢?
三数取中法
我们可以选取数组最左边的值、数组最右边的值和数组中间的值中既不是最大也不是最小的值,然后让这个中间值与我们的基准值(取数组最左边作为基准值)进行交换,再以数组最左边的值作为基准值继续进行排序。
public static void quickSort(int[] array) { quick(array,0,array.length-1); } private static void quick(int[] array,int start,int end) { if(start >= end) { return; } //三数取中 int index = midThree(array,start,end); swap(array,index,start); int pivot = partition(array,start,end); quick(array,start,pivot-1); quick(array,pivot+1,end); } //找三个数的中间值 private static int midThree(int[] array,int left,int right) { int mid = left + (right - left) / 2; if (array[left] > array[right]) { if (array[mid] < array[right]) { return right; } else if (array[mid] > array[left]) { return left; } else { return mid; } } else { if (array[mid] < array[left]) { return left; } else if (array[mid] > array[right]) { return right; } else { return mid; } } }
这里我们只需要更改找基准的方法,具体怎么排序,你可以在Hoare法、挖坑法、前后指针法中选择。当我们使用了三数取中法之后,我们可以发现可以很好的避免跟根节点只有左树或者只有右树,并且可以使二叉树尽可能的变成完全二叉树。
虽然三数取中可以解决递归深度太深的问题,但是因为它取的三个数字是随机的,有局限性,并不能根本性的解决问题。所以我们就又有一种方法:取小区间法。
取小区间法
随着递归深度的增加,递归次数以每层2倍的速度增加,这对效率有着很大的影响,当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。
public static void quickSort(int[] array) { quick(array,0,array.length-1); } private static void quick(int[] array,int start,int end) { if(start >= end) { return; } //更优的解法:因为在二叉树中,最后的两层节点占了大部分,如果继续使用递归地话,效率很慢, // 因为前面的节点已经递归实现排序了的,最后两层的数据也基本趋于有序,所以我们使用插入排序 //当分割的区间小于10时,使用插入排序 if(end - start + 1 <= 10) { insertSort2(array,start+1,end); return; } int pivot = partition(array,start,end); quick(array,start,pivot-1); quick(array,pivot+1,end); } private static void insertSort2(int[] array, int left, int right) { for(int i = left+1; i <= right; i++) { int j = i-1; int tmp = array[i]; for(; j >= left; j--) { if(array[j] > tmp) { array[j+1] = array[j]; }else { break; } } array[j+1] = tmp; } }
非递归实现快速排序
非递归实现快速排序,我们可以使用栈这种数据结构。先进行一次排序,找到基准,然后将基准的左边部分和右边部分的left和right存储到栈中,如果基准的左边部分或者右边部分的元素个数小于等于1,就不放入栈中,说明该部分已经排序完成,然后我们从栈中取出两个元素,分别赋值给right和left,继续上面的操作。
同样操作,排序pivot的左边部分。
public static void quickSort2(int[] array) { Deque<Integer> stack = new LinkedList<>(); int left = 0; int right = array.length-1; int pivot = partition(array,left,right); //判断基准的左右部分是否放入 if(pivot > left + 1) { stack.push(left); stack.push(pivot-1); } if(pivot < right - 1) { stack.push(pivot + 1); stack.push(right); } while(!stack.isEmpty()) { right = stack.pop(); left = stack.pop(); pivot = partition(array,left,right); if(pivot > left + 1) { stack.push(left); stack.push(pivot-1); } if(pivot < right - 1) { stack.push(pivot + 1); stack.push(right); } }
快速排序总结
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
归并排序
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
类似于二叉树,我们将一个数组从中间分成两个部分,两个线路同时进行,然后两个部分在分别从中间分成两个部分,直到数组被分成一个一个的数据。然后我们开始治的操作,将从一个数组分出来的两个数组按顺序合并成一个数组,重复此操作,直到再次合成一个完整的数组。
递归实现归并排序
每次就从数组的(left+riight)/2的位置将数组分成左右两部分,持续递归,直到left>=right
public static void mergeSort(int[] array) { mergeSortFunc(array,0,array.length-1); } private static void mergeSortFunc(int[] array,int left,int right) { if(left >= right) { return; } int mid = left + (right-left) / 2; //递归mid的左边部分 mergeSortFunc(array,left,mid); //递归mid的右边部分 mergeSortFunc(array,mid+1,right); //将两个数组合并成一个数组 merge(array,left,right,mid); } private static void merge(int[] array,int start,int end,int mid) { int s1 = start; int s2 = mid+1; //创建新的数组 int[] tmp = new int[end-start+1]; int k = 0; //tmp的下标 while(s1 <= mid && s2 <= end) { if(array[s1] <= array[s2]) { tmp[k++] = array[s1++]; }else { tmp[k++] = array[s2++]; } } //当一个数组遍历完后,直接将未遍历完的数组加到tmp数组的后面 while(s1 <= mid) { tmp[k++] = array[s1++]; } while(s2 <= end) { tmp[k++] = array[s2++]; } //将新创建的已经排好序的数组放回原数组中 for(int i = 0; i < tmp.length; i++) { array[i+start] = tmp[i]; } }
非递归实现归并排序
非递归实现归并排序就是将数组连续的n个数据分成一组,分成多个组,每组有2^n个数据,每个组进行排序。第一次每个组有2 ^ 1 = 2个数据,每个组内进行排序,然后是每个组有2 ^ 2 = 3个数据,组内再次排序,直到
2 ^ n >= arrayLength。
public static void mergeSort2(int[] array) { int gap = 1; while(gap < array.length) { for(int i = 0; i < array.length; i += gap*2) { int left = i; //同样是将一个数组看成左右两部分进行合并排序 int mid = i + gap - 1; //mid和right可能会越界,所以需要做出调整 if(mid > array.length) { mid = array.length-1; } int right = mid + gap; if(right > array.length) { right = array.length-1; } merge(array,left,right,mid); } //当前为2个一组有序,下一组变成4个一组有序 gap *= 2; } } private static void merge(int[] array,int start,int end,int mid) { int s1 = start; int s2 = mid+1; int[] tmp = new int[end-start+1]; int k = 0; //tmp的下标 while(s1 <= mid && s2 <= end) { if(array[s1] <= array[s2]) { tmp[k++] = array[s1++]; }else { tmp[k++] = array[s2++]; } } while(s1 <= mid) { tmp[k++] = array[s1++]; } while(s2 <= end) { tmp[k++] = array[s2++]; } for(int i = 0; i < tmp.length; i++) { array[i+start] = tmp[i]; } }
归并排序总结
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
计数排序
计数排序的基本思想是将输入的数据值转化为键存储在一个数组中,然后统计各个键值的出现个数,并依次累加,最后将数据按照键值从小到大的顺序依次取出,得到有序序列。
public static void countingSort(int[] array, int maxValue) { int[] bucket = new int[maxValue + 1]; for (int i = 0 ;i < array.length; i++) { bucket[array[i]]++; } int index = 0; for (int i = 0; i < bucket.length; i++) { while (bucket[i] != 0) { array[index++] = i; bucket[i]--; } } } public static void main(String[] args) { int[] array = {5, 3, 1, 2, 4, 5, 0}; countingSort(array, 5); System.out.println(Arrays.toString(array)); // [0, 1, 2, 3, 4, 5, 5] }
在上面的代码中,countingSort()方法接受两个参数,一个是待排序数组array,另一个是最大值maxValue,表示输入数据的取值范围为[0, maxValue]。首先,创建一个长度为maxValue+1的桶(bucket)数组,遍历待排序数组array,将每个值出现次数累加在桶数组中对应的位置上。然后,遍历桶数组,将桶数组中非0元素按照键值顺序依次放回原数组array中即可。
总结
如果本文章有错误的话,欢迎大家在评论区或者私信告诉我,如果觉得本文章不错的,记得给博主点个赞哦,制作不易,非常感谢!!!