【数据结构与算法】【左神】02-认识O(Nlog N)的排序
1. 归并排序
1.1 归并排序的原理
以数组 [2, 1, 3, 6, 5, 2] 为例来讲解归并排序的思路。首先,将待排序数组均分为两个数组,并将这两个数组排序。结果即 [1, 2, 3] 和 [2, 5, 6]。接下来,将这两个数组合并,使其整体有序。思路是创建一个 buffer,从这两个数组的首元素开始对比,将较小的元素放入 buffer。倘若两个子数组中取到的元素相等,将左侧的元素放入 buffer。直至某个子数组下标越界,将另一个子数组中的剩余元素放入 buffer。以上工作完成后,再将 buffer 中排好序的元素拷贝回原数组。
接下来的描述中,黑体的元素即为下标所指的待处理元素。
左子数组 右子数组 buffer
[1, 2, 3] [2, 5, 6] []
[1, 2, 3] [2, 5, 6] [1]
[1, 2, 3] [2, 5, 6] [1, 2]
[1, 2, 3] [2, 5, 6] [1, 2, 2]
[1, 2, 3] [2, 5, 6] [1, 2, 2, 3]
[1, 2, 3] [2, 5, 6] [1, 2, 2, 3, 5, 6]
简而言之,归并排序整体就是一个简单递归,将左边排好序,右边也排好序,然后再使其整体有序。让其整体有序的过程中用了外排序方法(先将数据放在外部数组里,排序完成再拷回来)。
1.2 归并排序代码示例
1 public class MergeSort { 2 3 public static void mergeSort(int[] arr) { 4 if (arr == null || arr.length < 2) { 5 return; 6 } 7 8 process(arr, 0, arr.length - 1); 9 } 10 11 public static void process(int[] arr, int L, int R) { 12 if (L == R) { 13 return; 14 } 15 16 int mid = L + ((R - L) >> 1); 17 process(arr, L, mid); 18 process(arr, mid + 1, R); 19 merge(arr, L, mid, R); 20 } 21 22 public static void merge(int[] arr, int L, int M, int R) { 23 int[] help = new int[R - L + 1]; 24 int i = 0; 25 int p1 = L; 26 int p2 = M + 1; 27 28 while (p1 <= M && p2 <= R) { 29 help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++]; 30 } 31 32 while (p1 <= M) { 33 help[i++] = arr[p1++]; 34 } 35 36 while (p2 <= R) { 37 help[i++] = arr[p2]++; 38 } 39 40 for (int i = 0; i < help.length; i++) { 41 arr[L + i] = help[i]; 42 } 43 44 return; 45 } 46 47 }
1.3 归并排序的时间复杂度和额外空间复杂度
利用 master 公式来求解时间复杂度,a = 2, b = 2,d = 1。套用 master 公式,即可得到时间复杂度为 O(Nlog N)。
额外空间复杂度为 O(N)。
1.4 归并排序的实质
选择排序、插入排序、冒泡排序的时间复杂度均为 O(N2)。它们就差在浪费了很多比较行为。以冒泡排序为例,第一次比较 0~N-1 之间的元素,搞定一个数。第二次比较 1~N-1 之间的元素,搞定一个数。以此类推。但这些排序是相互独立的,丢弃了大量的数据。
归并排序之所以能把时间复杂度优化到 O(Nlog N),是因为没有浪费比较行为。每次比较完成,都会将一个元素排好序。
1.5 归并排序的扩展——小和问题
1.5.1 小和问题概述
在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和。以数组 [1, 3, 4, 2, 5] 为例,1 左边没有比 1 小的数。3 左边比 3 小的数是 1。4 左边比 4 小的数是 1、3。2 左边比 2 小的数是 1。5 左边比 5 小的数是 1、3、4、2。综上,小和为 1 + 1 + 3 + 1 + 1 + 3 + 4 + 2 = 16。
1.5.2 小和问题的思路转化
求小和的问题,可以转化为判断在某个元素的右侧,有几个元素比它大的问题。右侧有几个比它大的元素,该元素就被计算几次小和。因此,可以将求小和转化为类似归并排序的问题。但有两个问题需要明确:
-
- 排序的操作是不能去掉的。因为我们在归并过程中希望直接根据下标判断,右侧有几个元素比当前元素大。这样做的前提是有序。
- 当左侧子数组和右侧子数组当前需要判断的元素相等时,归并排序是将左侧子数组中的元素放入 buffer。求小和的操作则应将右侧子数组中的元素放入 buffer,否则怎么知道右侧有几个元素比当前元素大呢?
1.5.3 小和问题示例代码
1 public class Code02_SmallSum { 2 3 public static int smallSum(int[] arr) { 4 if (arr == null || arr.length < 2) { 5 return 0; 6 } 7 return mergeSort(arr, 0, arr.length - 1); 8 } 9 10 public static int mergeSort(int[] arr, int l, int r) { 11 if (l == r) { 12 return 0; 13 } 14 int mid = l + ((r - l) >> 1); 15 return mergeSort(arr, l, mid) 16 + mergeSort(arr, mid + 1, r) 17 + merge(arr, l, mid, r); 18 } 19 20 public static int merge(int[] arr, int l, int m, int r) { 21 int[] help = new int[r - l + 1]; 22 int i = 0; 23 int p1 = l; 24 int p2 = m + 1; 25 int res = 0; 26 while (p1 <= m && p2 <= r) { 27 res += arr[p1] < arr[p2] ? (r - p2 + 1) * arr[p1] : 0; 28 help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++]; 29 } 30 while (p1 <= m) { 31 help[i++] = arr[p1++]; 32 } 33 while (p2 <= r) { 34 help[i++] = arr[p2++]; 35 } 36 for (i = 0; i < help.length; i++) { 37 arr[l + i] = help[i]; 38 } 39 return res; 40 } 41 42 // for test 43 public static int comparator(int[] arr) { 44 if (arr == null || arr.length < 2) { 45 return 0; 46 } 47 int res = 0; 48 for (int i = 1; i < arr.length; i++) { 49 for (int j = 0; j < i; j++) { 50 res += arr[j] < arr[i] ? arr[j] : 0; 51 } 52 } 53 return res; 54 } 55 56 // for test 57 public static int[] generateRandomArray(int maxSize, int maxValue) { 58 int[] arr = new int[(int) ((maxSize + 1) * Math.random())]; 59 for (int i = 0; i < arr.length; i++) { 60 arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random()); 61 } 62 return arr; 63 } 64 65 // for test 66 public static int[] copyArray(int[] arr) { 67 if (arr == null) { 68 return null; 69 } 70 int[] res = new int[arr.length]; 71 for (int i = 0; i < arr.length; i++) { 72 res[i] = arr[i]; 73 } 74 return res; 75 } 76 77 // for test 78 public static boolean isEqual(int[] arr1, int[] arr2) { 79 if ((arr1 == null && arr2 != null) || (arr1 != null && arr2 == null)) { 80 return false; 81 } 82 if (arr1 == null && arr2 == null) { 83 return true; 84 } 85 if (arr1.length != arr2.length) { 86 return false; 87 } 88 for (int i = 0; i < arr1.length; i++) { 89 if (arr1[i] != arr2[i]) { 90 return false; 91 } 92 } 93 return true; 94 } 95 96 // for test 97 public static void printArray(int[] arr) { 98 if (arr == null) { 99 return; 100 } 101 for (int i = 0; i < arr.length; i++) { 102 System.out.print(arr[i] + " "); 103 } 104 System.out.println(); 105 } 106 107 // for test 108 public static void main(String[] args) { 109 int testTime = 500000; 110 int maxSize = 100; 111 int maxValue = 100; 112 boolean succeed = true; 113 for (int i = 0; i < testTime; i++) { 114 int[] arr1 = generateRandomArray(maxSize, maxValue); 115 int[] arr2 = copyArray(arr1); 116 if (smallSum(arr1) != comparator(arr2)) { 117 succeed = false; 118 printArray(arr1); 119 printArray(arr2); 120 break; 121 } 122 } 123 System.out.println(succeed ? "Nice!" : "Fucking fucked!"); 124 } 125 126 }
1.6 归并排序的扩展——逆序对问题
1.6.1 逆序对问题概述
在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对。给定数组,请计算逆序对数量。
1.6.2 逆序对问题思路转化
逆序对问题可以转化为求某个元素右侧有几个元素比它小的问题。
1.6.3 逆序对问题代码示例
2 荷兰国旗问题
2.1 问题一
给定一个数组 arr 和一个数 num,请把小于等于 num 的数放在数组的左边,大于 num 的数放在数组的右边。要求:额外空间复杂度 O(1),时间复杂度 O(N)。
2.1.1 思路转化
首先,设置一个“≤ 区”,在这个区域中的元素都是小于等于给定 num 的。在程序起始阶段,“≤ 区”为空,此时“≤ 区”后面的元素是数组首元素。
此外,还要使用一个变量 i,向后遍历数组。此过程分为两种情况:
-
- arr[i] <= num
- 此时将 arr[i] 和“≤ 区”后面的元素交换,“≤ 区”右扩,i++;
- arr[i] > num
- i++。
- arr[i] <= num
以数组 [3, 5, 4, 7, 6, 3, 5, 8] 为例,分析算法过程(蓝色为 i 指向的元素,红色部分为“≤ 区”):
(1)[3, 5, 4, 7, 6, 3, 5, 8]
初始状态。此时 3 就是“≤ 区”后面的元素,因此是自己和自己交换。此处容易弄错,需要注意。
3 和自己交换,“≤ 区”右扩,i++。
(2)[3, 5, 4, 7, 6, 3, 5, 8]
5 和自己交换,“≤ 区”右扩,i++。
(3)[3, 5, 4, 7, 6, 3, 5, 8]
4 和自己交换,“≤ 区”右扩,i++。
(4)[3, 5, 4, 7, 6, 3, 5, 8]
不做交换,“≤ 区”不右扩,i++。
(5)[3, 5, 4, 7, 6, 3, 5, 8]
不做交换,“≤ 区”不右扩,i++。
(6)[3, 5, 4, 7, 6, 3, 5, 8]
3 和 7 交换,“≤ 区”右扩,i++。
(7)[3, 5, 4, 3, 6, 7, 5, 8]
5 和 6 交换,“≤ 区”右扩,i++。
(8)[3, 5, 4, 3, 5, 7, 6, 8]
不做交换,“≤ 区”不右扩,i++。
(9)最终结果为 [3, 5, 4, 3, 5, 7, 6, 8]。
2.2 问题二(荷兰国旗问题)
给定一个数组 arr 和一个数 num,请把小于 num 的数放在数组的左边,等于 num 的数放在数组的中间,大于 num 的数放在数组的右边。要求:额外空间复杂度 O(1),时间复杂度 O(N)。
2.2.1 思路转化
设置一个“< 区”,在这个区域中的元素都是小于给定 num 的。在程序起始阶段,“< 区”为空,此时“< 区”后面的元素是数组首元素。
设置一个“> 区”,在这个区域中的元素都是大于给定 num 的。在程序起始阶段,“> 区”为空,此时“> 区”前面的元素是数组尾元素。
此外,还要使用一个变量 i,向后遍历数组。此过程分为三种情况:
-
- arr[i] < num
- 将 arr[i] 和“< 区”后面的元素交换,“< 区”右扩,i++;
- arr[i] = num
- i++;
- arr[i] > num
- 将 arr[i] 和“> 区”前面的元素交换,“> 区”左扩,i 不变;
- arr[i] < num
以数组 [3, 5, 4, 7, 6, 3, 5, 8] 为例,分析算法过程(蓝色为 i 指向的元素,红色部分为“< 区”,绿色部分为“> 区”):
(1)[3, 5, 4, 7, 6, 3, 5, 8]
3 和自己交换,“< 区右扩”,i++。
(2)[3, 5, 4, 7, 6, 3, 5, 8]
i++。
(3)[3, 5, 4, 7, 6, 3, 5, 8]
4 和 5 交换,“< 区右扩”,i++。
(4)[3, 4, 5, 7, 6, 3, 5, 8]
7 和 8 交换,“> 区左扩”,i 不变。
(5)[3, 4, 5, 8, 6, 3, 5, 7]
8 和 5 交换,“> 区左扩”,i 不变。
(6)[3, 4, 5, 5, 6, 3, 8, 7]
i++。
(7)[3, 4, 5, 5, 6, 3, 8, 7]
6 和 3 交换,“> 区左扩”,i 不变。
(8)[3, 4, 5, 5, 3, 6, 8, 7]
3 和 5 交换,“< 区右扩”,i++。
(9)最终结果为 [3, 4, 3, 5, 5, 6, 8, 7]。
3. 快速排序
3.1 快排 V1.0
快排 V1.0 和荷兰问题的第一问思路类似。将数组的最后一个元素作为 num,将其前面的元素按“≤ num”和“> num”分成两个部分。划分完成后,将 num 和“> num”部分的首元素交换。其后,将划分出的两个区域分别递归,重复上述过程。
时间复杂度为 O(N2)。
3.2 快排 V2.0
快排 V2.0 和荷兰问题的第二问思路类似。将数组的最后一个元素作为 num,将其前面的元素按“< num”、“= num”和“> num”分成三个部分。划分完成后,将 num 和“> num”部分的首元素交换。其后,将划分出的“< num”和“> num”区域分别递归,重复上述过程。
时间复杂度为 O(N2)。
3.3 快排 V1.0 和快排 V2.0 的时间复杂度局限性
不难看出,快排 V1.0 和快排 V2.0 的时间复杂度依赖于 num 的选取。划分值越靠近两侧,复杂度越高。划分值越靠近中间,复杂度越低。对于极端的测试数据,譬如 [1, 2, 3, 4, 5] 这样的数组,时间复杂度为 O(N2)。
改善快速排序的时间复杂度,就要对 num 的选取做文章。这就引出了随即快速排序。
3.4 快排 V3.0(随机快速排序)
3.4.1 随机快速排序原理
在数组范围中,等概率随机选一个数作为划分值 num。根据这个 num 把数组按“< num”、“= num”和“> num”分成三个部分。划分完成后,将 num 和“> num”部分的首元素交换。其后,将划分出的“< num”和“> num”区域分别递归,重复上述过程。
时间复杂度为O(N*logN)。
3.4.2 随机快速排序示例代码
3.5 快速排序的额外空间复杂度
概率平均情况:O(log N)。
最差情况:O(N)。