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

  • 相关阅读:
    Shell使用
    从一道面试题谈linux下fork的运行机制
    老了,问题定位难了,xml编码解析
    javacc
    C++概述
    Notepad++中设置Windows、Unix、Mac三种行尾换行符格式间的转换
    玩转html5(一)-----盘点html5新增的那些酷酷的input类型和属性
    Java Drp项目实战—— 环境搭建
    cocos2d-x游戏开发 跑酷(八) 对象管理 碰撞检測
    电话拨号盘(带触摸振动反馈)
  • 原文地址:https://www.cnblogs.com/murongmochen/p/15143770.html
Copyright © 2020-2023  润新知