• 【数据结构与算法】【左神】01-复杂度和简单排序算法


    1 时间复杂度

    1.1 常数操作

      如果一个操作和样本的数据量没有关系,每次都在固定时间内完成,就称其为常数操作

      譬如根据下标从数组中取出元素的操作,与 array 中的元素个数无关,因此是常数操作。如果换成链表,想获取某个位置的元素,需要从头开始遍历,就不是常数操作了。

    1.2 什么是时间复杂度

      时间复杂度为一个算法流程中,常数操作数量的一个指标。通常用 O(读作 big O)来表示。

    1.3 时间复杂度的表示

      具体到某个算法流程,就是总结出这个算法流程中发生的常数操作数量的表达式。在这个表达式中,去掉低阶项和高阶项的系数,最后剩下的部分为 f(N)。我们称时间复杂度为 O(f(N))。

    1.4 时间复杂度的优劣评判

      比较两个算法流程的优劣,首先看时间复杂度 O(f(N))。如果 O(f(N)) 能区分开,则以 O(f(N)) 为准。但有时两个算法流程的 O(f(N)) 相同,此时就要分析不同数据样本下的实际运行时间,即“常数项时间”。之所以要分析实际运行时间,是因为不同算法流程的最高项前的系数可能不同,所谓“常数操作”的实际动作也不同(譬如一个是乘法,一个是位运算)。在这种情况下,无法通过理论值区分,只能实际运行后对比。

    1.5 数据对时间复杂度的影响

      在下文中,我们将会介绍几种排序算法。和选择排序、冒泡排序相比,插入排序较为特殊。插入排序的时间复杂度并非固定,而是和传入的数据情况有关。譬如传入的数组是 [7, 6, 5, 4, 3, 2, 1],时间复杂度为 O(N2)。传入的数组是 [1, 2, 3, 4, 5, 6, 7],时间复杂度为 O(N)。这就引出了以下规则:

      算法流程按最差情况来估计时间复杂度

    2 额外空间复杂度

      额外空间复杂度是指对于某个算法流程需要多少额外空间。如果只需要有限的几个变量,则额外空间复杂度为 O(1)。如果需要开辟与原数组相同的额外数组,则额外空间复杂度为 O(N)。

    3 补充:异或运算(^)

    3.1 异或运算的基本理解

      异或运算,两个操作数不同时运算结果为 1,相同时结果为 0。

      异或运算还可以理解为无进位相加。譬如 a 为 0b10110,b 为 0b00111,a^b 为 10001。

    3.2 异或运算的性质

      (1)0 ^ N = N

      (2)N ^ N = 0

      (3)异或运算满足交换律、结合律

    3.3 利用异或运算实现 swap

    1 public static void swap(int[] arr, int i, int j) {
    2     arr[i] = arr[i] ^ arr[j];
    3     arr[j] = arr[i] ^ arr[j];
    4     arr[i] = arr[i] ^ arr[j];
    5 }

      上述代码中用到的 swap 方法,就是利用 2.4.2 中的异或运算性质实现的。

      注意,这种方式能够正确应用的前提是,a 和 b 指向两块不同的内存。如果 a 和 b 指向同一块内存,相当于自己和自己异或,结果会把自己清零。由于这个限制,在实际应用中要谨慎使用异或运算来实现 swap

    3.4 关于异或的面试题

      已知一个整型数组 arr,请针对以下两种情况分别求解。要求时间复杂度为 O(N),额外空间复杂度为 O(1)。

      (1)只有一个数出现奇数次,其余数出现偶数次。求出现奇数次的数是多少?

      (2)只有两个数出现奇数次,且这两个数不相等。其余数出现偶数次。求出现奇数次的数是多少?

      第一问很简单,数组中的数异或到一起,出现偶数次的数会被消掉,结果就是出现奇数次的数。代码如下:

    1 public static void printOddTimesNum1(int[] arr) {
    2     int eor = 0;
    3 
    4     for (int tmp : arr) {
    5         eor ^= tmp;
    6     }
    7 
    8     System.out.println("The number is: " + eor);
    9 }

      在理解第一问的基础上,我们再来看看第二问。现在数组中有两个出现奇数次的数,我们不妨设其为 a 和 b。对整个数组进行异或运算,得到的结果为 a^b。由于 a≠b,所以 a^b 一定有某一位不为 0。接下来,根据这一位是否为 0,可以将整个数组拆分为两个第一问中的数组。对拆分出的两个数组分别求异或,即可求得 a 和 b。代码如下:

     1 public static void printOddTimesNum2(int[] arr) {
     2     int eor = 0;
     3 
     4     for (int tmp : arr) {
     5         eor ^= tmp;
     6     }
     7 
     8     int lastOne = eor & (~eor + 1);
     9     int firstNum = 0;
    10 
    11     for (int tmp : arr) {
    12         if ((lastOne & tmp) != 0) {
    13             firstNum ^= tmp;
    14         }
    15     }
    16 
    17     System.out.println("The first number is: " + firstNum + ", the second number is: " + (eor ^ firstNum));
    18 }

    4 选择排序

    4.1 选择排序的原理

      第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到待排序数据元素个数为零。

    4.2 选择排序代码示例

     1 public static void selectionSort(int[] arr) {
     2     
     3     if (arr == null || arr.length < 2) {
     4         return;
     5     }
     6 
     7     for (int i = 0; i < arr.length - 1; i++) { // i ~ N - 1
     8         int minIndex = i;
     9         for (int j = i + 1 ; j < arr.length; j++) { // i ~ N - 1 上找最小值的下标
    10             minIndex = arr[j] < arr[minIndex] ? j : minIndex;
    11         }
    12         swap(arr, i, minIndex);
    13     }
    14 
    15

    4.3 选择排序的时间复杂度和额外空间复杂度

      时间复杂度 O(N2)。额外空间复杂度 O(1)。

    5 冒泡排序

    5.1 冒泡排序的原理

      冒泡排序重复地遍历待排序的元素序列,依次比较两个相邻的元素,如果顺序错误则交换。遍历重复进行,直到没有相邻元素需要交换。

    5.2 冒泡排序代码示例

     1 public static void bubbleSort(int[] arr) {
     2     if (arr == null || arr.length < 2) {
     3         return;
     4     }
     5 
     6     for (int e = arr.length - 1; e > 0; e--) { // 0 ~ e
     7         for (int i = 0; i < e; i++) {
     8             if (arr[i] > arr[i + 1]) {
     9                 swap(arr, i, i + 1);
    10             }
    11         }
    12     }
    13 }
    14 
    15 // 交换 arr 的 i 和 j 位置上的值
    16 public static void swap(int[] arr, int i, int j) {
    17     arr[i] = arr[i] ^ arr[j];
    18     arr[j] = arr[i] ^ arr[j];
    19     arr[i] = arr[i] ^ arr[j];
    20 }

    5.3 冒泡排序的时间复杂度和额外空间复杂度

      时间复杂度 O(N2)。额外空间复杂度 O(1)。

    6 插入排序

    6.1 插入排序的原理

      在待排序的元素中,假设前面 n-1(n>=2)个数已经是排好顺序的,现将第 n 个数插到前面已经排好的序列中,使得插入第 n 个数的这个序列也是排好顺序的。按照此法对所有元素进行插入,直到整个序列排为有序的过程,称为插入排序。

      以数组 [5, 6, 2, 3, 7, 3, 1] 为例,插入排序的过程如下:

      对下标 0~0 的数排序。此时只有一个数,无需交换。数组为 [5, 6, 2, 3, 7, 3, 1]。

      对下标 0~1 的数排序。此时已经有序,无需交换。数组为 [5, 6, 2, 3, 7, 3, 1]。

      对下标 0~2 的数排序。首先交换 6 和 2,数组变为 [5, 2, 6, 3, 7, 3, 1]。再交换 5 和 2,数组变为 [2, 5, 6, 3, 7, 3, 1]。

      对下标 0~3 的数排序。首先交换 6 和 3,数组变为 [2, 5, 3, 6, 7, 3, 1]。再交换 5 和 3,数组变为 [2, 3, 5, 6, 7, 3, 1]。

      对下标 0~4 的数排序。此时已经有序,无需交换。数组为 [2, 3, 5, 6, 7, 3, 1]。

      对下标 0~5 的数排序。首先交换 7 和 3,数组变为 [2, 3, 5, 6, 3, 7, 1]。再交换 6 和 3,数组变为 [2, 3, 5, 3, 6, 7, 1]。再交换 5 和 3,数组变为 [2, 3, 3, 5, 6, 7, 1]。

      对下标 0~6 的数排序。首先交换 7 和 1,数组变为 [2, 3, 3, 5, 6, 1, 7]。再交换 6 和 1,数组变为 [2, 3, 3, 5, 1, 6, 7]。再交换 5 和 1,数组变为 [2, 3, 3, 1, 5, 6, 7]。再交换第二个 3 和 1,数组变为 [2, 3, 1, 3, 5, 6, 7]。再交换第一个 3 和 1,数组变为 [2, 1, 3, 3, 5, 6, 7]。再交换 2 和 1,数组变为 [1, 2, 3, 3, 5, 6, 7]。

    6.2 插入排序代码示例

     1 public static void insertionSort(int[] arr) {
     2 
     3     if (arr == null || arr.length < 2) {
     4         return;
     5     }
     6 
     7     // 0~0 有序的
     8     // 0~i 想有序
     9     for (int i = 1; i < arr.length; i++) { // 0~i 做到有序
    10         for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
    11             swap(arr, j, j + 1);
    12         }
    13     }
    14 }

    6.3 插入排序的时间复杂度和额外空间复杂度

      时间复杂度 O(N2),额外空间复杂度 O(1)。

    7 二分法

    7.1 在一个有序数组中,找某个数是否存在

      如果用遍历的方式查找,则时间复杂度为 O(N)。在数组有序的前提下,可以使用二分法进行查找,时间复杂度为 O(log N)。

    7.2 在一个有序数组中,找 ≥ 某个数最左侧的位置

      使用一个变量 t 来记录当前找到的最左侧下标。

    7.3 局部最小值问题

      在一个数组中,如果某个元素小于其相邻元素,则称其为局部最小。现有一无序数组 arr,其相邻元素一定不相等,求其中任意一个局部最小。

      首先判断下标为 0 和下标为 N-1 的元素是否为局部最小,如是则直接返回。如下标为 0 和下标为 N-1 的元素均非局部最小,则数组头部为向下趋势,尾部为向上趋势,其中必存在局部最小(注意前置条件为数组中每个元素都不相同)。

      此时使用二分,判断中点元素是否为局部最小。如不是,则判断其左右趋势,决定取左侧部分还是右侧部分。实际上,这种二分是利用数组中元素大小变化趋势来实现的。

    7.4 二分法的应用条件

      通过 7.3 中的问题,应当认识到,二分并非仅能应用于有序序列。在特定的数据状况和问题条件下,对于无序序列也有可能应用二分法。

      对于流程的优化,通常也就是数据状况和问题条件这两个方向。

    8 对数器

    8.1 对数器的概念

      对数器是一种测试的手段,通过对数器,我们可以摆脱对 OJ 的依赖。其原理简述如下:

      假设方法 a 是我们想验证的方法(譬如二分查找)。方法 b 是实现复杂度较差,但容易实现的方法(譬如遍历查找)。我们先写一个随机样本发生器,用 a 和 b 分别跑相同的随机样本,对比测试结果是否相同。如果有某个随机样本得到的结果不一致,可以缩小随机样本的规模,添加打印信息,进行人工修正。如果使用大规模随机样本测试,方法 a 和 b 的运算结果仍一致,即可确定方法 a 的正确性。

    8.2 对数器代码示例

      1 /*
      2  * 函数: comparator
      3  * 功能: 对数器方法,即上述说明中的方法b
      4  */
      5 public static void comparator(int[] arr) {
      6     Arrays.sort(arr);
      7 }
      8 
      9 /*
     10  * 函数: generateRandomArray
     11  * 功能: 生成随机数组(长度、值均随机)
     12  * 补充说明: 
     13  *      Math.random()             ->  [0, 1)    中所有的小数,等概率返回一个
     14  *      Math.random() * N         ->  [0, N)    中所有的小数,等概率返回一个
     15  *      (int)(Math.random() * N)  ->  [0, N-1]  中所有的整数,等概率返回一个
     16 */
     17 public static int[] generateRandomArray(int maxSize, int maxValue) {
     18     int[] arr = new int[(int)(Math.random() * (maxSize + 1))]; // 长度随机
     19     for (int i = 0; i < arr.length; i++) {
     20         arr[i] = (int)(Math.random() * (maxValue + 1)) - (int)(Math.random() * maxValue); // 值随机
     21     }
     22     return arr;
     23 }
     24 
     25 /*
     26  * 函数: copyArray
     27  * 功能: 复制数组,用于对数器方法的测试
     28  */
     29 public static int[] copyArray(int[] arr) {
     30     if (arr == null) {
     31         return null;
     32     }
     33     int[] res = new int[arr.length];
     34     for (int i = 0; i < arr.length; i++) {
     35         res[i] = arr[i];
     36     }
     37     return res;
     38 }
     39 
     40 /*
     41  * 函数: isEqual
     42  * 功能: 判断数组是否相等
     43  */
     44 public static boolean isEqual(int[] arr1, int[] arr2) {
     45     if ((arr1 == null && arr2 != null) || (arr1 != null && arr2 == null)) {
     46         return false;
     47     }
     48 
     49     if (arr1 == null && arr2 == null) {
     50         return true;
     51     }
     52 
     53     if (arr1.length != arr2.length) {
     54         return false;
     55     }
     56 
     57     for (int i = 0; i < arr1.length; i++) {
     58         if (arr1[i] != arr2[i]) {
     59             return false;
     60         }
     61     }
     62     
     63     return true;
     64 }
     65 
     66 /*
     67  * 函数: printArray
     68  * 功能: 打印数组元素
     69  */
     70 public static void printArray(int[] arr) {
     71     if (arr == null) {
     72         return;
     73     }
     74     for (int i = 0; i < arr.length; i++) {
     75         System.out.print(arr[i] + " ");
     76     }
     77     System.out.println();
     78 }
     79 
     80 // for test
     81 public static void main(String[] args) {
     82     int testTime = 500000;
     83     int maxSize = 100;
     84     int maxValue = 100;
     85     boolean succeed = true;
     86     for (int i = 0; i < testTime; i++) {
     87         int[] arr1 = generateRandomArray(maxSize, maxValue); // [-100, 100]
     88         int[] arr2 = copyArray(arr1); 
     89         insertionSort(arr1);
     90         comparator(arr2);
     91         if (!isEqual(arr1, arr2)) {
     92             succeed = false;
     93             break;
     94         }
     95     }
     96     System.out.println(succeed ? "Nice!" : "Fucking fucked!");
     97 
     98     int[] arr = generateRandomArray(maxSize, maxValue);
     99     printArray(arr);
    100     insertionSort(arr);
    101     printArray(arr);
    102 }

    9 剖析递归行为和递归行为时间复杂度的估算

    9.1 递归方法找数组中最大值

      用递归方法找一个数组中的最大值,代码如下:

     1 public class GetMax {
     2 
     3     public static int getMax(int[] arr) {
     4         return process(arr, 0, arr.length - 1);
     5     }
     6 
     7     public static int process(int[] arr, int L, int R) {
     8         if (L == R) {
     9             return arr[L];
    10         }
    11 
    12         int mid = L + ((R - L) >> 1);
    13         int leftMax = process(arr, L, mid);
    14         int rightMax = process(arr, mid + 1, R);
    15 
    16         return Math.max(leftMax, rightMax);
    17     }
    18 }

      注意 line 12 的求中点算法。通常可能写成 mid = (L + R) / 2; 。这种算法是有问题的,在数组长度很大的情况下, L + R 可能溢出。更可靠的版本是 mid = L + (R - L) / 2; 。进一步简化: mid = L + (R - L) >> 1; 。右移操作比除法操作速度快。

    9.2 master 公式

      T(N) = a * T(N/b) + O(Nd)。

      T(N) 是指母问题的数据量是 N 级别的。T(N/b) 是指子问题的数据量都是 N/b 级别的。a 是子问题的调用次数。O(Nd) 是指除了子问题调用之外,剩余部分的时间复杂度。符合以上规律的递归,可以用 master 公式来求解时间复杂度。

      以上描述可能过于抽象,我们还是以 9.1 中求数组最大值为例进行说明。由于 process 函数每轮递归都被拆成两个等量的子问题,所以可以应用 master 公式。T(N) = 2 * T(N/2) + O(N0)。

      确定 a、b、d 之后,即可使用以下公式计算时间复杂度:

      (1)loga > d    复杂度为 O(Nloga)

      (2)loga = d    复杂度为 O(Nd * log N)

      (3)loga < d    复杂度为 O(Nd)

      补充阅读:算法的复杂度与 Master 定理

  • 相关阅读:
    bzoj3064: Tyvj 1518 CPU监控
    bzoj3272: Zgg吃东西&&3267: KC采花
    bzoj2759: 一个动态树好题
    bzoj4594: [Shoi2015]零件组装机
    bzoj4873: [Shoi2017]寿司餐厅
    bzoj4593: [Shoi2015]聚变反应炉
    codeforces 739E
    bzoj2034: [2009国家集训队]最大收益
    mybatis-generator使用心得
    Linux 各种软件的安装-Jenkins和svn结合
  • 原文地址:https://www.cnblogs.com/murongmochen/p/15112723.html
Copyright © 2020-2023  润新知