【数据结构与算法】【左神】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)logb a > d 复杂度为 O(Nlogb a)
(2)logb a = d 复杂度为 O(Nd * log N)
(3)logb a < d 复杂度为 O(Nd)
补充阅读:算法的复杂度与 Master 定理。