算法分析的正确姿势


一、前言


在进一步学习数据结构与算法前,我们应该先掌握算法分析的一般方法。算法分析主要包括对算法的时空复杂度进行分析,但有些时候我们更关心算法的实际运行性能如何,此外,算法可视化是一项帮助我们理解算法实际执行过程的实用技能,在分析一些比较抽象的算法时,这项技能尤为实用。


在本篇博文中,我们首先会介绍如何通过设计实验来量化算法的实际运行性能,然后会介绍算法的时间复杂度的分析方法,我们还会介绍能够非常便捷的预测算法性能的倍率实验。当然,在文章的末尾,我们会一起来做几道一线互联网的相关面试/笔试题来巩固所学,达到学以致用。


二、算法分析的一般方法


1、量化算法的实际运行性能


在介绍算法的时空复杂度分析方法前,我们先来介绍以下如何来量化算法的实际运行性能,这里我们选取的衡量算法性能的量化指标是它的实际运行时间。通常这个运行时间与算法要解决的问题规模相关,比如排序100万个数的时间通常要比排序10万个数的时间要长。所以我们在观察算法的运行时间时,还要同时考虑它所解决问题的规模,观察随着问题规模的增长,算法的实际运行时间时怎样增长的。这里我们采用算法(第4版) (豆瓣)一书中的例子,代码如下:

public class ThreeSum {   public static int count(int[] a) {   int N = a.length;   int cnt = 0;   for (int i = 0; i < N; i++) {   for (int j = i + 1; j < N; j++) {   for (int k = j + 1; k < N; k++) {   if (a[i] + a[j] + a[k] == 0) {   cnt++;   }   }   }   }   return cnt;   }   public static void main(String[] args) {   int[] a = StdIn.readAllInts();   StdOut.println(count(a));   }   
}

以上代码用到的StdIn和StdOut这两个类都在这里:


https://github.com/absfree/Algo。我们可以看到,以上代码的功能是统计标准一个int[]数组中的所有和为0的三整数元组的数量。采用的算法十分直接,就是从头开始遍历数组,每次取三个数,若和为0,则计数加一,最后返回的计数值即为和为0的三元组的数量。这里我们采取含有整数数量分别为1000、2000、4000的3个文件(这些文件可以在上面的项目地址中找到),来对以上算法进行测试,观察它的运行时间随着问题规模的增长是怎样变化的。


测量一个过程的运行时间的一个直接的方法就是,在这个过程运行前后各获取一次当前时间,两者的差值即为这个过程的运行时间。当我们的过程本身需要的执行时间很短时间,这个测量方法可能会存在一些误差,但是我们可以通过执行多次这个过程再取平均数来减小以至可以忽略这个误差。下面我们来实际测量一下以上算法的运行时间,相关代码如下:

public static void main(String[] args) {   int[] a = In.readInts(args[0]);   long startTime = System.currentTimeMillis();   int count = count(a);   long endTime = System.currentTimeMillis();   double time = (endTime - startTime) / 1000.0;   StdOut.println("The result is: " + count + ", and takes " + time + " seconds.");   }

 我们分别以1000、2000、4000个整数作为输入,得到的运行结果如下

The result is: 70, and takes 1.017 seconds. //1000个整数   
The result is: 528, and takes 7.894 seconds. //2000个整数  
The result is: 4039, and takes 64.348 seconds. //4000个整数

我们从以上结果大概可你看到,当问题的规模变为原来的2倍时,实际运行时间大约变为原来的8倍。根据这个现象我们可以做出一个猜想:程序的运行时间关于问题规模N的函数关系式为T(N) = k*(n^3).


在这个关系式中,当n变为原来的2倍时,T(N)会变为原来的8倍。那么ThreeSum算法的运行时间与问题规模是否满足以上的函数关系呢?在介绍算法时间复杂度的相关内容后,我们会回过头来再看这个问题。


2、算法的时间复杂度分析


(1)基本概念


