排序算法汇总
数据结构的一部分重要内容便是排序算法,对于排序之后的数组可以采用快速的 binary search 算法。排序算法多种多样,按类别有插入类、选择类、交换类,还有一些其他的比如归并等等,对于数据量太多内存无法盛放的情况,则有外部排序。度量各种算法的标准主要有,1)稳定性,2)最好/坏情况下的时间复杂度,3)最好/坏情况下的空间复杂度。
接下来一一介绍以上的算法及其性能分析,以下排序方法均是对于长度为 $n$ 的序列进行排序。
1. 插入类排序
1)直接插入排序
直接插入排序从待排序序列中选取一个数,选好一个位置将其插入到有序列表中,不断重复这个过程直到排序完成。
上图展示了直接插入执行的过程,开始假设 第 $0$ 个元素有序,对于第 $i=1...n-1$ 个元素,自 $i-1$ 起往前搜索,查找插入位置,同时后移记录,找到合适位置插入即可,可见共进行 $n-1$ 次插入,若数组有序只需进行 $n-1$ 次比较即可,无需移动,所示复杂度为 $O(n)$ ,若数组逆序,则需进行 $\frac{(n-1)(n-1+1)}{2}$ 次比较与移动,复杂度为 $O(n^2)$ ,所以 最好时间复杂度 $O(n)$ ,最差时间复杂度 $O(n^2)$ ,平均时间复杂度为 $O(n^2)$ 。由于在原地排序,空间复杂度为 $O(1)$ ,另外注意算法是稳定的。
直接插入排序代码:
public void insert_sort(int[] nums){ if(nums == null || nums.length < 2) return; // i = 1 -> n-1 for(int i = 1 ; i < nums.length ; i ++){ if(nums[i] >= nums[i-1]) continue; int pivot = nums[i] ,j = i-1; // 待插入 while(j >= 0 && nums[j] > pivot) nums[j+1] = nums[j--]; nums[j+1] = pivot ; //j < 0代表插入到最掐面 } }
2)折半插入排序
由于排序过程中前边已经有序,所以可以对有序序列进行折半查找,相对直接插入来说,减少了比较次数,但是由于找到插入位置后扔需移动序列中的元素,所以时间复杂度仍为 $O(n^2)$ 。折半插入排序中,需要用 binary search 找到插入位置插入即可。若有相同元素,为了保证其稳定性,则找到相同 key 的最后一个,比如说现在序列为 [1,1,1,1,1,3,4,1] ,我们要插入最后一个 1 ,为了保持其稳定性,则需找到最后一个 1 ,这种带有相同元素的 binary search 的代码如下:
public int binary_search(int[] nums,int POS, int key){ int low = 0; int high = nums.length -1; while(low <= high){ int mid = low + (high - low)/2; //防止溢出 if(nums[mid] == key){ if(POS == -1){ if(mid > 0 && nums[mid-1] == nums[mid]) high = mid-1; else return mid; }else if(POS == 1){ if(mid < nums.length-1 && nums[mid+1] == nums[mid]) low = mid +1; else return mid; }else return mid; // POS == 0 }else if(nums[mid]>key){high = mid -1 ;} else{low = mid + 1;} } return high; //在 high 之后的元素插入即可 }
3) 希尔排序
直接插入排序在序列基本有序时,运算量接近线性,所以希尔排序先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。因此希尔排序可理解为增量插入排序,如下图所示:
需要注意的是需要时的增量序列中的任意元素没有除 1 之外的公因子,且最后一个增量必为 1 ,所以在排序中,先给出一个系列间隔,使得待排序数组按这个间隔进行排序,最后来一遍直接插入排序即可,代码如下:
public void shell_sort(int[] nums){ if(nums == null || nums.length < 2) return; //给定增量序列,, 注意 做自动生成 则增量不能有除 1 以外的公因子 int[] dks = {5,3,1}; for(int dk : dks ){ for(int i = dk ; i < nums.length ; i ++){ if(nums[i] > nums[i - dk]) continue; int pivot = nums[i],j = i - dk; while(j >= 0 && nums[j] > pivot){ nums[j+dk] = nums[j]; j -= dk; } nums[j+dk] = pivot; } //System.out.println(Arrays.toString(nums)); } } // output : // dk=5 [13, 27, 49, 55, 4, 49, 38, 65, 97, 76] // dk=3 [13, 4, 49, 38, 27, 49, 55, 65, 97, 76] // dk=1 [4, 13, 27, 38, 49, 49, 55, 65, 76, 97]
由于希尔排序的时间复杂度设计一些数学难题,当数组长度 $n$ 在一定范围内,其平均复杂度为 $O(n^{1.3})$ ,且是一种不稳定排序。
2. 选择类排序
1) 简单选择排序
选择排序是这样执行的,对于数组 $L$ 中的元素 $0...n-1$ ,首先找到最小的元素,与 $0$ 交换,找到次小的元素,与 $1$ 交换,重复执行直到结束。
代码如下:
public void select_sort(int[] nums){ if(nums == null || nums.length < 2) return; for(int i = 0 ; i < nums.length-1 ; i ++){ int j = i,idx = j; for(; j < nums.length ;j ++){ if(nums[j] < nums[idx]) idx = j; } if( i != idx){int tmp = nums[i];nums[i] = nums[idx];nums[idx] = tmp;} } }
选择排序的最好时间复杂度 $O(n^2)$ ,最差时间复杂度 $O(n^2)$ ,平均时间复杂度为 $O(n^2)$ 。由于在原地排序,空间复杂度为 $O(1)$ ,另外注意算法是不稳定的。比如说序列 $[\bar{5},8,5,2,7]$,第一次 $\bar{5} \leftrightarrow 2$ ,变为 $[2,8,5,\bar{5},7]$ ,之后会选择第一个5,这便导致了不稳定的发生。
2)堆排序
堆(Heap)可以看做一颗完全二叉树,其定义如下:
- 父节点的键值总是大于等于(或者小于等于,对应大顶堆或小顶堆)左右子节点的键值
- 每个节点的左右子树都是一个二叉堆
以大顶堆为例,输出堆顶的最大元素,使得剩余的 $n-1$ 个元素重新构建一个大顶堆,得到大值,反复执行,便能得到一个有序序列,这个过程便是堆排序。可见堆排序需要解决以下两个问题:
- 将无序序列构造成一个大顶堆
- 输出堆顶元素后,将剩余元素重新调整为一个大顶堆
给定待排序数组之后,将其理解为完全二叉树的形式,在该完全二叉树的最后一个非叶子节点开始进行调整,构建堆,之后取走最值元素,调整剩余元素构造的堆,直到完成。
heap sort 的代码如下(构建小顶堆只需把 heap_adjust 里的 > 改为 < 即可):
// heap sort 大顶堆,会生成从小到大的序列,因为每次取得一个最大的放在数组的最后 public void heap_sort(int[] nums){ if(nums == null || nums.length < 2) return; int len = nums.length; //长度 //最后一个非叶子节点 n/2 -> 0 不断调整,使其成为一个大顶堆 for(int i = len/2 ; i >= 0 ; -- i) head_adjust(nums, i, len - 1); //顶堆元素与最后一个交换,这时只有新的堆顶不满足堆的定义,调整为大顶堆即可,然后将堆顶与倒数第二个交换 for(int i = len - 1 ; i > 0 ; -- i){ int tmp = nums[i];nums[i]= nums[0];nums[0] = tmp; head_adjust(nums, 0, i-1); // 这里 i 之后的元素都已经排序好了 } System.out.println(Arrays.toString(nums)); } // 针对某个节点调整该堆,根节点从i = 0 开始,所以左右孩子节点分别为 2*i+1 、 2*i+2 public void head_adjust(int[] nums, int start ,int end){ int cur = nums[start]; for(int i = 2 * start+1 ; i <= end ; i *= 2){ if( i + 1 <= end && nums[i+1] > nums[i] ) i++;// i 为左右孩子较大的 if( cur > nums[i] ) break; // 父节点大于左右孩子 nums[start] = nums[i]; // 交换 父子节点 start = i; // 调整交换过后的子节点 } nums[start] = cur; //将待调整节点的值赋到最后的正确位置上 }
堆排序一般适用于 $n$ 值较大的情形,其时间主要耗费在构建堆时的元素的反复筛选上,其最好时间复杂度 $O(nlogn)$ ,最差时间复杂度 $O(nlogn)$ ,平均时间复杂度为 $O(nlogn)$ 。由于在原地排序,空间复杂度为 $O(1)$ ,另外注意 堆排序是不稳定的。虽然其时间复杂度比较低,但一般情况下效率不如快速,归并甚至希尔排序,下面贴一个别人做的实验,单位为秒(没有亲自测试):
3. 交换类排序
1)冒泡排序
冒泡比较简单,每次挑一个最大的,向气泡一样浮向最后,代码如下:
// 设置一个 flag ,当某次没有发生交换,说明数组已经有序了 public void bubble_sort(int[] nums){ if(nums == null || nums.length < 2) return; boolean flag = false; for(int i = nums.length-1 ; i > 0 ; i --){ flag = false; for(int j = 0 ; j < i ; j++){ if(nums[j] > nums[j+1]){ int tmp = nums[j+1]; nums[j+1] = nums[j]; nums[j] = tmp; flag = true; } } if (flag == false) break; } }
时间复杂度当有序时最好时间复杂度 $O(n)$ ,最差时间复杂度 $O(n^2)$ ,平均时间复杂度为 $O(n^2)$ ,为一种稳定排序。
2)快速排序
每次用数组中的第一个作为 pivot 元素, 用一个额外的变量 tmp = pivot,现在相当于数组中第一个元素的位置为一个坑,一个指针从后到前遍历,找到小于 pivot 的则扔到坑里,然后这个位置变为新的坑,另一个指针从后到前的遍历,找到大于 pivot 的扔到坑里,然后这个作为新的坑,知道 前后两个指针相遇,则将 pivot 扔到最终的坑里。整个过程如下图所示:
代码如下:
public void quick_sort(int[] nums){ if(nums == null || nums.length < 2) return; partion(nums,0,nums.length-1); } //递归版本,随机选取pivot public void ramdom_partion(int[] nums,int left ,int right){ if(left >= right) return; //产生 left -right 之间的随机数,并交换到 left 处作为pivot int idx = new Random().nextInt(right-left)+left; int tmp = nums[left]; nums[left] = nums[idx]; nums[idx] = tmp; int pivot = nums[left]; int l = left,r = right; while(l < r){ while(l < r && nums[r] >= pivot) --r; if(l < r) nums[l] = nums[r]; while(l < r && nums[l] < pivot) ++l ; if(l < r) nums[r] = nums[l]; } nums[l] = pivot; ramdom_partion(nums,left,l-1); ramdom_partion(nums,l+1,right); } //递归版本,设置第一个为 pivot public void partion(int[] nums,int left ,int right){ if(left >= right) return; int l = left,r = right; int pivot = nums[left]; // 设置 pivot while(l < r){ while( l < r && nums[r] >= pivot) --r; if(l < r ) nums[l] = nums[r]; while( l < r && nums[l] < pivot ) ++l; if(l < r ) nums[r] = nums[l]; } nums[l] = pivot; partion(nums, left , l-1); partion(nums, l+1, right); }
注意若数组逆序,则 quick sort 退化为 bubble sort ,所以选择 pivot 的时候最好随机选取,以上代码中给出了随机选取的方式,选好后换到第一个即可,最好时间复杂度 $O(nlogn)$ ,最差时间复杂度 $O(n^2)$ 即数组逆序的时候,但是随机选取 pivot 应该不会有这种状况,平均时间复杂度为 $O(nlogn)$ ,空间复杂度因为递归调用,所以操作系统需要对参数进行压栈,当数组逆序是,达到最坏空间复杂度为 $O(n)$ ,一般情况的平均空间复杂度为 $O(logn)$ ,且注意算法是不稳定的。
4. 归并排序
归并排序的思想是这样的,将待排序序列看成是n个长度为1的有序序列,将相邻的有序表成对归并,得到n/2个长度为2的有序表;将这些有序序列再次归并,得到n/4个长度为4的有序序列;如此反复进行下去,最后得到一个长度为n的有序序列。
数组归并排序:
//merge sort 分三个函数,分别是调用、划分、合并 public void merge_sort(int[] nums){ if(nums == null || nums.length < 2) return; sort(nums,0,nums.length-1); } //划分区间 public void sort(int[] nums, int left, int right){ if (left >= right) return; int mid = left +(right -left)/2; sort(nums, left, mid ); sort(nums,mid+1,right); merge(nums,left,mid,right); } //将 [left...mid] 与 [mid+1...right] 两个序列合并为新的有序序列 public void merge(int [] nums,int left,int mid,int right){ int[] tmp = new int[right -left+1];//建立额外的空间 int l = left, r = mid+1, k=0; // while(l <= mid && r <= right){ if(nums[l] <= nums [r]) tmp[k++] = nums[l++]; else tmp[k++] = nums[r++]; } while(l <= mid ) tmp[k++] = nums[l++]; while(r <= right) tmp[k++] = nums[r++]; for(int i = 0 ; i < tmp.length; i++) nums[left + i] = tmp[i]; }
链表归并排序:
//归并排序算法,类似于数组的归并,思路基本完全一样 public ListNode sortList(ListNode head) { if(head == null || head.next == null) return head; ListNode prev = null; ListNode slow = head, fast = head; while(fast != null && fast.next != null){ prev = slow; // 记录后半部分的头指针 slow = slow.next; fast = fast.next.next; } prev.next = null; // 断开连接 ListNode h1 = sortList(head); ListNode h2 = sortList(slow); return merge(h1, h2); }//归并两个链表的过程 public ListNode merge(ListNode h1, ListNode h2){ ListNode fake = new ListNode(0); ListNode tail = fake; while(h1 != null && h2 != null){ if(h1.val > h2.val){ tail.next = h2; h2 = h2.next; }else{ tail.next = h1; h1 = h1.next; } tail = tail.next; } if(h1 != null) tail.next = h1; if(h2 != null) tail.next = h2; return fake.next; }
归并排序最好最坏的时间复杂度均为 $O(nlgn)$ ,空间复杂度也为 $O(n)$ ,且最大的优点是在 $O(nlgn)$ 中他是一种稳定的排序算法。
5. 外部排序