算法设计与分析基础系列--算法时间复杂度分析(一)--输入规模与运行时间的度量单位

 转载文章,原文章来源于算法设计与分析基础系列--算法时间复杂度分析(一)--输入规模与运行时间的度量单位(欢迎关注微信公众号,会定期更新内容)

============================================================

前言

本文内容基于书籍"算法设计与分析基础"(Introduction to The Design and Analysis of Algorithms,作者Anany Levitin),主要学习和讨论其中的算法时间复杂度分析方法。如果能够掌握一些算法的时间复杂度分析方法,那么在编程竞赛/算法题面试/实际工作中等等,都是很有帮助的,能够让你判断某些算法是否适合用来解决当前问题,以及如何改进优化算法的效率。

(时间复杂度分析背后是有严谨的数学基础的,我掌握地也比较有限,因此主要是分享一些我的个人思考和理解)

从"算法时间复杂度"这个术语的名字来看还是比较直观的,我们可以理解为它表征了某个算法的运行时长(比如几秒或者几小时甚至几天)。一般来说,一个算法(也可以直观的理解为某段代码,比如冒泡排序的代码)由输入数据,代码执行,结果输出这几部分组成。为了分析算法的时间复杂度,我们需要建立一套框架,主要包括,输入数据规模的度量方法,运行时间/代码执行次数的度量单位,最优/最坏情况的复杂度,复杂度的渐进分析等等。

我们这篇文章主要关注"输入数据规模的度量方法"和"运行时间/代码执行次数的度量单位"。

输入数据规模的度量方法

一个算法通常都会有相应的输入数据,比如质数判定算法,它的输入是某个正整数n,比如冒泡排序,它的输入就是某个长度为m的数组。一个很符合直觉的认知是,一般来说,不同的输入数据,算法的执行时间也会有所不同。比如冒泡排序算法,我们对一个长度为10的数组排序,和对一个长度为10000000的数组排序,它们的运行时间会有较明显差异(大家也可以实验一下)。前者应该会很快就能运行完成,而后者可能需要很久很久。因此,一个比较自然的想法就是,我们将算法的时间复杂度看做一个函数f(x),它的输入变量x就是这个算法的输入数据规模,而对应的f(x)的取值就是它的时间复杂度。

输入数据规模,也可以直观的理解为"输入数据的大小/多少"。关于如何准确的度量输入数据规模,我觉得暂时还不存在一个严格的方法或者流程,很难通过固定的步骤去推导出输入数据的规模到底是多少。我觉得输入数据规模的判定,并不是单独存在的,而是和后面马上要讨论的"运行时间/代码执行次数的度量单位"有关系。在大多数情况下,输入数据规模的判定是"相对比较清晰"的,比如冒泡排序,它的输入数据规模可以认为是数组的长度m,那么它的时间复杂度表达式就是f(m)。再比如,对于一棵含有n个节点的二叉树计算其中序遍历的顺序,它的输入数据规模可以认为是节点的数量n,那么它的时间复杂度就是f(n)。这两个例子表明了,有些时候,输入的数据"数量"就是其输入数据规模。

但是,有时候对输入数据规模的判定也要小心谨慎一些。比如,对于一个由英文字母组成的字符串,将其中的小写字母全部转换为大写字母。虽然输入只有"一个"字符串,但是如果认为它的输入数据规模是1的话并不合适,而更加合理的判定是认为它的输入数据规模是"字符串的长度"(长度为10和长度为1000000000的字符串,其大小写字母的转换耗时应当是有较明显差异的)。

运行时间/代码执行次数的度量单位

我们现在来讨论一下,如何判定某个算法(也可以认为是一段代码)的运行时间度量单位,也可以认为是代码执行次数的度量单位。我们先给出一段代码

int n;

scanf("%d", &n);

int x = 2; // A

for(int i = 0; i < n; i++){ // B

    int y = x * i; // C

    int z = x + i; // D

    if(y % 2 == z % 2){ // E

         printf("find\n"); // F

    }

}