关于算法的时间复杂度,这里我们先简单介绍下相关的三种符号记法:


  • 第一种叫Big O notation,它给出了运行时间的”渐进上界“,也就是算法在最坏情况下运行时间的上限。它的定义如下:对于f(n)和g(n),若存在常数c和N0,使得对于所有n > N0,都有 |f(n)| < c * g(n),则称f(n)为O(g(n)。

  • 第三种叫做Big Ω notation,它给出了运行时间的“渐进下界”,也就是算法在最坏情况下运行时间的下限。它的定义如下:对于f(n)和g(n),若存在常数c和N0,使得对于所有n > N0,都有|f(n)| > c * g(n),则称f(n)为Ω(g(n))。

  • 第三种叫Big Θ notation,它确定了运行时间的”渐进确界“。定义如下:对于f(n)和g(n),若存在常数c和N0,对于所有n> N0,都有|f(n)| = c * g(n),则称f(n)为Θ为Θ(g(n))。


我们在平常的算法分析中最常用到的是Big O notation。下面我们将介绍分析算法的时间复杂度的具体方法,若对Big O notation的概念还不是很了解,推荐大家看这篇文章:http://blog.jobbole.com/55184/。


(2)时间复杂度的分析方法


这部分我们将以上面的ThreeSum程序为例,来介绍一下算法时间复杂度的分析方法。为了方便阅读,这里再贴一下上面的程序:

public static int count(int[] a) {   int N = a.length;   int cnt = 0;   for (int i = 0; i < N; i++) {   for (int j = i + 1; j < N; j++) {   for (int k = j + 1; k < N; k++) {   if (a[i] + a[j] + a[k] == 0) {   cnt++;   }   }   }   }   return cnt;   }

在介绍时间复杂度分析方法前,我们首先来明确下算法的运行时间究竟取决于什么。直观地想,一个算法的运行时间也就是执行所有程序语句的耗时总和。然而在实际的分析中,我们并不需要考虑所有程序语句的运行时间,我们应该做的是集中注意力于最耗时的部分,也就是执行频率最高而且最耗时的操作。也就是说,在对一个程序的时间复杂度进行分析前,我们要先确定这个程序中哪些语句的执行占用的它的大部分执行时间,而那些尽管耗时大但只执行常数次(和问题规模无关)的操作我们可以忽略。我们选出一个最耗时的操作,通过计算这些操作的执行次数来估计算法的时间复杂度,下面我们来具体介绍这一过程。


首先我们看到以上代码的第1行和第2行的语句只会执行一次,因此我们可以忽略它们。然后我们看到第4行到第12行是一个三层循环,最内存的循环体包含了一个if语句。也就是说,这个if语句是以上代码中耗时最多的语句,我们接下来只需要计算if语句的执行次数即可估计出这个算法的时间复杂度。以上算法中,我们的问题规模为N(输入数组包含的元素数目),我们也可以看到,if语句的执行次数与N是相关的。我们不难得出,if语句会执行N * (N - 1) * (N - 2) / 6次,因此这个算法的时间复杂度为O(n^3)。这也印证了我们之前猜想的运行时间与问题规模的函数关系(T(n) = k * n ^ 3)。由此我们也可以知道,算法的时间复杂度刻画的是随着问题规模的增长,算法的运行时间的增长速度是怎样的。在平常的使用中,Big O notation通常都不是严格表示最坏情况下算法的运行时间上限,而是用来表示通常情况下算法的渐进性能的上限,在使用Big O notation描述算法最坏情况下运行时间的上限时,我们通常加上限定词“最坏情况“。


通过以上分析,我们知道分析算法的时间复杂度只需要两步(比把大象放进冰箱还少一步:) ):


  • 寻找执行次数多的语句作为决定运行时间的[关键操作];

  • 分析关键操作的执行次数。


在以上的例子中我们可以看到,不论我们输入的整型数组是怎样的,if语句的执行次数是不变的,也就是说上面算法的运行时间与输入无关。而有些算法的实际运行时间高度依赖于我们给定的输入,关于这一问题下面我们进行介绍。


3、算法的期望运行时间


