时间复杂度与算法设计
算法的复杂性分析
- 算法的效率:时间效率和空间效率
效率很好理解,每条语句都不是废操作、所有的变量都在起作用那么算法的效率就高。
一个acmer,时间效率一定要尽可能100%,咱学算法本来就是为了优化时间,没有废操作是优化时间最基本的要求。举个简单的例子:素数筛的内循环是从I*i开始的,i*i之前的数已经在上一次外循环中处理过了,没有必要再处理一遍,这就是减少废操作。
空间效率是某些算法不可避免的问题,某些算法为了减少构建数据结构、增添新数据、查询数据等操作的时间(或减少代码量),就使用了占用大量内存的数组,数组进行这些操作只需要用下标很方便。典型的例子就是图论里的邻接矩阵和数据结构里的各种树。邻接矩阵是个二维数组,两个下标对应两个点,存的值代表两点之间的权值,而如果这个题目里的图不是复杂图,边不是很多的话,这个二维数组起码有三分之二没有被用到,白白浪费了内存。数组模拟的二叉树结构也是这样,下标为n的结点,左孩子下标为2n,右孩子是2n+1,不管有没有这个结点都要留出这块内存,所以也会有大片内存被浪费。
尽管有些算法的不可避免的浪费内存,可是我们还是要注意空间效率,因为爆内存也是个棘手的报错。举个例子:有些数组在多组输入的时候被反复使用,就不能把这些数组定义在循环里面,不然每一组数据都要开一个数组,测试数据一多就会爆内存,把定义放在外面或是全局,每一组数据输入之前清空一下数组即可。
- 空间复杂度:算法在执行中所占储存空间的大小。处理技巧见空间效率。
- 时间复杂度:将算法中基本操作的执行次数作为算法时间复杂度的度量。如果一个问题的规模是n,解这一问题的某一算法所需要的时间为T(n),它是n的某一函数,T(n)称为这一算法的时间复杂度。但是我们关注的并不是某一次程序运行时间T(n)的值(执行完一段程序的总时间),而是基本操作的总次数。换句话说,我们关注的是T(n)随n的变化趋势,即n趋于无穷大时,算法运行时间的变化趋势。
总结一个算法时间复杂度的具体步骤如下:
1)确定算法中基本操作以及问题的规模。(基本操作就是没有重复的操作,最简单的如printf,i++都属于基本操作)
2)根据基本操作执行情况计算出规模n的函数f(n),并确定时间的时间复杂度为T(n) = O( f(n)中增长最快项/此项系数 )
具体来说,就是先观察此算法中输入的数据规模,在竞赛中就是常数级和n级,一般就是n。然后在根据循环和迭代部分的代码求t(n),把t(n)中次数最高的项拿出来,丢掉系数就得到T(n)。准确求解t(n)的方法比较复杂,以程序设计竞赛为主的话也没必要去学,除非遇到很难的题要自创算法。简单求解见下面的代码举例。
“大O记法”:基本参数n——问题实例的规模;
把复杂性或运行时间表达为n的函数;
“O”表示量级,允许使用“=”代替“≈”,如O(2n2+n+1) = O(n2)。
时间复杂度主要关注点是循环的层数。一层是n,两层是n2,可忽略下一级的所有时间,如O(2n2+n+1) = O(n2),就是只考虑n2的时间,所以此项的系数也可忽略。
如果这个算法的代码执行次数会根据数据有序性而波动,就取最复杂的情况为时间复杂度。时间复杂度考虑的就是最坏的情况。在计算机数学里log2比log10常用的多,所以lg一般指log2。
常见的时间复杂度有O(1) O(lgn)O(n) O(nlgn) O(n2) O(nb) O(bn) ——从小到大排序,其中b是一个常数,O(1)的意思是不管数据量为多少,程序运行一次的时间始终不变,这是最好的时间复杂度。指数级的时间复杂度很难不超时,我们管这种时间复杂度的问题叫难解性问题。
举例:
void fun1() { int x = 0, n = 10000; for(int i = 0; i < n; i ++) //这里单层循环,n是个常数,所以t(n)与n无关,即时间复杂度T(n)恒为O(1) x = 0; //就一句,t(n) = n的值*1,是个常数。 } void fun2() { int x = 0, n; cin >> n; for(int i = 0; i < n; i ++) //n是输入的,所以数据规模是n x++; //假设a条简单操作,t(n) = an。所以T(n) = O(n) } void fun3() { int x = 0, n; cin >> n; for(int i = 0; i < n; i ++) //n是输入的,所以数据规模是n for(int j = 0; j < n; j++) x++; //两重循环,循环n*n次,t(n) = n^2, T(n) = O(n^2) } void fun4(int n) { int x = 0; cin >> n; for(int i = 0; i < n; i ++) //n是输入的,所以数据规模是n for(int j = i+1; j < n; j++) x++; //两重循环,内层循环改动了一下。可算出t(n) = n(n-1)/2, T(n) = O(n^2) } void fun5(int n) { int i = 0, s = 0; while(s < n) { // while和for一样看, n的数据规模,但是没执行n次, i++; // 算下i和s,可得T(n) = O(√n) s += i; } } //举个复杂的例子,节选于归并的代码 void mergesort(int i, int j) { int m; if(i != j) { m = (i+j)/2; mergesort(i, m); //一次递归 mergesort(m+1, j); //又来一次 merge(i, j, m); //已知此语句复杂度为O(n) } }
由于递归,所以计算变得复杂
f(n) = 2f(n/2)+n //这步肯定没问题,就是下层递归的基本操作函数f(n)*2,加上merge这一句的复杂度。
= 4f(n/4+2n //下层递归
= 2^k*f(n/2^k)+k*n //假设递归深度为k,得到进行k次递归后的f(n)
因为f(1) = 1,
当n = 2^k (k = log2n)时,f(n) = n+nlog2n,所以T(n) = O(nlgn)。
如果看不懂这个计算,就用讲课时画图的直观方法,也能得出nlgn。。。遇到新算法就背下来吧
既然是简单求解,由于T(n)由t(n)中增长最快的项决定,所以我们没必要算每一个循环和递归的复杂度再加一起,只需要求最复杂的那段代码的复杂度,就可以作为整个算法的时间复杂度了。
PS:一般来说,如果题目给出限时为1000ms,即1s,代表着最多能进行约1000万次运算,那么如果n是1e5级别的数,选用的算法时间复杂度是n2的话操作次数为1e10,就会超时,而选用的算法nlgn是1e6,符合时间要求。我们在做题中要熟记各算法的时间复杂度,从而放弃一定会超时的写法,能有效避免超时的错误。
参考资料: http://www.matrix67.com/blog/archives/105
https://blog.csdn.net/qq_21768483/article/details/80430590
算法导论第34章
浙公网安备 33010602011771号