只要讲到数据结构和算法,就必定离不开时间、空间复杂度分析。复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。算法
过后分析法:把代码跑一遍,经过统计、监控、就能获得算法执行的时间、占用的内存大小,可是这种统计方法具备很大的局限性。数组
测试环境中,硬件的不一样会对测试结果有很大的影响,好比 i9 的运算速度会比 i3 快,或者在 A 计算机上执行某个代码块a 的速度比另外一个代码块 b 的速度要快,放到 B 计算机上可能又会获得不一样的结果。数据结构
若是是在测试排序算法,测试数据的有序性极可能会影响不一样排序算法的执行时间;若是测试数据规模过小,测试结果有可能没法真实地反映算法的性能。数据结构和算法
咱们须要一个不须要用具体的测试数据来测试,就能够粗略的估计算法的执行效率的方法。这就是时间、空间复杂度分析。函数
算法的执行效率,粗略地讲,就是算法的执行时间。可是如何在不运行代码的状况下,用“肉眼”获得一段代码的执行时间呢?性能
这里有一段很是简单的代码例 1,求 1,2,3...n 的累加和:学习
例 1: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 for(; i <= n; i ++){ 5 sum = sum + i; 6 } 7 8 return sum; 9 }
从 CPU 的角度来看,例 1 的代码的每一行都执行着相似的操做:读数据 -- 运算 -- 写数据。尽管每一行代码对应的 CPU 执行的个数、执行时间都不同,可是,咱们这里只是粗略估计,因此能够假设每行代码执行的时间都为 unit_time。测试
例 1 中,第 二、三、8 行代码须要 3 * unit_time 的执行时间;第 四、5 行都运行了 n 遍,须要 2n * unit_time 的执行时间;总的执行时间就是T(n) = ( 2n + 3 ) * unit_time 。能够看出,全部代码的执行时间 T(n) 与每行代码的执行次数成正比。spa
再看例 2:code
例 2: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 int j = 1; 5 for(; i <= n; i ++){ 6 j = 1; 7 for(; j <= n; j ++){ 8 sum = sum + i; 9 } 10 } 11 12 return sum; 13 }
例 2 中,第 二、三、四、12 行代码须要 4 * unit_time 的执行时间;第 五、6 行代码须要 2n * unit_time 的执行时间;第 七、8 行代码循环执行了 n² 遍,须要 2 * n² * unit_time 的执行时间;总的执行时间就是 T(n) = ( 2n² + 2n + 4 ) * unit_time 。
根据例1 和例 2 的推导过程,能够获得一个很是重要的规律:全部的代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。
咱们把这个规律总结成一个公式,就是大 O 复杂度表示法:
其中,T(n) 表示代码执行的时间,n 表示数据规模的大小,f(n) 表示每行代码执行的次数总和;O 表示代码的执行时间 T(n) 与代码的执行次数 f(n) 成正比。
用大 O 复杂度表示法来表达时间复杂度,例 1 为 T(n) = O( 2n + 3),例 2 为 T(n) = O( 2n² + 2n + 4 )。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增加的变化趋势,因此也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当 n 很大时,公式中的低阶、常量、系数三部分并不左右增加趋势,因此都能够忽略,只须要记录一个最大量级就能够了。例 一、例 2 的时间复杂度,就分别能够记为: T(n) = O( n );T(n) = O( n² )。
大 O 复杂度表示方法,只是表示一种变化趋势,咱们一般会忽略掉公式中的常量、低阶、系数,只需记录一个最大阶的量级就能够了。
咱们在分析一个算法、一段代码的时间复杂度的时候,只关注循环执行次数最多的那一段代码就能够了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。
仍以例 1 为例:
例 1: 1 int cal(int n){ 2 int sum = 0; 3 int i = 1; 4 for(; i <= n; i ++){ 5 sum = sum + i; 6 } 7 8 return sum; 9 }
其中第 二、三、8 行代码都是常量级的执行时间,与 n 的大小无关,因此对于时间复杂度并没有影响。循环执行次数最多的是第 四、5 行代码,因此这块代码要重点分析。因为这两行代码被执行了 n 次,因此总的时间复杂度就是 O( n )。
下面你们看例 3:
例 3: 1 int cal(int n){ 2 int sum_1 = 0; 3 int p = 1; 4 for(; p < 100; p ++){ 5 sum_1 = sum_1 + p; 6 } 7 8 int sum_2 = 0; 9 int q = 1; 10 for(; q < n; q ++){ 11 sum_2 = sum_2 + q; 12 } 13 14 int sum_3 = 0; 15 int i = 1; 16 int j = 1; 17 for(; i <= n; i ++){ 18 j = 1; 19 for(; j <= n; j ++){ 20 sum_3 = sum_3 + i * j; 21 } 22 } 23 24 return sum_1 + sum_2 + sum_3; 25 }
例 3 的代码分为三部分,分别求 sum_1 、sum_2 、sum_3 。
综合这三段代码的时间复杂度,咱们取其中最大的量级,因此整段代码的时间复杂度为 T(n) = O( n² )。也就是说,总的时间复杂度,等于量级最大的那段代码的时间复杂度。
将这个规律抽象成公式,以下:
若是:
那么:
有以下代码例 4:
例 4: 1 int cal(int n){ 2 int ret = 0; 3 int i = 1; 4 for(; i < n; i ++){ 5 ret = ret + f(i); 6 } 7 8 return ret; 9 } 10 11 int f(int n){ 12 int sum = 0; 13 int i = 1; 14 for(; i < n; i ++){ 15 sum = sum + i; 16 } 17 18 return sum; 19 }
例 4 的代码为嵌套循环代码。假设 f() 只是一个普通的操做,那 四、5 行的时间复杂度就是T1(n) = O( n )。可是,f() 函数自己不是一个简单的操做,它的时间复杂度为T2(n) = O( n )。因此,例 4 中整个 cal() 函数的时间复杂度就是T(n) = T1(n) * T2(n) = O( n * n ) = O( n² )。
也就是说,总的时间复杂度,等于循环调用代码的时间复杂度的乘积。
将这个规律抽象成公式,以下:
若是:
那么:
常见的复杂度量级并很少,粗略的分为两类:多项式量级和非多项式量级。
多项式量级:
非多项式量级:
咱们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非肯定多项式)问题。
当数据规模愈来愈大时,非多项式量级算法的执行时间和急剧增长,求解问题的执行时间会无线增加。因此,非多项式时间复杂度的算法实际上是很是低效的算法。了解几种常见的多项式时间复杂度便可:
一、O(1) 二、O(㏒n)、O(n ㏒n) 三、O(m+n)、O(m\*n)
前面提到,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增加关系。
类比一下,空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增加关系。
相对时间复杂度来讲,空间复杂度分析要简单得多。
下面看一下例 5:
例 5: 1 void print(int n){ 2 int i = 0; 3 int[]a= new int[n]; 4 for(; i < n; i ++){ 5 a[i] = i * i; 6 } 7 8 for(i = n - 1; i >= 0; i --){ 9 System.out.println(a[i]); 10 } 11 }
跟时间复杂度分析同样,能够看到,第 2 行代码中,咱们申请了一个空间存储变量 i,可是它是常量阶的,与数据规模 n 无关,因此能够忽略;第 3 行申请了一个大小为 n 的 int 类型数组;除此以外,剩下的代码都没有占用更多的空间,因此整段代码的空间复杂度就是 O( n )。
咱们常见的空间复杂度就是O( 1 )、O( n )、O( n² )。
例 6:
例 6: 1 int find(int[]array, /*n 表示数组 array 长度*/int n, /*x 表示须要寻找的数字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 } 8 } 9 10 return pos; 11 }
例 6 要实现的功能,是在一个无序的数组中,查找变量 x 出现的位置,若是没有找到,就返回 -1;按照大 O 表示法,例 6 的时间复杂度是 O( n ),其中,n 表明数组长度。
可是,咱们在数组中查找某一个数字,不用所有都遍历一遍,有可能半途中就找到了,就能够提早结束循环了。
将例 6 改进后,获得例 7:
例 7: 1 int find(int[]array, /*n 表示数组 array 长度*/int n, /*x 表示须要寻找的数字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
不一样的状况下,例 7 的时间复杂度是不同的:
下面引入三个概念,用来表示代码在不一样状况下的不一样时间复杂度:
最好状况时间复杂度,就是在最理想的状况下,执行这段代码的时间复杂度。
例 7 中,若是数组中第一个数字就是 x,那这时候的时间复杂度就是最好状况时间复杂度。
最坏状况时间复杂度,就是在最不理想的状况下,执行这段代码的时间复杂度。
例 7 中,若是数组中 x 是最后一个数字,那这时候的时间复杂度就是最坏状况时间复杂度。
最好状况时间复杂度和最坏状况时间复杂度都是出如今极端环境下的代码复杂度,发生的几率并不大。为了更好地表示平均状况下的复杂度,咱们须要分析平均状况时间复杂度,简称平均时间复杂度。
继续看例 7:
例 7: 1 int find(int[]array, /*n 表示数组 array 长度*/int n, /*x 表示须要寻找的数字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
在例 7 中,咱们要查找的 x 所在的位置有 n+1 种状况:
咱们把每种状况下,查找须要遍历的元素个数累加起来,而后除以 n+1,就能够获得须要遍历的元素个数的平均值:
因此,总共的遍历个数为:
再除以总共的遍历方法 n + 1,即:
在大 O 表示法中,能够省略掉系数、低阶、常量,平均复杂度为 O( n )。
可是有一个问题,就是这 n+1 种状况,并非等几率的。
将几率因素考虑进去,平均时间复杂度为:
这样的计算结果叫作加权平均值,也叫作指望值,因此平均时间复杂的的其实是加权平均时间复杂度或指望时间复杂度。
舍掉系数和常量,获得的平均时间复杂度为 O( n )。
在大多数状况下,咱们并不须要区分最好、最坏、平均时间复杂度。只有同一块代码在不一样状况下,时间复杂度出现了量级的差距,才会使用这三种时间复杂度来表示。
大部分状况下,并不须要区分最好、最坏、平均时间复杂度,平均时间复杂度只在某些特殊场景有使用, 均摊时间复杂度的应用场景比平均时间复杂度更特殊、更有限。
事实上,均摊时间复杂度就是一种特殊的平均时间复杂度。
例 8:
例 8: 1 int[]array = new int[n]; // 长度为 n 的数组 2 int count = 0; 3 4 void insert(int val){ 5 if(count == array.length){ 6 int sum = 0; 7 for(int i = 0; i < array.length; i ++){ 8 sum = sum + array[i]; 9 } 10 array[0] = sum; 11 count = 1; 12 } 13 array[count] = val; 14 count ++; 15 }
例 8 实现了往数组中插入数据的功能:
因此例 8 的时间复杂度:
最后经过几率计算一下平均时间复杂度:假设数组长度是 n,根据数据插入位置的不一样,能够分为 n 种状况,每种状况的时间复杂度为 O( 1 );另外还有 1 种状况,假如数组中没有空闲空间时,插入一个数据的时间复杂度为O( n )。并且,这 n+1 种状况发生的几率同样,都是 1/(n+1) 。根据加权平均的计算方法,能够求得平均时间复杂度:
使用大 O 表示法,舍掉常量、系数、低阶,平均时间复杂度为 O( 1 )。
可是例 8 中的平均复杂度分析其实并不须要这么麻烦。再将例 7 和例 8 的代码放在一块儿看一下:
例 7: 1 int find(int[]array, /*n 表示数组 array 长度*/int n, /*x 表示须要寻找的数字*/int x){ 2 int i = 0; 3 int pos = -1; 4 for(; i < n; i ++){ 5 if(array[i] == x){ 6 pos = i; 7 break; 8 } 9 } 10 11 return pos; 12 }
例 8: 1 int[]array = new int[n]; // 长度为 n 的数组 2 int count = 0; 3 4 void insert(int val){ 5 if(count == array.length){ 6 int sum = 0; 7 for(int i = 0; i < array.length; i ++){ 8 sum = sum + array[i]; 9 } 10 array[0] = sum; 11 count = 1; 12 } 13 array[count] = val; 14 count ++; 15 }
因此,针对这样一种特殊场景的复杂度分析,并不须要以前的计算平均时间复杂度那样,引入几率计算加权平均值。由一种更加简单的分析方法:摊还分析法。经过摊还分析获得的时间复杂度,叫作均摊时间复杂度。
例 8 中的 insert() 函数,每一次 O(n) 插入操做后,都会跟着 n-1 次的 O(1) 插入操做,因此把耗时多的那次操做均摊到接下来的 n-1 次耗时少的操做上,均摊下来,这一组连续的操做的均摊时间复杂度就是 O(1)。
均摊时间复杂度和摊还分析的应用场景比较特殊,因此咱们不会常常用到。它们的应用场景通常以下:
对一个数据结构进行一组连续操做中,大部分状况下时间复杂度都很低,只有个别状况下时间复杂度比较高,并且这些操做之间存在先后连贯的时序关系,这个时候咱们就能够将这一组操做放在一起分析,看是否能将较高时间复杂度那次操做的耗时,均摊到其余那些时间复杂度比较低的操做上。并且,在可以应用均摊时间复杂度分析的场合, 通常状况下均摊时间复杂度就等于最好状况时间复杂度。
有以下代码例 9 ,试着分析一下 add() 函数的时间复杂度:
例 9: 1 int[]array = new int[10]; 2 int len = 10; 3 int i = 0; 4 5 void add(int element){ 6 if(i >= len){ 7 int[]new_array = new int[len * 2]; 8 for(int j = 0; j < len; j ++){ 9 new_array[j] = array[j]; 10 } 11 array = new_array; 12 len = len * 2; 13 } 14 array[i] = element; 15 i ++; 16 }
解答:
函数 add() 的做用,有两点:
- 代码 1四、15 行,将给定的数据 element 按顺序放入数组 array 中,这个过程当中的时间复杂度为 O(1)
- 代码 6-13 行,假如数组 array 已经存满,将数组 array 的容量扩充到原来容量的 2 倍,而后将原数组 array 元素赋值过去,再将给定的数据 element 按顺序放入数组 array 中,这个过程当中的时间复杂度为O(n),n 为数组 array 扩容前的长度。
由上述可知,最好时间复杂度为O(1),最坏时间复杂度为O(n)
通过观察,能够发现,1 、 2两步是有规律执行的。没有扩容时,先执行 len 次第 1 步,再执行 1 次第 2 步,此时完成一次扩容;而后执行 2*len-1 次第 1 步,再执行 1 次第 2 步,此时完成二次扩容;概括可得,执行的规律应该为 n 次 O(1),1 次 O(n),2*n-1 次 O(1),1 次 O(2n),4*n-1 次 O(1),1 次 O(4n)...
因此均摊复杂度应为O(1)