算法的期望运行时间我们可以理解为,在通常情况下,算法的运行时间是多少。在很多时候,我们更关心算法的期望运行时间而不是算法在最坏情况下运行时间的上限,因为最坏情况和最好情况发生的概率是比较低的,我们更常遇到的是一般情况。比如说尽管快速排序算法与归并排序算法的时间复杂度都为O(nlogn),但是在相同的问题规模下,快速排序往往要比归并排序快,因此快速排序算法的期望运行时间要比归并排序的期望时间小。然而在最坏情况下,快速排序的时间复杂度会变为O(n^2),快速排序算法就是一个运行时间依赖于输入的算法,对于这个问题,我们可以通过打乱输入的待排序数组的顺序来避免发生最坏情况。


4、倍率实验


下面我们来介绍一下算法(第4版) (豆瓣)一书中的“倍率实验”。这个方法能够简单有效地预测程序的性能并判断他们的运行时间大致的增长数量级。在正式介绍倍率实验前,我们先来简单介绍下“增长数量级“这一概念(同样引用自《算法》一书):


我们用~f(N)表示所有随着N的增大除以f(N)的结果趋于1的函数。用g(N)~f(N)表示g(N) / f(N)随着N的增大趋近于1。通常我们用到的近似方式都是g(N) ~ a * f(N)。我们将f(N)称为g(N)的增长数量级。


我们还是拿ThreeSum程序来举例,假设g(N)表示在输入数组尺寸为N时执行if语句的次数。根据以上的定义,我们就可以得到g(N) ~ N ^ 3(当N趋向于正无穷时,g(N) / N^3 趋近于1)。所以g(N)的增长数量级为N^3,即ThreeSum算法的运行时间的增长数量级为N^3。


现在,我们来正式介绍倍率实验(以下内容主要引用自上面提到的《算法》一书,同时结合了一些个人理解)。首先我们来一个热身的小程序:

public class DoublingTest {   public static double timeTrial(int N) {   int MAX = 1000000;   int[] a = new int[N];   for (int i = 0; i < N; i++) {   a[i] = StdRandom.uniform(-MAX, MAX);   }   long startTime = System.currentTimeMillis();   int count = ThreeSum.count(a);   long endTime = System.currentTimeMillis();   double time =  (endTime - startTime) / 1000.0;   return time;   }   public static void main(String[] args) {   for (int N = 250; true; N += N) {   double time = timeTrial(N);   StdOut.printf("%7d %5.1f\n", N, time);   }   }   
}

以上代码会以250为起点,每次讲ThreeSum的问题规模翻一倍,并在每次运行ThreeSum后输出本次问题规模和对应的运行时间。运行以上程序得到的输出如下所示:

250 0.0      
500 0.1      
1000 0.6      
2000 4.3      
4000 30.6

上面的输出之所以和理论值有所出入是因为实际运行环境是复杂多变的,因而会产生许多偏差,尽可能减小这种偏差的方式就是多次运行以上程序并取平均值。有了上面这个热身的小程序做铺垫,接下来我们就可以正式介绍这个“可以简单有效地预测任意程序执行性能并判断其运行时间的大致增长数量级”的方法了,实际上它的工作基于以上的DoublingTest程序,大致过程如下:


  • 开发一个[输入生成器]来产生实际情况下的各种可能的输入。

  • 反复运行下面的DoublingRatio程序,直至time/prev的值趋近于极限2^b,则该算法的增长数量级约为N^b(b为常数)。


DoublingRatio程序如下:


运行倍率程序,我们可以得到如下输出:

0.0 2.0   
0.1 5.5  
0.5 5.4  
3.7 7.0  
27.4 7.4  
218.0 8.0

我们可以看到,time/prev确实收敛到了8(2^3)。那么,为什么通过使输入不断翻倍而反复运行程序,运行时间的比例会趋于一个常数呢?答案是下面的[倍率定理]:


若T(N) ~ a * N^b * lgN,那么T(2N) / T(N) ~2^b。


以上定理的证明很简单,只需要计算T(2N) / T(N)在N趋向于正无穷时的极限即可。其中,“a * N^b * lgN”基本上涵盖了常见算法的增长量级(a、b为常数)。值得我们注意的是,当一个算法的增长量级为NlogN时,对它进行倍率测试,我们会得到它的运行时间的增长数量级约为N。实际上,这并不矛盾,因为我们并不能根据倍率实验的结果推测出算法符合某个特定的数学模型,我们只能够大致预测相应算法的性能(当N在16000到32000之间时,14N与NlgN十分接近)。


5、均摊分析


