时间复杂度分析是计算机科学中一个非常基础的内容,但同时也是一个非常重要的内容,计算机科学中的许多内容都围绕着时间复杂度的研究展开。而信息学竞赛由于其的竞赛性,更为注重时间复杂度。
本次的NOIP2015初赛中有一道题目,大意是这样的:
有一个算法的时间复杂度可用函数T(n)=T(n-1)+n定义,其中T(0)=1,则这个算法的渐近时间复杂度为?
这一道题是一道选择题,有四个答案,O(nlogn),O(logn),O(n)和O(n^2)。不少人选择了第三个答案O(n),也有人选择了O(nlogn)。但是真正的答案是最后一个,O(n^2)。显然把我们学校的不少人给坑到了。而正是因为对于时间复杂度分析的不熟悉,令得超时的“正确算法”程序出现。在本篇文章中,我将会谈到一些关于时间复杂度分析的内容和优化时间复杂度的同时拿高分的技巧。
【时间复杂度的分析】
时间复杂度,在某种意义上可以表示为“计算的次数”。在计算机科学中,我们对此的定义是:
对于一个函数F(n),当输入规模为n时,其的时间复杂度可以表示为另一个函数T(n)的相应值。
举个例子,比如最大连续和问题,给出一个序列A,求出最大连续的非空子串中元素的和。换句话说,就是找到这个序列A中的一段数,其的和是最大的。
这一道例题显然就是一个动态规划,但是我们先采用最简单的方法:枚举。枚举每一个子串的开头与结尾,并算出这个子串的和,最后求一个最大值就好了。代码如下:
注意其中BestAns不能为0,比如说该序列为-1,-2,-3,-4,-5,那非空的子串最小也就只能是-1了。如果BestAns = 0,那样的话就不满足非空的要求了。int count = 0; //时间复杂度统计
int BestAns = A[0];
for (int i=1;i<n;++i){ //开头
for (int j=i;j<n;++j){ //结尾
int sum = 0;
for (int k=i;k<=j;++k){
sum += A[k];
++count; //循环总次数
}
if (sum > BestAns){
BestAns = sum;
}
}
}
为什么要有count这个变量呢?为什么不能直接衡量运行的时间呢?既然叫时间复杂度,那为什么不能直接是秒之类的东西呢?因为count与机器的运行速度无关。这一段程序运行在ENIAC上(当然,不一定能运行),还是运行在天河二号上(也不一定能运行),时间肯定有着显著的区别,但是算法是同样的!也就是说,我们不能够拘泥于具体的机器,而是应该用另外一种方法,比如用数学的方式,抽象地表达一个算法的运行速度。
这一段代码的循环次数,可以分别表示为sum(i=1 to n),sum(j=i to n),sum(k=i to j),根据乘法原理,其运算的次数可以表示为sum(i=1 to n) * sum(j = i to n) * sum(k = i to j)。根据等差数列求和公式,可得:
T(n) = sum(i=1 to n) * sum(j=i to n) * (j-i-1)
T(n) = sum(i=1 to n)*(n-i+1)(n-i+2)/2
T(n) = n(n+1)(n+2)/6
T(n)=(n^3+3n^2+2n)/6
所以这个公式是一个关于n的三次多项式,这意味着当n趋向于无穷时,二次方项、线性项和常数项对总的值影响不大。因此我们可以
只取增长速度最高的一项,而且常数对于总值影响不大,所以我们也可以
忽略常数。
那么我们该怎样表示呢?我们利用一种叫大O表示法的方法,这称为算法复杂度的上界,差不多是最坏情况,也是许多信息学书中所最常用的方法。在这里,我们就知道这个算法的时间复杂度是O(n^3),用符号表示,就是O(n^3)=T(n)。这个的意思是O(n^3)与T(n)是
同阶的。换一种表达方式,就是T(n)和O(n^3)的增长速度大致相同。
但是我们刚才做了这样多的计算,难道每一次我们算的时候都要算上这么半天么?不是的,我们是可以估计的。估计的方法很简单:首先看每个循环的运行次数,然后乘起来,就大概是时间复杂度了。下面我们来推一下:首先,外面的循环执行次数大概为n次,中间的一层,在最坏情况下,也是n次左右。而最里面的一层在最坏情况下也是差不多n次,循环里面的常数就直接忽略。所以大概就是O(n^3)。
【时间复杂度与编程复杂度的优化】
时间复杂度是衡量一个算法的标准,但是时间复杂度有时也不够准确。比如有一个O(1000000n^2)的算法和另一个O(n^5)的算法,而n<100,那么后面的算法虽然没有第一个算法那么好,但是确实是更优的解法。
但是,时间复杂度也不一定要追求极致。比如说暴搜随随便便都能拿个70分,完美算法要动态规划+线段树套平衡树之类的超长程序,那这30分不拿也罢。毕竟比赛的时候我们可没有这么多的时间。这些时间,还不如直接将其他的程序写个对拍,找找漏洞之类的更有意义。
所以我们发现,常数和低次项,应该尽量优化一下,因为这样的优化相对简单。比如说,在输入大数据的时候,可以用scanf(),实在过大的时候,可以直接用getchar()之类的东西,但是似乎很少有。STL中的set,map,vector比起自己手写的要慢很多,因为STL的实现是防止溢出的,防止错误的,而且其的功能很多,为了考虑兼容性、统一性之类的问题,他用的是工程中的解法,而且真正竞赛中是不开-O2优化的,如果开了-O2,才能体现出STL的神速优势(绝对没有人能够当场手写超过STL!)。所以因为不开这个优化,除非没有时间写,或者说不会写,尽量不要用(具体多慢自己试一下就行了)。对于内存的申请,比如说链表之类的,尽量直接用数组,就是所谓的“静态指针”,用数组模拟一个内存,首先降低了编程的复杂度,其次也在常数上大大提高了效率,避免了堆分配内存的低效。搜索算法中频繁使用的小的布尔数组可以直接拿位运算做,就可以大大提高效率。
这样的优化还有很多,“经典”的《骗分导论——OI》中写到很多有关的内容。不过其实这样的优化也在联赛中应用不多,一般在省赛中才能够骗到一些比较坑的数据点,以能够进到省队之类。