程序员基本功系列2——排序算法
1、衡量排序算法的标准
其实几乎所有算法都可以从几个方便进行衡量:执行效率、内存开销、稳定性。
排序算法也一样,主要从:
? 时间复杂度,包括:最好情况、最坏情况、平均时间复杂度、还有比较和交换的次数
? 空间复杂度,如:原地排序
? 排序算法的稳定性,即相同数值的元素,排序后的前后顺序不变则称为稳定排序
来汇总一下常用排序算法:
| 算法分类 | 时间复杂度 | 空间复杂度 | 是否基于比较 | 是否稳定排序 |
| 冒泡、插入、选择 | O(n2) | O(1) | 是 | 冒泡和插入是稳定排序,选择是非稳定排序 |
| 快排、归并 | O(nlogn) | 是 | ||
| 桶排序、计数、基数 | O(n) | 否 |
2、冒泡、插入和选择
2.1、冒泡排序
举个简单的例子:对数组[4,5,6,3,2,1]进行排序。看下冒泡过程分解:
冒泡排序包含两个关键操作:比较和交换,根据上面的分析,我们写出具体的代码:
// 冒泡排序,a表示数组,n表示数组大小 public void bubbleSort(int[] a, int n) { if (n <= 1) return; for (int i = 0; i < n; ++i) { // 提前退出冒泡循环的标志位 boolean flag = false; for (int j = 0; j < n - i - 1; ++j) { if (a[j] > a[j+1]) { // 交换 int tmp = a[j]; a[j] = a[j+1]; a[j+1] = tmp; flag = true; // 表示有数据交换 } } if (!flag) break; // 没有数据交换,提前退出 } }
冒泡排序比较简单,注意这里我们设置了一个退出标志位,当数组已经当前位置后续元素已经有序的情况下,减少不必要的循环逻辑。
性能分析:
? 冒泡排序的最好情况下时间复杂度,即数组有序时(1,2,3,4,5,6),是O(n);最坏情况时间复杂度,即数组逆序(6,5,4,3,2,1),是O(n2);平均时间复杂度是 O(n2)。
? 冒泡排序是原地排序,空间复杂度 O(1)。
? 冒泡排序是稳定是排序,因为 a[j] == a[j+1] 时没有进行交换,所以前后顺序不会变。
2.2、插入排序
插入排序思想:将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。然后取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
对数组[4,5,6,3,2,1]进行排序,看下插入过程分解:
根据以上分析,我们写出具体代码:
// 插入排序,a表示数组,n表示数组大小 public void insertionSort(int[] a, int n) { if (n <= 1) return; for (int i = 1; i < n; ++i) { int value = a[i];// 查找插入的位置 for (int j=i-1; j >= 0; --j) { if (a[j] > value) { a[j+1] = a[j]; // 数据移动 } else { break; } } a[j+1] = value; // 插入数据,注意这里是a[j+1],因为循环结束找到要插入位置后,后面的--j还会执行。 } }
性能分析:
? 插入排序的最好情况下时间复杂度,即数组有序时(1,2,3,4,5,6),是O(n);最坏情况时间复杂度,即数组逆序(6,5,4,3,2,1),是O(n2);平均时间复杂度是 O(n2)。
? 插入排序是原地排序,空间复杂度 O(1)。
? 插入排序是稳定是排序,因为 a[j] == value 时没有将 a[j] 移动,所以前后顺序不会变。
2.3、选择排序
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
根据以上分析,我们写出代码:
// 选择排序,a表示数组,n表示数组大小 public void selectSort(int[] a, int n) { if (n <= 1) return; for (int i = 0; i < n; ++i) { int min_idx = i; // 找到最小元素的位置 for (int j=i+1; j < n; ++j) { if (a[j] < a[min_idx]) { min_idx = j; } } //移动未排序区最小元素到排序区 if(min_idx != i){ int tmp = a[i]; a[i] = a[min_idx]; a[min_idx] = tmp; } } }
性能分析:
? 选择排序的最好情况下时间复杂度,即数组有序时(1,2,3,4,5,6),是O(n);最坏情况时间复杂度,即数组逆序(6,5,4,3,2,1),是O(n2);平均时间复杂度是 O(n2)。
? 选择排序是原地排序,空间复杂度 O(1)。
? 选择排序是不稳定是排序,因为每次都要找到未排序区最小元素和前面的元素交换位置,这样破坏了稳定性。例如:[5,8,5,2,4],当第一个5和2交换位置时稳定性就被破坏了。
3、快速和归并
快速排序和归并排序都用到了分治思想,适合大规模数据排序,比上面三种更常用。
3.1、归并排序
归并排序的核心思想是:把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
分治思想都会用到递归,我们来看下递推公式:
merge_sort(p..r) = merge_sort(p...m) + merge_sort(m+1...r),终止条件:当 p >= r 时不再继续分解。
到这里,分解的过程就结束了,但是分解后还有一个合并的过程,就是将已经有序的 A[p...m] 和 A[m+1...r] 合并成一个有序数组,然后放入 A[p...r]。
合并的逻辑:申请一个临时数组 tmp,大小与 A[p...r] 相同。用两个游标 i 和 j,分别指向 A[p...m]和 A[m+1...r]的第一个元素。比较这两个元素 A[i] 和 A[j],如果 A[i]<=A[j],就把 A[i] 放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p...r]中。
根据以上分析,我们写出整个分解与合并的代码:
public class MergeSort { public static void main(String[] args) { int[] arr = {4,5,6,3,2,1}; int len = arr.length; sort(arr,0,len-1); for (int item : arr){ System.out.print(item); } } /** * 分解 */ private static void sort(int[] arr,int left,int right){ //终止条件 if (left >= right)return; int mid = (left+right) / 2; sort(arr,left,mid); sort(arr,mid+1,right); //如果两个数组已经有序,就不用再合并了 if (arr[mid] <= arr[mid+1]){ return; } //合并两段区间 merge(arr,left,mid,right); } /** * 合并 */ private static void merge(int[] arr,int left,int mid,int right){ //创建临时数组 int[] tmp = new int[right-left+1]; int i=left,j=mid+1; for (int k=0;k){ //如果左侧 if (i == mid+1){ tmp[k] = arr[j++]; }else if (j == right+1){ tmp[k] = arr[i++]; } //这一步如果去掉等号,就破坏调了稳定性 else if (arr[i] <= arr[j]){ tmp[k] = arr[i++]; }else { tmp[k] = arr[j++]; } } //采用Java自带的数组拷贝,也可以写个循环 System.arraycopy(tmp, 0,arr, left, tmp.length); } }
性能分析:
? 归并排序的时间复杂度在最坏、最好、平均情况下都是 O(nlogn),所以时间复杂度非常稳定。
? 归并排序并不是原地排序,合并时需要申请额外空间,的空间复杂度是 O(n),这也是其不如快排使用广泛的原因。
? 归并排序是稳定排序,关键在于合并过程 arr[i] <= arr[j] 的判断,相等元素将左侧的元素先放入临时数组 tmp 中。
3.2、快速排序
快排也是采用的分治思想,核心逻辑:如果要排序数组中下标从 p 到 r 之间的一组数据,选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
根据上面的分析,快排可以将 p-r 之前的排序分解为 p-q 和 q+1-r 两个子问题。然后逐步分解,知道区间为 1 时全部有序结束。我们写出递推公式:
quick_sort(p...r) == quick_sort(p...q) + quick_sort(q+1...r),终止条件:当 p >= r 时结束不再分解。
我们写一段伪代码来表示上面分析的过程:
// 快速排序,A是数组,n表示数组的大小 quick_sort(A, n) { quick_sort_c(A, 0, n-1) } // 快速排序递归函数,p,r为下标 quick_sort_c(A, p, r) { if p >= r then return q = partition(A, p, r) // 获取分区点 quick_sort_c(A, p, q-1) quick_sort_c(A, q+1, r) }
快排虽然没有归并排序的 merge() 函数,但是会在分解前有一个分区的过程 partition()。所以要先分析分区过程:
就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p...r]分区,函数返回 pivot 的下标。可以粗暴的申请两个数组,将小于分区点的放入一个,大于的放入另一个然后再合并。但是为了降低空间复杂度,我们选择双指针元素交换的方式实现原地排序。具体做法就是两个指针 i 和 j,i 指向大于分区点的位置,j 表示当前位置,如果当前位置小于分区点,则交换 i 和 j ,这样最后 i 的位置就是分区点,再将 i 和 povit 交换。图解:
这个分解我们就不写伪代码了,经过上面的分析,我们直接写出整个分区和分解排序的过程代码:
public class QuickSort { public static void main(String[] args) { int[] arr = {4,5,6,3,2,1}; sort(arr,0,arr.length-1); for (int item : arr){ System.out.print(item); } } /** * 分解 */ private static void sort(int[] arr,int left,int right){ //终止条件 if (left >= right)return; //获取分区位置 int povit = partition(arr,left,right); //分解 sort(arr,left,povit-1); sort(arr,povit+1,right); } /** * 通过双指针、原地交换的方式实现原地排序 */ private static int partition(int[] arr,int left,int right){ //i指针指向比基准点大的位置 int i=left; //选取最后一个元素为基准点 int povit = arr[right]; for (int j=left;j){ //如果当前位置小于基准点,则和大于基准点的位置交换 if (arr[j] < povit){ swap(arr,i,j); i++; } } //最后将基准点换到分区位置 swap(arr,i,right); return i; } private static void swap(int[] arr,int i,int j){ int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } }
性能分析:
? 快读排序是原地排序,空间复杂度 O(1)
? 快速排序是不稳定的排序
? 快读排序的最好和平均时间复杂度是 O(nlogn),但是当数组是有序的情况下,如[1,2,3,4,5,6],因为每次都选取最后一个元素为分区点,所以时间复杂度会退化成 O(n2)
接下来我们来分析一下归并和快速两种算法的区别:
二者都是分治思想,先来看张图看下二者的区别
可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
案例:
leetcode 215. 数组中的第K个最大元素,可以利用快速排序的分区方式来求解,时间复杂度 O(n)。