考虑下我们之前在 深入理解数据结构之链表 中提到的ResizingArrayStack,也就是底层用数组实现的支持动态调整大小的栈。每次添加一个元素到栈中后,我们都会判断当前元素是否填满的数组,若是填满了,则创建一个尺寸为原来两倍的新数组,并把所有元素从原数组复制到新数组中。我们知道,在数组未填满的情况下,push操作的复杂度为O(1),而当一个push操作使得数组被填满,创建新数组及复制这一工作会使得push操作的复杂度骤然上升到O(n)。


对于上面那种情况,我们显然不能说push的复杂度是O(n),我们通常认为push的“平均复杂度”为O(1),因为毕竟每n个push操作才会触发一次“复制元素到新数组”,因而这n个push把这一代价一均摊,对于这一系列push中的每个来说,它们的均摊代价就是O(1)。这种记录所有操作的总成本并除以操作总数来讲成本均摊的方法叫做均摊分析(也叫摊还分析)。


三、小试牛刀之实战名企面试题


前面我们介绍了算法分析的一些姿势,那么现在我们就来学以致用,一起来解决几道一线互联网企业有关于算法分析的面试/笔试题。


【腾讯】下面算法的时间复杂度是____

int foo(int n) {

    if (n <= 1) {

        return 1;

    }

    return n * foo(n - 1);

}

看到这道题要我们分析算法时间复杂度后,我们要做的第一步便是确定关键操作,这里的关键操作显然是if语句,那么我们只需要判断if语句执行的次数即可。首先我们看到这是一个递归过程:foo会不断的调用自身,直到foo的实参小于等于1,foo就会返回1,之后便不会再执行if语句了。由此我们可以知道,if语句调用的次数为n次,所以时间复杂度为O(n)。

 

【京东】以下函数的时间复杂度为____

void recursive(int n, int m, int o) {

    if (n <= 0) {

        printf("%d, %d\n", m, o);

    } else {

        recursive(n - 1, m + 1, o);

        recursive(n - 1, m, o + 1);

    }

}


这道题明显要比上道题难一些,那么让我们来按部就班的解决它。首先,它的关键操作时if语句,因此我们只需判断出if语句的执行次数即可。以上函数会在n > 0的时候不断递归调用自身,我们要做的是判断在到达递归的base case(即n <= 0)前,共执行了多少次if语句。我们假设if语句的执行次数为T(n, m, o),那么我们可以进一步得到:T(n, m, o) = T(n-1, m+1, o) + T(n-1, m, o+1) (当n > 0时)。我们可以看到base case与参数m, o无关,因此我们可以把以上表达式进一步简化为T(n) = 2T(n-1),由此我们可得T(n) = 2T(n-1) = (2^2) * T(n-2)......所以我们可以得到以上算法的时间复杂度为O(2^n)。

 

【京东】如下程序的时间复杂度为____(其中m > 1,e > 0)

x = m;

y = 1;

while (x - y > e) {

    x = (x + y) / 2;

    y = m / x;

}

print(x);


以上算法的关键操作即while语句中的两条赋值语句,我们只需要计算这两条语句的执行次数即可。我们可以看到,当x - y > e时,while语句体内的语句就会执行,x = (x + y) / 2使得x不断变小(当y<<x时,执行一次这个语句会使x变为约原来的一半),假定y的值固定在1,那么循环体的执行次数即为~logm,而实际情况是y在每次循环体最后都会被赋值为m / x,这个值总是比y在上一轮循环中的值大,这样一来x-y的值就会更小,所以以上算法的时间复杂度为O(logm)。

 

【搜狗】假设某算法的计算时间可用递推关系式T(n) = 2T(n/2) + n,T(1) = 1表示,则该算法的时间复杂度为____


根据题目给的递推关系式,我们可以进一步得到:T(n) = 2(2T(n/4) + n/2) + n = ... 将递推式进一步展开,我们可以得到该算法的时间复杂度为O(nlogn),这里就不贴上详细过程了。

 

四、参考资料

算法(第4版) (豆瓣)


来自:absfree - 博客园

链接:www.cnblogs.com/absfree/p/5464779.html


