01| 复杂度分析(上):如何分析算法的执行效率和资源消耗?


大家好,我是爱好编程的斌斌。

众所周知,数据结构解决的是""的问题,即如何让代码更省内存;算法解决的是""的问题,即如何让代码运行得更快。因此,执行效率是算法一个非常重要的考量指标。那么,如何来评估我们编写的代码的时空复杂度呢?
如果你想知道,就请把这篇文章看完。通俗易懂,你会找到答案。

一、为什么需要复杂度分析?

在复杂度分析还没有出现之前,人们是怎么衡量算法的执行效率和资源消耗呢?不知道观客老爷好不好奇?反正笔者已经蠢蠢欲动了。

于是乎,大家给出了答案:把代码放机器上跑一边,通过监测和统计,就能的带算法真实的运行时间和内存消耗。没错,这种方法是值得肯定的,它也被称为"事后统计法"。这种方法很简单,简单就意味着条件苛刻,存在非常大的局限性。

1.执行结果非常依赖于测试环境

如果你有两台机器,并且它们的硬件,比如CPU,是不同的,你可以让他们运行同一段代码,然后统计它们的运行时间,你会发现硬件更新的机器运行更快。其次,还可能出现,在A机器上代码a比b运行得更快,但放到机器B上,b代码反而比a运行得更快。

2.测试结果受数据规模的影响极大

拿排序算法来举例,待排数据的有序度不同,排序所要使用的时间会有很大的差异。极端情况下,待排数据已经有序,就不需要做任何操作,排序算法的执行时间就非常短。另外,如果测试数据规模太小,就无法真实反映实际情况的代码性能。就比如,当数据规模小的时候,插入排序可能比快速排序还要快。

综上,我们需要一种既不依赖测试环境,也不依赖于测试数据,就可以粗略的衡量算法执行效率的方法。它就是这篇文章要描述的—时空复杂度。

二、大O复杂度表示法

在不运行代码的情况下,如何用肉眼看出代码的复杂度呢?跟着笔者来看看下面这段代码:

int cal(int n){
	int sum =0;
	int i = 0;
	for(;i <=n;++i){
	sum = sum+i;
	}
	return sum;
}

这段代码是求1-n的和,这里假设每行代码的执行时间都一样,为一个unit_time。第二和第三行代码都只执行了一次,第四五行代码都执行了n次,所以总的时间T(n) = (2 + 2*n) unit_time。你有没有发现:代码的执行时间T(n)与每行代码的执行次数n成正比。如果一个例子不足以说服你,那就随我再看一个例子:

int cal(int n){
	int sum =0;
	int i =1;
	int i=1;
	for(;int i <=n; ++i){
	j = 1;
	for(;j<=n;++j){
		sum = sum + i * j;
		}
	}
}

按照上面的方法分析一下,第2、3、4行代码只执行了一次,第5、6行代码执行了n次,第7、8行代码执行了n的平方次。所以,总的时间T(n) = 2 n*n + 2n + 3;同理,所有代码的执行时间T(n)与每行代码的执行次数n成正比。这个时候,我要引出一个大家伙大O复杂度表示法:T(n) = O(f(n))。其中,T(n)表示代码的执行时间,n表示数据的规模大小,f(n)表示每行代码的执行次数,O表示T(n)与f(n)成正比。
带入公式,可以得出上面代码的大O复杂度,第一段代码T(n)=O(2 n+2),第二段代码T(n)=2 nn+2n+3.这里需要说明一点:大O所描述的不是代码真正的运行时间,而是代码运行时间随数据规模增长的一种变化趋势,也被叫作渐进实际复杂度。它是一种变化趋势,不是具体的代码运行时间。
当数据规模n非常大的时候,比如10000或者100000,f(n)表达式中的常数、系数、低阶都不能左右变化趋势。所以,我们只需要拿最高量级来描述算法的时间复杂度就好,因为它描述的是一种变化趋势,代码运行时间与数据规模之间的,最终能影响这种变化趋势的只有最高阶。因此,前面的复杂度就可改为:

T(n) = O(n);T(n)=O(n^2)。

三、时间复杂度分析

在了解了大O复杂度表示法之后,你是不是还带着疑惑,这个到底是怎么分析出来的?好,那就请你带着疑惑看下去吧!

下面介绍三个时间复杂度的分析方法

1.只关注循环执行次数最多的一段代码

哲学老学者都说:凡是要抓主要矛盾,算法分析也一样。前面也有描述,在分析一段代码的复杂度的时候,可以忽略掉低阶、系数、常数,只要关注循环执行次数最多的一段代码就好了。

int cal(int n){
	int sum =0;
	int i = 0;
	for(;i <=n;++i){
	sum = sum+i;
	}
	return sum;
}

拿之前的第一个例子来说,第2、3行代码只执行了一次,是常量级时间复杂度,与数据规模n没有关系。循环执行次数最多的代码是第4、5行,所以说,代码的复杂度就看这两行,为O(n).

2.加法法则:总的复杂度=最大量级那段代码的复杂度

让我们结合下面这个例子来看看:

int cal(int n){
	//第一段:循环执行了100次
	int sum_1 = 0;
	int p = 1;
	for(;p <100;++p){
		sum_1 = sum_1 + p;
	}
	
	//第二段:循环执行了n次
	int sum_2=0;
	int q =1;
	for(;p <n;++p){
		sum_2 = sum_2 + p;
	}
	
	//第三段:循环执行了n*n次
	int sum_3=0;
	int i =1;
	int j = 1for(;i <=n;++i){
	j = 1;
	for(;p <100;++p){
		sum_3 = sum_3 + i*j;
		}
	}
}

你会发现,这段代码可以分成三个部分(见解析),它们的循环此时分别为100、n、n^2。我们需要做的就是,把它们放到一块进行比较,然后去最大的量级来表示代码的时间复杂度。那这段代码的时间复杂度就为O(n*n)。和前面的逻辑一样,当数据规模n非常大的时候,只考虑最大量级对时间的影响,其它都可以忽略不计。方便理解,也可以总结成公式:

如果T1(n)=O(f(n)),T2(n)=O(g(n)),那么,
T(n) = T1(n) + T2(n) = O(max(f(n) , g(n))).

3.乘法法则:嵌套代码的复杂度=内外代码复杂度的乘积

这里就拿第二个例子来说:

int cal(int n){
	int sum =0;
	int i =1;
	int i=1;
	for(;int i <=n; ++i){
		j = 1;
		for(;j<=n;++j){
		sum = sum + i * j;
		}
	}
}

这段代码中有一个循环嵌套,那它的时间复杂度是多少呢?类比乘法,就是内外两个循环的时间复杂度的乘积。总结成公式:
如果T1(n)=O(f(n)),T2(n)=O(g(n)),那么,T(n) =T1(n)*T2(n)= O(f(n) *g(n))

四、几种常见的复杂度实例分析

在这里插入图片描述

1.多项式时间复杂度

a>O(1)

这里需要明确一个点,无论你代码执行了多少次,只要它与数据规模n没有关系,那它的复杂度就是常量级。其次,所有常量级代码的复杂度都为O(1),这里的1只是一个规定的格式,并不是说你的代码只执行了一次。
一般情况下,只要代码中没有出现循环、递归语句,时间复杂度就为O(1).

b>O(logn)、O(nlogn)

通过一个例子来分析对数阶时间复杂度:

i = 1;
while(i<n){
i = i*2;
}

你会发现,i的所有取值构成了一个公比为2,首项为1的等比数列。找规律,你会发现循环执行得次数与2得幂数相等。假设循环结束时i=n,那循环执行的次数就好求了n = 2^x,两边同时取对数得,x=log2(n),(相信你能看懂这个格式),这个就是这段代码的时间复杂度。

一开始看到这个式子我也有疑惑,复杂度不是O(=log2(n)),为什么变成了O(logn)。你会发现底数是可以互换的,比如:log2(m)=log2(3)*log3(m),再加上前面的描述系数可以忽略,那不就是一个不,所以,所有的对数阶时间复杂度都可以表示为O(logn).

