【数据结构与算法】【左神】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++。

  以数组 [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 不变;

  以数组 [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)。

 

上一篇:NLog使用


下一篇:寻峰函数fIndpeaks的C++实现