文章版权归原作者所有,转载仅供学习使用,不用于任何商业用途,如有侵权请留言联系删除,感谢合作。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/303963.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

浏览器缓存机制的研究分享

源宝导读&#xff1a;互联网Web应用大行其道的今天&#xff0c;浏览器已经成为Web应用运行的重要平台。而Web应用对浏览器缓存机制的高效利用&#xff0c;可以大幅提升应用性能和用户体验。本文将对浏览器缓存机制进行系统化的梳理&#xff0c;分享我们的经验。一、背景计算机读…

Windows2008应用之配置客户端自动添加打印机

打印机对我们每一个人来说都是习以为常的东西了&#xff0c;给你一条远程打印机共享路径&#xff0c;你只要轻轻的双击想安装的打印机图标&#xff0c;等上个三五秒打印机就安装好&#xff0c;这台打印机就任你摆布了。但相对我们IT人员来说&#xff0c;全面的自动化将是我们的…

现代CSS进化史

英文&#xff1a;Peter Jang 编译&#xff1a;缪斯segmentfault.com/a/1190000013191860CSS一直被web开发者认为是最简单也是最难的一门奇葩语言。它的入门确实非常简单——你只需为元素定义好样式属性和值&#xff0c;看起来似乎需要做的工作也就这样嘛&#xff01;然而在一些…

一日一技:Ocelot网关使用IdentityServer4认证

概述Ocelot是一个用.NET Core实现的开源API网关技术。IdentityServer4是一个基于OpenID Connect和OAuth2.0的针对ASP.NET Core的框架&#xff0c;以中间件的形式存在。OAuth是一种授权机制。系统产生一个短期的token&#xff0c;用来代替密码&#xff0c;供第三方应用使用。下面…

php windows共享内存,关于php的共享内存的使用和研究之由起

最近遇到一个场景&#xff0c;服务寻址的时候&#xff0c;需要请求远程的服务&#xff0c;获取一批可用的ip和端口地址及其权重。根据权重和随机算法选择最合适的一个服务地址&#xff0c;进行请求。由于服务地址在短时间之内不会发生变化&#xff0c;因此为了避免无限制的进行…

联想继续为其硬件产品完善Linux支持

喜欢就关注我们吧&#xff01;此前&#xff0c;联想曾宣布为其台式机/笔记本电脑预装 Fedora/Ubuntu 等 Linux 发行版。并通过与 RedHat 等达成合作&#xff0c;为 Linux 带来了更多的上游工作支持。时至今日&#xff0c;据 Phoronix 称&#xff0c;自联想开始提供 Linux 预装以…

Excel有哪些需要熟练掌握而很多人不会的技能!

看完这篇Excel攻略&#xff0c;你会感觉这么多年的excel都白学了&#xff01;来自知乎用户“未央之末”的分享。从今年年初的excel盲&#xff0c;到现在经常从大拿那偷师&#xff0c;也算是成长了不少&#xff0c;慢慢写下来算是对学习excel做个短期回顾——1排版篇给他人发送e…

如何在 ASP.NET Core 中使用 LazyCache

微软的 ASP.NET Core 已经是一个非常流行的用于构建 高性能, 模块化 并能运行在 Windows, Linux, MacOS 上的 WEB 框架&#xff0c;通常能够让程序保持高性能的一个有效途径就是通过缓存热链上的数据来应对高频的请求。LazyCache 是一款基于内存的易于使用和线程安全的缓存组件…

2018全球大学AI排名发布,中国高校表现强势!

[导读] 近日&#xff0c;麻省理工学院马萨诸塞校区计算机与信息科学学院教授 Emery Berger 发布一个全球院校计算机科学领域实力排名的开源项目 CSranking 更新了。目前&#xff0c;卡耐基梅隆大学&#xff08;CMU&#xff09;、麻省理工学院&#xff08;MIT&#xff09;与斯坦…

在C#中使用 CancellationToken 处理异步任务

在 .NET Core 中使用异步编程已经很普遍了&#xff0c; 你在项目中随处可见 async 和 await&#xff0c;它简化了异步操作&#xff0c;允许开发人员&#xff0c;使用同步的方式编写异步代码&#xff0c;你会发现在大部分的异步方法中&#xff0c;都提供了CancellationToken参数…

