前言
关于算法的笔记我调整了一下书写的方式,接下来的笔记我都会以总结开篇,通过自己对总结的发问倒推详情,最后以解答老师的思考题结束。
复杂度分析(上)
摘要:
一段代码所需执行时间和执行时需要的存储空间在算法中有统一的评价标准,被称为复杂度,分为(渐进)时间复杂度和(渐进)空间复杂度,都以大O表示法表示。
对于上述摘要,初看的人会对几个名词疑惑不解。时间复杂度和空间复杂度是什么,大O表示法如何表示复杂度,接下来对这几个疑问进行讲解。
时间复杂度
时间复杂度通俗说就是一段代码执行的时间消耗,但计算一段代码的执行时间最直观的方法执行代码后输出执行时间,对这种方法有个称呼,叫做事后统计法。这种方法直观也方便,但存在一定的问题,这样统计的代码执行时间与具体的外部环境关系较大,不同的硬件环境等都会对执行时间的统计结果影响颇大,而时间复杂度对代码的执行时间的统计是脱离外部环境影响的,更加客观。
如何分析代码时间复杂度
首先我们以一段代码为例:
int sum(int n) {
int i = 0;
int sum = 0;
for(; i < n; i++) {
sum += i;
}
return sum;
}
以上的代码都可以总结为 取数->运算->存数,虽然硬件环境会导致每行代码的执行时间有差异,但我们假设每行代码的执行时间都相同,并且为unit_time。现在来分析一下这段代码的总执行时间T,第2、3行代码执行1次,执行时间都为unit_time,第4、5行代码都循环执行了n遍,所以执行时间都为n*unit_time,所以这段代码的总执行时间
T = 2 * unit_time + 2n * unit_time
再以同样的方式分析一下下一段代码
int sum(int n) {
int i = 0;
int j = 0;
int sum = 0;
for(; i < n; i++) {
j = 0;
for(; j < n; j++) {
sum = sum + i * j;
}
}
return sum;
}
第2,3,4行代码执行时间都是unit_time,第5,6行代码执行都为n * unit_time,第7,8行代码循环了n * n遍,所以执行时间都为n2 * unit_time,这段代码的总执行时间为
T = 3 * unit_time + 2n * unit_time + 2n2 * unit_time
可以从时间复杂度分析代码的执行时间看出,总执行时间是随着n(数据规模)的增长而变化的,所以才会称为渐进时间复杂度。
大O表示法
时间复杂度需要用大O表示法表示,大O表示法的公式如下
T(n) = O(f(n))
T(n)是执行时间
f(n)是计算执行时间的公式
O表示两者之间成正比关系
可以将之前两段分析的时间复杂度表示为
T1(n) = O(2 + 2n)
T2(n) = O(3 + 2n + 2n2)
当数据规模也就是n足够大时,常量、系数和低阶项几乎不会影响总执行时间的变化,所以可以省略,上两个总时间表就可以表示为如下:
T1(n) = O(n)
T2(n) = O(n2)
这就是用大O表示法表示的时间复杂度。
时间复杂度分析技巧
了解了时间复杂度的分析,加上几个小技巧可以更快捷方便地分析出一段代码的时间复杂度,接下来我们介绍一下这几个时间复杂分析的技巧。
- 关注时间复杂度最大的一段代码,时间复杂度的加法法则
说是两个技巧,其实是同一个意思。因为大O表示法会省去系数、常量和低阶项,所以最大时间复杂度的一段代码就是整段代码的时间复杂度,时间复杂度的加法法则则是对这一阐述的数学表达,加法法则可表达为:
T1(n) = O(f(n))
T2(n) = O(g(n))
T(n) = T1(n) + T2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n)))
用下列代码举例:
int sum(int n) {
int i = 0;
int j = 0;
int sum = 0;
for(; i < 100; i++) {
sum += i;
}
for(; j < n; j++) {
sum += j;
}
return sum;
}
除了两个for循环外其他行代码执行次数都为1次,而两个for循环的时间复杂度分别为O(1)和O(n),根据加法法则,最终整段代码的时间复杂度为O(n)。注意,所以常量级的时间复杂度都表示为O(1),只要明确执行次数,不论10000,100000都是O(1)。
- 乘法法则:嵌套循环的时间复杂度为内外循环的时间复杂度乘积
先上数学表达式
T1(n) = O(f(n))
T2(n) = O(g(n))
T(n) = T1(n) * T2(n) = O(f(n)) * O(g(n)) = O(f(n) * g(n))
代码实例如下:
int sum(int n) {
int i = 0;
int j = 0;
int sum = 0;
for(; i < n; i++) {
for(j = 0; j < n; j++) {
sum = sum + i * j;
}
}
return sum;
}
这段代码有一段是嵌套循环,内外两个循环的时间复杂度都为O(n),所以整段嵌套循环的时间复杂度就为O(n) * O(n) = O(n2)。
常见的时间复杂度
时间复杂度中有一些是较为常见的,接下来看一下这几种常见的时间复杂度。
- O(1)
O(1)表示可确定执行次数的时间复杂度,如:
int sum(n) {
int i = 0;
int sum = 0;
for(; i < 100; i++) {
sum += i;
}
return sum;
}
第4、5行代码可确定的执行100次,所以时间复杂度为O(1),即使是10000次也是可确定的循环次数,也是O(1)。
- O(logn)、O(nlogn)
我们先尝试分析如下代码:
int sum(int n) {
int sum = 0;
for(int i = 0; i < n; i = i * 2;) {
sum += i;
}
return sum;
}
假设循环中执行了k次使i>=n,也即跳出循环,那k的最小情况可表示为如下式子
2k = n
所以 k = log2n
同理如果第3行代码的i = i * 2修改为 i = i * 3,那时间复杂度就为O(log3n),但对数间可以相互转化。
log3n = log32 * log2n
所以O(log3n) = O(log32 * log2n)
省略常量可以写为O(log2n)
所以这样的时间复杂度都写为O(logn)。那O(nlogn)便是上一段代码中的循环外再嵌套执行n次的循环。
- O(m+n),O(m*n)
当一段代码的执行时间会被多个数据规模的变化影响时,可以使用这样的大O表示法。例如:
int sum(int m, int n) {
int sum = 0;
for(int i = 0; i < m; i++) {
sum += i;
}
for(int i = 0; i < n; i++) {
sum += i;
}
return sum;
}
这段代码中的两段for循环时间复杂度分别为O(m)和O(n),要分析总的时间复杂度需要使用加法,此时加法法则不能套用了,因为影响两个for循环的是两个不同的数据规模,这段代码的总时间复杂度可以表示为 T = O(m) + O(n) = O(m + n)。
O(m * n)就是表示嵌套的两个for循环,分别执行m次和n次,如下代码:
int sum(int m, int n) {
int i = 0;
int j = 0;
int sum = 0;
for(; i < m; i++) {
for(j = 0; j < n; j++) {
sum = sum + m * n;
}
}
return sum;
}
常见的几种时间复杂度从低阶到高阶有O(1),O(logn),O(n),O(nlogn),O(n2)。
空间复杂度
空间复杂度与时间复杂度相似,空间复杂度描述的是数据规模的增长引起算法的存储空间的变化,分析如下代码:
int[] getIndex(int n) {
int i = 0;
int[] a = new int[n];
for(; i < n; i++) {
a[i] = i;
}
return a;
}
第2,3行代码都申请了存储空间,但第2行代码的存储空间大小与n无关,属于常量,第3行数组a新申请了大小为n的存储空间,其他代码没有占用更多空间,所以这段代码的空间复杂度为O(n)。
解答提问
王争老师在专栏最后提出了两个问题。
- 项目中会进行性能测试,再进行时间复杂度和空间复杂度的分析是否多此一举?
- 每段代码都进行时间复杂度和空间复杂度分析是否很浪费时间?
对于第1个问题,在时间复杂度介绍开始时已经讲过,性能测试也会被硬件等外部环境影响,而复杂度的分析可以相对客观从代码方面得到执行时间和存储空间占用的趋势情况。至于第2个问题,我还没有在实际中大量使用复杂度分析,没办法体会及提出自己的解答,当使用复杂度分析一段时间后我会再回来更新这个问题的回答。
文章中如有问题欢迎留言指正
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。