上述代码中,n是待输入的数据,它指定了后面for循环的执行次数,除了读入n之外,我们还标记了ABCDEF几处在代码执行时可能会产生耗时的地方。我们接着用t(*)表示每个地方的实际运行时间,先不考虑数据读入部分,那么这段代码的总耗时就是t(A)+n*t(B)+n*t(C)+n*t(D)+n*t(E)+x*t(F),其中x是因为我们不确定F的实际执行次数,所以暂时用x表示,但可以确定的是x一定不会超过n。

从这个表达式可以看到,虽然这段代码不算太复杂,但其总耗时的计算却并不简单。可以想象,对于实际更复杂的代码,如果我们考虑其中所有代码段的执行时间的话,那会变得非常繁琐,难以分析。因此这里有一个"一般性的指导思想",就是我们应当关注其中最关键的操作(或者说基本操作),它们对总耗时的贡献是最大的,而对于其他操作,我们可以适当地忽略。

对于上述代码,for循环内的操作就是"关键操作"(也很符合直觉对吧,因为这些操作会重复执行很多次),而A处的操作只执行一次,所以我们忽略A的贡献。此时总耗时的表达式变为n*t(B)+n*t(C)+n*t(D)+n*t(E)+x*t(F)=n*(t(B)+t(C)+t(D)+t(E))+x*t(F)。接着我们看下x*t(F)这一项,这里不方便处理的地方主要是x并不确定。我们知道x的取值范围是0到n,那么总耗时一定介于n*(t(B)+t(C)+t(D)+t(E))和n*(t(B)+t(C)+t(D)+t(E)+t(F))之间,这里两个边界的差异就在于是否有t(F)这项。实际上,我们没有必要搞清楚x到底是多少(一方面这会让分析变得更复杂,另一方面它和输入数据有关,想要知道确定的值相当于要把代码执行一遍),这里还有一个"一般性的指导思想"就是,"做好最坏打算",即不妨认为x=n(复杂度再高,也不可能比这个高了)。因此,总的耗时是n*(t(B)+t(C)+t(D)+t(E)+t(F))。

到了这里,我们会发现总的耗时表达式还是比较复杂的,主要在于我们还需要确定BCDEF五处的实际执行时间。我们具体看下,B主要是循环判定条件i<n,以及i++,C是乘法操作,D是加法操作,E是判定条件操作(其中包含了模运算),而F是输出。虽然,这里涉及了多种操作类型,而且每种类型的耗时确实有所不同(比如一般来说,乘法要比加法复杂),但是从"关键程度"上来说,它们都是一样的,或者说它们都属于基本操作,我们不用过度关注每种操作的耗时(这样也会让分析变得更困难,而且也不现实,比如机器性能不同,同样的操作耗时也不同,无法比较),而是把它们看成"等价"的,或者说"单位1",即t(B)=t(C)=t(D)=t(E)=t(F)="1"。这里实际上,我们已经"更换"了概念,即我们关注的已经不是耗时了,而是这些操作的执行次数。通常来说,耗时和执行次数正相关,所以用执行次数来代替"耗时"也是非常符合直觉的。因此,最终的执行次数表达式就是5*n。

我们再回到之前提到的"输入数据规模度量"。对于这段代码,其关键操作的执行次数是5*n,那么,将输入数据的规模度量看做n也是非常恰当的(其实就是输入的n决定了循环次数,所以我们说输入数据规模和基本操作的判定是有关系的),即该代码的时间复杂度表达式是f(n)=5*n。

那么,到此,我们已经建立了一个基本的算法时间复杂度分析框架,大概总结如下。

一,我们应当关注的是"关键操作"(或者说基本操作),它们一般都是循环(for/while)内的操作

二,不同的基本操作可以认为是等价的,这些基本操作包括加减乘除,模运算,条件判定等

三,我们应当关注的是基本操作的"执行次数"而非"执行时间"

四,对于输入数据规模为n的算法,其时间复杂度可以表达为f(n),这里f(n)表示基本操作的执行次数,它是n的函数

五,输入数据规模一般就是输入数据的数量/多少/大小等,例如数组的长度,字符串的长度,数字大小等等,但是也要结合算法的基本操作来共同分析。

欢迎大家多多转载并关注后续更新,如果有其他算法相关的问题也欢迎留言讨论~

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值