通过Python实现马尔科夫链蒙特卡罗方法的入门级应用

通过把马尔科夫链蒙特卡罗&#xff08;MCMC&#xff09;应用于一个具体问题&#xff0c;本文介绍了 Python 中 MCMC 的入门级应用。GitHub 地址&#xff1a;https://github.com/WillKoehrsen/ai-projects/blob/master/bayesian/bayesian_inference.ipynb过去几月中&#xff0c;…

315曝光不良奸商 对企业不能罚酒三杯

3月15日&#xff0c;315晚会又曝光了一批无良奸商&#xff0c;虽然315晚会年年曝光&#xff0c;各地政府也迅速跟进打击&#xff0c;但侵害消费者权益的情况却屡见不鲜。从被曝光企业的道歉信来看&#xff0c;“承认错误只是个别问题全面排查整改配合管理部门执法向消费者表示道…

.NET 产品版权保护方案 (.NET源码加密保护)

一. 前言大家好&#xff0c;我是康世杰&#xff0c;大家可以叫我Jason。我和大家一样&#xff0c;都是搞技术出身&#xff0c;也未当过讲师&#xff0c;所以口材有限&#xff0c;如果讲得不好之处&#xff0c;还希望大家多多海含&#xff0c;谢谢。今天是我们第一次见面&…

java地图 热力图,腾讯地图数据可视化之热力图

前言数据可视化API(Web)&#xff0c;是基于腾讯位置服务JavaScript API GL实现的专业地理空间数据可视化渲染引擎。 通过这套API&#xff0c;可以实现轨迹数据、坐标点数据、热力、迁徙、航线等空间数据的可视化展现。使用步骤1、注册成为腾讯位置服务开发者&#xff0c;并进入…

建模分析师与算法工程师的主要区别

大家晚上好&#xff0c;我是新来的实习生小模君&#xff0c;前几天小智老师给我科普了数据挖掘的基础知识&#xff0c;颇有收获&#xff0c;于是就趁小天今天有事休假冒个泡跟大家分享一番。数据挖掘&#xff0c;英文名叫Data mining&#xff0c;一般是指从大型数据库中将隐藏的…

Flurl使用Polly实现重试Policy

❝在使用Flurl作为HttpClient向Server请求时&#xff0c;由于网络或者其它一些原因导致请求会有失败的情况&#xff0c;比如HttpStatusCode.NotFound、HttpStatusCode.ServiceUnavailable、HttpStatusCode.RequestTimeout等&#xff1b;网络上有比较多的HttpClientFactory使用P…

推荐15个 JavaScript 和 CSS 库

Tutorialzine的使命是让开发者与最新的Web开发发展同步。因此&#xff0c;我们每月都会精选一批最优秀的资源推荐给大家&#xff0c;相信这些资源你绝对值得拥有&#xff01;ClarifyJSClarifyJS可以让你串联一串方法&#xff0c;以任意顺序执行。通常的JavaScript方法是从左到右…

Dapr Meetup 3.22【周六】

点击蓝字关注我们Dapr&#xff08;Distributed Application Runtime &#xff0c;分布式应用运行时&#xff09;是微软新推出的&#xff0c;一个可移植的、由事件驱动的运行时&#xff0c;用于跨云和边缘构建分布式应用程序。2019年10月9日&#xff0c;正式以 MIT 协议开源。…

日本老爷爷坚持17年用Excel作画,我可能用了假的Excel···

本文来源自网络说起办公软件Excel&#xff0c;不少人可能同小编一样&#xff0c;谈及色变。想想公式、表格头都大了&#xff0c;今天要介绍的这个人竟然可以用其作画&#xff0c;简直是大写的“丧心病狂”&#xff01;这位传奇人物就是堀内辰男&#xff0c;今年已经77岁了&…

腾讯二面挂了,就因为这个...

牛年跳槽季&#xff0c;惨遭开门黑&#xff0c;谨以此文纪念我的首次腾讯面试经历。经我的老师&#xff0c;微软MVP大佬推荐&#xff0c;有幸拿到了腾讯.NET Core高开面试机会&#xff0c;二面却挂在一个最常见的问题上&#xff0c;“你上家公司电商平台的TPS、QPS是多少&#…