原文: 数据结构与算法(03):如何分析、统计算法的执行效率和资源
前言
我们都知道,数据结构和算法本身解决的是“快”和“省”的问题,即如何让我们的代码运行的更快,如何让代码更节省存储空间。所以,执行效率是算法一个非常重要的考量指标。
那如何来衡量我们编写的算法代码的执行效率呢?也就是今天我的学习笔记中提到的时间和空间复杂度分析。
为什么需要复杂度分析
一开始会有些疑惑,我把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我们实实在在跑一边得到的数据更精准吗?
首先,上述说的这种评估方法也没有问题,也是正确的。很多数据结构和算法的书还会给这种方法起一个名字,叫“事后统计法”。但是,这种统计方法有非常大的局限性。
1. 测试结果非常依赖测试环境
测试环境中硬件的不同会对测试结果又很大的影响。比如,我们拿同样的代码,分别用Intel Core i9处理器和Intel Core i3处理器来执行,不用说,肯定是i9要比i3执行的速度要快很多。还有,比如原本在这台机器上代码A比代码B的执行速度要快,但是当我们换到另外一台机器上时,可能会有截然不同的结果。
2. 测试结果手数据规模影响很大
在这之前我们都学习,或多或少接触多一些排序算法,比如冒泡排序、插入排序、快排等。对于同一个排序算法,待排序的数据的有序度不一样,排序执行时间会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实的反应算法的性能,比如小规模数据的排序,插入排序可能翻到比快速排序要快。
所以我们需要一个不用具体的测试数据来测试,也不依赖硬件资源的条件下,就可以粗略的估计算法的执行效率的方法。也就是接下来笔记会重点记录的 —— 时间、空间复杂度分析方法。
大O复杂度表示法
算法的执行效率,粗略的讲,就是算法代码执行的时间。我们要学习的是在不运行的情况下用“肉眼”分析得到一段代码的执行时间。
有一段非常简单的代码,求1,2,3……n的累加和。现在,我们一块来估算下这段代码的执行时间:
private int cal(int n){
int sum = 0;
int i = 1;
for (; i <= n; ++i){
sum += i;
}
return sum;
}
从CPU的角度来看,这段代码的每一行都执行着类似的操作:读数据 - 运算 - 写数据。尽管每行代码对应的CPU执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行时间都一样,为unit_time(代为时间)。在这个假设的基础之上,这段代码的总执行时间是多少呢?
第2、3行代码(这个MakeDown好像不支持展示代码行,凑合着看吧)分别需要1个unit_time,第4、5行代码分别都运行了n遍,所以需要2nunit_time的执行时间,所以这段代码的总执行时间为 (2n+2)*unit_time.可以看出,所有代码的执行时间T(n)与每行代码的执行次数成正比。
按照这个分析思路,再来看下面这段代码:
private int cal2(int n){
int sum = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum += i*j;
}
}
return sum;
}
依然假设每个代码的执行时间是unit_time.那这段代码的时间时间T(n) = (3 + 2n + 2nn)* unit_time = (2nn + 2n + 3)unit_time. 尽管我们不知道unit_time的具体值是多少,但是通过这两段代码的推导执行时间的过程,我们可以得到一个非常重要的规律:
“所有代码的执行时间T(n)与每行代码的执行此处n成正比”
我们可以把这个规律总结成一个公式。注意,大O要出现了:
解释一下:其中,T(n)表示代码的执行时间;n表示数据规模的大小;f(n)表示每行代码执行的次数总和。因为这是一个公式,所以用f(n)来表示。公式中的O,表示代码执行时间T(n)与f(n)表达式成正比。
所以,第一个例子的T(n) = O(2n+2),第二个例子的T(n)=O(2n*n + 2n +3).这就是“大O时间复杂度表示法”。大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模中能建的变化趋势。所以,也叫做“渐进时间复杂度”,简称“时间复杂度”。
当n很大时,我们可以把它想象成10000、10000000等。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,如果用大O表示法表示上述两端代码的时间复杂度,分别为:T(n)=O (n) 与 T(n)= O(n*n)。
时间复杂度
上面介绍了大O时间复杂度的由来和表示方法。现在我们在来看下如何分析一段代码的时间复杂度,总结了三个方法:
1.只关注循环执行次数最多的代码
上面说到大O复杂度只是表示执行时间随着数据量级N的变化趋势。我们常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶就可以了。所以,我们分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那段代码就可以了。
还是来看例子:
private int cal(int n){
int sum = 0;
int i = 1;
for (; i <= n; ++i){
sum += i;
}
return sum;
}
还是我们上面的那个事例1的代码,第2、3行都是常量级的执行时间,与数据规模n无关,所以对于复杂度并没有影响。循环最多的是第4、5行代码,所以重点分析这部分。因为都执行了n次,所以最大的次数也就是n,则T(n)= O(n)
2.加法法则:总复杂度等于量级最大的那段代码的复杂度
直接上代码:
private int cal3(int n){
int sum1 = 0;
int i = 1;
for (; i < 100; ++i){
sum1 += i;
}
int sum2 = 0;
int j = 1;
for (; j < n; ++j){
sum2 += j;
}
int sum3 = 0;
int f = 1;
int g = 1;
for (; f < n; ++f){
g = 1;
for (; g < n; ++g){
sum3 += f*g;
}
}
return sum1 + sum2 + sum3;
}
这段代码可以分为三部分,分别求sum1、sum2、sum3。我们可以分别求每部分的时间复杂度。然后把它们放到一块,再去一个量级最大的作为整段代码的复杂度。
第一段:没有依赖n,所以是一个常量执行时间。(需要注意,几遍这段代码循环了1000次,还是1000000次,与n无关,它的时间复杂度则都是一个长量级的,因为他的时间增长趋势跟n无关)
第二段:T2(n)=O(n)
第三段:T3(n)= O(n*n)
所以最终的时间复杂度推导如下:
T(n) = T2(n)+ T3(n)= max(O(f2(n),O(f3(n))))= O(max(f2(n), f3(n))) = O(n*n)
3.乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
这里跟加法法则类似,还是以上面的加法法则事例来看,公式大致为 T(n)= T2(n)* T3(n)= O(f2(n)O(f3(n)))= O(f2(n) f3(n)) = O(nnn)
几种常见的时间复杂度分析
虽然代码千差万别,但是常见的复杂度量级并不多。我稍微总结了一下,下面这些复杂度量级几乎涵盖了今后我们可以接触到的所有代码的复杂度量级:
对于上面罗列的复杂度量级可以在粗略的分为两类:多项式量级和非多项式量级。其中非多项式量级只有两个:O(a^n) 与 O(n!)即指数阶与阶乘阶。
我们把时间复杂度为非多项式量级的算法问题成为 “NP”问题(非确定多项式问题)
多数据规模n越来越大的时候,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。还是重点来看下几种常见的多项式量级时间复杂度:
1. O(1)
首先我们需要明确一个概念,O(1)只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如下面这段代码的时间复杂度便是O(1)
int i = 1;
int j = 2;
int usum = i+j;
其实我们会发现只要代码与n无关,我们便可以把这段代码的T(n)记为 O(1)
O(log n)、O(n log n)、
对数阶时间复杂度非常常见,同时也是最难分析的一种。通过一个类似来说明一下:
int i = 1;
while (i <= n){
i *= 2;
}
是很常见吧,上面最3行代码是执行次数最多的,所以我们只要能计算出这行代码被执行了多少次就能得到整段代码的时间复杂度。
从代码中可以看出变量i的指从1开始,没循环一次就乘以2.当大于n时,循环结束。还记得高中学过的等比数列吗,实际上,变量i的取值就是一个等比数列:
所以我们要知道x的值是多少,就知道执行次数是多少了。通过2的x次方等于n,求解x的大小,高中数学里面学过的,x = log2(n)(不好编辑公式,凑合着看下,以2为低log n的值便是x对应的次数),所以这段代码的时间复杂度便是 T(n)= O(log2 n)
如果代码稍微改动一下:
int i = 1;
while (i <= n){
i *= 3;
}
循环里面变成乘以3了,则间复杂度便是 T(n)= O(log3 n),同样的道理,变成10,100我们都知道结果了,对于这种我们统计记作 T(n)= O(log n)。
在弄清楚了O(log n)之后,那O(nlog n)也就比较简单了,上面有提到一个乘法法则,即这段代码我们循环n次,则时间复杂度便为T(n)= O(nlog n)。
O(m+n)、O(m*n)
再来看一个与上面都不一样的事例:
private void cal(int m, int n){
int sum1 = 0;
int i = 1;
for(; i < m; ++i){
sum1 += i;
}
int sum2 = 0;
int j = 1;
for(; j < m; ++j){
sum2 +=j;
}
}
可以看出这次我们引入了两个数据规模m、n。因为这时候我们无法评估m、n的量,所以就不能简单的使用加法法则了,这时候的T(n)= O(m+n)
注意,这种情况下乘法法则依然有效,T(n)= O(m)O(n)= O(mn)
空间复杂度
前面花了我追两三集电视剧的时间,详细记录了时间复杂度的评估方法及常见的几种量级的复杂度,主要也是为了空间复杂度做准备,因为差不多。
类比时间复杂度的定义,我们可以推理出空间复杂度的定义便是:算法的存储空间与数据规模之间的增长关系。
还是拿一个具体的例子来看:
int i = 0;
int[] arr = new int[n];
for (; i < n ; ++i){
arr[i] = i * i;
}
for (i = n-1; i >= 0; --i){
System.out.println(arr[i]);
}
与分析时间复杂度一样,在第1行代码中,我们申请了一个空间存储变量i, 但是它是常量阶的(跟n无关),所以可以忽略。第2行代码申请了一个大小为n的int类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是O(n)
常见的空间复杂度有:O(1)、O(n)、O(n*n),想O(log n)、O(n logn)这样的对数阶复杂度我们平时几乎都用不到。而且空间复杂度要比时间复杂度分析简单很多,所以对于空间复杂度掌握这部门内容也就差不大多了。
更多个人博客: 个人网站RelaxHeart网 - Tec博客