接着,来说说O(nlogn),结合前面的乘法法则,你会发现,只要把时间复杂度为O(logn)的那段代码循环执行n次,时间复杂度就变成了O(nlogn).

c>O(m+n)、O(m*n)

结合前面的加法法则,代码的复杂度等于最大量级那段代码的复杂度。那当你无法确定谁的量级大的时候怎末办了?就像下面这个例子:

int cal(int m,int n){
int sum_1 = 0;
int i =1;
for(;i<m;++i){
	sum_1=sum_1+i;
	}
int sum_2 = 0;
int j =1;
for(;j<n;++j){
	sum_2=sum_2+i;
	}
}

针对这种情况,原来的加法法则就不起效果了,需要把几种情况都记录在大O中。加法法则徐娅萍改成:T1(m) + T2(n) = O(f(m) +g(n)),但乘法法则依然起作用:T1(n)*T2(n)=O(f(m) ^f(n)).

2.非多项式时间复杂度

其中,非多项式量级只有两个:O(2^n)和O(n!)。
当数据规模n越来越大时,非多项式的执行时间会急剧增加,求解问题执行的时间会无限增长,成指数爆炸式增长。所以,非多项式时间复杂度式非常低效的算法,因此,一般都不会用到它。

五、空间复杂度分析

前面花了大片文章来描述时间复杂度,在掌握了时间复杂度时候,空间复杂度就很容易啦。前面说的时间复杂度,也可以叫渐进时间复杂度,表示算法的执行时间与数据规模之间的增长趋势。类比到空间复杂度,也可以称为渐进空间复杂度,表示算法的存储空间与数据规模之间的增长趋势。

结合下面这个例子,来看看看见复杂度:

void print(int n){
	int i =0;
	int[] a=new int[n];
	for(;i<n;++i){
	a[i] = i *i;
	}
	for(i = n-1;i>=0;--i){
	print out a[i]
	}
}

只有第三行代码的空间复杂度与n相关,为n,其它代码都是常量级空间复杂度,可以忽略不计,所以整段代码的空间复杂度为O(n).

六、内容小结:

复杂度又称为渐进复杂度,包括两种:时间复杂度和空间复杂度,表示算法的运行效率和数据规模之间的变化关系,越高阶的算法,执行效率就越低。常见的复杂度如图所示:
在这里插入图片描述

课后思考:

有人说,我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?而且,每段代码都分析一下时间复杂度、空间复杂度,是不是很浪费时间呢?你怎么看待这个问题呢?

我不认为是多此一举,渐进时间,空间复杂度分析为我们提供了一个很好的理论分析的方向,并且它是宿主平台无关的,能够让我们对我们的程序或算法有
一个大致的认识,让我们知道,比如在最坏的情况下程序的执行效率如何,同时也为我们交流提供了一个不错的桥梁,我们可以说,算法1的时间复杂度是O(n),算法2的时间复杂度是O(logN),这样我们立刻就对不同的算法有了一个“效率”上的感性认识。

当然,渐进式时间,空间复杂度分析只是一个理论模型,只能提供给粗略的估计分析,我们不能直接断定就觉得O(logN)的算法一定优于O(n), 针对不同的宿
主环境,不同的数据集,不同的数据量的大小,在实际应用上面可能真正的性能会不同,个人觉得,针对不同的实际情况,进而进行一定的性能基准测试是
很有必要的,比如在统一一批手机上(同样的硬件,系统等等)进行横向基准测试,进而选择适合特定应用场景下的最有算法。

综上所述,渐进式时间,空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,但是一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时
间复杂度程序,所以在实际编程中,时刻关心理论时间,空间度模型是有助于产出效率高的程序的,同时,因为渐进式时间,空间复杂度分析只是提供一个
粗略的分析模型,因此也不会浪费太多时间,重点在于在编程时,要具有这种复杂度分析的思维。

  • 26
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值