复杂度分析:如何分析、统计算法的执行效率和资源消耗
大o复杂度表示法
算法的执行效率,粗略的讲,就是算法代码的执行时间,但是,如何在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?
以下代码:
func Cal(n int) int{
var sum int =0 // unit_time 1 行
for i:=0;i<n;i++{ // n * unit_time 2 行
sum += i // n * unit_time 3 行
}
return sum // unit_time 4 行
}
从CPU的角度,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的CPU执行个数,执行的时间都不一样,所以可以假设每行代码的执行时间都一样,为unit_time。在这个假设的基础上,这段代码的总执行时间是多少呢?
第1行与第4行执行要2个unit_time,第2行与第3行都运行了n遍,所以要2n * unit_time ,所以这段代码的执行时间为(2n + 2)unit_time ,可以看出,所有代码的执行时间T(n)与每行代码的执行次数成正比
按照这个思路,我们再看这段代码
func Cal(n int) int{
var sum int =0 // unit_time 1 行
for i:=0;i<n;i++{ // n * unit_time 2 行
for j :=0;j<n;j++{ // n * n * unit_time 3 行
sum += i * j // n * n * unit_time 4 行
}
}
return sum // unit_time 5 行
}
得到的结果是这段代码的总执行时间为:
T
(
n
)
=
(
2
n
2
+
n
+
2
)
T(n) = (2 n^2 + n + 2)
T(n)=(2n2+n+2)
尽管我们不知道unit_time的具体执行时间,但是通过这两端代码执行时间的推导过程,得出,所有代码的执行时间T(n)与每行代码的执行次数成正比.
我们可以得到一个公式。注意,大O就要登场了!
T
(
n
)
=
O
(
f
(
n
)
)
T(n) = O(f(n))
T(n)=O(f(n))
其中T(n)表示代码的执行时间,n表示数据规模;f(n)表示每行代码执行次数的总和。因为这是一个公式,所以用f(n)表示。公式中的O,表示代码的执行时间T(n)与f(n)表达式成正比。
所以,第一个例子中的T(n) = O(2n + 2),第二个例子中的T(n) = O(2 n2 + n + 2) 这就是大O时间复杂度表示法。大O时间复杂度实际上并不表示代码真正的执行时间,而表示代码执行时间随着数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度.
当n很大时,比如1000000000.而公式中的低阶、常亮、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大O表示刚才两段代码的时间复杂度,就可以记为:T(n) = O(n) ,T(n) = O(n2)
时间复杂度分析
前面介绍了大O时间复杂度的由来和表示方法。现在我们来看一下,如何分析一段代码的时间复杂度?我这有三个比较实用的方法分享给你。
-
只关注循环执行次数最多的一段代码
刚才说了,大O这种复杂度表示方法只表现一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数。只需要记录一个最大阶的量级就可以了。所以我们在分析一个算法。一段代码的时间复杂度的时候,也只关注执行次数最多的那一段代码就可以了。拿前面的例子
func Cal(n int) int{ var sum int =0 // unit_time 1 行 for i:=0;i<n;i++{ // n * unit_time 2 行 sum += i // n * unit_time 3 行 } return sum // unit_time 4 行 }
整段代码的1、 4 行都是常量级的执行次数,与n的大小无关,所以对复杂度没有影响。执行次数最多的2、 3行代码都被执行了n次,所以时间复杂度为O(2n),再忽略掉系数,这段代码总的时间复杂度就是O(n)的了。
-
加法法则:总的复杂度等于量级最大的那段代码的复杂度
试着分析这段代码,再看分析的思路是否与我一样
func Cal(n int) int{ var sum_1 int =0 var sum_2 int =0 var sum_3 int =0 for i:=0;i<100;i++{ // 1段 sum_1 += i } for j:=0;j<n;j++{ // 2段 sum_2 += j } for i:=0;i<n;i++{ // 3段 for j:=0;j<n;j++{ sum_3 += i * j } } return sum_1 + sum_2 + sum_3 }
这段代码分成了3个部分,分别求出了sum_1、 sum_2 、 sum_3。我们可以先分别求出每一段的时间复杂度,再取量级最大的作为代码的整体复杂度.
第一段代码的时间复杂度是多少呢?这段代码执行了100,次,与n无关,是一个常量的执行时间。
这里要强调一下,即便这段代码循环了1000000次,只要是一个已知数,与n无关,照样也是常量级别时间。当n无限大的时候,就可以忽略。尽管对代码的执行有很大影响,但是回到时间复杂度的概念来说,它表示一个算法执行效率与数据增长规模的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。
那第二段与第三段的时间复杂度很容易看出,是O(n)和O(n2)。
综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就是O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。抽象成公式
如 果 T 1 ( n ) = O ( f ( n ) ) , T 2 ( n ) = O ( g ( n ) ) , 那 么 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) 如果 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))) 如果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))) -
乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
刚才讲了一个复杂度分析中的加法法则,这还有一个乘法法则。类比一下:
如
果
T
1
(
n
)
=
O
(
f
(
n
)
)
,
T
2
(
n
)
=
O
(
g
(
n
)
)
,
那
么
T
(
n
)
=
T
1
(
n
)
×
T
2
(
n
)
=
O
(
f
(
n
)
×
O
(
g
(
n
)
)
=
O
(
f
(
n
)
×
g
(
n
)
)
如果 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))
如果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))
也就是说,假设 T1(n)=O(n),T2(n)=O(n^2),则T(n)= T1(n) × T2(n) = O(n3)
几种常见的时间复杂度案例分析
虽然代码千差万别,但是常见的复杂度量级并不多。稍微总结了一下,这些复杂量几乎涵盖了你今后可能接触的所有代码的复杂量级。
复杂量级 | 时间复杂度 |
---|---|
常量阶 | O(1) |
对数阶 | O(logn) |
线性阶 | O(n) |
线性对数阶 | O(nlogn) |
平方阶 | O(n2),O(n3),…O(nk) |
阶乘阶 | O(n!) |
指数阶 | O(2n) |
对于刚罗列的复杂度量级,我们可以粗略的分成两类,多项式量级和非多项式量级。其中非多项式量级只有两个:O(2n)和O(n!)。
当数据规模n越来越大时,多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以非多项式时间复杂度的算法是非常低效的算法。我们主要来看几种常见的多项式时间复杂度
首先必须明确一个概念,O(1)只是常量级时间的一种表示方法,并不是指只执行了一行代码,比如这段代码,即便有三行,它的时间复杂度也是O(1),而不是O(3)。
var sum_1 int =0
var sum_2 int =0
var sum_3 int =0
只要代码的执行时间不随n的增大而增长,这样代码的时间复杂度我们都记作O(1)。或者说,一般情况下,只要算法中不存在循环,递归语句,即使有成千上万行的代码,其时间复杂度也是O(1)。
对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度,通过例子说明一下:
func Cal(n int) int{
var sum int = 0
for i:=1;i<n;i=2*i{ // 最多
sum += 1 // 最多
}
return sum
}
根据前面的时间复杂度分析方法,判断出第2,3行的代码执行次数最多,假设执行次数为x,结果就是2x=n,求得
x=log2n。时间复杂度就是O(log2n)
现在,稍微改一下代码:
func Cal(n int) int{
var sum int = 0
for i:=1;i<n;i=3*i{ // 最多
sum += 1 // 最多
}
return sum
}
按照刚才的思路,求出复杂度为O(log3n)。
实际上,不管是以2为底、以3为底、还是以10,我们可以把所有对数阶的时间复杂度都记为O(logn)。为什么呢?
我们知道,对数之间是可以互相转换的log3n就等于log32 * log2n ,所以O(log3n) =O(C * log2n )其中C是一个常量,基于前面的理论,在大O时间复杂度计算时,系数可以忽略,,因此,在对数阶时间复杂度的表示方法里,我们忽略对数的"底",统一表示为O(logn)。
如果了理解了前面的O(logn),那么O(nlogn)就很容易理解了。如果一段代码的时间复杂度是O(logn),我们循环执行n遍,时间复杂度就是O(nlogn)。而且O(nlogn)也是一种非常常见的算法时间复杂度。比如,归并排序,快速排序的时间复杂度都是O(nlogn).
我们再看一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定。
func Cal(n,m int) int{
var sum_1 int = 0
var sum_2 int = 0
for i:=0;i<n;i++{
sum_1 += i
}
for i:=0;i<m;i++{
sum_2 += i
}
return sum_1 + sum_2
}
从代码的角度看,m、n表示两个数据规模。我们无法评估m,n谁的量级大,所以我们在表示复杂度时不能简单的使用加法法则,省去其中一个。所以上面代码的时间复杂度为O(m+n)
空间复杂度分析
前面,我们花了很长时间去讲大O表示时间复杂度分析,理解前面讲的内容,空间复杂度分析方法学起来就特别简单了。
空间复杂度全称就是渐进式空间复杂度,表示算法的存储空间与数据规模间的增长关系。
举例说明:
func Cal(n int) *[]int{
var n_list []int
for i:=0;i<n;i++{
n_list = append(n_list,i)
}
return &n_list
}
跟时间复杂度一样,这里我们使用了一个切片,它最后存储大小与n有关,所以代码的空间复杂度为O(n),我们常见的空间复杂度就是O(1),O(n),O(n2),像O(logn),O(nlogn)这样的对数阶复杂度平时都用不到。