算法手记(4)算法分析
“我的程序会运行多长时间?为什么我的程序耗尽了所有内存?”
在我们使用计算机解决困难问题或是处理大量数据时,不可避免地会产生这些疑问。为这些基础问题给出答案有时其实非常简单,这个过程是科学方法,这就是我们今天讨论的内容。
科学方法概述:
科学家用于观察世界的方法对于研究计算机程序一样有效:
1.观察真实世界特点
2.提出假设模型
3.根据假设模型预测未来事件
4.继续观察并核实预测的准确性
5.如此反复直至确认预测与观察一致
正如爱因斯坦所说:“再多的实验也不一定能够证明我是对的,但只需要一个实验就能证明我是错的”。科学方法的一条关键原则是我们所设计的实验必须是可重现的,我们提出的假设也必须是可证伪的。我们永远无法知道某个假设是绝对正确的,我们只能验证他和我们观察的一致性。
举例:
这里我们编写了一个ThreeSum的程序,用于统计所有和为0的三整数元组的数量。示例代码使用最简单的暴力算法,我们通过对其分析,可以进一步的优化,并了解算法分析的过程。
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; j < N; j++) if (a[i] + a[j] + a[k] == 0) cnt++; return cnt; } }
这里我们同时会问我们的程序会运行多久,我们可以自己编写一个近似计时器,用于度量程序运行时间,使用系统API实现起来很简单.
(1)观察
我们使用多组不同规模的数据输入进上述程序,统计其所耗费时长。根据统计的数据绘制图表,根据图表做出相应分析。
数据: 250 0.0 500 0.0 1000 0.1 2000 0.8 4000 6.4 8000 51.1 ...
实际绘制出图像符合对数曲线特点,我们由此可以猜测关于时间的规律
lg(T(N))=3lgN + lg(a)
~T(N)=aN^3
根据图像数据可以计算出以下公式:
T(N)=9.98*(10^-11)*(N^3)
据此,我们可以做出预测,当N=16000时,程序的运行时间约为6.8分钟
(2)验证
上面做出的猜测需要我们实际运行验证,并多次检验实际运行结果是否符合预测结果。
(3)结论
我们多次运行后发现,实际运行结果近似预测结果,统计的对数图像中的直线完全符合我们对数据符合公式T(N)=aN^3的猜想。
(4)总结
到现在为止,我们分析程序运行时长规律的过程与科学家们尝试理解真实世界奥秘时进行的过程完全相同,我们称以上公式为幂次法则。事实上,许多自然和人工现象都符合幂次法则,因此假设程序运行时间符合幂次法则也是合理的。对于算法的分析,我们有许多数学模型强烈支持这种函数和其他类似的假设,我们会经常接触并学习到它们。
算法优化
现在我们分析了算法的运行规律,下面会根据总结归纳的信息对算法进行相应地优化工作。
D. E. Knuth认为一个程序运行的总时间主要和两点有关:
执行每条语句的耗时;
执行每条语句的频率;
前者取决于计算机,编译器和操作系统,后者取决于程序本身和输入。如果程序的所有部分性质我们都知道,就可以将它们相乘并将程序的所有指令成本相加得到最终的总运行时间。
刚才我们讨论的ThreeSum程序中if语句的执行次数为:
N(N-1)(N-2)/6=1/6*N^3 +1/2*N^2 +N/3
一般在这里,首项之后的其他项都相对较小,所以可以使用近似的方式忽略那些非常复杂但幂次较低,对最终结果贡献无关紧要的项。
所以ThreeSum程序的增量级为立方级别,同时这个规律和机器无关,只由其算法本身决定。可以预料的是在任何计算机上使用任何语言实现的该算法的实现所需运行时间都是和N^3成正比的。实际上,经典算法的大部分性能理论大部分都发表于数十年前,但它们仍适用于今天的计算机。
下面是对一些常见算法的增长数量级的总结
描述 增长的数量级 典型代码 说明 常数级别 1 a=b+c 普通语句 对数级别 logN 二分查找算法 二分策略 线性级别 N for循环 循环 线性对数级别 NlogN 归并排序 分治 平方级别 N^2 双层循环 立方级别 N^3 三层循环 指数级别 2^N 穷举法 穷举查找
根据以上总结的数据,我们可以将ThreeSum程序设计为更快地算法,将其增量级从现有的N^3减小至(N^2)*logN .实现代码如下:
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++) if (rank(-a[i] - a[j], a, j + 1, N) > i) cnt++; return cnt; } //二分查找算法 private static int rank(int key, int[] a, int lo, int hi) { if (lo > hi) return -1; int mid = lo + (hi - lo) / 2; if (key < a[mid]) return rank(key, a, lo, mid - 1); else if (key > a[mid]) return rank(key, a, mid + 1, hi); else return mid; } }
我们已知二分查找算法的增量级别为logN, 所以可以将第三个for循环替换为对a[i]+a[j]和的相反数的查找,如果查找到即三元组数量加一。最终我们可以观察到新算法的结果和原来的暴力算法结果一样正确。
至于是否还会有更快地算法形式,我的回答是不知道,不过专家们相信Three-Sum可能的最优算法是平方级的。
到此本篇博客内容讲述完毕,带来的虽然是入门级的知识,但是所使用的方法适用于我们以后对任何高深算法的研究和优化。