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

2021/8/18 20:06:39

本文主要是介绍【数据结构与算法】【左神】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 定理。

 



这篇关于【数据结构与算法】【左神】01-复杂度和简单排序算法的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程