本文是一篇算法时间复杂度的入门介绍。
当我们需要衡量一个算法的快慢时,一个自然的想法是测量这个算法运行所需要的时间。但是不同的机器运行的速度是不一样的,一个同样的算法在不同机器上测出来的时间可能非常不同,像60年代的计算机和如今的计算机就完全没有可比性了。而且,每次想要知道一个算法的快慢如果都要在机器上通过计时来测量的话,是一件非常痛苦的事情,因为有些算法可能一次要跑上一天,一个月,甚至一个世纪。
一个有效的替代方法是通过考虑一个算法用了多少次操作(或者说运算量)来衡量它运行的快慢(比如用了多少次加减法,乘除法,函数调用和赋值等操作),操作数越多,运行的所需要的时间就越多。这样的一种想法保证了我们对算法的衡量不会因为测试环境的变化而变化,也不用通过实际运行来测量,只需通过计算就能得到操作数的数量。
但是计算操作数仍然不能解决一个问题:通常一个算法是针对不同的规模所需要的操作数是不一样的,比如一个排序的算法,排100个数字和排10000个数字相比,排10000个数字所需要的运算量会大很多。
考虑到问题规模,我们不能笼统地把操作数看成一个具体的数,而应当把操作数看成问题规模的一个函数。假如问题规模是\(n\),那么操作数就是\(f(n)\)。有时候,问题规模不只有一个,比如关于一个矩阵的算法需要的操作数,可能和矩阵的长和宽都有关系,这时候,\(f\)就变成了一个关于长和宽的二元函数,比如\(f(w,h)\)。这种扩展是合理的,但是为了讨论方便,我们先只考虑规模只是一个变量\(n\)的情况。
\(f\)可能形式会比较复杂。比如
公式一:\(f(n)=4n^2+40n+93\)
而且,不同程序员对相同算法的实现可能完全不同,比如A喜欢写"x++;",而B喜欢写"x=x+1;",后者有两个操作符,前者只有一个,这样\(f(n)\)可能就变成了
公式二:\(f(n)=4n^2+40n+94\)
如果这个不同的语句出现在一个循环里,那么\(f(n)\)可能就变成了:
公式三:\(f(n)=2*(4n^2+40n+93)\)
那么【公式二】和【公式三】到底谁才能代表这个算法的真正时间复杂度呢?我们希望给出的答案是,它们是等价的,因为只有这样,一个算法的复杂度才不会因为具体的实现方法不同而改变。而事实上我们可以做到这一点:考虑两个函数在规模\(n\)变得非常大的时候,它们的变化趋势是相同的。在这个意义上,它们是等价的(属于同一个等价类),而且都等价于一个更简单的形式:
公式四:\(f(n)=n^2\)
理解这一点,是理解时间复杂度这个概念最重要的一点。其中理解它的一个重要想法是:
我们忽略了不同表达式之间常数级别的差异,而只希望保留占最大比重的项。
成大事者不拘小节,且不说一个操作实际占用的时间是以纳秒计算的,即便是一秒和两秒的差别,我们其实也不会太在意的——我们不关心常数倍数的差别,我们关心的是某种数量级意义上的差别。因为常数不会随问题规模\(n\)的变化而变化,当问题规模相当大的时候,常数只是个细枝末节的问题,甚至连\(n\)在\(n^2\)面前也只是个细枝末节的问题。试想想,当数据规模\(n\)达到十万的时候,\(4n^2\)是\(40n\)的一万倍,我们还有必要考虑\(40n\)吗?
同时,这种机智的取舍也解决了很多细节问题,比如,不同的操作可能会耗时不同,就好像通常加法操作要快些,乘法要慢些,除法可能更慢,而内存的读写操作可能比逻辑操作更慢些。在一些要求非常精细的情况下,我们可能不得不仔细分开不同的操作,但是,在通常情况下,如果我们忽略常数造成的差异,我们可以把这些不同的操作看成是一个操作单元,也就是说,虽然乘法比加法慢了2倍,但2只是个常数,我们把这种差异忽略掉。
我们选取了【公式四】来作为实际的度量标准。这个选出来的代表,其实是“操作数关于问题规模的函数”的一个渐近函数表示,渐近函数有很多,我们选择了其中最简洁的一个(也就是\(f(n)=n^2\))来代表算法的时间复杂度。
什么是渐近函数呢?两个不同的函数满足渐近意义上的等价关系,它们就互为渐近函数。也就是说,考虑函数\(f\)和\(g\)是不断递增的,当\(n\)不断增大时,如果\(f(n)\)和\(g(n)\)的比值趋向于稳定(稳定等于一个不等于0的常数),我们就认为\(f(n)\)和\(g(n)\)其实是渐近等价的:
当\(\lim_{n\rightarrow \infty} \frac{f(n)}{g(n)}=c\),\(c\)代表一个非零常数时,我们就认为\(f\)和\(g\)是渐近等价的。
对于上面的例子来说,我们把\(f(n)=4n^2+40n+93\)和\(f(n)=2*(4n^2+40n+93)\)都用\(n^2\)来表示。因为:
\(\lim_{n\rightarrow \infty} \frac{4n^2+40n+93}{n^2}=4\)
\(\lim_{n\rightarrow \infty} \frac{2*(4n^2+40n+93)}{n^2}=8\)
这样一来,两个程序员的编码风格所造成的差别就不存在了。
这种忽略常数,只保留主要项而得到的时间复杂度通过渐近分析严格地描述了出来,我将会在下一篇【渐近分析】中讨论一些渐近分析的符号用法,比如通常我们不会说某个算法的复杂度是\(n^2\),而是说\(O(n^2)\)或者\(\Theta(n^2)\),我们会讨论这些符号表示的具体含义。这种技巧也同样地可以运用到空间复杂度的表示上,在这种复杂度的表示方法下,程序员们可以愉快地攀比谁的算法更优,而不要考虑实际实现的差异和具体运行环境等细枝末节的东西。