如果接触过算法,那么对于算法的时间复杂度分析一定不陌生,因为时间复杂度是算法优劣的一个重要评价标准。
对于一个算法,我们不光关心它在某个规模输入的情况下耗时多少,我们更关心的是,当输入规模疯狂增长的时候,算法耗时增加多少,是按线性形式增加的?指数增加的?对数形式增加的?比如当你的算法接收的输入规模从k个数变到nk个数时,你的耗时是增加了n倍(线性速度增加)?还是增加了n2倍(平方的速度)?还是增加了log(n)倍(对数形式增加)。
时间复杂度是一个函数,它描述了算法的运行时间,并且给出了运行时间与输入规模之间的关系。
对于时间复杂度的分析,我们都是使用渐进分析,这种分析方法关注地核心就是算法耗时会如何随着输入规模增长?我们使用三种记号来说明这个问题。
我们使用字母n来表示输入的规模
符号O()表示了一种上界的感觉,也是我们常说的大O表示法,O()在这三种符号中用得最多,因为涉及到时间复杂度分析,我们经常会考虑最坏情况,因为在代码的实际运用中,首先最坏情况发生的可能性还不小,其次我们需要衡量最坏情况发生的结果我们是否能接受。
它的定义也用到了极限的思想,因为渐进分析,就包含有当n趋于无穷的意思。如果学过数学分析,了解ε-Δ语言,那么一定对这块儿的定义不会陌生。
g(n) ϵ O(f(n)): 存在常数c和n0 > 0,对所有的n > n0,我们有g(n) ≤ cf(n)。尽管定义使用的是属于符号,因为他们是函数集合的运算,但是实际运用时我们会把属于号记成等于,我们经常听说的,这个算法时间复杂度是O(n2)就来自于此。
这个符号内含的意义就是,当我规模变得很大的时候,规模再怎么增大,我的耗时一定能被一个函数(f(n))乘一个常数( c )控制住。
例子:2n2+27 = O(n2): 我们找到n0 = 10,c = 3,很容易验证对所有n > n0我们都有2n2+27 < 3n2
符号Ω()表示了一种下界的感觉,和O()定义差不多,只是不等号改变了。
g(n) ϵ Ω(f(n)): 存在常数c和n0 > 0,对所有的n > n0,我们有g(n) ≥ cf(n)
这个符号内含的意义就是,当我规模变得很大的时候,规模再怎么增大,我的耗时一定比这个下界函数(f(n))乘一个常数( c )控制住,不管怎么样,总存在一个规模使得程序耗时会超过这个下界的。
例子:2n2+27 = Ω(n2): 我们找到n0 = 1,c = 1,很容易验证对所有n > n0我们都有2n2+27 > n2
符号Θ()表示了一种紧的感觉,他包含了O()和Ω(),把一个函数上下界都固定了边界,在n趋于很大规模的时候,形成了一个控制带控住了这个函数。
g(n) ϵ Θ(f(n)): g(n) ϵ O(f(n))并且g(n) ϵ Ω(f(n))
这个符号内含的意义就是,当我规模变得很大的时候,规模再怎么增大,我的耗时一定比这个下界函数(f(n))乘一个常数( c )控制住,不管怎么样,总存在一个规模使得程序耗时会超过这个下界的。
例子:2n2+27 = Θ(n2): 因为我们之前使用的例子表示了n2既是2n2+27的上界也是下界,找这个上下界的时候n0和c可以找的不一样(上界对应的n0和c是10和3,下界对应的n0和c是1和1,两者不需要统一)
符号o()表示了一种强上界的感觉,和O()定义差不多,只是条件更加严格了,对于所有c>0,那个上界都是上界。
g(n) ϵ o(f(n)): 存在n0 > 0,任取c>0,对所有的n > n0,我们有g(n) ≤ cf(n)。
这个符号内含的意义就是,当达到一定规模后,我的耗时永远都超不过上界函数(f(n)),不管c怎么帮我,c取0.01,0.000000001,耗时最后还是会比这个上界函数少,这是由于我的增长速度本质上就是慢过这个上界函数。
例子:2n2+27 = o(n3): n2的增长速度本质上就是比n3慢。我们最后找出的n0会是c的函数。
符号o()表示了一种强下界的感觉,和Ω()定义差不多,只是条件更加严格了,对于所有c>0,那个下界都是下界。
g(n) ϵ w(f(n)): 存在n0 > 0,任取c>0,对所有的n > n0,我们有g(n) ≥ cf(n)。
这个符号内含的意义就是,当达到一定规模后,我的耗时总会超过这个下界函数(f(n)),不管c怎么帮它,c取100,100000000,耗时最后还是会比这个下界函数多,这是由于我的增长速度本质上就是快过这个下界函数。
例子:2n2+27 = w(n): n2的增长速度本质上就是比n快。我们最后找出的n0会是c的函数。
对于算法的耗时的公式,一般不会像上面那样的例子直接给你一个清楚的2n2+27这种清晰的公式。比如许多时候我们会遇到递归这种情况,就会出现算法耗时的递推公式。
例子1:选择排序
给n个没排好序的数,我们从这列数先选出最小的那个排最前面,然后再从剩下没排好序的数继续选最小的排,持续下去。这种递归每次问题规模减小1,所以我们有耗时的递推公式如下:T(n)=T(n-1)+cn,我的n个数排序耗时等于我选好个最小的时间加上剩下n-1个数进行排序,从n个数里选最小的数耗时cn因为我们要浏览这n个数。
例子2:归并排序
对于给n个没排好序的数排序,我们可以把它平均拆解成两份,每份是n/2个数,再对这n/2个数进行排序,排好后,我们对左右两边进行融合,这需要浏览这n个数才可以按顺序融合好。所以耗时递推公式如下:T(n) = T(n/2) + cn
对于这些有递推公式的时间复杂度的计算,有如下方法可以解决:
1.猜想并用数学归纳法证明
2.迭代,比如T(n)=T(n-1)+cn,把n替换成n-1,我们也知道T(n-1) = T(n-2)+c(n-1),所以我们可以把T(n)写开:
T(n)=T(n-1)+cn = T(n-2)+c(n-1)+ cn = … = T(1) + c*2+… + c(n-1)+cn
这样就可把T(n)一直迭代下去,到表达成我们已知的信息,比如T(1)
3.借助递归树,比如T(n) = 2*T(n/2) + cn,我们可以把这个递归表达成如下树结构:
T(n)是这棵树所有结点值的总和,他等于根结点的值加左右子树的所有结点值总和,而左右子树和原树的区别就是n变成了n/2。所以有T(n) = 2T(n/2)(这是左右子树结点值的总和)+cn(这是根结点的值)。
注意到这树的每一层加起来都是cn,所以T(n) = cn × 这个树的高度。这个树一直会分叉到1,所以高度是log2(cn),因为高度表示的就是cn需要分叉几次到1,就是对cn求对数。
所以T(n) = cn × log2(cn),T(n) = Θ(nlog2n)
更一般的:T(n) = aT(n/b) + cnk, T(1) = c,
a ≥ 1, b > 1, c > 0 and k ≥ 0
我们也可以画出如下递归树后算出T(n)的值
同样也是每行相加都呈现一个规律性的值,再对T(n)进行分析,最后有如下结论:
对于T(n) = aT(n/b) + cnk, T(1) = c,
a ≥ 1, b > 1, c > 0 and k ≥ 0
有:
1.当a < bk, T(n) = Θ(nk)
2.当a = bk, T(n) = Θ(nklogn)
3.当a > bk, T(n) = Θ(n^logba)
1.我们首先谈及了时间复杂度的定义,我们最关心的就是算法的耗时增长的速率,是呈什么函数形式增长的。
2.接下来我们通过符号,将时间复杂度的表示进行了量化,包括给了上界,下界,紧,强上界,强下界的这些类似概念,很好地区刻画了一个算法的时间随着规模增长的形式和规律。
3.我们对算法的时间复杂度计算方法进行了一个初步的介绍,并对递归的算法给出了一个综合性的公式。