算法1.4 节 ----- 算法分析
科学方法
科学家用来理解自然世界的方法(科学方法)对于研究程序的运行时间同样有效。科学方法包含以下几个方面:
- 采用准确的方法来观察自然界的一些现象
- 假设出一个与观察到的现象一致的模型
- 使用假设来预测事件
- 通过进一步观察来验证预测
- 重复验证,直到假设和观察结果一致
科学方法的一个关键原则是我们设计的实验必须要具有可重现性,以便其他人能够说服自己该假设的有效性。假设也必须是可证伪的,这样我们能确定地知道给定的假设什么时候是错误的(因此需要修正)。我们永远无法确定任何假设是绝对正确的;我们只能验证它是否与我们的观察结果一致。
我们面临的第一个挑战就是如何对程序的运行时间进行定量测量。 很简单,运行这个程序就可以了。你每次运行一个程序,你就是在进行一项实验并回答一个核心问题:我的程序要运行多长时间?
我们对大多数程序的第一个定量观察是,计算任务的难度可以用问题的规模来衡量。正常情况下,问题的规模,或指的是输入的规模,或指的是输入的值。直觉告诉我们,程序的运行时间应该会随着问题的规模增长而增长,但问题是,每次我们开发和运行一个程序时,运行时间会增加多少呢?
许多程序的另一个定量观察是,运行时间对输入值本身相对不敏感,它主要取决于问题的规模。如果这种关系不成立,我们需要采取措施更好地理解,也许更好地控制运行时间对输入的敏感性。但它经常成立,所以我们现在把注意力集中在更好地量化问题规模和运行时间之间的关系。
看一下ThreeSum的例子,Three Sum 就是从N个整数中找出所有和为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; k < N; k++) if (a[i] + a[j] + a[k] == 0) cnt++; return cnt; } }
可以使用Stopwatch类来测量程序的执行时间
class Stopwatch { private final long start; public Stopwatch() { start = System.currentTimeMillis(); } public double elapsedTime() { long now = System.currentTimeMillis(); return (now - start) / 1000.0; } }
执行DoublingTest程序来看一下时间和问题规模之间的关系
import edu.princeton.cs.algs4.StdOut; import edu.princeton.cs.algs4.StdRandom; 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); Stopwatch timer = new Stopwatch(); int cnt = ThreeSum.count(a); return timer.elapsedTime(); } public static void main(String[] args) { // Print table of running times. for (int N = 250; true; N += N) { // Print time for problem size N. double time = timeTrial(N); StdOut.printf("%7d %5.1f\n", N, time); } } }
得出一个结论,如果程序的输入规模是N,那么程序的运行时间T(N) = aN3 , 或者lg(T(N )) = 3 lg N + lg a,a为常数。
数学方法
除了科学方法,数学方法也可以推导出程序的运行时间和输入规模N的关系。简单来说,程序的运行时间是由两个因素决定的。一个是每条语句的执行时间,这由计算机本身决定,你的计算机CPU性能高,执行就快,时间就短。一个是每条语句的执行频率,这通常是程序和输入规模决定的。只要知道这两者,相乘就可以得到程序的运行时间。一个主要的挑战是决定语句的执行频率。有些语句分析起来比较容易,比如 int cnt = 0;, 它就执行了一次。有些就比较复杂了,比如 if(arr[i] + a[j] + a[k] ==0) ,它执行了多少次呢?它执了N (N - 1)(N - 2)/6 次。为什么呢?整个循环加上if语句,表达出来的意思是,从n个数里面选取3个,组合为C(n,3)=n(n-1)(n-2)/(3*2*1)。
这类频率分析可能导致复杂且冗长的数学表达式,比如N (N - 1)(N - 2)/6= N3 /6 - N2 /2 + N/3,这是一种典型的表达式,就是首项(第一项或高阶项)后面的其它项,相对较小。比如当N=1000的时候, -N2 /2 + N/3的值是499,667, 和 N3 /6 =166,666,667 一比较,它就不重要了。我们经常使用~符号来忽略不重要的项,简化我们所处理的数学公式,这个符号使我们以近似的方式工作,它舍弃了低阶项。
当N增长时,函数除以f(N) 趋近于1,我们用~f(N) 来表示这些函数。g(N ) ~ f (N ) 就表示,当N增长时,g(N )/f (N )趋近于1。比如,我们使用近似值~N3 /6来描述ThreeSum函数中if语句的执行次数,因为当N增长时,N3 /6 - N2 /2 + N/3除以N3 /6)趋近于1。通常,我们都使用g(N ) ~ af (N )这种近似方式,其中f (N ) = Nb (log N )c , a, b, and c是常数。f(N) 也称为g(N)的增长阶。当在增长阶中使用对数时,我们一般不指定对数底。g(N ) ~ af (N )这种表达式,覆盖了我们在对程序运行时间研究中经常遇到的几种函数,比如1,n,n3。怎么说呢?
当a=1,b=0,c=0时, g(N ) ~ aNb (log N )c -> g(N ) ~ 1 N0(logN)0 -> g(N) ~ 1 常数增长级。
当a=1,b=0,c=1时, g(N ) ~ aNb (log N )c -> g(N ) ~ 1 N0(logN)1 -> g(N) ~ logN 对数增长级。
当a=1,b=1,c=1时, g(N ) ~ aNb (log N )c -> g(N ) ~ 1 N1(logN)1 -> g(N) ~ NlogN 线性对数增长级。
知道每条语句的执行频率,就能计算出程序的运行时间。根据执行频率将Java程序语句分块,然后计算出执行频率的近似值,如下图所示
决定每条语句的执行时间,执行时间涉及到计算机,编译器,比较复杂,通常用常数t0, t1, t2 ..... 来表示这个代码块的执行时间,
从这里我们观察到一个关键现象是执行最频繁的指令决定了程序的执行总时间--我们将这些执令称为内循环。对于ThreeSum来说,它的内循环是将k加1,判断它是否小N以及判断给定的三个数之和是否为0的语句(也许还包括计数的语句,不过这取决于输入)。这种情况很典型,许多程序的运行时间都只取决于其中的一小部分执令。
成本模型:它是一种模型用来描述当进行算法分析时,我们需要考虑哪些操作(基本操作),它定义了要研究的算法中的基本操作。比如,在陈述排序算法的性能时,我们倾向于将元素的比较和数组的访问看作是程序执行时产生成本的操作,因此在分析排序算法的性能时,我们就考虑它们。从技术上讲,任何操作(甚至分配变量)都会产生成本,但通常我们对足够复杂的操作感兴趣,如果过度执行它们,可能会导致程序变慢。ThreeSum的成本模型就是数组的访问次数。有了成本模型,我们可以对算法的属性,而不是一个特定的算法实现,做出精确的数学陈述,3-sum的暴力算法使用了~ N3/2次数组访问来计算N个整数中和为0的整数三元组的数量。证明一下就是,if语句执行了~ N3/6次,它里面有3次的数组访问,~ N3/6 * 2 = ~ N3/2. 使用成本模型的目的是,一个给定的算法实现的增长阶和它背后的算法成本的增长阶是一致的。换句话说,成本模型应该包含内循环中的操作。
总结一下: 对于许多程序,开发一个运行时间的数学模型变成了以下几步:
- 开发输入模型,包括定义输入规模
- 标识内循环
- 定义成本模型,它包含内循环中操作
- 对于给定的输入,判断这些操作的执行频率,这可能需要进行数学分析。
算法增长阶有,常数,对数,指数等等。它指的什么意思呢?当我们说一个算法的增长阶是N2,或算法的执行时间是N2,我们想表达什么意思?简单来说,一个算法的运行时间是N2, 它要执行N2操作,这些操作是什么呢?它由成本模型决定。如果一个排序算法的的运行时间或增加阶是N2, 它就意味着,给定一个N个元素的数组,算法需要N2次比较(或者数组访问)来排序这个数组。算法增长阶也可以这么理解,当输入规模增大一倍时,它的运行时间增加多少呢?如下图所示
这些图表清晰地表明了用平方级和立方级的算法来解决大规模的问题是不合适的。许多重要问题的直观解决办法是平方级的,但它们也有更聪明的解决办法是线性对数级的。这些算法至关重要,它能让我们解决更大规模的问题。自然的,我们要把注意力集中到对数级,线性级,和线性对数级的算法上来解决基础问题。
设计更快的算法
学习程序算法增长阶的一个主要原因就是帮助我们设计一个更快的算法来解决同一个问题。为了说明这一点,我们考虑一个更快的算法来解决3-sum 问题。先考虑2-sum问题,就是两个数之和等于0的组的个数。在一个数组中,当且仅当a[i] 和-a[i]同时存在。要解决这个问题,先把数组排序,然后对数组中的每一个a[i[,使用BinarySearch中的rank()方法对-a[i]进行二分查找。如果结果为j 且j > i, 就将计数器加1。这个简单测试覆盖了三种情况
一个不成功的二分查找返回-1, 我们不会增加count的值
如果二分查找返回 j > i, a[i] + a[j] = 0, 我们增加count的值
如果二分查找返回的 j 在0到i之间,我们也可以得到a[i] + a[j] = 0, 但不会增加count的值,以避免重复。
public static int count(int[] a) { Arrays.sort(a); int n = a.length; int count = 0; for (int i = 0; i < n; i++) { int k = Arrays.binarySearch(a, -a[i]); if (k > i) count++; } return count; }
Array.sort() 方法使用的是MergeSort,它的时间复杂度为NlogN, 二分查找算法的时间复杂度为logN,所以整个算法的时间复杂度为NlogN。这个想法对3-sum问题同样有效。当且仅当-(a[i]+a[j]) 存在数组中时,a[i]+a[j] -(a[i] + a[j]) = 0. 取出数组第一个数和第二个数,然后在数组中找第一个数+每二个之和的复数,找到了count++,
public static int count(int[] a) { Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) if (Arrays.binarySearch(a, -a[i] - a[j]) > j) cnt++; return cnt; }
倍率实验
以下是一个简单且有效的快捷办法来预测程序的性能和判断它的运行时间增长阶
- 开发一个输入生成器来产生能够模拟实际情况中需要的输入,比如在DoublingTest中的TimeTrial()方法的随机整数。
- 运行以下DoublingRatio 程序,能够计算每次程序运行时间和上一次运行时间的比率
import edu.princeton.cs.algs4.StdOut; import edu.princeton.cs.algs4.StdRandom; import edu.princeton.cs.algs4.Stopwatch; public class DoublingRatio { private static final int MAXIMUM_INTEGER = 1000000; public static double timeTrial(int n) { int[] a = new int[n]; for (int i = 0; i < n; i++) { a[i] = StdRandom.uniform(-MAXIMUM_INTEGER, MAXIMUM_INTEGER); } Stopwatch timer = new Stopwatch(); ThreeSum.count(a); return timer.elapsedTime(); } public static void main(String[] args) { double prev = timeTrial(125); for (int n = 250; true; n += n) { double time = timeTrial(n); StdOut.printf("%7d %7.1f %5.1f\n", n, time, time/prev); prev = time; } } } 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; } }
- 一直运行,直到比率接近于有限值2b
如果比率不能接近一个有限值,这种测试没有效果,但它仍然适用于许多程序,也暗示着也以得出以下结论
- 运行时间的增长阶近似等于Nb
- 要预测程序的运行时间,将上次观察到运行时间乘以2b,并将N加倍,如此反复。
为什么比率会接近一个常数呢?一个简单的数学计算 If T(N) ~ aN b lgN then T(2N)/T(N) ~ 2b .
T(2N)/T(N) = a (2N )b lg (2N ) / a Nb lgN
= 2b (1 + lg 2 / lg N )
~ 2b
注意事项
当试图详细分析程序性能时,有很多原因会导致你可能得到不一致或误导性结果。所有这些都与一个想法有关,那就是在我们假定中的一个或多个基本假设可能不那么完全正确。我们可以根据新的假设得出新的假定,但是我们需要考虑的细节越多,分析就越需要谨慎。
大常数:在首项近似中,我们互略了低阶项中的常数项系数,这可能是不合理的。比如,当我们把函数2N2 + cN近似成2N2时,我们假设c很小。如果事实不是这样(假设c是103或106), 这个近似就是误导人的。因此,我们不得不对大常数的可能性保持敏感。
非决定性的内循环:内循环占决定性地位的假设可能不总是正确的。成本模型可能会错过真正的内部循环,或者问题规模N可以能不那么充分大来使首项比我们忽略的低阶项大。一些程序在内循环外有大量的代码,这些代码也要考虑。换句话说,成本模型可能需要改进。
指令时间:每一个指令总是执行相同的时间的假设不总是正确的。比如,大多数现代计算机系统使用缓存技术来组织系统,在这种情况下,如果数组中元素没有相邻在一起,从巨大的数组中获取它们可能花费更长的时间。
系统因素:典型情况下,有太多太多的东西在你的计算机上运行。Java只是众多争夺计算机资源的应用程序中的一个,Java自身也有许多选项和控制来显著地影响性能。垃圾收集器,或者及时编译,或者网络下载可能极大地影响实验结果。这些因素可能会干扰到实验必须是可重现的这条科学研究的基本原则,因为在某一时刻发生的事情不会重新出现。
很难判断或无法预料(Too close to call), 当比较完成相同任务的两个不同的程序时,经常会出现一种情况,在某些情况下,一个比另一个快,而在另外的情况下,则相反。我们已经提到过的一个或多个因素可能会造成这种差异。有些程序员(一些学生)特别喜欢投入大量的精力进行竟争实验来找到最好的实现,但是这些工作最好交给专家。
强烈信赖输入:为了得到程序运行时间的增长阶,我们做的首要假设之一就是程序的运行时间对输入相对不敏感。如果不是这样的话,我们可能会得到不一致的结果或无法验证我们的假设。比如,假设把ThreeSum 改为返回一个布尔值,cn++替换成return true, 在程序的最后再加一句return false. 通过这个改变来回答一个问题,输入中是否有一个三元组和为0。如果输入中头三个整数的和为0,该程序的运行时间增长阶为常数项基别,如果输入不含有这样的三个数,程序的运行时间增长阶为立方级别。:
多问题参数:我们一直将注意力放在测量单个参数的函数的性能,参数值一般是命令行参数或输入规模。但是,多个参数也非常常见。一个典型的例子就是当算法需要构建数据结构,并使用它执行一系列操作时。数据结构的规模和操作的次数都是这些应用的参数。在使用二分查找分析白名单问题时,我们已经见过这样的例子。这个例子中,白名单中有N个整数,而输入中有M个整数,运行时间和MLogN成正比。
尽管这些问题,对每一个程序员来说,理解每一个程序的运行时间增长阶是有价值的,并且我们描述这些方法功能强大且广泛应用。Knuth的观点是原则上我们可以将这些方法贯彻到最后一个细节,来做出详细且准确预测。典型的计算机系统非常复杂,详细的分析最好留给专家,但是相同的方法用来程序的近似运行时间也是有效的。火箭科学家需要有一些关于试飞是在海洋还是在城市着陆的想法;医学研究人员需知道药物试验是否会杀死或治愈所有受试者;任何使用计算机程序的科学家或工程师需要知道它是否会运行一秒钟或者一年。
处理对于输入的信赖
对于许多程序,在我们刚刚提到的注意事项中,最重要的一个就是对输入的依赖,因为运行时间的变化范围可能非常大。根据输入,修改后的ThreeSum程序运行时间变化范围是从常数级别到立方级别,因此,如果我们想要预测它的性能,就需要对它进行更加详细的分析。在这里,我们简略地考虑一些有效的方法。
输入模型:一种方法是对我们需要解决的问题中要处理的输入类型进行更仔细地建模。比如,我们可能假设ThreeSum输入数字是随机的int值。有两个原因使这种方法充满困难:1,建模可能不现实。2,分析可能极其复杂,需要的数学技巧超过了学生或程序员。原因1更加重要,因为计算的目的就是发现输入的特性。
最坏情况性能保证:一些应用程序要求,无论输入什么,程序的时间都必须小于某个界限。为了提供这样的性能保证,理论家极端悲观地看待算法性能持:在最坏的情况下,运行时间是多少?例如,这种保守的方法可能适用于运行核反应堆、心脏起搏器或汽车制动器的软件。我们想保证这些软件在我们设定的范围内完成它的工作,因为如果不这样做,结果可能是灾难性的。科学家在研究自然界时,通常不会考虑最坏的情况:在生物学中,最坏的情况可能是人类的灭绝;在物理学中,最坏的情况可能是宇宙的终结。但在计算机系统中,最坏的情况可能是一个非常现实的问题,因为输入可能是由另一个(可能是恶意)用户生成的,而不是自然界生成的。 例如,不使用具有性能保证算法的网站会受到拒绝服务攻击,黑客会向其发送大量病态请求,使其运行速度比计划的慢得多。因此,我们许多算法都被设计成提供性能保证。
随机算法:提供性能保证的一个重要方式是引入随机性。例如,在最坏的情况下,快速排序算法是平方级别的,但对随机打乱输入,可以保证其运行时间是线性对数级别的。每次你运行该算法,所需的时间都会有所不同,但时间不是线性对数级别的可能性很小,可以忽略不计。类似地,符号表的哈希算法在最坏情况下是线性时间,但在概率保证下是常数时间。这些保证不是绝对的,但它们无效的几率小于您的计算机被闪电击中的几率。因此,在实践中,这种保证与最坏情况下的保证一样有用。
操作序列(运算过程): 对于许多应用程序来说,算法“输入”可能不仅仅是数据,而使用者执行的操作序列。例如,对于栈来说,使用者压入N个值,然后将它们全部弹出,可能与交替地压入和弹出N个值具有完全不同的性能特征。我们的分析必须考虑这两种情况(或包括操作顺序的合理模型)。
均摊分析:相应地,提供性能保证的另一种方法是通过跟踪所有操作的总成本除以操作次数来均摊成本。在这样的设置下,我们可能允许一些昂贵的操作,同时保持操作的平均成本较低。这类分析的典型例子是研究基于动态调整的数组实现的栈。简间起见,假设N是2的幂,如果数据结构初始为空,N次连续地push()调用会访问多少次的元素数组?数量很容易计算,数组访问的次数是
N + 4 + 8 + 16 + ... + 2N = 5N - 4
前面的N是N次调用push() 方法的数组访问,后面的4+8+16 +... + 2N是对数组扩容成原来2倍的进行的数组初始化操作。因些,即使最后一次操作需要组性级别时间,但每一次操作的数组访问的平均次数是常数项级别。这就是所谓的均摊分析,因为我们把一些非常耗时操作的成本平均分到大量的不耗时的操作上。
简单计算N + 4 + 8 + 16 + ... + 2N。N是2n,因为4+8+16 + ... 2n 的计算公式是【首项*(1-公比的n次方)】/1-公比, 就是(4 *(1-2^n))/1-2 ---> -(4 -4*2^n) --> -4 + 4*2^n, 再加上前面的N,也就是2n, -4 + 4*2^n + 2^n = 5*2n -4 也就5N -4
这和分析广泛应用,尤其是我们使用动态调整大小的数组作为底层的数据结构来实现算法。
算法分析师的任务是发现尽可能多的有关算法的相关信息,应用程序员的任务是应用这些知识来开发有效解决手头问题的程序。理想情况下,我们希望算法能够产生清晰紧凑的代码,在感兴趣的输入值上提供良好的保证和性能。许多经典算法对众多应用都十分重要,因为它们具备这些特性。使用它们作为模型,在编程时遇到典型问题时,你也能够自己开发出好的解决方案。
内存
和运行时间一样,程序的内存使用直接和物理世界相关:大量的计算机电路使程序能够存储值,稍后取出它们。在任何给定的时刻,需要存储的值越多,需要的电路就越多。你可能意识到了计算机内存使用的限制,因为你可能已经花了额外的钱来获得更多的内存。
在你的计算机上,对于Java程序来说,内存的使用被很好的定义了。每一次你运行程序的时候,每一个值需要相同的内存空间。但是实现在非常广泛的计算机设备上,并且内存的使用和实现有关。简单起见,我们使用“典型”这个词来标识值受机器信赖的影响。
Java最重要特性之一就是它的内存分配系统,它应该会把不得不担心内存中解放出来。确时,合话的时候,你被建议利用这个特性。尽管如此,您仍有责任,至少大致了解,程序的内存需求何时会阻止您解决给定的问题。
分析内存的使用相比分析运行时间要简单的多,主要因为没有那么多的程序语句包含其中(仅包含声明语句),并且分析把复杂的对象转化成了简单的原始类型,原始类型的内存使用被很好的定义了,并且很简单,空易理解。每一个变量乘以它们所占的字节,得到每个变量使用的内存空间,再把所有的变量所占的内字空间加起来。比如,典型的Java实现使用32个bit来表示int值,8个bit是一个字节,4个字节来表示int值。相似的,char占用2个字节,double和long占8个字节, 布尔值占1个字节。通过这些数据,再加上能够使用的计算机内存总量,你能计算出内存的限制。比如你的电脑有1GB的内存,你就不能使用3千2百万的int值,或者1千6百万的double值。
另一方面,分析内存的使用总是受到硬件不同和Java实现的影响,所以你要考虑特殊的情况。比如,很多数据结构包含机器地址,每一个地址使用的内存各不相同。为了一致,我们假设,在一个典型的64位的机器上,一个地址占8个字节。在32位的机器上,一个地址占4个字节。
对象:要知道一个对象所使用的内存空间,需要将每一个实量变量所占的内存空间加起来,再加上对象本身所占的空间(一般是16个字节)。对象本身所占的空间包括对类的引用,垃圾收集信息和同步信息。另外,一般内存的使用都会被填充为8字节(64位机器上)的倍数。例如,一个Integer对象会占用24个字节(16个字节的对象本身的开销,4个字节用于保存int值,以及4个填充字节)。相似的,一个Date对象使用32个字节,16个字节的对象本身的开销, 3个int类型(每个占4个字节),4个填充字节。一个对象的引用是一个内存地址,使用8个字节。例如,Counter类占32个字节,16个字节的对象本身的开销, 8个字节用于它的String类实例变量(一个引用),4个填充字节。当我们计算一个引用占多少内存的时候,是和引用的对象本身所占的内存分开的,因此,这个总和,没有包含String值本身所占的内存空间。
链表:一个内部类(Node类)需要8个字节的额外空间,用来引用外部类的实例。因此,Node内部类占用40个字节: 16个字节用于对象本身, Item引用和Node引用各占8个字节, 8个字节用于引用外部类对象. 因为 一个Interger类型占用24个字节,使用链表实现的包含栈N个整数, 需要占用32 + 64N 字节. 32的由来:16个字节用于栈对象本身, 8个字节用于引用实例变量,4个字节用于int类型的变量, 4个字节用于填充. 64个字节用于链表中的每一个节点: 一个node内部类占40个节点,一个integer 占24个字节.
数组: Java中的数组也是用对象实现的, 只是多保存了一个长度属性. 原始类型的数组所占用的空间, 等于24个字节来保存头部信息, 再加上数组中每一个值所占的空间。比如N个int类型的数组需要使用24+4N 的字节空间(当然还要是8的倍数)。N个double类型的数组需要24+8N个字节空间。对象数组保存的都是对象的引用,所以计算对象数组的占用空间时,不仅要计算引用的占用空间,还要计算对象本身的占用空间。比如,一个Date对象数组的占用空间是24个字节(数组本身),加上8N(数组中每一个对象引用),再加上32N个字节 (每一个Date对象占32个字节),总共24+40N