[本专题会对常见的数据结构及相应算法进行分析与总结,并会在每一个系列的博文中提供几道相关的一线互联网企业面试/笔试题来巩固所学及帮助咱们查漏补缺。项目地址:https://github.com/absfree/Algo。因为我的水平有限,叙述中不免存在不清晰准确的地方,但愿你们能够指正,谢谢你们:)] git
在进一步学习数据结构与算法前,咱们应该先掌握算法分析的通常方法。算法分析主要包括对算法的时空复杂度进行分析,但有些时候咱们更关心算法的实际运行性能如何,此外,算法可视化是一项帮助咱们理解算法实际执行过程的实用技能,在分析一些比较抽象的算法时,这项技能尤其实用。在在本篇博文中,咱们首先会介绍如何经过设计实验来量化算法的实际运行性能,而后会介绍算法的时间复杂度的分析方法,咱们还会介绍可以很是便捷的预测算法性能的倍率实验。固然,在文章的末尾,咱们会一块儿来作几道一线互联网的相关面试/笔试题来巩固所学,达到学以至用。github
在介绍算法的时空复杂度分析方法前,咱们先来介绍如下如何来量化算法的实际运行性能,这里咱们选取的衡量算法性能的量化指标是它的实际运行时间。一般这个运行时间与算法要解决的问题规模相关,好比排序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算法的运行时间与问题规模是否知足以上的函数关系呢?在介绍算法时间复杂度的相关内容后,咱们会回过头来再看这个问题。dom
关于算法的时间复杂度,这里咱们先简单介绍下相关的三种符号记法:函数
咱们在日常的算法分析中最经常使用到的是Big O notation。下面咱们将介绍分析算法的时间复杂度的具体方法,若对Big O notation的概念还不是很了解,推荐你们看这篇文章:http://blog.jobbole.com/55184/。性能
这部分咱们将以上面的ThreeSum程序为例,来介绍一下算法时间复杂度的分析方法。为了方便阅读,这里再贴一下上面的程序:
1 public static int count(int[] a) { 2 int N = a.length; 3 int cnt = 0; 4 for (int i = 0; i < N; i++) { 5 for (int j = i + 1; j < N; j++) { 6 for (int k = j + 1; k < N; k++) { 7 if (a[i] + a[j] + a[k] == 0) { 8 cnt++; 9 } 10 } 11 } 12 } 13 return cnt; 14 }
在介绍时间复杂度分析方法前,咱们首先来明确下算法的运行时间究竟取决于什么。直观地想,一个算法的运行时间也就是执行全部程序语句的耗时总和。然而在实际的分析中,咱们并不须要考虑全部程序语句的运行时间,咱们应该作的是集中注意力于最耗时的部分,也就是执行频率最高并且最耗时的操做。也就是说,在对一个程序的时间复杂度进行分析前,咱们要先肯定这个程序中哪些语句的执行占用的它的大部分执行时间,而那些尽管耗时大但只执行常数次(和问题规模无关)的操做咱们能够忽略。咱们选出一个最耗时的操做,经过计算这些操做的执行次数来估计算法的时间复杂度,下面咱们来具体介绍这一过程。
首先咱们看到以上代码的第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语句的执行次数是不变的,也就是说上面算法的运行时间与输入无关。而有些算法的实际运行时间高度依赖于咱们给定的输入,关于这一问题下面咱们进行介绍。
算法的指望运行时间咱们能够理解为,在一般状况下,算法的运行时间是多少。在不少时候,咱们更关心算法的指望运行时间而不是算法在最坏状况下运行时间的上限,由于最坏状况和最好状况发生的几率是比较低的,咱们更常遇到的是通常状况。好比说尽管快速排序算法与归并排序算法的时间复杂度都为O(nlogn),可是在相同的问题规模下,快速排序每每要比归并排序快,所以快速排序算法的指望运行时间要比归并排序的指望时间小。然而在最坏状况下,快速排序的时间复杂度会变为O(n^2),快速排序算法就是一个运行时间依赖于输入的算法,对于这个问题,咱们能够经过打乱输入的待排序数组的顺序来避免发生最坏状况。
下面咱们来介绍一下算法(第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程序以下:
运行倍率程序,咱们能够获得以下输出:
250 0.0 2.0 500 0.1 5.5 1000 0.5 5.4 2000 3.7 7.0 4000 27.4 7.4 8000 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十分接近)。
考虑下咱们以前在 深刻理解数据结构之链表 中提到的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),这里就不贴上详细过程了。